From 45a4811bb424f399515ccb08a49ef5d9f81fb634 Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Wed, 13 May 2026 08:40:19 +0200 Subject: [PATCH 0001/1815] [mitsubishi_cn105] Unified timeout handling (#16385) --- .../mitsubishi_cn105/mitsubishi_cn105.cpp | 56 +++++++++---------- .../mitsubishi_cn105/mitsubishi_cn105.h | 5 +- .../climate/mitsubishi_cn105_tests.cpp | 42 ++++++-------- tests/components/mitsubishi_cn105/common.h | 3 +- 4 files changed, 48 insertions(+), 58 deletions(-) diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index 2a173997f3..56f1ee1b3f 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -8,7 +8,7 @@ namespace esphome::mitsubishi_cn105 { static const char *const TAG = "mitsubishi_cn105.driver"; -static constexpr uint32_t WRITE_TIMEOUT_MS = 2000; +static constexpr uint32_t RESPONSE_TIMEOUT_MS = 2000; static constexpr uint8_t TARGET_TEMPERATURE_ENC_A_OFFSET = 31; @@ -91,25 +91,31 @@ static constexpr auto CONNECT_PACKET = make_packet(PACKET_TYPE_CONNECT_REQUEST, void MitsubishiCN105::initialize() { this->set_state_(State::CONNECTING); } bool MitsubishiCN105::update() { - if (const auto start = this->status_update_start_ms_) { - if (this->pending_updates_.any()) { - this->status_update_wait_credit_ms_ = std::min(this->update_interval_ms_, get_loop_time_ms() - *start); - this->cancel_waiting_and_transition_to_(State::APPLYING_SETTINGS); - return false; - } + switch (this->state_) { + case State::WAITING_FOR_SCHEDULED_STATUS_UPDATE: + if (this->pending_updates_.any()) { + this->status_update_wait_credit_ms_ = + std::min(this->update_interval_ms_, get_loop_time_ms() - this->operation_start_ms_); + this->set_state_(State::APPLYING_SETTINGS); + return false; + } + if (this->has_timed_out_(this->update_interval_ms_)) { + this->set_state_(State::UPDATING_STATUS); + return false; + } + break; - if ((get_loop_time_ms() - *start) >= this->update_interval_ms_) { - this->cancel_waiting_and_transition_to_(State::UPDATING_STATUS); - return false; - } - } + case State::CONNECTING: + case State::UPDATING_STATUS: + case State::APPLYING_SETTINGS: + if (this->has_timed_out_(RESPONSE_TIMEOUT_MS)) { + this->set_state_(State::READ_TIMEOUT); + return false; + } + break; - if (const auto start = this->write_timeout_start_ms_; start && (get_loop_time_ms() - *start) >= WRITE_TIMEOUT_MS) { - this->write_timeout_start_ms_.reset(); - this->frame_parser_.reset(); - this->status_update_wait_credit_ms_ = 0; - this->set_state_(State::READ_TIMEOUT); - return false; + default: + break; } return this->frame_parser_.read_and_parse(this->device_, [this](uint8_t type, const uint8_t *payload, size_t len) { @@ -171,7 +177,6 @@ void MitsubishiCN105::did_transition_(State to) { break; case State::CONNECTED: - this->write_timeout_start_ms_.reset(); this->current_status_msg_type_ = STATUS_MSG_SETTINGS; this->set_state_(State::UPDATING_STATUS); break; @@ -181,7 +186,6 @@ void MitsubishiCN105::did_transition_(State to) { break; case State::STATUS_UPDATED: { - this->write_timeout_start_ms_.reset(); if (this->pending_updates_.any() && this->is_status_initialized()) { this->set_state_(State::APPLYING_SETTINGS); } else if (this->current_status_msg_type_ == STATUS_MSG_SETTINGS && this->should_request_room_temperature_()) { @@ -194,7 +198,7 @@ void MitsubishiCN105::did_transition_(State to) { } case State::SCHEDULE_NEXT_STATUS_UPDATE: - this->status_update_start_ms_ = get_loop_time_ms() - this->status_update_wait_credit_ms_; + this->operation_start_ms_ = get_loop_time_ms() - this->status_update_wait_credit_ms_; this->status_update_wait_credit_ms_ = 0; this->current_status_msg_type_ = STATUS_MSG_SETTINGS; this->set_state_(State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); @@ -205,11 +209,12 @@ void MitsubishiCN105::did_transition_(State to) { break; case State::SETTINGS_APPLIED: - this->write_timeout_start_ms_.reset(); this->set_state_(State::SCHEDULE_NEXT_STATUS_UPDATE); break; case State::READ_TIMEOUT: + this->frame_parser_.reset(); + this->status_update_wait_credit_ms_ = 0; this->set_state_(State::CONNECTING); break; @@ -233,7 +238,7 @@ bool MitsubishiCN105::should_request_room_temperature_() const { void MitsubishiCN105::send_packet_(const uint8_t *packet, size_t len) { FrameParser::dump_buffer_vv("TX", packet, len); this->device_.write_array(packet, len); - this->write_timeout_start_ms_ = get_loop_time_ms(); + this->operation_start_ms_ = get_loop_time_ms(); } void MitsubishiCN105::update_status_() { @@ -241,11 +246,6 @@ void MitsubishiCN105::update_status_() { this->send_packet_(make_packet(PACKET_TYPE_STATUS_REQUEST, payload)); } -void MitsubishiCN105::cancel_waiting_and_transition_to_(State state) { - this->status_update_start_ms_.reset(); - this->set_state_(state); -} - bool MitsubishiCN105::process_rx_packet_(uint8_t type, const uint8_t *payload, size_t len) { switch (type) { case PACKET_TYPE_CONNECT_RESPONSE: diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h index 52b78efccd..60ca81cf9e 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h @@ -124,9 +124,9 @@ class MitsubishiCN105 { bool parse_status_room_temperature_(const uint8_t *payload, size_t len); void send_packet_(const uint8_t *packet, size_t len); void update_status_(); - void cancel_waiting_and_transition_to_(State state); bool should_request_room_temperature_() const; void apply_settings_(); + bool has_timed_out_(uint32_t timeout) const { return ((get_loop_time_ms() - this->operation_start_ms_) >= timeout); } void set_remote_temperature_half_deg_(uint8_t temperature_half_deg); template void send_packet_(const T &packet) { this->send_packet_(packet.data(), packet.size()); } static bool should_transition(State from, State to); @@ -135,9 +135,8 @@ class MitsubishiCN105 { uart::UARTDevice &device_; uint32_t update_interval_ms_{1000}; uint32_t status_update_wait_credit_ms_{0}; + uint32_t operation_start_ms_{0}; uint32_t room_temperature_min_interval_ms_{60000}; - std::optional write_timeout_start_ms_; - std::optional status_update_start_ms_; std::optional last_room_temperature_update_ms_; Status status_{}; State state_{State::NOT_CONNECTED}; diff --git a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp index 7615b62d03..db2fbced1c 100644 --- a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp +++ b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp @@ -16,13 +16,13 @@ TEST(MitsubishiCN105Tests, InitSendsConnectPacket) { ctx.sut.set_current_time(123); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::NOT_CONNECTED); EXPECT_TRUE(ctx.uart.tx.empty()); - EXPECT_FALSE(ctx.sut.write_timeout_start_ms_.has_value()); + EXPECT_EQ(ctx.sut.operation_start_ms_, 0); ctx.sut.initialize(); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x5A, 0x01, 0x30, 0x02, 0xCA, 0x01, 0xA8)); - EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{123}); + EXPECT_EQ(ctx.sut.operation_start_ms_, 123); } TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { @@ -32,8 +32,7 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { ctx.uart.tx.clear(); // Remove first connect packet bytes EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); - EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{0}); - EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value()); + EXPECT_EQ(ctx.sut.operation_start_ms_, 0); // Connect response ctx.uart.push_rx({0xFC, 0x7A, 0x01, 0x30, 0x00, 0x55}); @@ -47,8 +46,7 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS); EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x42, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7B)); - EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{200}); - EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value()); + EXPECT_EQ(ctx.sut.operation_start_ms_, 200); // Clear TX bytes. ctx.uart.tx.clear(); @@ -77,8 +75,7 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS); EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x42, 0x01, 0x30, 0x10, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7A)); - EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{300}); - EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value()); + EXPECT_EQ(ctx.sut.operation_start_ms_, 300); // Clear TX bytes. ctx.uart.tx.clear(); @@ -101,8 +98,7 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { EXPECT_TRUE(ctx.uart.tx.empty()); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); - EXPECT_FALSE(ctx.sut.write_timeout_start_ms_.has_value()); - EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional{400}); + EXPECT_EQ(ctx.sut.operation_start_ms_, 400); } TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) { @@ -115,21 +111,21 @@ TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) { ASSERT_FALSE(ctx.sut.update()); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); EXPECT_TRUE(ctx.uart.tx.empty()); - EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{0}); + EXPECT_EQ(ctx.sut.operation_start_ms_, 0); // Still no response after 1999ms, no retry yet ctx.sut.set_current_time(1999); ASSERT_FALSE(ctx.sut.update()); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); EXPECT_TRUE(ctx.uart.tx.empty()); - EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{0}); + EXPECT_EQ(ctx.sut.operation_start_ms_, 0); // Stop waiting after 2s and retry connect ctx.sut.set_current_time(2000); ASSERT_FALSE(ctx.sut.update()); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x5A, 0x01, 0x30, 0x02, 0xCA, 0x01, 0xA8)); - EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{2000}); + EXPECT_EQ(ctx.sut.operation_start_ms_, 2000); } TEST(MitsubishiCN105Tests, RxWatchdogLimitsProcessingPerUpdate) { @@ -233,15 +229,12 @@ TEST(MitsubishiCN105Tests, NextStatusUpdateAfterUpdateIntervalMilliseconds) { ctx.sut.set_update_interval(2000); ctx.sut.set_current_time(80000); - // No scheduled status update - EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value()); - // Status update completed, schedule next status update ctx.sut.state_ = TestableMitsubishiCN105::State::STATUS_UPDATED; ctx.sut.set_state(TestableMitsubishiCN105::State::SCHEDULE_NEXT_STATUS_UPDATE); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); - EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional{80000}); + EXPECT_EQ(ctx.sut.operation_start_ms_, 80000); // Wait for update_interval (ms) before doing another status update ASSERT_FALSE(ctx.sut.update()); @@ -257,7 +250,7 @@ TEST(MitsubishiCN105Tests, NextStatusUpdateAfterUpdateIntervalMilliseconds) { ASSERT_FALSE(ctx.sut.update()); EXPECT_FALSE(ctx.uart.tx.empty()); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS); - EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value()); + EXPECT_EQ(ctx.sut.operation_start_ms_, 82000); } TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedA) { @@ -382,14 +375,14 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) { ctx.sut.state_ = TestableMitsubishiCN105::State::STATUS_UPDATED; ctx.sut.set_state(TestableMitsubishiCN105::State::SCHEDULE_NEXT_STATUS_UPDATE); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); - EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional{5000}); + EXPECT_EQ(ctx.sut.operation_start_ms_, 5000); EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0); // Nothing to do in update (rx empty, no timeout) ctx.sut.set_current_time(5500); ASSERT_FALSE(ctx.sut.update()); EXPECT_TRUE(ctx.uart.tx.empty()); - EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional{5000}); + EXPECT_EQ(ctx.sut.operation_start_ms_, 5000); EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0); // Write new values @@ -402,7 +395,6 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) { // Waiting for next status update must be interrupted and new values send to AC ctx.sut.set_current_time(6000); ASSERT_FALSE(ctx.sut.update()); - EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value()); EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 1000); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS); EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x0F, 0x00, 0x00, 0x01, 0x00, @@ -414,7 +406,7 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) { ctx.sut.set_current_time(6500); ASSERT_FALSE(ctx.sut.update()); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); - EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional{6500 - 1000}); + EXPECT_EQ(ctx.sut.operation_start_ms_, 6500 - 1000); EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0); } @@ -502,7 +494,7 @@ TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect) ctx.sut.state_ = TestableMitsubishiCN105::State::STATUS_UPDATED; ctx.sut.set_state(TestableMitsubishiCN105::State::SCHEDULE_NEXT_STATUS_UPDATE); ASSERT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); - ASSERT_EQ(ctx.sut.status_update_start_ms_, std::optional{5000}); + ASSERT_EQ(ctx.sut.operation_start_ms_, 5000); ASSERT_EQ(ctx.sut.status_update_wait_credit_ms_, 0); // Interrupt that wait with a write so credit is accumulated. @@ -514,7 +506,7 @@ TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect) ctx.sut.set_current_time(6000); ASSERT_FALSE(ctx.sut.update()); ASSERT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS); - ASSERT_FALSE(ctx.sut.status_update_start_ms_.has_value()); + ASSERT_EQ(ctx.sut.operation_start_ms_, 6000); ASSERT_EQ(ctx.sut.status_update_wait_credit_ms_, 1000); // Do not ACK the write. Advance time far enough to force timeout/reconnect @@ -522,8 +514,8 @@ TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect) ctx.sut.set_current_time(36000); ASSERT_FALSE(ctx.sut.update()); EXPECT_NE(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS); + ASSERT_EQ(ctx.sut.operation_start_ms_, 36000); EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0); - EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value()); } TEST(MitsubishiCN105Tests, SetOutOfRangeRemoteRoomTempIsIgnored) { diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h index d0fdca1ea5..73e09d6c84 100644 --- a/tests/components/mitsubishi_cn105/common.h +++ b/tests/components/mitsubishi_cn105/common.h @@ -44,8 +44,7 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 { using MitsubishiCN105::State; using MitsubishiCN105::UpdateFlag; using MitsubishiCN105::state_; - using MitsubishiCN105::write_timeout_start_ms_; - using MitsubishiCN105::status_update_start_ms_; + using MitsubishiCN105::operation_start_ms_; using MitsubishiCN105::use_temperature_encoding_b_; using MitsubishiCN105::status_update_wait_credit_ms_; using MitsubishiCN105::pending_updates_; From 0e4922a3400d5b186214ff48ee6298be04bcba89 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 May 2026 05:14:19 -0500 Subject: [PATCH 0002/1815] [core] Cache validated config to skip re-validation on upload/logs (#16381) --- esphome/__main__.py | 26 ++- esphome/compiled_config.py | 76 ++++++ esphome/storage_json.py | 24 +- esphome/writer.py | 6 + tests/unit_tests/test_compiled_config.py | 282 +++++++++++++++++++++++ 5 files changed, 409 insertions(+), 5 deletions(-) create mode 100644 esphome/compiled_config.py create mode 100644 tests/unit_tests/test_compiled_config.py diff --git a/esphome/__main__.py b/esphome/__main__.py index bca8672917..d733534a5c 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2434,10 +2434,28 @@ def run_esphome(argv): # Commands that don't need fresh external components: logs just connects # to the device, and clean is about to delete the build directory. skip_external = args.command in ("logs", "clean") - config = read_config( - dict(args.substitution) if args.substitution else {}, - skip_external_update=skip_external, - ) + command_line_substitutions = dict(args.substitution) if args.substitution else {} + + # Fast path for upload/logs: reuse the validated-config cache the + # last compile wrote. Falls back to read_config when missing/stale. + # Skipped when -s overrides are passed, since the cache was written + # against the previous substitution set. + config: ConfigType | None = None + if args.command in ("upload", "logs") and not command_line_substitutions: + from esphome.compiled_config import load_compiled_config + + config = load_compiled_config(conf_path) + if config is not None: + _LOGGER.info( + "Loaded validated config cache for %s, skipping validation.", + conf_path.name, + ) + + if config is None: + config = read_config( + command_line_substitutions, + skip_external_update=skip_external, + ) if config is None: return 2 CORE.config = config diff --git a/esphome/compiled_config.py b/esphome/compiled_config.py new file mode 100644 index 0000000000..92cbb7348a --- /dev/null +++ b/esphome/compiled_config.py @@ -0,0 +1,76 @@ +"""Validated-config cache for the upload/logs fast path. + +compile dumps the validated config to /storage/.validated.yaml; +the next upload/logs for that YAML reuses it instead of running the full +read_config pipeline. YAML round-trip (yaml_util.dump/load_yaml) keeps +!lambda/!include/IDs/paths intact; mtime gates staleness. +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from esphome.core import CORE +from esphome.helpers import write_file +from esphome.storage_json import StorageJSON, ext_storage_path +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) + + +def compiled_config_path(config_filename: str) -> Path: + """Path to the cached validated config alongside the storage sidecar.""" + return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml" + + +def _cache_is_fresh(cache_path: Path, source_path: Path) -> bool: + """True iff the cache file exists and isn't older than the source.""" + try: + return cache_path.stat().st_mtime >= source_path.stat().st_mtime + except OSError: + return False + + +def save_compiled_config(config: ConfigType) -> None: + """Write the validated-config cache. Always-write so mtime stays fresh. + + Mode 0600 because show_secrets=True resolves !secret inline. + Failures are non-fatal: the fast path falls back to read_config. + """ + from esphome import yaml_util + + try: + rendered = yaml_util.dump(config, show_secrets=True) + write_file(compiled_config_path(CORE.config_filename), rendered, private=True) + except Exception as err: # pylint: disable=broad-except + _LOGGER.debug("Skipping compiled config cache write: %s", err) + + +def load_compiled_config(conf_path: Path) -> ConfigType | None: + """Load the cached validated config and apply storage metadata to CORE. + + Returns None (caller falls back to read_config) when the cache is + missing, older than the source YAML, unparseable, or the sidecar + is incomplete. + """ + cache_path = compiled_config_path(conf_path.name) + if not _cache_is_fresh(cache_path, conf_path): + return None + + from esphome import yaml_util + + try: + config = yaml_util.load_yaml(cache_path, clear_secrets=False) + except Exception: # pylint: disable=broad-except + return None + + storage = StorageJSON.load(ext_storage_path(conf_path.name)) + if storage is None: + return None + # apply_to_core assumes a real compile wrote the sidecar; wizard-only + # sidecars leave both of these unset and can't drive upload/logs. + if not storage.core_platform and not storage.target_platform: + return None + storage.apply_to_core() + return config diff --git a/esphome/storage_json.py b/esphome/storage_json.py index c6df16ce78..7d26b22f96 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -8,7 +8,13 @@ import os from pathlib import Path from esphome import const -from esphome.const import CONF_DISABLED, CONF_MDNS +from esphome.const import ( + CONF_DISABLED, + CONF_MDNS, + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, +) from esphome.core import CORE from esphome.helpers import write_file_if_changed from esphome.types import CoreType @@ -256,6 +262,22 @@ class StorageJSON: except Exception: # pylint: disable=broad-except return None + def apply_to_core(self) -> None: + """Populate CORE with the metadata upload/logs read. + + Inverse of :meth:`from_esphome_core`. Keep paired -- a new + attribute upload/logs needs has to be captured there too. + Validator-only fields (loaded_integrations/platforms, + friendly_name) are skipped; the fast path doesn't run + validation and CORE.__init__ defaults them. + """ + CORE.name = self.name + CORE.build_path = self.build_path + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: self.core_platform or self.target_platform.lower(), + KEY_TARGET_FRAMEWORK: self.framework, + } + def __eq__(self, o) -> bool: return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict() diff --git a/esphome/writer.py b/esphome/writer.py index 2fa43fa5eb..cf04e4f8d2 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -7,6 +7,7 @@ import re import time from esphome import loader +from esphome.compiled_config import save_compiled_config from esphome.config import iter_component_configs, iter_components from esphome.const import ( HEADER_FILE_EXTENSIONS, @@ -109,6 +110,11 @@ def update_storage_json() -> None: path = storage_path() old = StorageJSON.load(path) new = StorageJSON.from_esphome_core(CORE, old) + + # Refresh the cache upload/logs read on the next call. + if CORE.config is not None: + save_compiled_config(CORE.config) + if old == new: return diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py new file mode 100644 index 0000000000..34e811b97b --- /dev/null +++ b/tests/unit_tests/test_compiled_config.py @@ -0,0 +1,282 @@ +"""Tests for the validated-config cache used by upload/logs.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +from esphome.__main__ import run_esphome +from esphome.compiled_config import ( + compiled_config_path, + load_compiled_config, + save_compiled_config, +) +from esphome.const import ( + CONF_API, + CONF_ESPHOME, + CONF_NAME, + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, +) +from esphome.core import CORE + +_VALIDATED_CONFIG_YAML = """\ +esphome: + name: lite_test + friendly_name: Lite Test Device +esp32: + board: nodemcu-32s +logger: + baud_rate: 115200 +api: + port: 6053 + encryption: + key: 6dGhpcyBpcyBhIHRlc3Q= +ota: + - platform: esphome + port: 3232 + password: secret +wifi: + ssid: ssid + use_address: 192.168.1.42 +""" + + +def _write_storage(storage_path: Path) -> None: + """Write a vanilla StorageJSON sidecar for the cache tests.""" + storage_path.parent.mkdir(parents=True, exist_ok=True) + data = { + "storage_version": 1, + "name": "lite_test", + "friendly_name": "Lite Test Device", + "comment": None, + "esphome_version": "2026.1.0", + "src_version": 1, + "address": "192.168.1.42", + "web_port": None, + "esp_platform": "ESP32", + "build_path": "/build/lite_test", + "firmware_bin_path": "/build/lite_test/firmware.bin", + "loaded_integrations": ["api", "logger", "ota", "wifi"], + "loaded_platforms": [], + "no_mdns": False, + "framework": "arduino", + "core_platform": "esp32", + } + storage_path.write_text(json.dumps(data)) + + +def _write_cache(cache_path: Path, body: str = _VALIDATED_CONFIG_YAML) -> Path: + """Write the cache file and return it.""" + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_path.write_text(body) + return cache_path + + +def _set_cache_mtime(cache_path: Path, yaml_path: Path, *, offset: int) -> None: + """Force the cache file's mtime relative to the source YAML. + + Positive offset → cache is fresh. Negative → cache is stale. + """ + yaml_stat = yaml_path.stat() + os.utime(cache_path, (yaml_stat.st_atime, yaml_stat.st_mtime + offset)) + + +@pytest.fixture +def fresh_cache_files(tmp_path: Path) -> Path: + """YAML + StorageJSON + cache, all consistent and fresh.""" + yaml_path = tmp_path / "lite_test.yaml" + yaml_path.write_text("esphome:\n name: lite_test\n") + CORE.config_path = yaml_path + + storage_dir = tmp_path / ".esphome" / "storage" + _write_storage(storage_dir / "lite_test.yaml.json") + cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + _set_cache_mtime(cache, yaml_path, offset=5) + + return yaml_path + + +def test_compiled_config_path_lives_alongside_sidecar(setup_core: Path) -> None: + """The cache file shape is predictable from the YAML filename.""" + path = compiled_config_path("device.yaml") + assert path.name == "device.yaml.validated.yaml" + assert path.parent.name == "storage" + + +def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None: + """Fresh cache + sidecar → returns config and populates CORE.""" + config = load_compiled_config(fresh_cache_files) + + assert config is not None + assert config[CONF_ESPHOME][CONF_NAME] == "lite_test" + assert config[CONF_API]["encryption"]["key"] == "6dGhpcyBpcyBhIHRlc3Q=" + assert config["ota"][0]["password"] == "secret" + + # apply_to_core populated exactly what upload/logs read off CORE. + assert CORE.name == "lite_test" + assert CORE.build_path == Path("/build/lite_test") + assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == "esp32" + assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "arduino" + + +@pytest.mark.parametrize( + "scenario", + ["missing_cache", "stale_cache", "corrupt_cache", "missing_sidecar"], +) +def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None: + """All non-happy cases return None so the caller falls back.""" + yaml_path = tmp_path / "lite_test.yaml" + yaml_path.write_text("esphome:\n name: lite_test\n") + CORE.config_path = yaml_path + storage_dir = tmp_path / ".esphome" / "storage" + cache_path = storage_dir / "lite_test.yaml.validated.yaml" + sidecar_path = storage_dir / "lite_test.yaml.json" + + if scenario == "missing_cache": + pass # no cache, no sidecar + elif scenario == "stale_cache": + _write_storage(sidecar_path) + _set_cache_mtime(_write_cache(cache_path), yaml_path, offset=-60) + elif scenario == "corrupt_cache": + _write_storage(sidecar_path) + _set_cache_mtime( + _write_cache(cache_path, "not: valid: yaml: ["), yaml_path, offset=5 + ) + elif scenario == "missing_sidecar": + # Cache fresh + parseable, but no StorageJSON → can't populate CORE. + _set_cache_mtime(_write_cache(cache_path), yaml_path, offset=5) + + assert load_compiled_config(yaml_path) is None + + +@pytest.mark.parametrize("command", ["upload", "logs"]) +def test_run_esphome_upload_and_logs_use_cache_when_fresh( + command: str, + fresh_cache_files: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """upload/logs skip read_config() when the cache is fresh.""" + captured: dict = {} + + def _stub(_args, config): + captured["config"] = config + return 0 + + with ( + caplog.at_level("INFO", logger="esphome.__main__"), + patch("esphome.__main__.read_config") as mock_read, + patch.dict("esphome.__main__.POST_CONFIG_ACTIONS", {command: _stub}), + ): + assert run_esphome(["esphome", command, str(fresh_cache_files)]) == 0 + + mock_read.assert_not_called() + assert captured["config"][CONF_ESPHOME][CONF_NAME] == "lite_test" + assert captured["config"][CONF_API]["encryption"]["key"] == "6dGhpcyBpcyBhIHRlc3Q=" + # The success-branch log line is part of the patch; assert on it so + # branch coverage stays unambiguous in CI. + assert "Loaded validated config cache" in caplog.text + + +@pytest.mark.parametrize("command", ["upload", "logs"]) +def test_run_esphome_upload_and_logs_fall_back_when_no_cache( + tmp_path: Path, command: str +) -> None: + """Without a cache, the dispatcher falls back to read_config().""" + yaml_path = tmp_path / "lite_test.yaml" + yaml_path.write_text("esphome:\n name: lite_test\n") + + with ( + patch("esphome.__main__.read_config", return_value=None) as mock_read, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {command: lambda args, config: 0}, + ), + ): + assert run_esphome(["esphome", command, str(yaml_path)]) == 2 + + mock_read.assert_called_once() + + +def test_run_esphome_upload_with_substitution_skips_cache( + fresh_cache_files: Path, +) -> None: + """`-s key value` forces a fresh validation -- the cache was written + against the prior substitution set, so reusing it would silently + ignore the override.""" + with ( + patch("esphome.__main__.read_config", return_value=None) as mock_read, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {"upload": lambda args, config: 0}, + ), + ): + run_esphome(["esphome", "-s", "var", "val", "upload", str(fresh_cache_files)]) + + mock_read.assert_called_once() + + +def test_run_esphome_compile_does_not_use_cache(fresh_cache_files: Path) -> None: + """The compile subcommand always re-validates -- it's what writes the cache.""" + with ( + patch("esphome.__main__.read_config", return_value=None) as mock_read, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {"compile": lambda args, config: 0}, + ), + ): + run_esphome(["esphome", "compile", str(fresh_cache_files)]) + + mock_read.assert_called_once() + + +def test_save_compiled_config_writes_cache(tmp_path: Path) -> None: + """`save_compiled_config` writes the dumped YAML next to the sidecar.""" + CORE.config_path = tmp_path / "lite_test.yaml" + save_compiled_config({"esphome": {"name": "lite_test"}, "logger": {}}) + + cache_path = compiled_config_path("lite_test.yaml") + assert cache_path.is_file() + body = cache_path.read_text() + assert "name: lite_test" in body + assert "logger:" in body + + +def test_save_compiled_config_swallows_dump_errors( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Failures during the dump are non-fatal -- a bad cache just means + the next fast path falls back to read_config().""" + CORE.config_path = tmp_path / "lite_test.yaml" + with patch("esphome.yaml_util.dump", side_effect=RuntimeError("boom")): + save_compiled_config({"esphome": {"name": "lite_test"}}) + assert not compiled_config_path("lite_test.yaml").exists() + + +def test_load_compiled_config_rejects_wizard_only_sidecar(tmp_path: Path) -> None: + """A wizard-only sidecar (no compile -- no core_platform / target_platform) + can't drive upload/logs, so the fast path falls back.""" + yaml_path = tmp_path / "lite_test.yaml" + yaml_path.write_text("esphome:\n name: lite_test\n") + CORE.config_path = yaml_path + + storage_dir = tmp_path / ".esphome" / "storage" + storage_dir.mkdir(parents=True, exist_ok=True) + # StorageJSON with both core_platform and target_platform unset. + (storage_dir / "lite_test.yaml.json").write_text( + '{"storage_version": 1, "name": "lite_test", "friendly_name": null, ' + '"comment": null, "esphome_version": null, "src_version": 1, ' + '"address": null, "web_port": null, "esp_platform": null, ' + '"build_path": null, "firmware_bin_path": null, ' + '"loaded_integrations": [], "loaded_platforms": [], "no_mdns": false, ' + '"framework": null, "core_platform": null}' + ) + cache_path = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + _set_cache_mtime(cache_path, yaml_path, offset=5) + + assert load_compiled_config(yaml_path) is None From b86652543791a8c17da0c0f061606564f96dbf92 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 May 2026 10:01:11 -0500 Subject: [PATCH 0003/1815] [ci] Skip native ESP-IDF compile test when no relevant files changed (#16395) --- .github/workflows/ci.yml | 12 ++- script/determine-jobs.py | 123 +++++++++++++++++++++ tests/script/test_determine_jobs.py | 160 ++++++++++++++++++++++++++++ 3 files changed, 293 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad39b3f346..06c6c0fec1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -252,6 +252,8 @@ jobs: python-linters: ${{ steps.determine.outputs.python-linters }} import-time: ${{ steps.determine.outputs.import-time }} device-builder: ${{ steps.determine.outputs.device-builder }} + native-idf: ${{ steps.determine.outputs.native-idf }} + native-idf-components: ${{ steps.determine.outputs.native-idf-components }} 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 }} @@ -297,6 +299,8 @@ jobs: echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT echo "import-time=$(echo "$output" | jq -r '.import_time')" >> $GITHUB_OUTPUT echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT + echo "native-idf=$(echo "$output" | jq -r '.native_idf')" >> $GITHUB_OUTPUT + echo "native-idf-components=$(echo "$output" | jq -r '.native_idf_components')" >> $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 @@ -823,10 +827,14 @@ jobs: needs: - common - determine-jobs - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.native-idf == 'true' env: ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf - TEST_COMPONENTS: esp32,api,heatpumpir,bme280_i2c,bh1750,aht10,esp32_ble,esp32_ble_beacon,esp32_ble_client,esp32_ble_server,esp32_ble_tracker,ble_client,ble_presence,ble_rssi,ble_scanner + # Comma-joined subset of the native-IDF representative component list, + # computed by script/determine-jobs.py (native_idf_components_to_test). + # Single source of truth -- the full list lives in + # script/determine-jobs.py::NATIVE_IDF_TEST_COMPONENTS. + TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.native-idf-components }} steps: - name: Check out code from GitHub uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 57b3c6eb88..0a55b2a848 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -495,6 +495,125 @@ def should_run_device_builder(branch: str | None = None) -> bool: return False +# Components tested by the native ESP-IDF compile-test job. This is the +# single source of truth: the workflow reads the comma-joined list from the +# `native-idf-components` output of `determine-jobs` and uses it as the +# `TEST_COMPONENTS` env on the `test-native-idf` job. +NATIVE_IDF_TEST_COMPONENTS = frozenset( + { + "esp32", + "api", + "heatpumpir", + "bme280_i2c", + "bh1750", + "aht10", + "esp32_ble", + "esp32_ble_beacon", + "esp32_ble_client", + "esp32_ble_server", + "esp32_ble_tracker", + "ble_client", + "ble_presence", + "ble_rssi", + "ble_scanner", + } +) + +# Path prefixes whose changes always trigger the native ESP-IDF compile +# test: anything under esphome/espidf/ (the native IDF runner / API / +# framework / component generator). +NATIVE_IDF_TRIGGER_PATH_PREFIXES = ("esphome/espidf/",) + +# Standalone files that, when changed, also trigger the native ESP-IDF +# compile test: +# - esphome/build_gen/espidf.py -- the native IDF build generator +# (other files under build_gen/ target PlatformIO and don't affect +# the native IDF path) +# - script/test_build_components.py -- the harness the job invokes +# - .github/workflows/ci.yml -- the job's own definition +NATIVE_IDF_TRIGGER_FILES = frozenset( + { + "esphome/build_gen/espidf.py", + "script/test_build_components.py", + ".github/workflows/ci.yml", + } +) + + +def _native_idf_path_or_file_trigger(files: list[str]) -> bool: + """Whether any changed file is a native IDF infrastructure / harness trigger.""" + for file in files: + if file in NATIVE_IDF_TRIGGER_FILES: + return True + if any(file.startswith(prefix) for prefix in NATIVE_IDF_TRIGGER_PATH_PREFIXES): + return True + return False + + +def native_idf_components_to_test(branch: str | None = None) -> list[str]: + """Subset of ``NATIVE_IDF_TEST_COMPONENTS`` the job needs to compile. + + The job builds components with the native ESP-IDF toolchain (no + PlatformIO). When only a specific component (or something it depends + on) changed, there's no value in re-building every other unrelated + component in the test list -- the regular ``component-test`` matrix + already covers them via PlatformIO. So we narrow to the intersection + of ``NATIVE_IDF_TEST_COMPONENTS`` and the changed-component dependency + closure. + + Returns the full list (sorted) when we can't safely narrow: + + 1. Core C++/Python files changed (``esphome/core/*``). + 2. Native IDF infrastructure changed (``esphome/espidf/*`` or + ``esphome/build_gen/espidf.py``). + 3. The test harness or workflow itself changed + (``script/test_build_components.py``, ``.github/workflows/ci.yml``). + + Otherwise returns the intersection (sorted), which may be empty -- an + empty list signals the job should be skipped. + + The dependency closure is derived from ``files`` via + ``get_components_with_dependencies()`` (the same primitive ``main()`` + uses) so the result honors ``branch``. ``get_changed_components()`` + is deliberately not used here: it re-invokes ``changed_files()`` with + its own default branch, which would silently ignore our ``branch`` + argument. + + Args: + branch: Branch to compare against. If None, uses default. + + Returns: + Sorted list of component names to compile. + """ + files = changed_files(branch) + + if core_changed(files) or _native_idf_path_or_file_trigger(files): + return sorted(NATIVE_IDF_TEST_COMPONENTS) + + component_files = [f for f in files if filter_component_and_test_files(f)] + changed = get_components_with_dependencies(component_files, True) + + return sorted(NATIVE_IDF_TEST_COMPONENTS & set(changed)) + + +def should_run_native_idf(branch: str | None = None) -> bool: + """Determine if the `test-native-idf` compile-test job should run. + + Runs whenever ``native_idf_components_to_test()`` returns a non-empty + list. Skipping the job on unrelated Python-only PRs avoids ~5 min of + CI per PR (worse on cold caches). The regular ``component-test`` + matrix still exercises the same components through PlatformIO when + those components change. + + Args: + branch: Branch to compare against. If None, uses default. + + Returns: + True if the native ESP-IDF compile test should run, False otherwise. + """ + return bool(native_idf_components_to_test(branch)) + + def determine_cpp_unit_tests( branch: str | None = None, ) -> tuple[bool, list[str]]: @@ -957,6 +1076,8 @@ def main() -> None: run_python_linters = should_run_python_linters(args.branch) run_import_time = should_run_import_time(args.branch) run_device_builder = should_run_device_builder(args.branch) + native_idf_components = native_idf_components_to_test(args.branch) + run_native_idf = bool(native_idf_components) changed_cpp_file_count = count_changed_cpp_files(args.branch) # Get changed components @@ -1102,6 +1223,8 @@ def main() -> None: "python_linters": run_python_linters, "import_time": run_import_time, "device_builder": run_device_builder, + "native_idf": run_native_idf, + "native_idf_components": ",".join(native_idf_components), "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/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 5e2dd670dc..9139c6e095 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -70,6 +70,17 @@ def mock_should_run_device_builder() -> Generator[Mock, None, None]: yield mock +@pytest.fixture +def mock_native_idf_components_to_test() -> Generator[Mock, None, None]: + """Mock native_idf_components_to_test from determine_jobs. + + main() drives both the ``native_idf`` boolean output and the + ``native_idf_components`` CSV from this one function. + """ + with patch.object(determine_jobs, "native_idf_components_to_test") as mock: + yield mock + + @pytest.fixture def mock_determine_cpp_unit_tests() -> Generator[Mock, None, None]: """Mock determine_cpp_unit_tests from helpers.""" @@ -107,6 +118,7 @@ def test_main_all_tests_should_run( mock_should_run_python_linters: Mock, mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, + mock_native_idf_components_to_test: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -122,6 +134,7 @@ def test_main_all_tests_should_run( mock_should_run_python_linters.return_value = True mock_should_run_import_time.return_value = True mock_should_run_device_builder.return_value = True + mock_native_idf_components_to_test.return_value = ["api", "esp32"] mock_determine_cpp_unit_tests.return_value = (False, ["wifi", "api", "sensor"]) # Mock changed_files to return non-component files (to avoid memory impact) @@ -203,6 +216,8 @@ def test_main_all_tests_should_run( assert output["python_linters"] is True assert output["import_time"] is True assert output["device_builder"] is True + assert output["native_idf"] is True + assert output["native_idf_components"] == "api,esp32" 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 @@ -236,6 +251,7 @@ def test_main_no_tests_should_run( mock_should_run_python_linters: Mock, mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, + mock_native_idf_components_to_test: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -251,6 +267,7 @@ def test_main_no_tests_should_run( mock_should_run_python_linters.return_value = False mock_should_run_import_time.return_value = False mock_should_run_device_builder.return_value = False + mock_native_idf_components_to_test.return_value = [] mock_determine_cpp_unit_tests.return_value = (False, []) # Mock changed_files to return no component files @@ -291,6 +308,8 @@ def test_main_no_tests_should_run( assert output["python_linters"] is False assert output["import_time"] is False assert output["device_builder"] is False + assert output["native_idf"] is False + assert output["native_idf_components"] == "" assert output["changed_components"] == [] assert output["changed_components_with_tests"] == [] assert output["component_test_count"] == 0 @@ -313,6 +332,7 @@ def test_main_with_branch_argument( mock_should_run_python_linters: Mock, mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, + mock_native_idf_components_to_test: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -328,6 +348,7 @@ def test_main_with_branch_argument( mock_should_run_python_linters.return_value = True mock_should_run_import_time.return_value = True mock_should_run_device_builder.return_value = True + mock_native_idf_components_to_test.return_value = ["esp32"] mock_determine_cpp_unit_tests.return_value = (False, ["mqtt"]) # Mock changed_files to return non-component files (to avoid memory impact) @@ -366,6 +387,7 @@ def test_main_with_branch_argument( mock_should_run_python_linters.assert_called_once_with("main") mock_should_run_import_time.assert_called_once_with("main") mock_should_run_device_builder.assert_called_once_with("main") + mock_native_idf_components_to_test.assert_called_once_with("main") # Check output captured = capsys.readouterr() @@ -379,6 +401,8 @@ def test_main_with_branch_argument( assert output["python_linters"] is True assert output["import_time"] is True assert output["device_builder"] is True + assert output["native_idf"] is True + assert output["native_idf_components"] == "esp32" 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 @@ -827,6 +851,142 @@ def test_should_run_device_builder_skips_beta_release(target_branch: str) -> Non mock_changed.assert_not_called() +_NATIVE_IDF_FULL_LIST_FILES = [ + # Core C++/Python changes -- caught by core_changed() + ["esphome/core/component.cpp"], + ["esphome/core/config.py"], + # Native IDF infrastructure paths + ["esphome/espidf/framework.py"], + ["esphome/espidf/component.py"], + ["esphome/espidf/api.py"], + ["esphome/build_gen/espidf.py"], + # Workflow / harness files + ["script/test_build_components.py"], + [".github/workflows/ci.yml"], +] + + +@pytest.mark.parametrize("changed_files", _NATIVE_IDF_FULL_LIST_FILES) +def test_native_idf_components_to_test_returns_full_list_on_infrastructure( + changed_files: list[str], +) -> None: + """Infrastructure / core / harness changes fall back to the full component list.""" + with ( + patch.object(determine_jobs, "changed_files", return_value=changed_files), + # The dep-closure path shouldn't be consulted at all -- if it is, + # the obviously-wrong "wifi" sneaks in and the assertion catches it. + patch.object( + determine_jobs, "get_components_with_dependencies", return_value=["wifi"] + ), + ): + result = determine_jobs.native_idf_components_to_test() + assert result == sorted(determine_jobs.NATIVE_IDF_TEST_COMPONENTS) + + +@pytest.mark.parametrize( + ("changed_files", "dependency_closure", "expected"), + [ + # Single tested component changed -- narrow to just that component. + ( + ["esphome/components/esp32/__init__.py"], + ["esp32"], + ["esp32"], + ), + # Dependency closure: multiple BLE components in the changed set + # are all intersected with the test list and returned sorted. + ( + ["esphome/components/esp32_ble/ble.cpp"], + ["esp32_ble", "esp32_ble_tracker", "ble_scanner"], + ["ble_scanner", "esp32_ble", "esp32_ble_tracker"], + ), + # api in the test set -- narrow to [api] even though the closure + # has other (unrelated to native-IDF coverage) entries. + ( + ["esphome/components/api/api_connection.cpp"], + ["api", "logger"], + ["api"], + ), + # Components outside the test set return an empty list (job skipped). + ( + ["esphome/components/wifi/wifi_component.cpp"], + ["wifi", "network"], + [], + ), + # Pure Python-only change outside trigger paths -> empty. + (["esphome/yaml_util.py"], [], []), + # Non-IDF files in esphome/build_gen/ do NOT trigger the full + # list -- only esphome/build_gen/espidf.py is a trigger. + (["esphome/build_gen/platformio.py"], [], []), + # Docs / unrelated files -> empty. + (["README.md"], [], []), + ([], [], []), + ], +) +def test_native_idf_components_to_test_narrowing( + changed_files: list[str], + dependency_closure: list[str], + expected: list[str], +) -> None: + """Component changes narrow the test list to the intersection.""" + with ( + patch.object(determine_jobs, "changed_files", return_value=changed_files), + patch.object( + determine_jobs, + "get_components_with_dependencies", + return_value=dependency_closure, + ), + ): + result = determine_jobs.native_idf_components_to_test() + assert result == expected + + +def test_native_idf_components_to_test_with_branch() -> None: + """native_idf_components_to_test passes branch argument through. + + Regression test: an earlier version called ``get_changed_components()``, + which silently ignored the branch argument because that helper re-runs + ``changed_files()`` with its own default. The current implementation + derives the closure from ``files = changed_files(branch)`` directly, + so a branch arg has to flow through ``changed_files``. + """ + with ( + patch.object(determine_jobs, "changed_files") as mock_changed, + patch.object( + determine_jobs, "get_components_with_dependencies", return_value=[] + ), + ): + mock_changed.return_value = [] + determine_jobs.native_idf_components_to_test("release") + mock_changed.assert_called_once_with("release") + + +@pytest.mark.parametrize( + ("components_to_test", "expected"), + [ + ([], False), + (["esp32"], True), + (["esp32", "api"], True), + ], +) +def test_should_run_native_idf(components_to_test: list[str], expected: bool) -> None: + """should_run_native_idf is a thin wrapper around the component list.""" + with patch.object( + determine_jobs, + "native_idf_components_to_test", + return_value=components_to_test, + ): + assert determine_jobs.should_run_native_idf() is expected + + +def test_should_run_native_idf_with_branch() -> None: + """Test should_run_native_idf passes branch argument through.""" + with patch.object( + determine_jobs, "native_idf_components_to_test", return_value=[] + ) as mock_inner: + determine_jobs.should_run_native_idf("release") + mock_inner.assert_called_once_with("release") + + @pytest.mark.parametrize( ("changed_files", "expected_result"), [ From 8bce32ec35b3506bfc5950487d7c5bf8df18e06f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 May 2026 10:01:26 -0500 Subject: [PATCH 0004/1815] [tests] Cover top-level !include failure path in track_yaml_loads (#16396) --- tests/unit_tests/test_yaml_util.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 3815ac1d75..ace92fbf6f 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -390,6 +390,21 @@ def test_track_yaml_loads_cleanup_on_exception(tmp_path: Path) -> None: assert len(yaml_util._load_listeners) == before +def test_track_yaml_loads_no_duplicate_load_on_top_level_include_failure( + tmp_path: Path, +) -> None: + """A failed top-level !include must not record any file twice in track_yaml_loads.""" + main = tmp_path / "main.yaml" + main.write_text("!include missing.yaml\n") + + with yaml_util.track_yaml_loads() as loaded, pytest.raises(EsphomeError): + yaml_util.load_yaml(main) + + assert len(loaded) == len(set(loaded)), ( + f"Files loaded more than once during a failed top-level include: {loaded}" + ) + + @pytest.mark.parametrize( "data", [ From cb520cda6bf111cb333a8b71ffb067a00df7ab7e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 May 2026 10:01:42 -0500 Subject: [PATCH 0005/1815] [core] Retry PlatformIO downloads on transport-layer errors (#16397) --- esphome/platformio/runner.py | 12 +- tests/unit_tests/test_platformio_toolchain.py | 121 ++++++++++++++++++ 2 files changed, 129 insertions(+), 4 deletions(-) diff --git a/esphome/platformio/runner.py b/esphome/platformio/runner.py index 976979dc57..caab47dcc2 100644 --- a/esphome/platformio/runner.py +++ b/esphome/platformio/runner.py @@ -51,9 +51,13 @@ def patch_file_downloader() -> None: """Retry PlatformIO package downloads with exponential backoff. PlatformIO's ``FileDownloader`` uses an ``HTTPSession`` without built-in - retry for 502/503 errors. We wrap ``__init__`` to retry on - ``PackageException`` and close the session between attempts so a new - TCP connection can route to a different CDN edge node. + retry. We wrap ``__init__`` to retry on transient failures and close the + session between attempts so a new TCP connection can route to a different + CDN edge node. We catch both ``PackageException`` (raised when the server + returns a non-200 status such as 502/503) and ``OSError`` -- which covers + ``requests.exceptions.ConnectionError``, ``ReadTimeout``, and + ``ChunkedEncodingError`` (all subclasses of ``OSError``) that get raised + when the connection is aborted before a response is parsed. """ from platformio.package.download import FileDownloader from platformio.package.exception import PackageException @@ -70,7 +74,7 @@ def patch_file_downloader() -> None: try: original_init(self, *args, **kwargs) return - except PackageException as e: + except (PackageException, OSError) as e: if attempt < max_retries - 1: delay = 2 ** (attempt + 1) _LOGGER.warning( diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index f771437dd4..c1d16530cb 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -2,10 +2,13 @@ # pylint: disable=protected-access +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json import os from pathlib import Path import shutil +import threading from types import SimpleNamespace from unittest.mock import MagicMock, Mock, call, patch @@ -867,6 +870,56 @@ def test_patch_file_downloader_closes_session_and_response_between_retries() -> mock_session.close.assert_called_once() +def test_patch_file_downloader_retries_on_connection_error() -> None: + """Test patch_file_downloader retries on transport-layer errors (OSError subclasses). + + ``requests.exceptions.ConnectionError`` and ``ReadTimeout`` subclass + ``OSError`` and are raised when the connection is aborted before any HTTP + response is parsed -- e.g. ``RemoteDisconnected`` mid-download. These must + retry too, not just ``PackageException``. + """ + mock_exception_cls = type("PackageException", (Exception,), {}) + call_count = 0 + + def failing_init(self, *args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise ConnectionError( + f"Connection aborted attempt {call_count}: RemoteDisconnected" + ) + + with ( + patch.dict( + "sys.modules", + { + "platformio": MagicMock(), + "platformio.package": MagicMock(), + "platformio.package.download": SimpleNamespace( + FileDownloader=type( + "FileDownloader", (), {"__init__": failing_init} + ) + ), + "platformio.package.exception": SimpleNamespace( + PackageException=mock_exception_cls + ), + }, + ), + patch("time.sleep") as mock_sleep, + ): + runner.patch_file_downloader() + + from platformio.package.download import FileDownloader + + instance = object.__new__(FileDownloader) + FileDownloader.__init__(instance, "http://example.com/file.zip") + + assert call_count == 3 + assert mock_sleep.call_count == 2 + mock_sleep.assert_any_call(2) + mock_sleep.assert_any_call(4) + + def test_patch_file_downloader_idempotent() -> None: """Test patch_file_downloader does not stack wrappers when called multiple times.""" mock_exception_cls = type("PackageException", (Exception,), {}) @@ -903,6 +956,74 @@ def test_patch_file_downloader_idempotent() -> None: assert call_count == 1 +@contextmanager +def _flaky_http_server(fail_first_n: int, fail_mode: str): + """Local HTTP server that fails the first ``fail_first_n`` requests. + + ``fail_mode="drop"`` closes the TCP connection without responding, so + the client raises ``RemoteDisconnected`` -- the exact CI failure mode. + ``fail_mode="502"`` returns an HTTP 502, triggering ``PackageException``. + """ + state = {"hits": 0} + + class _Handler(BaseHTTPRequestHandler): + def handle_one_request(self) -> None: + state["hits"] += 1 + if state["hits"] <= fail_first_n and fail_mode == "drop": + return # Skip read+respond → kernel sends FIN → RemoteDisconnected + super().handle_one_request() + + def do_GET(self) -> None: # noqa: N802 + if state["hits"] <= fail_first_n and fail_mode == "502": + self.send_error(502) + return + body = b"esphome-test-payload" + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args: object) -> None: # noqa: A002 + pass # silence default stderr logging + + server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server.server_address[1], state + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +@pytest.mark.parametrize("fail_mode", ["drop", "502"]) +def test_patch_file_downloader_recovers_against_real_server( + tmp_path: Path, fail_mode: str +) -> None: + """End-to-end: real PlatformIO ``FileDownloader`` against a local server + that fails twice then succeeds. Exercises the real + requests/urllib3/http.client stack for both failure modes: + + - ``drop``: TCP close mid-request → ``RemoteDisconnected`` → caught as + ``OSError`` by the retry patch (the CI failure path). + - ``502``: HTTP error response → ``PackageException`` (the original path). + """ + runner.patch_file_downloader() + from platformio.package.download import FileDownloader + + with ( + _flaky_http_server(fail_first_n=2, fail_mode=fail_mode) as (port, state), + patch("time.sleep"), + ): + fd = FileDownloader(f"http://127.0.0.1:{port}/payload.bin") + fd.set_destination(str(tmp_path / "out.bin")) + fd.start(with_progress=False, silent=True) + + assert state["hits"] == 3 # 2 failures + 1 success + assert (tmp_path / "out.bin").read_bytes() == b"esphome-test-payload" + + def _filter_through_redirect(line: str) -> str: """Write a line through RedirectText with FILTER_PLATFORMIO_LINES and return what passes.""" import io From 3fee97ae5a81e67f0bc0c2a2f3437949f396ad48 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 13 May 2026 12:08:51 -0400 Subject: [PATCH 0006/1815] [espidf] Partition pio_components cache by framework (#16401) --- esphome/espidf/component.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index bb675d2c77..8cd77dc6d1 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -86,7 +86,10 @@ class URLSource(Source): self.url = url def download(self, dir_suffix: str, force: bool = False) -> Path: - base_dir = Path(CORE.data_dir) / DOMAIN + # Partition by framework: generated idf_component.yml content + # depends on CORE.using_arduino, so caches can't be shared. + framework = "arduino" if CORE.using_arduino else "idf" + base_dir = Path(CORE.data_dir) / DOMAIN / framework h = hashlib.new("sha256") h.update(self.url.encode()) path = base_dir / h.hexdigest()[:8] / dir_suffix @@ -121,11 +124,12 @@ class GitSource(Source): self.ref = ref def download(self, dir_suffix: str, force: bool = False) -> Path: + framework = "arduino" if CORE.using_arduino else "idf" path, _ = git.clone_or_update( url=self.url, ref=self.ref, refresh=git.NEVER_REFRESH if not force else None, - domain=DOMAIN, + domain=f"{DOMAIN}/{framework}", submodules=[], subpath=Path(dir_suffix), ) From d7b00047bd2d359c4ca56d38db8c012d9965e835 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 13 May 2026 12:27:06 -0400 Subject: [PATCH 0007/1815] [espidf] Emit -W warning flags at project scope so managed components also see them (#16403) --- esphome/build_gen/espidf.py | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index dfe2d72b9d..82c8537bef 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -54,11 +54,22 @@ def get_project_cmakelists() -> str: variant = get_esp32_variant() idf_target = variant.lower().replace("-", "") - # Extract compile definitions from build flags (-DXXX -> XXX) - compile_defs = [flag for flag in sorted(CORE.build_flags) if flag.startswith("-D")] + # Project-wide compile options: -D defines and -W warning flags (skip + # -Wl, linker flags — those go on the src component via + # target_link_options below). Emitted via idf_build_set_property so the + # flags propagate to every IDF component (including managed ones like + # esphome__micro-mp3) rather than just src/. Required so suppressions + # like ``-Wno-error=maybe-uninitialized`` actually silence warnings in + # third-party components we don't author. + project_compile_opts = [ + flag + for flag in sorted(CORE.build_flags) + if flag.startswith("-D") + or (flag.startswith("-W") and not flag.startswith("-Wl,")) + ] extra_compile_options = "\n".join( - f'idf_build_set_property(COMPILE_OPTIONS "{compile_def}" APPEND)' - for compile_def in compile_defs + f'idf_build_set_property(COMPILE_OPTIONS "{flag}" APPEND)' + for flag in project_compile_opts ) return f"""\ @@ -107,15 +118,9 @@ def get_component_cmakelists(minimal: bool = False) -> str: idf_requires = [] if minimal else (get_available_components() or []) requires_str = " ".join(idf_requires) - # Extract compile options (-W flags, excluding linker flags) - compile_opts = [ - flag - for flag in CORE.build_flags - if flag.startswith("-W") and not flag.startswith("-Wl,") - ] - compile_opts_str = "\n ".join(sorted(compile_opts)) if compile_opts else "" - - # Extract linker options (-Wl, flags) + # Extract linker options (-Wl, flags). Compile flags (-D, -W) are + # emitted project-wide via idf_build_set_property in + # get_project_cmakelists so they reach every component, not just src/. link_opts = [flag for flag in CORE.build_flags if flag.startswith("-Wl,")] link_opts_str = "\n ".join(sorted(link_opts)) if link_opts else "" @@ -137,11 +142,6 @@ idf_component_register( # Apply C++ standard target_compile_features(${{COMPONENT_LIB}} PUBLIC cxx_std_20) -# ESPHome compile options -target_compile_options(${{COMPONENT_LIB}} PUBLIC - {compile_opts_str} -) - # ESPHome linker options target_link_options(${{COMPONENT_LIB}} PUBLIC {link_opts_str} From 445d841229ec155fa13c6752ec54451fde553e1b Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Wed, 13 May 2026 18:49:32 +0200 Subject: [PATCH 0008/1815] [mitsubishi_cn105] Simplified protocol lookups (#16399) --- .../mitsubishi_cn105/mitsubishi_cn105.cpp | 73 +++++++++++-------- 1 file changed, 41 insertions(+), 32 deletions(-) diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index 56f1ee1b3f..3a42616d8a 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -30,44 +30,53 @@ static constexpr uint8_t STATUS_MSG_ROOM_TEMP = 0x03; static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_REQUEST = 0x41; static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_RESPONSE = 0x61; -static constexpr std::array, 9> PROTOCOL_MODE_MAP = { - std::nullopt, // 0x00 +template struct LookupMap { + using value_type = decltype(Unknown); + static constexpr auto UNKNOWN_VALUE = Unknown; + const std::array table; + + constexpr value_type lookup(uint8_t raw) const { return (raw < N) ? this->table[raw] : UNKNOWN_VALUE; } + + constexpr bool reverse_lookup(value_type value, uint8_t &out) const { + static_assert(N <= std::numeric_limits::max()); + if (value == UNKNOWN_VALUE) { + return false; + } + for (uint8_t i = 0; i < static_cast(N); ++i) { + if (this->table[i] == value) { + out = i; + return true; + } + } + return false; + } +}; + +template static constexpr auto make_map(const T (&values)[N]) { + return LookupMap{std::to_array(values)}; +} + +static constexpr auto PROTOCOL_MODE_MAP = make_map({ + MitsubishiCN105::Mode::UNKNOWN, // 0x00 MitsubishiCN105::Mode::HEAT, // 0x01 MitsubishiCN105::Mode::DRY, // 0x02 MitsubishiCN105::Mode::COOL, // 0x03 - std::nullopt, // 0x04 - std::nullopt, // 0x05 - std::nullopt, // 0x06 + MitsubishiCN105::Mode::UNKNOWN, // 0x04 + MitsubishiCN105::Mode::UNKNOWN, // 0x05 + MitsubishiCN105::Mode::UNKNOWN, // 0x06 MitsubishiCN105::Mode::FAN_ONLY, // 0x07 MitsubishiCN105::Mode::AUTO // 0x08 -}; +}); -static constexpr std::array, 7> PROTOCOL_FAN_MODE_MAP = { +static constexpr auto PROTOCOL_FAN_MODE_MAP = make_map({ MitsubishiCN105::FanMode::AUTO, // 0x00 MitsubishiCN105::FanMode::QUIET, // 0x01 MitsubishiCN105::FanMode::SPEED_1, // 0x02 MitsubishiCN105::FanMode::SPEED_2, // 0x03 - std::nullopt, // 0x04 + MitsubishiCN105::FanMode::UNKNOWN, // 0x04 MitsubishiCN105::FanMode::SPEED_3, // 0x05 MitsubishiCN105::FanMode::SPEED_4 // 0x06 -}; - -template -static constexpr std::optional lookup(const std::array, N> &table, uint8_t value) { - return (value < N) ? table[value] : std::nullopt; -} - -template -static constexpr bool reverse_lookup(const std::array, N> &table, T value, uint8_t &placeholder) { - for (size_t i = 0; i < N; ++i) { - const auto &table_value = table[i]; - if (table_value.has_value() && table_value == value) { - placeholder = i; - return true; - } - } - return false; -} +}); static constexpr uint8_t checksum(const uint8_t *bytes, size_t length) { return static_cast(0xFC - std::accumulate(bytes, bytes + length, uint8_t{0})); @@ -323,11 +332,11 @@ bool MitsubishiCN105::parse_status_settings_(const uint8_t *payload, size_t len) if (!this->pending_updates_.contains(UpdateFlag::MODE)) { const bool i_see = payload[3] > 0x08; - this->status_.mode = lookup(PROTOCOL_MODE_MAP, payload[3] - (i_see ? 0x08 : 0)).value_or(Mode::UNKNOWN); + this->status_.mode = PROTOCOL_MODE_MAP.lookup(payload[3] - (i_see ? 0x08 : 0)); } if (!this->pending_updates_.contains(UpdateFlag::FAN)) { - this->status_.fan_mode = lookup(PROTOCOL_FAN_MODE_MAP, payload[5]).value_or(FanMode::UNKNOWN); + this->status_.fan_mode = PROTOCOL_FAN_MODE_MAP.lookup(payload[5]); } return true; @@ -382,7 +391,7 @@ void MitsubishiCN105::set_target_temperature(float target_temperature) { void MitsubishiCN105::set_mode(Mode mode) { uint8_t placeholder; - if (!reverse_lookup(PROTOCOL_MODE_MAP, mode, placeholder)) { + if (!PROTOCOL_MODE_MAP.reverse_lookup(mode, placeholder)) { ESP_LOGD(TAG, "Setting invalid mode: %u", static_cast(mode)); return; } @@ -392,7 +401,7 @@ void MitsubishiCN105::set_mode(Mode mode) { void MitsubishiCN105::set_fan_mode(FanMode fan_mode) { uint8_t placeholder; - if (!reverse_lookup(PROTOCOL_FAN_MODE_MAP, fan_mode, placeholder)) { + if (!PROTOCOL_FAN_MODE_MAP.reverse_lookup(fan_mode, placeholder)) { ESP_LOGD(TAG, "Setting invalid fan mode: %u", static_cast(fan_mode)); return; } @@ -432,12 +441,12 @@ void MitsubishiCN105::apply_settings_() { } if (this->pending_updates_.contains(UpdateFlag::MODE) && - reverse_lookup(PROTOCOL_MODE_MAP, this->status_.mode, payload[4])) { + PROTOCOL_MODE_MAP.reverse_lookup(this->status_.mode, payload[4])) { payload[1] |= 0x02; } if (this->pending_updates_.contains(UpdateFlag::FAN) && - reverse_lookup(PROTOCOL_FAN_MODE_MAP, this->status_.fan_mode, payload[6])) { + PROTOCOL_FAN_MODE_MAP.reverse_lookup(this->status_.fan_mode, payload[6])) { payload[1] |= 0x08; } From 03f5e4775cee9b13d8a8d09af63117f6e1687cae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 May 2026 12:06:20 -0500 Subject: [PATCH 0009/1815] [tests] Add CodSpeed benchmark for compiled-config cache fast path (#16402) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 5 +- requirements_test.txt | 5 + tests/benchmarks/python/__init__.py | 0 tests/benchmarks/python/conftest.py | 22 ++++ .../fixtures/bluetooth_proxy_device.yaml | 62 ++++++++++ .../python/test_compiled_config_bench.py | 116 ++++++++++++++++++ 6 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 tests/benchmarks/python/__init__.py create mode 100644 tests/benchmarks/python/conftest.py create mode 100644 tests/benchmarks/python/fixtures/bluetooth_proxy_device.yaml create mode 100644 tests/benchmarks/python/test_compiled_config_bench.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06c6c0fec1..819dac926e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -423,7 +423,10 @@ jobs: - name: Run CodSpeed benchmarks uses: CodSpeedHQ/action@3194d9a39c4d46684cb44bf7207fc56626aad8fd # v4.15.1 with: - run: ${{ steps.build.outputs.binary }} + run: | + . venv/bin/activate + ${{ steps.build.outputs.binary }} + pytest tests/benchmarks/python/ --codspeed --no-cov mode: simulation clang-tidy-single: diff --git a/requirements_test.txt b/requirements_test.txt index 568d79d676..218bc0083c 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -13,5 +13,10 @@ pytest-xdist==3.8.0 asyncmock==0.4.2 hypothesis==6.92.1 +# CodSpeed benchmarks under tests/benchmarks/python/ +# (skipped via pytest.importorskip when missing -- only required for the +# benchmarks job in .github/workflows/ci.yml) +pytest-codspeed==5.0.1 + # Used by the import-time regression check (.github/workflows/ci.yml → import-time job) importtime-waterfall==1.0.0 diff --git a/tests/benchmarks/python/__init__.py b/tests/benchmarks/python/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/benchmarks/python/conftest.py b/tests/benchmarks/python/conftest.py new file mode 100644 index 0000000000..9b0f1a3d2b --- /dev/null +++ b/tests/benchmarks/python/conftest.py @@ -0,0 +1,22 @@ +"""Shared fixtures for the Python benchmark suite.""" + +from __future__ import annotations + +from collections.abc import Generator + +import pytest + +from esphome.core import CORE + + +@pytest.fixture(autouse=True) +def reset_core_state() -> Generator[None]: + """Reset CORE before and after every benchmark. + + Per-iteration setups inside benchmarks reset CORE for the loop body; + this fixture handles the test-level boundary so stale state from + fixture priming doesn't leak across benchmarks. + """ + CORE.reset() + yield + CORE.reset() diff --git a/tests/benchmarks/python/fixtures/bluetooth_proxy_device.yaml b/tests/benchmarks/python/fixtures/bluetooth_proxy_device.yaml new file mode 100644 index 0000000000..dfa5a487b8 --- /dev/null +++ b/tests/benchmarks/python/fixtures/bluetooth_proxy_device.yaml @@ -0,0 +1,62 @@ +substitutions: + devicename: bluetooth_proxy_device + friendly_name: bluetooth_proxy_device + +esphome: + name: $devicename + friendly_name: $friendly_name + +esp32: + board: esp32-poe-iso + framework: + type: esp-idf + advanced: + sram1_as_iram: true + minimum_chip_revision: "3.0" + +esp32_ble_tracker: + scan_parameters: + active: false + +bluetooth_proxy: + active: true + +ethernet: + type: LAN8720 + mdc_pin: GPIO23 + mdio_pin: GPIO18 + clk_mode: GPIO17_OUT + phy_addr: 0 + power_pin: GPIO12 + +debug: +logger: +api: +ota: + platform: esphome + +button: + - platform: restart + name: Restart + +time: + - platform: homeassistant + id: homeassistant_time + - platform: sntp + id: sntp_time + +sensor: + - platform: uptime + name: Ethernet Uptime + - platform: template + name: Free Memory + lambda: return heap_caps_get_free_size(MALLOC_CAP_INTERNAL); + unit_of_measurement: B + state_class: measurement + - platform: debug + free: + name: Heap Free + fragmentation: + name: Heap Fragmentation + min_free: + name: Heap Min Free diff --git a/tests/benchmarks/python/test_compiled_config_bench.py b/tests/benchmarks/python/test_compiled_config_bench.py new file mode 100644 index 0000000000..5c8892f8d0 --- /dev/null +++ b/tests/benchmarks/python/test_compiled_config_bench.py @@ -0,0 +1,116 @@ +"""CodSpeed benchmarks for the validated-config cache fast path. + +PR #16381 added a cache that lets ``esphome upload`` / ``esphome logs`` +skip re-running the full config-validation pipeline. These benchmarks +compare the cached path (``load_compiled_config``) against the slow +path (``read_config``) on the same input. + +The fixture YAML is a modest bluetooth-proxy device. The two paths +end up close on a config this small -- the win grows with config +complexity (external components, large package trees, deeply nested +schemas), where the slow path can be orders of magnitude slower than +the cache load. + +Skipped when ``pytest-codspeed`` isn't installed so the regular +unit-test suite keeps working unchanged. +""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +import shutil +from typing import Any + +import pytest + +from esphome.compiled_config import compiled_config_path, load_compiled_config +from esphome.config import read_config +from esphome.core import CORE +from esphome.storage_json import ext_storage_path +from esphome.writer import update_storage_json + +pytest.importorskip("pytest_codspeed") + +HERE = Path(__file__).parent +FIXTURE_YAML = HERE / "fixtures" / "bluetooth_proxy_device.yaml" + + +def _stage_yaml(tmp_path: Path) -> Path: + """Copy fixture YAML into a fresh tmp dir. + + Each benchmark gets its own copy so the cache files (under + ``.esphome/storage/`` next to the YAML) don't bleed between cases. + """ + target = tmp_path / FIXTURE_YAML.name + shutil.copy2(FIXTURE_YAML, target) + return target + + +def _prime_cache(yaml_path: Path) -> None: + """Run full validation once and persist the cache + sidecar. + + Mirrors ``esphome compile``: ``read_config`` populates ``CORE.config``, + then ``update_storage_json`` writes both the StorageJSON sidecar and + the ``.validated.yaml`` compiled-config cache. + """ + CORE.config_path = yaml_path + config = read_config({}, skip_external_update=True) + assert config is not None, f"fixture YAML failed to validate: {yaml_path}" + CORE.config = config + update_storage_json() + + +@pytest.fixture +def staged_yaml(tmp_path: Path) -> Path: + """YAML copied into tmp_path; no cache files written yet.""" + return _stage_yaml(tmp_path) + + +@pytest.fixture +def primed_yaml(staged_yaml: Path) -> Path: + """YAML plus a fresh cache + sidecar on disk.""" + _prime_cache(staged_yaml) + assert compiled_config_path(staged_yaml.name).is_file() + assert ext_storage_path(staged_yaml.name).is_file() + return staged_yaml + + +def _resetting_setup( + yaml_path: Path, + args: tuple[Any, ...], + kwargs: dict[str, Any], +) -> Callable[[], tuple[tuple[Any, ...], dict[str, Any]]]: + """Build a per-iteration setup that resets CORE and re-pins config_path.""" + + def setup() -> tuple[tuple[Any, ...], dict[str, Any]]: + CORE.reset() + CORE.config_path = yaml_path + return args, kwargs + + return setup + + +def test_load_compiled_config_cached(primed_yaml: Path, benchmark) -> None: + """Fast path: deserialize the cached, already-validated config.""" + benchmark.pedantic( + load_compiled_config, + setup=_resetting_setup(primed_yaml, (primed_yaml,), {}), + rounds=5, + iterations=1, + ) + + +def test_read_config_uncached(primed_yaml: Path, benchmark) -> None: + """Slow path: full validation pipeline (yaml load + schema + components). + + Uses the same primed fixture as the cached path -- ``read_config`` + ignores the cache file on disk, so the two benchmarks measure the + same input from two different code paths. + """ + benchmark.pedantic( + read_config, + setup=_resetting_setup(primed_yaml, ({},), {"skip_external_update": True}), + rounds=3, + iterations=1, + ) From 1c6966b7612cc2ad2a86070b05fbce3f2234260c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 13 May 2026 13:07:59 -0400 Subject: [PATCH 0010/1815] [espidf] Run PIO extraScript with SCons-env shim (#16404) --- esphome/espidf/component.py | 47 +++++-- esphome/espidf/extra_script.py | 161 ++++++++++++++++++++++ tests/unit_tests/test_espidf_component.py | 122 +++++++++++++++- 3 files changed, 317 insertions(+), 13 deletions(-) create mode 100644 esphome/espidf/extra_script.py diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 8cd77dc6d1..af8640949d 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -322,6 +322,36 @@ def _patch_component(component: IDFComponent, first_pass: bool): (component.path / "idf_component.yml").write_text("") +def _apply_extra_script(component: IDFComponent) -> None: + """Run a PIO ``extraScript`` and fold its captured env vars into + ``component.data["build"]["flags"]`` so the existing -L/-l/-D + extraction in ``generate_cmakelists_txt`` picks them up.""" + extra_script = component.data.get("build", {}).get("extraScript") + if not extra_script: + return + # Resolve and confine to the component dir so a malicious library.json + # can't escape (e.g. ``"extraScript": "../../etc/passwd"``). + library_root = component.path.resolve() + script_path = (component.path / extra_script).resolve() + if not script_path.is_relative_to(library_root) or not script_path.is_file(): + return + from esphome.components.esp32 import get_esp32_variant + from esphome.espidf.extra_script import captured_as_build_flags, run_extra_script + + idf_target = get_esp32_variant().lower().replace("-", "") + result = run_extra_script( + script_path, library_dir=component.path, idf_target=idf_target + ) + extra_flags = captured_as_build_flags(result, library_dir=component.path) + if not extra_flags: + return + flags = component.data.setdefault("build", {}).setdefault("flags", []) + if isinstance(flags, str): + flags = [flags] + flags.extend(extra_flags) + component.data["build"]["flags"] = flags + + T = TypeVar("T") @@ -748,13 +778,6 @@ def _check_library_data(data: dict): if not valid_framework: raise InvalidIDFComponent(f"Unsupported library frameworks: {frameworks}") - extra_script = data.get("build", {}).get("extraScript", None) - if extra_script: - _LOGGER.warning( - 'Extra scripts are not supported. The script "%s" will not be executed.', - extra_script, - ) - def _process_dependencies(component: IDFComponent): """ @@ -899,9 +922,17 @@ def _generate_idf_component(library: Library, force: bool = False) -> IDFCompone # Apply additional patches to the library metadata _patch_component(component, False) - # Check if the component is usable with ESP-IDF + # Check if the component is usable with ESP-IDF before executing any + # third-party Python from the library (``_apply_extra_script`` below). _check_library_data(component.data) + # If the library declares a PIO ``extraScript``, run it against a + # fake SCons env so we can fold its captured LIBPATH/LIBS/etc into + # the build-flag pipeline ``generate_cmakelists_txt`` consumes + # below. Without this, libraries that wire per-MCU archive linking + # via extraScript fail to link under native ESP-IDF. + _apply_extra_script(component) + # Handle the dependencies (convert PlatformIO library to ESP-IDF component if needed) _process_dependencies(component) diff --git a/esphome/espidf/extra_script.py b/esphome/espidf/extra_script.py new file mode 100644 index 0000000000..2f22f23c10 --- /dev/null +++ b/esphome/espidf/extra_script.py @@ -0,0 +1,161 @@ +"""Run a PlatformIO ``extraScript`` against a captured SCons-env stand-in. + +PlatformIO libraries occasionally configure per-target link/build state +via a Python ``extraScript`` declared in ``library.json``'s ``build`` +section instead of static fields. The script runs under SCons during +PIO's build and mutates the active ``Environment`` (``env.Append``, +``env.Replace``, …) — chiefly to set ``LIBPATH``/``LIBS`` per chip MCU. + +ESPHome's PIO→IDF converter (``_generate_idf_component``) doesn't run +SCons, so these scripts were previously ignored and any library +relying on them failed to link under ``toolchain: esp-idf``. This +module provides a small shim that ``exec``s an extra-script with a +fake ``env`` object, captures the common ``env.Append(...)`` calls, +and returns the captured vars so the caller can fold them back into +the library's generated CMakeLists. + +Caveats +------- +* Only the ``env.Append`` API is captured. ``env.Replace``, + ``env.Prepend``, ``env.AddPreAction``, SCons file generators, and any + arbitrary I/O are silently no-ops. Scripts that depend on those will + produce incomplete output. +* Running arbitrary Python from third-party libraries is a non-trivial + trust decision. The shim does no sandboxing — anything in the + script's process can run. Use only with libraries whose source you + trust. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import logging +import os +from pathlib import Path + +_LOGGER = logging.getLogger(__name__) + +# Keys we know how to translate back into ESPHome's build-flag pipeline. +# Other env.Append kwargs are recorded but ignored downstream. +_CAPTURED_KEYS = frozenset({"LIBPATH", "LIBS", "CPPDEFINES", "LINKFLAGS", "CPPFLAGS"}) + + +@dataclass +class ExtraScriptResult: + """Build-var deltas captured from a PIO extra-script ``env.Append`` call.""" + + libpath: list[str] = field(default_factory=list) + libs: list[str] = field(default_factory=list) + cppdefines: list[str | tuple[str, str]] = field(default_factory=list) + linkflags: list[str] = field(default_factory=list) + cppflags: list[str] = field(default_factory=list) + + +class _FakeSConsEnv: + """Minimal stand-in for SCons ``Environment`` exposed to extra-scripts. + + Implements just enough surface area to let scripts query ``BOARD_MCU`` + / ``PIOENV`` and call ``env.Append(LIBPATH=…, LIBS=…, …)``. Every + other env method swallows silently so unrelated calls don't raise + ``AttributeError`` and abort the script. + """ + + def __init__(self, *, board_mcu: str, pio_env: str) -> None: + self._vars: dict[str, str] = { + "BOARD_MCU": board_mcu, + "PIOPLATFORM": "espressif32", + "PIOENV": pio_env, + } + self.result = ExtraScriptResult() + + # ----- SCons env API the common scripts use ----- + + def get(self, key: str, default: str | None = None) -> str | None: + return self._vars.get(key, default) + + def Append(self, **kwargs) -> None: # noqa: N802 (SCons API name) + for key, value in kwargs.items(): + if key not in _CAPTURED_KEYS: + continue + items = list(value) if isinstance(value, (list, tuple)) else [value] + bucket = getattr(self.result, key.lower()) + bucket.extend(items) + + # ----- Everything else is a no-op so unsupported scripts don't crash ----- + + def __getattr__(self, name: str): + def _noop(*args, **kwargs): + return None + + return _noop + + +def run_extra_script( + script_path: Path, *, library_dir: Path, idf_target: str +) -> ExtraScriptResult: + """Execute ``script_path`` with a fake SCons env and return captured vars. + + ``idf_target`` is the active ESP-IDF target name (e.g. ``esp32``, + ``esp32s3``); it's exposed to the script as PlatformIO's + ``BOARD_MCU`` so chip-conditional logic resolves the same way it + would under PIO. The script runs with ``library_dir`` as the + process CWD so relative-path lookups (``join``, ``realpath``, + ``open``) resolve against the library tree. + + On any exception inside the script we log at debug level and return + an empty result — extra-scripts are best-effort, and an unsupported + script shouldn't block the build. + """ + env = _FakeSConsEnv(board_mcu=idf_target, pio_env=f"esphome_{idf_target}") + code = compile(script_path.read_text(), str(script_path), "exec") + old_cwd = os.getcwd() + try: + os.chdir(library_dir) + exec( # noqa: S102 pylint: disable=exec-used + code, + { + "Import": lambda *_args: None, # SCons-side import; harmless here + "env": env, + "__file__": str(script_path), + "__name__": "__pio_extra_script__", + }, + ) + except Exception as e: # pylint: disable=broad-exception-caught + _LOGGER.warning("PIO extra-script %s raised %s; skipping", script_path, e) + return ExtraScriptResult() + finally: + os.chdir(old_cwd) + return env.result + + +def captured_as_build_flags( + result: ExtraScriptResult, *, library_dir: Path +) -> list[str]: + """Translate captured env vars into the ``-L`` / ``-l`` / ``-D`` / + raw-flag form ``_generate_cmakelists_txt`` already knows how to consume. + + ``LIBPATH`` entries are made relative to ``library_dir`` so the + generated CMakeLists is portable; absolute paths outside the library + tree are kept as-is (CMake handles absolute paths in + ``target_link_directories`` fine). + """ + flags: list[str] = [] + library_root = library_dir.resolve() + for path in result.libpath: + # Anchor relative paths to library_dir (not the current CWD, which + # has been restored by the time we get here). Joining an absolute + # path against library_dir returns the absolute path unchanged. + resolved = (library_dir / path).resolve() + try: + flags.append(f"-L{resolved.relative_to(library_root)}") + except ValueError: + flags.append(f"-L{resolved}") + flags.extend(f"-l{lib}" for lib in result.libs) + for define in result.cppdefines: + if isinstance(define, tuple) and len(define) == 2: + flags.append(f"-D{define[0]}={define[1]}") + else: + flags.append(f"-D{define}") + flags.extend(result.linkflags) + flags.extend(result.cppflags) + return flags diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index caef10eea3..3988c997a7 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -250,14 +250,126 @@ def test_check_library_data_invalid_framework(esp32_idf_core): _check_library_data({"platforms": "*", "frameworks": ["other"]}) -def test_extra_script_logs_warning(caplog, esp32_idf_core): - extra_script = "myscript.sh" +def test_extra_script_captures_libpath_libs_and_defines(tmp_path): + from esphome.espidf.extra_script import captured_as_build_flags, run_extra_script + + (tmp_path / "src" / "esp32").mkdir(parents=True) + script = tmp_path / "extra_script.py" + script.write_text( + "Import('env')\n" + "mcu = env.get('BOARD_MCU')\n" + "env.Append(\n" + " LIBPATH=[join('src', mcu)],\n" + " LIBS=['algobsec'],\n" + " CPPDEFINES=['FOO', ('BAR', '1')],\n" + " LINKFLAGS=['-Wl,--gc-sections'],\n" + ")\n" + ) + # The script uses bare ``join`` (PIO's extra-scripts run inside SCons + # where this is in scope). Inject it via the script header so the + # shim's exec namespace can resolve it. + script.write_text("from os.path import join\n" + script.read_text()) + + result = run_extra_script(script, library_dir=tmp_path, idf_target="esp32") + + assert result.libpath == [os.path.join("src", "esp32")] + assert result.libs == ["algobsec"] + assert ("BAR", "1") in result.cppdefines + assert "FOO" in result.cppdefines + assert result.linkflags == ["-Wl,--gc-sections"] + + flags = captured_as_build_flags(result, library_dir=tmp_path) + sep = os.sep + assert f"-Lsrc{sep}esp32" in flags + assert "-lalgobsec" in flags + assert "-DFOO" in flags + assert "-DBAR=1" in flags + assert "-Wl,--gc-sections" in flags + + +def test_extra_script_libpath_relative_resolves_against_library_dir( + tmp_path, monkeypatch +): + """Relative LIBPATH entries must resolve against ``library_dir``, not the + caller's CWD (the shim restores CWD before ``captured_as_build_flags`` + runs).""" + from esphome.espidf.extra_script import ExtraScriptResult, captured_as_build_flags + + (tmp_path / "lib" / "esp32").mkdir(parents=True) + elsewhere = tmp_path.parent / "not_the_library_dir" + elsewhere.mkdir(exist_ok=True) + monkeypatch.chdir(elsewhere) + + result = ExtraScriptResult(libpath=["lib/esp32"]) + flags = captured_as_build_flags(result, library_dir=tmp_path) + + sep = os.sep + assert flags == [f"-Llib{sep}esp32"] + + +def test_extra_script_libpath_absolute_outside_library_dir(tmp_path): + from esphome.espidf.extra_script import ExtraScriptResult, captured_as_build_flags + + outside = tmp_path.parent / "system_lib" + outside.mkdir(exist_ok=True) + result = ExtraScriptResult(libpath=[str(outside)]) + + flags = captured_as_build_flags(result, library_dir=tmp_path) + assert flags == [f"-L{outside.resolve()}"] + + +def test_extra_script_failure_returns_empty_result(tmp_path, caplog): + from esphome.espidf.extra_script import run_extra_script + + script = tmp_path / "broken.py" + script.write_text("raise RuntimeError('boom')\n") with caplog.at_level("WARNING"): - _check_library_data({"build": {"extraScript": extra_script}}) + result = run_extra_script(script, library_dir=tmp_path, idf_target="esp32") - assert "not supported" in caplog.text - assert "myscript.sh" in caplog.text + assert result.libpath == [] + assert result.libs == [] + assert "broken.py" in caplog.text + + +def test_apply_extra_script_path_traversal_is_rejected(tmp_path): + from esphome.espidf.component import _apply_extra_script + + library_dir = tmp_path / "lib" + library_dir.mkdir() + outside = tmp_path / "evil.py" + outside.write_text("env.Append(LIBS=['pwned'])\n") + + c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy")) + c.path = library_dir + c.data = {"build": {"extraScript": "../evil.py"}} + + _apply_extra_script(c) + + # Nothing was folded into flags: the traversal was rejected before + # the script could run. + assert "flags" not in c.data["build"] + + +def test_apply_extra_script_merges_into_existing_flags(tmp_path, monkeypatch): + from esphome.components import esp32 as esp32_module + + monkeypatch.setattr(esp32_module, "get_esp32_variant", lambda: "ESP32") + + from esphome.espidf.component import _apply_extra_script + + (tmp_path / "src").mkdir() + script = tmp_path / "extra.py" + script.write_text("env.Append(LIBS=['algobsec'])\n") + + c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy")) + c.path = tmp_path + c.data = {"build": {"extraScript": "extra.py", "flags": ["-DEXISTING"]}} + + _apply_extra_script(c) + + assert "-DEXISTING" in c.data["build"]["flags"] + assert "-lalgobsec" in c.data["build"]["flags"] def test_parse_library_json(tmp_path): From ce8810bc42984d9b69ca795106b039f8be95eecb Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Wed, 13 May 2026 20:25:32 +0200 Subject: [PATCH 0011/1815] [mitsubishi_cn105] Add vane and wide-vane support (#16405) --- .../mitsubishi_cn105/mitsubishi_cn105.cpp | 90 +++++++++++++++++-- .../mitsubishi_cn105/mitsubishi_cn105.h | 35 +++++++- .../mitsubishi_cn105_climate.cpp | 2 +- .../climate/mitsubishi_cn105_tests.cpp | 67 +++++++++++++- tests/components/mitsubishi_cn105/common.h | 1 + 5 files changed, 178 insertions(+), 17 deletions(-) diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index 3a42616d8a..4782a2ef93 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -50,6 +50,11 @@ template struct LookupMap { } return false; } + + constexpr bool is_valid(value_type value) const { + uint8_t raw; + return reverse_lookup(value, raw); + } }; template static constexpr auto make_map(const T (&values)[N]) { @@ -78,6 +83,33 @@ static constexpr auto PROTOCOL_FAN_MODE_MAP = make_map({ + MitsubishiCN105::VaneMode::AUTO, // 0x00 + MitsubishiCN105::VaneMode::POSITION_1, // 0x01 + MitsubishiCN105::VaneMode::POSITION_2, // 0x02 + MitsubishiCN105::VaneMode::POSITION_3, // 0x03 + MitsubishiCN105::VaneMode::POSITION_4, // 0x04 + MitsubishiCN105::VaneMode::POSITION_5, // 0x05 + MitsubishiCN105::VaneMode::UNKNOWN, // 0x06 + MitsubishiCN105::VaneMode::SWING // 0x07 +}); + +static constexpr auto PROTOCOL_WIDE_VANE_MODE_MAP = make_map({ + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x00 + MitsubishiCN105::WideVaneMode::FAR_LEFT, // 0x01 + MitsubishiCN105::WideVaneMode::LEFT, // 0x02 + MitsubishiCN105::WideVaneMode::CENTER, // 0x03 + MitsubishiCN105::WideVaneMode::RIGHT, // 0x04 + MitsubishiCN105::WideVaneMode::FAR_RIGHT, // 0x05 + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x06 + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x07 + MitsubishiCN105::WideVaneMode::LEFT_RIGHT, // 0x08 + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x09 + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0A + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0B + MitsubishiCN105::WideVaneMode::SWING // 0x0C +}); + static constexpr uint8_t checksum(const uint8_t *bytes, size_t length) { return static_cast(0xFC - std::accumulate(bytes, bytes + length, uint8_t{0})); } @@ -91,7 +123,7 @@ static constexpr auto make_packet(uint8_t type, const std::arrayset_state_(State::STATUS_UPDATED); } - bool changed = previous.power_on != this->status_.power_on || previous.mode != this->status_.mode || - previous.fan_mode != this->status_.fan_mode || - previous.target_temperature != this->status_.target_temperature; + bool changed = + previous.power_on != this->status_.power_on || previous.mode != this->status_.mode || + previous.fan_mode != this->status_.fan_mode || previous.target_temperature != this->status_.target_temperature || + previous.vane_mode != this->status_.vane_mode || previous.wide_vane_mode != this->status_.wide_vane_mode; if (this->is_room_temperature_enabled()) { changed |= previous.room_temperature != this->status_.room_temperature; @@ -339,6 +372,15 @@ bool MitsubishiCN105::parse_status_settings_(const uint8_t *payload, size_t len) this->status_.fan_mode = PROTOCOL_FAN_MODE_MAP.lookup(payload[5]); } + if (!this->pending_updates_.contains(UpdateFlag::VANE)) { + this->status_.vane_mode = PROTOCOL_VANE_MODE_MAP.lookup(payload[6]); + } + + this->set_wide_vane_high_bit_ = (payload[9] & 0xF0) == 0x80; + if (!this->pending_updates_.contains(UpdateFlag::WIDE_VANE)) { + this->status_.wide_vane_mode = PROTOCOL_WIDE_VANE_MODE_MAP.lookup(payload[9] & 0x0F); + } + return true; } @@ -390,8 +432,7 @@ void MitsubishiCN105::set_target_temperature(float target_temperature) { } void MitsubishiCN105::set_mode(Mode mode) { - uint8_t placeholder; - if (!PROTOCOL_MODE_MAP.reverse_lookup(mode, placeholder)) { + if (!PROTOCOL_MODE_MAP.is_valid(mode)) { ESP_LOGD(TAG, "Setting invalid mode: %u", static_cast(mode)); return; } @@ -400,8 +441,7 @@ void MitsubishiCN105::set_mode(Mode mode) { } void MitsubishiCN105::set_fan_mode(FanMode fan_mode) { - uint8_t placeholder; - if (!PROTOCOL_FAN_MODE_MAP.reverse_lookup(fan_mode, placeholder)) { + if (!PROTOCOL_FAN_MODE_MAP.is_valid(fan_mode)) { ESP_LOGD(TAG, "Setting invalid fan mode: %u", static_cast(fan_mode)); return; } @@ -409,6 +449,24 @@ void MitsubishiCN105::set_fan_mode(FanMode fan_mode) { this->pending_updates_.set(UpdateFlag::FAN); } +void MitsubishiCN105::set_vane_mode(VaneMode vane_mode) { + if (!PROTOCOL_VANE_MODE_MAP.is_valid(vane_mode)) { + ESP_LOGD(TAG, "Setting invalid vane mode: %u", static_cast(vane_mode)); + return; + } + this->status_.vane_mode = vane_mode; + this->pending_updates_.set(UpdateFlag::VANE); +} + +void MitsubishiCN105::set_wide_vane_mode(WideVaneMode wide_vane_mode) { + if (!PROTOCOL_WIDE_VANE_MODE_MAP.is_valid(wide_vane_mode)) { + ESP_LOGD(TAG, "Setting invalid wide vane mode: %u", static_cast(wide_vane_mode)); + return; + } + this->status_.wide_vane_mode = wide_vane_mode; + this->pending_updates_.set(UpdateFlag::WIDE_VANE); +} + void MitsubishiCN105::apply_settings_() { std::array payload{}; @@ -450,7 +508,21 @@ void MitsubishiCN105::apply_settings_() { payload[1] |= 0x08; } - this->pending_updates_.clear(UpdateFlag::POWER, UpdateFlag::TEMPERATURE, UpdateFlag::MODE, UpdateFlag::FAN); + if (this->pending_updates_.contains(UpdateFlag::VANE) && + PROTOCOL_VANE_MODE_MAP.reverse_lookup(this->status_.vane_mode, payload[7])) { + payload[1] |= 0x10; + } + + if (this->pending_updates_.contains(UpdateFlag::WIDE_VANE) && + PROTOCOL_WIDE_VANE_MODE_MAP.reverse_lookup(this->status_.wide_vane_mode, payload[13])) { + payload[2] |= 0x01; + if (this->set_wide_vane_high_bit_) { + payload[13] |= 0x80; + } + } + + this->pending_updates_.clear(UpdateFlag::POWER, UpdateFlag::TEMPERATURE, UpdateFlag::MODE, UpdateFlag::FAN, + UpdateFlag::VANE, UpdateFlag::WIDE_VANE); } this->send_packet_(make_packet(PACKET_TYPE_WRITE_SETTINGS_REQUEST, payload)); diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h index 60ca81cf9e..dbeb43068e 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h @@ -29,12 +29,36 @@ class MitsubishiCN105 { UNKNOWN, }; + enum class VaneMode : uint8_t { + AUTO, + POSITION_1, + POSITION_2, + POSITION_3, + POSITION_4, + POSITION_5, + SWING, + UNKNOWN, + }; + + enum class WideVaneMode : uint8_t { + FAR_LEFT, + LEFT, + CENTER, + RIGHT, + FAR_RIGHT, + LEFT_RIGHT, + SWING, + UNKNOWN, + }; + struct Status { - bool power_on{false}; float target_temperature{NAN}; + float room_temperature{NAN}; + bool power_on{false}; Mode mode{Mode::UNKNOWN}; FanMode fan_mode{FanMode::UNKNOWN}; - float room_temperature{NAN}; + VaneMode vane_mode{VaneMode::UNKNOWN}; + WideVaneMode wide_vane_mode{WideVaneMode::UNKNOWN}; }; explicit MitsubishiCN105(uart::UARTDevice &device) : device_(device) {} @@ -61,6 +85,8 @@ class MitsubishiCN105 { void set_target_temperature(float target_temperature); void set_mode(Mode mode); void set_fan_mode(FanMode fan_mode); + void set_vane_mode(VaneMode vane_mode); + void set_wide_vane_mode(WideVaneMode mode); void set_remote_temperature(float temperature); void clear_remote_temperature(); @@ -98,7 +124,9 @@ class MitsubishiCN105 { POWER = 1, MODE = 2, FAN = 3, - REMOTE_TEMPERATURE = 4, + VANE = 4, + WIDE_VANE = 5, + REMOTE_TEMPERATURE = 6, }; struct UpdateFlags { @@ -142,6 +170,7 @@ class MitsubishiCN105 { State state_{State::NOT_CONNECTED}; UpdateFlags pending_updates_; bool use_temperature_encoding_b_{false}; + bool set_wide_vane_high_bit_{false}; FrameParser frame_parser_; uint8_t current_status_msg_type_{0}; diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp index 284339e57f..67a561397a 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp @@ -56,7 +56,7 @@ void MitsubishiCN105Climate::dump_config() { ESP_LOGCONFIG(TAG, " Current temperature min interval: %" PRIu32 " ms", this->hp_.get_room_temperature_min_interval()); } else { - ESP_LOGCONFIG(TAG, " Current temperature: disabled"); + ESP_LOGCONFIG(TAG, " Current temperature: DISABLED"); } ESP_LOGCONFIG(TAG, " Update interval: %" PRIu32 " ms\n" diff --git a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp index db2fbced1c..ef3cdd0fff 100644 --- a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp +++ b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp @@ -53,13 +53,15 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { // Settings response ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x08, 0x07, - 0x00, 0x00, 0x00, 0x00, 0x03, 0xB0, 0x00, 0x00, 0x00, 0x00, 0x99}); + 0x00, 0x04, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3C}); // Settings should still have initial values EXPECT_FALSE(ctx.sut.status().power_on); EXPECT_THAT(ctx.sut.status().target_temperature, ::testing::IsNan()); EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::UNKNOWN); EXPECT_EQ(ctx.sut.status().fan_mode, MitsubishiCN105::FanMode::UNKNOWN); + EXPECT_EQ(ctx.sut.status().vane_mode, MitsubishiCN105::VaneMode::UNKNOWN); + EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::UNKNOWN); ctx.sut.set_current_time(300); ASSERT_FALSE(ctx.sut.update()); @@ -70,6 +72,8 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { EXPECT_EQ(ctx.sut.status().target_temperature, 24.0f); EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::AUTO); EXPECT_EQ(ctx.sut.status().fan_mode, MitsubishiCN105::FanMode::AUTO); + EXPECT_EQ(ctx.sut.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_4); + EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::SWING); // Now fetch room temperature (0x03) EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS); @@ -303,6 +307,30 @@ TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedB) { EXPECT_EQ(ctx.sut.status().room_temperature, 30.0f); } +TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitNotSet) { + auto ctx = TestContext{}; + + ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x58}); + + ctx.sut.update(); + + EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::CENTER); + EXPECT_FALSE(ctx.sut.set_wide_vane_high_bit_); +} + +TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitSet) { + auto ctx = TestContext{}; + + ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x83, 0x00, 0x00, 0x00, 0x00, 0x00, 0xD8}); + + ctx.sut.update(); + + EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::CENTER); + EXPECT_TRUE(ctx.sut.set_wide_vane_high_bit_); +} + TEST(MitsubishiCN105Tests, ApplySettingsPowerOn) { auto ctx = TestContext{}; @@ -365,6 +393,37 @@ TEST(MitsubishiCN105Tests, ApplyFanModeSpeed1) { 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x73)); } +TEST(MitsubishiCN105Tests, ApplyVaneModeSwing) { + auto ctx = TestContext{}; + + ctx.sut.set_vane_mode(MitsubishiCN105::VaneMode::SWING); + ctx.sut.apply_settings(); + + EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x10, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x66)); +} + +TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitNotSet) { + auto ctx = TestContext{}; + + ctx.sut.set_wide_vane_mode(MitsubishiCN105::WideVaneMode::LEFT); + ctx.sut.apply_settings(); + + EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x7A)); +} + +TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitSet) { + auto ctx = TestContext{}; + + ctx.sut.set_wide_vane_high_bit_ = true; + ctx.sut.set_wide_vane_mode(MitsubishiCN105::WideVaneMode::LEFT); + ctx.sut.apply_settings(); + + EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x82, 0x00, 0x00, 0xFA)); +} + TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) { auto ctx = TestContext{}; @@ -391,15 +450,15 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) { ctx.sut.set_target_temperature(25.0f); ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT); ctx.sut.set_fan_mode(MitsubishiCN105::FanMode::AUTO); + ctx.sut.set_vane_mode(MitsubishiCN105::VaneMode::AUTO); // Waiting for next status update must be interrupted and new values send to AC ctx.sut.set_current_time(6000); ASSERT_FALSE(ctx.sut.update()); EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 1000); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS); - EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x0F, 0x00, 0x00, 0x01, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB2, 0x00, 0xBB)); - + EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x1F, 0x00, 0x00, 0x01, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB2, 0x00, 0xAB)); // Write ACK response ctx.uart.push_rx({0xFC, 0x61, 0x01, 0x30, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5E}); diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h index 73e09d6c84..59b6203732 100644 --- a/tests/components/mitsubishi_cn105/common.h +++ b/tests/components/mitsubishi_cn105/common.h @@ -46,6 +46,7 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 { using MitsubishiCN105::state_; using MitsubishiCN105::operation_start_ms_; using MitsubishiCN105::use_temperature_encoding_b_; + using MitsubishiCN105::set_wide_vane_high_bit_; using MitsubishiCN105::status_update_wait_credit_ms_; using MitsubishiCN105::pending_updates_; From c8aba6913b97dbca2e77b768b96cfde93fc89476 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 15:38:13 -0500 Subject: [PATCH 0012/1815] Bump requests from 2.34.0 to 2.34.1 (#16408) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6be6a95fe6..6291b5cd41 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ freetype-py==2.5.1 jinja2==3.1.6 bleak==2.1.1 smpclient==6.0.0 -requests==2.34.0 +requests==2.34.1 # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 From 910cc38dd704644e3dc2985af3feeabf566a8dc3 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 13 May 2026 19:25:35 -0400 Subject: [PATCH 0013/1815] [writer] Clean ESP-IDF build artifacts in clean_build (#16410) --- esphome/writer.py | 8 ++++++++ tests/unit_tests/test_writer.py | 28 +++++++++++++++++++++------- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/esphome/writer.py b/esphome/writer.py index cf04e4f8d2..72c2c355dc 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -490,6 +490,14 @@ def clean_build(clear_pio_cache: bool = True): if dependencies_lock.is_file(): _LOGGER.info("Deleting %s", dependencies_lock) dependencies_lock.unlink() + # Native ESP-IDF toolchain artifacts: the IDF CMake/ninja build dir + # and the Component Manager's fetched managed components live under + # the project's build path, not under .pioenvs / .piolibdeps. + for name in ("build", "managed_components"): + idf_path = CORE.relative_build_path(name) + if idf_path.is_dir(): + _LOGGER.info("Deleting %s", idf_path) + rmtree(idf_path) if not clear_pio_cache: return diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index e76769e6a8..91b4bd8e87 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -443,6 +443,14 @@ def test_clean_build( dependencies_lock = tmp_path / "dependencies.lock" dependencies_lock.write_text("lock file") + # Native ESP-IDF toolchain artifacts. + idf_build_dir = tmp_path / "build" + idf_build_dir.mkdir() + (idf_build_dir / "CMakeCache.txt").write_text("cache") + managed_components_dir = tmp_path / "managed_components" + managed_components_dir.mkdir() + (managed_components_dir / "espressif__arduino-esp32").mkdir() + # Create PlatformIO cache directory platformio_cache_dir = tmp_path / ".platformio" / ".cache" platformio_cache_dir.mkdir(parents=True) @@ -454,12 +462,14 @@ def test_clean_build( # Setup mocks mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir - mock_core.relative_build_path.return_value = dependencies_lock + mock_core.relative_build_path.side_effect = lambda name: tmp_path / name # Verify all exist before assert pioenvs_dir.exists() assert piolibdeps_dir.exists() assert dependencies_lock.exists() + assert idf_build_dir.exists() + assert managed_components_dir.exists() assert platformio_cache_dir.exists() # Mock PlatformIO's ProjectConfig cache_dir @@ -482,6 +492,8 @@ def test_clean_build( assert not pioenvs_dir.exists() assert not piolibdeps_dir.exists() assert not dependencies_lock.exists() + assert not idf_build_dir.exists() + assert not managed_components_dir.exists() assert not platformio_cache_dir.exists() # Verify logging @@ -489,6 +501,8 @@ def test_clean_build( assert ".pioenvs" in caplog.text assert ".piolibdeps" in caplog.text assert "dependencies.lock" in caplog.text + assert str(idf_build_dir) in caplog.text + assert str(managed_components_dir) in caplog.text assert "PlatformIO cache" in caplog.text @@ -510,7 +524,7 @@ def test_clean_build_partial_exists( # Setup mocks mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir - mock_core.relative_build_path.return_value = dependencies_lock + mock_core.relative_build_path.side_effect = lambda name: tmp_path / name # Verify only pioenvs exists assert pioenvs_dir.exists() @@ -547,7 +561,7 @@ def test_clean_build_nothing_exists( # Setup mocks mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir - mock_core.relative_build_path.return_value = dependencies_lock + mock_core.relative_build_path.side_effect = lambda name: tmp_path / name # Verify nothing exists assert not pioenvs_dir.exists() @@ -583,7 +597,7 @@ def test_clean_build_platformio_not_available( # Setup mocks mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir - mock_core.relative_build_path.return_value = dependencies_lock + mock_core.relative_build_path.side_effect = lambda name: tmp_path / name # Verify all exist before assert pioenvs_dir.exists() @@ -621,7 +635,7 @@ def test_clean_build_empty_cache_dir( # Setup mocks mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps" - mock_core.relative_build_path.return_value = tmp_path / "dependencies.lock" + mock_core.relative_build_path.side_effect = lambda name: tmp_path / name # Verify pioenvs exists before assert pioenvs_dir.exists() @@ -1349,7 +1363,7 @@ def test_clean_build_handles_readonly_files( # Setup mocks mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps" - mock_core.relative_build_path.return_value = tmp_path / "dependencies.lock" + mock_core.relative_build_path.side_effect = lambda name: tmp_path / name # Verify file is read-only assert not os.access(readonly_file, os.W_OK) @@ -1413,7 +1427,7 @@ def test_clean_build_reraises_for_other_errors( # Setup mocks mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps" - mock_core.relative_build_path.return_value = tmp_path / "dependencies.lock" + mock_core.relative_build_path.side_effect = lambda name: tmp_path / name try: # Mock os.access in writer module to return True (writable) From 06786da7dda30b243f3f20d263e5d03f3538adc9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 16:28:47 -0700 Subject: [PATCH 0014/1815] Bump actions/create-github-app-token from 3.1.1 to 3.2.0 (#16409) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/auto-label-pr.yml | 2 +- .github/workflows/codeowner-review-request.yml | 2 +- .github/workflows/dashboard-deprecation-comment.yml | 2 +- .github/workflows/external-component-bot.yml | 2 +- .github/workflows/release.yml | 6 +++--- .github/workflows/sync-device-classes.yml | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 2d000658a2..6c80d36d20 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -28,7 +28,7 @@ jobs: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml index cd6c1d34c6..7cdbfcf328 100644 --- a/.github/workflows/codeowner-review-request.yml +++ b/.github/workflows/codeowner-review-request.yml @@ -35,7 +35,7 @@ jobs: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/dashboard-deprecation-comment.yml b/.github/workflows/dashboard-deprecation-comment.yml index e15c61df5e..04a2a2151b 100644 --- a/.github/workflows/dashboard-deprecation-comment.yml +++ b/.github/workflows/dashboard-deprecation-comment.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/external-component-bot.yml b/.github/workflows/external-component-bot.yml index 2e96bec1de..104988d7a5 100644 --- a/.github/workflows/external-component-bot.yml +++ b/.github/workflows/external-component-bot.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d07c8fe633..c1086c858c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -221,7 +221,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} @@ -257,7 +257,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} @@ -289,7 +289,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index c6c829fbb4..f69c7530f7 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} From a3b6f92433c1ddf27d5ec936bda5e1e1bce31b53 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 13 May 2026 19:58:48 -0400 Subject: [PATCH 0015/1815] [espidf] Regenerate bundled CMakeLists; auto-REQUIRE via IDF build properties (#16406) --- esphome/build_gen/espidf.py | 96 ++++++++++--- esphome/components/esp32/__init__.py | 12 ++ esphome/espidf/component.py | 161 ++++------------------ esphome/espidf/toolchain.py | 17 ++- tests/unit_tests/build_gen/test_espidf.py | 159 +++++++++++++++++++++ tests/unit_tests/test_espidf_component.py | 90 +++++++----- 6 files changed, 344 insertions(+), 191 deletions(-) create mode 100644 tests/unit_tests/build_gen/test_espidf.py diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 82c8537bef..5ad2072c5b 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -10,11 +10,14 @@ from esphome.writer import update_storage_json def get_available_components() -> list[str] | None: - """Get list of available ESP-IDF components from project_description.json. + """Get list of built-in ESP-IDF components from project_description.json. - Returns only internal ESP-IDF components, excluding external/managed - components (from idf_component.yml). + Excludes ``src``, IDF-managed components (``managed_components/``), and + converted PIO libs (``pio_components/``). Returns ``None`` if the build + dir or ``project_description.json`` isn't ready yet. """ + if CORE.build_path is None: + return None project_desc = Path(CORE.build_path) / "build" / "project_description.json" if not project_desc.exists(): return None @@ -31,9 +34,9 @@ def get_available_components() -> list[str] | None: if name == "src": continue - # Exclude managed/external components + # Exclude IDF-managed and converted-PIO components (external). comp_dir = info.get("dir", "") - if "managed_components" in comp_dir: + if "managed_components" in comp_dir or "pio_components" in comp_dir: continue result.append(name) @@ -48,8 +51,12 @@ def has_discovered_components() -> bool: return get_available_components() is not None -def get_project_cmakelists() -> str: - """Generate the top-level CMakeLists.txt for ESP-IDF project.""" +def get_project_cmakelists(minimal: bool = False) -> str: + """Generate the top-level CMakeLists.txt for ESP-IDF project. + + When ``minimal`` is true, omit ``ESPHOME_PROJECT_BUILTIN_COMPONENTS`` + since ``project_description.json`` may be stale on the first write. + """ # Get IDF target from ESP32 variant (e.g., ESP32S3 -> esp32s3) variant = get_esp32_variant() idf_target = variant.lower().replace("-", "") @@ -72,6 +79,37 @@ def get_project_cmakelists() -> str: for flag in project_compile_opts ) + # Per-project list exposed as a CMake variable so converted PIO libs + # can reference ${ESPHOME_PROJECT_MANAGED_COMPONENTS} without baking + # project-specific names into their cached CMakeLists. + # + # Emit via idf_build_set_property (not plain set()) so the value is + # serialised into build_properties.temp.cmake and visible to IDF's + # early requirements-expansion pass (component_get_requirements.cmake + # runs as a separate CMake script invocation that doesn't load the + # project's top-level CMakeLists; without this, ${ESPHOME_PROJECT_ + # MANAGED_COMPONENTS} in a converted-lib REQUIRES expands to empty). + from esphome.components.esp32 import get_managed_component_require_names + + managed_components_property = "\n".join( + f"idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS {name} APPEND)" + for name in get_managed_component_require_names() + ) + + # Built-in IDF components exposed via our own property (not IDF's + # __COMPONENT_REQUIRES_COMMON, which would append them to every + # component's REQUIRES including real IDF components). Referenced by + # src/CMakeLists and by each converted PIO lib's CMakeLists. Skipped + # on minimal writes because project_description.json may be stale. + builtin_components_property = ( + "" + if minimal + else "\n".join( + f"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS {name} APPEND)" + for name in sorted(get_available_components() or []) + ) + ) + return f"""\ # Auto-generated by ESPHome cmake_minimum_required(VERSION 3.16) @@ -99,6 +137,10 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) {extra_compile_options} +{managed_components_property} + +{builtin_components_property} + project({CORE.name}) # Emit raw JSON size data for ESPHome to read post-build. @@ -113,11 +155,12 @@ add_custom_command( """ -def get_component_cmakelists(minimal: bool = False) -> str: - """Generate the main component CMakeLists.txt.""" - idf_requires = [] if minimal else (get_available_components() or []) - requires_str = " ".join(idf_requires) +def get_component_cmakelists() -> str: + """Generate the main component CMakeLists.txt. + REQUIRES pulls in the discovered built-in IDF components via the + project-level variables set in the top-level CMakeLists. + """ # Extract linker options (-Wl, flags). Compile flags (-D, -W) are # emitted project-wide via idf_build_set_property in # get_project_cmakelists so they reach every component, not just src/. @@ -126,17 +169,30 @@ def get_component_cmakelists(minimal: bool = False) -> str: return f"""\ # Auto-generated by ESPHome -file(GLOB_RECURSE app_sources - "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp" - "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c" - "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp" - "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c" -) +# CONFIGURE_DEPENDS asks CMake to re-check the glob each build so test +# runs that reuse the build dir don't compile stale source paths. It's +# invalid in script mode (cmake -P), which is how IDF's +# component_get_requirements.cmake includes us, so skip it there. +if(CMAKE_SCRIPT_MODE_FILE) + file(GLOB_RECURSE app_sources + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp" + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c" + ) +else() + file(GLOB_RECURSE app_sources CONFIGURE_DEPENDS + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp" + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c" + ) +endif() idf_component_register( SRCS ${{app_sources}} INCLUDE_DIRS "." "esphome" - REQUIRES {requires_str} + REQUIRES ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}} ) # Apply C++ standard @@ -162,11 +218,11 @@ def write_project(minimal: bool = False) -> None: # Write top-level CMakeLists.txt write_file_if_changed( CORE.relative_build_path("CMakeLists.txt"), - get_project_cmakelists(), + get_project_cmakelists(minimal=minimal), ) # Write component CMakeLists.txt in src/ write_file_if_changed( CORE.relative_src_path("CMakeLists.txt"), - get_component_cmakelists(minimal=minimal), + get_component_cmakelists(), ) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 221c84c149..1eb0bb2174 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -588,6 +588,18 @@ def add_idf_component( } +def get_managed_component_require_names() -> list[str]: + """Return sorted IDF require names for components added via + ``add_idf_component`` (``owner/name`` -> ``owner__name``). + + The build_gen layer (``build_gen.espidf.get_project_cmakelists``) + feeds this list into ``ESPHOME_PROJECT_MANAGED_COMPONENTS`` so + converted PIO libraries can REQUIRE them by name at configure time. + """ + components_registry = CORE.data.get(KEY_ESP32, {}).get(KEY_COMPONENTS, {}) + return sorted(name.replace("/", "__") for name in components_registry) + + def exclude_builtin_idf_component(name: str) -> None: """Exclude an ESP-IDF component from the build. diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index af8640949d..b9202fb6bf 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -12,7 +12,6 @@ from typing import TypeVar from urllib.parse import urlparse, urlsplit, urlunsplit from esphome import git, yaml_util -from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, Library from esphome.espidf.framework import archive_extract_all, download_from_mirrors, rmdir from esphome.helpers import write_file_if_changed @@ -50,28 +49,6 @@ SRC_FILE_EXTENSIONS = [ ESP32_PLATFORM = "espressif32" DOMAIN = "pio_components" -# -# Constants for workarounds -# - -REQUIRES_DETECT_PATTERNS = { - "mbedtls": [re.compile(r'^\s*#\s*include\s*[<"]mbedtls[^">]*[">]', re.MULTILINE)], - "esp_netif": [ - re.compile(r'^\s*#\s*include\s*[<"]esp_netif[^">]*[">]', re.MULTILINE) - ], - "esp_driver_gpio": [ - re.compile(r'^\s*#\s*include\s*[<"]driver/gpio\.h[^">]*[">]', re.MULTILINE) - ], - "esp_timer": [ - re.compile(r'^\s*#\s*include\s*[<"]esp_timer\.h[^">]*[">]', re.MULTILINE) - ], - "esp_wifi": [ - re.compile( - r'^\s*#\s*include\s*[<"]WiFi\.h[^">]*[">]', re.MULTILINE - ) # Arduino WiFi - ], -} - ESPHOME_DATA_KEY = "ESPHOME" ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE" @@ -86,10 +63,7 @@ class URLSource(Source): self.url = url def download(self, dir_suffix: str, force: bool = False) -> Path: - # Partition by framework: generated idf_component.yml content - # depends on CORE.using_arduino, so caches can't be shared. - framework = "arduino" if CORE.using_arduino else "idf" - base_dir = Path(CORE.data_dir) / DOMAIN / framework + base_dir = Path(CORE.data_dir) / DOMAIN h = hashlib.new("sha256") h.update(self.url.encode()) path = base_dir / h.hexdigest()[:8] / dir_suffix @@ -124,12 +98,11 @@ class GitSource(Source): self.ref = ref def download(self, dir_suffix: str, force: bool = False) -> Path: - framework = "arduino" if CORE.using_arduino else "idf" path, _ = git.clone_or_update( url=self.url, ref=self.ref, refresh=git.NEVER_REFRESH if not force else None, - domain=f"{DOMAIN}/{framework}", + domain=DOMAIN, submodules=[], subpath=Path(dir_suffix), ) @@ -282,46 +255,6 @@ def _get_package_from_pio_registry( return owner, name, version["name"], pkgfile["download_url"] -def _patch_component(component: IDFComponent, first_pass: bool): - """ - Apply patches/workarounds to specific components that have known issues. - - This function modifies component data to fix compatibility issues or missing - dependencies for certain libraries. It applies different patches based on - whether it's the first or second pass of processing. - - Args: - component: The IDFComponent object to potentially patch - first_pass: Boolean indicating if this is the first pass of processing - """ - - # Patch only on the second step - if not first_pass and CORE.using_arduino: - # Add the missing dependency to Arduino framework. Source is None so - # the IDF component manager resolves it from the registry instead of - # cloning the 2 GB arduino-esp32 git history. - component.dependencies.append( - IDFComponent( - "espressif/arduino-esp32", - str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]), - None, - ) - ) - - # - # fastled/FastLED - # - - # Patch only on the first step - if ( - first_pass - and component.name == _owner_pkgname_to_name("fastled", "FastLED") - and not (component.path / "idf_component.yml").is_file() - ): - # Force fake idf_component: This project already support ESP-IDF - (component.path / "idf_component.yml").write_text("") - - def _apply_extra_script(component: IDFComponent) -> None: """Run a PIO ``extraScript`` and fold its captured env vars into ``component.data["build"]["flags"]`` so the existing -L/-l/-D @@ -506,43 +439,6 @@ def _convert_library_to_component(library: Library) -> IDFComponent: return IDFComponent(name, version, source) -def _detect_requires(build_src_files: list[str]) -> set[str]: - """ - Detect required components from source files. - - Args: - build_src_files: List of source file paths to analyze - - Returns: - Set of detected required components - """ - detected = set() - - # 1. Process each source file - for file in build_src_files: - path = Path(file) - - if not path.is_file(): - continue - - try: - content = path.read_text(encoding="utf-8", errors="ignore") - except Exception: # pylint: disable=broad-exception-caught - continue - - # 2. Add required component if one of these patterns matches - for require_name, patterns in REQUIRES_DETECT_PATTERNS.items(): - if require_name in detected: - continue # already found - - for pattern in patterns: - if pattern.search(content): - detected.add(require_name) - break - - return detected - - def _split_list_by_condition( items: list[str], match_fn: Callable[[str], str | None] ) -> tuple[list[str], list[str]]: @@ -609,13 +505,14 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: component.path / Path(build_src_dir), build_src_filter ) - # Detect in the files which requirements to add - # By default in platformio, all the components are added: we need to detect them when using ESP-IDF - requires = _detect_requires(build_src_files) - - # Dependencies are required - for dependency in component.dependencies: - requires.add(dependency.get_require_name()) + # Only bake library.json-declared deps here. Project-managed and + # built-in components come in via ${ESPHOME_PROJECT_MANAGED_COMPONENTS} + # / ${ESPHOME_PROJECT_BUILTIN_COMPONENTS} set in the top-level + # CMakeLists, so this file stays project-agnostic when shared from + # the pio_components cache. + requires: set[str] = { + dependency.get_require_name() for dependency in component.dependencies + } # Only keep sources build_src_files = [os.path.relpath(p, component.path) for p in build_src_files] @@ -654,9 +551,19 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: if build_include_dirs: str_include_dirs = " ".join([escape_entry(p) for p in build_include_dirs]) content += f" INCLUDE_DIRS {str_include_dirs}\n" - if requires: - str_requires = " ".join(sorted(requires)) - content += f" REQUIRES {str_requires}\n" + # Project-managed and built-in component lists are set per-project + # via idf_build_set_property in the top-level CMakeLists; expanded + # here at configure time. Keeping them out of the per-lib REQUIRES + # means this CMakeLists is project-agnostic and reusable from the + # pio_components cache across builds. + str_requires = " ".join( + [ + *sorted(requires), + "${ESPHOME_PROJECT_MANAGED_COMPONENTS}", + "${ESPHOME_PROJECT_BUILTIN_COMPONENTS}", + ] + ) + content += f" REQUIRES {str_requires}\n" content += ")\n" # Add public and private build flags @@ -732,13 +639,10 @@ def generate_idf_component_yml(component: IDFComponent) -> str: try: dep["override_path"] = str(dependency.path) except RuntimeError as e: - # No local path; let the IDF component manager resolve. - # GitSource gives an explicit URL; arduino-esp32 is resolved by - # version from the registry. Anything else is a bug. - if isinstance(dependency.source, GitSource): - dep["git"] = dependency.source.url - elif dependency.name != "espressif/arduino-esp32": + # No local path: only a GitSource can substitute its URL. + if not isinstance(dependency.source, GitSource): raise e + dep["git"] = dependency.source.url data["dependencies"][dependency.get_sanitized_name()] = dep @@ -903,12 +807,9 @@ def _generate_idf_component(library: Library, force: bool = False) -> IDFCompone cmakelists_txt_path = component.path / "CMakeLists.txt" idf_component_yml_path = component.path / "idf_component.yml" - # Apply patches to the library metadata - _patch_component(component, True) - - if cmakelists_txt_path.is_file() and idf_component_yml_path.is_file(): - # Already an ESP-IDF component - return component + # Bundled CMakeLists.txt / idf_component.yml are ignored -- library + # authors' IDF support is frequently broken (bogus REQUIRES, hard-coded + # arduino-esp32, etc.). We always regenerate. if library_json_path.is_file(): component.data = _parse_library_json(library_json_path) @@ -919,9 +820,6 @@ def _generate_idf_component(library: Library, force: bool = False) -> IDFCompone "Invalid PIO library: missing library.json and/or library.properties" ) - # Apply additional patches to the library metadata - _patch_component(component, False) - # Check if the component is usable with ESP-IDF before executing any # third-party Python from the library (``_apply_extra_script`` below). _check_library_data(component.data) @@ -936,7 +834,6 @@ def _generate_idf_component(library: Library, force: bool = False) -> IDFCompone # Handle the dependencies (convert PlatformIO library to ESP-IDF component if needed) _process_dependencies(component) - # Generate files _LOGGER.debug("Generating CMakeLists.txt for %s@%s ...", name, version) write_file_if_changed( cmakelists_txt_path, diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index ecb759ed10..583f340996 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -302,10 +302,21 @@ def run_compile(config, verbose: bool) -> int: return rc _LOGGER.info("Regenerating CMakeLists.txt with discovered components...") write_project(minimal=False) + # The post-discovery rewrite leaves CMakeLists newer than + # CMakeCache.txt. CMake won't re-touch CMakeCache.txt on a + # configure that only changes idf_build_set_property values + # (those aren't cache variables), so has_outdated_files() would + # return True on every subsequent build, perpetually retriggering + # the two-pass. Touch CMakeCache.txt now so its mtime stays past + # the rewritten CMakeLists. + cmakecache = CORE.relative_build_path("build/CMakeCache.txt") + if cmakecache.is_file(): + os.utime(cmakecache) if CORE.testing_mode: - # Reconfigure again so cmake is up to date with the full component - # list. This ensures idf.py build won't re-run cmake, which would - # regenerate memory.ld and wipe the DRAM/IRAM patches applied below. + # Reconfigure again so cmake is up to date with the full + # component list before the build's idf.py invocation runs -- + # idf.py build would otherwise re-run cmake and regenerate + # memory.ld, wiping the DRAM/IRAM patches applied below. rc = run_reconfigure() if rc != 0: _LOGGER.error("Reconfigure with discovered components failed") diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py new file mode 100644 index 0000000000..36f0442355 --- /dev/null +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -0,0 +1,159 @@ +"""Tests for esphome.build_gen.espidf module.""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +from esphome.components.esp32 import ( + KEY_COMPONENTS, + KEY_ESP32, + KEY_PATH, + KEY_REF, + KEY_REPO, +) +from esphome.const import KEY_CORE +from esphome.core import CORE + + +@pytest.fixture(autouse=True) +def _reset_core(tmp_path: Path) -> None: + """Give each test its own CORE.build_path and a clean esp32 data slot.""" + CORE.build_path = str(tmp_path) + CORE.data.setdefault(KEY_CORE, {}) + CORE.data[KEY_ESP32] = {KEY_COMPONENTS: {}} + + +def _write_project_description(tmp_path: Path, components: dict[str, str]) -> None: + """Stub a project_description.json with the given component_name -> dir map.""" + build_dir = tmp_path / "build" + build_dir.mkdir(exist_ok=True) + (build_dir / "project_description.json").write_text( + json.dumps( + { + "build_component_info": { + name: {"dir": dir_} for name, dir_ in components.items() + } + } + ) + ) + + +def test_get_available_components_returns_none_without_build_path() -> None: + """No build_path set yet: must not raise on Path(None).""" + CORE.build_path = None + from esphome.build_gen.espidf import get_available_components + + assert get_available_components() is None + + +def test_get_available_components_returns_none_without_project_description( + tmp_path: Path, +) -> None: + from esphome.build_gen.espidf import get_available_components + + assert get_available_components() is None + + +def test_get_available_components_filters_src_managed_and_pio(tmp_path: Path) -> None: + """Built-ins are returned; src/, managed_components/, pio_components/ skipped.""" + _write_project_description( + tmp_path, + { + "src": f"{tmp_path}/src", + "esp_lcd": "/idf/components/esp_lcd", + "espressif__arduino-esp32": f"{tmp_path}/managed_components/arduino", + "JPEGDEC": f"{tmp_path}/pio_components/arduino/abc/bitbank2/JPEGDEC", + "freertos": "/idf/components/freertos", + }, + ) + from esphome.build_gen.espidf import get_available_components + + assert sorted(get_available_components()) == ["esp_lcd", "freertos"] + + +def test_get_project_cmakelists_minimal_omits_builtin_components_property( + tmp_path: Path, +) -> None: + """Minimal write must not emit ESPHOME_PROJECT_BUILTIN_COMPONENTS even + when project_description.json exists (the data may be stale on the + first write before the discovery pass refreshes it).""" + _write_project_description(tmp_path, {"esp_lcd": "/idf/components/esp_lcd"}) + + with ( + patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), + patch.object(CORE, "name", "test"), + ): + from esphome.build_gen.espidf import get_project_cmakelists + + content = get_project_cmakelists(minimal=True) + + assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS" not in content + + +def test_get_project_cmakelists_full_emits_builtin_components_property( + tmp_path: Path, +) -> None: + """Non-minimal write emits one idf_build_set_property line per built-in, + sorted, and excludes src/managed/pio components.""" + _write_project_description( + tmp_path, + { + "src": f"{tmp_path}/src", + "esp_lcd": "/idf/components/esp_lcd", + "freertos": "/idf/components/freertos", + "espressif__esp-dsp": f"{tmp_path}/managed_components/esp-dsp", + "JPEGDEC": f"{tmp_path}/pio_components/arduino/abc/bitbank2/JPEGDEC", + }, + ) + + with ( + patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), + patch.object(CORE, "name", "test"), + ): + from esphome.build_gen.espidf import get_project_cmakelists + + content = get_project_cmakelists(minimal=False) + + assert ( + "idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS esp_lcd APPEND)" + in content + ) + assert ( + "idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS freertos APPEND)" + in content + ) + # Excluded by get_available_components filtering. + assert "espressif__esp-dsp APPEND" not in content + assert "JPEGDEC APPEND" not in content + + +def test_get_project_cmakelists_emits_managed_components_property( + tmp_path: Path, +) -> None: + """ESPHOME_PROJECT_MANAGED_COMPONENTS is always emitted (both modes) + from the esp32 add_idf_component registry.""" + CORE.data[KEY_ESP32][KEY_COMPONENTS] = { + "espressif/esp-dsp": {KEY_REPO: None, KEY_REF: "1.7.1", KEY_PATH: None}, + "espressif/arduino-esp32": {KEY_REPO: None, KEY_REF: "3.3.8", KEY_PATH: None}, + } + + with ( + patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), + patch.object(CORE, "name", "test"), + ): + from esphome.build_gen.espidf import get_project_cmakelists + + for minimal in (True, False): + content = get_project_cmakelists(minimal=minimal) + assert ( + "idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS" + " espressif__arduino-esp32 APPEND)" + ) in content + assert ( + "idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS" + " espressif__esp-dsp APPEND)" + ) in content diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 3988c997a7..8977b05d23 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -1,5 +1,6 @@ import json import os +from pathlib import Path from unittest.mock import MagicMock import pytest @@ -21,7 +22,6 @@ from esphome.espidf.component import ( _check_library_data, _collect_filtered_files, _convert_library_to_component, - _detect_requires, _parse_library_json, _parse_library_properties, _process_dependencies, @@ -83,19 +83,6 @@ def test_collect_filtered_files_exclude(tmp_path): assert str(f2) not in result -def test_detect_requires(tmp_path): - f = tmp_path / "main.c" - f.write_text('#include "mbedtls/foo.h"') - - result = _detect_requires([str(f)]) - assert "mbedtls" in result - - -def test_detect_requires_ignores_invalid_file(tmp_path): - result = _detect_requires([str(tmp_path / "missing.c")]) - assert result == set() - - def test_split_list_by_condition(): items = ["-Iinclude", "-Llib", "-Wall"] @@ -142,7 +129,7 @@ def test_generate_cmakelists_txt_with_flags(tmp_component, tmp_path): == f"""idf_component_register( SRCS "src{sep}main.c" INCLUDE_DIRS "src" - REQUIRES dep + REQUIRES dep ${{ESPHOME_PROJECT_MANAGED_COMPONENTS}} ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}} ) target_compile_options(${{COMPONENT_LIB}} PUBLIC "-DTEST" @@ -160,6 +147,58 @@ target_link_libraries(${{COMPONENT_LIB}} INTERFACE ) +def test_generate_cmakelists_txt_references_project_managed_components_variable( + tmp_component: IDFComponent, +) -> None: + # The CMakeLists is cached under pio_components// and shared + # across projects, so the project-managed REQUIRES list is exposed via + # a CMake variable expanded at configure time rather than baked here. + src_dir = tmp_component.path / "src" + src_dir.mkdir() + (src_dir / "main.c").write_text("int main() {}") + tmp_component.data = {} + + content = generate_cmakelists_txt(tmp_component) + assert "${ESPHOME_PROJECT_MANAGED_COMPONENTS}" in content + + +def test_generate_idf_component_overwrites_bundled_files( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, +) -> None: + # A library that ships its own CMakeLists.txt + idf_component.yml must + # have both replaced by ESPHome's generated content. Library authors' + # bundled IDF metadata is frequently broken (bogus REQUIRES, hard-coded + # frameworks), so we always regenerate from library.json. + from esphome.espidf.component import _generate_idf_component + + (tmp_path / "src").mkdir() + (tmp_path / "src" / "main.cpp").write_text("// dummy\n") + (tmp_path / "library.json").write_text(json.dumps({"name": "tripwire-lib"})) + (tmp_path / "CMakeLists.txt").write_text("# TRIPWIRE_BUNDLED_CMAKELISTS\n") + (tmp_path / "idf_component.yml").write_text("# TRIPWIRE_BUNDLED_MANIFEST\n") + + fake_component = IDFComponent( + "owner/tripwire-lib", "1.0.0", source=URLSource("http://dummy") + ) + fake_component.path = tmp_path + monkeypatch.setattr( + esphome.espidf.component, + "_convert_library_to_component", + lambda _lib: fake_component, + ) + monkeypatch.setattr(fake_component, "download", lambda force=False: None) + + _generate_idf_component(Library("owner/tripwire-lib", "1.0.0", None)) + + cml = (tmp_path / "CMakeLists.txt").read_text() + manifest = (tmp_path / "idf_component.yml").read_text() + assert "TRIPWIRE_BUNDLED_CMAKELISTS" not in cml + assert "TRIPWIRE_BUNDLED_MANIFEST" not in manifest + assert "idf_component_register" in cml + + def test_generate_idf_component_yml_basic(tmp_component): tmp_component.data = {"description": "test", "repository": {"url": "http://aaa"}} result = generate_idf_component_yml(tmp_component) @@ -187,27 +226,6 @@ dependencies: ) -def test_generate_idf_component_yml_arduino_registry_dep(tmp_component): - # Synthetic arduino-esp32 dep with no source / no path: should emit a - # version-only entry so the IDF component manager resolves it from the - # registry instead of via git. - dep = IDFComponent("espressif/arduino-esp32", "3.3.8", source=None) - - tmp_component.dependencies = [dep] - tmp_component.data = {} - - result = generate_idf_component_yml(tmp_component) - - assert ( - result - == """version: 1.0.0 -dependencies: - espressif/arduino-esp32: - version: 3.3.8 -""" - ) - - def test_generate_idf_component_yml_missing_path_reraises(tmp_component): # A dep without a path and without a recognised source should re-raise # the underlying RuntimeError instead of silently producing a bad manifest. From 78b60ac6fa8b173163d74651a719cc1ed3d2e919 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 14 May 2026 12:33:43 +1200 Subject: [PATCH 0016/1815] Bump version to 2026.6.0-dev --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 7fce941c9b..3537516996 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.5.0-dev +PROJECT_NUMBER = 2026.6.0-dev # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index a256a10e62..fa1ea42bc6 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.5.0-dev" +__version__ = "2026.6.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 09a926fa13fa82ebe804ce8798a2a3ed4f9870cf Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 14 May 2026 12:33:43 +1200 Subject: [PATCH 0017/1815] Bump version to 2026.5.0b1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 7fce941c9b..a29a78ea9c 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.5.0-dev +PROJECT_NUMBER = 2026.5.0b1 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index a256a10e62..91bc52708c 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.5.0-dev" +__version__ = "2026.5.0b1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From d2107e40c8c0e019de1984009ba9a4c851d88736 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 May 2026 21:03:45 -0500 Subject: [PATCH 0018/1815] [ci] Prohibit curly braces in PR titles for MDX safety (#16412) --- .github/workflows/pr-title-check.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index ed0bff9664..e15d09da82 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -68,14 +68,15 @@ jobs: return; } - // Check for angle brackets not wrapped in backticks. - // Astro docs MDX treats bare < as JSX component opening tags. + // Check for MDX syntax characters not wrapped in backticks. + // Astro docs MDX treats bare `<` as JSX component opening tags and + // bare `{` as JS expressions, so both must be escaped in changelog entries. const stripped = title.replace(/`[^`]*`/g, ''); - if (/[<>]/.test(stripped)) { + if (/[<>{}]/.test(stripped)) { core.setFailed( - 'PR title contains `<` or `>` not wrapped in backticks.\n' + - 'Astro docs MDX interprets bare `<` as JSX components.\n' + - 'Please wrap angle brackets with backticks, e.g.: [component] Add `` support' + 'PR title contains `<`, `>`, `{`, or `}` not wrapped in backticks.\n' + + 'Astro docs MDX interprets bare `<` as JSX components and bare `{` as JS expressions.\n' + + 'Please wrap these characters with backticks, e.g.: [component] Add `` support' ); return; } From e593cb6efc2f9a8243718cedbc3f048bda5356db Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 13 May 2026 23:19:30 -0400 Subject: [PATCH 0019/1815] [espidf] Stop perpetual reconfigure loop on native ESP-IDF builds (#16415) --- esphome/components/esp32/__init__.py | 7 ++++++- esphome/espidf/toolchain.py | 30 ++++++++++++---------------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 1eb0bb2174..0c24dbf7b9 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2509,7 +2509,12 @@ def _write_idf_component_yml(): stubs_dir = CORE.relative_build_path("component_stubs") stubs_dir.mkdir(exist_ok=True) - for component_name in components_to_stub: + # Sort so the dict insertion order (and thus the generated + # src/idf_component.yml) is deterministic across runs; otherwise + # the manifest content shuffles every build, write_file_if_changed + # always writes, and ninja keeps triggering CMake re-runs on + # otherwise-cached rebuilds. + for component_name in sorted(components_to_stub): # Create stub directory with minimal CMakeLists.txt stub_path = stubs_dir / _idf_component_stub_name(component_name) stub_path.mkdir(exist_ok=True) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 583f340996..1245c643e1 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -191,13 +191,19 @@ def run_reconfigure() -> int: def has_outdated_files(): """Check if the build configuration is stale. - Returns True if required build files are missing or if configuration inputs - are newer than the generated CMake/Ninja build artifacts. + Returns True if required build files are missing or if external + configuration inputs (IDF install, sdkconfig, CMake's own build/config + dir) are newer than CMakeCache.txt. We deliberately don't watch the + top-level/src ``CMakeLists.txt`` here -- those are written by + ``write_project`` via ``write_file_if_changed`` (so an mtime bump + means our content actually changed) and ninja already tracks them as + configure-time deps via ``build.ninja``. Including them in this check + causes a perpetual reconfigure loop: the two-pass write leaves + CMakeLists newer than CMakeCache.txt, and CMake doesn't restamp the + cache when only ``idf_build_set_property`` values change, so the + check would trip on every subsequent build. """ cmakecache_txt_path = CORE.relative_build_path("build/CMakeCache.txt") - - cmakelists_txt_build_path = CORE.relative_build_path("CMakeLists.txt") - cmakelists_txt_src_path = CORE.relative_src_path("CMakeLists.txt") build_config_path = CORE.relative_build_path("build/config") sdkconfig_internal_path = CORE.relative_build_path( f"sdkconfig.{CORE.name}.esphomeinternal" @@ -221,8 +227,6 @@ def has_outdated_files(): os.path.getmtime(f) > cmakecache_txt_mtime for f in [ _get_idf_path(), - cmakelists_txt_build_path, - cmakelists_txt_src_path, sdkconfig_internal_path, build_config_path, ] @@ -302,21 +306,13 @@ def run_compile(config, verbose: bool) -> int: return rc _LOGGER.info("Regenerating CMakeLists.txt with discovered components...") write_project(minimal=False) - # The post-discovery rewrite leaves CMakeLists newer than - # CMakeCache.txt. CMake won't re-touch CMakeCache.txt on a - # configure that only changes idf_build_set_property values - # (those aren't cache variables), so has_outdated_files() would - # return True on every subsequent build, perpetually retriggering - # the two-pass. Touch CMakeCache.txt now so its mtime stays past - # the rewritten CMakeLists. - cmakecache = CORE.relative_build_path("build/CMakeCache.txt") - if cmakecache.is_file(): - os.utime(cmakecache) if CORE.testing_mode: # Reconfigure again so cmake is up to date with the full # component list before the build's idf.py invocation runs -- # idf.py build would otherwise re-run cmake and regenerate # memory.ld, wiping the DRAM/IRAM patches applied below. + # Outside testing mode ninja's own configure-time dep on + # CMakeLists.txt handles the re-run as part of the build step. rc = run_reconfigure() if rc != 0: _LOGGER.error("Reconfigure with discovered components failed") From f89a6f4f9c804d118efb7da701962920186dc44b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 00:02:22 -0400 Subject: [PATCH 0020/1815] [espidf] Trim has_outdated_files watch list; embed IDF version in sdkconfig (#16416) --- esphome/components/esp32/__init__.py | 8 ++++- esphome/espidf/toolchain.py | 45 +++++++++++++++++----------- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 0c24dbf7b9..f112549832 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2464,8 +2464,14 @@ def _write_sdkconfig(): ) want_opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + # Include the resolved framework version as a Kconfig comment so a + # version switch that happens to leave the option set unchanged still + # bumps this file's content -- which is what has_outdated_files() + # uses to decide whether to reconfigure. + framework_version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] contents = ( - "\n".join( + f"# ESPHOME_IDF_VERSION={framework_version}\n" + + "\n".join( f"{name}={_format_sdkconfig_val(value)}" for name, value in sorted(want_opts.items()) ) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 1245c643e1..e0bc5bb393 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -191,23 +191,38 @@ def run_reconfigure() -> int: def has_outdated_files(): """Check if the build configuration is stale. - Returns True if required build files are missing or if external - configuration inputs (IDF install, sdkconfig, CMake's own build/config - dir) are newer than CMakeCache.txt. We deliberately don't watch the - top-level/src ``CMakeLists.txt`` here -- those are written by - ``write_project`` via ``write_file_if_changed`` (so an mtime bump - means our content actually changed) and ninja already tracks them as - configure-time deps via ``build.ninja``. Including them in this check - causes a perpetual reconfigure loop: the two-pass write leaves - CMakeLists newer than CMakeCache.txt, and CMake doesn't restamp the - cache when only ``idf_build_set_property`` values change, so the - check would trip on every subsequent build. + Returns True if required build files are missing or if ESPHome's + resolved build inputs are newer than CMakeCache.txt: + + - ``sdkconfig..esphomeinternal`` -- the canonical "what state + did ESPHome resolve the YAML to" snapshot. Any change in build + flags, enabled components, framework version, or target ends up + rewriting it (we embed a ``# ESPHOME_IDF_VERSION=`` comment line + for the version case where the option set would otherwise be + identical). + - ``src/idf_component.yml`` -- the project manifest. Managed + component additions/removals (e.g. via ``add_idf_component``) can + happen without any sdkconfig impact, and ``_write_idf_component_yml`` + already deletes ``dependencies.lock`` on a change but that signal + gets lost as soon as the lock is missing. + + We deliberately don't watch: + - The top-level/src ``CMakeLists.txt`` -- ESPHome owns those, and + ninja already tracks them as configure-time deps. Including them + causes a perpetual reconfigure loop because CMake doesn't restamp + ``CMakeCache.txt`` when only ``idf_build_set_property`` values + change between configures. + - ``$IDF_PATH`` and CMake's ``build/config/`` -- both have mtime + semantics that fire after the wrong configure (or not at all in + common cases like in-place IDF version replacement). The sdkconfig + and manifest hashes subsume the meaningful signal. """ cmakecache_txt_path = CORE.relative_build_path("build/CMakeCache.txt") build_config_path = CORE.relative_build_path("build/config") sdkconfig_internal_path = CORE.relative_build_path( f"sdkconfig.{CORE.name}.esphomeinternal" ) + idf_component_yml_path = CORE.relative_build_path("src/idf_component.yml") dependency_lock_path = CORE.relative_build_path("dependencies.lock") build_ninja_path = CORE.relative_build_path("build/build.ninja") @@ -225,12 +240,8 @@ def has_outdated_files(): cmakecache_txt_mtime = os.path.getmtime(cmakecache_txt_path) return any( os.path.getmtime(f) > cmakecache_txt_mtime - for f in [ - _get_idf_path(), - sdkconfig_internal_path, - build_config_path, - ] - if f and os.path.exists(f) + for f in [sdkconfig_internal_path, idf_component_yml_path] + if f.exists() ) From 348b92910ed508a641467ec246bac01ae4f731da Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Thu, 14 May 2026 15:07:38 -0500 Subject: [PATCH 0021/1815] [tinyusb] Reject `tinyusb:` configured without a USB class companion (#16413) Co-authored-by: Claude Opus 4.7 (1M context) --- esphome/components/tinyusb/__init__.py | 20 +++++++++++++++++++ .../components/tinyusb/tinyusb_component.cpp | 15 ++++++++++++++ tests/components/tinyusb/common.yaml | 5 +++++ 3 files changed, 40 insertions(+) diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index df94ad7534..724f65721b 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -1,3 +1,4 @@ +from esphome import final_validate as fv import esphome.codegen as cg from esphome.components import esp32 from esphome.components.esp32 import ( @@ -20,6 +21,13 @@ CONF_USB_PRODUCT_STR = "usb_product_str" CONF_USB_SERIAL_STR = "usb_serial_str" CONF_USB_VENDOR_ID = "usb_vendor_id" +# Components that provide a USB device class (CDC, HID, MSC, ...) on top of +# tinyusb. Configuring `tinyusb:` without any of these triggers a 5s hang in +# esp_tinyusb's driver install (descriptors_set fails with no class and no +# user-provided full_speed_config), which trips the task watchdog before +# loop() ever runs. +_USB_CLASS_COMPONENTS = ("usb_cdc_acm",) + tinyusb_ns = cg.esphome_ns.namespace("tinyusb") TinyUSB = tinyusb_ns.class_("TinyUSB", cg.Component) @@ -41,6 +49,18 @@ CONFIG_SCHEMA = cv.All( ) +def _final_validate(config): + full_config = fv.full_config.get() + if not any(name in full_config for name in _USB_CLASS_COMPONENTS): + raise cv.Invalid( + "The 'tinyusb' component requires at least one USB class component" + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index 2ec696c3e4..3cefc0454a 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -26,6 +26,21 @@ void TinyUSB::setup() { .string_count = SIZE, }; + // Defense-in-depth: esp_tinyusb's tinyusb_descriptors_set() fails with + // ESP_ERR_INVALID_ARG when no configuration descriptor is provided and + // no class that has a built-in default (CDC/MSC/NCM) is compiled in. In + // that case the internal task exits without notifying us, and + // tinyusb_driver_install() blocks 5s on the notify-take -- long enough + // to trip the task watchdog. Bail early so the rest of the device can + // still boot. +#if !(CFG_TUD_CDC > 0 || CFG_TUD_MSC > 0 || CFG_TUD_NCM > 0) + if (this->tusb_cfg_.descriptor.full_speed_config == nullptr) { + ESP_LOGE(TAG, "No USB class configured"); + this->mark_failed(); + return; + } +#endif + esp_err_t result = tinyusb_driver_install(&this->tusb_cfg_); if (result != ESP_OK) { ESP_LOGE(TAG, "tinyusb_driver_install failed: %s", esp_err_to_name(result)); diff --git a/tests/components/tinyusb/common.yaml b/tests/components/tinyusb/common.yaml index cb3f48836a..674e89dbe8 100644 --- a/tests/components/tinyusb/common.yaml +++ b/tests/components/tinyusb/common.yaml @@ -6,3 +6,8 @@ tinyusb: usb_product_str: ESPHomeTestProduct usb_serial_str: ESPHomeTestSerialNumber usb_vendor_id: 0x2345 + +# tinyusb requires at least one USB class companion; usb_cdc_acm satisfies that. +usb_cdc_acm: + interfaces: + - id: tinyusb_test_cdc From 7436d1c1999cffa4e2121fd223a992808469dab9 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Thu, 14 May 2026 15:29:56 -0500 Subject: [PATCH 0022/1815] [tinyusb] Reject `logger.hardware_uart: USB_CDC` (#16417) Co-authored-by: Claude Opus 4.7 (1M context) --- esphome/components/tinyusb/__init__.py | 13 ++++++++++++- tests/components/tinyusb/test.esp32-s2-idf.yaml | 5 +++++ tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml | 5 +++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 724f65721b..0e02ff8724 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -9,7 +9,7 @@ from esphome.components.esp32 import ( add_idf_sdkconfig_option, ) import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_HARDWARE_UART, CONF_ID CODEOWNERS = ["@kbx81"] CONFLICTS_WITH = ["usb_host"] @@ -55,6 +55,17 @@ def _final_validate(config): raise cv.Invalid( "The 'tinyusb' component requires at least one USB class component" ) + # tinyusb owns the USB OTG peripheral. The logger's USB_CDC backend routes + # the ROM console through that same peripheral, so the two cannot coexist. + # (USB_SERIAL_JTAG is a separate peripheral and is fine alongside tinyusb.) + logger_config = full_config.get("logger") + if logger_config and logger_config.get(CONF_HARDWARE_UART) == "USB_CDC": + raise cv.Invalid( + "'tinyusb' cannot be used with 'logger.hardware_uart: USB_CDC' " + "because both share the USB OTG peripheral. Set " + "'logger.hardware_uart' to a hardware UART (e.g. UART0), or to " + "USB_SERIAL_JTAG on variants that support it (ESP32-S3, ESP32-P4)" + ) return config diff --git a/tests/components/tinyusb/test.esp32-s2-idf.yaml b/tests/components/tinyusb/test.esp32-s2-idf.yaml index dade44d145..09b98ada40 100644 --- a/tests/components/tinyusb/test.esp32-s2-idf.yaml +++ b/tests/components/tinyusb/test.esp32-s2-idf.yaml @@ -1 +1,6 @@ <<: !include common.yaml + +# S2 defaults logger to USB_CDC, which conflicts with tinyusb on the shared +# USB OTG peripheral; route the logger to UART0 so the fixture builds. +logger: + hardware_uart: UART0 diff --git a/tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml b/tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml index f159b38ff6..ff75731509 100644 --- a/tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml +++ b/tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml @@ -1,5 +1,10 @@ <<: !include tinyusb_common.yaml +# S2 defaults logger to USB_CDC, which conflicts with tinyusb on the shared +# USB OTG peripheral; route the logger to UART0 so the fixture builds. +logger: + hardware_uart: UART0 + usb_cdc_acm: interfaces: - id: usb_cdc_acm1 From a8e69a15e40b9fe4809ee4c3555053baf23503d4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 18:38:09 -0400 Subject: [PATCH 0023/1815] [clang-tidy] Enable readability-container-contains (#16438) --- .clang-tidy | 1 - .clang-tidy.hash | 2 +- esphome/components/lvgl/lvgl_esphome.cpp | 2 +- esphome/components/spi/spi.cpp | 4 ++-- esphome/components/touchscreen/touchscreen.cpp | 2 +- esphome/components/uponor_smatrix/uponor_smatrix.cpp | 2 +- esphome/components/web_server/web_server.cpp | 4 ++-- 7 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.clang-tidy b/.clang-tidy index ea7370a3b2..6dab84fbd9 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -116,7 +116,6 @@ Checks: >- -portability-template-virtual-member-function, -readability-ambiguous-smartptr-reset-call, -readability-avoid-nested-conditional-operator, - -readability-container-contains, -readability-container-data-pointer, -readability-convert-member-functions-to-static, -readability-else-after-return, diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 77b4f5323f..52d75d1601 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -593fd53fa09944a59af3f38521e31d87fe10b60326b8d82bb76413c5149b312c +27aaab4e0ebfc10491720345aa746fc2dffa6a3985f73ec111b12dd99078d46f diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 678ed9dbbf..12bf6d9f37 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -572,7 +572,7 @@ void LvButtonMatrixType::set_obj(lv_obj_t *lv_obj) { auto key_idx = lv_buttonmatrix_get_selected_button(self->obj); if (key_idx == LV_BUTTONMATRIX_BUTTON_NONE) return; - if (self->key_map_.count(key_idx) != 0) { + if (self->key_map_.contains(key_idx)) { self->send_key_(self->key_map_[key_idx]); return; } diff --git a/esphome/components/spi/spi.cpp b/esphome/components/spi/spi.cpp index 20359135ba..dfdc9fa624 100644 --- a/esphome/components/spi/spi.cpp +++ b/esphome/components/spi/spi.cpp @@ -16,7 +16,7 @@ GPIOPin *const NullPin::NULL_PIN = new NullPin(); // NOLINT(cppcoreguidelines-a SPIDelegate *SPIComponent::register_device(SPIClient *device, SPIMode mode, SPIBitOrder bit_order, uint32_t data_rate, GPIOPin *cs_pin, bool release_device, bool write_only) { - if (this->devices_.count(device) != 0) { + if (this->devices_.contains(device)) { ESP_LOGE(TAG, "Device already registered"); return this->devices_[device]; } @@ -27,7 +27,7 @@ SPIDelegate *SPIComponent::register_device(SPIClient *device, SPIMode mode, SPIB } void SPIComponent::unregister_device(SPIClient *device) { - if (this->devices_.count(device) == 0) { + if (!this->devices_.contains(device)) { esph_log_e(TAG, "Device not registered"); return; } diff --git a/esphome/components/touchscreen/touchscreen.cpp b/esphome/components/touchscreen/touchscreen.cpp index 5687213eb5..f4ef66ef3e 100644 --- a/esphome/components/touchscreen/touchscreen.cpp +++ b/esphome/components/touchscreen/touchscreen.cpp @@ -78,7 +78,7 @@ void Touchscreen::add_raw_touch_position_(uint8_t id, int16_t x_raw, int16_t y_r if (this->swap_x_y_) { std::swap(x_raw, y_raw); } - if (this->touches_.count(id) == 0) { + if (!this->touches_.contains(id)) { tp.state = STATE_PRESSED; tp.id = id; } else { diff --git a/esphome/components/uponor_smatrix/uponor_smatrix.cpp b/esphome/components/uponor_smatrix/uponor_smatrix.cpp index 3f1feaa927..0ba19f5cd7 100644 --- a/esphome/components/uponor_smatrix/uponor_smatrix.cpp +++ b/esphome/components/uponor_smatrix/uponor_smatrix.cpp @@ -154,7 +154,7 @@ bool UponorSmatrixComponent::parse_byte_(uint8_t byte) { } // Log unknown device addresses - if (!found && !this->unknown_devices_.count(device_address)) { + if (!found && !this->unknown_devices_.contains(device_address)) { ESP_LOGI(TAG, "Received packet for unknown device address 0x%08" PRIX32 " ", device_address); this->unknown_devices_.insert(device_address); } diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 198267204d..150f70aa6b 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -2638,9 +2638,9 @@ bool WebServer::isRequestHandlerTrivial() const { return false; } void WebServer::add_sorting_info_(JsonObject &root, EntityBase *entity) { #ifdef USE_WEBSERVER_SORTING - if (this->sorting_entitys_.find(entity) != this->sorting_entitys_.end()) { + if (this->sorting_entitys_.contains(entity)) { root[ESPHOME_F("sorting_weight")] = this->sorting_entitys_[entity].weight; - if (this->sorting_groups_.find(this->sorting_entitys_[entity].group_id) != this->sorting_groups_.end()) { + if (this->sorting_groups_.contains(this->sorting_entitys_[entity].group_id)) { root[ESPHOME_F("sorting_group")] = this->sorting_groups_[this->sorting_entitys_[entity].group_id].name; } } From f291dc8d2feeb1a22882e3bba103793d9916e111 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 18:39:16 -0400 Subject: [PATCH 0024/1815] [esp32] Sweep ESP-IDF toolchain warnings + bump deprecated mark_failed (#16432) --- esphome/components/esp32/__init__.py | 4 +++- esphome/components/hdc2080/hdc2080.cpp | 2 +- esphome/components/heatpumpir/climate.py | 1 - esphome/components/pulse_meter/pulse_meter_sensor.cpp | 4 ++-- esphome/components/sim800l/sim800l.cpp | 2 +- esphome/components/tuya/tuya.cpp | 2 ++ esphome/components/tx20/tx20.cpp | 6 +++--- esphome/components/usb_uart/usb_uart.h | 2 +- esphome/components/web_server/web_server.cpp | 2 +- esphome/components/web_server_idf/web_server_idf.cpp | 2 +- esphome/components/wiegand/wiegand.cpp | 4 ++-- 11 files changed, 17 insertions(+), 14 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index f112549832..e9b0f1fd0a 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1767,9 +1767,11 @@ async def to_code(config): else: cg.add_build_flag("-Wno-error=format") cg.add_build_flag("-Wno-error=maybe-uninitialized") - cg.add_build_flag("-Wno-error=missing-field-initializers") + cg.add_build_flag("-Wno-error=overloaded-virtual") cg.add_build_flag("-Wno-error=reorder") cg.add_build_flag("-Wno-error=volatile") + # -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates + cg.add_build_flag("-Wno-missing-field-initializers") cg.set_cpp_standard("gnu++20") cg.add_build_flag("-DUSE_ESP32") diff --git a/esphome/components/hdc2080/hdc2080.cpp b/esphome/components/hdc2080/hdc2080.cpp index dcb207e099..bf3a4bc79f 100644 --- a/esphome/components/hdc2080/hdc2080.cpp +++ b/esphome/components/hdc2080/hdc2080.cpp @@ -22,7 +22,7 @@ static constexpr uint8_t MEAS_CONF_HUM = 0x04; // Bits 2:1 = 10: humidity only void HDC2080Component::setup() { const uint8_t data = 0x00; // automatic measurement mode disabled, heater off if (this->write_register(REG_RESET_DRDY_INT_CONF, &data, 1) != i2c::ERROR_OK) { - this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index b7e0437480..aa3a08c294 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -125,7 +125,6 @@ async def to_code(config): cg.add(var.set_vertical_default(config[CONF_VERTICAL_DEFAULT])) cg.add(var.set_max_temperature(config[CONF_MAX_TEMPERATURE])) cg.add(var.set_min_temperature(config[CONF_MIN_TEMPERATURE])) - cg.add_build_flag("-Wno-error=overloaded-virtual") cg.add_library("tonia/HeatpumpIR", "1.0.41") if CORE.is_libretiny or CORE.is_esp32: diff --git a/esphome/components/pulse_meter/pulse_meter_sensor.cpp b/esphome/components/pulse_meter/pulse_meter_sensor.cpp index 3fe1c722eb..d6959d1a96 100644 --- a/esphome/components/pulse_meter/pulse_meter_sensor.cpp +++ b/esphome/components/pulse_meter/pulse_meter_sensor.cpp @@ -150,7 +150,7 @@ void IRAM_ATTR PulseMeterSensor::edge_intr(PulseMeterSensor *sensor) { edge_state.last_sent_edge_us_ = now; state.last_detected_edge_us_ = now; state.last_rising_edge_us_ = now; - state.count_++; // NOLINT(clang-diagnostic-deprecated-volatile) + state.count_ += 1; } // This ISR is bound to rising edges, so the pin is high @@ -173,7 +173,7 @@ void IRAM_ATTR PulseMeterSensor::pulse_intr(PulseMeterSensor *sensor) { } else if (length && !pulse_state.latched_ && sensor->last_pin_val_) { // Long enough high edge pulse_state.latched_ = true; state.last_detected_edge_us_ = pulse_state.last_intr_; - state.count_++; // NOLINT(clang-diagnostic-deprecated-volatile) + state.count_ += 1; } // Due to order of operations this includes diff --git a/esphome/components/sim800l/sim800l.cpp b/esphome/components/sim800l/sim800l.cpp index b8e97b1121..13b9888e05 100644 --- a/esphome/components/sim800l/sim800l.cpp +++ b/esphome/components/sim800l/sim800l.cpp @@ -126,7 +126,7 @@ void Sim800LComponent::parse_cmd_(std::string message) { break; } - // Else fall thru ... + [[fallthrough]]; } case STATE_CHECK_SMS: send_cmd_("AT+CMGL=\"ALL\""); diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index b29905f9a0..fd14844908 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -684,8 +684,10 @@ void Tuya::set_numeric_datapoint_value_(uint8_t datapoint_id, TuyaDatapointType case 4: data.push_back(value >> 24); data.push_back(value >> 16); + [[fallthrough]]; case 2: data.push_back(value >> 8); + [[fallthrough]]; case 1: data.push_back(value >> 0); break; diff --git a/esphome/components/tx20/tx20.cpp b/esphome/components/tx20/tx20.cpp index 353cb31513..3574bd7c2d 100644 --- a/esphome/components/tx20/tx20.cpp +++ b/esphome/components/tx20/tx20.cpp @@ -135,7 +135,7 @@ void Tx20Component::decode_and_publish_() { } if (tx20_se == tx20_sb) { tx20_wind_direction = tx20_se; - if (tx20_wind_direction >= 0 && tx20_wind_direction < 16) { + if (tx20_wind_direction < 16) { wind_cardinal_direction_ = DIRECTIONS[tx20_wind_direction]; } ESP_LOGV(TAG, "WindDirection %d", tx20_wind_direction); @@ -164,7 +164,7 @@ void IRAM_ATTR Tx20ComponentStore::gpio_intr(Tx20ComponentStore *arg) { } arg->buffer[arg->buffer_index] = 1; arg->start_time = now; - arg->buffer_index++; // NOLINT(clang-diagnostic-deprecated-volatile) + arg->buffer_index += 1; return; } const uint32_t delay = now - arg->start_time; @@ -195,7 +195,7 @@ void IRAM_ATTR Tx20ComponentStore::gpio_intr(Tx20ComponentStore *arg) { } arg->spent_time += delay; arg->start_time = now; - arg->buffer_index++; // NOLINT(clang-diagnostic-deprecated-volatile) + arg->buffer_index += 1; } void IRAM_ATTR Tx20ComponentStore::reset() { tx20_available = false; diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index e88c41c0cb..fb8425f6cd 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -135,7 +135,7 @@ class USBUartChannel : public uart::UARTComponent, public Parentedarg(ESPHOME_F("detail")) == "all" ? DETAIL_ALL : DETAIL_STATE; } diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index e1d3e4bf34..c32acaf03e 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -66,7 +66,7 @@ namespace { * - HTTPD_SOCK_ERR_TIMEOUT if the send buffer is full (EAGAIN/EWOULDBLOCK). * - HTTPD_SOCK_ERR_FAIL for other errors. */ -int nonblocking_send(httpd_handle_t hd, int sockfd, const char *buf, size_t buf_len, int flags) { +[[maybe_unused]] int nonblocking_send(httpd_handle_t hd, int sockfd, const char *buf, size_t buf_len, int flags) { if (buf == nullptr) { return HTTPD_SOCK_ERR_INVALID; } diff --git a/esphome/components/wiegand/wiegand.cpp b/esphome/components/wiegand/wiegand.cpp index e5c29f8b11..df64cd48aa 100644 --- a/esphome/components/wiegand/wiegand.cpp +++ b/esphome/components/wiegand/wiegand.cpp @@ -11,7 +11,7 @@ static const char *const KEYS = "0123456789*#"; void IRAM_ATTR HOT WiegandStore::d0_gpio_intr(WiegandStore *arg) { if (arg->d0.digital_read()) return; - arg->count++; // NOLINT(clang-diagnostic-deprecated-volatile) + arg->count += 1; arg->value <<= 1; arg->last_bit_time = millis(); arg->done = false; @@ -20,7 +20,7 @@ void IRAM_ATTR HOT WiegandStore::d0_gpio_intr(WiegandStore *arg) { void IRAM_ATTR HOT WiegandStore::d1_gpio_intr(WiegandStore *arg) { if (arg->d1.digital_read()) return; - arg->count++; // NOLINT(clang-diagnostic-deprecated-volatile) + arg->count += 1; arg->value = (arg->value << 1) | 1; arg->last_bit_time = millis(); arg->done = false; From f3d77434604b29122d71cad46877d9b991cf1614 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 18:40:40 -0400 Subject: [PATCH 0025/1815] [tests] Fix -Wformat= mismatches in test YAML lambdas/logger.log (#16435) --- esphome/components/logger/__init__.py | 14 +++++----- esphome/components/lvgl/helpers.py | 28 +++++++++---------- .../components/esp32_ble_tracker/common.yaml | 4 +-- tests/components/ld2412/common.yaml | 2 +- tests/components/lvgl/lvgl-package.yaml | 6 ++-- tests/components/modbus_server/common.yaml | 2 +- tests/components/mqtt/common.yaml | 2 +- tests/components/nextion/common.yaml | 2 +- .../remote_receiver/common-actions.yaml | 2 +- tests/components/script/common.yaml | 2 +- tests/components/udp/common.yaml | 2 +- tests/components/udp/test.host.yaml | 2 +- 12 files changed, 34 insertions(+), 34 deletions(-) diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 9d7dc8d92c..c6c440564a 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -506,13 +506,13 @@ async def _late_logger_init(config: ConfigType) -> None: def validate_printf(value): # https://stackoverflow.com/questions/30011379/how-can-i-parse-a-c-format-string-in-python cfmt = r""" - ( # start of capture group 1 - % # literal "%" - (?:[-+0 #]{0,5}) # optional flags - (?:\d+|\*)? # width - (?:\.(?:\d+|\*))? # precision - (?:h|l|ll|w|I|I32|I64)? # size - [cCdiouxXeEfgGaAnpsSZ] # type + ( # start of capture group 1 + % # literal "%" + (?:[-+0 #]{0,5}) # optional flags + (?:\d+|\*)? # width + (?:\.(?:\d+|\*))? # precision + (?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size + [cCdiouxXeEfgGaAnpsSZ] # type ) """ # noqa matches = re.findall(cfmt, value[CONF_FORMAT], flags=re.VERBOSE) diff --git a/esphome/components/lvgl/helpers.py b/esphome/components/lvgl/helpers.py index baa618d472..6f70a1e3bd 100644 --- a/esphome/components/lvgl/helpers.py +++ b/esphome/components/lvgl/helpers.py @@ -9,13 +9,13 @@ CONF_IF_NAN = "if_nan" # noqa f_regex = re.compile( r""" - ( # start of capture group 1 - % # literal "%" - [-+0 #]{0,5} # optional flags - (?:\d+|\*)? # width - (?:\.(?:\d+|\*))? # precision - (?:h|l|ll|w|I|I32|I64)? # size - f # type + ( # start of capture group 1 + % # literal "%" + [-+0 #]{0,5} # optional flags + (?:\d+|\*)? # width + (?:\.(?:\d+|\*))? # precision + (?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size + f # type ) """, flags=re.VERBOSE, @@ -23,13 +23,13 @@ f_regex = re.compile( # noqa c_regex = re.compile( r""" - ( # start of capture group 1 - % # literal "%" - [-+0 #]{0,5} # optional flags - (?:\d+|\*)? # width - (?:\.(?:\d+|\*))? # precision - (?:h|l|ll|w|I|I32|I64)? # size - [cCdiouxXeEfgGaAnpsSZ] # type + ( # start of capture group 1 + % # literal "%" + [-+0 #]{0,5} # optional flags + (?:\d+|\*)? # width + (?:\.(?:\d+|\*))? # precision + (?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size + [cCdiouxXeEfgGaAnpsSZ] # type ) """, flags=re.VERBOSE, diff --git a/tests/components/esp32_ble_tracker/common.yaml b/tests/components/esp32_ble_tracker/common.yaml index 018bbb42b3..564cf1f6ea 100644 --- a/tests/components/esp32_ble_tracker/common.yaml +++ b/tests/components/esp32_ble_tracker/common.yaml @@ -29,12 +29,12 @@ esp32_ble_tracker: - service_uuid: ABCD then: - lambda: !lambda |- - ESP_LOGD("main", "Length of service data is %i", x.size()); + ESP_LOGD("main", "Length of service data is %zu", x.size()); on_ble_manufacturer_data_advertise: - manufacturer_id: ABCD then: - lambda: !lambda |- - ESP_LOGD("main", "Length of manufacturer data is %i", x.size()); + ESP_LOGD("main", "Length of manufacturer data is %zu", x.size()); on_scan_end: - then: - lambda: |- diff --git a/tests/components/ld2412/common.yaml b/tests/components/ld2412/common.yaml index c5bda688dc..7a86b6fbda 100644 --- a/tests/components/ld2412/common.yaml +++ b/tests/components/ld2412/common.yaml @@ -123,7 +123,7 @@ select: - lambda: |- id(uart_bus).flush(); uint32_t new_baud_rate = stoi(x); - ESP_LOGD("change_baud_rate", "Changing baud rate from %i to %i",id(uart_bus).get_baud_rate(), new_baud_rate); + ESP_LOGD("change_baud_rate", "Changing baud rate from %" PRIu32 " to %" PRIu32, id(uart_bus).get_baud_rate(), new_baud_rate); if (id(uart_bus).get_baud_rate() != new_baud_rate) { id(uart_bus).set_baud_rate(new_baud_rate); #if defined(USE_ESP8266) || defined(USE_ESP32) diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 53984bb006..0f4b961297 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -660,13 +660,13 @@ lvgl: on_release: logger.log: format: Button released at %d/%d - args: [point.x, point.y] + args: ['(int) point.x', '(int) point.y'] on_long_press_repeat: logger.log: Button clicked on_pressing: logger.log: format: Button pressing at %d/%d - args: [point.x, point.y] + args: ['(int) point.x', '(int) point.y'] on_press_lost: logger.log: Button press lost on_single_click: @@ -944,7 +944,7 @@ lvgl: on_release: logger.log: format: Slider released at %d/%d with value %.0f - args: [point.x, point.y, x] + args: ['(int) point.x', '(int) point.y', x] - button: styles: spin_button id: spin_up diff --git a/tests/components/modbus_server/common.yaml b/tests/components/modbus_server/common.yaml index 3522c9248c..2e4a81a1aa 100644 --- a/tests/components/modbus_server/common.yaml +++ b/tests/components/modbus_server/common.yaml @@ -21,7 +21,7 @@ modbus_server: read_lambda: |- return 31; write_lambda: |- - printf("address=%d, value=%d", x); + printf("address=%d, value=%" PRId32 "\n", (int) address, x); return true; - id: modbus_server4 modbus_id: mod_bus2 diff --git a/tests/components/mqtt/common.yaml b/tests/components/mqtt/common.yaml index 8c58e9b080..6af2ce3939 100644 --- a/tests/components/mqtt/common.yaml +++ b/tests/components/mqtt/common.yaml @@ -64,7 +64,7 @@ mqtt: topic: some/topic payload: Good-bye - lambda: |- - ESP_LOGD("MQTT", "Disconnect reason %d", reason); + ESP_LOGD("MQTT", "Disconnect reason %d", (int) reason); publish_nan_as_none: false binary_sensor: diff --git a/tests/components/nextion/common.yaml b/tests/components/nextion/common.yaml index 0616b9a41a..fba6a22b97 100644 --- a/tests/components/nextion/common.yaml +++ b/tests/components/nextion/common.yaml @@ -299,7 +299,7 @@ display: - lambda: |- // key: StringRef, value: int32_t if (key == "temperature_raw") { - ESP_LOGD("nextion.custom", "%s=%d", key.c_str(), value); + ESP_LOGD("nextion.custom", "%s=%" PRId32, key.c_str(), value); } on_custom_binary_sensor: then: diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index 30b99eeb70..26a02d4dab 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -12,7 +12,7 @@ on_brennenstuhl: then: - logger.log: format: "on_brennenstuhl: %u" - args: ["x.code"] + args: ["(unsigned) x.code"] on_aeha: then: - logger.log: diff --git a/tests/components/script/common.yaml b/tests/components/script/common.yaml index c1dc68513f..f4818e2296 100644 --- a/tests/components/script/common.yaml +++ b/tests/components/script/common.yaml @@ -49,7 +49,7 @@ script: then: - lambda: |- ESP_LOGD("main", "ints=%d floats=%f bools=%d strings=%s", - ints[0], floats[0], bools[0], strings[0].c_str()); + ints[0], floats[0], (int) bools[0], strings[0].c_str()); - id: my_script_with_params parameters: prefix: string diff --git a/tests/components/udp/common.yaml b/tests/components/udp/common.yaml index 3466e8d2ee..a40ca455cb 100644 --- a/tests/components/udp/common.yaml +++ b/tests/components/udp/common.yaml @@ -11,7 +11,7 @@ udp: - "10.0.0.255" on_receive: - logger.log: - format: "Received %d bytes" + format: "Received %zu bytes" args: [data.size()] - udp.write: id: my_udp diff --git a/tests/components/udp/test.host.yaml b/tests/components/udp/test.host.yaml index 84e78894e5..825d86c19e 100644 --- a/tests/components/udp/test.host.yaml +++ b/tests/components/udp/test.host.yaml @@ -4,7 +4,7 @@ udp: addresses: ["239.0.60.53"] on_receive: - logger.log: - format: "Received %d bytes" + format: "Received %zu bytes" args: [data.size()] - udp.write: id: my_udp From 5d9d6e83f789b2453f748d6fa3feca206a25167f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 15:41:32 -0700 Subject: [PATCH 0026/1815] Bump ruff from 0.15.12 to 0.15.13 (#16437) Signed-off-by: dependabot[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 218bc0083c..9050132e70 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.12 # also change in .pre-commit-config.yaml when updating +ruff==0.15.13 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From 1bb191aa77b181da81880a88f65643adbdd187d1 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 18:51:36 -0400 Subject: [PATCH 0027/1815] [ci] Skip dashboard-deprecation bot on release/beta-bump PRs (#16427) --- .github/workflows/dashboard-deprecation-comment.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/dashboard-deprecation-comment.yml b/.github/workflows/dashboard-deprecation-comment.yml index 04a2a2151b..ffd5ec7bd9 100644 --- a/.github/workflows/dashboard-deprecation-comment.yml +++ b/.github/workflows/dashboard-deprecation-comment.yml @@ -12,6 +12,12 @@ jobs: dashboard-deprecation-comment: name: Dashboard deprecation comment runs-on: ubuntu-latest + # Release-bump PRs (bump-X.Y.Z -> beta, beta -> release) inevitably + # roll up everything merged into dev since the last cut, which can + # include dashboard changes that have already been reviewed once. + # The bot's purpose is to warn new contributors before they invest + # time -- that only applies to PRs entering dev. + if: github.event.pull_request.base.ref == 'dev' steps: - name: Generate a token id: generate-token From 1d86d856d1153fef03f5b3185ae795a943511faa Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 18:51:59 -0400 Subject: [PATCH 0028/1815] [docker] Install libusb-1.0 so ESP-IDF tools can validate openocd (#16424) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docker/Dockerfile | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 540d28be7f..25de9472b6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -13,12 +13,16 @@ RUN git config --system --add safe.directory "*" \ && git config --system advice.detachedHead false # Install build tools for Python packages that require compilation -# (e.g., ruamel.yaml.clibz used by ESP-IDF's idf-component-manager) +# (e.g., ruamel.yaml.clib used by ESP-IDF's idf-component-manager). +# Also install libusb-1.0 at runtime so the ESP-IDF tools installer can +# validate openocd-esp32 (it dynamically links libusb-1.0.so.0); without +# it idf_tools.py rejects the openocd install with exit 127 and aborts +# the whole framework setup. RUN if command -v apk > /dev/null; then \ - apk add --no-cache build-base; \ + apk add --no-cache build-base libusb; \ else \ apt-get update \ - && apt-get install -y --no-install-recommends build-essential \ + && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \ && rm -rf /var/lib/apt/lists/*; \ fi From 313d97498391084a085e9f99451928ce310f5f27 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 18:53:42 -0400 Subject: [PATCH 0029/1815] [multiple] Fix -Wformat= mismatches in component .cpp sources (#16433) --- esphome/components/bme680_bsec/bme680_bsec.cpp | 4 ++-- .../esp32_hosted/update/esp32_hosted_update.cpp | 6 +++--- esphome/components/esphome/ota/ota_esphome.cpp | 14 +++++++------- esphome/components/fastled_base/fastled_light.cpp | 2 +- esphome/components/inkplate/inkplate.cpp | 2 +- esphome/components/midea/air_conditioner.cpp | 4 ++-- esphome/components/ota/ota_partitions_esp_idf.cpp | 4 ++-- .../components/remote_receiver/remote_receiver.cpp | 8 ++++---- esphome/components/sendspin/sendspin_hub.cpp | 4 ++-- .../total_daily_energy/total_daily_energy.cpp | 2 +- 10 files changed, 25 insertions(+), 25 deletions(-) diff --git a/esphome/components/bme680_bsec/bme680_bsec.cpp b/esphome/components/bme680_bsec/bme680_bsec.cpp index b7f8c0da77..823f32c446 100644 --- a/esphome/components/bme680_bsec/bme680_bsec.cpp +++ b/esphome/components/bme680_bsec/bme680_bsec.cpp @@ -161,7 +161,7 @@ void BME680BSECComponent::dump_config() { " IAQ Mode: %s\n" " Supply Voltage: %sV\n" " Sample Rate: %s\n" - " State Save Interval: %ims", + " State Save Interval: %" PRIu32 "ms", this->temperature_offset_, this->iaq_mode_ == IAQ_MODE_STATIC ? "Static" : "Mobile", this->supply_voltage_ == SUPPLY_VOLTAGE_3V3 ? "3.3" : "1.8", BME680_BSEC_SAMPLE_RATE_LOG(this->sample_rate_), this->state_save_interval_ms_); @@ -461,7 +461,7 @@ int8_t BME680BSECComponent::write_bytes_wrapper(uint8_t devid, uint8_t a_registe } void BME680BSECComponent::delay_ms(uint32_t period) { - ESP_LOGV(TAG, "Delaying for %ums", period); + ESP_LOGV(TAG, "Delaying for %" PRIu32 "ms", period); delay(period); } diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index af35d32888..7f3ba77895 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -92,7 +92,7 @@ void Esp32HostedUpdate::setup() { if (esp_hosted_get_coprocessor_fwversion(&ver_info) == ESP_OK) { // 16 bytes: "255.255.255" (11 chars) + null + safety margin char buf[16]; - snprintf(buf, sizeof(buf), "%d.%d.%d", ver_info.major1, ver_info.minor1, ver_info.patch1); + snprintf(buf, sizeof(buf), "%" PRIu32 ".%" PRIu32 ".%" PRIu32, ver_info.major1, ver_info.minor1, ver_info.patch1); this->update_info_.current_version = buf; } else { this->update_info_.current_version = "unknown"; @@ -120,8 +120,8 @@ void Esp32HostedUpdate::setup() { this->state_ = update::UPDATE_STATE_NO_UPDATE; } } else { - ESP_LOGW(TAG, "Invalid app description magic word: 0x%08x (expected 0x%08x)", app_desc->magic_word, - ESP_APP_DESC_MAGIC_WORD); + ESP_LOGW(TAG, "Invalid app description magic word: 0x%08" PRIx32 " (expected 0x%08" PRIx32 ")", + app_desc->magic_word, ESP_APP_DESC_MAGIC_WORD); this->state_ = update::UPDATE_STATE_NO_UPDATE; } } else { diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index f1857ed664..fb0cc2e56d 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -108,8 +108,8 @@ void ESPHomeOTAComponent::dump_config() { ESP_LOGCONFIG(TAG, " Partition access allowed\n" " Running app:\n" - " Partition address: 0x%X\n" - " Used size: %zu bytes (0x%X)", + " Partition address: 0x%" PRIX32 "\n" + " Used size: %zu bytes (0x%zX)", this->running_app_offset_, this->running_app_size_, this->running_app_size_); #ifdef USE_ESP32 @@ -378,7 +378,7 @@ void ESPHomeOTAComponent::handle_data_() { } ota_size = (static_cast(buf[0]) << 24) | (static_cast(buf[1]) << 16) | (static_cast(buf[2]) << 8) | buf[3]; - ESP_LOGV(TAG, "Size is %u bytes", ota_size); + ESP_LOGV(TAG, "Size is %zu bytes", ota_size); #ifndef USE_OTA_PARTITIONS if (ota_type != ota::OTA_TYPE_UPDATE_APP) { @@ -749,7 +749,7 @@ bool ESPHomeOTAComponent::handle_auth_send_() { this->auth_buf_[0] = this->auth_type_; hasher.get_hex(buf); - ESP_LOGV(TAG, "Auth: Nonce is %.*s", hex_size, buf); + ESP_LOGV(TAG, "Auth: Nonce is %.*s", (int) hex_size, buf); } // Try to write auth_type + nonce @@ -809,13 +809,13 @@ bool ESPHomeOTAComponent::handle_auth_read_() { hasher.add(nonce, hex_size * 2); // Add both nonce and cnonce (contiguous in buffer) hasher.calculate(); - ESP_LOGV(TAG, "Auth: CNonce is %.*s", hex_size, cnonce); + ESP_LOGV(TAG, "Auth: CNonce is %.*s", (int) hex_size, cnonce); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char computed_hash[SHA256_HEX_SIZE + 1]; // Buffer for hex-encoded hash (max expected length + null terminator) hasher.get_hex(computed_hash); - ESP_LOGV(TAG, "Auth: Result is %.*s", hex_size, computed_hash); + ESP_LOGV(TAG, "Auth: Result is %.*s", (int) hex_size, computed_hash); #endif - ESP_LOGV(TAG, "Auth: Response is %.*s", hex_size, response); + ESP_LOGV(TAG, "Auth: Response is %.*s", (int) hex_size, response); // Compare response bool matches = hasher.equals_hex(response); diff --git a/esphome/components/fastled_base/fastled_light.cpp b/esphome/components/fastled_base/fastled_light.cpp index 8d1dd49dad..0fa69a23b4 100644 --- a/esphome/components/fastled_base/fastled_light.cpp +++ b/esphome/components/fastled_base/fastled_light.cpp @@ -19,7 +19,7 @@ void FastLEDLightOutput::dump_config() { ESP_LOGCONFIG(TAG, "FastLED light:\n" " Num LEDs: %u\n" - " Max refresh rate: %u", + " Max refresh rate: %" PRIu32, this->num_leds_, this->max_refresh_rate_.value_or(0)); } void FastLEDLightOutput::write_state(light::LightState *state) { diff --git a/esphome/components/inkplate/inkplate.cpp b/esphome/components/inkplate/inkplate.cpp index 39110ca83b..2e837fb614 100644 --- a/esphome/components/inkplate/inkplate.cpp +++ b/esphome/components/inkplate/inkplate.cpp @@ -319,7 +319,7 @@ void Inkplate::fill(Color color) { memset(this->partial_buffer_, fill, this->get_buffer_length_()); } - ESP_LOGV(TAG, "Fill finished (%ums)", millis() - start_time); + ESP_LOGV(TAG, "Fill finished (%" PRIu32 "ms)", millis() - start_time); } void Inkplate::display() { diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index 594f7fa661..7603dd5254 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -130,8 +130,8 @@ ClimateTraits AirConditioner::traits() { void AirConditioner::dump_config() { ESP_LOGCONFIG(Constants::TAG, "MideaDongle:\n" - " [x] Period: %dms\n" - " [x] Response timeout: %dms\n" + " [x] Period: %" PRIu32 "ms\n" + " [x] Response timeout: %" PRIu32 "ms\n" " [x] Request attempts: %d", this->base_.getPeriod(), this->base_.getTimeout(), this->base_.getNumAttempts()); #ifdef USE_REMOTE_TRANSMITTER diff --git a/esphome/components/ota/ota_partitions_esp_idf.cpp b/esphome/components/ota/ota_partitions_esp_idf.cpp index f91e88bde0..a7fc709313 100644 --- a/esphome/components/ota/ota_partitions_esp_idf.cpp +++ b/esphome/components/ota/ota_partitions_esp_idf.cpp @@ -210,7 +210,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGE(TAG, "Cannot resolve running app partition at address 0x%" PRIX32, running_app_offset); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } - ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, + ESP_LOGD(TAG, "Copying running app from 0x%" PRIX32 " to 0x%" PRIX32 " (size: 0x%zX)", running_app_part->address, plan.copy_dest_part->address, running_app_size); err = esp_partition_copy(plan.copy_dest_part, 0, running_app_part, 0, running_app_size); if (err != ESP_OK) { @@ -261,7 +261,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGE(TAG, "Selected app partition not found after partition table update"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } - ESP_LOGD(TAG, "Setting next boot partition to 0x%X", new_boot_partition->address); + ESP_LOGD(TAG, "Setting next boot partition to 0x%" PRIX32, new_boot_partition->address); err = esp_ota_set_boot_partition(new_boot_partition); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X)", err); diff --git a/esphome/components/remote_receiver/remote_receiver.cpp b/esphome/components/remote_receiver/remote_receiver.cpp index d59ee63695..222dae8f7f 100644 --- a/esphome/components/remote_receiver/remote_receiver.cpp +++ b/esphome/components/remote_receiver/remote_receiver.cpp @@ -78,10 +78,10 @@ void RemoteReceiverComponent::setup() { void RemoteReceiverComponent::dump_config() { ESP_LOGCONFIG(TAG, "Remote Receiver:\n" - " Buffer Size: %u\n" - " Tolerance: %u%s\n" - " Filter out pulses shorter than: %u us\n" - " Signal is done after %u us of no changes", + " Buffer Size: %" PRIu32 "\n" + " Tolerance: %" PRIu32 "%s\n" + " Filter out pulses shorter than: %" PRIu32 " us\n" + " Signal is done after %" PRIu32 " us of no changes", this->buffer_size_, this->tolerance_, (this->tolerance_mode_ == remote_base::TOLERANCE_MODE_TIME) ? " us" : "%", this->filter_us_, this->idle_us_); diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 04426b8b1d..57709306cd 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -153,7 +153,7 @@ bool SendspinHub::save_last_server_hash(uint32_t hash) { LastPlayedServerPref pref{.server_id_hash = hash}; bool ok = this->last_played_server_pref_.save(&pref); if (ok) { - ESP_LOGD(TAG, "Persisted last played server hash: 0x%08X", hash); + ESP_LOGD(TAG, "Persisted last played server hash: 0x%08" PRIX32, hash); } else { ESP_LOGW(TAG, "Failed to persist last played server hash"); } @@ -164,7 +164,7 @@ bool SendspinHub::save_last_server_hash(uint32_t hash) { std::optional SendspinHub::load_last_server_hash() { LastPlayedServerPref pref{}; if (this->last_played_server_pref_.load(&pref)) { - ESP_LOGI(TAG, "Loaded last played server hash: 0x%08X", pref.server_id_hash); + ESP_LOGI(TAG, "Loaded last played server hash: 0x%08" PRIX32, pref.server_id_hash); return pref.server_id_hash; } return std::nullopt; diff --git a/esphome/components/total_daily_energy/total_daily_energy.cpp b/esphome/components/total_daily_energy/total_daily_energy.cpp index 161c712cc1..7c9dbb604f 100644 --- a/esphome/components/total_daily_energy/total_daily_energy.cpp +++ b/esphome/components/total_daily_energy/total_daily_energy.cpp @@ -72,7 +72,7 @@ void TotalDailyEnergy::schedule_midnight_reset_() { timeout_seconds = seconds_until_midnight + 1; } - ESP_LOGD(TAG, "Scheduling midnight check in %us", timeout_seconds); + ESP_LOGD(TAG, "Scheduling midnight check in %" PRIu32 "s", timeout_seconds); this->set_timeout(TIMEOUT_ID_MIDNIGHT, timeout_seconds * MILLIS_PER_SECOND, [this]() { this->schedule_midnight_reset_(); }); } From a92b607754c3babd607f3e689bb017955485072f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 18:54:13 -0400 Subject: [PATCH 0030/1815] [ci] Add ci-run-all label to force full CI matrix (#16421) --- .github/workflows/ci.yml | 39 ++++++-- script/determine-jobs.py | 79 ++++++++++++---- tests/script/test_determine_jobs.py | 139 ++++++++++++++++++++++++++++ 3 files changed, 231 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 819dac926e..abd2d1b3a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -249,6 +249,7 @@ jobs: integration-test-buckets: ${{ steps.determine.outputs.integration-test-buckets }} clang-tidy: ${{ steps.determine.outputs.clang-tidy }} clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }} + clang-tidy-full-scan: ${{ steps.determine.outputs.clang-tidy-full-scan }} python-linters: ${{ steps.determine.outputs.python-linters }} import-time: ${{ steps.determine.outputs.import-time }} device-builder: ${{ steps.determine.outputs.device-builder }} @@ -287,7 +288,12 @@ jobs: GH_TOKEN: ${{ github.token }} run: | . venv/bin/activate - output=$(python script/determine-jobs.py) + EXTRA_ARGS="" + if [[ "${{ contains(github.event.pull_request.labels.*.name, 'ci-run-all') }}" == "true" ]]; then + EXTRA_ARGS="--force-all" + echo "::notice::ci-run-all label detected -- forcing every CI job to run" + fi + output=$(python script/determine-jobs.py $EXTRA_ARGS) echo "Test determination output:" echo "$output" | jq @@ -296,6 +302,7 @@ jobs: echo "integration-test-buckets=$(echo "$output" | jq -c '.integration_test_buckets')" >> $GITHUB_OUTPUT 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 "clang-tidy-full-scan=$(echo "$output" | jq -r '.clang_tidy_full_scan')" >> $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 "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT @@ -500,7 +507,13 @@ jobs: id: check_full_scan run: | . venv/bin/activate - if python script/clang_tidy_hash.py --check; then + # determine-jobs.clang-tidy-full-scan is true when core C++ changed + # OR the ci-run-all label forced --force-all. Independent of the + # hash check, both must produce a full scan in the job itself. + if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then + echo "full_scan=true" >> $GITHUB_OUTPUT + echo "reason=determine_jobs" >> $GITHUB_OUTPUT + elif python script/clang_tidy_hash.py --check; then echo "full_scan=true" >> $GITHUB_OUTPUT echo "reason=hash_changed" >> $GITHUB_OUTPUT else @@ -512,7 +525,7 @@ jobs: run: | . venv/bin/activate if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then - echo "Running FULL clang-tidy scan (hash changed)" + echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})" script/clang-tidy --all-headers --fix ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }} else echo "Running clang-tidy on changed files only" @@ -572,7 +585,13 @@ jobs: id: check_full_scan run: | . venv/bin/activate - if python script/clang_tidy_hash.py --check; then + # determine-jobs.clang-tidy-full-scan is true when core C++ changed + # OR the ci-run-all label forced --force-all. Independent of the + # hash check, both must produce a full scan in the job itself. + if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then + echo "full_scan=true" >> $GITHUB_OUTPUT + echo "reason=determine_jobs" >> $GITHUB_OUTPUT + elif python script/clang_tidy_hash.py --check; then echo "full_scan=true" >> $GITHUB_OUTPUT echo "reason=hash_changed" >> $GITHUB_OUTPUT else @@ -584,7 +603,7 @@ jobs: run: | . venv/bin/activate if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then - echo "Running FULL clang-tidy scan (hash changed)" + echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})" script/clang-tidy --all-headers --fix --environment esp32-arduino-tidy else echo "Running clang-tidy on changed files only" @@ -661,7 +680,13 @@ jobs: id: check_full_scan run: | . venv/bin/activate - if python script/clang_tidy_hash.py --check; then + # determine-jobs.clang-tidy-full-scan is true when core C++ changed + # OR the ci-run-all label forced --force-all. Independent of the + # hash check, both must produce a full scan in the job itself. + if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then + echo "full_scan=true" >> $GITHUB_OUTPUT + echo "reason=determine_jobs" >> $GITHUB_OUTPUT + elif python script/clang_tidy_hash.py --check; then echo "full_scan=true" >> $GITHUB_OUTPUT echo "reason=hash_changed" >> $GITHUB_OUTPUT else @@ -673,7 +698,7 @@ jobs: run: | . venv/bin/activate if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then - echo "Running FULL clang-tidy scan (hash changed)" + echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})" script/clang-tidy --all-headers --fix ${{ matrix.options }} else echo "Running clang-tidy on changed files only" diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 0a55b2a848..3259fb5836 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -1062,22 +1062,42 @@ def main() -> None: parser.add_argument( "-b", "--branch", help="Branch to compare changed files against" ) + parser.add_argument( + "--force-all", + action="store_true", + help=( + "Force every job to run regardless of what changed. Used by CI " + "when the ci-run-all label is applied to a PR (escape hatch for " + "changes that need full-matrix validation but don't touch enough " + "files to trigger it organically)." + ), + ) args = parser.parse_args() # Determine what should run - integration_run_all, integration_test_files = determine_integration_tests( - args.branch - ) + if args.force_all: + integration_run_all, integration_test_files = True, [] + run_clang_tidy = True + run_clang_format = True + run_python_linters = True + run_import_time = True + run_device_builder = True + native_idf_components = sorted(NATIVE_IDF_TEST_COMPONENTS) + run_native_idf = True + else: + integration_run_all, integration_test_files = determine_integration_tests( + args.branch + ) + 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) + run_device_builder = should_run_device_builder(args.branch) + native_idf_components = native_idf_components_to_test(args.branch) + run_native_idf = bool(native_idf_components) run_integration, integration_test_buckets = _compute_integration_test_buckets( integration_run_all, integration_test_files ) - 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) - run_device_builder = should_run_device_builder(args.branch) - native_idf_components = native_idf_components_to_test(args.branch) - run_native_idf = bool(native_idf_components) changed_cpp_file_count = count_changed_cpp_files(args.branch) # Get changed components @@ -1106,11 +1126,27 @@ def main() -> None: changed_components = changed_components_result is_core_change = False - # Filter to only components that have test files - # Components without tests shouldn't generate CI test jobs - changed_components_with_tests = [ - component for component in changed_components if _component_has_tests(component) - ] + if args.force_all: + # Force every component with tests into the CI matrix. Each disk entry + # under tests/components/ is treated as a component; filtered + # below by _component_has_tests so components without YAML tests are + # still excluded. + tests_root = Path(root_path) / ESPHOME_TESTS_COMPONENTS_PATH + all_components = sorted(d.name for d in tests_root.iterdir() if d.is_dir()) + changed_components_with_tests = [ + component for component in all_components if _component_has_tests(component) + ] + # Treat as a core change so downstream logic (clang-tidy full scan, + # dep expansion) sees the same world as when esphome/core/ changes. + is_core_change = True + else: + # Filter to only components that have test files + # Components without tests shouldn't generate CI test jobs + changed_components_with_tests = [ + component + for component in changed_components + if _component_has_tests(component) + ] # Get directly changed components with tests (for isolated testing) # These will be tested WITHOUT --testing-mode in CI to enable full validation @@ -1143,8 +1179,10 @@ def main() -> None: memory_impact = detect_memory_impact_config(args.branch) # Determine clang-tidy mode based on actual files that will be checked + is_full_scan = False if run_clang_tidy: # Full scan needed if: hash changed OR core files changed + # (is_core_change is forced True under --force-all) is_full_scan = _is_clang_tidy_full_scan() or is_core_change if is_full_scan: @@ -1177,10 +1215,12 @@ def main() -> None: # Build output # Determine which C++ unit tests to run - cpp_run_all, cpp_components = determine_cpp_unit_tests(args.branch) - - # Determine if benchmarks should run - run_benchmarks = should_run_benchmarks(args.branch) + if args.force_all: + cpp_run_all, cpp_components = True, [] + run_benchmarks = True + else: + cpp_run_all, cpp_components = determine_cpp_unit_tests(args.branch) + run_benchmarks = should_run_benchmarks(args.branch) # Split components into batches for CI testing # This intelligently groups components with similar bus configurations @@ -1219,6 +1259,7 @@ def main() -> None: "integration_test_buckets": integration_test_buckets, "clang_tidy": run_clang_tidy, "clang_tidy_mode": clang_tidy_mode, + "clang_tidy_full_scan": is_full_scan, "clang_format": run_clang_format, "python_linters": run_python_linters, "import_time": run_import_time, diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 9139c6e095..3fd5eada94 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -2602,3 +2602,142 @@ def test_main_validate_only_excludes_transitive_components( # Only foo (directly changed, validate-only). bar is a transitive dep # and still needs compile despite no source change of its own. assert output["validate_only_components"] == ["foo"] + + +def test_main_force_all_overrides_detection( + mock_determine_integration_tests: Mock, + 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_should_run_device_builder: Mock, + mock_native_idf_components_to_test: Mock, + mock_determine_cpp_unit_tests: Mock, + mock_changed_files: Mock, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """--force-all bypasses per-feature detection and runs every job. + + Detection mocks all return False/empty (which would normally skip + everything) -- the flag must override them. Also verifies clang-tidy + goes to ``split`` (full scan) and the component-test matrix is + populated from disk rather than from changed-files. + """ + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + + mock_determine_integration_tests.return_value = (False, []) + 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_should_run_device_builder.return_value = False + mock_native_idf_components_to_test.return_value = [] + mock_determine_cpp_unit_tests.return_value = (False, []) + mock_changed_files.return_value = [] + + with ( + patch("sys.argv", ["determine-jobs.py", "--force-all"]), + patch.object(determine_jobs, "get_changed_components", return_value=[]), + patch.object( + determine_jobs, "filter_component_and_test_files", return_value=False + ), + patch.object( + determine_jobs, "get_components_with_dependencies", return_value=[] + ), + patch.object( + determine_jobs, + "detect_memory_impact_config", + return_value={"should_run": "false"}, + ), + patch.object(determine_jobs, "should_run_benchmarks", return_value=False), + ): + determine_jobs.main() + + output = json.loads(capsys.readouterr().out) + + assert output["integration_tests"] is True + assert output["clang_tidy"] is True + assert output["clang_tidy_mode"] == "split" + assert output["clang_tidy_full_scan"] is True + assert output["clang_format"] is True + assert output["python_linters"] is True + assert output["import_time"] is True + assert output["device_builder"] is True + assert output["native_idf"] is True + # native_idf_components is a CSV of NATIVE_IDF_TEST_COMPONENTS + assert "esp32" in output["native_idf_components"].split(",") + assert output["cpp_unit_tests_run_all"] is True + assert output["cpp_unit_tests_components"] == [] + assert output["benchmarks"] is True + # Detection helpers must not be consulted when --force-all is set + mock_determine_integration_tests.assert_not_called() + mock_should_run_clang_tidy.assert_not_called() + mock_should_run_clang_format.assert_not_called() + mock_should_run_python_linters.assert_not_called() + mock_should_run_import_time.assert_not_called() + mock_should_run_device_builder.assert_not_called() + mock_native_idf_components_to_test.assert_not_called() + mock_determine_cpp_unit_tests.assert_not_called() + # Component matrix is populated from disk (tests/components/ in the repo) + assert output["component_test_count"] > 0 + assert len(output["component_test_batches"]) > 0 + + +def test_main_force_all_off_uses_detection( + mock_determine_integration_tests: Mock, + 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_should_run_device_builder: Mock, + mock_native_idf_components_to_test: Mock, + mock_determine_cpp_unit_tests: Mock, + mock_changed_files: Mock, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Without --force-all, detection helpers drive the decision (regression guard).""" + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + + mock_determine_integration_tests.return_value = (False, []) + 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_should_run_device_builder.return_value = False + mock_native_idf_components_to_test.return_value = [] + mock_determine_cpp_unit_tests.return_value = (False, []) + mock_changed_files.return_value = [] + + with ( + patch("sys.argv", ["determine-jobs.py"]), + patch.object(determine_jobs, "get_changed_components", return_value=[]), + patch.object( + determine_jobs, "filter_component_and_test_files", return_value=False + ), + patch.object( + determine_jobs, "get_components_with_dependencies", return_value=[] + ), + patch.object( + determine_jobs, + "detect_memory_impact_config", + return_value={"should_run": "false"}, + ), + patch.object( + determine_jobs, "create_intelligent_batches", return_value=([], {}) + ), + patch.object(determine_jobs, "should_run_benchmarks", return_value=False), + ): + determine_jobs.main() + + output = json.loads(capsys.readouterr().out) + + assert output["integration_tests"] is False + assert output["clang_tidy"] is False + assert output["clang_format"] is False + assert output["python_linters"] is False + assert output["native_idf"] is False + assert output["component_test_count"] == 0 + mock_determine_integration_tests.assert_called_once() + mock_should_run_clang_tidy.assert_called_once() From 56983f414fb9316062a09bbd57ae1584297cc595 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 22:41:36 -0400 Subject: [PATCH 0031/1815] [espidf] Gate esp_idf_size --ng on IDF version (#16441) --- esphome/build_gen/espidf.py | 10 ++++++++-- tests/unit_tests/build_gen/test_espidf.py | 7 ++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 5ad2072c5b..96f84ebbd1 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -3,7 +3,8 @@ import json from pathlib import Path -from esphome.components.esp32 import get_esp32_variant +from esphome.components.esp32 import get_esp32_variant, idf_version +import esphome.config_validation as cv from esphome.core import CORE from esphome.helpers import mkdir_p, write_file_if_changed from esphome.writer import update_storage_json @@ -61,6 +62,11 @@ def get_project_cmakelists(minimal: bool = False) -> str: variant = get_esp32_variant() idf_target = variant.lower().replace("-", "") + # esp_idf_size 2.x (bundled with IDF >=6.0) made NG the default and + # removed the --ng flag; on 1.x (IDF 5.5) --ng is required to get + # --format=raw because the legacy mode doesn't support it. + size_ng_flag = "--ng" if idf_version() < cv.Version(6, 0, 0) else "" + # Project-wide compile options: -D defines and -W warning flags (skip # -Wl, linker flags — those go on the src component via # target_link_options below). Emitted via idf_build_set_property so the @@ -146,7 +152,7 @@ project({CORE.name}) # Emit raw JSON size data for ESPHome to read post-build. add_custom_command( TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD - COMMAND ${{PYTHON}} -m esp_idf_size --ng --format=raw + COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=raw -o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json ${{CMAKE_PROJECT_NAME}}.map WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}} diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index 36f0442355..540dd06731 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -11,10 +11,12 @@ import pytest from esphome.components.esp32 import ( KEY_COMPONENTS, KEY_ESP32, + KEY_IDF_VERSION, KEY_PATH, KEY_REF, KEY_REPO, ) +import esphome.config_validation as cv from esphome.const import KEY_CORE from esphome.core import CORE @@ -24,7 +26,10 @@ def _reset_core(tmp_path: Path) -> None: """Give each test its own CORE.build_path and a clean esp32 data slot.""" CORE.build_path = str(tmp_path) CORE.data.setdefault(KEY_CORE, {}) - CORE.data[KEY_ESP32] = {KEY_COMPONENTS: {}} + CORE.data[KEY_ESP32] = { + KEY_COMPONENTS: {}, + KEY_IDF_VERSION: cv.Version(5, 5, 4), + } def _write_project_description(tmp_path: Path, components: dict[str, str]) -> None: From d046dd7276747918784bb46a6f4ccb257f5e738a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 22:51:14 -0400 Subject: [PATCH 0032/1815] [esp32_hosted] Bump esp_hosted to 2.12.7 (#16440) --- esphome/components/esp32_hosted/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index eca7c24b10..71d1fd3ac1 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -249,7 +249,7 @@ async def to_code(config): esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.5.1") esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.2") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.6") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.7") else: esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 814a4031c1..49c4cdbb2e 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -36,7 +36,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.12.6 + version: 2.12.7 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: From c5c627d534df0f96043b40f1bf1267de6bc33b5a Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 14 May 2026 23:31:11 -0400 Subject: [PATCH 0033/1815] [audio] Bump microMP3 to v0.2.1 (#16429) --- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 44371e87ab..13b379ba3a 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -395,7 +395,7 @@ async def to_code(config): ) if data.mp3_support: cg.add_define("USE_AUDIO_MP3_SUPPORT") - add_idf_component(name="esphome/micro-mp3", ref="0.2.0") + add_idf_component(name="esphome/micro-mp3", ref="0.2.1") _emit_memory_pair( data.mp3.buffer_memory, "CONFIG_MP3_DECODER_PREFER_PSRAM", diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 49c4cdbb2e..35c55cbb4d 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -10,7 +10,7 @@ dependencies: esphome/micro-flac: version: 0.2.0 esphome/micro-mp3: - version: 0.2.0 + version: 0.2.1 esphome/micro-opus: version: 0.4.1 esphome/micro-wav: From d663d80fdefdba965a08e8436ab00cc1e332b734 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 14 May 2026 23:33:36 -0400 Subject: [PATCH 0034/1815] [sound_level] Use RingBufferAudioSource (#16436) --- .../components/sound_level/sound_level.cpp | 58 ++++++++++--------- esphome/components/sound_level/sound_level.h | 9 +-- 2 files changed, 35 insertions(+), 32 deletions(-) diff --git a/esphome/components/sound_level/sound_level.cpp b/esphome/components/sound_level/sound_level.cpp index fb8bfd3085..a93e396367 100644 --- a/esphome/components/sound_level/sound_level.cpp +++ b/esphome/components/sound_level/sound_level.cpp @@ -11,7 +11,7 @@ namespace esphome::sound_level { static const char *const TAG = "sound_level"; -static const uint32_t AUDIO_BUFFER_DURATION_MS = 30; +static const uint32_t MAX_FILL_DURATION_MS = 30; static const uint32_t RING_BUFFER_DURATION_MS = 120; // Square INT16_MIN since INT16_MIN^2 > INT16_MAX^2 @@ -30,8 +30,7 @@ void SoundLevelComponent::dump_config() { void SoundLevelComponent::setup() { this->microphone_source_->add_data_callback([this](const std::vector &data) { std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (this->ring_buffer_.use_count() == 2) { - // ``audio_buffer_`` and ``temp_ring_buffer`` share ownership of a ring buffer, so its safe/useful to write + if (temp_ring_buffer != nullptr) { temp_ring_buffer->write((void *) data.data(), data.size()); } }); @@ -81,10 +80,11 @@ void SoundLevelComponent::loop() { return; } - // Copy data from ring buffer into the transfer buffer - don't block to avoid slowing the main loop - this->audio_buffer_->transfer_data_from_source(0); + // Expose a chunk of the ring buffer's internal storage - don't block to avoid slowing the main loop. + // pre_shift is ignored by RingBufferAudioSource (no intermediate transfer buffer to compact). + this->audio_source_->fill(0, false); - if (this->audio_buffer_->available() == 0) { + if (this->audio_source_->available() == 0) { // No new audio available for processing return; } @@ -92,11 +92,11 @@ void SoundLevelComponent::loop() { const uint32_t samples_in_window = this->microphone_source_->get_audio_stream_info().ms_to_samples(this->measurement_duration_ms_); const uint32_t samples_available_to_process = - this->microphone_source_->get_audio_stream_info().bytes_to_samples(this->audio_buffer_->available()); + this->microphone_source_->get_audio_stream_info().bytes_to_samples(this->audio_source_->available()); const uint32_t samples_to_process = std::min(samples_in_window - this->sample_count_, samples_available_to_process); // MicrophoneSource always provides int16 samples due to Python codegen settings - const int16_t *audio_data = reinterpret_cast(this->audio_buffer_->get_buffer_start()); + const int16_t *audio_data = reinterpret_cast(this->audio_source_->data()); // Process all the new audio samples for (uint32_t i = 0; i < samples_to_process; ++i) { @@ -115,9 +115,8 @@ void SoundLevelComponent::loop() { ++this->sample_count_; } - // Remove the processed samples from ``audio_buffer_`` - this->audio_buffer_->decrease_buffer_length( - this->microphone_source_->get_audio_stream_info().samples_to_bytes(samples_to_process)); + // Remove the processed samples from ``audio_source_`` + this->audio_source_->consume(this->microphone_source_->get_audio_stream_info().samples_to_bytes(samples_to_process)); if (this->sample_count_ == samples_in_window) { // Processed enough samples for the measurement window, compute and publish the sensor values @@ -158,36 +157,39 @@ void SoundLevelComponent::stop() { } bool SoundLevelComponent::start_() { - if (this->audio_buffer_ != nullptr) { + if (this->audio_source_ != nullptr) { return true; } - // Allocate a transfer buffer - this->audio_buffer_ = audio::AudioSourceTransferBuffer::create( - this->microphone_source_->get_audio_stream_info().ms_to_bytes(AUDIO_BUFFER_DURATION_MS)); - if (this->audio_buffer_ == nullptr) { - this->status_momentary_error("transfer_buffer", 15000); + const auto &stream_info = this->microphone_source_->get_audio_stream_info(); + const size_t bytes_per_frame = stream_info.frames_to_bytes(1); + + // Allocate a ring buffer for the microphone callback to write into. Round the size down to a multiple + // of bytes_per_frame so the wrap boundary stays frame-aligned and avoids unnecessary single-frame splices. + this->ring_buffer_.reset(); // Reset pointer to any previous ring buffer allocation + const size_t ring_buffer_size = + (stream_info.ms_to_bytes(RING_BUFFER_DURATION_MS) / bytes_per_frame) * bytes_per_frame; + std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); + if (temp_ring_buffer == nullptr) { + this->status_momentary_error("ring_buffer", 15000); return false; } - // Allocates a new ring buffer, adds it as a source for the transfer buffer, and points ring_buffer_ to it - this->ring_buffer_.reset(); // Reset pointer to any previous ring buffer allocation - std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create( - this->microphone_source_->get_audio_stream_info().ms_to_bytes(RING_BUFFER_DURATION_MS)); - if (temp_ring_buffer.use_count() == 0) { - this->status_momentary_error("ring_buffer", 15000); - this->stop_(); + // Zero-copy source that reads directly from the ring buffer's internal storage. Frame-aligned reads + // ensure multi-channel frames are never split across the ring buffer's wrap boundary. + this->audio_source_ = audio::RingBufferAudioSource::create( + temp_ring_buffer, stream_info.ms_to_bytes(MAX_FILL_DURATION_MS), static_cast(bytes_per_frame)); + if (this->audio_source_ == nullptr) { + this->status_momentary_error("audio_source", 15000); return false; - } else { - this->ring_buffer_ = temp_ring_buffer; - this->audio_buffer_->set_source(temp_ring_buffer); } + this->ring_buffer_ = temp_ring_buffer; this->status_clear_error(); return true; } -void SoundLevelComponent::stop_() { this->audio_buffer_.reset(); } +void SoundLevelComponent::stop_() { this->audio_source_.reset(); } } // namespace esphome::sound_level diff --git a/esphome/components/sound_level/sound_level.h b/esphome/components/sound_level/sound_level.h index 4f0081a510..aabea62ca4 100644 --- a/esphome/components/sound_level/sound_level.h +++ b/esphome/components/sound_level/sound_level.h @@ -36,11 +36,12 @@ class SoundLevelComponent : public Component { void stop(); protected: - /// @brief Internal start command that, if necessary, allocates ``audio_buffer_`` and a ring buffer which - /// ``audio_buffer_`` owns and ``ring_buffer_`` points to. Returns true if allocations were successful. + /// @brief Internal start command that, if necessary, allocates a ring buffer and a zero-copy + /// ``RingBufferAudioSource`` that reads directly from it. ``ring_buffer_`` weakly references the + /// ring buffer owned by ``audio_source_``. Returns true if allocations were successful. bool start_(); - /// @brief Internal stop command the deallocates ``audio_buffer_`` (which automatically deallocates its ring buffer) + /// @brief Internal stop command that deallocates ``audio_source_`` (which releases its ring buffer) void stop_(); microphone::MicrophoneSource *microphone_source_{nullptr}; @@ -48,7 +49,7 @@ class SoundLevelComponent : public Component { sensor::Sensor *peak_sensor_{nullptr}; sensor::Sensor *rms_sensor_{nullptr}; - std::unique_ptr audio_buffer_; + std::unique_ptr audio_source_; std::weak_ptr ring_buffer_; int32_t squared_peak_{0}; From d832ce51cd207f3cea82b04f2a1b7d60c0b68c6e Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Fri, 15 May 2026 05:42:10 +0200 Subject: [PATCH 0035/1815] [nextion] Replace `connect_info` vector with fixed-size field parser, always log device info (#16059) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/nextion/display.py | 21 ++++++-- esphome/components/nextion/nextion.cpp | 75 +++++++++++++++----------- esphome/components/nextion/nextion.h | 15 +++--- esphome/core/defines.h | 1 - tests/components/nextion/common.yaml | 1 - 5 files changed, 70 insertions(+), 43 deletions(-) diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index dc3d5c6d09..89e9b93520 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -1,3 +1,5 @@ +import logging + from esphome import automation import esphome.codegen as cg from esphome.components import display, esp32, uart @@ -39,6 +41,8 @@ from .base_component import ( CONF_WAKE_UP_PAGE, ) +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@senexcrenshaw", "@edwardtfn"] DEPENDENCIES = ["uart"] @@ -55,6 +59,15 @@ NextionSetBrightnessAction = nextion_ns.class_( ) +def _deprecated_dump_device_info(value): + _LOGGER.warning( + "'dump_device_info' is deprecated and will be removed in ESPHome 2026.11.0. " + "Device info is now always logged at connection time. " + "Please remove this option from your configuration." + ) + return value + + def _validate_tft_upload(config): has_tft_url = CONF_TFT_URL in config for conf_key in ( @@ -81,7 +94,10 @@ CONFIG_SCHEMA = cv.All( cv.positive_time_period_milliseconds, cv.Range(max=TimePeriod(milliseconds=255)), ), - cv.Optional(CONF_DUMP_DEVICE_INFO, default=False): cv.boolean, + # Deprecated — device info is now always logged. Remove before 2026.11.0. + cv.Optional(CONF_DUMP_DEVICE_INFO): cv.All( + cv.boolean, _deprecated_dump_device_info + ), cv.Optional(CONF_EXIT_REPARSE_ON_START, default=False): cv.boolean, cv.Optional(CONF_MAX_QUEUE_AGE, default="8000ms"): cv.All( cv.positive_time_period_milliseconds, @@ -277,9 +293,6 @@ async def to_code(config): cg.add(var.set_auto_wake_on_touch(config[CONF_AUTO_WAKE_ON_TOUCH])) - if config[CONF_DUMP_DEVICE_INFO]: - cg.add_define("USE_NEXTION_CONFIG_DUMP_DEVICE_INFO") - if config[CONF_EXIT_REPARSE_ON_START]: cg.add_define("USE_NEXTION_CONFIG_EXIT_REPARSE_ON_START") diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index b644cad507..4ebc717552 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -117,30 +117,41 @@ bool Nextion::check_connect_() { ESP_LOGN(TAG, "connect: %s", response.c_str()); - size_t start; + // Parse comok response fields directly + // Format: comok ,,,,,, + size_t field_count = 0; + size_t start = 0; size_t end = 0; - std::vector connect_info; + auto copy_field = [&](char *dst, size_t cap) { + size_t len = (end == std::string::npos ? response.size() : end) - start; + size_t n = len < cap ? len : cap; + std::memcpy(dst, response.data() + start, n); + dst[n] = '\0'; + }; while ((start = response.find_first_not_of(',', end)) != std::string::npos) { end = response.find(',', start); - connect_info.push_back(response.substr(start, end - start)); + switch (field_count) { + case 2: + copy_field(this->device_model_, this->NEXTION_MODEL_MAX); + break; + case 3: + copy_field(this->firmware_version_, this->NEXTION_FW_MAX); + break; + case 5: + copy_field(this->serial_number_, this->NEXTION_SERIAL_MAX); + break; + case 6: + this->flash_size_ = static_cast(std::strtoul(response.data() + start, nullptr, 10)); + break; + default: + break; + } + ++field_count; } - this->is_detected_ = (connect_info.size() == 7); + this->is_detected_ = (field_count == 7); if (this->is_detected_) { - ESP_LOGN(TAG, "Connect info: %zu", connect_info.size()); -#ifdef USE_NEXTION_CONFIG_DUMP_DEVICE_INFO - this->device_model_ = connect_info[2]; - this->firmware_version_ = connect_info[3]; - this->serial_number_ = connect_info[5]; - this->flash_size_ = connect_info[6]; -#else // USE_NEXTION_CONFIG_DUMP_DEVICE_INFO - ESP_LOGI(TAG, - " Device Model: %s\n" - " FW Version: %s\n" - " Serial Number: %s\n" - " Flash Size: %s\n", - connect_info[2].c_str(), connect_info[3].c_str(), connect_info[5].c_str(), connect_info[6].c_str()); -#endif // USE_NEXTION_CONFIG_DUMP_DEVICE_INFO + ESP_LOGN(TAG, "Connect info: %zu fields", field_count); } else { ESP_LOGE(TAG, "Bad connect value: '%s'", response.c_str()); } @@ -178,24 +189,26 @@ void Nextion::dump_config() { #ifdef USE_NEXTION_CONFIG_SKIP_CONNECTION_HANDSHAKE ESP_LOGCONFIG(TAG, " Skip handshake: YES"); #else // USE_NEXTION_CONFIG_SKIP_CONNECTION_HANDSHAKE -#ifdef USE_NEXTION_CONFIG_DUMP_DEVICE_INFO + if (this->is_setup()) { + ESP_LOGCONFIG(TAG, + " Device Model: %s\n" + " FW Version: %s\n" + " Serial Number: %s\n" + " Flash Size: %" PRIu32 " bytes", + this->device_model_, this->firmware_version_, this->serial_number_, this->flash_size_); + } else { + ESP_LOGCONFIG(TAG, " Device info: not yet detected"); + } ESP_LOGCONFIG(TAG, - " Device Model: %s\n" - " FW Version: %s\n" - " Serial Number: %s\n" - " Flash Size: %s\n" - " Max queue age: %u ms\n" - " Startup override: %u ms\n", - this->device_model_.c_str(), this->firmware_version_.c_str(), this->serial_number_.c_str(), - this->flash_size_.c_str(), this->max_q_age_ms_, this->startup_override_ms_); -#endif // USE_NEXTION_CONFIG_DUMP_DEVICE_INFO #ifdef USE_NEXTION_CONFIG_EXIT_REPARSE_ON_START - ESP_LOGCONFIG(TAG, " Exit reparse: YES\n"); + " Exit reparse: YES\n" #endif // USE_NEXTION_CONFIG_EXIT_REPARSE_ON_START - ESP_LOGCONFIG(TAG, + " Max queue age: %u ms\n" + " Startup override: %u ms\n" " Wake On Touch: %s\n" " Touch Timeout: %" PRIu16, - YESNO(this->connection_state_.auto_wake_on_touch_), this->touch_sleep_timeout_); + this->max_q_age_ms_, this->startup_override_ms_, YESNO(this->connection_state_.auto_wake_on_touch_), + this->touch_sleep_timeout_); #endif // USE_NEXTION_CONFIG_SKIP_CONNECTION_HANDSHAKE #ifdef USE_NEXTION_MAX_COMMANDS_PER_LOOP diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index c62772ac75..ef030e71da 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -1610,12 +1610,15 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe nextion_writer_t writer_; optional brightness_; -#ifdef USE_NEXTION_CONFIG_DUMP_DEVICE_INFO - std::string device_model_; - std::string firmware_version_; - std::string serial_number_; - std::string flash_size_; -#endif // USE_NEXTION_CONFIG_DUMP_DEVICE_INFO + // Device info populated from comok response (fixed-size, no heap allocation). + // Sizes derived from Nextion Upload Protocol documentation and observed hardware. + static constexpr size_t NEXTION_MODEL_MAX = 24; ///< Max observed ~18 chars from product numbering rules + static constexpr size_t NEXTION_FW_MAX = 7; ///< 'S' prefix + integer (e.g. 'S99' or `123`) + static constexpr size_t NEXTION_SERIAL_MAX = 20; ///< Consistently 16 hex chars across all documented examples + char device_model_[NEXTION_MODEL_MAX + 1]{}; + char firmware_version_[NEXTION_FW_MAX + 1]{}; + char serial_number_[NEXTION_SERIAL_MAX + 1]{}; + uint32_t flash_size_ = 0; ///< Flash size in bytes — plain integer, no string needed void remove_front_no_sensors_(); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 162a6034b8..ee8e89de8b 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -134,7 +134,6 @@ #define USE_MEDIA_SOURCE #define USE_NEXTION_COMMAND_SPACING #define USE_NEXTION_CONF_START_UP_PAGE -#define USE_NEXTION_CONFIG_DUMP_DEVICE_INFO #define USE_NEXTION_CONFIG_EXIT_REPARSE_ON_START #define USE_NEXTION_CONFIG_SKIP_CONNECTION_HANDSHAKE #define USE_NEXTION_MAX_COMMANDS_PER_LOOP diff --git a/tests/components/nextion/common.yaml b/tests/components/nextion/common.yaml index fba6a22b97..d79e3ee2ed 100644 --- a/tests/components/nextion/common.yaml +++ b/tests/components/nextion/common.yaml @@ -276,7 +276,6 @@ display: auto_wake_on_touch: true brightness: 80% command_spacing: 5ms - dump_device_info: true exit_reparse_on_start: true lambda: |- ESP_LOGD("display","Display is being tested!"); From ff968a4629915b4bf448da32773b7cc8e4204b60 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 15 May 2026 12:36:01 -0400 Subject: [PATCH 0036/1815] [ci] Fix sync-device-classes workflow (failing daily for weeks) (#16448) --- .github/workflows/sync-device-classes.yml | 25 +++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index f69c7530f7..879d7d5e0c 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -30,6 +30,11 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Create sync branch + # Switch off dev before pre-commit runs so the + # no-commit-to-branch hook passes (it blocks dev/release/beta). + run: git checkout -B sync/device-classes + - name: Checkout Home Assistant uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -45,15 +50,27 @@ jobs: run: | python -m pip install --upgrade pip pip install -e lib/home-assistant - pip install -r requirements_test.txt pre-commit + # Project requirements are needed so pylint can resolve runtime + # imports (e.g. smpclient in esphome/components/nrf52/ota.py). + pip install -r requirements.txt -r requirements_test.txt pre-commit - name: Sync run: | python ./script/sync-device_class.py - - name: Run pre-commit hooks - run: | - python script/run-in-env.py pre-commit run --all-files + - name: Apply pre-commit auto-fixes + # First pass: let formatters (ruff, end-of-file-fixer, etc.) modify + # files. pre-commit exits non-zero whenever a hook touches anything, + # which would otherwise abort the workflow before the auto-fixes + # can flow into the sync PR. + run: python script/run-in-env.py pre-commit run --all-files || true + + - name: Verify pre-commit clean + # Second pass: re-run all hooks against the now-fixed tree. + # Auto-fixers exit 0 (nothing to change); any remaining failure + # from a check-only hook (pylint / flake8 / yamllint / ci-custom) + # is a real issue and fails the workflow loudly. + run: python script/run-in-env.py pre-commit run --all-files - name: Commit changes uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 From 5b6c54c961d800bbc284729463060ec008ce849d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 09:45:11 -0700 Subject: [PATCH 0037/1815] [ci] sync-device-classes: use uv for installs and skip pylint (#16449) --- .github/workflows/sync-device-classes.yml | 31 ++++++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 879d7d5e0c..7a6f272ff2 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -46,13 +46,20 @@ jobs: with: python-version: "3.14" + - name: Set up uv + # An order of magnitude faster than pip on cold boots, with its + # own wheel cache. ``--system`` (below) installs into the + # setup-python interpreter so subsequent ``pre-commit`` / + # ``script/run-in-env.py`` steps find the deps without a + # ``uv run`` prefix. + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true + - name: Install Home Assistant run: | - python -m pip install --upgrade pip - pip install -e lib/home-assistant - # Project requirements are needed so pylint can resolve runtime - # imports (e.g. smpclient in esphome/components/nrf52/ota.py). - pip install -r requirements.txt -r requirements_test.txt pre-commit + uv pip install --system -e lib/home-assistant + uv pip install --system -r requirements.txt -r requirements_test.txt pre-commit - name: Sync run: | @@ -63,13 +70,23 @@ jobs: # files. pre-commit exits non-zero whenever a hook touches anything, # which would otherwise abort the workflow before the auto-fixes # can flow into the sync PR. + # + # pylint is skipped: this workflow only installs a subset of the + # runtime deps (HA + requirements*.txt), so pylint surfaces + # import-error / relative-beyond-top-level noise that the main CI + # already gates on real PRs. + env: + SKIP: pylint run: python script/run-in-env.py pre-commit run --all-files || true - name: Verify pre-commit clean # Second pass: re-run all hooks against the now-fixed tree. # Auto-fixers exit 0 (nothing to change); any remaining failure - # from a check-only hook (pylint / flake8 / yamllint / ci-custom) - # is a real issue and fails the workflow loudly. + # from a check-only hook (flake8 / yamllint / ci-custom) is a + # real issue and fails the workflow loudly. pylint stays skipped + # for the same reason as above. + env: + SKIP: pylint run: python script/run-in-env.py pre-commit run --all-files - name: Commit changes From 1b1e21d470589a65a63e8456a8a19de88af27d4d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 09:54:01 -0700 Subject: [PATCH 0038/1815] [ci] sync-device-classes: drop branch-switch hack, skip no-commit-to-branch instead (#16450) --- .github/workflows/sync-device-classes.yml | 25 +++++++++++------------ 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 7a6f272ff2..23a63c5d8a 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -30,11 +30,6 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Create sync branch - # Switch off dev before pre-commit runs so the - # no-commit-to-branch hook passes (it blocks dev/release/beta). - run: git checkout -B sync/device-classes - - name: Checkout Home Assistant uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -71,22 +66,26 @@ jobs: # which would otherwise abort the workflow before the auto-fixes # can flow into the sync PR. # - # pylint is skipped: this workflow only installs a subset of the - # runtime deps (HA + requirements*.txt), so pylint surfaces - # import-error / relative-beyond-top-level noise that the main CI - # already gates on real PRs. + # SKIP: + # - no-commit-to-branch is a local guard against committing on + # dev/release/beta; CI runs on dev by definition, and + # peter-evans/create-pull-request creates the branch itself. + # - pylint surfaces import-error / relative-beyond-top-level + # noise here because this workflow installs only a subset of + # the runtime deps (HA + requirements*.txt); main CI already + # gates pylint on real PRs. env: - SKIP: pylint + SKIP: pylint,no-commit-to-branch run: python script/run-in-env.py pre-commit run --all-files || true - name: Verify pre-commit clean # Second pass: re-run all hooks against the now-fixed tree. # Auto-fixers exit 0 (nothing to change); any remaining failure # from a check-only hook (flake8 / yamllint / ci-custom) is a - # real issue and fails the workflow loudly. pylint stays skipped - # for the same reason as above. + # real issue and fails the workflow loudly. Same SKIP list as + # above for the same reasons. env: - SKIP: pylint + SKIP: pylint,no-commit-to-branch run: python script/run-in-env.py pre-commit run --all-files - name: Commit changes From 4189979391cb9616ca8a812da678cbb89c50e698 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 09:57:49 -0700 Subject: [PATCH 0039/1815] Synchronise Device Classes from Home Assistant (#16452) Co-authored-by: esphomebot --- esphome/components/sensor/__init__.py | 2 ++ esphome/const.py | 1 + 2 files changed, 3 insertions(+) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index f076c7f17b..6bbab76363 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -96,6 +96,7 @@ from esphome.const import ( DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TEMPERATURE_DELTA, DEVICE_CLASS_TIMESTAMP, + DEVICE_CLASS_UPTIME, DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, DEVICE_CLASS_VOLTAGE, @@ -174,6 +175,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TEMPERATURE_DELTA, DEVICE_CLASS_TIMESTAMP, + DEVICE_CLASS_UPTIME, DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, DEVICE_CLASS_VOLTAGE, diff --git a/esphome/const.py b/esphome/const.py index fa1ea42bc6..4557380c73 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1367,6 +1367,7 @@ DEVICE_CLASS_TEMPERATURE = "temperature" DEVICE_CLASS_TEMPERATURE_DELTA = "temperature_delta" DEVICE_CLASS_TIMESTAMP = "timestamp" DEVICE_CLASS_UPDATE = "update" +DEVICE_CLASS_UPTIME = "uptime" DEVICE_CLASS_VIBRATION = "vibration" DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds" DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS = "volatile_organic_compounds_parts" From 4381a8baaaa369ddfdda112e90337c5f87660f3f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 09:59:35 -0700 Subject: [PATCH 0040/1815] [ci] pr-title-check: skip all bot authors, not just dependabot (#16453) --- .github/workflows/pr-title-check.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index e15d09da82..e8320672d2 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -29,10 +29,11 @@ jobs: } = require('./.github/scripts/detect-tags.js'); const title = context.payload.pull_request.title; - const author = context.payload.pull_request.user.login; + const user = context.payload.pull_request.user; - // Skip bot PRs (e.g. dependabot) - they have their own title format - if (author === 'dependabot[bot]') { + // Skip bot PRs (e.g. dependabot, esphome[bot] device-class sync) - + // they have their own title formats. + if (user.type === 'Bot') { return; } From 8b3bc47547d33f96bb3b23e23e5596de27b847f6 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 15 May 2026 19:16:01 +0200 Subject: [PATCH 0041/1815] [uptime] Update device_class for Uptime sensor (#16434) Co-authored-by: J. Nick Koston --- esphome/components/uptime/sensor/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/esphome/components/uptime/sensor/__init__.py b/esphome/components/uptime/sensor/__init__.py index e2a7aee1a2..6ce0795cdb 100644 --- a/esphome/components/uptime/sensor/__init__.py +++ b/esphome/components/uptime/sensor/__init__.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_TIME_ID, DEVICE_CLASS_DURATION, - DEVICE_CLASS_TIMESTAMP, + DEVICE_CLASS_UPTIME, ENTITY_CATEGORY_DIAGNOSTIC, ICON_TIMER, STATE_CLASS_TOTAL_INCREASING, @@ -33,9 +33,8 @@ CONFIG_SCHEMA = cv.typed_schema( ).extend(cv.polling_component_schema("60s")), "timestamp": sensor.sensor_schema( UptimeTimestampSensor, - icon=ICON_TIMER, accuracy_decimals=0, - device_class=DEVICE_CLASS_TIMESTAMP, + device_class=DEVICE_CLASS_UPTIME, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ) .extend( From ec1826a6ed47d9a94ad64b6751ec46a2088f7ce4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 10:17:50 -0700 Subject: [PATCH 0042/1815] [yaml_util] Promote include-discovery helper, share it with bundle (#16447) --- esphome/bundle.py | 97 ++----------- esphome/yaml_util.py | 125 +++++++++++++++++ tests/unit_tests/test_bundle.py | 12 +- tests/unit_tests/test_yaml_util.py | 216 +++++++++++++++++++++++++++++ 4 files changed, 359 insertions(+), 91 deletions(-) diff --git a/esphome/bundle.py b/esphome/bundle.py index 70c4fad0fd..4537cbce9d 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -260,42 +260,20 @@ class ConfigBundleCreator: def _discover_yaml_includes(self) -> None: """Discover YAML files loaded during config parsing. - Deliberately uses a fresh re-parse and force-loads every deferred - ``IncludeFile`` to include *all* potentially-reachable includes, - even branches not selected by the local substitutions. Bundles are - meant to be compiled on another system where command-line - substitution overrides may choose a different branch — e.g. - ``!include network/${eth_model}/config.yaml`` must ship every - candidate so the remote build can pick any one. - - Entries with unresolved substitution variables in the filename - path are skipped with a warning (they cannot be resolved without - the substitution pass). - - Secrets files are tracked separately so we can filter them to - only include the keys this config actually references. + Delegates to :func:`yaml_util.discover_user_yaml_files`, which does a + fresh re-parse and force-loads every deferred ``IncludeFile`` so that + *all* potentially-reachable includes are captured (even branches not + selected by local substitutions). Bundles are meant to be compiled on + another system where command-line substitution overrides may choose a + different branch — e.g. ``!include network/${eth_model}/config.yaml`` + must ship every candidate so the remote build can pick any one. """ - # Must be a fresh parse: IncludeFile.load() caches its result in - # _content, and we discover files by listening for loader calls. On - # an already-parsed tree the cache is populated, .load() returns - # without calling the loader, the listener never fires, and the - # referenced files would be silently dropped from the bundle. - with yaml_util.track_yaml_loads() as loaded_files: - try: - data = yaml_util.load_yaml(self._config_path) - except EsphomeError: - _LOGGER.debug( - "Bundle: re-loading YAML for include discovery failed, " - "proceeding with partial file list" - ) - else: - _force_load_include_files(data) - - for fpath in loaded_files: - if fpath == self._config_path.resolve(): + discovered = yaml_util.discover_user_yaml_files(self._config_path) + self._secrets_paths.update(discovered.secrets) + config_resolved = self._config_path.resolve() + for fpath in discovered.files: + if fpath == config_resolved: continue # Already added as config - if fpath.name in const.SECRETS_FILES: - self._secrets_paths.add(fpath) self._add_file(fpath) def _discover_component_files(self) -> None: @@ -625,57 +603,6 @@ def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None: tar.addfile(info, io.BytesIO(data)) -def _force_load_include_files(obj: Any, _seen: set[int] | None = None) -> None: - """Recursively resolve any ``IncludeFile`` instances in a YAML tree. - - Nested ``!include`` returns a deferred ``IncludeFile`` that is only - resolved during the substitution pass. During bundle discovery we need - the referenced files to actually load so the ``track_yaml_loads`` - listener fires for them. - - ``IncludeFile`` instances with unresolved substitution variables in the - filename cannot be loaded — we skip and warn about those. - """ - if _seen is None: - _seen = set() - - if isinstance(obj, yaml_util.IncludeFile): - if id(obj) in _seen: - return - _seen.add(id(obj)) - if obj.has_unresolved_expressions(): - _LOGGER.warning( - "Bundle: cannot resolve !include %s (referenced from %s) " - "with substitutions in path", - obj.file, - obj.parent_file, - ) - return - try: - loaded = obj.load() - except EsphomeError as err: - _LOGGER.warning( - "Bundle: failed to load !include %s (referenced from %s): %s", - obj.file, - obj.parent_file, - err, - ) - return - _force_load_include_files(loaded, _seen) - elif isinstance(obj, dict): - if id(obj) in _seen: - return - _seen.add(id(obj)) - for value in obj.values(): - _force_load_include_files(value, _seen) - elif isinstance(obj, (list, tuple)): - if id(obj) in _seen: - return - _seen.add(id(obj)) - for item in obj: - _force_load_include_files(item, _seen) - - def _resolve_include_path(include_path: Any) -> Path | None: """Resolve an include path to absolute, skipping system includes.""" if isinstance(include_path, str) and include_path.startswith("<"): diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index b153d160a7..b56d024418 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Callable, Generator from contextlib import contextmanager, suppress +from dataclasses import dataclass, field import functools import inspect from io import BytesIO, TextIOBase, TextIOWrapper @@ -233,6 +234,130 @@ class IncludeFile: return has_substitution_or_expression(str(self.file)) +def force_load_include_files( + obj: Any, + *, + warn_on_unresolved: bool = True, + _seen: set[int] | None = None, +) -> None: + """Recursively resolve any deferred ``IncludeFile`` instances in a YAML tree. + + Nested ``!include`` returns a deferred ``IncludeFile`` that is only resolved + later (substitution / packages pass). Callers that need every referenced + file to actually load — bundle discovery, on-device YAML recovery — invoke + this while a :func:`track_yaml_loads` listener is active so the underlying + loader fires and records every reachable file. + + ``IncludeFile`` instances whose path contains unresolved substitution + variables cannot be loaded. By default a warning is logged for each one; + pass ``warn_on_unresolved=False`` (used by discovery paths that run on a + fresh re-parse where substitutions haven't been applied yet) to demote it + to a debug log. + """ + if _seen is None: + _seen = set() + + if isinstance(obj, IncludeFile): + if id(obj) in _seen: + return + _seen.add(id(obj)) + if obj.has_unresolved_expressions(): + log = _LOGGER.warning if warn_on_unresolved else _LOGGER.debug + log( + "Cannot resolve !include %s (referenced from %s) with substitutions in path", + obj.file, + obj.parent_file, + ) + return + try: + loaded = obj.load() + except EsphomeError as err: + _LOGGER.warning( + "Failed to load !include %s (referenced from %s): %s", + obj.file, + obj.parent_file, + err, + ) + return + force_load_include_files( + loaded, warn_on_unresolved=warn_on_unresolved, _seen=_seen + ) + elif isinstance(obj, dict): + if id(obj) in _seen: + return + _seen.add(id(obj)) + for value in obj.values(): + force_load_include_files( + value, warn_on_unresolved=warn_on_unresolved, _seen=_seen + ) + elif isinstance(obj, (list, tuple)): + if id(obj) in _seen: + return + _seen.add(id(obj)) + for item in obj: + force_load_include_files( + item, warn_on_unresolved=warn_on_unresolved, _seen=_seen + ) + + +@dataclass(slots=True) +class DiscoveredYamlFiles: + """Result of :func:`discover_user_yaml_files`. + + ``files`` contains every resolved path the YAML loader touched while we + were re-parsing the user's config; ``secrets`` is the subset whose + *un-resolved* filename matched :data:`esphome.const.SECRETS_FILES` (so + a ``secrets.yaml`` symlinked to a differently-named target is still + flagged as secrets). + """ + + files: list[Path] = field(default_factory=list) + secrets: set[Path] = field(default_factory=set) + + +def discover_user_yaml_files(config_path: Path) -> DiscoveredYamlFiles: + """Fresh-re-parse ``config_path`` and report every file the YAML loader + pulled in, plus which of them came in under a secrets filename. + + Does NOT run schema validation, substitutions, or package resolution — so + component-internal YAML loaded by validators (LVGL helpers, dashboard + imports, etc.) is *not* captured. Deferred ``!include`` references whose + paths don't depend on substitutions are force-loaded here so they're + captured too. + + Must run on a fresh parse because :meth:`IncludeFile.load` caches its + result; on an already-resolved tree :meth:`load` returns without invoking + the loader and the listener would not fire for the referenced files. + """ + from esphome.const import SECRETS_FILES + + secrets: set[Path] = set() + + def _capture_secret(fname: Path) -> None: + if Path(fname).name in SECRETS_FILES: + secrets.add(Path(fname).resolve()) + + with track_yaml_loads() as loaded: + _load_listeners.append(_capture_secret) + try: + try: + data = load_yaml(config_path) + except EsphomeError: + return DiscoveredYamlFiles(list(loaded), secrets) + force_load_include_files(data, warn_on_unresolved=False) + finally: + _load_listeners.remove(_capture_secret) + + # Deduplicate while preserving first-seen order. + seen: set[Path] = set() + unique: list[Path] = [] + for path in loaded: + if path not in seen: + seen.add(path) + unique.append(path) + return DiscoveredYamlFiles(unique, secrets) + + def _add_data_ref(fn): @functools.wraps(fn) def wrapped(loader, node): diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index 5d046252da..f15bbf2e29 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -22,13 +22,13 @@ from esphome.bundle import ( _add_bytes_to_tar, _default_target_dir, _find_used_secret_keys, - _force_load_include_files, extract_bundle, is_bundle_path, prepare_bundle_for_compile, read_bundle_manifest, ) from esphome.core import CORE, EsphomeError +from esphome.yaml_util import force_load_include_files # --------------------------------------------------------------------------- # Helpers @@ -947,7 +947,7 @@ def test_discover_files_nested_include_load_failure( paths = [f.path for f in files] assert "test.yaml" in paths assert any( - "failed to load !include" in r.message and "missing.yaml" in r.message + "failed to load !include" in r.message.lower() and "missing.yaml" in r.message for r in caplog.records ) @@ -974,8 +974,8 @@ def test_force_load_skips_duplicate_include_file() -> None: # Same instance appears twice — second visit must hit the _seen guard. tree = {"a": stub, "b": [stub]} - with patch("esphome.bundle.yaml_util.IncludeFile", _StubInclude): - _force_load_include_files(tree) + with patch("esphome.yaml_util.IncludeFile", _StubInclude): + force_load_include_files(tree) assert stub.load_calls == 1 @@ -989,8 +989,8 @@ def test_force_load_handles_cyclic_containers() -> None: cyclic_list.append(cyclic_list) # Should return without recursing forever - _force_load_include_files(cyclic_dict) - _force_load_include_files(cyclic_list) + force_load_include_files(cyclic_dict) + force_load_include_files(cyclic_list) def test_discover_files_yaml_reload_failure( diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index ace92fbf6f..e97a188be4 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -12,11 +12,15 @@ import esphome.config_validation as cv from esphome.core import DocumentLocation, DocumentRange, EsphomeError from esphome.util import OrderedDict from esphome.yaml_util import ( + DiscoveredYamlFiles, ESPHomeDataBase, ESPLiteralValue, + discover_user_yaml_files, + force_load_include_files, format_path, make_data_base, make_literal, + track_yaml_loads, ) @@ -966,3 +970,215 @@ def test_make_literal_blocks_substitution() -> None: # undefined in the context. assert result == {"pin": "${PIN}"} assert isinstance(result, ESPLiteralValue) + + +# --------------------------------------------------------------------------- +# force_load_include_files / discover_user_yaml_files +# --------------------------------------------------------------------------- + + +class _StubInclude: + """Stand-in for `IncludeFile` that records how `load()` was called. + + Patched in via `esphome.yaml_util.IncludeFile` so the recursion in + `force_load_include_files` treats instances as deferred includes without + needing an actual on-disk file. + """ + + def __init__( + self, + file: str = "stub.yaml", + parent_file: Path | None = None, + *, + unresolved: bool = False, + load_result: object = None, + raise_on_load: EsphomeError | None = None, + ) -> None: + self.file = Path(file) + self.parent_file = parent_file or Path("/tmp/parent.yaml") + self._unresolved = unresolved + self._load_result = load_result if load_result is not None else {} + self._raise = raise_on_load + self.load_calls = 0 + + def has_unresolved_expressions(self) -> bool: + return self._unresolved + + def load(self) -> object: + self.load_calls += 1 + if self._raise is not None: + raise self._raise + return self._load_result + + +@pytest.fixture +def patch_include_file(): + """Replace `IncludeFile` with `_StubInclude` so isinstance checks in + `force_load_include_files` match the stubs constructed by tests.""" + with patch("esphome.yaml_util.IncludeFile", _StubInclude): + yield + + +def test_force_load_include_files_resolves_nested_includes( + patch_include_file: None, +) -> None: + """A tree of dict/list/IncludeFile is walked and every IncludeFile is loaded.""" + inner = _StubInclude("inner.yaml") + outer = _StubInclude("outer.yaml", load_result={"nested": inner}) + force_load_include_files([{"a": outer}, "scalar"]) + assert outer.load_calls == 1 + assert inner.load_calls == 1 + + +def test_force_load_include_files_seen_guard_prevents_double_load( + patch_include_file: None, +) -> None: + """The same IncludeFile referenced from two branches loads once.""" + stub = _StubInclude("once.yaml") + force_load_include_files({"a": stub, "b": [stub]}) + assert stub.load_calls == 1 + + +def test_force_load_include_files_handles_cyclic_containers() -> None: + """Cyclic dict/list references don't trigger infinite recursion.""" + cyclic_dict: dict[str, object] = {} + cyclic_dict["self"] = cyclic_dict + cyclic_list: list[object] = [] + cyclic_list.append(cyclic_list) + # Both calls must return without recursing forever. + force_load_include_files(cyclic_dict) + force_load_include_files(cyclic_list) + + +@pytest.mark.parametrize( + ("warn_on_unresolved", "expect_level"), + [ + pytest.param(True, "WARNING", id="default-warns"), + pytest.param(False, "DEBUG", id="opt-in-demotes"), + ], +) +def test_force_load_include_files_unresolved_log_level( + patch_include_file: None, + caplog: pytest.LogCaptureFixture, + warn_on_unresolved: bool, + expect_level: str, +) -> None: + """Substitution-templated include paths skip the load and log at the + level chosen by `warn_on_unresolved`.""" + stub = _StubInclude("${var}.yaml", unresolved=True) + with caplog.at_level("DEBUG", logger="esphome.yaml_util"): + force_load_include_files({"k": stub}, warn_on_unresolved=warn_on_unresolved) + assert stub.load_calls == 0 + matching = [ + r.levelname for r in caplog.records if "Cannot resolve !include" in r.message + ] + assert matching == [expect_level] + + +def test_force_load_include_files_warns_on_load_failure( + patch_include_file: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """An `EsphomeError` raised by `load()` is caught and logged, not propagated.""" + stub = _StubInclude("missing.yaml", raise_on_load=EsphomeError("boom")) + with caplog.at_level("WARNING", logger="esphome.yaml_util"): + force_load_include_files({"k": stub}) + assert any( + "Failed to load !include" in r.message and "missing.yaml" in r.message + for r in caplog.records + ) + + +def test_discovered_yaml_files_holds_files_and_secrets() -> None: + """`DiscoveredYamlFiles` is a small data carrier; both fields are mandatory.""" + files = [Path("/tmp/a.yaml")] + secrets = {Path("/tmp/a.yaml")} + discovered = DiscoveredYamlFiles(files, secrets) + assert discovered.files is files + assert discovered.secrets is secrets + + +def _write(tmp_path: Path, name: str, content: str) -> Path: + """Write `content` to `tmp_path/name`, creating parent dirs as needed.""" + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + return path + + +def _write_entry_including(tmp_path: Path, included_name: str) -> Path: + """Write a minimal entry yaml that `!include`s `included_name`.""" + return _write( + tmp_path, + "entry.yaml", + f"esphome:\n name: test\nwifi: !include {included_name}\n", + ) + + +def test_discover_user_yaml_files_captures_includes(tmp_path: Path) -> None: + """A `!include` in the entry yaml is force-loaded so the listener fires.""" + _write(tmp_path, "wifi.yaml", "ssid: my_ssid\npassword: my_pw\n") + discovered = discover_user_yaml_files(_write_entry_including(tmp_path, "wifi.yaml")) + names = {p.name for p in discovered.files} + assert names == {"entry.yaml", "wifi.yaml"} + assert discovered.secrets == set() + + +@pytest.mark.parametrize( + "secret_name", + [ + pytest.param("secrets.yaml", id="yaml"), + pytest.param("secrets.yml", id="yml"), + ], +) +def test_discover_user_yaml_files_flags_secrets_filename( + tmp_path: Path, secret_name: str +) -> None: + """Both `secrets.yaml` and `secrets.yml` get flagged in `.secrets`.""" + _write(tmp_path, secret_name, "key: value\n") + discovered = discover_user_yaml_files(_write_entry_including(tmp_path, secret_name)) + assert (tmp_path / secret_name).resolve() in discovered.secrets + + +def test_discover_user_yaml_files_flags_secrets_symlink(tmp_path: Path) -> None: + """`secrets.yaml` symlinked to a non-secrets-named target is still flagged + because the un-resolved basename is what gets recorded.""" + target = _write(tmp_path, "real_creds.yaml", "key: value\n") + (tmp_path / "secrets.yaml").symlink_to(target) + discovered = discover_user_yaml_files( + _write_entry_including(tmp_path, "secrets.yaml") + ) + # The recorded "secret path" is the resolved target — even though its + # basename is `real_creds.yaml`, it's still in `.secrets`. + assert target.resolve() in discovered.secrets + + +def test_discover_user_yaml_files_swallows_parse_errors(tmp_path: Path) -> None: + """A YAML parse failure returns whatever was tracked so far without raising.""" + entry = _write(tmp_path, "entry.yaml", "esphome: [unterminated\n") + discovered = discover_user_yaml_files(entry) + assert isinstance(discovered, DiscoveredYamlFiles) + + +def test_discover_user_yaml_files_deduplicates(tmp_path: Path) -> None: + """The same file referenced twice appears once in `.files`.""" + _write(tmp_path, "wifi.yaml", "ssid: a\n") + entry = _write( + tmp_path, + "entry.yaml", + "esphome:\n name: test\nwifi: !include wifi.yaml\nfoo: !include wifi.yaml\n", + ) + discovered = discover_user_yaml_files(entry) + wifi_resolved = (tmp_path / "wifi.yaml").resolve() + assert discovered.files.count(wifi_resolved) == 1 + + +def test_track_yaml_loads_records_resolved_paths(tmp_path: Path) -> None: + """`track_yaml_loads` is the building block — sanity-check it resolves + symlinks so callers can dedupe by identity.""" + target = _write(tmp_path, "actual.yaml", "esphome:\n name: t\n") + link = tmp_path / "alias.yaml" + link.symlink_to(target) + with track_yaml_loads() as loaded: + yaml_util.load_yaml(link) + assert target.resolve() in loaded From 46be0f4f62141a6dc5bfe7b55acb753b2b64c343 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 10:18:07 -0700 Subject: [PATCH 0043/1815] [ci] Log top 30 pytest durations (#16455) --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abd2d1b3a5..57c896fe19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -181,7 +181,7 @@ jobs: # own CI). No ``--cov`` here -- this is purely a downstream # smoke check against this PR's esphome code. working-directory: device-builder - run: pytest -q -n auto --maxfail=5 --durations=10 --no-cov --ignore=tests/benchmarks + run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks pytest: name: Run pytest @@ -222,12 +222,12 @@ jobs: if: matrix.os == 'windows-latest' run: | . ./venv/Scripts/activate.ps1 - pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/ + pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/ - name: Run pytest if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest' run: | . venv/bin/activate - pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/ + pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/ - name: Upload coverage to Codecov uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 with: @@ -370,7 +370,7 @@ jobs: . venv/bin/activate mapfile -t test_files < <(echo "$BUCKET_TESTS" | jq -r '.[]') echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests" - pytest -vv --no-cov --tb=native -n auto "${test_files[@]}" + pytest -vv --no-cov --tb=native --durations=30 -n auto "${test_files[@]}" cpp-unit-tests: name: Run C++ unit tests From 1674ed9744414bc00f91a8ae5b9255dbeac2f19f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 10:18:27 -0700 Subject: [PATCH 0044/1815] [ci] Use uv for pip installs across CI workflows (#16451) --- .github/actions/restore-python/action.yml | 16 ++++++++++++---- .github/workflows/ci-api-proto.yml | 8 +++++++- .github/workflows/ci.yml | 22 ++++++++++++++++++---- 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 21393f2aba..751f9ecf58 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -27,6 +27,14 @@ runs: path: venv # yamllint disable-line rule:line-length key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ inputs.cache-key }} + - name: Set up uv + # Only needed on cache miss to populate the venv. ``uv pip install`` + # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout + # downstream jobs rely on is preserved. + if: steps.cache-venv.outputs.cache-hit != 'true' + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os != 'Windows' shell: bash @@ -34,8 +42,8 @@ runs: python -m venv venv source venv/bin/activate python --version - pip install -r requirements.txt -r requirements_test.txt - pip install -e . + uv pip install -r requirements.txt -r requirements_test.txt + uv pip install -e . - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os == 'Windows' shell: bash @@ -43,5 +51,5 @@ runs: python -m venv venv source ./venv/Scripts/activate python --version - pip install -r requirements.txt -r requirements_test.txt - pip install -e . + uv pip install -r requirements.txt -r requirements_test.txt + uv pip install -e . diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 2f7fd271ba..1dc0ccb7fe 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -26,6 +26,12 @@ jobs: uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.11" + - name: Set up uv + # ``--system`` (below) installs into the setup-python interpreter; + # no venv is created or restored by this workflow. + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true - name: Install apt dependencies run: | @@ -34,7 +40,7 @@ jobs: sudo apt install -y protobuf-compiler protoc --version - name: Install python dependencies - run: pip install aioesphomeapi -c requirements.txt -r requirements_dev.txt + run: uv pip install --system aioesphomeapi -c requirements.txt -r requirements_dev.txt - name: Generate files run: script/api_protobuf/api_protobuf.py - name: Check for changes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57c896fe19..de21456841 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,14 +52,22 @@ jobs: path: venv # yamllint disable-line rule:line-length key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ steps.cache-key.outputs.key }} + - name: Set up uv + # Only needed on cache miss to populate the venv. ``uv pip install`` + # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs + # that ``. venv/bin/activate`` see an identical layout. + if: steps.cache-venv.outputs.cache-hit != 'true' + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' run: | python -m venv venv . venv/bin/activate python --version - pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit - pip install -e . + uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit + uv pip install -e . pylint: name: Check pylint @@ -351,14 +359,20 @@ jobs: with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} + - name: Set up uv + # Only needed on cache miss to populate the venv. + if: steps.cache-venv.outputs.cache-hit != 'true' + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' run: | python -m venv venv . venv/bin/activate python --version - pip install -r requirements.txt -r requirements_test.txt - pip install -e . + uv pip install -r requirements.txt -r requirements_test.txt + uv pip install -e . - name: Register matcher run: echo "::add-matcher::.github/workflows/matchers/pytest.json" - name: Run integration tests From 96106d25bc4555423ce7a05f2a8ee0888ed2641e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 10:20:15 -0700 Subject: [PATCH 0045/1815] [wifi] Refuse to compile when wifi_ssid is the device-builder placeholder (#16444) --- esphome/__main__.py | 8 +++ esphome/components/wifi/__init__.py | 52 +++++++++++++++- esphome/const.py | 9 +++ tests/unit_tests/components/test_wifi.py | 78 +++++++++++++++++++++++- 4 files changed, 144 insertions(+), 3 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index d733534a5c..16a05ad552 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -50,6 +50,7 @@ from esphome.const import ( CONF_TOPIC, CONF_USERNAME, CONF_WEB_SERVER, + CONF_WIFI, ENV_NOGITIGNORE, KEY_CORE, KEY_TARGET_PLATFORM, @@ -733,6 +734,13 @@ def write_cpp_file() -> int: def compile_program(args: ArgsProtocol, config: ConfigType) -> int: + # Keep this gate here, NOT in config validation: device-builder needs + # `esphome config` to keep succeeding with placeholders so onboarding can run. + if CONF_WIFI in config: + from esphome.components.wifi import check_placeholder_credentials + + check_placeholder_credentials(config) + # NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py # If you change this format, update the regex in that script as well _LOGGER.info("Compiling app... Build path: %s", CORE.build_path) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index bad57fc481..f9cb391442 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -54,10 +54,18 @@ from esphome.const import ( CONF_TTLS_PHASE_2, CONF_USE_ADDRESS, CONF_USERNAME, + CONF_WIFI, + PLACEHOLDER_WIFI_SSID, Platform, PlatformFramework, ) -from esphome.core import CORE, CoroPriority, HexInt, coroutine_with_priority +from esphome.core import ( + CORE, + CoroPriority, + EsphomeError, + HexInt, + coroutine_with_priority, +) import esphome.final_validate as fv from esphome.types import ConfigType @@ -903,3 +911,45 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( "wifi_component_pico_w.cpp": {PlatformFramework.RP2040_ARDUINO}, } ) + + +def _placeholder_wifi_credentials(config: ConfigType) -> list[str]: + """Return human-readable locations where the dashboard's placeholder wifi + values still appear. Empty list means no placeholders were found. + """ + placeholders: list[str] = [] + wifi_conf = config.get(CONF_WIFI) + if not wifi_conf: + return placeholders + + for idx, network in enumerate(wifi_conf.get(CONF_NETWORKS, [])): + ssid = network.get(CONF_SSID) + if isinstance(ssid, str) and ssid == PLACEHOLDER_WIFI_SSID: + placeholders.append(f"wifi.networks[{idx}].ssid") + + ap_conf = wifi_conf.get(CONF_AP) + if ap_conf: + ap_ssid = ap_conf.get(CONF_SSID) + if isinstance(ap_ssid, str) and ap_ssid == PLACEHOLDER_WIFI_SSID: + placeholders.append("wifi.ap.ssid") + + return placeholders + + +def check_placeholder_credentials(config: ConfigType) -> None: + """Raise EsphomeError if any wifi credential is the dashboard placeholder. + + Call only at compile time. NEVER from CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA, + or any path reached by `esphome config`; device-builder relies on + validation passing with the placeholders still in place. + """ + locations = _placeholder_wifi_credentials(config) + if not locations: + return + formatted = ", ".join(locations) + raise EsphomeError( + f"wifi configuration still contains the dashboard placeholder value " + f"'{PLACEHOLDER_WIFI_SSID}' at: {formatted}. " + f"Open secrets.yaml and replace 'wifi_ssid' (and 'wifi_password') " + f"with your real wifi credentials before flashing." + ) diff --git a/esphome/const.py b/esphome/const.py index 4557380c73..9dd77a7cb8 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1416,3 +1416,12 @@ ENTITY_CATEGORY_DIAGNOSTIC = "diagnostic" # The corresponding constant exists in c++ # when update_interval is set to never, it becomes SCHEDULER_DONT_RUN milliseconds SCHEDULER_DONT_RUN = 4294967295 + +# Sentinel values written by the esphome-device-builder dashboard into +# secrets.yaml on first boot so that !secret wifi_ssid / !secret wifi_password +# references resolve cleanly through validation before the user has finished +# the onboarding wizard. Compilation refuses if these reach the binary so that +# a user who dismisses onboarding can't accidentally flash a device that will +# never associate with their wifi. +PLACEHOLDER_WIFI_SSID = "REPLACE_WITH_YOUR_WIFI_NETWORK" +PLACEHOLDER_WIFI_PASSWORD = "REPLACE_WITH_YOUR_WIFI_PASSWORD" # noqa: S105 diff --git a/tests/unit_tests/components/test_wifi.py b/tests/unit_tests/components/test_wifi.py index 71a14d7817..9598c1bdd8 100644 --- a/tests/unit_tests/components/test_wifi.py +++ b/tests/unit_tests/components/test_wifi.py @@ -3,8 +3,20 @@ import pytest from esphome.components.esp32 import const -from esphome.components.wifi import has_native_wifi, variant_has_wifi -from esphome.const import Platform +from esphome.components.wifi import ( + check_placeholder_credentials, + has_native_wifi, + variant_has_wifi, +) +from esphome.const import ( + CONF_AP, + CONF_NETWORKS, + CONF_SSID, + CONF_WIFI, + PLACEHOLDER_WIFI_SSID, + Platform, +) +from esphome.core import EsphomeError, Lambda @pytest.mark.parametrize( @@ -123,3 +135,65 @@ def test_has_native_wifi_esp32_without_variant_assumes_wifi() -> None: def test_has_native_wifi_rp2040_without_board_assumes_wifi() -> None: """RP2040 without a board id falls open to True (custom-board default).""" assert has_native_wifi(platform=Platform.RP2040) is True + + +def _wifi_config( + *, + networks: list[dict] | None = None, + ap: dict | None = None, +) -> dict: + """Build a minimal config dict matching the post-validation shape.""" + wifi: dict = {} + if networks is not None: + wifi[CONF_NETWORKS] = networks + if ap is not None: + wifi[CONF_AP] = ap + return {CONF_WIFI: wifi} + + +def test_check_placeholder_credentials_passes_with_real_ssid() -> None: + """A real SSID compiles without complaint.""" + config = _wifi_config(networks=[{CONF_SSID: "home_network"}]) + assert check_placeholder_credentials(config) is None + + +def test_check_placeholder_credentials_refuses_placeholder_ssid() -> None: + """The placeholder SSID is rejected with an actionable message.""" + config = _wifi_config(networks=[{CONF_SSID: PLACEHOLDER_WIFI_SSID}]) + with pytest.raises(EsphomeError) as exc_info: + check_placeholder_credentials(config) + message = str(exc_info.value) + assert "wifi.networks[0].ssid" in message + assert "secrets.yaml" in message + + +def test_check_placeholder_credentials_refuses_placeholder_in_second_network() -> None: + """Index reporting picks the placeholder out of a mixed network list.""" + config = _wifi_config( + networks=[ + {CONF_SSID: "home_network"}, + {CONF_SSID: PLACEHOLDER_WIFI_SSID}, + ], + ) + with pytest.raises(EsphomeError) as exc_info: + check_placeholder_credentials(config) + assert "wifi.networks[1].ssid" in str(exc_info.value) + + +def test_check_placeholder_credentials_refuses_placeholder_ap_ssid() -> None: + """An AP using the placeholder broadcast name is also refused.""" + config = _wifi_config(ap={CONF_SSID: PLACEHOLDER_WIFI_SSID}) + with pytest.raises(EsphomeError) as exc_info: + check_placeholder_credentials(config) + assert "wifi.ap.ssid" in str(exc_info.value) + + +def test_check_placeholder_credentials_no_wifi_passes() -> None: + """Ethernet-only / wifi-less configs skip the check entirely.""" + assert check_placeholder_credentials({}) is None + + +def test_check_placeholder_credentials_skips_template_ssid() -> None: + """A templated (Lambda) SSID is not a string and is skipped.""" + config = _wifi_config(networks=[{CONF_SSID: Lambda('return "x";')}]) + assert check_placeholder_credentials(config) is None From 35631be260c0fd6fae1e4c945f16790979ba777c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 10:20:31 -0700 Subject: [PATCH 0046/1815] [writer] Mark storage_should_clean as public API for device-builder (#16443) --- esphome/writer.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/esphome/writer.py b/esphome/writer.py index 72c2c355dc..ad3877465d 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -87,6 +87,21 @@ def replace_file_content(text, pattern, repl): def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: + """Return True when the build tree must be wiped before reuse. + + Predicate is True when *old* is missing (first build), + ``src_version`` differs, ``build_path`` differs, or a previously + loaded integration was removed in *new*. Adding integrations or + changing unrelated fields (friendly name, esphome version, etc.) + does not trigger a clean. + + Used by esphome-device-builder (esphome/device-builder) to gate + its remote-build artifact materialiser so a local → remote → local + cycle preserves PlatformIO's local object cache instead of wiping + it on every cycle. The signature, semantics, and ``None`` handling + for *old* are part of the public contract; keep them stable so the + offloader's wipe decision tracks core's. + """ if old is None: return True From 47eb2adbf2c789d62405d69954d468ddb51d40a5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 10:29:15 -0700 Subject: [PATCH 0047/1815] [core] Fix KeyError: 'esp32' on upload when validated-config cache is used (#16457) --- esphome/storage_json.py | 13 +++++- tests/unit_tests/test_compiled_config.py | 56 ++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 7d26b22f96..e481827080 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -273,10 +273,21 @@ class StorageJSON: """ CORE.name = self.name CORE.build_path = self.build_path + target_platform = self.core_platform or self.target_platform.lower() CORE.data[KEY_CORE] = { - KEY_TARGET_PLATFORM: self.core_platform or self.target_platform.lower(), + KEY_TARGET_PLATFORM: target_platform, KEY_TARGET_FRAMEWORK: self.framework, } + # The compile pipeline populates CORE.data[KEY_ESP32] when esp32's + # validator runs; on the cache fast path that validator is skipped, + # so populate the variant upload_using_esptool reads via + # esp32.get_esp32_variant(). target_platform on disk is the variant + # (e.g. "ESP32S3"); core_platform is the family (e.g. "esp32"). + if target_platform == const.PLATFORM_ESP32: + from esphome.components.esp32.const import KEY_ESP32 + from esphome.const import KEY_VARIANT + + CORE.data[KEY_ESP32] = {KEY_VARIANT: self.target_platform} def __eq__(self, o) -> bool: return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict() diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index 34e811b97b..8c9cfa8101 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -22,6 +22,7 @@ from esphome.const import ( KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, + KEY_VARIANT, ) from esphome.core import CORE @@ -47,7 +48,12 @@ wifi: """ -def _write_storage(storage_path: Path) -> None: +def _write_storage( + storage_path: Path, + *, + esp_platform: str = "ESP32", + core_platform: str | None = "esp32", +) -> None: """Write a vanilla StorageJSON sidecar for the cache tests.""" storage_path.parent.mkdir(parents=True, exist_ok=True) data = { @@ -59,14 +65,14 @@ def _write_storage(storage_path: Path) -> None: "src_version": 1, "address": "192.168.1.42", "web_port": None, - "esp_platform": "ESP32", + "esp_platform": esp_platform, "build_path": "/build/lite_test", "firmware_bin_path": "/build/lite_test/firmware.bin", "loaded_integrations": ["api", "logger", "ota", "wifi"], "loaded_platforms": [], "no_mdns": False, "framework": "arduino", - "core_platform": "esp32", + "core_platform": core_platform, } storage_path.write_text(json.dumps(data)) @@ -123,6 +129,50 @@ def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None: assert CORE.build_path == Path("/build/lite_test") assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == "esp32" assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "arduino" + # upload_using_esptool reads get_esp32_variant() off CORE.data[KEY_ESP32]. + from esphome.components.esp32.const import KEY_ESP32 + + assert CORE.data[KEY_ESP32][KEY_VARIANT] == "ESP32" + + +def test_load_compiled_config_populates_esp32_variant(tmp_path: Path) -> None: + """ESP32 variants survive the cache fast path so esptool gets the right --chip.""" + from esphome.components.esp32.const import KEY_ESP32 + + yaml_path = tmp_path / "lite_test.yaml" + yaml_path.write_text("esphome:\n name: lite_test\n") + CORE.config_path = yaml_path + + storage_dir = tmp_path / ".esphome" / "storage" + _write_storage(storage_dir / "lite_test.yaml.json", esp_platform="ESP32S3") + cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + _set_cache_mtime(cache, yaml_path, offset=5) + + assert load_compiled_config(yaml_path) is not None + assert CORE.data[KEY_ESP32][KEY_VARIANT] == "ESP32S3" + + +def test_load_compiled_config_skips_esp32_block_for_other_platforms( + tmp_path: Path, +) -> None: + """Non-esp32 targets shouldn't fabricate an esp32 data block.""" + from esphome.components.esp32.const import KEY_ESP32 + + yaml_path = tmp_path / "lite_test.yaml" + yaml_path.write_text("esphome:\n name: lite_test\n") + CORE.config_path = yaml_path + + storage_dir = tmp_path / ".esphome" / "storage" + _write_storage( + storage_dir / "lite_test.yaml.json", + esp_platform="ESP8266", + core_platform="esp8266", + ) + cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + _set_cache_mtime(cache, yaml_path, offset=5) + + assert load_compiled_config(yaml_path) is not None + assert KEY_ESP32 not in CORE.data @pytest.mark.parametrize( From 65d6bb18ed09c4d916ed7c6f1ad98eae2284a34b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 15 May 2026 13:32:51 -0400 Subject: [PATCH 0048/1815] [esp32_hosted][fingerprint_grow] Fix two remaining ESP32 toolchain warnings (#16442) --- esphome/components/esp32_hosted/update/esp32_hosted_update.cpp | 2 +- esphome/components/fingerprint_grow/fingerprint_grow.cpp | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index 7f3ba77895..70fa41b312 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -121,7 +121,7 @@ void Esp32HostedUpdate::setup() { } } else { ESP_LOGW(TAG, "Invalid app description magic word: 0x%08" PRIx32 " (expected 0x%08" PRIx32 ")", - app_desc->magic_word, ESP_APP_DESC_MAGIC_WORD); + app_desc->magic_word, static_cast(ESP_APP_DESC_MAGIC_WORD)); this->state_ = update::UPDATE_STATE_NO_UPDATE; } } else { diff --git a/esphome/components/fingerprint_grow/fingerprint_grow.cpp b/esphome/components/fingerprint_grow/fingerprint_grow.cpp index 3f57789034..b38d42191b 100644 --- a/esphome/components/fingerprint_grow/fingerprint_grow.cpp +++ b/esphome/components/fingerprint_grow/fingerprint_grow.cpp @@ -206,6 +206,7 @@ uint8_t FingerprintGrowComponent::save_fingerprint_() { break; case ENROLL_MISMATCH: ESP_LOGE(TAG, "Scans do not match"); + [[fallthrough]]; default: return this->data_[0]; } From fb70095ba194b3d01f1a0801dd2cfdec9d7e0290 Mon Sep 17 00:00:00 2001 From: david-collett Date: Sat, 16 May 2026 03:47:26 +1000 Subject: [PATCH 0049/1815] [esp32_ble_server] Fix incorrect BLECharacteristic read truncation (#16420) (#16422) Co-authored-by: Dave Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../esp32_ble_server/ble_characteristic.cpp | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/esphome/components/esp32_ble_server/ble_characteristic.cpp b/esphome/components/esp32_ble_server/ble_characteristic.cpp index cc519846be..842c78a8aa 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_server/ble_characteristic.cpp @@ -218,13 +218,14 @@ void BLECharacteristic::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt } } else { response.attr_value.offset = 0; - if (this->value_.size() + 1 > max_offset) { - response.attr_value.len = max_offset; - this->value_read_offset_ = max_offset; - } else { - response.attr_value.len = this->value_.size(); + response.attr_value.len = this->value_.size(); + if (response.attr_value.len > ESP_GATT_MAX_ATTR_LEN) { + ESP_LOGW(TAG, "Characteristic length %u exceeds buffer size of %u, truncating", response.attr_value.len, + ESP_GATT_MAX_ATTR_LEN); + response.attr_value.len = ESP_GATT_MAX_ATTR_LEN; } memcpy(response.attr_value.value, this->value_.data(), response.attr_value.len); + this->value_read_offset_ = 0; } response.attr_value.handle = this->handle_; From 59f8c1019f23b603344014ddba4258e95f4e30c8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 12:37:28 -0700 Subject: [PATCH 0050/1815] Bump pytest-codspeed from 5.0.1 to 5.0.2 (#16459) Signed-off-by: dependabot[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 9050132e70..ea4941a882 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -16,7 +16,7 @@ hypothesis==6.92.1 # CodSpeed benchmarks under tests/benchmarks/python/ # (skipped via pytest.importorskip when missing -- only required for the # benchmarks job in .github/workflows/ci.yml) -pytest-codspeed==5.0.1 +pytest-codspeed==5.0.2 # Used by the import-time regression check (.github/workflows/ci.yml → import-time job) importtime-waterfall==1.0.0 From ec6669fa679ffed9d371c42ad8d3c9ee935b0bb8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 12:37:41 -0700 Subject: [PATCH 0051/1815] Bump requests from 2.34.1 to 2.34.2 (#16460) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6291b5cd41..a0a7ea5674 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ freetype-py==2.5.1 jinja2==3.1.6 bleak==2.1.1 smpclient==6.0.0 -requests==2.34.1 +requests==2.34.2 # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 From 4c090c6b8512adf16cf45fc0b71a031bb0b12407 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 12:37:56 -0700 Subject: [PATCH 0052/1815] Bump github/codeql-action from 4.35.4 to 4.35.5 (#16461) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0a4dd9a92d..fbef0f5157 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 with: category: "/language:${{matrix.language}}" From b78b78cbbbf1ee8ca65ef6ff563faadb449fe2ff Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 14:42:51 -0700 Subject: [PATCH 0053/1815] Bump aioesphomeapi from 45.0.0 to 45.0.1 (#16467) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a0a7ea5674..33a6c5b555 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==45.0.0 +aioesphomeapi==45.0.1 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From ff34e1061bc9ce55e39ff61f04a57a0258e94c76 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 14:43:08 -0700 Subject: [PATCH 0054/1815] Bump resvg-py from 0.3.1 to 0.3.2 (#16466) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 33a6c5b555..1996de0928 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 pillow==12.2.0 -resvg-py==0.3.1 +resvg-py==0.3.2 freetype-py==2.5.1 jinja2==3.1.6 bleak==2.1.1 From 1a287bf785af01ce50b52dcecc5a938babd75a01 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 16 May 2026 08:30:04 +1000 Subject: [PATCH 0055/1815] [ft5x06] Fix setting calibration values (#16446) --- .../ft5x06/touchscreen/ft5x06_touchscreen.cpp | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp b/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp index 835dc4aac0..24d3529fb4 100644 --- a/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp +++ b/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp @@ -15,6 +15,16 @@ void FT5x06Touchscreen::setup() { this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); } + // reading the chip registers to get max x/y does not seem to work. + if (this->display_ != nullptr) { + if (this->x_raw_max_ == this->x_raw_min_) { + this->x_raw_max_ = this->display_->get_native_width(); + } + if (this->y_raw_max_ == this->y_raw_min_) { + this->y_raw_max_ = this->display_->get_native_height(); + } + } + // wait 200ms after reset. this->set_timeout(200, [this] { this->continue_setup_(); }); } @@ -39,15 +49,6 @@ void FT5x06Touchscreen::continue_setup_() { this->mark_failed(); return; } - // reading the chip registers to get max x/y does not seem to work. - if (this->display_ != nullptr) { - if (this->x_raw_max_ == this->x_raw_min_) { - this->x_raw_max_ = this->display_->get_native_width(); - } - if (this->y_raw_max_ == this->y_raw_min_) { - this->y_raw_max_ = this->display_->get_native_height(); - } - } } void FT5x06Touchscreen::update_touches() { @@ -71,7 +72,7 @@ void FT5x06Touchscreen::update_touches() { uint16_t x = encode_uint16(data[i][0] & 0x0F, data[i][1]); uint16_t y = encode_uint16(data[i][2] & 0xF, data[i][3]); - ESP_LOGD(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y); + ESP_LOGV(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y); if (status == 0 || status == 2) { this->add_raw_touch_position_(id, x, y); } From df100681e0286ad13557a81027d46f962a21f349 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 16 May 2026 08:36:53 +1000 Subject: [PATCH 0056/1815] [lvgl] Fix image define (#16468) --- esphome/components/lvgl/__init__.py | 5 +++++ esphome/components/lvgl/lvgl_esphome.h | 10 ++++++---- esphome/components/lvgl/styles.py | 2 ++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 91b101cd25..4277c14dd7 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -55,6 +55,7 @@ from .automation import layers_to_code, lvgl_update from .defines import ( CONF_ALIGN_TO_LAMBDA_ID, LOGGER, + add_lv_use, get_focused_widgets, get_lv_images_used, get_refreshed_widgets, @@ -71,6 +72,7 @@ from .keypads import KEYPADS_CONFIG, keypads_to_code from .lv_validation import lv_bool from .lvcode import LvContext, LvglComponent, lv_event_t_ptr, lvgl_static from .schemas import ( + BASE_PROPS, DISP_BG_SCHEMA, FULL_STYLE_SCHEMA, STYLE_REMAP, @@ -100,6 +102,7 @@ from .widgets import ( get_screen_active, set_obj_properties, ) +from .widgets.img import CONF_IMAGE # Import only what we actually use directly in this file from .widgets.msgbox import MSGBOX_SCHEMA, msgboxes_to_code @@ -433,6 +436,8 @@ async def to_code(configs): # This must be done after all widgets are created styles_used = df.get_styles_used() + if any(BASE_PROPS.get(x) is lvalid.lv_image for x in styles_used): + add_lv_use(CONF_IMAGE) for use in df.get_lv_uses(): df.add_define(f"LV_USE_{use.upper()}") cg.add_define(f"USE_LVGL_{use.upper()}") diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 218f9a60ab..3f7f1dce14 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -74,11 +74,11 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) { lv_style_set_text_font(style, font->get_lv_font()); } #endif -#if defined(USE_LVGL_IMAGE) && defined(USE_IMAGE) -#if LV_USE_IMAGE + +#ifdef USE_IMAGE +#ifdef USE_LVGL_IMAGE // Shortcut / overload, so that the source of an image widget can easily be updated from within a lambda. inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { ::lv_image_set_src(obj, image->get_lv_image_dsc()); } -#endif // LV_USE_IMAGE inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) { ::lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector); @@ -93,7 +93,8 @@ inline void lv_style_set_bg_image_src(lv_style_t *style, image::Image *image) { inline void lv_style_set_bitmap_mask_src(lv_style_t *style, image::Image *image) { ::lv_style_set_bitmap_mask_src(style, image->get_lv_image_dsc()); } -#endif // USE_LVGL_IMAGE +#endif + #ifdef USE_LVGL_ANIMIMG inline void lv_animimg_set_src(lv_obj_t *img, std::vector images) { auto *dsc = static_cast *>(lv_obj_get_user_data(img)); @@ -109,6 +110,7 @@ inline void lv_animimg_set_src(lv_obj_t *img, std::vector images lv_animimg_set_src(img, (const void **) dsc->data(), dsc->size()); } #endif // USE_LVGL_ANIMIMG +#endif // USE_IMAGE #ifdef USE_LVGL_METER int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int32_t value); diff --git a/esphome/components/lvgl/styles.py b/esphome/components/lvgl/styles.py index c1441526f9..5911505555 100644 --- a/esphome/components/lvgl/styles.py +++ b/esphome/components/lvgl/styles.py @@ -9,6 +9,7 @@ from .defines import ( CONF_THEME, LValidator, add_lv_use, + get_styles_used, get_theme_widget_map, literal, ) @@ -25,6 +26,7 @@ def has_style_props(config) -> bool: async def style_set(svar, style): for prop, validator in ALL_STYLES.items(): if (value := style.get(prop)) is not None: + get_styles_used().add(prop) if isinstance(validator, LValidator): value = await validator.process(value) if isinstance(value, list): From 48d17571c82d8a048d263595865d2cf15bce2225 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 15:50:58 -0700 Subject: [PATCH 0057/1815] [tests] Mock determine_cpp_unit_tests in clang_tidy_mode tests (#16456) --- tests/script/test_determine_jobs.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 3fd5eada94..202ae9030f 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -1518,6 +1518,7 @@ def test_clang_tidy_mode_full_scan( mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, + mock_determine_cpp_unit_tests: Mock, mock_changed_files: Mock, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, @@ -1529,6 +1530,9 @@ def test_clang_tidy_mode_full_scan( mock_should_run_clang_tidy.return_value = True mock_should_run_clang_format.return_value = False mock_should_run_python_linters.return_value = False + # Without this mock, main() runs the real determine_cpp_unit_tests + # which loads the full component graph (~5s import of every component). + mock_determine_cpp_unit_tests.return_value = (False, []) # Mock changed_files to return no component files mock_changed_files.return_value = [] @@ -1584,6 +1588,7 @@ def test_clang_tidy_mode_targeted_scan( mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, + mock_determine_cpp_unit_tests: Mock, mock_changed_files: Mock, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, @@ -1595,6 +1600,9 @@ def test_clang_tidy_mode_targeted_scan( mock_should_run_clang_tidy.return_value = True mock_should_run_clang_format.return_value = False mock_should_run_python_linters.return_value = False + # Without this mock, main() runs the real determine_cpp_unit_tests + # which loads the full component graph (~5s import of every component). + mock_determine_cpp_unit_tests.return_value = (False, []) # Create component names components = [f"comp{i}" for i in range(component_count)] @@ -2651,6 +2659,15 @@ def test_main_force_all_overrides_detection( return_value={"should_run": "false"}, ), patch.object(determine_jobs, "should_run_benchmarks", return_value=False), + # create_intelligent_batches scans every tests/components//*.yaml + # under --force-all (~2500 YAML loads, ~10s in CI). This test only + # asserts that main() routes to it and returns non-empty -- the + # batching logic itself has its own dedicated tests. + patch.object( + determine_jobs, + "create_intelligent_batches", + return_value=([["fake_batch"]], None), + ), ): determine_jobs.main() From fb0bfea1c8955d24667da80872845598778d4865 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 22:06:33 -0700 Subject: [PATCH 0058/1815] Bump aioesphomeapi from 45.0.1 to 45.0.2 (#16469) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1996de0928..d117e47d91 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==45.0.1 +aioesphomeapi==45.0.2 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 7c5d5f75dc378683b02e645f435153c3891a3226 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 22:16:52 -0700 Subject: [PATCH 0059/1815] [ci] Use larger app partition for esp32-s3-idf component test grouping (#16430) --- .../build_components_base.esp32-s3-idf.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_build_components/build_components_base.esp32-s3-idf.yaml b/tests/test_build_components/build_components_base.esp32-s3-idf.yaml index ee209000e9..f3122f977e 100644 --- a/tests/test_build_components/build_components_base.esp32-s3-idf.yaml +++ b/tests/test_build_components/build_components_base.esp32-s3-idf.yaml @@ -7,6 +7,9 @@ esp32: variant: ESP32S3 framework: type: esp-idf + # Use custom partition table with larger app partition (3MB) + # Default IDF partitions only allow 1.75MB which is too small for grouped tests + partitions: ../partitions_testing.csv logger: level: VERY_VERBOSE From 01c0d3163e4f0eedd4efc29dbf8668d9b8ff4d2f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 18:41:09 -0700 Subject: [PATCH 0060/1815] Bump aioesphomeapi from 45.0.2 to 45.0.3 (#16479) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d117e47d91..eddc403820 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==45.0.2 +aioesphomeapi==45.0.3 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 155232875a671e903e62565764784fbb50bcd79e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 19:08:19 -0700 Subject: [PATCH 0061/1815] Bump zeroconf from 0.148.0 to 0.149.3 (#16480) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index eddc403820..6eef8ac643 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==45.0.3 -zeroconf==0.148.0 +zeroconf==0.149.3 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From 66907258603a2e96a71f85242bef096515b05705 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 17 May 2026 15:29:38 -0400 Subject: [PATCH 0062/1815] [espidf] Switch direct framework downloader to esphome-libs/esp-idf tarballs (#16484) --- esphome/espidf/framework.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 7ff373aba8..32bcf4fb3b 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -69,7 +69,7 @@ ESPHOME_IDF_DEFAULT_FEATURES = _str_to_lst_of_str( ESPHOME_IDF_FRAMEWORK_MIRRORS = _str_to_lst_of_str( os.environ.get( "ESPHOME_IDF_FRAMEWORK_MIRRORS", - "https://github.com/espressif/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.zip;https://github.com/espressif/esp-idf/releases/download/v{MAJOR}.{MINOR}/esp-idf-v{MAJOR}.{MINOR}.zip", + "https://github.com/esphome-libs/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.tar.xz;https://github.com/esphome-libs/esp-idf/releases/download/v{MAJOR}.{MINOR}/esp-idf-v{MAJOR}.{MINOR}.tar.xz", ) ) From 42ad2a627281c4826f14312c18eecae0666fc066 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 18 May 2026 10:49:03 +1200 Subject: [PATCH 0063/1815] [espidf] Accept list input in _str_to_lst_of_str helper (#16485) --- esphome/espidf/framework.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 32bcf4fb3b..8996ff1e02 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -26,16 +26,18 @@ _LOGGER = logging.getLogger(__name__) _SCRIPTS_DIR = Path(__file__).parent -def _str_to_lst_of_str(a: str) -> list[str]: +def _str_to_lst_of_str(a: str | list[str]) -> list[str]: """ Convert a string to a list of string Args: - a: A string containing semicolon-separated values + a: A string containing semicolon-separated values, or an already-split list Returns: list of strings """ + if isinstance(a, list): + return a return list(f.strip() for f in a.split(";") if f.strip()) @@ -67,10 +69,11 @@ ESPHOME_IDF_DEFAULT_FEATURES = _str_to_lst_of_str( ) ESPHOME_IDF_FRAMEWORK_MIRRORS = _str_to_lst_of_str( - os.environ.get( - "ESPHOME_IDF_FRAMEWORK_MIRRORS", - "https://github.com/esphome-libs/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.tar.xz;https://github.com/esphome-libs/esp-idf/releases/download/v{MAJOR}.{MINOR}/esp-idf-v{MAJOR}.{MINOR}.tar.xz", - ) + os.environ.get("ESPHOME_IDF_FRAMEWORK_MIRRORS") + or [ + "https://github.com/esphome-libs/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.tar.xz", + "https://github.com/esphome-libs/esp-idf/releases/download/v{MAJOR}.{MINOR}/esp-idf-v{MAJOR}.{MINOR}.tar.xz", + ] ) ESP_IDF_CONSTRAINTS_MIRRORS = _str_to_lst_of_str( From c863d589992079b43998471eb80ad783691fd290 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 13 May 2026 23:19:30 -0400 Subject: [PATCH 0064/1815] [espidf] Stop perpetual reconfigure loop on native ESP-IDF builds (#16415) --- esphome/components/esp32/__init__.py | 7 ++++++- esphome/espidf/toolchain.py | 30 ++++++++++++---------------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 1eb0bb2174..0c24dbf7b9 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2509,7 +2509,12 @@ def _write_idf_component_yml(): stubs_dir = CORE.relative_build_path("component_stubs") stubs_dir.mkdir(exist_ok=True) - for component_name in components_to_stub: + # Sort so the dict insertion order (and thus the generated + # src/idf_component.yml) is deterministic across runs; otherwise + # the manifest content shuffles every build, write_file_if_changed + # always writes, and ninja keeps triggering CMake re-runs on + # otherwise-cached rebuilds. + for component_name in sorted(components_to_stub): # Create stub directory with minimal CMakeLists.txt stub_path = stubs_dir / _idf_component_stub_name(component_name) stub_path.mkdir(exist_ok=True) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 583f340996..1245c643e1 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -191,13 +191,19 @@ def run_reconfigure() -> int: def has_outdated_files(): """Check if the build configuration is stale. - Returns True if required build files are missing or if configuration inputs - are newer than the generated CMake/Ninja build artifacts. + Returns True if required build files are missing or if external + configuration inputs (IDF install, sdkconfig, CMake's own build/config + dir) are newer than CMakeCache.txt. We deliberately don't watch the + top-level/src ``CMakeLists.txt`` here -- those are written by + ``write_project`` via ``write_file_if_changed`` (so an mtime bump + means our content actually changed) and ninja already tracks them as + configure-time deps via ``build.ninja``. Including them in this check + causes a perpetual reconfigure loop: the two-pass write leaves + CMakeLists newer than CMakeCache.txt, and CMake doesn't restamp the + cache when only ``idf_build_set_property`` values change, so the + check would trip on every subsequent build. """ cmakecache_txt_path = CORE.relative_build_path("build/CMakeCache.txt") - - cmakelists_txt_build_path = CORE.relative_build_path("CMakeLists.txt") - cmakelists_txt_src_path = CORE.relative_src_path("CMakeLists.txt") build_config_path = CORE.relative_build_path("build/config") sdkconfig_internal_path = CORE.relative_build_path( f"sdkconfig.{CORE.name}.esphomeinternal" @@ -221,8 +227,6 @@ def has_outdated_files(): os.path.getmtime(f) > cmakecache_txt_mtime for f in [ _get_idf_path(), - cmakelists_txt_build_path, - cmakelists_txt_src_path, sdkconfig_internal_path, build_config_path, ] @@ -302,21 +306,13 @@ def run_compile(config, verbose: bool) -> int: return rc _LOGGER.info("Regenerating CMakeLists.txt with discovered components...") write_project(minimal=False) - # The post-discovery rewrite leaves CMakeLists newer than - # CMakeCache.txt. CMake won't re-touch CMakeCache.txt on a - # configure that only changes idf_build_set_property values - # (those aren't cache variables), so has_outdated_files() would - # return True on every subsequent build, perpetually retriggering - # the two-pass. Touch CMakeCache.txt now so its mtime stays past - # the rewritten CMakeLists. - cmakecache = CORE.relative_build_path("build/CMakeCache.txt") - if cmakecache.is_file(): - os.utime(cmakecache) if CORE.testing_mode: # Reconfigure again so cmake is up to date with the full # component list before the build's idf.py invocation runs -- # idf.py build would otherwise re-run cmake and regenerate # memory.ld, wiping the DRAM/IRAM patches applied below. + # Outside testing mode ninja's own configure-time dep on + # CMakeLists.txt handles the re-run as part of the build step. rc = run_reconfigure() if rc != 0: _LOGGER.error("Reconfigure with discovered components failed") From 84b5931299de172ba87dc9c5cfdf88ba7ae15773 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 00:02:22 -0400 Subject: [PATCH 0065/1815] [espidf] Trim has_outdated_files watch list; embed IDF version in sdkconfig (#16416) --- esphome/components/esp32/__init__.py | 8 ++++- esphome/espidf/toolchain.py | 45 +++++++++++++++++----------- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 0c24dbf7b9..f112549832 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2464,8 +2464,14 @@ def _write_sdkconfig(): ) want_opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + # Include the resolved framework version as a Kconfig comment so a + # version switch that happens to leave the option set unchanged still + # bumps this file's content -- which is what has_outdated_files() + # uses to decide whether to reconfigure. + framework_version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] contents = ( - "\n".join( + f"# ESPHOME_IDF_VERSION={framework_version}\n" + + "\n".join( f"{name}={_format_sdkconfig_val(value)}" for name, value in sorted(want_opts.items()) ) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 1245c643e1..e0bc5bb393 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -191,23 +191,38 @@ def run_reconfigure() -> int: def has_outdated_files(): """Check if the build configuration is stale. - Returns True if required build files are missing or if external - configuration inputs (IDF install, sdkconfig, CMake's own build/config - dir) are newer than CMakeCache.txt. We deliberately don't watch the - top-level/src ``CMakeLists.txt`` here -- those are written by - ``write_project`` via ``write_file_if_changed`` (so an mtime bump - means our content actually changed) and ninja already tracks them as - configure-time deps via ``build.ninja``. Including them in this check - causes a perpetual reconfigure loop: the two-pass write leaves - CMakeLists newer than CMakeCache.txt, and CMake doesn't restamp the - cache when only ``idf_build_set_property`` values change, so the - check would trip on every subsequent build. + Returns True if required build files are missing or if ESPHome's + resolved build inputs are newer than CMakeCache.txt: + + - ``sdkconfig..esphomeinternal`` -- the canonical "what state + did ESPHome resolve the YAML to" snapshot. Any change in build + flags, enabled components, framework version, or target ends up + rewriting it (we embed a ``# ESPHOME_IDF_VERSION=`` comment line + for the version case where the option set would otherwise be + identical). + - ``src/idf_component.yml`` -- the project manifest. Managed + component additions/removals (e.g. via ``add_idf_component``) can + happen without any sdkconfig impact, and ``_write_idf_component_yml`` + already deletes ``dependencies.lock`` on a change but that signal + gets lost as soon as the lock is missing. + + We deliberately don't watch: + - The top-level/src ``CMakeLists.txt`` -- ESPHome owns those, and + ninja already tracks them as configure-time deps. Including them + causes a perpetual reconfigure loop because CMake doesn't restamp + ``CMakeCache.txt`` when only ``idf_build_set_property`` values + change between configures. + - ``$IDF_PATH`` and CMake's ``build/config/`` -- both have mtime + semantics that fire after the wrong configure (or not at all in + common cases like in-place IDF version replacement). The sdkconfig + and manifest hashes subsume the meaningful signal. """ cmakecache_txt_path = CORE.relative_build_path("build/CMakeCache.txt") build_config_path = CORE.relative_build_path("build/config") sdkconfig_internal_path = CORE.relative_build_path( f"sdkconfig.{CORE.name}.esphomeinternal" ) + idf_component_yml_path = CORE.relative_build_path("src/idf_component.yml") dependency_lock_path = CORE.relative_build_path("dependencies.lock") build_ninja_path = CORE.relative_build_path("build/build.ninja") @@ -225,12 +240,8 @@ def has_outdated_files(): cmakecache_txt_mtime = os.path.getmtime(cmakecache_txt_path) return any( os.path.getmtime(f) > cmakecache_txt_mtime - for f in [ - _get_idf_path(), - sdkconfig_internal_path, - build_config_path, - ] - if f and os.path.exists(f) + for f in [sdkconfig_internal_path, idf_component_yml_path] + if f.exists() ) From ab273a1f8fe4419bd67d9e2a2c05ed222a0beca7 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Thu, 14 May 2026 15:07:38 -0500 Subject: [PATCH 0066/1815] [tinyusb] Reject `tinyusb:` configured without a USB class companion (#16413) Co-authored-by: Claude Opus 4.7 (1M context) --- esphome/components/tinyusb/__init__.py | 20 +++++++++++++++++++ .../components/tinyusb/tinyusb_component.cpp | 15 ++++++++++++++ tests/components/tinyusb/common.yaml | 5 +++++ 3 files changed, 40 insertions(+) diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index df94ad7534..724f65721b 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -1,3 +1,4 @@ +from esphome import final_validate as fv import esphome.codegen as cg from esphome.components import esp32 from esphome.components.esp32 import ( @@ -20,6 +21,13 @@ CONF_USB_PRODUCT_STR = "usb_product_str" CONF_USB_SERIAL_STR = "usb_serial_str" CONF_USB_VENDOR_ID = "usb_vendor_id" +# Components that provide a USB device class (CDC, HID, MSC, ...) on top of +# tinyusb. Configuring `tinyusb:` without any of these triggers a 5s hang in +# esp_tinyusb's driver install (descriptors_set fails with no class and no +# user-provided full_speed_config), which trips the task watchdog before +# loop() ever runs. +_USB_CLASS_COMPONENTS = ("usb_cdc_acm",) + tinyusb_ns = cg.esphome_ns.namespace("tinyusb") TinyUSB = tinyusb_ns.class_("TinyUSB", cg.Component) @@ -41,6 +49,18 @@ CONFIG_SCHEMA = cv.All( ) +def _final_validate(config): + full_config = fv.full_config.get() + if not any(name in full_config for name in _USB_CLASS_COMPONENTS): + raise cv.Invalid( + "The 'tinyusb' component requires at least one USB class component" + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index 2ec696c3e4..3cefc0454a 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -26,6 +26,21 @@ void TinyUSB::setup() { .string_count = SIZE, }; + // Defense-in-depth: esp_tinyusb's tinyusb_descriptors_set() fails with + // ESP_ERR_INVALID_ARG when no configuration descriptor is provided and + // no class that has a built-in default (CDC/MSC/NCM) is compiled in. In + // that case the internal task exits without notifying us, and + // tinyusb_driver_install() blocks 5s on the notify-take -- long enough + // to trip the task watchdog. Bail early so the rest of the device can + // still boot. +#if !(CFG_TUD_CDC > 0 || CFG_TUD_MSC > 0 || CFG_TUD_NCM > 0) + if (this->tusb_cfg_.descriptor.full_speed_config == nullptr) { + ESP_LOGE(TAG, "No USB class configured"); + this->mark_failed(); + return; + } +#endif + esp_err_t result = tinyusb_driver_install(&this->tusb_cfg_); if (result != ESP_OK) { ESP_LOGE(TAG, "tinyusb_driver_install failed: %s", esp_err_to_name(result)); diff --git a/tests/components/tinyusb/common.yaml b/tests/components/tinyusb/common.yaml index cb3f48836a..674e89dbe8 100644 --- a/tests/components/tinyusb/common.yaml +++ b/tests/components/tinyusb/common.yaml @@ -6,3 +6,8 @@ tinyusb: usb_product_str: ESPHomeTestProduct usb_serial_str: ESPHomeTestSerialNumber usb_vendor_id: 0x2345 + +# tinyusb requires at least one USB class companion; usb_cdc_acm satisfies that. +usb_cdc_acm: + interfaces: + - id: tinyusb_test_cdc From fb659f9ac4e35022d87317a4bbfab8af9076cba0 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Thu, 14 May 2026 15:29:56 -0500 Subject: [PATCH 0067/1815] [tinyusb] Reject `logger.hardware_uart: USB_CDC` (#16417) Co-authored-by: Claude Opus 4.7 (1M context) --- esphome/components/tinyusb/__init__.py | 13 ++++++++++++- tests/components/tinyusb/test.esp32-s2-idf.yaml | 5 +++++ tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml | 5 +++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 724f65721b..0e02ff8724 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -9,7 +9,7 @@ from esphome.components.esp32 import ( add_idf_sdkconfig_option, ) import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_HARDWARE_UART, CONF_ID CODEOWNERS = ["@kbx81"] CONFLICTS_WITH = ["usb_host"] @@ -55,6 +55,17 @@ def _final_validate(config): raise cv.Invalid( "The 'tinyusb' component requires at least one USB class component" ) + # tinyusb owns the USB OTG peripheral. The logger's USB_CDC backend routes + # the ROM console through that same peripheral, so the two cannot coexist. + # (USB_SERIAL_JTAG is a separate peripheral and is fine alongside tinyusb.) + logger_config = full_config.get("logger") + if logger_config and logger_config.get(CONF_HARDWARE_UART) == "USB_CDC": + raise cv.Invalid( + "'tinyusb' cannot be used with 'logger.hardware_uart: USB_CDC' " + "because both share the USB OTG peripheral. Set " + "'logger.hardware_uart' to a hardware UART (e.g. UART0), or to " + "USB_SERIAL_JTAG on variants that support it (ESP32-S3, ESP32-P4)" + ) return config diff --git a/tests/components/tinyusb/test.esp32-s2-idf.yaml b/tests/components/tinyusb/test.esp32-s2-idf.yaml index dade44d145..09b98ada40 100644 --- a/tests/components/tinyusb/test.esp32-s2-idf.yaml +++ b/tests/components/tinyusb/test.esp32-s2-idf.yaml @@ -1 +1,6 @@ <<: !include common.yaml + +# S2 defaults logger to USB_CDC, which conflicts with tinyusb on the shared +# USB OTG peripheral; route the logger to UART0 so the fixture builds. +logger: + hardware_uart: UART0 diff --git a/tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml b/tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml index f159b38ff6..ff75731509 100644 --- a/tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml +++ b/tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml @@ -1,5 +1,10 @@ <<: !include tinyusb_common.yaml +# S2 defaults logger to USB_CDC, which conflicts with tinyusb on the shared +# USB OTG peripheral; route the logger to UART0 so the fixture builds. +logger: + hardware_uart: UART0 + usb_cdc_acm: interfaces: - id: usb_cdc_acm1 From dd1818661c29d03f83153e95ba3bbd5dd40c4796 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 18:39:16 -0400 Subject: [PATCH 0068/1815] [esp32] Sweep ESP-IDF toolchain warnings + bump deprecated mark_failed (#16432) --- esphome/components/esp32/__init__.py | 4 +++- esphome/components/hdc2080/hdc2080.cpp | 2 +- esphome/components/heatpumpir/climate.py | 1 - esphome/components/pulse_meter/pulse_meter_sensor.cpp | 4 ++-- esphome/components/sim800l/sim800l.cpp | 2 +- esphome/components/tuya/tuya.cpp | 2 ++ esphome/components/tx20/tx20.cpp | 6 +++--- esphome/components/usb_uart/usb_uart.h | 2 +- esphome/components/web_server/web_server.cpp | 2 +- esphome/components/web_server_idf/web_server_idf.cpp | 2 +- esphome/components/wiegand/wiegand.cpp | 4 ++-- 11 files changed, 17 insertions(+), 14 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index f112549832..e9b0f1fd0a 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1767,9 +1767,11 @@ async def to_code(config): else: cg.add_build_flag("-Wno-error=format") cg.add_build_flag("-Wno-error=maybe-uninitialized") - cg.add_build_flag("-Wno-error=missing-field-initializers") + cg.add_build_flag("-Wno-error=overloaded-virtual") cg.add_build_flag("-Wno-error=reorder") cg.add_build_flag("-Wno-error=volatile") + # -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates + cg.add_build_flag("-Wno-missing-field-initializers") cg.set_cpp_standard("gnu++20") cg.add_build_flag("-DUSE_ESP32") diff --git a/esphome/components/hdc2080/hdc2080.cpp b/esphome/components/hdc2080/hdc2080.cpp index dcb207e099..bf3a4bc79f 100644 --- a/esphome/components/hdc2080/hdc2080.cpp +++ b/esphome/components/hdc2080/hdc2080.cpp @@ -22,7 +22,7 @@ static constexpr uint8_t MEAS_CONF_HUM = 0x04; // Bits 2:1 = 10: humidity only void HDC2080Component::setup() { const uint8_t data = 0x00; // automatic measurement mode disabled, heater off if (this->write_register(REG_RESET_DRDY_INT_CONF, &data, 1) != i2c::ERROR_OK) { - this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index b7e0437480..aa3a08c294 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -125,7 +125,6 @@ async def to_code(config): cg.add(var.set_vertical_default(config[CONF_VERTICAL_DEFAULT])) cg.add(var.set_max_temperature(config[CONF_MAX_TEMPERATURE])) cg.add(var.set_min_temperature(config[CONF_MIN_TEMPERATURE])) - cg.add_build_flag("-Wno-error=overloaded-virtual") cg.add_library("tonia/HeatpumpIR", "1.0.41") if CORE.is_libretiny or CORE.is_esp32: diff --git a/esphome/components/pulse_meter/pulse_meter_sensor.cpp b/esphome/components/pulse_meter/pulse_meter_sensor.cpp index 3fe1c722eb..d6959d1a96 100644 --- a/esphome/components/pulse_meter/pulse_meter_sensor.cpp +++ b/esphome/components/pulse_meter/pulse_meter_sensor.cpp @@ -150,7 +150,7 @@ void IRAM_ATTR PulseMeterSensor::edge_intr(PulseMeterSensor *sensor) { edge_state.last_sent_edge_us_ = now; state.last_detected_edge_us_ = now; state.last_rising_edge_us_ = now; - state.count_++; // NOLINT(clang-diagnostic-deprecated-volatile) + state.count_ += 1; } // This ISR is bound to rising edges, so the pin is high @@ -173,7 +173,7 @@ void IRAM_ATTR PulseMeterSensor::pulse_intr(PulseMeterSensor *sensor) { } else if (length && !pulse_state.latched_ && sensor->last_pin_val_) { // Long enough high edge pulse_state.latched_ = true; state.last_detected_edge_us_ = pulse_state.last_intr_; - state.count_++; // NOLINT(clang-diagnostic-deprecated-volatile) + state.count_ += 1; } // Due to order of operations this includes diff --git a/esphome/components/sim800l/sim800l.cpp b/esphome/components/sim800l/sim800l.cpp index b8e97b1121..13b9888e05 100644 --- a/esphome/components/sim800l/sim800l.cpp +++ b/esphome/components/sim800l/sim800l.cpp @@ -126,7 +126,7 @@ void Sim800LComponent::parse_cmd_(std::string message) { break; } - // Else fall thru ... + [[fallthrough]]; } case STATE_CHECK_SMS: send_cmd_("AT+CMGL=\"ALL\""); diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index b29905f9a0..fd14844908 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -684,8 +684,10 @@ void Tuya::set_numeric_datapoint_value_(uint8_t datapoint_id, TuyaDatapointType case 4: data.push_back(value >> 24); data.push_back(value >> 16); + [[fallthrough]]; case 2: data.push_back(value >> 8); + [[fallthrough]]; case 1: data.push_back(value >> 0); break; diff --git a/esphome/components/tx20/tx20.cpp b/esphome/components/tx20/tx20.cpp index 353cb31513..3574bd7c2d 100644 --- a/esphome/components/tx20/tx20.cpp +++ b/esphome/components/tx20/tx20.cpp @@ -135,7 +135,7 @@ void Tx20Component::decode_and_publish_() { } if (tx20_se == tx20_sb) { tx20_wind_direction = tx20_se; - if (tx20_wind_direction >= 0 && tx20_wind_direction < 16) { + if (tx20_wind_direction < 16) { wind_cardinal_direction_ = DIRECTIONS[tx20_wind_direction]; } ESP_LOGV(TAG, "WindDirection %d", tx20_wind_direction); @@ -164,7 +164,7 @@ void IRAM_ATTR Tx20ComponentStore::gpio_intr(Tx20ComponentStore *arg) { } arg->buffer[arg->buffer_index] = 1; arg->start_time = now; - arg->buffer_index++; // NOLINT(clang-diagnostic-deprecated-volatile) + arg->buffer_index += 1; return; } const uint32_t delay = now - arg->start_time; @@ -195,7 +195,7 @@ void IRAM_ATTR Tx20ComponentStore::gpio_intr(Tx20ComponentStore *arg) { } arg->spent_time += delay; arg->start_time = now; - arg->buffer_index++; // NOLINT(clang-diagnostic-deprecated-volatile) + arg->buffer_index += 1; } void IRAM_ATTR Tx20ComponentStore::reset() { tx20_available = false; diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index e88c41c0cb..fb8425f6cd 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -135,7 +135,7 @@ class USBUartChannel : public uart::UARTComponent, public Parentedarg(ESPHOME_F("detail")) == "all" ? DETAIL_ALL : DETAIL_STATE; } diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index e1d3e4bf34..c32acaf03e 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -66,7 +66,7 @@ namespace { * - HTTPD_SOCK_ERR_TIMEOUT if the send buffer is full (EAGAIN/EWOULDBLOCK). * - HTTPD_SOCK_ERR_FAIL for other errors. */ -int nonblocking_send(httpd_handle_t hd, int sockfd, const char *buf, size_t buf_len, int flags) { +[[maybe_unused]] int nonblocking_send(httpd_handle_t hd, int sockfd, const char *buf, size_t buf_len, int flags) { if (buf == nullptr) { return HTTPD_SOCK_ERR_INVALID; } diff --git a/esphome/components/wiegand/wiegand.cpp b/esphome/components/wiegand/wiegand.cpp index e5c29f8b11..df64cd48aa 100644 --- a/esphome/components/wiegand/wiegand.cpp +++ b/esphome/components/wiegand/wiegand.cpp @@ -11,7 +11,7 @@ static const char *const KEYS = "0123456789*#"; void IRAM_ATTR HOT WiegandStore::d0_gpio_intr(WiegandStore *arg) { if (arg->d0.digital_read()) return; - arg->count++; // NOLINT(clang-diagnostic-deprecated-volatile) + arg->count += 1; arg->value <<= 1; arg->last_bit_time = millis(); arg->done = false; @@ -20,7 +20,7 @@ void IRAM_ATTR HOT WiegandStore::d0_gpio_intr(WiegandStore *arg) { void IRAM_ATTR HOT WiegandStore::d1_gpio_intr(WiegandStore *arg) { if (arg->d1.digital_read()) return; - arg->count++; // NOLINT(clang-diagnostic-deprecated-volatile) + arg->count += 1; arg->value = (arg->value << 1) | 1; arg->last_bit_time = millis(); arg->done = false; From d5c6efb2fe2891cde35aeae11654b776947f3d8c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 18:40:40 -0400 Subject: [PATCH 0069/1815] [tests] Fix -Wformat= mismatches in test YAML lambdas/logger.log (#16435) --- esphome/components/logger/__init__.py | 14 +++++----- esphome/components/lvgl/helpers.py | 28 +++++++++---------- .../components/esp32_ble_tracker/common.yaml | 4 +-- tests/components/ld2412/common.yaml | 2 +- tests/components/lvgl/lvgl-package.yaml | 6 ++-- tests/components/modbus_server/common.yaml | 2 +- tests/components/mqtt/common.yaml | 2 +- tests/components/nextion/common.yaml | 2 +- .../remote_receiver/common-actions.yaml | 2 +- tests/components/script/common.yaml | 2 +- tests/components/udp/common.yaml | 2 +- tests/components/udp/test.host.yaml | 2 +- 12 files changed, 34 insertions(+), 34 deletions(-) diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 9d7dc8d92c..c6c440564a 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -506,13 +506,13 @@ async def _late_logger_init(config: ConfigType) -> None: def validate_printf(value): # https://stackoverflow.com/questions/30011379/how-can-i-parse-a-c-format-string-in-python cfmt = r""" - ( # start of capture group 1 - % # literal "%" - (?:[-+0 #]{0,5}) # optional flags - (?:\d+|\*)? # width - (?:\.(?:\d+|\*))? # precision - (?:h|l|ll|w|I|I32|I64)? # size - [cCdiouxXeEfgGaAnpsSZ] # type + ( # start of capture group 1 + % # literal "%" + (?:[-+0 #]{0,5}) # optional flags + (?:\d+|\*)? # width + (?:\.(?:\d+|\*))? # precision + (?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size + [cCdiouxXeEfgGaAnpsSZ] # type ) """ # noqa matches = re.findall(cfmt, value[CONF_FORMAT], flags=re.VERBOSE) diff --git a/esphome/components/lvgl/helpers.py b/esphome/components/lvgl/helpers.py index baa618d472..6f70a1e3bd 100644 --- a/esphome/components/lvgl/helpers.py +++ b/esphome/components/lvgl/helpers.py @@ -9,13 +9,13 @@ CONF_IF_NAN = "if_nan" # noqa f_regex = re.compile( r""" - ( # start of capture group 1 - % # literal "%" - [-+0 #]{0,5} # optional flags - (?:\d+|\*)? # width - (?:\.(?:\d+|\*))? # precision - (?:h|l|ll|w|I|I32|I64)? # size - f # type + ( # start of capture group 1 + % # literal "%" + [-+0 #]{0,5} # optional flags + (?:\d+|\*)? # width + (?:\.(?:\d+|\*))? # precision + (?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size + f # type ) """, flags=re.VERBOSE, @@ -23,13 +23,13 @@ f_regex = re.compile( # noqa c_regex = re.compile( r""" - ( # start of capture group 1 - % # literal "%" - [-+0 #]{0,5} # optional flags - (?:\d+|\*)? # width - (?:\.(?:\d+|\*))? # precision - (?:h|l|ll|w|I|I32|I64)? # size - [cCdiouxXeEfgGaAnpsSZ] # type + ( # start of capture group 1 + % # literal "%" + [-+0 #]{0,5} # optional flags + (?:\d+|\*)? # width + (?:\.(?:\d+|\*))? # precision + (?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size + [cCdiouxXeEfgGaAnpsSZ] # type ) """, flags=re.VERBOSE, diff --git a/tests/components/esp32_ble_tracker/common.yaml b/tests/components/esp32_ble_tracker/common.yaml index 018bbb42b3..564cf1f6ea 100644 --- a/tests/components/esp32_ble_tracker/common.yaml +++ b/tests/components/esp32_ble_tracker/common.yaml @@ -29,12 +29,12 @@ esp32_ble_tracker: - service_uuid: ABCD then: - lambda: !lambda |- - ESP_LOGD("main", "Length of service data is %i", x.size()); + ESP_LOGD("main", "Length of service data is %zu", x.size()); on_ble_manufacturer_data_advertise: - manufacturer_id: ABCD then: - lambda: !lambda |- - ESP_LOGD("main", "Length of manufacturer data is %i", x.size()); + ESP_LOGD("main", "Length of manufacturer data is %zu", x.size()); on_scan_end: - then: - lambda: |- diff --git a/tests/components/ld2412/common.yaml b/tests/components/ld2412/common.yaml index c5bda688dc..7a86b6fbda 100644 --- a/tests/components/ld2412/common.yaml +++ b/tests/components/ld2412/common.yaml @@ -123,7 +123,7 @@ select: - lambda: |- id(uart_bus).flush(); uint32_t new_baud_rate = stoi(x); - ESP_LOGD("change_baud_rate", "Changing baud rate from %i to %i",id(uart_bus).get_baud_rate(), new_baud_rate); + ESP_LOGD("change_baud_rate", "Changing baud rate from %" PRIu32 " to %" PRIu32, id(uart_bus).get_baud_rate(), new_baud_rate); if (id(uart_bus).get_baud_rate() != new_baud_rate) { id(uart_bus).set_baud_rate(new_baud_rate); #if defined(USE_ESP8266) || defined(USE_ESP32) diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 53984bb006..0f4b961297 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -660,13 +660,13 @@ lvgl: on_release: logger.log: format: Button released at %d/%d - args: [point.x, point.y] + args: ['(int) point.x', '(int) point.y'] on_long_press_repeat: logger.log: Button clicked on_pressing: logger.log: format: Button pressing at %d/%d - args: [point.x, point.y] + args: ['(int) point.x', '(int) point.y'] on_press_lost: logger.log: Button press lost on_single_click: @@ -944,7 +944,7 @@ lvgl: on_release: logger.log: format: Slider released at %d/%d with value %.0f - args: [point.x, point.y, x] + args: ['(int) point.x', '(int) point.y', x] - button: styles: spin_button id: spin_up diff --git a/tests/components/modbus_server/common.yaml b/tests/components/modbus_server/common.yaml index 3522c9248c..2e4a81a1aa 100644 --- a/tests/components/modbus_server/common.yaml +++ b/tests/components/modbus_server/common.yaml @@ -21,7 +21,7 @@ modbus_server: read_lambda: |- return 31; write_lambda: |- - printf("address=%d, value=%d", x); + printf("address=%d, value=%" PRId32 "\n", (int) address, x); return true; - id: modbus_server4 modbus_id: mod_bus2 diff --git a/tests/components/mqtt/common.yaml b/tests/components/mqtt/common.yaml index 8c58e9b080..6af2ce3939 100644 --- a/tests/components/mqtt/common.yaml +++ b/tests/components/mqtt/common.yaml @@ -64,7 +64,7 @@ mqtt: topic: some/topic payload: Good-bye - lambda: |- - ESP_LOGD("MQTT", "Disconnect reason %d", reason); + ESP_LOGD("MQTT", "Disconnect reason %d", (int) reason); publish_nan_as_none: false binary_sensor: diff --git a/tests/components/nextion/common.yaml b/tests/components/nextion/common.yaml index 0616b9a41a..fba6a22b97 100644 --- a/tests/components/nextion/common.yaml +++ b/tests/components/nextion/common.yaml @@ -299,7 +299,7 @@ display: - lambda: |- // key: StringRef, value: int32_t if (key == "temperature_raw") { - ESP_LOGD("nextion.custom", "%s=%d", key.c_str(), value); + ESP_LOGD("nextion.custom", "%s=%" PRId32, key.c_str(), value); } on_custom_binary_sensor: then: diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index 30b99eeb70..26a02d4dab 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -12,7 +12,7 @@ on_brennenstuhl: then: - logger.log: format: "on_brennenstuhl: %u" - args: ["x.code"] + args: ["(unsigned) x.code"] on_aeha: then: - logger.log: diff --git a/tests/components/script/common.yaml b/tests/components/script/common.yaml index c1dc68513f..f4818e2296 100644 --- a/tests/components/script/common.yaml +++ b/tests/components/script/common.yaml @@ -49,7 +49,7 @@ script: then: - lambda: |- ESP_LOGD("main", "ints=%d floats=%f bools=%d strings=%s", - ints[0], floats[0], bools[0], strings[0].c_str()); + ints[0], floats[0], (int) bools[0], strings[0].c_str()); - id: my_script_with_params parameters: prefix: string diff --git a/tests/components/udp/common.yaml b/tests/components/udp/common.yaml index 3466e8d2ee..a40ca455cb 100644 --- a/tests/components/udp/common.yaml +++ b/tests/components/udp/common.yaml @@ -11,7 +11,7 @@ udp: - "10.0.0.255" on_receive: - logger.log: - format: "Received %d bytes" + format: "Received %zu bytes" args: [data.size()] - udp.write: id: my_udp diff --git a/tests/components/udp/test.host.yaml b/tests/components/udp/test.host.yaml index 84e78894e5..825d86c19e 100644 --- a/tests/components/udp/test.host.yaml +++ b/tests/components/udp/test.host.yaml @@ -4,7 +4,7 @@ udp: addresses: ["239.0.60.53"] on_receive: - logger.log: - format: "Received %d bytes" + format: "Received %zu bytes" args: [data.size()] - udp.write: id: my_udp From da8286f5542a9a8a80bfd6e4590707a78b30cd04 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 18:51:59 -0400 Subject: [PATCH 0070/1815] [docker] Install libusb-1.0 so ESP-IDF tools can validate openocd (#16424) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docker/Dockerfile | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 540d28be7f..25de9472b6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -13,12 +13,16 @@ RUN git config --system --add safe.directory "*" \ && git config --system advice.detachedHead false # Install build tools for Python packages that require compilation -# (e.g., ruamel.yaml.clibz used by ESP-IDF's idf-component-manager) +# (e.g., ruamel.yaml.clib used by ESP-IDF's idf-component-manager). +# Also install libusb-1.0 at runtime so the ESP-IDF tools installer can +# validate openocd-esp32 (it dynamically links libusb-1.0.so.0); without +# it idf_tools.py rejects the openocd install with exit 127 and aborts +# the whole framework setup. RUN if command -v apk > /dev/null; then \ - apk add --no-cache build-base; \ + apk add --no-cache build-base libusb; \ else \ apt-get update \ - && apt-get install -y --no-install-recommends build-essential \ + && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \ && rm -rf /var/lib/apt/lists/*; \ fi From 3831aa809f3e5fd425165274d0c2165b58546d8d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 18:53:42 -0400 Subject: [PATCH 0071/1815] [multiple] Fix -Wformat= mismatches in component .cpp sources (#16433) --- esphome/components/bme680_bsec/bme680_bsec.cpp | 4 ++-- .../esp32_hosted/update/esp32_hosted_update.cpp | 6 +++--- esphome/components/esphome/ota/ota_esphome.cpp | 14 +++++++------- esphome/components/fastled_base/fastled_light.cpp | 2 +- esphome/components/inkplate/inkplate.cpp | 2 +- esphome/components/midea/air_conditioner.cpp | 4 ++-- esphome/components/ota/ota_partitions_esp_idf.cpp | 4 ++-- .../components/remote_receiver/remote_receiver.cpp | 8 ++++---- esphome/components/sendspin/sendspin_hub.cpp | 4 ++-- .../total_daily_energy/total_daily_energy.cpp | 2 +- 10 files changed, 25 insertions(+), 25 deletions(-) diff --git a/esphome/components/bme680_bsec/bme680_bsec.cpp b/esphome/components/bme680_bsec/bme680_bsec.cpp index b7f8c0da77..823f32c446 100644 --- a/esphome/components/bme680_bsec/bme680_bsec.cpp +++ b/esphome/components/bme680_bsec/bme680_bsec.cpp @@ -161,7 +161,7 @@ void BME680BSECComponent::dump_config() { " IAQ Mode: %s\n" " Supply Voltage: %sV\n" " Sample Rate: %s\n" - " State Save Interval: %ims", + " State Save Interval: %" PRIu32 "ms", this->temperature_offset_, this->iaq_mode_ == IAQ_MODE_STATIC ? "Static" : "Mobile", this->supply_voltage_ == SUPPLY_VOLTAGE_3V3 ? "3.3" : "1.8", BME680_BSEC_SAMPLE_RATE_LOG(this->sample_rate_), this->state_save_interval_ms_); @@ -461,7 +461,7 @@ int8_t BME680BSECComponent::write_bytes_wrapper(uint8_t devid, uint8_t a_registe } void BME680BSECComponent::delay_ms(uint32_t period) { - ESP_LOGV(TAG, "Delaying for %ums", period); + ESP_LOGV(TAG, "Delaying for %" PRIu32 "ms", period); delay(period); } diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index af35d32888..7f3ba77895 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -92,7 +92,7 @@ void Esp32HostedUpdate::setup() { if (esp_hosted_get_coprocessor_fwversion(&ver_info) == ESP_OK) { // 16 bytes: "255.255.255" (11 chars) + null + safety margin char buf[16]; - snprintf(buf, sizeof(buf), "%d.%d.%d", ver_info.major1, ver_info.minor1, ver_info.patch1); + snprintf(buf, sizeof(buf), "%" PRIu32 ".%" PRIu32 ".%" PRIu32, ver_info.major1, ver_info.minor1, ver_info.patch1); this->update_info_.current_version = buf; } else { this->update_info_.current_version = "unknown"; @@ -120,8 +120,8 @@ void Esp32HostedUpdate::setup() { this->state_ = update::UPDATE_STATE_NO_UPDATE; } } else { - ESP_LOGW(TAG, "Invalid app description magic word: 0x%08x (expected 0x%08x)", app_desc->magic_word, - ESP_APP_DESC_MAGIC_WORD); + ESP_LOGW(TAG, "Invalid app description magic word: 0x%08" PRIx32 " (expected 0x%08" PRIx32 ")", + app_desc->magic_word, ESP_APP_DESC_MAGIC_WORD); this->state_ = update::UPDATE_STATE_NO_UPDATE; } } else { diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index f1857ed664..fb0cc2e56d 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -108,8 +108,8 @@ void ESPHomeOTAComponent::dump_config() { ESP_LOGCONFIG(TAG, " Partition access allowed\n" " Running app:\n" - " Partition address: 0x%X\n" - " Used size: %zu bytes (0x%X)", + " Partition address: 0x%" PRIX32 "\n" + " Used size: %zu bytes (0x%zX)", this->running_app_offset_, this->running_app_size_, this->running_app_size_); #ifdef USE_ESP32 @@ -378,7 +378,7 @@ void ESPHomeOTAComponent::handle_data_() { } ota_size = (static_cast(buf[0]) << 24) | (static_cast(buf[1]) << 16) | (static_cast(buf[2]) << 8) | buf[3]; - ESP_LOGV(TAG, "Size is %u bytes", ota_size); + ESP_LOGV(TAG, "Size is %zu bytes", ota_size); #ifndef USE_OTA_PARTITIONS if (ota_type != ota::OTA_TYPE_UPDATE_APP) { @@ -749,7 +749,7 @@ bool ESPHomeOTAComponent::handle_auth_send_() { this->auth_buf_[0] = this->auth_type_; hasher.get_hex(buf); - ESP_LOGV(TAG, "Auth: Nonce is %.*s", hex_size, buf); + ESP_LOGV(TAG, "Auth: Nonce is %.*s", (int) hex_size, buf); } // Try to write auth_type + nonce @@ -809,13 +809,13 @@ bool ESPHomeOTAComponent::handle_auth_read_() { hasher.add(nonce, hex_size * 2); // Add both nonce and cnonce (contiguous in buffer) hasher.calculate(); - ESP_LOGV(TAG, "Auth: CNonce is %.*s", hex_size, cnonce); + ESP_LOGV(TAG, "Auth: CNonce is %.*s", (int) hex_size, cnonce); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char computed_hash[SHA256_HEX_SIZE + 1]; // Buffer for hex-encoded hash (max expected length + null terminator) hasher.get_hex(computed_hash); - ESP_LOGV(TAG, "Auth: Result is %.*s", hex_size, computed_hash); + ESP_LOGV(TAG, "Auth: Result is %.*s", (int) hex_size, computed_hash); #endif - ESP_LOGV(TAG, "Auth: Response is %.*s", hex_size, response); + ESP_LOGV(TAG, "Auth: Response is %.*s", (int) hex_size, response); // Compare response bool matches = hasher.equals_hex(response); diff --git a/esphome/components/fastled_base/fastled_light.cpp b/esphome/components/fastled_base/fastled_light.cpp index 8d1dd49dad..0fa69a23b4 100644 --- a/esphome/components/fastled_base/fastled_light.cpp +++ b/esphome/components/fastled_base/fastled_light.cpp @@ -19,7 +19,7 @@ void FastLEDLightOutput::dump_config() { ESP_LOGCONFIG(TAG, "FastLED light:\n" " Num LEDs: %u\n" - " Max refresh rate: %u", + " Max refresh rate: %" PRIu32, this->num_leds_, this->max_refresh_rate_.value_or(0)); } void FastLEDLightOutput::write_state(light::LightState *state) { diff --git a/esphome/components/inkplate/inkplate.cpp b/esphome/components/inkplate/inkplate.cpp index 39110ca83b..2e837fb614 100644 --- a/esphome/components/inkplate/inkplate.cpp +++ b/esphome/components/inkplate/inkplate.cpp @@ -319,7 +319,7 @@ void Inkplate::fill(Color color) { memset(this->partial_buffer_, fill, this->get_buffer_length_()); } - ESP_LOGV(TAG, "Fill finished (%ums)", millis() - start_time); + ESP_LOGV(TAG, "Fill finished (%" PRIu32 "ms)", millis() - start_time); } void Inkplate::display() { diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index 594f7fa661..7603dd5254 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -130,8 +130,8 @@ ClimateTraits AirConditioner::traits() { void AirConditioner::dump_config() { ESP_LOGCONFIG(Constants::TAG, "MideaDongle:\n" - " [x] Period: %dms\n" - " [x] Response timeout: %dms\n" + " [x] Period: %" PRIu32 "ms\n" + " [x] Response timeout: %" PRIu32 "ms\n" " [x] Request attempts: %d", this->base_.getPeriod(), this->base_.getTimeout(), this->base_.getNumAttempts()); #ifdef USE_REMOTE_TRANSMITTER diff --git a/esphome/components/ota/ota_partitions_esp_idf.cpp b/esphome/components/ota/ota_partitions_esp_idf.cpp index f91e88bde0..a7fc709313 100644 --- a/esphome/components/ota/ota_partitions_esp_idf.cpp +++ b/esphome/components/ota/ota_partitions_esp_idf.cpp @@ -210,7 +210,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGE(TAG, "Cannot resolve running app partition at address 0x%" PRIX32, running_app_offset); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } - ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, + ESP_LOGD(TAG, "Copying running app from 0x%" PRIX32 " to 0x%" PRIX32 " (size: 0x%zX)", running_app_part->address, plan.copy_dest_part->address, running_app_size); err = esp_partition_copy(plan.copy_dest_part, 0, running_app_part, 0, running_app_size); if (err != ESP_OK) { @@ -261,7 +261,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGE(TAG, "Selected app partition not found after partition table update"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } - ESP_LOGD(TAG, "Setting next boot partition to 0x%X", new_boot_partition->address); + ESP_LOGD(TAG, "Setting next boot partition to 0x%" PRIX32, new_boot_partition->address); err = esp_ota_set_boot_partition(new_boot_partition); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X)", err); diff --git a/esphome/components/remote_receiver/remote_receiver.cpp b/esphome/components/remote_receiver/remote_receiver.cpp index d59ee63695..222dae8f7f 100644 --- a/esphome/components/remote_receiver/remote_receiver.cpp +++ b/esphome/components/remote_receiver/remote_receiver.cpp @@ -78,10 +78,10 @@ void RemoteReceiverComponent::setup() { void RemoteReceiverComponent::dump_config() { ESP_LOGCONFIG(TAG, "Remote Receiver:\n" - " Buffer Size: %u\n" - " Tolerance: %u%s\n" - " Filter out pulses shorter than: %u us\n" - " Signal is done after %u us of no changes", + " Buffer Size: %" PRIu32 "\n" + " Tolerance: %" PRIu32 "%s\n" + " Filter out pulses shorter than: %" PRIu32 " us\n" + " Signal is done after %" PRIu32 " us of no changes", this->buffer_size_, this->tolerance_, (this->tolerance_mode_ == remote_base::TOLERANCE_MODE_TIME) ? " us" : "%", this->filter_us_, this->idle_us_); diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 04426b8b1d..57709306cd 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -153,7 +153,7 @@ bool SendspinHub::save_last_server_hash(uint32_t hash) { LastPlayedServerPref pref{.server_id_hash = hash}; bool ok = this->last_played_server_pref_.save(&pref); if (ok) { - ESP_LOGD(TAG, "Persisted last played server hash: 0x%08X", hash); + ESP_LOGD(TAG, "Persisted last played server hash: 0x%08" PRIX32, hash); } else { ESP_LOGW(TAG, "Failed to persist last played server hash"); } @@ -164,7 +164,7 @@ bool SendspinHub::save_last_server_hash(uint32_t hash) { std::optional SendspinHub::load_last_server_hash() { LastPlayedServerPref pref{}; if (this->last_played_server_pref_.load(&pref)) { - ESP_LOGI(TAG, "Loaded last played server hash: 0x%08X", pref.server_id_hash); + ESP_LOGI(TAG, "Loaded last played server hash: 0x%08" PRIX32, pref.server_id_hash); return pref.server_id_hash; } return std::nullopt; diff --git a/esphome/components/total_daily_energy/total_daily_energy.cpp b/esphome/components/total_daily_energy/total_daily_energy.cpp index 161c712cc1..7c9dbb604f 100644 --- a/esphome/components/total_daily_energy/total_daily_energy.cpp +++ b/esphome/components/total_daily_energy/total_daily_energy.cpp @@ -72,7 +72,7 @@ void TotalDailyEnergy::schedule_midnight_reset_() { timeout_seconds = seconds_until_midnight + 1; } - ESP_LOGD(TAG, "Scheduling midnight check in %us", timeout_seconds); + ESP_LOGD(TAG, "Scheduling midnight check in %" PRIu32 "s", timeout_seconds); this->set_timeout(TIMEOUT_ID_MIDNIGHT, timeout_seconds * MILLIS_PER_SECOND, [this]() { this->schedule_midnight_reset_(); }); } From ecac6b64ec87b1e9d22a2181e724e1cb93f2b15e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 22:41:36 -0400 Subject: [PATCH 0072/1815] [espidf] Gate esp_idf_size --ng on IDF version (#16441) --- esphome/build_gen/espidf.py | 10 ++++++++-- tests/unit_tests/build_gen/test_espidf.py | 7 ++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 5ad2072c5b..96f84ebbd1 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -3,7 +3,8 @@ import json from pathlib import Path -from esphome.components.esp32 import get_esp32_variant +from esphome.components.esp32 import get_esp32_variant, idf_version +import esphome.config_validation as cv from esphome.core import CORE from esphome.helpers import mkdir_p, write_file_if_changed from esphome.writer import update_storage_json @@ -61,6 +62,11 @@ def get_project_cmakelists(minimal: bool = False) -> str: variant = get_esp32_variant() idf_target = variant.lower().replace("-", "") + # esp_idf_size 2.x (bundled with IDF >=6.0) made NG the default and + # removed the --ng flag; on 1.x (IDF 5.5) --ng is required to get + # --format=raw because the legacy mode doesn't support it. + size_ng_flag = "--ng" if idf_version() < cv.Version(6, 0, 0) else "" + # Project-wide compile options: -D defines and -W warning flags (skip # -Wl, linker flags — those go on the src component via # target_link_options below). Emitted via idf_build_set_property so the @@ -146,7 +152,7 @@ project({CORE.name}) # Emit raw JSON size data for ESPHome to read post-build. add_custom_command( TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD - COMMAND ${{PYTHON}} -m esp_idf_size --ng --format=raw + COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=raw -o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json ${{CMAKE_PROJECT_NAME}}.map WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}} diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index 36f0442355..540dd06731 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -11,10 +11,12 @@ import pytest from esphome.components.esp32 import ( KEY_COMPONENTS, KEY_ESP32, + KEY_IDF_VERSION, KEY_PATH, KEY_REF, KEY_REPO, ) +import esphome.config_validation as cv from esphome.const import KEY_CORE from esphome.core import CORE @@ -24,7 +26,10 @@ def _reset_core(tmp_path: Path) -> None: """Give each test its own CORE.build_path and a clean esp32 data slot.""" CORE.build_path = str(tmp_path) CORE.data.setdefault(KEY_CORE, {}) - CORE.data[KEY_ESP32] = {KEY_COMPONENTS: {}} + CORE.data[KEY_ESP32] = { + KEY_COMPONENTS: {}, + KEY_IDF_VERSION: cv.Version(5, 5, 4), + } def _write_project_description(tmp_path: Path, components: dict[str, str]) -> None: From c037058c199ff660e828474e2b284cca787b5f9b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 22:51:14 -0400 Subject: [PATCH 0073/1815] [esp32_hosted] Bump esp_hosted to 2.12.7 (#16440) --- esphome/components/esp32_hosted/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index eca7c24b10..71d1fd3ac1 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -249,7 +249,7 @@ async def to_code(config): esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.5.1") esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.2") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.6") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.7") else: esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 814a4031c1..49c4cdbb2e 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -36,7 +36,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.12.6 + version: 2.12.7 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: From 4f895425cac87e79e7b0396d41994d08181fd438 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 14 May 2026 23:31:11 -0400 Subject: [PATCH 0074/1815] [audio] Bump microMP3 to v0.2.1 (#16429) --- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 44371e87ab..13b379ba3a 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -395,7 +395,7 @@ async def to_code(config): ) if data.mp3_support: cg.add_define("USE_AUDIO_MP3_SUPPORT") - add_idf_component(name="esphome/micro-mp3", ref="0.2.0") + add_idf_component(name="esphome/micro-mp3", ref="0.2.1") _emit_memory_pair( data.mp3.buffer_memory, "CONFIG_MP3_DECODER_PREFER_PSRAM", diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 49c4cdbb2e..35c55cbb4d 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -10,7 +10,7 @@ dependencies: esphome/micro-flac: version: 0.2.0 esphome/micro-mp3: - version: 0.2.0 + version: 0.2.1 esphome/micro-opus: version: 0.4.1 esphome/micro-wav: From 25dbef83de8d4168133dbba41cf4d76cc79c6f59 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 14 May 2026 23:33:36 -0400 Subject: [PATCH 0075/1815] [sound_level] Use RingBufferAudioSource (#16436) --- .../components/sound_level/sound_level.cpp | 58 ++++++++++--------- esphome/components/sound_level/sound_level.h | 9 +-- 2 files changed, 35 insertions(+), 32 deletions(-) diff --git a/esphome/components/sound_level/sound_level.cpp b/esphome/components/sound_level/sound_level.cpp index fb8bfd3085..a93e396367 100644 --- a/esphome/components/sound_level/sound_level.cpp +++ b/esphome/components/sound_level/sound_level.cpp @@ -11,7 +11,7 @@ namespace esphome::sound_level { static const char *const TAG = "sound_level"; -static const uint32_t AUDIO_BUFFER_DURATION_MS = 30; +static const uint32_t MAX_FILL_DURATION_MS = 30; static const uint32_t RING_BUFFER_DURATION_MS = 120; // Square INT16_MIN since INT16_MIN^2 > INT16_MAX^2 @@ -30,8 +30,7 @@ void SoundLevelComponent::dump_config() { void SoundLevelComponent::setup() { this->microphone_source_->add_data_callback([this](const std::vector &data) { std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (this->ring_buffer_.use_count() == 2) { - // ``audio_buffer_`` and ``temp_ring_buffer`` share ownership of a ring buffer, so its safe/useful to write + if (temp_ring_buffer != nullptr) { temp_ring_buffer->write((void *) data.data(), data.size()); } }); @@ -81,10 +80,11 @@ void SoundLevelComponent::loop() { return; } - // Copy data from ring buffer into the transfer buffer - don't block to avoid slowing the main loop - this->audio_buffer_->transfer_data_from_source(0); + // Expose a chunk of the ring buffer's internal storage - don't block to avoid slowing the main loop. + // pre_shift is ignored by RingBufferAudioSource (no intermediate transfer buffer to compact). + this->audio_source_->fill(0, false); - if (this->audio_buffer_->available() == 0) { + if (this->audio_source_->available() == 0) { // No new audio available for processing return; } @@ -92,11 +92,11 @@ void SoundLevelComponent::loop() { const uint32_t samples_in_window = this->microphone_source_->get_audio_stream_info().ms_to_samples(this->measurement_duration_ms_); const uint32_t samples_available_to_process = - this->microphone_source_->get_audio_stream_info().bytes_to_samples(this->audio_buffer_->available()); + this->microphone_source_->get_audio_stream_info().bytes_to_samples(this->audio_source_->available()); const uint32_t samples_to_process = std::min(samples_in_window - this->sample_count_, samples_available_to_process); // MicrophoneSource always provides int16 samples due to Python codegen settings - const int16_t *audio_data = reinterpret_cast(this->audio_buffer_->get_buffer_start()); + const int16_t *audio_data = reinterpret_cast(this->audio_source_->data()); // Process all the new audio samples for (uint32_t i = 0; i < samples_to_process; ++i) { @@ -115,9 +115,8 @@ void SoundLevelComponent::loop() { ++this->sample_count_; } - // Remove the processed samples from ``audio_buffer_`` - this->audio_buffer_->decrease_buffer_length( - this->microphone_source_->get_audio_stream_info().samples_to_bytes(samples_to_process)); + // Remove the processed samples from ``audio_source_`` + this->audio_source_->consume(this->microphone_source_->get_audio_stream_info().samples_to_bytes(samples_to_process)); if (this->sample_count_ == samples_in_window) { // Processed enough samples for the measurement window, compute and publish the sensor values @@ -158,36 +157,39 @@ void SoundLevelComponent::stop() { } bool SoundLevelComponent::start_() { - if (this->audio_buffer_ != nullptr) { + if (this->audio_source_ != nullptr) { return true; } - // Allocate a transfer buffer - this->audio_buffer_ = audio::AudioSourceTransferBuffer::create( - this->microphone_source_->get_audio_stream_info().ms_to_bytes(AUDIO_BUFFER_DURATION_MS)); - if (this->audio_buffer_ == nullptr) { - this->status_momentary_error("transfer_buffer", 15000); + const auto &stream_info = this->microphone_source_->get_audio_stream_info(); + const size_t bytes_per_frame = stream_info.frames_to_bytes(1); + + // Allocate a ring buffer for the microphone callback to write into. Round the size down to a multiple + // of bytes_per_frame so the wrap boundary stays frame-aligned and avoids unnecessary single-frame splices. + this->ring_buffer_.reset(); // Reset pointer to any previous ring buffer allocation + const size_t ring_buffer_size = + (stream_info.ms_to_bytes(RING_BUFFER_DURATION_MS) / bytes_per_frame) * bytes_per_frame; + std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); + if (temp_ring_buffer == nullptr) { + this->status_momentary_error("ring_buffer", 15000); return false; } - // Allocates a new ring buffer, adds it as a source for the transfer buffer, and points ring_buffer_ to it - this->ring_buffer_.reset(); // Reset pointer to any previous ring buffer allocation - std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create( - this->microphone_source_->get_audio_stream_info().ms_to_bytes(RING_BUFFER_DURATION_MS)); - if (temp_ring_buffer.use_count() == 0) { - this->status_momentary_error("ring_buffer", 15000); - this->stop_(); + // Zero-copy source that reads directly from the ring buffer's internal storage. Frame-aligned reads + // ensure multi-channel frames are never split across the ring buffer's wrap boundary. + this->audio_source_ = audio::RingBufferAudioSource::create( + temp_ring_buffer, stream_info.ms_to_bytes(MAX_FILL_DURATION_MS), static_cast(bytes_per_frame)); + if (this->audio_source_ == nullptr) { + this->status_momentary_error("audio_source", 15000); return false; - } else { - this->ring_buffer_ = temp_ring_buffer; - this->audio_buffer_->set_source(temp_ring_buffer); } + this->ring_buffer_ = temp_ring_buffer; this->status_clear_error(); return true; } -void SoundLevelComponent::stop_() { this->audio_buffer_.reset(); } +void SoundLevelComponent::stop_() { this->audio_source_.reset(); } } // namespace esphome::sound_level diff --git a/esphome/components/sound_level/sound_level.h b/esphome/components/sound_level/sound_level.h index 4f0081a510..aabea62ca4 100644 --- a/esphome/components/sound_level/sound_level.h +++ b/esphome/components/sound_level/sound_level.h @@ -36,11 +36,12 @@ class SoundLevelComponent : public Component { void stop(); protected: - /// @brief Internal start command that, if necessary, allocates ``audio_buffer_`` and a ring buffer which - /// ``audio_buffer_`` owns and ``ring_buffer_`` points to. Returns true if allocations were successful. + /// @brief Internal start command that, if necessary, allocates a ring buffer and a zero-copy + /// ``RingBufferAudioSource`` that reads directly from it. ``ring_buffer_`` weakly references the + /// ring buffer owned by ``audio_source_``. Returns true if allocations were successful. bool start_(); - /// @brief Internal stop command the deallocates ``audio_buffer_`` (which automatically deallocates its ring buffer) + /// @brief Internal stop command that deallocates ``audio_source_`` (which releases its ring buffer) void stop_(); microphone::MicrophoneSource *microphone_source_{nullptr}; @@ -48,7 +49,7 @@ class SoundLevelComponent : public Component { sensor::Sensor *peak_sensor_{nullptr}; sensor::Sensor *rms_sensor_{nullptr}; - std::unique_ptr audio_buffer_; + std::unique_ptr audio_source_; std::weak_ptr ring_buffer_; int32_t squared_peak_{0}; From 50495c7085d618e28fa7f75531eb739673fe5d98 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 10:20:15 -0700 Subject: [PATCH 0076/1815] [wifi] Refuse to compile when wifi_ssid is the device-builder placeholder (#16444) --- esphome/__main__.py | 8 +++ esphome/components/wifi/__init__.py | 52 +++++++++++++++- esphome/const.py | 9 +++ tests/unit_tests/components/test_wifi.py | 78 +++++++++++++++++++++++- 4 files changed, 144 insertions(+), 3 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index d733534a5c..16a05ad552 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -50,6 +50,7 @@ from esphome.const import ( CONF_TOPIC, CONF_USERNAME, CONF_WEB_SERVER, + CONF_WIFI, ENV_NOGITIGNORE, KEY_CORE, KEY_TARGET_PLATFORM, @@ -733,6 +734,13 @@ def write_cpp_file() -> int: def compile_program(args: ArgsProtocol, config: ConfigType) -> int: + # Keep this gate here, NOT in config validation: device-builder needs + # `esphome config` to keep succeeding with placeholders so onboarding can run. + if CONF_WIFI in config: + from esphome.components.wifi import check_placeholder_credentials + + check_placeholder_credentials(config) + # NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py # If you change this format, update the regex in that script as well _LOGGER.info("Compiling app... Build path: %s", CORE.build_path) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index bad57fc481..f9cb391442 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -54,10 +54,18 @@ from esphome.const import ( CONF_TTLS_PHASE_2, CONF_USE_ADDRESS, CONF_USERNAME, + CONF_WIFI, + PLACEHOLDER_WIFI_SSID, Platform, PlatformFramework, ) -from esphome.core import CORE, CoroPriority, HexInt, coroutine_with_priority +from esphome.core import ( + CORE, + CoroPriority, + EsphomeError, + HexInt, + coroutine_with_priority, +) import esphome.final_validate as fv from esphome.types import ConfigType @@ -903,3 +911,45 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( "wifi_component_pico_w.cpp": {PlatformFramework.RP2040_ARDUINO}, } ) + + +def _placeholder_wifi_credentials(config: ConfigType) -> list[str]: + """Return human-readable locations where the dashboard's placeholder wifi + values still appear. Empty list means no placeholders were found. + """ + placeholders: list[str] = [] + wifi_conf = config.get(CONF_WIFI) + if not wifi_conf: + return placeholders + + for idx, network in enumerate(wifi_conf.get(CONF_NETWORKS, [])): + ssid = network.get(CONF_SSID) + if isinstance(ssid, str) and ssid == PLACEHOLDER_WIFI_SSID: + placeholders.append(f"wifi.networks[{idx}].ssid") + + ap_conf = wifi_conf.get(CONF_AP) + if ap_conf: + ap_ssid = ap_conf.get(CONF_SSID) + if isinstance(ap_ssid, str) and ap_ssid == PLACEHOLDER_WIFI_SSID: + placeholders.append("wifi.ap.ssid") + + return placeholders + + +def check_placeholder_credentials(config: ConfigType) -> None: + """Raise EsphomeError if any wifi credential is the dashboard placeholder. + + Call only at compile time. NEVER from CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA, + or any path reached by `esphome config`; device-builder relies on + validation passing with the placeholders still in place. + """ + locations = _placeholder_wifi_credentials(config) + if not locations: + return + formatted = ", ".join(locations) + raise EsphomeError( + f"wifi configuration still contains the dashboard placeholder value " + f"'{PLACEHOLDER_WIFI_SSID}' at: {formatted}. " + f"Open secrets.yaml and replace 'wifi_ssid' (and 'wifi_password') " + f"with your real wifi credentials before flashing." + ) diff --git a/esphome/const.py b/esphome/const.py index 91bc52708c..1819502201 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1415,3 +1415,12 @@ ENTITY_CATEGORY_DIAGNOSTIC = "diagnostic" # The corresponding constant exists in c++ # when update_interval is set to never, it becomes SCHEDULER_DONT_RUN milliseconds SCHEDULER_DONT_RUN = 4294967295 + +# Sentinel values written by the esphome-device-builder dashboard into +# secrets.yaml on first boot so that !secret wifi_ssid / !secret wifi_password +# references resolve cleanly through validation before the user has finished +# the onboarding wizard. Compilation refuses if these reach the binary so that +# a user who dismisses onboarding can't accidentally flash a device that will +# never associate with their wifi. +PLACEHOLDER_WIFI_SSID = "REPLACE_WITH_YOUR_WIFI_NETWORK" +PLACEHOLDER_WIFI_PASSWORD = "REPLACE_WITH_YOUR_WIFI_PASSWORD" # noqa: S105 diff --git a/tests/unit_tests/components/test_wifi.py b/tests/unit_tests/components/test_wifi.py index 71a14d7817..9598c1bdd8 100644 --- a/tests/unit_tests/components/test_wifi.py +++ b/tests/unit_tests/components/test_wifi.py @@ -3,8 +3,20 @@ import pytest from esphome.components.esp32 import const -from esphome.components.wifi import has_native_wifi, variant_has_wifi -from esphome.const import Platform +from esphome.components.wifi import ( + check_placeholder_credentials, + has_native_wifi, + variant_has_wifi, +) +from esphome.const import ( + CONF_AP, + CONF_NETWORKS, + CONF_SSID, + CONF_WIFI, + PLACEHOLDER_WIFI_SSID, + Platform, +) +from esphome.core import EsphomeError, Lambda @pytest.mark.parametrize( @@ -123,3 +135,65 @@ def test_has_native_wifi_esp32_without_variant_assumes_wifi() -> None: def test_has_native_wifi_rp2040_without_board_assumes_wifi() -> None: """RP2040 without a board id falls open to True (custom-board default).""" assert has_native_wifi(platform=Platform.RP2040) is True + + +def _wifi_config( + *, + networks: list[dict] | None = None, + ap: dict | None = None, +) -> dict: + """Build a minimal config dict matching the post-validation shape.""" + wifi: dict = {} + if networks is not None: + wifi[CONF_NETWORKS] = networks + if ap is not None: + wifi[CONF_AP] = ap + return {CONF_WIFI: wifi} + + +def test_check_placeholder_credentials_passes_with_real_ssid() -> None: + """A real SSID compiles without complaint.""" + config = _wifi_config(networks=[{CONF_SSID: "home_network"}]) + assert check_placeholder_credentials(config) is None + + +def test_check_placeholder_credentials_refuses_placeholder_ssid() -> None: + """The placeholder SSID is rejected with an actionable message.""" + config = _wifi_config(networks=[{CONF_SSID: PLACEHOLDER_WIFI_SSID}]) + with pytest.raises(EsphomeError) as exc_info: + check_placeholder_credentials(config) + message = str(exc_info.value) + assert "wifi.networks[0].ssid" in message + assert "secrets.yaml" in message + + +def test_check_placeholder_credentials_refuses_placeholder_in_second_network() -> None: + """Index reporting picks the placeholder out of a mixed network list.""" + config = _wifi_config( + networks=[ + {CONF_SSID: "home_network"}, + {CONF_SSID: PLACEHOLDER_WIFI_SSID}, + ], + ) + with pytest.raises(EsphomeError) as exc_info: + check_placeholder_credentials(config) + assert "wifi.networks[1].ssid" in str(exc_info.value) + + +def test_check_placeholder_credentials_refuses_placeholder_ap_ssid() -> None: + """An AP using the placeholder broadcast name is also refused.""" + config = _wifi_config(ap={CONF_SSID: PLACEHOLDER_WIFI_SSID}) + with pytest.raises(EsphomeError) as exc_info: + check_placeholder_credentials(config) + assert "wifi.ap.ssid" in str(exc_info.value) + + +def test_check_placeholder_credentials_no_wifi_passes() -> None: + """Ethernet-only / wifi-less configs skip the check entirely.""" + assert check_placeholder_credentials({}) is None + + +def test_check_placeholder_credentials_skips_template_ssid() -> None: + """A templated (Lambda) SSID is not a string and is skipped.""" + config = _wifi_config(networks=[{CONF_SSID: Lambda('return "x";')}]) + assert check_placeholder_credentials(config) is None From 5ec0879a1049bfccc1179efc05da072327ed5de9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 10:29:15 -0700 Subject: [PATCH 0077/1815] [core] Fix KeyError: 'esp32' on upload when validated-config cache is used (#16457) --- esphome/storage_json.py | 13 +++++- tests/unit_tests/test_compiled_config.py | 56 ++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 7d26b22f96..e481827080 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -273,10 +273,21 @@ class StorageJSON: """ CORE.name = self.name CORE.build_path = self.build_path + target_platform = self.core_platform or self.target_platform.lower() CORE.data[KEY_CORE] = { - KEY_TARGET_PLATFORM: self.core_platform or self.target_platform.lower(), + KEY_TARGET_PLATFORM: target_platform, KEY_TARGET_FRAMEWORK: self.framework, } + # The compile pipeline populates CORE.data[KEY_ESP32] when esp32's + # validator runs; on the cache fast path that validator is skipped, + # so populate the variant upload_using_esptool reads via + # esp32.get_esp32_variant(). target_platform on disk is the variant + # (e.g. "ESP32S3"); core_platform is the family (e.g. "esp32"). + if target_platform == const.PLATFORM_ESP32: + from esphome.components.esp32.const import KEY_ESP32 + from esphome.const import KEY_VARIANT + + CORE.data[KEY_ESP32] = {KEY_VARIANT: self.target_platform} def __eq__(self, o) -> bool: return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict() diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index 34e811b97b..8c9cfa8101 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -22,6 +22,7 @@ from esphome.const import ( KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, + KEY_VARIANT, ) from esphome.core import CORE @@ -47,7 +48,12 @@ wifi: """ -def _write_storage(storage_path: Path) -> None: +def _write_storage( + storage_path: Path, + *, + esp_platform: str = "ESP32", + core_platform: str | None = "esp32", +) -> None: """Write a vanilla StorageJSON sidecar for the cache tests.""" storage_path.parent.mkdir(parents=True, exist_ok=True) data = { @@ -59,14 +65,14 @@ def _write_storage(storage_path: Path) -> None: "src_version": 1, "address": "192.168.1.42", "web_port": None, - "esp_platform": "ESP32", + "esp_platform": esp_platform, "build_path": "/build/lite_test", "firmware_bin_path": "/build/lite_test/firmware.bin", "loaded_integrations": ["api", "logger", "ota", "wifi"], "loaded_platforms": [], "no_mdns": False, "framework": "arduino", - "core_platform": "esp32", + "core_platform": core_platform, } storage_path.write_text(json.dumps(data)) @@ -123,6 +129,50 @@ def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None: assert CORE.build_path == Path("/build/lite_test") assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == "esp32" assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "arduino" + # upload_using_esptool reads get_esp32_variant() off CORE.data[KEY_ESP32]. + from esphome.components.esp32.const import KEY_ESP32 + + assert CORE.data[KEY_ESP32][KEY_VARIANT] == "ESP32" + + +def test_load_compiled_config_populates_esp32_variant(tmp_path: Path) -> None: + """ESP32 variants survive the cache fast path so esptool gets the right --chip.""" + from esphome.components.esp32.const import KEY_ESP32 + + yaml_path = tmp_path / "lite_test.yaml" + yaml_path.write_text("esphome:\n name: lite_test\n") + CORE.config_path = yaml_path + + storage_dir = tmp_path / ".esphome" / "storage" + _write_storage(storage_dir / "lite_test.yaml.json", esp_platform="ESP32S3") + cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + _set_cache_mtime(cache, yaml_path, offset=5) + + assert load_compiled_config(yaml_path) is not None + assert CORE.data[KEY_ESP32][KEY_VARIANT] == "ESP32S3" + + +def test_load_compiled_config_skips_esp32_block_for_other_platforms( + tmp_path: Path, +) -> None: + """Non-esp32 targets shouldn't fabricate an esp32 data block.""" + from esphome.components.esp32.const import KEY_ESP32 + + yaml_path = tmp_path / "lite_test.yaml" + yaml_path.write_text("esphome:\n name: lite_test\n") + CORE.config_path = yaml_path + + storage_dir = tmp_path / ".esphome" / "storage" + _write_storage( + storage_dir / "lite_test.yaml.json", + esp_platform="ESP8266", + core_platform="esp8266", + ) + cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + _set_cache_mtime(cache, yaml_path, offset=5) + + assert load_compiled_config(yaml_path) is not None + assert KEY_ESP32 not in CORE.data @pytest.mark.parametrize( From c6a74222f1c198cd2032fc7f6a89f6eddbf28477 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 15 May 2026 13:32:51 -0400 Subject: [PATCH 0078/1815] [esp32_hosted][fingerprint_grow] Fix two remaining ESP32 toolchain warnings (#16442) --- esphome/components/esp32_hosted/update/esp32_hosted_update.cpp | 2 +- esphome/components/fingerprint_grow/fingerprint_grow.cpp | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index 7f3ba77895..70fa41b312 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -121,7 +121,7 @@ void Esp32HostedUpdate::setup() { } } else { ESP_LOGW(TAG, "Invalid app description magic word: 0x%08" PRIx32 " (expected 0x%08" PRIx32 ")", - app_desc->magic_word, ESP_APP_DESC_MAGIC_WORD); + app_desc->magic_word, static_cast(ESP_APP_DESC_MAGIC_WORD)); this->state_ = update::UPDATE_STATE_NO_UPDATE; } } else { diff --git a/esphome/components/fingerprint_grow/fingerprint_grow.cpp b/esphome/components/fingerprint_grow/fingerprint_grow.cpp index 3f57789034..b38d42191b 100644 --- a/esphome/components/fingerprint_grow/fingerprint_grow.cpp +++ b/esphome/components/fingerprint_grow/fingerprint_grow.cpp @@ -206,6 +206,7 @@ uint8_t FingerprintGrowComponent::save_fingerprint_() { break; case ENROLL_MISMATCH: ESP_LOGE(TAG, "Scans do not match"); + [[fallthrough]]; default: return this->data_[0]; } From 26907f17f5833f08a8b9b7e3baf0f9900f22aafb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 14:42:51 -0700 Subject: [PATCH 0079/1815] Bump aioesphomeapi from 45.0.0 to 45.0.1 (#16467) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6291b5cd41..ae50c4046b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==45.0.0 +aioesphomeapi==45.0.1 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 6a8f24b951ea396e60b6ef1f2ec1acce5be2f5b6 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 16 May 2026 08:30:04 +1000 Subject: [PATCH 0080/1815] [ft5x06] Fix setting calibration values (#16446) --- .../ft5x06/touchscreen/ft5x06_touchscreen.cpp | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp b/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp index 835dc4aac0..24d3529fb4 100644 --- a/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp +++ b/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp @@ -15,6 +15,16 @@ void FT5x06Touchscreen::setup() { this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); } + // reading the chip registers to get max x/y does not seem to work. + if (this->display_ != nullptr) { + if (this->x_raw_max_ == this->x_raw_min_) { + this->x_raw_max_ = this->display_->get_native_width(); + } + if (this->y_raw_max_ == this->y_raw_min_) { + this->y_raw_max_ = this->display_->get_native_height(); + } + } + // wait 200ms after reset. this->set_timeout(200, [this] { this->continue_setup_(); }); } @@ -39,15 +49,6 @@ void FT5x06Touchscreen::continue_setup_() { this->mark_failed(); return; } - // reading the chip registers to get max x/y does not seem to work. - if (this->display_ != nullptr) { - if (this->x_raw_max_ == this->x_raw_min_) { - this->x_raw_max_ = this->display_->get_native_width(); - } - if (this->y_raw_max_ == this->y_raw_min_) { - this->y_raw_max_ = this->display_->get_native_height(); - } - } } void FT5x06Touchscreen::update_touches() { @@ -71,7 +72,7 @@ void FT5x06Touchscreen::update_touches() { uint16_t x = encode_uint16(data[i][0] & 0x0F, data[i][1]); uint16_t y = encode_uint16(data[i][2] & 0xF, data[i][3]); - ESP_LOGD(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y); + ESP_LOGV(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y); if (status == 0 || status == 2) { this->add_raw_touch_position_(id, x, y); } From da237b5070cad2c0f2c430d7f54e77cf6bf8ca6b Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 16 May 2026 08:36:53 +1000 Subject: [PATCH 0081/1815] [lvgl] Fix image define (#16468) --- esphome/components/lvgl/__init__.py | 5 +++++ esphome/components/lvgl/lvgl_esphome.h | 10 ++++++---- esphome/components/lvgl/styles.py | 2 ++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 91b101cd25..4277c14dd7 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -55,6 +55,7 @@ from .automation import layers_to_code, lvgl_update from .defines import ( CONF_ALIGN_TO_LAMBDA_ID, LOGGER, + add_lv_use, get_focused_widgets, get_lv_images_used, get_refreshed_widgets, @@ -71,6 +72,7 @@ from .keypads import KEYPADS_CONFIG, keypads_to_code from .lv_validation import lv_bool from .lvcode import LvContext, LvglComponent, lv_event_t_ptr, lvgl_static from .schemas import ( + BASE_PROPS, DISP_BG_SCHEMA, FULL_STYLE_SCHEMA, STYLE_REMAP, @@ -100,6 +102,7 @@ from .widgets import ( get_screen_active, set_obj_properties, ) +from .widgets.img import CONF_IMAGE # Import only what we actually use directly in this file from .widgets.msgbox import MSGBOX_SCHEMA, msgboxes_to_code @@ -433,6 +436,8 @@ async def to_code(configs): # This must be done after all widgets are created styles_used = df.get_styles_used() + if any(BASE_PROPS.get(x) is lvalid.lv_image for x in styles_used): + add_lv_use(CONF_IMAGE) for use in df.get_lv_uses(): df.add_define(f"LV_USE_{use.upper()}") cg.add_define(f"USE_LVGL_{use.upper()}") diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 218f9a60ab..3f7f1dce14 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -74,11 +74,11 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) { lv_style_set_text_font(style, font->get_lv_font()); } #endif -#if defined(USE_LVGL_IMAGE) && defined(USE_IMAGE) -#if LV_USE_IMAGE + +#ifdef USE_IMAGE +#ifdef USE_LVGL_IMAGE // Shortcut / overload, so that the source of an image widget can easily be updated from within a lambda. inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { ::lv_image_set_src(obj, image->get_lv_image_dsc()); } -#endif // LV_USE_IMAGE inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) { ::lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector); @@ -93,7 +93,8 @@ inline void lv_style_set_bg_image_src(lv_style_t *style, image::Image *image) { inline void lv_style_set_bitmap_mask_src(lv_style_t *style, image::Image *image) { ::lv_style_set_bitmap_mask_src(style, image->get_lv_image_dsc()); } -#endif // USE_LVGL_IMAGE +#endif + #ifdef USE_LVGL_ANIMIMG inline void lv_animimg_set_src(lv_obj_t *img, std::vector images) { auto *dsc = static_cast *>(lv_obj_get_user_data(img)); @@ -109,6 +110,7 @@ inline void lv_animimg_set_src(lv_obj_t *img, std::vector images lv_animimg_set_src(img, (const void **) dsc->data(), dsc->size()); } #endif // USE_LVGL_ANIMIMG +#endif // USE_IMAGE #ifdef USE_LVGL_METER int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int32_t value); diff --git a/esphome/components/lvgl/styles.py b/esphome/components/lvgl/styles.py index c1441526f9..5911505555 100644 --- a/esphome/components/lvgl/styles.py +++ b/esphome/components/lvgl/styles.py @@ -9,6 +9,7 @@ from .defines import ( CONF_THEME, LValidator, add_lv_use, + get_styles_used, get_theme_widget_map, literal, ) @@ -25,6 +26,7 @@ def has_style_props(config) -> bool: async def style_set(svar, style): for prop, validator in ALL_STYLES.items(): if (value := style.get(prop)) is not None: + get_styles_used().add(prop) if isinstance(validator, LValidator): value = await validator.process(value) if isinstance(value, list): From 2dbaaf1efda5625b4552dbd71036bd7f2584764c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 22:06:33 -0700 Subject: [PATCH 0082/1815] Bump aioesphomeapi from 45.0.1 to 45.0.2 (#16469) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ae50c4046b..7d497e2834 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==45.0.1 +aioesphomeapi==45.0.2 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From f301e90fd9eea419dd82166f17f4ad3cd6477eb6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 22:16:52 -0700 Subject: [PATCH 0083/1815] [ci] Use larger app partition for esp32-s3-idf component test grouping (#16430) --- .../build_components_base.esp32-s3-idf.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_build_components/build_components_base.esp32-s3-idf.yaml b/tests/test_build_components/build_components_base.esp32-s3-idf.yaml index ee209000e9..f3122f977e 100644 --- a/tests/test_build_components/build_components_base.esp32-s3-idf.yaml +++ b/tests/test_build_components/build_components_base.esp32-s3-idf.yaml @@ -7,6 +7,9 @@ esp32: variant: ESP32S3 framework: type: esp-idf + # Use custom partition table with larger app partition (3MB) + # Default IDF partitions only allow 1.75MB which is too small for grouped tests + partitions: ../partitions_testing.csv logger: level: VERY_VERBOSE From 20f92ad5e96cb565f898b94c79b06f2b9a699536 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 18:41:09 -0700 Subject: [PATCH 0084/1815] Bump aioesphomeapi from 45.0.2 to 45.0.3 (#16479) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 7d497e2834..92e36297a6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==45.0.2 +aioesphomeapi==45.0.3 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 4f188bf9bb07a98ea06cdd915e962a290362a957 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 19:08:19 -0700 Subject: [PATCH 0085/1815] Bump zeroconf from 0.148.0 to 0.149.3 (#16480) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 92e36297a6..63a25c8e36 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==45.0.3 -zeroconf==0.148.0 +zeroconf==0.149.3 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From df31c72e4e4908a040a4e87fc5e6ca315bd7c3ef Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 17 May 2026 15:29:38 -0400 Subject: [PATCH 0086/1815] [espidf] Switch direct framework downloader to esphome-libs/esp-idf tarballs (#16484) --- esphome/espidf/framework.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 7ff373aba8..32bcf4fb3b 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -69,7 +69,7 @@ ESPHOME_IDF_DEFAULT_FEATURES = _str_to_lst_of_str( ESPHOME_IDF_FRAMEWORK_MIRRORS = _str_to_lst_of_str( os.environ.get( "ESPHOME_IDF_FRAMEWORK_MIRRORS", - "https://github.com/espressif/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.zip;https://github.com/espressif/esp-idf/releases/download/v{MAJOR}.{MINOR}/esp-idf-v{MAJOR}.{MINOR}.zip", + "https://github.com/esphome-libs/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.tar.xz;https://github.com/esphome-libs/esp-idf/releases/download/v{MAJOR}.{MINOR}/esp-idf-v{MAJOR}.{MINOR}.tar.xz", ) ) From cdf74c180e16cc9297c476953d477e2605516921 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 18 May 2026 11:11:54 +1200 Subject: [PATCH 0087/1815] Bump version to 2026.5.0b2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index a29a78ea9c..641a491828 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.5.0b1 +PROJECT_NUMBER = 2026.5.0b2 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 1819502201..d6d533a702 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.5.0b1" +__version__ = "2026.5.0b2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 9c696f5de1f86671a983ab322e72a5e06921c7d5 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 18 May 2026 15:56:44 +1200 Subject: [PATCH 0088/1815] [ci] Move ha-addon and schema release triggers to version-notifier (#16490) --- .github/workflows/release.yml | 70 +---------------------------------- 1 file changed, 1 insertion(+), 69 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c1086c858c..9799f882db 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -212,74 +212,6 @@ jobs: docker buildx imagetools create $(jq -Rcnr 'inputs | . / "," | map("-t " + .) | join(" ")' <<< "${{ steps.tags.outputs.tags}}") \ $(printf '${{ steps.tags.outputs.image }}@sha256:%s ' *) - deploy-ha-addon-repo: - if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false' - runs-on: ubuntu-latest - needs: - - init - - deploy-manifest - steps: - - name: Generate a token - id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} - private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} - owner: esphome - repositories: home-assistant-addon - permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token) - - - name: Trigger Workflow - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - github-token: ${{ steps.generate-token.outputs.token }} - script: | - let description = "ESPHome"; - if (context.eventName == "release") { - description = ${{ toJSON(github.event.release.body) }}; - } - github.rest.actions.createWorkflowDispatch({ - owner: "esphome", - repo: "home-assistant-addon", - workflow_id: "bump-version.yml", - ref: "main", - inputs: { - version: "${{ needs.init.outputs.tag }}", - content: description - } - }) - - deploy-esphome-schema: - if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false' - runs-on: ubuntu-latest - needs: [init] - environment: ${{ needs.init.outputs.deploy_env }} - steps: - - name: Generate a token - id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} - private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} - owner: esphome - repositories: esphome-schema - permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token) - - - name: Trigger Workflow - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - github-token: ${{ steps.generate-token.outputs.token }} - script: | - github.rest.actions.createWorkflowDispatch({ - owner: "esphome", - repo: "esphome-schema", - workflow_id: "generate-schemas.yml", - ref: "main", - inputs: { - version: "${{ needs.init.outputs.tag }}", - } - }) - version-notifier: if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false' runs-on: ubuntu-latest @@ -302,7 +234,7 @@ jobs: with: github-token: ${{ steps.generate-token.outputs.token }} script: | - github.rest.actions.createWorkflowDispatch({ + await github.rest.actions.createWorkflowDispatch({ owner: "esphome", repo: "version-notifier", workflow_id: "notify.yml", From edb59476b1841b932fbb74754659f885dc58d31f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 17 May 2026 23:29:03 -0700 Subject: [PATCH 0089/1815] Bump zeroconf from 0.149.3 to 0.149.7 (#16492) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6eef8ac643..e3de4a134c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==45.0.3 -zeroconf==0.149.3 +zeroconf==0.149.7 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From b0af4a9f0dd86a7aae573d9e8cb4ea22130fb915 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Mon, 18 May 2026 18:19:48 -0500 Subject: [PATCH 0090/1815] [sen5x] Remove incorrect AQI device class from VOC and NOx Index sensors (#16463) --- esphome/components/sen5x/sensor.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index ce35cf5bf1..480654ee1b 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -25,7 +25,6 @@ from esphome.const import ( CONF_TEMPERATURE_COMPENSATION, CONF_TIME_CONSTANT, CONF_VOC, - DEVICE_CLASS_AQI, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PM1, DEVICE_CLASS_PM10, @@ -77,7 +76,6 @@ def _gas_sensor( return sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, - device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, ).extend( { From cb581271edd4b7a2ad41538af5792e706bd3e5c2 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Mon, 18 May 2026 18:19:51 -0500 Subject: [PATCH 0091/1815] [sgp4x] Remove incorrect AQI device class from VOC and NOx Index sensors (#16464) --- esphome/components/sgp4x/sensor.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/components/sgp4x/sensor.py b/esphome/components/sgp4x/sensor.py index 1e58a0f26a..d407f20a4e 100644 --- a/esphome/components/sgp4x/sensor.py +++ b/esphome/components/sgp4x/sensor.py @@ -15,7 +15,6 @@ from esphome.const import ( CONF_STORE_BASELINE, CONF_TEMPERATURE_SOURCE, CONF_VOC, - DEVICE_CLASS_AQI, ICON_RADIATOR, STATE_CLASS_MEASUREMENT, ) @@ -72,13 +71,11 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_VOC): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, - device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, ).extend(VOC_SENSOR), cv.Optional(CONF_NOX): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, - device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, ).extend(NOX_SENSOR), cv.Optional(CONF_STORE_BASELINE, default=True): cv.boolean, From 36fc36071d44ff9f23668fa9209f207eeff615cb Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Mon, 18 May 2026 18:20:08 -0500 Subject: [PATCH 0092/1815] [sen6x] Remove incorrect AQI device class from VOC and NOx Index sensors (#16465) --- esphome/components/sen6x/sensor.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index 071478e719..19c0cb500e 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -14,7 +14,6 @@ from esphome.const import ( CONF_TEMPERATURE, CONF_TYPE, CONF_VOC, - DEVICE_CLASS_AQI, DEVICE_CLASS_CARBON_DIOXIDE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PM1, @@ -93,13 +92,11 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_VOC): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, - device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_NOX): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, - device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_CO2): sensor.sensor_schema( From 7ecfe4b5c9326690b7d852b4ff2cc891bb80e40e Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 18 May 2026 22:53:19 -0400 Subject: [PATCH 0093/1815] [i2s_audio] Compute ring buffer size with SPDIF sample count (#16400) --- .../i2s_audio/speaker/i2s_audio_spdif.cpp | 21 ++++++++++--------- .../i2s_audio/speaker/i2s_audio_speaker.h | 1 - .../speaker/i2s_audio_speaker_standard.cpp | 1 + 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp index 8f67562a77..877f67775b 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -138,21 +138,21 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { // Reset lockstep records queue so it starts paired with the (also-reset) i2s_event_queue_. xQueueReset(this->write_records_queue_); - const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * SPDIF_DMA_BUFFERS_COUNT; - // Ensure ring buffer duration is at least the duration of all DMA buffers - const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_); - // The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1); - // Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and - // avoids unnecessary single-frame splices. - const size_t ring_buffer_size = - (this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame; - // For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames + // For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames (~4 ms at 48 kHz), + // not the ~15 ms a standard I2S DMA buffer holds. Derive the DMA floor from actual block size. const uint32_t frames_to_fill_single_dma_buffer = SPDIF_BLOCK_SAMPLES; const size_t bytes_to_fill_single_dma_buffer = this->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer); + const size_t dma_buffers_floor_bytes = bytes_to_fill_single_dma_buffer * SPDIF_DMA_BUFFERS_COUNT; + + // Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and + // avoids unnecessary single-frame splices. Ensure it is at least large enough to cover all DMA buffers. + const size_t requested_ring_buffer_bytes = + (this->current_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame; + const size_t ring_buffer_size = std::max(dma_buffers_floor_bytes, requested_ring_buffer_bytes); bool successful_setup = false; std::unique_ptr audio_source; @@ -177,7 +177,8 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { // on_sent events drain in lockstep without crediting any audio frames. this->spdif_encoder_->set_preload_mode(true); for (size_t i = 0; i < SPDIF_DMA_BUFFERS_COUNT; i++) { - esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS)); + // i2s_channel_preload_data is non-blocking (returns immediately when the preload buffer fills), so no wait. + esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(0); if (preload_err != ESP_OK) { break; // DMA preload buffer full or error } diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index 20bb05e322..34792bdbea 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -19,7 +19,6 @@ namespace esphome::i2s_audio { // Shared constants used by both standard and SPDIF speaker implementations -static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15; static constexpr size_t TASK_STACK_SIZE = 4096; static constexpr ssize_t TASK_PRIORITY = 19; diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index e69601e87a..ffe901504d 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -16,6 +16,7 @@ namespace esphome::i2s_audio { static const char *const TAG = "i2s_audio.speaker.std"; +static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15; static constexpr size_t DMA_BUFFERS_COUNT = 4; // Sized to comfortably absorb scheduling jitter: at most DMA_BUFFERS_COUNT events can be in flight, // doubled so that a transient backlog never overruns the queue (which would desync the lockstep From c0e71fc713702f04ff858fd381621803c19ae750 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Tue, 19 May 2026 18:53:36 +0200 Subject: [PATCH 0094/1815] [zigbee] don't allow zigbee + thread or access point (#16499) --- esphome/components/zigbee/__init__.py | 2 ++ esphome/components/zigbee/zigbee_esp32.py | 10 +++------- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 69e3fe9c5a..c75b0773d2 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -50,6 +50,8 @@ _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@luar123", "@tomaszduda23"] +CONFLICTS_WITH = ["openthread"] + BASE_SCHEMA = cv.Schema( { cv.Optional(CONF_REPORT): cv.All( diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index e446377a06..89efd583ab 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -117,15 +117,11 @@ def final_validate_esp32(config: ConfigType) -> ConfigType: if not CORE.is_esp32: return config if CONF_WIFI in fv.full_config.get(): - if config[CONF_ROUTER] and CONF_AP in fv.full_config.get()[CONF_WIFI]: - raise cv.Invalid( - "Only Zigbee End Device can be used together with a Wifi Access Point." - ) if CONF_AP in fv.full_config.get()[CONF_WIFI]: - _LOGGER.warning( - "Wifi Access Point might be unstable while Zigbee is active, use only as fallback." + raise cv.Invalid( + "A Wifi Access Point can not be used together with Zigbee." ) - elif config[CONF_ROUTER]: + if config[CONF_ROUTER]: _LOGGER.warning( "The Zigbee Router might miss packets while Wifi is active and could destabilize " "your network. Use only if Wifi is off most of the time." From 1d0ddfac5d6abed8c13bc9c9c05c9a1066126155 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 19 May 2026 12:57:18 -0400 Subject: [PATCH 0095/1815] [espidf] Print RAM summary on ESP32-S3 / unified-DIRAM variants (#16494) --- esphome/espidf/size_summary.py | 7 +- tests/unit_tests/test_size_summary.py | 128 ++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 tests/unit_tests/test_size_summary.py diff --git a/esphome/espidf/size_summary.py b/esphome/espidf/size_summary.py index 9477e664b3..3ba0bf3b4d 100644 --- a/esphome/espidf/size_summary.py +++ b/esphome/espidf/size_summary.py @@ -94,9 +94,10 @@ def print_summary(size_json: Path, partitions_csv: Path | None) -> None: _LOGGER.debug("Skipping size summary: %s", e) return - dram = data.get("memory_types", {}).get("DRAM") or {} - ram_used = dram.get("used") - ram_total = dram.get("size") + memory_types = data.get("memory_types", {}) + ram_region = memory_types.get("DRAM") or memory_types.get("DIRAM") or {} + ram_used = ram_region.get("used") + ram_total = ram_region.get("size") if ram_total and ram_used is not None: print(f"RAM: {_format_bar(ram_used, ram_total)}") diff --git a/tests/unit_tests/test_size_summary.py b/tests/unit_tests/test_size_summary.py new file mode 100644 index 0000000000..933be88476 --- /dev/null +++ b/tests/unit_tests/test_size_summary.py @@ -0,0 +1,128 @@ +"""Tests for esphome.espidf.size_summary.print_summary.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from esphome.espidf.size_summary import print_summary + + +def _write_size_json(tmp_path: Path, data: dict) -> Path: + """Drop a fake esp_idf_size.json under ``tmp_path`` and return the path.""" + out = tmp_path / "esp_idf_size.json" + out.write_text(json.dumps(data)) + return out + + +def _esp32_size_data() -> dict: + """Synthetic esp_idf_size.json for the original ESP32 (split IRAM/DRAM).""" + return { + "image_size": 827455, + "memory_types": { + "DRAM": { + "size": 180736, + "used": 47332, + "sections": { + ".dram0.bss": {"abbrev_name": ".bss", "size": 30616}, + ".dram0.data": {"abbrev_name": ".data", "size": 16716}, + }, + }, + "IRAM": { + "size": 131072, + "used": 80351, + "sections": { + ".iram0.text": {"abbrev_name": ".text", "size": 79323}, + ".iram0.vectors": {"abbrev_name": ".vectors", "size": 1028}, + }, + }, + }, + } + + +def _s3_size_data() -> dict: + """Synthetic esp_idf_size.json for ESP32-S3 (unified DIRAM).""" + return { + "image_size": 724215, + "memory_types": { + "DIRAM": { + "size": 341760, + "used": 104999, + "sections": { + ".iram0.text": {"abbrev_name": ".text", "size": 58051}, + ".dram0.bss": {"abbrev_name": ".bss", "size": 27088}, + ".dram0.data": {"abbrev_name": ".data", "size": 19708}, + ".noinit": {"abbrev_name": ".noinit", "size": 152}, + }, + }, + "IRAM": { + "size": 16384, + "used": 16384, + "sections": { + ".iram0.text": {"abbrev_name": ".text", "size": 15356}, + ".iram0.vectors": {"abbrev_name": ".vectors", "size": 1028}, + }, + }, + }, + } + + +def test_print_summary_esp32_uses_dram( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Original ESP32: DRAM has no ``.text``, so RAM = DRAM.used / DRAM.size unchanged.""" + size_json = _write_size_json(tmp_path, _esp32_size_data()) + print_summary(size_json, partitions_csv=None) + out = capsys.readouterr().out + assert "RAM:" in out + assert "used 47332 bytes from 180736 bytes" in out + + +def test_print_summary_s3_falls_back_to_diram( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """ESP32-S3 with no DRAM key falls back to DIRAM and reports raw region usage.""" + size_json = _write_size_json(tmp_path, _s3_size_data()) + print_summary(size_json, partitions_csv=None) + out = capsys.readouterr().out + assert "used 104999 bytes from 341760 bytes" in out + + +def test_print_summary_skips_when_diram_total_collapses( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A zero-size region drops the RAM line rather than divide by zero.""" + size_json = _write_size_json( + tmp_path, + { + "memory_types": { + "DIRAM": { + "size": 0, + "used": 0, + "sections": {}, + }, + }, + }, + ) + print_summary(size_json, partitions_csv=None) + out = capsys.readouterr().out + assert "RAM:" not in out + + +def test_print_summary_handles_missing_json( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Missing size json is non-fatal and prints nothing.""" + print_summary(tmp_path / "does_not_exist.json", partitions_csv=None) + assert capsys.readouterr().out == "" + + +def test_print_summary_handles_no_memory_types( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A size json without ``memory_types`` still doesn't crash.""" + size_json = _write_size_json(tmp_path, {"image_size": 0}) + print_summary(size_json, partitions_csv=None) + assert capsys.readouterr().out == "" From 80ed54103257bab0ea1ba76caee56003ee5673e1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 May 2026 10:13:58 -0700 Subject: [PATCH 0096/1815] [core] Add progmem_memcpy HAL helper (#16470) --- esphome/components/esp8266/hal.h | 6 ++++++ esphome/core/hal.h | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp8266/hal.h b/esphome/components/esp8266/hal.h index effa9c9371..f3b33da692 100644 --- a/esphome/components/esp8266/hal.h +++ b/esphome/components/esp8266/hal.h @@ -58,6 +58,12 @@ __attribute__((always_inline)) inline const char *progmem_read_ptr(const char *c __attribute__((always_inline)) inline uint16_t progmem_read_uint16(const uint16_t *addr) { return pgm_read_word(addr); // NOLINT } +// Bulk PROGMEM copy: routes to the SDK's aligned-flash `memcpy_P` so callers +// don't have to drop to a byte-by-byte `progmem_read_byte` loop, which on +// ESP8266 is ~4x as many flash accesses as the bulk path. +__attribute__((always_inline)) inline void progmem_memcpy(void *dst, const void *src, size_t len) { + memcpy_P(dst, src, len); // NOLINT +} // NOLINTNEXTLINE(readability-identifier-naming) __attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 4babda807d..b44a422836 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -1,6 +1,7 @@ #pragma once -#include #include +#include +#include #include "gpio.h" #include "esphome/core/defines.h" #include "esphome/core/time_64.h" @@ -42,6 +43,9 @@ void __attribute__((noreturn)) arch_restart(); inline uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } inline const char *progmem_read_ptr(const char *const *addr) { return *addr; } inline uint16_t progmem_read_uint16(const uint16_t *addr) { return *addr; } +// Bulk copy out of PROGMEM. PROGMEM is a no-op everywhere except ESP8266, so a +// plain `std::memcpy` is correct and the fast path here. +inline void progmem_memcpy(void *dst, const void *src, size_t len) { std::memcpy(dst, src, len); } #endif } // namespace esphome From 863af482ecd4d1afbdd7c31bdcaea92f02431955 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 May 2026 10:14:15 -0700 Subject: [PATCH 0097/1815] [esp32_ble_server] Honor client offset and MTU in long reads (#16458) --- .../esp32_ble_server/ble_characteristic.cpp | 48 ++++++++----------- .../esp32_ble_server/ble_characteristic.h | 1 - 2 files changed, 20 insertions(+), 29 deletions(-) diff --git a/esphome/components/esp32_ble_server/ble_characteristic.cpp b/esphome/components/esp32_ble_server/ble_characteristic.cpp index 842c78a8aa..4d364b4655 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_server/ble_characteristic.cpp @@ -196,43 +196,35 @@ void BLECharacteristic::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt (*this->on_read_callback_)(param->read.conn_id); } - uint16_t max_offset = 22; - + // Use the client-supplied offset for long reads; short reads always start at 0. + // The Bluedroid stack truncates ATT_READ_RSP / ATT_READ_BLOB_RSP to MTU-1, so we + // just provide as much data as we have from the requested offset and let the stack + // handle framing. The client issues subsequent blob reads with increasing offsets + // until it has received the whole value. + const uint16_t offset = param->read.is_long ? param->read.offset : 0; + esp_gatt_status_t status = ESP_GATT_OK; esp_gatt_rsp_t response; - if (param->read.is_long) { - if (this->value_read_offset_ >= this->value_.size()) { - response.attr_value.len = 0; - response.attr_value.offset = this->value_read_offset_; - this->value_read_offset_ = 0; - } else if (this->value_.size() - this->value_read_offset_ < max_offset) { - // Last message in the chain - response.attr_value.len = this->value_.size() - this->value_read_offset_; - response.attr_value.offset = this->value_read_offset_; - memcpy(response.attr_value.value, this->value_.data() + response.attr_value.offset, response.attr_value.len); - this->value_read_offset_ = 0; - } else { - response.attr_value.len = max_offset; - response.attr_value.offset = this->value_read_offset_; - memcpy(response.attr_value.value, this->value_.data() + response.attr_value.offset, response.attr_value.len); - this->value_read_offset_ += max_offset; - } + response.attr_value.offset = offset; + + if (offset > this->value_.size()) { + status = ESP_GATT_INVALID_OFFSET; + response.attr_value.len = 0; } else { - response.attr_value.offset = 0; - response.attr_value.len = this->value_.size(); - if (response.attr_value.len > ESP_GATT_MAX_ATTR_LEN) { - ESP_LOGW(TAG, "Characteristic length %u exceeds buffer size of %u, truncating", response.attr_value.len, - ESP_GATT_MAX_ATTR_LEN); - response.attr_value.len = ESP_GATT_MAX_ATTR_LEN; + size_t remaining = this->value_.size() - offset; + if (remaining > ESP_GATT_MAX_ATTR_LEN) { + ESP_LOGW(TAG, "Characteristic length %u exceeds buffer size of %u, truncating", + static_cast(remaining), ESP_GATT_MAX_ATTR_LEN); + remaining = ESP_GATT_MAX_ATTR_LEN; } - memcpy(response.attr_value.value, this->value_.data(), response.attr_value.len); - this->value_read_offset_ = 0; + response.attr_value.len = remaining; + memcpy(response.attr_value.value, this->value_.data() + offset, remaining); } response.attr_value.handle = this->handle_; response.attr_value.auth_req = ESP_GATT_AUTH_REQ_NONE; esp_err_t err = - esp_ble_gatts_send_response(gatts_if, param->read.conn_id, param->read.trans_id, ESP_GATT_OK, &response); + esp_ble_gatts_send_response(gatts_if, param->read.conn_id, param->read.trans_id, status, &response); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ble_gatts_send_response failed: %d", err); } diff --git a/esphome/components/esp32_ble_server/ble_characteristic.h b/esphome/components/esp32_ble_server/ble_characteristic.h index 94c7495cbd..933177a399 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.h +++ b/esphome/components/esp32_ble_server/ble_characteristic.h @@ -79,7 +79,6 @@ class BLECharacteristic { esp_gatt_char_prop_t properties_; uint16_t handle_{0xFFFF}; - uint16_t value_read_offset_{0}; std::vector value_; std::vector descriptors_; From e979d461f01ca69f0715d687217609d6d4060994 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 12:15:50 -0500 Subject: [PATCH 0098/1815] Bump codecov/codecov-action from 6.0.0 to 6.0.1 (#16500) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de21456841..dbbb06c86c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -237,7 +237,7 @@ jobs: . venv/bin/activate pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/ - name: Upload coverage to Codecov - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 with: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache From 9924d998f1dbc42bb868c16f794e9238d34e70c7 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 19 May 2026 14:37:41 -0400 Subject: [PATCH 0099/1815] [i2s_audio] Optimize SPDIF encoder and suport higher bit depth audio (#16504) Co-authored-by: Keith Burzinski --- .../components/i2s_audio/speaker/__init__.py | 7 +- .../i2s_audio/speaker/i2s_audio_spdif.cpp | 12 +- .../i2s_audio/speaker/spdif_encoder.cpp | 537 +++++++++++------- .../i2s_audio/speaker/spdif_encoder.h | 59 +- .../common-spdif_mode.yaml} | 11 - .../test-spdif_speaker.esp32-idf.yaml | 8 + 6 files changed, 372 insertions(+), 262 deletions(-) rename tests/components/{speaker/spdif_mode.esp32-idf.yaml => i2s_audio/common-spdif_mode.yaml} (52%) create mode 100644 tests/components/i2s_audio/test-spdif_speaker.esp32-idf.yaml diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index 759cc40ca9..8215d8b518 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -89,10 +89,10 @@ def _set_num_channels_from_config(config): def _set_stream_limits(config): if config.get(CONF_SPDIF_MODE, False): - # SPDIF mode: fixed to 16-bit stereo at configured sample rate + # SPDIF mode: 16/24/32-bit audio and stereo at configured sample rate audio.set_stream_limits( min_bits_per_sample=16, - max_bits_per_sample=16, + max_bits_per_sample=32, min_channels=2, max_channels=2, min_sample_rate=config.get(CONF_SAMPLE_RATE), @@ -213,9 +213,6 @@ def _final_validate(config): ) if config[CONF_CHANNEL] != CONF_STEREO: raise cv.Invalid("SPDIF mode only supports stereo channel configuration") - # bits_per_sample is converted to float by the schema - if config[CONF_BITS_PER_SAMPLE] != 16: - raise cv.Invalid("SPDIF mode only supports 16 bits per sample") if not config[CONF_USE_APLL]: raise cv.Invalid( "SPDIF mode requires 'use_apll: true' for accurate clock generation" diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp index 877f67775b..989bcf2977 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -411,8 +411,9 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s this->sample_rate_, audio_stream_info.get_sample_rate()); return ESP_ERR_NOT_SUPPORTED; } - if (audio_stream_info.get_bits_per_sample() != 16) { - ESP_LOGE(TAG, "Only supports 16 bits per sample"); + const uint8_t bits_per_sample = audio_stream_info.get_bits_per_sample(); + if (bits_per_sample != 16 && bits_per_sample != 24 && bits_per_sample != 32) { + ESP_LOGE(TAG, "Only supports 16, 24, or 32 bits per sample (got %u)", (unsigned) bits_per_sample); return ESP_ERR_NOT_SUPPORTED; } if (audio_stream_info.get_channels() != 2) { @@ -420,11 +421,8 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s return ESP_ERR_NOT_SUPPORTED; } - if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO && - (i2s_slot_bit_width_t) audio_stream_info.get_bits_per_sample() > this->slot_bit_width_) { - ESP_LOGE(TAG, "Stream bits per sample must be less than or equal to the speaker's configuration"); - return ESP_ERR_NOT_SUPPORTED; - } + // Tell the encoder what input width to expect. 32-bit input is truncated to 24-bit on the wire. + this->spdif_encoder_->set_bytes_per_sample(bits_per_sample / 8); if (!this->parent_->try_lock()) { ESP_LOGE(TAG, "Parent bus is busy"); diff --git a/esphome/components/i2s_audio/speaker/spdif_encoder.cpp b/esphome/components/i2s_audio/speaker/spdif_encoder.cpp index 42a72346cc..30146e0a70 100644 --- a/esphome/components/i2s_audio/speaker/spdif_encoder.cpp +++ b/esphome/components/i2s_audio/speaker/spdif_encoder.cpp @@ -17,7 +17,7 @@ static constexpr uint8_t PREAMBLE_M = 0x1d; // Left channel (not block start) static constexpr uint8_t PREAMBLE_W = 0x1b; // Right channel // BMC encoding of 4 zero bits starting at phase HIGH: 00_11_00_11 = 0x33 -// Since both aux nibbles (bits 4-7, 8-11) are zero for 16-bit audio and phase is preserved, both are 0x33. +// Used as a constant in the 16-bit subframe path, where bits 4-11 are always zero. static constexpr uint32_t BMC_ZERO_NIBBLE = 0x33; // Constexpr BMC encoder for compile-time LUT generation. @@ -36,21 +36,43 @@ static constexpr uint16_t bmc_lut_encode(uint32_t data, uint8_t num_bits) { return bmc; } -// 4-bit BMC lookup table: 16 entries (16 bytes in flash) -// Index: 4-bit data value (0-15), always phase=true start +// Compile-time parity helper (constexpr-friendly, runs only at LUT build time). +static constexpr uint32_t bmc_lut_parity(uint32_t value, uint32_t num_bits) { + uint32_t p = 0; + for (uint32_t b = 0; b < num_bits; b++) + p ^= (value >> b) & 1u; + return p; +} + +// Combined BMC + phase-delta lookup tables. +// Each entry packs the BMC pattern (lower bits, phase=high start) together with +// a phase-mask delta in bits 16-31 (0xFFFF if the input has odd parity, else 0). +// XORing the delta into the running phase mask propagates parity across chunks +// without an explicit popcount. + +// 4-bit BMC lookup table: 16 entries x uint32_t = 64 bytes in flash. +// Bits 0-7 : 8-bit BMC pattern (phase=high start) +// Bits 16-31 : phase-mask delta (0xFFFFu if odd parity, else 0) static constexpr auto BMC_LUT_4 = [] { - std::array t{}; - for (uint32_t i = 0; i < 16; i++) - t[i] = static_cast(bmc_lut_encode(i, 4)); + std::array t{}; + for (uint32_t i = 0; i < 16; i++) { + uint32_t bmc = bmc_lut_encode(i, 4); + uint32_t delta = bmc_lut_parity(i, 4) ? 0xFFFF0000u : 0u; + t[i] = bmc | delta; + } return t; }(); -// 8-bit BMC lookup table: 256 entries (512 bytes in flash) -// Index: 8-bit data value (0-255), always phase=true start +// 8-bit BMC lookup table: 256 entries x uint32_t = 1024 bytes in flash. +// Bits 0-15 : 16-bit BMC pattern (phase=high start) +// Bits 16-31 : phase-mask delta (0xFFFFu if odd parity, else 0) static constexpr auto BMC_LUT_8 = [] { - std::array t{}; - for (uint32_t i = 0; i < 256; i++) - t[i] = bmc_lut_encode(i, 8); + std::array t{}; + for (uint32_t i = 0; i < 256; i++) { + uint32_t bmc = bmc_lut_encode(i, 8); + uint32_t delta = bmc_lut_parity(i, 8) ? 0xFFFF0000u : 0u; + t[i] = bmc | delta; + } return t; }(); @@ -63,7 +85,7 @@ bool SPDIFEncoder::setup() { } ESP_LOGV(TAG, "Buffer allocated (%zu bytes)", SPDIF_BLOCK_SIZE_BYTES); - // Build initial channel status block with default sample rate + // Build initial channel status block with default sample rate and width this->build_channel_status_(); this->reset(); @@ -73,7 +95,7 @@ bool SPDIFEncoder::setup() { void SPDIFEncoder::reset() { this->spdif_block_ptr_ = this->spdif_block_buf_.get(); this->frame_in_block_ = 0; - this->is_left_channel_ = true; + this->block_buf_is_silence_block_ = false; } void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) { @@ -84,31 +106,27 @@ void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) { } } +void SPDIFEncoder::set_bytes_per_sample(uint8_t bytes_per_sample) { + if (bytes_per_sample != 2 && bytes_per_sample != 3 && bytes_per_sample != 4) { + ESP_LOGE(TAG, "Unsupported bytes per sample: %u", (unsigned) bytes_per_sample); + return; + } + if (this->bytes_per_sample_ != bytes_per_sample) { + this->bytes_per_sample_ = bytes_per_sample; + this->build_channel_status_(); + // Discard any partial block built at the previous width so we never mix widths on the wire. + this->reset(); + ESP_LOGD(TAG, "Input width set to %u-bit", (unsigned) bytes_per_sample * 8); + } +} + void SPDIFEncoder::build_channel_status_() { // IEC 60958-3 Consumer Channel Status Block (192 bits = 24 bytes) - // Transmitted LSB-first within each byte, one bit per frame via C bit - // - // Byte 0: Control bits - // Bit 0: 0 = Consumer format (not professional AES3) - // Bit 1: 0 = PCM audio (not non-audio data like AC3) - // Bit 2: 0 = No copyright assertion - // Bits 3-5: 000 = No pre-emphasis - // Bits 6-7: 00 = Mode 0 (basic consumer format) - // - // Byte 1: Category code (0x00 = general, 0x01 = CD, etc.) - // - // Byte 2: Source/channel numbers - // Bits 0-3: Source number (0 = unspecified) - // Bits 4-7: Channel number (0 = unspecified) - // - // Byte 3: Sample frequency and clock accuracy - // Bits 0-3: Sample frequency code - // Bits 4-5: Clock accuracy (00 = Level II, ±1000 ppm, appropriate for ESP32) - // Bits 6-7: Reserved (0) - // - // Bytes 4-23: Reserved (zeros for basic compliance) + // Transmitted LSB-first within each byte, one bit per frame via C bit. + + // Any cached silence block was built for the previous channel status; it is now stale. + this->block_buf_is_silence_block_ = false; - // Clear all bytes first this->channel_status_.fill(0); // Byte 0: Consumer, PCM audio, no copyright, no pre-emphasis, Mode 0 @@ -140,132 +158,148 @@ void SPDIFEncoder::build_channel_status_() { // Byte 3: freq_code in bits 0-3, clock accuracy (00) in bits 4-5 this->channel_status_[3] = freq_code; // Clock accuracy bits 4-5 are already 0 - // Bytes 4-23 remain zero (word length not specified, no original sample freq, etc.) + // Byte 4: Word length encoding (IEC 60958-3 consumer) + // bit 0: max length flag (0 = max 20 bits, 1 = max 24 bits) + // bits 1-3: word length code relative to the max + // For our supported widths: + // 16-bit (max 20): 0b0010 = 0x02 -- "16 bits, max 20" + // 24-bit (max 24): 0b1101 = 0x0D -- "24 bits, max 24" + // 32-bit input is truncated to 24-bit on the wire, so use the 24-bit code. + uint8_t word_length_code; + switch (this->bytes_per_sample_) { + case 2: + word_length_code = 0x02; + break; + case 3: // Shared case + case 4: + word_length_code = 0x0D; + break; + default: + word_length_code = 0x00; // not specified + break; + } + this->channel_status_[4] = word_length_code; } -HOT void SPDIFEncoder::encode_sample_(const uint8_t *pcm_sample) { - // ============================================================================ - // Build raw 32-bit subframe (IEC 60958 format) - // ============================================================================ - // Bit layout: - // Bits 0-3: Preamble (handled separately, not in raw_subframe) - // Bits 4-7: Auxiliary audio data (zeros for 16-bit audio) - // Bits 8-11: Audio LSB extension (zeros for 16-bit audio) - // Bits 12-27: 16-bit audio sample (MSB-aligned in 20-bit audio field) - // Bit 28: V (Validity) - 0 = valid audio - // Bit 29: U (User data) - 0 - // Bit 30: C (Channel status) - from channel status block - // Bit 31: P (Parity) - even parity over bits 4-31 - // ============================================================================ +// Extract the C bit for the given frame from channel_status_ and shift it into bit 30 +// so it can be OR'd directly into a raw subframe. +ESPHOME_ALWAYS_INLINE static inline uint32_t c_bit_for_frame(const std::array &channel_status, + uint32_t frame) { + return static_cast((channel_status[frame >> 3] >> (frame & 7)) & 1u) << 30; +} - // Place 16-bit audio sample at bits 12-27 (little-endian input: [0]=LSB, [1]=MSB) - uint32_t raw_subframe = (static_cast(pcm_sample[1]) << 20) | (static_cast(pcm_sample[0]) << 12); +// ============================================================================ +// IEC 60958 subframe bit layout +// ============================================================================ +// Bits 0-3: Preamble (handled separately, not in raw_subframe) +// Bits 4-7: Auxiliary audio data / 24-bit audio LSB +// Bits 8-11: Audio LSB extension (zero for 16-bit, low nibble of audio for 24-bit) +// Bits 12-27: Audio sample (16 high bits in 16-bit mode, mid 16 bits in 24-bit mode) +// Bit 28: V (Validity) - 0 = valid audio +// Bit 29: U (User data) - 0 +// Bit 30: C (Channel status) - from channel status block +// Bit 31: P (Parity) - even parity over bits 4-31 +// ============================================================================ - // V = 0 (valid audio), U = 0 (no user data) - // C = channel status bit for current frame (same bit used for both L and R subframes) - bool c_bit = this->get_channel_status_bit_(this->frame_in_block_); - if (c_bit) { - raw_subframe |= (1U << 30); +// Build a raw IEC 60958 subframe from PCM little-endian input of width Bps bytes. +// Caller is responsible for OR-ing in the C bit and parity. +template ESPHOME_ALWAYS_INLINE static inline uint32_t build_raw_subframe(const uint8_t *pcm_sample) { + static_assert(Bps == 2 || Bps == 3 || Bps == 4, "Unsupported bytes per sample"); + if constexpr (Bps == 2) { + // 16-bit input: MSB-aligned in the 20-bit audio field, bits 12-27. + return (static_cast(pcm_sample[1]) << 20) | (static_cast(pcm_sample[0]) << 12); + } else if constexpr (Bps == 3) { + // 24-bit input: full 24-bit audio field, bits 4-27. + return (static_cast(pcm_sample[2]) << 20) | (static_cast(pcm_sample[1]) << 12) | + (static_cast(pcm_sample[0]) << 4); + } else { // Bps == 4 + // 32-bit input truncated to 24-bit: drop the lowest byte. + return (static_cast(pcm_sample[3]) << 20) | (static_cast(pcm_sample[2]) << 12) | + (static_cast(pcm_sample[1]) << 4); } +} - // Calculate even parity over bits 4-30 - // This ensures consistent BMC ending phase regardless of audio content - uint32_t bits_4_30 = (raw_subframe >> 4) & 0x07FFFFFF; // 27 bits (4-30) - uint32_t ones_count = __builtin_popcount(bits_4_30); - uint32_t parity = ones_count & 1; // 1 if odd count, 0 if even - raw_subframe |= parity << 31; // Set P bit to make total even +// BMC-encode a subframe and write the two output uint32 words to dst. Caller passes +// raw_subframe with the C bit set (bit 30) and the P bit cleared (bit 31 = 0). P is +// derived from the cumulative parity-mask delta of the per-byte LUT lookups. +// +// I2S halfword swap means word[0] transmits as: bits 24-31, 16-23, 8-15, 0-7. +// word[1] transmits as: bits 16-31, 0-15. Within each halfword, MSB-first. +// All preambles end at phase HIGH, so phase=true at the start of bit 4. +// +// P-bit derivation: BMC_LUT_*'s upper half encodes the parity of the input chunk. Each +// chunk's parity delta is shifted down (`lut >> 16`) into a phase_mask that lives in the +// low 16 bits, so the same value can also be XORed against subsequent BMC patterns to +// invert phase. XOR'ing those deltas through all chunks (with bit 31 = 0) yields the +// parity of bits 4-30 in the low bits of phase_mask -- the required value of the P bit +// for even total parity. The BMC of bit 31 lives in bit 0 of the high-byte BMC output +// (i = 7 maps to position (8-1-7)*2 = 0); flipping the source bit flips only the lower +// BMC bit (= phase XOR bit), so applying P is `bmc_24_31 ^= phase_mask & 1u`. +template +ESPHOME_ALWAYS_INLINE static inline void bmc_encode_subframe(uint32_t raw_subframe, uint8_t preamble, uint32_t *dst) { + if constexpr (Bps == 2) { + // 16-bit path: bits 4-11 are zero, encoded inline as BMC_ZERO_NIBBLE constants. + // Eight zero source bits with start phase=HIGH end at phase=HIGH (popcount of zeros is even), + // so encoding of bits 12-15 starts at phase=true. Zeros contribute 0 to parity. + uint32_t nibble = (raw_subframe >> 12) & 0xF; + uint32_t lut_n = BMC_LUT_4[nibble]; + uint32_t bmc_12_15 = lut_n & 0xFFu; + uint32_t phase_mask = lut_n >> 16; // 0xFFFFu if odd parity, else 0 - // ============================================================================ - // Select preamble based on position in block and channel - // ============================================================================ - // B = block start (left channel, frame 0 of 192-frame block) - // M = left channel (frames 1-191) - // W = right channel (all frames) - uint8_t preamble; - if (this->is_left_channel_) { - preamble = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M; + uint32_t byte_mid = (raw_subframe >> 16) & 0xFF; + uint32_t lut_m = BMC_LUT_8[byte_mid]; + uint32_t bmc_16_23 = (lut_m & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_m >> 16; + + uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; // bit 7 (= P) is 0 by precondition + uint32_t lut_h = BMC_LUT_8[byte_hi]; + uint32_t bmc_24_31 = (lut_h & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_h >> 16; + // phase_mask now reflects parity of bits 4-30. Apply P by flipping bit 0 of bmc_24_31. + bmc_24_31 ^= phase_mask & 1u; + + dst[0] = bmc_12_15 | (BMC_ZERO_NIBBLE << 8) | (BMC_ZERO_NIBBLE << 16) | (static_cast(preamble) << 24); + dst[1] = bmc_24_31 | (bmc_16_23 << 16); } else { - preamble = PREAMBLE_W; + // 24-bit (and 32-bit truncated) path: bits 4-11 are live audio. + uint32_t byte_lo = (raw_subframe >> 4) & 0xFF; + uint32_t lut_l = BMC_LUT_8[byte_lo]; + uint32_t bmc_4_11 = lut_l & 0xFFFFu; + uint32_t phase_mask = lut_l >> 16; // 0xFFFFu if odd parity, else 0 + + uint32_t nibble = (raw_subframe >> 12) & 0xF; + uint32_t lut_n = BMC_LUT_4[nibble]; + uint32_t bmc_12_15 = (lut_n & 0xFFu) ^ (phase_mask & 0xFFu); + phase_mask ^= lut_n >> 16; + + uint32_t byte_mid = (raw_subframe >> 16) & 0xFF; + uint32_t lut_m = BMC_LUT_8[byte_mid]; + uint32_t bmc_16_23 = (lut_m & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_m >> 16; + + uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; // bit 7 (= P) is 0 by precondition + uint32_t lut_h = BMC_LUT_8[byte_hi]; + uint32_t bmc_24_31 = (lut_h & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_h >> 16; + bmc_24_31 ^= phase_mask & 1u; + + // word[0]: bits 24-31 = preamble, bits 8-23 = bmc(4-11), bits 0-7 = bmc(12-15) + // word[1]: bits 16-31 = bmc(16-23), bits 0-15 = bmc(24-31) + dst[0] = bmc_12_15 | (bmc_4_11 << 8) | (static_cast(preamble) << 24); + dst[1] = bmc_24_31 | (bmc_16_23 << 16); } +} - // ============================================================================ - // BMC encode the data portion (bits 4-31) using lookup tables - // ============================================================================ - // The I2S uses 16-bit halfword swap: bits 16-31 transmit before bits 0-15. - // This applies to BOTH word[0] and word[1]. - // - // word[0] transmission order: [16-23] → [24-31] → [0-7] → [8-15] - // For correct S/PDIF subframe order (preamble → aux → audio): - // - bits 16-23: preamble (8 BMC bits) - // - bits 24-31: BMC(subframe bits 4-7) - first aux nibble - // - bits 0-7: BMC(subframe bits 8-11) - second aux nibble - // - bits 8-15: BMC(subframe bits 12-15) - audio low nibble - // - // word[1] transmission order: [16-31] → [0-15] - // For correct S/PDIF subframe order: - // - bits 16-31: BMC(subframe bits 16-23) - audio mid byte - // - bits 0-15: BMC(subframe bits 24-31) - audio high nibble + VUCP - // ============================================================================ - - // All preambles end at phase HIGH. Bits 4-11 are always zero for 16-bit audio; - // two zero nibbles flip phase 8 times total → back to HIGH. - // So bits 12-15 always start encoding at phase=true. - - // Bits 12-15: 4-bit LUT lookup (always phase=true start) - uint32_t nibble = (raw_subframe >> 12) & 0xF; - uint32_t bmc_12_15 = BMC_LUT_4[nibble]; - - // Phase tracking via branchless XOR mask: - // - 0x0000 means phase=true (use LUT value directly) - // - 0xFFFF means phase=false (complement LUT value) - // End phase = start XOR (popcount & 1) since zero-bits flip phase, - // and for even bit widths: #zeros parity == popcount parity. - uint32_t phase_mask = -(__builtin_popcount(nibble) & 1u) & 0xFFFF; - - // Bits 16-23: 8-bit LUT lookup with phase correction - uint32_t byte_mid = (raw_subframe >> 16) & 0xFF; - uint32_t bmc_16_23 = BMC_LUT_8[byte_mid] ^ phase_mask; - phase_mask ^= -(__builtin_popcount(byte_mid) & 1u) & 0xFFFF; - - // Bits 24-31: 8-bit LUT lookup with phase correction - uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; - uint32_t bmc_24_31 = BMC_LUT_8[byte_hi] ^ phase_mask; - - // ============================================================================ - // Combine with correct positioning for I2S transmission - // ============================================================================ - // I2S with halfword swap: transmits bits 16-31, then bits 0-15. - // Within each halfword, MSB (highest bit) is transmitted first. - // - // For upper halfword (bits 16-31): bit 31 → bit 16 - // For lower halfword (bits 0-15): bit 15 → bit 0 - // - // Desired S/PDIF order: preamble → bmc_4_7 → bmc_8_11 → bmc_12_15 - // - // word[0] layout for correct transmission: - // bits 24-31: preamble (transmitted 1st, as MSB of upper halfword) - // bits 16-23: BMC_ZERO_NIBBLE (transmitted 2nd, aux bits 4-7) - // bits 8-15: BMC_ZERO_NIBBLE (transmitted 3rd, aux bits 8-11) - // bits 0-7: bmc_12_15 (transmitted 4th, audio low nibble) - // - // word[1] layout: - // bits 16-31: bmc_16_23 (transmitted 5th) - // bits 0-15: bmc_24_31 (transmitted 6th) - this->spdif_block_ptr_[0] = - bmc_12_15 | (BMC_ZERO_NIBBLE << 8) | (BMC_ZERO_NIBBLE << 16) | (static_cast(preamble) << 24); - this->spdif_block_ptr_[1] = bmc_24_31 | (bmc_16_23 << 16); - this->spdif_block_ptr_ += 2; - - // ============================================================================ - // Update position tracking - // ============================================================================ - if (!this->is_left_channel_) { - // Completed a stereo frame, advance frame counter - if (++this->frame_in_block_ >= SPDIF_BLOCK_SAMPLES) { - this->frame_in_block_ = 0; - } +template void SPDIFEncoder::encode_silence_frame_() { + static constexpr uint8_t SILENCE[4] = {0, 0, 0, 0}; + uint32_t raw = build_raw_subframe(SILENCE) | c_bit_for_frame(this->channel_status_, this->frame_in_block_); + uint8_t preamble_l = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M; + bmc_encode_subframe(raw, preamble_l, this->spdif_block_ptr_); + bmc_encode_subframe(raw, PREAMBLE_W, this->spdif_block_ptr_ + 2); + this->spdif_block_ptr_ += 4; + if (++this->frame_in_block_ >= SPDIF_BLOCK_SAMPLES) { + this->frame_in_block_ = 0; } - this->is_left_channel_ = !this->is_left_channel_; } esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) { @@ -295,79 +329,162 @@ esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) { return err; } -size_t SPDIFEncoder::get_pending_pcm_bytes() const { - if (this->spdif_block_ptr_ == nullptr || this->spdif_block_buf_ == nullptr) { - return 0; +template +HOT esp_err_t SPDIFEncoder::write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait, + uint32_t *blocks_sent, size_t *bytes_consumed) { + const uint8_t *pcm_data = src; + const uint8_t *const pcm_end = src + size; + uint32_t block_count = 0; + + // Hot state lives in locals so the compiler can keep it in registers across the + // per-frame encoding work; byte writes through block_ptr may alias the member fields, + // which would block register allocation if the encoding read them directly from this->*. + uint32_t *block_ptr = this->spdif_block_ptr_; + uint32_t *const block_buf = this->spdif_block_buf_.get(); + uint32_t *const block_end = block_buf + SPDIF_BLOCK_SIZE_U32; + uint32_t frame = this->frame_in_block_; + const std::array &channel_status = this->channel_status_; + + auto save_state = [&]() { + this->spdif_block_ptr_ = block_ptr; + this->frame_in_block_ = static_cast(frame); + }; + + auto report_out_params = [&]() { + if (blocks_sent != nullptr) + *blocks_sent = block_count; + if (bytes_consumed != nullptr) + *bytes_consumed = pcm_data - src; + }; + + // Send a completed block if the buffer is full, propagating any error. + // send_block_ resets this->spdif_block_ptr_ to block_buf on success and leaves it + // unchanged on error -- mirror both behaviors in our local block_ptr. + auto maybe_send = [&]() -> esp_err_t { + if (block_ptr >= block_end) { + esp_err_t err = this->send_block_(ticks_to_wait); + if (err != ESP_OK) { + save_state(); + report_out_params(); + return err; + } + block_ptr = block_buf; + ++block_count; + } + return ESP_OK; + }; + + // Hot path: encode L+R pairs in two peeled sub-loops. Frame 0 carries the only + // buffer-full check and uses PREAMBLE_B (a block fills exactly when frame wraps from + // 191 back to 0). Frames 1..191 use PREAMBLE_M and need no buffer-full check or + // preamble branch. The encoding body is inlined here so block_ptr lives in a register + // for the duration of the loop. + while (pcm_data + 2 * Bps <= pcm_end) { + if (frame == 0) { + esp_err_t err = maybe_send(); + if (err != ESP_OK) + return err; + + uint32_t c_bit = c_bit_for_frame(channel_status, 0); + uint32_t raw_l = build_raw_subframe(pcm_data) | c_bit; + uint32_t raw_r = build_raw_subframe(pcm_data + Bps) | c_bit; + bmc_encode_subframe(raw_l, PREAMBLE_B, block_ptr); + bmc_encode_subframe(raw_r, PREAMBLE_W, block_ptr + 2); + block_ptr += 4; + frame = 1; + pcm_data += 2 * Bps; + } + + // The inner loop runs until min(SPDIF_BLOCK_SAMPLES, frame + input_frames). The + // input-size bound is folded into end_frame so a single `frame < end_frame` test + // governs termination. + uint32_t input_frames = static_cast(pcm_end - pcm_data) / (2u * Bps); + uint32_t end_frame = SPDIF_BLOCK_SAMPLES; + if (frame + input_frames < end_frame) + end_frame = frame + input_frames; + + while (frame < end_frame) { + uint32_t c_bit = c_bit_for_frame(channel_status, frame); + uint32_t raw_l = build_raw_subframe(pcm_data) | c_bit; + uint32_t raw_r = build_raw_subframe(pcm_data + Bps) | c_bit; + bmc_encode_subframe(raw_l, PREAMBLE_M, block_ptr); + bmc_encode_subframe(raw_r, PREAMBLE_W, block_ptr + 2); + block_ptr += 4; + ++frame; + pcm_data += 2 * Bps; + } + if (frame >= SPDIF_BLOCK_SAMPLES) + frame = 0; } - // Each PCM sample (2 bytes) produces 2 uint32_t values in the SPDIF buffer - // So pending uint32s / 2 = pending samples, and each sample is 2 bytes - size_t pending_uint32s = this->spdif_block_ptr_ - this->spdif_block_buf_.get(); - size_t pending_samples = pending_uint32s / 2; - return pending_samples * 2; // 2 bytes per sample + + // Send any complete block that was just finished. + if (block_ptr >= block_end) { + esp_err_t err = this->send_block_(ticks_to_wait); + if (err != ESP_OK) { + save_state(); + report_out_params(); + return err; + } + block_ptr = block_buf; + ++block_count; + } + + save_state(); + report_out_params(); + return ESP_OK; } HOT esp_err_t SPDIFEncoder::write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent, size_t *bytes_consumed) { - const uint8_t *pcm_data = src; - const uint8_t *pcm_end = src + size; - uint32_t block_count = 0; + if (size > 0) { + // Real PCM is about to be encoded into the buffer, so it is no longer a full-silence block. + this->block_buf_is_silence_block_ = false; + } + switch (this->bytes_per_sample_) { + case 2: + return this->write_typed_<2>(src, size, ticks_to_wait, blocks_sent, bytes_consumed); + case 3: + return this->write_typed_<3>(src, size, ticks_to_wait, blocks_sent, bytes_consumed); + case 4: + return this->write_typed_<4>(src, size, ticks_to_wait, blocks_sent, bytes_consumed); + default: + return ESP_ERR_INVALID_STATE; + } +} - while (pcm_data < pcm_end) { - // Check if there's a pending complete block from a previous failed send - if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { - esp_err_t err = this->send_block_(ticks_to_wait); - if (err != ESP_OK) { - if (blocks_sent != nullptr) { - *blocks_sent = block_count; - } - if (bytes_consumed != nullptr) { - *bytes_consumed = pcm_data - src; - } - return err; - } - ++block_count; +template esp_err_t SPDIFEncoder::flush_with_silence_typed_(TickType_t ticks_to_wait) { + // If a complete block is already pending (from a previous failed send), emit just that block. + // Otherwise pad the partial block with silence (or generate a full silence block if empty) and + // send. Always emits exactly one block on success. + if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { + const bool was_empty = (this->spdif_block_ptr_ == this->spdif_block_buf_.get()); + // Continuous-silence idle case: a full silence block is byte-identical every time for the + // active channel status, so when the buffer already holds one, re-send it as-is. + if (was_empty && this->block_buf_is_silence_block_) { + return this->send_block_(ticks_to_wait); } - - // Encode one 16-bit sample - this->encode_sample_(pcm_data); - pcm_data += 2; - } - - // Send any complete block that was just finished - if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { - esp_err_t err = this->send_block_(ticks_to_wait); - if (err != ESP_OK) { - if (blocks_sent != nullptr) { - *blocks_sent = block_count; - } - if (bytes_consumed != nullptr) { - *bytes_consumed = pcm_data - src; - } - return err; + // Pad with silence frames at the configured width. + while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { + this->encode_silence_frame_(); } - ++block_count; + // The buffer is a reusable full-silence block only if it was built entirely from silence; a + // partial real-audio block padded out with silence is not. + this->block_buf_is_silence_block_ = was_empty; } - - if (blocks_sent != nullptr) { - *blocks_sent = block_count; - } - if (bytes_consumed != nullptr) { - *bytes_consumed = size; - } - return ESP_OK; + return this->send_block_(ticks_to_wait); } esp_err_t SPDIFEncoder::flush_with_silence(TickType_t ticks_to_wait) { - // If a complete block is already pending (from a previous failed send), emit just that block. - // Otherwise pad the partial block with silence (or generate a full silence block if empty) - // and send. Always emits exactly one block on success. - if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { - static const uint8_t SILENCE[2] = {0, 0}; - while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { - this->encode_sample_(SILENCE); - } + switch (this->bytes_per_sample_) { + case 2: + return this->flush_with_silence_typed_<2>(ticks_to_wait); + case 3: + return this->flush_with_silence_typed_<3>(ticks_to_wait); + case 4: + return this->flush_with_silence_typed_<4>(ticks_to_wait); + default: + return ESP_ERR_INVALID_STATE; } - return this->send_block_(ticks_to_wait); } } // namespace esphome::i2s_audio diff --git a/esphome/components/i2s_audio/speaker/spdif_encoder.h b/esphome/components/i2s_audio/speaker/spdif_encoder.h index 8c5e068841..9e23a858f7 100644 --- a/esphome/components/i2s_audio/speaker/spdif_encoder.h +++ b/esphome/components/i2s_audio/speaker/spdif_encoder.h @@ -24,8 +24,6 @@ static constexpr uint16_t SPDIF_BLOCK_SIZE_BYTES = SPDIF_BLOCK_SAMPLES * (EMULAT static constexpr uint32_t SPDIF_BLOCK_SIZE_U32 = SPDIF_BLOCK_SIZE_BYTES / sizeof(uint32_t); // 3072 bytes / 4 = 768 // I2S frame count for one SPDIF block (for new driver where frame = 8 bytes for 32-bit stereo) static constexpr uint32_t SPDIF_BLOCK_I2S_FRAMES = SPDIF_BLOCK_SIZE_BYTES / 8; // 3072 / 8 = 384 frames -// PCM bytes needed for one complete SPDIF block (192 stereo frames * 2 bytes per sample * 2 channels) -static constexpr uint16_t SPDIF_PCM_BYTES_PER_BLOCK = SPDIF_BLOCK_SAMPLES * 2 * 2; // = 768 bytes /// Callback signature for block completion (raw function pointer for minimal overhead) /// @param user_ctx User context pointer passed during callback registration @@ -64,8 +62,16 @@ class SPDIFEncoder { /// @brief Check if currently in preload mode bool is_preload_mode() const { return this->preload_mode_; } + /// @brief Set input PCM width: 2 = 16-bit, 3 = 24-bit, 4 = 32-bit (truncated to 24-bit on the wire). + /// Must be called before write() if input width changes from the default (16-bit). Triggers a + /// channel-status rebuild to reflect the new word length. + void set_bytes_per_sample(uint8_t bytes_per_sample); + + /// @brief Get the configured input PCM width in bytes per sample + uint8_t get_bytes_per_sample() const { return this->bytes_per_sample_; } + /// @brief Convert PCM audio data to SPDIF BMC encoded data - /// @param src Source PCM audio data (16-bit stereo) + /// @param src Source PCM audio data (stereo, width matches set_bytes_per_sample) /// @param size Size of source data in bytes /// @param ticks_to_wait Timeout for blocking writes /// @param blocks_sent Optional pointer to receive the number of complete SPDIF blocks sent @@ -74,17 +80,6 @@ class SPDIFEncoder { esp_err_t write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent = nullptr, size_t *bytes_consumed = nullptr); - /// @brief Get the number of PCM bytes currently pending in the partial block buffer - /// @return Number of pending PCM bytes (0 to SPDIF_PCM_BYTES_PER_BLOCK - 1) - size_t get_pending_pcm_bytes() const; - - /// @brief Get the number of PCM frames currently pending in the partial block buffer - /// @return Number of pending PCM frames (0 to SPDIF_BLOCK_SAMPLES - 1) - uint32_t get_pending_frames() const { return this->get_pending_pcm_bytes() / 4; } - - /// @brief Check if there is a partial block pending - bool has_pending_data() const { return this->spdif_block_ptr_ != this->spdif_block_buf_.get(); } - /// @brief Emit one complete SPDIF block: pad any pending partial block with silence and send, /// or send a full silence block if nothing is pending. Always produces exactly one block on success. /// @param ticks_to_wait Timeout for blocking writes @@ -95,7 +90,7 @@ class SPDIFEncoder { void reset(); /// @brief Set the sample rate for Channel Status Block encoding - /// @param sample_rate Sample rate in Hz (e.g., 44100, 48000, 96000) + /// @param sample_rate Sample rate in Hz (e.g., 44100, 48000) /// Call this before writing audio data to ensure correct channel status. void set_sample_rate(uint32_t sample_rate); @@ -103,8 +98,19 @@ class SPDIFEncoder { uint32_t get_sample_rate() const { return this->sample_rate_; } protected: - /// @brief Encode a single 16-bit PCM sample into the current block position - HOT void encode_sample_(const uint8_t *pcm_sample); + /// @brief Encode a single stereo silence frame at the current block position. + /// @note Used only by flush_with_silence_typed_ to pad; the hot write path inlines the + /// encoding body directly into write_typed_ to keep block_ptr / frame_in_block_ in registers. + template void encode_silence_frame_(); + + /// @brief Templated write loop. Called from the public write() via runtime dispatch on bytes_per_sample_. + template + HOT esp_err_t write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent, + size_t *bytes_consumed); + + /// @brief Templated flush-with-silence. Pads the pending block with zeros at the configured width + /// (or builds a full silence block when nothing is pending) and sends it. Always emits one block. + template esp_err_t flush_with_silence_typed_(TickType_t ticks_to_wait); /// @brief Send the completed block via the appropriate callback esp_err_t send_block_(TickType_t ticks_to_wait); @@ -112,15 +118,6 @@ class SPDIFEncoder { /// @brief Build the channel status block from current configuration void build_channel_status_(); - /// @brief Get the channel status bit for a specific frame - /// @param frame Frame number (0-191) - /// @return The C bit value for this frame - ESPHOME_ALWAYS_INLINE inline bool get_channel_status_bit_(uint8_t frame) const { - // Channel status is 192 bits transmitted over 192 frames - // Bit N is transmitted in frame N, LSB-first within each byte - return (this->channel_status_[frame >> 3] >> (frame & 7)) & 1; - } - // Member ordering optimized to minimize padding (largest alignment first) // 4-byte aligned members (pointers and uint32_t) @@ -133,9 +130,13 @@ class SPDIFEncoder { uint32_t sample_rate_{48000}; // Sample rate for Channel Status Block encoding // 1-byte aligned members (grouped together to avoid internal padding) - uint8_t frame_in_block_{0}; // 0-191, tracks stereo frame position within block - bool is_left_channel_{true}; // Alternates L/R for stereo samples - bool preload_mode_{false}; // Whether to use preload callback vs write callback + uint8_t bytes_per_sample_{2}; // Input PCM width: 2/3/4 (16/24/32-bit). 32-bit truncates to 24-bit on the wire. + uint8_t frame_in_block_{0}; // 0-191, tracks stereo frame position within block + bool preload_mode_{false}; // Whether to use preload callback vs write callback + // True when spdif_block_buf_ currently holds a complete full-silence block valid for the active + // channel status. A full silence block is deterministic for a given sample rate and word length, + // so when this is set flush_with_silence() can re-send the buffer verbatim instead of re-encoding. + bool block_buf_is_silence_block_{false}; // Channel Status Block (192 bits = 24 bytes, transmitted over 192 frames) // Placed last since std::array has 1-byte alignment diff --git a/tests/components/speaker/spdif_mode.esp32-idf.yaml b/tests/components/i2s_audio/common-spdif_mode.yaml similarity index 52% rename from tests/components/speaker/spdif_mode.esp32-idf.yaml rename to tests/components/i2s_audio/common-spdif_mode.yaml index 4d6859feae..374a4bce1e 100644 --- a/tests/components/speaker/spdif_mode.esp32-idf.yaml +++ b/tests/components/i2s_audio/common-spdif_mode.yaml @@ -1,13 +1,3 @@ -substitutions: - i2s_bclk_pin: GPIO27 - i2s_lrclk_pin: GPIO26 - i2s_mclk_pin: GPIO25 - i2s_dout_pin: GPIO12 - spdif_data_pin: GPIO4 - -packages: - i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - i2s_audio: - id: i2s_output @@ -20,6 +10,5 @@ speaker: use_apll: true timeout: 2s sample_rate: 48000 - bits_per_sample: 16bit channel: stereo i2s_mode: primary diff --git a/tests/components/i2s_audio/test-spdif_speaker.esp32-idf.yaml b/tests/components/i2s_audio/test-spdif_speaker.esp32-idf.yaml new file mode 100644 index 0000000000..a69d808d1d --- /dev/null +++ b/tests/components/i2s_audio/test-spdif_speaker.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + i2s_bclk_pin: GPIO27 + i2s_lrclk_pin: GPIO26 + i2s_mclk_pin: GPIO25 + i2s_dout_pin: GPIO12 + spdif_data_pin: GPIO4 + +<<: !include common-spdif_mode.yaml From 09121226344c26f530dee36e78041616ac8d381f Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 19 May 2026 15:16:00 -0400 Subject: [PATCH 0100/1815] [sendspin] Bump sendspin to v0.6.0 (#16496) --- esphome/components/sendspin/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 35280020ba..36f13f7d07 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -206,7 +206,7 @@ async def to_code(config: ConfigType) -> None: ) # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.5.0") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.0") cg.add_define("USE_SENDSPIN", True) # for MDNS diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 35c55cbb4d..42d0d5de6b 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -100,6 +100,6 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.5.0 + version: 0.6.0 lvgl/lvgl: version: 9.5.0 From 9bb70d568da7fc65059f806fc74c81c4639cc3ce Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 18 May 2026 15:56:44 +1200 Subject: [PATCH 0101/1815] [ci] Move ha-addon and schema release triggers to version-notifier (#16490) --- .github/workflows/release.yml | 70 +---------------------------------- 1 file changed, 1 insertion(+), 69 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c1086c858c..9799f882db 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -212,74 +212,6 @@ jobs: docker buildx imagetools create $(jq -Rcnr 'inputs | . / "," | map("-t " + .) | join(" ")' <<< "${{ steps.tags.outputs.tags}}") \ $(printf '${{ steps.tags.outputs.image }}@sha256:%s ' *) - deploy-ha-addon-repo: - if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false' - runs-on: ubuntu-latest - needs: - - init - - deploy-manifest - steps: - - name: Generate a token - id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} - private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} - owner: esphome - repositories: home-assistant-addon - permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token) - - - name: Trigger Workflow - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - github-token: ${{ steps.generate-token.outputs.token }} - script: | - let description = "ESPHome"; - if (context.eventName == "release") { - description = ${{ toJSON(github.event.release.body) }}; - } - github.rest.actions.createWorkflowDispatch({ - owner: "esphome", - repo: "home-assistant-addon", - workflow_id: "bump-version.yml", - ref: "main", - inputs: { - version: "${{ needs.init.outputs.tag }}", - content: description - } - }) - - deploy-esphome-schema: - if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false' - runs-on: ubuntu-latest - needs: [init] - environment: ${{ needs.init.outputs.deploy_env }} - steps: - - name: Generate a token - id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} - private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} - owner: esphome - repositories: esphome-schema - permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token) - - - name: Trigger Workflow - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - github-token: ${{ steps.generate-token.outputs.token }} - script: | - github.rest.actions.createWorkflowDispatch({ - owner: "esphome", - repo: "esphome-schema", - workflow_id: "generate-schemas.yml", - ref: "main", - inputs: { - version: "${{ needs.init.outputs.tag }}", - } - }) - version-notifier: if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false' runs-on: ubuntu-latest @@ -302,7 +234,7 @@ jobs: with: github-token: ${{ steps.generate-token.outputs.token }} script: | - github.rest.actions.createWorkflowDispatch({ + await github.rest.actions.createWorkflowDispatch({ owner: "esphome", repo: "version-notifier", workflow_id: "notify.yml", From e1793a1eff10f14488462e53d848c5d27b9c942f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 17 May 2026 23:29:03 -0700 Subject: [PATCH 0102/1815] Bump zeroconf from 0.149.3 to 0.149.7 (#16492) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 63a25c8e36..3c66db489a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==45.0.3 -zeroconf==0.149.3 +zeroconf==0.149.7 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From e9ef58d99d50f258f6c0d2a50f713af1ba198426 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Mon, 18 May 2026 18:19:48 -0500 Subject: [PATCH 0103/1815] [sen5x] Remove incorrect AQI device class from VOC and NOx Index sensors (#16463) --- esphome/components/sen5x/sensor.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index ce35cf5bf1..480654ee1b 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -25,7 +25,6 @@ from esphome.const import ( CONF_TEMPERATURE_COMPENSATION, CONF_TIME_CONSTANT, CONF_VOC, - DEVICE_CLASS_AQI, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PM1, DEVICE_CLASS_PM10, @@ -77,7 +76,6 @@ def _gas_sensor( return sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, - device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, ).extend( { From bbf5fe84501f6a18fbacfa8c456e9a071102c468 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Mon, 18 May 2026 18:19:51 -0500 Subject: [PATCH 0104/1815] [sgp4x] Remove incorrect AQI device class from VOC and NOx Index sensors (#16464) --- esphome/components/sgp4x/sensor.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/components/sgp4x/sensor.py b/esphome/components/sgp4x/sensor.py index 1e58a0f26a..d407f20a4e 100644 --- a/esphome/components/sgp4x/sensor.py +++ b/esphome/components/sgp4x/sensor.py @@ -15,7 +15,6 @@ from esphome.const import ( CONF_STORE_BASELINE, CONF_TEMPERATURE_SOURCE, CONF_VOC, - DEVICE_CLASS_AQI, ICON_RADIATOR, STATE_CLASS_MEASUREMENT, ) @@ -72,13 +71,11 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_VOC): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, - device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, ).extend(VOC_SENSOR), cv.Optional(CONF_NOX): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, - device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, ).extend(NOX_SENSOR), cv.Optional(CONF_STORE_BASELINE, default=True): cv.boolean, From 25739091da33cfd797bc85fa440aa1550a998cc1 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Mon, 18 May 2026 18:20:08 -0500 Subject: [PATCH 0105/1815] [sen6x] Remove incorrect AQI device class from VOC and NOx Index sensors (#16465) --- esphome/components/sen6x/sensor.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index 071478e719..19c0cb500e 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -14,7 +14,6 @@ from esphome.const import ( CONF_TEMPERATURE, CONF_TYPE, CONF_VOC, - DEVICE_CLASS_AQI, DEVICE_CLASS_CARBON_DIOXIDE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PM1, @@ -93,13 +92,11 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_VOC): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, - device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_NOX): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, - device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_CO2): sensor.sensor_schema( From 41ad2ba76380db37c72b915d47bd7029ed8cad98 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 18 May 2026 22:53:19 -0400 Subject: [PATCH 0106/1815] [i2s_audio] Compute ring buffer size with SPDIF sample count (#16400) --- .../i2s_audio/speaker/i2s_audio_spdif.cpp | 21 ++++++++++--------- .../i2s_audio/speaker/i2s_audio_speaker.h | 1 - .../speaker/i2s_audio_speaker_standard.cpp | 1 + 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp index 8f67562a77..877f67775b 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -138,21 +138,21 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { // Reset lockstep records queue so it starts paired with the (also-reset) i2s_event_queue_. xQueueReset(this->write_records_queue_); - const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * SPDIF_DMA_BUFFERS_COUNT; - // Ensure ring buffer duration is at least the duration of all DMA buffers - const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_); - // The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1); - // Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and - // avoids unnecessary single-frame splices. - const size_t ring_buffer_size = - (this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame; - // For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames + // For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames (~4 ms at 48 kHz), + // not the ~15 ms a standard I2S DMA buffer holds. Derive the DMA floor from actual block size. const uint32_t frames_to_fill_single_dma_buffer = SPDIF_BLOCK_SAMPLES; const size_t bytes_to_fill_single_dma_buffer = this->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer); + const size_t dma_buffers_floor_bytes = bytes_to_fill_single_dma_buffer * SPDIF_DMA_BUFFERS_COUNT; + + // Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and + // avoids unnecessary single-frame splices. Ensure it is at least large enough to cover all DMA buffers. + const size_t requested_ring_buffer_bytes = + (this->current_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame; + const size_t ring_buffer_size = std::max(dma_buffers_floor_bytes, requested_ring_buffer_bytes); bool successful_setup = false; std::unique_ptr audio_source; @@ -177,7 +177,8 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { // on_sent events drain in lockstep without crediting any audio frames. this->spdif_encoder_->set_preload_mode(true); for (size_t i = 0; i < SPDIF_DMA_BUFFERS_COUNT; i++) { - esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS)); + // i2s_channel_preload_data is non-blocking (returns immediately when the preload buffer fills), so no wait. + esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(0); if (preload_err != ESP_OK) { break; // DMA preload buffer full or error } diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index 20bb05e322..34792bdbea 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -19,7 +19,6 @@ namespace esphome::i2s_audio { // Shared constants used by both standard and SPDIF speaker implementations -static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15; static constexpr size_t TASK_STACK_SIZE = 4096; static constexpr ssize_t TASK_PRIORITY = 19; diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index e69601e87a..ffe901504d 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -16,6 +16,7 @@ namespace esphome::i2s_audio { static const char *const TAG = "i2s_audio.speaker.std"; +static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15; static constexpr size_t DMA_BUFFERS_COUNT = 4; // Sized to comfortably absorb scheduling jitter: at most DMA_BUFFERS_COUNT events can be in flight, // doubled so that a transient backlog never overruns the queue (which would desync the lockstep From 43cc9fc879045fa20f41ad0231dd5cc1513deb3e Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Tue, 19 May 2026 18:53:36 +0200 Subject: [PATCH 0107/1815] [zigbee] don't allow zigbee + thread or access point (#16499) --- esphome/components/zigbee/__init__.py | 2 ++ esphome/components/zigbee/zigbee_esp32.py | 10 +++------- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 69e3fe9c5a..c75b0773d2 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -50,6 +50,8 @@ _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@luar123", "@tomaszduda23"] +CONFLICTS_WITH = ["openthread"] + BASE_SCHEMA = cv.Schema( { cv.Optional(CONF_REPORT): cv.All( diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index e446377a06..89efd583ab 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -117,15 +117,11 @@ def final_validate_esp32(config: ConfigType) -> ConfigType: if not CORE.is_esp32: return config if CONF_WIFI in fv.full_config.get(): - if config[CONF_ROUTER] and CONF_AP in fv.full_config.get()[CONF_WIFI]: - raise cv.Invalid( - "Only Zigbee End Device can be used together with a Wifi Access Point." - ) if CONF_AP in fv.full_config.get()[CONF_WIFI]: - _LOGGER.warning( - "Wifi Access Point might be unstable while Zigbee is active, use only as fallback." + raise cv.Invalid( + "A Wifi Access Point can not be used together with Zigbee." ) - elif config[CONF_ROUTER]: + if config[CONF_ROUTER]: _LOGGER.warning( "The Zigbee Router might miss packets while Wifi is active and could destabilize " "your network. Use only if Wifi is off most of the time." From 65e1e210de9150e31bfa1f30a488f68e3ff92a31 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 19 May 2026 12:57:18 -0400 Subject: [PATCH 0108/1815] [espidf] Print RAM summary on ESP32-S3 / unified-DIRAM variants (#16494) --- esphome/espidf/size_summary.py | 7 +- tests/unit_tests/test_size_summary.py | 128 ++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 tests/unit_tests/test_size_summary.py diff --git a/esphome/espidf/size_summary.py b/esphome/espidf/size_summary.py index 9477e664b3..3ba0bf3b4d 100644 --- a/esphome/espidf/size_summary.py +++ b/esphome/espidf/size_summary.py @@ -94,9 +94,10 @@ def print_summary(size_json: Path, partitions_csv: Path | None) -> None: _LOGGER.debug("Skipping size summary: %s", e) return - dram = data.get("memory_types", {}).get("DRAM") or {} - ram_used = dram.get("used") - ram_total = dram.get("size") + memory_types = data.get("memory_types", {}) + ram_region = memory_types.get("DRAM") or memory_types.get("DIRAM") or {} + ram_used = ram_region.get("used") + ram_total = ram_region.get("size") if ram_total and ram_used is not None: print(f"RAM: {_format_bar(ram_used, ram_total)}") diff --git a/tests/unit_tests/test_size_summary.py b/tests/unit_tests/test_size_summary.py new file mode 100644 index 0000000000..933be88476 --- /dev/null +++ b/tests/unit_tests/test_size_summary.py @@ -0,0 +1,128 @@ +"""Tests for esphome.espidf.size_summary.print_summary.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from esphome.espidf.size_summary import print_summary + + +def _write_size_json(tmp_path: Path, data: dict) -> Path: + """Drop a fake esp_idf_size.json under ``tmp_path`` and return the path.""" + out = tmp_path / "esp_idf_size.json" + out.write_text(json.dumps(data)) + return out + + +def _esp32_size_data() -> dict: + """Synthetic esp_idf_size.json for the original ESP32 (split IRAM/DRAM).""" + return { + "image_size": 827455, + "memory_types": { + "DRAM": { + "size": 180736, + "used": 47332, + "sections": { + ".dram0.bss": {"abbrev_name": ".bss", "size": 30616}, + ".dram0.data": {"abbrev_name": ".data", "size": 16716}, + }, + }, + "IRAM": { + "size": 131072, + "used": 80351, + "sections": { + ".iram0.text": {"abbrev_name": ".text", "size": 79323}, + ".iram0.vectors": {"abbrev_name": ".vectors", "size": 1028}, + }, + }, + }, + } + + +def _s3_size_data() -> dict: + """Synthetic esp_idf_size.json for ESP32-S3 (unified DIRAM).""" + return { + "image_size": 724215, + "memory_types": { + "DIRAM": { + "size": 341760, + "used": 104999, + "sections": { + ".iram0.text": {"abbrev_name": ".text", "size": 58051}, + ".dram0.bss": {"abbrev_name": ".bss", "size": 27088}, + ".dram0.data": {"abbrev_name": ".data", "size": 19708}, + ".noinit": {"abbrev_name": ".noinit", "size": 152}, + }, + }, + "IRAM": { + "size": 16384, + "used": 16384, + "sections": { + ".iram0.text": {"abbrev_name": ".text", "size": 15356}, + ".iram0.vectors": {"abbrev_name": ".vectors", "size": 1028}, + }, + }, + }, + } + + +def test_print_summary_esp32_uses_dram( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Original ESP32: DRAM has no ``.text``, so RAM = DRAM.used / DRAM.size unchanged.""" + size_json = _write_size_json(tmp_path, _esp32_size_data()) + print_summary(size_json, partitions_csv=None) + out = capsys.readouterr().out + assert "RAM:" in out + assert "used 47332 bytes from 180736 bytes" in out + + +def test_print_summary_s3_falls_back_to_diram( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """ESP32-S3 with no DRAM key falls back to DIRAM and reports raw region usage.""" + size_json = _write_size_json(tmp_path, _s3_size_data()) + print_summary(size_json, partitions_csv=None) + out = capsys.readouterr().out + assert "used 104999 bytes from 341760 bytes" in out + + +def test_print_summary_skips_when_diram_total_collapses( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A zero-size region drops the RAM line rather than divide by zero.""" + size_json = _write_size_json( + tmp_path, + { + "memory_types": { + "DIRAM": { + "size": 0, + "used": 0, + "sections": {}, + }, + }, + }, + ) + print_summary(size_json, partitions_csv=None) + out = capsys.readouterr().out + assert "RAM:" not in out + + +def test_print_summary_handles_missing_json( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Missing size json is non-fatal and prints nothing.""" + print_summary(tmp_path / "does_not_exist.json", partitions_csv=None) + assert capsys.readouterr().out == "" + + +def test_print_summary_handles_no_memory_types( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A size json without ``memory_types`` still doesn't crash.""" + size_json = _write_size_json(tmp_path, {"image_size": 0}) + print_summary(size_json, partitions_csv=None) + assert capsys.readouterr().out == "" From 302938f87507a3dc317829be93d11df353c516e2 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 19 May 2026 14:37:41 -0400 Subject: [PATCH 0109/1815] [i2s_audio] Optimize SPDIF encoder and suport higher bit depth audio (#16504) Co-authored-by: Keith Burzinski --- .../components/i2s_audio/speaker/__init__.py | 7 +- .../i2s_audio/speaker/i2s_audio_spdif.cpp | 12 +- .../i2s_audio/speaker/spdif_encoder.cpp | 537 +++++++++++------- .../i2s_audio/speaker/spdif_encoder.h | 59 +- .../common-spdif_mode.yaml} | 11 - .../test-spdif_speaker.esp32-idf.yaml | 8 + 6 files changed, 372 insertions(+), 262 deletions(-) rename tests/components/{speaker/spdif_mode.esp32-idf.yaml => i2s_audio/common-spdif_mode.yaml} (52%) create mode 100644 tests/components/i2s_audio/test-spdif_speaker.esp32-idf.yaml diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index 759cc40ca9..8215d8b518 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -89,10 +89,10 @@ def _set_num_channels_from_config(config): def _set_stream_limits(config): if config.get(CONF_SPDIF_MODE, False): - # SPDIF mode: fixed to 16-bit stereo at configured sample rate + # SPDIF mode: 16/24/32-bit audio and stereo at configured sample rate audio.set_stream_limits( min_bits_per_sample=16, - max_bits_per_sample=16, + max_bits_per_sample=32, min_channels=2, max_channels=2, min_sample_rate=config.get(CONF_SAMPLE_RATE), @@ -213,9 +213,6 @@ def _final_validate(config): ) if config[CONF_CHANNEL] != CONF_STEREO: raise cv.Invalid("SPDIF mode only supports stereo channel configuration") - # bits_per_sample is converted to float by the schema - if config[CONF_BITS_PER_SAMPLE] != 16: - raise cv.Invalid("SPDIF mode only supports 16 bits per sample") if not config[CONF_USE_APLL]: raise cv.Invalid( "SPDIF mode requires 'use_apll: true' for accurate clock generation" diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp index 877f67775b..989bcf2977 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -411,8 +411,9 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s this->sample_rate_, audio_stream_info.get_sample_rate()); return ESP_ERR_NOT_SUPPORTED; } - if (audio_stream_info.get_bits_per_sample() != 16) { - ESP_LOGE(TAG, "Only supports 16 bits per sample"); + const uint8_t bits_per_sample = audio_stream_info.get_bits_per_sample(); + if (bits_per_sample != 16 && bits_per_sample != 24 && bits_per_sample != 32) { + ESP_LOGE(TAG, "Only supports 16, 24, or 32 bits per sample (got %u)", (unsigned) bits_per_sample); return ESP_ERR_NOT_SUPPORTED; } if (audio_stream_info.get_channels() != 2) { @@ -420,11 +421,8 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s return ESP_ERR_NOT_SUPPORTED; } - if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO && - (i2s_slot_bit_width_t) audio_stream_info.get_bits_per_sample() > this->slot_bit_width_) { - ESP_LOGE(TAG, "Stream bits per sample must be less than or equal to the speaker's configuration"); - return ESP_ERR_NOT_SUPPORTED; - } + // Tell the encoder what input width to expect. 32-bit input is truncated to 24-bit on the wire. + this->spdif_encoder_->set_bytes_per_sample(bits_per_sample / 8); if (!this->parent_->try_lock()) { ESP_LOGE(TAG, "Parent bus is busy"); diff --git a/esphome/components/i2s_audio/speaker/spdif_encoder.cpp b/esphome/components/i2s_audio/speaker/spdif_encoder.cpp index 42a72346cc..30146e0a70 100644 --- a/esphome/components/i2s_audio/speaker/spdif_encoder.cpp +++ b/esphome/components/i2s_audio/speaker/spdif_encoder.cpp @@ -17,7 +17,7 @@ static constexpr uint8_t PREAMBLE_M = 0x1d; // Left channel (not block start) static constexpr uint8_t PREAMBLE_W = 0x1b; // Right channel // BMC encoding of 4 zero bits starting at phase HIGH: 00_11_00_11 = 0x33 -// Since both aux nibbles (bits 4-7, 8-11) are zero for 16-bit audio and phase is preserved, both are 0x33. +// Used as a constant in the 16-bit subframe path, where bits 4-11 are always zero. static constexpr uint32_t BMC_ZERO_NIBBLE = 0x33; // Constexpr BMC encoder for compile-time LUT generation. @@ -36,21 +36,43 @@ static constexpr uint16_t bmc_lut_encode(uint32_t data, uint8_t num_bits) { return bmc; } -// 4-bit BMC lookup table: 16 entries (16 bytes in flash) -// Index: 4-bit data value (0-15), always phase=true start +// Compile-time parity helper (constexpr-friendly, runs only at LUT build time). +static constexpr uint32_t bmc_lut_parity(uint32_t value, uint32_t num_bits) { + uint32_t p = 0; + for (uint32_t b = 0; b < num_bits; b++) + p ^= (value >> b) & 1u; + return p; +} + +// Combined BMC + phase-delta lookup tables. +// Each entry packs the BMC pattern (lower bits, phase=high start) together with +// a phase-mask delta in bits 16-31 (0xFFFF if the input has odd parity, else 0). +// XORing the delta into the running phase mask propagates parity across chunks +// without an explicit popcount. + +// 4-bit BMC lookup table: 16 entries x uint32_t = 64 bytes in flash. +// Bits 0-7 : 8-bit BMC pattern (phase=high start) +// Bits 16-31 : phase-mask delta (0xFFFFu if odd parity, else 0) static constexpr auto BMC_LUT_4 = [] { - std::array t{}; - for (uint32_t i = 0; i < 16; i++) - t[i] = static_cast(bmc_lut_encode(i, 4)); + std::array t{}; + for (uint32_t i = 0; i < 16; i++) { + uint32_t bmc = bmc_lut_encode(i, 4); + uint32_t delta = bmc_lut_parity(i, 4) ? 0xFFFF0000u : 0u; + t[i] = bmc | delta; + } return t; }(); -// 8-bit BMC lookup table: 256 entries (512 bytes in flash) -// Index: 8-bit data value (0-255), always phase=true start +// 8-bit BMC lookup table: 256 entries x uint32_t = 1024 bytes in flash. +// Bits 0-15 : 16-bit BMC pattern (phase=high start) +// Bits 16-31 : phase-mask delta (0xFFFFu if odd parity, else 0) static constexpr auto BMC_LUT_8 = [] { - std::array t{}; - for (uint32_t i = 0; i < 256; i++) - t[i] = bmc_lut_encode(i, 8); + std::array t{}; + for (uint32_t i = 0; i < 256; i++) { + uint32_t bmc = bmc_lut_encode(i, 8); + uint32_t delta = bmc_lut_parity(i, 8) ? 0xFFFF0000u : 0u; + t[i] = bmc | delta; + } return t; }(); @@ -63,7 +85,7 @@ bool SPDIFEncoder::setup() { } ESP_LOGV(TAG, "Buffer allocated (%zu bytes)", SPDIF_BLOCK_SIZE_BYTES); - // Build initial channel status block with default sample rate + // Build initial channel status block with default sample rate and width this->build_channel_status_(); this->reset(); @@ -73,7 +95,7 @@ bool SPDIFEncoder::setup() { void SPDIFEncoder::reset() { this->spdif_block_ptr_ = this->spdif_block_buf_.get(); this->frame_in_block_ = 0; - this->is_left_channel_ = true; + this->block_buf_is_silence_block_ = false; } void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) { @@ -84,31 +106,27 @@ void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) { } } +void SPDIFEncoder::set_bytes_per_sample(uint8_t bytes_per_sample) { + if (bytes_per_sample != 2 && bytes_per_sample != 3 && bytes_per_sample != 4) { + ESP_LOGE(TAG, "Unsupported bytes per sample: %u", (unsigned) bytes_per_sample); + return; + } + if (this->bytes_per_sample_ != bytes_per_sample) { + this->bytes_per_sample_ = bytes_per_sample; + this->build_channel_status_(); + // Discard any partial block built at the previous width so we never mix widths on the wire. + this->reset(); + ESP_LOGD(TAG, "Input width set to %u-bit", (unsigned) bytes_per_sample * 8); + } +} + void SPDIFEncoder::build_channel_status_() { // IEC 60958-3 Consumer Channel Status Block (192 bits = 24 bytes) - // Transmitted LSB-first within each byte, one bit per frame via C bit - // - // Byte 0: Control bits - // Bit 0: 0 = Consumer format (not professional AES3) - // Bit 1: 0 = PCM audio (not non-audio data like AC3) - // Bit 2: 0 = No copyright assertion - // Bits 3-5: 000 = No pre-emphasis - // Bits 6-7: 00 = Mode 0 (basic consumer format) - // - // Byte 1: Category code (0x00 = general, 0x01 = CD, etc.) - // - // Byte 2: Source/channel numbers - // Bits 0-3: Source number (0 = unspecified) - // Bits 4-7: Channel number (0 = unspecified) - // - // Byte 3: Sample frequency and clock accuracy - // Bits 0-3: Sample frequency code - // Bits 4-5: Clock accuracy (00 = Level II, ±1000 ppm, appropriate for ESP32) - // Bits 6-7: Reserved (0) - // - // Bytes 4-23: Reserved (zeros for basic compliance) + // Transmitted LSB-first within each byte, one bit per frame via C bit. + + // Any cached silence block was built for the previous channel status; it is now stale. + this->block_buf_is_silence_block_ = false; - // Clear all bytes first this->channel_status_.fill(0); // Byte 0: Consumer, PCM audio, no copyright, no pre-emphasis, Mode 0 @@ -140,132 +158,148 @@ void SPDIFEncoder::build_channel_status_() { // Byte 3: freq_code in bits 0-3, clock accuracy (00) in bits 4-5 this->channel_status_[3] = freq_code; // Clock accuracy bits 4-5 are already 0 - // Bytes 4-23 remain zero (word length not specified, no original sample freq, etc.) + // Byte 4: Word length encoding (IEC 60958-3 consumer) + // bit 0: max length flag (0 = max 20 bits, 1 = max 24 bits) + // bits 1-3: word length code relative to the max + // For our supported widths: + // 16-bit (max 20): 0b0010 = 0x02 -- "16 bits, max 20" + // 24-bit (max 24): 0b1101 = 0x0D -- "24 bits, max 24" + // 32-bit input is truncated to 24-bit on the wire, so use the 24-bit code. + uint8_t word_length_code; + switch (this->bytes_per_sample_) { + case 2: + word_length_code = 0x02; + break; + case 3: // Shared case + case 4: + word_length_code = 0x0D; + break; + default: + word_length_code = 0x00; // not specified + break; + } + this->channel_status_[4] = word_length_code; } -HOT void SPDIFEncoder::encode_sample_(const uint8_t *pcm_sample) { - // ============================================================================ - // Build raw 32-bit subframe (IEC 60958 format) - // ============================================================================ - // Bit layout: - // Bits 0-3: Preamble (handled separately, not in raw_subframe) - // Bits 4-7: Auxiliary audio data (zeros for 16-bit audio) - // Bits 8-11: Audio LSB extension (zeros for 16-bit audio) - // Bits 12-27: 16-bit audio sample (MSB-aligned in 20-bit audio field) - // Bit 28: V (Validity) - 0 = valid audio - // Bit 29: U (User data) - 0 - // Bit 30: C (Channel status) - from channel status block - // Bit 31: P (Parity) - even parity over bits 4-31 - // ============================================================================ +// Extract the C bit for the given frame from channel_status_ and shift it into bit 30 +// so it can be OR'd directly into a raw subframe. +ESPHOME_ALWAYS_INLINE static inline uint32_t c_bit_for_frame(const std::array &channel_status, + uint32_t frame) { + return static_cast((channel_status[frame >> 3] >> (frame & 7)) & 1u) << 30; +} - // Place 16-bit audio sample at bits 12-27 (little-endian input: [0]=LSB, [1]=MSB) - uint32_t raw_subframe = (static_cast(pcm_sample[1]) << 20) | (static_cast(pcm_sample[0]) << 12); +// ============================================================================ +// IEC 60958 subframe bit layout +// ============================================================================ +// Bits 0-3: Preamble (handled separately, not in raw_subframe) +// Bits 4-7: Auxiliary audio data / 24-bit audio LSB +// Bits 8-11: Audio LSB extension (zero for 16-bit, low nibble of audio for 24-bit) +// Bits 12-27: Audio sample (16 high bits in 16-bit mode, mid 16 bits in 24-bit mode) +// Bit 28: V (Validity) - 0 = valid audio +// Bit 29: U (User data) - 0 +// Bit 30: C (Channel status) - from channel status block +// Bit 31: P (Parity) - even parity over bits 4-31 +// ============================================================================ - // V = 0 (valid audio), U = 0 (no user data) - // C = channel status bit for current frame (same bit used for both L and R subframes) - bool c_bit = this->get_channel_status_bit_(this->frame_in_block_); - if (c_bit) { - raw_subframe |= (1U << 30); +// Build a raw IEC 60958 subframe from PCM little-endian input of width Bps bytes. +// Caller is responsible for OR-ing in the C bit and parity. +template ESPHOME_ALWAYS_INLINE static inline uint32_t build_raw_subframe(const uint8_t *pcm_sample) { + static_assert(Bps == 2 || Bps == 3 || Bps == 4, "Unsupported bytes per sample"); + if constexpr (Bps == 2) { + // 16-bit input: MSB-aligned in the 20-bit audio field, bits 12-27. + return (static_cast(pcm_sample[1]) << 20) | (static_cast(pcm_sample[0]) << 12); + } else if constexpr (Bps == 3) { + // 24-bit input: full 24-bit audio field, bits 4-27. + return (static_cast(pcm_sample[2]) << 20) | (static_cast(pcm_sample[1]) << 12) | + (static_cast(pcm_sample[0]) << 4); + } else { // Bps == 4 + // 32-bit input truncated to 24-bit: drop the lowest byte. + return (static_cast(pcm_sample[3]) << 20) | (static_cast(pcm_sample[2]) << 12) | + (static_cast(pcm_sample[1]) << 4); } +} - // Calculate even parity over bits 4-30 - // This ensures consistent BMC ending phase regardless of audio content - uint32_t bits_4_30 = (raw_subframe >> 4) & 0x07FFFFFF; // 27 bits (4-30) - uint32_t ones_count = __builtin_popcount(bits_4_30); - uint32_t parity = ones_count & 1; // 1 if odd count, 0 if even - raw_subframe |= parity << 31; // Set P bit to make total even +// BMC-encode a subframe and write the two output uint32 words to dst. Caller passes +// raw_subframe with the C bit set (bit 30) and the P bit cleared (bit 31 = 0). P is +// derived from the cumulative parity-mask delta of the per-byte LUT lookups. +// +// I2S halfword swap means word[0] transmits as: bits 24-31, 16-23, 8-15, 0-7. +// word[1] transmits as: bits 16-31, 0-15. Within each halfword, MSB-first. +// All preambles end at phase HIGH, so phase=true at the start of bit 4. +// +// P-bit derivation: BMC_LUT_*'s upper half encodes the parity of the input chunk. Each +// chunk's parity delta is shifted down (`lut >> 16`) into a phase_mask that lives in the +// low 16 bits, so the same value can also be XORed against subsequent BMC patterns to +// invert phase. XOR'ing those deltas through all chunks (with bit 31 = 0) yields the +// parity of bits 4-30 in the low bits of phase_mask -- the required value of the P bit +// for even total parity. The BMC of bit 31 lives in bit 0 of the high-byte BMC output +// (i = 7 maps to position (8-1-7)*2 = 0); flipping the source bit flips only the lower +// BMC bit (= phase XOR bit), so applying P is `bmc_24_31 ^= phase_mask & 1u`. +template +ESPHOME_ALWAYS_INLINE static inline void bmc_encode_subframe(uint32_t raw_subframe, uint8_t preamble, uint32_t *dst) { + if constexpr (Bps == 2) { + // 16-bit path: bits 4-11 are zero, encoded inline as BMC_ZERO_NIBBLE constants. + // Eight zero source bits with start phase=HIGH end at phase=HIGH (popcount of zeros is even), + // so encoding of bits 12-15 starts at phase=true. Zeros contribute 0 to parity. + uint32_t nibble = (raw_subframe >> 12) & 0xF; + uint32_t lut_n = BMC_LUT_4[nibble]; + uint32_t bmc_12_15 = lut_n & 0xFFu; + uint32_t phase_mask = lut_n >> 16; // 0xFFFFu if odd parity, else 0 - // ============================================================================ - // Select preamble based on position in block and channel - // ============================================================================ - // B = block start (left channel, frame 0 of 192-frame block) - // M = left channel (frames 1-191) - // W = right channel (all frames) - uint8_t preamble; - if (this->is_left_channel_) { - preamble = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M; + uint32_t byte_mid = (raw_subframe >> 16) & 0xFF; + uint32_t lut_m = BMC_LUT_8[byte_mid]; + uint32_t bmc_16_23 = (lut_m & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_m >> 16; + + uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; // bit 7 (= P) is 0 by precondition + uint32_t lut_h = BMC_LUT_8[byte_hi]; + uint32_t bmc_24_31 = (lut_h & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_h >> 16; + // phase_mask now reflects parity of bits 4-30. Apply P by flipping bit 0 of bmc_24_31. + bmc_24_31 ^= phase_mask & 1u; + + dst[0] = bmc_12_15 | (BMC_ZERO_NIBBLE << 8) | (BMC_ZERO_NIBBLE << 16) | (static_cast(preamble) << 24); + dst[1] = bmc_24_31 | (bmc_16_23 << 16); } else { - preamble = PREAMBLE_W; + // 24-bit (and 32-bit truncated) path: bits 4-11 are live audio. + uint32_t byte_lo = (raw_subframe >> 4) & 0xFF; + uint32_t lut_l = BMC_LUT_8[byte_lo]; + uint32_t bmc_4_11 = lut_l & 0xFFFFu; + uint32_t phase_mask = lut_l >> 16; // 0xFFFFu if odd parity, else 0 + + uint32_t nibble = (raw_subframe >> 12) & 0xF; + uint32_t lut_n = BMC_LUT_4[nibble]; + uint32_t bmc_12_15 = (lut_n & 0xFFu) ^ (phase_mask & 0xFFu); + phase_mask ^= lut_n >> 16; + + uint32_t byte_mid = (raw_subframe >> 16) & 0xFF; + uint32_t lut_m = BMC_LUT_8[byte_mid]; + uint32_t bmc_16_23 = (lut_m & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_m >> 16; + + uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; // bit 7 (= P) is 0 by precondition + uint32_t lut_h = BMC_LUT_8[byte_hi]; + uint32_t bmc_24_31 = (lut_h & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_h >> 16; + bmc_24_31 ^= phase_mask & 1u; + + // word[0]: bits 24-31 = preamble, bits 8-23 = bmc(4-11), bits 0-7 = bmc(12-15) + // word[1]: bits 16-31 = bmc(16-23), bits 0-15 = bmc(24-31) + dst[0] = bmc_12_15 | (bmc_4_11 << 8) | (static_cast(preamble) << 24); + dst[1] = bmc_24_31 | (bmc_16_23 << 16); } +} - // ============================================================================ - // BMC encode the data portion (bits 4-31) using lookup tables - // ============================================================================ - // The I2S uses 16-bit halfword swap: bits 16-31 transmit before bits 0-15. - // This applies to BOTH word[0] and word[1]. - // - // word[0] transmission order: [16-23] → [24-31] → [0-7] → [8-15] - // For correct S/PDIF subframe order (preamble → aux → audio): - // - bits 16-23: preamble (8 BMC bits) - // - bits 24-31: BMC(subframe bits 4-7) - first aux nibble - // - bits 0-7: BMC(subframe bits 8-11) - second aux nibble - // - bits 8-15: BMC(subframe bits 12-15) - audio low nibble - // - // word[1] transmission order: [16-31] → [0-15] - // For correct S/PDIF subframe order: - // - bits 16-31: BMC(subframe bits 16-23) - audio mid byte - // - bits 0-15: BMC(subframe bits 24-31) - audio high nibble + VUCP - // ============================================================================ - - // All preambles end at phase HIGH. Bits 4-11 are always zero for 16-bit audio; - // two zero nibbles flip phase 8 times total → back to HIGH. - // So bits 12-15 always start encoding at phase=true. - - // Bits 12-15: 4-bit LUT lookup (always phase=true start) - uint32_t nibble = (raw_subframe >> 12) & 0xF; - uint32_t bmc_12_15 = BMC_LUT_4[nibble]; - - // Phase tracking via branchless XOR mask: - // - 0x0000 means phase=true (use LUT value directly) - // - 0xFFFF means phase=false (complement LUT value) - // End phase = start XOR (popcount & 1) since zero-bits flip phase, - // and for even bit widths: #zeros parity == popcount parity. - uint32_t phase_mask = -(__builtin_popcount(nibble) & 1u) & 0xFFFF; - - // Bits 16-23: 8-bit LUT lookup with phase correction - uint32_t byte_mid = (raw_subframe >> 16) & 0xFF; - uint32_t bmc_16_23 = BMC_LUT_8[byte_mid] ^ phase_mask; - phase_mask ^= -(__builtin_popcount(byte_mid) & 1u) & 0xFFFF; - - // Bits 24-31: 8-bit LUT lookup with phase correction - uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; - uint32_t bmc_24_31 = BMC_LUT_8[byte_hi] ^ phase_mask; - - // ============================================================================ - // Combine with correct positioning for I2S transmission - // ============================================================================ - // I2S with halfword swap: transmits bits 16-31, then bits 0-15. - // Within each halfword, MSB (highest bit) is transmitted first. - // - // For upper halfword (bits 16-31): bit 31 → bit 16 - // For lower halfword (bits 0-15): bit 15 → bit 0 - // - // Desired S/PDIF order: preamble → bmc_4_7 → bmc_8_11 → bmc_12_15 - // - // word[0] layout for correct transmission: - // bits 24-31: preamble (transmitted 1st, as MSB of upper halfword) - // bits 16-23: BMC_ZERO_NIBBLE (transmitted 2nd, aux bits 4-7) - // bits 8-15: BMC_ZERO_NIBBLE (transmitted 3rd, aux bits 8-11) - // bits 0-7: bmc_12_15 (transmitted 4th, audio low nibble) - // - // word[1] layout: - // bits 16-31: bmc_16_23 (transmitted 5th) - // bits 0-15: bmc_24_31 (transmitted 6th) - this->spdif_block_ptr_[0] = - bmc_12_15 | (BMC_ZERO_NIBBLE << 8) | (BMC_ZERO_NIBBLE << 16) | (static_cast(preamble) << 24); - this->spdif_block_ptr_[1] = bmc_24_31 | (bmc_16_23 << 16); - this->spdif_block_ptr_ += 2; - - // ============================================================================ - // Update position tracking - // ============================================================================ - if (!this->is_left_channel_) { - // Completed a stereo frame, advance frame counter - if (++this->frame_in_block_ >= SPDIF_BLOCK_SAMPLES) { - this->frame_in_block_ = 0; - } +template void SPDIFEncoder::encode_silence_frame_() { + static constexpr uint8_t SILENCE[4] = {0, 0, 0, 0}; + uint32_t raw = build_raw_subframe(SILENCE) | c_bit_for_frame(this->channel_status_, this->frame_in_block_); + uint8_t preamble_l = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M; + bmc_encode_subframe(raw, preamble_l, this->spdif_block_ptr_); + bmc_encode_subframe(raw, PREAMBLE_W, this->spdif_block_ptr_ + 2); + this->spdif_block_ptr_ += 4; + if (++this->frame_in_block_ >= SPDIF_BLOCK_SAMPLES) { + this->frame_in_block_ = 0; } - this->is_left_channel_ = !this->is_left_channel_; } esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) { @@ -295,79 +329,162 @@ esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) { return err; } -size_t SPDIFEncoder::get_pending_pcm_bytes() const { - if (this->spdif_block_ptr_ == nullptr || this->spdif_block_buf_ == nullptr) { - return 0; +template +HOT esp_err_t SPDIFEncoder::write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait, + uint32_t *blocks_sent, size_t *bytes_consumed) { + const uint8_t *pcm_data = src; + const uint8_t *const pcm_end = src + size; + uint32_t block_count = 0; + + // Hot state lives in locals so the compiler can keep it in registers across the + // per-frame encoding work; byte writes through block_ptr may alias the member fields, + // which would block register allocation if the encoding read them directly from this->*. + uint32_t *block_ptr = this->spdif_block_ptr_; + uint32_t *const block_buf = this->spdif_block_buf_.get(); + uint32_t *const block_end = block_buf + SPDIF_BLOCK_SIZE_U32; + uint32_t frame = this->frame_in_block_; + const std::array &channel_status = this->channel_status_; + + auto save_state = [&]() { + this->spdif_block_ptr_ = block_ptr; + this->frame_in_block_ = static_cast(frame); + }; + + auto report_out_params = [&]() { + if (blocks_sent != nullptr) + *blocks_sent = block_count; + if (bytes_consumed != nullptr) + *bytes_consumed = pcm_data - src; + }; + + // Send a completed block if the buffer is full, propagating any error. + // send_block_ resets this->spdif_block_ptr_ to block_buf on success and leaves it + // unchanged on error -- mirror both behaviors in our local block_ptr. + auto maybe_send = [&]() -> esp_err_t { + if (block_ptr >= block_end) { + esp_err_t err = this->send_block_(ticks_to_wait); + if (err != ESP_OK) { + save_state(); + report_out_params(); + return err; + } + block_ptr = block_buf; + ++block_count; + } + return ESP_OK; + }; + + // Hot path: encode L+R pairs in two peeled sub-loops. Frame 0 carries the only + // buffer-full check and uses PREAMBLE_B (a block fills exactly when frame wraps from + // 191 back to 0). Frames 1..191 use PREAMBLE_M and need no buffer-full check or + // preamble branch. The encoding body is inlined here so block_ptr lives in a register + // for the duration of the loop. + while (pcm_data + 2 * Bps <= pcm_end) { + if (frame == 0) { + esp_err_t err = maybe_send(); + if (err != ESP_OK) + return err; + + uint32_t c_bit = c_bit_for_frame(channel_status, 0); + uint32_t raw_l = build_raw_subframe(pcm_data) | c_bit; + uint32_t raw_r = build_raw_subframe(pcm_data + Bps) | c_bit; + bmc_encode_subframe(raw_l, PREAMBLE_B, block_ptr); + bmc_encode_subframe(raw_r, PREAMBLE_W, block_ptr + 2); + block_ptr += 4; + frame = 1; + pcm_data += 2 * Bps; + } + + // The inner loop runs until min(SPDIF_BLOCK_SAMPLES, frame + input_frames). The + // input-size bound is folded into end_frame so a single `frame < end_frame` test + // governs termination. + uint32_t input_frames = static_cast(pcm_end - pcm_data) / (2u * Bps); + uint32_t end_frame = SPDIF_BLOCK_SAMPLES; + if (frame + input_frames < end_frame) + end_frame = frame + input_frames; + + while (frame < end_frame) { + uint32_t c_bit = c_bit_for_frame(channel_status, frame); + uint32_t raw_l = build_raw_subframe(pcm_data) | c_bit; + uint32_t raw_r = build_raw_subframe(pcm_data + Bps) | c_bit; + bmc_encode_subframe(raw_l, PREAMBLE_M, block_ptr); + bmc_encode_subframe(raw_r, PREAMBLE_W, block_ptr + 2); + block_ptr += 4; + ++frame; + pcm_data += 2 * Bps; + } + if (frame >= SPDIF_BLOCK_SAMPLES) + frame = 0; } - // Each PCM sample (2 bytes) produces 2 uint32_t values in the SPDIF buffer - // So pending uint32s / 2 = pending samples, and each sample is 2 bytes - size_t pending_uint32s = this->spdif_block_ptr_ - this->spdif_block_buf_.get(); - size_t pending_samples = pending_uint32s / 2; - return pending_samples * 2; // 2 bytes per sample + + // Send any complete block that was just finished. + if (block_ptr >= block_end) { + esp_err_t err = this->send_block_(ticks_to_wait); + if (err != ESP_OK) { + save_state(); + report_out_params(); + return err; + } + block_ptr = block_buf; + ++block_count; + } + + save_state(); + report_out_params(); + return ESP_OK; } HOT esp_err_t SPDIFEncoder::write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent, size_t *bytes_consumed) { - const uint8_t *pcm_data = src; - const uint8_t *pcm_end = src + size; - uint32_t block_count = 0; + if (size > 0) { + // Real PCM is about to be encoded into the buffer, so it is no longer a full-silence block. + this->block_buf_is_silence_block_ = false; + } + switch (this->bytes_per_sample_) { + case 2: + return this->write_typed_<2>(src, size, ticks_to_wait, blocks_sent, bytes_consumed); + case 3: + return this->write_typed_<3>(src, size, ticks_to_wait, blocks_sent, bytes_consumed); + case 4: + return this->write_typed_<4>(src, size, ticks_to_wait, blocks_sent, bytes_consumed); + default: + return ESP_ERR_INVALID_STATE; + } +} - while (pcm_data < pcm_end) { - // Check if there's a pending complete block from a previous failed send - if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { - esp_err_t err = this->send_block_(ticks_to_wait); - if (err != ESP_OK) { - if (blocks_sent != nullptr) { - *blocks_sent = block_count; - } - if (bytes_consumed != nullptr) { - *bytes_consumed = pcm_data - src; - } - return err; - } - ++block_count; +template esp_err_t SPDIFEncoder::flush_with_silence_typed_(TickType_t ticks_to_wait) { + // If a complete block is already pending (from a previous failed send), emit just that block. + // Otherwise pad the partial block with silence (or generate a full silence block if empty) and + // send. Always emits exactly one block on success. + if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { + const bool was_empty = (this->spdif_block_ptr_ == this->spdif_block_buf_.get()); + // Continuous-silence idle case: a full silence block is byte-identical every time for the + // active channel status, so when the buffer already holds one, re-send it as-is. + if (was_empty && this->block_buf_is_silence_block_) { + return this->send_block_(ticks_to_wait); } - - // Encode one 16-bit sample - this->encode_sample_(pcm_data); - pcm_data += 2; - } - - // Send any complete block that was just finished - if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { - esp_err_t err = this->send_block_(ticks_to_wait); - if (err != ESP_OK) { - if (blocks_sent != nullptr) { - *blocks_sent = block_count; - } - if (bytes_consumed != nullptr) { - *bytes_consumed = pcm_data - src; - } - return err; + // Pad with silence frames at the configured width. + while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { + this->encode_silence_frame_(); } - ++block_count; + // The buffer is a reusable full-silence block only if it was built entirely from silence; a + // partial real-audio block padded out with silence is not. + this->block_buf_is_silence_block_ = was_empty; } - - if (blocks_sent != nullptr) { - *blocks_sent = block_count; - } - if (bytes_consumed != nullptr) { - *bytes_consumed = size; - } - return ESP_OK; + return this->send_block_(ticks_to_wait); } esp_err_t SPDIFEncoder::flush_with_silence(TickType_t ticks_to_wait) { - // If a complete block is already pending (from a previous failed send), emit just that block. - // Otherwise pad the partial block with silence (or generate a full silence block if empty) - // and send. Always emits exactly one block on success. - if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { - static const uint8_t SILENCE[2] = {0, 0}; - while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { - this->encode_sample_(SILENCE); - } + switch (this->bytes_per_sample_) { + case 2: + return this->flush_with_silence_typed_<2>(ticks_to_wait); + case 3: + return this->flush_with_silence_typed_<3>(ticks_to_wait); + case 4: + return this->flush_with_silence_typed_<4>(ticks_to_wait); + default: + return ESP_ERR_INVALID_STATE; } - return this->send_block_(ticks_to_wait); } } // namespace esphome::i2s_audio diff --git a/esphome/components/i2s_audio/speaker/spdif_encoder.h b/esphome/components/i2s_audio/speaker/spdif_encoder.h index 8c5e068841..9e23a858f7 100644 --- a/esphome/components/i2s_audio/speaker/spdif_encoder.h +++ b/esphome/components/i2s_audio/speaker/spdif_encoder.h @@ -24,8 +24,6 @@ static constexpr uint16_t SPDIF_BLOCK_SIZE_BYTES = SPDIF_BLOCK_SAMPLES * (EMULAT static constexpr uint32_t SPDIF_BLOCK_SIZE_U32 = SPDIF_BLOCK_SIZE_BYTES / sizeof(uint32_t); // 3072 bytes / 4 = 768 // I2S frame count for one SPDIF block (for new driver where frame = 8 bytes for 32-bit stereo) static constexpr uint32_t SPDIF_BLOCK_I2S_FRAMES = SPDIF_BLOCK_SIZE_BYTES / 8; // 3072 / 8 = 384 frames -// PCM bytes needed for one complete SPDIF block (192 stereo frames * 2 bytes per sample * 2 channels) -static constexpr uint16_t SPDIF_PCM_BYTES_PER_BLOCK = SPDIF_BLOCK_SAMPLES * 2 * 2; // = 768 bytes /// Callback signature for block completion (raw function pointer for minimal overhead) /// @param user_ctx User context pointer passed during callback registration @@ -64,8 +62,16 @@ class SPDIFEncoder { /// @brief Check if currently in preload mode bool is_preload_mode() const { return this->preload_mode_; } + /// @brief Set input PCM width: 2 = 16-bit, 3 = 24-bit, 4 = 32-bit (truncated to 24-bit on the wire). + /// Must be called before write() if input width changes from the default (16-bit). Triggers a + /// channel-status rebuild to reflect the new word length. + void set_bytes_per_sample(uint8_t bytes_per_sample); + + /// @brief Get the configured input PCM width in bytes per sample + uint8_t get_bytes_per_sample() const { return this->bytes_per_sample_; } + /// @brief Convert PCM audio data to SPDIF BMC encoded data - /// @param src Source PCM audio data (16-bit stereo) + /// @param src Source PCM audio data (stereo, width matches set_bytes_per_sample) /// @param size Size of source data in bytes /// @param ticks_to_wait Timeout for blocking writes /// @param blocks_sent Optional pointer to receive the number of complete SPDIF blocks sent @@ -74,17 +80,6 @@ class SPDIFEncoder { esp_err_t write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent = nullptr, size_t *bytes_consumed = nullptr); - /// @brief Get the number of PCM bytes currently pending in the partial block buffer - /// @return Number of pending PCM bytes (0 to SPDIF_PCM_BYTES_PER_BLOCK - 1) - size_t get_pending_pcm_bytes() const; - - /// @brief Get the number of PCM frames currently pending in the partial block buffer - /// @return Number of pending PCM frames (0 to SPDIF_BLOCK_SAMPLES - 1) - uint32_t get_pending_frames() const { return this->get_pending_pcm_bytes() / 4; } - - /// @brief Check if there is a partial block pending - bool has_pending_data() const { return this->spdif_block_ptr_ != this->spdif_block_buf_.get(); } - /// @brief Emit one complete SPDIF block: pad any pending partial block with silence and send, /// or send a full silence block if nothing is pending. Always produces exactly one block on success. /// @param ticks_to_wait Timeout for blocking writes @@ -95,7 +90,7 @@ class SPDIFEncoder { void reset(); /// @brief Set the sample rate for Channel Status Block encoding - /// @param sample_rate Sample rate in Hz (e.g., 44100, 48000, 96000) + /// @param sample_rate Sample rate in Hz (e.g., 44100, 48000) /// Call this before writing audio data to ensure correct channel status. void set_sample_rate(uint32_t sample_rate); @@ -103,8 +98,19 @@ class SPDIFEncoder { uint32_t get_sample_rate() const { return this->sample_rate_; } protected: - /// @brief Encode a single 16-bit PCM sample into the current block position - HOT void encode_sample_(const uint8_t *pcm_sample); + /// @brief Encode a single stereo silence frame at the current block position. + /// @note Used only by flush_with_silence_typed_ to pad; the hot write path inlines the + /// encoding body directly into write_typed_ to keep block_ptr / frame_in_block_ in registers. + template void encode_silence_frame_(); + + /// @brief Templated write loop. Called from the public write() via runtime dispatch on bytes_per_sample_. + template + HOT esp_err_t write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent, + size_t *bytes_consumed); + + /// @brief Templated flush-with-silence. Pads the pending block with zeros at the configured width + /// (or builds a full silence block when nothing is pending) and sends it. Always emits one block. + template esp_err_t flush_with_silence_typed_(TickType_t ticks_to_wait); /// @brief Send the completed block via the appropriate callback esp_err_t send_block_(TickType_t ticks_to_wait); @@ -112,15 +118,6 @@ class SPDIFEncoder { /// @brief Build the channel status block from current configuration void build_channel_status_(); - /// @brief Get the channel status bit for a specific frame - /// @param frame Frame number (0-191) - /// @return The C bit value for this frame - ESPHOME_ALWAYS_INLINE inline bool get_channel_status_bit_(uint8_t frame) const { - // Channel status is 192 bits transmitted over 192 frames - // Bit N is transmitted in frame N, LSB-first within each byte - return (this->channel_status_[frame >> 3] >> (frame & 7)) & 1; - } - // Member ordering optimized to minimize padding (largest alignment first) // 4-byte aligned members (pointers and uint32_t) @@ -133,9 +130,13 @@ class SPDIFEncoder { uint32_t sample_rate_{48000}; // Sample rate for Channel Status Block encoding // 1-byte aligned members (grouped together to avoid internal padding) - uint8_t frame_in_block_{0}; // 0-191, tracks stereo frame position within block - bool is_left_channel_{true}; // Alternates L/R for stereo samples - bool preload_mode_{false}; // Whether to use preload callback vs write callback + uint8_t bytes_per_sample_{2}; // Input PCM width: 2/3/4 (16/24/32-bit). 32-bit truncates to 24-bit on the wire. + uint8_t frame_in_block_{0}; // 0-191, tracks stereo frame position within block + bool preload_mode_{false}; // Whether to use preload callback vs write callback + // True when spdif_block_buf_ currently holds a complete full-silence block valid for the active + // channel status. A full silence block is deterministic for a given sample rate and word length, + // so when this is set flush_with_silence() can re-send the buffer verbatim instead of re-encoding. + bool block_buf_is_silence_block_{false}; // Channel Status Block (192 bits = 24 bytes, transmitted over 192 frames) // Placed last since std::array has 1-byte alignment diff --git a/tests/components/speaker/spdif_mode.esp32-idf.yaml b/tests/components/i2s_audio/common-spdif_mode.yaml similarity index 52% rename from tests/components/speaker/spdif_mode.esp32-idf.yaml rename to tests/components/i2s_audio/common-spdif_mode.yaml index 4d6859feae..374a4bce1e 100644 --- a/tests/components/speaker/spdif_mode.esp32-idf.yaml +++ b/tests/components/i2s_audio/common-spdif_mode.yaml @@ -1,13 +1,3 @@ -substitutions: - i2s_bclk_pin: GPIO27 - i2s_lrclk_pin: GPIO26 - i2s_mclk_pin: GPIO25 - i2s_dout_pin: GPIO12 - spdif_data_pin: GPIO4 - -packages: - i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - i2s_audio: - id: i2s_output @@ -20,6 +10,5 @@ speaker: use_apll: true timeout: 2s sample_rate: 48000 - bits_per_sample: 16bit channel: stereo i2s_mode: primary diff --git a/tests/components/i2s_audio/test-spdif_speaker.esp32-idf.yaml b/tests/components/i2s_audio/test-spdif_speaker.esp32-idf.yaml new file mode 100644 index 0000000000..a69d808d1d --- /dev/null +++ b/tests/components/i2s_audio/test-spdif_speaker.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + i2s_bclk_pin: GPIO27 + i2s_lrclk_pin: GPIO26 + i2s_mclk_pin: GPIO25 + i2s_dout_pin: GPIO12 + spdif_data_pin: GPIO4 + +<<: !include common-spdif_mode.yaml From e4c8d1f43023cb805fce97e63afe8e35cf12157b Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 19 May 2026 15:16:00 -0400 Subject: [PATCH 0110/1815] [sendspin] Bump sendspin to v0.6.0 (#16496) --- esphome/components/sendspin/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 35280020ba..36f13f7d07 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -206,7 +206,7 @@ async def to_code(config: ConfigType) -> None: ) # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.5.0") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.0") cg.add_define("USE_SENDSPIN", True) # for MDNS diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 35c55cbb4d..42d0d5de6b 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -100,6 +100,6 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.5.0 + version: 0.6.0 lvgl/lvgl: version: 9.5.0 From 19c4da2aa595a8cb0c25070c599d7b515813d1bd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 20 May 2026 12:53:26 +1200 Subject: [PATCH 0111/1815] Bump version to 2026.5.0b3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 641a491828..433fcc6c45 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.5.0b2 +PROJECT_NUMBER = 2026.5.0b3 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index d6d533a702..3e576c8899 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.5.0b2" +__version__ = "2026.5.0b3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 8927ade7897904f84fad29d8cb446e7fd62fcafb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 15:40:20 +0000 Subject: [PATCH 0112/1815] Bump zeroconf from 0.149.7 to 0.149.12 (#16510) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e3de4a134c..21005fe85f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==45.0.3 -zeroconf==0.149.7 +zeroconf==0.149.12 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From fbe212944b01f7e768ade5edd7d259394566dde1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 10:51:53 -0500 Subject: [PATCH 0113/1815] Bump aioesphomeapi from 45.0.3 to 45.0.4 (#16513) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 21005fe85f..cfb1960ea1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==45.0.3 +aioesphomeapi==45.0.4 zeroconf==0.149.12 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 60afad442c83309ccfd8203e61689b263023f07c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 20 May 2026 13:36:18 -0400 Subject: [PATCH 0114/1815] [esp32] Fix sdkconfig int values silently clamped to default (#16515) --- esphome/components/esp32/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index e9b0f1fd0a..fd28d80536 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2004,7 +2004,8 @@ async def to_code(config): if not advanced[CONF_ENABLE_LWIP_MDNS_QUERIES]: add_idf_sdkconfig_option("CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES", False) if not advanced[CONF_ENABLE_LWIP_BRIDGE_INTERFACE]: - add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 0) + # Kconfig range is [1,63]; 0 gets clamped to the default. + add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 1) _configure_lwip_max_sockets(conf) @@ -2251,7 +2252,8 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 2) elif advanced[CONF_DISABLE_FATFS]: add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", True) - add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 0) + # Kconfig range is [1,10]; 0 gets clamped to the default. + add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 1) for name, value in conf[CONF_SDKCONFIG_OPTIONS].items(): add_idf_sdkconfig_option(name, RawSdkconfigValue(value)) From 52c9a2d07bcde57f0ccc5fde9f9f20bc97e1c3f9 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 20 May 2026 14:31:58 -0400 Subject: [PATCH 0115/1815] [espidf] Drop version field from generated idf_component.yml (#16511) --- esphome/components/esp32/__init__.py | 3 +- esphome/espidf/component.py | 66 ++++------------------- tests/unit_tests/test_espidf_component.py | 26 ++++++--- 3 files changed, 30 insertions(+), 65 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index fd28d80536..8bc8a71c94 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2490,9 +2490,8 @@ def _write_sdkconfig(): def _platformio_library_to_dependency(library: Library) -> tuple[str, dict[str, str]]: dependency: dict[str, str] = {} - name, version, path = generate_idf_component(library) + name, _version, path = generate_idf_component(library) dependency["override_path"] = str(path) - dependency["version"] = version return name, dependency diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index b9202fb6bf..b1352f7791 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -154,41 +154,6 @@ class IDFComponent: self.path = self.source.download(self.get_sanitized_name(), force=force) -def _sanitize_version(version: str) -> str: - """ - Sanitize a version string by removing common requirement prefixes or a leading v. - - Args: - version: Version string to clean. - - Returns: - Cleaned version string without common requirement symbols. - """ - version = version.strip() - - prefixes = ( - "^", - "~=", - "~", - ">=", - "<=", - "==", - "!=", - ">", - "<", - "=", - "v", - "V", - ) - - for p in prefixes: - if version.startswith(p): - version = version[len(p) :] - break - - return version.strip() - - def _get_package_from_pio_registry( username: str | None, pkgname: str, requirements: str ) -> tuple[str, str, str | None, str | None]: @@ -396,7 +361,8 @@ def _convert_library_to_component(library: Library) -> IDFComponent: # Repository is provided directly if library.repository: - # Parse repository URL to extract name and version + # Parse repository URL: path becomes the component name, fragment + # becomes the git ref stored on GitSource. split_result = urlsplit(library.repository) if not split_result.fragment.strip(): raise ValueError(f"Missing ref in URL {library.repository}") @@ -405,8 +371,10 @@ def _convert_library_to_component(library: Library) -> IDFComponent: name = str(split_result.path).strip("/") name = name.removesuffix(".git") - # Sanitize version - version = _sanitize_version(split_result.fragment) + # IDF Component Manager only accepts "*", a 40-char commit hash, or + # semver here. The actual git ref is preserved in GitSource.ref; + # override_path makes this field cosmetic at build time. + version = "*" repository = urlunsplit(split_result._replace(fragment="")) source = GitSource(str(repository), split_result.fragment) @@ -619,9 +587,6 @@ def generate_idf_component_yml(component: IDFComponent) -> str: if description: data["description"] = description - # Do not use the version from library.json/library.properties; it may be incorrect. - data["version"] = component.version - repository = component.data.get("repository", {}).get("url", None) if repository: data["repository"] = repository @@ -631,20 +596,11 @@ def generate_idf_component_yml(component: IDFComponent) -> str: if "dependencies" not in data: data["dependencies"] = {} - # Add this dependency to dependencies - dep = {} - dep["version"] = dependency.version - - # Should use dependency.path as override path - try: - dep["override_path"] = str(dependency.path) - except RuntimeError as e: - # No local path: only a GitSource can substitute its URL. - if not isinstance(dependency.source, GitSource): - raise e - dep["git"] = dependency.source.url - - data["dependencies"][dependency.get_sanitized_name()] = dep + # Every dependency goes through _generate_idf_component → + # component.download() before this runs, so .path is always set. + data["dependencies"][dependency.get_sanitized_name()] = { + "override_path": str(dependency.path), + } return yaml_util.dump(data) diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 8977b05d23..373432f7d2 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -203,7 +203,7 @@ def test_generate_idf_component_yml_basic(tmp_component): tmp_component.data = {"description": "test", "repository": {"url": "http://aaa"}} result = generate_idf_component_yml(tmp_component) - assert result == "description: test\nversion: 1.0.0\nrepository: http://aaa\n" + assert result == "description: test\nrepository: http://aaa\n" def test_generate_idf_component_yml_with_dependencies(tmp_component, tmp_path): @@ -217,18 +217,16 @@ def test_generate_idf_component_yml_with_dependencies(tmp_component, tmp_path): assert ( result - == f"""version: 1.0.0 -dependencies: + == f"""dependencies: dep: - version: '1.0' override_path: {dep.path} """ ) -def test_generate_idf_component_yml_missing_path_reraises(tmp_component): - # A dep without a path and without a recognised source should re-raise - # the underlying RuntimeError instead of silently producing a bad manifest. +def test_generate_idf_component_yml_missing_path_raises(tmp_component): + # A dep without a path is a contract violation — every dep is expected + # to have been downloaded before YAML generation. Raise loudly. dep = IDFComponent("foo/bar", "1.0", source=None) tmp_component.dependencies = [dep] @@ -422,8 +420,20 @@ def test_convert_library_with_repository(): result = _convert_library_to_component(lib) assert result.name == "foo/bar" - assert result.version == "1.2.3" + assert result.version == "*" assert isinstance(result.source, GitSource) + assert result.source.ref == "v1.2.3" + + +def test_convert_library_with_branch_ref(): + lib = Library("name", None, "https://github.com/foo/bar.git#some-branch") + + result = _convert_library_to_component(lib) + + assert result.name == "foo/bar" + assert result.version == "*" + assert isinstance(result.source, GitSource) + assert result.source.ref == "some-branch" def test_convert_library_missing_ref(): From 870f628637cbd9c2256c4c7f694ee8c575574d48 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 20 May 2026 16:40:59 -0400 Subject: [PATCH 0116/1815] [esp32] Decouple esp-idf toolchain version check from PIO, honor framework source: override (#16516) --- esphome/components/esp32/__init__.py | 85 +++++++++++++++-------- esphome/espidf/framework.py | 22 ++++-- esphome/espidf/toolchain.py | 17 ++++- tests/unit_tests/test_espidf_toolchain.py | 58 ++++++++++++++++ 4 files changed, 147 insertions(+), 35 deletions(-) create mode 100644 tests/unit_tests/test_espidf_toolchain.py diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 8bc8a71c94..5cf2129051 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -792,19 +792,15 @@ PLATFORM_VERSION_LOOKUP = { } -def _check_pio_versions(config): - config = config.copy() - value = config[CONF_FRAMEWORK] +def _resolve_framework_version(value: ConfigType) -> cv.Version: + """Resolve a named or raw framework version and validate the minimum. + Normalises value[CONF_VERSION] to its string form and returns the parsed + cv.Version. Shared between the PIO and esp-idf toolchain paths; toolchain- + specific concerns (source defaults, platform_version) live in the per- + toolchain functions. + """ if value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP: - if CONF_SOURCE in value or CONF_PLATFORM_VERSION in value: - raise cv.Invalid( - "Version needs to be explicitly set when a custom source or platform_version is used." - ) - - platform_lookup = PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]] - value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup)) - if value[CONF_TYPE] == FRAMEWORK_ARDUINO: version = ARDUINO_FRAMEWORK_VERSION_LOOKUP[value[CONF_VERSION]] else: @@ -817,7 +813,38 @@ def _check_pio_versions(config): if value[CONF_TYPE] == FRAMEWORK_ARDUINO: if version < cv.Version(3, 0, 0): raise cv.Invalid("Only Arduino 3.0+ is supported.") - recommended_version = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"] + recommended = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"] + else: + if version < cv.Version(5, 0, 0): + raise cv.Invalid("Only ESP-IDF 5.0+ is supported.") + recommended = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"] + + if version != recommended: + _LOGGER.warning( + "The selected framework version is not the recommended one. " + "If there are connectivity or build issues please remove the manual version." + ) + + return version + + +def _check_pio_versions(config: ConfigType) -> ConfigType: + config = config.copy() + value = config[CONF_FRAMEWORK] + + is_named_version = value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP + if is_named_version and (CONF_SOURCE in value or CONF_PLATFORM_VERSION in value): + raise cv.Invalid( + "Version needs to be explicitly set when a custom source or platform_version is used." + ) + if is_named_version: + value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version( + str(PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]]) + ) + + version = _resolve_framework_version(value) + + if value[CONF_TYPE] == FRAMEWORK_ARDUINO: platform_lookup = ARDUINO_PLATFORM_VERSION_LOOKUP.get(version) value[CONF_SOURCE] = value.get( CONF_SOURCE, _format_framework_arduino_version(version) @@ -825,9 +852,6 @@ def _check_pio_versions(config): if _is_framework_url(value[CONF_SOURCE]): value[CONF_SOURCE] = f"{ARDUINO_FRAMEWORK_PKG}@{value[CONF_SOURCE]}" else: - if version < cv.Version(5, 0, 0): - raise cv.Invalid("Only ESP-IDF 5.0+ is supported.") - recommended_version = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"] platform_lookup = ESP_IDF_PLATFORM_VERSION_LOOKUP.get(version) value[CONF_SOURCE] = value.get( CONF_SOURCE, @@ -843,12 +867,6 @@ def _check_pio_versions(config): ) value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup)) - if version != recommended_version: - _LOGGER.warning( - "The selected framework version is not the recommended one. " - "If there are connectivity or build issues please remove the manual version." - ) - if value[CONF_PLATFORM_VERSION] != _parse_pio_platform_version( str(PLATFORM_VERSION_LOOKUP["recommended"]) ): @@ -860,19 +878,26 @@ def _check_pio_versions(config): return config -def _check_esp_idf_versions(config): - config = _check_pio_versions(config) +def _check_esp_idf_versions(config: ConfigType) -> ConfigType: + config = config.copy() value = config[CONF_FRAMEWORK] - # Remove unwanted keys if present - for key in (CONF_SOURCE, CONF_PLATFORM_VERSION): - value.pop(key, None) + # platform_version is a PlatformIO concept; drop it if a user carried it + # over from a PIO-style config. CONF_SOURCE, on the other hand, is kept: + # it lets a user override the framework tarball URL under the esp-idf + # toolchain (the espidf framework downloader consults it). + value.pop(CONF_PLATFORM_VERSION, None) - # Official ESP-IDF frameworks don't use extra - version = cv.Version.parse(value[CONF_VERSION]) - version = cv.Version(version.major, version.minor, version.patch) + version = _resolve_framework_version(value) - value[CONF_VERSION] = str(version) + if CONF_SOURCE in value: + _LOGGER.warning( + "A custom framework source is set. " + "If there are connectivity or build issues please remove the manual source." + ) + + # Official ESP-IDF frameworks don't use the 'extra' semver component. + value[CONF_VERSION] = str(cv.Version(version.major, version.minor, version.patch)) return config diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 8996ff1e02..3dcb9dd242 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -789,6 +789,7 @@ def _check_esphome_idf_framework_install( tools: list[str], force: bool = False, env: dict[str, str] | None = None, + source_url: str | None = None, ) -> tuple[Path, bool]: """ Check and install ESP-IDF framework. @@ -799,6 +800,11 @@ def _check_esphome_idf_framework_install( tools: list of tools to install force: If True, force reinstallation env: Optional dictionary of environment variables to set + source_url: Optional override URL for the framework tarball. Supports + the same ``{VERSION}`` / ``{MAJOR}`` / ``{MINOR}`` / ``{PATCH}`` / + ``{EXTRA}`` substitutions as ESPHOME_IDF_FRAMEWORK_MIRRORS. When + set, it replaces the default mirror list — no implicit fallback, + so a misspelled URL fails loudly. Returns: tuple of (framework_path, install_flag) @@ -820,6 +826,10 @@ def _check_esphome_idf_framework_install( env_stamp_file = framework_path / ESPHOME_STAMP_FILE idf_tools_path = framework_path / "tools" / "idf_tools.py" _LOGGER.info("Checking ESP-IDF %s framework ...", version) + # Logged every invocation (not just on install) so the user can verify the + # override. A changed URL needs ``esphome clean`` to force a re-download. + if source_url: + _LOGGER.info("Using framework source override: %s", source_url) # 2. Download and extract the framework if not already extracted. # The marker is written last after extraction succeeds, so its presence @@ -847,9 +857,8 @@ def _check_esphome_idf_framework_install( except ValueError: pass - download_from_mirrors( - ESPHOME_IDF_FRAMEWORK_MIRRORS, substitutions, tmp.file - ) + mirrors = [source_url] if source_url else ESPHOME_IDF_FRAMEWORK_MIRRORS + download_from_mirrors(mirrors, substitutions, tmp.file) _LOGGER.info("Extracting ESP-IDF %s framework ...", version) archive_extract_all(tmp.file, framework_path, progress_header="Extracting") @@ -1011,6 +1020,7 @@ def check_esp_idf_install( tools: list[str] | None = None, features: list[str] | None = None, force: bool = False, + source_url: str | None = None, ) -> tuple[Path, Path]: """ Check and install ESP-IDF framework and Python environment. @@ -1021,6 +1031,10 @@ def check_esp_idf_install( tools: list of tools to install features: Features to install force: If True, force reinstallation + source_url: Optional override URL for the framework tarball. When + set, it replaces the default mirror list (no fallback). Forwarded + to ``_check_esphome_idf_framework_install``; supports the same URL + substitutions. Returns: tuple of (framework_path, python_env_path) @@ -1043,7 +1057,7 @@ def check_esp_idf_install( # 1) Framework framework_path, installed = _check_esphome_idf_framework_install( - version, targets, tools, force=force, env=env + version, targets, tools, force=force, env=env, source_url=source_url ) features = features or ESPHOME_IDF_DEFAULT_FEATURES diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index e0bc5bb393..ef28575caa 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -10,6 +10,7 @@ import shutil import subprocess from esphome.components.esp32.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION +from esphome.const import CONF_FRAMEWORK, CONF_SOURCE from esphome.core import CORE, EsphomeError from esphome.espidf.framework import check_esp_idf_install, get_framework_env from esphome.espidf.size_summary import print_summary @@ -37,13 +38,27 @@ def _get_core_framework_version(): return str(CORE.data[KEY_ESP32][KEY_IDF_VERSION]) +def _get_framework_source_override() -> str | None: + """Return the user-supplied esp32.framework.source override, if any. + + The override lets a user point the IDF tarball download at a custom URL + (mirror, fork, local server). Substitutions like ``{VERSION}`` / + ``{MAJOR}`` etc. work the same as in the default mirror list. + """ + if CORE.config is None: + return None + return CORE.config.get(KEY_ESP32, {}).get(CONF_FRAMEWORK, {}).get(CONF_SOURCE) + + def _get_esphome_esp_idf_paths( version: str | None = None, ) -> tuple[os.PathLike, os.PathLike]: version = version or _get_core_framework_version() paths = _cache().paths if version not in paths: - paths[version] = check_esp_idf_install(version) + paths[version] = check_esp_idf_install( + version, source_url=_get_framework_source_override() + ) return paths[version] diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py new file mode 100644 index 0000000000..adc8bfce63 --- /dev/null +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -0,0 +1,58 @@ +"""Tests for esphome.espidf.toolchain helpers.""" + +# pylint: disable=protected-access + +from unittest.mock import patch + +from esphome.const import CONF_FRAMEWORK, CONF_SOURCE +from esphome.core import CORE +from esphome.espidf import toolchain + + +def test_get_framework_source_override_no_config(): + """When CORE.config hasn't been set, no override is returned.""" + CORE.config = None + assert toolchain._get_framework_source_override() is None + + +def test_get_framework_source_override_no_esp32_section(): + """A config without an esp32 section yields no override.""" + CORE.config = {} + assert toolchain._get_framework_source_override() is None + + +def test_get_framework_source_override_no_framework_source(): + """An esp32 section without framework.source yields no override.""" + CORE.config = {"esp32": {CONF_FRAMEWORK: {}}} + assert toolchain._get_framework_source_override() is None + + +def test_get_framework_source_override_returns_value(): + """A user-supplied framework source is returned verbatim.""" + url = "https://example.com/esp-idf-v{VERSION}.tar.xz" + CORE.config = {"esp32": {CONF_FRAMEWORK: {CONF_SOURCE: url}}} + assert toolchain._get_framework_source_override() == url + + +def test_get_esphome_esp_idf_paths_forwards_source_override(): + """_get_esphome_esp_idf_paths threads the override into check_esp_idf_install.""" + url = "https://my-mirror/esp-idf-v{VERSION}.tar.xz" + CORE.config = {"esp32": {CONF_FRAMEWORK: {CONF_SOURCE: url}}} + # Hit a fresh cache key so check_esp_idf_install is actually called. + toolchain._cache().paths.clear() + with patch.object( + toolchain, "check_esp_idf_install", return_value=("/fw", "/penv") + ) as mock_install: + toolchain._get_esphome_esp_idf_paths("5.5.4") + mock_install.assert_called_once_with("5.5.4", source_url=url) + + +def test_get_esphome_esp_idf_paths_no_override(): + """When no source override is configured, source_url=None is passed.""" + CORE.config = {} + toolchain._cache().paths.clear() + with patch.object( + toolchain, "check_esp_idf_install", return_value=("/fw", "/penv") + ) as mock_install: + toolchain._get_esphome_esp_idf_paths("5.5.4") + mock_install.assert_called_once_with("5.5.4", source_url=None) From b79a306d0286ff9c675da88df5ba5bfa0d0a7e64 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 15:40:20 +0000 Subject: [PATCH 0117/1815] Bump zeroconf from 0.149.7 to 0.149.12 (#16510) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3c66db489a..98106f36c2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==45.0.3 -zeroconf==0.149.7 +zeroconf==0.149.12 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From 9fdad681385f04915aca111a7c9312f9e6b1af17 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 10:51:53 -0500 Subject: [PATCH 0118/1815] Bump aioesphomeapi from 45.0.3 to 45.0.4 (#16513) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 98106f36c2..4338063387 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==45.0.3 +aioesphomeapi==45.0.4 zeroconf==0.149.12 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From ecf823b871c6a336020bb3fa9168b3fb57c42c34 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 20 May 2026 14:31:58 -0400 Subject: [PATCH 0119/1815] [espidf] Drop version field from generated idf_component.yml (#16511) --- esphome/components/esp32/__init__.py | 3 +- esphome/espidf/component.py | 66 ++++------------------- tests/unit_tests/test_espidf_component.py | 26 ++++++--- 3 files changed, 30 insertions(+), 65 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index e9b0f1fd0a..9f1af9fdf7 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2488,9 +2488,8 @@ def _write_sdkconfig(): def _platformio_library_to_dependency(library: Library) -> tuple[str, dict[str, str]]: dependency: dict[str, str] = {} - name, version, path = generate_idf_component(library) + name, _version, path = generate_idf_component(library) dependency["override_path"] = str(path) - dependency["version"] = version return name, dependency diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index b9202fb6bf..b1352f7791 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -154,41 +154,6 @@ class IDFComponent: self.path = self.source.download(self.get_sanitized_name(), force=force) -def _sanitize_version(version: str) -> str: - """ - Sanitize a version string by removing common requirement prefixes or a leading v. - - Args: - version: Version string to clean. - - Returns: - Cleaned version string without common requirement symbols. - """ - version = version.strip() - - prefixes = ( - "^", - "~=", - "~", - ">=", - "<=", - "==", - "!=", - ">", - "<", - "=", - "v", - "V", - ) - - for p in prefixes: - if version.startswith(p): - version = version[len(p) :] - break - - return version.strip() - - def _get_package_from_pio_registry( username: str | None, pkgname: str, requirements: str ) -> tuple[str, str, str | None, str | None]: @@ -396,7 +361,8 @@ def _convert_library_to_component(library: Library) -> IDFComponent: # Repository is provided directly if library.repository: - # Parse repository URL to extract name and version + # Parse repository URL: path becomes the component name, fragment + # becomes the git ref stored on GitSource. split_result = urlsplit(library.repository) if not split_result.fragment.strip(): raise ValueError(f"Missing ref in URL {library.repository}") @@ -405,8 +371,10 @@ def _convert_library_to_component(library: Library) -> IDFComponent: name = str(split_result.path).strip("/") name = name.removesuffix(".git") - # Sanitize version - version = _sanitize_version(split_result.fragment) + # IDF Component Manager only accepts "*", a 40-char commit hash, or + # semver here. The actual git ref is preserved in GitSource.ref; + # override_path makes this field cosmetic at build time. + version = "*" repository = urlunsplit(split_result._replace(fragment="")) source = GitSource(str(repository), split_result.fragment) @@ -619,9 +587,6 @@ def generate_idf_component_yml(component: IDFComponent) -> str: if description: data["description"] = description - # Do not use the version from library.json/library.properties; it may be incorrect. - data["version"] = component.version - repository = component.data.get("repository", {}).get("url", None) if repository: data["repository"] = repository @@ -631,20 +596,11 @@ def generate_idf_component_yml(component: IDFComponent) -> str: if "dependencies" not in data: data["dependencies"] = {} - # Add this dependency to dependencies - dep = {} - dep["version"] = dependency.version - - # Should use dependency.path as override path - try: - dep["override_path"] = str(dependency.path) - except RuntimeError as e: - # No local path: only a GitSource can substitute its URL. - if not isinstance(dependency.source, GitSource): - raise e - dep["git"] = dependency.source.url - - data["dependencies"][dependency.get_sanitized_name()] = dep + # Every dependency goes through _generate_idf_component → + # component.download() before this runs, so .path is always set. + data["dependencies"][dependency.get_sanitized_name()] = { + "override_path": str(dependency.path), + } return yaml_util.dump(data) diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 8977b05d23..373432f7d2 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -203,7 +203,7 @@ def test_generate_idf_component_yml_basic(tmp_component): tmp_component.data = {"description": "test", "repository": {"url": "http://aaa"}} result = generate_idf_component_yml(tmp_component) - assert result == "description: test\nversion: 1.0.0\nrepository: http://aaa\n" + assert result == "description: test\nrepository: http://aaa\n" def test_generate_idf_component_yml_with_dependencies(tmp_component, tmp_path): @@ -217,18 +217,16 @@ def test_generate_idf_component_yml_with_dependencies(tmp_component, tmp_path): assert ( result - == f"""version: 1.0.0 -dependencies: + == f"""dependencies: dep: - version: '1.0' override_path: {dep.path} """ ) -def test_generate_idf_component_yml_missing_path_reraises(tmp_component): - # A dep without a path and without a recognised source should re-raise - # the underlying RuntimeError instead of silently producing a bad manifest. +def test_generate_idf_component_yml_missing_path_raises(tmp_component): + # A dep without a path is a contract violation — every dep is expected + # to have been downloaded before YAML generation. Raise loudly. dep = IDFComponent("foo/bar", "1.0", source=None) tmp_component.dependencies = [dep] @@ -422,8 +420,20 @@ def test_convert_library_with_repository(): result = _convert_library_to_component(lib) assert result.name == "foo/bar" - assert result.version == "1.2.3" + assert result.version == "*" assert isinstance(result.source, GitSource) + assert result.source.ref == "v1.2.3" + + +def test_convert_library_with_branch_ref(): + lib = Library("name", None, "https://github.com/foo/bar.git#some-branch") + + result = _convert_library_to_component(lib) + + assert result.name == "foo/bar" + assert result.version == "*" + assert isinstance(result.source, GitSource) + assert result.source.ref == "some-branch" def test_convert_library_missing_ref(): From cd7e2d79c4f9f4e454488752a798ca1b8086b077 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 20 May 2026 16:40:59 -0400 Subject: [PATCH 0120/1815] [esp32] Decouple esp-idf toolchain version check from PIO, honor framework source: override (#16516) --- esphome/components/esp32/__init__.py | 85 +++++++++++++++-------- esphome/espidf/framework.py | 22 ++++-- esphome/espidf/toolchain.py | 17 ++++- tests/unit_tests/test_espidf_toolchain.py | 58 ++++++++++++++++ 4 files changed, 147 insertions(+), 35 deletions(-) create mode 100644 tests/unit_tests/test_espidf_toolchain.py diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 9f1af9fdf7..1db97f95eb 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -792,19 +792,15 @@ PLATFORM_VERSION_LOOKUP = { } -def _check_pio_versions(config): - config = config.copy() - value = config[CONF_FRAMEWORK] +def _resolve_framework_version(value: ConfigType) -> cv.Version: + """Resolve a named or raw framework version and validate the minimum. + Normalises value[CONF_VERSION] to its string form and returns the parsed + cv.Version. Shared between the PIO and esp-idf toolchain paths; toolchain- + specific concerns (source defaults, platform_version) live in the per- + toolchain functions. + """ if value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP: - if CONF_SOURCE in value or CONF_PLATFORM_VERSION in value: - raise cv.Invalid( - "Version needs to be explicitly set when a custom source or platform_version is used." - ) - - platform_lookup = PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]] - value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup)) - if value[CONF_TYPE] == FRAMEWORK_ARDUINO: version = ARDUINO_FRAMEWORK_VERSION_LOOKUP[value[CONF_VERSION]] else: @@ -817,7 +813,38 @@ def _check_pio_versions(config): if value[CONF_TYPE] == FRAMEWORK_ARDUINO: if version < cv.Version(3, 0, 0): raise cv.Invalid("Only Arduino 3.0+ is supported.") - recommended_version = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"] + recommended = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"] + else: + if version < cv.Version(5, 0, 0): + raise cv.Invalid("Only ESP-IDF 5.0+ is supported.") + recommended = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"] + + if version != recommended: + _LOGGER.warning( + "The selected framework version is not the recommended one. " + "If there are connectivity or build issues please remove the manual version." + ) + + return version + + +def _check_pio_versions(config: ConfigType) -> ConfigType: + config = config.copy() + value = config[CONF_FRAMEWORK] + + is_named_version = value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP + if is_named_version and (CONF_SOURCE in value or CONF_PLATFORM_VERSION in value): + raise cv.Invalid( + "Version needs to be explicitly set when a custom source or platform_version is used." + ) + if is_named_version: + value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version( + str(PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]]) + ) + + version = _resolve_framework_version(value) + + if value[CONF_TYPE] == FRAMEWORK_ARDUINO: platform_lookup = ARDUINO_PLATFORM_VERSION_LOOKUP.get(version) value[CONF_SOURCE] = value.get( CONF_SOURCE, _format_framework_arduino_version(version) @@ -825,9 +852,6 @@ def _check_pio_versions(config): if _is_framework_url(value[CONF_SOURCE]): value[CONF_SOURCE] = f"{ARDUINO_FRAMEWORK_PKG}@{value[CONF_SOURCE]}" else: - if version < cv.Version(5, 0, 0): - raise cv.Invalid("Only ESP-IDF 5.0+ is supported.") - recommended_version = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"] platform_lookup = ESP_IDF_PLATFORM_VERSION_LOOKUP.get(version) value[CONF_SOURCE] = value.get( CONF_SOURCE, @@ -843,12 +867,6 @@ def _check_pio_versions(config): ) value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup)) - if version != recommended_version: - _LOGGER.warning( - "The selected framework version is not the recommended one. " - "If there are connectivity or build issues please remove the manual version." - ) - if value[CONF_PLATFORM_VERSION] != _parse_pio_platform_version( str(PLATFORM_VERSION_LOOKUP["recommended"]) ): @@ -860,19 +878,26 @@ def _check_pio_versions(config): return config -def _check_esp_idf_versions(config): - config = _check_pio_versions(config) +def _check_esp_idf_versions(config: ConfigType) -> ConfigType: + config = config.copy() value = config[CONF_FRAMEWORK] - # Remove unwanted keys if present - for key in (CONF_SOURCE, CONF_PLATFORM_VERSION): - value.pop(key, None) + # platform_version is a PlatformIO concept; drop it if a user carried it + # over from a PIO-style config. CONF_SOURCE, on the other hand, is kept: + # it lets a user override the framework tarball URL under the esp-idf + # toolchain (the espidf framework downloader consults it). + value.pop(CONF_PLATFORM_VERSION, None) - # Official ESP-IDF frameworks don't use extra - version = cv.Version.parse(value[CONF_VERSION]) - version = cv.Version(version.major, version.minor, version.patch) + version = _resolve_framework_version(value) - value[CONF_VERSION] = str(version) + if CONF_SOURCE in value: + _LOGGER.warning( + "A custom framework source is set. " + "If there are connectivity or build issues please remove the manual source." + ) + + # Official ESP-IDF frameworks don't use the 'extra' semver component. + value[CONF_VERSION] = str(cv.Version(version.major, version.minor, version.patch)) return config diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 32bcf4fb3b..aa97c65227 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -786,6 +786,7 @@ def _check_esphome_idf_framework_install( tools: list[str], force: bool = False, env: dict[str, str] | None = None, + source_url: str | None = None, ) -> tuple[Path, bool]: """ Check and install ESP-IDF framework. @@ -796,6 +797,11 @@ def _check_esphome_idf_framework_install( tools: list of tools to install force: If True, force reinstallation env: Optional dictionary of environment variables to set + source_url: Optional override URL for the framework tarball. Supports + the same ``{VERSION}`` / ``{MAJOR}`` / ``{MINOR}`` / ``{PATCH}`` / + ``{EXTRA}`` substitutions as ESPHOME_IDF_FRAMEWORK_MIRRORS. When + set, it replaces the default mirror list — no implicit fallback, + so a misspelled URL fails loudly. Returns: tuple of (framework_path, install_flag) @@ -817,6 +823,10 @@ def _check_esphome_idf_framework_install( env_stamp_file = framework_path / ESPHOME_STAMP_FILE idf_tools_path = framework_path / "tools" / "idf_tools.py" _LOGGER.info("Checking ESP-IDF %s framework ...", version) + # Logged every invocation (not just on install) so the user can verify the + # override. A changed URL needs ``esphome clean`` to force a re-download. + if source_url: + _LOGGER.info("Using framework source override: %s", source_url) # 2. Download and extract the framework if not already extracted. # The marker is written last after extraction succeeds, so its presence @@ -844,9 +854,8 @@ def _check_esphome_idf_framework_install( except ValueError: pass - download_from_mirrors( - ESPHOME_IDF_FRAMEWORK_MIRRORS, substitutions, tmp.file - ) + mirrors = [source_url] if source_url else ESPHOME_IDF_FRAMEWORK_MIRRORS + download_from_mirrors(mirrors, substitutions, tmp.file) _LOGGER.info("Extracting ESP-IDF %s framework ...", version) archive_extract_all(tmp.file, framework_path, progress_header="Extracting") @@ -1008,6 +1017,7 @@ def check_esp_idf_install( tools: list[str] | None = None, features: list[str] | None = None, force: bool = False, + source_url: str | None = None, ) -> tuple[Path, Path]: """ Check and install ESP-IDF framework and Python environment. @@ -1018,6 +1028,10 @@ def check_esp_idf_install( tools: list of tools to install features: Features to install force: If True, force reinstallation + source_url: Optional override URL for the framework tarball. When + set, it replaces the default mirror list (no fallback). Forwarded + to ``_check_esphome_idf_framework_install``; supports the same URL + substitutions. Returns: tuple of (framework_path, python_env_path) @@ -1040,7 +1054,7 @@ def check_esp_idf_install( # 1) Framework framework_path, installed = _check_esphome_idf_framework_install( - version, targets, tools, force=force, env=env + version, targets, tools, force=force, env=env, source_url=source_url ) features = features or ESPHOME_IDF_DEFAULT_FEATURES diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index e0bc5bb393..ef28575caa 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -10,6 +10,7 @@ import shutil import subprocess from esphome.components.esp32.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION +from esphome.const import CONF_FRAMEWORK, CONF_SOURCE from esphome.core import CORE, EsphomeError from esphome.espidf.framework import check_esp_idf_install, get_framework_env from esphome.espidf.size_summary import print_summary @@ -37,13 +38,27 @@ def _get_core_framework_version(): return str(CORE.data[KEY_ESP32][KEY_IDF_VERSION]) +def _get_framework_source_override() -> str | None: + """Return the user-supplied esp32.framework.source override, if any. + + The override lets a user point the IDF tarball download at a custom URL + (mirror, fork, local server). Substitutions like ``{VERSION}`` / + ``{MAJOR}`` etc. work the same as in the default mirror list. + """ + if CORE.config is None: + return None + return CORE.config.get(KEY_ESP32, {}).get(CONF_FRAMEWORK, {}).get(CONF_SOURCE) + + def _get_esphome_esp_idf_paths( version: str | None = None, ) -> tuple[os.PathLike, os.PathLike]: version = version or _get_core_framework_version() paths = _cache().paths if version not in paths: - paths[version] = check_esp_idf_install(version) + paths[version] = check_esp_idf_install( + version, source_url=_get_framework_source_override() + ) return paths[version] diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py new file mode 100644 index 0000000000..adc8bfce63 --- /dev/null +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -0,0 +1,58 @@ +"""Tests for esphome.espidf.toolchain helpers.""" + +# pylint: disable=protected-access + +from unittest.mock import patch + +from esphome.const import CONF_FRAMEWORK, CONF_SOURCE +from esphome.core import CORE +from esphome.espidf import toolchain + + +def test_get_framework_source_override_no_config(): + """When CORE.config hasn't been set, no override is returned.""" + CORE.config = None + assert toolchain._get_framework_source_override() is None + + +def test_get_framework_source_override_no_esp32_section(): + """A config without an esp32 section yields no override.""" + CORE.config = {} + assert toolchain._get_framework_source_override() is None + + +def test_get_framework_source_override_no_framework_source(): + """An esp32 section without framework.source yields no override.""" + CORE.config = {"esp32": {CONF_FRAMEWORK: {}}} + assert toolchain._get_framework_source_override() is None + + +def test_get_framework_source_override_returns_value(): + """A user-supplied framework source is returned verbatim.""" + url = "https://example.com/esp-idf-v{VERSION}.tar.xz" + CORE.config = {"esp32": {CONF_FRAMEWORK: {CONF_SOURCE: url}}} + assert toolchain._get_framework_source_override() == url + + +def test_get_esphome_esp_idf_paths_forwards_source_override(): + """_get_esphome_esp_idf_paths threads the override into check_esp_idf_install.""" + url = "https://my-mirror/esp-idf-v{VERSION}.tar.xz" + CORE.config = {"esp32": {CONF_FRAMEWORK: {CONF_SOURCE: url}}} + # Hit a fresh cache key so check_esp_idf_install is actually called. + toolchain._cache().paths.clear() + with patch.object( + toolchain, "check_esp_idf_install", return_value=("/fw", "/penv") + ) as mock_install: + toolchain._get_esphome_esp_idf_paths("5.5.4") + mock_install.assert_called_once_with("5.5.4", source_url=url) + + +def test_get_esphome_esp_idf_paths_no_override(): + """When no source override is configured, source_url=None is passed.""" + CORE.config = {} + toolchain._cache().paths.clear() + with patch.object( + toolchain, "check_esp_idf_install", return_value=("/fw", "/penv") + ) as mock_install: + toolchain._get_esphome_esp_idf_paths("5.5.4") + mock_install.assert_called_once_with("5.5.4", source_url=None) From de783e72d58faba684e617b2536be8b00dbdc696 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 21 May 2026 09:10:52 +1200 Subject: [PATCH 0121/1815] Bump version to 2026.5.0b4 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 433fcc6c45..f8486e9863 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.5.0b3 +PROJECT_NUMBER = 2026.5.0b4 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 3e576c8899..3c5243f304 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.5.0b3" +__version__ = "2026.5.0b4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 104c8bed41499cbe5235fc5762120d65961e2809 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 21 May 2026 11:16:58 +1200 Subject: [PATCH 0122/1815] Bump version to 2026.5.0 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index f8486e9863..206a181ffd 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.5.0b4 +PROJECT_NUMBER = 2026.5.0 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 3c5243f304..96554b12da 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.5.0b4" +__version__ = "2026.5.0" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 43a1c2067edb9031acb1f58b37bd41f8f96ed42a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 08:28:09 -0500 Subject: [PATCH 0123/1815] Bump zeroconf from 0.149.12 to 0.149.13 (#16520) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index cfb1960ea1..4daed8971e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==45.0.4 -zeroconf==0.149.12 +zeroconf==0.149.13 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From a70e358ceadd63022831744b0bbe44174c40fd65 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 09:55:28 -0500 Subject: [PATCH 0124/1815] Bump zeroconf from 0.149.13 to 0.149.16 (#16533) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4daed8971e..178e05497f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==45.0.4 -zeroconf==0.149.13 +zeroconf==0.149.16 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From 52e7d3ccfb680257f94c2bd72653e4e0c74c595d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 11:35:25 -0400 Subject: [PATCH 0125/1815] [esp32] Use new sdkconfig key names that replaced deprecated ones (#16522) --- esphome/components/esp32/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 5cf2129051..09d2e89bd3 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1987,7 +1987,7 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_HEAP_PLACE_FUNCTION_INTO_FLASH", True) # Setup watchdog - add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT", True) + add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_INIT", True) add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_PANIC", True) add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0", False) add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1", False) @@ -2122,7 +2122,6 @@ async def to_code(config): for key, flag in ASSERTION_LEVELS.items(): add_idf_sdkconfig_option(flag, assertion_level == key) - add_idf_sdkconfig_option("CONFIG_COMPILER_OPTIMIZATION_DEFAULT", False) compiler_optimization = advanced[CONF_COMPILER_OPTIMIZATION] for key, flag in COMPILER_OPTIMIZATIONS.items(): add_idf_sdkconfig_option(flag, compiler_optimization == key) From 90715373f2b074ffd66db674b382d8c9abc41e10 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 11:35:51 -0400 Subject: [PATCH 0126/1815] [espidf] Filter noisy 'git rev-parse' errors when .git is stripped (#16521) --- esphome/espidf/runner.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/esphome/espidf/runner.py b/esphome/espidf/runner.py index 65df37c7b2..da3f77cdd3 100644 --- a/esphome/espidf/runner.py +++ b/esphome/espidf/runner.py @@ -66,6 +66,12 @@ FILTER_IDF_LINES: list[str] = [ # Drop the blank line rich emits after the note so the build log # doesn't end with an orphan gap before ESPHome's own status lines. r"\s*$", + # ESP-IDF shells out to ``git rev-parse`` to embed a commit hash; + # esphome-libs strips ``.git`` from the tarball so those probes fail + # noisily without affecting the build. + r"-- git rev-parse returned ", + r"fatal: not a git repository", + r"Stopping at filesystem boundary", ] From f2bfe5cd178d297ec4e1c99d61e9abf45feb5409 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 11:36:27 -0400 Subject: [PATCH 0127/1815] [espidf] Fix tarfile extract crashing on Python 3.11 with None mode (#16530) --- esphome/espidf/framework.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 3dcb9dd242..f20391c6a5 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -549,11 +549,11 @@ def _tar_extract_all( if not (mode & stat.S_IXUSR): mode &= ~(stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) mode |= stat.S_IRUSR | stat.S_IWUSR - elif member.isdir() or member.issym(): - # Ignore mode for directories & symlinks - mode = None - else: - # Block special files + elif not (member.isdir() or member.issym()): + # Block special files. Directories and symlinks keep + # their masked-original mode — passing None here would + # crash tarfile.extract on Python <3.12 (its chmod + # path calls os.chmod unconditionally). continue member.mode = mode From b619e3e8c77ec710af69726013218ce3b2b1fa1d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 11:37:10 -0400 Subject: [PATCH 0128/1815] [espidf] Write version.txt after extract so bootloader shows the real version (#16532) --- esphome/espidf/framework.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index f20391c6a5..a967f7c5af 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -783,6 +783,34 @@ def download_from_mirrors( return None +def _write_idf_version_txt(framework_path: Path, version: str) -> None: + """Write /version.txt if missing. + + IDF's build.cmake picks the version it embeds in the firmware (and + stamps onto the bootloader) in this order: ``${IDF_PATH}/version.txt`` + if present, else ``git describe`` against IDF_PATH, else the + ``IDF_VERSION_MAJOR/MINOR/PATCH`` triplet from ``tools/cmake/version.cmake``. + On a clean esphome-libs tarball ``.git`` is fully stripped, so + git_describe returns ``HEAD-HASH-NOTFOUND`` (falsy) and the triplet + wins -- correct by luck. But a *partial* ``.git`` (e.g. a custom + framework.source pointed at a real git URL where build artifacts + mark the tree dirty) makes git_describe return ``-dirty``, + which is what then gets baked into the bootloader. Dropping + version.txt forces the right answer regardless. + """ + version_txt = framework_path / "version.txt" + if version_txt.exists(): + return + try: + version_txt.write_text(f"v{version}\n", encoding="utf-8") + except OSError as e: + _LOGGER.warning( + "Could not write %s (%s); bootloader version string may be incorrect.", + version_txt, + e, + ) + + def _check_esphome_idf_framework_install( version: str, targets: list[str], @@ -864,6 +892,11 @@ def _check_esphome_idf_framework_install( archive_extract_all(tmp.file, framework_path, progress_header="Extracting") extracted_marker.touch() + # Idempotent post-extract patch: written every invocation so a build + # dir extracted before this fix gets the file too, without forcing a + # clean. Skips when version.txt already exists. + _write_idf_version_txt(framework_path, version) + # 3. Check if the framework tools are the same and correctly installed if not install: install = True From e0076cb1a8b60657e24332844420ef321f25147c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 11:37:46 -0400 Subject: [PATCH 0129/1815] [core] Persist & restore CORE.toolchain through StorageJSON (#16531) --- esphome/storage_json.py | 20 ++++++++ tests/unit_tests/test_storage_json.py | 73 ++++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index e481827080..7f8885ba5f 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -14,6 +14,7 @@ from esphome.const import ( KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, + Toolchain, ) from esphome.core import CORE from esphome.helpers import write_file_if_changed @@ -98,6 +99,7 @@ class StorageJSON: no_mdns: bool, framework: str | None = None, core_platform: str | None = None, + toolchain: str | None = None, ) -> None: # Version of the storage JSON schema assert storage_version is None or isinstance(storage_version, int) @@ -134,6 +136,8 @@ class StorageJSON: self.framework = framework # The core platform of this firmware. Like "esp32", "rp2040", "host" etc. self.core_platform = core_platform + # The toolchain used for the build ("platformio" / "esp-idf") + self.toolchain = toolchain def as_dict(self): return { @@ -153,6 +157,7 @@ class StorageJSON: "no_mdns": self.no_mdns, "framework": self.framework, "core_platform": self.core_platform, + "toolchain": self.toolchain, } def to_json(self): @@ -189,6 +194,7 @@ class StorageJSON: ), framework=esph.target_framework, core_platform=esph.target_platform, + toolchain=esph.toolchain.value if esph.toolchain is not None else None, ) @staticmethod @@ -236,6 +242,7 @@ class StorageJSON: no_mdns = storage.get("no_mdns", False) framework = storage.get("framework") core_platform = storage.get("core_platform") + toolchain = storage.get("toolchain") return StorageJSON( storage_version, name, @@ -253,6 +260,7 @@ class StorageJSON: no_mdns, framework, core_platform, + toolchain, ) @staticmethod @@ -273,6 +281,18 @@ class StorageJSON: """ CORE.name = self.name CORE.build_path = self.build_path + # Restore toolchain so upload/logs picks the right firmware_bin path. + # An unknown value (corrupt sidecar, or written by a newer ESPHome) + # just leaves CORE.toolchain None — the fallback then picks PlatformIO. + if self.toolchain and CORE.toolchain is None: + try: + CORE.toolchain = Toolchain(self.toolchain) + except ValueError: + _LOGGER.debug( + "Ignoring unknown toolchain %r from %s", + self.toolchain, + storage_path(), + ) target_platform = self.core_platform or self.target_platform.lower() CORE.data[KEY_CORE] = { KEY_TARGET_PLATFORM: target_platform, diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index a3a38960e7..ea37492cf4 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -9,7 +9,7 @@ from unittest.mock import MagicMock, Mock, patch import pytest from esphome import storage_json -from esphome.const import CONF_DISABLED, CONF_MDNS +from esphome.const import CONF_DISABLED, CONF_MDNS, Toolchain from esphome.core import CORE @@ -308,6 +308,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: mock_core.loaded_platforms = {"sensor"} mock_core.config = {CONF_MDNS: {CONF_DISABLED: True}} mock_core.target_framework = "esp-idf" + mock_core.toolchain = Toolchain.ESP_IDF with patch("esphome.components.esp32.get_esp32_variant") as mock_variant: mock_variant.return_value = "ESP32-C3" @@ -327,6 +328,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: assert result.no_mdns is True assert result.framework == "esp-idf" assert result.core_platform == "esp32" + assert result.toolchain == "esp-idf" def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: @@ -345,10 +347,12 @@ def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: mock_core.loaded_platforms = set() mock_core.config = {} # No MDNS config means enabled mock_core.target_framework = "arduino" + mock_core.toolchain = None result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None) assert result.no_mdns is False + assert result.toolchain is None def test_storage_json_load_valid_file(tmp_path: Path) -> None: @@ -470,6 +474,73 @@ def test_storage_json_equality() -> None: assert storage1 != "not a storage object" +def _make_storage_with_toolchain( + toolchain: str | None, +) -> storage_json.StorageJSON: + return storage_json.StorageJSON( + storage_version=1, + name="dev", + friendly_name=None, + comment=None, + esphome_version="2024.1.0", + src_version=1, + address="dev.local", + web_port=None, + target_platform="ESP32", + build_path=Path("/build"), + firmware_bin_path=Path("/build/firmware.bin"), + loaded_integrations=set(), + loaded_platforms=set(), + no_mdns=False, + framework="esp-idf", + core_platform="esp32", + toolchain=toolchain, + ) + + +def test_storage_json_toolchain_round_trip(setup_core: Path) -> None: + """Sidecar toolchain survives save -> load -> apply_to_core.""" + storage = _make_storage_with_toolchain("esp-idf") + path = setup_core / "storage.json" + path.write_text(storage.to_json()) + + # Serialization key is stable -- device-builder relies on it. + assert json.loads(path.read_text())["toolchain"] == "esp-idf" + + loaded = storage_json.StorageJSON.load(path) + assert loaded is not None + assert loaded.toolchain == "esp-idf" + + CORE.toolchain = None + with patch("esphome.components.esp32.get_esp32_variant"): + loaded.apply_to_core() + assert CORE.toolchain == Toolchain.ESP_IDF + + +def test_storage_json_apply_to_core_preserves_cli_toolchain( + setup_core: Path, +) -> None: + """A CLI-set CORE.toolchain wins over the sidecar value.""" + loaded = _make_storage_with_toolchain("esp-idf") + + CORE.toolchain = Toolchain.PLATFORMIO + with patch("esphome.components.esp32.get_esp32_variant"): + loaded.apply_to_core() + assert CORE.toolchain == Toolchain.PLATFORMIO + + +def test_storage_json_apply_to_core_ignores_unknown_toolchain( + setup_core: Path, +) -> None: + """Unknown enum values (corrupt sidecar / newer ESPHome) fall through to None.""" + loaded = _make_storage_with_toolchain("gcc") + + CORE.toolchain = None + with patch("esphome.components.esp32.get_esp32_variant"): + loaded.apply_to_core() + assert CORE.toolchain is None + + def test_esphome_storage_json_as_dict() -> None: """Test EsphomeStorageJSON.as_dict returns correct dictionary.""" storage = storage_json.EsphomeStorageJSON( From 233a60f1062a6d344c3d19f030658a162e6940d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 May 2026 10:53:34 -0500 Subject: [PATCH 0130/1815] [ci] Pin uv version in setup-uv to fix Windows manifest fetch flake (#16534) --- .github/actions/restore-python/action.yml | 4 ++++ .github/workflows/ci-api-proto.yml | 4 ++++ .github/workflows/ci.yml | 12 ++++++++++++ .github/workflows/sync-device-classes.yml | 4 ++++ 4 files changed, 24 insertions(+) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 751f9ecf58..03b4803860 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -35,6 +35,10 @@ runs: uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true + # Pin uv version so the action does not have to fetch the + # manifest from raw.githubusercontent.com on every cache + # miss; that fetch flakes on Windows runners. + version: "0.11.15" - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os != 'Windows' shell: bash diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 1dc0ccb7fe..675bbe9d2c 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -32,6 +32,10 @@ jobs: uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true + # Pin uv version so the action does not have to fetch the + # manifest from raw.githubusercontent.com on every cache + # miss; that fetch flakes on Windows runners. + version: "0.11.15" - name: Install apt dependencies run: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbbb06c86c..43b03aec85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,10 @@ jobs: uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true + # Pin uv version so the action does not have to fetch the + # manifest from raw.githubusercontent.com on every cache + # miss; that fetch flakes on Windows runners. + version: "0.11.15" - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' run: | @@ -175,6 +179,10 @@ jobs: uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true + # Pin uv version so the action does not have to fetch the + # manifest from raw.githubusercontent.com on every cache + # miss; that fetch flakes on Windows runners. + version: "0.11.15" - name: Install device-builder + esphome from PR # Install device-builder with its esphome + test extras # first so its pinned versions of pytest/etc. land, then @@ -365,6 +373,10 @@ jobs: uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true + # Pin uv version so the action does not have to fetch the + # manifest from raw.githubusercontent.com on every cache + # miss; that fetch flakes on Windows runners. + version: "0.11.15" - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' run: | diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 23a63c5d8a..84be3c8e22 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -50,6 +50,10 @@ jobs: uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true + # Pin uv version so the action does not have to fetch the + # manifest from raw.githubusercontent.com on every cache + # miss; that fetch flakes on Windows runners. + version: "0.11.15" - name: Install Home Assistant run: | From 01494f7431ad2c1056d1eff0c0b2fd6cc7d50227 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 21 May 2026 11:57:32 -0400 Subject: [PATCH 0131/1815] [audio] Bump esp-audio-libs to v3.1.0 (#16519) --- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 13b379ba3a..c9775ab601 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -335,7 +335,7 @@ async def to_code(config): add_idf_component( name="esphome/esp-audio-libs", - ref="3.0.0", + ref="3.1.0", ) data = _get_data() diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 42d0d5de6b..44c63e46cd 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -2,7 +2,7 @@ dependencies: bblanchon/arduinojson: version: "7.4.2" esphome/esp-audio-libs: - version: 3.0.0 + version: 3.1.0 esphome/esp-micro-speech-features: version: 1.2.3 esphome/micro-decoder: From 750ae56778699dd727da51f5363592d543f97c1c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 12:05:27 -0400 Subject: [PATCH 0132/1815] [espidf] Backport ninja linux-arm64 entry into tools.json on aarch64 hosts (#16527) --- esphome/espidf/framework.py | 76 ++++++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index a967f7c5af..079c97cc98 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -7,6 +7,7 @@ import json import logging import os from pathlib import Path +import platform import shutil import subprocess import sys @@ -17,7 +18,7 @@ import requests from esphome.config_validation import Version from esphome.core import CORE -from esphome.helpers import ProgressBar, get_str_env, rmtree +from esphome.helpers import ProgressBar, get_str_env, rmtree, write_file_if_changed PathType = str | os.PathLike @@ -811,6 +812,74 @@ def _write_idf_version_txt(framework_path: Path, version: str) -> None: ) +# Backport of espressif/esp-idf#18272: every ESPHome-supported IDF release +# through v6.0 ships a tools.json whose ninja 1.12.1 entry has no +# ``linux-arm64`` source. ``idf_tools.py`` then either fails to find a +# matching binary or grabs the x86_64 one, which can't execute on +# aarch64. cmake is already populated across the same release range; we +# only need to inject ninja. Values lifted verbatim from the IDF v6.0.1 +# tools.json where the fix landed natively. +_NINJA_ARM64_BACKPORT: dict[str, dict[str, str | int]] = { + "1.12.1": { + "rename_dist": "ninja-linux-arm64-v1.12.1.zip", + "sha256": "5c25c6570b0155e95fce5918cb95f1ad9870df5768653afe128db822301a05a1", + "size": 121787, + "url": "https://github.com/ninja-build/ninja/releases/download/v1.12.1/ninja-linux-aarch64.zip", + }, +} + + +def _patch_tools_json_for_linux_arm64(framework_path: Path) -> None: + """Inject ninja linux-arm64 entries into the framework's tools.json on aarch64. + + Idempotent: a tools.json that already has the entry, or a host that + isn't aarch64, is a no-op. Applied unconditionally on every install + check so a build dir extracted before the backport got fixed up + without forcing a clean. + """ + if platform.machine() != "aarch64": + return + + tools_json = framework_path / "tools" / "tools.json" + if not tools_json.is_file(): + return + + try: + with open(tools_json, encoding="utf-8") as f: + data = json.load(f) + except (json.JSONDecodeError, OSError) as e: + _LOGGER.warning( + "Could not parse %s for linux-arm64 backport (%s); " + "skipping. A clean reinstall of the framework directory " + "may be needed.", + tools_json, + e, + ) + return + + changed = False + for tool in data.get("tools", []): + if tool.get("name") != "ninja": + continue + for ver in tool.get("versions", []): + entry = _NINJA_ARM64_BACKPORT.get(ver.get("name")) + if entry is None or ver.get("linux-arm64"): + continue + ver["linux-arm64"] = entry + changed = True + + if changed: + # write_file_if_changed stages a tempfile in the destination dir + # and atomically replaces — safe against mid-write interruption + # and concurrent invocations. + write_file_if_changed(tools_json, json.dumps(data, indent=2) + "\n") + _LOGGER.info( + "Patched %s to add ninja linux-arm64 download " + "(espressif/esp-idf#18272 backport).", + tools_json, + ) + + def _check_esphome_idf_framework_install( version: str, targets: list[str], @@ -897,6 +966,11 @@ def _check_esphome_idf_framework_install( # clean. Skips when version.txt already exists. _write_idf_version_txt(framework_path, version) + # Apply the ninja linux-arm64 backport on every invocation, not just on + # fresh extracts — idempotent and cheap, and lets a build dir carrying + # a pre-patch tools.json get fixed up without forcing a clean. + _patch_tools_json_for_linux_arm64(framework_path) + # 3. Check if the framework tools are the same and correctly installed if not install: install = True From 3719ea740a577659ec8e2837372e3f7cc553f1ef Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 14:01:19 -0400 Subject: [PATCH 0133/1815] [espidf] Default to remote HEAD when cg.add_library URL has no #ref (#16535) --- esphome/espidf/component.py | 15 ++++++++------- tests/unit_tests/test_espidf_component.py | 16 +++++++++++++--- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index b1352f7791..7d9874ad5f 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -93,7 +93,7 @@ class URLSource(Source): class GitSource(Source): - def __init__(self, url: str, ref: str): + def __init__(self, url: str, ref: str | None): self.url = url self.ref = ref @@ -109,7 +109,7 @@ class GitSource(Source): return path def __str__(self): - return f"{self.url}#{self.ref}" + return f"{self.url}#{self.ref}" if self.ref else self.url class InvalidIDFComponent(Exception): @@ -352,7 +352,6 @@ def _convert_library_to_component(library: Library) -> IDFComponent: IDFComponent: The resolved component with name, version, and URL Raises: - ValueError: If a repository URL is missing a reference (#) RuntimeError: If no artifact can be found for the library """ name = None @@ -362,10 +361,11 @@ def _convert_library_to_component(library: Library) -> IDFComponent: # Repository is provided directly if library.repository: # Parse repository URL: path becomes the component name, fragment - # becomes the git ref stored on GitSource. + # (if any) becomes the git ref stored on GitSource. A missing + # fragment is fine -- clone_or_update leaves the depth-1 clone on + # the remote's default branch, matching PIO's lib_deps behavior + # and external_components handling. split_result = urlsplit(library.repository) - if not split_result.fragment.strip(): - raise ValueError(f"Missing ref in URL {library.repository}") # Sanitize name name = str(split_result.path).strip("/") @@ -377,7 +377,8 @@ def _convert_library_to_component(library: Library) -> IDFComponent: version = "*" repository = urlunsplit(split_result._replace(fragment="")) - source = GitSource(str(repository), split_result.fragment) + ref = split_result.fragment.strip() or None + source = GitSource(str(repository), ref) # Version is provided - resolve using PlatformIO registry elif library.version: diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 373432f7d2..c4a419d1a2 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -436,11 +436,21 @@ def test_convert_library_with_branch_ref(): assert result.source.ref == "some-branch" -def test_convert_library_missing_ref(): +def test_convert_library_missing_ref_uses_default_branch(): + """A bare URL with no #ref clones the remote's default branch. + + Matches PIO's lib_deps behavior and external_components handling -- + git.clone_or_update with ref=None leaves the depth-1 clone on + whatever branch the remote HEAD points at. + """ lib = Library("name", None, "https://github.com/foo/bar.git") - with pytest.raises(ValueError): - _convert_library_to_component(lib) + result = _convert_library_to_component(lib) + + assert result.name == "foo/bar" + assert result.version == "*" + assert isinstance(result.source, GitSource) + assert result.source.ref is None def test_convert_library_registry(monkeypatch): From 56fd77e4c8480551525527de8196de17c64d05ed Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 14:01:54 -0400 Subject: [PATCH 0134/1815] [espidf] Honor the dict shorthand for library.json dependencies (#16537) --- esphome/espidf/component.py | 20 ++++ tests/unit_tests/test_espidf_component.py | 110 ++++++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 7d9874ad5f..a452a3f34a 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -656,6 +656,26 @@ def _process_dependencies(component: IDFComponent): if not dependencies: return + # PIO's library.json accepts both the list-of-dicts form and the + # shorthand dict form ``{"owner/Name": "version_spec"}``. Normalize + # the dict form so the loop below sees a uniform list. Iterating a + # dict gives string keys, which would silently fail the + # ``"name" in dependency`` substring check and skip every entry. + if isinstance(dependencies, dict): + normalized = [] + for raw_name, spec in dependencies.items(): + if "/" in raw_name: + owner, pkgname = raw_name.split("/", 1) + else: + owner, pkgname = None, raw_name + entry = {"name": pkgname, "owner": owner} + if isinstance(spec, dict): + entry.update(spec) + else: + entry["version"] = spec + normalized.append(entry) + dependencies = normalized + _LOGGER.info("Processing %s@%s component dependencies...", name, version) for dependency in dependencies: # Validate dependency structure diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index c4a419d1a2..7d6c861ffd 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -505,3 +505,113 @@ def test_process_dependencies_skips_invalid(tmp_component): _process_dependencies(tmp_component) assert tmp_component.dependencies == [] + + +def test_process_dependencies_dict_form(tmp_component, monkeypatch): + """PIO library.json shorthand ``{"owner/Name": "version"}`` is honored. + + Iterating a dict gives string keys, which would silently fail the + ``"name" in dependency`` substring check. Normalize to list-of-dicts + first so the dict form (used by e.g. tesla-ble for its nanopb dep) + is treated the same as the verbose list form. + """ + captured: list[Library] = [] + + def fake_generate(library): + captured.append(library) + return IDFComponent( + library.name, library.version, source=URLSource("http://dummy.com") + ) + + tmp_component.data = { + "dependencies": { + "nanopb/Nanopb": "^0.4.91", + "BareName": "1.2.3", + } + } + monkeypatch.setattr( + esphome.espidf.component, "_generate_idf_component", fake_generate + ) + monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None) + + _process_dependencies(tmp_component) + + assert len(tmp_component.dependencies) == 2 + names = sorted(lib.name for lib in captured) + versions = sorted(lib.version for lib in captured) + assert names == ["BareName", "nanopb/Nanopb"] + assert versions == ["1.2.3", "^0.4.91"] + + +def test_process_dependencies_dict_form_with_url_value(tmp_component, monkeypatch): + """A dict-value that's a URL gets routed to ``repository`` like the list form.""" + captured: list[Library] = [] + + def fake_generate(library): + captured.append(library) + return IDFComponent(library.name, "*", source=URLSource("http://dummy.com")) + + tmp_component.data = { + "dependencies": { + "foo/Bar": "https://github.com/foo/bar.git#main", + } + } + monkeypatch.setattr( + esphome.espidf.component, "_generate_idf_component", fake_generate + ) + monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None) + + _process_dependencies(tmp_component) + + assert len(captured) == 1 + assert captured[0].name == "foo/Bar" + assert captured[0].version is None + assert captured[0].repository == "https://github.com/foo/bar.git#main" + + +def test_process_dependencies_dict_form_with_nested_spec(tmp_component, monkeypatch): + """A dict-value that's itself a dict is merged into the entry. + + PIO's library.json allows ``{"owner/Name": {"version": "...", ...}}`` + for entries that need fields beyond just a version (platforms, + frameworks, etc.). The extra fields flow into _check_library_data + via the entry merge. + """ + captured: list[Library] = [] + checked: list[dict] = [] + + def fake_generate(library): + captured.append(library) + return IDFComponent( + library.name, library.version, source=URLSource("http://dummy.com") + ) + + tmp_component.data = { + "dependencies": { + "nanopb/Nanopb": {"version": "^0.4.91", "platforms": "espidf"}, + } + } + monkeypatch.setattr( + esphome.espidf.component, "_generate_idf_component", fake_generate + ) + monkeypatch.setattr( + esphome.espidf.component, + "_check_library_data", + checked.append, + ) + + _process_dependencies(tmp_component) + + assert len(captured) == 1 + assert captured[0].name == "nanopb/Nanopb" + assert captured[0].version == "^0.4.91" + # Extra spec fields reach _check_library_data so platform/framework + # gating still applies. + assert checked == [ + { + "name": "Nanopb", + "owner": "nanopb", + "version": "^0.4.91", + "platforms": "espidf", + } + ] From d2bda0a402f309418a0ba82e542632e12e8035d6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 14:03:55 -0400 Subject: [PATCH 0135/1815] [esp32] Defer esp_panic_handler wrap so arduino-esp32 IDF component skips it (#16538) --- esphome/components/esp32/__init__.py | 35 +++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 09d2e89bd3..274cc6fdb3 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -113,6 +113,7 @@ ARDUINO_FRAMEWORK_NAME = "framework-arduinoespressif32" ARDUINO_FRAMEWORK_PKG = f"pioarduino/{ARDUINO_FRAMEWORK_NAME}" ARDUINO_LIBS_NAME = f"{ARDUINO_FRAMEWORK_NAME}-libs" ARDUINO_LIBS_PKG = f"pioarduino/{ARDUINO_LIBS_NAME}" +ARDUINO_ESP32_COMPONENT_NAME = "espressif/arduino-esp32" LOG_LEVELS_IDF = [ "NONE", @@ -1743,6 +1744,31 @@ async def _add_yaml_idf_components(components: list[ConfigType]): ) +@coroutine_with_priority(CoroPriority.FINAL - 1) +async def _finalize_arduino_aware_flags(): + """Build flags that depend on whether arduino-esp32 is linked in. + + Scheduler runs lower priority values later, so ``FINAL - 1`` fires + after every ``FINAL`` job (incl. ``_add_yaml_idf_components``) -- + by then ``KEY_COMPONENTS`` is fully populated. + + - Skip our esp_panic_handler wrap when Arduino is linked; Arduino + wraps the same symbol and the linker errors on the duplicate. + - Define USE_ARDUINO in the hybrid esp-idf+arduino-esp32-component + case so ESPHome's ``#ifdef USE_ARDUINO`` paths light up. The + framework=arduino branch already adds it inline in to_code. + """ + arduino_linked = ( + CORE.using_arduino + or ARDUINO_ESP32_COMPONENT_NAME in CORE.data[KEY_ESP32][KEY_COMPONENTS] + ) + if not arduino_linked: + cg.add_build_flag("-Wl,--wrap=esp_panic_handler") + cg.add_define("USE_ESP32_CRASH_HANDLER") + elif not CORE.using_arduino: + cg.add_build_flag("-DUSE_ARDUINO") + + async def to_code(config): framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] conf = config[CONF_FRAMEWORK] @@ -1802,11 +1828,8 @@ async def to_code(config): cg.add_build_flag("-DUSE_ESP32") cg.add_define("USE_NATIVE_64BIT_TIME") cg.add_build_flag("-Wl,-z,noexecstack") - # Arduino already wraps esp_panic_handler for its own backtrace handler, - # so only add our wrap when using ESP-IDF framework to avoid linker conflicts. - if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: - cg.add_build_flag("-Wl,--wrap=esp_panic_handler") - cg.add_define("USE_ESP32_CRASH_HANDLER") + # Deferred so KEY_COMPONENTS is fully populated -- see the coroutine. + CORE.add_job(_finalize_arduino_aware_flags) cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) variant = config[CONF_VARIANT] cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}") @@ -2567,7 +2590,7 @@ def _write_idf_component_yml(): if CORE.using_toolchain_esp_idf: add_idf_component( - name="espressif/arduino-esp32", + name=ARDUINO_ESP32_COMPONENT_NAME, ref=str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]), ) From 1ea95264bd91d073cfba8d0ce7bca0ec8fa96062 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 14:08:09 -0400 Subject: [PATCH 0136/1815] [tuya] Restore null guard on status_pin lost in #16353 (#16539) --- esphome/components/tuya/tuya.cpp | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index fd14844908..3058d82cc4 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -206,15 +206,17 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff if (this->status_pin_reported_ != -1) { this->init_state_ = TuyaInitState::INIT_DATAPOINT; this->send_empty_command_(TuyaCommandType::DATAPOINT_QUERY); - bool is_pin_equals = - this->status_pin_ != nullptr && this->status_pin_->get_pin() == this->status_pin_reported_; - // Configure status pin toggling (if reported and configured) or WIFI_STATE periodic send - if (!is_pin_equals) { - ESP_LOGW(TAG, "Supplied status_pin does not equals the reported pin %i. Using supplied pin anyway.", + if (this->status_pin_ != nullptr) { + if (this->status_pin_->get_pin() != this->status_pin_reported_) { + ESP_LOGW(TAG, "Supplied status_pin does not equal the reported pin %i. Using supplied pin anyway.", + this->status_pin_reported_); + } + ESP_LOGV(TAG, "Configured status pin %i", this->status_pin_->get_pin()); + this->set_interval("wifi", 1000, [this] { this->set_status_pin_(); }); + } else { + ESP_LOGW(TAG, "MCU reported status_pin %i but no status_pin was configured; running in limited mode.", this->status_pin_reported_); } - ESP_LOGV(TAG, "Configured status pin %i", this->status_pin_->get_pin()); - this->set_interval("wifi", 1000, [this] { this->set_status_pin_(); }); } else { this->init_state_ = TuyaInitState::INIT_WIFI; ESP_LOGV(TAG, "Configured WIFI_STATE periodic send"); From 96eced0378882f9b2d2ebb3810d87cad255bf7f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 May 2026 15:42:57 -0500 Subject: [PATCH 0137/1815] [api] Break api_connection/api_server include cycle to drop custom unique_ptr deleter (#16542) --- esphome/components/api/api_connection.cpp | 1 + esphome/components/api/api_connection.h | 51 ++++-------------- .../components/api/api_connection_buffer.h | 54 +++++++++++++++++++ esphome/components/api/api_server.cpp | 5 -- esphome/components/api/api_server.h | 14 ++--- .../bluetooth_proxy/bluetooth_proxy.cpp | 1 + 6 files changed, 71 insertions(+), 55 deletions(-) create mode 100644 esphome/components/api/api_connection_buffer.h diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index b6f4aa2141..f2bf3752fa 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1,5 +1,6 @@ #include "api_connection.h" #ifdef USE_API +#include "api_connection_buffer.h" // for encode_to_buffer / get_batch_delay_ms_ inlines #ifdef USE_API_NOISE #include "api_frame_helper_noise.h" #endif diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 4165b7f3a2..804cd9ddd1 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -11,7 +11,8 @@ #endif #include "api_pb2.h" #include "api_pb2_service.h" -#include "api_server.h" +#include "list_entities.h" +#include "subscribe_state.h" #include "esphome/core/application.h" #include "esphome/core/component.h" #ifdef USE_ESP32_CRASH_HANDLER @@ -36,6 +37,9 @@ class ComponentIterator; namespace esphome::api { +// Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h. +class APIServer; + // Keepalive timeout in milliseconds static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000; // Maximum number of entities to process in a single batch during initial state/info sending @@ -411,44 +415,10 @@ class APIConnection final : public APIServerConnectionBase { // Non-template buffer management for send_message bool send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn, const void *msg); - // Core batch encoding logic. Computes header size, checks fit, resizes buffer, encodes. - // ALWAYS_INLINE so the compiler can devirtualize encode_fn at hot call sites. - static inline uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn, - const void *msg, APIConnection *conn, - uint32_t remaining_size) { -#ifdef HAS_PROTO_MESSAGE_DUMP - if (conn->flags_.log_only_mode) { - auto *proto_msg = static_cast(msg); - DumpBuffer dump_buf; - conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf)); - return 1; - } -#endif - const uint8_t footer_size = conn->helper_->frame_footer_size(); - - // First message uses max padding (already in buffer), subsequent use exact header size - size_t to_add; - if (conn->flags_.batch_first_message) { - conn->flags_.batch_first_message = false; - conn->batch_header_size_ = conn->helper_->frame_header_padding(); - to_add = calculated_size; - } else { - conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_); - to_add = calculated_size + conn->batch_header_size_ + footer_size; - } - - // Check if it fits (using actual header size, not max padding) - uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size; - if (total_calculated_size > remaining_size) - return 0; - - auto &shared_buf = conn->parent_->get_shared_buffer_ref(); - shared_buf.resize(shared_buf.size() + to_add); - ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size}; - encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); - - return total_calculated_size; - } + // Core batch encoding logic. ALWAYS_INLINE so encode_fn devirtualizes at hot call sites. + // Defined in api_connection_buffer.h (needs APIServer complete). + static uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn, + const void *msg, APIConnection *conn, uint32_t remaining_size); // Noinline version of encode_to_buffer for cold paths (entity info, zero-payload messages). // All cold callers share this single copy instead of each getting an ALWAYS_INLINE expansion. @@ -792,7 +762,8 @@ class APIConnection final : public APIServerConnectionBase { // Read by process_batch_multi_ to pass into MessageInfo. uint8_t batch_header_size_{0}; - uint32_t get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); } + // Defined in api_connection_buffer.h (needs APIServer complete). + uint32_t get_batch_delay_ms_() const; // Message will use 8 more bytes than the minimum size, and typical // MTU is 1500. Sometimes users will see as low as 1460 MTU. // If its IPv6 the header is 40 bytes, and if its IPv4 diff --git a/esphome/components/api/api_connection_buffer.h b/esphome/components/api/api_connection_buffer.h new file mode 100644 index 0000000000..1dd8a162e4 --- /dev/null +++ b/esphome/components/api/api_connection_buffer.h @@ -0,0 +1,54 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_API + +// Inline APIConnection methods that need APIServer complete. Include this +// instead of api_connection.h when calling encode_to_buffer or get_batch_delay_ms_. + +#include "api_connection.h" +#include "api_server.h" + +namespace esphome::api { + +inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t calculated_size, + MessageEncodeFn encode_fn, const void *msg, + APIConnection *conn, uint32_t remaining_size) { +#ifdef HAS_PROTO_MESSAGE_DUMP + if (conn->flags_.log_only_mode) { + auto *proto_msg = static_cast(msg); + DumpBuffer dump_buf; + conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf)); + return 1; + } +#endif + const uint8_t footer_size = conn->helper_->frame_footer_size(); + + // First message uses max padding (already in buffer), subsequent use exact header size + size_t to_add; + if (conn->flags_.batch_first_message) { + conn->flags_.batch_first_message = false; + conn->batch_header_size_ = conn->helper_->frame_header_padding(); + to_add = calculated_size; + } else { + conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_); + to_add = calculated_size + conn->batch_header_size_ + footer_size; + } + + // Check if it fits (using actual header size, not max padding) + uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size; + if (total_calculated_size > remaining_size) + return 0; + + auto &shared_buf = conn->parent_->get_shared_buffer_ref(); + shared_buf.resize(shared_buf.size() + to_add); + ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size}; + encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); + + return total_calculated_size; +} + +inline uint32_t APIConnection::get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); } + +} // namespace esphome::api +#endif diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 6c26c4e187..c30bd2e612 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -30,11 +30,6 @@ 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 6b575e536d..fbc8115091 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -3,6 +3,8 @@ #include "esphome/core/defines.h" #ifdef USE_API #include "api_buffer.h" +// Must precede clients_ so APIConnection is complete for default_delete (libc++). +#include "api_connection.h" #include "api_noise_context.h" #include "api_pb2.h" #include "api_pb2_service.h" @@ -12,8 +14,6 @@ #include "esphome/core/controller.h" #include "esphome/core/log.h" #include "esphome/core/string_ref.h" -#include "list_entities.h" -#include "subscribe_state.h" #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" #endif @@ -191,15 +191,9 @@ class APIServer final : public Component, bool is_connected_with_state_subscription() const; // 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 + // 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. - // 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; + using APIConnectionPtr = std::unique_ptr; class ActiveClientsView { const APIConnectionPtr *begin_; const APIConnectionPtr *end_; diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index c3461f9c51..ca30aab943 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -1,5 +1,6 @@ #include "bluetooth_proxy.h" +#include "esphome/components/api/api_server.h" #include "esphome/core/log.h" #include "esphome/core/macros.h" #include "esphome/core/application.h" From 38b8b41ccc9b4c4d1a963ae98b6308d9a34fe988 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 18:03:07 -0400 Subject: [PATCH 0138/1815] [sx126x] Assert NSS before wait_busy so commands wake the chip from sleep (#16546) --- esphome/components/sx126x/sx126x.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index 6e6857fadb..83afeac50a 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -30,8 +30,8 @@ static constexpr uint8_t OCP_140MA = 0x38; // 140 mA max current static constexpr float LOW_DATA_RATE_OPTIMIZE_THRESHOLD = 16.38f; // 16.38 ms uint8_t SX126x::read_fifo_(uint8_t offset, std::vector &packet) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->transfer_byte(RADIO_READ_BUFFER); this->transfer_byte(offset); uint8_t status = this->transfer_byte(0x00); @@ -43,8 +43,8 @@ uint8_t SX126x::read_fifo_(uint8_t offset, std::vector &packet) { } void SX126x::write_fifo_(uint8_t offset, const std::vector &packet) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->transfer_byte(RADIO_WRITE_BUFFER); this->transfer_byte(offset); for (const uint8_t &byte : packet) { @@ -55,8 +55,8 @@ void SX126x::write_fifo_(uint8_t offset, const std::vector &packet) { } uint8_t SX126x::read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->transfer_byte(opcode); uint8_t status = this->transfer_byte(0x00); for (int32_t i = 0; i < size; i++) { @@ -67,8 +67,8 @@ uint8_t SX126x::read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) { } void SX126x::write_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->transfer_byte(opcode); for (int32_t i = 0; i < size; i++) { this->transfer_byte(data[i]); @@ -78,8 +78,8 @@ void SX126x::write_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) { } void SX126x::read_register_(uint16_t reg, uint8_t *data, uint8_t size) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->write_byte(RADIO_READ_REGISTER); this->write_byte((reg >> 8) & 0xFF); this->write_byte((reg >> 0) & 0xFF); @@ -91,8 +91,8 @@ void SX126x::read_register_(uint16_t reg, uint8_t *data, uint8_t size) { } void SX126x::write_register_(uint16_t reg, uint8_t *data, uint8_t size) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->write_byte(RADIO_WRITE_REGISTER); this->write_byte((reg >> 8) & 0xFF); this->write_byte((reg >> 0) & 0xFF); From aea1e4d136cf2d35a3e694f9666bfab67dacd797 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 May 2026 17:05:17 -0500 Subject: [PATCH 0139/1815] [core] Refresh compiled config cache after upload/logs fallback (#16548) --- esphome/__main__.py | 15 +++- tests/unit_tests/test_compiled_config.py | 100 +++++++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 16a05ad552..07bbd89358 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2449,7 +2449,10 @@ def run_esphome(argv): # Skipped when -s overrides are passed, since the cache was written # against the previous substitution set. config: ConfigType | None = None - if args.command in ("upload", "logs") and not command_line_substitutions: + cache_eligible = ( + args.command in ("upload", "logs") and not command_line_substitutions + ) + if cache_eligible: from esphome.compiled_config import load_compiled_config config = load_compiled_config(conf_path) @@ -2464,6 +2467,16 @@ def run_esphome(argv): command_line_substitutions, skip_external_update=skip_external, ) + # Refresh the cache so the next upload/logs hits the fast path + # instead of re-running read_config. Skip when the storage + # sidecar is absent (no compile has run): the cache would + # never be loaded back, so writing secrets to disk is wasted. + if cache_eligible and config is not None: + from esphome.compiled_config import save_compiled_config + from esphome.storage_json import ext_storage_path + + if ext_storage_path(conf_path.name).exists(): + save_compiled_config(config) if config is None: return 2 CORE.config = config diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index 8c9cfa8101..e12107152b 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -253,6 +253,106 @@ def test_run_esphome_upload_and_logs_fall_back_when_no_cache( mock_read.assert_called_once() +def test_run_esphome_upload_does_not_refresh_cache_without_sidecar( + tmp_path: Path, +) -> None: + """Without a StorageJSON sidecar (no compile has run), the fallback + skips the cache write -- load_compiled_config requires the sidecar, + so writing the rendered (secret-resolved) YAML would be inert and + leak secrets to disk for nothing.""" + yaml_path = tmp_path / "lite_test.yaml" + yaml_path.write_text("esphome:\n name: lite_test\n") + CORE.config_path = yaml_path + + with ( + patch( + "esphome.__main__.read_config", + return_value={"esphome": {"name": "lite_test"}}, + ), + patch("esphome.compiled_config.save_compiled_config") as mock_save, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {"upload": lambda args, config: 0}, + ), + ): + run_esphome(["esphome", "upload", str(yaml_path)]) + + mock_save.assert_not_called() + + +@pytest.mark.parametrize("command", ["upload", "logs"]) +def test_run_esphome_upload_and_logs_refresh_cache_on_fallback( + tmp_path: Path, command: str +) -> None: + """A stale-cache fallback rewrites the cache so the next call hits + the fast path. Without this, every upload/logs after a YAML edit + pays for read_config() until the next compile rewrites the cache.""" + yaml_path = tmp_path / "lite_test.yaml" + yaml_path.write_text("esphome:\n name: lite_test\n") + CORE.config_path = yaml_path + + storage_dir = tmp_path / ".esphome" / "storage" + _write_storage(storage_dir / "lite_test.yaml.json") + cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + _set_cache_mtime(cache, yaml_path, offset=-60) # stale + + fresh_config = {"esphome": {"name": "lite_test"}, "logger": {}} + + with ( + patch("esphome.__main__.read_config", return_value=fresh_config), + patch( + "esphome.compiled_config.save_compiled_config", wraps=save_compiled_config + ) as mock_save, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {command: lambda args, config: 0}, + ), + ): + assert run_esphome(["esphome", command, str(yaml_path)]) == 0 + + mock_save.assert_called_once_with(fresh_config) + # mtime is now newer than the source YAML, so a follow-up call hits + # the fast path instead of repeating read_config. + assert cache.stat().st_mtime >= yaml_path.stat().st_mtime + + +def test_run_esphome_upload_with_substitution_does_not_refresh_cache( + fresh_cache_files: Path, +) -> None: + """`-s` substitutions skip the cache on both read and write -- saving + here would clobber the cache with a substitution-specific config.""" + with ( + patch("esphome.__main__.read_config", return_value={"esphome": {}}), + patch("esphome.compiled_config.save_compiled_config") as mock_save, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {"upload": lambda args, config: 0}, + ), + ): + run_esphome(["esphome", "-s", "var", "val", "upload", str(fresh_cache_files)]) + + mock_save.assert_not_called() + + +def test_run_esphome_compile_does_not_refresh_cache_via_fallback( + fresh_cache_files: Path, +) -> None: + """Compile writes the cache through update_storage_json, not via the + upload/logs fallback path -- the fallback save would skip the + storage_should_clean check.""" + with ( + patch("esphome.__main__.read_config", return_value={"esphome": {}}), + patch("esphome.compiled_config.save_compiled_config") as mock_save, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {"compile": lambda args, config: 0}, + ), + ): + run_esphome(["esphome", "compile", str(fresh_cache_files)]) + + mock_save.assert_not_called() + + def test_run_esphome_upload_with_substitution_skips_cache( fresh_cache_files: Path, ) -> None: From 4ff8eb4b15c64422b79ac618524b0c8b39d04485 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 22:08:56 +0000 Subject: [PATCH 0140/1815] Bump ruff from 0.15.13 to 0.15.14 (#16543) Co-authored-by: J. Nick Koston Signed-off-by: dependabot[bot] --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index da5fb94d5e..0470a948f5 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.12 + rev: v0.15.14 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index ea4941a882..dbdea1d935 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.13 # also change in .pre-commit-config.yaml when updating +ruff==0.15.14 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From 1d3eea098e317b5c6bf9d6656a26f5b0825fa46a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 22 May 2026 13:00:22 +1200 Subject: [PATCH 0141/1815] [core] Support YAML frontmatter for arbitrary user metadata (#16552) --- esphome/core/__init__.py | 9 +- esphome/yaml_util.py | 29 +++++- tests/unit_tests/test_yaml_util.py | 158 +++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 3 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index e13d5668af..580d7f6477 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -5,7 +5,7 @@ import math import os from pathlib import Path import re -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from esphome.const import ( CONF_COMMENT, @@ -569,6 +569,12 @@ class EsphomeCore: self.build_path: Path | None = None # The validated configuration, this is None until the config has been validated self.config: ConfigType | None = None + # YAML frontmatter loaded from user YAML files. Frontmatter is a leading + # YAML document separated by `---` from the actual configuration. It is + # ignored by config validation and code generation, but kept here so it + # can be inspected by callers (tooling, future features). Keyed by the + # resolved Path of the source file. + self.frontmatter: dict[Path, Any] = {} # The pending tasks in the task queue (mostly for C++ generation) # This is a priority queue (with heapq) # Each item is a tuple of form: (-priority, unique number, task) @@ -634,6 +640,7 @@ class EsphomeCore: self.config_path = None self.build_path = None self.config = None + self.frontmatter = {} self.event_loop = _FakeEventLoop() self.task_counter = 0 self.variables = {} diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index b56d024418..9a36ad089c 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -768,10 +768,35 @@ def _load_yaml_internal_with_type( content: TextIOWrapper, yaml_loader: Callable[[Path], dict[str, Any]], ) -> Any: - """Load a YAML file.""" + """Load a YAML file. + + Supports an optional leading YAML frontmatter document: when the file + contains two YAML documents separated by ``---``, the first document is + treated as metadata and stored in :attr:`CORE.frontmatter` keyed by the + resolved file path, while the second document is returned as the actual + configuration. Frontmatter is ignored by config validation and code + generation. + """ loader = loader_type(content, fname, yaml_loader) try: - return loader.get_single_data() or OrderedDict() + documents: list[Any] = [] + while loader.check_data(): + documents.append(loader.get_data()) + if len(documents) > 2: + raise EsphomeError( + f"YAML file '{fname}' contains {len(documents)} documents but " + f"at most two are supported (an optional frontmatter document " + f"followed by the configuration)." + ) + if len(documents) == 2: + frontmatter = documents[0] + config = documents[1] + if frontmatter is not None: + CORE.frontmatter[Path(fname).resolve()] = frontmatter + return config if config is not None else OrderedDict() + if len(documents) == 1: + return documents[0] or OrderedDict() + return OrderedDict() except yaml.YAMLError as exc: raise EsphomeError(exc) from exc finally: diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index e97a188be4..de70a5307d 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -34,6 +34,14 @@ def clear_secrets_cache() -> None: yaml_util._SECRET_CACHE.clear() +@pytest.fixture(autouse=True) +def clear_core_frontmatter() -> None: + """Reset CORE.frontmatter between tests.""" + core.CORE.frontmatter = {} + yield + core.CORE.frontmatter = {} + + def test_include_with_vars(fixture_path: Path) -> None: yaml_file = fixture_path / "yaml_util" / "includetest.yaml" @@ -1182,3 +1190,153 @@ def test_track_yaml_loads_records_resolved_paths(tmp_path: Path) -> None: with track_yaml_loads() as loaded: yaml_util.load_yaml(link) assert target.resolve() in loaded + + +# --------------------------------------------------------------------------- +# YAML frontmatter +# --------------------------------------------------------------------------- + + +def test_frontmatter_parsed_and_stored_on_core(tmp_path: Path) -> None: + """A leading `---`-separated YAML document is stored as frontmatter and + stripped from the returned config.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text( + "author: Jesse\nlabels: [office, climate]\n---\nesphome:\n name: my_node\n" + ) + + config = yaml_util.load_yaml(yaml_file) + + # Config does not contain frontmatter keys + assert "author" not in config + assert "labels" not in config + assert config["esphome"]["name"] == "my_node" + + # Frontmatter is stored on CORE keyed by resolved path + frontmatter = core.CORE.frontmatter[yaml_file.resolve()] + assert frontmatter["author"] == "Jesse" + assert frontmatter["labels"] == ["office", "climate"] + + +def test_frontmatter_absent_when_single_document(tmp_path: Path) -> None: + """A YAML file with a single document does not populate CORE.frontmatter.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text("esphome:\n name: my_node\n") + + yaml_util.load_yaml(yaml_file) + assert yaml_file.resolve() not in core.CORE.frontmatter + + +def test_frontmatter_absent_when_leading_doc_separator(tmp_path: Path) -> None: + """A leading `---` with no content above it is just a document start marker, + not frontmatter, and must not populate CORE.frontmatter.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text("---\nesphome:\n name: my_node\n") + + config = yaml_util.load_yaml(yaml_file) + assert config["esphome"]["name"] == "my_node" + assert yaml_file.resolve() not in core.CORE.frontmatter + + +def test_frontmatter_supports_arbitrary_keys(tmp_path: Path) -> None: + """Frontmatter keys are not validated — any structure is accepted.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text( + "any_key: any_value\n" + "nested:\n" + " count: 42\n" + " items:\n" + " - a\n" + " - b\n" + "---\n" + "esphome:\n" + " name: t\n" + ) + + yaml_util.load_yaml(yaml_file) + frontmatter = core.CORE.frontmatter[yaml_file.resolve()] + assert frontmatter["any_key"] == "any_value" + assert frontmatter["nested"]["count"] == 42 + assert frontmatter["nested"]["items"] == ["a", "b"] + + +def test_frontmatter_supports_deeply_nested_paths(tmp_path: Path) -> None: + """Frontmatter preserves deeply nested dict/list structures intact.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text( + "device:\n" + " metadata:\n" + " location:\n" + " building: HQ\n" + " floor: 3\n" + " room:\n" + " number: 302\n" + " occupants:\n" + " - name: Jesse\n" + " role:\n" + " title: maintainer\n" + " since: 2021\n" + " - name: Alice\n" + " role:\n" + " title: contributor\n" + " since: 2024\n" + "---\n" + "esphome:\n" + " name: t\n" + ) + + yaml_util.load_yaml(yaml_file) + fm = core.CORE.frontmatter[yaml_file.resolve()] + room = fm["device"]["metadata"]["location"]["room"] + assert room["number"] == 302 + assert room["occupants"][0]["name"] == "Jesse" + assert room["occupants"][0]["role"]["title"] == "maintainer" + assert room["occupants"][0]["role"]["since"] == 2021 + assert room["occupants"][1]["role"]["title"] == "contributor" + + +def test_frontmatter_more_than_two_documents_raises(tmp_path: Path) -> None: + """Three or more YAML documents is unsupported and must raise.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text("a: 1\n---\nb: 2\n---\nc: 3\n") + + with pytest.raises(EsphomeError, match="at most two are supported"): + yaml_util.load_yaml(yaml_file) + + +def test_frontmatter_empty_frontmatter_doc_not_stored(tmp_path: Path) -> None: + """An empty (null) frontmatter document is treated as no frontmatter.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text("---\n---\nesphome:\n name: t\n") + + config = yaml_util.load_yaml(yaml_file) + assert config["esphome"]["name"] == "t" + assert yaml_file.resolve() not in core.CORE.frontmatter + + +def test_frontmatter_empty_config_doc(tmp_path: Path) -> None: + """An empty config document after a frontmatter document yields an empty config.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text("only: frontmatter\n---\n") + + config = yaml_util.load_yaml(yaml_file) + assert config == {} + assert core.CORE.frontmatter[yaml_file.resolve()]["only"] == "frontmatter" + + +def test_frontmatter_included_file_stored(tmp_path: Path) -> None: + """Frontmatter on an !include'd file is also captured on CORE, keyed by + that file's resolved path.""" + inc = tmp_path / "child.yaml" + inc.write_text("child_meta: hello\n---\nchild_key: value\n") + main = tmp_path / "main.yaml" + main.write_text("esphome:\n name: t\nchild: !include child.yaml\n") + + config = yaml_util.load_yaml(main) + # !include is deferred; force resolution so the child file actually loads + force_load_include_files(config) + assert config["child"].load()["child_key"] == "value" + # Main file has no frontmatter + assert main.resolve() not in core.CORE.frontmatter + # Included file's frontmatter is captured + assert core.CORE.frontmatter[inc.resolve()]["child_meta"] == "hello" From 0b2eb6481f4ab2c73b0b85430093c1f6a9a33810 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 22 May 2026 13:42:50 +1200 Subject: [PATCH 0142/1815] [light] Add light.effect.next / light.effect.previous actions (#16491) --- esphome/components/light/__init__.py | 33 ++++- esphome/components/light/automation.h | 41 ++++++ esphome/components/light/automation.py | 76 ++++++++++- esphome/components/light/types.py | 1 + .../light/test_effect_validation.py | 127 +++++++++++++++++- tests/components/light/common.yaml | 10 ++ 6 files changed, 283 insertions(+), 5 deletions(-) diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 9540c64486..68d9f85af2 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -86,10 +86,22 @@ class EffectRef: component_path: list[str | int] # path_context when the action was validated +@dataclass +class EffectCycleRef: + """A pending light.effect.next/previous action to validate. + + Records that the referenced light needs at least one effect configured. + """ + + light_id: ID + component_path: list[str | int] + + @dataclass class LightData: gamma_tables: dict = field(default_factory=dict) # gamma_value -> fwd_arr effect_refs: list[EffectRef] = field(default_factory=list) + effect_cycle_refs: list[EffectCycleRef] = field(default_factory=list) def _get_data() -> LightData: @@ -160,13 +172,15 @@ def _final_validate(config: ConfigType) -> ConfigType: this never runs — but the ID validator will catch the missing light ID separately. """ data = _get_data() - if not data.effect_refs: + if not data.effect_refs and not data.effect_cycle_refs: return config - # Drain the list so we only validate once even though + # Drain the lists so we only validate once even though # FINAL_VALIDATE_SCHEMA runs for each light platform instance. refs = data.effect_refs data.effect_refs = [] + cycle_refs = data.effect_cycle_refs + data.effect_cycle_refs = [] fconf = fv.full_config.get() @@ -188,6 +202,21 @@ def _final_validate(config: ConfigType) -> ConfigType: path=[cv.ROOT_CONFIG_PATH] + ref.component_path, ) + for ref in cycle_refs: + try: + light_path = fconf.get_path_for_id(ref.light_id)[:-1] + light_config = fconf.get_config_for_path(light_path) + except KeyError: + continue + + if not light_config.get(CONF_EFFECTS): + raise cv.FinalExternalInvalid( + f"Light '{ref.light_id}' has no effects configured, but a " + f"'light.effect.next' or 'light.effect.previous' action " + f"references it. Add at least one effect to the light.", + path=[cv.ROOT_CONFIG_PATH] + ref.component_path, + ) + return config diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index 993d4a2ea6..260414f033 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -104,6 +104,47 @@ template class DimRelativeAction : pub transition_length_{}; }; +// Cycle through the light's configured effects. `Forward` selects direction +// at compile time so the chosen branch is the only one that gets instantiated +// per action site. `include_none` is runtime so a single set of templates +// covers both the "wrap through None" and "skip None" variants. +template class LightEffectCycleAction : public Action { + public: + explicit LightEffectCycleAction(LightState *parent) : parent_(parent) {} + + void set_include_none(bool include_none) { this->include_none_ = include_none; } + + void play(const Ts &...) override { + size_t count = this->parent_->get_effect_count(); + if (count == 0) { + return; + } + uint32_t current = this->parent_->get_current_effect_index(); + uint32_t next; + if (this->include_none_) { + uint32_t total = static_cast(count) + 1; + if constexpr (Forward) { + next = (current + 1) % total; + } else { + next = (current + total - 1) % total; + } + } else { + if constexpr (Forward) { + next = (current % static_cast(count)) + 1; + } else { + next = (current <= 1) ? static_cast(count) : current - 1; + } + } + auto call = this->parent_->turn_on(); + call.set_effect(next); + call.perform(); + } + + protected: + LightState *parent_; + bool include_none_{false}; +}; + template class LightIsOnCondition : public Condition { public: explicit LightIsOnCondition(LightState *state) : state_(state) {} diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index cef774af38..7eaba9b117 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -26,8 +26,8 @@ from esphome.const import ( CONF_WARM_WHITE, CONF_WHITE, ) -from esphome.core import CORE, EsphomeError, Lambda -from esphome.cpp_generator import LambdaExpression +from esphome.core import CORE, ID, EsphomeError, Lambda +from esphome.cpp_generator import LambdaExpression, MockObj, TemplateArgsType from esphome.types import ConfigType from .types import ( @@ -39,12 +39,15 @@ from .types import ( DimRelativeAction, LightCall, LightControlAction, + LightEffectCycleAction, LightIsOffCondition, LightIsOnCondition, LightState, ToggleAction, ) +CONF_INCLUDE_NONE = "include_none" + @automation.register_action( "light.toggle", @@ -253,6 +256,75 @@ async def light_control_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) +def _record_effect_cycle_ref(config: ConfigType) -> ConfigType: + """Record a cycle-action reference for later validation against the target light.""" + from . import EffectCycleRef, _get_data + + _get_data().effect_cycle_refs.append( + EffectCycleRef( + light_id=config[CONF_ID], + component_path=path_context.get(), + ) + ) + return config + + +LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(LightState), + cv.Optional(CONF_INCLUDE_NONE, default=False): cv.boolean, + } +) +LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA.add_extra(_record_effect_cycle_ref) + +LIGHT_EFFECT_CYCLE_ACTION_SCHEMA = automation.maybe_simple_id( + LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA +) + + +@automation.register_action( + "light.effect.next", + LightEffectCycleAction, + LIGHT_EFFECT_CYCLE_ACTION_SCHEMA, + synchronous=True, +) +async def light_effect_next_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + return await _light_effect_cycle_to_code(config, action_id, template_arg, True) + + +@automation.register_action( + "light.effect.previous", + LightEffectCycleAction, + LIGHT_EFFECT_CYCLE_ACTION_SCHEMA, + synchronous=True, +) +async def light_effect_previous_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + return await _light_effect_cycle_to_code(config, action_id, template_arg, False) + + +async def _light_effect_cycle_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + forward: bool, +) -> MockObj: + paren = await cg.get_variable(config[CONF_ID]) + cycle_template_arg = cg.TemplateArguments(forward, *template_arg) + var = cg.new_Pvariable(action_id, cycle_template_arg, paren) + cg.add(var.set_include_none(config[CONF_INCLUDE_NONE])) + return var + + CONF_RELATIVE_BRIGHTNESS = "relative_brightness" LIGHT_DIM_RELATIVE_ACTION_SCHEMA = cv.Schema( { diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index 534dcd2194..c7385cbee3 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -39,6 +39,7 @@ LIMIT_MODES = { # Actions ToggleAction = light_ns.class_("ToggleAction", automation.Action) LightControlAction = light_ns.class_("LightControlAction", automation.Action) +LightEffectCycleAction = light_ns.class_("LightEffectCycleAction", automation.Action) DimRelativeAction = light_ns.class_("DimRelativeAction", automation.Action) AddressableSet = light_ns.class_("AddressableSet", automation.Action) LightIsOnCondition = light_ns.class_("LightIsOnCondition", automation.Condition) diff --git a/tests/component_tests/light/test_effect_validation.py b/tests/component_tests/light/test_effect_validation.py index 579e92c62a..aab9072cc8 100644 --- a/tests/component_tests/light/test_effect_validation.py +++ b/tests/component_tests/light/test_effect_validation.py @@ -9,13 +9,17 @@ import pytest from esphome import config_validation as cv from esphome.components.light import ( + EffectCycleRef, EffectRef, _final_validate, _get_data, available_effects_str, find_effect_index, ) -from esphome.components.light.automation import _record_effect_ref +from esphome.components.light.automation import ( + _record_effect_cycle_ref, + _record_effect_ref, +) from esphome.config import Config, path_context from esphome.const import CONF_EFFECT, CONF_EFFECTS, CONF_ID, CONF_NAME from esphome.core import ID, Lambda @@ -215,6 +219,111 @@ def test_final_validate_drains_refs() -> None: fv.full_config.reset(token) +# --- _final_validate: EffectCycleRef --- + + +def _setup_cycle_final_validate( + cycle_refs: list[EffectCycleRef], + light_configs: list[ConfigType], + declare_ids: list[tuple[ID, list[str | int]]], +) -> Token: + """Set up CORE.data and fv.full_config for EffectCycleRef final_validate tests.""" + data = _get_data() + data.effect_cycle_refs = cycle_refs + + full_conf = Config() + full_conf["light"] = light_configs + for id_, path in declare_ids: + full_conf.declare_ids.append((id_, path)) + + return fv.full_config.set(full_conf) + + +def test_final_validate_cycle_accepts_light_with_effects() -> None: + """Cycle ref against a light with effects should not raise.""" + light_id = ID("led1", is_declaration=True) + token = _setup_cycle_final_validate( + cycle_refs=[ + EffectCycleRef(light_id=light_id, component_path=["esphome"]), + ], + light_configs=[{CONF_ID: light_id, CONF_EFFECTS: _make_effects("Fast Pulse")}], + declare_ids=[(light_id, ["light", 0, CONF_ID])], + ) + try: + _final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_final_validate_cycle_rejects_light_without_effects_key() -> None: + """Cycle ref against a light with no CONF_EFFECTS key should raise.""" + light_id = ID("led1", is_declaration=True) + token = _setup_cycle_final_validate( + cycle_refs=[ + EffectCycleRef(light_id=light_id, component_path=["esphome"]), + ], + light_configs=[{CONF_ID: light_id}], + declare_ids=[(light_id, ["light", 0, CONF_ID])], + ) + try: + with pytest.raises(cv.FinalExternalInvalid, match="no effects configured"): + _final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_final_validate_cycle_rejects_light_with_empty_effects() -> None: + """Cycle ref against a light with empty effects list should raise.""" + light_id = ID("led1", is_declaration=True) + token = _setup_cycle_final_validate( + cycle_refs=[ + EffectCycleRef(light_id=light_id, component_path=["esphome"]), + ], + light_configs=[{CONF_ID: light_id, CONF_EFFECTS: []}], + declare_ids=[(light_id, ["light", 0, CONF_ID])], + ) + try: + with pytest.raises(cv.FinalExternalInvalid, match="no effects configured"): + _final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_final_validate_cycle_unknown_light_id_skipped() -> None: + """Cycle refs to unknown light IDs should be silently skipped.""" + data = _get_data() + data.effect_cycle_refs = [ + EffectCycleRef( + light_id=ID("nonexistent", is_declaration=True), + component_path=["esphome"], + ) + ] + + full_conf = Config() + token = fv.full_config.set(full_conf) + try: + _final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_final_validate_drains_cycle_refs() -> None: + """Cycle refs should be drained after validation to avoid redundant runs.""" + light_id = ID("led1", is_declaration=True) + token = _setup_cycle_final_validate( + cycle_refs=[ + EffectCycleRef(light_id=light_id, component_path=["esphome"]), + ], + light_configs=[{CONF_ID: light_id, CONF_EFFECTS: _make_effects("Fast Pulse")}], + declare_ids=[(light_id, ["light", 0, CONF_ID])], + ) + try: + _final_validate({}) + assert _get_data().effect_cycle_refs == [] + finally: + fv.full_config.reset(token) + + # --- _record_effect_ref --- @@ -278,3 +387,19 @@ def test_record_effect_ref_skips_no_effect_key() -> None: config: ConfigType = {CONF_ID: ID("led1", is_declaration=True)} _record_effect_ref(config) assert _get_data().effect_refs == [] + + +# --- _record_effect_cycle_ref --- + + +@pytest.mark.usefixtures("_path_ctx") +def test_record_effect_cycle_ref() -> None: + """Cycle-action config should be recorded with light_id and path.""" + light_id = ID("led1", is_declaration=True) + config: ConfigType = {CONF_ID: light_id} + result = _record_effect_cycle_ref(config) + assert result is config + data = _get_data() + assert len(data.effect_cycle_refs) == 1 + assert data.effect_cycle_refs[0].light_id is light_id + assert data.effect_cycle_refs[0].component_path == ["esphome"] diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 044a8144fa..cd9b27768e 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -103,6 +103,16 @@ esphome: - light.turn_on: id: test_monochromatic_light effect: !lambda 'return iteration > 1 ? "Strobe" : "none";' + # Cycle through configured effects (skip "None") + - light.effect.next: test_monochromatic_light + - light.effect.previous: test_monochromatic_light + # Cycle through effects including "None" + - light.effect.next: + id: test_monochromatic_light + include_none: true + - light.effect.previous: + id: test_monochromatic_light + include_none: true - light.dim_relative: id: test_monochromatic_light relative_brightness: 5% From 0b5e7ae8fa052d6e193f730e0024bcd6a54a5fae Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 22 May 2026 06:43:08 -0400 Subject: [PATCH 0143/1815] [sendspin] Bump sendspin-cpp to v0.6.1 (#16553) --- esphome/components/sendspin/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 36f13f7d07..b670bd3c4d 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -206,7 +206,7 @@ async def to_code(config: ConfigType) -> None: ) # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.0") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.1") cg.add_define("USE_SENDSPIN", True) # for MDNS diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 44c63e46cd..6bc166ff44 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -100,6 +100,6 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.6.0 + version: 0.6.1 lvgl/lvgl: version: 9.5.0 From ac530c33b0c41fbbb923e0945ba6067ec30a5ca4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 08:08:14 -0500 Subject: [PATCH 0144/1815] Bump actions/stale from 10.2.0 to 10.3.0 (#16544) Signed-off-by: dependabot[bot] --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 2e57093bbb..7003f6c482 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Stale - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 + uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 with: debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch remove-stale-when-updated: true From 99de741f99eff9690ba1e4e5b827c2afaf304194 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 08:08:29 -0500 Subject: [PATCH 0145/1815] Bump docker/build-push-action from 7.1.0 to 7.2.0 in /.github/actions/build-image (#16545) Signed-off-by: dependabot[bot] --- .github/actions/build-image/action.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/build-image/action.yaml b/.github/actions/build-image/action.yaml index 52d72544d3..2081264b91 100644 --- a/.github/actions/build-image/action.yaml +++ b/.github/actions/build-image/action.yaml @@ -47,7 +47,7 @@ runs: - name: Build and push to ghcr by digest id: build-ghcr - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false @@ -73,7 +73,7 @@ runs: - name: Build and push to dockerhub by digest id: build-dockerhub - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false From 680c9fc9c0bc651bdf1dbd7890310e96742964e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 08:49:03 -0500 Subject: [PATCH 0146/1815] [dashboard] Fix flaky test_websocket_refresh_command on Windows CI (#16565) --- tests/dashboard/test_web_server.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 626aea0216..0ee841e68c 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -1503,13 +1503,18 @@ async def test_websocket_refresh_command( ) -> None: """Test WebSocket refresh command triggers dashboard update.""" with patch("esphome.dashboard.web_server.DASHBOARD_SUBSCRIBER") as mock_subscriber: - mock_subscriber.request_refresh = Mock() + # Signal an asyncio.Event when request_refresh is invoked so the + # test can deterministically wait for the server-side handler to run + # instead of relying on a fixed sleep (flaky on Windows CI under load). + called = asyncio.Event() + mock_subscriber.request_refresh = Mock(side_effect=called.set) # Send refresh command await websocket_client.write_message(json.dumps({"event": "refresh"})) - # Give it a moment to process - await asyncio.sleep(0.01) + # Wait for the server to process the message and invoke request_refresh + async with asyncio.timeout(5): + await called.wait() # Verify request_refresh was called mock_subscriber.request_refresh.assert_called_once() From 94b10981e1c78e1a82690edd4ac05a37fa318981 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Fri, 22 May 2026 22:09:32 +0300 Subject: [PATCH 0147/1815] [libretiny] Fix LN882H IRAM_ATTR injection point in patch_linker.py (#16570) --- esphome/components/libretiny/patch_linker.py.script | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 282a31d3f2..3a8a4787ed 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -13,7 +13,9 @@ import subprocess # - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it. # - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it. # - LN882H: stock linker has no glob for ".sram.text", so we inject -# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH). +# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH) +# immediately after KEEP(*(.vectors)), so the vector table stays at +# __copysection_ram0_start (0x20000000) for correct Cortex-M4 VTOR alignment. # # All families also get a post-link summary showing where IRAM_ATTR landed. @@ -27,7 +29,11 @@ _KEEP_LINE = ( "__esphome_sram_text_end = .; " + _MARKER + "\n" ) -_LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)") +# Inject after KEEP(*(.vectors)) so the vector table stays at +# __copysection_ram0_start (0x20000000). Cortex-M4 VTOR requires a 512-byte- +# aligned address; injecting before the vectors would push them to an +# unaligned offset and mis-route every IRQ handler. +_LN_COPY = re.compile(r"(KEEP\(\*\(\.vectors\)\)[^\n]*\n)") def _detect(env): @@ -56,7 +62,7 @@ KNOWN_VARIANTS = frozenset({ def _inject_keep(host_section): - """Return a patcher that injects _KEEP_LINE at the top of `host_section`.""" + """Return a patcher that injects _KEEP_LINE after `host_section` match.""" def patch(content): if _MARKER in content: return content From 64e32ebe046d8e145ddf2fc7f32f9b6d6844cc0a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 14:30:28 -0500 Subject: [PATCH 0148/1815] [esp8266] Use os_timer-based esp_delay() in delay() (#16563) --- esphome/components/esp8266/hal.cpp | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/esphome/components/esp8266/hal.cpp b/esphome/components/esp8266/hal.cpp index e8f472dc8a..3501c51859 100644 --- a/esphome/components/esp8266/hal.cpp +++ b/esphome/components/esp8266/hal.cpp @@ -5,6 +5,7 @@ #include #include +#include extern "C" { #include @@ -71,23 +72,22 @@ uint32_t IRAM_ATTR HOT millis() { 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. +// Delegate to Arduino's 1-arg esp_delay(), which uses os_timer + esp_suspend to +// suspend the cont task for `ms` milliseconds without polling millis(). This +// matches pre-2026.5.0 behavior (when esphome::delay() forwarded to ::delay()) +// and lets the SDK run freely while we wait, which timing-sensitive +// interrupt-driven code (e.g. ESP8266 software-serial RX in components like +// fingerprint_grow) depends on. The poll-based busy-wait that this replaced +// rarely yielded inside short waits like delay(1), starving WiFi/SDK tasks and +// extending interrupt latency. Unlike ::delay(), esp_delay()'s 1-arg form does +// not call millis(), so the slow Arduino millis() body is not pulled into IRAM +// by this path (the --wrap=millis goal of #15662 is preserved). void HOT delay(uint32_t ms) { if (ms == 0) { optimistic_yield(1000); return; } - uint32_t start = millis(); - while (millis() - start < ms) { - optimistic_yield(1000); - } + esp_delay(ms); } void arch_restart() { From 7182b1a8ae5a727c76f7ad5aebfb4dfa69342e4e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 14:30:43 -0500 Subject: [PATCH 0149/1815] [uart] Wake main loop on ESP8266 software serial RX (#16562) --- esphome/components/uart/__init__.py | 9 +++++---- .../components/uart/uart_component_esp8266.cpp | 15 ++++++++++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 7075228743..4ea32e26a3 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -513,10 +513,11 @@ async def uart_write_to_code(config, action_id, template_arg, args): @coroutine_with_priority(CoroPriority.FINAL) async def final_step(): """Final code generation step to configure optional UART features.""" - if CORE.is_esp32 and CORE.has_networking: - # Wake-on-RX is essentially free on ESP32 (just an ISR function pointer - # registration) — enable by default to reduce RX buffer overflow risk - # by waking the main loop immediately when data arrives. + if (CORE.is_esp32 or CORE.is_esp8266) and CORE.has_networking: + # Wake-on-RX is essentially free (just an ISR function pointer + # registration on ESP32, an inline flag set on ESP8266 software + # serial) — enable by default to reduce RX buffer overflow risk by + # waking the main loop immediately when data arrives. cg.add_define("USE_UART_WAKE_LOOP_ON_RX") diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index 0ea7930760..fc1509f737 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -4,6 +4,9 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#ifdef USE_UART_WAKE_LOOP_ON_RX +#include "esphome/core/wake.h" +#endif #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" @@ -149,7 +152,11 @@ void ESP8266UartComponent::dump_config() { if (this->hw_serial_ != nullptr) { ESP_LOGCONFIG(TAG, " Using hardware serial interface."); } else { - ESP_LOGCONFIG(TAG, " Using software serial"); + ESP_LOGCONFIG(TAG, " Using software serial" +#ifdef USE_UART_WAKE_LOOP_ON_RX + "\n Wake on data RX: ENABLED" +#endif + ); } this->check_logger_conflict(); } @@ -266,6 +273,12 @@ void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) { arg->rx_in_pos_ = (arg->rx_in_pos_ + 1) % arg->rx_buffer_size_; // Clear RX pin so that the interrupt doesn't re-trigger right away again. arg->rx_pin_.clear_interrupt(); +#ifdef USE_UART_WAKE_LOOP_ON_RX + // Wake the main loop so the consuming component drains the byte promptly + // instead of waiting for the next loop_interval_ tick. Important for timing + // sensitive setups that poll read() in a tight loop (e.g. fingerprint_grow). + wake_loop_isrsafe(); +#endif } void IRAM_ATTR HOT ESP8266SoftwareSerial::write_byte(uint8_t data) { if (this->gpio_tx_pin_ == nullptr) { From c3bef24389d9cf57598fee58efb58d550730c973 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 22 May 2026 15:43:50 -0400 Subject: [PATCH 0150/1815] [i2s_audio] Reset dout GPIO when stopping speaker driver (#16573) --- .../components/i2s_audio/speaker/i2s_audio_speaker.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 680ca069c0..691f68e912 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -2,6 +2,7 @@ #ifdef USE_ESP32 +#include #include #include "esphome/components/audio/audio.h" @@ -299,6 +300,15 @@ void I2SAudioSpeakerBase::stop_i2s_driver_() { i2s_channel_disable(this->tx_handle_); i2s_del_channel(this->tx_handle_); this->tx_handle_ = nullptr; + + // i2s_del_channel() leaves dout wired to this port's data-out signal in the GPIO matrix: it only + // clears an internal reservation mask, never the esp_rom_gpio_connect_out_signal() routing that + // setup installed. If another speaker reuses this port (shared bus), its audio still reaches our + // dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a + // SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise. + gpio_reset_pin(this->dout_pin_); + gpio_set_direction(this->dout_pin_, GPIO_MODE_OUTPUT); + gpio_set_level(this->dout_pin_, 0); } this->parent_->unlock(); } From 4a78c8d45a4a4cc2152e96d994528cdbdbb0beb9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 15:55:49 -0500 Subject: [PATCH 0151/1815] Bump pytest-codspeed from 5.0.2 to 5.0.3 (#16575) Signed-off-by: dependabot[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index dbdea1d935..102a9cae6e 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -16,7 +16,7 @@ hypothesis==6.92.1 # CodSpeed benchmarks under tests/benchmarks/python/ # (skipped via pytest.importorskip when missing -- only required for the # benchmarks job in .github/workflows/ci.yml) -pytest-codspeed==5.0.2 +pytest-codspeed==5.0.3 # Used by the import-time regression check (.github/workflows/ci.yml → import-time job) importtime-waterfall==1.0.0 From f85fdb475ad46673d3de9ee01a8a0da3b93183ca Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 23 May 2026 07:37:51 +0930 Subject: [PATCH 0152/1815] [homeassistant] Reduce log spam for sensors (#16555) --- .../components/homeassistant/sensor/homeassistant_sensor.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp b/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp index 112795a4ff..b79a56953a 100644 --- a/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp +++ b/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp @@ -17,9 +17,9 @@ void HomeassistantSensor::setup() { } if (this->attribute_ != nullptr) { - ESP_LOGD(TAG, "'%s::%s': Got attribute state %.2f", this->entity_id_, this->attribute_, *val); + ESP_LOGV(TAG, "'%s::%s': Got attribute state %.2f", this->entity_id_, this->attribute_, *val); } else { - ESP_LOGD(TAG, "'%s': Got state %.2f", this->entity_id_, *val); + ESP_LOGV(TAG, "'%s': Got state %.2f", this->entity_id_, *val); } this->publish_state(*val); }); From a58b4edb6ac12204274d021652f6bbffd82a37a7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 18:39:06 -0500 Subject: [PATCH 0153/1815] [ci] Gate unconditional CI jobs on a single determine-jobs output instead of a path filter (#16580) --- .github/workflows/ci.yml | 17 +++--- script/determine-jobs.py | 80 ++++++++++++++++++++++++++++ tests/script/test_determine_jobs.py | 82 +++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43b03aec85..53516db913 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,14 +6,6 @@ on: branches: [dev, beta, release] pull_request: - paths: - - "**" - - "!.github/workflows/*.yml" - - "!.github/actions/build-image/*" - - ".github/workflows/ci.yml" - - "!.yamllint" - - "!.github/dependabot.yml" - - "!docker/**" merge_group: permissions: @@ -101,6 +93,8 @@ jobs: runs-on: ubuntu-24.04 needs: - common + - determine-jobs + if: needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -223,6 +217,8 @@ jobs: runs-on: ${{ matrix.os }} needs: - common + - determine-jobs + if: needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -261,6 +257,7 @@ jobs: needs: - common outputs: + core-ci: ${{ steps.determine.outputs.core-ci }} integration-tests: ${{ steps.determine.outputs.integration-tests }} integration-test-buckets: ${{ steps.determine.outputs.integration-test-buckets }} clang-tidy: ${{ steps.determine.outputs.clang-tidy }} @@ -314,6 +311,7 @@ jobs: echo "$output" | jq # Extract individual fields + echo "core-ci=$(echo "$output" | jq -r '.core_ci')" >> $GITHUB_OUTPUT echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT echo "integration-test-buckets=$(echo "$output" | jq -c '.integration_test_buckets')" >> $GITHUB_OUTPUT echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT @@ -969,7 +967,8 @@ jobs: runs-on: ubuntu-latest needs: - common - if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') + - determine-jobs + if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 3259fb5836..ef2175eb79 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -5,6 +5,7 @@ This script is a centralized way to determine which CI jobs need to run based on what files have changed. It outputs JSON with the following structure: { + "core_ci": true/false, "integration_tests": true/false, "integration_test_buckets": [{"name": "1/3", "tests": ["tests/integration/test_foo.py", ...]}, ...], "clang_tidy": true/false, @@ -22,6 +23,11 @@ what files have changed. It outputs JSON with the following structure: } The CI workflow uses this information to: +- Gate the unconditional jobs (ci-custom, pytest, pre-commit-ci-lite) via core_ci; + false when a pull_request only touches CI-irrelevant meta paths (other workflow + files, .github/actions/build-image/*, .yamllint, .github/dependabot.yml, docker/**) + so workflow-only PRs satisfy the required CI Status check without running the + unconditional jobs. Always true on non-pull_request events and under --force-all. - Skip or run integration tests - Skip or run clang-tidy (and whether to do a full scan) - Skip or run clang-format @@ -712,6 +718,69 @@ def should_run_benchmarks(branch: str | None = None) -> bool: return any(get_component_from_path(f) in benchmarked_components for f in files) +# Files / path patterns whose changes alone don't warrant running the +# unconditional CI jobs (`ci-custom`, `pytest`, `pre-commit-ci-lite`). +# Single source of truth for what we treat as "CI-irrelevant" on +# pull_request events; ci.yml used to encode this in its own +# `pull_request.paths` filter, but that hid the required `CI Status` +# check on PRs that only touched these files (dependabot Action bumps, +# dependabot.yml edits, docker/ changes, etc.) and forced admin +# force-merges. +# +# ci.yml itself is deliberately *not* ignored — editing the CI workflow +# must still run CI. Workflows that have their own dedicated triggers +# (codeql.yml, ci-docker.yml, ...) are matched via the +# `.github/workflows/*.yml` prefix below and exclude ci.yml explicitly. +CI_IRRELEVANT_EXACT_FILES = frozenset( + { + ".yamllint", + ".github/dependabot.yml", + } +) + + +def _is_ci_irrelevant_path(path: str) -> bool: + """Whether a single changed path is irrelevant to the unconditional CI jobs.""" + if path in CI_IRRELEVANT_EXACT_FILES: + return True + # docker/** — all descendants + if path.startswith("docker/"): + return True + # .github/workflows/*.yml — top-level workflow files other than ci.yml + # (ci.yml itself must still trigger full CI when edited). + if path.startswith(".github/workflows/") and path.endswith(".yml"): + if path == ".github/workflows/ci.yml": + return False + if "/" not in path[len(".github/workflows/") :]: + return True + # .github/actions/build-image/* — direct children only, matches the + # single-star glob the workflow used to encode. + if path.startswith(".github/actions/build-image/"): + rest = path[len(".github/actions/build-image/") :] + if rest and "/" not in rest: + return True + return False + + +def should_run_core_ci(branch: str | None = None) -> bool: + """Determine if the unconditional CI jobs (ci-custom/pytest/pre-commit-ci-lite) should run. + + Returns False only when every changed file is in the CI-irrelevant set + above (see ``_is_ci_irrelevant_path``). Empty diffs return True so we + never accidentally skip CI when the diff probe fails. + + Args: + branch: Branch to compare against. If None, uses default. + + Returns: + True if the unconditional CI jobs should run, False otherwise. + """ + files = changed_files(branch) + if not files: + return True + return any(not _is_ci_irrelevant_path(f) for f in files) + + def _any_changed_file_endswith(branch: str | None, extensions: tuple[str, ...]) -> bool: """Check if a changed file ends with any of the specified extensions.""" return any(file.endswith(extensions) for file in changed_files(branch)) @@ -1075,6 +1144,16 @@ def main() -> None: args = parser.parse_args() # Determine what should run + # core_ci gates the unconditional jobs in ci.yml (ci-custom, pytest, + # pre-commit-ci-lite). Non-pull_request events (push to dev/beta/release + # and merge_group) always run them so behavior like venv-cache saves on + # push to dev is preserved. + event_name = os.environ.get("GITHUB_EVENT_NAME", "") + run_core_ci = ( + True + if args.force_all or event_name != "pull_request" + else should_run_core_ci(args.branch) + ) if args.force_all: integration_run_all, integration_test_files = True, [] run_clang_tidy = True @@ -1255,6 +1334,7 @@ def main() -> None: component_test_batches = [] output: dict[str, Any] = { + "core_ci": run_core_ci, "integration_tests": run_integration, "integration_test_buckets": integration_test_buckets, "clang_tidy": run_clang_tidy, diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 202ae9030f..7bb9fe2543 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -775,6 +775,88 @@ def test_should_run_import_time_with_branch() -> None: mock_changed.assert_called_once_with("release") +@pytest.mark.parametrize( + ("path", "expected_result"), + [ + # Exact-file matches in the CI-irrelevant set. + (".yamllint", True), + (".github/dependabot.yml", True), + # Other top-level workflow files are irrelevant; ci.yml itself is not. + (".github/workflows/codeql.yml", True), + (".github/workflows/release.yml", True), + (".github/workflows/ci.yml", False), + # Nested files under workflows/ are not matched by the single-star glob. + (".github/workflows/matchers/gcc.json", False), + # build-image action: direct children only (single-star glob). + (".github/actions/build-image/action.yml", True), + (".github/actions/build-image/nested/file.yml", False), + # Other actions are CI-relevant. + (".github/actions/restore-python/action.yml", False), + # docker/** covers everything under docker/. + ("docker/Dockerfile", True), + ("docker/scripts/run.sh", True), + # Regular source files are CI-relevant. + ("esphome/__main__.py", False), + ("esphome/components/wifi/wifi_component.cpp", False), + ("README.md", False), + ("tests/script/test_determine_jobs.py", False), + ], +) +def test_is_ci_irrelevant_path(path: str, expected_result: bool) -> None: + """Test _is_ci_irrelevant_path mirrors the historic ci.yml path filter.""" + assert determine_jobs._is_ci_irrelevant_path(path) == expected_result + + +@pytest.mark.parametrize( + ("changed_files", "expected_result"), + [ + # Empty diffs default to True — don't accidentally skip CI on a + # broken probe. + ([], True), + # Any CI-relevant file flips the result to True. + (["esphome/__main__.py"], True), + (["esphome/components/wifi/wifi_component.cpp"], True), + (["README.md"], True), + # All-irrelevant diffs return False. + ([".github/workflows/codeql.yml"], False), + ( + [".github/workflows/codeql.yml", ".github/workflows/release.yml"], + False, + ), + ([".yamllint"], False), + ([".github/dependabot.yml"], False), + (["docker/Dockerfile"], False), + ( + [ + ".github/workflows/codeql.yml", + ".github/dependabot.yml", + "docker/Dockerfile", + ], + False, + ), + # Mixed diffs always trigger CI. + ( + [".github/workflows/codeql.yml", "esphome/__main__.py"], + True, + ), + # ci.yml itself is treated as CI-relevant. + ([".github/workflows/ci.yml"], True), + ], +) +def test_should_run_core_ci(changed_files: list[str], expected_result: bool) -> None: + """Test should_run_core_ci function.""" + with patch.object(determine_jobs, "changed_files", return_value=changed_files): + assert determine_jobs.should_run_core_ci() == expected_result + + +def test_should_run_core_ci_with_branch() -> None: + """Test should_run_core_ci passes the branch through to changed_files.""" + with patch.object(determine_jobs, "changed_files") as mock_changed: + mock_changed.return_value = [] + determine_jobs.should_run_core_ci("release") + mock_changed.assert_called_once_with("release") + + @pytest.mark.parametrize( ("changed_files", "expected_result"), [ From 71550bb3bee1980f7edaa71e68445aa05a007984 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 18:39:25 -0500 Subject: [PATCH 0154/1815] [lvgl] Memoize and lazily build container_schema (#16567) --- esphome/components/lvgl/schemas.py | 42 ++++++--- .../lvgl/test_container_schema_cache.py | 87 +++++++++++++++++++ 2 files changed, 119 insertions(+), 10 deletions(-) create mode 100644 tests/component_tests/lvgl/test_container_schema_cache.py diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 553e0f7398..58ef88d6a8 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -1,4 +1,5 @@ from collections.abc import Callable +from typing import Any from esphome import config_validation as cv from esphome.automation import Trigger, validate_automation @@ -534,7 +535,16 @@ def strip_defaults(schema: cv.Schema): return cv.Schema({cv.Optional(k): v for k, v in schema.schema.items()}) -def container_schema(widget_type: WidgetType, extras=None): +# Keyed by (id(widget_type), id(extras)); strong refs in the value keep both +# alive so id() can't be recycled. +_CONTAINER_SCHEMA_CACHE: dict[ + tuple[int, int], tuple[Any, Any, Callable[[Any], Any]] +] = {} + + +def container_schema( + widget_type: WidgetType, extras: Any = None +) -> Callable[[Any], Any]: """ Create a schema for a container widget of a given type. All obj properties are available, plus the extras passed in, plus any defined for the specific widget being specified. @@ -542,19 +552,31 @@ def container_schema(widget_type: WidgetType, extras=None): :param extras: Additional options to be made available, e.g. layout properties for children :return: The schema for this type of widget. """ - schema = obj_schema(widget_type).extend( - {cv.GenerateID(): cv.declare_id(widget_type.w_type)} - ) - if extras: - schema = schema.extend(extras) - # Delayed evaluation for recursion + cache_key = (id(widget_type), id(extras)) + cached = _CONTAINER_SCHEMA_CACHE.get(cache_key) + if cached is not None: + cached_widget_type, cached_extras, cached_validator = cached + if cached_widget_type is widget_type and cached_extras is extras: + return cached_validator - schema = schema.extend(widget_type.schema) + cached_schema: cv.Schema | None = None - def validator(value): + def get_schema() -> cv.Schema: + nonlocal cached_schema + if cached_schema is None: + schema = obj_schema(widget_type).extend( + {cv.GenerateID(): cv.declare_id(widget_type.w_type)} + ) + if extras: + schema = schema.extend(extras) + cached_schema = schema.extend(widget_type.schema) + return cached_schema + + def validator(value: Any) -> Any: value = value or {} - return append_layout_schema(schema, value)(value) + return append_layout_schema(get_schema(), value)(value) + _CONTAINER_SCHEMA_CACHE[cache_key] = (widget_type, extras, validator) return validator diff --git a/tests/component_tests/lvgl/test_container_schema_cache.py b/tests/component_tests/lvgl/test_container_schema_cache.py new file mode 100644 index 0000000000..39e623d720 --- /dev/null +++ b/tests/component_tests/lvgl/test_container_schema_cache.py @@ -0,0 +1,87 @@ +"""Tests for container_schema() memoization and lazy build.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import patch + +import pytest + +from esphome import config_validation as cv +import esphome.components.lvgl # noqa: F401 +from esphome.components.lvgl import schemas as lvgl_schemas +from esphome.components.lvgl.schemas import WIDGET_TYPES, container_schema + + +@pytest.fixture(autouse=True) +def _clear_container_schema_cache() -> Generator[None]: + cache = getattr(lvgl_schemas, "_CONTAINER_SCHEMA_CACHE", None) + if cache is not None: + cache.clear() + yield + if cache is not None: + cache.clear() + + +def _widget_type(name: str = "obj"): + wt = WIDGET_TYPES.get(name) + assert wt is not None, f"widget type {name!r} not registered" + return wt + + +def test_same_args_return_same_validator() -> None: + wt = _widget_type("obj") + assert container_schema(wt) is container_schema(wt) + + +def test_extras_none_vs_truthy_get_different_validators() -> None: + wt = _widget_type("obj") + no_extras = container_schema(wt) + extras = {cv.Optional("custom_extra"): cv.string} + assert no_extras is not container_schema(wt, extras) + + +def test_different_widget_types_get_different_validators() -> None: + assert container_schema(_widget_type("obj")) is not container_schema( + _widget_type("label") + ) + + +def test_schema_build_is_deferred_until_first_validation() -> None: + wt = _widget_type("obj") + with patch.object( + lvgl_schemas, "obj_schema", wraps=lvgl_schemas.obj_schema + ) as obj_schema_mock: + validator = container_schema(wt) + assert obj_schema_mock.call_count == 0 + validator({}) + assert obj_schema_mock.call_count == 1 + validator({}) + assert obj_schema_mock.call_count == 1 + + +def test_cached_validator_produces_equivalent_output() -> None: + wt = _widget_type("obj") + cached = container_schema(wt) + cached_result = cached({}) + lvgl_schemas._CONTAINER_SCHEMA_CACHE.clear() + reference = container_schema(wt) + assert cached is not reference + assert cached_result == reference({}) + + +def test_id_recycling_is_caught_by_identity_guard() -> None: + wt = _widget_type("obj") + real_extras = {cv.Optional("a"): cv.int_} + validator_a = container_schema(wt, real_extras) + + cache_key = (id(wt), id(real_extras)) + cached_entry = lvgl_schemas._CONTAINER_SCHEMA_CACHE[cache_key] + sentinel = {cv.Optional("a"): cv.int_} + lvgl_schemas._CONTAINER_SCHEMA_CACHE[cache_key] = ( + cached_entry[0], + sentinel, + cached_entry[2], + ) + + assert container_schema(wt, real_extras) is not validator_a From 55f4e5cb7553c4a77e65e0d987c2fe29817dee67 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 19:18:20 -0500 Subject: [PATCH 0155/1815] Bump the docker-actions group across 1 directory with 2 updates (#16578) Signed-off-by: dependabot[bot] --- .github/workflows/ci-docker.yml | 2 +- .github/workflows/release.yml | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 3fd17888c7..89fbec5420 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -48,7 +48,7 @@ jobs: with: python-version: "3.11" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Set TAG run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9799f882db..344bd416c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -99,15 +99,15 @@ jobs: python-version: "3.11" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Log in to docker hub - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -178,17 +178,17 @@ jobs: merge-multiple: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Log in to docker hub if: matrix.registry == 'dockerhub' - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry if: matrix.registry == 'ghcr' - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} From 9930b3c216cefdd69599a53da337d42652ffbdce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 19:18:31 -0500 Subject: [PATCH 0156/1815] Bump github/codeql-action from 4.35.5 to 4.36.0 (#16579) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index fbef0f5157..dfc0e08bfa 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 + uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 + uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: category: "/language:${{matrix.language}}" From 2b422cbd991d26125986a3f5caa40d2edda60198 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 19:20:39 -0500 Subject: [PATCH 0157/1815] [lvgl] Build widget update action schemas lazily (#16569) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/lvgl/widgets/__init__.py | 36 +++++++++++-- .../lvgl/test_update_action_lazy.py | 53 +++++++++++++++++++ 2 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 tests/component_tests/lvgl/test_update_action_lazy.py diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index ab1c61ff88..400f7c709b 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -1,4 +1,6 @@ +from collections.abc import Callable import sys +from typing import Any from esphome import codegen as cg, config_validation as cv from esphome.automation import register_action @@ -15,6 +17,7 @@ from esphome.const import ( from esphome.core import ID, EsphomeError, TimePeriod from esphome.coroutine import FakeAwaitable from esphome.cpp_generator import MockObj +from esphome.schema_extractors import EnableSchemaExtraction from esphome.types import Expression from ..defines import ( @@ -73,6 +76,34 @@ from ..types import ( EVENT_LAMB = "event_lamb__" +def _build_update_schema(widget_type: "WidgetType") -> Schema: + # Local import: ..schemas imports WidgetType from this module. + from ..schemas import base_update_schema + + return base_update_schema(widget_type, widget_type.parts).extend( + widget_type.modify_schema + ) + + +def _update_action_schema( + widget_type: "WidgetType", +) -> Schema | Callable[[Any], Any]: + # Eager when extracting so build_language_schema.py sees the mapping; + # lazy otherwise to skip ~200 ms of import-time voluptuous work. + if EnableSchemaExtraction: + return _build_update_schema(widget_type) + + cached: Schema | None = None + + def validator(value: Any) -> Any: + nonlocal cached + if cached is None: + cached = _build_update_schema(widget_type) + return cached(value) + + return validator + + class WidgetType: """ Describes a type of Widget, e.g. "bar" or "line" @@ -113,18 +144,17 @@ class WidgetType: # Local import to avoid circular import from ..automation import update_to_code - from ..schemas import WIDGET_TYPES, base_update_schema + from ..schemas import WIDGET_TYPES if not is_mock: if self.name in WIDGET_TYPES: raise EsphomeError(f"Duplicate definition of widget type '{self.name}'") WIDGET_TYPES[self.name] = self - # Register the update action automatically, adding widget-specific properties register_action( f"lvgl.{self.name}.update", ObjUpdateAction, - base_update_schema(self, self.parts).extend(self.modify_schema), + _update_action_schema(self), synchronous=True, )(update_to_code) diff --git a/tests/component_tests/lvgl/test_update_action_lazy.py b/tests/component_tests/lvgl/test_update_action_lazy.py new file mode 100644 index 0000000000..7fcdc149cf --- /dev/null +++ b/tests/component_tests/lvgl/test_update_action_lazy.py @@ -0,0 +1,53 @@ +"""Tests for lvgl..update lazy schema build.""" + +from __future__ import annotations + +from unittest.mock import patch + +from esphome.automation import ACTION_REGISTRY +import esphome.components.lvgl # noqa: F401 +from esphome.components.lvgl.schemas import WIDGET_TYPES +from esphome.components.lvgl.widgets import _update_action_schema +from esphome.config_validation import Schema + + +def _widget_type(name: str = "obj"): + wt = WIDGET_TYPES.get(name) + assert wt is not None, f"widget type {name!r} not registered" + return wt + + +def test_registry_entry_uses_lazy_validator() -> None: + entry = ACTION_REGISTRY["lvgl.label.update"] + assert callable(entry.raw_schema) + assert not isinstance(entry.raw_schema, Schema) + + +def test_lazy_validator_defers_build_until_first_call() -> None: + wt = _widget_type("label") + with patch( + "esphome.components.lvgl.widgets._build_update_schema", + wraps=lambda w: Schema({}), + ) as build_mock: + validator = _update_action_schema(wt) + assert build_mock.call_count == 0 + validator({}) + assert build_mock.call_count == 1 + validator({}) + assert build_mock.call_count == 1 + + +def test_eager_build_when_schema_extraction_enabled() -> None: + wt = _widget_type("label") + with patch("esphome.components.lvgl.widgets.EnableSchemaExtraction", True): + result = _update_action_schema(wt) + assert isinstance(result, Schema) + + +def test_lazy_and_eager_produce_equivalent_validation() -> None: + wt = _widget_type("label") + with patch("esphome.components.lvgl.widgets.EnableSchemaExtraction", True): + eager = _update_action_schema(wt) + lazy = _update_action_schema(wt) + sample = {"id": "label_id"} + assert lazy(sample) == eager(sample) From b0dc688c148b5bc1c5c22459c5271b60952dfd6b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 22 May 2026 20:30:25 -0400 Subject: [PATCH 0158/1815] [esp32] Demote IDF #warning deprecations from error under ESP-IDF toolchain (#16584) --- esphome/components/esp32/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 274cc6fdb3..24312d64ad 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1821,6 +1821,7 @@ async def to_code(config): cg.add_build_flag("-Wno-error=overloaded-virtual") cg.add_build_flag("-Wno-error=reorder") cg.add_build_flag("-Wno-error=volatile") + cg.add_build_flag("-Wno-error=cpp") # -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates cg.add_build_flag("-Wno-missing-field-initializers") From be99553fd4aad7afdbb5d9a49037eb30656008d0 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 23 May 2026 13:56:53 +0930 Subject: [PATCH 0159/1815] [ci] Fix flash memory overflow on tests (#16587) --- tests/components/bluetooth_proxy/test.esp32-c6-idf.yaml | 2 ++ .../build_components_base.esp32-c6-idf.yaml | 1 + 2 files changed, 3 insertions(+) diff --git a/tests/components/bluetooth_proxy/test.esp32-c6-idf.yaml b/tests/components/bluetooth_proxy/test.esp32-c6-idf.yaml index 6c27bd35d0..df5b0123b5 100644 --- a/tests/components/bluetooth_proxy/test.esp32-c6-idf.yaml +++ b/tests/components/bluetooth_proxy/test.esp32-c6-idf.yaml @@ -1,6 +1,8 @@ <<: !include common.yaml esp32_ble_tracker: + +esp32_ble: max_connections: 9 bluetooth_proxy: diff --git a/tests/test_build_components/build_components_base.esp32-c6-idf.yaml b/tests/test_build_components/build_components_base.esp32-c6-idf.yaml index 9dbc465ca2..4105481dc5 100644 --- a/tests/test_build_components/build_components_base.esp32-c6-idf.yaml +++ b/tests/test_build_components/build_components_base.esp32-c6-idf.yaml @@ -4,6 +4,7 @@ esphome: esp32: board: esp32-c6-devkitc-1 + flash_size: 8MB framework: type: esp-idf From 188ff7ebfd70bc1f3fb417af5dbc41a486bcf99b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 May 2026 14:30:12 -0500 Subject: [PATCH 0160/1815] [bluetooth_proxy] Recover slot stuck in DISCONNECTING when CLOSE_EVT is dropped (#16588) --- .../bluetooth_proxy/bluetooth_connection.cpp | 26 ++++++++++++------- .../bluetooth_proxy/bluetooth_connection.h | 2 ++ .../esp32_ble_client/ble_client_base.cpp | 2 ++ .../esp32_ble_client/ble_client_base.h | 10 +++++++ 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 21573f0184..7ba9e61e19 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -135,12 +135,26 @@ void BluetoothConnection::loop() { // - For V3_WITH_CACHE: Services are never sent, disable after INIT state // - For V3_WITHOUT_CACHE: Disable only after service discovery is complete // (send_service_ == DONE_SENDING_SERVICES, which is only set after services are sent) - if (this->state() != espbt::ClientState::INIT && (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || - this->send_service_ == DONE_SENDING_SERVICES)) { + // Never disable while DISCONNECTING — BLEClientBase::loop() needs to keep running so the + // 10s safety timeout can force IDLE if CLOSE_EVT is never delivered. + if (this->state() != espbt::ClientState::INIT && this->state() != espbt::ClientState::DISCONNECTING && + (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || + this->send_service_ == DONE_SENDING_SERVICES)) { this->disable_loop(); } } +void BluetoothConnection::on_disconnect_complete(esp_err_t reason) { + // Called from both the CLOSE_EVT handler and the DISCONNECTING safety timeout in the + // base class. Free the proxy slot, notify the API client, and reset send_service_. + // address_ may already be 0 if reset_connection_ ran earlier on this teardown. + if (this->address_ == 0) { + return; + } + ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, reason); + this->reset_connection_(reason); +} + void BluetoothConnection::reset_connection_(esp_err_t reason) { // Send disconnection notification this->proxy_->send_device_connection(this->address_, false, 0, reason); @@ -372,14 +386,6 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga this->proxy_->send_device_connection(this->address_, false, 0, param->disconnect.reason); break; } - case ESP_GATTC_CLOSE_EVT: { - ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, - param->close.reason); - // Now the GATT connection is fully closed and controller resources are freed - // Safe to mark the connection slot as available - this->reset_connection_(param->close.reason); - break; - } case ESP_GATTC_OPEN_EVT: { if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { this->reset_connection_(param->open.status); diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_proxy/bluetooth_connection.h index b50ea2d6a2..e5600f6af4 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.h @@ -33,6 +33,8 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase { protected: friend class BluetoothProxy; + void on_disconnect_complete(esp_err_t reason) override; + bool supports_efficient_uuids_() const; void send_service_for_discovery_(); void reset_connection_(esp_err_t reason); diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 7f0f2c624d..3fb9632e9a 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -72,6 +72,7 @@ void BLEClientBase::loop() { // never delivered CLOSE_EVT/DISCONNECT_EVT, services would leak without this call. this->release_services(); this->set_idle_(); + this->on_disconnect_complete(ESP_GATT_CONN_TIMEOUT); } } @@ -418,6 +419,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->log_gattc_lifecycle_event_("CLOSE"); this->release_services(); this->set_idle_(); + this->on_disconnect_complete(param->close.reason); break; } case ESP_GATTC_SEARCH_RES_EVT: { diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 4e0b22cc29..0291a4b993 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -140,6 +140,12 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void log_gattc_warning_(const char *operation, esp_err_t err); void log_connection_params_(const char *param_type); void handle_connection_result_(esp_err_t ret); + /// Hook called once a connection has been fully torn down (after release_services() and + /// set_idle_()), from both the CLOSE_EVT handler and the DISCONNECTING safety timeout. + /// Subclasses with extra per-connection accounting (e.g. bluetooth_proxy slot state) + /// override this to release that state. `reason` is the controller reason code, or + /// ESP_GATT_CONN_TIMEOUT for the safety-timeout path. + virtual void on_disconnect_complete(esp_err_t reason) {} /// Transition to IDLE and reset conn_id — call when the connection is fully dead. void set_idle_() { this->set_state(espbt::ClientState::IDLE); @@ -149,6 +155,10 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void set_disconnecting_() { this->disconnecting_started_ = millis(); this->set_state(espbt::ClientState::DISCONNECTING); + // BluetoothConnection::loop() disables the component loop after service discovery + // completes, so the DISCONNECTING timeout check in loop() would never run if CLOSE_EVT + // gets lost. Re-enable the loop so the 10s safety timeout can force IDLE. + this->enable_loop(); } // Compact error logging helpers to reduce flash usage void log_error_(const char *message); From f61610362182d077af8f7e0e80ebca3fc3a77d87 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 23 May 2026 15:44:25 -0400 Subject: [PATCH 0161/1815] [esp32] Replace per-class -Wno-error=X demotes with blanket -Wno-error for ESP-IDF toolchain (#16599) --- esphome/components/esp32/__init__.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 24312d64ad..1864c3b544 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1816,12 +1816,9 @@ async def to_code(config): Path(__file__).parent / "iram_fix.py.script", ) else: - cg.add_build_flag("-Wno-error=format") - cg.add_build_flag("-Wno-error=maybe-uninitialized") - cg.add_build_flag("-Wno-error=overloaded-virtual") - cg.add_build_flag("-Wno-error=reorder") - cg.add_build_flag("-Wno-error=volatile") - cg.add_build_flag("-Wno-error=cpp") + # Undo IDF's blanket -Werror so third-party libraries and user + # lambdas don't need a -Wno-error= entry per warning class. + cg.add_build_flag("-Wno-error") # -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates cg.add_build_flag("-Wno-missing-field-initializers") From 58931f2610f65a5ca6c8c6204ed5253b18ebf378 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sat, 23 May 2026 17:37:59 -0400 Subject: [PATCH 0162/1815] [audio] Add `clear_buffered_data` method to RingBufferAudioSource (#16594) --- .../components/audio/audio_transfer_buffer.cpp | 16 ++++++++++++++++ esphome/components/audio/audio_transfer_buffer.h | 4 ++++ 2 files changed, 20 insertions(+) diff --git a/esphome/components/audio/audio_transfer_buffer.cpp b/esphome/components/audio/audio_transfer_buffer.cpp index d9ce8060e2..a611549e58 100644 --- a/esphome/components/audio/audio_transfer_buffer.cpp +++ b/esphome/components/audio/audio_transfer_buffer.cpp @@ -252,6 +252,22 @@ void RingBufferAudioSource::consume(size_t bytes) { } } +void RingBufferAudioSource::clear_buffered_data() { + // Release the held item before reset() so the source no longer references memory the reset will reclaim. + if (this->acquired_item_ != nullptr) { + this->ring_buffer_->receive_release(this->acquired_item_); + this->acquired_item_ = nullptr; + } + this->current_data_ = nullptr; + this->current_available_ = 0; + this->queued_data_ = nullptr; + this->queued_length_ = 0; + this->item_trailing_ptr_ = nullptr; + this->item_trailing_length_ = 0; + this->splice_length_ = 0; + this->ring_buffer_->reset(); +} + bool RingBufferAudioSource::has_buffered_data() const { // splice_length_ is deliberately not considered here. It holds an incomplete frame whose completion // bytes must still arrive through the ring buffer, which ring_buffer_->available() already reports. diff --git a/esphome/components/audio/audio_transfer_buffer.h b/esphome/components/audio/audio_transfer_buffer.h index b713326141..074684f068 100644 --- a/esphome/components/audio/audio_transfer_buffer.h +++ b/esphome/components/audio/audio_transfer_buffer.h @@ -250,6 +250,10 @@ class RingBufferAudioSource : public AudioReadableBuffer { /// exposure stays in place and fill() returns 0 until it is fully consumed. size_t fill(TickType_t ticks_to_wait, bool pre_shift) override; + /// @brief Discards all buffered audio: releases any held ring buffer item, clears the source's in-flight + /// state, and resets the underlying ring buffer. Must be invoked from the ring buffer's consumer thread. + void clear_buffered_data(); + /// @brief Returns a mutable pointer to the currently exposed audio data. /// The pointer may reference the ring buffer's internal storage or, when exposing a stitched frame /// across a wrap boundary, an internal splice buffer. In either case mutations are safe but data From 74001ccf05646c1a1c0dfe80df0724db822613e4 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sat, 23 May 2026 17:39:20 -0400 Subject: [PATCH 0163/1815] [wifi] Wake main loop when requesting high performance mode (#16598) --- esphome/components/wifi/wifi_component.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index edfb93bba2..72832d7ac8 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -2193,7 +2193,15 @@ bool WiFiComponent::request_high_performance() { } // Give the semaphore (non-blocking). This increments the count. - return xSemaphoreGive(this->high_performance_semaphore_) == pdTRUE; + bool success = xSemaphoreGive(this->high_performance_semaphore_) == pdTRUE; + + // Wake the main loop so the switch to high-performance mode is applied on the + // next tick instead of waiting up to loop_interval. + if (success) { + App.wake_loop_threadsafe(); + } + + return success; } bool WiFiComponent::release_high_performance() { From 5cb145a8c3869e568adbb4628d99d10181e0b473 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sat, 23 May 2026 17:53:53 -0400 Subject: [PATCH 0164/1815] [ethernet] Offload W5500 bulk SPI transfers from the busy-wait path (#16596) --- .../ethernet/ethernet_component_esp32.cpp | 5 + .../components/ethernet/w5500_custom_spi.cpp | 118 ++++++++++++++++++ .../components/ethernet/w5500_custom_spi.h | 35 ++++++ 3 files changed, 158 insertions(+) create mode 100644 esphome/components/ethernet/w5500_custom_spi.cpp create mode 100644 esphome/components/ethernet/w5500_custom_spi.h diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index d4585bf100..46e2bb4ec1 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -5,6 +5,7 @@ #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "w5500_custom_spi.h" #include #include @@ -207,6 +208,10 @@ void EthernetComponent::setup() { #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT w5500_config.poll_period_ms = this->polling_interval_; #endif + // Install the custom SPI driver that offloads the bulk RX/TX frame transfers off the busy-wait + // path. w5500_config (and the devcfg it references) outlives esp_eth_mac_new_w5500() below, which + // runs the driver's init(). + install_w5500_async_spi(w5500_config); #elif defined(USE_ETHERNET_DM9051) dm9051_config.int_gpio_num = this->interrupt_pin_; #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT diff --git a/esphome/components/ethernet/w5500_custom_spi.cpp b/esphome/components/ethernet/w5500_custom_spi.cpp new file mode 100644 index 0000000000..ed4f149738 --- /dev/null +++ b/esphome/components/ethernet/w5500_custom_spi.cpp @@ -0,0 +1,118 @@ +#include "w5500_custom_spi.h" + +#if defined(USE_ESP32) && defined(USE_ETHERNET_W5500) + +#include +#include +#include +#include +#include + +namespace esphome::ethernet { + +namespace { + +// Per-device context returned by init() and handed back to read/write/deinit. +struct W5500CustomSpiContext { + spi_device_handle_t handle; + SemaphoreHandle_t lock; +}; + +// Transfers up to the ESP32 SPI hardware FIFO size (64 bytes) stay on the polling path; larger +// transfers (the frame payloads) use the blocking, DMA-backed transmit. +constexpr uint32_t W5500_SPI_BULK_THRESHOLD = 64; +constexpr uint32_t W5500_SPI_LOCK_TIMEOUT_MS = 50; + +void *w5500_custom_spi_init(const void *spi_config) { + const auto *config = static_cast(spi_config); + auto *ctx = new (std::nothrow) W5500CustomSpiContext{}; + if (ctx == nullptr) { + return nullptr; + } + // The W5500 SPI frame carries the 16-bit address in the command phase and the 8-bit control + // byte in the address phase; mirror what the stock driver configures. + spi_device_interface_config_t devcfg = *config->spi_devcfg; + devcfg.command_bits = 16; + devcfg.address_bits = 8; + if (spi_bus_add_device(config->spi_host_id, &devcfg, &ctx->handle) != ESP_OK) { + delete ctx; + return nullptr; + } + ctx->lock = xSemaphoreCreateMutex(); + if (ctx->lock == nullptr) { + spi_bus_remove_device(ctx->handle); + delete ctx; + return nullptr; + } + return ctx; +} + +esp_err_t w5500_custom_spi_deinit(void *spi_ctx) { + auto *ctx = static_cast(spi_ctx); + spi_bus_remove_device(ctx->handle); + vSemaphoreDelete(ctx->lock); + delete ctx; + return ESP_OK; +} + +// Runs one transaction under the device lock, choosing the polling vs blocking transmit by size. +// Bulk payloads (> FIFO size) block so the calling task sleeps while DMA runs; small register +// accesses stay on the cheaper polling path. Used by both read and write. +esp_err_t w5500_custom_spi_transfer(W5500CustomSpiContext *ctx, spi_transaction_t *trans, uint32_t len) { + if (xSemaphoreTake(ctx->lock, pdMS_TO_TICKS(W5500_SPI_LOCK_TIMEOUT_MS)) != pdTRUE) { + return ESP_ERR_TIMEOUT; + } + esp_err_t ret; + if (len > W5500_SPI_BULK_THRESHOLD) { + ret = spi_device_transmit(ctx->handle, trans); + } else { + ret = spi_device_polling_transmit(ctx->handle, trans); + } + xSemaphoreGive(ctx->lock); + return ret; +} + +esp_err_t w5500_custom_spi_write(void *spi_ctx, uint32_t cmd, uint32_t addr, const void *data, uint32_t len) { + auto *ctx = static_cast(spi_ctx); + spi_transaction_t trans = {}; + trans.cmd = static_cast(cmd); + trans.addr = addr; + trans.length = 8 * len; + trans.tx_buffer = data; + return w5500_custom_spi_transfer(ctx, &trans, len); +} + +esp_err_t w5500_custom_spi_read(void *spi_ctx, uint32_t cmd, uint32_t addr, void *data, uint32_t len) { + auto *ctx = static_cast(spi_ctx); + spi_transaction_t trans = {}; + // Reads of <= 4 bytes use the transaction's inline RX buffer to avoid 4-byte boundary + // overwrites of adjacent registers (same guard the stock driver uses). + const bool use_rxdata = len <= 4; + trans.flags = use_rxdata ? SPI_TRANS_USE_RXDATA : 0; + trans.cmd = static_cast(cmd); + trans.addr = addr; + trans.length = 8 * len; + trans.rx_buffer = data; + esp_err_t ret = w5500_custom_spi_transfer(ctx, &trans, len); + if (use_rxdata && (ret == ESP_OK)) { + memcpy(data, trans.rx_data, len); + } + return ret; +} + +} // namespace + +void install_w5500_async_spi(eth_w5500_config_t &config) { + // Point the custom driver's config at the W5500 config itself; init() reads spi_host_id and + // spi_devcfg back out of it. The self-reference is valid because both the config and the + // spi_devcfg it points at outlive the esp_eth_mac_new_w5500() call that runs init(). + config.custom_spi_driver.config = &config; + config.custom_spi_driver.init = w5500_custom_spi_init; + config.custom_spi_driver.deinit = w5500_custom_spi_deinit; + config.custom_spi_driver.read = w5500_custom_spi_read; + config.custom_spi_driver.write = w5500_custom_spi_write; +} + +} // namespace esphome::ethernet + +#endif // USE_ESP32 && USE_ETHERNET_W5500 diff --git a/esphome/components/ethernet/w5500_custom_spi.h b/esphome/components/ethernet/w5500_custom_spi.h new file mode 100644 index 0000000000..8756a149af --- /dev/null +++ b/esphome/components/ethernet/w5500_custom_spi.h @@ -0,0 +1,35 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_ETHERNET_W5500) + +#include +// IDF 6.0 moved the per-chip SPI MAC drivers to the Espressif Component Registry; eth_w5500_config_t +// is no longer reachable through esp_eth.h and needs the explicit header. +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +#include +#else +#include +#endif + +namespace esphome::ethernet { + +// Installs a custom W5500 SPI driver that offloads the bulk frame transfers off the busy-wait path. +// +// The stock W5500 driver runs every SPI transfer through spi_device_polling_transmit(), which +// busy-waits the CPU for the whole transfer. The frame payload (one large read per received frame, +// one large write per transmitted frame) is by far the biggest transfer, so the RX task and the TX +// caller each spin for hundreds of microseconds per frame. This driver sends payload transfers +// through the blocking, interrupt-driven spi_device_transmit() instead, so the calling task sleeps +// while DMA moves the bytes. Small register accesses stay on the polling path, where the busy-wait +// is cheaper than an interrupt round-trip. +// +// Must be called before esp_eth_mac_new_w5500(). The driver reads spi_host_id and spi_devcfg back +// out of `config` in its init() callback, so `config` (and the spi_devcfg it points at) must stay +// alive until esp_eth_mac_new_w5500() returns. +void install_w5500_async_spi(eth_w5500_config_t &config); + +} // namespace esphome::ethernet + +#endif // USE_ESP32 && USE_ETHERNET_W5500 From c951881eea1a4066eef6d5ab1d872ccac0362bb4 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Sat, 23 May 2026 23:05:18 -0500 Subject: [PATCH 0165/1815] [api] Fix uint32_t/int32_t format strings for stricter GCC toolchain (#16603) --- esphome/components/api/api_server.cpp | 7 ++++--- tests/components/api/common-base.yaml | 28 +++++++++++++-------------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index c30bd2e612..031fa342c1 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -1,6 +1,7 @@ #include "api_server.h" #ifdef USE_API #include +#include #include "api_connection.h" #include "esphome/components/network/util.h" #include "esphome/core/application.h" @@ -677,7 +678,7 @@ uint32_t APIServer::register_active_action_call(uint32_t client_call_id, APIConn // Schedule automatic cleanup after timeout (client will have given up by then) // Uses numeric ID overload to avoid heap allocation from str_sprintf this->set_timeout(action_call_id, USE_API_ACTION_CALL_TIMEOUT_MS, [this, action_call_id]() { - ESP_LOGD(TAG, "Action call %u timed out", action_call_id); + ESP_LOGD(TAG, "Action call %" PRIu32 " timed out", action_call_id); this->unregister_active_action_call(action_call_id); }); @@ -721,7 +722,7 @@ void APIServer::send_action_response(uint32_t action_call_id, bool success, Stri return; } } - ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %u", action_call_id); + ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %" PRIu32, action_call_id); } #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON void APIServer::send_action_response(uint32_t action_call_id, bool success, StringRef error_message, @@ -733,7 +734,7 @@ void APIServer::send_action_response(uint32_t action_call_id, bool success, Stri return; } } - ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %u", action_call_id); + ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %" PRIu32, action_call_id); } #endif // USE_API_USER_DEFINED_ACTION_RESPONSES_JSON #endif // USE_API_USER_DEFINED_ACTION_RESPONSES diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index 504c52a57b..ca86445777 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -120,12 +120,12 @@ api: lambda: 'return condition;' then: - logger.log: - format: "Condition true, value: %d" - args: ['value'] + format: "Condition true, value: %ld" + args: ['(long) value'] else: - logger.log: - format: "Condition false, value: %d" - args: ['value'] + format: "Condition false, value: %ld" + args: ['(long) value'] - logger.log: "After if/else" # Test nested IfAction (multiple ContinuationAction instances) - action: test_nested_if @@ -171,8 +171,8 @@ api: count: !lambda 'return count;' then: - logger.log: - format: "Repeat iteration: %d" - args: ['iteration'] + format: "Repeat iteration: %lu" + args: ['(unsigned long) iteration'] - logger.log: "After repeat" # Test combined continuations (if + while + repeat) - action: test_combined_continuations @@ -193,8 +193,8 @@ api: lambda: 'return id(api_continuation_test_counter) > 0;' then: - logger.log: - format: "Combined: repeat=%d, while=%d" - args: ['iteration', 'id(api_continuation_test_counter)'] + format: "Combined: repeat=%lu, while=%d" + args: ['(unsigned long) iteration', 'id(api_continuation_test_counter)'] - lambda: 'id(api_continuation_test_counter)--;' else: - logger.log: "Skipped loops" @@ -208,8 +208,8 @@ api: - api.respond: success: true - logger.log: - format: "Status response sent (call_id=%d)" - args: [call_id] + format: "Status response sent (call_id=%lu)" + args: ['(unsigned long) call_id'] - action: test_respond_status_error variables: @@ -229,8 +229,8 @@ api: value: float then: - logger.log: - format: "Optional response (call_id=%d, return_response=%d)" - args: [call_id, return_response] + format: "Optional response (call_id=%lu, return_response=%lu)" + args: ['(unsigned long) call_id', '(unsigned long) return_response'] - api.respond: data: !lambda |- root["sensor"] = sensor_name; @@ -264,8 +264,8 @@ api: input: string then: - logger.log: - format: "Only response (call_id=%d)" - args: [call_id] + format: "Only response (call_id=%lu)" + args: ['(unsigned long) call_id'] - api.respond: data: !lambda |- root["input"] = input; From 5f860ff5bdabfb574554e617ed5379dbd847fd04 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 24 May 2026 00:19:07 -0400 Subject: [PATCH 0166/1815] [esp32] Disable IDF's COMPILER_DISABLE_DEFAULT_ERRORS so -Wno-error actually undoes -Werror (#16604) --- esphome/components/esp32/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 1864c3b544..a06ae89c3e 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1816,8 +1816,11 @@ async def to_code(config): Path(__file__).parent / "iram_fix.py.script", ) else: - # Undo IDF's blanket -Werror so third-party libraries and user - # lambdas don't need a -Wno-error= entry per warning class. + # Demote IDF's blanket -Werror to warnings so third-party libs + # and user lambdas don't need a -Wno-error= per warning. + # The sdkconfig knob disables IDF's rewrite to -Werror=all (which + # can't be globally undone); -Wno-error then handles the demotion. + add_idf_sdkconfig_option("CONFIG_COMPILER_DISABLE_DEFAULT_ERRORS", False) cg.add_build_flag("-Wno-error") # -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates cg.add_build_flag("-Wno-missing-field-initializers") From a37f27ee7f2d7ed74f666aa1b53d394b89b8d36f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Mart=C3=ADn?= Date: Sun, 24 May 2026 07:27:31 +0200 Subject: [PATCH 0167/1815] [espnow, ethernet, network, openthread, wifi] centralize network initialization for ESP32 (#14012) Co-authored-by: kbx81 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/espnow/__init__.py | 2 +- .../components/espnow/espnow_component.cpp | 8 ----- .../ethernet/ethernet_component_esp32.cpp | 6 +--- esphome/components/network/__init__.py | 17 +++++++++- .../components/network/network_component.cpp | 33 +++++++++++++++++++ .../components/network/network_component.h | 14 ++++++++ .../components/openthread/openthread_esp.cpp | 3 +- esphome/components/wifi/wifi_component.cpp | 3 -- .../wifi/wifi_component_esp_idf.cpp | 14 ++------ 9 files changed, 69 insertions(+), 31 deletions(-) create mode 100644 esphome/components/network/network_component.cpp create mode 100644 esphome/components/network/network_component.h diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index 7861c0affa..13f278d3bc 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -17,7 +17,7 @@ from esphome.core import HexInt from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] - +AUTO_LOAD = ["network"] byte_vector = cg.std_vector.template(cg.uint8) peer_address_t = cg.std_ns.class_("array").template(cg.uint8, 6) diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index 91d44394e8..403e6f4944 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -149,12 +149,6 @@ bool ESPNowComponent::is_wifi_enabled() { } void ESPNowComponent::setup() { -#ifndef USE_WIFI - // Initialize LwIP stack for wake_loop_threadsafe() socket support - // When WiFi component is present, it handles esp_netif_init() - ESP_ERROR_CHECK(esp_netif_init()); -#endif - if (this->enable_on_boot_) { this->enable_(); } else { @@ -174,8 +168,6 @@ void ESPNowComponent::enable() { void ESPNowComponent::enable_() { if (!this->is_wifi_enabled()) { - esp_event_loop_create_default(); - wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); ESP_ERROR_CHECK(esp_wifi_init(&cfg)); diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 46e2bb4ec1..6481c8c1f4 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -164,11 +164,7 @@ void EthernetComponent::setup() { err = spi_bus_initialize(host, &buscfg, SPI_DMA_CH_AUTO); ESPHL_ERROR_CHECK(err, "SPI bus initialize error"); #endif - - err = esp_netif_init(); - ESPHL_ERROR_CHECK(err, "ETH netif init error"); - err = esp_event_loop_create_default(); - ESPHL_ERROR_CHECK(err, "ETH event loop error"); + // Network interface setup handled by network component esp_netif_config_t cfg = ESP_NETIF_DEFAULT_ETH(); this->eth_netif_ = esp_netif_new(&cfg); diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 811e7c875a..2818b8c93e 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -5,8 +5,9 @@ import esphome.codegen as cg from esphome.components.esp32 import add_idf_sdkconfig_option from esphome.components.psram import is_guaranteed as psram_is_guaranteed import esphome.config_validation as cv -from esphome.const import CONF_ENABLE_IPV6, CONF_MIN_IPV6_ADDR_COUNT +from esphome.const import CONF_ENABLE_IPV6, CONF_ID, CONF_MIN_IPV6_ADDR_COUNT from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] AUTO_LOAD = ["mdns"] @@ -19,6 +20,7 @@ KEY_HIGH_PERFORMANCE_NETWORKING = "high_performance_networking" CONF_ENABLE_HIGH_PERFORMANCE = "enable_high_performance" network_ns = cg.esphome_ns.namespace("network") +NetworkComponent = network_ns.class_("NetworkComponent", cg.Component) IPAddress = network_ns.class_("IPAddress") @@ -107,6 +109,7 @@ def has_high_performance_networking() -> bool: CONFIG_SCHEMA = cv.Schema( { + cv.GenerateID(): cv.declare_id(NetworkComponent), cv.SplitDefault( CONF_ENABLE_IPV6, bk72xx=False, @@ -224,3 +227,15 @@ async def to_code(config): cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY") if CORE.is_rp2040: cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_ENABLE_IPV6") + # Pvariable creation lives in a separate coroutine at NETWORK_SERVICES so it + # emits after wifi/ethernet at COMMUNICATION. This keeps compile-time config + # (above) separate from C++ object lifecycle and allows wiring in interface + # pointers via get_variable(). + if CORE.is_esp32: + CORE.add_job(network_component_to_code, config) + + +@coroutine_with_priority(CoroPriority.NETWORK_SERVICES) +async def network_component_to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) diff --git a/esphome/components/network/network_component.cpp b/esphome/components/network/network_component.cpp new file mode 100644 index 0000000000..40cf64906c --- /dev/null +++ b/esphome/components/network/network_component.cpp @@ -0,0 +1,33 @@ +#include "network_component.h" + +#include "esphome/core/defines.h" +#if defined(USE_NETWORK) && defined(USE_ESP32) +#include "esphome/core/log.h" +#include "esp_err.h" +#include "esp_netif.h" +#include "esp_event.h" +namespace esphome::network { + +static const char *const TAG = "network"; + +void NetworkComponent::setup() { + // Initialize ESP-IDF network interfaces and ensure the default event loop exists + esp_err_t err; + err = esp_netif_init(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_netif_init failed: (%d) %s", err, esp_err_to_name(err)); + this->mark_failed(); + return; + } + err = esp_event_loop_create_default(); + // ESP_ERR_INVALID_STATE is returned if the default loop already exists, + // which is fine since we just want to make sure it exists + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + ESP_LOGE(TAG, "esp_event_loop_create_default failed: (%d) %s", err, esp_err_to_name(err)); + this->mark_failed(); + return; + } +} + +} // namespace esphome::network +#endif diff --git a/esphome/components/network/network_component.h b/esphome/components/network/network_component.h new file mode 100644 index 0000000000..dde15940e4 --- /dev/null +++ b/esphome/components/network/network_component.h @@ -0,0 +1,14 @@ +#pragma once +#include "esphome/core/defines.h" +#if defined(USE_NETWORK) && defined(USE_ESP32) +#include "esphome/core/component.h" + +namespace esphome::network { +class NetworkComponent : public Component { + public: + void setup() override; + // AFTER_BLUETOOTH: BLE controller must initialize before esp_netif_init per IDF guidance. + float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; } +}; +} // namespace esphome::network +#endif diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index 27712bd86a..787f2f5de8 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -35,9 +35,8 @@ void OpenThreadComponent::setup() { esp_vfs_eventfd_config_t eventfd_config = { .max_fds = 3, }; + // Network interface setup handled by network component ESP_ERROR_CHECK(nvs_flash_init()); - ESP_ERROR_CHECK(esp_event_loop_create_default()); - ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_vfs_eventfd_register(&eventfd_config)); xTaskCreate( diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 72832d7ac8..fdbd70bc61 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -634,9 +634,6 @@ void WiFiComponent::setup() { if (this->enable_on_boot_) { this->start(); } else { -#ifdef USE_ESP32 - esp_netif_init(); -#endif this->state_ = WIFI_COMPONENT_STATE_DISABLED; } } diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 4f39a3a4b1..11b39b5000 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -145,23 +145,15 @@ void WiFiComponent::wifi_pre_setup_() { get_mac_address_raw(mac); set_mac_address(mac); } - esp_err_t err = esp_netif_init(); - if (err != ERR_OK) { - ESP_LOGE(TAG, "esp_netif_init failed: %s", esp_err_to_name(err)); - return; - } + // Network interface setup handled by network component s_wifi_event_group = xEventGroupCreate(); if (s_wifi_event_group == nullptr) { ESP_LOGE(TAG, "xEventGroupCreate failed"); return; } - err = esp_event_loop_create_default(); - if (err != ERR_OK) { - ESP_LOGE(TAG, "esp_event_loop_create_default failed: %s", esp_err_to_name(err)); - return; - } esp_event_handler_instance_t instance_wifi_id, instance_ip_id; - err = esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &event_handler, nullptr, &instance_wifi_id); + esp_err_t err = + esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &event_handler, nullptr, &instance_wifi_id); if (err != ERR_OK) { ESP_LOGE(TAG, "esp_event_handler_instance_register failed: %s", esp_err_to_name(err)); return; From 750d52741a8c5136e855c4f1ad5992f64d973630 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sun, 24 May 2026 08:36:53 -0400 Subject: [PATCH 0168/1815] [voice_assistant] Use RingBufferAudioSource (#16597) --- .../components/voice_assistant/__init__.py | 2 +- .../voice_assistant/voice_assistant.cpp | 129 ++++++++---------- .../voice_assistant/voice_assistant.h | 13 +- 3 files changed, 63 insertions(+), 81 deletions(-) diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index 958d1cbf91..f41adfd8de 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -15,7 +15,7 @@ from esphome.const import ( CONF_SPEAKER, ) -AUTO_LOAD = ["ring_buffer", "socket"] +AUTO_LOAD = ["audio", "ring_buffer", "socket"] DEPENDENCIES = ["api", "microphone"] CODEOWNERS = ["@jesserockz", "@kahrendt"] diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index 286e6645d2..af1b98da02 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -30,7 +30,7 @@ VoiceAssistant::VoiceAssistant() { global_voice_assistant = this; } void VoiceAssistant::setup() { this->mic_source_->add_data_callback([this](const std::vector &data) { - std::shared_ptr temp_ring_buffer = this->ring_buffer_; + std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); if (temp_ring_buffer != nullptr) { temp_ring_buffer->write((void *) data.data(), data.size()); } @@ -39,7 +39,7 @@ void VoiceAssistant::setup() { // Second microphone channel if (this->mic_source2_ != nullptr) { this->mic_source2_->add_data_callback([this](const std::vector &data) { - std::shared_ptr temp_ring_buffer = this->ring_buffer2_; + std::shared_ptr temp_ring_buffer = this->ring_buffer2_.lock(); if (temp_ring_buffer != nullptr) { temp_ring_buffer->write((void *) data.data(), data.size()); } @@ -125,62 +125,47 @@ bool VoiceAssistant::allocate_buffers_() { } #endif - if (this->ring_buffer_ == nullptr) { - this->ring_buffer_ = ring_buffer::RingBuffer::create(RING_BUFFER_SIZE); - if (this->ring_buffer_ == nullptr) { + if (this->audio_source_ == nullptr) { + std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(RING_BUFFER_SIZE); + if (temp_ring_buffer == nullptr) { ESP_LOGE(TAG, "Could not allocate ring buffer"); return false; } - } - - if (this->send_buffer_ == nullptr) { - RAMAllocator send_allocator; - this->send_buffer_ = send_allocator.allocate(SEND_BUFFER_SIZE); - if (send_buffer_ == nullptr) { - ESP_LOGW(TAG, "Could not allocate send buffer"); + // Zero-copy source that reads directly from the ring buffer; frame-aligned to never split an int16 sample. + this->audio_source_ = audio::RingBufferAudioSource::create(temp_ring_buffer, SEND_BUFFER_SIZE, sizeof(int16_t)); + if (this->audio_source_ == nullptr) { + ESP_LOGE(TAG, "Could not allocate audio source"); return false; } + this->ring_buffer_ = temp_ring_buffer; } // Second microphone channel - if (this->mic_source2_ != nullptr) { - if (this->ring_buffer2_ == nullptr) { - this->ring_buffer2_ = ring_buffer::RingBuffer::create(RING_BUFFER_SIZE); - if (this->ring_buffer2_ == nullptr) { - ESP_LOGE(TAG, "Could not allocate second ring buffer"); - return false; - } + if ((this->mic_source2_ != nullptr) && (this->audio_source2_ == nullptr)) { + std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(RING_BUFFER_SIZE); + if (temp_ring_buffer == nullptr) { + ESP_LOGE(TAG, "Could not allocate second ring buffer"); + return false; } - - if (this->send_buffer2_ == nullptr) { - RAMAllocator send_allocator; - this->send_buffer2_ = send_allocator.allocate(SEND_BUFFER_SIZE); - if (this->send_buffer2_ == nullptr) { - ESP_LOGW(TAG, "Could not allocate second send buffer"); - return false; - } + this->audio_source2_ = audio::RingBufferAudioSource::create(temp_ring_buffer, SEND_BUFFER_SIZE, sizeof(int16_t)); + if (this->audio_source2_ == nullptr) { + ESP_LOGE(TAG, "Could not allocate second audio source"); + return false; } + this->ring_buffer2_ = temp_ring_buffer; } return true; } void VoiceAssistant::clear_buffers_() { - if (this->send_buffer_ != nullptr) { - memset(this->send_buffer_, 0, SEND_BUFFER_SIZE); - } - - if (this->ring_buffer_ != nullptr) { - this->ring_buffer_->reset(); + if (this->audio_source_ != nullptr) { + this->audio_source_->clear_buffered_data(); } // Second microphone channel - if (this->send_buffer2_ != nullptr) { - memset(this->send_buffer2_, 0, SEND_BUFFER_SIZE); - } - - if (this->ring_buffer2_ != nullptr) { - this->ring_buffer2_->reset(); + if (this->audio_source2_ != nullptr) { + this->audio_source2_->clear_buffered_data(); } #ifdef USE_SPEAKER @@ -195,22 +180,11 @@ void VoiceAssistant::clear_buffers_() { } void VoiceAssistant::deallocate_buffers_() { - if (this->send_buffer_ != nullptr) { - RAMAllocator send_deallocator; - send_deallocator.deallocate(this->send_buffer_, SEND_BUFFER_SIZE); - this->send_buffer_ = nullptr; - } - - this->ring_buffer_.reset(); + // Destroying each source releases its ring buffer; the matching weak_ptr then expires automatically. + this->audio_source_.reset(); // Second microphone channel - if (this->send_buffer2_ != nullptr) { - RAMAllocator send_deallocator; - send_deallocator.deallocate(this->send_buffer2_, SEND_BUFFER_SIZE); - this->send_buffer2_ = nullptr; - } - - this->ring_buffer2_.reset(); + this->audio_source2_.reset(); #ifdef USE_SPEAKER if ((this->speaker_ != nullptr) && (this->speaker_buffer_ != nullptr)) { @@ -316,52 +290,57 @@ void VoiceAssistant::loop() { break; // State changed when udp server port received } case State::STREAMING_MICROPHONE: { + // pre_shift is ignored by RingBufferAudioSource (no intermediate transfer buffer to compact). if (this->audio_mode_ == AUDIO_MODE_API) { // API audio // Both microphone channels are sent, if configured - bool is_available = this->ring_buffer_->available() >= SEND_BUFFER_SIZE; - bool is_available2 = false; - if (this->mic_source2_) { - is_available2 = this->ring_buffer2_->available() >= SEND_BUFFER_SIZE; + size_t available = this->audio_source_->fill(0, false); + size_t available2 = 0; + if (this->audio_source2_ != nullptr) { + available2 = this->audio_source2_->fill(0, false); } - while (is_available || is_available2) { + while (available > 0 || available2 > 0) { api::VoiceAssistantAudio msg; - if (is_available) { - size_t read_bytes = this->ring_buffer_->read((void *) this->send_buffer_, SEND_BUFFER_SIZE, 0); - msg.data = this->send_buffer_; - msg.data_len = read_bytes; + if (available > 0) { + // Zero-copy: send_message() copies the data out before we consume it + msg.data = this->audio_source_->data(); + msg.data_len = available; } // Second microphone channel - if (is_available2) { - size_t read_bytes = this->ring_buffer2_->read((void *) this->send_buffer2_, SEND_BUFFER_SIZE, 0); - msg.data2 = this->send_buffer2_; - msg.data2_len = read_bytes; + if (available2 > 0) { + msg.data2 = this->audio_source2_->data(); + msg.data2_len = available2; } this->api_client_->send_message(msg); - is_available = this->ring_buffer_->available() >= SEND_BUFFER_SIZE; - if (this->mic_source2_) { - is_available2 = this->ring_buffer2_->available() >= SEND_BUFFER_SIZE; - } else { - is_available2 = false; + + if (available > 0) { + this->audio_source_->consume(available); + } + available = this->audio_source_->fill(0, false); + if (available2 > 0) { + this->audio_source2_->consume(available2); + } + if (this->audio_source2_ != nullptr) { + available2 = this->audio_source2_->fill(0, false); } } } else { // UDP (will eventually be deprecated) // Only the primary microphone channel is used - while (this->ring_buffer_->available() >= SEND_BUFFER_SIZE) { - size_t read_bytes = this->ring_buffer_->read((void *) this->send_buffer_, SEND_BUFFER_SIZE, 0); + while (this->audio_source_->fill(0, false) > 0) { if (!this->udp_socket_running_) { if (!this->start_udp_socket_()) { this->set_state_(State::STOP_MICROPHONE, State::IDLE); break; } } - this->socket_->sendto(this->send_buffer_, read_bytes, 0, (struct sockaddr *) &this->dest_addr_, - sizeof(this->dest_addr_)); + this->socket_->sendto(this->audio_source_->data(), this->audio_source_->available(), 0, + (struct sockaddr *) &this->dest_addr_, sizeof(this->dest_addr_)); + this->audio_source_->consume(this->audio_source_->available()); } } // audio mode break; diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index c4fa7eb615..f3ea669e15 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -9,6 +9,7 @@ #include "esphome/core/helpers.h" #include "esphome/components/api/api_connection.h" +#include "esphome/components/audio/audio_transfer_buffer.h" #include "esphome/components/ring_buffer/ring_buffer.h" #include "esphome/components/api/api_pb2.h" #include "esphome/components/microphone/microphone_source.h" @@ -306,8 +307,13 @@ class VoiceAssistant : public Component { std::string wake_word_; - std::shared_ptr ring_buffer_; - std::shared_ptr ring_buffer2_; + // Zero-copy sources that read directly from each microphone channel's ring buffer internal storage. + // Each source owns its ring buffer; the matching ``ring_buffer_``/``ring_buffer2_`` weak_ptr is used by + // the microphone callback (a different thread) to write into it. + std::unique_ptr audio_source_; + std::unique_ptr audio_source2_; + std::weak_ptr ring_buffer_; + std::weak_ptr ring_buffer2_; bool use_wake_word_; uint8_t noise_suppression_level_; @@ -315,9 +321,6 @@ class VoiceAssistant : public Component { float volume_multiplier_; uint32_t conversation_timeout_; - uint8_t *send_buffer_{nullptr}; - uint8_t *send_buffer2_{nullptr}; - bool continuous_{false}; bool silence_detection_; From c17c4478ac17f375c79d561701cdc92a7152ae91 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sun, 24 May 2026 15:32:43 -0400 Subject: [PATCH 0169/1815] [mixer] Support any bit depth audio (#16524) --- esphome/components/mixer/speaker/__init__.py | 29 +-- .../mixer/speaker/mixer_speaker.cpp | 210 +++--------------- .../components/mixer/speaker/mixer_speaker.h | 58 +---- tests/components/mixer/common.yaml | 4 + 4 files changed, 65 insertions(+), 236 deletions(-) diff --git a/esphome/components/mixer/speaker/__init__.py b/esphome/components/mixer/speaker/__init__.py index 59a80d9297..8501843d3f 100644 --- a/esphome/components/mixer/speaker/__init__.py +++ b/esphome/components/mixer/speaker/__init__.py @@ -44,20 +44,10 @@ SOURCE_SPEAKER_SCHEMA = speaker.SPEAKER_SCHEMA.extend( cv.positive_time_period_milliseconds, cv.one_of(CONF_NEVER, lower=True), ), - cv.Optional(CONF_BITS_PER_SAMPLE, default=16): cv.int_range(16, 16), } ) -def _set_stream_limits(config): - audio.set_stream_limits( - min_bits_per_sample=16, - max_bits_per_sample=16, - )(config) - - return config - - def _validate_source_speaker(config): fconf = fv.full_config.get() @@ -67,15 +57,25 @@ def _validate_source_speaker(config): output_speaker_id = fconf.get_config_for_path(path) config[CONF_OUTPUT_SPEAKER] = output_speaker_id + inherit_property_from(CONF_BITS_PER_SAMPLE, CONF_OUTPUT_SPEAKER)(config) inherit_property_from(CONF_NUM_CHANNELS, CONF_OUTPUT_SPEAKER)(config) inherit_property_from(CONF_SAMPLE_RATE, CONF_OUTPUT_SPEAKER)(config) + audio.final_validate_audio_schema( + "mixer", + audio_device=CONF_OUTPUT_SPEAKER, + sample_rate=config.get(CONF_SAMPLE_RATE), + )(config) + + return config + + +def _validate_output_speaker(config): audio.final_validate_audio_schema( "mixer", audio_device=CONF_OUTPUT_SPEAKER, bits_per_sample=config.get(CONF_BITS_PER_SAMPLE), channels=config.get(CONF_NUM_CHANNELS), - sample_rate=config.get(CONF_SAMPLE_RATE), )(config) return config @@ -89,8 +89,8 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_SOURCE_SPEAKERS): cv.All( cv.ensure_list(SOURCE_SPEAKER_SCHEMA), cv.Length(min=2, max=8), - [_set_stream_limits], ), + cv.Optional(CONF_BITS_PER_SAMPLE): cv.one_of(8, 16, 24, 32, int=True), cv.Optional(CONF_NUM_CHANNELS): cv.int_range(min=1, max=2), cv.Optional(CONF_QUEUE_MODE, default=False): cv.boolean, cv.Optional(CONF_TASK_STACK_IN_PSRAM, default=False): cv.boolean, @@ -100,13 +100,15 @@ CONFIG_SCHEMA = cv.All( ) FINAL_VALIDATE_SCHEMA = cv.All( + inherit_property_from(CONF_BITS_PER_SAMPLE, CONF_OUTPUT_SPEAKER), + inherit_property_from(CONF_NUM_CHANNELS, CONF_OUTPUT_SPEAKER), cv.Schema( { cv.Optional(CONF_SOURCE_SPEAKERS): [_validate_source_speaker], }, extra=cv.ALLOW_EXTRA, ), - inherit_property_from(CONF_NUM_CHANNELS, CONF_OUTPUT_SPEAKER), + _validate_output_speaker, ) @@ -116,6 +118,7 @@ async def to_code(config): spkr = await cg.get_variable(config[CONF_OUTPUT_SPEAKER]) + cg.add(var.set_output_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) cg.add(var.set_output_channels(config[CONF_NUM_CHANNELS])) cg.add(var.set_output_speaker(spkr)) cg.add(var.set_queue_mode(config[CONF_QUEUE_MODE])) diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 1a995a6edf..6128dc3767 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -7,8 +7,10 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include // esp-audio-libs +#include // esp-audio-libs + #include -#include #include namespace esphome::mixer_speaker { @@ -22,19 +24,8 @@ static const uint32_t MIXER_AUTO_STOP_DEBOUNCE_MS = 200; static const size_t TASK_STACK_SIZE = 4096; -static const int16_t MAX_AUDIO_SAMPLE_VALUE = INT16_MAX; -static const int16_t MIN_AUDIO_SAMPLE_VALUE = INT16_MIN; - static const char *const TAG = "speaker_mixer"; -// Gives the Q15 fixed point scaling factor to reduce by 0 dB, 1dB, ..., 50 dB -// dB to PCM scaling factor formula: floating_point_scale_factor = 2^(-db/6.014) -// float to Q15 fixed point formula: q15_scale_factor = floating_point_scale_factor * 2^(15) -static const std::array DECIBEL_REDUCTION_TABLE = { - 32767, 29201, 26022, 23189, 20665, 18415, 16410, 14624, 13032, 11613, 10349, 9222, 8218, 7324, 6527, 5816, 5183, - 4619, 4116, 3668, 3269, 2913, 2596, 2313, 2061, 1837, 1637, 1459, 1300, 1158, 1032, 920, 820, 731, - 651, 580, 517, 461, 411, 366, 326, 291, 259, 231, 206, 183, 163, 146, 130, 116, 103}; - // Event bits for SourceSpeaker command processing enum SourceSpeakerEventBits : uint32_t { SOURCE_SPEAKER_COMMAND_START = (1 << 0), @@ -315,97 +306,17 @@ size_t SourceSpeaker::process_data_from_source(std::shared_ptraudio_stream_info_.bytes_to_samples(bytes_read); if (samples_to_duck > 0) { - int16_t *current_buffer = reinterpret_cast(audio_source->mutable_data()); - - duck_samples(current_buffer, samples_to_duck, &this->current_ducking_db_reduction_, - &this->ducking_transition_samples_remaining_, this->samples_per_ducking_step_, - this->db_change_per_ducking_step_); + esp_audio_libs::ducking::apply(audio_source->mutable_data(), + static_cast(this->audio_stream_info_.get_bits_per_sample() / 8), + samples_to_duck, this->ducking_state_); } return bytes_read; } void SourceSpeaker::apply_ducking(uint8_t decibel_reduction, uint32_t duration) { - if (this->target_ducking_db_reduction_ != decibel_reduction) { - // Start transition from the previous target (which becomes the new current level) - this->current_ducking_db_reduction_ = this->target_ducking_db_reduction_; - - this->target_ducking_db_reduction_ = decibel_reduction; - - // Calculate the number of intermediate dB steps for the transition timing. - // Subtract 1 because the first step is taken immediately after this calculation. - uint8_t total_ducking_steps = 0; - if (this->target_ducking_db_reduction_ > this->current_ducking_db_reduction_) { - // The dB reduction level is increasing (which results in quieter audio) - total_ducking_steps = this->target_ducking_db_reduction_ - this->current_ducking_db_reduction_ - 1; - this->db_change_per_ducking_step_ = 1; - } else { - // The dB reduction level is decreasing (which results in louder audio) - total_ducking_steps = this->current_ducking_db_reduction_ - this->target_ducking_db_reduction_ - 1; - this->db_change_per_ducking_step_ = -1; - } - if ((duration > 0) && (total_ducking_steps > 0)) { - this->ducking_transition_samples_remaining_ = this->audio_stream_info_.ms_to_samples(duration); - - this->samples_per_ducking_step_ = this->ducking_transition_samples_remaining_ / total_ducking_steps; - this->ducking_transition_samples_remaining_ = - this->samples_per_ducking_step_ * total_ducking_steps; // adjust for integer division rounding - - this->current_ducking_db_reduction_ += this->db_change_per_ducking_step_; - } else { - this->ducking_transition_samples_remaining_ = 0; - this->current_ducking_db_reduction_ = this->target_ducking_db_reduction_; - } - } -} - -void SourceSpeaker::duck_samples(int16_t *input_buffer, uint32_t input_samples_to_duck, - int8_t *current_ducking_db_reduction, uint32_t *ducking_transition_samples_remaining, - uint32_t samples_per_ducking_step, int8_t db_change_per_ducking_step) { - if (*ducking_transition_samples_remaining > 0) { - // Ducking level is still transitioning - - // Takes the ceiling of input_samples_to_duck/samples_per_ducking_step - uint32_t ducking_steps_in_batch = - input_samples_to_duck / samples_per_ducking_step + (input_samples_to_duck % samples_per_ducking_step != 0); - - for (uint32_t i = 0; i < ducking_steps_in_batch; ++i) { - uint32_t samples_left_in_step = *ducking_transition_samples_remaining % samples_per_ducking_step; - - if (samples_left_in_step == 0) { - samples_left_in_step = samples_per_ducking_step; - } - - uint32_t samples_to_duck = std::min(input_samples_to_duck, samples_left_in_step); - samples_to_duck = std::min(samples_to_duck, *ducking_transition_samples_remaining); - - // Ensure we only point to valid index in the Q15 scaling factor table - uint8_t safe_db_reduction_index = - clamp(*current_ducking_db_reduction, 0, DECIBEL_REDUCTION_TABLE.size() - 1); - int16_t q15_scale_factor = DECIBEL_REDUCTION_TABLE[safe_db_reduction_index]; - - audio::scale_audio_samples(input_buffer, input_buffer, q15_scale_factor, samples_to_duck); - - if (samples_left_in_step - samples_to_duck == 0) { - // After scaling the current samples, we are ready to transition to the next step - *current_ducking_db_reduction += db_change_per_ducking_step; - } - - input_buffer += samples_to_duck; - *ducking_transition_samples_remaining -= samples_to_duck; - input_samples_to_duck -= samples_to_duck; - } - } - - if ((*current_ducking_db_reduction > 0) && (input_samples_to_duck > 0)) { - // Audio is ducked, but its not in the middle of a transition step - - uint8_t safe_db_reduction_index = - clamp(*current_ducking_db_reduction, 0, DECIBEL_REDUCTION_TABLE.size() - 1); - int16_t q15_scale_factor = DECIBEL_REDUCTION_TABLE[safe_db_reduction_index]; - - audio::scale_audio_samples(input_buffer, input_buffer, q15_scale_factor, input_samples_to_duck); - } + const uint32_t transition_samples = duration > 0 ? this->audio_stream_info_.ms_to_samples(duration) : 0; + esp_audio_libs::ducking::set_target(this->ducking_state_, decibel_reduction, transition_samples); } void SourceSpeaker::enter_stopping_state_() { @@ -417,8 +328,9 @@ void SourceSpeaker::enter_stopping_state_() { void MixerSpeaker::dump_config() { ESP_LOGCONFIG(TAG, "Speaker Mixer:\n" - " Number of output channels: %u", - this->output_channels_); + " Number of output channels: %" PRIu8 "\n" + " Output bits per sample: %" PRIu8, + this->output_channels_, this->output_bits_per_sample_); } void MixerSpeaker::setup() { @@ -512,13 +424,8 @@ void MixerSpeaker::loop() { esp_err_t MixerSpeaker::start(audio::AudioStreamInfo &stream_info) { if (!this->audio_stream_info_.has_value()) { - if (stream_info.get_bits_per_sample() != 16) { - // Audio streams that don't have 16 bits per sample are not supported - return ESP_ERR_NOT_SUPPORTED; - } - - this->audio_stream_info_ = audio::AudioStreamInfo(stream_info.get_bits_per_sample(), this->output_channels_, - stream_info.get_sample_rate()); + this->audio_stream_info_ = + audio::AudioStreamInfo(this->output_bits_per_sample_, this->output_channels_, stream_info.get_sample_rate()); this->output_speaker_->set_audio_stream_info(this->audio_stream_info_.value()); } else { if (!this->queue_mode_ && (stream_info.get_sample_rate() != this->audio_stream_info_.value().get_sample_rate())) { @@ -542,57 +449,6 @@ esp_err_t MixerSpeaker::start(audio::AudioStreamInfo &stream_info) { return ESP_OK; } -void MixerSpeaker::copy_frames(const int16_t *input_buffer, audio::AudioStreamInfo input_stream_info, - int16_t *output_buffer, audio::AudioStreamInfo output_stream_info, - uint32_t frames_to_transfer) { - uint8_t input_channels = input_stream_info.get_channels(); - uint8_t output_channels = output_stream_info.get_channels(); - const uint8_t max_input_channel_index = input_channels - 1; - - if (input_channels == output_channels) { - size_t bytes_to_copy = input_stream_info.frames_to_bytes(frames_to_transfer); - memcpy(output_buffer, input_buffer, bytes_to_copy); - - return; - } - - for (uint32_t frame_index = 0; frame_index < frames_to_transfer; ++frame_index) { - for (uint8_t output_channel_index = 0; output_channel_index < output_channels; ++output_channel_index) { - uint8_t input_channel_index = std::min(output_channel_index, max_input_channel_index); - output_buffer[output_channels * frame_index + output_channel_index] = - input_buffer[input_channels * frame_index + input_channel_index]; - } - } -} - -void MixerSpeaker::mix_audio_samples(const int16_t *primary_buffer, audio::AudioStreamInfo primary_stream_info, - const int16_t *secondary_buffer, audio::AudioStreamInfo secondary_stream_info, - int16_t *output_buffer, audio::AudioStreamInfo output_stream_info, - uint32_t frames_to_mix) { - const uint8_t primary_channels = primary_stream_info.get_channels(); - const uint8_t secondary_channels = secondary_stream_info.get_channels(); - const uint8_t output_channels = output_stream_info.get_channels(); - - const uint8_t max_primary_channel_index = primary_channels - 1; - const uint8_t max_secondary_channel_index = secondary_channels - 1; - - for (uint32_t frames_index = 0; frames_index < frames_to_mix; ++frames_index) { - for (uint8_t output_channel_index = 0; output_channel_index < output_channels; ++output_channel_index) { - const uint32_t secondary_channel_index = std::min(output_channel_index, max_secondary_channel_index); - const int32_t secondary_sample = secondary_buffer[frames_index * secondary_channels + secondary_channel_index]; - - const uint32_t primary_channel_index = std::min(output_channel_index, max_primary_channel_index); - const int32_t primary_sample = - static_cast(primary_buffer[frames_index * primary_channels + primary_channel_index]); - - const int32_t added_sample = secondary_sample + primary_sample; - - output_buffer[frames_index * output_channels + output_channel_index] = - static_cast(clamp(added_sample, MIN_AUDIO_SAMPLE_VALUE, MAX_AUDIO_SAMPLE_VALUE)); - } - } -} - // NOLINTBEGIN(bugprone-unchecked-optional-access) -- audio_stream_info_ always set before this task is created void MixerSpeaker::audio_mixer_task(void *params) { MixerSpeaker *this_mixer = static_cast(params); @@ -662,6 +518,10 @@ void MixerSpeaker::audio_mixer_task(void *params) { uint32_t frames_to_mix = output_frames_free; + const audio::AudioStreamInfo &output_info = this_mixer->audio_stream_info_.value(); + const uint8_t output_bps = output_info.get_bits_per_sample() / 8; + const uint8_t output_channels = output_info.get_channels(); + if ((audio_sources_with_data.size() == 1) || this_mixer->queue_mode_) { // Only one speaker has audio data, just copy samples over @@ -669,14 +529,15 @@ void MixerSpeaker::audio_mixer_task(void *params) { if (active_stream_info.get_sample_rate() == this_mixer->output_speaker_->get_audio_stream_info().get_sample_rate()) { - // Speaker's sample rate matches the output speaker's, copy directly + // Speaker's sample rate matches the output speaker's, convert directly into the output buffer const uint32_t frames_available_in_buffer = active_stream_info.bytes_to_frames(audio_sources_with_data[0]->available()); frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer); - copy_frames(reinterpret_cast(audio_sources_with_data[0]->data()), active_stream_info, - reinterpret_cast(output_transfer_buffer->get_buffer_end()), - this_mixer->audio_stream_info_.value(), frames_to_mix); + esp_audio_libs::pcm_convert::copy_frames( + audio_sources_with_data[0]->data(), output_transfer_buffer->get_buffer_end(), + static_cast(active_stream_info.get_bits_per_sample() / 8), active_stream_info.get_channels(), + output_bps, output_channels, frames_to_mix); // Set playback delay for newly contributing source if (!speakers_with_data[0]->has_contributed_.load(std::memory_order_acquire)) { @@ -690,8 +551,7 @@ void MixerSpeaker::audio_mixer_task(void *params) { audio_sources_with_data[0]->consume(active_stream_info.frames_to_bytes(frames_to_mix)); // Update output transfer buffer length and pipeline frame count - output_transfer_buffer->increase_buffer_length( - this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix)); + output_transfer_buffer->increase_buffer_length(output_info.frames_to_bytes(frames_to_mix)); this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release); } else { // Speaker's stream info doesn't match the output speaker's, so it's a new source speaker @@ -703,7 +563,7 @@ void MixerSpeaker::audio_mixer_task(void *params) { } else { // Speaker has finished writing the current audio, update the stream information and restart the speaker this_mixer->audio_stream_info_ = - audio::AudioStreamInfo(active_stream_info.get_bits_per_sample(), this_mixer->output_channels_, + audio::AudioStreamInfo(this_mixer->output_bits_per_sample_, this_mixer->output_channels_, active_stream_info.get_sample_rate()); this_mixer->output_speaker_->set_audio_stream_info(this_mixer->audio_stream_info_.value()); this_mixer->output_speaker_->start(); @@ -719,21 +579,22 @@ void MixerSpeaker::audio_mixer_task(void *params) { speakers_with_data[i]->get_audio_stream_info().bytes_to_frames(audio_sources_with_data[i]->available()); frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer); } - const int16_t *primary_buffer = reinterpret_cast(audio_sources_with_data[0]->data()); + const uint8_t *primary_buffer = audio_sources_with_data[0]->data(); audio::AudioStreamInfo primary_stream_info = speakers_with_data[0]->get_audio_stream_info(); - // Mix two streams together + // Mix two streams together at a time, accumulating into the output buffer. for (size_t i = 1; i < audio_sources_with_data.size(); ++i) { - mix_audio_samples(primary_buffer, primary_stream_info, - reinterpret_cast(audio_sources_with_data[i]->data()), - speakers_with_data[i]->get_audio_stream_info(), - reinterpret_cast(output_transfer_buffer->get_buffer_end()), - this_mixer->audio_stream_info_.value(), frames_to_mix); + esp_audio_libs::mixer::mix_frames( + primary_buffer, static_cast(primary_stream_info.get_bits_per_sample() / 8), + primary_stream_info.get_channels(), audio_sources_with_data[i]->data(), + static_cast(speakers_with_data[i]->get_audio_stream_info().get_bits_per_sample() / 8), + speakers_with_data[i]->get_audio_stream_info().get_channels(), output_transfer_buffer->get_buffer_end(), + output_bps, output_channels, frames_to_mix); if (i != audio_sources_with_data.size() - 1) { // Need to mix more streams together, point primary buffer and stream info to the already mixed output - primary_buffer = reinterpret_cast(output_transfer_buffer->get_buffer_end()); - primary_stream_info = this_mixer->audio_stream_info_.value(); + primary_buffer = output_transfer_buffer->get_buffer_end(); + primary_stream_info = output_info; } } @@ -754,8 +615,7 @@ void MixerSpeaker::audio_mixer_task(void *params) { } // Update output transfer buffer length and pipeline frame count (once, not per source) - output_transfer_buffer->increase_buffer_length( - this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix)); + output_transfer_buffer->increase_buffer_length(output_info.frames_to_bytes(frames_to_mix)); this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release); } } diff --git a/esphome/components/mixer/speaker/mixer_speaker.h b/esphome/components/mixer/speaker/mixer_speaker.h index f57bead679..f1ae919b50 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.h +++ b/esphome/components/mixer/speaker/mixer_speaker.h @@ -11,6 +11,8 @@ #include "esphome/core/helpers.h" #include "esphome/core/static_task.h" +#include // esp-audio-libs + #include #include @@ -22,7 +24,8 @@ namespace esphome::mixer_speaker { * - Source speaker commands are signaled via event group bits and processed in its loop function to ensure thread * safety * - Directly handles pausing at the SourceSpeaker level; pause state is not passed through to the output speaker. - * - Audio sent to the SourceSpeaker must have 16 bits per sample. + * - Audio sent to the SourceSpeaker can have 8, 16, 24, or 32 bits per sample. Each source is converted to the output + * speaker's bit depth as it is mixed (or copied) into the output buffer. * - Audio sent to the SourceSpeaker can have any number of channels. They are duplicated or ignored as needed to match * the number of channels required for the output speaker. * - In queue mode, the audio sent to the SourceSpeakers can have different sample rates. @@ -93,19 +96,6 @@ class SourceSpeaker : public speaker::Speaker, public Component { void enter_stopping_state_(); void send_command_(uint32_t command_bit, bool wake_loop = false); - /// @brief Ducks audio samples by a specified amount. When changing the ducking amount, it can transition gradually - /// over a specified amount of samples. - /// @param input_buffer buffer with audio samples to be ducked in place - /// @param input_samples_to_duck number of samples to process in ``input_buffer`` - /// @param current_ducking_db_reduction pointer to the current dB reduction - /// @param ducking_transition_samples_remaining pointer to the total number of samples left before the - /// transition is finished - /// @param samples_per_ducking_step total number of samples per ducking step for the transition - /// @param db_change_per_ducking_step the change in dB reduction per step - static void duck_samples(int16_t *input_buffer, uint32_t input_samples_to_duck, int8_t *current_ducking_db_reduction, - uint32_t *ducking_transition_samples_remaining, uint32_t samples_per_ducking_step, - int8_t db_change_per_ducking_step); - MixerSpeaker *parent_; std::shared_ptr audio_source_; @@ -118,11 +108,7 @@ class SourceSpeaker : public speaker::Speaker, public Component { bool pause_state_{false}; - int8_t target_ducking_db_reduction_{0}; - int8_t current_ducking_db_reduction_{0}; - int8_t db_change_per_ducking_step_{1}; - uint32_t ducking_transition_samples_remaining_{0}; - uint32_t samples_per_ducking_step_{0}; + esp_audio_libs::ducking::DuckingState ducking_state_{}; std::atomic pending_playback_frames_{0}; std::atomic playback_delay_frames_{0}; // Frames in output pipeline when this source started contributing @@ -143,12 +129,14 @@ class MixerSpeaker : public Component { /// @brief Starts the mixer task. Called by a source speaker giving the current audio stream information /// @param stream_info The calling source speaker's audio stream information - /// @return ESP_ERR_NOT_SUPPORTED if the incoming stream is incompatible due to unsupported bits per sample - /// ESP_ERR_INVALID_ARG if the incoming stream is incompatible to be mixed with the other input audio stream + /// @return ESP_ERR_INVALID_ARG if the incoming stream is incompatible to be mixed with the other input audio stream /// ESP_OK if the incoming stream is compatible and the mixer task starts esp_err_t start(audio::AudioStreamInfo &stream_info); void set_output_channels(uint8_t output_channels) { this->output_channels_ = output_channels; } + void set_output_bits_per_sample(uint8_t output_bits_per_sample) { + this->output_bits_per_sample_ = output_bits_per_sample; + } void set_output_speaker(speaker::Speaker *speaker) { this->output_speaker_ = speaker; } void set_queue_mode(bool queue_mode) { this->queue_mode_ = queue_mode; } void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; } @@ -159,33 +147,6 @@ class MixerSpeaker : public Component { uint32_t get_frames_in_pipeline() const { return this->frames_in_pipeline_.load(std::memory_order_acquire); } protected: - /// @brief Copies audio frames from the input buffer to the output buffer taking into account the number of channels - /// in each stream. If the output stream has more channels, the input samples are duplicated. If the output stream has - /// less channels, the extra channel input samples are dropped. - /// @param input_buffer - /// @param input_stream_info - /// @param output_buffer - /// @param output_stream_info - /// @param frames_to_transfer number of frames (consisting of a sample for each channel) to copy from the input buffer - static void copy_frames(const int16_t *input_buffer, audio::AudioStreamInfo input_stream_info, int16_t *output_buffer, - audio::AudioStreamInfo output_stream_info, uint32_t frames_to_transfer); - - /// @brief Mixes the primary and secondary streams taking into account the number of channels in each stream. Primary - /// and secondary samples are duplicated or dropped as necessary to ensure the output stream has the configured number - /// of channels. Output samples are clamped to the corresponding int16 min or max values if the mixed sample - /// overflows. - /// @param primary_buffer samples buffer for the primary stream - /// @param primary_stream_info stream info for the primary stream - /// @param secondary_buffer samples buffer for secondary stream - /// @param secondary_stream_info stream info for the secondary stream - /// @param output_buffer buffer for the mixed samples - /// @param output_stream_info stream info for the output buffer - /// @param frames_to_mix number of frames in the primary and secondary buffers to mix together - static void mix_audio_samples(const int16_t *primary_buffer, audio::AudioStreamInfo primary_stream_info, - const int16_t *secondary_buffer, audio::AudioStreamInfo secondary_stream_info, - int16_t *output_buffer, audio::AudioStreamInfo output_stream_info, - uint32_t frames_to_mix); - static void audio_mixer_task(void *params); EventGroupHandle_t event_group_{nullptr}; @@ -193,6 +154,7 @@ class MixerSpeaker : public Component { FixedVector source_speakers_; speaker::Speaker *output_speaker_{nullptr}; + uint8_t output_bits_per_sample_; uint8_t output_channels_; bool queue_mode_; bool task_stack_in_psram_{false}; diff --git a/tests/components/mixer/common.yaml b/tests/components/mixer/common.yaml index e171b9499c..ef613b82bc 100644 --- a/tests/components/mixer/common.yaml +++ b/tests/components/mixer/common.yaml @@ -16,8 +16,12 @@ speaker: id: speaker_id dac_type: external i2s_dout_pin: ${dout_pin} + bits_per_sample: 32bit + channel: stereo - platform: mixer output_speaker: speaker_id + bits_per_sample: 32 + num_channels: 2 source_speakers: - id: source_speaker_1_id - id: source_speaker_2_id From 5cb7e622415ca036f8b33a139ea5db871eabc27d Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sun, 24 May 2026 15:33:32 -0400 Subject: [PATCH 0170/1815] [audio] Use RingBufferAudioSource for decoding (#16564) --- esphome/components/audio/audio_decoder.cpp | 83 +++++++++++----------- esphome/components/audio/audio_decoder.h | 9 +-- 2 files changed, 46 insertions(+), 46 deletions(-) diff --git a/esphome/components/audio/audio_decoder.cpp b/esphome/components/audio/audio_decoder.cpp index d4ff59fc36..f709c23fb6 100644 --- a/esphome/components/audio/audio_decoder.cpp +++ b/esphome/components/audio/audio_decoder.cpp @@ -9,9 +9,12 @@ namespace esphome::audio { static const char *const TAG = "audio.decoder"; -static const uint32_t DECODING_TIMEOUT_MS = 50; // The decode function will yield after this duration static const uint32_t READ_WRITE_TIMEOUT_MS = 20; // Timeout for transferring audio data +// Max consecutive decode iterations that consume input but produce no output; e.g., skipping a large metadata block, +// before yielding and returning. +static const uint8_t MAX_NO_OUTPUT_ITERATIONS = 32; + static const uint32_t MAX_POTENTIALLY_FAILED_COUNT = 10; AudioDecoder::AudioDecoder(size_t input_buffer_size, size_t output_buffer_size) @@ -20,11 +23,13 @@ AudioDecoder::AudioDecoder(size_t input_buffer_size, size_t output_buffer_size) } esp_err_t AudioDecoder::add_source(std::weak_ptr &input_ring_buffer) { - auto source = AudioSourceTransferBuffer::create(this->input_buffer_size_); + // Zero-copy source reading directly from the ring buffer's internal storage. Raw file data is byte + // aligned, so no frame alignment is required. + auto source = RingBufferAudioSource::create(input_ring_buffer.lock(), this->input_buffer_size_); if (source == nullptr) { - return ESP_ERR_NO_MEM; + // create() only returns nullptr for invalid arguments (expired ring buffer or zero buffer size) + return ESP_ERR_INVALID_ARG; } - source->set_source(input_ring_buffer); this->input_buffer_ = std::move(source); return ESP_OK; } @@ -141,13 +146,7 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) { } FileDecoderState state = FileDecoderState::MORE_TO_PROCESS; - - uint32_t decoding_start = millis(); - - bool first_loop_iteration = true; - - size_t bytes_processed = 0; - size_t bytes_available_before_processing = 0; + uint8_t no_output_iterations = 0; while (state == FileDecoderState::MORE_TO_PROCESS) { // Transfer decoded out @@ -161,45 +160,39 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) { this->playback_ms_ += this->audio_stream_info_.value().frames_to_milliseconds_with_remainder(&this->accumulated_frames_written_); } + + if ((bytes_written > 0) && (this->output_transfer_buffer_->available() == 0)) { + // All decoded audio has been flushed to the sink; return so the caller can react to stop/pause before + // decoding the next batch + return AudioDecoderState::DECODING; + } } else { // If paused, block to avoid wasting CPU resources delay(READ_WRITE_TIMEOUT_MS); } - // Verify there is enough space to store more decoded audio and that the function hasn't been running too long - if ((this->output_transfer_buffer_->free() < this->free_buffer_required_) || - (millis() - decoding_start > DECODING_TIMEOUT_MS)) { + if (this->output_transfer_buffer_->available() > 0) { + // Output transfer buffer indicates backpressure, return so caller can handle other events; + // e.g., stop/pause, before trying again return AudioDecoderState::DECODING; } - // Decode more audio - - // Never shift the input buffer; every decoder buffers internally and consumes only what it processed. - size_t bytes_read = this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false); - - if (!first_loop_iteration && (this->input_buffer_->available() < bytes_processed)) { - // Less data is available than what was processed in last iteration, so don't attempt to decode. - // This attempts to avoid the decoder from consistently trying to decode an incomplete frame. The transfer buffer - // will shift the remaining data to the start and copy more from the source the next time the decode function is - // called - break; + // Reaching here means no decoded output is pending (any would have returned above). Bounds long no-output + // stretches; e.g., skipping a large metadata block, so a source that keeps the ring buffer full can't spin this + // loop without yielding and trip the watchdog. The delay yields allowing other tasks to feed the watchdog and + // the return keeps stop/pause responsive. + if (++no_output_iterations >= MAX_NO_OUTPUT_ITERATIONS) { + delay(1); + return AudioDecoderState::DECODING; } - bytes_available_before_processing = this->input_buffer_->available(); + // Expose the next chunk of file data. Every decoder buffers internally and consumes only what it + // processed, so the source does not need to accumulate or stitch chunks across fill() calls. + this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false); - if ((this->potentially_failed_count_ > 0) && (bytes_read == 0)) { - // Failed to decode in last attempt and there is no new data + const size_t available_before_decode = this->input_buffer_->available(); - if ((this->input_buffer_->free() == 0) && first_loop_iteration) { - // The input buffer is full (or read-only, e.g. const flash source). Since it previously failed on the exact - // same data, we can never recover. For const sources this is correct: the entire file is already available, so - // a decode failure is genuine, not a transient out-of-data condition. - state = FileDecoderState::FAILED; - } else { - // Attempt to get more data next time - state = FileDecoderState::IDLE; - } - } else if (this->input_buffer_->available() == 0) { + if (available_before_decode == 0) { // No data to decode, attempt to get more data next time state = FileDecoderState::IDLE; } else { @@ -231,9 +224,6 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) { } } - first_loop_iteration = false; - bytes_processed = bytes_available_before_processing - this->input_buffer_->available(); - if (state == FileDecoderState::POTENTIALLY_FAILED) { ++this->potentially_failed_count_; } else if (state == FileDecoderState::END_OF_FILE) { @@ -241,7 +231,16 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) { } else if (state == FileDecoderState::FAILED) { return AudioDecoderState::FAILED; } else if (state == FileDecoderState::MORE_TO_PROCESS) { - this->potentially_failed_count_ = 0; + // Reset the failsafe only when the iteration made forward progress: input was consumed or output was + // produced (output_transfer_buffer_ is drained empty above, so any available bytes are new). A + // MORE_TO_PROCESS that neither consumes input nor produces output means the decoder is stalled; count it + // toward the failsafe so a stuck stream eventually surfaces as FAILED instead of looping forever. + if ((this->input_buffer_->available() < available_before_decode) || + (this->output_transfer_buffer_->available() > 0)) { + this->potentially_failed_count_ = 0; + } else { + ++this->potentially_failed_count_; + } } } return AudioDecoderState::DECODING; diff --git a/esphome/components/audio/audio_decoder.h b/esphome/components/audio/audio_decoder.h index c34ebbc613..e772b7eb5f 100644 --- a/esphome/components/audio/audio_decoder.h +++ b/esphome/components/audio/audio_decoder.h @@ -61,15 +61,16 @@ class AudioDecoder { */ public: /// @brief Allocates the output transfer buffer and stores the input buffer size for later use by add_source() - /// @param input_buffer_size Size of the input transfer buffer in bytes. + /// @param input_buffer_size Soft cap on the bytes a ring buffer source exposes per fill, in bytes. /// @param output_buffer_size Size of the output transfer buffer in bytes. AudioDecoder(size_t input_buffer_size, size_t output_buffer_size); ~AudioDecoder() = default; - /// @brief Adds a source ring buffer for raw file data. Takes ownership of the ring buffer in a shared_ptr. - /// @param input_ring_buffer weak_ptr of a shared_ptr of the sink ring buffer to transfer ownership - /// @return ESP_OK if successsful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated + /// @brief Adds a source ring buffer for raw file data. Shares ownership of the ring buffer via a shared_ptr. + /// The decoder reads directly from the ring buffer's internal storage with a zero-copy RingBufferAudioSource. + /// @param input_ring_buffer weak_ptr of the source ring buffer to read from + /// @return ESP_OK if successful, ESP_ERR_INVALID_ARG if the ring buffer is expired or the buffer size is zero esp_err_t add_source(std::weak_ptr &input_ring_buffer); /// @brief Adds a sink ring buffer for decoded audio. Takes ownership of the ring buffer in a shared_ptr. From 747787ae98f3a19819aa1ecbbecafca558435267 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sun, 24 May 2026 15:34:15 -0400 Subject: [PATCH 0171/1815] [audio] Use RingBufferAudioSource for resampling (#16560) --- esphome/components/audio/audio_resampler.cpp | 61 +++++++++++++++----- esphome/components/audio/audio_resampler.h | 17 +++--- 2 files changed, 55 insertions(+), 23 deletions(-) diff --git a/esphome/components/audio/audio_resampler.cpp b/esphome/components/audio/audio_resampler.cpp index c04cc881f5..bef62ce190 100644 --- a/esphome/components/audio/audio_resampler.cpp +++ b/esphome/components/audio/audio_resampler.cpp @@ -12,16 +12,17 @@ static const uint32_t READ_WRITE_TIMEOUT_MS = 20; AudioResampler::AudioResampler(size_t input_buffer_size, size_t output_buffer_size) : input_buffer_size_(input_buffer_size), output_buffer_size_(output_buffer_size) { - this->input_transfer_buffer_ = AudioSourceTransferBuffer::create(input_buffer_size); this->output_transfer_buffer_ = AudioSinkTransferBuffer::create(output_buffer_size); } esp_err_t AudioResampler::add_source(std::weak_ptr &input_ring_buffer) { - if (this->input_transfer_buffer_ != nullptr) { - this->input_transfer_buffer_->set_source(input_ring_buffer); - return ESP_OK; + // The zero-copy RingBufferAudioSource is created lazily on the first resample() call, once both the ring + // buffer (stored here) and the input stream info (set by start()) are available, in either order. + this->source_ring_buffer_ = input_ring_buffer.lock(); + if (this->source_ring_buffer_ == nullptr) { + return ESP_ERR_INVALID_STATE; } - return ESP_ERR_NO_MEM; + return ESP_OK; } esp_err_t AudioResampler::add_sink(std::weak_ptr &output_ring_buffer) { @@ -47,7 +48,7 @@ esp_err_t AudioResampler::start(AudioStreamInfo &input_stream_info, AudioStreamI this->input_stream_info_ = input_stream_info; this->output_stream_info_ = output_stream_info; - if ((this->input_transfer_buffer_ == nullptr) || (this->output_transfer_buffer_ == nullptr)) { + if (this->output_transfer_buffer_ == nullptr) { return ESP_ERR_NO_MEM; } @@ -56,6 +57,13 @@ esp_err_t AudioResampler::start(AudioStreamInfo &input_stream_info, AudioStreamI return ESP_ERR_NOT_SUPPORTED; } + // Reject frame sizes that can't be used as the zero-copy source's alignment up front, where the caller checks + // the return code. The lazy create() in resample() keeps its own guard since it runs before the uint8_t cast. + const size_t bytes_per_frame = this->input_stream_info_.frames_to_bytes(1); + if ((bytes_per_frame == 0) || (bytes_per_frame > RingBufferAudioSource::MAX_ALIGNMENT_BYTES)) { + return ESP_ERR_NOT_SUPPORTED; + } + if ((input_stream_info.get_sample_rate() != output_stream_info.get_sample_rate()) || (input_stream_info.get_bits_per_sample() != output_stream_info.get_bits_per_sample())) { this->resampler_ = make_unique( @@ -87,8 +95,27 @@ esp_err_t AudioResampler::start(AudioStreamInfo &input_stream_info, AudioStreamI } AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_differential) { + if (this->audio_source_ == nullptr) { + // Lazily create the zero-copy source on first use. Frame-aligned reads ensure multi-channel frames are + // never split across the ring buffer's wrap boundary. + const size_t bytes_per_frame = this->input_stream_info_.frames_to_bytes(1); + if ((bytes_per_frame == 0) || (bytes_per_frame > RingBufferAudioSource::MAX_ALIGNMENT_BYTES)) { + // Stream info is unset or the frame is too large to use as an alignment; the uint8_t cast below would + // truncate it and could yield a source that tears frames. + return AudioResamplerState::FAILED; + } + // Pass the shared_ptr by copy so a failed create() leaves source_ring_buffer_ intact; release our + // reference only after the source has taken ownership. + this->audio_source_ = RingBufferAudioSource::create(this->source_ring_buffer_, this->input_buffer_size_, + static_cast(bytes_per_frame)); + if (this->audio_source_ == nullptr) { + return AudioResamplerState::FAILED; + } + this->source_ring_buffer_.reset(); + } + if (stop_gracefully) { - if (!this->input_transfer_buffer_->has_buffered_data() && (this->output_transfer_buffer_->available() == 0)) { + if (!this->audio_source_->has_buffered_data() && (this->output_transfer_buffer_->available() == 0)) { return AudioResamplerState::FINISHED; } } @@ -102,9 +129,11 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d delay(READ_WRITE_TIMEOUT_MS); } - this->input_transfer_buffer_->transfer_data_from_source(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS)); + // Expose a chunk of the ring buffer's internal storage. pre_shift is ignored by RingBufferAudioSource + // (there is no intermediate transfer buffer to compact). + this->audio_source_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false); - if (this->input_transfer_buffer_->available() == 0) { + if (this->audio_source_->available() == 0) { // No samples available to process return AudioResamplerState::RESAMPLING; } @@ -112,17 +141,17 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d const size_t bytes_free = this->output_transfer_buffer_->free(); const uint32_t frames_free = this->output_stream_info_.bytes_to_frames(bytes_free); - const size_t bytes_available = this->input_transfer_buffer_->available(); + const size_t bytes_available = this->audio_source_->available(); const uint32_t frames_available = this->input_stream_info_.bytes_to_frames(bytes_available); if ((this->input_stream_info_.get_sample_rate() != this->output_stream_info_.get_sample_rate()) || (this->input_stream_info_.get_bits_per_sample() != this->output_stream_info_.get_bits_per_sample())) { // Adjust gain by -3 dB to avoid clipping due to the resampling process esp_audio_libs::resampler::ResamplerResults results = - this->resampler_->resample(this->input_transfer_buffer_->get_buffer_start(), - this->output_transfer_buffer_->get_buffer_end(), frames_available, frames_free, -3); + this->resampler_->resample(this->audio_source_->data(), this->output_transfer_buffer_->get_buffer_end(), + frames_available, frames_free, -3); - this->input_transfer_buffer_->decrease_buffer_length(this->input_stream_info_.frames_to_bytes(results.frames_used)); + this->audio_source_->consume(this->input_stream_info_.frames_to_bytes(results.frames_used)); this->output_transfer_buffer_->increase_buffer_length( this->output_stream_info_.frames_to_bytes(results.frames_generated)); @@ -146,10 +175,10 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d const size_t bytes_to_transfer = std::min(this->output_stream_info_.frames_to_bytes(frames_free), this->input_stream_info_.frames_to_bytes(frames_available)); - std::memcpy((void *) this->output_transfer_buffer_->get_buffer_end(), - (void *) this->input_transfer_buffer_->get_buffer_start(), bytes_to_transfer); + std::memcpy((void *) this->output_transfer_buffer_->get_buffer_end(), (const void *) this->audio_source_->data(), + bytes_to_transfer); - this->input_transfer_buffer_->decrease_buffer_length(bytes_to_transfer); + this->audio_source_->consume(bytes_to_transfer); this->output_transfer_buffer_->increase_buffer_length(bytes_to_transfer); } diff --git a/esphome/components/audio/audio_resampler.h b/esphome/components/audio/audio_resampler.h index 575ad13692..c09070c0ce 100644 --- a/esphome/components/audio/audio_resampler.h +++ b/esphome/components/audio/audio_resampler.h @@ -22,7 +22,7 @@ namespace esphome::audio { enum class AudioResamplerState : uint8_t { RESAMPLING, // More data is available to resample FINISHED, // All file data has been resampled and transferred - FAILED, // Unused state included for consistency among Audio classes + FAILED, // Failed to allocate the audio source }; class AudioResampler { @@ -32,14 +32,16 @@ class AudioResampler { * component). Also supports converting bits per sample. */ public: - /// @brief Allocates the input and output transfer buffers - /// @param input_buffer_size Size of the input transfer buffer in bytes. + /// @brief Allocates the output transfer buffer. The input source is created later in resample(). + /// @param input_buffer_size Max bytes exposed per fill() call on the zero-copy input source. /// @param output_buffer_size Size of the output transfer buffer in bytes. AudioResampler(size_t input_buffer_size, size_t output_buffer_size); - /// @brief Adds a source ring buffer for audio data. Takes ownership of the ring buffer in a shared_ptr. - /// @param input_ring_buffer weak_ptr of a shared_ptr of the sink ring buffer to transfer ownership - /// @return ESP_OK if successsful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated + /// @brief Sets the ring buffer the audio is read from and takes shared ownership of it. The zero-copy + /// RingBufferAudioSource that reads directly from its internal storage is created lazily on the first + /// resample() call, so add_source() and start() may be called in any order. + /// @param input_ring_buffer weak_ptr of a shared_ptr of the source ring buffer to transfer ownership + /// @return ESP_OK if successful, ESP_ERR_INVALID_STATE if the ring buffer is no longer alive esp_err_t add_source(std::weak_ptr &input_ring_buffer); /// @brief Adds a sink ring buffer for resampled audio. Takes ownership of the ring buffer in a shared_ptr. @@ -78,7 +80,8 @@ class AudioResampler { void set_pause_output_state(bool pause_state) { this->pause_output_ = pause_state; } protected: - std::unique_ptr input_transfer_buffer_; + std::shared_ptr source_ring_buffer_; + std::unique_ptr audio_source_; std::unique_ptr output_transfer_buffer_; size_t input_buffer_size_; From 9fcb638f33fcd8814cb4aa5fb2817b5480b73be8 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sun, 24 May 2026 15:34:51 -0400 Subject: [PATCH 0172/1815] [micro_wake_word] Use RingBufferAudioSource (#16595) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../micro_wake_word/micro_wake_word.cpp | 108 ++++++++++-------- .../micro_wake_word/micro_wake_word.h | 17 +-- 2 files changed, 71 insertions(+), 54 deletions(-) diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 6877e9e5df..739d64dc28 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -33,7 +33,8 @@ static const uint32_t INFERENCE_TASK_STACK_SIZE = 3072; static const UBaseType_t INFERENCE_TASK_PRIORITY = 3; enum EventGroupBits : uint32_t { - COMMAND_STOP = (1 << 0), // Signals the inference task should stop + COMMAND_STOP = (1 << 0), // Signals the inference task should stop + COMMAND_RESET_RING_BUFFER = (1 << 1), // Signals the inference task to discard buffered audio TASK_STARTING = (1 << 3), TASK_RUNNING = (1 << 4), @@ -114,13 +115,13 @@ void MicroWakeWord::setup() { } std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); if (this->ring_buffer_.use_count() > 1) { - size_t bytes_free = temp_ring_buffer->free(); - - if (bytes_free < data.size()) { - xEventGroupSetBits(this->event_group_, EventGroupBits::WARNING_FULL_RING_BUFFER); - temp_ring_buffer->reset(); + // Producer-only write: never touches consumer state. If the buffer is full, ask the inference task + // to drain it - reset() is a consumer operation and must run on the inference task's thread. + // Disable partial writes so audio chunks are either fully accepted or rejected and handled below. + if (temp_ring_buffer->write_without_replacement(data.data(), data.size(), 0, false) == 0) { + xEventGroupSetBits(this->event_group_, + EventGroupBits::WARNING_FULL_RING_BUFFER | EventGroupBits::COMMAND_RESET_RING_BUFFER); } - temp_ring_buffer->write((void *) data.data(), data.size()); } }); @@ -146,56 +147,65 @@ void MicroWakeWord::inference_task(void *params) { { // Ensures any C++ objects fall out of scope to deallocate before deleting the task - const size_t new_bytes_to_process = - this_mww->microphone_source_->get_audio_stream_info().ms_to_bytes(this_mww->features_step_size_); - std::unique_ptr audio_buffer; + const auto &stream_info = this_mww->microphone_source_->get_audio_stream_info(); + const size_t bytes_per_frame = stream_info.frames_to_bytes(1); + const size_t max_fill_bytes = stream_info.ms_to_bytes(this_mww->features_step_size_); + std::unique_ptr audio_source; int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE]; if (!(xEventGroupGetBits(this_mww->event_group_) & ERROR_BITS)) { - // Allocate audio transfer buffer - audio_buffer = audio::AudioSourceTransferBuffer::create(new_bytes_to_process); - - if (audio_buffer == nullptr) { + // Round ring buffer size down to a frame multiple so the wrap boundary never splits an int16 sample. + const size_t ring_buffer_size = + (stream_info.ms_to_bytes(RING_BUFFER_DURATION_MS) / bytes_per_frame) * bytes_per_frame; + std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); + if (temp_ring_buffer == nullptr) { xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_MEMORY); + } else { + audio_source = audio::RingBufferAudioSource::create(temp_ring_buffer, max_fill_bytes, + static_cast(bytes_per_frame)); + if (audio_source == nullptr) { + xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_MEMORY); + } else { + this_mww->ring_buffer_ = temp_ring_buffer; + } } } - if (!(xEventGroupGetBits(this_mww->event_group_) & ERROR_BITS)) { - // Allocate ring buffer - std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create( - this_mww->microphone_source_->get_audio_stream_info().ms_to_bytes(RING_BUFFER_DURATION_MS)); - if (temp_ring_buffer.use_count() == 0) { - xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_MEMORY); - } - audio_buffer->set_source(temp_ring_buffer); - this_mww->ring_buffer_ = temp_ring_buffer; - } - if (!(xEventGroupGetBits(this_mww->event_group_) & ERROR_BITS)) { this_mww->microphone_source_->start(); xEventGroupSetBits(this_mww->event_group_, EventGroupBits::TASK_RUNNING); - while (!(xEventGroupGetBits(this_mww->event_group_) & COMMAND_STOP)) { - audio_buffer->transfer_data_from_source(pdMS_TO_TICKS(DATA_TIMEOUT_MS)); - - if (audio_buffer->available() < new_bytes_to_process) { - // Insufficient data to generate new spectrogram features, read more next iteration - continue; + while (!(xEventGroupGetBits(this_mww->event_group_) & (COMMAND_STOP | ERROR_BITS))) { + if (xEventGroupGetBits(this_mww->event_group_) & EventGroupBits::COMMAND_RESET_RING_BUFFER) { + // Producer asked us to drain; run the consumer-side reset from this thread. + audio_source->clear_buffered_data(); + xEventGroupClearBits(this_mww->event_group_, EventGroupBits::COMMAND_RESET_RING_BUFFER); } - // Generate new spectrogram features - uint32_t processed_samples = this_mww->generate_features_( - (int16_t *) audio_buffer->get_buffer_start(), audio_buffer->available() / sizeof(int16_t), features_buffer); - audio_buffer->decrease_buffer_length(processed_samples * sizeof(int16_t)); + audio_source->fill(pdMS_TO_TICKS(DATA_TIMEOUT_MS), false); - // Run inference using the new spectorgram features - if (!this_mww->update_model_probabilities_(features_buffer)) { - xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_INFERENCE); - break; + // The frontend buffers samples internally and only emits a feature once it has a full window, so we can + // hand it whatever the source exposes. The frontend consumes at least one sample per call, so available() + // strictly decreases and this loop always terminates. + while (audio_source->available() >= sizeof(int16_t)) { + const size_t samples_available = audio_source->available() / sizeof(int16_t); + const int16_t *audio_data = reinterpret_cast(audio_source->data()); + + size_t processed_samples = 0; + const bool feature_generated = + this_mww->generate_features_(audio_data, samples_available, features_buffer, &processed_samples); + audio_source->consume(processed_samples * sizeof(int16_t)); + + if (feature_generated) { + if (!this_mww->update_model_probabilities_(features_buffer)) { + xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_INFERENCE); + break; + } + + // Process each model's probabilities and possibly send a Detection Event to the queue + this_mww->process_probabilities_(); + } } - - // Process each model's probabilities and possibly send a Detection Event to the queue - this_mww->process_probabilities_(); } } } @@ -386,11 +396,15 @@ void MicroWakeWord::set_state_(State state) { } } -size_t MicroWakeWord::generate_features_(int16_t *audio_buffer, size_t samples_available, - int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE]) { - size_t processed_samples = 0; +bool MicroWakeWord::generate_features_(const int16_t *audio_buffer, size_t samples_available, + int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE], size_t *processed_samples) { + *processed_samples = 0; struct FrontendOutput frontend_output = - FrontendProcessSamples(&this->frontend_state_, audio_buffer, samples_available, &processed_samples); + FrontendProcessSamples(&this->frontend_state_, audio_buffer, samples_available, processed_samples); + + if (frontend_output.size == 0) { + return false; + } for (size_t i = 0; i < frontend_output.size; ++i) { // These scaling values are set to match the TFLite audio frontend int8 output. @@ -415,7 +429,7 @@ size_t MicroWakeWord::generate_features_(int16_t *audio_buffer, size_t samples_a features_buffer[i] = static_cast(clamp(value, INT8_MIN, INT8_MAX)); } - return processed_samples; + return true; } void MicroWakeWord::process_probabilities_() { diff --git a/esphome/components/micro_wake_word/micro_wake_word.h b/esphome/components/micro_wake_word/micro_wake_word.h index 5c0c056ac0..ef440b5d37 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.h +++ b/esphome/components/micro_wake_word/micro_wake_word.h @@ -115,13 +115,16 @@ class MicroWakeWord : public Component void set_state_(State state); - /// @brief Generates spectrogram features from an input buffer of audio samples - /// @param audio_buffer (int16_t *) Buffer containing input audio samples - /// @param samples_available (size_t) Number of samples avaiable in the input buffer - /// @param features_buffer (int8_t *) Buffer to store generated features - /// @return (size_t) Number of samples processed from the input buffer - size_t generate_features_(int16_t *audio_buffer, size_t samples_available, - int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE]); + /// @brief Generates a spectrogram feature from an input buffer of audio samples. The frontend buffers samples + /// internally, so callers may stream arbitrary-sized chunks; a feature is only emitted once enough samples have + /// accumulated to fill a full analysis window. + /// @param audio_buffer (const int16_t *) Buffer containing input audio samples + /// @param samples_available (size_t) Number of samples available in the input buffer + /// @param features_buffer (int8_t *) Buffer to store the generated feature, valid only when the return value is true + /// @param processed_samples (size_t *) Set to the number of samples consumed from the input buffer + /// @return True if a new feature was generated; false if more samples are required + bool generate_features_(const int16_t *audio_buffer, size_t samples_available, + int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE], size_t *processed_samples); /// @brief Processes any new probabilities for each model. If any wake word is detected, it will send a DetectionEvent /// to the detection_queue_. From 16b6509a0348eecc22c51066049052a3c9383fc2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 08:28:09 -0500 Subject: [PATCH 0173/1815] Bump zeroconf from 0.149.12 to 0.149.13 (#16520) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4338063387..bd775d16a1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==45.0.4 -zeroconf==0.149.12 +zeroconf==0.149.13 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From 79539cb85d937238ce977372c0953fb6bbde8ff5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 09:55:28 -0500 Subject: [PATCH 0174/1815] Bump zeroconf from 0.149.13 to 0.149.16 (#16533) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index bd775d16a1..1174f957a6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==45.0.4 -zeroconf==0.149.13 +zeroconf==0.149.16 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From cc88456ce7f779c2e9b2413ae9b02bbe0bfec1d9 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 11:35:51 -0400 Subject: [PATCH 0175/1815] [espidf] Filter noisy 'git rev-parse' errors when .git is stripped (#16521) --- esphome/espidf/runner.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/esphome/espidf/runner.py b/esphome/espidf/runner.py index 65df37c7b2..da3f77cdd3 100644 --- a/esphome/espidf/runner.py +++ b/esphome/espidf/runner.py @@ -66,6 +66,12 @@ FILTER_IDF_LINES: list[str] = [ # Drop the blank line rich emits after the note so the build log # doesn't end with an orphan gap before ESPHome's own status lines. r"\s*$", + # ESP-IDF shells out to ``git rev-parse`` to embed a commit hash; + # esphome-libs strips ``.git`` from the tarball so those probes fail + # noisily without affecting the build. + r"-- git rev-parse returned ", + r"fatal: not a git repository", + r"Stopping at filesystem boundary", ] From 32fa856bf0597f7cecfdc80b838de6add3c15730 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 11:36:27 -0400 Subject: [PATCH 0176/1815] [espidf] Fix tarfile extract crashing on Python 3.11 with None mode (#16530) --- esphome/espidf/framework.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index aa97c65227..0e841af7d7 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -546,11 +546,11 @@ def _tar_extract_all( if not (mode & stat.S_IXUSR): mode &= ~(stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) mode |= stat.S_IRUSR | stat.S_IWUSR - elif member.isdir() or member.issym(): - # Ignore mode for directories & symlinks - mode = None - else: - # Block special files + elif not (member.isdir() or member.issym()): + # Block special files. Directories and symlinks keep + # their masked-original mode — passing None here would + # crash tarfile.extract on Python <3.12 (its chmod + # path calls os.chmod unconditionally). continue member.mode = mode From e92a4c9472c8bf726e51b3213ae43a7b55010090 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 11:37:10 -0400 Subject: [PATCH 0177/1815] [espidf] Write version.txt after extract so bootloader shows the real version (#16532) --- esphome/espidf/framework.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 0e841af7d7..6710c632c4 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -780,6 +780,34 @@ def download_from_mirrors( return None +def _write_idf_version_txt(framework_path: Path, version: str) -> None: + """Write /version.txt if missing. + + IDF's build.cmake picks the version it embeds in the firmware (and + stamps onto the bootloader) in this order: ``${IDF_PATH}/version.txt`` + if present, else ``git describe`` against IDF_PATH, else the + ``IDF_VERSION_MAJOR/MINOR/PATCH`` triplet from ``tools/cmake/version.cmake``. + On a clean esphome-libs tarball ``.git`` is fully stripped, so + git_describe returns ``HEAD-HASH-NOTFOUND`` (falsy) and the triplet + wins -- correct by luck. But a *partial* ``.git`` (e.g. a custom + framework.source pointed at a real git URL where build artifacts + mark the tree dirty) makes git_describe return ``-dirty``, + which is what then gets baked into the bootloader. Dropping + version.txt forces the right answer regardless. + """ + version_txt = framework_path / "version.txt" + if version_txt.exists(): + return + try: + version_txt.write_text(f"v{version}\n", encoding="utf-8") + except OSError as e: + _LOGGER.warning( + "Could not write %s (%s); bootloader version string may be incorrect.", + version_txt, + e, + ) + + def _check_esphome_idf_framework_install( version: str, targets: list[str], @@ -861,6 +889,11 @@ def _check_esphome_idf_framework_install( archive_extract_all(tmp.file, framework_path, progress_header="Extracting") extracted_marker.touch() + # Idempotent post-extract patch: written every invocation so a build + # dir extracted before this fix gets the file too, without forcing a + # clean. Skips when version.txt already exists. + _write_idf_version_txt(framework_path, version) + # 3. Check if the framework tools are the same and correctly installed if not install: install = True From 615d5aa827560675c5531e77f4d76ec9e4d5971c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 11:37:46 -0400 Subject: [PATCH 0178/1815] [core] Persist & restore CORE.toolchain through StorageJSON (#16531) --- esphome/storage_json.py | 20 ++++++++ tests/unit_tests/test_storage_json.py | 73 ++++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index e481827080..7f8885ba5f 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -14,6 +14,7 @@ from esphome.const import ( KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, + Toolchain, ) from esphome.core import CORE from esphome.helpers import write_file_if_changed @@ -98,6 +99,7 @@ class StorageJSON: no_mdns: bool, framework: str | None = None, core_platform: str | None = None, + toolchain: str | None = None, ) -> None: # Version of the storage JSON schema assert storage_version is None or isinstance(storage_version, int) @@ -134,6 +136,8 @@ class StorageJSON: self.framework = framework # The core platform of this firmware. Like "esp32", "rp2040", "host" etc. self.core_platform = core_platform + # The toolchain used for the build ("platformio" / "esp-idf") + self.toolchain = toolchain def as_dict(self): return { @@ -153,6 +157,7 @@ class StorageJSON: "no_mdns": self.no_mdns, "framework": self.framework, "core_platform": self.core_platform, + "toolchain": self.toolchain, } def to_json(self): @@ -189,6 +194,7 @@ class StorageJSON: ), framework=esph.target_framework, core_platform=esph.target_platform, + toolchain=esph.toolchain.value if esph.toolchain is not None else None, ) @staticmethod @@ -236,6 +242,7 @@ class StorageJSON: no_mdns = storage.get("no_mdns", False) framework = storage.get("framework") core_platform = storage.get("core_platform") + toolchain = storage.get("toolchain") return StorageJSON( storage_version, name, @@ -253,6 +260,7 @@ class StorageJSON: no_mdns, framework, core_platform, + toolchain, ) @staticmethod @@ -273,6 +281,18 @@ class StorageJSON: """ CORE.name = self.name CORE.build_path = self.build_path + # Restore toolchain so upload/logs picks the right firmware_bin path. + # An unknown value (corrupt sidecar, or written by a newer ESPHome) + # just leaves CORE.toolchain None — the fallback then picks PlatformIO. + if self.toolchain and CORE.toolchain is None: + try: + CORE.toolchain = Toolchain(self.toolchain) + except ValueError: + _LOGGER.debug( + "Ignoring unknown toolchain %r from %s", + self.toolchain, + storage_path(), + ) target_platform = self.core_platform or self.target_platform.lower() CORE.data[KEY_CORE] = { KEY_TARGET_PLATFORM: target_platform, diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index a3a38960e7..ea37492cf4 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -9,7 +9,7 @@ from unittest.mock import MagicMock, Mock, patch import pytest from esphome import storage_json -from esphome.const import CONF_DISABLED, CONF_MDNS +from esphome.const import CONF_DISABLED, CONF_MDNS, Toolchain from esphome.core import CORE @@ -308,6 +308,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: mock_core.loaded_platforms = {"sensor"} mock_core.config = {CONF_MDNS: {CONF_DISABLED: True}} mock_core.target_framework = "esp-idf" + mock_core.toolchain = Toolchain.ESP_IDF with patch("esphome.components.esp32.get_esp32_variant") as mock_variant: mock_variant.return_value = "ESP32-C3" @@ -327,6 +328,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: assert result.no_mdns is True assert result.framework == "esp-idf" assert result.core_platform == "esp32" + assert result.toolchain == "esp-idf" def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: @@ -345,10 +347,12 @@ def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: mock_core.loaded_platforms = set() mock_core.config = {} # No MDNS config means enabled mock_core.target_framework = "arduino" + mock_core.toolchain = None result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None) assert result.no_mdns is False + assert result.toolchain is None def test_storage_json_load_valid_file(tmp_path: Path) -> None: @@ -470,6 +474,73 @@ def test_storage_json_equality() -> None: assert storage1 != "not a storage object" +def _make_storage_with_toolchain( + toolchain: str | None, +) -> storage_json.StorageJSON: + return storage_json.StorageJSON( + storage_version=1, + name="dev", + friendly_name=None, + comment=None, + esphome_version="2024.1.0", + src_version=1, + address="dev.local", + web_port=None, + target_platform="ESP32", + build_path=Path("/build"), + firmware_bin_path=Path("/build/firmware.bin"), + loaded_integrations=set(), + loaded_platforms=set(), + no_mdns=False, + framework="esp-idf", + core_platform="esp32", + toolchain=toolchain, + ) + + +def test_storage_json_toolchain_round_trip(setup_core: Path) -> None: + """Sidecar toolchain survives save -> load -> apply_to_core.""" + storage = _make_storage_with_toolchain("esp-idf") + path = setup_core / "storage.json" + path.write_text(storage.to_json()) + + # Serialization key is stable -- device-builder relies on it. + assert json.loads(path.read_text())["toolchain"] == "esp-idf" + + loaded = storage_json.StorageJSON.load(path) + assert loaded is not None + assert loaded.toolchain == "esp-idf" + + CORE.toolchain = None + with patch("esphome.components.esp32.get_esp32_variant"): + loaded.apply_to_core() + assert CORE.toolchain == Toolchain.ESP_IDF + + +def test_storage_json_apply_to_core_preserves_cli_toolchain( + setup_core: Path, +) -> None: + """A CLI-set CORE.toolchain wins over the sidecar value.""" + loaded = _make_storage_with_toolchain("esp-idf") + + CORE.toolchain = Toolchain.PLATFORMIO + with patch("esphome.components.esp32.get_esp32_variant"): + loaded.apply_to_core() + assert CORE.toolchain == Toolchain.PLATFORMIO + + +def test_storage_json_apply_to_core_ignores_unknown_toolchain( + setup_core: Path, +) -> None: + """Unknown enum values (corrupt sidecar / newer ESPHome) fall through to None.""" + loaded = _make_storage_with_toolchain("gcc") + + CORE.toolchain = None + with patch("esphome.components.esp32.get_esp32_variant"): + loaded.apply_to_core() + assert CORE.toolchain is None + + def test_esphome_storage_json_as_dict() -> None: """Test EsphomeStorageJSON.as_dict returns correct dictionary.""" storage = storage_json.EsphomeStorageJSON( From 522541634738b95ac91d8266be67f2dd509e97c5 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 12:05:27 -0400 Subject: [PATCH 0179/1815] [espidf] Backport ninja linux-arm64 entry into tools.json on aarch64 hosts (#16527) --- esphome/espidf/framework.py | 76 ++++++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 6710c632c4..ee612c4c7e 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -7,6 +7,7 @@ import json import logging import os from pathlib import Path +import platform import shutil import subprocess import sys @@ -17,7 +18,7 @@ import requests from esphome.config_validation import Version from esphome.core import CORE -from esphome.helpers import ProgressBar, get_str_env, rmtree +from esphome.helpers import ProgressBar, get_str_env, rmtree, write_file_if_changed PathType = str | os.PathLike @@ -808,6 +809,74 @@ def _write_idf_version_txt(framework_path: Path, version: str) -> None: ) +# Backport of espressif/esp-idf#18272: every ESPHome-supported IDF release +# through v6.0 ships a tools.json whose ninja 1.12.1 entry has no +# ``linux-arm64`` source. ``idf_tools.py`` then either fails to find a +# matching binary or grabs the x86_64 one, which can't execute on +# aarch64. cmake is already populated across the same release range; we +# only need to inject ninja. Values lifted verbatim from the IDF v6.0.1 +# tools.json where the fix landed natively. +_NINJA_ARM64_BACKPORT: dict[str, dict[str, str | int]] = { + "1.12.1": { + "rename_dist": "ninja-linux-arm64-v1.12.1.zip", + "sha256": "5c25c6570b0155e95fce5918cb95f1ad9870df5768653afe128db822301a05a1", + "size": 121787, + "url": "https://github.com/ninja-build/ninja/releases/download/v1.12.1/ninja-linux-aarch64.zip", + }, +} + + +def _patch_tools_json_for_linux_arm64(framework_path: Path) -> None: + """Inject ninja linux-arm64 entries into the framework's tools.json on aarch64. + + Idempotent: a tools.json that already has the entry, or a host that + isn't aarch64, is a no-op. Applied unconditionally on every install + check so a build dir extracted before the backport got fixed up + without forcing a clean. + """ + if platform.machine() != "aarch64": + return + + tools_json = framework_path / "tools" / "tools.json" + if not tools_json.is_file(): + return + + try: + with open(tools_json, encoding="utf-8") as f: + data = json.load(f) + except (json.JSONDecodeError, OSError) as e: + _LOGGER.warning( + "Could not parse %s for linux-arm64 backport (%s); " + "skipping. A clean reinstall of the framework directory " + "may be needed.", + tools_json, + e, + ) + return + + changed = False + for tool in data.get("tools", []): + if tool.get("name") != "ninja": + continue + for ver in tool.get("versions", []): + entry = _NINJA_ARM64_BACKPORT.get(ver.get("name")) + if entry is None or ver.get("linux-arm64"): + continue + ver["linux-arm64"] = entry + changed = True + + if changed: + # write_file_if_changed stages a tempfile in the destination dir + # and atomically replaces — safe against mid-write interruption + # and concurrent invocations. + write_file_if_changed(tools_json, json.dumps(data, indent=2) + "\n") + _LOGGER.info( + "Patched %s to add ninja linux-arm64 download " + "(espressif/esp-idf#18272 backport).", + tools_json, + ) + + def _check_esphome_idf_framework_install( version: str, targets: list[str], @@ -894,6 +963,11 @@ def _check_esphome_idf_framework_install( # clean. Skips when version.txt already exists. _write_idf_version_txt(framework_path, version) + # Apply the ninja linux-arm64 backport on every invocation, not just on + # fresh extracts — idempotent and cheap, and lets a build dir carrying + # a pre-patch tools.json get fixed up without forcing a clean. + _patch_tools_json_for_linux_arm64(framework_path) + # 3. Check if the framework tools are the same and correctly installed if not install: install = True From 858cfd5b94f48e8120a40c17bb31dab5c3408cdb Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 14:01:19 -0400 Subject: [PATCH 0180/1815] [espidf] Default to remote HEAD when cg.add_library URL has no #ref (#16535) --- esphome/espidf/component.py | 15 ++++++++------- tests/unit_tests/test_espidf_component.py | 16 +++++++++++++--- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index b1352f7791..7d9874ad5f 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -93,7 +93,7 @@ class URLSource(Source): class GitSource(Source): - def __init__(self, url: str, ref: str): + def __init__(self, url: str, ref: str | None): self.url = url self.ref = ref @@ -109,7 +109,7 @@ class GitSource(Source): return path def __str__(self): - return f"{self.url}#{self.ref}" + return f"{self.url}#{self.ref}" if self.ref else self.url class InvalidIDFComponent(Exception): @@ -352,7 +352,6 @@ def _convert_library_to_component(library: Library) -> IDFComponent: IDFComponent: The resolved component with name, version, and URL Raises: - ValueError: If a repository URL is missing a reference (#) RuntimeError: If no artifact can be found for the library """ name = None @@ -362,10 +361,11 @@ def _convert_library_to_component(library: Library) -> IDFComponent: # Repository is provided directly if library.repository: # Parse repository URL: path becomes the component name, fragment - # becomes the git ref stored on GitSource. + # (if any) becomes the git ref stored on GitSource. A missing + # fragment is fine -- clone_or_update leaves the depth-1 clone on + # the remote's default branch, matching PIO's lib_deps behavior + # and external_components handling. split_result = urlsplit(library.repository) - if not split_result.fragment.strip(): - raise ValueError(f"Missing ref in URL {library.repository}") # Sanitize name name = str(split_result.path).strip("/") @@ -377,7 +377,8 @@ def _convert_library_to_component(library: Library) -> IDFComponent: version = "*" repository = urlunsplit(split_result._replace(fragment="")) - source = GitSource(str(repository), split_result.fragment) + ref = split_result.fragment.strip() or None + source = GitSource(str(repository), ref) # Version is provided - resolve using PlatformIO registry elif library.version: diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 373432f7d2..c4a419d1a2 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -436,11 +436,21 @@ def test_convert_library_with_branch_ref(): assert result.source.ref == "some-branch" -def test_convert_library_missing_ref(): +def test_convert_library_missing_ref_uses_default_branch(): + """A bare URL with no #ref clones the remote's default branch. + + Matches PIO's lib_deps behavior and external_components handling -- + git.clone_or_update with ref=None leaves the depth-1 clone on + whatever branch the remote HEAD points at. + """ lib = Library("name", None, "https://github.com/foo/bar.git") - with pytest.raises(ValueError): - _convert_library_to_component(lib) + result = _convert_library_to_component(lib) + + assert result.name == "foo/bar" + assert result.version == "*" + assert isinstance(result.source, GitSource) + assert result.source.ref is None def test_convert_library_registry(monkeypatch): From 878027ff50ea1760ff08c92189247ec28f8f5d64 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 14:01:54 -0400 Subject: [PATCH 0181/1815] [espidf] Honor the dict shorthand for library.json dependencies (#16537) --- esphome/espidf/component.py | 20 ++++ tests/unit_tests/test_espidf_component.py | 110 ++++++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 7d9874ad5f..a452a3f34a 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -656,6 +656,26 @@ def _process_dependencies(component: IDFComponent): if not dependencies: return + # PIO's library.json accepts both the list-of-dicts form and the + # shorthand dict form ``{"owner/Name": "version_spec"}``. Normalize + # the dict form so the loop below sees a uniform list. Iterating a + # dict gives string keys, which would silently fail the + # ``"name" in dependency`` substring check and skip every entry. + if isinstance(dependencies, dict): + normalized = [] + for raw_name, spec in dependencies.items(): + if "/" in raw_name: + owner, pkgname = raw_name.split("/", 1) + else: + owner, pkgname = None, raw_name + entry = {"name": pkgname, "owner": owner} + if isinstance(spec, dict): + entry.update(spec) + else: + entry["version"] = spec + normalized.append(entry) + dependencies = normalized + _LOGGER.info("Processing %s@%s component dependencies...", name, version) for dependency in dependencies: # Validate dependency structure diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index c4a419d1a2..7d6c861ffd 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -505,3 +505,113 @@ def test_process_dependencies_skips_invalid(tmp_component): _process_dependencies(tmp_component) assert tmp_component.dependencies == [] + + +def test_process_dependencies_dict_form(tmp_component, monkeypatch): + """PIO library.json shorthand ``{"owner/Name": "version"}`` is honored. + + Iterating a dict gives string keys, which would silently fail the + ``"name" in dependency`` substring check. Normalize to list-of-dicts + first so the dict form (used by e.g. tesla-ble for its nanopb dep) + is treated the same as the verbose list form. + """ + captured: list[Library] = [] + + def fake_generate(library): + captured.append(library) + return IDFComponent( + library.name, library.version, source=URLSource("http://dummy.com") + ) + + tmp_component.data = { + "dependencies": { + "nanopb/Nanopb": "^0.4.91", + "BareName": "1.2.3", + } + } + monkeypatch.setattr( + esphome.espidf.component, "_generate_idf_component", fake_generate + ) + monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None) + + _process_dependencies(tmp_component) + + assert len(tmp_component.dependencies) == 2 + names = sorted(lib.name for lib in captured) + versions = sorted(lib.version for lib in captured) + assert names == ["BareName", "nanopb/Nanopb"] + assert versions == ["1.2.3", "^0.4.91"] + + +def test_process_dependencies_dict_form_with_url_value(tmp_component, monkeypatch): + """A dict-value that's a URL gets routed to ``repository`` like the list form.""" + captured: list[Library] = [] + + def fake_generate(library): + captured.append(library) + return IDFComponent(library.name, "*", source=URLSource("http://dummy.com")) + + tmp_component.data = { + "dependencies": { + "foo/Bar": "https://github.com/foo/bar.git#main", + } + } + monkeypatch.setattr( + esphome.espidf.component, "_generate_idf_component", fake_generate + ) + monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None) + + _process_dependencies(tmp_component) + + assert len(captured) == 1 + assert captured[0].name == "foo/Bar" + assert captured[0].version is None + assert captured[0].repository == "https://github.com/foo/bar.git#main" + + +def test_process_dependencies_dict_form_with_nested_spec(tmp_component, monkeypatch): + """A dict-value that's itself a dict is merged into the entry. + + PIO's library.json allows ``{"owner/Name": {"version": "...", ...}}`` + for entries that need fields beyond just a version (platforms, + frameworks, etc.). The extra fields flow into _check_library_data + via the entry merge. + """ + captured: list[Library] = [] + checked: list[dict] = [] + + def fake_generate(library): + captured.append(library) + return IDFComponent( + library.name, library.version, source=URLSource("http://dummy.com") + ) + + tmp_component.data = { + "dependencies": { + "nanopb/Nanopb": {"version": "^0.4.91", "platforms": "espidf"}, + } + } + monkeypatch.setattr( + esphome.espidf.component, "_generate_idf_component", fake_generate + ) + monkeypatch.setattr( + esphome.espidf.component, + "_check_library_data", + checked.append, + ) + + _process_dependencies(tmp_component) + + assert len(captured) == 1 + assert captured[0].name == "nanopb/Nanopb" + assert captured[0].version == "^0.4.91" + # Extra spec fields reach _check_library_data so platform/framework + # gating still applies. + assert checked == [ + { + "name": "Nanopb", + "owner": "nanopb", + "version": "^0.4.91", + "platforms": "espidf", + } + ] From e6ed27574625d7d6706fab7243e3f6ee2b8999b6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 14:03:55 -0400 Subject: [PATCH 0182/1815] [esp32] Defer esp_panic_handler wrap so arduino-esp32 IDF component skips it (#16538) --- esphome/components/esp32/__init__.py | 35 +++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 1db97f95eb..2fdaa991ae 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -113,6 +113,7 @@ ARDUINO_FRAMEWORK_NAME = "framework-arduinoespressif32" ARDUINO_FRAMEWORK_PKG = f"pioarduino/{ARDUINO_FRAMEWORK_NAME}" ARDUINO_LIBS_NAME = f"{ARDUINO_FRAMEWORK_NAME}-libs" ARDUINO_LIBS_PKG = f"pioarduino/{ARDUINO_LIBS_NAME}" +ARDUINO_ESP32_COMPONENT_NAME = "espressif/arduino-esp32" LOG_LEVELS_IDF = [ "NONE", @@ -1743,6 +1744,31 @@ async def _add_yaml_idf_components(components: list[ConfigType]): ) +@coroutine_with_priority(CoroPriority.FINAL - 1) +async def _finalize_arduino_aware_flags(): + """Build flags that depend on whether arduino-esp32 is linked in. + + Scheduler runs lower priority values later, so ``FINAL - 1`` fires + after every ``FINAL`` job (incl. ``_add_yaml_idf_components``) -- + by then ``KEY_COMPONENTS`` is fully populated. + + - Skip our esp_panic_handler wrap when Arduino is linked; Arduino + wraps the same symbol and the linker errors on the duplicate. + - Define USE_ARDUINO in the hybrid esp-idf+arduino-esp32-component + case so ESPHome's ``#ifdef USE_ARDUINO`` paths light up. The + framework=arduino branch already adds it inline in to_code. + """ + arduino_linked = ( + CORE.using_arduino + or ARDUINO_ESP32_COMPONENT_NAME in CORE.data[KEY_ESP32][KEY_COMPONENTS] + ) + if not arduino_linked: + cg.add_build_flag("-Wl,--wrap=esp_panic_handler") + cg.add_define("USE_ESP32_CRASH_HANDLER") + elif not CORE.using_arduino: + cg.add_build_flag("-DUSE_ARDUINO") + + async def to_code(config): framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] conf = config[CONF_FRAMEWORK] @@ -1802,11 +1828,8 @@ async def to_code(config): cg.add_build_flag("-DUSE_ESP32") cg.add_define("USE_NATIVE_64BIT_TIME") cg.add_build_flag("-Wl,-z,noexecstack") - # Arduino already wraps esp_panic_handler for its own backtrace handler, - # so only add our wrap when using ESP-IDF framework to avoid linker conflicts. - if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: - cg.add_build_flag("-Wl,--wrap=esp_panic_handler") - cg.add_define("USE_ESP32_CRASH_HANDLER") + # Deferred so KEY_COMPONENTS is fully populated -- see the coroutine. + CORE.add_job(_finalize_arduino_aware_flags) cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) variant = config[CONF_VARIANT] cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}") @@ -2566,7 +2589,7 @@ def _write_idf_component_yml(): if CORE.using_toolchain_esp_idf: add_idf_component( - name="espressif/arduino-esp32", + name=ARDUINO_ESP32_COMPONENT_NAME, ref=str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]), ) From ae2e372762235e1dbf5cfe12164367940a8fc4b4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 14:08:09 -0400 Subject: [PATCH 0183/1815] [tuya] Restore null guard on status_pin lost in #16353 (#16539) --- esphome/components/tuya/tuya.cpp | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index fd14844908..3058d82cc4 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -206,15 +206,17 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff if (this->status_pin_reported_ != -1) { this->init_state_ = TuyaInitState::INIT_DATAPOINT; this->send_empty_command_(TuyaCommandType::DATAPOINT_QUERY); - bool is_pin_equals = - this->status_pin_ != nullptr && this->status_pin_->get_pin() == this->status_pin_reported_; - // Configure status pin toggling (if reported and configured) or WIFI_STATE periodic send - if (!is_pin_equals) { - ESP_LOGW(TAG, "Supplied status_pin does not equals the reported pin %i. Using supplied pin anyway.", + if (this->status_pin_ != nullptr) { + if (this->status_pin_->get_pin() != this->status_pin_reported_) { + ESP_LOGW(TAG, "Supplied status_pin does not equal the reported pin %i. Using supplied pin anyway.", + this->status_pin_reported_); + } + ESP_LOGV(TAG, "Configured status pin %i", this->status_pin_->get_pin()); + this->set_interval("wifi", 1000, [this] { this->set_status_pin_(); }); + } else { + ESP_LOGW(TAG, "MCU reported status_pin %i but no status_pin was configured; running in limited mode.", this->status_pin_reported_); } - ESP_LOGV(TAG, "Configured status pin %i", this->status_pin_->get_pin()); - this->set_interval("wifi", 1000, [this] { this->set_status_pin_(); }); } else { this->init_state_ = TuyaInitState::INIT_WIFI; ESP_LOGV(TAG, "Configured WIFI_STATE periodic send"); From 0c94a173b664ab2db05d841f622763545c41ff29 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 May 2026 15:42:57 -0500 Subject: [PATCH 0184/1815] [api] Break api_connection/api_server include cycle to drop custom unique_ptr deleter (#16542) --- esphome/components/api/api_connection.cpp | 1 + esphome/components/api/api_connection.h | 51 ++++-------------- .../components/api/api_connection_buffer.h | 54 +++++++++++++++++++ esphome/components/api/api_server.cpp | 5 -- esphome/components/api/api_server.h | 14 ++--- .../bluetooth_proxy/bluetooth_proxy.cpp | 1 + 6 files changed, 71 insertions(+), 55 deletions(-) create mode 100644 esphome/components/api/api_connection_buffer.h diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index b6f4aa2141..f2bf3752fa 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1,5 +1,6 @@ #include "api_connection.h" #ifdef USE_API +#include "api_connection_buffer.h" // for encode_to_buffer / get_batch_delay_ms_ inlines #ifdef USE_API_NOISE #include "api_frame_helper_noise.h" #endif diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 4165b7f3a2..804cd9ddd1 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -11,7 +11,8 @@ #endif #include "api_pb2.h" #include "api_pb2_service.h" -#include "api_server.h" +#include "list_entities.h" +#include "subscribe_state.h" #include "esphome/core/application.h" #include "esphome/core/component.h" #ifdef USE_ESP32_CRASH_HANDLER @@ -36,6 +37,9 @@ class ComponentIterator; namespace esphome::api { +// Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h. +class APIServer; + // Keepalive timeout in milliseconds static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000; // Maximum number of entities to process in a single batch during initial state/info sending @@ -411,44 +415,10 @@ class APIConnection final : public APIServerConnectionBase { // Non-template buffer management for send_message bool send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn, const void *msg); - // Core batch encoding logic. Computes header size, checks fit, resizes buffer, encodes. - // ALWAYS_INLINE so the compiler can devirtualize encode_fn at hot call sites. - static inline uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn, - const void *msg, APIConnection *conn, - uint32_t remaining_size) { -#ifdef HAS_PROTO_MESSAGE_DUMP - if (conn->flags_.log_only_mode) { - auto *proto_msg = static_cast(msg); - DumpBuffer dump_buf; - conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf)); - return 1; - } -#endif - const uint8_t footer_size = conn->helper_->frame_footer_size(); - - // First message uses max padding (already in buffer), subsequent use exact header size - size_t to_add; - if (conn->flags_.batch_first_message) { - conn->flags_.batch_first_message = false; - conn->batch_header_size_ = conn->helper_->frame_header_padding(); - to_add = calculated_size; - } else { - conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_); - to_add = calculated_size + conn->batch_header_size_ + footer_size; - } - - // Check if it fits (using actual header size, not max padding) - uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size; - if (total_calculated_size > remaining_size) - return 0; - - auto &shared_buf = conn->parent_->get_shared_buffer_ref(); - shared_buf.resize(shared_buf.size() + to_add); - ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size}; - encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); - - return total_calculated_size; - } + // Core batch encoding logic. ALWAYS_INLINE so encode_fn devirtualizes at hot call sites. + // Defined in api_connection_buffer.h (needs APIServer complete). + static uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn, + const void *msg, APIConnection *conn, uint32_t remaining_size); // Noinline version of encode_to_buffer for cold paths (entity info, zero-payload messages). // All cold callers share this single copy instead of each getting an ALWAYS_INLINE expansion. @@ -792,7 +762,8 @@ class APIConnection final : public APIServerConnectionBase { // Read by process_batch_multi_ to pass into MessageInfo. uint8_t batch_header_size_{0}; - uint32_t get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); } + // Defined in api_connection_buffer.h (needs APIServer complete). + uint32_t get_batch_delay_ms_() const; // Message will use 8 more bytes than the minimum size, and typical // MTU is 1500. Sometimes users will see as low as 1460 MTU. // If its IPv6 the header is 40 bytes, and if its IPv4 diff --git a/esphome/components/api/api_connection_buffer.h b/esphome/components/api/api_connection_buffer.h new file mode 100644 index 0000000000..1dd8a162e4 --- /dev/null +++ b/esphome/components/api/api_connection_buffer.h @@ -0,0 +1,54 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_API + +// Inline APIConnection methods that need APIServer complete. Include this +// instead of api_connection.h when calling encode_to_buffer or get_batch_delay_ms_. + +#include "api_connection.h" +#include "api_server.h" + +namespace esphome::api { + +inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t calculated_size, + MessageEncodeFn encode_fn, const void *msg, + APIConnection *conn, uint32_t remaining_size) { +#ifdef HAS_PROTO_MESSAGE_DUMP + if (conn->flags_.log_only_mode) { + auto *proto_msg = static_cast(msg); + DumpBuffer dump_buf; + conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf)); + return 1; + } +#endif + const uint8_t footer_size = conn->helper_->frame_footer_size(); + + // First message uses max padding (already in buffer), subsequent use exact header size + size_t to_add; + if (conn->flags_.batch_first_message) { + conn->flags_.batch_first_message = false; + conn->batch_header_size_ = conn->helper_->frame_header_padding(); + to_add = calculated_size; + } else { + conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_); + to_add = calculated_size + conn->batch_header_size_ + footer_size; + } + + // Check if it fits (using actual header size, not max padding) + uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size; + if (total_calculated_size > remaining_size) + return 0; + + auto &shared_buf = conn->parent_->get_shared_buffer_ref(); + shared_buf.resize(shared_buf.size() + to_add); + ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size}; + encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); + + return total_calculated_size; +} + +inline uint32_t APIConnection::get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); } + +} // namespace esphome::api +#endif diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 6c26c4e187..c30bd2e612 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -30,11 +30,6 @@ 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 6b575e536d..fbc8115091 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -3,6 +3,8 @@ #include "esphome/core/defines.h" #ifdef USE_API #include "api_buffer.h" +// Must precede clients_ so APIConnection is complete for default_delete (libc++). +#include "api_connection.h" #include "api_noise_context.h" #include "api_pb2.h" #include "api_pb2_service.h" @@ -12,8 +14,6 @@ #include "esphome/core/controller.h" #include "esphome/core/log.h" #include "esphome/core/string_ref.h" -#include "list_entities.h" -#include "subscribe_state.h" #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" #endif @@ -191,15 +191,9 @@ class APIServer final : public Component, bool is_connected_with_state_subscription() const; // 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 + // 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. - // 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; + using APIConnectionPtr = std::unique_ptr; class ActiveClientsView { const APIConnectionPtr *begin_; const APIConnectionPtr *end_; diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index c3461f9c51..ca30aab943 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -1,5 +1,6 @@ #include "bluetooth_proxy.h" +#include "esphome/components/api/api_server.h" #include "esphome/core/log.h" #include "esphome/core/macros.h" #include "esphome/core/application.h" From 27d53ec1171fe257400a0e214420c4cd77efafbb Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 18:03:07 -0400 Subject: [PATCH 0185/1815] [sx126x] Assert NSS before wait_busy so commands wake the chip from sleep (#16546) --- esphome/components/sx126x/sx126x.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index 6e6857fadb..83afeac50a 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -30,8 +30,8 @@ static constexpr uint8_t OCP_140MA = 0x38; // 140 mA max current static constexpr float LOW_DATA_RATE_OPTIMIZE_THRESHOLD = 16.38f; // 16.38 ms uint8_t SX126x::read_fifo_(uint8_t offset, std::vector &packet) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->transfer_byte(RADIO_READ_BUFFER); this->transfer_byte(offset); uint8_t status = this->transfer_byte(0x00); @@ -43,8 +43,8 @@ uint8_t SX126x::read_fifo_(uint8_t offset, std::vector &packet) { } void SX126x::write_fifo_(uint8_t offset, const std::vector &packet) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->transfer_byte(RADIO_WRITE_BUFFER); this->transfer_byte(offset); for (const uint8_t &byte : packet) { @@ -55,8 +55,8 @@ void SX126x::write_fifo_(uint8_t offset, const std::vector &packet) { } uint8_t SX126x::read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->transfer_byte(opcode); uint8_t status = this->transfer_byte(0x00); for (int32_t i = 0; i < size; i++) { @@ -67,8 +67,8 @@ uint8_t SX126x::read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) { } void SX126x::write_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->transfer_byte(opcode); for (int32_t i = 0; i < size; i++) { this->transfer_byte(data[i]); @@ -78,8 +78,8 @@ void SX126x::write_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) { } void SX126x::read_register_(uint16_t reg, uint8_t *data, uint8_t size) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->write_byte(RADIO_READ_REGISTER); this->write_byte((reg >> 8) & 0xFF); this->write_byte((reg >> 0) & 0xFF); @@ -91,8 +91,8 @@ void SX126x::read_register_(uint16_t reg, uint8_t *data, uint8_t size) { } void SX126x::write_register_(uint16_t reg, uint8_t *data, uint8_t size) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->write_byte(RADIO_WRITE_REGISTER); this->write_byte((reg >> 8) & 0xFF); this->write_byte((reg >> 0) & 0xFF); From f247def4acdeee5d707c71927cdf45c851c9452d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 May 2026 17:05:17 -0500 Subject: [PATCH 0186/1815] [core] Refresh compiled config cache after upload/logs fallback (#16548) --- esphome/__main__.py | 15 +++- tests/unit_tests/test_compiled_config.py | 100 +++++++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 16a05ad552..07bbd89358 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2449,7 +2449,10 @@ def run_esphome(argv): # Skipped when -s overrides are passed, since the cache was written # against the previous substitution set. config: ConfigType | None = None - if args.command in ("upload", "logs") and not command_line_substitutions: + cache_eligible = ( + args.command in ("upload", "logs") and not command_line_substitutions + ) + if cache_eligible: from esphome.compiled_config import load_compiled_config config = load_compiled_config(conf_path) @@ -2464,6 +2467,16 @@ def run_esphome(argv): command_line_substitutions, skip_external_update=skip_external, ) + # Refresh the cache so the next upload/logs hits the fast path + # instead of re-running read_config. Skip when the storage + # sidecar is absent (no compile has run): the cache would + # never be loaded back, so writing secrets to disk is wasted. + if cache_eligible and config is not None: + from esphome.compiled_config import save_compiled_config + from esphome.storage_json import ext_storage_path + + if ext_storage_path(conf_path.name).exists(): + save_compiled_config(config) if config is None: return 2 CORE.config = config diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index 8c9cfa8101..e12107152b 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -253,6 +253,106 @@ def test_run_esphome_upload_and_logs_fall_back_when_no_cache( mock_read.assert_called_once() +def test_run_esphome_upload_does_not_refresh_cache_without_sidecar( + tmp_path: Path, +) -> None: + """Without a StorageJSON sidecar (no compile has run), the fallback + skips the cache write -- load_compiled_config requires the sidecar, + so writing the rendered (secret-resolved) YAML would be inert and + leak secrets to disk for nothing.""" + yaml_path = tmp_path / "lite_test.yaml" + yaml_path.write_text("esphome:\n name: lite_test\n") + CORE.config_path = yaml_path + + with ( + patch( + "esphome.__main__.read_config", + return_value={"esphome": {"name": "lite_test"}}, + ), + patch("esphome.compiled_config.save_compiled_config") as mock_save, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {"upload": lambda args, config: 0}, + ), + ): + run_esphome(["esphome", "upload", str(yaml_path)]) + + mock_save.assert_not_called() + + +@pytest.mark.parametrize("command", ["upload", "logs"]) +def test_run_esphome_upload_and_logs_refresh_cache_on_fallback( + tmp_path: Path, command: str +) -> None: + """A stale-cache fallback rewrites the cache so the next call hits + the fast path. Without this, every upload/logs after a YAML edit + pays for read_config() until the next compile rewrites the cache.""" + yaml_path = tmp_path / "lite_test.yaml" + yaml_path.write_text("esphome:\n name: lite_test\n") + CORE.config_path = yaml_path + + storage_dir = tmp_path / ".esphome" / "storage" + _write_storage(storage_dir / "lite_test.yaml.json") + cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + _set_cache_mtime(cache, yaml_path, offset=-60) # stale + + fresh_config = {"esphome": {"name": "lite_test"}, "logger": {}} + + with ( + patch("esphome.__main__.read_config", return_value=fresh_config), + patch( + "esphome.compiled_config.save_compiled_config", wraps=save_compiled_config + ) as mock_save, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {command: lambda args, config: 0}, + ), + ): + assert run_esphome(["esphome", command, str(yaml_path)]) == 0 + + mock_save.assert_called_once_with(fresh_config) + # mtime is now newer than the source YAML, so a follow-up call hits + # the fast path instead of repeating read_config. + assert cache.stat().st_mtime >= yaml_path.stat().st_mtime + + +def test_run_esphome_upload_with_substitution_does_not_refresh_cache( + fresh_cache_files: Path, +) -> None: + """`-s` substitutions skip the cache on both read and write -- saving + here would clobber the cache with a substitution-specific config.""" + with ( + patch("esphome.__main__.read_config", return_value={"esphome": {}}), + patch("esphome.compiled_config.save_compiled_config") as mock_save, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {"upload": lambda args, config: 0}, + ), + ): + run_esphome(["esphome", "-s", "var", "val", "upload", str(fresh_cache_files)]) + + mock_save.assert_not_called() + + +def test_run_esphome_compile_does_not_refresh_cache_via_fallback( + fresh_cache_files: Path, +) -> None: + """Compile writes the cache through update_storage_json, not via the + upload/logs fallback path -- the fallback save would skip the + storage_should_clean check.""" + with ( + patch("esphome.__main__.read_config", return_value={"esphome": {}}), + patch("esphome.compiled_config.save_compiled_config") as mock_save, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {"compile": lambda args, config: 0}, + ), + ): + run_esphome(["esphome", "compile", str(fresh_cache_files)]) + + mock_save.assert_not_called() + + def test_run_esphome_upload_with_substitution_skips_cache( fresh_cache_files: Path, ) -> None: From 7ae55664727da6073c7672fc0e28aba1bf67126a Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 22 May 2026 06:43:08 -0400 Subject: [PATCH 0187/1815] [sendspin] Bump sendspin-cpp to v0.6.1 (#16553) --- esphome/components/sendspin/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 36f13f7d07..b670bd3c4d 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -206,7 +206,7 @@ async def to_code(config: ConfigType) -> None: ) # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.0") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.1") cg.add_define("USE_SENDSPIN", True) # for MDNS diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 42d0d5de6b..8dafde1111 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -100,6 +100,6 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.6.0 + version: 0.6.1 lvgl/lvgl: version: 9.5.0 From 59db9a4673e1e159476a83f8d6654d669344231a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 08:49:03 -0500 Subject: [PATCH 0188/1815] [dashboard] Fix flaky test_websocket_refresh_command on Windows CI (#16565) --- tests/dashboard/test_web_server.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 626aea0216..0ee841e68c 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -1503,13 +1503,18 @@ async def test_websocket_refresh_command( ) -> None: """Test WebSocket refresh command triggers dashboard update.""" with patch("esphome.dashboard.web_server.DASHBOARD_SUBSCRIBER") as mock_subscriber: - mock_subscriber.request_refresh = Mock() + # Signal an asyncio.Event when request_refresh is invoked so the + # test can deterministically wait for the server-side handler to run + # instead of relying on a fixed sleep (flaky on Windows CI under load). + called = asyncio.Event() + mock_subscriber.request_refresh = Mock(side_effect=called.set) # Send refresh command await websocket_client.write_message(json.dumps({"event": "refresh"})) - # Give it a moment to process - await asyncio.sleep(0.01) + # Wait for the server to process the message and invoke request_refresh + async with asyncio.timeout(5): + await called.wait() # Verify request_refresh was called mock_subscriber.request_refresh.assert_called_once() From 1f4a0615721382e36c0fecfe158a8f4eac33c080 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Fri, 22 May 2026 22:09:32 +0300 Subject: [PATCH 0189/1815] [libretiny] Fix LN882H IRAM_ATTR injection point in patch_linker.py (#16570) --- esphome/components/libretiny/patch_linker.py.script | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 282a31d3f2..3a8a4787ed 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -13,7 +13,9 @@ import subprocess # - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it. # - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it. # - LN882H: stock linker has no glob for ".sram.text", so we inject -# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH). +# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH) +# immediately after KEEP(*(.vectors)), so the vector table stays at +# __copysection_ram0_start (0x20000000) for correct Cortex-M4 VTOR alignment. # # All families also get a post-link summary showing where IRAM_ATTR landed. @@ -27,7 +29,11 @@ _KEEP_LINE = ( "__esphome_sram_text_end = .; " + _MARKER + "\n" ) -_LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)") +# Inject after KEEP(*(.vectors)) so the vector table stays at +# __copysection_ram0_start (0x20000000). Cortex-M4 VTOR requires a 512-byte- +# aligned address; injecting before the vectors would push them to an +# unaligned offset and mis-route every IRQ handler. +_LN_COPY = re.compile(r"(KEEP\(\*\(\.vectors\)\)[^\n]*\n)") def _detect(env): @@ -56,7 +62,7 @@ KNOWN_VARIANTS = frozenset({ def _inject_keep(host_section): - """Return a patcher that injects _KEEP_LINE at the top of `host_section`.""" + """Return a patcher that injects _KEEP_LINE after `host_section` match.""" def patch(content): if _MARKER in content: return content From 4e7bc92061d5cd6a962c5512d2e2c91e6e491ac9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 14:30:28 -0500 Subject: [PATCH 0190/1815] [esp8266] Use os_timer-based esp_delay() in delay() (#16563) --- esphome/components/esp8266/hal.cpp | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/esphome/components/esp8266/hal.cpp b/esphome/components/esp8266/hal.cpp index e8f472dc8a..3501c51859 100644 --- a/esphome/components/esp8266/hal.cpp +++ b/esphome/components/esp8266/hal.cpp @@ -5,6 +5,7 @@ #include #include +#include extern "C" { #include @@ -71,23 +72,22 @@ uint32_t IRAM_ATTR HOT millis() { 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. +// Delegate to Arduino's 1-arg esp_delay(), which uses os_timer + esp_suspend to +// suspend the cont task for `ms` milliseconds without polling millis(). This +// matches pre-2026.5.0 behavior (when esphome::delay() forwarded to ::delay()) +// and lets the SDK run freely while we wait, which timing-sensitive +// interrupt-driven code (e.g. ESP8266 software-serial RX in components like +// fingerprint_grow) depends on. The poll-based busy-wait that this replaced +// rarely yielded inside short waits like delay(1), starving WiFi/SDK tasks and +// extending interrupt latency. Unlike ::delay(), esp_delay()'s 1-arg form does +// not call millis(), so the slow Arduino millis() body is not pulled into IRAM +// by this path (the --wrap=millis goal of #15662 is preserved). void HOT delay(uint32_t ms) { if (ms == 0) { optimistic_yield(1000); return; } - uint32_t start = millis(); - while (millis() - start < ms) { - optimistic_yield(1000); - } + esp_delay(ms); } void arch_restart() { From 8f6ea62628965cdb47fa3f30d7f461be3b3c1f89 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 14:30:43 -0500 Subject: [PATCH 0191/1815] [uart] Wake main loop on ESP8266 software serial RX (#16562) --- esphome/components/uart/__init__.py | 9 +++++---- .../components/uart/uart_component_esp8266.cpp | 15 ++++++++++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 7075228743..4ea32e26a3 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -513,10 +513,11 @@ async def uart_write_to_code(config, action_id, template_arg, args): @coroutine_with_priority(CoroPriority.FINAL) async def final_step(): """Final code generation step to configure optional UART features.""" - if CORE.is_esp32 and CORE.has_networking: - # Wake-on-RX is essentially free on ESP32 (just an ISR function pointer - # registration) — enable by default to reduce RX buffer overflow risk - # by waking the main loop immediately when data arrives. + if (CORE.is_esp32 or CORE.is_esp8266) and CORE.has_networking: + # Wake-on-RX is essentially free (just an ISR function pointer + # registration on ESP32, an inline flag set on ESP8266 software + # serial) — enable by default to reduce RX buffer overflow risk by + # waking the main loop immediately when data arrives. cg.add_define("USE_UART_WAKE_LOOP_ON_RX") diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index 0ea7930760..fc1509f737 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -4,6 +4,9 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#ifdef USE_UART_WAKE_LOOP_ON_RX +#include "esphome/core/wake.h" +#endif #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" @@ -149,7 +152,11 @@ void ESP8266UartComponent::dump_config() { if (this->hw_serial_ != nullptr) { ESP_LOGCONFIG(TAG, " Using hardware serial interface."); } else { - ESP_LOGCONFIG(TAG, " Using software serial"); + ESP_LOGCONFIG(TAG, " Using software serial" +#ifdef USE_UART_WAKE_LOOP_ON_RX + "\n Wake on data RX: ENABLED" +#endif + ); } this->check_logger_conflict(); } @@ -266,6 +273,12 @@ void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) { arg->rx_in_pos_ = (arg->rx_in_pos_ + 1) % arg->rx_buffer_size_; // Clear RX pin so that the interrupt doesn't re-trigger right away again. arg->rx_pin_.clear_interrupt(); +#ifdef USE_UART_WAKE_LOOP_ON_RX + // Wake the main loop so the consuming component drains the byte promptly + // instead of waiting for the next loop_interval_ tick. Important for timing + // sensitive setups that poll read() in a tight loop (e.g. fingerprint_grow). + wake_loop_isrsafe(); +#endif } void IRAM_ATTR HOT ESP8266SoftwareSerial::write_byte(uint8_t data) { if (this->gpio_tx_pin_ == nullptr) { From adde7681e8a33884d47b11211b9a079bf12135de Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 22 May 2026 20:30:25 -0400 Subject: [PATCH 0192/1815] [esp32] Demote IDF #warning deprecations from error under ESP-IDF toolchain (#16584) --- esphome/components/esp32/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 2fdaa991ae..367ec32578 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1821,6 +1821,7 @@ async def to_code(config): cg.add_build_flag("-Wno-error=overloaded-virtual") cg.add_build_flag("-Wno-error=reorder") cg.add_build_flag("-Wno-error=volatile") + cg.add_build_flag("-Wno-error=cpp") # -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates cg.add_build_flag("-Wno-missing-field-initializers") From 0babc52472c2cc617c36dd5d9e872882d5e2577e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 May 2026 14:30:12 -0500 Subject: [PATCH 0193/1815] [bluetooth_proxy] Recover slot stuck in DISCONNECTING when CLOSE_EVT is dropped (#16588) --- .../bluetooth_proxy/bluetooth_connection.cpp | 26 ++++++++++++------- .../bluetooth_proxy/bluetooth_connection.h | 2 ++ .../esp32_ble_client/ble_client_base.cpp | 2 ++ .../esp32_ble_client/ble_client_base.h | 10 +++++++ 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 21573f0184..7ba9e61e19 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -135,12 +135,26 @@ void BluetoothConnection::loop() { // - For V3_WITH_CACHE: Services are never sent, disable after INIT state // - For V3_WITHOUT_CACHE: Disable only after service discovery is complete // (send_service_ == DONE_SENDING_SERVICES, which is only set after services are sent) - if (this->state() != espbt::ClientState::INIT && (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || - this->send_service_ == DONE_SENDING_SERVICES)) { + // Never disable while DISCONNECTING — BLEClientBase::loop() needs to keep running so the + // 10s safety timeout can force IDLE if CLOSE_EVT is never delivered. + if (this->state() != espbt::ClientState::INIT && this->state() != espbt::ClientState::DISCONNECTING && + (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || + this->send_service_ == DONE_SENDING_SERVICES)) { this->disable_loop(); } } +void BluetoothConnection::on_disconnect_complete(esp_err_t reason) { + // Called from both the CLOSE_EVT handler and the DISCONNECTING safety timeout in the + // base class. Free the proxy slot, notify the API client, and reset send_service_. + // address_ may already be 0 if reset_connection_ ran earlier on this teardown. + if (this->address_ == 0) { + return; + } + ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, reason); + this->reset_connection_(reason); +} + void BluetoothConnection::reset_connection_(esp_err_t reason) { // Send disconnection notification this->proxy_->send_device_connection(this->address_, false, 0, reason); @@ -372,14 +386,6 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga this->proxy_->send_device_connection(this->address_, false, 0, param->disconnect.reason); break; } - case ESP_GATTC_CLOSE_EVT: { - ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, - param->close.reason); - // Now the GATT connection is fully closed and controller resources are freed - // Safe to mark the connection slot as available - this->reset_connection_(param->close.reason); - break; - } case ESP_GATTC_OPEN_EVT: { if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { this->reset_connection_(param->open.status); diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_proxy/bluetooth_connection.h index b50ea2d6a2..e5600f6af4 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.h @@ -33,6 +33,8 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase { protected: friend class BluetoothProxy; + void on_disconnect_complete(esp_err_t reason) override; + bool supports_efficient_uuids_() const; void send_service_for_discovery_(); void reset_connection_(esp_err_t reason); diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 7f0f2c624d..3fb9632e9a 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -72,6 +72,7 @@ void BLEClientBase::loop() { // never delivered CLOSE_EVT/DISCONNECT_EVT, services would leak without this call. this->release_services(); this->set_idle_(); + this->on_disconnect_complete(ESP_GATT_CONN_TIMEOUT); } } @@ -418,6 +419,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->log_gattc_lifecycle_event_("CLOSE"); this->release_services(); this->set_idle_(); + this->on_disconnect_complete(param->close.reason); break; } case ESP_GATTC_SEARCH_RES_EVT: { diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 4e0b22cc29..0291a4b993 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -140,6 +140,12 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void log_gattc_warning_(const char *operation, esp_err_t err); void log_connection_params_(const char *param_type); void handle_connection_result_(esp_err_t ret); + /// Hook called once a connection has been fully torn down (after release_services() and + /// set_idle_()), from both the CLOSE_EVT handler and the DISCONNECTING safety timeout. + /// Subclasses with extra per-connection accounting (e.g. bluetooth_proxy slot state) + /// override this to release that state. `reason` is the controller reason code, or + /// ESP_GATT_CONN_TIMEOUT for the safety-timeout path. + virtual void on_disconnect_complete(esp_err_t reason) {} /// Transition to IDLE and reset conn_id — call when the connection is fully dead. void set_idle_() { this->set_state(espbt::ClientState::IDLE); @@ -149,6 +155,10 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void set_disconnecting_() { this->disconnecting_started_ = millis(); this->set_state(espbt::ClientState::DISCONNECTING); + // BluetoothConnection::loop() disables the component loop after service discovery + // completes, so the DISCONNECTING timeout check in loop() would never run if CLOSE_EVT + // gets lost. Re-enable the loop so the 10s safety timeout can force IDLE. + this->enable_loop(); } // Compact error logging helpers to reduce flash usage void log_error_(const char *message); From 9a34a6aabb8691447126e9775cb8fef90e80e932 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 23 May 2026 15:44:25 -0400 Subject: [PATCH 0194/1815] [esp32] Replace per-class -Wno-error=X demotes with blanket -Wno-error for ESP-IDF toolchain (#16599) --- esphome/components/esp32/__init__.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 367ec32578..5aeff91830 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1816,12 +1816,9 @@ async def to_code(config): Path(__file__).parent / "iram_fix.py.script", ) else: - cg.add_build_flag("-Wno-error=format") - cg.add_build_flag("-Wno-error=maybe-uninitialized") - cg.add_build_flag("-Wno-error=overloaded-virtual") - cg.add_build_flag("-Wno-error=reorder") - cg.add_build_flag("-Wno-error=volatile") - cg.add_build_flag("-Wno-error=cpp") + # Undo IDF's blanket -Werror so third-party libraries and user + # lambdas don't need a -Wno-error= entry per warning class. + cg.add_build_flag("-Wno-error") # -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates cg.add_build_flag("-Wno-missing-field-initializers") From ddd353d10560c46a97abb0b2dd7bce31b55a8bfe Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 24 May 2026 00:19:07 -0400 Subject: [PATCH 0195/1815] [esp32] Disable IDF's COMPILER_DISABLE_DEFAULT_ERRORS so -Wno-error actually undoes -Werror (#16604) --- esphome/components/esp32/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 5aeff91830..4f77258b2c 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1816,8 +1816,11 @@ async def to_code(config): Path(__file__).parent / "iram_fix.py.script", ) else: - # Undo IDF's blanket -Werror so third-party libraries and user - # lambdas don't need a -Wno-error= entry per warning class. + # Demote IDF's blanket -Werror to warnings so third-party libs + # and user lambdas don't need a -Wno-error= per warning. + # The sdkconfig knob disables IDF's rewrite to -Werror=all (which + # can't be globally undone); -Wno-error then handles the demotion. + add_idf_sdkconfig_option("CONFIG_COMPILER_DISABLE_DEFAULT_ERRORS", False) cg.add_build_flag("-Wno-error") # -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates cg.add_build_flag("-Wno-missing-field-initializers") From 03e2eb4b4a938b8600954afba8c74bdcbbbd7aee Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 25 May 2026 09:28:49 +1200 Subject: [PATCH 0196/1815] Bump version to 2026.5.1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 206a181ffd..30ae42ea2c 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.5.0 +PROJECT_NUMBER = 2026.5.1 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 96554b12da..39c5c6b60e 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.5.0" +__version__ = "2026.5.1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 090f5a486a63418c54a0f01b0e9a78ae61ec25ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 May 2026 16:32:47 -0500 Subject: [PATCH 0197/1815] Lift dependabot pip open PR limit (#16609) --- .github/dependabot.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 528e69c478..e87939f824 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,6 +5,7 @@ updates: directory: "/" schedule: interval: daily + open-pull-requests-limit: 10 ignore: # Hypotehsis is only used for testing and is updated quite often - dependency-name: hypothesis From 917ffc379795d9f42c5d92e46834811d056e3774 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 21:49:52 +0000 Subject: [PATCH 0198/1815] Bump aioesphomeapi from 45.0.4 to 45.2.2 (#16611) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 178e05497f..45401a7995 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==45.0.4 +aioesphomeapi==45.2.2 zeroconf==0.149.16 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 62b0a93e5e032d1ad4573cb055df1e50e6a5af56 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 25 May 2026 10:43:39 +1200 Subject: [PATCH 0199/1815] [rp2040] Add variant config option for RP2040/RP2350 (#16602) --- esphome/components/rp2040/__init__.py | 109 +++++++++++++++++- esphome/components/rp2040/const.py | 26 +++++ esphome/core/defines.h | 1 + tests/components/rp2040/test.rp2040-ard.yaml | 1 + .../rp2040/test.rp2040-pico2-ard.yaml | 6 + tests/unit_tests/components/test_rp2040.py | 67 ++++++++++- 6 files changed, 203 insertions(+), 7 deletions(-) create mode 100644 tests/components/rp2040/test.rp2040-pico2-ard.yaml diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 862d532645..830c961476 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -1,8 +1,10 @@ +from collections.abc import Callable import logging from pathlib import Path import re from string import ascii_letters, digits import subprocess +from typing import Any import esphome.codegen as cg import esphome.config_validation as cv @@ -12,6 +14,7 @@ from esphome.const import ( CONF_FRAMEWORK, CONF_PLATFORM_VERSION, CONF_SOURCE, + CONF_VARIANT, CONF_VERSION, CONF_WATCHDOG_TIMEOUT, KEY_CORE, @@ -21,12 +24,30 @@ from esphome.const import ( PLATFORM_RP2040, ThreadModel, ) -from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority +from esphome.core import ( + CORE, + CoroPriority, + EsphomeCore, + EsphomeError, + coroutine_with_priority, +) from esphome.core.config import BOARD_MAX_LENGTH from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed +from esphome.types import ConfigType from . import boards -from .const import KEY_BOARD, KEY_LWIP_OPTS, KEY_PIO_FILES, KEY_RP2040, rp2040_ns +from .const import ( + KEY_BOARD, + KEY_LWIP_OPTS, + KEY_PIO_FILES, + KEY_RP2040, + KEY_VARIANT, + MCU_TO_VARIANT, + STANDARD_BOARDS, + VARIANT_FRIENDLY, + VARIANTS, + rp2040_ns, +) # force import gpio to register pin schema from .gpio import rp2040_pin_to_code # noqa @@ -68,7 +89,7 @@ def board_id_has_wifi(board_id: str) -> bool: return board_info.get("wifi", False) -def set_core_data(config): +def set_core_data(config: ConfigType) -> ConfigType: CORE.data[KEY_RP2040] = {} CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_RP2040 CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = "arduino" @@ -76,12 +97,46 @@ def set_core_data(config): config[CONF_FRAMEWORK][CONF_VERSION] ) CORE.data[KEY_RP2040][KEY_BOARD] = config[CONF_BOARD] + CORE.data[KEY_RP2040][KEY_VARIANT] = config[CONF_VARIANT] CORE.data[KEY_RP2040][KEY_PIO_FILES] = {} return config +def get_rp2040_variant(core_obj: EsphomeCore | None = None) -> str: + return (core_obj or CORE).data[KEY_RP2040][KEY_VARIANT] + + +def only_on_variant( + *, + supported: str | list[str] | None = None, + unsupported: str | list[str] | None = None, + msg_prefix: str = "This feature", +) -> Callable[[Any], Any]: + """Config validator for features only available on some RP2040 variants.""" + if supported is not None and not isinstance(supported, list): + supported = [supported] + if unsupported is not None and not isinstance(unsupported, list): + unsupported = [unsupported] + + def validator_(obj: Any) -> Any: + if not CORE.is_rp2040: + raise cv.Invalid(f"{msg_prefix} is only available on RP2040") + variant = get_rp2040_variant() + if supported is not None and variant not in supported: + raise cv.Invalid( + f"{msg_prefix} is only available on {', '.join(supported)}" + ) + if unsupported is not None and variant in unsupported: + raise cv.Invalid( + f"{msg_prefix} is not available on {', '.join(unsupported)}" + ) + return obj + + return validator_ + + def get_download_types(storage_json): """Binary-download entries for a built RP2040 firmware. @@ -192,12 +247,52 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All( _arduino_check_versions, ) + +def _detect_variant(value: ConfigType) -> ConfigType: + value = value.copy() + board: str | None = value.get(CONF_BOARD) + variant: str | None = value.get(CONF_VARIANT) + + if board is None: + # `cv.has_at_least_one_key` guarantees variant is set here. + board = STANDARD_BOARDS[variant] + value[CONF_BOARD] = board + + board_info = boards.BOARDS.get(board) + if board_info is None: + if variant is None: + raise cv.Invalid( + "This board is unknown; please specify the chip variant using " + f"the '{CONF_VARIANT}' option.", + path=[CONF_BOARD], + ) + _LOGGER.warning( + "This board is unknown; the specified variant '%s' will be used " + "but this may not work as expected.", + variant, + ) + else: + board_variant = MCU_TO_VARIANT[board_info["mcu"]] + if variant is None: + variant = board_variant + elif variant != board_variant: + raise cv.Invalid( + f"Option '{CONF_VARIANT}' ({variant}) does not match the " + f"selected board '{board}' ({board_variant}).", + path=[CONF_VARIANT], + ) + + value[CONF_VARIANT] = variant + return value + + CONFIG_SCHEMA = cv.All( cv.Schema( { - cv.Required(CONF_BOARD): cv.All( + cv.Optional(CONF_BOARD): cv.All( cv.string_strict, cv.ByteLength(max=BOARD_MAX_LENGTH) ), + cv.Optional(CONF_VARIANT): cv.one_of(*VARIANTS, upper=True), cv.Optional(CONF_FRAMEWORK, default={}): ARDUINO_FRAMEWORK_SCHEMA, cv.Optional(CONF_WATCHDOG_TIMEOUT, default="8388ms"): cv.All( cv.positive_time_period_milliseconds, @@ -206,6 +301,8 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean, } ), + cv.has_at_least_one_key(CONF_BOARD, CONF_VARIANT), + _detect_variant, set_core_data, ) @@ -223,7 +320,9 @@ async def to_code(config): cg.add_define("USE_NATIVE_64BIT_TIME") cg.set_cpp_standard("gnu++20") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) - cg.add_define("ESPHOME_VARIANT", "RP2040") + variant = config[CONF_VARIANT] + cg.add_build_flag(f"-DUSE_RP2040_VARIANT_{variant}") + cg.add_define("ESPHOME_VARIANT", VARIANT_FRIENDLY[variant]) cg.add_define(ThreadModel.SINGLE) cg.add_platformio_option("extra_scripts", ["post:post_build.py"]) diff --git a/esphome/components/rp2040/const.py b/esphome/components/rp2040/const.py index e381d0482d..959753d95b 100644 --- a/esphome/components/rp2040/const.py +++ b/esphome/components/rp2040/const.py @@ -4,5 +4,31 @@ KEY_BOARD = "board" KEY_LWIP_OPTS = "lwip_opts" KEY_RP2040 = "rp2040" KEY_PIO_FILES = "pio_files" +KEY_VARIANT = "variant" + +VARIANT_RP2040 = "RP2040" +VARIANT_RP2350 = "RP2350" +VARIANTS = [ + VARIANT_RP2040, + VARIANT_RP2350, +] + +VARIANT_FRIENDLY = { + VARIANT_RP2040: "RP2040", + VARIANT_RP2350: "RP2350", +} + +# Map BOARDS[board]["mcu"] (lowercase) to canonical variant constant +MCU_TO_VARIANT = { + "rp2040": VARIANT_RP2040, + "rp2350": VARIANT_RP2350, +} + +# Default board chosen when only `variant` is specified — the Raspberry Pi +# Foundation reference boards (Pico W / Pico 2 W). +STANDARD_BOARDS = { + VARIANT_RP2040: "rpipicow", + VARIANT_RP2350: "rpipico2w", +} rp2040_ns = cg.esphome_ns.namespace("rp2040") diff --git a/esphome/core/defines.h b/esphome/core/defines.h index ee8e89de8b..3cb92616bb 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -401,6 +401,7 @@ #define USE_LOGGER_USB_CDC #define USE_SOCKET_IMPL_LWIP_TCP #define USE_RP2040_BLE +#define USE_RP2040_VARIANT_RP2040 #define USE_SPI #ifndef USE_ETHERNET #define USE_ETHERNET diff --git a/tests/components/rp2040/test.rp2040-ard.yaml b/tests/components/rp2040/test.rp2040-ard.yaml index 1eb315a3b4..09531f914e 100644 --- a/tests/components/rp2040/test.rp2040-ard.yaml +++ b/tests/components/rp2040/test.rp2040-ard.yaml @@ -1,4 +1,5 @@ rp2040: + variant: rp2040 enable_full_printf: false logger: diff --git a/tests/components/rp2040/test.rp2040-pico2-ard.yaml b/tests/components/rp2040/test.rp2040-pico2-ard.yaml new file mode 100644 index 0000000000..c9d795840d --- /dev/null +++ b/tests/components/rp2040/test.rp2040-pico2-ard.yaml @@ -0,0 +1,6 @@ +rp2040: + variant: rp2350 + enable_full_printf: false + +logger: + level: VERBOSE diff --git a/tests/unit_tests/components/test_rp2040.py b/tests/unit_tests/components/test_rp2040.py index 25a9ade567..8e726933ed 100644 --- a/tests/unit_tests/components/test_rp2040.py +++ b/tests/unit_tests/components/test_rp2040.py @@ -1,6 +1,11 @@ -"""Tests for RP2040 component public helpers.""" +"""Tests for RP2040 component public helpers and variant detection.""" -from esphome.components.rp2040 import board_id_has_wifi +import pytest + +from esphome.components.rp2040 import _detect_variant, board_id_has_wifi +from esphome.components.rp2040.const import VARIANT_RP2040, VARIANT_RP2350 +import esphome.config_validation as cv +from esphome.const import CONF_BOARD, CONF_VARIANT def test_board_id_has_wifi_for_known_wifi_board() -> None: @@ -27,3 +32,61 @@ def test_board_id_has_wifi_for_unknown_board_returns_true() -> None: "no CYW43" guard at compile time. """ assert board_id_has_wifi("not-a-real-board-id") is True + + +def test_detect_variant_derives_variant_from_board() -> None: + """Board alone resolves to the matching variant.""" + result = _detect_variant({CONF_BOARD: "rpipicow"}) + assert result[CONF_BOARD] == "rpipicow" + assert result[CONF_VARIANT] == VARIANT_RP2040 + + +def test_detect_variant_derives_variant_from_rp2350_board() -> None: + """An RP2350 board resolves to ``RP2350``.""" + result = _detect_variant({CONF_BOARD: "rpipico2"}) + assert result[CONF_BOARD] == "rpipico2" + assert result[CONF_VARIANT] == VARIANT_RP2350 + + +def test_detect_variant_only_picks_default_board_rp2040() -> None: + """Variant alone picks Pico W as the canonical RP2040 board.""" + result = _detect_variant({CONF_VARIANT: VARIANT_RP2040}) + assert result[CONF_BOARD] == "rpipicow" + assert result[CONF_VARIANT] == VARIANT_RP2040 + + +def test_detect_variant_only_picks_default_board_rp2350() -> None: + """Variant alone picks Pico 2 W as the canonical RP2350 board.""" + result = _detect_variant({CONF_VARIANT: VARIANT_RP2350}) + assert result[CONF_BOARD] == "rpipico2w" + assert result[CONF_VARIANT] == VARIANT_RP2350 + + +def test_detect_variant_matching_explicit_variant_passes() -> None: + """Specifying both a board and the matching variant is allowed.""" + result = _detect_variant({CONF_BOARD: "rpipico2", CONF_VARIANT: VARIANT_RP2350}) + assert result[CONF_BOARD] == "rpipico2" + assert result[CONF_VARIANT] == VARIANT_RP2350 + + +def test_detect_variant_mismatched_variant_raises() -> None: + """Board/variant mismatch must be rejected and name the offending board.""" + with pytest.raises( + cv.Invalid, match=r"does not match the selected board 'rpipicow'" + ): + _detect_variant({CONF_BOARD: "rpipicow", CONF_VARIANT: VARIANT_RP2350}) + + +def test_detect_variant_unknown_board_without_variant_raises() -> None: + """Unknown board with no variant tells the user how to recover.""" + with pytest.raises(cv.Invalid, match="please specify the chip variant"): + _detect_variant({CONF_BOARD: "not-a-real-board"}) + + +def test_detect_variant_unknown_board_with_variant_passes() -> None: + """Unknown board + explicit variant is accepted (with a warning).""" + result = _detect_variant( + {CONF_BOARD: "not-a-real-board", CONF_VARIANT: VARIANT_RP2040} + ) + assert result[CONF_BOARD] == "not-a-real-board" + assert result[CONF_VARIANT] == VARIANT_RP2040 From e0167e9bdff94e5a0aace72fbcb010ea1d9022d6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 May 2026 20:17:51 -0500 Subject: [PATCH 0200/1815] [lvgl] Memoize obj_schema by widget_type (#16615) --- esphome/components/lvgl/schemas.py | 14 +++- .../lvgl/test_obj_schema_cache.py | 67 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 tests/component_tests/lvgl/test_obj_schema_cache.py diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 58ef88d6a8..7436581fb4 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -462,13 +462,23 @@ def base_update_schema(widget_type: WidgetType | LvType, parts): return schema +# Widget types are module-level singletons populated at import time, so we +# can cache compiled obj_schemas by widget_type identity for the lifetime of +# the process. The strong reference in the value keeps the key (an id() +# target) from being recycled. +_OBJ_SCHEMA_CACHE: dict[int, tuple[WidgetType, cv.Schema]] = {} + + def obj_schema(widget_type: WidgetType): """ Create a schema for a widget type itself i.e. no allowance for children :param widget_type: :return: """ - return ( + cached = _OBJ_SCHEMA_CACHE.get(id(widget_type)) + if cached is not None and cached[0] is widget_type: + return cached[1] + schema = ( part_schema(widget_type.parts) .extend(ALIGN_TO_SCHEMA) .extend(automation_schema(widget_type.w_type)) @@ -479,6 +489,8 @@ def obj_schema(widget_type: WidgetType): } ) ) + _OBJ_SCHEMA_CACHE[id(widget_type)] = (widget_type, schema) + return schema ALIGN_TO_SCHEMA = { diff --git a/tests/component_tests/lvgl/test_obj_schema_cache.py b/tests/component_tests/lvgl/test_obj_schema_cache.py new file mode 100644 index 0000000000..860ee211dd --- /dev/null +++ b/tests/component_tests/lvgl/test_obj_schema_cache.py @@ -0,0 +1,67 @@ +"""Tests for obj_schema() memoization.""" + +from __future__ import annotations + +from collections.abc import Generator + +import pytest + +import esphome.components.lvgl # noqa: F401 +from esphome.components.lvgl import schemas as lvgl_schemas +from esphome.components.lvgl.schemas import WIDGET_TYPES, obj_schema + + +@pytest.fixture(autouse=True) +def _clear_obj_schema_cache() -> Generator[None]: + cache = getattr(lvgl_schemas, "_OBJ_SCHEMA_CACHE", None) + if cache is not None: + cache.clear() + yield + if cache is not None: + cache.clear() + + +def _widget_type(name: str = "obj"): + wt = WIDGET_TYPES.get(name) + assert wt is not None, f"widget type {name!r} not registered" + return wt + + +def test_same_widget_type_returns_same_schema() -> None: + wt = _widget_type("obj") + assert obj_schema(wt) is obj_schema(wt) + + +def test_different_widget_types_return_different_schemas() -> None: + assert obj_schema(_widget_type("obj")) is not obj_schema(_widget_type("label")) + + +def test_cache_is_populated_after_first_call() -> None: + wt = _widget_type("obj") + assert id(wt) not in lvgl_schemas._OBJ_SCHEMA_CACHE + obj_schema(wt) + assert id(wt) in lvgl_schemas._OBJ_SCHEMA_CACHE + + +def test_cached_schema_produces_equivalent_output() -> None: + wt = _widget_type("obj") + cached_result = obj_schema(wt)({}) + lvgl_schemas._OBJ_SCHEMA_CACHE.clear() + fresh_result = obj_schema(wt)({}) + assert cached_result == fresh_result + + +def test_id_recycling_is_caught_by_identity_guard() -> None: + wt = _widget_type("obj") + real_schema = obj_schema(wt) + + cached_widget_type, _ = lvgl_schemas._OBJ_SCHEMA_CACHE[id(wt)] + sentinel_schema = object() + lvgl_schemas._OBJ_SCHEMA_CACHE[id(wt)] = (cached_widget_type, sentinel_schema) + assert obj_schema(wt) is sentinel_schema + + other = _widget_type("label") + lvgl_schemas._OBJ_SCHEMA_CACHE[id(wt)] = (other, sentinel_schema) + rebuilt = obj_schema(wt) + assert rebuilt is not sentinel_schema + assert rebuilt is not real_schema From e7ab78366d184a912f5f76d717f6aa387b7cd21c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 25 May 2026 10:03:38 -0400 Subject: [PATCH 0201/1815] [core] Add esphome.build_flags option for IDF + PlatformIO (#16629) --- esphome/const.py | 1 + esphome/core/config.py | 11 +++++++++++ script/ci-custom.py | 2 +- tests/components/esphome/common.yaml | 2 ++ 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/esphome/const.py b/esphome/const.py index 9dd77a7cb8..07f6bad771 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -199,6 +199,7 @@ CONF_BROKER = "broker" CONF_BSSID = "bssid" CONF_BUFFER_DURATION = "buffer_duration" CONF_BUFFER_SIZE = "buffer_size" +CONF_BUILD_FLAGS = "build_flags" CONF_BUILD_PATH = "build_path" CONF_BUS_VOLTAGE = "bus_voltage" CONF_BUSY_PIN = "busy_pin" diff --git a/esphome/core/config.py b/esphome/core/config.py index 5a98b94781..6125c4ecc9 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -13,6 +13,7 @@ from esphome.const import ( CONF_AREA, CONF_AREA_ID, CONF_AREAS, + CONF_BUILD_FLAGS, CONF_BUILD_PATH, CONF_COMMENT, CONF_COMPILE_PROCESS_LIMIT, @@ -288,6 +289,7 @@ CONFIG_SCHEMA = cv.All( cv.string_strict: cv.Any([cv.string], cv.string), } ), + cv.Optional(CONF_BUILD_FLAGS, default=[]): cv.ensure_list(cv.string_strict), cv.Optional(CONF_ENVIRONMENT_VARIABLES, default={}): cv.Schema( { cv.string_strict: cv.string, @@ -510,6 +512,12 @@ async def _add_platformio_options(pio_options): cg.add_platformio_option(key, val) +@coroutine_with_priority(CoroPriority.FINAL) +async def _add_build_flags(flags: list[str]) -> None: + for flag in flags: + cg.add_build_flag(flag) + + @coroutine_with_priority(CoroPriority.FINAL) async def _add_environment_variables(env_vars: dict[str, str]) -> None: # Set environment variables for the build process @@ -705,6 +713,9 @@ async def to_code(config: ConfigType) -> None: if config[CONF_PLATFORMIO_OPTIONS]: CORE.add_job(_add_platformio_options, config[CONF_PLATFORMIO_OPTIONS]) + if config[CONF_BUILD_FLAGS]: + CORE.add_job(_add_build_flags, config[CONF_BUILD_FLAGS]) + if config[CONF_ENVIRONMENT_VARIABLES]: CORE.add_job(_add_environment_variables, config[CONF_ENVIRONMENT_VARIABLES]) diff --git a/script/ci-custom.py b/script/ci-custom.py index 56ca0d0355..51fea97874 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -562,7 +562,7 @@ def lint_constants_usage(): # Maximum allowed CONF_ constants in esphome/const.py. # This file is frozen — new constants go in esphome/components/const/__init__.py. # Decrease this number when constants are moved out of const.py. -CONST_PY_MAX_CONF = 1012 +CONST_PY_MAX_CONF = 1013 @lint_content_check(include=["esphome/const.py"]) diff --git a/tests/components/esphome/common.yaml b/tests/components/esphome/common.yaml index db75b08b38..93f82824e6 100644 --- a/tests/components/esphome/common.yaml +++ b/tests/components/esphome/common.yaml @@ -2,6 +2,8 @@ esphome: debug_scheduler: true platformio_options: board_build.flash_mode: dio + build_flags: + - "-DESPHOME_TEST_BUILD_FLAG" environment_variables: TEST_ENV_VAR: "test_value" BUILD_NUMBER: "12345" From 98e72133872cd7b198df16e6468dc874224f6f60 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 25 May 2026 10:08:16 -0400 Subject: [PATCH 0202/1815] [espidf] Warn instead of skipping libraries with framework mismatch (#16630) --- esphome/espidf/component.py | 23 +++++++++++++++++++---- tests/unit_tests/test_espidf_component.py | 11 ++++++++--- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index a452a3f34a..3534ac82f5 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -610,11 +610,17 @@ def _check_library_data(data: dict): """ Check if a library data is compatible with the ESP-IDF framework. + A platform mismatch (e.g. an AVR-only library on ESP32) raises + ``InvalidIDFComponent`` so the caller skips the library. A framework + mismatch only logs a warning — PIO manifests often understate the + frameworks they actually compile under, and IDF (unlike PIO's + ``lib_compat_mode``) has no opt-out, so we include the library anyway. + Args: - component: IDFComponent object being processed + data: PIO library manifest dict being processed. Raises: - ValueError: If library has unsupported platforms or frameworks + InvalidIDFComponent: If the library does not support the ESP32 platform. """ platforms = data.get("platforms", "*") if isinstance(platforms, str): @@ -632,12 +638,21 @@ def _check_library_data(data: dict): frameworks = [a.strip() for a in frameworks.split(",")] frameworks = _ensure_list(frameworks) - # Check if library supports ESP-IDF framework + # Check if library declares the active framework. PIO library manifests + # often list only "arduino" even when the library actually compiles fine + # under ESP-IDF, and IDF (unlike PIO with `lib_compat_mode`) has no way to + # opt out of the check. Warn instead of failing so the user isn't forced to + # fork the library to fix the manifest. framework = "arduino" if CORE.using_arduino else "espidf" valid_framework = "*" in frameworks or framework in frameworks if not valid_framework: - raise InvalidIDFComponent(f"Unsupported library frameworks: {frameworks}") + _LOGGER.warning( + "Library %s declares frameworks %s that do not include '%s'; including anyway", + data.get("name", ""), + frameworks, + framework, + ) def _process_dependencies(component: IDFComponent): diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 7d6c861ffd..f50f5317de 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -261,9 +261,14 @@ def test_check_library_data_invalid_platform(esp32_idf_core): _check_library_data({"platforms": ["other"], "frameworks": "*"}) -def test_check_library_data_invalid_framework(esp32_idf_core): - with pytest.raises(InvalidIDFComponent): - _check_library_data({"platforms": "*", "frameworks": ["other"]}) +def test_check_library_data_invalid_framework( + esp32_idf_core: None, caplog: pytest.LogCaptureFixture +) -> None: + # Framework mismatch is a warning, not a hard skip: the library is still + # included so that PIO manifests that only list "arduino" (but actually + # compile under IDF) can be used without forking them. + _check_library_data({"name": "lib", "platforms": "*", "frameworks": ["other"]}) + assert "do not include 'espidf'" in caplog.text def test_extra_script_captures_libpath_libs_and_defines(tmp_path): From cde52ef75e3c9f633883719f1c15b79041ca9efa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 09:09:54 -0500 Subject: [PATCH 0203/1815] [lvgl] Merge dict-extend chains to speed up schema construction (#16614) --- esphome/components/lvgl/__init__.py | 27 +- esphome/components/lvgl/schemas.py | 77 ++++-- .../lvgl/test_schema_dict_helpers.py | 236 ++++++++++++++++++ 3 files changed, 319 insertions(+), 21 deletions(-) create mode 100644 tests/component_tests/lvgl/test_schema_dict_helpers.py diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 4277c14dd7..44bcda9ba9 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -1,3 +1,4 @@ +import functools import importlib from pathlib import Path import pkgutil @@ -79,7 +80,7 @@ from .schemas import ( WIDGET_TYPES, any_widget_schema, container_schema, - obj_schema, + obj_dict, ) from .styles import styles_to_code, theme_to_code from .touchscreens import touchscreen_schema, touchscreens_to_code @@ -518,16 +519,32 @@ def add_hello_world(config): return config -def _theme_schema(value): +@functools.cache +def _build_theme_schema( + widget_types: tuple[tuple[str, widgets.WidgetType], ...], +) -> cv.Schema: + # The theme schema is value-independent: it depends only on the set of + # registered widget types. Key the cache on a snapshot of WIDGET_TYPES so + # that an external component registering a new widget after the first + # validation (legal per any_widget_schema's lazy-evaluation contract) + # produces a fresh tuple, a cache miss, and a rebuilt schema -- the cache + # self-heals instead of stale-rejecting valid themes. See obj_dict() in + # schemas.py for why chained .extend() is avoided here. return cv.Schema( { cv.Optional(df.CONF_DARK_MODE, default=False): cv.boolean, **{ - cv.Optional(name): obj_schema(w).extend(FULL_STYLE_SCHEMA) - for name, w in WIDGET_TYPES.items() + cv.Optional(name): cv.Schema( + {**obj_dict(w), **FULL_STYLE_SCHEMA.schema} + ) + for name, w in widget_types }, } - )(value) + ) + + +def _theme_schema(value: dict) -> dict: + return _build_theme_schema(tuple(WIDGET_TYPES.items()))(value) FINAL_VALIDATE_SCHEMA = final_validation diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 7436581fb4..b901eb4b53 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -378,15 +378,33 @@ TRIGGER_EVENT_MAP = { } -def part_schema(parts): +def part_dict(parts: tuple[str, ...] | list[str]) -> dict[Any, Any]: + """ + Return the raw mapping used by part_schema, so callers can merge it into a + larger dict and avoid chained .extend() calls (each .extend() recompiles the + whole mapping, turning the build into O(N^2)). + + Invariant: the source schemas spread here (STATE_SCHEMA, FLAG_SCHEMA, the + nested STATE_SCHEMA values) must use the default extra=PREVENT_EXTRA and + required=False and must not register any add_extra/prepend_extra + validators. Reaching into .schema and rebuilding via cv.Schema(...) keeps + only the mapping; non-default extra/required and any _extra_schemas would + be silently dropped. + """ + return { + **STATE_SCHEMA.schema, + **FLAG_SCHEMA.schema, + **{cv.Optional(part): STATE_SCHEMA for part in parts}, + } + + +def part_schema(parts: tuple[str, ...] | list[str]) -> cv.Schema: """ Generate a schema for the various parts (e.g. main:, indicator:) of a widget type :param parts: The parts to include :return: The schema """ - return STATE_SCHEMA.extend(FLAG_SCHEMA).extend( - {cv.Optional(part): STATE_SCHEMA for part in parts} - ) + return cv.Schema(part_dict(parts)) def automation_schema(typ: LvType): @@ -462,6 +480,43 @@ def base_update_schema(widget_type: WidgetType | LvType, parts): return schema +# Memoize obj_dict() the same way _OBJ_SCHEMA_CACHE memoizes obj_schema(). +# automation_schema(w.w_type) builds fresh Trigger.template(...) objects on +# every call, so without this cache _theme_schema pays that cost per widget +# per validation. Callers must treat the returned dict as immutable. The +# _theme_schema caller spreads it into a fresh dict, which is safe; the +# obj_schema caller passes it directly to cv.Schema(...) -- voluptuous stores +# the mapping by reference but never mutates it (.extend() copies first), so +# the alias is also safe today. Adding in-place mutation of obj_schema(w).schema +# would corrupt this cache. +_OBJ_DICT_CACHE: dict[int, tuple[WidgetType, dict[Any, Any]]] = {} + + +def obj_dict(widget_type: WidgetType) -> dict[Any, Any]: + """ + Return the raw mapping used by obj_schema, so callers can merge it into a + larger dict and avoid chained .extend() calls. + + Inherits the same source-schema invariant documented on part_dict: any + schema spread into this mapping must use the default extra=PREVENT_EXTRA + and required=False and must carry no add_extra/prepend_extra validators. + + The returned mapping is cached and must be treated as immutable by callers. + """ + cached = _OBJ_DICT_CACHE.get(id(widget_type)) + if cached is not None and cached[0] is widget_type: + return cached[1] + built = { + **part_dict(widget_type.parts), + **ALIGN_TO_SCHEMA, + **automation_schema(widget_type.w_type), + cv.Optional(CONF_STATE): SET_STATE_SCHEMA, + cv.Optional(CONF_GROUP): cv.use_id(lv_group_t), + } + _OBJ_DICT_CACHE[id(widget_type)] = (widget_type, built) + return built + + # Widget types are module-level singletons populated at import time, so we # can cache compiled obj_schemas by widget_type identity for the lifetime of # the process. The strong reference in the value keeps the key (an id() @@ -469,7 +524,7 @@ def base_update_schema(widget_type: WidgetType | LvType, parts): _OBJ_SCHEMA_CACHE: dict[int, tuple[WidgetType, cv.Schema]] = {} -def obj_schema(widget_type: WidgetType): +def obj_schema(widget_type: WidgetType) -> cv.Schema: """ Create a schema for a widget type itself i.e. no allowance for children :param widget_type: @@ -478,17 +533,7 @@ def obj_schema(widget_type: WidgetType): cached = _OBJ_SCHEMA_CACHE.get(id(widget_type)) if cached is not None and cached[0] is widget_type: return cached[1] - schema = ( - part_schema(widget_type.parts) - .extend(ALIGN_TO_SCHEMA) - .extend(automation_schema(widget_type.w_type)) - .extend( - { - cv.Optional(CONF_STATE): SET_STATE_SCHEMA, - cv.Optional(CONF_GROUP): cv.use_id(lv_group_t), - } - ) - ) + schema = cv.Schema(obj_dict(widget_type)) _OBJ_SCHEMA_CACHE[id(widget_type)] = (widget_type, schema) return schema diff --git a/tests/component_tests/lvgl/test_schema_dict_helpers.py b/tests/component_tests/lvgl/test_schema_dict_helpers.py new file mode 100644 index 0000000000..16714f54d7 --- /dev/null +++ b/tests/component_tests/lvgl/test_schema_dict_helpers.py @@ -0,0 +1,236 @@ +"""Tests for part_dict / obj_dict / part_schema / obj_schema mapping contracts. + +These guard the dict-merge refactor: the dict helpers must keep returning the +same logical mapping as the chained-extend version produced, and the +corresponding Schema(...) wrappers must accept and reject the same configs. +""" + +from __future__ import annotations + +from collections.abc import Generator + +import pytest +import voluptuous as vol + +from esphome import config_validation as cv +import esphome.components.lvgl +from esphome.components.lvgl import ( + _theme_schema, + defines as df, + schemas as lvgl_schemas, +) +from esphome.components.lvgl.schemas import ( + ALIGN_TO_SCHEMA, + FLAG_SCHEMA, + FULL_STYLE_SCHEMA, + STATE_SCHEMA, + STYLE_SCHEMA, + WIDGET_TYPES, + automation_schema, + obj_dict, + obj_schema, + part_dict, + part_schema, +) +from esphome.components.lvgl.types import LvType +from esphome.components.lvgl.widgets import WidgetType + + +@pytest.fixture(autouse=True) +def _clear_obj_dict_cache() -> Generator[None]: + cache = getattr(lvgl_schemas, "_OBJ_DICT_CACHE", None) + if cache is not None: + cache.clear() + # The lazily-built theme schema is cached on _build_theme_schema; clear it + # too so each test starts from a clean slate. + build_theme = getattr(esphome.components.lvgl, "_build_theme_schema", None) + if build_theme is not None and hasattr(build_theme, "cache_clear"): + build_theme.cache_clear() + yield + if cache is not None: + cache.clear() + if build_theme is not None and hasattr(build_theme, "cache_clear"): + build_theme.cache_clear() + + +def _marker_names(mapping) -> set[str]: + """Return the underlying string names of every voluptuous Marker key.""" + names: set[str] = set() + for key in mapping: + if isinstance(key, vol.Marker): + schema = key.schema + if isinstance(schema, str): + names.add(schema) + return names + + +def _widget_type(name: str = "obj"): + wt = WIDGET_TYPES.get(name) + assert wt is not None, f"widget type {name!r} not registered" + return wt + + +def test_part_dict_includes_state_flag_and_part_keys() -> None: + parts = ("indicator", "knob") + keys = _marker_names(part_dict(parts)) + + assert {"indicator", "knob"} <= keys + assert _marker_names(STATE_SCHEMA.schema) <= keys + assert _marker_names(FLAG_SCHEMA.schema) <= keys + + +def test_obj_dict_extends_part_dict_with_align_automation_state_group() -> None: + wt = _widget_type("obj") + part_keys = _marker_names(part_dict(wt.parts)) + obj_keys = _marker_names(obj_dict(wt)) + + assert part_keys <= obj_keys + assert _marker_names(ALIGN_TO_SCHEMA) <= obj_keys + assert _marker_names(automation_schema(wt.w_type)) <= obj_keys + assert {"state", "group"} <= obj_keys + + +def test_obj_dict_is_memoized_by_widget_type() -> None: + wt = _widget_type("obj") + first = obj_dict(wt) + second = obj_dict(wt) + assert first is second + # Different widget type, different dict. + assert obj_dict(_widget_type("label")) is not first + + +def test_part_schema_round_trips_known_state_and_part_settings() -> None: + schema = part_schema(("indicator",)) + out = schema( + { + "bg_color": 0x112233, + "checked": {"bg_color": 0x445566}, + "indicator": {"bg_color": 0x778899}, + } + ) + assert out["bg_color"] == 0x112233 + assert out["checked"]["bg_color"] == 0x445566 + assert out["indicator"]["bg_color"] == 0x778899 + + +def test_part_schema_rejects_unknown_part() -> None: + schema = part_schema(("indicator",)) + with pytest.raises(vol.Invalid): + schema({"definitely_not_a_part": {}}) + + +@pytest.mark.parametrize("name", sorted(WIDGET_TYPES)) +def test_obj_schema_accepts_empty_config_for_every_widget_type(name: str) -> None: + obj_schema(_widget_type(name))({}) + + +def test_obj_schema_accepts_align_to_and_state_group() -> None: + schema = obj_schema(_widget_type("obj")) + out = schema( + { + df.CONF_ALIGN_TO: { + "id": "some_other_widget", + df.CONF_ALIGN: "TOP_LEFT", + }, + "state": {"checked": True}, + } + ) + assert out[df.CONF_ALIGN_TO][df.CONF_ALIGN] == "LV_ALIGN_TOP_LEFT" + assert out["state"]["checked"] is True + + +def test_obj_schema_rejects_unknown_top_level_key() -> None: + with pytest.raises(vol.Invalid): + obj_schema(_widget_type("obj"))({"definitely_not_a_real_key": 1}) + + +def test_part_schema_returns_cv_schema_for_extend_callers() -> None: + schema = part_schema(("indicator",)) + extended = schema.extend({cv.Optional("extra_key"): cv.string}) + out = extended({"extra_key": "value", "bg_color": 0xAABBCC}) + assert out["extra_key"] == "value" + assert out["bg_color"] == 0xAABBCC + + +def test_obj_schema_returns_cv_schema_for_extend_callers() -> None: + schema = obj_schema(_widget_type("obj")) + extended = schema.extend({cv.Optional("extra_key"): cv.string}) + extended({"extra_key": "value"}) + + +@pytest.mark.parametrize( + "schema", + [STATE_SCHEMA, FLAG_SCHEMA, STYLE_SCHEMA, FULL_STYLE_SCHEMA], +) +def test_spread_sources_carry_no_extra_schemas(schema: cv.Schema) -> None: + # part_dict / obj_dict reach into .schema and rebuild via cv.Schema(...), + # which silently drops _extra_schemas and any non-default extra/required. + # Lock the invariant so a future add_extra() on these sources fails CI + # instead of quietly removing validation from part/obj/theme schemas. + assert not schema._extra_schemas + assert schema.extra is vol.PREVENT_EXTRA + assert schema.required is False + + +def test_theme_schema_merges_obj_dict_and_full_style_props() -> None: + # _theme_schema is the riskiest merge: obj_dict(w) and FULL_STYLE_SCHEMA.schema + # share many STYLE_SCHEMA marker instances. Exercise the merged schema + # end-to-end with one key from each side (a STATE_SCHEMA part from obj_dict + # and a FULL_STYLE-only property) to lock the behaviour against future + # regressions in either source. + out = _theme_schema( + { + df.CONF_DARK_MODE: True, + "obj": { + "bg_color": 0x112233, + "checked": {"bg_color": 0x445566}, + df.CONF_PAD_ROW: 4, + df.CONF_GRID_CELL_X_ALIGN: "CENTER", + }, + } + ) + assert out[df.CONF_DARK_MODE] is True + obj_out = out["obj"] + assert obj_out["bg_color"] == 0x112233 + assert obj_out["checked"]["bg_color"] == 0x445566 + assert obj_out[df.CONF_PAD_ROW] == 4 + assert obj_out[df.CONF_GRID_CELL_X_ALIGN] == "LV_GRID_ALIGN_CENTER" + + +def test_theme_schema_self_heals_when_a_widget_type_is_registered_later() -> None: + # _build_theme_schema is functools.cached on a snapshot of WIDGET_TYPES. + # any_widget_schema explicitly supports external components registering + # widgets lazily, and the device builder revalidates in-process, so a + # widget registered after first use must invalidate the cached snapshot. + _theme_schema({df.CONF_DARK_MODE: True}) # populate the cache + + name = "test_self_heal_widget" + assert name not in WIDGET_TYPES + # is_mock=True skips registration side-effects; insert into WIDGET_TYPES + # manually so the next theme call sees the new entry. + WIDGET_TYPES[name] = WidgetType(name, LvType("test_fake_t"), (), is_mock=True) + try: + out = _theme_schema({df.CONF_DARK_MODE: False, name: {"bg_color": 0x010203}}) + assert out[name]["bg_color"] == 0x010203 + finally: + WIDGET_TYPES.pop(name, None) + + +@pytest.mark.parametrize( + "schema", + [STATE_SCHEMA, FLAG_SCHEMA, STYLE_SCHEMA, FULL_STYLE_SCHEMA], +) +def test_spread_sources_have_no_top_level_marker_defaults(schema: cv.Schema) -> None: + # _theme_schema merges obj_dict(w) with FULL_STYLE_SCHEMA.schema; on a key + # collision, dict-spread keeps the first source's marker (and its default) + # but the last source's value, whereas .extend() would take both from the + # later source. The two are equivalent today because the overlapping + # markers are the same instances (both derive from STYLE_SCHEMA) and none + # carry a top-level default. Lock that so a future divergent default would + # fail CI rather than silently drift the merged validation. + offenders = [ + marker.schema + for marker in schema.schema + if isinstance(marker, vol.Optional) and marker.default is not vol.UNDEFINED + ] + assert not offenders, f"top-level Optional with default: {offenders}" From cf1fabe6d4d4e14936ce496fc4445a04ca62654f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 25 May 2026 10:11:31 -0400 Subject: [PATCH 0204/1815] [esp32_hosted] Bump esp_hosted to 2.12.8 and add use_psram option (#16627) --- esphome/components/esp32_hosted/__init__.py | 10 +++++++++- esphome/idf_component.yml | 2 +- tests/components/esp32_hosted/common.yaml | 1 + 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 71d1fd3ac1..94e20ea6c9 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -3,6 +3,7 @@ from pathlib import Path from esphome import pins from esphome.components import esp32 +from esphome.components.const import CONF_USE_PSRAM import esphome.config_validation as cv from esphome.const import ( CONF_CLK_PIN, @@ -39,6 +40,7 @@ BASE_SCHEMA = cv.Schema( cv.Required(CONF_VARIANT): cv.one_of(*esp32.VARIANTS, upper=True), cv.Required(CONF_ACTIVE_HIGH): cv.boolean, cv.Required(CONF_RESET_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_USE_PSRAM, default=False): cv.boolean, } ) @@ -242,6 +244,12 @@ async def to_code(config): else: _configure_spi(config) + # Place the transport mempool in PSRAM. Required on memory-tight host + # configurations (e.g. P4 with a large LVGL UI) where the internal-RAM + # mempool allocation fails at boot with `sdio_mempool_create` assert. + if config[CONF_USE_PSRAM]: + esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM", True) + # Library versions idf_ver = esp32.idf_version() os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}" @@ -249,7 +257,7 @@ async def to_code(config): esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.5.1") esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.2") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.7") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.8") else: esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 6bc166ff44..5af25fc351 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -36,7 +36,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.12.7 + version: 2.12.8 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: diff --git a/tests/components/esp32_hosted/common.yaml b/tests/components/esp32_hosted/common.yaml index ab029e5064..332fe5b070 100644 --- a/tests/components/esp32_hosted/common.yaml +++ b/tests/components/esp32_hosted/common.yaml @@ -3,6 +3,7 @@ esp32_hosted: slot: 1 active_high: true reset_pin: GPIO15 + use_psram: true cmd_pin: GPIO13 clk_pin: GPIO12 d0_pin: GPIO11 From 7c494fd3efcc9fc1c1dce0d6fc09d47a44affdfd Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 25 May 2026 10:15:51 -0400 Subject: [PATCH 0205/1815] [psram] Consolidate task stack in PSRAM handling (#16628) --- .../audio_file/media_source/__init__.py | 16 +++----------- esphome/components/audio_http/media_source.py | 18 +++------------ esphome/components/mixer/speaker/__init__.py | 13 +++++------ esphome/components/psram/__init__.py | 22 +++++++++++++++++++ .../components/resampler/speaker/__init__.py | 8 +++---- esphome/components/sendspin/__init__.py | 17 +++----------- .../sendspin/media_source/__init__.py | 5 ++--- .../speaker/media_player/__init__.py | 9 ++------ .../audio_file/validate.esp32-idf.yaml | 11 ++++++++++ 9 files changed, 54 insertions(+), 65 deletions(-) create mode 100644 tests/components/audio_file/validate.esp32-idf.yaml diff --git a/esphome/components/audio_file/media_source/__init__.py b/esphome/components/audio_file/media_source/__init__.py index 635a51b610..0710582813 100644 --- a/esphome/components/audio_file/media_source/__init__.py +++ b/esphome/components/audio_file/media_source/__init__.py @@ -1,7 +1,5 @@ -from typing import Any - import esphome.codegen as cg -from esphome.components import audio, esp32, media_source, psram +from esphome.components import audio, media_source, psram import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_TASK_STACK_IN_PSRAM from esphome.types import ConfigType @@ -21,19 +19,13 @@ def _request_micro_decoder(config: ConfigType) -> ConfigType: return config -def _validate_task_stack_in_psram(value: Any) -> bool: - if value := cv.boolean(value): - return cv.requires_component(psram.DOMAIN)(value) - return value - - CONFIG_SCHEMA = cv.All( media_source.media_source_schema( AudioFileMediaSource, ) .extend( { - cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram, + cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, } ) .extend(cv.COMPONENT_SCHEMA), @@ -49,6 +41,4 @@ async def to_code(config: ConfigType) -> None: if config.get(CONF_TASK_STACK_IN_PSRAM): cg.add(var.set_task_stack_in_psram(True)) - esp32.add_idf_sdkconfig_option( - "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True - ) + psram.request_external_task_stack() diff --git a/esphome/components/audio_http/media_source.py b/esphome/components/audio_http/media_source.py index 519d8df698..e8acbc81af 100644 --- a/esphome/components/audio_http/media_source.py +++ b/esphome/components/audio_http/media_source.py @@ -1,7 +1,5 @@ -from typing import Any - import esphome.codegen as cg -from esphome.components import audio, esp32, media_source, psram +from esphome.components import audio, media_source, psram import esphome.config_validation as cv from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TASK_STACK_IN_PSRAM from esphome.types import ConfigType @@ -20,14 +18,6 @@ def _request_micro_decoder(config: ConfigType) -> ConfigType: return config -def _validate_task_stack_in_psram(value: Any) -> bool: - # Only require the psram component when actually enabling PSRAM stacks; validating - # the boolean first means `false` doesn't trigger the requires_component check. - if value := cv.boolean(value): - return cv.requires_component(psram.DOMAIN)(value) - return value - - CONFIG_SCHEMA = cv.All( media_source.media_source_schema( AudioHTTPMediaSource, @@ -37,7 +27,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_BUFFER_SIZE, default=50000): cv.int_range( min=5000, max=1000000 ), - cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram, + cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, } ) .extend(cv.COMPONENT_SCHEMA), @@ -53,7 +43,5 @@ async def to_code(config: ConfigType) -> None: if config.get(CONF_TASK_STACK_IN_PSRAM): cg.add(var.set_task_stack_in_psram(True)) - esp32.add_idf_sdkconfig_option( - "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True - ) + psram.request_external_task_stack() cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE])) diff --git a/esphome/components/mixer/speaker/__init__.py b/esphome/components/mixer/speaker/__init__.py index 8501843d3f..47164a9997 100644 --- a/esphome/components/mixer/speaker/__init__.py +++ b/esphome/components/mixer/speaker/__init__.py @@ -1,6 +1,6 @@ from esphome import automation import esphome.codegen as cg -from esphome.components import audio, esp32, speaker +from esphome.components import audio, psram, speaker import esphome.config_validation as cv from esphome.const import ( CONF_BITS_PER_SAMPLE, @@ -93,7 +93,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_BITS_PER_SAMPLE): cv.one_of(8, 16, 24, 32, int=True), cv.Optional(CONF_NUM_CHANNELS): cv.int_range(min=1, max=2), cv.Optional(CONF_QUEUE_MODE, default=False): cv.boolean, - cv.Optional(CONF_TASK_STACK_IN_PSRAM, default=False): cv.boolean, + cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, } ), cv.only_on([PLATFORM_ESP32]), @@ -123,12 +123,9 @@ async def to_code(config): cg.add(var.set_output_speaker(spkr)) cg.add(var.set_queue_mode(config[CONF_QUEUE_MODE])) - if task_stack_in_psram := config.get(CONF_TASK_STACK_IN_PSRAM): - cg.add(var.set_task_stack_in_psram(task_stack_in_psram)) - if task_stack_in_psram and config[CONF_TASK_STACK_IN_PSRAM]: - esp32.add_idf_sdkconfig_option( - "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True - ) + if config.get(CONF_TASK_STACK_IN_PSRAM): + cg.add(var.set_task_stack_in_psram(True)) + psram.request_external_task_stack() # Initialize FixedVector with exact count of source speakers cg.add(var.init_source_speakers(len(config[CONF_SOURCE_SPEAKERS]))) diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index 86c17ce9ca..d36d900997 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -1,5 +1,6 @@ import logging import textwrap +from typing import Any import esphome.codegen as cg from esphome.components.const import CONF_IGNORE_NOT_FOUND @@ -94,6 +95,27 @@ def is_guaranteed() -> bool: return CORE.data.get(KEY_PSRAM_GUARANTEED, False) +def request_external_task_stack() -> None: + """Allow FreeRTOS task stacks to be allocated in external RAM (PSRAM). + + Components that expose a ``task_stack_in_psram`` option should call this from their + ``to_code`` when the option is enabled. The sdkconfig option only permits external + stacks; it does not move any stack into PSRAM on its own, so it stays opt-in per task. + """ + add_idf_sdkconfig_option("CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True) + + +def validate_task_stack_in_psram(value: Any) -> bool: + """Validate a ``task_stack_in_psram`` boolean, requiring the psram component only when enabled. + + Validating the boolean first means an explicit ``false`` does not pull in the psram + requirement, so the option can still be set to false on devices without PSRAM. + """ + if value := cv.boolean(value): + return cv.requires_component(DOMAIN)(value) + return value + + def validate_psram_mode(config): esp32_config = fv.full_config.get()[PLATFORM_ESP32] if config[CONF_SPEED] == "120MHZ": diff --git a/esphome/components/resampler/speaker/__init__.py b/esphome/components/resampler/speaker/__init__.py index 3134cf7646..8a13110631 100644 --- a/esphome/components/resampler/speaker/__init__.py +++ b/esphome/components/resampler/speaker/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import audio, esp32, speaker +from esphome.components import audio, psram, speaker import esphome.config_validation as cv from esphome.const import ( CONF_BITS_PER_SAMPLE, @@ -63,7 +63,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_BUFFER_DURATION, default="100ms" ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_TASK_STACK_IN_PSRAM, default=False): cv.boolean, + cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_FILTERS, default=16): cv.int_range(min=2, max=1024), cv.Optional(CONF_TAPS, default=16): _validate_taps, } @@ -88,9 +88,7 @@ async def to_code(config): if config.get(CONF_TASK_STACK_IN_PSRAM): cg.add(var.set_task_stack_in_psram(True)) - esp32.add_idf_sdkconfig_option( - "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True - ) + psram.request_external_task_stack() cg.add(var.set_target_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) cg.add(var.set_target_sample_rate(config[CONF_SAMPLE_RATE])) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index b670bd3c4d..e8c643f9b9 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -121,13 +121,6 @@ def register_player_config(config: ConfigType) -> None: data.player_config = config -def _validate_task_stack_in_psram(value): - value = cv.boolean(value) - if value: - return cv.requires_component(psram.DOMAIN)(value) - return value - - def _request_high_performance_networking(config: ConfigType) -> ConfigType: """Request high performance networking for Sendspin streaming. @@ -152,7 +145,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(SendspinHub), - cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram, + cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, } ), cv.only_on_esp32, @@ -201,9 +194,7 @@ async def to_code(config: ConfigType) -> None: if config.get(CONF_TASK_STACK_IN_PSRAM): cg.add(var.set_task_stack_in_psram(True)) - esp32.add_idf_sdkconfig_option( - "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True - ) + psram.request_external_task_stack() # sendspin-cpp library esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.1") @@ -261,9 +252,7 @@ async def to_code(config: ConfigType) -> None: psram_stack = player_cfg.get(CONF_TASK_STACK_IN_PSRAM, False) if psram_stack: - esp32.add_idf_sdkconfig_option( - "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True - ) + psram.request_external_task_stack() # Library defaults: priority 18 (one above httpd_priority 17 so the decoder is not # starved by the HTTP server during the initial encoded-audio burst at stream start), diff --git a/esphome/components/sendspin/media_source/__init__.py b/esphome/components/sendspin/media_source/__init__.py index f689ab01cb..6af244d41f 100644 --- a/esphome/components/sendspin/media_source/__init__.py +++ b/esphome/components/sendspin/media_source/__init__.py @@ -1,6 +1,6 @@ from esphome import automation import esphome.codegen as cg -from esphome.components import media_source +from esphome.components import media_source, psram import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, @@ -19,7 +19,6 @@ from .. import ( CONF_SENDSPIN_ID, MEMORY_LOCATIONS, SendspinHub, - _validate_task_stack_in_psram, register_player_config, request_controller_support, sendspin_ns, @@ -71,7 +70,7 @@ CONFIG_SCHEMA = cv.All( ).extend( { cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), - cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram, + cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_BUFFER_SIZE, default=1000000): cv.int_range(min=25000), cv.Optional(CONF_INITIAL_STATIC_DELAY, default="0ms"): cv.All( cv.positive_time_period_milliseconds, diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 094043c292..90eb19d73d 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -7,7 +7,6 @@ import esphome.codegen as cg from esphome.components import ( audio, audio_file, - esp32, media_player, network, ota, @@ -155,9 +154,7 @@ CONFIG_SCHEMA = cv.All( # Remove before 2026.10.0 cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.Any(cv.boolean, cv.string), cv.Optional(CONF_FILES): audio_file.audio_files_schema(), - cv.Optional(CONF_TASK_STACK_IN_PSRAM): cv.All( - cv.boolean, cv.requires_component(psram.DOMAIN) - ), + cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_VOLUME_INCREMENT, default=0.05): cv.percentage, cv.Optional(CONF_VOLUME_INITIAL, default=0.5): cv.percentage, cv.Optional(CONF_VOLUME_MAX, default=1.0): cv.percentage, @@ -198,9 +195,7 @@ async def to_code(config): if config.get(CONF_TASK_STACK_IN_PSRAM): cg.add(var.set_task_stack_in_psram(True)) - esp32.add_idf_sdkconfig_option( - "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True - ) + psram.request_external_task_stack() cg.add(var.set_volume_increment(config[CONF_VOLUME_INCREMENT])) cg.add(var.set_volume_initial(config[CONF_VOLUME_INITIAL])) diff --git a/tests/components/audio_file/validate.esp32-idf.yaml b/tests/components/audio_file/validate.esp32-idf.yaml new file mode 100644 index 0000000000..085f853c8e --- /dev/null +++ b/tests/components/audio_file/validate.esp32-idf.yaml @@ -0,0 +1,11 @@ +audio_file: + - id: test_audio + file: + type: local + path: $component_dir/test.wav + +media_source: + - platform: audio_file + id: audio_file_source + # task_stack_in_psram: false must validate without a psram: component + task_stack_in_psram: false From 684bce8b9a588a0a939673298158b3b9380dc633 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 25 May 2026 10:36:41 -0400 Subject: [PATCH 0206/1815] [esp32] Decode crash PCs via IDF toolchain on IDF builds (#16626) --- esphome/components/esp32/__init__.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index a06ae89c3e..e3bff8f934 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -46,7 +46,7 @@ from esphome.const import ( Toolchain, __version__, ) -from esphome.core import CORE, HexInt, Library +from esphome.core import CORE, EsphomeError, HexInt, Library from esphome.core.config import BOARD_MAX_LENGTH from esphome.coroutine import CoroPriority, coroutine_with_priority from esphome.espidf.component import generate_idf_component @@ -2658,13 +2658,29 @@ def copy_files(): def _decode_pc(config, addr): - from esphome.platformio import toolchain + # _decode_pc runs from the api log processor's asyncio callback, which + # only catches EsphomeError. Any other exception escaping here tears down + # the protocol and triggers an infinite reconnect/replay loop. Convert + # toolchain-resolution errors (e.g. missing build dir / cmake cache) into + # EsphomeError so the caller can disable decoding cleanly. + if CORE.using_toolchain_esp_idf: + from esphome.espidf import toolchain as idf_toolchain - idedata = toolchain.get_idedata(config) - if not idedata.addr2line_path or not idedata.firmware_elf_path: + try: + addr2line_path = idf_toolchain.get_addr2line_path() + firmware_elf_path = idf_toolchain.get_elf_path() + except RuntimeError as err: + raise EsphomeError(f"ESP-IDF toolchain not available: {err}") from err + else: + from esphome.platformio import toolchain + + idedata = toolchain.get_idedata(config) + addr2line_path = idedata.addr2line_path + firmware_elf_path = idedata.firmware_elf_path + if not addr2line_path or not firmware_elf_path: _LOGGER.debug("decode_pc no addr2line") return - command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr] + command = [str(addr2line_path), "-pfiaC", "-e", str(firmware_elf_path), addr] try: translation = subprocess.check_output(command, close_fds=False).decode().strip() except Exception: # pylint: disable=broad-except From 1c7ae96e424cadc00bbc9b778fbb2327b8103020 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 25 May 2026 11:04:26 -0400 Subject: [PATCH 0207/1815] [micro_wake_word] Allow task stack to be allocated in PSRAM (#16632) --- .../components/micro_wake_word/__init__.py | 8 ++++++- .../micro_wake_word/micro_wake_word.cpp | 24 +++++++------------ .../micro_wake_word/micro_wake_word.h | 8 ++++++- tests/components/micro_wake_word/common.yaml | 4 ++++ 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index 38926fce99..61d296cbba 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -7,7 +7,7 @@ from urllib.parse import urljoin from esphome import automation, external_files, git from esphome.automation import register_action, register_condition import esphome.codegen as cg -from esphome.components import esp32, microphone, ota +from esphome.components import esp32, microphone, ota, psram import esphome.config_validation as cv from esphome.const import ( CONF_FILE, @@ -20,6 +20,7 @@ from esphome.const import ( CONF_RAW_DATA_ID, CONF_REF, CONF_REFRESH, + CONF_TASK_STACK_IN_PSRAM, CONF_TYPE, CONF_URL, CONF_USERNAME, @@ -358,6 +359,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_VAD): _maybe_empty_vad_schema, cv.Optional(CONF_STOP_AFTER_DETECTION, default=True): cv.boolean, + cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_MODEL): cv.invalid( f"The {CONF_MODEL} parameter has moved to be a list element under the {CONF_MODELS} parameter." ), @@ -451,6 +453,10 @@ async def to_code(config): cg.add_define("USE_MICRO_WAKE_WORD") ota.request_ota_state_listeners() + if config.get(CONF_TASK_STACK_IN_PSRAM): + cg.add(var.set_task_stack_in_psram(True)) + psram.request_external_task_stack() + esp32.add_idf_component(name="espressif/esp-tflite-micro", ref="1.3.3~1") # Pin esp-nn for stable future builds (esp-tflite-micro depends on esp-nn) esp32.add_idf_component(name="espressif/esp-nn", ref="1.1.2") diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 739d64dc28..237d72229d 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -217,10 +217,7 @@ void MicroWakeWord::inference_task(void *params) { FrontendFreeStateContents(&this_mww->frontend_state_); xEventGroupSetBits(this_mww->event_group_, EventGroupBits::TASK_STOPPED); - while (true) { - // Continuously delay until the main loop deletes the task - delay(10); - } + vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it } std::vector MicroWakeWord::get_wake_words() { @@ -243,14 +240,14 @@ void MicroWakeWord::add_vad_model(const uint8_t *model_start, uint8_t probabilit #endif void MicroWakeWord::suspend_task_() { - if (this->inference_task_handle_ != nullptr) { - vTaskSuspend(this->inference_task_handle_); + if (this->inference_task_.is_created()) { + vTaskSuspend(this->inference_task_.get_handle()); } } void MicroWakeWord::resume_task_() { - if (this->inference_task_handle_ != nullptr) { - vTaskResume(this->inference_task_handle_); + if (this->inference_task_.is_created()) { + vTaskResume(this->inference_task_.get_handle()); } } @@ -292,8 +289,7 @@ void MicroWakeWord::loop() { if ((event_group_bits & EventGroupBits::TASK_STOPPED)) { ESP_LOGD(TAG, "Inference task is finished, freeing task resources"); - vTaskDelete(this->inference_task_handle_); - this->inference_task_handle_ = nullptr; + this->inference_task_.deallocate(); xEventGroupClearBits(this->event_group_, ALL_BITS); xQueueReset(this->detection_queue_); this->set_state_(State::STOPPED); @@ -311,7 +307,7 @@ void MicroWakeWord::loop() { switch (this->state_) { case State::STARTING: - if ((this->inference_task_handle_ == nullptr) && !this->status_has_error()) { + if (!this->inference_task_.is_created() && !this->status_has_error()) { // Setup preprocesor feature generator. If done in the task, it would lock the task to its initial core, as it // uses floating point operations. if (!FrontendPopulateState(&this->frontend_config_, &this->frontend_state_, @@ -320,10 +316,8 @@ void MicroWakeWord::loop() { return; } - xTaskCreate(MicroWakeWord::inference_task, "mww", INFERENCE_TASK_STACK_SIZE, (void *) this, - INFERENCE_TASK_PRIORITY, &this->inference_task_handle_); - - if (this->inference_task_handle_ == nullptr) { + if (!this->inference_task_.create(MicroWakeWord::inference_task, "mww", INFERENCE_TASK_STACK_SIZE, + (void *) this, INFERENCE_TASK_PRIORITY, this->task_stack_in_psram_)) { FrontendFreeStateContents(&this->frontend_state_); // Deallocate frontend state this->status_momentary_error("task_start", 1000); } diff --git a/esphome/components/micro_wake_word/micro_wake_word.h b/esphome/components/micro_wake_word/micro_wake_word.h index ef440b5d37..e4c590a423 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.h +++ b/esphome/components/micro_wake_word/micro_wake_word.h @@ -11,6 +11,7 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/defines.h" +#include "esphome/core/static_task.h" #ifdef USE_OTA_STATE_LISTENER #include "esphome/components/ota/ota_backend.h" @@ -59,6 +60,8 @@ class MicroWakeWord : public Component void set_stop_after_detection(bool stop_after_detection) { this->stop_after_detection_ = stop_after_detection; } + void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; } + Trigger *get_wake_word_detected_trigger() { return &this->wake_word_detected_trigger_; } void add_wake_word_model(WakeWordModel *model); @@ -93,6 +96,8 @@ class MicroWakeWord : public Component bool stop_after_detection_; + bool task_stack_in_psram_{false}; + uint8_t features_step_size_; // Audio frontend handles generating spectrogram features @@ -105,8 +110,9 @@ class MicroWakeWord : public Component // Used to send messages about the models' states to the main loop QueueHandle_t detection_queue_; + StaticTask inference_task_; + static void inference_task(void *params); - TaskHandle_t inference_task_handle_{nullptr}; /// @brief Suspends the inference task void suspend_task_(); diff --git a/tests/components/micro_wake_word/common.yaml b/tests/components/micro_wake_word/common.yaml index c051c8dd57..cd060c176e 100644 --- a/tests/components/micro_wake_word/common.yaml +++ b/tests/components/micro_wake_word/common.yaml @@ -1,3 +1,6 @@ +psram: + mode: quad + i2s_audio: i2s_lrclk_pin: GPIO18 i2s_bclk_pin: GPIO19 @@ -12,6 +15,7 @@ microphone: micro_wake_word: microphone: echo_microphone + task_stack_in_psram: true on_wake_word_detected: - logger.log: "Wake word detected" - micro_wake_word.stop: From 892e116680a0c8063e7d1ead63b74ed7748c89f9 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 25 May 2026 12:42:49 -0400 Subject: [PATCH 0208/1815] [router] Add a router speaker component to runtime choose output speaker (#16592) --- CODEOWNERS | 1 + esphome/components/router/__init__.py | 0 esphome/components/router/speaker/__init__.py | 123 +++++++++ .../router/speaker/router_speaker.cpp | 236 ++++++++++++++++++ .../router/speaker/router_speaker.h | 92 +++++++ tests/components/router/common.yaml | 44 ++++ tests/components/router/test.esp32-idf.yaml | 7 + 7 files changed, 503 insertions(+) create mode 100644 esphome/components/router/__init__.py create mode 100644 esphome/components/router/speaker/__init__.py create mode 100644 esphome/components/router/speaker/router_speaker.cpp create mode 100644 esphome/components/router/speaker/router_speaker.h create mode 100644 tests/components/router/common.yaml create mode 100644 tests/components/router/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index f8cdfdc6c6..3c3e502058 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -417,6 +417,7 @@ esphome/components/restart/* @esphome/core esphome/components/rf_bridge/* @jesserockz esphome/components/rgbct/* @jesserockz esphome/components/ring_buffer/* @kahrendt +esphome/components/router/speaker/* @kahrendt esphome/components/rp2040/* @jesserockz esphome/components/rp2040_ble/* @bdraco esphome/components/rp2040_pio_led_strip/* @Papa-DMan diff --git a/esphome/components/router/__init__.py b/esphome/components/router/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/router/speaker/__init__.py b/esphome/components/router/speaker/__init__.py new file mode 100644 index 0000000000..2b2dc56433 --- /dev/null +++ b/esphome/components/router/speaker/__init__.py @@ -0,0 +1,123 @@ +from esphome import automation, core +import esphome.codegen as cg +from esphome.components import audio, speaker +import esphome.config_validation as cv +from esphome.const import ( + CONF_BITS_PER_SAMPLE, + CONF_ID, + CONF_NUM_CHANNELS, + CONF_OUTPUT_SPEAKER, + CONF_SAMPLE_RATE, +) +from esphome.core import ID +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType, TemplateArgsType + +CODEOWNERS = ["@kahrendt"] + +CONF_OUTPUT_SPEAKERS = "output_speakers" +CONF_TARGET_SPEAKER = "target_speaker" + +router_ns = cg.esphome_ns.namespace("router") +Router = router_ns.class_("Router", cg.Component, speaker.Speaker) +SwitchOutputAction = router_ns.class_("SwitchOutputAction", automation.Action) + +SpeakerPtr = speaker.Speaker.operator("ptr") + + +def _set_stream_limits(config: ConfigType) -> ConfigType: + # Lock the router's stream limits to the user-declared format. Limits are set + # at CONFIG_SCHEMA time so they're visible to other components' FINAL_VALIDATE + # (which has no guaranteed ordering vs. ours). + audio.set_stream_limits( + min_bits_per_sample=config[CONF_BITS_PER_SAMPLE], + max_bits_per_sample=config[CONF_BITS_PER_SAMPLE], + min_channels=config[CONF_NUM_CHANNELS], + max_channels=config[CONF_NUM_CHANNELS], + min_sample_rate=config[CONF_SAMPLE_RATE], + max_sample_rate=config[CONF_SAMPLE_RATE], + )(config) + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(Router), + cv.Required(CONF_OUTPUT_SPEAKERS): cv.All( + cv.ensure_list(cv.use_id(speaker.Speaker)), + cv.Length(min=2, max=8), + ), + # All outputs must agree on a single format so the producer can keep + # streaming through a switch without reconfiguring. These are required + # rather than inherited because downstream components (e.g. mixer) + # read them from the router's declaration during FINAL_VALIDATE, + # which can't depend on our FINAL_VALIDATE running first. + cv.Required(CONF_BITS_PER_SAMPLE): cv.int_range(8, 32), + cv.Required(CONF_NUM_CHANNELS): cv.int_range(1, 2), + cv.Required(CONF_SAMPLE_RATE): cv.int_range(8000, 96000), + } + ).extend(cv.COMPONENT_SCHEMA), + cv.only_on_esp32, + _set_stream_limits, +) + + +def _final_validate(config: ConfigType) -> ConfigType: + # Validate every configured output speaker can accept the router's format. + # Switching to an output that can't reproduce the format the producer is + # already sending would otherwise fail silently at runtime. + for spk_id in config[CONF_OUTPUT_SPEAKERS]: + proxy = {**config, CONF_OUTPUT_SPEAKER: spk_id} + audio.final_validate_audio_schema( + "router", + audio_device=CONF_OUTPUT_SPEAKER, + bits_per_sample=config[CONF_BITS_PER_SAMPLE], + channels=config[CONF_NUM_CHANNELS], + sample_rate=config[CONF_SAMPLE_RATE], + )(proxy) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + # The first configured output is the default active output on boot. + speakers = config[CONF_OUTPUT_SPEAKERS] + cg.add(var.set_output_count(len(speakers))) + for spk_id in speakers: + spk = await cg.get_variable(spk_id) + cg.add(var.add_output(spk)) + + +@automation.register_action( + "router.speaker.switch_output", + SwitchOutputAction, + cv.Schema( + { + cv.GenerateID(CONF_ID): cv.use_id(Router), + cv.Required(CONF_TARGET_SPEAKER): cv.templatable( + cv.use_id(speaker.Speaker) + ), + } + ), + synchronous=True, +) +async def switch_output_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + parent = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, parent) + target = config[CONF_TARGET_SPEAKER] + if not isinstance(target, core.Lambda): + target = await cg.get_variable(target) + template_ = await cg.templatable(target, args, SpeakerPtr) + cg.add(var.set_target(template_)) + return var diff --git a/esphome/components/router/speaker/router_speaker.cpp b/esphome/components/router/speaker/router_speaker.cpp new file mode 100644 index 0000000000..f4bf7420ab --- /dev/null +++ b/esphome/components/router/speaker/router_speaker.cpp @@ -0,0 +1,236 @@ +#include "router_speaker.h" + +#ifdef USE_ESP32 + +#include "esphome/core/log.h" + +#include "esp_timer.h" + +#include + +namespace esphome::router { + +static const char *const TAG = "router.speaker"; + +static inline uint32_t atomic_subtract_clamped(std::atomic &var, uint32_t amount) { + uint32_t current = var.load(std::memory_order_acquire); + uint32_t subtracted = 0; + if (current > 0) { + uint32_t new_value; + do { + subtracted = std::min(amount, current); + new_value = current - subtracted; + } while (!var.compare_exchange_weak(current, new_value, std::memory_order_release, std::memory_order_acquire)); + } + return subtracted; +} + +void Router::setup() { + // Register a callback on every configured output. Each lambda captures its own + // index and only forwards when that output is the active one. This is required + // because CallbackManager has no remove() API. + for (size_t i = 0; i < this->outputs_.size(); i++) { + this->outputs_[i]->add_audio_output_callback([this, i](uint32_t frames, int64_t timestamp_us) { + // Always suppress the draining previous output during a switch, even if it's + // also the reselected active output (switching back to the bus holder). + // loop() fires one synthetic credit for its in-flight frames instead. + if (this->pending_start_prev_idx_.load(std::memory_order_relaxed) == static_cast(i)) { + return; + } + if (this->active_output_idx_.load(std::memory_order_relaxed) != static_cast(i)) { + return; + } + atomic_subtract_clamped(this->frames_in_pipeline_, frames); + this->audio_output_callback_.call(frames, timestamp_us); + }); + } +} + +void Router::loop() { + speaker::Speaker *active = this->get_active_output(); + + // Mid-switch: the new output's start() is deferred until the previous output + // fully releases shared hardware (e.g. a single i2s_audio bus driving two + // speakers). Starting earlier produces "Parent bus is busy" retries. The + // synthetic-credit callback is also deferred until prev is fully stopped, so + // that once its task has drained no natural callbacks can race ours. + const int8_t pending_prev_idx = this->pending_start_prev_idx_.load(std::memory_order_relaxed); + if (pending_prev_idx >= 0) { + speaker::Speaker *prev = this->outputs_[pending_prev_idx]; + if (prev->is_stopped()) { + this->pending_start_prev_idx_.store(-1, std::memory_order_relaxed); + + // Credit any frames left in prev's ring buffer / DMA so producer frame + // accounting (SpeakerSourceMediaPlayer pending_frames, sendspin/AEC + // clocks) clears cleanly. The leftover audio is intentionally dropped and + // the producer is told it played "now", giving a clean discontinuity that + // keeps frame accounting consistent across the switch. + const uint32_t in_flight = this->frames_in_pipeline_.exchange(0, std::memory_order_acq_rel); + if (in_flight > 0) { + this->audio_output_callback_.call(in_flight, esp_timer_get_time()); + } + + this->apply_cached_state_to_active_(); + this->state_ = speaker::STATE_STARTING; + active->start(); + } + return; + } + + // Mirror the active output's running/stopped state into our own state_ so that + // is_running() / is_stopped() stay accurate from the producer's perspective. + // Also catch the active output self-stopping (e.g. i2s_audio silence timeout): + // without this, our state_ would stay RUNNING forever and the next play() would + // skip start(). The output retains its own volume/mute across a restart (and we + // forward those live regardless), but stream info arrives via the non-virtual + // set_audio_stream_info() and never reaches the output on its own; if the format + // changed while stopped, only start()'s apply_cached_state_to_active_() pushes it + // down before the output's play()-side auto-start locks in the stale format. + if (active->is_stopped()) { + this->state_ = speaker::STATE_STOPPED; + } else if (this->state_ == speaker::STATE_STARTING && active->is_running()) { + this->state_ = speaker::STATE_RUNNING; + } +} + +void Router::dump_config() { + ESP_LOGCONFIG(TAG, + "Router Speaker:\n" + " Outputs: %u", + static_cast(this->outputs_.size())); +} + +size_t Router::play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) { + speaker::Speaker *active = this->get_active_output(); + + // Drop frames during a mid-switch until the old output releases shared hardware; + // forwarding now would trigger the new output's play()-side auto-start while + // the bus is still busy. + if (this->pending_start_prev_idx_.load(std::memory_order_relaxed) >= 0) { + vTaskDelay(ticks_to_wait); + return 0; + } + + // Producers (e.g. mixer) set stream info on us and then drive play() from a + // task without ever calling our start(). i2s_audio's play() auto-starts the + // underlying driver, so we must push our cached stream info to the active + // output before that auto-start, or it locks to its default (16k mono). + if (this->state_ == speaker::STATE_STOPPED) { + this->start(); + vTaskDelay(ticks_to_wait); + ticks_to_wait = 0; + } + + size_t written = active->play(data, length, ticks_to_wait); + if (written > 0) { + const uint32_t frames = this->audio_stream_info_.bytes_to_frames(written); + this->frames_in_pipeline_.fetch_add(frames, std::memory_order_release); + } + return written; +} + +void Router::start() { + this->frames_in_pipeline_.store(0, std::memory_order_release); + this->apply_cached_state_to_active_(); + this->state_ = speaker::STATE_STARTING; + this->get_active_output()->start(); +} + +void Router::stop() { + // Cancel any pending mid-switch start; the producer wants us stopped. + this->pending_start_prev_idx_.store(-1, std::memory_order_relaxed); + this->state_ = speaker::STATE_STOPPING; + this->get_active_output()->stop(); +} + +void Router::finish() { + this->pending_start_prev_idx_.store(-1, std::memory_order_relaxed); + this->state_ = speaker::STATE_STOPPING; + this->get_active_output()->finish(); +} + +bool Router::has_buffered_data() const { return this->get_active_output()->has_buffered_data(); } + +void Router::set_pause_state(bool pause_state) { + this->cached_pause_ = pause_state; + this->get_active_output()->set_pause_state(pause_state); +} + +void Router::set_volume(float volume) { + this->volume_ = volume; + this->get_active_output()->set_volume(volume); +} + +void Router::set_mute_state(bool mute_state) { + this->mute_state_ = mute_state; + this->get_active_output()->set_mute_state(mute_state); +} + +bool Router::switch_to_output(speaker::Speaker *target) { + if (target == nullptr) { + return false; + } + + int8_t new_idx = -1; + for (size_t i = 0; i < this->outputs_.size(); i++) { + if (this->outputs_[i] == target) { + new_idx = static_cast(i); + break; + } + } + if (new_idx < 0) { + ESP_LOGW(TAG, "Switch target is not a configured output"); + return false; + } + if (new_idx == this->active_output_idx_.load(std::memory_order_relaxed)) { + return true; + } + + // A switch is already in flight: pending_start_prev_idx_ is still releasing the + // shared bus and the current active output's start() is still deferred (it never + // started). Just redirect which output we start once the bus frees. Leave the bus + // holder (pending_start_prev_idx_), the in-flight frame counter (loop() still owes one + // synthetic credit for the bus holder's in-flight frames), and state_ alone, and + // don't stop the current active output, which never started. + if (this->pending_start_prev_idx_.load(std::memory_order_relaxed) >= 0) { + this->active_output_idx_.store(new_idx, std::memory_order_relaxed); + return true; + } + + const bool was_active = (this->state_ == speaker::STATE_STARTING || this->state_ == speaker::STATE_RUNNING); + const int8_t old_idx = this->active_output_idx_.load(std::memory_order_relaxed); + + if (was_active) { + this->outputs_[old_idx]->stop(); + } + + this->active_output_idx_.store(new_idx, std::memory_order_relaxed); + + if (was_active) { + // Defer start and the synthetic-credit callback until the old output's + // task is fully stopped; loop() handles both. Firing the synthetic credit + // here would race the old task's still-in-flight natural callbacks, + // dispatching audio_output_callback_ concurrently from two threads, which + // some consumers (e.g. sendspin's progress sync) aren't reentrant-safe for. + // STATE_STOPPING keeps producers from observing a transient stopped state + // and lets our play() short-circuit so the new output's play() doesn't + // auto-start it while the shared bus is still being released. + this->state_ = speaker::STATE_STOPPING; + this->pending_start_prev_idx_.store(old_idx, std::memory_order_relaxed); + } else { + this->frames_in_pipeline_.store(0, std::memory_order_release); + } + return true; +} + +void Router::apply_cached_state_to_active_() { + speaker::Speaker *active = this->get_active_output(); + active->set_audio_stream_info(this->audio_stream_info_); + active->set_volume(this->volume_); + active->set_mute_state(this->mute_state_); + active->set_pause_state(this->cached_pause_); +} + +} // namespace esphome::router + +#endif // USE_ESP32 diff --git a/esphome/components/router/speaker/router_speaker.h b/esphome/components/router/speaker/router_speaker.h new file mode 100644 index 0000000000..13b58a1c72 --- /dev/null +++ b/esphome/components/router/speaker/router_speaker.h @@ -0,0 +1,92 @@ +#pragma once + +#ifdef USE_ESP32 + +#include "esphome/components/speaker/speaker.h" +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include + +#include + +namespace esphome::router { + +class Router : public Component, public speaker::Speaker { + public: + float get_setup_priority() const override { return setup_priority::DATA; } + + void setup() override; + void loop() override; + void dump_config() override; + + size_t play(const uint8_t *data, size_t length) override { return this->play(data, length, 0); } + size_t play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) override; + + void start() override; + void stop() override; + void finish() override; + + bool has_buffered_data() const override; + + void set_pause_state(bool pause_state) override; + bool get_pause_state() const override { return this->cached_pause_; } + + void set_volume(float volume) override; + float get_volume() override { return this->volume_; } + + void set_mute_state(bool mute_state) override; + bool get_mute_state() override { return this->mute_state_; } + + // Allocate the output list to its final size. Must be called before add_output(). + void set_output_count(size_t count) { this->outputs_.init(count); } + void add_output(speaker::Speaker *spk) { this->outputs_.push_back(spk); } + + /// Switch the active output to the given speaker. Must be one of the configured outputs. + /// Returns false if `target` is not in the output list. + bool switch_to_output(speaker::Speaker *target); + + // Always valid: active_output_idx_ stays within [0, outputs_.size()) and at least + // two outputs are required (validated in Python), so this never returns null. + speaker::Speaker *get_active_output() const { + return this->outputs_[this->active_output_idx_.load(std::memory_order_relaxed)]; + } + + protected: + // Frames written to the active output but not yet played: incremented in play() and decremented + // (clamped at zero) by the active output's audio_output_callback. Mirrors mixer_speaker's + // frames_in_pipeline_. + std::atomic frames_in_pipeline_{0}; + + bool cached_pause_{false}; + + void apply_cached_state_to_active_(); + + // Index of the previously-active output we're waiting on to fully stop before + // starting the new one. -1 means no pending start. Set by switch_to_output() + // when switching mid-playback; cleared by loop() once the old output reports + // is_stopped(). Required because shared-bus drivers (e.g. two i2s_audio + // speakers on one i2s_bus) reject start() until the previous user releases. + std::atomic pending_start_prev_idx_{-1}; + + private: + FixedVector outputs_; + // Index into outputs_, always within [0, outputs_.size()). Defaults to the first + // configured output; updated by switch_to_output(). + std::atomic active_output_idx_{0}; +}; + +template class SwitchOutputAction : public Action { + public: + explicit SwitchOutputAction(Router *parent) : parent_(parent) {} + TEMPLATABLE_VALUE(speaker::Speaker *, target) + void play(const Ts &...x) override { this->parent_->switch_to_output(this->target_.value(x...)); } + + protected: + Router *parent_; +}; + +} // namespace esphome::router + +#endif // USE_ESP32 diff --git a/tests/components/router/common.yaml b/tests/components/router/common.yaml new file mode 100644 index 0000000000..360c6daaee --- /dev/null +++ b/tests/components/router/common.yaml @@ -0,0 +1,44 @@ +esphome: + on_boot: + then: + - router.speaker.switch_output: + id: router_id + target_speaker: speaker_b_id + # id omitted: auto-resolved since there's a single router instance + - router.speaker.switch_output: + target_speaker: !lambda return id(speaker_a_id); + +i2s_audio: + - id: i2s_a + i2s_lrclk_pin: ${a_lrclk_pin} + i2s_bclk_pin: ${a_bclk_pin} + - id: i2s_b + +speaker: + - platform: i2s_audio + id: speaker_a_id + i2s_audio_id: i2s_a + dac_type: external + i2s_dout_pin: ${a_dout_pin} + sample_rate: 48000 + bits_per_sample: 16bit + channel: stereo + - platform: i2s_audio + id: speaker_b_id + i2s_audio_id: i2s_b + dac_type: external + i2s_dout_pin: ${b_dout_pin} + spdif_mode: true + use_apll: true + sample_rate: 48000 + bits_per_sample: 16bit + channel: stereo + i2s_mode: primary + - platform: router + id: router_id + output_speakers: + - speaker_a_id + - speaker_b_id + sample_rate: 48000 + bits_per_sample: 16 + num_channels: 2 diff --git a/tests/components/router/test.esp32-idf.yaml b/tests/components/router/test.esp32-idf.yaml new file mode 100644 index 0000000000..241a9a8903 --- /dev/null +++ b/tests/components/router/test.esp32-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + a_lrclk_pin: GPIO4 + a_bclk_pin: GPIO5 + a_dout_pin: GPIO14 + b_dout_pin: GPIO19 + +<<: !include common.yaml From dcc30f865105587c26a85fc2cf29a4212726c86e Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 25 May 2026 15:39:54 -0400 Subject: [PATCH 0209/1815] [router] Share a single I2S bus in test (#16637) --- tests/components/router/common.yaml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/components/router/common.yaml b/tests/components/router/common.yaml index 360c6daaee..f1239de3cb 100644 --- a/tests/components/router/common.yaml +++ b/tests/components/router/common.yaml @@ -9,15 +9,12 @@ esphome: target_speaker: !lambda return id(speaker_a_id); i2s_audio: - - id: i2s_a - i2s_lrclk_pin: ${a_lrclk_pin} - i2s_bclk_pin: ${a_bclk_pin} - - id: i2s_b + i2s_lrclk_pin: ${a_lrclk_pin} + i2s_bclk_pin: ${a_bclk_pin} speaker: - platform: i2s_audio id: speaker_a_id - i2s_audio_id: i2s_a dac_type: external i2s_dout_pin: ${a_dout_pin} sample_rate: 48000 @@ -25,7 +22,6 @@ speaker: channel: stereo - platform: i2s_audio id: speaker_b_id - i2s_audio_id: i2s_b dac_type: external i2s_dout_pin: ${b_dout_pin} spdif_mode: true From 0b780f1fd29a70279c41e6e376d9a19f8f6f7e58 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 26 May 2026 06:21:15 +0930 Subject: [PATCH 0210/1815] [time][homeassistant] Fix timezone handling (#16583) --- esphome/components/api/api_connection.cpp | 2 +- esphome/components/api/client.py | 1 + .../components/homeassistant/time/__init__.py | 4 +- esphome/components/time/__init__.py | 56 ++- esphome/core/defines.h | 1 + tests/component_tests/time/__init__.py | 1 + tests/component_tests/time/test_init.py | 369 ++++++++++++++++++ 7 files changed, 414 insertions(+), 20 deletions(-) create mode 100644 tests/component_tests/time/__init__.py create mode 100644 tests/component_tests/time/test_init.py diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index f2bf3752fa..c880e036cb 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1169,7 +1169,7 @@ void APIConnection::on_camera_image_request(const CameraImageRequest &msg) { void APIConnection::on_get_time_response(const GetTimeResponse &value) { if (homeassistant::global_homeassistant_time != nullptr) { homeassistant::global_homeassistant_time->set_epoch_time(value.epoch_seconds); -#ifdef USE_TIME_TIMEZONE +#if defined(USE_HOMEASSISTANT_TIMEZONE) && defined(USE_TIME_TIMEZONE) if (!value.timezone.empty()) { // Check if the sender provided pre-parsed timezone data. // If std_offset is non-zero or DST rules are present, the parsed data was populated. diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index d6150fbd29..7fba091730 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -101,6 +101,7 @@ async def async_run_logs( client_info=f"ESPHome Logs {__version__}", noise_psk=noise_psk, addresses=addresses, # Pass all addresses for automatic retry + provide_time=False, ) # Try platform-specific stacktrace handler first, fall back to generic diff --git a/esphome/components/homeassistant/time/__init__.py b/esphome/components/homeassistant/time/__init__.py index 62cb96a25a..05ca86a26e 100644 --- a/esphome/components/homeassistant/time/__init__.py +++ b/esphome/components/homeassistant/time/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import time as time_ import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_ID, CONF_TIMEZONE from .. import homeassistant_ns @@ -21,3 +21,5 @@ async def to_code(config): await time_.register_time(var, config) await cg.register_component(var, config) cg.add_define("USE_HOMEASSISTANT_TIME") + if CONF_TIMEZONE not in config: + cg.add_define("USE_HOMEASSISTANT_TIMEZONE") diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index 29bb01b499..8839a988a1 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -30,13 +30,21 @@ from esphome.const import ( CONF_SECONDS, CONF_TIMEZONE, CONF_TRIGGER_ID, + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_HOST, + PLATFORM_LN882X, + PLATFORM_RP2040, + PLATFORM_RTL87XX, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True +DOMAIN = "time" time_ns = cg.esphome_ns.namespace("time") RealTimeClock = time_ns.class_("RealTimeClock", cg.PollingComponent) @@ -92,20 +100,34 @@ def _extract_tz_string(tzfile: bytes) -> str: raise -def detect_tz() -> str: +def detect_tz() -> str | None: + if CORE.target_platform not in { + PLATFORM_ESP8266, + PLATFORM_ESP32, + PLATFORM_RP2040, + PLATFORM_BK72XX, + PLATFORM_RTL87XX, + PLATFORM_LN882X, + PLATFORM_HOST, + }: + return None + # Avoids duplicate logger messages when multiple time components are configured + if cached := CORE.data.setdefault(DOMAIN, {}).get(CONF_TIMEZONE): + return cached iana_key = tzlocal.get_localzone_name() if iana_key is None: - raise cv.Invalid( + raise EsphomeError( "Could not automatically determine timezone, please set timezone manually." ) - _LOGGER.info("Detected timezone '%s'", iana_key) tzfile = _load_tzdata(iana_key) if tzfile is None: - raise cv.Invalid( + raise EsphomeError( "Could not automatically determine timezone, please set timezone manually." ) ret = _extract_tz_string(tzfile) + _LOGGER.info("Detected timezone '%s'", iana_key) _LOGGER.debug(" -> TZ string %s", ret) + CORE.data.setdefault(DOMAIN, {})[CONF_TIMEZONE] = ret return ret @@ -312,16 +334,7 @@ def validate_tz(value: str) -> str: TIME_SCHEMA = cv.Schema( { - cv.SplitDefault( - CONF_TIMEZONE, - esp8266=detect_tz, - esp32=detect_tz, - rp2040=detect_tz, - bk72xx=detect_tz, - rtl87xx=detect_tz, - ln882x=detect_tz, - host=detect_tz, - ): cv.All( + cv.Optional(CONF_TIMEZONE): cv.All( cv.only_with_framework(["arduino", "esp-idf", "host"]), validate_tz, ), @@ -384,7 +397,11 @@ def _emit_parsed_timezone_fields(parsed): async def setup_time_core_(time_var, config): - if timezone := config.get(CONF_TIMEZONE): + timezone = config.get(CONF_TIMEZONE) + # an empty timezone is treated as disabling timezones completely as before + if timezone is None: + timezone = detect_tz() + if timezone: cg.add_define("USE_TIME_TIMEZONE") if CORE.is_host: @@ -392,8 +409,11 @@ async def setup_time_core_(time_var, config): cg.add(time_var.set_timezone(timezone)) else: # Embedded: pre-parse at codegen time, emit struct directly - parsed = parse_posix_tz_python(timezone) - _emit_parsed_timezone_fields(parsed) + try: + parsed = parse_posix_tz_python(timezone) + _emit_parsed_timezone_fields(parsed) + except ValueError as e: + raise EsphomeError(f"Invalid timezone: {timezone}") from e for conf in config.get(CONF_ON_TIME, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], time_var) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 3cb92616bb..0229bc14fa 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -71,6 +71,7 @@ #define USE_GRAPH #define USE_GRAPHICAL_DISPLAY_MENU #define USE_HOMEASSISTANT_TIME +#define USE_HOMEASSISTANT_TIMEZONE #define USE_HTTP_REQUEST_OTA_WATCHDOG_TIMEOUT 8000 // NOLINT #define USE_I2S_AUDIO_SPDIF_MODE #define USE_IMAGE diff --git a/tests/component_tests/time/__init__.py b/tests/component_tests/time/__init__.py new file mode 100644 index 0000000000..dc24f4e532 --- /dev/null +++ b/tests/component_tests/time/__init__.py @@ -0,0 +1 @@ +"""Tests for the time component.""" diff --git a/tests/component_tests/time/test_init.py b/tests/component_tests/time/test_init.py new file mode 100644 index 0000000000..44469cfe28 --- /dev/null +++ b/tests/component_tests/time/test_init.py @@ -0,0 +1,369 @@ +"""Tests for time component – ha-timezone branch changes. + +Covers: +- detect_tz() platform guard (returns None for unsupported platforms) +- detect_tz() result caching (avoids duplicate log messages) +- detect_tz() error paths (tzlocal None, tzdata missing) +- validate_tz() accepts/rejects POSIX timezone strings and IANA keys +- TIME_SCHEMA: timezone is now truly optional (was SplitDefault) +- homeassistant/time: USE_HOMEASSISTANT_TIMEZONE define emitted iff + CONF_TIMEZONE is absent from the config +""" + +from __future__ import annotations + +from unittest import mock + +import pytest + +from esphome.components.time import DOMAIN, TIME_SCHEMA, detect_tz, validate_tz +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_TIMEZONE, + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + Platform, + PlatformFramework, +) +from esphome.core import CORE, EsphomeError +from tests.component_tests.types import SetCoreConfigCallable + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# A minimal TZif v2/v3 file that encodes "EST5EDT" as the footer line. +# The binary content is not validated at this level – what matters is that +# _extract_tz_string() picks up the last-but-one newline-terminated line. +_FAKE_TZFILE = b"\x00" * 44 + b"TZif2\x00" * 1 + b"\n" + b"EST5EDT,M3.2.0,M11.1.0\n" + + +def _set_platform(platform: Platform) -> None: + """Set CORE.data so that CORE.target_platform returns *platform*.""" + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: platform, + KEY_TARGET_FRAMEWORK: "arduino", + } + + +# --------------------------------------------------------------------------- +# detect_tz – platform guard +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "platform_framework", + [ + PlatformFramework.NRF52_ZEPHYR, + ], +) +def test_detect_tz_returns_none_for_unsupported_platform( + platform_framework: PlatformFramework, + set_core_config: SetCoreConfigCallable, +) -> None: + """detect_tz() must return None for platforms that do not support TZ auto-detection.""" + set_core_config(platform_framework) + result = detect_tz() + assert result is None + + +@pytest.mark.parametrize( + "platform_framework", + [ + PlatformFramework.ESP32_IDF, + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP8266_ARDUINO, + PlatformFramework.RP2040_ARDUINO, + PlatformFramework.BK72XX_ARDUINO, + PlatformFramework.RTL87XX_ARDUINO, + PlatformFramework.LN882X_ARDUINO, + PlatformFramework.HOST_NATIVE, + ], +) +def test_detect_tz_calls_tzlocal_for_supported_platform( + platform_framework: PlatformFramework, + set_core_config: SetCoreConfigCallable, +) -> None: + """detect_tz() must call tzlocal for every supported platform.""" + set_core_config(platform_framework) + with ( + mock.patch( + "esphome.components.time.tzlocal.get_localzone_name", + return_value="America/New_York", + ), + mock.patch( + "esphome.components.time._load_tzdata", + return_value=_FAKE_TZFILE, + ), + ): + result = detect_tz() + assert result is not None + assert isinstance(result, str) + assert len(result) > 0 + + +# --------------------------------------------------------------------------- +# detect_tz – caching +# --------------------------------------------------------------------------- + + +def test_detect_tz_caches_result( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """detect_tz() must cache the TZ string after the first call so that + subsequent invocations (e.g. when multiple time platforms are configured) + skip tzlocal and avoid duplicate INFO messages.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with ( + mock.patch( + "esphome.components.time.tzlocal.get_localzone_name", + return_value="America/New_York", + ) as mock_tz, + mock.patch( + "esphome.components.time._load_tzdata", + return_value=_FAKE_TZFILE, + ) as mock_load, + ): + first = detect_tz() + second = detect_tz() + + assert first == second + # tzlocal and _load_tzdata must be called exactly once despite two detect_tz() calls + mock_tz.assert_called_once() + mock_load.assert_called_once() + + +def test_detect_tz_cache_stored_in_core_data( + set_core_config: SetCoreConfigCallable, +) -> None: + """The cached TZ string should be stored under CORE.data[DOMAIN][CONF_TIMEZONE].""" + set_core_config(PlatformFramework.ESP32_IDF) + + with ( + mock.patch( + "esphome.components.time.tzlocal.get_localzone_name", + return_value="Europe/London", + ), + mock.patch( + "esphome.components.time._load_tzdata", + return_value=_FAKE_TZFILE, + ), + ): + result = detect_tz() + + assert CORE.data.get(DOMAIN, {}).get(CONF_TIMEZONE) == result + + +def test_detect_tz_returns_pre_seeded_cache( + set_core_config: SetCoreConfigCallable, +) -> None: + """If CORE.data already has a cached TZ string, detect_tz() must return it + without calling tzlocal at all.""" + set_core_config(PlatformFramework.ESP32_IDF) + CORE.data[DOMAIN] = {CONF_TIMEZONE: "CET-1CEST,M3.5.0,M10.5.0/3"} + + with mock.patch("esphome.components.time.tzlocal.get_localzone_name") as mock_tz: + result = detect_tz() + + assert result == "CET-1CEST,M3.5.0,M10.5.0/3" + mock_tz.assert_not_called() + + +# --------------------------------------------------------------------------- +# detect_tz – error paths +# --------------------------------------------------------------------------- + + +def test_detect_tz_raises_when_tzlocal_returns_none( + set_core_config: SetCoreConfigCallable, +) -> None: + """detect_tz() must raise EsphomeError when the local timezone cannot be determined.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with ( + mock.patch( + "esphome.components.time.tzlocal.get_localzone_name", + return_value=None, + ), + pytest.raises(EsphomeError, match="Could not automatically determine timezone"), + ): + detect_tz() + + +def test_detect_tz_raises_when_tzdata_not_found( + set_core_config: SetCoreConfigCallable, +) -> None: + """detect_tz() must raise EsphomeError when tzdata has no entry for the IANA key.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with ( + mock.patch( + "esphome.components.time.tzlocal.get_localzone_name", + return_value="Antarctica/Troll", + ), + mock.patch( + "esphome.components.time._load_tzdata", + return_value=None, + ), + pytest.raises(EsphomeError, match="Could not automatically determine timezone"), + ): + detect_tz() + + +# --------------------------------------------------------------------------- +# validate_tz +# --------------------------------------------------------------------------- + + +def test_validate_tz_accepts_valid_posix_string() -> None: + """validate_tz() must accept a syntactically valid POSIX TZ string.""" + result = validate_tz("UTC0") + assert result == "UTC0" + + +def test_validate_tz_accepts_posix_string_with_dst() -> None: + """validate_tz() must accept a full POSIX TZ string with DST rules.""" + tz = "EST5EDT,M3.2.0,M11.1.0" + result = validate_tz(tz) + assert result == tz + + +def test_validate_tz_accepts_iana_key_and_converts() -> None: + """validate_tz() must accept an IANA timezone key and return the POSIX string.""" + with mock.patch( + "esphome.components.time._load_tzdata", + return_value=_FAKE_TZFILE, + ): + result = validate_tz("America/New_York") + + # Should have been converted from IANA to POSIX via _extract_tz_string + assert result == "EST5EDT,M3.2.0,M11.1.0" + + +def test_validate_tz_rejects_invalid_posix_string() -> None: + """validate_tz() must raise cv.Invalid for a malformed POSIX TZ string.""" + with pytest.raises(cv.Invalid, match="Invalid POSIX timezone string"): + validate_tz("NOTAVALIDTZ!!!") + + +def test_validate_tz_accepts_empty_string() -> None: + """An empty string is accepted by validate_tz() and signals 'disable timezone'.""" + result = validate_tz("") + assert result == "" + + +# --------------------------------------------------------------------------- +# TIME_SCHEMA – timezone is now cv.Optional (no SplitDefault) +# --------------------------------------------------------------------------- + + +def test_time_schema_timezone_is_optional( + set_core_config: SetCoreConfigCallable, +) -> None: + """TIME_SCHEMA must accept a config with no timezone key on a supported platform.""" + set_core_config(PlatformFramework.ESP32_IDF) + # Should not raise + config = TIME_SCHEMA({}) + assert CONF_TIMEZONE not in config + + +def test_time_schema_explicit_timezone_accepted( + set_core_config: SetCoreConfigCallable, +) -> None: + """TIME_SCHEMA must accept an explicit valid POSIX timezone on Arduino/IDF.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = TIME_SCHEMA({CONF_TIMEZONE: "UTC0"}) + assert config[CONF_TIMEZONE] == "UTC0" + + +def test_time_schema_explicit_empty_timezone_accepted( + set_core_config: SetCoreConfigCallable, +) -> None: + """An empty timezone string (timezone-disable sentinel) must pass TIME_SCHEMA.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = TIME_SCHEMA({CONF_TIMEZONE: ""}) + assert config[CONF_TIMEZONE] == "" + + +def test_time_schema_timezone_rejected_on_zephyr( + set_core_config: SetCoreConfigCallable, +) -> None: + """TIME_SCHEMA must reject a timezone value on Zephyr with the framework error. + + The platform check (cv.only_with_framework) must run BEFORE validate_tz so + that users receive an actionable "unsupported framework" message rather than a + confusing TZ-parsing error. + """ + set_core_config(PlatformFramework.NRF52_ZEPHYR) + with pytest.raises(cv.Invalid, match="only available with framework"): + TIME_SCHEMA({CONF_TIMEZONE: "UTC0"}) + + +def test_time_schema_invalid_tz_on_zephyr_gives_framework_error( + set_core_config: SetCoreConfigCallable, +) -> None: + """Even a syntactically invalid TZ string must produce the framework error on Zephyr. + + This specifically tests that cv.only_with_framework is evaluated before + validate_tz: if the order were reversed, an invalid POSIX string would + generate a misleading TZ-parsing error instead. + """ + set_core_config(PlatformFramework.NRF52_ZEPHYR) + with pytest.raises(cv.Invalid, match="only available with framework"): + TIME_SCHEMA({CONF_TIMEZONE: "NOTAVALIDTZ!!!"}) + + +# --------------------------------------------------------------------------- +# homeassistant/time: USE_HOMEASSISTANT_TIMEZONE define +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_ha_cg(): + """Mock codegen functions used by homeassistant/time to_code.""" + with ( + mock.patch( + "esphome.components.homeassistant.time.cg.new_Pvariable", + return_value=mock.MagicMock(), + ), + mock.patch( + "esphome.components.homeassistant.time.cg.add_define", + ) as mock_add_define, + mock.patch( + "esphome.components.homeassistant.time.cg.register_component", + new_callable=mock.AsyncMock, + ), + mock.patch( + "esphome.components.homeassistant.time.time_.register_time", + new_callable=mock.AsyncMock, + ), + ): + yield mock_add_define + + +@pytest.mark.asyncio +async def test_ha_time_defines_ha_timezone_when_no_explicit_tz(mock_ha_cg) -> None: + """When CONF_TIMEZONE is absent from the config, to_code() must call + cg.add_define('USE_HOMEASSISTANT_TIMEZONE').""" + from esphome.components.homeassistant.time import to_code + + await to_code({CONF_ID: mock.MagicMock()}) + + mock_ha_cg.assert_any_call("USE_HOMEASSISTANT_TIMEZONE") + + +@pytest.mark.asyncio +async def test_ha_time_no_ha_timezone_define_when_explicit_tz(mock_ha_cg) -> None: + """When CONF_TIMEZONE is present in the config, to_code() must NOT call + cg.add_define('USE_HOMEASSISTANT_TIMEZONE').""" + from esphome.components.homeassistant.time import to_code + + await to_code({CONF_ID: mock.MagicMock(), CONF_TIMEZONE: "UTC0"}) + + define_calls = [call.args[0] for call in mock_ha_cg.call_args_list] + assert "USE_HOMEASSISTANT_TIME" in define_calls + assert "USE_HOMEASSISTANT_TIMEZONE" not in define_calls From fc0a4e22011fc4e852dc05b1433fbbc398434db7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 25 May 2026 17:07:35 -0400 Subject: [PATCH 0211/1815] [espidf] Support github:// and https://github.com/.../.git framework sources (#16639) --- esphome/espidf/framework.py | 115 +++++++++++++--- esphome/git.py | 5 +- tests/unit_tests/test_espidf_framework.py | 156 ++++++++++++++++++++++ 3 files changed, 256 insertions(+), 20 deletions(-) create mode 100644 tests/unit_tests/test_espidf_framework.py diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 079c97cc98..fb53066edb 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -8,6 +8,7 @@ import logging import os from pathlib import Path import platform +import re import shutil import subprocess import sys @@ -784,6 +785,77 @@ def download_from_mirrors( return None +_GITHUB_SHORTHAND_RE = re.compile( + r"^github://([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+?)(?:@([a-zA-Z0-9\-_.\./]+))?$" +) +_GITHUB_HTTPS_RE = re.compile( + r"^(https://github\.com/[a-zA-Z0-9\-]+/[a-zA-Z0-9\-\._]+?\.git)(?:@([a-zA-Z0-9\-_.\./]+))?$" +) + + +def _parse_git_source(source_url: str) -> tuple[str, str | None] | None: + """Return ``(url, ref)`` for ``github://owner/repo[@ref]`` or + ``https://github.com/owner/repo.git[@ref]``, else ``None``.""" + if m := _GITHUB_SHORTHAND_RE.match(source_url): + owner, repo, ref = m.group(1), m.group(2), m.group(3) + # Tolerate a trailing ".git" on the shorthand repo so the + # github://owner/repo.git form doesn't silently become repo.git.git. + repo = repo.removesuffix(".git") + return f"https://github.com/{owner}/{repo}.git", ref + if m := _GITHUB_HTTPS_RE.match(source_url): + return m.group(1), m.group(2) + return None + + +def _clone_idf_with_submodules( + framework_path: Path, git_url: str, ref: str | None +) -> None: + """Shallow-clone ESP-IDF with submodules into ``framework_path``. + + GitHub's archive zip strips submodules, so vendored components + (mbedtls, openthread, esptool, ...) come down empty and CMake fails. + + Uses clone + ``fetch FETCH_HEAD`` + ``reset --hard`` instead of + ``--branch``: ``--branch`` only accepts branch or tag names, but a + user can also point at a commit SHA. The fetch-then-reset pattern + handles branches, tags, and SHAs uniformly (mirrors the approach in + ``esphome.git.clone_or_update``). + """ + from esphome.git import run_git_command + + _LOGGER.info("Cloning ESP-IDF from %s%s", git_url, f"@{ref}" if ref else "") + run_git_command(["git", "clone", "--depth=1", "--", git_url, str(framework_path)]) + if ref: + run_git_command( + ["git", "fetch", "--depth=1", "--", "origin", ref], + git_dir=framework_path, + ) + run_git_command( + ["git", "reset", "--hard", "FETCH_HEAD"], + git_dir=framework_path, + ) + run_git_command( + [ + "git", + "submodule", + "update", + "--init", + "--recursive", + "--depth=1", + ], + git_dir=framework_path, + ) + + # Sanity-check the resulting tree. run_git_command only raises when + # stderr is non-empty, so a clone that silently produces no working + # tree would otherwise be marked extracted and stuck until + # ``esphome clean``. + if not (framework_path / "tools" / "idf_tools.py").is_file(): + raise RuntimeError( + f"Clone of {git_url} produced no usable ESP-IDF tree at {framework_path}" + ) + + def _write_idf_version_txt(framework_path: Path, version: str) -> None: """Write /version.txt if missing. @@ -939,27 +1011,34 @@ def _check_esphome_idf_framework_install( if install: rmdir(framework_path, msg=f"Clean up ESP-IDF {version} framework") - # Download in temporary file - with tempfile.NamedTemporaryFile() as tmp: - _LOGGER.info("Downloading ESP-IDF %s framework ...", version) + git_source = _parse_git_source(source_url) if source_url else None + if git_source is not None: + git_url, ref = git_source + _clone_idf_with_submodules(framework_path, git_url, ref) + else: + # Download in temporary file + with tempfile.NamedTemporaryFile() as tmp: + _LOGGER.info("Downloading ESP-IDF %s framework ...", version) - # Create substitutions for the URLs - substitutions = {"VERSION": version} - try: - ver = Version.parse(version) - substitutions["MAJOR"] = str(ver.major) - substitutions["MINOR"] = str(ver.minor) - substitutions["PATCH"] = str(ver.patch) - substitutions["EXTRA"] = ver.extra - except ValueError: - pass + # Create substitutions for the URLs + substitutions = {"VERSION": version} + try: + ver = Version.parse(version) + substitutions["MAJOR"] = str(ver.major) + substitutions["MINOR"] = str(ver.minor) + substitutions["PATCH"] = str(ver.patch) + substitutions["EXTRA"] = ver.extra + except ValueError: + pass - mirrors = [source_url] if source_url else ESPHOME_IDF_FRAMEWORK_MIRRORS - download_from_mirrors(mirrors, substitutions, tmp.file) + mirrors = [source_url] if source_url else ESPHOME_IDF_FRAMEWORK_MIRRORS + download_from_mirrors(mirrors, substitutions, tmp.file) - _LOGGER.info("Extracting ESP-IDF %s framework ...", version) - archive_extract_all(tmp.file, framework_path, progress_header="Extracting") - extracted_marker.touch() + _LOGGER.info("Extracting ESP-IDF %s framework ...", version) + archive_extract_all( + tmp.file, framework_path, progress_header="Extracting" + ) + extracted_marker.touch() # Idempotent post-extract patch: written every invocation so a build # dir extracted before this fix gets the file too, without forcing a diff --git a/esphome/git.py b/esphome/git.py index 0106f24845..f36bd559ef 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -72,8 +72,9 @@ def run_git_command(cmd: list[str], git_dir: Path | None = None) -> str: ) except FileNotFoundError as err: raise GitNotInstalledError( - "git is not installed but required for external_components.\n" - "Please see https://git-scm.com/book/en/v2/Getting-Started-Installing-Git for installing git" + "git is not installed. See " + "https://git-scm.com/book/en/v2/Getting-Started-Installing-Git " + "for installation instructions." ) from err if ret.returncode != 0 and ret.stderr: diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py new file mode 100644 index 0000000000..9f4e4fcca8 --- /dev/null +++ b/tests/unit_tests/test_espidf_framework.py @@ -0,0 +1,156 @@ +"""Tests for esphome.espidf.framework helpers.""" + +# pylint: disable=protected-access + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from esphome.espidf.framework import _clone_idf_with_submodules, _parse_git_source + + +@pytest.mark.parametrize( + ("source", "expected"), + [ + # github:// shorthand + ( + "github://espressif/esp-idf", + ("https://github.com/espressif/esp-idf.git", None), + ), + ( + "github://espressif/esp-idf@master", + ("https://github.com/espressif/esp-idf.git", "master"), + ), + ( + "github://espressif/esp-idf@release/v6.0", + ("https://github.com/espressif/esp-idf.git", "release/v6.0"), + ), + # explicit https://github.com/...git URL + ( + "https://github.com/espressif/esp-idf.git", + ("https://github.com/espressif/esp-idf.git", None), + ), + ( + "https://github.com/espressif/esp-idf.git@master", + ("https://github.com/espressif/esp-idf.git", "master"), + ), + ( + "https://github.com/espressif/esp-idf.git@v6.0.1", + ("https://github.com/espressif/esp-idf.git", "v6.0.1"), + ), + # Tolerate a trailing ".git" on the shorthand so the user doesn't + # silently end up with a doubled "...esp-idf.git.git" URL. + ( + "github://espressif/esp-idf.git", + ("https://github.com/espressif/esp-idf.git", None), + ), + ( + "github://espressif/esp-idf.git@master", + ("https://github.com/espressif/esp-idf.git", "master"), + ), + ], +) +def test_parse_git_source_recognized( + source: str, expected: tuple[str, str | None] +) -> None: + assert _parse_git_source(source) == expected + + +@pytest.mark.parametrize( + "source", + [ + # archive URLs fall through to the existing download path + "https://github.com/espressif/esp-idf/archive/refs/heads/master.zip", + "https://dl.espressif.com/dl/esp-idf/v6.0.1/esp-idf-v6.0.1.zip", + "https://github.com/esphome-libs/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz", + # SSH and other git protocols are intentionally rejected — match + # external_components, which only recognizes github:// + structured + # dicts for these. + "git@github.com:espressif/esp-idf.git", + "ssh://git@github.com/espressif/esp-idf.git", + "git://github.com/espressif/esp-idf.git", + # non-GitHub .git URLs are intentionally rejected for the same reason + "https://gitlab.com/foo/bar.git", + "https://github.example.com/foo/bar.git", + ], +) +def test_parse_git_source_rejected(source: str) -> None: + assert _parse_git_source(source) is None + + +def _make_idf_tree(framework_path: Path) -> None: + """Create the minimum tree _clone_idf_with_submodules sanity-checks for.""" + (framework_path / "tools").mkdir(parents=True) + (framework_path / "tools" / "idf_tools.py").write_text("# stub\n") + + +def test_clone_idf_with_submodules_without_ref(tmp_path: Path) -> None: + framework_path = tmp_path / "idf" + framework_path.mkdir() + _make_idf_tree(framework_path) + + with patch("esphome.git.run_git_command", return_value="") as run_git_command_mock: + _clone_idf_with_submodules( + framework_path, "https://github.com/espressif/esp-idf.git", None + ) + + # No ref -> just clone + submodule update, no fetch/reset. + calls = [c.args[0] for c in run_git_command_mock.call_args_list] + assert calls[0] == [ + "git", + "clone", + "--depth=1", + "--", + "https://github.com/espressif/esp-idf.git", + str(framework_path), + ] + assert calls[-1][:5] == ["git", "submodule", "update", "--init", "--recursive"] + assert not any(c[1] == "fetch" for c in calls) + assert not any(c[1] == "reset" for c in calls) + + +def test_clone_idf_with_submodules_with_ref(tmp_path: Path) -> None: + framework_path = tmp_path / "idf" + framework_path.mkdir() + _make_idf_tree(framework_path) + + with patch("esphome.git.run_git_command", return_value="") as run_git_command_mock: + _clone_idf_with_submodules( + framework_path, + "https://github.com/espressif/esp-idf.git", + "master", + ) + + calls = [c.args[0] for c in run_git_command_mock.call_args_list] + # clone, fetch ref, reset hard, submodule update + assert calls[0][:2] == ["git", "clone"] + assert calls[1] == [ + "git", + "fetch", + "--depth=1", + "--", + "origin", + "master", + ] + assert calls[2] == ["git", "reset", "--hard", "FETCH_HEAD"] + assert calls[3][:5] == ["git", "submodule", "update", "--init", "--recursive"] + + +def test_clone_idf_with_submodules_raises_when_tree_missing( + tmp_path: Path, +) -> None: + framework_path = tmp_path / "idf" + framework_path.mkdir() + # Deliberately do NOT call _make_idf_tree — simulate a clone that + # returned 0 but produced no tools/idf_tools.py. + + with ( + patch("esphome.git.run_git_command", return_value=""), + pytest.raises(RuntimeError, match="no usable ESP-IDF tree"), + ): + _clone_idf_with_submodules( + framework_path, + "https://github.com/espressif/esp-idf.git", + None, + ) From 61e8830a3ce38a67647d2b17b863049b296e7aaf Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 25 May 2026 17:40:38 -0400 Subject: [PATCH 0212/1815] [espidf] Keep cmake output filter working when IDF writes raw bytes (#16642) --- esphome/espidf/runner.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/esphome/espidf/runner.py b/esphome/espidf/runner.py index da3f77cdd3..857d16c674 100644 --- a/esphome/espidf/runner.py +++ b/esphome/espidf/runner.py @@ -164,6 +164,12 @@ def main() -> int: self._line_buffer = "" def __getattr__(self, name: str): + # Hide ``buffer`` so consumers that use either + # ``getattr(stream, 'buffer', None)`` or + # ``hasattr(stream, 'buffer')`` see this as a text-only stream + # and skip writing raw bytes (which would bypass the filter). + if name == "buffer": + raise AttributeError(name) return getattr(self._stream, name) def isatty(self) -> bool: From a257edba626ab1455f882f58a8a3f5787745a231 Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Mon, 25 May 2026 23:46:33 +0200 Subject: [PATCH 0213/1815] [mitsubishi_cn105] Add basic swing support (#15653) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/mitsubishi_cn105/climate.py | 12 +- .../mitsubishi_cn105/mitsubishi_cn105.h | 1 + .../mitsubishi_cn105_climate.cpp | 90 ++++++++++ .../mitsubishi_cn105_climate.h | 5 + .../mitsubishi_cn105_climate_tests.cpp | 165 ++++++++++++++++++ tests/components/mitsubishi_cn105/common.h | 11 ++ tests/components/mitsubishi_cn105/common.yaml | 3 + 7 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_climate_tests.cpp diff --git a/esphome/components/mitsubishi_cn105/climate.py b/esphome/components/mitsubishi_cn105/climate.py index cc44494d89..522b9218fc 100644 --- a/esphome/components/mitsubishi_cn105/climate.py +++ b/esphome/components/mitsubishi_cn105/climate.py @@ -1,8 +1,14 @@ from esphome import automation import esphome.codegen as cg from esphome.components import climate, uart +from esphome.components.climate import validate_climate_swing_mode import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_TEMPERATURE, CONF_UPDATE_INTERVAL +from esphome.const import ( + CONF_ID, + CONF_SUPPORTED_SWING_MODES, + CONF_TEMPERATURE, + CONF_UPDATE_INTERVAL, +) from esphome.core import ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType, TemplateArgsType @@ -43,6 +49,9 @@ CONFIG_SCHEMA = ( cv.Optional( CONF_CURRENT_TEMPERATURE_MIN_INTERVAL, default="60s" ): cv.update_interval, + cv.Optional( + CONF_SUPPORTED_SWING_MODES, default="OFF" + ): validate_climate_swing_mode, } ) ) @@ -63,6 +72,7 @@ async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) await cg.register_component(var, config) await uart.register_uart_device(var, config) + cg.add(var.set_supported_swing_mode(config[CONF_SUPPORTED_SWING_MODES])) cg.add( var.set_current_temperature_min_interval( config[CONF_CURRENT_TEMPERATURE_MIN_INTERVAL] diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h index dbeb43068e..742d8e18a9 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include "esphome/components/uart/uart.h" #include "esphome/core/finite_set_mask.h" diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp index 67a561397a..afffe7ea5e 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp @@ -84,6 +84,8 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() { traits.add_supported_fan_mode(p.second); } + traits.set_supported_swing_modes(this->supported_swing_modes_); + traits.set_visual_min_temperature(16.0f); traits.set_visual_max_temperature(31.0f); traits.set_visual_temperature_step(1.0f); @@ -114,6 +116,37 @@ void MitsubishiCN105Climate::control(const climate::ClimateCall &call) { this->hp_.set_fan_mode(*fan_mode); } + if (const auto swing_mode = call.get_swing_mode()) { + auto vane = this->last_non_swing_vane_mode_; + auto wide = this->last_non_swing_wide_vane_mode_; + + switch (*swing_mode) { + case climate::CLIMATE_SWING_BOTH: + vane = MitsubishiCN105::VaneMode::SWING; + wide = MitsubishiCN105::WideVaneMode::SWING; + break; + + case climate::CLIMATE_SWING_VERTICAL: + vane = MitsubishiCN105::VaneMode::SWING; + break; + + case climate::CLIMATE_SWING_HORIZONTAL: + wide = MitsubishiCN105::WideVaneMode::SWING; + break; + + case climate::CLIMATE_SWING_OFF: + default: + break; + } + + if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) { + this->hp_.set_vane_mode(vane); + } + if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) { + this->hp_.set_wide_vane_mode(wide); + } + } + if (this->hp_.is_status_initialized()) { this->apply_values_(); } @@ -143,7 +176,64 @@ void MitsubishiCN105Climate::apply_values_() { ESP_LOGD(TAG, "Unable to map fan mode"); } + if (!this->supported_swing_modes_.empty()) { + bool vertical_swinging = false; + bool horizontal_swinging = false; + + if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) { + if (status.vane_mode == MitsubishiCN105::VaneMode::SWING) { + vertical_swinging = true; + } else if (status.vane_mode != MitsubishiCN105::VaneMode::UNKNOWN) { + this->last_non_swing_vane_mode_ = status.vane_mode; + } + } + + if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) { + if (status.wide_vane_mode == MitsubishiCN105::WideVaneMode::SWING) { + horizontal_swinging = true; + } else if (status.wide_vane_mode != MitsubishiCN105::WideVaneMode::UNKNOWN) { + this->last_non_swing_wide_vane_mode_ = status.wide_vane_mode; + } + } + + if (vertical_swinging && horizontal_swinging) { + this->swing_mode = climate::CLIMATE_SWING_BOTH; + } else if (vertical_swinging) { + this->swing_mode = climate::CLIMATE_SWING_VERTICAL; + } else if (horizontal_swinging) { + this->swing_mode = climate::CLIMATE_SWING_HORIZONTAL; + } else { + this->swing_mode = climate::CLIMATE_SWING_OFF; + } + } + this->publish_state(); } +void MitsubishiCN105Climate::set_supported_swing_mode(climate::ClimateSwingMode mode) { + this->supported_swing_modes_.clear(); + switch (mode) { + case climate::CLIMATE_SWING_VERTICAL: + this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF); + this->supported_swing_modes_.insert(climate::CLIMATE_SWING_VERTICAL); + break; + + case climate::CLIMATE_SWING_HORIZONTAL: + this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF); + this->supported_swing_modes_.insert(climate::CLIMATE_SWING_HORIZONTAL); + break; + + case climate::CLIMATE_SWING_BOTH: + this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF); + this->supported_swing_modes_.insert(climate::CLIMATE_SWING_VERTICAL); + this->supported_swing_modes_.insert(climate::CLIMATE_SWING_HORIZONTAL); + this->supported_swing_modes_.insert(climate::CLIMATE_SWING_BOTH); + break; + + case climate::CLIMATE_SWING_OFF: + default: + break; + } +} + } // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h index e09158bfcf..c83a5519c1 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h @@ -25,10 +25,15 @@ class MitsubishiCN105Climate : public climate::Climate, public Component, public void set_remote_temperature(float temperature) { this->hp_.set_remote_temperature(temperature); } void clear_remote_temperature() { this->hp_.clear_remote_temperature(); } + void set_supported_swing_mode(climate::ClimateSwingMode mode); + protected: void apply_values_(); MitsubishiCN105 hp_; + climate::ClimateSwingModeMask supported_swing_modes_{}; + MitsubishiCN105::VaneMode last_non_swing_vane_mode_{MitsubishiCN105::VaneMode::AUTO}; + MitsubishiCN105::WideVaneMode last_non_swing_wide_vane_mode_{MitsubishiCN105::WideVaneMode::CENTER}; }; template diff --git a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_climate_tests.cpp b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_climate_tests.cpp new file mode 100644 index 0000000000..36e0fc90b4 --- /dev/null +++ b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_climate_tests.cpp @@ -0,0 +1,165 @@ +#include "../common.h" + +namespace esphome::mitsubishi_cn105::testing { + +TEST(MitsubishiCN105ClimateTests, SupportedSwingModeOffLeavesTraitsEmpty) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_OFF); + + EXPECT_FALSE(sut.traits().get_supports_swing_modes()); +} + +TEST(MitsubishiCN105ClimateTests, SupportedSwingModeVerticalExposesOffAndVertical) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL); + + EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF)); + EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL)); + EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL)); + EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH)); +} + +TEST(MitsubishiCN105ClimateTests, SupportedSwingModeHorizontalExposesOffAndHorizontal) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL); + + EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF)); + EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL)); + EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL)); + EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH)); +} + +TEST(MitsubishiCN105ClimateTests, SupportedSwingModeBothExposesAllExpectedModes) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH); + + EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF)); + EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL)); + EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL)); + EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH)); +} + +TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsVerticalSwingWhenSupported) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL); + + sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING; + sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER; + + sut.apply_values_(); + + EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_VERTICAL); +} + +TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsHorizontalSwingWhenSupported) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL); + + sut.status().vane_mode = MitsubishiCN105::VaneMode::AUTO; + sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING; + + sut.apply_values_(); + + EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_HORIZONTAL); +} + +TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsBothSwingWhenSupported) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH); + + sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING; + sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING; + + sut.apply_values_(); + + EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_BOTH); +} + +TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsSwingOffWhenNoSwingActive) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH); + + sut.status().vane_mode = MitsubishiCN105::VaneMode::POSITION_3; + sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER; + + sut.apply_values_(); + + EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF); +} + +TEST(MitsubishiCN105ClimateTests, ApplyValuesRemembersLastNonSwingPositions) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH); + + sut.status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4; + sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::RIGHT; + + sut.apply_values_(); + + EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_4); + EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::RIGHT); + + sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING; + sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING; + + sut.apply_values_(); + + EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_4); + EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::RIGHT); + EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_BOTH); +} + +TEST(MitsubishiCN105ClimateTests, ApplyValuesDoesNotOverwriteRememberedPositionWithUnknownValues) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH); + + sut.last_non_swing_vane_mode_ = MitsubishiCN105::VaneMode::POSITION_2; + sut.last_non_swing_wide_vane_mode_ = MitsubishiCN105::WideVaneMode::LEFT; + + sut.status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN; + sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::UNKNOWN; + + sut.apply_values_(); + + EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_2); + EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::LEFT); + EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF); +} + +TEST(MitsubishiCN105ClimateTests, ApplyValuesIgnoresUnsupportedVerticalSwingState) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL); + + sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING; + sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER; + + sut.apply_values_(); + + EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF); +} + +TEST(MitsubishiCN105ClimateTests, ApplyValuesIgnoresUnsupportedHorizontalSwingState) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL); + + sut.status().vane_mode = MitsubishiCN105::VaneMode::AUTO; + sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING; + + sut.apply_values_(); + + EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF); +} + +} // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h index 59b6203732..798f7283f6 100644 --- a/tests/components/mitsubishi_cn105/common.h +++ b/tests/components/mitsubishi_cn105/common.h @@ -8,6 +8,7 @@ #include #include "esphome/components/uart/uart_component.h" #include "esphome/components/mitsubishi_cn105/mitsubishi_cn105.h" +#include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h" namespace esphome::mitsubishi_cn105::testing { @@ -44,6 +45,7 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 { using MitsubishiCN105::State; using MitsubishiCN105::UpdateFlag; using MitsubishiCN105::state_; + using MitsubishiCN105::status_; using MitsubishiCN105::operation_start_ms_; using MitsubishiCN105::use_temperature_encoding_b_; using MitsubishiCN105::set_wide_vane_high_bit_; @@ -58,4 +60,13 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 { void set_current_time(uint32_t ms) { test_loop_time_ms = ms; } }; +class TestableMitsubishiCN105Climate : public MitsubishiCN105Climate { + public: + using MitsubishiCN105Climate::apply_values_; + using MitsubishiCN105Climate::last_non_swing_vane_mode_; + using MitsubishiCN105Climate::last_non_swing_wide_vane_mode_; + + MitsubishiCN105::Status &status() { return static_cast(this->hp_).status_; } +}; + } // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/mitsubishi_cn105/common.yaml b/tests/components/mitsubishi_cn105/common.yaml index 4b64f51261..5b9c3aaaf6 100644 --- a/tests/components/mitsubishi_cn105/common.yaml +++ b/tests/components/mitsubishi_cn105/common.yaml @@ -3,6 +3,9 @@ climate: id: ac name: "AC Test" uart_id: uart_bus + update_interval: 30s + current_temperature_min_interval: 120s + supported_swing_modes: BOTH esphome: on_boot: From 8645f3672d142417e5b3c0351e3946b9c2d7ebb0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 18:11:40 -0500 Subject: [PATCH 0214/1815] [core] Enable additional zero-violation ruff lint families (#16645) --- pyproject.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index d16bf2b625..94cd6d21b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -113,14 +113,22 @@ exclude = ['generated'] select = [ "E", # pycodestyle "F", # pyflakes/autoflake + "FA", # flake8-future-annotations "FLY", # flynt: convert string formatting to f-strings "FURB", # refurb "I", # isort + "ICN", # flake8-import-conventions + "LOG", # flake8-logging + "NPY", # numpy-specific rules "PERF", # performance "PL", # pylint + "Q", # flake8-quotes "SIM", # flake8-simplify "RET", # flake8-ret + "T10", # flake8-debugger "UP", # pyupgrade + "W", # pycodestyle warnings + "YTT", # flake8-2020 ] ignore = [ From 97267105e1917a0195d51b0267dc9b1073a47d88 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 20:05:51 -0500 Subject: [PATCH 0215/1815] [core] Enable ruff EXE (flake8-executable) lint family (#16648) --- pyproject.toml | 1 + script/ci_helpers.py | 0 2 files changed, 1 insertion(+) mode change 100755 => 100644 script/ci_helpers.py diff --git a/pyproject.toml b/pyproject.toml index 94cd6d21b8..5de8775713 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,6 +112,7 @@ exclude = ['generated'] [tool.ruff.lint] select = [ "E", # pycodestyle + "EXE", # flake8-executable "F", # pyflakes/autoflake "FA", # flake8-future-annotations "FLY", # flynt: convert string formatting to f-strings diff --git a/script/ci_helpers.py b/script/ci_helpers.py old mode 100755 new mode 100644 From 51722279312ab8c9e0475766592a0c39a581c2a8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 20:06:01 -0500 Subject: [PATCH 0216/1815] [core] Enable ruff SLOT (flake8-slots) lint family (#16647) --- pyproject.toml | 1 + tests/unit_tests/test_yaml_util.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5de8775713..a48bae660f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,6 +125,7 @@ select = [ "PL", # pylint "Q", # flake8-quotes "SIM", # flake8-simplify + "SLOT", # flake8-slots "RET", # flake8-ret "T10", # flake8-debugger "UP", # pyupgrade diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index de70a5307d..d6fb5b81f2 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -907,7 +907,7 @@ def test_format_path_current_obj_without_location_falls_back_to_key(): """An ESPHomeDataBase current_obj with no esp_range falls back to the key's location.""" class _NoRange(ESPHomeDataBase, str): - pass + __slots__ = () obj = _NoRange.__new__(_NoRange, "value") str.__init__(obj) From f1839489dd6c7ec61020fb0fa7e7e1fc5c62bda9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 20:06:18 -0500 Subject: [PATCH 0217/1815] [core] Enable ruff ISC (flake8-implicit-str-concat) lint family (#16646) --- pyproject.toml | 1 + script/api_protobuf/api_protobuf.py | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a48bae660f..8916abd6b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -119,6 +119,7 @@ select = [ "FURB", # refurb "I", # isort "ICN", # flake8-import-conventions + "ISC", # flake8-implicit-str-concat "LOG", # flake8-logging "NPY", # numpy-specific rules "PERF", # performance diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index bf672d0567..91aec91637 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1283,11 +1283,11 @@ class PackedBufferTypeInfo(TypeInfo): """Dump shows buffer info but not decoded values.""" return ( f'out.append(2, \' \').append_p(ESPHOME_PSTR("{self.name}")).append(": ");\n' - + 'out.append_p(ESPHOME_PSTR("packed buffer ["));\n' - + f"append_uint(out, this->{self.field_name}_count_);\n" - + 'out.append_p(ESPHOME_PSTR(" values, "));\n' - + f"append_uint(out, this->{self.field_name}_length_);\n" - + 'out.append_p(ESPHOME_PSTR(" bytes]\\n"));' + 'out.append_p(ESPHOME_PSTR("packed buffer ["));\n' + f"append_uint(out, this->{self.field_name}_count_);\n" + 'out.append_p(ESPHOME_PSTR(" values, "));\n' + f"append_uint(out, this->{self.field_name}_length_);\n" + 'out.append_p(ESPHOME_PSTR(" bytes]\\n"));' ) def dump(self, name: str) -> str: From bbc24ab5469ad15075ecd4ee52a60e6285bdd7c3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 20:06:34 -0500 Subject: [PATCH 0218/1815] [core] Enable ruff RSE (flake8-raise) lint family (#16649) --- esphome/components/external_components/__init__.py | 2 +- esphome/components/waveshare_epaper/display.py | 2 +- esphome/config_validation.py | 2 +- esphome/cpp_generator.py | 2 +- esphome/espidf/component.py | 2 +- pyproject.toml | 1 + 6 files changed, 6 insertions(+), 5 deletions(-) diff --git a/esphome/components/external_components/__init__.py b/esphome/components/external_components/__init__.py index 6eb577e5ad..c892ec1112 100644 --- a/esphome/components/external_components/__init__.py +++ b/esphome/components/external_components/__init__.py @@ -81,7 +81,7 @@ def _process_single_config(config: dict[str, Any]) -> None: elif conf[CONF_TYPE] == TYPE_LOCAL: components_dir = Path(CORE.relative_config_path(conf[CONF_PATH])) else: - raise NotImplementedError() + raise NotImplementedError if config[CONF_COMPONENTS] == "all": num_components = len(list(components_dir.glob("*/__init__.py"))) diff --git a/esphome/components/waveshare_epaper/display.py b/esphome/components/waveshare_epaper/display.py index 5db7a1fc3d..7ecc3b4a87 100644 --- a/esphome/components/waveshare_epaper/display.py +++ b/esphome/components/waveshare_epaper/display.py @@ -236,7 +236,7 @@ async def to_code(config): rhs = model.new() var = cg.Pvariable(config[CONF_ID], rhs, model) else: - raise NotImplementedError() + raise NotImplementedError await display.register_display(var, config) await spi.register_spi_device(var, config, write_only=True) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index c993c1dcc5..ca1fd8f5d4 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1862,7 +1862,7 @@ def extract_keys(schema): elif isinstance(skey, vol.Marker) and isinstance(skey.schema, str): keys.append(skey.schema) else: - raise ValueError() + raise ValueError keys.sort() return keys diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index c622207dac..c5e398b2d7 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -893,7 +893,7 @@ class MockObj(Expression): def __getattr__(self, attr: str) -> "MockObj": # prevent python dunder methods being replaced by mock objects if attr.startswith("__"): - raise AttributeError() + raise AttributeError next_op = "." if attr.startswith("P") and self.op not in ["::", ""]: attr = attr[1:] diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 3534ac82f5..81f2cd9632 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -55,7 +55,7 @@ ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE" class Source: def download(self, dir_suffix: str, force: bool = False) -> Path: - raise NotImplementedError() + raise NotImplementedError class URLSource(Source): diff --git a/pyproject.toml b/pyproject.toml index 8916abd6b9..0e7bba82e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,6 +125,7 @@ select = [ "PERF", # performance "PL", # pylint "Q", # flake8-quotes + "RSE", # flake8-raise "SIM", # flake8-simplify "SLOT", # flake8-slots "RET", # flake8-ret From b39b34bfe1f866c774719638552117ba5a2d3cc9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 20:14:26 -0500 Subject: [PATCH 0219/1815] [core] Enable ruff C4 (flake8-comprehensions) lint family (#16653) --- esphome/components/font/__init__.py | 4 ++-- esphome/components/libretiny/__init__.py | 14 +++++++------- esphome/components/lvgl/__init__.py | 2 +- esphome/components/lvgl/defines.py | 2 +- esphome/components/opentherm/generate.py | 10 +++------- esphome/components/time/__init__.py | 2 +- esphome/espidf/framework.py | 2 +- esphome/pins.py | 4 +--- pyproject.toml | 1 + script/ci-custom.py | 2 +- script/extract_automations.py | 6 +++--- 11 files changed, 22 insertions(+), 27 deletions(-) diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index a10c45a9d7..cb4b1d3a60 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -563,13 +563,13 @@ async def to_code(config): point_set.update(flatten(config[CONF_GLYPHS])) # Create the codepoint to font file map base_font = FONT_CACHE[config[CONF_FILE]] - point_font_map: dict[str, Face] = {c: base_font for c in point_set} + point_font_map: dict[str, Face] = dict.fromkeys(point_set, base_font) # process extras, updating the map and extending the codepoint list for extra in config[CONF_EXTRAS]: extra_points = flatten(extra[CONF_GLYPHS]) point_set.update(extra_points) extra_font = FONT_CACHE[extra[CONF_FILE]] - point_font_map.update({c: extra_font for c in extra_points}) + point_font_map.update(dict.fromkeys(extra_points, extra_font)) codepoints = list(point_set) codepoints.sort(key=functools.cmp_to_key(glyph_comparator)) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 40fb773784..d1f1042501 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -513,13 +513,13 @@ async def component_to_code(config): # apply LibreTiny options from framework: block # setup LT logger to work nicely with ESPHome logger - lt_options = dict( - LT_LOGLEVEL="LT_LEVEL_" + framework[CONF_LOGLEVEL], - LT_LOGGER_CALLER=0, - LT_LOGGER_TASK=0, - LT_LOGGER_COLOR=1, - LT_USE_TIME=1, - ) + lt_options = { + "LT_LOGLEVEL": "LT_LEVEL_" + framework[CONF_LOGLEVEL], + "LT_LOGGER_CALLER": 0, + "LT_LOGGER_TASK": 0, + "LT_LOGGER_COLOR": 1, + "LT_USE_TIME": 1, + } # enable/disable per-module debugging for module in framework[CONF_DEBUG]: if module == "NONE": diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 44bcda9ba9..6e005f897e 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -174,7 +174,7 @@ def generate_lv_conf_h(): if clashes: LOGGER.warning( "Some defines are set both by ESPHome build flags and by LVGL configuration which may lead to unexpected behavior: %s", - sorted(list(clashes)), + sorted(clashes), ) unused_defines = all_defines - lv_defines.keys() - defines_from_flags diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 15a24f1ad2..d9be881a7f 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -335,7 +335,7 @@ TYPE_NONE = "none" DIRECTIONS = LvConstant("LV_DIR_", "LEFT", "RIGHT", "BOTTOM", "TOP") -LV_FONTS = list(f"montserrat_{s}" for s in range(8, 50, 2)) + [ +LV_FONTS = [f"montserrat_{s}" for s in range(8, 50, 2)] + [ "dejavu_16_persian_hebrew", "simsun_16_cjk", "unscii_8", diff --git a/esphome/components/opentherm/generate.py b/esphome/components/opentherm/generate.py index 0b39895798..1c0de329e5 100644 --- a/esphome/components/opentherm/generate.py +++ b/esphome/components/opentherm/generate.py @@ -16,7 +16,7 @@ def define_has_component(component_type: str, keys: list[str]) -> None: cg.add_define( f"OPENTHERM_{component_type.upper()}_LIST(F, sep)", cg.RawExpression( - " sep ".join(map(lambda key: f"F({key}_{component_type.lower()})", keys)) + " sep ".join(f"F({key}_{component_type.lower()})" for key in keys) ), ) for key in keys: @@ -30,12 +30,8 @@ def define_has_settings(keys: list[str], schemas: dict[str, SettingSchema]) -> N "OPENTHERM_SETTING_LIST(F, sep)", cg.RawExpression( " sep ".join( - map( - lambda key: ( - f"F({schemas[key].backing_type}, {key}_setting, {schemas[key].default_value})" - ), - keys, - ) + f"F({schemas[key].backing_type}, {key}_setting, {schemas[key].default_value})" + for key in keys ) ), ) diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index 8839a988a1..e9f6cd77e5 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -204,7 +204,7 @@ def cron_expression_validator(name, min_value, max_value, special_mapping=None): raise cv.Invalid( f"{name} {v} is out of range (min={min_value} max={max_value})." ) - return list(sorted(value)) + return sorted(value) value = cv.string(value) values = set() for part in value.split(","): diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index fb53066edb..f393600732 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -40,7 +40,7 @@ def _str_to_lst_of_str(a: str | list[str]) -> list[str]: """ if isinstance(a, list): return a - return list(f.strip() for f in a.split(";") if f.strip()) + return [f.strip() for f in a.split(";") if f.strip()] ESPHOME_STAMP_FILE = ".esphome.stamp.json" diff --git a/esphome/pins.py b/esphome/pins.py index bdaa0e28ab..3e7848949f 100644 --- a/esphome/pins.py +++ b/esphome/pins.py @@ -313,9 +313,7 @@ def gpio_base_schema( :return: A schema for the pin """ mode_default = len(modes) == 1 - mode_dict = dict( - map(lambda m: (cv.Optional(m, default=mode_default), cv.boolean), modes) - ) + mode_dict = {cv.Optional(m, default=mode_default): cv.boolean for m in modes} def _number_validator(value): if isinstance(value, str) and value.upper().startswith("GPIOX"): diff --git a/pyproject.toml b/pyproject.toml index 0e7bba82e9..2b4a7272f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,6 +111,7 @@ exclude = ['generated'] [tool.ruff.lint] select = [ + "C4", # flake8-comprehensions "E", # pycodestyle "EXE", # flake8-executable "F", # pyflakes/autoflake diff --git a/script/ci-custom.py b/script/ci-custom.py index 51fea97874..c241343a1b 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -341,7 +341,7 @@ def lint_const_ordered(fname, content): matching = [ (i + 1, line) for i, line in enumerate(lines) if line.startswith(start) ] - ordered = list(sorted(matching, key=lambda x: x[1].replace("_", " "))) + ordered = sorted(matching, key=lambda x: x[1].replace("_", " ")) ordered = [(mi, ol) for (mi, _), (_, ol) in zip(matching, ordered)] for (mi, mline), (_, ol) in zip(matching, ordered): if mline == ol: diff --git a/script/extract_automations.py b/script/extract_automations.py index 4e650ce25f..3cdfb5d32c 100755 --- a/script/extract_automations.py +++ b/script/extract_automations.py @@ -12,9 +12,9 @@ if __name__ == "__main__": components = get_components_with_dependencies(files, True) dump = { - "actions": sorted(list(ACTION_REGISTRY.keys())), - "conditions": sorted(list(CONDITION_REGISTRY.keys())), - "pin_providers": sorted(list(PIN_SCHEMA_REGISTRY.keys())), + "actions": sorted(ACTION_REGISTRY.keys()), + "conditions": sorted(CONDITION_REGISTRY.keys()), + "pin_providers": sorted(PIN_SCHEMA_REGISTRY.keys()), } print(json.dumps(dump, indent=2)) From e492f8f8b6ba03f0cbe994d666e33215385800e1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 20:14:36 -0500 Subject: [PATCH 0220/1815] [tests] Disable hypothesis deadline on flaky IP address test (#16652) --- tests/unit_tests/test_helpers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index bb00a15bee..efc2d8e42a 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -7,7 +7,7 @@ import stat from unittest.mock import MagicMock, patch from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr -from hypothesis import given +from hypothesis import given, settings from hypothesis.strategies import ip_addresses import pytest @@ -151,6 +151,7 @@ def test_is_ip_address__invalid(host): assert actual is False +@settings(deadline=None) @given(value=ip_addresses(v=4).map(str)) def test_is_ip_address__valid(value): actual = helpers.is_ip_address(value) From dd0028c1b5a38b157143b267cd883fc0db277003 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 20:36:49 -0500 Subject: [PATCH 0221/1815] [core] Enable ruff G (flake8-logging-format) lint family (#16650) --- esphome/components/time/__init__.py | 2 +- esphome/loader.py | 4 ++-- esphome/pins.py | 3 ++- esphome/platformio/toolchain.py | 2 +- pyproject.toml | 1 + 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index e9f6cd77e5..f687df26c2 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -96,7 +96,7 @@ def _extract_tz_string(tzfile: bytes) -> str: return tzfile.split(b"\n")[-2].decode() except (IndexError, UnicodeDecodeError): _LOGGER.error("Could not determine TZ string. Please report this issue.") - _LOGGER.error("tzfile contents: %s", tzfile, exc_info=True) + _LOGGER.exception("tzfile contents: %s", tzfile) raise diff --git a/esphome/loader.py b/esphome/loader.py index d50554f8c9..c57c09274e 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -239,12 +239,12 @@ def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None: "Unable to import component %s: %s", domain, str(e), exc_info=False ) else: - _LOGGER.error("Unable to import component %s:", domain, exc_info=True) + _LOGGER.exception("Unable to import component %s:", domain) return None except Exception: # pylint: disable=broad-except if exception: raise - _LOGGER.error("Unable to load component %s:", domain, exc_info=True) + _LOGGER.exception("Unable to load component %s:", domain) return None manif = ComponentManifest(module) diff --git a/esphome/pins.py b/esphome/pins.py index 3e7848949f..d6393508ab 100644 --- a/esphome/pins.py +++ b/esphome/pins.py @@ -272,9 +272,10 @@ def check_strapping_pin(conf, strapping_pin_list: set[int], logger: Logger): num = conf[CONF_NUMBER] if num in strapping_pin_list and not conf.get(CONF_IGNORE_STRAPPING_WARNING): logger.warning( - f"GPIO{num} is a strapping PIN and should only be used for I/O with care.\n" + "GPIO%s is a strapping PIN and should only be used for I/O with care.\n" "Attaching external pullup/down resistors to strapping pins can cause unexpected failures.\n" "See https://esphome.io/guides/faq/#why-am-i-getting-a-warning-about-strapping-pins", + num, ) # mitigate undisciplined use of strapping: if num not in strapping_pin_list and conf.get(CONF_IGNORE_STRAPPING_WARNING): diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index 073e134ac4..c81420e6ca 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -96,7 +96,7 @@ def _run_idedata(config): try: return json.loads(match.group()) except ValueError: - _LOGGER.error("Could not parse idedata", exc_info=True) + _LOGGER.exception("Could not parse idedata") _LOGGER.error("Stdout: %s", stdout) raise diff --git a/pyproject.toml b/pyproject.toml index 2b4a7272f2..c8f4d88351 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,6 +118,7 @@ select = [ "FA", # flake8-future-annotations "FLY", # flynt: convert string formatting to f-strings "FURB", # refurb + "G", # flake8-logging-format "I", # isort "ICN", # flake8-import-conventions "ISC", # flake8-implicit-str-concat From 489cf483d0f7edd2373ada70efa284dc58e182ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 20:55:35 -0500 Subject: [PATCH 0222/1815] [core] Enable ruff PYI (flake8-pyi) lint family (#16654) --- esphome/cpp_generator.py | 22 ++++++++++++---------- esphome/mqtt.py | 2 +- esphome/yaml_util.py | 2 +- pyproject.toml | 1 + tests/unit_tests/test_main.py | 6 +++--- 5 files changed, 18 insertions(+), 15 deletions(-) diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index c5e398b2d7..151018baa4 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -1077,43 +1077,45 @@ class MockObj(Expression): op = BinOpExpression(other, "|", self) return MockObj(op) - def __iadd__(self, other: SafeExpType) -> "MockObj": + # MockObj operator overloads build a new C++ expression rather than mutating self, + # so the PYI034 "augmented assignment returns self" assumption does not apply. + def __iadd__(self, other: SafeExpType) -> "MockObj": # noqa: PYI034 op = BinOpExpression(self, "+=", other) return MockObj(op) - def __isub__(self, other: SafeExpType) -> "MockObj": + def __isub__(self, other: SafeExpType) -> "MockObj": # noqa: PYI034 op = BinOpExpression(self, "-=", other) return MockObj(op) - def __imul__(self, other: SafeExpType) -> "MockObj": + def __imul__(self, other: SafeExpType) -> "MockObj": # noqa: PYI034 op = BinOpExpression(self, "*=", other) return MockObj(op) - def __itruediv__(self, other: SafeExpType) -> "MockObj": + def __itruediv__(self, other: SafeExpType) -> "MockObj": # noqa: PYI034 op = BinOpExpression(self, "/=", other) return MockObj(op) - def __imod__(self, other: SafeExpType) -> "MockObj": + def __imod__(self, other: SafeExpType) -> "MockObj": # noqa: PYI034 op = BinOpExpression(self, "%=", other) return MockObj(op) - def __ilshift__(self, other: SafeExpType) -> "MockObj": + def __ilshift__(self, other: SafeExpType) -> "MockObj": # noqa: PYI034 op = BinOpExpression(self, "<<=", other) return MockObj(op) - def __irshift__(self, other: SafeExpType) -> "MockObj": + def __irshift__(self, other: SafeExpType) -> "MockObj": # noqa: PYI034 op = BinOpExpression(self, ">>=", other) return MockObj(op) - def __iand__(self, other: SafeExpType) -> "MockObj": + def __iand__(self, other: SafeExpType) -> "MockObj": # noqa: PYI034 op = BinOpExpression(self, "&=", other) return MockObj(op) - def __ixor__(self, other: SafeExpType) -> "MockObj": + def __ixor__(self, other: SafeExpType) -> "MockObj": # noqa: PYI034 op = BinOpExpression(self, "^=", other) return MockObj(op) - def __ior__(self, other: SafeExpType) -> "MockObj": + def __ior__(self, other: SafeExpType) -> "MockObj": # noqa: PYI034 op = BinOpExpression(self, "|=", other) return MockObj(op) diff --git a/esphome/mqtt.py b/esphome/mqtt.py index ccacbaea54..098292f599 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -159,7 +159,7 @@ def get_esphome_device_ip( username: str | None = None, password: str | None = None, client_id: str | None = None, - timeout: int | float = 25, + timeout: float = 25, ) -> list[str]: if CONF_MQTT not in config: raise EsphomeError( diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 9a36ad089c..28f72ab831 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -763,7 +763,7 @@ def parse_yaml(file_name: Path, file_handle: TextIOWrapper, yaml_loader=None) -> def _load_yaml_internal_with_type( - loader_type: type[ESPHomeLoader] | type[ESPHomePurePythonLoader], + loader_type: type[ESPHomeLoader | ESPHomePurePythonLoader], fname: Path, content: TextIOWrapper, yaml_loader: Callable[[Path], dict[str, Any]], diff --git a/pyproject.toml b/pyproject.toml index c8f4d88351..a094b05efe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,6 +126,7 @@ select = [ "NPY", # numpy-specific rules "PERF", # performance "PL", # pylint + "PYI", # flake8-pyi "Q", # flake8-quotes "RSE", # flake8-raise "SIM", # flake8-simplify diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 6ec0069b3a..f6b6d0b05f 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -11,7 +11,7 @@ from pathlib import Path import re import sys import time -from typing import Any +from typing import Any, Self from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest @@ -5110,11 +5110,11 @@ class MockSerial: self.timeout = 0.1 self._is_open = False - def __enter__(self) -> MockSerial: + def __enter__(self) -> Self: self._is_open = True return self - def __exit__(self, *args: Any) -> None: + def __exit__(self, *args: object) -> None: self._is_open = False @property From ae814cff5c51314f2daf43e89caeb71a832f636c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 21:28:14 -0500 Subject: [PATCH 0223/1815] [core] Enable ruff B (flake8-bugbear) lint family (#16655) --- esphome/__main__.py | 6 +++--- esphome/analyze_memory/cli.py | 6 +++--- esphome/analyze_memory/demangle.py | 2 +- esphome/components/api/client.py | 2 +- esphome/components/font/__init__.py | 4 +++- esphome/components/lvgl/lv_validation.py | 2 +- esphome/components/lvgl/widgets/tabview.py | 2 +- esphome/components/msa3xx/binary_sensor.py | 2 +- esphome/components/opentherm/output/__init__.py | 2 +- esphome/components/sensor/__init__.py | 9 ++++++--- esphome/core/__init__.py | 2 +- esphome/dashboard/status/mdns.py | 2 +- esphome/dashboard/status/ping.py | 6 +++--- esphome/dashboard/web_server.py | 9 ++++++--- esphome/writer.py | 2 +- esphome/zeroconf.py | 2 +- pyproject.toml | 1 + script/api_protobuf/api_protobuf.py | 4 ++-- script/build_language_schema.py | 2 +- script/ci-custom.py | 4 ++-- script/determine-jobs.py | 2 +- script/split_components_for_ci.py | 2 +- script/test_build_components.py | 4 ++-- tests/component_tests/display/test_display_metadata.py | 7 +++---- tests/component_tests/packages/test_packages.py | 10 ++-------- tests/integration/conftest.py | 6 ++++-- tests/unit_tests/test_config_normalization.py | 2 +- 27 files changed, 54 insertions(+), 50 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 07bbd89358..268164acf6 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -608,7 +608,7 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: try: module = importlib.import_module("esphome.components." + CORE.target_platform) - process_stacktrace = getattr(module, "process_stacktrace") + process_stacktrace = module.process_stacktrace except (AttributeError, ImportError): _LOGGER.info( 'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".', @@ -1101,7 +1101,7 @@ def upload_program( host = devices[0] try: module = importlib.import_module("esphome.components." + CORE.target_platform) - if getattr(module, "upload_program")(config, args, host): + if module.upload_program(config, args, host): return 0, host except AttributeError: pass @@ -1353,7 +1353,7 @@ def _validate_bootloader_binary(binary: Path) -> None: def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: try: module = importlib.import_module("esphome.components." + CORE.target_platform) - if getattr(module, "show_logs")(config, args, devices): + if module.show_logs(config, args, devices): return 0 except AttributeError: pass diff --git a/esphome/analyze_memory/cli.py b/esphome/analyze_memory/cli.py index 8f1f39e1d6..a856e2988d 100644 --- a/esphome/analyze_memory/cli.py +++ b/esphome/analyze_memory/cli.py @@ -509,7 +509,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): lines.append( f"{_COMPONENT_CORE} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B ({len(large_core_symbols)} symbols):" ) - for i, (symbol, demangled, size) in enumerate(large_core_symbols): + for i, (_symbol, demangled, size) in enumerate(large_core_symbols): # Core symbols only track (symbol, demangled, size) without section info, # so we don't show section labels here lines.append( @@ -601,7 +601,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): lines.append( f"{comp_name} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B & storage ({len(large_symbols)} symbols):" ) - for i, (symbol, demangled, size, section) in enumerate(large_symbols): + for i, (_symbol, demangled, size, section) in enumerate(large_symbols): lines.append( f"{i + 1}. {self._format_symbol_with_section(demangled, size, section)}" ) @@ -640,7 +640,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): lines.append( f" Symbols > {self.RAM_SYMBOL_SIZE_THRESHOLD} B ({len(large_ram_syms)}):" ) - for symbol, demangled, size, section in large_ram_syms[:10]: + for _symbol, demangled, size, section in large_ram_syms[:10]: # Format section label consistently by stripping leading dot section_label = section.lstrip(".") if section else "" display_name = _format_pstorage_name(demangled) diff --git a/esphome/analyze_memory/demangle.py b/esphome/analyze_memory/demangle.py index 8999108b51..7dbd6d4f63 100644 --- a/esphome/analyze_memory/demangle.py +++ b/esphome/analyze_memory/demangle.py @@ -154,7 +154,7 @@ def batch_demangle( failed_count = 0 for original, stripped, prefix, demangled in zip( - symbols, symbols_stripped, symbols_prefixes, demangled_lines + symbols, symbols_stripped, symbols_prefixes, demangled_lines, strict=True ): # Add back any prefix that was removed demangled = _restore_symbol_prefix(prefix, stripped, demangled) diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 7fba091730..327973a605 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -108,7 +108,7 @@ async def async_run_logs( platform_process_stacktrace = None try: module = importlib.import_module("esphome.components." + CORE.target_platform) - platform_process_stacktrace = getattr(module, "process_stacktrace") + platform_process_stacktrace = module.process_stacktrace except (AttributeError, ImportError): _LOGGER.info( 'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".', diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index cb4b1d3a60..4ea6267275 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -594,7 +594,9 @@ async def to_code(config): x.height, ] for (x, y) in zip( - glyph_args, list(accumulate([len(x.bitmap_data) for x in glyph_args])) + glyph_args, + list(accumulate([len(x.bitmap_data) for x in glyph_args])), + strict=True, ) ] diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index a1b75182eb..27cbfff694 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -239,7 +239,7 @@ def color_retmapper(value): else: r, g, b, _ = from_rgbw(cval) return literal(f"lv_color_make({r}, {g}, {b})") - assert False + raise AssertionError(f"Unhandled lv_color value: {value!r}") def option_string(value): diff --git a/esphome/components/lvgl/widgets/tabview.py b/esphome/components/lvgl/widgets/tabview.py index 5e9e0494dd..ee252ecf0b 100644 --- a/esphome/components/lvgl/widgets/tabview.py +++ b/esphome/components/lvgl/widgets/tabview.py @@ -97,7 +97,7 @@ class TabviewType(WidgetType): tab_bar = Widget(bar_obj, obj_spec) await set_obj_properties(tab_bar, tab_style) if tab_items_style: - for index, tab_conf in enumerate(config[CONF_TABS]): + for index, _tab_conf in enumerate(config[CONF_TABS]): await set_obj_properties( Widget(lv_obj.get_child(bar_obj, index), button_spec), tab_items_style, diff --git a/esphome/components/msa3xx/binary_sensor.py b/esphome/components/msa3xx/binary_sensor.py index 793d5190af..732a0ed291 100644 --- a/esphome/components/msa3xx/binary_sensor.py +++ b/esphome/components/msa3xx/binary_sensor.py @@ -26,7 +26,7 @@ CONFIG_SCHEMA = MSA_SENSOR_SCHEMA.extend( ), key=CONF_NAME, ) - for event, icon in zip(EVENT_SENSORS, ICONS) + for event, icon in zip(EVENT_SENSORS, ICONS, strict=True) } ) diff --git a/esphome/components/opentherm/output/__init__.py b/esphome/components/opentherm/output/__init__.py index 87307eb051..68977b9e34 100644 --- a/esphome/components/opentherm/output/__init__.py +++ b/esphome/components/opentherm/output/__init__.py @@ -21,7 +21,7 @@ async def new_openthermoutput( var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await output.register_output(var, config) - cg.add(getattr(var, "set_id")(cg.RawExpression(f'"{key}_{config[CONF_ID]}"'))) + cg.add(var.set_id(cg.RawExpression(f'"{key}_{config[CONF_ID]}"'))) input.generate_setters(var, config) return var diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 6bbab76363..5a2ebf03c0 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -1192,7 +1192,7 @@ def _std(x): def _correlation_coeff(x, y): m_x, m_y = _mean(x), _mean(y) - s_xy = sum((x_ - m_x) * (y_ - m_y) for x_, y_ in zip(x, y)) + s_xy = sum((x_ - m_x) * (y_ - m_y) for x_, y_ in zip(x, y, strict=True)) s_sq_x = sum((x_ - m_x) ** 2 for x_ in x) s_sq_y = sum((y_ - m_y) ** 2 for y_ in y) return s_xy / math.sqrt(s_sq_x * s_sq_y) @@ -1228,7 +1228,7 @@ def _mat_copy(m): def _mat_transpose(m): - return _mat_copy(zip(*m)) + return _mat_copy(zip(*m, strict=True)) def _mat_identity(n): @@ -1237,7 +1237,10 @@ def _mat_identity(n): def _mat_dot(a, b): b_t = _mat_transpose(b) - return [[sum(x * y for x, y in zip(row_a, col_b)) for col_b in b_t] for row_a in a] + return [ + [sum(x * y for x, y in zip(row_a, col_b, strict=True)) for col_b in b_t] + for row_a in a + ] def _mat_inverse(m): diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 580d7f6477..182be38b18 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -1081,7 +1081,7 @@ class EnumValue: @enum_value.setter def enum_value(self, value): - setattr(self, "_enum_value", value) + self._enum_value = value CORE = EsphomeCore() diff --git a/esphome/dashboard/status/mdns.py b/esphome/dashboard/status/mdns.py index 881340ab24..9da9bb8f01 100644 --- a/esphome/dashboard/status/mdns.py +++ b/esphome/dashboard/status/mdns.py @@ -115,7 +115,7 @@ class MDNSStatus: results = await asyncio.gather( *(self.aiozc.async_resolve_host(name) for name in poll_names) ) - for name, address_list in zip(poll_names, results): + for name, address_list in zip(poll_names, results, strict=True): result = bool(address_list) host_mdns_state[name] = result for entry in poll_names[name]: diff --git a/esphome/dashboard/status/ping.py b/esphome/dashboard/status/ping.py index b4f106d21a..eb69fbb9b3 100644 --- a/esphome/dashboard/status/ping.py +++ b/esphome/dashboard/status/ping.py @@ -83,7 +83,7 @@ class PingStatus: return_exceptions=True, ) - for entry, result in zip(ping_group, dns_results): + for entry, result in zip(ping_group, dns_results, strict=True): if isinstance(result, Exception): # Only update state if its unknown or from ping # so we don't mark it as offline if we have a state @@ -106,7 +106,7 @@ class PingStatus: return_exceptions=True, ) - for entry_addresses, result in zip(entry_addresses, results): + for entry_address, result in zip(entry_addresses, results, strict=True): if isinstance(result, Exception): ping_result = False elif isinstance(result, BaseException): @@ -114,7 +114,7 @@ class PingStatus: else: host: Host = result ping_result = host.is_alive - entry: DashboardEntry = entry_addresses[0] + entry: DashboardEntry = entry_address[0] # If we can reach it via ping, we always set it # online, however if we can't reach it via ping # we only set it to offline if the state is unknown diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 916e937a53..88b454e5cf 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -1030,7 +1030,7 @@ class DownloadListRequestHandler(BaseHandler): try: module = importlib.import_module(f"esphome.components.{platform}") - get_download_types = getattr(module, "get_download_types") + get_download_types = module.get_download_types except AttributeError as exc: raise ValueError(f"Unknown platform {platform}") from exc downloads = get_download_types(storage_json) @@ -1146,7 +1146,7 @@ class MainRequestHandler(BaseHandler): begin = bool(self.get_argument("begin", False)) if settings.using_password: # Simply accessing the xsrf_token sets the cookie for us - self.xsrf_token # pylint: disable=pointless-statement + self.xsrf_token # pylint: disable=pointless-statement # noqa: B018 else: self.clear_cookie("_xsrf") @@ -1519,7 +1519,10 @@ def get_static_file_url(name: str) -> str: return f"{base}?hash={hash_}" -def make_app(debug=get_bool_env(ENV_DEV)) -> tornado.web.Application: +def make_app(debug: bool | None = None) -> tornado.web.Application: + if debug is None: + debug = get_bool_env(ENV_DEV) + def log_function(handler: tornado.web.RequestHandler) -> None: if handler.get_status() < 400: log_method = access_log.info diff --git a/esphome/writer.py b/esphome/writer.py index ad3877465d..ab014c5daa 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -358,7 +358,7 @@ def copy_src_tree(): platform = "esphome.components." + CORE.target_platform try: module = importlib.import_module(platform) - copy_files = getattr(module, "copy_files") + copy_files = module.copy_files copy_files() except AttributeError: pass diff --git a/esphome/zeroconf.py b/esphome/zeroconf.py index 5d922ea911..a4f4f46097 100644 --- a/esphome/zeroconf.py +++ b/esphome/zeroconf.py @@ -249,7 +249,7 @@ async def async_resolve_hosts( ), return_exceptions=True, ) - for host, result in zip(pending, results): + for host, result in zip(pending, results, strict=True): if isinstance(result, BaseException): _LOGGER.debug("Failed to resolve %s: %s", host, result) diff --git a/pyproject.toml b/pyproject.toml index a094b05efe..c6d96560d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,6 +111,7 @@ exclude = ['generated'] [tool.ruff.lint] select = [ + "B", # flake8-bugbear "C4", # flake8-comprehensions "E", # pycodestyle "EXE", # flake8-executable diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 91aec91637..1cc8e1ec98 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -3551,7 +3551,7 @@ static const char *const TAG = "api.service"; if id_ is not None and not mt.options.deprecated: id_to_msg_name[id_] = mt.name - for id_, (_, _, case_label) in cases: + for id_, (_, _, _case_label) in cases: msg_name = id_to_msg_name.get(id_, "") if msg_name in message_auth_map: needs_auth = message_auth_map[msg_name] @@ -3614,7 +3614,7 @@ static const char *const TAG = "api.service"; # Dispatch switch out += " switch (msg_type) {\n" - for i, (case, ifdef, case_label) in cases: + for _i, (case, ifdef, case_label) in cases: if ifdef is not None: out += _make_ifdef_line(ifdef) + "\n" diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 921ee9d3d7..9dff70af3c 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -972,7 +972,7 @@ def convert(schema, config_var, path): } elif schema_type == "use_id": if inspect.ismodule(data): - m_attr_obj = getattr(data, "CONFIG_SCHEMA") + m_attr_obj = data.CONFIG_SCHEMA use_schema = known_schemas.get(repr(m_attr_obj)) if use_schema: [output_module, output_name] = use_schema[0][1].split(".") diff --git a/script/ci-custom.py b/script/ci-custom.py index c241343a1b..f2a9681be5 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -342,8 +342,8 @@ def lint_const_ordered(fname, content): (i + 1, line) for i, line in enumerate(lines) if line.startswith(start) ] ordered = sorted(matching, key=lambda x: x[1].replace("_", " ")) - ordered = [(mi, ol) for (mi, _), (_, ol) in zip(matching, ordered)] - for (mi, mline), (_, ol) in zip(matching, ordered): + ordered = [(mi, ol) for (mi, _), (_, ol) in zip(matching, ordered, strict=True)] + for (mi, mline), (_, ol) in zip(matching, ordered, strict=True): if mline == ol: continue target = next(i for i, line in ordered if line == mline) diff --git a/script/determine-jobs.py b/script/determine-jobs.py index ef2175eb79..01b8623813 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -1047,7 +1047,7 @@ def detect_memory_impact_config( # Find common platforms supported by ALL components # This ensures we can build all components together in a merged config common_platforms = set(MEMORY_IMPACT_PLATFORM_PREFERENCE) - for component, platforms in component_platforms_map.items(): + for platforms in component_platforms_map.values(): common_platforms &= platforms # Select the most preferred platform from the common set diff --git a/script/split_components_for_ci.py b/script/split_components_for_ci.py index 0d10246bb4..7f06f50f48 100755 --- a/script/split_components_for_ci.py +++ b/script/split_components_for_ci.py @@ -295,7 +295,7 @@ def main() -> int: # Sort groups by signature for readability groupable_groups = [] isolated_groups = [] - for (platform, signature), group_comps in sorted(signature_groups.items()): + for (_platform, signature), group_comps in sorted(signature_groups.items()): if signature.startswith(ISOLATED_SIGNATURE_PREFIX): isolated_groups.append((signature, group_comps)) else: diff --git a/script/test_build_components.py b/script/test_build_components.py index 43b71004eb..51f3758291 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -890,7 +890,7 @@ def run_grouped_component_tests( print("=" * 80 + "\n") # Execute grouped tests - for (platform, signature), components in grouped_components.items(): + for (platform, _signature), components in grouped_components.items(): # Only group if we have multiple components with same signature if len(components) <= 1: continue @@ -1055,7 +1055,7 @@ def test_components( # Create empty test files for each platform (or filtered platform) reference_tests: list[Path] = [] - for platform_name, base_file in platform_bases.items(): + for platform_name in platform_bases: if platform_filter and not platform_name.startswith(platform_filter): continue # Create an empty test file named to match the platform diff --git a/tests/component_tests/display/test_display_metadata.py b/tests/component_tests/display/test_display_metadata.py index e569754494..ef3f12cb73 100644 --- a/tests/component_tests/display/test_display_metadata.py +++ b/tests/component_tests/display/test_display_metadata.py @@ -2,6 +2,8 @@ from unittest.mock import patch +import pytest + from esphome.components.display import ( DisplayMetaData, add_metadata, @@ -74,8 +76,5 @@ def test_add_metadata_overwrites_existing(): def test_metadata_is_frozen(): """Test that DisplayMetaData instances are immutable (frozen dataclass).""" meta = DisplayMetaData(320, 240, True, False) - try: + with pytest.raises(AttributeError): meta.width = 640 - assert False, "Expected FrozenInstanceError" - except AttributeError: - pass diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 8c809c5e91..66f946a5bd 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -510,15 +510,9 @@ def test_package_merge_by_missing_id() -> None: ], } - error_raised = False - try: + with pytest.raises(cv.Invalid) as exc_info: packages_pass(config) - assert False, "Expected validation error for missing ID" - except cv.Invalid as err: - error_raised = True - assert err.path == [CONF_SENSOR, 2] - - assert error_raised + assert exc_info.value.path == [CONF_SENSOR, 2] def test_package_list_remove_by_id() -> None: diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index fb025ce427..e593929583 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -407,8 +407,10 @@ async def wait_and_connect_api_client( # Wait for connection with timeout try: await asyncio.wait_for(connected_future, timeout=timeout) - except TimeoutError: - raise TimeoutError(f"Failed to connect to API after {timeout} seconds") + except TimeoutError as err: + raise TimeoutError( + f"Failed to connect to API after {timeout} seconds" + ) from err if return_disconnect_event: yield client, disconnect_event diff --git a/tests/unit_tests/test_config_normalization.py b/tests/unit_tests/test_config_normalization.py index d70f3c24e0..4ec17b3c7c 100644 --- a/tests/unit_tests/test_config_normalization.py +++ b/tests/unit_tests/test_config_normalization.py @@ -67,7 +67,7 @@ def test_iter_component_configs_with_multi_conf(mock_get_component: Mock) -> Non configs = list(config.iter_component_configs(test_config)) assert len(configs) == 2 - for domain, component, conf in configs: + for domain, _component, conf in configs: assert domain == "switch" assert "name" in conf From ae74920b814ad555a15002d12675d057b040e0e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 00:14:42 -0500 Subject: [PATCH 0224/1815] [core] Enable ruff PTH (flake8-use-pathlib) lint family (#16661) --- esphome/__main__.py | 4 +- esphome/analyze_memory/cli.py | 6 +-- esphome/analyze_memory/toolchain.py | 3 +- esphome/build_gen/espidf.py | 2 +- esphome/bundle.py | 2 +- esphome/components/audio_file/__init__.py | 2 +- esphome/components/bme68x_bsec2/__init__.py | 2 +- .../esp32_hosted/update/__init__.py | 4 +- esphome/components/http_request/__init__.py | 4 +- esphome/components/image/__init__.py | 2 +- .../components/micro_wake_word/__init__.py | 4 +- esphome/components/nrf52/ota.py | 2 +- esphome/components/rp2040/generate_boards.py | 4 +- esphome/components/web_server/__init__.py | 4 +- esphome/components/zigbee/zigbee_esp32.py | 5 +-- esphome/dashboard/dashboard.py | 3 +- esphome/dashboard/web_server.py | 6 +-- esphome/espidf/component.py | 18 ++++---- esphome/espidf/extra_script.py | 2 +- esphome/espidf/framework.py | 44 +++++++++++-------- esphome/espidf/get_idf_tool_paths.py | 3 +- esphome/espidf/runner.py | 3 +- esphome/espidf/toolchain.py | 19 ++++---- esphome/espota2.py | 2 +- esphome/helpers.py | 4 +- esphome/mqtt.py | 6 +-- esphome/web_server_ota.py | 2 +- pyproject.toml | 1 + script/api_protobuf/api_protobuf.py | 12 ++--- script/build_helpers.py | 2 +- script/bump-version.py | 5 ++- script/ci-custom.py | 2 +- script/ci_add_metadata_to_json.py | 4 +- script/ci_helpers.py | 3 +- script/ci_memory_impact_comment.py | 2 +- script/ci_memory_impact_extract.py | 4 +- script/clang-format | 3 +- script/clang-tidy | 9 ++-- script/clang_tidy_hash.py | 6 +-- script/determine-jobs.py | 2 +- script/helpers.py | 19 ++++---- script/lint-python | 6 ++- script/sync-device_class.py | 5 ++- script/test_build_components.py | 2 +- tests/dashboard/test_web_server_paths.py | 10 ++--- tests/integration/conftest.py | 2 +- tests/script/test_check_import_time.py | 7 +-- tests/script/test_determine_jobs.py | 7 +-- tests/script/test_helpers.py | 4 +- tests/script/test_test_helpers.py | 5 +-- tests/unit_tests/core/test_config.py | 20 ++++----- tests/unit_tests/test_espidf_component.py | 2 +- tests/unit_tests/test_substitutions.py | 3 +- tests/unit_tests/test_writer.py | 8 ++-- 54 files changed, 162 insertions(+), 155 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 268164acf6..5f281ce832 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -794,7 +794,7 @@ def _check_and_emit_build_info() -> None: # Read build_info from JSON try: - with open(build_info_json_path, encoding="utf-8") as f: + with build_info_json_path.open(encoding="utf-8") as f: build_info = json.load(f) except (OSError, json.JSONDecodeError) as e: _LOGGER.debug("Failed to read build_info: %s", e) @@ -1056,7 +1056,7 @@ def _wait_for_serial_port( def _port_found() -> bool: if port is not None: if os.name == "posix": - return os.path.exists(port) + return Path(port).exists() return any(p.path == port for p in get_serial_ports()) ports = get_serial_ports() if known_ports is not None: diff --git a/esphome/analyze_memory/cli.py b/esphome/analyze_memory/cli.py index a856e2988d..4fbceb7e5e 100644 --- a/esphome/analyze_memory/cli.py +++ b/esphome/analyze_memory/cli.py @@ -6,6 +6,7 @@ from collections import defaultdict from collections.abc import Callable import heapq from operator import itemgetter +from pathlib import Path import sys from typing import TYPE_CHECKING @@ -699,7 +700,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): content = "\n".join(lines) if output_file: - with open(output_file, "w", encoding="utf-8") as f: + with Path(output_file).open("w", encoding="utf-8") as f: f.write(content) else: print(content) @@ -737,7 +738,6 @@ def main(): # Load build directory import json - from pathlib import Path from esphome.platformio.toolchain import IDEData @@ -785,7 +785,7 @@ def main(): if not idedata_path.exists(): continue try: - with open(idedata_path, encoding="utf-8") as f: + with idedata_path.open(encoding="utf-8") as f: raw_data = json.load(f) idedata = IDEData(raw_data) print(f"Loaded idedata from: {idedata_path}", file=sys.stderr) diff --git a/esphome/analyze_memory/toolchain.py b/esphome/analyze_memory/toolchain.py index 3a8a5f7be4..a724d52f25 100644 --- a/esphome/analyze_memory/toolchain.py +++ b/esphome/analyze_memory/toolchain.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -import os from pathlib import Path import subprocess from typing import TYPE_CHECKING @@ -37,7 +36,7 @@ def _find_in_platformio_packages(tool_name: str) -> str | None: Full path to the tool or None if not found """ # Get PlatformIO packages directory - platformio_home = Path(os.path.expanduser("~/.platformio/packages")) + platformio_home = Path("~/.platformio/packages").expanduser() if not platformio_home.exists(): return None diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 96f84ebbd1..0b50f72382 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -24,7 +24,7 @@ def get_available_components() -> list[str] | None: return None try: - with open(project_desc, encoding="utf-8") as f: + with project_desc.open(encoding="utf-8") as f: data = json.load(f) component_info = data.get("build_component_info", {}) diff --git a/esphome/bundle.py b/esphome/bundle.py index 4537cbce9d..d38f68ebfd 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -412,7 +412,7 @@ class ConfigBundleCreator: @staticmethod def _add_to_tar(tar: tarfile.TarFile, bf: BundleFile) -> None: """Add a BundleFile to the tar archive with deterministic metadata.""" - with open(bf.source, "rb") as f: + with bf.source.open("rb") as f: _add_bytes_to_tar(tar, bf.path, f.read()) diff --git a/esphome/components/audio_file/__init__.py b/esphome/components/audio_file/__init__.py index 23c90e9b76..8dc546cec1 100644 --- a/esphome/components/audio_file/__init__.py +++ b/esphome/components/audio_file/__init__.py @@ -98,7 +98,7 @@ def read_audio_file_and_type(file_config: ConfigType) -> tuple[bytes, MockObj]: else: raise cv.Invalid("Unsupported file source") - with open(path, "rb") as f: + with path.open("rb") as f: data = f.read() try: diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index 5083d283ef..62cd9e2e36 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -169,7 +169,7 @@ async def to_code_base(config): path = _compute_local_file_path(_compute_url(config)) try: - with open(path, encoding="utf-8") as f: + with path.open(encoding="utf-8") as f: bsec2_iaq_config = f.read() except Exception as e: raise core.EsphomeError( diff --git a/esphome/components/esp32_hosted/update/__init__.py b/esphome/components/esp32_hosted/update/__init__.py index b258a26b08..202df21ab5 100644 --- a/esphome/components/esp32_hosted/update/__init__.py +++ b/esphome/components/esp32_hosted/update/__init__.py @@ -75,7 +75,7 @@ def _validate_firmware(config: dict[str, Any]) -> None: return path = CORE.relative_config_path(config[CONF_PATH]) - with open(path, "rb") as f: + with path.open("rb") as f: firmware_data = f.read() calculated = hashlib.sha256(firmware_data).hexdigest() expected = config[CONF_SHA256].lower() @@ -93,7 +93,7 @@ async def to_code(config: dict[str, Any]) -> None: if config[CONF_TYPE] == TYPE_EMBEDDED: path = config[CONF_PATH] - with open(CORE.relative_config_path(path), "rb") as f: + with CORE.relative_config_path(path).open("rb") as f: firmware_data = f.read() rhs = [HexInt(x) for x in firmware_data] arr_id = ID(f"{config[CONF_ID]}_data", is_declaration=True, type=cg.uint8) diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index 90879c459e..2617951f0d 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -1,3 +1,5 @@ +from pathlib import Path + from esphome import automation import esphome.codegen as cg from esphome.components import esp32 @@ -174,7 +176,7 @@ async def to_code(config): if config.get(CONF_VERIFY_SSL): if ca_cert_path := config.get(CONF_CA_CERTIFICATE_PATH): - with open(ca_cert_path, encoding="utf-8") as f: + with Path(ca_cert_path).open(encoding="utf-8") as f: ca_cert_content = f.read() cg.add(var.set_ca_certificate(ca_cert_content)) else: diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 365554f7d2..2fefbdcd58 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -395,7 +395,7 @@ def download_image(value): def is_svg_file(file): if not file: return False - with open(file, "rb") as f: + with Path(file).open("rb") as f: return " tuple[dict, dict]: for json_file in sorted(json_dir.glob("*.json")): board_name = json_file.stem - with open(json_file, encoding="utf-8") as f: + with json_file.open(encoding="utf-8") as f: data = json.load(f) build = data.get("build", {}) @@ -136,7 +136,7 @@ def _get_variant(json_file: Path) -> str | None: """Get variant name from a board JSON file.""" if not json_file.exists(): return None - with open(json_file, encoding="utf-8") as f: + with json_file.open(encoding="utf-8") as f: data = json.load(f) return data.get("build", {}).get("variant") diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 84910b6f90..99a9b7518c 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -326,12 +326,12 @@ async def to_code(config): if CONF_CSS_INCLUDE in config: cg.add_define("USE_WEBSERVER_CSS_INCLUDE") path = CORE.relative_config_path(config[CONF_CSS_INCLUDE]) - with open(file=path, encoding="utf-8") as css_file: + with path.open(encoding="utf-8") as css_file: add_resource_as_progmem("CSS_INCLUDE", css_file.read()) if CONF_JS_INCLUDE in config: cg.add_define("USE_WEBSERVER_JS_INCLUDE") path = CORE.relative_config_path(config[CONF_JS_INCLUDE]) - with open(file=path, encoding="utf-8") as js_file: + with path.open(encoding="utf-8") as js_file: add_resource_as_progmem("JS_INCLUDE", js_file.read()) cg.add(var.set_include_internal(config[CONF_INCLUDE_INTERNAL])) if CONF_LOCAL in config and config[CONF_LOCAL]: diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 89efd583ab..a0fadbce8b 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -129,9 +129,8 @@ def final_validate_esp32(config: ConfigType) -> ConfigType: if CONF_PARTITIONS in fv.full_config.get() and not isinstance( fv.full_config.get()[CONF_PARTITIONS], list ): - with open( - CORE.relative_config_path(fv.full_config.get()[CONF_PARTITIONS]), - encoding="utf8", + with CORE.relative_config_path(fv.full_config.get()[CONF_PARTITIONS]).open( + encoding="utf8" ) as f: partitions_tab = f.read() for partition, types in [ diff --git a/esphome/dashboard/dashboard.py b/esphome/dashboard/dashboard.py index 81c10763e7..7fc21f8a44 100644 --- a/esphome/dashboard/dashboard.py +++ b/esphome/dashboard/dashboard.py @@ -6,6 +6,7 @@ from concurrent.futures import ThreadPoolExecutor import contextlib import logging import os +from pathlib import Path import socket import threading from time import monotonic @@ -149,4 +150,4 @@ async def async_start(args) -> None: await dashboard.async_run() finally: if sock: - os.remove(sock) + Path(sock).unlink() diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 88b454e5cf..97d6639c1f 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -1040,7 +1040,7 @@ class DownloadListRequestHandler(BaseHandler): class DownloadBinaryRequestHandler(BaseHandler): def _load_file(self, path: str, compressed: bool) -> bytes: """Load a file from disk and compress it if requested.""" - with open(path, "rb") as f: + with Path(path).open("rb") as f: data = f.read() if compressed: return gzip.compress(data, 9) @@ -1292,7 +1292,7 @@ class EditRequestHandler(BaseHandler): def _read_file(self, filename: str, configuration: str) -> bytes | None: """Read a file and return the content as bytes.""" try: - with open(file=filename, encoding="utf-8") as f: + with Path(filename).open(encoding="utf-8") as f: return f.read() except FileNotFoundError: if configuration in const.SECRETS_FILES: @@ -1493,7 +1493,7 @@ def get_base_frontend_path() -> Path: static_path += "/" # This path can be relative, so resolve against the root or else templates don't work - path = Path(os.getcwd()) / static_path / "esphome_dashboard" + path = Path.cwd() / static_path / "esphome_dashboard" return path.resolve() diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 81f2cd9632..050002d9e2 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -317,24 +317,26 @@ def _collect_filtered_files(src_dir: PathType, src_filters: list[str]) -> list[s if pattern.endswith("/"): pattern = pattern.rstrip("/") + "/**" - full_pattern = os.path.join(glob.escape(str(src_dir)), pattern) + # glob.escape has no pathlib equivalent and the matcher works on raw + # path strings, so PTH118/PTH207 don't apply here. + full_pattern = os.path.join(glob.escape(str(src_dir)), pattern) # noqa: PTH118 matched = [] - for item in glob.glob(full_pattern, recursive=True): - if not os.path.isdir(item): + for item in glob.glob(full_pattern, recursive=True): # noqa: PTH207 + if not Path(item).is_dir(): matched.append(item) else: # PlatformIO quirk: a directory matched with "*" should include all its # nested files and subdirectories, not just the directory itself. for root, _, files in os.walk(item): - matched.extend([os.path.join(root, f) for f in files]) + matched.extend([str(Path(root) / f) for f in files]) if sign == "+": selected.update(matched) elif sign == "-": selected.difference_update(matched) - return [r for r in selected if os.path.isfile(r)] + return [r for r in selected if Path(r).is_file()] def _convert_library_to_component(library: Library) -> IDFComponent: @@ -486,7 +488,7 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: # Only keep sources build_src_files = [os.path.relpath(p, component.path) for p in build_src_files] build_src_files = [ - f for f in build_src_files if os.path.splitext(f)[1] in SRC_FILE_EXTENSIONS + f for f in build_src_files if Path(f).suffix in SRC_FILE_EXTENSIONS ] # Handle build flags @@ -740,7 +742,7 @@ def _parse_library_json(library_json_path: PathType): Returns: dict: Parsed JSON content as a Python dictionary. """ - with open(library_json_path, encoding="utf8") as fp: + with Path(library_json_path).open(encoding="utf8") as fp: return json.load(fp) @@ -754,7 +756,7 @@ def _parse_library_properties(library_properties_path: PathType): Returns: dict[str, str]: Mapping of parsed property keys to values. """ - with open(library_properties_path, encoding="utf8") as fp: + with Path(library_properties_path).open(encoding="utf8") as fp: data = {} for line in fp.read().splitlines(): line = line.strip() diff --git a/esphome/espidf/extra_script.py b/esphome/espidf/extra_script.py index 2f22f23c10..bead63ca21 100644 --- a/esphome/espidf/extra_script.py +++ b/esphome/espidf/extra_script.py @@ -108,7 +108,7 @@ def run_extra_script( """ env = _FakeSConsEnv(board_mcu=idf_target, pio_env=f"esphome_{idf_target}") code = compile(script_path.read_text(), str(script_path), "exec") - old_cwd = os.getcwd() + old_cwd = Path.cwd() try: os.chdir(library_dir) exec( # noqa: S102 pylint: disable=exec-used diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index f393600732..331c2f84b0 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -138,7 +138,7 @@ def rmdir(directory: PathType, msg: str | None = None): Raises: RuntimeError: If directory removal fails """ - if os.path.isdir(directory): + if Path(directory).is_dir(): try: if msg: _LOGGER.debug(msg) @@ -192,7 +192,7 @@ def _check_stamp(file: PathType, data: dict[str, str]) -> bool: return False try: - with open(file, encoding="utf-8") as f: + with Path(file).open(encoding="utf-8") as f: return json.load(f) == data except (json.JSONDecodeError, OSError): return False @@ -206,7 +206,7 @@ def _write_stamp(file: PathType, data: dict[str, str]): file: Path to the stamp file to write data: Dictionary containing data to write """ - with open(file, "w", encoding="utf8") as fp: + with Path(file).open("w", encoding="utf8") as fp: json.dump(data, fp) @@ -471,8 +471,12 @@ def _tar_extract_all( import stat import tarfile + # Tar extraction safety: os.path.realpath / commonpath / normpath have no + # pathlib equivalents and Path.resolve() would follow symlinks unsafely. + # Use os.path for the security-sensitive parts; the simple checks move to + # Path. extract_dir = os.fspath(extract_dir) - abs_dest = os.path.abspath(extract_dir) + abs_dest = os.path.abspath(extract_dir) # noqa: PTH100 with tarfile.open(fileobj=data, mode="r") as tar_ref: all_members = tar_ref.getmembers() @@ -491,8 +495,8 @@ def _tar_extract_all( name = name.lstrip("/" + os.sep) # 2. Reject absolute paths (incl. Windows drive) - if os.path.isabs(name) or ( - os.name == "nt" and ":" in name.split(os.sep)[0] + if Path(name).is_absolute() or ( + os.name == "nt" and ":" in name.split(os.sep)[0] # noqa: PTH206 ): continue @@ -506,7 +510,7 @@ def _tar_extract_all( name = norm[len(strip_prefix) :] # 4. Compute final path - target_path = os.path.realpath(os.path.join(abs_dest, name)) + target_path = os.path.realpath(os.path.join(abs_dest, name)) # noqa: PTH118 if os.path.commonpath([abs_dest, target_path]) != abs_dest: continue @@ -515,18 +519,20 @@ def _tar_extract_all( linkname = member.linkname # Reject absolute link targets - if os.path.isabs(linkname): + if Path(linkname).is_absolute(): continue # Strip leading slashes linkname = os.path.normpath(linkname) if member.issym(): - link_target = os.path.join( - abs_dest, os.path.dirname(name), linkname + link_target = os.path.join( # noqa: PTH118 + abs_dest, + os.path.dirname(name), # noqa: PTH120 + linkname, ) else: - link_target = os.path.join(abs_dest, linkname) + link_target = os.path.join(abs_dest, linkname) # noqa: PTH118 link_target = os.path.realpath(link_target) if os.path.commonpath([abs_dest, link_target]) != abs_dest: @@ -598,7 +604,9 @@ def _zip_extract_all( """ import zipfile - extract_dir = os.path.abspath(extract_dir) + # See note in archive_extract_all_tar: os.path is used intentionally for + # the security-sensitive abspath/commonpath checks below. + extract_dir = os.path.abspath(extract_dir) # noqa: PTH100 with zipfile.ZipFile(data, "r") as zip_ref: all_members = zip_ref.infolist() @@ -618,8 +626,8 @@ def _zip_extract_all( name = member.filename.lstrip("/\\") # 2. Reject absolute paths / Windows drives - if os.path.isabs(name) or ( - os.name == "nt" and ":" in name.split(os.sep)[0] + if Path(name).is_absolute() or ( + os.name == "nt" and ":" in name.split(os.sep)[0] # noqa: PTH206 ): continue @@ -633,7 +641,7 @@ def _zip_extract_all( name = norm[len(strip_prefix) :] # 4. Compute safe target path - target_path = os.path.abspath(os.path.join(extract_dir, name)) + target_path = os.path.abspath(os.path.join(extract_dir, name)) # noqa: PTH100, PTH118 if os.path.commonpath([extract_dir, target_path]) != extract_dir: raise ValueError(f"Unsafe path detected: {member.filename}") @@ -680,7 +688,7 @@ def archive_extract_all( with ExitStack() as stack: archive_ref: io.BufferedIOBase if isinstance(archive, (str, os.PathLike)): - archive_ref = stack.enter_context(open(archive, "rb")) + archive_ref = stack.enter_context(Path(archive).open("rb")) elif isinstance(archive, (io.BufferedReader, io.BufferedRandom)): archive_ref = archive elif isinstance(archive, io.RawIOBase): @@ -727,7 +735,7 @@ def download_from_mirrors( # 1. Open target file for writing if path given with ExitStack() as stack: if isinstance(target, (str, os.PathLike)): - f = stack.enter_context(open(target, "wb")) + f = stack.enter_context(Path(target).open("wb")) elif isinstance(target, (io.RawIOBase, io.IOBase)): f = target else: @@ -917,7 +925,7 @@ def _patch_tools_json_for_linux_arm64(framework_path: Path) -> None: return try: - with open(tools_json, encoding="utf-8") as f: + with tools_json.open(encoding="utf-8") as f: data = json.load(f) except (json.JSONDecodeError, OSError) as e: _LOGGER.warning( diff --git a/esphome/espidf/get_idf_tool_paths.py b/esphome/espidf/get_idf_tool_paths.py index 2e8859631d..7d99e629b1 100644 --- a/esphome/espidf/get_idf_tool_paths.py +++ b/esphome/espidf/get_idf_tool_paths.py @@ -10,6 +10,7 @@ not installed. import json import os +from pathlib import Path import sys from types import SimpleNamespace @@ -25,7 +26,7 @@ from idf_tools import ( g.idf_path = sys.argv[1] g.idf_tools_path = os.environ.get("IDF_TOOLS_PATH") -g.tools_json = os.path.join(g.idf_path, TOOLS_FILE) +g.tools_json = str(Path(g.idf_path) / TOOLS_FILE) tools_info = filter_tools_info(IDFEnv.get_idf_env(), load_tools_info()) args = SimpleNamespace(prefer_system=False) diff --git a/esphome/espidf/runner.py b/esphome/espidf/runner.py index 857d16c674..7c568db7be 100644 --- a/esphome/espidf/runner.py +++ b/esphome/espidf/runner.py @@ -91,6 +91,7 @@ def main() -> int: # ---- end sys.path fix-up ----------------------------------------------- import os + from pathlib import Path import re import runpy @@ -229,7 +230,7 @@ def main() -> int: # runpy.run_path does not do this automatically, but idf.py relies # on it to import its sibling modules (python_version_checker, # idf_py_actions, ...). - script_dir = os.path.dirname(os.path.abspath(script_path)) + script_dir = str(Path(script_path).resolve().parent) if script_dir not in sys.path: sys.path.insert(0, script_dir) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index ef28575caa..752f582e74 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -241,20 +241,21 @@ def has_outdated_files(): dependency_lock_path = CORE.relative_build_path("dependencies.lock") build_ninja_path = CORE.relative_build_path("build/build.ninja") - if not os.path.isdir(build_config_path) or not os.listdir(build_config_path): + if not build_config_path.is_dir() or not any(build_config_path.iterdir()): return True - if not os.path.isfile(cmakecache_txt_path): + if not cmakecache_txt_path.is_file(): return True - if not os.path.isfile(build_ninja_path): + if not build_ninja_path.is_file(): return True - if os.path.isfile(dependency_lock_path) and os.path.getmtime( - dependency_lock_path - ) > os.path.getmtime(build_ninja_path): + if ( + dependency_lock_path.is_file() + and dependency_lock_path.stat().st_mtime > build_ninja_path.stat().st_mtime + ): return True - cmakecache_txt_mtime = os.path.getmtime(cmakecache_txt_path) + cmakecache_txt_mtime = cmakecache_txt_path.stat().st_mtime return any( - os.path.getmtime(f) > cmakecache_txt_mtime + f.stat().st_mtime > cmakecache_txt_mtime for f in [sdkconfig_internal_path, idf_component_yml_path] if f.exists() ) @@ -452,7 +453,7 @@ def create_factory_bin() -> bool: return False try: - with open(flasher_args_path, encoding="utf-8") as f: + with flasher_args_path.open(encoding="utf-8") as f: flash_data = json.load(f) except (json.JSONDecodeError, OSError) as e: _LOGGER.error("Failed to read flasher_args.json: %s", e) diff --git a/esphome/espota2.py b/esphome/espota2.py index 701a125bcd..266702c142 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -517,7 +517,7 @@ def run_ota_impl_( continue _LOGGER.info("Connected to %s", sa[0]) - with open(filename, "rb") as file_handle: + with Path(filename).open("rb") as file_handle: try: perform_ota(sock, password, file_handle, filename, ota_type) except OTAError as err: diff --git a/esphome/helpers.py b/esphome/helpers.py index d7ddb5c416..733474c9c9 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -385,7 +385,7 @@ def rmtree(path: Path | str) -> None: def _onerror(func, path, exc_info): if os.access(path, os.W_OK): raise exc_info[1].with_traceback(exc_info[2]) - os.chmod(path, stat.S_IWUSR | stat.S_IRUSR) + Path(path).chmod(stat.S_IWUSR | stat.S_IRUSR) func(path) # ``onerror`` is deprecated in 3.12 in favour of ``onexc`` (different @@ -512,7 +512,7 @@ def copy_file_if_changed(src: Path, dst: Path) -> bool: # -> delete file (it would be overwritten anyway), and try again # if that fails, use normal error handler with suppress(OSError): - os.unlink(dst) + Path(dst).unlink() shutil.copyfile(src, dst) return True diff --git a/esphome/mqtt.py b/esphome/mqtt.py index 098292f599..d6bde0cbfd 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -2,7 +2,7 @@ import contextlib from datetime import datetime import json import logging -import os +from pathlib import Path import ssl import tempfile import time @@ -120,8 +120,8 @@ def prepare( key_file.close() context.load_cert_chain(cert_file.name, key_file.name) finally: - os.unlink(cert_file.name) - os.unlink(key_file.name) + Path(cert_file.name).unlink() + Path(key_file.name).unlink() client.tls_set_context(context) try: diff --git a/esphome/web_server_ota.py b/esphome/web_server_ota.py index 7c31c1b123..8d0fdeecff 100644 --- a/esphome/web_server_ota.py +++ b/esphome/web_server_ota.py @@ -126,7 +126,7 @@ def _try_upload( _LOGGER.info("Connecting to %s port %s...", ip, port) try: - with open(filename, "rb") as fh: + with filename.open("rb") as fh: streamer = _MultipartStreamer(fh, file_size, filename.name) try: response = requests.post( diff --git a/pyproject.toml b/pyproject.toml index c6d96560d5..ae1bd34f60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -127,6 +127,7 @@ select = [ "NPY", # numpy-specific rules "PERF", # performance "PL", # pylint + "PTH", # flake8-use-pathlib "PYI", # flake8-pyi "Q", # flake8-quotes "RSE", # flake8-raise diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 1cc8e1ec98..240ee7890f 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -3163,7 +3163,7 @@ def main() -> None: defines_content += "\n" defines_content += "\nnamespace esphome::api {} // namespace esphome::api\n" - with open(root / "api_pb2_defines.h", "w", encoding="utf-8") as f: + with (root / "api_pb2_defines.h").open("w", encoding="utf-8") as f: f.write(defines_content) content = FILE_HEADER @@ -3448,13 +3448,13 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint #endif // HAS_PROTO_MESSAGE_DUMP """ - with open(root / "api_pb2.h", "w", encoding="utf-8") as f: + with (root / "api_pb2.h").open("w", encoding="utf-8") as f: f.write(content) - with open(root / "api_pb2.cpp", "w", encoding="utf-8") as f: + with (root / "api_pb2.cpp").open("w", encoding="utf-8") as f: f.write(cpp) - with open(root / "api_pb2_dump.cpp", "w", encoding="utf-8") as f: + with (root / "api_pb2_dump.cpp").open("w", encoding="utf-8") as f: f.write(dump_cpp) hpp = FILE_HEADER @@ -3641,10 +3641,10 @@ static const char *const TAG = "api.service"; } // namespace esphome::api """ - with open(root / "api_pb2_service.h", "w", encoding="utf-8") as f: + with (root / "api_pb2_service.h").open("w", encoding="utf-8") as f: f.write(hpp) - with open(root / "api_pb2_service.cpp", "w", encoding="utf-8") as f: + with (root / "api_pb2_service.cpp").open("w", encoding="utf-8") as f: f.write(cpp) prot_file.unlink() diff --git a/script/build_helpers.py b/script/build_helpers.py index fa722aa099..52f7ee317e 100644 --- a/script/build_helpers.py +++ b/script/build_helpers.py @@ -195,7 +195,7 @@ def load_component_yaml_configs(components: list[str], tests_dir: Path) -> dict: yaml_path = tests_dir / component / BENCHMARK_YAML_FILENAME if not yaml_path.is_file(): continue - with open(yaml_path) as f: + with yaml_path.open() as f: component_config = yaml.safe_load(f) if component_config and isinstance(component_config, dict): for key, value in component_config.items(): diff --git a/script/bump-version.py b/script/bump-version.py index ed927cb991..e09fc87c60 100755 --- a/script/bump-version.py +++ b/script/bump-version.py @@ -2,6 +2,7 @@ import argparse from dataclasses import dataclass +from pathlib import Path import re import sys @@ -39,12 +40,12 @@ class Version: def sub(path, pattern, repl, expected_count=1): - with open(path, encoding="utf-8") as fh: + with Path(path).open(encoding="utf-8") as fh: content = fh.read() content, count = re.subn(pattern, repl, content, flags=re.MULTILINE) if expected_count is not None: assert count == expected_count, f"Pattern {pattern} replacement failed!" - with open(path, "w", encoding="utf-8") as fh: + with Path(path).open("w", encoding="utf-8") as fh: fh.write(content) diff --git a/script/ci-custom.py b/script/ci-custom.py index f2a9681be5..1ac13e18f7 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -14,7 +14,7 @@ import time import colorama from helpers import filter_changed, git_ls_files, print_error_for_file, styled -sys.path.append(os.path.dirname(__file__)) +sys.path.append(str(Path(__file__).parent)) def find_all(a_str, sub): diff --git a/script/ci_add_metadata_to_json.py b/script/ci_add_metadata_to_json.py index 687b5131c0..e884e9a64c 100755 --- a/script/ci_add_metadata_to_json.py +++ b/script/ci_add_metadata_to_json.py @@ -44,7 +44,7 @@ def main() -> int: return 1 try: - with open(json_path, encoding="utf-8") as f: + with Path(json_path).open(encoding="utf-8") as f: data = json.load(f) except (json.JSONDecodeError, OSError) as e: print(f"Error loading JSON: {e}", file=sys.stderr) @@ -74,7 +74,7 @@ def main() -> int: # Write back try: - with open(json_path, "w", encoding="utf-8") as f: + with Path(json_path).open("w", encoding="utf-8") as f: json.dump(data, f, indent=2) print(f"Added metadata to {args.json_file}", file=sys.stderr) except OSError as e: diff --git a/script/ci_helpers.py b/script/ci_helpers.py index 48b0e4bbfe..a51a857ada 100644 --- a/script/ci_helpers.py +++ b/script/ci_helpers.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +from pathlib import Path def write_github_output(outputs: dict[str, str | int]) -> None: @@ -16,7 +17,7 @@ def write_github_output(outputs: dict[str, str | int]) -> None: """ github_output = os.environ.get("GITHUB_OUTPUT") if github_output: - with open(github_output, "a", encoding="utf-8") as f: + with Path(github_output).open("a", encoding="utf-8") as f: f.writelines(f"{key}={value}\n" for key, value in outputs.items()) else: for key, value in outputs.items(): diff --git a/script/ci_memory_impact_comment.py b/script/ci_memory_impact_comment.py index 01316da27f..0908b99595 100755 --- a/script/ci_memory_impact_comment.py +++ b/script/ci_memory_impact_comment.py @@ -91,7 +91,7 @@ def load_analysis_json(json_path: str) -> dict | None: return None try: - with open(json_file, encoding="utf-8") as f: + with Path(json_file).open(encoding="utf-8") as f: return json.load(f) except (json.JSONDecodeError, OSError) as e: print(f"Failed to load analysis JSON: {e}", file=sys.stderr) diff --git a/script/ci_memory_impact_extract.py b/script/ci_memory_impact_extract.py index 2aa7394b11..feacc2b1af 100755 --- a/script/ci_memory_impact_extract.py +++ b/script/ci_memory_impact_extract.py @@ -127,7 +127,7 @@ def run_detailed_analysis(build_dir: str) -> dict | None: if not idedata_path.exists(): continue try: - with open(idedata_path, encoding="utf-8") as f: + with idedata_path.open(encoding="utf-8") as f: raw_data = json.load(f) idedata = IDEData(raw_data) print(f"Loaded idedata from: {idedata_path}", file=sys.stderr) @@ -264,7 +264,7 @@ def main() -> int: output_path = Path(args.output_json) output_path.parent.mkdir(parents=True, exist_ok=True) - with open(output_path, "w", encoding="utf-8") as f: + with output_path.open("w", encoding="utf-8") as f: json.dump(output_data, f, indent=2) print(f"Saved analysis to {args.output_json}", file=sys.stderr) diff --git a/script/clang-format b/script/clang-format index 028d752c55..df45798a30 100755 --- a/script/clang-format +++ b/script/clang-format @@ -2,6 +2,7 @@ import argparse import os +from pathlib import Path import queue import re import subprocess @@ -70,7 +71,7 @@ def main(): ) args = parser.parse_args() - cwd = os.getcwd() + cwd = Path.cwd() files = [ os.path.relpath(path, cwd) for path in git_ls_files(["*.cpp", "*.h", "*.tcc"]) ] diff --git a/script/clang-tidy b/script/clang-tidy index 1c413ffa23..56c0a9db71 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -2,6 +2,7 @@ import argparse import os +from pathlib import Path import queue import re import shutil @@ -32,7 +33,7 @@ def clang_options(idedata): cmd = [] # extract target architecture from triplet in g++ filename - triplet = os.path.basename(idedata["cxx_path"])[:-4] + triplet = Path(idedata["cxx_path"]).name[:-4] if triplet.startswith("xtensa-"): # clang doesn't support Xtensa (yet?), so compile in 32-bit mode and pretend we're the Xtensa compiler cmd.append("-m32") @@ -153,8 +154,8 @@ def run_tidy(executable, args, options, tmpdir, path_queue, lock, failed_files): if sys.stdout.isatty(): invocation.append("--use-color") - invocation.append(f"--header-filter={os.path.abspath(basepath)}/.*") - invocation.append(os.path.abspath(path)) + invocation.append(f"--header-filter={Path(basepath).resolve()}/.*") + invocation.append(str(Path(path).resolve())) invocation.append("--") invocation.extend(options) @@ -229,7 +230,7 @@ def main(): ) args = parser.parse_args() - cwd = os.getcwd() + cwd = Path.cwd() files = [os.path.relpath(path, cwd) for path in git_ls_files(["*.cpp"])] # Exclude benchmark files — they require google benchmark headers not # available in the ESP32 toolchain and use different naming conventions. diff --git a/script/clang_tidy_hash.py b/script/clang_tidy_hash.py index d0d8438437..f478535567 100755 --- a/script/clang_tidy_hash.py +++ b/script/clang_tidy_hash.py @@ -16,7 +16,7 @@ sys.path.insert(0, str(script_dir)) def read_file_lines(path: Path) -> list[str]: """Read lines from a file.""" - with open(path) as f: + with path.open() as f: return f.readlines() @@ -65,7 +65,7 @@ def get_clang_tidy_version_from_requirements(repo_root: Path | None = None) -> s def read_file_bytes(path: Path) -> bytes: """Read bytes from a file.""" - with open(path, "rb") as f: + with path.open("rb") as f: return f.read() @@ -120,7 +120,7 @@ def read_stored_hash(repo_root: Path | None = None) -> str | None: def write_file_content(path: Path, content: str) -> None: """Write content to a file.""" - with open(path, "w") as f: + with path.open("w") as f: f.write(content) diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 01b8623813..417716cd77 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -306,7 +306,7 @@ def _is_clang_tidy_full_scan() -> bool: """ try: result = subprocess.run( - [os.path.join(root_path, "script", "clang_tidy_hash.py"), "--check"], + [str(Path(root_path) / "script" / "clang_tidy_hash.py"), "--check"], capture_output=True, check=False, ) diff --git a/script/helpers.py b/script/helpers.py index cf82a89f93..c56a434edf 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -17,10 +17,10 @@ from typing import Any import colorama -root_path = os.path.abspath(os.path.normpath(os.path.join(__file__, "..", ".."))) -basepath = os.path.join(root_path, "esphome") -temp_folder = os.path.join(root_path, ".temp") -temp_header_file = os.path.join(temp_folder, "all-include.cpp") +root_path = str(Path(__file__).resolve().parent.parent) +basepath = str(Path(root_path) / "esphome") +temp_folder = str(Path(root_path) / ".temp") +temp_header_file = str(Path(temp_folder) / "all-include.cpp") # C++ file extensions used for clang-tidy and clang-format checks CPP_FILE_EXTENSIONS = (".cpp", ".h", ".hpp", ".cc", ".cxx", ".c", ".tcc") @@ -339,8 +339,8 @@ def _get_github_event_data() -> dict | None: Parsed event data dictionary, or None if not available """ github_event_path = os.environ.get("GITHUB_EVENT_PATH") - if github_event_path and os.path.exists(github_event_path): - with open(github_event_path) as f: + if github_event_path and Path(github_event_path).exists(): + with Path(github_event_path).open() as f: return json.load(f) return None @@ -464,7 +464,8 @@ def _get_changed_files_from_command(command: list[str]) -> list[str]: raise Exception(f"Command failed: {' '.join(command)}\nstderr: {proc.stderr}") changed_files = splitlines_no_ends(proc.stdout) - changed_files = [os.path.relpath(f, os.getcwd()) for f in changed_files if f] + cwd = Path.cwd() + changed_files = [os.path.relpath(f, cwd) for f in changed_files if f] # noqa: PTH109 changed_files.sort() return changed_files @@ -499,7 +500,7 @@ def get_changed_components() -> list[str] | None: return None # Use list-components.py to get changed components - script_path = os.path.join(root_path, "script", "list-components.py") + script_path = str(Path(root_path) / "script" / "list-components.py") cmd = [script_path, "--changed"] try: @@ -619,7 +620,7 @@ def filter_changed(files: list[str]) -> list[str]: def filter_grep(files: list[str], value: list[str]) -> list[str]: matched = [] for file in files: - with open(file, encoding="utf-8") as handle: + with Path(file).open(encoding="utf-8") as handle: contents = handle.read() if any(v in contents for v in value): matched.append(file) diff --git a/script/lint-python b/script/lint-python index 18281c711e..e4b3314d2a 100755 --- a/script/lint-python +++ b/script/lint-python @@ -2,6 +2,7 @@ import argparse import os +from pathlib import Path import re import sys @@ -66,11 +67,12 @@ def main(): args = parser.parse_args() files = [] + cwd = Path.cwd() for path in git_ls_files(): filetypes = (".py",) - ext = os.path.splitext(path)[1] + ext = Path(path).suffix if ext in filetypes and path.startswith("esphome"): - path = os.path.relpath(path, os.getcwd()) + path = os.path.relpath(path, cwd) files.append(path) # Match against re file_name_re = re.compile("|".join(args.files)) diff --git a/script/sync-device_class.py b/script/sync-device_class.py index 121c89b8f9..660142195a 100755 --- a/script/sync-device_class.py +++ b/script/sync-device_class.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 +from pathlib import Path import re # pylint: disable=import-error @@ -34,10 +35,10 @@ DOMAINS = { def sub(path, pattern, repl): - with open(path, encoding="utf-8") as handle: + with Path(path).open(encoding="utf-8") as handle: content = handle.read() content = re.sub(pattern, repl, content, flags=re.MULTILINE) - with open(path, "w", encoding="utf-8") as handle: + with Path(path).open("w", encoding="utf-8") as handle: handle.write(content) diff --git a/script/test_build_components.py b/script/test_build_components.py index 51f3758291..767b55c94b 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -297,7 +297,7 @@ def write_github_summary( test_results: List of all test results """ summary_content = format_github_summary(test_results, toolchain) - with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as f: + with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a", encoding="utf-8") as f: f.write(summary_content) diff --git a/tests/dashboard/test_web_server_paths.py b/tests/dashboard/test_web_server_paths.py index b596ebb581..efeafbf3b5 100644 --- a/tests/dashboard/test_web_server_paths.py +++ b/tests/dashboard/test_web_server_paths.py @@ -34,9 +34,7 @@ def test_get_base_frontend_path_dev_mode() -> None: # The function uses Path.resolve() which resolves symlinks # The actual function adds "/" to the path, so we simulate that test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/" - expected = ( - Path(os.getcwd()) / test_path_with_slash / "esphome_dashboard" - ).resolve() + expected = (Path.cwd() / test_path_with_slash / "esphome_dashboard").resolve() assert result == expected @@ -62,9 +60,7 @@ def test_get_base_frontend_path_dev_mode_relative_path() -> None: # The function uses Path.resolve() which resolves symlinks # The actual function adds "/" to the path, so we simulate that test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/" - expected = ( - Path(os.getcwd()) / test_path_with_slash / "esphome_dashboard" - ).resolve() + expected = (Path.cwd() / test_path_with_slash / "esphome_dashboard").resolve() assert result == expected assert result.is_absolute() @@ -157,7 +153,7 @@ def test_load_file_path(tmp_path: Path) -> None: test_file = tmp_path / "test.txt" test_file.write_bytes(b"test content") - with open(test_file, "rb") as f: + with test_file.open("rb") as f: content = f.read() assert content == b"test content" diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index e593929583..a9c9e0686f 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -79,7 +79,7 @@ def shared_platformio_cache() -> Generator[Path]: lock_file = Path.home() / ".esphome-integration-tests-init.lock" # Always acquire the lock to ensure cache is ready before proceeding - with open(lock_file, "w") as lock_fd: + with lock_file.open("w") as lock_fd: fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX) # Check if the native platform is installed (the actual indicator of a populated cache) diff --git a/tests/script/test_check_import_time.py b/tests/script/test_check_import_time.py index 223c58002c..528ca0701c 100644 --- a/tests/script/test_check_import_time.py +++ b/tests/script/test_check_import_time.py @@ -4,7 +4,6 @@ from __future__ import annotations import importlib.util import json -import os from pathlib import Path import sys from unittest.mock import patch @@ -13,12 +12,10 @@ 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") -) +script_dir = str((Path(__file__).parent / ".." / ".." / "script").resolve()) 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", str(Path(script_dir) / "check_import_time.py") ) check_import_time = importlib.util.module_from_spec(spec) spec.loader.exec_module(check_import_time) diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 7bb9fe2543..ac3c6424bf 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -3,7 +3,6 @@ from collections.abc import Generator import importlib.util import json -import os from pathlib import Path import sys from unittest.mock import Mock, call, patch @@ -11,9 +10,7 @@ from unittest.mock import Mock, call, patch import pytest # Add the script directory to Python path so we can import the module -script_dir = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "..", "script") -) +script_dir = str((Path(__file__).parent / ".." / ".." / "script").resolve()) sys.path.insert(0, script_dir) # Import helpers module for patching @@ -22,7 +19,7 @@ import helpers # noqa: E402 import script.helpers # noqa: E402 spec = importlib.util.spec_from_file_location( - "determine_jobs", os.path.join(script_dir, "determine-jobs.py") + "determine_jobs", str(Path(script_dir) / "determine-jobs.py") ) determine_jobs = importlib.util.module_from_spec(spec) spec.loader.exec_module(determine_jobs) diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 10f258aa83..82ff5e1411 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -12,9 +12,7 @@ import pytest from pytest import MonkeyPatch # Add the script directory to Python path so we can import helpers -sys.path.insert( - 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "script")) -) +sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve())) import helpers # noqa: E402 diff --git a/tests/script/test_test_helpers.py b/tests/script/test_test_helpers.py index 3149712563..a8100252da 100644 --- a/tests/script/test_test_helpers.py +++ b/tests/script/test_test_helpers.py @@ -1,6 +1,5 @@ """Unit tests for script/build_helpers.py manifest override and build helpers.""" -import os from pathlib import Path import sys import textwrap @@ -9,9 +8,7 @@ from unittest.mock import MagicMock, patch import pytest # Add the script directory to Python path so we can import build_helpers -sys.path.insert( - 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "script")) -) +sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve())) import build_helpers # noqa: E402 diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 4ce862315d..b5b35b5172 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -486,7 +486,7 @@ def test_preload_core_config_basic(setup_core: Path) -> None: assert CONF_BUILD_PATH in config[CONF_ESPHOME] # Verify default build path is "build/" build_path = config[CONF_ESPHOME][CONF_BUILD_PATH] - assert build_path.endswith(os.path.join("build", "test_device")) + assert build_path.endswith(str(Path("build") / "test_device")) def test_preload_core_config_with_build_path(setup_core: Path) -> None: @@ -523,7 +523,7 @@ def test_preload_core_config_env_build_path(setup_core: Path) -> None: assert "test_device" in config[CONF_ESPHOME][CONF_BUILD_PATH] # Verify it uses the env var path with device name appended build_path = config[CONF_ESPHOME][CONF_BUILD_PATH] - expected_path = os.path.join("/env/build", "test_device") + expected_path = str(Path("/env/build") / "test_device") assert build_path == expected_path or build_path == expected_path.replace( "/", os.sep ) @@ -739,7 +739,7 @@ async def test_add_includes_with_single_file( """Test add_includes copies a single header file to build directory.""" CORE.config_path = tmp_path / "config.yaml" CORE.build_path = tmp_path / "build" - os.makedirs(CORE.build_path, exist_ok=True) + CORE.build_path.mkdir(parents=True, exist_ok=True) # Create include file include_file = tmp_path / "my_header.h" @@ -769,7 +769,7 @@ async def test_add_includes_with_directory_unix( """Test add_includes copies all files from a directory on Unix.""" CORE.config_path = tmp_path / "config.yaml" CORE.build_path = tmp_path / "build" - os.makedirs(CORE.build_path, exist_ok=True) + CORE.build_path.mkdir(parents=True, exist_ok=True) # Create include directory with files include_dir = tmp_path / "includes" @@ -814,7 +814,7 @@ async def test_add_includes_with_directory_windows( """Test add_includes copies all files from a directory on Windows.""" CORE.config_path = tmp_path / "config.yaml" CORE.build_path = tmp_path / "build" - os.makedirs(CORE.build_path, exist_ok=True) + CORE.build_path.mkdir(parents=True, exist_ok=True) # Create include directory with files include_dir = tmp_path / "includes" @@ -856,7 +856,7 @@ async def test_add_includes_with_multiple_sources( """Test add_includes with multiple files and directories.""" CORE.config_path = tmp_path / "config.yaml" CORE.build_path = tmp_path / "build" - os.makedirs(CORE.build_path, exist_ok=True) + CORE.build_path.mkdir(parents=True, exist_ok=True) # Create various include sources single_file = tmp_path / "single.h" @@ -884,7 +884,7 @@ async def test_add_includes_empty_directory( """Test add_includes with an empty directory doesn't fail.""" CORE.config_path = tmp_path / "config.yaml" CORE.build_path = tmp_path / "build" - os.makedirs(CORE.build_path, exist_ok=True) + CORE.build_path.mkdir(parents=True, exist_ok=True) # Create empty directory empty_dir = tmp_path / "empty" @@ -906,7 +906,7 @@ async def test_add_includes_preserves_directory_structure_unix( """Test that add_includes preserves relative directory structure on Unix.""" CORE.config_path = tmp_path / "config.yaml" CORE.build_path = tmp_path / "build" - os.makedirs(CORE.build_path, exist_ok=True) + CORE.build_path.mkdir(parents=True, exist_ok=True) # Create nested directory structure lib_dir = tmp_path / "lib" @@ -940,7 +940,7 @@ async def test_add_includes_preserves_directory_structure_windows( """Test that add_includes preserves relative directory structure on Windows.""" CORE.config_path = tmp_path / "config.yaml" CORE.build_path = tmp_path / "build" - os.makedirs(CORE.build_path, exist_ok=True) + CORE.build_path.mkdir(parents=True, exist_ok=True) # Create nested directory structure lib_dir = tmp_path / "lib" @@ -973,7 +973,7 @@ async def test_add_includes_overwrites_existing_files( """Test that add_includes overwrites existing files in build directory.""" CORE.config_path = tmp_path / "config.yaml" CORE.build_path = tmp_path / "build" - os.makedirs(CORE.build_path, exist_ok=True) + CORE.build_path.mkdir(parents=True, exist_ok=True) # Create include file include_file = tmp_path / "header.h" diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index f50f5317de..4f0a71053d 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -293,7 +293,7 @@ def test_extra_script_captures_libpath_libs_and_defines(tmp_path): result = run_extra_script(script, library_dir=tmp_path, idf_target="esp32") - assert result.libpath == [os.path.join("src", "esp32")] + assert result.libpath == [str(Path("src") / "esp32")] assert result.libs == ["algobsec"] assert ("BAR", "1") in result.cppdefines assert "FOO" in result.cppdefines diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 4783112578..c71be2fbab 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -1,4 +1,3 @@ -import glob import logging from pathlib import Path from typing import Any @@ -106,7 +105,7 @@ REMOTES = { # Collect all input YAML files for test_substitutions_fixtures parametrized tests: HERE = Path(__file__).parent BASE_DIR = HERE / "fixtures" / "substitutions" -SOURCES = sorted(glob.glob(str(BASE_DIR / "*.input.yaml"))) +SOURCES = sorted(str(p) for p in BASE_DIR.glob("*.input.yaml")) assert SOURCES, f"test_substitutions_fixtures: No input YAML files found in {BASE_DIR}" diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 91b4bd8e87..fc49f03067 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -1358,7 +1358,7 @@ def test_clean_build_handles_readonly_files( # Create a read-only file (simulating git pack files on Windows) readonly_file = git_dir / "pack-abc123.pack" readonly_file.write_text("pack data") - os.chmod(readonly_file, stat.S_IRUSR) # Read-only + readonly_file.chmod(stat.S_IRUSR) # Read-only # Setup mocks mock_core.relative_pioenvs_path.return_value = pioenvs_dir @@ -1393,7 +1393,7 @@ def test_clean_all_handles_readonly_files( subdir.mkdir() readonly_file = subdir / "readonly.txt" readonly_file.write_text("content") - os.chmod(readonly_file, stat.S_IRUSR) # Read-only + readonly_file.chmod(stat.S_IRUSR) # Read-only # Verify file is read-only assert not os.access(readonly_file, os.W_OK) @@ -1422,7 +1422,7 @@ def test_clean_build_reraises_for_other_errors( test_file.write_text("content") # Make subdir read-only so files inside can't be deleted - os.chmod(subdir, stat.S_IRUSR | stat.S_IXUSR) + subdir.chmod(stat.S_IRUSR | stat.S_IXUSR) # Setup mocks mock_core.relative_pioenvs_path.return_value = pioenvs_dir @@ -1440,7 +1440,7 @@ def test_clean_build_reraises_for_other_errors( clean_build() finally: # Cleanup - restore write permission so tmp_path cleanup works - os.chmod(subdir, stat.S_IRWXU) + subdir.chmod(stat.S_IRWXU) # Tests for get_build_info() From 423b60c90ce030f02f0bfa560464d4016301da59 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 26 May 2026 19:56:44 +1200 Subject: [PATCH 0225/1815] [packages] Resolve git symlinks on Windows when materialized as text (#16657) --- esphome/components/packages/__init__.py | 42 +++- esphome/git.py | 87 +++++++ tests/unit_tests/test_git.py | 303 +++++++++++++++++++++++- tests/unit_tests/test_substitutions.py | 83 +++++++ 4 files changed, 507 insertions(+), 8 deletions(-) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 06a64208b6..f3e0e0db8f 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -215,7 +215,7 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]: If loading fails after cloning, attempts a revert and retry in case a prior cached checkout is stale. """ - repo_dir, revert = git.clone_or_update( + repo_root, revert = git.clone_or_update( url=config[CONF_URL], ref=config.get(CONF_REF), refresh=config[CONF_REFRESH], @@ -225,6 +225,10 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]: ) files: list[dict[str, Any]] = [] + # ``repo_root`` is the directory containing ``.git`` and must be passed + # to git for symlink-stub resolution. ``repo_dir`` may be narrowed to a + # subdirectory via the user's CONF_PATH and is used for file lookups. + repo_dir = repo_root if base_path := config.get(CONF_PATH): repo_dir = repo_dir / base_path @@ -236,13 +240,37 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]: def _load_package_yaml(yaml_file: Path, filename: str) -> dict: """Load a YAML file from a remote package, validating min_version.""" - try: - new_yaml = yaml_util.load_yaml(yaml_file) - except EsphomeError as e: + + def _load(path: Path) -> dict | str | None: + try: + return yaml_util.load_yaml(path) + except EsphomeError as e: + raise cv.Invalid( + f"{filename} is not a valid YAML file." + f" Please check the file contents.\n{e}" + ) from e + + new_yaml = _load(yaml_file) + if not isinstance(new_yaml, dict): + # On Windows, git defaults to core.symlinks=false unless the user + # has Developer Mode enabled or is running elevated. Files stored + # in the repo as symlinks (tree mode 120000) are then checked out + # as plain text files containing the symlink target path, so + # parsing them as YAML yields a bare scalar instead of a mapping. + # Best-effort: follow the symlink target ourselves and re-load. + target = git.resolve_symlink_stub(repo_root, yaml_file) + if target is not None: + new_yaml = _load(target) + if not isinstance(new_yaml, dict): raise cv.Invalid( - f"{filename} is not a valid YAML file." - f" Please check the file contents.\n{e}" - ) from e + f"{filename} does not contain a YAML mapping at the top level " + f"(got {type(new_yaml).__name__}). " + f"If this file is a git symlink in the source repository, it " + f"may not have been materialized correctly on your platform " + f"(this is a known issue with git on Windows without Developer " + f"Mode enabled). Try pointing your package at the real file " + f"path instead." + ) esphome_config = new_yaml.get(CONF_ESPHOME) or {} min_version = esphome_config.get(CONF_MIN_VERSION) if min_version is not None and cv.Version.parse(min_version) > cv.Version.parse( diff --git a/esphome/git.py b/esphome/git.py index f36bd559ef..094a6dae19 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -6,6 +6,7 @@ import logging from pathlib import Path import re import subprocess +import sys import urllib.parse import esphome.config_validation as cv @@ -94,6 +95,92 @@ def _compute_destination_path(key: str, domain: str) -> Path: return base_dir / h.hexdigest()[:8] +def resolve_symlink_stub(repo_dir: Path, file_path: Path) -> Path | None: + """Return the symlink target if ``file_path`` is a Windows-checked-out symlink stub. + + On Windows, when ``core.symlinks=false`` (the default unless the user has + SeCreateSymbolicLinkPrivilege — i.e. Developer Mode or running elevated), + git materializes files with tree mode ``120000`` as plain text files + whose content is the literal symlink target path. Opening such a file + yields the target path string instead of the target's content. + + If ``file_path`` is one of those stubs, return the resolved target Path + inside ``repo_dir``. Otherwise return ``None`` and the caller should use + ``file_path`` as-is. + + Designed to be called *only* when normal access has already produced an + unexpected result (e.g. YAML parsed as a top-level scalar), so the + per-file ``git ls-files`` subprocess cost is paid only on the failure + path. Returns ``None`` on any error or check failure — it's purely a + best-effort recovery, never raises. + """ + # On non-Windows, git creates real symlinks; ordinary file access already + # transparently follows them. + if sys.platform != "win32": + return None + if file_path.is_symlink(): + return None + if not file_path.is_file(): + return None + + try: + rel = file_path.relative_to(repo_dir) + except ValueError: + return None + + try: + # ``git ls-files -s `` prints " \t" + # for that single entry, or empty if untracked. + out = run_git_command( + ["git", "ls-files", "-s", "--", rel.as_posix()], + git_dir=repo_dir, + ) + except GitException: + return None + + parts = out.split() + if not parts or parts[0] != "120000": + return None + + # Stubs are short ASCII relative paths. Decode defensively, and only + # strip the trailing newline git's checkout may append — preserving any + # whitespace that could be part of a valid target name. + try: + raw = file_path.read_bytes() + except OSError: + return None + try: + target_str = raw.decode("utf-8").rstrip("\r\n") + except UnicodeDecodeError: + return None + + # ``Path()`` and ``Path.resolve()`` can raise on malformed inputs (e.g. + # embedded NUL bytes from a hostile symlink blob, paths too long for the + # OS, or temporary I/O errors). Catch broadly — this helper is purely a + # best-effort recovery and must never raise. + try: + target_path = (file_path.parent / target_str).resolve() + repo_root_resolved = repo_dir.resolve() + except (OSError, ValueError, RuntimeError): + return None + + # ``Path.resolve()`` follows ``..``; re-verify containment afterwards. + try: + target_path.relative_to(repo_root_resolved) + except ValueError: + _LOGGER.warning( + "Refusing to follow symlink %s -> %s (escapes repository)", + file_path, + target_str, + ) + return None + + if not target_path.is_file(): + return None + + return target_path + + def clone_or_update( *, url: str, diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index eab6bfc2cb..690c47c183 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -4,7 +4,7 @@ from datetime import datetime, timedelta import os from pathlib import Path from typing import Any -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest @@ -1001,3 +1001,304 @@ def test_refresh_picks_up_new_remote_commits( "--hard", "old_sha", ] + + +def test_resolve_symlink_stub_returns_none_on_non_windows( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """On non-Windows, resolve_symlink_stub returns None without calling git.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + stub = repo_dir / "file.yaml" + stub.write_text("static/file.yaml") + + with patch("esphome.git.sys.platform", "linux"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + mock_run_git_command.assert_not_called() + + +def test_resolve_symlink_stub_returns_target_for_mode_120000( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A mode-120000 file is recognised as a stub; its target Path is returned.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + (repo_dir / "static").mkdir() + + target = repo_dir / "static" / "real.yaml" + target.write_text("esphome:\n name: real\n") + + stub = repo_dir / "real.yaml" + stub.write_text("static/real.yaml") + + mock_run_git_command.return_value = "120000 abc123 0\treal.yaml" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result == target.resolve() + # Stub file itself was not modified — only inspected. + assert stub.read_text() == "static/real.yaml" + + +def test_resolve_symlink_stub_resolves_relative_parent_paths( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Symlink targets with ``..`` segments resolve correctly within the repo.""" + repo_dir = tmp_path / "repo" + (repo_dir / "subdir").mkdir(parents=True) + (repo_dir / "static").mkdir() + + target = repo_dir / "static" / "shared.yaml" + target.write_text("shared content") + + stub = repo_dir / "subdir" / "shared.yaml" + stub.write_text("../static/shared.yaml") + + mock_run_git_command.return_value = "120000 abc123 0\tsubdir/shared.yaml" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result == target.resolve() + + +def test_resolve_symlink_stub_refuses_escape_outside_repo( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A symlink pointing outside the repository is not followed.""" + outside = tmp_path / "outside.yaml" + outside.write_text("sensitive") + + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + stub = repo_dir / "escape.yaml" + stub.write_text("../outside.yaml") + + mock_run_git_command.return_value = "120000 abc123 0\tescape.yaml" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + + +def test_resolve_symlink_stub_returns_none_for_real_symlink( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A real symlink already opens transparently, so the helper short-circuits. + + Skipped on Windows where symlink creation requires + SeCreateSymbolicLinkPrivilege. + """ + if os.name == "nt": + pytest.skip("Requires symlink-creation privilege on Windows") + + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + target = repo_dir / "real.yaml" + target.write_text("real content") + + real_link = repo_dir / "link.yaml" + real_link.symlink_to("real.yaml") + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, real_link) + + assert result is None + # No git call needed for real symlinks. + mock_run_git_command.assert_not_called() + + +def test_resolve_symlink_stub_returns_none_for_regular_file( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A regular file (mode 100644) whose content looks path-shaped is not + followed.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + regular = repo_dir / "looks_like_path.txt" + regular.write_text("static/something.yaml") + + mock_run_git_command.return_value = "100644 abc123 0\tlooks_like_path.txt" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, regular) + + assert result is None + + +def test_resolve_symlink_stub_returns_none_when_git_fails( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """If ``git ls-files`` fails (e.g. not a repo), the helper returns None.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + stub = repo_dir / "real.yaml" + stub.write_text("static/real.yaml") + + mock_run_git_command.side_effect = GitCommandError("ls-files exploded") + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + + +def test_resolve_symlink_stub_returns_none_for_non_utf8_content( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A file whose bytes are not valid UTF-8 must not raise — return None.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + stub = repo_dir / "binary.bin" + stub.write_bytes(b"\xff\xfe\x00\xff") + + mock_run_git_command.return_value = "120000 abc123 0\tbinary.bin" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + + +def test_resolve_symlink_stub_preserves_whitespace_in_target( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Only trailing CR/LF is stripped — internal whitespace is preserved.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + target_dir = repo_dir / "dir with spaces" + target_dir.mkdir() + target = target_dir / "real.yaml" + target.write_text("hello") + + stub = repo_dir / "link.yaml" + # Trailing newline (as git's checkout may append) is stripped, but + # whitespace inside the target path itself must survive. + stub.write_bytes(b"dir with spaces/real.yaml\n") + + mock_run_git_command.return_value = "120000 abc123 0\tlink.yaml" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result == target.resolve() + + +def test_resolve_symlink_stub_returns_none_for_directory_target( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A symlink pointing at a directory has no file content to load.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + (repo_dir / "dir_target").mkdir() + + stub = repo_dir / "link_to_dir" + stub.write_text("dir_target") + + mock_run_git_command.return_value = "120000 abc123 0\tlink_to_dir" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + + +def test_resolve_symlink_stub_returns_none_when_resolve_raises( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Path.resolve() raising (e.g. on a malformed target) must not propagate.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + stub = repo_dir / "broken.yaml" + stub.write_text("ignored") + + mock_run_git_command.return_value = "120000 abc123 0\tbroken.yaml" + + with ( + patch("esphome.git.sys.platform", "win32"), + patch.object(Path, "resolve", side_effect=OSError("bad path")), + ): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + + +def test_resolve_symlink_stub_returns_none_when_file_missing( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A file path that doesn't exist is rejected before git is consulted.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + missing = repo_dir / "ghost.yaml" # not created + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, missing) + + assert result is None + mock_run_git_command.assert_not_called() + + +def test_resolve_symlink_stub_returns_none_when_path_outside_repo( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A file path that isn't under repo_dir is rejected (ValueError from relative_to).""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + outside = tmp_path / "stray.yaml" + outside.write_text("something") + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, outside) + + assert result is None + mock_run_git_command.assert_not_called() + + +def test_resolve_symlink_stub_returns_none_when_untracked( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Empty `git ls-files` output (untracked file) makes the helper return None.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + stub = repo_dir / "untracked.yaml" + stub.write_text("static/foo.yaml") + + mock_run_git_command.return_value = "" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + + +def test_resolve_symlink_stub_returns_none_when_read_bytes_raises( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """An OSError from read_bytes() (e.g. file vanished mid-call) must not propagate.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + stub = repo_dir / "racy.yaml" + stub.write_text("static/racy.yaml") + + mock_run_git_command.return_value = "120000 abc123 0\tracy.yaml" + + with ( + patch("esphome.git.sys.platform", "win32"), + patch.object(Path, "read_bytes", side_effect=OSError("vanished")), + ): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index c71be2fbab..b5816f742e 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -837,3 +837,86 @@ def test_include_vars_applied_to_lambda_value(tmp_path: Path) -> None: assert isinstance(result["value"], Lambda) assert result["value"].value == 'return "bar";' + + +@patch("esphome.git.resolve_symlink_stub") +@patch("esphome.git.clone_or_update") +def test_remote_package_symlink_stub_is_followed( + mock_clone_or_update: MagicMock, + mock_resolve_symlink_stub: MagicMock, + tmp_path: Path, +) -> None: + """When a package YAML is a scalar (symlink stub) and resolve_symlink_stub + returns a target, the loader follows the target and uses its content.""" + CORE.config_path = tmp_path / "test.yaml" + + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + (repo_dir / "static").mkdir() + + # Stub file: content is the target path string (simulating Windows behavior). + stub = repo_dir / "file1.yaml" + stub.write_text("static/file1.yaml") + + # Real target with valid YAML mapping. + target = repo_dir / "static" / "file1.yaml" + target.write_text("substitutions:\n hello: world\n") + + mock_clone_or_update.return_value = (repo_dir, None) + mock_resolve_symlink_stub.return_value = target + + config: dict[str, Any] = { + "packages": { + "test_package": { + "url": "https://github.com/esphome/repo1", + "ref": "main", + "files": ["file1.yaml"], + } + } + } + + # Must succeed (does not raise the helpful cv.Invalid) because the stub + # was followed and a valid mapping was loaded from the target. + do_packages_pass(config) + assert mock_resolve_symlink_stub.called + + +@patch("esphome.git.clone_or_update") +def test_remote_package_scalar_yaml_raises_helpful_error( + mock_clone_or_update: MagicMock, tmp_path: Path +) -> None: + """A remote package YAML that is a top-level scalar (e.g. an unmaterialized + git symlink on Windows) raises a clear cv.Invalid, not AttributeError. + + Regression test for the case where a repo containing a YAML symlink, + checked out on Windows without symlink privilege, lands as a short text + file containing the symlink target path. PyYAML parses that as a bare + string scalar; the package loader must reject it with a human-readable + error instead of dying inside ``.get()``. + """ + CORE.config_path = tmp_path / "test.yaml" + + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + # Simulate the broken-symlink state: a YAML file whose entire content is + # the symlink target string. PyYAML parses this as a top-level scalar. + (repo_dir / "file1.yaml").write_text("static/file1.yaml") + + mock_clone_or_update.return_value = (repo_dir, None) + + config: dict[str, Any] = { + "packages": { + "test_package": { + "url": "https://github.com/esphome/repo1", + "ref": "main", + "files": ["file1.yaml"], + } + } + } + + with pytest.raises(cv.Invalid) as exc_info: + do_packages_pass(config) + + msg = str(exc_info.value) + assert "mapping at the top level" in msg + assert "file1.yaml" in msg From 8b62cfded7aec52d5d74b7f12751502b9ad2a059 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 02:57:44 -0500 Subject: [PATCH 0226/1815] [libretiny] Fix RTL8710B IRAM_ATTR section being dropped from flashed image (#16616) --- esphome/components/libretiny/hal.h | 24 +++++---- .../libretiny/patch_linker.py.script | 54 +++++++++++++++---- 2 files changed, 58 insertions(+), 20 deletions(-) diff --git a/esphome/components/libretiny/hal.h b/esphome/components/libretiny/hal.h index 9c512504b7..01a7b5450b 100644 --- a/esphome/components/libretiny/hal.h +++ b/esphome/components/libretiny/hal.h @@ -11,11 +11,19 @@ #include "esphome/core/time_64.h" // IRAM_ATTR places a function in executable RAM so it is callable from an -// ISR even while flash is busy (XIP stall, OTA, logger flash write). -// Each family uses a section its stock linker already routes to RAM: -// RTL8710B → .image2.ram.text, RTL8720C → .sram.text. LN882H is the -// exception: its stock linker has no matching glob, so patch_linker.py -// injects KEEP(*(.sram.text*)) into .flash_copysection at pre-link. +// ISR even while flash is busy (XIP stall, OTA, logger flash write). All +// LibreTiny families that need it share the same .sram.text input section +// name; how that section is routed into RAM differs per family: +// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text. +// RTL8710B: patch_linker.py.script injects KEEP(*(.sram.text*)) at the +// top of .ram_image2.data (which IS in ltchiptool's +// sections_ram). The stock linker has KEEP(*(.image2.ram.text*)) +// in .ram_image2.text but that output section is NOT in +// ltchiptool's AmebaZ elf2bin sections_ram list, so code routed +// there is dropped from the flashed binary. +// LN882H: patch_linker.py.script injects KEEP(*(.sram.text*)) into +// .flash_copysection (> RAM0 AT> FLASH), after KEEP(*(.vectors)) +// so the Cortex-M4 vector table stays 512-byte-aligned for VTOR. // // BK72xx (all variants) are left as a no-op: their SDK wraps flash // operations in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU for @@ -26,13 +34,7 @@ // layer. #if defined(USE_BK72XX) #define IRAM_ATTR -#elif defined(USE_LIBRETINY_VARIANT_RTL8710B) -// Stock linker consumes *(.image2.ram.text*) into .ram_image2.text (> BD_RAM). -#define IRAM_ATTR __attribute__((noinline, section(".image2.ram.text"))) #else -// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text. -// LN882H: patch_linker.py.script injects *(.sram.text*) into -// .flash_copysection (> RAM0 AT> FLASH). #define IRAM_ATTR __attribute__((noinline, section(".sram.text"))) #endif #define PROGMEM diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 3a8a4787ed..dfeaaa57d1 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -6,12 +6,18 @@ import re import subprocess # ESPHome marks ISR code IRAM_ATTR, which on LibreTiny maps to a per-family -# section routed into RAM-executable memory (see esphome/core/hal.h). +# section routed into RAM-executable memory (see esphome/core/hal.h). The +# input section name is always .sram.text; only the output section it lands +# in differs per family. # # This script is NOT loaded on BK72xx (IRAM_ATTR is a no-op there; the SDK # masks FIQ+IRQ around flash writes). On the remaining families: -# - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it. -# - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it. +# - RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text. +# - RTL8710B: stock linker has KEEP(*(.image2.ram.text*)) in .ram_image2.text, +# but ltchiptool's AmebaZ elf2bin (soc/ambz/binary.py) does NOT list +# .ram_image2.text in sections_ram, so code there is silently dropped from +# the flashed image. Inject KEEP(*(.sram.text*)) at the top of +# .ram_image2.data (which IS extracted) instead. # - LN882H: stock linker has no glob for ".sram.text", so we inject # KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH) # immediately after KEEP(*(.vectors)), so the vector table stays at @@ -34,6 +40,20 @@ _KEEP_LINE = ( # aligned address; injecting before the vectors would push them to an # unaligned offset and mis-route every IRQ handler. _LN_COPY = re.compile(r"(KEEP\(\*\(\.vectors\)\)[^\n]*\n)") +# Inject at the top of .ram_image2.data, before __data_start__ so our code +# does not fall inside the data range markers. .ram_image2.data is one of the +# sections ltchiptool's AmebaZ elf2bin extracts; BD_RAM is rwx so the code is +# executable. AmbZ has no C runtime .data copy loop (the bootloader loads +# image2 into BD_RAM whole) so the inline code is not clobbered after boot. +# +# The regex is intentionally strict (no attribute / ALIGN between the section +# name and the opening brace, brace on its own line). If a future AmbZ SDK +# linker template changes this format, _pre_link raises RuntimeError on the +# unpatched .ld file(s), and the RTL8710B CI compile job in +# tests/test_build_components fails on the PR, surfacing the mismatch loudly +# rather than silently shipping a binary with IRAM_ATTR code dropped from +# one or both OTA slots. +_AMBZ_DATA = re.compile(r"(\.ram_image2\.data\s*:\s*\n?\s*\{\s*\n)") def _detect(env): @@ -71,12 +91,11 @@ def _inject_keep(host_section): # Variants not listed here intentionally have no .ld patcher: -# - RTL8710B: hal.h uses section(".image2.ram.text") which the stock linker -# already routes into .ram_image2.text (> BD_RAM). -# - RTL8720C: stock linker already consumes *(.sram.text*). +# - RTL8720C: stock linker already consumes *(.sram.text*) into .ram.code_text. # - BK72xx (all): SDK masks FIQ+IRQ around flash writes, IRAM_ATTR is no-op. _PATCHERS_BY_VARIANT = { "LN882H": (_inject_keep(_LN_COPY),), + "RTL8710B": (_inject_keep(_AMBZ_DATA),), } @@ -87,13 +106,14 @@ def _patchers_for(variant): def _pre_link(target, source, env): build_dir = env.subst("$BUILD_DIR") ld_files = [f for f in os.listdir(build_dir) if f.endswith(".ld")] - patched = 0 + patched = [] + unpatched = [] for name in ld_files: path = os.path.join(build_dir, name) with open(path, "r", encoding="utf-8") as fh: original = fh.read() if _MARKER in original: - patched += 1 + patched.append(name) continue content = original for fn in _patchers: @@ -102,7 +122,9 @@ def _pre_link(target, source, env): with open(path, "w", encoding="utf-8") as fh: fh.write(content) print("ESPHome: patched {} for IRAM_ATTR placement".format(name)) - patched += 1 + patched.append(name) + else: + unpatched.append(name) if not patched: raise RuntimeError( "ESPHome: no .ld in {} was patched for IRAM_ATTR. Update the " @@ -110,6 +132,20 @@ def _pre_link(target, source, env): build_dir ) ) + # Every .ld in the build must be patched. RTL8710B generates one .ld per + # OTA slot (xip1, xip2); if only one matches, the unpatched slot would + # ship with IRAM_ATTR code dropped to zeros and brick the device on the + # boot after an OTA into that slot. + if unpatched: + raise RuntimeError( + "ESPHome: {} of {} .ld file(s) in {} were not patched for " + "IRAM_ATTR: {}. The regex in patch_linker.py.script " + "(_PATCHERS_BY_VARIANT[{!r}]) matched the others but not " + "these. Update the regex to cover all linker scripts.".format( + len(unpatched), len(ld_files), build_dir, + ", ".join(unpatched), _variant, + ) + ) # Substrings matched against demangled names as a fallback on RTL8720C, From ceb9d406e172919020c174e7931d69916a0ceeea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 06:46:44 -0500 Subject: [PATCH 0227/1815] [core] Enable ruff PIE (flake8-pie) lint family (#16658) --- esphome/components/as5600/__init__.py | 2 +- esphome/components/audio_file/__init__.py | 2 +- esphome/components/font/__init__.py | 2 +- esphome/components/http_request/__init__.py | 2 +- esphome/components/image/__init__.py | 2 +- esphome/components/packages/__init__.py | 2 +- esphome/components/time/__init__.py | 6 +++--- esphome/log.py | 6 ++++-- esphome/upload_targets.py | 2 +- pyproject.toml | 1 + script/api_protobuf/api_protobuf.py | 7 +------ script/determine-jobs.py | 10 +++------- script/helpers.py | 6 ++---- tests/integration/test_gpio_expander_cache.py | 4 ++-- tests/unit_tests/components/test_time.py | 4 ++-- 15 files changed, 25 insertions(+), 33 deletions(-) diff --git a/esphome/components/as5600/__init__.py b/esphome/components/as5600/__init__.py index 444306cec3..c05e556376 100644 --- a/esphome/components/as5600/__init__.py +++ b/esphome/components/as5600/__init__.py @@ -100,7 +100,7 @@ def position(min=-MAX_POSITION, max=MAX_POSITION): if isinstance(value, str) and value.endswith("%"): value = percent_to_position(value) - if isinstance(value, str) and (value.endswith("°") or value.endswith("deg")): + if isinstance(value, str) and value.endswith(("°", "deg")): return angle_to_position( value, min=round(min * POSITION_TO_ANGLE), diff --git a/esphome/components/audio_file/__init__.py b/esphome/components/audio_file/__init__.py index 8dc546cec1..53193c8008 100644 --- a/esphome/components/audio_file/__init__.py +++ b/esphome/components/audio_file/__init__.py @@ -72,7 +72,7 @@ def _file_schema(value: ConfigType | str) -> ConfigType: def _validate_file_shorthand(value: str) -> ConfigType: value = cv.string_strict(value) - if value.startswith("http://") or value.startswith("https://"): + if value.startswith(("http://", "https://")): return _file_schema( { CONF_TYPE: TYPE_WEB, diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index 4ea6267275..7510f2f8b6 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -401,7 +401,7 @@ def validate_file_shorthand(value): data[CONF_WEIGHT] = weight[1:] return font_file_schema(data) - if value.startswith("http://") or value.startswith("https://"): + if value.startswith(("http://", "https://")): return font_file_schema( { CONF_TYPE: TYPE_WEB, diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index 2617951f0d..fd033dac7f 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -65,7 +65,7 @@ CONF_JSON = "json" def validate_url(value): value = cv.url(value) - if value.startswith("http://") or value.startswith("https://"): + if value.startswith(("http://", "https://")): return value raise cv.Invalid("URL must start with 'http://' or 'https://'") diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 2fefbdcd58..5f8e5ca132 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -408,7 +408,7 @@ def validate_file_shorthand(value): raise cv.Invalid(f"Could not parse mdi icon name from '{value}'.") return download_gh_svg(parts[1], parts[0]) - if value.startswith("http://") or value.startswith("https://"): + if value.startswith(("http://", "https://")): return download_image(value) value = cv.file_(value) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index f3e0e0db8f..c1c5bd2ae3 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -112,7 +112,7 @@ def expand_file_to_files(config: dict): def validate_yaml_filename(value): value = cv.string(value) - if not (value.endswith(".yaml") or value.endswith(".yml")): + if not value.endswith((".yaml", ".yml")): raise cv.Invalid("Only YAML (.yaml / .yml) files are supported.") return value diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index f687df26c2..b3bf2d44d7 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -418,11 +418,11 @@ async def setup_time_core_(time_var, config): for conf in config.get(CONF_ON_TIME, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], time_var) - seconds = conf.get(CONF_SECONDS, list(range(0, 61))) + seconds = conf.get(CONF_SECONDS, list(range(61))) cg.add(trigger.add_seconds(seconds)) - minutes = conf.get(CONF_MINUTES, list(range(0, 60))) + minutes = conf.get(CONF_MINUTES, list(range(60))) cg.add(trigger.add_minutes(minutes)) - hours = conf.get(CONF_HOURS, list(range(0, 24))) + hours = conf.get(CONF_HOURS, list(range(24))) cg.add(trigger.add_hours(hours)) days_of_month = conf.get(CONF_DAYS_OF_MONTH, list(range(1, 32))) cg.add(trigger.add_days_of_month(days_of_month)) diff --git a/esphome/log.py b/esphome/log.py index bfd1875b55..b120c930d0 100644 --- a/esphome/log.py +++ b/esphome/log.py @@ -28,10 +28,12 @@ class AnsiFore(Enum): class AnsiStyle(Enum): + # BOLD/BRIGHT and THIN/DIM are intentional ANSI synonyms; Enum treats the + # second name in each pair as an alias of the first. BRIGHT = "\033[1m" - BOLD = "\033[1m" + BOLD = "\033[1m" # noqa: PIE796 DIM = "\033[2m" - THIN = "\033[2m" + THIN = "\033[2m" # noqa: PIE796 NORMAL = "\033[22m" RESET_ALL = "\033[0m" diff --git a/esphome/upload_targets.py b/esphome/upload_targets.py index 302ecf7301..d9d9713fc1 100644 --- a/esphome/upload_targets.py +++ b/esphome/upload_targets.py @@ -57,7 +57,7 @@ def get_port_type(port: str) -> PortType: """ if port == "BOOTSEL": return PortType.BOOTSEL - if port.startswith("/") or port.startswith("COM"): + if port.startswith(("/", "COM")): return PortType.SERIAL if port == "MQTT": return PortType.MQTT diff --git a/pyproject.toml b/pyproject.toml index ae1bd34f60..6572078746 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,6 +126,7 @@ select = [ "LOG", # flake8-logging "NPY", # numpy-specific rules "PERF", # performance + "PIE", # flake8-pie "PL", # pylint "PTH", # flake8-use-pathlib "PYI", # flake8-pyi diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 240ee7890f..451cd9ac1f 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -84,12 +84,7 @@ def indent_list(text: str, padding: str = " ") -> list[str]: """Indent each line of the given text with the specified padding.""" lines = [] for line in text.splitlines(): - if ( - line == "" - or line.startswith("#ifdef") - or line.startswith("#if ") - or line.startswith("#endif") - ): + if line == "" or line.startswith(("#ifdef", "#if ", "#endif")): p = "" else: p = padding diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 417716cd77..d91936952e 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -483,9 +483,7 @@ def should_run_device_builder(branch: str | None = None) -> bool: True if the device-builder downstream tests should run, False otherwise. """ target_branch = get_target_branch() - if target_branch and ( - target_branch.startswith("release") or target_branch.startswith("beta") - ): + if target_branch and (target_branch.startswith(("release", "beta"))): return False for file in changed_files(branch): @@ -955,9 +953,7 @@ def detect_memory_impact_config( # all components at once would produce nonsensical memory impact results. # Memory impact analysis is most useful for focused PRs targeting dev. target_branch = get_target_branch() - if target_branch and ( - target_branch.startswith("release") or target_branch.startswith("beta") - ): + if target_branch and (target_branch.startswith(("release", "beta"))): print( f"Memory impact: Skipping analysis for target branch {target_branch} " f"(would try to build all components at once, giving nonsensical results)", @@ -1311,7 +1307,7 @@ def main() -> None: # (no isolation, all components are groupable) target_branch = get_target_branch() is_release_branch = target_branch and ( - target_branch.startswith("release") or target_branch.startswith("beta") + target_branch.startswith(("release", "beta")) ) if is_release_branch: diff --git a/script/helpers.py b/script/helpers.py index c56a434edf..9839e766e2 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -103,9 +103,7 @@ def get_component_from_path(file_path: str) -> str | None: Returns: Component name if path is in components or tests directory, None otherwise """ - if file_path.startswith(ESPHOME_COMPONENTS_PATH) or file_path.startswith( - ESPHOME_TESTS_COMPONENTS_PATH - ): + if file_path.startswith((ESPHOME_COMPONENTS_PATH, ESPHOME_TESTS_COMPONENTS_PATH)): parts = file_path.split("/") if len(parts) >= 3 and parts[2]: # Verify that parts[2] is actually a component directory, not a file @@ -160,7 +158,7 @@ def is_validate_only_file(test_file: Path) -> bool: ``esphome config`` only and skipped during compile. """ name = test_file.name - return name.startswith("validate.") or name.startswith("validate-") + return name.startswith(("validate.", "validate-")) @dataclass(frozen=True) diff --git a/tests/integration/test_gpio_expander_cache.py b/tests/integration/test_gpio_expander_cache.py index e5f0f2818f..1d36ca3446 100644 --- a/tests/integration/test_gpio_expander_cache.py +++ b/tests/integration/test_gpio_expander_cache.py @@ -43,7 +43,7 @@ async def test_gpio_expander_cache( # ensure logs are in the expected order log_order = [ (digital_read_hw_pattern, 0), - [(digital_read_cache_pattern, i) for i in range(0, 8)], + [(digital_read_cache_pattern, i) for i in range(8)], (digital_read_hw_pattern, 8), [(digital_read_cache_pattern, i) for i in range(8, 16)], (digital_read_hw_pattern, 16), @@ -68,7 +68,7 @@ async def test_gpio_expander_cache( # uint16_t component tests (single bank of 16 pins) (uint16_read_hw_pattern, 0), # First pin triggers hw read [ - (uint16_read_cache_pattern, i) for i in range(0, 16) + (uint16_read_cache_pattern, i) for i in range(16) ], # All 16 pins return via cache # After cache reset (uint16_read_hw_pattern, 5), # First read after reset triggers hw diff --git a/tests/unit_tests/components/test_time.py b/tests/unit_tests/components/test_time.py index 6325bfbe75..5ae9d787d6 100644 --- a/tests/unit_tests/components/test_time.py +++ b/tests/unit_tests/components/test_time.py @@ -70,11 +70,11 @@ def test_numeric_offset_slash() -> None: def test_star() -> None: - assert _parse_cron_part("*", 0, 59, {}) == set(range(0, 60)) + assert _parse_cron_part("*", 0, 59, {}) == set(range(60)) def test_question() -> None: - assert _parse_cron_part("?", 0, 59, {}) == set(range(0, 60)) + assert _parse_cron_part("?", 0, 59, {}) == set(range(60)) def test_range() -> None: From 88b12a1c457f171ef877b033dcf8f4231e74bf13 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 08:41:54 -0500 Subject: [PATCH 0228/1815] [lvgl] Build automation_schema event validators lazily (#16633) --- esphome/components/lvgl/schemas.py | 34 ++++++++- .../lvgl/test_automation_schema_lazy.py | 71 +++++++++++++++++++ 2 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 tests/component_tests/lvgl/test_automation_schema_lazy.py diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index b901eb4b53..bdaa91f15c 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -22,6 +22,7 @@ from esphome.const import ( ) from esphome.core import TimePeriod from esphome.core.config import StartupTrigger +from esphome.schema_extractors import EnableSchemaExtraction from . import defines as df, lv_validation as lvalid from .defines import ( @@ -407,7 +408,34 @@ def part_schema(parts: tuple[str, ...] | list[str]) -> cv.Schema: return cv.Schema(part_dict(parts)) -def automation_schema(typ: LvType): +def _lazy_validate_automation(extra_schema: dict) -> Callable[[Any], Any]: + """Return a validator that defers building the validate_automation schema. + + validate_automation() runs AUTOMATION_SCHEMA.extend(extra_schema), which + voluptuous compiles eagerly. automation_schema() builds ~60 of these per + widget type, and the vast majority of slots are never invoked by a given + user config. Deferring the build to first use removes that work from + schema-construction time. + + When EnableSchemaExtraction is set (build_language_schema.py), fall back + to eager construction so the @schema_extractor("automation") decoration + inside validate_automation is registered. + """ + if EnableSchemaExtraction: + return validate_automation(extra_schema) + + cached: Callable[[Any], Any] | None = None + + def validator(value: Any) -> Any: + nonlocal cached + if cached is None: + cached = validate_automation(extra_schema) + return cached(value) + + return validator + + +def automation_schema(typ: LvType) -> dict[Any, Any]: events = df.LV_EVENT_TRIGGERS + df.SWIPE_TRIGGERS if typ.has_on_value: events = events + (CONF_ON_VALUE, CONF_ON_UPDATE) @@ -422,7 +450,7 @@ def automation_schema(typ: LvType): return { **{ - cv.Optional(event): validate_automation( + cv.Optional(event): _lazy_validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( Trigger.template(*get_trigger_args(event)) @@ -431,7 +459,7 @@ def automation_schema(typ: LvType): ) for event in events }, - cv.Optional(CONF_ON_BOOT): validate_automation( + cv.Optional(CONF_ON_BOOT): _lazy_validate_automation( {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StartupTrigger)} ), } diff --git a/tests/component_tests/lvgl/test_automation_schema_lazy.py b/tests/component_tests/lvgl/test_automation_schema_lazy.py new file mode 100644 index 0000000000..46430824f6 --- /dev/null +++ b/tests/component_tests/lvgl/test_automation_schema_lazy.py @@ -0,0 +1,71 @@ +"""Tests for lvgl automation_schema lazy validate_automation build.""" + +from __future__ import annotations + +from unittest.mock import patch + +import esphome.components.lvgl # noqa: F401 +from esphome.components.lvgl import schemas as lvgl_schemas +from esphome.components.lvgl.schemas import ( + WIDGET_TYPES, + _lazy_validate_automation, + automation_schema, +) +from esphome.components.lvgl.widgets import WidgetType +from esphome.config_validation import GenerateID, declare_id +from esphome.const import CONF_TRIGGER_ID +from esphome.core.config import StartupTrigger + + +def _widget_type(name: str = "obj") -> WidgetType: + wt = WIDGET_TYPES.get(name) + assert wt is not None, f"widget type {name!r} not registered" + return wt + + +def _trigger_extra_schema() -> dict: + return {GenerateID(CONF_TRIGGER_ID): declare_id(StartupTrigger)} + + +def test_lazy_validator_defers_build_until_first_call() -> None: + with patch( + "esphome.components.lvgl.schemas.validate_automation", + wraps=lvgl_schemas.validate_automation, + ) as va_mock: + validator = _lazy_validate_automation(_trigger_extra_schema()) + assert va_mock.call_count == 0 + validator({"then": []}) + assert va_mock.call_count == 1 + validator({"then": []}) + assert va_mock.call_count == 1 + + +def test_eager_build_when_schema_extraction_enabled() -> None: + with ( + patch("esphome.components.lvgl.schemas.EnableSchemaExtraction", True), + patch( + "esphome.components.lvgl.schemas.validate_automation", + wraps=lvgl_schemas.validate_automation, + ) as va_mock, + ): + _lazy_validate_automation(_trigger_extra_schema()) + assert va_mock.call_count == 1 + + +def test_lazy_and_eager_produce_equivalent_validation() -> None: + extra = _trigger_extra_schema() + with patch("esphome.components.lvgl.schemas.EnableSchemaExtraction", True): + eager = _lazy_validate_automation(extra) + lazy = _lazy_validate_automation(_trigger_extra_schema()) + sample = {"then": []} + assert lazy(sample) == eager(sample) + + +def test_automation_schema_uses_lazy_validators() -> None: + wt = _widget_type("obj") + with patch( + "esphome.components.lvgl.schemas.validate_automation", + wraps=lvgl_schemas.validate_automation, + ) as va_mock: + automation_schema(wt.w_type) + assert va_mock.call_count == 0 From 722cbfe843cad4aa729ef848016261fdb4bb884d Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 26 May 2026 14:05:57 -0400 Subject: [PATCH 0229/1815] [voice_assistant] Never send zero-length audio to Home Assistant (#16634) --- .../voice_assistant/voice_assistant.cpp | 136 ++++++++++++------ .../voice_assistant/voice_assistant.h | 13 ++ 2 files changed, 107 insertions(+), 42 deletions(-) diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index af1b98da02..f13ea39fa2 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -4,6 +4,7 @@ #ifdef USE_VOICE_ASSISTANT #include "esphome/components/socket/socket.h" +#include "esphome/core/application.h" #include "esphome/core/log.h" #include @@ -26,6 +27,11 @@ static const size_t SEND_BUFFER_SIZE = SEND_BUFFER_SAMPLES * sizeof(int16_t); static const size_t RECEIVE_SIZE = 1024; static const size_t SPEAKER_BUFFER_SIZE = 16 * RECEIVE_SIZE; +// If one microphone channel keeps producing audio while another configured channel produces none for this +// long, treat the silent channel as failed and stop the stream. A working microphone exposes a chunk every +// SEND_BUFFER_SAMPLES (32 ms), so this is far longer than any legitimate gap between chunks. +static const uint32_t AUDIO_CHANNEL_STALL_TIMEOUT_MS = 2000; + VoiceAssistant::VoiceAssistant() { global_voice_assistant = this; } void VoiceAssistant::setup() { @@ -168,6 +174,9 @@ void VoiceAssistant::clear_buffers_() { this->audio_source2_->clear_buffered_data(); } + // Reset the multi-channel stall watchdog (see audio_channel_stall_start_). + this->audio_channel_stall_start_ = 0; + #ifdef USE_SPEAKER if ((this->speaker_ != nullptr) && (this->speaker_buffer_ != nullptr)) { memset(this->speaker_buffer_, 0, SPEAKER_BUFFER_SIZE); @@ -200,6 +209,79 @@ void VoiceAssistant::reset_conversation_id() { ESP_LOGD(TAG, "reset conversation ID"); } +void VoiceAssistant::stream_api_audio_() { + // Both microphone channels are sent together, if configured. Home Assistant feeds one of the + // channels to its speech-to-text stream and treats an empty payload on that channel as + // end-of-stream, and the device cannot know which channel it picked, so only send once every + // configured channel has audio exposed, and always send them together. We don't target any + // particular message size: Home Assistant re-chunks the audio, and each fill() exposes at most + // SEND_BUFFER_SIZE bytes. + while (true) { + // fill() exposes a new chunk, or returns 0 if a previous chunk is still exposed; available() + // reports the currently exposed bytes either way. + this->audio_source_->fill(0, false); + size_t available = this->audio_source_->available(); + size_t available2 = 0; + if (this->audio_source2_ != nullptr) { + this->audio_source2_->fill(0, false); + available2 = this->audio_source2_->available(); + } + + const bool channel_empty = (available == 0); + const bool channel2_empty = (this->audio_source2_ != nullptr) && (available2 == 0); + if (channel_empty || channel2_empty) { + // A configured channel has no audio yet, so keep any chunk exposed on the other channel for the + // next pass rather than sending an empty payload. + this->handle_channel_stall_(available, available2); + break; + } + + // Both channels have audio exposed; clear any in-progress stall timer. + this->audio_channel_stall_start_ = 0; + + api::VoiceAssistantAudio msg; + // Zero-copy: send_message() copies the data out before we consume it. + msg.data = this->audio_source_->data(); + msg.data_len = available; + if (this->audio_source2_ != nullptr) { + msg.data2 = this->audio_source2_->data(); + msg.data2_len = available2; + } + + this->api_client_->send_message(msg); + + this->audio_source_->consume(available); + if (this->audio_source2_ != nullptr) { + this->audio_source2_->consume(available2); + } + } +} + +void VoiceAssistant::handle_channel_stall_(size_t available, size_t available2) { + // Called when at least one configured channel has no audio exposed. When one channel has data and the + // other does not, watch how long the empty channel stays starved: Home Assistant has no stream timeout + // and would never tell us to stop, so a channel that fails outright would otherwise hang streaming + // forever with the live channel's chunk held. Stop the stream with an error after a prolonged imbalance. + if ((available == 0) && (available2 == 0)) { + // Both channels are idle (no audio buffered yet); normal, not a stalled channel. + this->audio_channel_stall_start_ = 0; + return; + } + + const uint32_t now = App.get_loop_component_start_time(); + if (this->audio_channel_stall_start_ == 0) { + this->audio_channel_stall_start_ = now; + } else if ((now - this->audio_channel_stall_start_) >= AUDIO_CHANNEL_STALL_TIMEOUT_MS) { + ESP_LOGW(TAG, "Mic channel %d stalled, stopping stream", (available == 0) ? 0 : 1); + this->audio_channel_stall_start_ = 0; + this->signal_stop_(); + this->set_state_(State::STOP_MICROPHONE, State::IDLE); + this->defer([this]() { + this->error_trigger_.trigger("mic-channel-stalled", "A microphone channel stopped producing audio"); + }); + } +} + void VoiceAssistant::loop() { if (this->api_client_ == nullptr && this->state_ != State::IDLE && this->state_ != State::STOP_MICROPHONE && this->state_ != State::STOPPING_MICROPHONE) { @@ -292,55 +374,25 @@ void VoiceAssistant::loop() { case State::STREAMING_MICROPHONE: { // pre_shift is ignored by RingBufferAudioSource (no intermediate transfer buffer to compact). if (this->audio_mode_ == AUDIO_MODE_API) { - // API audio - // Both microphone channels are sent, if configured - size_t available = this->audio_source_->fill(0, false); - size_t available2 = 0; - if (this->audio_source2_ != nullptr) { - available2 = this->audio_source2_->fill(0, false); - } - - while (available > 0 || available2 > 0) { - api::VoiceAssistantAudio msg; - - if (available > 0) { - // Zero-copy: send_message() copies the data out before we consume it - msg.data = this->audio_source_->data(); - msg.data_len = available; - } - - // Second microphone channel - if (available2 > 0) { - msg.data2 = this->audio_source2_->data(); - msg.data2_len = available2; - } - - this->api_client_->send_message(msg); - - if (available > 0) { - this->audio_source_->consume(available); - } - available = this->audio_source_->fill(0, false); - if (available2 > 0) { - this->audio_source2_->consume(available2); - } - if (this->audio_source2_ != nullptr) { - available2 = this->audio_source2_->fill(0, false); - } - } + this->stream_api_audio_(); } else { // UDP (will eventually be deprecated) // Only the primary microphone channel is used - while (this->audio_source_->fill(0, false) > 0) { + while (true) { + this->audio_source_->fill(0, false); + size_t available = this->audio_source_->available(); + if (available == 0) { + break; + } if (!this->udp_socket_running_) { if (!this->start_udp_socket_()) { this->set_state_(State::STOP_MICROPHONE, State::IDLE); break; } } - this->socket_->sendto(this->audio_source_->data(), this->audio_source_->available(), 0, - (struct sockaddr *) &this->dest_addr_, sizeof(this->dest_addr_)); - this->audio_source_->consume(this->audio_source_->available()); + this->socket_->sendto(this->audio_source_->data(), available, 0, (struct sockaddr *) &this->dest_addr_, + sizeof(this->dest_addr_)); + this->audio_source_->consume(available); } } // audio mode break; @@ -841,8 +893,8 @@ void VoiceAssistant::on_event(const api::VoiceAssistantEventResponse &msg) { }); State new_state = this->local_output_ ? State::STREAMING_RESPONSE : State::IDLE; if (new_state != this->state_) { - // Don't needlessly change the state. The intent progress stage may have already changed the state to streaming - // response. + // Don't needlessly change the state. The intent progress stage may have already changed the state to + // streaming response. this->set_state_(new_state, new_state); } break; diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index f3ea669e15..76b076a366 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -244,6 +244,12 @@ class VoiceAssistant : public Component { void signal_stop_(); void start_playback_timeout_(); + // Drains the exposed microphone audio and sends it to Home Assistant over the API in one loop() pass. + void stream_api_audio_(); + // Handles a pass where at least one configured channel has no audio exposed, timing out a channel that + // stalls. See audio_channel_stall_start_. + void handle_channel_stall_(size_t available, size_t available2); + std::unique_ptr socket_ = nullptr; struct sockaddr_storage dest_addr_; @@ -315,6 +321,13 @@ class VoiceAssistant : public Component { std::weak_ptr ring_buffer_; std::weak_ptr ring_buffer2_; + // When streaming multiple channels, the send loop holds an exposed chunk on one channel until the other + // channel also has audio so the channels are always sent together (an empty payload looks like + // end-of-stream to Home Assistant). Home Assistant has no stream timeout, so a channel that stops + // producing entirely would hang streaming forever. This records when such an imbalance began so a + // prolonged one can be detected and stopped; 0 means no imbalance is currently being timed. + uint32_t audio_channel_stall_start_{0}; + bool use_wake_word_; uint8_t noise_suppression_level_; uint8_t auto_gain_; From bac62cb7dec73d5e3311d16d607fb1485e4e9584 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 15:29:06 -0500 Subject: [PATCH 0230/1815] [core] Add cv.sensitive marker for schema-level sensitive fields (#16673) --- esphome/config_validation.py | 47 +++++++++++++++ script/build_language_schema.py | 39 +++++++++++- tests/script/test_build_language_schema.py | 69 +++++++++++++++++++++- tests/unit_tests/test_config_validation.py | 43 ++++++++++++++ 4 files changed, 193 insertions(+), 5 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index ca1fd8f5d4..1d5e27c9ae 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -487,6 +487,53 @@ def string_strict(value): ) +# Substring fallbacks for fields whose validator isn't explicitly wrapped in +# ``cv.sensitive``. Frontends and dump tooling should prefer the explicit +# marker; this list exists so we still mask obvious leaks in unmigrated or +# third-party schemas. Kept here as the single source of truth. +SENSITIVE_KEY_FRAGMENTS: frozenset[str] = frozenset( + { + "password", + "passcode", + "secret", + "token", + "api_key", + "apikey", + "psk", + } +) + + +class SensitiveValidator: + """Marker wrapper that flags a field as containing sensitive data (passwords, + encryption keys, PSKs, tokens). Frontends and dump tooling detect this marker + to mask the value; validation behavior is delegated to the inner validator. + """ + + def __init__(self, inner: Callable[[typing.Any], typing.Any]) -> None: + self.inner = inner + + def __call__(self, value: typing.Any) -> typing.Any: + return self.inner(value) + + def __repr__(self) -> str: + # Mirror the inner validator's repr so ``build_language_schema``'s + # ``known_schemas``/``extended_schemas`` dedup (keyed on ``repr(schema)``) + # treats two wrappers around the same inner as identical, and so + # voluptuous error messages stay readable. + return repr(self.inner) + + +def sensitive( + inner: Callable[[typing.Any], typing.Any] = string, +) -> SensitiveValidator: + """Mark a field as sensitive so that frontends mask it and dump tooling redacts it. + + Validation behavior is identical to ``inner`` (defaults to ``cv.string``). + """ + return SensitiveValidator(inner) + + def icon(value): """Validate that a given config value is a valid icon.""" from esphome.core.config import ICON_MAX_LENGTH diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 9dff70af3c..6e4000e06e 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -39,7 +39,11 @@ parser.add_argument( ) parser.add_argument("--check", action="store_true", help="Check only for CI") -args = parser.parse_args() +# Module-level ``Namespace`` so helper functions can reference ``args`` +# without threading it through every call. ``main()`` fills it via +# ``parser.parse_args(namespace=args)``; tests import this module without +# invoking ``main()`` and rely on the defaults below. +args = argparse.Namespace(output_path=".", check=False) DUMP_RAW = False DUMP_UNKNOWN = False @@ -850,6 +854,12 @@ def convert(schema, config_var, path): convert(ext, config_var, f"{path}/ext{idx}") return + if isinstance(schema, cv.SensitiveValidator): + config_var["sensitive"] = True + config_var["sensitive_source"] = "explicit" + convert(schema.inner, config_var, f"{path}/sensitive") + return + if isinstance(schema, cv.All): i = 0 for inner in schema.validators: @@ -1125,6 +1135,25 @@ def convert_keys(converted, schema, path): # Do value convert(v, result, path + f"/{str(k)}") + + # Heuristic fallback when the field's validator wasn't explicitly + # wrapped in ``cv.sensitive``. Only applies to string-typed leaves so + # we don't mark unrelated nested schemas. ``sensitive_source`` lets + # consumers distinguish explicit markers from heuristic matches. Pull + # the field name from ``k.schema`` (voluptuous's stored key) rather + # than ``str(k)`` so we don't depend on the marker's ``__str__`` + # representation. + if ( + "sensitive" not in result + and result.get(S_TYPE) == "string" + and isinstance(k, (cv.Required, cv.Optional, cv.Inclusive, cv.Exclusive)) + and isinstance(k.schema, str) + ): + key_lower = k.schema.lower() + if any(frag in key_lower for frag in cv.SENSITIVE_KEY_FRAGMENTS): + result["sensitive"] = True + result["sensitive_source"] = "heuristic" + if "schema" not in converted: converted[S_TYPE] = "schema" converted["schema"] = {S_CONFIG_VARS: {}} @@ -1142,4 +1171,10 @@ def convert_keys(converted, schema, path): config_vars["string"] = config_vars.pop(key) -build_schema() +def main() -> None: + parser.parse_args(namespace=args) + build_schema() + + +if __name__ == "__main__": + main() diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index 59b8c7484b..dd1d88e74c 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -3,8 +3,11 @@ from __future__ import annotations import ast +import importlib.util from pathlib import Path +from esphome import config_validation as cv + SCRIPT_PATH = ( Path(__file__).resolve().parent.parent.parent / "script" @@ -12,10 +15,16 @@ SCRIPT_PATH = ( ) +def _load_script_module(): + spec = importlib.util.spec_from_file_location("build_language_schema", SCRIPT_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + def _extract_sort_obj(): - # build_language_schema.py runs argparse, loads every component, and - # calls build_schema() at import time, so a plain import isn't viable - # in a unit test. Pull just the pure helper out via AST instead. + # ``sort_obj`` is pure and self-contained; pulling it via AST avoids + # exercising the module-level component-loading state for these tests. tree = ast.parse(SCRIPT_PATH.read_text()) for node in tree.body: if isinstance(node, ast.FunctionDef) and node.name == "sort_obj": @@ -27,6 +36,7 @@ def _extract_sort_obj(): sort_obj = _extract_sort_obj() +_bls = _load_script_module() def test_sort_obj_sorts_dict_keys() -> None: @@ -96,3 +106,56 @@ def test_sort_obj_passes_through_scalars() -> None: assert sort_obj(42) == 42 assert sort_obj(None) is None assert sort_obj(True) is True + + +def test_convert_emits_explicit_sensitive_marker() -> None: + config_var: dict = {} + _bls.convert(cv.sensitive(cv.string), config_var, "/test") + + assert config_var["sensitive"] is True + assert config_var["sensitive_source"] == "explicit" + assert config_var["type"] == "string" + + +def test_convert_keys_emits_heuristic_sensitive_marker() -> None: + converted: dict = {} + _bls.convert_keys(converted, {cv.Optional("password"): cv.string}, "/root") + + entry = converted["schema"]["config_vars"]["password"] + assert entry["sensitive"] is True + assert entry["sensitive_source"] == "heuristic" + assert entry["type"] == "string" + + +def test_convert_keys_explicit_beats_heuristic() -> None: + # Key name matches a fragment but the validator is explicitly wrapped; + # the explicit branch should win and emit ``sensitive_source: explicit``. + converted: dict = {} + _bls.convert_keys( + converted, {cv.Optional("password"): cv.sensitive(cv.string)}, "/root" + ) + + entry = converted["schema"]["config_vars"]["password"] + assert entry["sensitive"] is True + assert entry["sensitive_source"] == "explicit" + + +def test_convert_keys_no_heuristic_for_non_string_leaves() -> None: + # Even though the key contains a fragment, a non-string leaf must not + # be flagged. Prevents false positives on unrelated fields whose name + # happens to embed a substring like "token". + converted: dict = {} + _bls.convert_keys(converted, {cv.Optional("password"): cv.boolean}, "/root") + + entry = converted["schema"]["config_vars"]["password"] + assert "sensitive" not in entry + assert "sensitive_source" not in entry + + +def test_convert_keys_no_marker_for_non_sensitive_field() -> None: + converted: dict = {} + _bls.convert_keys(converted, {cv.Optional("hostname"): cv.string}, "/root") + + entry = converted["schema"]["config_vars"]["hostname"] + assert "sensitive" not in entry + assert "sensitive_source" not in entry diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index fd6c0e95f2..2c34cbfb07 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -127,6 +127,49 @@ def test_string_string__invalid(value): config_validation.string_strict(value) +def test_sensitive__default_delegates_to_string() -> None: + validator = config_validation.sensitive() + + assert isinstance(validator, config_validation.SensitiveValidator) + assert validator.inner is config_validation.string + assert validator("hunter2") == "hunter2" + assert validator(42) == "42" + + +def test_sensitive__custom_inner_delegates_validation() -> None: + validator = config_validation.sensitive(config_validation.string_strict) + + assert validator.inner is config_validation.string_strict + assert validator("abc") == "abc" + with pytest.raises(Invalid, match="Must be string, got"): + validator(123) + + +def test_sensitive__is_detectable_via_isinstance() -> None: + validator = config_validation.sensitive() + + assert isinstance(validator, config_validation.SensitiveValidator) + + +def test_sensitive__repr_mirrors_inner() -> None: + # The schema dump dedups on ``repr(schema)``; mirroring the inner + # validator's repr keeps two ``cv.sensitive(cv.string)`` wrappers + # interchangeable for that purpose and avoids leaking the wrapper as + # noise in voluptuous error messages. + assert repr(config_validation.sensitive(config_validation.string)) == repr( + config_validation.string + ) + assert repr(config_validation.sensitive(config_validation.string)) == repr( + config_validation.sensitive(config_validation.string) + ) + + +def test_sensitive_key_fragments__covers_common_terms() -> None: + assert isinstance(config_validation.SENSITIVE_KEY_FRAGMENTS, frozenset) + for term in ("password", "passcode", "secret", "token", "api_key", "apikey", "psk"): + assert term in config_validation.SENSITIVE_KEY_FRAGMENTS + + @given( builds( lambda v: "mdi:" + v, From 96816e24916192a36daf7119b8fca81836067476 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 15:29:38 -0500 Subject: [PATCH 0231/1815] [core] Enable ruff DTZ (flake8-datetimez) lint family (#16660) --- esphome/__main__.py | 2 +- esphome/components/api/client.py | 2 +- esphome/components/zigbee/zigbee_zephyr.py | 5 ++++- esphome/config_validation.py | 4 +++- esphome/external_files.py | 9 ++++++--- esphome/git.py | 8 ++++---- esphome/mqtt.py | 6 +++--- esphome/storage_json.py | 5 ++++- pyproject.toml | 1 + tests/unit_tests/test_external_files.py | 4 ++-- tests/unit_tests/test_git.py | 18 +++++++++--------- tests/unit_tests/test_storage_json.py | 4 ++-- 12 files changed, 40 insertions(+), 28 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 5f281ce832..dd97c6eee9 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -639,7 +639,7 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: chunk = ser.read(ser.in_waiting or 1) if not chunk: continue - time_ = datetime.now() + time_ = datetime.now().astimezone() milliseconds = time_.microsecond // 1000 time_str = f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{milliseconds:03}]" diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 327973a605..44edc035f9 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -119,7 +119,7 @@ async def async_run_logs( def on_log(msg: SubscribeLogsResponse) -> None: """Handle a new log message.""" - time_ = datetime.now() + time_ = datetime.now().astimezone() message: bytes = msg.message text = message.decode("utf8", "backslashreplace") nanoseconds = time_.microsecond // 1000 diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index aa16bbef53..39ecadfddf 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -161,7 +161,10 @@ async def _attr_to_code(config: ConfigType) -> None: zigbee_set_string(basic_attrs.mf_name, "esphome"), zigbee_set_string(basic_attrs.model_id, config[CONF_MODEL]), zigbee_set_string( - basic_attrs.date_code, datetime.datetime.now().strftime("%Y%m%d %H%M%S") + basic_attrs.date_code, + # Local build time, matching the esp32 implementation + # (App.get_build_time() in C++). + datetime.datetime.now().astimezone().strftime("%Y%m%d %H%M%S"), ), zigbee_assign( basic_attrs.power_source, diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 1d5e27c9ae..f826b254ac 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1183,7 +1183,9 @@ def date_time(date: bool, time: bool): format += "%p" try: - date_obj = datetime.strptime(value, format) + # The generated format never includes %z/%Z, so this parses a + # naive wall-clock date/time by design. + date_obj = datetime.strptime(value, format) # noqa: DTZ007 except ValueError as err: raise Invalid(f"Invalid {exc_message}: {err}") from err diff --git a/esphome/external_files.py b/esphome/external_files.py index dfabc54f47..4e73c8dc21 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -7,6 +7,7 @@ from datetime import UTC, datetime import logging import os from pathlib import Path +import time import requests @@ -141,9 +142,11 @@ def has_remote_file_changed( def is_file_recent(file_path: Path, refresh: TimePeriodSeconds) -> bool: if file_path.exists(): - creation_time = file_path.stat().st_ctime - current_time = datetime.now().timestamp() - return current_time - creation_time <= refresh.total_seconds + # st_mtime, not st_ctime: ctime is inode-change time on POSIX + # (bumped by chmod/chown/rename) so a metadata touch would make + # the file look fresh. + modification_time = file_path.stat().st_mtime + return time.time() - modification_time <= refresh.total_seconds return False diff --git a/esphome/git.py b/esphome/git.py index 094a6dae19..744ce35ef6 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -1,12 +1,12 @@ from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime import hashlib import logging from pathlib import Path import re import subprocess import sys +import time import urllib.parse import esphome.config_validation as cv @@ -247,11 +247,11 @@ def clone_or_update( return repo_dir, None file_timestamp = Path(repo_dir / ".git" / "FETCH_HEAD") - # On first clone, FETCH_HEAD does not exists + # On first clone, FETCH_HEAD does not exist if not file_timestamp.exists(): file_timestamp = Path(repo_dir / ".git" / "HEAD") - age = datetime.now() - datetime.fromtimestamp(file_timestamp.stat().st_mtime) - if refresh is None or age.total_seconds() > refresh.total_seconds: + age_seconds = time.time() - file_timestamp.stat().st_mtime + if refresh is None or age_seconds > refresh.total_seconds: # Try to update the repository, recovering from broken state if needed old_sha: str | None = None try: diff --git a/esphome/mqtt.py b/esphome/mqtt.py index d6bde0cbfd..c6a7a7558b 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -139,7 +139,7 @@ def show_discover(config, username=None, password=None, client_id=None): _LOGGER.info("Starting log output from %s", topic) def on_message(client, userdata, msg): - time_ = datetime.now().time().strftime("[%H:%M:%S]") + time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]") payload = msg.payload.decode(errors="backslashreplace") if len(payload) > 0: message = time_ + " " + payload @@ -184,7 +184,7 @@ def get_esphome_device_ip( def on_message(client, userdata, msg): nonlocal dev_ip - time_ = datetime.now().time().strftime("[%H:%M:%S]") + time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]") payload = msg.payload.decode(errors="backslashreplace") if len(payload) > 0: message = time_ + " " + payload @@ -253,7 +253,7 @@ def show_logs(config, topic=None, username=None, password=None, client_id=None): _LOGGER.info("Starting log output from %s", topic) def on_message(client, userdata, msg): - time_ = datetime.now().time().strftime("[%H:%M:%S]") + time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]") payload = msg.payload.decode(errors="backslashreplace") message = time_ + payload safe_print(message) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 7f8885ba5f..04f5881465 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -338,7 +338,10 @@ class EsphomeStorageJSON: @property def last_update_check(self) -> datetime | None: try: - return datetime.strptime(self.last_update_check_str, "%Y-%m-%dT%H:%M:%S") + # Stored format is naive ISO without %z; preserved for backward compat. + return datetime.strptime( # noqa: DTZ007 + self.last_update_check_str, "%Y-%m-%dT%H:%M:%S" + ) except Exception: # pylint: disable=broad-except return None diff --git a/pyproject.toml b/pyproject.toml index 6572078746..d92c7ba894 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -113,6 +113,7 @@ exclude = ['generated'] select = [ "B", # flake8-bugbear "C4", # flake8-comprehensions + "DTZ", # flake8-datetimez "E", # pycodestyle "EXE", # flake8-executable "F", # pyflakes/autoflake diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index 64ef149581..16cee9564f 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -120,7 +120,7 @@ def test_is_file_recent_with_old_file(setup_core: Path) -> None: old_time = time.time() - 7200 mock_stat = MagicMock() - mock_stat.st_ctime = old_time + mock_stat.st_mtime = old_time with patch.object(Path, "stat", return_value=mock_stat): refresh = TimePeriod(seconds=3600) @@ -147,7 +147,7 @@ def test_is_file_recent_with_zero_refresh(setup_core: Path) -> None: # Mock stat to return a time 10 seconds ago mock_stat = MagicMock() - mock_stat.st_ctime = time.time() - 10 + mock_stat.st_mtime = time.time() - 10 with patch.object(Path, "stat", return_value=mock_stat): refresh = TimePeriod(seconds=0) result = external_files.is_file_recent(test_file, refresh) diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index 690c47c183..62d2344069 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -1,8 +1,8 @@ """Tests for git.py module.""" -from datetime import datetime, timedelta import os from pathlib import Path +import time from typing import Any from unittest.mock import Mock, patch @@ -34,9 +34,9 @@ def _setup_old_repo(repo_dir: Path, days_old: int = 2) -> None: # Create FETCH_HEAD file with old timestamp fetch_head = git_dir / "FETCH_HEAD" fetch_head.write_text("test") - old_time = datetime.now() - timedelta(days=days_old) + old_time = time.time() - days_old * 86400 fetch_head.touch() - os.utime(fetch_head, (old_time.timestamp(), old_time.timestamp())) + os.utime(fetch_head, (old_time, old_time)) def _get_git_command_type(cmd: list[str]) -> str | None: @@ -285,10 +285,10 @@ def test_clone_or_update_with_refresh_updates_old_repo( # Create FETCH_HEAD file with old timestamp (2 days ago) fetch_head = git_dir / "FETCH_HEAD" fetch_head.write_text("test") - old_time = datetime.now() - timedelta(days=2) + old_time = time.time() - 2 * 86400 fetch_head.touch() # Create the file # Set modification time to 2 days ago - os.utime(fetch_head, (old_time.timestamp(), old_time.timestamp())) + os.utime(fetch_head, (old_time, old_time)) # Mock git command responses mock_run_git_command.return_value = "abc123" # SHA for rev-parse @@ -333,10 +333,10 @@ def test_clone_or_update_with_refresh_skips_fresh_repo( # Create FETCH_HEAD file with recent timestamp (1 hour ago) fetch_head = git_dir / "FETCH_HEAD" fetch_head.write_text("test") - recent_time = datetime.now() - timedelta(hours=1) + recent_time = time.time() - 3600 fetch_head.touch() # Create the file # Set modification time to 1 hour ago - os.utime(fetch_head, (recent_time.timestamp(), recent_time.timestamp())) + os.utime(fetch_head, (recent_time, recent_time)) # Call with refresh=1d (1 day) refresh = TimePeriodSeconds(days=1) @@ -409,10 +409,10 @@ def test_clone_or_update_with_none_refresh_always_updates( # Create FETCH_HEAD file with very recent timestamp (1 second ago) fetch_head = git_dir / "FETCH_HEAD" fetch_head.write_text("test") - recent_time = datetime.now() - timedelta(seconds=1) + recent_time = time.time() - 1 fetch_head.touch() # Create the file # Set modification time to 1 second ago - os.utime(fetch_head, (recent_time.timestamp(), recent_time.timestamp())) + os.utime(fetch_head, (recent_time, recent_time)) # Mock git command responses mock_run_git_command.return_value = "abc123" # SHA for rev-parse diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index ea37492cf4..b3f8a05605 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -576,8 +576,8 @@ def test_esphome_storage_json_last_update_check_property() -> None: assert result.hour == 10 assert result.minute == 30 - # Test setter - new_date = datetime(2024, 2, 20, 15, 45, 30) + # Test setter — naive datetime matches the storage round-trip format. + new_date = datetime(2024, 2, 20, 15, 45, 30) # noqa: DTZ001 storage.last_update_check = new_date assert storage.last_update_check_str == "2024-02-20T15:45:30" From 52ead52ef29fd01feabf6c6826b10b5f4fda200c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 15:29:54 -0500 Subject: [PATCH 0232/1815] [core] Enable ruff PGH (pygrep-hooks) lint family (#16651) --- esphome/components/esp32/__init__.py | 4 ++-- esphome/components/host/__init__.py | 2 +- esphome/components/libretiny/__init__.py | 2 +- .../libretiny/generate_components.py | 18 +++++++++++++----- esphome/components/light/__init__.py | 2 +- esphome/components/logger/__init__.py | 2 +- esphome/components/lvgl/helpers.py | 2 -- esphome/components/nrf52/__init__.py | 2 +- esphome/components/rp2040/__init__.py | 2 +- pyproject.toml | 1 + 10 files changed, 22 insertions(+), 15 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index e3bff8f934..7b94a26f54 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -56,7 +56,7 @@ from esphome.types import ConfigType from esphome.writer import clean_build, clean_cmake_cache from .boards import BOARDS, STANDARD_BOARDS -from .const import ( # noqa +from .const import ( KEY_ARDUINO_LIBRARIES, KEY_BOARD, KEY_COMPONENTS, @@ -86,7 +86,7 @@ from .const import ( # noqa ) # force import gpio to register pin schema -from .gpio import esp32_pin_to_code # noqa +from .gpio import esp32_pin_to_code # noqa: F401 _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["preferences"] diff --git a/esphome/components/host/__init__.py b/esphome/components/host/__init__.py index 8adbfb02ec..50deb1acf6 100644 --- a/esphome/components/host/__init__.py +++ b/esphome/components/host/__init__.py @@ -14,7 +14,7 @@ from esphome.core import CORE from .const import KEY_HOST # force import gpio to register pin schema -from .gpio import host_pin_to_code # noqa +from .gpio import host_pin_to_code # noqa: F401 CODEOWNERS = ["@esphome/core", "@clydebarrow"] AUTO_LOAD = ["network", "preferences"] diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index d1f1042501..afe0360c22 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -28,7 +28,7 @@ from esphome.core.config import BOARD_MAX_LENGTH from esphome.helpers import copy_file_if_changed from esphome.storage_json import StorageJSON -from . import gpio # noqa +from . import gpio # noqa: F401 from .const import ( COMPONENT_BK72XX, CONF_GPIO_RECOVER, diff --git a/esphome/components/libretiny/generate_components.py b/esphome/components/libretiny/generate_components.py index d5437895a6..6ca16f277f 100644 --- a/esphome/components/libretiny/generate_components.py +++ b/esphome/components/libretiny/generate_components.py @@ -1,7 +1,7 @@ # Copyright (c) Kuba Szczodrzyński 2023-06-01. # pylint: skip-file -# flake8: noqa +# ruff: noqa: C408, I001 import json import re @@ -313,8 +313,12 @@ def write_const( # build component constants comp_str = "\n".join(f'COMPONENT_{f} = "{f.lower()}"' for f in components) # replace the 2nd regex group only - repl = lambda m: m.group(1) + comp_str + m.group(3) - code = re.sub(comp_regex, repl, code, flags=re.DOTALL | re.MULTILINE) + code = re.sub( + comp_regex, + lambda m: m.group(1) + comp_str + m.group(3), + code, + flags=re.DOTALL | re.MULTILINE, + ) # regex for finding the family list block fam_regex = r"(# FAMILIES.+?\n)(.*?)(\n# FAMILIES)" @@ -337,8 +341,12 @@ def write_const( ] var_str = "\n".join(fam_lines) # replace the 2nd regex group only - repl = lambda m: m.group(1) + var_str + m.group(3) - code = re.sub(fam_regex, repl, code, flags=re.DOTALL | re.MULTILINE) + code = re.sub( + fam_regex, + lambda m: m.group(1) + var_str + m.group(3), + code, + flags=re.DOTALL | re.MULTILINE, + ) # format with black code = format_str(code, mode=FileMode()) diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 68d9f85af2..7c4d7ed431 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -58,7 +58,7 @@ from .effects import ( RGB_EFFECTS, validate_effects, ) -from .types import ( # noqa +from .types import ( # noqa: F401 AddressableLight, AddressableLightState, ColorMode, diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index c6c440564a..5f160352cc 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -514,7 +514,7 @@ def validate_printf(value): (?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size [cCdiouxXeEfgGaAnpsSZ] # type ) - """ # noqa + """ matches = re.findall(cfmt, value[CONF_FORMAT], flags=re.VERBOSE) if len(matches) != len(value[CONF_ARGS]): raise cv.Invalid( diff --git a/esphome/components/lvgl/helpers.py b/esphome/components/lvgl/helpers.py index 6f70a1e3bd..3da8643308 100644 --- a/esphome/components/lvgl/helpers.py +++ b/esphome/components/lvgl/helpers.py @@ -6,7 +6,6 @@ from esphome.const import CONF_ARGS, CONF_FORMAT CONF_IF_NAN = "if_nan" -# noqa f_regex = re.compile( r""" ( # start of capture group 1 @@ -20,7 +19,6 @@ f_regex = re.compile( """, flags=re.VERBOSE, ) -# noqa c_regex = re.compile( r""" ( # start of capture group 1 diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 2aba208af7..4ba1ab5d4d 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -65,7 +65,7 @@ from .const import ( ) # force import gpio to register pin schema -from .gpio import nrf52_pin_to_code # noqa +from .gpio import nrf52_pin_to_code # noqa: F401 CODEOWNERS = ["@tomaszduda23"] AUTO_LOAD = ["zephyr", "preferences"] diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 830c961476..6ec0ee08b8 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -50,7 +50,7 @@ from .const import ( ) # force import gpio to register pin schema -from .gpio import rp2040_pin_to_code # noqa +from .gpio import rp2040_pin_to_code # noqa: F401 _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@jesserockz"] diff --git a/pyproject.toml b/pyproject.toml index d92c7ba894..d2f30ea3d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -127,6 +127,7 @@ select = [ "LOG", # flake8-logging "NPY", # numpy-specific rules "PERF", # performance + "PGH", # pygrep-hooks "PIE", # flake8-pie "PL", # pylint "PTH", # flake8-use-pathlib From 62b3b1cc7509e2b7a1ce5f26ebd08abe4fc8b495 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 27 May 2026 06:00:08 +0930 Subject: [PATCH 0233/1815] [lvgl] Support `rounded` property for meter arcs (#16669) --- esphome/components/lvgl/widgets/meter.py | 3 ++- tests/components/lvgl/lvgl-package.yaml | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/lvgl/widgets/meter.py b/esphome/components/lvgl/widgets/meter.py index 62ea14bdda..e2407fad5a 100644 --- a/esphome/components/lvgl/widgets/meter.py +++ b/esphome/components/lvgl/widgets/meter.py @@ -184,6 +184,7 @@ INDICATOR_ARC_SCHEMA = cv.Schema( cv.Optional(CONF_START_VALUE): lv_float, cv.Optional(CONF_END_VALUE): lv_float, cv.Optional(CONF_OPA, default=1.0): opacity, + cv.Optional(CONF_ROUNDED, default=False): cv.boolean, } ).add_extra(cv.has_at_most_one_key(CONF_VALUE, CONF_START_VALUE)) @@ -417,7 +418,7 @@ class MeterType(WidgetType): "arc_width": v[CONF_WIDTH], "arc_color": v[CONF_COLOR], "arc_opa": v[CONF_OPA], - "arc_rounded": v.get("arc_rounded", False), + "arc_rounded": v[CONF_ROUNDED], } if CONF_R_MOD in v: get_warnings().add( diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 0f4b961297..7af058e6b8 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -1313,6 +1313,7 @@ lvgl: width: 6 start_value: 0 end_value: 360 + rounded: true - id: page3 layout: Horizontal pad_all: 6px From 4d908798bcf41b8cbe151a2d5c1aa753d7aaaa40 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 26 May 2026 18:45:50 -0400 Subject: [PATCH 0234/1815] [core] Remove deprecated custom_components folder loading (#16679) --- esphome/config.py | 1 - esphome/loader.py | 13 ------------- esphome/writer.py | 4 ++-- 3 files changed, 2 insertions(+), 16 deletions(-) diff --git a/esphome/config.py b/esphome/config.py index 79d0d2b02b..9da39a387b 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -1005,7 +1005,6 @@ def validate_config( CORE.skip_external_update = skip_external_update loader.clear_component_meta_finders() - loader.install_custom_components_meta_finder() # 0. Load packages if CONF_PACKAGES in config: diff --git a/esphome/loader.py b/esphome/loader.py index c57c09274e..8823d82fc1 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -12,7 +12,6 @@ from types import ModuleType from typing import TYPE_CHECKING, Any from esphome.const import SOURCE_FILE_EXTENSIONS -from esphome.core import CORE from esphome.types import ConfigType if TYPE_CHECKING: @@ -206,18 +205,6 @@ def install_meta_finder( sys.meta_path.insert(0, ComponentMetaFinder(components_path, allowed_components)) -def install_custom_components_meta_finder(): - # Remove before 2026.6.0 - custom_components_dir = (Path(CORE.config_dir) / "custom_components").resolve() - if custom_components_dir.is_dir() and any(custom_components_dir.iterdir()): - _LOGGER.warning( - "The 'custom_components' folder is deprecated and will be removed in 2026.6.0. " - "Please use 'external_components' instead. " - "See https://esphome.io/components/external_components.html for more information." - ) - install_meta_finder(custom_components_dir) - - def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None: if domain in _COMPONENT_CACHE: return _COMPONENT_CACHE[domain] diff --git a/esphome/writer.py b/esphome/writer.py index ab014c5daa..ef7cbf5ac4 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -200,8 +200,8 @@ ESPHome automatically populates the build directory, and any changes to this directory will be removed the next time esphome is run. -For modifying esphome's core files, please use a development esphome install, -the custom_components folder or the external_components feature. +For modifying esphome's core files, please use a development esphome install +or the external_components feature. """ From b71d445e7963303f1ab76b6dd9ccdebf609115b1 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 26 May 2026 18:46:45 -0400 Subject: [PATCH 0235/1815] [core] Remove deprecated const char* mark_failed/status_set_error (#16680) --- esphome/core/component.cpp | 33 +++++++-------------------------- esphome/core/component.h | 17 ----------------- 2 files changed, 7 insertions(+), 43 deletions(-) diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index e33652482e..2d80301897 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -32,10 +32,7 @@ static const char *const TAG = "component"; namespace { struct ComponentErrorMessage { const Component *component; - const char *message; - // Track if message is flash pointer (needs LOG_STR_ARG) or RAM pointer - // Remove before 2026.6.0 when deprecated const char* API is removed - bool is_flash_ptr; + const LogString *message; }; #ifdef USE_SETUP_PRIORITY_OVERRIDE @@ -56,9 +53,8 @@ std::vector *setup_priority_overrides = nullptr; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) std::vector *component_error_messages = nullptr; -// Helper to store error messages - reduces duplication between deprecated and new API -// Remove before 2026.6.0 when deprecated const char* API is removed -void store_component_error_message(const Component *component, const char *message, bool is_flash_ptr) { +// Helper to store error messages +void store_component_error_message(const Component *component, const LogString *message) { // Lazy allocate the error messages vector if needed if (!component_error_messages) { component_error_messages = new std::vector(); @@ -67,12 +63,11 @@ void store_component_error_message(const Component *component, const char *messa for (auto &entry : *component_error_messages) { if (entry.component == component) { entry.message = message; - entry.is_flash_ptr = is_flash_ptr; return; } } // Add new error message - component_error_messages->emplace_back(ComponentErrorMessage{component, message, is_flash_ptr}); + component_error_messages->emplace_back(ComponentErrorMessage{component, message}); } } // namespace @@ -209,21 +204,17 @@ void Component::call_dump_config_() { this->dump_config(); if (this->is_failed()) { // Look up error message from global vector - const char *error_msg = nullptr; - bool is_flash_ptr = false; + const LogString *error_msg = nullptr; if (component_error_messages) { for (const auto &entry : *component_error_messages) { if (entry.component == this) { error_msg = entry.message; - is_flash_ptr = entry.is_flash_ptr; break; } } } - // Log with appropriate format based on pointer type ESP_LOGE(TAG, " %s is marked FAILED: %s", LOG_STR_ARG(this->get_component_log_str()), - error_msg ? (is_flash_ptr ? LOG_STR_ARG((const LogString *) error_msg) : error_msg) - : LOG_STR_LITERAL("unspecified")); + error_msg ? LOG_STR_ARG(error_msg) : LOG_STR_LITERAL("unspecified")); } } @@ -390,23 +381,13 @@ void Component::status_set_warning(const LogString *message) { message ? LOG_STR_ARG(message) : LOG_STR_LITERAL("unspecified")); } void Component::status_set_error() { this->status_set_error((const LogString *) nullptr); } -void Component::status_set_error(const char *message) { - if (!this->set_status_flag_(STATUS_LED_ERROR)) - return; - ESP_LOGE(TAG, "%s set Error flag: %s", LOG_STR_ARG(this->get_component_log_str()), - message ? message : LOG_STR_LITERAL("unspecified")); - if (message != nullptr) { - store_component_error_message(this, message, false); - } -} void Component::status_set_error(const LogString *message) { if (!this->set_status_flag_(STATUS_LED_ERROR)) return; ESP_LOGE(TAG, "%s set Error flag: %s", LOG_STR_ARG(this->get_component_log_str()), message ? LOG_STR_ARG(message) : LOG_STR_LITERAL("unspecified")); if (message != nullptr) { - // Store the LogString pointer directly (safe because LogString is always in flash/static memory) - store_component_error_message(this, LOG_STR_ARG(message), true); + store_component_error_message(this, message); } } void Component::status_clear_warning_slow_path_() { diff --git a/esphome/core/component.h b/esphome/core/component.h index 5baf795ca6..ff10f1a8f1 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -220,18 +220,6 @@ class Component { */ void mark_failed(); - // Remove before 2026.6.0 - ESPDEPRECATED("Use mark_failed(LOG_STR(\"static string literal\")) instead. Do NOT use .c_str() from temporary " - "strings. Will stop working in 2026.6.0", - "2025.12.0") - void mark_failed(const char *message) { -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - this->status_set_error(message); -#pragma GCC diagnostic pop - this->mark_failed(); - } - void mark_failed(const LogString *message) { this->status_set_error(message); this->mark_failed(); @@ -296,11 +284,6 @@ class Component { void status_set_warning(const LogString *message); void status_set_error(); // Set error flag without message - // Remove before 2026.6.0 - ESPDEPRECATED("Use status_set_error(LOG_STR(\"static string literal\")) instead. Do NOT use .c_str() from temporary " - "strings. Will stop working in 2026.6.0", - "2025.12.0") - void status_set_error(const char *message); void status_set_error(const LogString *message); void status_clear_warning() { From 171ded35a58728ef48cb65198bcd457ea92f1fa9 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 26 May 2026 18:47:16 -0400 Subject: [PATCH 0236/1815] [core] Remove cv.only_with_esp_idf and CORE.using_esp_idf (#16681) --- esphome/config_validation.py | 10 ---------- esphome/core/__init__.py | 9 --------- script/ci-custom.py | 13 ------------- 3 files changed, 32 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index f826b254ac..2f09fdc105 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -857,16 +857,6 @@ only_on_rp2040 = only_on(PLATFORM_RP2040) only_with_arduino = only_with_framework(Framework.ARDUINO) -def only_with_esp_idf(obj): - """Deprecated: use only_on_esp32 instead.""" - _LOGGER.warning( - "cv.only_with_esp_idf was deprecated in 2026.1, will change behavior in 2026.6. " - "ESP32 Arduino builds on top of ESP-IDF, so ESP-IDF features are available in both frameworks. " - "Use cv.only_on_esp32 and/or cv.only_with_arduino instead." - ) - return only_with_framework(Framework.ESP_IDF)(obj) - - # Adapted from: # https://github.com/alecthomas/voluptuous/issues/115#issuecomment-144464666 def has_at_least_one_key(*keys): diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 182be38b18..df8fd0a756 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -859,15 +859,6 @@ class EsphomeCore: def using_arduino(self): return self.target_framework == "arduino" - @property - def using_esp_idf(self): - _LOGGER.warning( - "CORE.using_esp_idf was deprecated in 2026.1, will change behavior in 2026.6. " - "ESP32 Arduino builds on top of ESP-IDF, so ESP-IDF features are available in both frameworks. " - "Use CORE.is_esp32 and/or CORE.using_arduino instead." - ) - return self.target_framework == "esp-idf" - @property def using_toolchain_esp_idf(self): return self.toolchain == Toolchain.ESP_IDF diff --git a/script/ci-custom.py b/script/ci-custom.py index 1ac13e18f7..78ff6cf781 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -693,19 +693,6 @@ def lint_esphome_h(fname, line, col, content): ) -@lint_content_find_check( - "CORE.using_esp_idf", - include=py_include, - exclude=["esphome/core/__init__.py", "script/ci-custom.py"], -) -def lint_using_esp_idf_deprecated(fname, line, col, content): - return ( - f"{highlight('CORE.using_esp_idf')} is deprecated and will change behavior in 2026.6. " - "ESP32 Arduino builds on top of ESP-IDF, so ESP-IDF features are available in both frameworks. " - f"Please use {highlight('CORE.is_esp32')} and/or {highlight('CORE.using_arduino')} instead." - ) - - @lint_content_check(include=["*.h"], exclude=["esphome/core/entity_types.h"]) def lint_pragma_once(fname, content): if "#pragma once" not in content: From fb0b73980bf188d5530e62c02dda451560164c2b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 26 May 2026 18:47:40 -0400 Subject: [PATCH 0237/1815] [wifi] Default ESP8266 min_auth_mode to WPA2 (#16682) --- esphome/components/wifi/__init__.py | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index f9cb391442..e5e57cc97d 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -326,23 +326,9 @@ def validate_variant(_): def _apply_min_auth_mode_default(config): - """Apply platform-specific default for min_auth_mode and warn ESP8266 users.""" - # Only apply defaults for platforms that support min_auth_mode + """Apply platform-specific default for min_auth_mode.""" if CONF_MIN_AUTH_MODE not in config and (CORE.is_esp8266 or CORE.is_esp32): - if CORE.is_esp8266: - _LOGGER.warning( - "The minimum WiFi authentication mode (wifi -> min_auth_mode) is not set. " - "This controls the weakest encryption your device will accept when connecting to WiFi. " - "Currently defaults to WPA (less secure), but will change to WPA2 (more secure) in 2026.6.0. " - "WPA uses TKIP encryption which has known security vulnerabilities and should be avoided. " - "WPA2 uses AES encryption which is significantly more secure. " - "To silence this warning, explicitly set min_auth_mode under 'wifi:'. " - "If your router supports WPA2 or WPA3, set 'min_auth_mode: WPA2'. " - "If your router only supports WPA, set 'min_auth_mode: WPA'." - ) - config[CONF_MIN_AUTH_MODE] = VALIDATE_WIFI_MIN_AUTH_MODE("WPA") - elif CORE.is_esp32: - config[CONF_MIN_AUTH_MODE] = VALIDATE_WIFI_MIN_AUTH_MODE("WPA2") + config[CONF_MIN_AUTH_MODE] = VALIDATE_WIFI_MIN_AUTH_MODE("WPA2") return config From eb1196c6b22ffe8da8c273d52bba546489ff0d04 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 26 May 2026 18:48:17 -0400 Subject: [PATCH 0238/1815] [nfc] Remove deprecated heap-allocating format helpers (#16684) --- esphome/components/nfc/nfc.cpp | 11 ----------- esphome/components/nfc/nfc.h | 7 ------- 2 files changed, 18 deletions(-) diff --git a/esphome/components/nfc/nfc.cpp b/esphome/components/nfc/nfc.cpp index 99e476dbdf..76a391f1de 100644 --- a/esphome/components/nfc/nfc.cpp +++ b/esphome/components/nfc/nfc.cpp @@ -15,17 +15,6 @@ char *format_bytes_to(char *buffer, std::span bytes) { return format_hex_pretty_to(buffer, FORMAT_BYTES_BUFFER_SIZE, bytes.data(), bytes.size(), ' '); } -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" -// Deprecated wrappers intentionally use heap-allocating version for backward compatibility -std::string format_uid(std::span uid) { - return format_hex_pretty(uid.data(), uid.size(), '-', false); // NOLINT -} -std::string format_bytes(std::span bytes) { - return format_hex_pretty(bytes.data(), bytes.size(), ' ', false); // NOLINT -} -#pragma GCC diagnostic pop - uint8_t guess_tag_type(uint8_t uid_length) { if (uid_length == 4) { return TAG_TYPE_MIFARE_CLASSIC; diff --git a/esphome/components/nfc/nfc.h b/esphome/components/nfc/nfc.h index 42ef993913..36b27ce5f6 100644 --- a/esphome/components/nfc/nfc.h +++ b/esphome/components/nfc/nfc.h @@ -63,13 +63,6 @@ static constexpr size_t FORMAT_BYTES_BUFFER_SIZE = 192; /// Format bytes to buffer with ' ' separator (e.g., "04 11 22 33"). Returns buffer for inline use. char *format_bytes_to(char *buffer, std::span bytes); -// Remove before 2026.6.0 -ESPDEPRECATED("Use format_uid_to() with stack buffer instead. Removed in 2026.6.0", "2025.12.0") -std::string format_uid(std::span uid); -// Remove before 2026.6.0 -ESPDEPRECATED("Use format_bytes_to() with stack buffer instead. Removed in 2026.6.0", "2025.12.0") -std::string format_bytes(std::span bytes); - uint8_t guess_tag_type(uint8_t uid_length); int8_t get_mifare_classic_ndef_start_index(std::vector &data); bool decode_mifare_classic_tlv(std::vector &data, uint32_t &message_length, uint8_t &message_start_index); From 6c4a8a324515f9d6dc3280ed4e82993022c5138b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 26 May 2026 18:49:44 -0400 Subject: [PATCH 0239/1815] [dsmr] Force BearSSL on ESP8266 to avoid mbedtls link failure (#16686) --- esphome/components/dsmr/dsmr.h | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/components/dsmr/dsmr.h b/esphome/components/dsmr/dsmr.h index 626a389c1f..e55db9f976 100644 --- a/esphome/components/dsmr/dsmr.h +++ b/esphome/components/dsmr/dsmr.h @@ -16,9 +16,14 @@ #include #include +// On ESP8266 Arduino, BearSSL is the native crypto. The mbedtls headers can +// still be in scope when a sibling component (e.g. wireguard) pulls in +// esp_mbedtls_esp8266, but that build leaves MBEDTLS_GCM_C disabled so the +// gcm.h symbols are unresolved at link time. Force BearSSL on ESP8266 to +// avoid that linker error. #if __has_include() #include -#elif __has_include() +#elif !defined(USE_ESP8266) && __has_include() #if __has_include() #include #endif @@ -33,7 +38,7 @@ namespace esphome::dsmr { #if __has_include() using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmTfPsa; -#elif __has_include() +#elif !defined(USE_ESP8266) && __has_include() using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmMbedTls; #else using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmBearSsl; From f728cb437377ae32c9c2b0b99ca9c55d6c8d9925 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 26 May 2026 18:50:20 -0400 Subject: [PATCH 0240/1815] [core] Remove deprecated seq/gens templates (#16685) --- esphome/core/automation.h | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/esphome/core/automation.h b/esphome/core/automation.h index 468ea3b382..ea522a4d2d 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -13,27 +13,6 @@ namespace esphome { -// C++20 std::index_sequence is now used for tuple unpacking -// Legacy seq<>/gens<> pattern deprecated but kept for backwards compatibility -// https://stackoverflow.com/questions/7858817/unpacking-a-tuple-to-call-a-matching-function-pointer/7858971#7858971 -// Remove before 2026.6.0 -// NOLINTBEGIN(readability-identifier-naming) -#if defined(__GNUC__) || defined(__clang__) -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" -#endif - -template struct ESPDEPRECATED("Use std::index_sequence instead. Removed in 2026.6.0", "2025.12.0") seq {}; -template -struct ESPDEPRECATED("Use std::make_index_sequence instead. Removed in 2026.6.0", "2025.12.0") gens - : gens {}; -template struct gens<0, S...> { using type = seq; }; - -#if defined(__GNUC__) || defined(__clang__) -#pragma GCC diagnostic pop -#endif -// NOLINTEND(readability-identifier-naming) - /// Function-pointer-only templatable storage (4 bytes on 32-bit). /// Used by the TEMPLATABLE_VALUE macro for codegen-managed fields. /// Codegen wraps constants in stateless lambdas so only a function pointer is needed. From e174c44b283f0ed6f3748f0c796b9ee62922bdb4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 26 May 2026 19:15:25 -0400 Subject: [PATCH 0241/1815] [neopixelbus] Deprecate on ESP32 (#16676) --- esphome/components/neopixelbus/light.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/esphome/components/neopixelbus/light.py b/esphome/components/neopixelbus/light.py index 943fd141f6..2e18688af0 100644 --- a/esphome/components/neopixelbus/light.py +++ b/esphome/components/neopixelbus/light.py @@ -1,3 +1,5 @@ +import logging + from esphome import pins import esphome.codegen as cg from esphome.components import light @@ -22,6 +24,7 @@ from esphome.const import ( Framework, ) from esphome.core import CORE +from esphome.types import ConfigType from ._methods import ( METHOD_BIT_BANG, @@ -34,6 +37,8 @@ from ._methods import ( ) from .const import CHIP_TYPES, CONF_ASYNC, CONF_BUS, ONE_WIRE_CHIPS +_LOGGER = logging.getLogger(__name__) + neopixelbus_ns = cg.esphome_ns.namespace("neopixelbus") NeoPixelBusLightOutputBase = neopixelbus_ns.class_( "NeoPixelBusLightOutputBase", light.AddressableLight @@ -134,6 +139,17 @@ def _validate(config): return config +def _warn_esp32_deprecated(config: ConfigType) -> ConfigType: + if CORE.is_esp32: + _LOGGER.warning( + "'neopixelbus' on ESP32 is deprecated. The upstream library " + "(makuna/NeoPixelBus) is no longer actively maintained. Migrate " + "to 'esp32_rmt_led_strip'. Removal is targeted for 2027.1 but " + "may happen sooner once ESPHome moves to ESP-IDF 6." + ) + return config + + def _validate_method(value): if value is None: # default method is determined afterwards because it depends on the chip type chosen @@ -195,6 +211,7 @@ CONFIG_SCHEMA = cv.All( ).extend(cv.COMPONENT_SCHEMA), _choose_default_method, _validate, + _warn_esp32_deprecated, ) From a6ef67aa65892d7312ba7191bbe41742b2d0c80a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 26 May 2026 19:34:52 -0400 Subject: [PATCH 0242/1815] [text_sensor] Remove deprecated public raw_state member (#16683) --- .../components/text_sensor/text_sensor.cpp | 19 ++++++------------- esphome/components/text_sensor/text_sensor.h | 10 ++-------- 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/esphome/components/text_sensor/text_sensor.cpp b/esphome/components/text_sensor/text_sensor.cpp index 31543117b8..d2483619a6 100644 --- a/esphome/components/text_sensor/text_sensor.cpp +++ b/esphome/components/text_sensor/text_sensor.cpp @@ -39,16 +39,13 @@ void TextSensor::publish_state(const char *state, size_t len) { #ifdef USE_TEXT_SENSOR_FILTER } else { // Has filters: need separate raw storage -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" // Only assign if changed to avoid heap allocation - if (len != this->raw_state.size() || memcmp(state, this->raw_state.data(), len) != 0) { - this->raw_state.assign(state, len); + if (len != this->raw_state_.size() || memcmp(state, this->raw_state_.data(), len) != 0) { + this->raw_state_.assign(state, len); } - this->raw_callback_.call(this->raw_state); - ESP_LOGV(TAG, "'%s': Received new state %s", this->name_.c_str(), this->raw_state.c_str()); - this->filter_list_->input(this->raw_state); -#pragma GCC diagnostic pop + this->raw_callback_.call(this->raw_state_); + ESP_LOGV(TAG, "'%s': Received new state %s", this->name_.c_str(), this->raw_state_.c_str()); + this->filter_list_->input(this->raw_state_); } #endif } @@ -89,11 +86,7 @@ const std::string &TextSensor::get_state() const { return this->state; } const std::string &TextSensor::get_raw_state() const { #ifdef USE_TEXT_SENSOR_FILTER if (this->filter_list_ != nullptr) { - // Suppress deprecation warning - get_raw_state() is the replacement API -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return this->raw_state; -#pragma GCC diagnostic pop + return this->raw_state_; } #endif return this->state; // No filters, raw == filtered diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index 3f69e91c8d..aa48781f41 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -29,19 +29,12 @@ class TextSensor : public EntityBase { public: std::string state; -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - /// @deprecated Use get_raw_state() instead. This member will be removed in ESPHome 2026.6.0. - ESPDEPRECATED("Use get_raw_state() instead of .raw_state. Will be removed in 2026.6.0", "2025.12.0") - std::string raw_state; - TextSensor() = default; ~TextSensor() = default; -#pragma GCC diagnostic pop /// Getter-syntax for .state. const std::string &get_state() const; - /// Getter-syntax for .raw_state + /// Returns the raw (pre-filter) state. const std::string &get_raw_state() const; void publish_state(const std::string &state); @@ -84,6 +77,7 @@ class TextSensor : public EntityBase { /// Notify frontend that state has changed (assumes this->state is already set) void notify_frontend_(); #ifdef USE_TEXT_SENSOR_FILTER + std::string raw_state_; ///< Backing storage for the raw (pre-filter) value. Only used when a filter is attached. LazyCallbackManager raw_callback_; ///< Storage for raw state callbacks. #endif LazyCallbackManager callback_; ///< Storage for filtered state callbacks. From 91ead4ff543e6d46f5e6aa987e7f4206e6de868a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 19:16:47 -0500 Subject: [PATCH 0243/1815] [core] Mark canonical sensitive fields with cv.sensitive (#16677) --- esphome/components/api/__init__.py | 2 +- esphome/components/esphome/ota/__init__.py | 2 +- esphome/components/http_request/ota/__init__.py | 2 +- esphome/components/mqtt/__init__.py | 2 +- esphome/components/web_server/__init__.py | 4 ++-- esphome/components/wifi/__init__.py | 8 ++++---- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index ca74483a2b..932702d47a 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -234,7 +234,7 @@ ACTIONS_SCHEMA = automation.validate_automation( ENCRYPTION_SCHEMA = cv.Schema( { - cv.Optional(CONF_KEY): validate_encryption_key, + cv.Optional(CONF_KEY): cv.sensitive(validate_encryption_key), } ) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index f7793b1493..66a33e1935 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -133,7 +133,7 @@ CONFIG_SCHEMA = cv.All( host=8082, ): cv.port, cv.Optional(CONF_ALLOW_PARTITION_ACCESS, default=False): cv.boolean, - cv.Optional(CONF_PASSWORD): cv.string, + cv.Optional(CONF_PASSWORD): cv.sensitive(), cv.Optional(CONF_NUM_ATTEMPTS): cv.invalid( f"'{CONF_SAFE_MODE}' (and its related configuration variables) has moved from 'ota' to its own component. See https://esphome.io/components/safe_mode" ), diff --git a/esphome/components/http_request/ota/__init__.py b/esphome/components/http_request/ota/__init__.py index fb59e51943..1bb54599dc 100644 --- a/esphome/components/http_request/ota/__init__.py +++ b/esphome/components/http_request/ota/__init__.py @@ -57,7 +57,7 @@ OTA_HTTP_REQUEST_FLASH_ACTION_SCHEMA = cv.All( cv.Optional(CONF_MD5): cv.templatable( cv.All(cv.string, cv.Length(min=32, max=32)) ), - cv.Optional(CONF_PASSWORD): cv.templatable(cv.string), + cv.Optional(CONF_PASSWORD): cv.sensitive(cv.templatable(cv.string)), cv.Optional(CONF_USERNAME): cv.templatable(cv.string), cv.Required(CONF_URL): cv.templatable(cv.url), } diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index cb6b9d144f..86bba11a60 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -232,7 +232,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean, cv.Optional(CONF_PORT, default=1883): cv.port, cv.Optional(CONF_USERNAME, default=""): cv.string, - cv.Optional(CONF_PASSWORD, default=""): cv.string, + cv.Optional(CONF_PASSWORD, default=""): cv.sensitive(), cv.Optional(CONF_CLEAN_SESSION, default=False): cv.boolean, cv.Optional(CONF_CLIENT_ID): cv.string, cv.SplitDefault(CONF_IDF_SEND_ASYNC, esp32=False): cv.All( diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 99a9b7518c..fd380a38dd 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -193,8 +193,8 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_USERNAME): cv.All( cv.string_strict, cv.Length(min=1) ), - cv.Required(CONF_PASSWORD): cv.All( - cv.string_strict, cv.Length(min=1) + cv.Required(CONF_PASSWORD): cv.sensitive( + cv.All(cv.string_strict, cv.Length(min=1)) ), } ), diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index e5e57cc97d..4e7dcc82e5 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -251,7 +251,7 @@ EAP_AUTH_SCHEMA = cv.All( { cv.Optional(CONF_IDENTITY): cv.string_strict, cv.Optional(CONF_USERNAME): cv.string_strict, - cv.Optional(CONF_PASSWORD): cv.string_strict, + cv.Optional(CONF_PASSWORD): cv.sensitive(cv.string_strict), cv.Optional(CONF_CERTIFICATE_AUTHORITY): wpa2_eap.validate_certificate, cv.SplitDefault(CONF_TTLS_PHASE_2, esp32="mschapv2"): cv.All( cv.enum(TTLS_PHASE_2), cv.only_on_esp32 @@ -272,7 +272,7 @@ WIFI_NETWORK_BASE = cv.Schema( { cv.GenerateID(): cv.declare_id(WiFiAP), cv.Optional(CONF_SSID): cv.ssid, - cv.Optional(CONF_PASSWORD): validate_password, + cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password), cv.Optional(CONF_CHANNEL): validate_channel, cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA, } @@ -435,7 +435,7 @@ CONFIG_SCHEMA = cv.All( cv.ensure_list(WIFI_NETWORK_STA), cv.Length(max=MAX_WIFI_NETWORKS) ), cv.Optional(CONF_SSID): cv.ssid, - cv.Optional(CONF_PASSWORD): validate_password, + cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password), cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA, cv.Optional(CONF_EAP): EAP_AUTH_SCHEMA, cv.Optional(CONF_AP): wifi_network_ap, @@ -851,7 +851,7 @@ async def final_step(): cv.Schema( { cv.Required(CONF_SSID): cv.templatable(cv.ssid), - cv.Required(CONF_PASSWORD): cv.templatable(validate_password), + cv.Required(CONF_PASSWORD): cv.sensitive(cv.templatable(validate_password)), cv.Optional(CONF_SAVE, default=True): cv.templatable(cv.boolean), cv.Optional(CONF_TIMEOUT, default="30000ms"): cv.templatable( cv.positive_time_period_milliseconds From 87d0e24d194164e1988ba742cd75b32176cf6cf0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 19:57:29 -0500 Subject: [PATCH 0244/1815] Bump aioesphomeapi from 45.2.2 to 45.3.1 (#16688) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 45401a7995..14dddbb1aa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==45.2.2 +aioesphomeapi==45.3.1 zeroconf==0.149.16 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 8d19c55be2325d0a1c2dd06898ad4ec19c7c6602 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 19:58:13 -0500 Subject: [PATCH 0245/1815] Bump pytest-asyncio from 1.3.0 to 1.4.0 (#16687) Signed-off-by: dependabot[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 102a9cae6e..aad1da0807 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,7 +8,7 @@ pre-commit pytest==9.0.3 pytest-cov==7.1.0 pytest-mock==3.15.1 -pytest-asyncio==1.3.0 +pytest-asyncio==1.4.0 pytest-xdist==3.8.0 asyncmock==0.4.2 hypothesis==6.92.1 From 7463a15c7e4b5c897a8e210fdd43891e5465c8cf Mon Sep 17 00:00:00 2001 From: Ardumine <61353807+Ardumine@users.noreply.github.com> Date: Wed, 27 May 2026 08:43:38 +0100 Subject: [PATCH 0246/1815] [network] Add Zephyr IPv6 networking support for nRF52 (#16336) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: tomaszduda23 --- esphome/components/e131/e131.h | 2 + esphome/components/mdns/__init__.py | 1 + esphome/components/mdns/mdns_zephyr.cpp | 17 ++++++++ esphome/components/network/__init__.py | 16 +++++++ esphome/components/network/ip_address.h | 42 +++++++++++++++++-- .../prometheus/prometheus_handler.h | 4 +- esphome/components/statsd/statsd.cpp | 2 +- esphome/components/statsd/statsd.h | 4 +- esphome/components/sx1509/sx1509.h | 10 ++--- esphome/components/tca9555/tca9555.h | 7 ++-- .../components/wake_on_lan/wake_on_lan.cpp | 2 +- esphome/components/wake_on_lan/wake_on_lan.h | 4 +- .../web_server_base/web_server_base.cpp | 2 +- .../web_server_base/web_server_base.h | 4 +- esphome/core/defines.h | 2 +- .../network/test.nrf52-adafruit.yaml | 1 + .../components/network/test.nrf52-mcumgr.yaml | 1 + .../network/test.nrf52-xiao-ble.yaml | 1 + 18 files changed, 99 insertions(+), 23 deletions(-) create mode 100644 esphome/components/mdns/mdns_zephyr.cpp create mode 100644 tests/components/network/test.nrf52-adafruit.yaml create mode 100644 tests/components/network/test.nrf52-mcumgr.yaml create mode 100644 tests/components/network/test.nrf52-xiao-ble.yaml diff --git a/esphome/components/e131/e131.h b/esphome/components/e131/e131.h index bfcb0ca7f8..6574037efb 100644 --- a/esphome/components/e131/e131.h +++ b/esphome/components/e131/e131.h @@ -52,6 +52,8 @@ class E131Component : public esphome::Component { if (!this->udp_.parsePacket()) return -1; return this->udp_.read(buf, len); +#else + return -1; #endif } bool packet_(const uint8_t *data, size_t len, int &universe, E131Packet &packet); diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 2b25cf243d..2de67542b2 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -280,5 +280,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, + "mdns_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, } ) diff --git a/esphome/components/mdns/mdns_zephyr.cpp b/esphome/components/mdns/mdns_zephyr.cpp new file mode 100644 index 0000000000..0b2fd9e62b --- /dev/null +++ b/esphome/components/mdns/mdns_zephyr.cpp @@ -0,0 +1,17 @@ +#include "esphome/core/defines.h" +#if defined(USE_ZEPHYR) && defined(USE_MDNS) + +#include "mdns_component.h" +#include "esphome/core/log.h" + +namespace esphome::mdns { + +static const char *const TAG = "mdns.zephyr"; + +void MDNSComponent::setup() { ESP_LOGW(TAG, "mDNS is not implemented for Zephyr"); } + +void MDNSComponent::on_shutdown() {} + +} // namespace esphome::mdns + +#endif // USE_ZEPHYR && USE_MDNS diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 2818b8c93e..3bb14a05a7 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -4,6 +4,7 @@ import logging import esphome.codegen as cg from esphome.components.esp32 import add_idf_sdkconfig_option from esphome.components.psram import is_guaranteed as psram_is_guaranteed +from esphome.components.zephyr import zephyr_add_prj_conf import esphome.config_validation as cv from esphome.const import CONF_ENABLE_IPV6, CONF_ID, CONF_MIN_IPV6_ADDR_COUNT from esphome.core import CORE, CoroPriority, coroutine_with_priority @@ -117,6 +118,7 @@ CONFIG_SCHEMA = cv.Schema( esp8266=False, host=False, rp2040=False, + nrf52=True, ): cv.All( cv.boolean, cv.Any( @@ -127,6 +129,7 @@ CONFIG_SCHEMA = cv.Schema( esp8266_arduino=cv.Version(0, 0, 0), host=cv.Version(0, 0, 0), rp2040_arduino=cv.Version(0, 0, 0), + nrf52_zephyr=cv.Version(0, 0, 0), ), cv.boolean_false, ), @@ -205,6 +208,19 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_LWIP_TCP_RECVMBOX_SIZE", 64) add_idf_sdkconfig_option("CONFIG_LWIP_TCPIP_RECVMBOX_SIZE", 64) + if CORE.is_nrf52: + enable_ipv6 = config.get(CONF_ENABLE_IPV6, True) + if not enable_ipv6: + _LOGGER.warning( + "IPv6 cannot be disabled on nRF52 because the Zephyr IPAddress implementation is IPv6-only. " + "Forcing CONFIG_NET_IPV6=y." + ) + config[CONF_ENABLE_IPV6] = True + zephyr_add_prj_conf("NETWORKING", True) + zephyr_add_prj_conf("NET_IPV6", True) + zephyr_add_prj_conf("NET_TCP", True) + zephyr_add_prj_conf("NET_UDP", True) + if (enable_ipv6 := config.get(CONF_ENABLE_IPV6, None)) is not None: cg.add_define("USE_NETWORK_IPV6", enable_ipv6) if enable_ipv6: diff --git a/esphome/components/network/ip_address.h b/esphome/components/network/ip_address.h index c0e7b2886c..55bb2a1c89 100644 --- a/esphome/components/network/ip_address.h +++ b/esphome/components/network/ip_address.h @@ -5,13 +5,13 @@ #include #include #include +#include #include "esphome/core/helpers.h" #include "esphome/core/macros.h" #if defined(USE_ESP32) || defined(USE_LIBRETINY) || USE_ARDUINO_VERSION_CODE > VERSION_CODE(3, 0, 0) #include #endif - #if USE_ARDUINO #include #include @@ -24,6 +24,14 @@ using ip4_addr_t = in_addr; #define ipaddr_aton(x, y) inet_aton((x), (y)) #endif +#ifdef USE_ZEPHYR +#include +#include +#include +using ip_addr_t = struct in6_addr; +static inline int ipaddr_aton(const char *cp, ip_addr_t *addr) { return inet_pton(AF_INET6, cp, addr) == 1 ? 1 : 0; } +#endif + #if USE_ESP32_FRAMEWORK_ARDUINO #define arduino_ns Arduino_h #elif USE_LIBRETINY @@ -33,7 +41,6 @@ using ip4_addr_t = in_addr; #endif #ifdef USE_ESP32 -#include #include #endif @@ -52,7 +59,36 @@ inline void lowercase_ip_str(char *buf) { struct IPAddress { public: -#ifdef USE_HOST +#ifdef USE_ZEPHYR + IPAddress() { memset(&ip_addr_, 0, sizeof(ip_addr_)); } + IPAddress(const std::string &in_address) : ip_addr_{} { ipaddr_aton(in_address.c_str(), &ip_addr_); } + IPAddress(const struct in6_addr *other_ip) { ip_addr_ = *other_ip; } + IPAddress(const struct sockaddr_in6 *addr) { ip_addr_ = addr->sin6_addr; } + + operator struct in6_addr() const { return ip_addr_; } + + bool is_set() const { return !net_ipv6_is_addr_unspecified(&ip_addr_); } + bool is_ip4() const { return false; } + bool is_ip6() const { return this->is_set(); } + bool is_multicast() const { return net_ipv6_is_addr_mcast(&ip_addr_); } + // Remove before 2026.8.0 + ESPDEPRECATED( + "str() is deprecated: use 'char buf[IP_ADDRESS_BUFFER_SIZE]; ip.str_to(buf);' instead. Removed in 2026.8.0", + "2026.2.0") + std::string str() const { + char buf[IP_ADDRESS_BUFFER_SIZE]; + this->str_to(buf); + return buf; + } + char *str_to(char *buf) const { + if (inet_ntop(AF_INET6, &ip_addr_, buf, IP_ADDRESS_BUFFER_SIZE) == nullptr) + buf[0] = '\0'; + return buf; + } + bool operator==(const IPAddress &other) const { return net_ipv6_addr_cmp(&ip_addr_, &other.ip_addr_); } + bool operator!=(const IPAddress &other) const { return !net_ipv6_addr_cmp(&ip_addr_, &other.ip_addr_); } + +#elif defined(USE_HOST) IPAddress() { ip_addr_.s_addr = 0; } IPAddress(uint8_t first, uint8_t second, uint8_t third, uint8_t fourth) { this->ip_addr_.s_addr = htonl((first << 24) | (second << 16) | (third << 8) | fourth); diff --git a/esphome/components/prometheus/prometheus_handler.h b/esphome/components/prometheus/prometheus_handler.h index 53326e9472..008081f586 100644 --- a/esphome/components/prometheus/prometheus_handler.h +++ b/esphome/components/prometheus/prometheus_handler.h @@ -1,6 +1,6 @@ #pragma once #include "esphome/core/defines.h" -#ifdef USE_NETWORK +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) #include #include @@ -219,4 +219,4 @@ class PrometheusHandler : public AsyncWebHandler, public Component { } // namespace esphome::prometheus -#endif +#endif // USE_NETWORK && !USE_ZEPHYR diff --git a/esphome/components/statsd/statsd.cpp b/esphome/components/statsd/statsd.cpp index 7086e462a7..2a56551255 100644 --- a/esphome/components/statsd/statsd.cpp +++ b/esphome/components/statsd/statsd.cpp @@ -2,7 +2,7 @@ #include "statsd.h" -#ifdef USE_NETWORK +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) namespace esphome::statsd { diff --git a/esphome/components/statsd/statsd.h b/esphome/components/statsd/statsd.h index 349bffe6fb..77f3d797c5 100644 --- a/esphome/components/statsd/statsd.h +++ b/esphome/components/statsd/statsd.h @@ -3,7 +3,7 @@ #include #include "esphome/core/defines.h" -#ifdef USE_NETWORK +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) #include "esphome/core/component.h" #include "esphome/components/socket/socket.h" #include "esphome/components/network/ip_address.h" @@ -83,4 +83,4 @@ class StatsdComponent : public PollingComponent { } // namespace esphome::statsd -#endif +#endif // USE_NETWORK && !USE_ZEPHYR diff --git a/esphome/components/sx1509/sx1509.h b/esphome/components/sx1509/sx1509.h index f645ede754..35883eed5b 100644 --- a/esphome/components/sx1509/sx1509.h +++ b/esphome/components/sx1509/sx1509.h @@ -51,7 +51,7 @@ class SX1509Component : public Component, this->cols_ = cols; this->has_keypad_ = true; }; - void set_keys(std::string keys) { this->keys_ = std::move(keys); }; + void set_keys(std::string keys) { this->keys_ = std::move(keys); }; // NOLINT(performance-unnecessary-value-param) void set_sleep_time(uint16_t sleep_time) { this->sleep_time_ = sleep_time; }; void set_scan_time(uint8_t scan_time) { this->scan_time_ = scan_time; }; void set_debounce_time(uint8_t debounce_time = 1) { this->debounce_time_ = debounce_time; }; @@ -62,10 +62,10 @@ class SX1509Component : public Component, void setup_led_driver(uint8_t pin); protected: - // Virtual methods from CachedGpioExpander - bool digital_read_hw(uint8_t pin) override; - bool digital_read_cache(uint8_t pin) override; - void digital_write_hw(uint8_t pin, bool value) override; + // Virtual methods from CachedGpioExpander — names come from base class + bool digital_read_hw(uint8_t pin) override; // NOLINT(readability-identifier-naming) + bool digital_read_cache(uint8_t pin) override; // NOLINT(readability-identifier-naming) + void digital_write_hw(uint8_t pin, bool value) override; // NOLINT(readability-identifier-naming) uint32_t clk_x_ = 2000000; uint8_t frequency_ = 0; diff --git a/esphome/components/tca9555/tca9555.h b/esphome/components/tca9555/tca9555.h index 7d37edad73..19773a0e93 100644 --- a/esphome/components/tca9555/tca9555.h +++ b/esphome/components/tca9555/tca9555.h @@ -27,9 +27,10 @@ class TCA9555Component : public Component, protected: static void IRAM_ATTR gpio_intr(TCA9555Component *arg); - bool digital_read_hw(uint8_t pin) override; - bool digital_read_cache(uint8_t pin) override; - void digital_write_hw(uint8_t pin, bool value) override; + // Virtual methods from GpioExpander base class — names come from base + bool digital_read_hw(uint8_t pin) override; // NOLINT(readability-identifier-naming) + bool digital_read_cache(uint8_t pin) override; // NOLINT(readability-identifier-naming) + void digital_write_hw(uint8_t pin, bool value) override; // NOLINT(readability-identifier-naming) /// Mask for the pin mode - 1 means output, 0 means input uint16_t mode_mask_{0x00}; diff --git a/esphome/components/wake_on_lan/wake_on_lan.cpp b/esphome/components/wake_on_lan/wake_on_lan.cpp index fee6377965..a514a55d80 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.cpp +++ b/esphome/components/wake_on_lan/wake_on_lan.cpp @@ -1,5 +1,5 @@ #include "wake_on_lan.h" -#ifdef USE_NETWORK +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) #include "esphome/core/log.h" #include "esphome/components/network/ip_address.h" #include "esphome/components/network/util.h" diff --git a/esphome/components/wake_on_lan/wake_on_lan.h b/esphome/components/wake_on_lan/wake_on_lan.h index 48f8d00a66..84bc26e064 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.h +++ b/esphome/components/wake_on_lan/wake_on_lan.h @@ -1,6 +1,6 @@ #pragma once #include "esphome/core/defines.h" -#ifdef USE_NETWORK +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) #include "esphome/components/button/button.h" #include "esphome/core/component.h" #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) @@ -32,4 +32,4 @@ class WakeOnLanButton : public button::Button, public Component { } // namespace esphome::wake_on_lan -#endif +#endif // USE_NETWORK && !USE_ZEPHYR diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 3e1baf34ba..ccfc04f674 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -1,5 +1,5 @@ #include "web_server_base.h" -#ifdef USE_NETWORK +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) namespace esphome::web_server_base { diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 2aa3ae215c..c7162c139a 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -1,6 +1,6 @@ #pragma once #include "esphome/core/defines.h" -#ifdef USE_NETWORK +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) #include #include @@ -145,4 +145,4 @@ class WebServerBase { }; } // namespace esphome::web_server_base -#endif +#endif // USE_NETWORK && !USE_ZEPHYR diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 0229bc14fa..f536467e2f 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -133,6 +133,7 @@ #define SNTP_SERVER_COUNT 3 #define USE_MEDIA_PLAYER #define USE_MEDIA_SOURCE +#define USE_NETWORK #define USE_NEXTION_COMMAND_SPACING #define USE_NEXTION_CONF_START_UP_PAGE #define USE_NEXTION_CONFIG_EXIT_REPARSE_ON_START @@ -202,7 +203,6 @@ #define USE_SHA256 #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 diff --git a/tests/components/network/test.nrf52-adafruit.yaml b/tests/components/network/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..61889b0361 --- /dev/null +++ b/tests/components/network/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +network: diff --git a/tests/components/network/test.nrf52-mcumgr.yaml b/tests/components/network/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..61889b0361 --- /dev/null +++ b/tests/components/network/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +network: diff --git a/tests/components/network/test.nrf52-xiao-ble.yaml b/tests/components/network/test.nrf52-xiao-ble.yaml new file mode 100644 index 0000000000..61889b0361 --- /dev/null +++ b/tests/components/network/test.nrf52-xiao-ble.yaml @@ -0,0 +1 @@ +network: From 3cc875c40b0b0a0f4ccd1b6110f4d4bd16a02288 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 27 May 2026 03:09:57 -0500 Subject: [PATCH 0247/1815] [core] Enable ruff BLE (flake8-blind-except) lint family (#16659) --- esphome/__main__.py | 2 +- esphome/async_thread.py | 2 +- esphome/compiled_config.py | 4 ++-- esphome/components/esp32/__init__.py | 2 +- esphome/components/esp8266/__init__.py | 2 +- esphome/components/nrf52/__init__.py | 2 +- esphome/dashboard/web_server.py | 2 +- esphome/espidf/extra_script.py | 2 +- esphome/espidf/framework.py | 2 +- esphome/platformio/runner.py | 2 +- esphome/storage_json.py | 6 +++--- esphome/util.py | 4 ++-- esphome/vscode.py | 4 ++-- esphome/zeroconf.py | 2 +- pyproject.toml | 1 + script/analyze_component_buses.py | 6 +++--- script/build_helpers.py | 2 +- script/determine-jobs.py | 2 +- script/merge_component_configs.py | 2 +- script/stress_test_connect.py | 2 +- script/test_component_grouping.py | 2 +- tests/integration/test_syslog.py | 2 +- tests/integration/test_udp.py | 2 +- 23 files changed, 30 insertions(+), 29 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index dd97c6eee9..03f12c75d7 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1800,7 +1800,7 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: ram_report = ram_analyzer.generate_report() print() print(ram_report) - except Exception as e: # pylint: disable=broad-except + except Exception as e: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.warning("RAM strings analysis failed: %s", e) return 0 diff --git a/esphome/async_thread.py b/esphome/async_thread.py index 7be3c83a9a..c5225a7a14 100644 --- a/esphome/async_thread.py +++ b/esphome/async_thread.py @@ -45,7 +45,7 @@ class AsyncThreadRunner(threading.Thread, Generic[_T]): async def _runner(self) -> None: try: self.result = await self._coro_factory() - except Exception as exc: # pylint: disable=broad-except + except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except # Capture all exceptions so ``event`` is always set — otherwise a # crash would hang the waiter forever. self.exception = exc diff --git a/esphome/compiled_config.py b/esphome/compiled_config.py index 92cbb7348a..f4fd205285 100644 --- a/esphome/compiled_config.py +++ b/esphome/compiled_config.py @@ -43,7 +43,7 @@ def save_compiled_config(config: ConfigType) -> None: try: rendered = yaml_util.dump(config, show_secrets=True) write_file(compiled_config_path(CORE.config_filename), rendered, private=True) - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.debug("Skipping compiled config cache write: %s", err) @@ -62,7 +62,7 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None: try: config = yaml_util.load_yaml(cache_path, clear_secrets=False) - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 # pylint: disable=broad-except return None storage = StorageJSON.load(ext_storage_path(conf_path.name)) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 7b94a26f54..703463bee9 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2683,7 +2683,7 @@ def _decode_pc(config, addr): command = [str(addr2line_path), "-pfiaC", "-e", str(firmware_elf_path), addr] try: translation = subprocess.check_output(command, close_fds=False).decode().strip() - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.debug("Caught exception for command %s", command, exc_info=1) return diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 38df282fb9..dd10a32fd6 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -472,7 +472,7 @@ def _decode_pc(config, addr): command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr] try: translation = subprocess.check_output(command, close_fds=False).decode().strip() - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.debug("Caught exception for command %s", command, exc_info=1) return diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 4ba1ab5d4d..48b67e1ef9 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -535,7 +535,7 @@ def _addr2line(addr2line: str, elf: Path, addr: str) -> str: check=True, ) return result.stdout.strip().splitlines()[0] - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.error("Running command failed: %s", err) return "" diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 97d6639c1f..f5203efe9c 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -1379,7 +1379,7 @@ class LoginHandler(BaseHandler): loop = asyncio.get_running_loop() try: req = await loop.run_in_executor(None, self._make_supervisor_auth_request) - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.warning("Error during Hass.io auth request: %s", err) self.set_status(500) self.render_login_page(error="Internal server error") diff --git a/esphome/espidf/extra_script.py b/esphome/espidf/extra_script.py index bead63ca21..5f59254aee 100644 --- a/esphome/espidf/extra_script.py +++ b/esphome/espidf/extra_script.py @@ -120,7 +120,7 @@ def run_extra_script( "__name__": "__pio_extra_script__", }, ) - except Exception as e: # pylint: disable=broad-exception-caught + except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught _LOGGER.warning("PIO extra-script %s raised %s; skipping", script_path, e) return ExtraScriptResult() finally: diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 331c2f84b0..b2251d00d8 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -783,7 +783,7 @@ def download_from_mirrors( f.seek(0) return url - except Exception as e: # pylint: disable=broad-exception-caught + except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught _LOGGER.debug("Failed to download %s: %s", url, str(e)) last_exception = e diff --git a/esphome/platformio/runner.py b/esphome/platformio/runner.py index caab47dcc2..c49220a044 100644 --- a/esphome/platformio/runner.py +++ b/esphome/platformio/runner.py @@ -94,7 +94,7 @@ def patch_file_downloader() -> None: self._http_response.close() if hasattr(self, "_http_session"): self._http_session.close() - except Exception: + except Exception: # noqa: BLE001 pass # pylint: enable=protected-access,broad-except time.sleep(delay) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 04f5881465..3df12f3985 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -267,7 +267,7 @@ class StorageJSON: def load(path: Path) -> StorageJSON | None: try: return StorageJSON._load_impl(path) - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 # pylint: disable=broad-except return None def apply_to_core(self) -> None: @@ -342,7 +342,7 @@ class EsphomeStorageJSON: return datetime.strptime( # noqa: DTZ007 self.last_update_check_str, "%Y-%m-%dT%H:%M:%S" ) - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 # pylint: disable=broad-except return None @last_update_check.setter @@ -371,7 +371,7 @@ class EsphomeStorageJSON: def load(path: str) -> EsphomeStorageJSON | None: try: return EsphomeStorageJSON._load_impl(path) - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 # pylint: disable=broad-except return None @staticmethod diff --git a/esphome/util.py b/esphome/util.py index 39ce7c0963..b597b4b42e 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -271,7 +271,7 @@ def run_external_command( raise except SystemExit as err: return err.args[0] - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.error("Running command failed: %s", err) _LOGGER.error("Please try running %s locally.", full_cmd) return 1 @@ -318,7 +318,7 @@ def run_external_process(*cmd: str, **kwargs: Any) -> int | str: return proc.stdout if capture_stdout else proc.returncode except KeyboardInterrupt: # pylint: disable=try-except-raise raise - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.error("Running command failed: %s", err) _LOGGER.error("Please try running %s locally.", full_cmd) return 1 diff --git a/esphome/vscode.py b/esphome/vscode.py index 53bb339a8e..f404f02f00 100644 --- a/esphome/vscode.py +++ b/esphome/vscode.py @@ -134,13 +134,13 @@ def read_config(args): try: config = loader(file_name) res = validate_config(config, command_line_substitutions) - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except vs.add_yaml_error(str(err)) else: for err in res.errors: try: range_ = _get_invalid_range(res, err) vs.add_validation_error(range_, _format_vol_invalid(err, res)) - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 # pylint: disable=broad-except continue print(vs.dump()) diff --git a/esphome/zeroconf.py b/esphome/zeroconf.py index a4f4f46097..e4b9abb976 100644 --- a/esphome/zeroconf.py +++ b/esphome/zeroconf.py @@ -342,7 +342,7 @@ async def async_discover_mdns_devices( ) try: aiozc = AsyncEsphomeZeroconf() - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except # Zeroconf init can raise OSError, NonUniqueNameException, etc. # Any failure here just means we can't discover — log and move on. _LOGGER.warning("mDNS discovery failed to initialize: %s", err) diff --git a/pyproject.toml b/pyproject.toml index d2f30ea3d7..a292377835 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,6 +112,7 @@ exclude = ['generated'] [tool.ruff.lint] select = [ "B", # flake8-bugbear + "BLE", # flake8-blind-except "C4", # flake8-comprehensions "DTZ", # flake8-datetimez "E", # pycodestyle diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index 1d86d5c71c..fc66605694 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -128,7 +128,7 @@ def uses_local_file_references(component_dir: Path) -> bool: try: content = common_yaml.read_text() - except Exception: # pylint: disable=broad-exception-caught + except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught return False # Pattern to match $component_dir or ${component_dir} references @@ -164,7 +164,7 @@ def is_platform_component(component_dir: Path) -> bool: try: content = comp_init.read_text() return "IS_PLATFORM_COMPONENT = True" in content - except Exception: # pylint: disable=broad-exception-caught + except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught return False @@ -222,7 +222,7 @@ def analyze_yaml_file(yaml_file: Path) -> dict[str, Any]: try: data = yaml_util.load_yaml(yaml_file) result["loaded"] = True - except Exception: # pylint: disable=broad-exception-caught + except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught return result # Check for Extend/Remove objects diff --git a/script/build_helpers.py b/script/build_helpers.py index 52f7ee317e..eaf3a1f1a7 100644 --- a/script/build_helpers.py +++ b/script/build_helpers.py @@ -392,7 +392,7 @@ def compile_and_get_binary( if exit_code != 0: print(f"Error compiling {label} for {', '.join(components)}") return exit_code, None - except Exception as e: + except Exception as e: # noqa: BLE001 print(f"Error compiling {label} for {', '.join(components)}: {e}") return EXIT_COMPILE_ERROR, None diff --git a/script/determine-jobs.py b/script/determine-jobs.py index d91936952e..cf098f92c9 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -312,7 +312,7 @@ def _is_clang_tidy_full_scan() -> bool: ) # Exit 0 means hash changed (full scan needed) return result.returncode == 0 - except Exception: + except Exception: # noqa: BLE001 # If hash check fails, run full scan to be safe return True diff --git a/script/merge_component_configs.py b/script/merge_component_configs.py index df7ad4a28c..a952ecff16 100755 --- a/script/merge_component_configs.py +++ b/script/merge_component_configs.py @@ -437,7 +437,7 @@ def main() -> None: tests_dir=args.tests_dir, output_file=args.output, ) - except Exception as e: + except Exception as e: # noqa: BLE001 print(f"Error merging configs: {e}", file=sys.stderr) import traceback diff --git a/script/stress_test_connect.py b/script/stress_test_connect.py index f91a7e8f99..e34cffb8e2 100644 --- a/script/stress_test_connect.py +++ b/script/stress_test_connect.py @@ -21,7 +21,7 @@ async def connect_disconnect(client_id: int, iteration: int) -> tuple[int, bool, await asyncio.wait_for(cli.connect(login=True), timeout=10) await cli.disconnect() return iteration, True, "" - except Exception as e: + except Exception as e: # noqa: BLE001 return ( iteration, False, diff --git a/script/test_component_grouping.py b/script/test_component_grouping.py index a2cee6e888..1e7dfc1792 100755 --- a/script/test_component_grouping.py +++ b/script/test_component_grouping.py @@ -63,7 +63,7 @@ def test_component_group( try: result = subprocess.run(cmd, check=False) return result.returncode == 0 - except Exception as e: + except Exception as e: # noqa: BLE001 print(f"Error running test: {e}") return False diff --git a/tests/integration/test_syslog.py b/tests/integration/test_syslog.py index b31a19392c..0567164805 100644 --- a/tests/integration/test_syslog.py +++ b/tests/integration/test_syslog.py @@ -110,7 +110,7 @@ async def syslog_udp_listener() -> AsyncGenerator[tuple[int, SyslogReceiver]]: receiver.on_message(msg) except BlockingIOError: await asyncio.sleep(0.01) - except Exception: + except Exception: # noqa: BLE001 break task = asyncio.create_task(receive_messages()) diff --git a/tests/integration/test_udp.py b/tests/integration/test_udp.py index 2187d13814..4ee3bba444 100644 --- a/tests/integration/test_udp.py +++ b/tests/integration/test_udp.py @@ -80,7 +80,7 @@ async def udp_listener(port: int = 0) -> AsyncGenerator[tuple[int, UDPReceiver]] receiver.on_message(data) except BlockingIOError: await asyncio.sleep(0.01) - except Exception: + except Exception: # noqa: BLE001 break task = asyncio.create_task(receive_messages()) From 21e548f1d78a3ed225694bb9ef3d8df7feab71cd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 27 May 2026 09:20:50 -0500 Subject: [PATCH 0248/1815] [core] Sensitive redaction via yaml_util representer (#16690) --- esphome/__main__.py | 38 +++++- esphome/components/wifi/__init__.py | 6 +- esphome/config_validation.py | 10 +- esphome/yaml_util.py | 39 +++++- tests/unit_tests/test_config_validation.py | 37 ++++++ tests/unit_tests/test_main.py | 131 +++++++++++++++++++++ tests/unit_tests/test_yaml_util.py | 55 +++++++++ 7 files changed, 306 insertions(+), 10 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 03f12c75d7..000087063f 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1412,17 +1412,47 @@ def command_config(args: ArgsProtocol, config: ConfigType) -> int | None: if not CORE.verbose: config = strip_default_ids(config) output = yaml_util.dump(config, args.show_secrets) - # add the console decoration so the front-end can hide the secrets if not args.show_secrets: - output = re.sub( - r"(password|key|psk|ssid)\: (.+)", r"\1: \\033[8m\2\\033[28m", output - ) + output = _redact_with_legacy_fallback(output) if not CORE.quiet: safe_print(output) _LOGGER.info("Configuration is valid!") return 0 +# Legacy substring redaction fallback for unmigrated schemas; removed in +# 2026.12.0 once canonical sensitive fields are tagged. The lookahead skips +# values that already render themselves: ``\033[8m`` (SensitiveStr wrap), +# ``!secret`` (preserves the user-friendly tag), ``!lambda`` (multi-line +# block; first line is structural). The fragment must either start the +# field name or follow ``_`` so the warning names a real field; this avoids +# false positives like ``monkey:`` matching the ``key`` fragment. +_LEGACY_REDACTION_RE = re.compile( + r"(?P\b(?:\w+_)?(?:password|key|psk|ssid))\: " + r"(?!\\033\[8m|!secret\b|!lambda\b)(?P.+)" +) +_LEGACY_REDACTION_REMOVAL = "2026.12.0" + + +def _redact_with_legacy_fallback(output: str) -> str: + unmarked: set[str] = set() + + def _replace(m: re.Match[str]) -> str: + unmarked.add(m.group("key")) + return f"{m.group('key')}: \\033[8m{m.group('val')}\\033[28m" + + output = _LEGACY_REDACTION_RE.sub(_replace, output) + for key in sorted(unmarked): + _LOGGER.warning( + "Field '%s' is being redacted by a legacy substring heuristic. " + "Mark this field's schema validator with cv.sensitive(...) for " + "deterministic redaction; the heuristic will be removed in %s.", + key, + _LEGACY_REDACTION_REMOVAL, + ) + return output + + def command_config_hash(args: ArgsProtocol, config: ConfigType) -> int | None: # generating code might modify config, so it must be done in order to generate # a hash that will match what was generated when compiling and then running diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 4e7dcc82e5..b7719c80d1 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -271,7 +271,7 @@ EAP_AUTH_SCHEMA = cv.All( WIFI_NETWORK_BASE = cv.Schema( { cv.GenerateID(): cv.declare_id(WiFiAP), - cv.Optional(CONF_SSID): cv.ssid, + cv.Optional(CONF_SSID): cv.sensitive(cv.ssid), cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password), cv.Optional(CONF_CHANNEL): validate_channel, cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA, @@ -434,7 +434,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_NETWORKS): cv.All( cv.ensure_list(WIFI_NETWORK_STA), cv.Length(max=MAX_WIFI_NETWORKS) ), - cv.Optional(CONF_SSID): cv.ssid, + cv.Optional(CONF_SSID): cv.sensitive(cv.ssid), cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password), cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA, cv.Optional(CONF_EAP): EAP_AUTH_SCHEMA, @@ -850,7 +850,7 @@ async def final_step(): WiFiConfigureAction, cv.Schema( { - cv.Required(CONF_SSID): cv.templatable(cv.ssid), + cv.Required(CONF_SSID): cv.sensitive(cv.templatable(cv.ssid)), cv.Required(CONF_PASSWORD): cv.sensitive(cv.templatable(validate_password)), cv.Optional(CONF_SAVE, default=True): cv.templatable(cv.boolean), cv.Optional(CONF_TIMEOUT, default="30000ms"): cv.templatable( diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 2f09fdc105..0ef6d212fe 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -101,7 +101,7 @@ from esphome.schema_extractors import ( ) from esphome.util import parse_esphome_version from esphome.voluptuous_schema import _Schema -from esphome.yaml_util import make_data_base +from esphome.yaml_util import SensitiveStr, make_data_base _LOGGER = logging.getLogger(__name__) @@ -514,7 +514,13 @@ class SensitiveValidator: self.inner = inner def __call__(self, value: typing.Any) -> typing.Any: - return self.inner(value) + validated = self.inner(value) + # Tag string results so yaml_util.dump can mask them. Non-string + # results pass through unchanged; already-tagged values are not + # re-wrapped to keep nested cv.sensitive applications idempotent. + if isinstance(validated, str) and not isinstance(validated, SensitiveStr): + return SensitiveStr(validated) + return validated def __repr__(self) -> str: # Mirror the inner validator's repr so ``build_language_schema``'s diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 28f72ab831..bfe1fb0136 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -52,6 +52,16 @@ _load_listeners: list[Callable[[Path], None]] = [] DocumentPath = list[str | int] +class SensitiveStr(str): + """Marker subclass for validated strings that should be masked in + user-visible YAML output. ``cv.sensitive`` wraps validated values in this + type so ``dump()`` can render them with ANSI conceal codes without + needing a post-process regex. + """ + + __slots__ = () + + @contextmanager def track_yaml_loads() -> Generator[list[Path]]: """Context manager that records every file loaded by the YAML loader. @@ -808,11 +818,18 @@ def dump(dict_, show_secrets=False, sort_keys=False): if show_secrets: _SECRET_VALUES.clear() _SECRET_CACHE.clear() + + # Per-call subclass so the redaction flag doesn't leak across calls. + # (``_SECRET_VALUES`` / ``_SECRET_CACHE`` remain module globals; YAML + # processing is single-threaded today, so this isolates only the flag.) + class _Dumper(ESPHomeDumper): + _redact_sensitive = not show_secrets + return yaml.dump( dict_, default_flow_style=False, allow_unicode=True, - Dumper=ESPHomeDumper, + Dumper=_Dumper, sort_keys=sort_keys, ) @@ -958,6 +975,10 @@ def format_path(path: DocumentPath, current_obj: Any) -> str: class ESPHomeDumper(yaml.SafeDumper): + # Default for the base class; per-call subclass in ``dump()`` overrides. + # When True, ``represent_sensitive`` wraps values in ANSI conceal codes. + _redact_sensitive: bool = False + def represent_mapping(self, tag, mapping, flow_style=None): value = [] node = yaml.MappingNode(tag, value, flow_style=flow_style) @@ -992,6 +1013,20 @@ class ESPHomeDumper(yaml.SafeDumper): return self.represent_secret(value) return self.represent_scalar(tag="tag:yaml.org,2002:str", value=str(value)) + def represent_sensitive(self, value: SensitiveStr) -> yaml.ScalarNode: + # Only the redact-and-not-a-secret branch is unique to sensitive + # values; otherwise let ``represent_stringify`` handle ``!secret`` + # precedence and the plain-str fallthrough. Conceal sequence is + # emitted as literal ``\033`` text (not actual ESC bytes) so the + # output matches the prior regex format and device-builder's + # ``\033[8m...\033[28m`` parser keeps working. + if self._redact_sensitive and not is_secret(value): + return self.represent_scalar( + tag="tag:yaml.org,2002:str", + value=f"\\033[8m{value}\\033[28m", + ) + return self.represent_stringify(value) + # pylint: disable=arguments-renamed def represent_bool(self, value): return self.represent_scalar( @@ -1063,6 +1098,8 @@ ESPHomeDumper.add_multi_representer( ) ESPHomeDumper.add_multi_representer(bool, ESPHomeDumper.represent_bool) ESPHomeDumper.add_multi_representer(str, ESPHomeDumper.represent_stringify) +# MRO-walked dispatch; SensitiveStr's own entry wins over the str one. +ESPHomeDumper.add_multi_representer(SensitiveStr, ESPHomeDumper.represent_sensitive) ESPHomeDumper.add_multi_representer(int, ESPHomeDumper.represent_int) ESPHomeDumper.add_multi_representer(float, ESPHomeDumper.represent_float) ESPHomeDumper.add_multi_representer(_BaseAddress, ESPHomeDumper.represent_stringify) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 2c34cbfb07..74d9a5047a 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -27,6 +27,7 @@ from esphome.const import ( SCHEDULER_DONT_RUN, ) from esphome.core import CORE, HexInt, Lambda +from esphome.yaml_util import SensitiveStr def test_check_not_templatable__invalid(): @@ -145,6 +146,42 @@ def test_sensitive__custom_inner_delegates_validation() -> None: validator(123) +def test_sensitive__wraps_string_result_in_sensitive_str() -> None: + validator = config_validation.sensitive() + result = validator("hunter2") + + assert isinstance(result, SensitiveStr) + assert isinstance(result, str) + assert result == "hunter2" + + +def test_sensitive__does_not_double_tag_already_sensitive() -> None: + # If the inner validator already returns a SensitiveStr (e.g., nested + # cv.sensitive wrappers), re-tagging is a no-op rather than a new + # SensitiveStr around the same value. + pre_tagged = SensitiveStr("hunter2") + + def inner(_value): + return pre_tagged + + validator = config_validation.sensitive(inner) + result = validator("anything") + + assert result is pre_tagged + + +def test_sensitive__non_string_result_passes_through() -> None: + # If an inner validator returns something other than a string (e.g., a + # Lambda template), the sensitive wrapper must not coerce it. + sentinel = object() + + def inner(_value): + return sentinel + + validator = config_validation.sensitive(inner) + assert validator("anything") is sentinel + + def test_sensitive__is_detectable_via_isinstance() -> None: validator = config_validation.sensitive() diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index f6b6d0b05f..26b550669f 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -22,6 +22,7 @@ from esphome.__main__ import ( Purpose, _get_configured_xtal_freq, _make_crystal_freq_callback, + _redact_with_legacy_fallback, _resolve_network_devices, _validate_bootloader_binary, _validate_partition_table_binary, @@ -29,6 +30,7 @@ from esphome.__main__ import ( command_analyze_memory, command_bundle, command_clean_all, + command_config, command_config_hash, command_rename, command_run, @@ -340,6 +342,135 @@ def mock_ram_strings_analyzer() -> Generator[Mock]: yield mock_class +def test_redact_with_legacy_fallback__wraps_unmarked_field( + caplog: pytest.LogCaptureFixture, +) -> None: + """Unmarked sensitive-shaped fields are redacted; a deprecation warning + is emitted naming the field.""" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback("password: hunter2\n") + assert "password: \\033[8mhunter2\\033[28m" in out + assert any( + "password" in rec.message and "cv.sensitive" in rec.message + for rec in caplog.records + ) + + +def test_redact_with_legacy_fallback__skips_already_wrapped( + caplog: pytest.LogCaptureFixture, +) -> None: + """Values already wrapped by the SensitiveStr representer don't trigger + the heuristic or the warning.""" + wrapped = "password: \\033[8mhunter2\\033[28m\n" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback(wrapped) + assert out == wrapped + assert not any("legacy substring" in rec.message for rec in caplog.records) + + +def test_redact_with_legacy_fallback__captures_full_field_name( + caplog: pytest.LogCaptureFixture, +) -> None: + """The warning names the actual field, not just the matched fragment.""" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + _redact_with_legacy_fallback("encryption_key: abc\n") + assert any("encryption_key" in rec.message for rec in caplog.records) + + +def test_redact_with_legacy_fallback__deduplicates_warnings( + caplog: pytest.LogCaptureFixture, +) -> None: + """One warning per unique field name even if it appears many times.""" + text = "password: a\npassword: b\npassword: c\n" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + _redact_with_legacy_fallback(text) + password_warnings = [rec for rec in caplog.records if "'password'" in rec.message] + assert len(password_warnings) == 1 + + +def test_redact_with_legacy_fallback__skips_lambda_values( + caplog: pytest.LogCaptureFixture, +) -> None: + """``!lambda`` first line is structural, body is unreachable by a + single-line regex anyway, and tagged fields shouldn't trigger a warning.""" + text = ' ssid: !lambda |-\n return "x";\n' + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback(text) + assert out == text + assert not any("legacy substring" in rec.message for rec in caplog.records) + + +def test_redact_with_legacy_fallback__skips_secret_references( + caplog: pytest.LogCaptureFixture, +) -> None: + """``!secret name`` is the dumper's user-friendly representation; the + name isn't the secret, so wrapping it would clobber the round-trip.""" + text = " password: !secret wifi_password\n" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback(text) + assert out == text + assert not any("legacy substring" in rec.message for rec in caplog.records) + + +def test_redact_with_legacy_fallback__does_not_match_fragment_in_middle( + caplog: pytest.LogCaptureFixture, +) -> None: + """Fragment must end the field name; embedded matches like + ``key_value_pair`` are unrelated to a sensitive key and must not be + redacted (matching the prior regex's scope).""" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback("key_value_pair: abc\n") + assert "\\033[8m" not in out + assert not any("legacy substring" in rec.message for rec in caplog.records) + + +def test_redact_with_legacy_fallback__does_not_match_fragment_as_suffix( + caplog: pytest.LogCaptureFixture, +) -> None: + """Fragment must start the name or follow ``_``; ``monkey:`` shouldn't + fire a 'legacy heuristic' warning because there's no sensitive field + here — the user has nothing to migrate.""" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback("monkey: 1234\n") + assert "\\033[8m" not in out + assert not any("legacy substring" in rec.message for rec in caplog.records) + + +def test_command_config__invokes_legacy_fallback_when_redacting( + tmp_path: Path, capfd: CaptureFixture[str] +) -> None: + """``command_config`` runs the legacy fallback on the dumped output when + ``--show-secrets`` is off. Cover the wiring (not just the helper). + """ + setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}}) + args = MockArgs() + args.show_secrets = False + + result = command_config(args, {"wifi": {"password": "hunter2"}}) + + assert result == 0 + output = capfd.readouterr().out + assert "\\033[8mhunter2\\033[28m" in output + + +def test_command_config__show_secrets_skips_redaction( + tmp_path: Path, capfd: CaptureFixture[str] +) -> None: + """With ``--show-secrets`` the helper isn't invoked and the value + renders raw. + """ + setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}}) + args = MockArgs() + args.show_secrets = True + + result = command_config(args, {"wifi": {"password": "hunter2"}}) + + assert result == 0 + output = capfd.readouterr().out + assert "hunter2" in output + assert "\\033[8m" not in output + + def test_choose_upload_log_host_with_string_default() -> None: """Test with a single string default device.""" setup_core() diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index d6fb5b81f2..6be090b869 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -15,6 +15,7 @@ from esphome.yaml_util import ( DiscoveredYamlFiles, ESPHomeDataBase, ESPLiteralValue, + SensitiveStr, discover_user_yaml_files, force_load_include_files, format_path, @@ -1340,3 +1341,57 @@ def test_frontmatter_included_file_stored(tmp_path: Path) -> None: assert main.resolve() not in core.CORE.frontmatter # Included file's frontmatter is captured assert core.CORE.frontmatter[inc.resolve()]["child_meta"] == "hello" + + +def test_sensitive_str__is_a_str_subclass() -> None: + value = SensitiveStr("hunter2") + assert isinstance(value, str) + assert value == "hunter2" + + +def test_dump__redacts_sensitive_str_by_default() -> None: + out = yaml_util.dump({"password": SensitiveStr("hunter2")}) + assert "\\033[8mhunter2\\033[28m" in out + assert "hunter2" not in out.replace( + "\\033[8mhunter2\\033[28m", "" + ) # the raw value is only present inside the wrap + + +def test_dump__show_secrets_emits_sensitive_str_raw() -> None: + out = yaml_util.dump({"password": SensitiveStr("hunter2")}, show_secrets=True) + assert "hunter2" in out + assert "\\033[8m" not in out + assert "\\033[28m" not in out + + +def test_dump__plain_str_is_not_redacted() -> None: + out = yaml_util.dump({"hostname": "myserver"}) + assert "myserver" in out + assert "\\033[8m" not in out + + +def test_dump__secret_reference_wins_over_redaction() -> None: + # If the value also has an entry in _SECRET_VALUES (i.e., it was loaded + # via !secret), the dump should render it as !secret , not as a + # redacted scalar. SensitiveStr layered on top must not change that. + value = SensitiveStr("hunter2") + yaml_util._SECRET_VALUES[str(value)] = "my_secret_name" + try: + out = yaml_util.dump({"password": value}) + assert "!secret" in out + assert "my_secret_name" in out + assert "\\033[8m" not in out + finally: + yaml_util._SECRET_VALUES.clear() + + +def test_dump__redaction_flag_does_not_leak_between_calls() -> None: + # Per-call _Dumper subclass means show_secrets in one call doesn't + # affect another. Run them in both orders to catch any leakage. + redacted = yaml_util.dump({"password": SensitiveStr("hunter2")}) + raw = yaml_util.dump({"password": SensitiveStr("hunter2")}, show_secrets=True) + redacted_again = yaml_util.dump({"password": SensitiveStr("hunter2")}) + + assert "\\033[8m" in redacted + assert "\\033[8m" not in raw + assert "\\033[8m" in redacted_again From e64b6bc3982936b0cdedce5f9ea5052cde7467ad Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 27 May 2026 11:00:51 -0400 Subject: [PATCH 0249/1815] [esp32] Stub arduino-esp32 with INTERFACE re-export to framework (#16695) --- esphome/components/esp32/__init__.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 703463bee9..ac0d2eaba2 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2583,6 +2583,26 @@ def _write_idf_component_yml(): "override_path": str(stub_path), } + # On the PlatformIO toolchain, framework-arduinoespressif32 already + # ships arduino-esp32. Stub the managed component so anything that + # `REQUIRES arduino-esp32` (e.g. third-party FastLED) resolves to a + # CMake target that re-exports the framework's INTERFACE properties + # (INCLUDE_DIRS, public compile options like -DESP32, transitive + # REQUIRES) instead of triggering a duplicate download/rebuild. + if CORE.using_toolchain_platformio: + arduino_stub = stubs_dir / "arduino-esp32" + arduino_stub.mkdir(exist_ok=True) + write_file_if_changed( + arduino_stub / "CMakeLists.txt", + "idf_component_register()\n" + "target_link_libraries(${COMPONENT_LIB} " + f"INTERFACE idf::{ARDUINO_FRAMEWORK_NAME})\n", + ) + dependencies[ARDUINO_ESP32_COMPONENT_NAME] = { + "version": "*", + "override_path": str(arduino_stub), + } + # Remove stubs for components that are now required by enabled libraries for component_name in required_idf_components: stub_path = stubs_dir / _idf_component_stub_name(component_name) From 911e330c0948231b8141474827f1ce22aaba1345 Mon Sep 17 00:00:00 2001 From: Elvin Luff Date: Wed, 27 May 2026 20:13:03 +0200 Subject: [PATCH 0250/1815] [core] Add Codeberg as a supported git url (#16501) --- esphome/git.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/git.py b/esphome/git.py index 744ce35ef6..c4a612753b 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -341,6 +341,7 @@ def clone_or_update( GIT_DOMAINS = { + "codeberg": "codeberg.org", "github": "github.com", "gitlab": "gitlab.com", } @@ -363,6 +364,8 @@ class GitFile: def raw_url(self) -> str: if self.ref is None: raise ValueError("URL has no ref") + if self.domain == "codeberg.org": + return f"https://codeberg.org/{self.owner}/{self.repo}/raw/commit/{self.ref}/{self.filename}" if self.domain == "github.com": return f"https://raw.githubusercontent.com/{self.owner}/{self.repo}/{self.ref}/{self.filename}" if self.domain == "gitlab.com": From e87190edb49356f9cad167c9a788853f89e2f11f Mon Sep 17 00:00:00 2001 From: SoCuul <63339559+SoCuul@users.noreply.github.com> Date: Wed, 27 May 2026 11:20:00 -0700 Subject: [PATCH 0251/1815] [midea] fix casing of custom fan modes (#16419) --- esphome/components/midea/ac_adapter.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/midea/ac_adapter.cpp b/esphome/components/midea/ac_adapter.cpp index ec9dc10297..2f4ef5c948 100644 --- a/esphome/components/midea/ac_adapter.cpp +++ b/esphome/components/midea/ac_adapter.cpp @@ -6,9 +6,9 @@ namespace esphome::midea::ac { const char *const Constants::TAG = "midea"; -const char *const Constants::FREEZE_PROTECTION = "freeze protection"; -const char *const Constants::SILENT = "silent"; -const char *const Constants::TURBO = "turbo"; +const char *const Constants::FREEZE_PROTECTION = "Freeze Protection"; +const char *const Constants::SILENT = "Silent"; +const char *const Constants::TURBO = "Turbo"; ClimateMode Converters::to_climate_mode(MideaMode mode) { switch (mode) { From ac29fad120115230cb52298cc3f43d9a44bb2978 Mon Sep 17 00:00:00 2001 From: GuzTech Date: Wed, 27 May 2026 20:21:50 +0200 Subject: [PATCH 0252/1815] [growatt_solar] Replace hard coded register addresses with constexpr (#16581) --- .../growatt_solar/growatt_solar.cpp | 105 ++++++++++-------- .../components/growatt_solar/growatt_solar.h | 49 ++++++++ 2 files changed, 110 insertions(+), 44 deletions(-) diff --git a/esphome/components/growatt_solar/growatt_solar.cpp b/esphome/components/growatt_solar/growatt_solar.cpp index 41beb6e4e9..fc35271017 100644 --- a/esphome/components/growatt_solar/growatt_solar.cpp +++ b/esphome/components/growatt_solar/growatt_solar.cpp @@ -63,71 +63,88 @@ void GrowattSolar::on_modbus_data(const std::vector &data) { switch (this->protocol_version_) { case RTU: { - publish_1_reg_sensor_state(this->inverter_status_, 0, 1); + publish_1_reg_sensor_state(this->inverter_status_, RTU_INVERTER_STATUS, 1); - publish_2_reg_sensor_state(this->pv_active_power_sensor_, 1, 2, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pv_active_power_sensor_, RTU_PV_ACTIVE_POWER, RTU_PV_ACTIVE_POWER + 1, + ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[0].voltage_sensor_, 3, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[0].current_sensor_, 4, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, 5, 6, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[0].voltage_sensor_, RTU_PV1_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[0].current_sensor_, RTU_PV1_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, RTU_PV1_ACTIVE_POWER, RTU_PV1_ACTIVE_POWER + 1, + ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[1].voltage_sensor_, 7, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[1].current_sensor_, 8, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, 9, 10, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[1].voltage_sensor_, RTU_PV2_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[1].current_sensor_, RTU_PV2_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, RTU_PV2_ACTIVE_POWER, RTU_PV2_ACTIVE_POWER + 1, + ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->grid_active_power_sensor_, 11, 12, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->grid_frequency_sensor_, 13, TWO_DEC_UNIT); + publish_2_reg_sensor_state(this->grid_active_power_sensor_, RTU_GRID_ACTIVE_POWER, RTU_GRID_ACTIVE_POWER + 1, + ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->grid_frequency_sensor_, RTU_GRID_FREQUENCY, TWO_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[0].voltage_sensor_, 14, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[0].current_sensor_, 15, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, 16, 17, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[0].voltage_sensor_, RTU_PHASE1_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[0].current_sensor_, RTU_PHASE1_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, RTU_PHASE1_ACTIVE_POWER, + RTU_PHASE1_ACTIVE_POWER + 1, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[1].voltage_sensor_, 18, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[1].current_sensor_, 19, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, 20, 21, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[1].voltage_sensor_, RTU_PHASE2_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[1].current_sensor_, RTU_PHASE2_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, RTU_PHASE2_ACTIVE_POWER, + RTU_PHASE2_ACTIVE_POWER + 1, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[2].voltage_sensor_, 22, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[2].current_sensor_, 23, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, 24, 25, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[2].voltage_sensor_, RTU_PHASE3_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[2].current_sensor_, RTU_PHASE3_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, RTU_PHASE3_ACTIVE_POWER, + RTU_PHASE3_ACTIVE_POWER + 1, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->today_production_, 26, 27, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->total_energy_production_, 28, 29, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->today_production_, RTU_TODAY_PRODUCTION, RTU_TODAY_PRODUCTION + 1, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->total_energy_production_, RTU_TOTAL_ENERGY_PRODUCTION, + RTU_TOTAL_ENERGY_PRODUCTION + 1, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->inverter_module_temp_, 32, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->inverter_module_temp_, RTU_INVERTER_MODULE_TEMP, ONE_DEC_UNIT); break; } case RTU2: { - publish_1_reg_sensor_state(this->inverter_status_, 0, 1); + publish_1_reg_sensor_state(this->inverter_status_, RTU2_INVERTER_STATUS, 1); - publish_2_reg_sensor_state(this->pv_active_power_sensor_, 1, 2, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pv_active_power_sensor_, RTU2_PV_ACTIVE_POWER, RTU2_PV_ACTIVE_POWER + 1, + ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[0].voltage_sensor_, 3, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[0].current_sensor_, 4, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, 5, 6, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[0].voltage_sensor_, RTU2_PV1_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[0].current_sensor_, RTU2_PV1_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, RTU2_PV1_ACTIVE_POWER, RTU2_PV1_ACTIVE_POWER + 1, + ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[1].voltage_sensor_, 7, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[1].current_sensor_, 8, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, 9, 10, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[1].voltage_sensor_, RTU2_PV2_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[1].current_sensor_, RTU2_PV2_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, RTU2_PV2_ACTIVE_POWER, RTU2_PV2_ACTIVE_POWER + 1, + ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->grid_active_power_sensor_, 35, 36, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->grid_frequency_sensor_, 37, TWO_DEC_UNIT); + publish_2_reg_sensor_state(this->grid_active_power_sensor_, RTU2_GRID_ACTIVE_POWER, RTU2_GRID_ACTIVE_POWER + 1, + ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->grid_frequency_sensor_, RTU2_GRID_FREQUENCY, TWO_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[0].voltage_sensor_, 38, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[0].current_sensor_, 39, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, 40, 41, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[0].voltage_sensor_, RTU2_PHASE1_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[0].current_sensor_, RTU2_PHASE1_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, RTU2_PHASE1_ACTIVE_POWER, + RTU2_PHASE1_ACTIVE_POWER + 1, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[1].voltage_sensor_, 42, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[1].current_sensor_, 43, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, 44, 45, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[1].voltage_sensor_, RTU2_PHASE2_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[1].current_sensor_, RTU2_PHASE2_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, RTU2_PHASE2_ACTIVE_POWER, + RTU2_PHASE2_ACTIVE_POWER + 1, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[2].voltage_sensor_, 46, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[2].current_sensor_, 47, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, 48, 49, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[2].voltage_sensor_, RTU2_PHASE3_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[2].current_sensor_, RTU2_PHASE3_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, RTU2_PHASE3_ACTIVE_POWER, + RTU2_PHASE3_ACTIVE_POWER + 1, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->today_production_, 53, 54, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->total_energy_production_, 55, 56, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->today_production_, RTU2_TODAY_PRODUCTION, RTU2_TODAY_PRODUCTION + 1, + ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->total_energy_production_, RTU2_TOTAL_ENERGY_PRODUCTION, + RTU2_TOTAL_ENERGY_PRODUCTION + 1, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->inverter_module_temp_, 93, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->inverter_module_temp_, RTU2_INVERTER_MODULE_TEMP, ONE_DEC_UNIT); break; } } diff --git a/esphome/components/growatt_solar/growatt_solar.h b/esphome/components/growatt_solar/growatt_solar.h index 7eba795601..27ae32cc46 100644 --- a/esphome/components/growatt_solar/growatt_solar.h +++ b/esphome/components/growatt_solar/growatt_solar.h @@ -16,6 +16,55 @@ enum GrowattProtocolVersion { RTU2, }; +// Register addresses for the RTU protocol. +constexpr size_t RTU_INVERTER_STATUS = 0; // length = 1 +constexpr size_t RTU_PV_ACTIVE_POWER = 1; // length = 2 +constexpr size_t RTU_PV1_VOLTAGE = 3; // length = 1 +constexpr size_t RTU_PV1_CURRENT = 4; // length = 1 +constexpr size_t RTU_PV1_ACTIVE_POWER = 5; // length = 2 +constexpr size_t RTU_PV2_VOLTAGE = 7; // length = 1 +constexpr size_t RTU_PV2_CURRENT = 8; // length = 1 +constexpr size_t RTU_PV2_ACTIVE_POWER = 9; // length = 2 +constexpr size_t RTU_GRID_ACTIVE_POWER = 11; // length = 2 +constexpr size_t RTU_GRID_FREQUENCY = 13; // length = 1 +constexpr size_t RTU_PHASE1_VOLTAGE = 14; // length = 1 +constexpr size_t RTU_PHASE1_CURRENT = 15; // length = 1 +constexpr size_t RTU_PHASE1_ACTIVE_POWER = 16; // length = 2 +constexpr size_t RTU_PHASE2_VOLTAGE = 18; // length = 1 +constexpr size_t RTU_PHASE2_CURRENT = 19; // length = 1 +constexpr size_t RTU_PHASE2_ACTIVE_POWER = 20; // length = 2 +constexpr size_t RTU_PHASE3_VOLTAGE = 22; // length = 1 +constexpr size_t RTU_PHASE3_CURRENT = 23; // length = 1 +constexpr size_t RTU_PHASE3_ACTIVE_POWER = 24; // length = 2 +constexpr size_t RTU_TODAY_PRODUCTION = 26; // length = 2 +constexpr size_t RTU_TOTAL_ENERGY_PRODUCTION = 28; // length = 2 +constexpr size_t RTU_INVERTER_MODULE_TEMP = 32; // length = 1 + +// Input register addresses for the RTU2 protocol as described +// in the "GROWATT INVERTER MODBUS PROTOCOL_II V1.39" document. +constexpr size_t RTU2_INVERTER_STATUS = 0; // length = 1 +constexpr size_t RTU2_PV_ACTIVE_POWER = 1; // length = 2 +constexpr size_t RTU2_PV1_VOLTAGE = 3; // length = 1 +constexpr size_t RTU2_PV1_CURRENT = 4; // length = 1 +constexpr size_t RTU2_PV1_ACTIVE_POWER = 5; // length = 2 +constexpr size_t RTU2_PV2_VOLTAGE = 7; // length = 1 +constexpr size_t RTU2_PV2_CURRENT = 8; // length = 1 +constexpr size_t RTU2_PV2_ACTIVE_POWER = 9; // length = 2 +constexpr size_t RTU2_GRID_ACTIVE_POWER = 35; // length = 2 +constexpr size_t RTU2_GRID_FREQUENCY = 37; // length = 1 +constexpr size_t RTU2_PHASE1_VOLTAGE = 38; // length = 1 +constexpr size_t RTU2_PHASE1_CURRENT = 39; // length = 1 +constexpr size_t RTU2_PHASE1_ACTIVE_POWER = 40; // length = 2 +constexpr size_t RTU2_PHASE2_VOLTAGE = 42; // length = 1 +constexpr size_t RTU2_PHASE2_CURRENT = 43; // length = 1 +constexpr size_t RTU2_PHASE2_ACTIVE_POWER = 44; // length = 2 +constexpr size_t RTU2_PHASE3_VOLTAGE = 46; // length = 1 +constexpr size_t RTU2_PHASE3_CURRENT = 47; // length = 1 +constexpr size_t RTU2_PHASE3_ACTIVE_POWER = 48; // length = 2 +constexpr size_t RTU2_TODAY_PRODUCTION = 53; // length = 2 +constexpr size_t RTU2_TOTAL_ENERGY_PRODUCTION = 55; // length = 2 +constexpr size_t RTU2_INVERTER_MODULE_TEMP = 93; // length = 1 + class GrowattSolar : public PollingComponent, public modbus::ModbusDevice { public: void loop() override; From 9a6157b469225923d68baa501af4e29fe32df41d Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Wed, 27 May 2026 15:50:43 -0400 Subject: [PATCH 0253/1815] [tests] Sandbox PlatformIO paths in test_writer to fix xdist race (#16619) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- tests/unit_tests/test_writer.py | 36 +++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index fc49f03067..d6df559571 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -44,6 +44,42 @@ from esphome.writer import ( ) +@pytest.fixture(autouse=True) +def _isolate_platformio_paths(tmp_path_factory: pytest.TempPathFactory) -> Any: + """Sandbox PlatformIO path lookups so tests never touch ~/.platformio. + + `clean_all` and `clean_build` both query ProjectConfig for paths like + `cache_dir` and `core_dir` and `rmtree` anything that exists. By + default `core_dir` resolves to ~/.platformio, which is global state + shared across pytest-xdist workers — multiple workers can each pass + `is_dir()` and then race inside `shutil.rmtree`, producing + FileNotFoundError flakes (and trashing developers' local PIO state + when the suite is run outside CI). + + 14 of the 18 `clean_*` tests in this file invoke `clean_all` / + `clean_build` without installing their own ProjectConfig mock, so + making the fixture autouse is simpler than tagging each test + individually. + + Patch ProjectConfig.get_instance to point every PIO dir at a unique + tmp directory that doesn't actually exist on disk — `is_dir()` + returns False, so the rmtree loop is skipped entirely. Tests that + want to verify the PIO-cleanup branch (e.g. test_clean_all, + test_clean_all_partial_exists) install their own inner patch which + stacks on top of this one and wins for the duration of their block. + """ + pio_root = tmp_path_factory.mktemp("isolated_pio") / "nonexistent" + mock_cfg = MagicMock() + mock_cfg.get.side_effect = lambda section, option: ( + str(pio_root / option) if section == "platformio" else "" + ) + with patch( + "platformio.project.config.ProjectConfig.get_instance", + return_value=mock_cfg, + ): + yield + + @pytest.fixture def mock_copy_src_tree(): """Mock copy_src_tree to avoid side effects during tests.""" From ec597bfc0349b48dd03d110659b9181e7617df59 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 28 May 2026 14:54:42 +1200 Subject: [PATCH 0254/1815] [docs] Update esphome-docs references to esphome.io after repo rename (#16705) --- .claude/skills/pr-workflow/SKILL.md | 8 ++++---- .github/ISSUE_TEMPLATE/config.yml | 2 +- .github/PULL_REQUEST_TEMPLATE.md | 6 +++--- .github/scripts/auto-label-pr/constants.js | 3 +++ AGENTS.md | 4 ++-- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/.claude/skills/pr-workflow/SKILL.md b/.claude/skills/pr-workflow/SKILL.md index 4ec2551804..2c529dcd0f 100644 --- a/.claude/skills/pr-workflow/SKILL.md +++ b/.claude/skills/pr-workflow/SKILL.md @@ -29,7 +29,7 @@ Required fields: - **What does this implement/fix?**: Brief description of changes - **Types of changes**: Check ONE appropriate box (Bugfix, New feature, Breaking change, etc.) - **Related issue**: Use `fixes ` syntax if applicable -- **Pull request in esphome-docs**: Link if docs are needed +- **Pull request in esphome.io**: Link if docs are needed - **Test Environment**: Check platforms you tested on - **Example config.yaml**: Include working example YAML - **Checklist**: Verify code is tested and tests added @@ -54,9 +54,9 @@ Required fields: - fixes https://github.com/esphome/esphome/issues/XXX -**Pull request in [esphome-docs](https://github.com/esphome/esphome-docs) with documentation (if applicable):** +**Pull request in [esphome.io](https://github.com/esphome/esphome.io) with documentation (if applicable):** -- esphome/esphome-docs#XXX +- esphome/esphome.io#XXX ## Test Environment @@ -83,7 +83,7 @@ component_name: - [x] Tests have been added to verify that the new code works (under `tests/` folder). If user exposed functionality or configuration variables are added/changed: - - [ ] Documentation added/updated in [esphome-docs](https://github.com/esphome/esphome-docs). + - [ ] Documentation added/updated in [esphome.io](https://github.com/esphome/esphome.io). ``` ## 5. Push and Create PR diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 19f52349a6..3b39d519c4 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -2,7 +2,7 @@ blank_issues_enabled: false contact_links: - name: Report an issue with the ESPHome documentation - url: https://github.com/esphome/esphome-docs/issues/new/choose + url: https://github.com/esphome/esphome.io/issues/new/choose about: Report an issue with the ESPHome documentation. - name: Report an issue with the ESPHome web server url: https://github.com/esphome/esphome-webserver/issues/new/choose diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 72013e411e..08def88577 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -16,9 +16,9 @@ - fixes -**Pull request in [esphome-docs](https://github.com/esphome/esphome-docs) with documentation (if applicable):** +**Pull request in [esphome.io](https://github.com/esphome/esphome.io) with documentation (if applicable):** -- esphome/esphome-docs# +- esphome/esphome.io# ## Test Environment @@ -43,4 +43,4 @@ - [ ] Tests have been added to verify that the new code works (under `tests/` folder). If user exposed functionality or configuration variables are added/changed: - - [ ] Documentation added/updated in [esphome-docs](https://github.com/esphome/esphome-docs). + - [ ] Documentation added/updated in [esphome.io](https://github.com/esphome/esphome.io). diff --git a/.github/scripts/auto-label-pr/constants.js b/.github/scripts/auto-label-pr/constants.js index e02b450bf0..2938fd923c 100644 --- a/.github/scripts/auto-label-pr/constants.js +++ b/.github/scripts/auto-label-pr/constants.js @@ -35,6 +35,9 @@ module.exports = { ], DOCS_PR_PATTERNS: [ + /https:\/\/github\.com\/esphome\/esphome\.io\/pull\/\d+/, + /esphome\/esphome\.io#\d+/, + // Keep matching the old esphome-docs name during the transition period /https:\/\/github\.com\/esphome\/esphome-docs\/pull\/\d+/, /esphome\/esphome-docs#\d+/ ] diff --git a/AGENTS.md b/AGENTS.md index 2139a2b796..4adc53cae9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -462,7 +462,7 @@ This document provides essential context for AI models interacting with this pro 6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title should have a prefix of the component being worked on (e.g., `[display] Fix bug`, `[abc123] Add new component`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template. * **Documentation Contributions:** - * Documentation is hosted in the separate `esphome/esphome-docs` repository. + * Documentation is hosted in the separate `esphome/esphome.io` repository. * The contribution workflow is the same as for the codebase. * When editing a component's documentation page, also update the corresponding component index page to ensure both pages remain in sync. @@ -681,7 +681,7 @@ This document provides essential context for AI models interacting with this pro - [ ] Explored non-breaking alternatives - [ ] Added deprecation warnings if possible (use `ESPDEPRECATED` macro for C++) - [ ] Documented migration path in PR description with before/after examples - - [ ] Updated all internal usage and esphome-docs + - [ ] Updated all internal usage and esphome.io - [ ] Tested backward compatibility during deprecation period * **Deprecation Pattern (C++):** From 5732d7135f395b7a3b5de5cec4c8ffd2b48b08bf Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Thu, 28 May 2026 14:39:11 +0200 Subject: [PATCH 0255/1815] [network] move ipv6 enforcement to validation step (#16701) --- esphome/components/network/__init__.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 3bb14a05a7..b662293ab5 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -108,6 +108,13 @@ def has_high_performance_networking() -> bool: return CORE.data.get(KEY_HIGH_PERFORMANCE_NETWORKING, False) +def validate_ipv6(value: bool) -> bool: + if CORE.is_nrf52 and not value: + raise cv.Invalid("On nRF52, enable_ipv6 must be true") + + return value + + CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(NetworkComponent), @@ -133,6 +140,7 @@ CONFIG_SCHEMA = cv.Schema( ), cv.boolean_false, ), + validate_ipv6, ), cv.Optional(CONF_MIN_IPV6_ADDR_COUNT, default=0): cv.positive_int, cv.Optional(CONF_ENABLE_HIGH_PERFORMANCE): cv.All(cv.boolean, cv.only_on_esp32), @@ -209,13 +217,6 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_LWIP_TCPIP_RECVMBOX_SIZE", 64) if CORE.is_nrf52: - enable_ipv6 = config.get(CONF_ENABLE_IPV6, True) - if not enable_ipv6: - _LOGGER.warning( - "IPv6 cannot be disabled on nRF52 because the Zephyr IPAddress implementation is IPv6-only. " - "Forcing CONFIG_NET_IPV6=y." - ) - config[CONF_ENABLE_IPV6] = True zephyr_add_prj_conf("NETWORKING", True) zephyr_add_prj_conf("NET_IPV6", True) zephyr_add_prj_conf("NET_TCP", True) From f41866a9b8ba9b8711e325f367733354bf2b5d4b Mon Sep 17 00:00:00 2001 From: Mischa Siekmann <45062894+gnumpi@users.noreply.github.com> Date: Thu, 28 May 2026 15:11:48 +0200 Subject: [PATCH 0256/1815] [gpio][binary_sensor] Fix pin validation for external GPIO pins (#16528) --- esphome/components/gpio/binary_sensor/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/gpio/binary_sensor/__init__.py b/esphome/components/gpio/binary_sensor/__init__.py index 390b26ba1d..f14a920c24 100644 --- a/esphome/components/gpio/binary_sensor/__init__.py +++ b/esphome/components/gpio/binary_sensor/__init__.py @@ -74,8 +74,6 @@ def _final_validate(config): if not use_interrupt: return config - pin_num = config[CONF_PIN][CONF_NUMBER] - # Expander pins (e.g. PCF8574, MCP23017) don't support direct interrupt # attachment — only internal/native GPIO pins do. if pins.PIN_SCHEMA_REGISTRY.get_key(config[CONF_PIN]) != CORE.target_platform: @@ -87,6 +85,8 @@ def _final_validate(config): config[CONF_USE_INTERRUPT] = False return config + pin_num = config[CONF_PIN][CONF_NUMBER] + # GPIO16 on ESP8266 doesn't support interrupts through attachInterrupt(). if CORE.is_esp8266 and pin_num == 16: _LOGGER.warning( From 4b8e06b5bc454b0de2af07522a0c3a4e71625c49 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 09:12:35 -0400 Subject: [PATCH 0257/1815] Bump tornado from 6.5.5 to 6.5.6 (#16704) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 14dddbb1aa..17b618dde7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 icmplib==3.0.4 -tornado==6.5.5 +tornado==6.5.6 tzlocal==5.3.1 # from time tzdata>=2026.2 # from time pyserial==3.5 From 8945550c6c375d4097d4b0ae620ce2da30f6162c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 03:35:37 +0000 Subject: [PATCH 0258/1815] Bump ruff from 0.15.14 to 0.15.15 (#16712) Co-authored-by: J. Nick Koston Signed-off-by: dependabot[bot] --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0470a948f5..a076128975 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.14 + rev: v0.15.15 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index aad1da0807..203cd2bbea 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.14 # also change in .pre-commit-config.yaml when updating +ruff==0.15.15 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From a85f8ad9359c041eedbb64ccd6ea3c1ca2f1e6df Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 29 May 2026 00:28:08 -0400 Subject: [PATCH 0259/1815] [core] Use esp_rom_crc.h public API instead of legacy rom/crc.h (#16698) --- esphome/core/helpers.cpp | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 1eb3345491..112dde7c45 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -15,7 +15,7 @@ #include #ifdef USE_ESP32 -#include "rom/crc.h" +#include "esp_rom_crc.h" #endif namespace esphome { @@ -47,7 +47,7 @@ static const uint16_t CRC16_8408_LE_LUT_H[] = {0x0000, 0x1081, 0x2102, 0x3183, 0 0x8408, 0x9489, 0xa50a, 0xb58b, 0xc60c, 0xd68d, 0xe70e, 0xf78f}; #endif -#if !defined(USE_ESP32) || defined(USE_ESP32_VARIANT_ESP32S2) +#ifndef USE_ESP32 static const uint16_t CRC16_1021_BE_LUT_L[] = {0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50a5, 0x60c6, 0x70e7, 0x8108, 0x9129, 0xa14a, 0xb16b, 0xc18c, 0xd1ad, 0xe1ce, 0xf1ef}; static const uint16_t CRC16_1021_BE_LUT_H[] = {0x0000, 0x1231, 0x2462, 0x3653, 0x48c4, 0x5af5, 0x6ca6, 0x7e97, @@ -86,7 +86,7 @@ uint8_t crc8(const uint8_t *data, uint8_t len, uint8_t crc, uint8_t poly, bool m uint16_t crc16(const uint8_t *data, uint16_t len, uint16_t crc, uint16_t reverse_poly, bool refin, bool refout) { #ifdef USE_ESP32 if (reverse_poly == 0x8408) { - crc = crc16_le(refin ? crc : (crc ^ 0xffff), data, len); + crc = esp_rom_crc16_le(refin ? crc : (crc ^ 0xffff), data, len); return refout ? crc : (crc ^ 0xffff); } #endif @@ -124,23 +124,24 @@ uint16_t crc16(const uint8_t *data, uint16_t len, uint16_t crc, uint16_t reverse } uint16_t crc16be(const uint8_t *data, uint16_t len, uint16_t crc, uint16_t poly, bool refin, bool refout) { -#if defined(USE_ESP32) && !defined(USE_ESP32_VARIANT_ESP32S2) +#ifdef USE_ESP32 if (poly == 0x1021) { - crc = crc16_be(refin ? crc : (crc ^ 0xffff), data, len); + crc = esp_rom_crc16_be(refin ? crc : (crc ^ 0xffff), data, len); return refout ? crc : (crc ^ 0xffff); } #endif if (refin) { crc ^= 0xffff; } -#if !defined(USE_ESP32) || defined(USE_ESP32_VARIANT_ESP32S2) +#ifndef USE_ESP32 if (poly == 0x1021) { while (len--) { uint8_t combo = (crc >> 8) ^ *data++; crc = (crc << 8) ^ CRC16_1021_BE_LUT_L[combo & 0x0F] ^ CRC16_1021_BE_LUT_H[combo >> 4]; } - } else { + } else #endif + { while (len--) { crc ^= (((uint16_t) *data++) << 8); for (uint8_t i = 0; i < 8; i++) { @@ -151,9 +152,7 @@ uint16_t crc16be(const uint8_t *data, uint16_t len, uint16_t crc, uint16_t poly, } } } -#if !defined(USE_ESP32) || defined(USE_ESP32_VARIANT_ESP32S2) } -#endif return refout ? (crc ^ 0xffff) : crc; } From 10abb0647c5c4204a1d4c3270b8f78284b50dbb5 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 29 May 2026 00:30:52 -0400 Subject: [PATCH 0260/1815] [esp32] Add ESP32-S31, ESP32-H4 and ESP32-H21 variant scaffolding (#16700) --- esphome/components/esp32/__init__.py | 32 ++++++++++++++++-- esphome/components/esp32/boards.py | 4 --- esphome/components/esp32/const.py | 9 +++++ esphome/components/esp32/gpio.py | 18 ++++++++++ esphome/components/esp32/gpio_esp32_h21.py | 34 +++++++++++++++++++ esphome/components/esp32/gpio_esp32_h4.py | 34 +++++++++++++++++++ esphome/components/esp32/gpio_esp32_s31.py | 38 ++++++++++++++++++++++ esphome/components/logger/__init__.py | 9 +++++ esphome/core/defines.h | 5 ++- tests/component_tests/esp32/test_esp32.py | 16 ++++++++- 10 files changed, 190 insertions(+), 9 deletions(-) create mode 100644 esphome/components/esp32/gpio_esp32_h21.py create mode 100644 esphome/components/esp32/gpio_esp32_h4.py create mode 100644 esphome/components/esp32/gpio_esp32_s31.py diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index ac0d2eaba2..4e3ffdc1e4 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -78,9 +78,12 @@ from .const import ( VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, VARIANT_FRIENDLY, VARIANTS, ) @@ -403,9 +406,12 @@ CPU_FREQUENCIES = { VARIANT_ESP32C6: get_cpu_frequencies(80, 120, 160), VARIANT_ESP32C61: get_cpu_frequencies(80, 120, 160), VARIANT_ESP32H2: get_cpu_frequencies(16, 32, 48, 64, 96), + VARIANT_ESP32H4: get_cpu_frequencies(48, 64, 96), + VARIANT_ESP32H21: get_cpu_frequencies(48, 64, 96), VARIANT_ESP32P4: get_cpu_frequencies(40, 360, 400), VARIANT_ESP32S2: get_cpu_frequencies(80, 160, 240), VARIANT_ESP32S3: get_cpu_frequencies(80, 160, 240), + VARIANT_ESP32S31: get_cpu_frequencies(240, 320), } # Make sure not missed here if a new variant added. @@ -907,11 +913,16 @@ def _validate_toolchain(value) -> Toolchain: return Toolchain(cv.one_of(*(t.value for t in Toolchain), lower=True)(value)) -def _check_versions(config): +def _resolve_toolchain(value: ConfigType) -> ConfigType: # Resolve toolchain: CLI (already on CORE.toolchain) > YAML > default. + # Runs before _detect_variant so downstream validators can rely on + # CORE.toolchain instead of re-resolving it from the config dict. if CORE.toolchain is None: - CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO) + CORE.toolchain = value.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO) + return value + +def _check_versions(config: ConfigType) -> ConfigType: if CORE.using_toolchain_esp_idf: return _check_esp_idf_versions(config) return _check_pio_versions(config) @@ -933,7 +944,21 @@ def _detect_variant(value): variant = value.get(CONF_VARIANT) if variant and board is None: # If variant is set, we can derive the board from it - # variant has already been validated against the known set + # variant has already been validated against the known set. + # PlatformIO needs a real board name to find its board file; the + # ESP-IDF toolchain only uses CONF_BOARD as the informational + # ESPHOME_BOARD string, so synthesize one from the friendly variant + # name rather than carrying a PIO board name through the IDF build. + if CORE.using_toolchain_esp_idf: + value = value.copy() + value[CONF_BOARD] = VARIANT_FRIENDLY[variant].lower() + return value + if variant not in STANDARD_BOARDS: + raise cv.Invalid( + f"No default board is known for {variant}. " + f"Please specify the `board:` option explicitly.", + path=[CONF_VARIANT], + ) value = value.copy() value[CONF_BOARD] = STANDARD_BOARDS[variant] if variant == VARIANT_ESP32P4: @@ -1606,6 +1631,7 @@ CONFIG_SCHEMA = cv.All( ), } ), + _resolve_toolchain, _detect_variant, _set_default_framework, _check_versions, diff --git a/esphome/components/esp32/boards.py b/esphome/components/esp32/boards.py index 2c73fe7d08..6062631d98 100644 --- a/esphome/components/esp32/boards.py +++ b/esphome/components/esp32/boards.py @@ -9,7 +9,6 @@ from .const import ( VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, - VARIANTS, ) STANDARD_BOARDS = { @@ -25,9 +24,6 @@ STANDARD_BOARDS = { VARIANT_ESP32S3: "esp32-s3-devkitc-1", } -# Make sure not missed here if a new variant added. -assert all(v in STANDARD_BOARDS for v in VARIANTS) - ESP32_BASE_PINS = { "TX": 1, "RX": 3, diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index d0d00723fc..322054ea91 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -24,9 +24,12 @@ VARIANT_ESP32C5 = "ESP32C5" VARIANT_ESP32C6 = "ESP32C6" VARIANT_ESP32C61 = "ESP32C61" VARIANT_ESP32H2 = "ESP32H2" +VARIANT_ESP32H4 = "ESP32H4" +VARIANT_ESP32H21 = "ESP32H21" VARIANT_ESP32P4 = "ESP32P4" VARIANT_ESP32S2 = "ESP32S2" VARIANT_ESP32S3 = "ESP32S3" +VARIANT_ESP32S31 = "ESP32S31" VARIANTS = [ VARIANT_ESP32, VARIANT_ESP32C2, @@ -35,9 +38,12 @@ VARIANTS = [ VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, ] VARIANT_FRIENDLY = { @@ -48,9 +54,12 @@ VARIANT_FRIENDLY = { VARIANT_ESP32C6: "ESP32-C6", VARIANT_ESP32C61: "ESP32-C61", VARIANT_ESP32H2: "ESP32-H2", + VARIANT_ESP32H4: "ESP32-H4", + VARIANT_ESP32H21: "ESP32-H21", VARIANT_ESP32P4: "ESP32-P4", VARIANT_ESP32S2: "ESP32-S2", VARIANT_ESP32S3: "ESP32-S3", + VARIANT_ESP32S31: "ESP32-S31", } esp32_ns = cg.esphome_ns.namespace("esp32") diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index 36dd44155a..2ff39cab69 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -31,9 +31,12 @@ from .const import ( VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, esp32_ns, ) from .gpio_esp32 import esp32_validate_gpio_pin, esp32_validate_supports @@ -43,9 +46,12 @@ from .gpio_esp32_c5 import esp32_c5_validate_gpio_pin, esp32_c5_validate_support from .gpio_esp32_c6 import esp32_c6_validate_gpio_pin, esp32_c6_validate_supports from .gpio_esp32_c61 import esp32_c61_validate_gpio_pin, esp32_c61_validate_supports from .gpio_esp32_h2 import esp32_h2_validate_gpio_pin, esp32_h2_validate_supports +from .gpio_esp32_h4 import esp32_h4_validate_gpio_pin, esp32_h4_validate_supports +from .gpio_esp32_h21 import esp32_h21_validate_gpio_pin, esp32_h21_validate_supports from .gpio_esp32_p4 import esp32_p4_validate_gpio_pin, esp32_p4_validate_supports from .gpio_esp32_s2 import esp32_s2_validate_gpio_pin, esp32_s2_validate_supports from .gpio_esp32_s3 import esp32_s3_validate_gpio_pin, esp32_s3_validate_supports +from .gpio_esp32_s31 import esp32_s31_validate_gpio_pin, esp32_s31_validate_supports ESP32InternalGPIOPin = esp32_ns.class_("ESP32InternalGPIOPin", cg.InternalGPIOPin) @@ -120,6 +126,14 @@ _esp32_validations = { pin_validation=esp32_h2_validate_gpio_pin, usage_validation=esp32_h2_validate_supports, ), + VARIANT_ESP32H4: ESP32ValidationFunctions( + pin_validation=esp32_h4_validate_gpio_pin, + usage_validation=esp32_h4_validate_supports, + ), + VARIANT_ESP32H21: ESP32ValidationFunctions( + pin_validation=esp32_h21_validate_gpio_pin, + usage_validation=esp32_h21_validate_supports, + ), VARIANT_ESP32P4: ESP32ValidationFunctions( pin_validation=esp32_p4_validate_gpio_pin, usage_validation=esp32_p4_validate_supports, @@ -132,6 +146,10 @@ _esp32_validations = { pin_validation=esp32_s3_validate_gpio_pin, usage_validation=esp32_s3_validate_supports, ), + VARIANT_ESP32S31: ESP32ValidationFunctions( + pin_validation=esp32_s31_validate_gpio_pin, + usage_validation=esp32_s31_validate_supports, + ), } diff --git a/esphome/components/esp32/gpio_esp32_h21.py b/esphome/components/esp32/gpio_esp32_h21.py new file mode 100644 index 0000000000..5ab1b7c074 --- /dev/null +++ b/esphome/components/esp32/gpio_esp32_h21.py @@ -0,0 +1,34 @@ +import logging +from typing import Any + +import esphome.config_validation as cv +from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.pins import check_strapping_pin + +# Partial set from the ESP-IDF / esptool boot-mode docs: +# https://docs.espressif.com/projects/esptool/en/latest/esp32h21/advanced-topics/boot-mode-selection.html +# The full list awaits the ESP32-H21 datasheet's "Strapping Pins" section. +_ESP32H21_STRAPPING_PINS: set[int] = {13, 14} + +_LOGGER = logging.getLogger(__name__) + + +def esp32_h21_validate_gpio_pin(value: int) -> int: + if value < 0 or value > 25: + raise cv.Invalid(f"Invalid pin number: {value} (must be 0-25)") + return value + + +def esp32_h21_validate_supports(value: dict[str, Any]) -> dict[str, Any]: + num = value[CONF_NUMBER] + mode = value[CONF_MODE] + is_input = mode[CONF_INPUT] + + if num < 0 or num > 25: + raise cv.Invalid(f"Invalid pin number: {num} (must be 0-25)") + if is_input: + # All ESP32 pins support input mode + pass + + check_strapping_pin(value, _ESP32H21_STRAPPING_PINS, _LOGGER) + return value diff --git a/esphome/components/esp32/gpio_esp32_h4.py b/esphome/components/esp32/gpio_esp32_h4.py new file mode 100644 index 0000000000..86a4d55858 --- /dev/null +++ b/esphome/components/esp32/gpio_esp32_h4.py @@ -0,0 +1,34 @@ +import logging +from typing import Any + +import esphome.config_validation as cv +from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.pins import check_strapping_pin + +# Partial set from the ESP-IDF / esptool boot-mode docs: +# https://docs.espressif.com/projects/esptool/en/latest/esp32h4/advanced-topics/boot-mode-selection.html +# The full list awaits the ESP32-H4 datasheet's "Strapping Pins" section. +_ESP32H4_STRAPPING_PINS: set[int] = {13, 14} + +_LOGGER = logging.getLogger(__name__) + + +def esp32_h4_validate_gpio_pin(value: int) -> int: + if value < 0 or value > 39: + raise cv.Invalid(f"Invalid pin number: {value} (must be 0-39)") + return value + + +def esp32_h4_validate_supports(value: dict[str, Any]) -> dict[str, Any]: + num = value[CONF_NUMBER] + mode = value[CONF_MODE] + is_input = mode[CONF_INPUT] + + if num < 0 or num > 39: + raise cv.Invalid(f"Invalid pin number: {num} (must be 0-39)") + if is_input: + # All ESP32 pins support input mode + pass + + check_strapping_pin(value, _ESP32H4_STRAPPING_PINS, _LOGGER) + return value diff --git a/esphome/components/esp32/gpio_esp32_s31.py b/esphome/components/esp32/gpio_esp32_s31.py new file mode 100644 index 0000000000..6a19e3fee4 --- /dev/null +++ b/esphome/components/esp32/gpio_esp32_s31.py @@ -0,0 +1,38 @@ +import logging +from typing import Any + +import esphome.config_validation as cv +from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.pins import check_strapping_pin + +# Per the ESP32-S31 datasheet (page 96): +# https://documentation.espressif.com/esp32-s31_datasheet_en.pdf +_ESP32S31_SPI_FLASH_PINS: set[int] = {27, 28, 29, 31, 32, 33} +_ESP32S31_STRAPPING_PINS: set[int] = {60, 61} + +_LOGGER = logging.getLogger(__name__) + + +def esp32_s31_validate_gpio_pin(value: int) -> int: + if value < 0 or value > 61: + raise cv.Invalid(f"Invalid pin number: {value} (must be 0-61)") + if value in _ESP32S31_SPI_FLASH_PINS: + raise cv.Invalid( + f"GPIO{value} is reserved for the SPI flash interface on ESP32-S31 and cannot be used." + ) + return value + + +def esp32_s31_validate_supports(value: dict[str, Any]) -> dict[str, Any]: + num = value[CONF_NUMBER] + mode = value[CONF_MODE] + is_input = mode[CONF_INPUT] + + if num < 0 or num > 61: + raise cv.Invalid(f"Invalid pin number: {num} (must be 0-61)") + if is_input: + # All ESP32 pins support input mode + pass + + check_strapping_pin(value, _ESP32S31_STRAPPING_PINS, _LOGGER) + return value diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 5f160352cc..e4921ae196 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -11,9 +11,12 @@ from esphome.components.esp32 import ( VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, add_idf_sdkconfig_option, get_esp32_variant, require_usb_serial_jtag_secondary, @@ -113,9 +116,12 @@ UART_SELECTION_ESP32 = { VARIANT_ESP32C6: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32C61: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32H2: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], + VARIANT_ESP32H4: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], + VARIANT_ESP32H21: [UART0, UART1, USB_SERIAL_JTAG], VARIANT_ESP32P4: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32S2: [UART0, UART1, USB_CDC], VARIANT_ESP32S3: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], + VARIANT_ESP32S31: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], } UART_SELECTION_ESP8266 = [UART0, UART0_SWAP, UART1] @@ -270,9 +276,12 @@ CONFIG_SCHEMA = cv.All( esp32_c6=USB_SERIAL_JTAG, esp32_c61=USB_SERIAL_JTAG, esp32_h2=USB_SERIAL_JTAG, + esp32_h4=USB_SERIAL_JTAG, + esp32_h21=USB_SERIAL_JTAG, esp32_p4=USB_SERIAL_JTAG, esp32_s2=USB_CDC, esp32_s3=USB_SERIAL_JTAG, + esp32_s31=USB_SERIAL_JTAG, rp2040=USB_CDC, bk72xx=DEFAULT, ln882x=DEFAULT, diff --git a/esphome/core/defines.h b/esphome/core/defines.h index f536467e2f..765c1aa3b2 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -354,9 +354,12 @@ #if defined(USE_ESP32_VARIANT_ESP32S2) #define USE_LOGGER_USB_CDC #define USE_LOGGER_UART_SELECTION_USB_CDC +#elif defined(USE_ESP32_VARIANT_ESP32H21) +#define USE_LOGGER_USB_SERIAL_JTAG #elif defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || \ - defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S3) + defined(USE_ESP32_VARIANT_ESP32H4) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) #define USE_LOGGER_USB_CDC #define USE_LOGGER_UART_SELECTION_USB_CDC #define USE_LOGGER_USB_SERIAL_JTAG diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index f0f96e9adc..e0fcbab0ee 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -45,11 +45,20 @@ def test_esp32_config( config = CONFIG_SCHEMA(config) assert config["variant"] == VARIANT_ESP32 - # Check that defining a variant sets the board name correctly + # Check that defining a variant sets the board name correctly. + # Run under the ESP-IDF toolchain so variants without an entry in + # STANDARD_BOARDS (S31, H4, H21) still derive a board name from + # VARIANT_FRIENDLY rather than failing with cv.Invalid. CORE.toolchain + # gets pinned by the first CONFIG_SCHEMA() call above (via + # _resolve_toolchain) and that pinned value wins over the dict's + # CONF_TOOLCHAIN, so clear it between iterations to mirror a fresh + # config run. for variant in VARIANTS: + CORE.toolchain = None config = CONFIG_SCHEMA( { "variant": variant, + "toolchain": Toolchain.ESP_IDF.value, } ) assert VARIANT_FRIENDLY[variant].lower() in config["board"] @@ -73,6 +82,11 @@ def test_esp32_config( r"Option 'variant' does not match selected board. @ data\['variant'\]", id="mismatched_board_variant_config", ), + pytest.param( + {"variant": "esp32s31"}, + r"No default board is known for ESP32S31\. Please specify the `board:` option explicitly\. @ data\['variant'\]", + id="variant_without_default_board_requires_explicit_board_under_platformio", + ), pytest.param( { "variant": "esp32s2", From dd961156d098ec3656a67021bd68e5c0a77633e6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 29 May 2026 00:54:14 -0400 Subject: [PATCH 0261/1815] [ledc] Adapt to LEDC LL API changes in ESP-IDF 6.1 (#16697) --- esphome/components/ledc/ledc_output.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/ledc/ledc_output.cpp b/esphome/components/ledc/ledc_output.cpp index 5b7b6c7ee6..bfb629143d 100644 --- a/esphome/components/ledc/ledc_output.cpp +++ b/esphome/components/ledc/ledc_output.cpp @@ -53,7 +53,11 @@ static_assert( "re-evaluate for this target"); static bool ledc_duty_update_pending(ledc_mode_t speed_mode, ledc_channel_t chan_num) { +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0) + auto *hw = LEDC_LL_GET_HW(0); +#else auto *hw = LEDC_LL_GET_HW(); +#endif return hw->channel_group[speed_mode].channel[chan_num].conf1.duty_start != 0; } #endif @@ -161,7 +165,9 @@ void LEDCOutput::write_state(float state) { void LEDCOutput::setup() { if (!ledc_peripheral_reset_done) { ESP_LOGV(TAG, "Resetting LEDC peripheral to clear stale state after reboot"); -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0) + PERIPH_RCC_ATOMIC() { ledc_ll_reset_register(0); } +#elif ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) PERIPH_RCC_ATOMIC() { ledc_ll_enable_reset_reg(true); ledc_ll_enable_reset_reg(false); From 091a05ccde035ed9812aaedabf8b171f9d6aacb7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 29 May 2026 01:16:55 -0400 Subject: [PATCH 0262/1815] [esp32_camera] Enable PicolibC Newlib compatibility on IDF 6.0+ (#16703) --- esphome/components/camera_encoder/__init__.py | 7 ++- esphome/components/esp32/__init__.py | 43 ++++++++++++++----- esphome/components/esp32_camera/__init__.py | 8 +++- 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/esphome/components/camera_encoder/__init__.py b/esphome/components/camera_encoder/__init__.py index a0c59a517a..7d4cdc881e 100644 --- a/esphome/components/camera_encoder/__init__.py +++ b/esphome/components/camera_encoder/__init__.py @@ -1,5 +1,8 @@ import esphome.codegen as cg -from esphome.components.esp32 import add_idf_component +from esphome.components.esp32 import ( + add_idf_component, + require_libc_picolibc_newlib_compat, +) import esphome.config_validation as cv from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TYPE from esphome.types import ConfigType @@ -51,6 +54,8 @@ async def to_code(config: ConfigType) -> None: cg.add(buffer.set_buffer_size(config[CONF_BUFFER_SIZE])) if config[CONF_TYPE] == ESP32_CAMERA_ENCODER: add_idf_component(name="espressif/esp32-camera", ref="2.1.5") + # esp32-camera 2.1.5 needs the Newlib shim on IDF 6.0+; remove when fixed upstream + require_libc_picolibc_newlib_compat() cg.add_define("USE_ESP32_CAMERA_JPEG_ENCODER") var = cg.new_Pvariable( config[CONF_ID], diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 4e3ffdc1e4..beb41b30f4 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1245,6 +1245,7 @@ KEY_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required" KEY_FATFS_REQUIRED = "fatfs_required" KEY_MBEDTLS_SHA512_REQUIRED = "mbedtls_sha512_required" KEY_ADC_ONESHOT_IRAM_REQUIRED = "adc_oneshot_iram_required" +KEY_LIBC_PICOLIBC_NEWLIB_COMPAT_REQUIRED = "libc_picolibc_newlib_compat_required" def require_vfs_select() -> None: @@ -1353,6 +1354,15 @@ def require_adc_oneshot_iram() -> None: CORE.data[KEY_ESP32][KEY_ADC_ONESHOT_IRAM_REQUIRED] = True +def require_libc_picolibc_newlib_compat() -> None: + """Keep CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY enabled on IDF 6.0+. + + Call this from components that link against precompiled Newlib binaries + referencing types/symbols the shim provides (e.g. esp32-camera). + """ + CORE.data[KEY_ESP32][KEY_LIBC_PICOLIBC_NEWLIB_COMPAT_REQUIRED] = True + + def _parse_idf_component(value: str) -> ConfigType: """Parse IDF component shorthand syntax like 'owner/component^version'""" # Match operator followed by version-like string (digit or *) @@ -1758,6 +1768,26 @@ async def _write_arduino_libraries_sdkconfig() -> None: add_idf_sdkconfig_option(f"CONFIG_ARDUINO_SELECTIVE_{lib}", lib in enabled_libs) +@coroutine_with_priority(CoroPriority.FINAL) +async def _set_libc_picolibc_newlib_compat() -> None: + """Apply the PicolibC Newlib compatibility shim option on IDF 6.0+. + + IDF 6.0 switched from Newlib to PicolibC; the shim is disabled by default. + Runs at FINAL priority so every require_libc_picolibc_newlib_compat() call + (default priority) is seen before the option is written. A user-supplied + sdkconfig_options value takes precedence. + """ + if idf_version() < cv.Version(6, 0, 0): + return + option = "CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY" + if option in CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]: + return + add_idf_sdkconfig_option( + option, + CORE.data[KEY_ESP32].get(KEY_LIBC_PICOLIBC_NEWLIB_COMPAT_REQUIRED, False), + ) + + @coroutine_with_priority(CoroPriority.FINAL) async def _add_yaml_idf_components(components: list[ConfigType]): """Add IDF components from YAML config with final priority to override code-added components.""" @@ -2291,17 +2321,8 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_MBEDTLS_SHA384_C", False) add_idf_sdkconfig_option("CONFIG_MBEDTLS_SHA512_C", False) - # Disable PicolibC Newlib compatibility shim on IDF 6.0+ - # IDF 6.0 switched from Newlib to PicolibC. The shim provides thread-local - # stdin/stdout/stderr and getreent() for code compiled against Newlib. - # ESPHome doesn't link against Newlib-built libraries that use stdio. - # If a component needs it (e.g. precompiled Newlib binaries), re-enable via: - # esp32: - # framework: - # sdkconfig_options: - # CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY: "y" - if idf_version() >= cv.Version(6, 0, 0): - add_idf_sdkconfig_option("CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY", False) + # FINAL priority: runs after every require_libc_picolibc_newlib_compat() call + CORE.add_job(_set_libc_picolibc_newlib_compat) # Disable regi2c control functions in IRAM # Only needed if using analog peripherals (ADC, DAC, etc.) from ISRs while cache is disabled diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index 9883a0a43e..763a1f3405 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -3,7 +3,11 @@ import logging from esphome import automation, pins import esphome.codegen as cg from esphome.components import i2c -from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option +from esphome.components.esp32 import ( + add_idf_component, + add_idf_sdkconfig_option, + require_libc_picolibc_newlib_compat, +) from esphome.components.psram import DOMAIN as psram_domain import esphome.config_validation as cv from esphome.const import ( @@ -402,6 +406,8 @@ async def to_code(config): add_idf_component(name="espressif/esp32-camera", ref="2.1.5") add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_NEW", True) add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_LEGACY", False) + # esp32-camera 2.1.5 needs the Newlib shim on IDF 6.0+; remove when fixed upstream + require_libc_picolibc_newlib_compat() for conf in config.get(CONF_ON_STREAM_START, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) From 07a57d7557bc451f4e66dc0f81818bd98112b35d Mon Sep 17 00:00:00 2001 From: Fyleo Date: Sat, 30 May 2026 05:03:42 +0200 Subject: [PATCH 0263/1815] [sx126x] fix a typo in image calibration on 863 - 870 Mhz frequency (#16731) --- esphome/components/sx126x/sx126x.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index 83afeac50a..aed0105e1f 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -394,7 +394,7 @@ void SX126x::run_image_cal() { buf[1] = 0xE9; } else if (this->frequency_ > 850000000) { buf[0] = 0xD7; - buf[1] = 0xD8; + buf[1] = 0xDB; } else if (this->frequency_ > 770000000) { buf[0] = 0xC1; buf[1] = 0xC5; From f0202155b318f42b8166d5d2046d0ea036a14616 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 30 May 2026 00:09:07 -0500 Subject: [PATCH 0264/1815] [core] Persist esphome.area in StorageJSON (#16710) --- esphome/core/config.py | 1 + esphome/storage_json.py | 7 +++++ tests/unit_tests/core/test_config.py | 27 +++++++++++++++++++ tests/unit_tests/test_storage_json.py | 38 +++++++++++++++++++++++++++ 4 files changed, 73 insertions(+) diff --git a/esphome/core/config.py b/esphome/core/config.py index 6125c4ecc9..8214fcf80c 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -722,6 +722,7 @@ async def to_code(config: ConfigType) -> None: # Process areas all_areas: list[dict[str, str | core.ID]] = [] if CONF_AREA in config: + CORE.area = config[CONF_AREA][CONF_NAME] all_areas.append(config[CONF_AREA]) all_areas.extend(config[CONF_AREAS]) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 3df12f3985..ba576fcfd7 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -100,6 +100,7 @@ class StorageJSON: framework: str | None = None, core_platform: str | None = None, toolchain: str | None = None, + area: str | None = None, ) -> None: # Version of the storage JSON schema assert storage_version is None or isinstance(storage_version, int) @@ -138,6 +139,8 @@ class StorageJSON: self.core_platform = core_platform # The toolchain used for the build ("platformio" / "esp-idf") self.toolchain = toolchain + # The area of the node + self.area = area def as_dict(self): return { @@ -158,6 +161,7 @@ class StorageJSON: "framework": self.framework, "core_platform": self.core_platform, "toolchain": self.toolchain, + "area": self.area, } def to_json(self): @@ -195,6 +199,7 @@ class StorageJSON: framework=esph.target_framework, core_platform=esph.target_platform, toolchain=esph.toolchain.value if esph.toolchain is not None else None, + area=esph.area, ) @staticmethod @@ -243,6 +248,7 @@ class StorageJSON: framework = storage.get("framework") core_platform = storage.get("core_platform") toolchain = storage.get("toolchain") + area = storage.get("area") return StorageJSON( storage_version, name, @@ -261,6 +267,7 @@ class StorageJSON: framework, core_platform, toolchain, + area, ) @staticmethod diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index b5b35b5172..ff150f2540 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -140,6 +140,33 @@ def test_multiple_areas_and_devices(yaml_file: Callable[[str], str]) -> None: } +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::RuntimeWarning") +@pytest.mark.parametrize( + ("fixture", "expected_area"), + [ + ("legacy_string_area.yaml", "Living Room"), + ("multiple_areas_devices.yaml", "Main Area"), + ], +) +async def test_to_code_records_core_area( + yaml_file: Callable[[str], Path], + fixture: str, + expected_area: str, +) -> None: + """``to_code`` records the node's area name on CORE for StorageJSON.""" + result = load_config_from_fixture(yaml_file, fixture, FIXTURES_DIR) + assert result is not None + assert CORE.area is None + + with patch("esphome.core.config.cg") as mock_cg: + mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock() + mock_cg.RawExpression.side_effect = lambda *args, **kwargs: MagicMock() + await config.to_code(result[CONF_ESPHOME]) + + assert CORE.area == expected_area + + def test_legacy_string_area( yaml_file: Callable[[str], str], caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index b3f8a05605..105d78505f 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -205,6 +205,7 @@ def test_storage_json_as_dict() -> None: no_mdns=True, framework="arduino", core_platform="esp32", + area="Living Room", ) result = storage.as_dict() @@ -233,6 +234,7 @@ def test_storage_json_as_dict() -> None: assert result["no_mdns"] is True assert result["framework"] == "arduino" assert result["core_platform"] == "esp32" + assert result["area"] == "Living Room" def test_storage_json_to_json() -> None: @@ -309,6 +311,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: mock_core.config = {CONF_MDNS: {CONF_DISABLED: True}} mock_core.target_framework = "esp-idf" mock_core.toolchain = Toolchain.ESP_IDF + mock_core.area = "Living Room" with patch("esphome.components.esp32.get_esp32_variant") as mock_variant: mock_variant.return_value = "ESP32-C3" @@ -329,6 +332,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: assert result.framework == "esp-idf" assert result.core_platform == "esp32" assert result.toolchain == "esp-idf" + assert result.area == "Living Room" def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: @@ -729,3 +733,37 @@ def test_storage_json_load_legacy_esphomeyaml_version(tmp_path: Path) -> None: assert result is not None assert result.esphome_version == "1.14.0" # Should map to esphome_version + + +def test_storage_json_load_area(tmp_path: Path) -> None: + """``area`` round-trips through load; absence loads as None.""" + file_path = tmp_path / "with_area.json" + file_path.write_text( + json.dumps( + { + "storage_version": 1, + "name": "lamp", + "friendly_name": "Lamp", + "esp_platform": "ESP32", + "area": "Living Room", + } + ) + ) + result = storage_json.StorageJSON.load(file_path) + assert result is not None + assert result.area == "Living Room" + + legacy_path = tmp_path / "no_area.json" + legacy_path.write_text( + json.dumps( + { + "storage_version": 1, + "name": "lamp", + "friendly_name": "Lamp", + "esp_platform": "ESP32", + } + ) + ) + legacy = storage_json.StorageJSON.load(legacy_path) + assert legacy is not None + assert legacy.area is None From 95397948b9a2a55514c4de84c131f082cb825213 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 00:09:27 -0500 Subject: [PATCH 0265/1815] Bump CodSpeedHQ/action from 4.15.1 to 4.17.0 (#16730) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53516db913..63efff1b3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -452,7 +452,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@3194d9a39c4d46684cb44bf7207fc56626aad8fd # v4.15.1 + uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # v4.17.0 with: run: | . venv/bin/activate From bf621240324db870dbce2909acdfafb0cffa949a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 30 May 2026 07:43:21 -0400 Subject: [PATCH 0266/1815] [esp32] Refine ESP-IDF framework version suffix handling (#16726) --- esphome/components/esp32/__init__.py | 35 ++++++++++++++++++++-------- esphome/espidf/framework.py | 9 +++---- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index beb41b30f4..d2dc979966 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -470,21 +470,20 @@ def set_core_data(config): framework_ver = cv.Version.parse(config[CONF_FRAMEWORK][CONF_VERSION]) CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = framework_ver - # Store the underlying IDF version for framework-agnostic checks + # Store the underlying IDF version for framework-agnostic checks. if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: - CORE.data[KEY_ESP32][KEY_IDF_VERSION] = framework_ver - elif (idf_ver := ARDUINO_IDF_VERSION_LOOKUP.get(framework_ver)) is not None: - if CORE.using_toolchain_esp_idf: - # Official ESP-IDF frameworks don't use extra - idf_ver = cv.Version(idf_ver.major, idf_ver.minor, idf_ver.patch) - CORE.data[KEY_ESP32][KEY_IDF_VERSION] = idf_ver - else: + idf_ver = framework_ver + elif (idf_ver := ARDUINO_IDF_VERSION_LOOKUP.get(framework_ver)) is None: raise cv.Invalid( f"Arduino version {framework_ver} has no known ESP-IDF version mapping. " "Please update ARDUINO_IDF_VERSION_LOOKUP.", path=[CONF_FRAMEWORK, CONF_VERSION], ) + # The esp-idf toolchain doesn't use pioarduino's packaging revision; PIO does. + if CORE.using_toolchain_esp_idf: + idf_ver = _strip_pioarduino_revision(idf_ver) + CORE.data[KEY_ESP32][KEY_IDF_VERSION] = idf_ver CORE.data[KEY_ESP32][KEY_BOARD] = config[CONF_BOARD] CORE.data[KEY_ESP32][KEY_FLASH_SIZE] = config[CONF_FLASH_SIZE] CORE.data[KEY_ESP32][KEY_VARIANT] = variant @@ -721,6 +720,9 @@ ARDUINO_FRAMEWORK_VERSION_LOOKUP = { "dev": cv.Version(3, 3, 8), } ARDUINO_PLATFORM_VERSION_LOOKUP = { + cv.Version( + 4, 0, 0, "alpha1" + ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", cv.Version(3, 3, 8): cv.Version(55, 3, 38, "1"), cv.Version(3, 3, 7): cv.Version(55, 3, 37), cv.Version(3, 3, 6): cv.Version(55, 3, 36), @@ -741,6 +743,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = { # These versions correspond to pioarduino/esp-idf releases # See: https://github.com/pioarduino/esp-idf/releases ARDUINO_IDF_VERSION_LOOKUP = { + cv.Version(4, 0, 0, "alpha1"): cv.Version(6, 0, 1), cv.Version(3, 3, 8): cv.Version(5, 5, 4), cv.Version(3, 3, 7): cv.Version(5, 5, 3, "1"), cv.Version(3, 3, 6): cv.Version(5, 5, 2), @@ -835,6 +838,16 @@ def _resolve_framework_version(value: ConfigType) -> cv.Version: return version +def _strip_pioarduino_revision(ver: cv.Version) -> cv.Version: + """Drop a numeric 'extra' (pioarduino packaging revision, e.g. "5.5.3-1"). + + Alphanumeric prerelease extras (e.g. "6.0.0-rc1") are kept. + """ + if ver.extra.isdigit(): + return cv.Version(ver.major, ver.minor, ver.patch) + return ver + + def _check_pio_versions(config: ConfigType) -> ConfigType: config = config.copy() value = config[CONF_FRAMEWORK] @@ -903,8 +916,10 @@ def _check_esp_idf_versions(config: ConfigType) -> ConfigType: "If there are connectivity or build issues please remove the manual source." ) - # Official ESP-IDF frameworks don't use the 'extra' semver component. - value[CONF_VERSION] = str(cv.Version(version.major, version.minor, version.patch)) + # esp-idf framework only: drop pioarduino's packaging revision (config + download). + # Arduino keeps its extra (it's the arduino-esp32 release tag / lookup key). + if value[CONF_TYPE] == FRAMEWORK_ESP_IDF: + value[CONF_VERSION] = str(_strip_pioarduino_revision(version)) return config diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index b2251d00d8..6ef73a2199 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -74,7 +74,7 @@ ESPHOME_IDF_FRAMEWORK_MIRRORS = _str_to_lst_of_str( os.environ.get("ESPHOME_IDF_FRAMEWORK_MIRRORS") or [ "https://github.com/esphome-libs/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.tar.xz", - "https://github.com/esphome-libs/esp-idf/releases/download/v{MAJOR}.{MINOR}/esp-idf-v{MAJOR}.{MINOR}.tar.xz", + "https://github.com/esphome-libs/esp-idf/releases/download/v{MAJOR}.{MINOR}{EXTRA}/esp-idf-v{MAJOR}.{MINOR}{EXTRA}.tar.xz", ] ) @@ -979,8 +979,9 @@ def _check_esphome_idf_framework_install( env: Optional dictionary of environment variables to set source_url: Optional override URL for the framework tarball. Supports the same ``{VERSION}`` / ``{MAJOR}`` / ``{MINOR}`` / ``{PATCH}`` / - ``{EXTRA}`` substitutions as ESPHOME_IDF_FRAMEWORK_MIRRORS. When - set, it replaces the default mirror list — no implicit fallback, + ``{EXTRA}`` substitutions as ESPHOME_IDF_FRAMEWORK_MIRRORS + (``{EXTRA}`` includes its leading ``-``, e.g. ``-rc1``, or is empty). + When set, it replaces the default mirror list — no implicit fallback, so a misspelled URL fails loudly. Returns: @@ -1035,7 +1036,7 @@ def _check_esphome_idf_framework_install( substitutions["MAJOR"] = str(ver.major) substitutions["MINOR"] = str(ver.minor) substitutions["PATCH"] = str(ver.patch) - substitutions["EXTRA"] = ver.extra + substitutions["EXTRA"] = f"-{ver.extra}" if ver.extra else "" except ValueError: pass From 7865dc33bc8bb3420dab2d2115f6cd20ac5fe70d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 31 May 2026 10:50:17 -0400 Subject: [PATCH 0267/1815] [ethernet] Bump espressif/dm9051 to 1.1.0 (#16735) --- esphome/components/ethernet/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 3f88f8ef9a..22f5eb33e1 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -163,7 +163,7 @@ _IDF6_ETHERNET_COMPONENTS: dict[str, IDFRegistryComponent] = { "KSZ8081": IDFRegistryComponent("espressif/ksz80xx", "1.0.0"), "KSZ8081RNA": IDFRegistryComponent("espressif/ksz80xx", "1.0.0"), "W5500": IDFRegistryComponent("espressif/w5500", "1.0.1"), - "DM9051": IDFRegistryComponent("espressif/dm9051", "1.0.0"), + "DM9051": IDFRegistryComponent("espressif/dm9051", "1.1.0"), "ENC28J60": IDFRegistryComponent("espressif/enc28j60", "1.0.1"), "LAN8670": IDFRegistryComponent("espressif/lan867x", "2.0.0"), } diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 5af25fc351..9476b38b72 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -78,7 +78,7 @@ dependencies: rules: - if: "idf_version >=6.0.0" espressif/dm9051: - version: "1.0.0" + version: "1.1.0" rules: - if: "idf_version >=6.0.0" espressif/esp_tinyusb: From 48844a68badaed2568f4f0581de0d559331d16a4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 31 May 2026 16:29:16 -0400 Subject: [PATCH 0268/1815] [core] Clean build when the toolchain changes (#16744) --- esphome/writer.py | 16 ++++++++++++---- tests/unit_tests/test_writer.py | 25 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/esphome/writer.py b/esphome/writer.py index ef7cbf5ac4..84f2f8101a 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -90,10 +90,12 @@ def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: """Return True when the build tree must be wiped before reuse. Predicate is True when *old* is missing (first build), - ``src_version`` differs, ``build_path`` differs, or a previously - loaded integration was removed in *new*. Adding integrations or - changing unrelated fields (friendly name, esphome version, etc.) - does not trigger a clean. + ``src_version`` differs, ``build_path`` differs, the build + ``toolchain`` differs (e.g. switching between the PlatformIO and + native ESP-IDF toolchains, which produce incompatible build trees), + or a previously loaded integration was removed in *new*. Adding + integrations or changing unrelated fields (friendly name, esphome + version, etc.) does not trigger a clean. Used by esphome-device-builder (esphome/device-builder) to gate its remote-build artifact materialiser so a local → remote → local @@ -109,6 +111,8 @@ def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: return True if old.build_path != new.build_path: return True + if old.toolchain != new.toolchain: + return True # Check if any components have been removed return bool(old.loaded_integrations - new.loaded_integrations) @@ -505,6 +509,10 @@ def clean_build(clear_pio_cache: bool = True): if dependencies_lock.is_file(): _LOGGER.info("Deleting %s", dependencies_lock) dependencies_lock.unlink() + idedata_cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json") + if idedata_cache.is_file(): + _LOGGER.info("Deleting %s", idedata_cache) + idedata_cache.unlink() # Native ESP-IDF toolchain artifacts: the IDF CMake/ninja build dir # and the Component Manager's fetched managed components live under # the project's build path, not under .pioenvs / .piolibdeps. diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index d6df559571..6f137fb351 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -111,6 +111,7 @@ def create_storage() -> Callable[..., StorageJSON]: no_mdns=kwargs.get("no_mdns", False), framework=kwargs.get("framework", "arduino"), core_platform=kwargs.get("core_platform", "esp32"), + toolchain=kwargs.get("toolchain", "platformio"), ) return _create @@ -142,6 +143,20 @@ def test_storage_should_clean_when_build_path_changes( assert storage_should_clean(old, new) is True +def test_storage_should_clean_when_toolchain_changes( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when the build toolchain changes. + + Switching between the PlatformIO and native ESP-IDF toolchains produces + incompatible build trees (and toolchain-specific idedata), so the build + must be wiped. + """ + old = create_storage(loaded_integrations=["api", "wifi"], toolchain="platformio") + new = create_storage(loaded_integrations=["api", "wifi"], toolchain="esp-idf") + assert storage_should_clean(old, new) is True + + def test_storage_should_clean_when_component_removed( create_storage: Callable[..., StorageJSON], ) -> None: @@ -479,6 +494,11 @@ def test_clean_build( dependencies_lock = tmp_path / "dependencies.lock" dependencies_lock.write_text("lock file") + # idedata cache lives under the data dir, not the build path. + idedata_cache = tmp_path / "idedata" / "test.json" + idedata_cache.parent.mkdir() + idedata_cache.write_text("{}") + # Native ESP-IDF toolchain artifacts. idf_build_dir = tmp_path / "build" idf_build_dir.mkdir() @@ -499,11 +519,14 @@ def test_clean_build( mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir mock_core.relative_build_path.side_effect = lambda name: tmp_path / name + mock_core.name = "test" + mock_core.relative_internal_path.side_effect = tmp_path.joinpath # Verify all exist before assert pioenvs_dir.exists() assert piolibdeps_dir.exists() assert dependencies_lock.exists() + assert idedata_cache.exists() assert idf_build_dir.exists() assert managed_components_dir.exists() assert platformio_cache_dir.exists() @@ -528,6 +551,7 @@ def test_clean_build( assert not pioenvs_dir.exists() assert not piolibdeps_dir.exists() assert not dependencies_lock.exists() + assert not idedata_cache.exists() assert not idf_build_dir.exists() assert not managed_components_dir.exists() assert not platformio_cache_dir.exists() @@ -537,6 +561,7 @@ def test_clean_build( assert ".pioenvs" in caplog.text assert ".piolibdeps" in caplog.text assert "dependencies.lock" in caplog.text + assert str(idedata_cache) in caplog.text assert str(idf_build_dir) in caplog.text assert str(managed_components_dir) in caplog.text assert "PlatformIO cache" in caplog.text From 6116d10ab1f6f5168692d3fa6fc1de3152bfcb45 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 31 May 2026 17:44:12 -0400 Subject: [PATCH 0269/1815] [espidf] Derive idedata from the native ESP-IDF compile_commands.json (#16742) --- esphome/__main__.py | 1 + esphome/espidf/idedata.py | 178 ++++++++++++++++++++ esphome/espidf/toolchain.py | 28 ++++ tests/unit_tests/test_espidf_idedata.py | 196 ++++++++++++++++++++++ tests/unit_tests/test_espidf_toolchain.py | 92 ++++++++++ 5 files changed, 495 insertions(+) create mode 100644 esphome/espidf/idedata.py create mode 100644 tests/unit_tests/test_espidf_idedata.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 000087063f..cc179ebf98 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -760,6 +760,7 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int: toolchain.create_factory_bin() toolchain.create_ota_bin() toolchain.create_elf_copy() + toolchain.get_idedata() else: from esphome.platformio import toolchain diff --git a/esphome/espidf/idedata.py b/esphome/espidf/idedata.py new file mode 100644 index 0000000000..6fce8a55d9 --- /dev/null +++ b/esphome/espidf/idedata.py @@ -0,0 +1,178 @@ +"""Derive idedata from an ESP-IDF native-toolchain ``compile_commands.json``. + +PlatformIO exposes a curated ``pio run -t idedata`` JSON; the native ESP-IDF +toolchain has no such command, but its CMake build emits +``build/compile_commands.json`` (CMAKE_EXPORT_COMPILE_COMMANDS). This module +turns that file into the same fields consumers (IDE integration, clang-tidy) +expect: + + {cxx_path, cxx_flags, defines, includes: {build, toolchain}} +""" + +from __future__ import annotations + +import json +import logging +import os +from pathlib import Path +import shlex +import subprocess + +_LOGGER = logging.getLogger(__name__) + +# C++ translation-unit suffixes used to identify ESPHome source files. +_CXX_SUFFIXES = (".cpp", ".cc") +# Suffixes of input/output files that appear bare on the command line (and so +# must not be mistaken for compiler flags). +_INPUT_FILE_SUFFIXES = (*_CXX_SUFFIXES, ".c", ".o", ".S", ".s") +# Path marker identifying an ESPHome source translation unit. +_ESPHOME_SRC_MARKER = "/src/esphome/" + + +def _expand_response_files(tokens: list[str], directory: Path) -> list[str]: + """Inline any ``@response-file`` arguments (paths relative to ``directory``). + + GCC response files embed flags that must be expanded so GCC-only flags + inside them (e.g. ``-mlongcalls``) can be filtered downstream; left as + ``@file`` clang would read them and choke. + """ + out: list[str] = [] + for tok in tokens: + if tok.startswith("@"): + rf = Path(tok[1:]) + if not rf.is_absolute(): + rf = directory / rf + try: + out.extend( + _expand_response_files( + shlex.split(rf.read_text(encoding="utf-8")), directory + ) + ) + continue + except OSError as err: + # Keep the literal token if the file can't be read, but log it + # so the (otherwise opaque) downstream clang failure is traceable. + _LOGGER.warning("Could not read response file %s: %s", rf, err) + out.append(tok) + return out + + +def _pick_entry(entries: list[dict]) -> dict: + """Pick a representative ESPHome C++ translation unit. + + All ESPHome sources share the same component flags/defines, so any one of + them yields the cxx_path / cxx_flags / defines we need. + """ + for entry in entries: + f = entry["file"] + if _ESPHOME_SRC_MARKER in f and f.endswith(_CXX_SUFFIXES): + return entry + for entry in entries: + if entry["file"].endswith(_CXX_SUFFIXES): + return entry + raise ValueError("no C++ translation unit found in compile_commands.json") + + +def _parse_entry(entry: dict) -> tuple[str, list[str], list[str], list[str]]: + """Parse one compile_commands entry -> (cxx_path, defines, includes, cxx_flags).""" + directory = Path(entry["directory"]) + tokens = _expand_response_files(shlex.split(entry["command"]), directory) + + def _include(raw: str) -> str: + # Include paths in compile_commands are interpreted relative to the + # entry's ``directory`` (e.g. build-local ``-Iconfig``); resolve them + # so the cached idedata is usable regardless of the consumer's cwd. + raw = raw.strip() + if raw and not Path(raw).is_absolute(): + raw = os.path.normpath(directory / raw) + return raw + + cxx_path = tokens[0] + defines: list[str] = [] + includes: list[str] = [] + cxx_flags: list[str] = [] + + it = iter(tokens[1:]) + for tok in it: + if tok in ("-c", "-o"): + next(it, None) # drop the flag and its argument (input/output) + elif tok.startswith("-D"): + # ``.strip()`` handles tokens like ``-D CONFIGURED=1`` (a single + # quoted arg with a space after -D) that some flags arrive as. + defines.append(tok[2:].strip() if len(tok) > 2 else next(it, "").strip()) + elif tok.startswith("-I"): + includes.append(_include(tok[2:] if len(tok) > 2 else next(it, ""))) + elif tok == "-isystem": + includes.append(_include(next(it, ""))) + elif tok.startswith("-isystem"): + includes.append(_include(tok[len("-isystem") :])) + elif tok in ("-MT", "-MF", "-MQ"): + next(it, None) # dependency-file flag + its argument + elif tok.startswith(("-MD", "-MMD", "-MP", "-MM")): + pass # dependency-generation flags, no argument + elif tok.endswith(_INPUT_FILE_SUFFIXES): + pass # input/output files + else: + cxx_flags.append(tok) + return cxx_path, defines, includes, cxx_flags + + +def _get_toolchain_includes(cxx_path: str) -> list[str]: + """Query the compiler for its builtin ``#include <...>`` search dirs.""" + result = subprocess.run( + [cxx_path, "-E", "-x", "c++", "-", "-v"], + input="", + text=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + check=False, + close_fds=False, + ) + includes: list[str] = [] + capture = False + for line in result.stderr.splitlines(): + if "#include <...> search starts here:" in line: + capture = True + continue + if "End of search list." in line: + break + if capture: + includes.append(line.strip()) + if result.returncode != 0 or not includes: + raise RuntimeError( + f"Could not query builtin include dirs from {cxx_path} " + f"(return code {result.returncode}); stderr:\n{result.stderr.strip()}" + ) + return includes + + +def idedata_from_build(compile_commands: Path) -> dict: + """Parse compile_commands.json into the idedata fields consumers expect. + + A single ESP-IDF compile entry only carries its own component's REQUIRES + include set, but consumers (clang-tidy) analyze ESPHome headers that + transitively pull in other components. So take cxx_path / cxx_flags / + defines from a representative ESPHome TU, but union the include dirs across + all ESPHome TUs to get a project-wide superset (as PlatformIO's idedata + provides). + """ + entries = json.loads(Path(compile_commands).read_text(encoding="utf-8")) + cxx_path, defines, _, cxx_flags = _parse_entry(_pick_entry(entries)) + + build_includes: dict[str, None] = {} + for entry in entries: + f = entry["file"] + if _ESPHOME_SRC_MARKER not in f or not f.endswith(_CXX_SUFFIXES): + continue + for inc in _parse_entry(entry)[2]: + build_includes.setdefault(inc, None) + + return { + "cxx_path": cxx_path, + "cxx_flags": cxx_flags, + "defines": defines, + "includes": { + "build": list(build_includes), + "toolchain": _get_toolchain_includes(cxx_path), + }, + } diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 752f582e74..2fef3faf8d 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -443,6 +443,34 @@ def get_addr2line_path() -> Path: return _get_cmake_tool_path("CMAKE_ADDR2LINE") +def get_idedata() -> dict | None: + """Derive idedata from the build's compile_commands.json. + + The native ESP-IDF toolchain has no ``pio run -t idedata`` equivalent, but + its CMake build emits ``build/compile_commands.json``. Parse that into the + idedata fields IDE integrations and clang-tidy expect, cached alongside the + PlatformIO idedata path. Returns None if the compile DB doesn't exist yet. + """ + from esphome.espidf.idedata import idedata_from_build + + compile_commands = CORE.relative_build_path("build", "compile_commands.json") + if not compile_commands.is_file(): + _LOGGER.debug("No %s yet; skipping idedata generation", compile_commands) + return None + + cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json") + if cache.is_file() and cache.stat().st_mtime >= compile_commands.stat().st_mtime: + try: + return json.loads(cache.read_text(encoding="utf-8")) + except ValueError: + pass + + data = idedata_from_build(compile_commands) + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + return data + + def create_factory_bin() -> bool: """Create factory.bin by merging bootloader, partition table, and app.""" build_dir = CORE.relative_build_path("build") diff --git a/tests/unit_tests/test_espidf_idedata.py b/tests/unit_tests/test_espidf_idedata.py new file mode 100644 index 0000000000..849ef274ed --- /dev/null +++ b/tests/unit_tests/test_espidf_idedata.py @@ -0,0 +1,196 @@ +"""Tests for esphome.espidf.idedata (compile_commands.json -> idedata).""" + +# pylint: disable=protected-access + +import json +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from esphome.espidf import idedata + +# An absolute, forward-slash (shlex-safe) path prefix valid on the host OS, so +# tests exercise the same is-absolute / normalize behavior as a real compile DB +# (a drive-qualified path on Windows, a leading slash elsewhere). +ABS = "C:/" if os.name == "nt" else "/" + + +def _entry(directory: str, file: str, command: str) -> dict: + return {"directory": directory, "file": file, "command": command} + + +def test_parse_entry_extracts_fields() -> None: + """cxx_path, defines, includes and remaining flags are split apart.""" + entry = _entry( + f"{ABS}build", + f"{ABS}build/src/esphome/core/application.cpp", + f"/tools/xtensa-esp32-elf-g++ -DUSE_ESP32 -DESPHOME_LOG_LEVEL=5 " + f"-I{ABS}inc/a -isystem {ABS}sys/b -std=gnu++20 -c app.cpp -o app.cpp.o", + ) + + cxx_path, defines, includes, cxx_flags = idedata._parse_entry(entry) + + assert cxx_path == "/tools/xtensa-esp32-elf-g++" + assert "USE_ESP32" in defines + assert "ESPHOME_LOG_LEVEL=5" in defines + assert f"{ABS}inc/a" in includes + assert f"{ABS}sys/b" in includes + assert "-std=gnu++20" in cxx_flags + # input/output files and their flags are not treated as flags + assert "-c" not in cxx_flags + assert "-o" not in cxx_flags + assert "app.cpp" not in cxx_flags + assert "app.cpp.o" not in cxx_flags + + +def test_parse_entry_space_separated_args() -> None: + """``-D X`` / ``-I path`` (separate arg) and ``-isystem`` (joined).""" + entry = _entry( + f"{ABS}build", + f"{ABS}build/src/esphome/x.cpp", + f"g++ -D FOO=1 -I {ABS}inc/sep -isystem{ABS}sys/joined -c x.cpp", + ) + + _, defines, includes, _ = idedata._parse_entry(entry) + + assert "FOO=1" in defines + assert f"{ABS}inc/sep" in includes + assert f"{ABS}sys/joined" in includes + + +def test_parse_entry_resolves_relative_includes() -> None: + """Relative includes are resolved against the entry's ``directory``.""" + directory = f"{ABS}build/proj" + entry = _entry( + directory, + f"{directory}/src/esphome/x.cpp", + "g++ -Iconfig -I../shared -isystem rel/sys -c x.cpp", + ) + + _, _, includes, _ = idedata._parse_entry(entry) + + def resolved(rel: str) -> str: + return os.path.normpath(Path(directory) / rel) + + assert resolved("config") in includes + assert resolved("../shared") in includes # ../ normalized away + assert resolved("rel/sys") in includes + # nothing is left relative + assert all(Path(inc).is_absolute() for inc in includes) + + +def test_parse_entry_skips_dependency_flags() -> None: + """Dependency-generation flags (and their args) are dropped.""" + entry = _entry( + "/build", + "/build/src/esphome/x.cpp", + "g++ -MD -MT x.cpp.o -MF x.cpp.o.d -c x.cpp -o x.cpp.o", + ) + + _, _, _, cxx_flags = idedata._parse_entry(entry) + + for tok in ("-MD", "-MT", "x.cpp.o", "-MF", "x.cpp.o.d", "-c", "-o", "x.cpp"): + assert tok not in cxx_flags + + +def test_expand_response_files(tmp_path: Path) -> None: + """``@file`` arguments are inlined relative to the directory.""" + rsp = tmp_path / "flags.rsp" + rsp.write_text("-DFROM_RSP -I/rsp/inc") + + tokens = idedata._expand_response_files( + ["g++", f"@{rsp.name}", "-c", "x.cpp"], tmp_path + ) + + assert "-DFROM_RSP" in tokens + assert "-I/rsp/inc" in tokens + assert not any(t.startswith("@") for t in tokens) + + +def test_expand_response_files_keeps_literal_when_missing(tmp_path: Path) -> None: + """An unreadable ``@file`` token is kept verbatim rather than dropped.""" + tokens = idedata._expand_response_files(["g++", "@nope.rsp"], tmp_path) + assert "@nope.rsp" in tokens + + +def test_pick_entry_prefers_esphome_tu() -> None: + """A ``/src/esphome/`` C++ TU is picked over other compile entries.""" + entries = [ + _entry("/b", "/b/managed_components/foo/foo.c", "gcc -c foo.c"), + _entry("/b", "/b/src/esphome/core/app.cpp", "g++ -c app.cpp"), + ] + assert idedata._pick_entry(entries)["file"].endswith("app.cpp") + + +def test_idedata_from_build(tmp_path: Path) -> None: + """Full transform: representative entry + include union + toolchain dirs.""" + compile_commands = tmp_path / "compile_commands.json" + entries = [ + _entry( + f"{ABS}b", + f"{ABS}b/src/esphome/core/app.cpp", + f"g++ -DUSE_ESP32 -I{ABS}inc/core -std=gnu++20 -c app.cpp -o app.cpp.o", + ), + _entry( + f"{ABS}b", + f"{ABS}b/src/esphome/sensor/s.cpp", + f"g++ -DUSE_ESP32 -I{ABS}inc/sensor -c s.cpp -o s.cpp.o", + ), + # non-esphome TU: its includes must not leak into the union + _entry( + f"{ABS}b", + f"{ABS}b/managed_components/x/x.c", + f"gcc -I{ABS}inc/managed -c x.c", + ), + ] + compile_commands.write_text(json.dumps(entries)) + + fake_proc = MagicMock( + returncode=0, + stderr=( + "ignored\n" + "#include <...> search starts here:\n" + " /tc/inc/c++\n" + " /tc/inc\n" + "End of search list.\n" + "more ignored\n" + ), + ) + with patch.object(idedata.subprocess, "run", return_value=fake_proc): + data = idedata.idedata_from_build(compile_commands) + + assert data["cxx_path"] == "g++" + assert "USE_ESP32" in data["defines"] + assert "-std=gnu++20" in data["cxx_flags"] + # include dirs unioned across all esphome TUs + assert f"{ABS}inc/core" in data["includes"]["build"] + assert f"{ABS}inc/sensor" in data["includes"]["build"] + # the non-esphome TU is excluded from the union + assert f"{ABS}inc/managed" not in data["includes"]["build"] + # toolchain search dirs parsed from the compiler's -v output + assert data["includes"]["toolchain"] == ["/tc/inc/c++", "/tc/inc"] + + +def test_get_toolchain_includes_raises_on_probe_failure() -> None: + """A failed compiler probe is a hard error, not a silent empty list.""" + fake_proc = MagicMock(returncode=1, stderr="xtensa-esp32-elf-g++: not found") + with ( + patch.object(idedata.subprocess, "run", return_value=fake_proc), + pytest.raises(RuntimeError, match="builtin include dirs"), + ): + idedata._get_toolchain_includes("/bad/compiler") + + +def test_get_toolchain_includes_raises_when_no_dirs_found() -> None: + """Markers present but no dirs (anomalous output) also raises.""" + fake_proc = MagicMock( + returncode=0, + stderr="#include <...> search starts here:\nEnd of search list.\n", + ) + with ( + patch.object(idedata.subprocess, "run", return_value=fake_proc), + pytest.raises(RuntimeError, match="builtin include dirs"), + ): + idedata._get_toolchain_includes("/some/compiler") diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index adc8bfce63..d00d8662f5 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -2,6 +2,9 @@ # pylint: disable=protected-access +import json +import os +from pathlib import Path from unittest.mock import patch from esphome.const import CONF_FRAMEWORK, CONF_SOURCE @@ -56,3 +59,92 @@ def test_get_esphome_esp_idf_paths_no_override(): ) as mock_install: toolchain._get_esphome_esp_idf_paths("5.5.4") mock_install.assert_called_once_with("5.5.4", source_url=None) + + +def _setup_build(setup_core: Path) -> tuple[Path, Path]: + """Point CORE at a build dir; return (compile_commands, idedata cache) paths.""" + CORE.name = "test" + CORE.build_path = setup_core / "build" / "test" + compile_commands = CORE.relative_build_path("build", "compile_commands.json") + cache = CORE.relative_internal_path("idedata", "test.json") + return compile_commands, cache + + +def test_get_idedata_returns_none_without_compile_commands(setup_core: Path) -> None: + """No compile DB yet -> None (rather than an error).""" + _setup_build(setup_core) + assert toolchain.get_idedata() is None + + +def test_get_idedata_generates_and_caches(setup_core: Path) -> None: + """Generates from the compile DB and writes the cache.""" + compile_commands, cache = _setup_build(setup_core) + compile_commands.parent.mkdir(parents=True, exist_ok=True) + compile_commands.write_text("[]") + + with patch( + "esphome.espidf.idedata.idedata_from_build", + return_value={"cxx_path": "g++"}, + ) as mock_transform: + result = toolchain.get_idedata() + + mock_transform.assert_called_once() + assert result == {"cxx_path": "g++"} + assert json.loads(cache.read_text()) == {"cxx_path": "g++"} + + +def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None: + """A cache at least as new as the compile DB is reused without regenerating.""" + compile_commands, cache = _setup_build(setup_core) + compile_commands.parent.mkdir(parents=True, exist_ok=True) + compile_commands.write_text("[]") + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text('{"cxx_path": "cached"}') + cc_mtime = compile_commands.stat().st_mtime + os.utime(cache, (cc_mtime + 1, cc_mtime + 1)) + + with patch("esphome.espidf.idedata.idedata_from_build") as mock_transform: + result = toolchain.get_idedata() + + mock_transform.assert_not_called() + assert result == {"cxx_path": "cached"} + + +def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) -> None: + """A compile DB newer than the cache forces regeneration.""" + compile_commands, cache = _setup_build(setup_core) + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text('{"cxx_path": "stale"}') + compile_commands.parent.mkdir(parents=True, exist_ok=True) + compile_commands.write_text("[]") + cache_mtime = cache.stat().st_mtime + os.utime(compile_commands, (cache_mtime + 1, cache_mtime + 1)) + + with patch( + "esphome.espidf.idedata.idedata_from_build", + return_value={"cxx_path": "fresh"}, + ) as mock_transform: + result = toolchain.get_idedata() + + mock_transform.assert_called_once() + assert result == {"cxx_path": "fresh"} + + +def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None: + """An unparseable (but newer) cache falls back to regeneration.""" + compile_commands, cache = _setup_build(setup_core) + compile_commands.parent.mkdir(parents=True, exist_ok=True) + compile_commands.write_text("[]") + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text("{not json") + cc_mtime = compile_commands.stat().st_mtime + os.utime(cache, (cc_mtime + 1, cc_mtime + 1)) + + with patch( + "esphome.espidf.idedata.idedata_from_build", + return_value={"cxx_path": "regen"}, + ) as mock_transform: + result = toolchain.get_idedata() + + mock_transform.assert_called_once() + assert result == {"cxx_path": "regen"} From 805aa252d537490b4f73121acbd11646b4cfc11f Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 2 Jun 2026 03:30:05 +1000 Subject: [PATCH 0270/1815] [const] Move CONF_SHA256 to common code (#16751) --- esphome/components/const/__init__.py | 1 + .../esp32_hosted/update/__init__.py | 2 +- esphome/components/shelly_dimmer/light.py | 2 +- tests/components/const/common.yaml | 37 ------------------- tests/components/const/test.esp32-s3-idf.yaml | 4 -- 5 files changed, 3 insertions(+), 43 deletions(-) delete mode 100644 tests/components/const/common.yaml delete mode 100644 tests/components/const/test.esp32-s3-idf.yaml diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 6f418b48ea..3f7777883e 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -22,6 +22,7 @@ CONF_PARITY = "parity" CONF_RECEIVER_FREQUENCY = "receiver_frequency" CONF_REQUEST_HEADERS = "request_headers" CONF_ROWS = "rows" +CONF_SHA256 = "sha256" CONF_STOP_BITS = "stop_bits" CONF_USE_PSRAM = "use_psram" CONF_VOLUME_INCREMENT = "volume_increment" diff --git a/esphome/components/esp32_hosted/update/__init__.py b/esphome/components/esp32_hosted/update/__init__.py index 202df21ab5..8e85cce75a 100644 --- a/esphome/components/esp32_hosted/update/__init__.py +++ b/esphome/components/esp32_hosted/update/__init__.py @@ -3,6 +3,7 @@ from typing import Any import esphome.codegen as cg from esphome.components import esp32, update +from esphome.components.const import CONF_SHA256 import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PATH, CONF_SOURCE, CONF_TYPE from esphome.core import CORE, ID, HexInt @@ -11,7 +12,6 @@ CODEOWNERS = ["@swoboda1337"] AUTO_LOAD = ["sha256", "watchdog", "json"] DEPENDENCIES = ["esp32_hosted"] -CONF_SHA256 = "sha256" CONF_HTTP_REQUEST_ID = "http_request_id" TYPE_EMBEDDED = "embedded" diff --git a/esphome/components/shelly_dimmer/light.py b/esphome/components/shelly_dimmer/light.py index 97538e13c9..ddf7fa161b 100644 --- a/esphome/components/shelly_dimmer/light.py +++ b/esphome/components/shelly_dimmer/light.py @@ -7,6 +7,7 @@ import requests from esphome import pins import esphome.codegen as cg from esphome.components import light, sensor, uart +from esphome.components.const import CONF_SHA256 import esphome.config_validation as cv from esphome.const import ( CONF_CURRENT, @@ -39,7 +40,6 @@ ShellyDimmer = shelly_dimmer_ns.class_( ) CONF_FIRMWARE = "firmware" -CONF_SHA256 = "sha256" CONF_UPDATE = "update" CONF_LEADING_EDGE = "leading_edge" diff --git a/tests/components/const/common.yaml b/tests/components/const/common.yaml deleted file mode 100644 index 109db65b63..0000000000 --- a/tests/components/const/common.yaml +++ /dev/null @@ -1,37 +0,0 @@ -display: - - platform: qspi_dbi - model: RM690B0 - data_rate: 80MHz - spi_mode: mode0 - dimensions: - width: 450 - height: 600 - offset_width: 16 - color_order: rgb - invert_colors: false - brightness: 255 - cs_pin: 11 - reset_pin: 13 - enable_pin: 9 - - - platform: qspi_dbi - model: CUSTOM - id: main_lcd - draw_from_origin: true - dimensions: - height: 240 - width: 536 - transform: - mirror_x: true - swap_xy: true - color_order: rgb - brightness: 255 - cs_pin: 6 - reset_pin: 17 - enable_pin: 38 - init_sequence: - - [0x3A, 0x66] - - [0x11] - - delay 120ms - - [0x29] - - delay 20ms diff --git a/tests/components/const/test.esp32-s3-idf.yaml b/tests/components/const/test.esp32-s3-idf.yaml deleted file mode 100644 index c335dee1f3..0000000000 --- a/tests/components/const/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,4 +0,0 @@ -packages: - qspi: !include ../../test_build_components/common/qspi/esp32-s3-idf.yaml - -<<: !include common.yaml From 4e4868246818a1e2bbe13c11ff82dd64f07ad747 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 1 Jun 2026 14:18:29 -0500 Subject: [PATCH 0271/1815] [wifi] Defer esp_wifi_init() to lazy-init so enable_on_boot: false actually saves RAM (#16606) Co-authored-by: Claude Opus 4.7 (1M context) --- esphome/components/wifi/wifi_component.cpp | 8 +++++++ esphome/components/wifi/wifi_component.h | 12 ++++++++++ .../wifi/wifi_component_esp_idf.cpp | 22 ++++++++++++++++--- .../wifi/test-lifecycle.esp32-idf.yaml | 15 +++++++++++++ 4 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 tests/components/wifi/test-lifecycle.esp32-idf.yaml diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index fdbd70bc61..07cb2ac243 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -632,6 +632,9 @@ void WiFiComponent::setup() { #endif if (this->enable_on_boot_) { +#ifdef USE_ESP32 + this->wifi_lazy_init_(); +#endif this->start(); } else { this->state_ = WIFI_COMPONENT_STATE_DISABLED; @@ -1275,6 +1278,11 @@ void WiFiComponent::enable() { ESP_LOGD(TAG, "Enabling"); this->state_ = WIFI_COMPONENT_STATE_OFF; +#ifdef USE_ESP32 + // Idempotent — only allocates DMA buffers + netifs on the first call. After this, + // start() can safely run. + this->wifi_lazy_init_(); +#endif this->start(); } diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 0437267a1f..d0521e548a 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -694,6 +694,12 @@ class WiFiComponent final : public Component { bool wifi_apply_hostname_(); bool wifi_sta_connect_(const WiFiAP &ap); void wifi_pre_setup_(); +#ifdef USE_ESP32 + // ESP-IDF only: defers esp_wifi_init() + netif creation (which allocate ~15-30KB of + // DMA-capable internal SRAM) until wifi actually needs to come up. Idempotent. + // Called from setup() only when enable_on_boot_=true, and from enable() on first use. + void wifi_lazy_init_(); +#endif WiFiSTAConnectStatus wifi_sta_connect_status_() const; bool is_connected_() const { return this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTED && @@ -889,6 +895,12 @@ class WiFiComponent final : public Component { bool rrm_{false}; #endif bool enable_on_boot_{true}; +#ifdef USE_ESP32 + // Tracks whether esp_wifi_init() + netif creation has happened. Allows enable() + // to be called at runtime without re-allocating, and ensures the heavy init is + // skipped entirely when enable_on_boot_ is false until first enable(). + bool wifi_initialized_{false}; +#endif bool got_ipv4_address_{false}; bool keep_scan_results_{false}; bool has_completed_scan_after_captive_portal_start_{ diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 11b39b5000..b395c77141 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -163,11 +163,26 @@ void WiFiComponent::wifi_pre_setup_() { ESP_LOGE(TAG, "esp_event_handler_instance_register failed: %s", esp_err_to_name(err)); return; } + // NOTE: netif creation + esp_wifi_init() used to live here. They allocate ~15-30KB of + // DMA-capable internal SRAM, which competes with W5500 SPI DMA and I2S DMA on + // memory-tight devices. They are now deferred to wifi_lazy_init_(), called from + // setup() when enable_on_boot_ is true, or from enable() on first runtime enable. + // This makes enable_on_boot:false genuinely skip the wifi DMA allocation. +} - s_sta_netif = esp_netif_create_default_wifi_sta(); +void WiFiComponent::wifi_lazy_init_() { + if (this->wifi_initialized_) + return; + + // Guard each creation so partial init (e.g. a failed esp_wifi_init() below) + // followed by a retry via enable() does not leak the existing netif handle + // nor re-register the default WiFi handlers. + if (s_sta_netif == nullptr) + s_sta_netif = esp_netif_create_default_wifi_sta(); #ifdef USE_WIFI_AP - s_ap_netif = esp_netif_create_default_wifi_ap(); + if (s_ap_netif == nullptr) + s_ap_netif = esp_netif_create_default_wifi_ap(); #endif // USE_WIFI_AP wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); @@ -175,7 +190,7 @@ void WiFiComponent::wifi_pre_setup_() { ESP_LOGW(TAG, "starting wifi without nvs"); cfg.nvs_enable = false; } - err = esp_wifi_init(&cfg); + esp_err_t err = esp_wifi_init(&cfg); if (err != ERR_OK) { ESP_LOGE(TAG, "esp_wifi_init failed: %s", esp_err_to_name(err)); return; @@ -185,6 +200,7 @@ void WiFiComponent::wifi_pre_setup_() { ESP_LOGE(TAG, "esp_wifi_set_storage failed: %s", esp_err_to_name(err)); return; } + this->wifi_initialized_ = true; } bool WiFiComponent::wifi_mode_(optional sta, optional ap) { diff --git a/tests/components/wifi/test-lifecycle.esp32-idf.yaml b/tests/components/wifi/test-lifecycle.esp32-idf.yaml new file mode 100644 index 0000000000..229a24b2d1 --- /dev/null +++ b/tests/components/wifi/test-lifecycle.esp32-idf.yaml @@ -0,0 +1,15 @@ +wifi: + ssid: MySSID + password: password1 + enable_on_boot: false + +esphome: + on_boot: + priority: 200 + then: + - if: + condition: + not: + wifi.enabled: + then: + - wifi.enable: From 2454ad1645321a8cde03f5a0e167c6f2b0ed5b2c Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 1 Jun 2026 15:30:07 -0500 Subject: [PATCH 0272/1815] [ethernet] Add enable_on_boot lifecycle + lazy-init to reclaim DMA-capable SRAM (#16607) Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/ethernet/__init__.py | 28 +++++++ esphome/components/ethernet/automation.h | 30 ++++++++ .../components/ethernet/ethernet_component.h | 32 ++++++++ .../ethernet/ethernet_component_esp32.cpp | 76 ++++++++++++++++++- .../ethernet/ethernet_component_rp2040.cpp | 17 +++++ .../ethernet/test-lifecycle.esp32-idf.yaml | 39 ++++++++++ 6 files changed, 219 insertions(+), 3 deletions(-) create mode 100644 esphome/components/ethernet/automation.h create mode 100644 tests/components/ethernet/test-lifecycle.esp32-idf.yaml diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 22f5eb33e1..784f5dee8c 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -2,6 +2,7 @@ from dataclasses import dataclass import logging from esphome import automation, pins +from esphome.automation import Condition import esphome.codegen as cg from esphome.components.network import ip_address_literal from esphome.config_helpers import filter_source_files_from_platform @@ -13,6 +14,7 @@ from esphome.const import ( CONF_DNS1, CONF_DNS2, CONF_DOMAIN, + CONF_ENABLE_ON_BOOT, CONF_GATEWAY, CONF_ID, CONF_INTERRUPT_PIN, @@ -217,6 +219,10 @@ MANUAL_IP_SCHEMA = cv.Schema( EthernetComponent = ethernet_ns.class_("EthernetComponent", cg.Component) ManualIP = ethernet_ns.struct("ManualIP") +EthernetConnectedCondition = ethernet_ns.class_("EthernetConnectedCondition", Condition) +EthernetEnabledCondition = ethernet_ns.class_("EthernetEnabledCondition", Condition) +EthernetEnableAction = ethernet_ns.class_("EthernetEnableAction", automation.Action) +EthernetDisableAction = ethernet_ns.class_("EthernetDisableAction", automation.Action) def _is_framework_spi_polling_mode_supported() -> bool: @@ -348,6 +354,7 @@ BASE_SCHEMA = cv.Schema( cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name, cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, + cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean, cv.Optional(CONF_ON_CONNECT): automation.validate_automation(single=True), cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation(single=True), } @@ -494,6 +501,9 @@ async def to_code(config): cg.add(var.set_type(ETHERNET_TYPES[config[CONF_TYPE]])) cg.add(var.set_use_address(config[CONF_USE_ADDRESS])) + # enable_on_boot defaults to true in C++ - only set if false + if not config[CONF_ENABLE_ON_BOOT]: + cg.add(var.set_enable_on_boot(False)) CORE.data.setdefault(KEY_ETHERNET, {})[ETHERNET_TYPE_KEY] = config[CONF_TYPE] if CONF_MANUAL_IP in config: @@ -715,3 +725,21 @@ def _filter_source_files() -> list[str]: FILTER_SOURCE_FILES = _filter_source_files + + +async def _new_pvariable_to_code(config, id_, template_arg, args): + return cg.new_Pvariable(id_, template_arg) + + +for _name, _cls in ( + ("ethernet.connected", EthernetConnectedCondition), + ("ethernet.enabled", EthernetEnabledCondition), +): + automation.register_condition(_name, _cls, cv.Schema({}))(_new_pvariable_to_code) +for _name, _cls in ( + ("ethernet.enable", EthernetEnableAction), + ("ethernet.disable", EthernetDisableAction), +): + automation.register_action(_name, _cls, cv.Schema({}), synchronous=True)( + _new_pvariable_to_code + ) diff --git a/esphome/components/ethernet/automation.h b/esphome/components/ethernet/automation.h new file mode 100644 index 0000000000..c16abc5bda --- /dev/null +++ b/esphome/components/ethernet/automation.h @@ -0,0 +1,30 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_ETHERNET +#include "ethernet_component.h" + +namespace esphome::ethernet { + +template class EthernetConnectedCondition : public Condition { + public: + bool check(const Ts &...x) override { return global_eth_component->is_connected(); } +}; + +template class EthernetEnabledCondition : public Condition { + public: + bool check(const Ts &...x) override { return global_eth_component->is_enabled(); } +}; + +template class EthernetEnableAction : public Action { + public: + void play(const Ts &...x) override { global_eth_component->enable(); } +}; + +template class EthernetDisableAction : public Action { + public: + void play(const Ts &...x) override { global_eth_component->disable(); } +}; + +} // namespace esphome::ethernet +#endif diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 17c84ee954..7d06377f90 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -124,6 +124,17 @@ class EthernetComponent final : public Component { void on_powerdown() override { powerdown(); } bool is_connected() { return this->state_ == EthernetComponentState::CONNECTED; } + // Per-interface lifecycle (parallels WiFiComponent::enable/disable/is_disabled). + // enable_on_boot defaults to true; when false, setup() runs all the driver/netif + // installation but skips esp_eth_start(), keeping the link cold until enable() is + // called. This is the primary lever for memory reclamation in multi-interface + // configurations where only one interface should carry traffic at a time. + void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } + void enable(); + void disable(); + bool is_disabled() { return this->disabled_; } + bool is_enabled() { return !this->disabled_; } + void set_type(EthernetType type); #ifdef USE_ETHERNET_MANUAL_IP void set_manual_ip(const ManualIP &manual_ip); @@ -194,6 +205,16 @@ class EthernetComponent final : public Component { void finish_connect_(); void dump_connect_params_(); +#ifdef USE_ESP32 + // ESP-IDF only: defers the SPI bus init, netif creation, MAC/PHY install, driver + // install, netif attach, and event handler registration (which together allocate + // ~3-8KB of DMA-capable internal SRAM via SPI driver state + eth driver RX queue) + // until ethernet actually needs to come up. Idempotent — guarded by the + // ethernet_initialized_ flag. Called from setup() when enable_on_boot_=true, or + // from enable() on first runtime enable. Mirrors wifi_lazy_init_() in WiFi. + void ethernet_lazy_init_(); +#endif + #ifdef USE_ETHERNET_IP_STATE_LISTENERS void notify_ip_state_listeners_(); #endif @@ -287,6 +308,17 @@ class EthernetComponent final : public Component { bool started_{false}; bool connected_{false}; bool got_ipv4_address_{false}; + // Codegen-time YAML option. When false, setup() defers esp_eth_start(). + bool enable_on_boot_{true}; + // Mirror of "is the link intentionally stopped" — set when setup() honors + // enable_on_boot=false, cleared by enable(), set again by disable(). + bool disabled_{false}; +#ifdef USE_ESP32 + // Tracks whether ethernet_lazy_init_() has completed successfully. Allows enable() + // to be called at runtime after enable_on_boot:false without re-allocating, and + // ensures setup() skips the heavy init when enable_on_boot_ is false. + bool ethernet_initialized_{false}; +#endif #if LWIP_IPV6 uint8_t ipv6_count_{0}; bool ipv6_setup_done_{false}; diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 6481c8c1f4..544ec79c32 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -138,6 +138,24 @@ void EthernetComponent::setup() { delay(300); // NOLINT } + if (this->enable_on_boot_) { + this->ethernet_lazy_init_(); + if (!this->ethernet_initialized_) { + // lazy_init bailed early via ESPHL_ERROR_CHECK or mark_failed; nothing more to do. + return; + } + esp_err_t err = esp_eth_start(this->eth_handle_); + ESPHL_ERROR_CHECK(err, "ETH start error"); + } else { + ESP_LOGCONFIG(TAG, "Skipping init (enable_on_boot: false)"); + this->disabled_ = true; + } +} + +void EthernetComponent::ethernet_lazy_init_() { + if (this->ethernet_initialized_) + return; + esp_err_t err; #ifdef USE_ETHERNET_SPI @@ -371,9 +389,41 @@ void EthernetComponent::setup() { ESPHL_ERROR_CHECK(err, "GOT IPv6 event handler register error"); #endif /* USE_NETWORK_IPV6 */ - /* start Ethernet driver state machine */ - err = esp_eth_start(this->eth_handle_); - ESPHL_ERROR_CHECK(err, "ETH start error"); + this->ethernet_initialized_ = true; +} + +void EthernetComponent::enable() { + if (!this->disabled_) + return; + + ESP_LOGD(TAG, "Enabling"); + this->ethernet_lazy_init_(); + if (!this->ethernet_initialized_) { + ESP_LOGE(TAG, "Cannot enable - init failed"); + return; + } + esp_err_t err = esp_eth_start(this->eth_handle_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_eth_start failed: %s", esp_err_to_name(err)); + return; + } + this->disabled_ = false; + // The ETH_EVENT_START handler will set started_=true; the loop state machine + // will then drive the STOPPED -> CONNECTING -> CONNECTED transitions. + this->enable_loop(); +} + +void EthernetComponent::disable() { + if (this->disabled_) + return; + + ESP_LOGD(TAG, "Disabling"); + esp_err_t err = esp_eth_stop(this->eth_handle_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "esp_eth_stop failed: %s — disabling anyway", esp_err_to_name(err)); + } + this->disabled_ = true; + // ETH_EVENT_STOP will clear started_; loop() will transition to STOPPED. } void EthernetComponent::dump_config() { @@ -487,6 +537,8 @@ void EthernetComponent::dump_config() { network::IPAddresses EthernetComponent::get_ip_addresses() { network::IPAddresses addresses; + if (!this->ethernet_initialized_) + return addresses; // all-zero IPs esp_netif_ip_info_t ip; esp_err_t err = esp_netif_get_ip_info(this->eth_netif_, &ip); if (err != ESP_OK) { @@ -709,6 +761,10 @@ void EthernetComponent::start_connect_() { } void EthernetComponent::dump_connect_params_() { + if (!this->ethernet_initialized_) { + ESP_LOGCONFIG(TAG, " uninitialized/disabled"); + return; + } esp_netif_ip_info_t ip; esp_netif_get_ip_info(this->eth_netif_, &ip); const ip_addr_t *dns_ip1; @@ -776,6 +832,16 @@ void EthernetComponent::add_phy_register(PHYRegister register_value) { this->phy #endif void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { + if (!this->ethernet_initialized_) { + // External callers (mdns, ethernet_info, etc.) may ask for the MAC before/regardless + // of whether ethernet is enabled. Use the configured MAC if set, else the system ETH MAC. + if (this->fixed_mac_.has_value()) { + memcpy(mac, this->fixed_mac_->data(), 6); + } else { + esp_read_mac(mac, ESP_MAC_ETH); + } + return; + } esp_err_t err; err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_MAC_ADDR, mac); ESPHL_ERROR_CHECK(err, "ETH_CMD_G_MAC error"); @@ -795,6 +861,8 @@ const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer( } eth_duplex_t EthernetComponent::get_duplex_mode() { + if (!this->ethernet_initialized_) + return ETH_DUPLEX_HALF; esp_err_t err; eth_duplex_t duplex_mode; err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_DUPLEX_MODE, &duplex_mode); @@ -803,6 +871,8 @@ eth_duplex_t EthernetComponent::get_duplex_mode() { } eth_speed_t EthernetComponent::get_link_speed() { + if (!this->ethernet_initialized_) + return ETH_SPEED_10M; esp_err_t err; eth_speed_t speed; err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_SPEED, &speed); diff --git a/esphome/components/ethernet/ethernet_component_rp2040.cpp b/esphome/components/ethernet/ethernet_component_rp2040.cpp index ef7bd46332..250297ddb5 100644 --- a/esphome/components/ethernet/ethernet_component_rp2040.cpp +++ b/esphome/components/ethernet/ethernet_component_rp2040.cpp @@ -361,6 +361,23 @@ void EthernetComponent::set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; } void EthernetComponent::set_interrupt_pin(int8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; } void EthernetComponent::set_reset_pin(int8_t reset_pin) { this->reset_pin_ = reset_pin; } +void EthernetComponent::enable() { + // RP2040 uses arduino-pico's LwipIntfDev which manages link state internally; + // there is no clean enable/disable hook today. The YAML option is accepted on + // RP2040 for schema parity but has no effect. + if (!this->disabled_) + return; + ESP_LOGW(TAG, "enable_on_boot/disable not supported"); + this->disabled_ = false; +} + +void EthernetComponent::disable() { + if (this->disabled_) + return; + ESP_LOGW(TAG, "enable_on_boot/disable not supported"); + this->disabled_ = true; +} + } // namespace esphome::ethernet #endif // USE_ETHERNET && USE_RP2040 diff --git a/tests/components/ethernet/test-lifecycle.esp32-idf.yaml b/tests/components/ethernet/test-lifecycle.esp32-idf.yaml new file mode 100644 index 0000000000..904a916789 --- /dev/null +++ b/tests/components/ethernet/test-lifecycle.esp32-idf.yaml @@ -0,0 +1,39 @@ +ethernet: + id: eth + type: W5500 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + enable_on_boot: false + manual_ip: + static_ip: 192.168.178.56 + gateway: 192.168.178.1 + subnet: 255.255.255.0 + mac_address: "02:AA:BB:CC:DD:01" + interface: spi2 + +esphome: + on_boot: + priority: 200 + then: + - if: + condition: + not: + ethernet.enabled: + then: + - ethernet.enable: + +button: + - platform: template + name: "Disable Ethernet" + on_press: + - ethernet.disable: + +binary_sensor: + - platform: template + name: "Ethernet Connected" + lambda: |- + return id(eth).is_connected(); From ab46f8bd7451d8f058bfd317096cbcd6bb74fbf2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Jun 2026 15:32:23 -0500 Subject: [PATCH 0273/1815] [api] Fix crash loop on VoiceAssistantConfigurationRequest (#16757) --- esphome/components/api/api_connection.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index c880e036cb..2b1458e2ae 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1306,6 +1306,9 @@ void APIConnection::on_voice_assistant_announce_request(const VoiceAssistantAnno bool APIConnection::send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg) { VoiceAssistantConfigurationResponse resp; if (!this->check_voice_assistant_api_connection_()) { + // send_message encodes synchronously, so this stack local outlives the encode + const std::vector empty_wake_words; + resp.active_wake_words = &empty_wake_words; return this->send_message(resp); } From d7d20f4f6bd76a8c7a2575da0c3d99772d41dbd5 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 2 Jun 2026 07:04:35 +1000 Subject: [PATCH 0274/1815] [cli] Allow state reporting control via env (#16746) --- esphome/__main__.py | 50 +++++++++++++++++++++------ tests/unit_tests/test_main.py | 64 +++++++++++++++++++++++++++++++---- 2 files changed, 96 insertions(+), 18 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index cc179ebf98..47dd8d273c 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1351,6 +1351,19 @@ def _validate_bootloader_binary(binary: Path) -> None: ) +def _should_subscribe_states(args: ArgsProtocol) -> bool: + """Determine whether entity state changes should be shown in log output. + + The ``--states``/``--no-states`` command line flags take precedence. When + neither is given, the ``ESPHOME_LOG_STATES`` environment variable controls + the behavior, defaulting to showing states. + """ + states = getattr(args, "states", None) + if states is not None: + return states + return get_bool_env("ESPHOME_LOG_STATES", True) + + def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: try: module = importlib.import_module("esphome.components." + CORE.target_platform) @@ -1380,7 +1393,7 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int return run_logs( config, network_devices, - subscribe_states=not getattr(args, "no_states", False), + subscribe_states=_should_subscribe_states(args), ) if port_type in (PortType.NETWORK, PortType.MQTT) and has_mqtt_logging(): @@ -2019,6 +2032,29 @@ SIMPLE_CONFIG_ACTIONS = [ ] +def _add_states_args(parser: argparse.ArgumentParser) -> None: + """Add mutually exclusive ``--states``/``--no-states`` flags to a parser. + + When neither flag is given, the ``ESPHOME_LOG_STATES`` environment variable + controls whether entity state changes are shown (defaulting to showing them). + """ + states_group = parser.add_mutually_exclusive_group() + states_group.add_argument( + "--states", + dest="states", + action="store_true", + default=None, + help="Show entity state changes in log output (overrides ESPHOME_LOG_STATES).", + ) + states_group.add_argument( + "--no-states", + dest="states", + action="store_false", + default=None, + help="Do not show entity state changes in log output.", + ) + + def parse_args(argv): options_parser = argparse.ArgumentParser(add_help=False) options_parser.add_argument( @@ -2195,11 +2231,7 @@ def parse_args(argv): help="Reset the device before starting serial logs.", default=os.getenv("ESPHOME_SERIAL_LOGGING_RESET"), ) - parser_logs.add_argument( - "--no-states", - action="store_true", - help="Do not show entity state changes in log output.", - ) + _add_states_args(parser_logs) parser_discover = subparsers.add_parser( "discover", @@ -2231,11 +2263,7 @@ def parse_args(argv): "--no-logs", help="Disable starting logs.", action="store_true" ) - parser_run.add_argument( - "--no-states", - action="store_true", - help="Do not show entity state changes in log output.", - ) + _add_states_args(parser_run) parser_run.add_argument( "--reset", diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 26b550669f..8cce60d351 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1269,6 +1269,7 @@ class MockArgs: ota_platform: str | None = None partition_table: bool = False bootloader: bool = False + states: bool | None = None def test_upload_program_serial_esp32( @@ -2663,7 +2664,7 @@ def test_show_logs_api_no_states( mock_run_logs.return_value = 0 args = MockArgs() - args.no_states = True + args.states = False devices = ["192.168.1.100"] result = show_logs(CORE.config, args, devices) @@ -5989,19 +5990,68 @@ def test_upload_using_esptool_subprocess_passes_crystal_callback( def test_parse_args_run_no_states() -> None: """Test that --no-states is parsed for the run command.""" args = parse_args(["esphome", "run", "--no-states", "device.yaml"]) - assert args.no_states is True + assert args.states is False -def test_parse_args_run_no_states_default() -> None: - """Test that no_states defaults to False for the run command.""" +def test_parse_args_run_states() -> None: + """Test that --states is parsed for the run command.""" + args = parse_args(["esphome", "run", "--states", "device.yaml"]) + assert args.states is True + + +def test_parse_args_run_states_default() -> None: + """Test that states defaults to None (unset) for the run command.""" args = parse_args(["esphome", "run", "device.yaml"]) - assert args.no_states is False + assert args.states is None def test_parse_args_logs_no_states() -> None: """Test that --no-states is parsed for the logs command.""" args = parse_args(["esphome", "logs", "--no-states", "device.yaml"]) - assert args.no_states is True + assert args.states is False + + +def test_parse_args_logs_states() -> None: + """Test that --states is parsed for the logs command.""" + args = parse_args(["esphome", "logs", "--states", "device.yaml"]) + assert args.states is True + + +def test_should_subscribe_states_default() -> None: + """Test that states are shown by default when nothing is set.""" + from esphome.__main__ import _should_subscribe_states + + args = parse_args(["esphome", "logs", "device.yaml"]) + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("ESPHOME_LOG_STATES", None) + assert _should_subscribe_states(args) is True + + +def test_should_subscribe_states_env_suppresses() -> None: + """Test that ESPHOME_LOG_STATES=false suppresses states by default.""" + from esphome.__main__ import _should_subscribe_states + + args = parse_args(["esphome", "logs", "device.yaml"]) + with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "false"}): + assert _should_subscribe_states(args) is False + + +def test_should_subscribe_states_flag_overrides_env() -> None: + """Test that --states overrides ESPHOME_LOG_STATES=false.""" + from esphome.__main__ import _should_subscribe_states + + args = parse_args(["esphome", "logs", "--states", "device.yaml"]) + with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "false"}): + assert _should_subscribe_states(args) is True + + +def test_should_subscribe_states_no_flag_overrides_env() -> None: + """Test that --no-states overrides ESPHOME_LOG_STATES=true.""" + from esphome.__main__ import _should_subscribe_states + + args = parse_args(["esphome", "logs", "--no-states", "device.yaml"]) + with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "true"}): + assert _should_subscribe_states(args) is False @patch("esphome.components.api.client.run_logs") @@ -6020,7 +6070,7 @@ def test_command_run_passes_no_states_to_show_logs( mock_run_logs.return_value = 0 args = MockArgs() - args.no_states = True + args.states = False args.no_logs = False args.device = None From d7f809181a43878b0e8e100e0edb5610d9535906 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 10:20:31 -0700 Subject: [PATCH 0275/1815] [writer] Mark storage_should_clean as public API for device-builder (#16443) --- esphome/writer.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/esphome/writer.py b/esphome/writer.py index 72c2c355dc..ad3877465d 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -87,6 +87,21 @@ def replace_file_content(text, pattern, repl): def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: + """Return True when the build tree must be wiped before reuse. + + Predicate is True when *old* is missing (first build), + ``src_version`` differs, ``build_path`` differs, or a previously + loaded integration was removed in *new*. Adding integrations or + changing unrelated fields (friendly name, esphome version, etc.) + does not trigger a clean. + + Used by esphome-device-builder (esphome/device-builder) to gate + its remote-build artifact materialiser so a local → remote → local + cycle preserves PlatformIO's local object cache instead of wiping + it on every cycle. The signature, semantics, and ``None`` handling + for *old* are part of the public contract; keep them stable so the + offloader's wipe decision tracks core's. + """ if old is None: return True From 3f57117efddb699089f64a762c98b103b9baf89c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 25 May 2026 10:36:41 -0400 Subject: [PATCH 0276/1815] [esp32] Decode crash PCs via IDF toolchain on IDF builds (#16626) --- esphome/components/esp32/__init__.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 4f77258b2c..1a95f77437 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -46,7 +46,7 @@ from esphome.const import ( Toolchain, __version__, ) -from esphome.core import CORE, HexInt, Library +from esphome.core import CORE, EsphomeError, HexInt, Library from esphome.core.config import BOARD_MAX_LENGTH from esphome.coroutine import CoroPriority, coroutine_with_priority from esphome.espidf.component import generate_idf_component @@ -2657,13 +2657,29 @@ def copy_files(): def _decode_pc(config, addr): - from esphome.platformio import toolchain + # _decode_pc runs from the api log processor's asyncio callback, which + # only catches EsphomeError. Any other exception escaping here tears down + # the protocol and triggers an infinite reconnect/replay loop. Convert + # toolchain-resolution errors (e.g. missing build dir / cmake cache) into + # EsphomeError so the caller can disable decoding cleanly. + if CORE.using_toolchain_esp_idf: + from esphome.espidf import toolchain as idf_toolchain - idedata = toolchain.get_idedata(config) - if not idedata.addr2line_path or not idedata.firmware_elf_path: + try: + addr2line_path = idf_toolchain.get_addr2line_path() + firmware_elf_path = idf_toolchain.get_elf_path() + except RuntimeError as err: + raise EsphomeError(f"ESP-IDF toolchain not available: {err}") from err + else: + from esphome.platformio import toolchain + + idedata = toolchain.get_idedata(config) + addr2line_path = idedata.addr2line_path + firmware_elf_path = idedata.firmware_elf_path + if not addr2line_path or not firmware_elf_path: _LOGGER.debug("decode_pc no addr2line") return - command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr] + command = [str(addr2line_path), "-pfiaC", "-e", str(firmware_elf_path), addr] try: translation = subprocess.check_output(command, close_fds=False).decode().strip() except Exception: # pylint: disable=broad-except From a04f6da814e318e9fecf766232edb4fc426619cb Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 26 May 2026 19:56:44 +1200 Subject: [PATCH 0277/1815] [packages] Resolve git symlinks on Windows when materialized as text (#16657) --- esphome/components/packages/__init__.py | 42 +++- esphome/git.py | 87 +++++++ tests/unit_tests/test_git.py | 303 +++++++++++++++++++++++- tests/unit_tests/test_substitutions.py | 83 +++++++ 4 files changed, 507 insertions(+), 8 deletions(-) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 06a64208b6..f3e0e0db8f 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -215,7 +215,7 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]: If loading fails after cloning, attempts a revert and retry in case a prior cached checkout is stale. """ - repo_dir, revert = git.clone_or_update( + repo_root, revert = git.clone_or_update( url=config[CONF_URL], ref=config.get(CONF_REF), refresh=config[CONF_REFRESH], @@ -225,6 +225,10 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]: ) files: list[dict[str, Any]] = [] + # ``repo_root`` is the directory containing ``.git`` and must be passed + # to git for symlink-stub resolution. ``repo_dir`` may be narrowed to a + # subdirectory via the user's CONF_PATH and is used for file lookups. + repo_dir = repo_root if base_path := config.get(CONF_PATH): repo_dir = repo_dir / base_path @@ -236,13 +240,37 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]: def _load_package_yaml(yaml_file: Path, filename: str) -> dict: """Load a YAML file from a remote package, validating min_version.""" - try: - new_yaml = yaml_util.load_yaml(yaml_file) - except EsphomeError as e: + + def _load(path: Path) -> dict | str | None: + try: + return yaml_util.load_yaml(path) + except EsphomeError as e: + raise cv.Invalid( + f"{filename} is not a valid YAML file." + f" Please check the file contents.\n{e}" + ) from e + + new_yaml = _load(yaml_file) + if not isinstance(new_yaml, dict): + # On Windows, git defaults to core.symlinks=false unless the user + # has Developer Mode enabled or is running elevated. Files stored + # in the repo as symlinks (tree mode 120000) are then checked out + # as plain text files containing the symlink target path, so + # parsing them as YAML yields a bare scalar instead of a mapping. + # Best-effort: follow the symlink target ourselves and re-load. + target = git.resolve_symlink_stub(repo_root, yaml_file) + if target is not None: + new_yaml = _load(target) + if not isinstance(new_yaml, dict): raise cv.Invalid( - f"{filename} is not a valid YAML file." - f" Please check the file contents.\n{e}" - ) from e + f"{filename} does not contain a YAML mapping at the top level " + f"(got {type(new_yaml).__name__}). " + f"If this file is a git symlink in the source repository, it " + f"may not have been materialized correctly on your platform " + f"(this is a known issue with git on Windows without Developer " + f"Mode enabled). Try pointing your package at the real file " + f"path instead." + ) esphome_config = new_yaml.get(CONF_ESPHOME) or {} min_version = esphome_config.get(CONF_MIN_VERSION) if min_version is not None and cv.Version.parse(min_version) > cv.Version.parse( diff --git a/esphome/git.py b/esphome/git.py index 0106f24845..c724ea2875 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -6,6 +6,7 @@ import logging from pathlib import Path import re import subprocess +import sys import urllib.parse import esphome.config_validation as cv @@ -93,6 +94,92 @@ def _compute_destination_path(key: str, domain: str) -> Path: return base_dir / h.hexdigest()[:8] +def resolve_symlink_stub(repo_dir: Path, file_path: Path) -> Path | None: + """Return the symlink target if ``file_path`` is a Windows-checked-out symlink stub. + + On Windows, when ``core.symlinks=false`` (the default unless the user has + SeCreateSymbolicLinkPrivilege — i.e. Developer Mode or running elevated), + git materializes files with tree mode ``120000`` as plain text files + whose content is the literal symlink target path. Opening such a file + yields the target path string instead of the target's content. + + If ``file_path`` is one of those stubs, return the resolved target Path + inside ``repo_dir``. Otherwise return ``None`` and the caller should use + ``file_path`` as-is. + + Designed to be called *only* when normal access has already produced an + unexpected result (e.g. YAML parsed as a top-level scalar), so the + per-file ``git ls-files`` subprocess cost is paid only on the failure + path. Returns ``None`` on any error or check failure — it's purely a + best-effort recovery, never raises. + """ + # On non-Windows, git creates real symlinks; ordinary file access already + # transparently follows them. + if sys.platform != "win32": + return None + if file_path.is_symlink(): + return None + if not file_path.is_file(): + return None + + try: + rel = file_path.relative_to(repo_dir) + except ValueError: + return None + + try: + # ``git ls-files -s `` prints " \t" + # for that single entry, or empty if untracked. + out = run_git_command( + ["git", "ls-files", "-s", "--", rel.as_posix()], + git_dir=repo_dir, + ) + except GitException: + return None + + parts = out.split() + if not parts or parts[0] != "120000": + return None + + # Stubs are short ASCII relative paths. Decode defensively, and only + # strip the trailing newline git's checkout may append — preserving any + # whitespace that could be part of a valid target name. + try: + raw = file_path.read_bytes() + except OSError: + return None + try: + target_str = raw.decode("utf-8").rstrip("\r\n") + except UnicodeDecodeError: + return None + + # ``Path()`` and ``Path.resolve()`` can raise on malformed inputs (e.g. + # embedded NUL bytes from a hostile symlink blob, paths too long for the + # OS, or temporary I/O errors). Catch broadly — this helper is purely a + # best-effort recovery and must never raise. + try: + target_path = (file_path.parent / target_str).resolve() + repo_root_resolved = repo_dir.resolve() + except (OSError, ValueError, RuntimeError): + return None + + # ``Path.resolve()`` follows ``..``; re-verify containment afterwards. + try: + target_path.relative_to(repo_root_resolved) + except ValueError: + _LOGGER.warning( + "Refusing to follow symlink %s -> %s (escapes repository)", + file_path, + target_str, + ) + return None + + if not target_path.is_file(): + return None + + return target_path + + def clone_or_update( *, url: str, diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index eab6bfc2cb..690c47c183 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -4,7 +4,7 @@ from datetime import datetime, timedelta import os from pathlib import Path from typing import Any -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest @@ -1001,3 +1001,304 @@ def test_refresh_picks_up_new_remote_commits( "--hard", "old_sha", ] + + +def test_resolve_symlink_stub_returns_none_on_non_windows( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """On non-Windows, resolve_symlink_stub returns None without calling git.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + stub = repo_dir / "file.yaml" + stub.write_text("static/file.yaml") + + with patch("esphome.git.sys.platform", "linux"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + mock_run_git_command.assert_not_called() + + +def test_resolve_symlink_stub_returns_target_for_mode_120000( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A mode-120000 file is recognised as a stub; its target Path is returned.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + (repo_dir / "static").mkdir() + + target = repo_dir / "static" / "real.yaml" + target.write_text("esphome:\n name: real\n") + + stub = repo_dir / "real.yaml" + stub.write_text("static/real.yaml") + + mock_run_git_command.return_value = "120000 abc123 0\treal.yaml" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result == target.resolve() + # Stub file itself was not modified — only inspected. + assert stub.read_text() == "static/real.yaml" + + +def test_resolve_symlink_stub_resolves_relative_parent_paths( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Symlink targets with ``..`` segments resolve correctly within the repo.""" + repo_dir = tmp_path / "repo" + (repo_dir / "subdir").mkdir(parents=True) + (repo_dir / "static").mkdir() + + target = repo_dir / "static" / "shared.yaml" + target.write_text("shared content") + + stub = repo_dir / "subdir" / "shared.yaml" + stub.write_text("../static/shared.yaml") + + mock_run_git_command.return_value = "120000 abc123 0\tsubdir/shared.yaml" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result == target.resolve() + + +def test_resolve_symlink_stub_refuses_escape_outside_repo( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A symlink pointing outside the repository is not followed.""" + outside = tmp_path / "outside.yaml" + outside.write_text("sensitive") + + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + stub = repo_dir / "escape.yaml" + stub.write_text("../outside.yaml") + + mock_run_git_command.return_value = "120000 abc123 0\tescape.yaml" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + + +def test_resolve_symlink_stub_returns_none_for_real_symlink( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A real symlink already opens transparently, so the helper short-circuits. + + Skipped on Windows where symlink creation requires + SeCreateSymbolicLinkPrivilege. + """ + if os.name == "nt": + pytest.skip("Requires symlink-creation privilege on Windows") + + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + target = repo_dir / "real.yaml" + target.write_text("real content") + + real_link = repo_dir / "link.yaml" + real_link.symlink_to("real.yaml") + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, real_link) + + assert result is None + # No git call needed for real symlinks. + mock_run_git_command.assert_not_called() + + +def test_resolve_symlink_stub_returns_none_for_regular_file( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A regular file (mode 100644) whose content looks path-shaped is not + followed.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + regular = repo_dir / "looks_like_path.txt" + regular.write_text("static/something.yaml") + + mock_run_git_command.return_value = "100644 abc123 0\tlooks_like_path.txt" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, regular) + + assert result is None + + +def test_resolve_symlink_stub_returns_none_when_git_fails( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """If ``git ls-files`` fails (e.g. not a repo), the helper returns None.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + stub = repo_dir / "real.yaml" + stub.write_text("static/real.yaml") + + mock_run_git_command.side_effect = GitCommandError("ls-files exploded") + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + + +def test_resolve_symlink_stub_returns_none_for_non_utf8_content( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A file whose bytes are not valid UTF-8 must not raise — return None.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + stub = repo_dir / "binary.bin" + stub.write_bytes(b"\xff\xfe\x00\xff") + + mock_run_git_command.return_value = "120000 abc123 0\tbinary.bin" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + + +def test_resolve_symlink_stub_preserves_whitespace_in_target( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Only trailing CR/LF is stripped — internal whitespace is preserved.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + target_dir = repo_dir / "dir with spaces" + target_dir.mkdir() + target = target_dir / "real.yaml" + target.write_text("hello") + + stub = repo_dir / "link.yaml" + # Trailing newline (as git's checkout may append) is stripped, but + # whitespace inside the target path itself must survive. + stub.write_bytes(b"dir with spaces/real.yaml\n") + + mock_run_git_command.return_value = "120000 abc123 0\tlink.yaml" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result == target.resolve() + + +def test_resolve_symlink_stub_returns_none_for_directory_target( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A symlink pointing at a directory has no file content to load.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + (repo_dir / "dir_target").mkdir() + + stub = repo_dir / "link_to_dir" + stub.write_text("dir_target") + + mock_run_git_command.return_value = "120000 abc123 0\tlink_to_dir" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + + +def test_resolve_symlink_stub_returns_none_when_resolve_raises( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Path.resolve() raising (e.g. on a malformed target) must not propagate.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + stub = repo_dir / "broken.yaml" + stub.write_text("ignored") + + mock_run_git_command.return_value = "120000 abc123 0\tbroken.yaml" + + with ( + patch("esphome.git.sys.platform", "win32"), + patch.object(Path, "resolve", side_effect=OSError("bad path")), + ): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + + +def test_resolve_symlink_stub_returns_none_when_file_missing( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A file path that doesn't exist is rejected before git is consulted.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + missing = repo_dir / "ghost.yaml" # not created + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, missing) + + assert result is None + mock_run_git_command.assert_not_called() + + +def test_resolve_symlink_stub_returns_none_when_path_outside_repo( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A file path that isn't under repo_dir is rejected (ValueError from relative_to).""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + outside = tmp_path / "stray.yaml" + outside.write_text("something") + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, outside) + + assert result is None + mock_run_git_command.assert_not_called() + + +def test_resolve_symlink_stub_returns_none_when_untracked( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Empty `git ls-files` output (untracked file) makes the helper return None.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + stub = repo_dir / "untracked.yaml" + stub.write_text("static/foo.yaml") + + mock_run_git_command.return_value = "" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + + +def test_resolve_symlink_stub_returns_none_when_read_bytes_raises( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """An OSError from read_bytes() (e.g. file vanished mid-call) must not propagate.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + stub = repo_dir / "racy.yaml" + stub.write_text("static/racy.yaml") + + mock_run_git_command.return_value = "120000 abc123 0\tracy.yaml" + + with ( + patch("esphome.git.sys.platform", "win32"), + patch.object(Path, "read_bytes", side_effect=OSError("vanished")), + ): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 4783112578..4ff857951f 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -838,3 +838,86 @@ def test_include_vars_applied_to_lambda_value(tmp_path: Path) -> None: assert isinstance(result["value"], Lambda) assert result["value"].value == 'return "bar";' + + +@patch("esphome.git.resolve_symlink_stub") +@patch("esphome.git.clone_or_update") +def test_remote_package_symlink_stub_is_followed( + mock_clone_or_update: MagicMock, + mock_resolve_symlink_stub: MagicMock, + tmp_path: Path, +) -> None: + """When a package YAML is a scalar (symlink stub) and resolve_symlink_stub + returns a target, the loader follows the target and uses its content.""" + CORE.config_path = tmp_path / "test.yaml" + + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + (repo_dir / "static").mkdir() + + # Stub file: content is the target path string (simulating Windows behavior). + stub = repo_dir / "file1.yaml" + stub.write_text("static/file1.yaml") + + # Real target with valid YAML mapping. + target = repo_dir / "static" / "file1.yaml" + target.write_text("substitutions:\n hello: world\n") + + mock_clone_or_update.return_value = (repo_dir, None) + mock_resolve_symlink_stub.return_value = target + + config: dict[str, Any] = { + "packages": { + "test_package": { + "url": "https://github.com/esphome/repo1", + "ref": "main", + "files": ["file1.yaml"], + } + } + } + + # Must succeed (does not raise the helpful cv.Invalid) because the stub + # was followed and a valid mapping was loaded from the target. + do_packages_pass(config) + assert mock_resolve_symlink_stub.called + + +@patch("esphome.git.clone_or_update") +def test_remote_package_scalar_yaml_raises_helpful_error( + mock_clone_or_update: MagicMock, tmp_path: Path +) -> None: + """A remote package YAML that is a top-level scalar (e.g. an unmaterialized + git symlink on Windows) raises a clear cv.Invalid, not AttributeError. + + Regression test for the case where a repo containing a YAML symlink, + checked out on Windows without symlink privilege, lands as a short text + file containing the symlink target path. PyYAML parses that as a bare + string scalar; the package loader must reject it with a human-readable + error instead of dying inside ``.get()``. + """ + CORE.config_path = tmp_path / "test.yaml" + + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + # Simulate the broken-symlink state: a YAML file whose entire content is + # the symlink target string. PyYAML parses this as a top-level scalar. + (repo_dir / "file1.yaml").write_text("static/file1.yaml") + + mock_clone_or_update.return_value = (repo_dir, None) + + config: dict[str, Any] = { + "packages": { + "test_package": { + "url": "https://github.com/esphome/repo1", + "ref": "main", + "files": ["file1.yaml"], + } + } + } + + with pytest.raises(cv.Invalid) as exc_info: + do_packages_pass(config) + + msg = str(exc_info.value) + assert "mapping at the top level" in msg + assert "file1.yaml" in msg From f9aba18f8e992581bf9c70a9f24a3c9d81c56110 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 02:57:44 -0500 Subject: [PATCH 0278/1815] [libretiny] Fix RTL8710B IRAM_ATTR section being dropped from flashed image (#16616) --- esphome/components/libretiny/hal.h | 24 +++++---- .../libretiny/patch_linker.py.script | 54 +++++++++++++++---- 2 files changed, 58 insertions(+), 20 deletions(-) diff --git a/esphome/components/libretiny/hal.h b/esphome/components/libretiny/hal.h index 9c512504b7..01a7b5450b 100644 --- a/esphome/components/libretiny/hal.h +++ b/esphome/components/libretiny/hal.h @@ -11,11 +11,19 @@ #include "esphome/core/time_64.h" // IRAM_ATTR places a function in executable RAM so it is callable from an -// ISR even while flash is busy (XIP stall, OTA, logger flash write). -// Each family uses a section its stock linker already routes to RAM: -// RTL8710B → .image2.ram.text, RTL8720C → .sram.text. LN882H is the -// exception: its stock linker has no matching glob, so patch_linker.py -// injects KEEP(*(.sram.text*)) into .flash_copysection at pre-link. +// ISR even while flash is busy (XIP stall, OTA, logger flash write). All +// LibreTiny families that need it share the same .sram.text input section +// name; how that section is routed into RAM differs per family: +// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text. +// RTL8710B: patch_linker.py.script injects KEEP(*(.sram.text*)) at the +// top of .ram_image2.data (which IS in ltchiptool's +// sections_ram). The stock linker has KEEP(*(.image2.ram.text*)) +// in .ram_image2.text but that output section is NOT in +// ltchiptool's AmebaZ elf2bin sections_ram list, so code routed +// there is dropped from the flashed binary. +// LN882H: patch_linker.py.script injects KEEP(*(.sram.text*)) into +// .flash_copysection (> RAM0 AT> FLASH), after KEEP(*(.vectors)) +// so the Cortex-M4 vector table stays 512-byte-aligned for VTOR. // // BK72xx (all variants) are left as a no-op: their SDK wraps flash // operations in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU for @@ -26,13 +34,7 @@ // layer. #if defined(USE_BK72XX) #define IRAM_ATTR -#elif defined(USE_LIBRETINY_VARIANT_RTL8710B) -// Stock linker consumes *(.image2.ram.text*) into .ram_image2.text (> BD_RAM). -#define IRAM_ATTR __attribute__((noinline, section(".image2.ram.text"))) #else -// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text. -// LN882H: patch_linker.py.script injects *(.sram.text*) into -// .flash_copysection (> RAM0 AT> FLASH). #define IRAM_ATTR __attribute__((noinline, section(".sram.text"))) #endif #define PROGMEM diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 3a8a4787ed..dfeaaa57d1 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -6,12 +6,18 @@ import re import subprocess # ESPHome marks ISR code IRAM_ATTR, which on LibreTiny maps to a per-family -# section routed into RAM-executable memory (see esphome/core/hal.h). +# section routed into RAM-executable memory (see esphome/core/hal.h). The +# input section name is always .sram.text; only the output section it lands +# in differs per family. # # This script is NOT loaded on BK72xx (IRAM_ATTR is a no-op there; the SDK # masks FIQ+IRQ around flash writes). On the remaining families: -# - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it. -# - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it. +# - RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text. +# - RTL8710B: stock linker has KEEP(*(.image2.ram.text*)) in .ram_image2.text, +# but ltchiptool's AmebaZ elf2bin (soc/ambz/binary.py) does NOT list +# .ram_image2.text in sections_ram, so code there is silently dropped from +# the flashed image. Inject KEEP(*(.sram.text*)) at the top of +# .ram_image2.data (which IS extracted) instead. # - LN882H: stock linker has no glob for ".sram.text", so we inject # KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH) # immediately after KEEP(*(.vectors)), so the vector table stays at @@ -34,6 +40,20 @@ _KEEP_LINE = ( # aligned address; injecting before the vectors would push them to an # unaligned offset and mis-route every IRQ handler. _LN_COPY = re.compile(r"(KEEP\(\*\(\.vectors\)\)[^\n]*\n)") +# Inject at the top of .ram_image2.data, before __data_start__ so our code +# does not fall inside the data range markers. .ram_image2.data is one of the +# sections ltchiptool's AmebaZ elf2bin extracts; BD_RAM is rwx so the code is +# executable. AmbZ has no C runtime .data copy loop (the bootloader loads +# image2 into BD_RAM whole) so the inline code is not clobbered after boot. +# +# The regex is intentionally strict (no attribute / ALIGN between the section +# name and the opening brace, brace on its own line). If a future AmbZ SDK +# linker template changes this format, _pre_link raises RuntimeError on the +# unpatched .ld file(s), and the RTL8710B CI compile job in +# tests/test_build_components fails on the PR, surfacing the mismatch loudly +# rather than silently shipping a binary with IRAM_ATTR code dropped from +# one or both OTA slots. +_AMBZ_DATA = re.compile(r"(\.ram_image2\.data\s*:\s*\n?\s*\{\s*\n)") def _detect(env): @@ -71,12 +91,11 @@ def _inject_keep(host_section): # Variants not listed here intentionally have no .ld patcher: -# - RTL8710B: hal.h uses section(".image2.ram.text") which the stock linker -# already routes into .ram_image2.text (> BD_RAM). -# - RTL8720C: stock linker already consumes *(.sram.text*). +# - RTL8720C: stock linker already consumes *(.sram.text*) into .ram.code_text. # - BK72xx (all): SDK masks FIQ+IRQ around flash writes, IRAM_ATTR is no-op. _PATCHERS_BY_VARIANT = { "LN882H": (_inject_keep(_LN_COPY),), + "RTL8710B": (_inject_keep(_AMBZ_DATA),), } @@ -87,13 +106,14 @@ def _patchers_for(variant): def _pre_link(target, source, env): build_dir = env.subst("$BUILD_DIR") ld_files = [f for f in os.listdir(build_dir) if f.endswith(".ld")] - patched = 0 + patched = [] + unpatched = [] for name in ld_files: path = os.path.join(build_dir, name) with open(path, "r", encoding="utf-8") as fh: original = fh.read() if _MARKER in original: - patched += 1 + patched.append(name) continue content = original for fn in _patchers: @@ -102,7 +122,9 @@ def _pre_link(target, source, env): with open(path, "w", encoding="utf-8") as fh: fh.write(content) print("ESPHome: patched {} for IRAM_ATTR placement".format(name)) - patched += 1 + patched.append(name) + else: + unpatched.append(name) if not patched: raise RuntimeError( "ESPHome: no .ld in {} was patched for IRAM_ATTR. Update the " @@ -110,6 +132,20 @@ def _pre_link(target, source, env): build_dir ) ) + # Every .ld in the build must be patched. RTL8710B generates one .ld per + # OTA slot (xip1, xip2); if only one matches, the unpatched slot would + # ship with IRAM_ATTR code dropped to zeros and brick the device on the + # boot after an OTA into that slot. + if unpatched: + raise RuntimeError( + "ESPHome: {} of {} .ld file(s) in {} were not patched for " + "IRAM_ATTR: {}. The regex in patch_linker.py.script " + "(_PATCHERS_BY_VARIANT[{!r}]) matched the others but not " + "these. Update the regex to cover all linker scripts.".format( + len(unpatched), len(ld_files), build_dir, + ", ".join(unpatched), _variant, + ) + ) # Substrings matched against demangled names as a fallback on RTL8720C, From 8e57894af709aa174971ac05d2d5f598e00db94a Mon Sep 17 00:00:00 2001 From: Fyleo Date: Sat, 30 May 2026 05:03:42 +0200 Subject: [PATCH 0279/1815] [sx126x] fix a typo in image calibration on 863 - 870 Mhz frequency (#16731) --- esphome/components/sx126x/sx126x.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index 83afeac50a..aed0105e1f 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -394,7 +394,7 @@ void SX126x::run_image_cal() { buf[1] = 0xE9; } else if (this->frequency_ > 850000000) { buf[0] = 0xD7; - buf[1] = 0xD8; + buf[1] = 0xDB; } else if (this->frequency_ > 770000000) { buf[0] = 0xC1; buf[1] = 0xC5; From a4d247fa0a47d935cc006b0748ebe7acc170c4eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 30 May 2026 00:09:07 -0500 Subject: [PATCH 0280/1815] [core] Persist esphome.area in StorageJSON (#16710) --- esphome/core/config.py | 1 + esphome/storage_json.py | 7 +++++ tests/unit_tests/core/test_config.py | 27 +++++++++++++++++++ tests/unit_tests/test_storage_json.py | 38 +++++++++++++++++++++++++++ 4 files changed, 73 insertions(+) diff --git a/esphome/core/config.py b/esphome/core/config.py index 5a98b94781..e4298b0865 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -711,6 +711,7 @@ async def to_code(config: ConfigType) -> None: # Process areas all_areas: list[dict[str, str | core.ID]] = [] if CONF_AREA in config: + CORE.area = config[CONF_AREA][CONF_NAME] all_areas.append(config[CONF_AREA]) all_areas.extend(config[CONF_AREAS]) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 7f8885ba5f..dc1576ab18 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -100,6 +100,7 @@ class StorageJSON: framework: str | None = None, core_platform: str | None = None, toolchain: str | None = None, + area: str | None = None, ) -> None: # Version of the storage JSON schema assert storage_version is None or isinstance(storage_version, int) @@ -138,6 +139,8 @@ class StorageJSON: self.core_platform = core_platform # The toolchain used for the build ("platformio" / "esp-idf") self.toolchain = toolchain + # The area of the node + self.area = area def as_dict(self): return { @@ -158,6 +161,7 @@ class StorageJSON: "framework": self.framework, "core_platform": self.core_platform, "toolchain": self.toolchain, + "area": self.area, } def to_json(self): @@ -195,6 +199,7 @@ class StorageJSON: framework=esph.target_framework, core_platform=esph.target_platform, toolchain=esph.toolchain.value if esph.toolchain is not None else None, + area=esph.area, ) @staticmethod @@ -243,6 +248,7 @@ class StorageJSON: framework = storage.get("framework") core_platform = storage.get("core_platform") toolchain = storage.get("toolchain") + area = storage.get("area") return StorageJSON( storage_version, name, @@ -261,6 +267,7 @@ class StorageJSON: framework, core_platform, toolchain, + area, ) @staticmethod diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 4ce862315d..39cd042a96 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -140,6 +140,33 @@ def test_multiple_areas_and_devices(yaml_file: Callable[[str], str]) -> None: } +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::RuntimeWarning") +@pytest.mark.parametrize( + ("fixture", "expected_area"), + [ + ("legacy_string_area.yaml", "Living Room"), + ("multiple_areas_devices.yaml", "Main Area"), + ], +) +async def test_to_code_records_core_area( + yaml_file: Callable[[str], Path], + fixture: str, + expected_area: str, +) -> None: + """``to_code`` records the node's area name on CORE for StorageJSON.""" + result = load_config_from_fixture(yaml_file, fixture, FIXTURES_DIR) + assert result is not None + assert CORE.area is None + + with patch("esphome.core.config.cg") as mock_cg: + mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock() + mock_cg.RawExpression.side_effect = lambda *args, **kwargs: MagicMock() + await config.to_code(result[CONF_ESPHOME]) + + assert CORE.area == expected_area + + def test_legacy_string_area( yaml_file: Callable[[str], str], caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index ea37492cf4..2a6f22abb1 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -205,6 +205,7 @@ def test_storage_json_as_dict() -> None: no_mdns=True, framework="arduino", core_platform="esp32", + area="Living Room", ) result = storage.as_dict() @@ -233,6 +234,7 @@ def test_storage_json_as_dict() -> None: assert result["no_mdns"] is True assert result["framework"] == "arduino" assert result["core_platform"] == "esp32" + assert result["area"] == "Living Room" def test_storage_json_to_json() -> None: @@ -309,6 +311,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: mock_core.config = {CONF_MDNS: {CONF_DISABLED: True}} mock_core.target_framework = "esp-idf" mock_core.toolchain = Toolchain.ESP_IDF + mock_core.area = "Living Room" with patch("esphome.components.esp32.get_esp32_variant") as mock_variant: mock_variant.return_value = "ESP32-C3" @@ -329,6 +332,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: assert result.framework == "esp-idf" assert result.core_platform == "esp32" assert result.toolchain == "esp-idf" + assert result.area == "Living Room" def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: @@ -729,3 +733,37 @@ def test_storage_json_load_legacy_esphomeyaml_version(tmp_path: Path) -> None: assert result is not None assert result.esphome_version == "1.14.0" # Should map to esphome_version + + +def test_storage_json_load_area(tmp_path: Path) -> None: + """``area`` round-trips through load; absence loads as None.""" + file_path = tmp_path / "with_area.json" + file_path.write_text( + json.dumps( + { + "storage_version": 1, + "name": "lamp", + "friendly_name": "Lamp", + "esp_platform": "ESP32", + "area": "Living Room", + } + ) + ) + result = storage_json.StorageJSON.load(file_path) + assert result is not None + assert result.area == "Living Room" + + legacy_path = tmp_path / "no_area.json" + legacy_path.write_text( + json.dumps( + { + "storage_version": 1, + "name": "lamp", + "friendly_name": "Lamp", + "esp_platform": "ESP32", + } + ) + ) + legacy = storage_json.StorageJSON.load(legacy_path) + assert legacy is not None + assert legacy.area is None From 571a12ffe5dbfd8967802b9352bf3cc3b02e07fa Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 31 May 2026 16:29:16 -0400 Subject: [PATCH 0281/1815] [core] Clean build when the toolchain changes (#16744) --- esphome/writer.py | 16 ++++++++++++---- tests/unit_tests/test_writer.py | 25 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/esphome/writer.py b/esphome/writer.py index ad3877465d..192c9d68e8 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -90,10 +90,12 @@ def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: """Return True when the build tree must be wiped before reuse. Predicate is True when *old* is missing (first build), - ``src_version`` differs, ``build_path`` differs, or a previously - loaded integration was removed in *new*. Adding integrations or - changing unrelated fields (friendly name, esphome version, etc.) - does not trigger a clean. + ``src_version`` differs, ``build_path`` differs, the build + ``toolchain`` differs (e.g. switching between the PlatformIO and + native ESP-IDF toolchains, which produce incompatible build trees), + or a previously loaded integration was removed in *new*. Adding + integrations or changing unrelated fields (friendly name, esphome + version, etc.) does not trigger a clean. Used by esphome-device-builder (esphome/device-builder) to gate its remote-build artifact materialiser so a local → remote → local @@ -109,6 +111,8 @@ def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: return True if old.build_path != new.build_path: return True + if old.toolchain != new.toolchain: + return True # Check if any components have been removed return bool(old.loaded_integrations - new.loaded_integrations) @@ -505,6 +509,10 @@ def clean_build(clear_pio_cache: bool = True): if dependencies_lock.is_file(): _LOGGER.info("Deleting %s", dependencies_lock) dependencies_lock.unlink() + idedata_cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json") + if idedata_cache.is_file(): + _LOGGER.info("Deleting %s", idedata_cache) + idedata_cache.unlink() # Native ESP-IDF toolchain artifacts: the IDF CMake/ninja build dir # and the Component Manager's fetched managed components live under # the project's build path, not under .pioenvs / .piolibdeps. diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 91b4bd8e87..be37dd5d58 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -75,6 +75,7 @@ def create_storage() -> Callable[..., StorageJSON]: no_mdns=kwargs.get("no_mdns", False), framework=kwargs.get("framework", "arduino"), core_platform=kwargs.get("core_platform", "esp32"), + toolchain=kwargs.get("toolchain", "platformio"), ) return _create @@ -106,6 +107,20 @@ def test_storage_should_clean_when_build_path_changes( assert storage_should_clean(old, new) is True +def test_storage_should_clean_when_toolchain_changes( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when the build toolchain changes. + + Switching between the PlatformIO and native ESP-IDF toolchains produces + incompatible build trees (and toolchain-specific idedata), so the build + must be wiped. + """ + old = create_storage(loaded_integrations=["api", "wifi"], toolchain="platformio") + new = create_storage(loaded_integrations=["api", "wifi"], toolchain="esp-idf") + assert storage_should_clean(old, new) is True + + def test_storage_should_clean_when_component_removed( create_storage: Callable[..., StorageJSON], ) -> None: @@ -443,6 +458,11 @@ def test_clean_build( dependencies_lock = tmp_path / "dependencies.lock" dependencies_lock.write_text("lock file") + # idedata cache lives under the data dir, not the build path. + idedata_cache = tmp_path / "idedata" / "test.json" + idedata_cache.parent.mkdir() + idedata_cache.write_text("{}") + # Native ESP-IDF toolchain artifacts. idf_build_dir = tmp_path / "build" idf_build_dir.mkdir() @@ -463,11 +483,14 @@ def test_clean_build( mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir mock_core.relative_build_path.side_effect = lambda name: tmp_path / name + mock_core.name = "test" + mock_core.relative_internal_path.side_effect = tmp_path.joinpath # Verify all exist before assert pioenvs_dir.exists() assert piolibdeps_dir.exists() assert dependencies_lock.exists() + assert idedata_cache.exists() assert idf_build_dir.exists() assert managed_components_dir.exists() assert platformio_cache_dir.exists() @@ -492,6 +515,7 @@ def test_clean_build( assert not pioenvs_dir.exists() assert not piolibdeps_dir.exists() assert not dependencies_lock.exists() + assert not idedata_cache.exists() assert not idf_build_dir.exists() assert not managed_components_dir.exists() assert not platformio_cache_dir.exists() @@ -501,6 +525,7 @@ def test_clean_build( assert ".pioenvs" in caplog.text assert ".piolibdeps" in caplog.text assert "dependencies.lock" in caplog.text + assert str(idedata_cache) in caplog.text assert str(idf_build_dir) in caplog.text assert str(managed_components_dir) in caplog.text assert "PlatformIO cache" in caplog.text From 559cfd1555f4af48687b3898b2fc281bd0b33942 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Jun 2026 15:32:23 -0500 Subject: [PATCH 0282/1815] [api] Fix crash loop on VoiceAssistantConfigurationRequest (#16757) --- esphome/components/api/api_connection.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index f2bf3752fa..cd5b3fd694 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1306,6 +1306,9 @@ void APIConnection::on_voice_assistant_announce_request(const VoiceAssistantAnno bool APIConnection::send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg) { VoiceAssistantConfigurationResponse resp; if (!this->check_voice_assistant_api_connection_()) { + // send_message encodes synchronously, so this stack local outlives the encode + const std::vector empty_wake_words; + resp.active_wake_words = &empty_wake_words; return this->send_message(resp); } From 070c14b04a10d986ee4aab16403c4a08be7b9ac9 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:33:41 +1200 Subject: [PATCH 0283/1815] Bump version to 2026.5.2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 30ae42ea2c..3dfe6c5ed4 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.5.1 +PROJECT_NUMBER = 2026.5.2 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 39c5c6b60e..fdbbbe5eab 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.5.1" +__version__ = "2026.5.2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 1740e541053b007fd47d1504a42dfe1443ad055a Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Mon, 1 Jun 2026 20:20:18 -0700 Subject: [PATCH 0284/1815] [ci] Fix auto label platform restructure false positive (#16734) Co-authored-by: Claude --- .github/scripts/auto-label-pr/detectors.js | 8 + .github/scripts/auto-label-pr/package.json | 7 + .../auto-label-pr/tests/detectors.test.js | 147 ++++++++++++++++++ .github/workflows/ci-github-scripts.yml | 27 ++++ 4 files changed, 189 insertions(+) create mode 100644 .github/scripts/auto-label-pr/package.json create mode 100644 .github/scripts/auto-label-pr/tests/detectors.test.js create mode 100644 .github/workflows/ci-github-scripts.yml diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index 410c1a53c0..81bb77843d 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -107,6 +107,8 @@ async function detectNewPlatforms(github, context, prFiles, apiData) { /^esphome\/components\/([^\/]+)\/([^\/]+)\/__init__\.py$/, ]; + const removedFiles = new Set(prFiles.filter(file => file.status === 'removed').map(file => file.filename)); + for (const file of addedFiles) { for (const re of platformPathPatterns) { const match = file.match(re); @@ -114,6 +116,12 @@ async function detectNewPlatforms(github, context, prFiles, apiData) { const platform = match[2]; if (!apiData.platformComponents.includes(platform)) break; + // Skip if this is a restructure between flat and subdirectory forms (either direction): + // /.py <-> //__init__.py + const flatEquivalent = `esphome/components/${match[1]}/${platform}.py`; + const subdirEquivalent = `esphome/components/${match[1]}/${platform}/__init__.py`; + if (removedFiles.has(flatEquivalent) || removedFiles.has(subdirEquivalent)) break; + labels.add('new-platform'); const content = await fetchPrFileContent(github, context, file); if (content === null) { diff --git a/.github/scripts/auto-label-pr/package.json b/.github/scripts/auto-label-pr/package.json new file mode 100644 index 0000000000..401b376db6 --- /dev/null +++ b/.github/scripts/auto-label-pr/package.json @@ -0,0 +1,7 @@ +{ + "name": "auto-label-pr", + "private": true, + "scripts": { + "test": "node --test tests/*.test.js" + } +} diff --git a/.github/scripts/auto-label-pr/tests/detectors.test.js b/.github/scripts/auto-label-pr/tests/detectors.test.js new file mode 100644 index 0000000000..02d69ca95e --- /dev/null +++ b/.github/scripts/auto-label-pr/tests/detectors.test.js @@ -0,0 +1,147 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { detectNewPlatforms, detectNewComponents } = require('../detectors'); + +// Minimal GitHub API mock — only repos.getContent is called by detectNewPlatforms/detectNewComponents +// to check for CONFIG_SCHEMA in newly added files. +function makeGithub(content = '') { + return { + rest: { + repos: { + getContent: async () => ({ + data: { content: Buffer.from(content).toString('base64') } + }) + } + } + }; +} + +const CONTEXT = { + repo: { owner: 'esphome', repo: 'esphome' }, + payload: { pull_request: { head: { sha: 'abc123' }, base: { ref: 'dev' } } } +}; + +const API_DATA = { + targetPlatforms: ['esp32', 'esp8266', 'rp2040'], + platformComponents: ['cover', 'sensor', 'binary_sensor', 'switch', 'light', 'fan', 'climate', 'valve'] +}; + +const WITH_SCHEMA = 'CONFIG_SCHEMA = cv.Schema({})'; +const WITHOUT_SCHEMA = 'CODEOWNERS = ["@esphome/core"]'; + +// --------------------------------------------------------------------------- +// detectNewPlatforms +// --------------------------------------------------------------------------- + +describe('detectNewPlatforms', () => { + describe('restructure detection (no false positives)', () => { + it('flat .py -> subdir __init__.py is not a new platform', async () => { + const prFiles = [ + { filename: 'esphome/components/endstop/cover.py', status: 'removed' }, + { filename: 'esphome/components/endstop/cover/__init__.py', status: 'added' }, + ]; + const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, API_DATA); + assert.equal(result.labels.size, 0); + assert.equal(result.hasYamlLoadable, false); + }); + + it('subdir __init__.py -> flat .py is not a new platform', async () => { + const prFiles = [ + { filename: 'esphome/components/endstop/cover/__init__.py', status: 'removed' }, + { filename: 'esphome/components/endstop/cover.py', status: 'added' }, + ]; + const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, API_DATA); + assert.equal(result.labels.size, 0); + assert.equal(result.hasYamlLoadable, false); + }); + }); + + describe('genuine new platforms', () => { + it('new subdir platform with CONFIG_SCHEMA sets new-platform and hasYamlLoadable', async () => { + const prFiles = [ + { filename: 'esphome/components/my_sensor/cover/__init__.py', status: 'added' }, + ]; + const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, API_DATA); + assert.ok(result.labels.has('new-platform')); + assert.equal(result.hasYamlLoadable, true); + }); + + it('new flat platform with CONFIG_SCHEMA sets new-platform and hasYamlLoadable', async () => { + const prFiles = [ + { filename: 'esphome/components/my_sensor/cover.py', status: 'added' }, + ]; + const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, API_DATA); + assert.ok(result.labels.has('new-platform')); + assert.equal(result.hasYamlLoadable, true); + }); + + it('new platform without CONFIG_SCHEMA sets new-platform but not hasYamlLoadable', async () => { + const prFiles = [ + { filename: 'esphome/components/my_sensor/cover.py', status: 'added' }, + ]; + const result = await detectNewPlatforms(makeGithub(WITHOUT_SCHEMA), CONTEXT, prFiles, API_DATA); + assert.ok(result.labels.has('new-platform')); + assert.equal(result.hasYamlLoadable, false); + }); + + it('non-platform file addition produces no labels', async () => { + const prFiles = [ + { filename: 'esphome/components/my_sensor/sensor.py', status: 'added' }, + ]; + // Override platformComponents so 'sensor' is not a recognized platform -> no label expected. + const nonPlatformApiData = { ...API_DATA, platformComponents: ['cover'] }; + const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, nonPlatformApiData); + assert.equal(result.labels.size, 0); + assert.equal(result.hasYamlLoadable, false); + }); + }); +}); + +// --------------------------------------------------------------------------- +// detectNewComponents +// --------------------------------------------------------------------------- + +describe('detectNewComponents', () => { + it('new top-level __init__.py sets new-component', async () => { + const prFiles = [ + { filename: 'esphome/components/actuator/__init__.py', status: 'added', }, + ]; + const result = await detectNewComponents(makeGithub(WITHOUT_SCHEMA), CONTEXT, prFiles); + assert.ok(result.labels.has('new-component')); + assert.equal(result.hasYamlLoadable, false); + }); + + it('new top-level __init__.py with CONFIG_SCHEMA sets hasYamlLoadable', async () => { + const prFiles = [ + { filename: 'esphome/components/my_component/__init__.py', status: 'added' }, + ]; + const result = await detectNewComponents(makeGithub(WITH_SCHEMA), CONTEXT, prFiles); + assert.ok(result.labels.has('new-component')); + assert.equal(result.hasYamlLoadable, true); + }); + + it('new top-level __init__.py with IS_TARGET_PLATFORM sets new-target-platform', async () => { + const prFiles = [ + { filename: 'esphome/components/my_platform/__init__.py', status: 'added' }, + ]; + const result = await detectNewComponents(makeGithub('IS_TARGET_PLATFORM = True'), CONTEXT, prFiles); + assert.ok(result.labels.has('new-component')); + assert.ok(result.labels.has('new-target-platform')); + }); + + it('modified __init__.py does not set new-component', async () => { + const prFiles = [ + { filename: 'esphome/components/existing/__init__.py', status: 'modified' }, + ]; + const result = await detectNewComponents(makeGithub(WITH_SCHEMA), CONTEXT, prFiles); + assert.equal(result.labels.size, 0); + }); + + it('nested __init__.py does not set new-component', async () => { + const prFiles = [ + { filename: 'esphome/components/endstop/cover/__init__.py', status: 'added' }, + ]; + const result = await detectNewComponents(makeGithub(WITH_SCHEMA), CONTEXT, prFiles); + assert.equal(result.labels.size, 0); + }); +}); diff --git a/.github/workflows/ci-github-scripts.yml b/.github/workflows/ci-github-scripts.yml new file mode 100644 index 0000000000..6713fcc454 --- /dev/null +++ b/.github/workflows/ci-github-scripts.yml @@ -0,0 +1,27 @@ +name: CI - GitHub Scripts + +on: + push: + branches: [dev, beta, release] + paths: + - ".github/scripts/**" + - ".github/workflows/ci-github-scripts.yml" + pull_request: + paths: + - ".github/scripts/**" + - ".github/workflows/ci-github-scripts.yml" + +permissions: + contents: read + +jobs: + test-auto-label-pr: + name: Test auto-label-pr scripts + runs-on: ubuntu-latest + steps: + - name: Check out code from GitHub + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Run tests + working-directory: .github/scripts/auto-label-pr + run: npm test From 063770bcf403f6a8f91b59ac85112405f36ec385 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 2 Jun 2026 09:32:27 -0400 Subject: [PATCH 0285/1815] [i2s_audio] Fix speaker DMA buffer sizing and validate bit depth at compile time (#16672) --- esphome/components/i2s_audio/__init__.py | 2 +- .../components/i2s_audio/speaker/__init__.py | 33 ++++++----- .../speaker/i2s_audio_speaker_standard.cpp | 55 +++++++++++++++++-- 3 files changed, 71 insertions(+), 19 deletions(-) diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index 951b8c0498..8e432695a1 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -170,7 +170,7 @@ def i2s_audio_component_schema( min=1 ), cv.Optional(CONF_BITS_PER_SAMPLE, default=default_bits_per_sample): cv.All( - _validate_bits, cv.one_of(*I2S_BITS_PER_SAMPLE) + _validate_bits, cv.int_, cv.one_of(*I2S_BITS_PER_SAMPLE) ), cv.Optional(CONF_I2S_MODE, default=CONF_PRIMARY): cv.one_of( *I2S_MODE_OPTIONS, lower=True diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index 8215d8b518..5ba2f4b1a5 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -98,11 +98,19 @@ def _set_stream_limits(config): min_sample_rate=config.get(CONF_SAMPLE_RATE), max_sample_rate=config.get(CONF_SAMPLE_RATE), )(config) - elif config[CONF_I2S_MODE] == CONF_PRIMARY: - # Primary mode has modifiable stream settings + return config + + # The original ESP32 cannot lay out sub-16-bit slots that match ESPHome's packed audio, so the smallest + # stream it accepts is 16-bit (see start_i2s_driver); the other variants handle 8-bit. + min_bits_per_sample = 16 if esp32.get_esp32_variant() == esp32.VARIANT_ESP32 else 8 + + if config[CONF_I2S_MODE] == CONF_PRIMARY: + # Primary mode can reconfigure the bus to the incoming sample rate and channel count, but the + # configured bits per sample is a hard ceiling: the speaker rejects any stream that exceeds the + # slot bit width it was set up with (see start_i2s_driver), so advertise that as the maximum. audio.set_stream_limits( - min_bits_per_sample=8, - max_bits_per_sample=32, + min_bits_per_sample=min_bits_per_sample, + max_bits_per_sample=config[CONF_BITS_PER_SAMPLE], min_channels=1, max_channels=2, min_sample_rate=16000, @@ -111,13 +119,13 @@ def _set_stream_limits(config): else: # Secondary mode has unmodifiable max bits per sample and min/max sample rates audio.set_stream_limits( - min_bits_per_sample=8, - max_bits_per_sample=config.get(CONF_BITS_PER_SAMPLE), + min_bits_per_sample=min_bits_per_sample, + max_bits_per_sample=config[CONF_BITS_PER_SAMPLE], min_channels=1, max_channels=2, min_sample_rate=config.get(CONF_SAMPLE_RATE), max_sample_rate=config.get(CONF_SAMPLE_RATE), - ) + )(config) return config @@ -134,12 +142,11 @@ def _validate_esp32_variant(config): if config[CONF_DAC_TYPE] == "internal": if variant not in INTERNAL_DAC_VARIANTS: raise cv.Invalid(f"{variant} does not have an internal DAC") - elif ( - variant == esp32.VARIANT_ESP32 - and config.get(CONF_BITS_PER_SAMPLE) == 8 - and config.get(CONF_CHANNEL) in (CONF_MONO, CONF_LEFT, CONF_RIGHT) - ): - raise cv.Invalid("8-bit mono mode is not supported on ESP32") + elif variant == esp32.VARIANT_ESP32 and config[CONF_BITS_PER_SAMPLE] == 8: + # The original ESP32 I2S peripheral packs each sample into a whole number of 16-bit words, so an + # 8-bit slot does not line up with ESPHome's tightly packed audio (see start_i2s_driver). Reject it + # at config time rather than emitting corrupted output at runtime. + raise cv.Invalid("8-bit audio is not supported on the original ESP32") return config diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index ffe901504d..0afb67fb36 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -3,6 +3,7 @@ #ifdef USE_ESP32 #include +#include #include "esphome/components/audio/audio.h" #include "esphome/components/audio/audio_transfer_buffer.h" @@ -16,8 +17,16 @@ namespace esphome::i2s_audio { static const char *const TAG = "i2s_audio.speaker.std"; -static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15; -static constexpr size_t DMA_BUFFERS_COUNT = 4; +static constexpr uint32_t DMA_BUFFER_DURATION_MS = 10; +static constexpr size_t DMA_BUFFERS_COUNT = 5; +// ESP-IDF clamps each DMA descriptor to this many bytes when allocating the channel (see i2s_get_buf_size in +// the I2S driver). Mirror its target-dependent selection so the requested dma_frame_num stays in range; the +// speaker task reads the size actually allocated back from the driver rather than relying on this value. +#if SOC_CACHE_INTERNAL_MEM_VIA_L1CACHE +static constexpr size_t I2S_DMA_BUFFER_MAX_SIZE = DMA_DESCRIPTOR_BUFFER_MAX_SIZE_64B_ALIGNED; +#else +static constexpr size_t I2S_DMA_BUFFER_MAX_SIZE = DMA_DESCRIPTOR_BUFFER_MAX_SIZE_4B_ALIGNED; +#endif // Sized to comfortably absorb scheduling jitter: at most DMA_BUFFERS_COUNT events can be in flight, // doubled so that a transient backlog never overruns the queue (which would desync the lockstep // invariant between i2s_event_queue_ and write_records_queue_). @@ -27,6 +36,17 @@ static constexpr size_t I2S_EVENT_QUEUE_COUNT = DMA_BUFFERS_COUNT * 2; // without masking real failures. static constexpr TickType_t WRITE_TIMEOUT_TICKS = pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS * (DMA_BUFFERS_COUNT + 1)); +// Requested frames per DMA buffer for the given stream, clamped so the byte size stays within the ESP-IDF +// maximum DMA descriptor size. This is only the value handed to the channel config: ESP-IDF may still adjust +// it (e.g. cache-line rounding on some targets), so the speaker task reads the size actually allocated back +// from the driver instead of assuming this value. Clamping here keeps the request in range and avoids a +// noisy ESP-IDF "dma frame num is out of dma buffer size" warning at high sample rates or bit depths. +static uint32_t dma_buffer_frames(const audio::AudioStreamInfo &stream_info) { + const uint32_t frames_from_duration = stream_info.ms_to_frames(DMA_BUFFER_DURATION_MS); + const uint32_t max_frames = I2S_DMA_BUFFER_MAX_SIZE / stream_info.frames_to_bytes(1); + return std::min(frames_from_duration, max_frames); +} + void I2SAudioSpeaker::dump_config() { I2SAudioSpeakerBase::dump_config(); const char *fmt_str; @@ -57,8 +77,21 @@ void I2SAudioSpeaker::run_speaker_task() { // avoids unnecessary single-frame splices. const size_t ring_buffer_size = (this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame; - const uint32_t frames_per_dma_buffer = this->current_stream_info_.ms_to_frames(DMA_BUFFER_DURATION_MS); - const size_t dma_buffer_bytes = this->current_stream_info_.frames_to_bytes(frames_per_dma_buffer); + // ESP-IDF may allocate smaller (or cache-line-rounded) DMA buffers than dma_buffer_frames() requested: it + // clamps each descriptor to the max DMA descriptor size and, on targets that route internal memory through + // the L1 cache (e.g. ESP32-P4), rounds the buffer to the cache line. Read the size the driver actually + // allocated so preload, silence padding, and the write/event lockstep all match it exactly. The channel is + // in the READY state here because start_i2s_driver() initialized it before this task was created. + size_t dma_buffer_bytes; + i2s_chan_info_t chan_info; + if (i2s_channel_get_info(this->tx_handle_, &chan_info) == ESP_OK && chan_info.total_dma_buf_size > 0) { + // total_dma_buf_size spans all DMA_BUFFERS_COUNT descriptors and is an exact multiple of the count. + dma_buffer_bytes = chan_info.total_dma_buf_size / DMA_BUFFERS_COUNT; + } else { + // Should not happen for a READY channel; fall back to the requested size. + dma_buffer_bytes = this->current_stream_info_.frames_to_bytes(dma_buffer_frames(this->current_stream_info_)); + } + const uint32_t frames_per_dma_buffer = this->current_stream_info_.bytes_to_frames(dma_buffer_bytes); bool successful_setup = false; @@ -308,12 +341,24 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream return ESP_ERR_NOT_SUPPORTED; } +#ifdef USE_ESP32_VARIANT_ESP32 + // The original ESP32 I2S peripheral stores each sample in a whole number of 16-bit words (a 24-bit sample + // occupies 4 bytes in the DMA buffer, an 8-bit sample 2 bytes), but ESPHome's audio pipeline packs samples + // tightly (3 bytes for 24-bit, 1 for 8-bit). The two layouts only line up when the bit depth is a multiple + // of 16, so reject anything else rather than emit corrupted audio. + if (audio_stream_info.get_bits_per_sample() % 16 != 0) { + ESP_LOGE(TAG, "ESP32 supports only 16- or 32-bit audio, got %u-bit", + (unsigned) audio_stream_info.get_bits_per_sample()); + return ESP_ERR_NOT_SUPPORTED; + } +#endif // USE_ESP32_VARIANT_ESP32 + if (!this->parent_->try_lock()) { ESP_LOGE(TAG, "Parent bus is busy"); return ESP_ERR_INVALID_STATE; } - uint32_t dma_buffer_length = audio_stream_info.ms_to_frames(DMA_BUFFER_DURATION_MS); + uint32_t dma_buffer_length = dma_buffer_frames(audio_stream_info); i2s_role_t i2s_role = this->i2s_role_; i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT; From 792e1ff30466d798805a75ea0224847da98cab9b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 3 Jun 2026 07:12:50 +1200 Subject: [PATCH 0286/1815] [i2c] Add basic host platform support (#14489) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/i2c/__init__.py | 119 +++++-- esphome/components/i2c/i2c_bus_host.cpp | 297 ++++++++++++++++++ esphome/components/i2c/i2c_bus_host.h | 41 +++ tests/components/i2c/test.host.yaml | 4 + .../common/i2c/host.yaml | 7 + 5 files changed, 449 insertions(+), 19 deletions(-) create mode 100644 esphome/components/i2c/i2c_bus_host.cpp create mode 100644 esphome/components/i2c/i2c_bus_host.h create mode 100644 tests/components/i2c/test.host.yaml create mode 100644 tests/test_build_components/common/i2c/host.yaml diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 1684f479ba..d9dd6d5ee2 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -1,4 +1,6 @@ import logging +import re +import sys from esphome import pins import esphome.codegen as cg @@ -29,6 +31,7 @@ from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, + CONF_DEVICE, CONF_FREQUENCY, CONF_I2C, CONF_I2C_ID, @@ -40,6 +43,7 @@ from esphome.const import ( CONF_TIMEOUT, PLATFORM_ESP32, PLATFORM_ESP8266, + PLATFORM_HOST, PLATFORM_NRF52, PLATFORM_RP2040, PlatformFramework, @@ -56,6 +60,7 @@ InternalI2CBus = i2c_ns.class_("InternalI2CBus", I2CBus) ArduinoI2CBus = i2c_ns.class_("ArduinoI2CBus", InternalI2CBus, cg.Component) IDFI2CBus = i2c_ns.class_("IDFI2CBus", InternalI2CBus, cg.Component) ZephyrI2CBus = i2c_ns.class_("ZephyrI2CBus", I2CBus, cg.Component) +HostI2CBus = i2c_ns.class_("HostI2CBus", I2CBus, cg.Component) I2CDevice = i2c_ns.class_("I2CDevice") ESP32_I2C_CAPABILITIES = { @@ -83,6 +88,12 @@ CONF_SCL_PULLUP_ENABLED = "scl_pullup_enabled" MULTI_CONF = True +def validate_device(value): + if not re.match(r"^/(?:[^/]+/)*[^/]+$", value): + raise cv.Invalid("Device must be an absolute device path (e.g., /dev/i2c-0)") + return value + + def _bus_declare_type(value): if CORE.is_esp32: return cv.declare_id(IDFI2CBus)(value) @@ -90,6 +101,8 @@ def _bus_declare_type(value): return cv.declare_id(ArduinoI2CBus)(value) if CORE.using_zephyr: return cv.declare_id(ZephyrI2CBus)(value) + if CORE.is_host: + return cv.declare_id(HostI2CBus)(value) raise NotImplementedError @@ -121,15 +134,48 @@ def validate_config(config): return config +def validate_host_config(config): + if CORE.is_host: + # Host I2C is currently only supported on Linux + if not sys.platform.lower().startswith("linux"): + raise cv.Invalid( + "I2C is only supported on Linux for the host platform. " + f"Current platform: {sys.platform}" + ) + if CONF_SDA in config or CONF_SCL in config: + raise cv.Invalid( + "'sda' and 'scl' are not supported on host platform; use 'device' instead." + ) + if CONF_SDA_PULLUP_ENABLED in config or CONF_SCL_PULLUP_ENABLED in config: + raise cv.Invalid("Pull-up configuration is not supported on host platform.") + if CONF_DEVICE not in config: + raise cv.Invalid( + "'device' is required for host platform (e.g., /dev/i2c-0)." + ) + return config + + CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): _bus_declare_type, - cv.Optional(CONF_SDA, default="SDA"): pins.internal_gpio_pin_number, + cv.SplitDefault( + CONF_SDA, + esp32="SDA", + esp8266="SDA", + rp2040="SDA", + nrf52="SDA", + ): pins.internal_gpio_pin_number, cv.SplitDefault(CONF_SDA_PULLUP_ENABLED, esp32=True): cv.All( cv.only_on_esp32, cv.boolean ), - cv.Optional(CONF_SCL, default="SCL"): pins.internal_gpio_pin_number, + cv.SplitDefault( + CONF_SCL, + esp32="SCL", + esp8266="SCL", + rp2040="SCL", + nrf52="SCL", + ): pins.internal_gpio_pin_number, cv.SplitDefault(CONF_SCL_PULLUP_ENABLED, esp32=True): cv.All( cv.only_on_esp32, cv.boolean ), @@ -139,6 +185,7 @@ CONFIG_SCHEMA = cv.All( esp8266="50kHz", rp2040="50kHz", nrf52="100kHz", + host="50kHz", ): cv.All( cv.frequency, cv.float_range(min=0, min_included=False), @@ -155,10 +202,22 @@ CONFIG_SCHEMA = cv.All( ), cv.boolean, ), + cv.Optional(CONF_DEVICE): cv.All( + cv.only_on(PLATFORM_HOST), validate_device + ), } ).extend(cv.COMPONENT_SCHEMA), - cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040, PLATFORM_NRF52]), + cv.only_on( + [ + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_RP2040, + PLATFORM_NRF52, + PLATFORM_HOST, + ] + ), validate_config, + validate_host_config, ) @@ -217,7 +276,13 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config): cg.add_global(i2c_ns.using) cg.add_define("USE_I2C") - if CORE.using_zephyr: + if CORE.is_host: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + cg.add(var.set_device(config[CONF_DEVICE])) + cg.add(var.set_frequency(int(config[CONF_FREQUENCY]))) + cg.add(var.set_scan(config[CONF_SCAN])) + elif CORE.using_zephyr: zephyr_add_prj_conf("I2C", True) i2c = "i2c0" if zephyr_data()[KEY_BOARD] == "xiao_ble": @@ -244,25 +309,40 @@ async def to_code(config): var = cg.new_Pvariable( config[CONF_ID], MockObj(f"DEVICE_DT_GET(DT_NODELABEL({i2c}))") ) + await cg.register_component(var, config) + + cg.add(var.set_sda_pin(config[CONF_SDA])) + if CONF_SDA_PULLUP_ENABLED in config: + cg.add(var.set_sda_pullup_enabled(config[CONF_SDA_PULLUP_ENABLED])) + cg.add(var.set_scl_pin(config[CONF_SCL])) + if CONF_SCL_PULLUP_ENABLED in config: + cg.add(var.set_scl_pullup_enabled(config[CONF_SCL_PULLUP_ENABLED])) + + cg.add(var.set_frequency(int(config[CONF_FREQUENCY]))) + cg.add(var.set_scan(config[CONF_SCAN])) + if CONF_TIMEOUT in config: + cg.add(var.set_timeout(int(config[CONF_TIMEOUT].total_microseconds))) + if CONF_LOW_POWER_MODE in config: + cg.add(var.set_lp_mode(bool(config[CONF_LOW_POWER_MODE]))) else: var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) + await cg.register_component(var, config) - cg.add(var.set_sda_pin(config[CONF_SDA])) - if CONF_SDA_PULLUP_ENABLED in config: - cg.add(var.set_sda_pullup_enabled(config[CONF_SDA_PULLUP_ENABLED])) - cg.add(var.set_scl_pin(config[CONF_SCL])) - if CONF_SCL_PULLUP_ENABLED in config: - cg.add(var.set_scl_pullup_enabled(config[CONF_SCL_PULLUP_ENABLED])) + cg.add(var.set_sda_pin(config[CONF_SDA])) + if CONF_SDA_PULLUP_ENABLED in config: + cg.add(var.set_sda_pullup_enabled(config[CONF_SDA_PULLUP_ENABLED])) + cg.add(var.set_scl_pin(config[CONF_SCL])) + if CONF_SCL_PULLUP_ENABLED in config: + cg.add(var.set_scl_pullup_enabled(config[CONF_SCL_PULLUP_ENABLED])) - cg.add(var.set_frequency(int(config[CONF_FREQUENCY]))) - cg.add(var.set_scan(config[CONF_SCAN])) - if CONF_TIMEOUT in config: - cg.add(var.set_timeout(int(config[CONF_TIMEOUT].total_microseconds))) - if CORE.using_arduino and not CORE.is_esp32: - cg.add_library("Wire", None) - if CONF_LOW_POWER_MODE in config: - cg.add(var.set_lp_mode(bool(config[CONF_LOW_POWER_MODE]))) + cg.add(var.set_frequency(int(config[CONF_FREQUENCY]))) + cg.add(var.set_scan(config[CONF_SCAN])) + if CONF_TIMEOUT in config: + cg.add(var.set_timeout(int(config[CONF_TIMEOUT].total_microseconds))) + if CORE.using_arduino and not CORE.is_esp32: + cg.add_library("Wire", None) + if CONF_LOW_POWER_MODE in config: + cg.add(var.set_lp_mode(bool(config[CONF_LOW_POWER_MODE]))) def i2c_device_schema(default_address): @@ -365,5 +445,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.ESP32_IDF, }, "i2c_bus_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, + "i2c_bus_host.cpp": {PlatformFramework.HOST_NATIVE}, } ) diff --git a/esphome/components/i2c/i2c_bus_host.cpp b/esphome/components/i2c/i2c_bus_host.cpp new file mode 100644 index 0000000000..17279fda50 --- /dev/null +++ b/esphome/components/i2c/i2c_bus_host.cpp @@ -0,0 +1,297 @@ +#ifdef USE_HOST +#if defined(__linux__) + +#include "i2c_bus_host.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace esphome::i2c { + +static const char *const TAG = "i2c.host"; + +HostI2CBus::~HostI2CBus() { + if (this->file_descriptor_ != -1) { + close(this->file_descriptor_); + this->file_descriptor_ = -1; + } +} + +void HostI2CBus::setup() { + ESP_LOGCONFIG(TAG, "Setting up I2C bus..."); + + // Open I2C device file + this->file_descriptor_ = open(this->device_.c_str(), O_RDWR); + if (this->file_descriptor_ == -1) { + int err = errno; + if (err == ENOENT) { + this->update_error_("not found"); + } else if (err == EACCES) { + this->update_error_("permission denied"); + } else { + this->update_error_(std::string("failed to open: ") + strerror(err)); + } + this->mark_failed(); + return; + } + + this->initialized_ = true; + ESP_LOGCONFIG(TAG, " Device: %s", this->device_.c_str()); + + // Run bus scan if enabled + if (this->scan_) { + this->i2c_scan_(); + } +} + +void HostI2CBus::dump_config() { + ESP_LOGCONFIG(TAG, "I2C Bus:"); + ESP_LOGCONFIG(TAG, " Device: %s", this->device_.c_str()); + // Bus frequency cannot be set from userspace via i2c-dev; report it as informational only + ESP_LOGCONFIG(TAG, " Frequency: %u Hz (informational; not applied on host)", this->frequency_); + + if (!this->first_error_.empty()) { + ESP_LOGE(TAG, " Setup Error: %s", this->first_error_.c_str()); + } + + if (this->scan_) { + ESP_LOGI(TAG, " Scan Results:"); + for (const auto &s : this->scan_results_) { + if (s.second) { + ESP_LOGI(TAG, " 0x%02X: Found", s.first); + } + } + } +} + +ErrorCode HostI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, + uint8_t *read_buffer, size_t read_count) { + if (!this->initialized_) { + ESP_LOGE(TAG, "I2C bus not initialized"); + return ERROR_NOT_INITIALIZED; + } + + ESP_LOGVV(TAG, "I2C write_readv addr=0x%02X write=%zu read=%zu", address, write_count, read_count); + + // Handle special case: probe (no write data, no read data) + // This is used for device detection during bus scanning + if (write_count == 0 && read_count == 0) { + struct i2c_msg msg; + msg.addr = address; + msg.flags = 0; + msg.len = 0; + msg.buf = nullptr; + + struct i2c_rdwr_ioctl_data rdwr_data; + rdwr_data.msgs = &msg; + rdwr_data.nmsgs = 1; + + int ret = ioctl(this->file_descriptor_, I2C_RDWR, &rdwr_data); + if (ret < 0) { + int err = errno; + // If I2C_RDWR not supported, try SMBus Quick command (what i2cdetect uses) + if (err == EOPNOTSUPP || err == ENOSYS) { + ESP_LOGVV(TAG, "I2C_RDWR probe failed, trying SMBus Quick for addr=0x%02X", address); + if (ioctl(this->file_descriptor_, I2C_SLAVE, address) < 0) { // NOLINT + return this->map_errno_to_error_code_(errno); + } + // Use I2C_SMBUS ioctl with Quick command + union i2c_smbus_data data; + struct i2c_smbus_ioctl_data args; + args.read_write = I2C_SMBUS_WRITE; + args.command = 0; + args.size = I2C_SMBUS_QUICK; + args.data = &data; + ret = ioctl(this->file_descriptor_, I2C_SMBUS, &args); + if (ret < 0) { + return this->map_errno_to_error_code_(errno); + } + return ERROR_OK; + } + return this->map_errno_to_error_code_(err); + } + return ERROR_OK; + } + + // i2c_msg.len is a 16-bit field; reject transfers that would silently truncate + if (write_count > UINT16_MAX || read_count > UINT16_MAX) { + ESP_LOGE(TAG, "I2C transfer too large: write=%zu read=%zu (max %u)", write_count, read_count, + (unsigned) UINT16_MAX); + return ERROR_TOO_LARGE; + } + + // Prepare messages for combined write-read transaction + struct i2c_msg msgs[2]; + int num_msgs = 0; + + // Add write message if write data present + if (write_count > 0) { + msgs[num_msgs].addr = address; + msgs[num_msgs].flags = 0; // Write + msgs[num_msgs].len = write_count; + msgs[num_msgs].buf = const_cast(write_buffer); + num_msgs++; + } + + // Add read message if read data requested + if (read_count > 0) { + msgs[num_msgs].addr = address; + msgs[num_msgs].flags = I2C_M_RD; // Read + msgs[num_msgs].len = read_count; + msgs[num_msgs].buf = read_buffer; + num_msgs++; + } + + // Execute I2C transaction + struct i2c_rdwr_ioctl_data rdwr_data; + rdwr_data.msgs = msgs; + rdwr_data.nmsgs = num_msgs; + + int ret = ioctl(this->file_descriptor_, I2C_RDWR, &rdwr_data); + if (ret < 0) { + int err = errno; + if (err == EOPNOTSUPP || err == ENOSYS) { + ESP_LOGV(TAG, "I2C_RDWR not supported, using I2C_SLAVE fallback for addr=0x%02X", address); // NOLINT + if (ioctl(this->file_descriptor_, I2C_SLAVE, address) < 0) { // NOLINT + ESP_LOGV(TAG, "I2C_SLAVE ioctl failed: %s", strerror(errno)); // NOLINT + return this->map_errno_to_error_code_(errno); + } + // Perform write if needed + if (write_count > 0) { + ssize_t written = ::write(this->file_descriptor_, write_buffer, write_count); + if (written != (ssize_t) write_count) { + int write_err = errno; + // If write() also fails with EOPNOTSUPP, try I2C_SMBUS as last resort + if (write_err == EOPNOTSUPP || write_err == ENOSYS) { + ESP_LOGV(TAG, "I2C_SLAVE write not supported, trying I2C_SMBUS for addr=0x%02X", address); // NOLINT + // Use I2C_SMBUS_I2C_BLOCK_DATA for writes up to 32 bytes + // Standard SMBus mapping: first byte is command, remaining bytes are data + if (write_count < 1) { + ESP_LOGE(TAG, "Write size too small for I2C_SMBUS"); + return ERROR_INVALID_ARGUMENT; + } + if (write_count > I2C_SMBUS_BLOCK_MAX + 1) { + ESP_LOGE(TAG, "Write size %zu exceeds I2C_SMBUS_BLOCK_MAX+1 (%d)", write_count, I2C_SMBUS_BLOCK_MAX + 1); + return ERROR_INVALID_ARGUMENT; + } + union i2c_smbus_data data; + // Standard SMBus: first byte = command, rest = data + uint8_t command = write_buffer[0]; + size_t data_len = write_count - 1; + data.block[0] = data_len; + if (data_len > 0) { + memcpy(&data.block[1], write_buffer + 1, data_len); + } + + struct i2c_smbus_ioctl_data args; + args.read_write = I2C_SMBUS_WRITE; + args.command = command; + args.size = I2C_SMBUS_I2C_BLOCK_DATA; + args.data = &data; + + ret = ioctl(this->file_descriptor_, I2C_SMBUS, &args); + if (ret < 0) { + ESP_LOGV(TAG, "I2C_SMBUS write failed: %s", strerror(errno)); + return this->map_errno_to_error_code_(errno); + } + } else { + ESP_LOGV(TAG, "I2C write failed: %s", strerror(write_err)); + return this->map_errno_to_error_code_(write_err); + } + } + } + // Perform read if needed + if (read_count > 0) { + ssize_t bytes_read = ::read(this->file_descriptor_, read_buffer, read_count); + if (bytes_read != (ssize_t) read_count) { + int read_err = errno; + // If read() also fails with EOPNOTSUPP, try I2C_SMBUS as last resort + if (read_err == EOPNOTSUPP || read_err == ENOSYS) { + ESP_LOGV(TAG, "I2C_SLAVE read not supported, trying I2C_SMBUS for addr=0x%02X", address); // NOLINT + // Use I2C_SMBUS_I2C_BLOCK_DATA for reads up to 32 bytes + if (read_count > I2C_SMBUS_BLOCK_MAX) { + ESP_LOGE(TAG, "Read size %zu exceeds I2C_SMBUS_BLOCK_MAX (%d)", read_count, I2C_SMBUS_BLOCK_MAX); + return ERROR_INVALID_ARGUMENT; + } + union i2c_smbus_data data; + data.block[0] = read_count; + + struct i2c_smbus_ioctl_data args; + args.read_write = I2C_SMBUS_READ; + args.command = 0; // Start register/command + args.size = I2C_SMBUS_I2C_BLOCK_DATA; + args.data = &data; + + ret = ioctl(this->file_descriptor_, I2C_SMBUS, &args); + if (ret < 0) { + ESP_LOGV(TAG, "I2C_SMBUS read failed: %s", strerror(errno)); + return this->map_errno_to_error_code_(errno); + } + // I2C_SMBUS_I2C_BLOCK_DATA returns the actual byte count in block[0]; + // a short read means we did not receive all requested bytes + if (data.block[0] < read_count) { + ESP_LOGV(TAG, "I2C_SMBUS short read: got %u, expected %zu", data.block[0], read_count); + return ERROR_NOT_ACKNOWLEDGED; + } + // Copy data from SMBus buffer to output buffer + memcpy(read_buffer, &data.block[1], read_count); + } else { + ESP_LOGV(TAG, "I2C read failed: %s", strerror(read_err)); + return this->map_errno_to_error_code_(read_err); + } + } + } + ESP_LOGVV(TAG, "I2C transaction successful (I2C_SLAVE method)"); // NOLINT + return ERROR_OK; + } + ESP_LOGV(TAG, "I2C transaction failed: %s", strerror(err)); + return this->map_errno_to_error_code_(err); + } + + ESP_LOGVV(TAG, "I2C transaction successful"); + return ERROR_OK; +} + +ErrorCode HostI2CBus::map_errno_to_error_code_(int err) { + switch (err) { + case ENXIO: + return ERROR_NOT_ACKNOWLEDGED; + case ETIMEDOUT: + return ERROR_TIMEOUT; + case EINVAL: + return ERROR_INVALID_ARGUMENT; + case ENODEV: + case ENOTTY: + return ERROR_NOT_INITIALIZED; + case EOPNOTSUPP: + case ENOSYS: + // Operation not supported - some I2C adapters don't support zero-length transactions + ESP_LOGVV(TAG, "I2C adapter does not support this operation (likely zero-length probe)"); + return ERROR_NOT_ACKNOWLEDGED; + default: + ESP_LOGV(TAG, "Unmapped error code: %d (%s)", err, strerror(err)); + return ERROR_UNKNOWN; + } +} + +void HostI2CBus::update_error_(const std::string &error) { + if (this->first_error_.empty()) { + this->first_error_ = error; + } + ESP_LOGE(TAG, "[%s] %s", this->device_.c_str(), error.c_str()); +} + +} // namespace esphome::i2c + +#else +#error "HostI2CBus is only supported on Linux" +#endif // defined(__linux__) +#endif // USE_HOST diff --git a/esphome/components/i2c/i2c_bus_host.h b/esphome/components/i2c/i2c_bus_host.h new file mode 100644 index 0000000000..8e3aff7977 --- /dev/null +++ b/esphome/components/i2c/i2c_bus_host.h @@ -0,0 +1,41 @@ +#pragma once + +#ifdef USE_HOST + +#include "esphome/core/component.h" +#include "esphome/core/log.h" +#include "i2c_bus.h" + +namespace esphome::i2c { + +class HostI2CBus : public I2CBus, public Component { + public: + ~HostI2CBus() override; + + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::BUS; } + + ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) override; + + void set_device(const std::string &device) { this->device_ = device; } + void set_scan(bool scan) { this->scan_ = scan; } + void set_frequency(uint32_t frequency) { this->frequency_ = frequency; } + + const std::string &get_device() const { return this->device_; } + + protected: + void update_error_(const std::string &error); + ErrorCode map_errno_to_error_code_(int err); + + std::string device_; + uint32_t frequency_{50000}; + int file_descriptor_{-1}; + bool initialized_{false}; + std::string first_error_; +}; + +} // namespace esphome::i2c + +#endif // USE_HOST diff --git a/tests/components/i2c/test.host.yaml b/tests/components/i2c/test.host.yaml new file mode 100644 index 0000000000..6ae617e230 --- /dev/null +++ b/tests/components/i2c/test.host.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/host.yaml + +<<: !include common.yaml diff --git a/tests/test_build_components/common/i2c/host.yaml b/tests/test_build_components/common/i2c/host.yaml new file mode 100644 index 0000000000..00bad206d8 --- /dev/null +++ b/tests/test_build_components/common/i2c/host.yaml @@ -0,0 +1,7 @@ +# Common I2C configuration for host platform tests + +i2c: + - id: i2c_bus + device: /dev/i2c-0 + frequency: 100kHz + scan: true From 997ab116876c73ccc14c61f5e0735d6050f7671a Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:21:33 +1000 Subject: [PATCH 0287/1815] [lvgl][mipi_spi][mipi_rgb][mipi_dsi][display] Metadata (#16702) --- esphome/components/display/__init__.py | 102 ++++++++-- esphome/components/lvgl/__init__.py | 116 ++++++------ esphome/components/mipi_dsi/display.py | 17 +- esphome/components/mipi_rgb/display.py | 17 +- esphome/components/mipi_spi/display.py | 28 ++- .../display/test_display_metadata.py | 130 ++++++++++--- tests/component_tests/lvgl/test_validation.py | 177 ++++++++++++++++++ .../mipi_spi/test_display_metadata.py | 106 ++++------- 8 files changed, 520 insertions(+), 173 deletions(-) create mode 100644 tests/component_tests/lvgl/test_validation.py diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index 744b5d16c4..7a66da11f2 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -3,11 +3,18 @@ from dataclasses import dataclass from esphome import automation, core from esphome.automation import maybe_simple_id import esphome.codegen as cg -from esphome.components.const import KEY_METADATA +from esphome.components.const import ( + BYTE_ORDER_BIG, + CONF_BYTE_ORDER, + CONF_DRAW_ROUNDING, + KEY_METADATA, +) import esphome.config_validation as cv from esphome.const import ( CONF_AUTO_CLEAR_ENABLED, + CONF_DIMENSIONS, CONF_FROM, + CONF_HEIGHT, CONF_ID, CONF_LAMBDA, CONF_PAGE_ID, @@ -16,10 +23,11 @@ from esphome.const import ( CONF_TO, CONF_TRIGGER_ID, CONF_UPDATE_INTERVAL, + CONF_WIDTH, SCHEDULER_DONT_RUN, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.cpp_generator import MockObj +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.final_validate import full_config DOMAIN = "display" IS_PLATFORM_COMPONENT = True @@ -159,29 +167,97 @@ async def setup_display_core_(var, config): class DisplayMetaData: width: int = 0 height: int = 0 - has_writer: bool = False has_hardware_rotation: bool = False + byte_order: str = BYTE_ORDER_BIG + has_writer: bool = False + rotation: int = 0 + draw_rounding: int = 0 + + +def _get_metadata_list() -> list[tuple]: + """Get the raw metadata list. Each entry is (id, DisplayMetaData).""" + return CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_METADATA, []) def get_all_display_metadata() -> dict[str, DisplayMetaData]: - """Get all display metadata.""" - return CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_METADATA, {}) + """Get all display metadata as a dict keyed by resolved ID strings. + + Must not be called before IDs have been finalised. + """ + entries = _get_metadata_list() + assert all(id_.id is not None for id_, _ in entries), ( + "get_all_display_metadata called before display IDs have been resolved" + ) + return {id_.id: meta for id_, meta in entries} -def get_display_metadata(display_id: str) -> DisplayMetaData | None: - """Get display metadata by ID for use by other components.""" - return get_all_display_metadata().get(display_id, DisplayMetaData()) +def get_display_metadata(display_id: ID) -> DisplayMetaData: + """Get display metadata by ID object + + Must not be called before IDs have been finalised. + """ + for id_, meta in _get_metadata_list(): + if id_ is display_id: + return meta + assert id_.id is not None, ( + "get_display_metadata called before display IDs have been resolved" + ) + if id_.id == display_id.id: + return meta + # No metadata found, display driver may not yet support it. + # Read the raw config to populate the returned data + global_config = full_config.get() + path = global_config.get_path_for_id(display_id)[:-1] + disp_config = global_config.get_config_for_path(path) + dimensions = disp_config.get(CONF_DIMENSIONS, (0, 0)) + if isinstance(dimensions, dict): + dimensions = (dimensions.get(CONF_WIDTH, 0), dimensions.get(CONF_HEIGHT, 0)) + elif not isinstance(dimensions, tuple) or len(dimensions) != 2: + dimensions = (0, 0) + + meta = DisplayMetaData( + width=dimensions[0], + height=dimensions[1], + has_hardware_rotation=False, + byte_order=disp_config.get(CONF_BYTE_ORDER, cv.UNDEFINED), + has_writer=disp_config.get(CONF_AUTO_CLEAR_ENABLED) is True + or disp_config.get(CONF_PAGES) is not None + or disp_config.get(CONF_LAMBDA) is not None + or disp_config.get(CONF_SHOW_TEST_CARD) is True, + rotation=disp_config.get(CONF_ROTATION, 0), + draw_rounding=disp_config.get(CONF_DRAW_ROUNDING, 0), + ) + _get_metadata_list().append((display_id, meta)) + return meta def add_metadata( - id: str | MockObj, + id: ID, width: int, height: int, - has_writer: bool, has_hardware_rotation: bool = False, + byte_order: str = BYTE_ORDER_BIG, + has_writer: bool = False, + rotation: int = 0, + draw_rounding: int = 0, ): - get_all_display_metadata()[str(id)] = DisplayMetaData( - width, height, has_writer, has_hardware_rotation + entries = _get_metadata_list() + assert not any(existing_id is id for existing_id, _ in entries), ( + f"Duplicate display metadata for ID {id}" + ) + entries.append( + ( + id, + DisplayMetaData( + width=width, + height=height, + has_hardware_rotation=has_hardware_rotation, + byte_order=byte_order, + has_writer=has_writer, + rotation=rotation, + draw_rounding=draw_rounding, + ), + ) ) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 6e005f897e..022d629960 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -7,6 +7,7 @@ import re from esphome.automation import Trigger, build_automation, validate_automation import esphome.codegen as cg from esphome.components.const import ( + BYTE_ORDER_BIG, CONF_BYTE_ORDER, CONF_COLOR_DEPTH, CONF_DRAW_ROUNDING, @@ -30,12 +31,10 @@ from esphome.components.image import ( from esphome.components.psram import DOMAIN as PSRAM_DOMAIN import esphome.config_validation as cv from esphome.const import ( - CONF_AUTO_CLEAR_ENABLED, CONF_BUFFER_SIZE, CONF_ESPHOME, CONF_GROUP, CONF_ID, - CONF_LAMBDA, CONF_LOG_LEVEL, CONF_ON_IDLE, CONF_PAGES, @@ -214,61 +213,73 @@ def multi_conf_validate(configs: list[dict]): def final_validation(config_list): - if len(config_list) != 1: - multi_conf_validate(config_list) global_config = full_config.get() + # Resolve byte_order from display metadata before multi-config validation for config in config_list: + metas = [get_display_metadata(disp) for disp in config[df.CONF_DISPLAYS]] + if any(m.has_writer for m in metas): + raise cv.Invalid( + "Using lambda:, pages:, auto_clear_enabled: true, or show_test_card: true in display config is not compatible with LVGL" + ) + if any(m.rotation != 0 for m in metas): + raise cv.Invalid( + "use of 'rotation' in the display config is not compatible with LVGL, please set rotation in the LVGL config instead" + ) + config[CONF_DRAW_ROUNDING] = max( + [m.draw_rounding for m in metas] + [config[CONF_DRAW_ROUNDING]] + ) + display_byte_orders = { + m.byte_order for m in metas if m.byte_order is not cv.UNDEFINED + } + if len(display_byte_orders) > 1: + raise cv.Invalid( + "All displays configured for an LVGL instance must use the same byte_order" + ) + if display_byte_orders: + display_order = next(iter(display_byte_orders)) + if CONF_BYTE_ORDER in config: + if config[CONF_BYTE_ORDER] != display_order: + raise cv.Invalid( + "LVGL byte order must match the display byte order", + [CONF_BYTE_ORDER], + ) + else: + config[CONF_BYTE_ORDER] = display_order + if CONF_BYTE_ORDER not in config: + config[CONF_BYTE_ORDER] = BYTE_ORDER_BIG + if (pages := config.get(CONF_PAGES)) and all(p[df.CONF_SKIP] for p in pages): raise cv.Invalid("At least one page must not be skipped") - for display_id in config[df.CONF_DISPLAYS]: - path = global_config.get_path_for_id(display_id)[:-1] - display = global_config.get_config_for_path(path) - if CONF_LAMBDA in display or CONF_PAGES in display: - raise cv.Invalid( - "Using lambda: or pages: in display config is not compatible with LVGL" - ) - # treating 0 as false is intended here. - if display.get(CONF_ROTATION): - raise cv.Invalid( - "use of 'rotation' in the display config is not compatible with LVGL, please set rotation in the LVGL config instead" - ) - if display.get(CONF_AUTO_CLEAR_ENABLED) is True: - raise cv.Invalid( - "Using auto_clear_enabled: true in display config not compatible with LVGL" - ) - if draw_rounding := display.get(CONF_DRAW_ROUNDING): - config[CONF_DRAW_ROUNDING] = max( - draw_rounding, config[CONF_DRAW_ROUNDING] - ) buffer_frac = config[CONF_BUFFER_SIZE] if CORE.is_esp32 and buffer_frac > 0.5 and PSRAM_DOMAIN not in global_config: df.LOGGER.warning("buffer_size: may need to be reduced without PSRAM") - for w in get_focused_widgets(): - path = global_config.get_path_for_id(w) - widget_conf = global_config.get_config_for_path(path[:-1]) - if ( - df.CONF_ADJUSTABLE in widget_conf - and not widget_conf[df.CONF_ADJUSTABLE] - ): - raise cv.Invalid( - "A non adjustable arc may not be focused", - path, - ) - for w in get_refreshed_widgets(): - path = global_config.get_path_for_id(w) - widget_conf = global_config.get_config_for_path(path[:-1]) - if not any(isinstance(v, (Lambda, dict)) for v in widget_conf.values()): - raise cv.Invalid( - f"Widget '{w}' does not have any dynamic properties to refresh", - ) - # Do per-widget type final validation for update actions - for widget_type, update_configs in df.get_updated_widgets().items(): - for conf in update_configs: - for id_conf in conf.get(CONF_ID, ()): - name = id_conf[CONF_ID] - path = global_config.get_path_for_id(name) - widget_conf = global_config.get_config_for_path(path[:-1]) - widget_type.final_validate(name, conf, widget_conf, path[1:]) + + if len(config_list) != 1: + multi_conf_validate(config_list) + + for w in get_focused_widgets(): + path = global_config.get_path_for_id(w) + widget_conf = global_config.get_config_for_path(path[:-1]) + if df.CONF_ADJUSTABLE in widget_conf and not widget_conf[df.CONF_ADJUSTABLE]: + raise cv.Invalid( + "A non adjustable arc may not be focused", + path, + ) + for w in get_refreshed_widgets(): + path = global_config.get_path_for_id(w) + widget_conf = global_config.get_config_for_path(path[:-1]) + if not any(isinstance(v, (Lambda, dict)) for v in widget_conf.values()): + raise cv.Invalid( + f"Widget '{w}' does not have any dynamic properties to refresh", + ) + # Do per-widget type final validation for update actions + for widget_type, update_configs in df.get_updated_widgets().items(): + for conf in update_configs: + for id_conf in conf.get(CONF_ID, ()): + name = id_conf[CONF_ID] + path = global_config.get_path_for_id(name) + widget_conf = global_config.get_config_for_path(path[:-1]) + widget_type.final_validate(name, conf, widget_conf, path[1:]) async def to_code(configs): @@ -367,8 +378,7 @@ async def to_code(configs): # options will have CONF_ROTATION true if rotation is changed in an automation. if CONF_ROTATION in config or df.get_options().get(CONF_ROTATION) is True: if all( - get_display_metadata(str(disp)).has_hardware_rotation - for disp in displays + get_display_metadata(disp).has_hardware_rotation for disp in displays ): rotation_type = RotationType.ROTATION_HARDWARE df.LOGGER.info("LVGL will use hardware rotation via display driver") @@ -583,7 +593,7 @@ LVGL_SCHEMA = cv.All( cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( *df.LV_LOG_LEVELS, upper=True ), - cv.Optional(CONF_BYTE_ORDER, default="big_endian"): cv.one_of( + cv.Optional(CONF_BYTE_ORDER): cv.one_of( "big_endian", "little_endian", lower=True ), cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list( diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 026c214569..3554e32299 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -37,6 +37,7 @@ from esphome.components.mipi import ( ) import esphome.config_validation as cv from esphome.const import ( + CONF_AUTO_CLEAR_ENABLED, CONF_COLOR_ORDER, CONF_DIMENSIONS, CONF_DISABLED, @@ -167,7 +168,21 @@ def _config_schema(config): }, extra=cv.ALLOW_EXTRA, )(config) - return model_schema(config)(config) + config = model_schema(config)(config) + model = MODELS[config[CONF_MODEL].upper()] + width, height, _offset_width, _offset_height = model.get_dimensions(config) + display.add_metadata( + config[CONF_ID], + width, + height, + has_hardware_rotation=False, + byte_order=config[CONF_BYTE_ORDER], + has_writer=requires_buffer(config) + or config.get(CONF_AUTO_CLEAR_ENABLED) is True, + rotation=config.get(CONF_ROTATION, 0), + draw_rounding=config.get(CONF_DRAW_ROUNDING, 0), + ) + return config def _final_validate(config): diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 4952bda95f..b38ddad491 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -39,6 +39,7 @@ from esphome.components.rpi_dpi_rgb.display import ( ) import esphome.config_validation as cv from esphome.const import ( + CONF_AUTO_CLEAR_ENABLED, CONF_BLUE, CONF_COLOR_ORDER, CONF_CS_PIN, @@ -226,11 +227,25 @@ def _config_schema(config): extra=cv.ALLOW_EXTRA, )(config) schema = model_schema(config) - return cv.All( + config = cv.All( schema, cv.only_on_esp32, only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4]), )(config) + model = MODELS[config[CONF_MODEL].upper()] + width, height, _offset_width, _offset_height = model.get_dimensions(config) + display.add_metadata( + config[CONF_ID], + width, + height, + model.rotation_as_transform(config), + byte_order=config[CONF_BYTE_ORDER], + has_writer=requires_buffer(config) + or config.get(CONF_AUTO_CLEAR_ENABLED) is True, + rotation=config.get(CONF_ROTATION, 0), + draw_rounding=config.get(CONF_DRAW_ROUNDING, 0), + ) + return config CONFIG_SCHEMA = _config_schema diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 364ada9046..3c5a84594e 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -30,6 +30,7 @@ from esphome.components.spi import TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE import esphome.config_validation as cv from esphome.config_validation import ALLOW_EXTRA from esphome.const import ( + CONF_AUTO_CLEAR_ENABLED, CONF_BRIGHTNESS, CONF_BUFFER_SIZE, CONF_COLOR_ORDER, @@ -47,6 +48,7 @@ from esphome.const import ( CONF_MIRROR_Y, CONF_MODEL, CONF_RESET_PIN, + CONF_ROTATION, CONF_SWAP_XY, CONF_TRANSFORM, CONF_WIDTH, @@ -267,6 +269,28 @@ def customise_schema(config): if bus_mode != TYPE_QUAD and CONF_DC_PIN not in config: raise cv.Invalid(f"DC pin is required in {bus_mode} mode") denominator(config) + model = MODELS[config[CONF_MODEL]] + has_hardware_transform = config.get( + CONF_TRANSFORM + ) != CONF_DISABLED and model.transforms == { + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_SWAP_XY, + } + width, height, _offset_width, _offset_height = model.get_dimensions( + config, not has_hardware_transform + ) + display.add_metadata( + config[CONF_ID], + width, + height, + has_hardware_transform, + byte_order=config[CONF_BYTE_ORDER], + has_writer=requires_buffer(config) + or config.get(CONF_AUTO_CLEAR_ENABLED) is True, + rotation=config.get(CONF_ROTATION, 0), + draw_rounding=config.get(CONF_DRAW_ROUNDING, 0), + ) return config @@ -338,7 +362,6 @@ def get_instance(config): buffer_type = cg.uint8 if color_depth == 8 else cg.uint16 frac = denominator(config) madctl = model.get_madctl(model.get_base_transform(config), config) - has_writer = requires_buffer(config) templateargs = [ buffer_type, bufferpixels, @@ -352,9 +375,6 @@ def get_instance(config): madctl, has_hardware_transform, ] - display.add_metadata( - config[CONF_ID], width, height, has_writer, has_hardware_transform - ) # If a buffer is required, use MipiSpiBuffer, otherwise use MipiSpi if requires_buffer(config): templateargs.extend( diff --git a/tests/component_tests/display/test_display_metadata.py b/tests/component_tests/display/test_display_metadata.py index ef3f12cb73..befb019612 100644 --- a/tests/component_tests/display/test_display_metadata.py +++ b/tests/component_tests/display/test_display_metadata.py @@ -4,77 +4,145 @@ from unittest.mock import patch import pytest +from esphome.components.const import BYTE_ORDER_BIG, BYTE_ORDER_LITTLE from esphome.components.display import ( DisplayMetaData, add_metadata, get_all_display_metadata, get_display_metadata, ) -from esphome.cpp_generator import MockObj +from esphome.config import Config +from esphome.core import ID +from esphome.final_validate import full_config -def test_add_metadata_with_string_id(): - """Test adding metadata with a plain string ID.""" +def test_add_metadata_basic(): + """Test adding metadata with an ID object.""" with patch("esphome.components.display.CORE.data", {}): - add_metadata("my_display", 320, 240, True) - meta = get_display_metadata("my_display") + add_metadata(ID("my_display"), 320, 240) + meta = get_display_metadata(ID("my_display")) assert meta == DisplayMetaData( - width=320, height=240, has_writer=True, has_hardware_rotation=False + width=320, + height=240, + has_hardware_rotation=False, + byte_order=BYTE_ORDER_BIG, ) -def test_add_metadata_with_mockobj_id(): - """Test adding metadata with a MockObj ID (converted via str()).""" +def test_add_metadata_with_all_fields(): + """Test adding metadata with all fields set.""" with patch("esphome.components.display.CORE.data", {}): - mock_id = MockObj("my_display_obj") - add_metadata(mock_id, 480, 320, False, has_hardware_rotation=True) - meta = get_display_metadata("my_display_obj") + add_metadata( + ID("my_display"), + 480, + 320, + has_hardware_rotation=True, + byte_order=BYTE_ORDER_LITTLE, + ) + meta = get_display_metadata(ID("my_display")) assert meta == DisplayMetaData( - width=480, height=320, has_writer=False, has_hardware_rotation=True + width=480, + height=320, + has_hardware_rotation=True, + byte_order=BYTE_ORDER_LITTLE, ) def test_add_metadata_hardware_rotation_default(): """Test that has_hardware_rotation defaults to False.""" with patch("esphome.components.display.CORE.data", {}): - add_metadata("disp", 128, 64, False) - meta = get_display_metadata("disp") + add_metadata(ID("disp"), 128, 64) + meta = get_display_metadata(ID("disp")) assert meta.has_hardware_rotation is False + assert meta.byte_order == BYTE_ORDER_BIG -def test_get_display_metadata_missing_returns_none(): - """Test that querying a non-existent ID returns None.""" +def test_add_metadata_with_byte_order(): + """Test adding metadata with explicit byte_order.""" with patch("esphome.components.display.CORE.data", {}): - data = get_display_metadata("no_such_display") - assert data.width == 0 - assert data.height == 0 - assert data.has_writer is False + add_metadata(ID("disp"), 240, 320, byte_order=BYTE_ORDER_LITTLE) + meta = get_display_metadata(ID("disp")) + assert meta.byte_order == BYTE_ORDER_LITTLE + + +def test_get_display_metadata_missing_reads_raw_config(): + """Querying a non-existent ID falls back to raw config lookup.""" + with patch("esphome.components.display.CORE.data", {}): + # Set up a minimal full_config with a display entry so the fallback + # path in get_display_metadata can find the display config. + fc = Config() + fc["display"] = [ + { + "id": ID("no_such_display", True), + "auto_clear_enabled": True, + "dimensions": {"width": 320, "height": 240}, + "byte_order": BYTE_ORDER_LITTLE, + "rotation": 90, + }, + { + "id": ID("other_display", True), + "auto_clear_enabled": "undefined", + "dimensions": (1024, 600), + }, + ] + fc.declare_ids.append((ID("no_such_display", True), ["display", 0, "id"])) + fc.declare_ids.append((ID("other_display", True), ["display", 1, "id"])) + full_config.set(fc) + data = get_display_metadata(ID("no_such_display")) + assert data.width == 320 + assert data.height == 240 assert data.has_hardware_rotation is False + assert data.has_writer is True + assert data.byte_order == BYTE_ORDER_LITTLE + assert data.rotation == 90 + + data = get_display_metadata(ID("other_display")) + assert data.width == 1024 + assert data.height == 600 + assert data.has_writer is False def test_add_multiple_displays(): """Test adding metadata for multiple displays.""" with patch("esphome.components.display.CORE.data", {}): - add_metadata("disp_a", 320, 240, True) - add_metadata("disp_b", 128, 64, False, has_hardware_rotation=True) + add_metadata(ID("disp_a"), 320, 240) + add_metadata(ID("disp_b"), 128, 64, has_hardware_rotation=True) all_meta = get_all_display_metadata() assert len(all_meta) == 2 - assert all_meta["disp_a"] == DisplayMetaData(320, 240, True, False) - assert all_meta["disp_b"] == DisplayMetaData(128, 64, False, True) + assert all_meta["disp_a"] == DisplayMetaData(320, 240, False) + assert all_meta["disp_b"] == DisplayMetaData(128, 64, True, BYTE_ORDER_BIG) -def test_add_metadata_overwrites_existing(): - """Test that adding metadata for the same ID overwrites the previous entry.""" +def test_add_duplicate_id_asserts(): + """Adding metadata for the same ID object twice should assert.""" with patch("esphome.components.display.CORE.data", {}): - add_metadata("disp", 320, 240, True) - add_metadata("disp", 640, 480, False, has_hardware_rotation=True) - meta = get_display_metadata("disp") - assert meta == DisplayMetaData(640, 480, False, True) + id_obj = ID("disp") + add_metadata(id_obj, 320, 240) + with pytest.raises(AssertionError, match="Duplicate"): + add_metadata(id_obj, 640, 480) def test_metadata_is_frozen(): """Test that DisplayMetaData instances are immutable (frozen dataclass).""" - meta = DisplayMetaData(320, 240, True, False) + meta = DisplayMetaData(320, 240, False, BYTE_ORDER_BIG) with pytest.raises(AttributeError): meta.width = 640 + with pytest.raises(AttributeError): + meta.byte_order = BYTE_ORDER_LITTLE + + +def test_get_all_metadata_asserts_on_unresolved_id(): + """get_all_display_metadata should assert if any ID has id=None.""" + with patch("esphome.components.display.CORE.data", {}): + add_metadata(ID(None), 320, 240) + with pytest.raises(AssertionError, match="resolved"): + get_all_display_metadata() + + +def test_get_metadata_asserts_on_unresolved_id(): + """get_display_metadata should assert if any ID has id=None.""" + with patch("esphome.components.display.CORE.data", {}): + add_metadata(ID(None), 320, 240) + with pytest.raises(AssertionError, match="resolved"): + get_display_metadata(ID("anything")) diff --git a/tests/component_tests/lvgl/test_validation.py b/tests/component_tests/lvgl/test_validation.py new file mode 100644 index 0000000000..9a767c0dae --- /dev/null +++ b/tests/component_tests/lvgl/test_validation.py @@ -0,0 +1,177 @@ +"""Tests for LVGL final_validation display metadata checks.""" + +from __future__ import annotations + +import pytest + +from esphome.components.const import BYTE_ORDER_BIG, BYTE_ORDER_LITTLE, CONF_BYTE_ORDER +from esphome.components.display import add_metadata +from esphome.components.lvgl import final_validation +from esphome.config import Config +from esphome.config_validation import Invalid +from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM +from esphome.core import CORE, ID +from esphome.final_validate import full_config + + +@pytest.fixture(autouse=True) +def _setup_core(): + """Ensure CORE.data has enough context for final_validation.""" + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "host", + KEY_TARGET_FRAMEWORK: "", + } + full_config.set(Config()) + yield + CORE.reset() + + +def _register_displays(*display_ids: str) -> None: + """Register display IDs in full_config so get_path_for_id works.""" + fc = full_config.get() + display_list = [{"id": ID(d, True)} for d in display_ids] + fc["display"] = display_list + for i, disp_id in enumerate(display_ids): + fc.declare_ids.append((ID(disp_id, True), ["display", i, "id"])) + + +def _make_lvgl_config( + display_ids: list[str], + byte_order: str | None = None, +) -> dict: + """Build a minimal LVGL config dict for final_validation.""" + _register_displays(*display_ids) + config = { + "displays": [ID(d, True) for d in display_ids], + "log_level": "WARN", + "color_depth": 16, + "transparency_key": 0x000400, + "draw_rounding": 2, + "buffer_size": 0, + } + if byte_order is not None: + config[CONF_BYTE_ORDER] = byte_order + return config + + +class TestByteOrderAutoConfig: + """Test that LVGL auto-configures byte_order from display metadata.""" + + def test_inherits_big_endian_from_display(self) -> None: + """LVGL should inherit big_endian from display metadata.""" + add_metadata(ID("my_disp"), 320, 240, byte_order=BYTE_ORDER_BIG) + configs = [_make_lvgl_config(["my_disp"])] + final_validation(configs) + assert configs[0][CONF_BYTE_ORDER] == BYTE_ORDER_BIG + + def test_inherits_little_endian_from_display(self) -> None: + """LVGL should inherit little_endian from display metadata.""" + add_metadata(ID("my_disp"), 320, 240, byte_order=BYTE_ORDER_LITTLE) + configs = [_make_lvgl_config(["my_disp"])] + final_validation(configs) + assert configs[0][CONF_BYTE_ORDER] == BYTE_ORDER_LITTLE + + def test_defaults_to_big_endian_when_no_metadata(self) -> None: + """LVGL should default to big_endian when display has no metadata.""" + configs = [_make_lvgl_config(["my_disp"])] + final_validation(configs) + assert configs[0][CONF_BYTE_ORDER] == BYTE_ORDER_BIG + + +class TestByteOrderExplicitMismatchError: + """Test that LVGL rejects explicit byte_order mismatch with display.""" + + def test_raises_on_mismatch(self) -> None: + """Explicit LVGL byte_order different from display should raise.""" + add_metadata(ID("my_disp"), 320, 240, byte_order=BYTE_ORDER_LITTLE) + configs = [_make_lvgl_config(["my_disp"], byte_order=BYTE_ORDER_BIG)] + with pytest.raises( + Invalid, match="LVGL byte order must match the display byte order" + ): + final_validation(configs) + + def test_no_error_when_matching(self) -> None: + """Explicit LVGL byte_order matching display should pass.""" + add_metadata(ID("my_disp"), 320, 240, byte_order=BYTE_ORDER_BIG) + configs = [_make_lvgl_config(["my_disp"], byte_order=BYTE_ORDER_BIG)] + final_validation(configs) + + +class TestByteOrderMultipleDisplays: + """Test byte_order validation with multiple displays.""" + + def test_consistent_displays_inherit(self) -> None: + """All displays with same byte_order should set LVGL byte_order.""" + add_metadata(ID("disp_a"), 320, 240, byte_order=BYTE_ORDER_LITTLE) + add_metadata(ID("disp_b"), 128, 64, byte_order=BYTE_ORDER_LITTLE) + configs = [_make_lvgl_config(["disp_a", "disp_b"])] + final_validation(configs) + assert configs[0][CONF_BYTE_ORDER] == BYTE_ORDER_LITTLE + + def test_inconsistent_displays_raises(self) -> None: + """Displays with different byte_order should raise an error.""" + add_metadata(ID("disp_a"), 320, 240, byte_order=BYTE_ORDER_BIG) + add_metadata(ID("disp_b"), 128, 64, byte_order=BYTE_ORDER_LITTLE) + configs = [_make_lvgl_config(["disp_a", "disp_b"])] + with pytest.raises(Invalid, match="same byte_order"): + final_validation(configs) + + +class TestHasWriterCheck: + """Test that LVGL rejects displays with has_writer set.""" + + def test_display_with_writer_raises(self) -> None: + """Display with lambda/pages/auto_clear should be rejected.""" + add_metadata(ID("my_disp"), 320, 240, has_writer=True) + configs = [_make_lvgl_config(["my_disp"])] + with pytest.raises(Invalid, match="not compatible with LVGL"): + final_validation(configs) + + def test_display_without_writer_passes(self) -> None: + """Display without writer should pass.""" + add_metadata(ID("my_disp"), 320, 240, has_writer=False) + configs = [_make_lvgl_config(["my_disp"])] + final_validation(configs) + + +class TestRotationCheck: + """Test that LVGL rejects displays with non-zero rotation.""" + + def test_display_with_rotation_raises(self) -> None: + """Display with rotation should be rejected.""" + add_metadata(ID("my_disp"), 320, 240, rotation=90) + configs = [_make_lvgl_config(["my_disp"])] + with pytest.raises(Invalid, match="rotation.*not compatible with LVGL"): + final_validation(configs) + + def test_display_without_rotation_passes(self) -> None: + """Display with rotation=0 should pass.""" + add_metadata(ID("my_disp"), 320, 240, rotation=0) + configs = [_make_lvgl_config(["my_disp"])] + final_validation(configs) + + +class TestDrawRoundingMerge: + """Test that display draw_rounding is merged into LVGL config.""" + + def test_display_draw_rounding_overrides_lower(self) -> None: + """Display draw_rounding higher than LVGL default should win.""" + add_metadata(ID("my_disp"), 320, 240, draw_rounding=8) + configs = [_make_lvgl_config(["my_disp"])] + final_validation(configs) + assert configs[0]["draw_rounding"] == 8 + + def test_display_draw_rounding_does_not_lower(self) -> None: + """Display draw_rounding lower than LVGL config should not reduce it.""" + add_metadata(ID("my_disp"), 320, 240, draw_rounding=1) + configs = [_make_lvgl_config(["my_disp"])] + configs[0]["draw_rounding"] = 4 + final_validation(configs) + assert configs[0]["draw_rounding"] == 4 + + def test_zero_draw_rounding_no_change(self) -> None: + """Display with draw_rounding=0 should not affect LVGL config.""" + add_metadata(ID("my_disp"), 320, 240, draw_rounding=0) + configs = [_make_lvgl_config(["my_disp"])] + final_validation(configs) + assert configs[0]["draw_rounding"] == 2 diff --git a/tests/component_tests/mipi_spi/test_display_metadata.py b/tests/component_tests/mipi_spi/test_display_metadata.py index c11c7816e4..e7f5143d91 100644 --- a/tests/component_tests/mipi_spi/test_display_metadata.py +++ b/tests/component_tests/mipi_spi/test_display_metadata.py @@ -3,22 +3,15 @@ from collections.abc import Callable from pathlib import Path -from esphome.components.display import ( - DisplayMetaData, - get_all_display_metadata, - get_display_metadata, -) +from esphome.components.const import BYTE_ORDER_BIG +from esphome.components.display import get_all_display_metadata, get_display_metadata from esphome.components.esp32 import ( KEY_BOARD, KEY_VARIANT, VARIANT_ESP32, VARIANT_ESP32S3, ) -from esphome.components.mipi_spi.display import ( - CONFIG_SCHEMA, - FINAL_VALIDATE_SCHEMA, - get_instance, -) +from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA from esphome.const import PlatformFramework from tests.component_tests.types import SetCoreConfigCallable @@ -38,38 +31,32 @@ def test_metadata_native_quad_default_test_card( PlatformFramework.ESP32_IDF, platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, ) - config = validated_config({"model": "JC3636W518"}) - get_instance(config) - meta = get_display_metadata(str(config["id"])) + config = CONFIG_SCHEMA({"model": "JC3636W518", "id": "jc3232w518"}) + meta = get_display_metadata(config["id"]) assert meta is not None assert meta.width == 360 assert meta.height == 360 - # final validation auto-enables show_test_card when no drawing methods are configured - assert meta.has_writer is True assert meta.has_hardware_rotation is True + assert meta.byte_order == BYTE_ORDER_BIG def test_metadata_single_mode_with_dc_pin( set_core_config: SetCoreConfigCallable, ) -> None: - """A single-mode display with no explicit drawing gets a test card from final validation.""" + """A single-mode display with no explicit drawing gets metadata from schema validation.""" set_core_config( PlatformFramework.ESP32_IDF, platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, ) - config = validated_config( - { - "model": "ST7735", - "dc_pin": 18, - } + config = CONFIG_SCHEMA( + {"model": "ST7735", "dc_pin": 18, "id": "single_mode_with_dc_pin"} ) - get_instance(config) - meta = get_display_metadata(str(config["id"])) + meta = get_display_metadata(config["id"]) assert meta is not None assert meta.width == 128 assert meta.height == 160 - assert meta.has_writer is True assert meta.has_hardware_rotation is True + assert meta.byte_order == BYTE_ORDER_BIG def test_metadata_custom_dimensions( @@ -80,47 +67,22 @@ def test_metadata_custom_dimensions( PlatformFramework.ESP32_IDF, platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, ) - config = validated_config( + config = CONFIG_SCHEMA( { "model": "custom", "dc_pin": 18, "dimensions": {"width": 480, "height": 320}, "init_sequence": [[0xA0, 0x01]], + "id": "custom_dimensions", } ) - get_instance(config) - meta = get_display_metadata(str(config["id"])) + meta = get_display_metadata(config["id"]) assert meta is not None assert meta.width == 480 assert meta.height == 320 - # final validation auto-enables show_test_card - assert meta.has_writer is True assert meta.has_hardware_rotation is True -def test_metadata_with_test_card_has_writer( - set_core_config: SetCoreConfigCallable, -) -> None: - """When show_test_card is enabled, has_writer should be True.""" - set_core_config( - PlatformFramework.ESP32_IDF, - platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, - ) - config = validated_config( - { - "model": "custom", - "dc_pin": 18, - "dimensions": {"width": 240, "height": 240}, - "init_sequence": [[0xA0, 0x01]], - "show_test_card": True, - } - ) - get_instance(config) - meta = get_display_metadata(str(config["id"])) - assert meta is not None - assert meta.has_writer is True - - def test_metadata_no_swap_xy_not_full_hardware_rotation( set_core_config: SetCoreConfigCallable, ) -> None: @@ -130,9 +92,8 @@ def test_metadata_no_swap_xy_not_full_hardware_rotation( platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, ) # JC3248W535 has swap_xy=cv.UNDEFINED -> transforms={mirror_x, mirror_y} only - config = validated_config({"model": "JC3248W535"}) - get_instance(config) - meta = get_display_metadata(str(config["id"])) + config = CONFIG_SCHEMA({"model": "JC3248W535", "id": "jc3248w535"}) + meta = get_display_metadata(config["id"]) assert meta is not None assert meta.has_hardware_rotation is False @@ -145,7 +106,7 @@ def test_metadata_multiple_displays_independent( PlatformFramework.ESP32_IDF, platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, ) - config_a = validated_config( + CONFIG_SCHEMA( { "id": "disp_a", "model": "custom", @@ -154,7 +115,7 @@ def test_metadata_multiple_displays_independent( "init_sequence": [[0xA0, 0x01]], } ) - config_b = validated_config( + CONFIG_SCHEMA( { "id": "disp_b", "model": "custom", @@ -163,13 +124,16 @@ def test_metadata_multiple_displays_independent( "init_sequence": [[0xA0, 0x01]], } ) - get_instance(config_a) - get_instance(config_b) all_meta = get_all_display_metadata() - # final validation auto-enables show_test_card for both - assert all_meta["disp_a"] == DisplayMetaData(320, 240, True, True) - assert all_meta["disp_b"] == DisplayMetaData(128, 64, True, True) + assert all_meta["disp_a"].width == 320 + assert all_meta["disp_a"].height == 240 + assert all_meta["disp_a"].has_hardware_rotation is True + assert all_meta["disp_a"].byte_order == BYTE_ORDER_BIG + assert all_meta["disp_b"].width == 128 + assert all_meta["disp_b"].height == 64 + assert all_meta["disp_b"].has_hardware_rotation is True + assert all_meta["disp_b"].byte_order == BYTE_ORDER_BIG def test_metadata_via_code_generation_native( @@ -179,12 +143,13 @@ def test_metadata_via_code_generation_native( """Full code generation for native.yaml should produce correct metadata.""" generate_main(component_fixture_path("native.yaml")) all_meta = get_all_display_metadata() - # native.yaml: model JC3636W518 -> 360x360, no writer, full hardware rotation + # native.yaml: model JC3636W518 -> 360x360, full hardware rotation assert len(all_meta) == 1 meta = next(iter(all_meta.values())) - assert meta == DisplayMetaData( - width=360, height=360, has_writer=True, has_hardware_rotation=True - ) + assert meta.width == 360 + assert meta.height == 360 + assert meta.has_hardware_rotation is True + assert meta.byte_order == BYTE_ORDER_BIG def test_metadata_via_code_generation_lvgl( @@ -194,9 +159,10 @@ def test_metadata_via_code_generation_lvgl( """Full code generation for lvgl.yaml should produce correct metadata.""" generate_main(component_fixture_path("lvgl.yaml")) all_meta = get_all_display_metadata() - # lvgl.yaml: model ST7735 -> 128x160, no writer (lvgl draws directly), full hw rotation + # lvgl.yaml: model ST7735 -> 128x160, full hw rotation assert len(all_meta) == 1 meta = next(iter(all_meta.values())) - assert meta == DisplayMetaData( - width=128, height=160, has_writer=False, has_hardware_rotation=True - ) + assert meta.width == 128 + assert meta.height == 160 + assert meta.has_hardware_rotation is True + assert meta.byte_order == BYTE_ORDER_BIG From e4980713d1a265613f3006b0ad29439f7a468cc2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Jun 2026 07:35:23 -0400 Subject: [PATCH 0288/1815] [core] esphome clean wipes the whole build directory (#16772) --- esphome/__main__.py | 7 +- esphome/build_gen/espidf.py | 6 - esphome/build_gen/platformio.py | 3 +- esphome/espidf/framework.py | 4 +- esphome/writer.py | 104 ++++++++++---- tests/unit_tests/build_gen/test_platformio.py | 32 +---- tests/unit_tests/test_writer.py | 128 ++++++++++-------- 7 files changed, 164 insertions(+), 120 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 47dd8d273c..7c4028da44 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -695,6 +695,11 @@ def _wrap_to_code(name, comp, yaml_util): def write_cpp(config: ConfigType) -> int: from esphome import writer + # Refresh the storage sidecar and clean an incompatible previous build + # before regenerating any sources. This may full-wipe the build dir, so it + # has to run before write_cpp_file writes src/. + writer.update_storage_json() + if not get_bool_env(ENV_NOGITIGNORE): writer.write_gitignore() @@ -1631,7 +1636,7 @@ def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None: from esphome import writer try: - writer.clean_build() + writer.clean_build(full=True) except OSError as err: _LOGGER.error("Error deleting build files: %s", err) return 1 diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 0b50f72382..9cc7a7ff12 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -7,7 +7,6 @@ from esphome.components.esp32 import get_esp32_variant, idf_version import esphome.config_validation as cv from esphome.core import CORE from esphome.helpers import mkdir_p, write_file_if_changed -from esphome.writer import update_storage_json def get_available_components() -> list[str] | None: @@ -213,11 +212,6 @@ target_link_options(${{COMPONENT_LIB}} PUBLIC def write_project(minimal: bool = False) -> None: """Write ESP-IDF project files.""" - # Refresh /storage/.yaml.json so the dashboard's - # /info and /downloads endpoints can locate the build (they 404 - # otherwise). This mirrors the PlatformIO build-gen path's call - # in build_gen/platformio.py:write_ini(). - update_storage_json() mkdir_p(CORE.build_path) mkdir_p(CORE.relative_src_path()) diff --git a/esphome/build_gen/platformio.py b/esphome/build_gen/platformio.py index 30dbb69d86..16c1597ccd 100644 --- a/esphome/build_gen/platformio.py +++ b/esphome/build_gen/platformio.py @@ -1,7 +1,7 @@ from esphome.const import __version__ from esphome.core import CORE from esphome.helpers import mkdir_p, read_file, write_file_if_changed -from esphome.writer import find_begin_end, update_storage_json +from esphome.writer import find_begin_end INI_AUTO_GENERATE_BEGIN = "; ========== AUTO GENERATED CODE BEGIN ===========" INI_AUTO_GENERATE_END = "; =========== AUTO GENERATED CODE END ============" @@ -58,7 +58,6 @@ def get_ini_content(): def write_ini(content): - update_storage_json() path = CORE.relative_build_path("platformio.ini") if path.is_file(): diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 6ef73a2199..2c520d0d2c 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -1005,7 +1005,9 @@ def _check_esphome_idf_framework_install( idf_tools_path = framework_path / "tools" / "idf_tools.py" _LOGGER.info("Checking ESP-IDF %s framework ...", version) # Logged every invocation (not just on install) so the user can verify the - # override. A changed URL needs ``esphome clean`` to force a re-download. + # override. A changed URL needs ``esphome clean-all`` to force a re-download + # (``esphome clean`` only wipes the build dir, not the extracted framework + # under /idf/frameworks/). if source_url: _LOGGER.info("Using framework source override: %s", source_url) diff --git a/esphome/writer.py b/esphome/writer.py index 84f2f8101a..b29b3c4b79 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -126,6 +126,13 @@ def storage_should_update_cmake_cache(old: StorageJSON, new: StorageJSON) -> boo def update_storage_json() -> None: + """Refresh the storage sidecar and clean an incompatible build. + + Runs at the start of ``write_cpp`` -- BEFORE any source/project files are + regenerated -- so the clean below can safely ``full``-wipe the whole build + directory (a switch of toolchain/framework/version also drops the stale + project scaffolding, not just the compiled objects). + """ path = storage_path() old = StorageJSON.load(path) new = StorageJSON.from_esphome_core(CORE, old) @@ -146,7 +153,7 @@ def update_storage_json() -> None: ) else: _LOGGER.info("Core config or version changed, cleaning build files...") - clean_build(clear_pio_cache=False) + clean_build(clear_pio_cache=False, full=True) elif storage_should_update_cmake_cache(old, new): _LOGGER.info("Integrations changed, cleaning cmake cache...") clean_cmake_cache() @@ -483,48 +490,89 @@ def write_cpp(code_s): def clean_cmake_cache(): - pioenvs = CORE.relative_pioenvs_path() - if pioenvs.is_dir(): - pioenvs_cmake_path = pioenvs / CORE.name / "CMakeCache.txt" - if pioenvs_cmake_path.is_file(): - _LOGGER.info("Deleting %s", pioenvs_cmake_path) - pioenvs_cmake_path.unlink() + # Drop the CMake cache so a component-set change forces a reconfigure. + # PlatformIO keeps it under .pioenvs//; the native ESP-IDF toolchain + # keeps it under build/ (where espidf's has_outdated_files() treats a + # missing CMakeCache.txt as stale). Only one exists for a given build. + cmake_cache_paths = ( + CORE.relative_pioenvs_path(CORE.name, "CMakeCache.txt"), + CORE.relative_build_path("build", "CMakeCache.txt"), + ) + for cmake_cache_path in cmake_cache_paths: + if cmake_cache_path.is_file(): + _LOGGER.info("Deleting %s", cmake_cache_path) + cmake_cache_path.unlink() -def clean_build(clear_pio_cache: bool = True): +def clean_build(clear_pio_cache: bool = True, *, full: bool = False): + """Remove build artifacts. + + By default only the compiled outputs are removed (``.pioenvs`` / + ``.piolibdeps`` / the native ESP-IDF ``build`` and ``managed_components`` + dirs) while the generated ``src/`` and project files are kept. This is what + in-build callers need: they regenerate a source/sdkconfig and then force a + rebuild without discarding the sources they just wrote. + + ``full=True`` wipes the entire build directory instead. Used by the + ``esphome clean`` command and by the pre-build clean in + ``update_storage_json`` (which runs before sources are regenerated) -- in + both cases nothing is mid-regeneration, so the next compile rebuilds from + scratch. It also drops stale project scaffolding the allow-list keeps (e.g. a + leftover platformio.ini / CMakeLists.txt from the other toolchain), making a + toolchain switch reliable. + """ # Allow skipping cache cleaning for integration tests if os.environ.get("ESPHOME_SKIP_CLEAN_BUILD"): _LOGGER.warning("Skipping build cleaning (ESPHOME_SKIP_CLEAN_BUILD set)") return - pioenvs = CORE.relative_pioenvs_path() - if pioenvs.is_dir(): - _LOGGER.info("Deleting %s", pioenvs) - rmtree(pioenvs) - piolibdeps = CORE.relative_piolibdeps_path() - if piolibdeps.is_dir(): - _LOGGER.info("Deleting %s", piolibdeps) - rmtree(piolibdeps) - dependencies_lock = CORE.relative_build_path("dependencies.lock") - if dependencies_lock.is_file(): - _LOGGER.info("Deleting %s", dependencies_lock) - dependencies_lock.unlink() + if full: + if CORE.build_path is not None: + build_path = Path(CORE.build_path) + if build_path.is_dir(): + _LOGGER.info("Deleting %s", build_path) + rmtree(build_path) + else: + pioenvs = CORE.relative_pioenvs_path() + if pioenvs.is_dir(): + _LOGGER.info("Deleting %s", pioenvs) + rmtree(pioenvs) + piolibdeps = CORE.relative_piolibdeps_path() + if piolibdeps.is_dir(): + _LOGGER.info("Deleting %s", piolibdeps) + rmtree(piolibdeps) + dependencies_lock = CORE.relative_build_path("dependencies.lock") + if dependencies_lock.is_file(): + _LOGGER.info("Deleting %s", dependencies_lock) + dependencies_lock.unlink() + # Native ESP-IDF toolchain artifacts: the IDF CMake/ninja build dir + # and the Component Manager's fetched managed components live under + # the project's build path, not under .pioenvs / .piolibdeps. + for name in ("build", "managed_components"): + idf_path = CORE.relative_build_path(name) + if idf_path.is_dir(): + _LOGGER.info("Deleting %s", idf_path) + rmtree(idf_path) + + # The idedata cache is derived from the build but lives under the data dir, + # not the build path, so it must be removed separately in both modes. idedata_cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json") if idedata_cache.is_file(): _LOGGER.info("Deleting %s", idedata_cache) idedata_cache.unlink() - # Native ESP-IDF toolchain artifacts: the IDF CMake/ninja build dir - # and the Component Manager's fetched managed components live under - # the project's build path, not under .pioenvs / .piolibdeps. - for name in ("build", "managed_components"): - idf_path = CORE.relative_build_path(name) - if idf_path.is_dir(): - _LOGGER.info("Deleting %s", idf_path) - rmtree(idf_path) if not clear_pio_cache: return + # The native ESP-IDF toolchain caches PlatformIO libraries converted to IDF + # components under /pio_components, shared across builds and keyed + # by source hash (the analog of PlatformIO's global package cache). Drop it + # on an explicit clean so a corrupt/stale converted lib is re-fetched. + pio_components = CORE.relative_internal_path("pio_components") + if pio_components.is_dir(): + _LOGGER.info("Deleting %s", pio_components) + rmtree(pio_components) + # Clean PlatformIO cache to resolve CMake compiler detection issues # This helps when toolchain paths change or get corrupted try: diff --git a/tests/unit_tests/build_gen/test_platformio.py b/tests/unit_tests/build_gen/test_platformio.py index a124dbc128..da0010afa3 100644 --- a/tests/unit_tests/build_gen/test_platformio.py +++ b/tests/unit_tests/build_gen/test_platformio.py @@ -12,13 +12,6 @@ from esphome.build_gen import platformio from esphome.core import CORE -@pytest.fixture -def mock_update_storage_json() -> Generator[MagicMock]: - """Mock update_storage_json for all tests.""" - with patch("esphome.build_gen.platformio.update_storage_json") as mock: - yield mock - - @pytest.fixture def mock_write_file_if_changed() -> Generator[MagicMock]: """Mock write_file_if_changed for tests.""" @@ -26,9 +19,7 @@ def mock_write_file_if_changed() -> Generator[MagicMock]: yield mock -def test_write_ini_creates_new_file( - tmp_path: Path, mock_update_storage_json: MagicMock -) -> None: +def test_write_ini_creates_new_file(tmp_path: Path) -> None: """Test write_ini creates a new platformio.ini file.""" CORE.build_path = str(tmp_path) @@ -50,9 +41,7 @@ framework = arduino assert platformio.INI_AUTO_GENERATE_END in file_content -def test_write_ini_updates_existing_file( - tmp_path: Path, mock_update_storage_json: MagicMock -) -> None: +def test_write_ini_updates_existing_file(tmp_path: Path) -> None: """Test write_ini updates existing platformio.ini file.""" CORE.build_path = str(tmp_path) @@ -97,9 +86,7 @@ framework = arduino assert "platform = old" not in file_content -def test_write_ini_preserves_custom_sections( - tmp_path: Path, mock_update_storage_json: MagicMock -) -> None: +def test_write_ini_preserves_custom_sections(tmp_path: Path) -> None: """Test write_ini preserves custom sections outside auto-generate markers.""" CORE.build_path = str(tmp_path) @@ -148,7 +135,6 @@ monitor_speed = 115200 def test_write_ini_no_change_when_content_same( tmp_path: Path, - mock_update_storage_json: MagicMock, mock_write_file_if_changed: MagicMock, ) -> None: """Test write_ini doesn't rewrite file when content is unchanged.""" @@ -174,15 +160,3 @@ def test_write_ini_no_change_when_content_same( call_args = mock_write_file_if_changed.call_args[0] assert call_args[0] == ini_file assert content in call_args[1] - - -def test_write_ini_calls_update_storage_json( - tmp_path: Path, mock_update_storage_json: MagicMock -) -> None: - """Test write_ini calls update_storage_json.""" - CORE.build_path = str(tmp_path) - - content = "[env:test]\nplatform = esp32" - - platformio.write_ini(content) - mock_update_storage_json.assert_called_once() diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 6f137fb351..1487517ca2 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -341,8 +341,8 @@ def test_update_storage_json_logging_when_old_is_none( with caplog.at_level("INFO"): update_storage_json() - # Verify clean_build was called - mock_clean_build.assert_called_once() + # Verify clean_build was called with a full wipe (runs before src is written) + mock_clean_build.assert_called_once_with(clear_pio_cache=False, full=True) # Verify the correct log message was used (not the component removal message) assert "Core config or version changed, cleaning build files..." in caplog.text @@ -392,60 +392,50 @@ def test_update_storage_json_logging_components_removed( new_storage.save.assert_called_once_with("/test/path") +def _mock_cmake_cache_paths(mock_core: MagicMock, tmp_path: Path) -> None: + """Wire relative_pioenvs_path/relative_build_path to tmp_path subtrees.""" + mock_core.name = "test_device" + mock_core.relative_pioenvs_path.side_effect = (tmp_path / ".pioenvs").joinpath + mock_core.relative_build_path.side_effect = tmp_path.joinpath + + @patch("esphome.writer.CORE") -def test_clean_cmake_cache( +def test_clean_cmake_cache_platformio( mock_core: MagicMock, tmp_path: Path, caplog: pytest.LogCaptureFixture, ) -> None: - """Test clean_cmake_cache removes CMakeCache.txt file.""" - # Create directory structure - pioenvs_dir = tmp_path / ".pioenvs" - pioenvs_dir.mkdir() - device_dir = pioenvs_dir / "test_device" - device_dir.mkdir() - cmake_cache_file = device_dir / "CMakeCache.txt" + """Test clean_cmake_cache removes the PlatformIO CMakeCache.txt.""" + _mock_cmake_cache_paths(mock_core, tmp_path) + cmake_cache_file = tmp_path / ".pioenvs" / "test_device" / "CMakeCache.txt" + cmake_cache_file.parent.mkdir(parents=True) cmake_cache_file.write_text("# CMake cache file") - # Setup mocks - mock_core.relative_pioenvs_path.return_value = pioenvs_dir - mock_core.name = "test_device" - - # Verify file exists before - assert cmake_cache_file.exists() - - # Call the function with caplog.at_level("INFO"): clean_cmake_cache() - # Verify file was removed assert not cmake_cache_file.exists() - - # Verify logging assert "Deleting" in caplog.text assert "CMakeCache.txt" in caplog.text @patch("esphome.writer.CORE") -def test_clean_cmake_cache_no_pioenvs_dir( +def test_clean_cmake_cache_esp_idf( mock_core: MagicMock, tmp_path: Path, + caplog: pytest.LogCaptureFixture, ) -> None: - """Test clean_cmake_cache when pioenvs directory doesn't exist.""" - # Setup non-existent directory path - pioenvs_dir = tmp_path / ".pioenvs" + """Test clean_cmake_cache removes the native ESP-IDF build/CMakeCache.txt.""" + _mock_cmake_cache_paths(mock_core, tmp_path) + cmake_cache_file = tmp_path / "build" / "CMakeCache.txt" + cmake_cache_file.parent.mkdir(parents=True) + cmake_cache_file.write_text("# CMake cache file") - # Setup mocks - mock_core.relative_pioenvs_path.return_value = pioenvs_dir + with caplog.at_level("INFO"): + clean_cmake_cache() - # Verify directory doesn't exist - assert not pioenvs_dir.exists() - - # Call the function - should not crash - clean_cmake_cache() - - # Verify directory still doesn't exist - assert not pioenvs_dir.exists() + assert not cmake_cache_file.exists() + assert str(cmake_cache_file) in caplog.text @patch("esphome.writer.CORE") @@ -453,27 +443,11 @@ def test_clean_cmake_cache_no_cmake_file( mock_core: MagicMock, tmp_path: Path, ) -> None: - """Test clean_cmake_cache when CMakeCache.txt doesn't exist.""" - # Create directory structure without CMakeCache.txt - pioenvs_dir = tmp_path / ".pioenvs" - pioenvs_dir.mkdir() - device_dir = pioenvs_dir / "test_device" - device_dir.mkdir() - cmake_cache_file = device_dir / "CMakeCache.txt" + """Test clean_cmake_cache when no CMakeCache.txt exists -- should not crash.""" + _mock_cmake_cache_paths(mock_core, tmp_path) - # Setup mocks - mock_core.relative_pioenvs_path.return_value = pioenvs_dir - mock_core.name = "test_device" - - # Verify file doesn't exist - assert not cmake_cache_file.exists() - - # Call the function - should not crash clean_cmake_cache() - # Verify file still doesn't exist - assert not cmake_cache_file.exists() - @patch("esphome.writer.CORE") def test_clean_build( @@ -507,6 +481,11 @@ def test_clean_build( managed_components_dir.mkdir() (managed_components_dir / "espressif__arduino-esp32").mkdir() + # Converted-PIO-library cache (native ESP-IDF), under the data dir. + pio_components_dir = tmp_path / "pio_components" + pio_components_dir.mkdir() + (pio_components_dir / "abc12345").mkdir() + # Create PlatformIO cache directory platformio_cache_dir = tmp_path / ".platformio" / ".cache" platformio_cache_dir.mkdir(parents=True) @@ -529,6 +508,7 @@ def test_clean_build( assert idedata_cache.exists() assert idf_build_dir.exists() assert managed_components_dir.exists() + assert pio_components_dir.exists() assert platformio_cache_dir.exists() # Mock PlatformIO's ProjectConfig cache_dir @@ -554,6 +534,7 @@ def test_clean_build( assert not idedata_cache.exists() assert not idf_build_dir.exists() assert not managed_components_dir.exists() + assert not pio_components_dir.exists() assert not platformio_cache_dir.exists() # Verify logging @@ -567,6 +548,41 @@ def test_clean_build( assert "PlatformIO cache" in caplog.text +@patch("esphome.writer.CORE") +def test_clean_build_full_wipes_build_dir( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """full=True wipes the whole build dir (incl. src/) but keeps siblings.""" + build_dir = tmp_path / "build" / "test" + (build_dir / "src").mkdir(parents=True) + (build_dir / "src" / "main.cpp").write_text("// generated") + (build_dir / "platformio.ini").write_text("[platformio]") + (build_dir / ".pioenvs").mkdir() + + idedata_cache = tmp_path / "idedata" / "test.json" + idedata_cache.parent.mkdir() + idedata_cache.write_text("{}") + + # A sibling of the build dir (under the data dir) must survive. + survivor = tmp_path / "keep_me.txt" + survivor.write_text("keep") + + # build_path may be a str (e.g. set from config); clean_build must coerce. + mock_core.build_path = str(build_dir) + mock_core.name = "test" + mock_core.relative_internal_path.side_effect = tmp_path.joinpath + + with caplog.at_level("INFO"): + clean_build(clear_pio_cache=False, full=True) + + assert not build_dir.exists() + assert not idedata_cache.exists() + assert survivor.exists() + assert str(build_dir) in caplog.text + + @patch("esphome.writer.CORE") def test_clean_build_partial_exists( mock_core: MagicMock, @@ -586,6 +602,7 @@ def test_clean_build_partial_exists( mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir mock_core.relative_build_path.side_effect = lambda name: tmp_path / name + mock_core.relative_internal_path.side_effect = tmp_path.joinpath # Verify only pioenvs exists assert pioenvs_dir.exists() @@ -623,6 +640,7 @@ def test_clean_build_nothing_exists( mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir mock_core.relative_build_path.side_effect = lambda name: tmp_path / name + mock_core.relative_internal_path.side_effect = tmp_path.joinpath # Verify nothing exists assert not pioenvs_dir.exists() @@ -659,6 +677,7 @@ def test_clean_build_platformio_not_available( mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir mock_core.relative_build_path.side_effect = lambda name: tmp_path / name + mock_core.relative_internal_path.side_effect = tmp_path.joinpath # Verify all exist before assert pioenvs_dir.exists() @@ -697,6 +716,7 @@ def test_clean_build_empty_cache_dir( mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps" mock_core.relative_build_path.side_effect = lambda name: tmp_path / name + mock_core.relative_internal_path.side_effect = tmp_path.joinpath # Verify pioenvs exists before assert pioenvs_dir.exists() @@ -1425,6 +1445,7 @@ def test_clean_build_handles_readonly_files( mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps" mock_core.relative_build_path.side_effect = lambda name: tmp_path / name + mock_core.relative_internal_path.side_effect = tmp_path.joinpath # Verify file is read-only assert not os.access(readonly_file, os.W_OK) @@ -1489,6 +1510,7 @@ def test_clean_build_reraises_for_other_errors( mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps" mock_core.relative_build_path.side_effect = lambda name: tmp_path / name + mock_core.relative_internal_path.side_effect = tmp_path.joinpath try: # Mock os.access in writer module to return True (writable) From 89ddd34cb9cd7e81e38d9dc2b305e6e5bc887b0d Mon Sep 17 00:00:00 2001 From: PolarGoose <35307286+PolarGoose@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:55:51 +0200 Subject: [PATCH 0289/1815] [dmsr] [breaking] Fix decryption that uses custom auth key. Add CRC to telegram sensor. Automatic hex string detection in equipment_id fields. Support EON Hungary smart meters (#16561) --- .clang-tidy.hash | 2 +- esphome/components/dsmr/__init__.py | 2 +- esphome/components/dsmr/dsmr.cpp | 7 ++++--- esphome/components/dsmr/dsmr.h | 11 ++++++++--- esphome/components/dsmr/sensor.py | 8 ++++---- esphome/components/dsmr/text_sensor.py | 1 + platformio.ini | 2 +- 7 files changed, 20 insertions(+), 13 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 52d75d1601..29c8b414f6 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -27aaab4e0ebfc10491720345aa746fc2dffa6a3985f73ec111b12dd99078d46f +a30d2e50f2cac76e9c504eb7e5b250070dc92df23469c44a7eb8e52e26fd375d diff --git a/esphome/components/dsmr/__init__.py b/esphome/components/dsmr/__init__.py index 31ec1ce5b5..05f9a78156 100644 --- a/esphome/components/dsmr/__init__.py +++ b/esphome/components/dsmr/__init__.py @@ -87,7 +87,7 @@ async def to_code(config): cg.add_build_flag("-DDSMR_WATER_MBUS_ID=" + str(config[CONF_WATER_MBUS_ID])) cg.add_build_flag("-DDSMR_THERMAL_MBUS_ID=" + str(config[CONF_THERMAL_MBUS_ID])) - cg.add_library("esphome/dsmr_parser", "1.4.0") + cg.add_library("esphome/dsmr_parser", "1.8.0") def final_validate(config: ConfigType) -> ConfigType: diff --git a/esphome/components/dsmr/dsmr.cpp b/esphome/components/dsmr/dsmr.cpp index 2fa51f73af..9580464a2e 100644 --- a/esphome/components/dsmr/dsmr.cpp +++ b/esphome/components/dsmr/dsmr.cpp @@ -153,8 +153,9 @@ void Dsmr::receive_encrypted_telegram_() { bool Dsmr::parse_telegram_(const dsmr_parser::DsmrUnencryptedTelegram &telegram) { this->stop_requesting_data_(); - ESP_LOGV(TAG, "Trying to parse telegram (%zu bytes)", telegram.content().size()); - ESP_LOGVV(TAG, "Telegram content:\n %.*s", static_cast(telegram.content().size()), telegram.content().data()); + ESP_LOGV(TAG, "Trying to parse telegram (%zu bytes)", telegram.full_content().size()); + ESP_LOGVV(TAG, "Telegram content:\n %.*s", static_cast(telegram.full_content().size()), + telegram.full_content().data()); MyData data; if (const bool res = dsmr_parser::DsmrParser::parse(data, telegram); !res) { @@ -167,7 +168,7 @@ bool Dsmr::parse_telegram_(const dsmr_parser::DsmrUnencryptedTelegram &telegram) // Publish the telegram, after publishing the sensors so it can also trigger action based on latest values if (this->s_telegram_ != nullptr) { - this->s_telegram_->publish_state(telegram.content().data(), telegram.content().size()); + this->s_telegram_->publish_state(telegram.full_content().data(), telegram.full_content().size()); } return true; } diff --git a/esphome/components/dsmr/dsmr.h b/esphome/components/dsmr/dsmr.h index e55db9f976..3642309c26 100644 --- a/esphome/components/dsmr/dsmr.h +++ b/esphome/components/dsmr/dsmr.h @@ -74,7 +74,8 @@ class Dsmr : public Component, public uart::UARTDevice { receive_timeout_(receive_timeout), request_pin_(request_pin), buffer_(max_telegram_length), - packet_accumulator_(buffer_, crc_check) { + packet_accumulator_(buffer_, crc_check), + dlms_decryptor_(gcm_decryptor_, crc_check) { this->set_decryption_key_(decryption_key); } @@ -97,7 +98,11 @@ class Dsmr : public Component, public uart::UARTDevice { // Remove before 2026.8.0 ESPDEPRECATED("Use 'decryption_key' configuration parameter. This method will be removed in 2026.8.0", "2026.2.0") - void set_decryption_key(const std::string &decryption_key) { this->set_decryption_key_(decryption_key.c_str()); } + void set_decryption_key(const std::string &decryption_key) { + // Some YAML configs pass a string longer than 32 symbols. We only need the first 32 symbols, + // otherwise `Aes128GcmDecryptionKey::from_hex` will fail. + this->set_decryption_key_(std::string(decryption_key, 0, 32).c_str()); + } // Sensor setters #define DSMR_SET_SENSOR(s) \ @@ -143,7 +148,7 @@ class Dsmr : public Component, public uart::UARTDevice { std::vector buffer_; dsmr_parser::PacketAccumulator packet_accumulator_; Aes128GcmDecryptorImpl gcm_decryptor_; - dsmr_parser::DlmsPacketDecryptor dlms_decryptor_{gcm_decryptor_}; + dsmr_parser::DlmsPacketDecryptor dlms_decryptor_; std::array uart_chunk_reading_buf_; }; } // namespace esphome::dsmr diff --git a/esphome/components/dsmr/sensor.py b/esphome/components/dsmr/sensor.py index 292e5a1156..7d93ee62e1 100644 --- a/esphome/components/dsmr/sensor.py +++ b/esphome/components/dsmr/sensor.py @@ -248,10 +248,6 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, ), - cv.Optional("electricity_switch_position"): sensor.sensor_schema( - accuracy_decimals=3, - state_class=STATE_CLASS_MEASUREMENT, - ), cv.Optional("electricity_failures"): sensor.sensor_schema( accuracy_decimals=0, state_class=STATE_CLASS_MEASUREMENT, @@ -808,6 +804,10 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_DURATION, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional("electricity_switch_position"): cv.invalid( + "'electricity_switch_position' has moved to the 'text_sensor' platform." + "Move it under 'text_sensor' to fix." + ), } ).extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/dsmr/text_sensor.py b/esphome/components/dsmr/text_sensor.py index a8f29c7ca8..54b5711923 100644 --- a/esphome/components/dsmr/text_sensor.py +++ b/esphome/components/dsmr/text_sensor.py @@ -14,6 +14,7 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional("p1_version"): text_sensor.text_sensor_schema(), cv.Optional("p1_version_be"): text_sensor.text_sensor_schema(), cv.Optional("timestamp"): text_sensor.text_sensor_schema(), + cv.Optional("electricity_switch_position"): text_sensor.text_sensor_schema(), cv.Optional("electricity_tariff"): text_sensor.text_sensor_schema(), cv.Optional("electricity_tariff_il"): text_sensor.text_sensor_schema(), cv.Optional("electricity_failure_log"): text_sensor.text_sensor_schema(), diff --git a/platformio.ini b/platformio.ini index 8a89f96b39..4ac60d8099 100644 --- a/platformio.ini +++ b/platformio.ini @@ -37,7 +37,7 @@ lib_deps_base = wjtje/qr-code-generator-library@1.7.0 ; qr_code functionpointer/arduino-MLX90393@1.0.2 ; mlx90393 pavlodn/HaierProtocol@0.9.31 ; haier - esphome/dsmr_parser@1.4.0 ; dsmr + esphome/dsmr_parser@1.8.0 ; dsmr https://github.com/esphome/TinyGPSPlus.git#v1.1.0 ; gps ; This is using the repository until a new release is published to PlatformIO https://github.com/Sensirion/arduino-gas-index-algorithm.git#3.2.1 ; Sensirion Gas Index Algorithm Arduino Library From 2009f6cc5f80bef33913383b8786cdbc133c58c1 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 4 Jun 2026 01:43:48 +1000 Subject: [PATCH 0290/1815] [lvgl] Fix indicator updates (#16780) --- esphome/components/lvgl/widgets/__init__.py | 1 + esphome/components/lvgl/widgets/meter.py | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index 400f7c709b..4d62c3de05 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -290,6 +290,7 @@ class Widget: # Properties for linear equations self.slope = None self.y_int = None + self.parent = None @staticmethod def create(name, var, wtype: WidgetType, config: dict = None): diff --git a/esphome/components/lvgl/widgets/meter.py b/esphome/components/lvgl/widgets/meter.py index e2407fad5a..166e88f382 100644 --- a/esphome/components/lvgl/widgets/meter.py +++ b/esphome/components/lvgl/widgets/meter.py @@ -430,7 +430,8 @@ class MeterType(WidgetType): tvar, LV_PART.MAIN, await arc_style.get_var() ) lw = Widget.create(iid, tvar, arc_indicator_type) - await set_indicator_values(lw, v) + lw.parent = scale_var + await set_indicator_values(scale_var, lw, v) if t == CONF_TICK_STYLE: # No object created for this @@ -482,7 +483,8 @@ class MeterType(WidgetType): if option in v: props["line_" + option] = v[option] lw = await widget_to_code(props, line_indicator_type, scale_var) - await set_indicator_values(lw, v) + lw.parent = scale_var + await set_indicator_values(scale_var, lw, v) if t == CONF_IMAGE: add_lv_use(CONF_IMAGE) @@ -501,7 +503,8 @@ class MeterType(WidgetType): } iw = await widget_to_code(props, image_indicator_type, scale_var) await iw.set_property(CONF_SRC, await lv_image.process(src)) - await set_indicator_values(iw, v) + iw.parent = scale_var + await set_indicator_values(scale_var, iw, v) # Hide the scale line lv.obj_set_style_arc_opa(scale_var, LV_OPA.TRANSP, LV_PART.MAIN) @@ -607,27 +610,27 @@ async def indicator_update_to_code(config, action_id, template_arg, args): widget = await get_widgets(config) async def set_value(w: Widget): - await set_indicator_values(w, config) + await set_indicator_values(w.parent, w, config) return await action_to_code( widget, set_value, action_id, template_arg, args, config ) -async def set_indicator_values(indicator: Widget, config): +async def set_indicator_values(scale: MockObj, indicator: Widget, config): """Update scale section values (replaces meter indicator values)""" start_value = await get_start_value(config) end_value = await get_end_value(config) if indicator.type is arc_indicator_type: # For scale sections, we update the range if start_value is not None and end_value is not None: - lv.scale_section_set_range(indicator.obj, start_value, end_value) + lv.scale_set_section_range(scale, indicator.obj, start_value, end_value) elif start_value is not None: # If only start value, use it as both start and end (single point) - lv.scale_section_set_range(indicator.obj, start_value, start_value) + lv.scale_set_section_range(scale, indicator.obj, start_value, start_value) elif end_value is not None: # If only end value, assume range from 0 to end_value - lv.scale_section_set_range(indicator.obj, 0, end_value) + lv.scale_set_section_range(scale, indicator.obj, 0, end_value) return if start_value is None: From 712ef2ec0eba901ecf2fecec48df36f27f3796b8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:45:52 -0400 Subject: [PATCH 0291/1815] Bump esptool from 5.2.0 to 5.3.0 (#16774) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 17b618dde7..85d9857e7d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ tzlocal==5.3.1 # from time tzdata>=2026.2 # from time pyserial==3.5 platformio==6.1.19 -esptool==5.2.0 +esptool==5.3.0 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==45.3.1 From eba70dc193d61982a94920b80de64f0d76f5d777 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:46:07 -0400 Subject: [PATCH 0292/1815] Bump github/codeql-action from 4.36.0 to 4.36.1 (#16775) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index dfc0e08bfa..122bb30b5e 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 with: category: "/language:${{matrix.language}}" From 87735d71a043293e3cdd09224d6bcbfa7e2ee3c9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:46:22 -0400 Subject: [PATCH 0293/1815] Bump actions/checkout from 6.0.2 to 6.0.3 (#16776) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/auto-label-pr.yml | 2 +- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci-clang-tidy-hash.yml | 2 +- .github/workflows/ci-docker.yml | 2 +- .github/workflows/ci-github-scripts.yml | 2 +- .../workflows/ci-memory-impact-comment.yml | 2 +- .github/workflows/ci.yml | 40 +++++++++---------- .../codeowner-approved-label-update.yml | 2 +- .../workflows/codeowner-review-request.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/pr-title-check.yml | 2 +- .github/workflows/release.yml | 8 ++-- .github/workflows/sync-device-classes.yml | 4 +- 13 files changed, 36 insertions(+), 36 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 6c80d36d20..e48d6f69bd 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -24,7 +24,7 @@ jobs: if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot') steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Generate a token id: generate-token diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 675bbe9d2c..2a5b701248 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml index d9148fb06d..73c437467b 100644 --- a/.github/workflows/ci-clang-tidy-hash.yml +++ b/.github/workflows/ci-clang-tidy-hash.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 89fbec5420..2a40675f3b 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -42,7 +42,7 @@ jobs: - "docker" # - "lint" steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: diff --git a/.github/workflows/ci-github-scripts.yml b/.github/workflows/ci-github-scripts.yml index 6713fcc454..43d530128c 100644 --- a/.github/workflows/ci-github-scripts.yml +++ b/.github/workflows/ci-github-scripts.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Run tests working-directory: .github/scripts/auto-label-pr diff --git a/.github/workflows/ci-memory-impact-comment.yml b/.github/workflows/ci-memory-impact-comment.yml index 025b960985..35cfce65f8 100644 --- a/.github/workflows/ci-memory-impact-comment.yml +++ b/.github/workflows/ci-memory-impact-comment.yml @@ -49,7 +49,7 @@ jobs: - name: Check out code from base repository if: steps.pr.outputs.skip != 'true' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: # Always check out from the base repository (esphome/esphome), never from forks # Use the PR's target branch to ensure we run trusted code from the main repo diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63efff1b3a..d3fc19ca41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: cache-key: ${{ steps.cache-key.outputs.key }} steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Generate cache-key id: cache-key run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT @@ -74,7 +74,7 @@ jobs: if: needs.determine-jobs.outputs.python-linters == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -97,7 +97,7 @@ jobs: if: needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -123,7 +123,7 @@ jobs: if: needs.determine-jobs.outputs.import-time == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -151,11 +151,11 @@ jobs: if: needs.determine-jobs.outputs.device-builder == 'true' steps: - name: Check out esphome (this PR) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: path: esphome - name: Check out esphome/device-builder - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: esphome/device-builder ref: main @@ -221,7 +221,7 @@ jobs: if: needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python id: restore-python uses: ./.github/actions/restore-python @@ -281,7 +281,7 @@ jobs: benchmarks: ${{ steps.determine.outputs.benchmarks }} steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: # Fetch enough history to find the merge base fetch-depth: 2 @@ -353,7 +353,7 @@ jobs: bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }} steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python 3.13 id: python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 @@ -405,7 +405,7 @@ jobs: if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]') steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python uses: ./.github/actions/restore-python @@ -434,7 +434,7 @@ jobs: (github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true') steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python uses: ./.github/actions/restore-python @@ -490,7 +490,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -575,7 +575,7 @@ jobs: GH_TOKEN: ${{ github.token }} steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -670,7 +670,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -764,7 +764,7 @@ jobs: version: 1.0 - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -889,7 +889,7 @@ jobs: TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.native-idf-components }} steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python uses: ./.github/actions/restore-python @@ -971,7 +971,7 @@ jobs: if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -997,7 +997,7 @@ jobs: skip: ${{ steps.check-script.outputs.skip || steps.check-tests.outputs.skip }} steps: - name: Check out target branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.base_ref }} @@ -1179,7 +1179,7 @@ jobs: flash_usage: ${{ steps.extract.outputs.flash_usage }} steps: - name: Check out PR branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -1248,7 +1248,7 @@ jobs: GH_TOKEN: ${{ github.token }} steps: - name: Check out code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python uses: ./.github/actions/restore-python with: diff --git a/.github/workflows/codeowner-approved-label-update.yml b/.github/workflows/codeowner-approved-label-update.yml index 013517bde6..1bd60fd11d 100644 --- a/.github/workflows/codeowner-approved-label-update.yml +++ b/.github/workflows/codeowner-approved-label-update.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout base branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.event.pull_request.base.sha }} sparse-checkout: | diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml index 7cdbfcf328..5ad0b02de1 100644 --- a/.github/workflows/codeowner-review-request.yml +++ b/.github/workflows/codeowner-review-request.yml @@ -29,7 +29,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout base branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.event.pull_request.base.sha }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 122bb30b5e..c71d7204de 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -52,7 +52,7 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index e8320672d2..0e2efb1bcf 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -16,7 +16,7 @@ jobs: name: Validate PR title runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 344bd416c6..8efc395951 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: branch_build: ${{ steps.tag.outputs.branch_build }} deploy_env: ${{ steps.tag.outputs.deploy_env }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Get tag id: tag # yamllint disable rule:line-length @@ -60,7 +60,7 @@ jobs: contents: read # actions/checkout to build the sdist/wheel id-token: write # OIDC token for PyPI Trusted Publishing (pypa/gh-action-pypi-publish) steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -92,7 +92,7 @@ jobs: os: "ubuntu-24.04-arm" steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -168,7 +168,7 @@ jobs: - ghcr - dockerhub steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Download digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 84be3c8e22..8796ddf7f0 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -28,10 +28,10 @@ jobs: permission-pull-requests: write # pulls.create / pulls.update to open or refresh the sync PR - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Checkout Home Assistant - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: home-assistant/core path: lib/home-assistant From 3b0f669f4782117661a9603137615f1fae99d128 Mon Sep 17 00:00:00 2001 From: Leonardo Rivera Date: Wed, 3 Jun 2026 14:38:00 -0300 Subject: [PATCH 0294/1815] [gree] Fix HEAT_COOL advertised when supports_heat is false; restrict YAN swing to vertical (#16199) --- esphome/components/gree/gree.cpp | 16 ++++++++++++++++ esphome/components/gree/gree.h | 1 + 2 files changed, 17 insertions(+) diff --git a/esphome/components/gree/gree.cpp b/esphome/components/gree/gree.cpp index 705c741dd0..a794e7721f 100644 --- a/esphome/components/gree/gree.cpp +++ b/esphome/components/gree/gree.cpp @@ -5,7 +5,23 @@ namespace esphome::gree { static const char *const TAG = "gree.climate"; +climate::ClimateTraits GreeClimate::traits() { + auto t = climate_ir::ClimateIR::traits(); + // ClimateIR unconditionally includes HEAT_COOL in the base mode set; remove it when heat is not supported. + if (!this->supports_heat_) { + auto modes = t.get_supported_modes(); + modes.erase(climate::CLIMATE_MODE_HEAT_COOL); + t.set_supported_modes(modes); + } + return t; +} + void GreeClimate::set_model(Model model) { + if (model == GREE_YAN) { + // YAN only has a vertical vane; the horizontal swing IR bytes are not defined for this model. + this->swing_modes_.erase(climate::CLIMATE_SWING_HORIZONTAL); + this->swing_modes_.erase(climate::CLIMATE_SWING_BOTH); + } if (model == GREE_YX1FF) { this->fan_modes_.insert(climate::CLIMATE_FAN_QUIET); // YX1FF 4 speed this->presets_.insert(climate::CLIMATE_PRESET_NONE); // YX1FF sleep mode diff --git a/esphome/components/gree/gree.h b/esphome/components/gree/gree.h index 24453750ae..1eb812ae46 100644 --- a/esphome/components/gree/gree.h +++ b/esphome/components/gree/gree.h @@ -94,6 +94,7 @@ class GreeClimate : public climate_ir::ClimateIR { protected: // Transmit via IR the state of this climate controller. void transmit_state() override; + climate::ClimateTraits traits() override; uint8_t operation_mode_(); uint8_t fan_speed_(); From 7b8cbe2de19d4d530af6103fda140bbb4575142f Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 4 Jun 2026 04:18:16 +1000 Subject: [PATCH 0295/1815] [sdl] Add option to choose display screen (#16363) --- esphome/components/sdl/display.py | 36 ++++++++++++++++++++++++---- esphome/components/sdl/sdl_esphome.h | 6 ++--- tests/components/sdl/common.yaml | 22 +++++++++++++++++ 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/esphome/components/sdl/display.py b/esphome/components/sdl/display.py index 78c180aa65..57266f33e2 100644 --- a/esphome/components/sdl/display.py +++ b/esphome/components/sdl/display.py @@ -20,6 +20,7 @@ Sdl = sdl_ns.class_("Sdl", display.Display, cg.Component) sdl_window_flags = cg.global_ns.enum("SDL_WindowFlags") +CONF_CENTERED_ON_DISPLAY = "centered_on_display" CONF_SDL_OPTIONS = "sdl_options" CONF_SDL_ID = "sdl_id" CONF_WINDOW_OPTIONS = "window_options" @@ -31,6 +32,8 @@ WINDOW_OPTIONS = ( "resizable", ) +SDL_WINDOWPOS_CENTERED_MASK = 0x2FFF0000 + def get_sdl_options(value): if value != "": @@ -47,6 +50,20 @@ def get_window_options(): return {cv.Optional(option, default=False): cv.boolean for option in WINDOW_OPTIONS} +def _validate_position(config: dict) -> dict: + if CONF_CENTERED_ON_DISPLAY in config: + if CONF_X in config or CONF_Y in config: + raise cv.Invalid( + f"Cannot specify '{CONF_CENTERED_ON_DISPLAY}' with '{CONF_X}' and '{CONF_Y}' options" + ) + return config + if CONF_X in config and CONF_Y in config: + return config + if CONF_X in config or CONF_Y in config: + raise cv.Invalid(f"Must specify both '{CONF_X}' and '{CONF_Y}' options") + raise cv.Invalid("Must specify either 'x' and 'y' or 'centered_on_display'") + + CONFIG_SCHEMA = cv.All( display.FULL_DISPLAY_SCHEMA.extend( cv.Schema( @@ -66,10 +83,13 @@ CONFIG_SCHEMA = cv.All( { cv.Optional(CONF_POSITION): cv.Schema( { - cv.Required(CONF_X): cv.int_, - cv.Required(CONF_Y): cv.int_, + cv.Optional(CONF_X): cv.int_, + cv.Optional(CONF_Y): cv.int_, + cv.Optional(CONF_CENTERED_ON_DISPLAY): cv.int_range( + 0, 128 + ), } - ), + ).add_extra(_validate_position), **get_window_options(), } ), @@ -105,7 +125,15 @@ async def to_code(config): cg.add(var.set_window_options(create_flags)) if position := window_options.get(CONF_POSITION): - cg.add(var.set_position(position[CONF_X], position[CONF_Y])) + if (centered := position.get(CONF_CENTERED_ON_DISPLAY)) is not None: + cg.add( + var.set_position( + SDL_WINDOWPOS_CENTERED_MASK | centered, + SDL_WINDOWPOS_CENTERED_MASK | centered, + ) + ) + else: + cg.add(var.set_position(position[CONF_X], position[CONF_Y])) if lamb := config.get(CONF_LAMBDA): lambda_ = await cg.process_lambda( diff --git a/esphome/components/sdl/sdl_esphome.h b/esphome/components/sdl/sdl_esphome.h index 3f54b70560..a5ebf44c38 100644 --- a/esphome/components/sdl/sdl_esphome.h +++ b/esphome/components/sdl/sdl_esphome.h @@ -28,7 +28,7 @@ class Sdl : public display::Display { this->height_ = height; } void set_window_options(uint32_t window_options) { this->window_options_ = window_options; } - void set_position(uint16_t pos_x, uint16_t pos_y) { + void set_position(int32_t pos_x, int32_t pos_y) { this->pos_x_ = pos_x; this->pos_y_ = pos_y; } @@ -54,8 +54,8 @@ class Sdl : public display::Display { int width_{}; int height_{}; uint32_t window_options_{0}; - int pos_x_{SDL_WINDOWPOS_UNDEFINED}; - int pos_y_{SDL_WINDOWPOS_UNDEFINED}; + int32_t pos_x_{SDL_WINDOWPOS_UNDEFINED}; + int32_t pos_y_{SDL_WINDOWPOS_UNDEFINED}; SDL_Renderer *renderer_{}; SDL_Window *window_{}; SDL_Texture *texture_{}; diff --git a/tests/components/sdl/common.yaml b/tests/components/sdl/common.yaml index 66f93915b6..d3d3c9ee5e 100644 --- a/tests/components/sdl/common.yaml +++ b/tests/components/sdl/common.yaml @@ -10,6 +10,28 @@ display: dimensions: width: 450 height: 600 + window_options: + position: + x: 100 + y: 100 + + - platform: sdl + id: second_display + dimensions: + width: 450 + height: 600 + window_options: + position: + centered_on_display: 1 + + - platform: sdl + id: third_display + dimensions: + width: 450 + height: 600 + window_options: + position: + centered_on_display: 0 binary_sensor: - platform: sdl From 92819d86586037b369fadf67ff1ce2ddcd14d723 Mon Sep 17 00:00:00 2001 From: Jon Little Date: Wed, 3 Jun 2026 15:54:34 -0500 Subject: [PATCH 0296/1815] [logger] Fix USB JTAG VFS symbols linked when logging is disabled (#15721) Co-authored-by: J. Nick Koston Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/logger/__init__.py | 6 +++++- esphome/components/logger/logger_esp32.cpp | 6 ++++-- esphome/core/defines.h | 5 +++-- .../logger/common-uart0_no_logging.yaml | 3 +++ .../test-uart0_no_logging.esp32-h2-idf.yaml | 1 + .../build_components_base.esp32-h2-idf.yaml | 20 +++++++++++++++++++ 6 files changed, 36 insertions(+), 5 deletions(-) create mode 100644 tests/components/logger/common-uart0_no_logging.yaml create mode 100644 tests/components/logger/test-uart0_no_logging.esp32-h2-idf.yaml create mode 100644 tests/test_build_components/build_components_base.esp32-h2-idf.yaml diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index e4921ae196..9629dce0bf 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -461,7 +461,11 @@ async def _late_logger_init(config: ConfigType) -> None: cg.add_define("USE_LOGGER_USB_SERIAL_JTAG") # USB Serial JTAG code is compiled when platform supports it. # Enable secondary USB serial JTAG console so the VFS functions are available. - if CORE.is_esp32 and config[CONF_HARDWARE_UART] != USB_SERIAL_JTAG: + if ( + CORE.is_esp32 + and config[CONF_HARDWARE_UART] != USB_SERIAL_JTAG + and has_serial_logging + ): require_usb_serial_jtag_secondary() require_vfs_termios() except cv.Invalid: diff --git a/esphome/components/logger/logger_esp32.cpp b/esphome/components/logger/logger_esp32.cpp index 8e0c00267a..b216a5427d 100644 --- a/esphome/components/logger/logger_esp32.cpp +++ b/esphome/components/logger/logger_esp32.cpp @@ -6,7 +6,7 @@ #include -#ifdef USE_LOGGER_USB_SERIAL_JTAG +#ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG #include #if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 3, 0) #include @@ -29,7 +29,7 @@ namespace esphome::logger { static const char *const TAG = "logger"; -#ifdef USE_LOGGER_USB_SERIAL_JTAG +#ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG static void init_usb_serial_jtag_() { setvbuf(stdin, NULL, _IONBF, 0); // Disable buffering on stdin @@ -108,7 +108,9 @@ void Logger::pre_setup() { #endif #ifdef USE_LOGGER_USB_SERIAL_JTAG case UART_SELECTION_USB_SERIAL_JTAG: +#ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG init_usb_serial_jtag_(); +#endif break; #endif } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 765c1aa3b2..6c840f56ee 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -358,11 +358,12 @@ #define USE_LOGGER_USB_SERIAL_JTAG #elif defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || \ - defined(USE_ESP32_VARIANT_ESP32H4) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S3) || \ - defined(USE_ESP32_VARIANT_ESP32S31) + defined(USE_ESP32_VARIANT_ESP32H21) || defined(USE_ESP32_VARIANT_ESP32H4) || defined(USE_ESP32_VARIANT_ESP32P4) || \ + defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32S31) #define USE_LOGGER_USB_CDC #define USE_LOGGER_UART_SELECTION_USB_CDC #define USE_LOGGER_USB_SERIAL_JTAG +#define USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG #endif #endif diff --git a/tests/components/logger/common-uart0_no_logging.yaml b/tests/components/logger/common-uart0_no_logging.yaml new file mode 100644 index 0000000000..3bb1691767 --- /dev/null +++ b/tests/components/logger/common-uart0_no_logging.yaml @@ -0,0 +1,3 @@ +logger: + hardware_uart: UART0 + baud_rate: 0 diff --git a/tests/components/logger/test-uart0_no_logging.esp32-h2-idf.yaml b/tests/components/logger/test-uart0_no_logging.esp32-h2-idf.yaml new file mode 100644 index 0000000000..76444a2e89 --- /dev/null +++ b/tests/components/logger/test-uart0_no_logging.esp32-h2-idf.yaml @@ -0,0 +1 @@ +<<: !include common-uart0_no_logging.yaml diff --git a/tests/test_build_components/build_components_base.esp32-h2-idf.yaml b/tests/test_build_components/build_components_base.esp32-h2-idf.yaml new file mode 100644 index 0000000000..a60c1fddd9 --- /dev/null +++ b/tests/test_build_components/build_components_base.esp32-h2-idf.yaml @@ -0,0 +1,20 @@ +esphome: + name: componenttestesp32h2idf + friendly_name: $component_name + +esp32: + board: esp32-h2-devkitm-1 + framework: + type: esp-idf + # Use custom partition table with larger app partition (3MB) + # Default IDF partitions only allow 1.75MB which is too small for grouped tests + partitions: ../partitions_testing.csv + +logger: + level: VERY_VERBOSE + +packages: + component_under_test: !include + file: $component_test_file + vars: + component_test_file: $component_test_file From 78d8a93fff36c749a9786811e01c382e79d5971f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:45:56 -0400 Subject: [PATCH 0297/1815] [remote_base] Fix RC5 decoding at either receive polarity (#16767) --- .../components/remote_base/rc5_protocol.cpp | 90 +++++++++++-------- 1 file changed, 51 insertions(+), 39 deletions(-) diff --git a/esphome/components/remote_base/rc5_protocol.cpp b/esphome/components/remote_base/rc5_protocol.cpp index c7f79ad84a..fd136a4e6d 100644 --- a/esphome/components/remote_base/rc5_protocol.cpp +++ b/esphome/components/remote_base/rc5_protocol.cpp @@ -7,6 +7,7 @@ static const char *const TAG = "remote.rc5"; static constexpr uint32_t BIT_TIME_US = 889; static constexpr uint8_t NBITS = 14; +static constexpr uint8_t NHALFBITS = NBITS * 2; void RC5Protocol::encode(RemoteTransmitData *dst, const RC5Data &data) { static bool toggle = false; @@ -35,52 +36,63 @@ void RC5Protocol::encode(RemoteTransmitData *dst, const RC5Data &data) { } toggle = !toggle; } + optional RC5Protocol::decode(RemoteReceiveData src) { - RC5Data out{ - .address = 0, - .command = 0, - }; - uint8_t field_bit; - - if (src.expect_space(BIT_TIME_US) && src.expect_mark(BIT_TIME_US)) { - field_bit = 1; - } else if (src.expect_space(2 * BIT_TIME_US)) { - field_bit = 0; - } else { - return {}; - } - - if (!(((src.expect_space(BIT_TIME_US) || src.peek_space(2 * BIT_TIME_US)) || - (src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US))) && - (((src.expect_mark(BIT_TIME_US) || src.expect_mark(2 * BIT_TIME_US)) && - (src.expect_space(BIT_TIME_US) || src.peek_space(2 * BIT_TIME_US))) || - ((src.expect_space(BIT_TIME_US) || src.expect_space(2 * BIT_TIME_US)) && - (src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US)))))) { - return {}; - } - - uint32_t out_data = 0; - for (int bit = NBITS - 4; bit >= 1; bit--) { - if ((src.expect_space(BIT_TIME_US) || src.expect_space(2 * BIT_TIME_US)) && - (src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US))) { - out_data |= 0 << bit; - } else if ((src.expect_mark(BIT_TIME_US) || src.expect_mark(2 * BIT_TIME_US)) && - (src.expect_space(BIT_TIME_US) || src.peek_space(2 * BIT_TIME_US))) { - out_data |= 1 << bit; + // Expand the runs into half-bit levels (true = mark). Each run is exactly one + // half-bit (BIT_TIME_US) or two (2 * BIT_TIME_US); stop at anything else. + // + // halfbits[0] is reserved for the leading half-bit, which is always dropped -- + // S1 is 1, so its first half sits at the idle level (at either polarity) and + // merges into the pre-frame idle. Captured half-bits start at index 1. + bool halfbits[NHALFBITS + 2]; + uint8_t n = 1; + for (uint32_t i = 0; n <= NHALFBITS && src.is_valid(i); i++) { + if (src.peek_mark(BIT_TIME_US, i)) { + halfbits[n++] = true; + } else if (src.peek_space(BIT_TIME_US, i)) { + halfbits[n++] = false; + } else if (src.peek_mark(2 * BIT_TIME_US, i)) { + halfbits[n++] = true; + halfbits[n++] = true; + } else if (src.peek_space(2 * BIT_TIME_US, i)) { + halfbits[n++] = false; + halfbits[n++] = false; } else { - return {}; + break; } } - if (src.expect_space(BIT_TIME_US) || src.expect_space(2 * BIT_TIME_US)) { - out_data |= 0; - } else if (src.expect_mark(BIT_TIME_US) || src.expect_mark(2 * BIT_TIME_US)) { - out_data |= 1; + + // Expect a full frame once the leading half is restored: 27 captured halves + // (n == 28) or 26 when the final bit also ends on idle and its trailing half + // is dropped too (n == 27). A dropped edge half is the inverse of its partner + // (a Manchester bit always transitions mid-bit), so reconstruct the leading + // half (always) and the trailing half (only when it was dropped). + if (n != NHALFBITS && n != NHALFBITS - 1) { + return {}; + } + halfbits[0] = !halfbits[1]; + if (n == NHALFBITS - 1) { + halfbits[n] = !halfbits[n - 1]; } - out.command = (uint8_t) (out_data & 0x3F) + (1 - field_bit) * 64u; - out.address = (out_data >> 6) & 0x1F; - return out; + const bool carrier = halfbits[1]; + uint16_t bits = 0; + for (uint8_t i = 0; i < NBITS; i++) { + const bool first = halfbits[2 * i]; + const bool second = halfbits[2 * i + 1]; + if (first == second) { + return {}; // no midpoint transition -> not a valid Manchester bit + } + bits = (bits << 1) | (second == carrier ? 1 : 0); + } + + const bool field_bit = bits & (1 << 12); // S2: the inverted 7th command bit + return RC5Data{ + .address = static_cast((bits >> 6) & 0x1F), + .command = static_cast((bits & 0x3F) | (field_bit ? 0 : 0x40)), + }; } + void RC5Protocol::dump(const RC5Data &data) { ESP_LOGI(TAG, "Received RC5: address=0x%02X, command=0x%02X", data.address, data.command); } From 74a1ff9fc76b4ee5129e47c7ac4a007fd7471987 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:46:01 -0400 Subject: [PATCH 0298/1815] [esp32][core] Restore ESP-IDF version on logs/upload fast path and clean build on framework change (#16770) --- esphome/storage_json.py | 29 ++++++++++-- esphome/writer.py | 13 ++++-- tests/unit_tests/test_espidf_toolchain.py | 9 ++++ tests/unit_tests/test_storage_json.py | 56 ++++++++++++++++++++++- tests/unit_tests/test_writer.py | 27 +++++++++++ 5 files changed, 126 insertions(+), 8 deletions(-) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index ba576fcfd7..3bdda1a9a1 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -16,7 +16,7 @@ from esphome.const import ( KEY_TARGET_PLATFORM, Toolchain, ) -from esphome.core import CORE +from esphome.core import CORE, EsphomeError from esphome.helpers import write_file_if_changed from esphome.types import CoreType @@ -101,6 +101,7 @@ class StorageJSON: core_platform: str | None = None, toolchain: str | None = None, area: str | None = None, + framework_version: str | None = None, ) -> None: # Version of the storage JSON schema assert storage_version is None or isinstance(storage_version, int) @@ -141,6 +142,8 @@ class StorageJSON: self.toolchain = toolchain # The area of the node self.area = area + # The framework version the build used (for esp32, the resolved ESP-IDF version) + self.framework_version = framework_version def as_dict(self): return { @@ -162,6 +165,7 @@ class StorageJSON: "core_platform": self.core_platform, "toolchain": self.toolchain, "area": self.area, + "framework_version": self.framework_version, } def to_json(self): @@ -173,10 +177,12 @@ class StorageJSON: @staticmethod def from_esphome_core(esph: CoreType, old: StorageJSON | None) -> StorageJSON: hardware = esph.target_platform.upper() + framework_version: str | None = None if esph.is_esp32: from esphome.components import esp32 hardware = esp32.get_esp32_variant(esph) + framework_version = str(esp32.idf_version()) return StorageJSON( storage_version=1, name=esph.name, @@ -200,6 +206,7 @@ class StorageJSON: core_platform=esph.target_platform, toolchain=esph.toolchain.value if esph.toolchain is not None else None, area=esph.area, + framework_version=framework_version, ) @staticmethod @@ -249,6 +256,7 @@ class StorageJSON: core_platform = storage.get("core_platform") toolchain = storage.get("toolchain") area = storage.get("area") + framework_version = storage.get("framework_version") return StorageJSON( storage_version, name, @@ -268,6 +276,7 @@ class StorageJSON: core_platform, toolchain, area, + framework_version, ) @staticmethod @@ -311,10 +320,24 @@ class StorageJSON: # esp32.get_esp32_variant(). target_platform on disk is the variant # (e.g. "ESP32S3"); core_platform is the family (e.g. "esp32"). if target_platform == const.PLATFORM_ESP32: - from esphome.components.esp32.const import KEY_ESP32 + from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION from esphome.const import KEY_VARIANT - CORE.data[KEY_ESP32] = {KEY_VARIANT: self.target_platform} + esp32_data = {KEY_VARIANT: self.target_platform} + if self.framework_version: + import esphome.config_validation as cv + + try: + esp32_data[KEY_IDF_VERSION] = cv.Version.parse( + self.framework_version + ) + except ValueError as err: + raise EsphomeError( + f"Could not parse the framework version " + f"{self.framework_version!r} from {storage_path()}. " + f"Please clean the build files and recompile." + ) from err + CORE.data[KEY_ESP32] = esp32_data def __eq__(self, o) -> bool: return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict() diff --git a/esphome/writer.py b/esphome/writer.py index b29b3c4b79..a9c072f156 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -93,9 +93,12 @@ def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: ``src_version`` differs, ``build_path`` differs, the build ``toolchain`` differs (e.g. switching between the PlatformIO and native ESP-IDF toolchains, which produce incompatible build trees), - or a previously loaded integration was removed in *new*. Adding - integrations or changing unrelated fields (friendly name, esphome - version, etc.) does not trigger a clean. + the ``framework`` or ``framework_version`` differs (e.g. switching + arduino <-> esp-idf, or bumping the ESP-IDF version, which also + produce incompatible build trees), or a previously loaded + integration was removed in *new*. Adding integrations or changing + unrelated fields (friendly name, esphome version, etc.) does not + trigger a clean. Used by esphome-device-builder (esphome/device-builder) to gate its remote-build artifact materialiser so a local → remote → local @@ -113,6 +116,10 @@ def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: return True if old.toolchain != new.toolchain: return True + if old.framework != new.framework: + return True + if old.framework_version != new.framework_version: + return True # Check if any components have been removed return bool(old.loaded_integrations - new.loaded_integrations) diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index d00d8662f5..8849ea8bc8 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -148,3 +148,12 @@ def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None: mock_transform.assert_called_once() assert result == {"cxx_path": "regen"} + + +def test_get_core_framework_version_from_core_data(): + """The version is read from CORE.data when validation populated it.""" + from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION + import esphome.config_validation as cv + + CORE.data = {KEY_ESP32: {KEY_IDF_VERSION: cv.Version(5, 5, 4)}} + assert toolchain._get_core_framework_version() == "5.5.4" diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index 105d78505f..7ba56b05f4 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -8,7 +8,7 @@ from unittest.mock import MagicMock, Mock, patch import pytest -from esphome import storage_json +from esphome import config_validation as cv, storage_json from esphome.const import CONF_DISABLED, CONF_MDNS, Toolchain from esphome.core import CORE @@ -206,6 +206,7 @@ def test_storage_json_as_dict() -> None: framework="arduino", core_platform="esp32", area="Living Room", + framework_version="5.3.1", ) result = storage.as_dict() @@ -235,6 +236,7 @@ def test_storage_json_as_dict() -> None: assert result["framework"] == "arduino" assert result["core_platform"] == "esp32" assert result["area"] == "Living Room" + assert result["framework_version"] == "5.3.1" def test_storage_json_to_json() -> None: @@ -313,8 +315,12 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: mock_core.toolchain = Toolchain.ESP_IDF mock_core.area = "Living Room" - with patch("esphome.components.esp32.get_esp32_variant") as mock_variant: + with ( + patch("esphome.components.esp32.get_esp32_variant") as mock_variant, + patch("esphome.components.esp32.idf_version") as mock_idf_version, + ): mock_variant.return_value = "ESP32-C3" + mock_idf_version.return_value = cv.Version(5, 3, 1) result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None) @@ -333,6 +339,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: assert result.core_platform == "esp32" assert result.toolchain == "esp-idf" assert result.area == "Living Room" + assert result.framework_version == "5.3.1" def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: @@ -545,6 +552,51 @@ def test_storage_json_apply_to_core_ignores_unknown_toolchain( assert CORE.toolchain is None +def test_storage_json_framework_version_round_trip(setup_core: Path) -> None: + """Sidecar framework_version restores CORE.data[esp32][idf_version].""" + from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION + + storage = _make_storage_with_toolchain("esp-idf") + storage.framework_version = "5.3.1" + path = setup_core / "storage.json" + path.write_text(storage.to_json()) + + assert json.loads(path.read_text())["framework_version"] == "5.3.1" + + loaded = storage_json.StorageJSON.load(path) + assert loaded is not None + assert loaded.framework_version == "5.3.1" + + loaded.apply_to_core() + assert CORE.data[KEY_ESP32][KEY_IDF_VERSION] == cv.Version(5, 3, 1) + + +def test_storage_json_apply_to_core_without_framework_version( + setup_core: Path, +) -> None: + """Older sidecars lacking framework_version don't populate idf_version.""" + from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION + + loaded = _make_storage_with_toolchain("esp-idf") + assert loaded.framework_version is None + + loaded.apply_to_core() + assert KEY_IDF_VERSION not in CORE.data[KEY_ESP32] + + +def test_storage_json_apply_to_core_raises_on_invalid_framework_version( + setup_core: Path, +) -> None: + """A malformed version string fails with an actionable error at parse time.""" + from esphome.core import EsphomeError + + loaded = _make_storage_with_toolchain("esp-idf") + loaded.framework_version = "not-a-version" + + with pytest.raises(EsphomeError, match="clean the build"): + loaded.apply_to_core() + + def test_esphome_storage_json_as_dict() -> None: """Test EsphomeStorageJSON.as_dict returns correct dictionary.""" storage = storage_json.EsphomeStorageJSON( diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 1487517ca2..c8cf68ff3e 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -112,6 +112,7 @@ def create_storage() -> Callable[..., StorageJSON]: framework=kwargs.get("framework", "arduino"), core_platform=kwargs.get("core_platform", "esp32"), toolchain=kwargs.get("toolchain", "platformio"), + framework_version=kwargs.get("framework_version"), ) return _create @@ -157,6 +158,32 @@ def test_storage_should_clean_when_toolchain_changes( assert storage_should_clean(old, new) is True +def test_storage_should_clean_when_framework_changes( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when the framework changes. + + Switching between arduino and esp-idf produces incompatible build trees + even on the same toolchain, so the build must be wiped. + """ + old = create_storage(loaded_integrations=["api", "wifi"], framework="arduino") + new = create_storage(loaded_integrations=["api", "wifi"], framework="esp-idf") + assert storage_should_clean(old, new) is True + + +def test_storage_should_clean_when_framework_version_changes( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when the framework version changes. + + A different framework/ESP-IDF version compiles against a different SDK, so + the stale build tree must be wiped. + """ + old = create_storage(loaded_integrations=["api", "wifi"], framework_version="5.3.1") + new = create_storage(loaded_integrations=["api", "wifi"], framework_version="5.4.0") + assert storage_should_clean(old, new) is True + + def test_storage_should_clean_when_component_removed( create_storage: Callable[..., StorageJSON], ) -> None: From 0fcfd1e3d636e9a1810f715832494c09ac82fa94 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:46:08 -0400 Subject: [PATCH 0299/1815] [rp2040] Fix lwipopts template load on Windows extended-length paths (#16783) --- esphome/components/rp2040/__init__.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 6ec0ee08b8..f98cde7968 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -501,18 +501,21 @@ def _generate_lwipopts_h() -> None: in the build directory, and a pre-build script injects this directory into the compiler include path before the framework's own include dir. """ - from jinja2 import Environment, FileSystemLoader + from jinja2 import Environment lwip_defines = CORE.data[KEY_RP2040].get(KEY_LWIP_OPTS) if not lwip_defines: return - template_dir = Path(__file__).parent - jinja_env = Environment( - loader=FileSystemLoader(str(template_dir)), - keep_trailing_newline=True, + # Read the template via pathlib and render from a string rather than using + # FileSystemLoader. jinja2's loader joins the search path with posixpath, which + # breaks on Windows extended-length paths (\\?\C:\...) where forward slashes are + # not accepted, causing a spurious TemplateNotFound (see issue #16732). + template_text = (Path(__file__).parent / "lwipopts.h.jinja").read_text( + encoding="utf-8" ) - template = jinja_env.get_template("lwipopts.h.jinja") + jinja_env = Environment(keep_trailing_newline=True) + template = jinja_env.from_string(template_text) content = template.render(**lwip_defines) lwip_dir = CORE.relative_build_path("lwip_override") From 0d7d091e7127b42edd7542c3e5c9dc894ed67bbc Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:46:17 -0400 Subject: [PATCH 0300/1815] [esp32_ble_server] Fix duplicate Device Information Service with string UUIDs (#16784) --- .../components/esp32_ble_server/__init__.py | 28 +++++++++-- .../esp32_ble_server/__init__.py | 0 .../esp32_ble_server/test_esp32_ble_server.py | 47 +++++++++++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/esp32_ble_server/__init__.py create mode 100644 tests/component_tests/esp32_ble_server/test_esp32_ble_server.py diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 7bf3092a4e..d45f2d9df2 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -62,6 +62,26 @@ MANUFACTURER_NAME_CHARACTERISTIC_UUID = 0x2A29 MODEL_CHARACTERISTIC_UUID = 0x2A24 FIRMWARE_VERSION_CHARACTERISTIC_UUID = 0x2A26 +# Suffix of the Bluetooth Base UUID used to expand 16/32 bit UUIDs to 128 bit. +_BASE_UUID_SUFFIX = "-0000-1000-8000-00805F9B34FB" + + +def uuid_is(uuid: int | str, uuid16: int) -> bool: + """Return True if a validated UUID refers to the given 16-bit short UUID. + + A service/characteristic UUID may be an ``int`` (from ``cv.hex_uint32_t``) or an + uppercase string in 16, 32 or 128 bit form (from ``bt_uuid``), so every + representation of the same UUID must be considered equivalent. + """ + if isinstance(uuid, int): + return uuid == uuid16 + return uuid.upper() in ( + f"{uuid16:04X}", + f"{uuid16:08X}", + f"{uuid16:08X}{_BASE_UUID_SUFFIX}", + ) + + # Core key to store the global configuration KEY_NOTIFY_REQUIRED = "notify_required" KEY_SET_VALUE = "set_value" @@ -195,7 +215,7 @@ def create_description_cud(char_config): return char_config # If the config displays a description, there cannot be a descriptor with the CUD UUID for desc in char_config[CONF_DESCRIPTORS]: - if desc[CONF_UUID] == CUD_DESCRIPTOR_UUID: + if uuid_is(desc[CONF_UUID], CUD_DESCRIPTOR_UUID): raise cv.Invalid( f"Characteristic {char_config[CONF_UUID]} has a description, but a CUD descriptor is already present" ) @@ -218,7 +238,7 @@ def create_notify_cccd(char_config): return char_config # If the CCCD descriptor is already present, return the config for desc in char_config[CONF_DESCRIPTORS]: - if desc[CONF_UUID] == CCCD_DESCRIPTOR_UUID: + if uuid_is(desc[CONF_UUID], CCCD_DESCRIPTOR_UUID): # Check if the WRITE property is set if not desc[CONF_WRITE]: raise cv.Invalid( @@ -244,7 +264,7 @@ def create_device_information_service(config): # If there is already a device information service, # there cannot be CONF_MODEL, CONF_MANUFACTURER or CONF_FIRMWARE_VERSION properties for service in config[CONF_SERVICES]: - if service[CONF_UUID] == DEVICE_INFORMATION_SERVICE_UUID: + if uuid_is(service[CONF_UUID], DEVICE_INFORMATION_SERVICE_UUID): if ( CONF_MODEL in config or CONF_MANUFACTURER in config @@ -592,7 +612,7 @@ async def to_code(config): ) for char_conf in service_config[CONF_CHARACTERISTICS]: await to_code_characteristic(service_var, char_conf) - if service_config[CONF_UUID] == DEVICE_INFORMATION_SERVICE_UUID: + if uuid_is(service_config[CONF_UUID], DEVICE_INFORMATION_SERVICE_UUID): cg.add(var.set_device_information_service(service_var)) else: cg.add(var.enqueue_start_service(service_var)) diff --git a/tests/component_tests/esp32_ble_server/__init__.py b/tests/component_tests/esp32_ble_server/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py new file mode 100644 index 0000000000..88307d0dcf --- /dev/null +++ b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py @@ -0,0 +1,47 @@ +"""Tests for esp32_ble_server configuration helpers.""" + +import pytest + +from esphome.components.esp32_ble_server import ( + CCCD_DESCRIPTOR_UUID, + CUD_DESCRIPTOR_UUID, + DEVICE_INFORMATION_SERVICE_UUID, + uuid_is, +) + + +@pytest.mark.parametrize( + "uuid", + [ + DEVICE_INFORMATION_SERVICE_UUID, # int form (cv.hex_uint32_t) + "180A", # 16 bit short form (bt_uuid) + "180a", # lowercase is normalized by bt_uuid but guard anyway + "0000180A", # 32 bit form + "0000180A-0000-1000-8000-00805F9B34FB", # full 128 bit form + ], +) +def test_uuid_is_matches_all_representations(uuid) -> None: + """All representations of the same 16 bit UUID must compare equal.""" + assert uuid_is(uuid, DEVICE_INFORMATION_SERVICE_UUID) + + +@pytest.mark.parametrize( + "uuid", + [ + 0x1818, # Cycling Power Service (different int) + "1818", # different 16 bit short form + "0000180B", # adjacent UUID + "0000180A-0000-1000-8000-00805F9B34FC", # wrong base UUID suffix + ], +) +def test_uuid_is_rejects_other_uuids(uuid) -> None: + """A different UUID must not be mistaken for the device information service.""" + assert not uuid_is(uuid, DEVICE_INFORMATION_SERVICE_UUID) + + +@pytest.mark.parametrize("uuid16", [CUD_DESCRIPTOR_UUID, CCCD_DESCRIPTOR_UUID]) +def test_uuid_is_matches_descriptor_short_strings(uuid16) -> None: + """Reserved descriptor UUIDs match whether given as int or short string.""" + assert uuid_is(uuid16, uuid16) + assert uuid_is(f"{uuid16:04X}", uuid16) + assert uuid_is(f"{uuid16:08X}", uuid16) From 93f25258ee65f741fa4654047232ba38db1b5041 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:08:36 +1200 Subject: [PATCH 0301/1815] [config] Add --no-defaults flag to config command (#16718) --- esphome/__main__.py | 17 +++++- esphome/config.py | 15 +++++ tests/unit_tests/test_main.py | 82 ++++++++++++++++++++++++++ tests/unit_tests/test_substitutions.py | 41 +++++++++++++ 4 files changed, 154 insertions(+), 1 deletion(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 7c4028da44..f7d3f8e834 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1428,7 +1428,16 @@ def command_wizard(args: ArgsProtocol) -> int | None: def command_config(args: ArgsProtocol, config: ConfigType) -> int | None: from esphome import yaml_util - if not CORE.verbose: + if getattr(args, "no_defaults", False): + user_config = getattr(config, "user_config", None) + if user_config is None: + _LOGGER.warning( + "--no-defaults requested but the user-only config snapshot is " + "unavailable; falling back to the validated configuration." + ) + else: + config = user_config + elif not CORE.verbose: config = strip_default_ids(config) output = yaml_util.dump(config, args.show_secrets) if not args.show_secrets: @@ -2152,6 +2161,12 @@ def parse_args(argv): parser_config.add_argument( "--show-secrets", help="Show secrets in output.", action="store_true" ) + parser_config.add_argument( + "--no-defaults", + help="Only output the user-supplied configuration without " + "schema defaults applied.", + action="store_true", + ) parser_config_hash = subparsers.add_parser( "config-hash", help="Calculate the hash of the configuration." diff --git a/esphome/config.py b/esphome/config.py index 9da39a387b..91e6df8bad 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -3,6 +3,7 @@ from __future__ import annotations import abc from contextlib import contextmanager import contextvars +import copy import functools import heapq import logging @@ -168,6 +169,11 @@ class Config(OrderedDict, fv.FinalValidateConfig): self.output_paths: list[tuple[ConfigPath, str]] = [] # A list of components ids with the config path self.declare_ids: list[tuple[core.ID, ConfigPath]] = [] + # Snapshot of the user's configuration after substitutions/packages/ + # extend-remove resolution but before any schema validation defaults + # are applied. Populated by validate_config; used by `esphome config + # --no-defaults` to emit only the user-supplied keys. + self.user_config: ConfigType | None = None self._data = {} # Store pending validation tasks (in heap order) self._validation_tasks: list[_ValidationStepTask] = [] @@ -1076,6 +1082,15 @@ def validate_config( ) return result + # Snapshot the user's config before any schema validation defaults are + # applied. preload_core_config and later validation steps rewrite entries + # in-place with defaulted values; deep-copying here preserves the + # user-supplied keys for `esphome config --no-defaults`. + result.user_config = copy.deepcopy(config) + if substitutions is not None: + result.user_config[CONF_SUBSTITUTIONS] = copy.deepcopy(substitutions) + result.user_config.move_to_end(CONF_SUBSTITUTIONS, last=False) + # 2. Load partial core config import esphome.core.config as core_config diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 8cce60d351..e99a630e83 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -471,6 +471,88 @@ def test_command_config__show_secrets_skips_redaction( assert "\\033[8m" not in output +def test_command_config__no_defaults_dumps_user_snapshot( + tmp_path: Path, capfd: CaptureFixture[str] +) -> None: + """``--no-defaults`` dumps ``config.user_config`` instead of the + validated config, so schema defaults don't leak into the output.""" + from esphome.config import Config + + setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}}) + args = MockArgs() + args.show_secrets = True + args.no_defaults = True + + validated = Config() + validated["esphome"] = {"name": "test", "build_path": "build/test"} + validated["wifi"] = {"ssid": "MyNet", "reboot_timeout": "15min"} + validated.user_config = { + "esphome": {"name": "test"}, + "wifi": {"ssid": "MyNet"}, + } + + result = command_config(args, validated) + + assert result == 0 + output = capfd.readouterr().out + assert "ssid: MyNet" in output + # Defaults present on the validated config must not appear. + assert "reboot_timeout" not in output + assert "build_path" not in output + + +def test_command_config__no_defaults_warns_when_snapshot_missing( + tmp_path: Path, + capfd: CaptureFixture[str], + caplog: pytest.LogCaptureFixture, +) -> None: + """If the snapshot is unavailable (e.g. a plain dict was passed in), + ``--no-defaults`` logs a warning and falls back to the input config.""" + setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}}) + args = MockArgs() + args.show_secrets = True + args.no_defaults = True + + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + result = command_config(args, {"wifi": {"ssid": "MyNet"}}) + + assert result == 0 + output = capfd.readouterr().out + assert "ssid: MyNet" in output + assert any( + "user-only config snapshot is unavailable" in rec.message + for rec in caplog.records + ) + + +def test_command_config__no_defaults_skips_strip_default_ids( + tmp_path: Path, capfd: CaptureFixture[str] +) -> None: + """When ``--no-defaults`` is set, ``strip_default_ids`` isn't run -- + the user snapshot is already free of schema-injected IDs.""" + from esphome.config import Config + + setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}}) + args = MockArgs() + args.show_secrets = True + args.no_defaults = True + + validated = Config() + validated["sensor"] = [{"name": "x", "id": "auto_generated"}] + validated.user_config = {"sensor": [{"name": "x"}]} + + with patch( + "esphome.__main__.strip_default_ids", side_effect=AssertionError + ) as mock_strip: + result = command_config(args, validated) + + assert result == 0 + mock_strip.assert_not_called() + output = capfd.readouterr().out + assert "name: x" in output + assert "auto_generated" not in output + + def test_choose_upload_log_host_with_string_default() -> None: """Test with a single string default device.""" setup_core() diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index b5816f742e..baaa99f2a7 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -361,6 +361,47 @@ def test_validate_config_without_command_line_substitutions_maintains_ordered_di assert result[CONF_SUBSTITUTIONS]["var2"] == "value2" +def test_validate_config_captures_user_config_snapshot(tmp_path: Path) -> None: + """validate_config stores a deep copy of the user's config -- with + substitutions re-added and no schema defaults applied -- on + ``result.user_config`` for ``esphome config --no-defaults``. + """ + test_config = _get_test_minimal_valid_config(tmp_path) + + result = config_module.validate_config(test_config, None) + + # Snapshot is populated. + assert result.user_config is not None + # Substitutions are re-added and appear first. + assert list(result.user_config.keys())[0] == CONF_SUBSTITUTIONS + assert result.user_config[CONF_SUBSTITUTIONS]["var1"] == "value1" + # User-supplied keys are present without schema-default fields like + # ``build_path`` (which preload_core_config injects on the validated + # result's esphome section). + assert result.user_config["esphome"] == {"name": "test_device"} + assert "build_path" not in result.user_config["esphome"] + assert "min_version" not in result.user_config["esphome"] + assert result.user_config["esp32"] == {"board": "esp32dev"} + + +def test_validate_config_user_config_snapshot_is_deep_copy(tmp_path: Path) -> None: + """The snapshot is independent of subsequent mutations to the result + config -- preload_core_config rewrites ``esphome:`` in place, but the + snapshot keeps the user's literal block. + """ + test_config = _get_test_minimal_valid_config(tmp_path) + + result = config_module.validate_config(test_config, None) + + assert result.user_config is not None + # preload_core_config injected build_path onto the validated config. + assert "build_path" in result["esphome"] + # The snapshot was taken before that and is unaffected. + assert "build_path" not in result.user_config["esphome"] + # And the two are not aliased. + assert result["esphome"] is not result.user_config["esphome"] + + def test_merge_config_preserves_ordered_dict() -> None: """Test that merge_config preserves OrderedDict type. From a02b9c379641a3c093df709b0fbae0b6dc02d0fa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:06:45 -0500 Subject: [PATCH 0302/1815] Bump astral-sh/setup-uv from 8.1.0 to 8.2.0 (#16791) Signed-off-by: dependabot[bot] --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci.yml | 6 +++--- .github/workflows/sync-device-classes.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 2a5b701248..c6e9a358ab 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -29,7 +29,7 @@ jobs: - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3fc19ca41..ca1fb07fda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the @@ -170,7 +170,7 @@ jobs: # install step (order-of-magnitude faster on cold boots, # with its own wheel cache). actions/setup-python still # provides the interpreter. - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the @@ -368,7 +368,7 @@ jobs: - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 8796ddf7f0..ab1ce2b587 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -47,7 +47,7 @@ jobs: # setup-python interpreter so subsequent ``pre-commit`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the From d47f6b896e9edbdbcd46f70ff6016daffcdfbc09 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:06:59 -0500 Subject: [PATCH 0303/1815] Bump astral-sh/setup-uv from 8.1.0 to 8.2.0 in /.github/actions/restore-python (#16790) Signed-off-by: dependabot[bot] --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 03b4803860..66d016b42d 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -32,7 +32,7 @@ runs: # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # downstream jobs rely on is preserved. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the From 3e562b9267b142c1f01502397234b4e9fb1ac23e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Jun 2026 22:30:36 -0500 Subject: [PATCH 0304/1815] [ci] Fix memory impact build selecting unbuildable platform (#16788) --- script/determine-jobs.py | 104 +++++++++++++++++----------- script/helpers.py | 25 +++++++ script/test_build_components.py | 16 +---- tests/script/test_determine_jobs.py | 100 +++++++++++++++++++++----- 4 files changed, 171 insertions(+), 74 deletions(-) diff --git a/script/determine-jobs.py b/script/determine-jobs.py index cf098f92c9..94a78e8423 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -70,6 +70,7 @@ from helpers import ( get_changed_components, get_component_from_path, get_component_test_files, + get_component_test_platforms, get_components_with_dependencies, get_cpp_changed_components, get_fixture_to_test_files, @@ -77,7 +78,6 @@ from helpers import ( get_target_branch, git_ls_files, is_validate_only_file, - parse_test_filename, root_path, ) from split_components_for_ci import create_intelligent_batches @@ -169,24 +169,6 @@ MEMORY_IMPACT_FALLBACK_COMPONENT = "api" # Representative component for core ch MEMORY_IMPACT_FALLBACK_PLATFORM = Platform.ESP32_IDF # Most representative platform MEMORY_IMPACT_MAX_COMPONENTS = 40 # Max components before results become nonsensical -# Platform-specific components that can only be built on their respective platforms -# These components contain platform-specific code and cannot be cross-compiled -# Regular components (wifi, logger, api, etc.) are cross-platform and not listed here -PLATFORM_SPECIFIC_COMPONENTS = frozenset( - { - "esp32", # ESP32 platform implementation - "esp8266", # ESP8266 platform implementation - "rp2040", # Raspberry Pi Pico / RP2040 platform implementation - "libretiny", # LibreTiny base platform implementation - "bk72xx", # Beken BK72xx platform implementation (uses LibreTiny) - "rtl87xx", # Realtek RTL87xx platform implementation (uses LibreTiny) - "ln882x", # Winner Micro LN882x platform implementation (uses LibreTiny) - "host", # Host platform (for testing on development machine) - "nrf52", # Nordic nRF52 platform implementation (uses Zephyr) - "zephyr", # Zephyr RTOS platform implementation - } -) - # Platform preference order for memory impact analysis # This order is used when no platform-specific hints are detected from filenames # Priority rationale: @@ -1006,23 +988,24 @@ def detect_memory_impact_config( ] = {} # Track which platforms each component supports for component in sorted(changed_component_set): - # Look for test files on preferred platforms - test_files = get_component_test_files(component, all_variants=True) - if not test_files: - continue - - # Check if component has tests for any preferred platform - available_platforms = [ - platform - for test_file in test_files - if (platform := parse_test_filename(test_file)[1]) != "all" - and platform in MEMORY_IMPACT_PLATFORM_PREFERENCE - ] + # Discover the platforms this component has BASE tests for, using the + # same logic as the build runner (get_component_test_platforms wraps the + # shared get_component_test_files + parse_test_filename helpers). Base + # tests only: the memory impact CI build runs test_build_components.py + # with --base-only, which compiles base test..yaml files but + # never variant test-..yaml files. Counting + # variant-only platforms here would let us select a platform the build + # then has nothing to compile for, producing no memory output. + available_platforms = { + Platform(platform) + for platform in get_component_test_platforms(component) + if platform in MEMORY_IMPACT_PLATFORM_PREFERENCE + } if not available_platforms: continue - component_platforms_map[component] = set(available_platforms) + component_platforms_map[component] = available_platforms components_with_tests.append(component) # If no components have tests, don't run memory impact @@ -1084,20 +1067,57 @@ def detect_memory_impact_config( ) platform = _select_platform_by_count(platform_counts) - # Filter out platform-specific components that are incompatible with selected platform - # Platform components (esp32, esp8266, rp2040, etc.) can only build on their own platform - # Other components (wifi, logger, etc.) are cross-platform and can build anywhere - compatible_components = [ - component - for component in components_with_tests - if component not in PLATFORM_SPECIFIC_COMPONENTS - or platform in component_platforms_map.get(component, set()) - ] + # Keep only components that have a base test on the selected platform. + # The merged build runs test_build_components.py -t --base-only, + # so a component without a base test..yaml compiles nothing and + # contributes no memory output. This also covers platform-specific + # components (esp32, esp8266, etc.), which only have tests on their own + # platform. When components don't share a common platform we build the + # largest subset that does, dropping the rest. + def components_supporting(target: Platform) -> list[str]: + return [ + component + for component in components_with_tests + if target in component_platforms_map.get(component, set()) + ] - # If no components are compatible with the selected platform, don't run + compatible_components = components_supporting(platform) + + # A platform hint (or no-common-platform fallback) can pick a platform that + # no changed component actually has a base test for, leaving nothing to + # build. In that case fall back to the platform supported by the most + # components. component_platforms_map is non-empty (guarded above) and every + # value is a non-empty platform set (components with no supported platform + # are skipped at discovery), so this always yields a buildable platform with + # at least one compatible component. + if not compatible_components: + platform = _select_platform_by_count( + Counter( + p for platforms in component_platforms_map.values() for p in platforms + ) + ) + compatible_components = components_supporting(platform) + + # Defensive backstop: unreachable given the invariant above, but guards + # against a future regression in platform selection silently passing an + # empty component list to the build. if not compatible_components: return {"should_run": "false"} + # Log components dropped because they lack a base test on the selected + # platform so partial-subset builds are visible in CI logs. + dropped_components = [ + component + for component in components_with_tests + if component not in compatible_components + ] + if dropped_components: + print( + f"Memory impact: Dropping components without a base test on " + f"{platform}: {dropped_components}", + file=sys.stderr, + ) + # Debug output print("Memory impact analysis:", file=sys.stderr) print(f" Changed components: {sorted(changed_component_set)}", file=sys.stderr) diff --git a/script/helpers.py b/script/helpers.py index 9839e766e2..1ebfe405a7 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -149,6 +149,31 @@ def get_component_test_files( return files +def get_component_test_platforms(component: str, *, base_only: bool = True) -> set[str]: + """Return the set of platforms a component has compilable test files for. + + Uses the same discovery as ``test_build_components.py`` (``get_component_test_files`` + + ``parse_test_filename``) so callers agree with what the build runner would + actually compile. With ``base_only=True`` (the default, matching the + memory-impact build's ``--base-only``), only base ``test..yaml`` + files are considered; variant ``test-..yaml`` files are + excluded. The ``"all"`` platform sentinel is excluded. + + Args: + component: Component name (e.g. "wifi") + base_only: If True, only consider base test files (default). + + Returns: + Set of platform identifiers (e.g. {"esp32-idf", "esp8266-ard"}). + """ + platforms: set[str] = set() + for test_file in get_component_test_files(component, all_variants=not base_only): + platform = parse_test_filename(test_file)[1] + if platform != "all": + platforms.add(platform) + return platforms + + def is_validate_only_file(test_file: Path) -> bool: """Return True if the given path is a config-only validate file. diff --git a/script/test_build_components.py b/script/test_build_components.py index 767b55c94b..651268609e 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -42,6 +42,7 @@ from script.analyze_component_buses import ( from script.helpers import ( get_component_test_files, is_validate_only_file, + parse_test_filename, split_conflicting_groups, ) from script.merge_component_configs import merge_component_configs @@ -122,21 +123,6 @@ def find_component_tests( return dict(component_tests) -def parse_test_filename(test_file: Path) -> tuple[str, str]: - """Parse test filename to extract test name and platform. - - Args: - test_file: Path to test file - - Returns: - Tuple of (test_name, platform) - """ - parts = test_file.stem.split(".") - if len(parts) == 2: - return parts[0], parts[1] # test, platform - return parts[0], "all" - - def get_platform_base_files(base_dir: Path) -> dict[str, list[Path]]: """Get all platform base files. diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index ac3c6424bf..acc268fa68 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -1426,7 +1426,15 @@ def test_detect_memory_impact_config_core_python_only_changes(tmp_path: Path) -> @pytest.mark.usefixtures("mock_target_branch_dev") def test_detect_memory_impact_config_no_common_platform(tmp_path: Path) -> None: - """Test memory impact detection when components have no common platform.""" + """Test memory impact detection when components have no common platform. + + The merged build runs with --base-only on a single platform, so components + without a base test on the selected platform cannot be built and must be + dropped. We build the largest subset that shares the selected platform + rather than handing the runner components it has nothing to compile for + (which previously produced "0 passed, 0 failed" and a failed memory + extraction). + """ # Create test directory structure tests_dir = tmp_path / "tests" / "components" @@ -1453,12 +1461,70 @@ def test_detect_memory_impact_config_no_common_platform(tmp_path: Path) -> None: result = determine_jobs.detect_memory_impact_config() - # Should pick the most frequently supported platform + # No common platform: pick the most preferred platform among those supported + # (esp8266-ard outranks esp32-idf in the preference list) and build only the + # components that have a base test on it. wifi (esp32-idf only) is dropped. assert result["should_run"] == "true" - assert set(result["components"]) == {"wifi", "logger"} - # When no common platform, picks most commonly supported - # esp8266-ard is preferred over esp32-idf in the preference list - assert result["platform"] in ["esp32-idf", "esp8266-ard"] + assert result["platform"] == "esp8266-ard" + assert result["components"] == ["logger"] + assert result["use_merged_config"] == "true" + + +def test_detect_memory_impact_config_variant_only_platform_excluded( + tmp_path: Path, +) -> None: + """Regression test for the const + shelly_dimmer memory-impact failure. + + Reproduces https://github.com/esphome/esphome/actions/runs/26746938473 + where a platform hint selected esp32-idf even though neither changed + component had a base test.esp32-idf.yaml. The merged --base-only build then + found nothing to compile ("0 passed, 0 failed") and memory extraction + failed. Also covers a component whose only esp32-idf test is a *variant* + (test-*.esp32-idf.yaml): --base-only never compiles variants, so it must + not count toward platform availability. + """ + tests_dir = tmp_path / "tests" / "components" + + # const: base test only on esp32-s3-idf + const_dir = tests_dir / "const" + const_dir.mkdir(parents=True) + (const_dir / "test.esp32-s3-idf.yaml").write_text("test: const") + + # shelly_dimmer: base test only on esp8266-ard + shelly_dir = tests_dir / "shelly_dimmer" + shelly_dir.mkdir(parents=True) + (shelly_dir / "test.esp8266-ard.yaml").write_text("test: shelly_dimmer") + + # mdns: only a VARIANT test on esp32-idf (no base test.esp32-idf.yaml). + # --base-only would never build it, so it must be excluded entirely. + mdns_dir = tests_dir / "mdns" + mdns_dir.mkdir(parents=True) + (mdns_dir / "test-min.esp32-idf.yaml").write_text("test: mdns") + + with ( + patch.object(determine_jobs, "root_path", str(tmp_path)), + patch.object(helpers, "root_path", str(tmp_path)), + patch.object(determine_jobs, "changed_files") as mock_changed_files, + ): + # The "_esp32" filename yields an esp32-idf platform hint, reproducing + # the original bug where the hint picked a platform no component could + # build as a base test. + mock_changed_files.return_value = [ + "esphome/components/const/const.cpp", + "esphome/components/shelly_dimmer/shelly_dimmer_esp32.cpp", + "esphome/components/mdns/mdns.cpp", + ] + + result = determine_jobs.detect_memory_impact_config() + + # The esp32-idf hint is unbuildable (no base test), so we fall back to the + # platform supported by the most components, broken by preference order: + # esp8266-ard (shelly_dimmer) outranks esp32-s3-idf (const). Only the + # component with a base test on the selected platform is returned; the + # variant-only mdns is excluded entirely. + assert result["should_run"] == "true" + assert result["platform"] == "esp8266-ard" + assert result["components"] == ["shelly_dimmer"] assert result["use_merged_config"] == "true" @@ -1545,12 +1611,16 @@ def test_detect_memory_impact_config_includes_base_bus_components( @pytest.mark.usefixtures("mock_target_branch_dev") -def test_detect_memory_impact_config_with_variant_tests(tmp_path: Path) -> None: - """Test memory impact detection for components with only variant test files. +def test_detect_memory_impact_config_variant_only_components_skipped( + tmp_path: Path, +) -> None: + """Components with only variant tests are skipped for memory impact. - This verifies that memory impact analysis works correctly for components like - improv_serial, ethernet, mdns, etc. which only have variant test files - (test-*.yaml) instead of base test files (test.*.yaml). + Components like improv_serial and ethernet only have variant test files + (test-*.yaml), no base test..yaml. The memory-impact build runs + test_build_components.py with --base-only, which never compiles variants, so + these components have nothing buildable and must not be selected. Selecting + them previously produced "0 passed, 0 failed" and a failed memory extraction. """ # Create test directory structure tests_dir = tmp_path / "tests" / "components" @@ -1581,12 +1651,8 @@ def test_detect_memory_impact_config_with_variant_tests(tmp_path: Path) -> None: result = determine_jobs.detect_memory_impact_config() - # Should detect both components even though they only have variant tests - assert result["should_run"] == "true" - assert set(result["components"]) == {"improv_serial", "ethernet"} - # Both components support esp32-idf - assert result["platform"] == "esp32-idf" - assert result["use_merged_config"] == "true" + # Neither component has a base test, so nothing is buildable under --base-only + assert result["should_run"] == "false" # Tests for clang-tidy split mode logic From 53d685f2423284ad3f5d5c219de72e8d2ff385b8 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:37:29 -0400 Subject: [PATCH 0305/1815] [mixer] Give mixer test its own speaker id to avoid CI grouping collision (#16792) --- tests/components/mixer/common.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/mixer/common.yaml b/tests/components/mixer/common.yaml index ef613b82bc..dee42ed280 100644 --- a/tests/components/mixer/common.yaml +++ b/tests/components/mixer/common.yaml @@ -13,13 +13,13 @@ i2s_audio: speaker: - platform: i2s_audio - id: speaker_id + id: mixer_output_speaker_id dac_type: external i2s_dout_pin: ${dout_pin} bits_per_sample: 32bit channel: stereo - platform: mixer - output_speaker: speaker_id + output_speaker: mixer_output_speaker_id bits_per_sample: 32 num_channels: 2 source_speakers: From ffaa31febc7f7bb5b02f2ed7df4cdfbca2e00e65 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:48:34 -0400 Subject: [PATCH 0306/1815] [clang-tidy] Hash idf_component.yml and trigger hash hook on more inputs (#16753) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .clang-tidy.hash | 2 +- .pre-commit-config.yaml | 2 +- script/clang_tidy_hash.py | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 29c8b414f6..648b31f8f0 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -a30d2e50f2cac76e9c504eb7e5b250070dc92df23469c44a7eb8e52e26fd375d +44db8a62d94c8fba83b95b73938db4377ebacc0adb504881387389f1cd8f2f3a diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a076128975..3b6278e6b5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -63,7 +63,7 @@ repos: name: Update clang-tidy hash entry: python script/clang_tidy_hash.py --update-if-changed language: python - files: ^(\.clang-tidy|platformio\.ini|requirements_dev\.txt)$ + files: ^(\.clang-tidy|platformio\.ini|requirements_dev\.txt|sdkconfig\.defaults|esphome/idf_component\.yml)$ pass_filenames: false additional_dependencies: [] - id: ci-custom diff --git a/script/clang_tidy_hash.py b/script/clang_tidy_hash.py index f478535567..1a6e4eb7be 100755 --- a/script/clang_tidy_hash.py +++ b/script/clang_tidy_hash.py @@ -105,6 +105,12 @@ def calculate_clang_tidy_hash(repo_root: Path | None = None) -> str: sdkconfig_content = read_file_bytes(sdkconfig_path) hasher.update(sdkconfig_content) + # Hash esphome/idf_component.yml: its managed deps drive the ESP-IDF + # build's include set, which clang-tidy analyzes. + idf_component_path = repo_root / "esphome" / "idf_component.yml" + if idf_component_path.exists(): + hasher.update(read_file_bytes(idf_component_path)) + return hasher.hexdigest() From 891ec33c94ef89d48b2dedea25f8dd6cf3d4d660 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 4 Jun 2026 07:39:17 -0400 Subject: [PATCH 0307/1815] [esp32] Deduplicate PlatformIO library conversion by resolving the batch together (#16756) --- esphome/components/esp32/__init__.py | 31 +- esphome/espidf/component.py | 566 ++++++++++--------- esphome/espidf/extra_script.py | 4 +- tests/unit_tests/test_espidf_component.py | 626 ++++++++++++++-------- 4 files changed, 702 insertions(+), 525 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index d2dc979966..160c06534e 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -46,10 +46,10 @@ from esphome.const import ( Toolchain, __version__, ) -from esphome.core import CORE, EsphomeError, HexInt, Library +from esphome.core import CORE, EsphomeError, HexInt from esphome.core.config import BOARD_MAX_LENGTH from esphome.coroutine import CoroPriority, coroutine_with_priority -from esphome.espidf.component import generate_idf_component +from esphome.espidf.component import generate_idf_components import esphome.final_validate as fv from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed from esphome.types import ConfigType @@ -2598,13 +2598,6 @@ def _write_sdkconfig(): clean_build(clear_pio_cache=False) -def _platformio_library_to_dependency(library: Library) -> tuple[str, dict[str, str]]: - dependency: dict[str, str] = {} - name, _version, path = generate_idf_component(library) - dependency["override_path"] = str(path) - return name, dependency - - def _write_idf_component_yml(): yml_path = CORE.relative_build_path("src/idf_component.yml") dependencies: dict[str, dict] = {} @@ -2678,13 +2671,21 @@ def _write_idf_component_yml(): ) if CORE.using_toolchain_esp_idf: - # Try to convert PlatformIO library to ESP-IDF components - for name, library in CORE.platformio_libraries.items(): + # Convert the PlatformIO libraries to ESP-IDF components as a batch so + # PlatformIO resolves the whole dependency tree at once -- deduplicating + # shared transitive deps (e.g. esphome/libsodium pulled by both noise-c + # and esp_wireguard) to a single version instead of clashing + # override_path entries. + libraries = [ + library + for name, library in CORE.platformio_libraries.items() # Don't process arduino libraries - if name in ARDUINO_DISABLED_LIBRARIES: - continue - dependency_name, dependency = _platformio_library_to_dependency(library) - dependencies[dependency_name] = dependency + if name not in ARDUINO_DISABLED_LIBRARIES + ] + for component in generate_idf_components(libraries): + dependencies[component.get_sanitized_name()] = { + "override_path": str(component.path) + } if CORE.data[KEY_ESP32][KEY_COMPONENTS]: components: dict = CORE.data[KEY_ESP32][KEY_COMPONENTS] diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 050002d9e2..7398a91c36 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -1,4 +1,6 @@ +from collections import deque from collections.abc import Callable +from dataclasses import dataclass, field import glob import hashlib import itertools @@ -8,7 +10,7 @@ import os from pathlib import Path import re import tempfile -from typing import TypeVar +from typing import Any, TypeVar from urllib.parse import urlparse, urlsplit, urlunsplit from esphome import git, yaml_util @@ -154,72 +156,6 @@ class IDFComponent: self.path = self.source.download(self.get_sanitized_name(), force=force) -def _get_package_from_pio_registry( - username: str | None, pkgname: str, requirements: str -) -> tuple[str, str, str | None, str | None]: - """ - Fetch package information from PlatformIO registry. - - This function queries the PlatformIO registry to find a library package - that matches the given criteria and returns its metadata including version - and download URL. - - Args: - username: The owner/username of the package (can be None) - pkgname: The name of the package - requirements: Version requirements (e.g., "^1.0.0") - - Returns: - tuple[str, str, str | None, str | None]: - A tuple containing (owner, name, version, download_url) - where version and download_url can be None if not found - """ - - from platformio.package.manager._registry import PackageManagerRegistryMixin - from platformio.package.meta import PackageSpec - - # Create a minimal PackageManagerRegistry class - class PackageManagerRegistry(PackageManagerRegistryMixin): - def __init__(self): - self._registry_client = None - self.pkg_type = "library" - - @staticmethod - def is_system_compatible(value, custom_system=None): - return True - - pio_registry = PackageManagerRegistry() - - # Fetch package metadata from registry - package = pio_registry.fetch_registry_package( - PackageSpec( - owner=username, - name=pkgname, - ) - ) - owner = package["owner"]["username"] - name = package["name"] - - # Find the best matching version based on requirements - version = pio_registry.pick_best_registry_version( - package.get("versions"), - PackageSpec(owner=username, name=pkgname, requirements=requirements), - ) - - # If no version found, return with None for version and URL - if not version: - return owner, name, None, None - - # Find the compatible package file for this version - pkgfile = pio_registry.pick_compatible_pkg_file(version["files"]) - - # If no package file found, return with None for URL but valid version - if not pkgfile: - return owner, name, version["name"], None - - return owner, name, version["name"], pkgfile["download_url"] - - def _apply_extra_script(component: IDFComponent) -> None: """Run a PIO ``extraScript`` and fold its captured env vars into ``component.data["build"]["flags"]`` so the existing -L/-l/-D @@ -339,77 +275,6 @@ def _collect_filtered_files(src_dir: PathType, src_filters: list[str]) -> list[s return [r for r in selected if Path(r).is_file()] -def _convert_library_to_component(library: Library) -> IDFComponent: - """ - Convert a Library object to an IDFComponent object by resolving its metadata. - - This function handles the conversion of library specifications to component - objects, resolving versions through PlatformIO registry when needed or - parsing direct repository URLs. - - Args: - library: The Library object containing name, version, and/or repository information - - Returns: - IDFComponent: The resolved component with name, version, and URL - - Raises: - RuntimeError: If no artifact can be found for the library - """ - name = None - version = None - source = None - - # Repository is provided directly - if library.repository: - # Parse repository URL: path becomes the component name, fragment - # (if any) becomes the git ref stored on GitSource. A missing - # fragment is fine -- clone_or_update leaves the depth-1 clone on - # the remote's default branch, matching PIO's lib_deps behavior - # and external_components handling. - split_result = urlsplit(library.repository) - - # Sanitize name - name = str(split_result.path).strip("/") - name = name.removesuffix(".git") - - # IDF Component Manager only accepts "*", a 40-char commit hash, or - # semver here. The actual git ref is preserved in GitSource.ref; - # override_path makes this field cosmetic at build time. - version = "*" - repository = urlunsplit(split_result._replace(fragment="")) - - ref = split_result.fragment.strip() or None - source = GitSource(str(repository), ref) - - # Version is provided - resolve using PlatformIO registry - elif library.version: - name = library.name - if "/" not in name: - owner, pkgname = None, name - else: - owner, pkgname = name.split("/", 1) - - owner, pkgname, version, url = _get_package_from_pio_registry( - owner, pkgname, library.version - ) - if url is None: - raise RuntimeError( - f"Can't find an pkg file from PlatformIO registry for library {library}" - ) - - name = _owner_pkgname_to_name(owner, pkgname) - source = URLSource(url) - - if source is None: - raise RuntimeError(f"Can't find an artifact associated to library {library}") - - assert name, "Missing library name" - assert version, "Missing library version" - - return IDFComponent(name, version, source) - - def _split_list_by_condition( items: list[str], match_fn: Callable[[str], str | None] ) -> tuple[list[str], list[str]]: @@ -599,8 +464,8 @@ def generate_idf_component_yml(component: IDFComponent) -> str: if "dependencies" not in data: data["dependencies"] = {} - # Every dependency goes through _generate_idf_component → - # component.download() before this runs, so .path is always set. + # Every dependency has been resolved and downloaded before this runs, + # so .path is always set. data["dependencies"][dependency.get_sanitized_name()] = { "override_path": str(dependency.path), } @@ -657,81 +522,6 @@ def _check_library_data(data: dict): ) -def _process_dependencies(component: IDFComponent): - """ - Process library dependencies and generate ESP-IDF components. - - Args: - component: IDFComponent object being processed - - Returns: - None - """ - - name, version = component.name, component.version - dependencies = component.data.get("dependencies") - if not dependencies: - return - - # PIO's library.json accepts both the list-of-dicts form and the - # shorthand dict form ``{"owner/Name": "version_spec"}``. Normalize - # the dict form so the loop below sees a uniform list. Iterating a - # dict gives string keys, which would silently fail the - # ``"name" in dependency`` substring check and skip every entry. - if isinstance(dependencies, dict): - normalized = [] - for raw_name, spec in dependencies.items(): - if "/" in raw_name: - owner, pkgname = raw_name.split("/", 1) - else: - owner, pkgname = None, raw_name - entry = {"name": pkgname, "owner": owner} - if isinstance(spec, dict): - entry.update(spec) - else: - entry["version"] = spec - normalized.append(entry) - dependencies = normalized - - _LOGGER.info("Processing %s@%s component dependencies...", name, version) - for dependency in dependencies: - # Validate dependency structure - if not all(k in dependency for k in ("name", "version")): - _LOGGER.debug("Ignore invalid library: %s", dependency) - continue - - try: - _check_library_data(dependency) - except InvalidIDFComponent as e: - _LOGGER.debug( - "Skip %s@%s: %s", dependency["name"], dependency["version"], str(e) - ) - continue - - # The version field may actually contain a URL - version = dependency["version"] - url = None - try: - result = urlparse(version) - if all([result.scheme, result.netloc]): - url, version = version, None - except (TypeError, ValueError): - pass - - # Generate ESP-IDF component from PlatformIO library - component.dependencies.append( - _generate_idf_component( - Library( - _owner_pkgname_to_name( - dependency.get("owner", None), dependency.get("name") - ), - version, - url, - ) - ) - ) - - def _parse_library_json(library_json_path: PathType): """ Load and parse a JSON file describing a library. @@ -772,92 +562,294 @@ def _parse_library_properties(library_properties_path: PathType): return data -def _generate_idf_component(library: Library, force: bool = False) -> IDFComponent: +def _make_registry_client() -> Any: + """Create a minimal PlatformIO registry client with no system filtering. + + ``is_system_compatible`` is forced True so version selection is driven purely + by the requested version requirements -- ESP-IDF/target compatibility is + handled elsewhere, not by the PlatformIO registry. """ - Generate an ESP-IDF component from a library specification. + from platformio.package.manager._registry import PackageManagerRegistryMixin - This function resolves the library, downloads it, processes metadata files, - and generates necessary ESP-IDF build files (CMakeLists.txt, idf_component.yml). + class _Registry(PackageManagerRegistryMixin): + def __init__(self) -> None: + self._registry_client = None + self.pkg_type = "library" - Args: - library: The library specification containing name, version, and repository URL - force: If True, forces re-download of the library even if it exists locally + @staticmethod + def is_system_compatible(value: Any, custom_system: Any = None) -> bool: + return True - Returns: - IDFComponent: The generated component object with resolved metadata + return _Registry() + + +def _resolve_registry_version( + owner: str | None, pkgname: str, requirements: set[str] +) -> tuple[str, str, str, str]: + """Resolve a registry package to the single highest version satisfying ALL + the given requirements; return ``(owner, name, version, download_url)``. + + Intersecting every requirement (rather than resolving each consumer in + isolation) makes the result independent of processing order and guarantees + no stated constraint is violated -- e.g. ``esphome/libsodium`` requested as + both ``==1.10021.0`` and ``^1.10018.1`` resolves to ``1.10021.0``. """ - _LOGGER.info("Generate IDF component for %s library ...", library) + from platformio.package.meta import PackageSpec - # Resolve component name, version and url - component = _convert_library_to_component(library) - name, version = component.name, component.version + registry = _make_registry_client() + package = registry.fetch_registry_package(PackageSpec(owner=owner, name=pkgname)) + owner = package["owner"]["username"] + name = package["name"] - # Download the library - component.download(force) - - # Paths to component metadata and build files - library_json_path = component.path / "library.json" - library_properties_path = component.path / "library.properties" - cmakelists_txt_path = component.path / "CMakeLists.txt" - idf_component_yml_path = component.path / "idf_component.yml" - - # Bundled CMakeLists.txt / idf_component.yml are ignored -- library - # authors' IDF support is frequently broken (bogus REQUIRES, hard-coded - # arduino-esp32, etc.). We always regenerate. - - if library_json_path.is_file(): - component.data = _parse_library_json(library_json_path) - elif library_properties_path.is_file(): - component.data = _parse_library_properties(library_properties_path) - else: + # Chaining the per-requirement filter intersects all constraints. + versions = package.get("versions") or [] + for requirement in sorted(requirements): + versions = registry.get_compatible_registry_versions( + versions, PackageSpec(owner=owner, name=name, requirements=requirement) + ) + if not versions: raise RuntimeError( - "Invalid PIO library: missing library.json and/or library.properties" + f"No version of {owner}/{name} satisfies all requirements " + f"{sorted(requirements)} requested across the library tree" ) - # Check if the component is usable with ESP-IDF before executing any - # third-party Python from the library (``_apply_extra_script`` below). - _check_library_data(component.data) - - # If the library declares a PIO ``extraScript``, run it against a - # fake SCons env so we can fold its captured LIBPATH/LIBS/etc into - # the build-flag pipeline ``generate_cmakelists_txt`` consumes - # below. Without this, libraries that wire per-MCU archive linking - # via extraScript fail to link under native ESP-IDF. - _apply_extra_script(component) - - # Handle the dependencies (convert PlatformIO library to ESP-IDF component if needed) - _process_dependencies(component) - - _LOGGER.debug("Generating CMakeLists.txt for %s@%s ...", name, version) - write_file_if_changed( - cmakelists_txt_path, - generate_cmakelists_txt(component), - ) - - _LOGGER.debug("Generating idf_component.yml for %s@%s ...", name, version) - write_file_if_changed( - idf_component_yml_path, - generate_idf_component_yml(component), - ) - - return component + best = registry.pick_best_registry_version(versions) + pkgfile = registry.pick_compatible_pkg_file(best["files"]) + if not pkgfile: + raise RuntimeError(f"No package file for {owner}/{name}@{best['name']}") + return owner, name, best["name"], pkgfile["download_url"] -def generate_idf_component( - library: Library, force: bool = False -) -> tuple[str, str, Path]: +def _normalize_dependencies(dependencies: Any) -> list[dict]: + """Normalize a library manifest's ``dependencies`` to a list of dicts. + + PIO's library.json accepts both the list-of-dicts form and the shorthand + dict form (``{"owner/Name": "version_spec"}``); normalize the latter so + callers see a uniform list. """ - Generate an ESP-IDF component and return its name, version, and path. + if not dependencies: + return [] + if isinstance(dependencies, dict): + normalized = [] + for raw_name, spec in dependencies.items(): + if "/" in raw_name: + owner, pkgname = raw_name.split("/", 1) + else: + owner, pkgname = None, raw_name + entry = {"name": pkgname, "owner": owner} + if isinstance(spec, dict): + entry.update(spec) + else: + entry["version"] = spec + normalized.append(entry) + return normalized + return [d for d in dependencies if isinstance(d, dict)] - This is a wrapper function that calls _generate_idf_component and returns - the standardized tuple format (name, version, path). - Args: - library: The library specification containing name, version, and repository URL - force: If True, forces re-download of the library even if it exists locally +@dataclass +class _LibNode: + """A node in the library dependency graph being resolved as a batch.""" - Returns: - tuple[str, str, Path]: A tuple containing (component_name, component_version, component_path) + key: str + is_git: bool + owner: str | None = None + pkgname: str | None = None + requirements: set[str] = field(default_factory=set) + url: str | None = None + ref: str | None = None + edges: set[str] = field(default_factory=set) + + +def _node_key( + name: str | None, version: str | None, repository: str | None +) -> tuple[str, bool, tuple[str | None, str | None]]: + """Return ``(key, is_git, locator)`` for a library or dependency spec. + + The key is derived from the *input* spec (the registry name as written, or + the git URL path), not the resolved canonical name. So a package referenced + inconsistently -- bare ``name`` vs ``owner/name``, or git vs registry -- maps + to distinct keys and isn't deduplicated; ``generate_idf_components`` warns + about that after resolution rather than merging the nodes. """ - component = _generate_idf_component(library, force) - return component.get_sanitized_name(), component.version, component.path + if repository: + split_result = urlsplit(repository) + key = str(split_result.path).strip("/").removesuffix(".git") + ref = split_result.fragment.strip() or None + url = urlunsplit(split_result._replace(fragment="")) + return key, True, (url, ref) + if name and "/" in name: + owner, pkgname = name.split("/", 1) + else: + owner, pkgname = None, name + return name, False, (owner, pkgname) + + +def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: + """Resolve and convert a batch of PlatformIO libraries to IDF components. + + Resolves the whole set together rather than each library independently: it + walks the dependency graph collecting every version *requirement* per + component name, then resolves each name once to a single version satisfying + all of them. So a transitive dependency shared under + different specs (e.g. ``esphome/libsodium``, pulled by both ``noise-c`` and + ``esp_wireguard``) becomes one component instead of two clashing + ``override_path`` entries -- order-independently, and without ever violating + a stated constraint. + + The returned list holds the top-level components (those directly requested); + transitive dependencies are converted too and wired into each component's + generated manifest. + """ + nodes: dict[str, _LibNode] = {} + + def add_spec(name: str | None, version: str | None, repository: str | None) -> str: + key, is_git, locator = _node_key(name, version, repository) + node = nodes.get(key) or _LibNode(key=key, is_git=is_git) + nodes[key] = node + if is_git: + node.is_git = True + node.url, node.ref = locator + else: + node.owner, node.pkgname = locator + if version: + node.requirements.add(version) + return key + + top_level = [ + add_spec(library.name, library.version, library.repository) + for library in libraries + ] + + # Collect + resolve to a fixpoint: a node is (re)resolved whenever its + # requirement set has grown since the last time, so every requirement in the + # graph is accounted for before conversion. + components: dict[str, IDFComponent] = {} + resolved_requirements: dict[str, frozenset[str]] = {} + top_level_keys = set(top_level) + worklist = deque(dict.fromkeys(top_level)) + while worklist: + key = worklist.popleft() + node = nodes[key] + + # A node is queued once per referring edge; skip the (uncached) registry + # lookup + download + dependency walk unless its requirement set grew + # since the last resolve. Requirements only ever grow, so this still + # converges the fixpoint and terminates dependency cycles. + requirements = frozenset(node.requirements) + if resolved_requirements.get(key) == requirements: + continue + resolved_requirements[key] = requirements + + if node.is_git: + component = IDFComponent(key, "*", GitSource(node.url, node.ref)) + else: + owner, name, version, url = _resolve_registry_version( + node.owner, node.pkgname, node.requirements + ) + component = IDFComponent( + _owner_pkgname_to_name(owner, name), version, URLSource(url) + ) + component.download() + + library_json_path = component.path / "library.json" + library_properties_path = component.path / "library.properties" + if library_json_path.is_file(): + component.data = _parse_library_json(library_json_path) + elif library_properties_path.is_file(): + component.data = _parse_library_properties(library_properties_path) + else: + raise RuntimeError( + f"Invalid PIO library {key}: missing library.json and " + "library.properties" + ) + + try: + _check_library_data(component.data) + except InvalidIDFComponent as e: + # Skip an incompatible transitive dependency, but fail fast if a + # top-level library the build explicitly requested is incompatible. + if key in top_level_keys: + raise RuntimeError( + f"Requested library {key} is not compatible with ESP-IDF: {e}" + ) from e + _LOGGER.debug("Skip incompatible dependency %s: %s", key, str(e)) + continue + components[key] = component + + # Requirements changed (we got past the short-circuit above), so + # (re)walk this component's dependencies. + node.edges = set() + for dependency in _normalize_dependencies(component.data.get("dependencies")): + if "name" not in dependency or "version" not in dependency: + continue + try: + _check_library_data(dependency) + except InvalidIDFComponent as e: + _LOGGER.debug("Skip dependency %s: %s", dependency.get("name"), str(e)) + continue + # The version field may actually be a URL (git/archive dependency). + dep_version = dependency["version"] + dep_url = None + try: + parsed = urlparse(dep_version) + if all([parsed.scheme, parsed.netloc]): + dep_url, dep_version = dep_version, None + except (TypeError, ValueError): + pass + dep_key = add_spec( + _owner_pkgname_to_name(dependency.get("owner"), dependency.get("name")), + dep_version, + dep_url, + ) + node.edges.add(dep_key) + worklist.append(dep_key) + + # A git source wins over any registry version requested for the same + # component. That's intentional, but warn so a dropped registry pin isn't a + # silent surprise. + for node in nodes.values(): + if node.is_git and node.requirements: + _LOGGER.warning( + "Library %s is requested both from a git source (%s) and as " + "registry version(s) %s; using the git source.", + node.key, + node.url, + sorted(node.requirements), + ) + + # Two graph nodes that resolve to the same component name (e.g. a package + # referenced both bare and as ``owner/name``) are not deduplicated and can + # produce conflicting component definitions. Warn so it's not silent. + canonical_keys: dict[str, str] = {} + for node_key, component in components.items(): + canonical = component.get_sanitized_name() + if canonical_keys.setdefault(canonical, node_key) != node_key: + _LOGGER.warning( + "Library %s is referenced under multiple names (%s and %s); these " + "are not deduplicated. Reference it consistently as %s.", + canonical, + canonical_keys[canonical], + node_key, + canonical, + ) + + # Wire each component's dependencies to the single resolved instances, then + # regenerate build files. + for key, component in components.items(): + component.dependencies = [ + components[dep_key] + for dep_key in sorted(nodes[key].edges) + if dep_key in components + ] + for component in components.values(): + _apply_extra_script(component) + write_file_if_changed( + component.path / "CMakeLists.txt", + generate_cmakelists_txt(component), + ) + write_file_if_changed( + component.path / "idf_component.yml", + generate_idf_component_yml(component), + ) + + return [components[key] for key in top_level if key in components] diff --git a/esphome/espidf/extra_script.py b/esphome/espidf/extra_script.py index 5f59254aee..4d06fb842a 100644 --- a/esphome/espidf/extra_script.py +++ b/esphome/espidf/extra_script.py @@ -6,8 +6,8 @@ section instead of static fields. The script runs under SCons during PIO's build and mutates the active ``Environment`` (``env.Append``, ``env.Replace``, …) — chiefly to set ``LIBPATH``/``LIBS`` per chip MCU. -ESPHome's PIO→IDF converter (``_generate_idf_component``) doesn't run -SCons, so these scripts were previously ignored and any library +ESPHome's PIO→IDF converter doesn't run SCons, so these scripts were +previously ignored and any library relying on them failed to link under ``toolchain: esp-idf``. This module provides a small shim that ``exec``s an extra-script with a fake ``env`` object, captures the common ``env.Append(...)`` calls, diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 4f0a71053d..602ff03942 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -21,13 +21,15 @@ from esphome.espidf.component import ( URLSource, _check_library_data, _collect_filtered_files, - _convert_library_to_component, + _node_key, + _normalize_dependencies, _parse_library_json, _parse_library_properties, - _process_dependencies, + _resolve_registry_version, _split_list_by_condition, generate_cmakelists_txt, generate_idf_component_yml, + generate_idf_components, ) @@ -162,43 +164,6 @@ def test_generate_cmakelists_txt_references_project_managed_components_variable( assert "${ESPHOME_PROJECT_MANAGED_COMPONENTS}" in content -def test_generate_idf_component_overwrites_bundled_files( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - esp32_idf_core: None, -) -> None: - # A library that ships its own CMakeLists.txt + idf_component.yml must - # have both replaced by ESPHome's generated content. Library authors' - # bundled IDF metadata is frequently broken (bogus REQUIRES, hard-coded - # frameworks), so we always regenerate from library.json. - from esphome.espidf.component import _generate_idf_component - - (tmp_path / "src").mkdir() - (tmp_path / "src" / "main.cpp").write_text("// dummy\n") - (tmp_path / "library.json").write_text(json.dumps({"name": "tripwire-lib"})) - (tmp_path / "CMakeLists.txt").write_text("# TRIPWIRE_BUNDLED_CMAKELISTS\n") - (tmp_path / "idf_component.yml").write_text("# TRIPWIRE_BUNDLED_MANIFEST\n") - - fake_component = IDFComponent( - "owner/tripwire-lib", "1.0.0", source=URLSource("http://dummy") - ) - fake_component.path = tmp_path - monkeypatch.setattr( - esphome.espidf.component, - "_convert_library_to_component", - lambda _lib: fake_component, - ) - monkeypatch.setattr(fake_component, "download", lambda force=False: None) - - _generate_idf_component(Library("owner/tripwire-lib", "1.0.0", None)) - - cml = (tmp_path / "CMakeLists.txt").read_text() - manifest = (tmp_path / "idf_component.yml").read_text() - assert "TRIPWIRE_BUNDLED_CMAKELISTS" not in cml - assert "TRIPWIRE_BUNDLED_MANIFEST" not in manifest - assert "idf_component_register" in cml - - def test_generate_idf_component_yml_basic(tmp_component): tmp_component.data = {"description": "test", "repository": {"url": "http://aaa"}} result = generate_idf_component_yml(tmp_component) @@ -419,200 +384,58 @@ empty= assert "empty" not in result -def test_convert_library_with_repository(): - lib = Library("name", None, "https://github.com/foo/bar.git#v1.2.3") - - result = _convert_library_to_component(lib) - - assert result.name == "foo/bar" - assert result.version == "*" - assert isinstance(result.source, GitSource) - assert result.source.ref == "v1.2.3" - - -def test_convert_library_with_branch_ref(): - lib = Library("name", None, "https://github.com/foo/bar.git#some-branch") - - result = _convert_library_to_component(lib) - - assert result.name == "foo/bar" - assert result.version == "*" - assert isinstance(result.source, GitSource) - assert result.source.ref == "some-branch" - - -def test_convert_library_missing_ref_uses_default_branch(): - """A bare URL with no #ref clones the remote's default branch. - - Matches PIO's lib_deps behavior and external_components handling -- - git.clone_or_update with ref=None leaves the depth-1 clone on - whatever branch the remote HEAD points at. - """ - lib = Library("name", None, "https://github.com/foo/bar.git") - - result = _convert_library_to_component(lib) - - assert result.name == "foo/bar" - assert result.version == "*" - assert isinstance(result.source, GitSource) - assert result.source.ref is None - - -def test_convert_library_registry(monkeypatch): - lib = Library("foo/bar", "^1.0.0", None) - - monkeypatch.setattr( - esphome.espidf.component, - "_get_package_from_pio_registry", - lambda o, n, r: ("foo", "bar", "1.2.3", "http://example.com/pkg.zip"), +def test_node_key_git_with_ref(): + key, is_git, locator = _node_key( + "name", None, "https://github.com/foo/bar.git#v1.2.3" ) - - result = _convert_library_to_component(lib) - - assert result.name == "foo/bar" - assert result.version == "1.2.3" - assert isinstance(result.source, URLSource) + assert key == "foo/bar" + assert is_git is True + assert locator == ("https://github.com/foo/bar.git", "v1.2.3") -def test_process_dependencies_adds_valid_dependency(tmp_component, monkeypatch): - tmp_component.data = { - "dependencies": [ - { - "name": "foo", - "version": "1.0", - } - ] - } - - monkeypatch.setattr( - esphome.espidf.component, - "_generate_idf_component", - lambda lib: esphome.espidf.component.IDFComponent( - lib.name, lib.version, source=URLSource("http://dummy.com") - ), +def test_node_key_git_branch_ref(): + key, is_git, locator = _node_key( + "name", None, "https://github.com/foo/bar.git#some-branch" ) - - monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None) - - _process_dependencies(tmp_component) - - assert len(tmp_component.dependencies) == 1 + assert (key, is_git, locator[1]) == ("foo/bar", True, "some-branch") -def test_process_dependencies_skips_invalid(tmp_component): - tmp_component.data = { - "dependencies": [ - {"name": "foo", "version": "1.0", "platforms": ["arduino"]}, - {"invalid": "entry"}, - ] - } - - _process_dependencies(tmp_component) - - assert tmp_component.dependencies == [] +def test_node_key_git_no_ref(): + _key, is_git, locator = _node_key("name", None, "https://github.com/foo/bar.git") + assert is_git is True + assert locator == ("https://github.com/foo/bar.git", None) -def test_process_dependencies_dict_form(tmp_component, monkeypatch): - """PIO library.json shorthand ``{"owner/Name": "version"}`` is honored. +def test_node_key_registry_owner_name(): + key, is_git, locator = _node_key("foo/bar", "^1.0.0", None) + assert (key, is_git, locator) == ("foo/bar", False, ("foo", "bar")) - Iterating a dict gives string keys, which would silently fail the - ``"name" in dependency`` substring check. Normalize to list-of-dicts - first so the dict form (used by e.g. tesla-ble for its nanopb dep) - is treated the same as the verbose list form. - """ - captured: list[Library] = [] - def fake_generate(library): - captured.append(library) - return IDFComponent( - library.name, library.version, source=URLSource("http://dummy.com") - ) +def test_node_key_registry_bare_name(): + key, is_git, locator = _node_key("bar", "1.0", None) + assert (key, is_git, locator) == ("bar", False, (None, "bar")) - tmp_component.data = { - "dependencies": { - "nanopb/Nanopb": "^0.4.91", - "BareName": "1.2.3", - } - } - monkeypatch.setattr( - esphome.espidf.component, "_generate_idf_component", fake_generate + +def test_normalize_dependencies_none(): + assert _normalize_dependencies(None) == [] + + +def test_normalize_dependencies_list_form(): + deps = [{"name": "foo", "version": "1.0"}] + assert _normalize_dependencies(deps) == [{"name": "foo", "version": "1.0"}] + + +def test_normalize_dependencies_dict_form(): + out = _normalize_dependencies({"nanopb/Nanopb": "^0.4.91", "BareName": "1.2.3"}) + assert {"name": "Nanopb", "owner": "nanopb", "version": "^0.4.91"} in out + assert {"name": "BareName", "owner": None, "version": "1.2.3"} in out + + +def test_normalize_dependencies_dict_form_nested_spec(): + out = _normalize_dependencies( + {"nanopb/Nanopb": {"version": "^0.4.91", "platforms": "espidf"}} ) - monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None) - - _process_dependencies(tmp_component) - - assert len(tmp_component.dependencies) == 2 - names = sorted(lib.name for lib in captured) - versions = sorted(lib.version for lib in captured) - assert names == ["BareName", "nanopb/Nanopb"] - assert versions == ["1.2.3", "^0.4.91"] - - -def test_process_dependencies_dict_form_with_url_value(tmp_component, monkeypatch): - """A dict-value that's a URL gets routed to ``repository`` like the list form.""" - captured: list[Library] = [] - - def fake_generate(library): - captured.append(library) - return IDFComponent(library.name, "*", source=URLSource("http://dummy.com")) - - tmp_component.data = { - "dependencies": { - "foo/Bar": "https://github.com/foo/bar.git#main", - } - } - monkeypatch.setattr( - esphome.espidf.component, "_generate_idf_component", fake_generate - ) - monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None) - - _process_dependencies(tmp_component) - - assert len(captured) == 1 - assert captured[0].name == "foo/Bar" - assert captured[0].version is None - assert captured[0].repository == "https://github.com/foo/bar.git#main" - - -def test_process_dependencies_dict_form_with_nested_spec(tmp_component, monkeypatch): - """A dict-value that's itself a dict is merged into the entry. - - PIO's library.json allows ``{"owner/Name": {"version": "...", ...}}`` - for entries that need fields beyond just a version (platforms, - frameworks, etc.). The extra fields flow into _check_library_data - via the entry merge. - """ - captured: list[Library] = [] - checked: list[dict] = [] - - def fake_generate(library): - captured.append(library) - return IDFComponent( - library.name, library.version, source=URLSource("http://dummy.com") - ) - - tmp_component.data = { - "dependencies": { - "nanopb/Nanopb": {"version": "^0.4.91", "platforms": "espidf"}, - } - } - monkeypatch.setattr( - esphome.espidf.component, "_generate_idf_component", fake_generate - ) - monkeypatch.setattr( - esphome.espidf.component, - "_check_library_data", - checked.append, - ) - - _process_dependencies(tmp_component) - - assert len(captured) == 1 - assert captured[0].name == "nanopb/Nanopb" - assert captured[0].version == "^0.4.91" - # Extra spec fields reach _check_library_data so platform/framework - # gating still applies. - assert checked == [ + assert out == [ { "name": "Nanopb", "owner": "nanopb", @@ -620,3 +443,364 @@ def test_process_dependencies_dict_form_with_nested_spec(tmp_component, monkeypa "platforms": "espidf", } ] + + +def _patch_registry(monkeypatch, versions): + """Patch the registry client to serve a canned version list (no network). + + Only ``fetch_registry_package`` is faked; the real + ``get_compatible_registry_versions`` / ``pick_best_registry_version`` run on + the canned data so the intersection logic is exercised for real. + """ + registry = esphome.espidf.component._make_registry_client() + monkeypatch.setattr( + registry, + "fetch_registry_package", + lambda spec: { + "owner": {"username": spec.owner or "owner"}, + "name": spec.name, + "versions": [ + {"name": v, "files": [{"download_url": f"http://x/{v}.tar.gz"}]} + for v in versions + ], + }, + ) + monkeypatch.setattr( + esphome.espidf.component, "_make_registry_client", lambda: registry + ) + + +def test_resolve_registry_version_intersects_constraints(monkeypatch): + _patch_registry(monkeypatch, ["1.10018.1", "1.10021.0", "1.10021.1"]) + owner, name, version, url = _resolve_registry_version( + "esphome", "libsodium", {"==1.10021.0", "^1.10018.1"} + ) + assert (owner, name, version) == ("esphome", "libsodium", "1.10021.0") + assert url == "http://x/1.10021.0.tar.gz" + + +def test_resolve_registry_version_picks_highest_satisfying(monkeypatch): + _patch_registry(monkeypatch, ["1.0.0", "1.5.0", "2.0.0"]) + _owner, _name, version, _url = _resolve_registry_version("o", "p", {"^1.0.0"}) + assert version == "1.5.0" + + +def test_resolve_registry_version_conflict_raises(monkeypatch): + _patch_registry(monkeypatch, ["1.0.0", "2.0.0"]) + with pytest.raises(RuntimeError, match="satisfies all requirements"): + _resolve_registry_version("o", "p", {"==1.0.0", "==2.0.0"}) + + +def test_generate_idf_components_dedupes_shared_dependency( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, +) -> None: + # A and B both depend on shared C under different version specs. The batch + # must resolve C once with BOTH requirements collected, wire a single C + # instance into both, and regenerate (overwrite) each library's build files. + manifests = { + "esphome/A": { + "name": "A", + "dependencies": [ + {"owner": "esphome", "name": "C", "version": "==1.10021.0"} + ], + }, + "esphome/B": { + "name": "B", + "dependencies": [ + {"owner": "esphome", "name": "C", "version": "^1.10018.1"} + ], + }, + "esphome/C": {"name": "C"}, + } + + def fake_download(self, force=False): + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + (self.path / "src").mkdir(parents=True, exist_ok=True) + (self.path / "src" / "x.c").write_text("int x;") + (self.path / "library.json").write_text(json.dumps(manifests[self.name])) + (self.path / "CMakeLists.txt").write_text("# TRIPWIRE\n") + + monkeypatch.setattr(IDFComponent, "download", fake_download) + + captured: dict[str, set[str]] = {} + resolve_calls: list[str] = [] + + def fake_resolve(owner, pkgname, requirements): + resolve_calls.append(pkgname) + captured[f"{owner}/{pkgname}"] = set(requirements) + version = "1.10021.0" if pkgname == "C" else "1.0.0" + return owner, pkgname, version, f"http://x/{pkgname}.tar.gz" + + monkeypatch.setattr( + esphome.espidf.component, "_resolve_registry_version", fake_resolve + ) + + top = generate_idf_components( + [Library("esphome/A", "1.0.0", None), Library("esphome/B", "1.0.0", None)] + ) + + # C resolved once (not once per consumer) with BOTH requirements gathered. + assert captured["esphome/C"] == {"==1.10021.0", "^1.10018.1"} + assert resolve_calls.count("C") == 1 + # Top-level components returned in request order. + assert [c.name for c in top] == ["esphome/A", "esphome/B"] + # A and B reference the SAME single C instance (deduped). + a_dep = top[0].dependencies[0] + b_dep = top[1].dependencies[0] + assert a_dep.name == "esphome/C" + assert a_dep is b_dep + # The bundled CMakeLists was overwritten with generated content. + generated = (a_dep.path / "CMakeLists.txt").read_text() + assert "TRIPWIRE" not in generated + assert "idf_component_register" in generated + + +def test_generate_idf_components_handles_dependency_cycle( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, +) -> None: + # A -> B -> A. Must terminate (not recurse forever) and wire the cycle with + # a single instance per component. + manifests = { + "esphome/A": { + "name": "A", + "dependencies": [{"owner": "esphome", "name": "B", "version": "1.0.0"}], + }, + "esphome/B": { + "name": "B", + "dependencies": [{"owner": "esphome", "name": "A", "version": "1.0.0"}], + }, + } + + def fake_download(self, force=False): + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + (self.path / "src").mkdir(parents=True, exist_ok=True) + (self.path / "src" / "x.c").write_text("int x;") + (self.path / "library.json").write_text(json.dumps(manifests[self.name])) + + monkeypatch.setattr(IDFComponent, "download", fake_download) + monkeypatch.setattr( + esphome.espidf.component, + "_resolve_registry_version", + lambda owner, pkgname, requirements: ( + owner, + pkgname, + "1.0.0", + f"http://x/{pkgname}.tar.gz", + ), + ) + + top = generate_idf_components([Library("esphome/A", "1.0.0", None)]) + + assert [c.name for c in top] == ["esphome/A"] + component_a = top[0] + component_b = component_a.dependencies[0] + assert component_b.name == "esphome/B" + # The cycle is wired back to the same A instance, not a duplicate. + assert component_b.dependencies[0] is component_a + + +def test_generate_idf_components_git_overrides_registry_warns( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, + caplog: pytest.LogCaptureFixture, +) -> None: + # A pulls shared as a registry pin; B pulls the same component from a git + # source. The git source wins, but the dropped registry pin must be warned + # about (not silently discarded). + manifests = { + "esphome/A": { + "name": "A", + "dependencies": [ + {"owner": "esphome", "name": "shared", "version": "==1.0.0"} + ], + }, + "esphome/B": { + "name": "B", + "dependencies": [ + { + "owner": "esphome", + "name": "shared", + "version": "https://github.com/esphome/shared.git#main", + } + ], + }, + "esphome/shared": {"name": "shared"}, + } + + def fake_download(self, force=False): + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + (self.path / "src").mkdir(parents=True, exist_ok=True) + (self.path / "src" / "x.c").write_text("int x;") + (self.path / "library.json").write_text(json.dumps(manifests[self.name])) + + monkeypatch.setattr(IDFComponent, "download", fake_download) + monkeypatch.setattr( + esphome.espidf.component, + "_resolve_registry_version", + lambda owner, pkgname, requirements: ( + owner, + pkgname, + "1.0.0", + f"http://x/{pkgname}.tar.gz", + ), + ) + + top = generate_idf_components( + [Library("esphome/A", "1.0.0", None), Library("esphome/B", "1.0.0", None)] + ) + + # shared resolved from the git source (version "*"), not the registry pin. + shared = top[0].dependencies[0] + assert shared.name == "esphome/shared" + assert isinstance(shared.source, GitSource) + assert "using the git source" in caplog.text + assert "==1.0.0" in caplog.text + + +def test_generate_idf_components_missing_manifest_raises( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, +) -> None: + # A library with neither library.json nor library.properties is invalid; + # fail loudly rather than silently generating build files for it. + def fake_download(self, force=False): + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + (self.path / "src").mkdir(parents=True, exist_ok=True) + # no library.json / library.properties written + + monkeypatch.setattr(IDFComponent, "download", fake_download) + monkeypatch.setattr( + esphome.espidf.component, + "_resolve_registry_version", + lambda owner, pkgname, requirements: ( + owner, + pkgname, + "1.0.0", + f"http://x/{pkgname}.tar.gz", + ), + ) + + with pytest.raises(RuntimeError, match="missing library.json"): + generate_idf_components([Library("esphome/A", "1.0.0", None)]) + + +def test_generate_idf_components_warns_on_noncanonical_duplicate( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, + caplog: pytest.LogCaptureFixture, +) -> None: + # A references "shared" (bare) and B references "owner/shared"; both resolve + # to the same canonical name but as distinct graph nodes, so they aren't + # deduplicated -- warn about it. + manifests = { + "esphome/A": { + "name": "A", + "dependencies": [{"name": "shared", "version": "1.0.0"}], + }, + "esphome/B": { + "name": "B", + "dependencies": [{"owner": "owner", "name": "shared", "version": "1.0.0"}], + }, + "owner/shared": {"name": "shared"}, + } + + def fake_download(self, force=False): + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + (self.path / "src").mkdir(parents=True, exist_ok=True) + (self.path / "src" / "x.c").write_text("int x;") + (self.path / "library.json").write_text(json.dumps(manifests[self.name])) + + monkeypatch.setattr(IDFComponent, "download", fake_download) + # Bare "shared" and "owner/shared" both resolve to canonical owner/shared. + monkeypatch.setattr( + esphome.espidf.component, + "_resolve_registry_version", + lambda owner, pkgname, requirements: ( + owner or "owner", + pkgname, + "1.0.0", + f"http://x/{pkgname}.tar.gz", + ), + ) + + generate_idf_components( + [Library("esphome/A", "1.0.0", None), Library("esphome/B", "1.0.0", None)] + ) + + assert "referenced under multiple names" in caplog.text + + +def test_generate_idf_components_incompatible_top_level_raises( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, +) -> None: + # A top-level library that isn't ESP-IDF/esp32 compatible must fail fast, + # not be silently dropped. + def fake_download(self, force=False): + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + (self.path / "src").mkdir(parents=True, exist_ok=True) + (self.path / "library.json").write_text( + json.dumps({"name": "A", "platforms": ["espressif8266"]}) + ) + + monkeypatch.setattr(IDFComponent, "download", fake_download) + monkeypatch.setattr( + esphome.espidf.component, + "_resolve_registry_version", + lambda owner, pkgname, requirements: ( + owner, + pkgname, + "1.0.0", + f"http://x/{pkgname}.tar.gz", + ), + ) + + with pytest.raises(RuntimeError, match="not compatible with ESP-IDF"): + generate_idf_components([Library("esphome/A", "1.0.0", None)]) + + +def test_generate_idf_components_incompatible_dependency_skipped( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, +) -> None: + # An incompatible *transitive* dependency is skipped (not fatal): A is fine, + # its esp8266-only dep B is dropped and not wired. + manifests = { + "esphome/A": { + "name": "A", + "dependencies": [{"owner": "esphome", "name": "B", "version": "1.0.0"}], + }, + "esphome/B": {"name": "B", "platforms": ["espressif8266"]}, + } + + def fake_download(self, force=False): + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + (self.path / "src").mkdir(parents=True, exist_ok=True) + (self.path / "library.json").write_text(json.dumps(manifests[self.name])) + + monkeypatch.setattr(IDFComponent, "download", fake_download) + monkeypatch.setattr( + esphome.espidf.component, + "_resolve_registry_version", + lambda owner, pkgname, requirements: ( + owner, + pkgname, + "1.0.0", + f"http://x/{pkgname}.tar.gz", + ), + ) + + top = generate_idf_components([Library("esphome/A", "1.0.0", None)]) + + assert [c.name for c in top] == ["esphome/A"] + # The incompatible dependency was dropped, not wired in. + assert top[0].dependencies == [] From 1734dc85d21ab4691290cb5fa3fce13ceef8d4b1 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:42:01 +1000 Subject: [PATCH 0308/1815] [const][animation][dfplayer] Extract CONF_LOOP to const (#16797) --- esphome/components/animation/__init__.py | 2 +- esphome/components/const/__init__.py | 1 + esphome/components/dfplayer/__init__.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/animation/__init__.py b/esphome/components/animation/__init__.py index e9630f5266..9c9c7e3871 100644 --- a/esphome/components/animation/__init__.py +++ b/esphome/components/animation/__init__.py @@ -2,6 +2,7 @@ import logging from esphome import automation import esphome.codegen as cg +from esphome.components.const import CONF_LOOP import esphome.components.image as espImage import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_REPEAT @@ -14,7 +15,6 @@ DEPENDENCIES = ["display"] MULTI_CONF = True MULTI_CONF_NO_DEFAULT = True -CONF_LOOP = "loop" CONF_START_FRAME = "start_frame" CONF_END_FRAME = "end_frame" CONF_FRAME = "frame" diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 3f7777883e..ebb4186a2b 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -15,6 +15,7 @@ CONF_DRAW_ROUNDING = "draw_rounding" CONF_ENABLED = "enabled" CONF_IGNORE_NOT_FOUND = "ignore_not_found" CONF_LIBRETINY = "libretiny" +CONF_LOOP = "loop" CONF_ON_PACKET = "on_packet" CONF_ON_RECEIVE = "on_receive" CONF_ON_STATE_CHANGE = "on_state_change" diff --git a/esphome/components/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py index 7796f5d891..d589381461 100644 --- a/esphome/components/dfplayer/__init__.py +++ b/esphome/components/dfplayer/__init__.py @@ -1,6 +1,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import uart +from esphome.components.const import CONF_LOOP import esphome.config_validation as cv from esphome.const import CONF_DEVICE, CONF_FILE, CONF_ID, CONF_VOLUME @@ -15,7 +16,6 @@ DFPlayerIsPlayingCondition = dfplayer_ns.class_( MULTI_CONF = True CONF_FOLDER = "folder" -CONF_LOOP = "loop" CONF_EQ_PRESET = "eq_preset" CONF_ON_FINISHED_PLAYBACK = "on_finished_playback" From c765e22622856c1150b5884d1bb477fee3f919ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Jun 2026 10:39:54 -0500 Subject: [PATCH 0309/1815] [ci] Exclude device-builder slow e2e tests from downstream CI (#16801) --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca1fb07fda..9f227b37a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -189,9 +189,12 @@ jobs: - name: Run device-builder pytest # ``-n auto`` runs under pytest-xdist (matches device-builder's # own CI). No ``--cov`` here -- this is purely a downstream - # smoke check against this PR's esphome code. + # smoke check against this PR's esphome code. ``tests/e2e/slow`` + # is excluded: those are real multi-minute toolchain compiles + # (LibreTiny SDK clone, native ESP-IDF install) that device-builder + # runs in its own dedicated jobs, not this smoke check. working-directory: device-builder - run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks + run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks --ignore=tests/e2e/slow pytest: name: Run pytest From 148a5ba68ea468eeb1d6a201d204314ef8da011b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:47:42 -0400 Subject: [PATCH 0310/1815] [esp32] Run clang-tidy via the native ESP-IDF toolchain (#16748) --- .clang-tidy.hash | 2 +- esphome/espidf/clang_tidy.py | 440 +++++++++++++++++++++++++++++++++++ script/clang-tidy | 26 ++- script/helpers.py | 40 ++-- sdkconfig.defaults | 15 +- 5 files changed, 491 insertions(+), 32 deletions(-) create mode 100644 esphome/espidf/clang_tidy.py diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 648b31f8f0..c007df6b9d 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -44db8a62d94c8fba83b95b73938db4377ebacc0adb504881387389f1cd8f2f3a +0550a8ea4182dbc007660de060dd023ce22c865c8e95040a36f3d07a5b354fc6 diff --git a/esphome/espidf/clang_tidy.py b/esphome/espidf/clang_tidy.py new file mode 100644 index 0000000000..2cfbe67a70 --- /dev/null +++ b/esphome/espidf/clang_tidy.py @@ -0,0 +1,440 @@ +"""Generate clang-tidy idedata via the native ESP-IDF toolchain. + +Produces idedata for clang-tidy **without an ESPHome YAML config**. Instead of +running codegen on a config, it generates a minimal ESP-IDF CMake project: + +* the managed-component dependencies come from ESPHome's own + ``idf_component.yml`` (arduinojson, lvgl, mdns, ...); +* the PlatformIO ``lib_deps`` (qr-code, mlx90393, ...) are converted to local + IDF components via the ESPHome PlatformIO->IDF converter; +* the ``main`` component ``REQUIRES`` every target-available builtin IDF + component, so their public include dirs land on the translation unit; +* the repo ``sdkconfig.defaults`` enables sdkconfig-gated components (bt, ...). + +then runs ``idf.py reconfigure`` (configure only, no compile) and reads the +resulting ``build/compile_commands.json``. The IDF version is the esp32 +component's recommended version. + +``ESPHOME_IDF_COMPILE_COMMANDS`` may point at an existing build's +``compile_commands.json`` to skip generation (fast iteration). +""" + +from dataclasses import dataclass +import os +from pathlib import Path + +TIDY_PROJECT_NAME = "esphome_tidy" + +# A do-nothing C++ app: just enough for IDF to configure a valid project. It's +# C++ (not C) so the compile command uses the C++ compiler and flags, matching +# how clang-tidy analyzes ESPHome's C++ sources. +_TIDY_MAIN_CPP = 'extern "C" void app_main() {}\n' + + +@dataclass(frozen=True) +class _Settings: + """Per-environment build settings derived from the tidy env name. + + The platform defines below are what a real ESPHome build adds via + cg.add_define; defines.h only *consumes* them, so without them + esphome/core/hal.h errors with "not implemented for this platform". + """ + + idf_target: str # esp32, esp32s3, ... + variant: str # ESP32, ESP32S3, ... + idf_version: str # ESP-IDF version to build with + target_framework: str # "espidf" or "arduino" + platform_defines: tuple[str, ...] + # Extra idf_component.yml deps the framework needs (e.g. arduino-esp32). + framework_deps: dict[str, dict] + + +def _settings_for(environment: str) -> _Settings: + """Derive build settings from a ``--tidy`` env name. + + Arduino on esp32 is itself a native ESP-IDF build with the + ``espressif/arduino-esp32`` component added, so both frameworks use this + path -- only the defines, IDF version, and that one component differ. + """ + from esphome.components.esp32 import ( + ARDUINO_FRAMEWORK_VERSION_LOOKUP, + ARDUINO_IDF_VERSION_LOOKUP, + ESP_IDF_FRAMEWORK_VERSION_LOOKUP, + ) + + parts = environment.split("-") + if len(parts) != 3 or parts[2] != "tidy" or parts[1] not in ("idf", "arduino"): + raise ValueError( + f"Unsupported clang-tidy environment {environment!r}: expected " + "--tidy with framework 'idf' or 'arduino' " + "(e.g. esp32-idf-tidy, esp32s3-arduino-tidy)" + ) + idf_target, framework, _ = parts + variant = idf_target.upper() + # Defines shared by both frameworks. ESPHOME_LOG_LEVEL must be set up front + # (as the PlatformIO tidy build_flags do) -- otherwise log.h's ``#ifndef`` + # sets it to NONE before defines.h redefines it, a macro-redefined warning + # across nearly every source. + common_defines = ( + "USE_ESP32", + f"USE_ESP32_VARIANT_{variant}", + "ESPHOME_LOG_LEVEL=ESPHOME_LOG_LEVEL_VERY_VERBOSE", + ) + + if framework == "arduino": + fw_version = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"] + return _Settings( + idf_target=idf_target, + variant=variant, + idf_version=str(ARDUINO_IDF_VERSION_LOOKUP[fw_version]), + target_framework="arduino", + platform_defines=( + *common_defines, + "USE_ARDUINO", + "USE_ESP32_FRAMEWORK_ARDUINO", + ), + framework_deps=_arduino_framework_deps(str(fw_version)), + ) + return _Settings( + idf_target=idf_target, + variant=variant, + idf_version=str(ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"]), + target_framework="espidf", + platform_defines=( + *common_defines, + "USE_ESP_IDF", + "USE_ESP32_FRAMEWORK_ESP_IDF", + ), + framework_deps={}, + ) + + +def _arduino_framework_deps(version: str) -> dict[str, dict]: + """Arduino-only managed deps merged on top of esphome/idf_component.yml. + + arduino-esp32 provides Arduino.h and the arduino libraries; its version is + the recommended arduino framework version so the tidy build matches what + ESPHome ships. + """ + from esphome.components.esp32 import ARDUINO_ESP32_COMPONENT_NAME + + return {ARDUINO_ESP32_COMPONENT_NAME: {"version": version}} + + +_TOP_CMAKELISTS = """\ +# Auto-generated by ESPHome (clang-tidy idedata project) +cmake_minimum_required(VERSION 3.16) +set(IDF_TARGET {idf_target}) +include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) +{compile_options} +project({name}) +""" + +_MAIN_CMAKELISTS = """\ +# Auto-generated by ESPHome (clang-tidy idedata project) +idf_component_register( + SRCS "tidy.cpp" + REQUIRES {requires} +) +""" + + +def _setup_core(work_dir: Path, settings: _Settings) -> None: + """Point CORE at the tidy project + IDF version, without any YAML config.""" + from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION, KEY_VARIANT + import esphome.config_validation as cv + from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM + from esphome.core import CORE + + CORE.name = TIDY_PROJECT_NAME + # config_path's parent is the data dir root: the IDF install lives at + # ``/.esphome/idf`` -- keep it beside (not inside) the per-run + # project dir so clearing the project doesn't force an IDF re-download. + CORE.config_path = work_dir.parent / "tidy.yaml" + CORE.build_path = work_dir + esp32 = CORE.data.setdefault(KEY_ESP32, {}) + esp32[KEY_IDF_VERSION] = cv.Version.parse(settings.idf_version) + esp32[KEY_VARIANT] = settings.variant + # The target framework drives the PlatformIO-library -> IDF-component + # converter and ESPHome's CORE.using_arduino / using_esp_idf helpers. + CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = "esp32" + CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = settings.target_framework + + +# Special IDF "components" that are tools/subprojects, not requirable by an app +# (they provide no public includes and break requirement resolution), plus our +# own ``main``. +_NON_REQUIRABLE_COMPONENTS = frozenset( + {"bootloader", "esptool_py", "partition_table", "main"} +) + + +def _parse_lib_deps(platformio_ini: Path, framework: str): + """Parse the framework's ``lib_deps`` from platformio.ini into Library specs. + + These are the PlatformIO libraries ESPHome components pull in via + ``cg.add_library``. The set is framework-specific: the arduino envs add + libs (FastLED, NeoPixelBus, MideaUART, ...) the idf envs don't. We read the + relevant ``[common*]`` sections directly (resolving the env's ``extends`` + chain) and skip the ``${...}`` cross-references and non-library entries. + """ + import configparser + + from esphome.core import Library + + parser = configparser.ConfigParser(interpolation=None, strict=False) + parser.read(platformio_ini) + + sections = [("common", "lib_deps_base"), ("common", "lib_deps")] + if framework == "arduino": + sections += [ + ("common:arduino", "lib_deps"), + ("common:esp32-arduino", "lib_deps"), + ] + else: + sections += [ + ("common:idf", "lib_deps"), + ("common:esp32-idf", "lib_deps"), + ] + + tokens: list[str] = [] + for section, key in sections: + if parser.has_option(section, key): + tokens += parser.get(section, key).splitlines() + + libs: list[Library] = [] + seen: set[str] = set() + for token in tokens: + token = token.split(";", 1)[0].strip() # drop trailing ; comment + # Skip blanks, ${...} cross-refs, and +<...> source filters. + if not token or token.startswith(("${", "+<")) or token in seen: + continue + seen.add(token) + if "://" in token or ".git" in token: + libs.append(Library(token, None, token)) # git repository (with #ref) + elif "@" in token: + name, _, version = token.partition("@") + libs.append(Library(name, version)) + # A bare name (SPI, Wire, WiFi, Networking, "ESP32 Async UDP", ...) is an + # Arduino framework built-in provided by arduino-esp32, not a convertible + # registry library (no owner/version), so skip it. + return libs + + +def _convert_pio_libs( + platformio_ini: Path, framework: str +) -> dict[str, dict[str, str]]: + """Convert the PlatformIO libs to IDF components; return manifest deps. + + Returns a mapping suitable for an ``idf_component.yml`` ``dependencies`` + block (``{name: {"override_path": }}``), reusing + ESPHome's own PlatformIO->IDF converter (registry/git resolution, no pio). + + The whole library set is resolved as a single batch so a shared transitive + dependency (e.g. esphome/libsodium pulled by both noise-c and esp_wireguard) + is deduplicated to one component instead of clashing override_path entries. + """ + from esphome.espidf.component import generate_idf_components + + libraries = _parse_lib_deps(platformio_ini, framework) + deps: dict[str, dict[str, str]] = {} + for component in generate_idf_components(libraries): + deps[component.get_sanitized_name()] = {"override_path": str(component.path)} + return deps + + +def _arduino_excluded_stubs(work_dir: Path) -> dict[str, dict]: + """Stub the arduino-bundled IDF components ESPHome doesn't use. + + arduino-esp32 declares deps (libsodium, RainMaker, modbus, ...) that ESPHome + replaces with its own library (noise-c) or doesn't use; point each at an + empty override_path component so the IDF manager doesn't resolve/download + them -- notably so ``espressif/libsodium`` doesn't clash with the converted + noise-c's ``libsodium``. Mirrors esp32's ``_write_idf_component_yml``. + + Components ESPHome's own idf_component.yml provides (e.g. lan867x for + ethernet) are NOT stubbed -- those are real deps we need, and arduino-esp32 + resolves to the same component rather than conflicting. + """ + import yaml + + from esphome.components.esp32 import ( + ARDUINO_EXCLUDED_IDF_COMPONENTS, + _idf_component_dep_name, + _idf_component_stub_name, + ) + + esphome_dir = Path(__file__).resolve().parent.parent + base_manifest = yaml.safe_load( + (esphome_dir / "idf_component.yml").read_text(encoding="utf-8") + ) + esphome_deps = set(base_manifest.get("dependencies") or {}) + + stubs_dir = work_dir / "component_stubs" + stubs_dir.mkdir(parents=True, exist_ok=True) + deps: dict[str, dict] = {} + for component in sorted(ARDUINO_EXCLUDED_IDF_COMPONENTS): + if _idf_component_dep_name(component) in esphome_deps: + continue # ESPHome needs this one for real (don't stub it away) + stub_path = stubs_dir / _idf_component_stub_name(component) + stub_path.mkdir(exist_ok=True) + (stub_path / "CMakeLists.txt").write_text( + "idf_component_register()\n", encoding="utf-8" + ) + deps[_idf_component_dep_name(component)] = { + "version": "*", + "override_path": str(stub_path), + } + return deps + + +def _write_tidy_project( + work_dir: Path, + requires: list[str], + extra_deps: dict[str, dict[str, str]], + settings: _Settings, +) -> None: + """Generate the minimal IDF CMake project (top + main + idf_component.yml).""" + main_dir = work_dir / "main" + main_dir.mkdir(parents=True, exist_ok=True) + + compile_options = "\n".join( + f'idf_build_set_property(COMPILE_OPTIONS "-D{define}" APPEND)' + for define in settings.platform_defines + ) + (work_dir / "CMakeLists.txt").write_text( + _TOP_CMAKELISTS.format( + name=TIDY_PROJECT_NAME, + compile_options=compile_options, + idf_target=settings.idf_target, + ), + encoding="utf-8", + ) + (main_dir / "CMakeLists.txt").write_text( + _MAIN_CMAKELISTS.format(requires=" ".join(requires)), encoding="utf-8" + ) + (main_dir / "tidy.cpp").write_text(_TIDY_MAIN_CPP, encoding="utf-8") + + # Managed components: ESPHome's own manifest (arduinojson, lvgl, mdns, ...), + # plus the converted PlatformIO libs as local (override_path) deps. Placing + # it in main/ makes every dep a requirement of the main component, so their + # public includes land on the tidy translation unit. + import yaml + + esphome_dir = Path(__file__).resolve().parent.parent # esphome/espidf -> esphome + manifest = yaml.safe_load( + (esphome_dir / "idf_component.yml").read_text(encoding="utf-8") + ) + manifest.setdefault("dependencies", {}).update(extra_deps) + (main_dir / "idf_component.yml").write_text( + yaml.safe_dump(manifest, sort_keys=False), encoding="utf-8" + ) + + # ESPHome's static-analysis sdkconfig (repo root): enables the flags any + # component sets (e.g. CONFIG_BT_ENABLED) so sdkconfig-gated IDF components + # register and expose their includes. IDF reads ``sdkconfig.defaults`` from + # the project root. + (work_dir / "sdkconfig.defaults").write_text( + (esphome_dir.parent / "sdkconfig.defaults").read_text(encoding="utf-8"), + encoding="utf-8", + ) + + +def _generate_compile_commands( + work_dir: Path, settings: _Settings, platformio_ini: Path +) -> Path: + """Generate the tidy project and run ``idf.py reconfigure`` (no build). + + Two-phase, like a real ESPHome build: a first configure with no builtin + requires discovers which components actually register for the target (e.g. + ``esp_tee`` only registers on c5/c6/h2), then a second configure requires + that discovered set so their public includes reach the tidy TU. + """ + import logging + + from esphome.build_gen.espidf import get_available_components + from esphome.espidf import toolchain + + # Surface ESPHome's INFO logs (ESP-IDF framework download/extract/install, + # git-library clones) -- they go through logging, which the clang-tidy + # script otherwise leaves at WARNING so the first-run downloads look silent. + logging.basicConfig(level=logging.INFO, format="%(message)s") + + _setup_core(work_dir, settings) + + # Framework deps (e.g. arduino-esp32) + PlatformIO libs converted to local + # IDF components, all added to the manifest as deps. + extra_deps = dict(settings.framework_deps) + extra_deps.update(_convert_pio_libs(platformio_ini, settings.target_framework)) + if settings.target_framework == "arduino": + # Stub the arduino-bundled components ESPHome doesn't use (avoids the + # libsodium clash with noise-c and ~26 unused heavy downloads). + extra_deps.update(_arduino_excluded_stubs(work_dir)) + + # Phase 1: discover the components available for this target. + _write_tidy_project(work_dir, [], extra_deps, settings) + if toolchain.run_reconfigure() != 0: + raise RuntimeError("idf.py reconfigure (discovery) failed") + + requires = sorted( + set(get_available_components() or []) - _NON_REQUIRABLE_COMPONENTS + ) + + # Phase 2: require every available builtin component. + _write_tidy_project(work_dir, requires, extra_deps, settings) + if toolchain.run_reconfigure() != 0: + raise RuntimeError("idf.py reconfigure failed") + + return work_dir / "build" / "compile_commands.json" + + +def _idedata_from_tidy_project(compile_commands: Path) -> dict: + """Assemble idedata from the single tidy translation unit. + + Unlike a real ESPHome build (many ``/src/esphome/`` TUs unioned), the tidy + project has one TU (``main/tidy.cpp``) that -- by requiring every component -- + already carries the full include set, so we parse it directly. + """ + import json + + from esphome.espidf.idedata import _get_toolchain_includes, _parse_entry + + entries = json.loads(Path(compile_commands).read_text(encoding="utf-8")) + entry = next((e for e in entries if e["file"].endswith("tidy.cpp")), None) + if entry is None: + raise RuntimeError(f"tidy.cpp not found in {compile_commands}") + cxx_path, defines, includes, cxx_flags = _parse_entry(entry) + + return { + "cxx_path": cxx_path, + "cxx_flags": cxx_flags, + "defines": defines, + "includes": { + "build": includes, + "toolchain": _get_toolchain_includes(cxx_path), + }, + } + + +def load_idedata(environment: str, temp_folder: str, platformio_ini: Path) -> dict: + if explicit := os.environ.get("ESPHOME_IDF_COMPILE_COMMANDS"): + compile_commands = Path(explicit) + else: + # The tidy env is ``--tidy`` (e.g. esp32-idf-tidy, + # esp32s3-arduino-tidy); derive the target, variant and framework. + settings = _settings_for(environment) + # Resolve to an absolute path: ``override_path`` entries in the generated + # component manifests are interpreted by the IDF component manager relative + # to the manifest's own directory, so a relative work dir would be + # mis-resolved (doubled under ``main/``). + work_dir = ( + Path(temp_folder) + / f"idf-tidy-{settings.idf_target}-{settings.target_framework}" + ).resolve() + compile_commands = _generate_compile_commands( + work_dir, settings, platformio_ini + ) + + if not compile_commands.is_file(): + raise RuntimeError(f"compile_commands.json not found: {compile_commands}") + return _idedata_from_tidy_project(compile_commands) diff --git a/script/clang-tidy b/script/clang-tidy index 56c0a9db71..ce266e2382 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -28,6 +28,12 @@ from helpers import ( temp_header_file, ) +# Limit the ESP-IDF tool install to esp32 for clang-tidy: the one xtensa-esp-elf +# toolchain bundles the s2/s3 compilers too, so all xtensa tidy envs still +# reconfigure while the large riscv32-esp-elf toolchain is skipped. Must be set +# before esphome.espidf.framework is imported (lazily, via load_idedata). +os.environ.setdefault("ESPHOME_IDF_DEFAULT_TARGETS", "esp32") + def clang_options(idedata): cmd = [] @@ -52,6 +58,10 @@ def clang_options(idedata): "-mfix-esp32-psram-cache-issue", "-mfix-esp32-psram-cache-strategy=memw", "-fno-tree-switch-conversion", + # GCC-only flags emitted by the native ESP-IDF toolchain build + "-freorder-blocks", + "-fno-jump-tables", + "-fno-shrink-wrap", ) if "zephyr" in triplet: @@ -97,8 +107,20 @@ def clang_options(idedata): ] ) - # copy compiler flags, except those clang doesn't understand. - cmd.extend(flag for flag in idedata["cxx_flags"] if flag not in omit_flags) + # Copy compiler flags, dropping: ones clang doesn't understand; -Werror* + # (clang-tidy enforces .clang-tidy's WarningsAsErrors, and a build -Werror + # would bypass the -clang-diagnostic-* suppressions); and -std= (the native + # ESP-IDF build defaults to gnu++2b, but ESPHome compiles with gnu++20 per + # platformio.ini -- analyzing as C++23 flags code that doesn't build under + # gnu++20). Force gnu++20 to match the real build. + cmd.extend( + flag + for flag in idedata["cxx_flags"] + if flag not in omit_flags + and not flag.startswith("-Werror") + and not flag.startswith("-std=") + ) + cmd.append("-std=gnu++20") # defines cmd.extend(f"-D{define}" for define in idedata["defines"]) diff --git a/script/helpers.py b/script/helpers.py index 1ebfe405a7..8b6751c1d3 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -664,26 +664,22 @@ def load_idedata(environment: str) -> dict[str, Any]: start_time = time.time() print(f"Loading IDE data for environment '{environment}'...") - platformio_ini = Path(root_path) / "platformio.ini" + # Reuse the clang-tidy input hash as the cache key: it already covers every + # file baked into the generated idedata (platformio.ini, sdkconfig.defaults, + # esphome/idf_component.yml), so this can't drift from that file list. A + # content hash -- unlike an mtime comparison -- stays correct across git + # checkouts, which don't preserve mtimes. + from clang_tidy_hash import calculate_clang_tidy_hash + temp_idedata = Path(temp_folder) / f"idedata-{environment}.json" - changed = False - if ( - not platformio_ini.is_file() - or not temp_idedata.is_file() - or platformio_ini.stat().st_mtime >= temp_idedata.stat().st_mtime - ): - changed = True + temp_hash = Path(temp_folder) / f"idedata-{environment}.hash" - if "idf" in environment: - # remove full sdkconfig when the defaults have changed so that it is regenerated - default_sdkconfig = Path(root_path) / "sdkconfig.defaults" - temp_sdkconfig = Path(temp_folder) / f"sdkconfig-{environment}" - - if not temp_sdkconfig.is_file(): - changed = True - elif default_sdkconfig.stat().st_mtime >= temp_sdkconfig.stat().st_mtime: - temp_sdkconfig.unlink() - changed = True + cache_key = calculate_clang_tidy_hash() + changed = ( + not temp_idedata.is_file() + or not temp_hash.is_file() + or temp_hash.read_text().strip() != cache_key + ) if not changed: data = json.loads(temp_idedata.read_text()) @@ -694,7 +690,12 @@ def load_idedata(environment: str) -> dict[str, Any]: # ensure temp directory exists before running pio, as it writes sdkconfig to it Path(temp_folder).mkdir(exist_ok=True) - if "nrf" in environment: + platformio_ini = Path(root_path) / "platformio.ini" + if "esp32" in environment: + from esphome.espidf.clang_tidy import load_idedata as idf_load_idedata + + data = idf_load_idedata(environment, temp_folder, platformio_ini) + elif "nrf" in environment: from helpers_zephyr import load_idedata as zephyr_load_idedata data = zephyr_load_idedata(environment, temp_folder, platformio_ini) @@ -705,6 +706,7 @@ def load_idedata(environment: str) -> dict[str, Any]: match = re.search(r'{\s*".*}', stdout.decode("utf-8")) data = json.loads(match.group()) temp_idedata.write_text(json.dumps(data, indent=2) + "\n") + temp_hash.write_text(cache_key + "\n") elapsed = time.time() - start_time print(f"IDE data generated and cached in {elapsed:.2f} seconds") diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 2996490295..b277ed18d0 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -1,15 +1,11 @@ -# ESP-IDF sdkconfig defaults used for development purposes only, not used during runtime. Used when PlatformIO is ran -# directly from the source directory, e.g. by IDEs or for static analysis (clang-tidy). This should enable all flags -# that are set by any component. +# ESP-IDF sdkconfig defaults used for development purposes only, not used during runtime. Used for static analysis +# (clang-tidy) -- by both the PlatformIO and the native ESP-IDF toolchain paths -- and when PlatformIO is run directly +# from the source directory (e.g. by IDEs). This should enable all flags that are set by any component. # esp32 -CONFIG_COMPILER_OPTIMIZATION_DEFAULT=n CONFIG_COMPILER_OPTIMIZATION_SIZE=y -CONFIG_PARTITION_TABLE_CUSTOM=y -#CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" -CONFIG_PARTITION_TABLE_SINGLE_APP=n CONFIG_FREERTOS_HZ=1000 -CONFIG_ESP_TASK_WDT=y +CONFIG_ESP_TASK_WDT_INIT=y CONFIG_ESP_TASK_WDT_PANIC=y CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=n CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=n @@ -18,8 +14,7 @@ CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=n CONFIG_BT_ENABLED=y # esp32_camera -CONFIG_RTCIO_SUPPORT_RTC_GPIO_DESC=y -CONFIG_ESP32_SPIRAM_SUPPORT=y +CONFIG_SPIRAM=y # zigbee CONFIG_ZB_ENABLED=y From 419bde18b05fb0e162159f2ee171fbf26d9e6047 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 4 Jun 2026 16:24:47 -0400 Subject: [PATCH 0311/1815] [audio] Bump esp-audio-libs to v3.2.0 (#16806) --- .clang-tidy.hash | 2 +- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index c007df6b9d..c3604e7ef2 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -0550a8ea4182dbc007660de060dd023ce22c865c8e95040a36f3d07a5b354fc6 +adf1b0ed175c64877f959b14ff1ff8d3ba0d15bafcd86fab85a66f1d5ce953e8 diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index c9775ab601..c051d70f3d 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -335,7 +335,7 @@ async def to_code(config): add_idf_component( name="esphome/esp-audio-libs", - ref="3.1.0", + ref="3.2.0", ) data = _get_data() diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 9476b38b72..4190c80027 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -2,7 +2,7 @@ dependencies: bblanchon/arduinojson: version: "7.4.2" esphome/esp-audio-libs: - version: 3.1.0 + version: 3.2.0 esphome/esp-micro-speech-features: version: 1.2.3 esphome/micro-decoder: From e2459a39235a18b0f75ce6dfd3d6d5e9792ae6d4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:57:56 -0400 Subject: [PATCH 0312/1815] [clang-tidy] Support RISC-V targets natively (#16809) --- script/clang-tidy | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/script/clang-tidy b/script/clang-tidy index ce266e2382..47f59e62a4 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -28,12 +28,6 @@ from helpers import ( temp_header_file, ) -# Limit the ESP-IDF tool install to esp32 for clang-tidy: the one xtensa-esp-elf -# toolchain bundles the s2/s3 compilers too, so all xtensa tidy envs still -# reconfigure while the large riscv32-esp-elf toolchain is skipped. Must be set -# before esphome.espidf.framework is imported (lazily, via load_idedata). -os.environ.setdefault("ESPHOME_IDF_DEFAULT_TARGETS", "esp32") - def clang_options(idedata): cmd = [] @@ -46,7 +40,14 @@ def clang_options(idedata): cmd.append("-D__XTENSA__") cmd.append("-D_LIBC") else: + # RISC-V (and other non-Xtensa targets) have a real clang backend, so + # compile for the actual triplet. Espressif's RISC-V GCC -march adds + # vendor extensions (xesploop, xespv) upstream clang doesn't know; those + # are stripped from the copied cxx_flags below. cmd.append(f"--target={triplet}") + # The GCC build passes flags (e.g. -fno-plt) that clang accepts for some + # targets but not others; don't error on the ones unused for this target. + cmd.append("-Qunused-arguments") omit_flags = ( "-free", @@ -113,8 +114,15 @@ def clang_options(idedata): # ESP-IDF build defaults to gnu++2b, but ESPHome compiles with gnu++20 per # platformio.ini -- analyzing as C++23 flags code that doesn't build under # gnu++20). Force gnu++20 to match the real build. + # Strip Espressif's non-standard RISC-V -march extensions (e.g. xesploop, + # xespv); clang rejects the whole arch string otherwise. + def strip_esp_march(flag): + if flag.startswith("-march=") and triplet.startswith("riscv"): + return re.sub(r"_xesp\w+", "", flag) + return flag + cmd.extend( - flag + strip_esp_march(flag) for flag in idedata["cxx_flags"] if flag not in omit_flags and not flag.startswith("-Werror") From 5288767abf18d9109b0e821f7e70a9f9c234d67c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:58:22 -0400 Subject: [PATCH 0313/1815] [clang-tidy] Add --exclude-grep to skip files by content (#16813) --- script/clang-tidy | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/script/clang-tidy b/script/clang-tidy index 47f59e62a4..633b8d4b7d 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -247,6 +247,12 @@ def main(): action="append", help="only run on files containing value", ) + parser.add_argument( + "-x", + "--exclude-grep", + action="append", + help="skip files containing value", + ) parser.add_argument( "--split-num", type=int, help="split the files into X jobs.", default=None ) @@ -281,6 +287,10 @@ def main(): if args.grep: files = filter_grep(files, args.grep) + if args.exclude_grep: + excluded = set(filter_grep(files, args.exclude_grep)) + files = [f for f in files if f not in excluded] + files.sort() if args.split_num: From d2c388f8934f4508756b08684648bb2f374a716d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:01:00 -0400 Subject: [PATCH 0314/1815] [ota][logger][esp32][internal_temperature] Fix clang-tidy findings surfaced by RISC-V analysis (#16811) --- esphome/components/esp32/crash_handler.cpp | 5 +++++ .../internal_temperature/internal_temperature.h | 11 +++++++++++ .../internal_temperature_esp32.cpp | 12 +++--------- esphome/components/logger/logger_esp32.cpp | 4 ++-- esphome/components/ota/ota_backend_esp_idf.h | 3 ++- 5 files changed, 23 insertions(+), 12 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index ed61b61936..a7de48a6ee 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -41,6 +41,7 @@ static inline bool is_return_addr(uint32_t addr) { // Use memcpy for alignment safety — RISC-V C extension means code addresses // are only 2-byte aligned, so addr-4 may not be 4-byte aligned. uint32_t inst; + // NOLINTNEXTLINE(performance-no-int-to-ptr) - reading code memory at a raw address is the point memcpy(&inst, (const void *) (addr - 4), sizeof(inst)); // RISC-V instruction encoding: bits [6:0] = opcode, bits [11:7] = rd uint32_t opcode = inst & 0x7f; // Extract 7-bit opcode @@ -51,6 +52,7 @@ static inline bool is_return_addr(uint32_t addr) { // Check for 2-byte compressed c.jalr before this address (C extension). // c.jalr saves to ra implicitly: funct4=1001, rs1!=0, rs2=0, op=10 if (addr >= 2) { + // NOLINTNEXTLINE(performance-no-int-to-ptr) - reading code memory at a raw address is the point uint16_t c_inst = *(uint16_t *) (addr - 2); if ((c_inst & 0xf07f) == 0x9002 && (c_inst & 0x0f80) != 0) return true; @@ -101,6 +103,7 @@ static uint8_t IRAM_ATTR capture_riscv_backtrace(RvExcFrame *frame, uint32_t *ou out[count++] = frame->ra; } *reg_count = count; + // NOLINTNEXTLINE(performance-no-int-to-ptr) - walking the raw stack by address is the point auto *scan_start = (uint32_t *) frame->sp; for (uint32_t i = 0; i < 64 && count < max; i++) { uint32_t val = scan_start[i]; @@ -354,6 +357,8 @@ void crash_handler_log() { #if SOC_CPU_CORES_NUM > 1 append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.other_backtrace, s_raw_crash_data.other_backtrace_count, s_raw_crash_data.other_reg_frame_count); +#else + (void) pos; // There is no second-core append on single-core targets, so pos would otherwise be unread. #endif ESP_LOGE(TAG, "%s", hint); } diff --git a/esphome/components/internal_temperature/internal_temperature.h b/esphome/components/internal_temperature/internal_temperature.h index 4810e8478d..41fea5a255 100644 --- a/esphome/components/internal_temperature/internal_temperature.h +++ b/esphome/components/internal_temperature/internal_temperature.h @@ -3,6 +3,12 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" +// Every ESP32 variant except the original one exposes the on-chip sensor through +// the IDF temperature_sensor driver (the original uses the legacy temprature_sens_read). +#if defined(USE_ESP32) && !defined(USE_ESP32_VARIANT_ESP32) +#include "driver/temperature_sensor.h" +#endif + namespace esphome::internal_temperature { class InternalTemperatureSensor : public sensor::Sensor, public PollingComponent { @@ -13,6 +19,11 @@ class InternalTemperatureSensor : public sensor::Sensor, public PollingComponent void dump_config() override; void update() override; + +#if defined(USE_ESP32) && !defined(USE_ESP32_VARIANT_ESP32) + protected: + temperature_sensor_handle_t tsens_{nullptr}; +#endif }; } // namespace esphome::internal_temperature diff --git a/esphome/components/internal_temperature/internal_temperature_esp32.cpp b/esphome/components/internal_temperature/internal_temperature_esp32.cpp index 09121fa9c9..1c44a9a238 100644 --- a/esphome/components/internal_temperature/internal_temperature_esp32.cpp +++ b/esphome/components/internal_temperature/internal_temperature_esp32.cpp @@ -19,12 +19,6 @@ namespace esphome::internal_temperature { static const char *const TAG = "internal_temperature.esp32"; -#if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ - defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || \ - defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) -static temperature_sensor_handle_t tsensNew = NULL; -#endif // USE_ESP32_VARIANT - void InternalTemperatureSensor::update() { float temperature = NAN; bool success = false; @@ -37,7 +31,7 @@ void InternalTemperatureSensor::update() { defined(USE_ESP32_VARIANT_ESP32C5) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || \ defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || \ defined(USE_ESP32_VARIANT_ESP32S3) - esp_err_t result = temperature_sensor_get_celsius(tsensNew, &temperature); + esp_err_t result = temperature_sensor_get_celsius(this->tsens_, &temperature); success = (result == ESP_OK); if (!success) { ESP_LOGE(TAG, "Reading failed (%d)", result); @@ -60,14 +54,14 @@ void InternalTemperatureSensor::setup() { defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) temperature_sensor_config_t tsens_config = TEMPERATURE_SENSOR_CONFIG_DEFAULT(-10, 80); - esp_err_t result = temperature_sensor_install(&tsens_config, &tsensNew); + esp_err_t result = temperature_sensor_install(&tsens_config, &this->tsens_); if (result != ESP_OK) { ESP_LOGE(TAG, "Install failed (%d)", result); this->mark_failed(); return; } - result = temperature_sensor_enable(tsensNew); + result = temperature_sensor_enable(this->tsens_); if (result != ESP_OK) { ESP_LOGE(TAG, "Enabling failed (%d)", result); this->mark_failed(); diff --git a/esphome/components/logger/logger_esp32.cpp b/esphome/components/logger/logger_esp32.cpp index b216a5427d..05fc959ceb 100644 --- a/esphome/components/logger/logger_esp32.cpp +++ b/esphome/components/logger/logger_esp32.cpp @@ -30,7 +30,7 @@ namespace esphome::logger { static const char *const TAG = "logger"; #ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG -static void init_usb_serial_jtag_() { +static void init_usb_serial_jtag() { setvbuf(stdin, NULL, _IONBF, 0); // Disable buffering on stdin #if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 3, 0) @@ -109,7 +109,7 @@ void Logger::pre_setup() { #ifdef USE_LOGGER_USB_SERIAL_JTAG case UART_SELECTION_USB_SERIAL_JTAG: #ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG - init_usb_serial_jtag_(); + init_usb_serial_jtag(); #endif break; #endif diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 73dd685df6..a49a5e34b3 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -54,9 +54,10 @@ class IDFOTABackend final { #endif private: + // Keep md5_ first since its digest_ is alignas(32) on DMA-SHA variants; md5_set_ stays last so buf_ packs tightly. + md5::MD5Digest md5_{}; esp_ota_handle_t update_handle_{0}; const esp_partition_t *partition_; - md5::MD5Digest md5_{}; char expected_bin_md5_[32]; bool md5_set_{false}; #ifdef USE_OTA_PARTITIONS From 82efa451871f89ee1693b39cfbb30aa9224c00cd Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:16:18 -0400 Subject: [PATCH 0315/1815] [multiple] Avoid float-to-double promotion in math calls (#16812) --- esphome/components/daikin_arc/daikin_arc.cpp | 2 +- esphome/components/display/display.cpp | 13 +++++++------ esphome/components/esp32/core.cpp | 4 +++- esphome/components/nau7802/nau7802.cpp | 3 ++- esphome/components/sgp4x/sgp4x.cpp | 3 ++- esphome/components/thermopro_ble/thermopro_ble.cpp | 3 ++- esphome/components/tuya/number/tuya_number.cpp | 4 +++- 7 files changed, 20 insertions(+), 12 deletions(-) diff --git a/esphome/components/daikin_arc/daikin_arc.cpp b/esphome/components/daikin_arc/daikin_arc.cpp index a455e2fd7f..e31f72dfb9 100644 --- a/esphome/components/daikin_arc/daikin_arc.cpp +++ b/esphome/components/daikin_arc/daikin_arc.cpp @@ -216,7 +216,7 @@ uint8_t DaikinArcClimate::temperature_() { return 0xc0; default: float new_temp = clamp(this->target_temperature, DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX); - uint8_t temperature = (uint8_t) floor(new_temp); + uint8_t temperature = (uint8_t) std::floor(new_temp); return temperature << 1 | (new_temp - temperature > 0 ? 0x01 : 0); } } diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index cd2d2143f5..b24c099bce 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -1,4 +1,5 @@ #include "display.h" +#include #include #include #include "display_color_utils.h" @@ -238,7 +239,7 @@ void Display::filled_gauge(int center_x, int center_y, int radius1, int radius2, int lhline_width = -(dxmax - dxmin) + 1; if (progress >= 50) { if (float(dymax) < float(-dxmax) * tan_a) { - upd_dxmax = ceil(float(dymax) / tan_a); + upd_dxmax = std::ceil(float(dymax) / tan_a); } else { upd_dxmax = -dxmax; } @@ -253,7 +254,7 @@ void Display::filled_gauge(int center_x, int center_y, int radius1, int radius2, } } else { if (float(dymin) > float(-dxmin) * tan_a) { - upd_dxmin = ceil(float(dymin) / tan_a); + upd_dxmin = std::ceil(float(dymin) / tan_a); } else { upd_dxmin = -dxmin; } @@ -268,12 +269,12 @@ void Display::filled_gauge(int center_x, int center_y, int radius1, int radius2, int hline_width = 2 * (-dxmax) + 1; if (progress >= 50) { if (dymax < float(-dxmax) * tan_a) { - upd_dxmax = ceil(float(dymax) / tan_a); + upd_dxmax = std::ceil(float(dymax) / tan_a); hline_width = -dxmax + upd_dxmax + 1; } } else { if (dymax < float(-dxmax) * tan_a) { - upd_dxmax = ceil(float(dymax) / tan_a); + upd_dxmax = std::ceil(float(dymax) / tan_a); hline_width = -dxmax - upd_dxmax + 1; } else { hline_width = 0; @@ -452,8 +453,8 @@ void HOT Display::get_regular_polygon_vertex(int vertex_id, int *vertex_x, int * rotation_radians -= (variation == VARIATION_FLAT_TOP) ? std::numbers::pi / edges : 0.0; float vertex_angle = ((float) vertex_id) / edges * 2 * std::numbers::pi + rotation_radians; - *vertex_x = (int) round(cos(vertex_angle) * radius) + center_x; - *vertex_y = (int) round(sin(vertex_angle) * radius) + center_y; + *vertex_x = (int) std::round(std::cos(vertex_angle) * radius) + center_x; + *vertex_y = (int) std::round(std::sin(vertex_angle) * radius) + center_y; } } diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 5249f4a59e..098a59937a 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -8,7 +8,9 @@ void setup(); // NOLINT(readability-redundant-declaration) -// Weak stub for initArduino - overridden when the Arduino component is present +// Weak stub for initArduino - overridden when the Arduino component is present. +// Name must match the Arduino framework's entry point, so the naming check is suppressed. +// NOLINTNEXTLINE(readability-identifier-naming) extern "C" __attribute__((weak)) void initArduino() {} namespace esphome { diff --git a/esphome/components/nau7802/nau7802.cpp b/esphome/components/nau7802/nau7802.cpp index 4d73ed6dd0..2092452087 100644 --- a/esphome/components/nau7802/nau7802.cpp +++ b/esphome/components/nau7802/nau7802.cpp @@ -1,4 +1,5 @@ #include "nau7802.h" +#include #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -76,7 +77,7 @@ void NAU7802Sensor::setup() { return; } - uint32_t gcal = (uint32_t) (round(this->gain_calibration_ * (1 << GCAL1_FRACTIONAL))); + uint32_t gcal = (uint32_t) (std::round(this->gain_calibration_ * (1 << GCAL1_FRACTIONAL))); this->write_value_(OCAL1_B2_REG, 3, this->offset_calibration_); this->write_value_(GCAL1_B3_REG, 4, gcal); diff --git a/esphome/components/sgp4x/sgp4x.cpp b/esphome/components/sgp4x/sgp4x.cpp index 94e6d69dcb..db56bd13f0 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -3,6 +3,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" #include +#include namespace esphome::sgp4x { @@ -199,7 +200,7 @@ void SGP4xComponent::measure_raw_() { response_words = 2; } } - uint16_t rhticks = (uint16_t) llround((humidity * 65535) / 100); + uint16_t rhticks = (uint16_t) std::llround((humidity * 65535) / 100); uint16_t tempticks = (uint16_t) (((temperature + 45) * 65535) / 175); // first parameter are the relative humidity ticks data[0] = rhticks; diff --git a/esphome/components/thermopro_ble/thermopro_ble.cpp b/esphome/components/thermopro_ble/thermopro_ble.cpp index 2c90ee23f8..1ccf59a2f6 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.cpp +++ b/esphome/components/thermopro_ble/thermopro_ble.cpp @@ -1,4 +1,5 @@ #include "thermopro_ble.h" +#include #include "esphome/core/log.h" #ifdef USE_ESP32 @@ -136,7 +137,7 @@ static inline uint32_t read_uint32(const uint8_t *data, std::size_t offset) { // A*tanh(B*x+C)+D // Where A,B,C,D are the variables to optimize for. This yielded the below function static float tp96_battery(uint16_t voltage) { - float level = 52.317286f * tanh(static_cast(voltage) / 273.624277936f - 8.76485439394f) + 51.06925f; + float level = 52.317286f * std::tanh(static_cast(voltage) / 273.624277936f - 8.76485439394f) + 51.06925f; return std::max(0.0f, std::min(level, 100.0f)); } diff --git a/esphome/components/tuya/number/tuya_number.cpp b/esphome/components/tuya/number/tuya_number.cpp index bfedbb9319..b0bbfce649 100644 --- a/esphome/components/tuya/number/tuya_number.cpp +++ b/esphome/components/tuya/number/tuya_number.cpp @@ -1,3 +1,5 @@ +#include + #include "esphome/core/log.h" #include "tuya_number.h" @@ -63,7 +65,7 @@ void TuyaNumber::setup() { void TuyaNumber::control(float value) { ESP_LOGV(TAG, "Setting number %u: %f", this->number_id_, value); if (this->type_ == TuyaDatapointType::INTEGER) { - int integer_value = lround(value * multiply_by_); + int integer_value = std::lround(value * multiply_by_); this->parent_->set_integer_datapoint_value(this->number_id_, integer_value); } else if (this->type_ == TuyaDatapointType::ENUM) { this->parent_->set_enum_datapoint_value(this->number_id_, value); From 9fbd4c38aeee6f998b2cf980dc111d9dd35529cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Jun 2026 17:28:32 -0500 Subject: [PATCH 0316/1815] [i2s_audio] Move test bus into a shared package and give fixtures unique ids (#16793) --- script/analyze_component_buses.py | 1 + script/helpers.py | 1 + .../i2s_audio/common-spdif_mode.yaml | 2 +- tests/components/micro_wake_word/common.yaml | 7 ++---- .../micro_wake_word/test.esp32-idf.yaml | 6 +++++ .../micro_wake_word/test.esp32-s3-idf.yaml | 6 +++++ tests/components/mixer/common.yaml | 6 +---- tests/components/mixer/test.esp32-idf.yaml | 4 +--- tests/components/mixer/test.esp32-s3-idf.yaml | 6 ++--- tests/components/resampler/common.yaml | 10 +++------ .../components/resampler/test.esp32-idf.yaml | 6 ++--- .../resampler/test.esp32-s3-idf.yaml | 8 +++---- tests/components/router/common.yaml | 6 ++--- tests/components/router/test.esp32-idf.yaml | 9 ++++---- tests/components/sound_level/common.yaml | 7 ++---- .../sound_level/test.esp32-idf.yaml | 5 ++--- .../sound_level/test.esp32-s3-idf.yaml | 7 +++--- .../components/speaker/common-audio_dac.yaml | 6 +---- tests/components/speaker/common.yaml | 6 +---- .../speaker/test-audio_dac.esp32-idf.yaml | 6 ++--- .../speaker/test-media_player.esp32-idf.yaml | 10 ++++----- tests/components/speaker/test.esp32-idf.yaml | 6 ++--- tests/components/speaker_source/common.yaml | 10 +++------ .../speaker_source/test.esp32-idf.yaml | 10 ++++----- .../voice_assistant/common-idf.yaml | 22 +++++++++---------- tests/components/voice_assistant/common.yaml | 15 +++++-------- .../voice_assistant/test.esp32-idf.yaml | 12 +++++----- tests/test_build_components/common/README.md | 9 ++++++++ .../common/i2s_audio/esp32-idf.yaml | 14 ++++++++++++ .../common/i2s_audio/esp32-s3-idf.yaml | 14 ++++++++++++ 30 files changed, 122 insertions(+), 115 deletions(-) create mode 100644 tests/test_build_components/common/i2s_audio/esp32-idf.yaml create mode 100644 tests/test_build_components/common/i2s_audio/esp32-s3-idf.yaml diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index fc66605694..a343e34328 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -59,6 +59,7 @@ DIRECT_BUS_TYPES = ( "modbus", "remote_transmitter", "remote_receiver", + "i2s_audio", ) # Signature for components with no bus requirements diff --git a/script/helpers.py b/script/helpers.py index 8b6751c1d3..fc2a3607fb 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -53,6 +53,7 @@ BASE_BUS_COMPONENTS = { "canbus", "remote_transmitter", "remote_receiver", + "i2s_audio", } # Cache version for components graph diff --git a/tests/components/i2s_audio/common-spdif_mode.yaml b/tests/components/i2s_audio/common-spdif_mode.yaml index 374a4bce1e..681ec2aa53 100644 --- a/tests/components/i2s_audio/common-spdif_mode.yaml +++ b/tests/components/i2s_audio/common-spdif_mode.yaml @@ -3,7 +3,7 @@ i2s_audio: speaker: - platform: i2s_audio - id: speaker_id + id: spdif_speaker_id dac_type: external i2s_dout_pin: ${spdif_data_pin} spdif_mode: true diff --git a/tests/components/micro_wake_word/common.yaml b/tests/components/micro_wake_word/common.yaml index cd060c176e..9ac1056ba4 100644 --- a/tests/components/micro_wake_word/common.yaml +++ b/tests/components/micro_wake_word/common.yaml @@ -1,14 +1,11 @@ psram: mode: quad -i2s_audio: - i2s_lrclk_pin: GPIO18 - i2s_bclk_pin: GPIO19 - microphone: - platform: i2s_audio id: echo_microphone - i2s_din_pin: GPIO17 + i2s_audio_id: i2s_audio_bus + i2s_din_pin: ${mic_din_pin} adc_type: external pdm: true bits_per_sample: 16bit diff --git a/tests/components/micro_wake_word/test.esp32-idf.yaml b/tests/components/micro_wake_word/test.esp32-idf.yaml index dade44d145..fa3984d57e 100644 --- a/tests/components/micro_wake_word/test.esp32-idf.yaml +++ b/tests/components/micro_wake_word/test.esp32-idf.yaml @@ -1 +1,7 @@ +substitutions: + mic_din_pin: GPIO36 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml + <<: !include common.yaml diff --git a/tests/components/micro_wake_word/test.esp32-s3-idf.yaml b/tests/components/micro_wake_word/test.esp32-s3-idf.yaml index dade44d145..a1b7b75423 100644 --- a/tests/components/micro_wake_word/test.esp32-s3-idf.yaml +++ b/tests/components/micro_wake_word/test.esp32-s3-idf.yaml @@ -1 +1,7 @@ +substitutions: + mic_din_pin: GPIO18 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-s3-idf.yaml + <<: !include common.yaml diff --git a/tests/components/mixer/common.yaml b/tests/components/mixer/common.yaml index dee42ed280..55e96df4c2 100644 --- a/tests/components/mixer/common.yaml +++ b/tests/components/mixer/common.yaml @@ -6,14 +6,10 @@ esphome: decibel_reduction: 10 duration: 1s -i2s_audio: - i2s_lrclk_pin: ${lrclk_pin} - i2s_bclk_pin: ${bclk_pin} - i2s_mclk_pin: ${mclk_pin} - speaker: - platform: i2s_audio id: mixer_output_speaker_id + i2s_audio_id: i2s_audio_bus dac_type: external i2s_dout_pin: ${dout_pin} bits_per_sample: 32bit diff --git a/tests/components/mixer/test.esp32-idf.yaml b/tests/components/mixer/test.esp32-idf.yaml index 6712f1e468..ba42761635 100644 --- a/tests/components/mixer/test.esp32-idf.yaml +++ b/tests/components/mixer/test.esp32-idf.yaml @@ -1,10 +1,8 @@ substitutions: - lrclk_pin: GPIO4 - bclk_pin: GPIO5 - mclk_pin: GPIO15 dout_pin: GPIO14 packages: spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/mixer/test.esp32-s3-idf.yaml b/tests/components/mixer/test.esp32-s3-idf.yaml index f1721f0862..f12a9615af 100644 --- a/tests/components/mixer/test.esp32-s3-idf.yaml +++ b/tests/components/mixer/test.esp32-s3-idf.yaml @@ -1,7 +1,7 @@ substitutions: - lrclk_pin: GPIO4 - bclk_pin: GPIO5 - mclk_pin: GPIO6 dout_pin: GPIO7 +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-s3-idf.yaml + <<: !include common.yaml diff --git a/tests/components/resampler/common.yaml b/tests/components/resampler/common.yaml index 8ff09ed256..782dc831c4 100644 --- a/tests/components/resampler/common.yaml +++ b/tests/components/resampler/common.yaml @@ -1,13 +1,9 @@ -i2s_audio: - i2s_lrclk_pin: ${lrclk_pin} - i2s_bclk_pin: ${bclk_pin} - i2s_mclk_pin: ${mclk_pin} - speaker: - platform: i2s_audio - id: speaker_id + id: resampler_i2s_speaker_id + i2s_audio_id: i2s_audio_bus dac_type: external i2s_dout_pin: ${dout_pin} - platform: resampler id: resampler_speaker_id - output_speaker: speaker_id + output_speaker: resampler_i2s_speaker_id diff --git a/tests/components/resampler/test.esp32-idf.yaml b/tests/components/resampler/test.esp32-idf.yaml index 6712f1e468..c6bc03e661 100644 --- a/tests/components/resampler/test.esp32-idf.yaml +++ b/tests/components/resampler/test.esp32-idf.yaml @@ -1,10 +1,8 @@ substitutions: - lrclk_pin: GPIO4 - bclk_pin: GPIO5 - mclk_pin: GPIO15 - dout_pin: GPIO14 + dout_pin: GPIO21 packages: spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/resampler/test.esp32-s3-idf.yaml b/tests/components/resampler/test.esp32-s3-idf.yaml index f1721f0862..1d80d24cdf 100644 --- a/tests/components/resampler/test.esp32-s3-idf.yaml +++ b/tests/components/resampler/test.esp32-s3-idf.yaml @@ -1,7 +1,7 @@ substitutions: - lrclk_pin: GPIO4 - bclk_pin: GPIO5 - mclk_pin: GPIO6 - dout_pin: GPIO7 + dout_pin: GPIO16 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-s3-idf.yaml <<: !include common.yaml diff --git a/tests/components/router/common.yaml b/tests/components/router/common.yaml index f1239de3cb..5914e4ca45 100644 --- a/tests/components/router/common.yaml +++ b/tests/components/router/common.yaml @@ -8,13 +8,10 @@ esphome: - router.speaker.switch_output: target_speaker: !lambda return id(speaker_a_id); -i2s_audio: - i2s_lrclk_pin: ${a_lrclk_pin} - i2s_bclk_pin: ${a_bclk_pin} - speaker: - platform: i2s_audio id: speaker_a_id + i2s_audio_id: i2s_audio_bus dac_type: external i2s_dout_pin: ${a_dout_pin} sample_rate: 48000 @@ -22,6 +19,7 @@ speaker: channel: stereo - platform: i2s_audio id: speaker_b_id + i2s_audio_id: i2s_audio_bus dac_type: external i2s_dout_pin: ${b_dout_pin} spdif_mode: true diff --git a/tests/components/router/test.esp32-idf.yaml b/tests/components/router/test.esp32-idf.yaml index 241a9a8903..8288774941 100644 --- a/tests/components/router/test.esp32-idf.yaml +++ b/tests/components/router/test.esp32-idf.yaml @@ -1,7 +1,8 @@ substitutions: - a_lrclk_pin: GPIO4 - a_bclk_pin: GPIO5 - a_dout_pin: GPIO14 - b_dout_pin: GPIO19 + a_dout_pin: GPIO26 + b_dout_pin: GPIO27 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/sound_level/common.yaml b/tests/components/sound_level/common.yaml index cc04f5bf79..eceef3a9b5 100644 --- a/tests/components/sound_level/common.yaml +++ b/tests/components/sound_level/common.yaml @@ -1,11 +1,8 @@ -i2s_audio: - i2s_lrclk_pin: ${i2s_bclk_pin} - i2s_bclk_pin: ${i2s_lrclk_pin} - microphone: - platform: i2s_audio id: i2s_microphone - i2s_din_pin: ${i2s_dout_pin} + i2s_audio_id: i2s_audio_bus + i2s_din_pin: ${i2s_din_pin} adc_type: external bits_per_sample: 16bit diff --git a/tests/components/sound_level/test.esp32-idf.yaml b/tests/components/sound_level/test.esp32-idf.yaml index 20e38e8df8..4d89f4cd2e 100644 --- a/tests/components/sound_level/test.esp32-idf.yaml +++ b/tests/components/sound_level/test.esp32-idf.yaml @@ -1,9 +1,8 @@ substitutions: - i2s_bclk_pin: GPIO25 - i2s_lrclk_pin: GPIO26 - i2s_dout_pin: GPIO27 + i2s_din_pin: GPIO39 packages: spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/sound_level/test.esp32-s3-idf.yaml b/tests/components/sound_level/test.esp32-s3-idf.yaml index 9c1f32d5bd..9dfe3b4877 100644 --- a/tests/components/sound_level/test.esp32-s3-idf.yaml +++ b/tests/components/sound_level/test.esp32-s3-idf.yaml @@ -1,6 +1,7 @@ substitutions: - i2s_bclk_pin: GPIO4 - i2s_lrclk_pin: GPIO5 - i2s_dout_pin: GPIO6 + i2s_din_pin: GPIO17 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-s3-idf.yaml <<: !include common.yaml diff --git a/tests/components/speaker/common-audio_dac.yaml b/tests/components/speaker/common-audio_dac.yaml index 67bd6c28ef..e3972b4da9 100644 --- a/tests/components/speaker/common-audio_dac.yaml +++ b/tests/components/speaker/common-audio_dac.yaml @@ -14,11 +14,6 @@ esphome: - speaker.finish: - speaker.stop: -i2s_audio: - i2s_lrclk_pin: ${i2s_bclk_pin} - i2s_bclk_pin: ${i2s_lrclk_pin} - i2s_mclk_pin: ${i2s_mclk_pin} - audio_dac: - platform: aic3204 i2c_id: i2c_bus @@ -27,6 +22,7 @@ audio_dac: speaker: - platform: i2s_audio id: speaker_with_audio_dac_id + i2s_audio_id: i2s_audio_bus audio_dac: internal_dac dac_type: external i2s_dout_pin: ${i2s_dout_pin} diff --git a/tests/components/speaker/common.yaml b/tests/components/speaker/common.yaml index 9aaf639162..895f4b4b8f 100644 --- a/tests/components/speaker/common.yaml +++ b/tests/components/speaker/common.yaml @@ -48,13 +48,9 @@ button: data: !lambda |- return {0x01, 0x02, (uint8_t)id(my_number).state}; -i2s_audio: - i2s_lrclk_pin: ${i2s_bclk_pin} - i2s_bclk_pin: ${i2s_lrclk_pin} - i2s_mclk_pin: ${i2s_mclk_pin} - speaker: - platform: i2s_audio id: speaker_id + i2s_audio_id: i2s_audio_bus dac_type: external i2s_dout_pin: ${i2s_dout_pin} diff --git a/tests/components/speaker/test-audio_dac.esp32-idf.yaml b/tests/components/speaker/test-audio_dac.esp32-idf.yaml index 71c8b06e24..48c55769da 100644 --- a/tests/components/speaker/test-audio_dac.esp32-idf.yaml +++ b/tests/components/speaker/test-audio_dac.esp32-idf.yaml @@ -1,10 +1,8 @@ substitutions: - i2s_bclk_pin: GPIO27 - i2s_lrclk_pin: GPIO26 - i2s_mclk_pin: GPIO25 - i2s_dout_pin: GPIO23 + i2s_dout_pin: GPIO33 packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml <<: !include common-audio_dac.yaml diff --git a/tests/components/speaker/test-media_player.esp32-idf.yaml b/tests/components/speaker/test-media_player.esp32-idf.yaml index 4712e4bae8..9ef164bb03 100644 --- a/tests/components/speaker/test-media_player.esp32-idf.yaml +++ b/tests/components/speaker/test-media_player.esp32-idf.yaml @@ -1,9 +1,7 @@ substitutions: - scl_pin: GPIO16 - sda_pin: GPIO17 - i2s_bclk_pin: GPIO27 - i2s_lrclk_pin: GPIO26 - i2s_mclk_pin: GPIO25 - i2s_dout_pin: GPIO23 + i2s_dout_pin: GPIO13 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml <<: !include common-media_player.yaml diff --git a/tests/components/speaker/test.esp32-idf.yaml b/tests/components/speaker/test.esp32-idf.yaml index 27b8604656..b6aeca4faa 100644 --- a/tests/components/speaker/test.esp32-idf.yaml +++ b/tests/components/speaker/test.esp32-idf.yaml @@ -1,10 +1,8 @@ substitutions: - i2s_bclk_pin: GPIO27 - i2s_lrclk_pin: GPIO26 - i2s_mclk_pin: GPIO25 - i2s_dout_pin: GPIO12 + i2s_dout_pin: GPIO13 packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/speaker_source/common.yaml b/tests/components/speaker_source/common.yaml index 7d663b802c..d31b97553e 100644 --- a/tests/components/speaker_source/common.yaml +++ b/tests/components/speaker_source/common.yaml @@ -1,17 +1,13 @@ -i2s_audio: - i2s_lrclk_pin: ${i2s_bclk_pin} - i2s_bclk_pin: ${i2s_lrclk_pin} - i2s_mclk_pin: ${i2s_mclk_pin} - speaker: - platform: i2s_audio - id: speaker_id + id: speaker_source_speaker_id + i2s_audio_id: i2s_audio_bus dac_type: external i2s_dout_pin: ${i2s_dout_pin} sample_rate: 48000 num_channels: 2 - platform: mixer - output_speaker: speaker_id + output_speaker: speaker_source_speaker_id source_speakers: - id: announcement_mixer_speaker_id - id: media_mixer_speaker_id diff --git a/tests/components/speaker_source/test.esp32-idf.yaml b/tests/components/speaker_source/test.esp32-idf.yaml index e2439ebdf2..5a2fd16938 100644 --- a/tests/components/speaker_source/test.esp32-idf.yaml +++ b/tests/components/speaker_source/test.esp32-idf.yaml @@ -1,9 +1,7 @@ substitutions: - scl_pin: GPIO16 - sda_pin: GPIO17 - i2s_bclk_pin: GPIO27 - i2s_lrclk_pin: GPIO26 - i2s_mclk_pin: GPIO25 - i2s_dout_pin: GPIO23 + i2s_dout_pin: GPIO22 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/voice_assistant/common-idf.yaml b/tests/components/voice_assistant/common-idf.yaml index 0fa0903370..812e7a2314 100644 --- a/tests/components/voice_assistant/common-idf.yaml +++ b/tests/components/voice_assistant/common-idf.yaml @@ -11,14 +11,9 @@ wifi: api: -i2s_audio: - i2s_lrclk_pin: ${i2s_lrclk_pin} - i2s_bclk_pin: ${i2s_bclk_pin} - i2s_mclk_pin: ${i2s_mclk_pin} - micro_wake_word: id: mww_id - microphone: mic_id_external + microphone: va_mic_id_external on_wake_word_detected: - voice_assistant.start: wake_word: !lambda return wake_word; @@ -27,31 +22,34 @@ micro_wake_word: microphone: - platform: i2s_audio - id: mic_id_external + id: va_mic_id_external + i2s_audio_id: i2s_audio_bus i2s_din_pin: ${i2s_din_pin} adc_type: external pdm: false - platform: i2s_audio - id: mic_id_external2 + id: va_mic_id_external2 + i2s_audio_id: i2s_audio_bus i2s_din_pin: ${i2s_din_pin2} adc_type: external pdm: false speaker: - platform: i2s_audio - id: speaker_id + id: va_speaker_id + i2s_audio_id: i2s_audio_bus dac_type: external i2s_dout_pin: ${i2s_dout_pin} voice_assistant: microphone: - - microphone: mic_id_external + - microphone: va_mic_id_external gain_factor: 4 channels: 0 - - microphone: mic_id_external2 + - microphone: va_mic_id_external2 gain_factor: 4 channels: 0 - speaker: speaker_id + speaker: va_speaker_id micro_wake_word: mww_id conversation_timeout: 60s on_listening: diff --git a/tests/components/voice_assistant/common.yaml b/tests/components/voice_assistant/common.yaml index d09de74396..8604bea795 100644 --- a/tests/components/voice_assistant/common.yaml +++ b/tests/components/voice_assistant/common.yaml @@ -11,30 +11,27 @@ wifi: api: -i2s_audio: - i2s_lrclk_pin: ${i2s_lrclk_pin} - i2s_bclk_pin: ${i2s_bclk_pin} - i2s_mclk_pin: ${i2s_mclk_pin} - microphone: - platform: i2s_audio - id: mic_id_external + id: va_mic_id_external + i2s_audio_id: i2s_audio_bus i2s_din_pin: ${i2s_din_pin} adc_type: external pdm: false speaker: - platform: i2s_audio - id: speaker_id + id: va_speaker_id + i2s_audio_id: i2s_audio_bus dac_type: external i2s_dout_pin: ${i2s_dout_pin} voice_assistant: microphone: - microphone: mic_id_external + microphone: va_mic_id_external gain_factor: 4 channels: 0 - speaker: speaker_id + speaker: va_speaker_id conversation_timeout: 60s on_listening: - logger.log: "Voice assistant microphone listening" diff --git a/tests/components/voice_assistant/test.esp32-idf.yaml b/tests/components/voice_assistant/test.esp32-idf.yaml index 0cc670a77e..de2b221da7 100644 --- a/tests/components/voice_assistant/test.esp32-idf.yaml +++ b/tests/components/voice_assistant/test.esp32-idf.yaml @@ -1,9 +1,9 @@ substitutions: - i2s_lrclk_pin: GPIO4 - i2s_bclk_pin: GPIO5 - i2s_mclk_pin: GPIO15 - i2s_din_pin: GPIO13 - i2s_din_pin2: GPIO14 - i2s_dout_pin: GPIO12 + i2s_din_pin: GPIO34 + i2s_din_pin2: GPIO35 + i2s_dout_pin: GPIO32 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml <<: !include common-idf.yaml diff --git a/tests/test_build_components/common/README.md b/tests/test_build_components/common/README.md index 76f14b8664..5e925d0067 100644 --- a/tests/test_build_components/common/README.md +++ b/tests/test_build_components/common/README.md @@ -145,6 +145,15 @@ Same pin allocations as standard I2C, but with 10kHz frequency for components re Same UART pins as above, plus: - **flow_control_pin**: GPIO4 (all platforms) +### I2S Audio +Provides a shared `i2s_audio_bus` (clock pins only); ESP32 family only: +- **ESP32 IDF / ESP32-S3 IDF**: BCLK=GPIO5, LRCLK=GPIO4, MCLK=GPIO15 + +Each consumer keeps its own `i2s_dout_pin`/`i2s_din_pin` substitution and must use a +unique data pin, since several speakers/microphones can share one bus when grouped. +The `i2s_audio` component itself (and the isolated PDM `microphone`) keep defining the +bus inline and are not grouped. + ### BLE - **ESP32**: Shared `esp32_ble_tracker` infrastructure - Each component defines unique `ble_client` with different MAC addresses diff --git a/tests/test_build_components/common/i2s_audio/esp32-idf.yaml b/tests/test_build_components/common/i2s_audio/esp32-idf.yaml new file mode 100644 index 0000000000..b540ca9af0 --- /dev/null +++ b/tests/test_build_components/common/i2s_audio/esp32-idf.yaml @@ -0,0 +1,14 @@ +# Common I2S audio bus configuration for ESP32 IDF tests +# Provides a shared i2s_audio bus that speaker/microphone components can use +# Each consumer must give its speaker/microphone a unique data pin + +substitutions: + i2s_bclk_pin: GPIO5 + i2s_lrclk_pin: GPIO4 + i2s_mclk_pin: GPIO15 + +i2s_audio: + - id: i2s_audio_bus + i2s_bclk_pin: ${i2s_bclk_pin} + i2s_lrclk_pin: ${i2s_lrclk_pin} + i2s_mclk_pin: ${i2s_mclk_pin} diff --git a/tests/test_build_components/common/i2s_audio/esp32-s3-idf.yaml b/tests/test_build_components/common/i2s_audio/esp32-s3-idf.yaml new file mode 100644 index 0000000000..d6632cc264 --- /dev/null +++ b/tests/test_build_components/common/i2s_audio/esp32-s3-idf.yaml @@ -0,0 +1,14 @@ +# Common I2S audio bus configuration for ESP32-S3 IDF tests +# Provides a shared i2s_audio bus that speaker/microphone components can use +# Each consumer must give its speaker/microphone a unique data pin + +substitutions: + i2s_bclk_pin: GPIO5 + i2s_lrclk_pin: GPIO4 + i2s_mclk_pin: GPIO15 + +i2s_audio: + - id: i2s_audio_bus + i2s_bclk_pin: ${i2s_bclk_pin} + i2s_lrclk_pin: ${i2s_lrclk_pin} + i2s_mclk_pin: ${i2s_mclk_pin} From a8032054ea38ef30409bf54177865a872e64d869 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:28:50 +1200 Subject: [PATCH 0317/1815] [light] Pass light reference into lambda light effect (#16815) --- esphome/components/light/base_light_effects.h | 6 +++--- esphome/components/light/effects.py | 5 ++++- esphome/components/light/types.py | 1 + tests/components/light/common.yaml | 3 +++ 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/esphome/components/light/base_light_effects.h b/esphome/components/light/base_light_effects.h index cdb9f1f666..ba3fba6c12 100644 --- a/esphome/components/light/base_light_effects.h +++ b/esphome/components/light/base_light_effects.h @@ -111,7 +111,7 @@ class RandomLightEffect : public LightEffect { class LambdaLightEffect : public LightEffect { public: - LambdaLightEffect(const char *name, void (*f)(bool initial_run), uint32_t update_interval) + LambdaLightEffect(const char *name, void (*f)(LightState &, bool initial_run), uint32_t update_interval) : LightEffect(name), f_(f), update_interval_(update_interval) {} void start() override { this->initial_run_ = true; } @@ -119,7 +119,7 @@ class LambdaLightEffect : public LightEffect { const uint32_t now = millis(); if (now - this->last_run_ >= this->update_interval_ || this->initial_run_) { this->last_run_ = now; - this->f_(this->initial_run_); + this->f_(*this->state_, this->initial_run_); this->initial_run_ = false; } } @@ -129,7 +129,7 @@ class LambdaLightEffect : public LightEffect { uint32_t get_current_index() const { return this->get_index(); } protected: - void (*f_)(bool initial_run); + void (*f_)(LightState &, bool initial_run); uint32_t update_interval_; uint32_t last_run_{0}; bool initial_run_; diff --git a/esphome/components/light/effects.py b/esphome/components/light/effects.py index 4088a78e0d..3ae15f9ee5 100644 --- a/esphome/components/light/effects.py +++ b/esphome/components/light/effects.py @@ -51,6 +51,7 @@ from .types import ( FlickerLightEffect, LambdaLightEffect, LightColorValues, + LightStateRef, PulseLightEffect, RandomLightEffect, StrobeLightEffect, @@ -175,7 +176,9 @@ def register_addressable_effect( ) async def lambda_effect_to_code(config, effect_id): lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(bool, "initial_run")], return_type=cg.void + config[CONF_LAMBDA], + [(LightStateRef, "it"), (bool, "initial_run")], + return_type=cg.void, ) return cg.new_Pvariable( effect_id, config[CONF_NAME], lambda_, config[CONF_UPDATE_INTERVAL] diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index c7385cbee3..9c1c7331d1 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -4,6 +4,7 @@ import esphome.codegen as cg # Base light_ns = cg.esphome_ns.namespace("light") LightState = light_ns.class_("LightState", cg.EntityBase, cg.Component) +LightStateRef = LightState.operator("ref") AddressableLightState = light_ns.class_("AddressableLightState", LightState) LightOutput = light_ns.class_("LightOutput") AddressableLight = light_ns.class_("AddressableLight", LightOutput, cg.Component) diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index cd9b27768e..2acc080c6d 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -182,6 +182,9 @@ light: state += 1; if (state == 4) state = 0; + if (initial_run) { + ESP_LOGD("custom_effect", "Effect %s started", it.get_name().c_str()); + } - pulse: transition_length: 10s update_interval: 20s From ef64d27ed46f74437e318b88bd2817c00b493ed1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:30:18 -0500 Subject: [PATCH 0318/1815] Bump ruff from 0.15.15 to 0.15.16 (#16807) Signed-off-by: dependabot[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 203cd2bbea..9da27acc19 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.15 # also change in .pre-commit-config.yaml when updating +ruff==0.15.16 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From 772cae445fdaae90f114e2bf30053339e3eb9e8d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:30:29 -0500 Subject: [PATCH 0319/1815] Bump github/codeql-action from 4.36.1 to 4.36.2 (#16808) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c71d7204de..e559472b60 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: category: "/language:${{matrix.language}}" From a5b4a7cd514d7dac5bf317c6a9e558e246828bbf Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:45:56 -0400 Subject: [PATCH 0320/1815] [remote_base] Fix RC5 decoding at either receive polarity (#16767) --- .../components/remote_base/rc5_protocol.cpp | 90 +++++++++++-------- 1 file changed, 51 insertions(+), 39 deletions(-) diff --git a/esphome/components/remote_base/rc5_protocol.cpp b/esphome/components/remote_base/rc5_protocol.cpp index c7f79ad84a..fd136a4e6d 100644 --- a/esphome/components/remote_base/rc5_protocol.cpp +++ b/esphome/components/remote_base/rc5_protocol.cpp @@ -7,6 +7,7 @@ static const char *const TAG = "remote.rc5"; static constexpr uint32_t BIT_TIME_US = 889; static constexpr uint8_t NBITS = 14; +static constexpr uint8_t NHALFBITS = NBITS * 2; void RC5Protocol::encode(RemoteTransmitData *dst, const RC5Data &data) { static bool toggle = false; @@ -35,52 +36,63 @@ void RC5Protocol::encode(RemoteTransmitData *dst, const RC5Data &data) { } toggle = !toggle; } + optional RC5Protocol::decode(RemoteReceiveData src) { - RC5Data out{ - .address = 0, - .command = 0, - }; - uint8_t field_bit; - - if (src.expect_space(BIT_TIME_US) && src.expect_mark(BIT_TIME_US)) { - field_bit = 1; - } else if (src.expect_space(2 * BIT_TIME_US)) { - field_bit = 0; - } else { - return {}; - } - - if (!(((src.expect_space(BIT_TIME_US) || src.peek_space(2 * BIT_TIME_US)) || - (src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US))) && - (((src.expect_mark(BIT_TIME_US) || src.expect_mark(2 * BIT_TIME_US)) && - (src.expect_space(BIT_TIME_US) || src.peek_space(2 * BIT_TIME_US))) || - ((src.expect_space(BIT_TIME_US) || src.expect_space(2 * BIT_TIME_US)) && - (src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US)))))) { - return {}; - } - - uint32_t out_data = 0; - for (int bit = NBITS - 4; bit >= 1; bit--) { - if ((src.expect_space(BIT_TIME_US) || src.expect_space(2 * BIT_TIME_US)) && - (src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US))) { - out_data |= 0 << bit; - } else if ((src.expect_mark(BIT_TIME_US) || src.expect_mark(2 * BIT_TIME_US)) && - (src.expect_space(BIT_TIME_US) || src.peek_space(2 * BIT_TIME_US))) { - out_data |= 1 << bit; + // Expand the runs into half-bit levels (true = mark). Each run is exactly one + // half-bit (BIT_TIME_US) or two (2 * BIT_TIME_US); stop at anything else. + // + // halfbits[0] is reserved for the leading half-bit, which is always dropped -- + // S1 is 1, so its first half sits at the idle level (at either polarity) and + // merges into the pre-frame idle. Captured half-bits start at index 1. + bool halfbits[NHALFBITS + 2]; + uint8_t n = 1; + for (uint32_t i = 0; n <= NHALFBITS && src.is_valid(i); i++) { + if (src.peek_mark(BIT_TIME_US, i)) { + halfbits[n++] = true; + } else if (src.peek_space(BIT_TIME_US, i)) { + halfbits[n++] = false; + } else if (src.peek_mark(2 * BIT_TIME_US, i)) { + halfbits[n++] = true; + halfbits[n++] = true; + } else if (src.peek_space(2 * BIT_TIME_US, i)) { + halfbits[n++] = false; + halfbits[n++] = false; } else { - return {}; + break; } } - if (src.expect_space(BIT_TIME_US) || src.expect_space(2 * BIT_TIME_US)) { - out_data |= 0; - } else if (src.expect_mark(BIT_TIME_US) || src.expect_mark(2 * BIT_TIME_US)) { - out_data |= 1; + + // Expect a full frame once the leading half is restored: 27 captured halves + // (n == 28) or 26 when the final bit also ends on idle and its trailing half + // is dropped too (n == 27). A dropped edge half is the inverse of its partner + // (a Manchester bit always transitions mid-bit), so reconstruct the leading + // half (always) and the trailing half (only when it was dropped). + if (n != NHALFBITS && n != NHALFBITS - 1) { + return {}; + } + halfbits[0] = !halfbits[1]; + if (n == NHALFBITS - 1) { + halfbits[n] = !halfbits[n - 1]; } - out.command = (uint8_t) (out_data & 0x3F) + (1 - field_bit) * 64u; - out.address = (out_data >> 6) & 0x1F; - return out; + const bool carrier = halfbits[1]; + uint16_t bits = 0; + for (uint8_t i = 0; i < NBITS; i++) { + const bool first = halfbits[2 * i]; + const bool second = halfbits[2 * i + 1]; + if (first == second) { + return {}; // no midpoint transition -> not a valid Manchester bit + } + bits = (bits << 1) | (second == carrier ? 1 : 0); + } + + const bool field_bit = bits & (1 << 12); // S2: the inverted 7th command bit + return RC5Data{ + .address = static_cast((bits >> 6) & 0x1F), + .command = static_cast((bits & 0x3F) | (field_bit ? 0 : 0x40)), + }; } + void RC5Protocol::dump(const RC5Data &data) { ESP_LOGI(TAG, "Received RC5: address=0x%02X, command=0x%02X", data.address, data.command); } From 375ecdfb2c4489300ce281942ee16fad3a0f7bd4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:46:01 -0400 Subject: [PATCH 0321/1815] [esp32][core] Restore ESP-IDF version on logs/upload fast path and clean build on framework change (#16770) --- esphome/storage_json.py | 29 ++++++++++-- esphome/writer.py | 13 ++++-- tests/unit_tests/test_espidf_toolchain.py | 9 ++++ tests/unit_tests/test_storage_json.py | 56 ++++++++++++++++++++++- tests/unit_tests/test_writer.py | 27 +++++++++++ 5 files changed, 126 insertions(+), 8 deletions(-) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index dc1576ab18..65444a2ed8 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -16,7 +16,7 @@ from esphome.const import ( KEY_TARGET_PLATFORM, Toolchain, ) -from esphome.core import CORE +from esphome.core import CORE, EsphomeError from esphome.helpers import write_file_if_changed from esphome.types import CoreType @@ -101,6 +101,7 @@ class StorageJSON: core_platform: str | None = None, toolchain: str | None = None, area: str | None = None, + framework_version: str | None = None, ) -> None: # Version of the storage JSON schema assert storage_version is None or isinstance(storage_version, int) @@ -141,6 +142,8 @@ class StorageJSON: self.toolchain = toolchain # The area of the node self.area = area + # The framework version the build used (for esp32, the resolved ESP-IDF version) + self.framework_version = framework_version def as_dict(self): return { @@ -162,6 +165,7 @@ class StorageJSON: "core_platform": self.core_platform, "toolchain": self.toolchain, "area": self.area, + "framework_version": self.framework_version, } def to_json(self): @@ -173,10 +177,12 @@ class StorageJSON: @staticmethod def from_esphome_core(esph: CoreType, old: StorageJSON | None) -> StorageJSON: hardware = esph.target_platform.upper() + framework_version: str | None = None if esph.is_esp32: from esphome.components import esp32 hardware = esp32.get_esp32_variant(esph) + framework_version = str(esp32.idf_version()) return StorageJSON( storage_version=1, name=esph.name, @@ -200,6 +206,7 @@ class StorageJSON: core_platform=esph.target_platform, toolchain=esph.toolchain.value if esph.toolchain is not None else None, area=esph.area, + framework_version=framework_version, ) @staticmethod @@ -249,6 +256,7 @@ class StorageJSON: core_platform = storage.get("core_platform") toolchain = storage.get("toolchain") area = storage.get("area") + framework_version = storage.get("framework_version") return StorageJSON( storage_version, name, @@ -268,6 +276,7 @@ class StorageJSON: core_platform, toolchain, area, + framework_version, ) @staticmethod @@ -311,10 +320,24 @@ class StorageJSON: # esp32.get_esp32_variant(). target_platform on disk is the variant # (e.g. "ESP32S3"); core_platform is the family (e.g. "esp32"). if target_platform == const.PLATFORM_ESP32: - from esphome.components.esp32.const import KEY_ESP32 + from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION from esphome.const import KEY_VARIANT - CORE.data[KEY_ESP32] = {KEY_VARIANT: self.target_platform} + esp32_data = {KEY_VARIANT: self.target_platform} + if self.framework_version: + import esphome.config_validation as cv + + try: + esp32_data[KEY_IDF_VERSION] = cv.Version.parse( + self.framework_version + ) + except ValueError as err: + raise EsphomeError( + f"Could not parse the framework version " + f"{self.framework_version!r} from {storage_path()}. " + f"Please clean the build files and recompile." + ) from err + CORE.data[KEY_ESP32] = esp32_data def __eq__(self, o) -> bool: return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict() diff --git a/esphome/writer.py b/esphome/writer.py index 192c9d68e8..67202ff925 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -93,9 +93,12 @@ def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: ``src_version`` differs, ``build_path`` differs, the build ``toolchain`` differs (e.g. switching between the PlatformIO and native ESP-IDF toolchains, which produce incompatible build trees), - or a previously loaded integration was removed in *new*. Adding - integrations or changing unrelated fields (friendly name, esphome - version, etc.) does not trigger a clean. + the ``framework`` or ``framework_version`` differs (e.g. switching + arduino <-> esp-idf, or bumping the ESP-IDF version, which also + produce incompatible build trees), or a previously loaded + integration was removed in *new*. Adding integrations or changing + unrelated fields (friendly name, esphome version, etc.) does not + trigger a clean. Used by esphome-device-builder (esphome/device-builder) to gate its remote-build artifact materialiser so a local → remote → local @@ -113,6 +116,10 @@ def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: return True if old.toolchain != new.toolchain: return True + if old.framework != new.framework: + return True + if old.framework_version != new.framework_version: + return True # Check if any components have been removed return bool(old.loaded_integrations - new.loaded_integrations) diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index adc8bfce63..15e2213816 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -56,3 +56,12 @@ def test_get_esphome_esp_idf_paths_no_override(): ) as mock_install: toolchain._get_esphome_esp_idf_paths("5.5.4") mock_install.assert_called_once_with("5.5.4", source_url=None) + + +def test_get_core_framework_version_from_core_data(): + """The version is read from CORE.data when validation populated it.""" + from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION + import esphome.config_validation as cv + + CORE.data = {KEY_ESP32: {KEY_IDF_VERSION: cv.Version(5, 5, 4)}} + assert toolchain._get_core_framework_version() == "5.5.4" diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index 2a6f22abb1..5b318008e1 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -8,7 +8,7 @@ from unittest.mock import MagicMock, Mock, patch import pytest -from esphome import storage_json +from esphome import config_validation as cv, storage_json from esphome.const import CONF_DISABLED, CONF_MDNS, Toolchain from esphome.core import CORE @@ -206,6 +206,7 @@ def test_storage_json_as_dict() -> None: framework="arduino", core_platform="esp32", area="Living Room", + framework_version="5.3.1", ) result = storage.as_dict() @@ -235,6 +236,7 @@ def test_storage_json_as_dict() -> None: assert result["framework"] == "arduino" assert result["core_platform"] == "esp32" assert result["area"] == "Living Room" + assert result["framework_version"] == "5.3.1" def test_storage_json_to_json() -> None: @@ -313,8 +315,12 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: mock_core.toolchain = Toolchain.ESP_IDF mock_core.area = "Living Room" - with patch("esphome.components.esp32.get_esp32_variant") as mock_variant: + with ( + patch("esphome.components.esp32.get_esp32_variant") as mock_variant, + patch("esphome.components.esp32.idf_version") as mock_idf_version, + ): mock_variant.return_value = "ESP32-C3" + mock_idf_version.return_value = cv.Version(5, 3, 1) result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None) @@ -333,6 +339,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: assert result.core_platform == "esp32" assert result.toolchain == "esp-idf" assert result.area == "Living Room" + assert result.framework_version == "5.3.1" def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: @@ -545,6 +552,51 @@ def test_storage_json_apply_to_core_ignores_unknown_toolchain( assert CORE.toolchain is None +def test_storage_json_framework_version_round_trip(setup_core: Path) -> None: + """Sidecar framework_version restores CORE.data[esp32][idf_version].""" + from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION + + storage = _make_storage_with_toolchain("esp-idf") + storage.framework_version = "5.3.1" + path = setup_core / "storage.json" + path.write_text(storage.to_json()) + + assert json.loads(path.read_text())["framework_version"] == "5.3.1" + + loaded = storage_json.StorageJSON.load(path) + assert loaded is not None + assert loaded.framework_version == "5.3.1" + + loaded.apply_to_core() + assert CORE.data[KEY_ESP32][KEY_IDF_VERSION] == cv.Version(5, 3, 1) + + +def test_storage_json_apply_to_core_without_framework_version( + setup_core: Path, +) -> None: + """Older sidecars lacking framework_version don't populate idf_version.""" + from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION + + loaded = _make_storage_with_toolchain("esp-idf") + assert loaded.framework_version is None + + loaded.apply_to_core() + assert KEY_IDF_VERSION not in CORE.data[KEY_ESP32] + + +def test_storage_json_apply_to_core_raises_on_invalid_framework_version( + setup_core: Path, +) -> None: + """A malformed version string fails with an actionable error at parse time.""" + from esphome.core import EsphomeError + + loaded = _make_storage_with_toolchain("esp-idf") + loaded.framework_version = "not-a-version" + + with pytest.raises(EsphomeError, match="clean the build"): + loaded.apply_to_core() + + def test_esphome_storage_json_as_dict() -> None: """Test EsphomeStorageJSON.as_dict returns correct dictionary.""" storage = storage_json.EsphomeStorageJSON( diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index be37dd5d58..2e3499e8e3 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -76,6 +76,7 @@ def create_storage() -> Callable[..., StorageJSON]: framework=kwargs.get("framework", "arduino"), core_platform=kwargs.get("core_platform", "esp32"), toolchain=kwargs.get("toolchain", "platformio"), + framework_version=kwargs.get("framework_version"), ) return _create @@ -121,6 +122,32 @@ def test_storage_should_clean_when_toolchain_changes( assert storage_should_clean(old, new) is True +def test_storage_should_clean_when_framework_changes( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when the framework changes. + + Switching between arduino and esp-idf produces incompatible build trees + even on the same toolchain, so the build must be wiped. + """ + old = create_storage(loaded_integrations=["api", "wifi"], framework="arduino") + new = create_storage(loaded_integrations=["api", "wifi"], framework="esp-idf") + assert storage_should_clean(old, new) is True + + +def test_storage_should_clean_when_framework_version_changes( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when the framework version changes. + + A different framework/ESP-IDF version compiles against a different SDK, so + the stale build tree must be wiped. + """ + old = create_storage(loaded_integrations=["api", "wifi"], framework_version="5.3.1") + new = create_storage(loaded_integrations=["api", "wifi"], framework_version="5.4.0") + assert storage_should_clean(old, new) is True + + def test_storage_should_clean_when_component_removed( create_storage: Callable[..., StorageJSON], ) -> None: From 5662e1b7cddd5ece07dfe8c60b47f2a48555ad30 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:46:08 -0400 Subject: [PATCH 0322/1815] [rp2040] Fix lwipopts template load on Windows extended-length paths (#16783) --- esphome/components/rp2040/__init__.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 862d532645..2ac3c4698b 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -402,18 +402,21 @@ def _generate_lwipopts_h() -> None: in the build directory, and a pre-build script injects this directory into the compiler include path before the framework's own include dir. """ - from jinja2 import Environment, FileSystemLoader + from jinja2 import Environment lwip_defines = CORE.data[KEY_RP2040].get(KEY_LWIP_OPTS) if not lwip_defines: return - template_dir = Path(__file__).parent - jinja_env = Environment( - loader=FileSystemLoader(str(template_dir)), - keep_trailing_newline=True, + # Read the template via pathlib and render from a string rather than using + # FileSystemLoader. jinja2's loader joins the search path with posixpath, which + # breaks on Windows extended-length paths (\\?\C:\...) where forward slashes are + # not accepted, causing a spurious TemplateNotFound (see issue #16732). + template_text = (Path(__file__).parent / "lwipopts.h.jinja").read_text( + encoding="utf-8" ) - template = jinja_env.get_template("lwipopts.h.jinja") + jinja_env = Environment(keep_trailing_newline=True) + template = jinja_env.from_string(template_text) content = template.render(**lwip_defines) lwip_dir = CORE.relative_build_path("lwip_override") From bcf5606b31630a91bfde8a5608d524fa260113e6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:46:17 -0400 Subject: [PATCH 0323/1815] [esp32_ble_server] Fix duplicate Device Information Service with string UUIDs (#16784) --- .../components/esp32_ble_server/__init__.py | 28 +++++++++-- .../esp32_ble_server/__init__.py | 0 .../esp32_ble_server/test_esp32_ble_server.py | 47 +++++++++++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/esp32_ble_server/__init__.py create mode 100644 tests/component_tests/esp32_ble_server/test_esp32_ble_server.py diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 7bf3092a4e..d45f2d9df2 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -62,6 +62,26 @@ MANUFACTURER_NAME_CHARACTERISTIC_UUID = 0x2A29 MODEL_CHARACTERISTIC_UUID = 0x2A24 FIRMWARE_VERSION_CHARACTERISTIC_UUID = 0x2A26 +# Suffix of the Bluetooth Base UUID used to expand 16/32 bit UUIDs to 128 bit. +_BASE_UUID_SUFFIX = "-0000-1000-8000-00805F9B34FB" + + +def uuid_is(uuid: int | str, uuid16: int) -> bool: + """Return True if a validated UUID refers to the given 16-bit short UUID. + + A service/characteristic UUID may be an ``int`` (from ``cv.hex_uint32_t``) or an + uppercase string in 16, 32 or 128 bit form (from ``bt_uuid``), so every + representation of the same UUID must be considered equivalent. + """ + if isinstance(uuid, int): + return uuid == uuid16 + return uuid.upper() in ( + f"{uuid16:04X}", + f"{uuid16:08X}", + f"{uuid16:08X}{_BASE_UUID_SUFFIX}", + ) + + # Core key to store the global configuration KEY_NOTIFY_REQUIRED = "notify_required" KEY_SET_VALUE = "set_value" @@ -195,7 +215,7 @@ def create_description_cud(char_config): return char_config # If the config displays a description, there cannot be a descriptor with the CUD UUID for desc in char_config[CONF_DESCRIPTORS]: - if desc[CONF_UUID] == CUD_DESCRIPTOR_UUID: + if uuid_is(desc[CONF_UUID], CUD_DESCRIPTOR_UUID): raise cv.Invalid( f"Characteristic {char_config[CONF_UUID]} has a description, but a CUD descriptor is already present" ) @@ -218,7 +238,7 @@ def create_notify_cccd(char_config): return char_config # If the CCCD descriptor is already present, return the config for desc in char_config[CONF_DESCRIPTORS]: - if desc[CONF_UUID] == CCCD_DESCRIPTOR_UUID: + if uuid_is(desc[CONF_UUID], CCCD_DESCRIPTOR_UUID): # Check if the WRITE property is set if not desc[CONF_WRITE]: raise cv.Invalid( @@ -244,7 +264,7 @@ def create_device_information_service(config): # If there is already a device information service, # there cannot be CONF_MODEL, CONF_MANUFACTURER or CONF_FIRMWARE_VERSION properties for service in config[CONF_SERVICES]: - if service[CONF_UUID] == DEVICE_INFORMATION_SERVICE_UUID: + if uuid_is(service[CONF_UUID], DEVICE_INFORMATION_SERVICE_UUID): if ( CONF_MODEL in config or CONF_MANUFACTURER in config @@ -592,7 +612,7 @@ async def to_code(config): ) for char_conf in service_config[CONF_CHARACTERISTICS]: await to_code_characteristic(service_var, char_conf) - if service_config[CONF_UUID] == DEVICE_INFORMATION_SERVICE_UUID: + if uuid_is(service_config[CONF_UUID], DEVICE_INFORMATION_SERVICE_UUID): cg.add(var.set_device_information_service(service_var)) else: cg.add(var.enqueue_start_service(service_var)) diff --git a/tests/component_tests/esp32_ble_server/__init__.py b/tests/component_tests/esp32_ble_server/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py new file mode 100644 index 0000000000..88307d0dcf --- /dev/null +++ b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py @@ -0,0 +1,47 @@ +"""Tests for esp32_ble_server configuration helpers.""" + +import pytest + +from esphome.components.esp32_ble_server import ( + CCCD_DESCRIPTOR_UUID, + CUD_DESCRIPTOR_UUID, + DEVICE_INFORMATION_SERVICE_UUID, + uuid_is, +) + + +@pytest.mark.parametrize( + "uuid", + [ + DEVICE_INFORMATION_SERVICE_UUID, # int form (cv.hex_uint32_t) + "180A", # 16 bit short form (bt_uuid) + "180a", # lowercase is normalized by bt_uuid but guard anyway + "0000180A", # 32 bit form + "0000180A-0000-1000-8000-00805F9B34FB", # full 128 bit form + ], +) +def test_uuid_is_matches_all_representations(uuid) -> None: + """All representations of the same 16 bit UUID must compare equal.""" + assert uuid_is(uuid, DEVICE_INFORMATION_SERVICE_UUID) + + +@pytest.mark.parametrize( + "uuid", + [ + 0x1818, # Cycling Power Service (different int) + "1818", # different 16 bit short form + "0000180B", # adjacent UUID + "0000180A-0000-1000-8000-00805F9B34FC", # wrong base UUID suffix + ], +) +def test_uuid_is_rejects_other_uuids(uuid) -> None: + """A different UUID must not be mistaken for the device information service.""" + assert not uuid_is(uuid, DEVICE_INFORMATION_SERVICE_UUID) + + +@pytest.mark.parametrize("uuid16", [CUD_DESCRIPTOR_UUID, CCCD_DESCRIPTOR_UUID]) +def test_uuid_is_matches_descriptor_short_strings(uuid16) -> None: + """Reserved descriptor UUIDs match whether given as int or short string.""" + assert uuid_is(uuid16, uuid16) + assert uuid_is(f"{uuid16:04X}", uuid16) + assert uuid_is(f"{uuid16:08X}", uuid16) From 7f3feec3a38e8df647f15af10e1732c2a1015aba Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:11:36 +1200 Subject: [PATCH 0324/1815] Bump version to 2026.5.3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 3dfe6c5ed4..3d74858d3d 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.5.2 +PROJECT_NUMBER = 2026.5.3 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index fdbbbe5eab..8bc7907cd0 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.5.2" +__version__ = "2026.5.3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From ea3ac1ee96acc4214b2e65a32df77f0eb259c4ce Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:02:22 -0400 Subject: [PATCH 0325/1815] [audio] Bump esp-audio-libs to v3.2.1 (#16818) --- .clang-tidy.hash | 2 +- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index c3604e7ef2..1dc63cc7bb 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -adf1b0ed175c64877f959b14ff1ff8d3ba0d15bafcd86fab85a66f1d5ce953e8 +0119a5940f061725291b5dfbafbd0ef843dbe2b40489f38d1d456ae81ee3dbe7 diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index c051d70f3d..2ddce577ef 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -335,7 +335,7 @@ async def to_code(config): add_idf_component( name="esphome/esp-audio-libs", - ref="3.2.0", + ref="3.2.1", ) data = _get_data() diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 4190c80027..9c87a7e5cf 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -2,7 +2,7 @@ dependencies: bblanchon/arduinojson: version: "7.4.2" esphome/esp-audio-libs: - version: 3.2.0 + version: 3.2.1 esphome/esp-micro-speech-features: version: 1.2.3 esphome/micro-decoder: From e209a3fa91318283672ae0bc8584eeab7d71237e Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Fri, 5 Jun 2026 05:57:05 +0200 Subject: [PATCH 0326/1815] [usb_uart] Add FTDI FT23XX USB UART driver (#14587) Co-authored-by: Oliver Kleinecke Co-authored-by: Claude Sonnet 4.6 Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/usb_uart/__init__.py | 18 +- esphome/components/usb_uart/ft23xx.cpp | 454 ++++++++++++++++++++++++ esphome/components/usb_uart/usb_uart.h | 19 + tests/components/usb_uart/common.yaml | 10 + 4 files changed, 497 insertions(+), 4 deletions(-) create mode 100644 esphome/components/usb_uart/ft23xx.cpp diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index 1cf78fdbd5..7b9c320879 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS +from esphome.components.esp32 import VARIANT_ESP32P4, get_esp32_variant from esphome.components.uart import CONF_DEBUG_PREFIX, CONF_FLUSH_TIMEOUT, UARTComponent from esphome.components.usb_host import ( get_max_packet_size, @@ -15,6 +16,7 @@ from esphome.const import ( CONF_DUMMY_RECEIVER, CONF_ID, ) +from esphome.core import CORE from esphome.cpp_types import Component AUTO_LOAD = ["uart", "usb_host", "bytebuffer"] @@ -55,16 +57,24 @@ class Type: uart_types = ( - Type("CH34X", 0x1A86, 0x55D5, "CH34X", 3), - Type("CH340", 0x1A86, 0x7523, "CH34X", 1), - Type("ESP_JTAG", 0x303A, 0x1001, "CdcAcm", 1, baud_rate_required=False), - Type("STM32_VCP", 0x0483, 0x5740, "CdcAcm", 1, baud_rate_required=False), Type("CDC_ACM", 0, 0, "CdcAcm", 1, baud_rate_required=False), Type("CP210X", 0x10C4, 0xEA60, "CP210X", 3), + Type("CH34X", 0x1A86, 0x55D5, "CH34X", 4), + Type("CH340", 0x1A86, 0x7523, "CH34X", 1), + Type("ESP_JTAG", 0x303A, 0x1001, "CdcAcm", 1, baud_rate_required=False), + Type("FT232", 0x0403, 0x6001, "FT23XX", 1), + Type("FT2232", 0x0403, 0x6010, "FT23XX", 2), + Type("FT4232", 0x0403, 0x6011, "FT23XX", 4), + Type("STM32_VCP", 0x0483, 0x5740, "CdcAcm", 1, baud_rate_required=False), ) def channel_schema(channels, baud_rate_required): + # For now S3 is restricted to 3 channels since each needs 2 endpoints, plus the control endpoint, and + # there are only a total of 8 endpoints available. + # This will need updating when the 8 channel devices that multiplex over an endpoint are added. + if CORE.is_esp32 and get_esp32_variant() != VARIANT_ESP32P4 and channels > 3: + channels = 3 return cv.Schema( { cv.Required(CONF_CHANNELS): cv.All( diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp new file mode 100644 index 0000000000..d57d7fe3bd --- /dev/null +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -0,0 +1,454 @@ +#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) +#include "usb_uart.h" +#include "usb/usb_host.h" +#include "esphome/core/log.h" +#include "esphome/components/uart/uart_debugger.h" + +#include "esphome/components/bytebuffer/bytebuffer.h" + +namespace esphome::usb_uart { + +using namespace bytebuffer; + +// FTDI chip family identifiers. These map to USB device bcdDevice values +// and determine how baudrate divisors and clock sources are calculated. +enum ftdi_chip_type { + TYPE_AM = 0, + TYPE_BM = 1, + TYPE_2232C = 2, + TYPE_R = 3, + TYPE_2232H = 4, + TYPE_4232H = 5, + TYPE_232H = 6, + TYPE_230X = 7, +}; + +static int ftdi_to_clkbits_AM(int baudrate, unsigned long *encoded_divisor) { + static const char frac_code[8] = {0, 3, 2, 4, 1, 5, 6, 7}; + static const char am_adjust_up[8] = {0, 0, 0, 1, 0, 3, 2, 1}; + static const char am_adjust_dn[8] = {0, 0, 0, 1, 0, 1, 2, 3}; + int divisor, best_divisor, best_baud, best_baud_diff; + int i; + divisor = 24000000 / baudrate; + + divisor -= am_adjust_dn[divisor & 7]; + + best_divisor = 0; + best_baud = 0; + best_baud_diff = 0; + for (i = 0; i < 2; i++) { + int try_divisor = divisor + i; + int baud_estimate; + int baud_diff; + + if (try_divisor <= 8) { + try_divisor = 8; + } else if (divisor < 16) { + try_divisor = 16; + } else { + try_divisor += am_adjust_up[try_divisor & 7]; + if (try_divisor > 0x1FFF8) { + // Round down to maximum supported divisor value (for AM) + try_divisor = 0x1FFF8; + } + } + baud_estimate = (24000000 + (try_divisor / 2)) / try_divisor; + if (baud_estimate < baudrate) { + baud_diff = baudrate - baud_estimate; + } else { + baud_diff = baud_estimate - baudrate; + } + if (i == 0 || baud_diff < best_baud_diff) { + best_divisor = try_divisor; + best_baud = baud_estimate; + best_baud_diff = baud_diff; + if (baud_diff == 0) { + break; + } + } + } + *encoded_divisor = (best_divisor >> 3) | (frac_code[best_divisor & 7] << 14); + if (*encoded_divisor == 1) { + *encoded_divisor = 0; // 3000000 baud + } else if (*encoded_divisor == 0x4001) { + *encoded_divisor = 1; // 2000000 baud (BM only) + } + return best_baud; +} + +static int ftdi_to_clkbits(int baudrate, unsigned int clk, int clk_div, unsigned long *encoded_divisor) { + static const char frac_code[8] = {0, 3, 2, 4, 1, 5, 6, 7}; + int best_baud = 0; + int divisor, best_divisor; + if (baudrate >= clk / clk_div) { + *encoded_divisor = 0; + best_baud = clk / clk_div; + } else if (baudrate >= clk / (clk_div + clk_div / 2)) { + *encoded_divisor = 1; + best_baud = clk / (clk_div + clk_div / 2); + } else if (baudrate >= clk / (2 * clk_div)) { + *encoded_divisor = 2; + best_baud = clk / (2 * clk_div); + } else { + divisor = clk * 16 / clk_div / baudrate; + if (divisor & 1) + best_divisor = divisor / 2 + 1; + else + best_divisor = divisor / 2; + if (best_divisor > 0x20000) + best_divisor = 0x1ffff; + best_baud = clk * 16 / clk_div / best_divisor; + if (best_baud & 1) + best_baud = best_baud / 2 + 1; + else + best_baud = best_baud / 2; + *encoded_divisor = (best_divisor >> 3) | (frac_code[best_divisor & 0x7] << 14); + } + return best_baud; +} + +static int ftdi_convert_baudrate(int baudrate, uint8_t chip_type, uint8_t channel_index, unsigned short *value, + unsigned short *index) { + int best_baud; + unsigned long encoded_divisor; + + if (baudrate <= 0) { + return -1; + } + + static constexpr uint32_t H_CLK = 120000000; + static constexpr uint32_t C_CLK = 48000000; + if ((chip_type == TYPE_2232H) || (chip_type == TYPE_4232H) || (chip_type == TYPE_232H)) { + if (baudrate * 10 > H_CLK / 0x3fff) { + best_baud = ftdi_to_clkbits(baudrate, H_CLK, 10, &encoded_divisor); + encoded_divisor |= 0x20000; /* switch on CLK/10*/ + } else + best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor); + } else if ((chip_type == TYPE_BM) || (chip_type == TYPE_2232C) || (chip_type == TYPE_R) || (chip_type == TYPE_230X)) { + best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor); + } else { + best_baud = ftdi_to_clkbits_AM(baudrate, &encoded_divisor); + } + + *value = (unsigned short) (encoded_divisor & 0xFFFF); + if (chip_type == TYPE_2232H || chip_type == TYPE_4232H || chip_type == TYPE_232H) { + *index = (unsigned short) (encoded_divisor >> 8); + *index &= 0xFF00; + *index |= (channel_index + 1); + } else + *index = (unsigned short) (encoded_divisor >> 16); + + return best_baud; +} + +static optional get_uart(const usb_config_desc_t *config_desc, uint8_t intf_idx) { + int conf_offset, ep_offset; + CdcEps eps{}; + + const auto *intf_desc = usb_parse_interface_descriptor(config_desc, intf_idx, 0, &conf_offset); + if (!intf_desc) { + ESP_LOGD(TAG, "usb_parse_interface_descriptor failed for intf_idx=%d (end of interfaces)", intf_idx); + return nullopt; + } + ESP_LOGD(TAG, + "intf_desc [idx=%d]: bInterfaceClass=%02X, bInterfaceSubClass=%02X, bInterfaceProtocol=%02X, " + "bNumEndpoints=%d, bInterfaceNumber=%d", + intf_idx, intf_desc->bInterfaceClass, intf_desc->bInterfaceSubClass, intf_desc->bInterfaceProtocol, + intf_desc->bNumEndpoints, intf_desc->bInterfaceNumber); + + std::vector endpoints; + for (uint8_t i = 0; i != intf_desc->bNumEndpoints; i++) { + ep_offset = conf_offset; + const auto *ep = usb_parse_endpoint_descriptor_by_index(intf_desc, i, config_desc->wTotalLength, &ep_offset); + if (!ep) { + ESP_LOGE(TAG, "Ran out of endpoints at %d before finding all %d endpoints", i, intf_desc->bNumEndpoints); + return nullopt; + } + ESP_LOGD(TAG, "ep: bEndpointAddress=%02X, bmAttributes=%02X", ep->bEndpointAddress, ep->bmAttributes); + + if (ep->bmAttributes != 0x2) { + ESP_LOGD(TAG, "Skipping non-bulk endpoint: %02X", ep->bEndpointAddress); + continue; + } + endpoints.push_back(ep); + } + + const usb_ep_desc_t *ep1 = nullptr; + const usb_ep_desc_t *ep2 = nullptr; + for (const auto *ep : endpoints) { + if (ep1 == nullptr) { + ep1 = ep; + } else if (ep2 == nullptr) { + ep2 = ep; + break; + } + } + + if (ep1 == nullptr || ep2 == nullptr) { + ESP_LOGD(TAG, "Interface %d has %zu endpoints (need 2 bulk endpoints)", intf_idx, endpoints.size()); + return nullopt; + } + + ESP_LOGD(TAG, "Interface %d: ep1=0x%02X, ep2=0x%02X", intf_idx, ep1->bEndpointAddress, ep2->bEndpointAddress); + + if (ep1->bEndpointAddress & usb_host::USB_DIR_IN) { + eps.in_ep = ep1; + eps.out_ep = ep2; + ESP_LOGD(TAG, "ep1 is IN (RX): ep1=0x%02X (in_ep), ep2=0x%02X (out_ep)", ep1->bEndpointAddress, + ep2->bEndpointAddress); + } else { + eps.out_ep = ep1; + eps.in_ep = ep2; + ESP_LOGD(TAG, "ep1 is OUT (TX): ep1=0x%02X (out_ep), ep2=0x%02X (in_ep)", ep1->bEndpointAddress, + ep2->bEndpointAddress); + } + + eps.bulk_interface_number = intf_desc->bInterfaceNumber; + return eps; +} + +std::vector USBUartTypeFT23XX::parse_descriptors(usb_device_handle_t dev_hdl) { + const usb_config_desc_t *config_desc; + const usb_device_desc_t *device_desc; + std::vector cdc_devs{}; + std::string type_string; + + if (usb_host_get_device_descriptor(dev_hdl, &device_desc) != ESP_OK) { + ESP_LOGE(TAG, "get_device_descriptor failed"); + return {}; + } + if (usb_host_get_active_config_descriptor(dev_hdl, &config_desc) != ESP_OK) { + ESP_LOGE(TAG, "get_active_config_descriptor failed"); + return {}; + } + if (device_desc->bcdDevice == 0x400 || (device_desc->bcdDevice == 0x200 && device_desc->iSerialNumber == 0)) { + this->chip_type_ = TYPE_BM; + type_string = "BM type chip"; + } else if (device_desc->bcdDevice == 0x200) { + this->chip_type_ = TYPE_AM; + type_string = "AM type chip"; + } else if (device_desc->bcdDevice == 0x500) { + this->chip_type_ = TYPE_2232C; + type_string = "2232C chip"; + } else if (device_desc->bcdDevice == 0x600) { + this->chip_type_ = TYPE_R; + type_string = "type R chip"; + } else if (device_desc->bcdDevice == 0x700) { + this->chip_type_ = TYPE_2232H; + type_string = "2232H chip"; + } else if (device_desc->bcdDevice == 0x800) { + this->chip_type_ = TYPE_4232H; + type_string = "4232H chip"; + } else if (device_desc->bcdDevice == 0x900) { + this->chip_type_ = TYPE_232H; + type_string = "232H type chip"; + } else if (device_desc->bcdDevice == 0x1000) { + this->chip_type_ = TYPE_230X; + type_string = "230x chip"; + } + + ESP_LOGD(TAG, "Found FTDI %s based device", type_string.c_str()); + for (uint8_t intf_idx = 0; intf_idx < this->channels_.size(); intf_idx++) { + if (auto eps = get_uart(config_desc, intf_idx)) { + cdc_devs.push_back(*eps); + ESP_LOGD(TAG, "Found CDC interface at USB interface index %d", intf_idx); + } + } + return cdc_devs; +} + +int USBUartTypeFT23XX::reset(USBUartChannel *channel) { + usb_host::transfer_cb_t callback = [=, this](const usb_host::TransferStatus &status) { + if (!status.success) { + ESP_LOGE(TAG, "Reset failed, status=%s", esp_err_to_name(status.error_code)); + channel->initialised_.store(false); + } else { + ESP_LOGD(TAG, "Reset successful, setting baudrate..."); + this->set_baudrate(channel); + } + }; + bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x00, 0x00, + channel->cdc_dev_.bulk_interface_number + 1, callback); + if (!ok) { + ESP_LOGE(TAG, "Reset control_transfer submit failed"); + channel->initialised_.store(false); + return -1; + } + return 0; +} + +int USBUartTypeFT23XX::set_baudrate(USBUartChannel *channel, uint32_t baudrate) { + usb_host::transfer_cb_t callback = [=, this](const usb_host::TransferStatus &status) { + if (!status.success) { + ESP_LOGE(TAG, "Set baudrate failed, status=%s", esp_err_to_name(status.error_code)); + channel->initialised_.store(false); + } else { + ESP_LOGD(TAG, "Baudrate %d set, setting line properties...", channel->baud_rate_); + this->set_line_properties(channel); + } + }; + if (baudrate == 0) { + baudrate = channel->baud_rate_; + } + unsigned short value, ftdi_index; + ftdi_convert_baudrate(baudrate, this->chip_type_, channel->index_, &value, &ftdi_index); + ESP_LOGD(TAG, "Baudrate: %d, value=0x%04X, ftdi_index=0x%04X", baudrate, value, ftdi_index); + uint16_t usb_index = (ftdi_index & 0xFF00) | (channel->cdc_dev_.bulk_interface_number + 1); + bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x03, value, usb_index, callback); + if (!ok) { + ESP_LOGE(TAG, "Set baudrate control_transfer submit failed"); + channel->initialised_.store(false); + return -1; + } + return 0; +} + +int USBUartTypeFT23XX::set_line_properties(USBUartChannel *channel) { + usb_host::transfer_cb_t callback = [=, this](const usb_host::TransferStatus &status) { + if (!status.success) { + ESP_LOGE(TAG, "Set line properties failed, status=%s", esp_err_to_name(status.error_code)); + channel->initialised_.store(false); + return; + } + ESP_LOGD(TAG, "Line properties set, setting modem control..."); + this->set_dtr_rts(channel); + }; + + unsigned short value = channel->data_bits_; + + switch (channel->parity_) { + case UART_CONFIG_PARITY_NONE: + value |= (0x00 << 8); + break; + case UART_CONFIG_PARITY_ODD: + value |= (0x01 << 8); + break; + case UART_CONFIG_PARITY_EVEN: + value |= (0x02 << 8); + break; + case UART_CONFIG_PARITY_MARK: + value |= (0x03 << 8); + break; + case UART_CONFIG_PARITY_SPACE: + value |= (0x04 << 8); + break; + } + + switch (channel->stop_bits_) { + case UART_CONFIG_STOP_BITS_1: + value |= (0x00 << 11); + break; + case UART_CONFIG_STOP_BITS_1_5: + value |= (0x01 << 11); + break; + case UART_CONFIG_STOP_BITS_2: + value |= (0x02 << 11); + break; + } + + value |= (0x00 << 14); + + bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x04, value, + channel->cdc_dev_.bulk_interface_number + 1, callback); + if (!ok) { + ESP_LOGE(TAG, "Set line properties control_transfer submit failed"); + channel->initialised_.store(false); + return -1; + } + return 0; +} + +int USBUartTypeFT23XX::set_dtr_rts(USBUartChannel *channel) { + usb_host::transfer_cb_t callback = [=, this](const usb_host::TransferStatus &status) { + if (!status.success) { + ESP_LOGE(TAG, "Set modem control failed, status=%s", esp_err_to_name(status.error_code)); + channel->initialised_.store(false); + return; + } + ESP_LOGD(TAG, "Modem control set for channel %d, starting input...", channel->index_); + channel->initialised_.store(true); + this->start_input(channel); + uint8_t next_index = channel->index_ + 1; + if (next_index < this->channels_.size()) { + USBUartChannel *next_channel = this->channels_[next_index]; + ESP_LOGD(TAG, "Configuring next channel %d", next_channel->index_); + this->reset(next_channel); + return; + } else { + ESP_LOGI(TAG, "All channels configured"); + } + }; + + bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x01, 0x0000, + channel->cdc_dev_.bulk_interface_number + 1, callback); + if (!ok) { + ESP_LOGE(TAG, "Set modem control control_transfer submit failed"); + channel->initialised_.store(false); + return -1; + } + return 0; +} + +void USBUartTypeFT23XX::start_input(USBUartChannel *channel) { + if (!channel->initialised_.load() || channel->input_started_.load()) + return; + + const auto *ep = channel->cdc_dev_.in_ep; + + auto callback = [this, channel](const usb_host::TransferStatus &status) { + if (!status.success) { + ESP_LOGE(TAG, "RX Transfer failed, status=%s", esp_err_to_name(status.error_code)); + channel->input_started_.store(false); + return; + } + + size_t uart_data_len = (status.data_len > 2) ? (status.data_len - 2) : 0; + + if (uart_data_len > 0) { + ESP_LOGV(TAG, "RX callback: Received %zu bytes, channel=%d", uart_data_len, channel->index_); + if (!channel->dummy_receiver_) { + // Copy the entire received UART payload into the ring buffer in one + // operation to avoid per-byte overhead and reduce the chance of + // heap activity in hot paths. + channel->input_buffer_.push(status.data + 2, uart_data_len); +#ifdef USE_UART_DEBUGGER + if (channel->debug_) { + // Debug path creates a temporary vector for logging only; this is + // acceptable because debug mode is opt-in and not used in release. + uart::UARTDebug::log_hex(uart::UART_DIRECTION_RX, + std::vector(status.data + 2, status.data + 2 + uart_data_len), ',', + channel->debug_prefix_); + } +#endif + } + } else { + ESP_LOGVV(TAG, "RX: Status packet, modem=0x%02X line=0x%02X, ch=%d", status.data[0], status.data[1], + channel->index_); + } + + channel->input_started_.store(false); + if (channel->dummy_receiver_ || + channel->input_buffer_.get_free_space() >= channel->cdc_dev_.in_ep->wMaxPacketSize) { + this->start_input(channel); + } + }; + + channel->input_started_.store(true); + this->transfer_in(ep->bEndpointAddress, callback, ep->wMaxPacketSize); +} + +void USBUartTypeFT23XX::enable_channels() { + if (!this->channels_.empty() && this->channels_[0]->initialised_.load()) { + this->reset(this->channels_[0]); + } + + for (auto *channel : this->channels_) { + if (!channel->initialised_.load()) + continue; + channel->input_started_.store(false); + channel->output_started_.store(false); + } +} + +} // namespace esphome::usb_uart +#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32P4 diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index fb8425f6cd..7a19aa8e4b 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -129,6 +129,7 @@ class USBUartChannel : public uart::UARTComponent, public Parented parse_descriptors(usb_device_handle_t dev_hdl) override; + void enable_channels() override; + + int reset(USBUartChannel *channel); + int set_baudrate(USBUartChannel *channel, uint32_t baudrate = 0); + int set_line_properties(USBUartChannel *channel); + int set_dtr_rts(USBUartChannel *channel); + + uint8_t chip_type_{255}; +}; + } // namespace esphome::usb_uart #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/tests/components/usb_uart/common.yaml b/tests/components/usb_uart/common.yaml index 5869b9468b..c8c1ee7df2 100644 --- a/tests/components/usb_uart/common.yaml +++ b/tests/components/usb_uart/common.yaml @@ -42,3 +42,13 @@ usb_uart: baud_rate: 9600 debug: true debug_prefix: "[CP210X] " + - id: uart_6 + type: ft2232 + channels: + - id: channel_6_1 + baud_rate: 115200 + - id: channel_6_2 + baud_rate: 9600 + stop_bits: 2 + data_bits: 7 + parity: odd From cbd3aaa1e001e889eac042d22558406ef4c09bb0 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 5 Jun 2026 19:40:18 +1200 Subject: [PATCH 0327/1815] [ci] Add codecov.yml to enforce 100% patch coverage on PRs (#16827) --- codecov.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 codecov.yml diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000000..f8afbbde04 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,18 @@ +coverage: + status: + patch: + default: + target: 100% + threshold: 0% + project: + default: + informational: true + +ignore: + - "esphome/components/**/*" + - "esphome/analyze_memory/**/*" + - "tests/integration/**/*" + +comment: + layout: "reach, diff, flags, files" + require_changes: true From 61bb1805b166f4a3afc19a652b490c51b6afdc14 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Jun 2026 10:47:32 -0500 Subject: [PATCH 0328/1815] [api] Fix nullptr deref when client teardown reenters state dispatch (#16834) --- esphome/components/api/api_server.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 031fa342c1..ddd03ace4a 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -186,8 +186,12 @@ void APIServer::remove_client_(uint8_t client_index) { if (client_index < last_index) { std::swap(this->clients_[client_index], this->clients_[last_index]); } - this->clients_[last_index].reset(); + // Drop the count before resetting the slot. reset() runs ~APIConnection(), which can reenter the + // server (e.g. voice_assistant unsubscribes in its disconnect trigger, publishing entity state -> + // on_*_update iterating active_clients()). Excluding the dying slot from the active range first + // keeps that reentrant iteration from dereferencing the now-null slot. this->api_connection_count_--; + this->clients_[last_index].reset(); // Last client disconnected - set warning and start tracking for reboot timeout if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0) { From 80c84d6665a47513c406b5181b7b76304b10307c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:55:39 -0400 Subject: [PATCH 0329/1815] [usb_host][usb_cdc_acm][tinyusb] Fix clang-tidy findings (#16836) --- esphome/components/tinyusb/tinyusb_component.cpp | 2 +- esphome/components/tinyusb/tinyusb_component.h | 2 +- esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp | 6 +++--- esphome/components/usb_host/usb_host.h | 2 +- esphome/components/usb_host/usb_host_client.cpp | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index 3cefc0454a..567a84f8c3 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -6,7 +6,7 @@ namespace esphome::tinyusb { -static const char *TAG = "tinyusb"; +static const char *const TAG = "tinyusb"; void TinyUSB::setup() { // Use the device's MAC address as its serial number if no serial number is defined diff --git a/esphome/components/tinyusb/tinyusb_component.h b/esphome/components/tinyusb/tinyusb_component.h index 7d8caade74..56c33a708f 100644 --- a/esphome/components/tinyusb/tinyusb_component.h +++ b/esphome/components/tinyusb/tinyusb_component.h @@ -17,7 +17,7 @@ enum USBDStringDescriptor : uint8_t { SIZE = 6, }; -static const char *DEFAULT_USB_STR = "ESPHome"; +static const char *const DEFAULT_USB_STR = "ESPHome"; class TinyUSB : public Component { public: diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp index 5498c38515..592207efa8 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp @@ -96,9 +96,9 @@ static void tinyusb_cdc_line_coding_changed_callback(int itf, cdcacm_event_t *ev } static esp_err_t ringbuf_read_bytes(RingbufHandle_t ring_buf, uint8_t *out_buf, size_t out_buf_sz, size_t *rx_data_size, - TickType_t xTicksToWait) { + TickType_t x_ticks_to_wait) { size_t read_sz; - uint8_t *buf = static_cast(xRingbufferReceiveUpTo(ring_buf, &read_sz, xTicksToWait, out_buf_sz)); + uint8_t *buf = static_cast(xRingbufferReceiveUpTo(ring_buf, &read_sz, x_ticks_to_wait, out_buf_sz)); if (buf == nullptr) { return ESP_FAIL; @@ -186,7 +186,7 @@ void USBCDCACMInstance::usb_tx_task() { uint8_t data[CONFIG_TINYUSB_CDC_TX_BUFSIZE] = {0}; size_t tx_data_size = 0; - while (1) { + while (true) { // Wait for a notification from the bridge component ulTaskNotifyTake(pdTRUE, portMAX_DELAY); diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index 480fd86750..a9f07a5422 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -167,7 +167,7 @@ class USBClient : public Component { // USB task management static void usb_task_fn(void *arg); - [[noreturn]] void usb_task_loop() const; + [[noreturn]] void usb_task_loop_() const; // Members ordered to minimize struct padding on 32-bit platforms TransferRequest requests_[MAX_REQUESTS]{}; diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 4ee8e2ac5e..45e2be17c7 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -236,9 +236,9 @@ void USBClient::setup() { void USBClient::usb_task_fn(void *arg) { auto *client = static_cast(arg); - client->usb_task_loop(); + client->usb_task_loop_(); } -void USBClient::usb_task_loop() const { +void USBClient::usb_task_loop_() const { while (true) { usb_host_client_handle_events(this->handle_, portMAX_DELAY); } From b0e1b94c450c4ba80ff13c04413f82884d8cdfe2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:56:48 -0400 Subject: [PATCH 0330/1815] [mipi_dsi][mipi_rgb][st7701s][rpi_dpi_rgb] Fix clang-tidy findings (#16837) --- esphome/components/mipi_dsi/display.py | 4 +-- esphome/components/mipi_dsi/mipi_dsi.cpp | 29 +++++++++---------- esphome/components/mipi_dsi/mipi_dsi.h | 4 +-- esphome/components/mipi_rgb/mipi_rgb.h | 8 ++--- .../components/rpi_dpi_rgb/rpi_dpi_rgb.cpp | 5 ++-- esphome/components/st7701s/st7701s.cpp | 5 ++-- esphome/components/st7701s/st7701s.h | 1 - .../mipi_dsi/test_mipi_dsi_config.py | 6 ++-- 8 files changed, 31 insertions(+), 31 deletions(-) diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 3554e32299..0939d84aa5 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -65,7 +65,7 @@ DOMAIN = "mipi_dsi" LOGGER = logging.getLogger(DOMAIN) -MIPI_DSI = mipi_dsi_ns.class_("MIPI_DSI", display.Display, cg.Component) +MipiDsi = mipi_dsi_ns.class_("MipiDsi", display.Display, cg.Component) ColorOrder = display.display_ns.enum("ColorMode") ColorBitness = display.display_ns.enum("ColorBitness") @@ -114,7 +114,7 @@ def model_schema(config): schema = display.FULL_DISPLAY_SCHEMA.extend( { model.option(CONF_RESET_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema, - cv.GenerateID(): cv.declare_id(MIPI_DSI), + cv.GenerateID(): cv.declare_id(MipiDsi), cv_dimensions(CONF_DIMENSIONS): dimension_schema( model.get_default(CONF_DRAW_ROUNDING, 1) ), diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index 9bd2dded2c..0ff934ae94 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -9,18 +9,18 @@ namespace esphome::mipi_dsi { static constexpr size_t MIPI_DSI_MAX_CMD_LOG_BYTES = 64; static bool notify_refresh_ready(esp_lcd_panel_handle_t panel, esp_lcd_dpi_panel_event_data_t *edata, void *user_ctx) { - auto sem = static_cast(user_ctx); + SemaphoreHandle_t sem = static_cast(user_ctx); BaseType_t need_yield = pdFALSE; xSemaphoreGiveFromISR(sem, &need_yield); return (need_yield == pdTRUE); } -void MIPI_DSI::smark_failed(const LogString *message, esp_err_t err) { +void MipiDsi::smark_failed(const LogString *message, esp_err_t err) { ESP_LOGE(TAG, "%s: %s", LOG_STR_ARG(message), esp_err_to_name(err)); this->mark_failed(message); } -void MIPI_DSI::setup() { +void MipiDsi::setup() { ESP_LOGCONFIG(TAG, "Running Setup"); if (!this->enable_pins_.empty()) { @@ -175,7 +175,7 @@ void MIPI_DSI::setup() { ESP_LOGCONFIG(TAG, "MIPI DSI setup complete"); } -void MIPI_DSI::update() { +void MipiDsi::update() { if (this->auto_clear_enabled_) { this->clear(); } @@ -202,8 +202,8 @@ void MIPI_DSI::update() { this->y_high_ = 0; } -void MIPI_DSI::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { +void MipiDsi::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { if (w <= 0 || h <= 0) return; // if color mapping is required, pass the buck. @@ -216,8 +216,8 @@ void MIPI_DSI::draw_pixels_at(int x_start, int y_start, int w, int h, const uint this->write_to_display_(x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad); } -void MIPI_DSI::write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, - int x_pad) { +void MipiDsi::write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, + int x_pad) { esp_err_t err = ESP_OK; auto bytes_per_pixel = 3 - this->color_depth_; auto stride = (x_offset + w + x_pad) * bytes_per_pixel; @@ -241,7 +241,7 @@ void MIPI_DSI::write_to_display_(int x_start, int y_start, int w, int h, const u ESP_LOGE(TAG, "lcd_lcd_panel_draw_bitmap failed: %s", esp_err_to_name(err)); } -bool MIPI_DSI::check_buffer_() { +bool MipiDsi::check_buffer_() { if (this->is_failed()) return false; if (this->buffer_ != nullptr) @@ -257,7 +257,7 @@ bool MIPI_DSI::check_buffer_() { return true; } -void MIPI_DSI::draw_pixel_at(int x, int y, Color color) { +void MipiDsi::draw_pixel_at(int x, int y, Color color) { if (!this->get_clipping().inside(x, y)) return; @@ -280,7 +280,6 @@ void MIPI_DSI::draw_pixel_at(int x, int y, Color color) { if (x >= this->get_width_internal() || x < 0 || y >= this->get_height_internal() || y < 0) { return; } - auto pixel = convert_big_endian(display::ColorUtil::color_to_565(color)); if (!this->check_buffer_()) return; size_t pos = (y * this->width_) + x; @@ -319,7 +318,7 @@ void MIPI_DSI::draw_pixel_at(int x, int y, Color color) { if (y > this->y_high_) this->y_high_ = y; } -void MIPI_DSI::fill(Color color) { +void MipiDsi::fill(Color color) { if (!this->check_buffer_()) return; @@ -359,7 +358,7 @@ void MIPI_DSI::fill(Color color) { } } -int MIPI_DSI::get_width() { +int MipiDsi::get_width() { switch (this->rotation_) { case display::DISPLAY_ROTATION_90_DEGREES: case display::DISPLAY_ROTATION_270_DEGREES: @@ -371,7 +370,7 @@ int MIPI_DSI::get_width() { } } -int MIPI_DSI::get_height() { +int MipiDsi::get_height() { switch (this->rotation_) { case display::DISPLAY_ROTATION_0_DEGREES: case display::DISPLAY_ROTATION_180_DEGREES: @@ -385,7 +384,7 @@ int MIPI_DSI::get_height() { static const uint8_t PIXEL_MODES[] = {0, 16, 18, 24}; -void MIPI_DSI::dump_config() { +void MipiDsi::dump_config() { ESP_LOGCONFIG(TAG, "MIPI_DSI RGB LCD" "\n Model: %s" diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index 82827d813e..c99f69989a 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -35,9 +35,9 @@ const uint8_t MADCTL_MV = 0x20; // row/column swap const uint8_t MADCTL_XFLIP = 0x02; // Mirror the display horizontally const uint8_t MADCTL_YFLIP = 0x01; // Mirror the display vertically -class MIPI_DSI : public display::Display { +class MipiDsi : public display::Display { public: - MIPI_DSI(size_t width, size_t height, display::ColorBitness color_depth, uint8_t pixel_mode) + MipiDsi(size_t width, size_t height, display::ColorBitness color_depth, uint8_t pixel_mode) : width_(width), height_(height), color_depth_(color_depth), pixel_mode_(pixel_mode) {} display::ColorOrder get_color_mode() { return this->color_mode_; } void set_color_mode(display::ColorOrder color_mode) { this->color_mode_ = color_mode; } diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index 4d1d836099..dfa8a36e1a 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.h +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -30,9 +30,6 @@ class MipiRgb : public display::Display { void fill(Color color) override; void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; - void write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, - int x_pad); - bool check_buffer_(); display::ColorOrder get_color_mode() { return this->color_mode_; } void set_color_mode(display::ColorOrder color_mode) { this->color_mode_ = color_mode; } @@ -60,12 +57,15 @@ class MipiRgb : public display::Display { display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } int get_width_internal() override { return this->width_; } int get_height_internal() override { return this->height_; } - void dump_pins_(uint8_t start, uint8_t end, const char *name, uint8_t offset); void dump_config() override; void draw_pixel_at(int x, int y, Color color) override; // this will be horribly slow. protected: + void write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, + int x_pad); + bool check_buffer_(); + void dump_pins_(uint8_t start, uint8_t end, const char *name, uint8_t offset); void setup_enables_(); void common_setup_(); InternalGPIOPin *de_pin_{nullptr}; diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp index 00530c3f96..aacb217965 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp @@ -54,8 +54,9 @@ void RpiDpiRgb::draw_pixels_at(int x_start, int y_start, int w, int h, const uin // if color mapping is required, pass the buck. // note that endianness is not considered here - it is assumed to match! if (bitness != display::COLOR_BITNESS_565) { - return display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, - x_pad); + display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, + x_pad); + return; } x_start += this->offset_x_; y_start += this->offset_y_; diff --git a/esphome/components/st7701s/st7701s.cpp b/esphome/components/st7701s/st7701s.cpp index dac8ac9dbc..3ffef86f3e 100644 --- a/esphome/components/st7701s/st7701s.cpp +++ b/esphome/components/st7701s/st7701s.cpp @@ -57,8 +57,9 @@ void ST7701S::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8 // if color mapping is required, pass the buck. // note that endianness is not considered here - it is assumed to match! if (bitness != display::COLOR_BITNESS_565) { - return display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, - x_pad); + display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, + x_pad); + return; } x_start += this->offset_x_; y_start += this->offset_y_; diff --git a/esphome/components/st7701s/st7701s.h b/esphome/components/st7701s/st7701s.h index de5e4c13d4..c65a213929 100644 --- a/esphome/components/st7701s/st7701s.h +++ b/esphome/components/st7701s/st7701s.h @@ -32,7 +32,6 @@ class ST7701S : public display::Display, public: void update() override { this->do_update_(); } void setup() override; - void complete_setup_(); void loop() override; void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; diff --git a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py index 955e945526..c14abdb4fd 100644 --- a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py +++ b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py @@ -124,15 +124,15 @@ def test_code_generation( main_cpp = generate_main(component_fixture_path("mipi_dsi.yaml")) assert ( - "alignas(mipi_dsi::MIPI_DSI) static unsigned char mipi_dsi__p4_nano__pstorage[sizeof(mipi_dsi::MIPI_DSI)];" + "alignas(mipi_dsi::MipiDsi) static unsigned char mipi_dsi__p4_nano__pstorage[sizeof(mipi_dsi::MipiDsi)];" in main_cpp ) assert ( - "static mipi_dsi::MIPI_DSI *const p4_nano = reinterpret_cast(mipi_dsi__p4_nano__pstorage);" + "static mipi_dsi::MipiDsi *const p4_nano = reinterpret_cast(mipi_dsi__p4_nano__pstorage);" in main_cpp ) assert ( - "new(p4_nano) mipi_dsi::MIPI_DSI(800, 1280, display::COLOR_BITNESS_565, 16);" + "new(p4_nano) mipi_dsi::MipiDsi(800, 1280, display::COLOR_BITNESS_565, 16);" in main_cpp ) assert "set_init_sequence({224, 1, 0, 225, 1, 147, 226, 1," in main_cpp From 351b98689686c94793c0dfd1f07fc610e249c64a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 12:01:53 -0400 Subject: [PATCH 0331/1815] [ci] Make ESP32 IDF the comprehensive clang-tidy pass (#16823) Co-authored-by: J. Nick Koston --- .github/workflows/ci.yml | 67 ++++---------------- esphome/components/heatpumpir/heatpumpir.cpp | 3 +- 2 files changed, 16 insertions(+), 54 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f227b37a8..ae3f4e2b98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -482,9 +482,8 @@ jobs: options: --environment esp8266-arduino-tidy --grep USE_ESP8266 pio_cache_key: tidyesp8266 - id: clang-tidy - name: Run script/clang-tidy for ESP32 IDF - options: --environment esp32-idf-tidy --grep USE_ESP_IDF - pio_cache_key: tidyesp32-idf + name: Run script/clang-tidy for ESP32 Arduino + options: --environment esp32-arduino-tidy --grep USE_ARDUINO - id: clang-tidy name: Run script/clang-tidy for ZEPHYR options: --environment nrf52-tidy --grep USE_ZEPHYR --grep USE_NRF52 @@ -505,14 +504,14 @@ jobs: cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache platformio - if: github.ref == 'refs/heads/dev' + if: github.ref == 'refs/heads/dev' && matrix.pio_cache_key uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} - name: Cache platformio - if: github.ref != 'refs/heads/dev' + if: github.ref != 'refs/heads/dev' && matrix.pio_cache_key uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio @@ -523,13 +522,6 @@ jobs: echo "::add-matcher::.github/workflows/matchers/gcc.json" echo "::add-matcher::.github/workflows/matchers/clang-tidy.json" - - name: Run 'pio run --list-targets -e esp32-idf-tidy' - if: matrix.name == 'Run script/clang-tidy for ESP32 IDF' - run: | - . venv/bin/activate - mkdir -p .temp - pio run --list-targets -e esp32-idf-tidy - - name: Check if full clang-tidy scan needed id: check_full_scan run: | @@ -568,7 +560,7 @@ jobs: if: always() clang-tidy-nosplit: - name: Run script/clang-tidy for ESP32 Arduino + name: Run script/clang-tidy for ESP32 IDF runs-on: ubuntu-24.04 needs: - common @@ -589,20 +581,6 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - name: Cache platformio - if: github.ref == 'refs/heads/dev' - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.platformio - key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} - - - name: Cache platformio - if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.platformio - key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} - - name: Register problem matchers run: | echo "::add-matcher::.github/workflows/matchers/gcc.json" @@ -631,10 +609,10 @@ jobs: . venv/bin/activate if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})" - script/clang-tidy --all-headers --fix --environment esp32-arduino-tidy + script/clang-tidy --all-headers --fix --environment esp32-idf-tidy else echo "Running clang-tidy on changed files only" - script/clang-tidy --all-headers --fix --changed --environment esp32-arduino-tidy + script/clang-tidy --all-headers --fix --changed --environment esp32-idf-tidy fi env: # Also cache libdeps, store them in a ~/.platformio subfolder @@ -655,21 +633,18 @@ jobs: GH_TOKEN: ${{ github.token }} strategy: fail-fast: false - max-parallel: 2 + max-parallel: 3 matrix: include: - id: clang-tidy - name: Run script/clang-tidy for ESP32 Arduino 1/4 - options: --environment esp32-arduino-tidy --split-num 4 --split-at 1 + name: Run script/clang-tidy for ESP32 IDF 1/3 + options: --environment esp32-idf-tidy --split-num 3 --split-at 1 - id: clang-tidy - name: Run script/clang-tidy for ESP32 Arduino 2/4 - options: --environment esp32-arduino-tidy --split-num 4 --split-at 2 + name: Run script/clang-tidy for ESP32 IDF 2/3 + options: --environment esp32-idf-tidy --split-num 3 --split-at 2 - id: clang-tidy - name: Run script/clang-tidy for ESP32 Arduino 3/4 - options: --environment esp32-arduino-tidy --split-num 4 --split-at 3 - - id: clang-tidy - name: Run script/clang-tidy for ESP32 Arduino 4/4 - options: --environment esp32-arduino-tidy --split-num 4 --split-at 4 + name: Run script/clang-tidy for ESP32 IDF 3/3 + options: --environment esp32-idf-tidy --split-num 3 --split-at 3 steps: - name: Check out code from GitHub @@ -684,20 +659,6 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - name: Cache platformio - if: github.ref == 'refs/heads/dev' - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.platformio - key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} - - - name: Cache platformio - if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.platformio - key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} - - name: Register problem matchers run: | echo "::add-matcher::.github/workflows/matchers/gcc.json" diff --git a/esphome/components/heatpumpir/heatpumpir.cpp b/esphome/components/heatpumpir/heatpumpir.cpp index 8e9a2c5298..502f83cd5d 100644 --- a/esphome/components/heatpumpir/heatpumpir.cpp +++ b/esphome/components/heatpumpir/heatpumpir.cpp @@ -2,6 +2,7 @@ #if defined(USE_ARDUINO) || defined(USE_ESP32) +#include #include #include #include @@ -113,7 +114,7 @@ void HeatpumpIRClimate::setup() { this->current_temperature = state; IRSenderESPHome esp_sender(this->transmitter_); - this->heatpump_ir_->send(esp_sender, uint8_t(lround(this->current_temperature))); + this->heatpump_ir_->send(esp_sender, uint8_t(std::lround(this->current_temperature))); // current temperature changed, publish state this->publish_state(); From 42cf421f5c41c7cb71f47ef8043de0ecbde831c1 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 12:03:22 -0400 Subject: [PATCH 0332/1815] [usb_uart] Fix clang-tidy findings (#16835) --- esphome/components/usb_uart/ch34x.cpp | 58 ++++++++-------- esphome/components/usb_uart/cp210x.cpp | 10 +-- esphome/components/usb_uart/ft23xx.cpp | 88 +++++++++++++----------- esphome/components/usb_uart/usb_uart.cpp | 24 +++---- esphome/components/usb_uart/usb_uart.h | 10 +-- 5 files changed, 96 insertions(+), 94 deletions(-) diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index d5428cc8d7..c5f904ead1 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -10,43 +10,43 @@ namespace esphome::usb_uart { using namespace bytebuffer; struct CH34xEntry { + const char *name; uint16_t pid; uint8_t byte_idx; // which status.data[] byte to inspect uint8_t mask; // bitmask applied before comparison uint8_t match; // 0xFF = wildcard (default/fallthrough for this PID) CH34xChipType chiptype; - const char *name; uint8_t num_ports; }; static const CH34xEntry CH34X_TABLE[] = { - {0x55D2, 1, 0xFF, 0x41, CHIP_CH342K, "CH342K", 2}, - {0x55D2, 1, 0xFF, 0xFF, CHIP_CH342F, "CH342F", 2}, - {0x55D3, 1, 0xFF, 0x02, CHIP_CH343J, "CH343J", 1}, - {0x55D3, 1, 0xFF, 0x01, CHIP_CH343K, "CH343K", 1}, - {0x55D3, 1, 0xFF, 0x18, CHIP_CH343G_AUTOBAUD, "CH343G_AUTOBAUD", 1}, - {0x55D3, 1, 0xFF, 0xFF, CHIP_CH343GP, "CH343GP", 1}, - {0x55D4, 1, 0xFF, 0x09, CHIP_CH9102X, "CH9102X", 1}, - {0x55D4, 1, 0xFF, 0xFF, CHIP_CH9102F, "CH9102F", 1}, - {0x55D5, 1, 0xFF, 0xC0, CHIP_CH344L, "CH344L", 4}, // CH344L vs CH344L_V2 resolved below - {0x55D5, 1, 0xFF, 0xFF, CHIP_CH344Q, "CH344Q", 4}, - {0x55D7, 1, 0xFF, 0xFF, CHIP_CH9103M, "CH9103M", 2}, - {0x55D8, 1, 0xFF, 0x0A, CHIP_CH9101RY, "CH9101RY", 1}, - {0x55D8, 1, 0xFF, 0xFF, CHIP_CH9101UH, "CH9101UH", 1}, - {0x55DB, 1, 0xFF, 0xFF, CHIP_CH347TF, "CH347TF", 1}, - {0x55DD, 1, 0xFF, 0xFF, CHIP_CH347TF, "CH347TF", 1}, - {0x55DA, 1, 0xFF, 0xFF, CHIP_CH347TF, "CH347TF", 2}, - {0x55DE, 1, 0xFF, 0xFF, CHIP_CH347TF, "CH347TF", 2}, - {0x55E7, 1, 0xFF, 0xFF, CHIP_CH339W, "CH339W", 1}, - {0x55DF, 1, 0xFF, 0xFF, CHIP_CH9104L, "CH9104L", 4}, - {0x55E9, 1, 0xFF, 0xFF, CHIP_CH9111L_M0, "CH9111L_M0", 1}, - {0x55EA, 1, 0xFF, 0xFF, CHIP_CH9111L_M1, "CH9111L_M1", 1}, - {0x55E8, 2, 0xFF, 0x48, CHIP_CH9114L, "CH9114L", 4}, - {0x55E8, 2, 0xFF, 0x49, CHIP_CH9114W, "CH9114W", 4}, - {0x55E8, 2, 0xFF, 0x4A, CHIP_CH9114F, "CH9114F", 4}, - {0x55EB, 4, 0x01, 0x01, CHIP_CH346C_M1, "CH346C_M1", 1}, - {0x55EB, 4, 0x01, 0xFF, CHIP_CH346C_M0, "CH346C_M0", 1}, - {0x55EC, 1, 0xFF, 0xFF, CHIP_CH346C_M2, "CH346C_M2", 2}, + {"CH342K", 0x55D2, 1, 0xFF, 0x41, CHIP_CH342K, 2}, + {"CH342F", 0x55D2, 1, 0xFF, 0xFF, CHIP_CH342F, 2}, + {"CH343J", 0x55D3, 1, 0xFF, 0x02, CHIP_CH343J, 1}, + {"CH343K", 0x55D3, 1, 0xFF, 0x01, CHIP_CH343K, 1}, + {"CH343G_AUTOBAUD", 0x55D3, 1, 0xFF, 0x18, CHIP_CH343G_AUTOBAUD, 1}, + {"CH343GP", 0x55D3, 1, 0xFF, 0xFF, CHIP_CH343GP, 1}, + {"CH9102X", 0x55D4, 1, 0xFF, 0x09, CHIP_CH9102X, 1}, + {"CH9102F", 0x55D4, 1, 0xFF, 0xFF, CHIP_CH9102F, 1}, + {"CH344L", 0x55D5, 1, 0xFF, 0xC0, CHIP_CH344L, 4}, // CH344L vs CH344L_V2 resolved below + {"CH344Q", 0x55D5, 1, 0xFF, 0xFF, CHIP_CH344Q, 4}, + {"CH9103M", 0x55D7, 1, 0xFF, 0xFF, CHIP_CH9103M, 2}, + {"CH9101RY", 0x55D8, 1, 0xFF, 0x0A, CHIP_CH9101RY, 1}, + {"CH9101UH", 0x55D8, 1, 0xFF, 0xFF, CHIP_CH9101UH, 1}, + {"CH347TF", 0x55DB, 1, 0xFF, 0xFF, CHIP_CH347TF, 1}, + {"CH347TF", 0x55DD, 1, 0xFF, 0xFF, CHIP_CH347TF, 1}, + {"CH347TF", 0x55DA, 1, 0xFF, 0xFF, CHIP_CH347TF, 2}, + {"CH347TF", 0x55DE, 1, 0xFF, 0xFF, CHIP_CH347TF, 2}, + {"CH339W", 0x55E7, 1, 0xFF, 0xFF, CHIP_CH339W, 1}, + {"CH9104L", 0x55DF, 1, 0xFF, 0xFF, CHIP_CH9104L, 4}, + {"CH9111L_M0", 0x55E9, 1, 0xFF, 0xFF, CHIP_CH9111L_M0, 1}, + {"CH9111L_M1", 0x55EA, 1, 0xFF, 0xFF, CHIP_CH9111L_M1, 1}, + {"CH9114L", 0x55E8, 2, 0xFF, 0x48, CHIP_CH9114L, 4}, + {"CH9114W", 0x55E8, 2, 0xFF, 0x49, CHIP_CH9114W, 4}, + {"CH9114F", 0x55E8, 2, 0xFF, 0x4A, CHIP_CH9114F, 4}, + {"CH346C_M1", 0x55EB, 4, 0x01, 0x01, CHIP_CH346C_M1, 1}, + {"CH346C_M0", 0x55EB, 4, 0x01, 0xFF, CHIP_CH346C_M0, 1}, + {"CH346C_M2", 0x55EC, 1, 0xFF, 0xFF, CHIP_CH346C_M2, 2}, }; void USBUartTypeCH34X::enable_channels() { @@ -157,7 +157,7 @@ void USBUartTypeCH34X::apply_line_settings_() { this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd, value, (factor << 8) | divisor, callback); this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd + 3, 0x80, 0, callback); } - this->start_channels(); + this->start_channels_(); } std::vector USBUartTypeCH34X::parse_descriptors(usb_device_handle_t dev_hdl) { diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index 261f40c0db..67fd03a813 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -65,7 +65,7 @@ std::vector USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev } for (uint8_t i = 0; i != config_desc->bNumInterfaces; i++) { - auto data_desc = usb_parse_interface_descriptor(config_desc, i, 0, &conf_offset); + const auto *data_desc = usb_parse_interface_descriptor(config_desc, i, 0, &conf_offset); if (!data_desc) { ESP_LOGE(TAG, "data_desc: usb_parse_interface_descriptor failed"); break; @@ -76,13 +76,13 @@ std::vector USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev continue; } ep_offset = conf_offset; - auto out_ep = usb_parse_endpoint_descriptor_by_index(data_desc, 0, config_desc->wTotalLength, &ep_offset); + const auto *out_ep = usb_parse_endpoint_descriptor_by_index(data_desc, 0, config_desc->wTotalLength, &ep_offset); if (!out_ep) { ESP_LOGE(TAG, "out_ep: usb_parse_endpoint_descriptor_by_index failed"); continue; } ep_offset = conf_offset; - auto in_ep = usb_parse_endpoint_descriptor_by_index(data_desc, 1, config_desc->wTotalLength, &ep_offset); + const auto *in_ep = usb_parse_endpoint_descriptor_by_index(data_desc, 1, config_desc->wTotalLength, &ep_offset); if (!in_ep) { ESP_LOGE(TAG, "in_ep: usb_parse_endpoint_descriptor_by_index failed"); continue; @@ -98,7 +98,7 @@ std::vector USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev void USBUartTypeCP210X::enable_channels() { // enable the channels - for (auto channel : this->channels_) { + for (auto *channel : this->channels_) { if (!channel->initialised_.load()) continue; usb_host::transfer_cb_t callback = [=](const usb_host::TransferStatus &status) { @@ -118,7 +118,7 @@ void USBUartTypeCP210X::enable_channels() { this->control_transfer(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_BAUDRATE, 0, channel->index_, callback, baud.get_data()); } - this->start_channels(); + this->start_channels_(); } } // namespace esphome::usb_uart diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index d57d7fe3bd..c2c8993805 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -12,7 +12,7 @@ using namespace bytebuffer; // FTDI chip family identifiers. These map to USB device bcdDevice values // and determine how baudrate divisors and clock sources are calculated. -enum ftdi_chip_type { +enum FtdiChipType { TYPE_AM = 0, TYPE_BM = 1, TYPE_2232C = 2, @@ -23,15 +23,15 @@ enum ftdi_chip_type { TYPE_230X = 7, }; -static int ftdi_to_clkbits_AM(int baudrate, unsigned long *encoded_divisor) { - static const char frac_code[8] = {0, 3, 2, 4, 1, 5, 6, 7}; - static const char am_adjust_up[8] = {0, 0, 0, 1, 0, 3, 2, 1}; - static const char am_adjust_dn[8] = {0, 0, 0, 1, 0, 1, 2, 3}; +static int ftdi_to_clkbits_am(int baudrate, uint32_t *encoded_divisor) { + static const char FRAC_CODE[8] = {0, 3, 2, 4, 1, 5, 6, 7}; + static const char AM_ADJUST_UP[8] = {0, 0, 0, 1, 0, 3, 2, 1}; + static const char AM_ADJUST_DN[8] = {0, 0, 0, 1, 0, 1, 2, 3}; int divisor, best_divisor, best_baud, best_baud_diff; int i; divisor = 24000000 / baudrate; - divisor -= am_adjust_dn[divisor & 7]; + divisor -= AM_ADJUST_DN[divisor & 7]; best_divisor = 0; best_baud = 0; @@ -46,7 +46,7 @@ static int ftdi_to_clkbits_AM(int baudrate, unsigned long *encoded_divisor) { } else if (divisor < 16) { try_divisor = 16; } else { - try_divisor += am_adjust_up[try_divisor & 7]; + try_divisor += AM_ADJUST_UP[try_divisor & 7]; if (try_divisor > 0x1FFF8) { // Round down to maximum supported divisor value (for AM) try_divisor = 0x1FFF8; @@ -67,7 +67,7 @@ static int ftdi_to_clkbits_AM(int baudrate, unsigned long *encoded_divisor) { } } } - *encoded_divisor = (best_divisor >> 3) | (frac_code[best_divisor & 7] << 14); + *encoded_divisor = (best_divisor >> 3) | (FRAC_CODE[best_divisor & 7] << 14); if (*encoded_divisor == 1) { *encoded_divisor = 0; // 3000000 baud } else if (*encoded_divisor == 0x4001) { @@ -76,8 +76,8 @@ static int ftdi_to_clkbits_AM(int baudrate, unsigned long *encoded_divisor) { return best_baud; } -static int ftdi_to_clkbits(int baudrate, unsigned int clk, int clk_div, unsigned long *encoded_divisor) { - static const char frac_code[8] = {0, 3, 2, 4, 1, 5, 6, 7}; +static int ftdi_to_clkbits(int baudrate, unsigned int clk, int clk_div, uint32_t *encoded_divisor) { + static const char FRAC_CODE[8] = {0, 3, 2, 4, 1, 5, 6, 7}; int best_baud = 0; int divisor, best_divisor; if (baudrate >= clk / clk_div) { @@ -91,26 +91,28 @@ static int ftdi_to_clkbits(int baudrate, unsigned int clk, int clk_div, unsigned best_baud = clk / (2 * clk_div); } else { divisor = clk * 16 / clk_div / baudrate; - if (divisor & 1) + if (divisor & 1) { best_divisor = divisor / 2 + 1; - else + } else { best_divisor = divisor / 2; + } if (best_divisor > 0x20000) best_divisor = 0x1ffff; best_baud = clk * 16 / clk_div / best_divisor; - if (best_baud & 1) + if (best_baud & 1) { best_baud = best_baud / 2 + 1; - else + } else { best_baud = best_baud / 2; - *encoded_divisor = (best_divisor >> 3) | (frac_code[best_divisor & 0x7] << 14); + } + *encoded_divisor = (best_divisor >> 3) | (FRAC_CODE[best_divisor & 0x7] << 14); } return best_baud; } -static int ftdi_convert_baudrate(int baudrate, uint8_t chip_type, uint8_t channel_index, unsigned short *value, - unsigned short *index) { +static int ftdi_convert_baudrate(int baudrate, uint8_t chip_type, uint8_t channel_index, uint16_t *value, + uint16_t *index) { int best_baud; - unsigned long encoded_divisor; + uint32_t encoded_divisor; if (baudrate <= 0) { return -1; @@ -122,21 +124,23 @@ static int ftdi_convert_baudrate(int baudrate, uint8_t chip_type, uint8_t channe if (baudrate * 10 > H_CLK / 0x3fff) { best_baud = ftdi_to_clkbits(baudrate, H_CLK, 10, &encoded_divisor); encoded_divisor |= 0x20000; /* switch on CLK/10*/ - } else + } else { best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor); + } } else if ((chip_type == TYPE_BM) || (chip_type == TYPE_2232C) || (chip_type == TYPE_R) || (chip_type == TYPE_230X)) { best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor); } else { - best_baud = ftdi_to_clkbits_AM(baudrate, &encoded_divisor); + best_baud = ftdi_to_clkbits_am(baudrate, &encoded_divisor); } - *value = (unsigned short) (encoded_divisor & 0xFFFF); + *value = (uint16_t) (encoded_divisor & 0xFFFF); if (chip_type == TYPE_2232H || chip_type == TYPE_4232H || chip_type == TYPE_232H) { - *index = (unsigned short) (encoded_divisor >> 8); + *index = (uint16_t) (encoded_divisor >> 8); *index &= 0xFF00; *index |= (channel_index + 1); - } else - *index = (unsigned short) (encoded_divisor >> 16); + } else { + *index = (uint16_t) (encoded_divisor >> 16); + } return best_baud; } @@ -248,23 +252,23 @@ std::vector USBUartTypeFT23XX::parse_descriptors(usb_device_handle_t dev } ESP_LOGD(TAG, "Found FTDI %s based device", type_string.c_str()); - for (uint8_t intf_idx = 0; intf_idx < this->channels_.size(); intf_idx++) { - if (auto eps = get_uart(config_desc, intf_idx)) { + for (size_t intf_idx = 0; intf_idx < this->channels_.size(); intf_idx++) { + if (auto eps = get_uart(config_desc, static_cast(intf_idx))) { cdc_devs.push_back(*eps); - ESP_LOGD(TAG, "Found CDC interface at USB interface index %d", intf_idx); + ESP_LOGD(TAG, "Found CDC interface at USB interface index %zu", intf_idx); } } return cdc_devs; } -int USBUartTypeFT23XX::reset(USBUartChannel *channel) { - usb_host::transfer_cb_t callback = [=, this](const usb_host::TransferStatus &status) { +int USBUartTypeFT23XX::reset_(USBUartChannel *channel) { + usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) { if (!status.success) { ESP_LOGE(TAG, "Reset failed, status=%s", esp_err_to_name(status.error_code)); channel->initialised_.store(false); } else { ESP_LOGD(TAG, "Reset successful, setting baudrate..."); - this->set_baudrate(channel); + this->set_baudrate_(channel); } }; bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x00, 0x00, @@ -277,20 +281,20 @@ int USBUartTypeFT23XX::reset(USBUartChannel *channel) { return 0; } -int USBUartTypeFT23XX::set_baudrate(USBUartChannel *channel, uint32_t baudrate) { - usb_host::transfer_cb_t callback = [=, this](const usb_host::TransferStatus &status) { +int USBUartTypeFT23XX::set_baudrate_(USBUartChannel *channel, uint32_t baudrate) { + usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) { if (!status.success) { ESP_LOGE(TAG, "Set baudrate failed, status=%s", esp_err_to_name(status.error_code)); channel->initialised_.store(false); } else { ESP_LOGD(TAG, "Baudrate %d set, setting line properties...", channel->baud_rate_); - this->set_line_properties(channel); + this->set_line_properties_(channel); } }; if (baudrate == 0) { baudrate = channel->baud_rate_; } - unsigned short value, ftdi_index; + uint16_t value, ftdi_index; ftdi_convert_baudrate(baudrate, this->chip_type_, channel->index_, &value, &ftdi_index); ESP_LOGD(TAG, "Baudrate: %d, value=0x%04X, ftdi_index=0x%04X", baudrate, value, ftdi_index); uint16_t usb_index = (ftdi_index & 0xFF00) | (channel->cdc_dev_.bulk_interface_number + 1); @@ -303,18 +307,18 @@ int USBUartTypeFT23XX::set_baudrate(USBUartChannel *channel, uint32_t baudrate) return 0; } -int USBUartTypeFT23XX::set_line_properties(USBUartChannel *channel) { - usb_host::transfer_cb_t callback = [=, this](const usb_host::TransferStatus &status) { +int USBUartTypeFT23XX::set_line_properties_(USBUartChannel *channel) { + usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) { if (!status.success) { ESP_LOGE(TAG, "Set line properties failed, status=%s", esp_err_to_name(status.error_code)); channel->initialised_.store(false); return; } ESP_LOGD(TAG, "Line properties set, setting modem control..."); - this->set_dtr_rts(channel); + this->set_dtr_rts_(channel); }; - unsigned short value = channel->data_bits_; + uint16_t value = channel->data_bits_; switch (channel->parity_) { case UART_CONFIG_PARITY_NONE: @@ -358,8 +362,8 @@ int USBUartTypeFT23XX::set_line_properties(USBUartChannel *channel) { return 0; } -int USBUartTypeFT23XX::set_dtr_rts(USBUartChannel *channel) { - usb_host::transfer_cb_t callback = [=, this](const usb_host::TransferStatus &status) { +int USBUartTypeFT23XX::set_dtr_rts_(USBUartChannel *channel) { + usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) { if (!status.success) { ESP_LOGE(TAG, "Set modem control failed, status=%s", esp_err_to_name(status.error_code)); channel->initialised_.store(false); @@ -372,7 +376,7 @@ int USBUartTypeFT23XX::set_dtr_rts(USBUartChannel *channel) { if (next_index < this->channels_.size()) { USBUartChannel *next_channel = this->channels_[next_index]; ESP_LOGD(TAG, "Configuring next channel %d", next_channel->index_); - this->reset(next_channel); + this->reset_(next_channel); return; } else { ESP_LOGI(TAG, "All channels configured"); @@ -439,7 +443,7 @@ void USBUartTypeFT23XX::start_input(USBUartChannel *channel) { void USBUartTypeFT23XX::enable_channels() { if (!this->channels_.empty() && this->channels_[0]->initialised_.load()) { - this->reset(this->channels_[0]); + this->reset_(this->channels_[0]); } for (auto *channel : this->channels_) { diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index e3bf5e40bc..3fdf35a472 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -141,13 +141,12 @@ void USBUartChannel::write_array(const uint8_t *data, size_t len) { } #ifdef USE_UART_DEBUGGER if (this->debug_) { - constexpr size_t BATCH = 16; - char buf[4 + format_hex_pretty_size(BATCH)]; // ">>> " + "XX,XX,...,XX\0" - for (size_t off = 0; off < len; off += BATCH) { - size_t n = std::min(len - off, BATCH); - memcpy(buf, ">>> ", 4); - format_hex_pretty_to(buf + 4, sizeof(buf) - 4, data + off, n, ','); - ESP_LOGD(TAG, "%s%s", this->debug_prefix_.c_str(), buf); + constexpr size_t batch = 16; + char buf[format_hex_pretty_size(batch)]; // "XX,XX,...,XX\0" + for (size_t off = 0; off < len; off += batch) { + size_t n = std::min(len - off, batch); + format_hex_pretty_to(buf, data + off, n, ','); + ESP_LOGD(TAG, "%s>>> %s", this->debug_prefix_.c_str(), buf); } } #endif @@ -222,10 +221,9 @@ void USBUartComponent::loop() { #ifdef USE_UART_DEBUGGER if (channel->debug_) { - char buf[4 + format_hex_pretty_size(usb_host::USB_MAX_PACKET_SIZE)]; // "<<< " + hex - memcpy(buf, "<<< ", 4); - format_hex_pretty_to(buf + 4, sizeof(buf) - 4, chunk->data, chunk->length, ','); - ESP_LOGD(TAG, "%s%s", channel->debug_prefix_.c_str(), buf); + char buf[format_hex_pretty_size(usb_host::USB_MAX_PACKET_SIZE)]; // "XX,XX,...,XX\0" + format_hex_pretty_to(buf, chunk->data, chunk->length, ','); + ESP_LOGD(TAG, "%s<<< %s", channel->debug_prefix_.c_str(), buf); } #endif @@ -528,10 +526,10 @@ void USBUartTypeCdcAcm::enable_channels() { } }); } - this->start_channels(); + this->start_channels_(); } -void USBUartTypeCdcAcm::start_channels() { +void USBUartTypeCdcAcm::start_channels_() { for (auto *channel : this->channels_) { if (!channel->initialised_.load()) continue; diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 7a19aa8e4b..41dc2c546d 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -214,7 +214,7 @@ class USBUartTypeCdcAcm : public USBUartComponent { /// Resets per-channel transfer flags and posts the first bulk IN transfer. /// Called by enable_channels() and by vendor-specific subclass overrides that /// handle their own line-coding setup before starting data flow. - void start_channels(); + void start_channels_(); }; class USBUartTypeCP210X : public USBUartTypeCdcAcm { @@ -251,10 +251,10 @@ class USBUartTypeFT23XX : public USBUartTypeCdcAcm { std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; void enable_channels() override; - int reset(USBUartChannel *channel); - int set_baudrate(USBUartChannel *channel, uint32_t baudrate = 0); - int set_line_properties(USBUartChannel *channel); - int set_dtr_rts(USBUartChannel *channel); + int reset_(USBUartChannel *channel); + int set_baudrate_(USBUartChannel *channel, uint32_t baudrate = 0); + int set_line_properties_(USBUartChannel *channel); + int set_dtr_rts_(USBUartChannel *channel); uint8_t chip_type_{255}; }; From 2b581ecd3c992cc35e8e932e6ef3e16d190394c3 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:22:20 -0400 Subject: [PATCH 0333/1815] [esp32] Bump platform to 55.03.39, Arduino to 3.3.9 (#16803) --- .clang-tidy.hash | 2 +- esphome/components/esp32/__init__.py | 14 ++++++++------ platformio.ini | 6 +++--- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 1dc63cc7bb..cab077385d 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -0119a5940f061725291b5dfbafbd0ef843dbe2b40489f38d1d456ae81ee3dbe7 +6f2f1745246a413712801462c8a02b92aae003d75b6cf45ca1a3cb2996b41f57 diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 160c06534e..6ecb41bff8 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -715,14 +715,15 @@ def _is_framework_url(source: str) -> bool: # The default/recommended arduino framework version # - https://github.com/espressif/arduino-esp32/releases ARDUINO_FRAMEWORK_VERSION_LOOKUP = { - "recommended": cv.Version(3, 3, 8), - "latest": cv.Version(3, 3, 8), - "dev": cv.Version(3, 3, 8), + "recommended": cv.Version(3, 3, 9), + "latest": cv.Version(3, 3, 9), + "dev": cv.Version(3, 3, 9), } ARDUINO_PLATFORM_VERSION_LOOKUP = { cv.Version( 4, 0, 0, "alpha1" ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", + cv.Version(3, 3, 9): cv.Version(55, 3, 39), cv.Version(3, 3, 8): cv.Version(55, 3, 38, "1"), cv.Version(3, 3, 7): cv.Version(55, 3, 37), cv.Version(3, 3, 6): cv.Version(55, 3, 36), @@ -744,6 +745,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = { # See: https://github.com/pioarduino/esp-idf/releases ARDUINO_IDF_VERSION_LOOKUP = { cv.Version(4, 0, 0, "alpha1"): cv.Version(6, 0, 1), + cv.Version(3, 3, 9): cv.Version(5, 5, 4), cv.Version(3, 3, 8): cv.Version(5, 5, 4), cv.Version(3, 3, 7): cv.Version(5, 5, 3, "1"), cv.Version(3, 3, 6): cv.Version(5, 5, 2), @@ -776,7 +778,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { cv.Version( 6, 0, 0 ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", - cv.Version(5, 5, 4): cv.Version(55, 3, 38, "1"), + cv.Version(5, 5, 4): cv.Version(55, 3, 39), cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37), cv.Version(5, 5, 3): cv.Version(55, 3, 37), cv.Version(5, 5, 2): cv.Version(55, 3, 37), @@ -796,8 +798,8 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { # The platform-espressif32 version # - https://github.com/pioarduino/platform-espressif32/releases PLATFORM_VERSION_LOOKUP = { - "recommended": cv.Version(55, 3, 38, "1"), - "latest": cv.Version(55, 3, 38, "1"), + "recommended": cv.Version(55, 3, 39), + "latest": cv.Version(55, 3, 39), "dev": "https://github.com/pioarduino/platform-espressif32.git#develop", } diff --git a/platformio.ini b/platformio.ini index 4ac60d8099..07e9b8aad3 100644 --- a/platformio.ini +++ b/platformio.ini @@ -132,9 +132,9 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script ; This are common settings for the ESP32 (all variants) using Arduino. [common:esp32-arduino] extends = common:arduino -platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38-1/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip platform_packages = - pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.8/esp32-core-3.3.8.tar.xz + pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.9/esp32-core-3.3.9.tar.xz pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz framework = arduino, espidf ; Arduino as an ESP-IDF component @@ -167,7 +167,7 @@ extra_scripts = post:esphome/components/esp32/post_build.py.script ; This are common settings for the ESP32 (all variants) using IDF. [common:esp32-idf] extends = common:idf -platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38-1/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip platform_packages = pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz From aa11ddb333184fb450853985df377fe405a5d72d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:22:55 -0400 Subject: [PATCH 0334/1815] [zigbee][openthread][esp32_hosted] Fix clang-tidy findings (#16838) --- .../update/esp32_hosted_update.cpp | 5 +++- esphome/components/openthread/openthread.cpp | 5 ++-- esphome/components/openthread/openthread.h | 2 +- .../components/openthread/openthread_esp.cpp | 5 +++- esphome/components/zigbee/zigbee_esp32.cpp | 26 +++++++++---------- esphome/components/zigbee/zigbee_esp32.h | 5 +--- 6 files changed, 26 insertions(+), 22 deletions(-) diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index 70fa41b312..351b0869b0 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -56,7 +56,10 @@ static bool parse_version(const std::string &version_str, int &major, int &minor major = minor = patch = 0; const char *ptr = version_str.c_str(); - if (!parse_int(ptr, major) || *ptr++ != '.' || !parse_int(ptr, minor)) + if (!parse_int(ptr, major) || *ptr != '.') + return false; + ++ptr; + if (!parse_int(ptr, minor)) return false; if (*ptr == '.') parse_int(++ptr, patch); diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index 8557427096..bf14514636 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -9,6 +9,7 @@ #include #include +#include #include "esphome/core/application.h" #include "esphome/core/helpers.h" @@ -43,7 +44,7 @@ void OpenThreadComponent::dump_config() { } } -void OpenThreadComponent::on_state_changed_(otChangedFlags flags, void *context) { +void OpenThreadComponent::on_state_changed(otChangedFlags flags, void *context) { if (flags & OT_CHANGED_THREAD_ROLE) { auto *self = static_cast(context); // This runs on the OpenThread task thread with the OT lock held, @@ -241,7 +242,7 @@ bool OpenThreadComponent::teardown() { } void OpenThreadComponent::on_factory_reset(std::function callback) { - factory_reset_external_callback_ = callback; + this->factory_reset_external_callback_ = std::move(callback); ESP_LOGD(TAG, "Start Removal SRP Host and Services"); otError error; InstanceLock lock = InstanceLock::acquire(); diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index b42fdd2d30..5898492a50 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -46,7 +46,7 @@ class OpenThreadComponent : public Component { protected: std::optional get_omr_address_(InstanceLock &lock); - static void on_state_changed_(otChangedFlags flags, void *context); + static void on_state_changed(otChangedFlags flags, void *context); otInstance *get_openthread_instance_(); int openthread_stop_(); std::function factory_reset_external_callback_; diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index 787f2f5de8..cf1288d90c 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -179,7 +179,10 @@ void OpenThreadComponent::ot_main() { ESP_ERROR_CHECK(esp_openthread_auto_start(dataset.mLength > 0 ? &dataset : nullptr)); // Register state change callback to update connected_ reactively instead of polling - otSetStateChangedCallback(instance, OpenThreadComponent::on_state_changed_, this); + otError ot_err = otSetStateChangedCallback(instance, OpenThreadComponent::on_state_changed, this); + if (ot_err != OT_ERROR_NONE) { + ESP_LOGW(TAG, "Failed to register state change callback: %d", ot_err); + } esp_openthread_launch_mainloop(); diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index ade9e16572..1809f181be 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -42,7 +42,7 @@ static void bdb_start_top_level_commissioning_cb(uint8_t mode_mask) { } } -void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct) { +extern "C" void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct) { static uint8_t steering_retry_count = 0; uint32_t *p_sg_p = signal_struct->p_app_signal; esp_err_t err_status = signal_struct->esp_err_status; @@ -183,21 +183,21 @@ esp_err_t ZigbeeComponent::create_endpoint(uint8_t endpoint_id, zb_ha_standard_d esp_zb_cluster_list_t *esp_zb_cluster_list) { esp_zb_endpoint_config_t endpoint_config = {.endpoint = endpoint_id, .app_profile_id = ESP_ZB_AF_HA_PROFILE_ID, - .app_device_id = device_id, + .app_device_id = static_cast(device_id), .app_device_version = 0}; return esp_zb_ep_list_add_ep(this->esp_zb_ep_list_, esp_zb_cluster_list, endpoint_config); } -static void esp_zb_task_(void *pvParameters) { +static void esp_zb_task(void *pv_parameters) { if (esp_zb_start(false) != ESP_OK) { ESP_LOGE(TAG, "Could not setup Zigbee"); vTaskDelete(NULL); } if (global_zigbee->is_battery_powered()) { ESP_LOGD(TAG, "Battery powered!"); - esp_zb_set_node_descriptor_power_source(0); + esp_zb_set_node_descriptor_power_source(false); } else { - esp_zb_set_node_descriptor_power_source(1); + esp_zb_set_node_descriptor_power_source(true); } esp_zb_stack_main_loop(); } @@ -218,20 +218,20 @@ void ZigbeeComponent::setup() { return; } - esp_zb_zed_cfg_t zb_zed_cfg = { - .ed_timeout = ESP_ZB_ED_AGING_TIMEOUT_64MIN, - .keep_alive = ED_KEEP_ALIVE, - }; - esp_zb_zczr_cfg_t zb_zczr_cfg = { - .max_children = MAX_CHILDREN, - }; esp_zb_cfg_t zb_nwk_cfg = { .esp_zb_role = this->device_role_, .install_code_policy = false, }; #ifdef ZB_ROUTER_ROLE + esp_zb_zczr_cfg_t zb_zczr_cfg = { + .max_children = MAX_CHILDREN, + }; zb_nwk_cfg.nwk_cfg.zczr_cfg = zb_zczr_cfg; #else + esp_zb_zed_cfg_t zb_zed_cfg = { + .ed_timeout = ESP_ZB_ED_AGING_TIMEOUT_64MIN, + .keep_alive = ED_KEEP_ALIVE, + }; zb_nwk_cfg.nwk_cfg.zed_cfg = zb_zed_cfg; #endif esp_zb_init(&zb_nwk_cfg); @@ -290,7 +290,7 @@ void ZigbeeComponent::setup() { } } } - xTaskCreate(esp_zb_task_, "Zigbee_main", 4096, NULL, 24, NULL); + xTaskCreate(esp_zb_task, "Zigbee_main", 4096, NULL, 24, NULL); this->disable_loop(); // loop is only needed for processing events, so disable until we join a network } diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index 03d3286ab8..34b2b827b6 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -13,7 +13,6 @@ #include "ha/esp_zigbee_ha_standard.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" -#include "esphome/core/defines.h" #include "zigbee_helpers_esp32.h" #ifdef USE_BINARY_SENSOR @@ -99,8 +98,6 @@ class ZigbeeComponent : public Component { CallbackManager join_cb_{}; }; -extern "C" void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct); - template void ZigbeeComponent::add_attr(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, uint8_t max_size, T value) { @@ -129,7 +126,7 @@ template void ZigbeeComponent::add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, T *value_p) { esp_zb_attribute_list_t *attr_list = this->attribute_list_[{endpoint_id, cluster_id, role}]; - esp_err_t ret = esphome_zb_cluster_add_or_update_attr(cluster_id, attr_list, attr_id, value_p); + esphome_zb_cluster_add_or_update_attr(cluster_id, attr_list, attr_id, value_p); if (attr != nullptr) { this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; From 2ab4399ae51a02be3b0931f9cb4208593ad9931c Mon Sep 17 00:00:00 2001 From: Ross Tyler Date: Fri, 5 Jun 2026 11:31:17 -0700 Subject: [PATCH 0335/1815] [qmp6988] fix false report of software reset error (#16843) --- esphome/components/qmp6988/qmp6988.cpp | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/esphome/components/qmp6988/qmp6988.cpp b/esphome/components/qmp6988/qmp6988.cpp index 8c8a04c5b7..293d8aa648 100644 --- a/esphome/components/qmp6988/qmp6988.cpp +++ b/esphome/components/qmp6988/qmp6988.cpp @@ -216,10 +216,7 @@ int32_t QMP6988Component::get_compensated_pressure_(qmp6988_ik_data_t *ik, int32 } void QMP6988Component::software_reset_() { - uint8_t ret = 0; - - ret = this->write_byte(QMP6988_RESET_REG, 0xe6); - if (ret != i2c::ERROR_OK) { + if (!this->write_byte(QMP6988_RESET_REG, 0xe6)) { ESP_LOGE(TAG, "Software Reset (0xe6) failed"); } delay(10); From 4cb6f2c04609752711bc72d006146f43ace6ca2d Mon Sep 17 00:00:00 2001 From: Ross Tyler Date: Fri, 5 Jun 2026 11:35:33 -0700 Subject: [PATCH 0336/1815] [qmp6988] fix publishing bogus zero values on i2c error (#16840) --- esphome/components/qmp6988/qmp6988.cpp | 15 ++++++++++----- esphome/components/qmp6988/qmp6988.h | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/esphome/components/qmp6988/qmp6988.cpp b/esphome/components/qmp6988/qmp6988.cpp index 293d8aa648..bb47e7b0f5 100644 --- a/esphome/components/qmp6988/qmp6988.cpp +++ b/esphome/components/qmp6988/qmp6988.cpp @@ -280,20 +280,21 @@ void QMP6988Component::calculate_altitude_(float pressure, float temp) { this->qmp6988_data_.altitude = altitude; } -void QMP6988Component::calculate_pressure_() { +bool QMP6988Component::calculate_pressure_() { uint8_t err = 0; uint32_t p_read, t_read; int32_t p_raw, t_raw; uint8_t a_data_uint8_tr[6] = {0}; int32_t t_int, p_int; - this->qmp6988_data_.temperature = 0; - this->qmp6988_data_.pressure = 0; err = this->read_register(QMP6988_PRESSURE_MSB_REG, a_data_uint8_tr, 6); if (err != i2c::ERROR_OK) { ESP_LOGE(TAG, "Error reading raw pressure/temp values"); - return; + this->status_set_warning(); + return false; } + this->status_clear_warning(); + p_read = encode_uint24(a_data_uint8_tr[0], a_data_uint8_tr[1], a_data_uint8_tr[2]); p_raw = (int32_t) (p_read - SUBTRACTOR); @@ -305,6 +306,7 @@ void QMP6988Component::calculate_pressure_() { this->qmp6988_data_.temperature = (float) t_int / 256.0f; this->qmp6988_data_.pressure = (float) p_int / 16.0f; + return true; } void QMP6988Component::setup() { @@ -336,7 +338,10 @@ void QMP6988Component::dump_config() { } void QMP6988Component::update() { - this->calculate_pressure_(); + if (!this->calculate_pressure_()) { + return; + } + float pressurehectopascals = this->qmp6988_data_.pressure / 100; float temperature = this->qmp6988_data_.temperature; diff --git a/esphome/components/qmp6988/qmp6988.h b/esphome/components/qmp6988/qmp6988.h index 26f858b5d2..41759478b8 100644 --- a/esphome/components/qmp6988/qmp6988.h +++ b/esphome/components/qmp6988/qmp6988.h @@ -98,7 +98,7 @@ class QMP6988Component : public PollingComponent, public i2c::I2CDevice { void write_oversampling_temperature_(QMP6988Oversampling oversampling_t); void write_oversampling_pressure_(QMP6988Oversampling oversampling_p); void write_filter_(QMP6988IIRFilter filter); - void calculate_pressure_(); + bool calculate_pressure_(); void calculate_altitude_(float pressure, float temp); int32_t get_compensated_pressure_(qmp6988_ik_data_t *ik, int32_t dp, int16_t tx); From 77f644f57649bf2bc3bf564fe7aff3defb67b874 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:42:51 -0400 Subject: [PATCH 0337/1815] [ci] Share a cached native ESP-IDF install across clang-tidy and build jobs (#16841) --- .github/actions/cache-esp-idf/action.yml | 46 +++++++++++++++++++ .github/workflows/ci.yml | 56 ++++++++++++++++-------- 2 files changed, 83 insertions(+), 19 deletions(-) create mode 100644 .github/actions/cache-esp-idf/action.yml diff --git a/.github/actions/cache-esp-idf/action.yml b/.github/actions/cache-esp-idf/action.yml new file mode 100644 index 0000000000..7a17c222a3 --- /dev/null +++ b/.github/actions/cache-esp-idf/action.yml @@ -0,0 +1,46 @@ +name: Cache ESP-IDF +description: > + Resolve the pinned ESP-IDF version and cache the native ESP-IDF install + (toolchains + source) at ~/.esphome-idf. Every job that installs ESP-IDF + natively (clang-tidy for IDF/Arduino and the native-IDF component build) + shares one cache, since the install is identical (ESPHOME_IDF_DEFAULT_TARGETS + defaults to "all", so all toolchains are present regardless of the chip). + Callers must set env ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf and have the + Python venv already restored. +inputs: + framework: + description: 'Which pinned IDF version to key on: "espidf" (recommended) or "arduino".' + default: espidf +runs: + using: composite + steps: + - name: Resolve ESP-IDF version for cache key + # The native-IDF version is pinned in code, not in any file that feeds the + # other cache keys, so resolve it explicitly. Keying on it means the cache + # invalidates on a version bump (actions/cache never overwrites a key). + id: version + shell: bash + run: | + . venv/bin/activate + if [ "${{ inputs.framework }}" = "arduino" ]; then + version=$(python -c 'from esphome.components.esp32 import ARDUINO_FRAMEWORK_VERSION_LOOKUP as A, ARDUINO_IDF_VERSION_LOOKUP as L; print(L[A["recommended"]])') + else + version=$(python -c 'from esphome.components.esp32 import ESP_IDF_FRAMEWORK_VERSION_LOOKUP as L; print(L["recommended"])') + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + # Mirror the adjacent PlatformIO cache: only dev-branch runs write the + # shared cache (so it lives in the default-branch scope readable by all + # PRs), and PRs are restore-only -- they never push multi-GB artifacts into + # their own scope / the repo quota (e.g. on a version-bump PR). + - name: Cache ESP-IDF install (write on dev) + if: github.ref == 'refs/heads/dev' + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/.esphome-idf + key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }} + - name: Cache ESP-IDF install (restore-only off dev) + if: github.ref != 'refs/heads/dev' + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/.esphome-idf + key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae3f4e2b98..40267240d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -472,6 +472,8 @@ jobs: if: needs.determine-jobs.outputs.clang-tidy == 'true' env: GH_TOKEN: ${{ github.token }} + # esp32-arduino-tidy installs ESP-IDF natively; share the native IDF cache. + ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf strategy: fail-fast: false max-parallel: 2 @@ -484,6 +486,7 @@ jobs: - id: clang-tidy name: Run script/clang-tidy for ESP32 Arduino options: --environment esp32-arduino-tidy --grep USE_ARDUINO + cache_idf: true - id: clang-tidy name: Run script/clang-tidy for ZEPHYR options: --environment nrf52-tidy --grep USE_ZEPHYR --grep USE_NRF52 @@ -517,6 +520,13 @@ jobs: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} + - name: Cache ESP-IDF install + # Shared with the IDF tidy + native-IDF build jobs (same install). + if: matrix.cache_idf + uses: ./.github/actions/cache-esp-idf + with: + framework: arduino + - name: Register problem matchers run: | echo "::add-matcher::.github/workflows/matchers/gcc.json" @@ -568,6 +578,8 @@ jobs: if: needs.determine-jobs.outputs.clang-tidy-mode == 'nosplit' env: GH_TOKEN: ${{ github.token }} + # esp32-idf-tidy installs ESP-IDF natively; share the native IDF cache. + ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf steps: - name: Check out code from GitHub uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -581,6 +593,10 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} + - name: Cache ESP-IDF install + # Shared with the Arduino tidy + native-IDF build jobs (same install). + uses: ./.github/actions/cache-esp-idf + - name: Register problem matchers run: | echo "::add-matcher::.github/workflows/matchers/gcc.json" @@ -631,6 +647,8 @@ jobs: if: needs.determine-jobs.outputs.clang-tidy-mode == 'split' env: GH_TOKEN: ${{ github.token }} + # esp32-idf-tidy installs ESP-IDF natively; share the native IDF cache. + ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf strategy: fail-fast: false max-parallel: 3 @@ -659,6 +677,10 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} + - name: Cache ESP-IDF install + # Shared with the Arduino tidy + native-IDF build jobs (same install). + uses: ./.github/actions/cache-esp-idf + - name: Register problem matchers run: | echo "::add-matcher::.github/workflows/matchers/gcc.json" @@ -785,7 +807,7 @@ jobs: fi echo "" - # Show disk space before validation (after bind mounts setup) + # Show disk space before validation echo "Disk space before config validation:" df -h echo "" @@ -861,33 +883,20 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - name: Cache ESPHome - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.esphome-idf - key: ${{ runner.os }}-esphome-${{ needs.common.outputs.cache-key }} - - - name: Run native ESP-IDF compile test + - name: Prepare build storage on /mnt + # Bind-mount the larger /mnt disk over the IDF install + build dirs BEFORE + # restoring the cache, so the ~4.5GB restore lands on the roomier volume + # instead of being shadowed by a mount set up later in the run step. run: | - . venv/bin/activate - - # Check if /mnt has more free space than / before bind mounting - # Extract available space in KB for comparison root_avail=$(df -k / | awk 'NR==2 {print $4}') mnt_avail=$(df -k /mnt 2>/dev/null | awk 'NR==2 {print $4}') - echo "Available space: / has ${root_avail}KB, /mnt has ${mnt_avail}KB" - - # Only use /mnt if it has more space than / if [ -n "$mnt_avail" ] && [ "$mnt_avail" -gt "$root_avail" ]; then echo "Using /mnt for build files (more space available)" - # Bind mount PlatformIO directory to /mnt (tools, packages, build cache all go there) sudo mkdir -p /mnt/esphome-idf sudo chown $USER:$USER /mnt/esphome-idf mkdir -p ~/.esphome-idf sudo mount --bind /mnt/esphome-idf ~/.esphome-idf - - # Bind mount test build directory to /mnt sudo mkdir -p /mnt/test_build_components_build sudo chown $USER:$USER /mnt/test_build_components_build mkdir -p tests/test_build_components/build @@ -896,10 +905,19 @@ jobs: echo "Using / for build files (more space available than /mnt or /mnt unavailable)" fi + - name: Cache ESP-IDF install + # Shared with the IDF/Arduino clang-tidy jobs (same install); restores + # into the /mnt bind-mount prepared above when present. + uses: ./.github/actions/cache-esp-idf + + - name: Run native ESP-IDF compile test + run: | + . venv/bin/activate + echo "Testing components: $TEST_COMPONENTS" echo "" - # Show disk space before validation (after bind mounts setup) + # Show disk space before validation echo "Disk space before config validation:" df -h echo "" From b63e327ae35186c35771d572b3d95bf6f2e19b98 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 5 Jun 2026 17:22:03 -0400 Subject: [PATCH 0338/1815] [audio] Deprecate unused scale_audio_samples helper (#16831) --- esphome/components/audio/audio.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/audio/audio.h b/esphome/components/audio/audio.h index 62c57b18cf..36780a3055 100644 --- a/esphome/components/audio/audio.h +++ b/esphome/components/audio/audio.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" // for ESPDEPRECATED #include #include @@ -143,6 +144,8 @@ AudioFileType detect_audio_file_type(const char *content_type, const char *url); /// @param output_buffer Buffer to store the scaled samples /// @param scale_factor Q15 fixed point scaling factor /// @param samples_to_scale Number of samples to scale +// Remove before 2026.12.0 +ESPDEPRECATED("Use esp_audio_libs::gain::apply() (from ) instead. Removed in 2026.12.0.", "2026.6.0") void scale_audio_samples(const int16_t *audio_samples, int16_t *output_buffer, int16_t scale_factor, size_t samples_to_scale); From 913b9f5ca442c44e9c7589883a715120dc5d70be Mon Sep 17 00:00:00 2001 From: i-am-no-magic Date: Fri, 5 Jun 2026 23:37:40 +0200 Subject: [PATCH 0339/1815] [tuya] Fixed hysteresis bug for Tuya climate (#16832) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/tuya/climate/tuya_climate.cpp | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/esphome/components/tuya/climate/tuya_climate.cpp b/esphome/components/tuya/climate/tuya_climate.cpp index 7dbf33878a..111d090c3e 100644 --- a/esphome/components/tuya/climate/tuya_climate.cpp +++ b/esphome/components/tuya/climate/tuya_climate.cpp @@ -513,14 +513,14 @@ void TuyaClimate::compute_state_() { } else { // Fallback to active state calc based on temp and hysteresis const float temp_diff = this->target_temperature - this->current_temperature; - if (std::abs(temp_diff) > this->hysteresis_) { - if (this->supports_heat_ && temp_diff > 0) { - target_action = climate::CLIMATE_ACTION_HEATING; - this->mode = climate::CLIMATE_MODE_HEAT; - } else if (this->supports_cool_ && temp_diff < 0) { - target_action = climate::CLIMATE_ACTION_COOLING; - this->mode = climate::CLIMATE_MODE_COOL; - } + if ((this->supports_heat_ && temp_diff >= this->hysteresis_) || + (this->action == climate::CLIMATE_ACTION_HEATING && temp_diff > 0)) { + target_action = climate::CLIMATE_ACTION_HEATING; + this->mode = climate::CLIMATE_MODE_HEAT; + } else if ((this->supports_cool_ && temp_diff <= -this->hysteresis_) || + (this->action == climate::CLIMATE_ACTION_COOLING && temp_diff < 0)) { + target_action = climate::CLIMATE_ACTION_COOLING; + this->mode = climate::CLIMATE_MODE_COOL; } } From 93334d4e606dd4c710c9ccca411c966825baa879 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 6 Jun 2026 07:44:31 +1000 Subject: [PATCH 0340/1815] [scripts] Fix build_language_schema (#16816) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- script/build_language_schema.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 6e4000e06e..025186299d 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -924,9 +924,14 @@ def convert(schema, config_var, path): config_var[S_TYPE] = "enum" config_var["values"] = dict.fromkeys(list(data.keys())) elif schema_type == "maybe": - config_var[S_TYPE] = S_SCHEMA + # maybe_simple_value: either a scalar shorthand (mapped to the key in + # data[1]) or the full wrapped schema. The wrapped schema is usually a + # plain Schema (converts to a "schema" config var), but may be something + # else, e.g. a typed_schema (converts to a "typed" config var with + # "types" and no top-level "schema" key). Merge whatever it produced + # rather than assuming a "schema" key is present. config_var["maybe"] = data[1] - config_var["schema"] = convert_config(data[0], path + "/maybe")["schema"] + config_var.update(convert_config(data[0], path + "/maybe")) # esphome/on_boot elif schema_type == "automation": extra_schema = None From 85fd83288da8ce5985f6b960c93d97682f8b7c91 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 18:29:33 -0400 Subject: [PATCH 0341/1815] [esp32_camera] Bump esp32-camera to 2.1.7 (#16846) --- .clang-tidy.hash | 2 +- esphome/components/camera_encoder/__init__.py | 9 ++------- esphome/components/esp32/__init__.py | 5 ++++- esphome/components/esp32_camera/__init__.py | 10 ++-------- esphome/components/zigbee/zigbee_esp32.py | 7 +++---- esphome/idf_component.yml | 2 +- 6 files changed, 13 insertions(+), 22 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index cab077385d..0782b065f3 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -6f2f1745246a413712801462c8a02b92aae003d75b6cf45ca1a3cb2996b41f57 +0b8325f52fca9224efb80dacca51ccbc8b3499bde7bb4aaa6f28a848c2e0a6a8 diff --git a/esphome/components/camera_encoder/__init__.py b/esphome/components/camera_encoder/__init__.py index 7d4cdc881e..344248fcf3 100644 --- a/esphome/components/camera_encoder/__init__.py +++ b/esphome/components/camera_encoder/__init__.py @@ -1,8 +1,5 @@ import esphome.codegen as cg -from esphome.components.esp32 import ( - add_idf_component, - require_libc_picolibc_newlib_compat, -) +from esphome.components.esp32 import add_idf_component import esphome.config_validation as cv from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TYPE from esphome.types import ConfigType @@ -53,9 +50,7 @@ async def to_code(config: ConfigType) -> None: buffer = cg.new_Pvariable(config[CONF_ENCODER_BUFFER_ID]) cg.add(buffer.set_buffer_size(config[CONF_BUFFER_SIZE])) if config[CONF_TYPE] == ESP32_CAMERA_ENCODER: - add_idf_component(name="espressif/esp32-camera", ref="2.1.5") - # esp32-camera 2.1.5 needs the Newlib shim on IDF 6.0+; remove when fixed upstream - require_libc_picolibc_newlib_compat() + add_idf_component(name="espressif/esp32-camera", ref="2.1.7") cg.add_define("USE_ESP32_CAMERA_JPEG_ENCODER") var = cg.new_Pvariable( config[CONF_ID], diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 6ecb41bff8..7e7b127814 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1375,8 +1375,11 @@ def require_libc_picolibc_newlib_compat() -> None: """Keep CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY enabled on IDF 6.0+. Call this from components that link against precompiled Newlib binaries - referencing types/symbols the shim provides (e.g. esp32-camera). + referencing types/symbols the shim provides (e.g. zigbee). No-op on + IDF < 6.0.0. """ + if idf_version() < cv.Version(6, 0, 0): + return CORE.data[KEY_ESP32][KEY_LIBC_PICOLIBC_NEWLIB_COMPAT_REQUIRED] = True diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index 763a1f3405..c3b35a8279 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -3,11 +3,7 @@ import logging from esphome import automation, pins import esphome.codegen as cg from esphome.components import i2c -from esphome.components.esp32 import ( - add_idf_component, - add_idf_sdkconfig_option, - require_libc_picolibc_newlib_compat, -) +from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option from esphome.components.psram import DOMAIN as psram_domain import esphome.config_validation as cv from esphome.const import ( @@ -403,11 +399,9 @@ async def to_code(config): if config[CONF_JPEG_QUALITY] != 0 and config[CONF_PIXEL_FORMAT] != "JPEG": cg.add_define("USE_ESP32_CAMERA_JPEG_CONVERSION") - add_idf_component(name="espressif/esp32-camera", ref="2.1.5") + add_idf_component(name="espressif/esp32-camera", ref="2.1.7") add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_NEW", True) add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_LEGACY", False) - # esp32-camera 2.1.5 needs the Newlib shim on IDF 6.0+; remove when fixed upstream - require_libc_picolibc_newlib_compat() for conf in config.get(CONF_ON_STREAM_START, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index a0fadbce8b..086cdcc267 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -9,7 +9,7 @@ from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, add_partition, - idf_version, + require_libc_picolibc_newlib_compat, require_vfs_select, ) import esphome.config_validation as cv @@ -240,9 +240,8 @@ async def _zigbee_add_sdkconfigs(config: ConfigType) -> None: # dynamic log level control to be enabled add_idf_sdkconfig_option("CONFIG_LOG_DYNAMIC_LEVEL_CONTROL", True) # The pre-built Zigbee library is compiled against newlib which requires newlib - # reentrancy to be enabled with picolibc compatibility. - if idf_version() >= cv.Version(6, 0, 0): - add_idf_sdkconfig_option("CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY", True) + # reentrancy to be enabled with picolibc compatibility (IDF 6.0+ only). + require_libc_picolibc_newlib_compat() async def attributes_to_code( diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 9c87a7e5cf..3a5b050072 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -20,7 +20,7 @@ dependencies: espressif/esp-tflite-micro: version: 1.3.3~1 espressif/esp32-camera: - version: 2.1.5 + version: 2.1.7 espressif/mdns: version: 1.11.0 espressif/esp_wifi_remote: From f18cf954bae11aa10f1ef574ab4ec5a3313a0c87 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 18:30:26 -0400 Subject: [PATCH 0342/1815] [improv_serial] Fix build on ESP32-C5/P4 and simplify variant guards (#16833) --- .../components/improv_serial/improv_serial_component.cpp | 9 +++------ .../components/improv_serial/improv_serial_component.h | 3 +-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index 18d0b44701..206df2c844 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -68,11 +68,9 @@ optional ImprovSerialComponent::read_byte_() { switch (logger::global_logger->get_uart()) { case logger::UART_SELECTION_UART0: case logger::UART_SELECTION_UART1: -#if !defined(USE_ESP32_VARIANT_ESP32C3) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ - !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32S2) && !defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32) case logger::UART_SELECTION_UART2: -#endif // !USE_ESP32_VARIANT_ESP32C3 && !USE_ESP32_VARIANT_ESP32C6 && !USE_ESP32_VARIANT_ESP32C61 && - // !USE_ESP32_VARIANT_ESP32S2 && !USE_ESP32_VARIANT_ESP32S3 +#endif if (this->uart_num_ >= 0) { size_t available; uart_get_buffered_data_len(this->uart_num_, &available); @@ -136,8 +134,7 @@ void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size) switch (logger::global_logger->get_uart()) { case logger::UART_SELECTION_UART0: case logger::UART_SELECTION_UART1: -#if !defined(USE_ESP32_VARIANT_ESP32C3) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ - !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32S2) && !defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32) case logger::UART_SELECTION_UART2: #endif uart_write_bytes(this->uart_num_, this->tx_header_, header_tx_len); diff --git a/esphome/components/improv_serial/improv_serial_component.h b/esphome/components/improv_serial/improv_serial_component.h index 2f1d0136a4..c58c42f0d8 100644 --- a/esphome/components/improv_serial/improv_serial_component.h +++ b/esphome/components/improv_serial/improv_serial_component.h @@ -11,8 +11,7 @@ #ifdef USE_ESP32 #include -#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || \ - defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32S3) +#ifdef USE_LOGGER_USB_SERIAL_JTAG #include #include #endif From 70d9ab25f3e20f8301ab353ce45dd543e61087db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Jun 2026 17:57:42 -0500 Subject: [PATCH 0343/1815] [tests] Fail component test merge on conflicting duplicate IDs (#16795) --- .github/workflows/ci.yml | 1 + script/ci_check_duplicate_test_ids.py | 122 ++++++++++++++++++ script/merge_component_configs.py | 48 ++++++- tests/components/adc/test.bk72xx-ard.yaml | 2 +- tests/components/adc/test.esp32-c2-idf.yaml | 2 +- tests/components/adc/test.esp32-c3-idf.yaml | 2 +- tests/components/adc/test.esp32-idf.yaml | 2 +- tests/components/adc/test.esp32-p4-idf.yaml | 2 +- tests/components/adc/test.esp32-s2-idf.yaml | 2 +- tests/components/adc/test.esp32-s3-idf.yaml | 2 +- tests/components/adc/test.esp8266-ard.yaml | 2 +- tests/components/adc/test.ln882x-ard.yaml | 2 +- tests/components/adc/test.rp2040-ard.yaml | 2 +- .../components/adc/test.rp2040-pico2-ard.yaml | 2 +- .../alarm_control_panel/common.yaml | 6 +- .../components/animation/test.esp32-idf.yaml | 2 +- .../animation/test.esp8266-ard.yaml | 2 +- .../components/animation/test.rp2040-ard.yaml | 2 +- tests/components/axs15231/common.yaml | 4 +- .../components/axs15231/test.esp8266-ard.yaml | 4 +- tests/components/bang_bang/common.yaml | 12 +- tests/components/binary_sensor/common.yaml | 6 +- .../components/binary_sensor_map/common.yaml | 24 ++-- tests/components/ble_client/common.yaml | 4 +- tests/components/canbus/common.yaml | 6 +- tests/components/climate_ir_lg/common.yaml | 4 +- .../components/color_temperature/common.yaml | 8 +- tests/components/copy/common.yaml | 8 +- tests/components/current_based/common.yaml | 6 +- tests/components/cwww/common.yaml | 4 +- tests/components/cwww/test.esp32-idf.yaml | 4 +- tests/components/cwww/test.esp8266-ard.yaml | 4 +- tests/components/cwww/test.rp2040-ard.yaml | 4 +- tests/components/display/common.yaml | 2 +- tests/components/duty_time/common.yaml | 4 +- tests/components/e131/test.rp2040-ard.yaml | 2 +- tests/components/ektf2232/common.yaml | 4 +- tests/components/endstop/common.yaml | 12 +- tests/components/esp32_can/common.yaml | 2 +- .../esp32_can/test.esp32-c6-idf.yaml | 6 +- tests/components/espnow/common.yaml | 6 +- .../components/fastled_clockless/common.yaml | 6 +- tests/components/fastled_spi/common.yaml | 6 +- tests/components/font/common.yaml | 6 +- tests/components/font/test.host.yaml | 6 +- tests/components/graph/common.yaml | 2 +- .../graphical_display_menu/common.yaml | 17 +-- tests/components/gt911/common.yaml | 4 +- tests/components/image/test.esp32-idf.yaml | 2 +- tests/components/image/test.esp8266-ard.yaml | 2 +- tests/components/image/test.rp2040-ard.yaml | 2 +- .../components/integration/common-esp32.yaml | 4 +- .../integration/test.esp8266-ard.yaml | 4 +- .../integration/test.rp2040-ard.yaml | 4 +- tests/components/lcd_menu/common.yaml | 4 +- tests/components/light/common.yaml | 2 +- tests/components/light/test.esp32-idf.yaml | 2 +- tests/components/light/test.esp8266-ard.yaml | 2 +- .../components/light/test.nrf52-adafruit.yaml | 4 +- tests/components/light/test.nrf52-mcumgr.yaml | 4 +- tests/components/light/test.rp2040-ard.yaml | 2 +- tests/components/lilygo_t5_47/common.yaml | 4 +- tests/components/lock/common.yaml | 4 +- tests/components/mapping/test.esp32-idf.yaml | 2 +- .../components/mapping/test.esp8266-ard.yaml | 2 +- tests/components/mapping/test.rp2040-ard.yaml | 2 +- tests/components/monochromatic/common.yaml | 4 +- tests/components/mpr121/common.yaml | 6 +- tests/components/nextion/common.yaml | 4 +- .../components/nextion/common_tft_upload.yaml | 2 +- .../nextion/common_tft_upload_watchdog.yaml | 2 +- tests/components/ntc/common.yaml | 10 +- tests/components/number/common.yaml | 4 +- .../components/online_image/common-esp32.yaml | 2 +- .../online_image/common-esp8266.yaml | 2 +- .../online_image/common-rp2040.yaml | 2 +- .../online_image/test.esp32-s3-ard.yaml | 2 +- .../online_image/test.esp32-s3-idf.yaml | 2 +- tests/components/output/common.yaml | 12 +- tests/components/pi4ioe5v6408/common.yaml | 2 +- tests/components/pid/common.yaml | 6 +- tests/components/prometheus/common.yaml | 6 +- tests/components/qspi_dbi/common.yaml | 2 +- .../remote_transmitter/common-buttons.yaml | 6 +- tests/components/resistance/common.yaml | 6 +- tests/components/rgb/common.yaml | 8 +- tests/components/rgbct/common.yaml | 8 +- tests/components/rgbw/common.yaml | 8 +- tests/components/rgbww/common.yaml | 8 +- .../rp2040_pio_led_strip/common.yaml | 2 +- tests/components/rp2040_pwm/common.yaml | 4 +- tests/components/sdl/common.yaml | 8 +- tests/components/speaker/common.yaml | 4 +- tests/components/speed/common.yaml | 6 +- tests/components/sprinkler/common.yaml | 12 +- tests/components/ssd1306_i2c/common.yaml | 2 +- tests/components/switch/common.yaml | 2 +- tests/components/sx126x/common.yaml | 4 +- tests/components/sx127x/common.yaml | 4 +- tests/components/template/common-base.yaml | 14 +- tests/components/tt21100/common.yaml | 4 +- tests/components/uart/test.esp32-idf.yaml | 4 +- tests/components/udp/common.yaml | 4 +- tests/components/ufire_ec/common.yaml | 6 +- tests/components/ufire_ise/common.yaml | 4 +- tests/components/web_server_idf/common.yaml | 4 +- tests/components/wk2132_i2c/common.yaml | 2 +- tests/components/wk2132_spi/common.yaml | 2 +- tests/components/wk2168_i2c/common.yaml | 2 +- tests/components/wk2168_spi/common.yaml | 2 +- tests/components/wk2204_i2c/common.yaml | 2 +- tests/components/wk2204_spi/common.yaml | 2 +- tests/components/wk2212_i2c/common.yaml | 2 +- tests/components/wk2212_spi/common.yaml | 2 +- tests/script/test_merge_component_configs.py | 72 +++++++++++ 115 files changed, 482 insertions(+), 252 deletions(-) create mode 100755 script/ci_check_duplicate_test_ids.py create mode 100644 tests/script/test_merge_component_configs.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40267240d8..96c205fb70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,6 +113,7 @@ jobs: script/build_language_schema.py --check script/generate-esp32-boards.py --check script/generate-rp2040-boards.py --check + script/ci_check_duplicate_test_ids.py import-time: name: Check import esphome.__main__ time diff --git a/script/ci_check_duplicate_test_ids.py b/script/ci_check_duplicate_test_ids.py new file mode 100755 index 0000000000..9d498dd64f --- /dev/null +++ b/script/ci_check_duplicate_test_ids.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Fail when two component test fixtures define the same id with different content. + +Component tests are merged and built in groups in CI (see +``script/merge_component_configs.py``). When two components declare the same id +under the same section but with different content, the merge silently keeps the +first and drops the rest, which can make a cross-reference resolve to an +incompatible entity (this is what broke the i2s_audio speaker tests). The merge +now raises on such a collision, but only when the two components land in the same +group. This script is the complete, batch-independent guard: it scans every +component's ``test..yaml`` per platform and reports any id that is +defined by more than one component with differing content. + +Ids that are intentionally shared across components (e.g. a singleton +``sntp_time`` clock) are listed in ``INTENTIONALLY_SHARED_IDS`` and skipped. +""" + +from __future__ import annotations + +from collections import defaultdict +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from script.merge_component_configs import ( # noqa: E402 + INTENTIONALLY_SHARED_IDS, + load_yaml_file, +) + +TESTS_DIR = Path("tests/components") + + +def _normalize(value: object) -> object: + """Return a hashable, order-independent representation for comparison.""" + if isinstance(value, dict): + return tuple(sorted((str(k), _normalize(v)) for k, v in value.items())) + if isinstance(value, (list, tuple)): + return tuple(_normalize(v) for v in value) + # Scalars (and ESPHome tag objects like !lambda) compare by their text form + return str(value) + + +def _collect_ids( + data: object, section: str, out: dict[tuple[str, str], object] +) -> None: + """Walk a parsed config and record (section, id) -> normalized content.""" + if isinstance(data, dict): + for key, value in data.items(): + if isinstance(value, list): + for item in value: + if isinstance(item, dict) and "id" in item: + out[(key, str(item["id"]))] = _normalize(item) + _collect_ids(item, key, out) + else: + _collect_ids(value, key, out) + elif isinstance(data, list): + for item in data: + _collect_ids(item, section, out) + + +def _discover_platforms() -> set[str]: + platforms: set[str] = set() + for test_file in TESTS_DIR.glob("*/test.*.yaml"): + # test..yaml -> platform is the middle dotted part + parts = test_file.name.split(".") + if len(parts) == 3: + platforms.add(parts[1]) + return platforms + + +def main() -> int: + conflicts: list[str] = [] + for platform in sorted(_discover_platforms()): + # (section, id) -> {normalized_content: [components]} + by_id: dict[tuple[str, str], dict[object, list[str]]] = defaultdict( + lambda: defaultdict(list) + ) + for comp_dir in sorted(TESTS_DIR.iterdir()): + if not comp_dir.is_dir(): + continue + test_file = comp_dir / f"test.{platform}.yaml" + if not test_file.exists(): + continue + try: + data = load_yaml_file(test_file) + except Exception as err: # noqa: BLE001 + print(f"WARNING: could not parse {test_file}: {err}", file=sys.stderr) + continue + ids: dict[tuple[str, str], object] = {} + _collect_ids(data, "", ids) + for (section, id_), content in ids.items(): + if id_ in INTENTIONALLY_SHARED_IDS: + continue + by_id[(section, id_)][content].append(comp_dir.name) + + for (section, id_), variants in sorted(by_id.items()): + if len(variants) < 2: + continue + components = sorted({c for comps in variants.values() for c in comps}) + conflicts.append( + f"[{platform}] id '{id_}' under '{section}' is defined " + f"differently by: {', '.join(components)}" + ) + + if conflicts: + print("Conflicting test component ids found:\n") + for line in conflicts: + print(f" - {line}") + print( + "\nGive each component a unique id (e.g. '_'), or add the " + "id to INTENTIONALLY_SHARED_IDS in script/merge_component_configs.py if " + "it is a deliberately shared singleton." + ) + return 1 + + print("No conflicting test component ids found.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/merge_component_configs.py b/script/merge_component_configs.py index a952ecff16..20457e906a 100755 --- a/script/merge_component_configs.py +++ b/script/merge_component_configs.py @@ -161,18 +161,42 @@ def prefix_substitutions_in_dict( return data +# Ids that several components intentionally share. ESPHome treats these as a +# single instance when merged (e.g. multiple components each declaring the same +# `sntp_time` clock collapse into one), so duplicates with differing content are +# expected and must not be flagged as accidental collisions. +INTENTIONALLY_SHARED_IDS = frozenset( + { + # Several components each declare an `sntp_time` clock; ESPHome merges + # them into one time source. + "sntp_time", + # esp_ldo and mipi_dsi both configure the channel-3 internal LDO on the + # ESP32-P4; only one LDO per channel may exist, so the shared id lets the + # merge collapse them into a single LDO. + "ldo_id", + } +) + + def deduplicate_by_id(data: dict) -> dict: """Deduplicate list items with the same ID. - Keeps only the first occurrence of each ID. If items with the same ID - are identical, this silently deduplicates. If they differ, the first - one is kept (ESPHome's validation will catch if this causes issues). + Identical items sharing an ID (e.g. a shared bus from a common package pulled + in by several components) are collapsed to the first occurrence. Two items that + share an ID but differ in content are a real conflict: when merged, the first + one silently wins and the others are dropped, which can make cross-references + resolve to an incompatible entity. Rather than defer that to downstream + validation (where it surfaces as a confusing, order-dependent failure), raise + immediately so the offending ID is named. Args: data: Parsed config dictionary Returns: Config with deduplicated lists + + Raises: + ValueError: If two items share an ID but have different content. """ if not isinstance(data, dict): return data @@ -181,16 +205,26 @@ def deduplicate_by_id(data: dict) -> dict: for key, value in data.items(): if isinstance(value, list): # Check for items with 'id' field - seen_ids = set() + seen_items = {} deduped_list = [] for item in value: if isinstance(item, dict) and "id" in item: item_id = item["id"] - if item_id not in seen_ids: - seen_ids.add(item_id) + if item_id not in seen_items: + seen_items[item_id] = item deduped_list.append(item) - # else: skip duplicate ID (keep first occurrence) + elif item_id in INTENTIONALLY_SHARED_IDS: + # Designed singleton shared by several components (e.g. an + # `sntp_time` clock); ESPHome collapses these, so keep first. + pass + elif item != seen_items[item_id]: + raise ValueError( + f"Conflicting definitions for id '{item_id}' under " + f"'{key}' when merging test configs; give each " + f"component a unique id" + ) + # else: identical duplicate (e.g. shared bus package) -> skip else: # No ID, just add it deduped_list.append(item) diff --git a/tests/components/adc/test.bk72xx-ard.yaml b/tests/components/adc/test.bk72xx-ard.yaml index 0645333a81..09ef0e1fad 100644 --- a/tests/components/adc/test.bk72xx-ard.yaml +++ b/tests/components/adc/test.bk72xx-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: P23 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-c2-idf.yaml b/tests/components/adc/test.esp32-c2-idf.yaml index e764f0fe21..a3019466b5 100644 --- a/tests/components/adc/test.esp32-c2-idf.yaml +++ b/tests/components/adc/test.esp32-c2-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-c3-idf.yaml b/tests/components/adc/test.esp32-c3-idf.yaml index e764f0fe21..a3019466b5 100644 --- a/tests/components/adc/test.esp32-c3-idf.yaml +++ b/tests/components/adc/test.esp32-c3-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-idf.yaml b/tests/components/adc/test.esp32-idf.yaml index ff1e3bb919..f31e0e087d 100644 --- a/tests/components/adc/test.esp32-idf.yaml +++ b/tests/components/adc/test.esp32-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: A0 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-p4-idf.yaml b/tests/components/adc/test.esp32-p4-idf.yaml index b77dc299c2..77cf50d17c 100644 --- a/tests/components/adc/test.esp32-p4-idf.yaml +++ b/tests/components/adc/test.esp32-p4-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: GPIO16 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-s2-idf.yaml b/tests/components/adc/test.esp32-s2-idf.yaml index e764f0fe21..a3019466b5 100644 --- a/tests/components/adc/test.esp32-s2-idf.yaml +++ b/tests/components/adc/test.esp32-s2-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-s3-idf.yaml b/tests/components/adc/test.esp32-s3-idf.yaml index e764f0fe21..a3019466b5 100644 --- a/tests/components/adc/test.esp32-s3-idf.yaml +++ b/tests/components/adc/test.esp32-s3-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp8266-ard.yaml b/tests/components/adc/test.esp8266-ard.yaml index 4cc865bb5d..617464818b 100644 --- a/tests/components/adc/test.esp8266-ard.yaml +++ b/tests/components/adc/test.esp8266-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: VCC name: ADC Test sensor diff --git a/tests/components/adc/test.ln882x-ard.yaml b/tests/components/adc/test.ln882x-ard.yaml index face38b647..d899259773 100644 --- a/tests/components/adc/test.ln882x-ard.yaml +++ b/tests/components/adc/test.ln882x-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: A5 name: ADC Test sensor diff --git a/tests/components/adc/test.rp2040-ard.yaml b/tests/components/adc/test.rp2040-ard.yaml index 4cc865bb5d..617464818b 100644 --- a/tests/components/adc/test.rp2040-ard.yaml +++ b/tests/components/adc/test.rp2040-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: VCC name: ADC Test sensor diff --git a/tests/components/adc/test.rp2040-pico2-ard.yaml b/tests/components/adc/test.rp2040-pico2-ard.yaml index 4cc865bb5d..617464818b 100644 --- a/tests/components/adc/test.rp2040-pico2-ard.yaml +++ b/tests/components/adc/test.rp2040-pico2-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: VCC name: ADC Test sensor diff --git a/tests/components/alarm_control_panel/common.yaml b/tests/components/alarm_control_panel/common.yaml index 39d5739255..327234d6ca 100644 --- a/tests/components/alarm_control_panel/common.yaml +++ b/tests/components/alarm_control_panel/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: gpio - id: bin1 + id: alarm_control_panel_bin1 pin: 1 alarm_control_panel: @@ -18,7 +18,7 @@ alarm_control_panel: pending_time: 15s trigger_time: 30s binary_sensors: - - input: bin1 + - input: alarm_control_panel_bin1 bypass_armed_home: true bypass_armed_night: true bypass_auto: true @@ -39,7 +39,7 @@ alarm_control_panel: pending_time: 15s trigger_time: 30s binary_sensors: - - input: bin1 + - input: alarm_control_panel_bin1 bypass_armed_home: true bypass_armed_night: true bypass_auto: true diff --git a/tests/components/animation/test.esp32-idf.yaml b/tests/components/animation/test.esp32-idf.yaml index c28e9584dd..b844f5ae92 100644 --- a/tests/components/animation/test.esp32-idf.yaml +++ b/tests/components/animation/test.esp32-idf.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: animation_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 12 diff --git a/tests/components/animation/test.esp8266-ard.yaml b/tests/components/animation/test.esp8266-ard.yaml index 11a7117d91..a7937ffca2 100644 --- a/tests/components/animation/test.esp8266-ard.yaml +++ b/tests/components/animation/test.esp8266-ard.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: animation_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 5 diff --git a/tests/components/animation/test.rp2040-ard.yaml b/tests/components/animation/test.rp2040-ard.yaml index 2c99e937f3..2cbb254adf 100644 --- a/tests/components/animation/test.rp2040-ard.yaml +++ b/tests/components/animation/test.rp2040-ard.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: animation_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 20 diff --git a/tests/components/axs15231/common.yaml b/tests/components/axs15231/common.yaml index d4fd3becbb..03e82ab26e 100644 --- a/tests/components/axs15231/common.yaml +++ b/tests/components/axs15231/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_i2c_display + id: axs15231_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: 19 pages: @@ -13,6 +13,6 @@ touchscreen: - platform: axs15231 i2c_id: i2c_bus id: axs15231_touchscreen - display: ssd1306_i2c_display + display: axs15231_ssd1306_i2c_display interrupt_pin: 20 reset_pin: 18 diff --git a/tests/components/axs15231/test.esp8266-ard.yaml b/tests/components/axs15231/test.esp8266-ard.yaml index eb599da773..245b87bec9 100644 --- a/tests/components/axs15231/test.esp8266-ard.yaml +++ b/tests/components/axs15231/test.esp8266-ard.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_display + id: axs15231_ssd1306_display model: SSD1306_128X64 reset_pin: 13 pages: @@ -15,5 +15,5 @@ display: touchscreen: - platform: axs15231 i2c_id: i2c_bus - display: ssd1306_display + display: axs15231_ssd1306_display interrupt_pin: 12 diff --git a/tests/components/bang_bang/common.yaml b/tests/components/bang_bang/common.yaml index 5882025191..28798f8173 100644 --- a/tests/components/bang_bang/common.yaml +++ b/tests/components/bang_bang/common.yaml @@ -1,6 +1,6 @@ switch: - platform: template - id: template_switch1 + id: bang_bang_template_switch1 optimistic: true - platform: template id: template_switch2 @@ -8,7 +8,7 @@ switch: sensor: - platform: template - id: template_sensor1 + id: bang_bang_template_sensor1 lambda: |- if (millis() > 10000) { return 42.0; @@ -20,16 +20,16 @@ sensor: climate: - platform: bang_bang name: Bang Bang Climate - sensor: template_sensor1 - humidity_sensor: template_sensor1 + sensor: bang_bang_template_sensor1 + humidity_sensor: bang_bang_template_sensor1 default_target_temperature_low: 18°C default_target_temperature_high: 24°C idle_action: - - switch.turn_on: template_switch1 + - switch.turn_on: bang_bang_template_switch1 cool_action: - switch.turn_on: template_switch2 heat_action: - - switch.turn_on: template_switch1 + - switch.turn_on: bang_bang_template_switch1 away_config: default_target_temperature_low: 16°C default_target_temperature_high: 20°C diff --git a/tests/components/binary_sensor/common.yaml b/tests/components/binary_sensor/common.yaml index e3fd159b08..4f4cf6ea59 100644 --- a/tests/components/binary_sensor/common.yaml +++ b/tests/components/binary_sensor/common.yaml @@ -1,7 +1,7 @@ binary_sensor: - platform: template trigger_on_initial_state: true - id: some_binary_sensor + id: binary_sensor_some_binary_sensor name: "Random binary" lambda: return (random_uint32() & 1) == 0; filters: @@ -21,7 +21,7 @@ binary_sensor: time_off: 100ms time_on: 400ms - lambda: |- - if (id(some_binary_sensor).state) { + if (id(binary_sensor_some_binary_sensor).state) { return x; } return {}; @@ -36,7 +36,7 @@ binary_sensor: - logger.log: format: "New state is %s" args: ['x.has_value() ? ONOFF(x) : "Unknown"'] - - binary_sensor.invalidate_state: some_binary_sensor + - binary_sensor.invalidate_state: binary_sensor_some_binary_sensor # Test autorepeat with default configuration (no timings) - platform: template diff --git a/tests/components/binary_sensor_map/common.yaml b/tests/components/binary_sensor_map/common.yaml index c054022583..667d0be9e7 100644 --- a/tests/components/binary_sensor_map/common.yaml +++ b/tests/components/binary_sensor_map/common.yaml @@ -1,20 +1,20 @@ binary_sensor: - platform: template - id: bin1 + id: binary_sensor_map_bin1 lambda: |- if (millis() > 10000) { return true; } return false; - platform: template - id: bin2 + id: binary_sensor_map_bin2 lambda: |- if (millis() > 20000) { return true; } return false; - platform: template - id: bin3 + id: binary_sensor_map_bin3 lambda: |- if (millis() > 30000) { return true; @@ -26,33 +26,33 @@ sensor: name: Binary Sensor Map Group type: group channels: - - binary_sensor: bin1 + - binary_sensor: binary_sensor_map_bin1 value: 10.0 - - binary_sensor: bin2 + - binary_sensor: binary_sensor_map_bin2 value: 15.0 - - binary_sensor: bin3 + - binary_sensor: binary_sensor_map_bin3 value: 100.0 - platform: binary_sensor_map name: Binary Sensor Map Sum type: sum channels: - - binary_sensor: bin1 + - binary_sensor: binary_sensor_map_bin1 value: 10.0 - - binary_sensor: bin2 + - binary_sensor: binary_sensor_map_bin2 value: 15.0 - - binary_sensor: bin3 + - binary_sensor: binary_sensor_map_bin3 value: 100.0 - platform: binary_sensor_map name: Binary Sensor Map Bayesian type: bayesian prior: 0.4 observations: - - binary_sensor: bin1 + - binary_sensor: binary_sensor_map_bin1 prob_given_true: 0.9 prob_given_false: 0.4 - - binary_sensor: bin2 + - binary_sensor: binary_sensor_map_bin2 prob_given_true: 0.7 prob_given_false: 0.05 - - binary_sensor: bin3 + - binary_sensor: binary_sensor_map_bin3 prob_given_true: 0.8 prob_given_false: 0.2 diff --git a/tests/components/ble_client/common.yaml b/tests/components/ble_client/common.yaml index 4ea1dd60f3..4ed6ad7fc9 100644 --- a/tests/components/ble_client/common.yaml +++ b/tests/components/ble_client/common.yaml @@ -56,7 +56,7 @@ sensor: number: - platform: template name: "Test Number" - id: test_number + id: ble_client_test_number optimistic: true min_value: 0 max_value: 255 @@ -72,5 +72,5 @@ button: service_uuid: "abcd1234-abcd-1234-abcd-abcd12345678" characteristic_uuid: "abcd1235-abcd-1234-abcd-abcd12345678" value: !lambda |- - uint8_t val = (uint8_t)id(test_number).state; + uint8_t val = (uint8_t)id(ble_client_test_number).state; return std::vector{0xAA, val, 0xBB}; diff --git a/tests/components/canbus/common.yaml b/tests/components/canbus/common.yaml index e779f7f078..3ba3564608 100644 --- a/tests/components/canbus/common.yaml +++ b/tests/components/canbus/common.yaml @@ -1,6 +1,6 @@ canbus: - platform: esp32_can - id: esp32_internal_can + id: canbus_esp32_internal_can rx_pin: 4 tx_pin: 5 can_id: 4 @@ -40,7 +40,7 @@ canbus: number: - platform: template name: "Test Number" - id: test_number + id: canbus_test_number optimistic: true min_value: 0 max_value: 255 @@ -62,5 +62,5 @@ button: - canbus.send: !lambda return {0, 1, 2}; # Test canbus.send with lambda that references a component (function pointer) - canbus.send: !lambda |- - uint8_t val = (uint8_t)id(test_number).state; + uint8_t val = (uint8_t)id(canbus_test_number).state; return std::vector{0xAA, val, 0xBB}; diff --git a/tests/components/climate_ir_lg/common.yaml b/tests/components/climate_ir_lg/common.yaml index 37011b16ee..e0bc185d2c 100644 --- a/tests/components/climate_ir_lg/common.yaml +++ b/tests/components/climate_ir_lg/common.yaml @@ -1,6 +1,6 @@ sensor: - platform: template - id: temp_sensor + id: climate_ir_lg_temp_sensor lambda: return 22.0; update_interval: 60s - platform: template @@ -12,5 +12,5 @@ climate: - platform: climate_ir_lg name: LG Climate transmitter_id: xmitr - sensor: temp_sensor + sensor: climate_ir_lg_temp_sensor humidity_sensor: humidity_sensor diff --git a/tests/components/color_temperature/common.yaml b/tests/components/color_temperature/common.yaml index fe0c5bf917..0db54d10d0 100644 --- a/tests/components/color_temperature/common.yaml +++ b/tests/components/color_temperature/common.yaml @@ -1,15 +1,15 @@ output: - platform: ${light_platform} - id: light_output_1 + id: color_temperature_light_output_1 pin: ${pin_o1} - platform: ${light_platform} - id: light_output_2 + id: color_temperature_light_output_2 pin: ${pin_o2} light: - platform: color_temperature name: Lights - color_temperature: light_output_1 - brightness: light_output_2 + color_temperature: color_temperature_light_output_1 + brightness: color_temperature_light_output_2 cold_white_color_temperature: 153 mireds warm_white_color_temperature: 500 mireds diff --git a/tests/components/copy/common.yaml b/tests/components/copy/common.yaml index a376004b2f..cbd056f070 100644 --- a/tests/components/copy/common.yaml +++ b/tests/components/copy/common.yaml @@ -1,17 +1,17 @@ output: - platform: ${pwm_platform} - id: fan_output_1 + id: copy_fan_output_1 pin: ${pin} fan: - platform: speed - id: fan_speed - output: fan_output_1 + id: copy_fan_speed + output: copy_fan_output_1 preset_modes: - Eco - Turbo - platform: copy - source_id: fan_speed + source_id: copy_fan_speed name: Fan Speed Copy select: diff --git a/tests/components/current_based/common.yaml b/tests/components/current_based/common.yaml index 503c4596e9..139571ccec 100644 --- a/tests/components/current_based/common.yaml +++ b/tests/components/current_based/common.yaml @@ -31,7 +31,7 @@ sensor: switch: - platform: template - id: template_switch1 + id: current_based_template_switch1 optimistic: true - platform: template id: template_switch2 @@ -46,7 +46,7 @@ cover: open_obstacle_current_threshold: 0.8 open_duration: 12s open_action: - - switch.turn_on: template_switch1 + - switch.turn_on: current_based_template_switch1 close_sensor: ade7953_current_b close_moving_current_threshold: 0.5 close_obstacle_current_threshold: 0.8 @@ -54,7 +54,7 @@ cover: close_action: - switch.turn_on: template_switch2 stop_action: - - switch.turn_off: template_switch1 + - switch.turn_off: current_based_template_switch1 - switch.turn_off: template_switch2 obstacle_rollback: 30% start_sensing_delay: 0.8s diff --git a/tests/components/cwww/common.yaml b/tests/components/cwww/common.yaml index 7fa5ab668c..bbb6c9182b 100644 --- a/tests/components/cwww/common.yaml +++ b/tests/components/cwww/common.yaml @@ -1,8 +1,8 @@ light: - platform: cwww name: CWWW Light - cold_white: light_output_1 - warm_white: light_output_2 + cold_white: cwww_light_output_1 + warm_white: cwww_light_output_2 cold_white_color_temperature: 153 mireds warm_white_color_temperature: 500 mireds constant_brightness: true diff --git a/tests/components/cwww/test.esp32-idf.yaml b/tests/components/cwww/test.esp32-idf.yaml index 01edf0b0b5..0665879b08 100644 --- a/tests/components/cwww/test.esp32-idf.yaml +++ b/tests/components/cwww/test.esp32-idf.yaml @@ -5,11 +5,11 @@ substitutions: output: - platform: ${light_platform} - id: light_output_1 + id: cwww_light_output_1 pin: ${pin_o1} channel: 0 - platform: ${light_platform} - id: light_output_2 + id: cwww_light_output_2 pin: ${pin_o2} channel: 1 phase_angle: 180° diff --git a/tests/components/cwww/test.esp8266-ard.yaml b/tests/components/cwww/test.esp8266-ard.yaml index 49d73b7d3d..bb1868fdef 100644 --- a/tests/components/cwww/test.esp8266-ard.yaml +++ b/tests/components/cwww/test.esp8266-ard.yaml @@ -5,10 +5,10 @@ substitutions: output: - platform: ${light_platform} - id: light_output_1 + id: cwww_light_output_1 pin: ${pin_o1} - platform: ${light_platform} - id: light_output_2 + id: cwww_light_output_2 pin: ${pin_o2} <<: !include common.yaml diff --git a/tests/components/cwww/test.rp2040-ard.yaml b/tests/components/cwww/test.rp2040-ard.yaml index ba8e0ad071..27fc930b68 100644 --- a/tests/components/cwww/test.rp2040-ard.yaml +++ b/tests/components/cwww/test.rp2040-ard.yaml @@ -5,10 +5,10 @@ substitutions: output: - platform: ${light_platform} - id: light_output_1 + id: cwww_light_output_1 pin: ${pin_o1} - platform: ${light_platform} - id: light_output_2 + id: cwww_light_output_2 pin: ${pin_o2} <<: !include common.yaml diff --git a/tests/components/display/common.yaml b/tests/components/display/common.yaml index a722a5f7c2..6617671972 100644 --- a/tests/components/display/common.yaml +++ b/tests/components/display/common.yaml @@ -1,6 +1,6 @@ display: - platform: ili9xxx - id: main_lcd + id: display_main_lcd model: ili9342 cs_pin: 12 dc_pin: 13 diff --git a/tests/components/duty_time/common.yaml b/tests/components/duty_time/common.yaml index 761d10f16a..12e4397c49 100644 --- a/tests/components/duty_time/common.yaml +++ b/tests/components/duty_time/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: template - id: bin1 + id: duty_time_bin1 lambda: |- if (millis() > 10000) { return true; @@ -10,4 +10,4 @@ binary_sensor: sensor: - platform: duty_time name: Duty Time - sensor: bin1 + sensor: duty_time_bin1 diff --git a/tests/components/e131/test.rp2040-ard.yaml b/tests/components/e131/test.rp2040-ard.yaml index 25fe3b6796..4593784ef9 100644 --- a/tests/components/e131/test.rp2040-ard.yaml +++ b/tests/components/e131/test.rp2040-ard.yaml @@ -2,7 +2,7 @@ light: - platform: rp2040_pio_led_strip - id: led_strip + id: e131_led_strip pin: 2 pio: 0 num_leds: 256 diff --git a/tests/components/ektf2232/common.yaml b/tests/components/ektf2232/common.yaml index 1c4d768b08..070b03eeb9 100644 --- a/tests/components/ektf2232/common.yaml +++ b/tests/components/ektf2232/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_i2c_display + id: ektf2232_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${display_reset_pin} pages: @@ -15,7 +15,7 @@ touchscreen: id: ektf2232_touchscreen interrupt_pin: ${interrupt_pin} reset_pin: ${touch_reset_pin} - display: ssd1306_i2c_display + display: ektf2232_ssd1306_i2c_display on_touch: - logger.log: format: Touch at (%d, %d) diff --git a/tests/components/endstop/common.yaml b/tests/components/endstop/common.yaml index b92b1e13b9..6f5cf61268 100644 --- a/tests/components/endstop/common.yaml +++ b/tests/components/endstop/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: template - id: bin1 + id: endstop_bin1 lambda: |- if (millis() > 10000) { return true; @@ -9,7 +9,7 @@ binary_sensor: switch: - platform: template - id: template_switch1 + id: endstop_template_switch1 optimistic: true - platform: template id: template_switch2 @@ -20,12 +20,12 @@ cover: id: endstop_cover name: Endstop Cover stop_action: - - switch.turn_on: template_switch1 - open_endstop: bin1 + - switch.turn_on: endstop_template_switch1 + open_endstop: endstop_bin1 open_action: - - switch.turn_on: template_switch1 + - switch.turn_on: endstop_template_switch1 open_duration: 5min - close_endstop: bin1 + close_endstop: endstop_bin1 close_action: - switch.turn_on: template_switch2 close_duration: 4.5min diff --git a/tests/components/esp32_can/common.yaml b/tests/components/esp32_can/common.yaml index 3b9b33c048..f15b609d84 100644 --- a/tests/components/esp32_can/common.yaml +++ b/tests/components/esp32_can/common.yaml @@ -13,7 +13,7 @@ esphome: canbus: - platform: esp32_can - id: esp32_internal_can + id: esp32_can_esp32_internal_can rx_pin: ${rx_pin} tx_pin: ${tx_pin} can_id: 4 diff --git a/tests/components/esp32_can/test.esp32-c6-idf.yaml b/tests/components/esp32_can/test.esp32-c6-idf.yaml index ac978482fc..c548b4f0f4 100644 --- a/tests/components/esp32_can/test.esp32-c6-idf.yaml +++ b/tests/components/esp32_can/test.esp32-c6-idf.yaml @@ -3,20 +3,20 @@ esphome: then: - canbus.send: # Extended ID explicit - canbus_id: esp32_internal_can + canbus_id: esp32_can_esp32_internal_can use_extended_id: true can_id: 0x100 data: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] - canbus.send: # Standard ID by default - canbus_id: esp32_internal_can + canbus_id: esp32_can_esp32_internal_can can_id: 0x100 data: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] # Note: esp32_internal_can_2 uses LISTENONLY mode, so no send actions canbus: - platform: esp32_can - id: esp32_internal_can + id: esp32_can_esp32_internal_can rx_pin: GPIO8 tx_pin: GPIO7 can_id: 4 diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml index bdc478ea03..f05735e8f4 100644 --- a/tests/components/espnow/common.yaml +++ b/tests/components/espnow/common.yaml @@ -62,7 +62,7 @@ packet_transport: encryption: key: "0123456789abcdef0123456789abcdef" sensors: - - temp_sensor + - espnow_temp_sensor providers: - name: test-provider encryption: @@ -70,9 +70,9 @@ packet_transport: sensor: - platform: internal_temperature - id: temp_sensor + id: espnow_temp_sensor - platform: packet_transport provider: test-provider - remote_id: temp_sensor + remote_id: espnow_temp_sensor id: remote_temp diff --git a/tests/components/fastled_clockless/common.yaml b/tests/components/fastled_clockless/common.yaml index 8b1447a17a..a7ce7ed280 100644 --- a/tests/components/fastled_clockless/common.yaml +++ b/tests/components/fastled_clockless/common.yaml @@ -1,6 +1,6 @@ light: - platform: fastled_clockless - id: addr1 + id: fastled_clockless_addr1 chipset: WS2811 pin: 13 num_leds: 100 @@ -59,13 +59,13 @@ light: name: Custom Effect sequence: - light.addressable_set: - id: addr1 + id: fastled_clockless_addr1 red: 100% green: 100% blue: 0% - delay: 100ms - light.addressable_set: - id: addr1 + id: fastled_clockless_addr1 red: 0% green: 100% blue: 0% diff --git a/tests/components/fastled_spi/common.yaml b/tests/components/fastled_spi/common.yaml index f6f7c5553b..19d00627f8 100644 --- a/tests/components/fastled_spi/common.yaml +++ b/tests/components/fastled_spi/common.yaml @@ -1,6 +1,6 @@ light: - platform: fastled_spi - id: addr1 + id: fastled_spi_addr1 chipset: WS2801 clock_pin: 22 data_pin: 23 @@ -59,13 +59,13 @@ light: name: Custom Effect sequence: - light.addressable_set: - id: addr1 + id: fastled_spi_addr1 red: 100% green: 100% blue: 0% - delay: 100ms - light.addressable_set: - id: addr1 + id: fastled_spi_addr1 red: 0% green: 100% blue: 0% diff --git a/tests/components/font/common.yaml b/tests/components/font/common.yaml index c156b4aea1..59063291e7 100644 --- a/tests/components/font/common.yaml +++ b/tests/components/font/common.yaml @@ -8,7 +8,7 @@ font: id: roboto32 - file: "gfonts://Roboto" - id: roboto + id: font_roboto size: 20 glyphs: "0123456789." extras: @@ -50,11 +50,11 @@ font: display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_display + id: font_ssd1306_display model: SSD1306_128X64 reset_pin: ${display_reset_pin} lambda: |- - it.print(0, 0, id(roboto), "Hello, World!"); + it.print(0, 0, id(font_roboto), "Hello, World!"); it.print(0, 20, id(roboto_web), "Hello, World!"); it.print(0, 40, id(monocraft), "Hello, World!"); it.print(0, 60, id(monocraft2), "Hello, World!"); diff --git a/tests/components/font/test.host.yaml b/tests/components/font/test.host.yaml index 387ea47335..8ada8b7a4e 100644 --- a/tests/components/font/test.host.yaml +++ b/tests/components/font/test.host.yaml @@ -8,7 +8,7 @@ font: id: roboto32 - file: "gfonts://Roboto" - id: roboto + id: font_roboto size: 20 glyphs: "0123456789." extras: @@ -44,12 +44,12 @@ font: display: - platform: sdl - id: sdl_display + id: font_sdl_display dimensions: width: 800 height: 600 lambda: |- - it.print(0, 0, id(roboto), "Hello, World!"); + it.print(0, 0, id(font_roboto), "Hello, World!"); it.print(0, 20, id(roboto_web), "Hello, World!"); it.print(0, 40, id(roboto_greek), "Hello κόσμε!"); it.print(0, 60, id(monocraft), "Hello, World!"); diff --git a/tests/components/graph/common.yaml b/tests/components/graph/common.yaml index 11e2a16ca1..edf4493aa6 100644 --- a/tests/components/graph/common.yaml +++ b/tests/components/graph/common.yaml @@ -12,7 +12,7 @@ graph: display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_display + id: graph_ssd1306_display model: SSD1306_128X64 reset_pin: ${reset_pin} pages: diff --git a/tests/components/graphical_display_menu/common.yaml b/tests/components/graphical_display_menu/common.yaml index 6cee2af232..50f8a5bc85 100644 --- a/tests/components/graphical_display_menu/common.yaml +++ b/tests/components/graphical_display_menu/common.yaml @@ -1,6 +1,7 @@ display: - platform: ssd1306_i2c - id: ssd1306_i2c_display + i2c_id: i2c_bus + id: graphical_display_menu_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${reset_pin} pages: @@ -10,12 +11,12 @@ display: font: - file: "gfonts://Roboto" - id: roboto + id: graphical_display_menu_roboto size: 20 number: - platform: template - id: test_number + id: graphical_display_menu_test_number min_value: 0 step: 1 max_value: 10 @@ -31,13 +32,13 @@ select: switch: - platform: template - id: test_switch + id: graphical_display_menu_test_switch optimistic: true graphical_display_menu: id: test_graphical_display_menu - display: ssd1306_i2c_display - font: roboto + display: graphical_display_menu_ssd1306_i2c_display + font: graphical_display_menu_roboto active: false mode: rotary on_enter: @@ -80,7 +81,7 @@ graphical_display_menu: lambda: 'ESP_LOGI("graphical_display_menu", "select value: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' - type: number text: "Number" - number: test_number + number: graphical_display_menu_test_number on_enter: then: lambda: 'ESP_LOGI("graphical_display_menu", "number enter: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' @@ -97,7 +98,7 @@ graphical_display_menu: - display_menu.hide: test_graphical_display_menu - type: switch text: "Switch" - switch: test_switch + switch: graphical_display_menu_test_switch on_text: "Bright" off_text: "Dark" immediate_edit: false diff --git a/tests/components/gt911/common.yaml b/tests/components/gt911/common.yaml index ff464cda24..0fc40737f0 100644 --- a/tests/components/gt911/common.yaml +++ b/tests/components/gt911/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_i2c_display + id: gt911_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${display_reset_pin} pages: @@ -13,7 +13,7 @@ touchscreen: - platform: gt911 i2c_id: i2c_bus id: gt911_touchscreen - display: ssd1306_i2c_display + display: gt911_ssd1306_i2c_display interrupt_pin: ${interrupt_pin} reset_pin: ${reset_pin} diff --git a/tests/components/image/test.esp32-idf.yaml b/tests/components/image/test.esp32-idf.yaml index aea2b4bbb0..9e93c4c289 100644 --- a/tests/components/image/test.esp32-idf.yaml +++ b/tests/components/image/test.esp32-idf.yaml @@ -3,7 +3,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: image_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 15 diff --git a/tests/components/image/test.esp8266-ard.yaml b/tests/components/image/test.esp8266-ard.yaml index 2e7bfc5ae5..492b57c449 100644 --- a/tests/components/image/test.esp8266-ard.yaml +++ b/tests/components/image/test.esp8266-ard.yaml @@ -3,7 +3,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: image_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 5 diff --git a/tests/components/image/test.rp2040-ard.yaml b/tests/components/image/test.rp2040-ard.yaml index 03a9c42a38..ce2a13fca7 100644 --- a/tests/components/image/test.rp2040-ard.yaml +++ b/tests/components/image/test.rp2040-ard.yaml @@ -3,7 +3,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: image_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 20 diff --git a/tests/components/integration/common-esp32.yaml b/tests/components/integration/common-esp32.yaml index 26550d3c5c..c912fb9b84 100644 --- a/tests/components/integration/common-esp32.yaml +++ b/tests/components/integration/common-esp32.yaml @@ -9,11 +9,11 @@ esphome: sensor: - platform: adc - id: my_sensor + id: integration_my_sensor pin: ${pin} attenuation: 12db - platform: integration id: integration_sensor - sensor: my_sensor + sensor: integration_my_sensor name: Integration Sensor time_unit: s diff --git a/tests/components/integration/test.esp8266-ard.yaml b/tests/components/integration/test.esp8266-ard.yaml index 51d3e19077..377bad5578 100644 --- a/tests/components/integration/test.esp8266-ard.yaml +++ b/tests/components/integration/test.esp8266-ard.yaml @@ -1,8 +1,8 @@ sensor: - platform: adc - id: my_sensor + id: integration_my_sensor pin: VCC - platform: integration - sensor: my_sensor + sensor: integration_my_sensor name: Integration Sensor time_unit: s diff --git a/tests/components/integration/test.rp2040-ard.yaml b/tests/components/integration/test.rp2040-ard.yaml index 51d3e19077..377bad5578 100644 --- a/tests/components/integration/test.rp2040-ard.yaml +++ b/tests/components/integration/test.rp2040-ard.yaml @@ -1,8 +1,8 @@ sensor: - platform: adc - id: my_sensor + id: integration_my_sensor pin: VCC - platform: integration - sensor: my_sensor + sensor: integration_my_sensor name: Integration Sensor time_unit: s diff --git a/tests/components/lcd_menu/common.yaml b/tests/components/lcd_menu/common.yaml index 970c18e0d2..a7740e771d 100644 --- a/tests/components/lcd_menu/common.yaml +++ b/tests/components/lcd_menu/common.yaml @@ -1,6 +1,6 @@ number: - platform: template - id: test_number + id: lcd_menu_test_number min_value: 0 step: 1 max_value: 10 @@ -83,7 +83,7 @@ lcd_menu: lambda: 'ESP_LOGI("lcd_menu", "select value: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' - type: number text: Number - number: test_number + number: lcd_menu_test_number on_enter: then: lambda: 'ESP_LOGI("lcd_menu", "number enter: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 2acc080c6d..71c00e5f10 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -156,7 +156,7 @@ light: - platform: binary id: test_binary_light name: Binary Light - output: test_binary + output: light_test_binary effects: - strobe: on_state: diff --git a/tests/components/light/test.esp32-idf.yaml b/tests/components/light/test.esp32-idf.yaml index 925197182c..49e49b4318 100644 --- a/tests/components/light/test.esp32-idf.yaml +++ b/tests/components/light/test.esp32-idf.yaml @@ -1,6 +1,6 @@ output: - platform: gpio - id: test_binary + id: light_test_binary pin: 12 - platform: ledc id: test_ledc_1 diff --git a/tests/components/light/test.esp8266-ard.yaml b/tests/components/light/test.esp8266-ard.yaml index 518011e925..1eb58eabc4 100644 --- a/tests/components/light/test.esp8266-ard.yaml +++ b/tests/components/light/test.esp8266-ard.yaml @@ -1,6 +1,6 @@ output: - platform: gpio - id: test_binary + id: light_test_binary pin: 4 - platform: esp8266_pwm id: test_ledc_1 diff --git a/tests/components/light/test.nrf52-adafruit.yaml b/tests/components/light/test.nrf52-adafruit.yaml index cb421ed4bb..60521b8088 100644 --- a/tests/components/light/test.nrf52-adafruit.yaml +++ b/tests/components/light/test.nrf52-adafruit.yaml @@ -5,14 +5,14 @@ esphome: output: - platform: gpio - id: test_binary + id: light_test_binary pin: 0 light: - platform: binary id: test_binary_light name: Binary Light - output: test_binary + output: light_test_binary effects: - strobe: on_state: diff --git a/tests/components/light/test.nrf52-mcumgr.yaml b/tests/components/light/test.nrf52-mcumgr.yaml index cb421ed4bb..60521b8088 100644 --- a/tests/components/light/test.nrf52-mcumgr.yaml +++ b/tests/components/light/test.nrf52-mcumgr.yaml @@ -5,14 +5,14 @@ esphome: output: - platform: gpio - id: test_binary + id: light_test_binary pin: 0 light: - platform: binary id: test_binary_light name: Binary Light - output: test_binary + output: light_test_binary effects: - strobe: on_state: diff --git a/tests/components/light/test.rp2040-ard.yaml b/tests/components/light/test.rp2040-ard.yaml index a5a37fd559..21d5cad774 100644 --- a/tests/components/light/test.rp2040-ard.yaml +++ b/tests/components/light/test.rp2040-ard.yaml @@ -1,6 +1,6 @@ output: - platform: gpio - id: test_binary + id: light_test_binary pin: 0 - platform: rp2040_pwm id: test_ledc_1 diff --git a/tests/components/lilygo_t5_47/common.yaml b/tests/components/lilygo_t5_47/common.yaml index 18f1ba10ae..5e71736eb0 100644 --- a/tests/components/lilygo_t5_47/common.yaml +++ b/tests/components/lilygo_t5_47/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_i2c_display + id: lilygo_t5_47_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${reset_pin} pages: @@ -14,7 +14,7 @@ touchscreen: i2c_id: i2c_bus id: lilygo_touchscreen interrupt_pin: ${interrupt_pin} - display: ssd1306_i2c_display + display: lilygo_t5_47_ssd1306_i2c_display on_touch: - logger.log: format: Touch at (%d, %d) diff --git a/tests/components/lock/common.yaml b/tests/components/lock/common.yaml index 9ba7f34857..08001855cb 100644 --- a/tests/components/lock/common.yaml +++ b/tests/components/lock/common.yaml @@ -7,7 +7,7 @@ esphome: output: - platform: gpio - id: test_binary + id: lock_test_binary pin: 4 lock: @@ -32,4 +32,4 @@ lock: - platform: output name: Generic Output Lock id: test_lock2 - output: test_binary + output: lock_test_binary diff --git a/tests/components/mapping/test.esp32-idf.yaml b/tests/components/mapping/test.esp32-idf.yaml index 93adcf9988..d99f8ddc4e 100644 --- a/tests/components/mapping/test.esp32-idf.yaml +++ b/tests/components/mapping/test.esp32-idf.yaml @@ -5,7 +5,7 @@ packages: display: spi_id: spi_bus platform: mipi_spi - id: main_lcd + id: mapping_main_lcd model: ili9342 cs_pin: 12 dc_pin: 13 diff --git a/tests/components/mapping/test.esp8266-ard.yaml b/tests/components/mapping/test.esp8266-ard.yaml index 6a308b67dd..e51240f20d 100644 --- a/tests/components/mapping/test.esp8266-ard.yaml +++ b/tests/components/mapping/test.esp8266-ard.yaml @@ -5,7 +5,7 @@ packages: display: spi_id: spi_bus platform: mipi_spi - id: main_lcd + id: mapping_main_lcd model: ili9342 cs_pin: 5 dc_pin: 15 diff --git a/tests/components/mapping/test.rp2040-ard.yaml b/tests/components/mapping/test.rp2040-ard.yaml index 01b83c4ab8..0562a4ba51 100644 --- a/tests/components/mapping/test.rp2040-ard.yaml +++ b/tests/components/mapping/test.rp2040-ard.yaml @@ -5,7 +5,7 @@ packages: display: spi_id: spi_bus platform: mipi_spi - id: main_lcd + id: mapping_main_lcd model: ili9342 data_rate: 31.25MHz cs_pin: 20 diff --git a/tests/components/monochromatic/common.yaml b/tests/components/monochromatic/common.yaml index 9915e086eb..e57c7bec29 100644 --- a/tests/components/monochromatic/common.yaml +++ b/tests/components/monochromatic/common.yaml @@ -1,13 +1,13 @@ output: - platform: ${light_platform} - id: light_output_1 + id: monochromatic_light_output_1 pin: ${pin} light: - platform: monochromatic name: Monochromatic Light id: monochromatic_light - output: light_output_1 + output: monochromatic_light_output_1 gamma_correct: 2.8 default_transition_length: 2s effects: diff --git a/tests/components/mpr121/common.yaml b/tests/components/mpr121/common.yaml index 67a06cf9c1..f96651e9bf 100644 --- a/tests/components/mpr121/common.yaml +++ b/tests/components/mpr121/common.yaml @@ -9,15 +9,15 @@ binary_sensor: name: touchkey0 channel: 0 - platform: mpr121 - id: bin1 + id: mpr121_bin1 name: touchkey1 channel: 1 - platform: mpr121 - id: bin2 + id: mpr121_bin2 name: touchkey2 channel: 2 - platform: mpr121 - id: bin3 + id: mpr121_bin3 name: touchkey3 channel: 6 diff --git a/tests/components/nextion/common.yaml b/tests/components/nextion/common.yaml index d79e3ee2ed..9eadd97a6d 100644 --- a/tests/components/nextion/common.yaml +++ b/tests/components/nextion/common.yaml @@ -1,6 +1,6 @@ esphome: on_boot: - - lambda: 'ESP_LOGD("display","is_connected(): %s", YESNO(id(main_lcd).is_connected()));' + - lambda: 'ESP_LOGD("display","is_connected(): %s", YESNO(id(nextion_main_lcd).is_connected()));' - display.nextion.set_brightness: 80% @@ -272,7 +272,7 @@ text_sensor: display: - platform: nextion - id: main_lcd + id: nextion_main_lcd auto_wake_on_touch: true brightness: 80% command_spacing: 5ms diff --git a/tests/components/nextion/common_tft_upload.yaml b/tests/components/nextion/common_tft_upload.yaml index 190abbc7b1..70a0809883 100644 --- a/tests/components/nextion/common_tft_upload.yaml +++ b/tests/components/nextion/common_tft_upload.yaml @@ -1,5 +1,5 @@ display: - - id: !extend main_lcd + - id: !extend nextion_main_lcd tft_url: http://esphome.io/default35.tft tft_upload_http_timeout: 20s tft_upload_http_retries: 10 diff --git a/tests/components/nextion/common_tft_upload_watchdog.yaml b/tests/components/nextion/common_tft_upload_watchdog.yaml index 385fee359e..f0b44ce8c3 100644 --- a/tests/components/nextion/common_tft_upload_watchdog.yaml +++ b/tests/components/nextion/common_tft_upload_watchdog.yaml @@ -1,3 +1,3 @@ display: - - id: !extend main_lcd + - id: !extend nextion_main_lcd tft_upload_watchdog_timeout: 30s diff --git a/tests/components/ntc/common.yaml b/tests/components/ntc/common.yaml index 79ae7f601d..1be2c335bc 100644 --- a/tests/components/ntc/common.yaml +++ b/tests/components/ntc/common.yaml @@ -1,23 +1,23 @@ sensor: - platform: adc - id: my_sensor + id: ntc_my_sensor pin: ${pin} - platform: resistance - sensor: my_sensor + sensor: ntc_my_sensor configuration: DOWNSTREAM resistor: 10kΩ reference_voltage: 3.3V name: Resistance - id: resist + id: ntc_resist - platform: ntc - sensor: resist + sensor: ntc_resist name: NTC Sensor calibration: b_constant: 3950 reference_resistance: 10k reference_temperature: 25°C - platform: ntc - sensor: resist + sensor: ntc_resist name: NTC Sensor2 calibration: - 10.0kOhm -> 25°C diff --git a/tests/components/number/common.yaml b/tests/components/number/common.yaml index c17c2dd5f8..b1a16ebfed 100644 --- a/tests/components/number/common.yaml +++ b/tests/components/number/common.yaml @@ -1,7 +1,7 @@ number: - platform: template name: "Test Number" - id: test_number + id: number_test_number optimistic: true min_value: 0 max_value: 100 @@ -10,4 +10,4 @@ number: sensor: - platform: number name: "Test Number Value" - source_id: test_number + source_id: number_test_number diff --git a/tests/components/online_image/common-esp32.yaml b/tests/components/online_image/common-esp32.yaml index 32c909d351..ee4c1ed0b8 100644 --- a/tests/components/online_image/common-esp32.yaml +++ b/tests/components/online_image/common-esp32.yaml @@ -6,7 +6,7 @@ packages: display: - platform: ili9xxx spi_id: spi_bus - id: main_lcd + id: online_image_main_lcd model: ili9342 cs_pin: 20 dc_pin: 13 diff --git a/tests/components/online_image/common-esp8266.yaml b/tests/components/online_image/common-esp8266.yaml index d7722d171a..fc61aad92e 100644 --- a/tests/components/online_image/common-esp8266.yaml +++ b/tests/components/online_image/common-esp8266.yaml @@ -6,7 +6,7 @@ packages: display: - platform: ili9xxx spi_id: spi_bus - id: main_lcd + id: online_image_main_lcd model: ili9342 cs_pin: 15 dc_pin: 3 diff --git a/tests/components/online_image/common-rp2040.yaml b/tests/components/online_image/common-rp2040.yaml index bbb514bded..4d2785f3e8 100644 --- a/tests/components/online_image/common-rp2040.yaml +++ b/tests/components/online_image/common-rp2040.yaml @@ -6,7 +6,7 @@ packages: display: - platform: ili9xxx spi_id: spi_bus - id: main_lcd + id: online_image_main_lcd model: ili9342 data_rate: 20MHz cs_pin: 20 diff --git a/tests/components/online_image/test.esp32-s3-ard.yaml b/tests/components/online_image/test.esp32-s3-ard.yaml index 9116fd86e0..9972a673c0 100644 --- a/tests/components/online_image/test.esp32-s3-ard.yaml +++ b/tests/components/online_image/test.esp32-s3-ard.yaml @@ -8,7 +8,7 @@ http_request: display: - platform: ili9xxx spi_id: spi_bus - id: main_lcd + id: online_image_main_lcd model: ili9342 cs_pin: 20 dc_pin: 13 diff --git a/tests/components/online_image/test.esp32-s3-idf.yaml b/tests/components/online_image/test.esp32-s3-idf.yaml index f219f71ee2..1f1485fd6c 100644 --- a/tests/components/online_image/test.esp32-s3-idf.yaml +++ b/tests/components/online_image/test.esp32-s3-idf.yaml @@ -8,7 +8,7 @@ http_request: display: - platform: ili9xxx spi_id: spi_bus - id: main_lcd + id: online_image_main_lcd model: ili9342 cs_pin: 20 dc_pin: 13 diff --git a/tests/components/output/common.yaml b/tests/components/output/common.yaml index 81d802e9bf..df20dcde2b 100644 --- a/tests/components/output/common.yaml +++ b/tests/components/output/common.yaml @@ -1,19 +1,19 @@ esphome: on_boot: then: - - output.turn_off: light_output_1 - - output.turn_on: light_output_1 + - output.turn_off: output_light_output_1 + - output.turn_on: output_light_output_1 - output.set_level: - id: light_output_1 + id: output_light_output_1 level: 50% - output.set_min_power: - id: light_output_1 + id: output_light_output_1 min_power: 20% - output.set_max_power: - id: light_output_1 + id: output_light_output_1 max_power: 80% output: - platform: ${output_platform} - id: light_output_1 + id: output_light_output_1 pin: ${pin} diff --git a/tests/components/pi4ioe5v6408/common.yaml b/tests/components/pi4ioe5v6408/common.yaml index 77a77fa3e4..aeda76d35c 100644 --- a/tests/components/pi4ioe5v6408/common.yaml +++ b/tests/components/pi4ioe5v6408/common.yaml @@ -9,7 +9,7 @@ pi4ioe5v6408: switch: - platform: gpio - id: switch1 + id: pi4ioe5v6408_switch1 pin: pi4ioe5v6408: pi4ioe1 number: 0 diff --git a/tests/components/pid/common.yaml b/tests/components/pid/common.yaml index 262e75591e..320e5f775f 100644 --- a/tests/components/pid/common.yaml +++ b/tests/components/pid/common.yaml @@ -23,7 +23,7 @@ output: sensor: - platform: template - id: template_sensor1 + id: pid_template_sensor1 lambda: |- if (millis() > 10000) { return 42.0; @@ -35,8 +35,8 @@ climate: - platform: pid id: pid_climate name: PID Climate Controller - sensor: template_sensor1 - humidity_sensor: template_sensor1 + sensor: pid_template_sensor1 + humidity_sensor: pid_template_sensor1 default_target_temperature: 21°C heat_output: pid_slow_pwm control_parameters: diff --git a/tests/components/prometheus/common.yaml b/tests/components/prometheus/common.yaml index 7ff416dccb..951d8f7fc5 100644 --- a/tests/components/prometheus/common.yaml +++ b/tests/components/prometheus/common.yaml @@ -31,7 +31,7 @@ update: sensor: - platform: template - id: template_sensor1 + id: prometheus_template_sensor1 lambda: |- if (millis() > 10000) { return 42.0; @@ -91,7 +91,7 @@ binary_sensor: switch: - platform: template - id: template_switch1 + id: prometheus_template_switch1 lambda: |- if (millis() > 10000) { return true; @@ -185,7 +185,7 @@ climate: prometheus: include_internal: true relabel: - template_sensor1: + prometheus_template_sensor1: id: hellow_world name: Hello World template_text_sensor1: diff --git a/tests/components/qspi_dbi/common.yaml b/tests/components/qspi_dbi/common.yaml index 109db65b63..0eadfa7392 100644 --- a/tests/components/qspi_dbi/common.yaml +++ b/tests/components/qspi_dbi/common.yaml @@ -16,7 +16,7 @@ display: - platform: qspi_dbi model: CUSTOM - id: main_lcd + id: qspi_dbi_main_lcd draw_from_origin: true dimensions: height: 240 diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index c6c7049605..5631c48f95 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -1,6 +1,6 @@ number: - platform: template - id: test_number + id: remote_transmitter_test_number optimistic: true min_value: 0 max_value: 255 @@ -151,7 +151,7 @@ button: on_press: remote_transmitter.transmit_raw: code: !lambda |- - return {(int32_t)id(test_number).state * 100, -1000}; + return {(int32_t)id(remote_transmitter_test_number).state * 100, -1000}; - platform: template name: AEHA id: eaha_hitachi_climate_power_on @@ -253,7 +253,7 @@ button: destination_address: 0x5678 message_type: 0x01 data: !lambda |- - return {(uint8_t)id(test_number).state, 0x20, 0x30}; + return {(uint8_t)id(remote_transmitter_test_number).state, 0x20, 0x30}; - platform: template name: Digital Write on_press: diff --git a/tests/components/resistance/common.yaml b/tests/components/resistance/common.yaml index b3eec49548..8966b574df 100644 --- a/tests/components/resistance/common.yaml +++ b/tests/components/resistance/common.yaml @@ -1,11 +1,11 @@ sensor: - platform: adc - id: my_sensor + id: resistance_my_sensor pin: ${pin} - platform: resistance - sensor: my_sensor + sensor: resistance_my_sensor configuration: DOWNSTREAM resistor: 10kΩ reference_voltage: 3.3V name: Resistance - id: resist + id: resistance_resist diff --git a/tests/components/rgb/common.yaml b/tests/components/rgb/common.yaml index 9f25efa431..bd72abbd17 100644 --- a/tests/components/rgb/common.yaml +++ b/tests/components/rgb/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${light_platform} - id: light_output_1 + id: rgb_light_output_1 pin: ${pin1} - platform: ${light_platform} - id: light_output_2 + id: rgb_light_output_2 pin: ${pin2} - platform: ${light_platform} id: light_output_3 @@ -13,6 +13,6 @@ light: - platform: rgb name: RGB Light id: rgb_light - red: light_output_1 - green: light_output_2 + red: rgb_light_output_1 + green: rgb_light_output_2 blue: light_output_3 diff --git a/tests/components/rgbct/common.yaml b/tests/components/rgbct/common.yaml index 65bb248e95..46d8082706 100644 --- a/tests/components/rgbct/common.yaml +++ b/tests/components/rgbct/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${light_platform} - id: light_output_1 + id: rgbct_light_output_1 pin: ${pin1} - platform: ${light_platform} - id: light_output_2 + id: rgbct_light_output_2 pin: ${pin2} - platform: ${light_platform} id: light_output_3 @@ -18,8 +18,8 @@ output: light: - platform: rgbct name: RGBCT Light - red: light_output_1 - green: light_output_2 + red: rgbct_light_output_1 + green: rgbct_light_output_2 blue: light_output_3 color_temperature: light_output_4 white_brightness: light_output_5 diff --git a/tests/components/rgbw/common.yaml b/tests/components/rgbw/common.yaml index b0f44869d3..4a8e56a255 100644 --- a/tests/components/rgbw/common.yaml +++ b/tests/components/rgbw/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${light_platform} - id: light_output_1 + id: rgbw_light_output_1 pin: ${pin1} - platform: ${light_platform} - id: light_output_2 + id: rgbw_light_output_2 pin: ${pin2} - platform: ${light_platform} id: light_output_3 @@ -15,8 +15,8 @@ output: light: - platform: rgbw name: RGBW Light - red: light_output_1 - green: light_output_2 + red: rgbw_light_output_1 + green: rgbw_light_output_2 blue: light_output_3 white: light_output_4 color_interlock: true diff --git a/tests/components/rgbww/common.yaml b/tests/components/rgbww/common.yaml index 0013960c10..bb1d73b3bc 100644 --- a/tests/components/rgbww/common.yaml +++ b/tests/components/rgbww/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${light_platform} - id: light_output_1 + id: rgbww_light_output_1 pin: ${pin1} - platform: ${light_platform} - id: light_output_2 + id: rgbww_light_output_2 pin: ${pin2} - platform: ${light_platform} id: light_output_3 @@ -18,8 +18,8 @@ output: light: - platform: rgbww name: RGBWW Light - red: light_output_1 - green: light_output_2 + red: rgbww_light_output_1 + green: rgbww_light_output_2 blue: light_output_3 cold_white: light_output_4 warm_white: light_output_5 diff --git a/tests/components/rp2040_pio_led_strip/common.yaml b/tests/components/rp2040_pio_led_strip/common.yaml index b9b1436cdb..254ac0e13d 100644 --- a/tests/components/rp2040_pio_led_strip/common.yaml +++ b/tests/components/rp2040_pio_led_strip/common.yaml @@ -1,6 +1,6 @@ light: - platform: rp2040_pio_led_strip - id: led_strip + id: rp2040_pio_led_strip_led_strip pin: 4 num_leds: 60 pio: 0 diff --git a/tests/components/rp2040_pwm/common.yaml b/tests/components/rp2040_pwm/common.yaml index 45c039106f..2970a48afb 100644 --- a/tests/components/rp2040_pwm/common.yaml +++ b/tests/components/rp2040_pwm/common.yaml @@ -1,7 +1,7 @@ output: - platform: rp2040_pwm - id: light_output_1 + id: rp2040_pwm_light_output_1 pin: 2 - platform: rp2040_pwm - id: light_output_2 + id: rp2040_pwm_light_output_2 pin: 3 diff --git a/tests/components/sdl/common.yaml b/tests/components/sdl/common.yaml index d3d3c9ee5e..3be86cf8be 100644 --- a/tests/components/sdl/common.yaml +++ b/tests/components/sdl/common.yaml @@ -3,7 +3,7 @@ host: display: - platform: sdl - id: sdl_display + id: sdl_sdl_display update_interval: 1s auto_clear_enabled: false show_test_card: true @@ -35,14 +35,14 @@ display: binary_sensor: - platform: sdl - sdl_id: sdl_display + sdl_id: sdl_sdl_display id: key_up key: SDLK_UP - platform: sdl - sdl_id: sdl_display + sdl_id: sdl_sdl_display id: key_down key: SDLK_DOWN - platform: sdl - sdl_id: sdl_display + sdl_id: sdl_sdl_display id: key_enter key: SDLK_RETURN diff --git a/tests/components/speaker/common.yaml b/tests/components/speaker/common.yaml index 895f4b4b8f..96f459c53f 100644 --- a/tests/components/speaker/common.yaml +++ b/tests/components/speaker/common.yaml @@ -1,7 +1,7 @@ number: - platform: template name: "Speaker Number" - id: my_number + id: speaker_my_number optimistic: true min_value: 0 max_value: 100 @@ -46,7 +46,7 @@ button: - speaker.play: id: speaker_id data: !lambda |- - return {0x01, 0x02, (uint8_t)id(my_number).state}; + return {0x01, 0x02, (uint8_t)id(speaker_my_number).state}; speaker: - platform: i2s_audio diff --git a/tests/components/speed/common.yaml b/tests/components/speed/common.yaml index be8172af7e..70c91259ba 100644 --- a/tests/components/speed/common.yaml +++ b/tests/components/speed/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${output_platform} - id: fan_output_1 + id: speed_fan_output_1 pin: ${pin} fan: - platform: speed - id: fan_speed - output: fan_output_1 + id: speed_fan_speed + output: speed_fan_output_1 diff --git a/tests/components/sprinkler/common.yaml b/tests/components/sprinkler/common.yaml index f099f77729..dbe109f524 100644 --- a/tests/components/sprinkler/common.yaml +++ b/tests/components/sprinkler/common.yaml @@ -34,7 +34,7 @@ esphome: switch: - platform: template - id: switch1 + id: sprinkler_switch1 optimistic: true - platform: template id: switch2 @@ -52,17 +52,17 @@ sprinkler: valves: - valve_switch: Yard Valve 0 enable_switch: Enable Yard Valve 0 - pump_switch_id: switch1 + pump_switch_id: sprinkler_switch1 run_duration: 10s valve_switch_id: switch2 - valve_switch: Yard Valve 1 enable_switch: Enable Yard Valve 1 - pump_switch_id: switch1 + pump_switch_id: sprinkler_switch1 run_duration: 10s valve_switch_id: switch2 - valve_switch: Yard Valve 2 enable_switch: Enable Yard Valve 2 - pump_switch_id: switch1 + pump_switch_id: sprinkler_switch1 run_duration: 10s valve_switch_id: switch2 - id: garden_sprinkler_ctrlr @@ -73,11 +73,11 @@ sprinkler: valves: - valve_switch: Garden Valve 0 enable_switch: Enable Garden Valve 0 - pump_switch_id: switch1 + pump_switch_id: sprinkler_switch1 run_duration: 10s valve_switch_id: switch2 - valve_switch: Garden Valve 1 enable_switch: Enable Garden Valve 1 - pump_switch_id: switch1 + pump_switch_id: sprinkler_switch1 run_duration: 10s valve_switch_id: switch2 diff --git a/tests/components/ssd1306_i2c/common.yaml b/tests/components/ssd1306_i2c/common.yaml index 09eb569a8e..b3b8ad85dc 100644 --- a/tests/components/ssd1306_i2c/common.yaml +++ b/tests/components/ssd1306_i2c/common.yaml @@ -4,7 +4,7 @@ display: model: SSD1306_128X64 reset_pin: ${reset_pin} address: 0x3C - id: ssd1306_i2c_display + id: ssd1306_i2c_ssd1306_i2c_display contrast: 60% pages: - id: ssd1306_i2c_page1 diff --git a/tests/components/switch/common.yaml b/tests/components/switch/common.yaml index afdf26c150..3ea235cfb9 100644 --- a/tests/components/switch/common.yaml +++ b/tests/components/switch/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: switch - id: some_binary_sensor + id: switch_some_binary_sensor name: "Template Switch State" source_id: the_switch diff --git a/tests/components/sx126x/common.yaml b/tests/components/sx126x/common.yaml index 659550cc01..a4a24d8da7 100644 --- a/tests/components/sx126x/common.yaml +++ b/tests/components/sx126x/common.yaml @@ -29,7 +29,7 @@ sx126x: number: - platform: template name: "SX126x Number" - id: my_number + id: sx126x_my_number optimistic: true min_value: 0 max_value: 100 @@ -47,4 +47,4 @@ button: - sx126x.send_packet: data: [0xC5, 0x51, 0x78, 0x82, 0xB7, 0xF9, 0x9C, 0x5C] - sx126x.send_packet: !lambda |- - return {0x01, 0x02, (uint8_t)id(my_number).state}; + return {0x01, 0x02, (uint8_t)id(sx126x_my_number).state}; diff --git a/tests/components/sx127x/common.yaml b/tests/components/sx127x/common.yaml index 6e48952fcc..b7eadc084f 100644 --- a/tests/components/sx127x/common.yaml +++ b/tests/components/sx127x/common.yaml @@ -29,7 +29,7 @@ sx127x: number: - platform: template name: "SX127x Number" - id: my_number + id: sx127x_my_number optimistic: true min_value: 0 max_value: 100 @@ -48,4 +48,4 @@ button: - sx127x.send_packet: data: [0xC5, 0x51, 0x78, 0x82, 0xB7, 0xF9, 0x9C, 0x5C] - sx127x.send_packet: !lambda |- - return {0x01, 0x02, (uint8_t)id(my_number).state}; + return {0x01, 0x02, (uint8_t)id(sx127x_my_number).state}; diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index d3985a848b..f1387a7afe 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -52,7 +52,7 @@ esphome: binary_sensor: - platform: template - id: some_binary_sensor + id: template_some_binary_sensor name: "Garage Door Open" lambda: |- if (id(template_sens).state > 30) { @@ -108,7 +108,7 @@ sensor: name: "Template Sensor" id: template_sens lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return 42.0; } return 0.0; @@ -230,7 +230,7 @@ switch: id: test_switch name: "Template Switch" lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return true; } return false; @@ -249,7 +249,7 @@ cover: - platform: template name: "Template Cover" lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return COVER_OPEN; } return COVER_CLOSED; @@ -264,7 +264,7 @@ cover: name: "Template Cover with Triggers" id: template_cover_with_triggers lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return COVER_OPEN; } return COVER_CLOSED; @@ -442,7 +442,7 @@ lock: - platform: template name: "Template Lock" lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return LOCK_STATE_LOCKED; } return LOCK_STATE_UNLOCKED; @@ -458,7 +458,7 @@ valve: id: template_valve name: "Template Valve" lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return VALVE_OPEN; } return VALVE_CLOSED; diff --git a/tests/components/tt21100/common.yaml b/tests/components/tt21100/common.yaml index 56089aed1e..1f9249f1ba 100644 --- a/tests/components/tt21100/common.yaml +++ b/tests/components/tt21100/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_i2c_display + id: tt21100_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${disp_reset_pin} pages: @@ -13,7 +13,7 @@ touchscreen: - platform: tt21100 i2c_id: i2c_bus id: tt21100_touchscreen - display: ssd1306_i2c_display + display: tt21100_ssd1306_i2c_display interrupt_pin: ${interrupt_pin} reset_pin: ${reset_pin} diff --git a/tests/components/uart/test.esp32-idf.yaml b/tests/components/uart/test.esp32-idf.yaml index fa76316b9c..c805188005 100644 --- a/tests/components/uart/test.esp32-idf.yaml +++ b/tests/components/uart/test.esp32-idf.yaml @@ -79,7 +79,7 @@ switch: number: - platform: template name: "Test Number" - id: test_number + id: uart_test_number optimistic: true min_value: 0 max_value: 100 @@ -103,7 +103,7 @@ button: - uart.write: id: uart_id data: !lambda |- - std::string cmd = "VALUE=" + str_sprintf("%.0f", id(test_number).state) + "\r\n"; + std::string cmd = "VALUE=" + str_sprintf("%.0f", id(uart_test_number).state) + "\r\n"; return std::vector(cmd.begin(), cmd.end()); event: diff --git a/tests/components/udp/common.yaml b/tests/components/udp/common.yaml index a40ca455cb..6824c5cca8 100644 --- a/tests/components/udp/common.yaml +++ b/tests/components/udp/common.yaml @@ -24,7 +24,7 @@ udp: number: - platform: template name: "UDP Number" - id: my_number + id: udp_my_number optimistic: true min_value: 0 max_value: 100 @@ -38,4 +38,4 @@ button: - udp.write: data: [0x01, 0x02, 0x03] - udp.write: !lambda |- - return {0x10, 0x20, (uint8_t)id(my_number).state}; + return {0x10, 0x20, (uint8_t)id(udp_my_number).state}; diff --git a/tests/components/ufire_ec/common.yaml b/tests/components/ufire_ec/common.yaml index 4260f0ab4c..2365b7a368 100644 --- a/tests/components/ufire_ec/common.yaml +++ b/tests/components/ufire_ec/common.yaml @@ -4,18 +4,18 @@ esphome: - ufire_ec.calibrate_probe: id: ufire_ec_board solution: 0.146 - temperature: !lambda "return id(test_sensor).state;" + temperature: !lambda "return id(ufire_ec_test_sensor).state;" - ufire_ec.reset: sensor: - platform: template - id: test_sensor + id: ufire_ec_test_sensor lambda: "return 21;" - platform: ufire_ec i2c_id: i2c_bus id: ufire_ec_board ec: name: Ufire EC - temperature_sensor: test_sensor + temperature_sensor: ufire_ec_test_sensor temperature_compensation: 20.0 temperature_coefficient: 0.019 diff --git a/tests/components/ufire_ise/common.yaml b/tests/components/ufire_ise/common.yaml index f7865ea87b..478c75ad37 100644 --- a/tests/components/ufire_ise/common.yaml +++ b/tests/components/ufire_ise/common.yaml @@ -11,11 +11,11 @@ esphome: sensor: - platform: template - id: test_sensor + id: ufire_ise_test_sensor lambda: "return 21;" - platform: ufire_ise i2c_id: i2c_bus id: ufire_ise_sensor - temperature_sensor: test_sensor + temperature_sensor: ufire_ise_test_sensor ph: name: Ufire pH diff --git a/tests/components/web_server_idf/common.yaml b/tests/components/web_server_idf/common.yaml index b1885af266..cfba0060d9 100644 --- a/tests/components/web_server_idf/common.yaml +++ b/tests/components/web_server_idf/common.yaml @@ -12,7 +12,7 @@ network: sensor: - platform: template name: "Test Sensor" - id: test_sensor + id: web_server_idf_test_sensor update_interval: 60s lambda: "return 42.5;" @@ -25,5 +25,5 @@ binary_sensor: switch: - platform: template name: "Test Switch" - id: test_switch + id: web_server_idf_test_switch optimistic: true diff --git a/tests/components/wk2132_i2c/common.yaml b/tests/components/wk2132_i2c/common.yaml index 39013baeb2..93bb17b38f 100644 --- a/tests/components/wk2132_i2c/common.yaml +++ b/tests/components/wk2132_i2c/common.yaml @@ -16,4 +16,4 @@ wk2132_i2c: sensor: - platform: a02yyuw uart_id: wk2132_id_1 - id: distance_sensor + id: wk2132_i2c_distance_sensor diff --git a/tests/components/wk2132_spi/common.yaml b/tests/components/wk2132_spi/common.yaml index 18294974b9..5ff48bc64c 100644 --- a/tests/components/wk2132_spi/common.yaml +++ b/tests/components/wk2132_spi/common.yaml @@ -17,4 +17,4 @@ wk2132_spi: sensor: - platform: a02yyuw uart_id: wk2132_spi_uart1 - id: distance_sensor + id: wk2132_spi_distance_sensor diff --git a/tests/components/wk2168_i2c/common.yaml b/tests/components/wk2168_i2c/common.yaml index 49f0d1ec6b..1b2de74c02 100644 --- a/tests/components/wk2168_i2c/common.yaml +++ b/tests/components/wk2168_i2c/common.yaml @@ -23,7 +23,7 @@ wk2168_i2c: sensor: - platform: a02yyuw uart_id: wk2168_i2c_uart3 - id: distance_sensor + id: wk2168_i2c_distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/components/wk2168_spi/common.yaml b/tests/components/wk2168_spi/common.yaml index b402077aa3..a21a4a34d0 100644 --- a/tests/components/wk2168_spi/common.yaml +++ b/tests/components/wk2168_spi/common.yaml @@ -23,7 +23,7 @@ wk2168_spi: sensor: - platform: a02yyuw uart_id: wk2168_spi_uart3 - id: distance_sensor + id: wk2168_spi_distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/components/wk2204_i2c/common.yaml b/tests/components/wk2204_i2c/common.yaml index 863633937b..55c67efd88 100644 --- a/tests/components/wk2204_i2c/common.yaml +++ b/tests/components/wk2204_i2c/common.yaml @@ -24,4 +24,4 @@ wk2204_i2c: sensor: - platform: a02yyuw uart_id: wk2204_id_3 - id: distance_sensor + id: wk2204_i2c_distance_sensor diff --git a/tests/components/wk2204_spi/common.yaml b/tests/components/wk2204_spi/common.yaml index 0b62a7a009..ee00da22bb 100644 --- a/tests/components/wk2204_spi/common.yaml +++ b/tests/components/wk2204_spi/common.yaml @@ -25,4 +25,4 @@ wk2204_spi: sensor: - platform: a02yyuw uart_id: wk2204_spi_uart3 - id: distance_sensor + id: wk2204_spi_distance_sensor diff --git a/tests/components/wk2212_i2c/common.yaml b/tests/components/wk2212_i2c/common.yaml index a754bec5c7..d48063bb4d 100644 --- a/tests/components/wk2212_i2c/common.yaml +++ b/tests/components/wk2212_i2c/common.yaml @@ -19,7 +19,7 @@ wk2212_i2c: sensor: - platform: a02yyuw uart_id: uart_i2c_id1 - id: distance_sensor + id: wk2212_i2c_distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/components/wk2212_spi/common.yaml b/tests/components/wk2212_spi/common.yaml index 969f16bb12..d17db2f676 100644 --- a/tests/components/wk2212_spi/common.yaml +++ b/tests/components/wk2212_spi/common.yaml @@ -17,7 +17,7 @@ wk2212_spi: sensor: - platform: a02yyuw uart_id: wk2212_spi_uart1 - id: distance_sensor + id: wk2212_spi_distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/script/test_merge_component_configs.py b/tests/script/test_merge_component_configs.py new file mode 100644 index 0000000000..9286380de1 --- /dev/null +++ b/tests/script/test_merge_component_configs.py @@ -0,0 +1,72 @@ +"""Unit tests for script/merge_component_configs.py deduplication.""" + +from pathlib import Path +import sys + +import pytest + +# Add the script directory to Python path so we can import the module +sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve())) + +import merge_component_configs # noqa: E402 + +deduplicate_by_id = merge_component_configs.deduplicate_by_id + + +def test_identical_duplicate_ids_collapse() -> None: + """Two identical items sharing an id collapse to one without error.""" + data = { + "sensor": [ + {"id": "shared", "platform": "template", "name": "A"}, + {"id": "shared", "platform": "template", "name": "A"}, + ] + } + result = deduplicate_by_id(data) + assert result["sensor"] == [{"id": "shared", "platform": "template", "name": "A"}] + + +def test_conflicting_duplicate_ids_raise() -> None: + """Two different items sharing an id is a hard error naming the id.""" + data = { + "sensor": [ + {"id": "dup", "platform": "template", "name": "A"}, + {"id": "dup", "platform": "template", "name": "B"}, + ] + } + with pytest.raises(ValueError, match="dup"): + deduplicate_by_id(data) + + +def test_intentionally_shared_id_does_not_raise() -> None: + """Allowlisted singleton ids may differ across components and collapse.""" + shared = next(iter(merge_component_configs.INTENTIONALLY_SHARED_IDS)) + data = { + "time": [ + {"id": shared, "platform": "sntp"}, + {"id": shared, "platform": "sntp", "servers": ["a"]}, + ] + } + result = deduplicate_by_id(data) + # First occurrence wins, no error raised + assert result["time"] == [{"id": shared, "platform": "sntp"}] + + +def test_items_without_id_are_preserved() -> None: + """Items lacking an id are passed through untouched.""" + data = {"binary_sensor": [{"platform": "gpio"}, {"platform": "gpio"}]} + result = deduplicate_by_id(data) + assert result["binary_sensor"] == [{"platform": "gpio"}, {"platform": "gpio"}] + + +def test_nested_lists_are_checked() -> None: + """Conflicts nested inside dict values are also detected.""" + data = { + "wrapper": { + "sensor": [ + {"id": "dup", "value": 1}, + {"id": "dup", "value": 2}, + ] + } + } + with pytest.raises(ValueError, match="dup"): + deduplicate_by_id(data) From 8f8a70b2be9e10cd81053420c4bc7a04e3d92b4c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Jun 2026 17:58:50 -0500 Subject: [PATCH 0344/1815] Exit nginx bypass placeholder cleanly on SIGTERM (#16845) --- .../ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run index bb5f52e10c..b8251e8e01 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run @@ -6,11 +6,15 @@ # ============================================================================== # The new device builder handles HA ingress itself, so nginx is bypassed. -# Block the longrun forever so s6 keeps the dependency satisfied and does -# not respawn it. +# Block the longrun so s6 keeps the dependency satisfied, but exit 0 on +# SIGTERM instead of being signal-killed; a 256/15 exit makes nginx/finish +# stamp the container exit 143, which trips the Supervisor's SIGTERM check. if bashio::config.true 'use_new_device_builder'; then bashio::log.info "NGINX bypassed: new device builder serves ingress directly." - exec sleep infinity + trap 'exit 0' TERM + sleep infinity & + wait + exit 0 fi bashio::log.info "Waiting for ESPHome dashboard to come up..." From 2a4913713a9ccb4799ec5ee8c7800a52c2070b10 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Jun 2026 19:37:19 -0500 Subject: [PATCH 0345/1815] Revert "[tests] Fail component test merge on conflicting duplicate IDs" (#16848) --- .github/workflows/ci.yml | 1 - script/ci_check_duplicate_test_ids.py | 122 ------------------ script/merge_component_configs.py | 48 +------ tests/components/adc/test.bk72xx-ard.yaml | 2 +- tests/components/adc/test.esp32-c2-idf.yaml | 2 +- tests/components/adc/test.esp32-c3-idf.yaml | 2 +- tests/components/adc/test.esp32-idf.yaml | 2 +- tests/components/adc/test.esp32-p4-idf.yaml | 2 +- tests/components/adc/test.esp32-s2-idf.yaml | 2 +- tests/components/adc/test.esp32-s3-idf.yaml | 2 +- tests/components/adc/test.esp8266-ard.yaml | 2 +- tests/components/adc/test.ln882x-ard.yaml | 2 +- tests/components/adc/test.rp2040-ard.yaml | 2 +- .../components/adc/test.rp2040-pico2-ard.yaml | 2 +- .../alarm_control_panel/common.yaml | 6 +- .../components/animation/test.esp32-idf.yaml | 2 +- .../animation/test.esp8266-ard.yaml | 2 +- .../components/animation/test.rp2040-ard.yaml | 2 +- tests/components/axs15231/common.yaml | 4 +- .../components/axs15231/test.esp8266-ard.yaml | 4 +- tests/components/bang_bang/common.yaml | 12 +- tests/components/binary_sensor/common.yaml | 6 +- .../components/binary_sensor_map/common.yaml | 24 ++-- tests/components/ble_client/common.yaml | 4 +- tests/components/canbus/common.yaml | 6 +- tests/components/climate_ir_lg/common.yaml | 4 +- .../components/color_temperature/common.yaml | 8 +- tests/components/copy/common.yaml | 8 +- tests/components/current_based/common.yaml | 6 +- tests/components/cwww/common.yaml | 4 +- tests/components/cwww/test.esp32-idf.yaml | 4 +- tests/components/cwww/test.esp8266-ard.yaml | 4 +- tests/components/cwww/test.rp2040-ard.yaml | 4 +- tests/components/display/common.yaml | 2 +- tests/components/duty_time/common.yaml | 4 +- tests/components/e131/test.rp2040-ard.yaml | 2 +- tests/components/ektf2232/common.yaml | 4 +- tests/components/endstop/common.yaml | 12 +- tests/components/esp32_can/common.yaml | 2 +- .../esp32_can/test.esp32-c6-idf.yaml | 6 +- tests/components/espnow/common.yaml | 6 +- .../components/fastled_clockless/common.yaml | 6 +- tests/components/fastled_spi/common.yaml | 6 +- tests/components/font/common.yaml | 6 +- tests/components/font/test.host.yaml | 6 +- tests/components/graph/common.yaml | 2 +- .../graphical_display_menu/common.yaml | 17 ++- tests/components/gt911/common.yaml | 4 +- tests/components/image/test.esp32-idf.yaml | 2 +- tests/components/image/test.esp8266-ard.yaml | 2 +- tests/components/image/test.rp2040-ard.yaml | 2 +- .../components/integration/common-esp32.yaml | 4 +- .../integration/test.esp8266-ard.yaml | 4 +- .../integration/test.rp2040-ard.yaml | 4 +- tests/components/lcd_menu/common.yaml | 4 +- tests/components/light/common.yaml | 2 +- tests/components/light/test.esp32-idf.yaml | 2 +- tests/components/light/test.esp8266-ard.yaml | 2 +- .../components/light/test.nrf52-adafruit.yaml | 4 +- tests/components/light/test.nrf52-mcumgr.yaml | 4 +- tests/components/light/test.rp2040-ard.yaml | 2 +- tests/components/lilygo_t5_47/common.yaml | 4 +- tests/components/lock/common.yaml | 4 +- tests/components/mapping/test.esp32-idf.yaml | 2 +- .../components/mapping/test.esp8266-ard.yaml | 2 +- tests/components/mapping/test.rp2040-ard.yaml | 2 +- tests/components/monochromatic/common.yaml | 4 +- tests/components/mpr121/common.yaml | 6 +- tests/components/nextion/common.yaml | 4 +- .../components/nextion/common_tft_upload.yaml | 2 +- .../nextion/common_tft_upload_watchdog.yaml | 2 +- tests/components/ntc/common.yaml | 10 +- tests/components/number/common.yaml | 4 +- .../components/online_image/common-esp32.yaml | 2 +- .../online_image/common-esp8266.yaml | 2 +- .../online_image/common-rp2040.yaml | 2 +- .../online_image/test.esp32-s3-ard.yaml | 2 +- .../online_image/test.esp32-s3-idf.yaml | 2 +- tests/components/output/common.yaml | 12 +- tests/components/pi4ioe5v6408/common.yaml | 2 +- tests/components/pid/common.yaml | 6 +- tests/components/prometheus/common.yaml | 6 +- tests/components/qspi_dbi/common.yaml | 2 +- .../remote_transmitter/common-buttons.yaml | 6 +- tests/components/resistance/common.yaml | 6 +- tests/components/rgb/common.yaml | 8 +- tests/components/rgbct/common.yaml | 8 +- tests/components/rgbw/common.yaml | 8 +- tests/components/rgbww/common.yaml | 8 +- .../rp2040_pio_led_strip/common.yaml | 2 +- tests/components/rp2040_pwm/common.yaml | 4 +- tests/components/sdl/common.yaml | 8 +- tests/components/speaker/common.yaml | 4 +- tests/components/speed/common.yaml | 6 +- tests/components/sprinkler/common.yaml | 12 +- tests/components/ssd1306_i2c/common.yaml | 2 +- tests/components/switch/common.yaml | 2 +- tests/components/sx126x/common.yaml | 4 +- tests/components/sx127x/common.yaml | 4 +- tests/components/template/common-base.yaml | 14 +- tests/components/tt21100/common.yaml | 4 +- tests/components/uart/test.esp32-idf.yaml | 4 +- tests/components/udp/common.yaml | 4 +- tests/components/ufire_ec/common.yaml | 6 +- tests/components/ufire_ise/common.yaml | 4 +- tests/components/web_server_idf/common.yaml | 4 +- tests/components/wk2132_i2c/common.yaml | 2 +- tests/components/wk2132_spi/common.yaml | 2 +- tests/components/wk2168_i2c/common.yaml | 2 +- tests/components/wk2168_spi/common.yaml | 2 +- tests/components/wk2204_i2c/common.yaml | 2 +- tests/components/wk2204_spi/common.yaml | 2 +- tests/components/wk2212_i2c/common.yaml | 2 +- tests/components/wk2212_spi/common.yaml | 2 +- tests/script/test_merge_component_configs.py | 72 ----------- 115 files changed, 252 insertions(+), 482 deletions(-) delete mode 100755 script/ci_check_duplicate_test_ids.py delete mode 100644 tests/script/test_merge_component_configs.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96c205fb70..40267240d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,7 +113,6 @@ jobs: script/build_language_schema.py --check script/generate-esp32-boards.py --check script/generate-rp2040-boards.py --check - script/ci_check_duplicate_test_ids.py import-time: name: Check import esphome.__main__ time diff --git a/script/ci_check_duplicate_test_ids.py b/script/ci_check_duplicate_test_ids.py deleted file mode 100755 index 9d498dd64f..0000000000 --- a/script/ci_check_duplicate_test_ids.py +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env python3 -"""Fail when two component test fixtures define the same id with different content. - -Component tests are merged and built in groups in CI (see -``script/merge_component_configs.py``). When two components declare the same id -under the same section but with different content, the merge silently keeps the -first and drops the rest, which can make a cross-reference resolve to an -incompatible entity (this is what broke the i2s_audio speaker tests). The merge -now raises on such a collision, but only when the two components land in the same -group. This script is the complete, batch-independent guard: it scans every -component's ``test..yaml`` per platform and reports any id that is -defined by more than one component with differing content. - -Ids that are intentionally shared across components (e.g. a singleton -``sntp_time`` clock) are listed in ``INTENTIONALLY_SHARED_IDS`` and skipped. -""" - -from __future__ import annotations - -from collections import defaultdict -from pathlib import Path -import sys - -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from script.merge_component_configs import ( # noqa: E402 - INTENTIONALLY_SHARED_IDS, - load_yaml_file, -) - -TESTS_DIR = Path("tests/components") - - -def _normalize(value: object) -> object: - """Return a hashable, order-independent representation for comparison.""" - if isinstance(value, dict): - return tuple(sorted((str(k), _normalize(v)) for k, v in value.items())) - if isinstance(value, (list, tuple)): - return tuple(_normalize(v) for v in value) - # Scalars (and ESPHome tag objects like !lambda) compare by their text form - return str(value) - - -def _collect_ids( - data: object, section: str, out: dict[tuple[str, str], object] -) -> None: - """Walk a parsed config and record (section, id) -> normalized content.""" - if isinstance(data, dict): - for key, value in data.items(): - if isinstance(value, list): - for item in value: - if isinstance(item, dict) and "id" in item: - out[(key, str(item["id"]))] = _normalize(item) - _collect_ids(item, key, out) - else: - _collect_ids(value, key, out) - elif isinstance(data, list): - for item in data: - _collect_ids(item, section, out) - - -def _discover_platforms() -> set[str]: - platforms: set[str] = set() - for test_file in TESTS_DIR.glob("*/test.*.yaml"): - # test..yaml -> platform is the middle dotted part - parts = test_file.name.split(".") - if len(parts) == 3: - platforms.add(parts[1]) - return platforms - - -def main() -> int: - conflicts: list[str] = [] - for platform in sorted(_discover_platforms()): - # (section, id) -> {normalized_content: [components]} - by_id: dict[tuple[str, str], dict[object, list[str]]] = defaultdict( - lambda: defaultdict(list) - ) - for comp_dir in sorted(TESTS_DIR.iterdir()): - if not comp_dir.is_dir(): - continue - test_file = comp_dir / f"test.{platform}.yaml" - if not test_file.exists(): - continue - try: - data = load_yaml_file(test_file) - except Exception as err: # noqa: BLE001 - print(f"WARNING: could not parse {test_file}: {err}", file=sys.stderr) - continue - ids: dict[tuple[str, str], object] = {} - _collect_ids(data, "", ids) - for (section, id_), content in ids.items(): - if id_ in INTENTIONALLY_SHARED_IDS: - continue - by_id[(section, id_)][content].append(comp_dir.name) - - for (section, id_), variants in sorted(by_id.items()): - if len(variants) < 2: - continue - components = sorted({c for comps in variants.values() for c in comps}) - conflicts.append( - f"[{platform}] id '{id_}' under '{section}' is defined " - f"differently by: {', '.join(components)}" - ) - - if conflicts: - print("Conflicting test component ids found:\n") - for line in conflicts: - print(f" - {line}") - print( - "\nGive each component a unique id (e.g. '_'), or add the " - "id to INTENTIONALLY_SHARED_IDS in script/merge_component_configs.py if " - "it is a deliberately shared singleton." - ) - return 1 - - print("No conflicting test component ids found.") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/script/merge_component_configs.py b/script/merge_component_configs.py index 20457e906a..a952ecff16 100755 --- a/script/merge_component_configs.py +++ b/script/merge_component_configs.py @@ -161,42 +161,18 @@ def prefix_substitutions_in_dict( return data -# Ids that several components intentionally share. ESPHome treats these as a -# single instance when merged (e.g. multiple components each declaring the same -# `sntp_time` clock collapse into one), so duplicates with differing content are -# expected and must not be flagged as accidental collisions. -INTENTIONALLY_SHARED_IDS = frozenset( - { - # Several components each declare an `sntp_time` clock; ESPHome merges - # them into one time source. - "sntp_time", - # esp_ldo and mipi_dsi both configure the channel-3 internal LDO on the - # ESP32-P4; only one LDO per channel may exist, so the shared id lets the - # merge collapse them into a single LDO. - "ldo_id", - } -) - - def deduplicate_by_id(data: dict) -> dict: """Deduplicate list items with the same ID. - Identical items sharing an ID (e.g. a shared bus from a common package pulled - in by several components) are collapsed to the first occurrence. Two items that - share an ID but differ in content are a real conflict: when merged, the first - one silently wins and the others are dropped, which can make cross-references - resolve to an incompatible entity. Rather than defer that to downstream - validation (where it surfaces as a confusing, order-dependent failure), raise - immediately so the offending ID is named. + Keeps only the first occurrence of each ID. If items with the same ID + are identical, this silently deduplicates. If they differ, the first + one is kept (ESPHome's validation will catch if this causes issues). Args: data: Parsed config dictionary Returns: Config with deduplicated lists - - Raises: - ValueError: If two items share an ID but have different content. """ if not isinstance(data, dict): return data @@ -205,26 +181,16 @@ def deduplicate_by_id(data: dict) -> dict: for key, value in data.items(): if isinstance(value, list): # Check for items with 'id' field - seen_items = {} + seen_ids = set() deduped_list = [] for item in value: if isinstance(item, dict) and "id" in item: item_id = item["id"] - if item_id not in seen_items: - seen_items[item_id] = item + if item_id not in seen_ids: + seen_ids.add(item_id) deduped_list.append(item) - elif item_id in INTENTIONALLY_SHARED_IDS: - # Designed singleton shared by several components (e.g. an - # `sntp_time` clock); ESPHome collapses these, so keep first. - pass - elif item != seen_items[item_id]: - raise ValueError( - f"Conflicting definitions for id '{item_id}' under " - f"'{key}' when merging test configs; give each " - f"component a unique id" - ) - # else: identical duplicate (e.g. shared bus package) -> skip + # else: skip duplicate ID (keep first occurrence) else: # No ID, just add it deduped_list.append(item) diff --git a/tests/components/adc/test.bk72xx-ard.yaml b/tests/components/adc/test.bk72xx-ard.yaml index 09ef0e1fad..0645333a81 100644 --- a/tests/components/adc/test.bk72xx-ard.yaml +++ b/tests/components/adc/test.bk72xx-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: P23 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-c2-idf.yaml b/tests/components/adc/test.esp32-c2-idf.yaml index a3019466b5..e764f0fe21 100644 --- a/tests/components/adc/test.esp32-c2-idf.yaml +++ b/tests/components/adc/test.esp32-c2-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-c3-idf.yaml b/tests/components/adc/test.esp32-c3-idf.yaml index a3019466b5..e764f0fe21 100644 --- a/tests/components/adc/test.esp32-c3-idf.yaml +++ b/tests/components/adc/test.esp32-c3-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-idf.yaml b/tests/components/adc/test.esp32-idf.yaml index f31e0e087d..ff1e3bb919 100644 --- a/tests/components/adc/test.esp32-idf.yaml +++ b/tests/components/adc/test.esp32-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: A0 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-p4-idf.yaml b/tests/components/adc/test.esp32-p4-idf.yaml index 77cf50d17c..b77dc299c2 100644 --- a/tests/components/adc/test.esp32-p4-idf.yaml +++ b/tests/components/adc/test.esp32-p4-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: GPIO16 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-s2-idf.yaml b/tests/components/adc/test.esp32-s2-idf.yaml index a3019466b5..e764f0fe21 100644 --- a/tests/components/adc/test.esp32-s2-idf.yaml +++ b/tests/components/adc/test.esp32-s2-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-s3-idf.yaml b/tests/components/adc/test.esp32-s3-idf.yaml index a3019466b5..e764f0fe21 100644 --- a/tests/components/adc/test.esp32-s3-idf.yaml +++ b/tests/components/adc/test.esp32-s3-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp8266-ard.yaml b/tests/components/adc/test.esp8266-ard.yaml index 617464818b..4cc865bb5d 100644 --- a/tests/components/adc/test.esp8266-ard.yaml +++ b/tests/components/adc/test.esp8266-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: VCC name: ADC Test sensor diff --git a/tests/components/adc/test.ln882x-ard.yaml b/tests/components/adc/test.ln882x-ard.yaml index d899259773..face38b647 100644 --- a/tests/components/adc/test.ln882x-ard.yaml +++ b/tests/components/adc/test.ln882x-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: A5 name: ADC Test sensor diff --git a/tests/components/adc/test.rp2040-ard.yaml b/tests/components/adc/test.rp2040-ard.yaml index 617464818b..4cc865bb5d 100644 --- a/tests/components/adc/test.rp2040-ard.yaml +++ b/tests/components/adc/test.rp2040-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: VCC name: ADC Test sensor diff --git a/tests/components/adc/test.rp2040-pico2-ard.yaml b/tests/components/adc/test.rp2040-pico2-ard.yaml index 617464818b..4cc865bb5d 100644 --- a/tests/components/adc/test.rp2040-pico2-ard.yaml +++ b/tests/components/adc/test.rp2040-pico2-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: VCC name: ADC Test sensor diff --git a/tests/components/alarm_control_panel/common.yaml b/tests/components/alarm_control_panel/common.yaml index 327234d6ca..39d5739255 100644 --- a/tests/components/alarm_control_panel/common.yaml +++ b/tests/components/alarm_control_panel/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: gpio - id: alarm_control_panel_bin1 + id: bin1 pin: 1 alarm_control_panel: @@ -18,7 +18,7 @@ alarm_control_panel: pending_time: 15s trigger_time: 30s binary_sensors: - - input: alarm_control_panel_bin1 + - input: bin1 bypass_armed_home: true bypass_armed_night: true bypass_auto: true @@ -39,7 +39,7 @@ alarm_control_panel: pending_time: 15s trigger_time: 30s binary_sensors: - - input: alarm_control_panel_bin1 + - input: bin1 bypass_armed_home: true bypass_armed_night: true bypass_auto: true diff --git a/tests/components/animation/test.esp32-idf.yaml b/tests/components/animation/test.esp32-idf.yaml index b844f5ae92..c28e9584dd 100644 --- a/tests/components/animation/test.esp32-idf.yaml +++ b/tests/components/animation/test.esp32-idf.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ili9xxx - id: animation_main_lcd + id: main_lcd spi_id: spi_bus model: ili9342 cs_pin: 12 diff --git a/tests/components/animation/test.esp8266-ard.yaml b/tests/components/animation/test.esp8266-ard.yaml index a7937ffca2..11a7117d91 100644 --- a/tests/components/animation/test.esp8266-ard.yaml +++ b/tests/components/animation/test.esp8266-ard.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ili9xxx - id: animation_main_lcd + id: main_lcd spi_id: spi_bus model: ili9342 cs_pin: 5 diff --git a/tests/components/animation/test.rp2040-ard.yaml b/tests/components/animation/test.rp2040-ard.yaml index 2cbb254adf..2c99e937f3 100644 --- a/tests/components/animation/test.rp2040-ard.yaml +++ b/tests/components/animation/test.rp2040-ard.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ili9xxx - id: animation_main_lcd + id: main_lcd spi_id: spi_bus model: ili9342 cs_pin: 20 diff --git a/tests/components/axs15231/common.yaml b/tests/components/axs15231/common.yaml index 03e82ab26e..d4fd3becbb 100644 --- a/tests/components/axs15231/common.yaml +++ b/tests/components/axs15231/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: axs15231_ssd1306_i2c_display + id: ssd1306_i2c_display model: SSD1306_128X64 reset_pin: 19 pages: @@ -13,6 +13,6 @@ touchscreen: - platform: axs15231 i2c_id: i2c_bus id: axs15231_touchscreen - display: axs15231_ssd1306_i2c_display + display: ssd1306_i2c_display interrupt_pin: 20 reset_pin: 18 diff --git a/tests/components/axs15231/test.esp8266-ard.yaml b/tests/components/axs15231/test.esp8266-ard.yaml index 245b87bec9..eb599da773 100644 --- a/tests/components/axs15231/test.esp8266-ard.yaml +++ b/tests/components/axs15231/test.esp8266-ard.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: axs15231_ssd1306_display + id: ssd1306_display model: SSD1306_128X64 reset_pin: 13 pages: @@ -15,5 +15,5 @@ display: touchscreen: - platform: axs15231 i2c_id: i2c_bus - display: axs15231_ssd1306_display + display: ssd1306_display interrupt_pin: 12 diff --git a/tests/components/bang_bang/common.yaml b/tests/components/bang_bang/common.yaml index 28798f8173..5882025191 100644 --- a/tests/components/bang_bang/common.yaml +++ b/tests/components/bang_bang/common.yaml @@ -1,6 +1,6 @@ switch: - platform: template - id: bang_bang_template_switch1 + id: template_switch1 optimistic: true - platform: template id: template_switch2 @@ -8,7 +8,7 @@ switch: sensor: - platform: template - id: bang_bang_template_sensor1 + id: template_sensor1 lambda: |- if (millis() > 10000) { return 42.0; @@ -20,16 +20,16 @@ sensor: climate: - platform: bang_bang name: Bang Bang Climate - sensor: bang_bang_template_sensor1 - humidity_sensor: bang_bang_template_sensor1 + sensor: template_sensor1 + humidity_sensor: template_sensor1 default_target_temperature_low: 18°C default_target_temperature_high: 24°C idle_action: - - switch.turn_on: bang_bang_template_switch1 + - switch.turn_on: template_switch1 cool_action: - switch.turn_on: template_switch2 heat_action: - - switch.turn_on: bang_bang_template_switch1 + - switch.turn_on: template_switch1 away_config: default_target_temperature_low: 16°C default_target_temperature_high: 20°C diff --git a/tests/components/binary_sensor/common.yaml b/tests/components/binary_sensor/common.yaml index 4f4cf6ea59..e3fd159b08 100644 --- a/tests/components/binary_sensor/common.yaml +++ b/tests/components/binary_sensor/common.yaml @@ -1,7 +1,7 @@ binary_sensor: - platform: template trigger_on_initial_state: true - id: binary_sensor_some_binary_sensor + id: some_binary_sensor name: "Random binary" lambda: return (random_uint32() & 1) == 0; filters: @@ -21,7 +21,7 @@ binary_sensor: time_off: 100ms time_on: 400ms - lambda: |- - if (id(binary_sensor_some_binary_sensor).state) { + if (id(some_binary_sensor).state) { return x; } return {}; @@ -36,7 +36,7 @@ binary_sensor: - logger.log: format: "New state is %s" args: ['x.has_value() ? ONOFF(x) : "Unknown"'] - - binary_sensor.invalidate_state: binary_sensor_some_binary_sensor + - binary_sensor.invalidate_state: some_binary_sensor # Test autorepeat with default configuration (no timings) - platform: template diff --git a/tests/components/binary_sensor_map/common.yaml b/tests/components/binary_sensor_map/common.yaml index 667d0be9e7..c054022583 100644 --- a/tests/components/binary_sensor_map/common.yaml +++ b/tests/components/binary_sensor_map/common.yaml @@ -1,20 +1,20 @@ binary_sensor: - platform: template - id: binary_sensor_map_bin1 + id: bin1 lambda: |- if (millis() > 10000) { return true; } return false; - platform: template - id: binary_sensor_map_bin2 + id: bin2 lambda: |- if (millis() > 20000) { return true; } return false; - platform: template - id: binary_sensor_map_bin3 + id: bin3 lambda: |- if (millis() > 30000) { return true; @@ -26,33 +26,33 @@ sensor: name: Binary Sensor Map Group type: group channels: - - binary_sensor: binary_sensor_map_bin1 + - binary_sensor: bin1 value: 10.0 - - binary_sensor: binary_sensor_map_bin2 + - binary_sensor: bin2 value: 15.0 - - binary_sensor: binary_sensor_map_bin3 + - binary_sensor: bin3 value: 100.0 - platform: binary_sensor_map name: Binary Sensor Map Sum type: sum channels: - - binary_sensor: binary_sensor_map_bin1 + - binary_sensor: bin1 value: 10.0 - - binary_sensor: binary_sensor_map_bin2 + - binary_sensor: bin2 value: 15.0 - - binary_sensor: binary_sensor_map_bin3 + - binary_sensor: bin3 value: 100.0 - platform: binary_sensor_map name: Binary Sensor Map Bayesian type: bayesian prior: 0.4 observations: - - binary_sensor: binary_sensor_map_bin1 + - binary_sensor: bin1 prob_given_true: 0.9 prob_given_false: 0.4 - - binary_sensor: binary_sensor_map_bin2 + - binary_sensor: bin2 prob_given_true: 0.7 prob_given_false: 0.05 - - binary_sensor: binary_sensor_map_bin3 + - binary_sensor: bin3 prob_given_true: 0.8 prob_given_false: 0.2 diff --git a/tests/components/ble_client/common.yaml b/tests/components/ble_client/common.yaml index 4ed6ad7fc9..4ea1dd60f3 100644 --- a/tests/components/ble_client/common.yaml +++ b/tests/components/ble_client/common.yaml @@ -56,7 +56,7 @@ sensor: number: - platform: template name: "Test Number" - id: ble_client_test_number + id: test_number optimistic: true min_value: 0 max_value: 255 @@ -72,5 +72,5 @@ button: service_uuid: "abcd1234-abcd-1234-abcd-abcd12345678" characteristic_uuid: "abcd1235-abcd-1234-abcd-abcd12345678" value: !lambda |- - uint8_t val = (uint8_t)id(ble_client_test_number).state; + uint8_t val = (uint8_t)id(test_number).state; return std::vector{0xAA, val, 0xBB}; diff --git a/tests/components/canbus/common.yaml b/tests/components/canbus/common.yaml index 3ba3564608..e779f7f078 100644 --- a/tests/components/canbus/common.yaml +++ b/tests/components/canbus/common.yaml @@ -1,6 +1,6 @@ canbus: - platform: esp32_can - id: canbus_esp32_internal_can + id: esp32_internal_can rx_pin: 4 tx_pin: 5 can_id: 4 @@ -40,7 +40,7 @@ canbus: number: - platform: template name: "Test Number" - id: canbus_test_number + id: test_number optimistic: true min_value: 0 max_value: 255 @@ -62,5 +62,5 @@ button: - canbus.send: !lambda return {0, 1, 2}; # Test canbus.send with lambda that references a component (function pointer) - canbus.send: !lambda |- - uint8_t val = (uint8_t)id(canbus_test_number).state; + uint8_t val = (uint8_t)id(test_number).state; return std::vector{0xAA, val, 0xBB}; diff --git a/tests/components/climate_ir_lg/common.yaml b/tests/components/climate_ir_lg/common.yaml index e0bc185d2c..37011b16ee 100644 --- a/tests/components/climate_ir_lg/common.yaml +++ b/tests/components/climate_ir_lg/common.yaml @@ -1,6 +1,6 @@ sensor: - platform: template - id: climate_ir_lg_temp_sensor + id: temp_sensor lambda: return 22.0; update_interval: 60s - platform: template @@ -12,5 +12,5 @@ climate: - platform: climate_ir_lg name: LG Climate transmitter_id: xmitr - sensor: climate_ir_lg_temp_sensor + sensor: temp_sensor humidity_sensor: humidity_sensor diff --git a/tests/components/color_temperature/common.yaml b/tests/components/color_temperature/common.yaml index 0db54d10d0..fe0c5bf917 100644 --- a/tests/components/color_temperature/common.yaml +++ b/tests/components/color_temperature/common.yaml @@ -1,15 +1,15 @@ output: - platform: ${light_platform} - id: color_temperature_light_output_1 + id: light_output_1 pin: ${pin_o1} - platform: ${light_platform} - id: color_temperature_light_output_2 + id: light_output_2 pin: ${pin_o2} light: - platform: color_temperature name: Lights - color_temperature: color_temperature_light_output_1 - brightness: color_temperature_light_output_2 + color_temperature: light_output_1 + brightness: light_output_2 cold_white_color_temperature: 153 mireds warm_white_color_temperature: 500 mireds diff --git a/tests/components/copy/common.yaml b/tests/components/copy/common.yaml index cbd056f070..a376004b2f 100644 --- a/tests/components/copy/common.yaml +++ b/tests/components/copy/common.yaml @@ -1,17 +1,17 @@ output: - platform: ${pwm_platform} - id: copy_fan_output_1 + id: fan_output_1 pin: ${pin} fan: - platform: speed - id: copy_fan_speed - output: copy_fan_output_1 + id: fan_speed + output: fan_output_1 preset_modes: - Eco - Turbo - platform: copy - source_id: copy_fan_speed + source_id: fan_speed name: Fan Speed Copy select: diff --git a/tests/components/current_based/common.yaml b/tests/components/current_based/common.yaml index 139571ccec..503c4596e9 100644 --- a/tests/components/current_based/common.yaml +++ b/tests/components/current_based/common.yaml @@ -31,7 +31,7 @@ sensor: switch: - platform: template - id: current_based_template_switch1 + id: template_switch1 optimistic: true - platform: template id: template_switch2 @@ -46,7 +46,7 @@ cover: open_obstacle_current_threshold: 0.8 open_duration: 12s open_action: - - switch.turn_on: current_based_template_switch1 + - switch.turn_on: template_switch1 close_sensor: ade7953_current_b close_moving_current_threshold: 0.5 close_obstacle_current_threshold: 0.8 @@ -54,7 +54,7 @@ cover: close_action: - switch.turn_on: template_switch2 stop_action: - - switch.turn_off: current_based_template_switch1 + - switch.turn_off: template_switch1 - switch.turn_off: template_switch2 obstacle_rollback: 30% start_sensing_delay: 0.8s diff --git a/tests/components/cwww/common.yaml b/tests/components/cwww/common.yaml index bbb6c9182b..7fa5ab668c 100644 --- a/tests/components/cwww/common.yaml +++ b/tests/components/cwww/common.yaml @@ -1,8 +1,8 @@ light: - platform: cwww name: CWWW Light - cold_white: cwww_light_output_1 - warm_white: cwww_light_output_2 + cold_white: light_output_1 + warm_white: light_output_2 cold_white_color_temperature: 153 mireds warm_white_color_temperature: 500 mireds constant_brightness: true diff --git a/tests/components/cwww/test.esp32-idf.yaml b/tests/components/cwww/test.esp32-idf.yaml index 0665879b08..01edf0b0b5 100644 --- a/tests/components/cwww/test.esp32-idf.yaml +++ b/tests/components/cwww/test.esp32-idf.yaml @@ -5,11 +5,11 @@ substitutions: output: - platform: ${light_platform} - id: cwww_light_output_1 + id: light_output_1 pin: ${pin_o1} channel: 0 - platform: ${light_platform} - id: cwww_light_output_2 + id: light_output_2 pin: ${pin_o2} channel: 1 phase_angle: 180° diff --git a/tests/components/cwww/test.esp8266-ard.yaml b/tests/components/cwww/test.esp8266-ard.yaml index bb1868fdef..49d73b7d3d 100644 --- a/tests/components/cwww/test.esp8266-ard.yaml +++ b/tests/components/cwww/test.esp8266-ard.yaml @@ -5,10 +5,10 @@ substitutions: output: - platform: ${light_platform} - id: cwww_light_output_1 + id: light_output_1 pin: ${pin_o1} - platform: ${light_platform} - id: cwww_light_output_2 + id: light_output_2 pin: ${pin_o2} <<: !include common.yaml diff --git a/tests/components/cwww/test.rp2040-ard.yaml b/tests/components/cwww/test.rp2040-ard.yaml index 27fc930b68..ba8e0ad071 100644 --- a/tests/components/cwww/test.rp2040-ard.yaml +++ b/tests/components/cwww/test.rp2040-ard.yaml @@ -5,10 +5,10 @@ substitutions: output: - platform: ${light_platform} - id: cwww_light_output_1 + id: light_output_1 pin: ${pin_o1} - platform: ${light_platform} - id: cwww_light_output_2 + id: light_output_2 pin: ${pin_o2} <<: !include common.yaml diff --git a/tests/components/display/common.yaml b/tests/components/display/common.yaml index 6617671972..a722a5f7c2 100644 --- a/tests/components/display/common.yaml +++ b/tests/components/display/common.yaml @@ -1,6 +1,6 @@ display: - platform: ili9xxx - id: display_main_lcd + id: main_lcd model: ili9342 cs_pin: 12 dc_pin: 13 diff --git a/tests/components/duty_time/common.yaml b/tests/components/duty_time/common.yaml index 12e4397c49..761d10f16a 100644 --- a/tests/components/duty_time/common.yaml +++ b/tests/components/duty_time/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: template - id: duty_time_bin1 + id: bin1 lambda: |- if (millis() > 10000) { return true; @@ -10,4 +10,4 @@ binary_sensor: sensor: - platform: duty_time name: Duty Time - sensor: duty_time_bin1 + sensor: bin1 diff --git a/tests/components/e131/test.rp2040-ard.yaml b/tests/components/e131/test.rp2040-ard.yaml index 4593784ef9..25fe3b6796 100644 --- a/tests/components/e131/test.rp2040-ard.yaml +++ b/tests/components/e131/test.rp2040-ard.yaml @@ -2,7 +2,7 @@ light: - platform: rp2040_pio_led_strip - id: e131_led_strip + id: led_strip pin: 2 pio: 0 num_leds: 256 diff --git a/tests/components/ektf2232/common.yaml b/tests/components/ektf2232/common.yaml index 070b03eeb9..1c4d768b08 100644 --- a/tests/components/ektf2232/common.yaml +++ b/tests/components/ektf2232/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ektf2232_ssd1306_i2c_display + id: ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${display_reset_pin} pages: @@ -15,7 +15,7 @@ touchscreen: id: ektf2232_touchscreen interrupt_pin: ${interrupt_pin} reset_pin: ${touch_reset_pin} - display: ektf2232_ssd1306_i2c_display + display: ssd1306_i2c_display on_touch: - logger.log: format: Touch at (%d, %d) diff --git a/tests/components/endstop/common.yaml b/tests/components/endstop/common.yaml index 6f5cf61268..b92b1e13b9 100644 --- a/tests/components/endstop/common.yaml +++ b/tests/components/endstop/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: template - id: endstop_bin1 + id: bin1 lambda: |- if (millis() > 10000) { return true; @@ -9,7 +9,7 @@ binary_sensor: switch: - platform: template - id: endstop_template_switch1 + id: template_switch1 optimistic: true - platform: template id: template_switch2 @@ -20,12 +20,12 @@ cover: id: endstop_cover name: Endstop Cover stop_action: - - switch.turn_on: endstop_template_switch1 - open_endstop: endstop_bin1 + - switch.turn_on: template_switch1 + open_endstop: bin1 open_action: - - switch.turn_on: endstop_template_switch1 + - switch.turn_on: template_switch1 open_duration: 5min - close_endstop: endstop_bin1 + close_endstop: bin1 close_action: - switch.turn_on: template_switch2 close_duration: 4.5min diff --git a/tests/components/esp32_can/common.yaml b/tests/components/esp32_can/common.yaml index f15b609d84..3b9b33c048 100644 --- a/tests/components/esp32_can/common.yaml +++ b/tests/components/esp32_can/common.yaml @@ -13,7 +13,7 @@ esphome: canbus: - platform: esp32_can - id: esp32_can_esp32_internal_can + id: esp32_internal_can rx_pin: ${rx_pin} tx_pin: ${tx_pin} can_id: 4 diff --git a/tests/components/esp32_can/test.esp32-c6-idf.yaml b/tests/components/esp32_can/test.esp32-c6-idf.yaml index c548b4f0f4..ac978482fc 100644 --- a/tests/components/esp32_can/test.esp32-c6-idf.yaml +++ b/tests/components/esp32_can/test.esp32-c6-idf.yaml @@ -3,20 +3,20 @@ esphome: then: - canbus.send: # Extended ID explicit - canbus_id: esp32_can_esp32_internal_can + canbus_id: esp32_internal_can use_extended_id: true can_id: 0x100 data: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] - canbus.send: # Standard ID by default - canbus_id: esp32_can_esp32_internal_can + canbus_id: esp32_internal_can can_id: 0x100 data: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] # Note: esp32_internal_can_2 uses LISTENONLY mode, so no send actions canbus: - platform: esp32_can - id: esp32_can_esp32_internal_can + id: esp32_internal_can rx_pin: GPIO8 tx_pin: GPIO7 can_id: 4 diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml index f05735e8f4..bdc478ea03 100644 --- a/tests/components/espnow/common.yaml +++ b/tests/components/espnow/common.yaml @@ -62,7 +62,7 @@ packet_transport: encryption: key: "0123456789abcdef0123456789abcdef" sensors: - - espnow_temp_sensor + - temp_sensor providers: - name: test-provider encryption: @@ -70,9 +70,9 @@ packet_transport: sensor: - platform: internal_temperature - id: espnow_temp_sensor + id: temp_sensor - platform: packet_transport provider: test-provider - remote_id: espnow_temp_sensor + remote_id: temp_sensor id: remote_temp diff --git a/tests/components/fastled_clockless/common.yaml b/tests/components/fastled_clockless/common.yaml index a7ce7ed280..8b1447a17a 100644 --- a/tests/components/fastled_clockless/common.yaml +++ b/tests/components/fastled_clockless/common.yaml @@ -1,6 +1,6 @@ light: - platform: fastled_clockless - id: fastled_clockless_addr1 + id: addr1 chipset: WS2811 pin: 13 num_leds: 100 @@ -59,13 +59,13 @@ light: name: Custom Effect sequence: - light.addressable_set: - id: fastled_clockless_addr1 + id: addr1 red: 100% green: 100% blue: 0% - delay: 100ms - light.addressable_set: - id: fastled_clockless_addr1 + id: addr1 red: 0% green: 100% blue: 0% diff --git a/tests/components/fastled_spi/common.yaml b/tests/components/fastled_spi/common.yaml index 19d00627f8..f6f7c5553b 100644 --- a/tests/components/fastled_spi/common.yaml +++ b/tests/components/fastled_spi/common.yaml @@ -1,6 +1,6 @@ light: - platform: fastled_spi - id: fastled_spi_addr1 + id: addr1 chipset: WS2801 clock_pin: 22 data_pin: 23 @@ -59,13 +59,13 @@ light: name: Custom Effect sequence: - light.addressable_set: - id: fastled_spi_addr1 + id: addr1 red: 100% green: 100% blue: 0% - delay: 100ms - light.addressable_set: - id: fastled_spi_addr1 + id: addr1 red: 0% green: 100% blue: 0% diff --git a/tests/components/font/common.yaml b/tests/components/font/common.yaml index 59063291e7..c156b4aea1 100644 --- a/tests/components/font/common.yaml +++ b/tests/components/font/common.yaml @@ -8,7 +8,7 @@ font: id: roboto32 - file: "gfonts://Roboto" - id: font_roboto + id: roboto size: 20 glyphs: "0123456789." extras: @@ -50,11 +50,11 @@ font: display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: font_ssd1306_display + id: ssd1306_display model: SSD1306_128X64 reset_pin: ${display_reset_pin} lambda: |- - it.print(0, 0, id(font_roboto), "Hello, World!"); + it.print(0, 0, id(roboto), "Hello, World!"); it.print(0, 20, id(roboto_web), "Hello, World!"); it.print(0, 40, id(monocraft), "Hello, World!"); it.print(0, 60, id(monocraft2), "Hello, World!"); diff --git a/tests/components/font/test.host.yaml b/tests/components/font/test.host.yaml index 8ada8b7a4e..387ea47335 100644 --- a/tests/components/font/test.host.yaml +++ b/tests/components/font/test.host.yaml @@ -8,7 +8,7 @@ font: id: roboto32 - file: "gfonts://Roboto" - id: font_roboto + id: roboto size: 20 glyphs: "0123456789." extras: @@ -44,12 +44,12 @@ font: display: - platform: sdl - id: font_sdl_display + id: sdl_display dimensions: width: 800 height: 600 lambda: |- - it.print(0, 0, id(font_roboto), "Hello, World!"); + it.print(0, 0, id(roboto), "Hello, World!"); it.print(0, 20, id(roboto_web), "Hello, World!"); it.print(0, 40, id(roboto_greek), "Hello κόσμε!"); it.print(0, 60, id(monocraft), "Hello, World!"); diff --git a/tests/components/graph/common.yaml b/tests/components/graph/common.yaml index edf4493aa6..11e2a16ca1 100644 --- a/tests/components/graph/common.yaml +++ b/tests/components/graph/common.yaml @@ -12,7 +12,7 @@ graph: display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: graph_ssd1306_display + id: ssd1306_display model: SSD1306_128X64 reset_pin: ${reset_pin} pages: diff --git a/tests/components/graphical_display_menu/common.yaml b/tests/components/graphical_display_menu/common.yaml index 50f8a5bc85..6cee2af232 100644 --- a/tests/components/graphical_display_menu/common.yaml +++ b/tests/components/graphical_display_menu/common.yaml @@ -1,7 +1,6 @@ display: - platform: ssd1306_i2c - i2c_id: i2c_bus - id: graphical_display_menu_ssd1306_i2c_display + id: ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${reset_pin} pages: @@ -11,12 +10,12 @@ display: font: - file: "gfonts://Roboto" - id: graphical_display_menu_roboto + id: roboto size: 20 number: - platform: template - id: graphical_display_menu_test_number + id: test_number min_value: 0 step: 1 max_value: 10 @@ -32,13 +31,13 @@ select: switch: - platform: template - id: graphical_display_menu_test_switch + id: test_switch optimistic: true graphical_display_menu: id: test_graphical_display_menu - display: graphical_display_menu_ssd1306_i2c_display - font: graphical_display_menu_roboto + display: ssd1306_i2c_display + font: roboto active: false mode: rotary on_enter: @@ -81,7 +80,7 @@ graphical_display_menu: lambda: 'ESP_LOGI("graphical_display_menu", "select value: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' - type: number text: "Number" - number: graphical_display_menu_test_number + number: test_number on_enter: then: lambda: 'ESP_LOGI("graphical_display_menu", "number enter: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' @@ -98,7 +97,7 @@ graphical_display_menu: - display_menu.hide: test_graphical_display_menu - type: switch text: "Switch" - switch: graphical_display_menu_test_switch + switch: test_switch on_text: "Bright" off_text: "Dark" immediate_edit: false diff --git a/tests/components/gt911/common.yaml b/tests/components/gt911/common.yaml index 0fc40737f0..ff464cda24 100644 --- a/tests/components/gt911/common.yaml +++ b/tests/components/gt911/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: gt911_ssd1306_i2c_display + id: ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${display_reset_pin} pages: @@ -13,7 +13,7 @@ touchscreen: - platform: gt911 i2c_id: i2c_bus id: gt911_touchscreen - display: gt911_ssd1306_i2c_display + display: ssd1306_i2c_display interrupt_pin: ${interrupt_pin} reset_pin: ${reset_pin} diff --git a/tests/components/image/test.esp32-idf.yaml b/tests/components/image/test.esp32-idf.yaml index 9e93c4c289..aea2b4bbb0 100644 --- a/tests/components/image/test.esp32-idf.yaml +++ b/tests/components/image/test.esp32-idf.yaml @@ -3,7 +3,7 @@ packages: display: - platform: ili9xxx - id: image_main_lcd + id: main_lcd spi_id: spi_bus model: ili9342 cs_pin: 15 diff --git a/tests/components/image/test.esp8266-ard.yaml b/tests/components/image/test.esp8266-ard.yaml index 492b57c449..2e7bfc5ae5 100644 --- a/tests/components/image/test.esp8266-ard.yaml +++ b/tests/components/image/test.esp8266-ard.yaml @@ -3,7 +3,7 @@ packages: display: - platform: ili9xxx - id: image_main_lcd + id: main_lcd spi_id: spi_bus model: ili9342 cs_pin: 5 diff --git a/tests/components/image/test.rp2040-ard.yaml b/tests/components/image/test.rp2040-ard.yaml index ce2a13fca7..03a9c42a38 100644 --- a/tests/components/image/test.rp2040-ard.yaml +++ b/tests/components/image/test.rp2040-ard.yaml @@ -3,7 +3,7 @@ packages: display: - platform: ili9xxx - id: image_main_lcd + id: main_lcd spi_id: spi_bus model: ili9342 cs_pin: 20 diff --git a/tests/components/integration/common-esp32.yaml b/tests/components/integration/common-esp32.yaml index c912fb9b84..26550d3c5c 100644 --- a/tests/components/integration/common-esp32.yaml +++ b/tests/components/integration/common-esp32.yaml @@ -9,11 +9,11 @@ esphome: sensor: - platform: adc - id: integration_my_sensor + id: my_sensor pin: ${pin} attenuation: 12db - platform: integration id: integration_sensor - sensor: integration_my_sensor + sensor: my_sensor name: Integration Sensor time_unit: s diff --git a/tests/components/integration/test.esp8266-ard.yaml b/tests/components/integration/test.esp8266-ard.yaml index 377bad5578..51d3e19077 100644 --- a/tests/components/integration/test.esp8266-ard.yaml +++ b/tests/components/integration/test.esp8266-ard.yaml @@ -1,8 +1,8 @@ sensor: - platform: adc - id: integration_my_sensor + id: my_sensor pin: VCC - platform: integration - sensor: integration_my_sensor + sensor: my_sensor name: Integration Sensor time_unit: s diff --git a/tests/components/integration/test.rp2040-ard.yaml b/tests/components/integration/test.rp2040-ard.yaml index 377bad5578..51d3e19077 100644 --- a/tests/components/integration/test.rp2040-ard.yaml +++ b/tests/components/integration/test.rp2040-ard.yaml @@ -1,8 +1,8 @@ sensor: - platform: adc - id: integration_my_sensor + id: my_sensor pin: VCC - platform: integration - sensor: integration_my_sensor + sensor: my_sensor name: Integration Sensor time_unit: s diff --git a/tests/components/lcd_menu/common.yaml b/tests/components/lcd_menu/common.yaml index a7740e771d..970c18e0d2 100644 --- a/tests/components/lcd_menu/common.yaml +++ b/tests/components/lcd_menu/common.yaml @@ -1,6 +1,6 @@ number: - platform: template - id: lcd_menu_test_number + id: test_number min_value: 0 step: 1 max_value: 10 @@ -83,7 +83,7 @@ lcd_menu: lambda: 'ESP_LOGI("lcd_menu", "select value: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' - type: number text: Number - number: lcd_menu_test_number + number: test_number on_enter: then: lambda: 'ESP_LOGI("lcd_menu", "number enter: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 71c00e5f10..2acc080c6d 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -156,7 +156,7 @@ light: - platform: binary id: test_binary_light name: Binary Light - output: light_test_binary + output: test_binary effects: - strobe: on_state: diff --git a/tests/components/light/test.esp32-idf.yaml b/tests/components/light/test.esp32-idf.yaml index 49e49b4318..925197182c 100644 --- a/tests/components/light/test.esp32-idf.yaml +++ b/tests/components/light/test.esp32-idf.yaml @@ -1,6 +1,6 @@ output: - platform: gpio - id: light_test_binary + id: test_binary pin: 12 - platform: ledc id: test_ledc_1 diff --git a/tests/components/light/test.esp8266-ard.yaml b/tests/components/light/test.esp8266-ard.yaml index 1eb58eabc4..518011e925 100644 --- a/tests/components/light/test.esp8266-ard.yaml +++ b/tests/components/light/test.esp8266-ard.yaml @@ -1,6 +1,6 @@ output: - platform: gpio - id: light_test_binary + id: test_binary pin: 4 - platform: esp8266_pwm id: test_ledc_1 diff --git a/tests/components/light/test.nrf52-adafruit.yaml b/tests/components/light/test.nrf52-adafruit.yaml index 60521b8088..cb421ed4bb 100644 --- a/tests/components/light/test.nrf52-adafruit.yaml +++ b/tests/components/light/test.nrf52-adafruit.yaml @@ -5,14 +5,14 @@ esphome: output: - platform: gpio - id: light_test_binary + id: test_binary pin: 0 light: - platform: binary id: test_binary_light name: Binary Light - output: light_test_binary + output: test_binary effects: - strobe: on_state: diff --git a/tests/components/light/test.nrf52-mcumgr.yaml b/tests/components/light/test.nrf52-mcumgr.yaml index 60521b8088..cb421ed4bb 100644 --- a/tests/components/light/test.nrf52-mcumgr.yaml +++ b/tests/components/light/test.nrf52-mcumgr.yaml @@ -5,14 +5,14 @@ esphome: output: - platform: gpio - id: light_test_binary + id: test_binary pin: 0 light: - platform: binary id: test_binary_light name: Binary Light - output: light_test_binary + output: test_binary effects: - strobe: on_state: diff --git a/tests/components/light/test.rp2040-ard.yaml b/tests/components/light/test.rp2040-ard.yaml index 21d5cad774..a5a37fd559 100644 --- a/tests/components/light/test.rp2040-ard.yaml +++ b/tests/components/light/test.rp2040-ard.yaml @@ -1,6 +1,6 @@ output: - platform: gpio - id: light_test_binary + id: test_binary pin: 0 - platform: rp2040_pwm id: test_ledc_1 diff --git a/tests/components/lilygo_t5_47/common.yaml b/tests/components/lilygo_t5_47/common.yaml index 5e71736eb0..18f1ba10ae 100644 --- a/tests/components/lilygo_t5_47/common.yaml +++ b/tests/components/lilygo_t5_47/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: lilygo_t5_47_ssd1306_i2c_display + id: ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${reset_pin} pages: @@ -14,7 +14,7 @@ touchscreen: i2c_id: i2c_bus id: lilygo_touchscreen interrupt_pin: ${interrupt_pin} - display: lilygo_t5_47_ssd1306_i2c_display + display: ssd1306_i2c_display on_touch: - logger.log: format: Touch at (%d, %d) diff --git a/tests/components/lock/common.yaml b/tests/components/lock/common.yaml index 08001855cb..9ba7f34857 100644 --- a/tests/components/lock/common.yaml +++ b/tests/components/lock/common.yaml @@ -7,7 +7,7 @@ esphome: output: - platform: gpio - id: lock_test_binary + id: test_binary pin: 4 lock: @@ -32,4 +32,4 @@ lock: - platform: output name: Generic Output Lock id: test_lock2 - output: lock_test_binary + output: test_binary diff --git a/tests/components/mapping/test.esp32-idf.yaml b/tests/components/mapping/test.esp32-idf.yaml index d99f8ddc4e..93adcf9988 100644 --- a/tests/components/mapping/test.esp32-idf.yaml +++ b/tests/components/mapping/test.esp32-idf.yaml @@ -5,7 +5,7 @@ packages: display: spi_id: spi_bus platform: mipi_spi - id: mapping_main_lcd + id: main_lcd model: ili9342 cs_pin: 12 dc_pin: 13 diff --git a/tests/components/mapping/test.esp8266-ard.yaml b/tests/components/mapping/test.esp8266-ard.yaml index e51240f20d..6a308b67dd 100644 --- a/tests/components/mapping/test.esp8266-ard.yaml +++ b/tests/components/mapping/test.esp8266-ard.yaml @@ -5,7 +5,7 @@ packages: display: spi_id: spi_bus platform: mipi_spi - id: mapping_main_lcd + id: main_lcd model: ili9342 cs_pin: 5 dc_pin: 15 diff --git a/tests/components/mapping/test.rp2040-ard.yaml b/tests/components/mapping/test.rp2040-ard.yaml index 0562a4ba51..01b83c4ab8 100644 --- a/tests/components/mapping/test.rp2040-ard.yaml +++ b/tests/components/mapping/test.rp2040-ard.yaml @@ -5,7 +5,7 @@ packages: display: spi_id: spi_bus platform: mipi_spi - id: mapping_main_lcd + id: main_lcd model: ili9342 data_rate: 31.25MHz cs_pin: 20 diff --git a/tests/components/monochromatic/common.yaml b/tests/components/monochromatic/common.yaml index e57c7bec29..9915e086eb 100644 --- a/tests/components/monochromatic/common.yaml +++ b/tests/components/monochromatic/common.yaml @@ -1,13 +1,13 @@ output: - platform: ${light_platform} - id: monochromatic_light_output_1 + id: light_output_1 pin: ${pin} light: - platform: monochromatic name: Monochromatic Light id: monochromatic_light - output: monochromatic_light_output_1 + output: light_output_1 gamma_correct: 2.8 default_transition_length: 2s effects: diff --git a/tests/components/mpr121/common.yaml b/tests/components/mpr121/common.yaml index f96651e9bf..67a06cf9c1 100644 --- a/tests/components/mpr121/common.yaml +++ b/tests/components/mpr121/common.yaml @@ -9,15 +9,15 @@ binary_sensor: name: touchkey0 channel: 0 - platform: mpr121 - id: mpr121_bin1 + id: bin1 name: touchkey1 channel: 1 - platform: mpr121 - id: mpr121_bin2 + id: bin2 name: touchkey2 channel: 2 - platform: mpr121 - id: mpr121_bin3 + id: bin3 name: touchkey3 channel: 6 diff --git a/tests/components/nextion/common.yaml b/tests/components/nextion/common.yaml index 9eadd97a6d..d79e3ee2ed 100644 --- a/tests/components/nextion/common.yaml +++ b/tests/components/nextion/common.yaml @@ -1,6 +1,6 @@ esphome: on_boot: - - lambda: 'ESP_LOGD("display","is_connected(): %s", YESNO(id(nextion_main_lcd).is_connected()));' + - lambda: 'ESP_LOGD("display","is_connected(): %s", YESNO(id(main_lcd).is_connected()));' - display.nextion.set_brightness: 80% @@ -272,7 +272,7 @@ text_sensor: display: - platform: nextion - id: nextion_main_lcd + id: main_lcd auto_wake_on_touch: true brightness: 80% command_spacing: 5ms diff --git a/tests/components/nextion/common_tft_upload.yaml b/tests/components/nextion/common_tft_upload.yaml index 70a0809883..190abbc7b1 100644 --- a/tests/components/nextion/common_tft_upload.yaml +++ b/tests/components/nextion/common_tft_upload.yaml @@ -1,5 +1,5 @@ display: - - id: !extend nextion_main_lcd + - id: !extend main_lcd tft_url: http://esphome.io/default35.tft tft_upload_http_timeout: 20s tft_upload_http_retries: 10 diff --git a/tests/components/nextion/common_tft_upload_watchdog.yaml b/tests/components/nextion/common_tft_upload_watchdog.yaml index f0b44ce8c3..385fee359e 100644 --- a/tests/components/nextion/common_tft_upload_watchdog.yaml +++ b/tests/components/nextion/common_tft_upload_watchdog.yaml @@ -1,3 +1,3 @@ display: - - id: !extend nextion_main_lcd + - id: !extend main_lcd tft_upload_watchdog_timeout: 30s diff --git a/tests/components/ntc/common.yaml b/tests/components/ntc/common.yaml index 1be2c335bc..79ae7f601d 100644 --- a/tests/components/ntc/common.yaml +++ b/tests/components/ntc/common.yaml @@ -1,23 +1,23 @@ sensor: - platform: adc - id: ntc_my_sensor + id: my_sensor pin: ${pin} - platform: resistance - sensor: ntc_my_sensor + sensor: my_sensor configuration: DOWNSTREAM resistor: 10kΩ reference_voltage: 3.3V name: Resistance - id: ntc_resist + id: resist - platform: ntc - sensor: ntc_resist + sensor: resist name: NTC Sensor calibration: b_constant: 3950 reference_resistance: 10k reference_temperature: 25°C - platform: ntc - sensor: ntc_resist + sensor: resist name: NTC Sensor2 calibration: - 10.0kOhm -> 25°C diff --git a/tests/components/number/common.yaml b/tests/components/number/common.yaml index b1a16ebfed..c17c2dd5f8 100644 --- a/tests/components/number/common.yaml +++ b/tests/components/number/common.yaml @@ -1,7 +1,7 @@ number: - platform: template name: "Test Number" - id: number_test_number + id: test_number optimistic: true min_value: 0 max_value: 100 @@ -10,4 +10,4 @@ number: sensor: - platform: number name: "Test Number Value" - source_id: number_test_number + source_id: test_number diff --git a/tests/components/online_image/common-esp32.yaml b/tests/components/online_image/common-esp32.yaml index ee4c1ed0b8..32c909d351 100644 --- a/tests/components/online_image/common-esp32.yaml +++ b/tests/components/online_image/common-esp32.yaml @@ -6,7 +6,7 @@ packages: display: - platform: ili9xxx spi_id: spi_bus - id: online_image_main_lcd + id: main_lcd model: ili9342 cs_pin: 20 dc_pin: 13 diff --git a/tests/components/online_image/common-esp8266.yaml b/tests/components/online_image/common-esp8266.yaml index fc61aad92e..d7722d171a 100644 --- a/tests/components/online_image/common-esp8266.yaml +++ b/tests/components/online_image/common-esp8266.yaml @@ -6,7 +6,7 @@ packages: display: - platform: ili9xxx spi_id: spi_bus - id: online_image_main_lcd + id: main_lcd model: ili9342 cs_pin: 15 dc_pin: 3 diff --git a/tests/components/online_image/common-rp2040.yaml b/tests/components/online_image/common-rp2040.yaml index 4d2785f3e8..bbb514bded 100644 --- a/tests/components/online_image/common-rp2040.yaml +++ b/tests/components/online_image/common-rp2040.yaml @@ -6,7 +6,7 @@ packages: display: - platform: ili9xxx spi_id: spi_bus - id: online_image_main_lcd + id: main_lcd model: ili9342 data_rate: 20MHz cs_pin: 20 diff --git a/tests/components/online_image/test.esp32-s3-ard.yaml b/tests/components/online_image/test.esp32-s3-ard.yaml index 9972a673c0..9116fd86e0 100644 --- a/tests/components/online_image/test.esp32-s3-ard.yaml +++ b/tests/components/online_image/test.esp32-s3-ard.yaml @@ -8,7 +8,7 @@ http_request: display: - platform: ili9xxx spi_id: spi_bus - id: online_image_main_lcd + id: main_lcd model: ili9342 cs_pin: 20 dc_pin: 13 diff --git a/tests/components/online_image/test.esp32-s3-idf.yaml b/tests/components/online_image/test.esp32-s3-idf.yaml index 1f1485fd6c..f219f71ee2 100644 --- a/tests/components/online_image/test.esp32-s3-idf.yaml +++ b/tests/components/online_image/test.esp32-s3-idf.yaml @@ -8,7 +8,7 @@ http_request: display: - platform: ili9xxx spi_id: spi_bus - id: online_image_main_lcd + id: main_lcd model: ili9342 cs_pin: 20 dc_pin: 13 diff --git a/tests/components/output/common.yaml b/tests/components/output/common.yaml index df20dcde2b..81d802e9bf 100644 --- a/tests/components/output/common.yaml +++ b/tests/components/output/common.yaml @@ -1,19 +1,19 @@ esphome: on_boot: then: - - output.turn_off: output_light_output_1 - - output.turn_on: output_light_output_1 + - output.turn_off: light_output_1 + - output.turn_on: light_output_1 - output.set_level: - id: output_light_output_1 + id: light_output_1 level: 50% - output.set_min_power: - id: output_light_output_1 + id: light_output_1 min_power: 20% - output.set_max_power: - id: output_light_output_1 + id: light_output_1 max_power: 80% output: - platform: ${output_platform} - id: output_light_output_1 + id: light_output_1 pin: ${pin} diff --git a/tests/components/pi4ioe5v6408/common.yaml b/tests/components/pi4ioe5v6408/common.yaml index aeda76d35c..77a77fa3e4 100644 --- a/tests/components/pi4ioe5v6408/common.yaml +++ b/tests/components/pi4ioe5v6408/common.yaml @@ -9,7 +9,7 @@ pi4ioe5v6408: switch: - platform: gpio - id: pi4ioe5v6408_switch1 + id: switch1 pin: pi4ioe5v6408: pi4ioe1 number: 0 diff --git a/tests/components/pid/common.yaml b/tests/components/pid/common.yaml index 320e5f775f..262e75591e 100644 --- a/tests/components/pid/common.yaml +++ b/tests/components/pid/common.yaml @@ -23,7 +23,7 @@ output: sensor: - platform: template - id: pid_template_sensor1 + id: template_sensor1 lambda: |- if (millis() > 10000) { return 42.0; @@ -35,8 +35,8 @@ climate: - platform: pid id: pid_climate name: PID Climate Controller - sensor: pid_template_sensor1 - humidity_sensor: pid_template_sensor1 + sensor: template_sensor1 + humidity_sensor: template_sensor1 default_target_temperature: 21°C heat_output: pid_slow_pwm control_parameters: diff --git a/tests/components/prometheus/common.yaml b/tests/components/prometheus/common.yaml index 951d8f7fc5..7ff416dccb 100644 --- a/tests/components/prometheus/common.yaml +++ b/tests/components/prometheus/common.yaml @@ -31,7 +31,7 @@ update: sensor: - platform: template - id: prometheus_template_sensor1 + id: template_sensor1 lambda: |- if (millis() > 10000) { return 42.0; @@ -91,7 +91,7 @@ binary_sensor: switch: - platform: template - id: prometheus_template_switch1 + id: template_switch1 lambda: |- if (millis() > 10000) { return true; @@ -185,7 +185,7 @@ climate: prometheus: include_internal: true relabel: - prometheus_template_sensor1: + template_sensor1: id: hellow_world name: Hello World template_text_sensor1: diff --git a/tests/components/qspi_dbi/common.yaml b/tests/components/qspi_dbi/common.yaml index 0eadfa7392..109db65b63 100644 --- a/tests/components/qspi_dbi/common.yaml +++ b/tests/components/qspi_dbi/common.yaml @@ -16,7 +16,7 @@ display: - platform: qspi_dbi model: CUSTOM - id: qspi_dbi_main_lcd + id: main_lcd draw_from_origin: true dimensions: height: 240 diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 5631c48f95..c6c7049605 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -1,6 +1,6 @@ number: - platform: template - id: remote_transmitter_test_number + id: test_number optimistic: true min_value: 0 max_value: 255 @@ -151,7 +151,7 @@ button: on_press: remote_transmitter.transmit_raw: code: !lambda |- - return {(int32_t)id(remote_transmitter_test_number).state * 100, -1000}; + return {(int32_t)id(test_number).state * 100, -1000}; - platform: template name: AEHA id: eaha_hitachi_climate_power_on @@ -253,7 +253,7 @@ button: destination_address: 0x5678 message_type: 0x01 data: !lambda |- - return {(uint8_t)id(remote_transmitter_test_number).state, 0x20, 0x30}; + return {(uint8_t)id(test_number).state, 0x20, 0x30}; - platform: template name: Digital Write on_press: diff --git a/tests/components/resistance/common.yaml b/tests/components/resistance/common.yaml index 8966b574df..b3eec49548 100644 --- a/tests/components/resistance/common.yaml +++ b/tests/components/resistance/common.yaml @@ -1,11 +1,11 @@ sensor: - platform: adc - id: resistance_my_sensor + id: my_sensor pin: ${pin} - platform: resistance - sensor: resistance_my_sensor + sensor: my_sensor configuration: DOWNSTREAM resistor: 10kΩ reference_voltage: 3.3V name: Resistance - id: resistance_resist + id: resist diff --git a/tests/components/rgb/common.yaml b/tests/components/rgb/common.yaml index bd72abbd17..9f25efa431 100644 --- a/tests/components/rgb/common.yaml +++ b/tests/components/rgb/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${light_platform} - id: rgb_light_output_1 + id: light_output_1 pin: ${pin1} - platform: ${light_platform} - id: rgb_light_output_2 + id: light_output_2 pin: ${pin2} - platform: ${light_platform} id: light_output_3 @@ -13,6 +13,6 @@ light: - platform: rgb name: RGB Light id: rgb_light - red: rgb_light_output_1 - green: rgb_light_output_2 + red: light_output_1 + green: light_output_2 blue: light_output_3 diff --git a/tests/components/rgbct/common.yaml b/tests/components/rgbct/common.yaml index 46d8082706..65bb248e95 100644 --- a/tests/components/rgbct/common.yaml +++ b/tests/components/rgbct/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${light_platform} - id: rgbct_light_output_1 + id: light_output_1 pin: ${pin1} - platform: ${light_platform} - id: rgbct_light_output_2 + id: light_output_2 pin: ${pin2} - platform: ${light_platform} id: light_output_3 @@ -18,8 +18,8 @@ output: light: - platform: rgbct name: RGBCT Light - red: rgbct_light_output_1 - green: rgbct_light_output_2 + red: light_output_1 + green: light_output_2 blue: light_output_3 color_temperature: light_output_4 white_brightness: light_output_5 diff --git a/tests/components/rgbw/common.yaml b/tests/components/rgbw/common.yaml index 4a8e56a255..b0f44869d3 100644 --- a/tests/components/rgbw/common.yaml +++ b/tests/components/rgbw/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${light_platform} - id: rgbw_light_output_1 + id: light_output_1 pin: ${pin1} - platform: ${light_platform} - id: rgbw_light_output_2 + id: light_output_2 pin: ${pin2} - platform: ${light_platform} id: light_output_3 @@ -15,8 +15,8 @@ output: light: - platform: rgbw name: RGBW Light - red: rgbw_light_output_1 - green: rgbw_light_output_2 + red: light_output_1 + green: light_output_2 blue: light_output_3 white: light_output_4 color_interlock: true diff --git a/tests/components/rgbww/common.yaml b/tests/components/rgbww/common.yaml index bb1d73b3bc..0013960c10 100644 --- a/tests/components/rgbww/common.yaml +++ b/tests/components/rgbww/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${light_platform} - id: rgbww_light_output_1 + id: light_output_1 pin: ${pin1} - platform: ${light_platform} - id: rgbww_light_output_2 + id: light_output_2 pin: ${pin2} - platform: ${light_platform} id: light_output_3 @@ -18,8 +18,8 @@ output: light: - platform: rgbww name: RGBWW Light - red: rgbww_light_output_1 - green: rgbww_light_output_2 + red: light_output_1 + green: light_output_2 blue: light_output_3 cold_white: light_output_4 warm_white: light_output_5 diff --git a/tests/components/rp2040_pio_led_strip/common.yaml b/tests/components/rp2040_pio_led_strip/common.yaml index 254ac0e13d..b9b1436cdb 100644 --- a/tests/components/rp2040_pio_led_strip/common.yaml +++ b/tests/components/rp2040_pio_led_strip/common.yaml @@ -1,6 +1,6 @@ light: - platform: rp2040_pio_led_strip - id: rp2040_pio_led_strip_led_strip + id: led_strip pin: 4 num_leds: 60 pio: 0 diff --git a/tests/components/rp2040_pwm/common.yaml b/tests/components/rp2040_pwm/common.yaml index 2970a48afb..45c039106f 100644 --- a/tests/components/rp2040_pwm/common.yaml +++ b/tests/components/rp2040_pwm/common.yaml @@ -1,7 +1,7 @@ output: - platform: rp2040_pwm - id: rp2040_pwm_light_output_1 + id: light_output_1 pin: 2 - platform: rp2040_pwm - id: rp2040_pwm_light_output_2 + id: light_output_2 pin: 3 diff --git a/tests/components/sdl/common.yaml b/tests/components/sdl/common.yaml index 3be86cf8be..d3d3c9ee5e 100644 --- a/tests/components/sdl/common.yaml +++ b/tests/components/sdl/common.yaml @@ -3,7 +3,7 @@ host: display: - platform: sdl - id: sdl_sdl_display + id: sdl_display update_interval: 1s auto_clear_enabled: false show_test_card: true @@ -35,14 +35,14 @@ display: binary_sensor: - platform: sdl - sdl_id: sdl_sdl_display + sdl_id: sdl_display id: key_up key: SDLK_UP - platform: sdl - sdl_id: sdl_sdl_display + sdl_id: sdl_display id: key_down key: SDLK_DOWN - platform: sdl - sdl_id: sdl_sdl_display + sdl_id: sdl_display id: key_enter key: SDLK_RETURN diff --git a/tests/components/speaker/common.yaml b/tests/components/speaker/common.yaml index 96f459c53f..895f4b4b8f 100644 --- a/tests/components/speaker/common.yaml +++ b/tests/components/speaker/common.yaml @@ -1,7 +1,7 @@ number: - platform: template name: "Speaker Number" - id: speaker_my_number + id: my_number optimistic: true min_value: 0 max_value: 100 @@ -46,7 +46,7 @@ button: - speaker.play: id: speaker_id data: !lambda |- - return {0x01, 0x02, (uint8_t)id(speaker_my_number).state}; + return {0x01, 0x02, (uint8_t)id(my_number).state}; speaker: - platform: i2s_audio diff --git a/tests/components/speed/common.yaml b/tests/components/speed/common.yaml index 70c91259ba..be8172af7e 100644 --- a/tests/components/speed/common.yaml +++ b/tests/components/speed/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${output_platform} - id: speed_fan_output_1 + id: fan_output_1 pin: ${pin} fan: - platform: speed - id: speed_fan_speed - output: speed_fan_output_1 + id: fan_speed + output: fan_output_1 diff --git a/tests/components/sprinkler/common.yaml b/tests/components/sprinkler/common.yaml index dbe109f524..f099f77729 100644 --- a/tests/components/sprinkler/common.yaml +++ b/tests/components/sprinkler/common.yaml @@ -34,7 +34,7 @@ esphome: switch: - platform: template - id: sprinkler_switch1 + id: switch1 optimistic: true - platform: template id: switch2 @@ -52,17 +52,17 @@ sprinkler: valves: - valve_switch: Yard Valve 0 enable_switch: Enable Yard Valve 0 - pump_switch_id: sprinkler_switch1 + pump_switch_id: switch1 run_duration: 10s valve_switch_id: switch2 - valve_switch: Yard Valve 1 enable_switch: Enable Yard Valve 1 - pump_switch_id: sprinkler_switch1 + pump_switch_id: switch1 run_duration: 10s valve_switch_id: switch2 - valve_switch: Yard Valve 2 enable_switch: Enable Yard Valve 2 - pump_switch_id: sprinkler_switch1 + pump_switch_id: switch1 run_duration: 10s valve_switch_id: switch2 - id: garden_sprinkler_ctrlr @@ -73,11 +73,11 @@ sprinkler: valves: - valve_switch: Garden Valve 0 enable_switch: Enable Garden Valve 0 - pump_switch_id: sprinkler_switch1 + pump_switch_id: switch1 run_duration: 10s valve_switch_id: switch2 - valve_switch: Garden Valve 1 enable_switch: Enable Garden Valve 1 - pump_switch_id: sprinkler_switch1 + pump_switch_id: switch1 run_duration: 10s valve_switch_id: switch2 diff --git a/tests/components/ssd1306_i2c/common.yaml b/tests/components/ssd1306_i2c/common.yaml index b3b8ad85dc..09eb569a8e 100644 --- a/tests/components/ssd1306_i2c/common.yaml +++ b/tests/components/ssd1306_i2c/common.yaml @@ -4,7 +4,7 @@ display: model: SSD1306_128X64 reset_pin: ${reset_pin} address: 0x3C - id: ssd1306_i2c_ssd1306_i2c_display + id: ssd1306_i2c_display contrast: 60% pages: - id: ssd1306_i2c_page1 diff --git a/tests/components/switch/common.yaml b/tests/components/switch/common.yaml index 3ea235cfb9..afdf26c150 100644 --- a/tests/components/switch/common.yaml +++ b/tests/components/switch/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: switch - id: switch_some_binary_sensor + id: some_binary_sensor name: "Template Switch State" source_id: the_switch diff --git a/tests/components/sx126x/common.yaml b/tests/components/sx126x/common.yaml index a4a24d8da7..659550cc01 100644 --- a/tests/components/sx126x/common.yaml +++ b/tests/components/sx126x/common.yaml @@ -29,7 +29,7 @@ sx126x: number: - platform: template name: "SX126x Number" - id: sx126x_my_number + id: my_number optimistic: true min_value: 0 max_value: 100 @@ -47,4 +47,4 @@ button: - sx126x.send_packet: data: [0xC5, 0x51, 0x78, 0x82, 0xB7, 0xF9, 0x9C, 0x5C] - sx126x.send_packet: !lambda |- - return {0x01, 0x02, (uint8_t)id(sx126x_my_number).state}; + return {0x01, 0x02, (uint8_t)id(my_number).state}; diff --git a/tests/components/sx127x/common.yaml b/tests/components/sx127x/common.yaml index b7eadc084f..6e48952fcc 100644 --- a/tests/components/sx127x/common.yaml +++ b/tests/components/sx127x/common.yaml @@ -29,7 +29,7 @@ sx127x: number: - platform: template name: "SX127x Number" - id: sx127x_my_number + id: my_number optimistic: true min_value: 0 max_value: 100 @@ -48,4 +48,4 @@ button: - sx127x.send_packet: data: [0xC5, 0x51, 0x78, 0x82, 0xB7, 0xF9, 0x9C, 0x5C] - sx127x.send_packet: !lambda |- - return {0x01, 0x02, (uint8_t)id(sx127x_my_number).state}; + return {0x01, 0x02, (uint8_t)id(my_number).state}; diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index f1387a7afe..d3985a848b 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -52,7 +52,7 @@ esphome: binary_sensor: - platform: template - id: template_some_binary_sensor + id: some_binary_sensor name: "Garage Door Open" lambda: |- if (id(template_sens).state > 30) { @@ -108,7 +108,7 @@ sensor: name: "Template Sensor" id: template_sens lambda: |- - if (id(template_some_binary_sensor).state) { + if (id(some_binary_sensor).state) { return 42.0; } return 0.0; @@ -230,7 +230,7 @@ switch: id: test_switch name: "Template Switch" lambda: |- - if (id(template_some_binary_sensor).state) { + if (id(some_binary_sensor).state) { return true; } return false; @@ -249,7 +249,7 @@ cover: - platform: template name: "Template Cover" lambda: |- - if (id(template_some_binary_sensor).state) { + if (id(some_binary_sensor).state) { return COVER_OPEN; } return COVER_CLOSED; @@ -264,7 +264,7 @@ cover: name: "Template Cover with Triggers" id: template_cover_with_triggers lambda: |- - if (id(template_some_binary_sensor).state) { + if (id(some_binary_sensor).state) { return COVER_OPEN; } return COVER_CLOSED; @@ -442,7 +442,7 @@ lock: - platform: template name: "Template Lock" lambda: |- - if (id(template_some_binary_sensor).state) { + if (id(some_binary_sensor).state) { return LOCK_STATE_LOCKED; } return LOCK_STATE_UNLOCKED; @@ -458,7 +458,7 @@ valve: id: template_valve name: "Template Valve" lambda: |- - if (id(template_some_binary_sensor).state) { + if (id(some_binary_sensor).state) { return VALVE_OPEN; } return VALVE_CLOSED; diff --git a/tests/components/tt21100/common.yaml b/tests/components/tt21100/common.yaml index 1f9249f1ba..56089aed1e 100644 --- a/tests/components/tt21100/common.yaml +++ b/tests/components/tt21100/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: tt21100_ssd1306_i2c_display + id: ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${disp_reset_pin} pages: @@ -13,7 +13,7 @@ touchscreen: - platform: tt21100 i2c_id: i2c_bus id: tt21100_touchscreen - display: tt21100_ssd1306_i2c_display + display: ssd1306_i2c_display interrupt_pin: ${interrupt_pin} reset_pin: ${reset_pin} diff --git a/tests/components/uart/test.esp32-idf.yaml b/tests/components/uart/test.esp32-idf.yaml index c805188005..fa76316b9c 100644 --- a/tests/components/uart/test.esp32-idf.yaml +++ b/tests/components/uart/test.esp32-idf.yaml @@ -79,7 +79,7 @@ switch: number: - platform: template name: "Test Number" - id: uart_test_number + id: test_number optimistic: true min_value: 0 max_value: 100 @@ -103,7 +103,7 @@ button: - uart.write: id: uart_id data: !lambda |- - std::string cmd = "VALUE=" + str_sprintf("%.0f", id(uart_test_number).state) + "\r\n"; + std::string cmd = "VALUE=" + str_sprintf("%.0f", id(test_number).state) + "\r\n"; return std::vector(cmd.begin(), cmd.end()); event: diff --git a/tests/components/udp/common.yaml b/tests/components/udp/common.yaml index 6824c5cca8..a40ca455cb 100644 --- a/tests/components/udp/common.yaml +++ b/tests/components/udp/common.yaml @@ -24,7 +24,7 @@ udp: number: - platform: template name: "UDP Number" - id: udp_my_number + id: my_number optimistic: true min_value: 0 max_value: 100 @@ -38,4 +38,4 @@ button: - udp.write: data: [0x01, 0x02, 0x03] - udp.write: !lambda |- - return {0x10, 0x20, (uint8_t)id(udp_my_number).state}; + return {0x10, 0x20, (uint8_t)id(my_number).state}; diff --git a/tests/components/ufire_ec/common.yaml b/tests/components/ufire_ec/common.yaml index 2365b7a368..4260f0ab4c 100644 --- a/tests/components/ufire_ec/common.yaml +++ b/tests/components/ufire_ec/common.yaml @@ -4,18 +4,18 @@ esphome: - ufire_ec.calibrate_probe: id: ufire_ec_board solution: 0.146 - temperature: !lambda "return id(ufire_ec_test_sensor).state;" + temperature: !lambda "return id(test_sensor).state;" - ufire_ec.reset: sensor: - platform: template - id: ufire_ec_test_sensor + id: test_sensor lambda: "return 21;" - platform: ufire_ec i2c_id: i2c_bus id: ufire_ec_board ec: name: Ufire EC - temperature_sensor: ufire_ec_test_sensor + temperature_sensor: test_sensor temperature_compensation: 20.0 temperature_coefficient: 0.019 diff --git a/tests/components/ufire_ise/common.yaml b/tests/components/ufire_ise/common.yaml index 478c75ad37..f7865ea87b 100644 --- a/tests/components/ufire_ise/common.yaml +++ b/tests/components/ufire_ise/common.yaml @@ -11,11 +11,11 @@ esphome: sensor: - platform: template - id: ufire_ise_test_sensor + id: test_sensor lambda: "return 21;" - platform: ufire_ise i2c_id: i2c_bus id: ufire_ise_sensor - temperature_sensor: ufire_ise_test_sensor + temperature_sensor: test_sensor ph: name: Ufire pH diff --git a/tests/components/web_server_idf/common.yaml b/tests/components/web_server_idf/common.yaml index cfba0060d9..b1885af266 100644 --- a/tests/components/web_server_idf/common.yaml +++ b/tests/components/web_server_idf/common.yaml @@ -12,7 +12,7 @@ network: sensor: - platform: template name: "Test Sensor" - id: web_server_idf_test_sensor + id: test_sensor update_interval: 60s lambda: "return 42.5;" @@ -25,5 +25,5 @@ binary_sensor: switch: - platform: template name: "Test Switch" - id: web_server_idf_test_switch + id: test_switch optimistic: true diff --git a/tests/components/wk2132_i2c/common.yaml b/tests/components/wk2132_i2c/common.yaml index 93bb17b38f..39013baeb2 100644 --- a/tests/components/wk2132_i2c/common.yaml +++ b/tests/components/wk2132_i2c/common.yaml @@ -16,4 +16,4 @@ wk2132_i2c: sensor: - platform: a02yyuw uart_id: wk2132_id_1 - id: wk2132_i2c_distance_sensor + id: distance_sensor diff --git a/tests/components/wk2132_spi/common.yaml b/tests/components/wk2132_spi/common.yaml index 5ff48bc64c..18294974b9 100644 --- a/tests/components/wk2132_spi/common.yaml +++ b/tests/components/wk2132_spi/common.yaml @@ -17,4 +17,4 @@ wk2132_spi: sensor: - platform: a02yyuw uart_id: wk2132_spi_uart1 - id: wk2132_spi_distance_sensor + id: distance_sensor diff --git a/tests/components/wk2168_i2c/common.yaml b/tests/components/wk2168_i2c/common.yaml index 1b2de74c02..49f0d1ec6b 100644 --- a/tests/components/wk2168_i2c/common.yaml +++ b/tests/components/wk2168_i2c/common.yaml @@ -23,7 +23,7 @@ wk2168_i2c: sensor: - platform: a02yyuw uart_id: wk2168_i2c_uart3 - id: wk2168_i2c_distance_sensor + id: distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/components/wk2168_spi/common.yaml b/tests/components/wk2168_spi/common.yaml index a21a4a34d0..b402077aa3 100644 --- a/tests/components/wk2168_spi/common.yaml +++ b/tests/components/wk2168_spi/common.yaml @@ -23,7 +23,7 @@ wk2168_spi: sensor: - platform: a02yyuw uart_id: wk2168_spi_uart3 - id: wk2168_spi_distance_sensor + id: distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/components/wk2204_i2c/common.yaml b/tests/components/wk2204_i2c/common.yaml index 55c67efd88..863633937b 100644 --- a/tests/components/wk2204_i2c/common.yaml +++ b/tests/components/wk2204_i2c/common.yaml @@ -24,4 +24,4 @@ wk2204_i2c: sensor: - platform: a02yyuw uart_id: wk2204_id_3 - id: wk2204_i2c_distance_sensor + id: distance_sensor diff --git a/tests/components/wk2204_spi/common.yaml b/tests/components/wk2204_spi/common.yaml index ee00da22bb..0b62a7a009 100644 --- a/tests/components/wk2204_spi/common.yaml +++ b/tests/components/wk2204_spi/common.yaml @@ -25,4 +25,4 @@ wk2204_spi: sensor: - platform: a02yyuw uart_id: wk2204_spi_uart3 - id: wk2204_spi_distance_sensor + id: distance_sensor diff --git a/tests/components/wk2212_i2c/common.yaml b/tests/components/wk2212_i2c/common.yaml index d48063bb4d..a754bec5c7 100644 --- a/tests/components/wk2212_i2c/common.yaml +++ b/tests/components/wk2212_i2c/common.yaml @@ -19,7 +19,7 @@ wk2212_i2c: sensor: - platform: a02yyuw uart_id: uart_i2c_id1 - id: wk2212_i2c_distance_sensor + id: distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/components/wk2212_spi/common.yaml b/tests/components/wk2212_spi/common.yaml index d17db2f676..969f16bb12 100644 --- a/tests/components/wk2212_spi/common.yaml +++ b/tests/components/wk2212_spi/common.yaml @@ -17,7 +17,7 @@ wk2212_spi: sensor: - platform: a02yyuw uart_id: wk2212_spi_uart1 - id: wk2212_spi_distance_sensor + id: distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/script/test_merge_component_configs.py b/tests/script/test_merge_component_configs.py deleted file mode 100644 index 9286380de1..0000000000 --- a/tests/script/test_merge_component_configs.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Unit tests for script/merge_component_configs.py deduplication.""" - -from pathlib import Path -import sys - -import pytest - -# Add the script directory to Python path so we can import the module -sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve())) - -import merge_component_configs # noqa: E402 - -deduplicate_by_id = merge_component_configs.deduplicate_by_id - - -def test_identical_duplicate_ids_collapse() -> None: - """Two identical items sharing an id collapse to one without error.""" - data = { - "sensor": [ - {"id": "shared", "platform": "template", "name": "A"}, - {"id": "shared", "platform": "template", "name": "A"}, - ] - } - result = deduplicate_by_id(data) - assert result["sensor"] == [{"id": "shared", "platform": "template", "name": "A"}] - - -def test_conflicting_duplicate_ids_raise() -> None: - """Two different items sharing an id is a hard error naming the id.""" - data = { - "sensor": [ - {"id": "dup", "platform": "template", "name": "A"}, - {"id": "dup", "platform": "template", "name": "B"}, - ] - } - with pytest.raises(ValueError, match="dup"): - deduplicate_by_id(data) - - -def test_intentionally_shared_id_does_not_raise() -> None: - """Allowlisted singleton ids may differ across components and collapse.""" - shared = next(iter(merge_component_configs.INTENTIONALLY_SHARED_IDS)) - data = { - "time": [ - {"id": shared, "platform": "sntp"}, - {"id": shared, "platform": "sntp", "servers": ["a"]}, - ] - } - result = deduplicate_by_id(data) - # First occurrence wins, no error raised - assert result["time"] == [{"id": shared, "platform": "sntp"}] - - -def test_items_without_id_are_preserved() -> None: - """Items lacking an id are passed through untouched.""" - data = {"binary_sensor": [{"platform": "gpio"}, {"platform": "gpio"}]} - result = deduplicate_by_id(data) - assert result["binary_sensor"] == [{"platform": "gpio"}, {"platform": "gpio"}] - - -def test_nested_lists_are_checked() -> None: - """Conflicts nested inside dict values are also detected.""" - data = { - "wrapper": { - "sensor": [ - {"id": "dup", "value": 1}, - {"id": "dup", "value": 2}, - ] - } - } - with pytest.raises(ValueError, match="dup"): - deduplicate_by_id(data) From 8aa4157574e6c7dcbbe797e1f51e3f29018f9ed0 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 21:01:29 -0400 Subject: [PATCH 0346/1815] [fastled_base] Use FastLED IDF component on ESP32 (#16804) --- .clang-tidy.hash | 2 +- esphome/components/fastled_base/__init__.py | 12 ++++-- .../components/fastled_base/fastled_light.h | 2 - esphome/espidf/clang_tidy.py | 6 +++ esphome/idf_component.yml | 5 +++ platformio.ini | 3 +- script/clang-tidy | 12 +++++- tests/unit_tests/test_espidf_clang_tidy.py | 39 +++++++++++++++++++ 8 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 tests/unit_tests/test_espidf_clang_tidy.py diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 0782b065f3..3bcf356f86 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -0b8325f52fca9224efb80dacca51ccbc8b3499bde7bb4aaa6f28a848c2e0a6a8 +d583091c0f465aed86a825138e309af6d9db6834106ab424f36712424a6c2223 diff --git a/esphome/components/fastled_base/__init__.py b/esphome/components/fastled_base/__init__.py index c944e8a930..d99dffdc08 100644 --- a/esphome/components/fastled_base/__init__.py +++ b/esphome/components/fastled_base/__init__.py @@ -41,10 +41,16 @@ async def new_fastled_light(config): if CONF_MAX_REFRESH_RATE in config: cg.add(var.set_max_refresh_rate(config[CONF_MAX_REFRESH_RATE])) - cg.add_library("fastled/FastLED", "3.9.16") if CORE.is_esp32: - from esphome.components.esp32 import include_builtin_idf_component + from esphome.components.esp32 import add_idf_component - include_builtin_idf_component("esp_lcd") + add_idf_component( + name="fastled/FastLED", + repo="https://github.com/FastLED/FastLED.git", + ref="d44c800a9e876a8394caefc2ce4915dd96dac77b", + ) + cg.add_library("SPI", None) + else: + cg.add_library("fastled/FastLED", "3.9.16") await light.register_light(var, config) return var diff --git a/esphome/components/fastled_base/fastled_light.h b/esphome/components/fastled_base/fastled_light.h index 8e87f67e6d..f8535eb628 100644 --- a/esphome/components/fastled_base/fastled_light.h +++ b/esphome/components/fastled_base/fastled_light.h @@ -143,7 +143,6 @@ class FastLEDLightOutput : public light::AddressableLight { } } -#ifdef FASTLED_HAS_CLOCKLESS template class CHIPSET, uint8_t DATA_PIN, EOrder RGB_ORDER> CLEDController &add_leds(int num_leds) { static CHIPSET controller; @@ -160,7 +159,6 @@ class FastLEDLightOutput : public light::AddressableLight { static CHIPSET controller; return add_leds(&controller, num_leds); } -#endif template class CHIPSET, EOrder RGB_ORDER> CLEDController &add_leds(int num_leds) { static CHIPSET controller; diff --git a/esphome/espidf/clang_tidy.py b/esphome/espidf/clang_tidy.py index 2cfbe67a70..7647db63f5 100644 --- a/esphome/espidf/clang_tidy.py +++ b/esphome/espidf/clang_tidy.py @@ -160,6 +160,12 @@ def _setup_core(work_dir: Path, settings: _Settings) -> None: CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = "esp32" CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = settings.target_framework + # Gates arduino-only components in esphome/idf_component.yml (IDF reads it at + # reconfigure time). Set here -- before the manifest is written/reconfigured. + os.environ["ESPHOME_ARDUINO"] = ( + "1" if settings.target_framework == "arduino" else "0" + ) + # Special IDF "components" that are tools/subprojects, not requirable by an app # (they provide no public includes and break requirement resolution), plus our diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 3a5b050072..4a4bc18579 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -103,3 +103,8 @@ dependencies: version: 0.6.1 lvgl/lvgl: version: 9.5.0 + fastled/FastLED: + git: https://github.com/FastLED/FastLED.git + version: d44c800a9e876a8394caefc2ce4915dd96dac77b + rules: + - if: "$ESPHOME_ARDUINO == 1" diff --git a/platformio.ini b/platformio.ini index 07e9b8aad3..d7bcc49758 100644 --- a/platformio.ini +++ b/platformio.ini @@ -79,7 +79,6 @@ lib_deps = SPI ; spi (Arduino built-in) Wire ; i2c (Arduino built-int) heman/AsyncMqttClient-esphome@1.0.0 ; mqtt - fastled/FastLED@3.9.16 ; fastled_base freekode/TM1651@1.0.1 ; tm1651 dudanov/MideaUART@1.1.9 ; midea tonia/HeatpumpIR@1.0.41 ; heatpumpir @@ -108,6 +107,7 @@ platform_packages = framework = arduino lib_deps = ${common:arduino.lib_deps} + fastled/FastLED@3.9.16 ; fastled_base bblanchon/ArduinoJson@7.4.2 ; json ESP8266WiFi ; wifi (Arduino built-in) Update ; ota (Arduino built-in) @@ -198,6 +198,7 @@ platform_packages = framework = arduino lib_deps = ${common:arduino.lib_deps} + fastled/FastLED@3.9.16 ; fastled_base ayushsharma82/RPAsyncTCP@1.3.2 ; async_tcp bblanchon/ArduinoJson@7.4.2 ; json ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base diff --git a/script/clang-tidy b/script/clang-tidy index 633b8d4b7d..f19bdb9b56 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -35,9 +35,19 @@ def clang_options(idedata): # extract target architecture from triplet in g++ filename triplet = Path(idedata["cxx_path"]).name[:-4] if triplet.startswith("xtensa-"): - # clang doesn't support Xtensa (yet?), so compile in 32-bit mode and pretend we're the Xtensa compiler + # clang has an Xtensa frontend, but only a generic core -- the esp32 IDF + # toolchain headers (xtruntime, xtensa/config) need the GCC core config + # (XCHAL_*) it doesn't ship, so we still compile in 32-bit x86 mode and + # just pretend to be Xtensa. Undefine the host x86 arch macros -m32 sets, + # so libraries with x86 SIMD paths (FastLED's fl/math/simd, simd_x86.hpp) + # fall back to their scalar implementation instead of an incomplete + # host-x86 one, and define the xtensa endianness macro newlib's + # machine/ieeefp.h then needs in their place. cmd.append("-m32") + cmd.append("-U__i386__") + cmd.append("-U__x86_64__") cmd.append("-D__XTENSA__") + cmd.append("-D__XTENSA_EL__") cmd.append("-D_LIBC") else: # RISC-V (and other non-Xtensa targets) have a real clang backend, so diff --git a/tests/unit_tests/test_espidf_clang_tidy.py b/tests/unit_tests/test_espidf_clang_tidy.py new file mode 100644 index 0000000000..7a71dc26f4 --- /dev/null +++ b/tests/unit_tests/test_espidf_clang_tidy.py @@ -0,0 +1,39 @@ +"""Tests for esphome.espidf.clang_tidy tidy-project setup.""" + +import os +from pathlib import Path + +import pytest + +from esphome.espidf.clang_tidy import _Settings, _setup_core + + +def _settings(target_framework: str) -> _Settings: + return _Settings( + idf_target="esp32", + variant="ESP32", + idf_version="5.5.4", + target_framework=target_framework, + platform_defines=("USE_ESP32",), + framework_deps={}, + ) + + +@pytest.mark.parametrize( + ("target_framework", "expected"), + [("arduino", "1"), ("espidf", "0")], +) +def test_setup_core_sets_arduino_env( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + target_framework: str, + expected: str, +) -> None: + """_setup_core sets ESPHOME_ARDUINO, which gates arduino-only manifest deps.""" + # monkeypatch snapshots os.environ, so the env var _setup_core writes is + # restored after the test instead of leaking into later tests. + monkeypatch.delenv("ESPHOME_ARDUINO", raising=False) + + _setup_core(tmp_path / "proj", _settings(target_framework)) + + assert os.environ["ESPHOME_ARDUINO"] == expected From 6996b7ed1c2d6aa6abf639b705af0af928a9361c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:03:08 -0400 Subject: [PATCH 0347/1815] [ci] Add ESP32 Variants clang-tidy run (S3/P4/C6) (#16825) --- .clang-tidy.hash | 2 +- .github/workflows/ci.yml | 88 ++++++++++++++++++++++ .gitignore | 1 + esphome/core/defines.h | 2 + esphome/espidf/clang_tidy.py | 11 ++- platformio.ini | 11 +++ script/clang_tidy_hash.py | 11 +-- sdkconfig.defaults | 5 -- sdkconfig.defaults.esp32c6 | 14 ++++ sdkconfig.defaults.esp32p4 | 31 ++++++++ sdkconfig.defaults.esp32s3 | 12 +++ tests/script/test_clang_tidy_hash.py | 24 ++++++ tests/unit_tests/test_espidf_clang_tidy.py | 41 ++++++++-- 13 files changed, 233 insertions(+), 20 deletions(-) create mode 100644 sdkconfig.defaults.esp32c6 create mode 100644 sdkconfig.defaults.esp32p4 create mode 100644 sdkconfig.defaults.esp32s3 diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 3bcf356f86..e89b4230ad 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -d583091c0f465aed86a825138e309af6d9db6834106ab424f36712424a6c2223 +d9c755e5f019b2ecb324834717bc1fb8563e622f5751794cb7156d324884481e diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40267240d8..a0d604f248 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -722,6 +722,93 @@ jobs: run: script/ci-suggest-changes if: always() + clang-tidy-esp32-variants: + name: ${{ matrix.name }} + runs-on: ubuntu-24.04 + needs: + - common + - determine-jobs + if: needs.determine-jobs.outputs.clang-tidy == 'true' + env: + GH_TOKEN: ${{ github.token }} + # The variant tidy envs install ESP-IDF natively; share the native IDF cache. + ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf + strategy: + fail-fast: false + max-parallel: 3 + matrix: + include: + - id: clang-tidy + name: Run script/clang-tidy for ESP32 S3 + options: --environment esp32s3-idf-tidy --grep USE_ESP32_VARIANT_ESP32S3 + - id: clang-tidy + name: Run script/clang-tidy for ESP32 P4 + # P4 has no native Wi-Fi/BLE; those run over the hosted co-processor, + # so their code paths differ -- lint them under the P4 build too. + # yamllint disable-line rule:line-length + options: --environment esp32p4-idf-tidy --grep USE_ESP32_VARIANT_ESP32P4 --grep USE_ESP32_HOSTED --grep USE_WIFI --grep USE_BLE + - id: clang-tidy + name: Run script/clang-tidy for ESP32 C6 + # yamllint disable-line rule:line-length + options: --environment esp32c6-idf-tidy --grep USE_ESP32_VARIANT_ESP32C6 --grep USE_OPENTHREAD --grep USE_ZIGBEE + + steps: + - name: Check out code from GitHub + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + # Need history for HEAD~1 to work for checking changed files + fetch-depth: 2 + + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + + - name: Cache ESP-IDF install + # Shared with the IDF/Arduino clang-tidy jobs + native-IDF build (same install). + uses: ./.github/actions/cache-esp-idf + + - name: Register problem matchers + run: | + echo "::add-matcher::.github/workflows/matchers/gcc.json" + echo "::add-matcher::.github/workflows/matchers/clang-tidy.json" + + - name: Check if full clang-tidy scan needed + id: check_full_scan + run: | + . venv/bin/activate + # determine-jobs.clang-tidy-full-scan is true when core C++ changed + # OR the ci-run-all label forced --force-all. Independent of the + # hash check, both must produce a full scan in the job itself. + if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then + echo "full_scan=true" >> $GITHUB_OUTPUT + echo "reason=determine_jobs" >> $GITHUB_OUTPUT + elif python script/clang_tidy_hash.py --check; then + echo "full_scan=true" >> $GITHUB_OUTPUT + echo "reason=hash_changed" >> $GITHUB_OUTPUT + else + echo "full_scan=false" >> $GITHUB_OUTPUT + echo "reason=normal" >> $GITHUB_OUTPUT + fi + + - name: Run clang-tidy + # Limited variant scan: only the files carrying that variant's code paths + # (no --all-headers; the comprehensive esp32-idf pass covers the shared tree). + run: | + . venv/bin/activate + if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then + echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})" + script/clang-tidy --fix ${{ matrix.options }} + else + echo "Running clang-tidy on changed files only" + script/clang-tidy --fix --changed ${{ matrix.options }} + fi + + - name: Suggested changes + run: script/ci-suggest-changes + if: always() + test-build-components-split: name: Test components batch (${{ matrix.components }}) runs-on: ubuntu-24.04 @@ -1273,6 +1360,7 @@ jobs: - clang-tidy-single - clang-tidy-nosplit - clang-tidy-split + - clang-tidy-esp32-variants - determine-jobs - device-builder - test-build-components-split diff --git a/.gitignore b/.gitignore index 4a4a88fd48..de3e4fa68e 100644 --- a/.gitignore +++ b/.gitignore @@ -141,6 +141,7 @@ tests/.esphome/ sdkconfig.* !sdkconfig.defaults +!sdkconfig.defaults.* .tests/ diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 6c840f56ee..410858f904 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -64,6 +64,7 @@ #define USE_ESP32_BLE_PSRAM #define USE_ESP32_CAMERA_JPEG_CONVERSION #define USE_ESP32_HOSTED +#define USE_ESP32_HOSTED_HTTP_UPDATE #define USE_ESP32_IMPROV_STATE_CALLBACK #define USE_EVENT #define USE_FAN @@ -312,6 +313,7 @@ #define ESPHOME_WIFI_POWER_SAVE_LISTENERS 2 #define USE_WIFI_RUNTIME_POWER_SAVE #define USB_HOST_MAX_REQUESTS 16 +#define USB_HOST_MAX_PACKET_SIZE 64 #define USB_UART_OUTPUT_CHUNK_COUNT 5 #ifdef USE_ARDUINO diff --git a/esphome/espidf/clang_tidy.py b/esphome/espidf/clang_tidy.py index 7647db63f5..62d6f0d00d 100644 --- a/esphome/espidf/clang_tidy.py +++ b/esphome/espidf/clang_tidy.py @@ -339,11 +339,18 @@ def _write_tidy_project( # ESPHome's static-analysis sdkconfig (repo root): enables the flags any # component sets (e.g. CONFIG_BT_ENABLED) so sdkconfig-gated IDF components # register and expose their includes. IDF reads ``sdkconfig.defaults`` from - # the project root. + # the project root, plus a per-target ``sdkconfig.defaults.`` + # for variant-only components (e.g. openthread on c6/h2). + repo_root = esphome_dir.parent (work_dir / "sdkconfig.defaults").write_text( - (esphome_dir.parent / "sdkconfig.defaults").read_text(encoding="utf-8"), + (repo_root / "sdkconfig.defaults").read_text(encoding="utf-8"), encoding="utf-8", ) + target_defaults = repo_root / f"sdkconfig.defaults.{settings.idf_target}" + if target_defaults.is_file(): + (work_dir / target_defaults.name).write_text( + target_defaults.read_text(encoding="utf-8"), encoding="utf-8" + ) def _generate_compile_commands( diff --git a/platformio.ini b/platformio.ini index d7bcc49758..d3fde193b4 100644 --- a/platformio.ini +++ b/platformio.ini @@ -392,6 +392,17 @@ build_flags = ${flags:runtime.build_flags} -DUSE_ESP32_VARIANT_ESP32P4 +[env:esp32p4-idf-tidy] +extends = common:esp32-idf +board = esp32-p4-evboard +board_build.esp-idf.sdkconfig_path = .temp/sdkconfig-esp32p4-idf-tidy +build_flags = + ${common:esp32-idf.build_flags} + ${flags:clangtidy.build_flags} + -DUSE_ESP32_VARIANT_ESP32P4 +build_unflags = + ${common.build_unflags} + ;;;;;;;; ESP32-S2 ;;;;;;;; [env:esp32s2-arduino] diff --git a/script/clang_tidy_hash.py b/script/clang_tidy_hash.py index 1a6e4eb7be..62f76246b4 100755 --- a/script/clang_tidy_hash.py +++ b/script/clang_tidy_hash.py @@ -99,11 +99,12 @@ def calculate_clang_tidy_hash(repo_root: Path | None = None) -> str: platformio_content = read_file_bytes(platformio_path) hasher.update(platformio_content) - # Hash sdkconfig.defaults file - sdkconfig_path = repo_root / "sdkconfig.defaults" - if sdkconfig_path.exists(): - sdkconfig_content = read_file_bytes(sdkconfig_path) - hasher.update(sdkconfig_content) + # Hash sdkconfig.defaults and any per-target sdkconfig.defaults.: + # the per-target files flip CONFIG flags that change which variant code + # paths clang-tidy sees. Include the filename so a rename is detected. + for sdkconfig_path in sorted(repo_root.glob("sdkconfig.defaults*")): + hasher.update(sdkconfig_path.name.encode()) + hasher.update(read_file_bytes(sdkconfig_path)) # Hash esphome/idf_component.yml: its managed deps drive the ESP-IDF # build's include set, which clang-tidy analyzes. diff --git a/sdkconfig.defaults b/sdkconfig.defaults index b277ed18d0..8d177a7e26 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -15,8 +15,3 @@ CONFIG_BT_ENABLED=y # esp32_camera CONFIG_SPIRAM=y - -# zigbee -CONFIG_ZB_ENABLED=y -CONFIG_ZB_ZED=y -CONFIG_ZB_RADIO_NATIVE=y diff --git a/sdkconfig.defaults.esp32c6 b/sdkconfig.defaults.esp32c6 new file mode 100644 index 0000000000..6dd5f4f329 --- /dev/null +++ b/sdkconfig.defaults.esp32c6 @@ -0,0 +1,14 @@ +# Per-target ESP-IDF sdkconfig defaults for esp32c6 static analysis (clang-tidy) only. +# Read by IDF in addition to sdkconfig.defaults. Enables variant-only components so +# their headers register for the tidy translation unit (these are normally set at +# codegen via add_idf_sdkconfig_option, which the stub tidy build skips). + +# openthread (only the C6/H2 variants have the 802.15.4 radio) +CONFIG_IEEE802154_ENABLED=y +CONFIG_OPENTHREAD_ENABLED=y +CONFIG_OPENTHREAD_RADIO_NATIVE=y + +# zigbee +CONFIG_ZB_ENABLED=y +CONFIG_ZB_ZED=y +CONFIG_ZB_RADIO_NATIVE=y diff --git a/sdkconfig.defaults.esp32p4 b/sdkconfig.defaults.esp32p4 new file mode 100644 index 0000000000..b49dcf0ef2 --- /dev/null +++ b/sdkconfig.defaults.esp32p4 @@ -0,0 +1,31 @@ +# Per-target ESP-IDF sdkconfig defaults for esp32p4 static analysis (clang-tidy) only. +# Read by IDF in addition to sdkconfig.defaults. Enables variant-only components so +# their headers register for the tidy translation unit (these are normally set at +# codegen via add_idf_sdkconfig_option, which the stub tidy build skips). + +# esp32_hosted (P4 has no native Wi-Fi; it drives a co-processor over SDIO/SPI). +# Mirrors a default SDIO 4-bit setup (slot 1, ESP32-C6 slave) so the esp_hosted +# code paths compile under static analysis. +CONFIG_SLAVE_IDF_TARGET_ESP32C6=y +CONFIG_ESP_HOSTED_SDIO_SLOT_1=y +CONFIG_ESP_HOSTED_SDIO_4_BIT_BUS=y +CONFIG_ESP_HOSTED_CUSTOM_SDIO_PINS=y +CONFIG_ESP_HOSTED_SDIO_CLOCK_FREQ_KHZ=40000 +CONFIG_ESP_HOSTED_SDIO_RESET_ACTIVE_HIGH=y +CONFIG_ESP_HOSTED_SDIO_GPIO_RESET_SLAVE=54 +CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_CLK_SLOT_1=18 +CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_CMD_SLOT_1=19 +CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D0_SLOT_1=14 +CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D1_4BIT_BUS_SLOT_1=15 +CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D2_4BIT_BUS_SLOT_1=16 +CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D3_4BIT_BUS_SLOT_1=17 + +# BLE runs over the hosted co-processor on P4 (no native BT controller), so +# esp32_ble_tracker must take the hosted bluedroid path instead of . +CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID=y + +# tinyusb CDC (usb_cdc_acm), same as esp32s3 +CONFIG_TINYUSB_CDC_ENABLED=y +CONFIG_TINYUSB_CDC_COUNT=1 +CONFIG_TINYUSB_CDC_RX_BUFSIZE=256 +CONFIG_TINYUSB_CDC_TX_BUFSIZE=256 diff --git a/sdkconfig.defaults.esp32s3 b/sdkconfig.defaults.esp32s3 new file mode 100644 index 0000000000..15b97eb1b4 --- /dev/null +++ b/sdkconfig.defaults.esp32s3 @@ -0,0 +1,12 @@ +# Per-target ESP-IDF sdkconfig defaults for esp32s3 static analysis (clang-tidy) only. +# Read by IDF in addition to sdkconfig.defaults. Enables variant-only components so +# their headers register for the tidy translation unit (these are normally set at +# codegen via add_idf_sdkconfig_option, which the stub tidy build skips). + +# tinyusb CDC (usb_cdc_acm) -- the esp_tinyusb managed component is already in +# esphome/idf_component.yml; these enable its CDC class so tud_cdc_* and the +# CONFIG_TINYUSB_CDC_* macros are declared. +CONFIG_TINYUSB_CDC_ENABLED=y +CONFIG_TINYUSB_CDC_COUNT=1 +CONFIG_TINYUSB_CDC_RX_BUFSIZE=256 +CONFIG_TINYUSB_CDC_TX_BUFSIZE=256 diff --git a/tests/script/test_clang_tidy_hash.py b/tests/script/test_clang_tidy_hash.py index e19e7886a2..194926a5df 100644 --- a/tests/script/test_clang_tidy_hash.py +++ b/tests/script/test_clang_tidy_hash.py @@ -63,6 +63,7 @@ def test_calculate_clang_tidy_hash_with_sdkconfig(tmp_path: Path) -> None: expected_hasher.update(clang_tidy_content) expected_hasher.update(requirements_version.encode()) expected_hasher.update(platformio_content) + expected_hasher.update(b"sdkconfig.defaults") expected_hasher.update(sdkconfig_content) expected_hash = expected_hasher.hexdigest() @@ -71,6 +72,29 @@ def test_calculate_clang_tidy_hash_with_sdkconfig(tmp_path: Path) -> None: assert result == expected_hash +def test_calculate_clang_tidy_hash_includes_per_target_sdkconfig( + tmp_path: Path, +) -> None: + """Per-target sdkconfig.defaults. files must be part of the hash.""" + (tmp_path / ".clang-tidy").write_bytes(b"Checks: '-*'\n") + (tmp_path / "platformio.ini").write_bytes(b"[env:esp32]\n") + (tmp_path / "requirements_dev.txt").write_text("clang-tidy==18.1.5\n") + (tmp_path / "sdkconfig.defaults").write_bytes(b"CONFIG_BASE=y\n") + + before = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) + + # Adding a per-target file must change the hash. + per_target = tmp_path / "sdkconfig.defaults.esp32c6" + per_target.write_bytes(b"CONFIG_OPENTHREAD_ENABLED=y\n") + after_add = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) + assert after_add != before + + # Editing the per-target file must change the hash again. + per_target.write_bytes(b"CONFIG_OPENTHREAD_ENABLED=n\n") + after_edit = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) + assert after_edit != after_add + + def test_calculate_clang_tidy_hash_without_sdkconfig(tmp_path: Path) -> None: """Test calculating hash without sdkconfig.defaults file.""" clang_tidy_content = b"Checks: '-*,readability-*'\n" diff --git a/tests/unit_tests/test_espidf_clang_tidy.py b/tests/unit_tests/test_espidf_clang_tidy.py index 7a71dc26f4..9791dfc543 100644 --- a/tests/unit_tests/test_espidf_clang_tidy.py +++ b/tests/unit_tests/test_espidf_clang_tidy.py @@ -1,24 +1,51 @@ -"""Tests for esphome.espidf.clang_tidy tidy-project setup.""" +"""Tests for esphome.espidf.clang_tidy tidy-project generation.""" import os from pathlib import Path import pytest -from esphome.espidf.clang_tidy import _Settings, _setup_core +from esphome.espidf.clang_tidy import _Settings, _setup_core, _write_tidy_project + +REPO_ROOT = Path(__file__).resolve().parents[2] -def _settings(target_framework: str) -> _Settings: +def _settings(idf_target: str = "esp32", target_framework: str = "espidf") -> _Settings: return _Settings( - idf_target="esp32", - variant="ESP32", + idf_target=idf_target, + variant=idf_target.upper(), idf_version="5.5.4", target_framework=target_framework, - platform_defines=("USE_ESP32",), + platform_defines=( + "USE_ESP32", + f"USE_ESP32_VARIANT_{idf_target.upper()}", + "USE_ESP_IDF", + ), framework_deps={}, ) +def test_write_tidy_project_copies_base_sdkconfig(tmp_path: Path) -> None: + """The shared sdkconfig.defaults is always copied; no per-target file for esp32.""" + _write_tidy_project(tmp_path, [], {}, _settings("esp32")) + + assert (tmp_path / "sdkconfig.defaults").is_file() + # esp32 has no sdkconfig.defaults.esp32, so nothing extra is copied. + assert not (tmp_path / "sdkconfig.defaults.esp32").exists() + + +def test_write_tidy_project_copies_per_target_sdkconfig(tmp_path: Path) -> None: + """A repo-root sdkconfig.defaults. is also copied into the build dir.""" + _write_tidy_project(tmp_path, [], {}, _settings("esp32c6")) + + target = tmp_path / "sdkconfig.defaults.esp32c6" + assert (tmp_path / "sdkconfig.defaults").is_file() + assert target.is_file() + assert target.read_text(encoding="utf-8") == ( + REPO_ROOT / "sdkconfig.defaults.esp32c6" + ).read_text(encoding="utf-8") + + @pytest.mark.parametrize( ("target_framework", "expected"), [("arduino", "1"), ("espidf", "0")], @@ -34,6 +61,6 @@ def test_setup_core_sets_arduino_env( # restored after the test instead of leaking into later tests. monkeypatch.delenv("ESPHOME_ARDUINO", raising=False) - _setup_core(tmp_path / "proj", _settings(target_framework)) + _setup_core(tmp_path / "proj", _settings(target_framework=target_framework)) assert os.environ["ESPHOME_ARDUINO"] == expected From 745db9f705818a5a1df2893176e98409254f4ee9 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 6 Jun 2026 18:10:08 +1000 Subject: [PATCH 0348/1815] [motion] Implement hub component for IMUs (#16226) Co-authored-by: J. Nick Koston Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/motion/__init__.py | 221 +++++ .../components/motion/motion_component.cpp | 194 ++++ esphome/components/motion/motion_component.h | 154 +++ esphome/components/motion/sensor.py | 128 +++ tests/component_tests/motion/__init__.py | 0 tests/component_tests/motion/test_motion.py | 899 ++++++++++++++++++ 7 files changed, 1597 insertions(+) create mode 100644 esphome/components/motion/__init__.py create mode 100644 esphome/components/motion/motion_component.cpp create mode 100644 esphome/components/motion/motion_component.h create mode 100644 esphome/components/motion/sensor.py create mode 100644 tests/component_tests/motion/__init__.py create mode 100644 tests/component_tests/motion/test_motion.py diff --git a/CODEOWNERS b/CODEOWNERS index 3c3e502058..abe33f9467 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -351,6 +351,7 @@ 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 +esphome/components/motion/* @esphome/core esphome/components/mpl3115a2/* @kbickar esphome/components/mpu6886/* @fabaff esphome/components/ms8607/* @e28eta diff --git a/esphome/components/motion/__init__.py b/esphome/components/motion/__init__.py new file mode 100644 index 0000000000..aea052fa2f --- /dev/null +++ b/esphome/components/motion/__init__.py @@ -0,0 +1,221 @@ +from collections.abc import Callable +import re + +from esphome import automation +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_ON_ERROR, CONF_ON_SUCCESS +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.helpers import fnv1_hash_object_id + +CODEOWNERS = ["@esphome/core"] + +DOMAIN = "motion" +IS_PLATFORM_COMPONENT = True + +# C++ namespace / class +motion_ns = cg.esphome_ns.namespace("motion") +MotionComponent = motion_ns.class_("MotionComponent", cg.PollingComponent) + +AXES = ["x", "y", "z"] + +CONF_AXIS_MAP = "axis_map" +CONF_MOTION_ID = "motion_id" +CONF_TRANSFORM_MATRIX = "transform_matrix" + +CalibrateLevelAction = motion_ns.class_("CalibrateLevelAction", automation.Action) +CalibrateHeadingAction = motion_ns.class_("CalibrateHeadingAction", automation.Action) +ClearCalibrationAction = motion_ns.class_("ClearCalibrationAction", automation.Action) + +KEY_ACCELEROMETER = "accelerometer" +KEY_GYROSCOPE = "gyroscope" + +SENSOR_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_MOTION_ID): cv.use_id(MotionComponent), + } +) + +_AXIS_REGEX = re.compile(r"^[+-]?[xyz]$", re.IGNORECASE) + + +def _axis_map(config: dict) -> dict: + errors = [] + for key, axis in config.items(): + if _AXIS_REGEX.fullmatch(axis) is None: + errors.append( + cv.Invalid( + "Each 'axis_map' config value must be one of 'x', 'y' or 'z' (optionally preceded by '+' or '-').", + path=[key], + ) + ) + values = {x.lower().removeprefix("-").removeprefix("+") for x in config.values()} + if values != set(AXES): + errors.append(cv.Invalid("Each axis may be mapped only once")) + if errors: + raise cv.MultipleInvalid(errors) + return config + + +def _axis_map_to_matrix(config: dict[str, str]) -> list[float]: + matrix = [] + for target_axis in AXES: + source_axis = config[target_axis].lower() + sign = -1.0 if source_axis.startswith("-") else 1.0 + source_axis = source_axis.removeprefix("+").removeprefix("-") + + row = [0.0, 0.0, 0.0] + row[AXES.index(source_axis)] = sign + matrix.extend(row) + + return matrix + + +def _transform_matrix(value): + """Accept a flat list of 9 floats or a 3x3 nested list.""" + if not isinstance(value, list) or len(value) == 0: + raise cv.Invalid("Expected a list of 9 numbers or a 3x3 nested list") + # Nested 3x3 + if isinstance(value[0], list): + if len(value) != 3: + raise cv.Invalid(f"3x3 matrix must have 3 rows, got {len(value)}") + flat = [] + for i, row in enumerate(value): + if not isinstance(row, list) or len(row) != 3: + raise cv.Invalid("Each row must be a list of 3 numbers", path=[i]) + flat.extend(cv.float_(v) for v in row) + return flat + # Flat list + if len(value) != 9: + raise cv.Invalid(f"Flat matrix must have exactly 9 values, got {len(value)}") + return [cv.float_(v) for v in value] + + +def _validate_matrix_options(config): + if CONF_AXIS_MAP in config and CONF_TRANSFORM_MATRIX in config: + raise cv.Invalid( + f"'{CONF_AXIS_MAP}' and '{CONF_TRANSFORM_MATRIX}' are mutually exclusive" + ) + return config + + +# Top-level CONFIG_SCHEMA +_CONFIG_SCHEMA = ( + cv.Schema( + { + cv.Optional(CONF_AXIS_MAP): cv.All( + {cv.Required(k): cv.string_strict for k in AXES}, + _axis_map, + ), + cv.Optional(CONF_TRANSFORM_MATRIX): _transform_matrix, + } + ) + .extend(cv.polling_component_schema("250ms")) + .add_extra(_validate_matrix_options) +) + + +def _add_data(has_accel: bool, has_gyro: bool) -> Callable[[dict], dict]: + + def validator(config): + config = config.copy() + config[KEY_ACCELEROMETER] = has_accel + config[KEY_GYROSCOPE] = has_gyro + return config + + return validator + + +def motion_schema(class_: MockObjClass, has_accel: bool, has_gyro: bool) -> cv.Schema: + return _CONFIG_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(class_), + } + ).add_extra(_add_data(has_accel, has_gyro)) + + +# Code generation +async def register_motion_component(var: MockObj, config) -> None: + await cg.register_component(var, config) + # Set preference key for NVS save/restore (based on component ID) + obj_id = config[CONF_ID].id + pref_hash = fnv1_hash_object_id(obj_id) + cg.add(var.set_calibration_key(pref_hash)) + if axis_map := config.get(CONF_AXIS_MAP): + cg.add(var.set_matrix(_axis_map_to_matrix(axis_map))) + elif transform_matrix := config.get(CONF_TRANSFORM_MATRIX): + cg.add(var.set_matrix(transform_matrix)) + + +async def new_motion_component(config: dict) -> MockObj: + var = cg.new_Pvariable(config[CONF_ID]) + await register_motion_component(var, config) + return var + + +# --- Actions --- + +CONF_SAVE = "save" + +CALIBRATE_ACTION_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(MotionComponent), + cv.Optional(CONF_SAVE, default=False): cv.boolean, + cv.Optional(CONF_ON_SUCCESS): automation.validate_automation(single=True), + cv.Optional(CONF_ON_ERROR): automation.validate_automation(single=True), + } +) + + +async def _build_calibrate_action(config, action_id, template_arg, args): + parent = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, parent) + if config.get(CONF_SAVE): + cg.add(var.set_save(True)) + if on_success := config.get(CONF_ON_SUCCESS): + await automation.build_automation(var.get_success_trigger(), [], on_success) + if on_error := config.get(CONF_ON_ERROR): + await automation.build_automation(var.get_error_trigger(), [], on_error) + return var + + +@automation.register_action( + "motion.calibrate_level", + CalibrateLevelAction, + CALIBRATE_ACTION_SCHEMA, + synchronous=True, +) +async def calibrate_level_to_code(config, action_id, template_arg, args): + return await _build_calibrate_action(config, action_id, template_arg, args) + + +@automation.register_action( + "motion.calibrate_heading", + CalibrateHeadingAction, + CALIBRATE_ACTION_SCHEMA, + synchronous=True, +) +async def calibrate_heading_to_code(config, action_id, template_arg, args): + return await _build_calibrate_action(config, action_id, template_arg, args) + + +CLEAR_ACTION_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(MotionComponent), + cv.Optional(CONF_SAVE, default=False): cv.boolean, + } +) + + +@automation.register_action( + "motion.clear_calibration", + ClearCalibrationAction, + CLEAR_ACTION_SCHEMA, + synchronous=True, +) +async def clear_calibration_to_code(config, action_id, template_arg, args): + parent = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, parent) + if config.get(CONF_SAVE): + cg.add(var.set_save(True)) + return var diff --git a/esphome/components/motion/motion_component.cpp b/esphome/components/motion/motion_component.cpp new file mode 100644 index 0000000000..8715c8385c --- /dev/null +++ b/esphome/components/motion/motion_component.cpp @@ -0,0 +1,194 @@ +#include "motion_component.h" +#include "esphome/core/log.h" + +namespace esphome::motion { + +static const char *const TAG = "motion"; + +static void log_matrix(const float m[9]) { + ESP_LOGCONFIG(TAG, " Calibration matrix:"); + ESP_LOGCONFIG(TAG, " - [%9.6f, %9.6f, %9.6f]", m[0], m[1], m[2]); + ESP_LOGCONFIG(TAG, " - [%9.6f, %9.6f, %9.6f]", m[3], m[4], m[5]); + ESP_LOGCONFIG(TAG, " - [%9.6f, %9.6f, %9.6f]", m[6], m[7], m[8]); +} + +// FNV-1a over the raw bytes of the matrix. Identical axis maps always yield +// bit-identical matrices, so this is a stable fingerprint of the build-time base. +static uint32_t hash_matrix(const float m[9]) { + const uint8_t *bytes = reinterpret_cast(m); + uint32_t hash = 2166136261UL; + for (size_t i = 0; i < sizeof(float) * 9; i++) { + hash ^= bytes[i]; + hash *= 16777619UL; + } + return hash; +} + +void MotionComponent::setup() { + // matrix_ currently holds the build-time base (set_matrix ran during codegen). + this->base_hash_ = hash_matrix(this->base_matrix_); + this->pref_ = global_preferences->make_preference(this->pref_key_); + CalibrationPref saved; + if (this->pref_.load(&saved) && saved.base_hash == this->base_hash_) { + memcpy(this->matrix_, saved.matrix, sizeof(this->matrix_)); + ESP_LOGI(TAG, "Restored calibration from NVS"); + } else { + ESP_LOGD(TAG, "No matching saved calibration; using build-time matrix"); + } + log_matrix(this->matrix_); +} +void MotionComponent::dump_config() { + LOG_UPDATE_INTERVAL(this); + log_matrix(this->matrix_); +} +bool MotionComponent::save_calibration() { + if (this->pref_key_ == 0) { + ESP_LOGW(TAG, "Cannot save calibration: no preference key set"); + return false; + } + CalibrationPref pref{this->base_hash_, {}}; + memcpy(pref.matrix, this->matrix_, sizeof(pref.matrix)); + if (this->pref_.save(&pref)) { + global_preferences->sync(); + ESP_LOGI(TAG, "Saved calibration to NVS"); + return true; + } + ESP_LOGW(TAG, "Calibration save failed"); + return false; +} +void MotionComponent::clear_calibration() { + memcpy(this->matrix_, this->base_matrix_, sizeof(this->matrix_)); + ESP_LOGI(TAG, "Calibration reset to build-time matrix"); + log_matrix(this->matrix_); +} +void MotionComponent::update() { + if (this->is_failed()) + return; + MotionData motion_data{}; + MotionData raw_data{}; + if (!this->update_data(raw_data)) + return; + this->map_axes_(motion_data.acceleration, raw_data.acceleration); + this->map_axes_(motion_data.angular_rate, raw_data.angular_rate); + this->motion_data_callback_.call(motion_data); + + ESP_LOGV(TAG, "Accel: [%.3f, %.3f, %.3f] g; Gyro: [%.3f, %.3f, %.3f] °/s", motion_data.acceleration[X_AXIS], + motion_data.acceleration[Y_AXIS], motion_data.acceleration[Z_AXIS], motion_data.angular_rate[X_AXIS], + motion_data.angular_rate[Y_AXIS], motion_data.angular_rate[Z_AXIS]); +} + +bool MotionComponent::calibrate_level() { + MotionData raw{}; + if (!this->update_data(raw)) { + ESP_LOGW(TAG, "calibrate_level: failed to read sensor data"); + return false; + } + + // Apply the current matrix first so any existing axis mapping is preserved. + float mapped[3]; + this->map_axes_(mapped, raw.acceleration); + + float nx = mapped[X_AXIS]; + float ny = mapped[Y_AXIS]; + float nz = mapped[Z_AXIS]; + float mag = std::sqrt(nx * nx + ny * ny + nz * nz); + if (mag < 0.1f) { + ESP_LOGW(TAG, "calibrate_level: acceleration magnitude too small (%.3f)", mag); + return false; + } + + // Normalize + nx /= mag; + ny /= mag; + nz /= mag; + + // Compute rotation matrix R such that R * [nx, ny, nz] = [0, 0, 1] + // using Rodrigues' rotation formula, then compose with the existing matrix. + if (nz > 0.99999f) { + // Already aligned with +Z — nothing to compose + ESP_LOGI(TAG, "Level calibration: already aligned"); + log_matrix(this->matrix_); + // returning true here will trigger on_success and a save to NVS, but the save will ultimately be a no-op + // since the backend sync will not write unchanged values. + return true; + } + + float r[9]; + if (nz < -0.9999f) { + // Aligned with -Z — 180° rotation about X + float m[9] = {1, 0, 0, 0, -1, 0, 0, 0, -1}; + memcpy(r, m, sizeof(r)); + } else { + float f = 1.0f / (1.0f + nz); + r[0] = 1.0f - nx * nx * f; + r[1] = -nx * ny * f; + r[2] = -nx; + r[3] = -nx * ny * f; + r[4] = 1.0f - ny * ny * f; + r[5] = -ny; + r[6] = nx; + r[7] = ny; + r[8] = nz; + } + + // Compose: new_matrix = R * old_matrix + float old[9]; + memcpy(old, this->matrix_, sizeof(old)); + for (int i = 0; i < 3; i++) { + for (int j = 0; j < 3; j++) { + this->matrix_[i * 3 + j] = r[i * 3 + 0] * old[j] + r[i * 3 + 1] * old[3 + j] + r[i * 3 + 2] * old[6 + j]; + } + } + + ESP_LOGI(TAG, "Level calibration applied (mapped accel: [%.3f, %.3f, %.3f])", mapped[X_AXIS], mapped[Y_AXIS], + mapped[Z_AXIS]); + log_matrix(this->matrix_); + return true; +} + +bool MotionComponent::calibrate_heading() { + MotionData raw{}; + if (!this->update_data(raw)) { + ESP_LOGW(TAG, "calibrate_heading: failed to read sensor data"); + return false; + } + + // Apply current matrix to get the mapped acceleration + float mapped[3]; + this->map_axes_(mapped, raw.acceleration); + + float mx = mapped[X_AXIS]; + float my = mapped[Y_AXIS]; + float h = std::sqrt(mx * mx + my * my); + if (h < 0.05f) { + ESP_LOGW(TAG, "calibrate_heading: device must be tilted (XY magnitude %.3f too small)", h); + return false; + } + + // Rotation angle in the XY plane: eliminate Y component while preserving X sign. + // Without the sign correction, atan2(my,mx) would rotate everything to +X, + // flipping the sign when the tilt projects onto -X. + float sign_mx = mx >= 0 ? 1.0f : -1.0f; + float cos_phi = sign_mx * mx / h; // = |mx| / h + float sin_phi = sign_mx * my / h; + + // Compose Rz(-phi) with the current matrix + // Rz(-phi) = [[cos_phi, sin_phi, 0], [-sin_phi, cos_phi, 0], [0, 0, 1]] + float old[9]; + memcpy(old, this->matrix_, sizeof(old)); + + this->matrix_[0] = cos_phi * old[0] + sin_phi * old[3]; + this->matrix_[1] = cos_phi * old[1] + sin_phi * old[4]; + this->matrix_[2] = cos_phi * old[2] + sin_phi * old[5]; + this->matrix_[3] = -sin_phi * old[0] + cos_phi * old[3]; + this->matrix_[4] = -sin_phi * old[1] + cos_phi * old[4]; + this->matrix_[5] = -sin_phi * old[2] + cos_phi * old[5]; + // Row 2 unchanged + + ESP_LOGI(TAG, "Heading calibration applied (mapped accel: [%.3f, %.3f, %.3f])", mapped[X_AXIS], mapped[Y_AXIS], + mapped[Z_AXIS]); + log_matrix(this->matrix_); + return true; +} + +} // namespace esphome::motion diff --git a/esphome/components/motion/motion_component.h b/esphome/components/motion/motion_component.h new file mode 100644 index 0000000000..00310c16fe --- /dev/null +++ b/esphome/components/motion/motion_component.h @@ -0,0 +1,154 @@ +#pragma once + +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" +#include +#include +#include // required for generated lambda code + +namespace esphome::motion { + +// ---Data class + +struct MotionData { + float acceleration[3]{NAN, NAN, NAN}; + float angular_rate[3]{NAN, NAN, NAN}; + // TODO - compass +}; + +// indices into data arrays +static constexpr uint8_t X_AXIS = 0; +static constexpr uint8_t Y_AXIS = 1; +static constexpr uint8_t Z_AXIS = 2; + +// Persisted calibration. `base_hash` ties the stored matrix to the build-time +// (axis_map / transform_matrix) base; if the base changes the saved calibration +// is ignored. Stored under a stable, ID-derived key so it overwrites in place. +struct CalibrationPref { + uint32_t base_hash; + float matrix[9]; +} PACKED; + +// Main component class +class MotionComponent : public PollingComponent { + public: + // Lifecycle + void setup() override; + void update() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::DATA; } + + void set_matrix(const std::array &m) { + memcpy(this->base_matrix_, m.data(), sizeof(this->base_matrix_)); + memcpy(this->matrix_, m.data(), sizeof(this->matrix_)); + } + void set_calibration_key(uint32_t key) { this->pref_key_ = key; } + + /// Calibrate the matrix so the current reading maps to [0, 0, 1] (device flat). + bool calibrate_level(); + /// Assuming Y-axis rotation only, correct the heading so X/Y align correctly. + bool calibrate_heading(); + /// Save the current matrix to NVS. + bool save_calibration(); + /// Restore the build-time (axis_map / transform_matrix) base, discarding calibration. + void clear_calibration(); + + template void add_listener(F &&cb) { this->motion_data_callback_.add(std::forward(cb)); } + + protected: + // platforms must implement this method to update raw data. + virtual bool update_data(MotionData &data) = 0; + + // for mapping axes + float matrix_[9]{ + 1, 0, 0, 0, 1, 0, 0, 0, 1, + }; + // build-time base (axis_map / transform_matrix); used to detect config changes + // and to restore on clear_calibration(). + float base_matrix_[9]{ + 1, 0, 0, 0, 1, 0, 0, 0, 1, + }; + + void map_axes_(float output[3], const float input[3]) const { + output[0] = input[X_AXIS] * this->matrix_[0] + input[Y_AXIS] * this->matrix_[1] + input[Z_AXIS] * this->matrix_[2]; + output[1] = input[X_AXIS] * this->matrix_[3] + input[Y_AXIS] * this->matrix_[4] + input[Z_AXIS] * this->matrix_[5]; + output[2] = input[X_AXIS] * this->matrix_[6] + input[Y_AXIS] * this->matrix_[7] + input[Z_AXIS] * this->matrix_[8]; + } + + LazyCallbackManager motion_data_callback_{}; + uint32_t pref_key_{0}; + uint32_t base_hash_{0}; // hash of base_matrix_, captured in setup() + ESPPreferenceObject pref_{}; +}; + +// --- Actions --- + +template class CalibrateLevelAction : public Action { + public: + explicit CalibrateLevelAction(MotionComponent *parent) : parent_(parent) {} + void set_save(bool save) { this->save_ = save; } + Trigger<> *get_success_trigger() { return &this->success_trigger_; } + Trigger<> *get_error_trigger() { return &this->error_trigger_; } + + protected: + void play(const Ts &...) override { + if (this->parent_->calibrate_level()) { + // if not saving, calibration success is enough. If save required only report success after that succeeds too. + if (!this->save_ || this->parent_->save_calibration()) { + this->success_trigger_.trigger(); + return; + } + } + this->error_trigger_.trigger(); + } + + MotionComponent *parent_; + Trigger<> success_trigger_; + Trigger<> error_trigger_; + bool save_{false}; +}; + +template class CalibrateHeadingAction : public Action { + public: + explicit CalibrateHeadingAction(MotionComponent *parent) : parent_(parent) {} + void set_save(bool save) { this->save_ = save; } + Trigger<> *get_success_trigger() { return &this->success_trigger_; } + Trigger<> *get_error_trigger() { return &this->error_trigger_; } + + protected: + void play(const Ts &...) override { + if (this->parent_->calibrate_heading()) { + // if not saving, calibration success is enough. If save required only report success after that succeeds too. + if (!this->save_ || this->parent_->save_calibration()) { + this->success_trigger_.trigger(); + return; + } + } + this->error_trigger_.trigger(); + } + + MotionComponent *parent_; + Trigger<> success_trigger_; + Trigger<> error_trigger_; + bool save_{false}; +}; + +template class ClearCalibrationAction : public Action { + public: + explicit ClearCalibrationAction(MotionComponent *parent) : parent_(parent) {} + void set_save(bool save) { this->save_ = save; } + + protected: + void play(const Ts &...) override { + this->parent_->clear_calibration(); + if (this->save_) + this->parent_->save_calibration(); + } + + MotionComponent *parent_; + bool save_{false}; +}; + +} // namespace esphome::motion diff --git a/esphome/components/motion/sensor.py b/esphome/components/motion/sensor.py new file mode 100644 index 0000000000..ad3163a01a --- /dev/null +++ b/esphome/components/motion/sensor.py @@ -0,0 +1,128 @@ +# YAML config keys +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_TYPE, + ICON_ACCELERATION, + ICON_ROTATE_RIGHT, + STATE_CLASS_MEASUREMENT, + UNIT_DEGREE_PER_SECOND, + UNIT_DEGREES, + UNIT_G, +) +from esphome.cpp_generator import MockObj +from esphome.cpp_types import std_ns +import esphome.final_validate as fv + +from . import ( + AXES, + CONF_MOTION_ID, + KEY_ACCELEROMETER, + KEY_GYROSCOPE, + SENSOR_SCHEMA, + motion_ns, +) + +MotionData = motion_ns.class_("MotionData") + +CONF_PITCH = "pitch" +CONF_ROLL = "roll" +ICON_SEESAW = "mdi:seesaw" + + +def _accel_sensor_schema(): + return sensor.sensor_schema( + unit_of_measurement=UNIT_G, + icon=ICON_ACCELERATION, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + ).extend(SENSOR_SCHEMA) + + +def _gyro_sensor_schema(): + return sensor.sensor_schema( + unit_of_measurement=UNIT_DEGREE_PER_SECOND, + icon=ICON_ROTATE_RIGHT, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + ).extend(SENSOR_SCHEMA) + + +def _level_sensor_schema(): + return sensor.sensor_schema( + unit_of_measurement=UNIT_DEGREES, + icon=ICON_SEESAW, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + ).extend(SENSOR_SCHEMA) + + +_ACCELERATIONS = ["acceleration_" + a for a in AXES] +_GYROSCOPES = ["gyroscope_" + g for g in AXES] +_ANGULAR_RATES = ["angular_rate_" + r for r in AXES] + +CONFIG_SCHEMA = cv.typed_schema( + { + **{x: _accel_sensor_schema() for x in _ACCELERATIONS}, + **{x: _gyro_sensor_schema() for x in _GYROSCOPES}, + **{x: _gyro_sensor_schema() for x in _ANGULAR_RATES}, + **{x: _level_sensor_schema() for x in (CONF_PITCH, CONF_ROLL)}, + } +) + + +def _final_validate(config: dict) -> None: + full_config = fv.full_config.get() + motion_path = full_config.get_path_for_id(config[CONF_MOTION_ID])[:-1] + motion_config = full_config.get_config_for_path(motion_path) + has_accel = motion_config.get(KEY_ACCELEROMETER, False) + has_gyro = motion_config.get(KEY_GYROSCOPE, False) + + sensor_type = config[CONF_TYPE] + if ( + sensor_type in _ACCELERATIONS or sensor_type in (CONF_ROLL, CONF_PITCH) + ) and not has_accel: + raise cv.Invalid( + "The motion device does not measure acceleration", path=[CONF_TYPE] + ) + if (sensor_type in _GYROSCOPES or sensor_type in _ANGULAR_RATES) and not has_gyro: + raise cv.Invalid( + "The motion device does not measure angular rate", path=[CONF_TYPE] + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +def build_sensor_expr(sensor_type: str, data: MockObj) -> MockObj: + """Build the C++ expression for a motion sensor type.""" + + # Note that is included via this component's header file. + pif = std_ns.namespace("numbers").pi_v.template(cg.float_) + if sensor_type == CONF_ROLL: + ay = data.acceleration[1] + az = data.acceleration[2] + return std_ns.atan2(ay, az) * (180.0 / pif) + if sensor_type == CONF_PITCH: + ax = data.acceleration[0] + ay = data.acceleration[1] + az = data.acceleration[2] + return std_ns.atan2(-ax, std_ns.sqrt(ay * ay + az * az)) * (180.0 / pif) + sensor_offset = AXES.index(sensor_type[-1:]) + if sensor_type in _GYROSCOPES: + sensor_type = _ANGULAR_RATES[sensor_offset] + return getattr(data, str(sensor_type[:-2]))[sensor_offset] + + +async def to_code(config): + sensor_type = config[CONF_TYPE] + var = await sensor.new_sensor(config) + parent = await cg.get_variable(config[CONF_MOTION_ID]) + data = MockObj("data") + expr = build_sensor_expr(sensor_type, data) + value_lambda = await cg.process_lambda( + var.publish_state(expr), + [(MotionData.operator("ref"), str(data))], + ) + cg.add(parent.add_listener(value_lambda)) diff --git a/tests/component_tests/motion/__init__.py b/tests/component_tests/motion/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/motion/test_motion.py b/tests/component_tests/motion/test_motion.py new file mode 100644 index 0000000000..f2c0f26344 --- /dev/null +++ b/tests/component_tests/motion/test_motion.py @@ -0,0 +1,899 @@ +"""Tests for the motion component.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from voluptuous import Invalid, MultipleInvalid + +from esphome.components.motion import ( + CALIBRATE_ACTION_SCHEMA, + CLEAR_ACTION_SCHEMA, + CONF_AXIS_MAP, + CONF_SAVE, + CONF_TRANSFORM_MATRIX, + _axis_map, + _axis_map_to_matrix, + _build_calibrate_action, + _transform_matrix, + _validate_matrix_options, + clear_calibration_to_code, +) +from esphome.components.motion.sensor import ( + _ACCELERATIONS, + _ANGULAR_RATES, + _GYROSCOPES, + CONF_PITCH, + CONF_ROLL, + CONFIG_SCHEMA, + build_sensor_expr, +) +from esphome.const import CONF_ID, CONF_ON_ERROR, CONF_ON_SUCCESS +from esphome.cpp_generator import MockObj + +# --- Axis map validation --- + + +class TestAxisMapValidation: + """Tests for the _axis_map validator.""" + + def test_identity_map(self): + result = _axis_map({"x": "x", "y": "y", "z": "z"}) + assert result == {"x": "x", "y": "y", "z": "z"} + + def test_axis_swap(self): + result = _axis_map({"x": "y", "y": "z", "z": "x"}) + assert result == {"x": "y", "y": "z", "z": "x"} + + def test_negation(self): + result = _axis_map({"x": "-y", "y": "z", "z": "x"}) + assert result == {"x": "-y", "y": "z", "z": "x"} + + def test_plus_prefix(self): + result = _axis_map({"x": "+y", "y": "z", "z": "x"}) + assert result == {"x": "+y", "y": "z", "z": "x"} + + def test_case_insensitive(self): + result = _axis_map({"x": "X", "y": "Y", "z": "Z"}) + assert result == {"x": "X", "y": "Y", "z": "Z"} + + def test_invalid_axis_value(self): + with pytest.raises(MultipleInvalid): + _axis_map({"x": "a", "y": "y", "z": "z"}) + + def test_duplicate_mapping(self): + with pytest.raises(MultipleInvalid): + _axis_map({"x": "x", "y": "x", "z": "z"}) + + def test_all_same_axis(self): + with pytest.raises(MultipleInvalid): + _axis_map({"x": "x", "y": "x", "z": "x"}) + + def test_empty_value(self): + with pytest.raises(MultipleInvalid): + _axis_map({"x": "", "y": "y", "z": "z"}) + + def test_invalid_and_duplicate(self): + """Both invalid value and duplicate should produce multiple errors.""" + with pytest.raises(MultipleInvalid) as exc_info: + _axis_map({"x": "a", "y": "x", "z": "z"}) + # Should have at least the invalid regex error and the duplicate error + assert len(exc_info.value.errors) >= 2 + + +# --- Transform matrix validation --- + + +class TestTransformMatrix: + """Tests for the _transform_matrix validator.""" + + def test_flat_identity(self): + result = _transform_matrix([1, 0, 0, 0, 1, 0, 0, 0, 1]) + assert result == [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0] + + def test_flat_values_converted_to_float(self): + result = _transform_matrix([1, 2, 3, 4, 5, 6, 7, 8, 9]) + assert all(isinstance(v, float) for v in result) + + def test_nested_3x3(self): + result = _transform_matrix([[1, 0, 0], [0, 1, 0], [0, 0, 1]]) + assert result == [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0] + + def test_nested_3x3_values(self): + result = _transform_matrix( + [[0.5, 0.1, -0.2], [-0.1, 0.9, 0.3], [0.2, -0.3, 0.8]] + ) + assert len(result) == 9 + assert result[0] == pytest.approx(0.5) + assert result[3] == pytest.approx(-0.1) + assert result[8] == pytest.approx(0.8) + + def test_flat_wrong_length_short(self): + with pytest.raises(Invalid, match="exactly 9"): + _transform_matrix([1, 0, 0]) + + def test_flat_wrong_length_long(self): + with pytest.raises(Invalid, match="exactly 9"): + _transform_matrix([1] * 12) + + def test_nested_wrong_row_count(self): + with pytest.raises(Invalid, match="3 rows"): + _transform_matrix([[1, 0, 0], [0, 1, 0]]) + + def test_nested_wrong_column_count(self): + with pytest.raises(Invalid, match="3 numbers"): + _transform_matrix([[1, 0], [0, 1, 0], [0, 0, 1]]) + + def test_empty_list(self): + with pytest.raises(Invalid): + _transform_matrix([]) + + def test_not_a_list(self): + with pytest.raises(Invalid): + _transform_matrix("identity") + + +class TestValidateMatrixOptions: + """Tests for mutual exclusivity of axis_map and transform_matrix.""" + + def test_neither_passes(self): + config = {"some_key": "value"} + assert _validate_matrix_options(config) is config + + def test_axis_map_only_passes(self): + config = {CONF_AXIS_MAP: {"x": "x", "y": "y", "z": "z"}} + assert _validate_matrix_options(config) is config + + def test_transform_matrix_only_passes(self): + config = {CONF_TRANSFORM_MATRIX: [1, 0, 0, 0, 1, 0, 0, 0, 1]} + assert _validate_matrix_options(config) is config + + def test_both_raises(self): + config = { + CONF_AXIS_MAP: {"x": "x", "y": "y", "z": "z"}, + CONF_TRANSFORM_MATRIX: [1, 0, 0, 0, 1, 0, 0, 0, 1], + } + with pytest.raises(Invalid, match="mutually exclusive"): + _validate_matrix_options(config) + + +# --- Axis map to matrix --- + + +class TestAxisMapToMatrix: + """Tests for _axis_map_to_matrix conversion.""" + + def test_identity(self): + assert _axis_map_to_matrix({"x": "x", "y": "y", "z": "z"}) == [ + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1, + ] + + def test_swap_xy(self): + # x←y, y←x, z←z + assert _axis_map_to_matrix({"x": "y", "y": "x", "z": "z"}) == [ + 0, + 1, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + ] + + def test_rotate_xyz(self): + # x←y, y←z, z←x + assert _axis_map_to_matrix({"x": "y", "y": "z", "z": "x"}) == [ + 0, + 1, + 0, + 0, + 0, + 1, + 1, + 0, + 0, + ] + + def test_negate_x(self): + assert _axis_map_to_matrix({"x": "-x", "y": "y", "z": "z"}) == [ + -1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1, + ] + + def test_negate_z(self): + assert _axis_map_to_matrix({"x": "x", "y": "y", "z": "-z"}) == [ + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + -1, + ] + + def test_swap_and_negate(self): + # x←-y, y←z, z←x + assert _axis_map_to_matrix({"x": "-y", "y": "z", "z": "x"}) == [ + 0, + -1, + 0, + 0, + 0, + 1, + 1, + 0, + 0, + ] + + def test_plus_prefix_ignored(self): + assert _axis_map_to_matrix({"x": "+y", "y": "z", "z": "x"}) == [ + 0, + 1, + 0, + 0, + 0, + 1, + 1, + 0, + 0, + ] + + +# --- Sensor expression generation --- + + +def _expr_str(sensor_type: str) -> str: + """Build a sensor expression via the production function and return its string form.""" + return str(build_sensor_expr(sensor_type, MockObj("data"))) + + +class TestSensorExpressions: + """Tests that sensor code generation produces correct C++ expressions.""" + + @pytest.mark.parametrize( + "sensor_type,expected_index", + [ + ("acceleration_x", 0), + ("acceleration_y", 1), + ("acceleration_z", 2), + ], + ) + def test_acceleration_sensors(self, sensor_type, expected_index): + assert _expr_str(sensor_type) == f"data.acceleration[{expected_index}]" + + @pytest.mark.parametrize( + "sensor_type,expected_index", + [ + ("angular_rate_x", 0), + ("angular_rate_y", 1), + ("angular_rate_z", 2), + ], + ) + def test_angular_rate_sensors(self, sensor_type, expected_index): + assert _expr_str(sensor_type) == f"data.angular_rate[{expected_index}]" + + @pytest.mark.parametrize( + "sensor_type,expected_index", + [ + ("gyroscope_x", 0), + ("gyroscope_y", 1), + ("gyroscope_z", 2), + ], + ) + def test_gyroscope_maps_to_angular_rate(self, sensor_type, expected_index): + """Gyroscope sensor types should be remapped to angular_rate in the expression.""" + assert _expr_str(sensor_type) == f"data.angular_rate[{expected_index}]" + + def test_roll_expression(self): + expr = _expr_str("roll") + assert "std::atan2" in expr + assert "data.acceleration[1]" in expr + assert "data.acceleration[2]" in expr + assert "180.0f" in expr + assert "std::numbers::pi_v" in expr + # Roll should NOT reference acceleration[0] + assert "data.acceleration[0]" not in expr + + def test_pitch_expression(self): + expr = _expr_str("pitch") + assert "std::atan2" in expr + assert "std::sqrt" in expr + # All three axes used + assert "data.acceleration[0]" in expr + assert "data.acceleration[1]" in expr + assert "data.acceleration[2]" in expr + assert "180.0f" in expr + assert "std::numbers::pi_v" in expr + # Pitch negates the x component + assert "(-data.acceleration[0])" in expr + + +# --- Calibration math --- +# +# Pure-Python reimplementation of the C++ calibration algorithms so we can +# verify the mathematical properties without needing to compile C++. + + +def _mat_vec(m: list[float], v: list[float]) -> list[float]: + """Multiply a row-major 3x3 matrix by a 3-vector.""" + return [ + m[0] * v[0] + m[1] * v[1] + m[2] * v[2], + m[3] * v[0] + m[4] * v[1] + m[5] * v[2], + m[6] * v[0] + m[7] * v[1] + m[8] * v[2], + ] + + +def _mat_mul(a: list[float], b: list[float]) -> list[float]: + """Multiply two row-major 3x3 matrices.""" + r = [0.0] * 9 + for i in range(3): + for j in range(3): + r[i * 3 + j] = sum(a[i * 3 + k] * b[k * 3 + j] for k in range(3)) + return r + + +def _transpose(m: list[float]) -> list[float]: + """Transpose a row-major 3x3 matrix.""" + return [m[0], m[3], m[6], m[1], m[4], m[7], m[2], m[5], m[8]] + + +def _det(m: list[float]) -> float: + """Determinant of a 3x3 matrix.""" + return ( + m[0] * (m[4] * m[8] - m[5] * m[7]) + - m[1] * (m[3] * m[8] - m[5] * m[6]) + + m[2] * (m[3] * m[7] - m[4] * m[6]) + ) + + +def _calibrate_level( + raw: list[float], matrix: list[float] | None = None +) -> list[float]: + """Python port of MotionComponent::calibrate_level. + + Composes the correction with *matrix* (defaults to identity). + """ + import math + + if matrix is None: + matrix = list(IDENTITY) + + # Apply current matrix first + mapped = _mat_vec(matrix, raw) + + nx, ny, nz = mapped + mag = math.sqrt(nx * nx + ny * ny + nz * nz) + nx /= mag + ny /= mag + nz /= mag + + if nz > 0.9999: + return matrix[:] # already aligned, preserve existing matrix + + if nz < -0.9999: + r = [1, 0, 0, 0, -1, 0, 0, 0, -1] + else: + f = 1.0 / (1.0 + nz) + r = [ + 1.0 - nx * nx * f, + -nx * ny * f, + -nx, + -nx * ny * f, + 1.0 - ny * ny * f, + -ny, + nx, + ny, + nz, + ] + + return _mat_mul(r, matrix) + + +def _calibrate_heading(matrix: list[float], raw: list[float]) -> list[float]: + """Python port of MotionComponent::calibrate_heading.""" + import math + + mapped = _mat_vec(matrix, raw) + mx, my = mapped[0], mapped[1] + h = math.sqrt(mx * mx + my * my) + sign_mx = 1.0 if mx >= 0 else -1.0 + cos_phi = sign_mx * mx / h # = |mx| / h + sin_phi = sign_mx * my / h + + old = matrix[:] + new = old[:] + new[0] = cos_phi * old[0] + sin_phi * old[3] + new[1] = cos_phi * old[1] + sin_phi * old[4] + new[2] = cos_phi * old[2] + sin_phi * old[5] + new[3] = -sin_phi * old[0] + cos_phi * old[3] + new[4] = -sin_phi * old[1] + cos_phi * old[4] + new[5] = -sin_phi * old[2] + cos_phi * old[5] + return new + + +IDENTITY = [1, 0, 0, 0, 1, 0, 0, 0, 1] + + +class TestCalibrateLevel: + """Verify the Rodrigues-based level calibration matrix.""" + + def _assert_maps_to_z(self, raw: list[float]) -> list[float]: + """Assert that the calibration matrix maps raw to [0, 0, 1].""" + import math + + m = _calibrate_level(raw) + mag = math.sqrt(sum(v * v for v in raw)) + norm = [v / mag for v in raw] + result = _mat_vec(m, norm) + assert result[0] == pytest.approx(0, abs=1e-6) + assert result[1] == pytest.approx(0, abs=1e-6) + assert result[2] == pytest.approx(1, abs=1e-6) + return m + + def test_already_flat(self): + m = _calibrate_level([0, 0, 1.0]) + assert m == IDENTITY + + def test_preserves_existing_matrix_when_flat(self): + """If already flat after axis mapping, level cal should not change the matrix.""" + swap = [0, 1, 0, 1, 0, 0, 0, 0, 1] # swap X↔Y + m = _calibrate_level([0, 0, 1.0], swap) + assert m == swap + + def test_composes_with_existing_matrix(self): + """Level calibration should correct tilt while preserving an existing axis swap.""" + import math + + swap = [0, 1, 0, 1, 0, 0, 0, 0, 1] # swap X↔Y + # Tilted raw: gravity has X component in raw frame + raw = [0.3, 0.0, 0.954] + m = _calibrate_level(raw, swap) + # After calibration, current raw should map to [0, 0, ~1] + mag = math.sqrt(sum(v * v for v in raw)) + norm = [v / mag for v in raw] + result = _mat_vec(m, norm) + assert result[0] == pytest.approx(0, abs=1e-5) + assert result[1] == pytest.approx(0, abs=1e-5) + assert result[2] == pytest.approx(1, abs=1e-5) + # Result should differ from calibrating without the swap + m_no_swap = _calibrate_level(raw) + assert m != m_no_swap + + def test_upside_down(self): + m = _calibrate_level([0, 0, -1.0]) + # 180° about X + assert m == [1, 0, 0, 0, -1, 0, 0, 0, -1] + result = _mat_vec(m, [0, 0, -1]) + assert result[2] == pytest.approx(1, abs=1e-6) + + def test_gravity_along_x(self): + self._assert_maps_to_z([1.0, 0, 0]) + + def test_gravity_along_neg_x(self): + self._assert_maps_to_z([-1.0, 0, 0]) + + def test_gravity_along_y(self): + self._assert_maps_to_z([0, 1.0, 0]) + + def test_tilted_45_degrees(self): + import math + + self._assert_maps_to_z( + [math.sin(math.radians(45)), 0, math.cos(math.radians(45))] + ) + + def test_arbitrary_vector(self): + self._assert_maps_to_z([0.3, -0.5, 0.81]) + + def test_unnormalized_input(self): + """Input does not need to be unit length.""" + self._assert_maps_to_z([0.6, -1.0, 1.62]) + + @pytest.mark.parametrize( + "raw", + [ + [1.0, 0, 0], + [0, 1.0, 0], + [0.3, -0.5, 0.81], + [-0.7, 0.4, 0.59], + ], + ) + def test_result_is_proper_rotation(self, raw): + """The resulting matrix should be orthogonal with determinant +1.""" + m = _calibrate_level(raw) + # R^T * R ≈ I + product = _mat_mul(_transpose(m), m) + for i in range(9): + expected = 1.0 if i % 4 == 0 else 0.0 + assert product[i] == pytest.approx(expected, abs=1e-6) + # det ≈ 1 + assert _det(m) == pytest.approx(1.0, abs=1e-6) + + +class TestCalibrateHeading: + """Verify the Z-rotation heading correction.""" + + def test_y_axis_tilt_no_heading_error(self): + """Device tilted purely around Y — heading should already be correct.""" + import math + + flat_raw = [0, 0, 1.0] + level_m = _calibrate_level(flat_raw) + # Tilt 30° around Y: gravity = [-sin30, 0, cos30] + tilted_raw = [-math.sin(math.radians(30)), 0, math.cos(math.radians(30))] + heading_m = _calibrate_heading(level_m, tilted_raw) + # Matrix should barely change since there's no Y component + for i in range(9): + assert heading_m[i] == pytest.approx(level_m[i], abs=1e-6) + + def test_corrects_heading_rotation(self): + """After level+heading calibration, mapped Y should be ~0 when tilted.""" + import math + + # Simulate a sensor whose chip is rotated 30° around Z relative to enclosure + angle = math.radians(30) + # When the enclosure is flat, the raw reading is [0, 0, 1] regardless of Z rotation + level_m = _calibrate_level([0, 0, 1.0]) + + # When tilted around the enclosure's Y axis, the raw reading in the + # chip frame has both X and Y components due to the Z-rotation offset + tilt = math.radians(20) + # In enclosure frame: [-sin(tilt), 0, cos(tilt)] + # Rotated by Z-angle into chip frame: + ex = -math.sin(tilt) * math.cos(angle) + ey = -math.sin(tilt) * math.sin(angle) + ez = math.cos(tilt) + tilted_raw = [ex, ey, ez] + + heading_m = _calibrate_heading(level_m, tilted_raw) + # After correction, mapped Y should be 0 + result = _mat_vec(heading_m, tilted_raw) + assert result[1] == pytest.approx(0, abs=1e-6) + # Z should still be correct + assert result[2] == pytest.approx(math.cos(tilt), abs=1e-6) + + def test_full_calibration_sequence(self): + """End-to-end: level then heading produces correct frame alignment.""" + import math + + # Chip is mounted tilted 15° around Y and 25° around Z + # Build the chip-to-enclosure rotation: Rz(25°) * Ry(15°) + yz = math.radians(25) + yy = math.radians(15) + # Ry(yy) + ry = [ + math.cos(yy), + 0, + math.sin(yy), + 0, + 1, + 0, + -math.sin(yy), + 0, + math.cos(yy), + ] + # Rz(yz) + rz = [ + math.cos(yz), + -math.sin(yz), + 0, + math.sin(yz), + math.cos(yz), + 0, + 0, + 0, + 1, + ] + chip_rot = _mat_mul(rz, ry) # chip orientation in enclosure frame + # Inverse (transpose) maps enclosure vectors to chip readings + chip_rot_inv = _transpose(chip_rot) + + # Step 1: Device flat — gravity in enclosure frame is [0, 0, 1] + flat_raw = _mat_vec(chip_rot_inv, [0, 0, 1]) + level_m = _calibrate_level(flat_raw) + + # After level calibration, flat reading should map to [0, 0, 1] + check_flat = _mat_vec(level_m, flat_raw) + assert check_flat[0] == pytest.approx(0, abs=1e-5) + assert check_flat[1] == pytest.approx(0, abs=1e-5) + assert check_flat[2] == pytest.approx(1, abs=1e-5) + + # Step 2: Tilt enclosure around Y by 20° + tilt = math.radians(20) + tilted_enclosure = [-math.sin(tilt), 0, math.cos(tilt)] + tilted_raw = _mat_vec(chip_rot_inv, tilted_enclosure) + heading_m = _calibrate_heading(level_m, tilted_raw) + + # After heading calibration, the mapped reading should be + # [-sin(tilt), 0, cos(tilt)] — all horizontal component in X + result = _mat_vec(heading_m, tilted_raw) + assert result[0] == pytest.approx(-math.sin(tilt), abs=1e-5) + assert result[1] == pytest.approx(0, abs=1e-5) + assert result[2] == pytest.approx(math.cos(tilt), abs=1e-5) + + @pytest.mark.parametrize( + "raw", + [ + [0.3, -0.5, 0.81], + [-0.7, 0.4, 0.59], + ], + ) + def test_heading_preserves_orthogonality(self, raw): + """Heading correction composed with level should remain a proper rotation.""" + + level_m = _calibrate_level(raw) + # Create a tilted reading for heading calibration + tilt_raw = [v + 0.3 for v in raw] # perturb to get XY component + heading_m = _calibrate_heading(level_m, tilt_raw) + product = _mat_mul(_transpose(heading_m), heading_m) + for i in range(9): + expected = 1.0 if i % 4 == 0 else 0.0 + assert product[i] == pytest.approx(expected, abs=1e-5) + assert _det(heading_m) == pytest.approx(1.0, abs=1e-5) + + +# --- Calibration action schema & codegen --- + + +class TestCalibrateActionSchema: + """Tests for the CALIBRATE_ACTION_SCHEMA used by both calibration actions.""" + + def test_schema_accepts_on_success_key(self): + """on_success must be a recognised optional key.""" + schema_keys = {str(k) for k in CALIBRATE_ACTION_SCHEMA.schema} + assert CONF_ON_SUCCESS in schema_keys + + def test_schema_accepts_on_error_key(self): + """on_error must be a recognised optional key.""" + schema_keys = {str(k) for k in CALIBRATE_ACTION_SCHEMA.schema} + assert CONF_ON_ERROR in schema_keys + + +@pytest.fixture +def mock_codegen(): + """Mock cg and automation functions used by _build_calibrate_action.""" + mock_var = MagicMock() + mock_parent = MagicMock() + + with ( + patch( + "esphome.components.motion.cg.get_variable", + new_callable=AsyncMock, + return_value=mock_parent, + ) as mock_get_var, + patch( + "esphome.components.motion.cg.new_Pvariable", + return_value=mock_var, + ) as mock_new_pvar, + patch( + "esphome.components.motion.automation.build_automation", + new_callable=AsyncMock, + ) as mock_build_auto, + ): + yield { + "get_variable": mock_get_var, + "new_Pvariable": mock_new_pvar, + "build_automation": mock_build_auto, + "var": mock_var, + "parent": mock_parent, + } + + +@pytest.mark.asyncio +async def test_build_calibrate_action_no_triggers(mock_codegen): + """Without on_success/on_error, build_automation should not be called.""" + config = {CONF_ID: MagicMock()} + action_id = MagicMock() + template_arg = MagicMock() + + result = await _build_calibrate_action(config, action_id, template_arg, []) + + assert result is mock_codegen["var"] + mock_codegen["new_Pvariable"].assert_called_once_with( + action_id, template_arg, mock_codegen["parent"] + ) + mock_codegen["build_automation"].assert_not_called() + + +@pytest.mark.asyncio +async def test_build_calibrate_action_with_on_success(mock_codegen): + """on_success should wire build_automation to get_success_trigger().""" + on_success_config = MagicMock() + config = {CONF_ID: MagicMock(), CONF_ON_SUCCESS: on_success_config} + + await _build_calibrate_action(config, MagicMock(), MagicMock(), []) + + mock_codegen["build_automation"].assert_called_once_with( + mock_codegen["var"].get_success_trigger(), [], on_success_config + ) + + +@pytest.mark.asyncio +async def test_build_calibrate_action_with_on_error(mock_codegen): + """on_error should wire build_automation to get_error_trigger().""" + on_error_config = MagicMock() + config = {CONF_ID: MagicMock(), CONF_ON_ERROR: on_error_config} + + await _build_calibrate_action(config, MagicMock(), MagicMock(), []) + + mock_codegen["build_automation"].assert_called_once_with( + mock_codegen["var"].get_error_trigger(), [], on_error_config + ) + + +@pytest.mark.asyncio +async def test_build_calibrate_action_with_both_triggers(mock_codegen): + """Both on_success and on_error should each produce a build_automation call.""" + on_success_config = MagicMock() + on_error_config = MagicMock() + config = { + CONF_ID: MagicMock(), + CONF_ON_SUCCESS: on_success_config, + CONF_ON_ERROR: on_error_config, + } + + await _build_calibrate_action(config, MagicMock(), MagicMock(), []) + + assert mock_codegen["build_automation"].call_count == 2 + calls = mock_codegen["build_automation"].call_args_list + # First call: on_success + assert calls[0].args == ( + mock_codegen["var"].get_success_trigger(), + [], + on_success_config, + ) + # Second call: on_error + assert calls[1].args == ( + mock_codegen["var"].get_error_trigger(), + [], + on_error_config, + ) + + +# --- Clear calibration action --- + + +class TestClearActionSchema: + """Tests for CLEAR_ACTION_SCHEMA.""" + + def test_schema_has_save_key(self): + schema_keys = {str(k) for k in CLEAR_ACTION_SCHEMA.schema} + assert CONF_SAVE in schema_keys + + def test_save_defaults_to_false(self): + result = CLEAR_ACTION_SCHEMA({CONF_ID: "x"}) + assert result[CONF_SAVE] is False + + +@pytest.fixture +def mock_clear_codegen(): + """Mock cg functions used by clear_calibration_to_code.""" + mock_var = MagicMock() + mock_parent = MagicMock() + with ( + patch( + "esphome.components.motion.cg.get_variable", + new_callable=AsyncMock, + return_value=mock_parent, + ), + patch( + "esphome.components.motion.cg.new_Pvariable", + return_value=mock_var, + ) as mock_new_pvar, + patch("esphome.components.motion.cg.add") as mock_add, + ): + yield {"new_Pvariable": mock_new_pvar, "add": mock_add, "var": mock_var} + + +@pytest.mark.asyncio +async def test_clear_action_without_save(mock_clear_codegen): + """With save=False, set_save should not be emitted.""" + config = {CONF_ID: MagicMock(), CONF_SAVE: False} + result = await clear_calibration_to_code(config, MagicMock(), MagicMock(), []) + assert result is mock_clear_codegen["var"] + mock_clear_codegen["add"].assert_not_called() + + +@pytest.mark.asyncio +async def test_clear_action_with_save(mock_clear_codegen): + """With save=True, set_save(True) should be emitted exactly once.""" + config = {CONF_ID: MagicMock(), CONF_SAVE: True} + await clear_calibration_to_code(config, MagicMock(), MagicMock(), []) + mock_clear_codegen["var"].set_save.assert_called_once_with(True) + mock_clear_codegen["add"].assert_called_once() + + +# --- Calibration persistence invalidation --- +# +# The C++ side stores a hash of the build-time base matrix alongside the saved +# calibration so a changed axis_map invalidates stale NVS data without orphaning +# storage (the pref key stays ID-stable). These tests pin the design properties +# of that base-matrix fingerprint: deterministic for identical maps, distinct +# for different ones. + + +def _hash_matrix(matrix: list[float]) -> int: + """Python port of the C++ hash_matrix() (FNV-1a over the float bytes).""" + import struct + + data = struct.pack("<9f", *matrix) + h = 2166136261 + for b in data: + h ^= b + h = (h * 16777619) & 0xFFFFFFFF + return h + + +class TestBaseMatrixHash: + """Properties of the base-matrix fingerprint used for NVS invalidation.""" + + def test_identical_axis_maps_hash_equal(self): + a = _axis_map_to_matrix({"x": "x", "y": "y", "z": "z"}) + b = _axis_map_to_matrix({"x": "x", "y": "y", "z": "z"}) + assert _hash_matrix([float(v) for v in a]) == _hash_matrix( + [float(v) for v in b] + ) + + def test_different_axis_maps_hash_differ(self): + identity = _axis_map_to_matrix({"x": "x", "y": "y", "z": "z"}) + swapped = _axis_map_to_matrix({"x": "y", "y": "x", "z": "z"}) + assert _hash_matrix([float(v) for v in identity]) != _hash_matrix( + [float(v) for v in swapped] + ) + + def test_sign_change_hashes_differ(self): + pos = _axis_map_to_matrix({"x": "x", "y": "y", "z": "z"}) + neg = _axis_map_to_matrix({"x": "-x", "y": "y", "z": "z"}) + assert _hash_matrix([float(v) for v in pos]) != _hash_matrix( + [float(v) for v in neg] + ) + + +# --- Sensor config schema type validation --- + + +class TestSensorConfigSchema: + """Tests for sensor CONFIG_SCHEMA type key validation.""" + + def test_invalid_type_rejected(self): + with pytest.raises((Invalid, MultipleInvalid), match="Unknown value"): + CONFIG_SCHEMA({"type": "invalid_type"}) + + def test_missing_type_rejected(self): + with pytest.raises((Invalid, MultipleInvalid)): + CONFIG_SCHEMA({}) + + @pytest.mark.parametrize( + "sensor_type", + _ACCELERATIONS + _GYROSCOPES + _ANGULAR_RATES + [CONF_PITCH, CONF_ROLL], + ) + def test_valid_types_accepted(self, sensor_type): + """Valid sensor types should pass type validation (errors from missing + required fields like motion_id are expected and acceptable).""" + try: + CONFIG_SCHEMA({"type": sensor_type}) + except (Invalid, MultipleInvalid) as e: + # Should NOT be a type validation error + assert "Unknown value" not in str(e), ( + f"Type '{sensor_type}' was rejected as unknown" + ) From 8400bab9265b0d12252514421294c07a4095d253 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 6 Jun 2026 19:58:25 -0400 Subject: [PATCH 0349/1815] [esp32] Make no-default-board variant test explicit about platformio toolchain (#16847) --- tests/component_tests/esp32/test_esp32.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index e0fcbab0ee..e9fa9446d4 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -83,7 +83,7 @@ def test_esp32_config( id="mismatched_board_variant_config", ), pytest.param( - {"variant": "esp32s31"}, + {"variant": "esp32s31", "toolchain": Toolchain.PLATFORMIO.value}, r"No default board is known for ESP32S31\. Please specify the `board:` option explicitly\. @ data\['variant'\]", id="variant_without_default_board_requires_explicit_board_under_platformio", ), From 64fc09646cdaecab29c55a002435f147a78a2dc4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 6 Jun 2026 20:00:42 -0400 Subject: [PATCH 0350/1815] [esp32] Fix clang-tidy on ESP-IDF 6 (#16850) --- .clang-tidy.hash | 2 +- esphome/components/bthome_mithermometer/bthome_ble.cpp | 1 + esphome/components/ledc/ledc_output.cpp | 3 +++ platformio.ini | 9 ++------- script/clang-tidy | 2 ++ sdkconfig.defaults | 8 ++++++++ 6 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index e89b4230ad..25ae506732 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -d9c755e5f019b2ecb324834717bc1fb8563e622f5751794cb7156d324884481e +58a760f5fd174bd438bcc3a7018292c158530c1a1d15181941c832d4c032511c diff --git a/esphome/components/bthome_mithermometer/bthome_ble.cpp b/esphome/components/bthome_mithermometer/bthome_ble.cpp index ff12e6157d..ff38ab1740 100644 --- a/esphome/components/bthome_mithermometer/bthome_ble.cpp +++ b/esphome/components/bthome_mithermometer/bthome_ble.cpp @@ -222,6 +222,7 @@ bool BTHomeMiThermometer::decrypt_bthome_payload_(const std::vector &da } size_t plaintext_length; + // NOLINTNEXTLINE(readability-suspicious-call-argument) - similarly named size args are not swapped psa_status_t status = psa_aead_decrypt(key_id, PSA_ALG_AEAD_WITH_SHORTENED_TAG(PSA_ALG_CCM, BTHOME_MIC_SIZE), nonce.data(), nonce.size(), nullptr, 0, ct_with_tag, ct_with_tag_size, payload.data(), ciphertext_size, &plaintext_length); diff --git a/esphome/components/ledc/ledc_output.cpp b/esphome/components/ledc/ledc_output.cpp index bfb629143d..62833a7649 100644 --- a/esphome/components/ledc/ledc_output.cpp +++ b/esphome/components/ledc/ledc_output.cpp @@ -165,6 +165,8 @@ void LEDCOutput::write_state(float state) { void LEDCOutput::setup() { if (!ledc_peripheral_reset_done) { ESP_LOGV(TAG, "Resetting LEDC peripheral to clear stale state after reboot"); + // Skip under clang-tidy: the inlined HAL MMIO writes trip clang-analyzer-core.FixedAddressDereference +#if !defined(CLANG_TIDY) #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0) PERIPH_RCC_ATOMIC() { ledc_ll_reset_register(0); } #elif ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) @@ -174,6 +176,7 @@ void LEDCOutput::setup() { } #else periph_module_reset(PERIPH_LEDC_MODULE); +#endif #endif ledc_peripheral_reset_done = true; } diff --git a/platformio.ini b/platformio.ini index d3fde193b4..182f426a31 100644 --- a/platformio.ini +++ b/platformio.ini @@ -132,10 +132,7 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script ; This are common settings for the ESP32 (all variants) using Arduino. [common:esp32-arduino] extends = common:arduino -platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip -platform_packages = - pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.9/esp32-core-3.3.9.tar.xz - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz +platform = https://github.com/pioarduino/platform-espressif32.git framework = arduino, espidf ; Arduino as an ESP-IDF component lib_deps = @@ -167,9 +164,7 @@ extra_scripts = post:esphome/components/esp32/post_build.py.script ; This are common settings for the ESP32 (all variants) using IDF. [common:esp32-idf] extends = common:idf -platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip -platform_packages = - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz +platform = https://github.com/pioarduino/platform-espressif32.git framework = espidf lib_deps = diff --git a/script/clang-tidy b/script/clang-tidy index f19bdb9b56..1416b9b332 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -73,6 +73,7 @@ def clang_options(idedata): "-freorder-blocks", "-fno-jump-tables", "-fno-shrink-wrap", + "-mno-target-align", ) if "zephyr" in triplet: @@ -137,6 +138,7 @@ def clang_options(idedata): if flag not in omit_flags and not flag.startswith("-Werror") and not flag.startswith("-std=") + and not flag.startswith("-mtune=esp") ) cmd.append("-std=gnu++20") diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 8d177a7e26..2bd702f48e 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -2,6 +2,14 @@ # (clang-tidy) -- by both the PlatformIO and the native ESP-IDF toolchain paths -- and when PlatformIO is run directly # from the source directory (e.g. by IDEs). This should enable all flags that are set by any component. +# clang-tidy analyzes with clang, and IDF 6 only offers newlib under clang +# (picolibc's Kconfig depends on !IDF_TOOLCHAIN_CLANG). The idedata is generated +# with GCC, whose default is picolibc -- but clang-tidy uses the toolchain's +# newlib headers, so a picolibc build config mismatches them (IDF's hal/assert.h +# redeclares abort()/__assert_func() with [[noreturn]] after newlib's stdlib.h, +# tripping clang-diagnostic-error). Pin newlib to match the analyzed headers. +CONFIG_LIBC_NEWLIB=y + # esp32 CONFIG_COMPILER_OPTIMIZATION_SIZE=y CONFIG_FREERTOS_HZ=1000 From cbc3770b11709bcdd7b5725bb42bfc8493f30b53 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Jun 2026 17:30:43 -0500 Subject: [PATCH 0351/1815] Include model-driven display schemas in the language schema dump (#16872) --- esphome/components/epaper_spi/display.py | 7 ++- esphome/components/mipi/__init__.py | 54 ++++++++++++++++++++++ esphome/components/mipi_dsi/display.py | 2 + esphome/components/mipi_rgb/display.py | 2 + esphome/components/mipi_spi/display.py | 2 + script/build_language_schema.py | 4 ++ tests/script/test_build_language_schema.py | 17 +++++++ 7 files changed, 87 insertions(+), 1 deletion(-) diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 658f9e2c4a..b7c56a283a 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -5,7 +5,11 @@ from esphome import core, pins import esphome.codegen as cg from esphome.components import display, spi from esphome.components.display import CONF_SHOW_TEST_CARD, validate_rotation -from esphome.components.mipi import flatten_sequence, map_sequence +from esphome.components.mipi import ( + flatten_sequence, + map_sequence, + model_schema_extractor, +) import esphome.config_validation as cv from esphome.config_validation import update_interval from esphome.const import ( @@ -111,6 +115,7 @@ def model_schema(config): ) +@model_schema_extractor(MODELS, model_schema) def customise_schema(config): """ Create a customised config schema for a specific model and validate the configuration. diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index ccd43c72cf..c3b744c919 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -2,8 +2,12 @@ # Various configuration constants for MIPI displays # Various utility functions for MIPI DBI configuration +from collections.abc import Callable +import functools from typing import Any, Self +import voluptuous as vol + from esphome.components.const import CONF_COLOR_DEPTH from esphome.components.display import CONF_SHOW_TEST_CARD, display_ns import esphome.config_validation as cv @@ -18,6 +22,7 @@ from esphome.const import ( CONF_LAMBDA, CONF_MIRROR_X, CONF_MIRROR_Y, + CONF_MODEL, CONF_OFFSET_HEIGHT, CONF_OFFSET_WIDTH, CONF_PAGES, @@ -27,6 +32,7 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.core import TimePeriod +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor LOGGER = cv.logging.getLogger(__name__) @@ -239,6 +245,54 @@ def delay(ms): return DELAY_FLAG, ms +# Generic placeholder model present in every DriverChip registry; skipped when +# choosing a representative model for schema extraction. +_CUSTOM_MODEL = "CUSTOM" + + +def model_schema_extractor( + models: dict[str, Any], + model_schema: Callable[[dict[str, Any]], Any], + extra: dict[str, Any] | None = None, +) -> Callable[[Callable[[Any], Any]], Callable[[Any], Any]]: + """ + Decorate a model-driven display CONFIG_SCHEMA so the language-schema dumper + can extract it. + + The schema is generated per ``model`` at validation time, so the static + dumper has nothing to walk. When the dumper passes SCHEMA_EXTRACT, resolve a + representative schema for a real model (the generic "CUSTOM" placeholder + over-constrains fields like init_sequence) plus any *extra* keys the model + needs, e.g. a bus mode, and hand that back; runtime validation is untouched. + """ + + def decorate(config_schema: Callable[[Any], Any]) -> Callable[[Any], Any]: + @schema_extractor("schema") + @functools.wraps(config_schema) + def wrapper(config: Any) -> Any: + if config is not SCHEMA_EXTRACT: + return config_schema(config) + names = sorted(models) + representative = next((n for n in names if n != _CUSTOM_MODEL), names[0]) + schema = model_schema({CONF_MODEL: representative, **(extra or {})}) + if isinstance(schema, vol.All): + schema = next( + (v for v in schema.validators if isinstance(v, vol.Schema)), + schema, + ) + if isinstance(schema, vol.Schema): + # The resolved schema pins ``model`` to the representative; expose + # the full model list so the dumped enum offers every model. + schema = schema.extend( + {cv.Required(CONF_MODEL): cv.one_of(*names, upper=True)} + ) + return schema + + return wrapper + + return decorate + + class DriverChip: """ A class representing a MIPI DBI driver chip model. diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 0939d84aa5..46e7a7d5a7 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -32,6 +32,7 @@ from esphome.components.mipi import ( dimension_schema, get_color_depth, map_sequence, + model_schema_extractor, power_of_two, requires_buffer, ) @@ -161,6 +162,7 @@ def model_schema(config): ) +@model_schema_extractor(MODELS, model_schema) def _config_schema(config): config = cv.Schema( { diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index b38ddad491..3c33c26726 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -30,6 +30,7 @@ from esphome.components.mipi import ( DriverChip, dimension_schema, map_sequence, + model_schema_extractor, power_of_two, requires_buffer, ) @@ -219,6 +220,7 @@ def model_schema(config): return schema +@model_schema_extractor(MODELS, model_schema) def _config_schema(config): config = cv.Schema( { diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 3c5a84594e..8c6ffff500 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -22,6 +22,7 @@ from esphome.components.mipi import ( dimension_schema, get_color_depth, map_sequence, + model_schema_extractor, power_of_two, requires_buffer, ) @@ -227,6 +228,7 @@ def model_schema(config): return schema +@model_schema_extractor(MODELS, model_schema, extra={CONF_BUS_MODE: TYPE_SINGLE}) def customise_schema(config): """ Create a customised config schema for a specific model and validate the configuration. diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 025186299d..4b0b0ee548 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -1002,6 +1002,10 @@ def convert(schema, config_var, path): else: config_var["use_id_type"] = str(data.base) config_var[S_TYPE] = "use_id" + elif schema_type == "schema": + # A callable CONFIG_SCHEMA that returned a representative schema + # for extraction (model-driven components); walk it as usual. + convert(data, config_var, path) else: raise TypeError("Unknown extracted schema type") elif config_var.get("key") == "GeneratedID": diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index dd1d88e74c..8b81a57fef 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -117,6 +117,23 @@ def test_convert_emits_explicit_sensitive_marker() -> None: assert config_var["type"] == "string" +def test_convert_walks_callable_schema_extractor() -> None: + """A callable schema tagged for "schema" extraction is resolved and walked.""" + from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor + + @schema_extractor("schema") + def dynamic_schema(value): + if value is SCHEMA_EXTRACT: + return cv.Schema({cv.Required("foo"): cv.string}) + return value + + config_var: dict = {} + _bls.convert(dynamic_schema, config_var, "/test") + + assert config_var["type"] == "schema" + assert "foo" in config_var["schema"]["config_vars"] + + def test_convert_keys_emits_heuristic_sensitive_marker() -> None: converted: dict = {} _bls.convert_keys(converted, {cv.Optional("password"): cv.string}, "/root") From 54c73bf1bcb863f2afa53f9c2bbe3c8de885d2bb Mon Sep 17 00:00:00 2001 From: "Kevin P. Fleming" Date: Mon, 8 Jun 2026 09:04:09 -0400 Subject: [PATCH 0352/1815] [ade7880][airthings_wave_base] Remove kpfleming from CODEOWNERS (#16858) --- CODEOWNERS | 3 +-- esphome/components/ade7880/__init__.py | 1 - esphome/components/airthings_wave_base/__init__.py | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index abe33f9467..6a81cc1d40 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -19,7 +19,6 @@ esphome/components/ac_dimmer/* @glmnet esphome/components/adc/* @esphome/core esphome/components/adc128s102/* @DeerMaximum esphome/components/addressable_light/* @justfalter -esphome/components/ade7880/* @kpfleming esphome/components/ade7953/* @angelnu esphome/components/ade7953_base/* @angelnu esphome/components/ade7953_i2c/* @angelnu @@ -28,7 +27,7 @@ esphome/components/ads1118/* @solomondg1 esphome/components/ags10/* @mak-42 esphome/components/aic3204/* @kbx81 esphome/components/airthings_ble/* @jeromelaban -esphome/components/airthings_wave_base/* @jeromelaban @kpfleming @ncareau +esphome/components/airthings_wave_base/* @jeromelaban @ncareau esphome/components/airthings_wave_mini/* @ncareau esphome/components/airthings_wave_plus/* @jeromelaban @precurse esphome/components/alarm_control_panel/* @grahambrown11 @hwstar diff --git a/esphome/components/ade7880/__init__.py b/esphome/components/ade7880/__init__.py index aed63c7dfa..e69de29bb2 100644 --- a/esphome/components/ade7880/__init__.py +++ b/esphome/components/ade7880/__init__.py @@ -1 +0,0 @@ -CODEOWNERS = ["@kpfleming"] diff --git a/esphome/components/airthings_wave_base/__init__.py b/esphome/components/airthings_wave_base/__init__.py index c3f3b8f199..dee26b524a 100644 --- a/esphome/components/airthings_wave_base/__init__.py +++ b/esphome/components/airthings_wave_base/__init__.py @@ -21,7 +21,7 @@ from esphome.const import ( UNIT_VOLT, ) -CODEOWNERS = ["@ncareau", "@jeromelaban", "@kpfleming"] +CODEOWNERS = ["@ncareau", "@jeromelaban"] DEPENDENCIES = ["ble_client"] From 36e043debb277f3830ca9ac566b08d143001b054 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Jun 2026 12:49:25 -0500 Subject: [PATCH 0353/1815] [tests] Fail component test merge on conflicting duplicate IDs (#16849) --- .github/workflows/ci.yml | 1 + script/analyze_component_buses.py | 9 +- script/ci_check_duplicate_test_ids.py | 214 ++++++++++++++++++ script/merge_component_configs.py | 164 ++++++++------ tests/components/adc/test.bk72xx-ard.yaml | 2 +- tests/components/adc/test.esp32-c2-idf.yaml | 2 +- tests/components/adc/test.esp32-c3-idf.yaml | 2 +- tests/components/adc/test.esp32-idf.yaml | 2 +- tests/components/adc/test.esp32-p4-idf.yaml | 2 +- tests/components/adc/test.esp32-s2-idf.yaml | 2 +- tests/components/adc/test.esp32-s3-idf.yaml | 2 +- tests/components/adc/test.esp8266-ard.yaml | 2 +- tests/components/adc/test.ln882x-ard.yaml | 2 +- tests/components/adc/test.rp2040-ard.yaml | 2 +- .../components/adc/test.rp2040-pico2-ard.yaml | 2 +- .../alarm_control_panel/common.yaml | 6 +- .../components/animation/test.esp32-idf.yaml | 2 +- .../animation/test.esp8266-ard.yaml | 2 +- .../components/animation/test.rp2040-ard.yaml | 2 +- tests/components/api/common-base.yaml | 2 +- tests/components/audio_file/common.yaml | 2 +- .../audio_file/validate.esp32-idf.yaml | 2 +- tests/components/axs15231/common.yaml | 4 +- .../components/axs15231/test.esp8266-ard.yaml | 4 +- tests/components/bang_bang/common.yaml | 12 +- tests/components/binary_sensor/common.yaml | 6 +- .../components/binary_sensor_map/common.yaml | 24 +- tests/components/ble_client/common.yaml | 4 +- tests/components/canbus/common.yaml | 6 +- tests/components/cd74hc4067/common.yaml | 6 +- tests/components/climate_ir_lg/common.yaml | 4 +- .../components/color_temperature/common.yaml | 8 +- tests/components/copy/common.yaml | 8 +- tests/components/ct_clamp/common.yaml | 4 +- tests/components/current_based/common.yaml | 6 +- tests/components/cwww/common.yaml | 4 +- tests/components/cwww/test.esp32-idf.yaml | 4 +- tests/components/cwww/test.esp8266-ard.yaml | 4 +- tests/components/cwww/test.rp2040-ard.yaml | 4 +- tests/components/duty_time/common.yaml | 4 +- tests/components/e131/test.rp2040-ard.yaml | 2 +- tests/components/ektf2232/common.yaml | 4 +- tests/components/endstop/common.yaml | 12 +- tests/components/esp32_can/common.yaml | 2 +- .../esp32_can/test.esp32-c6-idf.yaml | 6 +- tests/components/espnow/common.yaml | 6 +- .../components/fastled_clockless/common.yaml | 6 +- tests/components/fastled_spi/common.yaml | 6 +- tests/components/font/common.yaml | 6 +- tests/components/font/test.host.yaml | 6 +- tests/components/graph/common.yaml | 2 +- .../graphical_display_menu/common.yaml | 17 +- tests/components/gt911/common.yaml | 4 +- tests/components/homeassistant/common.yaml | 4 +- tests/components/image/test.esp32-idf.yaml | 2 +- tests/components/image/test.esp8266-ard.yaml | 2 +- tests/components/image/test.rp2040-ard.yaml | 2 +- tests/components/infrared/common.yaml | 2 +- .../components/integration/common-esp32.yaml | 4 +- .../integration/test.esp8266-ard.yaml | 4 +- .../integration/test.rp2040-ard.yaml | 4 +- tests/components/ir_rf_proxy/common-rx.yaml | 2 +- tests/components/lcd_gpio/common.yaml | 2 +- tests/components/lcd_menu/common.yaml | 8 +- tests/components/light/common.yaml | 2 +- tests/components/light/test.esp32-idf.yaml | 2 +- tests/components/light/test.esp8266-ard.yaml | 2 +- .../components/light/test.nrf52-adafruit.yaml | 4 +- tests/components/light/test.nrf52-mcumgr.yaml | 4 +- tests/components/light/test.rp2040-ard.yaml | 2 +- tests/components/lilygo_t5_47/common.yaml | 4 +- tests/components/lock/common.yaml | 4 +- tests/components/monochromatic/common.yaml | 4 +- tests/components/mpr121/common.yaml | 6 +- tests/components/mqtt/common.yaml | 20 +- .../components/mqtt_subscribe/common-ard.yaml | 4 +- .../components/mqtt_subscribe/common-idf.yaml | 4 +- tests/components/ntc/common.yaml | 10 +- tests/components/number/common.yaml | 4 +- .../components/online_image/common-esp32.yaml | 2 +- .../online_image/common-esp8266.yaml | 2 +- .../online_image/common-rp2040.yaml | 2 +- .../online_image/test.esp32-s3-ard.yaml | 2 +- .../online_image/test.esp32-s3-idf.yaml | 2 +- tests/components/output/common.yaml | 12 +- tests/components/pi4ioe5v6408/common.yaml | 2 +- tests/components/pid/common.yaml | 6 +- tests/components/prometheus/common.yaml | 6 +- tests/components/qspi_dbi/common.yaml | 2 +- .../remote_transmitter/common-buttons.yaml | 6 +- tests/components/resistance/common.yaml | 6 +- tests/components/rgb/common.yaml | 12 +- tests/components/rgbct/common.yaml | 20 +- tests/components/rgbw/common.yaml | 16 +- tests/components/rgbww/common.yaml | 20 +- .../rp2040_pio_led_strip/common.yaml | 2 +- tests/components/rp2040_pwm/common.yaml | 4 +- tests/components/sdl/common.yaml | 8 +- tests/components/speaker/common.yaml | 4 +- tests/components/speaker_source/common.yaml | 2 +- tests/components/speed/common.yaml | 6 +- tests/components/sprinkler/common.yaml | 12 +- tests/components/ssd1306_i2c/common.yaml | 2 +- tests/components/switch/common.yaml | 2 +- tests/components/sx126x/common.yaml | 4 +- tests/components/sx127x/common.yaml | 4 +- tests/components/template/common-base.yaml | 40 ++-- tests/components/tlc5947/common.yaml | 4 +- tests/components/tlc5971/common.yaml | 4 +- tests/components/tt21100/common.yaml | 4 +- tests/components/uart/test.esp32-idf.yaml | 4 +- tests/components/udp/common.yaml | 4 +- tests/components/ufire_ec/common.yaml | 6 +- tests/components/ufire_ise/common.yaml | 4 +- tests/components/web_server_idf/common.yaml | 4 +- tests/components/wk2132_i2c/common.yaml | 2 +- tests/components/wk2132_spi/common.yaml | 2 +- tests/components/wk2168_i2c/common.yaml | 2 +- tests/components/wk2168_spi/common.yaml | 2 +- tests/components/wk2204_i2c/common.yaml | 2 +- tests/components/wk2204_spi/common.yaml | 2 +- tests/components/wk2212_i2c/common.yaml | 2 +- tests/components/wk2212_spi/common.yaml | 2 +- .../test_ci_check_duplicate_test_ids.py | 114 ++++++++++ tests/script/test_merge_component_configs.py | 101 +++++++++ 125 files changed, 836 insertions(+), 372 deletions(-) create mode 100755 script/ci_check_duplicate_test_ids.py create mode 100644 tests/script/test_ci_check_duplicate_test_ids.py create mode 100644 tests/script/test_merge_component_configs.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0d604f248..3115b5b473 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,6 +113,7 @@ jobs: script/build_language_schema.py --check script/generate-esp32-boards.py --check script/generate-rp2040-boards.py --check + script/ci_check_duplicate_test_ids.py import-time: name: Check import esphome.__main__ time diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index a343e34328..8eb80d9943 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -39,8 +39,13 @@ from helpers import BASE_BUS_COMPONENTS, is_validate_only_file from esphome import yaml_util from esphome.config_helpers import Extend, Remove -# Path to common bus configs -COMMON_BUS_PATH = Path("tests/test_build_components/common") +# Path to common bus configs (resolved relative to this file, not the CWD) +COMMON_BUS_PATH = ( + Path(__file__).resolve().parent.parent + / "tests" + / "test_build_components" + / "common" +) # Package dependencies - maps packages to the packages they include # When a component uses a package on the left, it automatically gets diff --git a/script/ci_check_duplicate_test_ids.py b/script/ci_check_duplicate_test_ids.py new file mode 100755 index 0000000000..13da66c9b1 --- /dev/null +++ b/script/ci_check_duplicate_test_ids.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Fail when two component test fixtures define the same id with different content. + +Component tests are merged and built in groups in CI (see +``script/merge_component_configs.py``). When two components declare the same id +under the same section but with different content, the merge keeps the first and +drops the rest, which can make a cross-reference resolve to an incompatible +entity (this is what broke the i2s_audio speaker tests). That only surfaces when +the two components happen to land in the same group, often in an unrelated PR +long after the duplicate was written. + +This script is the complete, batch-independent guard: it scans every component's +``test..yaml`` per platform and reports any id that is defined by more +than one component with differing content, so a collision fails the PR that +introduces it and names the exact id and components. + +To stay byte-for-byte consistent with what the merge actually does (so the guard +never disagrees with the build), it reuses the merge's own helpers: + +* ``prefix_substitutions_in_dict`` -- the merge prefixes every component's + substitution references with the component name before deduplicating, so e.g. + ``pin: ${pin}`` in two components becomes ``${a_pin}`` and ``${b_pin}`` and + conflicts. We apply the same prefixing; otherwise a shared id whose only + difference is a substitution looks identical here but conflicts at merge time. +* ``deduplicate_by_id`` -- the actual merge comparison (including the + ``INTENTIONALLY_SHARED_IDS`` allowlist for deliberately shared singletons such + as ``sntp_time``). We feed each shared id's prefixed items straight through it + and treat a raised ``ValueError`` as a conflict, so this check and the merge + can never diverge. + +``packages:`` are left as opaque ``!include`` objects by the loader -- exactly as +the merge sees them at dedup time -- so package-provided bus ids (``i2c_bus`` ...) +are not compared here, matching the merge, which re-adds those packages once. +""" + +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Iterator +from dataclasses import dataclass, field +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from esphome.core import EsphomeError # noqa: E402 +from script.merge_component_configs import ( # noqa: E402 + deduplicate_by_id, + load_yaml_file, + prepare_component_body, +) + +# Resolved relative to this file (not the CWD) so the scan cannot silently cover +# nothing when run from a different directory. +TESTS_DIR = Path(__file__).resolve().parent.parent / "tests" / "components" + + +def _collect_ids( + data: object, + path: tuple[str, ...], + out: dict[tuple[tuple[str, ...], object], object], +) -> None: + """Record (dict_path, id) -> item for id-bearing items in dict-reachable lists. + + Keyed by the full dict path (not just the immediate key) so items under + different paths that happen to share a list key name are never compared. Only + lists reached purely through dict keys are recorded: once the merge + concatenates a list, items from different components live in separate elements, + so anything deeper is never compared across components (matching how + ``merge_config`` combines bodies). Ids keep their original type so ``5`` and + ``"5"`` stay distinct, exactly as ``deduplicate_by_id`` treats them; an + unhashable id (rare) falls back to its ``repr`` so it can still be grouped. + """ + if not isinstance(data, dict): + return + for key, value in data.items(): + new_path = path + (key,) + if isinstance(value, list): + for item in value: + if isinstance(item, dict) and "id" in item: + item_id = item["id"] + try: + hash(item_id) + except TypeError: + item_id = repr(item_id) + out[(new_path, item_id)] = item + elif isinstance(value, dict): + _collect_ids(value, new_path, out) + + +def _discover_platforms() -> set[str]: + platforms: set[str] = set() + for test_file in TESTS_DIR.glob("*/test.*.yaml"): + # test..yaml -> platform is the middle dotted part + parts = test_file.name.split(".") + if len(parts) == 3: + platforms.add(parts[1]) + return platforms + + +def _load_components( + platform: str, parse_errors: list[str] +) -> Iterator[tuple[str, object]]: + """Yield (component, prefixed config) for each component testing this platform. + + Each body is prepared with ``prepare_component_body`` (the same helper the + merge uses: it expands component-specific package includes and prefixes + substitutions), so the comparison sees what the build merges. Fixtures that + fail to parse are recorded in ``parse_errors`` so the run can fail rather than + silently skip them. + """ + for comp_dir in sorted(TESTS_DIR.iterdir()): + test_file = comp_dir / f"test.{platform}.yaml" + if not comp_dir.is_dir() or not test_file.exists(): + continue + try: + data = load_yaml_file(test_file) + except EsphomeError as err: + parse_errors.append(str(test_file)) + print(f"ERROR: could not parse {test_file}: {err}", file=sys.stderr) + continue + yield comp_dir.name, prepare_component_body(data, comp_dir.name, comp_dir) + + +@dataclass +class ScanResult: + """Outcome of a scan. A caller cannot observe a clean result while files were + skipped or nothing was scanned -- all three fields are reported together.""" + + conflicts: list[str] = field(default_factory=list) + parse_errors: list[str] = field(default_factory=list) + components_scanned: int = 0 + + +def scan() -> ScanResult: + """Scan every component's base test fixture and report cross-component id conflicts. + + Only base ``test..yaml`` fixtures are scanned because only those are + combined by ``merge_component_configs`` in grouped CI builds; variant + (``test-*.yaml``) fixtures are built individually and never cross-merged. + """ + result = ScanResult() + for platform in sorted(_discover_platforms()): + # (dict_path, id) -> {component: prefixed_item} + groups: dict[tuple[tuple[str, ...], object], dict[str, object]] = defaultdict( + dict + ) + for component, data in _load_components(platform, result.parse_errors): + result.components_scanned += 1 + collected: dict[tuple[tuple[str, ...], object], object] = {} + _collect_ids(data, (), collected) + for key, item in collected.items(): + groups[key][component] = item + + for (path, id_), by_component in sorted( + groups.items(), key=lambda kv: (kv[0][0], str(kv[0][1])) + ): + if len(by_component) < 2: + continue + # Delegate the decision to the merge's own deduplication so this guard + # can never disagree with what the build does. + try: + deduplicate_by_id({path[-1]: list(by_component.values())}) + except ValueError: + result.conflicts.append( + f"[{platform}] id '{id_}' under '{'.'.join(path)}' is defined " + f"differently by: {', '.join(sorted(by_component))}" + ) + return result + + +def main() -> int: + result = scan() + if result.conflicts: + print("Conflicting test component ids found:\n") + for line in result.conflicts: + print(f" - {line}") + print( + "\nGive each component a unique id (e.g. '_'), or add the " + "id to INTENTIONALLY_SHARED_IDS in script/merge_component_configs.py if " + "it is a deliberately shared singleton." + ) + + if result.parse_errors: + # A fixture we could not parse was never scanned, so the run is not a + # clean pass even if no conflicts were found among the rest. + print( + f"\n{len(result.parse_errors)} test fixture(s) could not be parsed and " + "were not checked:" + ) + for path in result.parse_errors: + print(f" - {path}") + + if result.components_scanned == 0: + # A scan that covered nothing is a false green -- the whole point of the + # guard is defeated. Fail loudly (wrong working directory or layout change). + print( + f"\nERROR: scanned 0 component test fixtures under {TESTS_DIR}; " + "the guard covered nothing.", + file=sys.stderr, + ) + + if result.conflicts or result.parse_errors or result.components_scanned == 0: + return 1 + + print( + f"No conflicting test component ids found " + f"({result.components_scanned} fixtures scanned)." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/merge_component_configs.py b/script/merge_component_configs.py index a952ecff16..5eeeafac2a 100755 --- a/script/merge_component_configs.py +++ b/script/merge_component_configs.py @@ -161,18 +161,46 @@ def prefix_substitutions_in_dict( return data +# (section, id) pairs that several components intentionally share. ESPHome +# treats these as a single instance when merged, so duplicates with differing +# content are expected and must not be flagged as accidental collisions. Keyed on +# the section as well as the id so a generic name (e.g. `ldo_id`) is only exempt +# in its intended section -- an accidental collision on the same name elsewhere +# is still caught. +INTENTIONALLY_SHARED_IDS = frozenset( + { + # Several components each declare an `sntp_time` clock; ESPHome merges + # them into one time source. + ("time", "sntp_time"), + # esp_ldo and mipi_dsi both configure the channel-3 internal LDO on the + # ESP32-P4; only one LDO per channel may exist, so the shared id lets the + # merge collapse them into a single LDO. + ("esp_ldo", "ldo_id"), + } +) + + def deduplicate_by_id(data: dict) -> dict: """Deduplicate list items with the same ID. - Keeps only the first occurrence of each ID. If items with the same ID - are identical, this silently deduplicates. If they differ, the first - one is kept (ESPHome's validation will catch if this causes issues). + Identical items sharing an ID (e.g. a shared bus from a common package pulled + in by several components) are collapsed to the first occurrence. Two items + that share an ID but differ in content are a real conflict: when merged, the + first silently wins and the others are dropped, which can make a + cross-reference resolve to an incompatible entity. Rather than defer that to + downstream validation (where it surfaces as a confusing, order-dependent + failure in an unrelated build), raise immediately so the offending ID is + named. Ids in ``INTENTIONALLY_SHARED_IDS`` are deliberately shared singletons + and keep their collapse behaviour. Args: data: Parsed config dictionary Returns: Config with deduplicated lists + + Raises: + ValueError: If two items share an ID but have different content. """ if not isinstance(data, dict): return data @@ -181,16 +209,25 @@ def deduplicate_by_id(data: dict) -> dict: for key, value in data.items(): if isinstance(value, list): # Check for items with 'id' field - seen_ids = set() + seen_items: dict[str, Any] = {} deduped_list = [] for item in value: if isinstance(item, dict) and "id" in item: item_id = item["id"] - if item_id not in seen_ids: - seen_ids.add(item_id) + if item_id not in seen_items: + seen_items[item_id] = item deduped_list.append(item) - # else: skip duplicate ID (keep first occurrence) + elif (key, item_id) in INTENTIONALLY_SHARED_IDS: + # Deliberately shared singleton -> keep first occurrence. + pass + elif item != seen_items[item_id]: + raise ValueError( + f"Conflicting definitions for id '{item_id}' under " + f"'{key}' when merging test configs; give each " + f"component a unique id" + ) + # else: identical duplicate (e.g. shared bus package) -> skip else: # No ID, just add it deduped_list.append(item) @@ -205,6 +242,55 @@ def deduplicate_by_id(data: dict) -> dict: return result +def prepare_component_body(comp_data: dict, comp_name: str, comp_dir: Path) -> dict: + """Return a component's test body as it enters the merge. + + Expands component-specific package includes inline (common bus packages are + left for the merge to re-add once), applies ESPHome's top-level-substitutions + -override-package-substitutions rule, then prefixes every substitution + reference with the component name. Shared by ``merge_component_configs`` and + the duplicate-id guard (``script/ci_check_duplicate_test_ids.py``) so the + guard compares exactly what the build merges. + """ + # $component_dir resolves to the component's absolute path. + comp_abs_dir = str(comp_dir.absolute()) + + # Top-level substitutions override package substitutions, so capture them + # before expanding packages can introduce their own. + top_level_subs = ( + comp_data["substitutions"].copy() + if isinstance(comp_data.get("substitutions"), dict) + else {} + ) + + packages_value = comp_data.get("packages") + if isinstance(packages_value, dict): + common_bus_packages = get_common_bus_packages() + for pkg_name, pkg_value in list(packages_value.items()): + if pkg_name in common_bus_packages: + continue + if isinstance(pkg_value, yaml_util.IncludeFile): + pkg_value = pkg_value.load() + if isinstance(pkg_value, dict): + comp_data = merge_config(comp_data, pkg_value) + elif isinstance(packages_value, list): + for pkg_value in packages_value: + if isinstance(pkg_value, yaml_util.IncludeFile): + pkg_value = pkg_value.load() + if isinstance(pkg_value, dict): + comp_data = merge_config(comp_data, pkg_value) + # Common bus packages are re-added once by the caller; drop them here. + comp_data.pop("packages", None) + + subs = comp_data.get("substitutions") or {} + subs.update(top_level_subs) + prefixed_subs = {f"{comp_name}_{name}": value for name, value in subs.items()} + prefixed_subs[f"{comp_name}_component_dir"] = comp_abs_dir + comp_data["substitutions"] = prefixed_subs + + return prefix_substitutions_in_dict(comp_data, comp_name) + + def merge_component_configs( component_names: list[str], platform: str, @@ -266,67 +352,9 @@ def merge_component_configs( # New package type - add it all_packages[pkg_name] = pkg_config - # Handle $component_dir by replacing with absolute path - # This allows components that use local file references to be grouped - comp_abs_dir = str(comp_dir.absolute()) - - # Save top-level substitutions BEFORE expanding packages - # In ESPHome, top-level substitutions override package substitutions - top_level_subs = ( - comp_data["substitutions"].copy() - if "substitutions" in comp_data and comp_data["substitutions"] is not None - else {} - ) - - # Expand packages - but we'll restore substitution priority after - if "packages" in comp_data: - packages_value = comp_data["packages"] - - if isinstance(packages_value, dict): - # Dict format - check each package - common_bus_packages = get_common_bus_packages() - for pkg_name, pkg_value in list(packages_value.items()): - if pkg_name in common_bus_packages: - continue - # Resolve deferred !include files before checking type - if isinstance(pkg_value, yaml_util.IncludeFile): - pkg_value = pkg_value.load() - if not isinstance(pkg_value, dict): - continue - # Component-specific package - expand its content into top level - comp_data = merge_config(comp_data, pkg_value) - elif isinstance(packages_value, list): - # List format - expand all package includes - for pkg_value in packages_value: - # Resolve deferred !include files before checking type - if isinstance(pkg_value, yaml_util.IncludeFile): - pkg_value = pkg_value.load() - if not isinstance(pkg_value, dict): - continue - comp_data = merge_config(comp_data, pkg_value) - - # Remove all packages (common will be re-added at the end) - del comp_data["packages"] - - # Restore top-level substitution priority - # Top-level substitutions override any from packages - if "substitutions" not in comp_data or comp_data["substitutions"] is None: - comp_data["substitutions"] = {} - - # Merge: package subs as base, top-level subs override - comp_data["substitutions"].update(top_level_subs) - - # Now prefix the final merged substitutions - comp_data["substitutions"] = { - f"{comp_name}_{sub_name}": sub_value - for sub_name, sub_value in comp_data["substitutions"].items() - } - - # Add component_dir substitution with absolute path for this component - comp_data["substitutions"][f"{comp_name}_component_dir"] = comp_abs_dir - - # Prefix substitution references throughout the config - comp_data = prefix_substitutions_in_dict(comp_data, comp_name) + # Expand component-specific packages and prefix substitutions, exactly as + # the duplicate-id guard does, so both see the same body. + comp_data = prepare_component_body(comp_data, comp_name, comp_dir) # Use ESPHome's merge_config to merge this component into the result # merge_config handles list merging with ID-based deduplication automatically diff --git a/tests/components/adc/test.bk72xx-ard.yaml b/tests/components/adc/test.bk72xx-ard.yaml index 0645333a81..09ef0e1fad 100644 --- a/tests/components/adc/test.bk72xx-ard.yaml +++ b/tests/components/adc/test.bk72xx-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: P23 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-c2-idf.yaml b/tests/components/adc/test.esp32-c2-idf.yaml index e764f0fe21..a3019466b5 100644 --- a/tests/components/adc/test.esp32-c2-idf.yaml +++ b/tests/components/adc/test.esp32-c2-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-c3-idf.yaml b/tests/components/adc/test.esp32-c3-idf.yaml index e764f0fe21..a3019466b5 100644 --- a/tests/components/adc/test.esp32-c3-idf.yaml +++ b/tests/components/adc/test.esp32-c3-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-idf.yaml b/tests/components/adc/test.esp32-idf.yaml index ff1e3bb919..f31e0e087d 100644 --- a/tests/components/adc/test.esp32-idf.yaml +++ b/tests/components/adc/test.esp32-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: A0 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-p4-idf.yaml b/tests/components/adc/test.esp32-p4-idf.yaml index b77dc299c2..77cf50d17c 100644 --- a/tests/components/adc/test.esp32-p4-idf.yaml +++ b/tests/components/adc/test.esp32-p4-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: GPIO16 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-s2-idf.yaml b/tests/components/adc/test.esp32-s2-idf.yaml index e764f0fe21..a3019466b5 100644 --- a/tests/components/adc/test.esp32-s2-idf.yaml +++ b/tests/components/adc/test.esp32-s2-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-s3-idf.yaml b/tests/components/adc/test.esp32-s3-idf.yaml index e764f0fe21..a3019466b5 100644 --- a/tests/components/adc/test.esp32-s3-idf.yaml +++ b/tests/components/adc/test.esp32-s3-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp8266-ard.yaml b/tests/components/adc/test.esp8266-ard.yaml index 4cc865bb5d..617464818b 100644 --- a/tests/components/adc/test.esp8266-ard.yaml +++ b/tests/components/adc/test.esp8266-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: VCC name: ADC Test sensor diff --git a/tests/components/adc/test.ln882x-ard.yaml b/tests/components/adc/test.ln882x-ard.yaml index face38b647..d899259773 100644 --- a/tests/components/adc/test.ln882x-ard.yaml +++ b/tests/components/adc/test.ln882x-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: A5 name: ADC Test sensor diff --git a/tests/components/adc/test.rp2040-ard.yaml b/tests/components/adc/test.rp2040-ard.yaml index 4cc865bb5d..617464818b 100644 --- a/tests/components/adc/test.rp2040-ard.yaml +++ b/tests/components/adc/test.rp2040-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: VCC name: ADC Test sensor diff --git a/tests/components/adc/test.rp2040-pico2-ard.yaml b/tests/components/adc/test.rp2040-pico2-ard.yaml index 4cc865bb5d..617464818b 100644 --- a/tests/components/adc/test.rp2040-pico2-ard.yaml +++ b/tests/components/adc/test.rp2040-pico2-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: VCC name: ADC Test sensor diff --git a/tests/components/alarm_control_panel/common.yaml b/tests/components/alarm_control_panel/common.yaml index 39d5739255..327234d6ca 100644 --- a/tests/components/alarm_control_panel/common.yaml +++ b/tests/components/alarm_control_panel/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: gpio - id: bin1 + id: alarm_control_panel_bin1 pin: 1 alarm_control_panel: @@ -18,7 +18,7 @@ alarm_control_panel: pending_time: 15s trigger_time: 30s binary_sensors: - - input: bin1 + - input: alarm_control_panel_bin1 bypass_armed_home: true bypass_armed_night: true bypass_auto: true @@ -39,7 +39,7 @@ alarm_control_panel: pending_time: 15s trigger_time: 30s binary_sensors: - - input: bin1 + - input: alarm_control_panel_bin1 bypass_armed_home: true bypass_armed_night: true bypass_auto: true diff --git a/tests/components/animation/test.esp32-idf.yaml b/tests/components/animation/test.esp32-idf.yaml index c28e9584dd..b844f5ae92 100644 --- a/tests/components/animation/test.esp32-idf.yaml +++ b/tests/components/animation/test.esp32-idf.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: animation_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 12 diff --git a/tests/components/animation/test.esp8266-ard.yaml b/tests/components/animation/test.esp8266-ard.yaml index 11a7117d91..a7937ffca2 100644 --- a/tests/components/animation/test.esp8266-ard.yaml +++ b/tests/components/animation/test.esp8266-ard.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: animation_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 5 diff --git a/tests/components/animation/test.rp2040-ard.yaml b/tests/components/animation/test.rp2040-ard.yaml index 2c99e937f3..2cbb254adf 100644 --- a/tests/components/animation/test.rp2040-ard.yaml +++ b/tests/components/animation/test.rp2040-ard.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: animation_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 20 diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index ca86445777..060254990d 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -296,7 +296,7 @@ api: event: - platform: template name: Test Event - id: test_event + id: api_test_event event_types: - single_click - double_click diff --git a/tests/components/audio_file/common.yaml b/tests/components/audio_file/common.yaml index e7f55b4806..02cb3814f2 100644 --- a/tests/components/audio_file/common.yaml +++ b/tests/components/audio_file/common.yaml @@ -1,5 +1,5 @@ audio_file: - - id: test_audio + - id: audio_file_test_audio file: type: local path: $component_dir/test.wav diff --git a/tests/components/audio_file/validate.esp32-idf.yaml b/tests/components/audio_file/validate.esp32-idf.yaml index 085f853c8e..1d8d4646fa 100644 --- a/tests/components/audio_file/validate.esp32-idf.yaml +++ b/tests/components/audio_file/validate.esp32-idf.yaml @@ -1,5 +1,5 @@ audio_file: - - id: test_audio + - id: audio_file_test_audio file: type: local path: $component_dir/test.wav diff --git a/tests/components/axs15231/common.yaml b/tests/components/axs15231/common.yaml index d4fd3becbb..03e82ab26e 100644 --- a/tests/components/axs15231/common.yaml +++ b/tests/components/axs15231/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_i2c_display + id: axs15231_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: 19 pages: @@ -13,6 +13,6 @@ touchscreen: - platform: axs15231 i2c_id: i2c_bus id: axs15231_touchscreen - display: ssd1306_i2c_display + display: axs15231_ssd1306_i2c_display interrupt_pin: 20 reset_pin: 18 diff --git a/tests/components/axs15231/test.esp8266-ard.yaml b/tests/components/axs15231/test.esp8266-ard.yaml index eb599da773..245b87bec9 100644 --- a/tests/components/axs15231/test.esp8266-ard.yaml +++ b/tests/components/axs15231/test.esp8266-ard.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_display + id: axs15231_ssd1306_display model: SSD1306_128X64 reset_pin: 13 pages: @@ -15,5 +15,5 @@ display: touchscreen: - platform: axs15231 i2c_id: i2c_bus - display: ssd1306_display + display: axs15231_ssd1306_display interrupt_pin: 12 diff --git a/tests/components/bang_bang/common.yaml b/tests/components/bang_bang/common.yaml index 5882025191..28798f8173 100644 --- a/tests/components/bang_bang/common.yaml +++ b/tests/components/bang_bang/common.yaml @@ -1,6 +1,6 @@ switch: - platform: template - id: template_switch1 + id: bang_bang_template_switch1 optimistic: true - platform: template id: template_switch2 @@ -8,7 +8,7 @@ switch: sensor: - platform: template - id: template_sensor1 + id: bang_bang_template_sensor1 lambda: |- if (millis() > 10000) { return 42.0; @@ -20,16 +20,16 @@ sensor: climate: - platform: bang_bang name: Bang Bang Climate - sensor: template_sensor1 - humidity_sensor: template_sensor1 + sensor: bang_bang_template_sensor1 + humidity_sensor: bang_bang_template_sensor1 default_target_temperature_low: 18°C default_target_temperature_high: 24°C idle_action: - - switch.turn_on: template_switch1 + - switch.turn_on: bang_bang_template_switch1 cool_action: - switch.turn_on: template_switch2 heat_action: - - switch.turn_on: template_switch1 + - switch.turn_on: bang_bang_template_switch1 away_config: default_target_temperature_low: 16°C default_target_temperature_high: 20°C diff --git a/tests/components/binary_sensor/common.yaml b/tests/components/binary_sensor/common.yaml index e3fd159b08..4f4cf6ea59 100644 --- a/tests/components/binary_sensor/common.yaml +++ b/tests/components/binary_sensor/common.yaml @@ -1,7 +1,7 @@ binary_sensor: - platform: template trigger_on_initial_state: true - id: some_binary_sensor + id: binary_sensor_some_binary_sensor name: "Random binary" lambda: return (random_uint32() & 1) == 0; filters: @@ -21,7 +21,7 @@ binary_sensor: time_off: 100ms time_on: 400ms - lambda: |- - if (id(some_binary_sensor).state) { + if (id(binary_sensor_some_binary_sensor).state) { return x; } return {}; @@ -36,7 +36,7 @@ binary_sensor: - logger.log: format: "New state is %s" args: ['x.has_value() ? ONOFF(x) : "Unknown"'] - - binary_sensor.invalidate_state: some_binary_sensor + - binary_sensor.invalidate_state: binary_sensor_some_binary_sensor # Test autorepeat with default configuration (no timings) - platform: template diff --git a/tests/components/binary_sensor_map/common.yaml b/tests/components/binary_sensor_map/common.yaml index c054022583..667d0be9e7 100644 --- a/tests/components/binary_sensor_map/common.yaml +++ b/tests/components/binary_sensor_map/common.yaml @@ -1,20 +1,20 @@ binary_sensor: - platform: template - id: bin1 + id: binary_sensor_map_bin1 lambda: |- if (millis() > 10000) { return true; } return false; - platform: template - id: bin2 + id: binary_sensor_map_bin2 lambda: |- if (millis() > 20000) { return true; } return false; - platform: template - id: bin3 + id: binary_sensor_map_bin3 lambda: |- if (millis() > 30000) { return true; @@ -26,33 +26,33 @@ sensor: name: Binary Sensor Map Group type: group channels: - - binary_sensor: bin1 + - binary_sensor: binary_sensor_map_bin1 value: 10.0 - - binary_sensor: bin2 + - binary_sensor: binary_sensor_map_bin2 value: 15.0 - - binary_sensor: bin3 + - binary_sensor: binary_sensor_map_bin3 value: 100.0 - platform: binary_sensor_map name: Binary Sensor Map Sum type: sum channels: - - binary_sensor: bin1 + - binary_sensor: binary_sensor_map_bin1 value: 10.0 - - binary_sensor: bin2 + - binary_sensor: binary_sensor_map_bin2 value: 15.0 - - binary_sensor: bin3 + - binary_sensor: binary_sensor_map_bin3 value: 100.0 - platform: binary_sensor_map name: Binary Sensor Map Bayesian type: bayesian prior: 0.4 observations: - - binary_sensor: bin1 + - binary_sensor: binary_sensor_map_bin1 prob_given_true: 0.9 prob_given_false: 0.4 - - binary_sensor: bin2 + - binary_sensor: binary_sensor_map_bin2 prob_given_true: 0.7 prob_given_false: 0.05 - - binary_sensor: bin3 + - binary_sensor: binary_sensor_map_bin3 prob_given_true: 0.8 prob_given_false: 0.2 diff --git a/tests/components/ble_client/common.yaml b/tests/components/ble_client/common.yaml index 4ea1dd60f3..4ed6ad7fc9 100644 --- a/tests/components/ble_client/common.yaml +++ b/tests/components/ble_client/common.yaml @@ -56,7 +56,7 @@ sensor: number: - platform: template name: "Test Number" - id: test_number + id: ble_client_test_number optimistic: true min_value: 0 max_value: 255 @@ -72,5 +72,5 @@ button: service_uuid: "abcd1234-abcd-1234-abcd-abcd12345678" characteristic_uuid: "abcd1235-abcd-1234-abcd-abcd12345678" value: !lambda |- - uint8_t val = (uint8_t)id(test_number).state; + uint8_t val = (uint8_t)id(ble_client_test_number).state; return std::vector{0xAA, val, 0xBB}; diff --git a/tests/components/canbus/common.yaml b/tests/components/canbus/common.yaml index e779f7f078..3ba3564608 100644 --- a/tests/components/canbus/common.yaml +++ b/tests/components/canbus/common.yaml @@ -1,6 +1,6 @@ canbus: - platform: esp32_can - id: esp32_internal_can + id: canbus_esp32_internal_can rx_pin: 4 tx_pin: 5 can_id: 4 @@ -40,7 +40,7 @@ canbus: number: - platform: template name: "Test Number" - id: test_number + id: canbus_test_number optimistic: true min_value: 0 max_value: 255 @@ -62,5 +62,5 @@ button: - canbus.send: !lambda return {0, 1, 2}; # Test canbus.send with lambda that references a component (function pointer) - canbus.send: !lambda |- - uint8_t val = (uint8_t)id(test_number).state; + uint8_t val = (uint8_t)id(canbus_test_number).state; return std::vector{0xAA, val, 0xBB}; diff --git a/tests/components/cd74hc4067/common.yaml b/tests/components/cd74hc4067/common.yaml index 9afb39cd31..c217ce9d39 100644 --- a/tests/components/cd74hc4067/common.yaml +++ b/tests/components/cd74hc4067/common.yaml @@ -6,13 +6,13 @@ cd74hc4067: sensor: - platform: adc - id: esp_adc_sensor + id: cd74hc4067_esp_adc_sensor pin: ${pin} - platform: cd74hc4067 id: cd74hc4067_adc_0 number: 0 - sensor: esp_adc_sensor + sensor: cd74hc4067_esp_adc_sensor - platform: cd74hc4067 id: cd74hc4067_adc_1 number: 1 - sensor: esp_adc_sensor + sensor: cd74hc4067_esp_adc_sensor diff --git a/tests/components/climate_ir_lg/common.yaml b/tests/components/climate_ir_lg/common.yaml index 37011b16ee..e0bc185d2c 100644 --- a/tests/components/climate_ir_lg/common.yaml +++ b/tests/components/climate_ir_lg/common.yaml @@ -1,6 +1,6 @@ sensor: - platform: template - id: temp_sensor + id: climate_ir_lg_temp_sensor lambda: return 22.0; update_interval: 60s - platform: template @@ -12,5 +12,5 @@ climate: - platform: climate_ir_lg name: LG Climate transmitter_id: xmitr - sensor: temp_sensor + sensor: climate_ir_lg_temp_sensor humidity_sensor: humidity_sensor diff --git a/tests/components/color_temperature/common.yaml b/tests/components/color_temperature/common.yaml index fe0c5bf917..0db54d10d0 100644 --- a/tests/components/color_temperature/common.yaml +++ b/tests/components/color_temperature/common.yaml @@ -1,15 +1,15 @@ output: - platform: ${light_platform} - id: light_output_1 + id: color_temperature_light_output_1 pin: ${pin_o1} - platform: ${light_platform} - id: light_output_2 + id: color_temperature_light_output_2 pin: ${pin_o2} light: - platform: color_temperature name: Lights - color_temperature: light_output_1 - brightness: light_output_2 + color_temperature: color_temperature_light_output_1 + brightness: color_temperature_light_output_2 cold_white_color_temperature: 153 mireds warm_white_color_temperature: 500 mireds diff --git a/tests/components/copy/common.yaml b/tests/components/copy/common.yaml index a376004b2f..cbd056f070 100644 --- a/tests/components/copy/common.yaml +++ b/tests/components/copy/common.yaml @@ -1,17 +1,17 @@ output: - platform: ${pwm_platform} - id: fan_output_1 + id: copy_fan_output_1 pin: ${pin} fan: - platform: speed - id: fan_speed - output: fan_output_1 + id: copy_fan_speed + output: copy_fan_output_1 preset_modes: - Eco - Turbo - platform: copy - source_id: fan_speed + source_id: copy_fan_speed name: Fan Speed Copy select: diff --git a/tests/components/ct_clamp/common.yaml b/tests/components/ct_clamp/common.yaml index 3ed9678447..656b1971a5 100644 --- a/tests/components/ct_clamp/common.yaml +++ b/tests/components/ct_clamp/common.yaml @@ -1,9 +1,9 @@ sensor: - platform: adc - id: esp_adc_sensor + id: ct_clamp_esp_adc_sensor pin: ${pin} - platform: ct_clamp - sensor: esp_adc_sensor + sensor: ct_clamp_esp_adc_sensor name: CT Clamp sample_duration: 500ms update_interval: 5s diff --git a/tests/components/current_based/common.yaml b/tests/components/current_based/common.yaml index 503c4596e9..139571ccec 100644 --- a/tests/components/current_based/common.yaml +++ b/tests/components/current_based/common.yaml @@ -31,7 +31,7 @@ sensor: switch: - platform: template - id: template_switch1 + id: current_based_template_switch1 optimistic: true - platform: template id: template_switch2 @@ -46,7 +46,7 @@ cover: open_obstacle_current_threshold: 0.8 open_duration: 12s open_action: - - switch.turn_on: template_switch1 + - switch.turn_on: current_based_template_switch1 close_sensor: ade7953_current_b close_moving_current_threshold: 0.5 close_obstacle_current_threshold: 0.8 @@ -54,7 +54,7 @@ cover: close_action: - switch.turn_on: template_switch2 stop_action: - - switch.turn_off: template_switch1 + - switch.turn_off: current_based_template_switch1 - switch.turn_off: template_switch2 obstacle_rollback: 30% start_sensing_delay: 0.8s diff --git a/tests/components/cwww/common.yaml b/tests/components/cwww/common.yaml index 7fa5ab668c..bbb6c9182b 100644 --- a/tests/components/cwww/common.yaml +++ b/tests/components/cwww/common.yaml @@ -1,8 +1,8 @@ light: - platform: cwww name: CWWW Light - cold_white: light_output_1 - warm_white: light_output_2 + cold_white: cwww_light_output_1 + warm_white: cwww_light_output_2 cold_white_color_temperature: 153 mireds warm_white_color_temperature: 500 mireds constant_brightness: true diff --git a/tests/components/cwww/test.esp32-idf.yaml b/tests/components/cwww/test.esp32-idf.yaml index 01edf0b0b5..0665879b08 100644 --- a/tests/components/cwww/test.esp32-idf.yaml +++ b/tests/components/cwww/test.esp32-idf.yaml @@ -5,11 +5,11 @@ substitutions: output: - platform: ${light_platform} - id: light_output_1 + id: cwww_light_output_1 pin: ${pin_o1} channel: 0 - platform: ${light_platform} - id: light_output_2 + id: cwww_light_output_2 pin: ${pin_o2} channel: 1 phase_angle: 180° diff --git a/tests/components/cwww/test.esp8266-ard.yaml b/tests/components/cwww/test.esp8266-ard.yaml index 49d73b7d3d..bb1868fdef 100644 --- a/tests/components/cwww/test.esp8266-ard.yaml +++ b/tests/components/cwww/test.esp8266-ard.yaml @@ -5,10 +5,10 @@ substitutions: output: - platform: ${light_platform} - id: light_output_1 + id: cwww_light_output_1 pin: ${pin_o1} - platform: ${light_platform} - id: light_output_2 + id: cwww_light_output_2 pin: ${pin_o2} <<: !include common.yaml diff --git a/tests/components/cwww/test.rp2040-ard.yaml b/tests/components/cwww/test.rp2040-ard.yaml index ba8e0ad071..27fc930b68 100644 --- a/tests/components/cwww/test.rp2040-ard.yaml +++ b/tests/components/cwww/test.rp2040-ard.yaml @@ -5,10 +5,10 @@ substitutions: output: - platform: ${light_platform} - id: light_output_1 + id: cwww_light_output_1 pin: ${pin_o1} - platform: ${light_platform} - id: light_output_2 + id: cwww_light_output_2 pin: ${pin_o2} <<: !include common.yaml diff --git a/tests/components/duty_time/common.yaml b/tests/components/duty_time/common.yaml index 761d10f16a..12e4397c49 100644 --- a/tests/components/duty_time/common.yaml +++ b/tests/components/duty_time/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: template - id: bin1 + id: duty_time_bin1 lambda: |- if (millis() > 10000) { return true; @@ -10,4 +10,4 @@ binary_sensor: sensor: - platform: duty_time name: Duty Time - sensor: bin1 + sensor: duty_time_bin1 diff --git a/tests/components/e131/test.rp2040-ard.yaml b/tests/components/e131/test.rp2040-ard.yaml index 25fe3b6796..4593784ef9 100644 --- a/tests/components/e131/test.rp2040-ard.yaml +++ b/tests/components/e131/test.rp2040-ard.yaml @@ -2,7 +2,7 @@ light: - platform: rp2040_pio_led_strip - id: led_strip + id: e131_led_strip pin: 2 pio: 0 num_leds: 256 diff --git a/tests/components/ektf2232/common.yaml b/tests/components/ektf2232/common.yaml index 1c4d768b08..070b03eeb9 100644 --- a/tests/components/ektf2232/common.yaml +++ b/tests/components/ektf2232/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_i2c_display + id: ektf2232_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${display_reset_pin} pages: @@ -15,7 +15,7 @@ touchscreen: id: ektf2232_touchscreen interrupt_pin: ${interrupt_pin} reset_pin: ${touch_reset_pin} - display: ssd1306_i2c_display + display: ektf2232_ssd1306_i2c_display on_touch: - logger.log: format: Touch at (%d, %d) diff --git a/tests/components/endstop/common.yaml b/tests/components/endstop/common.yaml index b92b1e13b9..6f5cf61268 100644 --- a/tests/components/endstop/common.yaml +++ b/tests/components/endstop/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: template - id: bin1 + id: endstop_bin1 lambda: |- if (millis() > 10000) { return true; @@ -9,7 +9,7 @@ binary_sensor: switch: - platform: template - id: template_switch1 + id: endstop_template_switch1 optimistic: true - platform: template id: template_switch2 @@ -20,12 +20,12 @@ cover: id: endstop_cover name: Endstop Cover stop_action: - - switch.turn_on: template_switch1 - open_endstop: bin1 + - switch.turn_on: endstop_template_switch1 + open_endstop: endstop_bin1 open_action: - - switch.turn_on: template_switch1 + - switch.turn_on: endstop_template_switch1 open_duration: 5min - close_endstop: bin1 + close_endstop: endstop_bin1 close_action: - switch.turn_on: template_switch2 close_duration: 4.5min diff --git a/tests/components/esp32_can/common.yaml b/tests/components/esp32_can/common.yaml index 3b9b33c048..f15b609d84 100644 --- a/tests/components/esp32_can/common.yaml +++ b/tests/components/esp32_can/common.yaml @@ -13,7 +13,7 @@ esphome: canbus: - platform: esp32_can - id: esp32_internal_can + id: esp32_can_esp32_internal_can rx_pin: ${rx_pin} tx_pin: ${tx_pin} can_id: 4 diff --git a/tests/components/esp32_can/test.esp32-c6-idf.yaml b/tests/components/esp32_can/test.esp32-c6-idf.yaml index ac978482fc..c548b4f0f4 100644 --- a/tests/components/esp32_can/test.esp32-c6-idf.yaml +++ b/tests/components/esp32_can/test.esp32-c6-idf.yaml @@ -3,20 +3,20 @@ esphome: then: - canbus.send: # Extended ID explicit - canbus_id: esp32_internal_can + canbus_id: esp32_can_esp32_internal_can use_extended_id: true can_id: 0x100 data: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] - canbus.send: # Standard ID by default - canbus_id: esp32_internal_can + canbus_id: esp32_can_esp32_internal_can can_id: 0x100 data: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] # Note: esp32_internal_can_2 uses LISTENONLY mode, so no send actions canbus: - platform: esp32_can - id: esp32_internal_can + id: esp32_can_esp32_internal_can rx_pin: GPIO8 tx_pin: GPIO7 can_id: 4 diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml index bdc478ea03..f05735e8f4 100644 --- a/tests/components/espnow/common.yaml +++ b/tests/components/espnow/common.yaml @@ -62,7 +62,7 @@ packet_transport: encryption: key: "0123456789abcdef0123456789abcdef" sensors: - - temp_sensor + - espnow_temp_sensor providers: - name: test-provider encryption: @@ -70,9 +70,9 @@ packet_transport: sensor: - platform: internal_temperature - id: temp_sensor + id: espnow_temp_sensor - platform: packet_transport provider: test-provider - remote_id: temp_sensor + remote_id: espnow_temp_sensor id: remote_temp diff --git a/tests/components/fastled_clockless/common.yaml b/tests/components/fastled_clockless/common.yaml index 8b1447a17a..a7ce7ed280 100644 --- a/tests/components/fastled_clockless/common.yaml +++ b/tests/components/fastled_clockless/common.yaml @@ -1,6 +1,6 @@ light: - platform: fastled_clockless - id: addr1 + id: fastled_clockless_addr1 chipset: WS2811 pin: 13 num_leds: 100 @@ -59,13 +59,13 @@ light: name: Custom Effect sequence: - light.addressable_set: - id: addr1 + id: fastled_clockless_addr1 red: 100% green: 100% blue: 0% - delay: 100ms - light.addressable_set: - id: addr1 + id: fastled_clockless_addr1 red: 0% green: 100% blue: 0% diff --git a/tests/components/fastled_spi/common.yaml b/tests/components/fastled_spi/common.yaml index f6f7c5553b..19d00627f8 100644 --- a/tests/components/fastled_spi/common.yaml +++ b/tests/components/fastled_spi/common.yaml @@ -1,6 +1,6 @@ light: - platform: fastled_spi - id: addr1 + id: fastled_spi_addr1 chipset: WS2801 clock_pin: 22 data_pin: 23 @@ -59,13 +59,13 @@ light: name: Custom Effect sequence: - light.addressable_set: - id: addr1 + id: fastled_spi_addr1 red: 100% green: 100% blue: 0% - delay: 100ms - light.addressable_set: - id: addr1 + id: fastled_spi_addr1 red: 0% green: 100% blue: 0% diff --git a/tests/components/font/common.yaml b/tests/components/font/common.yaml index c156b4aea1..59063291e7 100644 --- a/tests/components/font/common.yaml +++ b/tests/components/font/common.yaml @@ -8,7 +8,7 @@ font: id: roboto32 - file: "gfonts://Roboto" - id: roboto + id: font_roboto size: 20 glyphs: "0123456789." extras: @@ -50,11 +50,11 @@ font: display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_display + id: font_ssd1306_display model: SSD1306_128X64 reset_pin: ${display_reset_pin} lambda: |- - it.print(0, 0, id(roboto), "Hello, World!"); + it.print(0, 0, id(font_roboto), "Hello, World!"); it.print(0, 20, id(roboto_web), "Hello, World!"); it.print(0, 40, id(monocraft), "Hello, World!"); it.print(0, 60, id(monocraft2), "Hello, World!"); diff --git a/tests/components/font/test.host.yaml b/tests/components/font/test.host.yaml index 387ea47335..8ada8b7a4e 100644 --- a/tests/components/font/test.host.yaml +++ b/tests/components/font/test.host.yaml @@ -8,7 +8,7 @@ font: id: roboto32 - file: "gfonts://Roboto" - id: roboto + id: font_roboto size: 20 glyphs: "0123456789." extras: @@ -44,12 +44,12 @@ font: display: - platform: sdl - id: sdl_display + id: font_sdl_display dimensions: width: 800 height: 600 lambda: |- - it.print(0, 0, id(roboto), "Hello, World!"); + it.print(0, 0, id(font_roboto), "Hello, World!"); it.print(0, 20, id(roboto_web), "Hello, World!"); it.print(0, 40, id(roboto_greek), "Hello κόσμε!"); it.print(0, 60, id(monocraft), "Hello, World!"); diff --git a/tests/components/graph/common.yaml b/tests/components/graph/common.yaml index 11e2a16ca1..edf4493aa6 100644 --- a/tests/components/graph/common.yaml +++ b/tests/components/graph/common.yaml @@ -12,7 +12,7 @@ graph: display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_display + id: graph_ssd1306_display model: SSD1306_128X64 reset_pin: ${reset_pin} pages: diff --git a/tests/components/graphical_display_menu/common.yaml b/tests/components/graphical_display_menu/common.yaml index 6cee2af232..50f8a5bc85 100644 --- a/tests/components/graphical_display_menu/common.yaml +++ b/tests/components/graphical_display_menu/common.yaml @@ -1,6 +1,7 @@ display: - platform: ssd1306_i2c - id: ssd1306_i2c_display + i2c_id: i2c_bus + id: graphical_display_menu_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${reset_pin} pages: @@ -10,12 +11,12 @@ display: font: - file: "gfonts://Roboto" - id: roboto + id: graphical_display_menu_roboto size: 20 number: - platform: template - id: test_number + id: graphical_display_menu_test_number min_value: 0 step: 1 max_value: 10 @@ -31,13 +32,13 @@ select: switch: - platform: template - id: test_switch + id: graphical_display_menu_test_switch optimistic: true graphical_display_menu: id: test_graphical_display_menu - display: ssd1306_i2c_display - font: roboto + display: graphical_display_menu_ssd1306_i2c_display + font: graphical_display_menu_roboto active: false mode: rotary on_enter: @@ -80,7 +81,7 @@ graphical_display_menu: lambda: 'ESP_LOGI("graphical_display_menu", "select value: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' - type: number text: "Number" - number: test_number + number: graphical_display_menu_test_number on_enter: then: lambda: 'ESP_LOGI("graphical_display_menu", "number enter: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' @@ -97,7 +98,7 @@ graphical_display_menu: - display_menu.hide: test_graphical_display_menu - type: switch text: "Switch" - switch: test_switch + switch: graphical_display_menu_test_switch on_text: "Bright" off_text: "Dark" immediate_edit: false diff --git a/tests/components/gt911/common.yaml b/tests/components/gt911/common.yaml index ff464cda24..0fc40737f0 100644 --- a/tests/components/gt911/common.yaml +++ b/tests/components/gt911/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_i2c_display + id: gt911_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${display_reset_pin} pages: @@ -13,7 +13,7 @@ touchscreen: - platform: gt911 i2c_id: i2c_bus id: gt911_touchscreen - display: ssd1306_i2c_display + display: gt911_ssd1306_i2c_display interrupt_pin: ${interrupt_pin} reset_pin: ${reset_pin} diff --git a/tests/components/homeassistant/common.yaml b/tests/components/homeassistant/common.yaml index 60e3defd49..71a7ac65c2 100644 --- a/tests/components/homeassistant/common.yaml +++ b/tests/components/homeassistant/common.yaml @@ -93,12 +93,12 @@ text_sensor: event: - platform: template name: Test Event - id: test_event + id: homeassistant_test_event event_types: - test_event_type on_event: - homeassistant.event: - event: esphome.test_event + event: esphome.homeassistant_test_event data: event_name: !lambda |- return event_type; diff --git a/tests/components/image/test.esp32-idf.yaml b/tests/components/image/test.esp32-idf.yaml index aea2b4bbb0..9e93c4c289 100644 --- a/tests/components/image/test.esp32-idf.yaml +++ b/tests/components/image/test.esp32-idf.yaml @@ -3,7 +3,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: image_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 15 diff --git a/tests/components/image/test.esp8266-ard.yaml b/tests/components/image/test.esp8266-ard.yaml index 2e7bfc5ae5..492b57c449 100644 --- a/tests/components/image/test.esp8266-ard.yaml +++ b/tests/components/image/test.esp8266-ard.yaml @@ -3,7 +3,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: image_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 5 diff --git a/tests/components/image/test.rp2040-ard.yaml b/tests/components/image/test.rp2040-ard.yaml index 03a9c42a38..ce2a13fca7 100644 --- a/tests/components/image/test.rp2040-ard.yaml +++ b/tests/components/image/test.rp2040-ard.yaml @@ -3,7 +3,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: image_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 20 diff --git a/tests/components/infrared/common.yaml b/tests/components/infrared/common.yaml index cd2b10d31b..d9a4a43a26 100644 --- a/tests/components/infrared/common.yaml +++ b/tests/components/infrared/common.yaml @@ -23,7 +23,7 @@ infrared: # Infrared receiver - platform: ir_rf_proxy - id: ir_rx + id: infrared_ir_rx name: "IR Receiver" remote_receiver_id: ir_receiver diff --git a/tests/components/integration/common-esp32.yaml b/tests/components/integration/common-esp32.yaml index 26550d3c5c..c912fb9b84 100644 --- a/tests/components/integration/common-esp32.yaml +++ b/tests/components/integration/common-esp32.yaml @@ -9,11 +9,11 @@ esphome: sensor: - platform: adc - id: my_sensor + id: integration_my_sensor pin: ${pin} attenuation: 12db - platform: integration id: integration_sensor - sensor: my_sensor + sensor: integration_my_sensor name: Integration Sensor time_unit: s diff --git a/tests/components/integration/test.esp8266-ard.yaml b/tests/components/integration/test.esp8266-ard.yaml index 51d3e19077..377bad5578 100644 --- a/tests/components/integration/test.esp8266-ard.yaml +++ b/tests/components/integration/test.esp8266-ard.yaml @@ -1,8 +1,8 @@ sensor: - platform: adc - id: my_sensor + id: integration_my_sensor pin: VCC - platform: integration - sensor: my_sensor + sensor: integration_my_sensor name: Integration Sensor time_unit: s diff --git a/tests/components/integration/test.rp2040-ard.yaml b/tests/components/integration/test.rp2040-ard.yaml index 51d3e19077..377bad5578 100644 --- a/tests/components/integration/test.rp2040-ard.yaml +++ b/tests/components/integration/test.rp2040-ard.yaml @@ -1,8 +1,8 @@ sensor: - platform: adc - id: my_sensor + id: integration_my_sensor pin: VCC - platform: integration - sensor: my_sensor + sensor: integration_my_sensor name: Integration Sensor time_unit: s diff --git a/tests/components/ir_rf_proxy/common-rx.yaml b/tests/components/ir_rf_proxy/common-rx.yaml index 37033a128e..7ced9a29b3 100644 --- a/tests/components/ir_rf_proxy/common-rx.yaml +++ b/tests/components/ir_rf_proxy/common-rx.yaml @@ -6,7 +6,7 @@ remote_receiver: infrared: # Infrared receiver - platform: ir_rf_proxy - id: ir_rx + id: ir_rf_proxy_ir_rx name: "IR Receiver" receiver_frequency: 38kHz remote_receiver_id: ir_receiver diff --git a/tests/components/lcd_gpio/common.yaml b/tests/components/lcd_gpio/common.yaml index bd842454a1..cebadcbf2c 100644 --- a/tests/components/lcd_gpio/common.yaml +++ b/tests/components/lcd_gpio/common.yaml @@ -1,6 +1,6 @@ display: - platform: lcd_gpio - id: my_lcd_gpio + id: lcd_gpio_my_lcd_gpio dimensions: 18x4 data_pins: - number: ${d0_pin} diff --git a/tests/components/lcd_menu/common.yaml b/tests/components/lcd_menu/common.yaml index 970c18e0d2..2287e812bd 100644 --- a/tests/components/lcd_menu/common.yaml +++ b/tests/components/lcd_menu/common.yaml @@ -1,6 +1,6 @@ number: - platform: template - id: test_number + id: lcd_menu_test_number min_value: 0 step: 1 max_value: 10 @@ -22,7 +22,7 @@ switch: display: - platform: lcd_gpio - id: my_lcd_gpio + id: lcd_menu_my_lcd_gpio dimensions: 18x4 data_pins: - number: ${d0_pin} @@ -36,7 +36,7 @@ display: lcd_menu: id: test_lcd_menu - display_id: my_lcd_gpio + display_id: lcd_menu_my_lcd_gpio mark_back: 0x5e mark_selected: 0x3e mark_editing: 0x2a @@ -83,7 +83,7 @@ lcd_menu: lambda: 'ESP_LOGI("lcd_menu", "select value: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' - type: number text: Number - number: test_number + number: lcd_menu_test_number on_enter: then: lambda: 'ESP_LOGI("lcd_menu", "number enter: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 2acc080c6d..71c00e5f10 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -156,7 +156,7 @@ light: - platform: binary id: test_binary_light name: Binary Light - output: test_binary + output: light_test_binary effects: - strobe: on_state: diff --git a/tests/components/light/test.esp32-idf.yaml b/tests/components/light/test.esp32-idf.yaml index 925197182c..49e49b4318 100644 --- a/tests/components/light/test.esp32-idf.yaml +++ b/tests/components/light/test.esp32-idf.yaml @@ -1,6 +1,6 @@ output: - platform: gpio - id: test_binary + id: light_test_binary pin: 12 - platform: ledc id: test_ledc_1 diff --git a/tests/components/light/test.esp8266-ard.yaml b/tests/components/light/test.esp8266-ard.yaml index 518011e925..1eb58eabc4 100644 --- a/tests/components/light/test.esp8266-ard.yaml +++ b/tests/components/light/test.esp8266-ard.yaml @@ -1,6 +1,6 @@ output: - platform: gpio - id: test_binary + id: light_test_binary pin: 4 - platform: esp8266_pwm id: test_ledc_1 diff --git a/tests/components/light/test.nrf52-adafruit.yaml b/tests/components/light/test.nrf52-adafruit.yaml index cb421ed4bb..60521b8088 100644 --- a/tests/components/light/test.nrf52-adafruit.yaml +++ b/tests/components/light/test.nrf52-adafruit.yaml @@ -5,14 +5,14 @@ esphome: output: - platform: gpio - id: test_binary + id: light_test_binary pin: 0 light: - platform: binary id: test_binary_light name: Binary Light - output: test_binary + output: light_test_binary effects: - strobe: on_state: diff --git a/tests/components/light/test.nrf52-mcumgr.yaml b/tests/components/light/test.nrf52-mcumgr.yaml index cb421ed4bb..60521b8088 100644 --- a/tests/components/light/test.nrf52-mcumgr.yaml +++ b/tests/components/light/test.nrf52-mcumgr.yaml @@ -5,14 +5,14 @@ esphome: output: - platform: gpio - id: test_binary + id: light_test_binary pin: 0 light: - platform: binary id: test_binary_light name: Binary Light - output: test_binary + output: light_test_binary effects: - strobe: on_state: diff --git a/tests/components/light/test.rp2040-ard.yaml b/tests/components/light/test.rp2040-ard.yaml index a5a37fd559..21d5cad774 100644 --- a/tests/components/light/test.rp2040-ard.yaml +++ b/tests/components/light/test.rp2040-ard.yaml @@ -1,6 +1,6 @@ output: - platform: gpio - id: test_binary + id: light_test_binary pin: 0 - platform: rp2040_pwm id: test_ledc_1 diff --git a/tests/components/lilygo_t5_47/common.yaml b/tests/components/lilygo_t5_47/common.yaml index 18f1ba10ae..5e71736eb0 100644 --- a/tests/components/lilygo_t5_47/common.yaml +++ b/tests/components/lilygo_t5_47/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_i2c_display + id: lilygo_t5_47_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${reset_pin} pages: @@ -14,7 +14,7 @@ touchscreen: i2c_id: i2c_bus id: lilygo_touchscreen interrupt_pin: ${interrupt_pin} - display: ssd1306_i2c_display + display: lilygo_t5_47_ssd1306_i2c_display on_touch: - logger.log: format: Touch at (%d, %d) diff --git a/tests/components/lock/common.yaml b/tests/components/lock/common.yaml index 9ba7f34857..08001855cb 100644 --- a/tests/components/lock/common.yaml +++ b/tests/components/lock/common.yaml @@ -7,7 +7,7 @@ esphome: output: - platform: gpio - id: test_binary + id: lock_test_binary pin: 4 lock: @@ -32,4 +32,4 @@ lock: - platform: output name: Generic Output Lock id: test_lock2 - output: test_binary + output: lock_test_binary diff --git a/tests/components/monochromatic/common.yaml b/tests/components/monochromatic/common.yaml index 9915e086eb..e57c7bec29 100644 --- a/tests/components/monochromatic/common.yaml +++ b/tests/components/monochromatic/common.yaml @@ -1,13 +1,13 @@ output: - platform: ${light_platform} - id: light_output_1 + id: monochromatic_light_output_1 pin: ${pin} light: - platform: monochromatic name: Monochromatic Light id: monochromatic_light - output: light_output_1 + output: monochromatic_light_output_1 gamma_correct: 2.8 default_transition_length: 2s effects: diff --git a/tests/components/mpr121/common.yaml b/tests/components/mpr121/common.yaml index 67a06cf9c1..f96651e9bf 100644 --- a/tests/components/mpr121/common.yaml +++ b/tests/components/mpr121/common.yaml @@ -9,15 +9,15 @@ binary_sensor: name: touchkey0 channel: 0 - platform: mpr121 - id: bin1 + id: mpr121_bin1 name: touchkey1 channel: 1 - platform: mpr121 - id: bin2 + id: mpr121_bin2 name: touchkey2 channel: 2 - platform: mpr121 - id: bin3 + id: mpr121_bin3 name: touchkey3 channel: 6 diff --git a/tests/components/mqtt/common.yaml b/tests/components/mqtt/common.yaml index 6af2ce3939..a1d27cdbd5 100644 --- a/tests/components/mqtt/common.yaml +++ b/tests/components/mqtt/common.yaml @@ -74,7 +74,7 @@ binary_sensor: state_topic: some/topic/binary_sensor qos: 2 lambda: |- - if (id(template_sens).state > 30) { + if (id(mqtt_template_sens).state > 30) { // Garage Door is open. return true; } @@ -105,8 +105,8 @@ button: climate: - platform: thermostat name: Test Thermostat - sensor: template_sens - humidity_sensor: template_sens + sensor: mqtt_template_sens + humidity_sensor: mqtt_template_sens action_state_topic: some/topicaction_state current_temperature_state_topic: some/topiccurrent_temperature_state current_humidity_state_topic: some/topiccurrent_humidity_state @@ -283,10 +283,10 @@ cover: datetime: - platform: template name: Date - id: test_date + id: mqtt_test_date type: date state_topic: some/topic/date - command_topic: test_date/custom_command_topic + command_topic: mqtt_test_date/custom_command_topic qos: 2 subscribe_qos: 2 set_action: @@ -300,7 +300,7 @@ datetime: - x.day_of_month - platform: template name: Time - id: test_time + id: mqtt_test_time type: time state_topic: some/topic/time qos: 2 @@ -315,7 +315,7 @@ datetime: - x.second - platform: template name: DateTime - id: test_datetime + id: mqtt_test_datetime type: datetime state_topic: some/topic/datetime qos: 2 @@ -407,7 +407,7 @@ select: sensor: - platform: template name: Template Sensor - id: template_sens + id: mqtt_template_sens lambda: |- if (id(some_binary_sensor).state) { return 42.0; @@ -423,13 +423,13 @@ sensor: - platform: mqtt_subscribe name: MQTT Subscribe Sensor topic: mqtt/topic - id: the_sensor + id: mqtt_the_sensor qos: 2 on_value: - mqtt.publish_json: topic: the/topic payload: |- - root["key"] = id(template_sens).state; + root["key"] = id(mqtt_template_sens).state; root["greeting"] = "Hello World"; switch: diff --git a/tests/components/mqtt_subscribe/common-ard.yaml b/tests/components/mqtt_subscribe/common-ard.yaml index 13ed311b17..6b0b16e500 100644 --- a/tests/components/mqtt_subscribe/common-ard.yaml +++ b/tests/components/mqtt_subscribe/common-ard.yaml @@ -18,13 +18,13 @@ sensor: - platform: mqtt_subscribe name: MQTT Subscribe Sensor topic: mqtt/topic - id: the_sensor + id: mqtt_subscribe_the_sensor qos: 2 on_value: - mqtt.publish_json: topic: the/topic payload: |- - root["key"] = id(the_sensor).state; + root["key"] = id(mqtt_subscribe_the_sensor).state; root["greeting"] = "Hello World"; text_sensor: diff --git a/tests/components/mqtt_subscribe/common-idf.yaml b/tests/components/mqtt_subscribe/common-idf.yaml index 070672f15c..0f5293ac61 100644 --- a/tests/components/mqtt_subscribe/common-idf.yaml +++ b/tests/components/mqtt_subscribe/common-idf.yaml @@ -19,13 +19,13 @@ sensor: - platform: mqtt_subscribe name: MQTT Subscribe Sensor topic: mqtt/topic - id: the_sensor + id: mqtt_subscribe_the_sensor qos: 2 on_value: - mqtt.publish_json: topic: the/topic payload: |- - root["key"] = id(the_sensor).state; + root["key"] = id(mqtt_subscribe_the_sensor).state; root["greeting"] = "Hello World"; text_sensor: diff --git a/tests/components/ntc/common.yaml b/tests/components/ntc/common.yaml index 79ae7f601d..1be2c335bc 100644 --- a/tests/components/ntc/common.yaml +++ b/tests/components/ntc/common.yaml @@ -1,23 +1,23 @@ sensor: - platform: adc - id: my_sensor + id: ntc_my_sensor pin: ${pin} - platform: resistance - sensor: my_sensor + sensor: ntc_my_sensor configuration: DOWNSTREAM resistor: 10kΩ reference_voltage: 3.3V name: Resistance - id: resist + id: ntc_resist - platform: ntc - sensor: resist + sensor: ntc_resist name: NTC Sensor calibration: b_constant: 3950 reference_resistance: 10k reference_temperature: 25°C - platform: ntc - sensor: resist + sensor: ntc_resist name: NTC Sensor2 calibration: - 10.0kOhm -> 25°C diff --git a/tests/components/number/common.yaml b/tests/components/number/common.yaml index c17c2dd5f8..b1a16ebfed 100644 --- a/tests/components/number/common.yaml +++ b/tests/components/number/common.yaml @@ -1,7 +1,7 @@ number: - platform: template name: "Test Number" - id: test_number + id: number_test_number optimistic: true min_value: 0 max_value: 100 @@ -10,4 +10,4 @@ number: sensor: - platform: number name: "Test Number Value" - source_id: test_number + source_id: number_test_number diff --git a/tests/components/online_image/common-esp32.yaml b/tests/components/online_image/common-esp32.yaml index 32c909d351..ee4c1ed0b8 100644 --- a/tests/components/online_image/common-esp32.yaml +++ b/tests/components/online_image/common-esp32.yaml @@ -6,7 +6,7 @@ packages: display: - platform: ili9xxx spi_id: spi_bus - id: main_lcd + id: online_image_main_lcd model: ili9342 cs_pin: 20 dc_pin: 13 diff --git a/tests/components/online_image/common-esp8266.yaml b/tests/components/online_image/common-esp8266.yaml index d7722d171a..fc61aad92e 100644 --- a/tests/components/online_image/common-esp8266.yaml +++ b/tests/components/online_image/common-esp8266.yaml @@ -6,7 +6,7 @@ packages: display: - platform: ili9xxx spi_id: spi_bus - id: main_lcd + id: online_image_main_lcd model: ili9342 cs_pin: 15 dc_pin: 3 diff --git a/tests/components/online_image/common-rp2040.yaml b/tests/components/online_image/common-rp2040.yaml index bbb514bded..4d2785f3e8 100644 --- a/tests/components/online_image/common-rp2040.yaml +++ b/tests/components/online_image/common-rp2040.yaml @@ -6,7 +6,7 @@ packages: display: - platform: ili9xxx spi_id: spi_bus - id: main_lcd + id: online_image_main_lcd model: ili9342 data_rate: 20MHz cs_pin: 20 diff --git a/tests/components/online_image/test.esp32-s3-ard.yaml b/tests/components/online_image/test.esp32-s3-ard.yaml index 9116fd86e0..9972a673c0 100644 --- a/tests/components/online_image/test.esp32-s3-ard.yaml +++ b/tests/components/online_image/test.esp32-s3-ard.yaml @@ -8,7 +8,7 @@ http_request: display: - platform: ili9xxx spi_id: spi_bus - id: main_lcd + id: online_image_main_lcd model: ili9342 cs_pin: 20 dc_pin: 13 diff --git a/tests/components/online_image/test.esp32-s3-idf.yaml b/tests/components/online_image/test.esp32-s3-idf.yaml index f219f71ee2..1f1485fd6c 100644 --- a/tests/components/online_image/test.esp32-s3-idf.yaml +++ b/tests/components/online_image/test.esp32-s3-idf.yaml @@ -8,7 +8,7 @@ http_request: display: - platform: ili9xxx spi_id: spi_bus - id: main_lcd + id: online_image_main_lcd model: ili9342 cs_pin: 20 dc_pin: 13 diff --git a/tests/components/output/common.yaml b/tests/components/output/common.yaml index 81d802e9bf..df20dcde2b 100644 --- a/tests/components/output/common.yaml +++ b/tests/components/output/common.yaml @@ -1,19 +1,19 @@ esphome: on_boot: then: - - output.turn_off: light_output_1 - - output.turn_on: light_output_1 + - output.turn_off: output_light_output_1 + - output.turn_on: output_light_output_1 - output.set_level: - id: light_output_1 + id: output_light_output_1 level: 50% - output.set_min_power: - id: light_output_1 + id: output_light_output_1 min_power: 20% - output.set_max_power: - id: light_output_1 + id: output_light_output_1 max_power: 80% output: - platform: ${output_platform} - id: light_output_1 + id: output_light_output_1 pin: ${pin} diff --git a/tests/components/pi4ioe5v6408/common.yaml b/tests/components/pi4ioe5v6408/common.yaml index 77a77fa3e4..aeda76d35c 100644 --- a/tests/components/pi4ioe5v6408/common.yaml +++ b/tests/components/pi4ioe5v6408/common.yaml @@ -9,7 +9,7 @@ pi4ioe5v6408: switch: - platform: gpio - id: switch1 + id: pi4ioe5v6408_switch1 pin: pi4ioe5v6408: pi4ioe1 number: 0 diff --git a/tests/components/pid/common.yaml b/tests/components/pid/common.yaml index 262e75591e..320e5f775f 100644 --- a/tests/components/pid/common.yaml +++ b/tests/components/pid/common.yaml @@ -23,7 +23,7 @@ output: sensor: - platform: template - id: template_sensor1 + id: pid_template_sensor1 lambda: |- if (millis() > 10000) { return 42.0; @@ -35,8 +35,8 @@ climate: - platform: pid id: pid_climate name: PID Climate Controller - sensor: template_sensor1 - humidity_sensor: template_sensor1 + sensor: pid_template_sensor1 + humidity_sensor: pid_template_sensor1 default_target_temperature: 21°C heat_output: pid_slow_pwm control_parameters: diff --git a/tests/components/prometheus/common.yaml b/tests/components/prometheus/common.yaml index 7ff416dccb..951d8f7fc5 100644 --- a/tests/components/prometheus/common.yaml +++ b/tests/components/prometheus/common.yaml @@ -31,7 +31,7 @@ update: sensor: - platform: template - id: template_sensor1 + id: prometheus_template_sensor1 lambda: |- if (millis() > 10000) { return 42.0; @@ -91,7 +91,7 @@ binary_sensor: switch: - platform: template - id: template_switch1 + id: prometheus_template_switch1 lambda: |- if (millis() > 10000) { return true; @@ -185,7 +185,7 @@ climate: prometheus: include_internal: true relabel: - template_sensor1: + prometheus_template_sensor1: id: hellow_world name: Hello World template_text_sensor1: diff --git a/tests/components/qspi_dbi/common.yaml b/tests/components/qspi_dbi/common.yaml index 109db65b63..0eadfa7392 100644 --- a/tests/components/qspi_dbi/common.yaml +++ b/tests/components/qspi_dbi/common.yaml @@ -16,7 +16,7 @@ display: - platform: qspi_dbi model: CUSTOM - id: main_lcd + id: qspi_dbi_main_lcd draw_from_origin: true dimensions: height: 240 diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index c6c7049605..5631c48f95 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -1,6 +1,6 @@ number: - platform: template - id: test_number + id: remote_transmitter_test_number optimistic: true min_value: 0 max_value: 255 @@ -151,7 +151,7 @@ button: on_press: remote_transmitter.transmit_raw: code: !lambda |- - return {(int32_t)id(test_number).state * 100, -1000}; + return {(int32_t)id(remote_transmitter_test_number).state * 100, -1000}; - platform: template name: AEHA id: eaha_hitachi_climate_power_on @@ -253,7 +253,7 @@ button: destination_address: 0x5678 message_type: 0x01 data: !lambda |- - return {(uint8_t)id(test_number).state, 0x20, 0x30}; + return {(uint8_t)id(remote_transmitter_test_number).state, 0x20, 0x30}; - platform: template name: Digital Write on_press: diff --git a/tests/components/resistance/common.yaml b/tests/components/resistance/common.yaml index b3eec49548..8966b574df 100644 --- a/tests/components/resistance/common.yaml +++ b/tests/components/resistance/common.yaml @@ -1,11 +1,11 @@ sensor: - platform: adc - id: my_sensor + id: resistance_my_sensor pin: ${pin} - platform: resistance - sensor: my_sensor + sensor: resistance_my_sensor configuration: DOWNSTREAM resistor: 10kΩ reference_voltage: 3.3V name: Resistance - id: resist + id: resistance_resist diff --git a/tests/components/rgb/common.yaml b/tests/components/rgb/common.yaml index 9f25efa431..fb7b08eeb4 100644 --- a/tests/components/rgb/common.yaml +++ b/tests/components/rgb/common.yaml @@ -1,18 +1,18 @@ output: - platform: ${light_platform} - id: light_output_1 + id: rgb_light_output_1 pin: ${pin1} - platform: ${light_platform} - id: light_output_2 + id: rgb_light_output_2 pin: ${pin2} - platform: ${light_platform} - id: light_output_3 + id: rgb_light_output_3 pin: ${pin3} light: - platform: rgb name: RGB Light id: rgb_light - red: light_output_1 - green: light_output_2 - blue: light_output_3 + red: rgb_light_output_1 + green: rgb_light_output_2 + blue: rgb_light_output_3 diff --git a/tests/components/rgbct/common.yaml b/tests/components/rgbct/common.yaml index 65bb248e95..670f3ef9a4 100644 --- a/tests/components/rgbct/common.yaml +++ b/tests/components/rgbct/common.yaml @@ -1,28 +1,28 @@ output: - platform: ${light_platform} - id: light_output_1 + id: rgbct_light_output_1 pin: ${pin1} - platform: ${light_platform} - id: light_output_2 + id: rgbct_light_output_2 pin: ${pin2} - platform: ${light_platform} - id: light_output_3 + id: rgbct_light_output_3 pin: ${pin3} - platform: ${light_platform} - id: light_output_4 + id: rgbct_light_output_4 pin: ${pin4} - platform: ${light_platform} - id: light_output_5 + id: rgbct_light_output_5 pin: ${pin5} light: - platform: rgbct name: RGBCT Light - red: light_output_1 - green: light_output_2 - blue: light_output_3 - color_temperature: light_output_4 - white_brightness: light_output_5 + red: rgbct_light_output_1 + green: rgbct_light_output_2 + blue: rgbct_light_output_3 + color_temperature: rgbct_light_output_4 + white_brightness: rgbct_light_output_5 cold_white_color_temperature: 153 mireds warm_white_color_temperature: 500 mireds color_interlock: true diff --git a/tests/components/rgbw/common.yaml b/tests/components/rgbw/common.yaml index b0f44869d3..2b1ccae5a7 100644 --- a/tests/components/rgbw/common.yaml +++ b/tests/components/rgbw/common.yaml @@ -1,22 +1,22 @@ output: - platform: ${light_platform} - id: light_output_1 + id: rgbw_light_output_1 pin: ${pin1} - platform: ${light_platform} - id: light_output_2 + id: rgbw_light_output_2 pin: ${pin2} - platform: ${light_platform} - id: light_output_3 + id: rgbw_light_output_3 pin: ${pin3} - platform: ${light_platform} - id: light_output_4 + id: rgbw_light_output_4 pin: ${pin4} light: - platform: rgbw name: RGBW Light - red: light_output_1 - green: light_output_2 - blue: light_output_3 - white: light_output_4 + red: rgbw_light_output_1 + green: rgbw_light_output_2 + blue: rgbw_light_output_3 + white: rgbw_light_output_4 color_interlock: true diff --git a/tests/components/rgbww/common.yaml b/tests/components/rgbww/common.yaml index 0013960c10..5baecaebb8 100644 --- a/tests/components/rgbww/common.yaml +++ b/tests/components/rgbww/common.yaml @@ -1,28 +1,28 @@ output: - platform: ${light_platform} - id: light_output_1 + id: rgbww_light_output_1 pin: ${pin1} - platform: ${light_platform} - id: light_output_2 + id: rgbww_light_output_2 pin: ${pin2} - platform: ${light_platform} - id: light_output_3 + id: rgbww_light_output_3 pin: ${pin3} - platform: ${light_platform} - id: light_output_4 + id: rgbww_light_output_4 pin: ${pin4} - platform: ${light_platform} - id: light_output_5 + id: rgbww_light_output_5 pin: ${pin5} light: - platform: rgbww name: RGBWW Light - red: light_output_1 - green: light_output_2 - blue: light_output_3 - cold_white: light_output_4 - warm_white: light_output_5 + red: rgbww_light_output_1 + green: rgbww_light_output_2 + blue: rgbww_light_output_3 + cold_white: rgbww_light_output_4 + warm_white: rgbww_light_output_5 cold_white_color_temperature: 153 mireds warm_white_color_temperature: 500 mireds color_interlock: true diff --git a/tests/components/rp2040_pio_led_strip/common.yaml b/tests/components/rp2040_pio_led_strip/common.yaml index b9b1436cdb..254ac0e13d 100644 --- a/tests/components/rp2040_pio_led_strip/common.yaml +++ b/tests/components/rp2040_pio_led_strip/common.yaml @@ -1,6 +1,6 @@ light: - platform: rp2040_pio_led_strip - id: led_strip + id: rp2040_pio_led_strip_led_strip pin: 4 num_leds: 60 pio: 0 diff --git a/tests/components/rp2040_pwm/common.yaml b/tests/components/rp2040_pwm/common.yaml index 45c039106f..2970a48afb 100644 --- a/tests/components/rp2040_pwm/common.yaml +++ b/tests/components/rp2040_pwm/common.yaml @@ -1,7 +1,7 @@ output: - platform: rp2040_pwm - id: light_output_1 + id: rp2040_pwm_light_output_1 pin: 2 - platform: rp2040_pwm - id: light_output_2 + id: rp2040_pwm_light_output_2 pin: 3 diff --git a/tests/components/sdl/common.yaml b/tests/components/sdl/common.yaml index d3d3c9ee5e..3be86cf8be 100644 --- a/tests/components/sdl/common.yaml +++ b/tests/components/sdl/common.yaml @@ -3,7 +3,7 @@ host: display: - platform: sdl - id: sdl_display + id: sdl_sdl_display update_interval: 1s auto_clear_enabled: false show_test_card: true @@ -35,14 +35,14 @@ display: binary_sensor: - platform: sdl - sdl_id: sdl_display + sdl_id: sdl_sdl_display id: key_up key: SDLK_UP - platform: sdl - sdl_id: sdl_display + sdl_id: sdl_sdl_display id: key_down key: SDLK_DOWN - platform: sdl - sdl_id: sdl_display + sdl_id: sdl_sdl_display id: key_enter key: SDLK_RETURN diff --git a/tests/components/speaker/common.yaml b/tests/components/speaker/common.yaml index 895f4b4b8f..96f459c53f 100644 --- a/tests/components/speaker/common.yaml +++ b/tests/components/speaker/common.yaml @@ -1,7 +1,7 @@ number: - platform: template name: "Speaker Number" - id: my_number + id: speaker_my_number optimistic: true min_value: 0 max_value: 100 @@ -46,7 +46,7 @@ button: - speaker.play: id: speaker_id data: !lambda |- - return {0x01, 0x02, (uint8_t)id(my_number).state}; + return {0x01, 0x02, (uint8_t)id(speaker_my_number).state}; speaker: - platform: i2s_audio diff --git a/tests/components/speaker_source/common.yaml b/tests/components/speaker_source/common.yaml index d31b97553e..655e4241f1 100644 --- a/tests/components/speaker_source/common.yaml +++ b/tests/components/speaker_source/common.yaml @@ -13,7 +13,7 @@ speaker: - id: media_mixer_speaker_id audio_file: - - id: test_audio + - id: speaker_source_test_audio file: type: local path: $component_dir/test.wav diff --git a/tests/components/speed/common.yaml b/tests/components/speed/common.yaml index be8172af7e..70c91259ba 100644 --- a/tests/components/speed/common.yaml +++ b/tests/components/speed/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${output_platform} - id: fan_output_1 + id: speed_fan_output_1 pin: ${pin} fan: - platform: speed - id: fan_speed - output: fan_output_1 + id: speed_fan_speed + output: speed_fan_output_1 diff --git a/tests/components/sprinkler/common.yaml b/tests/components/sprinkler/common.yaml index f099f77729..dbe109f524 100644 --- a/tests/components/sprinkler/common.yaml +++ b/tests/components/sprinkler/common.yaml @@ -34,7 +34,7 @@ esphome: switch: - platform: template - id: switch1 + id: sprinkler_switch1 optimistic: true - platform: template id: switch2 @@ -52,17 +52,17 @@ sprinkler: valves: - valve_switch: Yard Valve 0 enable_switch: Enable Yard Valve 0 - pump_switch_id: switch1 + pump_switch_id: sprinkler_switch1 run_duration: 10s valve_switch_id: switch2 - valve_switch: Yard Valve 1 enable_switch: Enable Yard Valve 1 - pump_switch_id: switch1 + pump_switch_id: sprinkler_switch1 run_duration: 10s valve_switch_id: switch2 - valve_switch: Yard Valve 2 enable_switch: Enable Yard Valve 2 - pump_switch_id: switch1 + pump_switch_id: sprinkler_switch1 run_duration: 10s valve_switch_id: switch2 - id: garden_sprinkler_ctrlr @@ -73,11 +73,11 @@ sprinkler: valves: - valve_switch: Garden Valve 0 enable_switch: Enable Garden Valve 0 - pump_switch_id: switch1 + pump_switch_id: sprinkler_switch1 run_duration: 10s valve_switch_id: switch2 - valve_switch: Garden Valve 1 enable_switch: Enable Garden Valve 1 - pump_switch_id: switch1 + pump_switch_id: sprinkler_switch1 run_duration: 10s valve_switch_id: switch2 diff --git a/tests/components/ssd1306_i2c/common.yaml b/tests/components/ssd1306_i2c/common.yaml index 09eb569a8e..b3b8ad85dc 100644 --- a/tests/components/ssd1306_i2c/common.yaml +++ b/tests/components/ssd1306_i2c/common.yaml @@ -4,7 +4,7 @@ display: model: SSD1306_128X64 reset_pin: ${reset_pin} address: 0x3C - id: ssd1306_i2c_display + id: ssd1306_i2c_ssd1306_i2c_display contrast: 60% pages: - id: ssd1306_i2c_page1 diff --git a/tests/components/switch/common.yaml b/tests/components/switch/common.yaml index afdf26c150..3ea235cfb9 100644 --- a/tests/components/switch/common.yaml +++ b/tests/components/switch/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: switch - id: some_binary_sensor + id: switch_some_binary_sensor name: "Template Switch State" source_id: the_switch diff --git a/tests/components/sx126x/common.yaml b/tests/components/sx126x/common.yaml index 659550cc01..a4a24d8da7 100644 --- a/tests/components/sx126x/common.yaml +++ b/tests/components/sx126x/common.yaml @@ -29,7 +29,7 @@ sx126x: number: - platform: template name: "SX126x Number" - id: my_number + id: sx126x_my_number optimistic: true min_value: 0 max_value: 100 @@ -47,4 +47,4 @@ button: - sx126x.send_packet: data: [0xC5, 0x51, 0x78, 0x82, 0xB7, 0xF9, 0x9C, 0x5C] - sx126x.send_packet: !lambda |- - return {0x01, 0x02, (uint8_t)id(my_number).state}; + return {0x01, 0x02, (uint8_t)id(sx126x_my_number).state}; diff --git a/tests/components/sx127x/common.yaml b/tests/components/sx127x/common.yaml index 6e48952fcc..b7eadc084f 100644 --- a/tests/components/sx127x/common.yaml +++ b/tests/components/sx127x/common.yaml @@ -29,7 +29,7 @@ sx127x: number: - platform: template name: "SX127x Number" - id: my_number + id: sx127x_my_number optimistic: true min_value: 0 max_value: 100 @@ -48,4 +48,4 @@ button: - sx127x.send_packet: data: [0xC5, 0x51, 0x78, 0x82, 0xB7, 0xF9, 0x9C, 0x5C] - sx127x.send_packet: !lambda |- - return {0x01, 0x02, (uint8_t)id(my_number).state}; + return {0x01, 0x02, (uint8_t)id(sx127x_my_number).state}; diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index d3985a848b..92a1fc8eda 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -1,12 +1,12 @@ esphome: on_boot: - sensor.template.publish: - id: template_sens + id: template_template_sens state: 42.0 # Templated - sensor.template.publish: - id: template_sens + id: template_template_sens state: !lambda "return 42.0;" - water_heater.template.publish: @@ -28,34 +28,34 @@ esphome: # Test C++ API: set_template() with stateless lambda (no captures) # NOTE: set_template() is not intended to be a public API, but we test it to ensure it doesn't break. - lambda: |- - id(template_sens).set_template([]() -> std::optional { + id(template_template_sens).set_template([]() -> std::optional { return 123.0f; }); # Test that esphome::optional alias still works for backward compatibility - lambda: |- - id(template_sens).set_template([]() -> esphome::optional { + id(template_template_sens).set_template([]() -> esphome::optional { return 42.0f; }); - datetime.date.set: - id: test_date + id: template_test_date date: year: 2021 month: 1 day: 1 - datetime.date.set: - id: test_date + id: template_test_date date: !lambda "return {.day_of_month = 1, .month = 1, .year = 2021};" - datetime.date.set: - id: test_date + id: template_test_date date: "2021-01-01" binary_sensor: - platform: template - id: some_binary_sensor + id: template_some_binary_sensor name: "Garage Door Open" lambda: |- - if (id(template_sens).state > 30) { + if (id(template_template_sens).state > 30) { // Garage Door is open. return true; } else { @@ -78,7 +78,7 @@ binary_sensor: name: "Garage Door Closed" condition: sensor.in_range: - id: template_sens + id: template_template_sens below: 30.0 filters: - invert: @@ -106,9 +106,9 @@ binary_sensor: sensor: - platform: template name: "Template Sensor" - id: template_sens + id: template_template_sens lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return 42.0; } return 0.0; @@ -230,7 +230,7 @@ switch: id: test_switch name: "Template Switch" lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return true; } return false; @@ -249,7 +249,7 @@ cover: - platform: template name: "Template Cover" lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return COVER_OPEN; } return COVER_CLOSED; @@ -264,7 +264,7 @@ cover: name: "Template Cover with Triggers" id: template_cover_with_triggers lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return COVER_OPEN; } return COVER_CLOSED; @@ -442,7 +442,7 @@ lock: - platform: template name: "Template Lock" lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return LOCK_STATE_LOCKED; } return LOCK_STATE_UNLOCKED; @@ -458,7 +458,7 @@ valve: id: template_valve name: "Template Valve" lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return VALVE_OPEN; } return VALVE_CLOSED; @@ -537,7 +537,7 @@ water_heater: datetime: - platform: template name: Date - id: test_date + id: template_test_date type: date initial_value: "2000-1-2" set_action: @@ -551,7 +551,7 @@ datetime: - x.day_of_month - platform: template name: Time - id: test_time + id: template_test_time type: time initial_value: "12:34:56am" set_action: @@ -565,7 +565,7 @@ datetime: - x.second - platform: template name: DateTime - id: test_datetime + id: template_test_datetime type: datetime initial_value: "2000-1-2 12:34:56" set_action: diff --git a/tests/components/tlc5947/common.yaml b/tests/components/tlc5947/common.yaml index 89588f3c76..f16f07503e 100644 --- a/tests/components/tlc5947/common.yaml +++ b/tests/components/tlc5947/common.yaml @@ -5,9 +5,9 @@ tlc5947: output: - platform: tlc5947 - id: output_1 + id: tlc5947_output_1 channel: 0 max_power: 0.8 - platform: tlc5947 - id: output_2 + id: tlc5947_output_2 channel: 1 diff --git a/tests/components/tlc5971/common.yaml b/tests/components/tlc5971/common.yaml index fe7fe25f0e..e372582ac1 100644 --- a/tests/components/tlc5971/common.yaml +++ b/tests/components/tlc5971/common.yaml @@ -4,9 +4,9 @@ tlc5971: output: - platform: tlc5971 - id: output_1 + id: tlc5971_output_1 channel: 0 max_power: 0.8 - platform: tlc5971 - id: output_2 + id: tlc5971_output_2 channel: 1 diff --git a/tests/components/tt21100/common.yaml b/tests/components/tt21100/common.yaml index 56089aed1e..1f9249f1ba 100644 --- a/tests/components/tt21100/common.yaml +++ b/tests/components/tt21100/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_i2c_display + id: tt21100_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${disp_reset_pin} pages: @@ -13,7 +13,7 @@ touchscreen: - platform: tt21100 i2c_id: i2c_bus id: tt21100_touchscreen - display: ssd1306_i2c_display + display: tt21100_ssd1306_i2c_display interrupt_pin: ${interrupt_pin} reset_pin: ${reset_pin} diff --git a/tests/components/uart/test.esp32-idf.yaml b/tests/components/uart/test.esp32-idf.yaml index fa76316b9c..c805188005 100644 --- a/tests/components/uart/test.esp32-idf.yaml +++ b/tests/components/uart/test.esp32-idf.yaml @@ -79,7 +79,7 @@ switch: number: - platform: template name: "Test Number" - id: test_number + id: uart_test_number optimistic: true min_value: 0 max_value: 100 @@ -103,7 +103,7 @@ button: - uart.write: id: uart_id data: !lambda |- - std::string cmd = "VALUE=" + str_sprintf("%.0f", id(test_number).state) + "\r\n"; + std::string cmd = "VALUE=" + str_sprintf("%.0f", id(uart_test_number).state) + "\r\n"; return std::vector(cmd.begin(), cmd.end()); event: diff --git a/tests/components/udp/common.yaml b/tests/components/udp/common.yaml index a40ca455cb..6824c5cca8 100644 --- a/tests/components/udp/common.yaml +++ b/tests/components/udp/common.yaml @@ -24,7 +24,7 @@ udp: number: - platform: template name: "UDP Number" - id: my_number + id: udp_my_number optimistic: true min_value: 0 max_value: 100 @@ -38,4 +38,4 @@ button: - udp.write: data: [0x01, 0x02, 0x03] - udp.write: !lambda |- - return {0x10, 0x20, (uint8_t)id(my_number).state}; + return {0x10, 0x20, (uint8_t)id(udp_my_number).state}; diff --git a/tests/components/ufire_ec/common.yaml b/tests/components/ufire_ec/common.yaml index 4260f0ab4c..2365b7a368 100644 --- a/tests/components/ufire_ec/common.yaml +++ b/tests/components/ufire_ec/common.yaml @@ -4,18 +4,18 @@ esphome: - ufire_ec.calibrate_probe: id: ufire_ec_board solution: 0.146 - temperature: !lambda "return id(test_sensor).state;" + temperature: !lambda "return id(ufire_ec_test_sensor).state;" - ufire_ec.reset: sensor: - platform: template - id: test_sensor + id: ufire_ec_test_sensor lambda: "return 21;" - platform: ufire_ec i2c_id: i2c_bus id: ufire_ec_board ec: name: Ufire EC - temperature_sensor: test_sensor + temperature_sensor: ufire_ec_test_sensor temperature_compensation: 20.0 temperature_coefficient: 0.019 diff --git a/tests/components/ufire_ise/common.yaml b/tests/components/ufire_ise/common.yaml index f7865ea87b..478c75ad37 100644 --- a/tests/components/ufire_ise/common.yaml +++ b/tests/components/ufire_ise/common.yaml @@ -11,11 +11,11 @@ esphome: sensor: - platform: template - id: test_sensor + id: ufire_ise_test_sensor lambda: "return 21;" - platform: ufire_ise i2c_id: i2c_bus id: ufire_ise_sensor - temperature_sensor: test_sensor + temperature_sensor: ufire_ise_test_sensor ph: name: Ufire pH diff --git a/tests/components/web_server_idf/common.yaml b/tests/components/web_server_idf/common.yaml index b1885af266..cfba0060d9 100644 --- a/tests/components/web_server_idf/common.yaml +++ b/tests/components/web_server_idf/common.yaml @@ -12,7 +12,7 @@ network: sensor: - platform: template name: "Test Sensor" - id: test_sensor + id: web_server_idf_test_sensor update_interval: 60s lambda: "return 42.5;" @@ -25,5 +25,5 @@ binary_sensor: switch: - platform: template name: "Test Switch" - id: test_switch + id: web_server_idf_test_switch optimistic: true diff --git a/tests/components/wk2132_i2c/common.yaml b/tests/components/wk2132_i2c/common.yaml index 39013baeb2..93bb17b38f 100644 --- a/tests/components/wk2132_i2c/common.yaml +++ b/tests/components/wk2132_i2c/common.yaml @@ -16,4 +16,4 @@ wk2132_i2c: sensor: - platform: a02yyuw uart_id: wk2132_id_1 - id: distance_sensor + id: wk2132_i2c_distance_sensor diff --git a/tests/components/wk2132_spi/common.yaml b/tests/components/wk2132_spi/common.yaml index 18294974b9..5ff48bc64c 100644 --- a/tests/components/wk2132_spi/common.yaml +++ b/tests/components/wk2132_spi/common.yaml @@ -17,4 +17,4 @@ wk2132_spi: sensor: - platform: a02yyuw uart_id: wk2132_spi_uart1 - id: distance_sensor + id: wk2132_spi_distance_sensor diff --git a/tests/components/wk2168_i2c/common.yaml b/tests/components/wk2168_i2c/common.yaml index 49f0d1ec6b..1b2de74c02 100644 --- a/tests/components/wk2168_i2c/common.yaml +++ b/tests/components/wk2168_i2c/common.yaml @@ -23,7 +23,7 @@ wk2168_i2c: sensor: - platform: a02yyuw uart_id: wk2168_i2c_uart3 - id: distance_sensor + id: wk2168_i2c_distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/components/wk2168_spi/common.yaml b/tests/components/wk2168_spi/common.yaml index b402077aa3..a21a4a34d0 100644 --- a/tests/components/wk2168_spi/common.yaml +++ b/tests/components/wk2168_spi/common.yaml @@ -23,7 +23,7 @@ wk2168_spi: sensor: - platform: a02yyuw uart_id: wk2168_spi_uart3 - id: distance_sensor + id: wk2168_spi_distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/components/wk2204_i2c/common.yaml b/tests/components/wk2204_i2c/common.yaml index 863633937b..55c67efd88 100644 --- a/tests/components/wk2204_i2c/common.yaml +++ b/tests/components/wk2204_i2c/common.yaml @@ -24,4 +24,4 @@ wk2204_i2c: sensor: - platform: a02yyuw uart_id: wk2204_id_3 - id: distance_sensor + id: wk2204_i2c_distance_sensor diff --git a/tests/components/wk2204_spi/common.yaml b/tests/components/wk2204_spi/common.yaml index 0b62a7a009..ee00da22bb 100644 --- a/tests/components/wk2204_spi/common.yaml +++ b/tests/components/wk2204_spi/common.yaml @@ -25,4 +25,4 @@ wk2204_spi: sensor: - platform: a02yyuw uart_id: wk2204_spi_uart3 - id: distance_sensor + id: wk2204_spi_distance_sensor diff --git a/tests/components/wk2212_i2c/common.yaml b/tests/components/wk2212_i2c/common.yaml index a754bec5c7..d48063bb4d 100644 --- a/tests/components/wk2212_i2c/common.yaml +++ b/tests/components/wk2212_i2c/common.yaml @@ -19,7 +19,7 @@ wk2212_i2c: sensor: - platform: a02yyuw uart_id: uart_i2c_id1 - id: distance_sensor + id: wk2212_i2c_distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/components/wk2212_spi/common.yaml b/tests/components/wk2212_spi/common.yaml index 969f16bb12..d17db2f676 100644 --- a/tests/components/wk2212_spi/common.yaml +++ b/tests/components/wk2212_spi/common.yaml @@ -17,7 +17,7 @@ wk2212_spi: sensor: - platform: a02yyuw uart_id: wk2212_spi_uart1 - id: distance_sensor + id: wk2212_spi_distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/script/test_ci_check_duplicate_test_ids.py b/tests/script/test_ci_check_duplicate_test_ids.py new file mode 100644 index 0000000000..1ac8edeca0 --- /dev/null +++ b/tests/script/test_ci_check_duplicate_test_ids.py @@ -0,0 +1,114 @@ +"""Unit tests for script/ci_check_duplicate_test_ids.py. + +These lock in that the guard stays consistent with the actual config merge: it +prefixes substitutions the same way and delegates the conflict decision to +``merge_component_configs.deduplicate_by_id``. +""" + +from pathlib import Path +import sys + +import pytest + +sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve())) + +import ci_check_duplicate_test_ids as checker # noqa: E402 + + +def _write_component(tests_dir: Path, name: str, body: str) -> None: + comp = tests_dir / name + comp.mkdir(parents=True) + (comp / "test.esp32-idf.yaml").write_text(body) + + +@pytest.fixture +def tests_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setattr(checker, "TESTS_DIR", tmp_path) + return tmp_path + + +def test_substitution_only_difference_is_a_conflict(tests_dir: Path) -> None: + """Raw-identical items that differ only by a substitution still conflict. + + This is the class that the first version missed (and broke CI): the merge + prefixes ``${pin}`` per component, so the two become ``${a_pin}`` and + ``${b_pin}`` and collide. + """ + shared = "sensor:\n - platform: adc\n id: shared\n pin: ${pin}\n" + _write_component(tests_dir, "comp_a", shared) + _write_component(tests_dir, "comp_b", shared) + result = checker.scan() + assert any("shared" in line for line in result.conflicts), result.conflicts + + +def test_identical_substitution_free_items_do_not_conflict(tests_dir: Path) -> None: + same = "sensor:\n - platform: template\n id: shared\n name: Fixed\n" + _write_component(tests_dir, "comp_a", same) + _write_component(tests_dir, "comp_b", same) + assert checker.scan().conflicts == [] + + +def test_unique_ids_do_not_conflict(tests_dir: Path) -> None: + _write_component( + tests_dir, + "comp_a", + "sensor:\n - platform: adc\n id: comp_a_sensor\n pin: ${pin}\n", + ) + _write_component( + tests_dir, + "comp_b", + "sensor:\n - platform: adc\n id: comp_b_sensor\n pin: ${pin}\n", + ) + assert checker.scan().conflicts == [] + + +def test_same_list_key_under_different_paths_is_not_compared(tests_dir: Path) -> None: + """Ids sharing a list key name but under different parent paths don't conflict. + + The merge only concatenates lists at the same path, so ``foo.shared`` and + ``bar.shared`` are never compared against each other. + """ + _write_component( + tests_dir, "comp_a", "foo:\n shared:\n - id: dup\n v: 1\n" + ) + _write_component( + tests_dir, "comp_b", "bar:\n shared:\n - id: dup\n v: 2\n" + ) + assert checker.scan().conflicts == [] + + +def test_int_and_string_ids_are_distinct(tests_dir: Path) -> None: + """``5`` and ``"5"`` are different ids, exactly as deduplicate_by_id treats them.""" + _write_component(tests_dir, "comp_a", "sensor:\n - platform: t\n id: 5\n") + _write_component(tests_dir, "comp_b", 'sensor:\n - platform: t\n id: "5"\n') + assert checker.scan().conflicts == [] + + +def test_unparseable_fixture_is_reported_and_fails(tests_dir: Path) -> None: + """A fixture that cannot be parsed is surfaced and fails the run, not skipped.""" + _write_component(tests_dir, "broken", "foo: [unbalanced\n") + result = checker.scan() + assert result.conflicts == [] + assert any("broken" in path for path in result.parse_errors) + # The run as a whole must not pass when a covered fixture was not scanned. + assert checker.main() == 1 + + +def test_allowlisted_singleton_is_not_a_conflict(tests_dir: Path) -> None: + """Ids in INTENTIONALLY_SHARED_IDS may differ across components.""" + _write_component( + tests_dir, "comp_a", "time:\n - platform: sntp\n id: sntp_time\n" + ) + _write_component( + tests_dir, + "comp_b", + "time:\n - platform: sntp\n id: sntp_time\n servers: [a.example]\n", + ) + assert checker.scan().conflicts == [] + + +def test_empty_scan_fails(tests_dir: Path) -> None: + """A scan that covers zero fixtures is a false green and must fail.""" + result = checker.scan() + assert result.components_scanned == 0 + assert checker.main() == 1 diff --git a/tests/script/test_merge_component_configs.py b/tests/script/test_merge_component_configs.py new file mode 100644 index 0000000000..6ed1bd2c1e --- /dev/null +++ b/tests/script/test_merge_component_configs.py @@ -0,0 +1,101 @@ +"""Unit tests for script/merge_component_configs.py deduplication.""" + +from pathlib import Path +import sys + +import pytest + +# Add the script directory to Python path so we can import the module +sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve())) + +import merge_component_configs # noqa: E402 + +deduplicate_by_id = merge_component_configs.deduplicate_by_id + + +def test_identical_duplicate_ids_collapse() -> None: + """Two identical items sharing an id collapse to one without error.""" + data = { + "sensor": [ + {"id": "shared", "platform": "template", "name": "A"}, + {"id": "shared", "platform": "template", "name": "A"}, + ] + } + result = deduplicate_by_id(data) + assert result["sensor"] == [{"id": "shared", "platform": "template", "name": "A"}] + + +def test_conflicting_duplicate_ids_raise() -> None: + """Two different items sharing an id is a hard error naming the id.""" + data = { + "sensor": [ + {"id": "dup", "platform": "template", "name": "A"}, + {"id": "dup", "platform": "template", "name": "B"}, + ] + } + with pytest.raises(ValueError, match="dup"): + deduplicate_by_id(data) + + +def test_intentionally_shared_id_does_not_raise() -> None: + """An allowlisted (section, id) may differ across components and collapse.""" + section, id_ = "time", "sntp_time" + assert (section, id_) in merge_component_configs.INTENTIONALLY_SHARED_IDS + data = { + section: [ + {"id": id_, "platform": "sntp"}, + {"id": id_, "platform": "sntp", "servers": ["a"]}, + ] + } + result = deduplicate_by_id(data) + # First occurrence wins, no error raised + assert result[section] == [{"id": id_, "platform": "sntp"}] + + +def test_allowlisted_id_in_other_section_still_raises() -> None: + """The allowlist is keyed on (section, id): the same id elsewhere conflicts.""" + data = { + "sensor": [ + {"id": "sntp_time", "platform": "a"}, + {"id": "sntp_time", "platform": "b"}, + ] + } + with pytest.raises(ValueError, match="sntp_time"): + deduplicate_by_id(data) + + +def test_items_without_id_are_preserved() -> None: + """Items lacking an id are passed through untouched.""" + data = {"binary_sensor": [{"platform": "gpio"}, {"platform": "gpio"}]} + result = deduplicate_by_id(data) + assert result["binary_sensor"] == [{"platform": "gpio"}, {"platform": "gpio"}] + + +def test_comparison_is_type_sensitive() -> None: + """Comparison matches the merge exactly: 5 and "5" are a conflict. + + The duplicate-id CI guard reuses this function, so a looser (e.g. string + normalized) comparison would let the guard disagree with the build. + """ + data = { + "sensor": [ + {"id": "dup", "platform": "adc", "pin": 5}, + {"id": "dup", "platform": "adc", "pin": "5"}, + ] + } + with pytest.raises(ValueError, match="dup"): + deduplicate_by_id(data) + + +def test_nested_lists_are_checked() -> None: + """Conflicts nested inside dict values are also detected.""" + data = { + "wrapper": { + "sensor": [ + {"id": "dup", "value": 1}, + {"id": "dup", "value": 2}, + ] + } + } + with pytest.raises(ValueError, match="dup"): + deduplicate_by_id(data) From b21a69f07a341a7b1498e38afefe40510fc76c64 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 08:08:39 +1200 Subject: [PATCH 0354/1815] Bump codecov/codecov-action from 6.0.1 to 7.0.0 (#16884) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3115b5b473..a57be34e9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -245,7 +245,7 @@ jobs: . venv/bin/activate pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/ - name: Upload coverage to Codecov - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache From e0072ef4c546106295235b7377eeaed2f440ee03 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:22:48 -0500 Subject: [PATCH 0355/1815] Bump tornado from 6.5.6 to 6.5.7 (#16883) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 85d9857e7d..8202a2bb44 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 icmplib==3.0.4 -tornado==6.5.6 +tornado==6.5.7 tzlocal==5.3.1 # from time tzdata>=2026.2 # from time pyserial==3.5 From 6e01f3fccd5e6f2b54d4038392b1e0eb54a13fac Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 8 Jun 2026 20:29:35 -0400 Subject: [PATCH 0356/1815] [heatpumpir] Bump tonia/HeatpumpIR to 1.0.42 (#16880) --- .clang-tidy.hash | 2 +- esphome/components/heatpumpir/climate.py | 2 +- platformio.ini | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 25ae506732..591ca3eb4d 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -58a760f5fd174bd438bcc3a7018292c158530c1a1d15181941c832d4c032511c +a1aa12cb72cb0cc57c25649aafed8412434b013885cfda107f8aac5c083b4577 diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index aa3a08c294..cd1b7d2bb0 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -126,6 +126,6 @@ async def to_code(config): cg.add(var.set_max_temperature(config[CONF_MAX_TEMPERATURE])) cg.add(var.set_min_temperature(config[CONF_MIN_TEMPERATURE])) - cg.add_library("tonia/HeatpumpIR", "1.0.41") + cg.add_library("tonia/HeatpumpIR", "1.0.42") if CORE.is_libretiny or CORE.is_esp32: CORE.add_platformio_option("lib_ignore", ["IRremoteESP8266"]) diff --git a/platformio.ini b/platformio.ini index 182f426a31..251339fb5f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -81,7 +81,7 @@ lib_deps = heman/AsyncMqttClient-esphome@1.0.0 ; mqtt freekode/TM1651@1.0.1 ; tm1651 dudanov/MideaUART@1.1.9 ; midea - tonia/HeatpumpIR@1.0.41 ; heatpumpir + tonia/HeatpumpIR@1.0.42 ; heatpumpir build_flags = ${common.build_flags} -DUSE_ARDUINO @@ -170,7 +170,7 @@ framework = espidf lib_deps = ${common:idf.lib_deps} droscy/esp_wireguard@0.4.5 ; wireguard - tonia/HeatpumpIR@1.0.41 ; heatpumpir + tonia/HeatpumpIR@1.0.42 ; heatpumpir build_flags = ${common:idf.build_flags} -Wno-nonnull-compare From a32817207c76616f3dc01f004fe737676d154e48 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 8 Jun 2026 20:30:03 -0400 Subject: [PATCH 0357/1815] [ade7880] Fix reverse active energy reading from reserved register (#16822) --- esphome/components/ade7880/ade7880.cpp | 41 ++++++++----------- esphome/components/ade7880/ade7880.h | 3 +- .../components/ade7880/ade7880_registers.h | 4 +- 3 files changed, 21 insertions(+), 27 deletions(-) diff --git a/esphome/components/ade7880/ade7880.cpp b/esphome/components/ade7880/ade7880.cpp index 9d19770c57..0f4189ad90 100644 --- a/esphome/components/ade7880/ade7880.cpp +++ b/esphome/components/ade7880/ade7880.cpp @@ -87,14 +87,24 @@ void ADE7880::update_sensor_from_s16_register16_(sensor::Sensor *sensor, uint16_ sensor->publish_state(f(val)); } -template -void ADE7880::update_sensor_from_s32_register16_(sensor::Sensor *sensor, uint16_t a_register, F &&f) { - if (sensor == nullptr) { +void ADE7880::update_active_energy_(PowerChannel *channel, uint16_t a_register) { + if (channel->forward_active_energy == nullptr && channel->reverse_active_energy == nullptr) { return; } - float val = this->read_s32_register16_(a_register); - sensor->publish_state(f(val)); + // The ADE7880 has no separate forward/reverse active energy accumulators. The xWATTHR registers + // accumulate signed energy since the last read (positive = imported/forward, negative = exported/ + // reverse), so split the value by sign into the forward and reverse running totals. + float val = this->read_s32_register16_(a_register) / 14400.0f; + if (val >= 0.0f) { + if (channel->forward_active_energy != nullptr) { + channel->forward_active_energy->publish_state(channel->forward_active_energy_total += val); + } + } else { + if (channel->reverse_active_energy != nullptr) { + channel->reverse_active_energy->publish_state(channel->reverse_active_energy_total -= val); + } + } } void ADE7880::update() { @@ -117,12 +127,7 @@ void ADE7880::update() { this->update_sensor_from_s24zp_register16_(chan->apparent_power, AVA, [](float val) { return val / 100.0f; }); this->update_sensor_from_s16_register16_(chan->power_factor, APF, [](float val) { return std::abs(val / -327.68f); }); - this->update_sensor_from_s32_register16_(chan->forward_active_energy, AFWATTHR, [&chan](float val) { - return chan->forward_active_energy_total += val / 14400.0f; - }); - this->update_sensor_from_s32_register16_(chan->reverse_active_energy, ARWATTHR, [&chan](float val) { - return chan->reverse_active_energy_total += val / 14400.0f; - }); + this->update_active_energy_(chan, AWATTHR); } if (this->channel_b_ != nullptr) { @@ -133,12 +138,7 @@ void ADE7880::update() { this->update_sensor_from_s24zp_register16_(chan->apparent_power, BVA, [](float val) { return val / 100.0f; }); this->update_sensor_from_s16_register16_(chan->power_factor, BPF, [](float val) { return std::abs(val / -327.68f); }); - this->update_sensor_from_s32_register16_(chan->forward_active_energy, BFWATTHR, [&chan](float val) { - return chan->forward_active_energy_total += val / 14400.0f; - }); - this->update_sensor_from_s32_register16_(chan->reverse_active_energy, BRWATTHR, [&chan](float val) { - return chan->reverse_active_energy_total += val / 14400.0f; - }); + this->update_active_energy_(chan, BWATTHR); } if (this->channel_c_ != nullptr) { @@ -149,12 +149,7 @@ void ADE7880::update() { this->update_sensor_from_s24zp_register16_(chan->apparent_power, CVA, [](float val) { return val / 100.0f; }); this->update_sensor_from_s16_register16_(chan->power_factor, CPF, [](float val) { return std::abs(val / -327.68f); }); - this->update_sensor_from_s32_register16_(chan->forward_active_energy, CFWATTHR, [&chan](float val) { - return chan->forward_active_energy_total += val / 14400.0f; - }); - this->update_sensor_from_s32_register16_(chan->reverse_active_energy, CRWATTHR, [&chan](float val) { - return chan->reverse_active_energy_total += val / 14400.0f; - }); + this->update_active_energy_(chan, CWATTHR); } ESP_LOGD(TAG, "update took %" PRIu32 " ms", millis() - start); diff --git a/esphome/components/ade7880/ade7880.h b/esphome/components/ade7880/ade7880.h index 69c8e5abba..53f501dee2 100644 --- a/esphome/components/ade7880/ade7880.h +++ b/esphome/components/ade7880/ade7880.h @@ -105,7 +105,8 @@ class ADE7880 : public i2c::I2CDevice, public PollingComponent { // the callable will be passed a 'float' value and is expected to return a 'float' template void update_sensor_from_s24zp_register16_(sensor::Sensor *sensor, uint16_t a_register, F &&f); template void update_sensor_from_s16_register16_(sensor::Sensor *sensor, uint16_t a_register, F &&f); - template void update_sensor_from_s32_register16_(sensor::Sensor *sensor, uint16_t a_register, F &&f); + + void update_active_energy_(PowerChannel *channel, uint16_t a_register); void reset_device_(); diff --git a/esphome/components/ade7880/ade7880_registers.h b/esphome/components/ade7880/ade7880_registers.h index aee4e42445..8b0b86fe7a 100644 --- a/esphome/components/ade7880/ade7880_registers.h +++ b/esphome/components/ade7880/ade7880_registers.h @@ -84,9 +84,7 @@ constexpr uint16_t CWATTHR = 0xE402; constexpr uint16_t AFWATTHR = 0xE403; constexpr uint16_t BFWATTHR = 0xE404; constexpr uint16_t CFWATTHR = 0xE405; -constexpr uint16_t ARWATTHR = 0xE406; -constexpr uint16_t BRWATTHR = 0xE407; -constexpr uint16_t CRWATTHR = 0xE408; +// 0xE406-0xE408 are reserved on the ADE7880 (it does not implement total reactive energy accumulation) constexpr uint16_t AFVARHR = 0xE409; constexpr uint16_t BFVARHR = 0xE40A; constexpr uint16_t CFVARHR = 0xE40B; From ddd21ba442f9d066834440fc92b92978db7c7330 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:06:13 +1000 Subject: [PATCH 0358/1815] [mipi_spi] add WAVESHARE-ESP32-S3-TOUCH-AMOLED-2.16 (#16887) --- esphome/components/mipi_spi/models/waveshare.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/esphome/components/mipi_spi/models/waveshare.py b/esphome/components/mipi_spi/models/waveshare.py index ee8bd06700..ee46f931de 100644 --- a/esphome/components/mipi_spi/models/waveshare.py +++ b/esphome/components/mipi_spi/models/waveshare.py @@ -177,6 +177,20 @@ CO5300.extend( reset_pin=39, ) +# Waveshare ESP32-S3 Touch AMOLED 2.16" (CO5300 controller) +# Pin assignments are the same as the 1.75" devkit: CS=12, RESET=39. Width/height set to 480x480. +CO5300.extend( + "WAVESHARE-ESP32-S3-TOUCH-AMOLED-2.16", + width=480, + height=480, + pixel_mode="16bit", + offset_height=0, + offset_width=0, + cs_pin=12, + reset_pin=39, + data_rate="40MHz", +) + AXS15231.extend( "WAVESHARE-ESP32-S3-TOUCH-LCD-3.49", width=172, From cdc63f0fed7d91a1a94504c480b538d671c6e32d Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Tue, 9 Jun 2026 08:33:15 +0200 Subject: [PATCH 0359/1815] [pcm5122] Add PCM5122 audio DAC component (#15709) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: kbx81 --- CODEOWNERS | 1 + esphome/components/pcm5122/__init__.py | 1 + esphome/components/pcm5122/audio_dac.py | 98 ++++++++++++ esphome/components/pcm5122/pcm5122.cpp | 140 ++++++++++++++++++ esphome/components/pcm5122/pcm5122.h | 72 +++++++++ esphome/components/pcm5122/pcm5122_gpio.cpp | 69 +++++++++ esphome/components/pcm5122/pcm5122_gpio.h | 29 ++++ tests/components/pcm5122/common.yaml | 24 +++ tests/components/pcm5122/test.esp32-ard.yaml | 4 + tests/components/pcm5122/test.esp32-idf.yaml | 4 + .../components/pcm5122/test.esp8266-ard.yaml | 4 + tests/components/pcm5122/test.rp2040-ard.yaml | 4 + 12 files changed, 450 insertions(+) create mode 100644 esphome/components/pcm5122/__init__.py create mode 100644 esphome/components/pcm5122/audio_dac.py create mode 100644 esphome/components/pcm5122/pcm5122.cpp create mode 100644 esphome/components/pcm5122/pcm5122.h create mode 100644 esphome/components/pcm5122/pcm5122_gpio.cpp create mode 100644 esphome/components/pcm5122/pcm5122_gpio.h create mode 100644 tests/components/pcm5122/common.yaml create mode 100644 tests/components/pcm5122/test.esp32-ard.yaml create mode 100644 tests/components/pcm5122/test.esp32-idf.yaml create mode 100644 tests/components/pcm5122/test.esp8266-ard.yaml create mode 100644 tests/components/pcm5122/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 6a81cc1d40..c5beba8c0b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -379,6 +379,7 @@ esphome/components/pca6416a/* @Mat931 esphome/components/pca9554/* @bdraco @clydebarrow @hwstar esphome/components/pcf85063/* @brogon esphome/components/pcf8563/* @KoenBreeman +esphome/components/pcm5122/* @remcom esphome/components/pi4ioe5v6408/* @jesserockz esphome/components/pid/* @OttoWinter esphome/components/pipsolar/* @andreashergert1984 diff --git a/esphome/components/pcm5122/__init__.py b/esphome/components/pcm5122/__init__.py new file mode 100644 index 0000000000..81e00ca74b --- /dev/null +++ b/esphome/components/pcm5122/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@remcom"] diff --git a/esphome/components/pcm5122/audio_dac.py b/esphome/components/pcm5122/audio_dac.py new file mode 100644 index 0000000000..0017a1ef5a --- /dev/null +++ b/esphome/components/pcm5122/audio_dac.py @@ -0,0 +1,98 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.audio_dac import AudioDac +import esphome.config_validation as cv +from esphome.const import ( + CONF_BITS_PER_SAMPLE, + CONF_ID, + CONF_INPUT, + CONF_INVERTED, + CONF_MODE, + CONF_NUMBER, + CONF_OUTPUT, +) + +CODEOWNERS = ["@remcom"] +DEPENDENCIES = ["i2c"] + +pcm5122_ns = cg.esphome_ns.namespace("pcm5122") +PCM5122 = pcm5122_ns.class_("PCM5122", AudioDac, cg.Component, i2c.I2CDevice) +CONF_PCM5122 = "pcm5122" + +pcm5122_bits_per_sample = pcm5122_ns.enum("PCM5122BitsPerSample") +PCM5122_BITS_PER_SAMPLE_ENUM = { + 16: pcm5122_bits_per_sample.PCM5122_BITS_PER_SAMPLE_16, + 24: pcm5122_bits_per_sample.PCM5122_BITS_PER_SAMPLE_24, + 32: pcm5122_bits_per_sample.PCM5122_BITS_PER_SAMPLE_32, +} + +_validate_bits = cv.float_with_unit("bits", "bit") + + +PCM5122GPIOPin = pcm5122_ns.class_( + "PCM5122GPIOPin", + cg.GPIOPin, + cg.Parented.template(PCM5122), +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(PCM5122), + cv.Optional(CONF_BITS_PER_SAMPLE, default="16bit"): cv.All( + _validate_bits, cv.enum(PCM5122_BITS_PER_SAMPLE_ENUM) + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x4D)) +) + + +def _validate_pin_mode(value): + if not (value[CONF_INPUT] or value[CONF_OUTPUT]): + raise cv.Invalid("Mode must be either input or output") + if value[CONF_INPUT] and value[CONF_OUTPUT]: + raise cv.Invalid("Mode must be either input or output, not both") + return value + + +def _validate_pin(value): + if value[CONF_MODE][CONF_INPUT] and value[CONF_NUMBER] == 6: + raise cv.Invalid("GPIO6 cannot be used as input on the PCM5122") + return value + + +PIN_SCHEMA = cv.All( + pins.gpio_base_schema( + PCM5122GPIOPin, + cv.int_range(min=3, max=6), + modes=[CONF_INPUT, CONF_OUTPUT], + mode_validator=_validate_pin_mode, + ).extend( + { + cv.Required(CONF_PCM5122): cv.use_id(PCM5122), + } + ), + _validate_pin, +) + + +@pins.PIN_SCHEMA_REGISTRY.register(CONF_PCM5122, PIN_SCHEMA) +async def pcm5122_pin_to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_parented(var, config[CONF_PCM5122]) + + cg.add(var.set_pin(config[CONF_NUMBER])) + cg.add(var.set_inverted(config[CONF_INVERTED])) + cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) + return var + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + cg.add(var.set_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) diff --git a/esphome/components/pcm5122/pcm5122.cpp b/esphome/components/pcm5122/pcm5122.cpp new file mode 100644 index 0000000000..68bbd50e4f --- /dev/null +++ b/esphome/components/pcm5122/pcm5122.cpp @@ -0,0 +1,140 @@ +#include "pcm5122.h" + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::pcm5122 { + +static const char *const TAG = "pcm5122"; + +void PCM5122::setup() { + // Select page 0 and verify chip presence via I2C ACK + if (!this->select_page_(0)) { + ESP_LOGE(TAG, "Write failed"); + this->status_set_error(LOG_STR("Write failed")); + this->mark_failed(); + return; + } + + // Reset audio modules + this->reg(PCM5122_REG_RESET) = PCM5122_RESET_MODULES; + delay(20); + this->reg(PCM5122_REG_RESET) = 0x00; + + // Ignore clock halt detection; enable clock divider autoset + optional err_detect = this->read_byte(PCM5122_REG_ERROR_DETECT); + if (!err_detect.has_value()) { + ESP_LOGE(TAG, "Failed to read ERROR_DETECT"); + this->mark_failed(); + return; + } + uint8_t err_detect_val = err_detect.value(); + err_detect_val |= PCM5122_ERROR_DETECT_IGNORE_CLKHALT; + err_detect_val &= ~PCM5122_ERROR_DETECT_DISABLE_DIV_AUTOSET; + this->reg(PCM5122_REG_ERROR_DETECT) = err_detect_val; + + // I2S format with the configured word length + uint8_t alen; + switch (this->bits_per_sample_) { + case PCM5122_BITS_PER_SAMPLE_16: + alen = PCM5122_AUDIO_FORMAT_ALEN_16BIT; + break; + case PCM5122_BITS_PER_SAMPLE_24: + alen = PCM5122_AUDIO_FORMAT_ALEN_24BIT; + break; + case PCM5122_BITS_PER_SAMPLE_32: + default: + alen = PCM5122_AUDIO_FORMAT_ALEN_32BIT; + break; + } + this->reg(PCM5122_REG_AUDIO_FORMAT) = PCM5122_AUDIO_FORMAT_I2S | alen; + + // PLL reference clock: BCK + optional pll_ref = this->read_byte(PCM5122_REG_PLL_REF); + if (!pll_ref.has_value()) { + ESP_LOGE(TAG, "Failed to read PLL_REF"); + this->mark_failed(); + return; + } + uint8_t pll_ref_val = pll_ref.value(); + pll_ref_val &= ~PCM5122_PLL_REF_MASK; + pll_ref_val |= PCM5122_PLL_REF_SOURCE_BCK; + this->reg(PCM5122_REG_PLL_REF) = pll_ref_val; + + if (!this->set_mute_on() || !this->set_volume(this->volume_)) { + this->mark_failed(); + return; + } +} + +void PCM5122::dump_config() { + ESP_LOGCONFIG(TAG, "Audio DAC:"); + LOG_I2C_DEVICE(this); + ESP_LOGCONFIG(TAG, + " Bits per sample: %u\n" + " Muted: %s", + this->bits_per_sample_, YESNO(this->is_muted_)); +} + +bool PCM5122::set_mute_off() { + this->is_muted_ = false; + return this->write_mute_(); +} + +bool PCM5122::set_mute_on() { + this->is_muted_ = true; + return this->write_mute_(); +} + +bool PCM5122::set_volume(float volume) { + this->volume_ = clamp(volume, 0.0f, 1.0f); + return this->write_volume_(); +} + +bool PCM5122::is_muted() { return this->is_muted_; } + +float PCM5122::volume() { return this->volume_; } + +bool PCM5122::select_page_(uint8_t page) { + if (this->current_page_ == page) + return true; + if (!this->write_byte(PCM5122_REG_PAGE_SELECT, page)) { + this->current_page_ = -1; + return false; + } + this->current_page_ = page; + return true; +} + +bool PCM5122::write_mute_() { + uint8_t mute_byte = this->is_muted() ? 0x11 : 0x00; + if (!this->select_page_(0) || !this->write_byte(PCM5122_REG_MUTE, mute_byte)) { + ESP_LOGE(TAG, "Writing mute failed"); + return false; + } + return true; +} + +bool PCM5122::write_volume_() { + // DVOL register: 0x00 = +24 dB, 0x30 = 0 dB, 0xFF = mute (-0.5 dB/step). + // Note: volume=0.0 maps to -52.5 dB (still audible), not true silence. + // Use set_mute_on() for silence. + const uint8_t dvol_max_volume = 0x30; // 0 dB at full scale + const uint8_t dvol_min_volume = 0x99; // -52.5 dB at minimum + + const uint8_t volume_byte = + dvol_max_volume + static_cast(lroundf((1.0f - this->volume_) * (dvol_min_volume - dvol_max_volume))); + + ESP_LOGV(TAG, "Setting volume to 0x%.2x", volume_byte); + + if (!this->select_page_(0) || !this->write_byte(PCM5122_REG_DVOL_LEFT, volume_byte) || + !this->write_byte(PCM5122_REG_DVOL_RIGHT, volume_byte)) { + ESP_LOGE(TAG, "Writing volume failed"); + return false; + } + return true; +} + +} // namespace esphome::pcm5122 diff --git a/esphome/components/pcm5122/pcm5122.h b/esphome/components/pcm5122/pcm5122.h new file mode 100644 index 0000000000..f86b096c82 --- /dev/null +++ b/esphome/components/pcm5122/pcm5122.h @@ -0,0 +1,72 @@ +#pragma once + +#include "esphome/components/audio_dac/audio_dac.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" + +namespace esphome::pcm5122 { + +// Page 0 register addresses +static const uint8_t PCM5122_REG_PAGE_SELECT = 0x00; +static const uint8_t PCM5122_REG_RESET = 0x01; +static const uint8_t PCM5122_REG_MUTE = 0x03; +static const uint8_t PCM5122_REG_GPIO_ENABLE = 0x08; +static const uint8_t PCM5122_REG_PLL_REF = 0x0D; +static const uint8_t PCM5122_REG_ERROR_DETECT = 0x25; +static const uint8_t PCM5122_REG_AUDIO_FORMAT = 0x28; +static const uint8_t PCM5122_REG_DVOL_LEFT = 0x3D; +static const uint8_t PCM5122_REG_DVOL_RIGHT = 0x3E; +static const uint8_t PCM5122_REG_GPIO_OUTPUT_SELECT = 0x50; // Base address; GPIO n uses offset n-1 +static const uint8_t PCM5122_GPIO_OUTPUT_SELECT_REGISTER = 0x02; // GPIO driven by GPIO_OUTPUT register (reg 0x56) +static const uint8_t PCM5122_REG_GPIO_OUTPUT = 0x56; +static const uint8_t PCM5122_REG_GPIO_INVERT = 0x57; +static const uint8_t PCM5122_REG_GPIO_INPUT = 0x77; + +// Register values for init sequence +static const uint8_t PCM5122_RESET_MODULES = 0x10; // RSTM: reset audio modules +static const uint8_t PCM5122_AUDIO_FORMAT_I2S = 0x00; // AFMT = I2S (bits [5:4] = 00) +// ALEN (word length) occupies bits [1:0] of the audio format register +static const uint8_t PCM5122_AUDIO_FORMAT_ALEN_16BIT = 0x00; +static const uint8_t PCM5122_AUDIO_FORMAT_ALEN_24BIT = 0x02; +static const uint8_t PCM5122_AUDIO_FORMAT_ALEN_32BIT = 0x03; +static const uint8_t PCM5122_ERROR_DETECT_IGNORE_CLKHALT = (1 << 3); +static const uint8_t PCM5122_ERROR_DETECT_DISABLE_DIV_AUTOSET = (1 << 1); +static const uint8_t PCM5122_PLL_REF_MASK = (7 << 4); // SREF bits [6:4] +static const uint8_t PCM5122_PLL_REF_SOURCE_BCK = (1 << 4); // SREF = 001 (BCK) + +enum PCM5122BitsPerSample : uint8_t { + PCM5122_BITS_PER_SAMPLE_16 = 16, + PCM5122_BITS_PER_SAMPLE_24 = 24, + PCM5122_BITS_PER_SAMPLE_32 = 32, +}; + +class PCM5122 : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::IO; } + + void set_bits_per_sample(PCM5122BitsPerSample bits_per_sample) { this->bits_per_sample_ = bits_per_sample; } + + bool set_mute_off() override; + bool set_mute_on() override; + bool set_volume(float volume) override; + + bool is_muted() override; + float volume() override; + + friend class PCM5122GPIOPin; + + protected: + bool select_page_(uint8_t page); + bool write_mute_(); + bool write_volume_(); + + float volume_{1.0f}; // Matches chip post-reset DVOL default (0x30 = 0 dB) + int16_t current_page_{-1}; // -1 = unknown; cached to skip redundant page-select writes + bool is_muted_{false}; + PCM5122BitsPerSample bits_per_sample_{PCM5122_BITS_PER_SAMPLE_16}; +}; + +} // namespace esphome::pcm5122 diff --git a/esphome/components/pcm5122/pcm5122_gpio.cpp b/esphome/components/pcm5122/pcm5122_gpio.cpp new file mode 100644 index 0000000000..1aef130457 --- /dev/null +++ b/esphome/components/pcm5122/pcm5122_gpio.cpp @@ -0,0 +1,69 @@ +#include "pcm5122_gpio.h" + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::pcm5122 { + +static const char *const TAG = "pcm5122.gpio"; + +void PCM5122GPIOPin::setup() { this->pin_mode(this->flags_); } + +void PCM5122GPIOPin::pin_mode(gpio::Flags flags) { + this->flags_ = flags; + if (!this->parent_->select_page_(0)) { + ESP_LOGE(TAG, "Failed to select page 0"); + return; + } + optional curr = this->parent_->read_byte(PCM5122_REG_GPIO_ENABLE); + if (!curr.has_value()) { + ESP_LOGE(TAG, "Failed to read GPIO_ENABLE"); + return; + } + if (flags & gpio::FLAG_INPUT) { + this->parent_->reg(PCM5122_REG_GPIO_ENABLE) = curr.value() & ~(1 << (this->pin_ - 1)); + } else if (flags & gpio::FLAG_OUTPUT) { + this->parent_->reg(PCM5122_REG_GPIO_ENABLE) = curr.value() | (1 << (this->pin_ - 1)); + this->parent_->reg(PCM5122_REG_GPIO_OUTPUT_SELECT + (this->pin_ - 1)) = PCM5122_GPIO_OUTPUT_SELECT_REGISTER; + optional invert = this->parent_->read_byte(PCM5122_REG_GPIO_INVERT); + if (!invert.has_value()) { + ESP_LOGE(TAG, "Failed to read GPIO_INVERT"); + return; + } + if (this->inverted_) { + this->parent_->reg(PCM5122_REG_GPIO_INVERT) = invert.value() | (1 << (this->pin_ - 1)); + } else { + this->parent_->reg(PCM5122_REG_GPIO_INVERT) = invert.value() & ~(1 << (this->pin_ - 1)); + } + } +} + +void PCM5122GPIOPin::digital_write(bool value) { + if (!this->parent_->select_page_(0)) + return; + optional curr = this->parent_->read_byte(PCM5122_REG_GPIO_OUTPUT); + if (!curr.has_value()) + return; + if (value) { + this->parent_->reg(PCM5122_REG_GPIO_OUTPUT) = curr.value() | (1 << (this->pin_ - 1)); + } else { + this->parent_->reg(PCM5122_REG_GPIO_OUTPUT) = curr.value() & ~(1 << (this->pin_ - 1)); + } +} + +bool PCM5122GPIOPin::digital_read() { + if (!this->parent_->select_page_(0)) + return this->value_; + optional read = this->parent_->read_byte(PCM5122_REG_GPIO_INPUT); + if (read.has_value()) { + // GPIO input register has RSV at bit 0; GPIN_N is at bit N (unlike other GPIO registers) + this->value_ = !!(read.value() & (1 << this->pin_)) != this->inverted_; + } + return this->value_; +} + +size_t PCM5122GPIOPin::dump_summary(char *buffer, size_t len) const { + return buf_append_printf(buffer, len, 0, "PCM5122 GPIO%u", this->pin_); +} + +} // namespace esphome::pcm5122 diff --git a/esphome/components/pcm5122/pcm5122_gpio.h b/esphome/components/pcm5122/pcm5122_gpio.h new file mode 100644 index 0000000000..8edaa6d3e8 --- /dev/null +++ b/esphome/components/pcm5122/pcm5122_gpio.h @@ -0,0 +1,29 @@ +#pragma once + +#include "esphome/core/gpio.h" + +#include "pcm5122.h" + +namespace esphome::pcm5122 { + +class PCM5122GPIOPin : public GPIOPin, public Parented { + public: + void setup() override; + void pin_mode(gpio::Flags flags) override; + bool digital_read() override; + void digital_write(bool value) override; + size_t dump_summary(char *buffer, size_t len) const override; + + void set_pin(uint8_t pin) { this->pin_ = pin; } + void set_inverted(bool inverted) { this->inverted_ = inverted; } + void set_flags(gpio::Flags flags) { this->flags_ = flags; } + gpio::Flags get_flags() const override { return this->flags_; } + + protected: + uint8_t pin_{0}; + bool inverted_{false}; + gpio::Flags flags_{gpio::FLAG_NONE}; + bool value_{false}; +}; + +} // namespace esphome::pcm5122 diff --git a/tests/components/pcm5122/common.yaml b/tests/components/pcm5122/common.yaml new file mode 100644 index 0000000000..cf96f57464 --- /dev/null +++ b/tests/components/pcm5122/common.yaml @@ -0,0 +1,24 @@ +audio_dac: + - platform: pcm5122 + id: pcm5122_dac + i2c_id: i2c_bus + address: 0x4D + bits_per_sample: 32bit + +output: + - platform: gpio + id: pcm5122_amp_enable + pin: + pcm5122: pcm5122_dac + number: 3 + mode: + output: true + +binary_sensor: + - platform: gpio + id: pcm5122_gpio_input + pin: + pcm5122: pcm5122_dac + number: 4 + mode: + input: true diff --git a/tests/components/pcm5122/test.esp32-ard.yaml b/tests/components/pcm5122/test.esp32-ard.yaml new file mode 100644 index 0000000000..7c503b0ccb --- /dev/null +++ b/tests/components/pcm5122/test.esp32-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/pcm5122/test.esp32-idf.yaml b/tests/components/pcm5122/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/pcm5122/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/pcm5122/test.esp8266-ard.yaml b/tests/components/pcm5122/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4a98b9388a --- /dev/null +++ b/tests/components/pcm5122/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/pcm5122/test.rp2040-ard.yaml b/tests/components/pcm5122/test.rp2040-ard.yaml new file mode 100644 index 0000000000..319a7c71a6 --- /dev/null +++ b/tests/components/pcm5122/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml From 25d656d468ae8e51921d34f5a372951d93c66da1 Mon Sep 17 00:00:00 2001 From: PolarGoose <35307286+PolarGoose@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:04:10 +0200 Subject: [PATCH 0360/1815] [dsmr] Update dsmr_parser library to 1.9.0 (#16881) --- .clang-tidy.hash | 2 +- esphome/components/dsmr/__init__.py | 2 +- platformio.ini | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 591ca3eb4d..566cac066e 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -a1aa12cb72cb0cc57c25649aafed8412434b013885cfda107f8aac5c083b4577 +def25306bb0f5e09b94fe7b74ffa6995a56bb951e7a27d9ad0a21103532a74a9 diff --git a/esphome/components/dsmr/__init__.py b/esphome/components/dsmr/__init__.py index 05f9a78156..1dc3664602 100644 --- a/esphome/components/dsmr/__init__.py +++ b/esphome/components/dsmr/__init__.py @@ -87,7 +87,7 @@ async def to_code(config): cg.add_build_flag("-DDSMR_WATER_MBUS_ID=" + str(config[CONF_WATER_MBUS_ID])) cg.add_build_flag("-DDSMR_THERMAL_MBUS_ID=" + str(config[CONF_THERMAL_MBUS_ID])) - cg.add_library("esphome/dsmr_parser", "1.8.0") + cg.add_library("esphome/dsmr_parser", "1.9.0") def final_validate(config: ConfigType) -> ConfigType: diff --git a/platformio.ini b/platformio.ini index 251339fb5f..b41e850bcd 100644 --- a/platformio.ini +++ b/platformio.ini @@ -37,7 +37,7 @@ lib_deps_base = wjtje/qr-code-generator-library@1.7.0 ; qr_code functionpointer/arduino-MLX90393@1.0.2 ; mlx90393 pavlodn/HaierProtocol@0.9.31 ; haier - esphome/dsmr_parser@1.8.0 ; dsmr + esphome/dsmr_parser@1.9.0 ; dsmr https://github.com/esphome/TinyGPSPlus.git#v1.1.0 ; gps ; This is using the repository until a new release is published to PlatformIO https://github.com/Sensirion/arduino-gas-index-algorithm.git#3.2.1 ; Sensirion Gas Index Algorithm Arduino Library From 5faed9d5f5284b9182d5e12af578ba91c38a395a Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Tue, 9 Jun 2026 13:04:51 +0200 Subject: [PATCH 0361/1815] [nrf52] native build - download toolchain and sdk in venv (#16388) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: Jonathan Swoboda --- esphome/components/nrf52/__init__.py | 13 + esphome/components/nrf52/framework.py | 171 ++++ esphome/const.py | 1 + esphome/core/__init__.py | 4 + esphome/espidf/framework.py | 597 +---------- esphome/framework_helpers.py | 677 +++++++++++++ requirements.txt | 1 + tests/unit_tests/test_core.py | 7 + tests/unit_tests/test_espidf_framework.py | 530 +++++++++- tests/unit_tests/test_framework_helpers.py | 954 ++++++++++++++++++ tests/unit_tests/test_nrf52_framework.py | 219 ++++ tests/unit_tests/test_platformio_toolchain.py | 15 + 12 files changed, 2624 insertions(+), 565 deletions(-) create mode 100644 esphome/components/nrf52/framework.py create mode 100644 esphome/framework_helpers.py create mode 100644 tests/unit_tests/test_framework_helpers.py create mode 100644 tests/unit_tests/test_nrf52_framework.py diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 48b67e1ef9..56367d0b26 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -63,6 +63,7 @@ from .const import ( BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, ) +from .framework import check_and_install # force import gpio to register pin schema from .gpio import nrf52_pin_to_code # noqa: F401 @@ -562,3 +563,15 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> _LOGGER.error("LR: %s", _addr2line(addr2line, elf, lr)) return False + + +def run_compile(args, config: ConfigType) -> bool: + if CORE.using_toolchain_platformio: + return False + if not CORE.using_toolchain_sdk_nrf: + raise EsphomeError( + "Unsupported toolchain for nRF52. " + "Supported toolchains are 'platformio' and 'sdk-nrf'." + ) + check_and_install() + raise EsphomeError("Native build for nRF52 is not implemented yet") diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py new file mode 100644 index 0000000000..607ad0c7ed --- /dev/null +++ b/esphome/components/nrf52/framework.py @@ -0,0 +1,171 @@ +import logging +import os +from pathlib import Path +import platform +import tempfile + +from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION +from esphome.core import CORE, EsphomeError +from esphome.framework_helpers import ( + archive_extract_all, + create_venv, + download_from_mirrors, + get_python_env_executable_path, + rmdir, + run_command_ok, + str_to_lst_of_str, +) + +_LOGGER = logging.getLogger(__name__) + +_WEST_VERSION = "1.5.0" +_TOOLCHAIN_VERSION = "0.17.4" + +SDK_NG_TOOLCHAIN_MIRRORS = str_to_lst_of_str( + os.environ.get( + "ESPHOME_SDK_NG_TOOLCHAIN_MIRRORS", + "https://github.com/zephyrproject-rtos/sdk-ng/releases/download/v{VERSION}/toolchain_{sysname}-{machine}_arm-zephyr-eabi.{extension}", + ) +) + + +def _get_tools_path() -> Path: + return CORE.data_dir / "sdk-nrf" + + +def _get_python_env_path(version: str) -> Path: + return _get_tools_path() / "penvs" / version + + +def _get_framework_path(version: str) -> Path: + return _get_tools_path() / "frameworks" / f"{version}" + + +def _get_toolchain_path(version: str) -> Path: + return _get_tools_path() / "toolchains" / f"{version}" + + +# onexc/dir_fd were added to shutil.rmtree in 3.12; the 3.11 branch uses onerror. +_SITECUSTOMIZE = """\ +import os, stat, shutil, sys +_orig = shutil.rmtree +def _handler(func, path, exc): + os.chmod(path, stat.S_IWRITE); func(path) +if sys.version_info >= (3, 12): + def _rmtree(path, ignore_errors=False, onerror=None, *, onexc=None, dir_fd=None): + if onerror is None and onexc is None: + onexc = _handler + return _orig(path, ignore_errors=ignore_errors, onerror=onerror, onexc=onexc, dir_fd=dir_fd) +else: + def _rmtree(path, ignore_errors=False, onerror=None): + if onerror is None: + onerror = _handler + return _orig(path, ignore_errors=ignore_errors, onerror=onerror) +shutil.rmtree = _rmtree +""" + + +def _install_sitecustomize(python_env_path: Path) -> None: + """Patch shutil.rmtree inside the penv to handle read-only files. + + west init's shutil.move falls back to copytree+rmtree on Windows, and + rmtree dies on the read-only .idx/.pack files git just wrote into + manifest-tmp. Dropping a sitecustomize.py into the venv applies the + same fix esphome.helpers.rmtree uses, but inside the subprocess. + """ + if os.name != "nt": + return + site_packages = python_env_path / "Lib" / "site-packages" + site_packages.mkdir(parents=True, exist_ok=True) + (site_packages / "sitecustomize.py").write_text(_SITECUSTOMIZE, encoding="utf-8") + + +def _get_toolchain_platform_info() -> tuple[str, str, str]: + """Return (sysname, machine, extension) for the current host.""" + extension = "tar.xz" + sysname = platform.system().lower() + machine = platform.machine() + if machine == "arm64": + machine = "aarch64" + if sysname == "darwin": + sysname = "macos" + elif sysname == "windows": + machine = "x86_64" + extension = "7z" + return sysname, machine, extension + + +def check_and_install() -> None: + framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] + version = f"v{framework_ver.major}.{framework_ver.minor}.{framework_ver.patch}" + python_env_path = _get_python_env_path(version) + env_python_path = get_python_env_executable_path(python_env_path, "python") + sentinel = python_env_path / ".ready" + install_venv = not sentinel.exists() + if install_venv: + rmdir(python_env_path, msg=f"Clean up {version} Python environment") + + create_venv(python_env_path, msg=f"{version}") + + _install_sitecustomize(python_env_path) + + _LOGGER.info("Installing west %s ...", _WEST_VERSION) + cmd = [str(env_python_path), "-m", "pip", "install", f"west=={_WEST_VERSION}"] + if not run_command_ok(cmd): + raise EsphomeError(f"Install west for {version} Python environment failure") + sentinel.touch() + + framework_path = _get_framework_path(version) + sentinel = framework_path / ".ready" + if install_venv or not sentinel.exists(): + rmdir(framework_path, msg=f"Clean up {version} framework environment") + _LOGGER.info("Initializing nRF Connect SDK %s ...", version) + cmd = [ + str(env_python_path), + "-m", + "west", + "init", + "-m", + "https://github.com/nrfconnect/sdk-nrf", + "--mr", + f"{version}", + str(framework_path), + ] + if not run_command_ok(cmd): + raise EsphomeError(f"Can't initialize nRF Connect SDK {version}") + _LOGGER.info("Updating nRF Connect SDK %s (this may take a while) ...", version) + cmd = [ + str(env_python_path), + "-m", + "west", + "update", + "--narrow", + "--fetch-opt=--depth=1", + ] + if not run_command_ok(cmd, cwd=framework_path): + raise EsphomeError(f"Can't update nRF Connect SDK {version}") + sentinel.touch() + + toolchains_dir = _get_toolchain_path(_TOOLCHAIN_VERSION) + sentinel = toolchains_dir / ".ready" + if not sentinel.exists(): + rmdir( + toolchains_dir, msg=f"Clean up {_TOOLCHAIN_VERSION} toolchain environment" + ) + with tempfile.NamedTemporaryFile() as tmp: + _LOGGER.info("Downloading %s toolchain ...", _TOOLCHAIN_VERSION) + + sysname, machine, extension = _get_toolchain_platform_info() + + download_from_mirrors( + SDK_NG_TOOLCHAIN_MIRRORS, + { + "VERSION": _TOOLCHAIN_VERSION, + "sysname": sysname, + "machine": machine, + "extension": extension, + }, + tmp.file, + ) + archive_extract_all(tmp.file, toolchains_dir, progress_header="Extracting") + sentinel.touch() diff --git a/esphome/const.py b/esphome/const.py index 07f6bad771..22351244bd 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -20,6 +20,7 @@ class Toolchain(StrEnum): PLATFORMIO = "platformio" ESP_IDF = "esp-idf" + SDK_NRF = "sdk-nrf" class Platform(StrEnum): diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index df8fd0a756..90c162fedd 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -867,6 +867,10 @@ class EsphomeCore: def using_toolchain_platformio(self): return self.toolchain == Toolchain.PLATFORMIO + @property + def using_toolchain_sdk_nrf(self): + return self.toolchain == Toolchain.SDK_NRF + @property def using_zephyr(self): return self.target_framework == "zephyr" diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 2c520d0d2c..1bc79cc412 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -1,8 +1,5 @@ """ESP-IDF framework tools for ESPHome.""" -from collections.abc import Iterable -from contextlib import ExitStack -import io import json import logging import os @@ -10,39 +7,29 @@ from pathlib import Path import platform import re import shutil -import subprocess -import sys import tempfile -from typing import IO - -import requests from esphome.config_validation import Version from esphome.core import CORE -from esphome.helpers import ProgressBar, get_str_env, rmtree, write_file_if_changed - -PathType = str | os.PathLike +from esphome.framework_helpers import ( + PathType, + archive_extract_all, + create_venv, + download_from_mirrors, + get_python_env_executable_path, + get_system_python_path, + rmdir, + run_command, + run_command_ok, + str_to_lst_of_str, +) +from esphome.helpers import get_str_env, write_file_if_changed _LOGGER = logging.getLogger(__name__) _SCRIPTS_DIR = Path(__file__).parent -def _str_to_lst_of_str(a: str | list[str]) -> list[str]: - """ - Convert a string to a list of string - - Args: - a: A string containing semicolon-separated values, or an already-split list - - Returns: - list of strings - """ - if isinstance(a, list): - return a - return [f.strip() for f in a.split(";") if f.strip()] - - ESPHOME_STAMP_FILE = ".esphome.stamp.json" # Cache-buster baked into the stamp file. Bump this whenever a change would @@ -54,23 +41,23 @@ ESPHOME_STAMP_FILE = ".esphome.stamp.json" # Bumping triggers a full reinstall on every user's next run. STAMP_SCHEMA_VERSION = "0" -ESPHOME_IDF_DEFAULT_TARGETS = _str_to_lst_of_str( +ESPHOME_IDF_DEFAULT_TARGETS = str_to_lst_of_str( os.environ.get("ESPHOME_IDF_DEFAULT_TARGETS", "all") ) -ESPHOME_IDF_DEFAULT_TOOLS = _str_to_lst_of_str( +ESPHOME_IDF_DEFAULT_TOOLS = str_to_lst_of_str( os.environ.get("ESPHOME_IDF_DEFAULT_TOOLS", "cmake;ninja") ) -ESPHOME_IDF_DEFAULT_TOOLS_FORCE = _str_to_lst_of_str( +ESPHOME_IDF_DEFAULT_TOOLS_FORCE = str_to_lst_of_str( os.environ.get("ESPHOME_IDF_DEFAULT_TOOLS_FORCE", "required") ) -ESPHOME_IDF_DEFAULT_FEATURES = _str_to_lst_of_str( +ESPHOME_IDF_DEFAULT_FEATURES = str_to_lst_of_str( os.environ.get("ESPHOME_IDF_DEFAULT_FEATURES", "core") ) -ESPHOME_IDF_FRAMEWORK_MIRRORS = _str_to_lst_of_str( +ESPHOME_IDF_FRAMEWORK_MIRRORS = str_to_lst_of_str( os.environ.get("ESPHOME_IDF_FRAMEWORK_MIRRORS") or [ "https://github.com/esphome-libs/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.tar.xz", @@ -78,7 +65,7 @@ ESPHOME_IDF_FRAMEWORK_MIRRORS = _str_to_lst_of_str( ] ) -ESP_IDF_CONSTRAINTS_MIRRORS = _str_to_lst_of_str( +ESP_IDF_CONSTRAINTS_MIRRORS = str_to_lst_of_str( os.environ.get( "ESP_IDF_CONSTRAINTS_MIRRORS", "https://dl.espressif.com/dl/esp-idf/espidf.constraints.v{VERSION}.txt", @@ -124,59 +111,6 @@ def _get_python_env_path(version: str) -> Path: return _get_idf_tools_path() / "penvs" / f"{version}" -def rmdir(directory: PathType, msg: str | None = None): - """ - Remove a directory and its contents recursively if it exists. - - Args: - directory: Path to the directory to be removed - msg: Optional debug message to log before removal or it an error occurs - - Returns: - None - - Raises: - RuntimeError: If directory removal fails - """ - if Path(directory).is_dir(): - try: - if msg: - _LOGGER.debug(msg) - rmtree(directory) - except OSError as e: - raise RuntimeError( - f"Error during {msg}: can't remove `{directory}`. Please remove it manually!" - ) from e - - -def _get_pythonexe_path() -> str: - """ - Get the path to the Python executable. - - Returns: - Path to Python executable as string - """ - # Try to get PYTHONEXEPATH environment variable - # Fallback to sys.executable if not set - return os.environ.get("PYTHONEXEPATH", os.path.normpath(sys.executable)) - - -def _get_python_env_executable_path(root: PathType, binary: str) -> Path: - """ - Get the path to a Python environment executable file. - - Args: - root: Root directory of the Python environment - binary: Name of the executable binary - - Returns: - Path object pointing to the executable file - """ - if os.name == "nt": - return Path(root) / "Scripts" / f"{binary}.exe" - return Path(root) / "bin" / binary - - def _check_stamp(file: PathType, data: dict[str, str]) -> bool: """ Check if a stamp file contains the expected data. @@ -210,84 +144,6 @@ def _write_stamp(file: PathType, data: dict[str, str]): json.dump(data, fp) -def _exec( - cmd: list[str], - msg: str | None = None, - env: dict[str, str] | None = None, - stream_output: bool = False, -) -> tuple[bool, str | None, str | None]: - """ - Execute a command and return results. - - Args: - cmd: list of command arguments - msg: Optional custom message for logging - env: Optional dictionary of environment variables to set - stream_output: If True, inherit parent stdio so the subprocess prints - directly to the terminal (useful for commands that produce their - own progress output). stdout/stderr are not captured in this mode. - - Returns: - tuple of (success: bool, stdout: str or None, stderr: str or None). - When stream_output is True, stdout and stderr are always None. - """ - cmd_str = msg or " ".join(cmd) - try: - _LOGGER.debug("%s - running ...", cmd_str) - - run_env = os.environ.copy() - if env: - run_env.update(env) - - if stream_output: - result = subprocess.run(cmd, check=False, env=run_env) - stdout = stderr = None - else: - result = subprocess.run( - cmd, - capture_output=True, - text=True, - check=False, - env=run_env, - ) - stdout = result.stdout - stderr = result.stderr - - if result.returncode != 0: - if stream_output: - _LOGGER.error("%s - failed (returncode=%s)", cmd_str, result.returncode) - else: - tail = (stderr or stdout or "").strip()[-1000:] - _LOGGER.error( - "%s - failed (returncode=%s). Tail:\n%s", - cmd_str, - result.returncode, - tail, - ) - return False, stdout, stderr - - _LOGGER.debug("%s - executed successfully", cmd_str) - return True, stdout, stderr - - except (subprocess.SubprocessError, OSError) as e: - _LOGGER.error("%s - error: %s", cmd_str, str(e)) - return False, None, None - - -def _exec_ok(*args, **kwargs) -> bool: - """ - Execute a command and return only the success status. - - Args: - *args: Positional arguments to pass to _exec function - **kwargs: Keyword arguments to pass to _exec function - - Returns: - True if command executed successfully, False otherwise - """ - return _exec(*args, **kwargs)[0] - - def _get_idf_version( idf_framework_root: PathType, env: dict[str, str] | None = None ) -> str: @@ -306,12 +162,12 @@ def _get_idf_version( """ cmd = [ - _get_pythonexe_path(), + get_system_python_path(), str(_SCRIPTS_DIR / "get_idf_version.py"), str(idf_framework_root), ] - success, stdout, stderr = _exec( + success, stdout, stderr = run_command( cmd, msg="ESP-IDF version", env=(env or os.environ) @@ -346,12 +202,12 @@ def _get_idf_tool_paths( """ cmd = [ - _get_pythonexe_path(), + get_system_python_path(), str(_SCRIPTS_DIR / "get_idf_tool_paths.py"), str(idf_framework_root), ] - success, stdout, stderr = _exec( + success, stdout, stderr = run_command( cmd, msg="ESP-IDF tool paths", env=(env or os.environ) @@ -397,7 +253,7 @@ print(".".join([str(x) for x in sys.version_info])) """ cmd = [python_executable, "-c", script] - success, stdout, _ = _exec(cmd, msg="Python version", env=env) + success, stdout, _ = run_command(cmd, msg="Python version", env=env) if stdout: stdout = stdout.strip() @@ -406,393 +262,6 @@ print(".".join([str(x) for x in sys.version_info])) return stdout -def _create_venv(root: PathType, msg: str | None = None): - """ - Create a Python virtual environment. - - Args: - root: Path to the virtual environment directory - msg: Optional message for logging - - Returns: - None - - Raises: - Exception: If virtual environment creation fails - """ - cmd = [_get_pythonexe_path(), "-m", "venv", "--clear", root] - if not _exec_ok(cmd, msg=f"Create Python virtual environment for {msg}"): - raise RuntimeError(f"Can't create Python virtual environment for {msg}") - - -def _detect_archive_root(names: Iterable[str]) -> str | None: - """Detect a single top-level directory shared by all archive entries. - - Returns the directory name if every non-empty entry sits under the same - top-level directory, else ``None``. Extraction helpers use this to strip - the wrapper directory commonly found in source archives during extraction - rather than renaming it afterwards — post-extraction renames are - unreliable on Windows because antivirus and the search indexer briefly - hold handles on freshly written files. - """ - root: str | None = None - has_descendant = False - for raw in names: - name = raw.replace("\\", "/").strip("/") - if not name: - continue - first, sep, _ = name.partition("/") - if root is None: - root = first - elif root != first: - return None - if sep: - has_descendant = True - return root if has_descendant else None - - -def _tar_extract_all( - data: io.BufferedIOBase, - extract_dir: PathType = ".", - progress_header: str | None = None, -): - """ - Extract a TAR archive to the specified directory. - - Implementation is inspired by Python 3.12's tarfile data filtering logic. - This can be replaced with the standard library implementation once - support for Python 3.11 is no longer required. - - Args: - data: File-like object containing the TAR archive - extract_dir: Directory to extract contents to - progress_header: If set, show a progress bar with this header - """ - import stat - import tarfile - - # Tar extraction safety: os.path.realpath / commonpath / normpath have no - # pathlib equivalents and Path.resolve() would follow symlinks unsafely. - # Use os.path for the security-sensitive parts; the simple checks move to - # Path. - extract_dir = os.fspath(extract_dir) - abs_dest = os.path.abspath(extract_dir) # noqa: PTH100 - - with tarfile.open(fileobj=data, mode="r") as tar_ref: - all_members = tar_ref.getmembers() - - # Detect a single common top-level directory and strip it during - # extraction so we don't have to flatten it via a rename afterwards. - strip_root = _detect_archive_root(m.name for m in all_members) - strip_prefix = f"{strip_root}/" if strip_root is not None else None - - safe_members = [] - - for member in all_members: - name = member.name - - # 1. Strip leading slashes - name = name.lstrip("/" + os.sep) - - # 2. Reject absolute paths (incl. Windows drive) - if Path(name).is_absolute() or ( - os.name == "nt" and ":" in name.split(os.sep)[0] # noqa: PTH206 - ): - continue - - # 3. Strip wrapper directory if one was detected - if strip_prefix is not None: - norm = name.replace("\\", "/") - if norm in (strip_root, strip_prefix): - continue - if not norm.startswith(strip_prefix): - continue - name = norm[len(strip_prefix) :] - - # 4. Compute final path - target_path = os.path.realpath(os.path.join(abs_dest, name)) # noqa: PTH118 - if os.path.commonpath([abs_dest, target_path]) != abs_dest: - continue - - # 5. Validate links properly - if member.issym() or member.islnk(): - linkname = member.linkname - - # Reject absolute link targets - if Path(linkname).is_absolute(): - continue - - # Strip leading slashes - linkname = os.path.normpath(linkname) - - if member.issym(): - link_target = os.path.join( # noqa: PTH118 - abs_dest, - os.path.dirname(name), # noqa: PTH120 - linkname, - ) - else: - link_target = os.path.join(abs_dest, linkname) # noqa: PTH118 - link_target = os.path.realpath(link_target) - - if os.path.commonpath([abs_dest, link_target]) != abs_dest: - continue - - # write back normalized linkname - member.linkname = linkname - - # 6. Sanitize permissions - mode = member.mode - if mode is not None: - # Strip high bits & group/other write bits - mode &= ( - stat.S_IRWXU - | stat.S_IRGRP - | stat.S_IXGRP - | stat.S_IROTH - | stat.S_IXOTH - ) - if member.isfile() or member.islnk(): - # remove exec bits unless explicitly user-executable - if not (mode & stat.S_IXUSR): - mode &= ~(stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - mode |= stat.S_IRUSR | stat.S_IWUSR - elif not (member.isdir() or member.issym()): - # Block special files. Directories and symlinks keep - # their masked-original mode — passing None here would - # crash tarfile.extract on Python <3.12 (its chmod - # path calls os.chmod unconditionally). - continue - - member.mode = mode - - # 7. Strip ownership - member.uid = None - member.gid = None - member.uname = None - member.gname = None - - # 8. Assign sanitized name back - member.name = name - - safe_members.append(member) - - total = len(safe_members) - progress = ( - ProgressBar(progress_header) if progress_header and total > 0 else None - ) - for i, member in enumerate(safe_members, 1): - tar_ref.extract(member, abs_dest) - if progress is not None: - progress.update(i / total) - if progress is not None: - progress.update(1) - - -def _zip_extract_all( - data: io.BufferedIOBase, - extract_dir: PathType = ".", - progress_header: str | None = None, -): - """ - Extract a ZIP archive to the specified directory. - - Args: - data: File-like object containing the ZIP archive - extract_dir: Directory to extract contents to - progress_header: If set, show a progress bar with this header - """ - import zipfile - - # See note in archive_extract_all_tar: os.path is used intentionally for - # the security-sensitive abspath/commonpath checks below. - extract_dir = os.path.abspath(extract_dir) # noqa: PTH100 - - with zipfile.ZipFile(data, "r") as zip_ref: - all_members = zip_ref.infolist() - - # Detect a single common top-level directory and strip it during - # extraction so we don't have to flatten it via a rename afterwards. - strip_root = _detect_archive_root(m.filename for m in all_members) - strip_prefix = f"{strip_root}/" if strip_root is not None else None - - total = len(all_members) - progress = ( - ProgressBar(progress_header) if progress_header and total > 0 else None - ) - - for i, member in enumerate(all_members, 1): - # 1. Normalize name - name = member.filename.lstrip("/\\") - - # 2. Reject absolute paths / Windows drives - if Path(name).is_absolute() or ( - os.name == "nt" and ":" in name.split(os.sep)[0] # noqa: PTH206 - ): - continue - - # 3. Strip wrapper directory if one was detected - if strip_prefix is not None: - norm = name.replace("\\", "/") - if norm in (strip_root, strip_prefix): - continue - if not norm.startswith(strip_prefix): - continue - name = norm[len(strip_prefix) :] - - # 4. Compute safe target path - target_path = os.path.abspath(os.path.join(extract_dir, name)) # noqa: PTH100, PTH118 - - if os.path.commonpath([extract_dir, target_path]) != extract_dir: - raise ValueError(f"Unsafe path detected: {member.filename}") - - # 5. Assign sanitized name back - member.filename = name - - # 6. Extract - zip_ref.extract(member, extract_dir) - - if progress is not None: - progress.update(i / total) - if progress is not None: - progress.update(1) - - -_ARCHIVE_MAGIC_MAP = { - b"\x1f\x8b\x08": _tar_extract_all, - b"\x42\x5a\x68": _tar_extract_all, - b"\xfd\x37\x7a\x58\x5a\x00": _tar_extract_all, - b"\x50\x4b\x03\x04": _zip_extract_all, -} - - -def archive_extract_all( - archive: PathType | io.RawIOBase | IO[bytes], - extract_dir: PathType = ".", - progress_header: str | None = None, -): - """ - Extract an archive file to the specified directory. - - Args: - archive: Path to archive file or file-like object - extract_dir: Directory to extract contents to - progress_header: If set, show a progress bar with this header - - Raises: - TypeError: If archive is not a valid type - ValueError: If archive format is unsupported - """ - - # 1. Handle different archive input types - with ExitStack() as stack: - archive_ref: io.BufferedIOBase - if isinstance(archive, (str, os.PathLike)): - archive_ref = stack.enter_context(Path(archive).open("rb")) - elif isinstance(archive, (io.BufferedReader, io.BufferedRandom)): - archive_ref = archive - elif isinstance(archive, io.RawIOBase): - archive_ref = io.BufferedReader(archive) - else: - raise TypeError( - f"archive must be str, Path, or file-like object: {type(archive)}" - ) - - # 2. Detect archive format and select appropriate extraction function - matched_fct = None - magic_len = max(len(k) for k in _ARCHIVE_MAGIC_MAP) - header = archive_ref.peek(magic_len) - for magic, fct in _ARCHIVE_MAGIC_MAP.items(): - if header.startswith(magic): - matched_fct = fct - break - if matched_fct is None: - raise ValueError("Unsupported archive format") - matched_fct(archive_ref, extract_dir, progress_header=progress_header) - - -def download_from_mirrors( - mirrors: list[str], - substitutions: dict[str, str], - target: io.RawIOBase | IO[bytes] | PathType, - timeout: int = 30, -) -> str | None: - """ - Download file from multiple mirrors with substitution support. - - Args: - mirrors: list of mirror URLs - substitutions: Dictionary of substitutions to apply to URLs - target: Target file path or file-like object - timeout: Download timeout in seconds - - Returns: - The source URL. - - Raises: - Exception: If all download attempts fail - """ - # 1. Open target file for writing if path given - with ExitStack() as stack: - if isinstance(target, (str, os.PathLike)): - f = stack.enter_context(Path(target).open("wb")) - elif isinstance(target, (io.RawIOBase, io.IOBase)): - f = target - else: - raise TypeError( - f"target must be str, Path, or file-like object: {type(target)}" - ) - - # 2. Try each mirror in order - last_exception = None - - for mirror in mirrors: - # 3. Apply substitutions to URL - url = mirror.format(**substitutions) - - _LOGGER.debug("Trying downloading from %s", url) - - try: - # 4. Reset file pointer and download - f.seek(0) - f.truncate(0) - - with requests.get(url, stream=True, timeout=timeout) as r: - r.raise_for_status() - - total_size = int(r.headers.get("content-length", 0)) - downloaded = 0 - - progress = ProgressBar("Downloading") if total_size > 0 else None - - for chunk in r.iter_content(chunk_size=8192): - if chunk: - f.write(chunk) - - downloaded += len(chunk) - - if progress is not None: - progress.update(downloaded / total_size) - - if progress is not None: - progress.update(1) - - _LOGGER.debug("Downloaded successfully from: %s", url) - - # 6. Reset file pointer and return - f.seek(0) - return url - - except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught - _LOGGER.debug("Failed to download %s: %s", url, str(e)) - last_exception = e - - # 7. Raise last exception if all mirrors failed - if last_exception: - raise last_exception - return None - - _GITHUB_SHORTHAND_RE = re.compile( r"^github://([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+?)(?:@([a-zA-Z0-9\-_.\./]+))?$" ) @@ -1067,12 +536,12 @@ def _check_esphome_idf_framework_install( if _check_stamp(env_stamp_file, stamp_info): _LOGGER.info("Checking ESP-IDF %s framework installation ...", version) cmd = [ - _get_pythonexe_path(), + get_system_python_path(), str(idf_tools_path), "--non-interactive", "check", ] - if _exec_ok(cmd, msg=f"ESP-IDF {version} check", env=env): + if run_command_ok(cmd, msg=f"ESP-IDF {version} check", env=env): install = False # 4. Install framework tools if not installed or needs update @@ -1080,13 +549,13 @@ def _check_esphome_idf_framework_install( _LOGGER.info("Installing ESP-IDF %s framework ...", version) targets_str = ",".join(targets) cmd = [ - _get_pythonexe_path(), + get_system_python_path(), str(idf_tools_path), "--non-interactive", "install", f"--targets={targets_str}", ] + tools - if not _exec_ok( + if not run_command_ok( cmd, msg=f"ESP-IDF {version} framework installation", env=env, @@ -1128,7 +597,7 @@ def _check_esp_idf_python_env_install( framework_path = _get_framework_path(version) python_env_path = _get_python_env_path(version) env_stamp_file = python_env_path / ESPHOME_STAMP_FILE - env_python_path = _get_python_env_executable_path(python_env_path, "python") + env_python_path = get_python_env_executable_path(python_env_path, "python") _LOGGER.info("Checking ESP-IDF %s Python environment ...", version) install = force or not python_env_path.is_dir() or not env_python_path.is_file() @@ -1144,7 +613,7 @@ def _check_esp_idf_python_env_install( if install: rmdir(python_env_path, msg=f"Clean up ESP-IDF {version} Python environment") - _create_venv(python_env_path, msg=f"ESP-IDF {version}") + create_venv(python_env_path, msg=f"ESP-IDF {version}") esp_idf_version = _get_idf_version(framework_path, env=env) constraint_file_path = ( @@ -1174,7 +643,7 @@ def _check_esp_idf_python_env_install( "pip", "setuptools", ] - if not _exec_ok( + if not run_command_ok( cmd, msg=f"Upgrade ESP-IDF {version} Python environment packages", env=env, @@ -1194,7 +663,7 @@ def _check_esp_idf_python_env_install( "-r", str(requirements_file), ] - if not _exec_ok( + if not run_command_ok( cmd, msg=f"Install ESP-IDF {version} Python dependencies for {feature}", env=env, @@ -1296,7 +765,7 @@ def get_framework_env( # 3. If Python environment path is provided, add it to PATH and set IDF_PYTHON_ENV_PATH if python_env_path: - python_path = _get_python_env_executable_path(python_env_path, "python") + python_path = get_python_env_executable_path(python_env_path, "python") path_list.insert(0, str(python_path.parent)) env["IDF_PYTHON_ENV_PATH"] = str(python_env_path) diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py new file mode 100644 index 0000000000..276dfbbf1c --- /dev/null +++ b/esphome/framework_helpers.py @@ -0,0 +1,677 @@ +"""Generic toolchain installation helpers shared across framework implementations.""" + +from collections.abc import Iterable +from contextlib import ExitStack +import io +import logging +import os +from pathlib import Path +import subprocess +import sys +import time +from typing import IO + +import requests + +from esphome.helpers import ProgressBar, rmtree + +PathType = str | os.PathLike + +_LOGGER = logging.getLogger(__name__) + + +def str_to_lst_of_str(a: str | list[str]) -> list[str]: + """ + Convert a string to a list of string + + Args: + a: A string containing semicolon-separated values, or an already-split list + + Returns: + list of strings + """ + if isinstance(a, list): + return a + return [f.strip() for f in a.split(";") if f.strip()] + + +def rmdir(directory: PathType, msg: str | None = None): + """ + Remove a directory and its contents recursively if it exists. + + Args: + directory: Path to the directory to be removed + msg: Optional debug message to log before removal or it an error occurs + + Returns: + None + + Raises: + RuntimeError: If directory removal fails + """ + if Path(directory).is_dir(): + try: + if msg: + _LOGGER.debug(msg) + rmtree(directory) + except OSError as e: + raise RuntimeError( + f"Error during {msg}: can't remove `{directory}`. Please remove it manually!" + ) from e + + +def get_system_python_path() -> str: + """ + Get the path to the Python executable. + + Returns: + Path to Python executable as string + """ + # Try to get PYTHONEXEPATH environment variable + # Fallback to sys.executable if not set + return os.environ.get("PYTHONEXEPATH", os.path.normpath(sys.executable)) + + +def get_python_env_executable_path(root: PathType, binary: str) -> Path: + """ + Get the path to a Python environment executable file. + + Args: + root: Root directory of the Python environment + binary: Name of the executable binary + + Returns: + Path object pointing to the executable file + """ + if os.name == "nt": + return Path(root) / "Scripts" / f"{binary}.exe" + return Path(root) / "bin" / binary + + +def run_command( + cmd: list[str], + msg: str | None = None, + env: dict[str, str] | None = None, + stream_output: bool = False, + cwd: PathType | None = None, +) -> tuple[bool, str | None, str | None]: + """ + Execute a command and return results. + + Args: + cmd: list of command arguments + msg: Optional custom message for logging + env: Optional dictionary of environment variables to set + stream_output: If True, inherit parent stdio so the subprocess prints + directly to the terminal (useful for commands that produce their + own progress output). stdout/stderr are not captured in this mode. + cwd: Optional working directory for the subprocess. + + Returns: + tuple of (success: bool, stdout: str or None, stderr: str or None). + When stream_output is True, stdout and stderr are always None. + """ + cmd_str = msg or " ".join(cmd) + try: + _LOGGER.debug("%s - running ...", cmd_str) + + run_env = os.environ.copy() + if env: + run_env.update(env) + + if stream_output: + result = subprocess.run(cmd, check=False, env=run_env, cwd=cwd) + stdout = stderr = None + else: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=False, + env=run_env, + cwd=cwd, + ) + stdout = result.stdout + stderr = result.stderr + + if result.returncode != 0: + if stream_output: + _LOGGER.error("%s - failed (returncode=%s)", cmd_str, result.returncode) + else: + tail = (stderr or stdout or "").strip()[-1000:] + _LOGGER.error( + "%s - failed (returncode=%s). Tail:\n%s", + cmd_str, + result.returncode, + tail, + ) + return False, stdout, stderr + + _LOGGER.debug("%s - executed successfully", cmd_str) + return True, stdout, stderr + + except (subprocess.SubprocessError, OSError) as e: + _LOGGER.error("%s - error: %s", cmd_str, str(e)) + return False, None, None + + +def run_command_ok(*args, **kwargs) -> bool: + """ + Execute a command and return only the success status. + + Args: + *args: Positional arguments to pass to run_command + **kwargs: Keyword arguments to pass to run_command + + Returns: + True if command executed successfully, False otherwise + """ + return run_command(*args, **kwargs)[0] + + +def create_venv(root: PathType, msg: str | None = None): + """ + Create a Python virtual environment. + + Args: + root: Path to the virtual environment directory + msg: Optional message for logging + + Returns: + None + + Raises: + RuntimeError: If virtual environment creation fails + """ + cmd = [get_system_python_path(), "-m", "venv", "--clear", root] + if not run_command_ok(cmd, msg=f"Create Python virtual environment for {msg}"): + raise RuntimeError(f"Can't create Python virtual environment for {msg}") + + +def _detect_archive_root(names: Iterable[str]) -> str | None: + """Detect a single top-level directory shared by all archive entries. + + Returns the directory name if every non-empty entry sits under the same + top-level directory, else ``None``. Extraction helpers use this to strip + the wrapper directory commonly found in source archives during extraction + rather than renaming it afterwards — post-extraction renames are + unreliable on Windows because antivirus and the search indexer briefly + hold handles on freshly written files. + """ + root: str | None = None + has_descendant = False + for raw in names: + name = raw.replace("\\", "/").strip("/") + if not name: + continue + first, sep, _ = name.partition("/") + if root is None: + root = first + elif root != first: + return None + if sep: + has_descendant = True + return root if has_descendant else None + + +def _tar_extract_all( + data: io.BufferedIOBase, + extract_dir: PathType = ".", + progress_header: str | None = None, +): + """ + Extract a TAR archive to the specified directory. + + Implementation is inspired by Python 3.12's tarfile data filtering logic. + This can be replaced with the standard library implementation once + support for Python 3.11 is no longer required. + + Args: + data: File-like object containing the TAR archive + extract_dir: Directory to extract contents to + progress_header: If set, show a progress bar with this header + """ + import stat + import tarfile + + # Tar extraction safety: os.path.realpath / commonpath / normpath have no + # pathlib equivalents and Path.resolve() would follow symlinks unsafely. + # Use os.path for the security-sensitive parts; the simple checks move to + # Path. + extract_dir = os.fspath(extract_dir) + abs_dest = os.path.abspath(extract_dir) # noqa: PTH100 + + with tarfile.open(fileobj=data, mode="r") as tar_ref: + all_members = tar_ref.getmembers() + + # Detect a single common top-level directory and strip it during + # extraction so we don't have to flatten it via a rename afterwards. + strip_root = _detect_archive_root(m.name for m in all_members) + strip_prefix = f"{strip_root}/" if strip_root is not None else None + + safe_members = [] + + for member in all_members: + name = member.name + + # 1. Strip leading slashes + name = name.lstrip("/" + os.sep) + + # 2. Reject absolute paths (incl. Windows drive) + if Path(name).is_absolute() or ( + os.name == "nt" and ":" in name.split(os.sep)[0] # noqa: PTH206 + ): + continue + + # 3. Strip wrapper directory if one was detected + if strip_prefix is not None: + norm = name.replace("\\", "/") + if norm in (strip_root, strip_prefix): + continue + if not norm.startswith(strip_prefix): + continue + name = norm[len(strip_prefix) :] + + # 4. Compute final path + target_path = os.path.realpath(os.path.join(abs_dest, name)) # noqa: PTH118 + if os.path.commonpath([abs_dest, target_path]) != abs_dest: + continue + + # 5. Validate links properly + if member.issym() or member.islnk(): + linkname = member.linkname + + # Reject absolute link targets + if Path(linkname).is_absolute(): + continue + + if member.islnk() and strip_prefix is not None: + # Hard-link linknames reference another archive member + # by its archive name. We've stripped the wrapper prefix + # from member.name above (step 3); strip it here too so + # tarfile._find_link_target can resolve the target during + # extraction. Symlink linknames are filesystem-relative + # paths, not archive-member references, so they don't + # need this treatment. + norm_link = linkname.replace("\\", "/") + if norm_link in (strip_root, strip_prefix): + continue + if not norm_link.startswith(strip_prefix): + continue + linkname = norm_link[len(strip_prefix) :] + + # Strip leading slashes + linkname = os.path.normpath(linkname) + + if member.issym(): + link_target = os.path.join( # noqa: PTH118 + abs_dest, + os.path.dirname(name), # noqa: PTH120 + linkname, + ) + else: + link_target = os.path.join(abs_dest, linkname) # noqa: PTH118 + link_target = os.path.realpath(link_target) + + if os.path.commonpath([abs_dest, link_target]) != abs_dest: + continue + + # write back normalized linkname + member.linkname = linkname + + # 6. Sanitize permissions + mode = member.mode + if mode is not None: + # Strip high bits & group/other write bits + mode &= ( + stat.S_IRWXU + | stat.S_IRGRP + | stat.S_IXGRP + | stat.S_IROTH + | stat.S_IXOTH + ) + if member.isfile() or member.islnk(): + # remove exec bits unless explicitly user-executable + if not (mode & stat.S_IXUSR): + mode &= ~(stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + mode |= stat.S_IRUSR | stat.S_IWUSR + elif not (member.isdir() or member.issym()): + # Block special files. Directories and symlinks keep + # their masked-original mode — passing None here would + # crash tarfile.extract on Python <3.12 (its chmod + # path calls os.chmod unconditionally). + continue + + member.mode = mode + + # 7. Strip ownership + member.uid = None + member.gid = None + member.uname = None + member.gname = None + + # 8. Assign sanitized name back + member.name = name + + safe_members.append(member) + + total = len(safe_members) + progress = ( + ProgressBar(progress_header) if progress_header and total > 0 else None + ) + for i, member in enumerate(safe_members, 1): + tar_ref.extract(member, abs_dest) + if progress is not None: + progress.update(i / total) + if progress is not None: + progress.update(1) + + +def _zip_extract_all( + data: io.BufferedIOBase, + extract_dir: PathType = ".", + progress_header: str | None = None, +): + """ + Extract a ZIP archive to the specified directory. + + Args: + data: File-like object containing the ZIP archive + extract_dir: Directory to extract contents to + progress_header: If set, show a progress bar with this header + """ + import zipfile + + # See note in _tar_extract_all: os.path is used intentionally for + # the security-sensitive abspath/commonpath checks below. + extract_dir = os.path.abspath(extract_dir) # noqa: PTH100 + + with zipfile.ZipFile(data, "r") as zip_ref: + all_members = zip_ref.infolist() + + # Detect a single common top-level directory and strip it during + # extraction so we don't have to flatten it via a rename afterwards. + strip_root = _detect_archive_root(m.filename for m in all_members) + strip_prefix = f"{strip_root}/" if strip_root is not None else None + + total = len(all_members) + progress = ( + ProgressBar(progress_header) if progress_header and total > 0 else None + ) + + for i, member in enumerate(all_members, 1): + # 1. Normalize name + name = member.filename.lstrip("/\\") + + # 2. Reject absolute paths / Windows drives + if Path(name).is_absolute() or ( + os.name == "nt" and ":" in name.split(os.sep)[0] # noqa: PTH206 + ): + continue + + # 3. Strip wrapper directory if one was detected + if strip_prefix is not None: + norm = name.replace("\\", "/") + if norm in (strip_root, strip_prefix): + continue + if not norm.startswith(strip_prefix): + continue + name = norm[len(strip_prefix) :] + + # 4. Compute safe target path + target_path = os.path.abspath(os.path.join(extract_dir, name)) # noqa: PTH100, PTH118 + + if os.path.commonpath([extract_dir, target_path]) != extract_dir: + raise ValueError(f"Unsafe path detected: {member.filename}") + + # 5. Assign sanitized name back + member.filename = name + + # 6. Extract + zip_ref.extract(member, extract_dir) + + if progress is not None: + progress.update(i / total) + if progress is not None: + progress.update(1) + + +def _rename_with_retry(src: Path, dst: Path, attempts: int = 5) -> None: + """Rename ``src`` to ``dst`` with backoff retries on Windows sharing violations. + + Antivirus/indexer handles on freshly-written files can briefly block + ``os.rename`` with ERROR_SHARING_VIOLATION / ERROR_ACCESS_DENIED. The + handle is released within tens of ms in practice, so exponential backoff + works. + """ + for i in range(attempts): + try: + src.rename(dst) + return + except PermissionError: + if i == attempts - 1: + raise + time.sleep(0.1 * (2**i)) + + +def _7z_extract_all( + data: io.BufferedIOBase, + extract_dir: PathType = ".", + progress_header: str | None = None, +): + """ + Extract a 7z archive to the specified directory. + + py7zr only supports bulk extraction (no per-member rename hook like + tarfile/zipfile), so we extract into a unique staging subdir of + ``extract_dir`` and then move children up. This keeps everything on + the same volume and sidesteps wrapper-vs-child name collisions + (e.g. ``arm-zephyr-eabi/`` containing another ``arm-zephyr-eabi/``). + + Args: + data: File-like object containing the 7z archive (must be seekable) + extract_dir: Directory to extract contents to + progress_header: If set, show a progress bar with this header + """ + import py7zr + + extract_dir = os.path.abspath(extract_dir) # noqa: PTH100 + Path(extract_dir).mkdir(parents=True, exist_ok=True) + + suffix = 0 + while True: + staging = Path(extract_dir) / f".extract_tmp_{suffix}" + if not staging.exists(): + break + suffix += 1 + staging.mkdir() + + try: + with py7zr.SevenZipFile(data, "r") as z: + all_names = z.getnames() + + # Detect a single common top-level directory to flatten. + strip_root = _detect_archive_root(all_names) + + # Validate names: reject absolute paths, Windows drives, and + # path traversal. Filter via targets= since py7zr can't rename + # per-member. + safe_targets: list[str] = [] + for raw in all_names: + name = raw.lstrip("/\\") + if not name: + continue + if Path(name).is_absolute() or ( + os.name == "nt" and ":" in name.split(os.sep)[0] # noqa: PTH206 + ): + continue + target_path = os.path.abspath(os.path.join(staging, name)) # noqa: PTH100, PTH118 + if os.path.commonpath([str(staging), target_path]) != str(staging): + continue + safe_targets.append(raw) + + progress = ( + ProgressBar(progress_header) + if progress_header and safe_targets + else None + ) + + if len(safe_targets) == len(all_names): + z.extractall(path=staging) + else: + z.extract(path=staging, targets=safe_targets) + + if progress is not None: + progress.update(1) + + src_root = staging / strip_root if strip_root else staging + for item in src_root.iterdir(): + dest = Path(extract_dir) / item.name + if dest.exists(): + if dest.is_dir(): + rmtree(dest) + else: + dest.unlink() + _rename_with_retry(item, dest) + finally: + # staging is created before the try, so it always exists here; the + # guard is defensive cleanup and its False branch is unreachable. + if staging.exists(): # pragma: no cover + rmtree(staging) + + +_ARCHIVE_MAGIC_MAP = { + b"\x1f\x8b\x08": _tar_extract_all, + b"\x42\x5a\x68": _tar_extract_all, + b"\xfd\x37\x7a\x58\x5a\x00": _tar_extract_all, + b"\x50\x4b\x03\x04": _zip_extract_all, + b"\x37\x7a\xbc\xaf\x27\x1c": _7z_extract_all, +} + + +def archive_extract_all( + archive: PathType | io.RawIOBase | IO[bytes], + extract_dir: PathType = ".", + progress_header: str | None = None, +): + """ + Extract an archive file to the specified directory. + + Args: + archive: Path to archive file or file-like object + extract_dir: Directory to extract contents to + progress_header: If set, show a progress bar with this header + + Raises: + TypeError: If archive is not a valid type + ValueError: If archive format is unsupported + """ + + # 1. Handle different archive input types + with ExitStack() as stack: + archive_ref: io.BufferedIOBase + if isinstance(archive, (str, os.PathLike)): + archive_ref = stack.enter_context(Path(archive).open("rb")) + elif isinstance(archive, (io.BufferedReader, io.BufferedRandom)): + archive_ref = archive + elif isinstance(archive, io.RawIOBase): + archive_ref = io.BufferedReader(archive) + else: + raise TypeError( + f"archive must be str, Path, or file-like object: {type(archive)}" + ) + + # 2. Detect archive format and select appropriate extraction function + matched_fct = None + magic_len = max(len(k) for k in _ARCHIVE_MAGIC_MAP) + header = archive_ref.peek(magic_len) + for magic, fct in _ARCHIVE_MAGIC_MAP.items(): + if header.startswith(magic): + matched_fct = fct + break + if matched_fct is None: + raise ValueError("Unsupported archive format") + matched_fct(archive_ref, extract_dir, progress_header=progress_header) + + +def download_from_mirrors( + mirrors: list[str], + substitutions: dict[str, str], + target: io.RawIOBase | IO[bytes] | PathType, + timeout: int = 30, +) -> str: + """ + Download file from multiple mirrors with substitution support. + + Args: + mirrors: list of mirror URLs + substitutions: Dictionary of substitutions to apply to URLs + target: Target file path or file-like object + timeout: Download timeout in seconds + + Returns: + The source URL. + + Raises: + ValueError: If mirrors list is empty. + Exception: If all download attempts fail. + """ + # 1. Open target file for writing if path given + with ExitStack() as stack: + if isinstance(target, (str, os.PathLike)): + f = stack.enter_context(Path(target).open("wb")) + elif isinstance(target, (io.RawIOBase, io.IOBase)): + f = target + else: + raise TypeError( + f"target must be str, Path, or file-like object: {type(target)}" + ) + + # 2. Try each mirror in order + last_exception = None + + for mirror in mirrors: + # 3. Apply substitutions to URL + url = mirror.format(**substitutions) + + _LOGGER.debug("Trying downloading from %s", url) + + try: + # 4. Reset file pointer and download + f.seek(0) + f.truncate(0) + + with requests.get(url, stream=True, timeout=timeout) as r: + r.raise_for_status() + + total_size = int(r.headers.get("content-length", 0)) + downloaded = 0 + + progress = ProgressBar("Downloading") if total_size > 0 else None + + for chunk in r.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + + downloaded += len(chunk) + + if progress is not None: + progress.update(downloaded / total_size) + + if progress is not None: + progress.update(1) + + _LOGGER.debug("Downloaded successfully from: %s", url) + + # 6. Reset file pointer and return + f.seek(0) + return url + + except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught + _LOGGER.debug("Failed to download %s: %s", url, str(e)) + last_exception = e + + # 7. Raise last exception if all mirrors failed + if last_exception: + raise last_exception + raise ValueError("download_from_mirrors called with an empty mirrors list") diff --git a/requirements.txt b/requirements.txt index 8202a2bb44..ed7f2c2941 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,6 +25,7 @@ jinja2==3.1.6 bleak==2.1.1 smpclient==6.0.0 requests==2.34.2 +py7zr==0.22.0 # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 2322fdd014..cc371ee1f9 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -894,6 +894,13 @@ class TestEsphomeCore: "foo/build/.pioenvs/test-device/bootloader.bin" ) + def test_using_toolchain_sdk_nrf(self, target): + """using_toolchain_sdk_nrf is True only for the SDK_NRF toolchain.""" + target.toolchain = const.Toolchain.SDK_NRF + assert target.using_toolchain_sdk_nrf is True + target.toolchain = const.Toolchain.ESP_IDF + assert target.using_toolchain_sdk_nrf is False + def test_add_library__extracts_short_name_from_path(self, target): """Test add_library extracts short name from library paths like owner/lib.""" target.data[const.KEY_CORE] = { diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index 9f4e4fcca8..036c7c0454 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -2,12 +2,32 @@ # pylint: disable=protected-access +import io +import json from pathlib import Path +import tarfile +from types import SimpleNamespace from unittest.mock import patch import pytest -from esphome.espidf.framework import _clone_idf_with_submodules, _parse_git_source +from esphome.espidf.framework import ( + _check_stamp, + _clone_idf_with_submodules, + _get_framework_path, + _get_idf_tool_paths, + _get_idf_tools_path, + _get_idf_version, + _get_python_env_path, + _get_python_version, + _parse_git_source, + _patch_tools_json_for_linux_arm64, + _write_idf_version_txt, + _write_stamp, + check_esp_idf_install, + get_framework_env, +) +from esphome.framework_helpers import _tar_extract_all, get_python_env_executable_path @pytest.mark.parametrize( @@ -154,3 +174,511 @@ def test_clone_idf_with_submodules_raises_when_tree_missing( "https://github.com/espressif/esp-idf.git", None, ) + + +# --------------------------------------------------------------------------- +# Helpers for _tar_extract_all hard-link prefix-stripping tests +# --------------------------------------------------------------------------- + + +def _make_tar( + members: list[tarfile.TarInfo], file_contents: dict[str, bytes] +) -> io.BytesIO: + """Build an in-memory tar archive from a list of TarInfo objects.""" + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w") as tf: + for info in members: + if info.isreg() and info.name in file_contents: + data = file_contents[info.name] + info.size = len(data) + tf.addfile(info, io.BytesIO(data)) + else: + tf.addfile(info) + buf.seek(0) + return buf + + +def _regular(name: str) -> tarfile.TarInfo: + info = tarfile.TarInfo(name=name) + info.type = tarfile.REGTYPE + info.size = 0 + info.mode = 0o644 + return info + + +def _hardlink(name: str, linkname: str) -> tarfile.TarInfo: + info = tarfile.TarInfo(name=name) + info.type = tarfile.LNKTYPE + info.linkname = linkname + info.size = 0 + info.mode = 0o644 + return info + + +class TestTarExtractHardLinkPrefixStripping: + """ + Covers the hard-link prefix-stripping block in _tar_extract_all (L528-541). + + Archive layout used by every test: + + wrapper/ ← single top-level wrapper dir (stripped) + wrapper/target.txt ← regular file; becomes target.txt in dest + wrapper/link_good ← hard link to wrapper/target.txt (kept, linkname stripped) + wrapper/link_exact_root ← hard link to "wrapper" (skipped – equals strip_root) + wrapper/link_exact_prefix ← hard link to "wrapper/" (skipped – equals strip_prefix) + wrapper/link_outside ← hard link to "other/target.txt" (skipped – not under prefix) + """ + + WRAPPER = "wrapper" + + def _build_archive(self) -> io.BytesIO: + members = [ + _regular(f"{self.WRAPPER}/"), + _regular(f"{self.WRAPPER}/target.txt"), + _hardlink(f"{self.WRAPPER}/link_good", f"{self.WRAPPER}/target.txt"), + _hardlink(f"{self.WRAPPER}/link_exact_root", self.WRAPPER), + _hardlink(f"{self.WRAPPER}/link_exact_prefix", f"{self.WRAPPER}/"), + _hardlink(f"{self.WRAPPER}/link_outside", "other/target.txt"), + ] + return _make_tar(members, {f"{self.WRAPPER}/target.txt": b"hello"}) + + def test_good_hardlink_is_extracted_with_stripped_linkname( + self, tmp_path: Path + ) -> None: + """Hard link whose linkname starts with wrapper/ is extracted and its + linkname has the prefix removed so tarfile can resolve the target.""" + _tar_extract_all(self._build_archive(), tmp_path) + link = tmp_path / "link_good" + assert link.exists(), "link_good should have been extracted" + assert link.read_bytes() == b"hello" + + def test_hardlink_equal_to_strip_root_is_skipped(self, tmp_path: Path) -> None: + """Hard link whose linkname equals strip_root exactly must be dropped.""" + _tar_extract_all(self._build_archive(), tmp_path) + assert not (tmp_path / "link_exact_root").exists() + + def test_hardlink_equal_to_strip_prefix_is_skipped(self, tmp_path: Path) -> None: + """Hard link whose linkname equals strip_prefix (strip_root + '/') must be dropped.""" + _tar_extract_all(self._build_archive(), tmp_path) + assert not (tmp_path / "link_exact_prefix").exists() + + def test_hardlink_outside_prefix_is_skipped(self, tmp_path: Path) -> None: + """Hard link whose linkname does not start with wrapper/ must be dropped.""" + _tar_extract_all(self._build_archive(), tmp_path) + assert not (tmp_path / "link_outside").exists() + + def test_regular_file_and_no_spurious_files(self, tmp_path: Path) -> None: + """Sanity check: target.txt is extracted and no unexpected files appear.""" + _tar_extract_all(self._build_archive(), tmp_path) + assert (tmp_path / "target.txt").read_bytes() == b"hello" + extracted = {p.name for p in tmp_path.iterdir()} + assert extracted == {"target.txt", "link_good"} + + +_IDF_VERSION = "5.1.2" + + +@pytest.fixture +def espidf_mocks(setup_core: Path): + """Patch the heavy I/O of check_esp_idf_install and pre-create the framework dir.""" + # archive_extract_all is mocked, so pre-create the framework dir that the + # extracted-marker touch writes into. + _get_framework_path(_IDF_VERSION).mkdir(parents=True, exist_ok=True) + with ( + patch("esphome.espidf.framework.rmdir"), + patch( + "esphome.espidf.framework.download_from_mirrors", + return_value="https://example.com/idf.tar.xz", + ) as download, + patch("esphome.espidf.framework.archive_extract_all") as extract, + patch("esphome.espidf.framework.create_venv") as venv, + patch("esphome.espidf.framework.run_command_ok", return_value=True) as run_ok, + patch("esphome.espidf.framework._clone_idf_with_submodules") as clone, + patch("esphome.espidf.framework._write_idf_version_txt"), + patch("esphome.espidf.framework._patch_tools_json_for_linux_arm64"), + patch("esphome.espidf.framework._write_stamp"), + patch("esphome.espidf.framework._check_stamp", return_value=True), + patch("esphome.espidf.framework._get_idf_version", return_value=_IDF_VERSION), + patch("esphome.espidf.framework._get_python_version", return_value="3.11.0"), + patch("esphome.espidf.framework.get_system_python_path", return_value="python"), + ): + yield SimpleNamespace( + download=download, extract=extract, venv=venv, run_ok=run_ok, clone=clone + ) + + +def test_check_esp_idf_install_fresh(espidf_mocks: SimpleNamespace) -> None: + """A forced install drives download/extract, venv creation, and pip installs.""" + framework_path, python_env_path = check_esp_idf_install(_IDF_VERSION, force=True) + + assert framework_path == _get_framework_path(_IDF_VERSION) + assert python_env_path == _get_python_env_path(_IDF_VERSION) + # framework tarball + python-env constraints file are both downloaded + assert espidf_mocks.download.call_count == 2 + espidf_mocks.extract.assert_called_once() + espidf_mocks.venv.assert_called_once() + espidf_mocks.clone.assert_not_called() + + +def test_check_esp_idf_install_git_source(espidf_mocks: SimpleNamespace) -> None: + """A git source_url clones instead of downloading; explicit tools skip discovery.""" + check_esp_idf_install( + _IDF_VERSION, + force=True, + source_url="https://github.com/espressif/esp-idf.git", + tools=["xtensa-esp-elf"], + ) + + espidf_mocks.clone.assert_called_once() + # framework is cloned, so only the python-env constraints file is downloaded + assert espidf_mocks.download.call_count == 1 + + +def test_check_esp_idf_install_already_installed(espidf_mocks: SimpleNamespace) -> None: + """Marker + matching stamps + existing python env → nothing is re-installed.""" + framework_path = _get_framework_path(_IDF_VERSION) + (framework_path / ".esphome_extracted").touch() + python_env_path = _get_python_env_path(_IDF_VERSION) + env_python = get_python_env_executable_path(python_env_path, "python") + env_python.parent.mkdir(parents=True, exist_ok=True) + env_python.touch() + + check_esp_idf_install(_IDF_VERSION) + + espidf_mocks.extract.assert_not_called() + espidf_mocks.venv.assert_not_called() + + +def test_check_esp_idf_install_framework_failure(espidf_mocks: SimpleNamespace) -> None: + """A failing idf_tools install raises.""" + espidf_mocks.run_ok.side_effect = [False] + with pytest.raises(RuntimeError, match="framework installation failure"): + check_esp_idf_install(_IDF_VERSION, force=True) + + +def test_check_esp_idf_install_pip_upgrade_failure( + espidf_mocks: SimpleNamespace, +) -> None: + """A failing pip upgrade in the python env raises (framework install ok).""" + espidf_mocks.run_ok.side_effect = [True, False] + with pytest.raises(RuntimeError, match="Python environment packages failure"): + check_esp_idf_install(_IDF_VERSION, force=True) + + +def test_check_esp_idf_install_feature_failure(espidf_mocks: SimpleNamespace) -> None: + """A failing feature requirements install raises.""" + espidf_mocks.run_ok.side_effect = [True, True, False] + with pytest.raises(RuntimeError, match="Python dependencies for"): + check_esp_idf_install(_IDF_VERSION, force=True, features=["fb"]) + + +def _mark_installed() -> None: + """Create the extracted marker and python-env interpreter so the install + check takes the already-installed path rather than force-installing.""" + (_get_framework_path(_IDF_VERSION) / ".esphome_extracted").touch() + env_python = get_python_env_executable_path( + _get_python_env_path(_IDF_VERSION), "python" + ) + env_python.parent.mkdir(parents=True, exist_ok=True) + env_python.touch() + + +def test_check_esp_idf_install_stamp_mismatch_reinstalls( + espidf_mocks: SimpleNamespace, +) -> None: + """A stamp mismatch reinstalls tools (marker present, so no re-extract).""" + _mark_installed() + with patch("esphome.espidf.framework._check_stamp", return_value=False): + check_esp_idf_install(_IDF_VERSION) + + espidf_mocks.extract.assert_not_called() # marker present -> no re-extract + espidf_mocks.venv.assert_called_once() # tools reinstall -> venv rebuilt + + +def test_check_esp_idf_install_check_command_failure_reinstalls( + espidf_mocks: SimpleNamespace, +) -> None: + """A failing idf_tools check reinstalls tools (marker present, no re-extract).""" + _mark_installed() + # idf_tools check fails -> install stays True; the later installs succeed. + espidf_mocks.run_ok.side_effect = [False, True, True, True] + check_esp_idf_install(_IDF_VERSION, features=["fb"]) + + espidf_mocks.extract.assert_not_called() + espidf_mocks.venv.assert_called_once() + + +def test_check_esp_idf_install_unknown_python_version_reinstalls( + espidf_mocks: SimpleNamespace, +) -> None: + """An undeterminable python version rebuilds the venv (framework stamp still ok).""" + _mark_installed() + with patch("esphome.espidf.framework._get_python_version", return_value=None): + check_esp_idf_install(_IDF_VERSION) + + espidf_mocks.extract.assert_not_called() # framework stamp matched + espidf_mocks.venv.assert_called_once() # python env rebuilt + + +def test_check_esp_idf_install_python_stamp_mismatch_rebuilds_venv( + espidf_mocks: SimpleNamespace, +) -> None: + """Framework stamp matches but the python-env stamp does not -> venv rebuilt.""" + + # _check_stamp passes for the framework (no python_version key) and fails + # for the python env (carries python_version), so only the venv rebuilds. + def stamp_ok(_stamp_file, info: dict) -> bool: + return "python_version" not in info + + _mark_installed() + with patch("esphome.espidf.framework._check_stamp", side_effect=stamp_ok): + check_esp_idf_install(_IDF_VERSION) + + espidf_mocks.extract.assert_not_called() + espidf_mocks.venv.assert_called_once() + + +def test_check_esp_idf_install_unparseable_version( + espidf_mocks: SimpleNamespace, +) -> None: + """A non-semver version skips the MAJOR/MINOR substitutions without erroring.""" + bad_version = "main" + _get_framework_path(bad_version).mkdir(parents=True, exist_ok=True) + check_esp_idf_install(bad_version, force=True) + + espidf_mocks.extract.assert_called_once() + + +# --------------------------------------------------------------------------- +# _patch_tools_json_for_linux_arm64 (arm64-only ninja backport) +# --------------------------------------------------------------------------- + + +def _write_tools_json(framework_path: Path, data: dict) -> Path: + tools_dir = framework_path / "tools" + tools_dir.mkdir(parents=True, exist_ok=True) + tools_json = tools_dir / "tools.json" + tools_json.write_text(json.dumps(data), encoding="utf-8") + return tools_json + + +def test_patch_tools_json_non_aarch64_is_noop(tmp_path: Path) -> None: + tools_json = _write_tools_json( + tmp_path, {"tools": [{"name": "ninja", "versions": [{"name": "1.12.1"}]}]} + ) + before = tools_json.read_text(encoding="utf-8") + with patch("esphome.espidf.framework.platform.machine", return_value="x86_64"): + _patch_tools_json_for_linux_arm64(tmp_path) + assert tools_json.read_text(encoding="utf-8") == before + + +def test_patch_tools_json_missing_file_is_noop(tmp_path: Path) -> None: + with patch("esphome.espidf.framework.platform.machine", return_value="aarch64"): + _patch_tools_json_for_linux_arm64(tmp_path) # no tools/tools.json present + + +def test_patch_tools_json_corrupt_file_warns_and_skips(tmp_path: Path) -> None: + (tmp_path / "tools").mkdir() + (tmp_path / "tools" / "tools.json").write_text("{ not json", encoding="utf-8") + with patch("esphome.espidf.framework.platform.machine", return_value="aarch64"): + _patch_tools_json_for_linux_arm64(tmp_path) # JSONDecodeError -> skip + + +def test_patch_tools_json_injects_ninja_arm64(tmp_path: Path) -> None: + tools_json = _write_tools_json( + tmp_path, + { + "tools": [ + {"name": "ninja", "versions": [{"name": "1.12.1"}]}, + {"name": "cmake", "versions": [{"name": "3.24.0"}]}, + ] + }, + ) + with patch("esphome.espidf.framework.platform.machine", return_value="aarch64"): + _patch_tools_json_for_linux_arm64(tmp_path) + + data = json.loads(tools_json.read_text(encoding="utf-8")) + ninja = next(t for t in data["tools"] if t["name"] == "ninja") + assert "linux-arm64" in ninja["versions"][0] + assert ninja["versions"][0]["linux-arm64"]["size"] == 121787 + + +def test_patch_tools_json_already_patched_is_noop(tmp_path: Path) -> None: + tools_json = _write_tools_json( + tmp_path, + { + "tools": [ + { + "name": "ninja", + "versions": [{"name": "1.12.1", "linux-arm64": {"url": "x"}}], + } + ] + }, + ) + before = tools_json.read_text(encoding="utf-8") + with patch("esphome.espidf.framework.platform.machine", return_value="aarch64"): + _patch_tools_json_for_linux_arm64(tmp_path) + assert tools_json.read_text(encoding="utf-8") == before + + +# --------------------------------------------------------------------------- +# Subprocess-backed helpers (_exec -> run_command rename) and get_framework_env +# --------------------------------------------------------------------------- + + +def test_get_idf_version_parses_stdout(tmp_path: Path) -> None: + with patch( + "esphome.espidf.framework.run_command", return_value=(True, "5.1.2\n", "") + ): + assert _get_idf_version(tmp_path) == "5.1.2" + + +def test_get_idf_version_raises_on_failure(tmp_path: Path) -> None: + with ( + patch("esphome.espidf.framework.run_command", return_value=(False, "", "boom")), + pytest.raises(RuntimeError, match="Can't get ESP-IDF version"), + ): + _get_idf_version(tmp_path) + + +def test_get_idf_tool_paths_parses_json(tmp_path: Path) -> None: + payload = json.dumps({"paths_to_export": ["/a", "/b"], "export_vars": {"X": "1"}}) + with patch( + "esphome.espidf.framework.run_command", return_value=(True, payload, "") + ): + paths, export_vars = _get_idf_tool_paths(tmp_path) + assert paths == ["/a", "/b"] + assert export_vars == {"X": "1"} + + +def test_get_idf_tool_paths_raises_on_bad_json(tmp_path: Path) -> None: + with ( + patch( + "esphome.espidf.framework.run_command", return_value=(True, "not json", "") + ), + pytest.raises(RuntimeError, match="Can't extract ESP-IDF tool paths"), + ): + _get_idf_tool_paths(tmp_path) + + +def test_get_idf_tool_paths_raises_on_failure(tmp_path: Path) -> None: + with ( + patch("esphome.espidf.framework.run_command", return_value=(False, "", "err")), + pytest.raises(RuntimeError, match="Can't get ESP-IDF tool paths"), + ): + _get_idf_tool_paths(tmp_path) + + +def test_get_python_version_parses_stdout(tmp_path: Path) -> None: + with patch( + "esphome.espidf.framework.run_command", return_value=(True, "3.11.0\n", "") + ): + assert _get_python_version(tmp_path / "python") == "3.11.0" + + +def test_get_python_version_returns_falsy_on_failure(tmp_path: Path) -> None: + with patch("esphome.espidf.framework.run_command", return_value=(False, "", "")): + # non-throwing failure returns the (empty) stdout as-is + assert not _get_python_version(tmp_path / "python") + + +def test_get_python_version_raises_when_requested(tmp_path: Path) -> None: + with ( + patch("esphome.espidf.framework.run_command", return_value=(False, "", "")), + pytest.raises(RuntimeError, match="Can't get Python version"), + ): + _get_python_version(tmp_path / "python", throw_exception=True) + + +def test_write_stamp_writes_json(tmp_path: Path) -> None: + stamp = tmp_path / "stamp.json" + _write_stamp(stamp, {"a": "1", "b": "2"}) + assert json.loads(stamp.read_text(encoding="utf-8")) == {"a": "1", "b": "2"} + + +def test_get_framework_env_with_python_env(tmp_path: Path) -> None: + with ( + patch( + "esphome.espidf.framework._get_idf_tools_path", + return_value=tmp_path / "tools", + ), + patch("esphome.espidf.framework._get_idf_version", return_value="5.1.2"), + patch( + "esphome.espidf.framework._get_idf_tool_paths", + return_value=(["/tool/bin"], {"IDF_X": "1"}), + ), + ): + env = get_framework_env( + tmp_path / "fw", tmp_path / "penv", {"PATH": "/usr/bin"} + ) + + assert env["IDF_PATH"] == str(tmp_path / "fw") + assert env["ESP_IDF_VERSION"] == "5.1.2" + assert env["IDF_X"] == "1" + assert env["IDF_PYTHON_ENV_PATH"] == str(tmp_path / "penv") + assert "/tool/bin" in env["PATH"] + + +def test_get_framework_env_without_python_env_uses_os_path(tmp_path: Path) -> None: + with ( + patch( + "esphome.espidf.framework._get_idf_tools_path", + return_value=tmp_path / "tools", + ), + patch("esphome.espidf.framework._get_idf_version", return_value="5.1.2"), + patch("esphome.espidf.framework._get_idf_tool_paths", return_value=([], {})), + ): + env = get_framework_env(tmp_path / "fw") + + assert "IDF_PYTHON_ENV_PATH" not in env + assert env["PATH"] # taken from os.environ + + +# --------------------------------------------------------------------------- +# _check_stamp / _write_idf_version_txt / _get_idf_tools_path +# --------------------------------------------------------------------------- + + +def test_check_stamp_matches(tmp_path: Path) -> None: + f = tmp_path / "s.json" + f.write_text(json.dumps({"a": "1"}), encoding="utf-8") + assert _check_stamp(f, {"a": "1"}) is True + + +def test_check_stamp_mismatch(tmp_path: Path) -> None: + f = tmp_path / "s.json" + f.write_text(json.dumps({"a": "1"}), encoding="utf-8") + assert _check_stamp(f, {"a": "2"}) is False + + +def test_check_stamp_missing_file(tmp_path: Path) -> None: + assert _check_stamp(tmp_path / "nope.json", {"a": "1"}) is False + + +def test_check_stamp_corrupt_file(tmp_path: Path) -> None: + f = tmp_path / "s.json" + f.write_text("{ not json", encoding="utf-8") + assert _check_stamp(f, {"a": "1"}) is False + + +def test_write_idf_version_txt_writes_when_missing(tmp_path: Path) -> None: + _write_idf_version_txt(tmp_path, "5.1.2") + assert (tmp_path / "version.txt").read_text(encoding="utf-8") == "v5.1.2\n" + + +def test_write_idf_version_txt_skips_when_present(tmp_path: Path) -> None: + (tmp_path / "version.txt").write_text("existing\n", encoding="utf-8") + _write_idf_version_txt(tmp_path, "5.1.2") + assert (tmp_path / "version.txt").read_text(encoding="utf-8") == "existing\n" + + +def test_get_idf_tools_path_env_override(tmp_path: Path) -> None: + override = str(tmp_path / "custom-idf") + with patch.dict("os.environ", {"ESPHOME_ESP_IDF_PREFIX": override}): + assert _get_idf_tools_path() == Path(override) + + +def test_write_idf_version_txt_warns_on_write_error(tmp_path: Path) -> None: + with patch("pathlib.Path.write_text", side_effect=OSError("denied")): + # write failure is caught and warned, not raised + _write_idf_version_txt(tmp_path, "5.1.2") diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py new file mode 100644 index 0000000000..a8533608c0 --- /dev/null +++ b/tests/unit_tests/test_framework_helpers.py @@ -0,0 +1,954 @@ +"""Tests for esphome.framework_helpers.""" + +# pylint: disable=protected-access + +import importlib.util +import io +import logging +import os +from pathlib import Path +import subprocess +import sys +import tarfile +from unittest.mock import MagicMock, Mock, patch +import zipfile + +import pytest +import requests as req + +from esphome.framework_helpers import ( + _7z_extract_all, + _detect_archive_root, + _rename_with_retry, + _tar_extract_all, + _zip_extract_all, + archive_extract_all, + create_venv, + download_from_mirrors, + get_python_env_executable_path, + get_system_python_path, + rmdir, + run_command, + run_command_ok, + str_to_lst_of_str, +) + +_HAS_PY7ZR = importlib.util.find_spec("py7zr") is not None + +# --------------------------------------------------------------------------- +# str_to_lst_of_str +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("a;b;c", ["a", "b", "c"]), + (" a ; b ", ["a", "b"]), + (";; a ;;", ["a"]), + ("single", ["single"]), + ("", []), + (["already", "a", "list"], ["already", "a", "list"]), + ], +) +def test_str_to_lst_of_str(value: str | list, expected: list) -> None: + assert str_to_lst_of_str(value) == expected + + +# --------------------------------------------------------------------------- +# rmdir +# --------------------------------------------------------------------------- + + +def test_rmdir_nonexistent_is_noop(tmp_path: Path) -> None: + rmdir(tmp_path / "missing") + + +def test_rmdir_removes_existing_directory(tmp_path: Path) -> None: + d = tmp_path / "to_remove" + d.mkdir() + (d / "file.txt").write_text("x") + rmdir(d) + assert not d.exists() + + +def test_rmdir_logs_debug_with_msg( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + d = tmp_path / "logged" + d.mkdir() + with caplog.at_level(logging.DEBUG, logger="esphome.framework_helpers"): + rmdir(d, msg="cleanup message") + assert "cleanup message" in caplog.text + + +def test_rmdir_raises_runtime_error_on_os_error(tmp_path: Path) -> None: + d = tmp_path / "stubborn" + d.mkdir() + with ( + patch("esphome.framework_helpers.rmtree", side_effect=OSError("perm denied")), + pytest.raises(RuntimeError, match="can't remove"), + ): + rmdir(d, msg="cleanup step") + + +# --------------------------------------------------------------------------- +# get_system_python_path +# --------------------------------------------------------------------------- + + +def test_get_system_python_path_returns_env_var() -> None: + with patch.dict(os.environ, {"PYTHONEXEPATH": "/custom/python"}): + assert get_system_python_path() == "/custom/python" + + +def test_get_system_python_path_falls_back_to_sys_executable() -> None: + env = {k: v for k, v in os.environ.items() if k != "PYTHONEXEPATH"} + with patch.dict(os.environ, env, clear=True): + assert get_system_python_path() == os.path.normpath(sys.executable) + + +# --------------------------------------------------------------------------- +# get_python_env_executable_path +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(os.name != "posix", reason="PosixPath construction requires POSIX") +def test_get_python_env_executable_path_posix() -> None: + assert get_python_env_executable_path("/env", "python") == Path("/env/bin/python") + + +@pytest.mark.skipif(os.name != "nt", reason="WindowsPath construction requires Windows") +def test_get_python_env_executable_path_windows() -> None: + assert get_python_env_executable_path("/env", "python") == Path( + "/env/Scripts/python.exe" + ) + + +# --------------------------------------------------------------------------- +# run_command +# --------------------------------------------------------------------------- + + +def test_run_command_success_returns_stdout(mock_subprocess_run: Mock) -> None: + mock_subprocess_run.return_value = Mock(returncode=0, stdout="out\n", stderr="") + ok, stdout, _stderr = run_command(["echo", "hello"]) + assert ok is True + assert stdout == "out\n" + + +def test_run_command_failure_returns_false(mock_subprocess_run: Mock) -> None: + mock_subprocess_run.return_value = Mock(returncode=1, stdout="", stderr="boom") + ok, _stdout, stderr = run_command(["bad"]) + assert ok is False + assert stderr == "boom" + + +def test_run_command_stream_output_success(mock_subprocess_run: Mock) -> None: + mock_subprocess_run.return_value = Mock(returncode=0) + ok, stdout, stderr = run_command(["cmd"], stream_output=True) + assert ok is True + assert stdout is None + assert stderr is None + + +def test_run_command_stream_output_failure(mock_subprocess_run: Mock) -> None: + mock_subprocess_run.return_value = Mock(returncode=2) + ok, stdout, _stderr = run_command(["cmd"], stream_output=True) + assert ok is False + assert stdout is None + + +def test_run_command_subprocess_error_returns_false(mock_subprocess_run: Mock) -> None: + mock_subprocess_run.side_effect = subprocess.SubprocessError("exploded") + ok, stdout, stderr = run_command(["cmd"]) + assert ok is False + assert stdout is None + assert stderr is None + + +def test_run_command_os_error_returns_false(mock_subprocess_run: Mock) -> None: + mock_subprocess_run.side_effect = OSError("not found") + ok, _stdout, _stderr = run_command(["cmd"]) + assert ok is False + + +def test_run_command_passes_env(mock_subprocess_run: Mock) -> None: + mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="") + run_command(["cmd"], env={"MY_VAR": "42"}) + assert mock_subprocess_run.call_args[1]["env"]["MY_VAR"] == "42" + + +def test_run_command_passes_cwd(mock_subprocess_run: Mock, tmp_path: Path) -> None: + mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="") + run_command(["cmd"], cwd=str(tmp_path)) + assert mock_subprocess_run.call_args[1]["cwd"] == str(tmp_path) + + +# --------------------------------------------------------------------------- +# run_command_ok +# --------------------------------------------------------------------------- + + +def test_run_command_ok_true(mock_subprocess_run: Mock) -> None: + mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="") + assert run_command_ok(["cmd"]) is True + + +def test_run_command_ok_false(mock_subprocess_run: Mock) -> None: + mock_subprocess_run.return_value = Mock(returncode=1, stdout="", stderr="") + assert run_command_ok(["cmd"]) is False + + +# --------------------------------------------------------------------------- +# create_venv +# --------------------------------------------------------------------------- + + +def test_create_venv_calls_run_command_ok(tmp_path: Path) -> None: + with patch( + "esphome.framework_helpers.run_command_ok", return_value=True + ) as mock_cmd: + create_venv(tmp_path / "env", msg="test") + mock_cmd.assert_called_once() + + +def test_create_venv_raises_on_failure(tmp_path: Path) -> None: + with ( + patch("esphome.framework_helpers.run_command_ok", return_value=False), + pytest.raises(RuntimeError, match="Can't create Python virtual environment"), + ): + create_venv(tmp_path / "env", msg="test") + + +# --------------------------------------------------------------------------- +# _detect_archive_root +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("names", "expected"), + [ + (["wrapper/", "wrapper/a.txt", "wrapper/sub/b.txt"], "wrapper"), + (["root1/a.txt", "root2/b.txt"], None), + (["wrapper"], None), # no descendant → None + (["", "wrapper/file.txt"], "wrapper"), # empty names skipped + (["wrapper\\file.txt"], "wrapper"), # backslash normalised + (["w/a", "w/b", "w/c"], "w"), + ], +) +def test_detect_archive_root(names: list[str], expected: str | None) -> None: + assert _detect_archive_root(names) == expected + + +# --------------------------------------------------------------------------- +# Tar archive helpers +# --------------------------------------------------------------------------- + + +def _make_tar( + members: list[tarfile.TarInfo], + file_contents: dict[str, bytes] | None = None, +) -> io.BytesIO: + buf = io.BytesIO() + contents = file_contents or {} + with tarfile.open(fileobj=buf, mode="w") as tf: + for info in members: + if info.isreg() and info.name in contents: + data = contents[info.name] + info.size = len(data) + tf.addfile(info, io.BytesIO(data)) + else: + tf.addfile(info) + buf.seek(0) + return buf + + +def _reg(name: str) -> tarfile.TarInfo: + info = tarfile.TarInfo(name=name) + info.type = tarfile.REGTYPE + info.size = 0 + info.mode = 0o644 + return info + + +def _dir(name: str) -> tarfile.TarInfo: + info = tarfile.TarInfo(name=name) + info.type = tarfile.DIRTYPE + info.mode = 0o755 + return info + + +def _sym(name: str, target: str) -> tarfile.TarInfo: + info = tarfile.TarInfo(name=name) + info.type = tarfile.SYMTYPE + info.linkname = target + info.mode = 0o777 + return info + + +def _special(name: str) -> tarfile.TarInfo: + info = tarfile.TarInfo(name=name) + info.type = tarfile.CHRTYPE + info.mode = 0o600 + return info + + +def _hlnk(name: str, target: str) -> tarfile.TarInfo: + info = tarfile.TarInfo(name=name) + info.type = tarfile.LNKTYPE + info.linkname = target + info.mode = 0o644 + return info + + +# --------------------------------------------------------------------------- +# _tar_extract_all — branches not covered by the hard-link prefix-strip tests +# --------------------------------------------------------------------------- + + +class TestTarExtractAllSecurity: + def test_flat_archive_no_wrapper(self, tmp_path: Path) -> None: + """Without a single common root files land directly in extract_dir.""" + buf = _make_tar( + [_reg("a.txt"), _reg("b.txt")], + {"a.txt": b"aaa", "b.txt": b"bbb"}, + ) + _tar_extract_all(buf, tmp_path) + assert (tmp_path / "a.txt").read_bytes() == b"aaa" + assert (tmp_path / "b.txt").read_bytes() == b"bbb" + + def test_directory_member_extracted(self, tmp_path: Path) -> None: + buf = _make_tar([_dir("subdir/")]) + _tar_extract_all(buf, tmp_path) + assert (tmp_path / "subdir").is_dir() + + def test_symlink_within_dest_extracted(self, tmp_path: Path) -> None: + buf = _make_tar( + [_reg("target.txt"), _sym("link.txt", "target.txt")], + {"target.txt": b"data"}, + ) + _tar_extract_all(buf, tmp_path) + assert (tmp_path / "link.txt").exists() + + def test_path_traversal_skipped(self, tmp_path: Path) -> None: + """Member resolving outside extract_dir via .. is silently skipped.""" + info = tarfile.TarInfo(name="sub/../../escape.txt") + info.type = tarfile.REGTYPE + info.size = 5 + info.mode = 0o644 + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w") as tf: + tf.addfile(info, io.BytesIO(b"OOPS!")) + buf.seek(0) + _tar_extract_all(buf, tmp_path) + assert not (tmp_path.parent / "escape.txt").exists() + assert not list(tmp_path.rglob("escape.txt")) + + def test_absolute_symlink_target_skipped(self, tmp_path: Path) -> None: + """Symlink pointing to an absolute path is silently skipped.""" + buf = _make_tar( + [_reg("real.txt"), _sym("danger.lnk", "/etc/passwd")], + {"real.txt": b"ok"}, + ) + _tar_extract_all(buf, tmp_path) + assert not (tmp_path / "danger.lnk").exists() + + def test_symlink_escaping_dest_skipped(self, tmp_path: Path) -> None: + """Symlink whose resolved path exits extract_dir is silently skipped.""" + buf = _make_tar([_sym("up.lnk", "../outside.txt")]) + _tar_extract_all(buf, tmp_path) + assert not (tmp_path / "up.lnk").exists() + + def test_special_file_skipped(self, tmp_path: Path) -> None: + """Character-device and other special-file members are silently skipped.""" + buf = _make_tar([_special("chardev")]) + _tar_extract_all(buf, tmp_path) + assert not (tmp_path / "chardev").exists() + + @pytest.mark.skipif( + os.name == "nt", reason="Windows has no POSIX executable permission bit" + ) + def test_executable_bit_preserved(self, tmp_path: Path) -> None: + """User-executable bit is kept for explicitly executable files.""" + info = _reg("script.sh") + info.mode = 0o755 + buf = _make_tar([info], {"script.sh": b"#!/bin/sh"}) + _tar_extract_all(buf, tmp_path) + assert (tmp_path / "script.sh").stat().st_mode & 0o100 # S_IXUSR + + def test_non_executable_exec_bits_stripped(self, tmp_path: Path) -> None: + """Exec bits are removed when S_IXUSR is not set.""" + info = _reg("data.bin") + info.mode = 0o654 # group/other exec present, user exec absent + buf = _make_tar([info], {"data.bin": b"\x00"}) + _tar_extract_all(buf, tmp_path) + mode = (tmp_path / "data.bin").stat().st_mode + assert not (mode & 0o111) # all exec bits cleared + + +# --------------------------------------------------------------------------- +# ZIP archive helper +# --------------------------------------------------------------------------- + + +def _make_zip(entries: list[tuple[str, str | bytes]]) -> io.BytesIO: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + for name, content in entries: + zf.writestr(name, content) + buf.seek(0) + return buf + + +# --------------------------------------------------------------------------- +# _zip_extract_all +# --------------------------------------------------------------------------- + + +class TestZipExtractAll: + def test_basic_extraction_strips_wrapper(self, tmp_path: Path) -> None: + buf = _make_zip([("wrapper/file.txt", "hello")]) + _zip_extract_all(buf, tmp_path) + assert (tmp_path / "file.txt").read_text() == "hello" + + def test_flat_archive_no_wrapper(self, tmp_path: Path) -> None: + buf = _make_zip([("a.txt", "aaa"), ("b.txt", "bbb")]) + _zip_extract_all(buf, tmp_path) + assert (tmp_path / "a.txt").read_text() == "aaa" + assert (tmp_path / "b.txt").read_text() == "bbb" + + def test_wrapper_root_entry_skipped(self, tmp_path: Path) -> None: + """The wrapper directory entry itself (step 3a) does not appear in dest.""" + buf = _make_zip([("wrapper/", ""), ("wrapper/file.txt", "content")]) + _zip_extract_all(buf, tmp_path) + assert (tmp_path / "file.txt").read_text() == "content" + assert not (tmp_path / "wrapper").exists() + + def test_path_traversal_raises(self, tmp_path: Path) -> None: + # Two members with different roots so _detect_archive_root returns None + # and strip_prefix is not applied, leaving "../escape.txt" to hit the + # commonpath safety check directly. + buf = _make_zip([("safe.txt", "ok"), ("../escape.txt", "bad")]) + with pytest.raises(ValueError, match="Unsafe path"): + _zip_extract_all(buf, tmp_path) + + def test_multiple_files_extracted(self, tmp_path: Path) -> None: + entries = [(f"root/{c}.txt", c * 3) for c in "abc"] + buf = _make_zip(entries) + _zip_extract_all(buf, tmp_path) + for c in "abc": + assert (tmp_path / f"{c}.txt").read_text() == c * 3 + + +# --------------------------------------------------------------------------- +# archive_extract_all dispatch +# --------------------------------------------------------------------------- + + +def _gzip_tar_bytes(entries: dict[str, bytes]) -> bytes: + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tf: + for name, content in entries.items(): + info = tarfile.TarInfo(name=name) + info.size = len(content) + info.mode = 0o644 + tf.addfile(info, io.BytesIO(content)) + return buf.getvalue() + + +class TestArchiveExtractAll: + def test_path_input_gzip_tar(self, tmp_path: Path) -> None: + archive = tmp_path / "test.tar.gz" + archive.write_bytes(_gzip_tar_bytes({"file.txt": b"hello"})) + dest = tmp_path / "out" + dest.mkdir() + archive_extract_all(archive, dest) + assert (dest / "file.txt").read_bytes() == b"hello" + + def test_buffered_reader_input(self, tmp_path: Path) -> None: + archive = tmp_path / "test.tar.gz" + archive.write_bytes(_gzip_tar_bytes({"file.txt": b"data"})) + dest = tmp_path / "out" + dest.mkdir() + with archive.open("rb") as f: # io.BufferedReader + archive_extract_all(f, dest) + assert (dest / "file.txt").read_bytes() == b"data" + + def test_rawio_input(self, tmp_path: Path) -> None: + archive = tmp_path / "test.tar.gz" + archive.write_bytes(_gzip_tar_bytes({"file.txt": b"raw"})) + dest = tmp_path / "out" + dest.mkdir() + archive_extract_all(io.FileIO(archive), dest) + assert (dest / "file.txt").read_bytes() == b"raw" + + def test_zip_dispatched(self, tmp_path: Path) -> None: + archive = tmp_path / "test.zip" + archive.write_bytes(_make_zip([("file.txt", "hi")]).getvalue()) + dest = tmp_path / "out" + dest.mkdir() + archive_extract_all(archive, dest) + assert (dest / "file.txt").read_text() == "hi" + + def test_invalid_type_raises_type_error(self) -> None: + with pytest.raises(TypeError, match="archive must be"): + archive_extract_all(42, ".") # type: ignore[arg-type] + + def test_unsupported_format_raises_value_error(self, tmp_path: Path) -> None: + bad = tmp_path / "bad.bin" + bad.write_bytes(b"\x00\x01\x02\x03\x04\x05\x06") + with pytest.raises(ValueError, match="Unsupported archive format"): + archive_extract_all(bad, tmp_path) + + +# --------------------------------------------------------------------------- +# download_from_mirrors +# --------------------------------------------------------------------------- + + +def _mock_response(content: bytes, ok: bool = True) -> MagicMock: + r = MagicMock() + r.__enter__.return_value = r + r.__exit__.return_value = False + if ok: + r.raise_for_status.return_value = None + else: + r.raise_for_status.side_effect = req.HTTPError("503") + r.headers = {"content-length": "0"} # suppress ProgressBar + r.iter_content.return_value = [content] if content else [] + return r + + +class TestDownloadFromMirrors: + def test_success_returns_url_and_writes_content(self, tmp_path: Path) -> None: + target = tmp_path / "out.bin" + with patch( + "esphome.framework_helpers.requests.get", + return_value=_mock_response(b"filedata"), + ): + url = download_from_mirrors(["https://example.com/f"], {}, target) + assert url == "https://example.com/f" + assert target.read_bytes() == b"filedata" + + def test_substitutions_applied_to_url(self, tmp_path: Path) -> None: + with patch( + "esphome.framework_helpers.requests.get", + return_value=_mock_response(b"x"), + ) as mock_get: + download_from_mirrors( + ["https://example.com/{VERSION}.bin"], + {"VERSION": "1.2.3"}, + tmp_path / "out.bin", + ) + assert mock_get.call_args[0][0] == "https://example.com/1.2.3.bin" + + def test_falls_back_to_second_mirror(self, tmp_path: Path) -> None: + with patch( + "esphome.framework_helpers.requests.get", + side_effect=[_mock_response(b"", ok=False), _mock_response(b"second")], + ): + url = download_from_mirrors( + ["https://mirror1.com/f", "https://mirror2.com/f"], + {}, + tmp_path / "out.bin", + ) + assert url == "https://mirror2.com/f" + assert (tmp_path / "out.bin").read_bytes() == b"second" + + def test_all_mirrors_fail_reraises_last_exception(self, tmp_path: Path) -> None: + with ( + patch( + "esphome.framework_helpers.requests.get", + return_value=_mock_response(b"", ok=False), + ), + pytest.raises(req.HTTPError), + ): + download_from_mirrors(["https://example.com/f"], {}, tmp_path / "out.bin") + + def test_empty_mirrors_raises_value_error(self, tmp_path: Path) -> None: + with pytest.raises(ValueError, match="empty mirrors list"): + download_from_mirrors([], {}, tmp_path / "out.bin") + + def test_invalid_target_type_raises_type_error(self) -> None: + with pytest.raises(TypeError, match="target must be"): + download_from_mirrors(["https://example.com/f"], {}, 42) # type: ignore[arg-type] + + def test_file_like_target_written(self) -> None: + buf = io.BytesIO() + with patch( + "esphome.framework_helpers.requests.get", + return_value=_mock_response(b"bytes"), + ): + download_from_mirrors(["https://example.com/f"], {}, buf) + buf.seek(0) + assert buf.read() == b"bytes" + + def test_progress_bar_shown_when_content_length_known(self, tmp_path: Path) -> None: + r = _mock_response(b"1234567890") + r.headers = {"content-length": "10"} + with ( + patch("esphome.framework_helpers.requests.get", return_value=r), + patch("esphome.framework_helpers.ProgressBar") as mock_pb, + ): + download_from_mirrors(["https://example.com/f"], {}, tmp_path / "out.bin") + mock_pb.assert_called_once_with("Downloading") + mock_pb.return_value.update.assert_called() + + def test_empty_chunk_not_written(self, tmp_path: Path) -> None: + """Empty chunks yielded by iter_content are skipped without writing.""" + r = MagicMock() + r.__enter__.return_value = r + r.__exit__.return_value = False + r.raise_for_status.return_value = None + r.headers = {"content-length": "0"} + r.iter_content.return_value = [b""] # one empty chunk + target = tmp_path / "out.bin" + with patch("esphome.framework_helpers.requests.get", return_value=r): + download_from_mirrors(["https://example.com/f"], {}, target) + assert target.exists() + assert target.read_bytes() == b"" + + +# --------------------------------------------------------------------------- +# get_python_env_executable_path — Windows branch +# --------------------------------------------------------------------------- + + +def test_get_python_env_executable_path_nt() -> None: + """Windows path uses Scripts/ and .exe suffix.""" + from pathlib import PurePosixPath + + with ( + patch.object(os, "name", "nt"), + patch("esphome.framework_helpers.Path", PurePosixPath), + ): + result = get_python_env_executable_path("/env", "python") + assert str(result) == "/env/Scripts/python.exe" + + +# --------------------------------------------------------------------------- +# _tar_extract_all — additional branch coverage +# --------------------------------------------------------------------------- + + +class TestTarExtractAllBranches: + @pytest.mark.skipif( + sys.version_info < (3, 12), + reason="patching os.name makes pathlib build a WindowsPath, which only " + "instantiates on POSIX in 3.12+", + ) + def test_windows_drive_path_skipped(self, tmp_path: Path) -> None: + """Windows-style drive path (C:/...) is skipped when os.name == 'nt'.""" + info = tarfile.TarInfo(name="C:/secret.txt") + info.type = tarfile.REGTYPE + info.size = 0 + info.mode = 0o644 + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w") as tf: + tf.addfile(info) + buf.seek(0) + with patch.object(os, "name", "nt"): + _tar_extract_all(buf, tmp_path) + assert not list(tmp_path.rglob("*")) + + def test_strip_root_exact_match_skipped(self, tmp_path: Path) -> None: + """Member whose name equals strip_root exactly (no trailing slash) is skipped.""" + # "wrapper" (file entry) + "wrapper/file.txt" causes _detect_archive_root + # to return "wrapper"; the bare "wrapper" entry matches strip_root exactly. + buf = _make_tar( + [_reg("wrapper"), _reg("wrapper/file.txt")], + {"wrapper/file.txt": b"content"}, + ) + _tar_extract_all(buf, tmp_path) + assert not (tmp_path / "wrapper").exists() + assert (tmp_path / "file.txt").read_bytes() == b"content" + + def test_member_not_under_strip_prefix_skipped(self, tmp_path: Path) -> None: + """Member whose name doesn't start with strip_prefix is silently skipped.""" + buf = _make_tar([_reg("other/file.txt")], {"other/file.txt": b"data"}) + with patch("esphome.framework_helpers._detect_archive_root", return_value="w"): + _tar_extract_all(buf, tmp_path) + assert not list(tmp_path.rglob("*")) + + def test_hardlink_prefix_stripped(self, tmp_path: Path) -> None: + """Hard-link linkname has wrapper prefix stripped along with its entry name.""" + buf = _make_tar( + [_reg("wrapper/file.txt"), _hlnk("wrapper/link.txt", "wrapper/file.txt")], + {"wrapper/file.txt": b"data"}, + ) + _tar_extract_all(buf, tmp_path) + assert (tmp_path / "file.txt").read_bytes() == b"data" + assert (tmp_path / "link.txt").exists() + + def test_hardlink_linkname_equals_strip_root_skipped(self, tmp_path: Path) -> None: + """Hard link whose linkname equals strip_root is silently skipped.""" + buf = _make_tar( + [_reg("wrapper/file.txt"), _hlnk("wrapper/link.txt", "wrapper")], + {"wrapper/file.txt": b"data"}, + ) + _tar_extract_all(buf, tmp_path) + assert not (tmp_path / "link.txt").exists() + + def test_hardlink_linkname_outside_prefix_skipped(self, tmp_path: Path) -> None: + """Hard link whose linkname doesn't start with strip_prefix is skipped.""" + buf = _make_tar( + [_reg("wrapper/file.txt"), _hlnk("wrapper/link.txt", "other/file.txt")], + {"wrapper/file.txt": b"data"}, + ) + _tar_extract_all(buf, tmp_path) + assert not (tmp_path / "link.txt").exists() + + def test_member_mode_none_skips_sanitization(self, tmp_path: Path) -> None: + """Member with mode=None bypasses the sanitization block without error.""" + info = _reg("file.txt") + buf = _make_tar([info], {"file.txt": b"data"}) + buf.seek(0) + with tarfile.open(fileobj=buf) as tf: + members = tf.getmembers() + for m in members: + m.mode = None + buf.seek(0) + with ( + patch("tarfile.TarFile.getmembers", return_value=members), + patch("tarfile.TarFile.extract"), + ): + _tar_extract_all(buf, tmp_path) + + def test_progress_bar_shown(self, tmp_path: Path) -> None: + """A non-empty progress_header causes ProgressBar to be created and updated.""" + buf = _make_tar([_reg("file.txt")], {"file.txt": b"x"}) + with patch("esphome.framework_helpers.ProgressBar") as mock_pb: + _tar_extract_all(buf, tmp_path, progress_header="Extracting") + mock_pb.assert_called_once_with("Extracting") + mock_pb.return_value.update.assert_called() + + +# --------------------------------------------------------------------------- +# _zip_extract_all — additional branch coverage +# --------------------------------------------------------------------------- + + +class TestZipExtractAllBranches: + @pytest.mark.skipif( + sys.version_info < (3, 12), + reason="patching os.name makes pathlib build a WindowsPath, which only " + "instantiates on POSIX in 3.12+", + ) + def test_windows_drive_path_skipped(self, tmp_path: Path) -> None: + """Windows-style drive path (C:/...) is skipped when os.name == 'nt'.""" + buf = _make_zip([("C:/secret.txt", "bad")]) + with patch.object(os, "name", "nt"): + _zip_extract_all(buf, tmp_path) + assert not list(tmp_path.rglob("*")) + + def test_member_not_under_strip_prefix_skipped(self, tmp_path: Path) -> None: + """Member whose name doesn't start with strip_prefix is silently skipped.""" + buf = _make_zip([("other/file.txt", "data")]) + with patch("esphome.framework_helpers._detect_archive_root", return_value="w"): + _zip_extract_all(buf, tmp_path) + assert not list(tmp_path.rglob("*")) + + def test_progress_bar_shown(self, tmp_path: Path) -> None: + """A non-empty progress_header causes ProgressBar to be created and updated.""" + buf = _make_zip([("file.txt", "hello")]) + with patch("esphome.framework_helpers.ProgressBar") as mock_pb: + _zip_extract_all(buf, tmp_path, progress_header="Unzipping") + mock_pb.assert_called_once_with("Unzipping") + mock_pb.return_value.update.assert_called() + + +# --------------------------------------------------------------------------- +# _rename_with_retry +# --------------------------------------------------------------------------- + + +class TestRenameWithRetry: + def test_success_on_first_attempt(self, tmp_path: Path) -> None: + src = tmp_path / "src.txt" + src.write_text("data") + dst = tmp_path / "dst.txt" + _rename_with_retry(src, dst) + assert dst.read_text() == "data" + assert not src.exists() + + def test_retries_on_permission_error_then_succeeds(self, tmp_path: Path) -> None: + src = tmp_path / "src.txt" + src.write_text("data") + dst = tmp_path / "dst.txt" + call_count = 0 + original_rename = Path.rename + + def flaky_rename(self, target): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise PermissionError("locked") + return original_rename(self, target) + + with ( + patch.object(Path, "rename", flaky_rename), + patch("esphome.framework_helpers.time.sleep"), + ): + _rename_with_retry(src, dst, attempts=3) + assert dst.read_text() == "data" + + def test_raises_after_all_attempts_fail(self, tmp_path: Path) -> None: + src = tmp_path / "src.txt" + src.write_text("data") + dst = tmp_path / "dst.txt" + with ( + patch.object(Path, "rename", side_effect=PermissionError("locked")), + patch("esphome.framework_helpers.time.sleep"), + pytest.raises(PermissionError), + ): + _rename_with_retry(src, dst, attempts=3) + + def test_attempts_zero_is_noop(self, tmp_path: Path) -> None: + """Zero attempts means the for-loop body never runs; src is untouched.""" + src = tmp_path / "src.txt" + src.write_text("data") + dst = tmp_path / "dst.txt" + _rename_with_retry(src, dst, attempts=0) + assert src.exists() + assert not dst.exists() + + +# --------------------------------------------------------------------------- +# _7z_extract_all +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(not _HAS_PY7ZR, reason="py7zr not installed") +class TestSevenZipExtractAll: + @staticmethod + def _make_7z(entries: dict[str, bytes]) -> io.BytesIO: + import py7zr + + buf = io.BytesIO() + with py7zr.SevenZipFile(buf, "w") as sz: + for name, content in entries.items(): + sz.writef(io.BytesIO(content), name) + buf.seek(0) + return buf + + def test_basic_extraction_no_wrapper(self, tmp_path: Path) -> None: + buf = self._make_7z({"a.txt": b"aaa", "b.txt": b"bbb"}) + out = tmp_path / "out" + out.mkdir() + _7z_extract_all(buf, out) + assert (out / "a.txt").exists() + assert (out / "b.txt").exists() + + def test_strips_wrapper_directory(self, tmp_path: Path) -> None: + buf = self._make_7z({"wrapper/file.txt": b"data"}) + out = tmp_path / "out" + out.mkdir() + _7z_extract_all(buf, out) + assert (out / "file.txt").exists() + assert not (out / "wrapper").exists() + + def test_staging_suffix_collision(self, tmp_path: Path) -> None: + """When .extract_tmp_0 already exists, suffix is incremented to find a free slot.""" + out = tmp_path / "out" + out.mkdir() + (out / ".extract_tmp_0").mkdir() + buf = self._make_7z({"file.txt": b"hi"}) + _7z_extract_all(buf, out) + assert (out / "file.txt").exists() + # .extract_tmp_1 should be cleaned up after extraction + assert not (out / ".extract_tmp_1").exists() + + def test_overwrites_existing_directory(self, tmp_path: Path) -> None: + """Pre-existing destination directory is replaced.""" + out = tmp_path / "out" + out.mkdir() + existing_dir = out / "file.txt" + existing_dir.mkdir() + buf = self._make_7z({"file.txt": b"new"}) + _7z_extract_all(buf, out) + assert (out / "file.txt").is_file() + + def test_overwrites_existing_file(self, tmp_path: Path) -> None: + """Pre-existing destination file is replaced.""" + out = tmp_path / "out" + out.mkdir() + (out / "file.txt").write_bytes(b"old") + buf = self._make_7z({"file.txt": b"new"}) + _7z_extract_all(buf, out) + assert (out / "file.txt").exists() + + def test_empty_name_skipped(self, tmp_path: Path) -> None: + """Archive entries with empty names are silently skipped.""" + import py7zr + + buf = self._make_7z({"file.txt": b"data"}) + out = tmp_path / "out" + out.mkdir() + with patch.object( + py7zr.SevenZipFile, "getnames", return_value=["", "file.txt"] + ): + _7z_extract_all(buf, out) + assert (out / "file.txt").exists() + + def test_path_traversal_skipped(self, tmp_path: Path) -> None: + """Entries whose resolved path exits extract_dir are skipped.""" + import py7zr + + buf = self._make_7z({"file.txt": b"safe"}) + out = tmp_path / "out" + out.mkdir() + with patch.object( + py7zr.SevenZipFile, "getnames", return_value=["../escape.txt", "file.txt"] + ): + _7z_extract_all(buf, out) + assert not (tmp_path / "escape.txt").exists() + assert (out / "file.txt").exists() + + def test_progress_bar_shown(self, tmp_path: Path) -> None: + buf = self._make_7z({"file.txt": b"x"}) + out = tmp_path / "out" + out.mkdir() + with patch("esphome.framework_helpers.ProgressBar") as mock_pb: + _7z_extract_all(buf, out, progress_header="Unpacking 7z") + mock_pb.assert_called_once_with("Unpacking 7z") + mock_pb.return_value.update.assert_called() + + def test_absolute_path_in_names_skipped(self, tmp_path: Path) -> None: + """Names that resolve as absolute are silently skipped.""" + import py7zr + + buf = self._make_7z({"file.txt": b"safe"}) + out = tmp_path / "out" + out.mkdir() + + original_is_absolute = Path.is_absolute + + def patched_is_absolute(self: Path) -> bool: + if str(self).startswith("C:"): + return True + return original_is_absolute(self) + + with ( + patch.object( + py7zr.SevenZipFile, "getnames", return_value=["C:/evil.txt", "file.txt"] + ), + patch.object(Path, "is_absolute", patched_is_absolute), + ): + _7z_extract_all(buf, out) + # Avoid `out / "C:"` here: pathlib treats "C:" as a drive (always + # "exists" on Windows). Assert on the actual extracted files instead. + extracted = sorted(p.name for p in out.rglob("*") if p.is_file()) + assert extracted == ["file.txt"] + + def test_dispatched_via_archive_extract_all(self, tmp_path: Path) -> None: + """archive_extract_all dispatches 7z archives to _7z_extract_all.""" + buf = self._make_7z({"hello.txt": b"world"}) + data = buf.read() + assert data[:6] == b"\x37\x7a\xbc\xaf\x27\x1c" + archive = tmp_path / "test.7z" + archive.write_bytes(data) + out = tmp_path / "out" + out.mkdir() + archive_extract_all(archive, out) + assert (out / "hello.txt").exists() diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py new file mode 100644 index 0000000000..9652ad08eb --- /dev/null +++ b/tests/unit_tests/test_nrf52_framework.py @@ -0,0 +1,219 @@ +"""Tests for esphome.components.nrf52.framework helpers.""" + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from esphome.components.nrf52.framework import ( + _TOOLCHAIN_VERSION, + _get_toolchain_platform_info, + check_and_install, +) +from esphome.config_validation import Version +from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION +from esphome.core import CORE, EsphomeError + + +@pytest.mark.parametrize( + ("system", "machine", "expected"), + [ + # default — no branch hit + ("Linux", "x86_64", ("linux", "x86_64", "tar.xz")), + # arm64 → aarch64 rename + ("Linux", "arm64", ("linux", "aarch64", "tar.xz")), + # darwin → macos rename only + ("Darwin", "x86_64", ("macos", "x86_64", "tar.xz")), + # both renames apply + ("Darwin", "arm64", ("macos", "aarch64", "tar.xz")), + # windows forces x86_64 + 7z; arm64 rename is overwritten + ("Windows", "arm64", ("windows", "x86_64", "7z")), + ], +) +def test_get_toolchain_platform_info( + system: str, machine: str, expected: tuple[str, str, str] +) -> None: + with ( + patch("platform.system", return_value=system), + patch("platform.machine", return_value=machine), + ): + assert _get_toolchain_platform_info() == expected + + +# --------------------------------------------------------------------------- +# Helpers and fixtures for check_and_install tests +# --------------------------------------------------------------------------- + +_TEST_SDK_VERSION = "2.9.0" + + +@pytest.fixture +def nrf52_dirs(setup_core: Path) -> SimpleNamespace: + """Populate CORE and pre-create SDK directories so sentinel.touch() succeeds.""" + CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: Version.parse(_TEST_SDK_VERSION)} + tools = CORE.data_dir / "sdk-nrf" + python_env = tools / "penvs" / f"v{_TEST_SDK_VERSION}" + framework = tools / "frameworks" / f"v{_TEST_SDK_VERSION}" + toolchain_dir = tools / "toolchains" / _TOOLCHAIN_VERSION + for d in (python_env, framework, toolchain_dir): + d.mkdir(parents=True, exist_ok=True) + return SimpleNamespace( + python_env=python_env, + framework=framework, + toolchain=toolchain_dir, + ) + + +@pytest.fixture +def mock_nrf52_ops(): + """Patch all heavy I/O operations used by check_and_install.""" + with ( + patch("esphome.components.nrf52.framework.rmdir") as mock_rmdir, + patch("esphome.components.nrf52.framework.create_venv") as mock_create_venv, + patch( + "esphome.components.nrf52.framework.run_command_ok", return_value=True + ) as mock_run_cmd, + patch( + "esphome.components.nrf52.framework.download_from_mirrors", + return_value="https://example.com/tc.tar.xz", + ) as mock_download, + patch("esphome.components.nrf52.framework.archive_extract_all") as mock_extract, + ): + yield SimpleNamespace( + rmdir=mock_rmdir, + create_venv=mock_create_venv, + run_command_ok=mock_run_cmd, + download_from_mirrors=mock_download, + archive_extract_all=mock_extract, + ) + + +# --------------------------------------------------------------------------- +# check_and_install tests +# --------------------------------------------------------------------------- + + +class TestCheckAndInstall: + def test_all_installed_skips_all_steps( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """All three sentinels present → nothing downloaded or compiled.""" + (nrf52_dirs.python_env / ".ready").touch() + (nrf52_dirs.framework / ".ready").touch() + (nrf52_dirs.toolchain / ".ready").touch() + + check_and_install() + + mock_nrf52_ops.create_venv.assert_not_called() + mock_nrf52_ops.run_command_ok.assert_not_called() + mock_nrf52_ops.download_from_mirrors.assert_not_called() + mock_nrf52_ops.archive_extract_all.assert_not_called() + + def test_fresh_install_runs_all_steps( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """No sentinels → venv created, west installed, SDK init+update, toolchain downloaded.""" + check_and_install() + + mock_nrf52_ops.create_venv.assert_called_once() + # pip install west, west init, west update + assert mock_nrf52_ops.run_command_ok.call_count == 3 + mock_nrf52_ops.download_from_mirrors.assert_called_once() + mock_nrf52_ops.archive_extract_all.assert_called_once() + assert (nrf52_dirs.python_env / ".ready").exists() + assert (nrf52_dirs.framework / ".ready").exists() + assert (nrf52_dirs.toolchain / ".ready").exists() + + def test_venv_exists_installs_framework_and_toolchain( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Venv ready but framework missing → skip venv creation, run SDK init+update.""" + (nrf52_dirs.python_env / ".ready").touch() + + check_and_install() + + mock_nrf52_ops.create_venv.assert_not_called() + # west init + west update only (no pip install) + assert mock_nrf52_ops.run_command_ok.call_count == 2 + mock_nrf52_ops.download_from_mirrors.assert_called_once() + + def test_toolchain_only_missing( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Venv and framework ready → only toolchain downloaded and extracted.""" + (nrf52_dirs.python_env / ".ready").touch() + (nrf52_dirs.framework / ".ready").touch() + + check_and_install() + + mock_nrf52_ops.create_venv.assert_not_called() + mock_nrf52_ops.run_command_ok.assert_not_called() + mock_nrf52_ops.download_from_mirrors.assert_called_once() + mock_nrf52_ops.archive_extract_all.assert_called_once() + + def test_west_install_failure_raises( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Failing pip install west raises EsphomeError.""" + mock_nrf52_ops.run_command_ok.return_value = False + + with pytest.raises(EsphomeError, match="Install west"): + check_and_install() + + def test_framework_init_failure_raises( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Failing west init raises EsphomeError.""" + (nrf52_dirs.python_env / ".ready").touch() + mock_nrf52_ops.run_command_ok.return_value = False + + with pytest.raises(EsphomeError, match="Can't initialize"): + check_and_install() + + def test_framework_update_failure_raises( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Failing west update raises EsphomeError.""" + (nrf52_dirs.python_env / ".ready").touch() + # init succeeds, update fails + mock_nrf52_ops.run_command_ok.side_effect = [True, False] + + with pytest.raises(EsphomeError, match="Can't update"): + check_and_install() + + def test_toolchain_download_passes_platform_substitutions( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """download_from_mirrors receives VERSION + platform triple from _get_toolchain_platform_info.""" + (nrf52_dirs.python_env / ".ready").touch() + (nrf52_dirs.framework / ".ready").touch() + + with patch( + "esphome.components.nrf52.framework._get_toolchain_platform_info", + return_value=("linux", "x86_64", "tar.xz"), + ): + check_and_install() + + args, _ = mock_nrf52_ops.download_from_mirrors.call_args + substitutions = args[1] + assert substitutions["VERSION"] == _TOOLCHAIN_VERSION + assert substitutions["sysname"] == "linux" + assert substitutions["machine"] == "x86_64" + assert substitutions["extension"] == "tar.xz" diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index c1d16530cb..a37b19f584 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -442,6 +442,21 @@ def test_run_compile(setup_core: Path, mock_run_platformio_cli_run: Mock) -> Non mock_run_platformio_cli_run.assert_called_once_with(config, True, "-j4") +def test_run_compile_without_process_limit( + setup_core: Path, mock_run_platformio_cli_run: Mock +) -> None: + """When no compile_process_limit is set, run_compile passes no -j flag.""" + from esphome.const import CONF_ESPHOME + + CORE.build_path = str(setup_core / "build" / "test") + config = {CONF_ESPHOME: {}} + mock_run_platformio_cli_run.return_value = 0 + + toolchain.run_compile(config, verbose=False) + + mock_run_platformio_cli_run.assert_called_once_with(config, False) + + def test_get_idedata_caches_result( setup_core: Path, mock_run_platformio_cli_run: Mock ) -> None: From 8206df6e4e21ffb855be60865a832c142f473f1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Lohynsk=C3=BD?= <85194189+Tomer27cz@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:57:13 +0200 Subject: [PATCH 0362/1815] [dlms_meter] dlms_parser library (#15458) Co-authored-by: PolarGoose <35307286+PolarGoose@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .clang-tidy.hash | 2 +- CODEOWNERS | 2 +- esphome/components/dlms_meter/__init__.py | 253 ++++++- .../dlms_meter/binary_sensor/__init__.py | 20 + esphome/components/dlms_meter/dlms.h | 71 -- esphome/components/dlms_meter/dlms_meter.cpp | 674 +++++------------- esphome/components/dlms_meter/dlms_meter.h | 181 +++-- esphome/components/dlms_meter/mbus.h | 69 -- esphome/components/dlms_meter/obis.h | 94 --- .../components/dlms_meter/sensor/__init__.py | 228 +++--- .../dlms_meter/text_sensor/__init__.py | 68 +- esphome/idf_component.yml | 2 + platformio.ini | 4 + .../components/dlms_meter/common-generic.yaml | 11 - .../components/dlms_meter/common-netznoe.yaml | 17 - tests/components/dlms_meter/common.yaml | 40 ++ .../components/dlms_meter/test.esp32-ard.yaml | 4 +- .../components/dlms_meter/test.esp32-idf.yaml | 4 +- .../dlms_meter/test.esp8266-ard.yaml | 4 +- .../dlms_meter/test.rp2040-ard.yaml | 4 + 20 files changed, 796 insertions(+), 956 deletions(-) create mode 100644 esphome/components/dlms_meter/binary_sensor/__init__.py delete mode 100644 esphome/components/dlms_meter/dlms.h delete mode 100644 esphome/components/dlms_meter/mbus.h delete mode 100644 esphome/components/dlms_meter/obis.h delete mode 100644 tests/components/dlms_meter/common-generic.yaml delete mode 100644 tests/components/dlms_meter/common-netznoe.yaml create mode 100644 tests/components/dlms_meter/test.rp2040-ard.yaml diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 566cac066e..3c1c2be289 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -def25306bb0f5e09b94fe7b74ffa6995a56bb951e7a27d9ad0a21103532a74a9 +fe0fe4fde52c61eb40b1214675af8db44d2678c6b7bc2674d51ed4836ecf94da diff --git a/CODEOWNERS b/CODEOWNERS index c5beba8c0b..c69f8bccd4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -138,7 +138,7 @@ esphome/components/dfplayer/* @glmnet esphome/components/dfrobot_sen0395/* @niklasweber esphome/components/dht/* @OttoWinter esphome/components/display_menu_base/* @numo68 -esphome/components/dlms_meter/* @SimonFischer04 +esphome/components/dlms_meter/* @latonita @PolarGoose @SimonFischer04 @Tomer27cz esphome/components/dps310/* @kbx81 esphome/components/ds1307/* @badbadc0ffee esphome/components/ds2484/* @mrk-its diff --git a/esphome/components/dlms_meter/__init__.py b/esphome/components/dlms_meter/__init__.py index c22ab7b552..7094699b0b 100644 --- a/esphome/components/dlms_meter/__init__.py +++ b/esphome/components/dlms_meter/__init__.py @@ -1,57 +1,258 @@ -import esphome.codegen as cg -from esphome.components import uart -import esphome.config_validation as cv -from esphome.const import CONF_ID, PLATFORM_ESP32, PLATFORM_ESP8266 +import logging +import re -CODEOWNERS = ["@SimonFischer04"] +import esphome.codegen as cg +from esphome.components import esp32, uart +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_NAME, + CONF_PATTERN, + CONF_PRIORITY, + CONF_RECEIVE_TIMEOUT, +) +from esphome.core import CORE + +_LOGGER = logging.getLogger(__name__) + +CODEOWNERS = ["@SimonFischer04", "@Tomer27cz", "@latonita", "@PolarGoose"] DEPENDENCIES = ["uart"] CONF_DLMS_METER_ID = "dlms_meter_id" CONF_DECRYPTION_KEY = "decryption_key" +CONF_AUTH_KEY = "auth_key" +CONF_OBIS_CODE = "obis_code" +CONF_CUSTOM_PATTERNS = "custom_patterns" +CONF_SKIP_CRC = "skip_crc" +CONF_DEFAULT_OBIS = "default_obis" CONF_PROVIDER = "provider" -PROVIDERS = {"generic": 0, "netznoe": 1} - dlms_meter_component_ns = cg.esphome_ns.namespace("dlms_meter") DlmsMeterComponent = dlms_meter_component_ns.class_( "DlmsMeterComponent", cg.Component, uart.UARTDevice ) -def validate_key(value): - value = cv.string_strict(value) - if len(value) != 32: - raise cv.Invalid("Decryption key must be 32 hex characters (16 bytes)") - try: - return [int(value[i : i + 2], 16) for i in range(0, 32, 2)] - except ValueError as exc: - raise cv.Invalid("Decryption key must be hex values from 00 to FF") from exc +def obis_code(value): + # Normalize the OBIS code to the strict A.B.C.D.E.F format + bytes_list = parse_obis_code_bytes(value) + return ".".join(str(b) for b in bytes_list) +def parse_obis_code_bytes(value): + value = cv.string(value) + normalized = re.sub(r"[\-\:\*]", ".", value) + parts = normalized.split(".") + if len(parts) < 5 or len(parts) > 6: + raise cv.Invalid("OBIS code must have 5 or 6 parts") + try: + bytes_list = [int(p) for p in parts] + except ValueError as exc: + raise cv.Invalid("OBIS code parts must be integers") from exc + for b in bytes_list: + if b < 0 or b > 255: + raise cv.Invalid("OBIS code parts must be between 0 and 255") + if len(bytes_list) == 5: + bytes_list.append(255) + return bytes_list + + +def custom_pattern_dict(value): + if isinstance(value, str): + return {CONF_PATTERN: value} + return value + + +def validate_custom_pattern(value): + if CONF_DEFAULT_OBIS in value and CONF_NAME not in value: + raise cv.Invalid(f"'{CONF_DEFAULT_OBIS}' requires '{CONF_NAME}' to be set") + return value + + +def validate_provider_deprecation(config): + if CONF_PROVIDER in config: + provider = str(config[CONF_PROVIDER]).lower() + if provider == "netznoe": + _LOGGER.warning( + "The 'provider: netznoe' option is deprecated and will be removed in 2026.11.0. " + "The required custom patterns have been added automatically for this release, but you must update your configuration.\n" + "Please remove the 'provider' key and explicitly replace it with the following:\n\n" + "custom_patterns:\n" + ' - pattern: "L, TSTR"\n' + ' name: "MeterID"\n' + ' default_obis: "0.0.96.1.0.255"\n' + ' - pattern: "F, TDTM"\n' + ' name: "DateTime"\n' + ' default_obis: "0.0.1.0.0.255"\n' + ) + patterns = config.get(CONF_CUSTOM_PATTERNS, []) + + # Ensure "L, TSTR" for MeterID is present + if not any(p.get(CONF_PATTERN) == "L, TSTR" for p in patterns): + patterns.append( + { + CONF_PATTERN: "L, TSTR", + CONF_NAME: "MeterID", + CONF_DEFAULT_OBIS: [0, 0, 96, 1, 0, 255], + CONF_PRIORITY: 0, + } + ) + + # Ensure "F, TDTM" for DateTime is present + if not any(p.get(CONF_PATTERN) == "F, TDTM" for p in patterns): + patterns.append( + { + CONF_PATTERN: "F, TDTM", + CONF_NAME: "DateTime", + CONF_DEFAULT_OBIS: [0, 0, 1, 0, 0, 255], + CONF_PRIORITY: 0, + } + ) + + config[CONF_CUSTOM_PATTERNS] = patterns + else: + _LOGGER.warning( + "The 'provider' option is deprecated and will be removed in 2026.11.0. " + "The dlms_parser library now handles quirks dynamically. " + "Please remove this option from your configuration." + ) + return config + + +CUSTOM_PATTERN_SCHEMA = cv.All( + custom_pattern_dict, + cv.Schema( + { + cv.Required(CONF_PATTERN): cv.string, + cv.Optional(CONF_NAME): cv.string, + cv.Optional(CONF_PRIORITY, default=0): cv.int_, + cv.Optional(CONF_DEFAULT_OBIS): parse_obis_code_bytes, + } + ), + validate_custom_pattern, +) + CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(DlmsMeterComponent), - cv.Required(CONF_DECRYPTION_KEY): validate_key, - cv.Optional(CONF_PROVIDER, default="generic"): cv.enum( - PROVIDERS, lower=True + cv.Optional(CONF_DECRYPTION_KEY): lambda value: cv.bind_key( + value, name="Decryption key" ), + cv.Optional(CONF_AUTH_KEY): lambda value: cv.bind_key( + value, name="Authentication key" + ), + cv.Optional(CONF_CUSTOM_PATTERNS): cv.ensure_list(CUSTOM_PATTERN_SCHEMA), + cv.Optional(CONF_SKIP_CRC, default=False): cv.boolean, + cv.Optional(CONF_PROVIDER): cv.string, + cv.Optional( + CONF_RECEIVE_TIMEOUT, default="1000ms" + ): cv.positive_time_period_milliseconds, } ) .extend(uart.UART_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA), - cv.only_on([PLATFORM_ESP8266, PLATFORM_ESP32]), + validate_provider_deprecation, ) -FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( - "dlms_meter", baud_rate=2400, require_rx=True -) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema("dlms_meter", require_rx=True) async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) + dec_key_expr = cg.RawExpression("std::nullopt") + if dec_key := config.get(CONF_DECRYPTION_KEY): + key_bytes = [str(int(dec_key[i : i + 2], 16)) for i in range(0, 32, 2)] + dec_key_expr = cg.RawExpression( + f"std::array{{{', '.join(key_bytes)}}}" + ) + + auth_key_expr = cg.RawExpression("std::nullopt") + if auth_key := config.get(CONF_AUTH_KEY): + key_bytes = [str(int(auth_key[i : i + 2], 16)) for i in range(0, 32, 2)] + auth_key_expr = cg.RawExpression( + f"std::array{{{', '.join(key_bytes)}}}" + ) + + patterns = [] + if custom_patterns := config.get(CONF_CUSTOM_PATTERNS): + for p in custom_patterns: + name_expr = cg.RawExpression("std::nullopt") + if name_val := p.get(CONF_NAME): + name_expr = name_val + + if obis_vals := p.get(CONF_DEFAULT_OBIS): + obis_expr = cg.RawExpression( + f"std::array{{{obis_vals[0]}, {obis_vals[1]}, {obis_vals[2]}, {obis_vals[3]}, {obis_vals[4]}, {obis_vals[5]}}}" + ) + else: + obis_expr = cg.RawExpression("std::nullopt") + + patterns.append( + cg.ArrayInitializer( + p[CONF_PATTERN], + name_expr, + p.get(CONF_PRIORITY, 0), + obis_expr, + ) + ) + + patterns_expr = ( + cg.ArrayInitializer(*patterns) if patterns else cg.RawExpression("{}") + ) + + var = cg.new_Pvariable( + config[CONF_ID], + config[CONF_RECEIVE_TIMEOUT], + config[CONF_SKIP_CRC], + dec_key_expr, + auth_key_expr, + patterns_expr, + ) + + hub_id = config[CONF_ID].id + + sensor_count = 0 + for sens_conf in CORE.config.get("sensor", []): + if ( + sens_conf.get("platform") == "dlms_meter" + and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id + ): + if CONF_OBIS_CODE in sens_conf: + sensor_count += 1 + else: + from .sensor import NUMERIC_KEYS + + sensor_count += sum(1 for key in NUMERIC_KEYS if key in sens_conf) + + text_sensor_count = 0 + for sens_conf in CORE.config.get("text_sensor", []): + if ( + sens_conf.get("platform") == "dlms_meter" + and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id + ): + if CONF_OBIS_CODE in sens_conf: + text_sensor_count += 1 + else: + from .text_sensor import TEXT_KEYS + + text_sensor_count += sum(1 for key in TEXT_KEYS if key in sens_conf) + + binary_sensor_count = 0 + for sens_conf in CORE.config.get("binary_sensor", []): + if ( + sens_conf.get("platform") == "dlms_meter" + and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id + ): + binary_sensor_count += 1 + + cg.add_define("DLMS_MAX_SENSORS", sensor_count) + cg.add_define("DLMS_MAX_TEXT_SENSORS", text_sensor_count) + cg.add_define("DLMS_MAX_BINARY_SENSORS", binary_sensor_count) + await cg.register_component(var, config) await uart.register_uart_device(var, config) - key = ", ".join(str(b) for b in config[CONF_DECRYPTION_KEY]) - cg.add(var.set_decryption_key(cg.RawExpression(f"{{{key}}}"))) - cg.add(var.set_provider(PROVIDERS[config[CONF_PROVIDER]])) + + if CORE.is_esp32: + esp32.add_idf_component(name="esphome/dlms_parser", ref="1.1.0") + else: + cg.add_library("esphome/dlms_parser", "1.1.0") diff --git a/esphome/components/dlms_meter/binary_sensor/__init__.py b/esphome/components/dlms_meter/binary_sensor/__init__.py new file mode 100644 index 0000000000..f9bc1d9df7 --- /dev/null +++ b/esphome/components/dlms_meter/binary_sensor/__init__.py @@ -0,0 +1,20 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv + +from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code + +DEPENDENCIES = ["dlms_meter"] + +CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend( + { + cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), + cv.Required(CONF_OBIS_CODE): obis_code, + } +) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) + var = await binary_sensor.new_binary_sensor(config) + cg.add(hub.register_binary_sensor(config[CONF_OBIS_CODE], var)) diff --git a/esphome/components/dlms_meter/dlms.h b/esphome/components/dlms_meter/dlms.h deleted file mode 100644 index a3d8f62ce6..0000000000 --- a/esphome/components/dlms_meter/dlms.h +++ /dev/null @@ -1,71 +0,0 @@ -#pragma once - -#include - -namespace esphome::dlms_meter { - -/* -+-------------------------------+ -| Ciphering Service | -+-------------------------------+ -| System Title Length | -+-------------------------------+ -| | -| | -| | -| System | -| Title | -| | -| | -| | -+-------------------------------+ -| Length | (1 or 3 Bytes) -+-------------------------------+ -| Security Control Byte | -+-------------------------------+ -| | -| Frame | -| Counter | -| | -+-------------------------------+ -| | -~ ~ - Encrypted Payload -~ ~ -| | -+-------------------------------+ - -Ciphering Service: 0xDB (General-Glo-Ciphering) -System Title Length: 0x08 -System Title: Unique ID of meter -Length: 1 Byte=Length <= 127, 3 Bytes=Length > 127 (0x82 & 2 Bytes length) -Security Control Byte: -- Bit 3…0: Security_Suite_Id -- Bit 4: "A" subfield: indicates that authentication is applied -- Bit 5: "E" subfield: indicates that encryption is applied -- Bit 6: Key_Set subfield: 0 = Unicast, 1 = Broadcast -- Bit 7: Indicates the use of compression. - */ - -static constexpr uint8_t DLMS_HEADER_LENGTH = 16; -static constexpr uint8_t DLMS_HEADER_EXT_OFFSET = 2; // Extra offset for extended length header -static constexpr uint8_t DLMS_CIPHER_OFFSET = 0; -static constexpr uint8_t DLMS_SYST_OFFSET = 1; -static constexpr uint8_t DLMS_LENGTH_OFFSET = 10; -static constexpr uint8_t TWO_BYTE_LENGTH = 0x82; -static constexpr uint8_t DLMS_LENGTH_CORRECTION = 5; // Header bytes included in length field -static constexpr uint8_t DLMS_SECBYTE_OFFSET = 11; -static constexpr uint8_t DLMS_FRAMECOUNTER_OFFSET = 12; -static constexpr uint8_t DLMS_FRAMECOUNTER_LENGTH = 4; -static constexpr uint8_t DLMS_PAYLOAD_OFFSET = 16; -static constexpr uint8_t GLO_CIPHERING = 0xDB; -static constexpr uint8_t DATA_NOTIFICATION = 0x0F; -static constexpr uint8_t TIMESTAMP_DATETIME = 0x0C; -static constexpr uint16_t MAX_MESSAGE_LENGTH = 512; // Maximum size of message (when having 2 bytes length in header). - -// Provider specific quirks -static constexpr uint8_t NETZ_NOE_MAGIC_BYTE = 0x81; // Magic length byte used by Netz NOE -static constexpr uint8_t NETZ_NOE_EXPECTED_MESSAGE_LENGTH = 0xF8; -static constexpr uint8_t NETZ_NOE_EXPECTED_SECURITY_CONTROL_BYTE = 0x20; - -} // namespace esphome::dlms_meter diff --git a/esphome/components/dlms_meter/dlms_meter.cpp b/esphome/components/dlms_meter/dlms_meter.cpp index b732e71d24..bdbf798df5 100644 --- a/esphome/components/dlms_meter/dlms_meter.cpp +++ b/esphome/components/dlms_meter/dlms_meter.cpp @@ -1,516 +1,236 @@ #include "dlms_meter.h" +#include "esphome/core/log.h" -#include - -#if defined(USE_ESP8266_FRAMEWORK_ARDUINO) -#include -#elif defined(USE_ESP32) -#include -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) -#include -#else -#include "mbedtls/esp_config.h" -#include "mbedtls/gcm.h" -#endif -#endif +#include namespace esphome::dlms_meter { -static constexpr const char *TAG = "dlms_meter"; +static const char *const TAG = "dlms_meter"; +static void log_callback(dlms_parser::LogLevel level, const char *fmt, va_list args) { + std::array buf; + vsnprintf(buf.data(), buf.size(), fmt, args); + switch (level) { + case dlms_parser::LogLevel::ERROR: + ESP_LOGE(TAG, "%s", buf.data()); + break; + case dlms_parser::LogLevel::WARNING: + ESP_LOGW(TAG, "%s", buf.data()); + break; + case dlms_parser::LogLevel::INFO: + ESP_LOGI(TAG, "%s", buf.data()); + break; + case dlms_parser::LogLevel::VERBOSE: + ESP_LOGV(TAG, "%s", buf.data()); + break; + case dlms_parser::LogLevel::VERY_VERBOSE: + ESP_LOGVV(TAG, "%s", buf.data()); + break; + case dlms_parser::LogLevel::DEBUG: + ESP_LOGD(TAG, "%s", buf.data()); + break; + } +} + +DlmsMeterComponent::DlmsMeterComponent(uint32_t receive_timeout_ms, bool skip_crc_check, + std::optional> decryption_key, + std::optional> authentication_key, + std::vector custom_patterns) + : receive_timeout_ms_(receive_timeout_ms), + skip_crc_check_(skip_crc_check), + custom_patterns_(std::move(custom_patterns)), + parser_(&decryptor_) { + dlms_parser::Logger::set_log_function(log_callback); + + if (decryption_key.has_value()) { +#ifdef DLMS_METER_NO_CRYPTO + ESP_LOGE(TAG, "Decryption is not supported on this platform (no compatible crypto library found)"); +#else + auto opt_key = dlms_parser::Aes128GcmDecryptionKey::from_bytes(decryption_key.value()); + if (opt_key) { + this->parser_.set_decryption_key(*opt_key); + } else { + ESP_LOGE(TAG, "Failed to set decryption key: invalid key format"); + } +#endif + } + + if (authentication_key.has_value()) { +#ifdef DLMS_METER_NO_CRYPTO + ESP_LOGE(TAG, "Authentication is not supported on this platform (no compatible crypto library found)"); +#else + auto opt_key = dlms_parser::Aes128GcmAuthenticationKey::from_bytes(authentication_key.value()); + if (opt_key) { + this->parser_.set_authentication_key(*opt_key); + } else { + ESP_LOGE(TAG, "Failed to set authentication key: invalid key format"); + } +#endif + } + + this->parser_.set_skip_crc_check(this->skip_crc_check_); + + this->parser_.load_default_patterns(); + for (const auto &pattern : this->custom_patterns_) { + if (pattern.default_obis.has_value() && pattern.name.has_value()) { + this->parser_.register_pattern(pattern.name->c_str(), pattern.pattern.c_str(), pattern.priority, + pattern.default_obis.value()); + } else if (pattern.name.has_value()) { + this->parser_.register_pattern(pattern.name->c_str(), pattern.pattern.c_str(), pattern.priority); + } else { + this->parser_.register_pattern(pattern.pattern.c_str()); + } + } +} + +void DlmsMeterComponent::setup() { this->flush_rx_buffer_(); } void DlmsMeterComponent::dump_config() { - const char *provider_name = this->provider_ == PROVIDER_NETZNOE ? "Netz NOE" : "Generic"; - ESP_LOGCONFIG(TAG, - "DLMS Meter:\n" - " Provider: %s\n" - " Read Timeout: %" PRIu32 " ms", - provider_name, this->read_timeout_); -#define DLMS_METER_LOG_SENSOR(s) LOG_SENSOR(" ", #s, this->s##_sensor_); - DLMS_METER_SENSOR_LIST(DLMS_METER_LOG_SENSOR, ) -#define DLMS_METER_LOG_TEXT_SENSOR(s) LOG_TEXT_SENSOR(" ", #s, this->s##_text_sensor_); - DLMS_METER_TEXT_SENSOR_LIST(DLMS_METER_LOG_TEXT_SENSOR, ) + ESP_LOGCONFIG(TAG, "DLMS Meter:"); + ESP_LOGCONFIG(TAG, " Receive Timeout: %u ms", this->receive_timeout_ms_); + ESP_LOGCONFIG(TAG, " Skip CRC Check: %s", YESNO(this->skip_crc_check_)); + + for (const auto &pattern : this->custom_patterns_) { + if (pattern.default_obis.has_value() && pattern.name.has_value()) { + const auto &obis = pattern.default_obis.value(); + ESP_LOGCONFIG(TAG, " Custom Pattern: '%s' (name: %s, priority: %d, default_obis: %d.%d.%d.%d.%d.%d)", + pattern.pattern.c_str(), pattern.name->c_str(), pattern.priority, obis[0], obis[1], obis[2], + obis[3], obis[4], obis[5]); + } else if (pattern.name.has_value()) { + ESP_LOGCONFIG(TAG, " Custom Pattern: '%s' (name: %s, priority: %d)", pattern.pattern.c_str(), + pattern.name->c_str(), pattern.priority); + } else { + ESP_LOGCONFIG(TAG, " Custom Pattern: '%s'", pattern.pattern.c_str()); + } + } + +#ifdef USE_SENSOR + for (const auto &entry : this->sensors_) { + LOG_SENSOR(" ", "Numeric Sensor (OBIS)", entry.sensor); + ESP_LOGCONFIG(TAG, " OBIS: %s", entry.obis_code.c_str()); + } +#endif +#ifdef USE_TEXT_SENSOR + for (const auto &entry : this->text_sensors_) { + LOG_TEXT_SENSOR(" ", "Text Sensor (OBIS)", entry.sensor); + ESP_LOGCONFIG(TAG, " OBIS: %s", entry.obis_code.c_str()); + } +#endif +#ifdef USE_BINARY_SENSOR + for (const auto &entry : this->binary_sensors_) { + LOG_BINARY_SENSOR(" ", "Binary Sensor (OBIS)", entry.sensor); + ESP_LOGCONFIG(TAG, " OBIS: %s", entry.obis_code.c_str()); + } +#endif } void DlmsMeterComponent::loop() { - // Read while data is available, netznoe uses two frames so allow 2x max frame length - size_t avail = this->available(); - if (avail > 0) { - size_t remaining = MBUS_MAX_FRAME_LENGTH * 2 - this->receive_buffer_.size(); - if (remaining == 0) { - ESP_LOGW(TAG, "Receive buffer full, dropping remaining bytes"); - } else { - // Read all available bytes in batches to reduce UART call overhead. - // Cap reads to remaining buffer capacity. - if (avail > remaining) { - avail = remaining; - } - uint8_t buf[64]; - while (avail > 0) { - size_t to_read = std::min(avail, sizeof(buf)); - if (!this->read_array(buf, to_read)) { - break; - } - avail -= to_read; - this->receive_buffer_.insert(this->receive_buffer_.end(), buf, buf + to_read); - this->last_read_ = millis(); - } - } - } - - if (!this->receive_buffer_.empty() && millis() - this->last_read_ > this->read_timeout_) { - this->mbus_payload_.clear(); - if (!this->parse_mbus_(this->mbus_payload_)) - return; - - uint16_t message_length; - uint8_t systitle_length; - uint16_t header_offset; - if (!this->parse_dlms_(this->mbus_payload_, message_length, systitle_length, header_offset)) - return; - - if (message_length < DECODER_START_OFFSET || message_length > MAX_MESSAGE_LENGTH) { - ESP_LOGE(TAG, "DLMS: Message length invalid: %u", message_length); - this->receive_buffer_.clear(); - return; - } - - // Decrypt in place and then decode the OBIS codes - if (!this->decrypt_(this->mbus_payload_, message_length, systitle_length, header_offset)) - return; - this->decode_obis_(&this->mbus_payload_[header_offset + DLMS_PAYLOAD_OFFSET], message_length); + this->read_rx_buffer_(); + if (this->bytes_accumulated_ > 0 && + App.get_loop_component_start_time() - this->last_rx_char_time_ > this->receive_timeout_ms_) { + this->process_frame_(); } } -bool DlmsMeterComponent::parse_mbus_(std::vector &mbus_payload) { - ESP_LOGV(TAG, "Parsing M-Bus frames"); - uint16_t frame_offset = 0; // Offset is used if the M-Bus message is split into multiple frames - - while (frame_offset < this->receive_buffer_.size()) { - // Ensure enough bytes remain for the minimal intro header before accessing indices - if (this->receive_buffer_.size() - frame_offset < MBUS_HEADER_INTRO_LENGTH) { - ESP_LOGE(TAG, "MBUS: Not enough data for frame header (need %d, have %d)", MBUS_HEADER_INTRO_LENGTH, - (this->receive_buffer_.size() - frame_offset)); - this->receive_buffer_.clear(); - return false; - } - - // Check start bytes - if (this->receive_buffer_[frame_offset + MBUS_START1_OFFSET] != START_BYTE_LONG_FRAME || - this->receive_buffer_[frame_offset + MBUS_START2_OFFSET] != START_BYTE_LONG_FRAME) { - ESP_LOGE(TAG, "MBUS: Start bytes do not match"); - this->receive_buffer_.clear(); - return false; - } - - // Both length bytes must be identical - if (this->receive_buffer_[frame_offset + MBUS_LENGTH1_OFFSET] != - this->receive_buffer_[frame_offset + MBUS_LENGTH2_OFFSET]) { - ESP_LOGE(TAG, "MBUS: Length bytes do not match"); - this->receive_buffer_.clear(); - return false; - } - - uint8_t frame_length = this->receive_buffer_[frame_offset + MBUS_LENGTH1_OFFSET]; // Get length of this frame - - // Check if received data is enough for the given frame length - if (this->receive_buffer_.size() - frame_offset < - frame_length + 3) { // length field inside packet does not account for second start- + checksum- + stop- byte - ESP_LOGE(TAG, "MBUS: Frame too big for received data"); - this->receive_buffer_.clear(); - return false; - } - - // Ensure we have full frame (header + payload + checksum + stop byte) before accessing stop byte - size_t required_total = - frame_length + MBUS_HEADER_INTRO_LENGTH + MBUS_FOOTER_LENGTH; // payload + header + 2 footer bytes - if (this->receive_buffer_.size() - frame_offset < required_total) { - ESP_LOGE(TAG, "MBUS: Incomplete frame (need %d, have %d)", (unsigned int) required_total, - this->receive_buffer_.size() - frame_offset); - this->receive_buffer_.clear(); - return false; - } - - if (this->receive_buffer_[frame_offset + frame_length + MBUS_HEADER_INTRO_LENGTH + MBUS_FOOTER_LENGTH - 1] != - STOP_BYTE) { - ESP_LOGE(TAG, "MBUS: Invalid stop byte"); - this->receive_buffer_.clear(); - return false; - } - - // Verify checksum: sum of all bytes starting at MBUS_HEADER_INTRO_LENGTH, take last byte - uint8_t checksum = 0; // use uint8_t so only the 8 least significant bits are stored - for (uint16_t i = 0; i < frame_length; i++) { - checksum += this->receive_buffer_[frame_offset + MBUS_HEADER_INTRO_LENGTH + i]; - } - if (checksum != this->receive_buffer_[frame_offset + frame_length + MBUS_HEADER_INTRO_LENGTH]) { - ESP_LOGE(TAG, "MBUS: Invalid checksum: %x != %x", checksum, - this->receive_buffer_[frame_offset + frame_length + MBUS_HEADER_INTRO_LENGTH]); - this->receive_buffer_.clear(); - return false; - } - - mbus_payload.insert(mbus_payload.end(), &this->receive_buffer_[frame_offset + MBUS_FULL_HEADER_LENGTH], - &this->receive_buffer_[frame_offset + MBUS_HEADER_INTRO_LENGTH + frame_length]); - - frame_offset += MBUS_HEADER_INTRO_LENGTH + frame_length + MBUS_FOOTER_LENGTH; +void DlmsMeterComponent::flush_rx_buffer_() { + while (this->available()) { + this->read(); } - return true; } -bool DlmsMeterComponent::parse_dlms_(const std::vector &mbus_payload, uint16_t &message_length, - uint8_t &systitle_length, uint16_t &header_offset) { - ESP_LOGV(TAG, "Parsing DLMS header"); - if (mbus_payload.size() < DLMS_HEADER_LENGTH + DLMS_HEADER_EXT_OFFSET) { - ESP_LOGE(TAG, "DLMS: Payload too short"); - this->receive_buffer_.clear(); - return false; +void DlmsMeterComponent::read_rx_buffer_() { + int available = this->available(); + if (available == 0) + return; + + if (this->bytes_accumulated_ + available > this->rx_buffer_.size()) { + ESP_LOGW(TAG, "RX Buffer overflow. Frame too large! Dropping frame."); + this->bytes_accumulated_ = 0; + + this->flush_rx_buffer_(); + return; } - if (mbus_payload[DLMS_CIPHER_OFFSET] != GLO_CIPHERING) { // Only general-glo-ciphering is supported (0xDB) - ESP_LOGE(TAG, "DLMS: Unsupported cipher"); - this->receive_buffer_.clear(); - return false; + bool success = this->read_array(this->rx_buffer_.data() + this->bytes_accumulated_, available); + if (!success) { + ESP_LOGW(TAG, "UART read failed. Dropping frame."); + this->bytes_accumulated_ = 0; + this->flush_rx_buffer_(); + return; } - systitle_length = mbus_payload[DLMS_SYST_OFFSET]; + this->bytes_accumulated_ += available; - if (systitle_length != 0x08) { // Only system titles with length of 8 are supported - ESP_LOGE(TAG, "DLMS: Unsupported system title length"); - this->receive_buffer_.clear(); - return false; - } - - message_length = mbus_payload[DLMS_LENGTH_OFFSET]; - header_offset = 0; - - if (this->provider_ == PROVIDER_NETZNOE) { - // for some reason EVN seems to set the standard "length" field to 0x81 and then the actual length is in the next - // byte. Check some bytes to see if received data still matches expectation - if (message_length == NETZ_NOE_MAGIC_BYTE && - mbus_payload[DLMS_LENGTH_OFFSET + 1] == NETZ_NOE_EXPECTED_MESSAGE_LENGTH && - mbus_payload[DLMS_LENGTH_OFFSET + 2] == NETZ_NOE_EXPECTED_SECURITY_CONTROL_BYTE) { - message_length = mbus_payload[DLMS_LENGTH_OFFSET + 1]; - header_offset = 1; - } else { - ESP_LOGE(TAG, "Wrong Length - Security Control Byte sequence detected for provider EVN"); - } - } else { - if (message_length == TWO_BYTE_LENGTH) { - message_length = encode_uint16(mbus_payload[DLMS_LENGTH_OFFSET + 1], mbus_payload[DLMS_LENGTH_OFFSET + 2]); - header_offset = DLMS_HEADER_EXT_OFFSET; - } - } - if (message_length < DLMS_LENGTH_CORRECTION) { - ESP_LOGE(TAG, "DLMS: Message length too short: %u", message_length); - this->receive_buffer_.clear(); - return false; - } - message_length -= DLMS_LENGTH_CORRECTION; // Correct message length due to part of header being included in length - - if (mbus_payload.size() - DLMS_HEADER_LENGTH - header_offset != message_length) { - ESP_LOGV(TAG, "DLMS: Length mismatch - payload=%d, header=%d, offset=%d, message=%d", mbus_payload.size(), - DLMS_HEADER_LENGTH, header_offset, message_length); - ESP_LOGE(TAG, "DLMS: Message has invalid length"); - this->receive_buffer_.clear(); - return false; - } - - if (mbus_payload[header_offset + DLMS_SECBYTE_OFFSET] != 0x21 && - mbus_payload[header_offset + DLMS_SECBYTE_OFFSET] != - 0x20) { // Only certain security suite is supported (0x21 || 0x20) - ESP_LOGE(TAG, "DLMS: Unsupported security control byte"); - this->receive_buffer_.clear(); - return false; - } - - return true; + this->last_rx_char_time_ = App.get_loop_component_start_time(); } -bool DlmsMeterComponent::decrypt_(std::vector &mbus_payload, uint16_t message_length, uint8_t systitle_length, - uint16_t header_offset) { - ESP_LOGV(TAG, "Decrypting payload"); - uint8_t iv[12]; // Reserve space for the IV, always 12 bytes - // Copy system title to IV (System title is before length; no header offset needed!) - // Add 1 to the offset in order to skip the system title length byte - memcpy(&iv[0], &mbus_payload[DLMS_SYST_OFFSET + 1], systitle_length); - memcpy(&iv[8], &mbus_payload[header_offset + DLMS_FRAMECOUNTER_OFFSET], - DLMS_FRAMECOUNTER_LENGTH); // Copy frame counter to IV +void DlmsMeterComponent::process_frame_() { + ESP_LOGV(TAG, "Processing frame of size: %zu bytes", this->bytes_accumulated_); - uint8_t *payload_ptr = &mbus_payload[header_offset + DLMS_PAYLOAD_OFFSET]; + auto callback = [this](const char *obis_code, float float_val, const char *str_val, bool is_numeric) { + this->on_data_(obis_code, float_val, str_val, is_numeric); + }; -#if defined(USE_ESP8266_FRAMEWORK_ARDUINO) - br_gcm_context gcm_ctx; - br_aes_ct_ctr_keys bc; - br_aes_ct_ctr_init(&bc, this->decryption_key_.data(), this->decryption_key_.size()); - br_gcm_init(&gcm_ctx, &bc.vtable, br_ghash_ctmul32); - br_gcm_reset(&gcm_ctx, iv, sizeof(iv)); - br_gcm_flip(&gcm_ctx); - br_gcm_run(&gcm_ctx, 0, payload_ptr, message_length); -#elif defined(USE_ESP32) -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) - // PSA Crypto multipart AEAD (no tag verification, matching legacy behavior) - psa_key_attributes_t attributes = PSA_KEY_ATTRIBUTES_INIT; - psa_set_key_type(&attributes, PSA_KEY_TYPE_AES); - psa_set_key_bits(&attributes, this->decryption_key_.size() * 8); - psa_set_key_usage_flags(&attributes, PSA_KEY_USAGE_DECRYPT); - psa_set_key_algorithm(&attributes, PSA_ALG_GCM); + this->parser_.parse({this->rx_buffer_.data(), this->bytes_accumulated_}, callback); - mbedtls_svc_key_id_t key_id; - bool decrypt_failed = true; - if (psa_import_key(&attributes, this->decryption_key_.data(), this->decryption_key_.size(), &key_id) == PSA_SUCCESS) { - psa_aead_operation_t op = PSA_AEAD_OPERATION_INIT; - if (psa_aead_decrypt_setup(&op, key_id, PSA_ALG_GCM) == PSA_SUCCESS && - psa_aead_set_nonce(&op, iv, sizeof(iv)) == PSA_SUCCESS) { - size_t outlen = 0; - if (psa_aead_update(&op, payload_ptr, message_length, payload_ptr, message_length, &outlen) == PSA_SUCCESS && - outlen == message_length) { - decrypt_failed = false; + this->bytes_accumulated_ = 0; +} + +void DlmsMeterComponent::on_data_(const char *obis_code, float float_val, const char *str_val, bool is_numeric) { + int updated_count = 0; + +#ifdef USE_SENSOR + if (is_numeric) { + for (auto &item : this->sensors_) { + if (item.obis_code == obis_code) { + item.sensor->publish_state(float_val); + updated_count++; } } - psa_aead_abort(&op); - psa_destroy_key(key_id); - } - if (decrypt_failed) { - ESP_LOGE(TAG, "Decryption failed"); - this->receive_buffer_.clear(); - return false; - } -#else - size_t outlen = 0; - mbedtls_gcm_context gcm_ctx; - mbedtls_gcm_init(&gcm_ctx); - mbedtls_gcm_setkey(&gcm_ctx, MBEDTLS_CIPHER_ID_AES, this->decryption_key_.data(), this->decryption_key_.size() * 8); - mbedtls_gcm_starts(&gcm_ctx, MBEDTLS_GCM_DECRYPT, iv, sizeof(iv)); - auto ret = mbedtls_gcm_update(&gcm_ctx, payload_ptr, message_length, payload_ptr, message_length, &outlen); - mbedtls_gcm_free(&gcm_ctx); - if (ret != 0) { - ESP_LOGE(TAG, "Decryption failed with error: %d", ret); - this->receive_buffer_.clear(); - return false; } #endif -#else -#error "Invalid Platform" + +#ifdef USE_TEXT_SENSOR + if (!is_numeric && str_val != nullptr) { + for (auto &item : this->text_sensors_) { + if (item.obis_code == obis_code) { + item.sensor->publish_state(str_val); + updated_count++; + } + } + } #endif - if (payload_ptr[0] != DATA_NOTIFICATION || payload_ptr[5] != TIMESTAMP_DATETIME) { - ESP_LOGE(TAG, "OBIS: Packet was decrypted but data is invalid"); - this->receive_buffer_.clear(); - return false; - } - ESP_LOGV(TAG, "Decrypted payload: %d bytes", message_length); - return true; -} - -void DlmsMeterComponent::decode_obis_(uint8_t *plaintext, uint16_t message_length) { - ESP_LOGV(TAG, "Decoding payload"); - MeterData data{}; - uint16_t current_position = DECODER_START_OFFSET; - bool power_factor_found = false; - - while (current_position + OBIS_CODE_OFFSET <= message_length) { - if (plaintext[current_position + OBIS_TYPE_OFFSET] != DataType::OCTET_STRING) { - ESP_LOGE(TAG, "OBIS: Unsupported OBIS header type: %x", plaintext[current_position + OBIS_TYPE_OFFSET]); - this->receive_buffer_.clear(); - return; - } - - uint8_t obis_code_length = plaintext[current_position + OBIS_LENGTH_OFFSET]; - if (obis_code_length != OBIS_CODE_LENGTH_STANDARD && obis_code_length != OBIS_CODE_LENGTH_EXTENDED) { - ESP_LOGE(TAG, "OBIS: Unsupported OBIS header length: %x", obis_code_length); - this->receive_buffer_.clear(); - return; - } - if (current_position + OBIS_CODE_OFFSET + obis_code_length > message_length) { - ESP_LOGE(TAG, "OBIS: Buffer too short for OBIS code"); - this->receive_buffer_.clear(); - return; - } - - uint8_t *obis_code = &plaintext[current_position + OBIS_CODE_OFFSET]; - uint8_t obis_medium = obis_code[OBIS_A]; - uint16_t obis_cd = encode_uint16(obis_code[OBIS_C], obis_code[OBIS_D]); - - bool timestamp_found = false; - bool meter_number_found = false; - if (this->provider_ == PROVIDER_NETZNOE) { - // Do not advance Position when reading the Timestamp at DECODER_START_OFFSET - if ((obis_code_length == OBIS_CODE_LENGTH_EXTENDED) && (current_position == DECODER_START_OFFSET)) { - timestamp_found = true; - } else if (power_factor_found) { - meter_number_found = true; - power_factor_found = false; - } else { - current_position += obis_code_length + OBIS_CODE_OFFSET; // Advance past code and position - } - } else { - current_position += obis_code_length + OBIS_CODE_OFFSET; // Advance past code, position and type - } - if (!timestamp_found && !meter_number_found && obis_medium != Medium::ELECTRICITY && - obis_medium != Medium::ABSTRACT) { - ESP_LOGE(TAG, "OBIS: Unsupported OBIS medium: %x", obis_medium); - this->receive_buffer_.clear(); - return; - } - - if (current_position >= message_length) { - ESP_LOGE(TAG, "OBIS: Buffer too short for data type"); - this->receive_buffer_.clear(); - return; - } - - float value = 0.0f; - uint8_t value_size = 0; - uint8_t data_type = plaintext[current_position]; - current_position++; - - switch (data_type) { - case DataType::DOUBLE_LONG_UNSIGNED: { - value_size = 4; - if (current_position + value_size > message_length) { - ESP_LOGE(TAG, "OBIS: Buffer too short for DOUBLE_LONG_UNSIGNED"); - this->receive_buffer_.clear(); - return; - } - value = encode_uint32(plaintext[current_position + 0], plaintext[current_position + 1], - plaintext[current_position + 2], plaintext[current_position + 3]); - current_position += value_size; - break; - } - case DataType::LONG_UNSIGNED: { - value_size = 2; - if (current_position + value_size > message_length) { - ESP_LOGE(TAG, "OBIS: Buffer too short for LONG_UNSIGNED"); - this->receive_buffer_.clear(); - return; - } - value = encode_uint16(plaintext[current_position + 0], plaintext[current_position + 1]); - current_position += value_size; - break; - } - case DataType::OCTET_STRING: { - uint8_t data_length = plaintext[current_position]; - current_position++; // Advance past string length - if (current_position + data_length > message_length) { - ESP_LOGE(TAG, "OBIS: Buffer too short for OCTET_STRING"); - this->receive_buffer_.clear(); - return; - } - // Handle timestamp (normal OBIS code or NETZNOE special case) - if (obis_cd == OBIS_TIMESTAMP || timestamp_found) { - if (data_length < 8) { - ESP_LOGE(TAG, "OBIS: Timestamp data too short: %u", data_length); - this->receive_buffer_.clear(); - return; - } - uint16_t year = encode_uint16(plaintext[current_position + 0], plaintext[current_position + 1]); - uint8_t month = plaintext[current_position + 2]; - uint8_t day = plaintext[current_position + 3]; - uint8_t hour = plaintext[current_position + 5]; - uint8_t minute = plaintext[current_position + 6]; - uint8_t second = plaintext[current_position + 7]; - if (year > 9999 || month > 12 || day > 31 || hour > 23 || minute > 59 || second > 59) { - ESP_LOGE(TAG, "Invalid timestamp values: %04u-%02u-%02uT%02u:%02u:%02uZ", year, month, day, hour, minute, - second); - this->receive_buffer_.clear(); - return; - } - snprintf(data.timestamp, sizeof(data.timestamp), "%04u-%02u-%02uT%02u:%02u:%02uZ", year, month, day, hour, - minute, second); - } else if (meter_number_found) { - snprintf(data.meternumber, sizeof(data.meternumber), "%.*s", data_length, &plaintext[current_position]); - } - current_position += data_length; - break; - } - default: - ESP_LOGE(TAG, "OBIS: Unsupported OBIS data type: %x", data_type); - this->receive_buffer_.clear(); - return; - } - - // Skip break after data - if (this->provider_ == PROVIDER_NETZNOE) { - // Don't skip the break on the first timestamp, as there's none - if (!timestamp_found) { - current_position += 2; - } - } else { - current_position += 2; - } - - // Check for additional data (scaler-unit structure) - if (current_position < message_length && plaintext[current_position] == DataType::INTEGER) { - // Apply scaler: real_value = raw_value × 10^scaler - if (current_position + 1 < message_length) { - int8_t scaler = static_cast(plaintext[current_position + 1]); - if (scaler != 0) { - value *= pow10_int(scaler); - } - } - - // on EVN Meters there is no additional break - if (this->provider_ == PROVIDER_NETZNOE) { - current_position += 4; - } else { - current_position += 6; - } - } - - // Handle numeric values (LONG_UNSIGNED and DOUBLE_LONG_UNSIGNED) - if (value_size > 0) { - switch (obis_cd) { - case OBIS_VOLTAGE_L1: - data.voltage_l1 = value; - break; - case OBIS_VOLTAGE_L2: - data.voltage_l2 = value; - break; - case OBIS_VOLTAGE_L3: - data.voltage_l3 = value; - break; - case OBIS_CURRENT_L1: - data.current_l1 = value; - break; - case OBIS_CURRENT_L2: - data.current_l2 = value; - break; - case OBIS_CURRENT_L3: - data.current_l3 = value; - break; - case OBIS_ACTIVE_POWER_PLUS: - data.active_power_plus = value; - break; - case OBIS_ACTIVE_POWER_MINUS: - data.active_power_minus = value; - break; - case OBIS_ACTIVE_ENERGY_PLUS: - data.active_energy_plus = value; - break; - case OBIS_ACTIVE_ENERGY_MINUS: - data.active_energy_minus = value; - break; - case OBIS_REACTIVE_ENERGY_PLUS: - data.reactive_energy_plus = value; - break; - case OBIS_REACTIVE_ENERGY_MINUS: - data.reactive_energy_minus = value; - break; - case OBIS_POWER_FACTOR: - data.power_factor = value; - power_factor_found = true; - break; - default: - ESP_LOGW(TAG, "Unsupported OBIS code 0x%04X", obis_cd); +#ifdef USE_BINARY_SENSOR + if (is_numeric) { + bool state = float_val != 0.0f; + for (auto &item : this->binary_sensors_) { + if (item.obis_code == obis_code) { + item.sensor->publish_state(state); + updated_count++; } } } +#endif - this->receive_buffer_.clear(); - - ESP_LOGI(TAG, "Received valid data"); - this->publish_sensors(data); - this->status_clear_warning(); + if (updated_count == 0) { + ESP_LOGV(TAG, "Received OBIS %s, but no sensors are registered for it.", obis_code); + } } +#ifdef USE_SENSOR +void DlmsMeterComponent::register_sensor(const std::string &obis_code, sensor::Sensor *sensor) { + this->sensors_.push_back({obis_code, sensor}); +} +#endif +#ifdef USE_TEXT_SENSOR +void DlmsMeterComponent::register_text_sensor(const std::string &obis_code, text_sensor::TextSensor *sensor) { + this->text_sensors_.push_back({obis_code, sensor}); +} +#endif +#ifdef USE_BINARY_SENSOR +void DlmsMeterComponent::register_binary_sensor(const std::string &obis_code, binary_sensor::BinarySensor *sensor) { + this->binary_sensors_.push_back({obis_code, sensor}); +} +#endif + } // namespace esphome::dlms_meter diff --git a/esphome/components/dlms_meter/dlms_meter.h b/esphome/components/dlms_meter/dlms_meter.h index c50e6f6b4d..cdc53d5685 100644 --- a/esphome/components/dlms_meter/dlms_meter.h +++ b/esphome/components/dlms_meter/dlms_meter.h @@ -2,95 +2,150 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" +#include "esphome/core/application.h" #include "esphome/core/log.h" +#include "esphome/core/helpers.h" +#include "esphome/components/uart/uart.h" + #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif #ifdef USE_TEXT_SENSOR #include "esphome/components/text_sensor/text_sensor.h" #endif -#include "esphome/components/uart/uart.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif -#include "mbus.h" -#include "dlms.h" -#include "obis.h" +#include -#include #include +#include +#include +#include +#include + +#if __has_include() +#include +#elif !defined(USE_ESP8266) && __has_include() +#if __has_include() +#include +#endif +#include +#elif __has_include() +#include +#else +#define DLMS_METER_NO_CRYPTO +#endif + +#ifndef DLMS_MAX_SENSORS +static constexpr uint8_t DLMS_MAX_SENSORS = 0; +#endif +#ifndef DLMS_MAX_TEXT_SENSORS +static constexpr uint8_t DLMS_MAX_TEXT_SENSORS = 0; +#endif +#ifndef DLMS_MAX_BINARY_SENSORS +static constexpr uint8_t DLMS_MAX_BINARY_SENSORS = 0; +#endif namespace esphome::dlms_meter { -#ifndef DLMS_METER_SENSOR_LIST -#define DLMS_METER_SENSOR_LIST(F, SEP) -#endif - -#ifndef DLMS_METER_TEXT_SENSOR_LIST -#define DLMS_METER_TEXT_SENSOR_LIST(F, SEP) -#endif - -struct MeterData { - float voltage_l1 = 0.0f; // Voltage L1 - float voltage_l2 = 0.0f; // Voltage L2 - float voltage_l3 = 0.0f; // Voltage L3 - float current_l1 = 0.0f; // Current L1 - float current_l2 = 0.0f; // Current L2 - float current_l3 = 0.0f; // Current L3 - float active_power_plus = 0.0f; // Active power taken from grid - float active_power_minus = 0.0f; // Active power put into grid - float active_energy_plus = 0.0f; // Active energy taken from grid - float active_energy_minus = 0.0f; // Active energy put into grid - float reactive_energy_plus = 0.0f; // Reactive energy taken from grid - float reactive_energy_minus = 0.0f; // Reactive energy put into grid - char timestamp[27]{}; // Text sensor for the timestamp value - - // Netz NOE - float power_factor = 0.0f; // Power Factor - char meternumber[13]{}; // Text sensor for the meterNumber value +#ifdef DLMS_METER_NO_CRYPTO +// Fallback dummy decryptor for platforms without supported crypto (e.g., Zephyr during clang-tidy) +class Aes128GcmDecryptorDummy : public dlms_parser::Aes128GcmDecryptor { + public: + void set_decryption_key(const dlms_parser::Aes128GcmDecryptionKey &key) override {} + bool decrypt_in_place(std::span iv, std::span ciphertext_and_plaintext, + std::span aad, std::span tag) override { + return false; + } }; +#endif -// Provider constants -enum Providers : uint32_t { PROVIDER_GENERIC = 0x00, PROVIDER_NETZNOE = 0x01 }; +#if __has_include() +using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorTfPsa; +#elif !defined(USE_ESP8266) && __has_include() +using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorMbedTls; +#elif __has_include() +using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorBearSsl; +#else +using Aes128GcmDecryptorImpl = Aes128GcmDecryptorDummy; +#endif + +#ifdef USE_SENSOR +struct SensorItem { + std::string obis_code; + sensor::Sensor *sensor; +}; +#endif +#ifdef USE_TEXT_SENSOR +struct TextSensorItem { + std::string obis_code; + text_sensor::TextSensor *sensor; +}; +#endif +#ifdef USE_BINARY_SENSOR +struct BinarySensorItem { + std::string obis_code; + binary_sensor::BinarySensor *sensor; +}; +#endif + +struct CustomPattern { + std::string pattern; + std::optional name; + int priority{0}; + std::optional> default_obis; +}; class DlmsMeterComponent : public Component, public uart::UARTDevice { public: - DlmsMeterComponent() = default; + DlmsMeterComponent(uint32_t receive_timeout_ms, bool skip_crc_check, + std::optional> decryption_key, + std::optional> authentication_key, + std::vector custom_patterns); + void setup() override; void dump_config() override; void loop() override; - void set_decryption_key(const std::array &key) { this->decryption_key_ = key; } - void set_provider(uint32_t provider) { this->provider_ = provider; } - - void publish_sensors(MeterData &data) { -#define DLMS_METER_PUBLISH_SENSOR(s) \ - if (this->s##_sensor_ != nullptr) \ - s##_sensor_->publish_state(data.s); - DLMS_METER_SENSOR_LIST(DLMS_METER_PUBLISH_SENSOR, ) - -#define DLMS_METER_PUBLISH_TEXT_SENSOR(s) \ - if (this->s##_text_sensor_ != nullptr) \ - s##_text_sensor_->publish_state(data.s); - DLMS_METER_TEXT_SENSOR_LIST(DLMS_METER_PUBLISH_TEXT_SENSOR, ) - } - - DLMS_METER_SENSOR_LIST(SUB_SENSOR, ) - DLMS_METER_TEXT_SENSOR_LIST(SUB_TEXT_SENSOR, ) +#ifdef USE_SENSOR + void register_sensor(const std::string &obis_code, sensor::Sensor *sensor); +#endif +#ifdef USE_TEXT_SENSOR + void register_text_sensor(const std::string &obis_code, text_sensor::TextSensor *sensor); +#endif +#ifdef USE_BINARY_SENSOR + void register_binary_sensor(const std::string &obis_code, binary_sensor::BinarySensor *sensor); +#endif protected: - bool parse_mbus_(std::vector &mbus_payload); - bool parse_dlms_(const std::vector &mbus_payload, uint16_t &message_length, uint8_t &systitle_length, - uint16_t &header_offset); - bool decrypt_(std::vector &mbus_payload, uint16_t message_length, uint8_t systitle_length, - uint16_t header_offset); - void decode_obis_(uint8_t *plaintext, uint16_t message_length); + void read_rx_buffer_(); + void flush_rx_buffer_(); + void process_frame_(); + void on_data_(const char *obis_code, float float_val, const char *str_val, bool is_numeric); - std::vector receive_buffer_; // Stores the packet currently being received - std::vector mbus_payload_; // Parsed M-Bus payload, reused to avoid heap churn - uint32_t last_read_ = 0; // Timestamp when data was last read - uint32_t read_timeout_ = 1000; // Time to wait after last byte before considering data complete + std::array rx_buffer_; + size_t bytes_accumulated_{0}; + uint32_t last_rx_char_time_{0}; - uint32_t provider_ = PROVIDER_GENERIC; // Provider of the meter / your grid operator - std::array decryption_key_; + uint32_t receive_timeout_ms_{1000}; + bool skip_crc_check_{false}; + + std::vector custom_patterns_; + + Aes128GcmDecryptorImpl decryptor_; + dlms_parser::DlmsParser parser_; + +#ifdef USE_SENSOR + StaticVector sensors_; +#endif +#ifdef USE_TEXT_SENSOR + StaticVector text_sensors_; +#endif +#ifdef USE_BINARY_SENSOR + StaticVector binary_sensors_; +#endif }; } // namespace esphome::dlms_meter diff --git a/esphome/components/dlms_meter/mbus.h b/esphome/components/dlms_meter/mbus.h deleted file mode 100644 index 293d43a55b..0000000000 --- a/esphome/components/dlms_meter/mbus.h +++ /dev/null @@ -1,69 +0,0 @@ -#pragma once - -#include - -namespace esphome::dlms_meter { - -/* -+----------------------------------------------------+ - -| Start Character [0x68] | \ -+----------------------------------------------------+ | -| Data Length (L) | | -+----------------------------------------------------+ | -| Data Length Repeat (L) | | -+----------------------------------------------------+ > M-Bus Data link layer -| Start Character Repeat [0x68] | | -+----------------------------------------------------+ | -| Control/Function Field (C) | | -+----------------------------------------------------+ | -| Address Field (A) | / -+----------------------------------------------------+ - -| Control Information Field (CI) | \ -+----------------------------------------------------+ | -| Source Transport Service Access Point (STSAP) | > DLMS/COSEM M-Bus transport layer -+----------------------------------------------------+ | -| Destination Transport Service Access Point (DTSAP) | / -+----------------------------------------------------+ - -| | \ -~ ~ | - Data > DLMS/COSEM Application Layer -~ ~ | -| | / -+----------------------------------------------------+ - -| Checksum | \ -+----------------------------------------------------+ > M-Bus Data link layer -| Stop Character [0x16] | / -+----------------------------------------------------+ - - -Data_Length = L - C - A - CI -Each line (except Data) is one Byte - -Possible Values found in publicly available docs: -- C: 0x53/0x73 (SND_UD) -- A: FF (Broadcast) -- CI: 0x00-0x1F/0x60/0x61/0x7C/0x7D -- STSAP: 0x01 (Management Logical Device ID 1 of the meter) -- DTSAP: 0x67 (Consumer Information Push Client ID 103) - */ - -// MBUS start bytes for different telegram formats: -// - Single Character: 0xE5 (length=1) -// - Short Frame: 0x10 (length=5) -// - Control Frame: 0x68 (length=9) -// - Long Frame: 0x68 (length=9+data_length) -// This component currently only uses Long Frame. -static constexpr uint8_t START_BYTE_SINGLE_CHARACTER = 0xE5; -static constexpr uint8_t START_BYTE_SHORT_FRAME = 0x10; -static constexpr uint8_t START_BYTE_CONTROL_FRAME = 0x68; -static constexpr uint8_t START_BYTE_LONG_FRAME = 0x68; -static constexpr uint8_t MBUS_HEADER_INTRO_LENGTH = 4; // Header length for the intro (0x68, length, length, 0x68) -static constexpr uint8_t MBUS_FULL_HEADER_LENGTH = 9; // Total header length -static constexpr uint8_t MBUS_FOOTER_LENGTH = 2; // Footer after frame -static constexpr uint8_t MBUS_MAX_FRAME_LENGTH = 250; // Maximum size of frame -static constexpr uint8_t MBUS_START1_OFFSET = 0; // Offset of first start byte -static constexpr uint8_t MBUS_LENGTH1_OFFSET = 1; // Offset of first length byte -static constexpr uint8_t MBUS_LENGTH2_OFFSET = 2; // Offset of (duplicated) second length byte -static constexpr uint8_t MBUS_START2_OFFSET = 3; // Offset of (duplicated) second start byte -static constexpr uint8_t STOP_BYTE = 0x16; - -} // namespace esphome::dlms_meter diff --git a/esphome/components/dlms_meter/obis.h b/esphome/components/dlms_meter/obis.h deleted file mode 100644 index 1bb960e61e..0000000000 --- a/esphome/components/dlms_meter/obis.h +++ /dev/null @@ -1,94 +0,0 @@ -#pragma once - -#include - -namespace esphome::dlms_meter { - -// Data types as per specification -enum DataType { - NULL_DATA = 0x00, - BOOLEAN = 0x03, - BIT_STRING = 0x04, - DOUBLE_LONG = 0x05, - DOUBLE_LONG_UNSIGNED = 0x06, - OCTET_STRING = 0x09, - VISIBLE_STRING = 0x0A, - UTF8_STRING = 0x0C, - BINARY_CODED_DECIMAL = 0x0D, - INTEGER = 0x0F, - LONG = 0x10, - UNSIGNED = 0x11, - LONG_UNSIGNED = 0x12, - LONG64 = 0x14, - LONG64_UNSIGNED = 0x15, - ENUM = 0x16, - FLOAT32 = 0x17, - FLOAT64 = 0x18, - DATE_TIME = 0x19, - DATE = 0x1A, - TIME = 0x1B, - - ARRAY = 0x01, - STRUCTURE = 0x02, - COMPACT_ARRAY = 0x13 -}; - -enum Medium { - ABSTRACT = 0x00, - ELECTRICITY = 0x01, - HEAT_COST_ALLOCATOR = 0x04, - COOLING = 0x05, - HEAT = 0x06, - GAS = 0x07, - COLD_WATER = 0x08, - HOT_WATER = 0x09, - OIL = 0x10, - COMPRESSED_AIR = 0x11, - NITROGEN = 0x12 -}; - -// Data structure -static constexpr uint8_t DECODER_START_OFFSET = 20; // Skip header, timestamp and break block -static constexpr uint8_t OBIS_TYPE_OFFSET = 0; -static constexpr uint8_t OBIS_LENGTH_OFFSET = 1; -static constexpr uint8_t OBIS_CODE_OFFSET = 2; -static constexpr uint8_t OBIS_CODE_LENGTH_STANDARD = 0x06; // 6-byte OBIS code (A.B.C.D.E.F) -static constexpr uint8_t OBIS_CODE_LENGTH_EXTENDED = 0x0C; // 12-byte extended OBIS code -static constexpr uint8_t OBIS_A = 0; -static constexpr uint8_t OBIS_B = 1; -static constexpr uint8_t OBIS_C = 2; -static constexpr uint8_t OBIS_D = 3; -static constexpr uint8_t OBIS_E = 4; -static constexpr uint8_t OBIS_F = 5; - -// Metadata -static constexpr uint16_t OBIS_TIMESTAMP = 0x0100; -static constexpr uint16_t OBIS_SERIAL_NUMBER = 0x6001; -static constexpr uint16_t OBIS_DEVICE_NAME = 0x2A00; - -// Voltage -static constexpr uint16_t OBIS_VOLTAGE_L1 = 0x2007; -static constexpr uint16_t OBIS_VOLTAGE_L2 = 0x3407; -static constexpr uint16_t OBIS_VOLTAGE_L3 = 0x4807; - -// Current -static constexpr uint16_t OBIS_CURRENT_L1 = 0x1F07; -static constexpr uint16_t OBIS_CURRENT_L2 = 0x3307; -static constexpr uint16_t OBIS_CURRENT_L3 = 0x4707; - -// Power -static constexpr uint16_t OBIS_ACTIVE_POWER_PLUS = 0x0107; -static constexpr uint16_t OBIS_ACTIVE_POWER_MINUS = 0x0207; - -// Active energy -static constexpr uint16_t OBIS_ACTIVE_ENERGY_PLUS = 0x0108; -static constexpr uint16_t OBIS_ACTIVE_ENERGY_MINUS = 0x0208; - -// Reactive energy -static constexpr uint16_t OBIS_REACTIVE_ENERGY_PLUS = 0x0308; -static constexpr uint16_t OBIS_REACTIVE_ENERGY_MINUS = 0x0408; - -// Netz NOE specific -static constexpr uint16_t OBIS_POWER_FACTOR = 0x0D07; - -} // namespace esphome::dlms_meter diff --git a/esphome/components/dlms_meter/sensor/__init__.py b/esphome/components/dlms_meter/sensor/__init__.py index 27fd44f008..ec4639351d 100644 --- a/esphome/components/dlms_meter/sensor/__init__.py +++ b/esphome/components/dlms_meter/sensor/__init__.py @@ -1,8 +1,9 @@ +import logging + import esphome.codegen as cg from esphome.components import sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, @@ -16,109 +17,142 @@ from esphome.const import ( UNIT_WATT_HOURS, ) -from .. import CONF_DLMS_METER_ID, DlmsMeterComponent +from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code -AUTO_LOAD = ["dlms_meter"] +_LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = cv.Schema( +DEPENDENCIES = ["dlms_meter"] + +NUMERIC_KEYS = { + "voltage_l1": "1.0.32.7.0.255", + "voltage_l2": "1.0.52.7.0.255", + "voltage_l3": "1.0.72.7.0.255", + "current_l1": "1.0.31.7.0.255", + "current_l2": "1.0.51.7.0.255", + "current_l3": "1.0.71.7.0.255", + "active_power_plus": "1.0.1.7.0.255", + "active_power_minus": "1.0.2.7.0.255", + "active_energy_plus": "1.0.1.8.0.255", + "active_energy_minus": "1.0.2.8.0.255", + "reactive_energy_plus": "1.0.3.8.0.255", + "reactive_energy_minus": "1.0.4.8.0.255", + "power_factor": "1.0.13.7.0.255", +} + +DYNAMIC_SCHEMA = sensor.sensor_schema().extend( { cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), - cv.Optional("voltage_l1"): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("voltage_l2"): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("voltage_l3"): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("current_l1"): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("current_l2"): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("current_l3"): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("active_power_plus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT, - accuracy_decimals=0, - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("active_power_minus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT, - accuracy_decimals=0, - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("active_energy_plus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - cv.Optional("active_energy_minus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - cv.Optional("reactive_energy_plus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - cv.Optional("reactive_energy_minus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - # Netz NOE - cv.Optional("power_factor"): sensor.sensor_schema( - accuracy_decimals=3, - device_class=DEVICE_CLASS_POWER_FACTOR, - state_class=STATE_CLASS_MEASUREMENT, - ), + cv.Required(CONF_OBIS_CODE): obis_code, } -).extend(cv.COMPONENT_SCHEMA) +) + + +def deprecation_warning(config): + _LOGGER.warning( + "The dlms_meter sensor schema using predefined keys (e.g., 'voltage_l1') is deprecated and will be removed in 2026.11.0. " + "Please update your configuration to use the new schema with 'obis_code'." + ) + return config + + +OLD_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), + cv.Optional("voltage_l1"): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("voltage_l2"): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("voltage_l3"): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("current_l1"): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("current_l2"): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("current_l3"): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("active_power_plus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("active_power_minus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("active_energy_plus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("active_energy_minus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_plus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_minus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("power_factor"): sensor.sensor_schema( + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + ).extend(cv.COMPONENT_SCHEMA), + deprecation_warning, +) + + +CONFIG_SCHEMA = cv.Any(DYNAMIC_SCHEMA, OLD_SCHEMA) async def to_code(config): hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) - sensors = [] - for key, conf in config.items(): - if not isinstance(conf, dict): - continue - id = conf[CONF_ID] - if id and id.type == sensor.Sensor: - sens = await sensor.new_sensor(conf) - cg.add(getattr(hub, f"set_{key}_sensor")(sens)) - sensors.append(f"F({key})") - - if sensors: - cg.add_define( - "DLMS_METER_SENSOR_LIST(F, sep)", cg.RawExpression(" sep ".join(sensors)) - ) + if obis := config.get(CONF_OBIS_CODE): + var = await sensor.new_sensor(config) + cg.add(hub.register_sensor(obis, var)) + else: + for key, obis_val in NUMERIC_KEYS.items(): + if sensor_config := config.get(key): + sens = await sensor.new_sensor(sensor_config) + cg.add(hub.register_sensor(obis_val, sens)) diff --git a/esphome/components/dlms_meter/text_sensor/__init__.py b/esphome/components/dlms_meter/text_sensor/__init__.py index 4d2373f4f9..0bfb43a285 100644 --- a/esphome/components/dlms_meter/text_sensor/__init__.py +++ b/esphome/components/dlms_meter/text_sensor/__init__.py @@ -1,37 +1,59 @@ +import logging + import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv -from esphome.const import CONF_ID -from .. import CONF_DLMS_METER_ID, DlmsMeterComponent +from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code -AUTO_LOAD = ["dlms_meter"] +_LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = cv.Schema( +DEPENDENCIES = ["dlms_meter"] + +TEXT_KEYS = { + "timestamp": "0.0.1.0.0.255", + "meternumber": "0.0.96.1.0.255", +} + +DYNAMIC_SCHEMA = text_sensor.text_sensor_schema().extend( { cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), - cv.Optional("timestamp"): text_sensor.text_sensor_schema(), - # Netz NOE - cv.Optional("meternumber"): text_sensor.text_sensor_schema(), + cv.Required(CONF_OBIS_CODE): obis_code, } -).extend(cv.COMPONENT_SCHEMA) +) + + +def deprecation_warning(config): + _LOGGER.warning( + "The dlms_meter text_sensor schema using predefined keys (e.g., 'timestamp') is deprecated and will be removed in 2026.11.0. " + "Please update your configuration to use the new schema with 'obis_code'." + ) + return config + + +OLD_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), + cv.Optional("timestamp"): text_sensor.text_sensor_schema(), + cv.Optional("meternumber"): text_sensor.text_sensor_schema(), + } + ).extend(cv.COMPONENT_SCHEMA), + deprecation_warning, +) + + +CONFIG_SCHEMA = cv.Any(DYNAMIC_SCHEMA, OLD_SCHEMA) async def to_code(config): hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) - text_sensors = [] - for key, conf in config.items(): - if not isinstance(conf, dict): - continue - id = conf[CONF_ID] - if id and id.type == text_sensor.TextSensor: - sens = await text_sensor.new_text_sensor(conf) - cg.add(getattr(hub, f"set_{key}_text_sensor")(sens)) - text_sensors.append(f"F({key})") - - if text_sensors: - cg.add_define( - "DLMS_METER_TEXT_SENSOR_LIST(F, sep)", - cg.RawExpression(" sep ".join(text_sensors)), - ) + if obis := config.get(CONF_OBIS_CODE): + var = await text_sensor.new_text_sensor(config) + cg.add(hub.register_text_sensor(obis, var)) + else: + for key, obis_val in TEXT_KEYS.items(): + if text_sensor_config := config.get(key): + sens = await text_sensor.new_text_sensor(text_sensor_config) + cg.add(hub.register_text_sensor(obis_val, sens)) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 4a4bc18579..7cbc2ac4ae 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -1,6 +1,8 @@ dependencies: bblanchon/arduinojson: version: "7.4.2" + esphome/dlms_parser: + version: 1.1.0 esphome/esp-audio-libs: version: 3.2.1 esphome/esp-micro-speech-features: diff --git a/platformio.ini b/platformio.ini index b41e850bcd..d60a4fd68d 100644 --- a/platformio.ini +++ b/platformio.ini @@ -107,6 +107,7 @@ platform_packages = framework = arduino lib_deps = ${common:arduino.lib_deps} + esphome/dlms_parser@1.1.0 ; dlms_meter fastled/FastLED@3.9.16 ; fastled_base bblanchon/ArduinoJson@7.4.2 ; json ESP8266WiFi ; wifi (Arduino built-in) @@ -193,6 +194,7 @@ platform_packages = framework = arduino lib_deps = ${common:arduino.lib_deps} + esphome/dlms_parser@1.1.0 ; dlms_meter fastled/FastLED@3.9.16 ; fastled_base ayushsharma82/RPAsyncTCP@1.3.2 ; async_tcp bblanchon/ArduinoJson@7.4.2 ; json @@ -212,6 +214,7 @@ platform = https://github.com/libretiny-eu/libretiny.git#v1.12.1 framework = arduino lib_compat_mode = soft lib_deps = + esphome/dlms_parser@1.1.0 ; dlms_meter bblanchon/ArduinoJson@7.4.2 ; json ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard @@ -236,6 +239,7 @@ build_flags = -DUSE_NRF52 lib_deps = ${common.lib_deps_base} + esphome/dlms_parser@1.1.0 ; dlms_meter bblanchon/ArduinoJson@7.4.2 ; json lvgl/lvgl@9.5.0 ; lvgl diff --git a/tests/components/dlms_meter/common-generic.yaml b/tests/components/dlms_meter/common-generic.yaml deleted file mode 100644 index edb1c66f0f..0000000000 --- a/tests/components/dlms_meter/common-generic.yaml +++ /dev/null @@ -1,11 +0,0 @@ -dlms_meter: - decryption_key: "36C66639E48A8CA4D6BC8B282A793BBB" # change this to your decryption key! - -sensor: - - platform: dlms_meter - reactive_energy_plus: - name: "Reactive energy taken from grid" - reactive_energy_minus: - name: "Reactive energy put into grid" - -<<: !include common.yaml diff --git a/tests/components/dlms_meter/common-netznoe.yaml b/tests/components/dlms_meter/common-netznoe.yaml deleted file mode 100644 index db064b64f9..0000000000 --- a/tests/components/dlms_meter/common-netznoe.yaml +++ /dev/null @@ -1,17 +0,0 @@ -dlms_meter: - decryption_key: "36C66639E48A8CA4D6BC8B282A793BBB" # change this to your decryption key! - provider: netznoe # (optional) key - only set if using evn - -sensor: - - platform: dlms_meter - # EVN - power_factor: - name: "Power Factor" - -text_sensor: - - platform: dlms_meter - # EVN - meternumber: - name: "meterNumber" - -<<: !include common.yaml diff --git a/tests/components/dlms_meter/common.yaml b/tests/components/dlms_meter/common.yaml index 6aa4e1b0ff..59d854a3ae 100644 --- a/tests/components/dlms_meter/common.yaml +++ b/tests/components/dlms_meter/common.yaml @@ -1,4 +1,16 @@ +dlms_meter: + id: dlms_meter_hub + receive_timeout: 50ms + decryption_key: "36C66639E48A8CA4D6BC8B282A793BBB" + auth_key: "11223344556677889900AABBCCDDEEFF" + skip_crc: true + provider: "netznoe" + custom_patterns: + - "custom_pattern_1" + - "custom_pattern_2" + sensor: + # Old Schema tests - platform: dlms_meter voltage_l1: name: "Voltage L1" @@ -20,8 +32,36 @@ sensor: name: "Active energy taken from grid" active_energy_minus: name: "Active energy put into grid" + reactive_energy_plus: + name: "Reactive energy taken from grid" + reactive_energy_minus: + name: "Reactive energy put into grid" + power_factor: + name: "Power factor" + + # Dynamic Schema tests + - platform: dlms_meter + dlms_meter_id: dlms_meter_hub + obis_code: "1-0:99.99.9" + name: "Custom Dynamic Sensor" text_sensor: + # Old Schema tests - platform: dlms_meter timestamp: name: "timestamp" + meternumber: + name: "Meter Number" + + # Dynamic Schema tests + - platform: dlms_meter + dlms_meter_id: dlms_meter_hub + obis_code: "0-0:99.99.9" + name: "Custom Dynamic Text Sensor" + +binary_sensor: + # Dynamic Schema tests (Binary sensors only use the dynamic schema) + - platform: dlms_meter + dlms_meter_id: dlms_meter_hub + obis_code: "0-1:2.3.4" + name: "Custom Binary Sensor" diff --git a/tests/components/dlms_meter/test.esp32-ard.yaml b/tests/components/dlms_meter/test.esp32-ard.yaml index c9910aa600..bd11a44373 100644 --- a/tests/components/dlms_meter/test.esp32-ard.yaml +++ b/tests/components/dlms_meter/test.esp32-ard.yaml @@ -1,4 +1,4 @@ packages: - uart_2400: !include ../../test_build_components/common/uart_2400/esp32-ard.yaml + uart: !include ../../test_build_components/common/uart/esp32-ard.yaml -<<: !include common-generic.yaml +<<: !include common.yaml diff --git a/tests/components/dlms_meter/test.esp32-idf.yaml b/tests/components/dlms_meter/test.esp32-idf.yaml index 1547532f1e..2d29656c94 100644 --- a/tests/components/dlms_meter/test.esp32-idf.yaml +++ b/tests/components/dlms_meter/test.esp32-idf.yaml @@ -1,4 +1,4 @@ packages: - uart_2400: !include ../../test_build_components/common/uart_2400/esp32-idf.yaml + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml -<<: !include common-netznoe.yaml +<<: !include common.yaml diff --git a/tests/components/dlms_meter/test.esp8266-ard.yaml b/tests/components/dlms_meter/test.esp8266-ard.yaml index 119a1978de..5a05efa259 100644 --- a/tests/components/dlms_meter/test.esp8266-ard.yaml +++ b/tests/components/dlms_meter/test.esp8266-ard.yaml @@ -1,4 +1,4 @@ packages: - uart_2400: !include ../../test_build_components/common/uart_2400/esp8266-ard.yaml + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml -<<: !include common-generic.yaml +<<: !include common.yaml diff --git a/tests/components/dlms_meter/test.rp2040-ard.yaml b/tests/components/dlms_meter/test.rp2040-ard.yaml new file mode 100644 index 0000000000..f1df2daf83 --- /dev/null +++ b/tests/components/dlms_meter/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + +<<: !include common.yaml From 2310b9e3fe83c9765cc4a7f5eb9d8039b9b4da33 Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Tue, 9 Jun 2026 20:27:37 +0200 Subject: [PATCH 0363/1815] [usb_uart] Add Prolific PL2303 USB-serial driver (#16885) --- esphome/components/usb_uart/__init__.py | 9 +- esphome/components/usb_uart/pl2303.cpp | 298 ++++++++++++++++++++++++ esphome/components/usb_uart/usb_uart.h | 25 ++ tests/components/usb_uart/common.yaml | 13 ++ 4 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 esphome/components/usb_uart/pl2303.cpp diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index 7b9c320879..e42a2c092b 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -58,13 +58,20 @@ class Type: uart_types = ( Type("CDC_ACM", 0, 0, "CdcAcm", 1, baud_rate_required=False), - Type("CP210X", 0x10C4, 0xEA60, "CP210X", 3), Type("CH34X", 0x1A86, 0x55D5, "CH34X", 4), Type("CH340", 0x1A86, 0x7523, "CH34X", 1), + Type("CP210X", 0x10C4, 0xEA60, "CP210X", 3), Type("ESP_JTAG", 0x303A, 0x1001, "CdcAcm", 1, baud_rate_required=False), Type("FT232", 0x0403, 0x6001, "FT23XX", 1), Type("FT2232", 0x0403, 0x6010, "FT23XX", 2), Type("FT4232", 0x0403, 0x6011, "FT23XX", 4), + Type("PL2303", 0x067B, 0x2303, "PL2303", 1), + Type("PL2303GB", 0x067B, 0x23B3, "PL2303", 1), + Type("PL2303GC", 0x067B, 0x23A3, "PL2303", 1), + Type("PL2303GE", 0x067B, 0x23E3, "PL2303", 1), + Type("PL2303GL", 0x067B, 0x23D3, "PL2303", 1), + Type("PL2303GS", 0x067B, 0x23F3, "PL2303", 1), + Type("PL2303GT", 0x067B, 0x23C3, "PL2303", 1), Type("STM32_VCP", 0x0483, 0x5740, "CdcAcm", 1, baud_rate_required=False), ) diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp new file mode 100644 index 0000000000..a50f1cf2d4 --- /dev/null +++ b/esphome/components/usb_uart/pl2303.cpp @@ -0,0 +1,298 @@ +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "usb_uart.h" +#include "usb/usb_host.h" +#include "esphome/core/log.h" + +namespace esphome::usb_uart { + +// Control request types +static constexpr uint8_t SET_LINE_REQUEST_TYPE = 0x21; +static constexpr uint8_t SET_LINE_REQUEST = 0x20; + +static constexpr uint8_t SET_CONTROL_REQUEST_TYPE = 0x21; +static constexpr uint8_t SET_CONTROL_REQUEST = 0x22; +static constexpr uint8_t CONTROL_DTR = 0x01; +static constexpr uint8_t CONTROL_RTS = 0x02; + +static constexpr uint8_t VENDOR_WRITE_REQUEST_TYPE = 0x40; +static constexpr uint8_t VENDOR_WRITE_REQUEST = 0x01; + +static constexpr uint8_t VENDOR_READ_REQUEST_TYPE = 0xc0; +static constexpr uint8_t VENDOR_READ_REQUEST = 0x01; + +// Supported standard baud rates for direct encoding (TYPE_H, TYPE_HX, TYPE_HXD, TYPE_HXN) +static const uint32_t SUPPORTED_BAUD_RATES[] = { + 75, 150, 300, 600, 1200, 1800, 2400, 3600, 4800, 7200, 9600, 14400, 19200, + 28800, 38400, 57600, 115200, 230400, 460800, 614400, 921600, 1228800, 2457600, 3000000, 6000000, +}; + +static const char *pl2303_type_name(Pl2303ChipType type) { + switch (type) { + case PL2303_TYPE_H: + return "H (legacy)"; + case PL2303_TYPE_HX: + return "HX"; + case PL2303_TYPE_TA: + return "TA"; + case PL2303_TYPE_TB: + return "TB"; + case PL2303_TYPE_HXD: + return "HXD"; + case PL2303_TYPE_HXN: + return "G/HXN (newer)"; + default: + return "unknown"; + } +} + +// Find nearest supported baud rate for direct encoding +static uint32_t nearest_supported_baud(uint32_t baud) { + size_t n = sizeof(SUPPORTED_BAUD_RATES) / sizeof(SUPPORTED_BAUD_RATES[0]); + for (size_t i = 0; i < n; i++) { + if (SUPPORTED_BAUD_RATES[i] > baud) { + if (i == 0) + return SUPPORTED_BAUD_RATES[0]; + uint32_t lower = SUPPORTED_BAUD_RATES[i - 1]; + uint32_t upper = SUPPORTED_BAUD_RATES[i]; + return (upper - baud) > (baud - lower) ? lower : upper; + } + } + return SUPPORTED_BAUD_RATES[n - 1]; +} + +// Direct encoding: little-endian 32-bit baud rate value +static void encode_baud_direct(uint8_t buf[4], uint32_t baud) { + buf[0] = baud & 0xFF; + buf[1] = (baud >> 8) & 0xFF; + buf[2] = (baud >> 16) & 0xFF; + buf[3] = (baud >> 24) & 0xFF; +} + +// Divisor encoding for TYPE_HX, TYPE_HXD: baudrate = 12M*32 / (mantissa * 4^exponent) +static void encode_baud_divisor(uint8_t buf[4], uint32_t baud) { + static constexpr uint32_t BASELINE = 12000000 * 32; + uint32_t mantissa = BASELINE / baud; + if (mantissa == 0) + mantissa = 1; + uint8_t exponent = 0; + while (mantissa >= 512) { + if (exponent < 7) { + mantissa >>= 2; + exponent++; + } else { + mantissa = 511; + break; + } + } + buf[3] = 0x80; + buf[2] = 0; + buf[1] = (exponent << 1) | (mantissa >> 8); + buf[0] = mantissa & 0xFF; +} + +// Alt divisor encoding for TYPE_TA, TYPE_TB: baudrate = 12M*32 / (mantissa * 2^exponent) +static void encode_baud_divisor_alt(uint8_t buf[4], uint32_t baud) { + static constexpr uint32_t BASELINE = 12000000 * 32; + uint32_t mantissa = BASELINE / baud; + if (mantissa == 0) + mantissa = 1; + uint8_t exponent = 0; + while (mantissa >= 2048) { + if (exponent < 15) { + mantissa >>= 1; + exponent++; + } else { + mantissa = 2047; + break; + } + } + buf[3] = 0x80; + buf[2] = exponent & 0x01; + buf[1] = ((exponent & ~0x01) << 4) | (mantissa >> 8); + buf[0] = mantissa & 0xFF; +} + +std::vector USBUartTypePL2303::parse_descriptors(usb_device_handle_t dev_hdl) { + const usb_config_desc_t *config_desc; + const usb_device_desc_t *device_desc; + std::vector cdc_devs{}; + + if (usb_host_get_device_descriptor(dev_hdl, &device_desc) != ESP_OK) { + ESP_LOGE(TAG, "PL2303: get_device_descriptor failed"); + return {}; + } + if (usb_host_get_active_config_descriptor(dev_hdl, &config_desc) != ESP_OK) { + ESP_LOGE(TAG, "PL2303: get_active_config_descriptor failed"); + return {}; + } + + // Detect chip type from USB descriptor fields (mirrors pl2303_detect_type in Linux driver) + uint16_t bcd_device = device_desc->bcdDevice; + uint16_t bcd_usb = device_desc->bcdUSB; + uint8_t bmax_packet = device_desc->bMaxPacketSize0; + uint8_t bdev_class = device_desc->bDeviceClass; + + if (bdev_class == 0x02 || bmax_packet != 0x40) { + this->chip_type_ = PL2303_TYPE_H; + } else { + switch (bcd_usb) { + case 0x0101: + case 0x0110: + this->chip_type_ = (bcd_device == 0x0400) ? PL2303_TYPE_HXD : PL2303_TYPE_HX; + break; + default: + // TA and TB are distinguishable by bcdDevice without any USB probe. + if (bcd_device == 0x0300) { + this->chip_type_ = PL2303_TYPE_TA; + } else if (bcd_device == 0x0500) { + this->chip_type_ = PL2303_TYPE_TB; + } else { + this->chip_type_ = PL2303_TYPE_HXN; + } + break; + } + } + + ESP_LOGI(TAG, "PL2303 chip type: %s (bcdUSB=0x%04X bcdDevice=0x%04X bMaxPkt=%u)", pl2303_type_name(this->chip_type_), + bcd_usb, bcd_device, bmax_packet); + + // PL2303 is single-port: find first interface with 2 bulk endpoints + int conf_offset = 0; + for (uint8_t i = 0; i < config_desc->bNumInterfaces; i++) { + int ep_offset = conf_offset; + const auto *intf = usb_parse_interface_descriptor(config_desc, i, 0, &conf_offset); + if (!intf) + break; + if (intf->bNumEndpoints < 2) + continue; + + const usb_ep_desc_t *in_ep = nullptr; + const usb_ep_desc_t *out_ep = nullptr; + const usb_ep_desc_t *notify_ep = nullptr; + + for (uint8_t e = 0; e < intf->bNumEndpoints; e++) { + ep_offset = conf_offset; + const auto *ep = usb_parse_endpoint_descriptor_by_index(intf, e, config_desc->wTotalLength, &ep_offset); + if (!ep) + break; + if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_BULK) { + if (ep->bEndpointAddress & usb_host::USB_DIR_IN) { + in_ep = ep; + } else { + out_ep = ep; + } + } else if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_INT) { + notify_ep = ep; + } + } + + if (in_ep && out_ep) { + cdc_devs.push_back(CdcEps{notify_ep, in_ep, out_ep, intf->bInterfaceNumber, intf->bInterfaceNumber}); + break; // PL2303 is single-port + } + } + + if (cdc_devs.empty()) + ESP_LOGE(TAG, "PL2303: failed to find bulk IN+OUT endpoints"); + + return cdc_devs; +} + +void USBUartTypePL2303::enable_channels() { + if (this->channels_.empty()) + return; + + auto *channel = this->channels_[0]; + bool is_legacy = (this->chip_type_ == PL2303_TYPE_H); + bool is_hxn = (this->chip_type_ == PL2303_TYPE_HXN); + + usb_host::transfer_cb_t nop_cb = [](const usb_host::TransferStatus &status) { + if (!status.success) + ESP_LOGW(TAG, "PL2303: vendor init transfer failed"); + }; + + // Init sequence for non-HXN chips (mirrors pl2303_startup in Linux driver): + // Read 0x8484, write 0x0404=0, read 0x8484, read 0x8383, read 0x8484, + // write 0x0404=1, read 0x8484, read 0x8383, + // write 0=1, write 1=0, write 2=0x24 (legacy) or 0x44 (HX+) + if (!is_hxn) { + uint8_t req = VENDOR_READ_REQUEST; + uint8_t wreq = VENDOR_WRITE_REQUEST; + + // Fire-and-forget vendor reads: result discarded, chip requires this sequence. + // Pass a 1-byte buffer to set wLength=1 so the IN data stage is performed. + this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0}); + this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 0x0404, 0, nop_cb); + this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0}); + this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8383, 0, nop_cb, {0}); + this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0}); + this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 0x0404, 1, nop_cb); + this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0}); + this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8383, 0, nop_cb, {0}); + this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 0, 1, nop_cb); + this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 1, 0, nop_cb); + this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 2, is_legacy ? 0x24 : 0x44, nop_cb); + } + + // Build 7-byte line coding structure: + // [0-3] baud rate (LE32), [4] stop bits, [5] parity, [6] data bits + uint8_t line_coding[7] = {}; + uint32_t baud = channel->get_baud_rate(); + + // Choose baud encoding based on chip type + uint32_t nearest = nearest_supported_baud(baud); + if (baud == nearest || this->chip_type_ == PL2303_TYPE_HXN) { + encode_baud_direct(line_coding, baud); + } else if (this->chip_type_ == PL2303_TYPE_TA || this->chip_type_ == PL2303_TYPE_TB) { + encode_baud_divisor_alt(line_coding, baud); + } else { + encode_baud_divisor(line_coding, baud); + } + + // Stop bits: 0=1, 1=1.5, 2=2 + switch (channel->get_stop_bits()) { + case 2: + line_coding[4] = 2; + break; + default: + line_coding[4] = 0; + break; + } + + // Parity: 0=none, 1=odd, 2=even, 3=mark, 4=space + switch (channel->parity_) { + case UART_CONFIG_PARITY_ODD: + line_coding[5] = 1; + break; + case UART_CONFIG_PARITY_EVEN: + line_coding[5] = 2; + break; + case UART_CONFIG_PARITY_MARK: + line_coding[5] = 3; + break; + case UART_CONFIG_PARITY_SPACE: + line_coding[5] = 4; + break; + default: + line_coding[5] = 0; + break; + } + + // Data bits + line_coding[6] = channel->get_data_bits(); + + ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%u stop=%u parity=%u data=%u", baud, line_coding[4], line_coding[5], + line_coding[6]); + + std::vector lc_vec(line_coding, line_coding + 7); + uint16_t iface = channel->cdc_dev_.bulk_interface_number; + this->control_transfer(SET_LINE_REQUEST_TYPE, SET_LINE_REQUEST, 0, iface, nop_cb, lc_vec); + + // Assert DTR + RTS + this->control_transfer(SET_CONTROL_REQUEST_TYPE, SET_CONTROL_REQUEST, CONTROL_DTR | CONTROL_RTS, iface, nop_cb); + + this->start_channels_(); +} + +} // namespace esphome::usb_uart +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 41dc2c546d..d0dccf42b9 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -16,6 +16,7 @@ namespace esphome::usb_uart { class USBUartTypeCdcAcm; class USBUartComponent; class USBUartChannel; +class USBUartTypePL2303; static const char *const TAG = "usb_uart"; @@ -130,6 +131,7 @@ class USBUartChannel : public uart::UARTComponent, public Parented parse_descriptors(usb_device_handle_t dev_hdl) override; + void enable_channels() override; + + Pl2303ChipType chip_type_{PL2303_TYPE_UNKNOWN}; +}; + } // namespace esphome::usb_uart #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/tests/components/usb_uart/common.yaml b/tests/components/usb_uart/common.yaml index c8c1ee7df2..5b23f9d685 100644 --- a/tests/components/usb_uart/common.yaml +++ b/tests/components/usb_uart/common.yaml @@ -52,3 +52,16 @@ usb_uart: stop_bits: 2 data_bits: 7 parity: odd + - id: uart_7 + type: pl2303 + channels: + - id: channel_7_1 + baud_rate: 115200 + - id: uart_8 + type: pl2303gc + channels: + - id: channel_8_1 + baud_rate: 9600 + stop_bits: 2 + data_bits: 7 + parity: even From 7533835e044a2148a68e60fd7fe85776037a7acf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:03:31 -0400 Subject: [PATCH 0364/1815] Bump py7zr from 0.22.0 to 1.1.0 (#16901) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ed7f2c2941..62ed506e36 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,7 @@ jinja2==3.1.6 bleak==2.1.1 smpclient==6.0.0 requests==2.34.2 -py7zr==0.22.0 +py7zr==1.1.0 # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 From eb6d6eac7dd1eaf274d733d236bb5cfafea5bdc6 Mon Sep 17 00:00:00 2001 From: Ricky Tsai <49546657+RT530@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:58:02 +1200 Subject: [PATCH 0365/1815] [xdb401] XDB401 Pressure Sensor (#15108) Co-authored-by: Ricky Tsai Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/xdb401/__init__.py | 1 + esphome/components/xdb401/sensor.py | 64 +++++++ esphome/components/xdb401/xdb401.cpp | 177 ++++++++++++++++++ esphome/components/xdb401/xdb401.h | 37 ++++ tests/components/xdb401/common.yaml | 10 + tests/components/xdb401/test.esp32-idf.yaml | 4 + tests/components/xdb401/test.esp8266-ard.yaml | 4 + 8 files changed, 298 insertions(+) create mode 100644 esphome/components/xdb401/__init__.py create mode 100644 esphome/components/xdb401/sensor.py create mode 100644 esphome/components/xdb401/xdb401.cpp create mode 100644 esphome/components/xdb401/xdb401.h create mode 100644 tests/components/xdb401/common.yaml create mode 100644 tests/components/xdb401/test.esp32-idf.yaml create mode 100644 tests/components/xdb401/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index c69f8bccd4..a64d6f3daf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -596,6 +596,7 @@ esphome/components/wk2212_spi/* @DrCoolZic esphome/components/wl_134/* @hobbypunk90 esphome/components/wts01/* @alepee esphome/components/x9c/* @EtienneMD +esphome/components/xdb401/* @RT530 esphome/components/xgzp68xx/* @gcormier esphome/components/xiaomi_hhccjcy10/* @fariouche esphome/components/xiaomi_lywsd02mmc/* @juanluss31 diff --git a/esphome/components/xdb401/__init__.py b/esphome/components/xdb401/__init__.py new file mode 100644 index 0000000000..943139e19a --- /dev/null +++ b/esphome/components/xdb401/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@RT530"] diff --git a/esphome/components/xdb401/sensor.py b/esphome/components/xdb401/sensor.py new file mode 100644 index 0000000000..7545343f02 --- /dev/null +++ b/esphome/components/xdb401/sensor.py @@ -0,0 +1,64 @@ +import esphome.codegen as cg +from esphome.components import i2c, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_PRESSURE, + CONF_TEMPERATURE, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_PASCAL, +) + +DEPENDENCIES = ["i2c"] + +CONF_PRESSURE_RANGE_BAR = "pressure_range_bar" + +xdb401_ns = cg.esphome_ns.namespace("xdb401") + +XDB401Component = xdb401_ns.class_( + "XDB401Component", cg.PollingComponent, i2c.I2CDevice +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(XDB401Component), + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=2, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PRESSURE): sensor.sensor_schema( + unit_of_measurement=UNIT_PASCAL, + accuracy_decimals=0, + device_class=DEVICE_CLASS_PRESSURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PRESSURE_RANGE_BAR, default=10): cv.one_of( + 1, 2, 5, 10, 20, 50, 100, int=True + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x7F)) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + cg.add(var.set_pressure_range_bar(config[CONF_PRESSURE_RANGE_BAR])) + + if temperature_config := config.get(CONF_TEMPERATURE): + sens = await sensor.new_sensor(temperature_config) + cg.add(var.set_temperature_sensor(sens)) + + if pressure_config := config.get(CONF_PRESSURE): + sens = await sensor.new_sensor(pressure_config) + cg.add(var.set_pressure_sensor(sens)) diff --git a/esphome/components/xdb401/xdb401.cpp b/esphome/components/xdb401/xdb401.cpp new file mode 100644 index 0000000000..3a24d63760 --- /dev/null +++ b/esphome/components/xdb401/xdb401.cpp @@ -0,0 +1,177 @@ +#include "esphome/core/log.h" +#include "esphome/core/helpers.h" +#include "xdb401.h" + +namespace esphome::xdb401 { + +static const char *const TAG = "xdb401"; + +static const uint8_t REG_PRESSURE = 0x06; +static const uint8_t REG_TEMPERATURE = 0x09; +static const uint8_t REG_MAKE_MEASURE = 0x30; +static const uint8_t CMD_MAKE_MEASURE = 0x0A; +static const uint8_t MASK_MEASURE_READY = 0x08; +static const float CONVERT_PRESSURE = 8388608.0f; // 0x800000 + +static const uint32_t CHECK_DELAY = 5; +static const uint8_t CHECK_ATTEMPTS = 6; +static const uint8_t MARK_FAIL_AFTER = 5; + +void XDB401Component::setup() { + ESP_LOGCONFIG(TAG, "Running setup"); + + uint8_t meas_resp[1] = {}; + i2c::ErrorCode err_code = this->read_register(REG_MAKE_MEASURE, meas_resp, sizeof(meas_resp)); + if (err_code != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("I2C communication failed")); + return; + } + + this->comm_err_counter_ = 0; +} + +void XDB401Component::dump_config() { + ESP_LOGCONFIG(TAG, "XDB401:"); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + ESP_LOGCONFIG(TAG, " Pressure Range: %u bar", this->pressure_range_bar_); + LOG_SENSOR(" ", "Pressure", this->pressure_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); +} + +void XDB401Component::handle_comm_failure_(const char *message) { + this->status_set_warning(message); + + if (this->comm_err_counter_ >= MARK_FAIL_AFTER) { + this->mark_failed(LOG_STR("Too many consecutive I2C communication errors")); + } else { + this->comm_err_counter_++; + } + + this->measurement_in_progress_ = false; +} + +i2c::ErrorCode XDB401Component::start_measurement_() { + i2c::ErrorCode err_code = this->write_register(REG_MAKE_MEASURE, &CMD_MAKE_MEASURE, sizeof(CMD_MAKE_MEASURE)); + if (err_code != i2c::ERROR_OK) { + ESP_LOGE(TAG, "Error starting measurement, code: %u", err_code); + return err_code; + } + + return i2c::ERROR_OK; +} + +void XDB401Component::check_measurement_ready_(uint8_t attempt) { + uint8_t meas_resp[1] = {}; + i2c::ErrorCode err_code = this->read_register(REG_MAKE_MEASURE, meas_resp, sizeof(meas_resp)); + if (err_code != i2c::ERROR_OK) { + ESP_LOGE(TAG, "Error reading measurement status, code: %u", err_code); + this->handle_comm_failure_("I2C communication failed"); + return; + } + + ESP_LOGV(TAG, "Config response %02X", meas_resp[0]); + + // Bit 3 shall be 0 when measurement is ready + if ((meas_resp[0] & MASK_MEASURE_READY) == 0) { + ESP_LOGV(TAG, "Meas mode entered after %u ms", attempt * CHECK_DELAY); + this->read_measurement_(); + return; + } + + if (attempt >= CHECK_ATTEMPTS) { + ESP_LOGE(TAG, "Device not in measurement mode after timeout of %u ms", CHECK_DELAY * CHECK_ATTEMPTS); + this->handle_comm_failure_("Measurement timeout"); + return; + } + + this->set_timeout(CHECK_DELAY, [this, attempt]() { this->check_measurement_ready_(attempt + 1); }); +} + +void XDB401Component::read_measurement_() { + float temperature{}; + float pressure{}; + + i2c::ErrorCode err_code = this->read_pressure_(pressure); + if (err_code != i2c::ERROR_OK) { + this->handle_comm_failure_("Could not read pressure data"); + return; + } + + err_code = this->read_temperature_(temperature); + if (err_code != i2c::ERROR_OK) { + this->handle_comm_failure_("Could not read temperature data"); + return; + } + + ESP_LOGD(TAG, "Got pressure=%.1f Pa, temperature=%.2f°C", pressure, temperature); + + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(temperature); + if (this->pressure_sensor_ != nullptr) + this->pressure_sensor_->publish_state(pressure); + + this->comm_err_counter_ = 0; + this->status_clear_warning(); + this->measurement_in_progress_ = false; +} + +i2c::ErrorCode XDB401Component::read_pressure_(float &pressure) { + uint8_t p_data[3]{}; + i2c::ErrorCode err_code = this->read_register(REG_PRESSURE, p_data, 3); + if (err_code != i2c::ERROR_OK) { + ESP_LOGE(TAG, "Error reading pressure register"); + return err_code; + } + char pressure_buf[format_hex_pretty_size(3)]; + format_hex_pretty_to(pressure_buf, sizeof(pressure_buf), p_data, 3); + ESP_LOGV(TAG, "Got pressure data: %s", pressure_buf); + + // Sign-extend 24-bit big-endian pressure value to int32_t. + int32_t raw_pressure = static_cast(encode_uint24(p_data[0], p_data[1], p_data[2]) << 8) >> 8; + ESP_LOGD(TAG, "Pressure data raw %i", raw_pressure); + + pressure = (static_cast(raw_pressure) / CONVERT_PRESSURE) * + XDB401Component::full_scale_pressure_pa(this->pressure_range_bar_); + + return err_code; +} + +i2c::ErrorCode XDB401Component::read_temperature_(float &temperature) { + uint8_t t_data[2]{}; + i2c::ErrorCode err_code = this->read_register(REG_TEMPERATURE, t_data, 2); + if (err_code != i2c::ERROR_OK) { + ESP_LOGE(TAG, "Error reading temperature register"); + return err_code; + } + + char temperature_buf[format_hex_pretty_size(2)]; + format_hex_pretty_to(temperature_buf, sizeof(temperature_buf), t_data, 2); + ESP_LOGV(TAG, "Got temperature data: %s", temperature_buf); + + // Temperature is a signed 16-bit big-endian value in 1/256 °C (Q8.8 fixed point). + int16_t raw_temperature = static_cast(encode_uint16(t_data[0], t_data[1])); + ESP_LOGD(TAG, "Temperature data raw %i", raw_temperature); + + temperature = static_cast(raw_temperature) / 256.0f; + + return err_code; +} + +void XDB401Component::update() { + if (this->measurement_in_progress_) { + ESP_LOGV(TAG, "Skipping update, measurement already in progress"); + return; + } + + i2c::ErrorCode err_code = this->start_measurement_(); + if (err_code != i2c::ERROR_OK) { + this->handle_comm_failure_("I2C communication failed"); + return; + } + + this->measurement_in_progress_ = true; + this->set_timeout(CHECK_DELAY, [this]() { this->check_measurement_ready_(1); }); +} + +} // namespace esphome::xdb401 diff --git a/esphome/components/xdb401/xdb401.h b/esphome/components/xdb401/xdb401.h new file mode 100644 index 0000000000..674d26fe8e --- /dev/null +++ b/esphome/components/xdb401/xdb401.h @@ -0,0 +1,37 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" + +namespace esphome::xdb401 { + +class XDB401Component : public PollingComponent, public i2c::I2CDevice { + public: + void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } + void set_pressure_sensor(sensor::Sensor *pressure_sensor) { this->pressure_sensor_ = pressure_sensor; } + void set_pressure_range_bar(uint8_t pressure_range_bar) { this->pressure_range_bar_ = pressure_range_bar; } + + void setup() override; + void dump_config() override; + void update() override; + + protected: + void handle_comm_failure_(const char *message); + i2c::ErrorCode start_measurement_(); + void check_measurement_ready_(uint8_t attempt); + void read_measurement_(); + i2c::ErrorCode read_pressure_(float &pressure); + i2c::ErrorCode read_temperature_(float &temperature); + + static constexpr float full_scale_pressure_pa(uint8_t pressure_range_bar) { return pressure_range_bar * 100000.0f; } + + uint8_t comm_err_counter_{0}; + bool measurement_in_progress_{false}; + uint8_t pressure_range_bar_{10}; + + sensor::Sensor *temperature_sensor_{nullptr}; + sensor::Sensor *pressure_sensor_{nullptr}; +}; + +} // namespace esphome::xdb401 diff --git a/tests/components/xdb401/common.yaml b/tests/components/xdb401/common.yaml new file mode 100644 index 0000000000..feaa46010e --- /dev/null +++ b/tests/components/xdb401/common.yaml @@ -0,0 +1,10 @@ +sensor: + - platform: xdb401 + update_interval: 1s + i2c_id: i2c_bus + + temperature: + name: water temperature + + pressure: + name: water pressure diff --git a/tests/components/xdb401/test.esp32-idf.yaml b/tests/components/xdb401/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/xdb401/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/xdb401/test.esp8266-ard.yaml b/tests/components/xdb401/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4a98b9388a --- /dev/null +++ b/tests/components/xdb401/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml From 4f62bb7171fb30762c3c941ea66e89024ca4a714 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:04:28 +1000 Subject: [PATCH 0366/1815] [bmi270] Support Bosch BMI270 IMU (#16202) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/bmi270/__init__.py | 10 + esphome/components/bmi270/bmi270.cpp | 209 +++++++++ esphome/components/bmi270/bmi270.h | 108 +++++ esphome/components/bmi270/bmi270_config.h | 483 ++++++++++++++++++++ esphome/components/bmi270/motion.py | 91 ++++ esphome/components/bmi270/sensor.py | 41 ++ esphome/components/const/__init__.py | 4 + tests/components/bmi270/common.yaml | 68 +++ tests/components/bmi270/test.esp32-idf.yaml | 4 + 10 files changed, 1019 insertions(+) create mode 100644 esphome/components/bmi270/__init__.py create mode 100644 esphome/components/bmi270/bmi270.cpp create mode 100644 esphome/components/bmi270/bmi270.h create mode 100644 esphome/components/bmi270/bmi270_config.h create mode 100644 esphome/components/bmi270/motion.py create mode 100644 esphome/components/bmi270/sensor.py create mode 100644 tests/components/bmi270/common.yaml create mode 100644 tests/components/bmi270/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index a64d6f3daf..300ae13cf4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -83,6 +83,7 @@ esphome/components/bme680_bsec/* @trvrnrth esphome/components/bme68x_bsec2/* @kbx81 @neffs esphome/components/bme68x_bsec2_i2c/* @kbx81 @neffs esphome/components/bmi160/* @flaviut +esphome/components/bmi270/* @clydebarrow esphome/components/bmp280_base/* @ademuri esphome/components/bmp280_i2c/* @ademuri esphome/components/bmp280_spi/* @ademuri diff --git a/esphome/components/bmi270/__init__.py b/esphome/components/bmi270/__init__.py new file mode 100644 index 0000000000..0e67e41a0e --- /dev/null +++ b/esphome/components/bmi270/__init__.py @@ -0,0 +1,10 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.motion import MotionComponent + +CODEOWNERS = ["@clydebarrow"] + +CONF_BMI270_ID = "bmi270_id" +# C++ namespace / class +bmi270_ns = cg.esphome_ns.namespace("bmi270") +BMI270Component = bmi270_ns.class_("BMI270Component", MotionComponent, i2c.I2CDevice) diff --git a/esphome/components/bmi270/bmi270.cpp b/esphome/components/bmi270/bmi270.cpp new file mode 100644 index 0000000000..acb93158d4 --- /dev/null +++ b/esphome/components/bmi270/bmi270.cpp @@ -0,0 +1,209 @@ +#include "bmi270.h" +#include "bmi270_config.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" + +namespace esphome::bmi270 { + +static const char *const TAG = "bmi270"; + +#if defined(USE_ARDUINO) && !defined(USE_ESP32) +static const size_t MAX_I2C_BUFFER_SIZE = 32; +#else +static const size_t MAX_I2C_BUFFER_SIZE = 256; +#endif + +// Configuration blob upload +// The BMI270 requires a firmware config blob to be written to its internal +// memory after every power-on before sensors can be used. + +bool BMI270Component::load_config_file_() { + // 1. Disable advanced power-save so the config port is accessible + if (!this->write_byte(BMI270_REG_PWR_CONF, 0x00)) + return false; + delay(1); + + // 2. Prepare config load: write 0x00 to INIT_CTRL to start + if (!this->write_byte(BMI270_REG_INIT_CTRL, 0x00)) + return false; + + // 3. Burst-write the config in pages + const uint8_t *cfg = BMI270_CONFIG_FILE; + constexpr size_t cfg_len = sizeof(BMI270_CONFIG_FILE); + size_t index = 0; + + while (index != cfg_len) { + // Set the page address in INIT_ADDR registers + uint8_t addr_lsb = (uint8_t) ((index / 2) & 0x0F); + uint8_t addr_msb = (uint8_t) ((index / 2) >> 4); + if (!this->write_byte(BMI270_REG_INIT_ADDR_0, addr_lsb)) + return false; + if (!this->write_byte(BMI270_REG_INIT_ADDR_0 + 1, addr_msb)) + return false; + + // Write a burst of up to the maximum allowed size + size_t burst = clamp_at_most(cfg_len - index, MAX_I2C_BUFFER_SIZE); + if (this->write_register(BMI270_REG_INIT_DATA, cfg + index, burst) != i2c::ERROR_OK) + return false; + + index += burst; + } + + // 4. Signal end of config load + if (!this->write_byte(BMI270_REG_INIT_CTRL, 0x01)) + return false; + delay(20); // spec: wait ≥20 ms for init to complete + + // 5. Check INTERNAL_STATUS: bit[0:3] should be 0x01 ("initialisation OK") + uint8_t status = 0; + if (!this->read_byte(BMI270_REG_INTERNAL_STATUS, &status)) + return false; + if ((status & 0x0F) != 0x01) { + ESP_LOGE(TAG, "Config load failed: INTERNAL_STATUS=0x%02X (expected 0x01)", status); + return false; + } + return true; +} + +// setup() ─ + +void BMI270Component::setup() { + MotionComponent::setup(); + // 1. Verify chip ID + uint8_t chip_id = 0; + if (!this->read_byte(BMI270_REG_CHIP_ID, &chip_id)) { + ESP_LOGE(TAG, "Failed to read chip ID – check wiring / address"); + this->mark_failed(); + return; + } + if (chip_id != BMI270_CHIP_ID_VALUE) { + ESP_LOGE(TAG, "Wrong chip ID: 0x%02X (expected 0x%02X)", chip_id, BMI270_CHIP_ID_VALUE); + this->mark_failed(); + return; + } + ESP_LOGD(TAG, "Chip ID: 0x%02X", chip_id); + + // 2. Soft-reset via CMD register (0x7E = 0xB6) + if (!this->write_byte(0x7E, 0xB6)) { + this->mark_failed(); + return; + } + delay(20); + + // 4. Upload the configuration blob + if (!load_config_file_()) { + ESP_LOGE(TAG, "Config file upload failed"); + this->mark_failed(); + return; + } + ESP_LOGD(TAG, "Config blob uploaded ✓"); + + // 5. Configure accelerometer + // ACC_CONF: ODR | BWP(0x2 = normal avg4) | perf_mode(1) + uint8_t acc_conf = (uint8_t) (accel_odr_) | (0x2 << 4) | (1 << 7); + if (!this->write_byte(BMI270_REG_ACC_CONF, acc_conf)) { + this->mark_failed(); + return; + } + if (!this->write_byte(BMI270_REG_ACC_RANGE, (uint8_t) accel_range_)) { + this->mark_failed(); + return; + } + + // 6. Configure gyroscope + // GYR_CONF: ODR | BWP(0x2 = normal) | noise_perf(1) | filter_perf(1) + uint8_t gyr_conf = (uint8_t) (gyro_odr_) | (0x2 << 4) | (1 << 6) | (1 << 7); + if (!this->write_byte(BMI270_REG_GYR_CONF, gyr_conf)) { + this->mark_failed(); + return; + } + if (!this->write_byte(BMI270_REG_GYR_RANGE, (uint8_t) gyro_range_)) { + this->mark_failed(); + return; + } + + // 7. Enable accelerometer, gyroscope, and temperature sensor + // PWR_CTRL bits: temp_en[3] | gyr_en[2] | acc_en[1] + if (!this->write_byte(BMI270_REG_PWR_CTRL, 0x0E)) { + this->mark_failed(); + return; + } + delay(5); + + // 8. Re-enable advanced power save (optional; keeps current low between reads) + // Disabled here for simplicity – leave in performance mode + if (!this->write_byte(BMI270_REG_PWR_CONF, 0x02)) { // bit1 = fifo_self_wakeup + this->mark_failed(); + return; + } + + ESP_LOGCONFIG(TAG, "BMI270 initialised successfully"); +} + +void BMI270Component::dump_config() { + ESP_LOGCONFIG(TAG, "BMI270 IMU:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, " Communication failed!"); + return; + } + + static constexpr const char *const ACCEL_RANGE_STRS[] = {"±2g", "±4g", "±8g", "±16g"}; + static constexpr const char *const GYRO_RANGE_STRS[] = {"±2000°/s", "±1000°/s", "±500°/s", "±250°/s", "±125°/s"}; + + ESP_LOGCONFIG(TAG, " Accel range : %s", ACCEL_RANGE_STRS[accel_range_]); + ESP_LOGCONFIG(TAG, " Gyro range : %s", GYRO_RANGE_STRS[gyro_range_]); + MotionComponent::dump_config(); +} + +// update() ─ +// Reads all 6 axes + temperature in one block + +bool BMI270Component::update_data(motion::MotionData &data) { + if (this->is_failed()) + return false; + + // Accelerometer: registers 0x0C–0x11 (6 bytes: x_lsb, x_msb, y_lsb, y_msb, z_lsb, z_msb) + uint8_t raw_data[REG_READ_LEN]; + if (!this->read_bytes(BMI270_REG_DATA_8, raw_data, REG_READ_LEN)) { + ESP_LOGW(TAG, "Failed to read IMU data"); + return false; + } + // Scale factor: LSB/g depends on range + // raw is a signed 16-bit value; full-scale = range_g * 2^15 lsb + static constexpr float ACCEL_SCALE[] = { + 2.0f / 32768.0f, + 4.0f / 32768.0f, + 8.0f / 32768.0f, + 16.0f / 32768.0f, + }; + float scale = ACCEL_SCALE[this->accel_range_]; + + data.acceleration[motion::X_AXIS] = (int16_t) ((raw_data[1] << 8) | raw_data[0]) * scale; + data.acceleration[motion::Y_AXIS] = (int16_t) ((raw_data[3] << 8) | raw_data[2]) * scale; + data.acceleration[motion::Z_AXIS] = (int16_t) ((raw_data[5] << 8) | raw_data[4]) * scale; + + // Gyroscope: registers 0x12–0x17 (6 bytes) + // Scale: full-scale range / 2^15 + static constexpr float GYRO_SCALE[] = { + 2000.0f / 32768.0f, 1000.0f / 32768.0f, 500.0f / 32768.0f, 250.0f / 32768.0f, 125.0f / 32768.0f, + }; + static constexpr uint8_t GYR_OFFS = BMI270_REG_DATA_14 - BMI270_REG_DATA_8; + scale = GYRO_SCALE[this->gyro_range_]; + + data.angular_rate[motion::X_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 1] << 8) | raw_data[GYR_OFFS + 0]) * scale; + data.angular_rate[motion::Y_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 3] << 8) | raw_data[GYR_OFFS + 2]) * scale; + data.angular_rate[motion::Z_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 5] << 8) | raw_data[GYR_OFFS + 4]) * scale; + + if (this->temperature_callback_.empty()) + return true; + // Temperature: registers 0x22–0x23 + // Formula from datasheet: T[°C] = raw / 512 + 23 + static constexpr uint8_t TEMP_OFFS = BMI270_REG_TEMP_0 - BMI270_REG_DATA_8; + int16_t raw_t = (int16_t) ((raw_data[TEMP_OFFS + 1] << 8) | raw_data[TEMP_OFFS + 0]); + float temperature = (raw_t / 512.0f) + 23.0f; + this->temperature_callback_.call(temperature); + return true; +} + +} // namespace esphome::bmi270 diff --git a/esphome/components/bmi270/bmi270.h b/esphome/components/bmi270/bmi270.h new file mode 100644 index 0000000000..7c5a2db015 --- /dev/null +++ b/esphome/components/bmi270/bmi270.h @@ -0,0 +1,108 @@ +#pragma once + +#include "esphome/components/motion/motion_component.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/components/i2c/i2c.h" +#include + +namespace esphome::bmi270 { + +// Register map +static const uint8_t BMI270_REG_CHIP_ID = 0x00; +static const uint8_t BMI270_REG_ERR_REG = 0x02; +static const uint8_t BMI270_REG_STATUS = 0x03; +static const uint8_t BMI270_REG_DATA_8 = 0x0C; // ACC_X LSB +static const uint8_t BMI270_REG_DATA_14 = 0x12; // GYR_X LSB +static const uint8_t BMI270_REG_TEMP_0 = 0x22; +static const uint8_t BMI270_REG_TEMP_MSB = 0x23; // temperature (2 bytes big-endian ish) + +static constexpr uint8_t REG_READ_LEN = + BMI270_REG_TEMP_MSB - BMI270_REG_DATA_8 + + 1; // 0x23 - 0x0C + 1 = 0x18 bytes total for accel(6) + gyro(6) + temp(2) + padding(4) + +static const uint8_t BMI270_REG_PWR_CONF = 0x7C; +static const uint8_t BMI270_REG_PWR_CTRL = 0x7D; +static const uint8_t BMI270_REG_INIT_CTRL = 0x59; +static const uint8_t BMI270_REG_INIT_DATA = 0x5E; +static const uint8_t BMI270_REG_INIT_ADDR_0 = 0x5B; +static const uint8_t BMI270_REG_INTERNAL_STATUS = 0x21; +static const uint8_t BMI270_REG_ACC_CONF = 0x40; +static const uint8_t BMI270_REG_ACC_RANGE = 0x41; +static const uint8_t BMI270_REG_GYR_CONF = 0x42; +static const uint8_t BMI270_REG_GYR_RANGE = 0x43; + +static const uint8_t BMI270_CHIP_ID_VALUE = 0x24; + +// Accelerometer range options +enum BMI270AccelRange : uint8_t { + BMI270_ACCEL_RANGE_2G = 0x00, + BMI270_ACCEL_RANGE_4G = 0x01, + BMI270_ACCEL_RANGE_8G = 0x02, + BMI270_ACCEL_RANGE_16G = 0x03, +}; + +// Accelerometer ODR options +enum BMI270AccelODR : uint8_t { + BMI270_ACCEL_ODR_12_5 = 0x05, + BMI270_ACCEL_ODR_25 = 0x06, + BMI270_ACCEL_ODR_50 = 0x07, + BMI270_ACCEL_ODR_100 = 0x08, + BMI270_ACCEL_ODR_200 = 0x09, + BMI270_ACCEL_ODR_400 = 0x0A, + BMI270_ACCEL_ODR_800 = 0x0B, + BMI270_ACCEL_ODR_1600 = 0x0C, +}; + +// Gyroscope range options +enum BMI270GyroRange : uint8_t { + BMI270_GYRO_RANGE_2000 = 0x00, + BMI270_GYRO_RANGE_1000 = 0x01, + BMI270_GYRO_RANGE_500 = 0x02, + BMI270_GYRO_RANGE_250 = 0x03, + BMI270_GYRO_RANGE_125 = 0x04, +}; + +// Gyroscope ODR options +enum BMI270GyroODR : uint8_t { + BMI270_GYRO_ODR_25 = 0x06, + BMI270_GYRO_ODR_50 = 0x07, + BMI270_GYRO_ODR_100 = 0x08, + BMI270_GYRO_ODR_200 = 0x09, + BMI270_GYRO_ODR_400 = 0x0A, + BMI270_GYRO_ODR_800 = 0x0B, + BMI270_GYRO_ODR_1600 = 0x0C, + BMI270_GYRO_ODR_3200 = 0x0D, +}; + +// ---Data class + +// Main component class +class BMI270Component : public motion::MotionComponent, public i2c::I2CDevice { + public: + // Lifecycle + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::DATA; } + + // Configuration setters + void set_accel_range(BMI270AccelRange r) { this->accel_range_ = r; } + void set_accel_odr(BMI270AccelODR o) { this->accel_odr_ = o; } + void set_gyro_range(BMI270GyroRange r) { this->gyro_range_ = r; } + void set_gyro_odr(BMI270GyroODR o) { this->gyro_odr_ = o; } + template void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward(cb)); } + + protected: + bool update_data(motion::MotionData &data) override; + bool load_config_file_(); + + // Config + BMI270AccelRange accel_range_{BMI270_ACCEL_RANGE_4G}; + BMI270AccelODR accel_odr_{BMI270_ACCEL_ODR_100}; + BMI270GyroRange gyro_range_{BMI270_GYRO_RANGE_2000}; + BMI270GyroODR gyro_odr_{BMI270_GYRO_ODR_200}; + + LazyCallbackManager temperature_callback_{}; +}; + +} // namespace esphome::bmi270 diff --git a/esphome/components/bmi270/bmi270_config.h b/esphome/components/bmi270/bmi270_config.h new file mode 100644 index 0000000000..4243f4e157 --- /dev/null +++ b/esphome/components/bmi270/bmi270_config.h @@ -0,0 +1,483 @@ +#pragma once +#include + +namespace esphome::bmi270 { + +/** + BMI270 configuration file (chip ID 0x24, firmware v2.86.1) + Source: Bosch Sensortec BMI270_SensorAPI (BSD-3-Clause) + https://github.com/boschsensortec/BMI270_SensorAPI + +Copyright (c) 2023 Bosch Sensortec GmbH. All rights reserved. + +BSD-3-Clause + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING +IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + + + This blob MUST be written to the chip's internal INIT_DATA register + after every power cycle, before any sensor data can be read. + --------------------------------------------------------------------------- */ + +static constexpr uint8_t BMI270_CONFIG_FILE[] = { + 0xc8, 0x2e, 0x00, 0x2e, 0x80, 0x2e, 0x3d, 0xb1, 0xc8, 0x2e, 0x00, 0x2e, 0x80, 0x2e, 0x91, 0x03, 0x80, 0x2e, 0xbc, + 0xb0, 0x80, 0x2e, 0xa3, 0x03, 0xc8, 0x2e, 0x00, 0x2e, 0x80, 0x2e, 0x00, 0xb0, 0x50, 0x30, 0x21, 0x2e, 0x59, 0xf5, + 0x10, 0x30, 0x21, 0x2e, 0x6a, 0xf5, 0x80, 0x2e, 0x3b, 0x03, 0x00, 0x00, 0x00, 0x00, 0x08, 0x19, 0x01, 0x00, 0x22, + 0x00, 0x75, 0x00, 0x00, 0x10, 0x00, 0x10, 0xd1, 0x00, 0xb3, 0x43, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, + 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, + 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, + 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, + 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, + 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, + 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, + 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, + 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, + 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0xe0, 0x5f, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x92, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x19, 0x00, 0x00, 0x88, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, + 0xe0, 0xaa, 0x38, 0x05, 0xe0, 0x90, 0x30, 0xfa, 0x00, 0x96, 0x00, 0x4b, 0x09, 0x11, 0x00, 0x11, 0x00, 0x02, 0x00, + 0x2d, 0x01, 0xd4, 0x7b, 0x3b, 0x01, 0xdb, 0x7a, 0x04, 0x00, 0x3f, 0x7b, 0xcd, 0x6c, 0xc3, 0x04, 0x85, 0x09, 0xc3, + 0x04, 0xec, 0xe6, 0x0c, 0x46, 0x01, 0x00, 0x27, 0x00, 0x19, 0x00, 0x96, 0x00, 0xa0, 0x00, 0x01, 0x00, 0x0c, 0x00, + 0xf0, 0x3c, 0x00, 0x01, 0x01, 0x00, 0x03, 0x00, 0x01, 0x00, 0x0e, 0x00, 0x00, 0x00, 0x32, 0x00, 0x05, 0x00, 0xee, + 0x06, 0x04, 0x00, 0xc8, 0x00, 0x00, 0x00, 0x04, 0x00, 0xa8, 0x05, 0xee, 0x06, 0x00, 0x04, 0xbc, 0x02, 0xb3, 0x00, + 0x85, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0xb4, 0x00, 0x01, 0x00, 0xb9, 0x00, 0x01, 0x00, 0x98, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x80, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x2e, 0x00, 0xc1, 0xfd, 0x2d, 0xde, + 0x00, 0xeb, 0x00, 0xda, 0x00, 0x00, 0x0c, 0xff, 0x0f, 0x00, 0x04, 0xc0, 0x00, 0x5b, 0xf5, 0xc9, 0x01, 0x1e, 0xf2, + 0x80, 0x00, 0x3f, 0xff, 0x19, 0xf4, 0x58, 0xf5, 0x66, 0xf5, 0x64, 0xf5, 0xc0, 0xf1, 0xf0, 0x00, 0xe0, 0x00, 0xcd, + 0x01, 0xd3, 0x01, 0xdb, 0x01, 0xff, 0x7f, 0xff, 0x01, 0xe4, 0x00, 0x74, 0xf7, 0xf3, 0x00, 0xfa, 0x00, 0xff, 0x3f, + 0xca, 0x03, 0x6c, 0x38, 0x56, 0xfe, 0x44, 0xfd, 0xbc, 0x02, 0xf9, 0x06, 0x00, 0xfc, 0x12, 0x02, 0xae, 0x01, 0x58, + 0xfa, 0x9a, 0xfd, 0x77, 0x05, 0xbb, 0x02, 0x96, 0x01, 0x95, 0x01, 0x7f, 0x01, 0x82, 0x01, 0x89, 0x01, 0x87, 0x01, + 0x88, 0x01, 0x8a, 0x01, 0x8c, 0x01, 0x8f, 0x01, 0x8d, 0x01, 0x92, 0x01, 0x91, 0x01, 0xdd, 0x00, 0x9f, 0x01, 0x7e, + 0x01, 0xdb, 0x00, 0xb6, 0x01, 0x70, 0x69, 0x26, 0xd3, 0x9c, 0x07, 0x1f, 0x05, 0x9d, 0x00, 0x00, 0x08, 0xbc, 0x05, + 0x37, 0xfa, 0xa2, 0x01, 0xaa, 0x01, 0xa1, 0x01, 0xa8, 0x01, 0xa0, 0x01, 0xa8, 0x05, 0xb4, 0x01, 0xb4, 0x01, 0xce, + 0x00, 0xd0, 0x00, 0xfc, 0x00, 0xc5, 0x01, 0xff, 0xfb, 0xb1, 0x00, 0x00, 0x38, 0x00, 0x30, 0xfd, 0xf5, 0xfc, 0xf5, + 0xcd, 0x01, 0xa0, 0x00, 0x5f, 0xff, 0x00, 0x40, 0xff, 0x00, 0x00, 0x80, 0x6d, 0x0f, 0xeb, 0x00, 0x7f, 0xff, 0xc2, + 0xf5, 0x68, 0xf7, 0xb3, 0xf1, 0x67, 0x0f, 0x5b, 0x0f, 0x61, 0x0f, 0x80, 0x0f, 0x58, 0xf7, 0x5b, 0xf7, 0x83, 0x0f, + 0x86, 0x00, 0x72, 0x0f, 0x85, 0x0f, 0xc6, 0xf1, 0x7f, 0x0f, 0x6c, 0xf7, 0x00, 0xe0, 0x00, 0xff, 0xd1, 0xf5, 0x87, + 0x0f, 0x8a, 0x0f, 0xff, 0x03, 0xf0, 0x3f, 0x8b, 0x00, 0x8e, 0x00, 0x90, 0x00, 0xb9, 0x00, 0x2d, 0xf5, 0xca, 0xf5, + 0xcb, 0x01, 0x20, 0xf2, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x50, 0x98, 0x2e, + 0xd7, 0x0e, 0x50, 0x32, 0x98, 0x2e, 0xfa, 0x03, 0x00, 0x30, 0xf0, 0x7f, 0x00, 0x2e, 0x00, 0x2e, 0xd0, 0x2e, 0x00, + 0x2e, 0x01, 0x80, 0x08, 0xa2, 0xfb, 0x2f, 0x98, 0x2e, 0xba, 0x03, 0x21, 0x2e, 0x19, 0x00, 0x01, 0x2e, 0xee, 0x00, + 0x00, 0xb2, 0x07, 0x2f, 0x01, 0x2e, 0x19, 0x00, 0x00, 0xb2, 0x03, 0x2f, 0x01, 0x50, 0x03, 0x52, 0x98, 0x2e, 0x07, + 0xcc, 0x01, 0x2e, 0xdd, 0x00, 0x00, 0xb2, 0x27, 0x2f, 0x05, 0x2e, 0x8a, 0x00, 0x05, 0x52, 0x98, 0x2e, 0xc7, 0xc1, + 0x03, 0x2e, 0xe9, 0x00, 0x40, 0xb2, 0xf0, 0x7f, 0x08, 0x2f, 0x01, 0x2e, 0x19, 0x00, 0x00, 0xb2, 0x04, 0x2f, 0x00, + 0x30, 0x21, 0x2e, 0xe9, 0x00, 0x98, 0x2e, 0xb4, 0xb1, 0x01, 0x2e, 0x18, 0x00, 0x00, 0xb2, 0x10, 0x2f, 0x05, 0x50, + 0x98, 0x2e, 0x4d, 0xc3, 0x05, 0x50, 0x98, 0x2e, 0x5a, 0xc7, 0x98, 0x2e, 0xf9, 0xb4, 0x98, 0x2e, 0x54, 0xb2, 0x98, + 0x2e, 0x67, 0xb6, 0x98, 0x2e, 0x17, 0xb2, 0x10, 0x30, 0x21, 0x2e, 0x77, 0x00, 0x01, 0x2e, 0xef, 0x00, 0x00, 0xb2, + 0x04, 0x2f, 0x98, 0x2e, 0x7a, 0xb7, 0x00, 0x30, 0x21, 0x2e, 0xef, 0x00, 0x01, 0x2e, 0xd4, 0x00, 0x04, 0xae, 0x0b, + 0x2f, 0x01, 0x2e, 0xdd, 0x00, 0x00, 0xb2, 0x07, 0x2f, 0x05, 0x52, 0x98, 0x2e, 0x8e, 0x0e, 0x00, 0xb2, 0x02, 0x2f, + 0x10, 0x30, 0x21, 0x2e, 0x7d, 0x00, 0x01, 0x2e, 0x7d, 0x00, 0x00, 0x90, 0x90, 0x2e, 0xf1, 0x02, 0x01, 0x2e, 0xd7, + 0x00, 0x00, 0xb2, 0x04, 0x2f, 0x98, 0x2e, 0x2f, 0x0e, 0x00, 0x30, 0x21, 0x2e, 0x7b, 0x00, 0x01, 0x2e, 0x7b, 0x00, + 0x00, 0xb2, 0x12, 0x2f, 0x01, 0x2e, 0xd4, 0x00, 0x00, 0x90, 0x02, 0x2f, 0x98, 0x2e, 0x1f, 0x0e, 0x09, 0x2d, 0x98, + 0x2e, 0x81, 0x0d, 0x01, 0x2e, 0xd4, 0x00, 0x04, 0x90, 0x02, 0x2f, 0x50, 0x32, 0x98, 0x2e, 0xfa, 0x03, 0x00, 0x30, + 0x21, 0x2e, 0x7b, 0x00, 0x01, 0x2e, 0x7c, 0x00, 0x00, 0xb2, 0x90, 0x2e, 0x09, 0x03, 0x01, 0x2e, 0x7c, 0x00, 0x01, + 0x31, 0x01, 0x08, 0x00, 0xb2, 0x04, 0x2f, 0x98, 0x2e, 0x47, 0xcb, 0x10, 0x30, 0x21, 0x2e, 0x77, 0x00, 0x81, 0x30, + 0x01, 0x2e, 0x7c, 0x00, 0x01, 0x08, 0x00, 0xb2, 0x61, 0x2f, 0x03, 0x2e, 0x89, 0x00, 0x01, 0x2e, 0xd4, 0x00, 0x98, + 0xbc, 0x98, 0xb8, 0x05, 0xb2, 0x0f, 0x58, 0x23, 0x2f, 0x07, 0x90, 0x09, 0x54, 0x00, 0x30, 0x37, 0x2f, 0x15, 0x41, + 0x04, 0x41, 0xdc, 0xbe, 0x44, 0xbe, 0xdc, 0xba, 0x2c, 0x01, 0x61, 0x00, 0x0f, 0x56, 0x4a, 0x0f, 0x0c, 0x2f, 0xd1, + 0x42, 0x94, 0xb8, 0xc1, 0x42, 0x11, 0x30, 0x05, 0x2e, 0x6a, 0xf7, 0x2c, 0xbd, 0x2f, 0xb9, 0x80, 0xb2, 0x08, 0x22, + 0x98, 0x2e, 0xc3, 0xb7, 0x21, 0x2d, 0x61, 0x30, 0x23, 0x2e, 0xd4, 0x00, 0x98, 0x2e, 0xc3, 0xb7, 0x00, 0x30, 0x21, + 0x2e, 0x5a, 0xf5, 0x18, 0x2d, 0xe1, 0x7f, 0x50, 0x30, 0x98, 0x2e, 0xfa, 0x03, 0x0f, 0x52, 0x07, 0x50, 0x50, 0x42, + 0x70, 0x30, 0x0d, 0x54, 0x42, 0x42, 0x7e, 0x82, 0xe2, 0x6f, 0x80, 0xb2, 0x42, 0x42, 0x05, 0x2f, 0x21, 0x2e, 0xd4, + 0x00, 0x10, 0x30, 0x98, 0x2e, 0xc3, 0xb7, 0x03, 0x2d, 0x60, 0x30, 0x21, 0x2e, 0xd4, 0x00, 0x01, 0x2e, 0xd4, 0x00, + 0x06, 0x90, 0x18, 0x2f, 0x01, 0x2e, 0x76, 0x00, 0x0b, 0x54, 0x07, 0x52, 0xe0, 0x7f, 0x98, 0x2e, 0x7a, 0xc1, 0xe1, + 0x6f, 0x08, 0x1a, 0x40, 0x30, 0x08, 0x2f, 0x21, 0x2e, 0xd4, 0x00, 0x20, 0x30, 0x98, 0x2e, 0xaf, 0xb7, 0x50, 0x32, + 0x98, 0x2e, 0xfa, 0x03, 0x05, 0x2d, 0x98, 0x2e, 0x38, 0x0e, 0x00, 0x30, 0x21, 0x2e, 0xd4, 0x00, 0x00, 0x30, 0x21, + 0x2e, 0x7c, 0x00, 0x18, 0x2d, 0x01, 0x2e, 0xd4, 0x00, 0x03, 0xaa, 0x01, 0x2f, 0x98, 0x2e, 0x45, 0x0e, 0x01, 0x2e, + 0xd4, 0x00, 0x3f, 0x80, 0x03, 0xa2, 0x01, 0x2f, 0x00, 0x2e, 0x02, 0x2d, 0x98, 0x2e, 0x5b, 0x0e, 0x30, 0x30, 0x98, + 0x2e, 0xce, 0xb7, 0x00, 0x30, 0x21, 0x2e, 0x7d, 0x00, 0x50, 0x32, 0x98, 0x2e, 0xfa, 0x03, 0x01, 0x2e, 0x77, 0x00, + 0x00, 0xb2, 0x24, 0x2f, 0x98, 0x2e, 0xf5, 0xcb, 0x03, 0x2e, 0xd5, 0x00, 0x11, 0x54, 0x01, 0x0a, 0xbc, 0x84, 0x83, + 0x86, 0x21, 0x2e, 0xc9, 0x01, 0xe0, 0x40, 0x13, 0x52, 0xc4, 0x40, 0x82, 0x40, 0xa8, 0xb9, 0x52, 0x42, 0x43, 0xbe, + 0x53, 0x42, 0x04, 0x0a, 0x50, 0x42, 0xe1, 0x7f, 0xf0, 0x31, 0x41, 0x40, 0xf2, 0x6f, 0x25, 0xbd, 0x08, 0x08, 0x02, + 0x0a, 0xd0, 0x7f, 0x98, 0x2e, 0xa8, 0xcf, 0x06, 0xbc, 0xd1, 0x6f, 0xe2, 0x6f, 0x08, 0x0a, 0x80, 0x42, 0x98, 0x2e, + 0x58, 0xb7, 0x00, 0x30, 0x21, 0x2e, 0xee, 0x00, 0x21, 0x2e, 0x77, 0x00, 0x21, 0x2e, 0xdd, 0x00, 0x80, 0x2e, 0xf4, + 0x01, 0x1a, 0x24, 0x22, 0x00, 0x80, 0x2e, 0xec, 0x01, 0x10, 0x50, 0xfb, 0x7f, 0x98, 0x2e, 0xf3, 0x03, 0x57, 0x50, + 0xfb, 0x6f, 0x01, 0x30, 0x71, 0x54, 0x11, 0x42, 0x42, 0x0e, 0xfc, 0x2f, 0xc0, 0x2e, 0x01, 0x42, 0xf0, 0x5f, 0x80, + 0x2e, 0x00, 0xc1, 0xfd, 0x2d, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9a, 0x01, + 0x34, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x20, 0x50, 0xe7, 0x7f, 0xf6, 0x7f, 0x06, 0x32, 0x0f, 0x2e, 0x61, 0xf5, 0xfe, 0x09, 0xc0, 0xb3, 0x04, + 0x2f, 0x17, 0x30, 0x2f, 0x2e, 0xef, 0x00, 0x2d, 0x2e, 0x61, 0xf5, 0xf6, 0x6f, 0xe7, 0x6f, 0xe0, 0x5f, 0xc8, 0x2e, + 0x20, 0x50, 0xe7, 0x7f, 0xf6, 0x7f, 0x46, 0x30, 0x0f, 0x2e, 0xa4, 0xf1, 0xbe, 0x09, 0x80, 0xb3, 0x06, 0x2f, 0x0d, + 0x2e, 0xd4, 0x00, 0x84, 0xaf, 0x02, 0x2f, 0x16, 0x30, 0x2d, 0x2e, 0x7b, 0x00, 0x86, 0x30, 0x2d, 0x2e, 0x60, 0xf5, + 0xf6, 0x6f, 0xe7, 0x6f, 0xe0, 0x5f, 0xc8, 0x2e, 0x01, 0x2e, 0x77, 0xf7, 0x09, 0xbc, 0x0f, 0xb8, 0x00, 0xb2, 0x10, + 0x50, 0xfb, 0x7f, 0x10, 0x30, 0x0b, 0x2f, 0x03, 0x2e, 0x8a, 0x00, 0x96, 0xbc, 0x9f, 0xb8, 0x40, 0xb2, 0x05, 0x2f, + 0x03, 0x2e, 0x68, 0xf7, 0x9e, 0xbc, 0x9f, 0xb8, 0x40, 0xb2, 0x07, 0x2f, 0x03, 0x2e, 0x7e, 0x00, 0x41, 0x90, 0x01, + 0x2f, 0x98, 0x2e, 0xdc, 0x03, 0x03, 0x2c, 0x00, 0x30, 0x21, 0x2e, 0x7e, 0x00, 0xfb, 0x6f, 0xf0, 0x5f, 0xb8, 0x2e, + 0x20, 0x50, 0xe0, 0x7f, 0xfb, 0x7f, 0x00, 0x2e, 0x27, 0x50, 0x98, 0x2e, 0x3b, 0xc8, 0x29, 0x50, 0x98, 0x2e, 0xa7, + 0xc8, 0x01, 0x50, 0x98, 0x2e, 0x55, 0xcc, 0xe1, 0x6f, 0x2b, 0x50, 0x98, 0x2e, 0xe0, 0xc9, 0xfb, 0x6f, 0x00, 0x30, + 0xe0, 0x5f, 0x21, 0x2e, 0x7e, 0x00, 0xb8, 0x2e, 0x73, 0x50, 0x01, 0x30, 0x57, 0x54, 0x11, 0x42, 0x42, 0x0e, 0xfc, + 0x2f, 0xb8, 0x2e, 0x21, 0x2e, 0x59, 0xf5, 0x10, 0x30, 0xc0, 0x2e, 0x21, 0x2e, 0x4a, 0xf1, 0x90, 0x50, 0xf7, 0x7f, + 0xe6, 0x7f, 0xd5, 0x7f, 0xc4, 0x7f, 0xb3, 0x7f, 0xa1, 0x7f, 0x90, 0x7f, 0x82, 0x7f, 0x7b, 0x7f, 0x98, 0x2e, 0x35, + 0xb7, 0x00, 0xb2, 0x90, 0x2e, 0x97, 0xb0, 0x03, 0x2e, 0x8f, 0x00, 0x07, 0x2e, 0x91, 0x00, 0x05, 0x2e, 0xb1, 0x00, + 0x3f, 0xba, 0x9f, 0xb8, 0x01, 0x2e, 0xb1, 0x00, 0xa3, 0xbd, 0x4c, 0x0a, 0x05, 0x2e, 0xb1, 0x00, 0x04, 0xbe, 0xbf, + 0xb9, 0xcb, 0x0a, 0x4f, 0xba, 0x22, 0xbd, 0x01, 0x2e, 0xb3, 0x00, 0xdc, 0x0a, 0x2f, 0xb9, 0x03, 0x2e, 0xb8, 0x00, + 0x0a, 0xbe, 0x9a, 0x0a, 0xcf, 0xb9, 0x9b, 0xbc, 0x01, 0x2e, 0x97, 0x00, 0x9f, 0xb8, 0x93, 0x0a, 0x0f, 0xbc, 0x91, + 0x0a, 0x0f, 0xb8, 0x90, 0x0a, 0x25, 0x2e, 0x18, 0x00, 0x05, 0x2e, 0xc1, 0xf5, 0x2e, 0xbd, 0x2e, 0xb9, 0x01, 0x2e, + 0x19, 0x00, 0x31, 0x30, 0x8a, 0x04, 0x00, 0x90, 0x07, 0x2f, 0x01, 0x2e, 0xd4, 0x00, 0x04, 0xa2, 0x03, 0x2f, 0x01, + 0x2e, 0x18, 0x00, 0x00, 0xb2, 0x0c, 0x2f, 0x19, 0x50, 0x05, 0x52, 0x98, 0x2e, 0x4d, 0xb7, 0x05, 0x2e, 0x78, 0x00, + 0x80, 0x90, 0x10, 0x30, 0x01, 0x2f, 0x21, 0x2e, 0x78, 0x00, 0x25, 0x2e, 0xdd, 0x00, 0x98, 0x2e, 0x3e, 0xb7, 0x00, + 0xb2, 0x02, 0x30, 0x01, 0x30, 0x04, 0x2f, 0x01, 0x2e, 0x19, 0x00, 0x00, 0xb2, 0x00, 0x2f, 0x21, 0x30, 0x01, 0x2e, + 0xea, 0x00, 0x08, 0x1a, 0x0e, 0x2f, 0x23, 0x2e, 0xea, 0x00, 0x33, 0x30, 0x1b, 0x50, 0x0b, 0x09, 0x01, 0x40, 0x17, + 0x56, 0x46, 0xbe, 0x4b, 0x08, 0x4c, 0x0a, 0x01, 0x42, 0x0a, 0x80, 0x15, 0x52, 0x01, 0x42, 0x00, 0x2e, 0x01, 0x2e, + 0x18, 0x00, 0x00, 0xb2, 0x1f, 0x2f, 0x03, 0x2e, 0xc0, 0xf5, 0xf0, 0x30, 0x48, 0x08, 0x47, 0xaa, 0x74, 0x30, 0x07, + 0x2e, 0x7a, 0x00, 0x61, 0x22, 0x4b, 0x1a, 0x05, 0x2f, 0x07, 0x2e, 0x66, 0xf5, 0xbf, 0xbd, 0xbf, 0xb9, 0xc0, 0x90, + 0x0b, 0x2f, 0x1d, 0x56, 0x2b, 0x30, 0xd2, 0x42, 0xdb, 0x42, 0x01, 0x04, 0xc2, 0x42, 0x04, 0xbd, 0xfe, 0x80, 0x81, + 0x84, 0x23, 0x2e, 0x7a, 0x00, 0x02, 0x42, 0x02, 0x32, 0x25, 0x2e, 0x62, 0xf5, 0x05, 0x2e, 0xd6, 0x00, 0x81, 0x84, + 0x25, 0x2e, 0xd6, 0x00, 0x02, 0x31, 0x25, 0x2e, 0x60, 0xf5, 0x05, 0x2e, 0x8a, 0x00, 0x0b, 0x50, 0x90, 0x08, 0x80, + 0xb2, 0x0b, 0x2f, 0x05, 0x2e, 0xca, 0xf5, 0xf0, 0x3e, 0x90, 0x08, 0x25, 0x2e, 0xca, 0xf5, 0x05, 0x2e, 0x59, 0xf5, + 0xe0, 0x3f, 0x90, 0x08, 0x25, 0x2e, 0x59, 0xf5, 0x90, 0x6f, 0xa1, 0x6f, 0xb3, 0x6f, 0xc4, 0x6f, 0xd5, 0x6f, 0xe6, + 0x6f, 0xf7, 0x6f, 0x7b, 0x6f, 0x82, 0x6f, 0x70, 0x5f, 0xc8, 0x2e, 0xc0, 0x50, 0x90, 0x7f, 0xe5, 0x7f, 0xd4, 0x7f, + 0xc3, 0x7f, 0xb1, 0x7f, 0xa2, 0x7f, 0x87, 0x7f, 0xf6, 0x7f, 0x7b, 0x7f, 0x00, 0x2e, 0x01, 0x2e, 0x60, 0xf5, 0x60, + 0x7f, 0x98, 0x2e, 0x35, 0xb7, 0x02, 0x30, 0x63, 0x6f, 0x15, 0x52, 0x50, 0x7f, 0x62, 0x7f, 0x5a, 0x2c, 0x02, 0x32, + 0x1a, 0x09, 0x00, 0xb3, 0x14, 0x2f, 0x00, 0xb2, 0x03, 0x2f, 0x09, 0x2e, 0x18, 0x00, 0x00, 0x91, 0x0c, 0x2f, 0x43, + 0x7f, 0x98, 0x2e, 0x97, 0xb7, 0x1f, 0x50, 0x02, 0x8a, 0x02, 0x32, 0x04, 0x30, 0x25, 0x2e, 0x64, 0xf5, 0x15, 0x52, + 0x50, 0x6f, 0x43, 0x6f, 0x44, 0x43, 0x25, 0x2e, 0x60, 0xf5, 0xd9, 0x08, 0xc0, 0xb2, 0x36, 0x2f, 0x98, 0x2e, 0x3e, + 0xb7, 0x00, 0xb2, 0x06, 0x2f, 0x01, 0x2e, 0x19, 0x00, 0x00, 0xb2, 0x02, 0x2f, 0x50, 0x6f, 0x00, 0x90, 0x0a, 0x2f, + 0x01, 0x2e, 0x79, 0x00, 0x00, 0x90, 0x19, 0x2f, 0x10, 0x30, 0x21, 0x2e, 0x79, 0x00, 0x00, 0x30, 0x98, 0x2e, 0xdc, + 0x03, 0x13, 0x2d, 0x01, 0x2e, 0xc3, 0xf5, 0x0c, 0xbc, 0x0f, 0xb8, 0x12, 0x30, 0x10, 0x04, 0x03, 0xb0, 0x26, 0x25, + 0x21, 0x50, 0x03, 0x52, 0x98, 0x2e, 0x4d, 0xb7, 0x10, 0x30, 0x21, 0x2e, 0xee, 0x00, 0x02, 0x30, 0x60, 0x7f, 0x25, + 0x2e, 0x79, 0x00, 0x60, 0x6f, 0x00, 0x90, 0x05, 0x2f, 0x00, 0x30, 0x21, 0x2e, 0xea, 0x00, 0x15, 0x50, 0x21, 0x2e, + 0x64, 0xf5, 0x15, 0x52, 0x23, 0x2e, 0x60, 0xf5, 0x02, 0x32, 0x50, 0x6f, 0x00, 0x90, 0x02, 0x2f, 0x03, 0x30, 0x27, + 0x2e, 0x78, 0x00, 0x07, 0x2e, 0x60, 0xf5, 0x1a, 0x09, 0x00, 0x91, 0xa3, 0x2f, 0x19, 0x09, 0x00, 0x91, 0xa0, 0x2f, + 0x90, 0x6f, 0xa2, 0x6f, 0xb1, 0x6f, 0xc3, 0x6f, 0xd4, 0x6f, 0xe5, 0x6f, 0x7b, 0x6f, 0xf6, 0x6f, 0x87, 0x6f, 0x40, + 0x5f, 0xc8, 0x2e, 0xc0, 0x50, 0xe7, 0x7f, 0xf6, 0x7f, 0x26, 0x30, 0x0f, 0x2e, 0x61, 0xf5, 0x2f, 0x2e, 0x7c, 0x00, + 0x0f, 0x2e, 0x7c, 0x00, 0xbe, 0x09, 0xa2, 0x7f, 0x80, 0x7f, 0x80, 0xb3, 0xd5, 0x7f, 0xc4, 0x7f, 0xb3, 0x7f, 0x91, + 0x7f, 0x7b, 0x7f, 0x0b, 0x2f, 0x23, 0x50, 0x1a, 0x25, 0x12, 0x40, 0x42, 0x7f, 0x74, 0x82, 0x12, 0x40, 0x52, 0x7f, + 0x00, 0x2e, 0x00, 0x40, 0x60, 0x7f, 0x98, 0x2e, 0x6a, 0xd6, 0x81, 0x30, 0x01, 0x2e, 0x7c, 0x00, 0x01, 0x08, 0x00, + 0xb2, 0x42, 0x2f, 0x03, 0x2e, 0x89, 0x00, 0x01, 0x2e, 0x89, 0x00, 0x97, 0xbc, 0x06, 0xbc, 0x9f, 0xb8, 0x0f, 0xb8, + 0x00, 0x90, 0x23, 0x2e, 0xd8, 0x00, 0x10, 0x30, 0x01, 0x30, 0x2a, 0x2f, 0x03, 0x2e, 0xd4, 0x00, 0x44, 0xb2, 0x05, + 0x2f, 0x47, 0xb2, 0x00, 0x30, 0x2d, 0x2f, 0x21, 0x2e, 0x7c, 0x00, 0x2b, 0x2d, 0x03, 0x2e, 0xfd, 0xf5, 0x9e, 0xbc, + 0x9f, 0xb8, 0x40, 0x90, 0x14, 0x2f, 0x03, 0x2e, 0xfc, 0xf5, 0x99, 0xbc, 0x9f, 0xb8, 0x40, 0x90, 0x0e, 0x2f, 0x03, + 0x2e, 0x49, 0xf1, 0x25, 0x54, 0x4a, 0x08, 0x40, 0x90, 0x08, 0x2f, 0x98, 0x2e, 0x35, 0xb7, 0x00, 0xb2, 0x10, 0x30, + 0x03, 0x2f, 0x50, 0x30, 0x21, 0x2e, 0xd4, 0x00, 0x10, 0x2d, 0x98, 0x2e, 0xaf, 0xb7, 0x00, 0x30, 0x21, 0x2e, 0x7c, + 0x00, 0x0a, 0x2d, 0x05, 0x2e, 0x69, 0xf7, 0x2d, 0xbd, 0x2f, 0xb9, 0x80, 0xb2, 0x01, 0x2f, 0x21, 0x2e, 0x7d, 0x00, + 0x23, 0x2e, 0x7c, 0x00, 0xe0, 0x31, 0x21, 0x2e, 0x61, 0xf5, 0xf6, 0x6f, 0xe7, 0x6f, 0x80, 0x6f, 0xa2, 0x6f, 0xb3, + 0x6f, 0xc4, 0x6f, 0xd5, 0x6f, 0x7b, 0x6f, 0x91, 0x6f, 0x40, 0x5f, 0xc8, 0x2e, 0x60, 0x51, 0x0a, 0x25, 0x36, 0x88, + 0xf4, 0x7f, 0xeb, 0x7f, 0x00, 0x32, 0x31, 0x52, 0x32, 0x30, 0x13, 0x30, 0x98, 0x2e, 0x15, 0xcb, 0x0a, 0x25, 0x33, + 0x84, 0xd2, 0x7f, 0x43, 0x30, 0x05, 0x50, 0x2d, 0x52, 0x98, 0x2e, 0x95, 0xc1, 0xd2, 0x6f, 0x27, 0x52, 0x98, 0x2e, + 0xd7, 0xc7, 0x2a, 0x25, 0xb0, 0x86, 0xc0, 0x7f, 0xd3, 0x7f, 0xaf, 0x84, 0x29, 0x50, 0xf1, 0x6f, 0x98, 0x2e, 0x4d, + 0xc8, 0x2a, 0x25, 0xae, 0x8a, 0xaa, 0x88, 0xf2, 0x6e, 0x2b, 0x50, 0xc1, 0x6f, 0xd3, 0x6f, 0xf4, 0x7f, 0x98, 0x2e, + 0xb6, 0xc8, 0xe0, 0x6e, 0x00, 0xb2, 0x32, 0x2f, 0x33, 0x54, 0x83, 0x86, 0xf1, 0x6f, 0xc3, 0x7f, 0x04, 0x30, 0x30, + 0x30, 0xf4, 0x7f, 0xd0, 0x7f, 0xb2, 0x7f, 0xe3, 0x30, 0xc5, 0x6f, 0x56, 0x40, 0x45, 0x41, 0x28, 0x08, 0x03, 0x14, + 0x0e, 0xb4, 0x08, 0xbc, 0x82, 0x40, 0x10, 0x0a, 0x2f, 0x54, 0x26, 0x05, 0x91, 0x7f, 0x44, 0x28, 0xa3, 0x7f, 0x98, + 0x2e, 0xd9, 0xc0, 0x08, 0xb9, 0x33, 0x30, 0x53, 0x09, 0xc1, 0x6f, 0xd3, 0x6f, 0xf4, 0x6f, 0x83, 0x17, 0x47, 0x40, + 0x6c, 0x15, 0xb2, 0x6f, 0xbe, 0x09, 0x75, 0x0b, 0x90, 0x42, 0x45, 0x42, 0x51, 0x0e, 0x32, 0xbc, 0x02, 0x89, 0xa1, + 0x6f, 0x7e, 0x86, 0xf4, 0x7f, 0xd0, 0x7f, 0xb2, 0x7f, 0x04, 0x30, 0x91, 0x6f, 0xd6, 0x2f, 0xeb, 0x6f, 0xa0, 0x5e, + 0xb8, 0x2e, 0x03, 0x2e, 0x97, 0x00, 0x1b, 0xbc, 0x60, 0x50, 0x9f, 0xbc, 0x0c, 0xb8, 0xf0, 0x7f, 0x40, 0xb2, 0xeb, + 0x7f, 0x2b, 0x2f, 0x03, 0x2e, 0x7f, 0x00, 0x41, 0x40, 0x01, 0x2e, 0xc8, 0x00, 0x01, 0x1a, 0x11, 0x2f, 0x37, 0x58, + 0x23, 0x2e, 0xc8, 0x00, 0x10, 0x41, 0xa0, 0x7f, 0x38, 0x81, 0x01, 0x41, 0xd0, 0x7f, 0xb1, 0x7f, 0x98, 0x2e, 0x64, + 0xcf, 0xd0, 0x6f, 0x07, 0x80, 0xa1, 0x6f, 0x11, 0x42, 0x00, 0x2e, 0xb1, 0x6f, 0x01, 0x42, 0x11, 0x30, 0x01, 0x2e, + 0xfc, 0x00, 0x00, 0xa8, 0x03, 0x30, 0xcb, 0x22, 0x4a, 0x25, 0x01, 0x2e, 0x7f, 0x00, 0x3c, 0x89, 0x35, 0x52, 0x05, + 0x54, 0x98, 0x2e, 0xc4, 0xce, 0xc1, 0x6f, 0xf0, 0x6f, 0x98, 0x2e, 0x95, 0xcf, 0x04, 0x2d, 0x01, 0x30, 0xf0, 0x6f, + 0x98, 0x2e, 0x95, 0xcf, 0xeb, 0x6f, 0xa0, 0x5f, 0xb8, 0x2e, 0x03, 0x2e, 0xb3, 0x00, 0x02, 0x32, 0xf0, 0x30, 0x03, + 0x31, 0x30, 0x50, 0x8a, 0x08, 0x08, 0x08, 0xcb, 0x08, 0xe0, 0x7f, 0x80, 0xb2, 0xf3, 0x7f, 0xdb, 0x7f, 0x25, 0x2f, + 0x03, 0x2e, 0xca, 0x00, 0x41, 0x90, 0x04, 0x2f, 0x01, 0x30, 0x23, 0x2e, 0xca, 0x00, 0x98, 0x2e, 0x3f, 0x03, 0xc0, + 0xb2, 0x05, 0x2f, 0x03, 0x2e, 0xda, 0x00, 0x00, 0x30, 0x41, 0x04, 0x23, 0x2e, 0xda, 0x00, 0x98, 0x2e, 0x92, 0xb2, + 0x10, 0x25, 0xf0, 0x6f, 0x00, 0xb2, 0x05, 0x2f, 0x01, 0x2e, 0xda, 0x00, 0x02, 0x30, 0x10, 0x04, 0x21, 0x2e, 0xda, + 0x00, 0x40, 0xb2, 0x01, 0x2f, 0x23, 0x2e, 0xc8, 0x01, 0xdb, 0x6f, 0xe0, 0x6f, 0xd0, 0x5f, 0x80, 0x2e, 0x95, 0xcf, + 0x01, 0x30, 0xe0, 0x6f, 0x98, 0x2e, 0x95, 0xcf, 0x11, 0x30, 0x23, 0x2e, 0xca, 0x00, 0xdb, 0x6f, 0xd0, 0x5f, 0xb8, + 0x2e, 0xd0, 0x50, 0x0a, 0x25, 0x33, 0x84, 0x55, 0x50, 0xd2, 0x7f, 0xe2, 0x7f, 0x03, 0x8c, 0xc0, 0x7f, 0xbb, 0x7f, + 0x00, 0x30, 0x05, 0x5a, 0x39, 0x54, 0x51, 0x41, 0xa5, 0x7f, 0x96, 0x7f, 0x80, 0x7f, 0x98, 0x2e, 0xd9, 0xc0, 0x05, + 0x30, 0xf5, 0x7f, 0x20, 0x25, 0x91, 0x6f, 0x3b, 0x58, 0x3d, 0x5c, 0x3b, 0x56, 0x98, 0x2e, 0x67, 0xcc, 0xc1, 0x6f, + 0xd5, 0x6f, 0x52, 0x40, 0x50, 0x43, 0xc1, 0x7f, 0xd5, 0x7f, 0x10, 0x25, 0x98, 0x2e, 0xfe, 0xc9, 0x10, 0x25, 0x98, + 0x2e, 0x74, 0xc0, 0x86, 0x6f, 0x30, 0x28, 0x92, 0x6f, 0x82, 0x8c, 0xa5, 0x6f, 0x6f, 0x52, 0x69, 0x0e, 0x39, 0x54, + 0xdb, 0x2f, 0x19, 0xa0, 0x15, 0x30, 0x03, 0x2f, 0x00, 0x30, 0x21, 0x2e, 0x81, 0x01, 0x0a, 0x2d, 0x01, 0x2e, 0x81, + 0x01, 0x05, 0x28, 0x42, 0x36, 0x21, 0x2e, 0x81, 0x01, 0x02, 0x0e, 0x01, 0x2f, 0x98, 0x2e, 0xf3, 0x03, 0x57, 0x50, + 0x12, 0x30, 0x01, 0x40, 0x98, 0x2e, 0xfe, 0xc9, 0x51, 0x6f, 0x0b, 0x5c, 0x8e, 0x0e, 0x3b, 0x6f, 0x57, 0x58, 0x02, + 0x30, 0x21, 0x2e, 0x95, 0x01, 0x45, 0x6f, 0x2a, 0x8d, 0xd2, 0x7f, 0xcb, 0x7f, 0x13, 0x2f, 0x02, 0x30, 0x3f, 0x50, + 0xd2, 0x7f, 0xa8, 0x0e, 0x0e, 0x2f, 0xc0, 0x6f, 0x53, 0x54, 0x02, 0x00, 0x51, 0x54, 0x42, 0x0e, 0x10, 0x30, 0x59, + 0x52, 0x02, 0x30, 0x01, 0x2f, 0x00, 0x2e, 0x03, 0x2d, 0x50, 0x42, 0x42, 0x42, 0x12, 0x30, 0xd2, 0x7f, 0x80, 0xb2, + 0x03, 0x2f, 0x00, 0x30, 0x21, 0x2e, 0x80, 0x01, 0x12, 0x2d, 0x01, 0x2e, 0xc9, 0x00, 0x02, 0x80, 0x05, 0x2e, 0x80, + 0x01, 0x11, 0x30, 0x91, 0x28, 0x00, 0x40, 0x25, 0x2e, 0x80, 0x01, 0x10, 0x0e, 0x05, 0x2f, 0x01, 0x2e, 0x7f, 0x01, + 0x01, 0x90, 0x01, 0x2f, 0x98, 0x2e, 0xf3, 0x03, 0x00, 0x2e, 0xa0, 0x41, 0x01, 0x90, 0xa6, 0x7f, 0x90, 0x2e, 0xe3, + 0xb4, 0x01, 0x2e, 0x95, 0x01, 0x00, 0xa8, 0x90, 0x2e, 0xe3, 0xb4, 0x5b, 0x54, 0x95, 0x80, 0x82, 0x40, 0x80, 0xb2, + 0x02, 0x40, 0x2d, 0x8c, 0x3f, 0x52, 0x96, 0x7f, 0x90, 0x2e, 0xc2, 0xb3, 0x29, 0x0e, 0x76, 0x2f, 0x01, 0x2e, 0xc9, + 0x00, 0x00, 0x40, 0x81, 0x28, 0x45, 0x52, 0xb3, 0x30, 0x98, 0x2e, 0x0f, 0xca, 0x5d, 0x54, 0x80, 0x7f, 0x00, 0x2e, + 0xa1, 0x40, 0x72, 0x7f, 0x82, 0x80, 0x82, 0x40, 0x60, 0x7f, 0x98, 0x2e, 0xfe, 0xc9, 0x10, 0x25, 0x98, 0x2e, 0x74, + 0xc0, 0x62, 0x6f, 0x05, 0x30, 0x87, 0x40, 0xc0, 0x91, 0x04, 0x30, 0x05, 0x2f, 0x05, 0x2e, 0x83, 0x01, 0x80, 0xb2, + 0x14, 0x30, 0x00, 0x2f, 0x04, 0x30, 0x05, 0x2e, 0xc9, 0x00, 0x73, 0x6f, 0x81, 0x40, 0xe2, 0x40, 0x69, 0x04, 0x11, + 0x0f, 0xe1, 0x40, 0x16, 0x30, 0xfe, 0x29, 0xcb, 0x40, 0x02, 0x2f, 0x83, 0x6f, 0x83, 0x0f, 0x22, 0x2f, 0x47, 0x56, + 0x13, 0x0f, 0x12, 0x30, 0x77, 0x2f, 0x49, 0x54, 0x42, 0x0e, 0x12, 0x30, 0x73, 0x2f, 0x00, 0x91, 0x0a, 0x2f, 0x01, + 0x2e, 0x8b, 0x01, 0x19, 0xa8, 0x02, 0x30, 0x6c, 0x2f, 0x63, 0x50, 0x00, 0x2e, 0x17, 0x42, 0x05, 0x42, 0x68, 0x2c, + 0x12, 0x30, 0x0b, 0x25, 0x08, 0x0f, 0x50, 0x30, 0x02, 0x2f, 0x21, 0x2e, 0x83, 0x01, 0x03, 0x2d, 0x40, 0x30, 0x21, + 0x2e, 0x83, 0x01, 0x2b, 0x2e, 0x85, 0x01, 0x5a, 0x2c, 0x12, 0x30, 0x00, 0x91, 0x2b, 0x25, 0x04, 0x2f, 0x63, 0x50, + 0x02, 0x30, 0x17, 0x42, 0x17, 0x2c, 0x02, 0x42, 0x98, 0x2e, 0xfe, 0xc9, 0x10, 0x25, 0x98, 0x2e, 0x74, 0xc0, 0x05, + 0x2e, 0xc9, 0x00, 0x81, 0x84, 0x5b, 0x30, 0x82, 0x40, 0x37, 0x2e, 0x83, 0x01, 0x02, 0x0e, 0x07, 0x2f, 0x5f, 0x52, + 0x40, 0x30, 0x62, 0x40, 0x41, 0x40, 0x91, 0x0e, 0x01, 0x2f, 0x21, 0x2e, 0x83, 0x01, 0x05, 0x30, 0x2b, 0x2e, 0x85, + 0x01, 0x12, 0x30, 0x36, 0x2c, 0x16, 0x30, 0x15, 0x25, 0x81, 0x7f, 0x98, 0x2e, 0xfe, 0xc9, 0x10, 0x25, 0x98, 0x2e, + 0x74, 0xc0, 0x19, 0xa2, 0x16, 0x30, 0x15, 0x2f, 0x05, 0x2e, 0x97, 0x01, 0x80, 0x6f, 0x82, 0x0e, 0x05, 0x2f, 0x01, + 0x2e, 0x86, 0x01, 0x06, 0x28, 0x21, 0x2e, 0x86, 0x01, 0x0b, 0x2d, 0x03, 0x2e, 0x87, 0x01, 0x5f, 0x54, 0x4e, 0x28, + 0x91, 0x42, 0x00, 0x2e, 0x82, 0x40, 0x90, 0x0e, 0x01, 0x2f, 0x21, 0x2e, 0x88, 0x01, 0x02, 0x30, 0x13, 0x2c, 0x05, + 0x30, 0xc0, 0x6f, 0x08, 0x1c, 0xa8, 0x0f, 0x16, 0x30, 0x05, 0x30, 0x5b, 0x50, 0x09, 0x2f, 0x02, 0x80, 0x2d, 0x2e, + 0x82, 0x01, 0x05, 0x42, 0x05, 0x80, 0x00, 0x2e, 0x02, 0x42, 0x3e, 0x80, 0x00, 0x2e, 0x06, 0x42, 0x02, 0x30, 0x90, + 0x6f, 0x3e, 0x88, 0x01, 0x40, 0x04, 0x41, 0x4c, 0x28, 0x01, 0x42, 0x07, 0x80, 0x10, 0x25, 0x24, 0x40, 0x00, 0x40, + 0x00, 0xa8, 0xf5, 0x22, 0x23, 0x29, 0x44, 0x42, 0x7a, 0x82, 0x7e, 0x88, 0x43, 0x40, 0x04, 0x41, 0x00, 0xab, 0xf5, + 0x23, 0xdf, 0x28, 0x43, 0x42, 0xd9, 0xa0, 0x14, 0x2f, 0x00, 0x90, 0x02, 0x2f, 0xd2, 0x6f, 0x81, 0xb2, 0x05, 0x2f, + 0x63, 0x54, 0x06, 0x28, 0x90, 0x42, 0x85, 0x42, 0x09, 0x2c, 0x02, 0x30, 0x5b, 0x50, 0x03, 0x80, 0x29, 0x2e, 0x7e, + 0x01, 0x2b, 0x2e, 0x82, 0x01, 0x05, 0x42, 0x12, 0x30, 0x2b, 0x2e, 0x83, 0x01, 0x45, 0x82, 0x00, 0x2e, 0x40, 0x40, + 0x7a, 0x82, 0x02, 0xa0, 0x08, 0x2f, 0x63, 0x50, 0x3b, 0x30, 0x15, 0x42, 0x05, 0x42, 0x37, 0x80, 0x37, 0x2e, 0x7e, + 0x01, 0x05, 0x42, 0x12, 0x30, 0x01, 0x2e, 0xc9, 0x00, 0x02, 0x8c, 0x40, 0x40, 0x84, 0x41, 0x7a, 0x8c, 0x04, 0x0f, + 0x03, 0x2f, 0x01, 0x2e, 0x8b, 0x01, 0x19, 0xa4, 0x04, 0x2f, 0x2b, 0x2e, 0x82, 0x01, 0x98, 0x2e, 0xf3, 0x03, 0x12, + 0x30, 0x81, 0x90, 0x61, 0x52, 0x08, 0x2f, 0x65, 0x42, 0x65, 0x42, 0x43, 0x80, 0x39, 0x84, 0x82, 0x88, 0x05, 0x42, + 0x45, 0x42, 0x85, 0x42, 0x05, 0x43, 0x00, 0x2e, 0x80, 0x41, 0x00, 0x90, 0x90, 0x2e, 0xe1, 0xb4, 0x65, 0x54, 0xc1, + 0x6f, 0x80, 0x40, 0x00, 0xb2, 0x43, 0x58, 0x69, 0x50, 0x44, 0x2f, 0x55, 0x5c, 0xb7, 0x87, 0x8c, 0x0f, 0x0d, 0x2e, + 0x96, 0x01, 0xc4, 0x40, 0x36, 0x2f, 0x41, 0x56, 0x8b, 0x0e, 0x2a, 0x2f, 0x0b, 0x52, 0xa1, 0x0e, 0x0a, 0x2f, 0x05, + 0x2e, 0x8f, 0x01, 0x14, 0x25, 0x98, 0x2e, 0xfe, 0xc9, 0x4b, 0x54, 0x02, 0x0f, 0x69, 0x50, 0x05, 0x30, 0x65, 0x54, + 0x15, 0x2f, 0x03, 0x2e, 0x8e, 0x01, 0x4d, 0x5c, 0x8e, 0x0f, 0x3a, 0x2f, 0x05, 0x2e, 0x8f, 0x01, 0x98, 0x2e, 0xfe, + 0xc9, 0x4f, 0x54, 0x82, 0x0f, 0x05, 0x30, 0x69, 0x50, 0x65, 0x54, 0x30, 0x2f, 0x6d, 0x52, 0x15, 0x30, 0x42, 0x8c, + 0x45, 0x42, 0x04, 0x30, 0x2b, 0x2c, 0x84, 0x43, 0x6b, 0x52, 0x42, 0x8c, 0x00, 0x2e, 0x85, 0x43, 0x15, 0x30, 0x24, + 0x2c, 0x45, 0x42, 0x8e, 0x0f, 0x20, 0x2f, 0x0d, 0x2e, 0x8e, 0x01, 0xb1, 0x0e, 0x1c, 0x2f, 0x23, 0x2e, 0x8e, 0x01, + 0x1a, 0x2d, 0x0e, 0x0e, 0x17, 0x2f, 0xa1, 0x0f, 0x15, 0x2f, 0x23, 0x2e, 0x8d, 0x01, 0x13, 0x2d, 0x98, 0x2e, 0x74, + 0xc0, 0x43, 0x54, 0xc2, 0x0e, 0x0a, 0x2f, 0x65, 0x50, 0x04, 0x80, 0x0b, 0x30, 0x06, 0x82, 0x0b, 0x42, 0x79, 0x80, + 0x41, 0x40, 0x12, 0x30, 0x25, 0x2e, 0x8c, 0x01, 0x01, 0x42, 0x05, 0x30, 0x69, 0x50, 0x65, 0x54, 0x84, 0x82, 0x43, + 0x84, 0xbe, 0x8c, 0x84, 0x40, 0x86, 0x41, 0x26, 0x29, 0x94, 0x42, 0xbe, 0x8e, 0xd5, 0x7f, 0x19, 0xa1, 0x43, 0x40, + 0x0b, 0x2e, 0x8c, 0x01, 0x84, 0x40, 0xc7, 0x41, 0x5d, 0x29, 0x27, 0x29, 0x45, 0x42, 0x84, 0x42, 0xc2, 0x7f, 0x01, + 0x2f, 0xc0, 0xb3, 0x1d, 0x2f, 0x05, 0x2e, 0x94, 0x01, 0x99, 0xa0, 0x01, 0x2f, 0x80, 0xb3, 0x13, 0x2f, 0x80, 0xb3, + 0x18, 0x2f, 0xc0, 0xb3, 0x16, 0x2f, 0x12, 0x40, 0x01, 0x40, 0x92, 0x7f, 0x98, 0x2e, 0x74, 0xc0, 0x92, 0x6f, 0x10, + 0x0f, 0x20, 0x30, 0x03, 0x2f, 0x10, 0x30, 0x21, 0x2e, 0x7e, 0x01, 0x0a, 0x2d, 0x21, 0x2e, 0x7e, 0x01, 0x07, 0x2d, + 0x20, 0x30, 0x21, 0x2e, 0x7e, 0x01, 0x03, 0x2d, 0x10, 0x30, 0x21, 0x2e, 0x7e, 0x01, 0xc2, 0x6f, 0x01, 0x2e, 0xc9, + 0x00, 0xbc, 0x84, 0x02, 0x80, 0x82, 0x40, 0x00, 0x40, 0x90, 0x0e, 0xd5, 0x6f, 0x02, 0x2f, 0x15, 0x30, 0x98, 0x2e, + 0xf3, 0x03, 0x41, 0x91, 0x05, 0x30, 0x07, 0x2f, 0x67, 0x50, 0x3d, 0x80, 0x2b, 0x2e, 0x8f, 0x01, 0x05, 0x42, 0x04, + 0x80, 0x00, 0x2e, 0x05, 0x42, 0x02, 0x2c, 0x00, 0x30, 0x00, 0x30, 0xa2, 0x6f, 0x98, 0x8a, 0x86, 0x40, 0x80, 0xa7, + 0x05, 0x2f, 0x98, 0x2e, 0xf3, 0x03, 0xc0, 0x30, 0x21, 0x2e, 0x95, 0x01, 0x06, 0x25, 0x1a, 0x25, 0xe2, 0x6f, 0x76, + 0x82, 0x96, 0x40, 0x56, 0x43, 0x51, 0x0e, 0xfb, 0x2f, 0xbb, 0x6f, 0x30, 0x5f, 0xb8, 0x2e, 0x01, 0x2e, 0xb8, 0x00, + 0x01, 0x31, 0x41, 0x08, 0x40, 0xb2, 0x20, 0x50, 0xf2, 0x30, 0x02, 0x08, 0xfb, 0x7f, 0x01, 0x30, 0x10, 0x2f, 0x05, + 0x2e, 0xcc, 0x00, 0x81, 0x90, 0xe0, 0x7f, 0x03, 0x2f, 0x23, 0x2e, 0xcc, 0x00, 0x98, 0x2e, 0x55, 0xb6, 0x98, 0x2e, + 0x1d, 0xb5, 0x10, 0x25, 0xfb, 0x6f, 0xe0, 0x6f, 0xe0, 0x5f, 0x80, 0x2e, 0x95, 0xcf, 0x98, 0x2e, 0x95, 0xcf, 0x10, + 0x30, 0x21, 0x2e, 0xcc, 0x00, 0xfb, 0x6f, 0xe0, 0x5f, 0xb8, 0x2e, 0x00, 0x51, 0x05, 0x58, 0xeb, 0x7f, 0x2a, 0x25, + 0x89, 0x52, 0x6f, 0x5a, 0x89, 0x50, 0x13, 0x41, 0x06, 0x40, 0xb3, 0x01, 0x16, 0x42, 0xcb, 0x16, 0x06, 0x40, 0xf3, + 0x02, 0x13, 0x42, 0x65, 0x0e, 0xf5, 0x2f, 0x05, 0x40, 0x14, 0x30, 0x2c, 0x29, 0x04, 0x42, 0x08, 0xa1, 0x00, 0x30, + 0x90, 0x2e, 0x52, 0xb6, 0xb3, 0x88, 0xb0, 0x8a, 0xb6, 0x84, 0xa4, 0x7f, 0xc4, 0x7f, 0xb5, 0x7f, 0xd5, 0x7f, 0x92, + 0x7f, 0x73, 0x30, 0x04, 0x30, 0x55, 0x40, 0x42, 0x40, 0x8a, 0x17, 0xf3, 0x08, 0x6b, 0x01, 0x90, 0x02, 0x53, 0xb8, + 0x4b, 0x82, 0xad, 0xbe, 0x71, 0x7f, 0x45, 0x0a, 0x09, 0x54, 0x84, 0x7f, 0x98, 0x2e, 0xd9, 0xc0, 0xa3, 0x6f, 0x7b, + 0x54, 0xd0, 0x42, 0xa3, 0x7f, 0xf2, 0x7f, 0x60, 0x7f, 0x20, 0x25, 0x71, 0x6f, 0x75, 0x5a, 0x77, 0x58, 0x79, 0x5c, + 0x75, 0x56, 0x98, 0x2e, 0x67, 0xcc, 0xb1, 0x6f, 0x62, 0x6f, 0x50, 0x42, 0xb1, 0x7f, 0xb3, 0x30, 0x10, 0x25, 0x98, + 0x2e, 0x0f, 0xca, 0x84, 0x6f, 0x20, 0x29, 0x71, 0x6f, 0x92, 0x6f, 0xa5, 0x6f, 0x76, 0x82, 0x6a, 0x0e, 0x73, 0x30, + 0x00, 0x30, 0xd0, 0x2f, 0xd2, 0x6f, 0xd1, 0x7f, 0xb4, 0x7f, 0x98, 0x2e, 0x2b, 0xb7, 0x15, 0xbd, 0x0b, 0xb8, 0x02, + 0x0a, 0xc2, 0x6f, 0xc0, 0x7f, 0x98, 0x2e, 0x2b, 0xb7, 0x15, 0xbd, 0x0b, 0xb8, 0x42, 0x0a, 0xc0, 0x6f, 0x08, 0x17, + 0x41, 0x18, 0x89, 0x16, 0xe1, 0x18, 0xd0, 0x18, 0xa1, 0x7f, 0x27, 0x25, 0x16, 0x25, 0x98, 0x2e, 0x79, 0xc0, 0x8b, + 0x54, 0x90, 0x7f, 0xb3, 0x30, 0x82, 0x40, 0x80, 0x90, 0x0d, 0x2f, 0x7d, 0x52, 0x92, 0x6f, 0x98, 0x2e, 0x0f, 0xca, + 0xb2, 0x6f, 0x90, 0x0e, 0x06, 0x2f, 0x8b, 0x50, 0x14, 0x30, 0x42, 0x6f, 0x51, 0x6f, 0x14, 0x42, 0x12, 0x42, 0x01, + 0x42, 0x00, 0x2e, 0x31, 0x6f, 0x98, 0x2e, 0x74, 0xc0, 0x41, 0x6f, 0x80, 0x7f, 0x98, 0x2e, 0x74, 0xc0, 0x82, 0x6f, + 0x10, 0x04, 0x43, 0x52, 0x01, 0x0f, 0x05, 0x2e, 0xcb, 0x00, 0x00, 0x30, 0x04, 0x30, 0x21, 0x2f, 0x51, 0x6f, 0x43, + 0x58, 0x8c, 0x0e, 0x04, 0x30, 0x1c, 0x2f, 0x85, 0x88, 0x41, 0x6f, 0x04, 0x41, 0x8c, 0x0f, 0x04, 0x30, 0x16, 0x2f, + 0x84, 0x88, 0x00, 0x2e, 0x04, 0x41, 0x04, 0x05, 0x8c, 0x0e, 0x04, 0x30, 0x0f, 0x2f, 0x82, 0x88, 0x31, 0x6f, 0x04, + 0x41, 0x04, 0x05, 0x8c, 0x0e, 0x04, 0x30, 0x08, 0x2f, 0x83, 0x88, 0x00, 0x2e, 0x04, 0x41, 0x8c, 0x0f, 0x04, 0x30, + 0x02, 0x2f, 0x21, 0x2e, 0xad, 0x01, 0x14, 0x30, 0x00, 0x91, 0x14, 0x2f, 0x03, 0x2e, 0xa1, 0x01, 0x41, 0x90, 0x0e, + 0x2f, 0x03, 0x2e, 0xad, 0x01, 0x14, 0x30, 0x4c, 0x28, 0x23, 0x2e, 0xad, 0x01, 0x46, 0xa0, 0x06, 0x2f, 0x81, 0x84, + 0x8d, 0x52, 0x48, 0x82, 0x82, 0x40, 0x21, 0x2e, 0xa1, 0x01, 0x42, 0x42, 0x5c, 0x2c, 0x02, 0x30, 0x05, 0x2e, 0xaa, + 0x01, 0x80, 0xb2, 0x02, 0x30, 0x55, 0x2f, 0x03, 0x2e, 0xa9, 0x01, 0x92, 0x6f, 0xb3, 0x30, 0x98, 0x2e, 0x0f, 0xca, + 0xb2, 0x6f, 0x90, 0x0f, 0x00, 0x30, 0x02, 0x30, 0x4a, 0x2f, 0xa2, 0x6f, 0x87, 0x52, 0x91, 0x00, 0x85, 0x52, 0x51, + 0x0e, 0x02, 0x2f, 0x00, 0x2e, 0x43, 0x2c, 0x02, 0x30, 0xc2, 0x6f, 0x7f, 0x52, 0x91, 0x0e, 0x02, 0x30, 0x3c, 0x2f, + 0x51, 0x6f, 0x81, 0x54, 0x98, 0x2e, 0xfe, 0xc9, 0x10, 0x25, 0xb3, 0x30, 0x21, 0x25, 0x98, 0x2e, 0x0f, 0xca, 0x32, + 0x6f, 0xc0, 0x7f, 0xb3, 0x30, 0x12, 0x25, 0x98, 0x2e, 0x0f, 0xca, 0x42, 0x6f, 0xb0, 0x7f, 0xb3, 0x30, 0x12, 0x25, + 0x98, 0x2e, 0x0f, 0xca, 0xb2, 0x6f, 0x90, 0x28, 0x83, 0x52, 0x98, 0x2e, 0xfe, 0xc9, 0xc2, 0x6f, 0x90, 0x0f, 0x00, + 0x30, 0x02, 0x30, 0x1d, 0x2f, 0x05, 0x2e, 0xa1, 0x01, 0x80, 0xb2, 0x12, 0x30, 0x0f, 0x2f, 0x42, 0x6f, 0x03, 0x2e, + 0xab, 0x01, 0x91, 0x0e, 0x02, 0x30, 0x12, 0x2f, 0x52, 0x6f, 0x03, 0x2e, 0xac, 0x01, 0x91, 0x0f, 0x02, 0x30, 0x0c, + 0x2f, 0x21, 0x2e, 0xaa, 0x01, 0x0a, 0x2c, 0x12, 0x30, 0x03, 0x2e, 0xcb, 0x00, 0x8d, 0x58, 0x08, 0x89, 0x41, 0x40, + 0x11, 0x43, 0x00, 0x43, 0x25, 0x2e, 0xa1, 0x01, 0xd4, 0x6f, 0x8f, 0x52, 0x00, 0x43, 0x3a, 0x89, 0x00, 0x2e, 0x10, + 0x43, 0x10, 0x43, 0x61, 0x0e, 0xfb, 0x2f, 0x03, 0x2e, 0xa0, 0x01, 0x11, 0x1a, 0x02, 0x2f, 0x02, 0x25, 0x21, 0x2e, + 0xa0, 0x01, 0xeb, 0x6f, 0x00, 0x5f, 0xb8, 0x2e, 0x91, 0x52, 0x10, 0x30, 0x02, 0x30, 0x95, 0x56, 0x52, 0x42, 0x4b, + 0x0e, 0xfc, 0x2f, 0x8d, 0x54, 0x88, 0x82, 0x93, 0x56, 0x80, 0x42, 0x53, 0x42, 0x40, 0x42, 0x42, 0x86, 0x83, 0x54, + 0xc0, 0x2e, 0xc2, 0x42, 0x00, 0x2e, 0xa3, 0x52, 0x00, 0x51, 0x52, 0x40, 0x47, 0x40, 0x1a, 0x25, 0x01, 0x2e, 0x97, + 0x00, 0x8f, 0xbe, 0x72, 0x86, 0xfb, 0x7f, 0x0b, 0x30, 0x7c, 0xbf, 0xa5, 0x50, 0x10, 0x08, 0xdf, 0xba, 0x70, 0x88, + 0xf8, 0xbf, 0xcb, 0x42, 0xd3, 0x7f, 0x6c, 0xbb, 0xfc, 0xbb, 0xc5, 0x0a, 0x90, 0x7f, 0x1b, 0x7f, 0x0b, 0x43, 0xc0, + 0xb2, 0xe5, 0x7f, 0xb7, 0x7f, 0xa6, 0x7f, 0xc4, 0x7f, 0x90, 0x2e, 0x1c, 0xb7, 0x07, 0x2e, 0xd2, 0x00, 0xc0, 0xb2, + 0x0b, 0x2f, 0x97, 0x52, 0x01, 0x2e, 0xcd, 0x00, 0x82, 0x7f, 0x98, 0x2e, 0xbb, 0xcc, 0x0b, 0x30, 0x37, 0x2e, 0xd2, + 0x00, 0x82, 0x6f, 0x90, 0x6f, 0x1a, 0x25, 0x00, 0xb2, 0x8b, 0x7f, 0x14, 0x2f, 0xa6, 0xbd, 0x25, 0xbd, 0xb6, 0xb9, + 0x2f, 0xb9, 0x80, 0xb2, 0xd4, 0xb0, 0x0c, 0x2f, 0x99, 0x54, 0x9b, 0x56, 0x0b, 0x30, 0x0b, 0x2e, 0xb1, 0x00, 0xa1, + 0x58, 0x9b, 0x42, 0xdb, 0x42, 0x6c, 0x09, 0x2b, 0x2e, 0xb1, 0x00, 0x8b, 0x42, 0xcb, 0x42, 0x86, 0x7f, 0x73, 0x84, + 0xa7, 0x56, 0xc3, 0x08, 0x39, 0x52, 0x05, 0x50, 0x72, 0x7f, 0x63, 0x7f, 0x98, 0x2e, 0xc2, 0xc0, 0xe1, 0x6f, 0x62, + 0x6f, 0xd1, 0x0a, 0x01, 0x2e, 0xcd, 0x00, 0xd5, 0x6f, 0xc4, 0x6f, 0x72, 0x6f, 0x97, 0x52, 0x9d, 0x5c, 0x98, 0x2e, + 0x06, 0xcd, 0x23, 0x6f, 0x90, 0x6f, 0x99, 0x52, 0xc0, 0xb2, 0x04, 0xbd, 0x54, 0x40, 0xaf, 0xb9, 0x45, 0x40, 0xe1, + 0x7f, 0x02, 0x30, 0x06, 0x2f, 0xc0, 0xb2, 0x02, 0x30, 0x03, 0x2f, 0x9b, 0x5c, 0x12, 0x30, 0x94, 0x43, 0x85, 0x43, + 0x03, 0xbf, 0x6f, 0xbb, 0x80, 0xb3, 0x20, 0x2f, 0x06, 0x6f, 0x26, 0x01, 0x16, 0x6f, 0x6e, 0x03, 0x45, 0x42, 0xc0, + 0x90, 0x29, 0x2e, 0xce, 0x00, 0x9b, 0x52, 0x14, 0x2f, 0x9b, 0x5c, 0x00, 0x2e, 0x93, 0x41, 0x86, 0x41, 0xe3, 0x04, + 0xae, 0x07, 0x80, 0xab, 0x04, 0x2f, 0x80, 0x91, 0x0a, 0x2f, 0x86, 0x6f, 0x73, 0x0f, 0x07, 0x2f, 0x83, 0x6f, 0xc0, + 0xb2, 0x04, 0x2f, 0x54, 0x42, 0x45, 0x42, 0x12, 0x30, 0x04, 0x2c, 0x11, 0x30, 0x02, 0x2c, 0x11, 0x30, 0x11, 0x30, + 0x02, 0xbc, 0x0f, 0xb8, 0xd2, 0x7f, 0x00, 0xb2, 0x0a, 0x2f, 0x01, 0x2e, 0xfc, 0x00, 0x05, 0x2e, 0xc7, 0x01, 0x10, + 0x1a, 0x02, 0x2f, 0x21, 0x2e, 0xc7, 0x01, 0x03, 0x2d, 0x02, 0x2c, 0x01, 0x30, 0x01, 0x30, 0xb0, 0x6f, 0x98, 0x2e, + 0x95, 0xcf, 0xd1, 0x6f, 0xa0, 0x6f, 0x98, 0x2e, 0x95, 0xcf, 0xe2, 0x6f, 0x9f, 0x52, 0x01, 0x2e, 0xce, 0x00, 0x82, + 0x40, 0x50, 0x42, 0x0c, 0x2c, 0x42, 0x42, 0x11, 0x30, 0x23, 0x2e, 0xd2, 0x00, 0x01, 0x30, 0xb0, 0x6f, 0x98, 0x2e, + 0x95, 0xcf, 0xa0, 0x6f, 0x01, 0x30, 0x98, 0x2e, 0x95, 0xcf, 0x00, 0x2e, 0xfb, 0x6f, 0x00, 0x5f, 0xb8, 0x2e, 0x83, + 0x86, 0x01, 0x30, 0x00, 0x30, 0x94, 0x40, 0x24, 0x18, 0x06, 0x00, 0x53, 0x0e, 0x4f, 0x02, 0xf9, 0x2f, 0xb8, 0x2e, + 0xa9, 0x52, 0x00, 0x2e, 0x60, 0x40, 0x41, 0x40, 0x0d, 0xbc, 0x98, 0xbc, 0xc0, 0x2e, 0x01, 0x0a, 0x0f, 0xb8, 0xab, + 0x52, 0x53, 0x3c, 0x52, 0x40, 0x40, 0x40, 0x4b, 0x00, 0x82, 0x16, 0x26, 0xb9, 0x01, 0xb8, 0x41, 0x40, 0x10, 0x08, + 0x97, 0xb8, 0x01, 0x08, 0xc0, 0x2e, 0x11, 0x30, 0x01, 0x08, 0x43, 0x86, 0x25, 0x40, 0x04, 0x40, 0xd8, 0xbe, 0x2c, + 0x0b, 0x22, 0x11, 0x54, 0x42, 0x03, 0x80, 0x4b, 0x0e, 0xf6, 0x2f, 0xb8, 0x2e, 0x9f, 0x50, 0x10, 0x50, 0xad, 0x52, + 0x05, 0x2e, 0xd3, 0x00, 0xfb, 0x7f, 0x00, 0x2e, 0x13, 0x40, 0x93, 0x42, 0x41, 0x0e, 0xfb, 0x2f, 0x98, 0x2e, 0xa5, + 0xb7, 0x98, 0x2e, 0x87, 0xcf, 0x01, 0x2e, 0xd9, 0x00, 0x00, 0xb2, 0xfb, 0x6f, 0x0b, 0x2f, 0x01, 0x2e, 0x69, 0xf7, + 0xb1, 0x3f, 0x01, 0x08, 0x01, 0x30, 0xf0, 0x5f, 0x23, 0x2e, 0xd9, 0x00, 0x21, 0x2e, 0x69, 0xf7, 0x80, 0x2e, 0x7a, + 0xb7, 0xf0, 0x5f, 0xb8, 0x2e, 0x01, 0x2e, 0xc0, 0xf8, 0x03, 0x2e, 0xfc, 0xf5, 0x15, 0x54, 0xaf, 0x56, 0x82, 0x08, + 0x0b, 0x2e, 0x69, 0xf7, 0xcb, 0x0a, 0xb1, 0x58, 0x80, 0x90, 0xdd, 0xbe, 0x4c, 0x08, 0x5f, 0xb9, 0x59, 0x22, 0x80, + 0x90, 0x07, 0x2f, 0x03, 0x34, 0xc3, 0x08, 0xf2, 0x3a, 0x0a, 0x08, 0x02, 0x35, 0xc0, 0x90, 0x4a, 0x0a, 0x48, 0x22, + 0xc0, 0x2e, 0x23, 0x2e, 0xfc, 0xf5, 0x10, 0x50, 0xfb, 0x7f, 0x98, 0x2e, 0x56, 0xc7, 0x98, 0x2e, 0x49, 0xc3, 0x10, + 0x30, 0xfb, 0x6f, 0xf0, 0x5f, 0x21, 0x2e, 0xcc, 0x00, 0x21, 0x2e, 0xca, 0x00, 0xb8, 0x2e, 0x03, 0x2e, 0xd3, 0x00, + 0x16, 0xb8, 0x02, 0x34, 0x4a, 0x0c, 0x21, 0x2e, 0x2d, 0xf5, 0xc0, 0x2e, 0x23, 0x2e, 0xd3, 0x00, 0x03, 0xbc, 0x21, + 0x2e, 0xd5, 0x00, 0x03, 0x2e, 0xd5, 0x00, 0x40, 0xb2, 0x10, 0x30, 0x21, 0x2e, 0x77, 0x00, 0x01, 0x30, 0x05, 0x2f, + 0x05, 0x2e, 0xd8, 0x00, 0x80, 0x90, 0x01, 0x2f, 0x23, 0x2e, 0x6f, 0xf5, 0xc0, 0x2e, 0x21, 0x2e, 0xd9, 0x00, 0x11, + 0x30, 0x81, 0x08, 0x01, 0x2e, 0x6a, 0xf7, 0x71, 0x3f, 0x23, 0xbd, 0x01, 0x08, 0x02, 0x0a, 0xc0, 0x2e, 0x21, 0x2e, + 0x6a, 0xf7, 0x30, 0x25, 0x00, 0x30, 0x21, 0x2e, 0x5a, 0xf5, 0x10, 0x50, 0x21, 0x2e, 0x7b, 0x00, 0x21, 0x2e, 0x7c, + 0x00, 0xfb, 0x7f, 0x98, 0x2e, 0xc3, 0xb7, 0x40, 0x30, 0x21, 0x2e, 0xd4, 0x00, 0xfb, 0x6f, 0xf0, 0x5f, 0x03, 0x25, + 0x80, 0x2e, 0xaf, 0xb7, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, + 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, + 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, + 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x01, 0x2e, 0x5d, 0xf7, 0x08, 0xbc, 0x80, 0xac, 0x0e, 0xbb, 0x02, 0x2f, + 0x00, 0x30, 0x41, 0x04, 0x82, 0x06, 0xc0, 0xa4, 0x00, 0x30, 0x11, 0x2f, 0x40, 0xa9, 0x03, 0x2f, 0x40, 0x91, 0x0d, + 0x2f, 0x00, 0xa7, 0x0b, 0x2f, 0x80, 0xb3, 0xb3, 0x58, 0x02, 0x2f, 0x90, 0xa1, 0x26, 0x13, 0x20, 0x23, 0x80, 0x90, + 0x10, 0x30, 0x01, 0x2f, 0xcc, 0x0e, 0x00, 0x2f, 0x00, 0x30, 0xb8, 0x2e, 0xb5, 0x50, 0x18, 0x08, 0x08, 0xbc, 0x88, + 0xb6, 0x0d, 0x17, 0xc6, 0xbd, 0x56, 0xbc, 0xb7, 0x58, 0xda, 0xba, 0x04, 0x01, 0x1d, 0x0a, 0x10, 0x50, 0x05, 0x30, + 0x32, 0x25, 0x45, 0x03, 0xfb, 0x7f, 0xf6, 0x30, 0x21, 0x25, 0x98, 0x2e, 0x37, 0xca, 0x16, 0xb5, 0x9a, 0xbc, 0x06, + 0xb8, 0x80, 0xa8, 0x41, 0x0a, 0x0e, 0x2f, 0x80, 0x90, 0x02, 0x2f, 0x2d, 0x50, 0x48, 0x0f, 0x09, 0x2f, 0xbf, 0xa0, + 0x04, 0x2f, 0xbf, 0x90, 0x06, 0x2f, 0xb7, 0x54, 0xca, 0x0f, 0x03, 0x2f, 0x00, 0x2e, 0x02, 0x2c, 0xb7, 0x52, 0x2d, + 0x52, 0xf2, 0x33, 0x98, 0x2e, 0xd9, 0xc0, 0xfb, 0x6f, 0xf1, 0x37, 0xc0, 0x2e, 0x01, 0x08, 0xf0, 0x5f, 0xbf, 0x56, + 0xb9, 0x54, 0xd0, 0x40, 0xc4, 0x40, 0x0b, 0x2e, 0xfd, 0xf3, 0xbf, 0x52, 0x90, 0x42, 0x94, 0x42, 0x95, 0x42, 0x05, + 0x30, 0xc1, 0x50, 0x0f, 0x88, 0x06, 0x40, 0x04, 0x41, 0x96, 0x42, 0xc5, 0x42, 0x48, 0xbe, 0x73, 0x30, 0x0d, 0x2e, + 0xd8, 0x00, 0x4f, 0xba, 0x84, 0x42, 0x03, 0x42, 0x81, 0xb3, 0x02, 0x2f, 0x2b, 0x2e, 0x6f, 0xf5, 0x06, 0x2d, 0x05, + 0x2e, 0x77, 0xf7, 0xbd, 0x56, 0x93, 0x08, 0x25, 0x2e, 0x77, 0xf7, 0xbb, 0x54, 0x25, 0x2e, 0xc2, 0xf5, 0x07, 0x2e, + 0xfd, 0xf3, 0x42, 0x30, 0xb4, 0x33, 0xda, 0x0a, 0x4c, 0x00, 0x27, 0x2e, 0xfd, 0xf3, 0x43, 0x40, 0xd4, 0x3f, 0xdc, + 0x08, 0x43, 0x42, 0x00, 0x2e, 0x00, 0x2e, 0x43, 0x40, 0x24, 0x30, 0xdc, 0x0a, 0x43, 0x42, 0x04, 0x80, 0x03, 0x2e, + 0xfd, 0xf3, 0x4a, 0x0a, 0x23, 0x2e, 0xfd, 0xf3, 0x61, 0x34, 0xc0, 0x2e, 0x01, 0x42, 0x00, 0x2e, 0x60, 0x50, 0x1a, + 0x25, 0x7a, 0x86, 0xe0, 0x7f, 0xf3, 0x7f, 0x03, 0x25, 0xc3, 0x52, 0x41, 0x84, 0xdb, 0x7f, 0x33, 0x30, 0x98, 0x2e, + 0x16, 0xc2, 0x1a, 0x25, 0x7d, 0x82, 0xf0, 0x6f, 0xe2, 0x6f, 0x32, 0x25, 0x16, 0x40, 0x94, 0x40, 0x26, 0x01, 0x85, + 0x40, 0x8e, 0x17, 0xc4, 0x42, 0x6e, 0x03, 0x95, 0x42, 0x41, 0x0e, 0xf4, 0x2f, 0xdb, 0x6f, 0xa0, 0x5f, 0xb8, 0x2e, + 0xb0, 0x51, 0xfb, 0x7f, 0x98, 0x2e, 0xe8, 0x0d, 0x5a, 0x25, 0x98, 0x2e, 0x0f, 0x0e, 0xcb, 0x58, 0x32, 0x87, 0xc4, + 0x7f, 0x65, 0x89, 0x6b, 0x8d, 0xc5, 0x5a, 0x65, 0x7f, 0xe1, 0x7f, 0x83, 0x7f, 0xa6, 0x7f, 0x74, 0x7f, 0xd0, 0x7f, + 0xb6, 0x7f, 0x94, 0x7f, 0x17, 0x30, 0xc7, 0x52, 0xc9, 0x54, 0x51, 0x7f, 0x00, 0x2e, 0x85, 0x6f, 0x42, 0x7f, 0x00, + 0x2e, 0x51, 0x41, 0x45, 0x81, 0x42, 0x41, 0x13, 0x40, 0x3b, 0x8a, 0x00, 0x40, 0x4b, 0x04, 0xd0, 0x06, 0xc0, 0xac, + 0x85, 0x7f, 0x02, 0x2f, 0x02, 0x30, 0x51, 0x04, 0xd3, 0x06, 0x41, 0x84, 0x05, 0x30, 0x5d, 0x02, 0xc9, 0x16, 0xdf, + 0x08, 0xd3, 0x00, 0x8d, 0x02, 0xaf, 0xbc, 0xb1, 0xb9, 0x59, 0x0a, 0x65, 0x6f, 0x11, 0x43, 0xa1, 0xb4, 0x52, 0x41, + 0x53, 0x41, 0x01, 0x43, 0x34, 0x7f, 0x65, 0x7f, 0x26, 0x31, 0xe5, 0x6f, 0xd4, 0x6f, 0x98, 0x2e, 0x37, 0xca, 0x32, + 0x6f, 0x75, 0x6f, 0x83, 0x40, 0x42, 0x41, 0x23, 0x7f, 0x12, 0x7f, 0xf6, 0x30, 0x40, 0x25, 0x51, 0x25, 0x98, 0x2e, + 0x37, 0xca, 0x14, 0x6f, 0x20, 0x05, 0x70, 0x6f, 0x25, 0x6f, 0x69, 0x07, 0xa2, 0x6f, 0x31, 0x6f, 0x0b, 0x30, 0x04, + 0x42, 0x9b, 0x42, 0x8b, 0x42, 0x55, 0x42, 0x32, 0x7f, 0x40, 0xa9, 0xc3, 0x6f, 0x71, 0x7f, 0x02, 0x30, 0xd0, 0x40, + 0xc3, 0x7f, 0x03, 0x2f, 0x40, 0x91, 0x15, 0x2f, 0x00, 0xa7, 0x13, 0x2f, 0x00, 0xa4, 0x11, 0x2f, 0x84, 0xbd, 0x98, + 0x2e, 0x79, 0xca, 0x55, 0x6f, 0xb7, 0x54, 0x54, 0x41, 0x82, 0x00, 0xf3, 0x3f, 0x45, 0x41, 0xcb, 0x02, 0xf6, 0x30, + 0x98, 0x2e, 0x37, 0xca, 0x35, 0x6f, 0xa4, 0x6f, 0x41, 0x43, 0x03, 0x2c, 0x00, 0x43, 0xa4, 0x6f, 0x35, 0x6f, 0x17, + 0x30, 0x42, 0x6f, 0x51, 0x6f, 0x93, 0x40, 0x42, 0x82, 0x00, 0x41, 0xc3, 0x00, 0x03, 0x43, 0x51, 0x7f, 0x00, 0x2e, + 0x94, 0x40, 0x41, 0x41, 0x4c, 0x02, 0xc4, 0x6f, 0xd1, 0x56, 0x63, 0x0e, 0x74, 0x6f, 0x51, 0x43, 0xa5, 0x7f, 0x8a, + 0x2f, 0x09, 0x2e, 0xd8, 0x00, 0x01, 0xb3, 0x21, 0x2f, 0xcb, 0x58, 0x90, 0x6f, 0x13, 0x41, 0xb6, 0x6f, 0xe4, 0x7f, + 0x00, 0x2e, 0x91, 0x41, 0x14, 0x40, 0x92, 0x41, 0x15, 0x40, 0x17, 0x2e, 0x6f, 0xf5, 0xb6, 0x7f, 0xd0, 0x7f, 0xcb, + 0x7f, 0x98, 0x2e, 0x00, 0x0c, 0x07, 0x15, 0xc2, 0x6f, 0x14, 0x0b, 0x29, 0x2e, 0x6f, 0xf5, 0xc3, 0xa3, 0xc1, 0x8f, + 0xe4, 0x6f, 0xd0, 0x6f, 0xe6, 0x2f, 0x14, 0x30, 0x05, 0x2e, 0x6f, 0xf5, 0x14, 0x0b, 0x29, 0x2e, 0x6f, 0xf5, 0x18, + 0x2d, 0xcd, 0x56, 0x04, 0x32, 0xb5, 0x6f, 0x1c, 0x01, 0x51, 0x41, 0x52, 0x41, 0xc3, 0x40, 0xb5, 0x7f, 0xe4, 0x7f, + 0x98, 0x2e, 0x1f, 0x0c, 0xe4, 0x6f, 0x21, 0x87, 0x00, 0x43, 0x04, 0x32, 0xcf, 0x54, 0x5a, 0x0e, 0xef, 0x2f, 0x15, + 0x54, 0x09, 0x2e, 0x77, 0xf7, 0x22, 0x0b, 0x29, 0x2e, 0x77, 0xf7, 0xfb, 0x6f, 0x50, 0x5e, 0xb8, 0x2e, 0x10, 0x50, + 0x01, 0x2e, 0xd4, 0x00, 0x00, 0xb2, 0xfb, 0x7f, 0x51, 0x2f, 0x01, 0xb2, 0x48, 0x2f, 0x02, 0xb2, 0x42, 0x2f, 0x03, + 0x90, 0x56, 0x2f, 0xd7, 0x52, 0x79, 0x80, 0x42, 0x40, 0x81, 0x84, 0x00, 0x40, 0x42, 0x42, 0x98, 0x2e, 0x93, 0x0c, + 0xd9, 0x54, 0xd7, 0x50, 0xa1, 0x40, 0x98, 0xbd, 0x82, 0x40, 0x3e, 0x82, 0xda, 0x0a, 0x44, 0x40, 0x8b, 0x16, 0xe3, + 0x00, 0x53, 0x42, 0x00, 0x2e, 0x43, 0x40, 0x9a, 0x02, 0x52, 0x42, 0x00, 0x2e, 0x41, 0x40, 0x15, 0x54, 0x4a, 0x0e, + 0x3a, 0x2f, 0x3a, 0x82, 0x00, 0x30, 0x41, 0x40, 0x21, 0x2e, 0x85, 0x0f, 0x40, 0xb2, 0x0a, 0x2f, 0x98, 0x2e, 0xb1, + 0x0c, 0x98, 0x2e, 0x45, 0x0e, 0x98, 0x2e, 0x5b, 0x0e, 0xfb, 0x6f, 0xf0, 0x5f, 0x00, 0x30, 0x80, 0x2e, 0xce, 0xb7, + 0xdd, 0x52, 0xd3, 0x54, 0x42, 0x42, 0x4f, 0x84, 0x73, 0x30, 0xdb, 0x52, 0x83, 0x42, 0x1b, 0x30, 0x6b, 0x42, 0x23, + 0x30, 0x27, 0x2e, 0xd7, 0x00, 0x37, 0x2e, 0xd4, 0x00, 0x21, 0x2e, 0xd6, 0x00, 0x7a, 0x84, 0x17, 0x2c, 0x42, 0x42, + 0x30, 0x30, 0x21, 0x2e, 0xd4, 0x00, 0x12, 0x2d, 0x21, 0x30, 0x00, 0x30, 0x23, 0x2e, 0xd4, 0x00, 0x21, 0x2e, 0x7b, + 0xf7, 0x0b, 0x2d, 0x17, 0x30, 0x98, 0x2e, 0x51, 0x0c, 0xd5, 0x50, 0x0c, 0x82, 0x72, 0x30, 0x2f, 0x2e, 0xd4, 0x00, + 0x25, 0x2e, 0x7b, 0xf7, 0x40, 0x42, 0x00, 0x2e, 0xfb, 0x6f, 0xf0, 0x5f, 0xb8, 0x2e, 0x70, 0x50, 0x0a, 0x25, 0x39, + 0x86, 0xfb, 0x7f, 0xe1, 0x32, 0x62, 0x30, 0x98, 0x2e, 0xc2, 0xc4, 0xb5, 0x56, 0xa5, 0x6f, 0xab, 0x08, 0x91, 0x6f, + 0x4b, 0x08, 0xdf, 0x56, 0xc4, 0x6f, 0x23, 0x09, 0x4d, 0xba, 0x93, 0xbc, 0x8c, 0x0b, 0xd1, 0x6f, 0x0b, 0x09, 0xcb, + 0x52, 0xe1, 0x5e, 0x56, 0x42, 0xaf, 0x09, 0x4d, 0xba, 0x23, 0xbd, 0x94, 0x0a, 0xe5, 0x6f, 0x68, 0xbb, 0xeb, 0x08, + 0xbd, 0xb9, 0x63, 0xbe, 0xfb, 0x6f, 0x52, 0x42, 0xe3, 0x0a, 0xc0, 0x2e, 0x43, 0x42, 0x90, 0x5f, 0xd1, 0x50, 0x03, + 0x2e, 0x25, 0xf3, 0x13, 0x40, 0x00, 0x40, 0x9b, 0xbc, 0x9b, 0xb4, 0x08, 0xbd, 0xb8, 0xb9, 0x98, 0xbc, 0xda, 0x0a, + 0x08, 0xb6, 0x89, 0x16, 0xc0, 0x2e, 0x19, 0x00, 0x62, 0x02, 0x10, 0x50, 0xfb, 0x7f, 0x98, 0x2e, 0x81, 0x0d, 0x01, + 0x2e, 0xd4, 0x00, 0x31, 0x30, 0x08, 0x04, 0xfb, 0x6f, 0x01, 0x30, 0xf0, 0x5f, 0x23, 0x2e, 0xd6, 0x00, 0x21, 0x2e, + 0xd7, 0x00, 0xb8, 0x2e, 0x01, 0x2e, 0xd7, 0x00, 0x03, 0x2e, 0xd6, 0x00, 0x48, 0x0e, 0x01, 0x2f, 0x80, 0x2e, 0x1f, + 0x0e, 0xb8, 0x2e, 0xe3, 0x50, 0x21, 0x34, 0x01, 0x42, 0x82, 0x30, 0xc1, 0x32, 0x25, 0x2e, 0x62, 0xf5, 0x01, 0x00, + 0x22, 0x30, 0x01, 0x40, 0x4a, 0x0a, 0x01, 0x42, 0xb8, 0x2e, 0xe3, 0x54, 0xf0, 0x3b, 0x83, 0x40, 0xd8, 0x08, 0xe5, + 0x52, 0x83, 0x42, 0x00, 0x30, 0x83, 0x30, 0x50, 0x42, 0xc4, 0x32, 0x27, 0x2e, 0x64, 0xf5, 0x94, 0x00, 0x50, 0x42, + 0x40, 0x42, 0xd3, 0x3f, 0x84, 0x40, 0x7d, 0x82, 0xe3, 0x08, 0x40, 0x42, 0x83, 0x42, 0xb8, 0x2e, 0xdd, 0x52, 0x00, + 0x30, 0x40, 0x42, 0x7c, 0x86, 0xb9, 0x52, 0x09, 0x2e, 0x70, 0x0f, 0xbf, 0x54, 0xc4, 0x42, 0xd3, 0x86, 0x54, 0x40, + 0x55, 0x40, 0x94, 0x42, 0x85, 0x42, 0x21, 0x2e, 0xd7, 0x00, 0x42, 0x40, 0x25, 0x2e, 0xfd, 0xf3, 0xc0, 0x42, 0x7e, + 0x82, 0x05, 0x2e, 0x7d, 0x00, 0x80, 0xb2, 0x14, 0x2f, 0x05, 0x2e, 0x89, 0x00, 0x27, 0xbd, 0x2f, 0xb9, 0x80, 0x90, + 0x02, 0x2f, 0x21, 0x2e, 0x6f, 0xf5, 0x0c, 0x2d, 0x07, 0x2e, 0x71, 0x0f, 0x14, 0x30, 0x1c, 0x09, 0x05, 0x2e, 0x77, + 0xf7, 0xbd, 0x56, 0x47, 0xbe, 0x93, 0x08, 0x94, 0x0a, 0x25, 0x2e, 0x77, 0xf7, 0xe7, 0x54, 0x50, 0x42, 0x4a, 0x0e, + 0xfc, 0x2f, 0xb8, 0x2e, 0x50, 0x50, 0x02, 0x30, 0x43, 0x86, 0xe5, 0x50, 0xfb, 0x7f, 0xe3, 0x7f, 0xd2, 0x7f, 0xc0, + 0x7f, 0xb1, 0x7f, 0x00, 0x2e, 0x41, 0x40, 0x00, 0x40, 0x48, 0x04, 0x98, 0x2e, 0x74, 0xc0, 0x1e, 0xaa, 0xd3, 0x6f, + 0x14, 0x30, 0xb1, 0x6f, 0xe3, 0x22, 0xc0, 0x6f, 0x52, 0x40, 0xe4, 0x6f, 0x4c, 0x0e, 0x12, 0x42, 0xd3, 0x7f, 0xeb, + 0x2f, 0x03, 0x2e, 0x86, 0x0f, 0x40, 0x90, 0x11, 0x30, 0x03, 0x2f, 0x23, 0x2e, 0x86, 0x0f, 0x02, 0x2c, 0x00, 0x30, + 0xd0, 0x6f, 0xfb, 0x6f, 0xb0, 0x5f, 0xb8, 0x2e, 0x40, 0x50, 0xf1, 0x7f, 0x0a, 0x25, 0x3c, 0x86, 0xeb, 0x7f, 0x41, + 0x33, 0x22, 0x30, 0x98, 0x2e, 0xc2, 0xc4, 0xd3, 0x6f, 0xf4, 0x30, 0xdc, 0x09, 0x47, 0x58, 0xc2, 0x6f, 0x94, 0x09, + 0xeb, 0x58, 0x6a, 0xbb, 0xdc, 0x08, 0xb4, 0xb9, 0xb1, 0xbd, 0xe9, 0x5a, 0x95, 0x08, 0x21, 0xbd, 0xf6, 0xbf, 0x77, + 0x0b, 0x51, 0xbe, 0xf1, 0x6f, 0xeb, 0x6f, 0x52, 0x42, 0x54, 0x42, 0xc0, 0x2e, 0x43, 0x42, 0xc0, 0x5f, 0x50, 0x50, + 0xf5, 0x50, 0x31, 0x30, 0x11, 0x42, 0xfb, 0x7f, 0x7b, 0x30, 0x0b, 0x42, 0x11, 0x30, 0x02, 0x80, 0x23, 0x33, 0x01, + 0x42, 0x03, 0x00, 0x07, 0x2e, 0x80, 0x03, 0x05, 0x2e, 0xd3, 0x00, 0x23, 0x52, 0xe2, 0x7f, 0xd3, 0x7f, 0xc0, 0x7f, + 0x98, 0x2e, 0xb6, 0x0e, 0xd1, 0x6f, 0x08, 0x0a, 0x1a, 0x25, 0x7b, 0x86, 0xd0, 0x7f, 0x01, 0x33, 0x12, 0x30, 0x98, + 0x2e, 0xc2, 0xc4, 0xd1, 0x6f, 0x08, 0x0a, 0x00, 0xb2, 0x0d, 0x2f, 0xe3, 0x6f, 0x01, 0x2e, 0x80, 0x03, 0x51, 0x30, + 0xc7, 0x86, 0x23, 0x2e, 0x21, 0xf2, 0x08, 0xbc, 0xc0, 0x42, 0x98, 0x2e, 0xa5, 0xb7, 0x00, 0x2e, 0x00, 0x2e, 0xd0, + 0x2e, 0xb0, 0x6f, 0x0b, 0xb8, 0x03, 0x2e, 0x1b, 0x00, 0x08, 0x1a, 0xb0, 0x7f, 0x70, 0x30, 0x04, 0x2f, 0x21, 0x2e, + 0x21, 0xf2, 0x00, 0x2e, 0x00, 0x2e, 0xd0, 0x2e, 0x98, 0x2e, 0x6d, 0xc0, 0x98, 0x2e, 0x5d, 0xc0, 0xed, 0x50, 0x98, + 0x2e, 0x44, 0xcb, 0xef, 0x50, 0x98, 0x2e, 0x46, 0xc3, 0xf1, 0x50, 0x98, 0x2e, 0x53, 0xc7, 0x35, 0x50, 0x98, 0x2e, + 0x64, 0xcf, 0x10, 0x30, 0x98, 0x2e, 0xdc, 0x03, 0x20, 0x26, 0xc0, 0x6f, 0x02, 0x31, 0x12, 0x42, 0xab, 0x33, 0x0b, + 0x42, 0x37, 0x80, 0x01, 0x30, 0x01, 0x42, 0xf3, 0x37, 0xf7, 0x52, 0xfb, 0x50, 0x44, 0x40, 0xa2, 0x0a, 0x42, 0x42, + 0x8b, 0x31, 0x09, 0x2e, 0x5e, 0xf7, 0xf9, 0x54, 0xe3, 0x08, 0x83, 0x42, 0x1b, 0x42, 0x23, 0x33, 0x4b, 0x00, 0xbc, + 0x84, 0x0b, 0x40, 0x33, 0x30, 0x83, 0x42, 0x0b, 0x42, 0xe0, 0x7f, 0xd1, 0x7f, 0x98, 0x2e, 0x58, 0xb7, 0xd1, 0x6f, + 0x80, 0x30, 0x40, 0x42, 0x03, 0x30, 0xe0, 0x6f, 0xf3, 0x54, 0x04, 0x30, 0x00, 0x2e, 0x00, 0x2e, 0x01, 0x89, 0x62, + 0x0e, 0xfa, 0x2f, 0x43, 0x42, 0x11, 0x30, 0xfb, 0x6f, 0xc0, 0x2e, 0x01, 0x42, 0xb0, 0x5f, 0xc1, 0x4a, 0x00, 0x00, + 0x6d, 0x57, 0x00, 0x00, 0x77, 0x8e, 0x00, 0x00, 0xe0, 0xff, 0xff, 0xff, 0xd3, 0xff, 0xff, 0xff, 0xe5, 0xff, 0xff, + 0xff, 0xee, 0xe1, 0xff, 0xff, 0x7c, 0x13, 0x00, 0x00, 0x46, 0xe6, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x2e, 0x00, 0xc1, 0x80, + 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, + 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, + 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, + 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, + 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, + 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, + 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, + 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, + 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, + 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, + 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, + 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, + 0x2e, 0x00, 0xc1 + +}; + +} // namespace esphome::bmi270 diff --git a/esphome/components/bmi270/motion.py b/esphome/components/bmi270/motion.py new file mode 100644 index 0000000000..c1616665f9 --- /dev/null +++ b/esphome/components/bmi270/motion.py @@ -0,0 +1,91 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.const import ( + CONF_ACCELEROMETER_ODR, + CONF_ACCELEROMETER_RANGE, + CONF_GYROSCOPE_ODR, + CONF_GYROSCOPE_RANGE, +) +from esphome.components.motion import motion_schema, new_motion_component +import esphome.config_validation as cv + +from . import BMI270Component, bmi270_ns + +DEPENDENCIES = ["i2c"] + +# Enum proxies (must match the C++ enum values exactly) +BMI270AccelRange = bmi270_ns.enum("BMI270AccelRange") +ACCEL_RANGE_OPTIONS = { + "2G": BMI270AccelRange.BMI270_ACCEL_RANGE_2G, + "4G": BMI270AccelRange.BMI270_ACCEL_RANGE_4G, + "8G": BMI270AccelRange.BMI270_ACCEL_RANGE_8G, + "16G": BMI270AccelRange.BMI270_ACCEL_RANGE_16G, +} + +BMI270GyroRange = bmi270_ns.enum("BMI270GyroRange") +GYRO_RANGE_OPTIONS = { + "2000DPS": BMI270GyroRange.BMI270_GYRO_RANGE_2000, + "1000DPS": BMI270GyroRange.BMI270_GYRO_RANGE_1000, + "500DPS": BMI270GyroRange.BMI270_GYRO_RANGE_500, + "250DPS": BMI270GyroRange.BMI270_GYRO_RANGE_250, + "125DPS": BMI270GyroRange.BMI270_GYRO_RANGE_125, +} + +BMI270AccelODR = bmi270_ns.enum("BMI270AccelODR") +ACCEL_ODR_OPTIONS = { + "12_5HZ": BMI270AccelODR.BMI270_ACCEL_ODR_12_5, + "25HZ": BMI270AccelODR.BMI270_ACCEL_ODR_25, + "50HZ": BMI270AccelODR.BMI270_ACCEL_ODR_50, + "100HZ": BMI270AccelODR.BMI270_ACCEL_ODR_100, + "200HZ": BMI270AccelODR.BMI270_ACCEL_ODR_200, + "400HZ": BMI270AccelODR.BMI270_ACCEL_ODR_400, + "800HZ": BMI270AccelODR.BMI270_ACCEL_ODR_800, + "1600HZ": BMI270AccelODR.BMI270_ACCEL_ODR_1600, +} + +BMI270GyroODR = bmi270_ns.enum("BMI270GyroODR") +GYRO_ODR_OPTIONS = { + "25HZ": BMI270GyroODR.BMI270_GYRO_ODR_25, + "50HZ": BMI270GyroODR.BMI270_GYRO_ODR_50, + "100HZ": BMI270GyroODR.BMI270_GYRO_ODR_100, + "200HZ": BMI270GyroODR.BMI270_GYRO_ODR_200, + "400HZ": BMI270GyroODR.BMI270_GYRO_ODR_400, + "800HZ": BMI270GyroODR.BMI270_GYRO_ODR_800, + "1600HZ": BMI270GyroODR.BMI270_GYRO_ODR_1600, + "3200HZ": BMI270GyroODR.BMI270_GYRO_ODR_3200, +} + +# Top-level CONFIG_SCHEMA +CONFIG_SCHEMA = ( + motion_schema(BMI270Component, has_accel=True, has_gyro=True) + .extend( + { + cv.Optional(CONF_ACCELEROMETER_RANGE, default="4G"): cv.enum( + ACCEL_RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_ACCELEROMETER_ODR, default="100HZ"): cv.enum( + ACCEL_ODR_OPTIONS, upper=True + ), + cv.Optional(CONF_GYROSCOPE_RANGE, default="2000DPS"): cv.enum( + GYRO_RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_GYROSCOPE_ODR, default="200HZ"): cv.enum( + GYRO_ODR_OPTIONS, upper=True + ), + } + ) + .extend(i2c.i2c_device_schema(0x68)) +) + + +# Code generation +async def to_code(config): + var = await new_motion_component(config) + await i2c.register_i2c_device(var, config) + + # Accelerometer sensors + # Hardware configuration + cg.add(var.set_accel_range(config[CONF_ACCELEROMETER_RANGE])) + cg.add(var.set_accel_odr(config[CONF_ACCELEROMETER_ODR])) + cg.add(var.set_gyro_range(config[CONF_GYROSCOPE_RANGE])) + cg.add(var.set_gyro_odr(config[CONF_GYROSCOPE_ODR])) diff --git a/esphome/components/bmi270/sensor.py b/esphome/components/bmi270/sensor.py new file mode 100644 index 0000000000..69235ed8dc --- /dev/null +++ b/esphome/components/bmi270/sensor.py @@ -0,0 +1,41 @@ +# YAML config keys +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_TEMPERATURE, + CONF_TYPE, + DEVICE_CLASS_TEMPERATURE, + ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) +from esphome.cpp_generator import MockObj + +from . import CONF_BMI270_ID, BMI270Component + +AUTO_LOAD = ["bmi270"] + +CONFIG_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_TEMPERATURE, +).extend( + { + cv.Optional(CONF_TYPE): cv.one_of(CONF_TEMPERATURE), + cv.GenerateID(CONF_BMI270_ID): cv.use_id(BMI270Component), + } +) + + +async def to_code(config): + var = await sensor.new_sensor(config) + parent = await cg.get_variable(config[CONF_BMI270_ID]) + data = MockObj("data") + value_lambda = await cg.process_lambda( + var.publish_state(data), + [(cg.float_, str(data))], + ) + cg.add(parent.add_temperature_listener(value_lambda)) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index ebb4186a2b..9951243f0d 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -5,6 +5,8 @@ CODEOWNERS = ["@esphome/core"] BYTE_ORDER_LITTLE = "little_endian" BYTE_ORDER_BIG = "big_endian" +CONF_ACCELEROMETER_ODR = "accelerometer_odr" +CONF_ACCELEROMETER_RANGE = "accelerometer_range" CONF_B_CONSTANT = "b_constant" CONF_BYTE_ORDER = "byte_order" CONF_CLIMATE_ID = "climate_id" @@ -13,6 +15,8 @@ CONF_CRC_ENABLE = "crc_enable" CONF_DATA_BITS = "data_bits" CONF_DRAW_ROUNDING = "draw_rounding" CONF_ENABLED = "enabled" +CONF_GYROSCOPE_ODR = "gyroscope_odr" +CONF_GYROSCOPE_RANGE = "gyroscope_range" CONF_IGNORE_NOT_FOUND = "ignore_not_found" CONF_LIBRETINY = "libretiny" CONF_LOOP = "loop" diff --git a/tests/components/bmi270/common.yaml b/tests/components/bmi270/common.yaml new file mode 100644 index 0000000000..0ffb1c6281 --- /dev/null +++ b/tests/components/bmi270/common.yaml @@ -0,0 +1,68 @@ +sensor: + - platform: bmi270 + name: "BMI270 Temperature" + + - platform: motion + type: acceleration_x + name: "Accel X" + accuracy_decimals: 4 + filters: + - sliding_window_moving_average: + window_size: 4 + send_every: 1 + - platform: motion + type: acceleration_y + name: "Accel Y" + accuracy_decimals: 4 + - platform: motion + type: acceleration_z + name: "Accel Z" + accuracy_decimals: 4 + + # Gyroscope axes (unit: °/s) + - platform: motion + type: gyroscope_x + name: "Gyro X" + - platform: motion + type: gyroscope_y + name: "Gyro Y" + - platform: motion + type: gyroscope_z + name: "Gyro Z" + + - platform: motion + type: angular_rate_x + name: "Angular Rate X" + - platform: motion + type: angular_rate_y + name: "Angular Rate Y" + - platform: motion + type: angular_rate_z + name: "Angular Rate Z" + + - platform: motion + type: pitch + name: "Pitch" + - platform: motion + type: roll + name: "Roll" + +motion: + - platform: bmi270 + # Accelerometer full-scale range: 2G | 4G | 8G | 16G + accelerometer_range: 4G + + # Accelerometer output data rate: 12_5HZ | 25HZ | 50HZ | 100HZ | + # 200HZ | 400HZ | 800HZ | 1600HZ + accelerometer_odr: 100HZ + + # Gyroscope full-scale range: 125DPS | 250DPS | 500DPS | 1000DPS | 2000DPS + gyroscope_range: 2000DPS + + # Gyroscope output data rate: 25HZ | 50HZ | 100HZ | 200HZ | + # 400HZ | 800HZ | 1600HZ | 3200HZ + gyroscope_odr: 200HZ + axis_map: + x: y + y: x + z: -z diff --git a/tests/components/bmi270/test.esp32-idf.yaml b/tests/components/bmi270/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/bmi270/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml From 4963ddcb95d777b0753bf7ecaca8b43b280fe45e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:09:50 -0400 Subject: [PATCH 0367/1815] [espidf] Fix idedata generation on Windows (#16894) --- esphome/espidf/idedata.py | 66 ++++++++++++++++++++--- tests/unit_tests/test_espidf_idedata.py | 70 ++++++++++++++++++++++++- 2 files changed, 127 insertions(+), 9 deletions(-) diff --git a/esphome/espidf/idedata.py b/esphome/espidf/idedata.py index 6fce8a55d9..0ed357a759 100644 --- a/esphome/espidf/idedata.py +++ b/esphome/espidf/idedata.py @@ -29,6 +29,54 @@ _INPUT_FILE_SUFFIXES = (*_CXX_SUFFIXES, ".c", ".o", ".S", ".s") _ESPHOME_SRC_MARKER = "/src/esphome/" +def _is_esphome_src(file: str) -> bool: + """Whether ``file`` is an ESPHome C++ translation unit. + + ``compile_commands.json`` ``file`` paths use the OS-native separator, so on + Windows they contain backslashes; normalize to ``/`` before testing the + marker, otherwise no source matches and the build-include union is empty. + """ + return _ESPHOME_SRC_MARKER in file.replace("\\", "/") and file.endswith( + _CXX_SUFFIXES + ) + + +def _split_command(command: str) -> list[str]: + r"""Tokenize a compile_commands.json / response-file command string. + + On Windows, tokenize per Windows ``argv`` rules via ``CommandLineToArgvW``. + ESP-IDF's compile_commands.json there mixes two backslash conventions in one + string: literal path separators in the compiler path (``C:\Users\...g++.exe``, + no quote follows) and shell quote-escaping in -D defines (``-DVER=\"1.2.3\"``). + Only the real Windows parser — where a backslash escapes solely a following + quote — handles both, and it is the exact tokenizer the compiler is launched + with. ``shlex`` cannot: POSIX mode eats the path separators, and disabling + its escape mangles the defines. + """ + if os.name != "nt": + return shlex.split(command) + + import ctypes + from ctypes import wintypes + + # CommandLineToArgvW("") returns the current process name, not []; guard it + # so an empty response file tokenizes the same as it would via shlex. + if not command.strip(): + return [] + + CommandLineToArgvW = ctypes.windll.shell32.CommandLineToArgvW + CommandLineToArgvW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_int)] + CommandLineToArgvW.restype = ctypes.POINTER(wintypes.LPWSTR) + argc = ctypes.c_int() + argv = CommandLineToArgvW(command, ctypes.byref(argc)) + if not argv: # pragma: no cover + raise ctypes.WinError() + try: + return [argv[i] for i in range(argc.value)] + finally: + ctypes.windll.kernel32.LocalFree(argv) + + def _expand_response_files(tokens: list[str], directory: Path) -> list[str]: """Inline any ``@response-file`` arguments (paths relative to ``directory``). @@ -45,7 +93,7 @@ def _expand_response_files(tokens: list[str], directory: Path) -> list[str]: try: out.extend( _expand_response_files( - shlex.split(rf.read_text(encoding="utf-8")), directory + _split_command(rf.read_text(encoding="utf-8")), directory ) ) continue @@ -64,8 +112,7 @@ def _pick_entry(entries: list[dict]) -> dict: them yields the cxx_path / cxx_flags / defines we need. """ for entry in entries: - f = entry["file"] - if _ESPHOME_SRC_MARKER in f and f.endswith(_CXX_SUFFIXES): + if _is_esphome_src(entry["file"]): return entry for entry in entries: if entry["file"].endswith(_CXX_SUFFIXES): @@ -76,18 +123,22 @@ def _pick_entry(entries: list[dict]) -> dict: def _parse_entry(entry: dict) -> tuple[str, list[str], list[str], list[str]]: """Parse one compile_commands entry -> (cxx_path, defines, includes, cxx_flags).""" directory = Path(entry["directory"]) - tokens = _expand_response_files(shlex.split(entry["command"]), directory) + tokens = _expand_response_files(_split_command(entry["command"]), directory) def _include(raw: str) -> str: # Include paths in compile_commands are interpreted relative to the # entry's ``directory`` (e.g. build-local ``-Iconfig``); resolve them # so the cached idedata is usable regardless of the consumer's cwd. + # Emit forward slashes (``normpath`` yields ``\`` on Windows) so the + # paths match the absolute, already-forward-slash entries in the JSON. raw = raw.strip() if raw and not Path(raw).is_absolute(): raw = os.path.normpath(directory / raw) - return raw + return raw.replace("\\", "/") - cxx_path = tokens[0] + # token0 is the compiler path; the rest of the command already uses forward + # slashes on Windows, so normalize it too for a consistent idedata file. + cxx_path = tokens[0].replace("\\", "/") defines: list[str] = [] includes: list[str] = [] cxx_flags: list[str] = [] @@ -161,8 +212,7 @@ def idedata_from_build(compile_commands: Path) -> dict: build_includes: dict[str, None] = {} for entry in entries: - f = entry["file"] - if _ESPHOME_SRC_MARKER not in f or not f.endswith(_CXX_SUFFIXES): + if not _is_esphome_src(entry["file"]): continue for inc in _parse_entry(entry)[2]: build_includes.setdefault(inc, None) diff --git a/tests/unit_tests/test_espidf_idedata.py b/tests/unit_tests/test_espidf_idedata.py index 849ef274ed..1088517ed1 100644 --- a/tests/unit_tests/test_espidf_idedata.py +++ b/tests/unit_tests/test_espidf_idedata.py @@ -72,7 +72,9 @@ def test_parse_entry_resolves_relative_includes() -> None: _, _, includes, _ = idedata._parse_entry(entry) def resolved(rel: str) -> str: - return os.path.normpath(Path(directory) / rel) + # _parse_entry emits forward slashes for consistency (normpath would + # yield backslashes on Windows). + return os.path.normpath(Path(directory) / rel).replace("\\", "/") assert resolved("config") in includes assert resolved("../shared") in includes # ../ normalized away @@ -124,6 +126,29 @@ def test_pick_entry_prefers_esphome_tu() -> None: assert idedata._pick_entry(entries)["file"].endswith("app.cpp") +def test_pick_entry_falls_back_to_any_cxx_tu() -> None: + """With no ``/src/esphome/`` TU present, the first C++ entry is the fallback.""" + entries = [ + _entry("/b", "/b/managed_components/foo/foo.c", "gcc -c foo.c"), + _entry("/b", "/b/components/x/x.cpp", "g++ -c x.cpp"), + ] + assert idedata._pick_entry(entries)["file"].endswith("x.cpp") + + +def test_is_esphome_src_handles_backslash_paths() -> None: + r"""The src marker must match Windows ``\src\esphome\`` paths too. + + compile_commands ``file`` entries use the OS-native separator; if the + marker only matched forward slashes no source would match on Windows and + the build-include union would be silently empty. + """ + assert idedata._is_esphome_src(r"C:\b\src\esphome\core\app.cpp") + assert idedata._is_esphome_src("/b/src/esphome/core/app.cpp") + # non-esphome and non-C++ still rejected regardless of separator + assert not idedata._is_esphome_src(r"C:\b\managed_components\x\x.cpp") + assert not idedata._is_esphome_src(r"C:\b\src\esphome\core\app.h") + + def test_idedata_from_build(tmp_path: Path) -> None: """Full transform: representative entry + include union + toolchain dirs.""" compile_commands = tmp_path / "compile_commands.json" @@ -194,3 +219,46 @@ def test_get_toolchain_includes_raises_when_no_dirs_found() -> None: pytest.raises(RuntimeError, match="builtin include dirs"), ): idedata._get_toolchain_includes("/some/compiler") + + +# ESP-IDF's compile_commands.json on Windows mixes literal backslash path +# separators in the compiler path with shell ``\"`` quote-escaping in defines, +# which only the real Windows argv parser handles. These exercise that path. +@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization") +def test_split_command_preserves_paths_and_unescapes_quotes() -> None: + r"""Backslash paths survive while ``\"`` define-quoting is unescaped.""" + command = r"C:\esp\bin\riscv32-esp-elf-g++.exe -DVER=\"1.2.3\" -IC:/inc/a -c x.cpp" + + tokens = idedata._split_command(command) + + assert tokens[0] == r"C:\esp\bin\riscv32-esp-elf-g++.exe" + assert '-DVER="1.2.3"' in tokens + assert "-IC:/inc/a" in tokens + + +@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization") +def test_split_command_empty_returns_empty() -> None: + """An empty or blank command tokenizes to ``[]`` (e.g. an empty response file). + + Guards against ``CommandLineToArgvW("")`` returning the current process name + instead of an empty list. + """ + assert idedata._split_command("") == [] + assert idedata._split_command(" ") == [] + + +@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization") +def test_parse_entry_normalizes_windows_cxx_path() -> None: + """A backslash compiler path is emitted forward-slashed; define unescaped.""" + entry = _entry( + r"C:\b", + r"C:\b\src\esphome\x.cpp", + r"C:\esp\bin\g++.exe -DVER=\"1.2.3\" -IC:/inc/a -c x.cpp", + ) + + cxx_path, defines, includes, _ = idedata._parse_entry(entry) + + assert cxx_path == "C:/esp/bin/g++.exe" + assert "\\" not in cxx_path + assert 'VER="1.2.3"' in defines + assert "C:/inc/a" in includes From e16a877745bcee26ce040109b35bc0872ef27b62 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:11:11 -0400 Subject: [PATCH 0368/1815] [platformio] De-duplicate non-ESP32 lib_deps into common:idf-component-libs (#16893) --- .clang-tidy.hash | 2 +- platformio.ini | 27 ++++++++++++++------------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 3c1c2be289..6f6339ff84 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -fe0fe4fde52c61eb40b1214675af8db44d2678c6b7bc2674d51ed4836ecf94da +442b8197be00e6fee6b1b64b07a0e3b3558188fddf1d9c510565da884687c451 diff --git a/platformio.ini b/platformio.ini index d60a4fd68d..718dfb672f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -97,6 +97,16 @@ build_flags = build_unflags = ${common.build_unflags} +; Libraries shared by the non-ESP32 embedded environments (esp8266, rp2040, +; libretiny, nrf52). On ESP32 these are provided as ESP-IDF managed components +; via the esphome/idf_component.yml manifest, so they must not be listed in the +; esp32 envs (which would double-include them). +[common:idf-component-libs] +lib_deps = + esphome/dlms_parser@1.1.0 ; dlms_meter + bblanchon/ArduinoJson@7.4.2 ; json + lvgl/lvgl@9.5.0 ; lvgl + ; This are common settings for the ESP8266 using Arduino. [common:esp8266-arduino] extends = common:arduino @@ -107,9 +117,8 @@ platform_packages = framework = arduino lib_deps = ${common:arduino.lib_deps} - esphome/dlms_parser@1.1.0 ; dlms_meter + ${common:idf-component-libs.lib_deps} fastled/FastLED@3.9.16 ; fastled_base - bblanchon/ArduinoJson@7.4.2 ; json ESP8266WiFi ; wifi (Arduino built-in) Update ; ota (Arduino built-in) ESP32Async/ESPAsyncTCP@2.0.0 ; async_tcp @@ -119,7 +128,6 @@ lib_deps = ESP8266mDNS ; mdns (Arduino built-in) DNSServer ; captive_portal (Arduino built-in) droscy/esp_wireguard@0.4.5 ; wireguard - lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common:arduino.build_flags} @@ -194,12 +202,9 @@ platform_packages = framework = arduino lib_deps = ${common:arduino.lib_deps} - esphome/dlms_parser@1.1.0 ; dlms_meter - fastled/FastLED@3.9.16 ; fastled_base + ${common:idf-component-libs.lib_deps} ayushsharma82/RPAsyncTCP@1.3.2 ; async_tcp - bblanchon/ArduinoJson@7.4.2 ; json ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base - lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common:arduino.build_flags} -DUSE_RP2040 @@ -214,11 +219,9 @@ platform = https://github.com/libretiny-eu/libretiny.git#v1.12.1 framework = arduino lib_compat_mode = soft lib_deps = - esphome/dlms_parser@1.1.0 ; dlms_meter - bblanchon/ArduinoJson@7.4.2 ; json + ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common:arduino.build_flags} -DUSE_LIBRETINY @@ -239,9 +242,7 @@ build_flags = -DUSE_NRF52 lib_deps = ${common.lib_deps_base} - esphome/dlms_parser@1.1.0 ; dlms_meter - bblanchon/ArduinoJson@7.4.2 ; json - lvgl/lvgl@9.5.0 ; lvgl + ${common:idf-component-libs.lib_deps} ; All the actual environments are defined below. From 6809af3de0a7369203287157a3388bd4d059b6e7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:12:28 -0400 Subject: [PATCH 0369/1815] [espidf] Warn when the install path is too long for Windows MAX_PATH (#16896) --- esphome/espidf/framework.py | 71 +++++++++++++ tests/unit_tests/test_espidf_framework.py | 118 ++++++++++++++++++++++ 2 files changed, 189 insertions(+) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 1bc79cc412..c0e9a0051f 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -85,6 +85,75 @@ def _get_idf_tools_path() -> Path: return CORE.data_dir / "idf" +# Windows' default MAX_PATH is 260 characters. ESP-IDF toolchains nest deeply +# below the IDF tools directory: the longest file on disk (picolibc C++ +# headers) sits ~209 characters down, but the operative number is worse -- gcc +# probes its multilib include dirs via un-normalized self-relative paths +# ("bin/../lib/gcc///../../../..//include/..."), and +# Windows checks the path string as given, before collapsing "..". Measured +# worst case (riscv32, esp-15.2.0, longest multilib + no-rtti, probing +# bits/c++config.h): ~243 characters below the tools directory. Exceeding the +# limit surfaces as cryptic build failures -- missing headers ("fatal error: +# bits/c++config.h: No such file or directory") or partial extraction +# ("cannot execute 'as'"). Warn up front so the user can shorten the path or +# enable long path support. +_WINDOWS_MAX_PATH = 260 +# Measured 243 plus a small safety margin for future toolchain growth. +_TOOLCHAIN_NESTED_PATH_LEN = 245 + + +def _windows_long_paths_enabled() -> bool: + """Return True if Windows long path support is enabled in the registry.""" + try: + import winreg # pylint: disable=import-error # Windows-only module + + with winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, + r"SYSTEM\CurrentControlSet\Control\FileSystem", + ) as key: + value, _ = winreg.QueryValueEx(key, "LongPathsEnabled") + return value == 1 + except OSError: + return False + + +def _check_windows_path_length() -> None: + """Warn when the install path is too long for Windows' MAX_PATH limit. + + No-op off Windows or when long path support is enabled. Otherwise warns if + the deepest toolchain file would exceed the 260-character limit, which makes + ESP-IDF toolchains extract incompletely and fail to build. + """ + if platform.system() != "Windows" or _windows_long_paths_enabled(): + return + tools_path = str(_get_idf_tools_path()) + projected = len(tools_path) + _TOOLCHAIN_NESTED_PATH_LEN + if projected <= _WINDOWS_MAX_PATH: + return + _LOGGER.warning( + "ESP-IDF tools path is too long for the default Windows path limit:\n" + " %s (%d characters)\n" + "ESP-IDF toolchain paths reach up to ~%d characters deeper (including the\n" + "compiler's internal 'bin/../lib/...' relative paths), projecting to ~%d\n" + "characters -- over the %d-character limit. This causes cryptic build\n" + "failures such as:\n" + " fatal error: bits/c++config.h: No such file or directory\n" + " cannot execute 'as': CreateProcess: No such file or directory\n" + "To fix, either:\n" + " - Enable Windows long path support: set\n" + " HKLM\\SYSTEM\\CurrentControlSet\\Control\\FileSystem\\LongPathsEnabled\n" + " to 1 and reboot, or\n" + " - Move your ESPHome project to a shorter path\n" + "Then delete the ESP-IDF tools directory above so the toolchain " + "reinstalls cleanly.", + tools_path, + len(tools_path), + _TOOLCHAIN_NESTED_PATH_LEN, + projected, + _WINDOWS_MAX_PATH, + ) + + def _get_framework_path(version: str) -> Path: """ Get the path to the ESPHome ESP-IDF framework directory for a specific version. @@ -705,6 +774,8 @@ def check_esp_idf_install( Returns: tuple of (framework_path, python_env_path) """ + _check_windows_path_length() + env = {} env["IDF_TOOLS_PATH"] = str(_get_idf_tools_path()) env["IDF_PATH"] = "" diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index 036c7c0454..d89b93f478 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -2,9 +2,12 @@ # pylint: disable=protected-access +from contextlib import contextmanager import io import json +import logging from pathlib import Path +import sys import tarfile from types import SimpleNamespace from unittest.mock import patch @@ -13,6 +16,7 @@ import pytest from esphome.espidf.framework import ( _check_stamp, + _check_windows_path_length, _clone_idf_with_submodules, _get_framework_path, _get_idf_tool_paths, @@ -22,6 +26,7 @@ from esphome.espidf.framework import ( _get_python_version, _parse_git_source, _patch_tools_json_for_linux_arm64, + _windows_long_paths_enabled, _write_idf_version_txt, _write_stamp, check_esp_idf_install, @@ -682,3 +687,116 @@ def test_write_idf_version_txt_warns_on_write_error(tmp_path: Path) -> None: with patch("pathlib.Path.write_text", side_effect=OSError("denied")): # write failure is caught and warned, not raised _write_idf_version_txt(tmp_path, "5.1.2") + + +def _fake_winreg( + query_result: int | None = None, query_error: OSError | None = None +) -> SimpleNamespace: + """Build a minimal winreg stand-in (the real module is Windows-only).""" + + @contextmanager + def open_key(root, path): + yield "hkey" + + def query_value_ex(key, name): + if query_error is not None: + raise query_error + return query_result, 4 # (value, REG_DWORD) + + return SimpleNamespace( + HKEY_LOCAL_MACHINE=object(), + OpenKey=open_key, + QueryValueEx=query_value_ex, + ) + + +@pytest.mark.parametrize(("reg_value", "expected"), [(1, True), (0, False)]) +def test_windows_long_paths_enabled_reads_registry( + reg_value: int, expected: bool +) -> None: + with patch.dict(sys.modules, {"winreg": _fake_winreg(query_result=reg_value)}): + assert _windows_long_paths_enabled() is expected + + +def test_windows_long_paths_enabled_missing_value() -> None: + """A missing registry value (FileNotFoundError is an OSError) reads as disabled.""" + fake = _fake_winreg(query_error=FileNotFoundError("no such value")) + with patch.dict(sys.modules, {"winreg": fake}): + assert _windows_long_paths_enabled() is False + + +# 8 chars -> projected well under the 260 limit even with the ~245-char reserve +_SHORT_IDF_PATH = "C:\\e\\idf" +# 25 chars -> projected over the limit +_LONG_IDF_PATH = "C:\\Users\\bob\\.esphome\\idf" + + +def test_check_windows_path_length_noop_off_windows( + caplog: pytest.LogCaptureFixture, +) -> None: + """Off Windows the check returns before touching the registry or the path.""" + with ( + patch("esphome.espidf.framework.platform.system", return_value="Linux"), + patch( + "esphome.espidf.framework._windows_long_paths_enabled" + ) as long_paths_mock, + caplog.at_level(logging.WARNING), + ): + _check_windows_path_length() + long_paths_mock.assert_not_called() + assert not caplog.records + + +def test_check_windows_path_length_noop_when_long_paths_enabled( + caplog: pytest.LogCaptureFixture, +) -> None: + with ( + patch("esphome.espidf.framework.platform.system", return_value="Windows"), + patch( + "esphome.espidf.framework._windows_long_paths_enabled", return_value=True + ), + patch("esphome.espidf.framework._get_idf_tools_path") as get_path_mock, + caplog.at_level(logging.WARNING), + ): + _check_windows_path_length() + get_path_mock.assert_not_called() + assert not caplog.records + + +def test_check_windows_path_length_short_path_silent( + caplog: pytest.LogCaptureFixture, +) -> None: + with ( + patch("esphome.espidf.framework.platform.system", return_value="Windows"), + patch( + "esphome.espidf.framework._windows_long_paths_enabled", return_value=False + ), + patch( + "esphome.espidf.framework._get_idf_tools_path", + return_value=_SHORT_IDF_PATH, + ), + caplog.at_level(logging.WARNING), + ): + _check_windows_path_length() + assert not caplog.records + + +def test_check_windows_path_length_long_path_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + with ( + patch("esphome.espidf.framework.platform.system", return_value="Windows"), + patch( + "esphome.espidf.framework._windows_long_paths_enabled", return_value=False + ), + patch( + "esphome.espidf.framework._get_idf_tools_path", + return_value=_LONG_IDF_PATH, + ), + caplog.at_level(logging.WARNING), + ): + _check_windows_path_length() + assert len(caplog.records) == 1 + message = caplog.records[0].getMessage() + assert _LONG_IDF_PATH in message + assert "long path support" in message From a25ac28ae5224e53fdac8df0a0b5a8c603222da6 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:22:44 +1000 Subject: [PATCH 0370/1815] [lsm6ds] Add motion platform for STMicro LSM6DS IMU (#16232) Co-authored-by: J. Nick Koston Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/lsm6ds/__init__.py | 15 ++ esphome/components/lsm6ds/lsm6ds.cpp | 203 ++++++++++++++++++++ esphome/components/lsm6ds/lsm6ds.h | 111 +++++++++++ esphome/components/lsm6ds/motion.py | 106 ++++++++++ esphome/components/lsm6ds/sensor.py | 39 ++++ tests/components/lsm6ds/common.yaml | 63 ++++++ tests/components/lsm6ds/test.esp32-idf.yaml | 4 + 8 files changed, 542 insertions(+) create mode 100644 esphome/components/lsm6ds/__init__.py create mode 100644 esphome/components/lsm6ds/lsm6ds.cpp create mode 100644 esphome/components/lsm6ds/lsm6ds.h create mode 100644 esphome/components/lsm6ds/motion.py create mode 100644 esphome/components/lsm6ds/sensor.py create mode 100644 tests/components/lsm6ds/common.yaml create mode 100644 tests/components/lsm6ds/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 300ae13cf4..10128c64e5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -291,6 +291,7 @@ esphome/components/lock/* @esphome/core esphome/components/logger/* @esphome/core esphome/components/logger/select/* @clydebarrow esphome/components/lps22/* @nagisa +esphome/components/lsm6ds/* @clydebarrow esphome/components/ltr390/* @latonita @sjtrny esphome/components/ltr501/* @latonita esphome/components/ltr_als_ps/* @latonita diff --git a/esphome/components/lsm6ds/__init__.py b/esphome/components/lsm6ds/__init__.py new file mode 100644 index 0000000000..b1044276a1 --- /dev/null +++ b/esphome/components/lsm6ds/__init__.py @@ -0,0 +1,15 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.motion import MotionComponent + +CODEOWNERS = ["@clydebarrow"] + +CONF_LSM6DS_ID = "lsm6ds_id" +# C++ namespace / class + +lsm6ds_ns = cg.esphome_ns.namespace("lsm6ds") +LSM6DSComponent = lsm6ds_ns.class_( + "LSM6DSComponent", + MotionComponent, + i2c.I2CDevice, +) diff --git a/esphome/components/lsm6ds/lsm6ds.cpp b/esphome/components/lsm6ds/lsm6ds.cpp new file mode 100644 index 0000000000..efdd241578 --- /dev/null +++ b/esphome/components/lsm6ds/lsm6ds.cpp @@ -0,0 +1,203 @@ +#include "lsm6ds.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" + +namespace esphome::lsm6ds { + +static const char *const TAG = "lsm6ds"; + +static const struct { + uint8_t who_am_i; + const char *const name; +} CHIP_IDS[] = {{0x69, "LSMDSO"}, {0x6A, "LSM6DS3"}}; + +void LSM6DSComponent::setup() { + MotionComponent::setup(); + uint8_t who_am_i = 0; + if (this->read_register(LSM6DS_REG_WHO_AM_I, &who_am_i, 1) != i2c::ERROR_OK) { + ESP_LOGE(TAG, "Failed to read WHO_AM_I — check wiring and I2C address"); + this->mark_failed(); + return; + } + const char *chip_name = nullptr; + for (const auto &chip : CHIP_IDS) { + if (chip.who_am_i == who_am_i) { + chip_name = chip.name; + break; + } + } + if (chip_name == nullptr) { + ESP_LOGE(TAG, "Unknown WHO_AM_I: 0x%02X", who_am_i); + this->mark_failed(LOG_STR("Unknown WHO_AM_I value")); + return; + } + ESP_LOGD(TAG, "Found %s (WHO_AM_I = 0x%02X)", chip_name, who_am_i); + this->chip_name_ = chip_name; + + // 2. Software reset — clears all registers to defaults + if (this->write_register(LSM6DS_REG_CTRL3_C, &CTRL3_C_SW_RESET, 1) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Software reset failed")); + return; + } + // Datasheet: reset bit self-clears after boot (typ. 50 µs); + delay(2); + + // 3. Enable auto-increment and block data update (BDU). + // BDU prevents reading a high-byte from one sample and a low-byte from the next. + // IF_INC is set by default after reset but we set it explicitly for clarity. + uint8_t ctrl3 = CTRL3_C_IF_INC | CTRL3_C_BDU; + if (this->write_register(LSM6DS_REG_CTRL3_C, &ctrl3, 1) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Config failed")); + return; + } + + // 4. Configure accelerometer: ODR in bits[7:4], FS in bits[3:2] + // Anti-aliasing filter bandwidth is left at power-on default (bits[1:0] = 00 = ODR/2). + uint8_t ctrl1_xl = (uint8_t) (this->accel_odr_ << 4) | (uint8_t) (this->accel_range_ << 2); + if (this->write_register(LSM6DS_REG_CTRL1_XL, &ctrl1_xl, 1) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Failed to configure accelerometer")); + return; + } + + // 5. Configure gyroscope: ODR in bits[7:4], FS_G + FS_125 in bits[3:0] + // For ±125 dps: FS_G[2:1]=00 and FS_125(bit1)=1, so gyro_range_ encodes the full nibble. + uint8_t ctrl2_g = (uint8_t) (this->gyro_odr_ << 4) | (uint8_t) (this->gyro_range_); + if (this->write_register(LSM6DS_REG_CTRL2_G, &ctrl2_g, 1) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Failed to configure gyroscope")); + return; + } + + // 6. Ensure accelerometer is in high-performance mode (CTRL6_C bit 4 = XL_HM_MODE = 0) + // and gyroscope is in high-performance mode (CTRL7_G bit 7 = G_HM_MODE = 0). + // Both default to 0 (high-performance) after reset, but write explicitly. + uint8_t zero = 0x00; + if (this->write_register(LSM6DS_REG_CTRL6_C, &zero, 1) != i2c::ERROR_OK) { + this->mark_failed(); + return; + } + if (this->write_register(LSM6DS_REG_CTRL7_G, &zero, 1) != i2c::ERROR_OK) { + this->mark_failed(); + return; + } +} + +void LSM6DSComponent::dump_config() { + ESP_LOGCONFIG(TAG, + "LSM6DS IMU:\n" + " Chip type: %s\n", + this->chip_name_); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + + // Accel range — index into the sensitivity table (datasheet Table 3) + static const char *const ACCEL_RANGE_STR[] = {"±2g", "±16g", "±4g", "±8g"}; + + const char *gyro_str; + switch (this->gyro_range_) { + case LSM6DS_GYRO_RANGE_125: + gyro_str = "±125dps"; + break; + case LSM6DS_GYRO_RANGE_250: + gyro_str = "±250dps"; + break; + case LSM6DS_GYRO_RANGE_500: + gyro_str = "±500dps"; + break; + case LSM6DS_GYRO_RANGE_1000: + gyro_str = "±1000dps"; + break; + case LSM6DS_GYRO_RANGE_2000: + gyro_str = "±2000dps"; + break; + default: + gyro_str = "unknown"; + break; + } + auto accel_odr = this->accel_odr_ == 0 ? 0 : 13 * (1 << (this->accel_odr_ - 1)); + auto gyro_odr = this->gyro_odr_ == 0 ? 0 : 13 * (1 << (this->gyro_odr_ - 1)); + ESP_LOGCONFIG(TAG, + " Accel range : %s\n" + " Accel data rate : %dHz\n" + " Gyro range : %s\n" + " Gyro data rate : %dHz", + ACCEL_RANGE_STR[this->accel_range_], accel_odr, gyro_str, gyro_odr); +} + +// update_data() +// Called by MotionComponent::update() on each polling interval. +// Reads gyro XYZ and accel XYZ in a single 12-byte burst (registers 0x22–0x2D). +// Values are in g (accel) and °/s (gyro) — MotionComponent handles axis mapping +// and sensor publishing. + +bool LSM6DSComponent::update_data(motion::MotionData &data) { + if (this->is_failed()) + return false; + + // Single burst: gyro X/Y/Z (0x22–0x27) then accel X/Y/Z (0x28–0x2D) + uint8_t raw[LSM6DS_BURST_LEN]; + if (!this->read_bytes(LSM6DS_REG_OUTX_L_G, raw, LSM6DS_BURST_LEN)) { + this->status_set_error(LOG_STR("Failed to read IMU data")); + return false; + } + this->status_clear_error(); + + // Gyroscope + // Sensitivity (mdps/LSB) from datasheet Table 3. + // Multiply by 1e-3 to convert mdps → dps (°/s). + static constexpr float GYRO_SCALE[] = { + 8.75e-3f, // 0x00 — ±250 dps + 8.75e-3f, // 0x01 — unused (maps to 250 as fallback) + 4.375e-3f, // 0x02 — ±125 dps (FS_125 bit set) + 8.75e-3f, // 0x03 — unused + 17.50e-3f, // 0x04 — ±500 dps + 17.50e-3f, // 0x05 — unused + 8.75e-3f, // 0x06 — unused + 8.75e-3f, // 0x07 — unused + 35.0e-3f, // 0x08 — ±1000 dps + 35.0e-3f, // 0x09 — unused + 17.50e-3f, // 0x0A — unused + 17.50e-3f, // 0x0B — unused + 70.0e-3f, // 0x0C — ±2000 dps + }; + float gyro_scale = GYRO_SCALE[this->gyro_range_]; + + data.angular_rate[motion::X_AXIS] = (int16_t) ((raw[1] << 8) | raw[0]) * gyro_scale; + data.angular_rate[motion::Y_AXIS] = (int16_t) ((raw[3] << 8) | raw[2]) * gyro_scale; + data.angular_rate[motion::Z_AXIS] = (int16_t) ((raw[5] << 8) | raw[4]) * gyro_scale; + + // Accelerometer + // Sensitivity (mg/LSB) from datasheet Table 3. + // Multiply by 1e-3 to convert mg → g. + // Note: FS_XL register values are non-monotonic (0=2g, 1=16g, 2=4g, 3=8g). + static constexpr float ACCEL_SCALE[] = { + 0.061e-3f, // 0x00 — ±2g + 0.488e-3f, // 0x01 — ±16g + 0.122e-3f, // 0x02 — ±4g + 0.244e-3f, // 0x03 — ±8g + }; + float accel_scale = ACCEL_SCALE[this->accel_range_]; + + data.acceleration[motion::X_AXIS] = + (int16_t) ((raw[LSM6DS_ACCEL_OFFSET + 1] << 8) | raw[LSM6DS_ACCEL_OFFSET + 0]) * accel_scale; + data.acceleration[motion::Y_AXIS] = + (int16_t) ((raw[LSM6DS_ACCEL_OFFSET + 3] << 8) | raw[LSM6DS_ACCEL_OFFSET + 2]) * accel_scale; + data.acceleration[motion::Z_AXIS] = + (int16_t) ((raw[LSM6DS_ACCEL_OFFSET + 5] << 8) | raw[LSM6DS_ACCEL_OFFSET + 4]) * accel_scale; + + // Temperature (lazy — only read if a listener is registered) + // Kept as a separate 2-byte read to avoid extending the burst to 14 bytes when + // temperature is not needed. + // Formula: T(°C) = (raw / 256.0) + 25.0 (datasheet Table 90, OUT_TEMP register) + if (!this->temperature_callback_.empty()) { + uint8_t raw_t[2]; + if (this->read_bytes(LSM6DS_REG_OUT_TEMP_L, raw_t, 2)) { + int16_t temp_raw = (int16_t) ((raw_t[1] << 8) | raw_t[0]); + float temperature = (temp_raw / 256.0f) + 25.0f; + this->temperature_callback_.call(temperature); + } + } + + return true; +} + +} // namespace esphome::lsm6ds diff --git a/esphome/components/lsm6ds/lsm6ds.h b/esphome/components/lsm6ds/lsm6ds.h new file mode 100644 index 0000000000..75462ff1fb --- /dev/null +++ b/esphome/components/lsm6ds/lsm6ds.h @@ -0,0 +1,111 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/motion/motion_component.h" + +namespace esphome::lsm6ds { + +// ── Register map (datasheet DocID030071 Rev 3, Table 19) ──────────────────── +static const uint8_t LSM6DS_REG_WHO_AM_I = 0x0F; +static const uint8_t LSM6DS_REG_CTRL1_XL = 0x10; // Accel ODR + FS +static const uint8_t LSM6DS_REG_CTRL2_G = 0x11; // Gyro ODR + FS +static const uint8_t LSM6DS_REG_CTRL3_C = 0x12; // SW_RESET, BDU, IF_INC +static const uint8_t LSM6DS_REG_CTRL6_C = 0x15; // Accel HP disable, Gyro LPF1 +static const uint8_t LSM6DS_REG_CTRL7_G = 0x16; // Gyro HP disable +static const uint8_t LSM6DS_REG_STATUS = 0x1E; // XLDA, GDA, TDA +static const uint8_t LSM6DS_REG_OUT_TEMP_L = 0x20; // Temperature LSB +static const uint8_t LSM6DS_REG_OUTX_L_G = 0x22; // Gyro X LSB (burst start) +static const uint8_t LSM6DS_REG_OUTX_L_XL = 0x28; // Accel X LSB + +// Burst read from 0x22 to 0x2D inclusive: gyro XYZ (6 bytes) + accel XYZ (6 bytes) +static const uint8_t LSM6DS_BURST_LEN = 12; +static const uint8_t LSM6DS_ACCEL_OFFSET = 6; // 0x28 - 0x22 + +// ── CTRL3_C bit fields ─────────────────────────────────────────────────────── +static const uint8_t CTRL3_C_SW_RESET = (1 << 0); +static const uint8_t CTRL3_C_IF_INC = (1 << 2); // auto-increment address on burst (default 1) +static const uint8_t CTRL3_C_BDU = (1 << 6); // block data update + +// ── Accelerometer full-scale range ────────────────────────────────────────── +// CTRL1_XL bits [3:2] — FS_XL[1:0] +// Note: 0x01 = ±16g is intentional per Table 52 — the mapping is non-monotonic +enum LSM6DSAccelRange : uint8_t { + LSM6DS_ACCEL_RANGE_2G = 0x00, // ±2 g, 0.061 mg/LSB + LSM6DS_ACCEL_RANGE_16G = 0x01, // ±16 g, 0.488 mg/LSB + LSM6DS_ACCEL_RANGE_4G = 0x02, // ±4 g, 0.122 mg/LSB + LSM6DS_ACCEL_RANGE_8G = 0x03, // ±8 g, 0.244 mg/LSB +}; + +// ── Accelerometer output data rate ────────────────────────────────────────── +// CTRL1_XL bits [7:4] — ODR_XL[3:0] +enum LSM6DSAccelODR : uint8_t { + LSM6DS_ACCEL_ODR_OFF = 0x00, + LSM6DS_ACCEL_ODR_12_5 = 0x01, // 12.5 Hz + LSM6DS_ACCEL_ODR_26 = 0x02, // 26 Hz + LSM6DS_ACCEL_ODR_52 = 0x03, // 52 Hz + LSM6DS_ACCEL_ODR_104 = 0x04, // 104 Hz + LSM6DS_ACCEL_ODR_208 = 0x05, // 208 Hz + LSM6DS_ACCEL_ODR_416 = 0x06, // 416 Hz + LSM6DS_ACCEL_ODR_833 = 0x07, // 833 Hz + LSM6DS_ACCEL_ODR_1666 = 0x08, // 1666 Hz + LSM6DS_ACCEL_ODR_3332 = 0x09, // 3332 Hz + LSM6DS_ACCEL_ODR_6664 = 0x0A, // 6664 Hz +}; + +// ── Gyroscope full-scale range ─────────────────────────────────────────────── +// CTRL2_G bits [3:0] — FS_G[2:1] and FS_125 (bit 1) +// The FS_125 bit (bit 1) enables the ±125 dps range independently of FS_G. +// For all other ranges, bits [3:2] select the range and bit 1 = 0. +enum LSM6DSGyroRange : uint8_t { + LSM6DS_GYRO_RANGE_125 = 0x02, // ±125 dps, 4.375 mdps/LSB (FS_125=1) + LSM6DS_GYRO_RANGE_250 = 0x00, // ±250 dps, 8.75 mdps/LSB + LSM6DS_GYRO_RANGE_500 = 0x04, // ±500 dps, 17.50 mdps/LSB + LSM6DS_GYRO_RANGE_1000 = 0x08, // ±1000 dps, 35 mdps/LSB + LSM6DS_GYRO_RANGE_2000 = 0x0C, // ±2000 dps, 70 mdps/LSB +}; + +// ── Gyroscope output data rate ─────────────────────────────────────────────── +// CTRL2_G bits [7:4] — ODR_G[3:0] +enum LSM6DSGyroODR : uint8_t { + LSM6DS_GYRO_ODR_OFF = 0x00, + LSM6DS_GYRO_ODR_12_5 = 0x01, // 12.5 Hz + LSM6DS_GYRO_ODR_26 = 0x02, // 26 Hz + LSM6DS_GYRO_ODR_52 = 0x03, // 52 Hz + LSM6DS_GYRO_ODR_104 = 0x04, // 104 Hz + LSM6DS_GYRO_ODR_208 = 0x05, // 208 Hz + LSM6DS_GYRO_ODR_416 = 0x06, // 416 Hz + LSM6DS_GYRO_ODR_833 = 0x07, // 833 Hz + LSM6DS_GYRO_ODR_1666 = 0x08, // 1666 Hz + LSM6DS_GYRO_ODR_3332 = 0x09, // 3332 Hz + LSM6DS_GYRO_ODR_6664 = 0x0A, // 6664 Hz +}; + +// ── Main component class ───────────────────────────────────────────────────── +class LSM6DSComponent : public motion::MotionComponent, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::DATA; } + + // Configuration setters (called from Python codegen) + void set_accel_range(LSM6DSAccelRange r) { this->accel_range_ = r; } + void set_accel_odr(LSM6DSAccelODR o) { this->accel_odr_ = o; } + void set_gyro_range(LSM6DSGyroRange r) { this->gyro_range_ = r; } + void set_gyro_odr(LSM6DSGyroODR o) { this->gyro_odr_ = o; } + + template void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward(cb)); } + + protected: + const char *chip_name_{"Unknown"}; + bool update_data(motion::MotionData &data) override; + + LSM6DSAccelRange accel_range_{LSM6DS_ACCEL_RANGE_4G}; + LSM6DSAccelODR accel_odr_{LSM6DS_ACCEL_ODR_104}; + LSM6DSGyroRange gyro_range_{LSM6DS_GYRO_RANGE_2000}; + LSM6DSGyroODR gyro_odr_{LSM6DS_GYRO_ODR_208}; + + LazyCallbackManager temperature_callback_{}; +}; + +} // namespace esphome::lsm6ds diff --git a/esphome/components/lsm6ds/motion.py b/esphome/components/lsm6ds/motion.py new file mode 100644 index 0000000000..8c2c5198ea --- /dev/null +++ b/esphome/components/lsm6ds/motion.py @@ -0,0 +1,106 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.const import ( + CONF_ACCELEROMETER_ODR, + CONF_ACCELEROMETER_RANGE, + CONF_GYROSCOPE_ODR, + CONF_GYROSCOPE_RANGE, +) +from esphome.components.motion import motion_schema, new_motion_component +import esphome.config_validation as cv + +from . import LSM6DSComponent, lsm6ds_ns + +# ── Dependency declarations ────────────────────────────────────────────────── +DEPENDENCIES = ["i2c"] +DOMAIN = "lsm6ds" + +# ── C++ namespace / class ──────────────────────────────────────────────────── +# ── Enum proxies ───────────────────────────────────────────────────────────── +LSM6DSAccelRange = lsm6ds_ns.enum("LSM6DSAccelRange") +ACCEL_RANGE_OPTIONS = { + "2G": LSM6DSAccelRange.LSM6DS_ACCEL_RANGE_2G, + "4G": LSM6DSAccelRange.LSM6DS_ACCEL_RANGE_4G, + "8G": LSM6DSAccelRange.LSM6DS_ACCEL_RANGE_8G, + "16G": LSM6DSAccelRange.LSM6DS_ACCEL_RANGE_16G, +} + +LSM6DSAccelODR = lsm6ds_ns.enum("LSM6DSAccelODR") +ACCEL_ODR_OPTIONS = { + "OFF": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_OFF, + "12_5HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_12_5, + "26HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_26, + "52HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_52, + "104HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_104, + "208HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_208, + "416HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_416, + "833HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_833, + "1666HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_1666, + "3332HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_3332, + "6664HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_6664, +} + +LSM6DSGyroRange = lsm6ds_ns.enum("LSM6DSGyroRange") +GYRO_RANGE_OPTIONS = { + "125DPS": LSM6DSGyroRange.LSM6DS_GYRO_RANGE_125, + "250DPS": LSM6DSGyroRange.LSM6DS_GYRO_RANGE_250, + "500DPS": LSM6DSGyroRange.LSM6DS_GYRO_RANGE_500, + "1000DPS": LSM6DSGyroRange.LSM6DS_GYRO_RANGE_1000, + "2000DPS": LSM6DSGyroRange.LSM6DS_GYRO_RANGE_2000, +} + +LSM6DSGyroODR = lsm6ds_ns.enum("LSM6DSGyroODR") +GYRO_ODR_OPTIONS = { + "OFF": LSM6DSGyroODR.LSM6DS_GYRO_ODR_OFF, + "12_5HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_12_5, + "26HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_26, + "52HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_52, + "104HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_104, + "208HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_208, + "416HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_416, + "833HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_833, + "1666HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_1666, + "3332HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_3332, + "6664HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_6664, +} + +# ── CONFIG_SCHEMA ───────────────────────────────────────────────────────────── +# Extend the motion platform schema which provides: +# - accel_x/y/z sensor schemas +# - gyro_x/y/z sensor schemas +# - axis_mapping schema + validation +# - update_interval / polling +CONFIG_SCHEMA = ( + motion_schema(LSM6DSComponent, has_accel=True, has_gyro=True) + .extend( + { + cv.Optional(CONF_ACCELEROMETER_RANGE, default="4G"): cv.enum( + ACCEL_RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_ACCELEROMETER_ODR, default="104HZ"): cv.enum( + ACCEL_ODR_OPTIONS, upper=True + ), + cv.Optional(CONF_GYROSCOPE_RANGE, default="2000DPS"): cv.enum( + GYRO_RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_GYROSCOPE_ODR, default="208HZ"): cv.enum( + GYRO_ODR_OPTIONS, upper=True + ), + } + ) + .extend(i2c.i2c_device_schema(0x6A)) +) + + +# ── Code generation ────────────────────────────────────────────────────────── +async def to_code(config): + var = await new_motion_component(config) + + # Let the motion platform handle sensor wiring, axis mapping, and polling + await i2c.register_i2c_device(var, config) + + # Chip-specific hardware configuration + cg.add(var.set_accel_range(config[CONF_ACCELEROMETER_RANGE])) + cg.add(var.set_accel_odr(config[CONF_ACCELEROMETER_ODR])) + cg.add(var.set_gyro_range(config[CONF_GYROSCOPE_RANGE])) + cg.add(var.set_gyro_odr(config[CONF_GYROSCOPE_ODR])) diff --git a/esphome/components/lsm6ds/sensor.py b/esphome/components/lsm6ds/sensor.py new file mode 100644 index 0000000000..980e84a2e9 --- /dev/null +++ b/esphome/components/lsm6ds/sensor.py @@ -0,0 +1,39 @@ +# YAML config keys +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_TEMPERATURE, + CONF_TYPE, + DEVICE_CLASS_TEMPERATURE, + ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) +from esphome.cpp_generator import MockObj + +from . import CONF_LSM6DS_ID, LSM6DSComponent + +CONFIG_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_TEMPERATURE, +).extend( + { + cv.Optional(CONF_TYPE): CONF_TEMPERATURE, + cv.GenerateID(CONF_LSM6DS_ID): cv.use_id(LSM6DSComponent), + } +) + + +async def to_code(config): + var = await sensor.new_sensor(config) + parent = await cg.get_variable(config[CONF_LSM6DS_ID]) + data = MockObj("data") + value_lambda = await cg.process_lambda( + var.publish_state(data), + [(cg.float_, str(data))], + ) + cg.add(parent.add_temperature_listener(value_lambda)) diff --git a/tests/components/lsm6ds/common.yaml b/tests/components/lsm6ds/common.yaml new file mode 100644 index 0000000000..832254781f --- /dev/null +++ b/tests/components/lsm6ds/common.yaml @@ -0,0 +1,63 @@ +sensor: + - platform: lsm6ds + name: "lsm6ds Temperature" + + - platform: motion + type: acceleration_x + name: "Accel X" + accuracy_decimals: 4 + filters: + - sliding_window_moving_average: + window_size: 4 + send_every: 1 + - platform: motion + type: acceleration_y + name: "Accel Y" + accuracy_decimals: 4 + - platform: motion + type: acceleration_z + name: "Accel Z" + accuracy_decimals: 4 + + # Gyroscope axes (unit: °/s) + - platform: motion + type: gyroscope_x + name: "Gyro X" + - platform: motion + type: gyroscope_y + name: "Gyro Y" + - platform: motion + type: gyroscope_z + name: "Gyro Z" + + - platform: motion + type: angular_rate_x + name: "Angular Rate X" + - platform: motion + type: angular_rate_y + name: "Angular Rate Y" + - platform: motion + type: angular_rate_z + name: "Angular Rate Z" + + - platform: motion + type: pitch + name: "Pitch" + - platform: motion + type: roll + name: "Roll" + +motion: + - platform: lsm6ds + # Accelerometer full-scale range: 2G | 4G | 8G | 16G + accelerometer_range: 4G + + accelerometer_odr: 104HZ + + gyroscope_range: 2000DPS + + gyroscope_odr: 208HZ + axis_map: + x: y + y: x + z: -z diff --git a/tests/components/lsm6ds/test.esp32-idf.yaml b/tests/components/lsm6ds/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/lsm6ds/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml From dafc3560ddb897798050cabf5d7baf9e2be3fa71 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:44:58 +1000 Subject: [PATCH 0371/1815] [tests] Isolate ESPHOME_LOG_STATES in main logs-states tests (#16905) Co-authored-by: Claude Opus 4.8 --- tests/unit_tests/test_main.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index e99a630e83..03c005dc27 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -6118,6 +6118,15 @@ def test_should_subscribe_states_env_suppresses() -> None: assert _should_subscribe_states(args) is False +def test_should_subscribe_states_env_enables() -> None: + """Test that ESPHOME_LOG_STATES=true enables states by default.""" + from esphome.__main__ import _should_subscribe_states + + args = parse_args(["esphome", "logs", "device.yaml"]) + with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "true"}): + assert _should_subscribe_states(args) is True + + def test_should_subscribe_states_flag_overrides_env() -> None: """Test that --states overrides ESPHOME_LOG_STATES=false.""" from esphome.__main__ import _should_subscribe_states @@ -6202,7 +6211,11 @@ def test_command_run_defaults_subscribe_states_true( ), patch("esphome.__main__.upload_program", return_value=(0, "192.168.1.100")), patch("esphome.__main__.get_serial_ports", return_value=[]), + patch.dict(os.environ, {}, clear=False), ): + # Ensure the default behavior is not affected by an ambient + # ESPHOME_LOG_STATES set in the test runner's environment. + os.environ.pop("ESPHOME_LOG_STATES", None) result = command_run(args, CORE.config) assert result == 0 From 29a79b1373be6a0969efb5da0eb330e8407f178b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:25:03 -0400 Subject: [PATCH 0372/1815] [core] Make set_cpp_standard work on the native IDF toolchain (#16907) --- esphome/build_gen/espidf.py | 22 ++++++-- esphome/build_gen/platformio.py | 15 ++++++ esphome/core/__init__.py | 3 ++ esphome/cpp_generator.py | 11 +--- tests/unit_tests/build_gen/test_espidf.py | 50 +++++++++++++++++++ tests/unit_tests/build_gen/test_platformio.py | 40 +++++++++++++++ 6 files changed, 129 insertions(+), 12 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 9cc7a7ff12..9e11d785c0 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -8,6 +8,17 @@ import esphome.config_validation as cv from esphome.core import CORE from esphome.helpers import mkdir_p, write_file_if_changed +# Replaces the IDF default C++ standard (-std=gnu++2b appended to +# CXX_COMPILE_OPTIONS by project.cmake's __build_init) with the one set via +# cg.set_cpp_standard(). Emitted between include(project.cmake) and project(), +# i.e. after IDF appends its default and before the options are consumed, and +# applies project-wide like PlatformIO build_unflags. +CPP_STANDARD_TEMPLATE = """\ +idf_build_get_property(esphome_cxx_compile_options CXX_COMPILE_OPTIONS) +list(FILTER esphome_cxx_compile_options EXCLUDE REGEX "^-std=") +list(APPEND esphome_cxx_compile_options "-std={standard}") +idf_build_set_property(CXX_COMPILE_OPTIONS "${{esphome_cxx_compile_options}}")""" + def get_available_components() -> list[str] | None: """Get list of built-in ESP-IDF components from project_description.json. @@ -84,6 +95,12 @@ def get_project_cmakelists(minimal: bool = False) -> str: for flag in project_compile_opts ) + cpp_standard_options = ( + CPP_STANDARD_TEMPLATE.format(standard=CORE.cpp_standard) + if CORE.cpp_standard + else "" + ) + # Per-project list exposed as a CMake variable so converted PIO libs # can reference ${ESPHOME_PROJECT_MANAGED_COMPONENTS} without baking # project-specific names into their cached CMakeLists. @@ -140,6 +157,8 @@ set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src) include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) +{cpp_standard_options} + {extra_compile_options} {managed_components_property} @@ -200,9 +219,6 @@ idf_component_register( REQUIRES ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}} ) -# Apply C++ standard -target_compile_features(${{COMPONENT_LIB}} PUBLIC cxx_std_20) - # ESPHome linker options target_link_options(${{COMPONENT_LIB}} PUBLIC {link_opts_str} diff --git a/esphome/build_gen/platformio.py b/esphome/build_gen/platformio.py index 16c1597ccd..a583279ea7 100644 --- a/esphome/build_gen/platformio.py +++ b/esphome/build_gen/platformio.py @@ -33,12 +33,27 @@ def format_ini(data: dict[str, str | list[str]]) -> str: return content +# All -std= variants a platform/framework may set by default, in both the GNU +# and strict dialects; unflagged so the cg.set_cpp_standard() value is the +# only standard left in the build. +CPP_STD_VARIANTS = [ + f"{prefix}{year}" + for year in ("11", "14", "17", "20", "23", "26", "2a", "2b", "2c") + for prefix in ("gnu++", "c++") +] + + def get_ini_content(): CORE.add_platformio_option( "lib_deps", [x.as_lib_dep for x in CORE.platformio_libraries.values()] + ["${common.lib_deps}"], ) + if CORE.cpp_standard: + for variant in CPP_STD_VARIANTS: + if variant != CORE.cpp_standard: + CORE.add_build_unflag(f"-std={variant}") + CORE.add_build_flag(f"-std={CORE.cpp_standard}") # Sort to avoid changing build flags order CORE.add_platformio_option("build_flags", sorted(CORE.build_flags)) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 90c162fedd..4289cdf3e5 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -593,6 +593,8 @@ class EsphomeCore: self.build_flags: set[str] = set() # A set of build unflags to set in the platformio project self.build_unflags: set[str] = set() + # The C++ language standard for the build (e.g. "gnu++20"), set via cg.set_cpp_standard() + self.cpp_standard: str | None = None # A set of defines to set for the compile process in esphome/core/defines.h self.defines: set[Define] = set() # A map of all platformio options to apply @@ -649,6 +651,7 @@ class EsphomeCore: self.platformio_libraries = {} self.build_flags = set() self.build_unflags = set() + self.cpp_standard = None self.defines = set() self.platformio_options = {} self.loaded_integrations = set() diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 151018baa4..582b8fc74d 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -705,15 +705,8 @@ def add_build_unflag(build_unflag: str) -> None: def set_cpp_standard(standard: str) -> None: - """Set C++ standard with compiler flag `-std={standard}`.""" - CORE.add_build_unflag("-std=gnu++11") - CORE.add_build_unflag("-std=gnu++14") - CORE.add_build_unflag("-std=gnu++17") - CORE.add_build_unflag("-std=gnu++23") - CORE.add_build_unflag("-std=gnu++2a") - CORE.add_build_unflag("-std=gnu++2b") - CORE.add_build_unflag("-std=gnu++2c") - CORE.add_build_flag(f"-std={standard}") + """Set the C++ language standard for the build (e.g. ``gnu++20``).""" + CORE.cpp_standard = standard def add_define(name: str, value: SafeExpType = None): diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index 540dd06731..a5c2719f42 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -162,3 +162,53 @@ def test_get_project_cmakelists_emits_managed_components_property( "idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS" " espressif__esp-dsp APPEND)" ) in content + + +def test_get_project_cmakelists_replaces_cpp_standard(tmp_path: Path) -> None: + """cg.set_cpp_standard() replaces the IDF default -std in + CXX_COMPILE_OPTIONS between include(project.cmake) and project().""" + with ( + patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), + patch.object(CORE, "name", "test"), + patch.object(CORE, "cpp_standard", "gnu++20"), + ): + from esphome.build_gen.espidf import get_project_cmakelists + + content = get_project_cmakelists(minimal=True) + + assert ( + "idf_build_get_property(esphome_cxx_compile_options CXX_COMPILE_OPTIONS)" + in content + ) + assert 'list(FILTER esphome_cxx_compile_options EXCLUDE REGEX "^-std=")' in content + assert 'list(APPEND esphome_cxx_compile_options "-std=gnu++20")' in content + # The replacement must come after project.cmake (which appends the IDF + # default) and before project() (which consumes the options). + include_pos = content.index("tools/cmake/project.cmake") + replace_pos = content.index("CXX_COMPILE_OPTIONS") + project_pos = content.index("project(test)") + assert include_pos < replace_pos < project_pos + + +def test_get_project_cmakelists_no_cpp_standard(tmp_path: Path) -> None: + with ( + patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), + patch.object(CORE, "name", "test"), + patch.object(CORE, "cpp_standard", None), + ): + from esphome.build_gen.espidf import get_project_cmakelists + + content = get_project_cmakelists(minimal=True) + + assert "CXX_COMPILE_OPTIONS" not in content + + +def test_get_component_cmakelists_no_compile_features() -> None: + """The C++ standard is pinned project-wide via CXX_COMPILE_OPTIONS in the + top-level CMakeLists; the src component must not set its own.""" + with patch.object(CORE, "build_flags", set()): + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + + assert "target_compile_features" not in content diff --git a/tests/unit_tests/build_gen/test_platformio.py b/tests/unit_tests/build_gen/test_platformio.py index da0010afa3..2ae3836a25 100644 --- a/tests/unit_tests/build_gen/test_platformio.py +++ b/tests/unit_tests/build_gen/test_platformio.py @@ -160,3 +160,43 @@ def test_write_ini_no_change_when_content_same( call_args = mock_write_file_if_changed.call_args[0] assert call_args[0] == ini_file assert content in call_args[1] + + +@pytest.fixture +def clean_core(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(CORE, "name", "test") + monkeypatch.setattr(CORE, "platformio_options", {}) + monkeypatch.setattr(CORE, "platformio_libraries", {}) + monkeypatch.setattr(CORE, "build_flags", set()) + monkeypatch.setattr(CORE, "build_unflags", set()) + + +def test_get_ini_content_pins_cpp_standard( + clean_core: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """cg.set_cpp_standard() pins -std via build_flags and unflags every other + known standard so the platform/framework default is stripped.""" + monkeypatch.setattr(CORE, "cpp_standard", "gnu++20") + + content = platformio.get_ini_content() + + flags_section = content.split("build_flags =")[1].split("build_unflags =")[0] + unflags_section = content.split("build_unflags =")[1].split("extra_scripts")[0] + assert "-std=gnu++20\n" in flags_section + # Both the GNU and strict dialects of every other standard are stripped. + for year in ("11", "14", "17", "23", "26", "2a", "2b", "2c"): + assert f"-std=gnu++{year}\n" in unflags_section + assert f"-std=c++{year}\n" in unflags_section + assert "-std=c++20\n" in unflags_section + # The selected standard must not unflag itself. + assert "-std=gnu++20\n" not in unflags_section + + +def test_get_ini_content_no_cpp_standard( + clean_core: None, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(CORE, "cpp_standard", None) + + content = platformio.get_ini_content() + + assert "-std=" not in content From cd7e54dbf23b91dfec42bbbb1c6b3207d8c22770 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:32:17 -0400 Subject: [PATCH 0373/1815] Bump cryptography from 48.0.0 to 48.0.1 (#16909) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 62ed506e36..a825cd9bff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -cryptography==48.0.0 +cryptography==48.0.1 voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 From 77009cfafe06b4db8b017116b75a9a9dec118611 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 10 Jun 2026 18:21:04 -0400 Subject: [PATCH 0374/1815] [resampler] Allow resampler to passthrough bits per sample instead of converting (#16892) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/resampler/speaker/__init__.py | 21 ++++++++++-- .../resampler/speaker/resampler_speaker.cpp | 32 ++++++++++++++----- .../resampler/speaker/resampler_speaker.h | 25 +++++++++------ tests/components/resampler/common.yaml | 5 +++ 4 files changed, 63 insertions(+), 20 deletions(-) diff --git a/esphome/components/resampler/speaker/__init__.py b/esphome/components/resampler/speaker/__init__.py index 8a13110631..ea080adc6b 100644 --- a/esphome/components/resampler/speaker/__init__.py +++ b/esphome/components/resampler/speaker/__init__.py @@ -24,6 +24,8 @@ ResamplerSpeaker = resampler_ns.class_( CONF_TAPS = "taps" +PASSTHROUGH = "passthrough" + def _set_stream_limits(config): audio.set_stream_limits( @@ -35,14 +37,21 @@ def _set_stream_limits(config): def _validate_audio_compatibility(config): - inherit_property_from(CONF_BITS_PER_SAMPLE, CONF_OUTPUT_SPEAKER)(config) inherit_property_from(CONF_NUM_CHANNELS, CONF_OUTPUT_SPEAKER)(config) inherit_property_from(CONF_SAMPLE_RATE, CONF_OUTPUT_SPEAKER)(config) + # In passthrough mode the output bits per sample is determined at runtime from the input stream, so there is + # nothing to inherit or validate against the output speaker. + passthrough = config.get(CONF_BITS_PER_SAMPLE) == PASSTHROUGH + if not passthrough: + inherit_property_from(CONF_BITS_PER_SAMPLE, CONF_OUTPUT_SPEAKER)(config) + audio.final_validate_audio_schema( "source_speaker", audio_device=CONF_OUTPUT_SPEAKER, - bits_per_sample=config.get(CONF_BITS_PER_SAMPLE), + bits_per_sample=cv.UNDEFINED + if passthrough + else config.get(CONF_BITS_PER_SAMPLE), channels=config.get(CONF_NUM_CHANNELS), sample_rate=config.get(CONF_SAMPLE_RATE), )(config) @@ -60,6 +69,9 @@ CONFIG_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(ResamplerSpeaker), cv.Required(CONF_OUTPUT_SPEAKER): cv.use_id(speaker.Speaker), + cv.Optional(CONF_BITS_PER_SAMPLE, default=PASSTHROUGH): cv.Any( + cv.one_of(PASSTHROUGH, lower=True), cv.int_range(8, 32) + ), cv.Optional( CONF_BUFFER_DURATION, default="100ms" ): cv.positive_time_period_milliseconds, @@ -90,7 +102,10 @@ async def to_code(config): cg.add(var.set_task_stack_in_psram(True)) psram.request_external_task_stack() - cg.add(var.set_target_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) + if config[CONF_BITS_PER_SAMPLE] == PASSTHROUGH: + cg.add(var.set_passthrough_bits_per_sample(True)) + else: + cg.add(var.set_target_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) cg.add(var.set_target_sample_rate(config[CONF_SAMPLE_RATE])) cg.add(var.set_filters(config[CONF_FILTERS])) diff --git a/esphome/components/resampler/speaker/resampler_speaker.cpp b/esphome/components/resampler/speaker/resampler_speaker.cpp index ecbd445a80..f1ebd180cc 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.cpp +++ b/esphome/components/resampler/speaker/resampler_speaker.cpp @@ -40,11 +40,19 @@ enum ResamplingEventGroupBits : uint32_t { }; void ResamplerSpeaker::dump_config() { - ESP_LOGCONFIG(TAG, - "Resampler Speaker:\n" - " Target Bits Per Sample: %u\n" - " Target Sample Rate: %" PRIu32 " Hz", - this->target_bits_per_sample_, this->target_sample_rate_); + if (this->passthrough_bits_per_sample_) { + ESP_LOGCONFIG(TAG, + "Resampler Speaker:\n" + " Target Bits Per Sample: passthrough\n" + " Target Sample Rate: %" PRIu32 " Hz", + this->target_sample_rate_); + } else { + ESP_LOGCONFIG(TAG, + "Resampler Speaker:\n" + " Target Bits Per Sample: %" PRIu8 "\n" + " Target Sample Rate: %" PRIu32 " Hz", + this->target_bits_per_sample_, this->target_sample_rate_); + } } void ResamplerSpeaker::setup() { @@ -253,8 +261,12 @@ void ResamplerSpeaker::send_command_(uint32_t command_bit, bool wake_loop) { void ResamplerSpeaker::start() { this->send_command_(ResamplingEventGroupBits::COMMAND_START, true); } esp_err_t ResamplerSpeaker::start_() { - this->target_stream_info_ = audio::AudioStreamInfo( - this->target_bits_per_sample_, this->audio_stream_info_.get_channels(), this->target_sample_rate_); + // In passthrough mode, the output keeps the input's bits per sample so only the sample rate is resampled. + const uint8_t target_bits_per_sample = this->passthrough_bits_per_sample_ + ? this->audio_stream_info_.get_bits_per_sample() + : this->target_bits_per_sample_; + this->target_stream_info_ = audio::AudioStreamInfo(target_bits_per_sample, this->audio_stream_info_.get_channels(), + this->target_sample_rate_); this->output_speaker_->set_audio_stream_info(this->target_stream_info_); this->output_speaker_->start(); @@ -305,7 +317,11 @@ void ResamplerSpeaker::set_volume(float volume) { } bool ResamplerSpeaker::requires_resampling_() const { - return (this->audio_stream_info_.get_sample_rate() != this->target_sample_rate_) || + if (this->audio_stream_info_.get_sample_rate() != this->target_sample_rate_) { + return true; + } + // In passthrough mode the bits per sample always matches the input, so it never forces resampling. + return !this->passthrough_bits_per_sample_ && (this->audio_stream_info_.get_bits_per_sample() != this->target_bits_per_sample_); } diff --git a/esphome/components/resampler/speaker/resampler_speaker.h b/esphome/components/resampler/speaker/resampler_speaker.h index 4a091e298a..f482ce4b88 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.h +++ b/esphome/components/resampler/speaker/resampler_speaker.h @@ -49,6 +49,12 @@ class ResamplerSpeaker : public Component, public speaker::Speaker { } void set_target_sample_rate(uint32_t target_sample_rate) { this->target_sample_rate_ = target_sample_rate; } + /// @brief When enabled, the input bits per sample are passed through to the output speaker unchanged instead of being + /// converted to a fixed target. Only the sample rate is resampled if it differs from the target. + void set_passthrough_bits_per_sample(bool passthrough_bits_per_sample) { + this->passthrough_bits_per_sample_ = passthrough_bits_per_sample; + } + void set_filters(uint16_t filters) { this->filters_ = filters; } void set_taps(uint16_t taps) { this->taps_ = taps; } @@ -80,23 +86,24 @@ class ResamplerSpeaker : public Component, public speaker::Speaker { speaker::Speaker *output_speaker_{nullptr}; - bool task_stack_in_psram_{false}; - bool waiting_for_output_{false}; - StaticTask task_; audio::AudioStreamInfo target_stream_info_; - uint16_t taps_; - uint16_t filters_; - - uint8_t target_bits_per_sample_; - uint32_t target_sample_rate_; + uint64_t callback_remainder_{0}; uint32_t buffer_duration_ms_; uint32_t state_start_ms_{0}; + uint32_t target_sample_rate_; - uint64_t callback_remainder_{0}; + uint16_t taps_; + uint16_t filters_; + + uint8_t target_bits_per_sample_{0}; + + bool passthrough_bits_per_sample_{false}; + bool task_stack_in_psram_{false}; + bool waiting_for_output_{false}; }; } // namespace esphome::resampler diff --git a/tests/components/resampler/common.yaml b/tests/components/resampler/common.yaml index 782dc831c4..65dd5590ee 100644 --- a/tests/components/resampler/common.yaml +++ b/tests/components/resampler/common.yaml @@ -7,3 +7,8 @@ speaker: - platform: resampler id: resampler_speaker_id output_speaker: resampler_i2s_speaker_id + bits_per_sample: 16 + - platform: resampler + id: resampler_speaker_2_id + output_speaker: resampler_speaker_id + bits_per_sample: passthrough From 92c82f3d25596a9bfb51cd91a53ccf3d1d1820c7 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 10 Jun 2026 17:26:50 -0500 Subject: [PATCH 0375/1815] [improv_serial] Report stopped state when Wi-Fi is disabled (#16904) Co-authored-by: Claude Opus 4.8 (1M context) --- .../esp32_improv/esp32_improv_component.cpp | 8 +++++++ .../improv_serial/improv_serial_component.cpp | 23 ++++++++++++++++++- .../improv_serial/improv_serial_component.h | 1 + 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index 183820256f..e6fcc018d9 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -338,6 +338,14 @@ void ESP32ImprovComponent::process_incoming_data_() { this->incoming_data_.clear(); return; } + if (wifi::global_wifi_component->is_disabled()) { + // Wi-Fi is disabled, so we can't provision. Respond immediately + // instead of letting the client wait out its provisioning timeout. + ESP_LOGW(TAG, "Wi-Fi is disabled; cannot provision"); + this->set_error_(improv::ERROR_UNABLE_TO_CONNECT); + this->incoming_data_.clear(); + return; + } wifi::WiFiAP sta{}; sta.set_ssid(command.ssid.c_str()); sta.set_password(command.password.c_str()); diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index 206df2c844..4ee703f363 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -22,7 +22,9 @@ void ImprovSerialComponent::setup() { if (wifi::global_wifi_component->has_sta()) { this->state_ = improv::STATE_PROVISIONED; - } else { + } else if (!wifi::global_wifi_component->is_disabled()) { + // Respect Wi-Fi's disabled state; forcing a scan while disabled throws + // the wifi component into an invalid state from which it cannot recover. wifi::global_wifi_component->start_scanning(); } } @@ -230,6 +232,13 @@ bool ImprovSerialComponent::parse_improv_serial_byte_(uint8_t byte) { bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command) { switch (command.command) { case improv::WIFI_SETTINGS: { + if (wifi::global_wifi_component->is_disabled()) { + // Wi-Fi is disabled, so we can't provision. Respond immediately + // instead of letting the client wait out its provisioning timeout. + ESP_LOGW(TAG, "Wi-Fi is disabled; cannot provision"); + this->set_error_(improv::ERROR_UNABLE_TO_CONNECT); + return true; + } wifi::WiFiAP sta{}; sta.set_ssid(command.ssid.c_str()); sta.set_password(command.password.c_str()); @@ -245,6 +254,14 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command return true; } case improv::GET_CURRENT_STATE: + if (wifi::global_wifi_component->is_disabled()) { + // Wi-Fi is disabled; report the Improv "stopped" state so a client can tell + // the user that provisioning is unavailable. Reported transiently without + // disturbing our internal provisioning state machine, so a later `wifi.enable` + // still reports the correct state. + this->send_current_state_(improv::STATE_STOPPED); + return true; + } this->set_state_(this->state_); if (this->state_ == improv::STATE_PROVISIONED) { std::vector url = this->build_rpc_settings_response_(improv::GET_CURRENT_STATE); @@ -299,6 +316,10 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command void ImprovSerialComponent::set_state_(improv::State state) { this->state_ = state; + this->send_current_state_(state); +} + +void ImprovSerialComponent::send_current_state_(improv::State state) { this->tx_header_[TX_TYPE_IDX] = TYPE_CURRENT_STATE; this->tx_header_[TX_DATA_IDX] = state; this->write_data_(); diff --git a/esphome/components/improv_serial/improv_serial_component.h b/esphome/components/improv_serial/improv_serial_component.h index c58c42f0d8..70f9214e2d 100644 --- a/esphome/components/improv_serial/improv_serial_component.h +++ b/esphome/components/improv_serial/improv_serial_component.h @@ -57,6 +57,7 @@ class ImprovSerialComponent : public Component, public improv_base::ImprovBase { bool parse_improv_payload_(improv::ImprovCommand &command); void set_state_(improv::State state); + void send_current_state_(improv::State state); void set_error_(improv::Error error); void send_response_(std::vector &response); void on_wifi_connect_timeout_(); From e0b0c1e8d3a4763e255a45a7fa9eb0ebe1392110 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:41:19 +1200 Subject: [PATCH 0376/1815] Bump version to 2026.7.0-dev --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 3537516996..9f4e20b977 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.6.0-dev +PROJECT_NUMBER = 2026.7.0-dev # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 22351244bd..3ca7b2e618 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.6.0-dev" +__version__ = "2026.7.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 4dbc5ce920e50b37ac1e301e338c15ed8cb90f12 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:41:19 +1200 Subject: [PATCH 0377/1815] Bump version to 2026.6.0b1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 3537516996..647d25559a 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.6.0-dev +PROJECT_NUMBER = 2026.6.0b1 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 22351244bd..9a951c1527 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.6.0-dev" +__version__ = "2026.6.0b1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 6a527c7efc24a3a14b5d29db862810ee830bc7c5 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:04:22 +1200 Subject: [PATCH 0378/1815] [tests] Mock target branch in memory-impact exclusion test (#16913) --- tests/script/test_determine_jobs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index acc268fa68..a9defcacac 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -1470,6 +1470,7 @@ def test_detect_memory_impact_config_no_common_platform(tmp_path: Path) -> None: assert result["use_merged_config"] == "true" +@pytest.mark.usefixtures("mock_target_branch_dev") def test_detect_memory_impact_config_variant_only_platform_excluded( tmp_path: Path, ) -> None: From abf6212a5a3b28c57a9a8f933247fa86b268a1b8 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:04:22 +1200 Subject: [PATCH 0379/1815] [tests] Mock target branch in memory-impact exclusion test (#16913) --- tests/script/test_determine_jobs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index acc268fa68..a9defcacac 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -1470,6 +1470,7 @@ def test_detect_memory_impact_config_no_common_platform(tmp_path: Path) -> None: assert result["use_merged_config"] == "true" +@pytest.mark.usefixtures("mock_target_branch_dev") def test_detect_memory_impact_config_variant_only_platform_excluded( tmp_path: Path, ) -> None: From 750cf1995b894a80fcae6c875a0a60d3c56beee6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Jun 2026 08:47:50 -0500 Subject: [PATCH 0380/1815] [esp8266] Decode crash handler PC and backtrace in logs (#16911) --- esphome/components/esp8266/__init__.py | 18 ++++++++++- .../components/test_esp_stacktrace.py | 30 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index dd10a32fd6..db94f0ec6d 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -492,6 +492,15 @@ def _parse_register(config, regex, line): STACKTRACE_ESP8266_EXCEPTION_TYPE_RE = re.compile(r"[eE]xception \((\d+)\):") STACKTRACE_ESP8266_PC_RE = re.compile(r"epc1=0x(4[0-9a-fA-F]{7})") STACKTRACE_ESP8266_EXCVADDR_RE = re.compile(r"excvaddr=0x(4[0-9a-fA-F]{7})") +# Structured crash handler output (crash_handler.cpp) from a previous boot: +# PC: 0x40220060 +# EXCVADDR: 0x0000008A +# BT0: 0x40212345 +STACKTRACE_ESP8266_CRASH_PC_RE = re.compile(r".*PC\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})") +STACKTRACE_ESP8266_CRASH_EXCVADDR_RE = re.compile( + r".*EXCVADDR\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})" +) +STACKTRACE_ESP8266_CRASH_BT_RE = re.compile(r"BT\d+:\s*0x([0-9a-fA-F]{8})") STACKTRACE_BAD_ALLOC_RE = re.compile( r"^last failed alloc call: (4[0-9a-fA-F]{7})\((\d+)\)$" ) @@ -508,10 +517,17 @@ def process_stacktrace(config, line, backtrace_state): "Exception type: %s", ESP8266_EXCEPTION_CODES.get(code, "unknown") ) - # ESP8266 PC/EXCVADDR + # ESP8266 PC/EXCVADDR (legacy Arduino postmortem) _parse_register(config, STACKTRACE_ESP8266_PC_RE, line) _parse_register(config, STACKTRACE_ESP8266_EXCVADDR_RE, line) + # ESP8266 structured crash handler (crash_handler.cpp) from previous boot + _parse_register(config, STACKTRACE_ESP8266_CRASH_PC_RE, line) + _parse_register(config, STACKTRACE_ESP8266_CRASH_EXCVADDR_RE, line) + match = re.search(STACKTRACE_ESP8266_CRASH_BT_RE, line) + if match is not None: + _decode_pc(config, match.group(1)) + # bad alloc match = re.match(STACKTRACE_BAD_ALLOC_RE, line) if match is not None: diff --git a/tests/unit_tests/components/test_esp_stacktrace.py b/tests/unit_tests/components/test_esp_stacktrace.py index 5235f313d6..f231ac5fb7 100644 --- a/tests/unit_tests/components/test_esp_stacktrace.py +++ b/tests/unit_tests/components/test_esp_stacktrace.py @@ -45,6 +45,36 @@ def test_process_stacktrace_esp8266_backtrace( assert state is False +def test_process_stacktrace_esp8266_crash_handler( + setup_core: Path, mock_esp8266_decode_pc: Mock +) -> None: + """Test process_stacktrace handles ESP8266 crash handler backtrace lines.""" + from esphome.components.esp8266 import process_stacktrace + + config = {"name": "test"} + + # Simulate crash handler log lines as they appear from the API/serial + line_pc = "[E][esp8266:191]: PC: 0x40220060" + state = process_stacktrace(config, line_pc, False) + mock_esp8266_decode_pc.assert_called_once_with(config, "40220060") + assert state is False + + mock_esp8266_decode_pc.reset_mock() + + # Near-null data address (wild pointer) is not a code address, must be ignored + line_excvaddr = "[E][esp8266:193]: EXCVADDR: 0x0000008A" + state = process_stacktrace(config, line_excvaddr, False) + mock_esp8266_decode_pc.assert_not_called() + assert state is False + + mock_esp8266_decode_pc.reset_mock() + + line_bt0 = "[E][esp8266:196]: BT0: 0x40212345" + state = process_stacktrace(config, line_bt0, False) + mock_esp8266_decode_pc.assert_called_once_with(config, "40212345") + assert state is False + + def test_process_stacktrace_esp32_backtrace( setup_core: Path, mock_esp32_decode_pc: Mock ) -> None: From 28dd935359ea59270c18b463f858041eed35ef25 Mon Sep 17 00:00:00 2001 From: Dan Drown Date: Thu, 11 Jun 2026 11:35:44 -0500 Subject: [PATCH 0381/1815] [xpt2046] touchscreen driver enhancement (#16414) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .../xpt2046/touchscreen/xpt2046.cpp | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/esphome/components/xpt2046/touchscreen/xpt2046.cpp b/esphome/components/xpt2046/touchscreen/xpt2046.cpp index d08a54529d..83a7332005 100644 --- a/esphome/components/xpt2046/touchscreen/xpt2046.cpp +++ b/esphome/components/xpt2046/touchscreen/xpt2046.cpp @@ -6,6 +6,13 @@ namespace esphome::xpt2046 { +static constexpr uint8_t XPT_READ_Z1 = 0xB0; +static constexpr uint8_t XPT_READ_Z2 = 0xC0; +static constexpr uint8_t XPT_READ_X = 0xD0; +static constexpr uint8_t XPT_READ_Y = 0x90; +static constexpr uint8_t XPT_ADC_ON = 0x01; +static constexpr uint8_t XPT_VREF_ON = 0x02; + static const char *const TAG = "xpt2046"; void XPT2046Component::setup() { @@ -20,7 +27,7 @@ void XPT2046Component::setup() { this->attach_interrupt_(this->irq_pin_, gpio::INTERRUPT_FALLING_EDGE); } this->spi_setup(); - this->read_adc_(0xD0); // ADC powerdown, enable PENIRQ pin + this->read_adc_(XPT_READ_X); // ADC powerdown, enable PENIRQ pin } void XPT2046Component::update_touches() { @@ -29,21 +36,22 @@ void XPT2046Component::update_touches() { enable(); - int16_t touch_pressure_1 = this->read_adc_(0xB1 /* touch_pressure_1 */); - int16_t touch_pressure_2 = this->read_adc_(0xC1 /* touch_pressure_2 */); + int16_t touch_pressure_1 = this->read_adc_(XPT_READ_Z1 | XPT_ADC_ON); + int16_t touch_pressure_2 = this->read_adc_(XPT_READ_Z2 | XPT_ADC_ON); z_raw = touch_pressure_1 + 0xfff - touch_pressure_2; ESP_LOGVV(TAG, "Touchscreen Update z = %d", z_raw); touch = (z_raw >= this->threshold_); if (touch) { - read_adc_(0xD1 /* X */); // dummy Y measure, 1st is always noisy - data[0] = this->read_adc_(0x91 /* Y */); - data[1] = this->read_adc_(0xD1 /* X */); // make 3 x-y measurements - data[2] = this->read_adc_(0x91 /* Y */); - data[3] = this->read_adc_(0xD1 /* X */); - data[4] = this->read_adc_(0x91 /* Y */); + read_adc_(XPT_READ_X | XPT_ADC_ON); // dummy X measure, 1st is always noisy + // make 3 x-y measurements + data[0] = this->read_adc_(XPT_READ_Y | XPT_ADC_ON); + data[1] = this->read_adc_(XPT_READ_X | XPT_ADC_ON); + data[2] = this->read_adc_(XPT_READ_Y | XPT_ADC_ON); + data[3] = this->read_adc_(XPT_READ_X | XPT_ADC_ON); + data[4] = this->read_adc_(XPT_READ_Y | XPT_ADC_ON); } - data[5] = this->read_adc_(0xD0 /* X */); // Last X touch power down + data[5] = this->read_adc_(XPT_READ_X); // Last X touch power down disable(); @@ -95,15 +103,16 @@ int16_t XPT2046Component::best_two_avg(int16_t value1, int16_t value2, int16_t v return reta; } -int16_t XPT2046Component::read_adc_(uint8_t ctrl) { // NOLINT - uint8_t data[2]; +int16_t XPT2046Component::read_adc_(uint8_t ctrl) { + uint8_t data[3]; - this->write_byte(ctrl); - delay(1); - data[0] = this->read_byte(); - data[1] = this->read_byte(); + data[0] = ctrl; + data[1] = 0; + data[2] = 0; - return ((data[0] << 8) | data[1]) >> 3; + this->transfer_array(data, sizeof(data)); + + return ((data[1] << 8) | data[2]) >> 3; } } // namespace esphome::xpt2046 From 6ef35b6d3d163d0d027866b10805c617a841bfdc Mon Sep 17 00:00:00 2001 From: Tobiasz Jakubowski <12734857+tjakubo@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:50:51 +0200 Subject: [PATCH 0382/1815] [spi] Skip logging on begin_transaction() of an auto-releasing write-only SPI device (#16921) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/spi/spi_esp_idf.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/esphome/components/spi/spi_esp_idf.cpp b/esphome/components/spi/spi_esp_idf.cpp index 107b6a3f1a..0731078eec 100644 --- a/esphome/components/spi/spi_esp_idf.cpp +++ b/esphome/components/spi/spi_esp_idf.cpp @@ -17,6 +17,11 @@ class SPIDelegateHw : public SPIDelegate { write_only_(write_only) { if (!this->release_device_) add_device_(); + + if (this->write_only_) { + ESP_LOGV(TAG, "SPI device with CS pin %d using half-duplex mode (write-only)", + Utility::get_pin_no(this->cs_pin_)); + } } bool is_ready() override { return this->handle_ != nullptr; } @@ -195,11 +200,8 @@ class SPIDelegateHw : public SPIDelegate { config.post_cb = nullptr; if (this->bit_order_ == BIT_ORDER_LSB_FIRST) config.flags |= SPI_DEVICE_BIT_LSBFIRST; - if (this->write_only_) { + if (this->write_only_) config.flags |= SPI_DEVICE_HALFDUPLEX | SPI_DEVICE_NO_DUMMY; - ESP_LOGD(TAG, "SPI device with CS pin %d using half-duplex mode (write-only)", - Utility::get_pin_no(this->cs_pin_)); - } esp_err_t const err = spi_bus_add_device(this->channel_, &config, &this->handle_); if (err != ESP_OK) { ESP_LOGE(TAG, "Add device failed - err %X", err); From 88084f2ec712ef015c51feb57f1d0bbaf7955737 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:32:51 -0400 Subject: [PATCH 0383/1815] Bump ruff from 0.15.16 to 0.15.17 (#16918) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 9da27acc19..5ba806a2f5 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.16 # also change in .pre-commit-config.yaml when updating +ruff==0.15.17 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From bf6c8568d364b8c2d76c29aba756c9ebd4651ab5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:33:28 -0400 Subject: [PATCH 0384/1815] Bump CodSpeedHQ/action from 4.17.0 to 4.17.5 (#16919) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a57be34e9b..deeec72095 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -456,7 +456,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # v4.17.0 + uses: CodSpeedHQ/action@c145068895e045cc725ee76fcd2307624b65c3af # v4.17.5 with: run: | . venv/bin/activate From 10ce6024bf2339b888a5182aea1230634d789d69 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 13 Jun 2026 22:21:38 +1000 Subject: [PATCH 0385/1815] [lvgl] Fix schema extraction (#16895) Co-authored-by: Claude Opus 4.8 --- esphome/components/lvgl/__init__.py | 175 ++++++++++++--------- esphome/components/lvgl/schemas.py | 48 +++++- script/build_language_schema.py | 28 ++++ tests/script/test_build_language_schema.py | 107 +++++++++++++ 4 files changed, 276 insertions(+), 82 deletions(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 022d629960..9137412abe 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -47,6 +47,7 @@ from esphome.core import CORE, ID, Lambda from esphome.cpp_generator import MockObj from esphome.final_validate import full_config from esphome.helpers import write_file_if_changed +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.writer import clean_build from esphome.yaml_util import load_yaml @@ -75,10 +76,14 @@ from .schemas import ( BASE_PROPS, DISP_BG_SCHEMA, FULL_STYLE_SCHEMA, + SET_STATE_SCHEMA, + STATE_SCHEMA, STYLE_REMAP, + STYLE_SCHEMA, WIDGET_TYPES, any_widget_schema, container_schema, + container_schema_value, obj_dict, ) from .styles import styles_to_code, theme_to_code @@ -113,6 +118,14 @@ from .widgets.page import ( # page_spec used in LVGL_SCHEMA page_spec, ) +# These style schemas live in .schemas but are imported here so they land in +# this module's namespace, where script/build_language_schema.py registers them +# as *named* schemas and emits `extends` references — instead of inlining the +# ~80-property STYLE_SCHEMA at every widget x part x state, which bloated the +# dumped lvgl schema ~23x (17 MB vs ~750 KB). They are not otherwise used in +# this file; this tuple keeps the imports live (and self-documents why). +_SCHEMA_DUMPER_NAMED_SCHEMAS = (STYLE_SCHEMA, STATE_SCHEMA, SET_STATE_SCHEMA) + # Widget registration happens via WidgetType.__init__ in individual widget files # The imports below trigger creation of the widget types # Action registration (lvgl.{widget}.update) happens automatically @@ -559,94 +572,106 @@ def _theme_schema(value: dict) -> dict: FINAL_VALIDATE_SCHEMA = final_validation -LVGL_SCHEMA = cv.All( - container_schema( - obj_spec, - cv.polling_component_schema("1s") - .extend( - { - **{ - cv.Optional(event): validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - Trigger.template(lv_obj_t_ptr, lv_event_t_ptr) - ), - } - ) - for event in df.LV_SCREEN_EVENT_TRIGGERS - + df.LV_DISPLAY_EVENT_TRIGGERS - }, - cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent), - cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t), - cv.GenerateID(df.CONF_DISPLAYS): display_schema, - cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16), - cv.Optional( - df.CONF_DEFAULT_FONT, default="montserrat_14" - ): lvalid.lv_font, - cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean, - cv.Optional( - df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False - ): cv.boolean, - cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int, - cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage, - cv.Optional(CONF_ROTATION): validate_rotation, - cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( - *df.LV_LOG_LEVELS, upper=True - ), - cv.Optional(CONF_BYTE_ORDER): cv.one_of( - "big_endian", "little_endian", lower=True - ), - cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list( - cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}).extend( - FULL_STYLE_SCHEMA - ) - ), - cv.Optional(CONF_ON_IDLE): validate_automation( +# The options accepted at the top level of an `lvgl:` block, on top of the base +# object schema that `container_schema(obj_spec, ...)` supplies. Held in a +# module-level name (rather than inline) so the schema-extractor wrapper on +# CONFIG_SCHEMA below can hand the language-schema dumper the same composed +# schema the runtime validates against. +LVGL_TOP_LEVEL_SCHEMA = ( + cv.polling_component_schema("1s") + .extend( + { + **{ + cv.Optional(event): validate_automation( { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(IdleTrigger), - cv.Required(CONF_TIMEOUT): cv.templatable( - cv.positive_time_period_milliseconds + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + Trigger.template(lv_obj_t_ptr, lv_event_t_ptr) ), } - ), - cv.Optional(CONF_PAGES): cv.ensure_list(container_schema(page_spec)), - **{ - cv.Optional(x): validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger), - }, - single=True, - ) - for x in SIMPLE_TRIGGERS - }, - cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA), - cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool, - cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec), - cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec), - cv.Optional( - df.CONF_TRANSPARENCY_KEY, default=0x000400 - ): lvalid.lv_color, - cv.Optional(df.CONF_THEME): _theme_schema, - cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA, - cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema, - cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG, - cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG, - cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t), - cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean, - } - ) - .extend(DISP_BG_SCHEMA), - ), + ) + for event in df.LV_SCREEN_EVENT_TRIGGERS + df.LV_DISPLAY_EVENT_TRIGGERS + }, + cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent), + cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t), + cv.GenerateID(df.CONF_DISPLAYS): display_schema, + cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16), + cv.Optional(df.CONF_DEFAULT_FONT, default="montserrat_14"): lvalid.lv_font, + cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean, + cv.Optional(df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False): cv.boolean, + cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int, + cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage, + cv.Optional(CONF_ROTATION): validate_rotation, + cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( + *df.LV_LOG_LEVELS, upper=True + ), + cv.Optional(CONF_BYTE_ORDER): cv.one_of( + "big_endian", "little_endian", lower=True + ), + cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list( + cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}).extend( + FULL_STYLE_SCHEMA + ) + ), + cv.Optional(CONF_ON_IDLE): validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(IdleTrigger), + cv.Required(CONF_TIMEOUT): cv.templatable( + cv.positive_time_period_milliseconds + ), + } + ), + cv.Optional(CONF_PAGES): cv.ensure_list(container_schema(page_spec)), + **{ + cv.Optional(x): validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger), + }, + single=True, + ) + for x in SIMPLE_TRIGGERS + }, + cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA), + cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool, + cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec), + cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec), + cv.Optional(df.CONF_TRANSPARENCY_KEY, default=0x000400): lvalid.lv_color, + cv.Optional(df.CONF_THEME): _theme_schema, + cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA, + cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema, + cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG, + cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG, + cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t), + cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean, + } + ) + .extend(DISP_BG_SCHEMA) +) + + +LVGL_SCHEMA = cv.All( + container_schema(obj_spec, LVGL_TOP_LEVEL_SCHEMA), cv.has_at_most_one_key(CONF_PAGES, df.CONF_LAYOUT), add_hello_world, ) +@schema_extractor("schema") def lvgl_config_schema(config): """ Can't use cv.ensure_list here because it converts an empty config to an empty list, rather than a default config. """ + if config is SCHEMA_EXTRACT: + # CONFIG_SCHEMA is this callable wrapping `cv.All` over a container_schema + # closure, so the language-schema dumper can't see the top-level `lvgl:` + # fields (it would emit an empty schema). Hand it the same composed + # obj + top-level schema the runtime validates against, plus the + # `widgets:` key (added per-value by append_layout_schema at runtime, so + # otherwise invisible to the dumper). Validation of real configs (the + # branches below) is unchanged. + return container_schema_value(obj_spec, LVGL_TOP_LEVEL_SCHEMA).extend( + {cv.Optional(df.CONF_WIDGETS): any_widget_schema()} + ) if not config or isinstance(config, dict): return [LVGL_SCHEMA(config)] return cv.Schema([LVGL_SCHEMA])(config) diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index bdaa91f15c..d7df628907 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -22,7 +22,11 @@ from esphome.const import ( ) from esphome.core import TimePeriod from esphome.core.config import StartupTrigger -from esphome.schema_extractors import EnableSchemaExtraction +from esphome.schema_extractors import ( + SCHEMA_EXTRACT, + EnableSchemaExtraction, + schema_extractor, +) from . import defines as df, lv_validation as lvalid from .defines import ( @@ -627,6 +631,25 @@ _CONTAINER_SCHEMA_CACHE: dict[ ] = {} +def container_schema_value(widget_type: WidgetType, extras: Any = None) -> cv.Schema: + """ + Build the static schema that :func:`container_schema` validates against, i.e. + everything except the value-dependent ``append_layout_schema`` applied at + validation time. + + Factored out and exposed so the language-schema dumper can extract a + representative schema for a widget — and for the top-level ``lvgl:`` block, + whose ``CONFIG_SCHEMA`` is a callable that otherwise hides this behind the + :func:`container_schema` validator closure. + """ + schema = obj_schema(widget_type).extend( + {cv.GenerateID(): cv.declare_id(widget_type.w_type)} + ) + if extras: + schema = schema.extend(extras) + return schema.extend(widget_type.schema) + + def container_schema( widget_type: WidgetType, extras: Any = None ) -> Callable[[Any], Any]: @@ -649,12 +672,7 @@ def container_schema( def get_schema() -> cv.Schema: nonlocal cached_schema if cached_schema is None: - schema = obj_schema(widget_type).extend( - {cv.GenerateID(): cv.declare_id(widget_type.w_type)} - ) - if extras: - schema = schema.extend(extras) - cached_schema = schema.extend(widget_type.schema) + cached_schema = container_schema_value(widget_type, extras) return cached_schema def validator(value: Any) -> Any: @@ -678,7 +696,23 @@ def any_widget_schema(extras=None): :return: A validator for the Widgets key """ + @schema_extractor("schema") def validator(value): + if value is SCHEMA_EXTRACT: + # The widgets: list is built per-value at validation time, so the + # language-schema dumper sees nothing. Enumerate every registered + # widget type as an optional key (a widget item is really a + # single-key mapping; over-listing them lets editors complete any + # widget — `esphome config` enforces exactly one). extras carries the + # layout child options where applicable. + return cv.ensure_list( + cv.Schema( + { + cv.Optional(name): container_schema_value(widget_type, extras) + for name, widget_type in WIDGET_TYPES.items() + } + ) + ) if isinstance(value, dict): # Convert to list is_dict = True diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 4b0b0ee548..61845c4b25 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -428,6 +428,33 @@ def fix_menu(): menu[S_EXTENDS].append("display_menu_base.MENU_TYPES") +def fix_lvgl_widgets(): + # lvgl's `widgets:` is a recursive tree (a widget can contain widgets). The + # dumper has no cycle detection, so — like fix_menu — hoist the inlined + # widget-type enumeration into a named schema and reference it for both the + # top-level list and each widget's own children, instead of expanding it. + if "lvgl" not in output: + return + schemas = output["lvgl"][S_SCHEMAS] + config_vars = schemas["CONFIG_SCHEMA"][S_SCHEMA][S_CONFIG_VARS] + widgets = config_vars.get("widgets") + if not widgets or S_SCHEMA not in widgets or S_CONFIG_VARS not in widgets[S_SCHEMA]: + return + # 1. Hoist the (one-level) widget enumeration into a named schema. + schemas["WIDGET_TYPES"] = {S_TYPE: S_SCHEMA, S_SCHEMA: widgets[S_SCHEMA]} + # 2. Reference it from the top-level widgets: list instead of inlining. + widgets[S_SCHEMA] = {S_EXTENDS: ["lvgl.WIDGET_TYPES"]} + # 3. Let every widget contain child widgets, via the same named ref. + for widget in schemas["WIDGET_TYPES"][S_SCHEMA][S_CONFIG_VARS].values(): + if widget.get(S_TYPE) == S_SCHEMA and S_SCHEMA in widget: + widget[S_SCHEMA].setdefault(S_CONFIG_VARS, {})["widgets"] = { + S_TYPE: S_SCHEMA, + "is_list": True, + "key": "Optional", + S_SCHEMA: {S_EXTENDS: ["lvgl.WIDGET_TYPES"]}, + } + + def get_logger_tags(): pattern = re.compile(r'^static const char \*const TAG = "(\w.*)";', re.MULTILINE) # tags not in components dir @@ -740,6 +767,7 @@ def build_schema(): add_logger_tags() shrink() fix_menu() + fix_lvgl_widgets() # aggregate components, so all component info is in same file, otherwise we have dallas.json, dallas.sensor.json, etc. data = {} diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index 8b81a57fef..badd4686f6 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -4,7 +4,12 @@ from __future__ import annotations import ast import importlib.util +import json from pathlib import Path +import subprocess +import sys + +import pytest from esphome import config_validation as cv @@ -176,3 +181,105 @@ def test_convert_keys_no_marker_for_non_sensitive_field() -> None: entry = converted["schema"]["config_vars"]["hostname"] assert "sensitive" not in entry assert "sensitive_source" not in entry + + +# --------------------------------------------------------------------------- +# Regression tests for the lvgl schema dump. +# +# lvgl's CONFIG_SCHEMA is a callable closure and its widget/style schemas are +# built lazily at validation time, so the static dumper used to emit an empty +# `lvgl:` schema, no widget completion, and an inlined ~80-property STYLE_SCHEMA +# duplicated at every widget x part x state (a 17 MB lvgl.json). These exercise +# the full `build_schema()` and assert the generated lvgl.json carries the data +# the schema_extractor hooks added. +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def lvgl_schema(tmp_path_factory: pytest.TempPathFactory) -> dict: + """Run the full language-schema build once and return parsed lvgl.json. + + The build must run in a fresh interpreter: ``build_language_schema.py`` + enables schema extraction *before* importing any esphome component, and the + extraction hooks are no-ops if the components were already imported (as they + are inside the pytest session). Running it as a subprocess mirrors how CI + generates the schema and keeps this test isolated from import order. + """ + out_dir = tmp_path_factory.mktemp("language_schema") + subprocess.run( + [sys.executable, str(SCRIPT_PATH), "--output-path", str(out_dir)], + check=True, + capture_output=True, + text=True, + ) + return json.loads((out_dir / "lvgl.json").read_text()) + + +def _lvgl_config_vars(lvgl_schema: dict) -> dict: + config_schema = lvgl_schema["lvgl"]["schemas"]["CONFIG_SCHEMA"] + # Previously empty (`{}`); the schema_extractor on lvgl_config_schema now + # hands the dumper the composed top-level schema. + assert config_schema["type"] == "schema" + return config_schema["schema"]["config_vars"] + + +def test_lvgl_top_level_schema_is_exposed(lvgl_schema: dict) -> None: + config_vars = _lvgl_config_vars(lvgl_schema) + # Was 0 config_vars before LVGL_TOP_LEVEL_SCHEMA was exposed. + assert len(config_vars) > 100 + # A representative spread of top-level options the runtime validates. + for key in ("displays", "pages", "default_font", "on_idle", "touchscreens"): + assert key in config_vars, f"missing top-level lvgl option: {key}" + + +def test_lvgl_widgets_key_enumerated(lvgl_schema: dict) -> None: + config_vars = _lvgl_config_vars(lvgl_schema) + # The widgets: list is assembled per-value at runtime; the extractor + # enumerates every registered widget type into a named WIDGET_TYPES schema + # which the widgets: list references (recursive, so widgets can nest). + assert "widgets" in config_vars + widgets = config_vars["widgets"] + assert widgets["is_list"] is True + assert widgets["schema"]["extends"] == ["lvgl.WIDGET_TYPES"] + + widget_types = lvgl_schema["lvgl"]["schemas"]["WIDGET_TYPES"]["schema"][ + "config_vars" + ] + # Every registered widget type should appear as an optional key. + for name in ("obj", "label", "button", "slider", "switch", "arc"): + assert name in widget_types, f"widget type not enumerated: {name}" + # Each enumerated widget carries its own property schema, not an empty stub. + assert widget_types["label"]["type"] == "schema" + assert len(widget_types["label"]["schema"]["config_vars"]) > 0 + # Each widget can contain child widgets, via the same named ref — so the + # tree is recursive and the dump stays finite. + nested = widget_types["obj"]["schema"]["config_vars"]["widgets"] + assert nested["is_list"] is True + assert nested["schema"]["extends"] == ["lvgl.WIDGET_TYPES"] + + +def test_lvgl_style_schemas_are_named_and_deduped(lvgl_schema: dict) -> None: + schemas = lvgl_schema["lvgl"]["schemas"] + # Importing these into the lvgl __init__ namespace lets the dumper register + # them as named schemas and emit `extends` refs instead of inlining them. + for name in ("STYLE_SCHEMA", "STATE_SCHEMA", "SET_STATE_SCHEMA"): + assert name in schemas, f"style schema not registered as named: {name}" + + # STYLE_SCHEMA must be referenced via `extends`, not inlined at every use + # site. Count the references to prove the dedup actually happened. + refs = 0 + + def _count(node: object) -> None: + nonlocal refs + if isinstance(node, dict): + extends = node.get("extends") + if isinstance(extends, list) and "lvgl.STYLE_SCHEMA" in extends: + refs += 1 + for value in node.values(): + _count(value) + elif isinstance(node, list): + for value in node: + _count(value) + + _count(lvgl_schema) + assert refs > 100, f"STYLE_SCHEMA should be referenced via extends, got {refs}" From 35e5c7c7c353ab8182d3c74a65b434e1738ec2e3 Mon Sep 17 00:00:00 2001 From: guillempages Date: Sat, 13 Jun 2026 23:40:49 +0200 Subject: [PATCH 0386/1815] [runtime_image] Improve error logging (#16943) --- esphome/components/online_image/online_image.cpp | 3 ++- esphome/components/runtime_image/image_decoder.h | 16 ++++++++++++++++ .../components/runtime_image/jpeg_decoder.cpp | 16 ++++++++++++++-- esphome/components/runtime_image/png_decoder.cpp | 1 + 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/esphome/components/online_image/online_image.cpp b/esphome/components/online_image/online_image.cpp index a5a3ea5104..22bce4cc41 100644 --- a/esphome/components/online_image/online_image.cpp +++ b/esphome/components/online_image/online_image.cpp @@ -1,4 +1,5 @@ #include "online_image.h" +#include "esphome/components/runtime_image/image_decoder.h" #include "esphome/core/log.h" #include @@ -181,7 +182,7 @@ void OnlineImage::loop() { auto consumed = this->feed_data(this->download_buffer_.data(), this->download_buffer_.unread()); if (consumed < 0) { - ESP_LOGE(TAG, "Error decoding image: %d", consumed); + ESP_LOGE(TAG, "Error decoding image: %s", esphome::runtime_image::decode_error_to_string(consumed)); this->end_connection_(); this->download_error_callback_.call(); return; diff --git a/esphome/components/runtime_image/image_decoder.h b/esphome/components/runtime_image/image_decoder.h index 926108a8a0..c68ea5720b 100644 --- a/esphome/components/runtime_image/image_decoder.h +++ b/esphome/components/runtime_image/image_decoder.h @@ -7,8 +7,24 @@ enum DecodeError : int { DECODE_ERROR_INVALID_TYPE = -1, DECODE_ERROR_UNSUPPORTED_FORMAT = -2, DECODE_ERROR_OUT_OF_MEMORY = -3, + DECODE_ERROR_INTERNAL_DECODER_ERROR = -4, }; +constexpr const char *decode_error_to_string(int error) { + switch (error) { + case DECODE_ERROR_INVALID_TYPE: + return "Invalid type"; + case DECODE_ERROR_UNSUPPORTED_FORMAT: + return "Unsupported format"; + case DECODE_ERROR_OUT_OF_MEMORY: + return "Out of memory"; + case DECODE_ERROR_INTERNAL_DECODER_ERROR: + return "Internal decoder error"; + default: + return "Unknown error"; + } +} + class RuntimeImage; /** diff --git a/esphome/components/runtime_image/jpeg_decoder.cpp b/esphome/components/runtime_image/jpeg_decoder.cpp index dcaa07cd58..c46e86fd0d 100644 --- a/esphome/components/runtime_image/jpeg_decoder.cpp +++ b/esphome/components/runtime_image/jpeg_decoder.cpp @@ -89,9 +89,21 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) { return DECODE_ERROR_OUT_OF_MEMORY; } if (!this->jpeg_.decode(0, 0, 0)) { - ESP_LOGE(TAG, "Error while decoding."); + auto error = this->jpeg_.getLastError(); + ESP_LOGE(TAG, "Error while decoding: %d", error); this->jpeg_.close(); - return DECODE_ERROR_UNSUPPORTED_FORMAT; + switch (error) { + case JPEG_ERROR_MEMORY: + return DECODE_ERROR_OUT_OF_MEMORY; + case JPEG_UNSUPPORTED_FEATURE: + return DECODE_ERROR_UNSUPPORTED_FORMAT; + case JPEG_INVALID_FILE: + case JPEG_INVALID_PARAMETER: + return DECODE_ERROR_INVALID_TYPE; + case JPEG_DECODE_ERROR: + default: + return DECODE_ERROR_INTERNAL_DECODER_ERROR; + } } this->decoded_bytes_ = size; this->jpeg_.close(); diff --git a/esphome/components/runtime_image/png_decoder.cpp b/esphome/components/runtime_image/png_decoder.cpp index 591504328d..12bce0d284 100644 --- a/esphome/components/runtime_image/png_decoder.cpp +++ b/esphome/components/runtime_image/png_decoder.cpp @@ -95,6 +95,7 @@ int HOT PngDecoder::decode(uint8_t *buffer, size_t size) { auto fed = pngle_feed(this->pngle_, buffer, size); if (fed < 0) { ESP_LOGE(TAG, "Error decoding image: %s", pngle_error(this->pngle_)); + return DECODE_ERROR_INTERNAL_DECODER_ERROR; } else { this->decoded_bytes_ += fed; } From 5b7f8cf90d0d78a0563cd342b718fa6fd75992e5 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 15 Jun 2026 07:36:38 +1000 Subject: [PATCH 0387/1815] [mipi_spi] Implement automatic mapping of offsets (#16722) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/mipi/__init__.py | 131 ++++-- esphome/components/mipi_dsi/display.py | 8 +- esphome/components/mipi_rgb/display.py | 8 +- esphome/components/mipi_spi/display.py | 36 +- esphome/components/mipi_spi/mipi_spi.h | 87 ++-- esphome/components/mipi_spi/models/ili.py | 28 ++ .../components/mipi_spi/models/waveshare.py | 13 + tests/component_tests/mipi_spi/test_init.py | 4 +- .../mipi_spi/test_padding_and_offsets.py | 434 ++++++++++++++++++ 9 files changed, 662 insertions(+), 87 deletions(-) create mode 100644 tests/component_tests/mipi_spi/test_padding_and_offsets.py diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index c3b744c919..129befe600 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -139,6 +139,8 @@ MADCTL_FLIP_FLAG = 0x100 # meta-flag to indicate use of axis flips # Special constant for delays in command sequences DELAY_FLAG = 0xFFF # Special flag to indicate a delay +CONF_PAD_HEIGHT = "pad_height" +CONF_PAD_WIDTH = "pad_width" CONF_PIXEL_MODE = "pixel_mode" CONF_USE_AXIS_FLIPS = "use_axis_flips" @@ -202,6 +204,8 @@ def dimension_schema(rounding): rounding ), cv.Optional(CONF_OFFSET_WIDTH, default=0): validate_dimension(rounding), + cv.Optional(CONF_PAD_WIDTH): validate_dimension(rounding), + cv.Optional(CONF_PAD_HEIGHT): validate_dimension(rounding), } ), ) @@ -311,6 +315,36 @@ class DriverChip: name = name.upper() self.name = name self.initsequence = initsequence + if CONF_NATIVE_WIDTH in defaults: + if CONF_WIDTH not in defaults: + defaults[CONF_WIDTH] = ( + defaults[CONF_NATIVE_WIDTH] + - defaults.get(CONF_OFFSET_WIDTH, 0) + - defaults.get(CONF_PAD_WIDTH, 0) + ) + else: + native_width = ( + defaults.get(CONF_WIDTH, 0) + + defaults.get(CONF_OFFSET_WIDTH, 0) + + defaults.get(CONF_PAD_WIDTH, 0) + ) + if native_width != 0: + defaults[CONF_NATIVE_WIDTH] = native_width + if CONF_NATIVE_HEIGHT in defaults: + if CONF_HEIGHT not in defaults: + defaults[CONF_HEIGHT] = ( + defaults[CONF_NATIVE_HEIGHT] + - defaults.get(CONF_OFFSET_HEIGHT, 0) + - defaults.get(CONF_PAD_HEIGHT, 0) + ) + else: + native_height = ( + defaults.get(CONF_HEIGHT, 0) + + defaults.get(CONF_OFFSET_HEIGHT, 0) + + defaults.get(CONF_PAD_HEIGHT, 0) + ) + if native_height != 0: + defaults[CONF_NATIVE_HEIGHT] = native_height self.defaults = defaults DriverChip.models[name] = self @@ -336,18 +370,6 @@ class DriverChip: initsequence = list(kwargs.pop("initsequence", self.initsequence)) initsequence.extend(kwargs.pop("add_init_sequence", ())) defaults = self.defaults.copy() - if ( - CONF_WIDTH in defaults - and CONF_OFFSET_WIDTH in kwargs - and CONF_NATIVE_WIDTH not in defaults - ): - defaults[CONF_NATIVE_WIDTH] = defaults[CONF_WIDTH] - if ( - CONF_HEIGHT in defaults - and CONF_OFFSET_HEIGHT in kwargs - and CONF_NATIVE_HEIGHT not in defaults - ): - defaults[CONF_NATIVE_HEIGHT] = defaults[CONF_HEIGHT] defaults.update(kwargs) return self.__class__(name, initsequence=tuple(initsequence), **defaults) @@ -385,13 +407,16 @@ class DriverChip: return CONF_SWAP_XY in transforms and CONF_MIRROR_X in transforms return CONF_SWAP_XY in transforms and CONF_MIRROR_Y in transforms - def get_dimensions(self, config, swap: bool = True) -> tuple[int, int, int, int]: + def get_dimensions( + self, config, swap: bool = True + ) -> tuple[int, int, int, int, int, int]: """ Return the dimensions of the current model. :param config: The current configuration :param swap: If width/height should be swapped when axes are swapped. - :return: + :return: A tuple (width, height, offset_width, offset_height, pad_width, pad_height). """ + if CONF_DIMENSIONS in config: # Explicit dimensions, just use as is dimensions = config[CONF_DIMENSIONS] @@ -400,33 +425,71 @@ class DriverChip: height = dimensions[CONF_HEIGHT] offset_width = dimensions[CONF_OFFSET_WIDTH] offset_height = dimensions[CONF_OFFSET_HEIGHT] - return width, height, offset_width, offset_height - (width, height) = dimensions - return width, height, 0, 0 + if CONF_PAD_WIDTH in dimensions: + pad_width = dimensions[CONF_PAD_WIDTH] + native_width = width + offset_width + pad_width + else: + native_width = self.get_default(CONF_NATIVE_WIDTH, 0) + if native_width == 0: + pad_width = 0 + native_width = width + offset_width + else: + pad_width = native_width - width - offset_width + if CONF_PAD_HEIGHT in dimensions: + pad_height = dimensions[CONF_PAD_HEIGHT] + native_height = height + offset_height + pad_height + else: + native_height = self.get_default(CONF_NATIVE_HEIGHT, 0) + if native_height == 0: + pad_height = 0 + native_height = height + offset_height + else: + pad_height = native_height - height - offset_height + if ( + pad_width + offset_width >= native_width + or pad_height + offset_height >= native_height + ): + raise cv.Invalid("Dimensions exceed native size", [CONF_DIMENSIONS]) + if pad_width < 0 or pad_height < 0: + raise cv.Invalid("Invalid offsets", [CONF_DIMENSIONS]) + + return width, height, offset_width, offset_height, pad_width, pad_height + + # Must be a tuple + width, height = dimensions + return width, height, 0, 0, 0, 0 # Default dimensions, use model defaults transform = self.get_transform(config) width = self.get_default(CONF_WIDTH) height = self.get_default(CONF_HEIGHT) + native_width = self.get_default(CONF_NATIVE_WIDTH, 0) + native_height = self.get_default(CONF_NATIVE_HEIGHT, 0) offset_width = self.get_default(CONF_OFFSET_WIDTH, 0) offset_height = self.get_default(CONF_OFFSET_HEIGHT, 0) + pad_width = self.get_default( + CONF_PAD_WIDTH, native_width - width - offset_width + ) + pad_height = self.get_default( + CONF_PAD_HEIGHT, native_height - height - offset_height + ) + + if pad_width < 0 or pad_height < 0: + raise cv.Invalid("Offsets exceed native size", [CONF_DIMENSIONS]) # if mirroring axes and there are offsets, also mirror the offsets to cater for situations where # the offset is asymmetric if transform.get(CONF_MIRROR_X): - native_width = self.get_default(CONF_NATIVE_WIDTH, width + offset_width * 2) - offset_width = native_width - width - offset_width + offset_width, pad_width = pad_width, offset_width if transform.get(CONF_MIRROR_Y): - native_height = self.get_default( - CONF_NATIVE_HEIGHT, height + offset_height * 2 - ) - offset_height = native_height - height - offset_height - # Swap default dimensions if swap_xy is set, or if rotation is 90/270 and we are not using a buffer + offset_height, pad_height = pad_height, offset_height + # Swap default dimensions if swap_xy is set, or if rotation is 90/270, and we are not using a buffer if swap and transform.get(CONF_SWAP_XY) is True: width, height = height, width offset_height, offset_width = offset_width, offset_height - return width, height, offset_width, offset_height + pad_width, pad_height = pad_height, pad_width + return width, height, offset_width, offset_height, pad_width, pad_height def get_base_transform(self, config): transform = config.get( @@ -450,20 +513,8 @@ class DriverChip: def get_transform(self, config) -> dict[str, bool]: transform = self.get_base_transform(config) - can_transform = self.rotation_as_transform(config) # Can we use the MADCTL register to set the rotation? - if can_transform and CONF_TRANSFORM not in config: - rotation = config[CONF_ROTATION] - if rotation == 180: - transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] - transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] - elif rotation == 90: - transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] - transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] - else: - transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] - transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] - transform[CONF_TRANSFORM] = True + transform[CONF_TRANSFORM] = self.rotation_as_transform(config) return transform def swap_xy_schema(self): @@ -498,8 +549,8 @@ class DriverChip: return madctl def add_madctl(self, sequence: list, config: dict): - # Add the MADCTL command to the sequence based on the configuration. - # This takes into account rotation if it can be implemented in the transform + # Add the MADCTL command to the sequence based on the base configuration. + # Rotation is not applied here, it will be done at runtime. transform = self.get_transform(config) madctl = self.get_madctl(transform, config) sequence.append((MADCTL, madctl & 0xFF)) diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 46e7a7d5a7..896140b4b1 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -172,7 +172,9 @@ def _config_schema(config): )(config) config = model_schema(config)(config) model = MODELS[config[CONF_MODEL].upper()] - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) display.add_metadata( config[CONF_ID], width, @@ -206,7 +208,9 @@ async def to_code(config): model = MODELS[config[CONF_MODEL].upper()] color_depth = COLOR_DEPTHS[get_color_depth(config)] pixel_mode = int(config[CONF_PIXEL_MODE].removesuffix("bit")) - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) var = cg.new_Pvariable(config[CONF_ID], width, height, color_depth, pixel_mode) sequence = model.get_sequence(config) diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 3c33c26726..1eacc31fc5 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -235,7 +235,9 @@ def _config_schema(config): only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4]), )(config) model = MODELS[config[CONF_MODEL].upper()] - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) display.add_metadata( config[CONF_ID], width, @@ -273,7 +275,9 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config): model = MODELS[config[CONF_MODEL].upper()] - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) var = cg.new_Pvariable(config[CONF_ID], width, height) cg.add(var.set_model(model.name)) if enable_pin := config.get(CONF_ENABLE_PIN): diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 8c6ffff500..abb7eaa458 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -27,7 +27,7 @@ from esphome.components.mipi import ( requires_buffer, ) from esphome.components.psram import DOMAIN as PSRAM_DOMAIN -from esphome.components.spi import TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE +from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE import esphome.config_validation as cv from esphome.config_validation import ALLOW_EXTRA from esphome.const import ( @@ -121,7 +121,9 @@ def denominator(config): """ model = MODELS[config[CONF_MODEL]] frac = config.get(CONF_BUFFER_SIZE) - _width, height, _offset_width, _offset_height = model.get_dimensions(config) + _width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) if frac is None or frac > 0.75 or height < 32: return 1 try: @@ -169,11 +171,22 @@ def model_schema(config): ] if bus_mode == TYPE_SINGLE: other_options.append(CONF_SPI_16) + # Calculate default SPI mode. Mode3 for octal bus or single bus with no cs pin, mode0 otherwise. + spi_mode = model.get_default(CONF_SPI_MODE) + if not spi_mode: + if bus_mode == TYPE_OCTAL or ( + bus_mode == TYPE_SINGLE + and not config.get(CONF_CS_PIN, model.get_default(CONF_CS_PIN)) + ): + spi_mode = "MODE3" + else: + spi_mode = "MODE0" + schema = ( display.FULL_DISPLAY_SCHEMA.extend( spi.spi_device_schema( cs_pin_required=False, - default_mode="MODE3" if bus_mode == TYPE_OCTAL else "MODE0", + default_mode=spi_mode, default_data_rate=model.get_default(CONF_DATA_RATE, 10_000_000), mode=bus_mode, ) @@ -279,8 +292,8 @@ def customise_schema(config): CONF_MIRROR_Y, CONF_SWAP_XY, } - width, height, _offset_width, _offset_height = model.get_dimensions( - config, not has_hardware_transform + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config, not has_hardware_transform) ) display.add_metadata( config[CONF_ID], @@ -313,14 +326,17 @@ def _final_validate(config): # If no drawing methods are configured, and LVGL is not enabled, show a test card config[CONF_SHOW_TEST_CARD] = True + # Always call this to check dimensions during validation + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) + if PSRAM_DOMAIN not in global_config and CONF_BUFFER_SIZE not in config: # If PSRAM is not enabled, choose a small buffer size by default if not requires_buffer(config): return # No need to pick a size color_depth = get_color_depth(config) frac = denominator(config) - width, height, _offset_width, _offset_height = model.get_dimensions(config) - buffer_size = color_depth // 8 * width * height // frac # Target a buffer size of 20kB, except for large displays, which shouldn't end up here fraction = min(20000.0, buffer_size // 4) / buffer_size @@ -347,8 +363,8 @@ def get_instance(config): CONF_MIRROR_Y, CONF_SWAP_XY, } - width, height, offset_width, offset_height = model.get_dimensions( - config, not has_hardware_transform + width, height, offset_width, offset_height, pad_width, pad_height = ( + model.get_dimensions(config, not has_hardware_transform) ) color_depth = int(config[CONF_COLOR_DEPTH].removesuffix("bit")) @@ -374,6 +390,8 @@ def get_instance(config): height, offset_width, offset_height, + pad_width, + pad_height, madctl, has_hardware_transform, ] diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 5023cf8089..a594e48209 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -81,10 +81,15 @@ void internal_dump_config(const char *model, int width, int height, int offset_w * @tparam HEIGHT Height of the display in pixels * @tparam OFFSET_WIDTH The x-offset of the display in pixels * @tparam OFFSET_HEIGHT The y-offset of the display in pixels + * @tparam PAD_WIDTH Additional pixels recognised by the controller after the offset and width + * @tparam PAD_HEIGHT Additional lines recognised by the controller after the offset and width + * @tparam MADCTL The base MADCTL value for the display, with no rotation bits set. + * @tparam HAS_HARDWARE_ROTATION Whether the display supports hardware rotation. * buffer */ template + int WIDTH, int HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, int PAD_WIDTH, int PAD_HEIGHT, uint16_t MADCTL, + bool HAS_HARDWARE_ROTATION> class MipiSpi : public display::Display, public spi::SPIDevice { @@ -126,17 +131,6 @@ class MipiSpi : public display::Display, return HEIGHT; } - // If hardware rotation is in use, the actual display width/height changes with rotation - int get_width_internal() override { - if constexpr (HAS_HARDWARE_ROTATION) - return get_width(); - return WIDTH; - } - int get_height_internal() override { - if constexpr (HAS_HARDWARE_ROTATION) - return get_height(); - return HEIGHT; - } void set_init_sequence(const std::vector &sequence) { this->init_sequence_ = sequence; } // reset the display, and write the init sequence @@ -233,14 +227,25 @@ class MipiSpi : public display::Display, } void dump_config() override { - internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT, - (uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_, - this->cs_, this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE, - HAS_HARDWARE_ROTATION); + internal_dump_config(this->model_, this->get_width(), this->get_height(), this->get_offset_width_(), + this->get_offset_height_(), (uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8, + IS_BIG_ENDIAN, this->brightness_, this->cs_, this->reset_pin_, this->dc_pin_, this->mode_, + this->data_rate_, BUS_TYPE, HAS_HARDWARE_ROTATION); } protected: /* METHODS */ + // If hardware rotation is in use, the actual display width/height changes with rotation + int get_width_internal() override { + if constexpr (HAS_HARDWARE_ROTATION) + return get_width(); + return WIDTH; + } + int get_height_internal() override { + if constexpr (HAS_HARDWARE_ROTATION) + return get_height(); + return HEIGHT; + } // convenience functions to write commands with or without data void write_command_(uint8_t cmd, uint8_t data) { this->write_command_(cmd, &data, 1); } void write_command_(uint8_t cmd) { this->write_command_(cmd, &cmd, 0); } @@ -330,20 +335,34 @@ class MipiSpi : public display::Display, this->write_command_(MADCTL_CMD, madctl); } - uint16_t get_offset_width_() { + uint16_t get_offset_width_() const { if constexpr (HAS_HARDWARE_ROTATION) { - if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || - this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) - return OFFSET_HEIGHT; + switch (this->rotation_) { + case display::DISPLAY_ROTATION_90_DEGREES: + return OFFSET_HEIGHT; + case display::DISPLAY_ROTATION_180_DEGREES: + return PAD_WIDTH; + case display::DISPLAY_ROTATION_270_DEGREES: + return PAD_HEIGHT; + default: + break; + } } return OFFSET_WIDTH; } - uint16_t get_offset_height_() { + uint16_t get_offset_height_() const { if constexpr (HAS_HARDWARE_ROTATION) { - if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || - this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) - return OFFSET_WIDTH; + switch (this->rotation_) { + case display::DISPLAY_ROTATION_90_DEGREES: + return PAD_WIDTH; + case display::DISPLAY_ROTATION_180_DEGREES: + return PAD_HEIGHT; + case display::DISPLAY_ROTATION_270_DEGREES: + return OFFSET_WIDTH; + default: + break; + } } return OFFSET_HEIGHT; } @@ -396,7 +415,7 @@ class MipiSpi : public display::Display, this->write_cmd_addr_data(0, 0, 0, 0, ptr, w * h, 8); } } else { - for (size_t y = 0; y != static_cast(h); y++) { + for (size_t y = 0; y != h; y++) { if constexpr (BUS_TYPE == BUS_TYPE_SINGLE || BUS_TYPE == BUS_TYPE_SINGLE_16) { this->write_array(ptr, w); } else if constexpr (BUS_TYPE == BUS_TYPE_QUAD) { @@ -492,19 +511,23 @@ class MipiSpi : public display::Display, * @tparam BUFFERPIXEL Color depth of the buffer * @tparam DISPLAYPIXEL Color depth of the display * @tparam BUS_TYPE The type of the interface bus (single, quad, octal) - * @tparam ROTATION The rotation of the display * @tparam WIDTH Width of the display in pixels * @tparam HEIGHT Height of the display in pixels * @tparam OFFSET_WIDTH The x-offset of the display in pixels * @tparam OFFSET_HEIGHT The y-offset of the display in pixels + * @tparam PAD_WIDTH Additional pixels recognised by the controller after the offset and width + * @tparam PAD_HEIGHT Additional lines recognised by the controller after the offset and width + * @tparam MADCTL The base MADCTL value for the display, with no rotation bits set. + * @tparam HAS_HARDWARE_ROTATION Whether the display supports hardware rotation. * @tparam FRACTION The fraction of the display size to use for the buffer (e.g. 4 means a 1/4 buffer). * @tparam ROUNDING The alignment requirement for drawing operations (e.g. 2 means that x coordinates must be even) */ template -class MipiSpiBuffer : public MipiSpi { + uint16_t WIDTH, uint16_t HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, int PAD_WIDTH, int PAD_HEIGHT, + uint16_t MADCTL, bool HAS_HARDWARE_ROTATION, int FRACTION, unsigned ROUNDING> +class MipiSpiBuffer + : public MipiSpi { public: // these values define the buffer size needed to write in accordance with the chip pixel alignment // requirements. If the required rounding does not divide the width and height, we round up to the next multiple and @@ -515,7 +538,7 @@ class MipiSpiBuffer : public MipiSpi::dump_config(); + PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION>::dump_config(); esph_log_config(TAG, " Rotation: %d°\n" " Buffer pixels: %d bits\n" @@ -528,7 +551,7 @@ class MipiSpiBuffer : public MipiSpi::setup(); + PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION>::setup(); RAMAllocator allocator{}; this->buffer_ = allocator.allocate(round_buffer(WIDTH) * round_buffer(HEIGHT) / FRACTION); if (this->buffer_ == nullptr) { diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index ae6accb907..5df7a275df 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -179,6 +179,9 @@ ILI9342 = DriverChip( # M5Stack Core2 uses ILI9341 chip - mirror_x disabled for correct orientation ILI9341.extend( "M5CORE2", + # Reset native dimensions due to axis swap. + native_width=320, + native_height=240, width=320, height=240, mirror_x=False, @@ -786,3 +789,28 @@ ST7796.extend( dc_pin=0, invert_colors=True, ) + +ST7789V.extend( + "GEEKMAGIC-SMALLTV", + data_rate="40MHz", + height=240, + width=240, + offset_width=0, + offset_height=0, + invert_colors=True, + buffer_size=0.125, + reset_pin=2, + dc_pin=0, +) +ST7789V.extend( + "GEEKMAGIC-SMALLTV-PRO", + data_rate="40MHz", + height=240, + width=240, + offset_width=0, + offset_height=0, + invert_colors=True, + buffer_size=0.125, + reset_pin=4, + dc_pin=2, +) diff --git a/esphome/components/mipi_spi/models/waveshare.py b/esphome/components/mipi_spi/models/waveshare.py index ee46f931de..3c719b0f5e 100644 --- a/esphome/components/mipi_spi/models/waveshare.py +++ b/esphome/components/mipi_spi/models/waveshare.py @@ -269,3 +269,16 @@ ST7789V.extend( cs_pin=14, dc_pin={"number": 15, "ignore_strapping_warning": True}, ) + +ST7789V.extend( + "WAVESHARE-ESP32-S3-GEEK", + cs_pin=10, + dc_pin=8, + reset_pin=9, + width=135, + height=240, + offset_width=52, + offset_height=40, + invert_colors=True, + data_rate="40MHz", +) diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index 4873892a8d..d681908027 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -314,7 +314,7 @@ def test_native_generation( main_cpp = generate_main(component_fixture_path("native.yaml")) assert ( - "mipi_spi::MipiSpiBuffer()" + "mipi_spi::MipiSpiBuffer()" in main_cpp ) assert "set_init_sequence({240, 1, 8, 242" in main_cpp @@ -330,7 +330,7 @@ def test_lvgl_generation( main_cpp = generate_main(component_fixture_path("lvgl.yaml")) assert ( - "mipi_spi::MipiSpi();" + "mipi_spi::MipiSpi();" in main_cpp ) assert "set_init_sequence({1, 0, 10, 255, 177" in main_cpp diff --git a/tests/component_tests/mipi_spi/test_padding_and_offsets.py b/tests/component_tests/mipi_spi/test_padding_and_offsets.py new file mode 100644 index 0000000000..82adf88b7e --- /dev/null +++ b/tests/component_tests/mipi_spi/test_padding_and_offsets.py @@ -0,0 +1,434 @@ +"""Tests for padding, offset calculation, and SPI mode configuration in mipi_spi.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.esp32 import ( + KEY_BOARD, + KEY_VARIANT, + VARIANT_ESP32, + VARIANT_ESP32S3, +) +from esphome.components.mipi_spi.display import ( + CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA, + MODELS, + get_instance, +) +from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE +from esphome.const import CONF_CS_PIN, CONF_DC_PIN, PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def validated_config(config: ConfigType) -> ConfigType: + """Run schema + final validation and return the validated config.""" + config = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(config) + return config + + +class TestSPIModeCalculation: + """Test default SPI mode calculation logic.""" + + @pytest.mark.parametrize( + ("bus_mode", "cs_pin", "expected_mode"), + [ + pytest.param( + TYPE_OCTAL, + None, + "MODE3", + id="octal_bus_no_cs", + ), + pytest.param( + TYPE_OCTAL, + 14, + "MODE3", + id="octal_bus_with_cs", + ), + pytest.param( + TYPE_SINGLE, + None, + "MODE3", + id="single_bus_no_cs", + ), + pytest.param( + TYPE_SINGLE, + 14, + "MODE0", + id="single_bus_with_cs", + ), + pytest.param( + TYPE_QUAD, + None, + "MODE0", + id="quad_bus_no_cs", + ), + pytest.param( + TYPE_QUAD, + 14, + "MODE0", + id="quad_bus_with_cs", + ), + ], + ) + def test_default_spi_mode_calculation( + self, + bus_mode: str, + cs_pin: int | None, + expected_mode: str, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that SPI mode is correctly calculated based on bus mode and CS pin.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + config: ConfigType = { + "model": "custom", + "dimensions": {"width": 320, "height": 240}, + "init_sequence": [[0xA0, 0x01]], + "bus_mode": bus_mode, + } + + # Add dc_pin for modes that require it (single and octal) + # quad mode does not allow dc_pin + if bus_mode != TYPE_QUAD: + config[CONF_DC_PIN] = 11 + + # Add CS pin if specified + if cs_pin is not None: + config[CONF_CS_PIN] = cs_pin + + validated = validated_config(config) + # The validated config should have the correct SPI mode set by model_schema + assert validated.get(CONF_SPI_MODE) == expected_mode + + def test_explicit_spi_mode_overrides_default( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that an explicitly configured SPI mode is not overridden.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + # For octal bus, default is MODE3, but we specify MODE0 + config = validated_config( + { + "model": "custom", + "dc_pin": 11, # Required for octal mode + "dimensions": {"width": 320, "height": 240}, + "init_sequence": [[0xA0, 0x01]], + "bus_mode": TYPE_OCTAL, + "spi_mode": "MODE0", # Explicitly set + } + ) + + assert config[CONF_SPI_MODE] == "MODE0" + + +class TestModelWithPaddingDimensions: + """Test that padding dimensions are correctly returned by models.""" + + def test_model_get_dimensions_returns_six_values( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that get_dimensions() returns 6 values including padding.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + # Test with a real model + model = MODELS["ST7735"] + config = {"model": "ST7735", "dc_pin": 18} + + # Call get_dimensions - should return 6 values (width, height, offset_x, offset_y, pad_width, pad_height) + dimensions = model.get_dimensions(config) + assert len(dimensions) == 6 + assert all(isinstance(v, int) for v in dimensions) + + def test_custom_model_padding_values( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test padding values for a custom model with explicit offset.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, + "height": 320, + "offset_width": 20, + "offset_height": 10, + }, + "init_sequence": [[0xA0, 0x01]], + } + ) + + # For custom models, the model is created dynamically from the config + # We can verify the config has the right dimensions + assert config["dimensions"]["width"] == 240 + assert config["dimensions"]["height"] == 320 + assert config["dimensions"]["offset_width"] == 20 + assert config["dimensions"]["offset_height"] == 10 + # Padding is not stored in config for custom models (defaults to 0) + assert config["dimensions"].get("offset_width_pad", 0) == 0 + assert config["dimensions"].get("offset_height_pad", 0) == 0 + + +class TestNewModelVariants: + """Test new model variants added in this change.""" + + def test_m5core2_with_native_dimensions( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test M5CORE2 variant with reset native_width and native_height.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + # M5CORE2 should validate successfully + config = validated_config({"model": "M5CORE2"}) + assert config is not None + + # Verify the model has correct dimensions + model = MODELS["M5CORE2"] + dimensions = model.get_dimensions(config) + width, height, _, _, _, _ = dimensions + assert width == 320 + assert height == 240 + + def test_geekmagic_smalltv_variant( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test GEEKMAGIC-SMALLTV variant of ST7789V.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # GEEKMAGIC-SMALLTV should validate successfully + config = validated_config({"model": "GEEKMAGIC-SMALLTV"}) + assert config is not None + + # Verify it's a variant of ST7789V with expected dimensions + model = MODELS["GEEKMAGIC-SMALLTV"] + dimensions = model.get_dimensions(config) + width, height, offset_x, offset_y, _, _ = dimensions + assert width == 240 + assert height == 240 + assert offset_x == 0 + assert offset_y == 0 + + def test_all_predefined_models_with_new_get_dimensions_signature( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Verify all predefined models work with new 6-value get_dimensions().""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + for name, model in MODELS.items(): + # Skip custom model + if name == "custom": + continue + + config = {"model": name} + + # Try to get dimensions - should return 6 values for all models + dimensions = model.get_dimensions(config) + assert len(dimensions) == 6, ( + f"Model {name} should return 6 dimensions, got {len(dimensions)}" + ) + + +class TestTemplateParameterPassing: + """Test that padding parameters are correctly passed to C++ templates.""" + + def test_instance_creation_with_padding( + self, + generate_main: Callable[[str | Path], str], + component_fixture_path: Callable[[str], Path], + ) -> None: + """Test that get_instance() correctly passes padding parameters to template.""" + main_cpp = generate_main(component_fixture_path("native.yaml")) + + # native.yaml uses JC3636W518 which should have 8 template parameters for MipiSpiBuffer + # (BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DISPLAYPIXEL, BUS_TYPE, + # WIDTH, HEIGHT, OFFSET_WIDTH, OFFSET_HEIGHT, PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION, + # FRACTION, ROUNDING) + # The instantiation should include padding values (0, 0 for default) + assert ( + "mipi_spi::MipiSpiBuffer()" + in main_cpp + ), ( + "Padding parameters (0, 0) should be in the MipiSpiBuffer template instantiation" + ) + + def test_single_mode_with_offset_padding( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that single-mode display with custom offset works with padding.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, + "height": 320, + "offset_width": 40, + "offset_height": 20, + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + # Should not raise any errors + instance = get_instance(config) + assert instance is not None + + +class TestUserConfiguredPadding: + """Test that pad_width and pad_height can be configured in user dimensions.""" + + def test_explicit_pad_width_and_height_in_dimensions( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that pad_width and pad_height can be explicitly set in dimensions.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, + "height": 320, + "offset_width": 40, + "offset_height": 20, + "pad_width": 80, + "pad_height": 40, + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + # Config should validate successfully with padding dimensions + assert config is not None + assert config["dimensions"]["pad_width"] == 80 + assert config["dimensions"]["pad_height"] == 40 + + def test_padding_for_native_dimension_calculation( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that explicit padding allows native dimensions to be calculated.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # A controller that has 320x320 total pixels with: + # - 240x320 active display area + # - offset_width=40, offset_height=20 + # - pad_width=40 (remaining pixels on right), pad_height=60 (remaining pixels on bottom) + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, # Active display width + "height": 320, # Active display height + "offset_width": 40, + "offset_height": 0, + "pad_width": 40, # Pixels after width+offset + "pad_height": 0, # Pixels after height+offset + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + # Get instance should work and correctly calculate native dimensions + instance = get_instance(config) + assert instance is not None + + def test_padding_without_offset( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test padding can be used without offset for controllers with top-left-aligned displays.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # A display with no offset but padding on right and bottom + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, + "height": 240, + "offset_width": 0, + "offset_height": 0, + "pad_width": 0, + "pad_height": 16, + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + assert config is not None + assert config["dimensions"]["width"] == 240 + assert config["dimensions"]["height"] == 240 + assert config["dimensions"]["pad_height"] == 16 From 1e5771a3fa446c0de961a9a667efc19c8002ec5c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:48:43 -0400 Subject: [PATCH 0388/1815] [esp32] Fix idedata generation failing on unset ESPHOME_ARDUINO (#16925) --- .clang-tidy.hash | 2 +- esphome/components/esp32/pre_build.py.script | 7 +++++++ esphome/espidf/clang_tidy.py | 2 +- esphome/idf_component.yml | 2 +- platformio.ini | 18 +++++++++++++----- tests/unit_tests/test_espidf_clang_tidy.py | 6 +++--- 6 files changed, 26 insertions(+), 11 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 6f6339ff84..7497cc3679 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -442b8197be00e6fee6b1b64b07a0e3b3558188fddf1d9c510565da884687c451 +a6ec18b82143e293ca6dee6947217f10a387ace99881a34b2c308ff627c8173c diff --git a/esphome/components/esp32/pre_build.py.script b/esphome/components/esp32/pre_build.py.script index af12275a0b..8728e02a34 100644 --- a/esphome/components/esp32/pre_build.py.script +++ b/esphome/components/esp32/pre_build.py.script @@ -1,3 +1,5 @@ +import os + Import("env") # noqa: F821 # Remove custom_sdkconfig from the board config as it causes @@ -7,3 +9,8 @@ if "espidf.custom_sdkconfig" in board: del board._manifest["espidf"]["custom_sdkconfig"] if not board._manifest["espidf"]: del board._manifest["espidf"] + +# Referenced by rules in esphome/idf_component.yml; an unset env var is a +# fatal error there. Always 0: in PlatformIO builds arduino is not a managed +# IDF component. +os.environ.setdefault("ESPHOME_ARDUINO_COMPONENT", "0") diff --git a/esphome/espidf/clang_tidy.py b/esphome/espidf/clang_tidy.py index 62d6f0d00d..d3f4d151c2 100644 --- a/esphome/espidf/clang_tidy.py +++ b/esphome/espidf/clang_tidy.py @@ -162,7 +162,7 @@ def _setup_core(work_dir: Path, settings: _Settings) -> None: # Gates arduino-only components in esphome/idf_component.yml (IDF reads it at # reconfigure time). Set here -- before the manifest is written/reconfigured. - os.environ["ESPHOME_ARDUINO"] = ( + os.environ["ESPHOME_ARDUINO_COMPONENT"] = ( "1" if settings.target_framework == "arduino" else "0" ) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 7cbc2ac4ae..c97e8906a8 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -109,4 +109,4 @@ dependencies: git: https://github.com/FastLED/FastLED.git version: d44c800a9e876a8394caefc2ce4915dd96dac77b rules: - - if: "$ESPHOME_ARDUINO == 1" + - if: "$ESPHOME_ARDUINO_COMPONENT == 1" diff --git a/platformio.ini b/platformio.ini index 718dfb672f..862b7a7dbe 100644 --- a/platformio.ini +++ b/platformio.ini @@ -141,7 +141,10 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script ; This are common settings for the ESP32 (all variants) using Arduino. [common:esp32-arduino] extends = common:arduino -platform = https://github.com/pioarduino/platform-espressif32.git +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip +platform_packages = + pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.9/esp32-core-3.3.9.tar.xz + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz framework = arduino, espidf ; Arduino as an ESP-IDF component lib_deps = @@ -168,12 +171,16 @@ build_flags = -DAUDIO_NO_SD_FS ; i2s_audio build_unflags = ${common.build_unflags} -extra_scripts = post:esphome/components/esp32/post_build.py.script +extra_scripts = + pre:esphome/components/esp32/pre_build.py.script + post:esphome/components/esp32/post_build.py.script ; This are common settings for the ESP32 (all variants) using IDF. [common:esp32-idf] extends = common:idf -platform = https://github.com/pioarduino/platform-espressif32.git +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip +platform_packages = + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz framework = espidf lib_deps = @@ -187,7 +194,9 @@ build_flags = -DUSE_ESP32_FRAMEWORK_ESP_IDF build_unflags = ${common.build_unflags} -extra_scripts = post:esphome/components/esp32/post_build.py.script +extra_scripts = + pre:esphome/components/esp32/pre_build.py.script + post:esphome/components/esp32/post_build.py.script ; These are common settings for the RP2040 using Arduino. [common:rp2040-arduino] @@ -271,7 +280,6 @@ build_unflags = [env:esp32-arduino] extends = common:esp32-arduino board = esp32dev -board_build.partitions = huge_app.csv build_flags = ${common:esp32-arduino.build_flags} ${flags:runtime.build_flags} diff --git a/tests/unit_tests/test_espidf_clang_tidy.py b/tests/unit_tests/test_espidf_clang_tidy.py index 9791dfc543..cb25535d8d 100644 --- a/tests/unit_tests/test_espidf_clang_tidy.py +++ b/tests/unit_tests/test_espidf_clang_tidy.py @@ -56,11 +56,11 @@ def test_setup_core_sets_arduino_env( target_framework: str, expected: str, ) -> None: - """_setup_core sets ESPHOME_ARDUINO, which gates arduino-only manifest deps.""" + """_setup_core sets ESPHOME_ARDUINO_COMPONENT, which gates arduino-only manifest deps.""" # monkeypatch snapshots os.environ, so the env var _setup_core writes is # restored after the test instead of leaking into later tests. - monkeypatch.delenv("ESPHOME_ARDUINO", raising=False) + monkeypatch.delenv("ESPHOME_ARDUINO_COMPONENT", raising=False) _setup_core(tmp_path / "proj", _settings(target_framework=target_framework)) - assert os.environ["ESPHOME_ARDUINO"] == expected + assert os.environ["ESPHOME_ARDUINO_COMPONENT"] == expected From e191fc5d47284c2e0609c4fe368847d1fb33e79f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:56:03 -0400 Subject: [PATCH 0389/1815] [core] Support platformio_options on the native ESP-IDF toolchain (#16917) --- esphome/core/__init__.py | 7 + esphome/core/config.py | 66 ++++++++-- esphome/espidf/component.py | 55 ++++++-- tests/unit_tests/core/test_config.py | 123 ++++++++++++++++++ .../fixtures/core/config/libraries.yaml | 8 ++ tests/unit_tests/test_core.py | 18 +++ tests/unit_tests/test_espidf_component.py | 122 ++++++++++++++++- 7 files changed, 366 insertions(+), 33 deletions(-) create mode 100644 tests/unit_tests/fixtures/core/config/libraries.yaml diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 4289cdf3e5..21ff7ef07c 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -958,6 +958,13 @@ class EsphomeCore: return build_flag def add_build_unflag(self, build_unflag: str) -> None: + if self.using_toolchain_esp_idf: + # The native ESP-IDF build generator does not consume build_unflags + _LOGGER.warning( + "Build unflag %s is ignored when building with the native " + "ESP-IDF toolchain", + build_unflag, + ) self.build_unflags.add(build_unflag) _LOGGER.debug("Adding build unflag: %s", build_unflag) diff --git a/esphome/core/config.py b/esphome/core/config.py index 8214fcf80c..b925f0b7d9 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -503,8 +503,58 @@ async def add_includes(includes: list[str], is_c_header: bool = False) -> None: include_file(path, basename, is_c_header) +def _add_library_str(lib: str) -> None: + if "@" in lib: + name, vers = lib.split("@", 1) + cg.add_library(name, vers) + elif "://" in lib: + # Repository... + if "=" in lib: + name, repo = lib.split("=", 1) + cg.add_library(name, None, repo) + else: + cg.add_library(None, None, lib) + else: + cg.add_library(lib, None) + + @coroutine_with_priority(CoroPriority.FINAL) -async def _add_platformio_options(pio_options): +async def _add_platformio_options(pio_options: dict[str, str | list[str]]) -> None: + if CORE.using_toolchain_esp_idf: + # The native ESP-IDF build doesn't read platformio.ini; honor the + # options with a native equivalent and warn about the rest, which + # would otherwise be silently ignored. + for key, val in pio_options.items(): + vals = [val] if isinstance(val, str) else val + if key == CONF_BUILD_FLAGS: + # Deprecated: esphome->build_flags is the native equivalent. + # Remove before 2026.12.0 + _LOGGER.warning( + "esphome->platformio_options->build_flags is deprecated; use " + "esphome->build_flags instead. Support for it will be removed " + "in 2026.12.0." + ) + for flag in vals: + cg.add_build_flag(flag) + elif key == "lib_deps": + # Routed through the regular library mechanism so the libraries + # are converted to IDF components like any other PIO library + for lib in vals: + _add_library_str(lib) + elif key == "lib_ignore": + # Read by the PIO-library-to-IDF-component conversion + # (generate_idf_components); filters both top-level libraries + # and dependencies discovered during conversion + cg.add_platformio_option(key, vals) + elif key != "upload_speed": + # upload_speed needs no handling: it is read from the raw + # config at upload time (upload_using_esptool) + _LOGGER.warning( + "esphome->platformio_options->%s is ignored when building with " + "the native ESP-IDF toolchain", + key, + ) + return # Add includes at the very end, so that they override everything for key, val in pio_options.items(): if key in ["build_flags", "lib_ignore"] and not isinstance(val, list): @@ -655,19 +705,7 @@ async def to_code(config: ConfigType) -> None: # Libraries for lib in config[CONF_LIBRARIES]: - if "@" in lib: - name, vers = lib.split("@", 1) - cg.add_library(name, vers) - elif "://" in lib: - # Repository... - if "=" in lib: - name, repo = lib.split("=", 1) - cg.add_library(name, None, repo) - else: - cg.add_library(None, None, lib) - - else: - cg.add_library(lib, None) + _add_library_str(lib) cg.add_build_flag("-Wno-unused-variable") cg.add_build_flag("-Wno-unused-but-set-variable") diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 7398a91c36..cfd42916b2 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -56,7 +56,7 @@ ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE" class Source: - def download(self, dir_suffix: str, force: bool = False) -> Path: + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: raise NotImplementedError @@ -64,10 +64,12 @@ class URLSource(Source): def __init__(self, url: str): self.url = url - def download(self, dir_suffix: str, force: bool = False) -> Path: + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: base_dir = Path(CORE.data_dir) / DOMAIN h = hashlib.new("sha256") h.update(self.url.encode()) + if salt: + h.update(salt.encode()) path = base_dir / h.hexdigest()[:8] / dir_suffix # Marker file written last to signal a complete extraction. Using a # marker (instead of just `path.is_dir()`) means an interrupted @@ -99,12 +101,12 @@ class GitSource(Source): self.url = url self.ref = ref - def download(self, dir_suffix: str, force: bool = False) -> Path: + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: path, _ = git.clone_or_update( url=self.url, ref=self.ref, refresh=git.NEVER_REFRESH if not force else None, - domain=DOMAIN, + domain=f"{DOMAIN}/{salt}" if salt else DOMAIN, submodules=[], subpath=Path(dir_suffix), ) @@ -146,14 +148,16 @@ class IDFComponent: def get_require_name(self): return self.get_sanitized_name().replace("/", "__") - def download(self, force: bool = False): + def download(self, force: bool = False, salt: str = ""): """ The dependency name should match the directory name at the end of the override path. The ESP-IDF build system uses the directory name as the component name, so the directory of the override_path should match the component name. If you want to specify the full name of the component with the namespace, replace / in the component name with __. @see https://docs.espressif.com/projects/idf-component-manager/en/latest/reference/manifest_file.html """ - self.path = self.source.download(self.get_sanitized_name(), force=force) + self.path = self.source.download( + self.get_sanitized_name(), force=force, salt=salt + ) def _apply_extra_script(component: IDFComponent) -> None: @@ -699,9 +703,33 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: The returned list holds the top-level components (those directly requested); transitive dependencies are converted too and wired into each component's generated manifest. + + ``lib_ignore`` from ``esphome->platformio_options`` excludes libraries by + short name (part after the ``/``), matched against both the top-level + libraries and every dependency discovered during the graph walk. """ nodes: dict[str, _LibNode] = {} + lib_ignore = { + name.split("/")[-1].lower() + for name in CORE.platformio_options.get("lib_ignore", []) + } + + # The generated CMakeLists.txt/idf_component.yml inside the shared cache + # bake in the dependency wiring, which lib_ignore changes; salt the cache + # path so configs with different lib_ignore values don't fight over (and + # constantly rewrite) the same converted component files. + salt = ( + hashlib.sha256(",".join(sorted(lib_ignore)).encode()).hexdigest()[:8] + if lib_ignore + else "" + ) + + def is_ignored(name: str | None) -> bool: + if not lib_ignore or name is None: + return False + return name.split("/")[-1].lower() in lib_ignore + def add_spec(name: str | None, version: str | None, repository: str | None) -> str: key, is_git, locator = _node_key(name, version, repository) node = nodes.get(key) or _LibNode(key=key, is_git=is_git) @@ -718,6 +746,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: top_level = [ add_spec(library.name, library.version, library.repository) for library in libraries + if not is_ignored(library.name) ] # Collect + resolve to a fixpoint: a node is (re)resolved whenever its @@ -749,7 +778,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: component = IDFComponent( _owner_pkgname_to_name(owner, name), version, URLSource(url) ) - component.download() + component.download(salt=salt) library_json_path = component.path / "library.json" library_properties_path = component.path / "library.properties" @@ -787,6 +816,12 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: except InvalidIDFComponent as e: _LOGGER.debug("Skip dependency %s: %s", dependency.get("name"), str(e)) continue + dep_name = _owner_pkgname_to_name( + dependency.get("owner"), dependency.get("name") + ) + if is_ignored(dep_name): + _LOGGER.debug("Skip ignored dependency %s", dep_name) + continue # The version field may actually be a URL (git/archive dependency). dep_version = dependency["version"] dep_url = None @@ -796,11 +831,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: dep_url, dep_version = dep_version, None except (TypeError, ValueError): pass - dep_key = add_spec( - _owner_pkgname_to_name(dependency.get("owner"), dependency.get("name")), - dep_version, - dep_url, - ) + dep_key = add_spec(dep_name, dep_version, dep_url) node.edges.add(dep_key) worklist.append(dep_key) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index ff150f2540..e2b34d92d8 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -20,6 +20,9 @@ from esphome.const import ( CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + Toolchain, ) from esphome.core import CORE, config from esphome.core.config import ( @@ -1161,3 +1164,123 @@ def test_make_app_name_cpp_special_chars_escaped() -> None: cpp_expr, _, _ = make_app_name_cpp('my "device"', "buf", "-", add_mac_suffix=False) # cpp_string_escape uses octal escapes for quotes assert '"' not in cpp_expr[1:-1] # no unescaped quotes inside the outer quotes + + +@pytest.mark.parametrize( + ("lib", "name", "version", "repository"), + [ + ("ArduinoJson", "ArduinoJson", None, None), + ("bblanchon/ArduinoJson@7.4.2", "bblanchon/ArduinoJson", "7.4.2", None), + ( + "noise-c=https://github.com/esphome/noise-c.git", + "noise-c", + None, + "https://github.com/esphome/noise-c.git", + ), + ], +) +def test_add_library_str( + lib: str, name: str, version: str | None, repository: str | None +) -> None: + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "esp32", + KEY_TARGET_FRAMEWORK: "esp-idf", + } + + config._add_library_str(lib) + + libraries = list(CORE.platformio_libraries.values()) + assert len(libraries) == 1 + assert libraries[0].name == name + assert libraries[0].version == version + assert libraries[0].repository == repository + + +@pytest.mark.asyncio +async def test_add_platformio_options_native_idf( + caplog: pytest.LogCaptureFixture, +) -> None: + """On the native IDF toolchain, build_flags/lib_deps/lib_ignore are + honored, upload_speed is silent and everything else warns.""" + CORE.toolchain = Toolchain.ESP_IDF + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "esp32", + KEY_TARGET_FRAMEWORK: "esp-idf", + } + + await config._add_platformio_options( + { + "build_flags": "-DSINGLE_FLAG", # string and list forms both valid + "lib_deps": ["bblanchon/ArduinoJson@7.4.2"], + "lib_ignore": "libsodium", + "upload_speed": "115200", + "board_build.f_flash": "80000000L", + } + ) + + assert "-DSINGLE_FLAG" in CORE.build_flags + assert "ArduinoJson" in CORE.platformio_libraries + # lib_ignore is stored (listified) for generate_idf_components to read; + # nothing else lands in platformio_options on the native toolchain. + assert CORE.platformio_options == {"lib_ignore": ["libsodium"]} + assert "esphome->platformio_options->board_build.f_flash is ignored" in caplog.text + assert "upload_speed" not in caplog.text + # build_flags has a first-class esphome equivalent, so it is deprecated. + # lib_deps/lib_ignore are kept as valid platformio_options (no warning). + assert ( + "esphome->platformio_options->build_flags is deprecated; use " + "esphome->build_flags instead" in caplog.text + ) + assert "lib_deps is deprecated" not in caplog.text + assert "lib_ignore is deprecated" not in caplog.text + + +@pytest.mark.asyncio +async def test_add_platformio_options_platformio( + caplog: pytest.LogCaptureFixture, +) -> None: + """On the PlatformIO toolchain all options pass through to the ini, + with build_flags/lib_ignore listified.""" + CORE.toolchain = Toolchain.PLATFORMIO + + await config._add_platformio_options( + { + "build_flags": "-DSINGLE_FLAG", + "lib_ignore": "libsodium", + "upload_speed": "115200", + } + ) + + assert CORE.platformio_options == { + "build_flags": ["-DSINGLE_FLAG"], + "lib_ignore": ["libsodium"], + "upload_speed": "115200", + } + # platformio_options is the correct mechanism on the PlatformIO toolchain, + # so the native-equivalent deprecation must not fire here. + assert "deprecated" not in caplog.text + + +def test_add_library_str_bare_url_requires_name() -> None: + """A bare repository URL has no library name; CORE.add_library rejects it.""" + with pytest.raises(ValueError, match="must have a name"): + config._add_library_str("https://github.com/esphome/noise-c.git") + + +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::RuntimeWarning") +async def test_to_code_adds_libraries(yaml_file: Callable[[str], Path]) -> None: + """esphome->libraries entries are parsed and registered via cg.add_library.""" + result = load_config_from_fixture(yaml_file, "libraries.yaml", FIXTURES_DIR) + assert result is not None + + with patch("esphome.core.config.cg") as mock_cg: + mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock() + mock_cg.RawExpression.side_effect = lambda *args, **kwargs: MagicMock() + await config.to_code(result[CONF_ESPHOME]) + + mock_cg.add_library.assert_any_call("SomeLib", None) + mock_cg.add_library.assert_any_call("bblanchon/ArduinoJson", "7.4.2") + mock_cg.add_library.assert_any_call( + "noise-c", None, "https://github.com/esphome/noise-c.git" + ) diff --git a/tests/unit_tests/fixtures/core/config/libraries.yaml b/tests/unit_tests/fixtures/core/config/libraries.yaml new file mode 100644 index 0000000000..c93e828f31 --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/libraries.yaml @@ -0,0 +1,8 @@ +esphome: + name: test-libraries + libraries: + - SomeLib + - bblanchon/ArduinoJson@7.4.2 + - noise-c=https://github.com/esphome/noise-c.git + +host: diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index cc371ee1f9..a61b6ae7ae 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -915,3 +915,21 @@ class TestEsphomeCore: mock_enable.assert_called_once_with("Wire") assert "Wire" in target.platformio_libraries + + def test_add_build_unflag__warns_on_native_idf_toolchain( + self, target, caplog: pytest.LogCaptureFixture + ) -> None: + """Build unflags are not consumed by the native IDF build generator, + so adding one on that toolchain warns; PlatformIO stays silent.""" + target.toolchain = const.Toolchain.PLATFORMIO + target.add_build_unflag("-fno-rtti") + assert "ignored" not in caplog.text + + target.toolchain = const.Toolchain.ESP_IDF + target.add_build_unflag("-fno-exceptions") + assert ( + "Build unflag -fno-exceptions is ignored when building with the " + "native ESP-IDF toolchain" in caplog.text + ) + # The unflag is still recorded either way. + assert target.build_unflags == {"-fno-rtti", "-fno-exceptions"} diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 602ff03942..87e168dc94 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -1,3 +1,4 @@ +import hashlib import json import os from pathlib import Path @@ -515,7 +516,7 @@ def test_generate_idf_components_dedupes_shared_dependency( "esphome/C": {"name": "C"}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -557,6 +558,62 @@ def test_generate_idf_components_dedupes_shared_dependency( assert "idf_component_register" in generated +def test_generate_idf_components_lib_ignore_filters_top_level_and_dependencies( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, +) -> None: + # lib_ignore must drop B at the top level and C when it is discovered as a + # dependency of A during the graph walk -- neither may be resolved, + # downloaded, or wired into a manifest. Matching is by lowercase short name. + manifests = { + "esphome/A": { + "name": "A", + "dependencies": [ + {"owner": "esphome", "name": "C", "version": "==1.10021.0"} + ], + }, + "esphome/B": {"name": "B"}, + } + + download_salts: list[str] = [] + + def fake_download(self, force=False, salt=""): + download_salts.append(salt) + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + (self.path / "src").mkdir(parents=True, exist_ok=True) + (self.path / "src" / "x.c").write_text("int x;") + (self.path / "library.json").write_text(json.dumps(manifests[self.name])) + + monkeypatch.setattr(IDFComponent, "download", fake_download) + + resolve_calls: list[str] = [] + + def fake_resolve(owner, pkgname, requirements): + resolve_calls.append(pkgname) + return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz" + + monkeypatch.setattr( + esphome.espidf.component, "_resolve_registry_version", fake_resolve + ) + # lib_ignore is read from CORE.platformio_options (stored there by + # _add_platformio_options); matched by lowercase short name. + monkeypatch.setattr(CORE, "platformio_options", {"lib_ignore": ["B", "esphome/C"]}) + + top = generate_idf_components( + [Library("esphome/A", "1.0.0", None), Library("esphome/B", "1.0.0", None)] + ) + + assert [c.name for c in top] == ["esphome/A"] + # Ignored libraries were never resolved (and therefore never downloaded). + assert resolve_calls == ["A"] + # The ignored dependency is not wired into A's manifest. + assert top[0].dependencies == [] + # lib_ignore changes the generated wiring, so the cache path is salted to + # keep this conversion separate from ones with a different lib_ignore. + assert download_salts == [hashlib.sha256(b"b,c").hexdigest()[:8]] + + def test_generate_idf_components_handles_dependency_cycle( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -575,7 +632,7 @@ def test_generate_idf_components_handles_dependency_cycle( }, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -632,7 +689,7 @@ def test_generate_idf_components_git_overrides_registry_warns( "esphome/shared": {"name": "shared"}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -669,7 +726,7 @@ def test_generate_idf_components_missing_manifest_raises( ) -> None: # A library with neither library.json nor library.properties is invalid; # fail loudly rather than silently generating build files for it. - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) # no library.json / library.properties written @@ -711,7 +768,7 @@ def test_generate_idf_components_warns_on_noncanonical_duplicate( "owner/shared": {"name": "shared"}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -744,7 +801,7 @@ def test_generate_idf_components_incompatible_top_level_raises( ) -> None: # A top-level library that isn't ESP-IDF/esp32 compatible must fail fast, # not be silently dropped. - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "library.json").write_text( @@ -782,7 +839,7 @@ def test_generate_idf_components_incompatible_dependency_skipped( "esphome/B": {"name": "B", "platforms": ["espressif8266"]}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "library.json").write_text(json.dumps(manifests[self.name])) @@ -804,3 +861,54 @@ def test_generate_idf_components_incompatible_dependency_skipped( assert [c.name for c in top] == ["esphome/A"] # The incompatible dependency was dropped, not wired in. assert top[0].dependencies == [] + + +def test_url_source_salt_changes_cache_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The salt is mixed into the URL hash so salted conversions get their own + cache tree. Pre-created extraction markers keep this network-free.""" + monkeypatch.setattr(CORE, "config_path", tmp_path / "test.yaml") + url = "http://example.com/lib.tar.gz" + base = tmp_path / ".esphome" / "pio_components" + expected = {} + for salt in ("", "abcd1234"): + digest = hashlib.sha256((url + salt).encode()).hexdigest()[:8] + expected[salt] = base / digest / "lib" + expected[salt].mkdir(parents=True) + (expected[salt] / ".esphome_extracted").touch() + + source = URLSource(url) + assert source.download("lib") == expected[""] + assert source.download("lib", salt="abcd1234") == expected["abcd1234"] + + +def test_git_source_salt_scopes_domain(monkeypatch: pytest.MonkeyPatch) -> None: + """The salt becomes a subdirectory of the git clone domain.""" + domains: list[str] = [] + + def fake_clone_or_update(**kwargs): + domains.append(kwargs["domain"]) + return Path("/cloned"), None + + monkeypatch.setattr( + esphome.espidf.component.git, "clone_or_update", fake_clone_or_update + ) + + source = GitSource("https://github.com/esphome/noise-c.git", "v1.0") + source.download("noise-c") + source.download("noise-c", salt="abcd1234") + assert domains == ["pio_components", "pio_components/abcd1234"] + + +def test_idf_component_download_passes_salt() -> None: + """IDFComponent.download forwards the sanitized name and salt to the + source and records the returned path.""" + source = MagicMock() + source.download.return_value = Path("/converted/owner/name") + + c = IDFComponent("owner/name", "1.0", source=source) + c.download(force=True, salt="abcd1234") + + source.download.assert_called_once_with("owner/name", force=True, salt="abcd1234") + assert c.path == Path("/converted/owner/name") From efebea32969ba72ce34524798f057064ffb7f766 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:56:18 -0400 Subject: [PATCH 0390/1815] [esp32] Add flash_mode and flash_frequency config options (#16920) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/esp32/__init__.py | 24 +++++++++++++++++ .../esp32/config/flash_mode_default.yaml | 7 +++++ .../esp32/config/flash_mode_idf.yaml | 9 +++++++ tests/component_tests/esp32/test_esp32.py | 26 +++++++++++++++++++ 4 files changed, 66 insertions(+) create mode 100644 tests/component_tests/esp32/config/flash_mode_default.yaml create mode 100644 tests/component_tests/esp32/config/flash_mode_idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 7e7b127814..d703e22e46 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1615,8 +1615,14 @@ FLASH_SIZES = [ ] CONF_FLASH_SIZE = "flash_size" +CONF_FLASH_MODE = "flash_mode" +CONF_FLASH_FREQUENCY = "flash_frequency" CONF_CPU_FREQUENCY = "cpu_frequency" CONF_PARTITIONS = "partitions" +FLASH_MODES = ["qio", "qout", "dio", "dout", "opi"] +FLASH_FREQUENCIES = [ + f"{freq}MHZ" for freq in (120, 80, 64, 60, 48, 40, 32, 30, 26, 24, 20, 16) +] CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -1630,6 +1636,10 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_FLASH_SIZE, default="4MB"): cv.one_of( *FLASH_SIZES, upper=True ), + cv.Optional(CONF_FLASH_MODE): cv.one_of(*FLASH_MODES, lower=True), + cv.Optional(CONF_FLASH_FREQUENCY): cv.one_of( + *FLASH_FREQUENCIES, upper=True + ), cv.Optional(CONF_PARTITIONS): cv.Any( cv.file_, cv.ensure_list( @@ -1866,6 +1876,12 @@ async def to_code(config): "board_upload.maximum_size", int(config[CONF_FLASH_SIZE].removesuffix("MB")) * 1024 * 1024, ) + if flash_mode := config.get(CONF_FLASH_MODE): + cg.add_platformio_option("board_build.flash_mode", flash_mode) + if flash_frequency := config.get(CONF_FLASH_FREQUENCY): + cg.add_platformio_option( + "board_build.f_flash", f"{flash_frequency[:-3]}000000L" + ) if CONF_SOURCE in conf: cg.add_platformio_option("platform_packages", [conf[CONF_SOURCE]]) @@ -2016,6 +2032,14 @@ async def to_code(config): add_idf_sdkconfig_option( f"CONFIG_ESPTOOLPY_FLASHSIZE_{config[CONF_FLASH_SIZE]}", True ) + if flash_mode := config.get(CONF_FLASH_MODE): + add_idf_sdkconfig_option( + f"CONFIG_ESPTOOLPY_FLASHMODE_{flash_mode.upper()}", True + ) + if flash_frequency := config.get(CONF_FLASH_FREQUENCY): + add_idf_sdkconfig_option( + f"CONFIG_ESPTOOLPY_FLASHFREQ_{flash_frequency[:-3]}M", True + ) # ESP32-P4: ESP-IDF 5.5.3 changed the default of ESP32P4_SELECTS_REV_LESS_V3 # from y to n. PlatformIO uses sections.ld.in (for rev <3) or diff --git a/tests/component_tests/esp32/config/flash_mode_default.yaml b/tests/component_tests/esp32/config/flash_mode_default.yaml new file mode 100644 index 0000000000..0d05142099 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_mode_default.yaml @@ -0,0 +1,7 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf diff --git a/tests/component_tests/esp32/config/flash_mode_idf.yaml b/tests/component_tests/esp32/config/flash_mode_idf.yaml new file mode 100644 index 0000000000..7c7f50a439 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_mode_idf.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + board: esp32dev + flash_mode: qio + flash_frequency: 80MHz + framework: + type: esp-idf diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index e9fa9446d4..a8b5720a80 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -285,3 +285,29 @@ def test_native_idf_enables_reproducible_build( sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True + + +def test_flash_mode_sets_sdkconfig_and_pio_option( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """flash_mode/flash_frequency select the esptool flash parameters on both backends.""" + generate_main(component_config_path("flash_mode_idf.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHMODE_QIO") is True + assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHFREQ_80M") is True + assert CORE.platformio_options.get("board_build.flash_mode") == "qio" + assert CORE.platformio_options.get("board_build.f_flash") == "80000000L" + + +def test_flash_mode_unset_leaves_defaults( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Without flash_mode the board/sdkconfig defaults stay untouched.""" + generate_main(component_config_path("flash_mode_default.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHMODE_") for key in sdkconfig) + assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHFREQ_") for key in sdkconfig) + assert "board_build.flash_mode" not in CORE.platformio_options + assert "board_build.f_flash" not in CORE.platformio_options From 83504d2de2567619cdee1770a9bfbce36ff8da11 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Jun 2026 08:47:50 -0500 Subject: [PATCH 0391/1815] [esp8266] Decode crash handler PC and backtrace in logs (#16911) --- esphome/components/esp8266/__init__.py | 18 ++++++++++- .../components/test_esp_stacktrace.py | 30 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index dd10a32fd6..db94f0ec6d 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -492,6 +492,15 @@ def _parse_register(config, regex, line): STACKTRACE_ESP8266_EXCEPTION_TYPE_RE = re.compile(r"[eE]xception \((\d+)\):") STACKTRACE_ESP8266_PC_RE = re.compile(r"epc1=0x(4[0-9a-fA-F]{7})") STACKTRACE_ESP8266_EXCVADDR_RE = re.compile(r"excvaddr=0x(4[0-9a-fA-F]{7})") +# Structured crash handler output (crash_handler.cpp) from a previous boot: +# PC: 0x40220060 +# EXCVADDR: 0x0000008A +# BT0: 0x40212345 +STACKTRACE_ESP8266_CRASH_PC_RE = re.compile(r".*PC\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})") +STACKTRACE_ESP8266_CRASH_EXCVADDR_RE = re.compile( + r".*EXCVADDR\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})" +) +STACKTRACE_ESP8266_CRASH_BT_RE = re.compile(r"BT\d+:\s*0x([0-9a-fA-F]{8})") STACKTRACE_BAD_ALLOC_RE = re.compile( r"^last failed alloc call: (4[0-9a-fA-F]{7})\((\d+)\)$" ) @@ -508,10 +517,17 @@ def process_stacktrace(config, line, backtrace_state): "Exception type: %s", ESP8266_EXCEPTION_CODES.get(code, "unknown") ) - # ESP8266 PC/EXCVADDR + # ESP8266 PC/EXCVADDR (legacy Arduino postmortem) _parse_register(config, STACKTRACE_ESP8266_PC_RE, line) _parse_register(config, STACKTRACE_ESP8266_EXCVADDR_RE, line) + # ESP8266 structured crash handler (crash_handler.cpp) from previous boot + _parse_register(config, STACKTRACE_ESP8266_CRASH_PC_RE, line) + _parse_register(config, STACKTRACE_ESP8266_CRASH_EXCVADDR_RE, line) + match = re.search(STACKTRACE_ESP8266_CRASH_BT_RE, line) + if match is not None: + _decode_pc(config, match.group(1)) + # bad alloc match = re.match(STACKTRACE_BAD_ALLOC_RE, line) if match is not None: diff --git a/tests/unit_tests/components/test_esp_stacktrace.py b/tests/unit_tests/components/test_esp_stacktrace.py index 5235f313d6..f231ac5fb7 100644 --- a/tests/unit_tests/components/test_esp_stacktrace.py +++ b/tests/unit_tests/components/test_esp_stacktrace.py @@ -45,6 +45,36 @@ def test_process_stacktrace_esp8266_backtrace( assert state is False +def test_process_stacktrace_esp8266_crash_handler( + setup_core: Path, mock_esp8266_decode_pc: Mock +) -> None: + """Test process_stacktrace handles ESP8266 crash handler backtrace lines.""" + from esphome.components.esp8266 import process_stacktrace + + config = {"name": "test"} + + # Simulate crash handler log lines as they appear from the API/serial + line_pc = "[E][esp8266:191]: PC: 0x40220060" + state = process_stacktrace(config, line_pc, False) + mock_esp8266_decode_pc.assert_called_once_with(config, "40220060") + assert state is False + + mock_esp8266_decode_pc.reset_mock() + + # Near-null data address (wild pointer) is not a code address, must be ignored + line_excvaddr = "[E][esp8266:193]: EXCVADDR: 0x0000008A" + state = process_stacktrace(config, line_excvaddr, False) + mock_esp8266_decode_pc.assert_not_called() + assert state is False + + mock_esp8266_decode_pc.reset_mock() + + line_bt0 = "[E][esp8266:196]: BT0: 0x40212345" + state = process_stacktrace(config, line_bt0, False) + mock_esp8266_decode_pc.assert_called_once_with(config, "40212345") + assert state is False + + def test_process_stacktrace_esp32_backtrace( setup_core: Path, mock_esp32_decode_pc: Mock ) -> None: From 20925b32207ebd70060bc0c21799de862a447fd4 Mon Sep 17 00:00:00 2001 From: Tobiasz Jakubowski <12734857+tjakubo@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:50:51 +0200 Subject: [PATCH 0392/1815] [spi] Skip logging on begin_transaction() of an auto-releasing write-only SPI device (#16921) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/spi/spi_esp_idf.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/esphome/components/spi/spi_esp_idf.cpp b/esphome/components/spi/spi_esp_idf.cpp index 107b6a3f1a..0731078eec 100644 --- a/esphome/components/spi/spi_esp_idf.cpp +++ b/esphome/components/spi/spi_esp_idf.cpp @@ -17,6 +17,11 @@ class SPIDelegateHw : public SPIDelegate { write_only_(write_only) { if (!this->release_device_) add_device_(); + + if (this->write_only_) { + ESP_LOGV(TAG, "SPI device with CS pin %d using half-duplex mode (write-only)", + Utility::get_pin_no(this->cs_pin_)); + } } bool is_ready() override { return this->handle_ != nullptr; } @@ -195,11 +200,8 @@ class SPIDelegateHw : public SPIDelegate { config.post_cb = nullptr; if (this->bit_order_ == BIT_ORDER_LSB_FIRST) config.flags |= SPI_DEVICE_BIT_LSBFIRST; - if (this->write_only_) { + if (this->write_only_) config.flags |= SPI_DEVICE_HALFDUPLEX | SPI_DEVICE_NO_DUMMY; - ESP_LOGD(TAG, "SPI device with CS pin %d using half-duplex mode (write-only)", - Utility::get_pin_no(this->cs_pin_)); - } esp_err_t const err = spi_bus_add_device(this->channel_, &config, &this->handle_); if (err != ESP_OK) { ESP_LOGE(TAG, "Add device failed - err %X", err); From 26ccaf70dbb3e4e6d422c1cd2584973edbc06647 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 13 Jun 2026 22:21:38 +1000 Subject: [PATCH 0393/1815] [lvgl] Fix schema extraction (#16895) Co-authored-by: Claude Opus 4.8 --- esphome/components/lvgl/__init__.py | 175 ++++++++++++--------- esphome/components/lvgl/schemas.py | 48 +++++- script/build_language_schema.py | 28 ++++ tests/script/test_build_language_schema.py | 107 +++++++++++++ 4 files changed, 276 insertions(+), 82 deletions(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 022d629960..9137412abe 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -47,6 +47,7 @@ from esphome.core import CORE, ID, Lambda from esphome.cpp_generator import MockObj from esphome.final_validate import full_config from esphome.helpers import write_file_if_changed +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.writer import clean_build from esphome.yaml_util import load_yaml @@ -75,10 +76,14 @@ from .schemas import ( BASE_PROPS, DISP_BG_SCHEMA, FULL_STYLE_SCHEMA, + SET_STATE_SCHEMA, + STATE_SCHEMA, STYLE_REMAP, + STYLE_SCHEMA, WIDGET_TYPES, any_widget_schema, container_schema, + container_schema_value, obj_dict, ) from .styles import styles_to_code, theme_to_code @@ -113,6 +118,14 @@ from .widgets.page import ( # page_spec used in LVGL_SCHEMA page_spec, ) +# These style schemas live in .schemas but are imported here so they land in +# this module's namespace, where script/build_language_schema.py registers them +# as *named* schemas and emits `extends` references — instead of inlining the +# ~80-property STYLE_SCHEMA at every widget x part x state, which bloated the +# dumped lvgl schema ~23x (17 MB vs ~750 KB). They are not otherwise used in +# this file; this tuple keeps the imports live (and self-documents why). +_SCHEMA_DUMPER_NAMED_SCHEMAS = (STYLE_SCHEMA, STATE_SCHEMA, SET_STATE_SCHEMA) + # Widget registration happens via WidgetType.__init__ in individual widget files # The imports below trigger creation of the widget types # Action registration (lvgl.{widget}.update) happens automatically @@ -559,94 +572,106 @@ def _theme_schema(value: dict) -> dict: FINAL_VALIDATE_SCHEMA = final_validation -LVGL_SCHEMA = cv.All( - container_schema( - obj_spec, - cv.polling_component_schema("1s") - .extend( - { - **{ - cv.Optional(event): validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - Trigger.template(lv_obj_t_ptr, lv_event_t_ptr) - ), - } - ) - for event in df.LV_SCREEN_EVENT_TRIGGERS - + df.LV_DISPLAY_EVENT_TRIGGERS - }, - cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent), - cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t), - cv.GenerateID(df.CONF_DISPLAYS): display_schema, - cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16), - cv.Optional( - df.CONF_DEFAULT_FONT, default="montserrat_14" - ): lvalid.lv_font, - cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean, - cv.Optional( - df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False - ): cv.boolean, - cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int, - cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage, - cv.Optional(CONF_ROTATION): validate_rotation, - cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( - *df.LV_LOG_LEVELS, upper=True - ), - cv.Optional(CONF_BYTE_ORDER): cv.one_of( - "big_endian", "little_endian", lower=True - ), - cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list( - cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}).extend( - FULL_STYLE_SCHEMA - ) - ), - cv.Optional(CONF_ON_IDLE): validate_automation( +# The options accepted at the top level of an `lvgl:` block, on top of the base +# object schema that `container_schema(obj_spec, ...)` supplies. Held in a +# module-level name (rather than inline) so the schema-extractor wrapper on +# CONFIG_SCHEMA below can hand the language-schema dumper the same composed +# schema the runtime validates against. +LVGL_TOP_LEVEL_SCHEMA = ( + cv.polling_component_schema("1s") + .extend( + { + **{ + cv.Optional(event): validate_automation( { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(IdleTrigger), - cv.Required(CONF_TIMEOUT): cv.templatable( - cv.positive_time_period_milliseconds + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + Trigger.template(lv_obj_t_ptr, lv_event_t_ptr) ), } - ), - cv.Optional(CONF_PAGES): cv.ensure_list(container_schema(page_spec)), - **{ - cv.Optional(x): validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger), - }, - single=True, - ) - for x in SIMPLE_TRIGGERS - }, - cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA), - cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool, - cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec), - cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec), - cv.Optional( - df.CONF_TRANSPARENCY_KEY, default=0x000400 - ): lvalid.lv_color, - cv.Optional(df.CONF_THEME): _theme_schema, - cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA, - cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema, - cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG, - cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG, - cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t), - cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean, - } - ) - .extend(DISP_BG_SCHEMA), - ), + ) + for event in df.LV_SCREEN_EVENT_TRIGGERS + df.LV_DISPLAY_EVENT_TRIGGERS + }, + cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent), + cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t), + cv.GenerateID(df.CONF_DISPLAYS): display_schema, + cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16), + cv.Optional(df.CONF_DEFAULT_FONT, default="montserrat_14"): lvalid.lv_font, + cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean, + cv.Optional(df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False): cv.boolean, + cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int, + cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage, + cv.Optional(CONF_ROTATION): validate_rotation, + cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( + *df.LV_LOG_LEVELS, upper=True + ), + cv.Optional(CONF_BYTE_ORDER): cv.one_of( + "big_endian", "little_endian", lower=True + ), + cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list( + cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}).extend( + FULL_STYLE_SCHEMA + ) + ), + cv.Optional(CONF_ON_IDLE): validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(IdleTrigger), + cv.Required(CONF_TIMEOUT): cv.templatable( + cv.positive_time_period_milliseconds + ), + } + ), + cv.Optional(CONF_PAGES): cv.ensure_list(container_schema(page_spec)), + **{ + cv.Optional(x): validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger), + }, + single=True, + ) + for x in SIMPLE_TRIGGERS + }, + cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA), + cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool, + cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec), + cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec), + cv.Optional(df.CONF_TRANSPARENCY_KEY, default=0x000400): lvalid.lv_color, + cv.Optional(df.CONF_THEME): _theme_schema, + cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA, + cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema, + cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG, + cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG, + cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t), + cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean, + } + ) + .extend(DISP_BG_SCHEMA) +) + + +LVGL_SCHEMA = cv.All( + container_schema(obj_spec, LVGL_TOP_LEVEL_SCHEMA), cv.has_at_most_one_key(CONF_PAGES, df.CONF_LAYOUT), add_hello_world, ) +@schema_extractor("schema") def lvgl_config_schema(config): """ Can't use cv.ensure_list here because it converts an empty config to an empty list, rather than a default config. """ + if config is SCHEMA_EXTRACT: + # CONFIG_SCHEMA is this callable wrapping `cv.All` over a container_schema + # closure, so the language-schema dumper can't see the top-level `lvgl:` + # fields (it would emit an empty schema). Hand it the same composed + # obj + top-level schema the runtime validates against, plus the + # `widgets:` key (added per-value by append_layout_schema at runtime, so + # otherwise invisible to the dumper). Validation of real configs (the + # branches below) is unchanged. + return container_schema_value(obj_spec, LVGL_TOP_LEVEL_SCHEMA).extend( + {cv.Optional(df.CONF_WIDGETS): any_widget_schema()} + ) if not config or isinstance(config, dict): return [LVGL_SCHEMA(config)] return cv.Schema([LVGL_SCHEMA])(config) diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index bdaa91f15c..d7df628907 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -22,7 +22,11 @@ from esphome.const import ( ) from esphome.core import TimePeriod from esphome.core.config import StartupTrigger -from esphome.schema_extractors import EnableSchemaExtraction +from esphome.schema_extractors import ( + SCHEMA_EXTRACT, + EnableSchemaExtraction, + schema_extractor, +) from . import defines as df, lv_validation as lvalid from .defines import ( @@ -627,6 +631,25 @@ _CONTAINER_SCHEMA_CACHE: dict[ ] = {} +def container_schema_value(widget_type: WidgetType, extras: Any = None) -> cv.Schema: + """ + Build the static schema that :func:`container_schema` validates against, i.e. + everything except the value-dependent ``append_layout_schema`` applied at + validation time. + + Factored out and exposed so the language-schema dumper can extract a + representative schema for a widget — and for the top-level ``lvgl:`` block, + whose ``CONFIG_SCHEMA`` is a callable that otherwise hides this behind the + :func:`container_schema` validator closure. + """ + schema = obj_schema(widget_type).extend( + {cv.GenerateID(): cv.declare_id(widget_type.w_type)} + ) + if extras: + schema = schema.extend(extras) + return schema.extend(widget_type.schema) + + def container_schema( widget_type: WidgetType, extras: Any = None ) -> Callable[[Any], Any]: @@ -649,12 +672,7 @@ def container_schema( def get_schema() -> cv.Schema: nonlocal cached_schema if cached_schema is None: - schema = obj_schema(widget_type).extend( - {cv.GenerateID(): cv.declare_id(widget_type.w_type)} - ) - if extras: - schema = schema.extend(extras) - cached_schema = schema.extend(widget_type.schema) + cached_schema = container_schema_value(widget_type, extras) return cached_schema def validator(value: Any) -> Any: @@ -678,7 +696,23 @@ def any_widget_schema(extras=None): :return: A validator for the Widgets key """ + @schema_extractor("schema") def validator(value): + if value is SCHEMA_EXTRACT: + # The widgets: list is built per-value at validation time, so the + # language-schema dumper sees nothing. Enumerate every registered + # widget type as an optional key (a widget item is really a + # single-key mapping; over-listing them lets editors complete any + # widget — `esphome config` enforces exactly one). extras carries the + # layout child options where applicable. + return cv.ensure_list( + cv.Schema( + { + cv.Optional(name): container_schema_value(widget_type, extras) + for name, widget_type in WIDGET_TYPES.items() + } + ) + ) if isinstance(value, dict): # Convert to list is_dict = True diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 4b0b0ee548..61845c4b25 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -428,6 +428,33 @@ def fix_menu(): menu[S_EXTENDS].append("display_menu_base.MENU_TYPES") +def fix_lvgl_widgets(): + # lvgl's `widgets:` is a recursive tree (a widget can contain widgets). The + # dumper has no cycle detection, so — like fix_menu — hoist the inlined + # widget-type enumeration into a named schema and reference it for both the + # top-level list and each widget's own children, instead of expanding it. + if "lvgl" not in output: + return + schemas = output["lvgl"][S_SCHEMAS] + config_vars = schemas["CONFIG_SCHEMA"][S_SCHEMA][S_CONFIG_VARS] + widgets = config_vars.get("widgets") + if not widgets or S_SCHEMA not in widgets or S_CONFIG_VARS not in widgets[S_SCHEMA]: + return + # 1. Hoist the (one-level) widget enumeration into a named schema. + schemas["WIDGET_TYPES"] = {S_TYPE: S_SCHEMA, S_SCHEMA: widgets[S_SCHEMA]} + # 2. Reference it from the top-level widgets: list instead of inlining. + widgets[S_SCHEMA] = {S_EXTENDS: ["lvgl.WIDGET_TYPES"]} + # 3. Let every widget contain child widgets, via the same named ref. + for widget in schemas["WIDGET_TYPES"][S_SCHEMA][S_CONFIG_VARS].values(): + if widget.get(S_TYPE) == S_SCHEMA and S_SCHEMA in widget: + widget[S_SCHEMA].setdefault(S_CONFIG_VARS, {})["widgets"] = { + S_TYPE: S_SCHEMA, + "is_list": True, + "key": "Optional", + S_SCHEMA: {S_EXTENDS: ["lvgl.WIDGET_TYPES"]}, + } + + def get_logger_tags(): pattern = re.compile(r'^static const char \*const TAG = "(\w.*)";', re.MULTILINE) # tags not in components dir @@ -740,6 +767,7 @@ def build_schema(): add_logger_tags() shrink() fix_menu() + fix_lvgl_widgets() # aggregate components, so all component info is in same file, otherwise we have dallas.json, dallas.sensor.json, etc. data = {} diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index 8b81a57fef..badd4686f6 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -4,7 +4,12 @@ from __future__ import annotations import ast import importlib.util +import json from pathlib import Path +import subprocess +import sys + +import pytest from esphome import config_validation as cv @@ -176,3 +181,105 @@ def test_convert_keys_no_marker_for_non_sensitive_field() -> None: entry = converted["schema"]["config_vars"]["hostname"] assert "sensitive" not in entry assert "sensitive_source" not in entry + + +# --------------------------------------------------------------------------- +# Regression tests for the lvgl schema dump. +# +# lvgl's CONFIG_SCHEMA is a callable closure and its widget/style schemas are +# built lazily at validation time, so the static dumper used to emit an empty +# `lvgl:` schema, no widget completion, and an inlined ~80-property STYLE_SCHEMA +# duplicated at every widget x part x state (a 17 MB lvgl.json). These exercise +# the full `build_schema()` and assert the generated lvgl.json carries the data +# the schema_extractor hooks added. +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def lvgl_schema(tmp_path_factory: pytest.TempPathFactory) -> dict: + """Run the full language-schema build once and return parsed lvgl.json. + + The build must run in a fresh interpreter: ``build_language_schema.py`` + enables schema extraction *before* importing any esphome component, and the + extraction hooks are no-ops if the components were already imported (as they + are inside the pytest session). Running it as a subprocess mirrors how CI + generates the schema and keeps this test isolated from import order. + """ + out_dir = tmp_path_factory.mktemp("language_schema") + subprocess.run( + [sys.executable, str(SCRIPT_PATH), "--output-path", str(out_dir)], + check=True, + capture_output=True, + text=True, + ) + return json.loads((out_dir / "lvgl.json").read_text()) + + +def _lvgl_config_vars(lvgl_schema: dict) -> dict: + config_schema = lvgl_schema["lvgl"]["schemas"]["CONFIG_SCHEMA"] + # Previously empty (`{}`); the schema_extractor on lvgl_config_schema now + # hands the dumper the composed top-level schema. + assert config_schema["type"] == "schema" + return config_schema["schema"]["config_vars"] + + +def test_lvgl_top_level_schema_is_exposed(lvgl_schema: dict) -> None: + config_vars = _lvgl_config_vars(lvgl_schema) + # Was 0 config_vars before LVGL_TOP_LEVEL_SCHEMA was exposed. + assert len(config_vars) > 100 + # A representative spread of top-level options the runtime validates. + for key in ("displays", "pages", "default_font", "on_idle", "touchscreens"): + assert key in config_vars, f"missing top-level lvgl option: {key}" + + +def test_lvgl_widgets_key_enumerated(lvgl_schema: dict) -> None: + config_vars = _lvgl_config_vars(lvgl_schema) + # The widgets: list is assembled per-value at runtime; the extractor + # enumerates every registered widget type into a named WIDGET_TYPES schema + # which the widgets: list references (recursive, so widgets can nest). + assert "widgets" in config_vars + widgets = config_vars["widgets"] + assert widgets["is_list"] is True + assert widgets["schema"]["extends"] == ["lvgl.WIDGET_TYPES"] + + widget_types = lvgl_schema["lvgl"]["schemas"]["WIDGET_TYPES"]["schema"][ + "config_vars" + ] + # Every registered widget type should appear as an optional key. + for name in ("obj", "label", "button", "slider", "switch", "arc"): + assert name in widget_types, f"widget type not enumerated: {name}" + # Each enumerated widget carries its own property schema, not an empty stub. + assert widget_types["label"]["type"] == "schema" + assert len(widget_types["label"]["schema"]["config_vars"]) > 0 + # Each widget can contain child widgets, via the same named ref — so the + # tree is recursive and the dump stays finite. + nested = widget_types["obj"]["schema"]["config_vars"]["widgets"] + assert nested["is_list"] is True + assert nested["schema"]["extends"] == ["lvgl.WIDGET_TYPES"] + + +def test_lvgl_style_schemas_are_named_and_deduped(lvgl_schema: dict) -> None: + schemas = lvgl_schema["lvgl"]["schemas"] + # Importing these into the lvgl __init__ namespace lets the dumper register + # them as named schemas and emit `extends` refs instead of inlining them. + for name in ("STYLE_SCHEMA", "STATE_SCHEMA", "SET_STATE_SCHEMA"): + assert name in schemas, f"style schema not registered as named: {name}" + + # STYLE_SCHEMA must be referenced via `extends`, not inlined at every use + # site. Count the references to prove the dedup actually happened. + refs = 0 + + def _count(node: object) -> None: + nonlocal refs + if isinstance(node, dict): + extends = node.get("extends") + if isinstance(extends, list) and "lvgl.STYLE_SCHEMA" in extends: + refs += 1 + for value in node.values(): + _count(value) + elif isinstance(node, list): + for value in node: + _count(value) + + _count(lvgl_schema) + assert refs > 100, f"STYLE_SCHEMA should be referenced via extends, got {refs}" From 9ffd350095e5836de12a3110fef73a95e77bdb53 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 15 Jun 2026 07:36:38 +1000 Subject: [PATCH 0394/1815] [mipi_spi] Implement automatic mapping of offsets (#16722) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/mipi/__init__.py | 131 ++++-- esphome/components/mipi_dsi/display.py | 8 +- esphome/components/mipi_rgb/display.py | 8 +- esphome/components/mipi_spi/display.py | 36 +- esphome/components/mipi_spi/mipi_spi.h | 87 ++-- esphome/components/mipi_spi/models/ili.py | 28 ++ .../components/mipi_spi/models/waveshare.py | 13 + tests/component_tests/mipi_spi/test_init.py | 4 +- .../mipi_spi/test_padding_and_offsets.py | 434 ++++++++++++++++++ 9 files changed, 662 insertions(+), 87 deletions(-) create mode 100644 tests/component_tests/mipi_spi/test_padding_and_offsets.py diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index c3b744c919..129befe600 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -139,6 +139,8 @@ MADCTL_FLIP_FLAG = 0x100 # meta-flag to indicate use of axis flips # Special constant for delays in command sequences DELAY_FLAG = 0xFFF # Special flag to indicate a delay +CONF_PAD_HEIGHT = "pad_height" +CONF_PAD_WIDTH = "pad_width" CONF_PIXEL_MODE = "pixel_mode" CONF_USE_AXIS_FLIPS = "use_axis_flips" @@ -202,6 +204,8 @@ def dimension_schema(rounding): rounding ), cv.Optional(CONF_OFFSET_WIDTH, default=0): validate_dimension(rounding), + cv.Optional(CONF_PAD_WIDTH): validate_dimension(rounding), + cv.Optional(CONF_PAD_HEIGHT): validate_dimension(rounding), } ), ) @@ -311,6 +315,36 @@ class DriverChip: name = name.upper() self.name = name self.initsequence = initsequence + if CONF_NATIVE_WIDTH in defaults: + if CONF_WIDTH not in defaults: + defaults[CONF_WIDTH] = ( + defaults[CONF_NATIVE_WIDTH] + - defaults.get(CONF_OFFSET_WIDTH, 0) + - defaults.get(CONF_PAD_WIDTH, 0) + ) + else: + native_width = ( + defaults.get(CONF_WIDTH, 0) + + defaults.get(CONF_OFFSET_WIDTH, 0) + + defaults.get(CONF_PAD_WIDTH, 0) + ) + if native_width != 0: + defaults[CONF_NATIVE_WIDTH] = native_width + if CONF_NATIVE_HEIGHT in defaults: + if CONF_HEIGHT not in defaults: + defaults[CONF_HEIGHT] = ( + defaults[CONF_NATIVE_HEIGHT] + - defaults.get(CONF_OFFSET_HEIGHT, 0) + - defaults.get(CONF_PAD_HEIGHT, 0) + ) + else: + native_height = ( + defaults.get(CONF_HEIGHT, 0) + + defaults.get(CONF_OFFSET_HEIGHT, 0) + + defaults.get(CONF_PAD_HEIGHT, 0) + ) + if native_height != 0: + defaults[CONF_NATIVE_HEIGHT] = native_height self.defaults = defaults DriverChip.models[name] = self @@ -336,18 +370,6 @@ class DriverChip: initsequence = list(kwargs.pop("initsequence", self.initsequence)) initsequence.extend(kwargs.pop("add_init_sequence", ())) defaults = self.defaults.copy() - if ( - CONF_WIDTH in defaults - and CONF_OFFSET_WIDTH in kwargs - and CONF_NATIVE_WIDTH not in defaults - ): - defaults[CONF_NATIVE_WIDTH] = defaults[CONF_WIDTH] - if ( - CONF_HEIGHT in defaults - and CONF_OFFSET_HEIGHT in kwargs - and CONF_NATIVE_HEIGHT not in defaults - ): - defaults[CONF_NATIVE_HEIGHT] = defaults[CONF_HEIGHT] defaults.update(kwargs) return self.__class__(name, initsequence=tuple(initsequence), **defaults) @@ -385,13 +407,16 @@ class DriverChip: return CONF_SWAP_XY in transforms and CONF_MIRROR_X in transforms return CONF_SWAP_XY in transforms and CONF_MIRROR_Y in transforms - def get_dimensions(self, config, swap: bool = True) -> tuple[int, int, int, int]: + def get_dimensions( + self, config, swap: bool = True + ) -> tuple[int, int, int, int, int, int]: """ Return the dimensions of the current model. :param config: The current configuration :param swap: If width/height should be swapped when axes are swapped. - :return: + :return: A tuple (width, height, offset_width, offset_height, pad_width, pad_height). """ + if CONF_DIMENSIONS in config: # Explicit dimensions, just use as is dimensions = config[CONF_DIMENSIONS] @@ -400,33 +425,71 @@ class DriverChip: height = dimensions[CONF_HEIGHT] offset_width = dimensions[CONF_OFFSET_WIDTH] offset_height = dimensions[CONF_OFFSET_HEIGHT] - return width, height, offset_width, offset_height - (width, height) = dimensions - return width, height, 0, 0 + if CONF_PAD_WIDTH in dimensions: + pad_width = dimensions[CONF_PAD_WIDTH] + native_width = width + offset_width + pad_width + else: + native_width = self.get_default(CONF_NATIVE_WIDTH, 0) + if native_width == 0: + pad_width = 0 + native_width = width + offset_width + else: + pad_width = native_width - width - offset_width + if CONF_PAD_HEIGHT in dimensions: + pad_height = dimensions[CONF_PAD_HEIGHT] + native_height = height + offset_height + pad_height + else: + native_height = self.get_default(CONF_NATIVE_HEIGHT, 0) + if native_height == 0: + pad_height = 0 + native_height = height + offset_height + else: + pad_height = native_height - height - offset_height + if ( + pad_width + offset_width >= native_width + or pad_height + offset_height >= native_height + ): + raise cv.Invalid("Dimensions exceed native size", [CONF_DIMENSIONS]) + if pad_width < 0 or pad_height < 0: + raise cv.Invalid("Invalid offsets", [CONF_DIMENSIONS]) + + return width, height, offset_width, offset_height, pad_width, pad_height + + # Must be a tuple + width, height = dimensions + return width, height, 0, 0, 0, 0 # Default dimensions, use model defaults transform = self.get_transform(config) width = self.get_default(CONF_WIDTH) height = self.get_default(CONF_HEIGHT) + native_width = self.get_default(CONF_NATIVE_WIDTH, 0) + native_height = self.get_default(CONF_NATIVE_HEIGHT, 0) offset_width = self.get_default(CONF_OFFSET_WIDTH, 0) offset_height = self.get_default(CONF_OFFSET_HEIGHT, 0) + pad_width = self.get_default( + CONF_PAD_WIDTH, native_width - width - offset_width + ) + pad_height = self.get_default( + CONF_PAD_HEIGHT, native_height - height - offset_height + ) + + if pad_width < 0 or pad_height < 0: + raise cv.Invalid("Offsets exceed native size", [CONF_DIMENSIONS]) # if mirroring axes and there are offsets, also mirror the offsets to cater for situations where # the offset is asymmetric if transform.get(CONF_MIRROR_X): - native_width = self.get_default(CONF_NATIVE_WIDTH, width + offset_width * 2) - offset_width = native_width - width - offset_width + offset_width, pad_width = pad_width, offset_width if transform.get(CONF_MIRROR_Y): - native_height = self.get_default( - CONF_NATIVE_HEIGHT, height + offset_height * 2 - ) - offset_height = native_height - height - offset_height - # Swap default dimensions if swap_xy is set, or if rotation is 90/270 and we are not using a buffer + offset_height, pad_height = pad_height, offset_height + # Swap default dimensions if swap_xy is set, or if rotation is 90/270, and we are not using a buffer if swap and transform.get(CONF_SWAP_XY) is True: width, height = height, width offset_height, offset_width = offset_width, offset_height - return width, height, offset_width, offset_height + pad_width, pad_height = pad_height, pad_width + return width, height, offset_width, offset_height, pad_width, pad_height def get_base_transform(self, config): transform = config.get( @@ -450,20 +513,8 @@ class DriverChip: def get_transform(self, config) -> dict[str, bool]: transform = self.get_base_transform(config) - can_transform = self.rotation_as_transform(config) # Can we use the MADCTL register to set the rotation? - if can_transform and CONF_TRANSFORM not in config: - rotation = config[CONF_ROTATION] - if rotation == 180: - transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] - transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] - elif rotation == 90: - transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] - transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] - else: - transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] - transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] - transform[CONF_TRANSFORM] = True + transform[CONF_TRANSFORM] = self.rotation_as_transform(config) return transform def swap_xy_schema(self): @@ -498,8 +549,8 @@ class DriverChip: return madctl def add_madctl(self, sequence: list, config: dict): - # Add the MADCTL command to the sequence based on the configuration. - # This takes into account rotation if it can be implemented in the transform + # Add the MADCTL command to the sequence based on the base configuration. + # Rotation is not applied here, it will be done at runtime. transform = self.get_transform(config) madctl = self.get_madctl(transform, config) sequence.append((MADCTL, madctl & 0xFF)) diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 46e7a7d5a7..896140b4b1 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -172,7 +172,9 @@ def _config_schema(config): )(config) config = model_schema(config)(config) model = MODELS[config[CONF_MODEL].upper()] - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) display.add_metadata( config[CONF_ID], width, @@ -206,7 +208,9 @@ async def to_code(config): model = MODELS[config[CONF_MODEL].upper()] color_depth = COLOR_DEPTHS[get_color_depth(config)] pixel_mode = int(config[CONF_PIXEL_MODE].removesuffix("bit")) - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) var = cg.new_Pvariable(config[CONF_ID], width, height, color_depth, pixel_mode) sequence = model.get_sequence(config) diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 3c33c26726..1eacc31fc5 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -235,7 +235,9 @@ def _config_schema(config): only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4]), )(config) model = MODELS[config[CONF_MODEL].upper()] - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) display.add_metadata( config[CONF_ID], width, @@ -273,7 +275,9 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config): model = MODELS[config[CONF_MODEL].upper()] - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) var = cg.new_Pvariable(config[CONF_ID], width, height) cg.add(var.set_model(model.name)) if enable_pin := config.get(CONF_ENABLE_PIN): diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 8c6ffff500..abb7eaa458 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -27,7 +27,7 @@ from esphome.components.mipi import ( requires_buffer, ) from esphome.components.psram import DOMAIN as PSRAM_DOMAIN -from esphome.components.spi import TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE +from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE import esphome.config_validation as cv from esphome.config_validation import ALLOW_EXTRA from esphome.const import ( @@ -121,7 +121,9 @@ def denominator(config): """ model = MODELS[config[CONF_MODEL]] frac = config.get(CONF_BUFFER_SIZE) - _width, height, _offset_width, _offset_height = model.get_dimensions(config) + _width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) if frac is None or frac > 0.75 or height < 32: return 1 try: @@ -169,11 +171,22 @@ def model_schema(config): ] if bus_mode == TYPE_SINGLE: other_options.append(CONF_SPI_16) + # Calculate default SPI mode. Mode3 for octal bus or single bus with no cs pin, mode0 otherwise. + spi_mode = model.get_default(CONF_SPI_MODE) + if not spi_mode: + if bus_mode == TYPE_OCTAL or ( + bus_mode == TYPE_SINGLE + and not config.get(CONF_CS_PIN, model.get_default(CONF_CS_PIN)) + ): + spi_mode = "MODE3" + else: + spi_mode = "MODE0" + schema = ( display.FULL_DISPLAY_SCHEMA.extend( spi.spi_device_schema( cs_pin_required=False, - default_mode="MODE3" if bus_mode == TYPE_OCTAL else "MODE0", + default_mode=spi_mode, default_data_rate=model.get_default(CONF_DATA_RATE, 10_000_000), mode=bus_mode, ) @@ -279,8 +292,8 @@ def customise_schema(config): CONF_MIRROR_Y, CONF_SWAP_XY, } - width, height, _offset_width, _offset_height = model.get_dimensions( - config, not has_hardware_transform + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config, not has_hardware_transform) ) display.add_metadata( config[CONF_ID], @@ -313,14 +326,17 @@ def _final_validate(config): # If no drawing methods are configured, and LVGL is not enabled, show a test card config[CONF_SHOW_TEST_CARD] = True + # Always call this to check dimensions during validation + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) + if PSRAM_DOMAIN not in global_config and CONF_BUFFER_SIZE not in config: # If PSRAM is not enabled, choose a small buffer size by default if not requires_buffer(config): return # No need to pick a size color_depth = get_color_depth(config) frac = denominator(config) - width, height, _offset_width, _offset_height = model.get_dimensions(config) - buffer_size = color_depth // 8 * width * height // frac # Target a buffer size of 20kB, except for large displays, which shouldn't end up here fraction = min(20000.0, buffer_size // 4) / buffer_size @@ -347,8 +363,8 @@ def get_instance(config): CONF_MIRROR_Y, CONF_SWAP_XY, } - width, height, offset_width, offset_height = model.get_dimensions( - config, not has_hardware_transform + width, height, offset_width, offset_height, pad_width, pad_height = ( + model.get_dimensions(config, not has_hardware_transform) ) color_depth = int(config[CONF_COLOR_DEPTH].removesuffix("bit")) @@ -374,6 +390,8 @@ def get_instance(config): height, offset_width, offset_height, + pad_width, + pad_height, madctl, has_hardware_transform, ] diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 5023cf8089..a594e48209 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -81,10 +81,15 @@ void internal_dump_config(const char *model, int width, int height, int offset_w * @tparam HEIGHT Height of the display in pixels * @tparam OFFSET_WIDTH The x-offset of the display in pixels * @tparam OFFSET_HEIGHT The y-offset of the display in pixels + * @tparam PAD_WIDTH Additional pixels recognised by the controller after the offset and width + * @tparam PAD_HEIGHT Additional lines recognised by the controller after the offset and width + * @tparam MADCTL The base MADCTL value for the display, with no rotation bits set. + * @tparam HAS_HARDWARE_ROTATION Whether the display supports hardware rotation. * buffer */ template + int WIDTH, int HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, int PAD_WIDTH, int PAD_HEIGHT, uint16_t MADCTL, + bool HAS_HARDWARE_ROTATION> class MipiSpi : public display::Display, public spi::SPIDevice { @@ -126,17 +131,6 @@ class MipiSpi : public display::Display, return HEIGHT; } - // If hardware rotation is in use, the actual display width/height changes with rotation - int get_width_internal() override { - if constexpr (HAS_HARDWARE_ROTATION) - return get_width(); - return WIDTH; - } - int get_height_internal() override { - if constexpr (HAS_HARDWARE_ROTATION) - return get_height(); - return HEIGHT; - } void set_init_sequence(const std::vector &sequence) { this->init_sequence_ = sequence; } // reset the display, and write the init sequence @@ -233,14 +227,25 @@ class MipiSpi : public display::Display, } void dump_config() override { - internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT, - (uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_, - this->cs_, this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE, - HAS_HARDWARE_ROTATION); + internal_dump_config(this->model_, this->get_width(), this->get_height(), this->get_offset_width_(), + this->get_offset_height_(), (uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8, + IS_BIG_ENDIAN, this->brightness_, this->cs_, this->reset_pin_, this->dc_pin_, this->mode_, + this->data_rate_, BUS_TYPE, HAS_HARDWARE_ROTATION); } protected: /* METHODS */ + // If hardware rotation is in use, the actual display width/height changes with rotation + int get_width_internal() override { + if constexpr (HAS_HARDWARE_ROTATION) + return get_width(); + return WIDTH; + } + int get_height_internal() override { + if constexpr (HAS_HARDWARE_ROTATION) + return get_height(); + return HEIGHT; + } // convenience functions to write commands with or without data void write_command_(uint8_t cmd, uint8_t data) { this->write_command_(cmd, &data, 1); } void write_command_(uint8_t cmd) { this->write_command_(cmd, &cmd, 0); } @@ -330,20 +335,34 @@ class MipiSpi : public display::Display, this->write_command_(MADCTL_CMD, madctl); } - uint16_t get_offset_width_() { + uint16_t get_offset_width_() const { if constexpr (HAS_HARDWARE_ROTATION) { - if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || - this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) - return OFFSET_HEIGHT; + switch (this->rotation_) { + case display::DISPLAY_ROTATION_90_DEGREES: + return OFFSET_HEIGHT; + case display::DISPLAY_ROTATION_180_DEGREES: + return PAD_WIDTH; + case display::DISPLAY_ROTATION_270_DEGREES: + return PAD_HEIGHT; + default: + break; + } } return OFFSET_WIDTH; } - uint16_t get_offset_height_() { + uint16_t get_offset_height_() const { if constexpr (HAS_HARDWARE_ROTATION) { - if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || - this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) - return OFFSET_WIDTH; + switch (this->rotation_) { + case display::DISPLAY_ROTATION_90_DEGREES: + return PAD_WIDTH; + case display::DISPLAY_ROTATION_180_DEGREES: + return PAD_HEIGHT; + case display::DISPLAY_ROTATION_270_DEGREES: + return OFFSET_WIDTH; + default: + break; + } } return OFFSET_HEIGHT; } @@ -396,7 +415,7 @@ class MipiSpi : public display::Display, this->write_cmd_addr_data(0, 0, 0, 0, ptr, w * h, 8); } } else { - for (size_t y = 0; y != static_cast(h); y++) { + for (size_t y = 0; y != h; y++) { if constexpr (BUS_TYPE == BUS_TYPE_SINGLE || BUS_TYPE == BUS_TYPE_SINGLE_16) { this->write_array(ptr, w); } else if constexpr (BUS_TYPE == BUS_TYPE_QUAD) { @@ -492,19 +511,23 @@ class MipiSpi : public display::Display, * @tparam BUFFERPIXEL Color depth of the buffer * @tparam DISPLAYPIXEL Color depth of the display * @tparam BUS_TYPE The type of the interface bus (single, quad, octal) - * @tparam ROTATION The rotation of the display * @tparam WIDTH Width of the display in pixels * @tparam HEIGHT Height of the display in pixels * @tparam OFFSET_WIDTH The x-offset of the display in pixels * @tparam OFFSET_HEIGHT The y-offset of the display in pixels + * @tparam PAD_WIDTH Additional pixels recognised by the controller after the offset and width + * @tparam PAD_HEIGHT Additional lines recognised by the controller after the offset and width + * @tparam MADCTL The base MADCTL value for the display, with no rotation bits set. + * @tparam HAS_HARDWARE_ROTATION Whether the display supports hardware rotation. * @tparam FRACTION The fraction of the display size to use for the buffer (e.g. 4 means a 1/4 buffer). * @tparam ROUNDING The alignment requirement for drawing operations (e.g. 2 means that x coordinates must be even) */ template -class MipiSpiBuffer : public MipiSpi { + uint16_t WIDTH, uint16_t HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, int PAD_WIDTH, int PAD_HEIGHT, + uint16_t MADCTL, bool HAS_HARDWARE_ROTATION, int FRACTION, unsigned ROUNDING> +class MipiSpiBuffer + : public MipiSpi { public: // these values define the buffer size needed to write in accordance with the chip pixel alignment // requirements. If the required rounding does not divide the width and height, we round up to the next multiple and @@ -515,7 +538,7 @@ class MipiSpiBuffer : public MipiSpi::dump_config(); + PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION>::dump_config(); esph_log_config(TAG, " Rotation: %d°\n" " Buffer pixels: %d bits\n" @@ -528,7 +551,7 @@ class MipiSpiBuffer : public MipiSpi::setup(); + PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION>::setup(); RAMAllocator allocator{}; this->buffer_ = allocator.allocate(round_buffer(WIDTH) * round_buffer(HEIGHT) / FRACTION); if (this->buffer_ == nullptr) { diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index ae6accb907..5df7a275df 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -179,6 +179,9 @@ ILI9342 = DriverChip( # M5Stack Core2 uses ILI9341 chip - mirror_x disabled for correct orientation ILI9341.extend( "M5CORE2", + # Reset native dimensions due to axis swap. + native_width=320, + native_height=240, width=320, height=240, mirror_x=False, @@ -786,3 +789,28 @@ ST7796.extend( dc_pin=0, invert_colors=True, ) + +ST7789V.extend( + "GEEKMAGIC-SMALLTV", + data_rate="40MHz", + height=240, + width=240, + offset_width=0, + offset_height=0, + invert_colors=True, + buffer_size=0.125, + reset_pin=2, + dc_pin=0, +) +ST7789V.extend( + "GEEKMAGIC-SMALLTV-PRO", + data_rate="40MHz", + height=240, + width=240, + offset_width=0, + offset_height=0, + invert_colors=True, + buffer_size=0.125, + reset_pin=4, + dc_pin=2, +) diff --git a/esphome/components/mipi_spi/models/waveshare.py b/esphome/components/mipi_spi/models/waveshare.py index ee46f931de..3c719b0f5e 100644 --- a/esphome/components/mipi_spi/models/waveshare.py +++ b/esphome/components/mipi_spi/models/waveshare.py @@ -269,3 +269,16 @@ ST7789V.extend( cs_pin=14, dc_pin={"number": 15, "ignore_strapping_warning": True}, ) + +ST7789V.extend( + "WAVESHARE-ESP32-S3-GEEK", + cs_pin=10, + dc_pin=8, + reset_pin=9, + width=135, + height=240, + offset_width=52, + offset_height=40, + invert_colors=True, + data_rate="40MHz", +) diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index 4873892a8d..d681908027 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -314,7 +314,7 @@ def test_native_generation( main_cpp = generate_main(component_fixture_path("native.yaml")) assert ( - "mipi_spi::MipiSpiBuffer()" + "mipi_spi::MipiSpiBuffer()" in main_cpp ) assert "set_init_sequence({240, 1, 8, 242" in main_cpp @@ -330,7 +330,7 @@ def test_lvgl_generation( main_cpp = generate_main(component_fixture_path("lvgl.yaml")) assert ( - "mipi_spi::MipiSpi();" + "mipi_spi::MipiSpi();" in main_cpp ) assert "set_init_sequence({1, 0, 10, 255, 177" in main_cpp diff --git a/tests/component_tests/mipi_spi/test_padding_and_offsets.py b/tests/component_tests/mipi_spi/test_padding_and_offsets.py new file mode 100644 index 0000000000..82adf88b7e --- /dev/null +++ b/tests/component_tests/mipi_spi/test_padding_and_offsets.py @@ -0,0 +1,434 @@ +"""Tests for padding, offset calculation, and SPI mode configuration in mipi_spi.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.esp32 import ( + KEY_BOARD, + KEY_VARIANT, + VARIANT_ESP32, + VARIANT_ESP32S3, +) +from esphome.components.mipi_spi.display import ( + CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA, + MODELS, + get_instance, +) +from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE +from esphome.const import CONF_CS_PIN, CONF_DC_PIN, PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def validated_config(config: ConfigType) -> ConfigType: + """Run schema + final validation and return the validated config.""" + config = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(config) + return config + + +class TestSPIModeCalculation: + """Test default SPI mode calculation logic.""" + + @pytest.mark.parametrize( + ("bus_mode", "cs_pin", "expected_mode"), + [ + pytest.param( + TYPE_OCTAL, + None, + "MODE3", + id="octal_bus_no_cs", + ), + pytest.param( + TYPE_OCTAL, + 14, + "MODE3", + id="octal_bus_with_cs", + ), + pytest.param( + TYPE_SINGLE, + None, + "MODE3", + id="single_bus_no_cs", + ), + pytest.param( + TYPE_SINGLE, + 14, + "MODE0", + id="single_bus_with_cs", + ), + pytest.param( + TYPE_QUAD, + None, + "MODE0", + id="quad_bus_no_cs", + ), + pytest.param( + TYPE_QUAD, + 14, + "MODE0", + id="quad_bus_with_cs", + ), + ], + ) + def test_default_spi_mode_calculation( + self, + bus_mode: str, + cs_pin: int | None, + expected_mode: str, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that SPI mode is correctly calculated based on bus mode and CS pin.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + config: ConfigType = { + "model": "custom", + "dimensions": {"width": 320, "height": 240}, + "init_sequence": [[0xA0, 0x01]], + "bus_mode": bus_mode, + } + + # Add dc_pin for modes that require it (single and octal) + # quad mode does not allow dc_pin + if bus_mode != TYPE_QUAD: + config[CONF_DC_PIN] = 11 + + # Add CS pin if specified + if cs_pin is not None: + config[CONF_CS_PIN] = cs_pin + + validated = validated_config(config) + # The validated config should have the correct SPI mode set by model_schema + assert validated.get(CONF_SPI_MODE) == expected_mode + + def test_explicit_spi_mode_overrides_default( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that an explicitly configured SPI mode is not overridden.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + # For octal bus, default is MODE3, but we specify MODE0 + config = validated_config( + { + "model": "custom", + "dc_pin": 11, # Required for octal mode + "dimensions": {"width": 320, "height": 240}, + "init_sequence": [[0xA0, 0x01]], + "bus_mode": TYPE_OCTAL, + "spi_mode": "MODE0", # Explicitly set + } + ) + + assert config[CONF_SPI_MODE] == "MODE0" + + +class TestModelWithPaddingDimensions: + """Test that padding dimensions are correctly returned by models.""" + + def test_model_get_dimensions_returns_six_values( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that get_dimensions() returns 6 values including padding.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + # Test with a real model + model = MODELS["ST7735"] + config = {"model": "ST7735", "dc_pin": 18} + + # Call get_dimensions - should return 6 values (width, height, offset_x, offset_y, pad_width, pad_height) + dimensions = model.get_dimensions(config) + assert len(dimensions) == 6 + assert all(isinstance(v, int) for v in dimensions) + + def test_custom_model_padding_values( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test padding values for a custom model with explicit offset.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, + "height": 320, + "offset_width": 20, + "offset_height": 10, + }, + "init_sequence": [[0xA0, 0x01]], + } + ) + + # For custom models, the model is created dynamically from the config + # We can verify the config has the right dimensions + assert config["dimensions"]["width"] == 240 + assert config["dimensions"]["height"] == 320 + assert config["dimensions"]["offset_width"] == 20 + assert config["dimensions"]["offset_height"] == 10 + # Padding is not stored in config for custom models (defaults to 0) + assert config["dimensions"].get("offset_width_pad", 0) == 0 + assert config["dimensions"].get("offset_height_pad", 0) == 0 + + +class TestNewModelVariants: + """Test new model variants added in this change.""" + + def test_m5core2_with_native_dimensions( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test M5CORE2 variant with reset native_width and native_height.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + # M5CORE2 should validate successfully + config = validated_config({"model": "M5CORE2"}) + assert config is not None + + # Verify the model has correct dimensions + model = MODELS["M5CORE2"] + dimensions = model.get_dimensions(config) + width, height, _, _, _, _ = dimensions + assert width == 320 + assert height == 240 + + def test_geekmagic_smalltv_variant( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test GEEKMAGIC-SMALLTV variant of ST7789V.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # GEEKMAGIC-SMALLTV should validate successfully + config = validated_config({"model": "GEEKMAGIC-SMALLTV"}) + assert config is not None + + # Verify it's a variant of ST7789V with expected dimensions + model = MODELS["GEEKMAGIC-SMALLTV"] + dimensions = model.get_dimensions(config) + width, height, offset_x, offset_y, _, _ = dimensions + assert width == 240 + assert height == 240 + assert offset_x == 0 + assert offset_y == 0 + + def test_all_predefined_models_with_new_get_dimensions_signature( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Verify all predefined models work with new 6-value get_dimensions().""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + for name, model in MODELS.items(): + # Skip custom model + if name == "custom": + continue + + config = {"model": name} + + # Try to get dimensions - should return 6 values for all models + dimensions = model.get_dimensions(config) + assert len(dimensions) == 6, ( + f"Model {name} should return 6 dimensions, got {len(dimensions)}" + ) + + +class TestTemplateParameterPassing: + """Test that padding parameters are correctly passed to C++ templates.""" + + def test_instance_creation_with_padding( + self, + generate_main: Callable[[str | Path], str], + component_fixture_path: Callable[[str], Path], + ) -> None: + """Test that get_instance() correctly passes padding parameters to template.""" + main_cpp = generate_main(component_fixture_path("native.yaml")) + + # native.yaml uses JC3636W518 which should have 8 template parameters for MipiSpiBuffer + # (BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DISPLAYPIXEL, BUS_TYPE, + # WIDTH, HEIGHT, OFFSET_WIDTH, OFFSET_HEIGHT, PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION, + # FRACTION, ROUNDING) + # The instantiation should include padding values (0, 0 for default) + assert ( + "mipi_spi::MipiSpiBuffer()" + in main_cpp + ), ( + "Padding parameters (0, 0) should be in the MipiSpiBuffer template instantiation" + ) + + def test_single_mode_with_offset_padding( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that single-mode display with custom offset works with padding.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, + "height": 320, + "offset_width": 40, + "offset_height": 20, + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + # Should not raise any errors + instance = get_instance(config) + assert instance is not None + + +class TestUserConfiguredPadding: + """Test that pad_width and pad_height can be configured in user dimensions.""" + + def test_explicit_pad_width_and_height_in_dimensions( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that pad_width and pad_height can be explicitly set in dimensions.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, + "height": 320, + "offset_width": 40, + "offset_height": 20, + "pad_width": 80, + "pad_height": 40, + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + # Config should validate successfully with padding dimensions + assert config is not None + assert config["dimensions"]["pad_width"] == 80 + assert config["dimensions"]["pad_height"] == 40 + + def test_padding_for_native_dimension_calculation( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that explicit padding allows native dimensions to be calculated.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # A controller that has 320x320 total pixels with: + # - 240x320 active display area + # - offset_width=40, offset_height=20 + # - pad_width=40 (remaining pixels on right), pad_height=60 (remaining pixels on bottom) + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, # Active display width + "height": 320, # Active display height + "offset_width": 40, + "offset_height": 0, + "pad_width": 40, # Pixels after width+offset + "pad_height": 0, # Pixels after height+offset + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + # Get instance should work and correctly calculate native dimensions + instance = get_instance(config) + assert instance is not None + + def test_padding_without_offset( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test padding can be used without offset for controllers with top-left-aligned displays.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # A display with no offset but padding on right and bottom + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, + "height": 240, + "offset_width": 0, + "offset_height": 0, + "pad_width": 0, + "pad_height": 16, + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + assert config is not None + assert config["dimensions"]["width"] == 240 + assert config["dimensions"]["height"] == 240 + assert config["dimensions"]["pad_height"] == 16 From c768e2eabc1cc88a6b78437c42d23ed04b171199 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:48:43 -0400 Subject: [PATCH 0395/1815] [esp32] Fix idedata generation failing on unset ESPHOME_ARDUINO (#16925) --- .clang-tidy.hash | 2 +- esphome/components/esp32/pre_build.py.script | 7 +++++++ esphome/espidf/clang_tidy.py | 2 +- esphome/idf_component.yml | 2 +- platformio.ini | 18 +++++++++++++----- tests/unit_tests/test_espidf_clang_tidy.py | 6 +++--- 6 files changed, 26 insertions(+), 11 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 6f6339ff84..7497cc3679 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -442b8197be00e6fee6b1b64b07a0e3b3558188fddf1d9c510565da884687c451 +a6ec18b82143e293ca6dee6947217f10a387ace99881a34b2c308ff627c8173c diff --git a/esphome/components/esp32/pre_build.py.script b/esphome/components/esp32/pre_build.py.script index af12275a0b..8728e02a34 100644 --- a/esphome/components/esp32/pre_build.py.script +++ b/esphome/components/esp32/pre_build.py.script @@ -1,3 +1,5 @@ +import os + Import("env") # noqa: F821 # Remove custom_sdkconfig from the board config as it causes @@ -7,3 +9,8 @@ if "espidf.custom_sdkconfig" in board: del board._manifest["espidf"]["custom_sdkconfig"] if not board._manifest["espidf"]: del board._manifest["espidf"] + +# Referenced by rules in esphome/idf_component.yml; an unset env var is a +# fatal error there. Always 0: in PlatformIO builds arduino is not a managed +# IDF component. +os.environ.setdefault("ESPHOME_ARDUINO_COMPONENT", "0") diff --git a/esphome/espidf/clang_tidy.py b/esphome/espidf/clang_tidy.py index 62d6f0d00d..d3f4d151c2 100644 --- a/esphome/espidf/clang_tidy.py +++ b/esphome/espidf/clang_tidy.py @@ -162,7 +162,7 @@ def _setup_core(work_dir: Path, settings: _Settings) -> None: # Gates arduino-only components in esphome/idf_component.yml (IDF reads it at # reconfigure time). Set here -- before the manifest is written/reconfigured. - os.environ["ESPHOME_ARDUINO"] = ( + os.environ["ESPHOME_ARDUINO_COMPONENT"] = ( "1" if settings.target_framework == "arduino" else "0" ) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 7cbc2ac4ae..c97e8906a8 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -109,4 +109,4 @@ dependencies: git: https://github.com/FastLED/FastLED.git version: d44c800a9e876a8394caefc2ce4915dd96dac77b rules: - - if: "$ESPHOME_ARDUINO == 1" + - if: "$ESPHOME_ARDUINO_COMPONENT == 1" diff --git a/platformio.ini b/platformio.ini index 718dfb672f..862b7a7dbe 100644 --- a/platformio.ini +++ b/platformio.ini @@ -141,7 +141,10 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script ; This are common settings for the ESP32 (all variants) using Arduino. [common:esp32-arduino] extends = common:arduino -platform = https://github.com/pioarduino/platform-espressif32.git +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip +platform_packages = + pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.9/esp32-core-3.3.9.tar.xz + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz framework = arduino, espidf ; Arduino as an ESP-IDF component lib_deps = @@ -168,12 +171,16 @@ build_flags = -DAUDIO_NO_SD_FS ; i2s_audio build_unflags = ${common.build_unflags} -extra_scripts = post:esphome/components/esp32/post_build.py.script +extra_scripts = + pre:esphome/components/esp32/pre_build.py.script + post:esphome/components/esp32/post_build.py.script ; This are common settings for the ESP32 (all variants) using IDF. [common:esp32-idf] extends = common:idf -platform = https://github.com/pioarduino/platform-espressif32.git +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip +platform_packages = + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz framework = espidf lib_deps = @@ -187,7 +194,9 @@ build_flags = -DUSE_ESP32_FRAMEWORK_ESP_IDF build_unflags = ${common.build_unflags} -extra_scripts = post:esphome/components/esp32/post_build.py.script +extra_scripts = + pre:esphome/components/esp32/pre_build.py.script + post:esphome/components/esp32/post_build.py.script ; These are common settings for the RP2040 using Arduino. [common:rp2040-arduino] @@ -271,7 +280,6 @@ build_unflags = [env:esp32-arduino] extends = common:esp32-arduino board = esp32dev -board_build.partitions = huge_app.csv build_flags = ${common:esp32-arduino.build_flags} ${flags:runtime.build_flags} diff --git a/tests/unit_tests/test_espidf_clang_tidy.py b/tests/unit_tests/test_espidf_clang_tidy.py index 9791dfc543..cb25535d8d 100644 --- a/tests/unit_tests/test_espidf_clang_tidy.py +++ b/tests/unit_tests/test_espidf_clang_tidy.py @@ -56,11 +56,11 @@ def test_setup_core_sets_arduino_env( target_framework: str, expected: str, ) -> None: - """_setup_core sets ESPHOME_ARDUINO, which gates arduino-only manifest deps.""" + """_setup_core sets ESPHOME_ARDUINO_COMPONENT, which gates arduino-only manifest deps.""" # monkeypatch snapshots os.environ, so the env var _setup_core writes is # restored after the test instead of leaking into later tests. - monkeypatch.delenv("ESPHOME_ARDUINO", raising=False) + monkeypatch.delenv("ESPHOME_ARDUINO_COMPONENT", raising=False) _setup_core(tmp_path / "proj", _settings(target_framework=target_framework)) - assert os.environ["ESPHOME_ARDUINO"] == expected + assert os.environ["ESPHOME_ARDUINO_COMPONENT"] == expected From f83e3ad6a6c4d7fdb2dfd20be815313f2afe6e81 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:56:03 -0400 Subject: [PATCH 0396/1815] [core] Support platformio_options on the native ESP-IDF toolchain (#16917) --- esphome/core/__init__.py | 7 + esphome/core/config.py | 66 ++++++++-- esphome/espidf/component.py | 55 ++++++-- tests/unit_tests/core/test_config.py | 123 ++++++++++++++++++ .../fixtures/core/config/libraries.yaml | 8 ++ tests/unit_tests/test_core.py | 18 +++ tests/unit_tests/test_espidf_component.py | 122 ++++++++++++++++- 7 files changed, 366 insertions(+), 33 deletions(-) create mode 100644 tests/unit_tests/fixtures/core/config/libraries.yaml diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 4289cdf3e5..21ff7ef07c 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -958,6 +958,13 @@ class EsphomeCore: return build_flag def add_build_unflag(self, build_unflag: str) -> None: + if self.using_toolchain_esp_idf: + # The native ESP-IDF build generator does not consume build_unflags + _LOGGER.warning( + "Build unflag %s is ignored when building with the native " + "ESP-IDF toolchain", + build_unflag, + ) self.build_unflags.add(build_unflag) _LOGGER.debug("Adding build unflag: %s", build_unflag) diff --git a/esphome/core/config.py b/esphome/core/config.py index 8214fcf80c..b925f0b7d9 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -503,8 +503,58 @@ async def add_includes(includes: list[str], is_c_header: bool = False) -> None: include_file(path, basename, is_c_header) +def _add_library_str(lib: str) -> None: + if "@" in lib: + name, vers = lib.split("@", 1) + cg.add_library(name, vers) + elif "://" in lib: + # Repository... + if "=" in lib: + name, repo = lib.split("=", 1) + cg.add_library(name, None, repo) + else: + cg.add_library(None, None, lib) + else: + cg.add_library(lib, None) + + @coroutine_with_priority(CoroPriority.FINAL) -async def _add_platformio_options(pio_options): +async def _add_platformio_options(pio_options: dict[str, str | list[str]]) -> None: + if CORE.using_toolchain_esp_idf: + # The native ESP-IDF build doesn't read platformio.ini; honor the + # options with a native equivalent and warn about the rest, which + # would otherwise be silently ignored. + for key, val in pio_options.items(): + vals = [val] if isinstance(val, str) else val + if key == CONF_BUILD_FLAGS: + # Deprecated: esphome->build_flags is the native equivalent. + # Remove before 2026.12.0 + _LOGGER.warning( + "esphome->platformio_options->build_flags is deprecated; use " + "esphome->build_flags instead. Support for it will be removed " + "in 2026.12.0." + ) + for flag in vals: + cg.add_build_flag(flag) + elif key == "lib_deps": + # Routed through the regular library mechanism so the libraries + # are converted to IDF components like any other PIO library + for lib in vals: + _add_library_str(lib) + elif key == "lib_ignore": + # Read by the PIO-library-to-IDF-component conversion + # (generate_idf_components); filters both top-level libraries + # and dependencies discovered during conversion + cg.add_platformio_option(key, vals) + elif key != "upload_speed": + # upload_speed needs no handling: it is read from the raw + # config at upload time (upload_using_esptool) + _LOGGER.warning( + "esphome->platformio_options->%s is ignored when building with " + "the native ESP-IDF toolchain", + key, + ) + return # Add includes at the very end, so that they override everything for key, val in pio_options.items(): if key in ["build_flags", "lib_ignore"] and not isinstance(val, list): @@ -655,19 +705,7 @@ async def to_code(config: ConfigType) -> None: # Libraries for lib in config[CONF_LIBRARIES]: - if "@" in lib: - name, vers = lib.split("@", 1) - cg.add_library(name, vers) - elif "://" in lib: - # Repository... - if "=" in lib: - name, repo = lib.split("=", 1) - cg.add_library(name, None, repo) - else: - cg.add_library(None, None, lib) - - else: - cg.add_library(lib, None) + _add_library_str(lib) cg.add_build_flag("-Wno-unused-variable") cg.add_build_flag("-Wno-unused-but-set-variable") diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 7398a91c36..cfd42916b2 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -56,7 +56,7 @@ ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE" class Source: - def download(self, dir_suffix: str, force: bool = False) -> Path: + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: raise NotImplementedError @@ -64,10 +64,12 @@ class URLSource(Source): def __init__(self, url: str): self.url = url - def download(self, dir_suffix: str, force: bool = False) -> Path: + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: base_dir = Path(CORE.data_dir) / DOMAIN h = hashlib.new("sha256") h.update(self.url.encode()) + if salt: + h.update(salt.encode()) path = base_dir / h.hexdigest()[:8] / dir_suffix # Marker file written last to signal a complete extraction. Using a # marker (instead of just `path.is_dir()`) means an interrupted @@ -99,12 +101,12 @@ class GitSource(Source): self.url = url self.ref = ref - def download(self, dir_suffix: str, force: bool = False) -> Path: + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: path, _ = git.clone_or_update( url=self.url, ref=self.ref, refresh=git.NEVER_REFRESH if not force else None, - domain=DOMAIN, + domain=f"{DOMAIN}/{salt}" if salt else DOMAIN, submodules=[], subpath=Path(dir_suffix), ) @@ -146,14 +148,16 @@ class IDFComponent: def get_require_name(self): return self.get_sanitized_name().replace("/", "__") - def download(self, force: bool = False): + def download(self, force: bool = False, salt: str = ""): """ The dependency name should match the directory name at the end of the override path. The ESP-IDF build system uses the directory name as the component name, so the directory of the override_path should match the component name. If you want to specify the full name of the component with the namespace, replace / in the component name with __. @see https://docs.espressif.com/projects/idf-component-manager/en/latest/reference/manifest_file.html """ - self.path = self.source.download(self.get_sanitized_name(), force=force) + self.path = self.source.download( + self.get_sanitized_name(), force=force, salt=salt + ) def _apply_extra_script(component: IDFComponent) -> None: @@ -699,9 +703,33 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: The returned list holds the top-level components (those directly requested); transitive dependencies are converted too and wired into each component's generated manifest. + + ``lib_ignore`` from ``esphome->platformio_options`` excludes libraries by + short name (part after the ``/``), matched against both the top-level + libraries and every dependency discovered during the graph walk. """ nodes: dict[str, _LibNode] = {} + lib_ignore = { + name.split("/")[-1].lower() + for name in CORE.platformio_options.get("lib_ignore", []) + } + + # The generated CMakeLists.txt/idf_component.yml inside the shared cache + # bake in the dependency wiring, which lib_ignore changes; salt the cache + # path so configs with different lib_ignore values don't fight over (and + # constantly rewrite) the same converted component files. + salt = ( + hashlib.sha256(",".join(sorted(lib_ignore)).encode()).hexdigest()[:8] + if lib_ignore + else "" + ) + + def is_ignored(name: str | None) -> bool: + if not lib_ignore or name is None: + return False + return name.split("/")[-1].lower() in lib_ignore + def add_spec(name: str | None, version: str | None, repository: str | None) -> str: key, is_git, locator = _node_key(name, version, repository) node = nodes.get(key) or _LibNode(key=key, is_git=is_git) @@ -718,6 +746,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: top_level = [ add_spec(library.name, library.version, library.repository) for library in libraries + if not is_ignored(library.name) ] # Collect + resolve to a fixpoint: a node is (re)resolved whenever its @@ -749,7 +778,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: component = IDFComponent( _owner_pkgname_to_name(owner, name), version, URLSource(url) ) - component.download() + component.download(salt=salt) library_json_path = component.path / "library.json" library_properties_path = component.path / "library.properties" @@ -787,6 +816,12 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: except InvalidIDFComponent as e: _LOGGER.debug("Skip dependency %s: %s", dependency.get("name"), str(e)) continue + dep_name = _owner_pkgname_to_name( + dependency.get("owner"), dependency.get("name") + ) + if is_ignored(dep_name): + _LOGGER.debug("Skip ignored dependency %s", dep_name) + continue # The version field may actually be a URL (git/archive dependency). dep_version = dependency["version"] dep_url = None @@ -796,11 +831,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: dep_url, dep_version = dep_version, None except (TypeError, ValueError): pass - dep_key = add_spec( - _owner_pkgname_to_name(dependency.get("owner"), dependency.get("name")), - dep_version, - dep_url, - ) + dep_key = add_spec(dep_name, dep_version, dep_url) node.edges.add(dep_key) worklist.append(dep_key) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index ff150f2540..e2b34d92d8 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -20,6 +20,9 @@ from esphome.const import ( CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + Toolchain, ) from esphome.core import CORE, config from esphome.core.config import ( @@ -1161,3 +1164,123 @@ def test_make_app_name_cpp_special_chars_escaped() -> None: cpp_expr, _, _ = make_app_name_cpp('my "device"', "buf", "-", add_mac_suffix=False) # cpp_string_escape uses octal escapes for quotes assert '"' not in cpp_expr[1:-1] # no unescaped quotes inside the outer quotes + + +@pytest.mark.parametrize( + ("lib", "name", "version", "repository"), + [ + ("ArduinoJson", "ArduinoJson", None, None), + ("bblanchon/ArduinoJson@7.4.2", "bblanchon/ArduinoJson", "7.4.2", None), + ( + "noise-c=https://github.com/esphome/noise-c.git", + "noise-c", + None, + "https://github.com/esphome/noise-c.git", + ), + ], +) +def test_add_library_str( + lib: str, name: str, version: str | None, repository: str | None +) -> None: + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "esp32", + KEY_TARGET_FRAMEWORK: "esp-idf", + } + + config._add_library_str(lib) + + libraries = list(CORE.platformio_libraries.values()) + assert len(libraries) == 1 + assert libraries[0].name == name + assert libraries[0].version == version + assert libraries[0].repository == repository + + +@pytest.mark.asyncio +async def test_add_platformio_options_native_idf( + caplog: pytest.LogCaptureFixture, +) -> None: + """On the native IDF toolchain, build_flags/lib_deps/lib_ignore are + honored, upload_speed is silent and everything else warns.""" + CORE.toolchain = Toolchain.ESP_IDF + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "esp32", + KEY_TARGET_FRAMEWORK: "esp-idf", + } + + await config._add_platformio_options( + { + "build_flags": "-DSINGLE_FLAG", # string and list forms both valid + "lib_deps": ["bblanchon/ArduinoJson@7.4.2"], + "lib_ignore": "libsodium", + "upload_speed": "115200", + "board_build.f_flash": "80000000L", + } + ) + + assert "-DSINGLE_FLAG" in CORE.build_flags + assert "ArduinoJson" in CORE.platformio_libraries + # lib_ignore is stored (listified) for generate_idf_components to read; + # nothing else lands in platformio_options on the native toolchain. + assert CORE.platformio_options == {"lib_ignore": ["libsodium"]} + assert "esphome->platformio_options->board_build.f_flash is ignored" in caplog.text + assert "upload_speed" not in caplog.text + # build_flags has a first-class esphome equivalent, so it is deprecated. + # lib_deps/lib_ignore are kept as valid platformio_options (no warning). + assert ( + "esphome->platformio_options->build_flags is deprecated; use " + "esphome->build_flags instead" in caplog.text + ) + assert "lib_deps is deprecated" not in caplog.text + assert "lib_ignore is deprecated" not in caplog.text + + +@pytest.mark.asyncio +async def test_add_platformio_options_platformio( + caplog: pytest.LogCaptureFixture, +) -> None: + """On the PlatformIO toolchain all options pass through to the ini, + with build_flags/lib_ignore listified.""" + CORE.toolchain = Toolchain.PLATFORMIO + + await config._add_platformio_options( + { + "build_flags": "-DSINGLE_FLAG", + "lib_ignore": "libsodium", + "upload_speed": "115200", + } + ) + + assert CORE.platformio_options == { + "build_flags": ["-DSINGLE_FLAG"], + "lib_ignore": ["libsodium"], + "upload_speed": "115200", + } + # platformio_options is the correct mechanism on the PlatformIO toolchain, + # so the native-equivalent deprecation must not fire here. + assert "deprecated" not in caplog.text + + +def test_add_library_str_bare_url_requires_name() -> None: + """A bare repository URL has no library name; CORE.add_library rejects it.""" + with pytest.raises(ValueError, match="must have a name"): + config._add_library_str("https://github.com/esphome/noise-c.git") + + +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::RuntimeWarning") +async def test_to_code_adds_libraries(yaml_file: Callable[[str], Path]) -> None: + """esphome->libraries entries are parsed and registered via cg.add_library.""" + result = load_config_from_fixture(yaml_file, "libraries.yaml", FIXTURES_DIR) + assert result is not None + + with patch("esphome.core.config.cg") as mock_cg: + mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock() + mock_cg.RawExpression.side_effect = lambda *args, **kwargs: MagicMock() + await config.to_code(result[CONF_ESPHOME]) + + mock_cg.add_library.assert_any_call("SomeLib", None) + mock_cg.add_library.assert_any_call("bblanchon/ArduinoJson", "7.4.2") + mock_cg.add_library.assert_any_call( + "noise-c", None, "https://github.com/esphome/noise-c.git" + ) diff --git a/tests/unit_tests/fixtures/core/config/libraries.yaml b/tests/unit_tests/fixtures/core/config/libraries.yaml new file mode 100644 index 0000000000..c93e828f31 --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/libraries.yaml @@ -0,0 +1,8 @@ +esphome: + name: test-libraries + libraries: + - SomeLib + - bblanchon/ArduinoJson@7.4.2 + - noise-c=https://github.com/esphome/noise-c.git + +host: diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index cc371ee1f9..a61b6ae7ae 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -915,3 +915,21 @@ class TestEsphomeCore: mock_enable.assert_called_once_with("Wire") assert "Wire" in target.platformio_libraries + + def test_add_build_unflag__warns_on_native_idf_toolchain( + self, target, caplog: pytest.LogCaptureFixture + ) -> None: + """Build unflags are not consumed by the native IDF build generator, + so adding one on that toolchain warns; PlatformIO stays silent.""" + target.toolchain = const.Toolchain.PLATFORMIO + target.add_build_unflag("-fno-rtti") + assert "ignored" not in caplog.text + + target.toolchain = const.Toolchain.ESP_IDF + target.add_build_unflag("-fno-exceptions") + assert ( + "Build unflag -fno-exceptions is ignored when building with the " + "native ESP-IDF toolchain" in caplog.text + ) + # The unflag is still recorded either way. + assert target.build_unflags == {"-fno-rtti", "-fno-exceptions"} diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 602ff03942..87e168dc94 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -1,3 +1,4 @@ +import hashlib import json import os from pathlib import Path @@ -515,7 +516,7 @@ def test_generate_idf_components_dedupes_shared_dependency( "esphome/C": {"name": "C"}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -557,6 +558,62 @@ def test_generate_idf_components_dedupes_shared_dependency( assert "idf_component_register" in generated +def test_generate_idf_components_lib_ignore_filters_top_level_and_dependencies( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, +) -> None: + # lib_ignore must drop B at the top level and C when it is discovered as a + # dependency of A during the graph walk -- neither may be resolved, + # downloaded, or wired into a manifest. Matching is by lowercase short name. + manifests = { + "esphome/A": { + "name": "A", + "dependencies": [ + {"owner": "esphome", "name": "C", "version": "==1.10021.0"} + ], + }, + "esphome/B": {"name": "B"}, + } + + download_salts: list[str] = [] + + def fake_download(self, force=False, salt=""): + download_salts.append(salt) + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + (self.path / "src").mkdir(parents=True, exist_ok=True) + (self.path / "src" / "x.c").write_text("int x;") + (self.path / "library.json").write_text(json.dumps(manifests[self.name])) + + monkeypatch.setattr(IDFComponent, "download", fake_download) + + resolve_calls: list[str] = [] + + def fake_resolve(owner, pkgname, requirements): + resolve_calls.append(pkgname) + return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz" + + monkeypatch.setattr( + esphome.espidf.component, "_resolve_registry_version", fake_resolve + ) + # lib_ignore is read from CORE.platformio_options (stored there by + # _add_platformio_options); matched by lowercase short name. + monkeypatch.setattr(CORE, "platformio_options", {"lib_ignore": ["B", "esphome/C"]}) + + top = generate_idf_components( + [Library("esphome/A", "1.0.0", None), Library("esphome/B", "1.0.0", None)] + ) + + assert [c.name for c in top] == ["esphome/A"] + # Ignored libraries were never resolved (and therefore never downloaded). + assert resolve_calls == ["A"] + # The ignored dependency is not wired into A's manifest. + assert top[0].dependencies == [] + # lib_ignore changes the generated wiring, so the cache path is salted to + # keep this conversion separate from ones with a different lib_ignore. + assert download_salts == [hashlib.sha256(b"b,c").hexdigest()[:8]] + + def test_generate_idf_components_handles_dependency_cycle( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -575,7 +632,7 @@ def test_generate_idf_components_handles_dependency_cycle( }, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -632,7 +689,7 @@ def test_generate_idf_components_git_overrides_registry_warns( "esphome/shared": {"name": "shared"}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -669,7 +726,7 @@ def test_generate_idf_components_missing_manifest_raises( ) -> None: # A library with neither library.json nor library.properties is invalid; # fail loudly rather than silently generating build files for it. - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) # no library.json / library.properties written @@ -711,7 +768,7 @@ def test_generate_idf_components_warns_on_noncanonical_duplicate( "owner/shared": {"name": "shared"}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -744,7 +801,7 @@ def test_generate_idf_components_incompatible_top_level_raises( ) -> None: # A top-level library that isn't ESP-IDF/esp32 compatible must fail fast, # not be silently dropped. - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "library.json").write_text( @@ -782,7 +839,7 @@ def test_generate_idf_components_incompatible_dependency_skipped( "esphome/B": {"name": "B", "platforms": ["espressif8266"]}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "library.json").write_text(json.dumps(manifests[self.name])) @@ -804,3 +861,54 @@ def test_generate_idf_components_incompatible_dependency_skipped( assert [c.name for c in top] == ["esphome/A"] # The incompatible dependency was dropped, not wired in. assert top[0].dependencies == [] + + +def test_url_source_salt_changes_cache_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The salt is mixed into the URL hash so salted conversions get their own + cache tree. Pre-created extraction markers keep this network-free.""" + monkeypatch.setattr(CORE, "config_path", tmp_path / "test.yaml") + url = "http://example.com/lib.tar.gz" + base = tmp_path / ".esphome" / "pio_components" + expected = {} + for salt in ("", "abcd1234"): + digest = hashlib.sha256((url + salt).encode()).hexdigest()[:8] + expected[salt] = base / digest / "lib" + expected[salt].mkdir(parents=True) + (expected[salt] / ".esphome_extracted").touch() + + source = URLSource(url) + assert source.download("lib") == expected[""] + assert source.download("lib", salt="abcd1234") == expected["abcd1234"] + + +def test_git_source_salt_scopes_domain(monkeypatch: pytest.MonkeyPatch) -> None: + """The salt becomes a subdirectory of the git clone domain.""" + domains: list[str] = [] + + def fake_clone_or_update(**kwargs): + domains.append(kwargs["domain"]) + return Path("/cloned"), None + + monkeypatch.setattr( + esphome.espidf.component.git, "clone_or_update", fake_clone_or_update + ) + + source = GitSource("https://github.com/esphome/noise-c.git", "v1.0") + source.download("noise-c") + source.download("noise-c", salt="abcd1234") + assert domains == ["pio_components", "pio_components/abcd1234"] + + +def test_idf_component_download_passes_salt() -> None: + """IDFComponent.download forwards the sanitized name and salt to the + source and records the returned path.""" + source = MagicMock() + source.download.return_value = Path("/converted/owner/name") + + c = IDFComponent("owner/name", "1.0", source=source) + c.download(force=True, salt="abcd1234") + + source.download.assert_called_once_with("owner/name", force=True, salt="abcd1234") + assert c.path == Path("/converted/owner/name") From 99425e3a976ac8c5c9602ffffa35a3b987f9d779 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:56:18 -0400 Subject: [PATCH 0397/1815] [esp32] Add flash_mode and flash_frequency config options (#16920) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/esp32/__init__.py | 24 +++++++++++++++++ .../esp32/config/flash_mode_default.yaml | 7 +++++ .../esp32/config/flash_mode_idf.yaml | 9 +++++++ tests/component_tests/esp32/test_esp32.py | 26 +++++++++++++++++++ 4 files changed, 66 insertions(+) create mode 100644 tests/component_tests/esp32/config/flash_mode_default.yaml create mode 100644 tests/component_tests/esp32/config/flash_mode_idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 7e7b127814..d703e22e46 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1615,8 +1615,14 @@ FLASH_SIZES = [ ] CONF_FLASH_SIZE = "flash_size" +CONF_FLASH_MODE = "flash_mode" +CONF_FLASH_FREQUENCY = "flash_frequency" CONF_CPU_FREQUENCY = "cpu_frequency" CONF_PARTITIONS = "partitions" +FLASH_MODES = ["qio", "qout", "dio", "dout", "opi"] +FLASH_FREQUENCIES = [ + f"{freq}MHZ" for freq in (120, 80, 64, 60, 48, 40, 32, 30, 26, 24, 20, 16) +] CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -1630,6 +1636,10 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_FLASH_SIZE, default="4MB"): cv.one_of( *FLASH_SIZES, upper=True ), + cv.Optional(CONF_FLASH_MODE): cv.one_of(*FLASH_MODES, lower=True), + cv.Optional(CONF_FLASH_FREQUENCY): cv.one_of( + *FLASH_FREQUENCIES, upper=True + ), cv.Optional(CONF_PARTITIONS): cv.Any( cv.file_, cv.ensure_list( @@ -1866,6 +1876,12 @@ async def to_code(config): "board_upload.maximum_size", int(config[CONF_FLASH_SIZE].removesuffix("MB")) * 1024 * 1024, ) + if flash_mode := config.get(CONF_FLASH_MODE): + cg.add_platformio_option("board_build.flash_mode", flash_mode) + if flash_frequency := config.get(CONF_FLASH_FREQUENCY): + cg.add_platformio_option( + "board_build.f_flash", f"{flash_frequency[:-3]}000000L" + ) if CONF_SOURCE in conf: cg.add_platformio_option("platform_packages", [conf[CONF_SOURCE]]) @@ -2016,6 +2032,14 @@ async def to_code(config): add_idf_sdkconfig_option( f"CONFIG_ESPTOOLPY_FLASHSIZE_{config[CONF_FLASH_SIZE]}", True ) + if flash_mode := config.get(CONF_FLASH_MODE): + add_idf_sdkconfig_option( + f"CONFIG_ESPTOOLPY_FLASHMODE_{flash_mode.upper()}", True + ) + if flash_frequency := config.get(CONF_FLASH_FREQUENCY): + add_idf_sdkconfig_option( + f"CONFIG_ESPTOOLPY_FLASHFREQ_{flash_frequency[:-3]}M", True + ) # ESP32-P4: ESP-IDF 5.5.3 changed the default of ESP32P4_SELECTS_REV_LESS_V3 # from y to n. PlatformIO uses sections.ld.in (for rev <3) or diff --git a/tests/component_tests/esp32/config/flash_mode_default.yaml b/tests/component_tests/esp32/config/flash_mode_default.yaml new file mode 100644 index 0000000000..0d05142099 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_mode_default.yaml @@ -0,0 +1,7 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf diff --git a/tests/component_tests/esp32/config/flash_mode_idf.yaml b/tests/component_tests/esp32/config/flash_mode_idf.yaml new file mode 100644 index 0000000000..7c7f50a439 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_mode_idf.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + board: esp32dev + flash_mode: qio + flash_frequency: 80MHz + framework: + type: esp-idf diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index e9fa9446d4..a8b5720a80 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -285,3 +285,29 @@ def test_native_idf_enables_reproducible_build( sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True + + +def test_flash_mode_sets_sdkconfig_and_pio_option( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """flash_mode/flash_frequency select the esptool flash parameters on both backends.""" + generate_main(component_config_path("flash_mode_idf.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHMODE_QIO") is True + assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHFREQ_80M") is True + assert CORE.platformio_options.get("board_build.flash_mode") == "qio" + assert CORE.platformio_options.get("board_build.f_flash") == "80000000L" + + +def test_flash_mode_unset_leaves_defaults( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Without flash_mode the board/sdkconfig defaults stay untouched.""" + generate_main(component_config_path("flash_mode_default.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHMODE_") for key in sdkconfig) + assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHFREQ_") for key in sdkconfig) + assert "board_build.flash_mode" not in CORE.platformio_options + assert "board_build.f_flash" not in CORE.platformio_options From a46aa594b33b11c252b7eb38002e92568e1f3aa4 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:04:46 +1200 Subject: [PATCH 0398/1815] Bump version to 2026.6.0b2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 647d25559a..809f934797 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.6.0b1 +PROJECT_NUMBER = 2026.6.0b2 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 9a951c1527..27abfa2dd2 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.6.0b1" +__version__ = "2026.6.0b2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From f1fd5f2f4957849602f7903c7d70dc57e119671e Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:10:58 +1000 Subject: [PATCH 0399/1815] [epaper_spi] Metadata, bug fixes, new model (#16950) --- esphome/components/epaper_spi/display.py | 22 +- esphome/components/epaper_spi/epaper_spi.cpp | 4 + esphome/components/epaper_spi/epaper_spi.h | 2 + .../components/epaper_spi/models/ssd1677.py | 17 +- tests/component_tests/conftest.py | 38 ++++ .../epaper_spi/config/enable_pin_test.yaml | 24 +++ .../epaper_spi/test_display_metadata.py | 156 ++++++++++++++ tests/component_tests/epaper_spi/test_init.py | 190 ++++++++++++++---- tests/component_tests/mipi_spi/conftest.py | 39 +--- 9 files changed, 412 insertions(+), 80 deletions(-) create mode 100644 tests/component_tests/epaper_spi/config/enable_pin_test.yaml create mode 100644 tests/component_tests/epaper_spi/test_display_metadata.py diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index b7c56a283a..ce28fb0d67 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -13,6 +13,7 @@ from esphome.components.mipi import ( import esphome.config_validation as cv from esphome.config_validation import update_interval from esphome.const import ( + CONF_AUTO_CLEAR_ENABLED, CONF_BUSY_PIN, CONF_CS_PIN, CONF_DATA_RATE, @@ -129,7 +130,23 @@ def customise_schema(config): }, extra=cv.ALLOW_EXTRA, )(config) - return model_schema(config)(config) + model = MODELS[config[CONF_MODEL]] + config = model_schema(config)(config) + width, height = model.get_dimensions(config) + display.add_metadata( + config[CONF_ID], + width, + height, + has_hardware_rotation=True, + byte_order=cv.UNDEFINED, + has_writer=config.get(CONF_AUTO_CLEAR_ENABLED) is True + or config.get(CONF_PAGES) is not None + or config.get(CONF_LAMBDA) is not None + or config.get(CONF_SHOW_TEST_CARD) is True, + rotation=config.get(CONF_ROTATION, 0), + draw_rounding=0, + ) + return config CONFIG_SCHEMA = customise_schema @@ -197,6 +214,9 @@ async def to_code(config): if busy_pin := config.get(CONF_BUSY_PIN): busy = await cg.gpio_pin_expression(busy_pin) cg.add(var.set_busy_pin(busy)) + if enable_pin := config.get(CONF_ENABLE_PIN): + enable = [await cg.gpio_pin_expression(pin) for pin in enable_pin] + cg.add(var.set_enable_pins(enable)) cg.add(var.set_full_update_every(config[CONF_FULL_UPDATE_EVERY])) if CONF_RESET_DURATION in config: cg.add(var.set_reset_duration(config[CONF_RESET_DURATION])) diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index a2ca311b30..3214f932bf 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -38,6 +38,10 @@ bool EPaperBase::init_buffer_(size_t buffer_length) { } void EPaperBase::setup_pins_() const { + for (auto *pin : this->enable_pins_) { + pin->setup(); + pin->digital_write(true); + } this->dc_pin_->setup(); // OUTPUT this->dc_pin_->digital_write(false); diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 2992ca5afd..8e2fd78e62 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -50,6 +50,7 @@ class EPaperBase : public Display, float get_setup_priority() const override; void set_reset_pin(GPIOPin *reset) { this->reset_pin_ = reset; } void set_busy_pin(GPIOPin *busy) { this->busy_pin_ = busy; } + void set_enable_pins(std::vector enable_pins) { this->enable_pins_ = std::move(enable_pins); } void set_reset_duration(uint32_t reset_duration) { this->reset_duration_ = reset_duration; } void set_transform(uint8_t transform) { this->transform_ = transform; @@ -177,6 +178,7 @@ class EPaperBase : public Display, GPIOPin *dc_pin_{}; GPIOPin *busy_pin_{}; GPIOPin *reset_pin_{}; + std::vector enable_pins_{}; bool waiting_for_idle_{}; uint32_t delay_until_{}; // timestamp until which to delay processing uint16_t next_delay_{}; // milliseconds to delay before next state diff --git a/esphome/components/epaper_spi/models/ssd1677.py b/esphome/components/epaper_spi/models/ssd1677.py index bad33a6a02..13f1035045 100644 --- a/esphome/components/epaper_spi/models/ssd1677.py +++ b/esphome/components/epaper_spi/models/ssd1677.py @@ -10,11 +10,11 @@ class SSD1677(EpaperModel): # fmt: off def get_init_sequence(self, config: dict): - width, _height = self.get_dimensions(config) + _width, height = self.get_dimensions(config) return ( (0x18, 0x80), # Select internal Temp sensor (0x0C, 0xAE, 0xC7, 0xC3, 0xC0, 0x80), # inrush current level 2 - (0x01, (width - 1) % 256, (width - 1) // 256, 0x02), # Set column gate limit + (0x01, (height - 1) % 256, (height - 1) // 256, 0x02), # Set gate limit (number of rows-1) (0x3C, 0x01), # Set border waveform (0x11, 3), # Set transform ) @@ -51,3 +51,16 @@ ssd1677.extend( height=480, mirror_x=True, ) + +ssd1677.extend( + "seeed-reterminal-sticky", + width=800, + height=480, + mirror_x=True, + enable_pin=47, + cs_pin=15, + dc_pin=16, + reset_pin=17, + busy_pin=18, + data_rate="10MHz", +) diff --git a/tests/component_tests/conftest.py b/tests/component_tests/conftest.py index 763628f57c..3730978ec3 100644 --- a/tests/component_tests/conftest.py +++ b/tests/component_tests/conftest.py @@ -104,6 +104,44 @@ def set_component_config() -> Callable[[str, Any], None]: return setter +@pytest.fixture +def choose_variant_with_pins() -> Generator[Callable[[list], None]]: + """Set the ESP32 variant to the first one on which all the given pins are valid. + + For ESP32 only, since the other platforms do not have variants. The core + configuration must already have been set up for an ESP32 target. + Using local imports to avoid importing when ESP32 is not the target. + """ + from esphome import config_validation as cv + from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANTS + from esphome.components.esp32.gpio import validate_gpio_pin + from esphome.const import CONF_INPUT, CONF_OUTPUT + from esphome.pins import gpio_pin_schema + + def chooser(pins: list) -> None: + for variant in VARIANTS: + try: + CORE.data[KEY_ESP32][KEY_VARIANT] = variant + for pin in pins: + if pin is not None: + pin = gpio_pin_schema( + { + CONF_INPUT: True, + CONF_OUTPUT: True, + }, + internal=True, + )(pin) + validate_gpio_pin(pin) + return + except cv.Invalid: + continue + raise cv.Invalid( + f"No compatible variant found for pins: {', '.join(map(str, pins))}" + ) + + yield chooser + + @pytest.fixture def component_fixture_path(request: pytest.FixtureRequest) -> Callable[[str], Path]: """Return a function to get absolute paths relative to the component's fixtures directory.""" diff --git a/tests/component_tests/epaper_spi/config/enable_pin_test.yaml b/tests/component_tests/epaper_spi/config/enable_pin_test.yaml new file mode 100644 index 0000000000..d238cd1d9e --- /dev/null +++ b/tests/component_tests/epaper_spi/config/enable_pin_test.yaml @@ -0,0 +1,24 @@ +esphome: + name: test + +esp32: + board: esp32dev + +spi: + clk_pin: GPIO18 + mosi_pin: GPIO19 + +display: + - platform: epaper_spi + id: epaper_display + model: ssd1677 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO23 + cs_pin: GPIO5 + enable_pin: + - GPIO25 + - GPIO26 + dimensions: + width: 200 + height: 200 diff --git a/tests/component_tests/epaper_spi/test_display_metadata.py b/tests/component_tests/epaper_spi/test_display_metadata.py new file mode 100644 index 0000000000..95afefcf35 --- /dev/null +++ b/tests/component_tests/epaper_spi/test_display_metadata.py @@ -0,0 +1,156 @@ +"""Tests for display metadata created by the epaper_spi component.""" + +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from esphome import config_validation as cv +from esphome.components.display import get_all_display_metadata, get_display_metadata +from esphome.components.epaper_spi.display import CONFIG_SCHEMA +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.const import PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _base_config(**overrides: Any) -> ConfigType: + """Build a minimal valid ssd1677 config, allowing field overrides.""" + config: ConfigType = { + "id": "test_display", + "model": "ssd1677", + "dc_pin": 21, + "busy_pin": 22, + "reset_pin": 23, + "cs_pin": 5, + "dimensions": {"width": 200, "height": 300}, + } + config.update(overrides) + return config + + +def test_metadata_dimensions_and_defaults( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Metadata picks up explicit dimensions and epaper_spi defaults.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + config = CONFIG_SCHEMA(_base_config()) + meta = get_display_metadata(config["id"]) + + assert meta is not None + assert meta.width == 200 + assert meta.height == 300 + # epaper_spi always reports full hardware rotation + assert meta.has_hardware_rotation is True + # epaper_spi does not declare a byte order + assert meta.byte_order is cv.UNDEFINED + assert meta.draw_rounding == 0 + # no drawing methods configured -> no writer + assert meta.has_writer is False + + +def test_metadata_default_dimensions_from_model( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """A model with built-in dimensions reports those without explicit dimensions.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + # waveshare-4.26in is an ssd1677 derivative with default 800x480 dimensions + config = CONFIG_SCHEMA( + { + "id": "wave_display", + "model": "waveshare-4.26in", + "dc_pin": 21, + "busy_pin": 22, + "reset_pin": 23, + "cs_pin": 5, + } + ) + meta = get_display_metadata(config["id"]) + + assert meta is not None + assert meta.width == 800 + assert meta.height == 480 + + +def test_metadata_has_writer_with_auto_clear( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """A display with auto_clear_enabled reports has_writer=True.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + config = CONFIG_SCHEMA(_base_config(auto_clear_enabled=True)) + meta = get_display_metadata(config["id"]) + + assert meta is not None + assert meta.has_writer is True + + +def test_metadata_rotation_propagated( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The configured rotation is stored in the metadata.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + config = CONFIG_SCHEMA(_base_config(rotation=90)) + meta = get_display_metadata(config["id"]) + + assert meta is not None + assert meta.rotation == 90 + + +def test_metadata_multiple_displays_independent( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Each display gets its own independent metadata entry.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + CONFIG_SCHEMA(_base_config(id="disp_a", dimensions={"width": 200, "height": 300})) + CONFIG_SCHEMA(_base_config(id="disp_b", dimensions={"width": 400, "height": 480})) + + all_meta = get_all_display_metadata() + assert all_meta["disp_a"].width == 200 + assert all_meta["disp_a"].height == 300 + assert all_meta["disp_b"].width == 400 + assert all_meta["disp_b"].height == 480 + + +def test_metadata_via_code_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Full code generation registers metadata for the configured display.""" + generate_main(component_config_path("enable_pin_test.yaml")) + + all_meta = get_all_display_metadata() + assert len(all_meta) == 1 + meta = next(iter(all_meta.values())) + # enable_pin_test.yaml: ssd1677 at 200x200 + assert meta.width == 200 + assert meta.height == 200 + assert meta.has_hardware_rotation is True diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index a9f5735fca..c7f34d7dd2 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -1,6 +1,8 @@ """Tests for epaper_spi configuration validation.""" from collections.abc import Callable +from pathlib import Path +import re from typing import Any import pytest @@ -11,17 +13,13 @@ from esphome.components.epaper_spi.display import ( FINAL_VALIDATE_SCHEMA, MODELS, ) -from esphome.components.esp32 import ( - KEY_BOARD, - KEY_VARIANT, - VARIANT_ESP32, - VARIANT_ESP32S3, -) +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 from esphome.const import ( CONF_BUSY_PIN, CONF_CS_PIN, CONF_DC_PIN, CONF_DIMENSIONS, + CONF_ENABLE_PIN, CONF_HEIGHT, CONF_INIT_SEQUENCE, CONF_RESET_PIN, @@ -31,6 +29,30 @@ from esphome.const import ( from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable +# Pin options whose values must be valid on the chosen ESP32 variant. +_PIN_CONF_KEYS = ( + CONF_CS_PIN, + CONF_DC_PIN, + CONF_RESET_PIN, + CONF_BUSY_PIN, + CONF_ENABLE_PIN, +) + + +def _pins_for(model: Any, config: ConfigType) -> list: + """Collect every GPIO the config will actually use (model defaults or injected).""" + pins: list = [] + for key in _PIN_CONF_KEYS: + # An injected value in the config takes precedence over the model default. + value = config[key] if key in config else model.get_default(key) + if not value: # get_default returns False for pins the model omits + continue + if isinstance(value, list): + pins.extend(value) + else: + pins.append(value) + return pins + def run_schema_validation( config: ConfigType, with_final_validate: bool = False @@ -90,29 +112,20 @@ def test_basic_configuration_errors( def test_all_predefined_models( set_core_config: SetCoreConfigCallable, set_component_config: Callable[[str, Any], None], + choose_variant_with_pins: Callable[[list], None], ) -> None: """Test all predefined epaper models validate successfully with appropriate defaults.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # Configure SPI component which is required by epaper_spi + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + # Test all models, providing default values where necessary for name, model in MODELS.items(): - # SEEED models are designed for ESP32-S3 hardware - if name in ("SEEED-EE04-MONO-4.26", "SEEED-RETERMINAL-E1002"): - set_core_config( - PlatformFramework.ESP32_IDF, - platform_data={ - KEY_BOARD: "esp32-s3-devkitc-1", - KEY_VARIANT: VARIANT_ESP32S3, - }, - ) - else: - set_core_config( - PlatformFramework.ESP32_IDF, - platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, - ) - - # Configure SPI component which is required by epaper_spi - set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) - config = {"model": name} # Add ID field @@ -141,6 +154,10 @@ def test_all_predefined_models( if not model.get_default(CONF_CS_PIN): config[CONF_CS_PIN] = 5 + # Select an ESP32 variant on which all of this model's pins are valid + # (some models default to high-numbered pins only present on the S3). + choose_variant_with_pins(_pins_for(model, config)) + run_schema_validation(config) @@ -152,27 +169,19 @@ def test_individual_models( model_name: str, set_core_config: SetCoreConfigCallable, set_component_config: Callable[[str, Any], None], + choose_variant_with_pins: Callable[[list], None], ) -> None: """Test each epaper model individually to ensure it validates correctly.""" - # SEEED models are designed for ESP32-S3 hardware - if model_name in ("SEEED-EE04-MONO-4.26", "SEEED-RETERMINAL-E1002"): - set_core_config( - PlatformFramework.ESP32_IDF, - platform_data={ - KEY_BOARD: "esp32-s3-devkitc-1", - KEY_VARIANT: VARIANT_ESP32S3, - }, - ) - else: - set_core_config( - PlatformFramework.ESP32_IDF, - platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, - ) + model = MODELS[model_name] + + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) # Configure SPI component which is required by epaper_spi set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) - model = MODELS[model_name] config: dict[str, Any] = {"model": model_name, "id": "test_display"} # Add required fields based on model defaults @@ -195,6 +204,10 @@ def test_individual_models( if not model.get_default(CONF_CS_PIN): config[CONF_CS_PIN] = 5 + # Select an ESP32 variant on which all of this model's pins are valid + # (some models default to high-numbered pins only present on the S3). + choose_variant_with_pins(_pins_for(model, config)) + # This should not raise any exceptions run_schema_validation(config) @@ -342,3 +355,102 @@ def test_busy_pin_input_mode_ssd1677( reset_pin_config = result[CONF_RESET_PIN] assert "mode" in reset_pin_config assert reset_pin_config["mode"]["output"] is True + + +def test_enable_pin_single( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Test that a single enable_pin is accepted and normalised to a list of output pins.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # Configure SPI component which is required by epaper_spi + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + result = run_schema_validation( + { + "id": "test_display", + "model": "ssd1677", + "dc_pin": 21, + "busy_pin": 22, + "reset_pin": 23, + "cs_pin": 5, + "enable_pin": 25, + "dimensions": { + "width": 200, + "height": 200, + }, + } + ) + + # A single pin is normalised to a list by cv.ensure_list + assert CONF_ENABLE_PIN in result + enable_pins = result[CONF_ENABLE_PIN] + assert isinstance(enable_pins, list) + assert len(enable_pins) == 1 + # enable pins are configured as outputs + assert enable_pins[0]["mode"]["output"] is True + + +def test_enable_pin_multiple( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Test that a list of enable_pins is accepted.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # Configure SPI component which is required by epaper_spi + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + result = run_schema_validation( + { + "id": "test_display", + "model": "ssd1677", + "dc_pin": 21, + "busy_pin": 22, + "reset_pin": 23, + "cs_pin": 5, + "enable_pin": [25, 26], + "dimensions": { + "width": 200, + "height": 200, + }, + } + ) + + assert CONF_ENABLE_PIN in result + enable_pins = result[CONF_ENABLE_PIN] + assert isinstance(enable_pins, list) + assert len(enable_pins) == 2 + assert all(pin["mode"]["output"] is True for pin in enable_pins) + + +def test_enable_pin_code_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Test that enable_pins are wired up in the generated C++ code.""" + main_cpp = generate_main(component_config_path("enable_pin_test.yaml")) + + # Derive the auto-generated pin variable names from the set_pin() lines + # rather than hard-coding them, so the test does not break when unrelated + # codegen details shift the generated IDs. + def pin_var_for(gpio_num: int) -> str: + match = re.search(rf"(\w+)->set_pin\(::GPIO_NUM_{gpio_num}\);", main_cpp) + assert match is not None, ( + f"GPIO_NUM_{gpio_num} pin not set up in generated code" + ) + return match.group(1) + + pin_25 = pin_var_for(25) + pin_26 = pin_var_for(26) + + # Both pin objects must be passed to the display via set_enable_pins() as a + # std::vector initializer list, in the configured order. + assert f"set_enable_pins({{{pin_25}, {pin_26}}});" in main_cpp diff --git a/tests/component_tests/mipi_spi/conftest.py b/tests/component_tests/mipi_spi/conftest.py index 082a9e55f2..ed48056f63 100644 --- a/tests/component_tests/mipi_spi/conftest.py +++ b/tests/component_tests/mipi_spi/conftest.py @@ -1,16 +1,10 @@ """Tests for mpip_spi configuration validation.""" -from collections.abc import Callable, Generator from unittest import mock import pytest -from esphome import config_validation as cv -from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANTS -from esphome.components.esp32.gpio import validate_gpio_pin -from esphome.const import CONF_INPUT, CONF_OUTPUT -from esphome.core import CORE -from esphome.pins import gpio_pin_schema +# choose_variant_with_pins is provided by the shared parent conftest. @pytest.fixture(autouse=True) @@ -21,34 +15,3 @@ def mock_spi_final_validate(): return_value=lambda config: None, ): yield - - -@pytest.fixture -def choose_variant_with_pins() -> Generator[Callable[[list], None]]: - """ - Set the ESP32 variant for the given model based on pins. For ESP32 only since the other platforms - do not have variants. - """ - - def chooser(pins: list) -> None: - for variant in VARIANTS: - try: - CORE.data[KEY_ESP32][KEY_VARIANT] = variant - for pin in pins: - if pin is not None: - pin = gpio_pin_schema( - { - CONF_INPUT: True, - CONF_OUTPUT: True, - }, - internal=True, - )(pin) - validate_gpio_pin(pin) - return - except cv.Invalid: - continue - raise cv.Invalid( - f"No compatible variant found for pins: {', '.join(map(str, pins))}" - ) - - yield chooser From c1a7a8ff55e2384e89a9958c0bec3e6b69ba31c3 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:01:44 +1200 Subject: [PATCH 0400/1815] Add PEP 572 walrus operator preference to coding conventions (#16951) --- AGENTS.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 4adc53cae9..4346ffbdae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,19 @@ This document provides essential context for AI models interacting with this pro - Protected/private fields: `lower_snake_case_with_trailing_underscore_` - Favor descriptive names over abbreviations +* **Python Idioms:** + * **Assignment expressions (PEP 572):** Prefer the walrus operator (`:=`) wherever it removes a redundant lookup or a throwaway temporary. The most common case in component code is presence-checking a config key and then indexing it separately — fetch once with `.get()` and bind in the condition instead: + ```python + # Bad - looks up CONF_BLAH twice + if CONF_BLAH in config: + cg.add(var.set_blah(config[CONF_BLAH])) + + # Good - single lookup, value bound inline + if (blah := config.get(CONF_BLAH)) is not None: + cg.add(var.set_blah(blah)) + ``` + The same applies to `while` loops and comprehensions where it avoids recomputing a value. Don't contort code to use it — reach for `:=` only when it genuinely cuts repetition or an extra assignment line. + * **C++ Field Visibility:** * **Prefer `protected`:** Use `protected` for most class fields to enable extensibility and testing. Fields should be `lower_snake_case_with_trailing_underscore_`. * **Use `private` for safety-critical cases:** Use `private` visibility when direct field access could introduce bugs or violate invariants: From 1ee49720c7fe4a112b8b7604a13cdf2044fae965 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:55:21 +1200 Subject: [PATCH 0401/1815] [psram] Make schema extractable with per-variant options (#16949) Co-authored-by: J. Nick Koston --- esphome/components/esp32/__init__.py | 29 +++++++++++ esphome/components/psram/__init__.py | 52 +++++++++++++------ script/build_language_schema.py | 9 ++++ tests/component_tests/psram/test_psram.py | 48 +++++++++++++++++ .../psram/validate-quad.esp32-s3-idf.yaml | 5 ++ .../components/psram/validate.esp32-idf.yaml | 4 ++ .../psram/validate.esp32-p4-idf.yaml | 4 ++ tests/script/test_build_language_schema.py | 22 ++++++++ 8 files changed, 158 insertions(+), 15 deletions(-) create mode 100644 tests/components/psram/validate-quad.esp32-s3-idf.yaml create mode 100644 tests/components/psram/validate.esp32-idf.yaml create mode 100644 tests/components/psram/validate.esp32-p4-idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index d703e22e46..5d4b3b8b47 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1,3 +1,4 @@ +from collections.abc import Callable, Iterable import contextlib from dataclasses import dataclass import itertools @@ -6,6 +7,7 @@ import os from pathlib import Path import re import subprocess +from typing import Any from esphome import yaml_util import esphome.codegen as cg @@ -52,6 +54,7 @@ from esphome.coroutine import CoroPriority, coroutine_with_priority from esphome.espidf.component import generate_idf_components import esphome.final_validate as fv from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType from esphome.writer import clean_build, clean_cmake_cache @@ -496,6 +499,32 @@ def get_esp32_variant(core_obj=None): return (core_obj or CORE).data[KEY_ESP32][KEY_VARIANT] +def variant_filtered_enum( + by_variant: dict[str, Iterable[Any]], **kwargs: Any +) -> Callable[[Any], Any]: + """Build a ``one_of`` validator whose valid set depends on the active variant. + + ``by_variant`` maps each ESP32 variant constant to the iterable of values that + are valid on that variant. At validation time the value is checked against the + set allowed for the current target variant. For schema extraction the inverted + ``{value: [variants, ...]}`` map is returned instead, so the language-schema + dump can tag every option with the variants that accept it and frontends can + filter to the user's selected variant. + """ + by_value: dict[str, list[str]] = {} + for variant, values in by_variant.items(): + for value in values: + by_value.setdefault(str(value), []).append(variant) + + @schema_extractor("variant_enum") + def validator(value: Any) -> Any: + if value is SCHEMA_EXTRACT: + return by_value + return cv.one_of(*by_variant.get(get_esp32_variant(), ()), **kwargs)(value) + + return validator + + def get_board(core_obj=None): return (core_obj or CORE).data[KEY_ESP32][KEY_BOARD] diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index d36d900997..296ea6c08c 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -16,6 +16,7 @@ from esphome.components.esp32 import ( add_idf_sdkconfig_option, get_esp32_variant, idf_version, + variant_filtered_enum, ) import esphome.config_validation as cv from esphome.const import ( @@ -29,6 +30,7 @@ from esphome.const import ( ) from esphome.core import CORE import esphome.final_validate as fv +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] DOMAIN = "psram" @@ -70,6 +72,11 @@ SPIRAM_SPEEDS = { VARIANT_ESP32P4: (20, 100, 200), } +SPIRAM_SPEEDS_MHZ = { + variant: tuple(f"{speed}MHZ" for speed in speeds) + for variant, speeds in SPIRAM_SPEEDS.items() +} + def supported() -> bool: if not CORE.is_esp32: @@ -145,15 +152,23 @@ def validate_psram_mode(config): return config -def get_config_schema(config): +def _set_variant_defaults(config: ConfigType) -> ConfigType: + """Resolve variant-dependent defaults before the static schema validates. + + The set of valid ``mode``/``speed`` values is variant-specific (enforced by + ``variant_filtered_enum`` in the schema below); this only supplies the default + when the user omits the option. ``mode`` has no single default on chips that + support more than one mode, so selection is required there. + """ variant = get_esp32_variant() - speeds = [f"{s}MHZ" for s in SPIRAM_SPEEDS.get(variant, [])] - if not speeds: + modes = SPIRAM_MODES.get(variant) + speeds = SPIRAM_SPEEDS.get(variant) + if not modes or not speeds: raise cv.Invalid("PSRAM is not supported on this chip") - modes = SPIRAM_MODES[variant] - if CONF_MODE not in config and len(modes) != 1: - raise ( - cv.Invalid( + config = config.copy() + if CONF_MODE not in config: + if len(modes) != 1: + raise cv.Invalid( textwrap.dedent( f""" {variant} requires PSRAM mode selection; one of {", ".join(modes)} @@ -161,20 +176,27 @@ def get_config_schema(config): """ ) ) - ) - return cv.Schema( + config[CONF_MODE] = modes[0] + if CONF_SPEED not in config: + config[CONF_SPEED] = f"{speeds[0]}MHZ" + return config + + +CONFIG_SCHEMA = cv.All( + _set_variant_defaults, + cv.Schema( { cv.GenerateID(): cv.declare_id(PsramComponent), - cv.Optional(CONF_MODE, default=modes[0]): cv.one_of(*modes, lower=True), + cv.Optional(CONF_MODE): variant_filtered_enum(SPIRAM_MODES, lower=True), cv.Optional(CONF_ENABLE_ECC, default=False): cv.boolean, - cv.Optional(CONF_SPEED, default=speeds[0]): cv.one_of(*speeds, upper=True), + cv.Optional(CONF_SPEED): variant_filtered_enum( + SPIRAM_SPEEDS_MHZ, upper=True + ), cv.Optional(CONF_DISABLED, default=False): cv.boolean, cv.Optional(CONF_IGNORE_NOT_FOUND, default=True): cv.boolean, } - )(config) - - -CONFIG_SCHEMA = get_config_schema + ), +) def _store_psram_guaranteed(config): diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 61845c4b25..974957245a 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -951,6 +951,15 @@ def convert(schema, config_var, path): elif schema_type == "enum": config_var[S_TYPE] = "enum" config_var["values"] = dict.fromkeys(list(data.keys())) + elif schema_type == "variant_enum": + # Per-variant enum (e.g. psram mode/speed): each value carries the + # list of variants that accept it so clients can filter to the + # user's selected variant. Additive to the plain enum format — + # consumers that ignore the metadata still see every option. + config_var[S_TYPE] = "enum" + config_var["values"] = { + value: {"variants": variants} for value, variants in data.items() + } elif schema_type == "maybe": # maybe_simple_value: either a scalar shorthand (mapped to the key in # data[1]) or the full wrapped schema. The wrapped schema is usually a diff --git a/tests/component_tests/psram/test_psram.py b/tests/component_tests/psram/test_psram.py index 0924e66adc..ea4adc69a9 100644 --- a/tests/component_tests/psram/test_psram.py +++ b/tests/component_tests/psram/test_psram.py @@ -97,6 +97,54 @@ def test_psram_configuration_valid_supported_variants( FINAL_VALIDATE_SCHEMA(config) +def test_psram_applies_single_mode_default( + set_core_config: SetCoreConfigCallable, +) -> None: + """On a single-mode variant the omitted mode/speed fall back to defaults.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32}, + full_config={CONF_ESPHOME: {}}, + ) + from esphome.components.psram import CONFIG_SCHEMA + + config = CONFIG_SCHEMA({}) + assert config["mode"] == "quad" + assert config["speed"] == "40MHZ" + assert config["disabled"] is False + assert config["ignore_not_found"] is True + + +def test_psram_requires_mode_on_multi_mode_variant( + set_core_config: SetCoreConfigCallable, +) -> None: + """A variant with multiple modes requires an explicit mode selection.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32S3}, + full_config={CONF_ESPHOME: {}}, + ) + from esphome.components.psram import CONFIG_SCHEMA + + with pytest.raises(cv.Invalid, match=r"requires PSRAM mode selection"): + CONFIG_SCHEMA({}) + + +def test_psram_rejects_mode_invalid_for_variant( + set_core_config: SetCoreConfigCallable, +) -> None: + """A mode not supported by the active variant is rejected by the schema.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32}, + full_config={CONF_ESPHOME: {}}, + ) + from esphome.components.psram import CONFIG_SCHEMA + + with pytest.raises(cv.Invalid, match=r"Unknown value 'octal'"): + CONFIG_SCHEMA({"mode": "octal"}) + + def _setup_psram_final_validation_test( esp32_config: dict, set_core_config: SetCoreConfigCallable, diff --git a/tests/components/psram/validate-quad.esp32-s3-idf.yaml b/tests/components/psram/validate-quad.esp32-s3-idf.yaml new file mode 100644 index 0000000000..3fa6360d14 --- /dev/null +++ b/tests/components/psram/validate-quad.esp32-s3-idf.yaml @@ -0,0 +1,5 @@ +# Config-only: the ESP32-S3 supports both quad and octal. The compile test uses +# octal; this exercises the other branch of the per-variant mode enum (quad) and +# lets speed fall back to its 40MHz default. +psram: + mode: quad diff --git a/tests/components/psram/validate.esp32-idf.yaml b/tests/components/psram/validate.esp32-idf.yaml new file mode 100644 index 0000000000..9c04284163 --- /dev/null +++ b/tests/components/psram/validate.esp32-idf.yaml @@ -0,0 +1,4 @@ +# Config-only: with no options the single-mode ESP32 resolves mode -> quad and +# speed -> 40MHz from the per-variant defaults. Compiling adds no signal here, +# so this only runs through `esphome config`. +psram: diff --git a/tests/components/psram/validate.esp32-p4-idf.yaml b/tests/components/psram/validate.esp32-p4-idf.yaml new file mode 100644 index 0000000000..3e5899061f --- /dev/null +++ b/tests/components/psram/validate.esp32-p4-idf.yaml @@ -0,0 +1,4 @@ +# Config-only: the ESP32-P4 has a distinct value set (hex mode, 20/100/200MHz). +# With no options it resolves mode -> hex and speed -> 20MHz, exercising the +# P4-specific default branch of the per-variant enums. +psram: diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index badd4686f6..8bbaa2773a 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -139,6 +139,28 @@ def test_convert_walks_callable_schema_extractor() -> None: assert "foo" in config_var["schema"]["config_vars"] +def test_convert_emits_variant_enum() -> None: + """A per-variant enum is dumped with each value tagged by its variants.""" + from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANT_ESP32S3, + variant_filtered_enum, + ) + + validator = variant_filtered_enum( + {VARIANT_ESP32: ("quad",), VARIANT_ESP32S3: ("quad", "octal")}, + lower=True, + ) + config_var: dict = {} + _bls.convert(validator, config_var, "/test") + + assert config_var["type"] == "enum" + assert config_var["values"] == { + "quad": {"variants": [VARIANT_ESP32, VARIANT_ESP32S3]}, + "octal": {"variants": [VARIANT_ESP32S3]}, + } + + def test_convert_keys_emits_heuristic_sensitive_marker() -> None: converted: dict = {} _bls.convert_keys(converted, {cv.Optional("password"): cv.string}, "/root") From 963465a0a6977db8693057b7609de64ffde613a6 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:25:54 +1000 Subject: [PATCH 0402/1815] [mipi_dsi] Add SWRESET command to M5Stack Tab5-V2 init sequence (#16975) --- esphome/components/mipi_dsi/models/m5stack.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/mipi_dsi/models/m5stack.py b/esphome/components/mipi_dsi/models/m5stack.py index 2298f76cd4..53fac9b534 100644 --- a/esphome/components/mipi_dsi/models/m5stack.py +++ b/esphome/components/mipi_dsi/models/m5stack.py @@ -71,6 +71,7 @@ DriverChip( swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ + (0x01,), (0x60, 0x71, 0x23, 0xa2), (0x60, 0x71, 0x23, 0xa3), (0x60, 0x71, 0x23, 0xa4), From 3420cff31647983904e4bd6289aed972fcd8142f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Jun 2026 15:46:33 -0500 Subject: [PATCH 0403/1815] [core] Attribute "took a long time" blocking warning to the owning script (#16768) --- .../components/runtime_stats/runtime_stats.h | 2 +- esphome/components/script/script.h | 19 ++- esphome/core/application.h | 95 +++++++++++++- esphome/core/base_automation.h | 9 +- esphome/core/component.cpp | 30 +++-- esphome/core/component.h | 60 +-------- esphome/core/millis_internal.h | 4 +- esphome/core/scheduler.cpp | 33 +++-- esphome/core/scheduler.h | 39 ++++-- .../fixtures/scheduler_blocking_warning.yaml | 22 ++++ ...duler_blocking_warning_generic_source.yaml | 30 +++++ ...eduler_delay_runs_on_failed_component.yaml | 29 +++++ .../test_scheduler_blocking_warning.py | 120 ++++++++++++++++++ 13 files changed, 389 insertions(+), 103 deletions(-) create mode 100644 tests/integration/fixtures/scheduler_blocking_warning.yaml create mode 100644 tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml create mode 100644 tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml create mode 100644 tests/integration/test_scheduler_blocking_warning.py diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 888d48e672..1e4910453a 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -47,7 +47,7 @@ class RuntimeStatsCollector { // overhead between Phase A and stats belongs to "residual"). // Residual overhead at log time = active − Σ(component) − before − tail, // which captures per-iteration inter-component bookkeeping (set_current_component, - // WarnIfComponentBlockingGuard construction/destruction, feed_wdt_with_time calls, + // LoopBlockingGuard construction/destruction, feed_wdt_with_time calls, // the for-loop itself). void record_loop_active(uint32_t active_us, uint32_t before_us, uint32_t tail_us) { this->period_active_count_++; diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 847fab02bd..6cd33e566c 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -3,6 +3,7 @@ #include #include #include +#include "esphome/core/application.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -57,6 +58,14 @@ template class Script : public ScriptLogger, public Triggerexecute(std::get(tuple)...); } + // Run the action chain with this script's name published as the current source (RAII save/restore, + // so nesting composes), so deferred work inside the script is attributed to it in blocking + // warnings. Force-inlined to fold into the always-inlined trigger chain (no extra stack frame). + inline void run_actions_(const Ts &...x) ESPHOME_ALWAYS_INLINE { + ScopedSourceGuard source_guard{this->name_}; + this->trigger(x...); + } + const LogString *name_{nullptr}; }; @@ -74,7 +83,7 @@ template class SingleScript : public Script { return; } - this->trigger(x...); + this->run_actions_(x...); } }; @@ -91,7 +100,7 @@ template class RestartScript : public Script { this->stop_action(); } - this->trigger(x...); + this->run_actions_(x...); } }; @@ -136,7 +145,7 @@ template class QueueingScript : public Script, public Com return; } - this->trigger(x...); + this->run_actions_(x...); // Check if the trigger was immediate and we can continue right away. this->loop(); } @@ -175,7 +184,7 @@ template class QueueingScript : public Script, public Com } template void trigger_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { - this->trigger(std::get(tuple)...); + this->run_actions_(std::get(tuple)...); } int num_queued_ = 0; // Number of queued instances (not including currently running) @@ -197,7 +206,7 @@ template class ParallelScript : public Script { LOG_STR_ARG(this->name_)); return; } - this->trigger(x...); + this->run_actions_(x...); } void set_max_runs(int max_runs) { max_runs_ = max_runs; } diff --git a/esphome/core/application.h b/esphome/core/application.h index 369c970d46..7c12a66b2c 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -104,9 +104,13 @@ class Application { void register_area(Area *area) { this->areas_.push_back(area); } #endif - void set_current_component(Component *component) { this->current_component_ = component; } Component *get_current_component() { return this->current_component_; } + // Owning script of the action chain currently executing (nullptr when none); used to attribute + // blocking warnings for deferred work to the script that scheduled it. + void set_current_source(const LogString *source) { this->current_source_ = source; } + const LogString *get_current_source() { return this->current_source_; } + // Entity register methods (generated from entity_types.h). // Each entity type gets two overloads: // - register_(obj) — bare push_back @@ -393,6 +397,7 @@ class Application { protected: friend Component; friend class Scheduler; + friend class LoopBlockingGuard; #ifdef USE_RUNTIME_STATS friend class runtime_stats::RuntimeStatsCollector; #endif @@ -402,6 +407,14 @@ class Application { /// 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; } + // Publish the running unit's identity (component + source) and dispatch time together, so a + // dispatch site can't set one without the others. Friend-only (Scheduler). + void set_current_execution_context_(Component *component, const LogString *source, uint32_t now) { + this->current_component_ = component; + this->current_source_ = source; + this->set_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 @@ -482,6 +495,7 @@ class Application { // Pointer-sized members first Component *current_component_{nullptr}; + const LogString *current_source_{nullptr}; // std::vector (3 pointers each: begin, end, capacity) // Partitioned vector design for looping components @@ -554,6 +568,76 @@ class Application { /// Global storage of Application pointer - only one Application can exist. extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +/// RAII guard that publishes a current source (e.g. a script name) for a scope and restores the +/// previous value on exit, attributing deferred work scheduled inside to that source. +class ScopedSourceGuard { + public: + explicit ScopedSourceGuard(const LogString *source) : prev_(App.get_current_source()) { + App.set_current_source(source); + } + ~ScopedSourceGuard() { App.set_current_source(this->prev_); } + ScopedSourceGuard(const ScopedSourceGuard &) = delete; + ScopedSourceGuard &operator=(const ScopedSourceGuard &) = delete; + + private: + const LogString *prev_; +}; + +// Times one unit of work (a component loop() or a scheduled callback) and warns if it blocks the +// main loop too long. The constructor publishes the unit's identity + dispatch time to App; +// finish()/the cold warning path read them back, so the guard stores no copy. +// +// Guards must not nest: the constructor publishes to App but never restores on destruction, so a +// nested guard would clobber the outer's context. Safe because the two dispatch sites (component +// loop phase, execute_item_) run strictly sequentially and aren't re-entered from a timed callback. +class LoopBlockingGuard { + public: + // Publish the unit's identity + dispatch time, then start timing. The millis start lives in App, + // so only the runtime-stats micros stamp is kept here. + LoopBlockingGuard(Component *component, const LogString *source, uint32_t now) { + App.set_current_execution_context_(component, source, now); +#ifdef USE_RUNTIME_STATS + this->started_us_ = micros(); +#endif + } + + // Finish the timing operation and return the current time (millis) + // Inlined: the fast path is just millis() + subtract + compare + inline uint32_t HOT finish() { +#ifdef USE_RUNTIME_STATS + uint32_t elapsed_us = micros() - this->started_us_; + // Delays have no component; accumulate into the global counter so loop() can subtract them. + Component *component = App.get_current_component(); + if (component != nullptr) { + component->runtime_stats_.record_time(elapsed_us); + } else { + ComponentRuntimeStats::global_recorded_us += elapsed_us; + } +#endif + uint32_t curr_time = MillisInternal::get(); +#ifndef USE_BENCHMARK + // Fast path: compare against constant threshold in ms (computed at compile time from centiseconds) + static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast(WARN_IF_BLOCKING_OVER_CS) * 10U; + uint32_t blocking_time = curr_time - App.get_loop_component_start_time(); + if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] { + warn_blocking(blocking_time); + } +#endif + return curr_time; + } + + ~LoopBlockingGuard() = default; + +#ifdef USE_RUNTIME_STATS + protected: + uint32_t started_us_; +#endif + + private: + // Cold path; defined in component.cpp. Reads the current component/source from App to name the culprit. + static void __attribute__((noinline, cold)) warn_blocking(uint32_t blocking_time); +}; + // Phase A: drain wake notifications and run the scheduler. Invoked on every // Application::loop() tick regardless of whether a component phase runs, so // scheduler items fire at their requested cadence even when the caller has @@ -607,7 +691,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // before/tail splits recorded below. uint32_t loop_active_start_us = micros(); // Snapshot the cumulative component-recorded time so we can subtract the - // slice that the scheduler spends inside its own WarnIfComponentBlockingGuard + // slice that the scheduler spends inside its own LoopBlockingGuard // (scheduler.cpp) — that time is already counted in per-component stats, // so charging it again to "before" would double-count. uint64_t loop_recorded_snap = ComponentRuntimeStats::global_recorded_us; @@ -660,12 +744,9 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { this->current_loop_index_++) { Component *component = this->looping_components_[this->current_loop_index_]; - // Update the cached time before each component runs - this->loop_component_start_time_ = last_op_end_time; - { - this->set_current_component(component); - WarnIfComponentBlockingGuard guard{component, last_op_end_time}; + // Guard publishes this component (no script source) + dispatch time, then times loop(). + LoopBlockingGuard guard{component, nullptr, last_op_end_time}; component->loop(); // Use the finish method to get the current time as the end time last_op_end_time = guard.finish(); diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index dcad7c9d2e..cf8b05a300 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -201,7 +201,10 @@ template class DelayAction : public Action { /* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER, /* static_name= */ reinterpret_cast(this), /* hash_or_id= */ 0, this->delay_.value(), [this]() { this->play_next_(); }, - /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); + /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1, + // Record the owning script (if any) so the blocking warning can name it; propagates across + // chained delays via the scheduler. + /* source= */ App.get_current_source()); } else { // For delays with arguments, capture by value to preserve argument values // Arguments must be copied because original references may be invalid after delay @@ -212,7 +215,9 @@ template class DelayAction : public Action { /* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER, /* static_name= */ reinterpret_cast(this), /* hash_or_id= */ 0, this->delay_.value(x...), std::move(f), - /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); + /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1, + // See the no-argument branch above: record the owning script for log attribution. + /* source= */ App.get_current_source()); } } diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 2d80301897..7ef5ff50a5 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -258,9 +258,11 @@ void Component::call() { break; } } -bool Component::should_warn_of_blocking(uint32_t blocking_time) { +bool Component::should_warn_of_blocking(uint32_t blocking_time, uint32_t &threshold_ms_out) { // Convert centisecond threshold to milliseconds for comparison uint32_t threshold_ms = static_cast(this->warn_if_blocking_over_) * 10U; + // Report the threshold that was exceeded (before any ratcheting below) so the warning is accurate. + threshold_ms_out = threshold_ms; if (blocking_time > threshold_ms) { // Set new threshold: blocking_time + increment, converted back to centiseconds uint32_t new_threshold_ms = blocking_time + WARN_IF_BLOCKING_INCREMENT_MS; @@ -491,19 +493,25 @@ uint32_t PollingComponent::get_update_interval() const { return this->update_int uint64_t ComponentRuntimeStats::global_recorded_us = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) #endif -void __attribute__((noinline, cold)) -WarnIfComponentBlockingGuard::warn_blocking(Component *component, uint32_t blocking_time) { - bool should_warn; +void __attribute__((noinline, cold)) LoopBlockingGuard::warn_blocking(uint32_t blocking_time) { + // Identity is published on App by the caller before the guard is built; read it back here. + Component *component = App.get_current_component(); + // Component-less path always warns (the caller already checked the constant threshold). + uint32_t threshold_ms = WARN_IF_BLOCKING_OVER_MS; + if (component != nullptr && !component->should_warn_of_blocking(blocking_time, threshold_ms)) { + return; // Component's (possibly ratcheted) threshold not exceeded yet + } + // Component name if any, else the published source (owning script), else a generic label. + const LogString *name; if (component != nullptr) { - should_warn = component->should_warn_of_blocking(blocking_time); + name = component->get_component_log_str(); } else { - should_warn = true; // Already checked > WARN_IF_BLOCKING_OVER_MS in caller - } - if (should_warn) { - ESP_LOGW(TAG, "%s took a long time for an operation (%" PRIu32 " ms), max is 30 ms", - component == nullptr ? LOG_STR_LITERAL("") : LOG_STR_ARG(component->get_component_log_str()), - blocking_time); + name = App.get_current_source(); + if (name == nullptr) + name = LOG_STR("a scheduled task"); } + ESP_LOGW(TAG, "%s took a long time for an operation (%" PRIu32 " ms), max is %" PRIu32 " ms", LOG_STR_ARG(name), + blocking_time, threshold_ms); } #ifdef USE_SETUP_PRIORITY_OVERRIDE diff --git a/esphome/core/component.h b/esphome/core/component.h index ff10f1a8f1..299a5f72ea 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -118,7 +118,7 @@ struct ComponentRuntimeStats { // Cumulative sum of every record_time() duration since boot, across all // components. Used by Application::loop() to snapshot time spent inside - // WarnIfComponentBlockingGuard (including guards constructed by the + // LoopBlockingGuard (including guards constructed by the // scheduler at scheduler.cpp) so main-loop overhead accounting can // subtract scheduled-callback time from the before_loop_tasks_ wall time. static uint64_t global_recorded_us; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -326,7 +326,7 @@ class Component { return component_source_lookup(this->component_source_index_); } - bool should_warn_of_blocking(uint32_t blocking_time); + bool should_warn_of_blocking(uint32_t blocking_time, uint32_t &threshold_ms_out); protected: friend class Application; @@ -571,7 +571,7 @@ class Component { volatile bool pending_enable_loop_{false}; ///< ISR-safe flag for enable_loop_soon_any_context #ifdef USE_RUNTIME_STATS friend class runtime_stats::RuntimeStatsCollector; - friend class WarnIfComponentBlockingGuard; + friend class LoopBlockingGuard; ComponentRuntimeStats runtime_stats_; #endif }; @@ -619,59 +619,7 @@ class PollingComponent : public Component { uint32_t update_interval_; }; -// millis() and micros() are available via hal.h - -class WarnIfComponentBlockingGuard { - public: - WarnIfComponentBlockingGuard(Component *component, uint32_t start_time) - : started_(start_time), - component_(component) -#ifdef USE_RUNTIME_STATS - , - started_us_(micros()) -#endif - { - } - - // Finish the timing operation and return the current time (millis) - // Inlined: the fast path is just millis() + subtract + compare - inline uint32_t HOT finish() { -#ifdef USE_RUNTIME_STATS - uint32_t elapsed_us = micros() - this->started_us_; - // component_ is nullptr for self-keyed scheduler items (set_timeout/set_interval(self, ...)) - if (this->component_ != nullptr) { - this->component_->runtime_stats_.record_time(elapsed_us); - } else { - // Still accumulate into the global counter so Application::loop() can subtract - // this time from before_loop_tasks_ wall time. - ComponentRuntimeStats::global_recorded_us += elapsed_us; - } -#endif - uint32_t curr_time = MillisInternal::get(); -#ifndef USE_BENCHMARK - // Fast path: compare against constant threshold in ms (computed at compile time from centiseconds) - static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast(WARN_IF_BLOCKING_OVER_CS) * 10U; - uint32_t blocking_time = curr_time - this->started_; - if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] { - warn_blocking(this->component_, blocking_time); - } -#endif - return curr_time; - } - - ~WarnIfComponentBlockingGuard() = default; - - protected: - uint32_t started_; - Component *component_; -#ifdef USE_RUNTIME_STATS - uint32_t started_us_; -#endif - - private: - // Cold path for blocking warning - defined in component.cpp - static void __attribute__((noinline, cold)) warn_blocking(Component *component, uint32_t blocking_time); -}; +// LoopBlockingGuard lives in application.h because it reads its state from App. // Function to clear setup priority overrides after all components are set up // Only has an implementation when USE_SETUP_PRIORITY_OVERRIDE is defined diff --git a/esphome/core/millis_internal.h b/esphome/core/millis_internal.h index bc1d55a1c4..7297d22357 100644 --- a/esphome/core/millis_internal.h +++ b/esphome/core/millis_internal.h @@ -16,7 +16,7 @@ namespace esphome { // Friend-gated accessor for a fast millis() variant intended only for // known task-context callers on the main loop hot path (Application::loop() -// and WarnIfComponentBlockingGuard::finish()). It skips the ISR-context +// and LoopBlockingGuard::finish()). It skips the ISR-context // dispatch that the public esphome::millis() pays on ESP32 and libretiny. // // MUST NOT be called from ISR context: on ESP32 and libretiny it calls the @@ -50,7 +50,7 @@ class MillisInternal { #endif } friend class Application; - friend class WarnIfComponentBlockingGuard; + friend class LoopBlockingGuard; }; } // namespace esphome diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index a7c624486d..15bb9ea239 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -131,7 +131,8 @@ bool Scheduler::is_retry_cancelled_locked_(Component *component, NameType name_t // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type, const char *static_name, uint32_t hash_or_id, uint32_t delay, - std::function &&func, bool is_retry, bool skip_cancel) { + std::function &&func, bool is_retry, bool skip_cancel, + const LogString *source) { if (delay == SCHEDULER_DONT_RUN) { // Still need to cancel existing timer if we have a name/id if (!skip_cancel) { @@ -174,7 +175,12 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type // Create and populate the scheduler item SchedulerItem *item = this->get_item_from_pool_locked_(); - item->component = component; + // SELF_POINTER items store the source name (owning script) in the union slot instead of a component. + if (name_type == NameType::SELF_POINTER) { + item->source_name = source; + } else { + item->component = component; + } item->set_name(name_type, static_name, hash_or_id); item->type = type; // Use destroy + placement-new instead of move-assignment. @@ -642,8 +648,8 @@ uint32_t HOT Scheduler::call(uint32_t now) { // Not reached timeout yet, done for this call break; } - // Don't run on failed components - if (item->component != nullptr && item->component->is_failed()) { + // Don't run on failed components (is_item_failed_ exempts SELF_POINTER delays). + if (this->is_item_failed_(item)) { LockGuard guard{this->lock_}; this->recycle_item_main_loop_(this->pop_raw_locked_()); continue; @@ -790,10 +796,21 @@ 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}; + // Resolve the component and (for SELF_POINTER/deferred items) the source name from the shared + // union slot with a single name-type check. Self-keyed items have no owning component; their slot + // holds the source name (e.g. the owning script), published so deferred work chained inside the + // callback re-captures it and the blocking warning can name the script instead of "". + Component *component; + const LogString *source; + if (item->get_name_type() == NameType::SELF_POINTER) { + component = nullptr; + source = item->source_name; + } else { + component = item->component; + source = nullptr; + } + // Guard publishes the item's identity + dispatch time, then times the callback. + LoopBlockingGuard guard{component, source, now}; item->callback(); uint32_t end = guard.finish(); // Feed the watchdog after each scheduled item (both main heap and defer diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index b640aa86fe..378c0fb94b 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -183,11 +183,12 @@ class Scheduler { protected: struct SchedulerItem { - // Ordered by size to minimize padding. - // `component` while live; `next_free` while in scheduler_item_pool_head_ (mutually exclusive). + // Ordered by size to minimize padding. Mutually exclusive by state; read the component via + // get_component() so SELF_POINTER items read as component-less. union { - Component *component; - SchedulerItem *next_free; + Component *component; // live, non-SELF_POINTER: owning component + const LogString *source_name; // live SELF_POINTER: owning script name (log attribution) + SchedulerItem *next_free; // while pooled }; // Optimized name storage using tagged union - zero heap allocation union { @@ -302,14 +303,23 @@ class Scheduler { next_execution_high_ = static_cast(value >> 32); } constexpr const char *get_type_str() const { return (type == TIMEOUT) ? "timeout" : "interval"; } - const LogString *get_source() const { return component ? component->get_component_log_str() : LOG_STR("unknown"); } + // The owning component, or nullptr for SELF_POINTER items (whose slot holds source_name instead). + // All component access goes through this so SELF_POINTER items read as component-less. + Component *get_component() const { return name_type_ == NameType::SELF_POINTER ? nullptr : component; } + const LogString *get_source() const { + // Same no-source label as warn_blocking, for consistent log vocabulary. + if (name_type_ == NameType::SELF_POINTER) + return source_name != nullptr ? source_name : LOG_STR("a scheduled task"); + return component != nullptr ? component->get_component_log_str() : LOG_STR("unknown"); + } }; // Common implementation for both timeout and interval // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id + // `source` is stored (in the union slot) only for SELF_POINTER items; ignored otherwise. void set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type, const char *static_name, uint32_t hash_or_id, uint32_t delay, std::function &&func, bool is_retry = false, - bool skip_cancel = false); + bool skip_cancel = false, const LogString *source = nullptr); // Common implementation for retry - Remove before 2026.8.0 // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id @@ -402,8 +412,10 @@ class Scheduler { // Fixes: https://github.com/esphome/esphome/issues/11940 if (item == nullptr) return false; - if (item->component != component || item->type != type || (skip_removed && this->is_item_removed_locked_(item)) || - (match_retry && !item->is_retry)) { + // get_component() is nullptr for SELF_POINTER items (their cancels pass nullptr too), so they + // match by the `this` key alone. + if (item->get_component() != component || item->type != type || + (skip_removed && this->is_item_removed_locked_(item)) || (match_retry && !item->is_retry)) { return false; } // Name type must match @@ -423,11 +435,16 @@ class Scheduler { // Helper to execute a scheduler item uint32_t execute_item_(SchedulerItem *item, uint32_t now); - // Helper to check if item should be skipped - bool should_skip_item_(SchedulerItem *item) const { - return is_item_removed_(item) || (item->component != nullptr && item->component->is_failed()); + // True if the item's component is failed (so it must not run). SELF_POINTER delays have no + // component (get_component() == nullptr) and always fire. + bool is_item_failed_(SchedulerItem *item) const { + Component *component = item->get_component(); + return component != nullptr && component->is_failed(); } + // Helper to check if item should be skipped + bool should_skip_item_(SchedulerItem *item) const { return is_item_removed_(item) || this->is_item_failed_(item); } + // Helper to recycle a SchedulerItem back to the pool. // Takes a raw pointer — caller transfers ownership. The item is either added to the // pool or deleted if the pool is full. diff --git a/tests/integration/fixtures/scheduler_blocking_warning.yaml b/tests/integration/fixtures/scheduler_blocking_warning.yaml new file mode 100644 index 0000000000..594ec46afb --- /dev/null +++ b/tests/integration/fixtures/scheduler_blocking_warning.yaml @@ -0,0 +1,22 @@ +esphome: + name: scheduler-blocking-warning + on_boot: + then: + - script.execute: blocking_script + +host: +api: +logger: + level: DEBUG + +# The busy-block runs in the second delay's continuation; the warning must name the script. Two +# delays verify the source survives chained delays (the scheduler republishes it each continuation). +script: + - id: blocking_script + then: + - delay: 10ms + - delay: 10ms + - lambda: |- + const uint32_t start = millis(); + while (millis() - start < 80) { + } diff --git a/tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml b/tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml new file mode 100644 index 0000000000..2d8a62f25b --- /dev/null +++ b/tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml @@ -0,0 +1,30 @@ +esphome: + name: scheduler-blocking-generic + +host: +api: +logger: + level: DEBUG + +globals: + - id: done + type: bool + restore_value: false + initial_value: "false" + +# A delay in a plain (non-script) automation has no owning script, so the block must log the +# generic "a scheduled task" label, not a script name. +interval: + - interval: 100ms + id: gen_interval + then: + - if: + condition: + lambda: "return !id(done);" + then: + - lambda: "id(done) = true;" + - delay: 10ms + - lambda: |- + const uint32_t start = millis(); + while (millis() - start < 80) { + } diff --git a/tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml b/tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml new file mode 100644 index 0000000000..860fa00c37 --- /dev/null +++ b/tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml @@ -0,0 +1,29 @@ +esphome: + name: scheduler-delay-failed + +host: +api: +logger: + level: DEBUG + +globals: + - id: started + type: bool + restore_value: false + initial_value: "false" + +# The interval marks itself failed, then schedules a delay. The delay must still fire: a failed +# component must not drop it, since the SELF_POINTER scheduler item has no owning component. +interval: + - interval: 100ms + id: host_interval + then: + - if: + condition: + lambda: "return !id(started);" + then: + - lambda: |- + id(started) = true; + id(host_interval)->mark_failed(); + - delay: 200ms + - logger.log: "DELAY_FIRED_AFTER_FAIL" diff --git a/tests/integration/test_scheduler_blocking_warning.py b/tests/integration/test_scheduler_blocking_warning.py new file mode 100644 index 0000000000..699a5bc746 --- /dev/null +++ b/tests/integration/test_scheduler_blocking_warning.py @@ -0,0 +1,120 @@ +"""Integration tests for blocking-warning source attribution. + +A blocking operation that runs inside a deferred scheduler continuation (e.g. after a ``delay`` +in a script) used to be reported as `` took a long time for an operation (NN ms), +max is 30 ms`` because the continuation carries no component. The warning should instead name +the owning script and report the real threshold (50 ms). +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Matches: " took a long time for an operation (NN ms), max is NN ms" +WARN_PATTERN = re.compile( + r"(\S+) took a long time for an operation \((\d+) ms\), max is (\d+) ms" +) + + +@pytest.mark.asyncio +async def test_scheduler_blocking_warning( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Deferred blocking work inside a script is attributed to the script, not "".""" + loop = asyncio.get_running_loop() + warning_future: asyncio.Future[str] = loop.create_future() + + def check_output(line: str) -> None: + if WARN_PATTERN.search(line) and not warning_future.done(): + warning_future.set_result(line) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + + # on_boot runs the script, which defers via delay then busy-blocks > 50 ms in the + # continuation, tripping the blocking warning. + warning_line = await asyncio.wait_for(warning_future, timeout=10.0) + + # Must name the owning script, not "" and not the generic fallback. + assert "" not in warning_line, ( + f"Warning should name the script, got: {warning_line}" + ) + assert "a scheduled task" not in warning_line, ( + f"Warning should name the script, got: {warning_line}" + ) + match = WARN_PATTERN.search(warning_line) + assert match is not None + assert match.group(1) == "blocking_script", ( + f"Warning should name 'blocking_script', got: {warning_line}" + ) + # The reported threshold must be the real default (50 ms), not the stale "30 ms". + assert match.group(3) == "50", f"Expected 'max is 50 ms', got: {warning_line}" + + +@pytest.mark.asyncio +async def test_scheduler_blocking_warning_generic_source( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A delay in a plain (non-script) automation logs the generic label, not a script name.""" + loop = asyncio.get_running_loop() + warning_future: asyncio.Future[str] = loop.create_future() + + def check_output(line: str) -> None: + if WARN_PATTERN.search(line) and not warning_future.done(): + warning_future.set_result(line) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + assert await client.device_info() is not None + warning_line = await asyncio.wait_for(warning_future, timeout=10.0) + + assert "a scheduled task took a long time" in warning_line, ( + f"Non-script deferred work should log the generic label, got: {warning_line}" + ) + assert "" not in warning_line + match = WARN_PATTERN.search(warning_line) + assert match is not None and match.group(3) == "50", ( + f"Expected 'max is 50 ms', got: {warning_line}" + ) + + +@pytest.mark.asyncio +async def test_scheduler_delay_runs_on_failed_component( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A delay must still fire even when its context component is marked failed. + + Deferred (SELF_POINTER) scheduler items have no owning component, so the scheduler's + failed-component skip must not drop them. + """ + loop = asyncio.get_running_loop() + fired: asyncio.Future[bool] = loop.create_future() + + def check_output(line: str) -> None: + if "DELAY_FIRED_AFTER_FAIL" in line and not fired.done(): + fired.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + assert await client.device_info() is not None + # If the failed host component wrongly dropped the delay, this times out. + await asyncio.wait_for(fired, timeout=10.0) From 7a2657cea19b5ce831b52a2682f6bae5fb62bfd7 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 15 Jun 2026 16:48:07 -0400 Subject: [PATCH 0404/1815] [audio] Bump microMP3 to v0.2.3 (#16977) --- .clang-tidy.hash | 2 +- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 7497cc3679..7a3cfc7a03 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -a6ec18b82143e293ca6dee6947217f10a387ace99881a34b2c308ff627c8173c +34f6ce4a4775acf8c7201778f114b191f78269f232b67f01fed920f0cdf73686 diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 2ddce577ef..2aceff0c97 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -395,7 +395,7 @@ async def to_code(config): ) if data.mp3_support: cg.add_define("USE_AUDIO_MP3_SUPPORT") - add_idf_component(name="esphome/micro-mp3", ref="0.2.1") + add_idf_component(name="esphome/micro-mp3", ref="0.2.3") _emit_memory_pair( data.mp3.buffer_memory, "CONFIG_MP3_DECODER_PREFER_PSRAM", diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index c97e8906a8..04220488cc 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -12,7 +12,7 @@ dependencies: esphome/micro-flac: version: 0.2.0 esphome/micro-mp3: - version: 0.2.1 + version: 0.2.3 esphome/micro-opus: version: 0.4.1 esphome/micro-wav: From a7a407c22c255f0cb4e3bb5014415e4268a60327 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:05:50 -0400 Subject: [PATCH 0405/1815] [openthread] Fix InstanceLock releasing the lock twice on try_acquire (#16980) --- esphome/components/openthread/openthread.cpp | 2 +- esphome/components/openthread/openthread.h | 23 +++++++++++++++---- .../components/openthread/openthread_esp.cpp | 17 +++++++------- .../openthread_info_text_sensor.h | 2 +- 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index bf14514636..c8ffc02131 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -227,7 +227,7 @@ bool OpenThreadComponent::teardown() { ESP_LOGW(TAG, "Failed to acquire OpenThread lock during teardown, leaking memory"); return true; } - otInstance *instance = lock->get_instance(); + otInstance *instance = lock.get_instance(); otSrpClientClearHostAndServices(instance); otSrpClientBuffersFreeAllServices(instance); global_openthread_component = nullptr; diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index 5898492a50..96f1abdb92 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -86,19 +86,32 @@ class OpenThreadSrpComponent : public Component { void *pool_alloc_(size_t size); }; +// RAII guard for the OpenThread API lock. Modeled on std::unique_lock: the +// guard may or may not own the lock (try_acquire can fail), so check it with +// operator bool before use. Non-copyable and non-movable: the factories return +// by value via guaranteed copy elision, so a guard is never duplicated and the +// lock is released exactly once, when the owning guard goes out of scope. class InstanceLock { public: - static std::optional try_acquire(int delay); + // May fail to acquire within delay ms; check the returned guard with operator bool. + static InstanceLock try_acquire(int delay); + // Blocks until the lock is held. static InstanceLock acquire(); + InstanceLock(const InstanceLock &) = delete; + InstanceLock(InstanceLock &&) = delete; + InstanceLock &operator=(const InstanceLock &) = delete; + InstanceLock &operator=(InstanceLock &&) = delete; ~InstanceLock(); - // Returns the global openthread instance guarded by this lock + explicit operator bool() const { return this->owns_; } + + // Returns the global openthread instance. Only valid on an owning guard + // (operator bool is true); the instance must not be used without the lock held. otInstance *get_instance(); private: - // Use a private constructor in order to force the handling - // of acquisition failure - InstanceLock() {} + explicit InstanceLock(bool owns) : owns_(owns) {} + bool owns_; }; } // namespace esphome::openthread diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index cf1288d90c..4d88cbd226 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -216,14 +216,11 @@ network::IPAddresses OpenThreadComponent::get_ip_addresses() { // not thread safe, only use in read-only use cases otInstance *OpenThreadComponent::get_openthread_instance_() { return esp_openthread_get_instance(); } -std::optional InstanceLock::try_acquire(int delay) { +InstanceLock InstanceLock::try_acquire(int delay) { if (!global_openthread_component->is_lock_initialized()) { - return {}; + return InstanceLock(false); } - if (esp_openthread_lock_acquire(delay)) { - return InstanceLock(); - } - return {}; + return InstanceLock(esp_openthread_lock_acquire(delay)); } InstanceLock InstanceLock::acquire() { @@ -242,12 +239,16 @@ InstanceLock InstanceLock::acquire() { while (!esp_openthread_lock_acquire(100)) { esp_task_wdt_reset(); } - return InstanceLock(); + return InstanceLock(true); } otInstance *InstanceLock::get_instance() { return esp_openthread_get_instance(); } -InstanceLock::~InstanceLock() { esp_openthread_lock_release(); } +InstanceLock::~InstanceLock() { + if (this->owns_) { + esp_openthread_lock_release(); + } +} } // namespace esphome::openthread #endif diff --git a/esphome/components/openthread_info/openthread_info_text_sensor.h b/esphome/components/openthread_info/openthread_info_text_sensor.h index 10e83281f0..ef7c5cc8e9 100644 --- a/esphome/components/openthread_info/openthread_info_text_sensor.h +++ b/esphome/components/openthread_info/openthread_info_text_sensor.h @@ -17,7 +17,7 @@ class OpenThreadInstancePollingComponent : public PollingComponent { return; } - this->update_instance(lock->get_instance()); + this->update_instance(lock.get_instance()); } float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } From 73f839437ea4450b786ca6d767def371f7bdc015 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:12:53 +1200 Subject: [PATCH 0406/1815] [docker] Remove alpine base, build only on debian (#16991) --- .github/actions/build-image/action.yaml | 7 ------- docker/Dockerfile | 15 +++++---------- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/.github/actions/build-image/action.yaml b/.github/actions/build-image/action.yaml index 2081264b91..494c0cebe8 100644 --- a/.github/actions/build-image/action.yaml +++ b/.github/actions/build-image/action.yaml @@ -15,11 +15,6 @@ inputs: description: "Version to build" required: true example: "2023.12.0" - base_os: - description: "Base OS to use" - required: false - default: "debian" - example: "debian" runs: using: "composite" steps: @@ -60,7 +55,6 @@ runs: build-args: | BUILD_TYPE=${{ inputs.build_type }} BUILD_VERSION=${{ inputs.version }} - BUILD_OS=${{ inputs.base_os }} outputs: | type=image,name=ghcr.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true @@ -86,7 +80,6 @@ runs: build-args: | BUILD_TYPE=${{ inputs.build_type }} BUILD_VERSION=${{ inputs.version }} - BUILD_OS=${{ inputs.base_os }} outputs: | type=image,name=docker.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true diff --git a/docker/Dockerfile b/docker/Dockerfile index 25de9472b6..c360ae1a4a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,10 +1,9 @@ ARG BUILD_VERSION=dev -ARG BUILD_OS=alpine ARG BUILD_BASE_VERSION=2025.04.0 ARG BUILD_TYPE=docker -FROM ghcr.io/esphome/docker-base:${BUILD_OS}-${BUILD_BASE_VERSION} AS base-source-docker -FROM ghcr.io/esphome/docker-base:${BUILD_OS}-ha-addon-${BUILD_BASE_VERSION} AS base-source-ha-addon +FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base-source-docker +FROM ghcr.io/esphome/docker-base:debian-ha-addon-${BUILD_BASE_VERSION} AS base-source-ha-addon ARG BUILD_TYPE FROM base-source-${BUILD_TYPE} AS base @@ -18,13 +17,9 @@ RUN git config --system --add safe.directory "*" \ # validate openocd-esp32 (it dynamically links libusb-1.0.so.0); without # it idf_tools.py rejects the openocd install with exit 127 and aborts # the whole framework setup. -RUN if command -v apk > /dev/null; then \ - apk add --no-cache build-base libusb; \ - else \ - apt-get update \ - && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \ - && rm -rf /var/lib/apt/lists/*; \ - fi +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \ + && rm -rf /var/lib/apt/lists/* ENV PIP_DISABLE_PIP_VERSION_CHECK=1 From bb6cd97948206d38469eba878e53a806292afaa0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:15:12 -0400 Subject: [PATCH 0407/1815] Bump clang-tidy from 22.1.0.1 to 22.1.7 (#16984) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .clang-tidy.hash | 2 +- requirements_dev.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 7a3cfc7a03..1f709bb90d 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -34f6ce4a4775acf8c7201778f114b191f78269f232b67f01fed920f0cdf73686 +007cddcd7aa933f0ff9b3fd65f0b7571579ac223d11c6117af2b291bd2f9fe74 diff --git a/requirements_dev.txt b/requirements_dev.txt index 31463e07c3..7e66c7244d 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,4 +1,4 @@ # Useful stuff when working in a development environment clang-format==13.0.1 # also change in .pre-commit-config.yaml and Dockerfile when updating -clang-tidy==22.1.0.1 +clang-tidy==22.1.7 yamllint==1.38.0 # also change in .pre-commit-config.yaml when updating From b09a5f9e43efd49abed4d7a2845758d2f37fd257 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:37:31 +1200 Subject: [PATCH 0408/1815] [ci] Push branch-tagged docker images to ghcr.io for local testing (#16992) --- .github/workflows/ci-docker.yml | 84 ++++++++++++++- docker/build.py | 55 +++++++--- tests/script/test_docker_build.py | 169 ++++++++++++++++++++++++++++++ 3 files changed, 290 insertions(+), 18 deletions(-) create mode 100644 tests/script/test_docker_build.py diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 2a40675f3b..7d4b850356 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -22,7 +22,7 @@ on: - "script/platformio_install_deps.py" permissions: - contents: read # actions/checkout only; the build does not push images + contents: read # actions/checkout only concurrency: # yamllint disable-line rule:line-length @@ -33,6 +33,9 @@ jobs: check-docker: name: Build docker containers runs-on: ${{ matrix.os }} + permissions: + contents: read # actions/checkout to load Dockerfile and build context + packages: write # push branch-tagged images to ghcr.io for local testing strategy: fail-fast: false matrix: @@ -41,6 +44,9 @@ jobs: - "ha-addon" - "docker" # - "lint" + outputs: + tag: ${{ steps.tag.outputs.tag }} + push: ${{ steps.tag.outputs.push }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python @@ -50,14 +56,82 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - name: Set TAG + - name: Determine tag and whether to push + id: tag run: | - echo "TAG=check" >> $GITHUB_ENV + # Sanitize the branch name into a valid docker tag: replace invalid + # characters, ensure the first character is valid (tags must start + # with [A-Za-z0-9_]), and cap the length at 128 characters. + branch="${{ github.head_ref || github.ref_name }}" + tag="${branch//[^a-zA-Z0-9_.-]/-}" + case "$tag" in + [a-zA-Z0-9_]*) ;; + *) tag="pr-${tag}" ;; + esac + tag="${tag:0:128}" + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + # Only push branch images for same-repo pull requests. Push events + # only fire for dev/beta/release, whose images are owned by the + # release pipeline -- never overwrite those from here. + if [ "${{ github.event_name }}" = "pull_request" ] \ + && [ "${{ github.repository }}" = "esphome/esphome" ] \ + && [ "${{ github.event.pull_request.head.repo.full_name }}" = "esphome/esphome" ]; then + echo "push=true" >> "$GITHUB_OUTPUT" + else + echo "push=false" >> "$GITHUB_OUTPUT" + fi + + - name: Log in to the GitHub container registry + if: steps.tag.outputs.push == 'true' + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Run build run: | docker/build.py \ - --tag "${TAG}" \ + --tag "${{ steps.tag.outputs.tag }}" \ --arch "${{ matrix.os == 'ubuntu-24.04-arm' && 'aarch64' || 'amd64' }}" \ --build-type "${{ matrix.build_type }}" \ - build + --registry ghcr \ + build ${{ steps.tag.outputs.push == 'true' && '--push --no-cache-to' || '' }} + + manifest: + name: Push ${{ matrix.build_type }} manifest to ghcr.io + needs: [check-docker] + if: needs.check-docker.outputs.push == 'true' + runs-on: ubuntu-24.04 + permissions: + contents: read # actions/checkout to run docker/build.py + packages: write # buildx imagetools writes the multi-arch tag to ghcr.io + strategy: + fail-fast: false + matrix: + build_type: + - "ha-addon" + - "docker" + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.11" + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Log in to the GitHub container registry + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create and push manifest + run: | + docker/build.py \ + --tag "${{ needs.check-docker.outputs.tag }}" \ + --build-type "${{ matrix.build_type }}" \ + --registry ghcr \ + manifest diff --git a/docker/build.py b/docker/build.py index 4d093cf88d..475986e905 100755 --- a/docker/build.py +++ b/docker/build.py @@ -20,6 +20,10 @@ TYPE_HA_ADDON = "ha-addon" TYPE_LINT = "lint" TYPES = [TYPE_DOCKER, TYPE_HA_ADDON, TYPE_LINT] +REGISTRY_GHCR = "ghcr" +REGISTRY_DOCKERHUB = "dockerhub" +REGISTRIES = [REGISTRY_GHCR, REGISTRY_DOCKERHUB] + parser = argparse.ArgumentParser() parser.add_argument( @@ -34,6 +38,12 @@ parser.add_argument( parser.add_argument( "--build-type", choices=TYPES, required=True, help="The type of build to run" ) +parser.add_argument( + "--registry", + choices=REGISTRIES, + action="append", + help="Restrict to specific registries (default: all). May be passed multiple times.", +) parser.add_argument( "--dry-run", action="store_true", help="Don't run any commands, just print them" ) @@ -45,6 +55,11 @@ build_parser.add_argument("--push", help="Also push the images", action="store_t build_parser.add_argument( "--load", help="Load the docker image locally", action="store_true" ) +build_parser.add_argument( + "--no-cache-to", + help="Don't write the build cache (avoids polluting the shared cache)", + action="store_true", +) manifest_parser = subparsers.add_parser( "manifest", help="Create a manifest from already pushed images" ) @@ -95,11 +110,14 @@ def main(): print("Command failed") sys.exit(1) + registries = args.registry or REGISTRIES + # detect channel from tag match = re.match(r"^(\d+\.\d+)(?:\.\d+)?(b\d+)?$", args.tag) major_minor_version = None if match is None: - channel = CHANNEL_DEV + # Custom tag (e.g. a branch name) -- push only the tag itself + channel = None elif match.group(2) is None: major_minor_version = match.group(1) channel = CHANNEL_RELEASE @@ -128,11 +146,18 @@ def main(): CHANNEL_DEV: "cache-dev", CHANNEL_BETA: "cache-beta", CHANNEL_RELEASE: "cache-latest", - }[channel] - cache_img = f"ghcr.io/{params.build_to}:{cache_tag}" + }.get(channel, "cache-dev") + # Cache images live alongside the pushed images; prefer GHCR when it is + # one of the selected registries, otherwise fall back to Docker Hub so a + # registry-restricted build doesn't need GHCR auth. + cache_prefix = "ghcr.io/" if REGISTRY_GHCR in registries else "" + cache_img = f"{cache_prefix}{params.build_to}:{cache_tag}" - imgs = [f"{params.build_to}:{tag}" for tag in tags_to_push] - imgs += [f"ghcr.io/{params.build_to}:{tag}" for tag in tags_to_push] + imgs = [] + if REGISTRY_DOCKERHUB in registries: + imgs += [f"{params.build_to}:{tag}" for tag in tags_to_push] + if REGISTRY_GHCR in registries: + imgs += [f"ghcr.io/{params.build_to}:{tag}" for tag in tags_to_push] # 3. build cmd = [ @@ -155,7 +180,9 @@ def main(): for img in imgs: cmd += ["--tag", img] if args.push: - cmd += ["--push", "--cache-to", f"type=registry,ref={cache_img},mode=max"] + cmd += ["--push"] + if not args.no_cache_to: + cmd += ["--cache-to", f"type=registry,ref={cache_img},mode=max"] if args.load: cmd += ["--load"] @@ -163,20 +190,22 @@ def main(): elif args.command == "manifest": manifest = DockerParams.for_type_arch(args.build_type, ARCH_AMD64).manifest_to - targets = [f"{manifest}:{tag}" for tag in tags_to_push] - targets += [f"ghcr.io/{manifest}:{tag}" for tag in tags_to_push] - # 1. Create manifests + targets = [] + if REGISTRY_DOCKERHUB in registries: + targets += [f"{manifest}:{tag}" for tag in tags_to_push] + if REGISTRY_GHCR in registries: + targets += [f"ghcr.io/{manifest}:{tag}" for tag in tags_to_push] + # Use buildx imagetools (not `docker manifest`) so the per-arch sources, + # which buildx pushes as single-platform manifest lists, are combined + # and pushed correctly in one step. for target in targets: - cmd = ["docker", "manifest", "create", target] + cmd = ["docker", "buildx", "imagetools", "create", "--tag", target] for arch in ARCHS: src = f"{DockerParams.for_type_arch(args.build_type, arch).build_to}:{args.tag}" if target.startswith("ghcr.io"): src = f"ghcr.io/{src}" cmd.append(src) run_command(*cmd) - # 2. Push manifests - for target in targets: - run_command("docker", "manifest", "push", target) if __name__ == "__main__": diff --git a/tests/script/test_docker_build.py b/tests/script/test_docker_build.py new file mode 100644 index 0000000000..34bcc4e714 --- /dev/null +++ b/tests/script/test_docker_build.py @@ -0,0 +1,169 @@ +"""Unit tests for docker/build.py command generation.""" + +import importlib.util +from pathlib import Path +import sys + +import pytest + +_BUILD_PY = Path(__file__).parents[2] / "docker" / "build.py" +_spec = importlib.util.spec_from_file_location("docker_build", _BUILD_PY) +docker_build = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(docker_build) + + +def _run(capsys: pytest.CaptureFixture[str], *argv: str) -> list[str]: + """Run build.py main() in dry-run mode and return the emitted commands.""" + full_argv = ["build.py", "--dry-run", *argv] + with pytest.MonkeyPatch.context() as mp: + mp.setattr(sys, "argv", full_argv) + docker_build.main() + out = capsys.readouterr().out + return [line[2:] for line in out.splitlines() if line.startswith("$ ")] + + +def test_branch_build_pushes_single_ghcr_tag_without_cache_to( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--arch", + "amd64", + "--build-type", + "docker", + "--registry", + "ghcr", + "build", + "--push", + "--no-cache-to", + ) + + assert len(commands) == 1 + cmd = commands[0] + # Custom tag -> only the tag itself, no companion "dev"/"latest" tags + assert "--tag ghcr.io/esphome/esphome-amd64:my-branch" in cmd + assert ":dev" not in cmd + # ghcr only -> no Docker Hub image name + assert "--tag esphome/esphome-amd64:my-branch" not in cmd + # custom tag falls back to the dev cache for reads + assert ( + "--cache-from type=registry,ref=ghcr.io/esphome/esphome-amd64:cache-dev" in cmd + ) + assert "--push" in cmd + # --no-cache-to must suppress the cache write + assert "--cache-to" not in cmd + + +def test_branch_manifest_targets_ghcr_only( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--build-type", + "ha-addon", + "--registry", + "ghcr", + "manifest", + ) + + assert commands == [ + "docker buildx imagetools create " + "--tag ghcr.io/esphome/esphome-hassio:my-branch " + "ghcr.io/esphome/esphome-hassio-amd64:my-branch " + "ghcr.io/esphome/esphome-hassio-aarch64:my-branch" + ] + + +def test_release_build_keeps_both_registries_and_cache_to( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "2025.6.0", + "--arch", + "amd64", + "--build-type", + "docker", + "build", + "--push", + ) + + cmd = commands[0] + # Default (no --registry) keeps both Docker Hub and ghcr image names + assert "--tag esphome/esphome-amd64:2025.6.0" in cmd + assert "--tag ghcr.io/esphome/esphome-amd64:2025.6.0" in cmd + # Release channel still gets its companion tags + assert "--tag esphome/esphome-amd64:latest" in cmd + # Without --no-cache-to the cache write is preserved + assert ( + "--cache-to type=registry,ref=ghcr.io/esphome/esphome-amd64:cache-latest,mode=max" + in cmd + ) + + +def test_build_no_push_omits_push_and_cache( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--arch", + "amd64", + "--build-type", + "docker", + "--registry", + "ghcr", + "build", + ) + + cmd = commands[0] + assert "--tag ghcr.io/esphome/esphome-amd64:my-branch" in cmd + assert "--push" not in cmd + assert "--cache-to" not in cmd + + +def test_build_dockerhub_only(capsys: pytest.CaptureFixture[str]) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--arch", + "amd64", + "--build-type", + "docker", + "--registry", + "dockerhub", + "build", + "--push", + ) + + cmd = commands[0] + assert "--tag esphome/esphome-amd64:my-branch" in cmd + assert "ghcr.io" not in cmd + # Cache reference falls back to Docker Hub when GHCR isn't selected + assert "--cache-from type=registry,ref=esphome/esphome-amd64:cache-dev" in cmd + + +def test_manifest_dockerhub_only(capsys: pytest.CaptureFixture[str]) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--build-type", + "docker", + "--registry", + "dockerhub", + "manifest", + ) + + create = commands[0] + assert create.startswith( + "docker buildx imagetools create --tag esphome/esphome:my-branch " + ) + assert "ghcr.io" not in create From d8fa0e414093cc8625ec6f4ce538bd4352b8d56f Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:24:26 +1200 Subject: [PATCH 0409/1815] [core] Stop parent git repos from breaking ESP-IDF/PlatformIO builds (#16994) --- esphome/espidf/toolchain.py | 6 +++++ esphome/helpers.py | 21 +++++++++++++++ esphome/platformio/toolchain.py | 5 ++++ tests/unit_tests/test_espidf_toolchain.py | 14 ++++++++++ tests/unit_tests/test_helpers.py | 27 +++++++++++++++++++ tests/unit_tests/test_platformio_toolchain.py | 5 ++++ 6 files changed, 78 insertions(+) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 2fef3faf8d..c622a2dd36 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -14,6 +14,7 @@ from esphome.const import CONF_FRAMEWORK, CONF_SOURCE from esphome.core import CORE, EsphomeError from esphome.espidf.framework import check_esp_idf_install, get_framework_env from esphome.espidf.size_summary import print_summary +from esphome.helpers import add_git_ceiling_directory _LOGGER = logging.getLogger(__name__) @@ -82,6 +83,11 @@ def _get_idf_env(version: str | None = None) -> dict[str, str]: env_cache[version] |= get_framework_env( *_get_esphome_esp_idf_paths(version) ) + + # Cap git's repo search at the config directory so ESP-IDF's + # `git describe` for the app version can't error out on an + # uninitialized or corrupt git repo in a parent directory. + add_git_ceiling_directory(env_cache[version], CORE.config_dir) return env_cache[version] diff --git a/esphome/helpers.py b/esphome/helpers.py index 733474c9c9..ef7e2d0b93 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import MutableMapping from contextlib import suppress import ipaddress import logging @@ -374,6 +375,26 @@ def is_ha_addon(): return get_bool_env("ESPHOME_IS_HA_ADDON") +def add_git_ceiling_directory(env: MutableMapping[str, str], directory: Path) -> None: + """Add ``directory`` to ``env``'s ``GIT_CEILING_DIRECTORIES`` list. + + Git stops walking up the directory tree to find a repository once it reaches + a ceiling directory, so this caps the search at ``directory`` (the ESPHome + project root). Without it, an uninitialized or corrupt git repo in a parent + directory makes the ``git describe`` that build toolchains run for the app + version error out and fail the whole build. + + ``GIT_CEILING_DIRECTORIES`` is an ``os.pathsep``-joined list of absolute + paths; any existing entries are preserved and duplicates are skipped. + """ + ceiling = str(directory) + existing = env.get("GIT_CEILING_DIRECTORIES", "") + parts = existing.split(os.pathsep) if existing else [] + if ceiling not in parts: + parts.append(ceiling) + env["GIT_CEILING_DIRECTORIES"] = os.pathsep.join(parts) + + def rmtree(path: Path | str) -> None: """Remove a directory tree, handling read-only files on Windows. diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index c81420e6ca..c97df812e3 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -7,6 +7,7 @@ import sys from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE from esphome.core import CORE, EsphomeError +from esphome.helpers import add_git_ceiling_directory from esphome.util import FlashImage, run_external_process _LOGGER = logging.getLogger(__name__) @@ -53,6 +54,10 @@ def run_platformio_cli(*args, **kwargs) -> str | int: os.environ.setdefault("PYTHONWARNINGS", "ignore::SyntaxWarning") # Increase uv retry count to handle transient network errors (default is 3) os.environ.setdefault("UV_HTTP_RETRIES", "10") + # Cap git's repo search at the config directory so the framework's build + # scripts running `git describe` for the app version can't error out on an + # uninitialized or corrupt git repo in a parent directory. + add_git_ceiling_directory(os.environ, CORE.config_dir) # Strip the Windows extended-length path prefix from sys.executable so it # doesn't propagate into PlatformIO's $PYTHONEXE and break SCons-emitted # command lines run through cmd.exe. diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 8849ea8bc8..b2309439f9 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -150,6 +150,20 @@ def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None: assert result == {"cxx_path": "regen"} +def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None: + """The IDF env caps git's upward search at the config directory. + + This stops ESP-IDF's `git describe` from walking into an uninitialized or + corrupt git repo in a parent directory and failing the build. + """ + toolchain._cache().env.clear() + # Set IDF_PATH so the framework-install branch is skipped. + with patch.dict(os.environ, {"IDF_PATH": str(setup_core)}): + env = toolchain._get_idf_env(version="5.5.4") + assert CORE.config_dir == setup_core + assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep) + + def test_get_core_framework_version_from_core_data(): """The version is read from CORE.data when validation populated it.""" from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index efc2d8e42a..70c4b90082 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -196,6 +196,33 @@ def test_is_ha_addon(monkeypatch, value, expected): assert actual == expected +def test_add_git_ceiling_directory_sets_when_unset(): + """An empty env gets GIT_CEILING_DIRECTORIES set to the directory.""" + env: dict[str, str] = {} + directory = Path("/home/user/config") + helpers.add_git_ceiling_directory(env, directory) + assert env["GIT_CEILING_DIRECTORIES"] == str(directory) + + +def test_add_git_ceiling_directory_appends_to_existing(): + """An existing value is preserved and the new directory is appended.""" + env = {"GIT_CEILING_DIRECTORIES": str(Path("/some/ceiling"))} + directory = Path("/home/user/config") + helpers.add_git_ceiling_directory(env, directory) + assert env["GIT_CEILING_DIRECTORIES"].split(os.pathsep) == [ + str(Path("/some/ceiling")), + str(directory), + ] + + +def test_add_git_ceiling_directory_skips_duplicate(): + """A directory already in the list is not appended again.""" + directory = Path("/home/user/config") + env = {"GIT_CEILING_DIRECTORIES": str(directory)} + helpers.add_git_ceiling_directory(env, directory) + assert env["GIT_CEILING_DIRECTORIES"] == str(directory) + + def test_walk_files(fixture_path): path = fixture_path / "helpers" diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index a37b19f584..568b43a259 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -304,6 +304,11 @@ def test_run_platformio_cli_sets_environment_variables( ) assert "PLATFORMIO_LIBDEPS_DIR" in os.environ assert "PYTHONWARNINGS" in os.environ + # Caps git's upward search at the config dir so an uninitialized or + # corrupt parent git repo can't break the framework's `git describe`. + assert str(CORE.config_dir) in os.environ["GIT_CEILING_DIRECTORIES"].split( + os.pathsep + ) # Check command was called correctly — runs PlatformIO as a subprocess # via the esphome.platformio.runner entry point. From 930cf2b5b94dcf8143aa4a5afd236b72ee1cc668 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:47:14 +1200 Subject: [PATCH 0410/1815] [docker] Bundle device-builder 1.0.1, make HA add-on builder-only (#16989) Co-authored-by: J. Nick Koston --- docker/Dockerfile | 5 +- docker/docker_entrypoint.sh | 8 ++ .../etc/cont-init.d/40-device-builder.sh | 22 ----- .../etc/nginx/includes/mime.types | 96 ------------------- .../etc/nginx/includes/proxy_params.conf | 16 ---- .../etc/nginx/includes/server_params.conf | 8 -- .../etc/nginx/includes/ssl_params.conf | 8 -- .../etc/nginx/includes/upstream.conf | 3 - docker/ha-addon-rootfs/etc/nginx/nginx.conf | 30 ------ .../etc/nginx/servers/.gitkeep | 1 - .../etc/nginx/templates/direct.gtpl | 28 ------ .../etc/nginx/templates/ingress.gtpl | 18 ---- .../s6-rc.d/discovery/dependencies.d/nginx | 0 .../etc/s6-overlay/s6-rc.d/discovery/run | 2 +- .../etc/s6-overlay/s6-rc.d/esphome/finish | 4 +- .../etc/s6-overlay/s6-rc.d/esphome/run | 15 +-- .../s6-rc.d/init-nginx/dependencies.d/base | 0 .../etc/s6-overlay/s6-rc.d/init-nginx/run | 35 ------- .../etc/s6-overlay/s6-rc.d/init-nginx/type | 1 - .../etc/s6-overlay/s6-rc.d/init-nginx/up | 1 - .../s6-rc.d/nginx/dependencies.d/esphome | 0 .../s6-rc.d/nginx/dependencies.d/init-nginx | 0 .../etc/s6-overlay/s6-rc.d/nginx/finish | 25 ----- .../etc/s6-overlay/s6-rc.d/nginx/run | 27 ------ .../etc/s6-overlay/s6-rc.d/nginx/type | 1 - .../s6-rc.d/user/contents.d/init-nginx | 0 .../s6-overlay/s6-rc.d/user/contents.d/nginx | 0 27 files changed, 20 insertions(+), 334 deletions(-) delete mode 100755 docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/mime.types delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/nginx.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep delete mode 100644 docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl delete mode 100644 docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/dependencies.d/nginx delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/dependencies.d/base delete mode 100755 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/run delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/type delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/up delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/dependencies.d/esphome delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/dependencies.d/init-nginx delete mode 100755 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/finish delete mode 100755 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-nginx delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/nginx diff --git a/docker/Dockerfile b/docker/Dockerfile index c360ae1a4a..c7634cf1c8 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ ARG BUILD_VERSION=dev -ARG BUILD_BASE_VERSION=2025.04.0 +ARG BUILD_BASE_VERSION=2026.06.0 ARG BUILD_TYPE=docker FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base-source-docker @@ -31,6 +31,9 @@ RUN \ uv pip install --no-cache-dir \ -r /requirements.txt +# Install the ESPHome Device Builder dashboard. +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.1 + RUN \ platformio settings set enable_telemetry No \ && platformio settings set check_platformio_interval 1000000 \ diff --git a/docker/docker_entrypoint.sh b/docker/docker_entrypoint.sh index 1b9224244c..18baf40c29 100755 --- a/docker/docker_entrypoint.sh +++ b/docker/docker_entrypoint.sh @@ -27,4 +27,12 @@ if [[ -d /build ]]; then export ESPHOME_BUILD_PATH=/build fi +# The default CMD is "dashboard /config". Route the dashboard to the new +# Device Builder, but pass every other subcommand (compile, run, config, +# logs, ...) straight through to the esphome CLI so direct CLI use keeps working. +if [[ "$1" == "dashboard" ]]; then + shift + exec esphome-device-builder "$@" +fi + exec esphome "$@" diff --git a/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh b/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh deleted file mode 100755 index b990469762..0000000000 --- a/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/with-contenv bashio -# ============================================================================== -# Installs the latest prerelease of esphome-device-builder when the -# `use_new_device_builder` config option is enabled. -# This is a temporary install-on-boot step until esphome-device-builder -# becomes a direct dependency of esphome. -# ============================================================================== - -if ! bashio::config.true 'use_new_device_builder'; then - exit 0 -fi - -bashio::log.info "Installing latest prerelease of esphome-device-builder..." -if command -v uv > /dev/null; then - uv pip install --system --no-cache-dir --prerelease=allow --upgrade \ - esphome-device-builder || - bashio::exit.nok "Failed installing esphome-device-builder." -else - pip install --no-cache-dir --pre --upgrade esphome-device-builder || - bashio::exit.nok "Failed installing esphome-device-builder." -fi -bashio::log.info "Installed esphome-device-builder." diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/mime.types b/docker/ha-addon-rootfs/etc/nginx/includes/mime.types deleted file mode 100644 index 7c7cdef2d1..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/mime.types +++ /dev/null @@ -1,96 +0,0 @@ -types { - text/html html htm shtml; - text/css css; - text/xml xml; - image/gif gif; - image/jpeg jpeg jpg; - application/javascript js; - application/atom+xml atom; - application/rss+xml rss; - - text/mathml mml; - text/plain txt; - text/vnd.sun.j2me.app-descriptor jad; - text/vnd.wap.wml wml; - text/x-component htc; - - image/png png; - image/svg+xml svg svgz; - image/tiff tif tiff; - image/vnd.wap.wbmp wbmp; - image/webp webp; - image/x-icon ico; - image/x-jng jng; - image/x-ms-bmp bmp; - - font/woff woff; - font/woff2 woff2; - - application/java-archive jar war ear; - application/json json; - application/mac-binhex40 hqx; - application/msword doc; - application/pdf pdf; - application/postscript ps eps ai; - application/rtf rtf; - application/vnd.apple.mpegurl m3u8; - application/vnd.google-earth.kml+xml kml; - application/vnd.google-earth.kmz kmz; - application/vnd.ms-excel xls; - application/vnd.ms-fontobject eot; - application/vnd.ms-powerpoint ppt; - application/vnd.oasis.opendocument.graphics odg; - application/vnd.oasis.opendocument.presentation odp; - application/vnd.oasis.opendocument.spreadsheet ods; - application/vnd.oasis.opendocument.text odt; - application/vnd.openxmlformats-officedocument.presentationml.presentation - pptx; - application/vnd.openxmlformats-officedocument.spreadsheetml.sheet - xlsx; - application/vnd.openxmlformats-officedocument.wordprocessingml.document - docx; - application/vnd.wap.wmlc wmlc; - application/x-7z-compressed 7z; - application/x-cocoa cco; - application/x-java-archive-diff jardiff; - application/x-java-jnlp-file jnlp; - application/x-makeself run; - application/x-perl pl pm; - application/x-pilot prc pdb; - application/x-rar-compressed rar; - application/x-redhat-package-manager rpm; - application/x-sea sea; - application/x-shockwave-flash swf; - application/x-stuffit sit; - application/x-tcl tcl tk; - application/x-x509-ca-cert der pem crt; - application/x-xpinstall xpi; - application/xhtml+xml xhtml; - application/xspf+xml xspf; - application/zip zip; - - application/octet-stream bin exe dll; - application/octet-stream deb; - application/octet-stream dmg; - application/octet-stream iso img; - application/octet-stream msi msp msm; - - audio/midi mid midi kar; - audio/mpeg mp3; - audio/ogg ogg; - audio/x-m4a m4a; - audio/x-realaudio ra; - - video/3gpp 3gpp 3gp; - video/mp2t ts; - video/mp4 mp4; - video/mpeg mpeg mpg; - video/quicktime mov; - video/webm webm; - video/x-flv flv; - video/x-m4v m4v; - video/x-mng mng; - video/x-ms-asf asx asf; - video/x-ms-wmv wmv; - video/x-msvideo avi; -} diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf deleted file mode 100644 index a1ebb5079a..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf +++ /dev/null @@ -1,16 +0,0 @@ -proxy_http_version 1.1; -proxy_ignore_client_abort off; -proxy_read_timeout 86400s; -proxy_redirect off; -proxy_send_timeout 86400s; -proxy_max_temp_file_size 0; - -proxy_set_header Accept-Encoding ""; -proxy_set_header Connection $connection_upgrade; -proxy_set_header Host $http_host; -proxy_set_header Upgrade $http_upgrade; -proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; -proxy_set_header X-Forwarded-Proto $scheme; -proxy_set_header X-NginX-Proxy true; -proxy_set_header X-Real-IP $remote_addr; -proxy_set_header Authorization ""; diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf deleted file mode 100644 index debdf83a8c..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf +++ /dev/null @@ -1,8 +0,0 @@ -root /dev/null; -server_name $hostname; - -client_max_body_size 512m; - -add_header X-Content-Type-Options nosniff; -add_header X-XSS-Protection "1; mode=block"; -add_header X-Robots-Tag none; diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf deleted file mode 100644 index e6789cbb9b..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf +++ /dev/null @@ -1,8 +0,0 @@ -ssl_protocols TLSv1.2 TLSv1.3; -ssl_prefer_server_ciphers off; -ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; -ssl_session_timeout 10m; -ssl_session_cache shared:SSL:10m; -ssl_session_tickets off; -ssl_stapling on; -ssl_stapling_verify on; diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf b/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf deleted file mode 100644 index 8e782bdc88..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf +++ /dev/null @@ -1,3 +0,0 @@ -upstream esphome { - server unix:/var/run/esphome.sock; -} diff --git a/docker/ha-addon-rootfs/etc/nginx/nginx.conf b/docker/ha-addon-rootfs/etc/nginx/nginx.conf deleted file mode 100644 index 497427596d..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/nginx.conf +++ /dev/null @@ -1,30 +0,0 @@ -daemon off; -user root; -pid /var/run/nginx.pid; -worker_processes 1; -error_log /proc/1/fd/1 error; -events { - worker_connections 1024; -} - -http { - include /etc/nginx/includes/mime.types; - - access_log off; - default_type application/octet-stream; - gzip on; - keepalive_timeout 65; - sendfile on; - server_tokens off; - - tcp_nodelay on; - tcp_nopush on; - - map $http_upgrade $connection_upgrade { - default upgrade; - '' close; - } - - include /etc/nginx/includes/upstream.conf; - include /etc/nginx/servers/*.conf; -} diff --git a/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep b/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep deleted file mode 100644 index 85ad51be5f..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -Without requirements or design, programming is the art of adding bugs to an empty text file. (Louis Srygley) diff --git a/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl b/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl deleted file mode 100644 index 4fb0ca3f90..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl +++ /dev/null @@ -1,28 +0,0 @@ -server { - {{ if not .ssl }} - listen 6052 default_server; - {{ else }} - listen 6052 default_server ssl http2; - {{ end }} - - include /etc/nginx/includes/server_params.conf; - include /etc/nginx/includes/proxy_params.conf; - - {{ if .ssl }} - include /etc/nginx/includes/ssl_params.conf; - - ssl_certificate /ssl/{{ .certfile }}; - ssl_certificate_key /ssl/{{ .keyfile }}; - - # Redirect http requests to https on the same port. - # https://rageagainstshell.com/2016/11/redirect-http-to-https-on-the-same-port-in-nginx/ - error_page 497 https://$http_host$request_uri; - {{ end }} - - # Clear Home Assistant Ingress header - proxy_set_header X-HA-Ingress ""; - - location / { - proxy_pass http://esphome; - } -} diff --git a/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl b/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl deleted file mode 100644 index 105ddde710..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl +++ /dev/null @@ -1,18 +0,0 @@ -server { - listen 127.0.0.1:{{ .port }} default_server; - listen {{ .interface }}:{{ .port }} default_server; - - include /etc/nginx/includes/server_params.conf; - include /etc/nginx/includes/proxy_params.conf; - - # Set Home Assistant Ingress header - proxy_set_header X-HA-Ingress "YES"; - - location / { - allow 172.30.32.2; - allow 127.0.0.1; - deny all; - - proxy_pass http://esphome; - } -} diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/dependencies.d/nginx b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/dependencies.d/nginx deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run index 111157d301..bb36cfcdb4 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run @@ -16,7 +16,7 @@ fi port=$(bashio::addon.ingress_port) -# Wait for NGINX to become available +# Wait for the ESPHome Device Builder to become available bashio::net.wait_for "${port}" "127.0.0.1" 300 config=$(\ diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish index 6e0f8fe23a..da450c25f9 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish @@ -2,7 +2,7 @@ # shellcheck shell=bash # ============================================================================== # Home Assistant Community Add-on: ESPHome -# Take down the S6 supervision tree when ESPHome dashboard fails +# Take down the S6 supervision tree when ESPHome Device Builder fails # ============================================================================== declare exit_code readonly exit_code_container=$( /run/s6-linux-init-container-results/exitcode - fi - [[ "${exit_code_signal}" -eq 15 ]] && exec /run/s6/basedir/bin/halt -elif [[ "${exit_code_service}" -ne 0 ]]; then - if [[ "${exit_code_container}" -eq 0 ]]; then - echo "${exit_code_service}" > /run/s6-linux-init-container-results/exitcode - fi - exec /run/s6/basedir/bin/halt -fi diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run deleted file mode 100755 index b8251e8e01..0000000000 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run +++ /dev/null @@ -1,27 +0,0 @@ -#!/command/with-contenv bashio -# shellcheck shell=bash -# ============================================================================== -# Community Hass.io Add-ons: ESPHome -# Runs the NGINX proxy -# ============================================================================== - -# The new device builder handles HA ingress itself, so nginx is bypassed. -# Block the longrun so s6 keeps the dependency satisfied, but exit 0 on -# SIGTERM instead of being signal-killed; a 256/15 exit makes nginx/finish -# stamp the container exit 143, which trips the Supervisor's SIGTERM check. -if bashio::config.true 'use_new_device_builder'; then - bashio::log.info "NGINX bypassed: new device builder serves ingress directly." - trap 'exit 0' TERM - sleep infinity & - wait - exit 0 -fi - -bashio::log.info "Waiting for ESPHome dashboard to come up..." - -while [[ ! -S /var/run/esphome.sock ]]; do - sleep 0.5 -done - -bashio::log.info "Starting NGINX..." -exec nginx diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type deleted file mode 100644 index 5883cff0cd..0000000000 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type +++ /dev/null @@ -1 +0,0 @@ -longrun diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-nginx b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-nginx deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/nginx b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/nginx deleted file mode 100644 index e69de29bb2..0000000000 From 32ab3abd7c0cb4cbca7bb43cb8e20cf637395f02 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:55:21 +1200 Subject: [PATCH 0411/1815] [psram] Make schema extractable with per-variant options (#16949) Co-authored-by: J. Nick Koston --- esphome/components/esp32/__init__.py | 29 +++++++++++ esphome/components/psram/__init__.py | 52 +++++++++++++------ script/build_language_schema.py | 9 ++++ tests/component_tests/psram/test_psram.py | 48 +++++++++++++++++ .../psram/validate-quad.esp32-s3-idf.yaml | 5 ++ .../components/psram/validate.esp32-idf.yaml | 4 ++ .../psram/validate.esp32-p4-idf.yaml | 4 ++ tests/script/test_build_language_schema.py | 22 ++++++++ 8 files changed, 158 insertions(+), 15 deletions(-) create mode 100644 tests/components/psram/validate-quad.esp32-s3-idf.yaml create mode 100644 tests/components/psram/validate.esp32-idf.yaml create mode 100644 tests/components/psram/validate.esp32-p4-idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index d703e22e46..5d4b3b8b47 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1,3 +1,4 @@ +from collections.abc import Callable, Iterable import contextlib from dataclasses import dataclass import itertools @@ -6,6 +7,7 @@ import os from pathlib import Path import re import subprocess +from typing import Any from esphome import yaml_util import esphome.codegen as cg @@ -52,6 +54,7 @@ from esphome.coroutine import CoroPriority, coroutine_with_priority from esphome.espidf.component import generate_idf_components import esphome.final_validate as fv from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType from esphome.writer import clean_build, clean_cmake_cache @@ -496,6 +499,32 @@ def get_esp32_variant(core_obj=None): return (core_obj or CORE).data[KEY_ESP32][KEY_VARIANT] +def variant_filtered_enum( + by_variant: dict[str, Iterable[Any]], **kwargs: Any +) -> Callable[[Any], Any]: + """Build a ``one_of`` validator whose valid set depends on the active variant. + + ``by_variant`` maps each ESP32 variant constant to the iterable of values that + are valid on that variant. At validation time the value is checked against the + set allowed for the current target variant. For schema extraction the inverted + ``{value: [variants, ...]}`` map is returned instead, so the language-schema + dump can tag every option with the variants that accept it and frontends can + filter to the user's selected variant. + """ + by_value: dict[str, list[str]] = {} + for variant, values in by_variant.items(): + for value in values: + by_value.setdefault(str(value), []).append(variant) + + @schema_extractor("variant_enum") + def validator(value: Any) -> Any: + if value is SCHEMA_EXTRACT: + return by_value + return cv.one_of(*by_variant.get(get_esp32_variant(), ()), **kwargs)(value) + + return validator + + def get_board(core_obj=None): return (core_obj or CORE).data[KEY_ESP32][KEY_BOARD] diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index d36d900997..296ea6c08c 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -16,6 +16,7 @@ from esphome.components.esp32 import ( add_idf_sdkconfig_option, get_esp32_variant, idf_version, + variant_filtered_enum, ) import esphome.config_validation as cv from esphome.const import ( @@ -29,6 +30,7 @@ from esphome.const import ( ) from esphome.core import CORE import esphome.final_validate as fv +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] DOMAIN = "psram" @@ -70,6 +72,11 @@ SPIRAM_SPEEDS = { VARIANT_ESP32P4: (20, 100, 200), } +SPIRAM_SPEEDS_MHZ = { + variant: tuple(f"{speed}MHZ" for speed in speeds) + for variant, speeds in SPIRAM_SPEEDS.items() +} + def supported() -> bool: if not CORE.is_esp32: @@ -145,15 +152,23 @@ def validate_psram_mode(config): return config -def get_config_schema(config): +def _set_variant_defaults(config: ConfigType) -> ConfigType: + """Resolve variant-dependent defaults before the static schema validates. + + The set of valid ``mode``/``speed`` values is variant-specific (enforced by + ``variant_filtered_enum`` in the schema below); this only supplies the default + when the user omits the option. ``mode`` has no single default on chips that + support more than one mode, so selection is required there. + """ variant = get_esp32_variant() - speeds = [f"{s}MHZ" for s in SPIRAM_SPEEDS.get(variant, [])] - if not speeds: + modes = SPIRAM_MODES.get(variant) + speeds = SPIRAM_SPEEDS.get(variant) + if not modes or not speeds: raise cv.Invalid("PSRAM is not supported on this chip") - modes = SPIRAM_MODES[variant] - if CONF_MODE not in config and len(modes) != 1: - raise ( - cv.Invalid( + config = config.copy() + if CONF_MODE not in config: + if len(modes) != 1: + raise cv.Invalid( textwrap.dedent( f""" {variant} requires PSRAM mode selection; one of {", ".join(modes)} @@ -161,20 +176,27 @@ def get_config_schema(config): """ ) ) - ) - return cv.Schema( + config[CONF_MODE] = modes[0] + if CONF_SPEED not in config: + config[CONF_SPEED] = f"{speeds[0]}MHZ" + return config + + +CONFIG_SCHEMA = cv.All( + _set_variant_defaults, + cv.Schema( { cv.GenerateID(): cv.declare_id(PsramComponent), - cv.Optional(CONF_MODE, default=modes[0]): cv.one_of(*modes, lower=True), + cv.Optional(CONF_MODE): variant_filtered_enum(SPIRAM_MODES, lower=True), cv.Optional(CONF_ENABLE_ECC, default=False): cv.boolean, - cv.Optional(CONF_SPEED, default=speeds[0]): cv.one_of(*speeds, upper=True), + cv.Optional(CONF_SPEED): variant_filtered_enum( + SPIRAM_SPEEDS_MHZ, upper=True + ), cv.Optional(CONF_DISABLED, default=False): cv.boolean, cv.Optional(CONF_IGNORE_NOT_FOUND, default=True): cv.boolean, } - )(config) - - -CONFIG_SCHEMA = get_config_schema + ), +) def _store_psram_guaranteed(config): diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 61845c4b25..974957245a 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -951,6 +951,15 @@ def convert(schema, config_var, path): elif schema_type == "enum": config_var[S_TYPE] = "enum" config_var["values"] = dict.fromkeys(list(data.keys())) + elif schema_type == "variant_enum": + # Per-variant enum (e.g. psram mode/speed): each value carries the + # list of variants that accept it so clients can filter to the + # user's selected variant. Additive to the plain enum format — + # consumers that ignore the metadata still see every option. + config_var[S_TYPE] = "enum" + config_var["values"] = { + value: {"variants": variants} for value, variants in data.items() + } elif schema_type == "maybe": # maybe_simple_value: either a scalar shorthand (mapped to the key in # data[1]) or the full wrapped schema. The wrapped schema is usually a diff --git a/tests/component_tests/psram/test_psram.py b/tests/component_tests/psram/test_psram.py index 0924e66adc..ea4adc69a9 100644 --- a/tests/component_tests/psram/test_psram.py +++ b/tests/component_tests/psram/test_psram.py @@ -97,6 +97,54 @@ def test_psram_configuration_valid_supported_variants( FINAL_VALIDATE_SCHEMA(config) +def test_psram_applies_single_mode_default( + set_core_config: SetCoreConfigCallable, +) -> None: + """On a single-mode variant the omitted mode/speed fall back to defaults.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32}, + full_config={CONF_ESPHOME: {}}, + ) + from esphome.components.psram import CONFIG_SCHEMA + + config = CONFIG_SCHEMA({}) + assert config["mode"] == "quad" + assert config["speed"] == "40MHZ" + assert config["disabled"] is False + assert config["ignore_not_found"] is True + + +def test_psram_requires_mode_on_multi_mode_variant( + set_core_config: SetCoreConfigCallable, +) -> None: + """A variant with multiple modes requires an explicit mode selection.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32S3}, + full_config={CONF_ESPHOME: {}}, + ) + from esphome.components.psram import CONFIG_SCHEMA + + with pytest.raises(cv.Invalid, match=r"requires PSRAM mode selection"): + CONFIG_SCHEMA({}) + + +def test_psram_rejects_mode_invalid_for_variant( + set_core_config: SetCoreConfigCallable, +) -> None: + """A mode not supported by the active variant is rejected by the schema.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32}, + full_config={CONF_ESPHOME: {}}, + ) + from esphome.components.psram import CONFIG_SCHEMA + + with pytest.raises(cv.Invalid, match=r"Unknown value 'octal'"): + CONFIG_SCHEMA({"mode": "octal"}) + + def _setup_psram_final_validation_test( esp32_config: dict, set_core_config: SetCoreConfigCallable, diff --git a/tests/components/psram/validate-quad.esp32-s3-idf.yaml b/tests/components/psram/validate-quad.esp32-s3-idf.yaml new file mode 100644 index 0000000000..3fa6360d14 --- /dev/null +++ b/tests/components/psram/validate-quad.esp32-s3-idf.yaml @@ -0,0 +1,5 @@ +# Config-only: the ESP32-S3 supports both quad and octal. The compile test uses +# octal; this exercises the other branch of the per-variant mode enum (quad) and +# lets speed fall back to its 40MHz default. +psram: + mode: quad diff --git a/tests/components/psram/validate.esp32-idf.yaml b/tests/components/psram/validate.esp32-idf.yaml new file mode 100644 index 0000000000..9c04284163 --- /dev/null +++ b/tests/components/psram/validate.esp32-idf.yaml @@ -0,0 +1,4 @@ +# Config-only: with no options the single-mode ESP32 resolves mode -> quad and +# speed -> 40MHz from the per-variant defaults. Compiling adds no signal here, +# so this only runs through `esphome config`. +psram: diff --git a/tests/components/psram/validate.esp32-p4-idf.yaml b/tests/components/psram/validate.esp32-p4-idf.yaml new file mode 100644 index 0000000000..3e5899061f --- /dev/null +++ b/tests/components/psram/validate.esp32-p4-idf.yaml @@ -0,0 +1,4 @@ +# Config-only: the ESP32-P4 has a distinct value set (hex mode, 20/100/200MHz). +# With no options it resolves mode -> hex and speed -> 20MHz, exercising the +# P4-specific default branch of the per-variant enums. +psram: diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index badd4686f6..8bbaa2773a 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -139,6 +139,28 @@ def test_convert_walks_callable_schema_extractor() -> None: assert "foo" in config_var["schema"]["config_vars"] +def test_convert_emits_variant_enum() -> None: + """A per-variant enum is dumped with each value tagged by its variants.""" + from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANT_ESP32S3, + variant_filtered_enum, + ) + + validator = variant_filtered_enum( + {VARIANT_ESP32: ("quad",), VARIANT_ESP32S3: ("quad", "octal")}, + lower=True, + ) + config_var: dict = {} + _bls.convert(validator, config_var, "/test") + + assert config_var["type"] == "enum" + assert config_var["values"] == { + "quad": {"variants": [VARIANT_ESP32, VARIANT_ESP32S3]}, + "octal": {"variants": [VARIANT_ESP32S3]}, + } + + def test_convert_keys_emits_heuristic_sensitive_marker() -> None: converted: dict = {} _bls.convert_keys(converted, {cv.Optional("password"): cv.string}, "/root") From 33ace9d698a8ff8e6bed06a71b741b38bed2ecc7 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:25:54 +1000 Subject: [PATCH 0412/1815] [mipi_dsi] Add SWRESET command to M5Stack Tab5-V2 init sequence (#16975) --- esphome/components/mipi_dsi/models/m5stack.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/mipi_dsi/models/m5stack.py b/esphome/components/mipi_dsi/models/m5stack.py index 2298f76cd4..53fac9b534 100644 --- a/esphome/components/mipi_dsi/models/m5stack.py +++ b/esphome/components/mipi_dsi/models/m5stack.py @@ -71,6 +71,7 @@ DriverChip( swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ + (0x01,), (0x60, 0x71, 0x23, 0xa2), (0x60, 0x71, 0x23, 0xa3), (0x60, 0x71, 0x23, 0xa4), From 9bf35ab8fbc69847a4f7b292784946cbc8e2e37b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Jun 2026 15:46:33 -0500 Subject: [PATCH 0413/1815] [core] Attribute "took a long time" blocking warning to the owning script (#16768) --- .../components/runtime_stats/runtime_stats.h | 2 +- esphome/components/script/script.h | 19 ++- esphome/core/application.h | 95 +++++++++++++- esphome/core/base_automation.h | 9 +- esphome/core/component.cpp | 30 +++-- esphome/core/component.h | 60 +-------- esphome/core/millis_internal.h | 4 +- esphome/core/scheduler.cpp | 33 +++-- esphome/core/scheduler.h | 39 ++++-- .../fixtures/scheduler_blocking_warning.yaml | 22 ++++ ...duler_blocking_warning_generic_source.yaml | 30 +++++ ...eduler_delay_runs_on_failed_component.yaml | 29 +++++ .../test_scheduler_blocking_warning.py | 120 ++++++++++++++++++ 13 files changed, 389 insertions(+), 103 deletions(-) create mode 100644 tests/integration/fixtures/scheduler_blocking_warning.yaml create mode 100644 tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml create mode 100644 tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml create mode 100644 tests/integration/test_scheduler_blocking_warning.py diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 888d48e672..1e4910453a 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -47,7 +47,7 @@ class RuntimeStatsCollector { // overhead between Phase A and stats belongs to "residual"). // Residual overhead at log time = active − Σ(component) − before − tail, // which captures per-iteration inter-component bookkeeping (set_current_component, - // WarnIfComponentBlockingGuard construction/destruction, feed_wdt_with_time calls, + // LoopBlockingGuard construction/destruction, feed_wdt_with_time calls, // the for-loop itself). void record_loop_active(uint32_t active_us, uint32_t before_us, uint32_t tail_us) { this->period_active_count_++; diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 847fab02bd..6cd33e566c 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -3,6 +3,7 @@ #include #include #include +#include "esphome/core/application.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -57,6 +58,14 @@ template class Script : public ScriptLogger, public Triggerexecute(std::get(tuple)...); } + // Run the action chain with this script's name published as the current source (RAII save/restore, + // so nesting composes), so deferred work inside the script is attributed to it in blocking + // warnings. Force-inlined to fold into the always-inlined trigger chain (no extra stack frame). + inline void run_actions_(const Ts &...x) ESPHOME_ALWAYS_INLINE { + ScopedSourceGuard source_guard{this->name_}; + this->trigger(x...); + } + const LogString *name_{nullptr}; }; @@ -74,7 +83,7 @@ template class SingleScript : public Script { return; } - this->trigger(x...); + this->run_actions_(x...); } }; @@ -91,7 +100,7 @@ template class RestartScript : public Script { this->stop_action(); } - this->trigger(x...); + this->run_actions_(x...); } }; @@ -136,7 +145,7 @@ template class QueueingScript : public Script, public Com return; } - this->trigger(x...); + this->run_actions_(x...); // Check if the trigger was immediate and we can continue right away. this->loop(); } @@ -175,7 +184,7 @@ template class QueueingScript : public Script, public Com } template void trigger_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { - this->trigger(std::get(tuple)...); + this->run_actions_(std::get(tuple)...); } int num_queued_ = 0; // Number of queued instances (not including currently running) @@ -197,7 +206,7 @@ template class ParallelScript : public Script { LOG_STR_ARG(this->name_)); return; } - this->trigger(x...); + this->run_actions_(x...); } void set_max_runs(int max_runs) { max_runs_ = max_runs; } diff --git a/esphome/core/application.h b/esphome/core/application.h index 369c970d46..7c12a66b2c 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -104,9 +104,13 @@ class Application { void register_area(Area *area) { this->areas_.push_back(area); } #endif - void set_current_component(Component *component) { this->current_component_ = component; } Component *get_current_component() { return this->current_component_; } + // Owning script of the action chain currently executing (nullptr when none); used to attribute + // blocking warnings for deferred work to the script that scheduled it. + void set_current_source(const LogString *source) { this->current_source_ = source; } + const LogString *get_current_source() { return this->current_source_; } + // Entity register methods (generated from entity_types.h). // Each entity type gets two overloads: // - register_(obj) — bare push_back @@ -393,6 +397,7 @@ class Application { protected: friend Component; friend class Scheduler; + friend class LoopBlockingGuard; #ifdef USE_RUNTIME_STATS friend class runtime_stats::RuntimeStatsCollector; #endif @@ -402,6 +407,14 @@ class Application { /// 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; } + // Publish the running unit's identity (component + source) and dispatch time together, so a + // dispatch site can't set one without the others. Friend-only (Scheduler). + void set_current_execution_context_(Component *component, const LogString *source, uint32_t now) { + this->current_component_ = component; + this->current_source_ = source; + this->set_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 @@ -482,6 +495,7 @@ class Application { // Pointer-sized members first Component *current_component_{nullptr}; + const LogString *current_source_{nullptr}; // std::vector (3 pointers each: begin, end, capacity) // Partitioned vector design for looping components @@ -554,6 +568,76 @@ class Application { /// Global storage of Application pointer - only one Application can exist. extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +/// RAII guard that publishes a current source (e.g. a script name) for a scope and restores the +/// previous value on exit, attributing deferred work scheduled inside to that source. +class ScopedSourceGuard { + public: + explicit ScopedSourceGuard(const LogString *source) : prev_(App.get_current_source()) { + App.set_current_source(source); + } + ~ScopedSourceGuard() { App.set_current_source(this->prev_); } + ScopedSourceGuard(const ScopedSourceGuard &) = delete; + ScopedSourceGuard &operator=(const ScopedSourceGuard &) = delete; + + private: + const LogString *prev_; +}; + +// Times one unit of work (a component loop() or a scheduled callback) and warns if it blocks the +// main loop too long. The constructor publishes the unit's identity + dispatch time to App; +// finish()/the cold warning path read them back, so the guard stores no copy. +// +// Guards must not nest: the constructor publishes to App but never restores on destruction, so a +// nested guard would clobber the outer's context. Safe because the two dispatch sites (component +// loop phase, execute_item_) run strictly sequentially and aren't re-entered from a timed callback. +class LoopBlockingGuard { + public: + // Publish the unit's identity + dispatch time, then start timing. The millis start lives in App, + // so only the runtime-stats micros stamp is kept here. + LoopBlockingGuard(Component *component, const LogString *source, uint32_t now) { + App.set_current_execution_context_(component, source, now); +#ifdef USE_RUNTIME_STATS + this->started_us_ = micros(); +#endif + } + + // Finish the timing operation and return the current time (millis) + // Inlined: the fast path is just millis() + subtract + compare + inline uint32_t HOT finish() { +#ifdef USE_RUNTIME_STATS + uint32_t elapsed_us = micros() - this->started_us_; + // Delays have no component; accumulate into the global counter so loop() can subtract them. + Component *component = App.get_current_component(); + if (component != nullptr) { + component->runtime_stats_.record_time(elapsed_us); + } else { + ComponentRuntimeStats::global_recorded_us += elapsed_us; + } +#endif + uint32_t curr_time = MillisInternal::get(); +#ifndef USE_BENCHMARK + // Fast path: compare against constant threshold in ms (computed at compile time from centiseconds) + static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast(WARN_IF_BLOCKING_OVER_CS) * 10U; + uint32_t blocking_time = curr_time - App.get_loop_component_start_time(); + if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] { + warn_blocking(blocking_time); + } +#endif + return curr_time; + } + + ~LoopBlockingGuard() = default; + +#ifdef USE_RUNTIME_STATS + protected: + uint32_t started_us_; +#endif + + private: + // Cold path; defined in component.cpp. Reads the current component/source from App to name the culprit. + static void __attribute__((noinline, cold)) warn_blocking(uint32_t blocking_time); +}; + // Phase A: drain wake notifications and run the scheduler. Invoked on every // Application::loop() tick regardless of whether a component phase runs, so // scheduler items fire at their requested cadence even when the caller has @@ -607,7 +691,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // before/tail splits recorded below. uint32_t loop_active_start_us = micros(); // Snapshot the cumulative component-recorded time so we can subtract the - // slice that the scheduler spends inside its own WarnIfComponentBlockingGuard + // slice that the scheduler spends inside its own LoopBlockingGuard // (scheduler.cpp) — that time is already counted in per-component stats, // so charging it again to "before" would double-count. uint64_t loop_recorded_snap = ComponentRuntimeStats::global_recorded_us; @@ -660,12 +744,9 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { this->current_loop_index_++) { Component *component = this->looping_components_[this->current_loop_index_]; - // Update the cached time before each component runs - this->loop_component_start_time_ = last_op_end_time; - { - this->set_current_component(component); - WarnIfComponentBlockingGuard guard{component, last_op_end_time}; + // Guard publishes this component (no script source) + dispatch time, then times loop(). + LoopBlockingGuard guard{component, nullptr, last_op_end_time}; component->loop(); // Use the finish method to get the current time as the end time last_op_end_time = guard.finish(); diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index dcad7c9d2e..cf8b05a300 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -201,7 +201,10 @@ template class DelayAction : public Action { /* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER, /* static_name= */ reinterpret_cast(this), /* hash_or_id= */ 0, this->delay_.value(), [this]() { this->play_next_(); }, - /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); + /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1, + // Record the owning script (if any) so the blocking warning can name it; propagates across + // chained delays via the scheduler. + /* source= */ App.get_current_source()); } else { // For delays with arguments, capture by value to preserve argument values // Arguments must be copied because original references may be invalid after delay @@ -212,7 +215,9 @@ template class DelayAction : public Action { /* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER, /* static_name= */ reinterpret_cast(this), /* hash_or_id= */ 0, this->delay_.value(x...), std::move(f), - /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); + /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1, + // See the no-argument branch above: record the owning script for log attribution. + /* source= */ App.get_current_source()); } } diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 2d80301897..7ef5ff50a5 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -258,9 +258,11 @@ void Component::call() { break; } } -bool Component::should_warn_of_blocking(uint32_t blocking_time) { +bool Component::should_warn_of_blocking(uint32_t blocking_time, uint32_t &threshold_ms_out) { // Convert centisecond threshold to milliseconds for comparison uint32_t threshold_ms = static_cast(this->warn_if_blocking_over_) * 10U; + // Report the threshold that was exceeded (before any ratcheting below) so the warning is accurate. + threshold_ms_out = threshold_ms; if (blocking_time > threshold_ms) { // Set new threshold: blocking_time + increment, converted back to centiseconds uint32_t new_threshold_ms = blocking_time + WARN_IF_BLOCKING_INCREMENT_MS; @@ -491,19 +493,25 @@ uint32_t PollingComponent::get_update_interval() const { return this->update_int uint64_t ComponentRuntimeStats::global_recorded_us = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) #endif -void __attribute__((noinline, cold)) -WarnIfComponentBlockingGuard::warn_blocking(Component *component, uint32_t blocking_time) { - bool should_warn; +void __attribute__((noinline, cold)) LoopBlockingGuard::warn_blocking(uint32_t blocking_time) { + // Identity is published on App by the caller before the guard is built; read it back here. + Component *component = App.get_current_component(); + // Component-less path always warns (the caller already checked the constant threshold). + uint32_t threshold_ms = WARN_IF_BLOCKING_OVER_MS; + if (component != nullptr && !component->should_warn_of_blocking(blocking_time, threshold_ms)) { + return; // Component's (possibly ratcheted) threshold not exceeded yet + } + // Component name if any, else the published source (owning script), else a generic label. + const LogString *name; if (component != nullptr) { - should_warn = component->should_warn_of_blocking(blocking_time); + name = component->get_component_log_str(); } else { - should_warn = true; // Already checked > WARN_IF_BLOCKING_OVER_MS in caller - } - if (should_warn) { - ESP_LOGW(TAG, "%s took a long time for an operation (%" PRIu32 " ms), max is 30 ms", - component == nullptr ? LOG_STR_LITERAL("") : LOG_STR_ARG(component->get_component_log_str()), - blocking_time); + name = App.get_current_source(); + if (name == nullptr) + name = LOG_STR("a scheduled task"); } + ESP_LOGW(TAG, "%s took a long time for an operation (%" PRIu32 " ms), max is %" PRIu32 " ms", LOG_STR_ARG(name), + blocking_time, threshold_ms); } #ifdef USE_SETUP_PRIORITY_OVERRIDE diff --git a/esphome/core/component.h b/esphome/core/component.h index ff10f1a8f1..299a5f72ea 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -118,7 +118,7 @@ struct ComponentRuntimeStats { // Cumulative sum of every record_time() duration since boot, across all // components. Used by Application::loop() to snapshot time spent inside - // WarnIfComponentBlockingGuard (including guards constructed by the + // LoopBlockingGuard (including guards constructed by the // scheduler at scheduler.cpp) so main-loop overhead accounting can // subtract scheduled-callback time from the before_loop_tasks_ wall time. static uint64_t global_recorded_us; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -326,7 +326,7 @@ class Component { return component_source_lookup(this->component_source_index_); } - bool should_warn_of_blocking(uint32_t blocking_time); + bool should_warn_of_blocking(uint32_t blocking_time, uint32_t &threshold_ms_out); protected: friend class Application; @@ -571,7 +571,7 @@ class Component { volatile bool pending_enable_loop_{false}; ///< ISR-safe flag for enable_loop_soon_any_context #ifdef USE_RUNTIME_STATS friend class runtime_stats::RuntimeStatsCollector; - friend class WarnIfComponentBlockingGuard; + friend class LoopBlockingGuard; ComponentRuntimeStats runtime_stats_; #endif }; @@ -619,59 +619,7 @@ class PollingComponent : public Component { uint32_t update_interval_; }; -// millis() and micros() are available via hal.h - -class WarnIfComponentBlockingGuard { - public: - WarnIfComponentBlockingGuard(Component *component, uint32_t start_time) - : started_(start_time), - component_(component) -#ifdef USE_RUNTIME_STATS - , - started_us_(micros()) -#endif - { - } - - // Finish the timing operation and return the current time (millis) - // Inlined: the fast path is just millis() + subtract + compare - inline uint32_t HOT finish() { -#ifdef USE_RUNTIME_STATS - uint32_t elapsed_us = micros() - this->started_us_; - // component_ is nullptr for self-keyed scheduler items (set_timeout/set_interval(self, ...)) - if (this->component_ != nullptr) { - this->component_->runtime_stats_.record_time(elapsed_us); - } else { - // Still accumulate into the global counter so Application::loop() can subtract - // this time from before_loop_tasks_ wall time. - ComponentRuntimeStats::global_recorded_us += elapsed_us; - } -#endif - uint32_t curr_time = MillisInternal::get(); -#ifndef USE_BENCHMARK - // Fast path: compare against constant threshold in ms (computed at compile time from centiseconds) - static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast(WARN_IF_BLOCKING_OVER_CS) * 10U; - uint32_t blocking_time = curr_time - this->started_; - if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] { - warn_blocking(this->component_, blocking_time); - } -#endif - return curr_time; - } - - ~WarnIfComponentBlockingGuard() = default; - - protected: - uint32_t started_; - Component *component_; -#ifdef USE_RUNTIME_STATS - uint32_t started_us_; -#endif - - private: - // Cold path for blocking warning - defined in component.cpp - static void __attribute__((noinline, cold)) warn_blocking(Component *component, uint32_t blocking_time); -}; +// LoopBlockingGuard lives in application.h because it reads its state from App. // Function to clear setup priority overrides after all components are set up // Only has an implementation when USE_SETUP_PRIORITY_OVERRIDE is defined diff --git a/esphome/core/millis_internal.h b/esphome/core/millis_internal.h index bc1d55a1c4..7297d22357 100644 --- a/esphome/core/millis_internal.h +++ b/esphome/core/millis_internal.h @@ -16,7 +16,7 @@ namespace esphome { // Friend-gated accessor for a fast millis() variant intended only for // known task-context callers on the main loop hot path (Application::loop() -// and WarnIfComponentBlockingGuard::finish()). It skips the ISR-context +// and LoopBlockingGuard::finish()). It skips the ISR-context // dispatch that the public esphome::millis() pays on ESP32 and libretiny. // // MUST NOT be called from ISR context: on ESP32 and libretiny it calls the @@ -50,7 +50,7 @@ class MillisInternal { #endif } friend class Application; - friend class WarnIfComponentBlockingGuard; + friend class LoopBlockingGuard; }; } // namespace esphome diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index a7c624486d..15bb9ea239 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -131,7 +131,8 @@ bool Scheduler::is_retry_cancelled_locked_(Component *component, NameType name_t // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type, const char *static_name, uint32_t hash_or_id, uint32_t delay, - std::function &&func, bool is_retry, bool skip_cancel) { + std::function &&func, bool is_retry, bool skip_cancel, + const LogString *source) { if (delay == SCHEDULER_DONT_RUN) { // Still need to cancel existing timer if we have a name/id if (!skip_cancel) { @@ -174,7 +175,12 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type // Create and populate the scheduler item SchedulerItem *item = this->get_item_from_pool_locked_(); - item->component = component; + // SELF_POINTER items store the source name (owning script) in the union slot instead of a component. + if (name_type == NameType::SELF_POINTER) { + item->source_name = source; + } else { + item->component = component; + } item->set_name(name_type, static_name, hash_or_id); item->type = type; // Use destroy + placement-new instead of move-assignment. @@ -642,8 +648,8 @@ uint32_t HOT Scheduler::call(uint32_t now) { // Not reached timeout yet, done for this call break; } - // Don't run on failed components - if (item->component != nullptr && item->component->is_failed()) { + // Don't run on failed components (is_item_failed_ exempts SELF_POINTER delays). + if (this->is_item_failed_(item)) { LockGuard guard{this->lock_}; this->recycle_item_main_loop_(this->pop_raw_locked_()); continue; @@ -790,10 +796,21 @@ 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}; + // Resolve the component and (for SELF_POINTER/deferred items) the source name from the shared + // union slot with a single name-type check. Self-keyed items have no owning component; their slot + // holds the source name (e.g. the owning script), published so deferred work chained inside the + // callback re-captures it and the blocking warning can name the script instead of "". + Component *component; + const LogString *source; + if (item->get_name_type() == NameType::SELF_POINTER) { + component = nullptr; + source = item->source_name; + } else { + component = item->component; + source = nullptr; + } + // Guard publishes the item's identity + dispatch time, then times the callback. + LoopBlockingGuard guard{component, source, now}; item->callback(); uint32_t end = guard.finish(); // Feed the watchdog after each scheduled item (both main heap and defer diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index b640aa86fe..378c0fb94b 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -183,11 +183,12 @@ class Scheduler { protected: struct SchedulerItem { - // Ordered by size to minimize padding. - // `component` while live; `next_free` while in scheduler_item_pool_head_ (mutually exclusive). + // Ordered by size to minimize padding. Mutually exclusive by state; read the component via + // get_component() so SELF_POINTER items read as component-less. union { - Component *component; - SchedulerItem *next_free; + Component *component; // live, non-SELF_POINTER: owning component + const LogString *source_name; // live SELF_POINTER: owning script name (log attribution) + SchedulerItem *next_free; // while pooled }; // Optimized name storage using tagged union - zero heap allocation union { @@ -302,14 +303,23 @@ class Scheduler { next_execution_high_ = static_cast(value >> 32); } constexpr const char *get_type_str() const { return (type == TIMEOUT) ? "timeout" : "interval"; } - const LogString *get_source() const { return component ? component->get_component_log_str() : LOG_STR("unknown"); } + // The owning component, or nullptr for SELF_POINTER items (whose slot holds source_name instead). + // All component access goes through this so SELF_POINTER items read as component-less. + Component *get_component() const { return name_type_ == NameType::SELF_POINTER ? nullptr : component; } + const LogString *get_source() const { + // Same no-source label as warn_blocking, for consistent log vocabulary. + if (name_type_ == NameType::SELF_POINTER) + return source_name != nullptr ? source_name : LOG_STR("a scheduled task"); + return component != nullptr ? component->get_component_log_str() : LOG_STR("unknown"); + } }; // Common implementation for both timeout and interval // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id + // `source` is stored (in the union slot) only for SELF_POINTER items; ignored otherwise. void set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type, const char *static_name, uint32_t hash_or_id, uint32_t delay, std::function &&func, bool is_retry = false, - bool skip_cancel = false); + bool skip_cancel = false, const LogString *source = nullptr); // Common implementation for retry - Remove before 2026.8.0 // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id @@ -402,8 +412,10 @@ class Scheduler { // Fixes: https://github.com/esphome/esphome/issues/11940 if (item == nullptr) return false; - if (item->component != component || item->type != type || (skip_removed && this->is_item_removed_locked_(item)) || - (match_retry && !item->is_retry)) { + // get_component() is nullptr for SELF_POINTER items (their cancels pass nullptr too), so they + // match by the `this` key alone. + if (item->get_component() != component || item->type != type || + (skip_removed && this->is_item_removed_locked_(item)) || (match_retry && !item->is_retry)) { return false; } // Name type must match @@ -423,11 +435,16 @@ class Scheduler { // Helper to execute a scheduler item uint32_t execute_item_(SchedulerItem *item, uint32_t now); - // Helper to check if item should be skipped - bool should_skip_item_(SchedulerItem *item) const { - return is_item_removed_(item) || (item->component != nullptr && item->component->is_failed()); + // True if the item's component is failed (so it must not run). SELF_POINTER delays have no + // component (get_component() == nullptr) and always fire. + bool is_item_failed_(SchedulerItem *item) const { + Component *component = item->get_component(); + return component != nullptr && component->is_failed(); } + // Helper to check if item should be skipped + bool should_skip_item_(SchedulerItem *item) const { return is_item_removed_(item) || this->is_item_failed_(item); } + // Helper to recycle a SchedulerItem back to the pool. // Takes a raw pointer — caller transfers ownership. The item is either added to the // pool or deleted if the pool is full. diff --git a/tests/integration/fixtures/scheduler_blocking_warning.yaml b/tests/integration/fixtures/scheduler_blocking_warning.yaml new file mode 100644 index 0000000000..594ec46afb --- /dev/null +++ b/tests/integration/fixtures/scheduler_blocking_warning.yaml @@ -0,0 +1,22 @@ +esphome: + name: scheduler-blocking-warning + on_boot: + then: + - script.execute: blocking_script + +host: +api: +logger: + level: DEBUG + +# The busy-block runs in the second delay's continuation; the warning must name the script. Two +# delays verify the source survives chained delays (the scheduler republishes it each continuation). +script: + - id: blocking_script + then: + - delay: 10ms + - delay: 10ms + - lambda: |- + const uint32_t start = millis(); + while (millis() - start < 80) { + } diff --git a/tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml b/tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml new file mode 100644 index 0000000000..2d8a62f25b --- /dev/null +++ b/tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml @@ -0,0 +1,30 @@ +esphome: + name: scheduler-blocking-generic + +host: +api: +logger: + level: DEBUG + +globals: + - id: done + type: bool + restore_value: false + initial_value: "false" + +# A delay in a plain (non-script) automation has no owning script, so the block must log the +# generic "a scheduled task" label, not a script name. +interval: + - interval: 100ms + id: gen_interval + then: + - if: + condition: + lambda: "return !id(done);" + then: + - lambda: "id(done) = true;" + - delay: 10ms + - lambda: |- + const uint32_t start = millis(); + while (millis() - start < 80) { + } diff --git a/tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml b/tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml new file mode 100644 index 0000000000..860fa00c37 --- /dev/null +++ b/tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml @@ -0,0 +1,29 @@ +esphome: + name: scheduler-delay-failed + +host: +api: +logger: + level: DEBUG + +globals: + - id: started + type: bool + restore_value: false + initial_value: "false" + +# The interval marks itself failed, then schedules a delay. The delay must still fire: a failed +# component must not drop it, since the SELF_POINTER scheduler item has no owning component. +interval: + - interval: 100ms + id: host_interval + then: + - if: + condition: + lambda: "return !id(started);" + then: + - lambda: |- + id(started) = true; + id(host_interval)->mark_failed(); + - delay: 200ms + - logger.log: "DELAY_FIRED_AFTER_FAIL" diff --git a/tests/integration/test_scheduler_blocking_warning.py b/tests/integration/test_scheduler_blocking_warning.py new file mode 100644 index 0000000000..699a5bc746 --- /dev/null +++ b/tests/integration/test_scheduler_blocking_warning.py @@ -0,0 +1,120 @@ +"""Integration tests for blocking-warning source attribution. + +A blocking operation that runs inside a deferred scheduler continuation (e.g. after a ``delay`` +in a script) used to be reported as `` took a long time for an operation (NN ms), +max is 30 ms`` because the continuation carries no component. The warning should instead name +the owning script and report the real threshold (50 ms). +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Matches: " took a long time for an operation (NN ms), max is NN ms" +WARN_PATTERN = re.compile( + r"(\S+) took a long time for an operation \((\d+) ms\), max is (\d+) ms" +) + + +@pytest.mark.asyncio +async def test_scheduler_blocking_warning( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Deferred blocking work inside a script is attributed to the script, not "".""" + loop = asyncio.get_running_loop() + warning_future: asyncio.Future[str] = loop.create_future() + + def check_output(line: str) -> None: + if WARN_PATTERN.search(line) and not warning_future.done(): + warning_future.set_result(line) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + + # on_boot runs the script, which defers via delay then busy-blocks > 50 ms in the + # continuation, tripping the blocking warning. + warning_line = await asyncio.wait_for(warning_future, timeout=10.0) + + # Must name the owning script, not "" and not the generic fallback. + assert "" not in warning_line, ( + f"Warning should name the script, got: {warning_line}" + ) + assert "a scheduled task" not in warning_line, ( + f"Warning should name the script, got: {warning_line}" + ) + match = WARN_PATTERN.search(warning_line) + assert match is not None + assert match.group(1) == "blocking_script", ( + f"Warning should name 'blocking_script', got: {warning_line}" + ) + # The reported threshold must be the real default (50 ms), not the stale "30 ms". + assert match.group(3) == "50", f"Expected 'max is 50 ms', got: {warning_line}" + + +@pytest.mark.asyncio +async def test_scheduler_blocking_warning_generic_source( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A delay in a plain (non-script) automation logs the generic label, not a script name.""" + loop = asyncio.get_running_loop() + warning_future: asyncio.Future[str] = loop.create_future() + + def check_output(line: str) -> None: + if WARN_PATTERN.search(line) and not warning_future.done(): + warning_future.set_result(line) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + assert await client.device_info() is not None + warning_line = await asyncio.wait_for(warning_future, timeout=10.0) + + assert "a scheduled task took a long time" in warning_line, ( + f"Non-script deferred work should log the generic label, got: {warning_line}" + ) + assert "" not in warning_line + match = WARN_PATTERN.search(warning_line) + assert match is not None and match.group(3) == "50", ( + f"Expected 'max is 50 ms', got: {warning_line}" + ) + + +@pytest.mark.asyncio +async def test_scheduler_delay_runs_on_failed_component( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A delay must still fire even when its context component is marked failed. + + Deferred (SELF_POINTER) scheduler items have no owning component, so the scheduler's + failed-component skip must not drop them. + """ + loop = asyncio.get_running_loop() + fired: asyncio.Future[bool] = loop.create_future() + + def check_output(line: str) -> None: + if "DELAY_FIRED_AFTER_FAIL" in line and not fired.done(): + fired.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + assert await client.device_info() is not None + # If the failed host component wrongly dropped the delay, this times out. + await asyncio.wait_for(fired, timeout=10.0) From aef9b5b72f731ff6d8d307e71cfbdcf37dcb52c5 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 15 Jun 2026 16:48:07 -0400 Subject: [PATCH 0414/1815] [audio] Bump microMP3 to v0.2.3 (#16977) --- .clang-tidy.hash | 2 +- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 7497cc3679..7a3cfc7a03 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -a6ec18b82143e293ca6dee6947217f10a387ace99881a34b2c308ff627c8173c +34f6ce4a4775acf8c7201778f114b191f78269f232b67f01fed920f0cdf73686 diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 2ddce577ef..2aceff0c97 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -395,7 +395,7 @@ async def to_code(config): ) if data.mp3_support: cg.add_define("USE_AUDIO_MP3_SUPPORT") - add_idf_component(name="esphome/micro-mp3", ref="0.2.1") + add_idf_component(name="esphome/micro-mp3", ref="0.2.3") _emit_memory_pair( data.mp3.buffer_memory, "CONFIG_MP3_DECODER_PREFER_PSRAM", diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index c97e8906a8..04220488cc 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -12,7 +12,7 @@ dependencies: esphome/micro-flac: version: 0.2.0 esphome/micro-mp3: - version: 0.2.1 + version: 0.2.3 esphome/micro-opus: version: 0.4.1 esphome/micro-wav: From 1d38498ca7c27609dd166610397909cdcad8d174 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:05:50 -0400 Subject: [PATCH 0415/1815] [openthread] Fix InstanceLock releasing the lock twice on try_acquire (#16980) --- esphome/components/openthread/openthread.cpp | 2 +- esphome/components/openthread/openthread.h | 23 +++++++++++++++---- .../components/openthread/openthread_esp.cpp | 17 +++++++------- .../openthread_info_text_sensor.h | 2 +- 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index bf14514636..c8ffc02131 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -227,7 +227,7 @@ bool OpenThreadComponent::teardown() { ESP_LOGW(TAG, "Failed to acquire OpenThread lock during teardown, leaking memory"); return true; } - otInstance *instance = lock->get_instance(); + otInstance *instance = lock.get_instance(); otSrpClientClearHostAndServices(instance); otSrpClientBuffersFreeAllServices(instance); global_openthread_component = nullptr; diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index 5898492a50..96f1abdb92 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -86,19 +86,32 @@ class OpenThreadSrpComponent : public Component { void *pool_alloc_(size_t size); }; +// RAII guard for the OpenThread API lock. Modeled on std::unique_lock: the +// guard may or may not own the lock (try_acquire can fail), so check it with +// operator bool before use. Non-copyable and non-movable: the factories return +// by value via guaranteed copy elision, so a guard is never duplicated and the +// lock is released exactly once, when the owning guard goes out of scope. class InstanceLock { public: - static std::optional try_acquire(int delay); + // May fail to acquire within delay ms; check the returned guard with operator bool. + static InstanceLock try_acquire(int delay); + // Blocks until the lock is held. static InstanceLock acquire(); + InstanceLock(const InstanceLock &) = delete; + InstanceLock(InstanceLock &&) = delete; + InstanceLock &operator=(const InstanceLock &) = delete; + InstanceLock &operator=(InstanceLock &&) = delete; ~InstanceLock(); - // Returns the global openthread instance guarded by this lock + explicit operator bool() const { return this->owns_; } + + // Returns the global openthread instance. Only valid on an owning guard + // (operator bool is true); the instance must not be used without the lock held. otInstance *get_instance(); private: - // Use a private constructor in order to force the handling - // of acquisition failure - InstanceLock() {} + explicit InstanceLock(bool owns) : owns_(owns) {} + bool owns_; }; } // namespace esphome::openthread diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index cf1288d90c..4d88cbd226 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -216,14 +216,11 @@ network::IPAddresses OpenThreadComponent::get_ip_addresses() { // not thread safe, only use in read-only use cases otInstance *OpenThreadComponent::get_openthread_instance_() { return esp_openthread_get_instance(); } -std::optional InstanceLock::try_acquire(int delay) { +InstanceLock InstanceLock::try_acquire(int delay) { if (!global_openthread_component->is_lock_initialized()) { - return {}; + return InstanceLock(false); } - if (esp_openthread_lock_acquire(delay)) { - return InstanceLock(); - } - return {}; + return InstanceLock(esp_openthread_lock_acquire(delay)); } InstanceLock InstanceLock::acquire() { @@ -242,12 +239,16 @@ InstanceLock InstanceLock::acquire() { while (!esp_openthread_lock_acquire(100)) { esp_task_wdt_reset(); } - return InstanceLock(); + return InstanceLock(true); } otInstance *InstanceLock::get_instance() { return esp_openthread_get_instance(); } -InstanceLock::~InstanceLock() { esp_openthread_lock_release(); } +InstanceLock::~InstanceLock() { + if (this->owns_) { + esp_openthread_lock_release(); + } +} } // namespace esphome::openthread #endif diff --git a/esphome/components/openthread_info/openthread_info_text_sensor.h b/esphome/components/openthread_info/openthread_info_text_sensor.h index 10e83281f0..ef7c5cc8e9 100644 --- a/esphome/components/openthread_info/openthread_info_text_sensor.h +++ b/esphome/components/openthread_info/openthread_info_text_sensor.h @@ -17,7 +17,7 @@ class OpenThreadInstancePollingComponent : public PollingComponent { return; } - this->update_instance(lock->get_instance()); + this->update_instance(lock.get_instance()); } float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } From 66be793cd8a57af57c032e5ab207a34b5f83caa7 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:12:53 +1200 Subject: [PATCH 0416/1815] [docker] Remove alpine base, build only on debian (#16991) --- .github/actions/build-image/action.yaml | 7 ------- docker/Dockerfile | 15 +++++---------- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/.github/actions/build-image/action.yaml b/.github/actions/build-image/action.yaml index 2081264b91..494c0cebe8 100644 --- a/.github/actions/build-image/action.yaml +++ b/.github/actions/build-image/action.yaml @@ -15,11 +15,6 @@ inputs: description: "Version to build" required: true example: "2023.12.0" - base_os: - description: "Base OS to use" - required: false - default: "debian" - example: "debian" runs: using: "composite" steps: @@ -60,7 +55,6 @@ runs: build-args: | BUILD_TYPE=${{ inputs.build_type }} BUILD_VERSION=${{ inputs.version }} - BUILD_OS=${{ inputs.base_os }} outputs: | type=image,name=ghcr.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true @@ -86,7 +80,6 @@ runs: build-args: | BUILD_TYPE=${{ inputs.build_type }} BUILD_VERSION=${{ inputs.version }} - BUILD_OS=${{ inputs.base_os }} outputs: | type=image,name=docker.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true diff --git a/docker/Dockerfile b/docker/Dockerfile index 25de9472b6..c360ae1a4a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,10 +1,9 @@ ARG BUILD_VERSION=dev -ARG BUILD_OS=alpine ARG BUILD_BASE_VERSION=2025.04.0 ARG BUILD_TYPE=docker -FROM ghcr.io/esphome/docker-base:${BUILD_OS}-${BUILD_BASE_VERSION} AS base-source-docker -FROM ghcr.io/esphome/docker-base:${BUILD_OS}-ha-addon-${BUILD_BASE_VERSION} AS base-source-ha-addon +FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base-source-docker +FROM ghcr.io/esphome/docker-base:debian-ha-addon-${BUILD_BASE_VERSION} AS base-source-ha-addon ARG BUILD_TYPE FROM base-source-${BUILD_TYPE} AS base @@ -18,13 +17,9 @@ RUN git config --system --add safe.directory "*" \ # validate openocd-esp32 (it dynamically links libusb-1.0.so.0); without # it idf_tools.py rejects the openocd install with exit 127 and aborts # the whole framework setup. -RUN if command -v apk > /dev/null; then \ - apk add --no-cache build-base libusb; \ - else \ - apt-get update \ - && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \ - && rm -rf /var/lib/apt/lists/*; \ - fi +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \ + && rm -rf /var/lib/apt/lists/* ENV PIP_DISABLE_PIP_VERSION_CHECK=1 From 0ce89c17ab7233216c78a7e66875477f08d0acf3 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:37:31 +1200 Subject: [PATCH 0417/1815] [ci] Push branch-tagged docker images to ghcr.io for local testing (#16992) --- .github/workflows/ci-docker.yml | 84 ++++++++++++++- docker/build.py | 55 +++++++--- tests/script/test_docker_build.py | 169 ++++++++++++++++++++++++++++++ 3 files changed, 290 insertions(+), 18 deletions(-) create mode 100644 tests/script/test_docker_build.py diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 2a40675f3b..7d4b850356 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -22,7 +22,7 @@ on: - "script/platformio_install_deps.py" permissions: - contents: read # actions/checkout only; the build does not push images + contents: read # actions/checkout only concurrency: # yamllint disable-line rule:line-length @@ -33,6 +33,9 @@ jobs: check-docker: name: Build docker containers runs-on: ${{ matrix.os }} + permissions: + contents: read # actions/checkout to load Dockerfile and build context + packages: write # push branch-tagged images to ghcr.io for local testing strategy: fail-fast: false matrix: @@ -41,6 +44,9 @@ jobs: - "ha-addon" - "docker" # - "lint" + outputs: + tag: ${{ steps.tag.outputs.tag }} + push: ${{ steps.tag.outputs.push }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python @@ -50,14 +56,82 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - name: Set TAG + - name: Determine tag and whether to push + id: tag run: | - echo "TAG=check" >> $GITHUB_ENV + # Sanitize the branch name into a valid docker tag: replace invalid + # characters, ensure the first character is valid (tags must start + # with [A-Za-z0-9_]), and cap the length at 128 characters. + branch="${{ github.head_ref || github.ref_name }}" + tag="${branch//[^a-zA-Z0-9_.-]/-}" + case "$tag" in + [a-zA-Z0-9_]*) ;; + *) tag="pr-${tag}" ;; + esac + tag="${tag:0:128}" + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + # Only push branch images for same-repo pull requests. Push events + # only fire for dev/beta/release, whose images are owned by the + # release pipeline -- never overwrite those from here. + if [ "${{ github.event_name }}" = "pull_request" ] \ + && [ "${{ github.repository }}" = "esphome/esphome" ] \ + && [ "${{ github.event.pull_request.head.repo.full_name }}" = "esphome/esphome" ]; then + echo "push=true" >> "$GITHUB_OUTPUT" + else + echo "push=false" >> "$GITHUB_OUTPUT" + fi + + - name: Log in to the GitHub container registry + if: steps.tag.outputs.push == 'true' + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Run build run: | docker/build.py \ - --tag "${TAG}" \ + --tag "${{ steps.tag.outputs.tag }}" \ --arch "${{ matrix.os == 'ubuntu-24.04-arm' && 'aarch64' || 'amd64' }}" \ --build-type "${{ matrix.build_type }}" \ - build + --registry ghcr \ + build ${{ steps.tag.outputs.push == 'true' && '--push --no-cache-to' || '' }} + + manifest: + name: Push ${{ matrix.build_type }} manifest to ghcr.io + needs: [check-docker] + if: needs.check-docker.outputs.push == 'true' + runs-on: ubuntu-24.04 + permissions: + contents: read # actions/checkout to run docker/build.py + packages: write # buildx imagetools writes the multi-arch tag to ghcr.io + strategy: + fail-fast: false + matrix: + build_type: + - "ha-addon" + - "docker" + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.11" + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Log in to the GitHub container registry + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create and push manifest + run: | + docker/build.py \ + --tag "${{ needs.check-docker.outputs.tag }}" \ + --build-type "${{ matrix.build_type }}" \ + --registry ghcr \ + manifest diff --git a/docker/build.py b/docker/build.py index 4d093cf88d..475986e905 100755 --- a/docker/build.py +++ b/docker/build.py @@ -20,6 +20,10 @@ TYPE_HA_ADDON = "ha-addon" TYPE_LINT = "lint" TYPES = [TYPE_DOCKER, TYPE_HA_ADDON, TYPE_LINT] +REGISTRY_GHCR = "ghcr" +REGISTRY_DOCKERHUB = "dockerhub" +REGISTRIES = [REGISTRY_GHCR, REGISTRY_DOCKERHUB] + parser = argparse.ArgumentParser() parser.add_argument( @@ -34,6 +38,12 @@ parser.add_argument( parser.add_argument( "--build-type", choices=TYPES, required=True, help="The type of build to run" ) +parser.add_argument( + "--registry", + choices=REGISTRIES, + action="append", + help="Restrict to specific registries (default: all). May be passed multiple times.", +) parser.add_argument( "--dry-run", action="store_true", help="Don't run any commands, just print them" ) @@ -45,6 +55,11 @@ build_parser.add_argument("--push", help="Also push the images", action="store_t build_parser.add_argument( "--load", help="Load the docker image locally", action="store_true" ) +build_parser.add_argument( + "--no-cache-to", + help="Don't write the build cache (avoids polluting the shared cache)", + action="store_true", +) manifest_parser = subparsers.add_parser( "manifest", help="Create a manifest from already pushed images" ) @@ -95,11 +110,14 @@ def main(): print("Command failed") sys.exit(1) + registries = args.registry or REGISTRIES + # detect channel from tag match = re.match(r"^(\d+\.\d+)(?:\.\d+)?(b\d+)?$", args.tag) major_minor_version = None if match is None: - channel = CHANNEL_DEV + # Custom tag (e.g. a branch name) -- push only the tag itself + channel = None elif match.group(2) is None: major_minor_version = match.group(1) channel = CHANNEL_RELEASE @@ -128,11 +146,18 @@ def main(): CHANNEL_DEV: "cache-dev", CHANNEL_BETA: "cache-beta", CHANNEL_RELEASE: "cache-latest", - }[channel] - cache_img = f"ghcr.io/{params.build_to}:{cache_tag}" + }.get(channel, "cache-dev") + # Cache images live alongside the pushed images; prefer GHCR when it is + # one of the selected registries, otherwise fall back to Docker Hub so a + # registry-restricted build doesn't need GHCR auth. + cache_prefix = "ghcr.io/" if REGISTRY_GHCR in registries else "" + cache_img = f"{cache_prefix}{params.build_to}:{cache_tag}" - imgs = [f"{params.build_to}:{tag}" for tag in tags_to_push] - imgs += [f"ghcr.io/{params.build_to}:{tag}" for tag in tags_to_push] + imgs = [] + if REGISTRY_DOCKERHUB in registries: + imgs += [f"{params.build_to}:{tag}" for tag in tags_to_push] + if REGISTRY_GHCR in registries: + imgs += [f"ghcr.io/{params.build_to}:{tag}" for tag in tags_to_push] # 3. build cmd = [ @@ -155,7 +180,9 @@ def main(): for img in imgs: cmd += ["--tag", img] if args.push: - cmd += ["--push", "--cache-to", f"type=registry,ref={cache_img},mode=max"] + cmd += ["--push"] + if not args.no_cache_to: + cmd += ["--cache-to", f"type=registry,ref={cache_img},mode=max"] if args.load: cmd += ["--load"] @@ -163,20 +190,22 @@ def main(): elif args.command == "manifest": manifest = DockerParams.for_type_arch(args.build_type, ARCH_AMD64).manifest_to - targets = [f"{manifest}:{tag}" for tag in tags_to_push] - targets += [f"ghcr.io/{manifest}:{tag}" for tag in tags_to_push] - # 1. Create manifests + targets = [] + if REGISTRY_DOCKERHUB in registries: + targets += [f"{manifest}:{tag}" for tag in tags_to_push] + if REGISTRY_GHCR in registries: + targets += [f"ghcr.io/{manifest}:{tag}" for tag in tags_to_push] + # Use buildx imagetools (not `docker manifest`) so the per-arch sources, + # which buildx pushes as single-platform manifest lists, are combined + # and pushed correctly in one step. for target in targets: - cmd = ["docker", "manifest", "create", target] + cmd = ["docker", "buildx", "imagetools", "create", "--tag", target] for arch in ARCHS: src = f"{DockerParams.for_type_arch(args.build_type, arch).build_to}:{args.tag}" if target.startswith("ghcr.io"): src = f"ghcr.io/{src}" cmd.append(src) run_command(*cmd) - # 2. Push manifests - for target in targets: - run_command("docker", "manifest", "push", target) if __name__ == "__main__": diff --git a/tests/script/test_docker_build.py b/tests/script/test_docker_build.py new file mode 100644 index 0000000000..34bcc4e714 --- /dev/null +++ b/tests/script/test_docker_build.py @@ -0,0 +1,169 @@ +"""Unit tests for docker/build.py command generation.""" + +import importlib.util +from pathlib import Path +import sys + +import pytest + +_BUILD_PY = Path(__file__).parents[2] / "docker" / "build.py" +_spec = importlib.util.spec_from_file_location("docker_build", _BUILD_PY) +docker_build = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(docker_build) + + +def _run(capsys: pytest.CaptureFixture[str], *argv: str) -> list[str]: + """Run build.py main() in dry-run mode and return the emitted commands.""" + full_argv = ["build.py", "--dry-run", *argv] + with pytest.MonkeyPatch.context() as mp: + mp.setattr(sys, "argv", full_argv) + docker_build.main() + out = capsys.readouterr().out + return [line[2:] for line in out.splitlines() if line.startswith("$ ")] + + +def test_branch_build_pushes_single_ghcr_tag_without_cache_to( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--arch", + "amd64", + "--build-type", + "docker", + "--registry", + "ghcr", + "build", + "--push", + "--no-cache-to", + ) + + assert len(commands) == 1 + cmd = commands[0] + # Custom tag -> only the tag itself, no companion "dev"/"latest" tags + assert "--tag ghcr.io/esphome/esphome-amd64:my-branch" in cmd + assert ":dev" not in cmd + # ghcr only -> no Docker Hub image name + assert "--tag esphome/esphome-amd64:my-branch" not in cmd + # custom tag falls back to the dev cache for reads + assert ( + "--cache-from type=registry,ref=ghcr.io/esphome/esphome-amd64:cache-dev" in cmd + ) + assert "--push" in cmd + # --no-cache-to must suppress the cache write + assert "--cache-to" not in cmd + + +def test_branch_manifest_targets_ghcr_only( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--build-type", + "ha-addon", + "--registry", + "ghcr", + "manifest", + ) + + assert commands == [ + "docker buildx imagetools create " + "--tag ghcr.io/esphome/esphome-hassio:my-branch " + "ghcr.io/esphome/esphome-hassio-amd64:my-branch " + "ghcr.io/esphome/esphome-hassio-aarch64:my-branch" + ] + + +def test_release_build_keeps_both_registries_and_cache_to( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "2025.6.0", + "--arch", + "amd64", + "--build-type", + "docker", + "build", + "--push", + ) + + cmd = commands[0] + # Default (no --registry) keeps both Docker Hub and ghcr image names + assert "--tag esphome/esphome-amd64:2025.6.0" in cmd + assert "--tag ghcr.io/esphome/esphome-amd64:2025.6.0" in cmd + # Release channel still gets its companion tags + assert "--tag esphome/esphome-amd64:latest" in cmd + # Without --no-cache-to the cache write is preserved + assert ( + "--cache-to type=registry,ref=ghcr.io/esphome/esphome-amd64:cache-latest,mode=max" + in cmd + ) + + +def test_build_no_push_omits_push_and_cache( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--arch", + "amd64", + "--build-type", + "docker", + "--registry", + "ghcr", + "build", + ) + + cmd = commands[0] + assert "--tag ghcr.io/esphome/esphome-amd64:my-branch" in cmd + assert "--push" not in cmd + assert "--cache-to" not in cmd + + +def test_build_dockerhub_only(capsys: pytest.CaptureFixture[str]) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--arch", + "amd64", + "--build-type", + "docker", + "--registry", + "dockerhub", + "build", + "--push", + ) + + cmd = commands[0] + assert "--tag esphome/esphome-amd64:my-branch" in cmd + assert "ghcr.io" not in cmd + # Cache reference falls back to Docker Hub when GHCR isn't selected + assert "--cache-from type=registry,ref=esphome/esphome-amd64:cache-dev" in cmd + + +def test_manifest_dockerhub_only(capsys: pytest.CaptureFixture[str]) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--build-type", + "docker", + "--registry", + "dockerhub", + "manifest", + ) + + create = commands[0] + assert create.startswith( + "docker buildx imagetools create --tag esphome/esphome:my-branch " + ) + assert "ghcr.io" not in create From 0422b581cb1537b6ceebb1b12546911a51efc43b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:24:26 +1200 Subject: [PATCH 0418/1815] [core] Stop parent git repos from breaking ESP-IDF/PlatformIO builds (#16994) --- esphome/espidf/toolchain.py | 6 +++++ esphome/helpers.py | 21 +++++++++++++++ esphome/platformio/toolchain.py | 5 ++++ tests/unit_tests/test_espidf_toolchain.py | 14 ++++++++++ tests/unit_tests/test_helpers.py | 27 +++++++++++++++++++ tests/unit_tests/test_platformio_toolchain.py | 5 ++++ 6 files changed, 78 insertions(+) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 2fef3faf8d..c622a2dd36 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -14,6 +14,7 @@ from esphome.const import CONF_FRAMEWORK, CONF_SOURCE from esphome.core import CORE, EsphomeError from esphome.espidf.framework import check_esp_idf_install, get_framework_env from esphome.espidf.size_summary import print_summary +from esphome.helpers import add_git_ceiling_directory _LOGGER = logging.getLogger(__name__) @@ -82,6 +83,11 @@ def _get_idf_env(version: str | None = None) -> dict[str, str]: env_cache[version] |= get_framework_env( *_get_esphome_esp_idf_paths(version) ) + + # Cap git's repo search at the config directory so ESP-IDF's + # `git describe` for the app version can't error out on an + # uninitialized or corrupt git repo in a parent directory. + add_git_ceiling_directory(env_cache[version], CORE.config_dir) return env_cache[version] diff --git a/esphome/helpers.py b/esphome/helpers.py index 733474c9c9..ef7e2d0b93 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import MutableMapping from contextlib import suppress import ipaddress import logging @@ -374,6 +375,26 @@ def is_ha_addon(): return get_bool_env("ESPHOME_IS_HA_ADDON") +def add_git_ceiling_directory(env: MutableMapping[str, str], directory: Path) -> None: + """Add ``directory`` to ``env``'s ``GIT_CEILING_DIRECTORIES`` list. + + Git stops walking up the directory tree to find a repository once it reaches + a ceiling directory, so this caps the search at ``directory`` (the ESPHome + project root). Without it, an uninitialized or corrupt git repo in a parent + directory makes the ``git describe`` that build toolchains run for the app + version error out and fail the whole build. + + ``GIT_CEILING_DIRECTORIES`` is an ``os.pathsep``-joined list of absolute + paths; any existing entries are preserved and duplicates are skipped. + """ + ceiling = str(directory) + existing = env.get("GIT_CEILING_DIRECTORIES", "") + parts = existing.split(os.pathsep) if existing else [] + if ceiling not in parts: + parts.append(ceiling) + env["GIT_CEILING_DIRECTORIES"] = os.pathsep.join(parts) + + def rmtree(path: Path | str) -> None: """Remove a directory tree, handling read-only files on Windows. diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index c81420e6ca..c97df812e3 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -7,6 +7,7 @@ import sys from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE from esphome.core import CORE, EsphomeError +from esphome.helpers import add_git_ceiling_directory from esphome.util import FlashImage, run_external_process _LOGGER = logging.getLogger(__name__) @@ -53,6 +54,10 @@ def run_platformio_cli(*args, **kwargs) -> str | int: os.environ.setdefault("PYTHONWARNINGS", "ignore::SyntaxWarning") # Increase uv retry count to handle transient network errors (default is 3) os.environ.setdefault("UV_HTTP_RETRIES", "10") + # Cap git's repo search at the config directory so the framework's build + # scripts running `git describe` for the app version can't error out on an + # uninitialized or corrupt git repo in a parent directory. + add_git_ceiling_directory(os.environ, CORE.config_dir) # Strip the Windows extended-length path prefix from sys.executable so it # doesn't propagate into PlatformIO's $PYTHONEXE and break SCons-emitted # command lines run through cmd.exe. diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 8849ea8bc8..b2309439f9 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -150,6 +150,20 @@ def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None: assert result == {"cxx_path": "regen"} +def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None: + """The IDF env caps git's upward search at the config directory. + + This stops ESP-IDF's `git describe` from walking into an uninitialized or + corrupt git repo in a parent directory and failing the build. + """ + toolchain._cache().env.clear() + # Set IDF_PATH so the framework-install branch is skipped. + with patch.dict(os.environ, {"IDF_PATH": str(setup_core)}): + env = toolchain._get_idf_env(version="5.5.4") + assert CORE.config_dir == setup_core + assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep) + + def test_get_core_framework_version_from_core_data(): """The version is read from CORE.data when validation populated it.""" from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index efc2d8e42a..70c4b90082 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -196,6 +196,33 @@ def test_is_ha_addon(monkeypatch, value, expected): assert actual == expected +def test_add_git_ceiling_directory_sets_when_unset(): + """An empty env gets GIT_CEILING_DIRECTORIES set to the directory.""" + env: dict[str, str] = {} + directory = Path("/home/user/config") + helpers.add_git_ceiling_directory(env, directory) + assert env["GIT_CEILING_DIRECTORIES"] == str(directory) + + +def test_add_git_ceiling_directory_appends_to_existing(): + """An existing value is preserved and the new directory is appended.""" + env = {"GIT_CEILING_DIRECTORIES": str(Path("/some/ceiling"))} + directory = Path("/home/user/config") + helpers.add_git_ceiling_directory(env, directory) + assert env["GIT_CEILING_DIRECTORIES"].split(os.pathsep) == [ + str(Path("/some/ceiling")), + str(directory), + ] + + +def test_add_git_ceiling_directory_skips_duplicate(): + """A directory already in the list is not appended again.""" + directory = Path("/home/user/config") + env = {"GIT_CEILING_DIRECTORIES": str(directory)} + helpers.add_git_ceiling_directory(env, directory) + assert env["GIT_CEILING_DIRECTORIES"] == str(directory) + + def test_walk_files(fixture_path): path = fixture_path / "helpers" diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index a37b19f584..568b43a259 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -304,6 +304,11 @@ def test_run_platformio_cli_sets_environment_variables( ) assert "PLATFORMIO_LIBDEPS_DIR" in os.environ assert "PYTHONWARNINGS" in os.environ + # Caps git's upward search at the config dir so an uninitialized or + # corrupt parent git repo can't break the framework's `git describe`. + assert str(CORE.config_dir) in os.environ["GIT_CEILING_DIRECTORIES"].split( + os.pathsep + ) # Check command was called correctly — runs PlatformIO as a subprocess # via the esphome.platformio.runner entry point. From 310baab5248a4fd28cc4c7941f2b716be63e3482 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:47:14 +1200 Subject: [PATCH 0419/1815] [docker] Bundle device-builder 1.0.1, make HA add-on builder-only (#16989) Co-authored-by: J. Nick Koston --- docker/Dockerfile | 5 +- docker/docker_entrypoint.sh | 8 ++ .../etc/cont-init.d/40-device-builder.sh | 22 ----- .../etc/nginx/includes/mime.types | 96 ------------------- .../etc/nginx/includes/proxy_params.conf | 16 ---- .../etc/nginx/includes/server_params.conf | 8 -- .../etc/nginx/includes/ssl_params.conf | 8 -- .../etc/nginx/includes/upstream.conf | 3 - docker/ha-addon-rootfs/etc/nginx/nginx.conf | 30 ------ .../etc/nginx/servers/.gitkeep | 1 - .../etc/nginx/templates/direct.gtpl | 28 ------ .../etc/nginx/templates/ingress.gtpl | 18 ---- .../s6-rc.d/discovery/dependencies.d/nginx | 0 .../etc/s6-overlay/s6-rc.d/discovery/run | 2 +- .../etc/s6-overlay/s6-rc.d/esphome/finish | 4 +- .../etc/s6-overlay/s6-rc.d/esphome/run | 15 +-- .../s6-rc.d/init-nginx/dependencies.d/base | 0 .../etc/s6-overlay/s6-rc.d/init-nginx/run | 35 ------- .../etc/s6-overlay/s6-rc.d/init-nginx/type | 1 - .../etc/s6-overlay/s6-rc.d/init-nginx/up | 1 - .../s6-rc.d/nginx/dependencies.d/esphome | 0 .../s6-rc.d/nginx/dependencies.d/init-nginx | 0 .../etc/s6-overlay/s6-rc.d/nginx/finish | 25 ----- .../etc/s6-overlay/s6-rc.d/nginx/run | 27 ------ .../etc/s6-overlay/s6-rc.d/nginx/type | 1 - .../s6-rc.d/user/contents.d/init-nginx | 0 .../s6-overlay/s6-rc.d/user/contents.d/nginx | 0 27 files changed, 20 insertions(+), 334 deletions(-) delete mode 100755 docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/mime.types delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/nginx.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep delete mode 100644 docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl delete mode 100644 docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/dependencies.d/nginx delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/dependencies.d/base delete mode 100755 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/run delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/type delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/up delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/dependencies.d/esphome delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/dependencies.d/init-nginx delete mode 100755 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/finish delete mode 100755 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-nginx delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/nginx diff --git a/docker/Dockerfile b/docker/Dockerfile index c360ae1a4a..c7634cf1c8 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ ARG BUILD_VERSION=dev -ARG BUILD_BASE_VERSION=2025.04.0 +ARG BUILD_BASE_VERSION=2026.06.0 ARG BUILD_TYPE=docker FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base-source-docker @@ -31,6 +31,9 @@ RUN \ uv pip install --no-cache-dir \ -r /requirements.txt +# Install the ESPHome Device Builder dashboard. +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.1 + RUN \ platformio settings set enable_telemetry No \ && platformio settings set check_platformio_interval 1000000 \ diff --git a/docker/docker_entrypoint.sh b/docker/docker_entrypoint.sh index 1b9224244c..18baf40c29 100755 --- a/docker/docker_entrypoint.sh +++ b/docker/docker_entrypoint.sh @@ -27,4 +27,12 @@ if [[ -d /build ]]; then export ESPHOME_BUILD_PATH=/build fi +# The default CMD is "dashboard /config". Route the dashboard to the new +# Device Builder, but pass every other subcommand (compile, run, config, +# logs, ...) straight through to the esphome CLI so direct CLI use keeps working. +if [[ "$1" == "dashboard" ]]; then + shift + exec esphome-device-builder "$@" +fi + exec esphome "$@" diff --git a/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh b/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh deleted file mode 100755 index b990469762..0000000000 --- a/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/with-contenv bashio -# ============================================================================== -# Installs the latest prerelease of esphome-device-builder when the -# `use_new_device_builder` config option is enabled. -# This is a temporary install-on-boot step until esphome-device-builder -# becomes a direct dependency of esphome. -# ============================================================================== - -if ! bashio::config.true 'use_new_device_builder'; then - exit 0 -fi - -bashio::log.info "Installing latest prerelease of esphome-device-builder..." -if command -v uv > /dev/null; then - uv pip install --system --no-cache-dir --prerelease=allow --upgrade \ - esphome-device-builder || - bashio::exit.nok "Failed installing esphome-device-builder." -else - pip install --no-cache-dir --pre --upgrade esphome-device-builder || - bashio::exit.nok "Failed installing esphome-device-builder." -fi -bashio::log.info "Installed esphome-device-builder." diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/mime.types b/docker/ha-addon-rootfs/etc/nginx/includes/mime.types deleted file mode 100644 index 7c7cdef2d1..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/mime.types +++ /dev/null @@ -1,96 +0,0 @@ -types { - text/html html htm shtml; - text/css css; - text/xml xml; - image/gif gif; - image/jpeg jpeg jpg; - application/javascript js; - application/atom+xml atom; - application/rss+xml rss; - - text/mathml mml; - text/plain txt; - text/vnd.sun.j2me.app-descriptor jad; - text/vnd.wap.wml wml; - text/x-component htc; - - image/png png; - image/svg+xml svg svgz; - image/tiff tif tiff; - image/vnd.wap.wbmp wbmp; - image/webp webp; - image/x-icon ico; - image/x-jng jng; - image/x-ms-bmp bmp; - - font/woff woff; - font/woff2 woff2; - - application/java-archive jar war ear; - application/json json; - application/mac-binhex40 hqx; - application/msword doc; - application/pdf pdf; - application/postscript ps eps ai; - application/rtf rtf; - application/vnd.apple.mpegurl m3u8; - application/vnd.google-earth.kml+xml kml; - application/vnd.google-earth.kmz kmz; - application/vnd.ms-excel xls; - application/vnd.ms-fontobject eot; - application/vnd.ms-powerpoint ppt; - application/vnd.oasis.opendocument.graphics odg; - application/vnd.oasis.opendocument.presentation odp; - application/vnd.oasis.opendocument.spreadsheet ods; - application/vnd.oasis.opendocument.text odt; - application/vnd.openxmlformats-officedocument.presentationml.presentation - pptx; - application/vnd.openxmlformats-officedocument.spreadsheetml.sheet - xlsx; - application/vnd.openxmlformats-officedocument.wordprocessingml.document - docx; - application/vnd.wap.wmlc wmlc; - application/x-7z-compressed 7z; - application/x-cocoa cco; - application/x-java-archive-diff jardiff; - application/x-java-jnlp-file jnlp; - application/x-makeself run; - application/x-perl pl pm; - application/x-pilot prc pdb; - application/x-rar-compressed rar; - application/x-redhat-package-manager rpm; - application/x-sea sea; - application/x-shockwave-flash swf; - application/x-stuffit sit; - application/x-tcl tcl tk; - application/x-x509-ca-cert der pem crt; - application/x-xpinstall xpi; - application/xhtml+xml xhtml; - application/xspf+xml xspf; - application/zip zip; - - application/octet-stream bin exe dll; - application/octet-stream deb; - application/octet-stream dmg; - application/octet-stream iso img; - application/octet-stream msi msp msm; - - audio/midi mid midi kar; - audio/mpeg mp3; - audio/ogg ogg; - audio/x-m4a m4a; - audio/x-realaudio ra; - - video/3gpp 3gpp 3gp; - video/mp2t ts; - video/mp4 mp4; - video/mpeg mpeg mpg; - video/quicktime mov; - video/webm webm; - video/x-flv flv; - video/x-m4v m4v; - video/x-mng mng; - video/x-ms-asf asx asf; - video/x-ms-wmv wmv; - video/x-msvideo avi; -} diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf deleted file mode 100644 index a1ebb5079a..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf +++ /dev/null @@ -1,16 +0,0 @@ -proxy_http_version 1.1; -proxy_ignore_client_abort off; -proxy_read_timeout 86400s; -proxy_redirect off; -proxy_send_timeout 86400s; -proxy_max_temp_file_size 0; - -proxy_set_header Accept-Encoding ""; -proxy_set_header Connection $connection_upgrade; -proxy_set_header Host $http_host; -proxy_set_header Upgrade $http_upgrade; -proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; -proxy_set_header X-Forwarded-Proto $scheme; -proxy_set_header X-NginX-Proxy true; -proxy_set_header X-Real-IP $remote_addr; -proxy_set_header Authorization ""; diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf deleted file mode 100644 index debdf83a8c..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf +++ /dev/null @@ -1,8 +0,0 @@ -root /dev/null; -server_name $hostname; - -client_max_body_size 512m; - -add_header X-Content-Type-Options nosniff; -add_header X-XSS-Protection "1; mode=block"; -add_header X-Robots-Tag none; diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf deleted file mode 100644 index e6789cbb9b..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf +++ /dev/null @@ -1,8 +0,0 @@ -ssl_protocols TLSv1.2 TLSv1.3; -ssl_prefer_server_ciphers off; -ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; -ssl_session_timeout 10m; -ssl_session_cache shared:SSL:10m; -ssl_session_tickets off; -ssl_stapling on; -ssl_stapling_verify on; diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf b/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf deleted file mode 100644 index 8e782bdc88..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf +++ /dev/null @@ -1,3 +0,0 @@ -upstream esphome { - server unix:/var/run/esphome.sock; -} diff --git a/docker/ha-addon-rootfs/etc/nginx/nginx.conf b/docker/ha-addon-rootfs/etc/nginx/nginx.conf deleted file mode 100644 index 497427596d..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/nginx.conf +++ /dev/null @@ -1,30 +0,0 @@ -daemon off; -user root; -pid /var/run/nginx.pid; -worker_processes 1; -error_log /proc/1/fd/1 error; -events { - worker_connections 1024; -} - -http { - include /etc/nginx/includes/mime.types; - - access_log off; - default_type application/octet-stream; - gzip on; - keepalive_timeout 65; - sendfile on; - server_tokens off; - - tcp_nodelay on; - tcp_nopush on; - - map $http_upgrade $connection_upgrade { - default upgrade; - '' close; - } - - include /etc/nginx/includes/upstream.conf; - include /etc/nginx/servers/*.conf; -} diff --git a/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep b/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep deleted file mode 100644 index 85ad51be5f..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -Without requirements or design, programming is the art of adding bugs to an empty text file. (Louis Srygley) diff --git a/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl b/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl deleted file mode 100644 index 4fb0ca3f90..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl +++ /dev/null @@ -1,28 +0,0 @@ -server { - {{ if not .ssl }} - listen 6052 default_server; - {{ else }} - listen 6052 default_server ssl http2; - {{ end }} - - include /etc/nginx/includes/server_params.conf; - include /etc/nginx/includes/proxy_params.conf; - - {{ if .ssl }} - include /etc/nginx/includes/ssl_params.conf; - - ssl_certificate /ssl/{{ .certfile }}; - ssl_certificate_key /ssl/{{ .keyfile }}; - - # Redirect http requests to https on the same port. - # https://rageagainstshell.com/2016/11/redirect-http-to-https-on-the-same-port-in-nginx/ - error_page 497 https://$http_host$request_uri; - {{ end }} - - # Clear Home Assistant Ingress header - proxy_set_header X-HA-Ingress ""; - - location / { - proxy_pass http://esphome; - } -} diff --git a/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl b/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl deleted file mode 100644 index 105ddde710..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl +++ /dev/null @@ -1,18 +0,0 @@ -server { - listen 127.0.0.1:{{ .port }} default_server; - listen {{ .interface }}:{{ .port }} default_server; - - include /etc/nginx/includes/server_params.conf; - include /etc/nginx/includes/proxy_params.conf; - - # Set Home Assistant Ingress header - proxy_set_header X-HA-Ingress "YES"; - - location / { - allow 172.30.32.2; - allow 127.0.0.1; - deny all; - - proxy_pass http://esphome; - } -} diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/dependencies.d/nginx b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/dependencies.d/nginx deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run index 111157d301..bb36cfcdb4 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run @@ -16,7 +16,7 @@ fi port=$(bashio::addon.ingress_port) -# Wait for NGINX to become available +# Wait for the ESPHome Device Builder to become available bashio::net.wait_for "${port}" "127.0.0.1" 300 config=$(\ diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish index 6e0f8fe23a..da450c25f9 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish @@ -2,7 +2,7 @@ # shellcheck shell=bash # ============================================================================== # Home Assistant Community Add-on: ESPHome -# Take down the S6 supervision tree when ESPHome dashboard fails +# Take down the S6 supervision tree when ESPHome Device Builder fails # ============================================================================== declare exit_code readonly exit_code_container=$( /run/s6-linux-init-container-results/exitcode - fi - [[ "${exit_code_signal}" -eq 15 ]] && exec /run/s6/basedir/bin/halt -elif [[ "${exit_code_service}" -ne 0 ]]; then - if [[ "${exit_code_container}" -eq 0 ]]; then - echo "${exit_code_service}" > /run/s6-linux-init-container-results/exitcode - fi - exec /run/s6/basedir/bin/halt -fi diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run deleted file mode 100755 index b8251e8e01..0000000000 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run +++ /dev/null @@ -1,27 +0,0 @@ -#!/command/with-contenv bashio -# shellcheck shell=bash -# ============================================================================== -# Community Hass.io Add-ons: ESPHome -# Runs the NGINX proxy -# ============================================================================== - -# The new device builder handles HA ingress itself, so nginx is bypassed. -# Block the longrun so s6 keeps the dependency satisfied, but exit 0 on -# SIGTERM instead of being signal-killed; a 256/15 exit makes nginx/finish -# stamp the container exit 143, which trips the Supervisor's SIGTERM check. -if bashio::config.true 'use_new_device_builder'; then - bashio::log.info "NGINX bypassed: new device builder serves ingress directly." - trap 'exit 0' TERM - sleep infinity & - wait - exit 0 -fi - -bashio::log.info "Waiting for ESPHome dashboard to come up..." - -while [[ ! -S /var/run/esphome.sock ]]; do - sleep 0.5 -done - -bashio::log.info "Starting NGINX..." -exec nginx diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type deleted file mode 100644 index 5883cff0cd..0000000000 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type +++ /dev/null @@ -1 +0,0 @@ -longrun diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-nginx b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-nginx deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/nginx b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/nginx deleted file mode 100644 index e69de29bb2..0000000000 From 53fd99578ae69088a4a93bc7fc8ad9b3f4932969 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:02:55 +1200 Subject: [PATCH 0420/1815] Bump version to 2026.6.0b3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 809f934797..c94ea34387 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.6.0b2 +PROJECT_NUMBER = 2026.6.0b3 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 27abfa2dd2..bf770ae5b5 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.6.0b2" +__version__ = "2026.6.0b3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From ce11d38c9bac04b1dfe5570cb289399baff6e6f0 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:53:11 -0400 Subject: [PATCH 0421/1815] [esp32_hosted] Bump esp_hosted to 2.12.9 (#16999) --- .clang-tidy.hash | 2 +- esphome/components/esp32_hosted/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 1f709bb90d..591ce70a62 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -007cddcd7aa933f0ff9b3fd65f0b7571579ac223d11c6117af2b291bd2f9fe74 +6765760d573967b853b1f790f0f5478135d12f2b15ffa8bee9b0314090b582ee diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 94e20ea6c9..7f420f27d8 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -257,7 +257,7 @@ async def to_code(config): esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.5.1") esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.2") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.8") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.9") else: esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 04220488cc..5f3000e52d 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -38,7 +38,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.12.8 + version: 2.12.9 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: From 29e8949e3e66c1cea04b5a16ab32b87eb539bd6f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:23:46 -0400 Subject: [PATCH 0422/1815] [ota] Scale ESP-IDF OTA erase watchdog to image size (#16998) --- esphome/components/ota/ota_backend_esp_idf.cpp | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index ade726da1f..ac765d8018 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -57,7 +57,18 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION; } - watchdog::WatchdogManager watchdog(15000); + // esp_ota_begin() erases the destination region, which blocks loopTask and + // scales with the erase size -- a fixed watchdog overruns on large OTA slots. + // An unknown size (0, e.g. web_server uploads) erases the whole partition, so + // budget against the bytes actually erased. ~10ms/KiB (conservative + // ~100 KiB/s erase) over a 15s floor; panic stays on so a stuck erase still + // resets rather than hanging forever. + size_t erase_size = image_size; + if (erase_size == 0 || erase_size > this->partition_->size) { + erase_size = this->partition_->size; + } + const uint32_t erase_budget_ms = 15000 + (erase_size >> 10) * 10; + watchdog::WatchdogManager watchdog(erase_budget_ms); esp_err_t err = esp_ota_begin(this->partition_, image_size, &this->update_handle_); if (err != ESP_OK) { From e80461eba972acaad9e0592a912948c9855e7f83 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:28:27 -0500 Subject: [PATCH 0423/1815] Bump bundled esphome-device-builder to 1.0.3 (#17005) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c7634cf1c8..8e7580490f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.3 RUN \ platformio settings set enable_telemetry No \ From 009c6dd9957df088017cae2e34617728f794d1d6 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:38:23 -0500 Subject: [PATCH 0424/1815] Bump bundled esphome-device-builder to 1.0.4 (#17013) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 8e7580490f..185a0740ed 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.3 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.4 RUN \ platformio settings set enable_telemetry No \ From 40d0cbee3fea57b43eb3dfd7d8181588b1efef56 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:07:36 -0500 Subject: [PATCH 0425/1815] Bump bundled esphome-device-builder to 1.0.5 (#17014) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 185a0740ed..980791013f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.4 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.5 RUN \ platformio settings set enable_telemetry No \ From 900e0b8566a535b58a4ce14a9b07c720bcedf71f Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:50:56 -0500 Subject: [PATCH 0426/1815] Bump bundled esphome-device-builder to 1.0.6 (#17016) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 980791013f..706dd93e67 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.5 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.6 RUN \ platformio settings set enable_telemetry No \ From 0f5defa67eebccbbca0b997d8e4fd3ec0192b8a1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:51:08 -0500 Subject: [PATCH 0427/1815] Bump tzlocal from 5.3.1 to 5.4.3 (#17015) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a825cd9bff..4ef3df60ff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ paho-mqtt==1.6.1 colorama==0.4.6 icmplib==3.0.4 tornado==6.5.7 -tzlocal==5.3.1 # from time +tzlocal==5.4.3 # from time tzdata>=2026.2 # from time pyserial==3.5 platformio==6.1.19 From 24e276c3f96a8a75eb1ca795e56eca8d90332625 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:53:11 -0400 Subject: [PATCH 0428/1815] [esp32_hosted] Bump esp_hosted to 2.12.9 (#16999) --- .clang-tidy.hash | 2 +- esphome/components/esp32_hosted/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 7a3cfc7a03..84daffc69f 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -34f6ce4a4775acf8c7201778f114b191f78269f232b67f01fed920f0cdf73686 +72f02816e288b68ff4ef4b3d6fb66432c893b187a80ad3ebaa29afa443ff9ea6 diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 94e20ea6c9..7f420f27d8 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -257,7 +257,7 @@ async def to_code(config): esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.5.1") esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.2") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.8") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.9") else: esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 04220488cc..5f3000e52d 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -38,7 +38,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.12.8 + version: 2.12.9 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: From 045de436ba8e5c002babc54fc8f42a2ee26cd0aa Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:23:46 -0400 Subject: [PATCH 0429/1815] [ota] Scale ESP-IDF OTA erase watchdog to image size (#16998) --- esphome/components/ota/ota_backend_esp_idf.cpp | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index ade726da1f..ac765d8018 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -57,7 +57,18 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION; } - watchdog::WatchdogManager watchdog(15000); + // esp_ota_begin() erases the destination region, which blocks loopTask and + // scales with the erase size -- a fixed watchdog overruns on large OTA slots. + // An unknown size (0, e.g. web_server uploads) erases the whole partition, so + // budget against the bytes actually erased. ~10ms/KiB (conservative + // ~100 KiB/s erase) over a 15s floor; panic stays on so a stuck erase still + // resets rather than hanging forever. + size_t erase_size = image_size; + if (erase_size == 0 || erase_size > this->partition_->size) { + erase_size = this->partition_->size; + } + const uint32_t erase_budget_ms = 15000 + (erase_size >> 10) * 10; + watchdog::WatchdogManager watchdog(erase_budget_ms); esp_err_t err = esp_ota_begin(this->partition_, image_size, &this->update_handle_); if (err != ESP_OK) { From 41f7f8cccb143b8c46ef1cd8d90c60184150f910 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:28:27 -0500 Subject: [PATCH 0430/1815] Bump bundled esphome-device-builder to 1.0.3 (#17005) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c7634cf1c8..8e7580490f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.3 RUN \ platformio settings set enable_telemetry No \ From cdd2bfbc609ec3d1cafb2a87a6340458f3a06b6b Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:38:23 -0500 Subject: [PATCH 0431/1815] Bump bundled esphome-device-builder to 1.0.4 (#17013) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 8e7580490f..185a0740ed 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.3 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.4 RUN \ platformio settings set enable_telemetry No \ From 7ab95ddcb1668db200ab76d5cfff39271330a0ad Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:50:56 -0500 Subject: [PATCH 0432/1815] Bump bundled esphome-device-builder to 1.0.6 (#17016) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 185a0740ed..706dd93e67 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.4 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.6 RUN \ platformio settings set enable_telemetry No \ From db6b9166f457ecb9041a85212cfbe425ec423272 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 18 Jun 2026 08:20:15 +1200 Subject: [PATCH 0433/1815] Bump version to 2026.6.0b4 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index c94ea34387..aab6c1000d 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.6.0b3 +PROJECT_NUMBER = 2026.6.0b4 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index bf770ae5b5..1386565d78 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.6.0b3" +__version__ = "2026.6.0b4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 6d9490b5a361459c1f5b1009e372a46a310fed9b Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:53:10 -0500 Subject: [PATCH 0434/1815] Bump bundled esphome-device-builder to 1.0.7 (#17018) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 706dd93e67..b48ba64aa8 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.6 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.7 RUN \ platformio settings set enable_telemetry No \ From 9e7b3e033084fe5cdd29d42c7439347394053678 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:18:37 +1200 Subject: [PATCH 0435/1815] Bump version to 2026.6.0 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index aab6c1000d..56879237d4 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.6.0b4 +PROJECT_NUMBER = 2026.6.0 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 1386565d78..c045e452f7 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.6.0b4" +__version__ = "2026.6.0" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From ae7c800de826aee6a0a8aa548c7c93c96dd484a8 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:32:37 -0500 Subject: [PATCH 0436/1815] Bump bundled esphome-device-builder to 1.0.8 (#17020) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index b48ba64aa8..c199f2edbd 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.7 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.8 RUN \ platformio settings set enable_telemetry No \ From 77a99bceb2739a3cd8e857e705fc9d22299c8ed9 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:38:39 -0500 Subject: [PATCH 0437/1815] Bump bundled esphome-device-builder to 1.0.9 (#17021) Co-authored-by: J. Nick Koston --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c199f2edbd..18a9903735 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.8 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.9 RUN \ platformio settings set enable_telemetry No \ From 9ac22f924405223df483927c5d9ab4b021e99db0 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:53:10 -0500 Subject: [PATCH 0438/1815] Bump bundled esphome-device-builder to 1.0.7 (#17018) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 706dd93e67..b48ba64aa8 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.6 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.7 RUN \ platformio settings set enable_telemetry No \ From c4076ec8a99c5781065477be90e7153fbd74f3dc Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:32:37 -0500 Subject: [PATCH 0439/1815] Bump bundled esphome-device-builder to 1.0.8 (#17020) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index b48ba64aa8..c199f2edbd 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.7 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.8 RUN \ platformio settings set enable_telemetry No \ From d934fb3910fc4d96806f4f29b3aff533b498a472 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:38:39 -0500 Subject: [PATCH 0440/1815] Bump bundled esphome-device-builder to 1.0.9 (#17021) Co-authored-by: J. Nick Koston --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c199f2edbd..18a9903735 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.8 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.9 RUN \ platformio settings set enable_telemetry No \ From 7cb6cf2f2a46436117c370ce303dda2055fc6e38 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:12:39 -0400 Subject: [PATCH 0441/1815] [ci] Replace clang-tidy hash with direct config-file diff check (#17019) --- .clang-tidy.hash | 1 - .github/workflows/ci-clang-tidy-hash.yml | 76 ----- .github/workflows/ci.yml | 38 +-- .pre-commit-config.yaml | 9 +- script/ci-custom.py | 2 +- script/clang_tidy_hash.py | 208 +++----------- script/determine-jobs.py | 65 ++--- tests/script/test_clang_tidy_hash.py | 351 +++-------------------- tests/script/test_determine_jobs.py | 48 ++-- 9 files changed, 124 insertions(+), 674 deletions(-) delete mode 100644 .clang-tidy.hash delete mode 100644 .github/workflows/ci-clang-tidy-hash.yml mode change 100755 => 100644 script/clang_tidy_hash.py diff --git a/.clang-tidy.hash b/.clang-tidy.hash deleted file mode 100644 index 591ce70a62..0000000000 --- a/.clang-tidy.hash +++ /dev/null @@ -1 +0,0 @@ -6765760d573967b853b1f790f0f5478135d12f2b15ffa8bee9b0314090b582ee diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml deleted file mode 100644 index 73c437467b..0000000000 --- a/.github/workflows/ci-clang-tidy-hash.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Clang-tidy Hash CI - -on: - pull_request: - paths: - - ".clang-tidy" - - "platformio.ini" - - "requirements_dev.txt" - - "sdkconfig.defaults" - - ".clang-tidy.hash" - - "script/clang_tidy_hash.py" - - ".github/workflows/ci-clang-tidy-hash.yml" - -permissions: - contents: read # actions/checkout for the PR head - pull-requests: write # pulls.createReview / listReviews / dismissReview when the clang-tidy hash is out of date - -jobs: - verify-hash: - name: Verify clang-tidy hash - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - with: - python-version: "3.11" - - - name: Verify hash - run: | - python script/clang_tidy_hash.py --verify - - - if: failure() - name: Show hash details - run: | - python script/clang_tidy_hash.py - echo "## Job Failed" | tee -a $GITHUB_STEP_SUMMARY - echo "You have modified clang-tidy configuration but have not updated the hash." | tee -a $GITHUB_STEP_SUMMARY - echo "Please run 'script/clang_tidy_hash.py --update' and commit the changes." | tee -a $GITHUB_STEP_SUMMARY - - - if: failure() && github.event.pull_request.head.repo.full_name == github.repository - name: Request changes - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - await github.rest.pulls.createReview({ - pull_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - event: 'REQUEST_CHANGES', - body: 'You have modified clang-tidy configuration but have not updated the hash.\nPlease run `script/clang_tidy_hash.py --update` and commit the changes.' - }) - - - if: success() && github.event.pull_request.head.repo.full_name == github.repository - name: Dismiss review - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - let reviews = await github.rest.pulls.listReviews({ - pull_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo - }); - for (let review of reviews.data) { - if (review.user.login === 'github-actions[bot]' && review.state === 'CHANGES_REQUESTED') { - await github.rest.pulls.dismissReview({ - pull_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - review_id: review.id, - message: 'Clang-tidy hash now matches configuration.' - }); - } - } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index deeec72095..1b1032bcde 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -537,15 +537,12 @@ jobs: id: check_full_scan run: | . venv/bin/activate - # determine-jobs.clang-tidy-full-scan is true when core C++ changed - # OR the ci-run-all label forced --force-all. Independent of the - # hash check, both must produce a full scan in the job itself. + # determine-jobs.clang-tidy-full-scan is true when core C++ or a + # clang-tidy-relevant config file changed, or the ci-run-all label + # forced --force-all. if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then echo "full_scan=true" >> $GITHUB_OUTPUT echo "reason=determine_jobs" >> $GITHUB_OUTPUT - elif python script/clang_tidy_hash.py --check; then - echo "full_scan=true" >> $GITHUB_OUTPUT - echo "reason=hash_changed" >> $GITHUB_OUTPUT else echo "full_scan=false" >> $GITHUB_OUTPUT echo "reason=normal" >> $GITHUB_OUTPUT @@ -607,15 +604,12 @@ jobs: id: check_full_scan run: | . venv/bin/activate - # determine-jobs.clang-tidy-full-scan is true when core C++ changed - # OR the ci-run-all label forced --force-all. Independent of the - # hash check, both must produce a full scan in the job itself. + # determine-jobs.clang-tidy-full-scan is true when core C++ or a + # clang-tidy-relevant config file changed, or the ci-run-all label + # forced --force-all. if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then echo "full_scan=true" >> $GITHUB_OUTPUT echo "reason=determine_jobs" >> $GITHUB_OUTPUT - elif python script/clang_tidy_hash.py --check; then - echo "full_scan=true" >> $GITHUB_OUTPUT - echo "reason=hash_changed" >> $GITHUB_OUTPUT else echo "full_scan=false" >> $GITHUB_OUTPUT echo "reason=normal" >> $GITHUB_OUTPUT @@ -691,15 +685,12 @@ jobs: id: check_full_scan run: | . venv/bin/activate - # determine-jobs.clang-tidy-full-scan is true when core C++ changed - # OR the ci-run-all label forced --force-all. Independent of the - # hash check, both must produce a full scan in the job itself. + # determine-jobs.clang-tidy-full-scan is true when core C++ or a + # clang-tidy-relevant config file changed, or the ci-run-all label + # forced --force-all. if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then echo "full_scan=true" >> $GITHUB_OUTPUT echo "reason=determine_jobs" >> $GITHUB_OUTPUT - elif python script/clang_tidy_hash.py --check; then - echo "full_scan=true" >> $GITHUB_OUTPUT - echo "reason=hash_changed" >> $GITHUB_OUTPUT else echo "full_scan=false" >> $GITHUB_OUTPUT echo "reason=normal" >> $GITHUB_OUTPUT @@ -779,15 +770,12 @@ jobs: id: check_full_scan run: | . venv/bin/activate - # determine-jobs.clang-tidy-full-scan is true when core C++ changed - # OR the ci-run-all label forced --force-all. Independent of the - # hash check, both must produce a full scan in the job itself. + # determine-jobs.clang-tidy-full-scan is true when core C++ or a + # clang-tidy-relevant config file changed, or the ci-run-all label + # forced --force-all. if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then echo "full_scan=true" >> $GITHUB_OUTPUT echo "reason=determine_jobs" >> $GITHUB_OUTPUT - elif python script/clang_tidy_hash.py --check; then - echo "full_scan=true" >> $GITHUB_OUTPUT - echo "reason=hash_changed" >> $GITHUB_OUTPUT else echo "full_scan=false" >> $GITHUB_OUTPUT echo "reason=normal" >> $GITHUB_OUTPUT @@ -1049,7 +1037,7 @@ jobs: cache-key: ${{ needs.common.outputs.cache-key }} - uses: esphome/pre-commit-action@43cd1109c09c544d97196f7730ee5b2e0cc6d81e # v3.0.1 fork with pinned actions/cache env: - SKIP: pylint,clang-tidy-hash,ci-custom + SKIP: pylint,ci-custom - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 if: always() diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3b6278e6b5..ba74aff07c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,7 @@ ci: autoupdate_commit_msg: 'pre-commit: autoupdate' autoupdate_schedule: off # Disabled until ruff versions are synced between deps and pre-commit # Skip hooks that have issues in pre-commit CI environment - skip: [pylint, clang-tidy-hash] + skip: [pylint] repos: - repo: https://github.com/astral-sh/ruff-pre-commit @@ -59,13 +59,6 @@ repos: language: system types: [python] files: ^esphome/.+\.py$ - - id: clang-tidy-hash - name: Update clang-tidy hash - entry: python script/clang_tidy_hash.py --update-if-changed - language: python - files: ^(\.clang-tidy|platformio\.ini|requirements_dev\.txt|sdkconfig\.defaults|esphome/idf_component\.yml)$ - pass_filenames: false - additional_dependencies: [] - id: ci-custom name: ci-custom entry: python script/run-in-env.py script/ci-custom.py diff --git a/script/ci-custom.py b/script/ci-custom.py index 78ff6cf781..cbc54ce55d 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -276,7 +276,7 @@ def lint_newline(fname, line, col, content): return "File contains Windows newline. Please set your editor to Unix newline mode." -@lint_content_check(exclude=["*.svg", ".clang-tidy.hash"]) +@lint_content_check(exclude=["*.svg"]) def lint_end_newline(fname, content): if content and not content.endswith("\n"): return "File does not end with a newline, please add an empty line at the end of the file." diff --git a/script/clang_tidy_hash.py b/script/clang_tidy_hash.py old mode 100755 new mode 100644 index 62f76246b4..00bcaf45b0 --- a/script/clang_tidy_hash.py +++ b/script/clang_tidy_hash.py @@ -1,66 +1,32 @@ -#!/usr/bin/env python3 -"""Calculate and manage hash for clang-tidy configuration.""" +"""Files that affect clang-tidy results, and a content hash over them. + +``CLANG_TIDY_GLOBAL_FILES`` (plus ``SDKCONFIG_DEFAULTS_PREFIX``) is the single +source of truth for which files influence clang-tidy output. A change to any of +them can surface warnings in source files a PR didn't touch, so: + +* ``script/determine-jobs.py`` runs a full clang-tidy scan when one changes, and +* ``calculate_clang_tidy_hash()`` folds them into the idedata cache key used by + ``script/helpers.py`` (a content hash, unlike an mtime check, stays correct + across git checkouts). +""" from __future__ import annotations -import argparse import hashlib from pathlib import Path -import re -import sys -# Add the script directory to path to import helpers -script_dir = Path(__file__).parent -sys.path.insert(0, str(script_dir)) +# Root-relative paths whose contents affect clang-tidy results. +CLANG_TIDY_GLOBAL_FILES = ( + ".clang-tidy", + "platformio.ini", + "requirements_dev.txt", + "esphome/idf_component.yml", +) - -def read_file_lines(path: Path) -> list[str]: - """Read lines from a file.""" - with path.open() as f: - return f.readlines() - - -def parse_requirement_line(line: str) -> tuple[str, str] | None: - """Parse a requirement line and return (package, original_line) or None. - - Handles formats like: - - package==1.2.3 - - package==1.2.3 # comment - - package>=1.2.3,<2.0.0 - """ - original_line = line.strip() - - # Extract the part before any comment for parsing - parse_line = line - if "#" in parse_line: - parse_line = parse_line[: parse_line.index("#")] - - parse_line = parse_line.strip() - if not parse_line: - return None - - # Use regex to extract package name - # This matches package names followed by version operators - match = re.match(r"^([a-zA-Z0-9_-]+)(==|>=|<=|>|<|!=|~=)(.+)$", parse_line) - if match: - return (match.group(1), original_line) # Return package name and original line - - return None - - -def get_clang_tidy_version_from_requirements(repo_root: Path | None = None) -> str: - """Get clang-tidy version from requirements_dev.txt""" - repo_root = _ensure_repo_root(repo_root) - requirements_path = repo_root / "requirements_dev.txt" - lines = read_file_lines(requirements_path) - - for line in lines: - parsed = parse_requirement_line(line) - if parsed and parsed[0] == "clang-tidy": - # Return the original line (preserves comments) - return parsed[1] - - return "clang-tidy version not found" +# sdkconfig.defaults and per-target sdkconfig.defaults. files flip the +# CONFIG flags that decide which variant code paths clang-tidy sees. Matched by +# this prefix at the repo root. +SDKCONFIG_DEFAULTS_PREFIX = "sdkconfig.defaults" def read_file_bytes(path: Path) -> bytes: @@ -80,130 +46,20 @@ def _ensure_repo_root(repo_root: Path | None) -> Path: def calculate_clang_tidy_hash(repo_root: Path | None = None) -> str: - """Calculate hash of clang-tidy configuration and version""" + """Calculate a hash of the files that affect clang-tidy results.""" repo_root = _ensure_repo_root(repo_root) hasher = hashlib.sha256() - # Hash .clang-tidy file - clang_tidy_path = repo_root / ".clang-tidy" - content = read_file_bytes(clang_tidy_path) - hasher.update(content) + for name in CLANG_TIDY_GLOBAL_FILES: + path = repo_root / name + if path.exists(): + hasher.update(read_file_bytes(path)) - # Hash clang-tidy version from requirements_dev.txt - version = get_clang_tidy_version_from_requirements(repo_root) - hasher.update(version.encode()) - - # Hash the entire platformio.ini file - platformio_path = repo_root / "platformio.ini" - platformio_content = read_file_bytes(platformio_path) - hasher.update(platformio_content) - - # Hash sdkconfig.defaults and any per-target sdkconfig.defaults.: - # the per-target files flip CONFIG flags that change which variant code - # paths clang-tidy sees. Include the filename so a rename is detected. - for sdkconfig_path in sorted(repo_root.glob("sdkconfig.defaults*")): - hasher.update(sdkconfig_path.name.encode()) - hasher.update(read_file_bytes(sdkconfig_path)) - - # Hash esphome/idf_component.yml: its managed deps drive the ESP-IDF - # build's include set, which clang-tidy analyzes. - idf_component_path = repo_root / "esphome" / "idf_component.yml" - if idf_component_path.exists(): - hasher.update(read_file_bytes(idf_component_path)) + # Hash each sdkconfig.defaults* file. Include the filename so adding or + # renaming a per-target variant is detected, not just content edits. + for path in sorted(repo_root.glob(f"{SDKCONFIG_DEFAULTS_PREFIX}*")): + hasher.update(path.name.encode()) + hasher.update(read_file_bytes(path)) return hasher.hexdigest() - - -def read_stored_hash(repo_root: Path | None = None) -> str | None: - """Read the stored hash from file""" - repo_root = _ensure_repo_root(repo_root) - hash_file = repo_root / ".clang-tidy.hash" - if hash_file.exists(): - lines = read_file_lines(hash_file) - return lines[0].strip() if lines else None - return None - - -def write_file_content(path: Path, content: str) -> None: - """Write content to a file.""" - with path.open("w") as f: - f.write(content) - - -def write_hash(hash_value: str, repo_root: Path | None = None) -> None: - """Write hash to file""" - repo_root = _ensure_repo_root(repo_root) - hash_file = repo_root / ".clang-tidy.hash" - # Strip any trailing newlines to ensure consistent formatting - write_file_content(hash_file, hash_value.strip() + "\n") - - -def main() -> None: - parser = argparse.ArgumentParser(description="Manage clang-tidy configuration hash") - parser.add_argument( - "--check", - action="store_true", - help="Check if full scan needed (exit 0 if needed)", - ) - parser.add_argument("--update", action="store_true", help="Update the hash file") - parser.add_argument( - "--update-if-changed", - action="store_true", - help="Update hash only if configuration changed (for pre-commit)", - ) - parser.add_argument( - "--verify", action="store_true", help="Verify hash matches (for CI)" - ) - - args = parser.parse_args() - - current_hash = calculate_clang_tidy_hash() - stored_hash = read_stored_hash() - - if args.check: - # Check if hash changed OR if .clang-tidy.hash was updated in this PR - # This is used in CI to determine if a full clang-tidy scan is needed - hash_changed = current_hash != stored_hash - - # Lazy import to avoid requiring dependencies that aren't needed for other modes - from helpers import changed_files # noqa: E402 - - hash_file_updated = ".clang-tidy.hash" in changed_files() - - # Exit 0 if full scan needed - sys.exit(0 if (hash_changed or hash_file_updated) else 1) - - elif args.verify: - # Verify that hash file is up to date with current configuration - # This is used in pre-commit and CI checks to ensure hash was updated - if current_hash != stored_hash: - print("ERROR: Clang-tidy configuration has changed but hash not updated!") - print(f"Expected: {current_hash}") - print(f"Found: {stored_hash}") - print("\nPlease run: script/clang_tidy_hash.py --update") - sys.exit(1) - print("Hash verification passed") - - elif args.update: - write_hash(current_hash) - print(f"Hash updated: {current_hash}") - - elif args.update_if_changed: - if current_hash != stored_hash: - write_hash(current_hash) - print(f"Clang-tidy hash updated: {current_hash}") - # Exit 0 so pre-commit can stage the file - sys.exit(0) - else: - print("Clang-tidy hash unchanged") - sys.exit(0) - - else: - print(f"Current hash: {current_hash}") - print(f"Stored hash: {stored_hash}") - print(f"Match: {current_hash == stored_hash}") - - -if __name__ == "__main__": - main() diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 94a78e8423..4904883ca9 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -55,10 +55,10 @@ from functools import cache import json import os from pathlib import Path -import subprocess import sys from typing import Any +from clang_tidy_hash import CLANG_TIDY_GLOBAL_FILES, SDKCONFIG_DEFAULTS_PREFIX from helpers import ( CPP_FILE_EXTENSIONS, ESPHOME_TESTS_COMPONENTS_PATH, @@ -280,23 +280,22 @@ def determine_integration_tests(branch: str | None = None) -> tuple[bool, list[s @cache -def _is_clang_tidy_full_scan() -> bool: - """Check if clang-tidy configuration changed (requires full scan). +def _is_clang_tidy_full_scan(branch: str | None = None) -> bool: + """Check if a clang-tidy-relevant config file changed (requires full scan). + + A change to a file that affects clang-tidy globally can surface warnings in + source files the PR didn't touch, so the entire codebase must be re-scanned. Returns: - True if full scan is needed (hash changed), False otherwise. + True if full scan is needed, False otherwise. """ - try: - result = subprocess.run( - [str(Path(root_path) / "script" / "clang_tidy_hash.py"), "--check"], - capture_output=True, - check=False, - ) - # Exit 0 means hash changed (full scan needed) - return result.returncode == 0 - except Exception: # noqa: BLE001 - # If hash check fails, run full scan to be safe - return True + for file in changed_files(branch): + if file in CLANG_TIDY_GLOBAL_FILES: + return True + # Root-level sdkconfig.defaults and per-target sdkconfig.defaults. + if "/" not in file and file.startswith(SDKCONFIG_DEFAULTS_PREFIX): + return True + return False def should_run_clang_tidy(branch: str | None = None) -> bool: @@ -307,13 +306,12 @@ def should_run_clang_tidy(branch: str | None = None) -> bool: Clang-tidy will run when ANY of the following conditions are met: - 1. Clang-tidy configuration changed - - The hash of .clang-tidy configuration file has changed - - The hash includes the .clang-tidy file, clang-tidy version from requirements_dev.txt, - and relevant platformio.ini sections - - When configuration changes, a full scan is needed to ensure all code complies - with the new rules - - Detected by script/clang_tidy_hash.py --check returning exit code 0 + 1. A clang-tidy-relevant config file changed (full scan needed) + - Any file in CLANG_TIDY_GLOBAL_FILES (.clang-tidy, platformio.ini, + requirements_dev.txt, esphome/idf_component.yml) or a root-level + sdkconfig.defaults* file + - These affect clang-tidy results globally, so all code must be re-checked + to ensure it still complies 2. Any C++ source files changed - Any file with C++ extensions: .cpp, .h, .hpp, .cc, .cxx, .c, .tcc @@ -321,27 +319,14 @@ def should_run_clang_tidy(branch: str | None = None) -> bool: - This ensures all C++ code is checked, including tests, examples, etc. - Examples: esphome/core/component.cpp, tests/custom/my_component.h - 3. The .clang-tidy.hash file itself changed - - This indicates the configuration has been updated and clang-tidy should run - - Ensures that PRs updating the clang-tidy configuration are properly validated - - If the hash check fails for any reason, clang-tidy runs as a safety measure to ensure - code quality is maintained. - Args: branch: Branch to compare against. If None, uses default. Returns: True if clang-tidy should run, False otherwise. """ - # First check if clang-tidy configuration changed (full scan needed) - if _is_clang_tidy_full_scan(): - return True - - # Check if .clang-tidy.hash file itself was changed - # This handles the case where the hash was properly updated in the PR - files = changed_files(branch) - if ".clang-tidy.hash" in files: + # First check if a clang-tidy-relevant config file changed (full scan needed) + if _is_clang_tidy_full_scan(branch): return True return _any_changed_file_endswith(branch, CPP_FILE_EXTENSIONS) @@ -1276,9 +1261,9 @@ def main() -> None: # Determine clang-tidy mode based on actual files that will be checked is_full_scan = False if run_clang_tidy: - # Full scan needed if: hash changed OR core files changed - # (is_core_change is forced True under --force-all) - is_full_scan = _is_clang_tidy_full_scan() or is_core_change + # Full scan needed if: a clang-tidy-relevant config file changed OR + # core files changed (is_core_change is forced True under --force-all) + is_full_scan = _is_clang_tidy_full_scan(args.branch) or is_core_change if is_full_scan: # Full scan checks all files - always use split mode for efficiency diff --git a/tests/script/test_clang_tidy_hash.py b/tests/script/test_clang_tidy_hash.py index 194926a5df..b5a9d8ebe9 100644 --- a/tests/script/test_clang_tidy_hash.py +++ b/tests/script/test_clang_tidy_hash.py @@ -1,9 +1,7 @@ """Unit tests for script/clang_tidy_hash.py module.""" -import hashlib from pathlib import Path import sys -from unittest.mock import Mock, patch import pytest @@ -11,76 +9,45 @@ import pytest sys.path.insert(0, str(Path(__file__).parent.parent.parent / "script")) import clang_tidy_hash # noqa: E402 +from clang_tidy_hash import CLANG_TIDY_GLOBAL_FILES # noqa: E402 -@pytest.mark.parametrize( - ("file_content", "expected"), - [ - ( - "clang-tidy==18.1.5 # via -r requirements_dev.in\n", - "clang-tidy==18.1.5 # via -r requirements_dev.in", - ), - ( - "other-package==1.0\nclang-tidy==17.0.0\nmore-packages==2.0\n", - "clang-tidy==17.0.0", - ), - ( - "# comment\nclang-tidy==16.0.0 # some comment\n", - "clang-tidy==16.0.0 # some comment", - ), - ("no-clang-tidy-here==1.0\n", "clang-tidy version not found"), - ], -) -def test_get_clang_tidy_version_from_requirements( - file_content: str, expected: str +def _populate(repo_root: Path) -> None: + """Create every clang-tidy global file plus a base sdkconfig.defaults.""" + for name in CLANG_TIDY_GLOBAL_FILES: + path = repo_root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"contents of {name}\n") + (repo_root / "sdkconfig.defaults").write_text("CONFIG_BASE=y\n") + + +def test_calculate_clang_tidy_hash_is_deterministic(tmp_path: Path) -> None: + """Same inputs must produce the same hash.""" + _populate(tmp_path) + assert clang_tidy_hash.calculate_clang_tidy_hash( + repo_root=tmp_path + ) == clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) + + +@pytest.mark.parametrize("filename", CLANG_TIDY_GLOBAL_FILES) +def test_calculate_clang_tidy_hash_changes_with_each_global_file( + tmp_path: Path, filename: str ) -> None: - """Test extracting clang-tidy version from various file formats.""" - # Mock read_file_lines to return our test content - with patch("clang_tidy_hash.read_file_lines") as mock_read: - mock_read.return_value = file_content.splitlines(keepends=True) + """Editing any global file must change the hash.""" + _populate(tmp_path) + before = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) - result = clang_tidy_hash.get_clang_tidy_version_from_requirements() + (tmp_path / filename).write_text("changed\n") + after = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) - assert result == expected - - -def test_calculate_clang_tidy_hash_with_sdkconfig(tmp_path: Path) -> None: - """Test calculating hash from all configuration sources including sdkconfig.defaults.""" - clang_tidy_content = b"Checks: '-*,readability-*'\n" - requirements_version = "clang-tidy==18.1.5" - platformio_content = b"[env:esp32]\nplatform = espressif32\n" - sdkconfig_content = b"" - requirements_content = "clang-tidy==18.1.5\n" - - # Create temporary files - (tmp_path / ".clang-tidy").write_bytes(clang_tidy_content) - (tmp_path / "platformio.ini").write_bytes(platformio_content) - (tmp_path / "sdkconfig.defaults").write_bytes(sdkconfig_content) - (tmp_path / "requirements_dev.txt").write_text(requirements_content) - - # Expected hash calculation - expected_hasher = hashlib.sha256() - expected_hasher.update(clang_tidy_content) - expected_hasher.update(requirements_version.encode()) - expected_hasher.update(platformio_content) - expected_hasher.update(b"sdkconfig.defaults") - expected_hasher.update(sdkconfig_content) - expected_hash = expected_hasher.hexdigest() - - result = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) - - assert result == expected_hash + assert after != before def test_calculate_clang_tidy_hash_includes_per_target_sdkconfig( tmp_path: Path, ) -> None: """Per-target sdkconfig.defaults. files must be part of the hash.""" - (tmp_path / ".clang-tidy").write_bytes(b"Checks: '-*'\n") - (tmp_path / "platformio.ini").write_bytes(b"[env:esp32]\n") - (tmp_path / "requirements_dev.txt").write_text("clang-tidy==18.1.5\n") - (tmp_path / "sdkconfig.defaults").write_bytes(b"CONFIG_BASE=y\n") - + _populate(tmp_path) before = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) # Adding a per-target file must change the hash. @@ -95,230 +62,14 @@ def test_calculate_clang_tidy_hash_includes_per_target_sdkconfig( assert after_edit != after_add -def test_calculate_clang_tidy_hash_without_sdkconfig(tmp_path: Path) -> None: - """Test calculating hash without sdkconfig.defaults file.""" - clang_tidy_content = b"Checks: '-*,readability-*'\n" - requirements_version = "clang-tidy==18.1.5" - platformio_content = b"[env:esp32]\nplatform = espressif32\n" - requirements_content = "clang-tidy==18.1.5\n" - - # Create temporary files (without sdkconfig.defaults) - (tmp_path / ".clang-tidy").write_bytes(clang_tidy_content) - (tmp_path / "platformio.ini").write_bytes(platformio_content) - (tmp_path / "requirements_dev.txt").write_text(requirements_content) - - # Expected hash calculation (no sdkconfig) - expected_hasher = hashlib.sha256() - expected_hasher.update(clang_tidy_content) - expected_hasher.update(requirements_version.encode()) - expected_hasher.update(platformio_content) - expected_hash = expected_hasher.hexdigest() - +def test_calculate_clang_tidy_hash_handles_missing_optional_files( + tmp_path: Path, +) -> None: + """Hash calculation must not fail when files are absent.""" + # Only .clang-tidy present; everything else missing. + (tmp_path / ".clang-tidy").write_text("Checks: '-*'\n") result = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) - - assert result == expected_hash - - -def test_read_stored_hash_exists(tmp_path: Path) -> None: - """Test reading hash when file exists.""" - stored_hash = "abc123def456" - hash_file = tmp_path / ".clang-tidy.hash" - hash_file.write_text(f"{stored_hash}\n") - - result = clang_tidy_hash.read_stored_hash(repo_root=tmp_path) - - assert result == stored_hash - - -def test_read_stored_hash_not_exists(tmp_path: Path) -> None: - """Test reading hash when file doesn't exist.""" - result = clang_tidy_hash.read_stored_hash(repo_root=tmp_path) - - assert result is None - - -def test_write_hash(tmp_path: Path) -> None: - """Test writing hash to file.""" - hash_value = "abc123def456" - hash_file = tmp_path / ".clang-tidy.hash" - - clang_tidy_hash.write_hash(hash_value, repo_root=tmp_path) - - assert hash_file.exists() - assert hash_file.read_text() == hash_value.strip() + "\n" - - -@pytest.mark.parametrize( - ("args", "current_hash", "stored_hash", "hash_file_in_changed", "expected_exit"), - [ - (["--check"], "abc123", "abc123", False, 1), # Hashes match, no scan needed - (["--check"], "abc123", "def456", False, 0), # Hashes differ, scan needed - (["--check"], "abc123", None, False, 0), # No stored hash, scan needed - ( - ["--check"], - "abc123", - "abc123", - True, - 0, - ), # Hash file updated in PR, scan needed - ], -) -def test_main_check_mode( - args: list[str], - current_hash: str, - stored_hash: str | None, - hash_file_in_changed: bool, - expected_exit: int, -) -> None: - """Test main function in check mode.""" - changed = [".clang-tidy.hash"] if hash_file_in_changed else [] - - # Create a mock module that can be imported - mock_helpers = Mock() - mock_helpers.changed_files = Mock(return_value=changed) - - with ( - patch("sys.argv", ["clang_tidy_hash.py"] + args), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), - patch.dict("sys.modules", {"helpers": mock_helpers}), - pytest.raises(SystemExit) as exc_info, - ): - clang_tidy_hash.main() - - assert exc_info.value.code == expected_exit - - -def test_main_update_mode(capsys: pytest.CaptureFixture[str]) -> None: - """Test main function in update mode.""" - current_hash = "abc123" - - with ( - patch("sys.argv", ["clang_tidy_hash.py", "--update"]), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.write_hash") as mock_write, - ): - clang_tidy_hash.main() - - mock_write.assert_called_once_with(current_hash) - captured = capsys.readouterr() - assert f"Hash updated: {current_hash}" in captured.out - - -@pytest.mark.parametrize( - ("current_hash", "stored_hash"), - [ - ("abc123", "def456"), # Hash changed, should update - ("abc123", None), # No stored hash, should update - ], -) -def test_main_update_if_changed_mode_update( - current_hash: str, stored_hash: str | None, capsys: pytest.CaptureFixture[str] -) -> None: - """Test main function in update-if-changed mode when update is needed.""" - with ( - patch("sys.argv", ["clang_tidy_hash.py", "--update-if-changed"]), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), - patch("clang_tidy_hash.write_hash") as mock_write, - pytest.raises(SystemExit) as exc_info, - ): - clang_tidy_hash.main() - - assert exc_info.value.code == 0 - mock_write.assert_called_once_with(current_hash) - captured = capsys.readouterr() - assert "Clang-tidy hash updated" in captured.out - - -def test_main_update_if_changed_mode_no_update( - capsys: pytest.CaptureFixture[str], -) -> None: - """Test main function in update-if-changed mode when no update is needed.""" - current_hash = "abc123" - stored_hash = "abc123" - - with ( - patch("sys.argv", ["clang_tidy_hash.py", "--update-if-changed"]), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), - patch("clang_tidy_hash.write_hash") as mock_write, - pytest.raises(SystemExit) as exc_info, - ): - clang_tidy_hash.main() - - assert exc_info.value.code == 0 - mock_write.assert_not_called() - captured = capsys.readouterr() - assert "Clang-tidy hash unchanged" in captured.out - - -def test_main_verify_mode_success(capsys: pytest.CaptureFixture[str]) -> None: - """Test main function in verify mode when verification passes.""" - current_hash = "abc123" - stored_hash = "abc123" - - with ( - patch("sys.argv", ["clang_tidy_hash.py", "--verify"]), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), - ): - clang_tidy_hash.main() - captured = capsys.readouterr() - assert "Hash verification passed" in captured.out - - -@pytest.mark.parametrize( - ("current_hash", "stored_hash"), - [ - ("abc123", "def456"), # Hashes differ, verification fails - ("abc123", None), # No stored hash, verification fails - ], -) -def test_main_verify_mode_failure( - current_hash: str, stored_hash: str | None, capsys: pytest.CaptureFixture[str] -) -> None: - """Test main function in verify mode when verification fails.""" - with ( - patch("sys.argv", ["clang_tidy_hash.py", "--verify"]), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), - pytest.raises(SystemExit) as exc_info, - ): - clang_tidy_hash.main() - - assert exc_info.value.code == 1 - captured = capsys.readouterr() - assert "ERROR: Clang-tidy configuration has changed" in captured.out - - -def test_main_default_mode(capsys: pytest.CaptureFixture[str]) -> None: - """Test main function in default mode (no arguments).""" - current_hash = "abc123" - stored_hash = "def456" - - with ( - patch("sys.argv", ["clang_tidy_hash.py"]), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), - ): - clang_tidy_hash.main() - - captured = capsys.readouterr() - assert f"Current hash: {current_hash}" in captured.out - assert f"Stored hash: {stored_hash}" in captured.out - assert "Match: False" in captured.out - - -def test_read_file_lines(tmp_path: Path) -> None: - """Test read_file_lines helper function.""" - test_file = tmp_path / "test.txt" - test_content = "line1\nline2\nline3\n" - test_file.write_text(test_content) - - result = clang_tidy_hash.read_file_lines(test_file) - - assert result == ["line1\n", "line2\n", "line3\n"] + assert len(result) == 64 # sha256 hexdigest length def test_read_file_bytes(tmp_path: Path) -> None: @@ -330,35 +81,3 @@ def test_read_file_bytes(tmp_path: Path) -> None: result = clang_tidy_hash.read_file_bytes(test_file) assert result == test_content - - -def test_write_file_content(tmp_path: Path) -> None: - """Test write_file_content helper function.""" - test_file = tmp_path / "test.txt" - test_content = "test content" - - clang_tidy_hash.write_file_content(test_file, test_content) - - assert test_file.read_text() == test_content - - -@pytest.mark.parametrize( - ("line", "expected"), - [ - ("clang-tidy==18.1.5", ("clang-tidy", "clang-tidy==18.1.5")), - ( - "clang-tidy==18.1.5 # comment", - ("clang-tidy", "clang-tidy==18.1.5 # comment"), - ), - ("some-package>=1.0,<2.0", ("some-package", "some-package>=1.0,<2.0")), - ("pkg_with-dashes==1.0", ("pkg_with-dashes", "pkg_with-dashes==1.0")), - ("# just a comment", None), - ("", None), - (" ", None), - ("invalid line without version", None), - ], -) -def test_parse_requirement_line(line: str, expected: tuple[str, str] | None) -> None: - """Test parsing individual requirement lines.""" - result = clang_tidy_hash.parse_requirement_line(line) - assert result == expected diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index a9defcacac..f8f359ee22 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -5,7 +5,7 @@ import importlib.util import json from pathlib import Path import sys -from unittest.mock import Mock, call, patch +from unittest.mock import Mock, patch import pytest @@ -653,52 +653,38 @@ def test_determine_integration_tests_non_yaml_fixture_runs_all() -> None: @pytest.mark.parametrize( - ("check_returncode", "changed_files", "expected_result"), + ("changed_files", "expected_result"), [ - (0, [], True), # Hash changed - need full scan - (1, ["esphome/core.cpp"], True), # C++ file changed - (1, ["README.md"], False), # No C++ files changed - (1, [".clang-tidy.hash"], True), # Hash file itself changed - (1, ["platformio.ini", ".clang-tidy.hash"], True), # Config + hash changed + ([], False), # Nothing changed + (["esphome/core.cpp"], True), # C++ file changed + (["README.md"], False), # No C++ files changed + ([".clang-tidy"], True), # clang-tidy config changed - full scan + (["platformio.ini"], True), # build config changed - full scan + (["requirements_dev.txt"], True), # clang-tidy version source changed + (["sdkconfig.defaults"], True), # sdkconfig changed - full scan + (["sdkconfig.defaults.esp32c6"], True), # per-target sdkconfig changed + (["esphome/idf_component.yml"], True), # idf managed deps changed + (["platformio.ini", "README.md"], True), # config + non-C++ ], ) def test_should_run_clang_tidy( - check_returncode: int, changed_files: list[str], expected_result: bool, ) -> None: """Test should_run_clang_tidy function.""" - with ( - patch.object(determine_jobs, "changed_files", return_value=changed_files), - patch("subprocess.run") as mock_run, - ): - # Test with hash check returning specific code - mock_run.return_value = Mock(returncode=check_returncode) + with patch.object(determine_jobs, "changed_files", return_value=changed_files): result = determine_jobs.should_run_clang_tidy() assert result == expected_result -def test_should_run_clang_tidy_hash_check_exception() -> None: - """Test should_run_clang_tidy when hash check fails with exception.""" - # When hash check fails, clang-tidy should run as a safety measure - with ( - patch.object(determine_jobs, "changed_files", return_value=["README.md"]), - patch("subprocess.run", side_effect=Exception("Hash check failed")), - ): - result = determine_jobs.should_run_clang_tidy() - assert result is True # Fail safe - run clang-tidy - - def test_should_run_clang_tidy_with_branch() -> None: """Test should_run_clang_tidy with branch argument.""" with patch.object(determine_jobs, "changed_files") as mock_changed: mock_changed.return_value = [] - with patch("subprocess.run") as mock_run: - mock_run.return_value = Mock(returncode=1) # Hash unchanged - determine_jobs.should_run_clang_tidy("release") - # Changed files is called twice now - once for hash check, once for .clang-tidy.hash check - assert mock_changed.call_count == 2 - mock_changed.assert_has_calls([call("release"), call("release")]) + determine_jobs.should_run_clang_tidy("release") + # changed_files is queried against the given branch by both the + # config-file full-scan check and the C++ extension check. + mock_changed.assert_called_with("release") @pytest.mark.parametrize( From c9095841ae74cc59093100907b19f29aa3bfab5b Mon Sep 17 00:00:00 2001 From: Petter Ljungqvist Date: Thu, 18 Jun 2026 04:16:28 +0300 Subject: [PATCH 0442/1815] [ufm01] Add UFM-01 ultrasonic flow meter component (#16582) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Cursor Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/ufm01/__init__.py | 40 +++ esphome/components/ufm01/binary_sensor.py | 52 ++++ esphome/components/ufm01/sensor.py | 63 +++++ esphome/components/ufm01/ufm01.cpp | 234 ++++++++++++++++++ esphome/components/ufm01/ufm01.h | 57 +++++ tests/components/ufm01/common.yaml | 30 +++ tests/components/ufm01/test.esp32-idf.yaml | 4 + tests/components/ufm01/test.esp8266-ard.yaml | 4 + tests/components/ufm01/test.rp2040-ard.yaml | 4 + .../common/uart_2400_even/esp32-idf.yaml | 12 + .../common/uart_2400_even/esp8266-ard.yaml | 12 + .../common/uart_2400_even/rp2040-ard.yaml | 12 + 13 files changed, 525 insertions(+) create mode 100644 esphome/components/ufm01/__init__.py create mode 100644 esphome/components/ufm01/binary_sensor.py create mode 100644 esphome/components/ufm01/sensor.py create mode 100644 esphome/components/ufm01/ufm01.cpp create mode 100644 esphome/components/ufm01/ufm01.h create mode 100644 tests/components/ufm01/common.yaml create mode 100644 tests/components/ufm01/test.esp32-idf.yaml create mode 100644 tests/components/ufm01/test.esp8266-ard.yaml create mode 100644 tests/components/ufm01/test.rp2040-ard.yaml create mode 100644 tests/test_build_components/common/uart_2400_even/esp32-idf.yaml create mode 100644 tests/test_build_components/common/uart_2400_even/esp8266-ard.yaml create mode 100644 tests/test_build_components/common/uart_2400_even/rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 10128c64e5..3265627c03 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -561,6 +561,7 @@ esphome/components/uart/packet_transport/* @clydebarrow esphome/components/udp/* @clydebarrow esphome/components/ufire_ec/* @pvizeli esphome/components/ufire_ise/* @pvizeli +esphome/components/ufm01/* @ljungqvist esphome/components/ultrasonic/* @ssieb @swoboda1337 esphome/components/update/* @jesserockz esphome/components/uponor_smatrix/* @kroimon diff --git a/esphome/components/ufm01/__init__.py b/esphome/components/ufm01/__init__.py new file mode 100644 index 0000000000..51cf3cfd91 --- /dev/null +++ b/esphome/components/ufm01/__init__.py @@ -0,0 +1,40 @@ +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID + +CODEOWNERS = ["@ljungqvist"] + +MULTI_CONF = True + +DEPENDENCIES = ["uart"] + +ufm01_ns = cg.esphome_ns.namespace("ufm01") +UFM01Component = ufm01_ns.class_("UFM01Component", uart.UARTDevice, cg.Component) + +CONF_UFM01_ID = "ufm01_id" + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(UFM01Component), + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "ufm01", + require_tx=True, + require_rx=True, + baud_rate=2400, + parity="EVEN", + stop_bits=1, +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) diff --git a/esphome/components/ufm01/binary_sensor.py b/esphome/components/ufm01/binary_sensor.py new file mode 100644 index 0000000000..92ae585d96 --- /dev/null +++ b/esphome/components/ufm01/binary_sensor.py @@ -0,0 +1,52 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAGNOSTIC + +from . import CONF_UFM01_ID, UFM01Component + +DEPENDENCIES = ["ufm01"] + +CONF_UFC_CHIP_ERROR = "ufc_chip_error" +CONF_FLOW_DIRECTION_WRONG = "flow_direction_wrong" +CONF_EMPTY_TUBE = "empty_tube" +CONF_FLOW_RATE_OUT_OF_RANGE = "flow_rate_out_of_range" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_UFM01_ID): cv.use_id(UFM01Component), + cv.Optional(CONF_UFC_CHIP_ERROR): binary_sensor.binary_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, device_class=DEVICE_CLASS_PROBLEM + ), + cv.Optional(CONF_FLOW_DIRECTION_WRONG): binary_sensor.binary_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=DEVICE_CLASS_PROBLEM, + ), + cv.Optional(CONF_EMPTY_TUBE): binary_sensor.binary_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=DEVICE_CLASS_PROBLEM, + ), + cv.Optional(CONF_FLOW_RATE_OUT_OF_RANGE): binary_sensor.binary_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=DEVICE_CLASS_PROBLEM, + ), +} + + +async def to_code(config): + ufm01_component = await cg.get_variable(config[CONF_UFM01_ID]) + + if ufc_chip_error_config := config.get(CONF_UFC_CHIP_ERROR): + sens = await binary_sensor.new_binary_sensor(ufc_chip_error_config) + cg.add(ufm01_component.set_ufc_chip_error_binary_sensor(sens)) + + if flow_direction_wrong_config := config.get(CONF_FLOW_DIRECTION_WRONG): + sens = await binary_sensor.new_binary_sensor(flow_direction_wrong_config) + cg.add(ufm01_component.set_flow_direction_wrong_binary_sensor(sens)) + + if empty_tube_config := config.get(CONF_EMPTY_TUBE): + sens = await binary_sensor.new_binary_sensor(empty_tube_config) + cg.add(ufm01_component.set_empty_tube_binary_sensor(sens)) + + if flow_rate_out_of_range_config := config.get(CONF_FLOW_RATE_OUT_OF_RANGE): + sens = await binary_sensor.new_binary_sensor(flow_rate_out_of_range_config) + cg.add(ufm01_component.set_flow_rate_out_of_range_binary_sensor(sens)) diff --git a/esphome/components/ufm01/sensor.py b/esphome/components/ufm01/sensor.py new file mode 100644 index 0000000000..4dcd7ceebe --- /dev/null +++ b/esphome/components/ufm01/sensor.py @@ -0,0 +1,63 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_FLOW, + CONF_TEMPERATURE, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLUME_FLOW_RATE, + DEVICE_CLASS_WATER, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_CELSIUS, + UNIT_CUBIC_METER_PER_HOUR, + UNIT_LITRE, +) + +from . import CONF_UFM01_ID, UFM01Component + +DEPENDENCIES = ["ufm01"] + +CONF_ACCUMULATED_FLOW = "accumulated_flow" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_UFM01_ID): cv.use_id(UFM01Component), + cv.Optional(CONF_ACCUMULATED_FLOW): sensor.sensor_schema( + unit_of_measurement=UNIT_LITRE, + accuracy_decimals=3, + device_class=DEVICE_CLASS_WATER, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional(CONF_FLOW): sensor.sensor_schema( + unit_of_measurement=UNIT_CUBIC_METER_PER_HOUR, + accuracy_decimals=5, + device_class=DEVICE_CLASS_VOLUME_FLOW_RATE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:waves-arrow-right", + ), + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=2, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:thermometer-water", + ), + } +) + + +async def to_code(config): + ufm01_component = await cg.get_variable(config[CONF_UFM01_ID]) + + if CONF_ACCUMULATED_FLOW in config: + sens = await sensor.new_sensor(config[CONF_ACCUMULATED_FLOW]) + cg.add(ufm01_component.set_accumulated_flow_sensor(sens)) + + if CONF_FLOW in config: + sens = await sensor.new_sensor(config[CONF_FLOW]) + cg.add(ufm01_component.set_flow_sensor(sens)) + + if CONF_TEMPERATURE in config: + sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) + cg.add(ufm01_component.set_temperature_sensor(sens)) diff --git a/esphome/components/ufm01/ufm01.cpp b/esphome/components/ufm01/ufm01.cpp new file mode 100644 index 0000000000..1380c34284 --- /dev/null +++ b/esphome/components/ufm01/ufm01.cpp @@ -0,0 +1,234 @@ +#include "ufm01.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::ufm01 { + +static const char *const TAG = "ufm01"; + +static constexpr uint8_t COMMAND_ACK = 0xE5; +static constexpr uint32_t COMMAND_ACK_TIMEOUT_MS = 200; + +static constexpr float L_PER_M3 = 1000.0f; +static constexpr float M3_PER_L = 1.0f / L_PER_M3; + +static constexpr std::array ACTIVE_MODE = {0xFE, 0xFE, 0x11, 0x5C, 0x00, 0x5C, 0x16}; +static constexpr std::array CLEAR_ACCUMULATED_FLOW = {0xFE, 0xFE, 0x11, 0x5A, 0xFD, 0x57, 0x16}; +static constexpr std::array RESET_DEVICE = {0xFE, 0xFE, 0x11, 0x5D, 0xCB, 0x28, 0x16}; + +// Active-mode frame layout (datasheet Table 7) +static constexpr size_t FRAME_CHECKSUM_INDEX = 30; +static constexpr size_t FRAME_STOP_INDEX = 31; +static constexpr uint8_t FRAME_START_BYTE_1 = 0x3C; +static constexpr uint8_t FRAME_START_BYTE_2 = 0x32; +static constexpr uint8_t FRAME_STOP_BYTE = 0x16; +static constexpr uint8_t FRAME_INDEX_INSTANT_FLOW_FLAG = 15; +static constexpr uint8_t FRAME_INDEX_RESERVED_SECTION = 21; +static constexpr uint8_t FRAME_INDEX_TEMP_FLAG = 24; +static constexpr uint8_t FRAME_FLAG_INSTANT_FLOW = 0x0B; +static constexpr uint8_t FRAME_FLAG_RESERVED_SECTION = 0x0C; +static constexpr uint8_t FRAME_FLAG_TEMP = 0x0D; + +// Measurement decoding +static constexpr uint8_t FRAME_ACC_FLOW_FLAG_INDEX = 8; +static constexpr uint8_t ACC_FLOW_M3_FLAG = 0x1A; +static constexpr uint8_t FRAME_FLOW_SIGN_INDEX = 20; +static constexpr uint8_t FLOW_NEGATIVE_SIGN = 0x80; + +// Status bytes (datasheet ST1 / ST2) +static constexpr uint8_t FRAME_ST1_INDEX = 28; +static constexpr uint8_t FRAME_ST2_INDEX = 29; +static constexpr uint8_t ST1_EMPTY_TUBE_MASK = 0x20; +static constexpr uint8_t ST2_UFC_ERROR_MASK = 0x20; +static constexpr uint8_t ST2_FLOW_DIRECTION_WRONG_MASK = 0x08; +static constexpr uint8_t ST2_FLOW_RATE_OUT_OF_RANGE_MASK = 0x04; + +static float to_float(uint8_t data) { return (data >> 4) * 10 + (data & 0x0F); } + +static bool check_byte(const uint8_t data[FRAME_SIZE], size_t index, uint8_t expected, const char *name) { + if (data[index] == expected) + return true; + ESP_LOGW(TAG, "%s (byte %zu) - expected 0x%02X, but was 0x%02X", name, index, expected, data[index]); + return false; +} + +static bool validate_data(uint8_t data[FRAME_SIZE]) { + uint8_t sum = 0; + for (size_t i = 0; i < FRAME_CHECKSUM_INDEX; ++i) + sum += data[i]; + return check_byte(data, 0, FRAME_START_BYTE_1, "start byte 1") && + check_byte(data, 1, FRAME_START_BYTE_2, "start byte 2") && + check_byte(data, FRAME_INDEX_INSTANT_FLOW_FLAG, FRAME_FLAG_INSTANT_FLOW, "instant flow flag") && + check_byte(data, FRAME_INDEX_RESERVED_SECTION, FRAME_FLAG_RESERVED_SECTION, "reserved section flag") && + check_byte(data, FRAME_INDEX_TEMP_FLAG, FRAME_FLAG_TEMP, "temperature flag") && + check_byte(data, FRAME_CHECKSUM_INDEX, sum, "checksum") && + check_byte(data, FRAME_STOP_INDEX, FRAME_STOP_BYTE, "stop byte"); +} + +static float read_accumulated_flow(uint8_t data[FRAME_SIZE]) { + return (data[FRAME_ACC_FLOW_FLAG_INDEX] == ACC_FLOW_M3_FLAG ? L_PER_M3 : 1.0f) * + (to_float(data[14]) * 10000000.0f + to_float(data[13]) * 100000.0f + to_float(data[12]) * 1000.0f + + to_float(data[11]) * 10.0f + to_float(data[10]) * 0.1f + to_float(data[9]) * 0.001f); +} + +static float read_flow(uint8_t data[FRAME_SIZE]) { + return (data[FRAME_FLOW_SIGN_INDEX] == FLOW_NEGATIVE_SIGN ? -1.0f : 1.0f) * + (to_float(data[19]) * 10000.0f + to_float(data[18]) * 100.0f + to_float(data[17]) + + to_float(data[16]) * 0.01f) * + M3_PER_L; +} + +static void log_hex(const uint8_t *data, size_t len) { + char hex_buf[format_hex_pretty_size(FRAME_SIZE)]; + ESP_LOGD(TAG, "%s", format_hex_pretty_to(hex_buf, data, len, ' ')); +} + +static float read_temperature(uint8_t data[FRAME_SIZE]) { + // happens sometimes before getting a real reading + if (data[27] == 0x00 && (data[26] == 0x00 || data[26] == 0x70) && data[25] == 0x00) { + return NAN; + } + return to_float(data[27]) * 100.0f + to_float(data[26]) + to_float(data[25]) * 0.01f; +} + +static bool read_ufc_chip_error(const uint8_t data[FRAME_SIZE]) { return data[FRAME_ST2_INDEX] & ST2_UFC_ERROR_MASK; } + +static bool read_flow_direction_wrong(const uint8_t data[FRAME_SIZE]) { + return data[FRAME_ST2_INDEX] & ST2_FLOW_DIRECTION_WRONG_MASK; +} + +static bool read_empty_tube(const uint8_t data[FRAME_SIZE]) { return data[FRAME_ST1_INDEX] & ST1_EMPTY_TUBE_MASK; } + +static bool read_flow_rate_out_of_range(const uint8_t data[FRAME_SIZE]) { + return data[FRAME_ST2_INDEX] & ST2_FLOW_RATE_OUT_OF_RANGE_MASK; +} + +bool UFM01Component::send_command_(const std::array &command) { + this->write_array(command); + this->flush(); + const uint32_t start = millis(); + while (millis() - start < COMMAND_ACK_TIMEOUT_MS) { + if (this->available()) { + uint8_t byte; + if (this->read_byte(&byte)) { + if (byte == COMMAND_ACK) + return true; + ESP_LOGV(TAG, "Unexpected byte while waiting for command ACK: 0x%02X", byte); + } + } + delay(1); + } + return false; +} + +bool UFM01Component::reset_device_() { return this->send_command_(RESET_DEVICE); } + +bool UFM01Component::clear_accumulated_flow_() { return this->send_command_(CLEAR_ACCUMULATED_FLOW); } + +bool UFM01Component::set_active_mode_() { return this->send_command_(ACTIVE_MODE); } + +float UFM01Component::get_setup_priority() const { return setup_priority::IO; } + +void UFM01Component::setup() { + ESP_LOGI(TAG, "Setting up UFM-01..."); + if (!this->set_active_mode_()) { + ESP_LOGW(TAG, "Failed to set active mode (no ACK from device)"); + this->mark_failed(); + } +} + +void UFM01Component::dump_config() { + ESP_LOGCONFIG(TAG, "UFM-01:"); +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Accumulated Flow", this->accumulated_flow_sensor_); + LOG_SENSOR(" ", "Flow", this->flow_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); +#endif +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "UFC Chip Error", this->ufc_chip_error_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Flow Direction Wrong", this->flow_direction_wrong_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Empty Tube", this->empty_tube_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Flow Rate Out Of Range", this->flow_rate_out_of_range_binary_sensor_); +#endif + this->check_uart_settings(2400, 1, uart::UART_CONFIG_PARITY_EVEN, 8); + if (this->is_failed()) { + ESP_LOGW(TAG, "Setup failed: active mode not acknowledged by device"); + } +} + +void UFM01Component::on_data_(uint8_t data[FRAME_SIZE]) { + bool empty_tube = read_empty_tube(data); +#ifdef USE_BINARY_SENSOR + if (this->ufc_chip_error_binary_sensor_ != nullptr) + this->ufc_chip_error_binary_sensor_->publish_state(read_ufc_chip_error(data)); + if (this->flow_direction_wrong_binary_sensor_ != nullptr) + this->flow_direction_wrong_binary_sensor_->publish_state(read_flow_direction_wrong(data)); + if (this->empty_tube_binary_sensor_ != nullptr) + this->empty_tube_binary_sensor_->publish_state(empty_tube); + if (this->flow_rate_out_of_range_binary_sensor_ != nullptr) + this->flow_rate_out_of_range_binary_sensor_->publish_state(read_flow_rate_out_of_range(data)); +#endif + +#ifdef USE_SENSOR + // Total volume remains valid when the tube is dry; flow and temperature are not. + if (this->accumulated_flow_sensor_ != nullptr) + this->accumulated_flow_sensor_->publish_state(read_accumulated_flow(data)); + + if (empty_tube) { + if (this->flow_sensor_ != nullptr) + this->flow_sensor_->publish_state(NAN); + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(NAN); + } else { + if (this->flow_sensor_ != nullptr) + this->flow_sensor_->publish_state(read_flow(data)); + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(read_temperature(data)); + } +#endif +} + +void UFM01Component::loop() { + // Drain the UART buffer each loop, reading one byte at a time into the frame + while (this->available()) { + if (!this->read_byte(&this->data_[this->read_index_])) { + ESP_LOGW(TAG, "unable to read byte"); + this->read_index_ = 0; + continue; + } + if ((this->read_index_ == 0 && this->data_[0] != FRAME_START_BYTE_1) || + (this->read_index_ == 1 && this->data_[1] != FRAME_START_BYTE_2)) { + ESP_LOGW(TAG, "not start of data at %d (is 0x%02X)", this->read_index_, this->data_[this->read_index_]); + this->read_index_ = 0; + continue; + } + if (++this->read_index_ < static_cast(FRAME_SIZE)) + continue; + + // Full frame received + if (validate_data(this->data_)) { + this->on_data_(this->data_); + this->read_index_ = 0; + continue; + } + + // Invalid frame: try to resync on the next start marker within the buffer + log_hex(this->data_, sizeof(this->data_)); + ESP_LOGE(TAG, "unable to read data"); + for (int32_t i = 2; + i < static_cast(FRAME_STOP_INDEX) && this->read_index_ == static_cast(FRAME_SIZE); ++i) { + if ((this->data_[i] == FRAME_START_BYTE_1) && (this->data_[i + 1] == FRAME_START_BYTE_2)) { + for (int32_t j = i; j < static_cast(FRAME_SIZE); ++j) + this->data_[j - i] = this->data_[j]; + this->read_index_ = static_cast(FRAME_SIZE) - i; + } + } + if (this->read_index_ == static_cast(FRAME_SIZE)) + this->read_index_ = 0; + } +} + +} // namespace esphome::ufm01 diff --git a/esphome/components/ufm01/ufm01.h b/esphome/components/ufm01/ufm01.h new file mode 100644 index 0000000000..e759de9169 --- /dev/null +++ b/esphome/components/ufm01/ufm01.h @@ -0,0 +1,57 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#include "esphome/components/uart/uart.h" + +#include + +// component API definition at https://www.sciosense.com/wp-content/uploads/2025/06/UFM-01-Datasheet-1.pdf + +namespace esphome::ufm01 { + +static constexpr size_t FRAME_SIZE = 32; + +class UFM01Component : public uart::UARTDevice, public Component { +#ifdef USE_SENSOR + SUB_SENSOR(accumulated_flow) + SUB_SENSOR(flow) + SUB_SENSOR(temperature) +#endif + +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(ufc_chip_error) + SUB_BINARY_SENSOR(flow_direction_wrong) + SUB_BINARY_SENSOR(empty_tube) + SUB_BINARY_SENSOR(flow_rate_out_of_range) +#endif + + public: + void setup() override; + + void dump_config() override; + + void loop() override; + + float get_setup_priority() const override; + + protected: + bool clear_accumulated_flow_(); + bool set_active_mode_(); + bool reset_device_(); + + private: + bool send_command_(const std::array &command); + + int32_t read_index_ = 0; + uint8_t data_[FRAME_SIZE]; + void on_data_(uint8_t data[FRAME_SIZE]); +}; + +} // namespace esphome::ufm01 diff --git a/tests/components/ufm01/common.yaml b/tests/components/ufm01/common.yaml new file mode 100644 index 0000000000..c818dc2965 --- /dev/null +++ b/tests/components/ufm01/common.yaml @@ -0,0 +1,30 @@ +ufm01: + id: ufm01_component + uart_id: uart_bus + +sensor: + - platform: ufm01 + accumulated_flow: + id: accumulated_flow + name: "Accumulated flow" + flow: + id: flow + name: "Flow" + temperature: + id: temperature + name: "Temperature" + +binary_sensor: + - platform: ufm01 + ufc_chip_error: + id: ufc_chip_error + name: "UFC chip error" + flow_direction_wrong: + id: flow_direction_wrong + name: "Flow direction wrong" + empty_tube: + id: empty_tube + name: "Empty tube" + flow_rate_out_of_range: + id: flow_rate_out_of_range + name: "Flow rate out of range" diff --git a/tests/components/ufm01/test.esp32-idf.yaml b/tests/components/ufm01/test.esp32-idf.yaml new file mode 100644 index 0000000000..34041cc223 --- /dev/null +++ b/tests/components/ufm01/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + uart_2400_even: !include ../../test_build_components/common/uart_2400_even/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/ufm01/test.esp8266-ard.yaml b/tests/components/ufm01/test.esp8266-ard.yaml new file mode 100644 index 0000000000..195f4b41b5 --- /dev/null +++ b/tests/components/ufm01/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart_2400_even: !include ../../test_build_components/common/uart_2400_even/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/ufm01/test.rp2040-ard.yaml b/tests/components/ufm01/test.rp2040-ard.yaml new file mode 100644 index 0000000000..13b3284fe3 --- /dev/null +++ b/tests/components/ufm01/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart_2400_even: !include ../../test_build_components/common/uart_2400_even/rp2040-ard.yaml + +<<: !include common.yaml diff --git a/tests/test_build_components/common/uart_2400_even/esp32-idf.yaml b/tests/test_build_components/common/uart_2400_even/esp32-idf.yaml new file mode 100644 index 0000000000..92a65c463e --- /dev/null +++ b/tests/test_build_components/common/uart_2400_even/esp32-idf.yaml @@ -0,0 +1,12 @@ +# Common UART configuration for ESP32 IDF tests - 2400 baud, EVEN parity + +substitutions: + tx_pin: GPIO17 + rx_pin: GPIO16 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 2400 + parity: EVEN diff --git a/tests/test_build_components/common/uart_2400_even/esp8266-ard.yaml b/tests/test_build_components/common/uart_2400_even/esp8266-ard.yaml new file mode 100644 index 0000000000..00333867db --- /dev/null +++ b/tests/test_build_components/common/uart_2400_even/esp8266-ard.yaml @@ -0,0 +1,12 @@ +# Common UART configuration for ESP8266 Arduino tests - 2400 baud even parity + +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 2400 + parity: EVEN diff --git a/tests/test_build_components/common/uart_2400_even/rp2040-ard.yaml b/tests/test_build_components/common/uart_2400_even/rp2040-ard.yaml new file mode 100644 index 0000000000..c915e7846d --- /dev/null +++ b/tests/test_build_components/common/uart_2400_even/rp2040-ard.yaml @@ -0,0 +1,12 @@ +# Common UART configuration for RP2040 Arduino tests - 2400 baud even parity + +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO1 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 2400 + parity: EVEN From e3f164fff20edb2e3aa7e4b9a3a4330d4c41fbec Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Thu, 18 Jun 2026 03:17:07 +0200 Subject: [PATCH 0443/1815] [nrf52] add support for native builds (#16898) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/build_gen/espidf.py | 12 +-- esphome/components/nrf52/__init__.py | 98 +++++++++++++++++- esphome/components/nrf52/framework.py | 114 ++++++++++++++++++--- esphome/components/nrf52/requirements.txt | 3 + esphome/framework_helpers.py | 19 ++++ tests/unit_tests/build_gen/test_espidf.py | 48 +++++++++ tests/unit_tests/test_framework_helpers.py | 83 +++++++++++++++ tests/unit_tests/test_nrf52_framework.py | 33 +++--- 8 files changed, 369 insertions(+), 41 deletions(-) create mode 100644 esphome/components/nrf52/requirements.txt diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 9e11d785c0..dec6ea04de 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -6,6 +6,7 @@ from pathlib import Path from esphome.components.esp32 import get_esp32_variant, idf_version import esphome.config_validation as cv from esphome.core import CORE +from esphome.framework_helpers import get_project_compile_flags, get_project_link_flags from esphome.helpers import mkdir_p, write_file_if_changed # Replaces the IDF default C++ standard (-std=gnu++2b appended to @@ -84,12 +85,7 @@ def get_project_cmakelists(minimal: bool = False) -> str: # esphome__micro-mp3) rather than just src/. Required so suppressions # like ``-Wno-error=maybe-uninitialized`` actually silence warnings in # third-party components we don't author. - project_compile_opts = [ - flag - for flag in sorted(CORE.build_flags) - if flag.startswith("-D") - or (flag.startswith("-W") and not flag.startswith("-Wl,")) - ] + project_compile_opts = get_project_compile_flags() extra_compile_options = "\n".join( f'idf_build_set_property(COMPILE_OPTIONS "{flag}" APPEND)' for flag in project_compile_opts @@ -188,8 +184,8 @@ def get_component_cmakelists() -> str: # Extract linker options (-Wl, flags). Compile flags (-D, -W) are # emitted project-wide via idf_build_set_property in # get_project_cmakelists so they reach every component, not just src/. - link_opts = [flag for flag in CORE.build_flags if flag.startswith("-Wl,")] - link_opts_str = "\n ".join(sorted(link_opts)) if link_opts else "" + link_opts = get_project_link_flags() + link_opts_str = "\n ".join(link_opts) if link_opts else "" return f"""\ # Auto-generated by ESPHome diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 56367d0b26..d87318b03d 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -52,6 +52,11 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority from esphome.core.config import BOARD_MAX_LENGTH import esphome.final_validate as fv +from esphome.framework_helpers import ( + get_project_compile_flags, + get_project_link_flags, + run_command_ok, +) from esphome.helpers import write_file_if_changed from esphome.storage_json import StorageJSON from esphome.types import ConfigType @@ -63,7 +68,7 @@ from .const import ( BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, ) -from .framework import check_and_install +from .framework import check_and_install, get_build_env, get_build_paths # force import gpio to register pin schema from .gpio import nrf52_pin_to_code # noqa: F401 @@ -99,9 +104,6 @@ FAKE_BOARD_MANIFEST = """ def set_core_data(config: ConfigType) -> ConfigType: - # Resolve toolchain: CLI (already on CORE.toolchain) > YAML > default. - if CORE.toolchain is None: - CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO) zephyr_set_core_data(config) CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_NRF52 CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = KEY_ZEPHYR @@ -112,6 +114,12 @@ def set_core_data(config: ConfigType) -> ConfigType: return config +def _resolve_toolchain(config: ConfigType) -> ConfigType: + if CORE.toolchain is None: + CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO) + return config + + def set_framework(config: ConfigType) -> ConfigType: if CONF_VERSION not in config[CONF_FRAMEWORK]: default_version = "2.6.1-b" if CORE.using_toolchain_platformio else "2.9.2" @@ -147,6 +155,12 @@ BOOTLOADERS = [ ] +def _validate_toolchain(value) -> Toolchain: + return Toolchain( + cv.one_of(Toolchain.PLATFORMIO, Toolchain.SDK_NRF, lower=True)(value) + ) + + def _detect_bootloader(config: ConfigType) -> ConfigType: """Detect the bootloader for the given board.""" config = config.copy() @@ -233,9 +247,11 @@ CONFIG_SCHEMA = cv.All( ), } ), + cv.Optional(CONF_TOOLCHAIN): _validate_toolchain, cv.GenerateID(CONF_CDC_ACM): cv.declare_id(CdcAcm), } ), + _resolve_toolchain, set_framework, ) @@ -565,6 +581,47 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> return False +def _generate_cmake_lists() -> None: + compile_flags = get_project_compile_flags() + link_flags = get_project_link_flags() + + lines = [ + "cmake_minimum_required(VERSION 3.20.0)", + "", + 'set(Zephyr_DIR "$ENV{ZEPHYR_BASE}/share/zephyr-package/cmake/")', + "", + "find_package(Zephyr REQUIRED)", + "", + f"project({CORE.name})", + "", + 'file(GLOB_RECURSE APP_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_LIST_DIR}/../src/*.cpp" "${CMAKE_CURRENT_LIST_DIR}/../src/*.c")', + "", + "target_sources(app PRIVATE ${APP_SOURCES})", + 'target_include_directories(app PRIVATE "${CMAKE_CURRENT_LIST_DIR}/../src")', + ] + + if compile_flags: + lines += [ + "", + "target_compile_options(app PRIVATE", + *[f' "{flag}"' for flag in compile_flags], + ")", + ] + + if link_flags: + lines += [ + "", + "zephyr_ld_options(", + *[f' "{flag}"' for flag in link_flags], + ")", + ] + + write_file_if_changed( + CORE.relative_build_path("zephyr", "CMakeLists.txt"), + "\n".join(lines) + "\n", + ) + + def run_compile(args, config: ConfigType) -> bool: if CORE.using_toolchain_platformio: return False @@ -574,4 +631,35 @@ def run_compile(args, config: ConfigType) -> bool: "Supported toolchains are 'platformio' and 'sdk-nrf'." ) check_and_install() - raise EsphomeError("Native build for nRF52 is not implemented yet") + + paths = get_build_paths() + env = get_build_env() + + _generate_cmake_lists() + + board = zephyr_data()[KEY_BOARD] + build_dir = CORE.relative_pioenvs_path(CORE.name) + source_dir = CORE.relative_build_path("zephyr") + + west_cmd = [ + str(paths["python_executable"]), + "-m", + "west", + "build", + "--pristine=auto", + "-b", + board, + "-d", + str(build_dir), + str(source_dir), + ] + + if not run_command_ok( + west_cmd, + env=env, + stream_output=True, + cwd=str(paths["framework_path"]), + ): + raise EsphomeError("nRF52 native build failed") + + return True diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 607ad0c7ed..a35ba3ef85 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -18,7 +18,7 @@ from esphome.framework_helpers import ( _LOGGER = logging.getLogger(__name__) -_WEST_VERSION = "1.5.0" +_REQUIREMENTS = Path(__file__).parent / "requirements.txt" _TOOLCHAIN_VERSION = "0.17.4" SDK_NG_TOOLCHAIN_MIRRORS = str_to_lst_of_str( @@ -28,6 +28,15 @@ SDK_NG_TOOLCHAIN_MIRRORS = str_to_lst_of_str( ) ) +# Minimal SDK provides cmake discovery files (Zephyr-sdkConfig.cmake) and +# host tools (dtc etc.) required by the Zephyr cmake build system. +SDK_NG_MINIMAL_MIRRORS = str_to_lst_of_str( + os.environ.get( + "ESPHOME_SDK_NG_MINIMAL_MIRRORS", + "https://github.com/zephyrproject-rtos/sdk-ng/releases/download/v{VERSION}/zephyr-sdk-{VERSION}_{sysname}-{machine}_minimal.{extension}", + ) +) + def _get_tools_path() -> Path: return CORE.data_dir / "sdk-nrf" @@ -38,11 +47,11 @@ def _get_python_env_path(version: str) -> Path: def _get_framework_path(version: str) -> Path: - return _get_tools_path() / "frameworks" / f"{version}" + return _get_tools_path() / "frameworks" / version def _get_toolchain_path(version: str) -> Path: - return _get_tools_path() / "toolchains" / f"{version}" + return _get_tools_path() / "toolchains" / version # onexc/dir_fd were added to shutil.rmtree in 3.12; the 3.11 branch uses onerror. @@ -95,29 +104,68 @@ def _get_toolchain_platform_info() -> tuple[str, str, str]: return sysname, machine, extension -def check_and_install() -> None: +def _get_version_str() -> str: framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] - version = f"v{framework_ver.major}.{framework_ver.minor}.{framework_ver.patch}" + return f"v{framework_ver.major}.{framework_ver.minor}.{framework_ver.patch}" + + +def get_build_paths() -> dict: + version = _get_version_str() + return { + "python_executable": get_python_env_executable_path( + _get_python_env_path(version), "python" + ), + "framework_path": _get_framework_path(version), + } + + +def get_build_env() -> dict: + version = _get_version_str() + venv_bin_dir = get_python_env_executable_path( + _get_python_env_path(version), "python" + ).parent + env = os.environ.copy() + env["PATH"] = str(venv_bin_dir) + os.pathsep + env.get("PATH", "") + env["ZEPHYR_BASE"] = str(_get_framework_path(version) / "zephyr") + env["Zephyr-sdk_DIR"] = str(_get_toolchain_path(_TOOLCHAIN_VERSION) / "cmake") + return env + + +def check_and_install() -> None: + version = _get_version_str() python_env_path = _get_python_env_path(version) env_python_path = get_python_env_executable_path(python_env_path, "python") sentinel = python_env_path / ".ready" - install_venv = not sentinel.exists() + install_venv = ( + not sentinel.exists() + or _REQUIREMENTS.stat().st_mtime > sentinel.stat().st_mtime + ) if install_venv: rmdir(python_env_path, msg=f"Clean up {version} Python environment") - create_venv(python_env_path, msg=f"{version}") + create_venv(python_env_path, msg=version) _install_sitecustomize(python_env_path) - _LOGGER.info("Installing west %s ...", _WEST_VERSION) - cmd = [str(env_python_path), "-m", "pip", "install", f"west=={_WEST_VERSION}"] + _LOGGER.info("Installing requirements ...") + cmd = [ + str(env_python_path), + "-m", + "pip", + "install", + "-r", + str(_REQUIREMENTS), + ] if not run_command_ok(cmd): - raise EsphomeError(f"Install west for {version} Python environment failure") + raise EsphomeError( + f"Install requirements for {version} Python environment failure" + ) sentinel.touch() framework_path = _get_framework_path(version) sentinel = framework_path / ".ready" - if install_venv or not sentinel.exists(): + zephyr_reqs = framework_path / "zephyr" / "scripts" / "requirements.txt" + if not sentinel.exists() or not zephyr_reqs.exists(): rmdir(framework_path, msg=f"Clean up {version} framework environment") _LOGGER.info("Initializing nRF Connect SDK %s ...", version) cmd = [ @@ -128,7 +176,7 @@ def check_and_install() -> None: "-m", "https://github.com/nrfconnect/sdk-nrf", "--mr", - f"{version}", + version, str(framework_path), ] if not run_command_ok(cmd): @@ -146,17 +194,47 @@ def check_and_install() -> None: raise EsphomeError(f"Can't update nRF Connect SDK {version}") sentinel.touch() + zephyr_sentinel = python_env_path / ".zephyr_reqs_ready" + if ( + install_venv + or not zephyr_sentinel.exists() + or zephyr_reqs.stat().st_mtime > zephyr_sentinel.stat().st_mtime + ): + _LOGGER.info("Installing Zephyr requirements ...") + cmd = [ + str(env_python_path), + "-m", + "pip", + "install", + "-r", + str(zephyr_reqs), + ] + if not run_command_ok(cmd): + raise EsphomeError(f"Install Zephyr requirements for {version} failure") + zephyr_sentinel.touch() + toolchains_dir = _get_toolchain_path(_TOOLCHAIN_VERSION) sentinel = toolchains_dir / ".ready" if not sentinel.exists(): rmdir( toolchains_dir, msg=f"Clean up {_TOOLCHAIN_VERSION} toolchain environment" ) + sysname, machine, extension = _get_toolchain_platform_info() + with tempfile.NamedTemporaryFile() as tmp: + _LOGGER.info("Downloading Zephyr SDK %s minimal ...", _TOOLCHAIN_VERSION) + download_from_mirrors( + SDK_NG_MINIMAL_MIRRORS, + { + "VERSION": _TOOLCHAIN_VERSION, + "sysname": sysname, + "machine": machine, + "extension": extension, + }, + tmp.file, + ) + archive_extract_all(tmp.file, toolchains_dir, progress_header="Extracting") with tempfile.NamedTemporaryFile() as tmp: _LOGGER.info("Downloading %s toolchain ...", _TOOLCHAIN_VERSION) - - sysname, machine, extension = _get_toolchain_platform_info() - download_from_mirrors( SDK_NG_TOOLCHAIN_MIRRORS, { @@ -167,5 +245,9 @@ def check_and_install() -> None: }, tmp.file, ) - archive_extract_all(tmp.file, toolchains_dir, progress_header="Extracting") + archive_extract_all( + tmp.file, + toolchains_dir / "arm-zephyr-eabi", + progress_header="Extracting", + ) sentinel.touch() diff --git a/esphome/components/nrf52/requirements.txt b/esphome/components/nrf52/requirements.txt new file mode 100644 index 0000000000..250d3a29cf --- /dev/null +++ b/esphome/components/nrf52/requirements.txt @@ -0,0 +1,3 @@ +west==1.5.0 +ninja==1.13.0 +cmake==4.3.2 diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 276dfbbf1c..6bf389240b 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -20,6 +20,25 @@ PathType = str | os.PathLike _LOGGER = logging.getLogger(__name__) +def get_project_link_flags() -> list[str]: + """Return the sorted -Wl, linker flags from the current build.""" + from esphome.core import CORE # local import to avoid circular dependency + + return sorted(flag for flag in CORE.build_flags if flag.startswith("-Wl,")) + + +def get_project_compile_flags() -> list[str]: + """Return the sorted -D and -W (non-linker) flags from the current build.""" + from esphome.core import CORE # local import to avoid circular dependency + + return [ + flag + for flag in sorted(CORE.build_flags) + if flag.startswith("-D") + or (flag.startswith("-W") and not flag.startswith("-Wl,")) + ] + + def str_to_lst_of_str(a: str | list[str]) -> list[str]: """ Convert a string to a list of string diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index a5c2719f42..0f4444f719 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -136,6 +136,54 @@ def test_get_project_cmakelists_full_emits_builtin_components_property( assert "JPEGDEC APPEND" not in content +def test_get_component_cmakelists_no_link_flags() -> None: + """With no -Wl, flags the target_link_options block is emitted with an empty body.""" + CORE.build_flags = set() + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + assert "target_link_options(${COMPONENT_LIB} PUBLIC\n \n)" in content + + +def test_get_component_cmakelists_single_link_flag() -> None: + """A single -Wl, flag appears indented inside target_link_options.""" + CORE.build_flags = {"-Wl,--gc-sections"} + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + assert ( + "target_link_options(${COMPONENT_LIB} PUBLIC\n -Wl,--gc-sections\n)" + in content + ) + + +def test_get_component_cmakelists_multiple_link_flags_sorted() -> None: + """Multiple -Wl, flags are sorted and joined with the four-space indent.""" + CORE.build_flags = {"-Wl,-z,noexecstack", "-Wl,--gc-sections", "-Wl,-Map=out.map"} + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + expected = ( + "target_link_options(${COMPONENT_LIB} PUBLIC\n" + " -Wl,--gc-sections\n" + " -Wl,-Map=out.map\n" + " -Wl,-z,noexecstack\n" + ")" + ) + assert expected in content + + +def test_get_component_cmakelists_compile_flags_excluded_from_link_opts() -> None: + """-D and -W (non-linker) flags must not appear in target_link_options.""" + CORE.build_flags = {"-DFOO", "-Wall", "-Wl,--gc-sections"} + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + assert "-DFOO" not in content.split("target_link_options")[1] + assert "-Wall" not in content.split("target_link_options")[1] + assert "-Wl,--gc-sections" in content + + def test_get_project_cmakelists_emits_managed_components_property( tmp_path: Path, ) -> None: diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index a8533608c0..f6e783b5e8 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -25,6 +25,8 @@ from esphome.framework_helpers import ( archive_extract_all, create_venv, download_from_mirrors, + get_project_compile_flags, + get_project_link_flags, get_python_env_executable_path, get_system_python_path, rmdir, @@ -952,3 +954,84 @@ class TestSevenZipExtractAll: out.mkdir() archive_extract_all(archive, out) assert (out / "hello.txt").exists() + + +# --------------------------------------------------------------------------- +# get_project_compile_flags / get_project_link_flags +# --------------------------------------------------------------------------- + + +def _make_core(flags: set[str]): + core = MagicMock() + core.build_flags = flags + return core + + +class TestGetProjectCompileFlags: + def test_returns_define_flags(self) -> None: + with patch("esphome.core.CORE", _make_core({"-DFOO", "-DBAR=1"})): + assert get_project_compile_flags() == ["-DBAR=1", "-DFOO"] + + def test_returns_warning_flags(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-Wno-error", "-Wall"}), + ): + assert get_project_compile_flags() == ["-Wall", "-Wno-error"] + + def test_excludes_linker_flags(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-DFOO", "-Wl,--gc-sections", "-Wl,-Map=output.map"}), + ): + assert get_project_compile_flags() == ["-DFOO"] + + def test_excludes_other_flags(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-O2", "-std=gnu++20", "-DFOO"}), + ): + assert get_project_compile_flags() == ["-DFOO"] + + def test_empty_build_flags(self) -> None: + with patch("esphome.core.CORE", _make_core(set())): + assert get_project_compile_flags() == [] + + def test_result_is_sorted(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-DZFLAG", "-DAFLAG", "-Wno-unused"}), + ): + result = get_project_compile_flags() + assert result == sorted(result) + + +class TestGetProjectLinkFlags: + def test_returns_linker_flags(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-Wl,--gc-sections", "-Wl,-Map=output.map"}), + ): + assert get_project_link_flags() == [ + "-Wl,--gc-sections", + "-Wl,-Map=output.map", + ] + + def test_excludes_compile_flags(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-DFOO", "-Wall", "-Wl,--gc-sections"}), + ): + assert get_project_link_flags() == ["-Wl,--gc-sections"] + + def test_empty_build_flags(self) -> None: + with patch("esphome.core.CORE", _make_core(set())): + assert get_project_link_flags() == [] + + def test_result_is_sorted(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-Wl,-z", "-Wl,-a", "-Wl,-m"}), + ): + result = get_project_link_flags() + assert result == sorted(result) diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index 9652ad08eb..04c712f0b7 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -58,6 +58,9 @@ def nrf52_dirs(setup_core: Path) -> SimpleNamespace: toolchain_dir = tools / "toolchains" / _TOOLCHAIN_VERSION for d in (python_env, framework, toolchain_dir): d.mkdir(parents=True, exist_ok=True) + zephyr_scripts = framework / "zephyr" / "scripts" + zephyr_scripts.mkdir(parents=True, exist_ok=True) + (zephyr_scripts / "requirements.txt").touch() return SimpleNamespace( python_env=python_env, framework=framework, @@ -102,6 +105,7 @@ class TestCheckAndInstall: ) -> None: """All three sentinels present → nothing downloaded or compiled.""" (nrf52_dirs.python_env / ".ready").touch() + (nrf52_dirs.python_env / ".zephyr_reqs_ready").touch() (nrf52_dirs.framework / ".ready").touch() (nrf52_dirs.toolchain / ".ready").touch() @@ -121,11 +125,13 @@ class TestCheckAndInstall: check_and_install() mock_nrf52_ops.create_venv.assert_called_once() - # pip install west, west init, west update - assert mock_nrf52_ops.run_command_ok.call_count == 3 - mock_nrf52_ops.download_from_mirrors.assert_called_once() - mock_nrf52_ops.archive_extract_all.assert_called_once() + # pip install requirements, west init, west update, pip install zephyr reqs + assert mock_nrf52_ops.run_command_ok.call_count == 4 + # minimal SDK + per-arch toolchain + assert mock_nrf52_ops.download_from_mirrors.call_count == 2 + assert mock_nrf52_ops.archive_extract_all.call_count == 2 assert (nrf52_dirs.python_env / ".ready").exists() + assert (nrf52_dirs.python_env / ".zephyr_reqs_ready").exists() assert (nrf52_dirs.framework / ".ready").exists() assert (nrf52_dirs.toolchain / ".ready").exists() @@ -140,9 +146,10 @@ class TestCheckAndInstall: check_and_install() mock_nrf52_ops.create_venv.assert_not_called() - # west init + west update only (no pip install) - assert mock_nrf52_ops.run_command_ok.call_count == 2 - mock_nrf52_ops.download_from_mirrors.assert_called_once() + # west init, west update, pip install zephyr reqs + assert mock_nrf52_ops.run_command_ok.call_count == 3 + # minimal SDK + per-arch toolchain + assert mock_nrf52_ops.download_from_mirrors.call_count == 2 def test_toolchain_only_missing( self, @@ -151,24 +158,26 @@ class TestCheckAndInstall: ) -> None: """Venv and framework ready → only toolchain downloaded and extracted.""" (nrf52_dirs.python_env / ".ready").touch() + (nrf52_dirs.python_env / ".zephyr_reqs_ready").touch() (nrf52_dirs.framework / ".ready").touch() check_and_install() mock_nrf52_ops.create_venv.assert_not_called() mock_nrf52_ops.run_command_ok.assert_not_called() - mock_nrf52_ops.download_from_mirrors.assert_called_once() - mock_nrf52_ops.archive_extract_all.assert_called_once() + # minimal SDK + per-arch toolchain + assert mock_nrf52_ops.download_from_mirrors.call_count == 2 + assert mock_nrf52_ops.archive_extract_all.call_count == 2 - def test_west_install_failure_raises( + def test_requirements_install_failure_raises( self, nrf52_dirs: SimpleNamespace, mock_nrf52_ops: SimpleNamespace, ) -> None: - """Failing pip install west raises EsphomeError.""" + """Failing pip install -r requirements.txt raises EsphomeError.""" mock_nrf52_ops.run_command_ok.return_value = False - with pytest.raises(EsphomeError, match="Install west"): + with pytest.raises(EsphomeError, match="Install requirements"): check_and_install() def test_framework_init_failure_raises( From c214a8ce799cfa483eaecbf8cad4dc3d3ceaaf44 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:21:00 +1200 Subject: [PATCH 0444/1815] [core] Add generic component alias infrastructure (#16826) --- esphome/config.py | 102 +++++ esphome/loader.py | 305 +++++++++++++++ tests/unit_tests/test_loader.py | 663 +++++++++++++++++++++++++++++++- 3 files changed, 1068 insertions(+), 2 deletions(-) diff --git a/esphome/config.py b/esphome/config.py index 91e6df8bad..33e687137f 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -137,6 +137,96 @@ def _path_begins_with(path: ConfigPath, other: ConfigPath) -> bool: return path[: len(other)] == other +# CORE.data key for the per-alias "already warned this run" dedupe set. +# Cleared between runs because CORE.data is reset; one warning per alias +# per `esphome config|compile|run` invocation is the desired UX. +_ALIAS_WARNED_KEY = "_component_aliases_warned" + + +def _resolve_component_aliases(config: dict[str, Any]) -> None: + """Rewrite legacy top-level keys to their canonical names, in place. + + Looks up each top-level key against the component-alias map built by + :mod:`esphome.loader` (see ``ComponentManifest.aliases``); when a + matching alias is found, the key is moved to its canonical name and a + one-shot deprecation warning is logged (per alias, per run — deduped + via ``CORE.data``). + + Ambiguous configurations raise ``cv.Invalid`` rather than silently + keeping one entry — that would hide a real misconfiguration. Two cases + are rejected: the canonical key together with one of its deprecated + aliases, and two or more different aliases of the same canonical + component. + + The rest of the validator chain (dependency resolution, schema + validation, codegen) sees only canonical names, so component + `DEPENDENCIES = [""]` works regardless of which spelling + the user typed. + """ + alias_meta_map = loader.get_alias_metadata() + if not alias_meta_map: + return + + # Group every legacy alias key present in the config by the canonical + # component it resolves to, preserving config order within each group. + legacy_by_canonical: dict[str, list[str]] = {} + for key in config: + meta = alias_meta_map.get(key) + if meta is not None: + legacy_by_canonical.setdefault(meta.canonical, []).append(key) + + if not legacy_by_canonical: + return + + # Reject ambiguous configurations up front — checking before rewriting + # means a conflict is caught regardless of key order. + for canonical, legacies in legacy_by_canonical.items(): + if canonical in config: + # The canonical key and (at least) one deprecated alias are both + # present. + raise vol.Invalid( + f"Both '{legacies[0]}:' (deprecated alias of '{canonical}:') " + f"and '{canonical}:' are present in the configuration. Remove " + f"the deprecated '{legacies[0]}:' key.", + path=[legacies[0]], + ) + if len(legacies) > 1: + # Several different deprecated aliases of the same component. + listed = ", ".join(f"'{alias}:'" for alias in legacies) + raise vol.Invalid( + f"Multiple deprecated aliases of '{canonical}:' are present " + f"({listed}). Use only '{canonical}:'.", + path=[legacies[0]], + ) + + warned: set[str] = CORE.data.setdefault(_ALIAS_WARNED_KEY, set()) + + # Rebuild in place so each canonical key keeps the legacy key's original + # position — top-level key order matters for some downstream passes + # (e.g. auto-load ordering). A plain `config[canonical] = config.pop(...)` + # would instead move the renamed key to the end. + rewritten: dict[str, Any] = {} + for key, value in config.items(): + meta = alias_meta_map.get(key) + if meta is None: + rewritten[key] = value + continue + rewritten[meta.canonical] = value + if key not in warned: + warned.add(key) + removal = ( + f" Removed in {meta.removal_version}." if meta.removal_version else "" + ) + _LOGGER.warning( + "The '%s:' top-level key is deprecated; rename it to '%s:'.%s", + key, + meta.canonical, + removal, + ) + config.clear() + config.update(rewritten) + + @functools.total_ordering class _ValidationStepTask: def __init__(self, priority: float, id_number: int, step: ConfigValidationStep): @@ -1048,6 +1138,18 @@ def validate_config( substitutions = config.pop(CONF_SUBSTITUTIONS, None) CORE.raw_config = config + # 1.15. Resolve component aliases so legacy top-level keys + # (`rp2040:`, …) route to their canonical component before any + # downstream pass touches the config. Logs a deprecation warning + # per alias; mutates `config` in place. Errors here surface as + # plain config errors and abort further validation. + try: + _resolve_component_aliases(config) + except vol.Invalid as err: + result.update(config) + result.add_error(err) + return result + # 1.2. Resolve !extend and !remove and check for REPLACEME # After this step, there will not be any Extend or Remove values in the config anymore try: diff --git a/esphome/loader.py b/esphome/loader.py index 8823d82fc1..a9287abf86 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -101,6 +101,27 @@ class ComponentManifest: def codeowners(self) -> list[str]: return getattr(self.module, "CODEOWNERS", []) + @property + def aliases(self) -> list[str]: + """Legacy names that should transparently route to this component. + + See the :func:`_build_alias_map` documentation for how aliases are + discovered (AST scan, no execution) and registered both for the YAML + loader (top-level key rename in :mod:`esphome.config`) and for + Python imports (``sys.meta_path`` finder, below). + """ + return getattr(self.module, "ALIASES", []) + + @property + def alias_removal_version(self) -> str | None: + """Optional ESPHome version when the alias warning becomes a hard error. + + Surfaced in the deprecation warning emitted by the YAML pre-pass so + users know how long they have to migrate. ``None`` means the warning + does not mention a specific version. + """ + return getattr(self.module, "ALIAS_REMOVAL_VERSION", None) + @property def instance_type(self) -> "MockObjClass | None": return getattr(self.module, "INSTANCE_TYPE", None) @@ -216,6 +237,17 @@ def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None: _COMPONENT_CACHE[domain] = manif return manif + # If `domain` is the legacy name of a renamed component, redirect to the + # canonical module so the rest of the loader (and every caller of + # `get_component(legacy)`) transparently sees the new component. + alias_map = _get_alias_map() + if domain in alias_map: + canonical = alias_map[domain] + manif = _lookup_module(canonical, exception) + if manif is not None: + _COMPONENT_CACHE[domain] = manif + return manif + try: module = importlib.import_module(f"esphome.components.{domain}") except ImportError as e: @@ -261,3 +293,276 @@ def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> Non code should never call this. """ _COMPONENT_CACHE[domain] = manifest + + +# --------------------------------------------------------------------------- +# Component aliases (renamed-platform back-compat) +# --------------------------------------------------------------------------- +# +# A component can declare ``ALIASES = ["legacy_name"]`` (and optionally +# ``ALIAS_REMOVAL_VERSION = "YYYY.M.0"``) in its ``__init__.py``. Two +# integrations are then wired up automatically: +# +# 1. **Python imports** — a ``sys.meta_path`` finder (``_AliasFinder``) +# intercepts ``esphome.components.``/``....`` +# imports and resolves them against the canonical component so external +# custom components that still import from the old path keep working. +# +# 2. **YAML loader** — ``_lookup_module`` consults the alias map so +# ``get_component("legacy")`` returns the canonical manifest. The +# ``esphome.config`` pre-pass uses the same map to rewrite legacy +# top-level keys in the user's config (with a deprecation warning) so +# dependency checks, schema validation and codegen all see only the +# canonical name. +# +# Both lookups are populated by ``_build_alias_map``, which **AST-parses** +# every component's ``__init__.py`` rather than importing it. That keeps the +# cost low: scanning ~400 components on disk takes ~5 ms instead of the +# multi-second cost of executing every component's import side-effects. + + +_ALIAS_MAP_CACHE: dict[str, str] | None = None +_ALIAS_META_CACHE: dict[str, "AliasMeta"] | None = None + + +@dataclass(frozen=True) +class AliasMeta: + """Metadata for a single deprecated alias entry. + + Used by the YAML pre-pass in :mod:`esphome.config` to produce a + deprecation warning citing the canonical name and (optionally) the + removal version declared by the canonical component. + """ + + canonical: str + removal_version: str | None + + +def _ensure_alias_caches() -> None: + """Populate both alias caches from a single directory scan. + + ``_build_alias_map`` returns both maps together, so building them in one + shot avoids scanning every component's ``__init__.py`` twice when a run + needs both the canonical map (loader) and the metadata map (config + pre-pass). + """ + global _ALIAS_MAP_CACHE, _ALIAS_META_CACHE + if _ALIAS_MAP_CACHE is None or _ALIAS_META_CACHE is None: + _ALIAS_MAP_CACHE, _ALIAS_META_CACHE = _build_alias_map() + + +def _get_alias_map() -> dict[str, str]: + """Return the legacy-name → canonical-name map, building it lazily.""" + _ensure_alias_caches() + return _ALIAS_MAP_CACHE + + +def get_alias_metadata() -> dict[str, AliasMeta]: + """Return the legacy-name → :class:`AliasMeta` map (cached). + + Used by the YAML pre-pass to format a per-alias deprecation warning. + """ + _ensure_alias_caches() + return _ALIAS_META_CACHE + + +def _build_alias_map() -> tuple[dict[str, str], dict[str, AliasMeta]]: + """Scan every core component dir for ``ALIASES`` declarations. + + Uses :mod:`ast` to read each component's ``__init__.py`` without + executing it — component import side-effects (logger setup, + namespace registration, etc.) shouldn't run just because we're + enumerating aliases. + + Raises if the same alias is claimed by two canonical components, since + silently picking one would cause non-deterministic routing depending on + directory-iteration order. Also raises if an alias shadows an existing + component package: that would hijack a live component domain and, in the + self-alias case (alias == canonical), send ``_lookup_module`` into + infinite recursion redirecting a domain to itself. + """ + import ast + + alias_to_canonical: dict[str, str] = {} + alias_to_meta: dict[str, AliasMeta] = {} + + if not CORE_COMPONENTS_PATH.is_dir(): + return alias_to_canonical, alias_to_meta + + for child in sorted(CORE_COMPONENTS_PATH.iterdir()): + if not child.is_dir(): + continue + init = child / "__init__.py" + if not init.is_file(): + continue + aliases, removal_version = _read_aliases(init, ast) + if not aliases: + continue + canonical = child.name + for alias in aliases: + if (CORE_COMPONENTS_PATH / alias / "__init__.py").is_file(): + from esphome.core import EsphomeError + + raise EsphomeError( + f"Component alias '{alias}' (declared by '{canonical}') " + "shadows an existing component package of the same name. " + "An alias may only name a component that no longer exists." + ) + if alias in alias_to_canonical: + from esphome.core import EsphomeError + + raise EsphomeError( + f"Component alias '{alias}' is declared by both " + f"'{alias_to_canonical[alias]}' and '{canonical}'. " + "Each alias must map to exactly one canonical component." + ) + alias_to_canonical[alias] = canonical + alias_to_meta[alias] = AliasMeta( + canonical=canonical, removal_version=removal_version + ) + return alias_to_canonical, alias_to_meta + + +def _read_aliases( + init_path: Path, ast_module: ModuleType +) -> tuple[list[str], str | None]: + """Extract ``ALIASES`` and ``ALIAS_REMOVAL_VERSION`` from a component + ``__init__.py`` via AST parsing. + + Only handles the simple ``NAME = [str_literal, ...]`` / ``NAME = "..."`` + forms — anything more dynamic (function call, conditional, etc.) is + silently ignored. Components should keep their alias declarations + static so this scanner can see them. + """ + try: + source = init_path.read_text(encoding="utf-8") + except OSError as err: + _LOGGER.warning( + "Could not read %s while scanning for component aliases: %s", + init_path, + err, + ) + return [], None + + # Cheap substring pre-filter: almost no component declares ALIASES, and + # parsing every component __init__.py with ast is comparatively expensive. + # Skip the parse entirely unless the token appears in the file at all. + if "ALIASES" not in source: + return [], None + + try: + tree = ast_module.parse(source) + except SyntaxError as err: + _LOGGER.warning( + "Could not parse %s while scanning for component aliases: %s", + init_path, + err, + ) + return [], None + + aliases: list[str] = [] + removal_version: str | None = None + + for node in tree.body: + if not isinstance(node, ast_module.Assign): + continue + for target in node.targets: + if not isinstance(target, ast_module.Name): + continue + if target.id == "ALIASES" and isinstance(node.value, ast_module.List): + aliases.extend( + elt.value + for elt in node.value.elts + if isinstance(elt, ast_module.Constant) + and isinstance(elt.value, str) + ) + elif ( + target.id == "ALIAS_REMOVAL_VERSION" + and isinstance(node.value, ast_module.Constant) + and isinstance(node.value.value, str) + ): + removal_version = node.value.value + return aliases, removal_version + + +class _AliasFinder(importlib.abc.MetaPathFinder): + """``sys.meta_path`` finder that resolves legacy-component imports. + + Routes ``esphome.components.[.]`` to the canonical + component's module/submodule of the same name, so external code that + still imports ``from esphome.components.rp2040 import boards`` keeps + working without the canonical component having to maintain a shim + package on disk. + + The finder caches the resolved module in ``sys.modules`` under the + legacy name on first lookup, so subsequent imports hit the cache and + skip this finder entirely. + """ + + _PREFIX = "esphome.components." + + def find_spec(self, fullname, path, target=None): # noqa: ARG002 + if not fullname.startswith(self._PREFIX): + return None + # Anything matching the ``esphome.components.`` prefix splits into at + # least three parts, so ``parts[2]`` (the domain) always exists. + parts = fullname.split(".") + domain = parts[2] + alias_map = _get_alias_map() + if domain not in alias_map: + return None + + parts[2] = alias_map[domain] + canonical_fullname = ".".join(parts) + try: + canonical_module = importlib.import_module(canonical_fullname) + except ModuleNotFoundError as err: + # Only treat a missing *canonical target* as "no alias to + # resolve" (let the normal import machinery report it). If some + # other module is missing, the canonical exists but failed to + # import one of its own dependencies — surface that real error + # rather than masking it as an unresolved alias. + if err.name == canonical_fullname: + return None + raise + # Do NOT pre-populate ``sys.modules[fullname]`` here. Python's + # ``_find_spec`` (in importlib._bootstrap) has an optimization that + # detects ``name in sys.modules`` after a finder returns and prefers + # ``sys.modules[name].__spec__`` over the finder's spec — for an + # alias, that's the canonical module's own SourceFileLoader spec, + # which Python then *re-loads*, defeating the aliasing. Letting + # ``_load_unlocked`` populate sys.modules itself (via our + # ``_AliasLoader.create_module``) sidesteps that branch. + return importlib.util.spec_from_loader(fullname, _AliasLoader(canonical_module)) + + +class _AliasLoader(importlib.abc.Loader): + """No-op loader that returns the already-resolved canonical module. + + :class:`_AliasFinder` populates ``sys.modules`` itself; this loader + just satisfies the :mod:`importlib` protocol so Python doesn't try to + re-execute the module. + """ + + def __init__(self, module: ModuleType) -> None: + self._module = module + + def create_module(self, spec): # noqa: ARG002 + return self._module + + def exec_module(self, module): # noqa: ARG002 + # Nothing to execute — the canonical module is already initialized. + return None + + +# Register once at module load. Idempotent: re-installing the finder on +# repeated imports (e.g. by tests that reload `esphome.loader`) is a no-op +# because we check for an existing instance first. +def _install_alias_finder() -> None: + for entry in sys.meta_path: + if isinstance(entry, _AliasFinder): + return + sys.meta_path.append(_AliasFinder()) + + +_install_alias_finder() diff --git a/tests/unit_tests/test_loader.py b/tests/unit_tests/test_loader.py index 3fb0eca4a0..42e5203a73 100644 --- a/tests/unit_tests/test_loader.py +++ b/tests/unit_tests/test_loader.py @@ -1,8 +1,28 @@ """Unit tests for esphome.loader module.""" -from unittest.mock import MagicMock, patch +import ast +import logging +from pathlib import Path +import sys +import textwrap +from types import ModuleType +from unittest.mock import MagicMock, Mock, patch -from esphome.loader import ComponentManifest, _replace_component_manifest, get_component +import pytest +import voluptuous as vol + +from esphome import config as esphome_config, config_validation as cv +from esphome.core import CORE +import esphome.loader as loader_mod +from esphome.loader import ( + AliasMeta, + ComponentManifest, + _AliasFinder, + _build_alias_map, + _read_aliases, + _replace_component_manifest, + get_component, +) from tests.testing_helpers import ComponentManifestOverride # --------------------------------------------------------------------------- @@ -322,3 +342,642 @@ def test_component_manifest_resources_recursive_filter_source_files_supports_sub names = [r.resource for r in manifest.resources] assert names == ["wake/wake_freertos.cpp"] + + +# --------------------------------------------------------------------------- +# Component aliases (renamed-platform back-compat) +# --------------------------------------------------------------------------- +# +# These tests pin down the substrate behind `ALIASES = [...]` on component +# `__init__.py` files: the AST scanner, the resulting global alias map, the +# Python-import `sys.meta_path` finder, the `get_component` integration, and +# the YAML pre-pass that rewrites legacy top-level keys. +# +# The framework is component-agnostic, so the integration tests inject a +# synthetic alias map (pointing a fake legacy name at the real `esp32` +# component) rather than depending on any specific renamed component. + +# A legacy name that is NOT a real component, used as a synthetic alias. +_FAKE_ALIAS = "esp32_legacy_alias" + + +def _write_component(root: Path, name: str, body: str) -> None: + """Write a fake component package at ``root//__init__.py``.""" + pkg = root / name + pkg.mkdir() + (pkg / "__init__.py").write_text(body) + + +def test_read_aliases_extracts_list_literal(tmp_path: Path) -> None: + """AST scan should pick up ``ALIASES = ["legacy"]`` without executing.""" + init = tmp_path / "__init__.py" + init.write_text("ALIASES = ['legacy_name']\n") + aliases, removal = _read_aliases(init, ast) + assert aliases == ["legacy_name"] + assert removal is None + + +def test_read_aliases_extracts_removal_version(tmp_path: Path) -> None: + """``ALIAS_REMOVAL_VERSION`` should be paired with the alias list.""" + init = tmp_path / "__init__.py" + init.write_text( + textwrap.dedent("""\ + ALIASES = ['old'] + ALIAS_REMOVAL_VERSION = "2027.6.0" + """) + ) + aliases, removal = _read_aliases(init, ast) + assert aliases == ["old"] + assert removal == "2027.6.0" + + +def test_read_aliases_skips_dynamic_forms(tmp_path: Path) -> None: + """A call-expression / non-literal ALIASES shouldn't surface — the + scanner deliberately ignores anything non-static to keep behavior + predictable (and avoid executing component code).""" + init = tmp_path / "__init__.py" + init.write_text("ALIASES = list_helper()\nALIASES = ['caught'] if False else []\n") + aliases, _ = _read_aliases(init, ast) + assert aliases == [] + + +def test_read_aliases_returns_empty_for_missing_declaration(tmp_path: Path) -> None: + init = tmp_path / "__init__.py" + init.write_text("CODEOWNERS = ['@me']\n") + aliases, removal = _read_aliases(init, ast) + assert aliases == [] + assert removal is None + + +def test_read_aliases_handles_syntax_error( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A broken __init__.py shouldn't crash the alias scanner — it'll + surface as an ImportError elsewhere, but the scanner logs a warning and + yields nothing so other components keep working. The substring pre-filter + only skips files with no ``ALIASES`` token, so this file (which has one) + still reaches the parse.""" + init = tmp_path / "__init__.py" + init.write_text("ALIASES = ['x']\ndef broken( :\n") + assert _read_aliases(init, ast) == ([], None) + assert "Could not parse" in caplog.text + + +def test_read_aliases_handles_read_error( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """An unreadable __init__.py logs a warning and yields nothing rather + than aborting the whole component scan.""" + missing = tmp_path / "nope" / "__init__.py" + assert _read_aliases(missing, ast) == ([], None) + assert "Could not read" in caplog.text + + +def test_build_alias_map_aggregates_components(tmp_path: Path) -> None: + """End-to-end map build over a fake components dir.""" + _write_component(tmp_path, "newcomp", "ALIASES = ['oldcomp']\n") + _write_component(tmp_path, "other", "") + + with patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path): + alias_map, meta_map = _build_alias_map() + + assert alias_map == {"oldcomp": "newcomp"} + assert meta_map == {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)} + + +def test_build_alias_map_carries_removal_version(tmp_path: Path) -> None: + _write_component( + tmp_path, + "newcomp", + "ALIASES = ['oldcomp']\nALIAS_REMOVAL_VERSION = '2028.1.0'\n", + ) + + with patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path): + _, meta_map = _build_alias_map() + + assert meta_map["oldcomp"].removal_version == "2028.1.0" + + +def test_build_alias_map_rejects_duplicate_alias(tmp_path: Path) -> None: + """If two canonical components both claim the same legacy alias, + routing becomes ambiguous — the build must refuse to start so the + conflict surfaces immediately at import time, not later as a + 'mysterious wrong component' bug.""" + _write_component(tmp_path, "comp_a", "ALIASES = ['shared']\n") + _write_component(tmp_path, "comp_b", "ALIASES = ['shared']\n") + + from esphome.core import EsphomeError + + with ( + patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path), + pytest.raises(EsphomeError, match="shared"), + ): + _build_alias_map() + + +def test_build_alias_map_handles_missing_dir(tmp_path: Path) -> None: + """If the components directory doesn't exist (unlikely in production, + but possible in some test contexts), we want an empty map rather than + a crash — the rest of the loader can still function.""" + fake = tmp_path / "does-not-exist" + with patch("esphome.loader.CORE_COMPONENTS_PATH", fake): + alias_map, meta_map = _build_alias_map() + assert alias_map == {} + assert meta_map == {} + + +def test_build_alias_map_rejects_alias_shadowing_component(tmp_path: Path) -> None: + """An alias that names an existing component package is refused: it would + hijack a live domain, and a self-alias (alias == canonical) would send + ``_lookup_module`` into infinite recursion.""" + # `newcomp` declares itself as an alias — its own package already exists. + _write_component(tmp_path, "newcomp", "ALIASES = ['newcomp']\n") + + from esphome.core import EsphomeError + + with ( + patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path), + pytest.raises(EsphomeError, match="shadows an existing component"), + ): + _build_alias_map() + + +# ---- Integration against a synthetic alias map (fake legacy -> esp32) ---- + + +def _patch_alias_map(monkeypatch: pytest.MonkeyPatch, mapping: dict[str, str]) -> None: + """Force the loader's alias map (used by the finder and get_component). + + Patches the lazily-built caches so both ``_get_alias_map`` and the + installed meta-path finder resolve against ``mapping`` regardless of + what the real on-disk scan would produce. + """ + monkeypatch.setattr("esphome.loader._get_alias_map", lambda: mapping) + + +def test_get_component_resolves_alias(monkeypatch: pytest.MonkeyPatch) -> None: + """``get_component()`` should return the canonical manifest — every + caller of the loader (dep checker, schema validator, codegen) hits + the canonical component without knowing about the alias.""" + import esphome.loader as loader_mod + + _patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"}) + loader_mod._COMPONENT_CACHE.pop(_FAKE_ALIAS, None) + + canonical = get_component("esp32") + aliased = get_component(_FAKE_ALIAS) + assert canonical is not None + assert aliased is canonical + + +def test_alias_finder_resolves_top_level_import( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``import esphome.components.`` resolves to the canonical + module via the meta-path finder. ``_FAKE_ALIAS`` == ``esp32_legacy_alias``.""" + _patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"}) + sys.modules.pop(f"esphome.components.{_FAKE_ALIAS}", None) + + finder = _AliasFinder() + spec = finder.find_spec(f"esphome.components.{_FAKE_ALIAS}", None) + assert spec is not None + + import esphome.components.esp32 + import esphome.components.esp32_legacy_alias + + assert esphome.components.esp32_legacy_alias is esphome.components.esp32 + + +def test_alias_finder_resolves_submodule_import( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``from esphome.components. import boards`` routes through to + ``esphome.components.esp32.boards`` — same submodule object on both paths. + + The canonical submodule is imported first so its parent module carries + the ``boards`` attribute; ``from import boards`` then resolves + the aliased parent (via the finder) and reads that same attribute, + rather than triggering a fresh file load under the alias name. + ``_FAKE_ALIAS`` == ``esp32_legacy_alias``.""" + _patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"}) + sys.modules.pop(f"esphome.components.{_FAKE_ALIAS}", None) + + finder = _AliasFinder() + spec = finder.find_spec(f"esphome.components.{_FAKE_ALIAS}.boards", None) + assert spec is not None + + from esphome.components.esp32 import boards as canonical_boards + from esphome.components.esp32_legacy_alias import boards as aliased_boards + + assert aliased_boards is canonical_boards + + +def test_alias_finder_ignores_non_components_path() -> None: + """The finder must scope itself to ``esphome.components.`` — + everything else (other esphome submodules, third-party packages) is + left for the normal import machinery.""" + finder = _AliasFinder() + assert finder.find_spec("esphome.core", None) is None + assert finder.find_spec("os.path", None) is None + # `esphome.components` itself (no domain segment) is not a candidate. + assert finder.find_spec("esphome.components", None) is None + # A real, non-aliased component domain defers to normal import machinery + # (no component declares an alias in this repo, so the live map is empty). + assert finder.find_spec("esphome.components.logger", None) is None + + +# --------------------------------------------------------------------------- +# YAML pre-pass: top-level key rename + centralized deprecation warning +# --------------------------------------------------------------------------- +# +# The companion to the loader-side alias map: ``esphome.config`` runs a +# pre-pass over the user's parsed YAML that rewrites legacy top-level keys +# to their canonical names, surfacing a one-shot deprecation warning. These +# tests inject a synthetic alias-metadata map so the rewrite behavior, the +# warning text, and the both-keys-present conflict can be tested in isolation. + + +def _patch_alias_metadata( + monkeypatch: pytest.MonkeyPatch, mapping: dict[str, AliasMeta] +) -> None: + monkeypatch.setattr("esphome.loader.get_alias_metadata", lambda: mapping) + + +def test_resolve_component_aliases_renames_legacy_key( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """A legacy alias key should be renamed to the canonical key and a + deprecation warning citing the removal version logged.""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + {"oldcomp": AliasMeta(canonical="newcomp", removal_version="2027.6.0")}, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) # ensure the warning fires + config = {"esphome": {"name": "test"}, "oldcomp": {"board": "x"}} + + with caplog.at_level(logging.WARNING, logger="esphome.config"): + _resolve_component_aliases(config) + + assert "oldcomp" not in config + assert config["newcomp"] == {"board": "x"} + assert any( + "'oldcomp:' top-level key is deprecated" in record.message + and "rename it to 'newcomp:'" in record.message + and "2027.6.0" in record.message + for record in caplog.records + ) + + +def test_resolve_component_aliases_dedupes_warning_within_a_run( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """Schema validators can run twice (auto-load discovery + final pass) + so the rename pass must emit the warning only once per alias per run. + Deduped via ``CORE.data``; cleared between runs.""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) + with caplog.at_level(logging.WARNING, logger="esphome.config"): + _resolve_component_aliases({"oldcomp": {"board": "a"}}) + _resolve_component_aliases({"oldcomp": {"board": "b"}}) + + matches = [ + r + for r in caplog.records + if "'oldcomp:' top-level key is deprecated" in r.message + ] + assert len(matches) == 1 + + +def test_resolve_component_aliases_rejects_both_keys_present( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """If the user has BOTH legacy and canonical keys, silently dropping + one would hide a real misconfiguration. Raise instead.""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) + config = {"newcomp": {"board": "x"}, "oldcomp": {"board": "x"}} + with pytest.raises(vol.Invalid, match="Both 'oldcomp:'"): + _resolve_component_aliases(config) + + +def test_resolve_component_aliases_rejects_canonical_key_after_legacy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The both-keys conflict must be detected even when the canonical key + appears *after* the legacy key in the config (the up-front conflict + scan, not a position-dependent check).""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) + config = {"oldcomp": {"board": "x"}, "newcomp": {"board": "x"}} + with pytest.raises(vol.Invalid, match="Both 'oldcomp:'"): + _resolve_component_aliases(config) + + +def test_resolve_component_aliases_rejects_multiple_aliases_of_one_component( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Two different deprecated aliases of the same canonical component is + ambiguous — silently keeping one would hide a misconfiguration.""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + { + "oldcomp": AliasMeta(canonical="newcomp", removal_version=None), + "legacycomp": AliasMeta(canonical="newcomp", removal_version=None), + }, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) + config = {"oldcomp": {"board": "x"}, "legacycomp": {"board": "y"}} + with pytest.raises(vol.Invalid, match=r"Multiple deprecated aliases of 'newcomp:'"): + _resolve_component_aliases(config) + + +def test_resolve_component_aliases_preserves_key_position( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The renamed canonical key keeps the legacy key's original position + rather than being moved to the end of the config.""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) + config = {"esphome": {"name": "t"}, "oldcomp": {"board": "x"}, "logger": {}} + + _resolve_component_aliases(config) + + assert list(config) == ["esphome", "newcomp", "logger"] + + +def test_resolve_component_aliases_no_op_when_no_legacy_keys( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """The pre-pass must be a no-op (no warning, no mutation) for configs + that already use canonical keys.""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) + config = {"esphome": {"name": "test"}, "newcomp": {"board": "x"}} + original = dict(config) + + with caplog.at_level(logging.WARNING, logger="esphome.config"): + _resolve_component_aliases(config) + + assert config == original + assert not any("deprecated" in r.message for r in caplog.records) + + +# --------------------------------------------------------------------------- +# ComponentManifest alias properties +# --------------------------------------------------------------------------- + + +def test_component_manifest_alias_properties_default_empty() -> None: + """``aliases`` / ``alias_removal_version`` fall back to ``[]`` / ``None`` + when the component module declares neither. + + Uses a real ``ModuleType`` rather than a ``MagicMock`` so that the + ``getattr(..., default)`` fallback is actually exercised — a bare mock + auto-creates any attribute on access and would never hit the default.""" + mod = ModuleType("fake_component") + manifest = ComponentManifest(mod) + assert manifest.aliases == [] + assert manifest.alias_removal_version is None + + +def test_component_manifest_alias_properties_read_module_values() -> None: + """The properties surface the module's declared values verbatim.""" + mod = MagicMock() + mod.ALIASES = ["legacy"] + mod.ALIAS_REMOVAL_VERSION = "2027.6.0" + manifest = ComponentManifest(mod) + assert manifest.aliases == ["legacy"] + assert manifest.alias_removal_version == "2027.6.0" + + +# --------------------------------------------------------------------------- +# Real (unpatched) lazy build + cache and remaining scanner branches +# --------------------------------------------------------------------------- + + +def test_get_alias_map_real_build_and_caches(monkeypatch: pytest.MonkeyPatch) -> None: + """Exercise the real lazy build over the actual components dir (no patch): + the first call scans and caches, the second returns the cached object.""" + monkeypatch.setattr(loader_mod, "_ALIAS_MAP_CACHE", None) + first = loader_mod._get_alias_map() + second = loader_mod._get_alias_map() + assert isinstance(first, dict) + assert first is second # cached, not rebuilt on the second call + + +def test_get_alias_metadata_real_build_and_caches( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(loader_mod, "_ALIAS_META_CACHE", None) + first = loader_mod.get_alias_metadata() + second = loader_mod.get_alias_metadata() + assert isinstance(first, dict) + assert first is second + + +def test_build_alias_map_skips_files_and_initless_dirs(tmp_path: Path) -> None: + """Loose files and directories without an ``__init__.py`` are ignored; + only real component packages contribute to the map.""" + (tmp_path / "loose_file.py").write_text("ALIASES = ['ignored']\n") + (tmp_path / "initless").mkdir() # a dir, but no __init__.py + _write_component(tmp_path, "realcomp", "ALIASES = ['legacy']\n") + + with patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path): + alias_map, _ = _build_alias_map() + + assert alias_map == {"legacy": "realcomp"} + + +def test_read_aliases_ignores_non_assignment_and_complex_targets( + tmp_path: Path, +) -> None: + """Non-assignment statements and assignments to non-Name targets are + skipped; only simple ``NAME = ...`` assignments are read.""" + init = tmp_path / "__init__.py" + init.write_text( + "import os\n" # non-Assign (Import) node -> skipped + "obj.attr = 'v'\n" # Assign with an Attribute target -> skipped + "ALIASES = ['legacy']\n" + ) + aliases, _ = _read_aliases(init, ast) + assert aliases == ["legacy"] + + +# --------------------------------------------------------------------------- +# Finder / loader edge branches +# --------------------------------------------------------------------------- + + +def test_alias_finder_returns_none_when_canonical_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """If an alias points at a canonical *target* that doesn't exist, the + finder declines (returns None) and lets normal import machinery report + the missing module.""" + _patch_alias_map(monkeypatch, {"broken_alias": "definitely_not_a_real_component"}) + finder = _AliasFinder() + assert finder.find_spec("esphome.components.broken_alias", None) is None + + +def test_alias_finder_reraises_when_canonical_dependency_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """If the canonical module exists but fails to import one of its own + dependencies, the finder surfaces that real error instead of masking it + as an unresolved alias (which would silently fall through to a confusing + 'no module named ').""" + _patch_alias_map(monkeypatch, {"some_alias": "real_canonical"}) + + def boom(name: str) -> None: + raise ModuleNotFoundError("No module named 'missing_dep'", name="missing_dep") + + monkeypatch.setattr("esphome.loader.importlib.import_module", boom) + finder = _AliasFinder() + with pytest.raises(ModuleNotFoundError, match="missing_dep"): + finder.find_spec("esphome.components.some_alias", None) + + +def test_install_alias_finder_is_idempotent() -> None: + """The finder is installed once at import; calling the installer again is + a no-op (no duplicate ``_AliasFinder`` on ``sys.meta_path``).""" + before = [e for e in sys.meta_path if isinstance(e, _AliasFinder)] + assert len(before) == 1 # installed at module import time + loader_mod._install_alias_finder() + after = [e for e in sys.meta_path if isinstance(e, _AliasFinder)] + assert len(after) == 1 + + +def test_get_component_alias_to_missing_canonical_returns_none( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """If an alias resolves to a canonical component that can't be loaded, + ``get_component`` returns None and caches no bogus manifest.""" + _patch_alias_map(monkeypatch, {"ghost_alias": "definitely_not_a_real_component"}) + loader_mod._COMPONENT_CACHE.pop("ghost_alias", None) + + assert get_component("ghost_alias") is None + assert "ghost_alias" not in loader_mod._COMPONENT_CACHE + + +# --------------------------------------------------------------------------- +# YAML pre-pass: empty-map fast path + validate_config integration +# --------------------------------------------------------------------------- + + +def test_resolve_component_aliases_noop_when_no_aliases_declared( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When no component declares an alias, the pre-pass returns immediately + without inspecting or mutating the config.""" + from esphome.config import _resolve_component_aliases + + monkeypatch.setattr("esphome.loader.get_alias_metadata", dict) # empty map + config = {"esphome": {"name": "t"}, "rp2040": {"board": "x"}} + original = dict(config) + _resolve_component_aliases(config) + assert config == original + + +def _default_component_mock() -> Mock: + """A permissive component mock that validates any config (ALLOW_EXTRA).""" + return Mock( + auto_load=[], + is_platform_component=False, + is_platform=False, + multi_conf=False, + multi_conf_no_default=False, + dependencies=[], + conflicts_with=[], + config_schema=cv.Schema({}, extra=cv.ALLOW_EXTRA), + ) + + +@pytest.mark.usefixtures("setup_core") +def test_validate_config_renames_alias_key( + mock_get_component: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: + """End-to-end: a legacy top-level key is renamed to its canonical name + before the rest of ``validate_config`` runs, and validation succeeds. + + A real ``esp32`` target platform is included so ``preload_core_config`` + is satisfied and validation runs to completion (the renamed canonical + key is loaded via the mocked, permissive component).""" + mock_get_component.side_effect = lambda name: _default_component_mock() + monkeypatch.setattr( + "esphome.loader.get_alias_metadata", + lambda: { + "legacyfoo": AliasMeta(canonical="newcomp", removal_version="2027.6.0") + }, + ) + CORE.data.pop("_component_aliases_warned", None) + + raw_config = { + "esphome": {"name": "test"}, + "esp32": {"board": "esp32dev"}, + "legacyfoo": {"opt": 1}, + } + result = esphome_config.validate_config(raw_config, {}) + + assert not result.errors, f"unexpected errors: {result.errors}" + assert "newcomp" in result + assert "legacyfoo" not in result + + +@pytest.mark.usefixtures("setup_core") +def test_validate_config_reports_alias_conflict_as_error( + mock_get_component: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: + """If both the legacy and canonical keys are present, ``validate_config`` + surfaces the conflict as a config error (the ``vol.Invalid`` path).""" + mock_get_component.return_value = _default_component_mock() + monkeypatch.setattr( + "esphome.loader.get_alias_metadata", + lambda: {"legacyfoo": AliasMeta(canonical="newcomp", removal_version=None)}, + ) + CORE.data.pop("_component_aliases_warned", None) + + raw_config = { + "esphome": {"name": "test"}, + "newcomp": {"opt": 1}, + "legacyfoo": {"opt": 2}, + } + result = esphome_config.validate_config(raw_config, {}) + + assert result.errors + assert "Both 'legacyfoo:'" in str(result.errors) From ac6a0f34ecbaec6217e5701bcfa8b825ed0aa6f8 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:45:30 -0400 Subject: [PATCH 0445/1815] [esp32] Make ESP-IDF the default toolchain (#16910) --- esphome/components/esp32/__init__.py | 2 +- .../esp32/config/flash_mode_idf.yaml | 1 + tests/component_tests/esp32/test_esp32.py | 32 +++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 5d4b3b8b47..3ffec6b826 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -964,7 +964,7 @@ def _resolve_toolchain(value: ConfigType) -> ConfigType: # Runs before _detect_variant so downstream validators can rely on # CORE.toolchain instead of re-resolving it from the config dict. if CORE.toolchain is None: - CORE.toolchain = value.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO) + CORE.toolchain = value.get(CONF_TOOLCHAIN, Toolchain.ESP_IDF) return value diff --git a/tests/component_tests/esp32/config/flash_mode_idf.yaml b/tests/component_tests/esp32/config/flash_mode_idf.yaml index 7c7f50a439..d12d4a734b 100644 --- a/tests/component_tests/esp32/config/flash_mode_idf.yaml +++ b/tests/component_tests/esp32/config/flash_mode_idf.yaml @@ -5,5 +5,6 @@ esp32: board: esp32dev flash_mode: qio flash_frequency: 80MHz + toolchain: platformio framework: type: esp-idf diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index a8b5720a80..e3311f6860 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -64,6 +64,38 @@ def test_esp32_config( assert VARIANT_FRIENDLY[variant].lower() in config["board"] +@pytest.mark.parametrize( + ("config_toolchain", "expected"), + [ + # No `toolchain:` set -> the new default for esp32. + (None, Toolchain.ESP_IDF), + # An explicit `toolchain:` still wins over the default. + (Toolchain.PLATFORMIO.value, Toolchain.PLATFORMIO), + (Toolchain.ESP_IDF.value, Toolchain.ESP_IDF), + ], +) +def test_esp32_default_toolchain_is_esp_idf( + set_core_config: SetCoreConfigCallable, + config_toolchain: str | None, + expected: Toolchain, +) -> None: + """With no `toolchain:` set (and nothing pinned via the CLI), esp32 resolves + to the ESP-IDF toolchain; an explicit `toolchain:` still wins.""" + set_core_config(PlatformFramework.ESP32_IDF) + + from esphome.components.esp32 import CONFIG_SCHEMA + + # Fresh run: no --toolchain CLI and no prior config pinned CORE.toolchain. + CORE.toolchain = None + config: dict[str, Any] = {"variant": VARIANT_ESP32} + if config_toolchain is not None: + config["toolchain"] = config_toolchain + + CONFIG_SCHEMA(config) + + assert CORE.toolchain == expected + + @pytest.mark.parametrize( ("config", "error_match"), [ From 4b8568e94824341dd49aa8ee05d5f5927f65af9c Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Wed, 17 Jun 2026 21:54:29 -0400 Subject: [PATCH 0446/1815] [socket] bugfix Set wake-request gate flag on LwIP socket receive event (#17010) Co-authored-by: Claude Sonnet 4.6 --- esphome/core/lwip_fast_select.c | 7 +- esphome/core/wake/wake_freertos.cpp | 5 ++ esphome/core/wake/wake_host.cpp | 8 ++ .../fixtures/socket_wake_gate_tcp.yaml | 27 +++++++ .../integration/test_socket_wake_gate_tcp.py | 75 +++++++++++++++++++ 5 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 tests/integration/fixtures/socket_wake_gate_tcp.yaml create mode 100644 tests/integration/test_socket_wake_gate_tcp.py diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 36000d4e77..2042c43804 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -157,6 +157,8 @@ _Static_assert(offsetof(struct lwip_sock, rcvevent) == ESPHOME_LWIP_SOCK_RCVEVEN // Saved original event_callback pointer — written once in first hook_socket(), read from TCP/IP task. static netconn_callback s_original_callback = NULL; +extern void esphome_wake_loop_threadsafe(void); + #ifdef USE_OTA_PLATFORM_ESPHOME static struct netconn *s_ota_listener_conn = NULL; extern void esphome_wake_ota_component_any_context(void); @@ -189,10 +191,7 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt esphome_wake_ota_component_any_context(); } #endif - TaskHandle_t task = esphome_main_task_handle; - if (task != NULL) { - xTaskNotifyGive(task); - } + esphome_wake_loop_threadsafe(); } } diff --git a/esphome/core/wake/wake_freertos.cpp b/esphome/core/wake/wake_freertos.cpp index 0bf700daa8..458ef51f89 100644 --- a/esphome/core/wake/wake_freertos.cpp +++ b/esphome/core/wake/wake_freertos.cpp @@ -30,4 +30,9 @@ void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); } } // namespace esphome +extern "C" void esphome_wake_loop_threadsafe() { + esphome::wake_request_set(); + esphome_main_task_notify(); +} + #endif // USE_ESP32 || USE_LIBRETINY diff --git a/esphome/core/wake/wake_host.cpp b/esphome/core/wake/wake_host.cpp index 9d2a650ca2..8cb382a77e 100644 --- a/esphome/core/wake/wake_host.cpp +++ b/esphome/core/wake/wake_host.cpp @@ -123,6 +123,14 @@ void wakeable_delay(uint32_t ms) { if (ms == 0) [[unlikely]] { yield(); } + // A socket woke select() early — open the component-phase gate so the + // owning component's loop() drains the data on this tick rather than + // waiting up to loop_interval_ ms. Idempotent if wake_loop_threadsafe() + // already set the flag (wake socket fired); required when an application + // socket fired and nothing else set the flag. + if (ret > 0) { + wake_request_set(); + } return; } // ret < 0: error (EINTR is normal, anything else is unexpected). diff --git a/tests/integration/fixtures/socket_wake_gate_tcp.yaml b/tests/integration/fixtures/socket_wake_gate_tcp.yaml new file mode 100644 index 0000000000..4dbf89cbf0 --- /dev/null +++ b/tests/integration/fixtures/socket_wake_gate_tcp.yaml @@ -0,0 +1,27 @@ +esphome: + name: socket-wake-gate-tcp + on_boot: + priority: -100 + then: + - lambda: |- + // Raise loop_interval_ to 2000ms. Without wake_request_set() being + // called when select() returns due to socket data, the component + // phase would be gated for up to 2000ms after a TCP request arrives. + App.set_loop_interval(2000); + # Let boot transients and API handshake settle. + - delay: 500ms + - lambda: |- + ESP_LOGI("test", "BOOT_DONE"); + +host: + +api: + actions: + - action: ping + then: + - logger.log: + format: "PONG" + level: INFO + +logger: + level: INFO diff --git a/tests/integration/test_socket_wake_gate_tcp.py b/tests/integration/test_socket_wake_gate_tcp.py new file mode 100644 index 0000000000..2955d2803a --- /dev/null +++ b/tests/integration/test_socket_wake_gate_tcp.py @@ -0,0 +1,75 @@ +"""Test that a TCP socket receive opens the component-phase gate immediately. + +Regression test for the wake-request flag not being set when select() returns +due to socket data on the host platform (wake_host.cpp wakeable_delay fix). + +The API server's accepted connection sockets use accept_loop_monitored(), so +they are registered with the host select() loop. A service call from the Python +client arrives on that socket. Without the fix, select() returning early did not +set g_wake_requested, so Application::loop()'s Phase B gate stayed closed until +loop_interval_ expired. With the fix, the gate opens immediately. +""" + +from __future__ import annotations + +import asyncio +import time + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_socket_wake_gate_tcp( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """TCP socket receive must open the component-phase gate immediately, + even with loop_interval_ raised to 2000ms.""" + loop = asyncio.get_running_loop() + boot_done: asyncio.Future[None] = loop.create_future() + pong: asyncio.Future[None] = loop.create_future() + + def on_log_line(line: str) -> None: + if "BOOT_DONE" in line and not boot_done.done(): + boot_done.set_result(None) + if "PONG" in line and not pong.done(): + pong.set_result(None) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "socket-wake-gate-tcp" + + try: + await asyncio.wait_for(boot_done, timeout=15.0) + except TimeoutError: + pytest.fail("BOOT_DONE never appeared — device did not complete boot") + + _, services = await client.list_entities_services() + ping_service = next((s for s in services if s.name == "ping"), None) + assert ping_service is not None, "ping service not found" + + # Execute the service and time how long until PONG appears in logs. + # The request bytes arrive on an accept_loop_monitored() TCP socket, + # which is registered with the host select() loop. + t_send = time.monotonic() + await client.execute_service(ping_service, {}) + + try: + await asyncio.wait_for(pong, timeout=5.0) + except TimeoutError: + pytest.fail("PONG never appeared — service did not execute") + + elapsed_ms = (time.monotonic() - t_send) * 1000 + # Without the fix the gate stays closed for up to loop_interval_=2000ms. + # With the fix the gate opens on the next tick; 500ms gives ample CI headroom. + assert elapsed_ms < 500, ( + f"Service response took {elapsed_ms:.0f}ms with loop_interval_=2000ms — " + f"expected < 500ms; without the wake-request fix this would take up to 2000ms" + ) From f76dfd579cbe64e619440e04d70f39cf858edc09 Mon Sep 17 00:00:00 2001 From: Ardumine <61353807+Ardumine@users.noreply.github.com> Date: Thu, 18 Jun 2026 02:58:51 +0100 Subject: [PATCH 0447/1815] [openthread] Add basic Openthread support to Zephyr/nRF52 platform (#16854) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: tomaszduda23 Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/openthread/__init__.py | 72 +++++++-- esphome/components/openthread/openthread.cpp | 22 ++- esphome/components/openthread/openthread.h | 3 +- .../components/openthread/openthread_esp.cpp | 2 +- .../openthread/openthread_zephyr.cpp | 141 ++++++++++++++++++ esphome/components/zephyr/__init__.py | 7 +- .../openthread/test.nrf52-adafruit.yaml | 5 + 7 files changed, 229 insertions(+), 23 deletions(-) create mode 100644 esphome/components/openthread/openthread_zephyr.cpp create mode 100644 tests/components/openthread/test.nrf52-adafruit.yaml diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index bc1e91d6da..215f921229 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -10,6 +10,8 @@ from esphome.components.esp32 import ( require_vfs_select, ) from esphome.components.mdns import MDNSComponent, enable_mdns_storage +from esphome.components.zephyr import zephyr_add_prj_conf +from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( CONF_CHANNEL, @@ -20,6 +22,7 @@ from esphome.const import ( CONF_OUTPUT_POWER, CONF_USE_ADDRESS, PLATFORM_ESP32, + PlatformFramework, ) from esphome.core import ( CORE, @@ -52,7 +55,6 @@ AUTO_LOAD = ["network"] # Wi-fi / Bluetooth / Thread coexistence isn't implemented at this time # TODO: Doesn't conflict with wifi if you're using another ESP as an RCP (radio coprocessor), but this isn't implemented yet CONFLICTS_WITH = ["wifi"] -DEPENDENCIES = ["esp32"] IDF_TO_OT_LOG_LEVEL = { "NONE": "NONE", @@ -98,9 +100,7 @@ def set_sdkconfig_options(config): add_idf_sdkconfig_option("CONFIG_OPENTHREAD_ENABLED", True) - if tlv := config.get(CONF_TLV): - cg.add_define("USE_OPENTHREAD_TLVS", tlv) - else: + if not config.get(CONF_TLV): if pan_id := config.get(CONF_PAN_ID): add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_PANID", pan_id) @@ -128,9 +128,6 @@ def set_sdkconfig_options(config): "CONFIG_OPENTHREAD_NETWORK_PSKC", f"{pskc:X}".lower() ) - if config.get(CONF_FORCE_DATASET): - cg.add_define("USE_OPENTHREAD_FORCE_DATASET") - add_idf_sdkconfig_option("CONFIG_OPENTHREAD_DNS64_CLIENT", True) add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT", True) add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT_MAX_SERVICES", 5) @@ -159,6 +156,11 @@ _CONNECTION_SCHEMA = cv.Schema( def _validate(config: ConfigType) -> ConfigType: if CONF_USE_ADDRESS not in config: config[CONF_USE_ADDRESS] = f"{CORE.name}.local" + if CORE.using_zephyr and CONF_TLV not in config: + raise cv.Invalid( + "On nRF52, OpenThread credentials must be provided via 'tlv'. " + "Individual parameters (network_key, pan_id, channel, etc.) are not yet supported on this platform." + ) device_type = config.get(CONF_DEVICE_TYPE) poll_period = config.get(CONF_POLL_PERIOD) if ( @@ -175,11 +177,33 @@ def _validate(config: ConfigType) -> ConfigType: def _require_vfs_select(config): """Register VFS select requirement during config validation.""" - # OpenThread uses esp_vfs_eventfd which requires VFS select support - require_vfs_select() + # OpenThread uses esp_vfs_eventfd which requires VFS select support (ESP32 only) + if CORE.is_esp32: + require_vfs_select() return config +def _validate_platform(config): + if CORE.using_zephyr: + return config + return only_on_variant( + supported=[VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2] + )(config) + + +def _validate_tlv_hex(value): + s = cv.string_strict(value) + if len(s) % 2 != 0: + raise cv.Invalid("TLV must have an even number of hex characters") + try: + raw = bytes.fromhex(s) + except ValueError as e: + raise cv.Invalid(f"TLV must be valid hex: {e}") from e + if len(raw) > 254: # sizeof(otOperationalDatasetTlvs::mTlvs) + raise cv.Invalid(f"TLV too long ({len(raw)} bytes, max 254)") + return s + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -190,7 +214,7 @@ CONFIG_SCHEMA = cv.All( *CONF_DEVICE_TYPES, upper=True ), cv.Optional(CONF_FORCE_DATASET): cv.boolean, - cv.Optional(CONF_TLV): cv.string_strict, + cv.Optional(CONF_TLV): cv.All(cv.string_strict, _validate_tlv_hex), cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_POLL_PERIOD): cv.positive_time_period_milliseconds, cv.Optional(CONF_OUTPUT_POWER): cv.All( @@ -200,7 +224,7 @@ CONFIG_SCHEMA = cv.All( } ).extend(_CONNECTION_SCHEMA), cv.has_exactly_one_key(CONF_NETWORK_KEY, CONF_TLV), - only_on_variant(supported=[VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2]), + _validate_platform, _validate, _require_vfs_select, ) @@ -227,13 +251,27 @@ def _final_validate(_): FINAL_VALIDATE_SCHEMA = _final_validate +FILTER_SOURCE_FILES = filter_source_files_from_platform( + { + "openthread_esp.cpp": { + PlatformFramework.ESP32_IDF, + }, + "openthread_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, + } +) + @coroutine_with_priority(CoroPriority.COMMUNICATION) async def to_code(config): # Re-enable openthread IDF component (excluded by default) - include_builtin_idf_component("openthread") + if CORE.is_esp32: + include_builtin_idf_component("openthread") cg.add_define("USE_OPENTHREAD") + if config.get(CONF_FORCE_DATASET): + cg.add_define("USE_OPENTHREAD_FORCE_DATASET") + if tlv := config.get(CONF_TLV): + cg.add_define("USE_OPENTHREAD_TLVS", tlv) # OpenThread SRP needs access to mDNS services after setup enable_mdns_storage() @@ -252,4 +290,12 @@ async def to_code(config): if (output_power := config.get(CONF_OUTPUT_POWER)) is not None: cg.add(ot.set_output_power(output_power)) - set_sdkconfig_options(config) + if CORE.is_esp32: + set_sdkconfig_options(config) + elif CORE.using_zephyr: + zephyr_add_prj_conf("NET_L2_OPENTHREAD", True) + zephyr_add_prj_conf( + f"OPENTHREAD_NORDIC_LIBRARY_{config.get(CONF_DEVICE_TYPE)}", True + ) + zephyr_add_prj_conf(f"OPENTHREAD_{config.get(CONF_DEVICE_TYPE)}", True) + zephyr_add_prj_conf("MAIN_STACK_SIZE", 4096) diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index c8ffc02131..102424c62e 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -132,7 +132,7 @@ void OpenThreadSrpComponent::setup() { char *existing_host_name = otSrpClientBuffersGetHostNameString(instance, &size); const auto &host_name = App.get_name(); uint16_t host_name_len = host_name.size(); - if (host_name_len > size) { + if (host_name_len >= size) { ESP_LOGW(TAG, "Hostname is too long, choose a shorter project name"); return; } @@ -151,7 +151,7 @@ void OpenThreadSrpComponent::setup() { return; } - // Get mdns services and copy their data (strings are copied with strdup below) + // Get mdns services and copy their data (strdup on ESP32, pool_alloc_ on Zephyr) const auto &mdns_services = this->mdns_->get_services(); ESP_LOGD(TAG, "Setting up SRP services. count = %d\n", mdns_services.size()); for (const auto &service : mdns_services) { @@ -164,7 +164,7 @@ void OpenThreadSrpComponent::setup() { // Set service name char *string = otSrpClientBuffersGetServiceEntryServiceNameString(entry, &size); std::string full_service = std::string(MDNS_STR_ARG(service.service_type)) + "." + MDNS_STR_ARG(service.proto); - if (full_service.size() > size) { + if (full_service.size() >= size) { ESP_LOGW(TAG, "Service name too long: %s", full_service.c_str()); continue; } @@ -172,7 +172,7 @@ void OpenThreadSrpComponent::setup() { // Set instance name (using host_name) string = otSrpClientBuffersGetServiceEntryInstanceNameString(entry, &size); - if (host_name_len > size) { + if (host_name_len >= size) { ESP_LOGW(TAG, "Instance name too long: %s", host_name.c_str()); continue; } @@ -189,11 +189,21 @@ void OpenThreadSrpComponent::setup() { for (size_t i = 0; i < service.txt_records.size(); i++) { const auto &txt = service.txt_records[i]; // Value is either a compile-time string literal in flash or a pointer to dynamic_txt_values_ - // OpenThread SRP client expects the data to persist, so we strdup it + // OpenThread SRP client expects the data to persist, so we copy it const char *value_str = MDNS_STR_ARG(txt.value); txt_entries[i].mKey = MDNS_STR_ARG(txt.key); +#ifndef USE_ZEPHYR txt_entries[i].mValue = reinterpret_cast(strdup(value_str)); txt_entries[i].mValueLength = strlen(value_str); +#else + // strdup is not available on zephyr + // https:// github.com/zephyrproject-rtos/zephyr/issues/22464 + size_t value_len = strlen(value_str); + char *value_copy = reinterpret_cast(this->pool_alloc_(value_len + 1)); + memcpy(value_copy, value_str, value_len + 1); + txt_entries[i].mValue = reinterpret_cast(value_copy); + txt_entries[i].mValueLength = value_len; +#endif } entry->mService.mTxtEntries = txt_entries; entry->mService.mNumTxtEntries = service.txt_records.size(); @@ -233,7 +243,7 @@ bool OpenThreadComponent::teardown() { global_openthread_component = nullptr; ESP_LOGD(TAG, "Exit main loop "); int error = this->openthread_stop_(); - if (error != ESP_OK) { + if (error != 0) { ESP_LOGW(TAG, "Failed attempt to stop main loop %d", error); this->teardown_complete_ = true; } diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index 96f1abdb92..f1c79fb9cb 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -43,10 +43,11 @@ class OpenThreadComponent : public Component { void set_poll_period(uint32_t poll_period) { this->poll_period_ = poll_period; } #endif void set_output_power(int8_t output_power) { this->output_power_ = output_power; } + void set_connected(bool connected) { this->connected_ = connected; } + static void on_state_changed(otChangedFlags flags, void *context); protected: std::optional get_omr_address_(InstanceLock &lock); - static void on_state_changed(otChangedFlags flags, void *context); otInstance *get_openthread_instance_(); int openthread_stop_(); std::function factory_reset_external_callback_; diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index 4d88cbd226..6edaa98524 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -217,7 +217,7 @@ network::IPAddresses OpenThreadComponent::get_ip_addresses() { otInstance *OpenThreadComponent::get_openthread_instance_() { return esp_openthread_get_instance(); } InstanceLock InstanceLock::try_acquire(int delay) { - if (!global_openthread_component->is_lock_initialized()) { + if (global_openthread_component == nullptr || !global_openthread_component->is_lock_initialized()) { return InstanceLock(false); } return InstanceLock(esp_openthread_lock_acquire(delay)); diff --git a/esphome/components/openthread/openthread_zephyr.cpp b/esphome/components/openthread/openthread_zephyr.cpp new file mode 100644 index 0000000000..7b9f14ab8c --- /dev/null +++ b/esphome/components/openthread/openthread_zephyr.cpp @@ -0,0 +1,141 @@ +#include "esphome/core/defines.h" +#if defined(USE_OPENTHREAD) && defined(USE_NRF52) +#include +#include +#include +#include "openthread.h" +#include "esphome/core/helpers.h" +#include + +static const char *const TAG = "openthread"; + +namespace esphome::openthread { + +static void on_thread_state_changed(otChangedFlags flags, struct openthread_context *ot_context, void *user_data) { + // Delegate connection status tracking to common callback + if (global_openthread_component != nullptr) { + OpenThreadComponent::on_state_changed(flags, global_openthread_component); + } + if (flags & OT_CHANGED_THREAD_ROLE) { + otDeviceRole role = otThreadGetDeviceRole(ot_context->instance); + ESP_LOGI(TAG, "Thread role changed to %s", otThreadDeviceRoleToString(role)); + } + if (flags & OT_CHANGED_THREAD_NETDATA) { + ESP_LOGI(TAG, "Thread network data updated"); + } + if (flags & (OT_CHANGED_THREAD_ROLE | OT_CHANGED_THREAD_NETDATA)) { + char buf[NET_IPV6_ADDR_LEN]; + for (const otNetifAddress *addr = otIp6GetUnicastAddresses(ot_context->instance); addr != nullptr; + addr = addr->mNext) { + ESP_LOGI(TAG, " Address: %s", net_addr_ntop(AF_INET6, &addr->mAddress, buf, sizeof(buf))); + } + } +} + +static struct openthread_state_changed_cb ot_state_changed_cb = {.state_changed_cb = on_thread_state_changed}; + +void OpenThreadComponent::setup() { + struct openthread_context *context = openthread_get_default_context(); + this->lock_initialized_ = true; + otOperationalDatasetTlvs dataset = {}; + +#ifndef USE_OPENTHREAD_FORCE_DATASET + otError error = otDatasetGetActiveTlvs(context->instance, &dataset); + if (error != OT_ERROR_NONE) { + dataset.mLength = 0; + } else { + ESP_LOGI(TAG, "Found existing dataset, ignoring config (force_dataset: true to override)"); + } +#endif + +#ifdef USE_OPENTHREAD_TLVS + if (dataset.mLength == 0) { + const size_t tlv_chars = sizeof(USE_OPENTHREAD_TLVS) - 1; + if ((tlv_chars % 2) != 0) { + ESP_LOGE(TAG, "Invalid OpenThread TLV hex string length (must be even, got %zu)", tlv_chars); + this->mark_failed(); + return; + } + + size_t len = tlv_chars / 2; + if (len > sizeof(dataset.mTlvs)) { + ESP_LOGE(TAG, "OpenThread TLV too long (max %zu bytes, got %zu bytes)", sizeof(dataset.mTlvs), len); + this->mark_failed(); + return; + } + + size_t parsed = parse_hex(USE_OPENTHREAD_TLVS, tlv_chars, dataset.mTlvs, len); + if (parsed != tlv_chars) { + ESP_LOGE(TAG, "Invalid OpenThread TLV hex string (expected %zu hex chars, got %zu)", tlv_chars, parsed); + this->mark_failed(); + return; + } + dataset.mLength = len; + } +#endif + if (dataset.mLength > 0) { + otError error = otDatasetSetActiveTlvs(context->instance, &dataset); + if (error != OT_ERROR_NONE) { + ESP_LOGE(TAG, "Failed to set active dataset: %s", otThreadErrorToString(error)); + this->mark_failed(); + return; + } + } + openthread_state_changed_cb_register(context, &ot_state_changed_cb); + openthread_start(context); +} + +void OpenThreadComponent::ot_main() {} + +otInstance *OpenThreadComponent::get_openthread_instance_() { return openthread_get_default_instance(); } + +int OpenThreadComponent::openthread_stop_() { + // OT stack is intentionally left running — no Zephyr stop API. The state callback stays + // registered but is safe (null-checks global_openthread_component). nRF52840 never + // re-enters setup() after teardown so this is functionally correct. + this->teardown_complete_ = true; + return 0; +} + +network::IPAddresses OpenThreadComponent::get_ip_addresses() { + network::IPAddresses addresses; + auto lock = InstanceLock::acquire(); + size_t addr_count = 0; + for (const otNetifAddress *addr = otIp6GetUnicastAddresses(openthread_get_default_instance()); + addr != nullptr && addr_count + 1 < addresses.size(); addr = addr->mNext) { + struct in6_addr ip6; + memcpy(&ip6, addr->mAddress.mFields.m8, sizeof(ip6)); + addresses[addr_count + 1] = network::IPAddress(&ip6); + addr_count++; + } + return addresses; +} + +InstanceLock InstanceLock::try_acquire(int delay) { + if (global_openthread_component == nullptr || !global_openthread_component->is_lock_initialized()) { + return InstanceLock(false); + } + struct openthread_context *ot_context = openthread_get_default_context(); + if (k_mutex_lock(&ot_context->api_lock, K_MSEC(delay)) == 0) { + return InstanceLock(true); + } + return InstanceLock(false); +} + +InstanceLock InstanceLock::acquire() { + struct openthread_context *ot_context = openthread_get_default_context(); + k_mutex_lock(&ot_context->api_lock, K_FOREVER); + return InstanceLock(true); +} + +otInstance *InstanceLock::get_instance() { return openthread_get_default_instance(); } + +InstanceLock::~InstanceLock() { + if (this->owns_) { + struct openthread_context *ot_context = openthread_get_default_context(); + k_mutex_unlock(&ot_context->api_lock); + } +} + +} // namespace esphome::openthread +#endif diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index 57f5778d54..bd5f01aa3a 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -76,7 +76,10 @@ def zephyr_data() -> ZephyrData: def zephyr_add_prj_conf( - name: str, value: PrjConfValueType, required: bool = True, image: str = "" + name: str, + value: PrjConfValueType, + required: bool = True, + image: str = "", ) -> None: """Set an zephyr prj conf value.""" if not name.startswith("CONFIG_"): @@ -133,7 +136,7 @@ def zephyr_to_code(config: ConfigType) -> None: # os: ***** USAGE FAULT ***** # os: Illegal load of EXC_RETURN into PC - zephyr_add_prj_conf("MAIN_STACK_SIZE", 2048) + zephyr_add_prj_conf("MAIN_STACK_SIZE", 2048, required=False) CORE.add_job(_cdc_acm_to_code, config) diff --git a/tests/components/openthread/test.nrf52-adafruit.yaml b/tests/components/openthread/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..ac2fe63739 --- /dev/null +++ b/tests/components/openthread/test.nrf52-adafruit.yaml @@ -0,0 +1,5 @@ +network: + enable_ipv6: true + +openthread: + tlv: 0E080000000000010000 From b6763cfaed5dfd1a2d40b7e0d3f8866ac184a1bd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:02:07 +1200 Subject: [PATCH 0448/1815] [ci] Smoke-test docker image by compiling each target toolchain (#16995) Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/ci-docker.yml | 102 ++++++++++++++++-- docker/test_configs/bk72xx-arduino.yaml | 7 ++ .../test_configs/esp32-arduino-esp-idf.yaml | 10 ++ .../esp32-arduino-platformio.yaml | 10 ++ docker/test_configs/esp32-idf-esp-idf.yaml | 10 ++ docker/test_configs/esp32-idf-platformio.yaml | 10 ++ docker/test_configs/esp8266-arduino.yaml | 7 ++ docker/test_configs/host.yaml | 6 ++ docker/test_configs/ln882x-arduino.yaml | 7 ++ docker/test_configs/nrf52.yaml | 8 ++ docker/test_configs/rp2040-arduino.yaml | 7 ++ docker/test_configs/rtl87xx-arduino.yaml | 7 ++ 12 files changed, 180 insertions(+), 11 deletions(-) create mode 100644 docker/test_configs/bk72xx-arduino.yaml create mode 100644 docker/test_configs/esp32-arduino-esp-idf.yaml create mode 100644 docker/test_configs/esp32-arduino-platformio.yaml create mode 100644 docker/test_configs/esp32-idf-esp-idf.yaml create mode 100644 docker/test_configs/esp32-idf-platformio.yaml create mode 100644 docker/test_configs/esp8266-arduino.yaml create mode 100644 docker/test_configs/host.yaml create mode 100644 docker/test_configs/ln882x-arduino.yaml create mode 100644 docker/test_configs/nrf52.yaml create mode 100644 docker/test_configs/rp2040-arduino.yaml create mode 100644 docker/test_configs/rtl87xx-arduino.yaml diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 7d4b850356..373cd905b1 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -1,25 +1,38 @@ --- name: CI for docker images -# Only run when docker paths change +# Only run on PRs that touch the docker image, its build inputs, or any code +# whose toolchain the compile smoke test exercises (core + target platforms). on: - push: - branches: [dev, beta, release] - paths: - - "docker/**" - - ".github/workflows/ci-docker.yml" - - "requirements*.txt" - - "platformio.ini" - - "script/platformio_install_deps.py" - pull_request: paths: + # Docker image and its build inputs. - "docker/**" - ".github/workflows/ci-docker.yml" - "requirements*.txt" + - "pyproject.toml" - "platformio.ini" + - "esphome/idf_component.yml" - "script/platformio_install_deps.py" + # Core, build pipeline, toolchain, and target-platform changes can change + # how a toolchain is set up or built, so re-run the per-toolchain compile + # smoke test when they change. + - "esphome/core/**" + - "esphome/writer.py" + - "esphome/build_gen/**" + - "esphome/espidf/**" + - "esphome/platformio/**" + - "esphome/components/bk72xx/**" + - "esphome/components/esp32/**" + - "esphome/components/esp8266/**" + - "esphome/components/host/**" + - "esphome/components/libretiny/**" + - "esphome/components/ln882x/**" + - "esphome/components/nrf52/**" + - "esphome/components/rp2040/**" + - "esphome/components/rtl87xx/**" + - "esphome/components/zephyr/**" permissions: contents: read # actions/checkout only @@ -96,7 +109,26 @@ jobs: --arch "${{ matrix.os == 'ubuntu-24.04-arm' && 'aarch64' || 'amd64' }}" \ --build-type "${{ matrix.build_type }}" \ --registry ghcr \ - build ${{ steps.tag.outputs.push == 'true' && '--push --no-cache-to' || '' }} + build ${{ steps.tag.outputs.push == 'true' && '--push --no-cache-to' || '' }} ${{ (matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker') && '--load' || '' }} + + # The amd64 "docker" image is also loaded locally (above) and handed to + # compile-test as an artifact, so the smoke test reuses this build instead + # of building the image a second time. Using an artifact (rather than the + # pushed image) keeps it working for fork PRs, which never push to ghcr.io. + - name: Export image for compile-test + if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker' + run: docker save "ghcr.io/esphome/esphome-amd64:${{ steps.tag.outputs.tag }}" | gzip > compile-test-image.tar.gz + + - name: Upload compile-test image artifact + if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + # The tar is already gzipped, so upload it as-is. archive: false skips + # the redundant zip and makes the file name the artifact name (the + # `name` input is ignored in that mode). + path: compile-test-image.tar.gz + retention-days: 1 + archive: false manifest: name: Push ${{ matrix.build_type }} manifest to ghcr.io @@ -135,3 +167,51 @@ jobs: --build-type "${{ matrix.build_type }}" \ --registry ghcr \ manifest + + # Smoke-test the built image by compiling one minimal config per target + # platform / toolchain. This catches missing system dependencies in the image + # that only surface when a given toolchain is downloaded and run. The image is + # the amd64 "docker" build produced by check-docker (shared as an artifact). + compile-test: + name: Compile ${{ matrix.id }} + needs: check-docker + runs-on: ubuntu-24.04 + permissions: + contents: read # actions/checkout to load the test configs + strategy: + fail-fast: false + # Cap concurrency so this smoke test doesn't hog all the shared runners. + max-parallel: 2 + matrix: + # One entry per distinct toolchain. ESP32 variants (c3/c6/s2/s3/p4) + # share a toolchain bundle, so esp32 is exercised on the base variant + # across the full framework x toolchain cross-product (arduino/esp-idf + # framework, each built with the platformio and native esp-idf + # toolchains) so both toolchains stay covered regardless of which one is + # the default. + id: + - esp8266-arduino + - esp32-arduino-platformio + - esp32-arduino-esp-idf + - esp32-idf-platformio + - esp32-idf-esp-idf + - rp2040-arduino + - bk72xx-arduino + - rtl87xx-arduino + - ln882x-arduino + - nrf52 + - host + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Download image artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: compile-test-image.tar.gz + - name: Load image + run: docker load --input compile-test-image.tar.gz + - name: Compile ${{ matrix.id }} + run: | + docker run --rm \ + -v "${{ github.workspace }}/docker/test_configs:/config" \ + "ghcr.io/esphome/esphome-amd64:${{ needs.check-docker.outputs.tag }}" \ + compile "${{ matrix.id }}.yaml" diff --git a/docker/test_configs/bk72xx-arduino.yaml b/docker/test_configs/bk72xx-arduino.yaml new file mode 100644 index 0000000000..138aa9e282 --- /dev/null +++ b/docker/test_configs/bk72xx-arduino.yaml @@ -0,0 +1,7 @@ +esphome: + name: docker-test-bk72xx-arduino + +bk72xx: + board: generic-bk7231n-qfn32-tuya + +logger: diff --git a/docker/test_configs/esp32-arduino-esp-idf.yaml b/docker/test_configs/esp32-arduino-esp-idf.yaml new file mode 100644 index 0000000000..fbc68aff0c --- /dev/null +++ b/docker/test_configs/esp32-arduino-esp-idf.yaml @@ -0,0 +1,10 @@ +esphome: + name: docker-test-esp32-ard-idf + +esp32: + variant: esp32 + framework: + type: arduino + toolchain: esp-idf + +logger: diff --git a/docker/test_configs/esp32-arduino-platformio.yaml b/docker/test_configs/esp32-arduino-platformio.yaml new file mode 100644 index 0000000000..e216c02059 --- /dev/null +++ b/docker/test_configs/esp32-arduino-platformio.yaml @@ -0,0 +1,10 @@ +esphome: + name: docker-test-esp32-ard-pio + +esp32: + variant: esp32 + framework: + type: arduino + toolchain: platformio + +logger: diff --git a/docker/test_configs/esp32-idf-esp-idf.yaml b/docker/test_configs/esp32-idf-esp-idf.yaml new file mode 100644 index 0000000000..b180aa9c0a --- /dev/null +++ b/docker/test_configs/esp32-idf-esp-idf.yaml @@ -0,0 +1,10 @@ +esphome: + name: docker-test-esp32-idf-idf + +esp32: + variant: esp32 + framework: + type: esp-idf + toolchain: esp-idf + +logger: diff --git a/docker/test_configs/esp32-idf-platformio.yaml b/docker/test_configs/esp32-idf-platformio.yaml new file mode 100644 index 0000000000..5aec23e40d --- /dev/null +++ b/docker/test_configs/esp32-idf-platformio.yaml @@ -0,0 +1,10 @@ +esphome: + name: docker-test-esp32-idf-pio + +esp32: + variant: esp32 + framework: + type: esp-idf + toolchain: platformio + +logger: diff --git a/docker/test_configs/esp8266-arduino.yaml b/docker/test_configs/esp8266-arduino.yaml new file mode 100644 index 0000000000..80b52260e4 --- /dev/null +++ b/docker/test_configs/esp8266-arduino.yaml @@ -0,0 +1,7 @@ +esphome: + name: docker-test-esp8266-arduino + +esp8266: + board: d1_mini + +logger: diff --git a/docker/test_configs/host.yaml b/docker/test_configs/host.yaml new file mode 100644 index 0000000000..9f99069304 --- /dev/null +++ b/docker/test_configs/host.yaml @@ -0,0 +1,6 @@ +esphome: + name: docker-test-host + +host: + +logger: diff --git a/docker/test_configs/ln882x-arduino.yaml b/docker/test_configs/ln882x-arduino.yaml new file mode 100644 index 0000000000..4cff3a4883 --- /dev/null +++ b/docker/test_configs/ln882x-arduino.yaml @@ -0,0 +1,7 @@ +esphome: + name: docker-test-ln882x-arduino + +ln882x: + board: generic-ln882hki + +logger: diff --git a/docker/test_configs/nrf52.yaml b/docker/test_configs/nrf52.yaml new file mode 100644 index 0000000000..d6337149cc --- /dev/null +++ b/docker/test_configs/nrf52.yaml @@ -0,0 +1,8 @@ +esphome: + name: docker-test-nrf52 + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 + +logger: diff --git a/docker/test_configs/rp2040-arduino.yaml b/docker/test_configs/rp2040-arduino.yaml new file mode 100644 index 0000000000..4b5df11d87 --- /dev/null +++ b/docker/test_configs/rp2040-arduino.yaml @@ -0,0 +1,7 @@ +esphome: + name: docker-test-rp2040-arduino + +rp2040: + variant: rp2040 + +logger: diff --git a/docker/test_configs/rtl87xx-arduino.yaml b/docker/test_configs/rtl87xx-arduino.yaml new file mode 100644 index 0000000000..e8d9cf7503 --- /dev/null +++ b/docker/test_configs/rtl87xx-arduino.yaml @@ -0,0 +1,7 @@ +esphome: + name: docker-test-rtl87xx-arduino + +rtl87xx: + board: generic-rtl8710bn-2mb-788k + +logger: From 3a1a8a89559477cbab10c5b7bd73dacdd8edefef Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:02:22 +1200 Subject: [PATCH 0449/1815] [ci] Fail CI Status job when workflow is cancelled (#17024) Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b1032bcde..aca6d9007a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1369,4 +1369,7 @@ jobs: # 1. The target branch has a build issue independent of this PR # 2. This PR fixes a build issue on the target branch # In either case, we only care that the PR branch builds successfully. - echo "$NEEDS_JSON" | jq -e 'del(.["memory-impact-target-branch"]) | all(.result != "failure")' + # Every other job must have succeeded or been skipped; a "cancelled" or + # "failure" result fails this check so CI is not reported green when the + # workflow was cancelled. + echo "$NEEDS_JSON" | jq -e 'del(.["memory-impact-target-branch"]) | all(.result == "success" or .result == "skipped")' From c2784c9fd8a388a4edbc2ec208101fc72be9686a Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 17 Jun 2026 22:09:39 -0500 Subject: [PATCH 0450/1815] [esp32] Consolidate network/coexistence sdkconfig into a single reconciler (#17008) Co-authored-by: Claude Opus 4.8 (1M context) --- esphome/components/esp32/__init__.py | 126 ++++++++++- esphome/components/esp32/const.py | 1 + esphome/components/esp32_ble/__init__.py | 10 +- .../components/esp32_ble_beacon/__init__.py | 5 +- .../components/esp32_ble_server/__init__.py | 4 +- .../components/esp32_ble_tracker/__init__.py | 10 +- esphome/components/ethernet/__init__.py | 8 +- esphome/components/wifi/__init__.py | 9 +- .../esp32/config/network_ethernet_only.yaml | 17 ++ .../config/network_wifi_ble_coexistence.yaml | 14 ++ .../esp32/config/network_wifi_only.yaml | 11 + tests/component_tests/esp32/test_esp32.py | 195 +++++++++++++++++- 12 files changed, 382 insertions(+), 28 deletions(-) create mode 100644 tests/component_tests/esp32/config/network_ethernet_only.yaml create mode 100644 tests/component_tests/esp32/config/network_wifi_ble_coexistence.yaml create mode 100644 tests/component_tests/esp32/config/network_wifi_only.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 3ffec6b826..aee86a0554 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -69,6 +69,7 @@ from .const import ( KEY_FLASH_SIZE, KEY_FULL_CERT_BUNDLE, KEY_IDF_VERSION, + KEY_NETWORK_SDKCONFIG, KEY_PATH, KEY_REF, KEY_REPO, @@ -597,6 +598,59 @@ def add_idf_sdkconfig_option(name: str, value: SdkconfigValueType): CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS][name] = value +@dataclass +class NetworkSdkconfigData: + """Inputs for the network-related esp32 sdkconfig flags, reconciled at FINAL. + + Components call the request_*() helpers below (and esp32's own to_code fills + in enable_lwip_dhcp_server) instead of setting the WiFi/Ethernet/Bluetooth + sdkconfig flags directly; the single _reconcile_network_sdkconfig() coroutine + then decides the final values so they no longer depend on call order. + """ + + wifi: bool = False # WiFi component active (STA and/or AP) + wifi_ap: bool = False # WiFi AP mode configured + ethernet: bool = False # Ethernet component active + bluetooth: bool = False # any BLE component active + ble_42: bool = False # BLE 4.2 features needed + software_coexistence: bool = False # WiFi/BT software coexistence requested + # esp32 advanced enable_lwip_dhcp_server option (True/False/None=unset) + enable_lwip_dhcp_server: bool | None = None + + +def _network_sdkconfig() -> NetworkSdkconfigData: + data = CORE.data[KEY_ESP32] + if KEY_NETWORK_SDKCONFIG not in data: + data[KEY_NETWORK_SDKCONFIG] = NetworkSdkconfigData() + return data[KEY_NETWORK_SDKCONFIG] + + +def request_wifi(ap: bool = False) -> None: + """Request the WiFi stack. Pass ap=True when AP mode is configured.""" + net = _network_sdkconfig() + net.wifi = True + if ap: + net.wifi_ap = True + + +def request_ethernet() -> None: + """Request the Ethernet stack.""" + _network_sdkconfig().ethernet = True + + +def request_bluetooth(ble_42: bool = False) -> None: + """Request the Bluetooth controller. Pass ble_42=True for 4.2 features.""" + net = _network_sdkconfig() + net.bluetooth = True + if ble_42: + net.ble_42 = True + + +def request_software_coexistence() -> None: + """Request WiFi/BT software coexistence (only valid alongside WiFi).""" + _network_sdkconfig().software_coexistence = True + + def add_idf_component( *, name: str, @@ -1847,6 +1901,61 @@ async def _set_libc_picolibc_newlib_compat() -> None: ) +@coroutine_with_priority(CoroPriority.FINAL) +async def _reconcile_network_sdkconfig() -> None: + """Reconcile WiFi/Ethernet/Bluetooth/coexistence sdkconfig flags. + + Single decision point for flags that multiple components used to set + directly (and sometimes with conflicting values). Runs at FINAL priority so + every request_*() call (made from the various components' to_code at their + own priorities) is seen first. A user-supplied sdkconfig_options value + always takes precedence. + """ + net = CORE.data[KEY_ESP32].get(KEY_NETWORK_SDKCONFIG, NetworkSdkconfigData()) + opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + is_arduino = CORE.using_arduino + + def set_opt(name: str, value: SdkconfigValueType) -> None: + # User sdkconfig_options (applied during to_code) win. + if name not in opts: + add_idf_sdkconfig_option(name, value) + + # Bluetooth: only ever enable when requested. The IDF default is off and + # nothing sets these False today, so never write False here. + if net.bluetooth: + set_opt("CONFIG_BT_ENABLED", True) + if net.ble_42: + set_opt("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) + + # WiFi stack: disable only when Ethernet is present and WiFi is not. WiFi + # relies on the IDF default (enabled), so it is never written True here. + wifi_disabled = net.ethernet and not net.wifi + if wifi_disabled: + set_opt("CONFIG_ESP_WIFI_ENABLED", False) + + # Software coexistence: enable when requested (the schema only allows it + # alongside WiFi). Disable only in the Ethernet-without-WiFi case. + if net.software_coexistence: + set_opt("CONFIG_SW_COEXIST_ENABLE", True) + elif wifi_disabled: + set_opt("CONFIG_SW_COEXIST_ENABLE", False) + + # SoftAP support: drop it when WiFi is used without AP mode (IDF only). + if not is_arduino and net.wifi and not net.wifi_ap: + set_opt("CONFIG_ESP_WIFI_SOFTAP_SUPPORT", False) + + # LWIP DHCP server: a WiFi-AP-mode / enable_lwip_dhcp_server concern (not + # coexistence). Disable when WiFi has no AP (IDF) or the enable_lwip_dhcp_server + # option is set to false, unless Arduino+Ethernet needs the symbols to compile. + wifi_wants_dhcps_off = not is_arduino and net.wifi and not net.wifi_ap + dhcp_server_disabled_by_option = net.enable_lwip_dhcp_server is False + arduino_eth_exclusion = is_arduino and net.ethernet + if ( + wifi_wants_dhcps_off or dhcp_server_disabled_by_option + ) and not arduino_eth_exclusion: + set_opt("CONFIG_LWIP_DHCPS", False) + + @coroutine_with_priority(CoroPriority.FINAL) async def _add_yaml_idf_components(components: list[ConfigType]): """Add IDF components from YAML config with final priority to override code-added components.""" @@ -2171,14 +2280,12 @@ async def to_code(config): for component_name in advanced.get(CONF_INCLUDE_BUILTIN_IDF_COMPONENTS, []): include_builtin_idf_component(component_name) - # DHCP server: only disable if explicitly set to false - # WiFi component handles its own optimization when AP mode is not used - # When using Arduino with Ethernet, DHCP server functions must be available - # for the Network library to compile, even if not actively used - if advanced.get(CONF_ENABLE_LWIP_DHCP_SERVER) is False and not ( - conf[CONF_TYPE] == FRAMEWORK_ARDUINO and "ethernet" in CORE.loaded_integrations - ): - add_idf_sdkconfig_option("CONFIG_LWIP_DHCPS", False) + # DHCP server (CONFIG_LWIP_DHCPS) is reconciled in _reconcile_network_sdkconfig + # together with the WiFi component's own AP-mode optimization; record the user's + # advanced tristate (True/False/None) for it to consume at FINAL priority. + _network_sdkconfig().enable_lwip_dhcp_server = advanced.get( + CONF_ENABLE_LWIP_DHCP_SERVER + ) if not advanced[CONF_ENABLE_LWIP_MDNS_QUERIES]: add_idf_sdkconfig_option("CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES", False) if not advanced[CONF_ENABLE_LWIP_BRIDGE_INTERFACE]: @@ -2397,6 +2504,9 @@ async def to_code(config): # FINAL priority: runs after every require_libc_picolibc_newlib_compat() call CORE.add_job(_set_libc_picolibc_newlib_compat) + # FINAL priority: runs after every network/coexistence request_*() call + CORE.add_job(_reconcile_network_sdkconfig) + # Disable regi2c control functions in IRAM # Only needed if using analog peripherals (ADC, DAC, etc.) from ISRs while cache is disabled if advanced[CONF_DISABLE_REGI2C_IN_IRAM]: diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index 322054ea91..83fcfd233e 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -16,6 +16,7 @@ KEY_SUBMODULES = "submodules" KEY_EXTRA_BUILD_FILES = "extra_build_files" KEY_FULL_CERT_BUNDLE = "full_cert_bundle" KEY_IDF_VERSION = "idf_version" +KEY_NETWORK_SDKCONFIG = "network_sdkconfig" VARIANT_ESP32 = "ESP32" VARIANT_ESP32C2 = "ESP32C2" diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index c7b6b40394..c9fb42fde4 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -8,7 +8,12 @@ from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components.const import CONF_USE_PSRAM -from esphome.components.esp32 import add_idf_sdkconfig_option, const, get_esp32_variant +from esphome.components.esp32 import ( + add_idf_sdkconfig_option, + const, + get_esp32_variant, + request_bluetooth, +) from esphome.components.esp32.const import VARIANT_ESP32C2 import esphome.config_validation as cv from esphome.const import ( @@ -599,8 +604,7 @@ async def to_code(config): max_connections = config.get(CONF_MAX_CONNECTIONS, DEFAULT_MAX_CONNECTIONS) cg.add_define("USE_ESP32_BLE_MAX_CONNECTIONS", max_connections) - add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) - add_idf_sdkconfig_option("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) + request_bluetooth(ble_42=True) # When PSRAM and BT are used together, Bluedroid should prefer SPIRAM for # heap allocations and use dynamic (heap-based) environment memory tables diff --git a/esphome/components/esp32_ble_beacon/__init__.py b/esphome/components/esp32_ble_beacon/__init__.py index 8052c13596..7a59cce19b 100644 --- a/esphome/components/esp32_ble_beacon/__init__.py +++ b/esphome/components/esp32_ble_beacon/__init__.py @@ -1,6 +1,6 @@ import esphome.codegen as cg from esphome.components import esp32_ble -from esphome.components.esp32 import add_idf_sdkconfig_option +from esphome.components.esp32 import request_bluetooth from esphome.components.esp32_ble import CONF_BLE_ID import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_TX_POWER, CONF_TYPE, CONF_UUID @@ -86,5 +86,4 @@ async def to_code(config): cg.add_define("USE_ESP32_BLE_ADVERTISING") - add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) - add_idf_sdkconfig_option("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) + request_bluetooth(ble_42=True) diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index d45f2d9df2..ea2a9667d7 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -3,7 +3,7 @@ import encodings from esphome import automation import esphome.codegen as cg from esphome.components import esp32_ble -from esphome.components.esp32 import add_idf_sdkconfig_option +from esphome.components.esp32 import request_bluetooth from esphome.components.esp32_ble import BTLoggers, bt_uuid import esphome.config_validation as cv from esphome.config_validation import UNDEFINED @@ -632,7 +632,7 @@ async def to_code(config): ) cg.add_define("USE_ESP32_BLE_SERVER") cg.add_define("USE_ESP32_BLE_ADVERTISING") - add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) + request_bluetooth() @automation.register_action( diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index d758b400c4..e4139bed65 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -6,7 +6,11 @@ import logging from esphome import automation import esphome.codegen as cg from esphome.components import esp32_ble, ota -from esphome.components.esp32 import add_idf_sdkconfig_option +from esphome.components.esp32 import ( + add_idf_sdkconfig_option, + request_bluetooth, + request_software_coexistence, +) from esphome.components.esp32_ble import ( IDF_MAX_CONNECTIONS, BTLoggers, @@ -315,9 +319,9 @@ async def to_code(config): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [], conf) - add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) + request_bluetooth() if config.get(CONF_SOFTWARE_COEXISTENCE): - add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", True) + request_software_coexistence() # https://github.com/espressif/esp-idf/issues/4101 # https://github.com/espressif/esp-idf/issues/2503 # Match arduino CONFIG_BTU_TASK_STACK_SIZE diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 784f5dee8c..f6afc30ff2 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -540,6 +540,7 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: add_idf_sdkconfig_option, idf_version, include_builtin_idf_component, + request_ethernet, ) if config[CONF_TYPE] in SPI_ETHERNET_TYPES: @@ -586,10 +587,9 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: ) cg.add(var.add_phy_register(reg)) - # Disable WiFi when using Ethernet to save memory - add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENABLED", False) - # Also disable WiFi/BT coexistence since WiFi is disabled - add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", False) + # Register Ethernet with the esp32 sdkconfig reconciler, which disables the + # WiFi stack and WiFi/BT coexistence when Ethernet is used without WiFi. + request_ethernet() # Re-enable ESP-IDF's Ethernet driver (excluded by default to save compile time) include_builtin_idf_component("esp_eth") diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index b7719c80d1..080a7bb97b 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -10,6 +10,7 @@ from esphome.components.esp32 import ( const, get_esp32_variant, only_on_variant, + request_wifi, ) from esphome.components.network import ( has_high_performance_networking, @@ -594,9 +595,11 @@ async def to_code(config): ) cg.add(var.set_ap_timeout(conf[CONF_AP_TIMEOUT])) cg.add_define("USE_WIFI_AP") - elif CORE.is_esp32 and not CORE.using_arduino: - add_idf_sdkconfig_option("CONFIG_ESP_WIFI_SOFTAP_SUPPORT", False) - add_idf_sdkconfig_option("CONFIG_LWIP_DHCPS", False) + + # ESP32: register the WiFi stack with the esp32 sdkconfig reconciler, which + # drops SoftAP support / the LWIP DHCP server when AP mode is unused. + if CORE.is_esp32: + request_wifi(ap=CONF_AP in config) # Disable Enterprise WiFi support if no EAP is configured if CORE.is_esp32: diff --git a/tests/component_tests/esp32/config/network_ethernet_only.yaml b/tests/component_tests/esp32/config/network_ethernet_only.yaml new file mode 100644 index 0000000000..73d11e0a13 --- /dev/null +++ b/tests/component_tests/esp32/config/network_ethernet_only.yaml @@ -0,0 +1,17 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +ethernet: + type: W5500 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + clock_speed: 10Mhz diff --git a/tests/component_tests/esp32/config/network_wifi_ble_coexistence.yaml b/tests/component_tests/esp32/config/network_wifi_ble_coexistence.yaml new file mode 100644 index 0000000000..9aff46b7c4 --- /dev/null +++ b/tests/component_tests/esp32/config/network_wifi_ble_coexistence.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +esp32_ble_tracker: + software_coexistence: true diff --git a/tests/component_tests/esp32/config/network_wifi_only.yaml b/tests/component_tests/esp32/config/network_wifi_only.yaml new file mode 100644 index 0000000000..61dfde3e03 --- /dev/null +++ b/tests/component_tests/esp32/config/network_wifi_only.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index e3311f6860..bdba981c44 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -2,14 +2,25 @@ Test ESP32 configuration """ +import asyncio from collections.abc import Callable from pathlib import Path from typing import Any import pytest -from esphome.components.esp32 import VARIANT_ESP32, VARIANTS -from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS, KEY_VARIANT +from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANTS, + NetworkSdkconfigData, + _reconcile_network_sdkconfig, +) +from esphome.components.esp32.const import ( + KEY_ESP32, + KEY_NETWORK_SDKCONFIG, + KEY_SDKCONFIG_OPTIONS, + KEY_VARIANT, +) from esphome.components.esp32.gpio import validate_gpio_pin import esphome.config_validation as cv from esphome.const import ( @@ -343,3 +354,183 @@ def test_flash_mode_unset_leaves_defaults( assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHFREQ_") for key in sdkconfig) assert "board_build.flash_mode" not in CORE.platformio_options assert "board_build.f_flash" not in CORE.platformio_options + + +@pytest.mark.parametrize( + ("framework", "net", "preset", "expected"), + [ + # --- IDF: single-interface cases (must match pre-refactor behavior) --- + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData(wifi=True), + {}, + { + "CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False, + "CONFIG_LWIP_DHCPS": False, + }, + id="idf_wifi_no_ap", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData(wifi=True, wifi_ap=True), + {}, + {}, + id="idf_wifi_ap_leaves_softap_dhcps", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData(ethernet=True), + {}, + { + "CONFIG_ESP_WIFI_ENABLED": False, + "CONFIG_SW_COEXIST_ENABLE": False, + }, + id="idf_ethernet_only", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData( + wifi=True, bluetooth=True, ble_42=True, software_coexistence=True + ), + {}, + { + "CONFIG_BT_ENABLED": True, + "CONFIG_BT_BLE_42_FEATURES_SUPPORTED": True, + "CONFIG_SW_COEXIST_ENABLE": True, + "CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False, + "CONFIG_LWIP_DHCPS": False, + }, + id="idf_wifi_ble_tracker_coexistence", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData(bluetooth=True), + {}, + {"CONFIG_BT_ENABLED": True}, + id="idf_ble_server_only_no_ble42", + ), + # --- IDF: user sdkconfig_options always win --- + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData(wifi=True), + {"CONFIG_ESP_WIFI_SOFTAP_SUPPORT": True}, + { + "CONFIG_ESP_WIFI_SOFTAP_SUPPORT": True, + "CONFIG_LWIP_DHCPS": False, + }, + id="idf_user_override_wins", + ), + # --- IDF: user advanced enable_lwip_dhcp_server: false, even with AP --- + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData( + wifi=True, wifi_ap=True, enable_lwip_dhcp_server=False + ), + {}, + {"CONFIG_LWIP_DHCPS": False}, + id="idf_user_disables_dhcps_with_ap", + ), + # --- IDF: WiFi + Ethernet coexist (the multi-interface unlock) --- + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData(wifi=True, ethernet=True), + {}, + { + "CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False, + "CONFIG_LWIP_DHCPS": False, + }, + id="idf_wifi_and_ethernet_keeps_wifi_enabled", + ), + # --- Arduino: SoftAP/DHCPS disable is IDF-only --- + pytest.param( + PlatformFramework.ESP32_ARDUINO, + NetworkSdkconfigData(wifi=True), + {}, + {}, + id="arduino_wifi_no_ap_untouched", + ), + pytest.param( + PlatformFramework.ESP32_ARDUINO, + NetworkSdkconfigData(ethernet=True), + {}, + { + "CONFIG_ESP_WIFI_ENABLED": False, + "CONFIG_SW_COEXIST_ENABLE": False, + }, + id="arduino_ethernet_only_disables_wifi", + ), + # --- Arduino + Ethernet: DHCPS stays available even if user disabled it --- + pytest.param( + PlatformFramework.ESP32_ARDUINO, + NetworkSdkconfigData(ethernet=True, enable_lwip_dhcp_server=False), + {}, + { + "CONFIG_ESP_WIFI_ENABLED": False, + "CONFIG_SW_COEXIST_ENABLE": False, + }, + id="arduino_ethernet_dhcps_exclusion", + ), + ], +) +def test_reconcile_network_sdkconfig( + set_core_config: SetCoreConfigCallable, + framework: PlatformFramework, + net: NetworkSdkconfigData, + preset: dict[str, Any], + expected: dict[str, Any], +) -> None: + """The FINAL-priority reconciler resolves WiFi/Ethernet/Bluetooth/coexistence + sdkconfig flags from the requests recorded in NetworkSdkconfigData.""" + set_core_config(framework) + CORE.data[KEY_ESP32] = { + KEY_SDKCONFIG_OPTIONS: dict(preset), + KEY_NETWORK_SDKCONFIG: net, + } + + asyncio.run(_reconcile_network_sdkconfig()) + + assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == expected + + +def test_network_wifi_only_reconciles_end_to_end( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """End-to-end: codegen for an ESP-IDF WiFi (no AP) config runs the reconciler + after wifi's request_wifi(), disabling SoftAP support and the DHCP server.""" + generate_main(component_config_path("network_wifi_only.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False + assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False + # WiFi stack stays enabled (no ethernet) and no Bluetooth requested. + assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig + assert "CONFIG_BT_ENABLED" not in sdkconfig + + +def test_network_ethernet_only_reconciles_end_to_end( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """End-to-end: ethernet's request_ethernet() makes the reconciler disable the + WiFi stack and coexistence when WiFi is absent.""" + generate_main(component_config_path("network_ethernet_only.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_ESP_WIFI_ENABLED") is False + assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is False + + +def test_network_wifi_ble_coexistence_reconciles_end_to_end( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """End-to-end: WiFi + esp32_ble_tracker software_coexistence resolves to + BT enabled and coexistence on, with SoftAP/DHCP server dropped (no AP).""" + generate_main(component_config_path("network_wifi_ble_coexistence.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_BT_ENABLED") is True + assert sdkconfig.get("CONFIG_BT_BLE_42_FEATURES_SUPPORTED") is True + assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is True + assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False + assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False + # WiFi present alongside BT -> WiFi stack must stay enabled. + assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig From bd9375117a91d86854a81f0ba7090b2678309a92 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Jun 2026 22:11:24 -0500 Subject: [PATCH 0451/1815] [core] Honor transferred address cache in has_resolvable_address (#17025) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/__main__.py | 6 ++++++ tests/unit_tests/test_main.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/esphome/__main__.py b/esphome/__main__.py index f7d3f8e834..27dd878495 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -504,6 +504,12 @@ def has_resolvable_address() -> bool: if has_ip_address(): return True + # The dashboard pre-resolves the device and passes the IPs via + # --mdns-address-cache/--dns-address-cache; honor a cached address even when the + # device has mDNS disabled (e.g. a .local host found via ping). + if CORE.address_cache and CORE.address_cache.get_addresses(CORE.address): + return True + if has_mdns(): return True diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 03c005dc27..e44f746a75 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -689,6 +689,25 @@ def test_choose_upload_log_host_with_ota_device_with_ota_config() -> None: assert result == ["192.168.1.100"] +def test_choose_upload_log_host_ota_mdns_disabled_uses_address_cache() -> None: + """A .local device with mDNS disabled resolves via the dashboard-supplied cache.""" + setup_core( + config={ + CONF_API: {}, + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], + CONF_MDNS: {CONF_DISABLED: True}, + }, + address="esp32-a1s.local", + ) + CORE.address_cache = AddressCache(mdns_cache={"esp32-a1s.local": ["192.168.1.50"]}) + + for purpose in (Purpose.LOGGING, Purpose.UPLOADING): + result = choose_upload_log_host( + default="OTA", check_default=None, purpose=purpose + ) + assert result == ["192.168.1.50"] + + def test_choose_upload_log_host_with_ota_device_with_api_config() -> None: """Test OTA device when API is configured (no upload without OTA in config).""" setup_core(config={CONF_API: {}}, address="192.168.1.100") @@ -3135,6 +3154,22 @@ def test_has_resolvable_address() -> None: setup_core(config={CONF_MDNS: {CONF_DISABLED: True}}, address=None) assert has_resolvable_address() is False + # mDNS disabled + .local, but the dashboard cached the address -> resolvable + setup_core( + config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local" + ) + CORE.address_cache = AddressCache( + mdns_cache={"esphome-device.local": ["192.168.1.100"]} + ) + assert has_resolvable_address() is True + + # mDNS disabled + .local, cache present but missing this host -> not resolvable + setup_core( + config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local" + ) + CORE.address_cache = AddressCache(mdns_cache={"other-device.local": ["10.0.0.1"]}) + assert has_resolvable_address() is False + def test_has_name_add_mac_suffix() -> None: """Test has_name_add_mac_suffix function.""" From d4b642608793a06249656ea16f26d5d97bcb58e6 Mon Sep 17 00:00:00 2001 From: "Thomas A." Date: Thu, 18 Jun 2026 05:12:22 +0200 Subject: [PATCH 0452/1815] [esp32] Pin Names for Seeed XIAO C3 / C6 / S3 (#17002) Co-authored-by: Thomas A <1294885+zeroflow@users.noreply.github.com> Co-authored-by: Claude --- esphome/components/esp32/boards.py | 90 +++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/boards.py b/esphome/components/esp32/boards.py index 6062631d98..729b0c89ab 100644 --- a/esphome/components/esp32/boards.py +++ b/esphome/components/esp32/boards.py @@ -1240,6 +1240,43 @@ ESP32_BOARD_PINS = { "LED_BUILTINB": 4, }, "sensesiot_weizen": {}, + # Source: https://wiki.seeedstudio.com/XIAO_ESP32C3_Getting_Started/ + # The XIAO ESP32-C3 has no user-controllable LED (only a hardwired charge + # LED), so LED/LED_BUILTIN are intentionally omitted. The Ax keys override + # the incorrect ESP32_BASE_PINS A* fallback (which otherwise makes pin: A0 + # resolve to phantom GPIO36 and pin: A1/A2 raise cv.Invalid). + "seeed_xiao_esp32c3": { + "D0": 2, + "D1": 3, + "D2": 4, + "D3": 5, + "D4": 6, + "D5": 7, + "D6": 21, + "D7": 20, + "D8": 8, + "D9": 9, + "D10": 10, + "MTDO": 7, + "MTCK": 6, + "MTDI": 5, + "MTMS": 4, + "BOOT": 9, + "TX": 21, + "RX": 20, + "SDA": 6, + "SCL": 7, + "SCK": 8, + "MISO": 9, + "MOSI": 10, + "A0": 2, + "A1": 3, + "A2": 4, + "A3": 5, + }, + # Source: https://wiki.seeedstudio.com/xiao_esp32c6_getting_started/ + # The Ax keys override the incorrect ESP32_BASE_PINS A* fallback (which + # otherwise makes pin: A0 resolve to phantom GPIO36). "seeed_xiao_esp32c6": { "D0": 0, "D1": 1, @@ -1257,10 +1294,59 @@ ESP32_BOARD_PINS = { "MTDI": 5, "MTMS": 4, "BOOT": 9, - "LED": 8, - "LED_BUILTIN": 8, + "LED": 15, # Bugfix: was GPIO8; the yellow user LED is GPIO15 + "LED_BUILTIN": 15, # Bugfix: was GPIO8; the yellow user LED is GPIO15 "RF_SWITCH_EN": 3, "RF_ANT_SELECT": 14, + "TX": 16, + "RX": 17, + "SDA": 22, + "SCL": 23, + "SCK": 19, + "MISO": 20, + "MOSI": 18, + "A0": 0, + "A1": 1, + "A2": 2, + }, + # Source: https://wiki.seeedstudio.com/xiao_esp32s3_getting_started/ + # LED (GPIO21) is active-LOW; BOOT=GPIO0 is the standard ESP32-S3 strapping + # pin. The Ax keys override the incorrect ESP32_BASE_PINS A* fallback for the + # published silkscreen set. A6/A7 are intentionally absent (D6/D7 = GPIO43/44 + # have no ADC); because ESP32_BASE_PINS already defines A6=34/A7=35, pin: A6/A7 + # still resolve to those classic-ESP32 phantom values via the base-pins + # fallback (a disclosed residual, not fixable without editing ESP32_BASE_PINS). + "seeed_xiao_esp32s3": { + "D0": 1, + "D1": 2, + "D2": 3, + "D3": 4, + "D4": 5, + "D5": 6, + "D6": 43, + "D7": 44, + "D8": 7, + "D9": 8, + "D10": 9, + "BOOT": 0, + "LED": 21, + "LED_BUILTIN": 21, + "TX": 43, + "RX": 44, + "SDA": 5, + "SCL": 6, + "SCK": 7, + "MISO": 8, + "MOSI": 9, + "A0": 1, + "A1": 2, + "A2": 3, + "A3": 4, + "A4": 5, + "A5": 6, + "A8": 7, + "A9": 8, + "A10": 9, }, "sg-o_airMon": {}, "sparkfun_lora_gateway_1-channel": {"MISO": 12, "MOSI": 13, "SCK": 14, "SS": 16}, From 9ace0ffb262a3cbbc24822a3ff036d1116fd39ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:15:15 -0400 Subject: [PATCH 0453/1815] Bump pylint from 4.0.5 to 4.0.6 (#16983) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 5ba806a2f5..438d6cd005 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,4 @@ -pylint==4.0.5 +pylint==4.0.6 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating ruff==0.15.17 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating From 26c42af35478ff74d7a02ef7ae9645508b0e71d7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:15:46 -0400 Subject: [PATCH 0454/1815] Bump awalsh128/cache-apt-pkgs-action from 1.6.0 to 1.6.1 (#16986) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aca6d9007a..6ff846e4b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -820,7 +820,7 @@ jobs: run: echo ${{ matrix.components }} - name: Cache apt packages - uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3 + uses: awalsh128/cache-apt-pkgs-action@681749ae568c81c2037cb9185e38b709b261bd2f # v1.5.3 with: packages: libsdl2-dev version: 1.0 From 3b2564bbf3b7a31fae5794185fb740aca6b5cd3a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:15:56 -0400 Subject: [PATCH 0455/1815] Bump cryptography from 48.0.1 to 49.0.0 (#16985) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4ef3df60ff..efb5ec8723 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -cryptography==48.0.1 +cryptography==49.0.0 voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 From c63bed8c217ebb127c7e9ffdf776c08207db7d26 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:16:04 -0400 Subject: [PATCH 0456/1815] Bump pytest from 9.0.3 to 9.1.0 (#16981) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 438d6cd005..fc9681921a 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -5,7 +5,7 @@ pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit # Unit tests -pytest==9.0.3 +pytest==9.1.0 pytest-cov==7.1.0 pytest-mock==3.15.1 pytest-asyncio==1.4.0 From 2b38e4b7e2f0cfbd49a782855ff94373100916f5 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 17 Jun 2026 23:23:18 -0400 Subject: [PATCH 0457/1815] [audio] Bump microMP3 to v0.3.0 (#17009) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/audio/__init__.py | 6 +++--- esphome/idf_component.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 2aceff0c97..091f496e33 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -395,11 +395,11 @@ async def to_code(config): ) if data.mp3_support: cg.add_define("USE_AUDIO_MP3_SUPPORT") - add_idf_component(name="esphome/micro-mp3", ref="0.2.3") + add_idf_component(name="esphome/micro-mp3", ref="0.3.0") _emit_memory_pair( data.mp3.buffer_memory, - "CONFIG_MP3_DECODER_PREFER_PSRAM", - "CONFIG_MP3_DECODER_PREFER_INTERNAL", + "CONFIG_MICRO_MP3_PREFER_PSRAM", + "CONFIG_MICRO_MP3_PREFER_INTERNAL", ) if data.opus_support: cg.add_define("USE_AUDIO_OPUS_SUPPORT") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 5f3000e52d..b3b670d77b 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -12,7 +12,7 @@ dependencies: esphome/micro-flac: version: 0.2.0 esphome/micro-mp3: - version: 0.2.3 + version: 0.3.0 esphome/micro-opus: version: 0.4.1 esphome/micro-wav: From 11deff2bed04b9c887c311889cd35abb60efec3d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:35:03 +1200 Subject: [PATCH 0458/1815] Mark configurable classes as final (1/21: a01nyub-aqi) (#16952) --- esphome/components/a01nyub/a01nyub.h | 2 +- esphome/components/a02yyuw/a02yyuw.h | 2 +- esphome/components/a4988/a4988.h | 2 +- .../absolute_humidity/absolute_humidity.h | 2 +- esphome/components/ac_dimmer/ac_dimmer.h | 2 +- esphome/components/adc/adc_sensor.h | 2 +- esphome/components/adc128s102/adc128s102.h | 6 +++--- .../adc128s102/sensor/adc128s102_sensor.h | 8 ++++---- .../addressable_light_display.h | 2 +- esphome/components/ade7880/ade7880.h | 2 +- esphome/components/ade7953_i2c/ade7953_i2c.h | 2 +- esphome/components/ads1115/ads1115.h | 2 +- .../ads1115/sensor/ads1115_sensor.h | 8 ++++---- esphome/components/ads1118/ads1118.h | 6 +++--- .../ads1118/sensor/ads1118_sensor.h | 8 ++++---- esphome/components/ags10/ags10.h | 6 +++--- esphome/components/aht10/aht10.h | 2 +- esphome/components/aic3204/aic3204.h | 2 +- esphome/components/aic3204/automation.h | 2 +- .../airthings_ble/airthings_listener.h | 2 +- .../airthings_wave_mini/airthings_wave_mini.h | 2 +- .../airthings_wave_plus/airthings_wave_plus.h | 2 +- .../alarm_control_panel/automation.h | 14 +++++++------- esphome/components/alpha3/alpha3.h | 2 +- esphome/components/am2315c/am2315c.h | 2 +- esphome/components/am2320/am2320.h | 2 +- esphome/components/am43/cover/am43_cover.h | 2 +- esphome/components/am43/sensor/am43_sensor.h | 2 +- .../analog_threshold_binary_sensor.h | 2 +- esphome/components/animation/animation.h | 8 ++++---- esphome/components/anova/anova.h | 2 +- esphome/components/apds9306/apds9306.h | 2 +- esphome/components/apds9960/apds9960.h | 2 +- esphome/components/api/api_server.h | 2 +- .../components/api/homeassistant_service.h | 2 +- esphome/components/api/user_services.h | 19 ++++++++++--------- esphome/components/aqi/aqi_sensor.h | 2 +- 37 files changed, 70 insertions(+), 69 deletions(-) diff --git a/esphome/components/a01nyub/a01nyub.h b/esphome/components/a01nyub/a01nyub.h index 5c0d20bd37..69636eb8e4 100644 --- a/esphome/components/a01nyub/a01nyub.h +++ b/esphome/components/a01nyub/a01nyub.h @@ -8,7 +8,7 @@ namespace esphome::a01nyub { -class A01nyubComponent : public sensor::Sensor, public Component, public uart::UARTDevice { +class A01nyubComponent final : public sensor::Sensor, public Component, public uart::UARTDevice { public: // Nothing really public. diff --git a/esphome/components/a02yyuw/a02yyuw.h b/esphome/components/a02yyuw/a02yyuw.h index 693bcfd03c..2e71651301 100644 --- a/esphome/components/a02yyuw/a02yyuw.h +++ b/esphome/components/a02yyuw/a02yyuw.h @@ -8,7 +8,7 @@ namespace esphome::a02yyuw { -class A02yyuwComponent : public sensor::Sensor, public Component, public uart::UARTDevice { +class A02yyuwComponent final : public sensor::Sensor, public Component, public uart::UARTDevice { public: // Nothing really public. diff --git a/esphome/components/a4988/a4988.h b/esphome/components/a4988/a4988.h index 04040241c0..f50b5926c1 100644 --- a/esphome/components/a4988/a4988.h +++ b/esphome/components/a4988/a4988.h @@ -6,7 +6,7 @@ namespace esphome::a4988 { -class A4988 : public stepper::Stepper, public Component { +class A4988 final : public stepper::Stepper, public Component { public: void set_step_pin(GPIOPin *step_pin) { step_pin_ = step_pin; } void set_dir_pin(GPIOPin *dir_pin) { dir_pin_ = dir_pin; } diff --git a/esphome/components/absolute_humidity/absolute_humidity.h b/esphome/components/absolute_humidity/absolute_humidity.h index be28d3dc50..9989bb17fc 100644 --- a/esphome/components/absolute_humidity/absolute_humidity.h +++ b/esphome/components/absolute_humidity/absolute_humidity.h @@ -13,7 +13,7 @@ enum SaturationVaporPressureEquation { }; /// This class implements calculation of absolute humidity from temperature and relative humidity. -class AbsoluteHumidityComponent : public sensor::Sensor, public Component { +class AbsoluteHumidityComponent final : public sensor::Sensor, public Component { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } diff --git a/esphome/components/ac_dimmer/ac_dimmer.h b/esphome/components/ac_dimmer/ac_dimmer.h index 6bfcf0bdb5..783a9d7e24 100644 --- a/esphome/components/ac_dimmer/ac_dimmer.h +++ b/esphome/components/ac_dimmer/ac_dimmer.h @@ -41,7 +41,7 @@ struct AcDimmerDataStore { #endif }; -class AcDimmer : public output::FloatOutput, public Component { +class AcDimmer final : public output::FloatOutput, public Component { public: void setup() override; diff --git a/esphome/components/adc/adc_sensor.h b/esphome/components/adc/adc_sensor.h index 676940eca1..03de6f8b4b 100644 --- a/esphome/components/adc/adc_sensor.h +++ b/esphome/components/adc/adc_sensor.h @@ -54,7 +54,7 @@ template class Aggregator { SamplingMode mode_{SamplingMode::AVG}; }; -class ADCSensor : public sensor::Sensor, public PollingComponent, public voltage_sampler::VoltageSampler { +class ADCSensor final : public sensor::Sensor, public PollingComponent, public voltage_sampler::VoltageSampler { public: /// Update the sensor's state by reading the current ADC value. /// This method is called periodically based on the update interval. diff --git a/esphome/components/adc128s102/adc128s102.h b/esphome/components/adc128s102/adc128s102.h index f04ed87b2a..7d6355815e 100644 --- a/esphome/components/adc128s102/adc128s102.h +++ b/esphome/components/adc128s102/adc128s102.h @@ -6,9 +6,9 @@ namespace esphome::adc128s102 { -class ADC128S102 : public Component, - public spi::SPIDevice { +class ADC128S102 final : public Component, + public spi::SPIDevice { public: ADC128S102() = default; diff --git a/esphome/components/adc128s102/sensor/adc128s102_sensor.h b/esphome/components/adc128s102/sensor/adc128s102_sensor.h index c840102380..3c42e709f2 100644 --- a/esphome/components/adc128s102/sensor/adc128s102_sensor.h +++ b/esphome/components/adc128s102/sensor/adc128s102_sensor.h @@ -9,10 +9,10 @@ namespace esphome::adc128s102 { -class ADC128S102Sensor : public PollingComponent, - public Parented, - public sensor::Sensor, - public voltage_sampler::VoltageSampler { +class ADC128S102Sensor final : public PollingComponent, + public Parented, + public sensor::Sensor, + public voltage_sampler::VoltageSampler { public: ADC128S102Sensor(uint8_t channel); diff --git a/esphome/components/addressable_light/addressable_light_display.h b/esphome/components/addressable_light/addressable_light_display.h index 917d334f05..39d62b8733 100644 --- a/esphome/components/addressable_light/addressable_light_display.h +++ b/esphome/components/addressable_light/addressable_light_display.h @@ -9,7 +9,7 @@ namespace esphome::addressable_light { -class AddressableLightDisplay : public display::DisplayBuffer { +class AddressableLightDisplay final : public display::DisplayBuffer { public: light::AddressableLight *get_light() const { return this->light_; } diff --git a/esphome/components/ade7880/ade7880.h b/esphome/components/ade7880/ade7880.h index 53f501dee2..12be0849ff 100644 --- a/esphome/components/ade7880/ade7880.h +++ b/esphome/components/ade7880/ade7880.h @@ -65,7 +65,7 @@ struct ADE7880Store { static void gpio_intr(ADE7880Store *arg); }; -class ADE7880 : public i2c::I2CDevice, public PollingComponent { +class ADE7880 final : public i2c::I2CDevice, public PollingComponent { public: void set_irq0_pin(InternalGPIOPin *pin) { this->irq0_pin_ = pin; } void set_irq1_pin(InternalGPIOPin *pin) { this->irq1_pin_ = pin; } diff --git a/esphome/components/ade7953_i2c/ade7953_i2c.h b/esphome/components/ade7953_i2c/ade7953_i2c.h index 74d7e3e7cc..0b368a73ee 100644 --- a/esphome/components/ade7953_i2c/ade7953_i2c.h +++ b/esphome/components/ade7953_i2c/ade7953_i2c.h @@ -10,7 +10,7 @@ namespace esphome::ade7953_i2c { -class AdE7953I2c : public ade7953_base::ADE7953, public i2c::I2CDevice { +class AdE7953I2c final : public ade7953_base::ADE7953, public i2c::I2CDevice { public: void dump_config() override; diff --git a/esphome/components/ads1115/ads1115.h b/esphome/components/ads1115/ads1115.h index b1eed68aff..0b7f7ae700 100644 --- a/esphome/components/ads1115/ads1115.h +++ b/esphome/components/ads1115/ads1115.h @@ -43,7 +43,7 @@ enum ADS1115Samplerate { ADS1115_860SPS = 0b111 }; -class ADS1115Component : public Component, public i2c::I2CDevice { +class ADS1115Component final : public Component, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ads1115/sensor/ads1115_sensor.h b/esphome/components/ads1115/sensor/ads1115_sensor.h index 3b82c153dd..ecc8fb7af8 100644 --- a/esphome/components/ads1115/sensor/ads1115_sensor.h +++ b/esphome/components/ads1115/sensor/ads1115_sensor.h @@ -11,10 +11,10 @@ namespace esphome::ads1115 { /// Internal holder class that is in instance of Sensor so that the hub can create individual sensors. -class ADS1115Sensor : public sensor::Sensor, - public PollingComponent, - public voltage_sampler::VoltageSampler, - public Parented { +class ADS1115Sensor final : public sensor::Sensor, + public PollingComponent, + public voltage_sampler::VoltageSampler, + public Parented { public: void update() override; void set_multiplexer(ADS1115Multiplexer multiplexer) { this->multiplexer_ = multiplexer; } diff --git a/esphome/components/ads1118/ads1118.h b/esphome/components/ads1118/ads1118.h index ef125a0b44..275933c70d 100644 --- a/esphome/components/ads1118/ads1118.h +++ b/esphome/components/ads1118/ads1118.h @@ -26,9 +26,9 @@ enum ADS1118Gain { ADS1118_GAIN_0P256 = 0b101, }; -class ADS1118 : public Component, - public spi::SPIDevice { +class ADS1118 final : public Component, + public spi::SPIDevice { public: ADS1118() = default; void setup() override; diff --git a/esphome/components/ads1118/sensor/ads1118_sensor.h b/esphome/components/ads1118/sensor/ads1118_sensor.h index b929e75c62..8987dba073 100644 --- a/esphome/components/ads1118/sensor/ads1118_sensor.h +++ b/esphome/components/ads1118/sensor/ads1118_sensor.h @@ -10,10 +10,10 @@ namespace esphome::ads1118 { -class ADS1118Sensor : public PollingComponent, - public sensor::Sensor, - public voltage_sampler::VoltageSampler, - public Parented { +class ADS1118Sensor final : public PollingComponent, + public sensor::Sensor, + public voltage_sampler::VoltageSampler, + public Parented { public: void update() override; diff --git a/esphome/components/ags10/ags10.h b/esphome/components/ags10/ags10.h index 703acd5228..8ebc8da544 100644 --- a/esphome/components/ags10/ags10.h +++ b/esphome/components/ags10/ags10.h @@ -7,7 +7,7 @@ namespace esphome::ags10 { -class AGS10Component : public PollingComponent, public i2c::I2CDevice { +class AGS10Component final : public PollingComponent, public i2c::I2CDevice { public: /** * Sets TVOC sensor. @@ -100,7 +100,7 @@ class AGS10Component : public PollingComponent, public i2c::I2CDevice { template optional> read_and_check_(uint8_t a_register); }; -template class AGS10NewI2cAddressAction : public Action, public Parented { +template class AGS10NewI2cAddressAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, new_address) @@ -116,7 +116,7 @@ enum AGS10SetZeroPointActionMode { CUSTOM_VALUE, }; -template class AGS10SetZeroPointAction : public Action, public Parented { +template class AGS10SetZeroPointAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, value) TEMPLATABLE_VALUE(AGS10SetZeroPointActionMode, mode) diff --git a/esphome/components/aht10/aht10.h b/esphome/components/aht10/aht10.h index 7b9b1761c4..e99ba6fb98 100644 --- a/esphome/components/aht10/aht10.h +++ b/esphome/components/aht10/aht10.h @@ -10,7 +10,7 @@ namespace esphome::aht10 { enum AHT10Variant { AHT10, AHT20 }; -class AHT10Component : public PollingComponent, public i2c::I2CDevice { +class AHT10Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void update() override; diff --git a/esphome/components/aic3204/aic3204.h b/esphome/components/aic3204/aic3204.h index 9b8c792824..ae99a8f4d6 100644 --- a/esphome/components/aic3204/aic3204.h +++ b/esphome/components/aic3204/aic3204.h @@ -61,7 +61,7 @@ static const uint8_t AIC3204_ADC_PTM = 0x3D; // Register 61 - ADC Power Tu static const uint8_t AIC3204_AN_IN_CHRG = 0x47; // Register 71 - Analog Input Quick Charging Config static const uint8_t AIC3204_REF_STARTUP = 0x7B; // Register 123 - Reference Power Up Config -class AIC3204 : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { +class AIC3204 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/aic3204/automation.h b/esphome/components/aic3204/automation.h index 50ae03edbd..f0f8856614 100644 --- a/esphome/components/aic3204/automation.h +++ b/esphome/components/aic3204/automation.h @@ -6,7 +6,7 @@ namespace esphome::aic3204 { -template class SetAutoMuteAction : public Action { +template class SetAutoMuteAction final : public Action { public: explicit SetAutoMuteAction(AIC3204 *aic3204) : aic3204_(aic3204) {} diff --git a/esphome/components/airthings_ble/airthings_listener.h b/esphome/components/airthings_ble/airthings_listener.h index 707e9c3f21..8105ac32eb 100644 --- a/esphome/components/airthings_ble/airthings_listener.h +++ b/esphome/components/airthings_ble/airthings_listener.h @@ -7,7 +7,7 @@ namespace esphome::airthings_ble { -class AirthingsListener : public esp32_ble_tracker::ESPBTDeviceListener { +class AirthingsListener final : public esp32_ble_tracker::ESPBTDeviceListener { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; }; diff --git a/esphome/components/airthings_wave_mini/airthings_wave_mini.h b/esphome/components/airthings_wave_mini/airthings_wave_mini.h index 910ac90239..c41dde15c9 100644 --- a/esphome/components/airthings_wave_mini/airthings_wave_mini.h +++ b/esphome/components/airthings_wave_mini/airthings_wave_mini.h @@ -12,7 +12,7 @@ static const char *const SERVICE_UUID = "b42e3882-ade7-11e4-89d3-123b93f75cba"; static const char *const CHARACTERISTIC_UUID = "b42e3b98-ade7-11e4-89d3-123b93f75cba"; static const char *const ACCESS_CONTROL_POINT_CHARACTERISTIC_UUID = "b42e3ef4-ade7-11e4-89d3-123b93f75cba"; -class AirthingsWaveMini : public airthings_wave_base::AirthingsWaveBase { +class AirthingsWaveMini final : public airthings_wave_base::AirthingsWaveBase { public: AirthingsWaveMini(); diff --git a/esphome/components/airthings_wave_plus/airthings_wave_plus.h b/esphome/components/airthings_wave_plus/airthings_wave_plus.h index 6f51f3c65a..af355e45d6 100644 --- a/esphome/components/airthings_wave_plus/airthings_wave_plus.h +++ b/esphome/components/airthings_wave_plus/airthings_wave_plus.h @@ -19,7 +19,7 @@ static const char *const CHARACTERISTIC_UUID_WAVE_RADON_GEN2 = "b42e4dcc-ade7-11 static const char *const ACCESS_CONTROL_POINT_CHARACTERISTIC_UUID_WAVE_RADON_GEN2 = "b42e50d8-ade7-11e4-89d3-123b93f75cba"; -class AirthingsWavePlus : public airthings_wave_base::AirthingsWaveBase { +class AirthingsWavePlus final : public airthings_wave_base::AirthingsWaveBase { public: void setup() override; diff --git a/esphome/components/alarm_control_panel/automation.h b/esphome/components/alarm_control_panel/automation.h index 022d2650d2..dcb5121c60 100644 --- a/esphome/components/alarm_control_panel/automation.h +++ b/esphome/components/alarm_control_panel/automation.h @@ -27,7 +27,7 @@ static_assert(std::is_trivially_copyable_v); static_assert(sizeof(StateEnterForwarder) <= sizeof(void *)); static_assert(std::is_trivially_copyable_v>); -template class ArmAwayAction : public Action { +template class ArmAwayAction final : public Action { public: explicit ArmAwayAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} @@ -39,7 +39,7 @@ template class ArmAwayAction : public Action { AlarmControlPanel *alarm_control_panel_; }; -template class ArmHomeAction : public Action { +template class ArmHomeAction final : public Action { public: explicit ArmHomeAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} @@ -51,7 +51,7 @@ template class ArmHomeAction : public Action { AlarmControlPanel *alarm_control_panel_; }; -template class ArmNightAction : public Action { +template class ArmNightAction final : public Action { public: explicit ArmNightAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} @@ -63,7 +63,7 @@ template class ArmNightAction : public Action { AlarmControlPanel *alarm_control_panel_; }; -template class DisarmAction : public Action { +template class DisarmAction final : public Action { public: explicit DisarmAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} @@ -75,7 +75,7 @@ template class DisarmAction : public Action { AlarmControlPanel *alarm_control_panel_; }; -template class PendingAction : public Action { +template class PendingAction final : public Action { public: explicit PendingAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} @@ -85,7 +85,7 @@ template class PendingAction : public Action { AlarmControlPanel *alarm_control_panel_; }; -template class TriggeredAction : public Action { +template class TriggeredAction final : public Action { public: explicit TriggeredAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} @@ -95,7 +95,7 @@ template class TriggeredAction : public Action { AlarmControlPanel *alarm_control_panel_; }; -template class AlarmControlPanelCondition : public Condition { +template class AlarmControlPanelCondition final : public Condition { public: AlarmControlPanelCondition(AlarmControlPanel *parent) : parent_(parent) {} bool check(const Ts &...x) override { diff --git a/esphome/components/alpha3/alpha3.h b/esphome/components/alpha3/alpha3.h index c63129031a..5a5b01ac0b 100644 --- a/esphome/components/alpha3/alpha3.h +++ b/esphome/components/alpha3/alpha3.h @@ -31,7 +31,7 @@ static const int16_t GENI_RESPONSE_POWER_OFFSET = 12; static const int16_t GENI_RESPONSE_MOTOR_POWER_OFFSET = 16; // not sure static const int16_t GENI_RESPONSE_MOTOR_SPEED_OFFSET = 20; -class Alpha3 : public esphome::ble_client::BLEClientNode, public PollingComponent { +class Alpha3 final : public esphome::ble_client::BLEClientNode, public PollingComponent { public: void setup() override; void update() override; diff --git a/esphome/components/am2315c/am2315c.h b/esphome/components/am2315c/am2315c.h index 5a959af4c3..73dc0d8758 100644 --- a/esphome/components/am2315c/am2315c.h +++ b/esphome/components/am2315c/am2315c.h @@ -27,7 +27,7 @@ namespace esphome::am2315c { -class AM2315C : public PollingComponent, public i2c::I2CDevice { +class AM2315C final : public PollingComponent, public i2c::I2CDevice { public: void dump_config() override; void update() override; diff --git a/esphome/components/am2320/am2320.h b/esphome/components/am2320/am2320.h index ddb5c6f165..f92156b154 100644 --- a/esphome/components/am2320/am2320.h +++ b/esphome/components/am2320/am2320.h @@ -6,7 +6,7 @@ namespace esphome::am2320 { -class AM2320Component : public PollingComponent, public i2c::I2CDevice { +class AM2320Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/am43/cover/am43_cover.h b/esphome/components/am43/cover/am43_cover.h index aa48aced15..be7af59ade 100644 --- a/esphome/components/am43/cover/am43_cover.h +++ b/esphome/components/am43/cover/am43_cover.h @@ -14,7 +14,7 @@ namespace esphome::am43 { namespace espbt = esphome::esp32_ble_tracker; -class Am43Component : public cover::Cover, public esphome::ble_client::BLEClientNode, public Component { +class Am43Component final : public cover::Cover, public esphome::ble_client::BLEClientNode, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/am43/sensor/am43_sensor.h b/esphome/components/am43/sensor/am43_sensor.h index 9198a5cbcb..944681bb60 100644 --- a/esphome/components/am43/sensor/am43_sensor.h +++ b/esphome/components/am43/sensor/am43_sensor.h @@ -14,7 +14,7 @@ namespace esphome::am43 { namespace espbt = esphome::esp32_ble_tracker; -class Am43 : public esphome::ble_client::BLEClientNode, public PollingComponent { +class Am43 final : public esphome::ble_client::BLEClientNode, public PollingComponent { public: void setup() override; void update() override; diff --git a/esphome/components/analog_threshold/analog_threshold_binary_sensor.h b/esphome/components/analog_threshold/analog_threshold_binary_sensor.h index c768f1f82d..a4df00ff05 100644 --- a/esphome/components/analog_threshold/analog_threshold_binary_sensor.h +++ b/esphome/components/analog_threshold/analog_threshold_binary_sensor.h @@ -7,7 +7,7 @@ namespace esphome::analog_threshold { -class AnalogThresholdBinarySensor : public Component, public binary_sensor::BinarySensor { +class AnalogThresholdBinarySensor final : public Component, public binary_sensor::BinarySensor { public: void dump_config() override; void setup() override; diff --git a/esphome/components/animation/animation.h b/esphome/components/animation/animation.h index ca800ad931..64cddbf09c 100644 --- a/esphome/components/animation/animation.h +++ b/esphome/components/animation/animation.h @@ -5,7 +5,7 @@ namespace esphome::animation { -class Animation : public image::Image { +class Animation final : public image::Image { public: Animation(const uint8_t *data_start, int width, int height, uint32_t animation_frame_count, image::ImageType type, image::Transparency transparent); @@ -35,7 +35,7 @@ class Animation : public image::Image { int loop_current_iteration_; }; -template class AnimationNextFrameAction : public Action { +template class AnimationNextFrameAction final : public Action { public: AnimationNextFrameAction(Animation *parent) : parent_(parent) {} void play(const Ts &...x) override { this->parent_->next_frame(); } @@ -44,7 +44,7 @@ template class AnimationNextFrameAction : public Action { Animation *parent_; }; -template class AnimationPrevFrameAction : public Action { +template class AnimationPrevFrameAction final : public Action { public: AnimationPrevFrameAction(Animation *parent) : parent_(parent) {} void play(const Ts &...x) override { this->parent_->prev_frame(); } @@ -53,7 +53,7 @@ template class AnimationPrevFrameAction : public Action { Animation *parent_; }; -template class AnimationSetFrameAction : public Action { +template class AnimationSetFrameAction final : public Action { public: AnimationSetFrameAction(Animation *parent) : parent_(parent) {} TEMPLATABLE_VALUE(uint16_t, frame) diff --git a/esphome/components/anova/anova.h b/esphome/components/anova/anova.h index a3e175be28..49b1100c37 100644 --- a/esphome/components/anova/anova.h +++ b/esphome/components/anova/anova.h @@ -17,7 +17,7 @@ namespace espbt = esphome::esp32_ble_tracker; static const uint16_t ANOVA_SERVICE_UUID = 0xFFE0; static const uint16_t ANOVA_CHARACTERISTIC_UUID = 0xFFE1; -class Anova : public climate::Climate, public esphome::ble_client::BLEClientNode, public PollingComponent { +class Anova final : public climate::Climate, public esphome::ble_client::BLEClientNode, public PollingComponent { public: void setup() override; void loop() override; diff --git a/esphome/components/apds9306/apds9306.h b/esphome/components/apds9306/apds9306.h index 093ec55bc6..f971290cdd 100644 --- a/esphome/components/apds9306/apds9306.h +++ b/esphome/components/apds9306/apds9306.h @@ -39,7 +39,7 @@ enum AmbientLightGain : uint8_t { }; static const uint8_t AMBIENT_LIGHT_GAIN_VALUES[] = {1, 3, 6, 9, 18}; -class APDS9306 : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class APDS9306 final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; float get_setup_priority() const override { return setup_priority::BUS; } diff --git a/esphome/components/apds9960/apds9960.h b/esphome/components/apds9960/apds9960.h index 2823294207..bfa64bcc74 100644 --- a/esphome/components/apds9960/apds9960.h +++ b/esphome/components/apds9960/apds9960.h @@ -12,7 +12,7 @@ namespace esphome::apds9960 { -class APDS9960 : public PollingComponent, public i2c::I2CDevice { +class APDS9960 final : public PollingComponent, public i2c::I2CDevice { #ifdef USE_SENSOR SUB_SENSOR(red) SUB_SENSOR(green) diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index fbc8115091..16b5762f68 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -342,7 +342,7 @@ class APIServer final : public Component, extern APIServer *global_api_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -template class APIConnectedCondition : public Condition { +template class APIConnectedCondition final : public Condition { TEMPLATABLE_VALUE(bool, state_subscription_only) public: bool check(const Ts &...x) override { diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h index aef046fbb0..9e0faf9881 100644 --- a/esphome/components/api/homeassistant_service.h +++ b/esphome/components/api/homeassistant_service.h @@ -104,7 +104,7 @@ class ActionResponse { template using ActionResponseCallback = std::function; #endif -template class HomeAssistantServiceCallAction : public Action { +template class HomeAssistantServiceCallAction final : public Action { public: explicit HomeAssistantServiceCallAction(APIServer *parent, bool is_event) : parent_(parent) { this->flags_.is_event = is_event; diff --git a/esphome/components/api/user_services.h b/esphome/components/api/user_services.h index 29eadda927..ea57d0944b 100644 --- a/esphome/components/api/user_services.h +++ b/esphome/components/api/user_services.h @@ -164,7 +164,8 @@ template class UserServiceTrig // Specialization for NONE - no extra trigger arguments template -class UserServiceTrigger : public UserServiceBase, public Trigger { +class UserServiceTrigger final : public UserServiceBase, + public Trigger { public: UserServiceTrigger(const char *name, const std::array &arg_names) : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_NONE) {} @@ -175,8 +176,8 @@ class UserServiceTrigger : public UserServ // Specialization for OPTIONAL - call_id and return_response trigger arguments template -class UserServiceTrigger : public UserServiceBase, - public Trigger { +class UserServiceTrigger final : public UserServiceBase, + public Trigger { public: UserServiceTrigger(const char *name, const std::array &arg_names) : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_OPTIONAL) {} @@ -189,8 +190,8 @@ class UserServiceTrigger : public User // Specialization for ONLY - just call_id trigger argument template -class UserServiceTrigger : public UserServiceBase, - public Trigger { +class UserServiceTrigger final : public UserServiceBase, + public Trigger { public: UserServiceTrigger(const char *name, const std::array &arg_names) : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_ONLY) {} @@ -201,8 +202,8 @@ class UserServiceTrigger : public UserServ // Specialization for STATUS - just call_id trigger argument (reports success/error without data) template -class UserServiceTrigger : public UserServiceBase, - public Trigger { +class UserServiceTrigger final : public UserServiceBase, + public Trigger { public: UserServiceTrigger(const char *name, const std::array &arg_names) : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_STATUS) {} @@ -221,7 +222,7 @@ class UserServiceTrigger : public UserSe namespace esphome::api { -template class APIRespondAction : public Action { +template class APIRespondAction final : public Action { public: explicit APIRespondAction(APIServer *parent) : parent_(parent) {} @@ -286,7 +287,7 @@ template class APIRespondAction : public Action { // Action to unregister a service call after execution completes // Automatically appended to the end of action lists for non-none response modes -template class APIUnregisterServiceCallAction : public Action { +template class APIUnregisterServiceCallAction final : public Action { public: explicit APIUnregisterServiceCallAction(APIServer *parent) : parent_(parent) {} diff --git a/esphome/components/aqi/aqi_sensor.h b/esphome/components/aqi/aqi_sensor.h index 2e526ca825..aa64fa5a4d 100644 --- a/esphome/components/aqi/aqi_sensor.h +++ b/esphome/components/aqi/aqi_sensor.h @@ -6,7 +6,7 @@ namespace esphome::aqi { -class AQISensor : public sensor::Sensor, public Component { +class AQISensor final : public sensor::Sensor, public Component { public: void setup() override; void dump_config() override; From 92028e53b5d55ee55608e361d6fa019ab5b8fe30 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:35:12 +1200 Subject: [PATCH 0459/1815] Mark configurable classes as final (2/21: as3935_i2c-ble_rssi) (#16953) --- esphome/components/as3935_i2c/as3935_i2c.h | 2 +- esphome/components/as3935_spi/as3935_spi.h | 6 ++--- esphome/components/as5600/as5600.h | 2 +- .../components/as5600/sensor/as5600_sensor.h | 2 +- esphome/components/as7341/as7341.h | 2 +- esphome/components/at581x/at581x.h | 2 +- esphome/components/at581x/automation.h | 4 ++-- esphome/components/at581x/switch/rf_switch.h | 2 +- .../atc_mithermometer/atc_mithermometer.h | 2 +- esphome/components/atm90e26/atm90e26.h | 6 ++--- esphome/components/atm90e32/atm90e32.h | 6 ++--- .../atm90e32/button/atm90e32_button.h | 12 +++++----- esphome/components/audio_adc/automation.h | 2 +- esphome/components/audio_dac/automation.h | 6 ++--- .../media_source/audio_file_media_source.h | 4 +++- .../audio_http/audio_http_media_source.h | 4 +++- .../touchscreen/axs15231_touchscreen.h | 2 +- esphome/components/ballu/ballu.h | 2 +- .../components/bang_bang/bang_bang_climate.h | 2 +- esphome/components/bedjet/bedjet_hub.h | 2 +- .../bedjet/climate/bedjet_climate.h | 2 +- esphome/components/bedjet/fan/bedjet_fan.h | 2 +- .../components/bedjet/sensor/bedjet_sensor.h | 2 +- .../beken_spi_led_strip/led_strip.h | 2 +- esphome/components/bh1750/bh1750.h | 2 +- esphome/components/bh1900nux/bh1900nux.h | 2 +- esphome/components/binary/fan/binary_fan.h | 2 +- .../binary/light/binary_light_output.h | 2 +- esphome/components/binary_sensor/automation.h | 20 ++++++++--------- .../binary_sensor_map/binary_sensor_map.h | 2 +- esphome/components/bl0906/bl0906.h | 4 ++-- esphome/components/bl0939/bl0939.h | 2 +- esphome/components/bl0940/bl0940.h | 2 +- .../bl0940/button/calibration_reset_button.h | 2 +- .../bl0940/number/calibration_number.h | 2 +- esphome/components/bl0942/bl0942.h | 2 +- esphome/components/ble_client/automation.h | 22 +++++++++---------- esphome/components/ble_client/ble_client.h | 2 +- .../ble_client/output/ble_binary_output.h | 2 +- .../components/ble_client/sensor/automation.h | 2 +- .../ble_client/sensor/ble_rssi_sensor.h | 2 +- .../components/ble_client/switch/ble_switch.h | 2 +- .../ble_client/text_sensor/automation.h | 2 +- esphome/components/ble_nus/ble_nus.h | 2 +- .../ble_presence/ble_presence_device.h | 6 ++--- esphome/components/ble_rssi/ble_rssi_sensor.h | 2 +- 46 files changed, 86 insertions(+), 82 deletions(-) diff --git a/esphome/components/as3935_i2c/as3935_i2c.h b/esphome/components/as3935_i2c/as3935_i2c.h index c43ec4afd5..c15f2d6e3e 100644 --- a/esphome/components/as3935_i2c/as3935_i2c.h +++ b/esphome/components/as3935_i2c/as3935_i2c.h @@ -5,7 +5,7 @@ namespace esphome::as3935_i2c { -class I2CAS3935Component : public as3935::AS3935Component, public i2c::I2CDevice { +class I2CAS3935Component final : public as3935::AS3935Component, public i2c::I2CDevice { public: void dump_config() override; diff --git a/esphome/components/as3935_spi/as3935_spi.h b/esphome/components/as3935_spi/as3935_spi.h index 935707a18c..053e34b3d0 100644 --- a/esphome/components/as3935_spi/as3935_spi.h +++ b/esphome/components/as3935_spi/as3935_spi.h @@ -8,9 +8,9 @@ namespace esphome::as3935_spi { enum AS3935RegisterMasks { SPI_READ_M = 0x40 }; -class SPIAS3935Component : public as3935::AS3935Component, - public spi::SPIDevice { +class SPIAS3935Component final : public as3935::AS3935Component, + public spi::SPIDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/as5600/as5600.h b/esphome/components/as5600/as5600.h index 414633f978..a385322b70 100644 --- a/esphome/components/as5600/as5600.h +++ b/esphome/components/as5600/as5600.h @@ -43,7 +43,7 @@ enum AS5600MagnetStatus : uint8_t { MAGNET_WEAK = 6, // 0b110 / magnet too weak }; -class AS5600Component : public Component, public i2c::I2CDevice { +class AS5600Component final : public Component, public i2c::I2CDevice { public: /// Set up the internal sensor array. void setup() override; diff --git a/esphome/components/as5600/sensor/as5600_sensor.h b/esphome/components/as5600/sensor/as5600_sensor.h index 0086fe54cc..170ff6d86b 100644 --- a/esphome/components/as5600/sensor/as5600_sensor.h +++ b/esphome/components/as5600/sensor/as5600_sensor.h @@ -9,7 +9,7 @@ namespace esphome::as5600 { -class AS5600Sensor : public PollingComponent, public Parented, public sensor::Sensor { +class AS5600Sensor final : public PollingComponent, public Parented, public sensor::Sensor { public: void update() override; void dump_config() override; diff --git a/esphome/components/as7341/as7341.h b/esphome/components/as7341/as7341.h index 8bc157fe79..2d72987f1c 100644 --- a/esphome/components/as7341/as7341.h +++ b/esphome/components/as7341/as7341.h @@ -73,7 +73,7 @@ enum AS7341Gain { AS7341_GAIN_512X, }; -class AS7341Component : public PollingComponent, public i2c::I2CDevice { +class AS7341Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/at581x/at581x.h b/esphome/components/at581x/at581x.h index e7f8ee3692..594395e96d 100644 --- a/esphome/components/at581x/at581x.h +++ b/esphome/components/at581x/at581x.h @@ -12,7 +12,7 @@ namespace esphome::at581x { -class AT581XComponent : public Component, public i2c::I2CDevice { +class AT581XComponent final : public Component, public i2c::I2CDevice { public: #ifdef USE_SWITCH void set_rf_power_switch(switch_::Switch *s) { diff --git a/esphome/components/at581x/automation.h b/esphome/components/at581x/automation.h index eb8b1b2562..a732d2bcc7 100644 --- a/esphome/components/at581x/automation.h +++ b/esphome/components/at581x/automation.h @@ -7,12 +7,12 @@ namespace esphome::at581x { -template class AT581XResetAction : public Action, public Parented { +template class AT581XResetAction final : public Action, public Parented { public: void play(const Ts &...x) { this->parent_->reset_hardware_frontend(); } }; -template class AT581XSettingsAction : public Action, public Parented { +template class AT581XSettingsAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(int8_t, hw_frontend_reset) TEMPLATABLE_VALUE(int, frequency) diff --git a/esphome/components/at581x/switch/rf_switch.h b/esphome/components/at581x/switch/rf_switch.h index 47367fad45..0e251b8baa 100644 --- a/esphome/components/at581x/switch/rf_switch.h +++ b/esphome/components/at581x/switch/rf_switch.h @@ -5,7 +5,7 @@ namespace esphome::at581x { -class RFSwitch : public switch_::Switch, public Parented { +class RFSwitch final : public switch_::Switch, public Parented { protected: void write_state(bool state) override; }; diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.h b/esphome/components/atc_mithermometer/atc_mithermometer.h index 8f62f05bc1..3dde5f1868 100644 --- a/esphome/components/atc_mithermometer/atc_mithermometer.h +++ b/esphome/components/atc_mithermometer/atc_mithermometer.h @@ -18,7 +18,7 @@ struct ParseResult { int raw_offset; }; -class ATCMiThermometer : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class ATCMiThermometer final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; diff --git a/esphome/components/atm90e26/atm90e26.h b/esphome/components/atm90e26/atm90e26.h index 657f8f3c43..0381d8e5c1 100644 --- a/esphome/components/atm90e26/atm90e26.h +++ b/esphome/components/atm90e26/atm90e26.h @@ -6,9 +6,9 @@ namespace esphome::atm90e26 { -class ATM90E26Component : public PollingComponent, - public spi::SPIDevice { +class ATM90E26Component final : public PollingComponent, + public spi::SPIDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/atm90e32/atm90e32.h b/esphome/components/atm90e32/atm90e32.h index 5fa224b353..c636e5065a 100644 --- a/esphome/components/atm90e32/atm90e32.h +++ b/esphome/components/atm90e32/atm90e32.h @@ -13,9 +13,9 @@ namespace esphome::atm90e32 { -class ATM90E32Component : public PollingComponent, - public spi::SPIDevice { +class ATM90E32Component final : public PollingComponent, + public spi::SPIDevice { public: static const uint8_t PHASEA = 0; static const uint8_t PHASEB = 1; diff --git a/esphome/components/atm90e32/button/atm90e32_button.h b/esphome/components/atm90e32/button/atm90e32_button.h index 0cfce62293..988c6d5c16 100644 --- a/esphome/components/atm90e32/button/atm90e32_button.h +++ b/esphome/components/atm90e32/button/atm90e32_button.h @@ -6,7 +6,7 @@ namespace esphome::atm90e32 { -class ATM90E32GainCalibrationButton : public button::Button, public Parented { +class ATM90E32GainCalibrationButton final : public button::Button, public Parented { public: ATM90E32GainCalibrationButton() = default; @@ -14,7 +14,7 @@ class ATM90E32GainCalibrationButton : public button::Button, public Parented { +class ATM90E32ClearGainCalibrationButton final : public button::Button, public Parented { public: ATM90E32ClearGainCalibrationButton() = default; @@ -22,7 +22,7 @@ class ATM90E32ClearGainCalibrationButton : public button::Button, public Parente void press_action() override; }; -class ATM90E32OffsetCalibrationButton : public button::Button, public Parented { +class ATM90E32OffsetCalibrationButton final : public button::Button, public Parented { public: ATM90E32OffsetCalibrationButton() = default; @@ -30,7 +30,7 @@ class ATM90E32OffsetCalibrationButton : public button::Button, public Parented { +class ATM90E32ClearOffsetCalibrationButton final : public button::Button, public Parented { public: ATM90E32ClearOffsetCalibrationButton() = default; @@ -38,7 +38,7 @@ class ATM90E32ClearOffsetCalibrationButton : public button::Button, public Paren void press_action() override; }; -class ATM90E32PowerOffsetCalibrationButton : public button::Button, public Parented { +class ATM90E32PowerOffsetCalibrationButton final : public button::Button, public Parented { public: ATM90E32PowerOffsetCalibrationButton() = default; @@ -46,7 +46,7 @@ class ATM90E32PowerOffsetCalibrationButton : public button::Button, public Paren void press_action() override; }; -class ATM90E32ClearPowerOffsetCalibrationButton : public button::Button, public Parented { +class ATM90E32ClearPowerOffsetCalibrationButton final : public button::Button, public Parented { public: ATM90E32ClearPowerOffsetCalibrationButton() = default; diff --git a/esphome/components/audio_adc/automation.h b/esphome/components/audio_adc/automation.h index e74e023203..fc7af25622 100644 --- a/esphome/components/audio_adc/automation.h +++ b/esphome/components/audio_adc/automation.h @@ -6,7 +6,7 @@ namespace esphome::audio_adc { -template class SetMicGainAction : public Action { +template class SetMicGainAction final : public Action { public: explicit SetMicGainAction(AudioAdc *audio_adc) : audio_adc_(audio_adc) {} diff --git a/esphome/components/audio_dac/automation.h b/esphome/components/audio_dac/automation.h index 67bbc78ac2..9c5348271c 100644 --- a/esphome/components/audio_dac/automation.h +++ b/esphome/components/audio_dac/automation.h @@ -6,7 +6,7 @@ namespace esphome::audio_dac { -template class MuteOffAction : public Action { +template class MuteOffAction final : public Action { public: explicit MuteOffAction(AudioDac *audio_dac) : audio_dac_(audio_dac) {} @@ -16,7 +16,7 @@ template class MuteOffAction : public Action { AudioDac *audio_dac_; }; -template class MuteOnAction : public Action { +template class MuteOnAction final : public Action { public: explicit MuteOnAction(AudioDac *audio_dac) : audio_dac_(audio_dac) {} @@ -26,7 +26,7 @@ template class MuteOnAction : public Action { AudioDac *audio_dac_; }; -template class SetVolumeAction : public Action { +template class SetVolumeAction final : public Action { public: explicit SetVolumeAction(AudioDac *audio_dac) : audio_dac_(audio_dac) {} diff --git a/esphome/components/audio_file/media_source/audio_file_media_source.h b/esphome/components/audio_file/media_source/audio_file_media_source.h index 2c6189f272..d269f77c35 100644 --- a/esphome/components/audio_file/media_source/audio_file_media_source.h +++ b/esphome/components/audio_file/media_source/audio_file_media_source.h @@ -23,7 +23,9 @@ namespace esphome::audio_file { // (the orchestrator calls set_listener() on us with a MediaSourceListener*). // - micro_decoder::DecoderListener: the underlying decoder calls back *into* us with decoded // audio and state changes (we call decoder_->set_listener(this) in setup()). -class AudioFileMediaSource : public Component, public media_source::MediaSource, public micro_decoder::DecoderListener { +class AudioFileMediaSource final : public Component, + public media_source::MediaSource, + public micro_decoder::DecoderListener { public: void setup() override; void loop() override; diff --git a/esphome/components/audio_http/audio_http_media_source.h b/esphome/components/audio_http/audio_http_media_source.h index e4bd69e9e6..f794aa1f02 100644 --- a/esphome/components/audio_http/audio_http_media_source.h +++ b/esphome/components/audio_http/audio_http_media_source.h @@ -23,7 +23,9 @@ namespace esphome::audio_http { // - micro_decoder::DecoderListener: the underlying decoder calls back *into* us with decoded // audio and state changes (we call decoder_->set_listener(this) in setup()). // The two set_listener() methods live on different base classes and serve opposite directions. -class AudioHTTPMediaSource : public Component, public media_source::MediaSource, public micro_decoder::DecoderListener { +class AudioHTTPMediaSource final : public Component, + public media_source::MediaSource, + public micro_decoder::DecoderListener { public: void setup() override; void loop() override; diff --git a/esphome/components/axs15231/touchscreen/axs15231_touchscreen.h b/esphome/components/axs15231/touchscreen/axs15231_touchscreen.h index 94d232777c..43bd379925 100644 --- a/esphome/components/axs15231/touchscreen/axs15231_touchscreen.h +++ b/esphome/components/axs15231/touchscreen/axs15231_touchscreen.h @@ -7,7 +7,7 @@ namespace esphome::axs15231 { -class AXS15231Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { +class AXS15231Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ballu/ballu.h b/esphome/components/ballu/ballu.h index 8a45d39c70..cb40f415ad 100644 --- a/esphome/components/ballu/ballu.h +++ b/esphome/components/ballu/ballu.h @@ -10,7 +10,7 @@ namespace esphome::ballu { const float YKR_K_002E_TEMP_MIN = 16.0; const float YKR_K_002E_TEMP_MAX = 32.0; -class BalluClimate : public climate_ir::ClimateIR { +class BalluClimate final : public climate_ir::ClimateIR { public: BalluClimate() : climate_ir::ClimateIR(YKR_K_002E_TEMP_MIN, YKR_K_002E_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/bang_bang/bang_bang_climate.h b/esphome/components/bang_bang/bang_bang_climate.h index 1e5ff84883..d83257f9f3 100644 --- a/esphome/components/bang_bang/bang_bang_climate.h +++ b/esphome/components/bang_bang/bang_bang_climate.h @@ -16,7 +16,7 @@ struct BangBangClimateTargetTempConfig { float default_temperature_high{NAN}; }; -class BangBangClimate : public climate::Climate, public Component { +class BangBangClimate final : public climate::Climate, public Component { public: BangBangClimate(); void setup() override; diff --git a/esphome/components/bedjet/bedjet_hub.h b/esphome/components/bedjet/bedjet_hub.h index 9f25f7a466..32ddd94cff 100644 --- a/esphome/components/bedjet/bedjet_hub.h +++ b/esphome/components/bedjet/bedjet_hub.h @@ -33,7 +33,7 @@ static const espbt::ESPBTUUID BEDJET_NAME_UUID = espbt::ESPBTUUID::from_raw("000 /** * Hub component connecting to the BedJet device over Bluetooth. */ -class BedJetHub : public esphome::ble_client::BLEClientNode, public PollingComponent { +class BedJetHub final : public esphome::ble_client::BLEClientNode, public PollingComponent { public: /* BedJet functionality exposed to `BedJetClient` children and/or accessible from action lambdas. */ diff --git a/esphome/components/bedjet/climate/bedjet_climate.h b/esphome/components/bedjet/climate/bedjet_climate.h index f59e67eeb7..6f81b87289 100644 --- a/esphome/components/bedjet/climate/bedjet_climate.h +++ b/esphome/components/bedjet/climate/bedjet_climate.h @@ -12,7 +12,7 @@ namespace esphome::bedjet { -class BedJetClimate : public climate::Climate, public BedJetClient, public PollingComponent { +class BedJetClimate final : public climate::Climate, public BedJetClient, public PollingComponent { public: void setup() override; void loop() override; diff --git a/esphome/components/bedjet/fan/bedjet_fan.h b/esphome/components/bedjet/fan/bedjet_fan.h index 03f42f1438..814a87d8b9 100644 --- a/esphome/components/bedjet/fan/bedjet_fan.h +++ b/esphome/components/bedjet/fan/bedjet_fan.h @@ -12,7 +12,7 @@ namespace esphome::bedjet { -class BedJetFan : public fan::Fan, public BedJetClient, public PollingComponent { +class BedJetFan final : public fan::Fan, public BedJetClient, public PollingComponent { public: void update() override; void dump_config() override; diff --git a/esphome/components/bedjet/sensor/bedjet_sensor.h b/esphome/components/bedjet/sensor/bedjet_sensor.h index 0c3f713579..c387e9d5fd 100644 --- a/esphome/components/bedjet/sensor/bedjet_sensor.h +++ b/esphome/components/bedjet/sensor/bedjet_sensor.h @@ -7,7 +7,7 @@ namespace esphome::bedjet { -class BedjetSensor : public BedJetClient, public Component { +class BedjetSensor final : public BedJetClient, public Component { public: void dump_config() override; diff --git a/esphome/components/beken_spi_led_strip/led_strip.h b/esphome/components/beken_spi_led_strip/led_strip.h index 4ed640a3bc..909634e266 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.h +++ b/esphome/components/beken_spi_led_strip/led_strip.h @@ -19,7 +19,7 @@ enum RGBOrder : uint8_t { ORDER_BRG, }; -class BekenSPILEDStripLightOutput : public light::AddressableLight { +class BekenSPILEDStripLightOutput final : public light::AddressableLight { public: void setup() override; void write_state(light::LightState *state) override; diff --git a/esphome/components/bh1750/bh1750.h b/esphome/components/bh1750/bh1750.h index 39dbd1d6a9..092a21359b 100644 --- a/esphome/components/bh1750/bh1750.h +++ b/esphome/components/bh1750/bh1750.h @@ -13,7 +13,7 @@ enum BH1750Mode : uint8_t { }; /// This class implements support for the i2c-based BH1750 ambient light sensor. -class BH1750Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class BH1750Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) diff --git a/esphome/components/bh1900nux/bh1900nux.h b/esphome/components/bh1900nux/bh1900nux.h index 61d1bac268..f1d62d1647 100644 --- a/esphome/components/bh1900nux/bh1900nux.h +++ b/esphome/components/bh1900nux/bh1900nux.h @@ -6,7 +6,7 @@ namespace esphome::bh1900nux { -class BH1900NUXSensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class BH1900NUXSensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; void update() override; diff --git a/esphome/components/binary/fan/binary_fan.h b/esphome/components/binary/fan/binary_fan.h index 17157dd29c..601f4cb641 100644 --- a/esphome/components/binary/fan/binary_fan.h +++ b/esphome/components/binary/fan/binary_fan.h @@ -6,7 +6,7 @@ namespace esphome::binary { -class BinaryFan : public Component, public fan::Fan { +class BinaryFan final : public Component, public fan::Fan { public: void setup() override; void dump_config() override; diff --git a/esphome/components/binary/light/binary_light_output.h b/esphome/components/binary/light/binary_light_output.h index f6be7e162e..32707e8b0c 100644 --- a/esphome/components/binary/light/binary_light_output.h +++ b/esphome/components/binary/light/binary_light_output.h @@ -6,7 +6,7 @@ namespace esphome::binary { -class BinaryLightOutput : public light::LightOutput { +class BinaryLightOutput final : public light::LightOutput { public: void set_output(output::BinaryOutput *output) { output_ = output; } light::LightTraits get_traits() override { diff --git a/esphome/components/binary_sensor/automation.h b/esphome/components/binary_sensor/automation.h index 1875910aff..d5a85ca9c4 100644 --- a/esphome/components/binary_sensor/automation.h +++ b/esphome/components/binary_sensor/automation.h @@ -18,7 +18,7 @@ struct MultiClickTriggerEvent { uint32_t max_length; }; -class PressTrigger : public Trigger<> { +class PressTrigger final : public Trigger<> { public: explicit PressTrigger(BinarySensor *parent) { parent->add_on_state_callback([this](bool state) { @@ -28,7 +28,7 @@ class PressTrigger : public Trigger<> { } }; -class ReleaseTrigger : public Trigger<> { +class ReleaseTrigger final : public Trigger<> { public: explicit ReleaseTrigger(BinarySensor *parent) { parent->add_on_state_callback([this](bool state) { @@ -40,7 +40,7 @@ class ReleaseTrigger : public Trigger<> { bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length); -class ClickTrigger : public Trigger<> { +class ClickTrigger final : public Trigger<> { public: explicit ClickTrigger(BinarySensor *parent, uint32_t min_length, uint32_t max_length) : min_length_(min_length), max_length_(max_length) { @@ -61,7 +61,7 @@ class ClickTrigger : public Trigger<> { uint32_t max_length_; /// Maximum length of click. 0 means no maximum. }; -class DoubleClickTrigger : public Trigger<> { +class DoubleClickTrigger final : public Trigger<> { public: explicit DoubleClickTrigger(BinarySensor *parent, uint32_t min_length, uint32_t max_length) : min_length_(min_length), max_length_(max_length) { @@ -127,7 +127,7 @@ class MultiClickTriggerBase : public Trigger<>, public Component { /// Template wrapper that provides inline std::array storage for timing events. /// N is set by code generation to match the exact number of timing events configured in YAML. -template class MultiClickTrigger : public MultiClickTriggerBase { +template class MultiClickTrigger final : public MultiClickTriggerBase { public: MultiClickTrigger(BinarySensor *parent, std::initializer_list timing) : MultiClickTriggerBase(parent) { @@ -140,14 +140,14 @@ template class MultiClickTrigger : public MultiClickTriggerBase { std::array timing_storage_{}; }; -class StateTrigger : public Trigger { +class StateTrigger final : public Trigger { public: explicit StateTrigger(BinarySensor *parent) { parent->add_on_state_callback([this](bool state) { this->trigger(state); }); } }; -class StateChangeTrigger : public Trigger, optional > { +class StateChangeTrigger final : public Trigger, optional > { public: explicit StateChangeTrigger(BinarySensor *parent) { parent->add_full_state_callback( @@ -155,7 +155,7 @@ class StateChangeTrigger : public Trigger, optional > { } }; -template class BinarySensorCondition : public Condition { +template class BinarySensorCondition final : public Condition { public: BinarySensorCondition(BinarySensor *parent, bool state) : parent_(parent), state_(state) {} bool check(const Ts &...x) override { return this->parent_->state == this->state_; } @@ -165,7 +165,7 @@ template class BinarySensorCondition : public Condition { bool state_; }; -template class BinarySensorPublishAction : public Action { +template class BinarySensorPublishAction final : public Action { public: explicit BinarySensorPublishAction(BinarySensor *sensor) : sensor_(sensor) {} TEMPLATABLE_VALUE(bool, state) @@ -179,7 +179,7 @@ template class BinarySensorPublishAction : public Action BinarySensor *sensor_; }; -template class BinarySensorInvalidateAction : public Action { +template class BinarySensorInvalidateAction final : public Action { public: explicit BinarySensorInvalidateAction(BinarySensor *sensor) : sensor_(sensor) {} diff --git a/esphome/components/binary_sensor_map/binary_sensor_map.h b/esphome/components/binary_sensor_map/binary_sensor_map.h index 60224242db..bb2c273957 100644 --- a/esphome/components/binary_sensor_map/binary_sensor_map.h +++ b/esphome/components/binary_sensor_map/binary_sensor_map.h @@ -29,7 +29,7 @@ struct BinarySensorMapChannel { * * Each binary sensor has configured parameters that each mapping type uses to compute the single numerical result */ -class BinarySensorMap : public sensor::Sensor, public Component { +class BinarySensorMap final : public sensor::Sensor, public Component { public: void dump_config() override; diff --git a/esphome/components/bl0906/bl0906.h b/esphome/components/bl0906/bl0906.h index 821aac476c..54de9f9b0c 100644 --- a/esphome/components/bl0906/bl0906.h +++ b/esphome/components/bl0906/bl0906.h @@ -53,7 +53,7 @@ class BL0906; using ActionCallbackFuncPtr = void (BL0906::*)(); -class BL0906 : public PollingComponent, public uart::UARTDevice { +class BL0906 final : public PollingComponent, public uart::UARTDevice { SUB_SENSOR(voltage) SUB_SENSOR(current_1) SUB_SENSOR(current_2) @@ -103,7 +103,7 @@ class BL0906 : public PollingComponent, public uart::UARTDevice { std::vector action_queue_{}; }; -template class ResetEnergyAction : public Action, public Parented { +template class ResetEnergyAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->enqueue_action_(&BL0906::reset_energy_); } }; diff --git a/esphome/components/bl0939/bl0939.h b/esphome/components/bl0939/bl0939.h index b4f6d42e71..333bca3715 100644 --- a/esphome/components/bl0939/bl0939.h +++ b/esphome/components/bl0939/bl0939.h @@ -56,7 +56,7 @@ union DataPacket { // NOLINT(altera-struct-pack-align) }; } __attribute__((packed)); -class BL0939 : public PollingComponent, public uart::UARTDevice { +class BL0939 final : public PollingComponent, public uart::UARTDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_current_sensor_1(sensor::Sensor *current_sensor_1) { current_sensor_1_ = current_sensor_1; } diff --git a/esphome/components/bl0940/bl0940.h b/esphome/components/bl0940/bl0940.h index 14cb69d0b0..007fa990d5 100644 --- a/esphome/components/bl0940/bl0940.h +++ b/esphome/components/bl0940/bl0940.h @@ -33,7 +33,7 @@ struct DataPacket { uint8_t checksum; // Packet checksum } __attribute__((packed)); -class BL0940 : public PollingComponent, public uart::UARTDevice { +class BL0940 final : public PollingComponent, public uart::UARTDevice { public: // Sensor setters void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } diff --git a/esphome/components/bl0940/button/calibration_reset_button.h b/esphome/components/bl0940/button/calibration_reset_button.h index d528992d58..f5a4f50886 100644 --- a/esphome/components/bl0940/button/calibration_reset_button.h +++ b/esphome/components/bl0940/button/calibration_reset_button.h @@ -7,7 +7,7 @@ namespace esphome::bl0940 { class BL0940; // Forward declaration of BL0940 class -class CalibrationResetButton : public button::Button, public Component, public Parented { +class CalibrationResetButton final : public button::Button, public Component, public Parented { public: void dump_config() override; diff --git a/esphome/components/bl0940/number/calibration_number.h b/esphome/components/bl0940/number/calibration_number.h index 062890d918..186a34c583 100644 --- a/esphome/components/bl0940/number/calibration_number.h +++ b/esphome/components/bl0940/number/calibration_number.h @@ -6,7 +6,7 @@ namespace esphome::bl0940 { -class CalibrationNumber : public number::Number, public Component { +class CalibrationNumber final : public number::Number, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/bl0942/bl0942.h b/esphome/components/bl0942/bl0942.h index c366878637..f926dd022d 100644 --- a/esphome/components/bl0942/bl0942.h +++ b/esphome/components/bl0942/bl0942.h @@ -83,7 +83,7 @@ enum LineFrequency : uint8_t { LINE_FREQUENCY_60HZ = 60, }; -class BL0942 : public PollingComponent, public uart::UARTDevice { +class BL0942 final : public PollingComponent, public uart::UARTDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { this->voltage_sensor_ = voltage_sensor; } void set_current_sensor(sensor::Sensor *current_sensor) { this->current_sensor_ = current_sensor; } diff --git a/esphome/components/ble_client/automation.h b/esphome/components/ble_client/automation.h index 01590d1d53..94eeb83b3e 100644 --- a/esphome/components/ble_client/automation.h +++ b/esphome/components/ble_client/automation.h @@ -23,7 +23,7 @@ class Automation { }; // implement on_connect automation. -class BLEClientConnectTrigger : public Trigger<>, public BLEClientNode { +class BLEClientConnectTrigger final : public Trigger<>, public BLEClientNode { public: explicit BLEClientConnectTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -37,7 +37,7 @@ class BLEClientConnectTrigger : public Trigger<>, public BLEClientNode { }; // on_disconnect automation -class BLEClientDisconnectTrigger : public Trigger<>, public BLEClientNode { +class BLEClientDisconnectTrigger final : public Trigger<>, public BLEClientNode { public: explicit BLEClientDisconnectTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -61,7 +61,7 @@ class BLEClientDisconnectTrigger : public Trigger<>, public BLEClientNode { } }; -class BLEClientPasskeyRequestTrigger : public Trigger<>, public BLEClientNode { +class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientNode { public: explicit BLEClientPasskeyRequestTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -71,7 +71,7 @@ class BLEClientPasskeyRequestTrigger : public Trigger<>, public BLEClientNode { } }; -class BLEClientPasskeyNotificationTrigger : public Trigger, public BLEClientNode { +class BLEClientPasskeyNotificationTrigger final : public Trigger, public BLEClientNode { public: explicit BLEClientPasskeyNotificationTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -82,7 +82,7 @@ class BLEClientPasskeyNotificationTrigger : public Trigger, public BLE } }; -class BLEClientNumericComparisonRequestTrigger : public Trigger, public BLEClientNode { +class BLEClientNumericComparisonRequestTrigger final : public Trigger, public BLEClientNode { public: explicit BLEClientNumericComparisonRequestTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -94,7 +94,7 @@ class BLEClientNumericComparisonRequestTrigger : public Trigger, publi }; // implement the ble_client.ble_write action. -template class BLEClientWriteAction : public Action, public BLEClientNode { +template class BLEClientWriteAction final : public Action, public BLEClientNode { public: BLEClientWriteAction(BLEClient *ble_client) { ble_client->register_ble_node(this); @@ -231,7 +231,7 @@ template class BLEClientWriteAction : public Action, publ esp_gatt_write_type_t write_type_{}; }; -template class BLEClientPasskeyReplyAction : public Action { +template class BLEClientPasskeyReplyAction final : public Action { public: BLEClientPasskeyReplyAction(BLEClient *ble_client) { parent_ = ble_client; } @@ -268,7 +268,7 @@ template class BLEClientPasskeyReplyAction : public Action class BLEClientNumericComparisonReplyAction : public Action { +template class BLEClientNumericComparisonReplyAction final : public Action { public: BLEClientNumericComparisonReplyAction(BLEClient *ble_client) { parent_ = ble_client; } @@ -301,7 +301,7 @@ template class BLEClientNumericComparisonReplyAction : public Ac } value_{.simple = false}; }; -template class BLEClientRemoveBondAction : public Action { +template class BLEClientRemoveBondAction final : public Action { public: BLEClientRemoveBondAction(BLEClient *ble_client) { parent_ = ble_client; } @@ -315,7 +315,7 @@ template class BLEClientRemoveBondAction : public Action BLEClient *parent_{nullptr}; }; -template class BLEClientConnectAction : public Action, public BLEClientNode { +template class BLEClientConnectAction final : public Action, public BLEClientNode { public: BLEClientConnectAction(BLEClient *ble_client) { ble_client->register_ble_node(this); @@ -364,7 +364,7 @@ template class BLEClientConnectAction : public Action, pu std::tuple var_{}; }; -template class BLEClientDisconnectAction : public Action, public BLEClientNode { +template class BLEClientDisconnectAction final : public Action, public BLEClientNode { public: BLEClientDisconnectAction(BLEClient *ble_client) { ble_client->register_ble_node(this); diff --git a/esphome/components/ble_client/ble_client.h b/esphome/components/ble_client/ble_client.h index ca523251ef..f27bef332b 100644 --- a/esphome/components/ble_client/ble_client.h +++ b/esphome/components/ble_client/ble_client.h @@ -44,7 +44,7 @@ class BLEClientNode { uint64_t address_; }; -class BLEClient : public BLEClientBase { +class BLEClient final : public BLEClientBase { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ble_client/output/ble_binary_output.h b/esphome/components/ble_client/output/ble_binary_output.h index 299de9b860..8ea700529b 100644 --- a/esphome/components/ble_client/output/ble_binary_output.h +++ b/esphome/components/ble_client/output/ble_binary_output.h @@ -11,7 +11,7 @@ namespace esphome::ble_client { namespace espbt = esphome::esp32_ble_tracker; -class BLEBinaryOutput : public output::BinaryOutput, public BLEClientNode, public Component { +class BLEBinaryOutput final : public output::BinaryOutput, public BLEClientNode, public Component { public: void dump_config() override; void loop() override {} diff --git a/esphome/components/ble_client/sensor/automation.h b/esphome/components/ble_client/sensor/automation.h index 84430cb7d9..e805ebdb59 100644 --- a/esphome/components/ble_client/sensor/automation.h +++ b/esphome/components/ble_client/sensor/automation.h @@ -7,7 +7,7 @@ namespace esphome::ble_client { -class BLESensorNotifyTrigger : public Trigger, public BLESensor { +class BLESensorNotifyTrigger final : public Trigger, public BLESensor { public: explicit BLESensorNotifyTrigger(BLESensor *sensor) { sensor_ = sensor; } void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, diff --git a/esphome/components/ble_client/sensor/ble_rssi_sensor.h b/esphome/components/ble_client/sensor/ble_rssi_sensor.h index 570a5b423c..e1590dbdeb 100644 --- a/esphome/components/ble_client/sensor/ble_rssi_sensor.h +++ b/esphome/components/ble_client/sensor/ble_rssi_sensor.h @@ -12,7 +12,7 @@ namespace esphome::ble_client { namespace espbt = esphome::esp32_ble_tracker; -class BLEClientRSSISensor : public sensor::Sensor, public PollingComponent, public BLEClientNode { +class BLEClientRSSISensor final : public sensor::Sensor, public PollingComponent, public BLEClientNode { public: void loop() override; void update() override; diff --git a/esphome/components/ble_client/switch/ble_switch.h b/esphome/components/ble_client/switch/ble_switch.h index 9be6d06b1c..42b450243a 100644 --- a/esphome/components/ble_client/switch/ble_switch.h +++ b/esphome/components/ble_client/switch/ble_switch.h @@ -12,7 +12,7 @@ namespace esphome::ble_client { namespace espbt = esphome::esp32_ble_tracker; -class BLEClientSwitch : public switch_::Switch, public Component, public BLEClientNode { +class BLEClientSwitch final : public switch_::Switch, public Component, public BLEClientNode { public: void dump_config() override; void loop() override {} diff --git a/esphome/components/ble_client/text_sensor/automation.h b/esphome/components/ble_client/text_sensor/automation.h index d4114cd1ba..8a81610668 100644 --- a/esphome/components/ble_client/text_sensor/automation.h +++ b/esphome/components/ble_client/text_sensor/automation.h @@ -7,7 +7,7 @@ namespace esphome::ble_client { -class BLETextSensorNotifyTrigger : public Trigger, public BLETextSensor { +class BLETextSensorNotifyTrigger final : public Trigger, public BLETextSensor { public: explicit BLETextSensorNotifyTrigger(BLETextSensor *sensor) { sensor_ = sensor; } void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, diff --git a/esphome/components/ble_nus/ble_nus.h b/esphome/components/ble_nus/ble_nus.h index f1afd54af9..82e2db6900 100644 --- a/esphome/components/ble_nus/ble_nus.h +++ b/esphome/components/ble_nus/ble_nus.h @@ -11,7 +11,7 @@ namespace esphome::ble_nus { -class BLENUS : public uart::UARTComponent, public Component { +class BLENUS final : public uart::UARTComponent, public Component { enum TxStatus { TX_DISABLED, TX_ENABLED, diff --git a/esphome/components/ble_presence/ble_presence_device.h b/esphome/components/ble_presence/ble_presence_device.h index 76e8079948..e17e26ff1c 100644 --- a/esphome/components/ble_presence/ble_presence_device.h +++ b/esphome/components/ble_presence/ble_presence_device.h @@ -8,9 +8,9 @@ namespace esphome::ble_presence { -class BLEPresenceDevice : public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener, - public Component { +class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff, + public esp32_ble_tracker::ESPBTDeviceListener, + public Component { public: void set_address(uint64_t address) { this->match_by_ = MATCH_BY_MAC_ADDRESS; diff --git a/esphome/components/ble_rssi/ble_rssi_sensor.h b/esphome/components/ble_rssi/ble_rssi_sensor.h index a876fa51d2..8e804ab8e7 100644 --- a/esphome/components/ble_rssi/ble_rssi_sensor.h +++ b/esphome/components/ble_rssi/ble_rssi_sensor.h @@ -8,7 +8,7 @@ namespace esphome::ble_rssi { -class BLERSSISensor : public sensor::Sensor, public esp32_ble_tracker::ESPBTDeviceListener, public Component { +class BLERSSISensor final : public sensor::Sensor, public esp32_ble_tracker::ESPBTDeviceListener, public Component { public: void set_address(uint64_t address) { this->match_by_ = MATCH_BY_MAC_ADDRESS; From 69f905f15448270b842803aaeba562c9e359e79a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Jun 2026 04:18:12 -0500 Subject: [PATCH 0460/1815] [ci] Revert "Bump awalsh128/cache-apt-pkgs-action from 1.6.0 to 1.6.1" (#17028) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ff846e4b2..aca6d9007a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -820,7 +820,7 @@ jobs: run: echo ${{ matrix.components }} - name: Cache apt packages - uses: awalsh128/cache-apt-pkgs-action@681749ae568c81c2037cb9185e38b709b261bd2f # v1.5.3 + uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3 with: packages: libsdl2-dev version: 1.0 From 1753ccd81198b8a1cdf40374a12c478265c9e5c0 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 08:57:59 -0400 Subject: [PATCH 0461/1815] [ci] Update component-test CI for ESP-IDF default toolchain (#16383) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- .github/actions/cache-esp-idf/action.yml | 14 +- .github/workflows/ci.yml | 89 +++-------- script/determine-jobs.py | 124 +++++++++------ tests/script/test_determine_jobs.py | 187 ++++++++++++++++------- 4 files changed, 247 insertions(+), 167 deletions(-) diff --git a/.github/actions/cache-esp-idf/action.yml b/.github/actions/cache-esp-idf/action.yml index 7a17c222a3..f566ba4c43 100644 --- a/.github/actions/cache-esp-idf/action.yml +++ b/.github/actions/cache-esp-idf/action.yml @@ -2,8 +2,8 @@ name: Cache ESP-IDF description: > Resolve the pinned ESP-IDF version and cache the native ESP-IDF install (toolchains + source) at ~/.esphome-idf. Every job that installs ESP-IDF - natively (clang-tidy for IDF/Arduino and the native-IDF component build) - shares one cache, since the install is identical (ESPHOME_IDF_DEFAULT_TARGETS + natively (clang-tidy for IDF/Arduino and the component test batches) shares + one cache, since the install is identical (ESPHOME_IDF_DEFAULT_TARGETS defaults to "all", so all toolchains are present regardless of the chip). Callers must set env ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf and have the Python venv already restored. @@ -11,6 +11,12 @@ inputs: framework: description: 'Which pinned IDF version to key on: "espidf" (recommended) or "arduino".' default: espidf + restore-only: + description: > + When "true", only restore -- never save the cache, even on dev. Use from + jobs that may not produce an ESP-IDF install (e.g. a component batch with + no esp32 target), so a partial/empty install is never written to the key. + default: "false" runs: using: composite steps: @@ -33,13 +39,13 @@ runs: # PRs), and PRs are restore-only -- they never push multi-GB artifacts into # their own scope / the repo quota (e.g. on a version-bump PR). - name: Cache ESP-IDF install (write on dev) - if: github.ref == 'refs/heads/dev' + if: github.ref == 'refs/heads/dev' && inputs.restore-only != 'true' uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.esphome-idf key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }} - name: Cache ESP-IDF install (restore-only off dev) - if: github.ref != 'refs/heads/dev' + if: github.ref != 'refs/heads/dev' || inputs.restore-only == 'true' uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.esphome-idf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aca6d9007a..29d42330cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -270,8 +270,8 @@ jobs: python-linters: ${{ steps.determine.outputs.python-linters }} import-time: ${{ steps.determine.outputs.import-time }} device-builder: ${{ steps.determine.outputs.device-builder }} - native-idf: ${{ steps.determine.outputs.native-idf }} - native-idf-components: ${{ steps.determine.outputs.native-idf-components }} + esp32-platformio: ${{ steps.determine.outputs.esp32-platformio }} + esp32-platformio-components: ${{ steps.determine.outputs.esp32-platformio-components }} 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 }} @@ -324,8 +324,8 @@ jobs: echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT echo "import-time=$(echo "$output" | jq -r '.import_time')" >> $GITHUB_OUTPUT echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT - echo "native-idf=$(echo "$output" | jq -r '.native_idf')" >> $GITHUB_OUTPUT - echo "native-idf-components=$(echo "$output" | jq -r '.native_idf_components')" >> $GITHUB_OUTPUT + echo "esp32-platformio=$(echo "$output" | jq -r '.esp32_platformio')" >> $GITHUB_OUTPUT + echo "esp32-platformio-components=$(echo "$output" | jq -r '.esp32_platformio_components')" >> $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 @@ -522,7 +522,6 @@ jobs: key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} - name: Cache ESP-IDF install - # Shared with the IDF tidy + native-IDF build jobs (same install). if: matrix.cache_idf uses: ./.github/actions/cache-esp-idf with: @@ -592,7 +591,6 @@ jobs: cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache ESP-IDF install - # Shared with the Arduino tidy + native-IDF build jobs (same install). uses: ./.github/actions/cache-esp-idf - name: Register problem matchers @@ -673,7 +671,6 @@ jobs: cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache ESP-IDF install - # Shared with the Arduino tidy + native-IDF build jobs (same install). uses: ./.github/actions/cache-esp-idf - name: Register problem matchers @@ -758,7 +755,6 @@ jobs: cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache ESP-IDF install - # Shared with the IDF/Arduino clang-tidy jobs + native-IDF build (same install). uses: ./.github/actions/cache-esp-idf - name: Register problem matchers @@ -805,6 +801,10 @@ jobs: - common - determine-jobs if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.component-test-count) > 0 + env: + # esp32 component builds use the native ESP-IDF toolchain (default), so + # share the tidy jobs' install location -- the restore below lands here. + ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf strategy: fail-fast: false max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 8 || 4 }} @@ -832,6 +832,12 @@ jobs: with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} + - name: Cache ESP-IDF install (restore-only) + # A batch may contain no esp32 build, so never save -- just reuse the + # shared install the dev tidy jobs already cached when present. + uses: ./.github/actions/cache-esp-idf + with: + restore-only: true - name: Validate and compile components with intelligent grouping run: | . venv/bin/activate @@ -935,20 +941,19 @@ jobs: echo "All components in this batch are validate-only -- skipping compile stage." fi - test-native-idf: - name: Test components with native ESP-IDF + test-esp32-platformio: + name: Test esp32 components with PlatformIO runs-on: ubuntu-24.04 needs: - common - determine-jobs - if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.native-idf == 'true' + if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.esp32-platformio == 'true' env: - ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf - # Comma-joined subset of the native-IDF representative component list, - # computed by script/determine-jobs.py (native_idf_components_to_test). + # Comma-joined subset of the esp32 PlatformIO representative component list, + # computed by script/determine-jobs.py (esp32_platformio_components_to_test). # Single source of truth -- the full list lives in - # script/determine-jobs.py::NATIVE_IDF_TEST_COMPONENTS. - TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.native-idf-components }} + # script/determine-jobs.py::ESP32_PLATFORMIO_TEST_COMPONENTS. + TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.esp32-platformio-components }} steps: - name: Check out code from GitHub uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -959,66 +964,22 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - name: Prepare build storage on /mnt - # Bind-mount the larger /mnt disk over the IDF install + build dirs BEFORE - # restoring the cache, so the ~4.5GB restore lands on the roomier volume - # instead of being shadowed by a mount set up later in the run step. - run: | - root_avail=$(df -k / | awk 'NR==2 {print $4}') - mnt_avail=$(df -k /mnt 2>/dev/null | awk 'NR==2 {print $4}') - echo "Available space: / has ${root_avail}KB, /mnt has ${mnt_avail}KB" - if [ -n "$mnt_avail" ] && [ "$mnt_avail" -gt "$root_avail" ]; then - echo "Using /mnt for build files (more space available)" - sudo mkdir -p /mnt/esphome-idf - sudo chown $USER:$USER /mnt/esphome-idf - mkdir -p ~/.esphome-idf - sudo mount --bind /mnt/esphome-idf ~/.esphome-idf - sudo mkdir -p /mnt/test_build_components_build - sudo chown $USER:$USER /mnt/test_build_components_build - mkdir -p tests/test_build_components/build - sudo mount --bind /mnt/test_build_components_build tests/test_build_components/build - else - echo "Using / for build files (more space available than /mnt or /mnt unavailable)" - fi - - - name: Cache ESP-IDF install - # Shared with the IDF/Arduino clang-tidy jobs (same install); restores - # into the /mnt bind-mount prepared above when present. - uses: ./.github/actions/cache-esp-idf - - - name: Run native ESP-IDF compile test + - name: Run PlatformIO compile test run: | . venv/bin/activate echo "Testing components: $TEST_COMPONENTS" echo "" - # Show disk space before validation - echo "Disk space before config validation:" - df -h - echo "" - # Run config validation (auto-grouped by test_build_components.py) - python3 script/test_build_components.py -e config -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain esp-idf + python3 script/test_build_components.py -e config -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio echo "" echo "Config validation passed! Starting compilation..." echo "" - # Show disk space before compilation - echo "Disk space before compilation:" - df -h - echo "" - # Run compilation (auto-grouped by test_build_components.py) - python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain esp-idf - - - name: Save ESPHome cache - if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.esphome-idf - key: ${{ runner.os }}-esphome-${{ needs.common.outputs.cache-key }} + python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio pre-commit-ci-lite: name: pre-commit.ci lite @@ -1353,7 +1314,7 @@ jobs: - determine-jobs - device-builder - test-build-components-split - - test-native-idf + - test-esp32-platformio - pre-commit-ci-lite - memory-impact-target-branch - memory-impact-pr-branch diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 4904883ca9..af3e83f96b 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -466,11 +466,11 @@ def should_run_device_builder(branch: str | None = None) -> bool: return False -# Components tested by the native ESP-IDF compile-test job. This is the +# Components tested by the PlatformIO compile-test job. This is the # single source of truth: the workflow reads the comma-joined list from the -# `native-idf-components` output of `determine-jobs` and uses it as the -# `TEST_COMPONENTS` env on the `test-native-idf` job. -NATIVE_IDF_TEST_COMPONENTS = frozenset( +# `esp32-platformio-components` output of `determine-jobs` and uses it as the +# `TEST_COMPONENTS` env on the `test-esp32-platformio` job. +ESP32_PLATFORMIO_TEST_COMPONENTS = frozenset( { "esp32", "api", @@ -490,53 +490,75 @@ NATIVE_IDF_TEST_COMPONENTS = frozenset( } ) -# Path prefixes whose changes always trigger the native ESP-IDF compile -# test: anything under esphome/espidf/ (the native IDF runner / API / -# framework / component generator). -NATIVE_IDF_TRIGGER_PATH_PREFIXES = ("esphome/espidf/",) +# Path prefixes whose changes always trigger the PlatformIO compile test: +# anything under esphome/platformio/ (the PlatformIO runner / toolchain that +# drives every PlatformIO build). The esp32 platform component is already in +# ESP32_PLATFORMIO_TEST_COMPONENTS, so its changes are covered by the normal +# component-narrowing path. +ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES = ("esphome/platformio/",) -# Standalone files that, when changed, also trigger the native ESP-IDF -# compile test: -# - esphome/build_gen/espidf.py -- the native IDF build generator -# (other files under build_gen/ target PlatformIO and don't affect -# the native IDF path) +# Standalone files that, when changed, trigger the PlatformIO compile test: +# - esphome/build_gen/platformio.py -- the PlatformIO build generator # - script/test_build_components.py -- the harness the job invokes # - .github/workflows/ci.yml -- the job's own definition -NATIVE_IDF_TRIGGER_FILES = frozenset( +ESP32_PLATFORMIO_TRIGGER_FILES = frozenset( { - "esphome/build_gen/espidf.py", + "esphome/build_gen/platformio.py", "script/test_build_components.py", ".github/workflows/ci.yml", } ) -def _native_idf_path_or_file_trigger(files: list[str]) -> bool: - """Whether any changed file is a native IDF infrastructure / harness trigger.""" +def _esp32_platformio_path_or_file_trigger(files: list[str]) -> bool: + """Whether any changed file is a PlatformIO infrastructure / harness trigger.""" for file in files: - if file in NATIVE_IDF_TRIGGER_FILES: + if file in ESP32_PLATFORMIO_TRIGGER_FILES: return True - if any(file.startswith(prefix) for prefix in NATIVE_IDF_TRIGGER_PATH_PREFIXES): + if any( + file.startswith(prefix) for prefix in ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES + ): return True return False -def native_idf_components_to_test(branch: str | None = None) -> list[str]: - """Subset of ``NATIVE_IDF_TEST_COMPONENTS`` the job needs to compile. +# ESP-IDF infra: changes under esphome/espidf/ or to the IDF build generator +# affect every esp32 IDF build (now the default toolchain) but aren't +# components, so the component matrix wouldn't otherwise force any esp32 +# compile. When they change we fold the `esp32` component into the matrix so +# the default native-IDF build path is still compiled on an infra-only PR. +ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES = ("esphome/espidf/",) +ESP_IDF_INFRA_TRIGGER_FILES = frozenset({"esphome/build_gen/espidf.py"}) - The job builds components with the native ESP-IDF toolchain (no - PlatformIO). When only a specific component (or something it depends - on) changed, there's no value in re-building every other unrelated - component in the test list -- the regular ``component-test`` matrix - already covers them via PlatformIO. So we narrow to the intersection - of ``NATIVE_IDF_TEST_COMPONENTS`` and the changed-component dependency + +def _esp_idf_infra_changed(files: list[str]) -> bool: + """Whether any changed file is ESP-IDF build/runner infrastructure.""" + for file in files: + if file in ESP_IDF_INFRA_TRIGGER_FILES: + return True + if any( + file.startswith(prefix) for prefix in ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES + ): + return True + return False + + +def esp32_platformio_components_to_test(branch: str | None = None) -> list[str]: + """Subset of ``ESP32_PLATFORMIO_TEST_COMPONENTS`` the job needs to compile. + + The job builds components with the PlatformIO toolchain. When only a + specific component (or something it depends on) changed, there's no + value in re-building every other unrelated component in the test list -- + the regular ``component-test`` matrix already covers them via the + default toolchain. So we narrow to the intersection of + ``ESP32_PLATFORMIO_TEST_COMPONENTS`` and the changed-component dependency closure. Returns the full list (sorted) when we can't safely narrow: 1. Core C++/Python files changed (``esphome/core/*``). - 2. Native IDF infrastructure changed (``esphome/espidf/*`` or - ``esphome/build_gen/espidf.py``). + 2. PlatformIO infrastructure changed (``esphome/platformio/*`` or + ``esphome/build_gen/platformio.py``). 3. The test harness or workflow itself changed (``script/test_build_components.py``, ``.github/workflows/ci.yml``). @@ -558,31 +580,31 @@ def native_idf_components_to_test(branch: str | None = None) -> list[str]: """ files = changed_files(branch) - if core_changed(files) or _native_idf_path_or_file_trigger(files): - return sorted(NATIVE_IDF_TEST_COMPONENTS) + if core_changed(files) or _esp32_platformio_path_or_file_trigger(files): + return sorted(ESP32_PLATFORMIO_TEST_COMPONENTS) component_files = [f for f in files if filter_component_and_test_files(f)] changed = get_components_with_dependencies(component_files, True) - return sorted(NATIVE_IDF_TEST_COMPONENTS & set(changed)) + return sorted(ESP32_PLATFORMIO_TEST_COMPONENTS & set(changed)) -def should_run_native_idf(branch: str | None = None) -> bool: - """Determine if the `test-native-idf` compile-test job should run. +def should_run_esp32_platformio(branch: str | None = None) -> bool: + """Determine if the `test-esp32-platformio` compile-test job should run. - Runs whenever ``native_idf_components_to_test()`` returns a non-empty + Runs whenever ``esp32_platformio_components_to_test()`` returns a non-empty list. Skipping the job on unrelated Python-only PRs avoids ~5 min of CI per PR (worse on cold caches). The regular ``component-test`` - matrix still exercises the same components through PlatformIO when - those components change. + matrix still exercises the same components through the default + toolchain when those components change. Args: branch: Branch to compare against. If None, uses default. Returns: - True if the native ESP-IDF compile test should run, False otherwise. + True if the PlatformIO compile test should run, False otherwise. """ - return bool(native_idf_components_to_test(branch)) + return bool(esp32_platformio_components_to_test(branch)) def determine_cpp_unit_tests( @@ -1162,8 +1184,8 @@ def main() -> None: run_python_linters = True run_import_time = True run_device_builder = True - native_idf_components = sorted(NATIVE_IDF_TEST_COMPONENTS) - run_native_idf = True + esp32_platformio_components = sorted(ESP32_PLATFORMIO_TEST_COMPONENTS) + run_esp32_platformio = True else: integration_run_all, integration_test_files = determine_integration_tests( args.branch @@ -1173,8 +1195,8 @@ def main() -> None: run_python_linters = should_run_python_linters(args.branch) run_import_time = should_run_import_time(args.branch) run_device_builder = should_run_device_builder(args.branch) - native_idf_components = native_idf_components_to_test(args.branch) - run_native_idf = bool(native_idf_components) + esp32_platformio_components = esp32_platformio_components_to_test(args.branch) + run_esp32_platformio = bool(esp32_platformio_components) run_integration, integration_test_buckets = _compute_integration_test_buckets( integration_run_all, integration_test_files ) @@ -1228,6 +1250,18 @@ def main() -> None: if _component_has_tests(component) ] + # ESP-IDF build-gen/runner changed but no component pulled esp32 in: fold the + # `esp32` component into the matrix so the default native-IDF build path is + # still compiled on an infra-only PR. force_all/core already test everything, + # so skip there. Runs grouped (not added to directly-changed). + if ( + not is_core_change + and _esp_idf_infra_changed(changed) + and "esp32" not in changed_components_with_tests + and _component_has_tests("esp32") + ): + changed_components_with_tests.append("esp32") + # Get directly changed components with tests (for isolated testing) # These will be tested WITHOUT --testing-mode in CI to enable full validation # (pin conflicts, etc.) since they contain the actual changes being reviewed @@ -1345,8 +1379,8 @@ def main() -> None: "python_linters": run_python_linters, "import_time": run_import_time, "device_builder": run_device_builder, - "native_idf": run_native_idf, - "native_idf_components": ",".join(native_idf_components), + "esp32_platformio": run_esp32_platformio, + "esp32_platformio_components": ",".join(esp32_platformio_components), "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/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index f8f359ee22..a9876632bd 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -68,13 +68,13 @@ def mock_should_run_device_builder() -> Generator[Mock, None, None]: @pytest.fixture -def mock_native_idf_components_to_test() -> Generator[Mock, None, None]: - """Mock native_idf_components_to_test from determine_jobs. +def mock_esp32_platformio_components_to_test() -> Generator[Mock, None, None]: + """Mock esp32_platformio_components_to_test from determine_jobs. - main() drives both the ``native_idf`` boolean output and the - ``native_idf_components`` CSV from this one function. + main() drives both the ``esp32_platformio`` boolean output and the + ``esp32_platformio_components`` CSV from this one function. """ - with patch.object(determine_jobs, "native_idf_components_to_test") as mock: + with patch.object(determine_jobs, "esp32_platformio_components_to_test") as mock: yield mock @@ -115,7 +115,7 @@ def test_main_all_tests_should_run( mock_should_run_python_linters: Mock, mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, - mock_native_idf_components_to_test: Mock, + mock_esp32_platformio_components_to_test: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -131,7 +131,7 @@ def test_main_all_tests_should_run( mock_should_run_python_linters.return_value = True mock_should_run_import_time.return_value = True mock_should_run_device_builder.return_value = True - mock_native_idf_components_to_test.return_value = ["api", "esp32"] + mock_esp32_platformio_components_to_test.return_value = ["api", "esp32"] mock_determine_cpp_unit_tests.return_value = (False, ["wifi", "api", "sensor"]) # Mock changed_files to return non-component files (to avoid memory impact) @@ -213,8 +213,8 @@ def test_main_all_tests_should_run( assert output["python_linters"] is True assert output["import_time"] is True assert output["device_builder"] is True - assert output["native_idf"] is True - assert output["native_idf_components"] == "api,esp32" + assert output["esp32_platformio"] is True + assert output["esp32_platformio_components"] == "api,esp32" 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 @@ -248,7 +248,7 @@ def test_main_no_tests_should_run( mock_should_run_python_linters: Mock, mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, - mock_native_idf_components_to_test: Mock, + mock_esp32_platformio_components_to_test: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -264,7 +264,7 @@ def test_main_no_tests_should_run( mock_should_run_python_linters.return_value = False mock_should_run_import_time.return_value = False mock_should_run_device_builder.return_value = False - mock_native_idf_components_to_test.return_value = [] + mock_esp32_platformio_components_to_test.return_value = [] mock_determine_cpp_unit_tests.return_value = (False, []) # Mock changed_files to return no component files @@ -305,8 +305,8 @@ def test_main_no_tests_should_run( assert output["python_linters"] is False assert output["import_time"] is False assert output["device_builder"] is False - assert output["native_idf"] is False - assert output["native_idf_components"] == "" + assert output["esp32_platformio"] is False + assert output["esp32_platformio_components"] == "" assert output["changed_components"] == [] assert output["changed_components_with_tests"] == [] assert output["component_test_count"] == 0 @@ -322,6 +322,65 @@ def test_main_no_tests_should_run( assert output["component_test_batches"] == [] +def test_main_esp_idf_infra_change_folds_esp32( + mock_determine_integration_tests: Mock, + 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_should_run_device_builder: Mock, + mock_esp32_platformio_components_to_test: Mock, + mock_changed_files: Mock, + mock_determine_cpp_unit_tests: Mock, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An ESP-IDF infra-only change folds the `esp32` component into the matrix, + so the default native-IDF build path is still compiled.""" + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + + mock_determine_integration_tests.return_value = (False, []) + 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_should_run_device_builder.return_value = False + mock_esp32_platformio_components_to_test.return_value = [] + mock_determine_cpp_unit_tests.return_value = (False, []) + + # IDF build generator changed; no component changed. + mock_changed_files.return_value = ["esphome/build_gen/espidf.py"] + + with ( + patch("sys.argv", ["determine-jobs.py"]), + patch.object(determine_jobs, "get_changed_components", return_value=[]), + patch.object( + determine_jobs, "filter_component_and_test_files", return_value=False + ), + patch.object( + determine_jobs, "get_components_with_dependencies", return_value=[] + ), + # esp32 has tests on disk, but pin it so the fold-in isn't coupled to layout. + patch.object(determine_jobs, "_component_has_tests", return_value=True), + patch.object( + determine_jobs, + "detect_memory_impact_config", + return_value={"should_run": "false"}, + ), + patch.object( + determine_jobs, "create_intelligent_batches", return_value=([], {}) + ), + ): + determine_jobs.main() + + output = json.loads(capsys.readouterr().out) + # Only `esp32` is folded in (not the whole representative set), and it's + # grouped, not isolated (infra changed, not the component). + assert output["changed_components_with_tests"] == ["esp32"] + assert output["directly_changed_components_with_tests"] == [] + assert output["component_test_count"] == 1 + + def test_main_with_branch_argument( mock_determine_integration_tests: Mock, mock_should_run_clang_tidy: Mock, @@ -329,7 +388,7 @@ def test_main_with_branch_argument( mock_should_run_python_linters: Mock, mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, - mock_native_idf_components_to_test: Mock, + mock_esp32_platformio_components_to_test: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -345,7 +404,7 @@ def test_main_with_branch_argument( mock_should_run_python_linters.return_value = True mock_should_run_import_time.return_value = True mock_should_run_device_builder.return_value = True - mock_native_idf_components_to_test.return_value = ["esp32"] + mock_esp32_platformio_components_to_test.return_value = ["esp32"] mock_determine_cpp_unit_tests.return_value = (False, ["mqtt"]) # Mock changed_files to return non-component files (to avoid memory impact) @@ -384,7 +443,7 @@ def test_main_with_branch_argument( mock_should_run_python_linters.assert_called_once_with("main") mock_should_run_import_time.assert_called_once_with("main") mock_should_run_device_builder.assert_called_once_with("main") - mock_native_idf_components_to_test.assert_called_once_with("main") + mock_esp32_platformio_components_to_test.assert_called_once_with("main") # Check output captured = capsys.readouterr() @@ -398,8 +457,8 @@ def test_main_with_branch_argument( assert output["python_linters"] is True assert output["import_time"] is True assert output["device_builder"] is True - assert output["native_idf"] is True - assert output["native_idf_components"] == "esp32" + assert output["esp32_platformio"] is True + assert output["esp32_platformio_components"] == "esp32" 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 @@ -916,23 +975,22 @@ def test_should_run_device_builder_skips_beta_release(target_branch: str) -> Non mock_changed.assert_not_called() -_NATIVE_IDF_FULL_LIST_FILES = [ +_ESP32_PLATFORMIO_FULL_LIST_FILES = [ # Core C++/Python changes -- caught by core_changed() ["esphome/core/component.cpp"], ["esphome/core/config.py"], - # Native IDF infrastructure paths - ["esphome/espidf/framework.py"], - ["esphome/espidf/component.py"], - ["esphome/espidf/api.py"], - ["esphome/build_gen/espidf.py"], + # PlatformIO subsystem (path-prefix trigger) + build generator + ["esphome/platformio/runner.py"], + ["esphome/platformio/toolchain.py"], + ["esphome/build_gen/platformio.py"], # Workflow / harness files ["script/test_build_components.py"], [".github/workflows/ci.yml"], ] -@pytest.mark.parametrize("changed_files", _NATIVE_IDF_FULL_LIST_FILES) -def test_native_idf_components_to_test_returns_full_list_on_infrastructure( +@pytest.mark.parametrize("changed_files", _ESP32_PLATFORMIO_FULL_LIST_FILES) +def test_esp32_platformio_components_to_test_returns_full_list_on_infrastructure( changed_files: list[str], ) -> None: """Infrastructure / core / harness changes fall back to the full component list.""" @@ -944,8 +1002,8 @@ def test_native_idf_components_to_test_returns_full_list_on_infrastructure( determine_jobs, "get_components_with_dependencies", return_value=["wifi"] ), ): - result = determine_jobs.native_idf_components_to_test() - assert result == sorted(determine_jobs.NATIVE_IDF_TEST_COMPONENTS) + result = determine_jobs.esp32_platformio_components_to_test() + assert result == sorted(determine_jobs.ESP32_PLATFORMIO_TEST_COMPONENTS) @pytest.mark.parametrize( @@ -965,7 +1023,7 @@ def test_native_idf_components_to_test_returns_full_list_on_infrastructure( ["ble_scanner", "esp32_ble", "esp32_ble_tracker"], ), # api in the test set -- narrow to [api] even though the closure - # has other (unrelated to native-IDF coverage) entries. + # has other (unrelated to PlatformIO coverage) entries. ( ["esphome/components/api/api_connection.cpp"], ["api", "logger"], @@ -979,15 +1037,15 @@ def test_native_idf_components_to_test_returns_full_list_on_infrastructure( ), # Pure Python-only change outside trigger paths -> empty. (["esphome/yaml_util.py"], [], []), - # Non-IDF files in esphome/build_gen/ do NOT trigger the full - # list -- only esphome/build_gen/espidf.py is a trigger. - (["esphome/build_gen/platformio.py"], [], []), + # Non-PlatformIO files in esphome/build_gen/ do NOT trigger the + # full list -- only esphome/build_gen/platformio.py is a trigger. + (["esphome/build_gen/espidf.py"], [], []), # Docs / unrelated files -> empty. (["README.md"], [], []), ([], [], []), ], ) -def test_native_idf_components_to_test_narrowing( +def test_esp32_platformio_components_to_test_narrowing( changed_files: list[str], dependency_closure: list[str], expected: list[str], @@ -1001,12 +1059,12 @@ def test_native_idf_components_to_test_narrowing( return_value=dependency_closure, ), ): - result = determine_jobs.native_idf_components_to_test() + result = determine_jobs.esp32_platformio_components_to_test() assert result == expected -def test_native_idf_components_to_test_with_branch() -> None: - """native_idf_components_to_test passes branch argument through. +def test_esp32_platformio_components_to_test_with_branch() -> None: + """esp32_platformio_components_to_test passes branch argument through. Regression test: an earlier version called ``get_changed_components()``, which silently ignored the branch argument because that helper re-runs @@ -1021,7 +1079,7 @@ def test_native_idf_components_to_test_with_branch() -> None: ), ): mock_changed.return_value = [] - determine_jobs.native_idf_components_to_test("release") + determine_jobs.esp32_platformio_components_to_test("release") mock_changed.assert_called_once_with("release") @@ -1033,25 +1091,46 @@ def test_native_idf_components_to_test_with_branch() -> None: (["esp32", "api"], True), ], ) -def test_should_run_native_idf(components_to_test: list[str], expected: bool) -> None: - """should_run_native_idf is a thin wrapper around the component list.""" +def test_should_run_esp32_platformio( + components_to_test: list[str], expected: bool +) -> None: + """should_run_esp32_platformio is a thin wrapper around the component list.""" with patch.object( determine_jobs, - "native_idf_components_to_test", + "esp32_platformio_components_to_test", return_value=components_to_test, ): - assert determine_jobs.should_run_native_idf() is expected + assert determine_jobs.should_run_esp32_platformio() is expected -def test_should_run_native_idf_with_branch() -> None: - """Test should_run_native_idf passes branch argument through.""" +def test_should_run_esp32_platformio_with_branch() -> None: + """Test should_run_esp32_platformio passes branch argument through.""" with patch.object( - determine_jobs, "native_idf_components_to_test", return_value=[] + determine_jobs, "esp32_platformio_components_to_test", return_value=[] ) as mock_inner: - determine_jobs.should_run_native_idf("release") + determine_jobs.should_run_esp32_platformio("release") mock_inner.assert_called_once_with("release") +@pytest.mark.parametrize( + ("changed_files", "expected"), + [ + # ESP-IDF runner / framework / build generator -> trigger + (["esphome/espidf/runner.py"], True), + (["esphome/espidf/framework.py"], True), + (["esphome/build_gen/espidf.py"], True), + # PlatformIO build gen and esp32 component are NOT IDF-infra triggers + (["esphome/build_gen/platformio.py"], False), + (["esphome/components/esp32/__init__.py"], False), + (["README.md"], False), + ([], False), + ], +) +def test_esp_idf_infra_changed(changed_files: list[str], expected: bool) -> None: + """ESP-IDF build/runner infra paths are detected; other paths are not.""" + assert determine_jobs._esp_idf_infra_changed(changed_files) is expected + + @pytest.mark.parametrize( ("changed_files", "expected_result"), [ @@ -2751,7 +2830,7 @@ def test_main_force_all_overrides_detection( mock_should_run_python_linters: Mock, mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, - mock_native_idf_components_to_test: Mock, + mock_esp32_platformio_components_to_test: Mock, mock_determine_cpp_unit_tests: Mock, mock_changed_files: Mock, capsys: pytest.CaptureFixture[str], @@ -2772,7 +2851,7 @@ def test_main_force_all_overrides_detection( mock_should_run_python_linters.return_value = False mock_should_run_import_time.return_value = False mock_should_run_device_builder.return_value = False - mock_native_idf_components_to_test.return_value = [] + mock_esp32_platformio_components_to_test.return_value = [] mock_determine_cpp_unit_tests.return_value = (False, []) mock_changed_files.return_value = [] @@ -2813,9 +2892,9 @@ def test_main_force_all_overrides_detection( assert output["python_linters"] is True assert output["import_time"] is True assert output["device_builder"] is True - assert output["native_idf"] is True - # native_idf_components is a CSV of NATIVE_IDF_TEST_COMPONENTS - assert "esp32" in output["native_idf_components"].split(",") + assert output["esp32_platformio"] is True + # esp32_platformio_components is a CSV of ESP32_PLATFORMIO_TEST_COMPONENTS + assert "esp32" in output["esp32_platformio_components"].split(",") assert output["cpp_unit_tests_run_all"] is True assert output["cpp_unit_tests_components"] == [] assert output["benchmarks"] is True @@ -2826,7 +2905,7 @@ def test_main_force_all_overrides_detection( mock_should_run_python_linters.assert_not_called() mock_should_run_import_time.assert_not_called() mock_should_run_device_builder.assert_not_called() - mock_native_idf_components_to_test.assert_not_called() + mock_esp32_platformio_components_to_test.assert_not_called() mock_determine_cpp_unit_tests.assert_not_called() # Component matrix is populated from disk (tests/components/ in the repo) assert output["component_test_count"] > 0 @@ -2840,7 +2919,7 @@ def test_main_force_all_off_uses_detection( mock_should_run_python_linters: Mock, mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, - mock_native_idf_components_to_test: Mock, + mock_esp32_platformio_components_to_test: Mock, mock_determine_cpp_unit_tests: Mock, mock_changed_files: Mock, capsys: pytest.CaptureFixture[str], @@ -2855,7 +2934,7 @@ def test_main_force_all_off_uses_detection( mock_should_run_python_linters.return_value = False mock_should_run_import_time.return_value = False mock_should_run_device_builder.return_value = False - mock_native_idf_components_to_test.return_value = [] + mock_esp32_platformio_components_to_test.return_value = [] mock_determine_cpp_unit_tests.return_value = (False, []) mock_changed_files.return_value = [] @@ -2886,7 +2965,7 @@ def test_main_force_all_off_uses_detection( assert output["clang_tidy"] is False assert output["clang_format"] is False assert output["python_linters"] is False - assert output["native_idf"] is False + assert output["esp32_platformio"] is False assert output["component_test_count"] == 0 mock_determine_integration_tests.assert_called_once() mock_should_run_clang_tidy.assert_called_once() From bf12af46458c023a372d76f07800426550b702c4 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 18 Jun 2026 09:31:49 -0400 Subject: [PATCH 0462/1815] [wifi] Add runtime suppression of post-connect roaming scans (#17012) Co-authored-by: J. Nick Koston --- esphome/components/wifi/__init__.py | 16 ++++++ esphome/components/wifi/wifi_component.cpp | 2 +- esphome/components/wifi/wifi_component.h | 61 ++++++++++++++++++++++ esphome/core/defines.h | 1 + tests/components/wifi/test.esp32-idf.yaml | 6 ++- 5 files changed, 84 insertions(+), 2 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 080a7bb97b..1cfd2b9821 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -764,6 +764,7 @@ async def wifi_disable_to_code(config, action_id, template_arg, args): KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results" RUNTIME_POWER_SAVE_KEY = "wifi_runtime_power_save" +RUNTIME_ROAMING_SUPPRESSION_KEY = "wifi_runtime_roaming_suppression" # Keys for listener counts IP_STATE_LISTENERS_KEY = "wifi_ip_state_listeners" SCAN_RESULTS_LISTENERS_KEY = "wifi_scan_results_listeners" @@ -794,6 +795,19 @@ def enable_runtime_power_save_control(): CORE.data[RUNTIME_POWER_SAVE_KEY] = True +def enable_runtime_roaming_suppression() -> None: + """Enable runtime suppression of post-connect roaming scans. + + Components that are disrupted by the radio briefly going off-channel during a + roaming scan (e.g., audio playback) should call this function during their code + generation. This enables the request_roaming_suppression() and + release_roaming_suppression() APIs, which pause periodic roaming scans while active. + + Only supported on ESP32. + """ + CORE.data[RUNTIME_ROAMING_SUPPRESSION_KEY] = True + + def request_wifi_ip_state_listener() -> None: """Request an IP state listener slot.""" CORE.data[IP_STATE_LISTENERS_KEY] = CORE.data.get(IP_STATE_LISTENERS_KEY, 0) + 1 @@ -827,6 +841,8 @@ async def final_step(): ) if CORE.data.get(RUNTIME_POWER_SAVE_KEY, False): cg.add_define("USE_WIFI_RUNTIME_POWER_SAVE") + if CORE.data.get(RUNTIME_ROAMING_SUPPRESSION_KEY, False): + cg.add_define("USE_WIFI_RUNTIME_ROAMING_SUPPRESSION") # Generate listener defines - each listener type has its own #ifdef ip_state_count = CORE.data.get(IP_STATE_LISTENERS_KEY, 0) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 07cb2ac243..ffc6ea8e14 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -822,7 +822,7 @@ void WiFiComponent::loop() { } // else: scan in progress, wait } else if (this->roaming_state_ == RoamingState::IDLE && this->roaming_attempts_ < ROAMING_MAX_ATTEMPTS && - now - this->roaming_last_check_ >= ROAMING_CHECK_INTERVAL) { + now - this->roaming_last_check_ >= ROAMING_CHECK_INTERVAL && !this->roaming_suppressed_()) { this->check_roaming_(now); } } diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index d0521e548a..c774e3a68e 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -16,6 +16,8 @@ #endif #include "esphome/core/string_ref.h" +#include +#include #include #include #include @@ -604,6 +606,49 @@ class WiFiComponent final : public Component { bool release_high_performance(); #endif // USE_WIFI_RUNTIME_POWER_SAVE +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION) + /** Request that post-connect roaming scans be suppressed. + * + * Components that are disrupted by the radio briefly going off-channel during a + * scan (e.g., audio playback) can call this to pause periodic roaming scans while + * active. Multiple components can request suppression simultaneously; roaming + * resumes once every requester has called release_roaming_suppression(). + * + * A roaming scan already in progress is allowed to finish; this only prevents new + * roaming scans from starting. The roaming interval timer is not reset, so roaming + * resumes on the next loop once suppression is released (and the interval elapsed). + * + * Note: Only supported on ESP32. + * + * Thread-safe: may be called from any task. + */ + void request_roaming_suppression() { + uint8_t current = this->roaming_suppression_count_.load(std::memory_order_relaxed); + // CAS loop: saturate at max instead of wrapping, so an excess of requests can't roll the + // counter back to zero and unintentionally re-enable roaming. + while (current < std::numeric_limits::max() && + !this->roaming_suppression_count_.compare_exchange_weak(current, current + 1, std::memory_order_relaxed)) { + } + } + + /** Release a roaming suppression request. + * + * Must be paired with a prior request_roaming_suppression() call. When all requests + * are released (count reaches zero), post-connect roaming resumes. A release with no + * outstanding request is ignored rather than underflowing the counter. + * + * Thread-safe: may be called from any task. + */ + void release_roaming_suppression() { + uint8_t current = this->roaming_suppression_count_.load(std::memory_order_relaxed); + // CAS loop: decrement only if non-zero, so an unmatched release can't wrap the counter + // and permanently suppress roaming. + while (current > 0 && + !this->roaming_suppression_count_.compare_exchange_weak(current, current - 1, std::memory_order_relaxed)) { + } + } +#endif // USE_ESP32 && USE_WIFI_RUNTIME_ROAMING_SUPPRESSION + protected: #ifdef USE_WIFI_AP void setup_ap_config_(); @@ -732,6 +777,15 @@ class WiFiComponent final : public Component { void process_roaming_scan_(); void clear_roaming_state_(); + /// Returns true if a component has requested that roaming scans be suppressed (e.g. during audio playback). + bool roaming_suppressed_() const { +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION) + return this->roaming_suppression_count_.load(std::memory_order_relaxed) != 0; +#else + return false; +#endif + } + /// Free scan results memory unless a component needs them void release_scan_results_(); @@ -845,6 +899,13 @@ class WiFiComponent final : public Component { // int8_t limits to 127 APs (enforced in __init__.py via MAX_WIFI_NETWORKS) int8_t selected_sta_index_{-1}; uint8_t roaming_attempts_{0}; +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION) + // Count of active roaming-suppression requests. Incremented/decremented from any task + // (e.g. audio playback), read in loop(). Roaming scans are paused while non-zero. + // Relaxed ordering is sufficient: the count value is the only data shared across threads, + // so no happens-before relationship with other memory needs to be established. + std::atomic roaming_suppression_count_{0}; +#endif #if USE_NETWORK_IPV6 uint8_t num_ipv6_addresses_{0}; #endif /* USE_NETWORK_IPV6 */ diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 410858f904..17b5e64862 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -312,6 +312,7 @@ #define ESPHOME_WIFI_CONNECT_STATE_LISTENERS 2 #define ESPHOME_WIFI_POWER_SAVE_LISTENERS 2 #define USE_WIFI_RUNTIME_POWER_SAVE +#define USE_WIFI_RUNTIME_ROAMING_SUPPRESSION #define USB_HOST_MAX_REQUESTS 16 #define USB_HOST_MAX_PACKET_SIZE 64 #define USB_UART_OUTPUT_CHUNK_COUNT 5 diff --git a/tests/components/wifi/test.esp32-idf.yaml b/tests/components/wifi/test.esp32-idf.yaml index b2b2233ef3..d000c61170 100644 --- a/tests/components/wifi/test.esp32-idf.yaml +++ b/tests/components/wifi/test.esp32-idf.yaml @@ -1,15 +1,19 @@ psram: -# Tests the high performance request and release; requires the USE_WIFI_RUNTIME_POWER_SAVE define +# Tests the high performance and roaming suppression request/release APIs; +# requires the USE_WIFI_RUNTIME_POWER_SAVE and USE_WIFI_RUNTIME_ROAMING_SUPPRESSION defines esphome: platformio_options: build_flags: - "-DUSE_WIFI_RUNTIME_POWER_SAVE" + - "-DUSE_WIFI_RUNTIME_ROAMING_SUPPRESSION" on_boot: - then: - lambda: |- esphome::wifi::global_wifi_component->request_high_performance(); esphome::wifi::global_wifi_component->release_high_performance(); + esphome::wifi::global_wifi_component->request_roaming_suppression(); + esphome::wifi::global_wifi_component->release_roaming_suppression(); wifi: use_psram: true From 14e89f3dae752e968d979b40f437ed2814ee3615 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:31:17 +0000 Subject: [PATCH 0463/1815] Bump actions/checkout from 6.0.3 to 7.0.0 (#17049) Signed-off-by: dependabot[bot] --- .github/workflows/auto-label-pr.yml | 2 +- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci-docker.yml | 6 +-- .github/workflows/ci-github-scripts.yml | 2 +- .../workflows/ci-memory-impact-comment.yml | 2 +- .github/workflows/ci.yml | 42 +++++++++---------- .../codeowner-approved-label-update.yml | 2 +- .../workflows/codeowner-review-request.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/pr-title-check.yml | 2 +- .github/workflows/release.yml | 8 ++-- .github/workflows/sync-device-classes.yml | 4 +- 12 files changed, 38 insertions(+), 38 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index e48d6f69bd..d034227ef6 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -24,7 +24,7 @@ jobs: if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot') steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Generate a token id: generate-token diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index c6e9a358ab..2155b67b25 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 373cd905b1..8301f8e9e3 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -61,7 +61,7 @@ jobs: tag: ${{ steps.tag.outputs.tag }} push: ${{ steps.tag.outputs.push }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -145,7 +145,7 @@ jobs: - "ha-addon" - "docker" steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -202,7 +202,7 @@ jobs: - nrf52 - host steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Download image artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: diff --git a/.github/workflows/ci-github-scripts.yml b/.github/workflows/ci-github-scripts.yml index 43d530128c..3313ced690 100644 --- a/.github/workflows/ci-github-scripts.yml +++ b/.github/workflows/ci-github-scripts.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Run tests working-directory: .github/scripts/auto-label-pr diff --git a/.github/workflows/ci-memory-impact-comment.yml b/.github/workflows/ci-memory-impact-comment.yml index 35cfce65f8..4bef082aab 100644 --- a/.github/workflows/ci-memory-impact-comment.yml +++ b/.github/workflows/ci-memory-impact-comment.yml @@ -49,7 +49,7 @@ jobs: - name: Check out code from base repository if: steps.pr.outputs.skip != 'true' - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Always check out from the base repository (esphome/esphome), never from forks # Use the PR's target branch to ensure we run trusted code from the main repo diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29d42330cd..e46c6e2fc5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: cache-key: ${{ steps.cache-key.outputs.key }} steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Generate cache-key id: cache-key run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT @@ -74,7 +74,7 @@ jobs: if: needs.determine-jobs.outputs.python-linters == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -97,7 +97,7 @@ jobs: if: needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -124,7 +124,7 @@ jobs: if: needs.determine-jobs.outputs.import-time == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -152,11 +152,11 @@ jobs: if: needs.determine-jobs.outputs.device-builder == 'true' steps: - name: Check out esphome (this PR) - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: path: esphome - name: Check out esphome/device-builder - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: esphome/device-builder ref: main @@ -225,7 +225,7 @@ jobs: if: needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python id: restore-python uses: ./.github/actions/restore-python @@ -285,7 +285,7 @@ jobs: benchmarks: ${{ steps.determine.outputs.benchmarks }} steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Fetch enough history to find the merge base fetch-depth: 2 @@ -357,7 +357,7 @@ jobs: bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }} steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python 3.13 id: python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 @@ -409,7 +409,7 @@ jobs: if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]') steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python @@ -438,7 +438,7 @@ jobs: (github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true') steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python @@ -496,7 +496,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -579,7 +579,7 @@ jobs: ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -659,7 +659,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -743,7 +743,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -826,7 +826,7 @@ jobs: version: 1.0 - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -956,7 +956,7 @@ jobs: TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.esp32-platformio-components }} steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python @@ -990,7 +990,7 @@ jobs: if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -1016,7 +1016,7 @@ jobs: skip: ${{ steps.check-script.outputs.skip || steps.check-tests.outputs.skip }} steps: - name: Check out target branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.base_ref }} @@ -1198,7 +1198,7 @@ jobs: flash_usage: ${{ steps.extract.outputs.flash_usage }} steps: - name: Check out PR branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -1267,7 +1267,7 @@ jobs: GH_TOKEN: ${{ github.token }} steps: - name: Check out code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: diff --git a/.github/workflows/codeowner-approved-label-update.yml b/.github/workflows/codeowner-approved-label-update.yml index 1bd60fd11d..9b1333734e 100644 --- a/.github/workflows/codeowner-approved-label-update.yml +++ b/.github/workflows/codeowner-approved-label-update.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout base branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.pull_request.base.sha }} sparse-checkout: | diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml index 5ad0b02de1..da9c5f63d6 100644 --- a/.github/workflows/codeowner-review-request.yml +++ b/.github/workflows/codeowner-review-request.yml @@ -29,7 +29,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout base branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.pull_request.base.sha }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e559472b60..5a448c4003 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -52,7 +52,7 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index 0e2efb1bcf..2bb6505b74 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -16,7 +16,7 @@ jobs: name: Validate PR title runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8efc395951..3056d9e7d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: branch_build: ${{ steps.tag.outputs.branch_build }} deploy_env: ${{ steps.tag.outputs.deploy_env }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Get tag id: tag # yamllint disable rule:line-length @@ -60,7 +60,7 @@ jobs: contents: read # actions/checkout to build the sdist/wheel id-token: write # OIDC token for PyPI Trusted Publishing (pypa/gh-action-pypi-publish) steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -92,7 +92,7 @@ jobs: os: "ubuntu-24.04-arm" steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -168,7 +168,7 @@ jobs: - ghcr - dockerhub steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Download digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index ab1ce2b587..05036f3500 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -28,10 +28,10 @@ jobs: permission-pull-requests: write # pulls.create / pulls.update to open or refresh the sync PR - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Checkout Home Assistant - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: home-assistant/core path: lib/home-assistant From a39505f5ef0426fe21977a9cc8c7e9a7dad3d983 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:33:01 +0000 Subject: [PATCH 0464/1815] Bump CodSpeedHQ/action from 4.17.5 to 4.17.6 (#17047) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e46c6e2fc5..6774695e58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -456,7 +456,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@c145068895e045cc725ee76fcd2307624b65c3af # v4.17.5 + uses: CodSpeedHQ/action@63f3e98b61959fe67f146a3ff022e4136fe9bb9c # v4.17.6 with: run: | . venv/bin/activate From 1a553018bfa8a8e84da002a136643590e1c135eb Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:38:57 -0400 Subject: [PATCH 0465/1815] [build] Skip target-platform deps when populating host unit-test config (#17039) --- script/build_helpers.py | 21 ++++++--- tests/script/test_build_helpers.py | 76 ++++++++++++++++++++++++++++++ tests/script/test_test_helpers.py | 2 + 3 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 tests/script/test_build_helpers.py diff --git a/script/build_helpers.py b/script/build_helpers.py index eaf3a1f1a7..50830c221e 100644 --- a/script/build_helpers.py +++ b/script/build_helpers.py @@ -70,12 +70,15 @@ def populate_dependency_config( * ``domain.platform`` form (e.g. ``sensor.gpio``) appends ``{platform: }`` to ``config[domain]``, creating the list if needed. - * Bare components are looked up via ``get_component_fn``. Platform - components (``IS_PLATFORM_COMPONENT``) and ``MULTI_CONF`` components are - initialised as ``[]`` so the sibling ``domain.platform`` branch can - ``append`` into them. Everything else is populated by running the - component's schema with ``{}`` so defaults exist; if the schema requires - explicit input, an empty ``{}`` is used as a fallback. + * Bare components are looked up via ``get_component_fn``. Target-platform + components (``is_target_platform``, e.g. ``esp32``) are skipped entirely: + a host build targets ``host``, so a foreign target platform's sources are + guarded out and its schema must not run here (it would mutate global CORE + state as a side effect). Platform components (``IS_PLATFORM_COMPONENT``) + and ``MULTI_CONF`` components are initialised as ``[]`` so the sibling + ``domain.platform`` branch can ``append`` into them. Everything else is + populated by running the component's schema with ``{}`` so defaults exist; + if the schema requires explicit input, an empty ``{}`` is used as a fallback. Platform components must always be a list here even when no ``domain.platform`` entry follows, because the ``domain.platform`` branch @@ -96,6 +99,12 @@ def populate_dependency_config( component = get_component_fn(component_name) if component is None: continue + # Skip target platforms (e.g. esp32): a host build targets `host`, so a + # foreign target's sources are guarded out, and running its schema with + # {} leaks global CORE state (esp32 pins CORE.toolchain to ESP-IDF), + # crashing the host compile. See #17035. + if component.is_target_platform: + continue if component.multi_conf or component.is_platform_component: config.setdefault(component_name, []) elif component_name not in config: diff --git a/tests/script/test_build_helpers.py b/tests/script/test_build_helpers.py new file mode 100644 index 0000000000..efa6a75483 --- /dev/null +++ b/tests/script/test_build_helpers.py @@ -0,0 +1,76 @@ +"""Unit tests for script/build_helpers.py.""" + +from pathlib import Path +import sys + +import pytest + +# Add the script directory to the path so we can import build_helpers. +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "script")) + +import build_helpers # noqa: E402 + +from esphome.core import CORE # noqa: E402 + + +class _FakeComponent: + def __init__(self, config_schema, *, is_target_platform=False): + self.multi_conf = False + self.is_platform_component = False + self.is_target_platform = is_target_platform + self.config_schema = config_schema + + +@pytest.fixture(autouse=True) +def _restore_core_toolchain(): + """Keep CORE.toolchain changes from leaking between tests.""" + saved = CORE.toolchain + try: + yield + finally: + CORE.toolchain = saved + + +def test_populate_dependency_config_skips_target_platforms() -> None: + """Target-platform deps must be skipped, not config-populated, in a host build. + + Regression test for #17035: esp32 (a target platform) appears only as a + transitive dependency of a host C++ unit test. Running its schema with {} + set ``CORE.toolchain = ESP_IDF`` as a side effect before failing validation, + which crashed the host compile with KeyError('esp32'). The fix skips + target-platform components entirely so their schema never runs. + """ + CORE.toolchain = None # the state a host build starts from + schema_calls = [] + + def leaky_schema(value): + # If this ever runs for a target platform, the bug is back. + schema_calls.append(value) + CORE.toolchain = "esp-idf-leak" + raise ValueError("no board or variant") + + config: dict = {} + build_helpers.populate_dependency_config( + config, + ["esp32"], + get_component_fn=lambda name: _FakeComponent( + leaky_schema, is_target_platform=True + ), + register_platform_fn=lambda domain: None, + ) + + assert "esp32" not in config # skipped: no synthesized entry + assert schema_calls == [] # schema never run + assert CORE.toolchain is None # no global side effect leaked + + +def test_populate_dependency_config_populates_defaults() -> None: + """A non-target-platform dep still has its schema defaults harvested.""" + config: dict = {} + build_helpers.populate_dependency_config( + config, + ["ok"], + get_component_fn=lambda name: _FakeComponent(lambda value: {"default": 1}), + register_platform_fn=lambda domain: None, + ) + assert config["ok"] == {"default": 1} diff --git a/tests/script/test_test_helpers.py b/tests/script/test_test_helpers.py index a8100252da..4b05cab376 100644 --- a/tests/script/test_test_helpers.py +++ b/tests/script/test_test_helpers.py @@ -266,11 +266,13 @@ def _make_component_stub( *, multi_conf: bool = False, is_platform_component: bool = False, + is_target_platform: bool = False, config_schema=None, ) -> MagicMock: stub = MagicMock() stub.multi_conf = multi_conf stub.is_platform_component = is_platform_component + stub.is_target_platform = is_target_platform stub.config_schema = config_schema return stub From 19cca9e177045dd95f86cc351a25c7d5a4fc89b1 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:41:03 -0400 Subject: [PATCH 0466/1815] [esp32] Remove framework migration notice (#17023) --- esphome/components/esp32/__init__.py | 53 ---------------------------- 1 file changed, 53 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index aee86a0554..ec33d9d271 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1589,65 +1589,12 @@ FRAMEWORK_SCHEMA = cv.Schema( ) -# Remove this class in 2026.7.0 -class _FrameworkMigrationWarning: - shown = False - - -def _show_framework_migration_message(name: str, variant: str) -> None: - """Show a message about the framework default change and how to switch back to Arduino.""" - # Remove this function in 2026.7.0 - if _FrameworkMigrationWarning.shown: - return - _FrameworkMigrationWarning.shown = True - - from esphome.log import AnsiFore, color - - message = ( - color( - AnsiFore.BOLD_CYAN, - f"💡 NOTICE: {name} does not have a framework specified.", - ) - + "\n\n" - + f"Starting with ESPHome 2026.1.0, the default framework for {variant} is ESP-IDF.\n" - + "(We've been warning about this change since ESPHome 2025.8.0)\n" - + "\n" - + "Why we made this change:\n" - + color(AnsiFore.GREEN, " ✨ Smaller firmware binaries\n") - + color(AnsiFore.GREEN, " ⚡ Faster compile times\n") - + color(AnsiFore.GREEN, " 🚀 Better performance and newer features\n") - + color(AnsiFore.GREEN, " 🔧 More actively maintained by ESPHome\n") - + "\n" - + "To continue using Arduino, add this to your YAML under 'esp32:':\n" - + color(AnsiFore.WHITE, " framework:\n") - + color(AnsiFore.WHITE, " type: arduino\n") - + "\n" - + "To silence this message with ESP-IDF, explicitly set:\n" - + color(AnsiFore.WHITE, " framework:\n") - + color(AnsiFore.WHITE, " type: esp-idf\n") - + "\n" - + "Migration guide: " - + color( - AnsiFore.BLUE, - "https://esphome.io/guides/esp32_arduino_to_idf/", - ) - ) - _LOGGER.warning(message) - - def _set_default_framework(config): config = config.copy() if CONF_FRAMEWORK not in config: config[CONF_FRAMEWORK] = FRAMEWORK_SCHEMA({}) if CONF_TYPE not in config[CONF_FRAMEWORK]: - variant = config[CONF_VARIANT] config[CONF_FRAMEWORK][CONF_TYPE] = FRAMEWORK_ESP_IDF - # Show migration message for variants that previously defaulted to Arduino - # Remove this message in 2026.7.0 - if variant in ARDUINO_ALLOWED_VARIANTS: - _show_framework_migration_message( - config.get(CONF_NAME, "This device"), variant - ) return config From f6c78f74154d8328b163bb053d170ebc7349924b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:43:17 -0400 Subject: [PATCH 0467/1815] [uptime] Revert timestamp sensor device_class to timestamp (#17037) --- esphome/components/uptime/sensor/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/uptime/sensor/__init__.py b/esphome/components/uptime/sensor/__init__.py index 6ce0795cdb..e2a7aee1a2 100644 --- a/esphome/components/uptime/sensor/__init__.py +++ b/esphome/components/uptime/sensor/__init__.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_TIME_ID, DEVICE_CLASS_DURATION, - DEVICE_CLASS_UPTIME, + DEVICE_CLASS_TIMESTAMP, ENTITY_CATEGORY_DIAGNOSTIC, ICON_TIMER, STATE_CLASS_TOTAL_INCREASING, @@ -33,8 +33,9 @@ CONFIG_SCHEMA = cv.typed_schema( ).extend(cv.polling_component_schema("60s")), "timestamp": sensor.sensor_schema( UptimeTimestampSensor, + icon=ICON_TIMER, accuracy_decimals=0, - device_class=DEVICE_CLASS_UPTIME, + device_class=DEVICE_CLASS_TIMESTAMP, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ) .extend( From 53e85e07d475abab906f6597e9d1b5a7c958dc84 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:56:21 -0400 Subject: [PATCH 0468/1815] [esp32] Support `esphome idedata` with the native ESP-IDF toolchain (#17040) --- esphome/__main__.py | 15 ++++++++++++ esphome/espidf/toolchain.py | 1 + tests/unit_tests/test_espidf_toolchain.py | 28 +++++++++++++++++++---- tests/unit_tests/test_main.py | 26 +++++++++++++++++++++ 4 files changed, 66 insertions(+), 4 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 27dd878495..bda3dcbd05 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1771,6 +1771,21 @@ def command_update_all(args: ArgsProtocol) -> int | None: def command_idedata(args: ArgsProtocol, config: ConfigType) -> int: import json + if CORE.using_toolchain_esp_idf: + # Native ESP-IDF derives idedata from the build's compile_commands.json, + # so the configuration must already be compiled. + from esphome.espidf import toolchain as espidf_toolchain + + idedata = espidf_toolchain.get_idedata() + if idedata is None: + _LOGGER.error( + "No idedata available; compile the configuration first", + ) + return 1 + + print(json.dumps(idedata, indent=2) + "\n") + return 0 + if not CORE.using_toolchain_platformio: _LOGGER.error( "The idedata command is not compatible with %s toolchain", diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index c622a2dd36..000ce739db 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -472,6 +472,7 @@ def get_idedata() -> dict | None: pass data = idedata_from_build(compile_commands) + data["prog_path"] = str(get_elf_path()) cache.parent.mkdir(parents=True, exist_ok=True) cache.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") return data diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index b2309439f9..017d8c49b4 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -89,8 +89,9 @@ def test_get_idedata_generates_and_caches(setup_core: Path) -> None: result = toolchain.get_idedata() mock_transform.assert_called_once() - assert result == {"cxx_path": "g++"} - assert json.loads(cache.read_text()) == {"cxx_path": "g++"} + prog_path = str(toolchain.get_elf_path()) + assert result == {"cxx_path": "g++", "prog_path": prog_path} + assert json.loads(cache.read_text()) == {"cxx_path": "g++", "prog_path": prog_path} def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None: @@ -127,7 +128,7 @@ def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) - result = toolchain.get_idedata() mock_transform.assert_called_once() - assert result == {"cxx_path": "fresh"} + assert result == {"cxx_path": "fresh", "prog_path": str(toolchain.get_elf_path())} def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None: @@ -147,7 +148,26 @@ def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None: result = toolchain.get_idedata() mock_transform.assert_called_once() - assert result == {"cxx_path": "regen"} + assert result == {"cxx_path": "regen", "prog_path": str(toolchain.get_elf_path())} + + +def test_get_idedata_prog_path_points_at_firmware_elf(setup_core: Path) -> None: + """The idedata exposes prog_path (the ELF) so consumers like build-action + can locate firmware.factory.bin / firmware.ota.bin as its siblings.""" + compile_commands, _ = _setup_build(setup_core) + compile_commands.parent.mkdir(parents=True, exist_ok=True) + compile_commands.write_text("[]") + + with patch( + "esphome.espidf.idedata.idedata_from_build", + return_value={"cxx_path": "g++"}, + ): + result = toolchain.get_idedata() + + # Use Path semantics so the contract holds on Windows too (backslashes). + prog_path = Path(result["prog_path"]) + assert prog_path.name == "firmware.elf" + assert prog_path.parent.name == "build" def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index e44f746a75..acd39cedc6 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -32,6 +32,7 @@ from esphome.__main__ import ( command_clean_all, command_config, command_config_hash, + command_idedata, command_rename, command_run, command_update_all, @@ -6257,3 +6258,28 @@ def test_command_run_defaults_subscribe_states_true( mock_run_logs.assert_called_once_with( CORE.config, ["192.168.1.100"], subscribe_states=True ) + + +def test_command_idedata_esp_idf_prints_json(capsys: CaptureFixture) -> None: + """Under the native ESP-IDF toolchain, idedata is emitted as JSON.""" + setup_core() + CORE.toolchain = Toolchain.ESP_IDF + data = {"cxx_path": "g++", "prog_path": "/build/firmware.elf"} + + with patch("esphome.espidf.toolchain.get_idedata", return_value=data) as mock_get: + result = command_idedata(MagicMock(), CORE.config) + + assert result == 0 + mock_get.assert_called_once_with() + assert json.loads(capsys.readouterr().out) == data + + +def test_command_idedata_esp_idf_no_build_errors() -> None: + """Under ESP-IDF, a missing build (no idedata) returns an error, not a crash.""" + setup_core() + CORE.toolchain = Toolchain.ESP_IDF + + with patch("esphome.espidf.toolchain.get_idedata", return_value=None): + result = command_idedata(MagicMock(), CORE.config) + + assert result == 1 From a0f546e375ae2c74c34b4ce5b25dd5e520c5ec14 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:08:22 -0400 Subject: [PATCH 0469/1815] [ci] Smoke-test Arduino framework in esp32 PlatformIO job (#17034) --- .github/workflows/ci.yml | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6774695e58..10ace8c179 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -971,16 +971,17 @@ jobs: echo "Testing components: $TEST_COMPONENTS" echo "" - # Run config validation (auto-grouped by test_build_components.py) - python3 script/test_build_components.py -e config -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio - - echo "" - echo "Config validation passed! Starting compilation..." - echo "" - - # Run compilation (auto-grouped by test_build_components.py) + # compile validates config first, so a separate config pass is + # redundant for this smoke test. ESP-IDF framework via PlatformIO: python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio + echo "" + echo "ESP-IDF-via-PlatformIO build passed! Starting Arduino smoke test..." + echo "" + + # Arduino framework via PlatformIO (only components with an esp32-ard test are built): + python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio + pre-commit-ci-lite: name: pre-commit.ci lite runs-on: ubuntu-latest From 8e7518fe9df898e487f15a570c55c1c09f8cd12e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:15:38 -0400 Subject: [PATCH 0470/1815] [esp32] Don't overwrite PlatformIO's factory.bin (#17042) --- esphome/components/esp32/post_build.py.script | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32/post_build.py.script b/esphome/components/esp32/post_build.py.script index b329f6b82b..f1a38f9e76 100644 --- a/esphome/components/esp32/post_build.py.script +++ b/esphome/components/esp32/post_build.py.script @@ -224,6 +224,17 @@ def merge_factory_bin(source, target, env): flash_size = env.BoardConfig().get("upload.flash_size", "4MB") chip = env.BoardConfig().get("build.mcu", "esp32") + # PlatformIO's esp-idf builder already creates a correct firmware.factory.bin (right + # artifact names and partition offsets, including custom partition tables). The merge + # below is only a fallback and cannot honor custom layouts, so don't overwrite an image + # PlatformIO already produced. Post-build actions only run when firmware.bin is rebuilt, + # and PlatformIO's combined-image builder runs before us in that batch, so an existing + # file here is current. + output_path = firmware_path.with_suffix(".factory.bin") + if output_path.exists(): + print(f"{output_path.name} already created by PlatformIO - skipping merge") + return + sections = [] flasher_args_path = build_dir / "flasher_args.json" @@ -291,7 +302,6 @@ def merge_factory_bin(source, target, env): print("No valid flash sections found — skipping .factory.bin creation.") return - output_path = firmware_path.with_suffix(".factory.bin") python_exe = f'"{env.subst("$PYTHONEXE")}"' cmd = [ python_exe, From b97182d302ef98da48fc26eb10149bf3d5b1b853 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Jun 2026 16:16:14 -0500 Subject: [PATCH 0471/1815] [logger] Hold recursion guard while draining the task log buffer (#17044) --- esphome/components/logger/logger.cpp | 4 + .../logger_buffered_recursion_guard.yaml | 61 +++++++++ .../test_logger_buffered_recursion_guard.py | 119 ++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 tests/integration/fixtures/logger_buffered_recursion_guard.yaml create mode 100644 tests/integration/test_logger_buffered_recursion_guard.py diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index a035525101..684da0202e 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -175,6 +175,10 @@ void Logger::process_messages_() { #ifdef USE_ESPHOME_TASK_LOG_BUFFER // Process any buffered messages when available if (this->log_buffer_.has_messages()) { + // Prevent main-task logs emitted by listener callbacks (e.g. the API send path) from re-entering + // and corrupting the shared tx_buffer_ / API shared_write_buffer_ while we are draining here. + // Mirrors the guard held by log_message_to_buffer_and_send_ on the synchronous logging path. + RecursionGuard guard(this->main_task_recursion_guard_); logger::TaskLogBuffer::LogMessage *message; uint16_t text_length; while (this->log_buffer_.borrow_message_main_loop(message, text_length)) { diff --git a/tests/integration/fixtures/logger_buffered_recursion_guard.yaml b/tests/integration/fixtures/logger_buffered_recursion_guard.yaml new file mode 100644 index 0000000000..058adbff99 --- /dev/null +++ b/tests/integration/fixtures/logger_buffered_recursion_guard.yaml @@ -0,0 +1,61 @@ +esphome: + name: logger-recursion-test +host: +api: +logger: + level: DEBUG + on_message: + # Fires on the main loop for every message delivered to listeners, including + # messages drained from the task log buffer (i.e. logged from a non-main thread). + # The lambda logs again on the main task. Without a recursion guard on the buffered + # drain path this re-entrant log reuses the shared tx_buffer_ and clobbers the + # buffered message that is still being delivered, corrupting its console output. + - level: VERY_VERBOSE + then: + - lambda: |- + ESP_LOGD("reentry", "REENTRANT_CLOBBER_MARKER"); + +button: + - platform: template + name: "Start Race Test" + id: start_test_button + on_press: + - lambda: |- + // Keep the count well under the host task-log-buffer slot count so every + // message goes through the ring buffer (buffered drain path) instead of the + // emergency console fallback. The main loop is blocked in pthread_join while + // the thread logs, so all messages are drained together once it returns. + static const int NUM_MESSAGES = 30; + + struct ThreadTest { + static void *thread_func(void *arg) { + char thread_name[16]; + snprintf(thread_name, sizeof(thread_name), "LogThread"); + #ifdef __APPLE__ + pthread_setname_np(thread_name); + #else + pthread_setname_np(pthread_self(), thread_name); + #endif + + for (int i = 0; i < NUM_MESSAGES; i++) { + // Verifiable payload: data is a deterministic function of the message + // index, so a clobbered buffer shows up as a missing or mismatched line. + ESP_LOGD("thread_test", "THREADMSG%03d_DATA_%08X", i, i * 12345); + } + return nullptr; + } + }; + + // RACE_TEST_START / RACE_TEST_COMPLETE are logged from the main task (the + // synchronous path, which already holds the recursion guard) so the test can + // always detect completion even when the buffered path is corrupted. + ESP_LOGI("thread_test", "RACE_TEST_START: logging %d messages from a thread", NUM_MESSAGES); + + pthread_t thread; + if (pthread_create(&thread, nullptr, ThreadTest::thread_func, nullptr) != 0) { + ESP_LOGE("thread_test", "RACE_TEST_ERROR: Failed to create thread"); + return; + } + pthread_join(thread, nullptr); + + ESP_LOGI("thread_test", "RACE_TEST_COMPLETE: thread finished, expected %d messages", NUM_MESSAGES); diff --git a/tests/integration/test_logger_buffered_recursion_guard.py b/tests/integration/test_logger_buffered_recursion_guard.py new file mode 100644 index 0000000000..5bef915b28 --- /dev/null +++ b/tests/integration/test_logger_buffered_recursion_guard.py @@ -0,0 +1,119 @@ +"""Integration test for the recursion guard on the buffered logger drain path. + +Regression test for a crash where a log message drained from the task log buffer +(i.e. logged from a non-main thread) re-entered the logger on the main task while it +was still being delivered to listeners. The buffered drain in +``Logger::process_messages_`` did not hold the main-task recursion guard that the +synchronous logging path holds, so a listener callback that logged again on the main +task (e.g. the API log-forwarding path, or a ``logger.on_message`` automation) reused +the shared ``tx_buffer_`` and clobbered the message mid-delivery. On ESP32 this showed +up as a ``StoreProhibited`` panic inside the API send path. + +The fixture logs a small batch of verifiable messages from a non-main thread (kept +under the host task-log-buffer slot count so they all take the buffered drain path +rather than the emergency console fallback) while an ``on_message`` automation re-logs +``REENTRANT_CLOBBER_MARKER`` on the main task for every delivered message. + +Without the guard the re-entrant marker is written into the shared ``tx_buffer_`` while +the buffered thread message is still being delivered, so the message the API receives is +contaminated (it contains the marker and an embedded newline glued onto the thread +payload). With the guard the re-entrant log is dropped during the drain, the marker +never appears, and every thread message is delivered clean. +""" + +from __future__ import annotations + +import asyncio +import re + +from aioesphomeapi import LogLevel +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +_ANSI = re.compile(r"\x1b\[[0-9;]*m") +# THREADMSGnnn_DATA_xxxxxxxx where data is a deterministic checksum of the index +THREAD_MSG_PATTERN = re.compile(r"THREADMSG(\d{3})_DATA_([0-9A-F]{8})") + +NUM_MESSAGES = 30 + + +@pytest.mark.asyncio +async def test_logger_buffered_recursion_guard( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Buffered (non-main-thread) log messages survive a re-entrant main-task log.""" + api_messages: list[str] = [] + all_drained = asyncio.Event() + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "logger-recursion-test" + + # Subscribe over the API: this is the exact path that crashed in the field + # (the API log callback runs during the buffered drain). The API message field + # preserves embedded newlines, so it reliably exposes a clobbered buffer. + # + # Every buffered thread message is delivered here whether it survives intact or + # gets clobbered (a clobbered message still carries its THREADMSG payload), so + # counting THREADMSG occurrences is a deterministic "drain complete" signal: no + # arbitrary sleep, no dependence on the fix being present. + def on_log(msg) -> None: + text = msg.message.decode("utf-8", errors="replace") + api_messages.append(text) + received = sum(len(THREAD_MSG_PATTERN.findall(m)) for m in api_messages) + if received >= NUM_MESSAGES: + all_drained.set() + + client.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERY_VERBOSE) + + entities, _ = await client.list_entities_services() + buttons = [e for e in entities if e.name == "Start Race Test"] + assert buttons, "Could not find Start Race Test button" + client.button_command(buttons[0].key) + + # Wait until every buffered thread message has been delivered over the API. + try: + await asyncio.wait_for(all_drained.wait(), timeout=30.0) + except TimeoutError: + received = sum(len(THREAD_MSG_PATTERN.findall(m)) for m in api_messages) + pytest.fail( + f"Only {received}/{NUM_MESSAGES} thread messages arrived before timeout; " + "device likely crashed or hung." + ) + + intact: set[int] = set() + contaminated: list[str] = [] + for raw in api_messages: + text = _ANSI.sub("", raw) + if "THREADMSG" not in text: + continue + # A clean thread message is a single line carrying only its own payload. A + # clobbered buffer glues the re-entrant marker (and an embedded newline) onto it. + if "REENTRANT" in text or "\n" in text: + contaminated.append(repr(raw)) + continue + match = THREAD_MSG_PATTERN.search(text) + assert match, f"Unexpected thread message format: {raw!r}" + msg_num = int(match.group(1)) + expected = f"{msg_num * 12345:08X}" + if match.group(2) != expected: + contaminated.append(repr(raw)) + continue + intact.add(msg_num) + + assert not contaminated, ( + "Buffered thread messages were clobbered by a re-entrant main-task log " + "(missing recursion guard on the buffered drain path):\n" + + "\n".join(contaminated[:10]) + ) + assert len(intact) == NUM_MESSAGES, ( + f"Expected {NUM_MESSAGES} intact buffered thread messages over the API, got " + f"{len(intact)}. Missing ids: {sorted(set(range(NUM_MESSAGES)) - intact)}" + ) From a497174da24cd864cade284a17e89c9732479ba6 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:05:20 +1200 Subject: [PATCH 0472/1815] Bump bundled esphome-device-builder to 1.0.10 (#17051) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 18a9903735..221121c8d3 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.9 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.10 RUN \ platformio settings set enable_telemetry No \ From ac5a28301a51ad3a68a8e863465e618d494e87a6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Jun 2026 22:11:24 -0500 Subject: [PATCH 0473/1815] [core] Honor transferred address cache in has_resolvable_address (#17025) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/__main__.py | 6 ++++++ tests/unit_tests/test_main.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/esphome/__main__.py b/esphome/__main__.py index f7d3f8e834..27dd878495 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -504,6 +504,12 @@ def has_resolvable_address() -> bool: if has_ip_address(): return True + # The dashboard pre-resolves the device and passes the IPs via + # --mdns-address-cache/--dns-address-cache; honor a cached address even when the + # device has mDNS disabled (e.g. a .local host found via ping). + if CORE.address_cache and CORE.address_cache.get_addresses(CORE.address): + return True + if has_mdns(): return True diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 03c005dc27..e44f746a75 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -689,6 +689,25 @@ def test_choose_upload_log_host_with_ota_device_with_ota_config() -> None: assert result == ["192.168.1.100"] +def test_choose_upload_log_host_ota_mdns_disabled_uses_address_cache() -> None: + """A .local device with mDNS disabled resolves via the dashboard-supplied cache.""" + setup_core( + config={ + CONF_API: {}, + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], + CONF_MDNS: {CONF_DISABLED: True}, + }, + address="esp32-a1s.local", + ) + CORE.address_cache = AddressCache(mdns_cache={"esp32-a1s.local": ["192.168.1.50"]}) + + for purpose in (Purpose.LOGGING, Purpose.UPLOADING): + result = choose_upload_log_host( + default="OTA", check_default=None, purpose=purpose + ) + assert result == ["192.168.1.50"] + + def test_choose_upload_log_host_with_ota_device_with_api_config() -> None: """Test OTA device when API is configured (no upload without OTA in config).""" setup_core(config={CONF_API: {}}, address="192.168.1.100") @@ -3135,6 +3154,22 @@ def test_has_resolvable_address() -> None: setup_core(config={CONF_MDNS: {CONF_DISABLED: True}}, address=None) assert has_resolvable_address() is False + # mDNS disabled + .local, but the dashboard cached the address -> resolvable + setup_core( + config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local" + ) + CORE.address_cache = AddressCache( + mdns_cache={"esphome-device.local": ["192.168.1.100"]} + ) + assert has_resolvable_address() is True + + # mDNS disabled + .local, cache present but missing this host -> not resolvable + setup_core( + config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local" + ) + CORE.address_cache = AddressCache(mdns_cache={"other-device.local": ["10.0.0.1"]}) + assert has_resolvable_address() is False + def test_has_name_add_mac_suffix() -> None: """Test has_name_add_mac_suffix function.""" From 86096b96f583a1902de8c4b935826c4f69fdeac4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:38:57 -0400 Subject: [PATCH 0474/1815] [build] Skip target-platform deps when populating host unit-test config (#17039) --- script/build_helpers.py | 21 ++++++--- tests/script/test_build_helpers.py | 76 ++++++++++++++++++++++++++++++ tests/script/test_test_helpers.py | 2 + 3 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 tests/script/test_build_helpers.py diff --git a/script/build_helpers.py b/script/build_helpers.py index eaf3a1f1a7..50830c221e 100644 --- a/script/build_helpers.py +++ b/script/build_helpers.py @@ -70,12 +70,15 @@ def populate_dependency_config( * ``domain.platform`` form (e.g. ``sensor.gpio``) appends ``{platform: }`` to ``config[domain]``, creating the list if needed. - * Bare components are looked up via ``get_component_fn``. Platform - components (``IS_PLATFORM_COMPONENT``) and ``MULTI_CONF`` components are - initialised as ``[]`` so the sibling ``domain.platform`` branch can - ``append`` into them. Everything else is populated by running the - component's schema with ``{}`` so defaults exist; if the schema requires - explicit input, an empty ``{}`` is used as a fallback. + * Bare components are looked up via ``get_component_fn``. Target-platform + components (``is_target_platform``, e.g. ``esp32``) are skipped entirely: + a host build targets ``host``, so a foreign target platform's sources are + guarded out and its schema must not run here (it would mutate global CORE + state as a side effect). Platform components (``IS_PLATFORM_COMPONENT``) + and ``MULTI_CONF`` components are initialised as ``[]`` so the sibling + ``domain.platform`` branch can ``append`` into them. Everything else is + populated by running the component's schema with ``{}`` so defaults exist; + if the schema requires explicit input, an empty ``{}`` is used as a fallback. Platform components must always be a list here even when no ``domain.platform`` entry follows, because the ``domain.platform`` branch @@ -96,6 +99,12 @@ def populate_dependency_config( component = get_component_fn(component_name) if component is None: continue + # Skip target platforms (e.g. esp32): a host build targets `host`, so a + # foreign target's sources are guarded out, and running its schema with + # {} leaks global CORE state (esp32 pins CORE.toolchain to ESP-IDF), + # crashing the host compile. See #17035. + if component.is_target_platform: + continue if component.multi_conf or component.is_platform_component: config.setdefault(component_name, []) elif component_name not in config: diff --git a/tests/script/test_build_helpers.py b/tests/script/test_build_helpers.py new file mode 100644 index 0000000000..efa6a75483 --- /dev/null +++ b/tests/script/test_build_helpers.py @@ -0,0 +1,76 @@ +"""Unit tests for script/build_helpers.py.""" + +from pathlib import Path +import sys + +import pytest + +# Add the script directory to the path so we can import build_helpers. +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "script")) + +import build_helpers # noqa: E402 + +from esphome.core import CORE # noqa: E402 + + +class _FakeComponent: + def __init__(self, config_schema, *, is_target_platform=False): + self.multi_conf = False + self.is_platform_component = False + self.is_target_platform = is_target_platform + self.config_schema = config_schema + + +@pytest.fixture(autouse=True) +def _restore_core_toolchain(): + """Keep CORE.toolchain changes from leaking between tests.""" + saved = CORE.toolchain + try: + yield + finally: + CORE.toolchain = saved + + +def test_populate_dependency_config_skips_target_platforms() -> None: + """Target-platform deps must be skipped, not config-populated, in a host build. + + Regression test for #17035: esp32 (a target platform) appears only as a + transitive dependency of a host C++ unit test. Running its schema with {} + set ``CORE.toolchain = ESP_IDF`` as a side effect before failing validation, + which crashed the host compile with KeyError('esp32'). The fix skips + target-platform components entirely so their schema never runs. + """ + CORE.toolchain = None # the state a host build starts from + schema_calls = [] + + def leaky_schema(value): + # If this ever runs for a target platform, the bug is back. + schema_calls.append(value) + CORE.toolchain = "esp-idf-leak" + raise ValueError("no board or variant") + + config: dict = {} + build_helpers.populate_dependency_config( + config, + ["esp32"], + get_component_fn=lambda name: _FakeComponent( + leaky_schema, is_target_platform=True + ), + register_platform_fn=lambda domain: None, + ) + + assert "esp32" not in config # skipped: no synthesized entry + assert schema_calls == [] # schema never run + assert CORE.toolchain is None # no global side effect leaked + + +def test_populate_dependency_config_populates_defaults() -> None: + """A non-target-platform dep still has its schema defaults harvested.""" + config: dict = {} + build_helpers.populate_dependency_config( + config, + ["ok"], + get_component_fn=lambda name: _FakeComponent(lambda value: {"default": 1}), + register_platform_fn=lambda domain: None, + ) + assert config["ok"] == {"default": 1} diff --git a/tests/script/test_test_helpers.py b/tests/script/test_test_helpers.py index a8100252da..4b05cab376 100644 --- a/tests/script/test_test_helpers.py +++ b/tests/script/test_test_helpers.py @@ -266,11 +266,13 @@ def _make_component_stub( *, multi_conf: bool = False, is_platform_component: bool = False, + is_target_platform: bool = False, config_schema=None, ) -> MagicMock: stub = MagicMock() stub.multi_conf = multi_conf stub.is_platform_component = is_platform_component + stub.is_target_platform = is_target_platform stub.config_schema = config_schema return stub From a84ad7b1f8533138cb8552c3e6cd600875cb2607 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:43:17 -0400 Subject: [PATCH 0475/1815] [uptime] Revert timestamp sensor device_class to timestamp (#17037) --- esphome/components/uptime/sensor/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/uptime/sensor/__init__.py b/esphome/components/uptime/sensor/__init__.py index 6ce0795cdb..e2a7aee1a2 100644 --- a/esphome/components/uptime/sensor/__init__.py +++ b/esphome/components/uptime/sensor/__init__.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_TIME_ID, DEVICE_CLASS_DURATION, - DEVICE_CLASS_UPTIME, + DEVICE_CLASS_TIMESTAMP, ENTITY_CATEGORY_DIAGNOSTIC, ICON_TIMER, STATE_CLASS_TOTAL_INCREASING, @@ -33,8 +33,9 @@ CONFIG_SCHEMA = cv.typed_schema( ).extend(cv.polling_component_schema("60s")), "timestamp": sensor.sensor_schema( UptimeTimestampSensor, + icon=ICON_TIMER, accuracy_decimals=0, - device_class=DEVICE_CLASS_UPTIME, + device_class=DEVICE_CLASS_TIMESTAMP, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ) .extend( From 129aebe8f42277772d4b7280108cb3fcf2a74be9 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:56:21 -0400 Subject: [PATCH 0476/1815] [esp32] Support `esphome idedata` with the native ESP-IDF toolchain (#17040) --- esphome/__main__.py | 15 ++++++++++++ esphome/espidf/toolchain.py | 1 + tests/unit_tests/test_espidf_toolchain.py | 28 +++++++++++++++++++---- tests/unit_tests/test_main.py | 26 +++++++++++++++++++++ 4 files changed, 66 insertions(+), 4 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 27dd878495..bda3dcbd05 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1771,6 +1771,21 @@ def command_update_all(args: ArgsProtocol) -> int | None: def command_idedata(args: ArgsProtocol, config: ConfigType) -> int: import json + if CORE.using_toolchain_esp_idf: + # Native ESP-IDF derives idedata from the build's compile_commands.json, + # so the configuration must already be compiled. + from esphome.espidf import toolchain as espidf_toolchain + + idedata = espidf_toolchain.get_idedata() + if idedata is None: + _LOGGER.error( + "No idedata available; compile the configuration first", + ) + return 1 + + print(json.dumps(idedata, indent=2) + "\n") + return 0 + if not CORE.using_toolchain_platformio: _LOGGER.error( "The idedata command is not compatible with %s toolchain", diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index c622a2dd36..000ce739db 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -472,6 +472,7 @@ def get_idedata() -> dict | None: pass data = idedata_from_build(compile_commands) + data["prog_path"] = str(get_elf_path()) cache.parent.mkdir(parents=True, exist_ok=True) cache.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") return data diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index b2309439f9..017d8c49b4 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -89,8 +89,9 @@ def test_get_idedata_generates_and_caches(setup_core: Path) -> None: result = toolchain.get_idedata() mock_transform.assert_called_once() - assert result == {"cxx_path": "g++"} - assert json.loads(cache.read_text()) == {"cxx_path": "g++"} + prog_path = str(toolchain.get_elf_path()) + assert result == {"cxx_path": "g++", "prog_path": prog_path} + assert json.loads(cache.read_text()) == {"cxx_path": "g++", "prog_path": prog_path} def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None: @@ -127,7 +128,7 @@ def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) - result = toolchain.get_idedata() mock_transform.assert_called_once() - assert result == {"cxx_path": "fresh"} + assert result == {"cxx_path": "fresh", "prog_path": str(toolchain.get_elf_path())} def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None: @@ -147,7 +148,26 @@ def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None: result = toolchain.get_idedata() mock_transform.assert_called_once() - assert result == {"cxx_path": "regen"} + assert result == {"cxx_path": "regen", "prog_path": str(toolchain.get_elf_path())} + + +def test_get_idedata_prog_path_points_at_firmware_elf(setup_core: Path) -> None: + """The idedata exposes prog_path (the ELF) so consumers like build-action + can locate firmware.factory.bin / firmware.ota.bin as its siblings.""" + compile_commands, _ = _setup_build(setup_core) + compile_commands.parent.mkdir(parents=True, exist_ok=True) + compile_commands.write_text("[]") + + with patch( + "esphome.espidf.idedata.idedata_from_build", + return_value={"cxx_path": "g++"}, + ): + result = toolchain.get_idedata() + + # Use Path semantics so the contract holds on Windows too (backslashes). + prog_path = Path(result["prog_path"]) + assert prog_path.name == "firmware.elf" + assert prog_path.parent.name == "build" def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index e44f746a75..acd39cedc6 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -32,6 +32,7 @@ from esphome.__main__ import ( command_clean_all, command_config, command_config_hash, + command_idedata, command_rename, command_run, command_update_all, @@ -6257,3 +6258,28 @@ def test_command_run_defaults_subscribe_states_true( mock_run_logs.assert_called_once_with( CORE.config, ["192.168.1.100"], subscribe_states=True ) + + +def test_command_idedata_esp_idf_prints_json(capsys: CaptureFixture) -> None: + """Under the native ESP-IDF toolchain, idedata is emitted as JSON.""" + setup_core() + CORE.toolchain = Toolchain.ESP_IDF + data = {"cxx_path": "g++", "prog_path": "/build/firmware.elf"} + + with patch("esphome.espidf.toolchain.get_idedata", return_value=data) as mock_get: + result = command_idedata(MagicMock(), CORE.config) + + assert result == 0 + mock_get.assert_called_once_with() + assert json.loads(capsys.readouterr().out) == data + + +def test_command_idedata_esp_idf_no_build_errors() -> None: + """Under ESP-IDF, a missing build (no idedata) returns an error, not a crash.""" + setup_core() + CORE.toolchain = Toolchain.ESP_IDF + + with patch("esphome.espidf.toolchain.get_idedata", return_value=None): + result = command_idedata(MagicMock(), CORE.config) + + assert result == 1 From d27229a1c75774dd2ae279ae0dd5f43dd3634562 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:15:38 -0400 Subject: [PATCH 0477/1815] [esp32] Don't overwrite PlatformIO's factory.bin (#17042) --- esphome/components/esp32/post_build.py.script | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32/post_build.py.script b/esphome/components/esp32/post_build.py.script index b329f6b82b..f1a38f9e76 100644 --- a/esphome/components/esp32/post_build.py.script +++ b/esphome/components/esp32/post_build.py.script @@ -224,6 +224,17 @@ def merge_factory_bin(source, target, env): flash_size = env.BoardConfig().get("upload.flash_size", "4MB") chip = env.BoardConfig().get("build.mcu", "esp32") + # PlatformIO's esp-idf builder already creates a correct firmware.factory.bin (right + # artifact names and partition offsets, including custom partition tables). The merge + # below is only a fallback and cannot honor custom layouts, so don't overwrite an image + # PlatformIO already produced. Post-build actions only run when firmware.bin is rebuilt, + # and PlatformIO's combined-image builder runs before us in that batch, so an existing + # file here is current. + output_path = firmware_path.with_suffix(".factory.bin") + if output_path.exists(): + print(f"{output_path.name} already created by PlatformIO - skipping merge") + return + sections = [] flasher_args_path = build_dir / "flasher_args.json" @@ -291,7 +302,6 @@ def merge_factory_bin(source, target, env): print("No valid flash sections found — skipping .factory.bin creation.") return - output_path = firmware_path.with_suffix(".factory.bin") python_exe = f'"{env.subst("$PYTHONEXE")}"' cmd = [ python_exe, From 20cd6a1771794f1f20f6d9e39c2e4d6e45b04c07 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Jun 2026 16:16:14 -0500 Subject: [PATCH 0478/1815] [logger] Hold recursion guard while draining the task log buffer (#17044) --- esphome/components/logger/logger.cpp | 4 + .../logger_buffered_recursion_guard.yaml | 61 +++++++++ .../test_logger_buffered_recursion_guard.py | 119 ++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 tests/integration/fixtures/logger_buffered_recursion_guard.yaml create mode 100644 tests/integration/test_logger_buffered_recursion_guard.py diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index a035525101..684da0202e 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -175,6 +175,10 @@ void Logger::process_messages_() { #ifdef USE_ESPHOME_TASK_LOG_BUFFER // Process any buffered messages when available if (this->log_buffer_.has_messages()) { + // Prevent main-task logs emitted by listener callbacks (e.g. the API send path) from re-entering + // and corrupting the shared tx_buffer_ / API shared_write_buffer_ while we are draining here. + // Mirrors the guard held by log_message_to_buffer_and_send_ on the synchronous logging path. + RecursionGuard guard(this->main_task_recursion_guard_); logger::TaskLogBuffer::LogMessage *message; uint16_t text_length; while (this->log_buffer_.borrow_message_main_loop(message, text_length)) { diff --git a/tests/integration/fixtures/logger_buffered_recursion_guard.yaml b/tests/integration/fixtures/logger_buffered_recursion_guard.yaml new file mode 100644 index 0000000000..058adbff99 --- /dev/null +++ b/tests/integration/fixtures/logger_buffered_recursion_guard.yaml @@ -0,0 +1,61 @@ +esphome: + name: logger-recursion-test +host: +api: +logger: + level: DEBUG + on_message: + # Fires on the main loop for every message delivered to listeners, including + # messages drained from the task log buffer (i.e. logged from a non-main thread). + # The lambda logs again on the main task. Without a recursion guard on the buffered + # drain path this re-entrant log reuses the shared tx_buffer_ and clobbers the + # buffered message that is still being delivered, corrupting its console output. + - level: VERY_VERBOSE + then: + - lambda: |- + ESP_LOGD("reentry", "REENTRANT_CLOBBER_MARKER"); + +button: + - platform: template + name: "Start Race Test" + id: start_test_button + on_press: + - lambda: |- + // Keep the count well under the host task-log-buffer slot count so every + // message goes through the ring buffer (buffered drain path) instead of the + // emergency console fallback. The main loop is blocked in pthread_join while + // the thread logs, so all messages are drained together once it returns. + static const int NUM_MESSAGES = 30; + + struct ThreadTest { + static void *thread_func(void *arg) { + char thread_name[16]; + snprintf(thread_name, sizeof(thread_name), "LogThread"); + #ifdef __APPLE__ + pthread_setname_np(thread_name); + #else + pthread_setname_np(pthread_self(), thread_name); + #endif + + for (int i = 0; i < NUM_MESSAGES; i++) { + // Verifiable payload: data is a deterministic function of the message + // index, so a clobbered buffer shows up as a missing or mismatched line. + ESP_LOGD("thread_test", "THREADMSG%03d_DATA_%08X", i, i * 12345); + } + return nullptr; + } + }; + + // RACE_TEST_START / RACE_TEST_COMPLETE are logged from the main task (the + // synchronous path, which already holds the recursion guard) so the test can + // always detect completion even when the buffered path is corrupted. + ESP_LOGI("thread_test", "RACE_TEST_START: logging %d messages from a thread", NUM_MESSAGES); + + pthread_t thread; + if (pthread_create(&thread, nullptr, ThreadTest::thread_func, nullptr) != 0) { + ESP_LOGE("thread_test", "RACE_TEST_ERROR: Failed to create thread"); + return; + } + pthread_join(thread, nullptr); + + ESP_LOGI("thread_test", "RACE_TEST_COMPLETE: thread finished, expected %d messages", NUM_MESSAGES); diff --git a/tests/integration/test_logger_buffered_recursion_guard.py b/tests/integration/test_logger_buffered_recursion_guard.py new file mode 100644 index 0000000000..5bef915b28 --- /dev/null +++ b/tests/integration/test_logger_buffered_recursion_guard.py @@ -0,0 +1,119 @@ +"""Integration test for the recursion guard on the buffered logger drain path. + +Regression test for a crash where a log message drained from the task log buffer +(i.e. logged from a non-main thread) re-entered the logger on the main task while it +was still being delivered to listeners. The buffered drain in +``Logger::process_messages_`` did not hold the main-task recursion guard that the +synchronous logging path holds, so a listener callback that logged again on the main +task (e.g. the API log-forwarding path, or a ``logger.on_message`` automation) reused +the shared ``tx_buffer_`` and clobbered the message mid-delivery. On ESP32 this showed +up as a ``StoreProhibited`` panic inside the API send path. + +The fixture logs a small batch of verifiable messages from a non-main thread (kept +under the host task-log-buffer slot count so they all take the buffered drain path +rather than the emergency console fallback) while an ``on_message`` automation re-logs +``REENTRANT_CLOBBER_MARKER`` on the main task for every delivered message. + +Without the guard the re-entrant marker is written into the shared ``tx_buffer_`` while +the buffered thread message is still being delivered, so the message the API receives is +contaminated (it contains the marker and an embedded newline glued onto the thread +payload). With the guard the re-entrant log is dropped during the drain, the marker +never appears, and every thread message is delivered clean. +""" + +from __future__ import annotations + +import asyncio +import re + +from aioesphomeapi import LogLevel +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +_ANSI = re.compile(r"\x1b\[[0-9;]*m") +# THREADMSGnnn_DATA_xxxxxxxx where data is a deterministic checksum of the index +THREAD_MSG_PATTERN = re.compile(r"THREADMSG(\d{3})_DATA_([0-9A-F]{8})") + +NUM_MESSAGES = 30 + + +@pytest.mark.asyncio +async def test_logger_buffered_recursion_guard( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Buffered (non-main-thread) log messages survive a re-entrant main-task log.""" + api_messages: list[str] = [] + all_drained = asyncio.Event() + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "logger-recursion-test" + + # Subscribe over the API: this is the exact path that crashed in the field + # (the API log callback runs during the buffered drain). The API message field + # preserves embedded newlines, so it reliably exposes a clobbered buffer. + # + # Every buffered thread message is delivered here whether it survives intact or + # gets clobbered (a clobbered message still carries its THREADMSG payload), so + # counting THREADMSG occurrences is a deterministic "drain complete" signal: no + # arbitrary sleep, no dependence on the fix being present. + def on_log(msg) -> None: + text = msg.message.decode("utf-8", errors="replace") + api_messages.append(text) + received = sum(len(THREAD_MSG_PATTERN.findall(m)) for m in api_messages) + if received >= NUM_MESSAGES: + all_drained.set() + + client.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERY_VERBOSE) + + entities, _ = await client.list_entities_services() + buttons = [e for e in entities if e.name == "Start Race Test"] + assert buttons, "Could not find Start Race Test button" + client.button_command(buttons[0].key) + + # Wait until every buffered thread message has been delivered over the API. + try: + await asyncio.wait_for(all_drained.wait(), timeout=30.0) + except TimeoutError: + received = sum(len(THREAD_MSG_PATTERN.findall(m)) for m in api_messages) + pytest.fail( + f"Only {received}/{NUM_MESSAGES} thread messages arrived before timeout; " + "device likely crashed or hung." + ) + + intact: set[int] = set() + contaminated: list[str] = [] + for raw in api_messages: + text = _ANSI.sub("", raw) + if "THREADMSG" not in text: + continue + # A clean thread message is a single line carrying only its own payload. A + # clobbered buffer glues the re-entrant marker (and an embedded newline) onto it. + if "REENTRANT" in text or "\n" in text: + contaminated.append(repr(raw)) + continue + match = THREAD_MSG_PATTERN.search(text) + assert match, f"Unexpected thread message format: {raw!r}" + msg_num = int(match.group(1)) + expected = f"{msg_num * 12345:08X}" + if match.group(2) != expected: + contaminated.append(repr(raw)) + continue + intact.add(msg_num) + + assert not contaminated, ( + "Buffered thread messages were clobbered by a re-entrant main-task log " + "(missing recursion guard on the buffered drain path):\n" + + "\n".join(contaminated[:10]) + ) + assert len(intact) == NUM_MESSAGES, ( + f"Expected {NUM_MESSAGES} intact buffered thread messages over the API, got " + f"{len(intact)}. Missing ids: {sorted(set(range(NUM_MESSAGES)) - intact)}" + ) From e3d68deef904c11683b3d32eee9fe67c4fb7e920 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:05:20 +1200 Subject: [PATCH 0479/1815] Bump bundled esphome-device-builder to 1.0.10 (#17051) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 18a9903735..221121c8d3 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.9 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.10 RUN \ platformio settings set enable_telemetry No \ From 1b1c8d767d29674a4245d9c13af3df0f6df8a3b0 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:06:13 +1200 Subject: [PATCH 0480/1815] Bump version to 2026.6.1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 56879237d4..d8c6bdbcdc 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.6.0 +PROJECT_NUMBER = 2026.6.1 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index c045e452f7..0bcf60a510 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.6.0" +__version__ = "2026.6.1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 1dbd9af6179bcae2c758758c5d7d70b2965ee06d Mon Sep 17 00:00:00 2001 From: Big Mike Date: Fri, 19 Jun 2026 00:04:11 -0500 Subject: [PATCH 0481/1815] [sen6x] Remove codeowner (#17056) --- CODEOWNERS | 2 +- esphome/components/sen6x/sensor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 3265627c03..d425614582 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -445,7 +445,7 @@ esphome/components/select/* @esphome/core esphome/components/sen0321/* @notjj esphome/components/sen21231/* @shreyaskarnik esphome/components/sen5x/* @martgras -esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct +esphome/components/sen6x/* @martgras @mebner86 @tuct esphome/components/sendspin/* @kahrendt esphome/components/sendspin/media_player/* @kahrendt esphome/components/sendspin/media_source/* @kahrendt diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index 19c0cb500e..5eb34add65 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -32,7 +32,7 @@ from esphome.const import ( UNIT_PERCENT, ) -CODEOWNERS = ["@martgras", "@mebner86", "@mikelawrence", "@tuct"] +CODEOWNERS = ["@martgras", "@mebner86", "@tuct"] DEPENDENCIES = ["i2c"] AUTO_LOAD = ["sensirion_common"] From 6a79dfb5c5a954cf3d0ba1da2cfd3351e165d3f0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:07:32 +1200 Subject: [PATCH 0482/1815] Bump ruff from 0.15.17 to 0.15.18 (#17046) Signed-off-by: dependabot[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index fc9681921a..b0e917566e 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.6 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.17 # also change in .pre-commit-config.yaml when updating +ruff==0.15.18 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From 4ae6dc355f1e525f0ebe1e2cd8d2965588ec4b3e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Jun 2026 00:08:07 -0500 Subject: [PATCH 0483/1815] [select] Remove deprecated state member (#17027) --- esphome/components/select/select.cpp | 4 ---- esphome/components/select/select.h | 7 ------- .../fixtures/multi_device_preferences.yaml | 12 ++++++------ 3 files changed, 6 insertions(+), 17 deletions(-) diff --git a/esphome/components/select/select.cpp b/esphome/components/select/select.cpp index 7c3dab15ad..17c6c811dd 100644 --- a/esphome/components/select/select.cpp +++ b/esphome/components/select/select.cpp @@ -27,10 +27,6 @@ void Select::publish_state(size_t index) { const char *option = this->option_at(index); this->set_has_state(true); this->active_index_ = index; -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - this->state = option; // Update deprecated member for backward compatibility -#pragma GCC diagnostic pop ESP_LOGV(TAG, "'%s' >> %s (%zu)", this->get_name().c_str(), option, index); this->state_callback_.call(index); #if defined(USE_SELECT) && defined(USE_CONTROLLER_REGISTRY) diff --git a/esphome/components/select/select.h b/esphome/components/select/select.h index 465283d92a..34d9248523 100644 --- a/esphome/components/select/select.h +++ b/esphome/components/select/select.h @@ -30,15 +30,8 @@ class Select : public EntityBase { public: SelectTraits traits; -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - /// @deprecated Use current_option() instead. This member will be removed in ESPHome 2026.7.0. - ESPDEPRECATED("Use current_option() instead of .state. Will be removed in 2026.7.0", "2026.1.0") - std::string state{}; - Select() = default; ~Select() = default; -#pragma GCC diagnostic pop void publish_state(const std::string &state); void publish_state(const char *state); diff --git a/tests/integration/fixtures/multi_device_preferences.yaml b/tests/integration/fixtures/multi_device_preferences.yaml index 634d7157b2..01e4394559 100644 --- a/tests/integration/fixtures/multi_device_preferences.yaml +++ b/tests/integration/fixtures/multi_device_preferences.yaml @@ -109,7 +109,7 @@ select: set_action: - lambda: |- ESP_LOGI("test", "Device A Mode set to %s", x.c_str()); - id(mode_device_a).state = x; + id(mode_device_a).publish_state(x); - platform: template name: Mode @@ -124,7 +124,7 @@ select: set_action: - lambda: |- ESP_LOGI("test", "Device B Mode set to %s", x.c_str()); - id(mode_device_b).state = x; + id(mode_device_b).publish_state(x); - platform: template name: Mode @@ -138,7 +138,7 @@ select: set_action: - lambda: |- ESP_LOGI("test", "Main Mode set to %s", x.c_str()); - id(mode_main).state = x; + id(mode_main).publish_state(x); # Button to trigger preference logging test button: @@ -153,9 +153,9 @@ button: ESP_LOGI("test", "Device A Setpoint: %.1f", id(setpoint_device_a).state); ESP_LOGI("test", "Device B Setpoint: %.1f", id(setpoint_device_b).state); ESP_LOGI("test", "Main Setpoint: %.1f", id(setpoint_main).state); - ESP_LOGI("test", "Device A Mode: %s", id(mode_device_a).state.c_str()); - ESP_LOGI("test", "Device B Mode: %s", id(mode_device_b).state.c_str()); - ESP_LOGI("test", "Main Mode: %s", id(mode_main).state.c_str()); + ESP_LOGI("test", "Device A Mode: %s", id(mode_device_a).current_option().c_str()); + ESP_LOGI("test", "Device B Mode: %s", id(mode_device_b).current_option().c_str()); + ESP_LOGI("test", "Main Mode: %s", id(mode_main).current_option().c_str()); // Log preference hashes for entities that actually store preferences ESP_LOGI("test", "Device A Switch Pref Hash: %u", id(light_device_a).get_preference_hash()); ESP_LOGI("test", "Device B Switch Pref Hash: %u", id(light_device_b).get_preference_hash()); From 350e7bb7638b0e8551d1e5f1244bbe62e952cb77 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:17:06 -0400 Subject: [PATCH 0484/1815] [espidf] Resolve IDF tools path to avoid unnormalized path warning (#17055) --- esphome/espidf/framework.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index c0e9a0051f..6f4aeef9f0 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -81,8 +81,13 @@ def _get_idf_tools_path() -> Path: Path object pointing to the ESP-IDF tools directory """ if "ESPHOME_ESP_IDF_PREFIX" in os.environ: - return Path(get_str_env("ESPHOME_ESP_IDF_PREFIX", None)).expanduser() - return CORE.data_dir / "idf" + path = Path(get_str_env("ESPHOME_ESP_IDF_PREFIX", None)).expanduser() + else: + path = CORE.data_dir / "idf" + # Resolve so an unnormalized config path (e.g. compiling ``../config/x.yaml``) + # doesn't leave ``..`` segments in the IDF_TOOLS_PATH handed to idf.py, which + # otherwise warns that the venv interpreter path doesn't match the install. + return path.resolve() # Windows' default MAX_PATH is 260 characters. ESP-IDF toolchains nest deeply From 50994704a39397db0672f0fb16cdaa5fd4366c7c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:19:17 -0400 Subject: [PATCH 0485/1815] [fastled_base] Fix RMT5 intr_priority conflict (#17072) --- esphome/components/fastled_base/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/fastled_base/__init__.py b/esphome/components/fastled_base/__init__.py index d99dffdc08..a26a235da7 100644 --- a/esphome/components/fastled_base/__init__.py +++ b/esphome/components/fastled_base/__init__.py @@ -50,6 +50,11 @@ async def new_fastled_light(config): ref="d44c800a9e876a8394caefc2ce4915dd96dac77b", ) cg.add_library("SPI", None) + # FastLED's RMT5 driver hard-codes intr_priority=3, which conflicts with + # esphome's RMT channels (remote_transmitter etc., priority 0): the IDF + # driver rejects FastLED's channel and show() then hangs ~3s with no + # output. Override to 0 so it shares the interrupt. See #17063. + cg.add_build_flag("-DFL_RMT5_INTERRUPT_LEVEL=0") else: cg.add_library("fastled/FastLED", "3.9.16") await light.register_light(var, config) From f57d31374e1cc88bdd5f16e16fface70ef9184fb Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:19:30 -0400 Subject: [PATCH 0486/1815] [packet_transport] Mark encryption key as cv.sensitive (#17066) --- esphome/components/packet_transport/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/packet_transport/__init__.py b/esphome/components/packet_transport/__init__.py index 0b166bb65c..4293dffb15 100644 --- a/esphome/components/packet_transport/__init__.py +++ b/esphome/components/packet_transport/__init__.py @@ -69,7 +69,7 @@ ENCRYPTION_SCHEMA = { cv.Optional(CONF_ENCRYPTION): cv.maybe_simple_value( cv.Schema( { - cv.Required(CONF_KEY): cv.string, + cv.Required(CONF_KEY): cv.sensitive(cv.string), } ), key=CONF_KEY, From db6bd36cf90eccbec52cba89feddecb6178ca14f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:20:17 -0400 Subject: [PATCH 0487/1815] Bump py7zr from 1.1.0 to 1.1.3 (#17071) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index efb5ec8723..717f3b7e21 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,7 @@ jinja2==3.1.6 bleak==2.1.1 smpclient==6.0.0 requests==2.34.2 -py7zr==1.1.0 +py7zr==1.1.3 # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 From d8bd80ef3888b9c30b0a5690c2b6c4ec018bb71a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:20:28 -0400 Subject: [PATCH 0488/1815] Bump resvg-py from 0.3.2 to 0.3.3 (#17070) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 717f3b7e21..06a383b00a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 pillow==12.2.0 -resvg-py==0.3.2 +resvg-py==0.3.3 freetype-py==2.5.1 jinja2==3.1.6 bleak==2.1.1 From 3fb250133fe12af47c1b9d8d7b6e4e92363d4e0d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:20:37 -0400 Subject: [PATCH 0489/1815] Bump pytest from 9.1.0 to 9.1.1 (#17069) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index b0e917566e..4e498abc21 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -5,7 +5,7 @@ pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit # Unit tests -pytest==9.1.0 +pytest==9.1.1 pytest-cov==7.1.0 pytest-mock==3.15.1 pytest-asyncio==1.4.0 From 657d9bf4d094e13b7c7e5d3c7ff67566dfa53a8b Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 00:30:30 -0500 Subject: [PATCH 0490/1815] Bump bundled esphome-device-builder to 1.0.11 (#17081) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 221121c8d3..aa0406320c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.10 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.11 RUN \ platformio settings set enable_telemetry No \ From d77c0d2bc544a9c94e4e317ce485c59081f48b5c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 20 Jun 2026 11:33:54 -0500 Subject: [PATCH 0491/1815] [ha-addon] Expose the device-builder public port only when port 6052 is mapped (#17076) --- .../etc/s6-overlay/s6-rc.d/esphome/run | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run index a61f237a5a..d4628ffa83 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run @@ -49,7 +49,21 @@ if bashio::fs.directory_exists '/config/esphome/.esphome'; then rm -rf /config/esphome/.esphome fi +# Only signal device-builder to expose the public LAN port when the operator +# mapped port 6052, matching the legacy dashboard where nginx listened on the +# fixed port 6052 only when it was configured. We use the mapping purely as a +# presence check and don't forward the published value; device-builder binds +# its default port 6052 (the fixed container port, as the legacy +# "listen 6052" did). --ha-addon-allow-public is inert on its own: the no-auth +# gate is the DISABLE_HA_AUTHENTICATION env var set above, so both opt-ins are +# required to bind 6052 unauthenticated; either alone stays ingress-only. +set -- +if bashio::var.has_value "$(bashio::addon.port 6052)"; then + set -- --ha-addon-allow-public +fi + bashio::log.info "Starting ESPHome Device Builder..." exec esphome-device-builder /config/esphome \ --ha-addon \ - --ingress-port "$(bashio::addon.ingress_port)" + --ingress-port "$(bashio::addon.ingress_port)" \ + "$@" From 59711b8e6a39bb5db7b8b79067298feb0a268a77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 20 Jun 2026 11:51:42 -0500 Subject: [PATCH 0492/1815] Add THREAT_MODEL.md (#17089) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- THREAT_MODEL.md | 104 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 THREAT_MODEL.md diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md new file mode 100644 index 0000000000..a4640467c9 --- /dev/null +++ b/THREAT_MODEL.md @@ -0,0 +1,104 @@ +# ESPHome Threat Model + +This document defines the trust boundary for the **ESPHome** repository — the +Python compiler/CLI and the device firmware it generates — so that real security +bugs can be told apart from defense-in-depth improvements. It gives contributors, +reviewers, and security researchers a clear answer to one question: +**does this issue let an _unauthenticated_ attacker do something they shouldn't?** + +Related documents: + +- Deployment guidance for operators: + https://esphome.io/guides/security_best_practices/ +- The **Device Builder dashboard** (the web UI, its authentication, ingress, + Origin/Host gates, and peer-link pairing) lives in a separate repository and + has its own threat model. If your report concerns any of that, please read and + report there instead: + https://github.com/esphome/device-builder/blob/main/docs/THREAT_MODEL.md + +## The trust boundary + +For this repository there are two trusted inputs by design: + +1. **The configuration.** Anyone who can supply or edit a YAML config is trusted + (see below). +2. **Authenticated peers of a running device** — clients holding the device's + API encryption key / password, OTA password, or web server credentials. + +The security boundary is therefore **unauthenticated network traffic vs. those +trusted inputs.** A bug that lets an unauthenticated attacker cross it is a +security bug. + +## Config authors are host-equivalent by design + +Anyone who can supply or edit a configuration is **trusted with full code +execution on the host that runs `esphome`**, on purpose. This is what the product +does, not a flaw. A config author can already, through fully supported features: + +- Run arbitrary **Python** at validation/compile time via `external_components:` + (and other component-import mechanisms) — ESPHome imports those packages as + ordinary Python. +- Run arbitrary **shell** commands through the compile/validate/flash toolchain + that ESPHome invokes as subprocesses. +- Read and write arbitrary files reachable by the process (e.g. via `!include`, + `packages:`, `dashboard_import:`, and generated build output). + +Because of this, a malicious config author is equivalent to shell access on the +host running the build. + +## What is *not* a security vulnerability + +If exploiting an issue requires the ability to supply or edit configuration, it +is **not** a vulnerability in ESPHome, because that ability already grants host +code execution. This explicitly includes, among others: + +- Template / expression injection in substitutions or any YAML string value + (e.g. Jinja `${...}` evaluation reaching Python internals). This grants no + capability a config author lacks. +- `!include` / `packages:` / `dashboard_import:` reading or fetching content + from surprising or remote locations. +- The validator or compiler crashing or behaving unexpectedly on adversarial + YAML. +- ESPHome running as root in the official container — that is the documented + deployment posture, reachable by the same caller through the features above. + +These do not warrant a CVE or coordinated disclosure. Hardening in these areas +(for example, sandboxing template evaluation as least-surprise defense-in-depth) +is welcome as a normal enhancement PR, framed as cleanliness rather than a +security fix — not as a vulnerability remediation. + +## What we do defend + +These *are* security bugs in this repo, and we want to hear about them privately: + +- Memory-safety or protocol bugs in the generated **device firmware** that are + remotely triggerable over the network (native API, web server, OTA, BLE, + captive portal, etc.) **without** valid credentials. +- Authentication or encryption bypass on the device — reaching API calls, OTA + updates, or the web server without the configured key/password. +- Flaws that weaken the device's API encryption (Noise), OTA, or web server auth + below their documented guarantees. + +## Explicitly out of scope + +- Local attackers who already have shell access on the host that runs `esphome`. +- Supply-chain attacks against ESPHome or its dependencies. +- Operator-supplied hostile YAML (covered above — config authoring is trusted). +- Attacks that require an already-authenticated device peer (someone who already + holds the API key / OTA / web credentials). +- Anything in the dashboard / device-builder — report that in its own repository + (linked at the top). +- The legacy bundled dashboard in this repo (`esphome/dashboard/`) — it is + deprecated and being replaced by Device Builder; report dashboard issues there. +- Deployments where the operator removed protections or exposed credentials. See + the security best practices guide: + https://esphome.io/guides/security_best_practices/ + +## Reporting a vulnerability + +If you believe you've found an issue that crosses the unauthenticated boundary +above, please report it privately via GitHub Security Advisories rather than a +public issue. For issues that require config-write access, please review this +document first — they are very likely out of scope by design. For dashboard / +device-builder issues, report against that repository and consult its threat +model (linked at the top). From 9609d370c09a1bdcdedffe7061625455e23fbf2a Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:22:19 -0500 Subject: [PATCH 0493/1815] Bump bundled esphome-device-builder to 1.0.12 (#17091) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index aa0406320c..1d39644ab8 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.11 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.12 RUN \ platformio settings set enable_telemetry No \ From 8d77051b9a2d80e07cade2971f95050892ee5339 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:17:06 -0400 Subject: [PATCH 0494/1815] [espidf] Resolve IDF tools path to avoid unnormalized path warning (#17055) --- esphome/espidf/framework.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index c0e9a0051f..6f4aeef9f0 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -81,8 +81,13 @@ def _get_idf_tools_path() -> Path: Path object pointing to the ESP-IDF tools directory """ if "ESPHOME_ESP_IDF_PREFIX" in os.environ: - return Path(get_str_env("ESPHOME_ESP_IDF_PREFIX", None)).expanduser() - return CORE.data_dir / "idf" + path = Path(get_str_env("ESPHOME_ESP_IDF_PREFIX", None)).expanduser() + else: + path = CORE.data_dir / "idf" + # Resolve so an unnormalized config path (e.g. compiling ``../config/x.yaml``) + # doesn't leave ``..`` segments in the IDF_TOOLS_PATH handed to idf.py, which + # otherwise warns that the venv interpreter path doesn't match the install. + return path.resolve() # Windows' default MAX_PATH is 260 characters. ESP-IDF toolchains nest deeply From fe794a26e845c33a0e9ae86e8c7363fd78cca09e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:19:17 -0400 Subject: [PATCH 0495/1815] [fastled_base] Fix RMT5 intr_priority conflict (#17072) --- esphome/components/fastled_base/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/fastled_base/__init__.py b/esphome/components/fastled_base/__init__.py index d99dffdc08..a26a235da7 100644 --- a/esphome/components/fastled_base/__init__.py +++ b/esphome/components/fastled_base/__init__.py @@ -50,6 +50,11 @@ async def new_fastled_light(config): ref="d44c800a9e876a8394caefc2ce4915dd96dac77b", ) cg.add_library("SPI", None) + # FastLED's RMT5 driver hard-codes intr_priority=3, which conflicts with + # esphome's RMT channels (remote_transmitter etc., priority 0): the IDF + # driver rejects FastLED's channel and show() then hangs ~3s with no + # output. Override to 0 so it shares the interrupt. See #17063. + cg.add_build_flag("-DFL_RMT5_INTERRUPT_LEVEL=0") else: cg.add_library("fastled/FastLED", "3.9.16") await light.register_light(var, config) From f5697b0ae574104bd69ec4fc8da5919c596918c1 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:19:30 -0400 Subject: [PATCH 0496/1815] [packet_transport] Mark encryption key as cv.sensitive (#17066) --- esphome/components/packet_transport/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/packet_transport/__init__.py b/esphome/components/packet_transport/__init__.py index 0b166bb65c..4293dffb15 100644 --- a/esphome/components/packet_transport/__init__.py +++ b/esphome/components/packet_transport/__init__.py @@ -69,7 +69,7 @@ ENCRYPTION_SCHEMA = { cv.Optional(CONF_ENCRYPTION): cv.maybe_simple_value( cv.Schema( { - cv.Required(CONF_KEY): cv.string, + cv.Required(CONF_KEY): cv.sensitive(cv.string), } ), key=CONF_KEY, From 2354165e41e02205e465b17364f523ecd513b8f3 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 00:30:30 -0500 Subject: [PATCH 0497/1815] Bump bundled esphome-device-builder to 1.0.11 (#17081) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 221121c8d3..aa0406320c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.10 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.11 RUN \ platformio settings set enable_telemetry No \ From 039a1f063e1e3754f8199c2bae6f9b2ed92ad06f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 20 Jun 2026 11:33:54 -0500 Subject: [PATCH 0498/1815] [ha-addon] Expose the device-builder public port only when port 6052 is mapped (#17076) --- .../etc/s6-overlay/s6-rc.d/esphome/run | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run index a61f237a5a..d4628ffa83 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run @@ -49,7 +49,21 @@ if bashio::fs.directory_exists '/config/esphome/.esphome'; then rm -rf /config/esphome/.esphome fi +# Only signal device-builder to expose the public LAN port when the operator +# mapped port 6052, matching the legacy dashboard where nginx listened on the +# fixed port 6052 only when it was configured. We use the mapping purely as a +# presence check and don't forward the published value; device-builder binds +# its default port 6052 (the fixed container port, as the legacy +# "listen 6052" did). --ha-addon-allow-public is inert on its own: the no-auth +# gate is the DISABLE_HA_AUTHENTICATION env var set above, so both opt-ins are +# required to bind 6052 unauthenticated; either alone stays ingress-only. +set -- +if bashio::var.has_value "$(bashio::addon.port 6052)"; then + set -- --ha-addon-allow-public +fi + bashio::log.info "Starting ESPHome Device Builder..." exec esphome-device-builder /config/esphome \ --ha-addon \ - --ingress-port "$(bashio::addon.ingress_port)" + --ingress-port "$(bashio::addon.ingress_port)" \ + "$@" From b079be756f5d92076fd20bdb5ac53283e67503e2 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:22:19 -0500 Subject: [PATCH 0499/1815] Bump bundled esphome-device-builder to 1.0.12 (#17091) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index aa0406320c..1d39644ab8 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.11 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.12 RUN \ platformio settings set enable_telemetry No \ From 99d1c4eb694e600914b17321a165516101e542b1 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:33:41 -0400 Subject: [PATCH 0500/1815] Bump version to 2026.6.2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index d8c6bdbcdc..ea36d45fee 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.6.1 +PROJECT_NUMBER = 2026.6.2 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 0bcf60a510..7cc9b604d9 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.6.1" +__version__ = "2026.6.2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 63d8a344c564d3ba67b802ee913c546d3de8214a Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 21 Jun 2026 11:32:35 -0700 Subject: [PATCH 0501/1815] [modbus] Fix parsing & split out server mode (#11969) --- esphome/components/modbus/__init__.py | 86 ++- esphome/components/modbus/modbus.cpp | 729 +++++++++++------- esphome/components/modbus/modbus.h | 244 ++++-- .../components/modbus/modbus_definitions.h | 26 +- esphome/components/modbus/modbus_helpers.cpp | 177 ++++- esphome/components/modbus/modbus_helpers.h | 98 ++- .../modbus_controller/modbus_controller.cpp | 2 +- .../modbus_controller/modbus_controller.h | 2 +- esphome/components/modbus_server/__init__.py | 9 +- .../modbus_server/modbus_server.cpp | 37 +- .../components/modbus_server/modbus_server.h | 24 +- .../components/modbus/modbus_helpers_test.cpp | 175 +++++ tests/components/modbus/modbus_test.cpp | 59 -- .../fixtures/uart_mock_modbus.yaml | 16 +- .../uart_mock_modbus_no_threshold.yaml | 11 +- .../uart_mock_modbus_server_controller.yaml | 6 +- ...ock_modbus_server_controller_multiple.yaml | 5 +- ...t_mock_modbus_server_controller_write.yaml | 4 +- .../fixtures/uart_mock_modbus_timing.yaml | 11 +- tests/integration/test_uart_mock_modbus.py | 22 +- 20 files changed, 1211 insertions(+), 532 deletions(-) delete mode 100644 tests/components/modbus/modbus_test.cpp diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index f6e0f98857..492dfcaafe 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -14,7 +14,11 @@ DEPENDENCIES = ["uart"] modbus_ns = cg.esphome_ns.namespace("modbus") Modbus = modbus_ns.class_("Modbus", cg.Component, uart.UARTDevice) +ModbusServer = modbus_ns.class_("ModbusServerHub", Modbus) +ModbusClient = modbus_ns.class_("ModbusClientHub", Modbus) ModbusDevice = modbus_ns.class_("ModbusDevice") +ModbusClientDevice = modbus_ns.class_("ModbusClientDevice") +ModbusServerDevice = modbus_ns.class_("ModbusServerDevice") MULTI_CONF = True CONF_ROLE = "role" @@ -22,29 +26,43 @@ CONF_MODBUS_ID = "modbus_id" CONF_SEND_WAIT_TIME = "send_wait_time" CONF_TURNAROUND_TIME = "turnaround_time" -ModbusRole = modbus_ns.enum("ModbusRole") -MODBUS_ROLES = { - "client": ModbusRole.CLIENT, - "server": ModbusRole.SERVER, -} +MODBUS_ROLES = ["client", "server"] -CONFIG_SCHEMA = ( - cv.Schema( - { - cv.GenerateID(): cv.declare_id(Modbus), - cv.Optional(CONF_ROLE, default="client"): cv.enum(MODBUS_ROLES), - cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema, - cv.Optional( - CONF_SEND_WAIT_TIME, default="250ms" - ): cv.positive_time_period_milliseconds, - cv.Optional( - CONF_TURNAROUND_TIME, default="100ms" - ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_DISABLE_CRC, default=False): cv.boolean, - } - ) - .extend(cv.COMPONENT_SCHEMA) - .extend(uart.UART_DEVICE_SCHEMA) +CONFIG_SCHEMA = cv.typed_schema( + { + "client": cv.Schema( + { + cv.GenerateID(): cv.declare_id(ModbusClient), + cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema, + cv.Optional( + CONF_SEND_WAIT_TIME, default="2000ms" + ): cv.positive_time_period_milliseconds, + cv.Optional( + CONF_TURNAROUND_TIME, default="600ms" + ): cv.positive_time_period_milliseconds, + # Remove before 2026.10.0 + cv.Optional(CONF_DISABLE_CRC): cv.invalid( + "'disable_crc' has been removed. The parser no longer requires it — remove this option." + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA), + "server": cv.Schema( + { + cv.GenerateID(): cv.declare_id(ModbusServer), + cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema, + # Remove before 2026.10.0 + cv.Optional(CONF_DISABLE_CRC): cv.invalid( + "'disable_crc' has been removed. The parser no longer requires it — remove this option." + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA), + }, + key=CONF_ROLE, + default_type="client", ) @@ -55,19 +73,19 @@ async def to_code(config): await uart.register_uart_device(var, config) - cg.add(var.set_role(config[CONF_ROLE])) if CONF_FLOW_CONTROL_PIN in config: pin = await gpio_pin_expression(config[CONF_FLOW_CONTROL_PIN]) cg.add(var.set_flow_control_pin(pin)) - cg.add(var.set_send_wait_time(config[CONF_SEND_WAIT_TIME])) - cg.add(var.set_turnaround_time(config[CONF_TURNAROUND_TIME])) - cg.add(var.set_disable_crc(config[CONF_DISABLE_CRC])) + if config[CONF_ROLE] == "client": + cg.add(var.set_send_wait_time(config[CONF_SEND_WAIT_TIME])) + cg.add(var.set_turnaround_time(config[CONF_TURNAROUND_TIME])) -def modbus_device_schema(default_address): +def modbus_device_schema(default_address, role: Literal["client", "server"] = "client"): + hub_type = ModbusClient if role == "client" else ModbusServer schema = { - cv.GenerateID(CONF_MODBUS_ID): cv.use_id(Modbus), + cv.GenerateID(CONF_MODBUS_ID): cv.use_id(hub_type), } if default_address is None: schema[cv.Required(CONF_ADDRESS)] = cv.hex_uint8_t @@ -98,8 +116,18 @@ def final_validate_modbus_device( ) -async def register_modbus_device(var, config): +async def register_modbus_client_device(var, config): + parent = await cg.get_variable(config[CONF_MODBUS_ID]) + cg.add(var.set_parent(parent)) + cg.add(var.set_address(config[CONF_ADDRESS])) + + +async def register_modbus_server_device(var, config): parent = await cg.get_variable(config[CONF_MODBUS_ID]) cg.add(var.set_parent(parent)) cg.add(var.set_address(config[CONF_ADDRESS])) cg.add(parent.register_device(var)) + + +async def register_modbus_device(var, config): + return await register_modbus_client_device(var, config) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 679ec34c0f..136fc73db6 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -37,9 +37,36 @@ void Modbus::setup() { } void Modbus::loop() { - // First process all available incoming data. - this->receive_and_parse_modbus_bytes_(); + // Receive any available bytes from UART + this->receive_bytes_(); + // Parse bytes into frames and process them + this->parse_modbus_frames(); +} + +void ModbusClientHub::loop() { + // Call base class to receive bytes and parse frames + this->Modbus::loop(); + + // If we're past the send_wait_time timeout and response buffer doesn't have the start of the expected response + if (this->waiting_for_response_.has_value()) { + ModbusDeviceCommand &wfr = this->waiting_for_response_.value(); + uint8_t expected_address = wfr.frame.data.get()[0]; + if (this->last_receive_check_ - this->last_send_ > this->last_send_tx_offset_ + this->send_wait_time_ && + (this->rx_buffer_.empty() || this->rx_buffer_[0] != expected_address)) { + ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send", expected_address, + this->last_receive_check_ - this->last_send_); + if (wfr.device) + wfr.device->on_modbus_no_response(); + this->waiting_for_response_.reset(); + } + } + + // If there's no response pending and there's commands in the buffer + this->send_next_frame_(); +} + +bool Modbus::timeout_() { // If the response frame is finished (including interframe delay) - we timeout. // The long_rx_buffer_delay accounts for long responses (larger than the UART rx_full_threshold) to avoid timeouts // when the buffer is filling the back half of the response @@ -47,250 +74,307 @@ void Modbus::loop() { (uint16_t) this->frame_delay_ms_, (uint16_t) (this->rx_buffer_.size() >= this->parent_->get_rx_full_threshold() ? this->long_rx_buffer_delay_ms_ : 0)); + + return this->last_receive_check_ - this->last_modbus_byte_ > timeout; +} + +int32_t Modbus::tx_delay_remaining() { // We use millis() here and elsewhere instead of App.get_loop_component_start_time() to avoid stale timestamps // It's critical in all timestamp comparisons that the left timestamp comes before the right one in time // If we use a cached value in place of millis() and last_modbus_byte_ is updated inside our loop // then the comparison is backwards (small negative which wraps to large positive) and will cause a false timeout // So in this component we don't use any cached timestamp values to avoid these annoying bugs - if (millis() - this->last_modbus_byte_ > timeout) { - this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true); - } + const uint32_t now = millis(); + return std::max({(int32_t) 0, + (int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ - (now - this->last_send_)), + (int32_t) (this->frame_delay_ms_ - (now - this->last_modbus_byte_))}); +} - // If we're past the send_wait_time timeout and response buffer doesn't have the start of the expected response - if (this->waiting_for_response_ != 0 && - millis() - this->last_send_ > this->last_send_tx_offset_ + this->send_wait_time_ && - (this->rx_buffer_.empty() || this->rx_buffer_[0] != this->waiting_for_response_)) { - ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send", - this->waiting_for_response_, millis() - this->last_send_); - this->waiting_for_response_ = 0; - } - - // If there's no response pending and there's commands in the buffer - this->send_next_frame_(); +int32_t ModbusClientHub::tx_delay_remaining() { + const uint32_t now = millis(); + return std::max({(int32_t) 0, + (int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ + this->turnaround_delay_ms_ - + (now - this->last_send_)), + (int32_t) (this->frame_delay_ms_ + this->turnaround_delay_ms_ - (now - this->last_modbus_byte_))}); } bool Modbus::tx_blocked() { - const uint32_t now = millis(); - - // We block transmission in any of these case: + // We block transmission in any of these cases: // 1. There are bytes in the UART Rx buffer // 2. There are bytes in our Rx buffer - // 3. We're waiting for a response - // 4. The last sent byte isn't more than frame_delay ms ago (i.e. wait to tell receivers that our previous Tx is done) - // 5. The last received byte isn't more than frame_delay ms ago (i.e. wait to be sure there isn't more Rx coming) - // 6. If we're a client - also wait for the turnaround delay, to give the servers time to process the previous message - return this->available() || !this->rx_buffer_.empty() || (this->waiting_for_response_ != 0) || - (now - this->last_send_ < this->last_send_tx_offset_ + this->frame_delay_ms_ + - (this->role == ModbusRole::CLIENT ? this->turnaround_delay_ms_ : 0)) || - (now - this->last_modbus_byte_ < - this->frame_delay_ms_ + (this->role == ModbusRole::CLIENT ? this->turnaround_delay_ms_ : 0)); + // 3. The last sent byte isn't more than tx_delay ms ago (i.e. wait to tell receivers that our previous Tx is done) + // 4. The last received byte isn't more than tx_delay ms ago (i.e. wait to be sure there isn't more Rx coming) + // N.B. We allow a small delay (MODBUS_TX_MAX_DELAY_MS) to avoid looping on small delays. This gets handled by + // send_frame_. + return this->available() || !this->rx_buffer_.empty() || this->tx_delay_remaining() > MODBUS_TX_MAX_DELAY_MS; } -bool Modbus::tx_buffer_empty() { return this->tx_buffer_.empty(); } +bool ModbusClientHub::tx_blocked() { + // We block transmission in any of these case: + // 1. We're waiting for a response + // 2. Any of the base class tx_blocked conditions + return (this->waiting_for_response_.has_value()) || this->Modbus::tx_blocked(); +} -void Modbus::receive_and_parse_modbus_bytes_() { - // Read all available bytes in batches to reduce UART call overhead. - size_t avail = this->available(); - uint8_t buf[64]; - while (avail > 0) { - size_t to_read = std::min(avail, sizeof(buf)); - if (!this->read_array(buf, to_read)) { - break; +bool ModbusClientHub::tx_buffer_empty() { return this->tx_buffer_.empty(); } + +void Modbus::receive_bytes_() { + this->last_receive_check_ = millis(); + size_t bytes = this->available(); + + if (bytes) { + size_t buffer_size = this->rx_buffer_.size(); + this->last_modbus_byte_ = this->last_receive_check_; + this->rx_buffer_.resize(buffer_size + bytes); + if (!this->read_array(this->rx_buffer_.data() + buffer_size, bytes)) { + this->rx_buffer_.resize(buffer_size); + return; } - avail -= to_read; - for (size_t i = 0; i < to_read; i++) { - if (this->rx_buffer_.empty()) { - ESP_LOGV(TAG, "Received first byte %" PRIu8 " (0X%x) %" PRIu32 "ms after last send", buf[i], buf[i], - millis() - this->last_send_); - } else { - ESP_LOGVV(TAG, "Received byte %" PRIu8 " (0X%x) %" PRIu32 "ms after last send", buf[i], buf[i], - millis() - this->last_send_); - } - - // If the bytes in the rx buffer do not parse, clear out the buffer - if (!this->parse_modbus_byte_(buf[i])) { - this->clear_rx_buffer_(LOG_STR("parse failed"), true); - } - this->last_modbus_byte_ = millis(); + if (buffer_size == 0) { + ESP_LOGV(TAG, "Received first byte %" PRIu8 " (0X%x) of %zu bytes %" PRIu32 "ms after last send", + this->rx_buffer_[0], this->rx_buffer_[0], this->rx_buffer_.size(), millis() - this->last_send_); } } } -bool Modbus::parse_modbus_byte_(uint8_t byte) { - size_t at = this->rx_buffer_.size(); - this->rx_buffer_.push_back(byte); - const uint8_t *raw = &this->rx_buffer_[0]; +void ModbusClientHub::parse_modbus_frames() { + if (!this->rx_buffer_.empty()) { + size_t size; + do { + size = this->rx_buffer_.size(); + if (!this->parse_modbus_server_frame_()) + this->clear_rx_buffer_(LOG_STR("parse failed"), true); + } while (!this->rx_buffer_.empty() && size > this->rx_buffer_.size()); + if (this->timeout_()) + this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true); + } +} - // Byte 0: modbus address (match all) - if (at == 0) - return true; - // Byte 1: function code - if (at == 1) - return true; - // Byte 2: Size (with modbus rtu function code 4/3) - // See also https://en.wikipedia.org/wiki/Modbus - if (at == 2) - return true; - - uint8_t address = raw[0]; - uint8_t function_code = raw[1]; - - uint8_t data_len = raw[2]; - uint8_t data_offset = 3; - - // Per https://modbus.org/docs/Modbus_Application_Protocol_V1_1b3.pdf Ch 5 User-Defined function codes - if (((function_code >= FUNCTION_CODE_USER_DEFINED_SPACE_1_INIT) && - (function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_1_END)) || - ((function_code >= FUNCTION_CODE_USER_DEFINED_SPACE_2_INIT) && - (function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_2_END))) { - // Handle user-defined function, since we don't know how big this ought to be, - // ideally we should delegate the entire length detection to whatever handler is - // installed, but wait, there is the CRC, and if we get a hit there is a good - // chance that this is a complete message ... admittedly there is a small chance is - // isn't but that is quite small given the purpose of the CRC in the first place - - data_len = at - 2; - data_offset = 1; - - uint16_t computed_crc = crc16(raw, data_offset + data_len); - uint16_t remote_crc = uint16_t(raw[data_offset + data_len]) | (uint16_t(raw[data_offset + data_len + 1]) << 8); - - if (computed_crc != remote_crc) - return true; - - ESP_LOGD(TAG, "User-defined function %02X found", function_code); - - } else { - // data starts at 2 and length is 4 for read registers commands - if (this->role == ModbusRole::SERVER) { - if (function_code == ModbusFunctionCode::READ_COILS || - function_code == ModbusFunctionCode::READ_DISCRETE_INPUTS || - function_code == ModbusFunctionCode::READ_HOLDING_REGISTERS || - function_code == ModbusFunctionCode::READ_INPUT_REGISTERS || - function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER) { - data_offset = 2; - data_len = 4; - } else if (function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { - if (at < 6) { - return true; - } - data_offset = 2; - // starting address (2 bytes) + quantity of registers (2 bytes) + byte count itself (1 byte) + actual byte count - data_len = 2 + 2 + 1 + raw[6]; +void ModbusServerHub::parse_modbus_frames() { + while (!this->rx_buffer_.empty()) { + size_t size = this->rx_buffer_.size(); + ESP_LOGVV(TAG, "Parsing frames buffer size = %" PRIu32, size); + bool retry_as_client = false; + if (this->expecting_peer_response_ != 0) { + if (!this->parse_modbus_server_frame_()) { + ESP_LOGV(TAG, "Stop expecting peer response from %" PRIu8 " due to parse failure, and retry parse", + this->expecting_peer_response_); + this->expecting_peer_response_ = 0; + retry_as_client = true; + } else if (this->timeout_() && size == this->rx_buffer_.size()) { + // If we timed out and the above parse attempt did not consume data, stop expecting a response + ESP_LOGV(TAG, + "Stop expecting peer response from %" PRIu8 " due to timeout after partial response, and retry parse", + this->expecting_peer_response_); + this->expecting_peer_response_ = 0; + retry_as_client = true; } } else { - // the response for write command mirrors the requests and data starts at offset 2 instead of 3 for read commands - if (function_code == ModbusFunctionCode::WRITE_SINGLE_COIL || - function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER || - function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS || - function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { - data_offset = 2; - data_len = 4; - } - } - - // Error ( msb indicates error ) - // response format: Byte[0] = device address, Byte[1] function code | 0x80 , Byte[2] exception code, Byte[3-4] crc - if ((function_code & FUNCTION_CODE_EXCEPTION_MASK) == FUNCTION_CODE_EXCEPTION_MASK) { - data_offset = 2; - data_len = 1; - } - - // Byte data_offset..data_offset+data_len-1: Data - if (at < data_offset + data_len) - return true; - - // Byte 3+data_len: CRC_LO (over all bytes) - if (at == data_offset + data_len) - return true; - - // Byte data_offset+len+1: CRC_HI (over all bytes) - uint16_t computed_crc = crc16(raw, data_offset + data_len); - uint16_t remote_crc = uint16_t(raw[data_offset + data_len]) | (uint16_t(raw[data_offset + data_len + 1]) << 8); - if (computed_crc != remote_crc) { - if (this->disable_crc_) { - ESP_LOGD(TAG, "CRC check failed %" PRIu32 "ms after last send; ignoring", millis() - this->last_send_); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, " (%02X != %02X) %s", computed_crc, remote_crc, - format_hex_pretty_to(hex_buf, this->rx_buffer_.data(), this->rx_buffer_.size())); - } else { - ESP_LOGW(TAG, "CRC check failed %" PRIu32 "ms after last send", millis() - this->last_send_); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, " (%02X != %02X) %s", computed_crc, remote_crc, - format_hex_pretty_to(hex_buf, this->rx_buffer_.data(), this->rx_buffer_.size())); - return false; - } + if (!this->parse_modbus_client_frame_()) + this->clear_rx_buffer_(LOG_STR("parse failed"), true); } + // Stop if the buffer didn't shrink (no frame consumed) and no mode switch triggered a retry + if (!retry_as_client && size <= this->rx_buffer_.size()) + break; } - std::vector data(this->rx_buffer_.begin() + data_offset, this->rx_buffer_.begin() + data_offset + data_len); - bool found = false; - for (auto *device : this->devices_) { - if (device->address_ == address) { - found = true; - if (this->role == ModbusRole::SERVER) { - if (function_code == ModbusFunctionCode::READ_HOLDING_REGISTERS || - function_code == ModbusFunctionCode::READ_INPUT_REGISTERS) { - device->on_modbus_read_registers(function_code, uint16_t(data[1]) | (uint16_t(data[0]) << 8), - uint16_t(data[3]) | (uint16_t(data[2]) << 8)); - } else if (function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER || - function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { - device->on_modbus_write_registers(function_code, data); - } - } else { // We're a client - // Is it an error response? - if ((function_code & FUNCTION_CODE_EXCEPTION_MASK) == FUNCTION_CODE_EXCEPTION_MASK) { - uint8_t exception = raw[2]; - ESP_LOGW(TAG, - "Error function code: 0x%X exception: %" PRIu8 ", address: %" PRIu8 ", %" PRIu32 - "ms after last send", - function_code, exception, address, millis() - this->last_send_); - if (this->waiting_for_response_ == address) { - device->on_modbus_error(function_code & FUNCTION_CODE_MASK, exception); - } else { - // Ignore modbus exception not related to a pending command - ESP_LOGD(TAG, "Ignoring error - not expecting a response from %" PRIu8 "", address); - } - } else { // Not an error response - if (this->waiting_for_response_ == address) { - device->on_modbus_data(data); - } else { - // Ignore modbus response not related to a pending command - ESP_LOGW(TAG, "Ignoring response - not expecting a response from %" PRIu8 ", %" PRIu32 "ms after last send", - address, millis() - this->last_send_); - } - } - } - } + if (this->timeout_()) + this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true); +} + +uint16_t Modbus::find_custom_frame_end_(uint16_t min_length) const { + // Custom functions could be any length - we have to rely on the CRC to determine completeness. + // If a CRC match is never found, the buffer will eventually overflow and be cleared. + const uint8_t *raw = &this->rx_buffer_[0]; + const size_t size = this->rx_buffer_.size(); + for (uint16_t len = min_length; len <= std::min(size, size_t(MAX_FRAME_SIZE)); len++) { + if (crc16(raw, len) == 0) + return len; + } + return 0; +} + +bool Modbus::parse_modbus_server_frame_() { + size_t size = this->rx_buffer_.size(); + uint16_t frame_length = helpers::server_frame_length(this->rx_buffer_.data(), this->rx_buffer_.size()); + + if (size < frame_length) + return true; + + uint8_t address = this->rx_buffer_[0]; + uint8_t function_code = this->rx_buffer_[1]; + + if (helpers::is_function_code_custom(function_code)) { + frame_length = this->find_custom_frame_end_(frame_length); + if (frame_length == 0) + return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size + ESP_LOGD(TAG, "User-defined function %02X found", function_code); + } else { + if (crc16(&this->rx_buffer_[0], frame_length) != 0) + return false; } - if (!found && this->role == ModbusRole::CLIENT) { - ESP_LOGW(TAG, "Got frame from unknown address %" PRIu8 ", %" PRIu32 "ms after last send", address, - millis() - this->last_send_); - } + // Process before clearing: process_modbus_server_frame (receiving a response or peer message) never sends a reply + // synchronously. We can safely point directly into rx_buffer_ and avoid a copy. + uint8_t data_offset = helpers::server_frame_data_offset(this->rx_buffer_.data(), this->rx_buffer_.size()); + const uint8_t *data = this->rx_buffer_.data() + data_offset; + uint16_t data_len = frame_length - 2 - data_offset; - this->clear_rx_buffer_(LOG_STR("parse succeeded")); - - if (this->waiting_for_response_ == address) - this->waiting_for_response_ = 0; + this->process_modbus_server_frame(address, function_code, data, data_len); + this->clear_rx_buffer_(LOG_STR("parse succeeded"), false, frame_length); return true; } -void Modbus::send_next_frame_() { - if (this->tx_buffer_.empty()) +bool ModbusServerHub::parse_modbus_client_frame_() { + size_t size = this->rx_buffer_.size(); + uint16_t frame_length = helpers::client_frame_length(this->rx_buffer_.data(), this->rx_buffer_.size()); + + if (size < frame_length) + return true; + + uint8_t address = this->rx_buffer_[0]; + uint8_t function_code = this->rx_buffer_[1]; + + if (helpers::is_function_code_custom(function_code)) { + frame_length = this->find_custom_frame_end_(frame_length); + if (frame_length == 0) + return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size + ESP_LOGD(TAG, "User-defined function %02X found", function_code); + } else { + if (crc16(&this->rx_buffer_[0], frame_length) != 0) + return false; + } + + // Clear before processing: process_modbus_client_frame_ dispatches to a server device which sends + // a response immediately. We need to clear the rx buffer first so the response doesn't snag tx_blocked. + // This requires copying the frame data to a local buffer beforehand. + uint8_t data_offset = helpers::client_frame_data_offset(this->rx_buffer_.data(), this->rx_buffer_.size()); + uint16_t data_len = frame_length - 2 - data_offset; + uint8_t data[MAX_FRAME_SIZE] = {}; + std::memcpy(data, this->rx_buffer_.data() + data_offset, data_len); + this->clear_rx_buffer_(LOG_STR("parse succeeded"), false, frame_length); + + this->process_modbus_client_frame_(address, function_code, data, data_len); + + return true; +} + +void ModbusClientHub::process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, + uint16_t len) { + if (!this->waiting_for_response_.has_value()) { + ESP_LOGW(TAG, + "Received unexpected frame from address %" PRIu8 ", function code 0x%X, %" PRIu32 "ms after last send", + address, function_code, this->last_modbus_byte_ - this->last_send_); return; + } else { // We are waiting for a response + // Check if the response matches the expected address and function code - if (this->tx_blocked()) - return; + ModbusDeviceCommand &wfr = this->waiting_for_response_.value(); + uint8_t expected_address = wfr.frame.data.get()[0]; + uint8_t expected_function_code = wfr.frame.data.get()[1]; + if (expected_address != address || expected_function_code != (function_code & FUNCTION_CODE_MASK)) { + ESP_LOGW(TAG, + "Received incorrect frame address %" PRIu8 " <> %" PRIu8 " or function code 0x%X <> 0x%X, %" PRIu32 + "ms after last send", + address, expected_address, (function_code & FUNCTION_CODE_MASK), expected_function_code, + this->last_modbus_byte_ - this->last_send_); + // Invalidate the waiting device so it won't process this response. + if (wfr.device) + wfr.device->on_modbus_no_response(); + wfr.interrupted = true; + wfr.device = nullptr; + return; + } - const ModbusDeviceCommand &frame = this->tx_buffer_.front(); + if (wfr.interrupted) { + ESP_LOGW(TAG, + "Ignoring response from %" PRIu8 " - transmission interrupted by previous unexpected response, %" PRIu32 + "ms after last send", + address, this->last_modbus_byte_ - this->last_send_); + return; + } else { // We have a valid device waiting for this response - if (this->role == ModbusRole::CLIENT) { - this->waiting_for_response_ = frame.data.get()[0]; + ModbusClientDevice *device = wfr.device; + this->waiting_for_response_.reset(); + // Is it an error response? + if (helpers::is_function_code_exception(function_code)) { + uint8_t exception = len > 0 ? data[0] : 0; + ESP_LOGW(TAG, + "Error function code: 0x%X exception: %" PRIu8 ", address: %" PRIu8 ", %" PRIu32 "ms after last send", + function_code, exception, address, this->last_modbus_byte_ - this->last_send_); + if (device) + device->on_modbus_error(function_code & FUNCTION_CODE_MASK, exception); + + } else if (device) { // Not an error response + // on_modbus_data is existing public API taking const std::vector& + device->on_modbus_data(std::vector(data, data + len)); + } else { // Not an error response, but no device to respond to + ESP_LOGV(TAG, "Ignoring response from %" PRIu8 " - no callback device set, %" PRIu32 "ms after last send", + address, this->last_modbus_byte_ - this->last_send_); + } + } + } +} + +void ModbusServerHub::process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *, uint16_t) { + for (auto *device : this->devices_) { + if (device->address_ == address) { + ESP_LOGE(TAG, "Unexpected response from address %" PRIu8 ", which is mapped to this device.", address); + } + } + + if (this->expecting_peer_response_ == address) { + ESP_LOGV(TAG, "Expected response from peer %" PRIu8 " received", address); + } else { + ESP_LOGV(TAG, "Unexpected response from peer %" PRIu8 " received", address); + } + + // This always resets, even if the address doesn't match. + // If an unexpected response is received, we can't trust that a correct response will follow (it shouldn't). + this->expecting_peer_response_ = 0; +} + +void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data, + uint16_t len) { + bool found = false; + + for (auto *device : this->devices_) { + if (device->address_ == address) { + found = true; + + if (static_cast(function_code) == ModbusFunctionCode::READ_HOLDING_REGISTERS || + static_cast(function_code) == ModbusFunctionCode::READ_INPUT_REGISTERS) { + device->on_modbus_read_registers(function_code, helpers::get_data(data, 0), + helpers::get_data(data, 2)); + } else if (static_cast(function_code) == ModbusFunctionCode::WRITE_SINGLE_REGISTER || + static_cast(function_code) == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { + device->on_modbus_write_registers(function_code, std::vector(data, data + len)); + } else { + ESP_LOGW(TAG, "Unsupported function code %" PRIu8, function_code); + device->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); + } + } + } + + if (!found) { + this->expecting_peer_response_ = address; + ESP_LOGV(TAG, "Request to peer %" PRIu8 " received", address); + } +} + +bool Modbus::send_frame_(const ModbusFrame &frame) { + if (this->tx_blocked()) { + ESP_LOGE(TAG, "Attempted to send while transmission blocked"); + return false; + } + if (frame.size > MAX_FRAME_SIZE) { + ESP_LOGE(TAG, "Attempted to send frame larger than max frame size of %" PRIu16 " bytes", MAX_FRAME_SIZE); + return false; + } + + const int32_t tx_delay_remaining = this->tx_delay_remaining(); + if (tx_delay_remaining > 0) { + delay(tx_delay_remaining); } if (this->flow_control_pin_ != nullptr) { @@ -304,123 +388,190 @@ void Modbus::send_next_frame_() { this->last_send_tx_offset_ = frame.size * MODBUS_BITS_PER_CHAR * MS_PER_SEC / this->parent_->get_baud_rate() + 1; } + uint32_t now = millis(); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; #endif - ESP_LOGV(TAG, "Write: %s %" PRIu32 "ms after last send", format_hex_pretty_to(hex_buf, frame.data.get(), frame.size), - millis() - this->last_send_); - this->last_send_ = millis(); + ESP_LOGV(TAG, "Write: %s %" PRIu32 "ms after last send, %" PRIu32 "ms after last receive", + format_hex_pretty_to(hex_buf, frame.data.get(), frame.size), now - this->last_send_, + now - this->last_modbus_byte_); + this->last_send_ = now; + return true; +} + +void ModbusClientHub::send_next_frame_() { + if (this->tx_buffer_.empty()) { + return; + } + + if (this->tx_blocked()) { + return; + } + + ModbusDeviceCommand &command = this->tx_buffer_.front(); + + if (this->send_frame_(command.frame)) { + this->waiting_for_response_ = std::move(command); + } else { + if (command.device) + command.device->on_modbus_not_sent(); + } + this->tx_buffer_.pop_front(); + if (!this->tx_buffer_.empty()) { ESP_LOGV(TAG, "Write queue contains %zu items.", this->tx_buffer_.size()); } } -void Modbus::dump_config() { +void ModbusClientHub::dump_config() { ESP_LOGCONFIG(TAG, "Modbus:\n" - " Send Wait Time: %d ms\n" - " Turnaround Time: %d ms\n" - " Frame Delay: %d ms\n" - " Long Rx Buffer Delay: %d ms\n" - " CRC Disabled: %s", + " Send Wait Time: %" PRIu16 " ms\n" + " Turnaround Time: %" PRIu16 " ms\n" + " Frame Delay: %" PRIu16 " ms\n" + " Long Rx Buffer Delay: %" PRIu16 " ms", this->send_wait_time_, this->turnaround_delay_ms_, this->frame_delay_ms_, - this->long_rx_buffer_delay_ms_, YESNO(this->disable_crc_)); + this->long_rx_buffer_delay_ms_); LOG_PIN(" Flow Control Pin: ", this->flow_control_pin_); } +void ModbusServerHub::dump_config() { + ESP_LOGCONFIG(TAG, + "Modbus:\n" + " Frame Delay: %" PRIu16 " ms\n" + " Long Rx Buffer Delay: %" PRIu16 " ms", + this->frame_delay_ms_, this->long_rx_buffer_delay_ms_); + LOG_PIN(" Flow Control Pin: ", this->flow_control_pin_); +} + float Modbus::get_setup_priority() const { // After UART bus return setup_priority::BUS - 1.0f; } -void Modbus::send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities, - uint8_t payload_len, const uint8_t *payload) { - static const size_t MAX_VALUES = 128; - - // Only check max number of registers for standard function codes - // Some devices use non standard codes like 0x43 - if (number_of_entities > MAX_VALUES && function_code <= ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { - ESP_LOGE(TAG, "send too many values %d max=%zu", number_of_entities, MAX_VALUES); +void ModbusServerHub::send(uint8_t address, uint8_t function_code, const std::vector &payload) { + const uint16_t len = static_cast(2 + payload.size()); + if (len > MAX_RAW_SIZE) { + ESP_LOGE(TAG, "Server send frame too large (%" PRIu16 " bytes)", len); return; } - - uint8_t data[MAX_FRAME_SIZE]; - size_t pos = 0; - - data[pos++] = address; - data[pos++] = function_code; - if (this->role == ModbusRole::CLIENT) { - data[pos++] = start_address >> 8; - data[pos++] = start_address >> 0; - if (function_code != ModbusFunctionCode::WRITE_SINGLE_COIL && - function_code != ModbusFunctionCode::WRITE_SINGLE_REGISTER) { - data[pos++] = number_of_entities >> 8; - data[pos++] = number_of_entities >> 0; - } - } - - if (payload != nullptr) { - if (this->role == ModbusRole::SERVER || function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS || - function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { // Write multiple - data[pos++] = payload_len; // Byte count is required for write - } else { - payload_len = 2; // Write single register or coil - } - if (payload_len + pos + 2 > MAX_FRAME_SIZE) { // Check if payload fits (accounting for CRC) - ESP_LOGE(TAG, "Payload too large to send: %d bytes", payload_len); - return; - } - for (int i = 0; i < payload_len; i++) { - data[pos++] = payload[i]; - } - } - - this->queue_raw_(data, pos); + uint8_t raw_frame[MAX_RAW_SIZE]; + raw_frame[0] = address; + raw_frame[1] = function_code; + std::memcpy(raw_frame + 2, payload.data(), payload.size()); + this->send_raw_(raw_frame, len); } -// Helper function for lambdas -// Send raw command. Except CRC everything must be contained in payload -void Modbus::send_raw(const std::vector &payload) { - if (payload.empty()) { +// Raw send for client: pushes to tx queue. Everything except the CRC must be contained in payload. +void ModbusClientHub::queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device) { + if (pdu_len == 0) { + if (device) + device->on_modbus_not_sent(); return; } - // Frame size: payload + CRC(2) - if (payload.size() + 2 > MAX_FRAME_SIZE) { - ESP_LOGE(TAG, "Attempted to send frame larger than max frame size of %d bytes", MAX_FRAME_SIZE); - return; - } - // Use stack buffer - Modbus frames are small and bounded - uint8_t data[MAX_FRAME_SIZE]; - std::memcpy(data, payload.data(), payload.size()); - - this->queue_raw_(data, payload.size()); -} - -// Assume data and length is valid and append CRC, then queue for sending. Used internally to avoid unnecessary copying -// of data into vectors -void Modbus::queue_raw_(const uint8_t *data, uint16_t len) { if (this->tx_buffer_.size() < MODBUS_TX_BUFFER_SIZE) { - this->tx_buffer_.emplace_back(data, len); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; +#endif + ESP_LOGV(TAG, "Adding frame to tx queue: %" PRIu8 ":%s", address, format_hex_pretty_to(hex_buf, pdu, pdu_len)); + this->tx_buffer_.emplace_back(device, address, pdu, pdu_len); } else { #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_ERROR char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; #endif - ESP_LOGE(TAG, "Write buffer full, dropped: %s", format_hex_pretty_to(hex_buf, data, len)); + ESP_LOGE(TAG, "Write buffer full, dropped: %" PRIu8 ":%s", address, format_hex_pretty_to(hex_buf, pdu, pdu_len)); + if (device) + device->on_modbus_not_sent(); } } -void Modbus::clear_rx_buffer_(const LogString *reason, bool warn) { - size_t at = this->rx_buffer_.size(); - if (at > 0) { +void ModbusClientHub::clear_tx_queue_for_address(uint8_t address, bool clear_sent) { + // Remove any pending commands for this address from the tx buffer + auto &tx_buffer = this->tx_buffer_; + tx_buffer.erase(std::remove_if(tx_buffer.begin(), tx_buffer.end(), + [address](const ModbusDeviceCommand &cmd) { return cmd.frame.data[0] == address; }), + tx_buffer.end()); + + if (clear_sent && this->waiting_for_response_.has_value() && this->waiting_for_response_.value().device) { + if (this->waiting_for_response_.value().frame.data[0] == address) { + ESP_LOGV(TAG, "Clearing waiting for response for address %" PRIu8, address); + // Invalidate the waiting device so it won't process a response. + this->waiting_for_response_.value().device = nullptr; + } + } +} +void ModbusClientHub::clear_tx_queue_for_device(ModbusClientDevice *device) { + // Remove any pending commands for this address from the tx buffer + auto &tx_buffer = this->tx_buffer_; + tx_buffer.erase(std::remove_if(tx_buffer.begin(), tx_buffer.end(), + [device](const ModbusDeviceCommand &cmd) { return cmd.device == device; }), + tx_buffer.end()); + + if (this->waiting_for_response_.has_value() && this->waiting_for_response_.value().device) { + if (this->waiting_for_response_.value().device == device) { + ESP_LOGV(TAG, "Clearing waiting for response"); + // Invalidate the waiting device so it won't process a response. + this->waiting_for_response_.value().device = nullptr; + } + } +} + +void ModbusClientHub::send_raw(const std::vector &payload, ModbusClientDevice *device) { + if (payload.size() < 2) { + if (device) + device->on_modbus_not_sent(); + return; + } + this->queue_raw_(payload[0], payload.data() + 1, static_cast(payload.size() - 1), device); +} + +// Send raw command for server replies immediately. Except CRC everything must be contained in payload +void ModbusServerHub::send_raw_(const uint8_t *payload, uint16_t len) { + if (len == 0) { + return; + } + if (len > MAX_RAW_SIZE) { + ESP_LOGE(TAG, "Server send frame too large (%" PRIu16 " bytes)", len); + return; + } + + // In the rare case that the server is blocked (frame delay has not elapsed), we delay the send. + // This should only happen at low baud rates with long frame delays. + if (this->tx_blocked()) { + // Stash the raw payload in a single member buffer so the deferred callback can rebuild the frame + // without a heap allocation. Only one server reply is ever in flight, and the named timeout ensures + // only one deferred send is pending, so a single buffer is sufficient. + std::memcpy(this->deferred_payload_.data(), payload, len); + this->deferred_payload_len_ = len; + this->set_timeout("deferred_send", this->tx_delay_remaining(), [this]() { + ModbusFrame frame(this->deferred_payload_[0], this->deferred_payload_.data() + 1, + this->deferred_payload_len_ - 1); + this->send_frame_(frame); + }); + } else { + ModbusFrame frame(payload[0], payload + 1, len - 1); + this->send_frame_(frame); + } +} + +void Modbus::clear_rx_buffer_(const LogString *reason, bool warn, size_t bytes_to_clear) { + size_t bytes = this->rx_buffer_.size(); + if (bytes_to_clear > 0 && bytes >= bytes_to_clear) + bytes = bytes_to_clear; + if (bytes > 0) { if (warn) { - ESP_LOGW(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "ms after last send", at, LOG_STR_ARG(reason), + ESP_LOGW(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "ms after last send", bytes, LOG_STR_ARG(reason), millis() - this->last_send_); } else { - ESP_LOGV(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "ms after last send", at, LOG_STR_ARG(reason), + ESP_LOGV(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "ms after last send", bytes, LOG_STR_ARG(reason), millis() - this->last_send_); } - this->rx_buffer_.clear(); + if (bytes == this->rx_buffer_.size()) { + this->rx_buffer_.clear(); + } else { + this->rx_buffer_.erase(this->rx_buffer_.begin(), this->rx_buffer_.begin() + bytes); + } } } diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 26f64401be..86337442c6 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -4,33 +4,32 @@ #include "esphome/components/uart/uart.h" #include "esphome/components/modbus/modbus_definitions.h" +#include "esphome/components/modbus/modbus_helpers.h" +#include #include #include #include -#include +#include +#include namespace esphome::modbus { static constexpr uint16_t MODBUS_TX_BUFFER_SIZE = 15; +static constexpr uint16_t MODBUS_TX_MAX_DELAY_MS = 5; -enum ModbusRole { - CLIENT, - SERVER, -}; - -class ModbusDevice; - -struct ModbusDeviceCommand { +struct ModbusFrame { // Frame with exact-size allocation to avoid std::vector overhead std::unique_ptr data; uint16_t size; // Modbus RTU max is 256 bytes - ModbusDeviceCommand(const uint8_t *src, uint16_t len) : data(std::make_unique(len + 2)), size(len + 2) { - std::memcpy(this->data.get(), src, len); - auto crc = crc16(data.get(), len); - data[len + 0] = crc >> 0; - data[len + 1] = crc >> 8; + ModbusFrame(uint8_t address, const uint8_t *pdu, uint16_t pdu_len) + : data(std::make_unique(pdu_len + 3)), size(pdu_len + 3) { + data[0] = address; + memcpy(data.get() + 1, pdu, pdu_len); + auto crc = crc16(data.get(), pdu_len + 1); + data[pdu_len + 1] = crc >> 0; + data[pdu_len + 2] = crc >> 8; } }; @@ -39,86 +38,197 @@ class Modbus : public uart::UARTDevice, public Component { Modbus() = default; void setup() override; - void loop() override; - void dump_config() override; - - void register_device(ModbusDevice *device) { this->devices_.push_back(device); } - float get_setup_priority() const override; - bool tx_buffer_empty(); - bool tx_blocked(); + virtual bool tx_blocked(); - void send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities, - uint8_t payload_len = 0, const uint8_t *payload = nullptr); - void send_raw(const std::vector &payload); - void set_role(ModbusRole role) { this->role = role; } void set_flow_control_pin(GPIOPin *flow_control_pin) { this->flow_control_pin_ = flow_control_pin; } - void set_send_wait_time(uint16_t time_in_ms) { this->send_wait_time_ = time_in_ms; } - void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_ms_ = time_in_ms; } - void set_disable_crc(bool disable_crc) { this->disable_crc_ = disable_crc; } - - ModbusRole role; protected: - bool parse_modbus_byte_(uint8_t byte); - void receive_and_parse_modbus_bytes_(); - void clear_rx_buffer_(const LogString *reason, bool warn = false); - void send_next_frame_(); - void queue_raw_(const uint8_t *data, uint16_t len); + void receive_bytes_(); + bool timeout_(); + virtual int32_t tx_delay_remaining(); + virtual void parse_modbus_frames() = 0; + bool parse_modbus_server_frame_(); + virtual void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, + uint16_t len) = 0; + void clear_rx_buffer_(const LogString *reason, bool warn = false, size_t bytes_to_clear = 0); + bool send_frame_(const ModbusFrame &frame); + // Scans forward from min_length to find a frame boundary by CRC match for custom function codes. + // Returns the matched frame length, or 0 if no valid CRC was found within MAX_FRAME_SIZE. + uint16_t find_custom_frame_end_(uint16_t min_length) const; uint32_t last_modbus_byte_{0}; + uint32_t last_receive_check_{0}; uint32_t last_send_{0}; uint32_t last_send_tx_offset_{0}; uint16_t frame_delay_ms_{5}; uint16_t long_rx_buffer_delay_ms_{0}; - uint16_t send_wait_time_{250}; - uint16_t turnaround_delay_ms_{100}; - uint8_t waiting_for_response_{0}; - bool disable_crc_{false}; GPIOPin *flow_control_pin_{nullptr}; std::vector rx_buffer_; - std::vector devices_; +}; + +class ModbusClientDevice; +class ModbusServerDevice; + +struct ModbusDeviceCommand { + ModbusClientDevice *device; + ModbusFrame frame; + bool interrupted{false}; + + ModbusDeviceCommand(ModbusClientDevice *device, uint8_t address, const uint8_t *src, uint16_t len) + : device(device), frame(address, src, len) {} +}; + +class ModbusClientHub : public Modbus { + public: + ModbusClientHub() = default; + void dump_config() override; + void loop() override; + void set_send_wait_time(uint16_t time_in_ms) { this->send_wait_time_ = time_in_ms; } + void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_ms_ = time_in_ms; } + bool tx_buffer_empty(); + bool tx_blocked() override; + ESPDEPRECATED("Use send_pdu() with create_client_pdu() instead. Removed in 2026.10.0", "2026.4.0") + void send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities, + uint8_t payload_len = 0, const uint8_t *payload = nullptr, ModbusClientDevice *device = nullptr) { + this->send_pdu(address, + helpers::create_client_pdu((ModbusFunctionCode) function_code, start_address, number_of_entities, + payload, payload_len), + device); + }; + void send_pdu(uint8_t address, const StaticVector &pdu, ModbusClientDevice *device = nullptr) { + this->queue_raw_(address, pdu.data(), pdu.size(), device); + } + void send_raw(const std::vector &payload, ModbusClientDevice *device = nullptr); + void clear_tx_queue_for_address(uint8_t address, bool clear_sent = true); + void clear_tx_queue_for_device(ModbusClientDevice *device); + + protected: + int32_t tx_delay_remaining() override; + void parse_modbus_frames() override; + // Parsers need to handle standard (ModbusFunctionCode) and custom (uint8_t) function codes, so we use uint8_t here. + void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) override; + void send_next_frame_(); + void queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device = nullptr); + + uint16_t send_wait_time_{2000}; + uint16_t turnaround_delay_ms_{0}; + std::optional waiting_for_response_; + // std::deque is appropriate here since we need a FIFO buffer, and we can't know ahead of time how many // requests will be queued. Each modbus component may queue multiple requests, and the sequence of scheduling // may change at run time. std::deque tx_buffer_; }; -class ModbusDevice { +class ModbusServerHub : public Modbus { public: - void set_parent(Modbus *parent) { parent_ = parent; } - void set_address(uint8_t address) { address_ = address; } - virtual void on_modbus_data(const std::vector &data) = 0; - virtual void on_modbus_error(uint8_t function_code, uint8_t exception_code) {} - virtual void on_modbus_read_registers(uint8_t function_code, uint16_t start_address, uint16_t number_of_registers){}; - virtual void on_modbus_write_registers(uint8_t function_code, const std::vector &data){}; - void send(uint8_t function, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0, - const uint8_t *payload = nullptr) { - this->parent_->send(this->address_, function, start_address, number_of_entities, payload_len, payload); - } - void send_raw(const std::vector &payload) { this->parent_->send_raw(payload); } - void send_error(uint8_t function_code, ModbusExceptionCode exception_code) { - std::vector error_response; - error_response.reserve(3); - error_response.push_back(this->address_); - error_response.push_back(function_code | FUNCTION_CODE_EXCEPTION_MASK); - error_response.push_back(static_cast(exception_code)); - this->send_raw(error_response); - } - // If more than one device is connected block sending a new command before a response is received - ESPDEPRECATED("Use ready_for_immediate_send() instead. Removed in 2026.9.0", "2026.3.0") - bool waiting_for_response() { return !ready_for_immediate_send(); } - bool ready_for_immediate_send() { return parent_->tx_buffer_empty() && !parent_->tx_blocked(); } + ModbusServerHub() = default; + void dump_config() override; + void send(uint8_t address, uint8_t function_code, const std::vector &payload); + ESPDEPRECATED("Use ModbusServerDevice::send_raw instead. Removed in 2026.10.0", "2026.4.0") + void send_raw(const std::vector &payload) { + this->send_raw_(payload.data(), static_cast(payload.size())); + }; + void register_device(ModbusServerDevice *device) { this->devices_.push_back(device); } protected: - friend Modbus; + friend class ModbusServerDevice; - Modbus *parent_; - uint8_t address_; + void parse_modbus_frames() override; + bool parse_modbus_client_frame_(); + // Parsers need to handle standard (ModbusFunctionCode) and custom (uint8_t) function codes, so we use uint8_t here. + void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) override; + void process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len); + void send_raw_(const uint8_t *payload, uint16_t len); + uint8_t expecting_peer_response_{0}; + std::vector devices_; + + // Holds the raw payload of a single reply deferred for sending when tx was blocked at send time. + // Only one server reply can be in flight at once, so a single fixed buffer avoids heap allocation. + std::array deferred_payload_; + uint16_t deferred_payload_len_{0}; +}; + +class ModbusClientDevice { + public: + ModbusClientDevice() = default; + ModbusClientDevice(ModbusClientHub *parent, uint8_t address) : parent_(parent), address_(address) {} + virtual ~ModbusClientDevice() { + if (this->parent_ != nullptr) + this->clear_tx_queue_for_device(); + } + ModbusClientDevice(const ModbusClientDevice &) = delete; + ModbusClientDevice &operator=(const ModbusClientDevice &) = delete; + ModbusClientDevice(ModbusClientDevice &&) = delete; + ModbusClientDevice &operator=(ModbusClientDevice &&) = delete; + void set_parent(ModbusClientHub *parent) { this->parent_ = parent; } + void set_address(uint8_t address) { this->address_ = address; } + virtual void on_modbus_data(const std::vector &data) {} + virtual void on_modbus_error(uint8_t function_code, uint8_t exception_code) {} + virtual void on_modbus_not_sent() {} + virtual void on_modbus_no_response() {} + void send(uint8_t function, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0, + const uint8_t *payload = nullptr) { + this->parent_->send_pdu(this->address_, + helpers::create_client_pdu((ModbusFunctionCode) function, start_address, number_of_entities, + payload, payload_len), + this); + } + void send_pdu(const StaticVector &pdu) { this->parent_->send_pdu(this->address_, pdu, this); } + void send_raw(const std::vector &payload) { this->parent_->send_raw(payload, this); } + inline void clear_tx_queue_for_address(bool clear_sent = true) { + this->parent_->clear_tx_queue_for_address(this->address_, clear_sent); + } + inline void clear_tx_queue_for_device() { this->parent_->clear_tx_queue_for_device(this); } + + // If more than one device is connected block sending a new command before a response is received + ESPDEPRECATED("Use ready_for_immediate_send() instead. Removed in 2026.9.0", "2026.3.0") + bool waiting_for_response() { return !this->ready_for_immediate_send(); } + bool ready_for_immediate_send() { return this->parent_->tx_buffer_empty() && !this->parent_->tx_blocked(); } + + protected: + ModbusClientHub *parent_{nullptr}; + uint8_t address_{0}; +}; + +// This is for compatibility with external components using the former class name +using ModbusDevice = ModbusClientDevice; + +class ModbusServerDevice { + public: + ModbusServerDevice() = default; + ModbusServerDevice(ModbusServerHub *parent, uint8_t address) : parent_(parent), address_(address) {} + virtual ~ModbusServerDevice() = default; + ModbusServerDevice(const ModbusServerDevice &) = delete; + ModbusServerDevice &operator=(const ModbusServerDevice &) = delete; + ModbusServerDevice(ModbusServerDevice &&) = delete; + ModbusServerDevice &operator=(ModbusServerDevice &&) = delete; + void set_parent(ModbusServerHub *parent) { this->parent_ = parent; } + void set_address(uint8_t address) { this->address_ = address; } + virtual void on_modbus_read_registers(uint8_t function_code, uint16_t start_address, uint16_t number_of_registers){}; + virtual void on_modbus_write_registers(uint8_t function_code, const std::vector &data){}; + void send(uint8_t function, const std::vector &payload) { + this->parent_->send(this->address_, function, payload); + } + void send_raw(const std::vector &payload) { + this->parent_->send_raw_(payload.data(), static_cast(payload.size())); + } + void send_error(uint8_t function_code, ModbusExceptionCode exception_code) { + uint8_t error_response[3] = {this->address_, uint8_t(function_code | FUNCTION_CODE_EXCEPTION_MASK), + static_cast(exception_code)}; + this->parent_->send_raw_(error_response, 3); + } + + protected: + friend ModbusServerHub; + + ModbusServerHub *parent_{nullptr}; + uint8_t address_{0}; }; } // namespace esphome::modbus diff --git a/esphome/components/modbus/modbus_definitions.h b/esphome/components/modbus/modbus_definitions.h index fb8c011259..49172b9dca 100644 --- a/esphome/components/modbus/modbus_definitions.h +++ b/esphome/components/modbus/modbus_definitions.h @@ -14,7 +14,8 @@ const uint8_t FUNCTION_CODE_USER_DEFINED_SPACE_2_INIT = 100; // 0x64 const uint8_t FUNCTION_CODE_USER_DEFINED_SPACE_2_END = 110; // 0x6E enum class ModbusFunctionCode : uint8_t { - CUSTOM = 0x00, + INVALID = 0x00, // 0x00 is not a valid function code (even for custom functions). + CUSTOM = 0x00, // The CUSTOM alias should be removed in future. READ_COILS = 0x01, READ_DISCRETE_INPUTS = 0x02, READ_HOLDING_REGISTERS = 0x03, @@ -35,19 +36,11 @@ enum class ModbusFunctionCode : uint8_t { READ_FIFO_QUEUE = 0x18, // not implemented }; -/*Allow comparison operators between ModbusFunctionCode and uint8_t*/ +/*Allow direct comparison operators between ModbusFunctionCode and uint8_t*/ inline bool operator==(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast(lhs) == rhs; } inline bool operator==(uint8_t lhs, ModbusFunctionCode rhs) { return lhs == static_cast(rhs); } inline bool operator!=(ModbusFunctionCode lhs, uint8_t rhs) { return !(static_cast(lhs) == rhs); } inline bool operator!=(uint8_t lhs, ModbusFunctionCode rhs) { return !(lhs == static_cast(rhs)); } -inline bool operator<(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast(lhs) < rhs; } -inline bool operator<(uint8_t lhs, ModbusFunctionCode rhs) { return lhs < static_cast(rhs); } -inline bool operator<=(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast(lhs) <= rhs; } -inline bool operator<=(uint8_t lhs, ModbusFunctionCode rhs) { return lhs <= static_cast(rhs); } -inline bool operator>(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast(lhs) > rhs; } -inline bool operator>(uint8_t lhs, ModbusFunctionCode rhs) { return lhs > static_cast(rhs); } -inline bool operator>=(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast(lhs) >= rhs; } -inline bool operator>=(uint8_t lhs, ModbusFunctionCode rhs) { return lhs >= static_cast(rhs); } // 4.3 MODBUS Data model enum class ModbusRegisterType : uint8_t { @@ -75,12 +68,21 @@ enum class ModbusExceptionCode : uint8_t { }; // 6.12 16 (0x10) Write Multiple registers: -const uint8_t MAX_NUM_OF_REGISTERS_TO_WRITE = 123; // 0x7B +static constexpr uint16_t MAX_NUM_OF_REGISTERS_TO_WRITE = 123; // 0x7B + +// 6.1 01 (0x01) Read Coils +// 6.2 02 (0x02) Read Discrete Inputs +static constexpr uint16_t MAX_NUM_OF_COILS_TO_READ = 2000; // 0x7D0 +static constexpr uint16_t MAX_NUM_OF_DISCRETE_INPUTS_TO_READ = 2000; // 0x7D0 // 6.3 03 (0x03) Read Holding Registers // 6.4 04 (0x04) Read Input Registers -const uint8_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D +static constexpr uint8_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D +// Smallest possible frame is 4 bytes (custom function with no data): address(1) + function(1) + CRC(2) +static constexpr uint16_t MIN_FRAME_SIZE = 4; +static constexpr uint16_t MAX_PDU_SIZE = 253; // Max PDU size is 256 - address(1) - CRC(2) = 253 +static constexpr uint16_t MAX_RAW_SIZE = 254; // Max RAW size is 255 - CRC(2) = 254 static constexpr uint16_t MAX_FRAME_SIZE = 256; /// End of Modbus definitions } // namespace esphome::modbus diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index 89dc3c08bc..4cddfca104 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -1,10 +1,83 @@ #include "modbus_helpers.h" #include "esphome/core/log.h" +#include + namespace esphome::modbus::helpers { static const char *const TAG = "modbus_helpers"; +uint16_t server_frame_length(const uint8_t *frame, size_t size) { + if (size < 2) + return MIN_FRAME_SIZE; + if (is_function_code_exception(frame[1])) { + return 5; // address(1) + function(1) + exception(1) + CRC(2) + } + switch (static_cast(frame[1])) { + case ModbusFunctionCode::READ_COILS: + case ModbusFunctionCode::READ_DISCRETE_INPUTS: + case ModbusFunctionCode::READ_HOLDING_REGISTERS: + case ModbusFunctionCode::READ_INPUT_REGISTERS: + // address(1) + function(1) + byte count(1) + data + CRC(2) + return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0); + case ModbusFunctionCode::WRITE_SINGLE_COIL: + case ModbusFunctionCode::WRITE_SINGLE_REGISTER: + case ModbusFunctionCode::WRITE_MULTIPLE_COILS: + case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS: + return 8; // address(1) + function(1) + output/register address(2) + value(2) + CRC(2) + // Unsupported function codes. Included here to prevent parser failures. Excluding Serial Line specific functions. + case ModbusFunctionCode::READ_FILE_RECORD: + case ModbusFunctionCode::WRITE_FILE_RECORD: + // address(1) + function(1) + byte count(1) + data + CRC(2) + return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_FRAME_SIZE - 5)) : 0); + case ModbusFunctionCode::MASK_WRITE_REGISTER: + return 10; // address(1) + function(1) + reference address(2) + AND mask(2) + OR mask(2) + CRC(2) + case ModbusFunctionCode::READ_WRITE_MULTIPLE_REGISTERS: + // address(1) + function(1) + byte count(1) + data + CRC(2) + return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0); + case ModbusFunctionCode::READ_FIFO_QUEUE: + // address(1) + function(1) + fifo address(2) CRC(2) + return 6; + default: + return MIN_FRAME_SIZE; // unknown length + } +} + +uint16_t client_frame_length(const uint8_t *frame, size_t size) { + if (size < 2) + return MIN_FRAME_SIZE; + switch (static_cast(frame[1])) { + case ModbusFunctionCode::READ_COILS: + case ModbusFunctionCode::READ_DISCRETE_INPUTS: + case ModbusFunctionCode::READ_HOLDING_REGISTERS: + case ModbusFunctionCode::READ_INPUT_REGISTERS: + // address(1) + function(1) + start address(2) + quantity(2) + CRC(2) + case ModbusFunctionCode::WRITE_SINGLE_COIL: + case ModbusFunctionCode::WRITE_SINGLE_REGISTER: + return 8; // address(1) + function(1) + output/register address(2) + value(2) + CRC(2) + case ModbusFunctionCode::WRITE_MULTIPLE_COILS: + case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS: + // address(1) + function(1) + start address(2) + quantity(2) + byte count(1) + data + CRC(2) + return 9 + (size > 6 ? std::min(frame[6], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE * 2)) : 0); + // Unsupported function codes. Included here to prevent parser failures. Excluding Serial Line specific functions. + case ModbusFunctionCode::READ_FILE_RECORD: + case ModbusFunctionCode::WRITE_FILE_RECORD: + // address(1) + function(1) + byte count(1) + data + CRC(2) + return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_FRAME_SIZE - 5)) : 0); + case ModbusFunctionCode::MASK_WRITE_REGISTER: + return 10; // address(1) + function(1) + reference address(2) + AND mask(2) + OR mask(2) + CRC(2) + case ModbusFunctionCode::READ_WRITE_MULTIPLE_REGISTERS: + // address(1) + function(1) + read start address(2) + read quantity(2) + write start address(2) + + // write quantity(2) + byte count(1) + data + CRC(2) + return 13 + (size > 10 ? std::min(frame[10], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE * 2)) : 0); + case ModbusFunctionCode::READ_FIFO_QUEUE: + // address(1) + function(1) + fifo address(2) CRC(2) + return 6; + default: + return MIN_FRAME_SIZE; // unknown length + } +} + static size_t required_payload_size(SensorValueType sensor_value_type) { switch (sensor_value_type) { case SensorValueType::U_WORD: @@ -67,7 +140,7 @@ void number_to_payload(std::vector &data, int64_t value, SensorValueTy } int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, - uint32_t bitmask) { + uint32_t bitmask, bool *error_return) { int64_t value = 0; // int64_t because it can hold signed and unsigned 32 bits // Validate offset against the buffer for all types, including RAW/unsupported, so @@ -75,6 +148,8 @@ int64_t payload_to_number(const std::vector &data, SensorValueType sens if (static_cast(offset) > data.size()) { ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu", static_cast(sensor_value_type), static_cast(offset), data.size()); + if (error_return) + *error_return = true; return value; } @@ -87,6 +162,8 @@ int64_t payload_to_number(const std::vector &data, SensorValueType sens ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu required=%zu", static_cast(sensor_value_type), static_cast(offset), data.size(), required_size); + if (error_return) + *error_return = true; return value; } @@ -136,4 +213,102 @@ int64_t payload_to_number(const std::vector &data, SensorValueType sens } return value; } + +StaticVector create_client_pdu(ModbusFunctionCode function_code, uint16_t start_address, + uint16_t number_of_entities, const uint8_t *values, + size_t values_len) { + if (is_function_code_read(static_cast(function_code))) { + if (values != nullptr || values_len > 0) { + ESP_LOGW(TAG, "Values provided for read function code %02X, but will be ignored", + static_cast(function_code)); + } + } else if (is_function_code_write(static_cast(function_code))) { + if (values == nullptr || values_len == 0) { + ESP_LOGE(TAG, "No values provided for write function code %02X", static_cast(function_code)); + return {}; + } + } else { + ESP_LOGE(TAG, "Unsupported function code %02X for client PDU creation", static_cast(function_code)); + return {}; + } + + if (number_of_entities == 0) { + ESP_LOGE(TAG, "Number of entities is zero for function code %02X", static_cast(function_code)); + return {}; + } + + switch (function_code) { + case ModbusFunctionCode::READ_COILS: + if (number_of_entities > MAX_NUM_OF_COILS_TO_READ) { + ESP_LOGE(TAG, "number_of_entities %u exceeds maximum coils to read %u for function code %02X", + number_of_entities, MAX_NUM_OF_COILS_TO_READ, static_cast(function_code)); + return {}; + } + break; + case ModbusFunctionCode::READ_DISCRETE_INPUTS: + if (number_of_entities > MAX_NUM_OF_DISCRETE_INPUTS_TO_READ) { + ESP_LOGE(TAG, "number_of_entities %u exceeds maximum discrete inputs to read %u for function code %02X", + number_of_entities, MAX_NUM_OF_DISCRETE_INPUTS_TO_READ, static_cast(function_code)); + return {}; + } + break; + case ModbusFunctionCode::READ_HOLDING_REGISTERS: + case ModbusFunctionCode::READ_INPUT_REGISTERS: + if (number_of_entities > MAX_NUM_OF_REGISTERS_TO_READ) { + ESP_LOGE(TAG, "number_of_entities %u exceeds maximum registers to read %u for function code %02X", + number_of_entities, MAX_NUM_OF_REGISTERS_TO_READ, static_cast(function_code)); + return {}; + } + break; + case ModbusFunctionCode::WRITE_SINGLE_COIL: + case ModbusFunctionCode::WRITE_SINGLE_REGISTER: + break; // number_of_entities is ignored for single write, so no need to validate + case ModbusFunctionCode::WRITE_MULTIPLE_COILS: + case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS: + if (number_of_entities > MAX_NUM_OF_REGISTERS_TO_WRITE) { + ESP_LOGE(TAG, "number_of_entities %u exceeds maximum registers to write %u for function code %02X", + number_of_entities, MAX_NUM_OF_REGISTERS_TO_WRITE, static_cast(function_code)); + return {}; + } + break; + default: + ESP_LOGE(TAG, "Unsupported function code %u for client PDU creation", static_cast(function_code)); + return {}; + } + + StaticVector pdu; + pdu.push_back(static_cast(function_code)); + pdu.push_back(start_address >> 8); + pdu.push_back(start_address >> 0); + if (function_code != ModbusFunctionCode::WRITE_SINGLE_COIL && + function_code != ModbusFunctionCode::WRITE_SINGLE_REGISTER) { + pdu.push_back(number_of_entities >> 8); + pdu.push_back(number_of_entities >> 0); + } + + if (is_function_code_write(static_cast(function_code))) { + if (function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS || + function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { + // 6 bytes of overhead (fc + start_addr×2 + qty×2 + byte_count) leave MAX_PDU_SIZE-6 bytes for values + static constexpr size_t MAX_WRITE_MULTIPLE_VALUES_LEN = MAX_PDU_SIZE - 6; + if (values_len > MAX_WRITE_MULTIPLE_VALUES_LEN) { + ESP_LOGE(TAG, "values_len %zu exceeds PDU capacity %zu, dropping request", values_len, + MAX_WRITE_MULTIPLE_VALUES_LEN); + return {}; + } + pdu.push_back(values_len); // Byte count is required for write multiple + for (size_t i = 0; i < values_len; i++) + pdu.push_back(values[i]); + } else { + // Write single register or coil (2 bytes) + if (values_len < 2) { + ESP_LOGE(TAG, "values_len %zu too small for write-single command (need 2), dropping request", values_len); + return {}; + } + pdu.push_back(values[0]); + pdu.push_back(values[1]); + } + } + return pdu; +} } // namespace esphome::modbus::helpers diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index 84897bcad3..b637d872cf 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -9,6 +9,58 @@ namespace esphome::modbus::helpers { +inline bool is_function_code_read(uint8_t function_code) { + ModbusFunctionCode masked_function_code = static_cast(function_code & FUNCTION_CODE_MASK); + return masked_function_code == ModbusFunctionCode::READ_COILS || + masked_function_code == ModbusFunctionCode::READ_DISCRETE_INPUTS || + masked_function_code == ModbusFunctionCode::READ_HOLDING_REGISTERS || + masked_function_code == ModbusFunctionCode::READ_INPUT_REGISTERS; +} + +inline bool is_function_code_write(uint8_t function_code) { + ModbusFunctionCode masked_function_code = static_cast(function_code & FUNCTION_CODE_MASK); + return masked_function_code == ModbusFunctionCode::WRITE_SINGLE_COIL || + masked_function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER || + masked_function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS || + masked_function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS; +} + +inline bool is_function_code_exception(uint8_t function_code) { + return (static_cast(function_code) & FUNCTION_CODE_EXCEPTION_MASK) != 0; +} + +inline bool is_function_code_custom(uint8_t function_code) { + uint8_t masked_function_code = function_code & FUNCTION_CODE_MASK; + return (masked_function_code >= FUNCTION_CODE_USER_DEFINED_SPACE_1_INIT && + masked_function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_1_END) || + (masked_function_code >= FUNCTION_CODE_USER_DEFINED_SPACE_2_INIT && + masked_function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_2_END); +} + +// Returns the expected length of a server response frame based on the function code +// If the frame is too short to determine the length, returns the minimum length +uint16_t server_frame_length(const uint8_t *frame, size_t size); + +// Returns the expected length of a client request frame based on the function code +// If the frame is too short to determine the length, returns the minimum length +uint16_t client_frame_length(const uint8_t *frame, size_t size); + +inline uint8_t server_frame_data_offset(const uint8_t *frame, size_t size) { + if (size < 2) + return 0; + switch (static_cast(frame[1])) { + case ModbusFunctionCode::READ_COILS: + case ModbusFunctionCode::READ_DISCRETE_INPUTS: + case ModbusFunctionCode::READ_HOLDING_REGISTERS: + case ModbusFunctionCode::READ_INPUT_REGISTERS: + return 3; // address(1) + function(1) + byte count(1) + data + CRC(2) + default: + return 2; + } +} + +inline uint8_t client_frame_data_offset(const uint8_t *, size_t) { return 2; } + enum class SensorValueType : uint8_t { RAW = 0x00, // variable length U_WORD = 0x1, // 1 Register unsigned @@ -41,21 +93,21 @@ inline ModbusFunctionCode modbus_register_read_function(ModbusRegisterType reg_t case ModbusRegisterType::READ: return ModbusFunctionCode::READ_INPUT_REGISTERS; default: - return ModbusFunctionCode::CUSTOM; + return ModbusFunctionCode::INVALID; } } -inline ModbusFunctionCode modbus_register_write_function(ModbusRegisterType reg_type) { +inline ModbusFunctionCode modbus_register_write_function(ModbusRegisterType reg_type, bool multiple = false) { switch (reg_type) { case ModbusRegisterType::COIL: - return ModbusFunctionCode::WRITE_SINGLE_COIL; - case ModbusRegisterType::DISCRETE_INPUT: - return ModbusFunctionCode::CUSTOM; + return multiple ? ModbusFunctionCode::WRITE_MULTIPLE_COILS : ModbusFunctionCode::WRITE_SINGLE_COIL; case ModbusRegisterType::HOLDING: - return ModbusFunctionCode::READ_WRITE_MULTIPLE_REGISTERS; + return multiple ? ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS : ModbusFunctionCode::WRITE_SINGLE_REGISTER; + // These register types can't be written (per spec) case ModbusRegisterType::READ: + case ModbusRegisterType::DISCRETE_INPUT: default: - return ModbusFunctionCode::CUSTOM; + return ModbusFunctionCode::INVALID; } } @@ -112,31 +164,31 @@ inline uint64_t qword_from_hex_str(const std::string &value, uint8_t pos) { * @param buffer_offset offset in bytes. * @return value of type T extracted from buffer */ -template T get_data(const std::vector &data, size_t buffer_offset) { +template T get_data(const uint8_t *data, size_t buffer_offset) { if (sizeof(T) == sizeof(uint8_t)) { return T(data[buffer_offset]); } if (sizeof(T) == sizeof(uint16_t)) { return T((uint16_t(data[buffer_offset + 0]) << 8) | (uint16_t(data[buffer_offset + 1]) << 0)); } - if (sizeof(T) == sizeof(uint32_t)) { return static_cast(get_data(data, buffer_offset)) << 16 | static_cast(get_data(data, buffer_offset + 2)); } - if (sizeof(T) == sizeof(uint64_t)) { return static_cast(get_data(data, buffer_offset)) << 32 | (static_cast(get_data(data, buffer_offset + 4))); } - static_assert(sizeof(T) == sizeof(uint8_t) || sizeof(T) == sizeof(uint16_t) || sizeof(T) == sizeof(uint32_t) || sizeof(T) == sizeof(uint64_t), "Unsupported type size in get_data; only 1, 2, 4, or 8-byte integer types are supported."); - return T{}; } +template T get_data(const std::vector &data, size_t buffer_offset) { + return get_data(data.data(), buffer_offset); +} + /** Extract coil data from modbus response buffer * Responses for coil are packed into bytes . * coil 3 is bit 3 of the first response byte @@ -188,7 +240,27 @@ void number_to_payload(std::vector &data, int64_t value, SensorValueTy * @return 64-bit number of the payload */ int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, - uint32_t bitmask); + uint32_t bitmask, bool *error_return = nullptr); + +/** Create a modbus clinet pdu for reading/writing single/multiple coils/register/inputs. + * @param function_code the modbus function code to use. One of: + * READ_COILS + * READ_DISCRETE_INPUTS + * READ_HOLDING_REGISTERS + * READ_INPUT_REGISTERS + * WRITE_SINGLE_COIL + * WRITE_SINGLE_REGISTER + * WRITE_MULTIPLE_COILS + * WRITE_MULTIPLE_REGISTERS + * @param start_address coil/register/input starting address + * @param number_of_entities number of coils/registers/inputs to read/write + * @param values optional payload bytes to write (nullptr for read commands) + * @param values_len length of values array + * @return PDU (function code + data, no address, no CRC) + */ +StaticVector create_client_pdu(ModbusFunctionCode function_code, uint16_t start_address, + uint16_t number_of_entities, const uint8_t *values = nullptr, + size_t values_len = 0); inline std::vector float_to_payload(float value, SensorValueType value_type) { int64_t val; diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 6604276cc2..9246239ef9 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -201,7 +201,7 @@ void ModbusController::update() { // walk through the sensors and determine the register ranges to read size_t ModbusController::create_register_ranges_() { this->register_ranges_.clear(); - if (this->parent_->role == modbus::ModbusRole::CLIENT && this->sensorset_.empty()) { + if (this->sensorset_.empty()) { ESP_LOGW(TAG, "No sensors registered"); return 0; } diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index ba86c2cd16..4f674b2675 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -279,7 +279,7 @@ class ModbusCommandItem { * Responses for the commands are dispatched to the modbus sensor items. */ -class ModbusController : public PollingComponent, public modbus::ModbusDevice { +class ModbusController : public PollingComponent, public modbus::ModbusClientDevice { public: void dump_config() override; void loop() override; diff --git a/esphome/components/modbus_server/__init__.py b/esphome/components/modbus_server/__init__.py index 5182bc05d1..2ba7f41b83 100644 --- a/esphome/components/modbus_server/__init__.py +++ b/esphome/components/modbus_server/__init__.py @@ -27,7 +27,7 @@ MULTI_CONF = True modbus_server_ns = cg.esphome_ns.namespace("modbus_server") ModbusServer = modbus_server_ns.class_( - "ModbusServer", cg.Component, modbus.ModbusDevice + "ModbusServer", cg.Component, modbus.ModbusServerDevice ) ServerCourtesyResponse = modbus_server_ns.struct("ServerCourtesyResponse") @@ -44,7 +44,7 @@ SERVER_COURTESY_RESPONSE_SCHEMA = cv.Schema( ModbusServerRegisterSchema = cv.Schema( { cv.GenerateID(): cv.declare_id(ServerRegister), - cv.Required(CONF_ADDRESS): cv.positive_int, + cv.Required(CONF_ADDRESS): cv.hex_uint16_t, 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, @@ -61,7 +61,7 @@ CONFIG_SCHEMA = cv.All( CONF_REGISTERS, ): cv.ensure_list(ModbusServerRegisterSchema), } - ).extend(modbus.modbus_device_schema(0x01)), + ).extend(modbus.modbus_device_schema(0x01, role="server")), ) @@ -119,6 +119,5 @@ async def to_code(config): ) ) 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) + return await modbus.register_modbus_server_device(var, config) diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp index e5ea2efa4d..c294d08888 100644 --- a/esphome/components/modbus_server/modbus_server.cpp +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -5,6 +5,7 @@ namespace esphome::modbus_server { using modbus::ModbusFunctionCode; using modbus::ModbusExceptionCode; +using modbus::helpers::payload_to_number; static const char *const TAG = "modbus_server"; @@ -16,7 +17,7 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star 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); + ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 ". Sending exception response.", number_of_registers); this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); return; } @@ -30,9 +31,10 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star break; } int64_t value = server_register->read_lambda(); + char value_buf[ServerRegister::FORMAT_VALUE_BUF_SIZE]; ESP_LOGV(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()); + server_register->register_count, server_register->format_value(value, value_buf, sizeof(value_buf))); std::vector payload; payload.reserve(server_register->register_count * 2); @@ -49,7 +51,7 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star (current_address <= this->server_courtesy_response_.register_last_address)) { ESP_LOGV(TAG, "Could not match any register to address 0x%02X, but default allowed. " - "Returning default value: %d.", + "Returning default value: %" PRIu16 ".", 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 @@ -64,20 +66,22 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star } std::vector response; + if (number_of_registers != sixteen_bit_response.size()) + ESP_LOGW(TAG, "Response size not matched to request register count."); + response.push_back(sixteen_bit_response.size() * 2); // actual byte count 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()); + this->send(function_code, response); } 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 (static_cast(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); @@ -85,13 +89,15 @@ void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::v } 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); + ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 ". 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.", + ESP_LOGW(TAG, + "Payload size of %" PRIu16 " bytes is not 2 times the number of registers (%" PRIu16 + "). Sending exception response.", payload_size, number_of_registers); this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); return; @@ -103,7 +109,7 @@ void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::v return; } payload_offset = 5; - } else if (function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER) { + } else if (static_cast(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); @@ -148,15 +154,22 @@ void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::v 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); + ESP_LOGW(TAG, "Invalid register address. Sending exception response."); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); 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); + bool error = false; + int64_t number = payload_to_number(data, server_register->value_type, offset, 0xFFFFFFFF, &error); + if (error) { + return false; + } else { + return server_register->write_lambda(number); + } })) { + ESP_LOGW(TAG, "Could not write all registers. Sending exception response."); this->send_error(function_code, ModbusExceptionCode::SERVICE_DEVICE_FAILURE); return; } diff --git a/esphome/components/modbus_server/modbus_server.h b/esphome/components/modbus_server/modbus_server.h index 0fc2e0bef5..fa1376542c 100644 --- a/esphome/components/modbus_server/modbus_server.h +++ b/esphome/components/modbus_server/modbus_server.h @@ -52,32 +52,34 @@ class ServerRegister { }; } - // 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]; + // 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 + static constexpr size_t FORMAT_VALUE_BUF_SIZE = 44; + + // Formats a raw value into a caller-provided buffer based on the value type for debugging. + // Returns buf for convenience. + const char *format_value(int64_t value, char *buf, size_t buf_size) const { 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)); + buf_append_printf(buf, buf_size, 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); + buf_append_printf(buf, buf_size, 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))); + buf_append_printf(buf, buf_size, 0, "%.1f", bit_cast(static_cast(value))); return buf; default: - buf_append_printf(buf, sizeof(buf), 0, "%" PRId64, value); + buf_append_printf(buf, buf_size, 0, "%" PRId64, value); return buf; } } @@ -89,12 +91,10 @@ class ServerRegister { WriteLambda write_lambda; }; -class ModbusServer : public Component, public modbus::ModbusDevice { +class ModbusServer : public Component, public modbus::ModbusServerDevice { 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 diff --git a/tests/components/modbus/modbus_helpers_test.cpp b/tests/components/modbus/modbus_helpers_test.cpp index e1b4fb2aa6..cd260f410a 100644 --- a/tests/components/modbus/modbus_helpers_test.cpp +++ b/tests/components/modbus/modbus_helpers_test.cpp @@ -4,6 +4,181 @@ namespace esphome::modbus::helpers { +using FC = ModbusFunctionCode; + +// --- server_frame_length --------------------------------------------------- +// Frame layout: address(1) + function(1) + ... + CRC(2). Fixtures borrowed from +// tests/integration/fixtures/uart_mock_modbus.yaml. + +TEST(ModbusServerFrameLength, TooShortReturnsMinimum) { + const uint8_t frame[] = {0x01}; + EXPECT_EQ(server_frame_length(frame, 1), MIN_FRAME_SIZE); +} + +TEST(ModbusServerFrameLength, ReadHoldingUsesByteCount) { + // inject_rx for basic_register: 2 data bytes -> 5 + 2 = 7 + const uint8_t frame[] = {0x01, 0x03, 0x02, 0x01, 0x03, 0xF9, 0xD5}; + EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 7); +} + +TEST(ModbusServerFrameLength, ReadByteCountCappedAtMax) { + const uint8_t frame[] = {0x01, 0x03, 0xFF}; // claim 255 bytes + EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 5 + MAX_NUM_OF_REGISTERS_TO_READ * 2); +} + +TEST(ModbusServerFrameLength, ReadMissingByteCountReturnsHeaderOnly) { + const uint8_t frame[] = {0x01, 0x03}; + EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 5); +} + +TEST(ModbusServerFrameLength, ExceptionResponse) { + // exception_response fixture: function code 0x83 has the exception bit set + const uint8_t frame[] = {0x01, 0x83, 0x02, 0xC0, 0xF1}; + EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 5); +} + +TEST(ModbusServerFrameLength, WriteResponsesAreFixed) { + for (FC fc : + {FC::WRITE_SINGLE_COIL, FC::WRITE_SINGLE_REGISTER, FC::WRITE_MULTIPLE_COILS, FC::WRITE_MULTIPLE_REGISTERS}) { + const uint8_t frame[] = {0x01, static_cast(fc)}; + EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 8) << "fc=" << static_cast(fc); + } +} + +TEST(ModbusServerFrameLength, MiscFixedAndUnknown) { + const uint8_t mask[] = {0x01, static_cast(FC::MASK_WRITE_REGISTER)}; + const uint8_t fifo[] = {0x01, static_cast(FC::READ_FIFO_QUEUE)}; + const uint8_t unknown[] = {0x01, 0x42}; + EXPECT_EQ(server_frame_length(mask, sizeof(mask)), 10); + EXPECT_EQ(server_frame_length(fifo, sizeof(fifo)), 6); + EXPECT_EQ(server_frame_length(unknown, sizeof(unknown)), MIN_FRAME_SIZE); +} + +// --- client_frame_length --------------------------------------------------- + +TEST(ModbusClientFrameLength, TooShortReturnsMinimum) { + const uint8_t frame[] = {0x01}; + EXPECT_EQ(client_frame_length(frame, 1), MIN_FRAME_SIZE); +} + +TEST(ModbusClientFrameLength, ReadAndWriteSingleAreFixed) { + // basic_register request fixture is a read-holding request -> 8 bytes + const uint8_t read[] = {0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A}; + EXPECT_EQ(client_frame_length(read, sizeof(read)), 8); + for (FC fc : {FC::READ_COILS, FC::READ_DISCRETE_INPUTS, FC::READ_INPUT_REGISTERS, FC::WRITE_SINGLE_COIL, + FC::WRITE_SINGLE_REGISTER}) { + const uint8_t frame[] = {0x01, static_cast(fc)}; + EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 8) << "fc=" << static_cast(fc); + } +} + +TEST(ModbusClientFrameLength, WriteMultipleUsesByteCount) { + // write 2 registers (4 data bytes): addr(2)+qty(2)+count(1) then data; count is frame[6] + const uint8_t frame[] = {0x01, 0x10, 0x00, 0x00, 0x00, 0x02, 0x04, 0x00, 0x0B, 0x00, 0x16}; + EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9 + 4); +} + +TEST(ModbusClientFrameLength, WriteMultipleByteCountCapped) { + const uint8_t frame[] = {0x01, 0x0F, 0x00, 0x00, 0x00, 0x02, 0xFF}; + EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9 + MAX_NUM_OF_REGISTERS_TO_WRITE * 2); +} + +TEST(ModbusClientFrameLength, WriteMultipleMissingByteCount) { + const uint8_t frame[] = {0x01, 0x10, 0x00, 0x00, 0x00, 0x02}; + EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9); +} + +TEST(ModbusClientFrameLength, MiscFixedAndUnknown) { + const uint8_t mask[] = {0x01, static_cast(FC::MASK_WRITE_REGISTER)}; + const uint8_t fifo[] = {0x01, static_cast(FC::READ_FIFO_QUEUE)}; + const uint8_t unknown[] = {0x01, 0x42}; + EXPECT_EQ(client_frame_length(mask, sizeof(mask)), 10); + EXPECT_EQ(client_frame_length(fifo, sizeof(fifo)), 6); + EXPECT_EQ(client_frame_length(unknown, sizeof(unknown)), MIN_FRAME_SIZE); +} + +// --- create_client_pdu ----------------------------------------------------- +// PDU = function code + data (no address, no CRC). + +TEST(ModbusCreateClientPdu, ReadHolding) { + auto pdu = create_client_pdu(FC::READ_HOLDING_REGISTERS, 0x0003, 1); + const std::vector expected{0x03, 0x00, 0x03, 0x00, 0x01}; + EXPECT_EQ(std::vector(pdu.begin(), pdu.end()), expected); +} + +TEST(ModbusCreateClientPdu, WriteSingleOmitsQuantity) { + const uint8_t values[] = {0x00, 0x0B}; + auto pdu = create_client_pdu(FC::WRITE_SINGLE_REGISTER, 0x0003, 1, values, sizeof(values)); + const std::vector expected{0x06, 0x00, 0x03, 0x00, 0x0B}; + EXPECT_EQ(std::vector(pdu.begin(), pdu.end()), expected); +} + +TEST(ModbusCreateClientPdu, WriteSingleTooFewValuesReturnsEmpty) { + const uint8_t values[] = {0x00}; + auto pdu = create_client_pdu(FC::WRITE_SINGLE_COIL, 0x0003, 1, values, sizeof(values)); + EXPECT_TRUE(pdu.empty()); +} + +TEST(ModbusCreateClientPdu, WriteMultipleIncludesByteCount) { + const uint8_t values[] = {0x00, 0x0B, 0x00, 0x16}; + auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 2, values, sizeof(values)); + const std::vector expected{0x10, 0x00, 0x00, 0x00, 0x02, 0x04, 0x00, 0x0B, 0x00, 0x16}; + EXPECT_EQ(std::vector(pdu.begin(), pdu.end()), expected); +} + +TEST(ModbusCreateClientPdu, WriteMultipleOverCapacityReturnsEmpty) { + std::vector values(MAX_PDU_SIZE - 6 + 1, 0xAA); + auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 1, values.data(), values.size()); + EXPECT_TRUE(pdu.empty()); +} + +TEST(ModbusCreateClientPdu, UnsupportedFunctionCodeReturnsEmpty) { + auto pdu = create_client_pdu(FC::READ_FIFO_QUEUE, 0x0000, 1); + EXPECT_TRUE(pdu.empty()); +} + +TEST(ModbusCreateClientPdu, ZeroEntitiesReturnsEmpty) { + auto pdu = create_client_pdu(FC::READ_HOLDING_REGISTERS, 0x0000, 0); + EXPECT_TRUE(pdu.empty()); +} + +TEST(ModbusCreateClientPdu, WriteWithoutValuesReturnsEmpty) { + auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 1, nullptr, 0); + EXPECT_TRUE(pdu.empty()); +} + +TEST(ModbusCreateClientPdu, ReadHoldingOverMaxReturnsEmpty) { + auto pdu = create_client_pdu(FC::READ_HOLDING_REGISTERS, 0x0000, MAX_NUM_OF_REGISTERS_TO_READ + 1); + EXPECT_TRUE(pdu.empty()); +} + +// Regression: coils allow up to 2000 entities, well above the 125 register limit. +// A switch fall-through previously subjected coil/discrete reads to the register limit. +TEST(ModbusCreateClientPdu, ReadCoilsAboveRegisterLimitIsValid) { + const uint16_t quantity = MAX_NUM_OF_REGISTERS_TO_READ + 1; // 126: valid for coils, too many for registers + auto pdu = create_client_pdu(FC::READ_COILS, 0x0000, quantity); + const std::vector expected{0x01, 0x00, 0x00, static_cast(quantity >> 8), + static_cast(quantity & 0xFF)}; + EXPECT_EQ(std::vector(pdu.begin(), pdu.end()), expected); +} + +TEST(ModbusCreateClientPdu, ReadCoilsOverMaxReturnsEmpty) { + auto pdu = create_client_pdu(FC::READ_COILS, 0x0000, MAX_NUM_OF_COILS_TO_READ + 1); + EXPECT_TRUE(pdu.empty()); +} + +TEST(ModbusCreateClientPdu, ReadDiscreteInputsOverMaxReturnsEmpty) { + auto pdu = create_client_pdu(FC::READ_DISCRETE_INPUTS, 0x0000, MAX_NUM_OF_DISCRETE_INPUTS_TO_READ + 1); + EXPECT_TRUE(pdu.empty()); +} + +TEST(ModbusCreateClientPdu, WriteMultipleOverEntityLimitReturnsEmpty) { + const uint8_t values[] = {0x00, 0x0B}; + auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, MAX_NUM_OF_REGISTERS_TO_WRITE + 1, values, + sizeof(values)); + EXPECT_TRUE(pdu.empty()); +} + TEST(ModbusHelpersTest, PayloadToNumberRejectsOffsetAtEndOfBuffer) { const std::vector data{0x12, 0x34}; EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 2, 0xFFFFFFFF), 0); diff --git a/tests/components/modbus/modbus_test.cpp b/tests/components/modbus/modbus_test.cpp deleted file mode 100644 index afe5ced082..0000000000 --- a/tests/components/modbus/modbus_test.cpp +++ /dev/null @@ -1,59 +0,0 @@ -#include -#include "esphome/components/modbus/modbus.h" -#include "esphome/core/helpers.h" - -namespace esphome::modbus { - -// Exposes protected methods for testing. -class TestModbus : public Modbus { - public: - bool test_parse_modbus_byte(uint8_t byte) { return this->parse_modbus_byte_(byte); } - void test_clear_rx_buffer() { this->rx_buffer_.clear(); } - void set_waiting(uint8_t addr) { this->waiting_for_response_ = addr; } -}; - -class MockDevice : public ModbusDevice { - public: - void on_modbus_data(const std::vector &data) override { this->data_received = true; } - bool data_received{false}; -}; - -TEST(ModbusTest, TwoByteRegressionTest) { - TestModbus modbus; - modbus.set_role(ModbusRole::CLIENT); - // First byte (at=0) - EXPECT_TRUE(modbus.test_parse_modbus_byte(0x01)); - // Second byte (at=1) - // This used to reach raw[2] because it skipped the if(at==2) check, causing a - // buffer overflow. - EXPECT_TRUE(modbus.test_parse_modbus_byte(0x03)); -} - -TEST(ModbusTest, TestValidFrame) { - TestModbus modbus; - modbus.set_role(ModbusRole::CLIENT); - - MockDevice device; - device.set_parent(&modbus); - device.set_address(0x01); - modbus.register_device(&device); - modbus.set_waiting(0x01); - - // Address 1, Function 3, Length 2, Data 0x1234 - uint8_t frame_data[] = {0x01, 0x03, 0x02, 0x12, 0x34}; - uint16_t crc = esphome::crc16(frame_data, sizeof(frame_data)); - - std::vector frame; - for (uint8_t b : frame_data) - frame.push_back(b); - frame.push_back(crc & 0xFF); - frame.push_back((crc >> 8) & 0xFF); - - for (size_t i = 0; i < frame.size(); i++) { - bool result = modbus.test_parse_modbus_byte(frame[i]); - EXPECT_TRUE(result) << "Failed at byte " << i << " (0x" << std::hex << (int) frame[i] << ")"; - } - EXPECT_TRUE(device.data_received); -} - -} // namespace esphome::modbus diff --git a/tests/integration/fixtures/uart_mock_modbus.yaml b/tests/integration/fixtures/uart_mock_modbus.yaml index da36da4de1..7e2bcff3ef 100644 --- a/tests/integration/fixtures/uart_mock_modbus.yaml +++ b/tests/integration/fixtures/uart_mock_modbus.yaml @@ -49,15 +49,16 @@ modbus_controller: - address: 1 id: modbus_controller_ok max_cmd_retries: 2 - update_interval: 1s + # Update interval is set to never to prevent automatic polling: the test will trigger requests by pressing the "Start Scenario" button + update_interval: never - address: 2 id: modbus_controller_slow max_cmd_retries: 0 - update_interval: 1s + update_interval: never - address: 3 id: modbus_controller_offline max_cmd_retries: 0 - update_interval: 1s + update_interval: never sensor: - platform: modbus_controller @@ -91,4 +92,11 @@ button: name: "Start Scenario" id: start_scenario_btn on_press: - - lambda: "id(virtual_uart_dev).start_scenario();" + - lambda: |- + id(virtual_uart_dev).start_scenario(); + id(modbus_controller_ok).set_update_interval(1000); + id(modbus_controller_ok).start_poller(); + id(modbus_controller_slow).set_update_interval(1000); + id(modbus_controller_slow).start_poller(); + id(modbus_controller_offline).set_update_interval(1000); + id(modbus_controller_offline).start_poller(); diff --git a/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml b/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml index 9bc4dc50e9..5a7c9b74dc 100644 --- a/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml @@ -54,7 +54,11 @@ modbus: sensor: - platform: sdm_meter address: 2 - update_interval: 1s + id: sdm_meter_1 + # update_interval is set to never to avoid automatic polling before the test starts the scenario. + # The test will manually start the poller after subscribing to states, to ensure no state changes are missed. + # This also allows us to assert there are no modbus errors/warnings during the initial request/response. + update_interval: never phase_a: voltage: name: sdm_voltage @@ -64,4 +68,7 @@ button: name: "Start Scenario" id: start_scenario_btn on_press: - - lambda: "id(virtual_uart_dev).start_scenario();" + - lambda: |- + id(virtual_uart_dev).start_scenario(); + id(sdm_meter_1).set_update_interval(1000); + id(sdm_meter_1).start_poller(); diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml index 1e5f5a3389..20306bd73a 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml @@ -53,8 +53,8 @@ modbus: modbus_controller: - address: 1 modbus_id: virtual_modbus_controller - update_interval: 1s id: modbus_controller_1 + update_interval: 1s modbus_server: - address: 1 @@ -176,6 +176,4 @@ button: - platform: template name: "Start Scenario" id: start_scenario_btn - on_press: - - lambda: "id(virtual_uart_server).start_scenario();" - - lambda: "id(virtual_uart_controller).start_scenario();" + # This test does not have anything to start (mock is autostart) 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 e68edd2271..18423be6d5 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml @@ -113,7 +113,4 @@ button: - platform: template name: "Start Scenario" id: start_scenario_btn - on_press: - - lambda: "id(virtual_uart_server).start_scenario();" - - lambda: "id(virtual_uart_server_2).start_scenario();" - - lambda: "id(virtual_uart_controller).start_scenario();" + # This test does not have anything to start (mock is autostart) 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 94890e90de..b3b5e76e31 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml @@ -326,6 +326,4 @@ button: - platform: template name: "Start Scenario" id: start_scenario_btn - on_press: - - lambda: "id(virtual_uart_server).start_scenario();" - - lambda: "id(virtual_uart_controller).start_scenario();" + # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/fixtures/uart_mock_modbus_timing.yaml b/tests/integration/fixtures/uart_mock_modbus_timing.yaml index c670864085..c62e0188bb 100644 --- a/tests/integration/fixtures/uart_mock_modbus_timing.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_timing.yaml @@ -53,7 +53,11 @@ modbus: sensor: - platform: sdm_meter address: 2 - update_interval: 1s + id: sdm_meter_1 + # update_interval is set to never to avoid automatic polling before the test starts the scenario. + # The test will manually start the poller after subscribing to states, to ensure no state changes are missed. + # This also allows us to assert there are no modbus errors/warnings during the initial request/response. + update_interval: never phase_a: voltage: name: sdm_voltage @@ -63,4 +67,7 @@ button: name: "Start Scenario" id: start_scenario_btn on_press: - - lambda: "id(virtual_uart_dev).start_scenario();" + - lambda: |- + id(virtual_uart_dev).start_scenario(); + id(sdm_meter_1).set_update_interval(1000); + id(sdm_meter_1).start_poller(); diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index e8dfa1b822..2c437341c6 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -127,15 +127,18 @@ async def test_uart_mock_modbus_timing( ) -> None: """Test modbus timing with multi-register SDM meter response.""" + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + tracker = SensorTracker(["sdm_voltage"]) voltage_changed = tracker.expect_any("sdm_voltage") async with ( - run_compiled(yaml_config), + run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): await tracker.setup_and_start_scenario(client) await tracker.await_change(voltage_changed, "sdm_voltage") + _assert_no_modbus_errors(error_log_lines, warning_log_lines) @pytest.mark.asyncio @@ -148,26 +151,25 @@ async def test_uart_mock_modbus_no_threshold( Without the 50ms fallback timeout, the chunked response with a 40ms gap between USB packets would cause a false timeout and CRC failure cascade. - Bus-level warnings (CRC failures, buffer clears) are expected during - chunked reassembly — the test only verifies the final value arrives. + Bus-level warnings (CRC/parse failures, buffer clears) are NOT expected during + chunked reassembly, if timeouts are set properly — these warnings indicate undersized timeouts. """ + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + tracker = SensorTracker(["sdm_voltage"]) voltage_changed = tracker.expect_any("sdm_voltage") async with ( - run_compiled(yaml_config), + run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): await tracker.setup_and_start_scenario(client) await tracker.await_change(voltage_changed, "sdm_voltage") + _assert_no_modbus_errors(error_log_lines, warning_log_lines) @pytest.mark.asyncio -@pytest.mark.xfail( - reason="Modbus parser cannot handle server responses from other devices on the bus. Fix tracked in PR #11969.", - strict=True, -) async def test_uart_mock_modbus_server( yaml_config: str, run_compiled: RunCompiledFunction, @@ -308,10 +310,6 @@ async def test_uart_mock_modbus_server_controller_write( @pytest.mark.asyncio -@pytest.mark.xfail( - reason="Modbus parser cannot handle server responses from other devices on the bus. Fix tracked in PR #11969.", - strict=True, -) async def test_uart_mock_modbus_server_controller_multiple( yaml_config: str, run_compiled: RunCompiledFunction, From 1bd937d89c91050a9f77a735b2d31f54e1e7d327 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 14:44:00 -0500 Subject: [PATCH 0502/1815] [api] Remove pre-1.14 object_id backward-compat code (#17108) --- esphome/components/api/api_connection.cpp | 12 +----------- esphome/components/api/api_connection.h | 12 +----------- 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 2b1458e2ae..acdf24e747 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -375,7 +375,7 @@ void APIConnection::finalize_iterator_sync_() { void APIConnection::process_iterator_batch_(ComponentIterator &iterator) { size_t initial_size = this->deferred_batch_.size(); - size_t max_batch = this->get_max_batch_size_(); + size_t max_batch = MAX_INITIAL_PER_BATCH; while (!iterator.completed() && (this->deferred_batch_.size() - initial_size) < max_batch) { iterator.advance(); } @@ -418,16 +418,6 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp // Set common fields that are shared by all entity types msg.key = entity->get_object_id_hash(); - // API 1.14+ clients compute object_id client-side from the entity name - // For older clients, we must send object_id for backward compatibility - // See: https://github.com/esphome/backlog/issues/76 - // TODO: Remove this backward compat code before 2026.7.0 - all clients should support API 1.14 by then - // Buffer must remain in scope until encode_to_buffer is called - char object_id_buf[OBJECT_ID_MAX_LEN]; - if (!conn->client_supports_api_version(1, 14)) { - msg.object_id = entity->get_object_id_to(object_id_buf); - } - if (entity->has_own_name()) { msg.name = entity->get_name(); } diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 804cd9ddd1..92f7065730 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -43,10 +43,7 @@ class APIServer; // Keepalive timeout in milliseconds static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000; // Maximum number of entities to process in a single batch during initial state/info sending -// API 1.14+ clients compute object_id client-side, so messages are smaller and we can fit more per batch -// TODO: Remove MAX_INITIAL_PER_BATCH_LEGACY before 2026.7.0 - all clients should support API 1.14 by then -static constexpr size_t MAX_INITIAL_PER_BATCH_LEGACY = 24; // For clients < API 1.14 (includes object_id) -static constexpr size_t MAX_INITIAL_PER_BATCH = 34; // For clients >= API 1.14 (no object_id) +static constexpr size_t MAX_INITIAL_PER_BATCH = 34; // Verify MAX_MESSAGES_PER_BATCH (defined in api_frame_helper.h) can hold the initial batch static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_PER_BATCH, "MAX_MESSAGES_PER_BATCH must be >= MAX_INITIAL_PER_BATCH"); @@ -481,13 +478,6 @@ class APIConnection final : public APIServerConnectionBase { inline bool check_voice_assistant_api_connection_() const; #endif - // Get the max batch size based on client API version - // API 1.14+ clients don't receive object_id, so messages are smaller and more fit per batch - // TODO: Remove this method before 2026.7.0 and use MAX_INITIAL_PER_BATCH directly - size_t get_max_batch_size_() const { - return this->client_supports_api_version(1, 14) ? MAX_INITIAL_PER_BATCH : MAX_INITIAL_PER_BATCH_LEGACY; - } - // Send keepalive ping or disconnect unresponsive client. // Cold path — extracted from loop() to reduce instruction cache pressure. void __attribute__((noinline)) check_keepalive_(uint32_t now); From 21aee91e6799164c39da486f45a7e971dde03496 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 14:45:03 -0500 Subject: [PATCH 0503/1815] [web_server] Remove deprecated object ID URL matching (#17113) --- esphome/components/web_server/web_server.cpp | 29 +------------------- esphome/components/web_server/web_server.h | 2 +- 2 files changed, 2 insertions(+), 29 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 909a27c81c..cdb8544fbb 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -164,36 +164,9 @@ EntityMatchResult UrlMatch::match_entity(EntityBase *entity) const { } #endif - // Try matching by entity name (new format) + // Match by entity name if (this->id == entity->get_name()) { result.matched = true; - return result; - } - - // Fall back to object_id (deprecated format) - char object_id_buf[OBJECT_ID_MAX_LEN]; - StringRef object_id = entity->get_object_id_to(object_id_buf); - if (this->id == object_id) { - result.matched = true; - // Log deprecation warning -#ifdef USE_DEVICES - Device *device = entity->get_device(); - if (device != nullptr) { - ESP_LOGW(TAG, - "Deprecated URL format: /%.*s/%.*s/%.*s - use entity name '/%.*s/%s/%s' instead. " - "Object ID URLs will be removed in 2026.7.0.", - (int) this->domain.size(), this->domain.c_str(), (int) this->device_name.size(), - this->device_name.c_str(), (int) this->id.size(), this->id.c_str(), (int) this->domain.size(), - this->domain.c_str(), device->get_name(), entity->get_name().c_str()); - } else -#endif - { - ESP_LOGW(TAG, - "Deprecated URL format: /%.*s/%.*s - use entity name '/%.*s/%s' instead. " - "Object ID URLs will be removed in 2026.7.0.", - (int) this->domain.size(), this->domain.c_str(), (int) this->id.size(), this->id.c_str(), - (int) this->domain.size(), this->domain.c_str(), entity->get_name().c_str()); - } } return result; diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 25f8f8212d..e4defdbd9a 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -76,7 +76,7 @@ struct UrlMatch { bool method_equals(const __FlashStringHelper *str) const { return this->method == str; } #endif - /// Match entity by name first, then fall back to object_id with deprecation warning + /// Match entity by name /// Returns EntityMatchResult with match status and whether action segment is empty EntityMatchResult match_entity(EntityBase *entity) const; }; From 7c2603d9bc764c0ff10053b81bf5edc48d9c90eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 14:47:15 -0500 Subject: [PATCH 0504/1815] [ethernet] Defer clk_mode removal to 2026.9.0 (#17114) --- esphome/components/ethernet/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index f6afc30ff2..6af68e4e3c 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -324,7 +324,7 @@ def _validate(config): " clk:\n" " mode: %s\n" " pin: %s\n" - "Removal scheduled for 2026.7.0.", + "Removal scheduled for 2026.9.0.", config[CONF_CLK_MODE], mode, pin, From 03121d2efe6744df4ae3176a40f9c259f1e5162c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 14:49:27 -0500 Subject: [PATCH 0505/1815] [core] Remove deprecated std::string GPIOPin::dump_summary() (#17115) --- esphome/core/gpio.h | 31 +++++-------------------------- 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/esphome/core/gpio.h b/esphome/core/gpio.h index f2f85e18bc..43db3b7c0c 100644 --- a/esphome/core/gpio.h +++ b/esphome/core/gpio.h @@ -1,8 +1,6 @@ #pragma once #include #include -#include -#include #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -80,11 +78,6 @@ class GPIOPin { /// which may exceed len-1 if truncation occurred (snprintf semantics) virtual size_t dump_summary(char *buffer, size_t len) const; - /// Get a summary of this pin as a string. - /// @deprecated Use dump_summary(char*, size_t) instead. Will be removed in 2026.7.0. - ESPDEPRECATED("Override dump_summary(char*, size_t) instead. Will be removed in 2026.7.0.", "2026.1.0") - virtual std::string dump_summary() const; - virtual bool is_internal() { return false; } }; @@ -122,28 +115,14 @@ class InternalGPIOPin : public GPIOPin { virtual void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const = 0; }; -// Inline default implementations for GPIOPin virtual methods. -// These provide bridge functionality for backwards compatibility with external components. - -// Default implementation bridges to old std::string method for backwards compatibility. +// Inline default implementation for GPIOPin::dump_summary. +// Writes an empty summary; subclasses override to provide pin details. inline size_t GPIOPin::dump_summary(char *buffer, size_t len) const { - if (len == 0) - return 0; -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - std::string s = this->dump_summary(); -#pragma GCC diagnostic pop - size_t copy_len = std::min(s.size(), len - 1); - memcpy(buffer, s.c_str(), copy_len); - buffer[copy_len] = '\0'; - return s.size(); // Return would-be length (snprintf semantics) + if (len > 0) + buffer[0] = '\0'; + return 0; } -// Default implementation returns empty string. -// External components should override this if they haven't migrated to buffer-based version. -// Remove before 2026.7.0 -inline std::string GPIOPin::dump_summary() const { return {}; } - // Inline helper for log_pin - allows compiler to inline into log_pin in gpio.cpp inline void log_pin_with_prefix(const char *tag, const char *prefix, GPIOPin *pin) { char buffer[GPIO_SUMMARY_MAX_LEN]; From f273221cf47fb2c80c9883fcb7681591e0977312 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 14:50:32 -0500 Subject: [PATCH 0506/1815] [core] Remove deprecated value_accuracy_to_string() (#17116) --- esphome/core/alloc_helpers.cpp | 8 -------- esphome/core/alloc_helpers.h | 8 -------- 2 files changed, 16 deletions(-) diff --git a/esphome/core/alloc_helpers.cpp b/esphome/core/alloc_helpers.cpp index 11c7abe3f7..27c50ebb2a 100644 --- a/esphome/core/alloc_helpers.cpp +++ b/esphome/core/alloc_helpers.cpp @@ -86,14 +86,6 @@ std::string str_sprintf(const char *fmt, ...) { return str; } -// --- Value formatting helpers --- - -std::string value_accuracy_to_string(float value, int8_t accuracy_decimals) { - char buf[VALUE_ACCURACY_MAX_LEN]; - value_accuracy_to_buf(buf, value, accuracy_decimals); - return std::string(buf); -} - // --- Base64 helpers --- static constexpr const char *BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" diff --git a/esphome/core/alloc_helpers.h b/esphome/core/alloc_helpers.h index fe350886b7..1da3162333 100644 --- a/esphome/core/alloc_helpers.h +++ b/esphome/core/alloc_helpers.h @@ -94,14 +94,6 @@ std::string format_hex_pretty(const std::string &data, char separator = '.', boo /// @warning Allocates heap memory. Use format_bin_to() with a stack buffer instead. std::string format_bin(const uint8_t *data, size_t length); -// --- Value formatting helpers (allocating) --- - -/// Format a float value with accuracy decimals to a string. -/// @deprecated Allocates heap memory. Use value_accuracy_to_buf() instead. Removed in 2026.7.0. -__attribute__((deprecated("Allocates heap memory. Use value_accuracy_to_buf() instead. Removed in 2026.7.0."))) -std::string -value_accuracy_to_string(float value, int8_t accuracy_decimals); - // --- Base64 helpers (allocating) --- /// Encode a byte buffer to base64 string. From d1d77fc217e51af4291ba63e33a2281ac35e5a79 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 14:51:08 -0500 Subject: [PATCH 0507/1815] [remote_base] Remove deprecated MideaData::to_string() (#17117) --- esphome/components/remote_base/midea_protocol.h | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/components/remote_base/midea_protocol.h b/esphome/components/remote_base/midea_protocol.h index f21dd40828..47bad6826f 100644 --- a/esphome/components/remote_base/midea_protocol.h +++ b/esphome/components/remote_base/midea_protocol.h @@ -28,9 +28,6 @@ class MideaData { bool is_valid() const { return this->data_[OFFSET_CS] == this->calc_cs_(); } void finalize() { this->data_[OFFSET_CS] = this->calc_cs_(); } bool is_compliment(const MideaData &rhs) const; - /// @deprecated Allocates heap memory. Use to_str() instead. Removed in 2026.7.0. - ESPDEPRECATED("Allocates heap memory. Use to_str() instead. Removed in 2026.7.0.", "2026.1.0") - std::string to_string() const { return format_hex_pretty(this->data_.data(), this->data_.size()); } // NOLINT /// Buffer size for to_str(): 6 bytes = "AA.BB.CC.DD.EE.FF\0" static constexpr size_t TO_STR_BUFFER_SIZE = format_hex_pretty_size(6); /// Format to buffer, returns pointer to buffer From d8f883bd9d37399c9528ff39f47f6f7d92f85df9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 14:54:05 -0500 Subject: [PATCH 0508/1815] [core] Remove deprecated get_object_id() and get_compilation_time() (#17112) --- esphome/core/application.h | 9 --------- esphome/core/entity_base.cpp | 7 ------- esphome/core/entity_base.h | 12 ------------ esphome/core/entity_helpers.py | 2 +- tests/unit_tests/core/test_entity_helpers.py | 9 +++++---- 5 files changed, 6 insertions(+), 33 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 7c12a66b2c..76af514511 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -194,15 +194,6 @@ class Application { /// Buffer must be BUILD_TIME_STR_SIZE bytes (compile-time enforced) void get_build_time_string(std::span buffer); - /// Get the build time as a string (deprecated, use get_build_time_string() instead) - // Remove before 2026.7.0 - ESPDEPRECATED("Use get_build_time_string() instead. Removed in 2026.7.0", "2026.1.0") - std::string get_compilation_time() { - char buf[BUILD_TIME_STR_SIZE]; - this->get_build_time_string(buf); - return std::string(buf); - } - /// Get the cached time in milliseconds from when the current component started its loop execution inline uint32_t IRAM_ATTR HOT get_loop_component_start_time() const { return this->loop_component_start_time_; } diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index a47af1dd93..32135860bb 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -147,13 +147,6 @@ std::string EntityBase::get_icon() const { } #endif // !USE_ESP8266 -// Entity Object ID - computed on-demand from name -std::string EntityBase::get_object_id() const { - char buf[OBJECT_ID_MAX_LEN]; - size_t len = this->write_object_id_to(buf, sizeof(buf)); - return std::string(buf, len); -} - // Calculate Object ID Hash directly from name using snake_case + sanitize void EntityBase::calc_object_id_() { this->object_id_hash_ = fnv1_hash_object_id(this->name_.c_str(), this->name_.size()); diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 2726a92c97..4f708209d4 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -73,18 +73,6 @@ class EntityBase { // Get whether this Entity has its own name or it should use the device friendly_name. bool has_own_name() const { return this->flags_.has_own_name; } - // Get the sanitized name of this Entity as an ID. - // Deprecated: object_id mangles names and all object_id methods are planned for removal. - // See https://github.com/esphome/backlog/issues/76 - // Now is the time to stop using object_id entirely. If you still need it temporarily, - // use get_object_id_to() which will remain available longer but will also eventually be removed. - ESPDEPRECATED("object_id mangles names and all object_id methods are planned for removal " - "(see https://github.com/esphome/backlog/issues/76). " - "Now is the time to stop using object_id. If still needed, use get_object_id_to() " - "which will remain available longer. get_object_id() will be removed in 2026.7.0", - "2025.12.0") - std::string get_object_id() const; - // Get the unique Object ID of this Entity uint32_t get_object_id_hash() const { return this->object_id_hash_; } diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index ff60260280..38c7f3ca43 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -337,7 +337,7 @@ def get_base_entity_object_id( This function calculates what object_id_c_str_ should be set to in C++. - The C++ EntityBase::get_object_id() (entity_base.cpp lines 38-49) works as: + The C++ EntityBase::write_object_id_to() (entity_base.cpp) works as: - If !has_own_name && is_name_add_mac_suffix_enabled(): return str_sanitize(str_snake_case(App.get_friendly_name())) // Dynamic - Else: diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index e79ff850f9..3ac4ce27af 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -174,10 +174,11 @@ def test_empty_name_fallback() -> None: def test_name_add_mac_suffix_behavior() -> None: """Test behavior related to name_add_mac_suffix. - In C++, when name_add_mac_suffix is enabled and entity has no name, - get_object_id() returns str_sanitize(str_snake_case(App.get_friendly_name())) - dynamically. Our function always returns the same result since we're - calculating the base for duplicate tracking. + In C++, an entity's object_id is computed from its name_ via + write_object_id_to() (sanitized snake_case). When an entity has no name, + configure_entity_() sets name_ from the friendly name, with the MAC suffix + appended when name_add_mac_suffix is enabled. Our function always returns + the same result since we're calculating the base for duplicate tracking. """ # The function should always return the same result regardless of # name_add_mac_suffix setting, as we're calculating the base object_id From 78c6131bbf1c57eac765507fe274f99c9d716fc5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 15:00:36 -0500 Subject: [PATCH 0509/1815] [web_server] Deprecate version 1 (#17109) --- esphome/components/web_server/__init__.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index fd380a38dd..788bedec34 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations import gzip +import logging import esphome.codegen as cg from esphome.components import web_server_base @@ -38,6 +39,8 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority import esphome.final_validate as fv from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + AUTO_LOAD = ["json", "web_server_base"] CONF_SORTING_GROUP_ID = "sorting_group_id" @@ -71,6 +74,15 @@ def default_url(config: ConfigType) -> ConfigType: return config +def validate_version_deprecated(config: ConfigType) -> ConfigType: + if config[CONF_VERSION] == 1: + _LOGGER.warning( + "Version 1 of 'web_server' is deprecated and will be removed in " + "2027.1.0. Please migrate to version 2 (the default) or version 3." + ) + return config + + def validate_local(config: ConfigType) -> ConfigType: if CONF_LOCAL in config and config[CONF_VERSION] == 1: raise cv.Invalid("'local' is not supported in version 1") @@ -220,6 +232,7 @@ CONFIG_SCHEMA = cv.All( ] ), default_url, + validate_version_deprecated, validate_local, validate_sorting_groups, validate_ota, From c6ead57a9ef7a74556da54da6e2b0ebd93fac729 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 15:00:46 -0500 Subject: [PATCH 0510/1815] [packages] Remove deprecated single-package include syntax (#17119) --- esphome/components/packages/__init__.py | 61 +------- .../component_tests/packages/test_packages.py | 140 ++---------------- 2 files changed, 18 insertions(+), 183 deletions(-) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index c1c5bd2ae3..44a1ebf36e 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -1,7 +1,6 @@ from collections import UserDict from collections.abc import Callable from functools import reduce -import logging from pathlib import Path from typing import Any @@ -36,8 +35,6 @@ from esphome.const import ( ) from esphome.core import EsphomeError -_LOGGER = logging.getLogger(__name__) - DOMAIN = CONF_PACKAGES # Guard against infinite include chains (e.g. A includes B includes A). MAX_INCLUDE_DEPTH = 20 @@ -53,18 +50,6 @@ def is_remote_package(package_config: dict) -> bool: return CONF_URL in package_config -def is_package_definition(value: object) -> bool: - """Returns True if the value looks like a package definition rather than a config fragment. - - Package definitions are IncludeFile objects, git URL shorthand strings, or - remote package dicts (containing a ``url:`` key). Config fragments are - plain dicts that represent component configuration. - """ - return isinstance(value, (yaml_util.IncludeFile, str)) or ( - isinstance(value, dict) and is_remote_package(value) - ) - - def valid_package_contents(package_config: dict) -> dict: """Validate that a package looks like a plausible ESPHome config fragment. @@ -134,22 +119,6 @@ def validate_source_shorthand(value): return REMOTE_PACKAGE_SCHEMA(conf) -def deprecate_single_package(config: dict) -> dict: - _LOGGER.warning( - """ - Including a single package under `packages:`, i.e., `packages: !include mypackage.yaml` is deprecated. - This method for including packages will go away in 2026.7.0 - Please use a list instead: - - packages: - - !include mypackage.yaml - - See https://github.com/esphome/esphome/pull/12116 - """ - ) - return config - - REMOTE_PACKAGE_SCHEMA = cv.All( cv.Schema( { @@ -198,10 +167,7 @@ CONFIG_SCHEMA = cv.Any( # under `packages:` we can have either: str: PACKAGE_SCHEMA, # a named dict of package definitions, or } ), - [PACKAGE_SCHEMA], # a list of package definitions, or - cv.All( # a single package definition (deprecated) - cv.ensure_list(PACKAGE_SCHEMA), deprecate_single_package - ), + [PACKAGE_SCHEMA], # a list of package definitions ) @@ -348,7 +314,6 @@ def _walk_packages( config: dict, callback: PackageCallback, context: ContextVars | None = None, - validate_deprecated: bool = True, path: yaml_util.DocumentPath | None = None, ) -> dict: """Walks the packages structure in priority order, invoking ``callback`` on each package definition found. @@ -378,17 +343,7 @@ def _walk_packages( elif ( result := _walk_package_dict(packages, callback, context, packages_path) ) is not None: - if not validate_deprecated or any( - is_package_definition(v) for v in packages.values() - ): - raise result - # Fallback: treat the dict as a single deprecated package. - # This block can be removed once the single-package - # deprecation period (2026.7.0) is over. - config[CONF_PACKAGES] = [packages] - return _walk_packages( - deprecate_single_package(config), callback, context, path=path - ) + raise result config[CONF_PACKAGES] = packages return config @@ -588,9 +543,6 @@ class _PackageProcessor: path: yaml_util.DocumentPath, ) -> dict: """Resolve a single package and recurse into any nested packages.""" - from_remote = isinstance(package_config, dict) and is_remote_package( - package_config - ) package_config = self.resolve_package(package_config, context_vars, path) context_vars = self.collect_substitutions(package_config, context_vars) @@ -600,17 +552,10 @@ class _PackageProcessor: # Push context from !include vars on the packages key (the package root # was already pushed in collect_substitutions above). context_vars = push_context(package_config[CONF_PACKAGES], context_vars) - # Disable the deprecated single-package fallback for remote - # packages. _process_remote_package returns dicts with - # already-resolved values that is_package_definition cannot - # distinguish from config fragments, so the fallback would - # always fire and mask real errors with wrong paths - # (packages->0 instead of packages->). return _walk_packages( package_config, self.process_package, context_vars, - validate_deprecated=not from_remote, path=path, ) @@ -673,7 +618,7 @@ def merge_packages(config: dict) -> dict: merge_list.append(package_config) return _walk_packages(package_config, process_package_callback, path=path) - _walk_packages(config, process_package_callback, validate_deprecated=False) + _walk_packages(config, process_package_callback) # Merge all packages into the main config: config = reduce(lambda new, old: merge_config(old, new), merge_list, config) del config[CONF_PACKAGES] diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 66f946a5bd..6990c1c051 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -12,7 +12,6 @@ from esphome.components.packages import ( _substitute_package_definition, _walk_packages, do_packages_pass, - is_package_definition, merge_packages, resolve_packages, ) @@ -89,44 +88,6 @@ def packages_pass(config): return config -_INCLUDE_FILE = "INCLUDE_FILE" - - -@pytest.mark.parametrize( - ("value", "expected"), - [ - # IncludeFile objects are package definitions - (_INCLUDE_FILE, True), - # Git URL shorthand strings are package definitions - ("github://esphome/firmware/base.yaml@main", True), - # Remote package dicts (with url key) are package definitions - ({"url": "https://github.com/esphome/firmware", "file": "base.yaml"}, True), - # Plain config dicts are NOT package definitions (they are config fragments) - ({"wifi": {"ssid": "test"}}, False), - # None is not a package definition - (None, False), - # Lists are not package definitions - ([{"wifi": {"ssid": "test"}}], False), - # Empty dicts are not package definitions - ({}, False), - ], - ids=[ - "include_file", - "git_shorthand", - "remote_package", - "config_fragment", - "none", - "list", - "empty_dict", - ], -) -def test_is_package_definition(value: object, expected: bool) -> None: - """Test that is_package_definition correctly identifies package definitions.""" - if value is _INCLUDE_FILE: - value = MagicMock(spec=IncludeFile) - assert is_package_definition(value) is expected - - def test_package_unused(basic_esphome, basic_wifi) -> None: """ Ensures do_package_pass does not change a config if packages aren't used. @@ -210,30 +171,6 @@ def test_package_include(basic_wifi, basic_esphome) -> None: assert actual == expected -def test_single_package( - basic_esphome, - basic_wifi, - caplog: pytest.LogCaptureFixture, -) -> None: - """ - Tests the simple case where a single package is added to the top-level config as is. - In this test, the CONF_WIFI config is expected to be simply added to the top-level config. - This tests the case where the user just put packages: !include package.yaml, not - part of a list or mapping of packages. - This behavior is deprecated, the test also checks if a warning is issued. - """ - config = {CONF_ESPHOME: basic_esphome, CONF_PACKAGES: {CONF_WIFI: basic_wifi}} - - expected = {CONF_ESPHOME: basic_esphome, CONF_WIFI: basic_wifi} - - with caplog.at_level("WARNING"): - actual = packages_pass(config) - - assert actual == expected - - assert "This method for including packages will go away in 2026.7.0" in caplog.text - - def test_package_append(basic_wifi, basic_esphome) -> None: """ Tests the case where a key is present in both a package and top-level config. @@ -1154,6 +1091,10 @@ def test_packages_include_file_resolves_to_invalid_type_raises( 6, "some string", True, + None, + ["some string"], + {"some_component": 8}, + {3: 2}, ], ) def test_invalid_package_contents_rejected(invalid_package: object) -> None: @@ -1167,28 +1108,15 @@ def test_invalid_package_contents_rejected(invalid_package: object) -> None: do_packages_pass(config) -@pytest.mark.xfail( - reason="Deprecated single-package fallback swallows these errors. " - "Remove xfail when single-package deprecation is removed (2026.7.0).", - strict=True, -) -@pytest.mark.parametrize( - "invalid_package", - [ - None, - ["some string"], - {"some_component": 8}, - {3: 2}, - ], -) -def test_invalid_package_contents_masked_by_deprecation( - invalid_package: object, -) -> None: - """These invalid packages are swallowed by the deprecated single-package fallback.""" +def test_single_package_fragment_form_rejected() -> None: + """The deprecated single-package form is removed and now raises. + + Previously ``packages: !include some_package.yaml`` resolving to a bare config + fragment dict was silently wrapped and merged via the single-package fallback. + That form must now raise instead of being accepted. + """ config = { - CONF_PACKAGES: { - "some_package": invalid_package, - }, + CONF_PACKAGES: {CONF_WIFI: {CONF_SSID: "test", CONF_PASSWORD: "secret"}}, } with pytest.raises(cv.Invalid): do_packages_pass(config) @@ -1231,14 +1159,10 @@ def test_named_dict_with_include_files_no_false_deprecation_warning( assert "deprecated" not in caplog.text.lower() -def test_validate_deprecated_false_raises_directly( +def test_named_package_errors_raise_directly( caplog: pytest.LogCaptureFixture, ) -> None: - """With validate_deprecated=False, errors raise directly without fallback. - - This is the codepath used for remote packages where _process_remote_package - returns already-resolved dicts that is_package_definition cannot detect. - """ + """Errors processing a named-dict package raise directly, with no deprecation warning.""" config = { CONF_PACKAGES: { "pkg_a": {CONF_WIFI: {CONF_SSID: "test"}}, @@ -1261,7 +1185,7 @@ def test_validate_deprecated_false_raises_directly( caplog.at_level(logging.WARNING), pytest.raises(cv.Invalid, match="nested error"), ): - _walk_packages(config, failing_callback, validate_deprecated=False) + _walk_packages(config, failing_callback) assert "deprecated" not in caplog.text.lower() @@ -1296,40 +1220,6 @@ def test_error_on_first_declared_package_still_detected() -> None: _walk_packages(config, fail_on_last) -def test_deprecated_single_package_fallback_still_works( - caplog: pytest.LogCaptureFixture, -) -> None: - """The deprecated single-package form still falls back at the top level. - - When a dict's values are plain config fragments (not package definitions) - and the callback fails, the deprecated fallback wraps the dict in a list - and retries with a deprecation warning. - """ - config = { - CONF_PACKAGES: { - CONF_WIFI: {CONF_SSID: "test", CONF_PASSWORD: "secret"}, - }, - } - - attempt = 0 - - def fail_then_succeed( - package_config: dict, context: object, path: DocumentPath | None = None - ) -> dict: - nonlocal attempt - attempt += 1 - if attempt == 1: - # First attempt: treating as named dict fails - raise cv.Invalid("not a valid package") - # Second attempt: after fallback wraps as list, succeeds - return package_config - - with caplog.at_level(logging.WARNING): - _walk_packages(config, fail_then_succeed) - - assert "deprecated" in caplog.text.lower() - - def test_merge_packages_invalid_nested_type_raises() -> None: """Invalid nested packages type during merge raises cv.Invalid.""" config = { From 921758f87dcdaf12cb40d12f1c5443094b5fc4e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 15:00:55 -0500 Subject: [PATCH 0511/1815] [core] Clarify resolve error when a device has no network log/OTA transport (#17107) --- esphome/__main__.py | 39 +++++++++++++++++++----- tests/unit_tests/test_main.py | 56 +++++++++++++++++++++++++++++++++-- 2 files changed, 84 insertions(+), 11 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index bda3dcbd05..680de02201 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -268,6 +268,36 @@ def _ota_hostnames_for_default(purpose: Purpose) -> list[str]: return _resolve_with_cache(CORE.address, purpose) +def _unresolved_default_error(purpose: Purpose, defaults: list[str]) -> str: + """Build the error when a default device target produced no usable host. + + When the OTA default was requested and the address resolves but the config + lacks the transport the purpose needs (``api:`` for logs, an ``ota:`` + platform for uploads), name that gap instead of the misleading + "could not be resolved" / set-use_address hint. + """ + if "OTA" in defaults and has_resolvable_address(): + if purpose == Purpose.LOGGING and not has_api(): + return ( + "Cannot view logs over the network: no 'api:' component is " + "configured. Network log streaming requires the native API; add " + "an 'api:' component, enable MQTT logging, or view logs over USB." + ) + if purpose == Purpose.UPLOADING and not has_ota(): + return ( + "Cannot upload over the network: no 'ota:' platform is " + "configured. Add an 'ota:' platform, or upload over USB." + ) + if CORE.dashboard: + hint = "If you know the IP, set 'use_address' in your network config." + else: + hint = "If you know the IP, try --device " + return ( + f"All specified devices {defaults} could not be resolved. " + f"Is the device connected to the network? {hint}" + ) + + def choose_upload_log_host( default: list[str] | str | None, check_default: str | None, @@ -317,14 +347,7 @@ def choose_upload_log_host( else: resolved.append(device) if not resolved: - if CORE.dashboard: - hint = "If you know the IP, set 'use_address' in your network config." - else: - hint = "If you know the IP, try --device " - raise EsphomeError( - f"All specified devices {defaults} could not be resolved. " - f"Is the device connected to the network? {hint}" - ) + raise EsphomeError(_unresolved_default_error(purpose, defaults)) return resolved # No devices specified, show interactive chooser diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index acd39cedc6..bb06b6c930 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -24,6 +24,7 @@ from esphome.__main__ import ( _make_crystal_freq_callback, _redact_with_legacy_fallback, _resolve_network_devices, + _unresolved_default_error, _validate_bootloader_binary, _validate_partition_table_binary, choose_upload_log_host, @@ -713,9 +714,7 @@ def test_choose_upload_log_host_with_ota_device_with_api_config() -> None: """Test OTA device when API is configured (no upload without OTA in config).""" setup_core(config={CONF_API: {}}, address="192.168.1.100") - with pytest.raises( - EsphomeError, match="All specified devices .* could not be resolved" - ): + with pytest.raises(EsphomeError, match="no 'ota:' platform is configured"): choose_upload_log_host( default="OTA", check_default=None, @@ -735,6 +734,57 @@ def test_choose_upload_log_host_with_ota_device_with_api_config_logging() -> Non assert result == ["192.168.1.100"] +def test_choose_upload_log_host_logging_without_api_reports_missing_api() -> None: + """A resolvable device with only ota: fails logs with a missing-api message.""" + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100" + ) + + with pytest.raises(EsphomeError, match="no 'api:' component is configured"): + choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, + ) + + +def test_choose_upload_log_host_logging_no_transport_reports_missing_api() -> None: + """A resolvable device with neither api: nor MQTT logging fails clearly.""" + setup_core(address="192.168.1.100") + + with pytest.raises(EsphomeError, match="no 'api:' component is configured"): + choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, + ) + + +def test_unresolved_default_error_unresolvable_keeps_dashboard_hint() -> None: + """A .local host with mDNS disabled and no cache keeps the dashboard hint.""" + setup_core( + config={CONF_API: {}, CONF_MDNS: {CONF_DISABLED: True}}, + address="esp32-a1s.local", + ) + CORE.dashboard = True + + msg = _unresolved_default_error(Purpose.LOGGING, ["OTA"]) + assert "could not be resolved" in msg + assert "set 'use_address'" in msg + + +def test_unresolved_default_error_upload_with_ota_is_generic() -> None: + """With ota: present the upload error stays generic, not transport-specific.""" + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100" + ) + CORE.dashboard = False + + msg = _unresolved_default_error(Purpose.UPLOADING, ["OTA"]) + assert "could not be resolved" in msg + assert "try --device " in msg + + @pytest.mark.usefixtures("mock_has_mqtt_logging") def test_choose_upload_log_host_with_ota_device_fallback_to_mqtt() -> None: """Test OTA device fallback to MQTT when no OTA/API config.""" From 036768c399ae88c0c37b2ac0cf1d26f6b72538f6 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sun, 21 Jun 2026 16:19:13 -0400 Subject: [PATCH 0512/1815] [audio] Fix mono channel MP3 playback (#17106) --- esphome/components/audio/audio_decoder.cpp | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/esphome/components/audio/audio_decoder.cpp b/esphome/components/audio/audio_decoder.cpp index f709c23fb6..fe9ad9c9ad 100644 --- a/esphome/components/audio/audio_decoder.cpp +++ b/esphome/components/audio/audio_decoder.cpp @@ -326,14 +326,8 @@ FileDecoderState AudioDecoder::decode_mp3_() { } else if (result == micro_mp3::MP3_NEED_MORE_DATA) { return FileDecoderState::MORE_TO_PROCESS; } else if (result == micro_mp3::MP3_OUTPUT_BUFFER_TOO_SMALL) { - // Reallocate to decode the frame on the next call - if (this->mp3_decoder_->get_channels() > 0) { - this->free_buffer_required_ = - this->mp3_decoder_->get_samples_per_frame() * this->mp3_decoder_->get_channels() * sizeof(int16_t); - } else { - // Fallback to worst-case size if channel info isn't available - this->free_buffer_required_ = this->mp3_decoder_->get_min_output_buffer_bytes(); - } + // Fallback to worst-case size + this->free_buffer_required_ = this->mp3_decoder_->get_min_output_buffer_bytes(); if (!this->output_transfer_buffer_->reallocate(this->free_buffer_required_)) { return FileDecoderState::FAILED; } From 6c10fc1272ae17befe1f4b1275918f73791a1455 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 16:21:50 -0500 Subject: [PATCH 0513/1815] [hub75] Remove deprecated scan_wiring name aliases (#17118) --- esphome/components/hub75/display.py | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index 0d1b87941d..a404fbbade 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -1,4 +1,3 @@ -import logging from typing import Any from esphome import automation, pins @@ -27,8 +26,6 @@ from esphome.types import ConfigType from . import boards, hub75_ns -_LOGGER = logging.getLogger(__name__) - DEPENDENCIES = ["esp32"] CODEOWNERS = ["@stuartparmenter"] @@ -133,30 +130,11 @@ SCAN_WIRINGS = { "SCAN_1_8_64PX_HIGH": Hub75ScanWiring.SCAN_1_8_64PX_HIGH, } -# Deprecated scan wiring names - mapped to new names -DEPRECATED_SCAN_WIRINGS = { - "FOUR_SCAN_16PX_HIGH": "SCAN_1_4_16PX_HIGH", - "FOUR_SCAN_32PX_HIGH": "SCAN_1_8_32PX_HIGH", - "FOUR_SCAN_64PX_HIGH": "SCAN_1_8_64PX_HIGH", -} - def _validate_scan_wiring(value): - """Validate scan_wiring with deprecation warnings for old names.""" + """Validate scan_wiring against the allowed names.""" value = cv.string(value).upper().replace(" ", "_") - # Check if using deprecated name - # Remove deprecated names in 2026.7.0 - if value in DEPRECATED_SCAN_WIRINGS: - new_name = DEPRECATED_SCAN_WIRINGS[value] - _LOGGER.warning( - "Scan wiring '%s' is deprecated and will be removed in ESPHome 2026.7.0. " - "Please use '%s' instead.", - value, - new_name, - ) - value = new_name - # Validate against allowed values if value not in SCAN_WIRINGS: raise cv.Invalid( From c4abc5476e11acfe3e81bbe4b3894bb881a34a4e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 16:22:08 -0500 Subject: [PATCH 0514/1815] [core] Remove deprecated std::string scheduler/timer overloads (#17111) --- esphome/core/component.cpp | 40 --- esphome/core/component.h | 78 ++---- esphome/core/scheduler.cpp | 17 -- esphome/core/scheduler.h | 16 +- .../scheduler_bulk_cleanup_component.cpp | 19 +- .../rapid_cancellation_component.cpp | 14 +- .../simultaneous_callbacks_component.cpp | 14 +- .../__init__.py | 21 -- .../string_lifetime_component.cpp | 260 ------------------ .../string_lifetime_component.h | 35 --- .../__init__.py | 21 -- .../string_name_stress_component.cpp | 108 -------- .../string_name_stress_component.h | 20 -- .../integration/fixtures/scheduler_pool.yaml | 12 +- .../fixtures/scheduler_string_lifetime.yaml | 48 ---- .../scheduler_string_name_stress.yaml | 39 --- .../fixtures/scheduler_string_test.yaml | 42 ++- .../test_scheduler_string_lifetime.py | 169 ------------ .../test_scheduler_string_name_stress.py | 116 -------- .../integration/test_scheduler_string_test.py | 2 +- 20 files changed, 74 insertions(+), 1017 deletions(-) delete mode 100644 tests/integration/fixtures/external_components/scheduler_string_lifetime_component/__init__.py delete mode 100644 tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp delete mode 100644 tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.h delete mode 100644 tests/integration/fixtures/external_components/scheduler_string_name_stress_component/__init__.py delete mode 100644 tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.cpp delete mode 100644 tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.h delete mode 100644 tests/integration/fixtures/scheduler_string_lifetime.yaml delete mode 100644 tests/integration/fixtures/scheduler_string_name_stress.yaml delete mode 100644 tests/integration/test_scheduler_string_lifetime.py delete mode 100644 tests/integration/test_scheduler_string_name_stress.py diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 7ef5ff50a5..281d7aaecd 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -85,24 +85,10 @@ void Component::setup() {} void Component::loop() {} -void Component::set_interval(const std::string &name, uint32_t interval, std::function &&f) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - App.scheduler.set_interval(this, name, interval, std::move(f)); -#pragma GCC diagnostic pop -} - void Component::set_interval(const char *name, uint32_t interval, std::function &&f) { // NOLINT App.scheduler.set_interval(this, name, interval, std::move(f)); } -bool Component::cancel_interval(const std::string &name) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return App.scheduler.cancel_interval(this, name); -#pragma GCC diagnostic pop -} - bool Component::cancel_interval(const char *name) { // NOLINT return App.scheduler.cancel_interval(this, name); } @@ -137,24 +123,10 @@ bool Component::cancel_retry(const char *name) { // NOLINT #pragma GCC diagnostic pop } -void Component::set_timeout(const std::string &name, uint32_t timeout, std::function &&f) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - App.scheduler.set_timeout(this, name, timeout, std::move(f)); -#pragma GCC diagnostic pop -} - void Component::set_timeout(const char *name, uint32_t timeout, std::function &&f) { // NOLINT App.scheduler.set_timeout(this, name, timeout, std::move(f)); } -bool Component::cancel_timeout(const std::string &name) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return App.scheduler.cancel_timeout(this, name); -#pragma GCC diagnostic pop -} - bool Component::cancel_timeout(const char *name) { // NOLINT return App.scheduler.cancel_timeout(this, name); } @@ -319,21 +291,9 @@ void Component::reset_to_construction_state() { void Component::defer(std::function &&f) { // NOLINT App.scheduler.set_timeout(this, static_cast(nullptr), 0, std::move(f)); } -bool Component::cancel_defer(const std::string &name) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return App.scheduler.cancel_timeout(this, name); -#pragma GCC diagnostic pop -} bool Component::cancel_defer(const char *name) { // NOLINT return App.scheduler.cancel_timeout(this, name); } -void Component::defer(const std::string &name, std::function &&f) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - App.scheduler.set_timeout(this, name, 0, std::move(f)); -#pragma GCC diagnostic pop -} void Component::defer(const char *name, std::function &&f) { // NOLINT App.scheduler.set_timeout(this, name, 0, std::move(f)); } diff --git a/esphome/core/component.h b/esphome/core/component.h index 299a5f72ea..1ae70371a1 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -357,9 +357,9 @@ class Component { /// so once a flag is set, subsequent (potentially different) messages may be suppressed. bool set_status_flag_(uint8_t flag); - /** Set an interval function with a unique name. Empty name means no cancelling possible. + /** Set an interval function with a const char* name. Empty name means no cancelling possible. * - * This will call f every interval ms. Can be cancelled via CancelInterval(). + * This will call f every interval ms. Can be cancelled via cancel_interval(). * Similar to javascript's setInterval(). * * IMPORTANT NOTE: @@ -372,18 +372,6 @@ class Component { * * Note also that the first call to f will not happen immediately, but after a random delay. This is * intended to prevent many interval functions from being called at the same time. - * - * @param name The identifier for this interval function. - * @param interval The interval in ms. - * @param f The function (or lambda) that should be called - * - * @see cancel_interval() - */ - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - void set_interval(const std::string &name, uint32_t interval, std::function &&f); // NOLINT - - /** Set an interval function with a const char* name. * * IMPORTANT: The provided name pointer must remain valid for the lifetime of the scheduler item. * This means the name should be: @@ -391,7 +379,7 @@ class Component { * - A static const char* variable * - A pointer with lifetime >= the scheduled task * - * For dynamic strings, use the std::string overload instead. + * For dynamic names, use the uint32_t id overload instead. * * @param name The identifier for this interval function (must have static lifetime) * @param interval The interval in ms @@ -416,12 +404,9 @@ class Component { * @param name The identifier for this interval function. * @return Whether an interval functions was deleted. */ - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - bool cancel_interval(const std::string &name); // NOLINT - bool cancel_interval(const char *name); // NOLINT - bool cancel_interval(uint32_t id); // NOLINT - bool cancel_interval(InternalSchedulerID id); // NOLINT + bool cancel_interval(const char *name); // NOLINT + bool cancel_interval(uint32_t id); // NOLINT + bool cancel_interval(InternalSchedulerID id); // NOLINT /// @deprecated set_retry is deprecated. Use set_timeout or set_interval instead. Removed in 2026.8.0. // Remove before 2026.8.0 @@ -458,25 +443,13 @@ class Component { ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") bool cancel_retry(uint32_t id); // NOLINT - /** Set a timeout function with a unique name. + /** Set a timeout function with a const char* name. * * Similar to javascript's setTimeout(). Empty name means no cancelling possible. * * IMPORTANT: Do not rely on this having correct timing. This is only called from - * loop() and therefore can be significantly delay. If you need exact timing please + * loop() and therefore can be significantly delayed. If you need exact timing please * use hardware timers. - * - * @param name The identifier for this timeout function. - * @param timeout The timeout in ms. - * @param f The function (or lambda) that should be called - * - * @see cancel_timeout() - */ - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - void set_timeout(const std::string &name, uint32_t timeout, std::function &&f); // NOLINT - - /** Set a timeout function with a const char* name. * * IMPORTANT: The provided name pointer must remain valid for the lifetime of the scheduler item. * This means the name should be: @@ -484,7 +457,9 @@ class Component { * - A static const char* variable * - A pointer with lifetime >= the timeout duration * - * For dynamic strings, use the std::string overload instead. + * For dynamic names, use the uint32_t id overload instead. + * + * @see cancel_timeout() * * @param name The identifier for this timeout function (must have static lifetime) * @param timeout The timeout in ms @@ -509,25 +484,13 @@ class Component { * @param name The identifier for this timeout function. * @return Whether a timeout functions was deleted. */ - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - bool cancel_timeout(const std::string &name); // NOLINT - bool cancel_timeout(const char *name); // NOLINT - bool cancel_timeout(uint32_t id); // NOLINT - bool cancel_timeout(InternalSchedulerID id); // NOLINT - - /** Defer a callback to the next loop() call. - * - * If name is specified and a defer() object with the same name exists, the old one is first removed. - * - * @param name The name of the defer function. - * @param f The callback. - */ - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* overload instead. Removed in 2026.7.0", "2026.1.0") - void defer(const std::string &name, std::function &&f); // NOLINT + bool cancel_timeout(const char *name); // NOLINT + bool cancel_timeout(uint32_t id); // NOLINT + bool cancel_timeout(InternalSchedulerID id); // NOLINT /** Defer a callback to the next loop() call with a const char* name. + * + * If name is specified and a defer() object with the same name exists, the old one is first removed. * * IMPORTANT: The provided name pointer must remain valid for the lifetime of the deferred task. * This means the name should be: @@ -535,7 +498,7 @@ class Component { * - A static const char* variable * - A pointer with lifetime >= the deferred execution * - * For dynamic strings, use the std::string overload instead. + * For dynamic names, use the uint32_t id overload instead. * * @param name The name of the defer function (must have static lifetime) * @param f The callback @@ -549,11 +512,8 @@ class Component { void defer(uint32_t id, std::function &&f); // NOLINT /// Cancel a defer callback using the specified name, name must not be empty. - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* overload instead. Removed in 2026.7.0", "2026.1.0") - bool cancel_defer(const std::string &name); // NOLINT - bool cancel_defer(const char *name); // NOLINT - bool cancel_defer(uint32_t id); // NOLINT + bool cancel_defer(const char *name); // NOLINT + bool cancel_defer(uint32_t id); // NOLINT void status_clear_warning_slow_path_(); void status_clear_error_slow_path_(); diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 15bb9ea239..9c5557bdfc 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -254,30 +254,16 @@ void HOT Scheduler::set_timeout(Component *component, const char *name, uint32_t std::move(func)); } -void HOT Scheduler::set_timeout(Component *component, const std::string &name, uint32_t timeout, - std::function &&func) { - this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), - timeout, std::move(func)); -} void HOT Scheduler::set_timeout(Component *component, uint32_t id, uint32_t timeout, std::function &&func) { this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::NUMERIC_ID, nullptr, id, timeout, std::move(func)); } -bool HOT Scheduler::cancel_timeout(Component *component, const std::string &name) { - return this->cancel_item_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), SchedulerItem::TIMEOUT); -} bool HOT Scheduler::cancel_timeout(Component *component, const char *name) { return this->cancel_item_(component, NameType::STATIC_STRING, name, 0, SchedulerItem::TIMEOUT); } bool HOT Scheduler::cancel_timeout(Component *component, uint32_t id) { return this->cancel_item_(component, NameType::NUMERIC_ID, nullptr, id, SchedulerItem::TIMEOUT); } -void HOT Scheduler::set_interval(Component *component, const std::string &name, uint32_t interval, - std::function &&func) { - this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), - interval, std::move(func)); -} - void HOT Scheduler::set_interval(Component *component, const char *name, uint32_t interval, std::function &&func) { this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::STATIC_STRING, name, 0, interval, @@ -287,9 +273,6 @@ void HOT Scheduler::set_interval(Component *component, uint32_t id, uint32_t int this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::NUMERIC_ID, nullptr, id, interval, std::move(func)); } -bool HOT Scheduler::cancel_interval(Component *component, const std::string &name) { - return this->cancel_item_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), SchedulerItem::INTERVAL); -} bool HOT Scheduler::cancel_interval(Component *component, const char *name) { return this->cancel_item_(component, NameType::STATIC_STRING, name, 0, SchedulerItem::INTERVAL); } diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 378c0fb94b..c7743e5b2a 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -31,11 +31,6 @@ class Scheduler { template friend class DelayAction; public: - // std::string overload - deprecated, use const char* or uint32_t instead - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - void set_timeout(Component *component, const std::string &name, uint32_t timeout, std::function &&func); - /** Set a timeout with a const char* name. * * IMPORTANT: The provided name pointer must remain valid for the lifetime of the scheduler item. @@ -53,8 +48,6 @@ class Scheduler { static_cast(id), timeout, std::move(func)); } - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - bool cancel_timeout(Component *component, const std::string &name); bool cancel_timeout(Component *component, const char *name); bool cancel_timeout(Component *component, uint32_t id); bool cancel_timeout(Component *component, InternalSchedulerID id) { @@ -62,9 +55,6 @@ class Scheduler { SchedulerItem::TIMEOUT); } - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - void set_interval(Component *component, const std::string &name, uint32_t interval, std::function &&func); - /** Set an interval with a const char* name. * * IMPORTANT: The provided name pointer must remain valid for the lifetime of the scheduler item. @@ -82,8 +72,6 @@ class Scheduler { static_cast(id), interval, std::move(func)); } - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - bool cancel_interval(Component *component, const std::string &name); bool cancel_interval(Component *component, const char *name); bool cancel_interval(Component *component, uint32_t id); bool cancel_interval(Component *component, InternalSchedulerID id) { @@ -396,8 +384,8 @@ class Scheduler { inline bool HOT names_match_static_(const char *name1, const char *name2) const { // Check pointer equality first (common for static strings), then string contents // The core ESPHome codebase uses static strings (const char*) for component names, - // making pointer comparison effective. The std::string overloads exist only for - // compatibility with external components but are rarely used in practice. + // making pointer comparison effective. The strcmp fallback covers distinct pointers + // with identical content (e.g. names built into separate static buffers). return (name1 != nullptr && name2 != nullptr) && ((name1 == name2) || (strcmp(name1, name2) == 0)); } diff --git a/tests/integration/fixtures/external_components/scheduler_bulk_cleanup_component/scheduler_bulk_cleanup_component.cpp b/tests/integration/fixtures/external_components/scheduler_bulk_cleanup_component/scheduler_bulk_cleanup_component.cpp index f6fd1b1de7..d419694af7 100644 --- a/tests/integration/fixtures/external_components/scheduler_bulk_cleanup_component/scheduler_bulk_cleanup_component.cpp +++ b/tests/integration/fixtures/external_components/scheduler_bulk_cleanup_component/scheduler_bulk_cleanup_component.cpp @@ -8,14 +8,23 @@ static const char *const TAG = "bulk_cleanup"; void SchedulerBulkCleanupComponent::setup() { ESP_LOGI(TAG, "Scheduler bulk cleanup test component loaded"); } +// Static name tables keep the const char* pointers valid for the lifetime of the scheduled tasks. +static const char *const BULK_TIMEOUT_NAMES[25] = { + "bulk_timeout_0", "bulk_timeout_1", "bulk_timeout_2", "bulk_timeout_3", "bulk_timeout_4", + "bulk_timeout_5", "bulk_timeout_6", "bulk_timeout_7", "bulk_timeout_8", "bulk_timeout_9", + "bulk_timeout_10", "bulk_timeout_11", "bulk_timeout_12", "bulk_timeout_13", "bulk_timeout_14", + "bulk_timeout_15", "bulk_timeout_16", "bulk_timeout_17", "bulk_timeout_18", "bulk_timeout_19", + "bulk_timeout_20", "bulk_timeout_21", "bulk_timeout_22", "bulk_timeout_23", "bulk_timeout_24"}; +static const char *const POST_CLEANUP_NAMES[5] = {"post_cleanup_0", "post_cleanup_1", "post_cleanup_2", + "post_cleanup_3", "post_cleanup_4"}; + void SchedulerBulkCleanupComponent::trigger_bulk_cleanup() { ESP_LOGI(TAG, "Starting bulk cleanup test..."); // Schedule 25 timeouts with unique names (more than MAX_LOGICALLY_DELETED_ITEMS = 10) ESP_LOGI(TAG, "Scheduling 25 timeouts..."); for (int i = 0; i < 25; i++) { - std::string name = "bulk_timeout_" + std::to_string(i); - App.scheduler.set_timeout(this, name, 2500, [i]() { + App.scheduler.set_timeout(this, BULK_TIMEOUT_NAMES[i], 2500, [i]() { // These should never execute as we'll cancel them ESP_LOGW(TAG, "Timeout %d executed - this should not happen!", i); }); @@ -24,8 +33,7 @@ void SchedulerBulkCleanupComponent::trigger_bulk_cleanup() { // Cancel all of them to mark for removal ESP_LOGI(TAG, "Cancelling all 25 timeouts to trigger bulk cleanup..."); int cancelled_count = 0; - for (int i = 0; i < 25; i++) { - std::string name = "bulk_timeout_" + std::to_string(i); + for (const char *name : BULK_TIMEOUT_NAMES) { if (App.scheduler.cancel_timeout(this, name)) { cancelled_count++; } @@ -56,8 +64,7 @@ void SchedulerBulkCleanupComponent::trigger_bulk_cleanup() { // Also schedule some normal timeouts to ensure scheduler keeps working after cleanup static int post_cleanup_count = 0; for (int i = 0; i < 5; i++) { - std::string name = "post_cleanup_" + std::to_string(i); - App.scheduler.set_timeout(this, name, 50 + i * 25, [i]() { + App.scheduler.set_timeout(this, POST_CLEANUP_NAMES[i], 50 + i * 25, [i]() { ESP_LOGI(TAG, "Post-cleanup timeout %d executed correctly", i); post_cleanup_count++; if (post_cleanup_count >= 5) { diff --git a/tests/integration/fixtures/external_components/scheduler_rapid_cancellation_component/rapid_cancellation_component.cpp b/tests/integration/fixtures/external_components/scheduler_rapid_cancellation_component/rapid_cancellation_component.cpp index 0e5525d265..4971a15dbc 100644 --- a/tests/integration/fixtures/external_components/scheduler_rapid_cancellation_component/rapid_cancellation_component.cpp +++ b/tests/integration/fixtures/external_components/scheduler_rapid_cancellation_component/rapid_cancellation_component.cpp @@ -4,12 +4,18 @@ #include #include #include -#include namespace esphome::scheduler_rapid_cancellation_component { static const char *const TAG = "scheduler_rapid_cancellation"; +// Static name table keeps the const char* pointers valid for the lifetime of the scheduled tasks. +// Threads race over this fixed set of names; STATIC_STRING names match by content, so scheduling +// the same name replaces (implicitly cancels) the previous timeout, exactly as before. +static const char *const SHARED_TIMEOUT_NAMES[10] = { + "shared_timeout_0", "shared_timeout_1", "shared_timeout_2", "shared_timeout_3", "shared_timeout_4", + "shared_timeout_5", "shared_timeout_6", "shared_timeout_7", "shared_timeout_8", "shared_timeout_9"}; + void SchedulerRapidCancellationComponent::setup() { ESP_LOGCONFIG(TAG, "SchedulerRapidCancellationComponent setup"); } void SchedulerRapidCancellationComponent::run_rapid_cancellation_test() { @@ -32,14 +38,12 @@ void SchedulerRapidCancellationComponent::run_rapid_cancellation_test() { for (int i = 0; i < OPERATIONS_PER_THREAD; i++) { // Use modulo to ensure multiple threads use the same names int name_index = i % NUM_NAMES; - std::stringstream ss; - ss << "shared_timeout_" << name_index; - std::string name = ss.str(); + const char *name = SHARED_TIMEOUT_NAMES[name_index]; // All threads schedule timeouts - this will implicitly cancel existing ones this->set_timeout(name, 150, [this, name]() { this->total_executed_.fetch_add(1); - ESP_LOGI(TAG, "Executed callback '%s'", name.c_str()); + ESP_LOGI(TAG, "Executed callback '%s'", name); }); this->total_scheduled_.fetch_add(1); diff --git a/tests/integration/fixtures/external_components/scheduler_simultaneous_callbacks_component/simultaneous_callbacks_component.cpp b/tests/integration/fixtures/external_components/scheduler_simultaneous_callbacks_component/simultaneous_callbacks_component.cpp index a817b9f508..a3d135527f 100644 --- a/tests/integration/fixtures/external_components/scheduler_simultaneous_callbacks_component/simultaneous_callbacks_component.cpp +++ b/tests/integration/fixtures/external_components/scheduler_simultaneous_callbacks_component/simultaneous_callbacks_component.cpp @@ -1,9 +1,9 @@ #include "simultaneous_callbacks_component.h" #include "esphome/core/log.h" +#include #include #include #include -#include namespace esphome::scheduler_simultaneous_callbacks_component { @@ -41,13 +41,11 @@ void SchedulerSimultaneousCallbacksComponent::run_simultaneous_callbacks_test() std::this_thread::sleep_until(start_time + std::chrono::microseconds(100)); for (int i = 0; i < CALLBACKS_PER_THREAD; i++) { - // Create unique name for each callback - std::stringstream ss; - ss << "thread_" << thread_id << "_cb_" << i; - std::string name = ss.str(); + // Unique numeric ID for each callback (zero heap allocation, no name collisions) + uint32_t callback_id = static_cast(thread_id) * CALLBACKS_PER_THREAD + i; // Schedule callback for exactly DELAY_MS from now - this->set_timeout(name, DELAY_MS, [this, name]() { + this->set_timeout(callback_id, DELAY_MS, [this, callback_id]() { // Increment concurrent counter atomically int current = this->callbacks_at_once_.fetch_add(1) + 1; @@ -57,7 +55,7 @@ void SchedulerSimultaneousCallbacksComponent::run_simultaneous_callbacks_test() // Loop until we successfully update or someone else set a higher value } - ESP_LOGV(TAG, "Callback executed: %s (concurrent: %d)", name.c_str(), current); + ESP_LOGV(TAG, "Callback executed: id=%" PRIu32 " (concurrent: %d)", callback_id, current); // Simulate some minimal work std::atomic work{0}; @@ -73,7 +71,7 @@ void SchedulerSimultaneousCallbacksComponent::run_simultaneous_callbacks_test() }); this->total_scheduled_.fetch_add(1); - ESP_LOGV(TAG, "Scheduled callback %s", name.c_str()); + ESP_LOGV(TAG, "Scheduled callback id=%" PRIu32, callback_id); } ESP_LOGD(TAG, "Thread %d completed scheduling", thread_id); diff --git a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/__init__.py b/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/__init__.py deleted file mode 100644 index 3f29a839ef..0000000000 --- a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -import esphome.codegen as cg -import esphome.config_validation as cv -from esphome.const import CONF_ID - -scheduler_string_lifetime_component_ns = cg.esphome_ns.namespace( - "scheduler_string_lifetime_component" -) -SchedulerStringLifetimeComponent = scheduler_string_lifetime_component_ns.class_( - "SchedulerStringLifetimeComponent", cg.Component -) - -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(SchedulerStringLifetimeComponent), - } -).extend(cv.COMPONENT_SCHEMA) - - -async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp b/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp deleted file mode 100644 index cc1b9f7814..0000000000 --- a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp +++ /dev/null @@ -1,260 +0,0 @@ -#include "string_lifetime_component.h" -#include "esphome/core/log.h" -#include -#include -#include - -namespace esphome::scheduler_string_lifetime_component { - -static const char *const TAG = "scheduler_string_lifetime"; - -void SchedulerStringLifetimeComponent::setup() { ESP_LOGCONFIG(TAG, "SchedulerStringLifetimeComponent setup"); } - -void SchedulerStringLifetimeComponent::run_string_lifetime_test() { - ESP_LOGI(TAG, "Starting string lifetime tests"); - - this->tests_passed_ = 0; - this->tests_failed_ = 0; - - // Run each test - test_temporary_string_lifetime(); - test_scope_exit_string(); - test_vector_reallocation(); - test_string_move_semantics(); - test_lambda_capture_lifetime(); -} - -void SchedulerStringLifetimeComponent::run_test1() { - test_temporary_string_lifetime(); - // Wait for all callbacks to execute - this->set_timeout("test1_complete", 10, []() { ESP_LOGI(TAG, "Test 1 complete"); }); -} - -void SchedulerStringLifetimeComponent::run_test2() { - test_scope_exit_string(); - // Wait for all callbacks to execute - this->set_timeout("test2_complete", 20, []() { ESP_LOGI(TAG, "Test 2 complete"); }); -} - -void SchedulerStringLifetimeComponent::run_test3() { - test_vector_reallocation(); - // Wait for all callbacks to execute - this->set_timeout("test3_complete", 60, []() { ESP_LOGI(TAG, "Test 3 complete"); }); -} - -void SchedulerStringLifetimeComponent::run_test4() { - test_string_move_semantics(); - // Wait for all callbacks to execute - this->set_timeout("test4_complete", 35, []() { ESP_LOGI(TAG, "Test 4 complete"); }); -} - -void SchedulerStringLifetimeComponent::run_test5() { - test_lambda_capture_lifetime(); - // Wait for all callbacks to execute - this->set_timeout("test5_complete", 50, []() { ESP_LOGI(TAG, "Test 5 complete"); }); -} - -void SchedulerStringLifetimeComponent::run_final_check() { - ESP_LOGI(TAG, "Tests passed: %d", this->tests_passed_); - ESP_LOGI(TAG, "Tests failed: %d", this->tests_failed_); - - if (this->tests_failed_ == 0) { - ESP_LOGI(TAG, "SUCCESS: All string lifetime tests passed!"); - } else { - ESP_LOGE(TAG, "FAILURE: %d string lifetime tests failed!", this->tests_failed_); - } - ESP_LOGI(TAG, "String lifetime tests complete"); -} - -void SchedulerStringLifetimeComponent::test_temporary_string_lifetime() { - ESP_LOGI(TAG, "Test 1: Temporary string lifetime for timeout names"); - - // Test with a temporary string that goes out of scope immediately - { - std::string temp_name = "temp_callback_" + std::to_string(12345); - - // Schedule with temporary string name - scheduler must copy/store this - this->set_timeout(temp_name, 1, [this]() { - ESP_LOGD(TAG, "Callback for temp string name executed"); - this->tests_passed_++; - }); - - // String goes out of scope here, but scheduler should have made a copy - } - - // Test with rvalue string as name - this->set_timeout(std::string("rvalue_test"), 2, [this]() { - ESP_LOGD(TAG, "Rvalue string name callback executed"); - this->tests_passed_++; - }); - - // Test cancelling with reconstructed string - { - std::string cancel_name = "cancel_test_" + std::to_string(999); - this->set_timeout(cancel_name, 100, [this]() { - ESP_LOGE(TAG, "This should have been cancelled!"); - this->tests_failed_++; - }); - } // cancel_name goes out of scope - - // Reconstruct the same string to cancel - std::string cancel_name_2 = "cancel_test_" + std::to_string(999); - bool cancelled = this->cancel_timeout(cancel_name_2); - if (cancelled) { - ESP_LOGD(TAG, "Successfully cancelled with reconstructed string"); - this->tests_passed_++; - } else { - ESP_LOGE(TAG, "Failed to cancel with reconstructed string"); - this->tests_failed_++; - } -} - -void SchedulerStringLifetimeComponent::test_scope_exit_string() { - ESP_LOGI(TAG, "Test 2: Scope exit string names"); - - // Create string names in a limited scope - { - std::string scoped_name = "scoped_timeout_" + std::to_string(555); - - // Schedule with scoped string name - this->set_timeout(scoped_name, 3, [this]() { - ESP_LOGD(TAG, "Scoped name callback executed"); - this->tests_passed_++; - }); - - // scoped_name goes out of scope here - } - - // Test with dynamically allocated string name - { - auto *dynamic_name = new std::string("dynamic_timeout_" + std::to_string(777)); - - this->set_timeout(*dynamic_name, 4, [this, dynamic_name]() { - ESP_LOGD(TAG, "Dynamic string name callback executed"); - this->tests_passed_++; - delete dynamic_name; // Clean up in callback - }); - - // Pointer goes out of scope but string object remains until callback - } - - // Test multiple timeouts with same dynamically created name - for (int i = 0; i < 3; i++) { - std::string loop_name = "loop_timeout_" + std::to_string(i); - this->set_timeout(loop_name, 5 + i * 1, [this, i]() { - ESP_LOGD(TAG, "Loop timeout %d executed", i); - this->tests_passed_++; - }); - // loop_name destroyed and recreated each iteration - } -} - -void SchedulerStringLifetimeComponent::test_vector_reallocation() { - ESP_LOGI(TAG, "Test 3: Vector reallocation stress on timeout names"); - - // Create a vector that will reallocate - std::vector names; - names.reserve(2); // Small initial capacity to force reallocation - - // Schedule callbacks with string names from vector - for (int i = 0; i < 10; i++) { - names.push_back("vector_cb_" + std::to_string(i)); - // Use the string from vector as timeout name - this->set_timeout(names.back(), 8 + i * 1, [this, i]() { - ESP_LOGV(TAG, "Vector name callback %d executed", i); - this->tests_passed_++; - }); - } - - // Force reallocation by adding more elements - // This will move all strings to new memory locations - for (int i = 10; i < 50; i++) { - names.push_back("realloc_trigger_" + std::to_string(i)); - } - - // Add more timeouts after reallocation to ensure old names still work - for (int i = 50; i < 55; i++) { - names.push_back("post_realloc_" + std::to_string(i)); - this->set_timeout(names.back(), 20 + (i - 50), [this]() { - ESP_LOGV(TAG, "Post-reallocation callback executed"); - this->tests_passed_++; - }); - } - - // Clear the vector while timeouts are still pending - names.clear(); - ESP_LOGD(TAG, "Vector cleared - all string names destroyed"); -} - -void SchedulerStringLifetimeComponent::test_string_move_semantics() { - ESP_LOGI(TAG, "Test 4: String move semantics for timeout names"); - - // Test moving string names - std::string original = "move_test_original"; - std::string moved = std::move(original); - - // Schedule with moved string as name - this->set_timeout(moved, 30, [this]() { - ESP_LOGD(TAG, "Moved string name callback executed"); - this->tests_passed_++; - }); - - // original is now empty, try to use it as a different timeout name - original = "reused_after_move"; - this->set_timeout(original, 32, [this]() { - ESP_LOGD(TAG, "Reused string name callback executed"); - this->tests_passed_++; - }); -} - -void SchedulerStringLifetimeComponent::test_lambda_capture_lifetime() { - ESP_LOGI(TAG, "Test 5: Complex timeout name scenarios"); - - // Test scheduling with name built in lambda - [this]() { - std::string lambda_name = "lambda_built_name_" + std::to_string(888); - this->set_timeout(lambda_name, 38, [this]() { - ESP_LOGD(TAG, "Lambda-built name callback executed"); - this->tests_passed_++; - }); - }(); // Lambda executes and lambda_name is destroyed - - // Test with shared_ptr name - auto shared_name = std::make_shared("shared_ptr_timeout"); - this->set_timeout(*shared_name, 40, [this, shared_name]() { - ESP_LOGD(TAG, "Shared_ptr name callback executed"); - this->tests_passed_++; - }); - shared_name.reset(); // Release the shared_ptr - - // Test overwriting timeout with same name - std::string overwrite_name = "overwrite_test"; - this->set_timeout(overwrite_name, 1000, [this]() { - ESP_LOGE(TAG, "This should have been overwritten!"); - this->tests_failed_++; - }); - - // Overwrite with shorter timeout - this->set_timeout(overwrite_name, 42, [this]() { - ESP_LOGD(TAG, "Overwritten timeout executed"); - this->tests_passed_++; - }); - - // Test very long string name - std::string long_name; - for (int i = 0; i < 100; i++) { - long_name += "very_long_timeout_name_segment_" + std::to_string(i) + "_"; - } - this->set_timeout(long_name, 44, [this]() { - ESP_LOGD(TAG, "Very long name timeout executed"); - this->tests_passed_++; - }); - - // Test empty string as name - this->set_timeout("", 46, [this]() { - ESP_LOGD(TAG, "Empty string name timeout executed"); - this->tests_passed_++; - }); -} - -} // namespace esphome::scheduler_string_lifetime_component diff --git a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.h b/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.h deleted file mode 100644 index 20185f128d..0000000000 --- a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.h +++ /dev/null @@ -1,35 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include -#include - -namespace esphome::scheduler_string_lifetime_component { - -class SchedulerStringLifetimeComponent : public Component { - public: - void setup() override; - float get_setup_priority() const override { return setup_priority::LATE; } - - void run_string_lifetime_test(); - - // Individual test methods exposed as services - void run_test1(); - void run_test2(); - void run_test3(); - void run_test4(); - void run_test5(); - void run_final_check(); - - private: - void test_temporary_string_lifetime(); - void test_scope_exit_string(); - void test_vector_reallocation(); - void test_string_move_semantics(); - void test_lambda_capture_lifetime(); - - int tests_passed_{0}; - int tests_failed_{0}; -}; - -} // namespace esphome::scheduler_string_lifetime_component diff --git a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/__init__.py b/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/__init__.py deleted file mode 100644 index 6cc564395c..0000000000 --- a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -import esphome.codegen as cg -import esphome.config_validation as cv -from esphome.const import CONF_ID - -scheduler_string_name_stress_component_ns = cg.esphome_ns.namespace( - "scheduler_string_name_stress_component" -) -SchedulerStringNameStressComponent = scheduler_string_name_stress_component_ns.class_( - "SchedulerStringNameStressComponent", cg.Component -) - -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(SchedulerStringNameStressComponent), - } -).extend(cv.COMPONENT_SCHEMA) - - -async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.cpp b/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.cpp deleted file mode 100644 index 677d371f25..0000000000 --- a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.cpp +++ /dev/null @@ -1,108 +0,0 @@ -#include "string_name_stress_component.h" -#include "esphome/core/log.h" -#include -#include -#include -#include -#include -#include - -namespace esphome::scheduler_string_name_stress_component { - -static const char *const TAG = "scheduler_string_name_stress"; - -void SchedulerStringNameStressComponent::setup() { ESP_LOGCONFIG(TAG, "SchedulerStringNameStressComponent setup"); } - -void SchedulerStringNameStressComponent::run_string_name_stress_test() { - // Use member variables to reset state - this->total_callbacks_ = 0; - this->executed_callbacks_ = 0; - static constexpr int NUM_THREADS = 10; - static constexpr int CALLBACKS_PER_THREAD = 100; - - ESP_LOGI(TAG, "Starting string name stress test - multi-threaded set_timeout with std::string names"); - ESP_LOGI(TAG, "This test specifically uses dynamic string names to test memory management"); - - // Track start time - auto start_time = std::chrono::steady_clock::now(); - - // Create threads - std::vector threads; - - ESP_LOGI(TAG, "Creating %d threads, each will schedule %d callbacks with dynamic names", NUM_THREADS, - CALLBACKS_PER_THREAD); - - threads.reserve(NUM_THREADS); - for (int i = 0; i < NUM_THREADS; i++) { - threads.emplace_back([this, i]() { - ESP_LOGV(TAG, "Thread %d starting", i); - - // Each thread schedules callbacks with dynamically created string names - for (int j = 0; j < CALLBACKS_PER_THREAD; j++) { - int callback_id = this->total_callbacks_.fetch_add(1); - - // Create a dynamic string name - this will test memory management - std::stringstream ss; - ss << "thread_" << i << "_callback_" << j << "_id_" << callback_id; - std::string dynamic_name = ss.str(); - - ESP_LOGV(TAG, "Thread %d scheduling timeout with dynamic name: %s", i, dynamic_name.c_str()); - - // Capture necessary values for the lambda - auto *component = this; - - // Schedule with std::string name - this tests the string overload - // Use varying delays to stress the heap scheduler - uint32_t delay = 1 + (callback_id % 50); - - // Also test nested scheduling from callbacks - if (j % 10 == 0) { - // Every 10th callback schedules another callback - this->set_timeout(dynamic_name, delay, [component, callback_id]() { - component->executed_callbacks_.fetch_add(1); - ESP_LOGV(TAG, "Executed string-named callback %d (nested scheduler)", callback_id); - - // Schedule another timeout from within this callback with a new dynamic name - std::string nested_name = "nested_from_" + std::to_string(callback_id); - component->set_timeout(nested_name, 1, [callback_id]() { - ESP_LOGV(TAG, "Executed nested string-named callback from %d", callback_id); - }); - }); - } else { - // Regular callback - this->set_timeout(dynamic_name, delay, [component, callback_id]() { - component->executed_callbacks_.fetch_add(1); - ESP_LOGV(TAG, "Executed string-named callback %d", callback_id); - }); - } - - // Add some timing variations to increase race conditions - if (j % 5 == 0) { - std::this_thread::sleep_for(std::chrono::microseconds(100)); - } - } - ESP_LOGV(TAG, "Thread %d finished scheduling", i); - }); - } - - // Wait for all threads to complete scheduling - for (auto &t : threads) { - t.join(); - } - - auto end_time = std::chrono::steady_clock::now(); - auto thread_time = std::chrono::duration_cast(end_time - start_time).count(); - ESP_LOGI(TAG, "All threads finished scheduling in %lldms. Created %d callbacks with dynamic names", thread_time, - this->total_callbacks_.load()); - - // Give some time for callbacks to execute - ESP_LOGI(TAG, "Waiting for callbacks to execute..."); - - // Schedule a final callback to signal completion - this->set_timeout("test_complete", 2000, [this]() { - ESP_LOGI(TAG, "String name stress test complete. Executed %d of %d callbacks", this->executed_callbacks_.load(), - this->total_callbacks_.load()); - }); -} - -} // namespace esphome::scheduler_string_name_stress_component diff --git a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.h b/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.h deleted file mode 100644 index 121bda6204..0000000000 --- a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.h +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include - -namespace esphome::scheduler_string_name_stress_component { - -class SchedulerStringNameStressComponent : public Component { - public: - void setup() override; - float get_setup_priority() const override { return setup_priority::LATE; } - - void run_string_name_stress_test(); - - private: - std::atomic total_callbacks_{0}; - std::atomic executed_callbacks_{0}; -}; - -} // namespace esphome::scheduler_string_name_stress_component diff --git a/tests/integration/fixtures/scheduler_pool.yaml b/tests/integration/fixtures/scheduler_pool.yaml index 989c1535b0..a75d9dbcbc 100644 --- a/tests/integration/fixtures/scheduler_pool.yaml +++ b/tests/integration/fixtures/scheduler_pool.yaml @@ -156,9 +156,9 @@ script: // Simulate a burst of defer operations like ratgdo does with state updates // These should execute immediately and recycle quickly to the pool + // Phase-specific id range (0..9) so ids never collide with later phases for (int i = 0; i < 10; i++) { - std::string defer_name = "defer_" + std::to_string(i); - App.scheduler.set_timeout(component, defer_name, 0, [i]() { + App.scheduler.set_timeout(component, static_cast(i), 0, [i]() { ESP_LOGD("test", "Defer %d executed", i); // Force a small delay between defer executions to see recycling if (i == 5) { @@ -207,9 +207,9 @@ script: // Now create 8 new timeouts - they should reuse from pool when available int reuse_test_count = 8; + // Phase-specific id range (100..107) so ids never collide with other phases for (int i = 0; i < reuse_test_count; i++) { - std::string name = "reuse_test_" + std::to_string(i); - App.scheduler.set_timeout(component, name, 10 + i * 5, [i]() { + App.scheduler.set_timeout(component, static_cast(100 + i), 10 + i * 5, [i]() { ESP_LOGD("test", "Reuse test %d completed", i); }); } @@ -229,9 +229,9 @@ script: auto *component = id(test_sensor); int full_reuse_count = 10; + // Phase-specific id range (200..209) so ids never collide with other phases for (int i = 0; i < full_reuse_count; i++) { - std::string name = "full_reuse_" + std::to_string(i); - App.scheduler.set_timeout(component, name, 10 + i * 5, [i]() { + App.scheduler.set_timeout(component, static_cast(200 + i), 10 + i * 5, [i]() { ESP_LOGD("test", "Full reuse test %d completed", i); }); } diff --git a/tests/integration/fixtures/scheduler_string_lifetime.yaml b/tests/integration/fixtures/scheduler_string_lifetime.yaml deleted file mode 100644 index 5ae5a1914e..0000000000 --- a/tests/integration/fixtures/scheduler_string_lifetime.yaml +++ /dev/null @@ -1,48 +0,0 @@ -esphome: - debug_scheduler: true # Enable scheduler leak detection - name: scheduler-string-lifetime-test - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - components: [scheduler_string_lifetime_component] - -host: - -logger: - level: DEBUG - -scheduler_string_lifetime_component: - id: string_lifetime - -api: - services: - - service: run_string_lifetime_test - then: - - lambda: |- - id(string_lifetime)->run_string_lifetime_test(); - - service: run_test1 - then: - - lambda: |- - id(string_lifetime)->run_test1(); - - service: run_test2 - then: - - lambda: |- - id(string_lifetime)->run_test2(); - - service: run_test3 - then: - - lambda: |- - id(string_lifetime)->run_test3(); - - service: run_test4 - then: - - lambda: |- - id(string_lifetime)->run_test4(); - - service: run_test5 - then: - - lambda: |- - id(string_lifetime)->run_test5(); - - service: run_final_check - then: - - lambda: |- - id(string_lifetime)->run_final_check(); diff --git a/tests/integration/fixtures/scheduler_string_name_stress.yaml b/tests/integration/fixtures/scheduler_string_name_stress.yaml deleted file mode 100644 index 8f68d1d102..0000000000 --- a/tests/integration/fixtures/scheduler_string_name_stress.yaml +++ /dev/null @@ -1,39 +0,0 @@ -esphome: - debug_scheduler: true # Enable scheduler leak detection - name: sched-string-name-stress - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - components: [scheduler_string_name_stress_component] - -host: - -logger: - level: VERBOSE - -scheduler_string_name_stress_component: - id: string_stress - -api: - services: - - service: run_string_name_stress_test - then: - - lambda: |- - id(string_stress)->run_string_name_stress_test(); - -event: - - platform: template - name: "Test Complete" - id: test_complete - device_class: button - event_types: - - "test_finished" - - platform: template - name: "Test Result" - id: test_result - device_class: button - event_types: - - "passed" - - "failed" diff --git a/tests/integration/fixtures/scheduler_string_test.yaml b/tests/integration/fixtures/scheduler_string_test.yaml index c53ec392df..06e3a4c97c 100644 --- a/tests/integration/fixtures/scheduler_string_test.yaml +++ b/tests/integration/fixtures/scheduler_string_test.yaml @@ -18,9 +18,6 @@ globals: - id: interval_counter type: int initial_value: '0' - - id: dynamic_counter - type: int - initial_value: '0' - id: static_tests_done type: bool initial_value: 'false' @@ -103,46 +100,43 @@ script: - id: test_dynamic_strings then: - - logger.log: "Testing dynamic string timeouts and intervals" + - logger.log: "Testing const char* timeouts and intervals" - lambda: |- auto *component2 = id(test_sensor2); - // Test 8: Dynamic string with set_timeout (std::string) - std::string dynamic_name = "dynamic_timeout_" + std::to_string(id(dynamic_counter)++); - App.scheduler.set_timeout(component2, dynamic_name, 100, []() { + // Test 8: const char* name with set_timeout + App.scheduler.set_timeout(component2, "dynamic_timeout", 100, []() { ESP_LOGI("test", "Dynamic timeout fired"); id(timeout_counter) += 1; }); - // Test 9: Dynamic string with set_interval - std::string interval_name = "dynamic_interval_" + std::to_string(id(dynamic_counter)++); - App.scheduler.set_interval(component2, interval_name, 250, [interval_name]() { - ESP_LOGI("test", "Dynamic interval fired: %s", interval_name.c_str()); + // Test 9: const char* name with set_interval, cancelled from inside the callback + App.scheduler.set_interval(component2, "dynamic_interval", 250, []() { + ESP_LOGI("test", "Dynamic interval fired"); id(interval_counter) += 1; if (id(interval_counter) >= 6) { - App.scheduler.cancel_interval(id(test_sensor2), interval_name); + App.scheduler.cancel_interval(id(test_sensor2), "dynamic_interval"); ESP_LOGI("test", "Cancelled dynamic interval"); } }); - // Test 10: Cancel with different string object but same content - std::string cancel_name = "cancel_test"; - App.scheduler.set_timeout(component2, cancel_name, 2000, []() { + // Test 10: Cancel with a different pointer but identical content. + // STATIC_STRING names match by content, so a distinct static buffer with the + // same characters still cancels the scheduled timeout. + static const char CANCEL_NAME[] = "cancel_test"; + App.scheduler.set_timeout(component2, CANCEL_NAME, 2000, []() { ESP_LOGI("test", "This should be cancelled"); }); + static const char CANCEL_NAME_2[] = "cancel_test"; + App.scheduler.cancel_timeout(component2, CANCEL_NAME_2); + ESP_LOGI("test", "Cancelled timeout using different buffer with same content"); - // Cancel using a different string object - std::string cancel_name_2 = "cancel_test"; - App.scheduler.cancel_timeout(component2, cancel_name_2); - ESP_LOGI("test", "Cancelled timeout using different string object"); - - // Test 11: Dynamic string with defer (using std::string overload) + // Test 11: const char* name with defer class TestDynamicDeferComponent : public Component { public: void test_dynamic_defer() { - std::string defer_name = "dynamic_defer_" + std::to_string(id(dynamic_counter)++); - this->defer(defer_name, [defer_name]() { - ESP_LOGI("test", "Dynamic defer fired: %s", defer_name.c_str()); + this->defer("dynamic_defer", []() { + ESP_LOGI("test", "Dynamic defer fired"); id(timeout_counter) += 1; }); } diff --git a/tests/integration/test_scheduler_string_lifetime.py b/tests/integration/test_scheduler_string_lifetime.py deleted file mode 100644 index bfa581129b..0000000000 --- a/tests/integration/test_scheduler_string_lifetime.py +++ /dev/null @@ -1,169 +0,0 @@ -"""String lifetime test - verify scheduler handles string destruction correctly.""" - -import asyncio -from pathlib import Path -import re - -import pytest - -from .types import APIClientConnectedFactory, RunCompiledFunction - - -@pytest.mark.asyncio -async def test_scheduler_string_lifetime( - yaml_config: str, - run_compiled: RunCompiledFunction, - api_client_connected: APIClientConnectedFactory, -) -> None: - """Test that scheduler correctly handles string lifetimes when strings go out of scope.""" - - # Get the absolute path to the external components directory - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - - # Replace the placeholder in the YAML config with the actual path - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - - # Create events for synchronization - test1_complete = asyncio.Event() - test2_complete = asyncio.Event() - test3_complete = asyncio.Event() - test4_complete = asyncio.Event() - test5_complete = asyncio.Event() - all_tests_complete = asyncio.Event() - - # Track test progress - test_stats = { - "tests_passed": 0, - "tests_failed": 0, - "errors": [], - "current_test": None, - "test_callbacks_executed": {}, - } - - def on_log_line(line: str) -> None: - # Track test-specific events - if "Test 1 complete" in line: - test1_complete.set() - elif "Test 2 complete" in line: - test2_complete.set() - elif "Test 3 complete" in line: - test3_complete.set() - elif "Test 4 complete" in line: - test4_complete.set() - elif "Test 5 complete" in line: - test5_complete.set() - - # Track individual callback executions - callback_match = re.search(r"Callback '(.+?)' executed", line) - if callback_match: - callback_name = callback_match.group(1) - test_stats["test_callbacks_executed"][callback_name] = True - - # Track test results from the C++ test output - if "Tests passed:" in line and "string_lifetime" in line: - # Extract the number from "Tests passed: 32" - match = re.search(r"Tests passed:\s*(\d+)", line) - if match: - test_stats["tests_passed"] = int(match.group(1)) - elif "Tests failed:" in line and "string_lifetime" in line: - match = re.search(r"Tests failed:\s*(\d+)", line) - if match: - test_stats["tests_failed"] = int(match.group(1)) - elif "ERROR" in line and "string_lifetime" in line: - test_stats["errors"].append(line) - - # Check for memory corruption indicators - if any( - indicator in line.lower() - for indicator in [ - "use after free", - "heap corruption", - "segfault", - "abort", - "assertion", - "sanitizer", - "bad memory", - "invalid pointer", - ] - ): - pytest.fail(f"Memory corruption detected: {line}") - - # Check for completion - if "String lifetime tests complete" in line: - all_tests_complete.set() - - async with ( - run_compiled(yaml_config, line_callback=on_log_line), - api_client_connected() as client, - ): - # Verify we can connect - device_info = await client.device_info() - assert device_info is not None - assert device_info.name == "scheduler-string-lifetime-test" - - # List entities and services - _, services = await asyncio.wait_for( - client.list_entities_services(), timeout=5.0 - ) - - # Find our test services - test_services = {} - for service in services: - if service.name == "run_test1": - test_services["test1"] = service - elif service.name == "run_test2": - test_services["test2"] = service - elif service.name == "run_test3": - test_services["test3"] = service - elif service.name == "run_test4": - test_services["test4"] = service - elif service.name == "run_test5": - test_services["test5"] = service - elif service.name == "run_final_check": - test_services["final"] = service - - # Ensure all services are found - required_services = ["test1", "test2", "test3", "test4", "test5", "final"] - for service_name in required_services: - assert service_name in test_services, f"{service_name} service not found" - - # Run tests sequentially, waiting for each to complete - try: - # Test 1 - await client.execute_service(test_services["test1"], {}) - await asyncio.wait_for(test1_complete.wait(), timeout=5.0) - - # Test 2 - await client.execute_service(test_services["test2"], {}) - await asyncio.wait_for(test2_complete.wait(), timeout=5.0) - - # Test 3 - await client.execute_service(test_services["test3"], {}) - await asyncio.wait_for(test3_complete.wait(), timeout=5.0) - - # Test 4 - await client.execute_service(test_services["test4"], {}) - await asyncio.wait_for(test4_complete.wait(), timeout=5.0) - - # Test 5 - await client.execute_service(test_services["test5"], {}) - await asyncio.wait_for(test5_complete.wait(), timeout=5.0) - - # Final check - await client.execute_service(test_services["final"], {}) - await asyncio.wait_for(all_tests_complete.wait(), timeout=5.0) - - except TimeoutError: - pytest.fail(f"String lifetime test timed out. Stats: {test_stats}") - - # Check for any errors - assert test_stats["tests_failed"] == 0, f"Tests failed: {test_stats['errors']}" - - # Verify we had the expected number of passing tests - assert test_stats["tests_passed"] == 30, ( - f"Expected exactly 30 tests to pass, but got {test_stats['tests_passed']}" - ) diff --git a/tests/integration/test_scheduler_string_name_stress.py b/tests/integration/test_scheduler_string_name_stress.py deleted file mode 100644 index 56b8998c56..0000000000 --- a/tests/integration/test_scheduler_string_name_stress.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Stress test for heap scheduler with std::string names from multiple threads.""" - -import asyncio -from pathlib import Path -import re - -from aioesphomeapi import UserService -import pytest - -from .types import APIClientConnectedFactory, RunCompiledFunction - - -@pytest.mark.asyncio -async def test_scheduler_string_name_stress( - yaml_config: str, - run_compiled: RunCompiledFunction, - api_client_connected: APIClientConnectedFactory, -) -> None: - """Test that set_timeout/set_interval with std::string names doesn't crash when called from multiple threads.""" - - # Get the absolute path to the external components directory - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - - # Replace the placeholder in the YAML config with the actual path - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - - # Create a future to signal test completion - loop = asyncio.get_running_loop() - test_complete_future: asyncio.Future[None] = loop.create_future() - - # Track executed callbacks and any crashes - executed_callbacks: set[int] = set() - error_messages: list[str] = [] - - def on_log_line(line: str) -> None: - # Check for crash indicators - if any( - indicator in line.lower() - for indicator in [ - "segfault", - "abort", - "assertion", - "heap corruption", - "use after free", - ] - ): - error_messages.append(line) - if not test_complete_future.done(): - test_complete_future.set_exception(Exception(f"Crash detected: {line}")) - return - - # Track executed callbacks - match = re.search(r"Executed string-named callback (\d+)", line) - if match: - callback_id = int(match.group(1)) - executed_callbacks.add(callback_id) - - # Check for completion - if ( - "String name stress test complete" in line - and not test_complete_future.done() - ): - test_complete_future.set_result(None) - - async with ( - run_compiled(yaml_config, line_callback=on_log_line), - api_client_connected() as client, - ): - # Verify we can connect - device_info = await client.device_info() - assert device_info is not None - assert device_info.name == "sched-string-name-stress" - - # List entities and services - _, services = await asyncio.wait_for( - client.list_entities_services(), timeout=5.0 - ) - - # Find our test service - run_stress_test_service: UserService | None = None - for service in services: - if service.name == "run_string_name_stress_test": - run_stress_test_service = service - break - - assert run_stress_test_service is not None, ( - "run_string_name_stress_test service not found" - ) - - # Call the service to start the test - await client.execute_service(run_stress_test_service, {}) - - # Wait for test to complete or crash - try: - await asyncio.wait_for(test_complete_future, timeout=30.0) - except TimeoutError: - pytest.fail( - f"String name stress test timed out. Executed {len(executed_callbacks)} callbacks. " - f"This might indicate a deadlock." - ) - - # Verify no errors occurred (crashes already handled by exception) - assert not error_messages, f"Errors detected during test: {error_messages}" - - # Verify we executed all 1000 callbacks (10 threads × 100 callbacks each) - assert len(executed_callbacks) == 1000, ( - f"Expected 1000 callbacks but got {len(executed_callbacks)}" - ) - - # Verify each callback ID was executed exactly once - for i in range(1000): - assert i in executed_callbacks, f"Callback {i} was not executed" diff --git a/tests/integration/test_scheduler_string_test.py b/tests/integration/test_scheduler_string_test.py index 783ed37c13..3bc3487432 100644 --- a/tests/integration/test_scheduler_string_test.py +++ b/tests/integration/test_scheduler_string_test.py @@ -99,7 +99,7 @@ async def test_scheduler_string_test( timeout_count += 1 # Check for cancel test - elif "Cancelled timeout using different string object" in clean_line: + elif "Cancelled timeout using different buffer with same content" in clean_line: cancel_test_done.set() # Check for final results From d0e3e98d552d03e7cc2f0896b48c26b6f32dc4bd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 09:33:27 +1200 Subject: [PATCH 0515/1815] [dashboard] Remove legacy web dashboard (#17124) --- .github/scripts/detect-tags.js | 1 - .../dashboard-deprecation-comment.yml | 119 -- AGENTS.md | 4 +- esphome/__main__.py | 68 +- .../components/dashboard_import/__init__.py | 1 - esphome/components/esp32/__init__.py | 16 +- esphome/components/esp8266/__init__.py | 16 +- esphome/components/libretiny/__init__.py | 16 +- esphome/components/rp2040/__init__.py | 16 +- esphome/dashboard/__init__.py | 0 esphome/dashboard/const.py | 32 - esphome/dashboard/core.py | 190 -- esphome/dashboard/dashboard.py | 153 -- esphome/dashboard/dns.py | 77 - esphome/dashboard/entries.py | 458 ---- esphome/dashboard/models.py | 76 - esphome/dashboard/settings.py | 101 - esphome/dashboard/status/__init__.py | 0 esphome/dashboard/status/mdns.py | 170 -- esphome/dashboard/status/mqtt.py | 78 - esphome/dashboard/status/ping.py | 151 -- esphome/dashboard/util/__init__.py | 0 esphome/dashboard/util/itertools.py | 22 - esphome/dashboard/util/password.py | 11 - esphome/dashboard/util/subprocess.py | 31 - esphome/dashboard/util/text.py | 15 - esphome/dashboard/web_server.py | 1645 -------------- esphome/helpers.py | 10 +- esphome/storage_json.py | 12 +- esphome/zeroconf.py | 24 +- requirements.txt | 3 - script/ci-custom.py | 9 +- tests/dashboard/__init__.py | 0 tests/dashboard/common.py | 6 - tests/dashboard/conftest.py | 43 - tests/dashboard/fixtures/conf/pico.yaml | 47 - tests/dashboard/status/__init__.py | 0 tests/dashboard/status/test_dns.py | 199 -- tests/dashboard/status/test_mdns.py | 240 --- tests/dashboard/test_entries.py | 288 --- tests/dashboard/test_settings.py | 287 --- tests/dashboard/test_web_server.py | 1889 ----------------- tests/dashboard/test_web_server_paths.py | 219 -- tests/dashboard/util/__init__.py | 0 tests/script/test_determine_jobs.py | 3 +- tests/unit_tests/test_helpers.py | 16 - tests/unit_tests/test_main.py | 40 + 47 files changed, 109 insertions(+), 6693 deletions(-) delete mode 100644 .github/workflows/dashboard-deprecation-comment.yml delete mode 100644 esphome/dashboard/__init__.py delete mode 100644 esphome/dashboard/const.py delete mode 100644 esphome/dashboard/core.py delete mode 100644 esphome/dashboard/dashboard.py delete mode 100644 esphome/dashboard/dns.py delete mode 100644 esphome/dashboard/entries.py delete mode 100644 esphome/dashboard/models.py delete mode 100644 esphome/dashboard/settings.py delete mode 100644 esphome/dashboard/status/__init__.py delete mode 100644 esphome/dashboard/status/mdns.py delete mode 100644 esphome/dashboard/status/mqtt.py delete mode 100644 esphome/dashboard/status/ping.py delete mode 100644 esphome/dashboard/util/__init__.py delete mode 100644 esphome/dashboard/util/itertools.py delete mode 100644 esphome/dashboard/util/password.py delete mode 100644 esphome/dashboard/util/subprocess.py delete mode 100644 esphome/dashboard/util/text.py delete mode 100644 esphome/dashboard/web_server.py delete mode 100644 tests/dashboard/__init__.py delete mode 100644 tests/dashboard/common.py delete mode 100644 tests/dashboard/conftest.py delete mode 100644 tests/dashboard/fixtures/conf/pico.yaml delete mode 100644 tests/dashboard/status/__init__.py delete mode 100644 tests/dashboard/status/test_dns.py delete mode 100644 tests/dashboard/status/test_mdns.py delete mode 100644 tests/dashboard/test_entries.py delete mode 100644 tests/dashboard/test_settings.py delete mode 100644 tests/dashboard/test_web_server.py delete mode 100644 tests/dashboard/test_web_server_paths.py delete mode 100644 tests/dashboard/util/__init__.py diff --git a/.github/scripts/detect-tags.js b/.github/scripts/detect-tags.js index 3933776c61..99caccc2f8 100644 --- a/.github/scripts/detect-tags.js +++ b/.github/scripts/detect-tags.js @@ -41,7 +41,6 @@ function hasCoreChanges(changedFiles) { */ function hasDashboardChanges(changedFiles) { return changedFiles.some(file => - file.startsWith('esphome/dashboard/') || file.startsWith('esphome/components/dashboard_import/') ); } diff --git a/.github/workflows/dashboard-deprecation-comment.yml b/.github/workflows/dashboard-deprecation-comment.yml deleted file mode 100644 index ffd5ec7bd9..0000000000 --- a/.github/workflows/dashboard-deprecation-comment.yml +++ /dev/null @@ -1,119 +0,0 @@ -name: Add Dashboard Deprecation Comment - -on: - pull_request_target: - types: [opened, synchronize] - -# All API calls (pulls.listFiles + issues.{list,create,update}Comment) are performed with -# the App token minted below, so the workflow's GITHUB_TOKEN does not need any scopes. -permissions: {} - -jobs: - dashboard-deprecation-comment: - name: Dashboard deprecation comment - runs-on: ubuntu-latest - # Release-bump PRs (bump-X.Y.Z -> beta, beta -> release) inevitably - # roll up everything merged into dev since the last cut, which can - # include dashboard changes that have already been reviewed once. - # The bot's purpose is to warn new contributors before they invest - # time -- that only applies to PRs entering dev. - if: github.event.pull_request.base.ref == 'dev' - steps: - - name: Generate a token - id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} - private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} - # pulls.listFiles + issues.{list,create,update}Comment on PRs. For PR resources - # the issues.*Comment APIs require the pull-requests scope, not issues. - permission-pull-requests: write - - - name: Add dashboard deprecation comment - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - github-token: ${{ steps.generate-token.outputs.token }} - script: | - const commentMarker = ""; - - const commentBody = `Thanks for opening this PR! - - Heads up: the legacy ESPHome dashboard (\`esphome/dashboard/\` and \`tests/dashboard/\`) is **deprecated** and is being replaced by [ESPHome Device Builder](https://github.com/esphome/device-builder). We are not adding new features to the legacy dashboard and it will eventually be removed from this repository. - - What this means for your PR: - - - **New features / enhancements**: please port the change to [esphome/device-builder](https://github.com/esphome/device-builder) instead. We are unlikely to review or merge new dashboard features here. - - **Bug fixes**: small fixes may still be considered, but please check first whether the same issue exists in Device Builder, where the fix will have a longer life. - - **Security issues**: please do not file a public PR. Report privately via [GitHub security advisories](https://github.com/esphome/esphome/security/advisories/new) so we can coordinate a fix. - - We appreciate the contribution and apologize for the friction; flagging this early so your time isn't spent on a change that may not land. - - --- - (Added by the PR bot) - - ${commentMarker}`; - - async function getDashboardChanges(github, owner, repo, prNumber) { - const changedFiles = await github.paginate( - github.rest.pulls.listFiles, - { - owner: owner, - repo: repo, - pull_number: prNumber, - per_page: 100, - } - ); - - return changedFiles.filter(file => - file.filename.startsWith('esphome/dashboard/') || - file.filename.startsWith('tests/dashboard/') - ); - } - - async function findBotComment(github, owner, repo, prNumber) { - const comments = await github.paginate( - github.rest.issues.listComments, - { - owner: owner, - repo: repo, - issue_number: prNumber, - per_page: 100, - } - ); - - return comments.find(comment => - comment.body.includes(commentMarker) && comment.user.type === "Bot" - ); - } - - const prNumber = context.payload.pull_request.number; - const { owner, repo } = context.repo; - - const dashboardChanges = await getDashboardChanges(github, owner, repo, prNumber); - const existingComment = await findBotComment(github, owner, repo, prNumber); - - if (dashboardChanges.length === 0) { - // PR doesn't (or no longer) touches the legacy dashboard. If we previously - // commented (e.g. files were removed in a later push), leave the comment in - // place for history rather than thrash on edit/delete. - return; - } - - if (existingComment) { - if (existingComment.body === commentBody) { - return; - } - await github.rest.issues.updateComment({ - owner: owner, - repo: repo, - comment_id: existingComment.id, - body: commentBody, - }); - } else { - await github.rest.issues.createComment({ - owner: owner, - repo: repo, - issue_number: prNumber, - body: commentBody, - }); - } diff --git a/AGENTS.md b/AGENTS.md index 4346ffbdae..be2e912d48 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,6 @@ This document provides essential context for AI models interacting with this pro 2. **Code Generation** (`esphome/codegen.py`, `esphome/cpp_generator.py`): Manages Python to C++ code generation, template processing, and build flag management. 3. **Component System** (`esphome/components/`): Contains modular hardware and software components with platform-specific implementations and dependency management. 4. **Core Framework** (`esphome/core/`): Manages the application lifecycle, hardware abstraction, and component registration. - 5. **Dashboard** (`esphome/dashboard/`): A web-based interface for device configuration, management, and OTA updates. * **Platform Support:** 1. **ESP32** (`components/esp32/`): Espressif ESP32 family. Supports multiple variants (Original, C2, C3, C5, C6, H2, P4, S2, S3) with ESP-IDF framework. Arduino framework supports only a subset of the variants (Original, C3, S2, S3). @@ -456,7 +455,6 @@ This document provides essential context for AI models interacting with this pro * **Debug Tools:** - `esphome config .yaml` to validate configuration. - `esphome compile .yaml` to compile without uploading. - - Check the Dashboard for real-time logs. - Use component-specific debug logging. * **Common Issues:** - **Import Errors**: Check component dependencies and `PYTHONPATH`. @@ -658,7 +656,7 @@ This document provides essential context for AI models interacting with this pro If you need a real-world example, search for components that use `@dataclass` with `CORE.data` in the codebase. Note: Some components may use `TypedDict` for dictionary-based storage; both patterns are acceptable depending on your needs. **Why this matters:** - - Module-level globals persist between compilation runs if the dashboard doesn't fork/exec + - Module-level globals persist between compilation runs if the host process (e.g. device-builder) doesn't fork/exec - `CORE.data` automatically clears between runs - Namespacing under `DOMAIN` prevents key collisions between components - `@dataclass` provides type safety and cleaner attribute access diff --git a/esphome/__main__.py b/esphome/__main__.py index 680de02201..35ab767cf7 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -527,7 +527,7 @@ def has_resolvable_address() -> bool: if has_ip_address(): return True - # The dashboard pre-resolves the device and passes the IPs via + # device-builder pre-resolves the device and passes the IPs via # --mdns-address-cache/--dns-address-cache; honor a cached address even when the # device has mDNS disabled (e.g. a .local host found via ping). if CORE.address_cache and CORE.address_cache.get_addresses(CORE.address): @@ -1715,9 +1715,13 @@ def command_bundle(args: ArgsProtocol, config: ConfigType) -> int | None: def command_dashboard(args: ArgsProtocol) -> int | None: - from esphome.dashboard import dashboard - - return dashboard.start_dashboard(args) + raise EsphomeError( + "The built-in dashboard has been removed from ESPHome. " + "Install and run ESPHome Device Builder instead:\n" + " pip install esphome-device-builder\n" + " esphome-device-builder\n" + "See https://github.com/esphome/device-builder for more information." + ) def run_multiple_configs( @@ -2379,44 +2383,22 @@ def parse_args(argv): "configuration", help="Your YAML file or configuration directory.", nargs="*" ) - parser_dashboard = subparsers.add_parser( - "dashboard", help="Create a simple web server for a dashboard." + # The dashboard moved to ESPHome Device Builder; the command is kept only to + # print a redirect (see command_dashboard). Accept and ignore the old flags + # so legacy invocations reach that message instead of failing on argparse + # "unrecognized arguments". + parser_dashboard = subparsers.add_parser("dashboard") + parser_dashboard.add_argument("configuration", nargs="?", help=argparse.SUPPRESS) + parser_dashboard.add_argument("--port", help=argparse.SUPPRESS) + parser_dashboard.add_argument("--address", help=argparse.SUPPRESS) + parser_dashboard.add_argument("--username", help=argparse.SUPPRESS) + parser_dashboard.add_argument("--password", help=argparse.SUPPRESS) + parser_dashboard.add_argument("--socket", help=argparse.SUPPRESS) + parser_dashboard.add_argument( + "--open-ui", action="store_true", help=argparse.SUPPRESS ) parser_dashboard.add_argument( - "configuration", help="Your YAML configuration file directory." - ) - parser_dashboard.add_argument( - "--port", - help="The HTTP port to open connections on. Defaults to 6052.", - type=int, - default=6052, - ) - parser_dashboard.add_argument( - "--address", - help="The address to bind to.", - type=str, - default="0.0.0.0", - ) - parser_dashboard.add_argument( - "--username", - help="The optional username to require for authentication.", - type=str, - default="", - ) - parser_dashboard.add_argument( - "--password", - help="The optional password to require for authentication.", - type=str, - default="", - ) - parser_dashboard.add_argument( - "--open-ui", help="Open the dashboard UI in a browser.", action="store_true" - ) - parser_dashboard.add_argument( - "--ha-addon", help=argparse.SUPPRESS, action="store_true" - ) - parser_dashboard.add_argument( - "--socket", help="Make the dashboard serve under a unix socket", type=str + "--ha-addon", action="store_true", help=argparse.SUPPRESS ) parser_vscode = subparsers.add_parser("vscode") @@ -2511,11 +2493,7 @@ def run_esphome(argv): elif args.quiet: args.log_level = "CRITICAL" - setup_log( - log_level=args.log_level, - # Show timestamp for dashboard access logs - include_timestamp=args.command == "dashboard", - ) + setup_log(log_level=args.log_level) if args.command in PRE_CONFIG_ACTIONS: try: diff --git a/esphome/components/dashboard_import/__init__.py b/esphome/components/dashboard_import/__init__.py index 30b3394165..911fc387a0 100644 --- a/esphome/components/dashboard_import/__init__.py +++ b/esphome/components/dashboard_import/__init__.py @@ -92,7 +92,6 @@ def import_config( """Materialise a dashboard-imported device's YAML on disk. Used by: - - esphome.dashboard (legacy dashboard) - device-builder (esphome/device-builder) — called from the ``devices/import`` WS handler to seed the YAML for an adopted factory firmware. Coordinate before changing the kwargs or the diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index ec33d9d271..8ba1ac4608 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -533,15 +533,13 @@ def get_board(core_obj=None): def get_download_types(storage_json): """Binary-download entries for a built ESP32 firmware. - Used by: - - esphome.dashboard (legacy "Download .bin" button) - - device-builder (esphome/device-builder) — same dispatch via - ``importlib.import_module(f"esphome.components.{platform}")`` - then ``module.get_download_types(storage)``. The contract is - "returns ``list[dict]`` with at least ``title`` / - ``description`` / ``file`` / ``download`` keys"; please keep - the shape stable so the new dashboard's download panel - doesn't have to special-case per-platform schemas. + Used by device-builder (esphome/device-builder), via + ``importlib.import_module(f"esphome.components.{platform}")`` + then ``module.get_download_types(storage)``. The contract is + "returns ``list[dict]`` with at least ``title`` / + ``description`` / ``file`` / ``download`` keys"; please keep + the shape stable so the download panel + doesn't have to special-case per-platform schemas. """ return [ { diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index db94f0ec6d..db7120a9ef 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -97,15 +97,13 @@ def set_core_data(config): def get_download_types(storage_json): """Binary-download entries for a built ESP8266 firmware. - Used by: - - esphome.dashboard (legacy "Download .bin" button) - - device-builder (esphome/device-builder) — same dispatch via - ``importlib.import_module(f"esphome.components.{platform}")`` - then ``module.get_download_types(storage)``. The contract is - "returns ``list[dict]`` with at least ``title`` / - ``description`` / ``file`` / ``download`` keys"; please keep - the shape stable so the new dashboard's download panel - doesn't have to special-case per-platform schemas. + Used by device-builder (esphome/device-builder), via + ``importlib.import_module(f"esphome.components.{platform}")`` + then ``module.get_download_types(storage)``. The contract is + "returns ``list[dict]`` with at least ``title`` / + ``description`` / ``file`` / ``download`` keys"; please keep + the shape stable so the download panel + doesn't have to special-case per-platform schemas. """ return [ { diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index afe0360c22..bcc393f3fd 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -158,15 +158,13 @@ def only_on_family(*, supported=None, unsupported=None): def get_download_types(storage_json: StorageJSON = None): """Binary-download entries for a built LibreTiny firmware. - Used by: - - esphome.dashboard (legacy "Download .bin" button) - - device-builder (esphome/device-builder) — same dispatch via - ``importlib.import_module(f"esphome.components.{platform}")`` - then ``module.get_download_types(storage)``. The contract is - "returns ``list[dict]`` with at least ``title`` / - ``description`` / ``file`` / ``download`` keys"; please keep - the shape stable so the new dashboard's download panel - doesn't have to special-case per-platform schemas. + Used by device-builder (esphome/device-builder), via + ``importlib.import_module(f"esphome.components.{platform}")`` + then ``module.get_download_types(storage)``. The contract is + "returns ``list[dict]`` with at least ``title`` / + ``description`` / ``file`` / ``download`` keys"; please keep + the shape stable so the download panel + doesn't have to special-case per-platform schemas. """ types = [ { diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index f98cde7968..dd851b8e16 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -140,15 +140,13 @@ def only_on_variant( def get_download_types(storage_json): """Binary-download entries for a built RP2040 firmware. - Used by: - - esphome.dashboard (legacy "Download .bin" button) - - device-builder (esphome/device-builder) — same dispatch via - ``importlib.import_module(f"esphome.components.{platform}")`` - then ``module.get_download_types(storage)``. The contract is - "returns ``list[dict]`` with at least ``title`` / - ``description`` / ``file`` / ``download`` keys"; please keep - the shape stable so the new dashboard's download panel - doesn't have to special-case per-platform schemas. + Used by device-builder (esphome/device-builder), via + ``importlib.import_module(f"esphome.components.{platform}")`` + then ``module.get_download_types(storage)``. The contract is + "returns ``list[dict]`` with at least ``title`` / + ``description`` / ``file`` / ``download`` keys"; please keep + the shape stable so the download panel + doesn't have to special-case per-platform schemas. """ return [ { diff --git a/esphome/dashboard/__init__.py b/esphome/dashboard/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/esphome/dashboard/const.py b/esphome/dashboard/const.py deleted file mode 100644 index 9cadc442ef..0000000000 --- a/esphome/dashboard/const.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -import sys - -from esphome.enum import StrEnum - - -class DashboardEvent(StrEnum): - """Dashboard WebSocket event types.""" - - # Server -> Client events (backend sends to frontend) - ENTRY_ADDED = "entry_added" - ENTRY_REMOVED = "entry_removed" - ENTRY_UPDATED = "entry_updated" - ENTRY_STATE_CHANGED = "entry_state_changed" - IMPORTABLE_DEVICE_ADDED = "importable_device_added" - IMPORTABLE_DEVICE_REMOVED = "importable_device_removed" - INITIAL_STATE = "initial_state" # Sent on WebSocket connection - PONG = "pong" # Response to client ping - - # Client -> Server events (frontend sends to backend) - PING = "ping" # WebSocket keepalive from client - REFRESH = "refresh" # Force backend to poll for changes - - -MAX_EXECUTOR_WORKERS = 48 - - -SENTINEL = object() - -ESPHOME_COMMAND = [sys.executable, "-m", "esphome"] -DASHBOARD_COMMAND = [*ESPHOME_COMMAND, "--dashboard"] diff --git a/esphome/dashboard/core.py b/esphome/dashboard/core.py deleted file mode 100644 index b9ec56cd00..0000000000 --- a/esphome/dashboard/core.py +++ /dev/null @@ -1,190 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import Callable, Coroutine -import contextlib -from dataclasses import dataclass -from functools import partial -import json -import logging -import threading -from typing import Any - -from esphome.storage_json import ignored_devices_storage_path - -from ..zeroconf import DiscoveredImport -from .const import DashboardEvent -from .dns import DNSCache -from .entries import DashboardEntries -from .settings import DashboardSettings -from .status.mdns import MDNSStatus -from .status.ping import PingStatus - -_LOGGER = logging.getLogger(__name__) - -IGNORED_DEVICES_STORAGE_PATH = "ignored-devices.json" - -MDNS_BOOTSTRAP_TIME = 7.5 - - -@dataclass -class Event: - """Dashboard Event.""" - - event_type: DashboardEvent - data: dict[str, Any] - - -class EventBus: - """Dashboard event bus.""" - - def __init__(self) -> None: - """Initialize the Dashboard event bus.""" - self._listeners: dict[DashboardEvent, set[Callable[[Event], None]]] = {} - - def async_add_listener( - self, event_type: DashboardEvent, listener: Callable[[Event], None] - ) -> Callable[[], None]: - """Add a listener to the event bus.""" - self._listeners.setdefault(event_type, set()).add(listener) - return partial(self._async_remove_listener, event_type, listener) - - def _async_remove_listener( - self, event_type: DashboardEvent, listener: Callable[[Event], None] - ) -> None: - """Remove a listener from the event bus.""" - self._listeners[event_type].discard(listener) - - def async_fire( - self, event_type: DashboardEvent, event_data: dict[str, Any] - ) -> None: - """Fire an event.""" - event = Event(event_type, event_data) - - _LOGGER.debug("Firing event: %s", event) - - for listener in self._listeners.get(event_type, set()): - listener(event) - - -class ESPHomeDashboard: - """Class that represents the dashboard.""" - - __slots__ = ( - "bus", - "entries", - "loop", - "import_result", - "stop_event", - "ping_request", - "mqtt_ping_request", - "mdns_status", - "settings", - "dns_cache", - "_background_tasks", - "ignored_devices", - "_ping_status_task", - ) - - def __init__(self) -> None: - """Initialize the ESPHomeDashboard.""" - self.bus = EventBus() - self.entries: DashboardEntries | None = None - self.loop: asyncio.AbstractEventLoop | None = None - self.import_result: dict[str, DiscoveredImport] = {} - self.stop_event = threading.Event() - self.ping_request: asyncio.Event | None = None - self.mqtt_ping_request = threading.Event() - self.mdns_status: MDNSStatus | None = None - self.settings = DashboardSettings() - self.dns_cache = DNSCache() - self._background_tasks: set[asyncio.Task] = set() - self.ignored_devices: set[str] = set() - self._ping_status_task: asyncio.Task | None = None - - async def async_setup(self) -> None: - """Setup the dashboard.""" - self.loop = asyncio.get_running_loop() - self.ping_request = asyncio.Event() - self.entries = DashboardEntries(self) - await self.loop.run_in_executor(None, self.load_ignored_devices) - - def load_ignored_devices(self) -> None: - storage_path = ignored_devices_storage_path() - try: - with storage_path.open("r", encoding="utf-8") as f_handle: - data = json.load(f_handle) - self.ignored_devices = set(data.get("ignored_devices", set())) - except FileNotFoundError: - pass - - def save_ignored_devices(self) -> None: - storage_path = ignored_devices_storage_path() - with storage_path.open("w", encoding="utf-8") as f_handle: - json.dump( - {"ignored_devices": sorted(self.ignored_devices)}, indent=2, fp=f_handle - ) - - def _async_start_ping_status(self, ping_status: PingStatus) -> None: - self._ping_status_task = asyncio.create_task(ping_status.async_run()) - - async def async_run(self) -> None: - """Run the dashboard.""" - settings = self.settings - mdns_task: asyncio.Task | None = None - await self.entries.async_update_entries() - - mdns_status = MDNSStatus(self) - ping_status = PingStatus(self) - start_ping_timer: asyncio.TimerHandle | None = None - - self.mdns_status = mdns_status - if mdns_status.async_setup(): - mdns_task = asyncio.create_task(mdns_status.async_run()) - # Start ping MDNS_BOOTSTRAP_TIME seconds after startup to ensure - # MDNS has had a chance to resolve the devices - start_ping_timer = self.loop.call_later( - MDNS_BOOTSTRAP_TIME, self._async_start_ping_status, ping_status - ) - else: - # If mDNS is not available, start the ping status immediately - self._async_start_ping_status(ping_status) - - if settings.status_use_mqtt: - from .status.mqtt import MqttStatusThread - - status_thread_mqtt = MqttStatusThread(self) - status_thread_mqtt.start() - - try: - await asyncio.Event().wait() - finally: - _LOGGER.info("Shutting down...") - self.stop_event.set() - self.ping_request.set() - if start_ping_timer: - start_ping_timer.cancel() - if self._ping_status_task: - self._ping_status_task.cancel() - self._ping_status_task = None - if mdns_task: - mdns_task.cancel() - if settings.status_use_mqtt: - status_thread_mqtt.join() - self.mqtt_ping_request.set() - for task in self._background_tasks: - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task - await asyncio.sleep(0) - - def async_create_background_task( - self, coro: Coroutine[Any, Any, Any] - ) -> asyncio.Task: - """Create a background task.""" - task = self.loop.create_task(coro) - task.add_done_callback(self._background_tasks.discard) - return task - - -DASHBOARD = ESPHomeDashboard() diff --git a/esphome/dashboard/dashboard.py b/esphome/dashboard/dashboard.py deleted file mode 100644 index 7fc21f8a44..0000000000 --- a/esphome/dashboard/dashboard.py +++ /dev/null @@ -1,153 +0,0 @@ -from __future__ import annotations - -import asyncio -from asyncio import events -from concurrent.futures import ThreadPoolExecutor -import contextlib -import logging -import os -from pathlib import Path -import socket -import threading -from time import monotonic -import traceback -from typing import Any - -from esphome.storage_json import EsphomeStorageJSON, esphome_storage_path - -from .const import MAX_EXECUTOR_WORKERS -from .core import DASHBOARD -from .web_server import make_app, start_web_server - -ENV_DEV = "ESPHOME_DASHBOARD_DEV" - -settings = DASHBOARD.settings - - -def can_use_pidfd() -> bool: - """Check if pidfd_open is available. - - Back ported from cpython 3.12 - """ - if not hasattr(os, "pidfd_open"): - return False - try: - pid = os.getpid() - os.close(os.pidfd_open(pid, 0)) - except OSError: - # blocked by security policy like SECCOMP - return False - return True - - -class DashboardEventLoopPolicy(asyncio.DefaultEventLoopPolicy): - """Event loop policy for Home Assistant.""" - - def __init__(self, debug: bool) -> None: - """Init the event loop policy.""" - super().__init__() - self.debug = debug - self._watcher: asyncio.AbstractChildWatcher | None = None - - def _init_watcher(self) -> None: - """Initialize the watcher for child processes. - - Back ported from cpython 3.12 - """ - with events._lock: # type: ignore[attr-defined] # pylint: disable=protected-access - if self._watcher is None: # pragma: no branch - if can_use_pidfd(): - self._watcher = asyncio.PidfdChildWatcher() - else: - self._watcher = asyncio.ThreadedChildWatcher() - if threading.current_thread() is threading.main_thread(): - self._watcher.attach_loop( - self._local._loop # type: ignore[attr-defined] # pylint: disable=protected-access - ) - - @property - def loop_name(self) -> str: - """Return name of the loop.""" - return self._loop_factory.__name__ # type: ignore[no-any-return,attr-defined] - - def new_event_loop(self) -> asyncio.AbstractEventLoop: - """Get the event loop.""" - loop: asyncio.AbstractEventLoop = super().new_event_loop() - loop.set_exception_handler(_async_loop_exception_handler) - - if self.debug: - loop.set_debug(True) - - executor = ThreadPoolExecutor( - thread_name_prefix="SyncWorker", max_workers=MAX_EXECUTOR_WORKERS - ) - loop.set_default_executor(executor) - # bind the built-in time.monotonic directly as loop.time to avoid the - # overhead of the additional method call since its the most called loop - # method and its roughly 10%+ of all the call time in base_events.py - loop.time = monotonic # type: ignore[method-assign] - return loop - - -def _async_loop_exception_handler(_: Any, context: dict[str, Any]) -> None: - """Handle all exception inside the core loop.""" - kwargs = {} - if exception := context.get("exception"): - kwargs["exc_info"] = (type(exception), exception, exception.__traceback__) - - logger = logging.getLogger(__package__) - if source_traceback := context.get("source_traceback"): - stack_summary = "".join(traceback.format_list(source_traceback)) - logger.error( - "Error doing job: %s: %s", - context["message"], - stack_summary, - **kwargs, # type: ignore[arg-type] - ) - return - - logger.error( - "Error doing job: %s", - context["message"], - **kwargs, # type: ignore[arg-type] - ) - - -def start_dashboard(args) -> None: - """Start the dashboard.""" - settings.parse_args(args) - - if settings.using_auth: - path = esphome_storage_path() - storage = EsphomeStorageJSON.load(path) - if storage is None: - storage = EsphomeStorageJSON.get_default() - storage.save(path) - settings.cookie_secret = storage.cookie_secret - - asyncio.set_event_loop_policy(DashboardEventLoopPolicy(settings.verbose)) - - with contextlib.suppress(KeyboardInterrupt): - asyncio.run(async_start(args)) - - -async def async_start(args) -> None: - """Start the dashboard.""" - dashboard = DASHBOARD - await dashboard.async_setup() - sock: socket.socket | None = args.socket - address: str | None = args.address - port: int | None = args.port - - start_web_server(make_app(args.verbose), sock, address, port, settings.config_dir) - - if args.open_ui: - import webbrowser - - webbrowser.open(f"http://{args.address}:{args.port}") - - try: - await dashboard.async_run() - finally: - if sock: - Path(sock).unlink() diff --git a/esphome/dashboard/dns.py b/esphome/dashboard/dns.py deleted file mode 100644 index eb4a87dbfb..0000000000 --- a/esphome/dashboard/dns.py +++ /dev/null @@ -1,77 +0,0 @@ -from __future__ import annotations - -import asyncio -from contextlib import suppress -from ipaddress import ip_address -import logging - -from icmplib import NameLookupError, async_resolve - -RESOLVE_TIMEOUT = 3.0 - -_LOGGER = logging.getLogger(__name__) - -_RESOLVE_EXCEPTIONS = (TimeoutError, NameLookupError, UnicodeError) - - -async def _async_resolve_wrapper(hostname: str) -> list[str] | Exception: - """Wrap the icmplib async_resolve function.""" - with suppress(ValueError): - return [str(ip_address(hostname))] - try: - async with asyncio.timeout(RESOLVE_TIMEOUT): - return await async_resolve(hostname) - except _RESOLVE_EXCEPTIONS as ex: - # If the hostname ends with .local and resolution failed, - # try the bare hostname as a fallback since mDNS may not be - # working on the system but unicast DNS might resolve it - if hostname.endswith(".local"): - bare_hostname = hostname[:-6] # Remove ".local" - try: - async with asyncio.timeout(RESOLVE_TIMEOUT): - result = await async_resolve(bare_hostname) - _LOGGER.debug( - "Bare hostname %s resolved to %s", bare_hostname, result - ) - return result - except _RESOLVE_EXCEPTIONS: - _LOGGER.debug("Bare hostname %s also failed to resolve", bare_hostname) - return ex - - -class DNSCache: - """DNS cache for the dashboard.""" - - def __init__(self, ttl: int | None = 120) -> None: - """Initialize the DNSCache.""" - self._cache: dict[str, tuple[float, list[str] | Exception]] = {} - self._ttl = ttl - - def get_cached_addresses( - self, hostname: str, now_monotonic: float - ) -> list[str] | None: - """Get cached addresses without triggering resolution. - - Returns None if not in cache, list of addresses if found. - """ - # Normalize hostname for consistent lookups - normalized = hostname.rstrip(".").lower() - if expire_time_addresses := self._cache.get(normalized): - expire_time, addresses = expire_time_addresses - if expire_time > now_monotonic and not isinstance(addresses, Exception): - return addresses - return None - - async def async_resolve( - self, hostname: str, now_monotonic: float - ) -> list[str] | Exception: - """Resolve a hostname to a list of IP address.""" - if expire_time_addresses := self._cache.get(hostname): - expire_time, addresses = expire_time_addresses - if expire_time > now_monotonic: - return addresses - - expires = now_monotonic + self._ttl - addresses = await _async_resolve_wrapper(hostname) - self._cache[hostname] = (expires, addresses) - return addresses diff --git a/esphome/dashboard/entries.py b/esphome/dashboard/entries.py deleted file mode 100644 index 95b8a7b2ae..0000000000 --- a/esphome/dashboard/entries.py +++ /dev/null @@ -1,458 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections import defaultdict -from dataclasses import dataclass -from functools import lru_cache -import logging -from pathlib import Path -from typing import TYPE_CHECKING, Any - -from esphome import const, util -from esphome.enum import StrEnum -from esphome.storage_json import StorageJSON, ext_storage_path - -from .const import DASHBOARD_COMMAND, DashboardEvent -from .util.subprocess import async_run_system_command - -if TYPE_CHECKING: - from .core import ESPHomeDashboard - -_LOGGER = logging.getLogger(__name__) - - -DashboardCacheKeyType = tuple[int, int, float, int] - - -@dataclass(frozen=True) -class EntryState: - """Represents the state of an entry.""" - - reachable: ReachableState - source: EntryStateSource - - -class EntryStateSource(StrEnum): - MDNS = "mdns" - PING = "ping" - MQTT = "mqtt" - UNKNOWN = "unknown" - - -class ReachableState(StrEnum): - ONLINE = "online" - OFFLINE = "offline" - DNS_FAILURE = "dns_failure" - UNKNOWN = "unknown" - - -_BOOL_TO_REACHABLE_STATE = { - True: ReachableState.ONLINE, - False: ReachableState.OFFLINE, - None: ReachableState.UNKNOWN, -} -_REACHABLE_STATE_TO_BOOL = { - ReachableState.ONLINE: True, - ReachableState.OFFLINE: False, - ReachableState.DNS_FAILURE: False, - ReachableState.UNKNOWN: None, -} - -UNKNOWN_STATE = EntryState(ReachableState.UNKNOWN, EntryStateSource.UNKNOWN) - - -@lru_cache # creating frozen dataclass instances is expensive, so we cache them -def bool_to_entry_state(value: bool | None, source: EntryStateSource) -> EntryState: - """Convert a bool to an entry state.""" - return EntryState(_BOOL_TO_REACHABLE_STATE[value], source) - - -def entry_state_to_bool(value: EntryState) -> bool | None: - """Convert an entry state to a bool.""" - return _REACHABLE_STATE_TO_BOOL[value.reachable] - - -class DashboardEntries: - """Represents all dashboard entries.""" - - __slots__ = ( - "_dashboard", - "_loop", - "_config_dir", - "_entries", - "_entry_states", - "_loaded_entries", - "_update_lock", - "_name_to_entry", - ) - - def __init__(self, dashboard: ESPHomeDashboard) -> None: - """Initialize the DashboardEntries.""" - self._dashboard = dashboard - self._loop = asyncio.get_running_loop() - self._config_dir = dashboard.settings.config_dir - # Entries are stored as - # { - # "path/to/file.yaml": DashboardEntry, - # ... - # } - self._entries: dict[Path, DashboardEntry] = {} - self._loaded_entries = False - self._update_lock = asyncio.Lock() - self._name_to_entry: dict[str, set[DashboardEntry]] = defaultdict(set) - - def get(self, path: Path) -> DashboardEntry | None: - """Get an entry by path.""" - return self._entries.get(path) - - def get_by_name(self, name: str) -> set[DashboardEntry] | None: - """Get an entry by name.""" - return self._name_to_entry.get(name) - - async def _async_all(self) -> list[DashboardEntry]: - """Return all entries.""" - return list(self._entries.values()) - - def all(self) -> list[DashboardEntry]: - """Return all entries.""" - return asyncio.run_coroutine_threadsafe(self._async_all(), self._loop).result() - - def async_all(self) -> list[DashboardEntry]: - """Return all entries.""" - return list(self._entries.values()) - - def set_state(self, entry: DashboardEntry, state: EntryState) -> None: - """Set the state for an entry.""" - asyncio.run_coroutine_threadsafe( - self._async_set_state(entry, state), self._loop - ).result() - - async def _async_set_state(self, entry: DashboardEntry, state: EntryState) -> None: - """Set the state for an entry.""" - self.async_set_state(entry, state) - - def set_state_if_online_or_source( - self, entry: DashboardEntry, state: EntryState - ) -> None: - """Set the state for an entry if its online or provided by the source or unknown.""" - asyncio.run_coroutine_threadsafe( - self._async_set_state_if_online_or_source(entry, state), self._loop - ).result() - - async def _async_set_state_if_online_or_source( - self, entry: DashboardEntry, state: EntryState - ) -> None: - """Set the state for an entry if its online or provided by the source or unknown.""" - self.async_set_state_if_online_or_source(entry, state) - - def async_set_state_if_online_or_source( - self, entry: DashboardEntry, state: EntryState - ) -> None: - """Set the state for an entry if its online or provided by the source or unknown.""" - if ( - state.reachable is ReachableState.ONLINE - and entry.state.reachable is not ReachableState.ONLINE - ) or entry.state.source in ( - EntryStateSource.UNKNOWN, - state.source, - ): - self.async_set_state(entry, state) - - def set_state_if_source(self, entry: DashboardEntry, state: EntryState) -> None: - """Set the state for an entry if provided by the source or unknown.""" - asyncio.run_coroutine_threadsafe( - self._async_set_state_if_source(entry, state), self._loop - ).result() - - async def _async_set_state_if_source( - self, entry: DashboardEntry, state: EntryState - ) -> None: - """Set the state for an entry if rovided by the source or unknown.""" - self.async_set_state_if_source(entry, state) - - def async_set_state_if_source( - self, entry: DashboardEntry, state: EntryState - ) -> None: - """Set the state for an entry if provided by the source or unknown.""" - if entry.state.source in ( - EntryStateSource.UNKNOWN, - state.source, - ): - self.async_set_state(entry, state) - - def async_set_state(self, entry: DashboardEntry, state: EntryState) -> None: - """Set the state for an entry.""" - if entry.state == state: - return - entry.state = state - self._dashboard.bus.async_fire( - DashboardEvent.ENTRY_STATE_CHANGED, {"entry": entry, "state": state} - ) - - async def async_request_update_entries(self) -> None: - """Request an update of the dashboard entries from disk. - - If an update is already in progress, this will do nothing. - """ - if self._update_lock.locked(): - _LOGGER.debug("Dashboard entries are already being updated") - return - await self.async_update_entries() - - async def async_update_entries(self) -> None: - """Update the dashboard entries from disk.""" - async with self._update_lock: - await self._async_update_entries() - - def _load_entries( - self, entries: dict[DashboardEntry, DashboardCacheKeyType] - ) -> None: - """Load all entries from disk.""" - for entry, cache_key in entries.items(): - _LOGGER.debug( - "Loading dashboard entry %s because cache key changed: %s", - entry.path, - cache_key, - ) - entry.load_from_disk(cache_key) - - async def _async_update_entries(self) -> list[DashboardEntry]: - """Sync the dashboard entries from disk.""" - _LOGGER.debug("Updating dashboard entries") - # At some point it would be nice to use watchdog to avoid polling - - path_to_cache_key = await self._loop.run_in_executor( - None, self._get_path_to_cache_key - ) - entries = self._entries - name_to_entry = self._name_to_entry - added: dict[DashboardEntry, DashboardCacheKeyType] = {} - updated: dict[DashboardEntry, DashboardCacheKeyType] = {} - removed: set[DashboardEntry] = { - entry - for filename, entry in entries.items() - if filename not in path_to_cache_key - } - original_names: dict[DashboardEntry, str] = {} - - for path, cache_key in path_to_cache_key.items(): - if not (entry := entries.get(path)): - entry = DashboardEntry(path, cache_key) - added[entry] = cache_key - continue - - if entry.cache_key != cache_key: - updated[entry] = cache_key - original_names[entry] = entry.name - - if added or updated: - await self._loop.run_in_executor( - None, self._load_entries, {**added, **updated} - ) - - bus = self._dashboard.bus - for entry in added: - entries[entry.path] = entry - name_to_entry[entry.name].add(entry) - bus.async_fire(DashboardEvent.ENTRY_ADDED, {"entry": entry}) - - for entry in removed: - del entries[entry.path] - name_to_entry[entry.name].discard(entry) - bus.async_fire(DashboardEvent.ENTRY_REMOVED, {"entry": entry}) - - for entry in updated: - if (original_name := original_names[entry]) != (current_name := entry.name): - name_to_entry[original_name].discard(entry) - name_to_entry[current_name].add(entry) - bus.async_fire(DashboardEvent.ENTRY_UPDATED, {"entry": entry}) - - def _get_path_to_cache_key(self) -> dict[Path, DashboardCacheKeyType]: - """Return a dict of path to cache key.""" - path_to_cache_key: dict[Path, DashboardCacheKeyType] = {} - # - # The cache key is (inode, device, mtime, size) - # which allows us to avoid locking since it ensures - # every iteration of this call will always return the newest - # items from disk at the cost of a stat() call on each - # file which is much faster than reading the file - # for the cache hit case which is the common case. - # - for file in util.list_yaml_files([self._config_dir]): - try: - # Prefer the json storage path if it exists - stat = ext_storage_path(file.name).stat() - except OSError: - try: - # Fallback to the yaml file if the storage - # file does not exist or could not be generated - stat = file.stat() - except OSError: - # File was deleted, ignore - continue - path_to_cache_key[file] = ( - stat.st_ino, - stat.st_dev, - stat.st_mtime, - stat.st_size, - ) - return path_to_cache_key - - def async_schedule_storage_json_update(self, filename: str) -> None: - """Schedule a task to update the storage JSON file.""" - self._dashboard.async_create_background_task( - async_run_system_command( - [*DASHBOARD_COMMAND, "compile", "--only-generate", filename] - ) - ) - - -class DashboardEntry: - """Represents a single dashboard entry. - - This class is thread-safe and read-only. - """ - - __slots__ = ( - "path", - "filename", - "_storage_path", - "cache_key", - "storage", - "state", - "_to_dict", - ) - - def __init__(self, path: Path, cache_key: DashboardCacheKeyType) -> None: - """Initialize the DashboardEntry.""" - self.path = path - self.filename: str = path.name - self._storage_path = ext_storage_path(self.filename) - self.cache_key = cache_key - self.storage: StorageJSON | None = None - self.state = UNKNOWN_STATE - self._to_dict: dict[str, Any] | None = None - - def __repr__(self) -> str: - """Return the representation of this entry.""" - return ( - f"DashboardEntry(path={self.path} " - f"address={self.address} " - f"web_port={self.web_port} " - f"name={self.name} " - f"no_mdns={self.no_mdns} " - f"state={self.state} " - ")" - ) - - def to_dict(self) -> dict[str, Any]: - """Return a dict representation of this entry. - - The dict includes the loaded configuration but not - the current state of the entry. - """ - if self._to_dict is None: - self._to_dict = { - "name": self.name, - "friendly_name": self.friendly_name, - "configuration": self.filename, - "loaded_integrations": sorted(self.loaded_integrations), - "deployed_version": self.update_old, - "current_version": self.update_new, - "path": str(self.path), - "comment": self.comment, - "address": self.address, - "web_port": self.web_port, - "target_platform": self.target_platform, - } - return self._to_dict - - def load_from_disk(self, cache_key: DashboardCacheKeyType | None = None) -> None: - """Load this entry from disk.""" - self.storage = StorageJSON.load(self._storage_path) - self._to_dict = None - # - # Currently StorageJSON.load() will return None if the file does not exist - # - # StorageJSON currently does not provide an updated cache key so we use the - # one that is passed in. - # - # The cache key was read from the disk moments ago and may be stale but - # it does not matter since we are polling anyways, and the next call to - # async_update_entries() will load it again in the extremely rare case that - # it changed between the two calls. - # - if cache_key: - self.cache_key = cache_key - - @property - def address(self) -> str | None: - """Return the address of this entry.""" - if self.storage is None: - return None - return self.storage.address - - @property - def no_mdns(self) -> bool | None: - """Return the no_mdns of this entry.""" - if self.storage is None: - return None - return self.storage.no_mdns - - @property - def web_port(self) -> int | None: - """Return the web port of this entry.""" - if self.storage is None: - return None - return self.storage.web_port - - @property - def name(self) -> str: - """Return the name of this entry.""" - if self.storage is None: - return self.filename.replace(".yml", "").replace(".yaml", "") - return self.storage.name - - @property - def friendly_name(self) -> str: - """Return the friendly name of this entry.""" - if self.storage is None: - return self.name - return self.storage.friendly_name - - @property - def comment(self) -> str | None: - """Return the comment of this entry.""" - if self.storage is None: - return None - return self.storage.comment - - @property - def target_platform(self) -> str | None: - """Return the target platform of this entry.""" - if self.storage is None: - return None - return self.storage.target_platform - - @property - def update_available(self) -> bool: - """Return if an update is available for this entry.""" - if self.storage is None: - return True - return self.update_old != self.update_new - - @property - def update_old(self) -> str: - if self.storage is None: - return "" - return self.storage.esphome_version or "" - - @property - def update_new(self) -> str: - return const.__version__ - - @property - def loaded_integrations(self) -> set[str]: - if self.storage is None: - return [] - return self.storage.loaded_integrations diff --git a/esphome/dashboard/models.py b/esphome/dashboard/models.py deleted file mode 100644 index 47ddddd5ce..0000000000 --- a/esphome/dashboard/models.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Data models and builders for the dashboard.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, TypedDict - -if TYPE_CHECKING: - from esphome.zeroconf import DiscoveredImport - - from .core import ESPHomeDashboard - from .entries import DashboardEntry - - -class ImportableDeviceDict(TypedDict): - """Dictionary representation of an importable device.""" - - name: str - friendly_name: str | None - package_import_url: str - project_name: str - project_version: str - network: str - ignored: bool - - -class ConfiguredDeviceDict(TypedDict, total=False): - """Dictionary representation of a configured device.""" - - name: str - friendly_name: str | None - configuration: str - loaded_integrations: list[str] | None - deployed_version: str | None - current_version: str | None - path: str - comment: str | None - address: str | None - web_port: int | None - target_platform: str | None - - -class DeviceListResponse(TypedDict): - """Response for device list API.""" - - configured: list[ConfiguredDeviceDict] - importable: list[ImportableDeviceDict] - - -def build_importable_device_dict( - dashboard: ESPHomeDashboard, discovered: DiscoveredImport -) -> ImportableDeviceDict: - """Build the importable device dictionary.""" - return ImportableDeviceDict( - name=discovered.device_name, - friendly_name=discovered.friendly_name, - package_import_url=discovered.package_import_url, - project_name=discovered.project_name, - project_version=discovered.project_version, - network=discovered.network, - ignored=discovered.device_name in dashboard.ignored_devices, - ) - - -def build_device_list_response( - dashboard: ESPHomeDashboard, entries: list[DashboardEntry] -) -> DeviceListResponse: - """Build the device list response data.""" - configured = {entry.name for entry in entries} - return DeviceListResponse( - configured=[entry.to_dict() for entry in entries], - importable=[ - build_importable_device_dict(dashboard, res) - for res in dashboard.import_result.values() - if res.device_name not in configured - ], - ) diff --git a/esphome/dashboard/settings.py b/esphome/dashboard/settings.py deleted file mode 100644 index 3b22180b1d..0000000000 --- a/esphome/dashboard/settings.py +++ /dev/null @@ -1,101 +0,0 @@ -from __future__ import annotations - -import hmac -import os -from pathlib import Path -from typing import Any - -from esphome.core import CORE -from esphome.helpers import get_bool_env - -from .util.password import password_hash - -# Sentinel file name used for CORE.config_path when dashboard initializes. -# This ensures .parent returns the config directory instead of root. -_DASHBOARD_SENTINEL_FILE = "___DASHBOARD_SENTINEL___.yaml" - - -class DashboardSettings: - """Settings for the dashboard.""" - - __slots__ = ( - "config_dir", - "password_hash", - "username", - "using_password", - "on_ha_addon", - "cookie_secret", - "absolute_config_dir", - "verbose", - ) - - def __init__(self) -> None: - """Initialize the dashboard settings.""" - self.config_dir: Path = None - self.password_hash: bytes = b"" - self.username: str = "" - self.using_password: bool = False - self.on_ha_addon: bool = False - self.cookie_secret: str | None = None - self.absolute_config_dir: Path | None = None - self.verbose: bool = False - - def parse_args(self, args: Any) -> None: - """Parse the arguments.""" - self.on_ha_addon: bool = args.ha_addon - password = args.password or os.getenv("PASSWORD") or "" - if not self.on_ha_addon: - self.username = args.username or os.getenv("USERNAME") or "" - self.using_password = bool(password) - if self.using_password: - self.password_hash = password_hash(password) - self.config_dir = Path(args.configuration) - self.absolute_config_dir = self.config_dir.resolve() - self.verbose = args.verbose - # Set to a sentinel file so .parent gives us the config directory. - # Previously this was `os.path.join(self.config_dir, ".")` which worked because - # os.path.dirname("/config/.") returns "/config", but Path("/config/.").parent - # normalizes to Path("/config") first, then .parent returns Path("/"), breaking - # secret resolution. Using a sentinel file ensures .parent gives the correct directory. - CORE.config_path = self.config_dir / _DASHBOARD_SENTINEL_FILE - - @property - def relative_url(self) -> str: - return os.getenv("ESPHOME_DASHBOARD_RELATIVE_URL") or "/" - - @property - def status_use_mqtt(self) -> bool: - return get_bool_env("ESPHOME_DASHBOARD_USE_MQTT") - - @property - def using_ha_addon_auth(self) -> bool: - if not self.on_ha_addon: - return False - return not get_bool_env("DISABLE_HA_AUTHENTICATION") - - @property - def using_auth(self) -> bool: - return self.using_password or self.using_ha_addon_auth - - @property - def streamer_mode(self) -> bool: - return get_bool_env("ESPHOME_STREAMER_MODE") - - def check_password(self, username: str, password: str) -> bool: - if not self.using_auth: - return True - # Compare in constant running time (to prevent timing attacks) - username_matches = hmac.compare_digest( - username.encode("utf-8"), self.username.encode("utf-8") - ) - password_matches = hmac.compare_digest( - self.password_hash, password_hash(password) - ) - return username_matches and password_matches - - def rel_path(self, *args: Any) -> Path: - """Return a path relative to the ESPHome config folder.""" - joined_path = self.config_dir / Path(*args) - # Raises ValueError if not relative to ESPHome config folder - joined_path.resolve().relative_to(self.absolute_config_dir) - return joined_path diff --git a/esphome/dashboard/status/__init__.py b/esphome/dashboard/status/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/esphome/dashboard/status/mdns.py b/esphome/dashboard/status/mdns.py deleted file mode 100644 index 9da9bb8f01..0000000000 --- a/esphome/dashboard/status/mdns.py +++ /dev/null @@ -1,170 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -import typing - -from zeroconf import AddressResolver, IPVersion - -from esphome.address_cache import normalize_hostname -from esphome.zeroconf import ( - ESPHOME_SERVICE_TYPE, - AsyncEsphomeZeroconf, - DashboardBrowser, - DashboardImportDiscovery, - DashboardStatus, - DiscoveredImport, -) - -from ..const import SENTINEL, DashboardEvent -from ..entries import DashboardEntry, EntryStateSource, bool_to_entry_state -from ..models import build_importable_device_dict - -if typing.TYPE_CHECKING: - from ..core import ESPHomeDashboard - -_LOGGER = logging.getLogger(__name__) - - -class MDNSStatus: - """Class that updates the mdns status.""" - - def __init__(self, dashboard: ESPHomeDashboard) -> None: - """Initialize the MDNSStatus class.""" - super().__init__() - self.aiozc: AsyncEsphomeZeroconf | None = None - # This is the current mdns state for each host (True, False, None) - self.host_mdns_state: dict[str, bool | None] = {} - self._loop = asyncio.get_running_loop() - self.dashboard = dashboard - - def async_setup(self) -> bool: - """Set up the MDNSStatus class.""" - try: - self.aiozc = AsyncEsphomeZeroconf() - except OSError as e: - _LOGGER.warning( - "Failed to initialize zeroconf, will fallback to ping: %s", e - ) - return False - return True - - async def async_resolve_host(self, host_name: str) -> list[str] | None: - """Resolve a host name to an address in a thread-safe manner.""" - if aiozc := self.aiozc: - return await aiozc.async_resolve_host(host_name) - return None - - def get_cached_addresses(self, host_name: str) -> list[str] | None: - """Get cached addresses for a host without triggering resolution. - - Returns None if not in cache or no zeroconf available. - """ - if not self.aiozc: - _LOGGER.debug("No zeroconf instance available for %s", host_name) - return None - - # Normalize hostname and get the base name - normalized = normalize_hostname(host_name) - base_name = normalized.partition(".")[0] - - # Try to load from zeroconf cache without triggering resolution - resolver_name = f"{base_name}.local." - info = AddressResolver(resolver_name) - # Let zeroconf use its own current time for cache checking - if info.load_from_cache(self.aiozc.zeroconf): - addresses = info.parsed_scoped_addresses(IPVersion.All) - _LOGGER.debug("Found %s in zeroconf cache: %s", resolver_name, addresses) - return addresses - _LOGGER.debug("Not found in zeroconf cache: %s", resolver_name) - return None - - def _on_import_update(self, name: str, discovered: DiscoveredImport | None) -> None: - """Handle importable device updates.""" - if discovered is None: - # Device removed - self.dashboard.bus.async_fire( - DashboardEvent.IMPORTABLE_DEVICE_REMOVED, {"name": name} - ) - else: - # Device added - self.dashboard.bus.async_fire( - DashboardEvent.IMPORTABLE_DEVICE_ADDED, - {"device": build_importable_device_dict(self.dashboard, discovered)}, - ) - - async def async_refresh_hosts(self) -> None: - """Refresh the hosts to track.""" - dashboard = self.dashboard - host_mdns_state = self.host_mdns_state - entries = dashboard.entries - poll_names: dict[str, set[DashboardEntry]] = {} - for entry in entries.async_all(): - if entry.no_mdns: - continue - # If we just adopted/imported this host, we likely - # already have a state for it, so we should make sure - # to set it so the dashboard shows it as online - if entry.loaded_integrations and "api" not in entry.loaded_integrations: - # No api available so we have to poll since - # the device won't respond to a request to ._esphomelib._tcp.local. - poll_names.setdefault(entry.name, set()).add(entry) - elif (online := host_mdns_state.get(entry.name, SENTINEL)) != SENTINEL: - self._async_set_state(entry, online) - if poll_names and self.aiozc: - results = await asyncio.gather( - *(self.aiozc.async_resolve_host(name) for name in poll_names) - ) - for name, address_list in zip(poll_names, results, strict=True): - result = bool(address_list) - host_mdns_state[name] = result - for entry in poll_names[name]: - self._async_set_state(entry, result) - - def _async_set_state(self, entry: DashboardEntry, result: bool | None) -> None: - """Set the state of an entry.""" - state = bool_to_entry_state(result, EntryStateSource.MDNS) - if result: - # If we can reach it via mDNS, we always set it online - # since its the fastest source if its working - self.dashboard.entries.async_set_state(entry, state) - else: - # However if we can't reach it via mDNS - # we only set it to offline if the state is unknown - # or from mDNS - self.dashboard.entries.async_set_state_if_source(entry, state) - - async def async_run(self) -> None: - """Run the mdns status.""" - dashboard = self.dashboard - entries = dashboard.entries - host_mdns_state = self.host_mdns_state - - def on_update(dat: dict[str, bool | None]) -> None: - """Update the entry state.""" - for name, result in dat.items(): - host_mdns_state[name] = result - if matching_entries := entries.get_by_name(name): - for entry in matching_entries: - self._async_set_state(entry, result) - - stat = DashboardStatus(on_update) - - imports = DashboardImportDiscovery(self._on_import_update) - dashboard.import_result = imports.import_state - - browser = DashboardBrowser( - self.aiozc.zeroconf, - ESPHOME_SERVICE_TYPE, - [stat.browser_callback, imports.browser_callback], - ) - - ping_request = dashboard.ping_request - while not dashboard.stop_event.is_set(): - await self.async_refresh_hosts() - await ping_request.wait() - ping_request.clear() - - await browser.async_cancel() - await self.aiozc.async_close() - self.aiozc = None diff --git a/esphome/dashboard/status/mqtt.py b/esphome/dashboard/status/mqtt.py deleted file mode 100644 index c3e4883849..0000000000 --- a/esphome/dashboard/status/mqtt.py +++ /dev/null @@ -1,78 +0,0 @@ -from __future__ import annotations - -import binascii -import json -import os -import threading -import typing - -from esphome import mqtt - -from ..entries import EntryStateSource, bool_to_entry_state - -if typing.TYPE_CHECKING: - from ..core import ESPHomeDashboard - - -class MqttStatusThread(threading.Thread): - """Status thread to get the status of the devices via MQTT.""" - - def __init__(self, dashboard: ESPHomeDashboard) -> None: - """Initialize the status thread.""" - super().__init__() - self.dashboard = dashboard - - def run(self) -> None: - """Run the status thread.""" - dashboard = self.dashboard - entries = dashboard.entries - current_entries = entries.all() - - config = mqtt.config_from_env() - topic = "esphome/discover/#" - - def on_message(client, userdata, msg): - payload = msg.payload.decode(errors="backslashreplace") - if len(payload) > 0: - data = json.loads(payload) - if "name" not in data: - return - if matching_entries := entries.get_by_name(data["name"]): - for entry in matching_entries: - # Only override state if we don't have a state from another source - # or we have a state from MQTT and the device is reachable - entries.set_state_if_online_or_source( - entry, bool_to_entry_state(True, EntryStateSource.MQTT) - ) - - def on_connect(client, userdata, flags, return_code): - client.publish("esphome/discover", None, retain=False) - - mqttid = str(binascii.hexlify(os.urandom(6)).decode()) - - client = mqtt.prepare( - config, - [topic], - on_message, - on_connect, - None, - None, - f"esphome-dashboard-{mqttid}", - ) - client.loop_start() - - while not dashboard.stop_event.wait(2): - current_entries = entries.all() - # will be set to true on on_message - for entry in current_entries: - # Only override state if we don't have a state from another source - entries.set_state_if_source( - entry, bool_to_entry_state(False, EntryStateSource.MQTT) - ) - - client.publish("esphome/discover", None, retain=False) - dashboard.mqtt_ping_request.wait() - dashboard.mqtt_ping_request.clear() - - client.disconnect() - client.loop_stop() diff --git a/esphome/dashboard/status/ping.py b/esphome/dashboard/status/ping.py deleted file mode 100644 index eb69fbb9b3..0000000000 --- a/esphome/dashboard/status/ping.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -import time -import typing -from typing import cast - -from icmplib import Host, SocketPermissionError, async_ping - -from ..const import MAX_EXECUTOR_WORKERS -from ..entries import ( - DashboardEntry, - EntryState, - EntryStateSource, - ReachableState, - bool_to_entry_state, -) -from ..util.itertools import chunked - -if typing.TYPE_CHECKING: - from ..core import ESPHomeDashboard - - -_LOGGER = logging.getLogger(__name__) - -GROUP_SIZE = int(MAX_EXECUTOR_WORKERS / 2) - -DNS_FAILURE_STATE = EntryState(ReachableState.DNS_FAILURE, EntryStateSource.PING) - -MIN_PING_INTERVAL = 5 # ensure we don't ping too often - - -class PingStatus: - def __init__(self, dashboard: ESPHomeDashboard) -> None: - """Initialize the PingStatus class.""" - super().__init__() - self._loop = asyncio.get_running_loop() - self.dashboard = dashboard - - async def async_run(self) -> None: - """Run the ping status.""" - dashboard = self.dashboard - entries = dashboard.entries - privileged = await _can_use_icmp_lib_with_privilege() - if privileged is None: - _LOGGER.warning("Cannot use icmplib because privileges are insufficient") - return - - while not dashboard.stop_event.is_set(): - # Only ping if the dashboard is open - await dashboard.ping_request.wait() - dashboard.ping_request.clear() - iteration_start = time.monotonic() - current_entries = dashboard.entries.async_all() - to_ping: list[DashboardEntry] = [] - - for entry in current_entries: - if entry.address is None: - # No address or we already have a state from another source - # so no need to ping - continue - if ( - entry.state.reachable is ReachableState.ONLINE - and entry.state.source - not in (EntryStateSource.PING, EntryStateSource.UNKNOWN) - ): - # If we already have a state from another source and - # it's online, we don't need to ping - continue - to_ping.append(entry) - - # Resolve DNS for all entries - entries_with_addresses: dict[DashboardEntry, list[str]] = {} - for ping_group in chunked(to_ping, GROUP_SIZE): - ping_group = cast(list[DashboardEntry], ping_group) - now_monotonic = time.monotonic() - dns_results = await asyncio.gather( - *( - dashboard.dns_cache.async_resolve(entry.address, now_monotonic) - for entry in ping_group - ), - return_exceptions=True, - ) - - for entry, result in zip(ping_group, dns_results, strict=True): - if isinstance(result, Exception): - # Only update state if its unknown or from ping - # so we don't mark it as offline if we have a state - # from mDNS or MQTT - entries.async_set_state_if_source(entry, DNS_FAILURE_STATE) - continue - if isinstance(result, BaseException): - raise result - entries_with_addresses[entry] = result - - # Ping all entries with valid addresses - for ping_group in chunked(entries_with_addresses.items(), GROUP_SIZE): - entry_addresses = cast(tuple[DashboardEntry, list[str]], ping_group) - - results = await asyncio.gather( - *( - async_ping(addresses[0], privileged=privileged) - for _, addresses in entry_addresses - ), - return_exceptions=True, - ) - - for entry_address, result in zip(entry_addresses, results, strict=True): - if isinstance(result, Exception): - ping_result = False - elif isinstance(result, BaseException): - raise result - else: - host: Host = result - ping_result = host.is_alive - entry: DashboardEntry = entry_address[0] - # If we can reach it via ping, we always set it - # online, however if we can't reach it via ping - # we only set it to offline if the state is unknown - # or from ping - entries.async_set_state_if_online_or_source( - entry, - bool_to_entry_state(ping_result, EntryStateSource.PING), - ) - - if not dashboard.stop_event.is_set(): - iteration_duration = time.monotonic() - iteration_start - if iteration_duration < MIN_PING_INTERVAL: - await asyncio.sleep(MIN_PING_INTERVAL - iteration_duration) - - -async def _can_use_icmp_lib_with_privilege() -> None | bool: - """Verify we can create a raw socket.""" - try: - await async_ping("127.0.0.1", count=0, timeout=0, privileged=True) - except SocketPermissionError: - try: - await async_ping("127.0.0.1", count=0, timeout=0, privileged=False) - except SocketPermissionError: - _LOGGER.debug( - "Cannot use icmplib because privileges are insufficient to create the" - " socket" - ) - return None - - _LOGGER.debug("Using icmplib in privileged=False mode") - return False - - _LOGGER.debug("Using icmplib in privileged=True mode") - return True diff --git a/esphome/dashboard/util/__init__.py b/esphome/dashboard/util/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/esphome/dashboard/util/itertools.py b/esphome/dashboard/util/itertools.py deleted file mode 100644 index 54e95ef802..0000000000 --- a/esphome/dashboard/util/itertools.py +++ /dev/null @@ -1,22 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterable -from functools import partial -from itertools import islice -from typing import Any - - -def take(take_num: int, iterable: Iterable) -> list[Any]: - """Return first n items of the iterable as a list. - - From itertools recipes - """ - return list(islice(iterable, take_num)) - - -def chunked(iterable: Iterable, chunked_num: int) -> Iterable[Any]: - """Break *iterable* into lists of length *n*. - - From more-itertools - """ - return iter(partial(take, chunked_num, iter(iterable)), []) diff --git a/esphome/dashboard/util/password.py b/esphome/dashboard/util/password.py deleted file mode 100644 index e7ea28c25d..0000000000 --- a/esphome/dashboard/util/password.py +++ /dev/null @@ -1,11 +0,0 @@ -from __future__ import annotations - -import hashlib - - -def password_hash(password: str) -> bytes: - """Create a hash of a password to transform it to a fixed-length digest. - - Note this is not meant for secure storage, but for securely comparing passwords. - """ - return hashlib.sha256(password.encode()).digest() diff --git a/esphome/dashboard/util/subprocess.py b/esphome/dashboard/util/subprocess.py deleted file mode 100644 index 583dd116e3..0000000000 --- a/esphome/dashboard/util/subprocess.py +++ /dev/null @@ -1,31 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import Iterable - - -async def async_system_command_status(command: Iterable[str]) -> bool: - """Run a system command checking only the status.""" - process = await asyncio.create_subprocess_exec( - *command, - stdin=asyncio.subprocess.DEVNULL, - stdout=asyncio.subprocess.DEVNULL, - stderr=asyncio.subprocess.DEVNULL, - close_fds=False, - ) - await process.wait() - return process.returncode == 0 - - -async def async_run_system_command(command: Iterable[str]) -> tuple[bool, bytes, bytes]: - """Run a system command and return a tuple of returncode, stdout, stderr.""" - process = await asyncio.create_subprocess_exec( - *command, - stdin=asyncio.subprocess.DEVNULL, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - close_fds=False, - ) - stdout, stderr = await process.communicate() - await process.wait() - return process.returncode, stdout, stderr diff --git a/esphome/dashboard/util/text.py b/esphome/dashboard/util/text.py deleted file mode 100644 index bdf9abfdb9..0000000000 --- a/esphome/dashboard/util/text.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Back-compat shim for ``friendly_name_slugify``. - -The function moved to :mod:`esphome.helpers` so it survives the legacy -dashboard's eventual removal — see the -``esphome.helpers.friendly_name_slugify`` docstring. This module -re-exports the name so existing -``from esphome.dashboard.util.text import friendly_name_slugify`` -imports keep working while downstream consumers migrate. -""" - -from __future__ import annotations - -from esphome.helpers import friendly_name_slugify - -__all__ = ["friendly_name_slugify"] diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py deleted file mode 100644 index f5203efe9c..0000000000 --- a/esphome/dashboard/web_server.py +++ /dev/null @@ -1,1645 +0,0 @@ -from __future__ import annotations - -import asyncio -import base64 -import binascii -from collections.abc import Callable, Iterable -import contextlib -import datetime -import functools -from functools import partial -import gzip -import hashlib -import importlib -import json -import logging -import os -from pathlib import Path -import secrets -import shutil -import subprocess -import threading -import time -from typing import TYPE_CHECKING, Any, TypeVar -from urllib.parse import urlparse - -import tornado -import tornado.concurrent -import tornado.gen -import tornado.httpserver -import tornado.httputil -import tornado.ioloop -import tornado.iostream -from tornado.log import access_log -import tornado.netutil -import tornado.process -import tornado.queues -import tornado.web -import tornado.websocket -import voluptuous as vol -import yaml -from yaml.nodes import Node - -from esphome import const, yaml_util -from esphome.helpers import get_bool_env, mkdir_p, sort_ip_addresses -from esphome.platformio import toolchain -from esphome.storage_json import ( - StorageJSON, - archive_storage_path, - ext_storage_path, - trash_storage_path, -) -from esphome.util import get_serial_ports, shlex_quote -from esphome.yaml_util import FastestAvailableSafeLoader - -from ..helpers import write_file -from .const import DASHBOARD_COMMAND, ESPHOME_COMMAND, DashboardEvent -from .core import DASHBOARD, ESPHomeDashboard, Event -from .entries import UNKNOWN_STATE, DashboardEntry, entry_state_to_bool -from .models import build_device_list_response -from .util.subprocess import async_run_system_command -from .util.text import friendly_name_slugify - -if TYPE_CHECKING: - from requests import Response - -_LOGGER = logging.getLogger(__name__) - -ENV_DEV = "ESPHOME_DASHBOARD_DEV" - -COOKIE_AUTHENTICATED_YES = b"yes" - -AUTH_COOKIE_NAME = "authenticated" - - -settings = DASHBOARD.settings - - -def template_args() -> dict[str, Any]: - version = const.__version__ - if "b" in version: - docs_link = "https://beta.esphome.io/" - elif "dev" in version: - docs_link = "https://next.esphome.io/" - else: - docs_link = "https://www.esphome.io/" - - return { - "version": version, - "docs_link": docs_link, - "get_static_file_url": get_static_file_url, - "relative_url": settings.relative_url, - "streamer_mode": settings.streamer_mode, - "config_dir": settings.config_dir, - } - - -T = TypeVar("T", bound=Callable[..., Any]) - - -def authenticated(func: T) -> T: - @functools.wraps(func) - def decorator(self, *args: Any, **kwargs: Any): - if not is_authenticated(self): - self.redirect("./login") - return None - return func(self, *args, **kwargs) - - return decorator - - -def is_authenticated(handler: BaseHandler) -> bool: - """Check if the request is authenticated.""" - if settings.on_ha_addon: - # Handle ingress - disable auth on ingress port - # X-HA-Ingress is automatically stripped on the non-ingress server in nginx - header = handler.request.headers.get("X-HA-Ingress", "NO") - if str(header) == "YES": - return True - - if settings.using_auth: - if auth_header := handler.request.headers.get("Authorization"): - assert isinstance(auth_header, str) - if auth_header.startswith("Basic "): - try: - auth_decoded = base64.b64decode(auth_header[6:]).decode() - username, password = auth_decoded.split(":", 1) - except (binascii.Error, ValueError, UnicodeDecodeError): - return False - return settings.check_password(username, password) - return handler.get_secure_cookie(AUTH_COOKIE_NAME) == COOKIE_AUTHENTICATED_YES - - return True - - -def bind_config(func): - def decorator(self, *args, **kwargs): - configuration = self.get_argument("configuration") - kwargs = kwargs.copy() - kwargs["configuration"] = configuration - return func(self, *args, **kwargs) - - return decorator - - -# pylint: disable=abstract-method -class BaseHandler(tornado.web.RequestHandler): - pass - - -def websocket_class(cls): - # pylint: disable=protected-access - if not hasattr(cls, "_message_handlers"): - cls._message_handlers = {} - - for method in cls.__dict__.values(): - if hasattr(method, "_message_handler"): - cls._message_handlers[method._message_handler] = method - - return cls - - -def websocket_method(name): - def wrap(fn): - # pylint: disable=protected-access - fn._message_handler = name - return fn - - return wrap - - -class CheckOriginMixin: - """Mixin to handle WebSocket origin checks for reverse proxy setups.""" - - def check_origin(self, origin: str) -> bool: - if "ESPHOME_TRUSTED_DOMAINS" not in os.environ: - return super().check_origin(origin) - trusted_domains = [ - s.strip() for s in os.environ["ESPHOME_TRUSTED_DOMAINS"].split(",") - ] - url = urlparse(origin) - if url.hostname in trusted_domains: - return True - _LOGGER.info("check_origin %s, domain is not trusted", origin) - return False - - -@websocket_class -class EsphomeCommandWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandler): - """Base class for ESPHome websocket commands.""" - - def __init__( - self, - application: tornado.web.Application, - request: tornado.httputil.HTTPServerRequest, - **kwargs: Any, - ) -> None: - """Initialize the websocket.""" - super().__init__(application, request, **kwargs) - self._proc = None - self._queue = None - self._is_closed = False - # Windows doesn't support non-blocking pipes, - # use Popen() with a reading thread instead - self._use_popen = os.name == "nt" - - def open(self, *args: str, **kwargs: str) -> None: - """Handle new WebSocket connection.""" - # Ensure messages from the subprocess are sent immediately - # to avoid a 200-500ms delay when nodelay is not set. - self.set_nodelay(True) - - @authenticated - async def on_message( # pylint: disable=invalid-overridden-method - self, message: str - ) -> None: - # Since tornado 4.5, on_message is allowed to be a coroutine - # Messages are always JSON, 500 when not - json_message = json.loads(message) - type_ = json_message["type"] - # pylint: disable=no-member - handlers = type(self)._message_handlers - if type_ not in handlers: - _LOGGER.warning("Requested unknown message type %s", type_) - return - - await handlers[type_](self, json_message) - - @websocket_method("spawn") - async def handle_spawn(self, json_message: dict[str, Any]) -> None: - if self._proc is not None: - # spawn can only be called once - return - command = await self.build_command(json_message) - _LOGGER.info("Running command '%s'", " ".join(shlex_quote(x) for x in command)) - - if self._use_popen: - self._queue = tornado.queues.Queue() - # pylint: disable=consider-using-with - self._proc = subprocess.Popen( - command, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - close_fds=False, - ) - stdout_thread = threading.Thread(target=self._stdout_thread) - stdout_thread.daemon = True - stdout_thread.start() - else: - self._proc = tornado.process.Subprocess( - command, - stdout=tornado.process.Subprocess.STREAM, - stderr=subprocess.STDOUT, - stdin=tornado.process.Subprocess.STREAM, - close_fds=False, - ) - self._proc.set_exit_callback(self._proc_on_exit) - - tornado.ioloop.IOLoop.current().spawn_callback(self._redirect_stdout) - - @property - def is_process_active(self) -> bool: - return self._proc is not None and self._proc.returncode is None - - @websocket_method("stdin") - async def handle_stdin(self, json_message: dict[str, Any]) -> None: - if not self.is_process_active: - return - text: str = json_message["data"] - data = text.encode("utf-8", "replace") - _LOGGER.debug("< stdin: %s", data) - self._proc.stdin.write(data) - - @tornado.gen.coroutine - def _redirect_stdout(self) -> None: - reg = b"[\n\r]" - - while True: - try: - if self._use_popen: - data: bytes = yield self._queue.get() - if data is None: - self._proc_on_exit(self._proc.poll()) - break - else: - data: bytes = yield self._proc.stdout.read_until_regex(reg) - except tornado.iostream.StreamClosedError: - break - - text = data.decode("utf-8", "replace") - _LOGGER.debug("> stdout: %s", text) - self.write_message({"event": "line", "data": text}) - - def _stdout_thread(self) -> None: - if not self._use_popen: - return - line = b"" - cr = False - while True: - data = self._proc.stdout.read(1) - if data: - if data == b"\r": - cr = True - elif data == b"\n": - self._queue.put_nowait(line + b"\n") - line = b"" - cr = False - elif cr: - self._queue.put_nowait(line + b"\r") - line = data - cr = False - else: - line += data - if self._proc.poll() is not None: - break - self._proc.wait(1.0) - self._queue.put_nowait(None) - - def _proc_on_exit(self, returncode: int) -> None: - if not self._is_closed: - # Check if the proc was not forcibly closed - _LOGGER.info("Process exited with return code %s", returncode) - self.write_message({"event": "exit", "code": returncode}) - self.close() - - def on_close(self) -> None: - # Check if proc exists (if 'start' has been run) - if self.is_process_active: - _LOGGER.debug("Terminating process") - if self._use_popen: - self._proc.terminate() - else: - self._proc.proc.terminate() - # Shutdown proc on WS close - self._is_closed = True - - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - raise NotImplementedError - - -def build_cache_arguments( - entry: DashboardEntry | None, - dashboard: ESPHomeDashboard, - now: float, -) -> list[str]: - """Build cache arguments for passing to CLI. - - Args: - entry: Dashboard entry for the configuration - dashboard: Dashboard instance with cache access - now: Current monotonic time for DNS cache expiry checks - - Returns: - List of cache arguments to pass to CLI - """ - cache_args: list[str] = [] - - if not entry: - return cache_args - - _LOGGER.debug( - "Building cache for entry (address=%s, name=%s)", - entry.address, - entry.name, - ) - - def add_cache_entry(hostname: str, addresses: list[str], cache_type: str) -> None: - """Add a cache entry to the command arguments.""" - if not addresses: - return - normalized = hostname.rstrip(".").lower() - cache_args.extend( - [ - f"--{cache_type}-address-cache", - f"{normalized}={','.join(sort_ip_addresses(addresses))}", - ] - ) - - # Check entry.address for cached addresses - if use_address := entry.address: - if use_address.endswith(".local"): - # mDNS cache for .local addresses - if (mdns := dashboard.mdns_status) and ( - cached := mdns.get_cached_addresses(use_address) - ): - _LOGGER.debug("mDNS cache hit for %s: %s", use_address, cached) - add_cache_entry(use_address, cached, "mdns") - # DNS cache for non-.local addresses - elif cached := dashboard.dns_cache.get_cached_addresses(use_address, now): - _LOGGER.debug("DNS cache hit for %s: %s", use_address, cached) - add_cache_entry(use_address, cached, "dns") - - # Check entry.name if we haven't already cached via address - # For mDNS devices, entry.name typically doesn't have .local suffix - if entry.name and not use_address: - mdns_name = ( - f"{entry.name}.local" if not entry.name.endswith(".local") else entry.name - ) - if (mdns := dashboard.mdns_status) and ( - cached := mdns.get_cached_addresses(mdns_name) - ): - _LOGGER.debug("mDNS cache hit for %s: %s", mdns_name, cached) - add_cache_entry(mdns_name, cached, "mdns") - - return cache_args - - -class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): - """Base class for commands that require a port.""" - - async def build_device_command( - self, args: list[str], json_message: dict[str, Any] - ) -> list[str]: - """Build the command to run.""" - dashboard = DASHBOARD - entries = dashboard.entries - configuration = json_message["configuration"] - config_file = settings.rel_path(configuration) - port = json_message["port"] - - # Build cache arguments to pass to CLI - cache_args: list[str] = [] - - if ( - port == "OTA" # pylint: disable=too-many-boolean-expressions - and (entry := entries.get(config_file)) - and entry.loaded_integrations - and "api" in entry.loaded_integrations - ): - cache_args = build_cache_arguments(entry, dashboard, time.monotonic()) - - # Cache arguments must come before the subcommand - cmd = [*DASHBOARD_COMMAND, *cache_args, *args, config_file, "--device", port] - _LOGGER.debug("Built command: %s", cmd) - return cmd - - -class EsphomeLogsHandler(EsphomePortCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - """Build the command to run.""" - 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): - old_name: str - - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - config_file = settings.rel_path(json_message["configuration"]) - self.old_name = json_message["configuration"] - return [ - *DASHBOARD_COMMAND, - "rename", - config_file, - json_message["newName"], - ] - - def _proc_on_exit(self, returncode): - super()._proc_on_exit(returncode) - - if returncode != 0: - return - - # Remove the old ping result from the cache - entries = DASHBOARD.entries - if entry := entries.get(self.old_name): - entries.async_set_state(entry, UNKNOWN_STATE) - - -class EsphomeUploadHandler(EsphomePortCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - """Build the command to run.""" - return await self.build_device_command(["upload"], json_message) - - -class EsphomeRunHandler(EsphomePortCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - """Build the command to run.""" - return await self.build_device_command(["run"], json_message) - - -class EsphomeCompileHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - config_file = settings.rel_path(json_message["configuration"]) - command = [*DASHBOARD_COMMAND, "compile"] - if json_message.get("only_generate", False): - command.append("--only-generate") - command.append(config_file) - return command - - -class EsphomeValidateHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - config_file = settings.rel_path(json_message["configuration"]) - command = [*DASHBOARD_COMMAND, "config", config_file] - if not settings.streamer_mode: - command.append("--show-secrets") - return command - - -class EsphomeCleanMqttHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - config_file = settings.rel_path(json_message["configuration"]) - return [*DASHBOARD_COMMAND, "clean-mqtt", config_file] - - -class EsphomeCleanAllHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - clean_build_dir = json_message.get("clean_build_dir", True) - if clean_build_dir: - return [*DASHBOARD_COMMAND, "clean-all", settings.config_dir] - return [*DASHBOARD_COMMAND, "clean-all"] - - -class EsphomeCleanHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - config_file = settings.rel_path(json_message["configuration"]) - return [*DASHBOARD_COMMAND, "clean", config_file] - - -class EsphomeVscodeHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - return [*DASHBOARD_COMMAND, "-q", "vscode", "dummy"] - - -class EsphomeAceEditorHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - return [*DASHBOARD_COMMAND, "-q", "vscode", "--ace", settings.config_dir] - - -class EsphomeUpdateAllHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - return [*DASHBOARD_COMMAND, "update-all", settings.config_dir] - - -# Dashboard polling constants -DASHBOARD_POLL_INTERVAL = 2 # seconds -DASHBOARD_ENTRIES_UPDATE_INTERVAL = 10 # seconds -DASHBOARD_ENTRIES_UPDATE_ITERATIONS = ( - DASHBOARD_ENTRIES_UPDATE_INTERVAL // DASHBOARD_POLL_INTERVAL -) - - -class DashboardSubscriber: - """Manages dashboard event polling task lifecycle based on active subscribers.""" - - def __init__(self) -> None: - """Initialize the dashboard subscriber.""" - self._subscribers: set[DashboardEventsWebSocket] = set() - self._event_loop_task: asyncio.Task | None = None - self._refresh_event: asyncio.Event = asyncio.Event() - - def subscribe(self, subscriber: DashboardEventsWebSocket) -> Callable[[], None]: - """Subscribe to dashboard updates and start event loop if needed.""" - self._subscribers.add(subscriber) - if not self._event_loop_task or self._event_loop_task.done(): - self._event_loop_task = asyncio.create_task(self._event_loop()) - _LOGGER.info("Started dashboard event loop") - return partial(self._unsubscribe, subscriber) - - def _unsubscribe(self, subscriber: DashboardEventsWebSocket) -> None: - """Unsubscribe from dashboard updates and stop event loop if no subscribers.""" - self._subscribers.discard(subscriber) - if ( - not self._subscribers - and self._event_loop_task - and not self._event_loop_task.done() - ): - self._event_loop_task.cancel() - self._event_loop_task = None - _LOGGER.info("Stopped dashboard event loop - no subscribers") - - def request_refresh(self) -> None: - """Signal the polling loop to refresh immediately.""" - self._refresh_event.set() - - async def _event_loop(self) -> None: - """Run the event polling loop while there are subscribers.""" - dashboard = DASHBOARD - entries_update_counter = 0 - - while self._subscribers: - # Signal that we need ping updates (non-blocking) - dashboard.ping_request.set() - if settings.status_use_mqtt: - dashboard.mqtt_ping_request.set() - - # Check if it's time to update entries or if refresh was requested - entries_update_counter += 1 - if ( - entries_update_counter >= DASHBOARD_ENTRIES_UPDATE_ITERATIONS - or self._refresh_event.is_set() - ): - entries_update_counter = 0 - await dashboard.entries.async_request_update_entries() - # Clear the refresh event if it was set - self._refresh_event.clear() - - # Wait for either timeout or refresh event - try: - async with asyncio.timeout(DASHBOARD_POLL_INTERVAL): - await self._refresh_event.wait() - # If we get here, refresh was requested - continue loop immediately - except TimeoutError: - # Normal timeout - continue with regular polling - pass - - -# Global dashboard subscriber instance -DASHBOARD_SUBSCRIBER = DashboardSubscriber() - - -@websocket_class -class DashboardEventsWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandler): - """WebSocket handler for real-time dashboard events.""" - - _event_listeners: list[Callable[[], None]] | None = None - _dashboard_unsubscribe: Callable[[], None] | None = None - - async def get(self, *args: str, **kwargs: str) -> None: - """Handle WebSocket upgrade request.""" - if not is_authenticated(self): - self.set_status(401) - self.finish("Unauthorized") - return - await super().get(*args, **kwargs) - - async def open(self, *args: str, **kwargs: str) -> None: # pylint: disable=invalid-overridden-method - """Handle new WebSocket connection.""" - # Ensure messages are sent immediately to avoid - # a 200-500ms delay when nodelay is not set. - self.set_nodelay(True) - - # Update entries first - await DASHBOARD.entries.async_request_update_entries() - # Send initial state - self._send_initial_state() - # Subscribe to events - self._subscribe_to_events() - # Subscribe to dashboard updates - self._dashboard_unsubscribe = DASHBOARD_SUBSCRIBER.subscribe(self) - _LOGGER.debug("Dashboard status WebSocket opened") - - def _send_initial_state(self) -> None: - """Send initial device list and ping status.""" - entries = DASHBOARD.entries.async_all() - - # Send initial state - self._safe_send_message( - { - "event": DashboardEvent.INITIAL_STATE, - "data": { - "devices": build_device_list_response(DASHBOARD, entries), - "ping": { - entry.filename: entry_state_to_bool(entry.state) - for entry in entries - }, - }, - } - ) - - def _subscribe_to_events(self) -> None: - """Subscribe to dashboard events.""" - async_add_listener = DASHBOARD.bus.async_add_listener - # Subscribe to all events - self._event_listeners = [ - async_add_listener( - DashboardEvent.ENTRY_STATE_CHANGED, self._on_entry_state_changed - ), - async_add_listener( - DashboardEvent.ENTRY_ADDED, - self._make_entry_handler(DashboardEvent.ENTRY_ADDED), - ), - async_add_listener( - DashboardEvent.ENTRY_REMOVED, - self._make_entry_handler(DashboardEvent.ENTRY_REMOVED), - ), - async_add_listener( - DashboardEvent.ENTRY_UPDATED, - self._make_entry_handler(DashboardEvent.ENTRY_UPDATED), - ), - async_add_listener( - DashboardEvent.IMPORTABLE_DEVICE_ADDED, self._on_importable_added - ), - async_add_listener( - DashboardEvent.IMPORTABLE_DEVICE_REMOVED, - self._on_importable_removed, - ), - ] - - def _on_entry_state_changed(self, event: Event) -> None: - """Handle entry state change event.""" - entry = event.data["entry"] - state = event.data["state"] - self._safe_send_message( - { - "event": DashboardEvent.ENTRY_STATE_CHANGED, - "data": { - "filename": entry.filename, - "name": entry.name, - "state": entry_state_to_bool(state), - }, - } - ) - - def _make_entry_handler( - self, event_type: DashboardEvent - ) -> Callable[[Event], None]: - """Create an entry event handler.""" - - def handler(event: Event) -> None: - self._safe_send_message( - {"event": event_type, "data": {"device": event.data["entry"].to_dict()}} - ) - - return handler - - def _on_importable_added(self, event: Event) -> None: - """Handle importable device added event.""" - # Don't send if device is already configured - device_name = event.data.get("device", {}).get("name") - if device_name and DASHBOARD.entries.get_by_name(device_name): - return - self._safe_send_message( - {"event": DashboardEvent.IMPORTABLE_DEVICE_ADDED, "data": event.data} - ) - - def _on_importable_removed(self, event: Event) -> None: - """Handle importable device removed event.""" - self._safe_send_message( - {"event": DashboardEvent.IMPORTABLE_DEVICE_REMOVED, "data": event.data} - ) - - def _safe_send_message(self, message: dict[str, Any]) -> None: - """Send a message to the WebSocket client, ignoring closed errors.""" - with contextlib.suppress(tornado.websocket.WebSocketClosedError): - self.write_message(json.dumps(message)) - - def on_message(self, message: str) -> None: - """Handle incoming WebSocket messages.""" - _LOGGER.debug("WebSocket received message: %s", message) - try: - data = json.loads(message) - except json.JSONDecodeError as err: - _LOGGER.debug("Failed to parse WebSocket message: %s", err) - return - - event = data.get("event") - _LOGGER.debug("WebSocket message event: %s", event) - if event == DashboardEvent.PING: - # Send pong response for client ping - _LOGGER.debug("Received client ping, sending pong") - self._safe_send_message({"event": DashboardEvent.PONG}) - elif event == DashboardEvent.REFRESH: - # Signal the polling loop to refresh immediately - _LOGGER.debug("Received refresh request, signaling polling loop") - DASHBOARD_SUBSCRIBER.request_refresh() - - def on_close(self) -> None: - """Handle WebSocket close.""" - # Unsubscribe from dashboard updates - if self._dashboard_unsubscribe: - self._dashboard_unsubscribe() - self._dashboard_unsubscribe = None - - # Unsubscribe from events - for remove_listener in self._event_listeners or []: - remove_listener() - - _LOGGER.debug("Dashboard status WebSocket closed") - - -class SerialPortRequestHandler(BaseHandler): - @authenticated - async def get(self) -> None: - ports = await asyncio.get_running_loop().run_in_executor(None, get_serial_ports) - data = [] - for port in ports: - desc = port.description - if port.path == "/dev/ttyAMA0": - desc = "UART pins on GPIO header" - split_desc = desc.split(" - ") - if len(split_desc) == 2 and split_desc[0] == split_desc[1]: - # Some serial ports repeat their values - desc = split_desc[0] - data.append({"port": port.path, "desc": desc}) - data.append({"port": "OTA", "desc": "Over-The-Air"}) - data.sort(key=lambda x: x["port"], reverse=True) - self.set_header("content-type", "application/json") - self.write(json.dumps(data)) - - -class WizardRequestHandler(BaseHandler): - @authenticated - def post(self) -> None: - from esphome import wizard - - kwargs = { - k: v - for k, v in json.loads(self.request.body.decode()).items() - if k - in ( - "type", - "name", - "platform", - "board", - "ssid", - "psk", - "password", - "file_content", - ) - } - if not kwargs["name"]: - self.set_status(422) - self.set_header("content-type", "application/json") - self.write(json.dumps({"error": "Name is required"})) - return - - if "type" not in kwargs: - # Default to basic wizard type for backwards compatibility - kwargs["type"] = "basic" - - kwargs["friendly_name"] = kwargs["name"] - kwargs["name"] = friendly_name_slugify(kwargs["friendly_name"]) - if kwargs["type"] == "basic": - kwargs["ota_password"] = secrets.token_hex(16) - noise_psk = secrets.token_bytes(32) - kwargs["api_encryption_key"] = base64.b64encode(noise_psk).decode() - elif kwargs["type"] == "upload": - try: - kwargs["file_text"] = base64.b64decode(kwargs["file_content"]).decode( - "utf-8" - ) - except (binascii.Error, UnicodeDecodeError): - self.set_status(422) - self.set_header("content-type", "application/json") - self.write( - json.dumps({"error": "The uploaded file is not correctly encoded."}) - ) - return - elif kwargs["type"] != "empty": - self.set_status(422) - self.set_header("content-type", "application/json") - self.write( - json.dumps( - {"error": f"Invalid wizard type specified: {kwargs['type']}"} - ) - ) - return - filename = f"{kwargs['name']}.yaml" - destination = settings.rel_path(filename) - - # Check if destination file already exists - if destination.exists(): - self.set_status(409) # Conflict status code - self.set_header("content-type", "application/json") - self.write( - json.dumps({"error": f"Configuration file '{filename}' already exists"}) - ) - self.finish() - return - - success = wizard.wizard_write(path=destination, **kwargs) - if success: - self.set_status(200) - self.set_header("content-type", "application/json") - self.write(json.dumps({"configuration": filename})) - self.finish() - else: - self.set_status(500) - self.set_header("content-type", "application/json") - self.write( - json.dumps( - {"error": "Failed to write configuration, see logs for details"} - ) - ) - self.finish() - - -class ImportRequestHandler(BaseHandler): - @authenticated - def post(self) -> None: - from esphome.components.dashboard_import import import_config - - dashboard = DASHBOARD - args = json.loads(self.request.body.decode()) - try: - name = args["name"] - friendly_name = args.get("friendly_name") - encryption = args.get("encryption", False) - - imported_device = next( - ( - res - for res in dashboard.import_result.values() - if res.device_name == name - ), - None, - ) - - if imported_device is not None: - network = imported_device.network - if friendly_name is None: - friendly_name = imported_device.friendly_name - else: - network = const.CONF_WIFI - - import_config( - settings.rel_path(f"{name}.yaml"), - name, - friendly_name, - args["project_name"], - args["package_import_url"], - network, - encryption, - ) - # Make sure the device gets marked online right away - dashboard.ping_request.set() - except FileExistsError: - self.set_status(500) - self.write("File already exists") - return - except ValueError as e: - _LOGGER.error(e) - self.set_status(422) - self.write("Invalid package url") - return - - self.set_status(200) - self.set_header("content-type", "application/json") - self.write(json.dumps({"configuration": f"{name}.yaml"})) - self.finish() - - -class IgnoreDeviceRequestHandler(BaseHandler): - @authenticated - async def post(self) -> None: - dashboard = DASHBOARD - try: - args = json.loads(self.request.body.decode()) - device_name = args["name"] - ignore = args["ignore"] - except (json.JSONDecodeError, KeyError): - self.set_status(400) - self.set_header("content-type", "application/json") - self.write(json.dumps({"error": "Invalid payload"})) - return - - ignored_device = next( - ( - res - for res in dashboard.import_result.values() - if res.device_name == device_name - ), - None, - ) - - if ignored_device is None: - self.set_status(404) - self.set_header("content-type", "application/json") - self.write(json.dumps({"error": "Device not found"})) - return - - if ignore: - dashboard.ignored_devices.add(ignored_device.device_name) - else: - dashboard.ignored_devices.discard(ignored_device.device_name) - - loop = asyncio.get_running_loop() - await loop.run_in_executor(None, dashboard.save_ignored_devices) - - self.set_status(204) - self.finish() - - -class DownloadListRequestHandler(BaseHandler): - @authenticated - @bind_config - async def get(self, configuration: str | None = None) -> None: - loop = asyncio.get_running_loop() - try: - downloads_json = await loop.run_in_executor(None, self._get, configuration) - except vol.Invalid as exc: - _LOGGER.exception("Error while fetching downloads", exc_info=exc) - self.send_error(404) - return - if downloads_json is None: - _LOGGER.error("Configuration %s not found", configuration) - self.send_error(404) - return - self.set_status(200) - self.set_header("content-type", "application/json") - self.write(downloads_json) - self.finish() - - def _get(self, configuration: str | None = None) -> dict[str, Any] | None: - storage_path = ext_storage_path(configuration) - storage_json = StorageJSON.load(storage_path) - if storage_json is None: - return None - - try: - config = yaml_util.load_yaml(settings.rel_path(configuration)) - - if const.CONF_EXTERNAL_COMPONENTS in config: - from esphome.components.external_components import ( - do_external_components_pass, - ) - - do_external_components_pass(config) - except vol.Invalid: - _LOGGER.info("Could not parse `external_components`, skipping") - - from esphome.components.esp32 import VARIANTS as ESP32_VARIANTS - - downloads: list[dict[str, Any]] = [] - platform: str = storage_json.target_platform.lower() - - if platform.upper() in ESP32_VARIANTS: - platform = "esp32" - elif platform in ( - const.PLATFORM_RTL87XX, - const.PLATFORM_BK72XX, - const.PLATFORM_LN882X, - ): - platform = "libretiny" - - try: - module = importlib.import_module(f"esphome.components.{platform}") - get_download_types = module.get_download_types - except AttributeError as exc: - raise ValueError(f"Unknown platform {platform}") from exc - downloads = get_download_types(storage_json) - return json.dumps(downloads) - - -class DownloadBinaryRequestHandler(BaseHandler): - def _load_file(self, path: str, compressed: bool) -> bytes: - """Load a file from disk and compress it if requested.""" - with Path(path).open("rb") as f: - data = f.read() - if compressed: - return gzip.compress(data, 9) - return data - - @authenticated - @bind_config - async def get(self, configuration: str | None = None) -> None: - """Download a binary file.""" - loop = asyncio.get_running_loop() - compressed = self.get_argument("compressed", "0") == "1" - - storage_path = ext_storage_path(configuration) - storage_json = StorageJSON.load(storage_path) - if storage_json is None: - self.send_error(404) - return - - # fallback to type=, but prioritize file= - file_name = self.get_argument("type", None) - file_name = self.get_argument("file", file_name) - if file_name is None or not file_name.strip(): - self.send_error(400) - return - # get requested download name, or build it based on filename - download_name = self.get_argument( - "download", - f"{storage_json.name}-{file_name}", - ) - - if storage_json.firmware_bin_path is None: - self.send_error(404) - return - - base_dir = storage_json.firmware_bin_path.parent.resolve() - path = base_dir.joinpath(file_name).resolve() - try: - path.relative_to(base_dir) - except ValueError: - self.send_error(403) - return - - if not path.is_file(): - args = [*ESPHOME_COMMAND, "idedata", settings.rel_path(configuration)] - rc, stdout, _ = await async_run_system_command(args) - - if rc != 0: - self.send_error(404 if rc == 2 else 500) - return - - idedata = toolchain.IDEData(json.loads(stdout)) - - found = False - for image in idedata.extra_flash_images: - if image.path.as_posix().endswith(file_name): - path = image.path - download_name = file_name - found = True - break - - if not found: - self.send_error(404) - return - - download_name = download_name + ".gz" if compressed else download_name - - self.set_header("Content-Type", "application/octet-stream") - self.set_header( - "Content-Disposition", f'attachment; filename="{download_name}"' - ) - self.set_header("Cache-Control", "no-cache") - if not Path(path).is_file(): - self.send_error(404) - return - - data = await loop.run_in_executor(None, self._load_file, path, compressed) - self.write(data) - - self.finish() - - -class EsphomeVersionHandler(BaseHandler): - @authenticated - def get(self) -> None: - self.set_header("Content-Type", "application/json") - self.write(json.dumps({"version": const.__version__})) - self.finish() - - -class ListDevicesHandler(BaseHandler): - @authenticated - async def get(self) -> None: - dashboard = DASHBOARD - await dashboard.entries.async_request_update_entries() - entries = dashboard.entries.async_all() - self.set_header("content-type", "application/json") - self.write(json.dumps(build_device_list_response(dashboard, entries))) - - -class MainRequestHandler(BaseHandler): - @authenticated - def get(self) -> None: - begin = bool(self.get_argument("begin", False)) - if settings.using_password: - # Simply accessing the xsrf_token sets the cookie for us - self.xsrf_token # pylint: disable=pointless-statement # noqa: B018 - else: - self.clear_cookie("_xsrf") - - self.render( - "index.template.html", - begin=begin, - **template_args(), - login_enabled=settings.using_password, - ) - - -class PrometheusServiceDiscoveryHandler(BaseHandler): - @authenticated - async def get(self) -> None: - dashboard = DASHBOARD - await dashboard.entries.async_request_update_entries() - entries = dashboard.entries.async_all() - self.set_header("content-type", "application/json") - sd = [] - for entry in entries: - if entry.web_port is None: - continue - labels = { - "__meta_name": entry.name, - "__meta_esp_platform": entry.target_platform, - "__meta_esphome_version": entry.storage.esphome_version, - } - for integration in entry.storage.loaded_integrations: - labels[f"__meta_integration_{integration}"] = "true" - sd.append( - { - "targets": [ - f"{entry.address}:{entry.web_port}", - ], - "labels": labels, - } - ) - self.write(json.dumps(sd)) - - -class BoardsRequestHandler(BaseHandler): - @authenticated - def get(self, platform: str) -> None: - # filter all ESP32 variants by requested platform - if platform.startswith("esp32"): - from esphome.components.esp32.boards import BOARDS as ESP32_BOARDS - - boards = { - k: v - for k, v in ESP32_BOARDS.items() - if v[const.KEY_VARIANT] == platform.upper() - } - elif platform == const.PLATFORM_ESP8266: - from esphome.components.esp8266.boards import BOARDS as ESP8266_BOARDS - - boards = ESP8266_BOARDS - elif platform == const.PLATFORM_RP2040: - from esphome.components.rp2040.boards import BOARDS as RP2040_BOARDS - - boards = RP2040_BOARDS - elif platform == const.PLATFORM_BK72XX: - from esphome.components.bk72xx.boards import BOARDS as BK72XX_BOARDS - - boards = BK72XX_BOARDS - elif platform == const.PLATFORM_LN882X: - from esphome.components.ln882x.boards import BOARDS as LN882X_BOARDS - - boards = LN882X_BOARDS - elif platform == const.PLATFORM_RTL87XX: - from esphome.components.rtl87xx.boards import BOARDS as RTL87XX_BOARDS - - boards = RTL87XX_BOARDS - else: - raise ValueError(f"Unknown platform {platform}") - - # map to a {board_name: board_title} dict - platform_boards = {key: val[const.KEY_NAME] for key, val in boards.items()} - # sort by board title - boards_items = sorted(platform_boards.items(), key=lambda item: item[1]) - output = [{"items": dict(boards_items)}] - - self.set_header("content-type", "application/json") - self.write(json.dumps(output)) - - -class PingRequestHandler(BaseHandler): - @authenticated - def get(self) -> None: - dashboard = DASHBOARD - dashboard.ping_request.set() - if settings.status_use_mqtt: - dashboard.mqtt_ping_request.set() - self.set_header("content-type", "application/json") - - self.write( - json.dumps( - { - entry.filename: entry_state_to_bool(entry.state) - for entry in dashboard.entries.async_all() - } - ) - ) - - -class InfoRequestHandler(BaseHandler): - @authenticated - @bind_config - async def get(self, configuration: str | None = None) -> None: - yaml_path = settings.rel_path(configuration) - dashboard = DASHBOARD - entry = dashboard.entries.get(yaml_path) - - if not entry or entry.storage is None: - self.set_status(404) - return - - self.set_header("content-type", "application/json") - self.write(entry.storage.to_json()) - - -class EditRequestHandler(BaseHandler): - @authenticated - @bind_config - async def get(self, configuration: str | None = None) -> None: - """Get the content of a file.""" - if not configuration.endswith((".yaml", ".yml")): - self.send_error(404) - return - - filename = settings.rel_path(configuration) - if filename.resolve().parent != settings.absolute_config_dir: - self.send_error(404) - return - - loop = asyncio.get_running_loop() - content = await loop.run_in_executor( - None, self._read_file, filename, configuration - ) - if content is not None: - self.set_header("Content-Type", "application/yaml") - self.write(content) - - def _read_file(self, filename: str, configuration: str) -> bytes | None: - """Read a file and return the content as bytes.""" - try: - with Path(filename).open(encoding="utf-8") as f: - return f.read() - except FileNotFoundError: - if configuration in const.SECRETS_FILES: - return "" - self.set_status(404) - return None - - @authenticated - @bind_config - async def post(self, configuration: str | None = None) -> None: - """Write the content of a file.""" - if not configuration.endswith((".yaml", ".yml")): - self.send_error(404) - return - - filename = settings.rel_path(configuration) - if filename.resolve().parent != settings.absolute_config_dir: - self.send_error(404) - return - - loop = asyncio.get_running_loop() - await loop.run_in_executor(None, write_file, filename, self.request.body) - # Ensure the StorageJSON is updated as well - DASHBOARD.entries.async_schedule_storage_json_update(filename) - self.set_status(200) - - -class ArchiveRequestHandler(BaseHandler): - @authenticated - @bind_config - def post(self, configuration: str | None = None) -> None: - config_file = settings.rel_path(configuration) - storage_path = ext_storage_path(configuration) - - archive_path = archive_storage_path() - mkdir_p(archive_path) - shutil.move(config_file, archive_path / configuration) - - storage_json = StorageJSON.load(storage_path) - if storage_json is not None and storage_json.build_path: - # Delete build folder (if exists) - shutil.rmtree(storage_json.build_path, ignore_errors=True) - - -class UnArchiveRequestHandler(BaseHandler): - @authenticated - @bind_config - def post(self, configuration: str | None = None) -> None: - config_file = settings.rel_path(configuration) - archive_path = archive_storage_path() - shutil.move(archive_path / configuration, config_file) - - -class LoginHandler(BaseHandler): - def get(self) -> None: - if is_authenticated(self): - self.redirect("./") - else: - self.render_login_page() - - def render_login_page(self, error: str | None = None) -> None: - self.render( - "login.template.html", - error=error, - ha_addon=settings.using_ha_addon_auth, - has_username=bool(settings.username), - **template_args(), - ) - - def _make_supervisor_auth_request(self) -> Response: - """Make a request to the supervisor auth endpoint.""" - import requests - - headers = {"X-Supervisor-Token": os.getenv("SUPERVISOR_TOKEN")} - data = { - "username": self.get_argument("username", ""), - "password": self.get_argument("password", ""), - } - return requests.post( - "http://supervisor/auth", headers=headers, json=data, timeout=30 - ) - - async def post_ha_addon_login(self) -> None: - loop = asyncio.get_running_loop() - try: - req = await loop.run_in_executor(None, self._make_supervisor_auth_request) - except Exception as err: # noqa: BLE001 # pylint: disable=broad-except - _LOGGER.warning("Error during Hass.io auth request: %s", err) - self.set_status(500) - self.render_login_page(error="Internal server error") - return - - if req.status_code == 200: - self._set_authenticated() - self.redirect("/") - return - self.set_status(401) - self.render_login_page(error="Invalid username or password") - - def _set_authenticated(self) -> None: - """Set the authenticated cookie.""" - self.set_secure_cookie(AUTH_COOKIE_NAME, COOKIE_AUTHENTICATED_YES) - - def post_native_login(self) -> None: - username = self.get_argument("username", "") - password = self.get_argument("password", "") - if settings.check_password(username, password): - self._set_authenticated() - self.redirect("./") - return - error_str = ( - "Invalid username or password" if settings.username else "Invalid password" - ) - self.set_status(401) - self.render_login_page(error=error_str) - - async def post(self): - if settings.using_ha_addon_auth: - await self.post_ha_addon_login() - else: - self.post_native_login() - - -class LogoutHandler(BaseHandler): - @authenticated - def get(self) -> None: - self.clear_cookie(AUTH_COOKIE_NAME) - self.redirect("./login") - - -class SecretKeysRequestHandler(BaseHandler): - @authenticated - def get(self) -> None: - filename = None - - for secret_filename in const.SECRETS_FILES: - relative_filename = settings.rel_path(secret_filename) - if relative_filename.is_file(): - filename = relative_filename - break - - if filename is None: - self.send_error(404) - return - - secret_keys = list(yaml_util.load_yaml(filename, clear_secrets=False)) - - self.set_header("content-type", "application/json") - self.write(json.dumps(secret_keys)) - - -class SafeLoaderIgnoreUnknown(FastestAvailableSafeLoader): - def ignore_unknown(self, node: Node) -> str: - return f"{node.tag} {node.value}" - - def construct_yaml_binary(self, node: Node) -> str: - return super().construct_yaml_binary(node).decode("ascii") - - -SafeLoaderIgnoreUnknown.add_constructor(None, SafeLoaderIgnoreUnknown.ignore_unknown) -SafeLoaderIgnoreUnknown.add_constructor( - "tag:yaml.org,2002:binary", SafeLoaderIgnoreUnknown.construct_yaml_binary -) - - -class JsonConfigRequestHandler(BaseHandler): - @authenticated - @bind_config - async def get(self, configuration: str | None = None) -> None: - filename = settings.rel_path(configuration) - if not filename.is_file(): - self.send_error(404) - return - - args = [*ESPHOME_COMMAND, "config", str(filename), "--show-secrets"] - - rc, stdout, stderr = await async_run_system_command(args) - - if rc != 0: - self.set_status(422) - self.write(stderr) - return - - data = yaml.load(stdout, Loader=SafeLoaderIgnoreUnknown) - self.set_header("content-type", "application/json") - self.write(json.dumps(data)) - self.finish() - - -def get_base_frontend_path() -> Path: - if ENV_DEV not in os.environ: - import esphome_dashboard - - return esphome_dashboard.where() - - static_path = os.environ[ENV_DEV] - if not static_path.endswith("/"): - static_path += "/" - - # This path can be relative, so resolve against the root or else templates don't work - path = Path.cwd() / static_path / "esphome_dashboard" - return path.resolve() - - -def get_static_path(*args: Iterable[str]) -> Path: - return get_base_frontend_path() / "static" / Path(*args) - - -@functools.cache -def get_static_file_url(name: str) -> str: - base = f"./static/{name}" - - if ENV_DEV in os.environ: - return base - - # Module imports can't deduplicate if stuff added to url - if name == "js/esphome/index.js": - import esphome_dashboard - - return base.replace("index.js", esphome_dashboard.entrypoint()) - - path = get_static_path(name) - hash_ = hashlib.md5(path.read_bytes()).hexdigest()[:8] - return f"{base}?hash={hash_}" - - -def make_app(debug: bool | None = None) -> tornado.web.Application: - if debug is None: - debug = get_bool_env(ENV_DEV) - - def log_function(handler: tornado.web.RequestHandler) -> None: - if handler.get_status() < 400: - log_method = access_log.info - - if isinstance(handler, SerialPortRequestHandler) and not debug: - return - if isinstance(handler, PingRequestHandler) and not debug: - return - elif handler.get_status() < 500: - log_method = access_log.warning - else: - log_method = access_log.error - - request_time = 1000.0 * handler.request.request_time() - # pylint: disable=protected-access - log_method( - "%d %s %.2fms", - handler.get_status(), - handler._request_summary(), - request_time, - ) - - class StaticFileHandler(tornado.web.StaticFileHandler): - def get_cache_time( - self, path: str, modified: datetime.datetime | None, mime_type: str - ) -> int: - """Override to customize cache control behavior.""" - if debug: - return 0 - # Assets that are hashed have ?hash= in the URL, all javascript - # filenames hashed so we can cache them for a long time - if "hash" in self.request.arguments or "/javascript" in mime_type: - return self.CACHE_MAX_AGE - return super().get_cache_time(path, modified, mime_type) - - app_settings = { - "debug": debug, - "cookie_secret": settings.cookie_secret, - "log_function": log_function, - "websocket_ping_interval": 30.0, - "template_path": get_base_frontend_path(), - "xsrf_cookies": settings.using_password, - } - rel = settings.relative_url - return tornado.web.Application( - [ - (f"{rel}", MainRequestHandler), - (f"{rel}login", LoginHandler), - (f"{rel}logout", LogoutHandler), - (f"{rel}logs", EsphomeLogsHandler), - (f"{rel}upload", EsphomeUploadHandler), - (f"{rel}run", EsphomeRunHandler), - (f"{rel}compile", EsphomeCompileHandler), - (f"{rel}validate", EsphomeValidateHandler), - (f"{rel}clean-mqtt", EsphomeCleanMqttHandler), - (f"{rel}clean-all", EsphomeCleanAllHandler), - (f"{rel}clean", EsphomeCleanHandler), - (f"{rel}vscode", EsphomeVscodeHandler), - (f"{rel}ace", EsphomeAceEditorHandler), - (f"{rel}update-all", EsphomeUpdateAllHandler), - (f"{rel}info", InfoRequestHandler), - (f"{rel}edit", EditRequestHandler), - (f"{rel}downloads", DownloadListRequestHandler), - (f"{rel}download.bin", DownloadBinaryRequestHandler), - (f"{rel}serial-ports", SerialPortRequestHandler), - (f"{rel}ping", PingRequestHandler), - (f"{rel}delete", ArchiveRequestHandler), - (f"{rel}undo-delete", UnArchiveRequestHandler), - (f"{rel}archive", ArchiveRequestHandler), - (f"{rel}unarchive", UnArchiveRequestHandler), - (f"{rel}wizard", WizardRequestHandler), - (f"{rel}static/(.*)", StaticFileHandler, {"path": get_static_path()}), - (f"{rel}devices", ListDevicesHandler), - (f"{rel}events", DashboardEventsWebSocket), - (f"{rel}import", ImportRequestHandler), - (f"{rel}secret_keys", SecretKeysRequestHandler), - (f"{rel}json-config", JsonConfigRequestHandler), - (f"{rel}rename", EsphomeRenameHandler), - (f"{rel}prometheus-sd", PrometheusServiceDiscoveryHandler), - (f"{rel}boards/([a-z0-9]+)", BoardsRequestHandler), - (f"{rel}version", EsphomeVersionHandler), - (f"{rel}ignore-device", IgnoreDeviceRequestHandler), - ], - **app_settings, - ) - - -def start_web_server( - app: tornado.web.Application, - socket: str | None, - address: str | None, - port: int | None, - config_dir: str, -) -> None: - """Start the web server listener.""" - - trash_path = trash_storage_path() - if trash_path.is_dir() and trash_path.exists(): - _LOGGER.info("Renaming 'trash' folder to 'archive'") - archive_path = archive_storage_path() - shutil.move(trash_path, archive_path) - - if socket is None: - _LOGGER.info( - "Starting dashboard web server on http://%s:%s and configuration dir %s...", - address, - port, - config_dir, - ) - app.listen(port, address) - return - - _LOGGER.info( - "Starting dashboard web server on unix socket %s and configuration dir %s...", - socket, - config_dir, - ) - server = tornado.httpserver.HTTPServer(app) - socket = tornado.netutil.bind_unix_socket(socket, mode=0o666) - server.add_socket(socket) diff --git a/esphome/helpers.py b/esphome/helpers.py index ef7e2d0b93..62dfd0fb09 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -124,14 +124,8 @@ def slugify(value: str) -> str: def friendly_name_slugify(value: str) -> str: """Convert a friendly name to a slug with dashes instead of underscores. - Used by: - - esphome.dashboard.web_server (legacy dashboard) - - device-builder (esphome/device-builder) — slugifies friendly names - into the YAML filename / device name during adoption + wizard flows. - - Lives here rather than in ``esphome.dashboard.util.text`` so it - survives the legacy dashboard's eventual removal. - The dashboard module re-exports this name as a back-compat shim. + Used by device-builder (esphome/device-builder), which slugifies friendly + names into the YAML filename / device name during adoption + wizard flows. Coordinate with the device-builder team before changing the slugification rules — the mapping must stay stable so existing on-disk filenames keep matching across releases. diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 3bdda1a9a1..f754673b79 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -71,14 +71,10 @@ def _to_path_if_not_none(value: str | None) -> Path | None: class StorageJSON: """Persisted device metadata sidecar. - Used by: - - esphome.dashboard (legacy dashboard) - - device-builder (esphome/device-builder) — reads/writes the same - JSON file as the legacy dashboard so a single config_dir can be - shared between the two during the transition. The schema - (``storage_version``, field names, types) must stay backwards - compatible — coordinate with the device-builder team before - adding required fields or changing semantics of existing ones. + Used by device-builder (esphome/device-builder), which reads/writes this + JSON file. The schema (``storage_version``, field names, types) must stay + backwards compatible — coordinate with the device-builder team before + adding required fields or changing semantics of existing ones. """ def __init__( diff --git a/esphome/zeroconf.py b/esphome/zeroconf.py index e4b9abb976..04075ec4c1 100644 --- a/esphome/zeroconf.py +++ b/esphome/zeroconf.py @@ -62,14 +62,12 @@ TXT_RECORD_VERSION = b"version" class DiscoveredImport: """An importable device discovered via mDNS ``_esphomelib._tcp.local.``. - Used by: - - esphome.dashboard (legacy dashboard) - - device-builder (esphome/device-builder) — surfaces these as - "discovered devices" on the new dashboard's adoption flow. + Used by device-builder (esphome/device-builder), which surfaces these as + "discovered devices" on its adoption flow. Fields are populated from TXT records on the broadcast service info (see :class:`DashboardImportDiscovery`). Coordinate before - adding/removing fields — both consumers persist them. + adding/removing fields — the consumer persists them. """ friendly_name: str | None @@ -87,11 +85,9 @@ class DashboardBrowser(AsyncServiceBrowser): class DashboardImportDiscovery: """Track importable devices announcing on ``_esphomelib._tcp.local.``. - Used by: - - esphome.dashboard (legacy dashboard) - - device-builder (esphome/device-builder) — wired up alongside - the dashboard's own ``ServiceBrowser`` to populate the - "Discovered devices" panel and the adoption flow. + Used by device-builder (esphome/device-builder), which wires it up + alongside its own ``ServiceBrowser`` to populate the + "Discovered devices" panel and the adoption flow. The class maintains ``import_state: dict[str, DiscoveredImport]`` keyed by the mDNS service name. ``on_update`` is invoked with @@ -262,11 +258,9 @@ async def async_resolve_hosts( class AsyncEsphomeZeroconf(AsyncZeroconf): """ESPHome-tuned ``AsyncZeroconf`` with a hostname-resolve helper. - Used by: - - esphome.dashboard (legacy dashboard) - - device-builder (esphome/device-builder) — drives both the live - mDNS browser and the per-sweep ``async_resolve_host`` fallback - for non-API devices that don't broadcast esphomelib. + Used by device-builder (esphome/device-builder), which drives both the live + mDNS browser and the per-sweep ``async_resolve_host`` fallback + for non-API devices that don't broadcast esphomelib. Coordinate before adding required constructor args or changing the ``async_resolve_host`` signature — device-builder calls it diff --git a/requirements.txt b/requirements.txt index 06a383b00a..b01b2a4c6b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,15 +3,12 @@ voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 -icmplib==3.0.4 -tornado==6.5.7 tzlocal==5.4.3 # from time tzdata>=2026.2 # from time pyserial==3.5 platformio==6.1.19 esptool==5.3.0 click==8.3.3 -esphome-dashboard==20260425.0 aioesphomeapi==45.3.1 zeroconf==0.149.16 puremagic==1.30 diff --git a/script/ci-custom.py b/script/ci-custom.py index cbc54ce55d..6c5ad5bb69 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -259,14 +259,7 @@ def lint_executable_bit(fname: Path) -> str | None: return None -@lint_content_find_check( - "\t", - only_first=True, - exclude=[ - "esphome/dashboard/static/ace.js", - "esphome/dashboard/static/ext-searchbox.js", - ], -) +@lint_content_find_check("\t", only_first=True) def lint_tabs(fname, line, col, content): return "File contains tab character. Please convert tabs to spaces." diff --git a/tests/dashboard/__init__.py b/tests/dashboard/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/dashboard/common.py b/tests/dashboard/common.py deleted file mode 100644 index f84c03aad8..0000000000 --- a/tests/dashboard/common.py +++ /dev/null @@ -1,6 +0,0 @@ -import pathlib - - -def get_fixture_path(filename: str) -> pathlib.Path: - """Get path of fixture.""" - return pathlib.Path(__file__).parent.joinpath("fixtures", filename) diff --git a/tests/dashboard/conftest.py b/tests/dashboard/conftest.py deleted file mode 100644 index f95adef749..0000000000 --- a/tests/dashboard/conftest.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Common fixtures for dashboard tests.""" - -from __future__ import annotations - -from pathlib import Path -from unittest.mock import MagicMock, Mock - -import pytest -import pytest_asyncio - -from esphome.dashboard.core import ESPHomeDashboard -from esphome.dashboard.entries import DashboardEntries - - -@pytest.fixture -def mock_settings(tmp_path: Path) -> MagicMock: - """Create mock dashboard settings.""" - settings = MagicMock() - settings.config_dir = str(tmp_path) - settings.absolute_config_dir = tmp_path - return settings - - -@pytest.fixture -def mock_dashboard(mock_settings: MagicMock) -> Mock: - """Create a mock dashboard.""" - dashboard = Mock(spec=ESPHomeDashboard) - dashboard.settings = mock_settings - dashboard.entries = Mock() - dashboard.entries.async_all.return_value = [] - dashboard.stop_event = Mock() - dashboard.stop_event.is_set.return_value = True - dashboard.ping_request = Mock() - dashboard.ignored_devices = set() - dashboard.bus = Mock() - dashboard.bus.async_fire = Mock() - return dashboard - - -@pytest_asyncio.fixture -async def dashboard_entries(mock_dashboard: Mock) -> DashboardEntries: - """Create a DashboardEntries instance for testing.""" - return DashboardEntries(mock_dashboard) diff --git a/tests/dashboard/fixtures/conf/pico.yaml b/tests/dashboard/fixtures/conf/pico.yaml deleted file mode 100644 index cf5b5b75bf..0000000000 --- a/tests/dashboard/fixtures/conf/pico.yaml +++ /dev/null @@ -1,47 +0,0 @@ -substitutions: - name: picoproxy - friendly_name: Pico Proxy - -esphome: - name: ${name} - friendly_name: ${friendly_name} - project: - name: esphome.bluetooth-proxy - version: "1.0" - -esp32: - board: esp32dev - framework: - type: esp-idf - -wifi: - ap: - -api: -logger: -ota: -improv_serial: - -dashboard_import: - package_import_url: github://esphome/firmware/bluetooth-proxy/esp32-generic.yaml@main - -button: - - platform: factory_reset - id: resetf - - platform: safe_mode - name: Safe Mode Boot - entity_category: diagnostic - -sensor: - - platform: template - id: pm11 - name: "pm 1.0µm" - lambda: return 1.0; - - platform: template - id: pm251 - name: "pm 2.5µm" - lambda: return 2.5; - - platform: template - id: pm101 - name: "pm 10µm" - lambda: return 10; diff --git a/tests/dashboard/status/__init__.py b/tests/dashboard/status/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/dashboard/status/test_dns.py b/tests/dashboard/status/test_dns.py deleted file mode 100644 index f7c4992079..0000000000 --- a/tests/dashboard/status/test_dns.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Unit tests for esphome.dashboard.dns module.""" - -from __future__ import annotations - -import time -from unittest.mock import AsyncMock, patch - -from icmplib import NameLookupError -import pytest - -from esphome.dashboard.dns import DNSCache, _async_resolve_wrapper - - -@pytest.fixture -def dns_cache_fixture() -> DNSCache: - """Create a DNSCache instance.""" - return DNSCache() - - -def test_get_cached_addresses_not_in_cache(dns_cache_fixture: DNSCache) -> None: - """Test get_cached_addresses when hostname is not in cache.""" - now = time.monotonic() - result = dns_cache_fixture.get_cached_addresses("unknown.example.com", now) - assert result is None - - -def test_get_cached_addresses_expired(dns_cache_fixture: DNSCache) -> None: - """Test get_cached_addresses when cache entry is expired.""" - now = time.monotonic() - # Add entry that's already expired - dns_cache_fixture._cache["example.com"] = (now - 1, ["192.168.1.10"]) - - result = dns_cache_fixture.get_cached_addresses("example.com", now) - assert result is None - # Expired entry should still be in cache (not removed by get_cached_addresses) - assert "example.com" in dns_cache_fixture._cache - - -def test_get_cached_addresses_valid(dns_cache_fixture: DNSCache) -> None: - """Test get_cached_addresses with valid cache entry.""" - now = time.monotonic() - # Add entry that expires in 60 seconds - dns_cache_fixture._cache["example.com"] = ( - now + 60, - ["192.168.1.10", "192.168.1.11"], - ) - - result = dns_cache_fixture.get_cached_addresses("example.com", now) - assert result == ["192.168.1.10", "192.168.1.11"] - # Entry should still be in cache - assert "example.com" in dns_cache_fixture._cache - - -def test_get_cached_addresses_hostname_normalization( - dns_cache_fixture: DNSCache, -) -> None: - """Test get_cached_addresses normalizes hostname.""" - now = time.monotonic() - # Add entry with lowercase hostname - dns_cache_fixture._cache["example.com"] = (now + 60, ["192.168.1.10"]) - - # Test with various forms - assert dns_cache_fixture.get_cached_addresses("EXAMPLE.COM", now) == [ - "192.168.1.10" - ] - assert dns_cache_fixture.get_cached_addresses("example.com.", now) == [ - "192.168.1.10" - ] - assert dns_cache_fixture.get_cached_addresses("EXAMPLE.COM.", now) == [ - "192.168.1.10" - ] - - -def test_get_cached_addresses_ipv6(dns_cache_fixture: DNSCache) -> None: - """Test get_cached_addresses with IPv6 addresses.""" - now = time.monotonic() - dns_cache_fixture._cache["example.com"] = (now + 60, ["2001:db8::1", "fe80::1"]) - - result = dns_cache_fixture.get_cached_addresses("example.com", now) - assert result == ["2001:db8::1", "fe80::1"] - - -def test_get_cached_addresses_empty_list(dns_cache_fixture: DNSCache) -> None: - """Test get_cached_addresses with empty address list.""" - now = time.monotonic() - dns_cache_fixture._cache["example.com"] = (now + 60, []) - - result = dns_cache_fixture.get_cached_addresses("example.com", now) - assert result == [] - - -def test_get_cached_addresses_exception_in_cache(dns_cache_fixture: DNSCache) -> None: - """Test get_cached_addresses when cache contains an exception.""" - now = time.monotonic() - # Store an exception (from failed resolution) - dns_cache_fixture._cache["example.com"] = (now + 60, OSError("Resolution failed")) - - result = dns_cache_fixture.get_cached_addresses("example.com", now) - assert result is None # Should return None for exceptions - - -def test_async_resolve_not_called(dns_cache_fixture: DNSCache) -> None: - """Test that get_cached_addresses never calls async_resolve.""" - now = time.monotonic() - - with patch.object(dns_cache_fixture, "async_resolve") as mock_resolve: - # Test non-cached - result = dns_cache_fixture.get_cached_addresses("uncached.com", now) - assert result is None - mock_resolve.assert_not_called() - - # Test expired - dns_cache_fixture._cache["expired.com"] = (now - 1, ["192.168.1.10"]) - result = dns_cache_fixture.get_cached_addresses("expired.com", now) - assert result is None - mock_resolve.assert_not_called() - - # Test valid - dns_cache_fixture._cache["valid.com"] = (now + 60, ["192.168.1.10"]) - result = dns_cache_fixture.get_cached_addresses("valid.com", now) - assert result == ["192.168.1.10"] - mock_resolve.assert_not_called() - - -@pytest.mark.asyncio -async def test_async_resolve_wrapper_ip_address() -> None: - """Test _async_resolve_wrapper returns IP address directly.""" - result = await _async_resolve_wrapper("192.168.1.10") - assert result == ["192.168.1.10"] - - result = await _async_resolve_wrapper("2001:db8::1") - assert result == ["2001:db8::1"] - - -@pytest.mark.asyncio -async def test_async_resolve_wrapper_local_fallback_success() -> None: - """Test _async_resolve_wrapper falls back to bare hostname for .local.""" - mock_resolve = AsyncMock() - # First call (device.local) fails, second call (device) succeeds - mock_resolve.side_effect = [ - NameLookupError("device.local"), - ["192.168.1.50"], - ] - - with patch("esphome.dashboard.dns.async_resolve", mock_resolve): - result = await _async_resolve_wrapper("device.local") - - assert result == ["192.168.1.50"] - assert mock_resolve.call_count == 2 - mock_resolve.assert_any_call("device.local") - mock_resolve.assert_any_call("device") - - -@pytest.mark.asyncio -async def test_async_resolve_wrapper_local_fallback_both_fail() -> None: - """Test _async_resolve_wrapper returns exception when both fail.""" - mock_resolve = AsyncMock() - original_exception = NameLookupError("device.local") - mock_resolve.side_effect = [ - original_exception, - NameLookupError("device"), - ] - - with patch("esphome.dashboard.dns.async_resolve", mock_resolve): - result = await _async_resolve_wrapper("device.local") - - # Should return the original exception, not the fallback exception - assert result is original_exception - assert mock_resolve.call_count == 2 - - -@pytest.mark.asyncio -async def test_async_resolve_wrapper_non_local_no_fallback() -> None: - """Test _async_resolve_wrapper doesn't fallback for non-.local hostnames.""" - mock_resolve = AsyncMock() - original_exception = NameLookupError("device.example.com") - mock_resolve.side_effect = original_exception - - with patch("esphome.dashboard.dns.async_resolve", mock_resolve): - result = await _async_resolve_wrapper("device.example.com") - - assert result is original_exception - # Should only try the original hostname, no fallback - assert mock_resolve.call_count == 1 - mock_resolve.assert_called_once_with("device.example.com") - - -@pytest.mark.asyncio -async def test_async_resolve_wrapper_local_success_no_fallback() -> None: - """Test _async_resolve_wrapper doesn't fallback when .local succeeds.""" - mock_resolve = AsyncMock(return_value=["192.168.1.50"]) - - with patch("esphome.dashboard.dns.async_resolve", mock_resolve): - result = await _async_resolve_wrapper("device.local") - - assert result == ["192.168.1.50"] - # Should only try once since it succeeded - assert mock_resolve.call_count == 1 - mock_resolve.assert_called_once_with("device.local") diff --git a/tests/dashboard/status/test_mdns.py b/tests/dashboard/status/test_mdns.py deleted file mode 100644 index 56c6d254cf..0000000000 --- a/tests/dashboard/status/test_mdns.py +++ /dev/null @@ -1,240 +0,0 @@ -"""Unit tests for esphome.dashboard.status.mdns module.""" - -from __future__ import annotations - -from unittest.mock import Mock, patch - -import pytest -import pytest_asyncio -from zeroconf import AddressResolver, IPVersion - -from esphome.dashboard.const import DashboardEvent -from esphome.dashboard.status.mdns import MDNSStatus -from esphome.zeroconf import DiscoveredImport - - -@pytest_asyncio.fixture -async def mdns_status(mock_dashboard: Mock) -> MDNSStatus: - """Create an MDNSStatus instance in async context.""" - # We're in an async context so get_running_loop will work - return MDNSStatus(mock_dashboard) - - -@pytest.mark.asyncio -async def test_get_cached_addresses_no_zeroconf(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses when no zeroconf instance is available.""" - mdns_status.aiozc = None - result = mdns_status.get_cached_addresses("device.local") - assert result is None - - -@pytest.mark.asyncio -async def test_get_cached_addresses_not_in_cache(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses when address is not in cache.""" - mdns_status.aiozc = Mock() - mdns_status.aiozc.zeroconf = Mock() - - with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: - mock_info = Mock(spec=AddressResolver) - mock_info.load_from_cache.return_value = False - mock_resolver.return_value = mock_info - - result = mdns_status.get_cached_addresses("device.local") - assert result is None - mock_info.load_from_cache.assert_called_once_with(mdns_status.aiozc.zeroconf) - - -@pytest.mark.asyncio -async def test_get_cached_addresses_found_in_cache(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses when address is found in cache.""" - mdns_status.aiozc = Mock() - mdns_status.aiozc.zeroconf = Mock() - - with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: - mock_info = Mock(spec=AddressResolver) - mock_info.load_from_cache.return_value = True - mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10", "fe80::1"] - mock_resolver.return_value = mock_info - - result = mdns_status.get_cached_addresses("device.local") - assert result == ["192.168.1.10", "fe80::1"] - mock_info.load_from_cache.assert_called_once_with(mdns_status.aiozc.zeroconf) - mock_info.parsed_scoped_addresses.assert_called_once_with(IPVersion.All) - - -@pytest.mark.asyncio -async def test_get_cached_addresses_with_trailing_dot(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses with hostname having trailing dot.""" - mdns_status.aiozc = Mock() - mdns_status.aiozc.zeroconf = Mock() - - with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: - mock_info = Mock(spec=AddressResolver) - mock_info.load_from_cache.return_value = True - mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"] - mock_resolver.return_value = mock_info - - result = mdns_status.get_cached_addresses("device.local.") - assert result == ["192.168.1.10"] - # Should normalize to device.local. for zeroconf - mock_resolver.assert_called_once_with("device.local.") - - -@pytest.mark.asyncio -async def test_get_cached_addresses_uppercase_hostname(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses with uppercase hostname.""" - mdns_status.aiozc = Mock() - mdns_status.aiozc.zeroconf = Mock() - - with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: - mock_info = Mock(spec=AddressResolver) - mock_info.load_from_cache.return_value = True - mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"] - mock_resolver.return_value = mock_info - - result = mdns_status.get_cached_addresses("DEVICE.LOCAL") - assert result == ["192.168.1.10"] - # Should normalize to device.local. for zeroconf - mock_resolver.assert_called_once_with("device.local.") - - -@pytest.mark.asyncio -async def test_get_cached_addresses_simple_hostname(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses with simple hostname (no domain).""" - mdns_status.aiozc = Mock() - mdns_status.aiozc.zeroconf = Mock() - - with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: - mock_info = Mock(spec=AddressResolver) - mock_info.load_from_cache.return_value = True - mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"] - mock_resolver.return_value = mock_info - - result = mdns_status.get_cached_addresses("device") - assert result == ["192.168.1.10"] - # Should append .local. for zeroconf - mock_resolver.assert_called_once_with("device.local.") - - -@pytest.mark.asyncio -async def test_get_cached_addresses_ipv6_only(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses returning only IPv6 addresses.""" - mdns_status.aiozc = Mock() - mdns_status.aiozc.zeroconf = Mock() - - with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: - mock_info = Mock(spec=AddressResolver) - mock_info.load_from_cache.return_value = True - mock_info.parsed_scoped_addresses.return_value = ["fe80::1", "2001:db8::1"] - mock_resolver.return_value = mock_info - - result = mdns_status.get_cached_addresses("device.local") - assert result == ["fe80::1", "2001:db8::1"] - - -@pytest.mark.asyncio -async def test_get_cached_addresses_empty_list(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses returning empty list from cache.""" - mdns_status.aiozc = Mock() - mdns_status.aiozc.zeroconf = Mock() - - with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: - mock_info = Mock(spec=AddressResolver) - mock_info.load_from_cache.return_value = True - mock_info.parsed_scoped_addresses.return_value = [] - mock_resolver.return_value = mock_info - - result = mdns_status.get_cached_addresses("device.local") - assert result == [] - - -@pytest.mark.asyncio -async def test_async_setup_success(mock_dashboard: Mock) -> None: - """Test successful async_setup.""" - mdns_status = MDNSStatus(mock_dashboard) - with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc: - mock_zc.return_value = Mock() - result = mdns_status.async_setup() - assert result is True - assert mdns_status.aiozc is not None - - -@pytest.mark.asyncio -async def test_async_setup_failure(mock_dashboard: Mock) -> None: - """Test async_setup with OSError.""" - mdns_status = MDNSStatus(mock_dashboard) - with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc: - mock_zc.side_effect = OSError("Network error") - result = mdns_status.async_setup() - assert result is False - assert mdns_status.aiozc is None - - -@pytest.mark.asyncio -async def test_on_import_update_device_added(mdns_status: MDNSStatus) -> None: - """Test _on_import_update when a device is added.""" - # Create a DiscoveredImport object - discovered = DiscoveredImport( - device_name="test_device", - friendly_name="Test Device", - package_import_url="https://example.com/package", - project_name="test_project", - project_version="1.0.0", - network="wifi", - ) - - # Call _on_import_update with a device - mdns_status._on_import_update("test_device", discovered) - - # Should fire IMPORTABLE_DEVICE_ADDED event - mock_dashboard = mdns_status.dashboard - mock_dashboard.bus.async_fire.assert_called_once() - call_args = mock_dashboard.bus.async_fire.call_args - assert call_args[0][0] == DashboardEvent.IMPORTABLE_DEVICE_ADDED - assert "device" in call_args[0][1] - device_data = call_args[0][1]["device"] - assert device_data["name"] == "test_device" - assert device_data["friendly_name"] == "Test Device" - assert device_data["project_name"] == "test_project" - assert device_data["ignored"] is False - - -@pytest.mark.asyncio -async def test_on_import_update_device_ignored(mdns_status: MDNSStatus) -> None: - """Test _on_import_update when a device is ignored.""" - # Add device to ignored list - mdns_status.dashboard.ignored_devices.add("ignored_device") - - # Create a DiscoveredImport object for ignored device - discovered = DiscoveredImport( - device_name="ignored_device", - friendly_name="Ignored Device", - package_import_url="https://example.com/package", - project_name="test_project", - project_version="1.0.0", - network="ethernet", - ) - - # Call _on_import_update with an ignored device - mdns_status._on_import_update("ignored_device", discovered) - - # Should fire IMPORTABLE_DEVICE_ADDED event with ignored=True - mock_dashboard = mdns_status.dashboard - mock_dashboard.bus.async_fire.assert_called_once() - call_args = mock_dashboard.bus.async_fire.call_args - assert call_args[0][0] == DashboardEvent.IMPORTABLE_DEVICE_ADDED - device_data = call_args[0][1]["device"] - assert device_data["name"] == "ignored_device" - assert device_data["ignored"] is True - - -@pytest.mark.asyncio -async def test_on_import_update_device_removed(mdns_status: MDNSStatus) -> None: - """Test _on_import_update when a device is removed.""" - # Call _on_import_update with None (device removed) - mdns_status._on_import_update("removed_device", None) - - # Should fire IMPORTABLE_DEVICE_REMOVED event - mdns_status.dashboard.bus.async_fire.assert_called_once_with( - DashboardEvent.IMPORTABLE_DEVICE_REMOVED, {"name": "removed_device"} - ) diff --git a/tests/dashboard/test_entries.py b/tests/dashboard/test_entries.py deleted file mode 100644 index 9a3a776b28..0000000000 --- a/tests/dashboard/test_entries.py +++ /dev/null @@ -1,288 +0,0 @@ -"""Tests for dashboard entries Path-related functionality.""" - -from __future__ import annotations - -import os -from pathlib import Path -import tempfile -from unittest.mock import Mock - -import pytest - -from esphome.core import CORE -from esphome.dashboard.const import DashboardEvent -from esphome.dashboard.entries import DashboardEntries, DashboardEntry - - -def create_cache_key() -> tuple[int, int, float, int]: - """Helper to create a valid DashboardCacheKeyType.""" - return (0, 0, 0.0, 0) - - -@pytest.fixture(autouse=True) -def setup_core(): - """Set up CORE for testing.""" - with tempfile.TemporaryDirectory() as tmpdir: - CORE.config_path = Path(tmpdir) / "test.yaml" - yield - CORE.reset() - - -def test_dashboard_entry_path_initialization() -> None: - """Test DashboardEntry initializes with path correctly.""" - test_path = Path("/test/config/device.yaml") - cache_key = create_cache_key() - - entry = DashboardEntry(test_path, cache_key) - - assert entry.path == test_path - assert entry.cache_key == cache_key - - -def test_dashboard_entry_path_with_absolute_path() -> None: - """Test DashboardEntry handles absolute paths.""" - # Use a truly absolute path for the platform - test_path = Path.cwd() / "absolute" / "path" / "to" / "config.yaml" - cache_key = create_cache_key() - - entry = DashboardEntry(test_path, cache_key) - - assert entry.path == test_path - assert entry.path.is_absolute() - - -def test_dashboard_entry_path_with_relative_path() -> None: - """Test DashboardEntry handles relative paths.""" - test_path = Path("configs/device.yaml") - cache_key = create_cache_key() - - entry = DashboardEntry(test_path, cache_key) - - assert entry.path == test_path - assert not entry.path.is_absolute() - - -@pytest.mark.asyncio -async def test_dashboard_entries_get_by_path( - dashboard_entries: DashboardEntries, tmp_path: Path -) -> None: - """Test getting entry by path.""" - # Create a test file - test_file = tmp_path / "device.yaml" - test_file.write_text("test config") - - # Update entries to load the file - await dashboard_entries.async_update_entries() - - # Verify the entry was loaded - all_entries = dashboard_entries.async_all() - assert len(all_entries) == 1 - entry = all_entries[0] - assert entry.path == test_file - - # Also verify get() works with Path - result = dashboard_entries.get(test_file) - assert result == entry - - -@pytest.mark.asyncio -async def test_dashboard_entries_get_nonexistent_path( - dashboard_entries: DashboardEntries, -) -> None: - """Test getting non-existent entry returns None.""" - result = dashboard_entries.get("/nonexistent/path.yaml") - assert result is None - - -@pytest.mark.asyncio -async def test_dashboard_entries_path_normalization( - dashboard_entries: DashboardEntries, tmp_path: Path -) -> None: - """Test that paths are handled consistently.""" - # Create a test file - test_file = tmp_path / "device.yaml" - test_file.write_text("test config") - - # Update entries to load the file - await dashboard_entries.async_update_entries() - - # Get the entry by path - result = dashboard_entries.get(test_file) - assert result is not None - - -@pytest.mark.asyncio -async def test_dashboard_entries_path_with_spaces( - dashboard_entries: DashboardEntries, tmp_path: Path -) -> None: - """Test handling paths with spaces.""" - # Create a test file with spaces in name - test_file = tmp_path / "my device.yaml" - test_file.write_text("test config") - - # Update entries to load the file - await dashboard_entries.async_update_entries() - - # Get the entry by path - result = dashboard_entries.get(test_file) - assert result is not None - assert result.path == test_file - - -@pytest.mark.asyncio -async def test_dashboard_entries_path_with_special_chars( - dashboard_entries: DashboardEntries, tmp_path: Path -) -> None: - """Test handling paths with special characters.""" - # Create a test file with special characters - test_file = tmp_path / "device-01_test.yaml" - test_file.write_text("test config") - - # Update entries to load the file - await dashboard_entries.async_update_entries() - - # Get the entry by path - result = dashboard_entries.get(test_file) - assert result is not None - - -def test_dashboard_entries_windows_path() -> None: - """Test handling Windows-style paths.""" - test_path = Path(r"C:\Users\test\esphome\device.yaml") - cache_key = create_cache_key() - - entry = DashboardEntry(test_path, cache_key) - - assert entry.path == test_path - - -@pytest.mark.asyncio -async def test_dashboard_entries_path_to_cache_key_mapping( - dashboard_entries: DashboardEntries, tmp_path: Path -) -> None: - """Test internal entries storage with paths and cache keys.""" - # Create test files - file1 = tmp_path / "device1.yaml" - file2 = tmp_path / "device2.yaml" - file1.write_text("test config 1") - file2.write_text("test config 2") - - # Update entries to load the files - await dashboard_entries.async_update_entries() - - # Get entries and verify they have different cache keys - entry1 = dashboard_entries.get(file1) - entry2 = dashboard_entries.get(file2) - - assert entry1 is not None - assert entry2 is not None - assert entry1.cache_key != entry2.cache_key - - -def test_dashboard_entry_path_property() -> None: - """Test that path property returns expected value.""" - test_path = Path("/test/config/device.yaml") - entry = DashboardEntry(test_path, create_cache_key()) - - assert entry.path == test_path - assert isinstance(entry.path, Path) - - -@pytest.mark.asyncio -async def test_dashboard_entries_all_returns_entries_with_paths( - dashboard_entries: DashboardEntries, tmp_path: Path -) -> None: - """Test that all() returns entries with their paths intact.""" - # Create test files - files = [ - tmp_path / "device1.yaml", - tmp_path / "device2.yaml", - tmp_path / "device3.yaml", - ] - - for file in files: - file.write_text("test config") - - # Update entries to load the files - await dashboard_entries.async_update_entries() - - all_entries = dashboard_entries.async_all() - - assert len(all_entries) == len(files) - retrieved_paths = [entry.path for entry in all_entries] - assert set(retrieved_paths) == set(files) - - -@pytest.mark.asyncio -async def test_async_update_entries_removed_path( - dashboard_entries: DashboardEntries, mock_dashboard: Mock, tmp_path: Path -) -> None: - """Test that removed files trigger ENTRY_REMOVED event.""" - - # Create a test file - test_file = tmp_path / "device.yaml" - test_file.write_text("test config") - - # First update to add the entry - await dashboard_entries.async_update_entries() - - # Verify entry was added - all_entries = dashboard_entries.async_all() - assert len(all_entries) == 1 - entry = all_entries[0] - - # Delete the file - test_file.unlink() - - # Second update to detect removal - await dashboard_entries.async_update_entries() - - # Verify entry was removed - all_entries = dashboard_entries.async_all() - assert len(all_entries) == 0 - - # Verify ENTRY_REMOVED event was fired - mock_dashboard.bus.async_fire.assert_any_call( - DashboardEvent.ENTRY_REMOVED, {"entry": entry} - ) - - -@pytest.mark.asyncio -async def test_async_update_entries_updated_path( - dashboard_entries: DashboardEntries, mock_dashboard: Mock, tmp_path: Path -) -> None: - """Test that modified files trigger ENTRY_UPDATED event.""" - - # Create a test file - test_file = tmp_path / "device.yaml" - test_file.write_text("test config") - - # First update to add the entry - await dashboard_entries.async_update_entries() - - # Verify entry was added - all_entries = dashboard_entries.async_all() - assert len(all_entries) == 1 - entry = all_entries[0] - original_cache_key = entry.cache_key - - # Modify the file to change its mtime - test_file.write_text("updated config") - # Explicitly change the mtime to ensure it's different - stat = test_file.stat() - os.utime(test_file, (stat.st_atime, stat.st_mtime + 1)) - - # Second update to detect modification - await dashboard_entries.async_update_entries() - - # Verify entry is still there with updated cache key - all_entries = dashboard_entries.async_all() - assert len(all_entries) == 1 - updated_entry = all_entries[0] - assert updated_entry == entry # Same entry object - assert updated_entry.cache_key != original_cache_key # But cache key updated - - # Verify ENTRY_UPDATED event was fired - mock_dashboard.bus.async_fire.assert_any_call( - DashboardEvent.ENTRY_UPDATED, {"entry": entry} - ) diff --git a/tests/dashboard/test_settings.py b/tests/dashboard/test_settings.py deleted file mode 100644 index 55776ac7c4..0000000000 --- a/tests/dashboard/test_settings.py +++ /dev/null @@ -1,287 +0,0 @@ -"""Tests for DashboardSettings (path resolution and authentication).""" - -from __future__ import annotations - -from argparse import Namespace -from pathlib import Path -import tempfile - -import pytest - -from esphome.core import CORE -from esphome.dashboard.settings import DashboardSettings -from esphome.dashboard.util.password import password_hash - - -@pytest.fixture -def dashboard_settings(tmp_path: Path) -> DashboardSettings: - """Create DashboardSettings instance with temp directory.""" - settings = DashboardSettings() - # Resolve symlinks to ensure paths match - resolved_dir = tmp_path.resolve() - settings.config_dir = resolved_dir - settings.absolute_config_dir = resolved_dir - return settings - - -def test_rel_path_simple(dashboard_settings: DashboardSettings) -> None: - """Test rel_path with simple relative path.""" - result = dashboard_settings.rel_path("config.yaml") - - expected = dashboard_settings.config_dir / "config.yaml" - assert result == expected - - -def test_rel_path_multiple_components(dashboard_settings: DashboardSettings) -> None: - """Test rel_path with multiple path components.""" - result = dashboard_settings.rel_path("subfolder", "device", "config.yaml") - - expected = dashboard_settings.config_dir / "subfolder" / "device" / "config.yaml" - assert result == expected - - -def test_rel_path_with_dots(dashboard_settings: DashboardSettings) -> None: - """Test rel_path prevents directory traversal.""" - # This should raise ValueError as it tries to go outside config_dir - with pytest.raises(ValueError): - dashboard_settings.rel_path("..", "outside.yaml") - - -def test_rel_path_absolute_path_within_config( - dashboard_settings: DashboardSettings, -) -> None: - """Test rel_path with absolute path that's within config dir.""" - internal_path = dashboard_settings.absolute_config_dir / "internal.yaml" - - internal_path.touch() - result = dashboard_settings.rel_path("internal.yaml") - expected = dashboard_settings.config_dir / "internal.yaml" - assert result == expected - - -def test_rel_path_absolute_path_outside_config( - dashboard_settings: DashboardSettings, -) -> None: - """Test rel_path with absolute path outside config dir raises error.""" - outside_path = "/tmp/outside/config.yaml" - - with pytest.raises(ValueError): - dashboard_settings.rel_path(outside_path) - - -def test_rel_path_empty_args(dashboard_settings: DashboardSettings) -> None: - """Test rel_path with no arguments returns config_dir.""" - result = dashboard_settings.rel_path() - assert result == dashboard_settings.config_dir - - -def test_rel_path_with_pathlib_path(dashboard_settings: DashboardSettings) -> None: - """Test rel_path works with Path objects as arguments.""" - path_obj = Path("subfolder") / "config.yaml" - result = dashboard_settings.rel_path(path_obj) - - expected = dashboard_settings.config_dir / "subfolder" / "config.yaml" - assert result == expected - - -def test_rel_path_normalizes_slashes(dashboard_settings: DashboardSettings) -> None: - """Test rel_path normalizes path separators.""" - # os.path.join normalizes slashes on Windows but preserves them on Unix - # Test that providing components separately gives same result - result1 = dashboard_settings.rel_path("folder", "subfolder", "file.yaml") - result2 = dashboard_settings.rel_path("folder", "subfolder", "file.yaml") - assert result1 == result2 - - # Also test that the result is as expected - expected = dashboard_settings.config_dir / "folder" / "subfolder" / "file.yaml" - assert result1 == expected - - -def test_rel_path_handles_spaces(dashboard_settings: DashboardSettings) -> None: - """Test rel_path handles paths with spaces.""" - result = dashboard_settings.rel_path("my folder", "my config.yaml") - - expected = dashboard_settings.config_dir / "my folder" / "my config.yaml" - assert result == expected - - -def test_rel_path_handles_special_chars(dashboard_settings: DashboardSettings) -> None: - """Test rel_path handles paths with special characters.""" - result = dashboard_settings.rel_path("device-01_test", "config.yaml") - - expected = dashboard_settings.config_dir / "device-01_test" / "config.yaml" - assert result == expected - - -def test_config_dir_as_path_property(dashboard_settings: DashboardSettings) -> None: - """Test that config_dir can be accessed and used with Path operations.""" - config_path = dashboard_settings.config_dir - - assert config_path.exists() - assert config_path.is_dir() - assert config_path.is_absolute() - - -def test_absolute_config_dir_property(dashboard_settings: DashboardSettings) -> None: - """Test absolute_config_dir is a Path object.""" - assert isinstance(dashboard_settings.absolute_config_dir, Path) - assert dashboard_settings.absolute_config_dir.exists() - assert dashboard_settings.absolute_config_dir.is_dir() - assert dashboard_settings.absolute_config_dir.is_absolute() - - -def test_rel_path_symlink_inside_config(dashboard_settings: DashboardSettings) -> None: - """Test rel_path with symlink that points inside config dir.""" - target = dashboard_settings.absolute_config_dir / "target.yaml" - target.touch() - symlink = dashboard_settings.absolute_config_dir / "link.yaml" - symlink.symlink_to(target) - result = dashboard_settings.rel_path("link.yaml") - expected = dashboard_settings.config_dir / "link.yaml" - assert result == expected - - -def test_rel_path_symlink_outside_config(dashboard_settings: DashboardSettings) -> None: - """Test rel_path with symlink that points outside config dir.""" - with tempfile.NamedTemporaryFile(suffix=".yaml") as tmp: - symlink = dashboard_settings.absolute_config_dir / "external_link.yaml" - symlink.symlink_to(tmp.name) - with pytest.raises(ValueError): - dashboard_settings.rel_path("external_link.yaml") - - -def test_rel_path_with_none_arg(dashboard_settings: DashboardSettings) -> None: - """Test rel_path handles None arguments gracefully.""" - result = dashboard_settings.rel_path("None") - expected = dashboard_settings.config_dir / "None" - assert result == expected - - -def test_rel_path_with_numeric_args(dashboard_settings: DashboardSettings) -> None: - """Test rel_path handles numeric arguments.""" - result = dashboard_settings.rel_path("123", "456.789") - expected = dashboard_settings.config_dir / "123" / "456.789" - assert result == expected - - -def test_config_path_parent_resolves_to_config_dir(tmp_path: Path) -> None: - """Test that CORE.config_path.parent resolves to config_dir after parse_args. - - This is a regression test for issue #11280 where binary download failed - when using packages with secrets after the Path migration in 2025.10.0. - - The issue was that after switching from os.path to Path: - - Before: os.path.dirname("/config/.") → "/config" - - After: Path("/config/.").parent → Path("/") (normalized first!) - - The fix uses a sentinel file so .parent returns the correct directory: - - Fixed: Path("/config/___DASHBOARD_SENTINEL___.yaml").parent → Path("/config") - """ - # Create test directory structure with secrets and packages - config_dir = tmp_path / "config" - config_dir.mkdir() - - # Create secrets.yaml with obviously fake test values - secrets_file = config_dir / "secrets.yaml" - secrets_file.write_text( - "wifi_ssid: TEST-DUMMY-SSID\n" - "wifi_password: not-a-real-password-just-for-testing\n" - ) - - # Create package file that uses secrets - package_file = config_dir / "common.yaml" - package_file.write_text( - "wifi:\n ssid: !secret wifi_ssid\n password: !secret wifi_password\n" - ) - - # Create main device config that includes the package - device_config = config_dir / "test-device.yaml" - device_config.write_text( - "esphome:\n name: test-device\n\npackages:\n common: !include common.yaml\n" - ) - - # Set up dashboard settings with our test config directory - settings = DashboardSettings() - args = Namespace( - configuration=str(config_dir), - password=None, - username=None, - ha_addon=False, - verbose=False, - ) - settings.parse_args(args) - - # Verify that CORE.config_path.parent correctly points to the config directory - # This is critical for secret resolution in yaml_util.py which does: - # main_config_dir = CORE.config_path.parent - # main_secret_yml = main_config_dir / "secrets.yaml" - assert CORE.config_path.parent == config_dir.resolve() - assert (CORE.config_path.parent / "secrets.yaml").exists() - assert (CORE.config_path.parent / "common.yaml").exists() - - # Verify that CORE.config_path itself uses the sentinel file - assert CORE.config_path.name == "___DASHBOARD_SENTINEL___.yaml" - assert not CORE.config_path.exists() # Sentinel file doesn't actually exist - - -@pytest.fixture -def auth_settings(dashboard_settings: DashboardSettings) -> DashboardSettings: - """Create DashboardSettings with auth configured, based on dashboard_settings.""" - dashboard_settings.username = "admin" - dashboard_settings.using_password = True - dashboard_settings.password_hash = password_hash("correctpassword") - return dashboard_settings - - -def test_check_password_correct_credentials(auth_settings: DashboardSettings) -> None: - """Test check_password returns True for correct username and password.""" - assert auth_settings.check_password("admin", "correctpassword") is True - - -def test_check_password_wrong_password(auth_settings: DashboardSettings) -> None: - """Test check_password returns False for wrong password.""" - assert auth_settings.check_password("admin", "wrongpassword") is False - - -def test_check_password_wrong_username(auth_settings: DashboardSettings) -> None: - """Test check_password returns False for wrong username.""" - assert auth_settings.check_password("notadmin", "correctpassword") is False - - -def test_check_password_both_wrong(auth_settings: DashboardSettings) -> None: - """Test check_password returns False when both are wrong.""" - assert auth_settings.check_password("notadmin", "wrongpassword") is False - - -def test_check_password_no_auth(dashboard_settings: DashboardSettings) -> None: - """Test check_password returns True when auth is not configured.""" - assert dashboard_settings.check_password("anyone", "anything") is True - - -def test_check_password_non_ascii_username( - dashboard_settings: DashboardSettings, -) -> None: - """Test check_password handles non-ASCII usernames without TypeError.""" - dashboard_settings.username = "\u00e9l\u00e8ve" - dashboard_settings.using_password = True - dashboard_settings.password_hash = password_hash("pass") - assert dashboard_settings.check_password("\u00e9l\u00e8ve", "pass") is True - assert dashboard_settings.check_password("\u00e9l\u00e8ve", "wrong") is False - assert dashboard_settings.check_password("other", "pass") is False - - -def test_check_password_ha_addon_no_password( - dashboard_settings: DashboardSettings, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Test check_password doesn't crash in HA add-on mode without a password. - - In HA add-on mode, using_ha_addon_auth can be True while using_password - is False, leaving password_hash as b"". This must not raise TypeError - in hmac.compare_digest. - """ - monkeypatch.delenv("DISABLE_HA_AUTHENTICATION", raising=False) - dashboard_settings.on_ha_addon = True - dashboard_settings.using_password = False - # password_hash stays as default b"" - assert dashboard_settings.check_password("anyone", "anything") is False diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py deleted file mode 100644 index 0ee841e68c..0000000000 --- a/tests/dashboard/test_web_server.py +++ /dev/null @@ -1,1889 +0,0 @@ -from __future__ import annotations - -from argparse import Namespace -import asyncio -import base64 -from collections.abc import Generator -from contextlib import asynccontextmanager -import gzip -import json -import os -from pathlib import Path -import sys -from unittest.mock import AsyncMock, MagicMock, Mock, patch - -import pytest -import pytest_asyncio -from tornado.httpclient import AsyncHTTPClient, HTTPClientError, HTTPResponse -from tornado.httpserver import HTTPServer -from tornado.ioloop import IOLoop -from tornado.testing import bind_unused_port -from tornado.websocket import WebSocketClientConnection, websocket_connect - -from esphome import yaml_util -from esphome.core import CORE -from esphome.dashboard import web_server -from esphome.dashboard.const import DashboardEvent -from esphome.dashboard.core import DASHBOARD -from esphome.dashboard.entries import ( - DashboardEntry, - EntryStateSource, - bool_to_entry_state, -) -from esphome.dashboard.models import build_importable_device_dict -from esphome.dashboard.web_server import DashboardSubscriber, EsphomeCommandWebSocket -from esphome.zeroconf import DiscoveredImport - -from .common import get_fixture_path - - -def get_build_path(base_path: Path, device_name: str) -> Path: - """Get the build directory path for a device. - - This is a test helper that constructs the standard ESPHome build directory - structure. Note: This helper does NOT perform path traversal sanitization - because it's only used in tests where we control the inputs. The actual - web_server.py code handles sanitization in DownloadBinaryRequestHandler.get() - via file_name.replace("..", "").lstrip("/"). - - Args: - base_path: The base temporary path (typically tmp_path from pytest) - device_name: The name of the device (should not contain path separators - in production use, but tests may use it for specific scenarios) - - Returns: - Path to the build directory (.esphome/build/device_name) - """ - return base_path / ".esphome" / "build" / device_name - - -class DashboardTestHelper: - def __init__(self, io_loop: IOLoop, client: AsyncHTTPClient, port: int) -> None: - self.io_loop = io_loop - self.client = client - self.port = port - - async def fetch(self, path: str, **kwargs) -> HTTPResponse: - """Get a response for the given path.""" - if path.lower().startswith(("http://", "https://")): - url = path - else: - url = f"http://127.0.0.1:{self.port}{path}" - future = self.client.fetch(url, raise_error=True, **kwargs) - return await future - - -@pytest.fixture -def mock_async_run_system_command() -> Generator[MagicMock]: - """Fixture to mock async_run_system_command.""" - with patch("esphome.dashboard.web_server.async_run_system_command") as mock: - yield mock - - -@pytest.fixture -def mock_trash_storage_path(tmp_path: Path) -> Generator[MagicMock]: - """Fixture to mock trash_storage_path.""" - trash_dir = tmp_path / "trash" - with patch( - "esphome.dashboard.web_server.trash_storage_path", return_value=trash_dir - ) as mock: - yield mock - - -@pytest.fixture -def mock_archive_storage_path(tmp_path: Path) -> Generator[MagicMock]: - """Fixture to mock archive_storage_path.""" - archive_dir = tmp_path / "archive" - with patch( - "esphome.dashboard.web_server.archive_storage_path", - return_value=archive_dir, - ) as mock: - yield mock - - -@pytest.fixture -def mock_dashboard_settings() -> Generator[MagicMock]: - """Fixture to mock dashboard settings.""" - with patch("esphome.dashboard.web_server.settings") as mock_settings: - # Set default auth settings to avoid authentication issues - mock_settings.using_auth = False - mock_settings.on_ha_addon = False - yield mock_settings - - -@pytest.fixture -def mock_ext_storage_path(tmp_path: Path) -> Generator[MagicMock]: - """Fixture to mock ext_storage_path.""" - with patch("esphome.dashboard.web_server.ext_storage_path") as mock: - mock.return_value = str(tmp_path / "storage.json") - yield mock - - -@pytest.fixture -def mock_storage_json() -> Generator[MagicMock]: - """Fixture to mock StorageJSON.""" - with patch("esphome.dashboard.web_server.StorageJSON") as mock: - yield mock - - -@pytest.fixture -def mock_idedata() -> Generator[MagicMock]: - """Fixture to mock platformio toolchain.IDEData.""" - with patch("esphome.dashboard.web_server.toolchain.IDEData") as mock: - yield mock - - -@pytest_asyncio.fixture() -async def dashboard() -> DashboardTestHelper: - sock, port = bind_unused_port() - args = Mock( - ha_addon=True, - configuration=get_fixture_path("conf"), - port=port, - ) - DASHBOARD.settings.parse_args(args) - app = web_server.make_app() - http_server = HTTPServer(app) - http_server.add_sockets([sock]) - await DASHBOARD.async_setup() - os.environ["DISABLE_HA_AUTHENTICATION"] = "1" - assert DASHBOARD.settings.using_password is False - assert DASHBOARD.settings.on_ha_addon is True - assert DASHBOARD.settings.using_auth is False - task = asyncio.create_task(DASHBOARD.async_run()) - # Wait for initial device loading to complete - await DASHBOARD.entries.async_request_update_entries() - client = AsyncHTTPClient() - io_loop = IOLoop(make_current=False) - yield DashboardTestHelper(io_loop, client, port) - task.cancel() - sock.close() - client.close() - io_loop.close() - - -@asynccontextmanager -async def websocket_connection(dashboard: DashboardTestHelper): - """Async context manager for WebSocket connections.""" - url = f"ws://127.0.0.1:{dashboard.port}/events" - ws = await websocket_connect(url) - try: - yield ws - finally: - if ws: - ws.close() - - -@pytest_asyncio.fixture -async def websocket_client(dashboard: DashboardTestHelper) -> WebSocketClientConnection: - """Create a WebSocket connection for testing.""" - url = f"ws://127.0.0.1:{dashboard.port}/events" - ws = await websocket_connect(url) - - # Read and discard initial state message - await ws.read_message() - - yield ws - - if ws: - ws.close() - - -@pytest.mark.asyncio -async def test_main_page(dashboard: DashboardTestHelper) -> None: - response = await dashboard.fetch("/") - assert response.code == 200 - - -@pytest.mark.asyncio -async def test_devices_page(dashboard: DashboardTestHelper) -> None: - response = await dashboard.fetch("/devices") - assert response.code == 200 - assert response.headers["content-type"] == "application/json" - json_data = json.loads(response.body.decode()) - configured_devices = json_data["configured"] - assert len(configured_devices) != 0 - first_device = configured_devices[0] - assert first_device["name"] == "pico" - assert first_device["configuration"] == "pico.yaml" - - -@pytest.mark.asyncio -async def test_wizard_handler_invalid_input(dashboard: DashboardTestHelper) -> None: - """Test the WizardRequestHandler.post method with invalid inputs.""" - # Test with missing name (should fail with 422) - body_no_name = json.dumps( - { - "name": "", # Empty name - "platform": "ESP32", - "board": "esp32dev", - } - ) - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/wizard", - method="POST", - body=body_no_name, - headers={"Content-Type": "application/json"}, - ) - assert exc_info.value.code == 422 - - # Test with invalid wizard type (should fail with 422) - body_invalid_type = json.dumps( - { - "name": "test_device", - "type": "invalid_type", - "platform": "ESP32", - "board": "esp32dev", - } - ) - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/wizard", - method="POST", - body=body_invalid_type, - headers={"Content-Type": "application/json"}, - ) - assert exc_info.value.code == 422 - - -@pytest.mark.asyncio -async def test_wizard_handler_conflict(dashboard: DashboardTestHelper) -> None: - """Test the WizardRequestHandler.post when config already exists.""" - # Try to create a wizard for existing pico.yaml (should conflict) - body = json.dumps( - { - "name": "pico", # This already exists in fixtures - "platform": "ESP32", - "board": "esp32dev", - } - ) - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/wizard", - method="POST", - body=body, - headers={"Content-Type": "application/json"}, - ) - assert exc_info.value.code == 409 - - -@pytest.mark.asyncio -async def test_download_binary_handler_not_found( - dashboard: DashboardTestHelper, -) -> None: - """Test the DownloadBinaryRequestHandler.get with non-existent config.""" - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/download.bin?configuration=nonexistent.yaml", - method="GET", - ) - assert exc_info.value.code == 404 - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_no_file_param( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, -) -> None: - """Test the DownloadBinaryRequestHandler.get without file parameter.""" - # Mock storage to exist, but still should fail without file param - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = str(tmp_path / "firmware.bin") - mock_storage_json.load.return_value = mock_storage - - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/download.bin?configuration=pico.yaml", - method="GET", - ) - assert exc_info.value.code == 400 - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_with_file( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, -) -> None: - """Test the DownloadBinaryRequestHandler.get with existing binary file.""" - # Create a fake binary file - build_dir = tmp_path / ".esphome" / "build" / "test" - build_dir.mkdir(parents=True) - firmware_file = build_dir / "firmware.bin" - firmware_file.write_bytes(b"fake firmware content") - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - response = await dashboard.fetch( - "/download.bin?configuration=test.yaml&file=firmware.bin", - method="GET", - ) - assert response.code == 200 - assert response.body == b"fake firmware content" - assert response.headers["Content-Type"] == "application/octet-stream" - assert "attachment" in response.headers["Content-Disposition"] - assert "test_device-firmware.bin" in response.headers["Content-Disposition"] - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_compressed( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, -) -> None: - """Test the DownloadBinaryRequestHandler.get with compression.""" - # Create a fake binary file - build_dir = tmp_path / ".esphome" / "build" / "test" - build_dir.mkdir(parents=True) - firmware_file = build_dir / "firmware.bin" - original_content = b"fake firmware content for compression test" - firmware_file.write_bytes(original_content) - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - response = await dashboard.fetch( - "/download.bin?configuration=test.yaml&file=firmware.bin&compressed=1", - method="GET", - ) - assert response.code == 200 - # Decompress and verify content - decompressed = gzip.decompress(response.body) - assert decompressed == original_content - assert response.headers["Content-Type"] == "application/octet-stream" - assert "firmware.bin.gz" in response.headers["Content-Disposition"] - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_custom_download_name( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, -) -> None: - """Test the DownloadBinaryRequestHandler.get with custom download name.""" - # Create a fake binary file - build_dir = tmp_path / ".esphome" / "build" / "test" - build_dir.mkdir(parents=True) - firmware_file = build_dir / "firmware.bin" - firmware_file.write_bytes(b"content") - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - response = await dashboard.fetch( - "/download.bin?configuration=test.yaml&file=firmware.bin&download=custom_name.bin", - method="GET", - ) - assert response.code == 200 - assert "custom_name.bin" in response.headers["Content-Disposition"] - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_idedata_fallback( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_async_run_system_command: MagicMock, - mock_storage_json: MagicMock, - mock_idedata: MagicMock, -) -> None: - """Test the DownloadBinaryRequestHandler.get falling back to idedata for extra images.""" - # Create build directory but no bootloader file initially - build_dir = tmp_path / ".esphome" / "build" / "test" - build_dir.mkdir(parents=True) - firmware_file = build_dir / "firmware.bin" - firmware_file.write_bytes(b"firmware") - - # Create bootloader file that idedata will find - bootloader_file = tmp_path / "bootloader.bin" - bootloader_file.write_bytes(b"bootloader content") - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - # Mock idedata response - mock_image = Mock() - mock_image.path = bootloader_file - mock_idedata_instance = Mock() - mock_idedata_instance.extra_flash_images = [mock_image] - mock_idedata.return_value = mock_idedata_instance - - # Mock async_run_system_command to return idedata JSON - mock_async_run_system_command.return_value = (0, '{"extra_flash_images": []}', "") - - response = await dashboard.fetch( - "/download.bin?configuration=test.yaml&file=bootloader.bin", - method="GET", - ) - assert response.code == 200 - assert response.body == b"bootloader content" - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_subdirectory_file( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, -) -> None: - """Test the DownloadBinaryRequestHandler.get with file in subdirectory (nRF52 case). - - This is a regression test for issue #11343 where the Path migration broke - downloads for nRF52 firmware files in subdirectories like 'zephyr/zephyr.uf2'. - - The issue was that with_name() doesn't accept path separators: - - Before: path = storage_json.firmware_bin_path.with_name(file_name) - ValueError: Invalid name 'zephyr/zephyr.uf2' - - After: path = storage_json.firmware_bin_path.parent.joinpath(file_name) - Works correctly with subdirectory paths - """ - # Create a fake nRF52 build structure with firmware in subdirectory - build_dir = get_build_path(tmp_path, "nrf52-device") - zephyr_dir = build_dir / "zephyr" - zephyr_dir.mkdir(parents=True) - - # Create the main firmware binary (would be in build root) - firmware_file = build_dir / "firmware.bin" - firmware_file.write_bytes(b"main firmware") - - # Create the UF2 file in zephyr subdirectory (nRF52 specific) - uf2_file = zephyr_dir / "zephyr.uf2" - uf2_file.write_bytes(b"nRF52 UF2 firmware content") - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "nrf52-device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - # Request the UF2 file with subdirectory path - response = await dashboard.fetch( - "/download.bin?configuration=nrf52-device.yaml&file=zephyr/zephyr.uf2", - method="GET", - ) - assert response.code == 200 - assert response.body == b"nRF52 UF2 firmware content" - assert response.headers["Content-Type"] == "application/octet-stream" - assert "attachment" in response.headers["Content-Disposition"] - # Download name should be device-name + full file path - assert "nrf52-device-zephyr/zephyr.uf2" in response.headers["Content-Disposition"] - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_subdirectory_file_url_encoded( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, -) -> None: - """Test the DownloadBinaryRequestHandler.get with URL-encoded subdirectory path. - - Verifies that URL-encoded paths (e.g., zephyr%2Fzephyr.uf2) are correctly - decoded and handled, and that custom download names work with subdirectories. - """ - # Create a fake build structure with firmware in subdirectory - build_dir = get_build_path(tmp_path, "test") - zephyr_dir = build_dir / "zephyr" - zephyr_dir.mkdir(parents=True) - - firmware_file = build_dir / "firmware.bin" - firmware_file.write_bytes(b"content") - - uf2_file = zephyr_dir / "zephyr.uf2" - uf2_file.write_bytes(b"content") - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - # Request with URL-encoded path and custom download name - response = await dashboard.fetch( - "/download.bin?configuration=test.yaml&file=zephyr%2Fzephyr.uf2&download=custom_name.bin", - method="GET", - ) - assert response.code == 200 - assert "custom_name.bin" in response.headers["Content-Disposition"] - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -@pytest.mark.parametrize( - ("attack_path", "expected_code"), - [ - pytest.param("../../../secrets.yaml", 403, id="basic_traversal"), - pytest.param("..%2F..%2F..%2Fsecrets.yaml", 403, id="url_encoded"), - pytest.param("zephyr/../../../secrets.yaml", 403, id="traversal_with_prefix"), - pytest.param("/etc/passwd", 403, id="absolute_path"), - pytest.param("//etc/passwd", 403, id="double_slash_absolute"), - pytest.param( - "....//secrets.yaml", - # On Windows, Path.resolve() treats "..." and "...." as parent - # traversal (like ".."), so the path escapes base_dir -> 403. - # On Unix, "...." is a literal directory name that stays inside - # base_dir but doesn't exist -> 404. - 403 if sys.platform == "win32" else 404, - id="multiple_dots", - ), - ], -) -async def test_download_binary_handler_path_traversal_protection( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, - attack_path: str, - expected_code: int, -) -> None: - """Test that DownloadBinaryRequestHandler prevents path traversal attacks. - - Verifies that attempts to escape the build directory via '..' are rejected - using resolve()/relative_to() validation. Tests multiple attack vectors. - Real traversals that escape the base directory get 403. Paths like '....' - that resolve inside the base directory but don't exist get 404. - """ - # Create build structure - build_dir = get_build_path(tmp_path, "test") - build_dir.mkdir(parents=True) - firmware_file = build_dir / "firmware.bin" - firmware_file.write_bytes(b"firmware content") - - # Create a sensitive file outside the build directory that should NOT be accessible - sensitive_file = tmp_path / "secrets.yaml" - sensitive_file.write_bytes(b"secret: my_secret_password") - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - # Mock async_run_system_command so paths that pass validation but don't exist - # return 404 deterministically without spawning a real subprocess. - with ( - patch( - "esphome.dashboard.web_server.async_run_system_command", - new_callable=AsyncMock, - return_value=(2, "", ""), - ), - pytest.raises(HTTPClientError) as exc_info, - ): - await dashboard.fetch( - f"/download.bin?configuration=test.yaml&file={attack_path}", - method="GET", - ) - assert exc_info.value.code == expected_code - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_no_firmware_bin_path( - dashboard: DashboardTestHelper, - mock_storage_json: MagicMock, -) -> None: - """Test that download returns 404 when firmware_bin_path is None. - - This covers configs created by StorageJSON.from_wizard() where no - firmware has been compiled yet. - """ - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = None - mock_storage_json.load.return_value = mock_storage - - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/download.bin?configuration=test.yaml&file=firmware.bin", - method="GET", - ) - assert exc_info.value.code == 404 - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -@pytest.mark.parametrize("file_value", ["", "%20%20", "%20"]) -async def test_download_binary_handler_empty_file_name( - dashboard: DashboardTestHelper, - mock_storage_json: MagicMock, - file_value: str, -) -> None: - """Test that download returns 400 for empty or whitespace-only file names.""" - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = Path("/fake/firmware.bin") - mock_storage_json.load.return_value = mock_storage - - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - f"/download.bin?configuration=test.yaml&file={file_value}", - method="GET", - ) - assert exc_info.value.code == 400 - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_multiple_subdirectory_levels( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, -) -> None: - """Test downloading files from multiple subdirectory levels. - - Verifies that joinpath correctly handles multi-level paths like 'build/output/firmware.bin'. - """ - # Create nested directory structure - build_dir = get_build_path(tmp_path, "test") - nested_dir = build_dir / "build" / "output" - nested_dir.mkdir(parents=True) - - firmware_file = build_dir / "firmware.bin" - firmware_file.write_bytes(b"main") - - nested_file = nested_dir / "firmware.bin" - nested_file.write_bytes(b"nested firmware content") - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - response = await dashboard.fetch( - "/download.bin?configuration=test.yaml&file=build/output/firmware.bin", - method="GET", - ) - assert response.code == 200 - assert response.body == b"nested firmware content" - - -@pytest.mark.asyncio -async def test_edit_request_handler_post_invalid_file( - dashboard: DashboardTestHelper, -) -> None: - """Test the EditRequestHandler.post with non-yaml file.""" - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/edit?configuration=test.txt", - method="POST", - body=b"content", - ) - assert exc_info.value.code == 404 - - -@pytest.mark.asyncio -async def test_edit_request_handler_post_existing( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_dashboard_settings: MagicMock, -) -> None: - """Test the EditRequestHandler.post with existing yaml file.""" - # Create a temporary yaml file to edit (don't modify fixtures) - test_file = tmp_path / "test_edit.yaml" - test_file.write_text("esphome:\n name: original\n") - - # Configure the mock settings - mock_dashboard_settings.rel_path.return_value = test_file - mock_dashboard_settings.absolute_config_dir = test_file.parent - - new_content = "esphome:\n name: modified\n" - response = await dashboard.fetch( - "/edit?configuration=test_edit.yaml", - method="POST", - body=new_content.encode(), - ) - assert response.code == 200 - - # Verify the file was actually modified - assert test_file.read_text() == new_content - - -@pytest.mark.asyncio -async def test_unarchive_request_handler( - dashboard: DashboardTestHelper, - mock_archive_storage_path: MagicMock, - mock_dashboard_settings: MagicMock, - tmp_path: Path, -) -> None: - """Test the UnArchiveRequestHandler.post method.""" - # Set up an archived file - archive_dir = mock_archive_storage_path.return_value - archive_dir.mkdir(parents=True, exist_ok=True) - archived_file = archive_dir / "archived.yaml" - archived_file.write_text("test content") - - # Set up the destination path where the file should be moved - config_dir = tmp_path / "config" - config_dir.mkdir(parents=True, exist_ok=True) - destination_file = config_dir / "archived.yaml" - mock_dashboard_settings.rel_path.return_value = destination_file - - response = await dashboard.fetch( - "/unarchive?configuration=archived.yaml", - method="POST", - body=b"", - ) - assert response.code == 200 - - # Verify the file was actually moved from archive to config - assert not archived_file.exists() # File should be gone from archive - assert destination_file.exists() # File should now be in config - assert destination_file.read_text() == "test content" # Content preserved - - -@pytest.mark.asyncio -async def test_secret_keys_handler_no_file(dashboard: DashboardTestHelper) -> None: - """Test the SecretKeysRequestHandler.get when no secrets file exists.""" - # By default, there's no secrets file in the test fixtures - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch("/secret_keys", method="GET") - assert exc_info.value.code == 404 - - -@pytest.mark.asyncio -async def test_secret_keys_handler_with_file( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_dashboard_settings: MagicMock, -) -> None: - """Test the SecretKeysRequestHandler.get when secrets file exists.""" - # Create a secrets file in temp directory - secrets_file = tmp_path / "secrets.yaml" - secrets_file.write_text( - "wifi_ssid: TestNetwork\nwifi_password: TestPass123\napi_key: test_key\n" - ) - - # Configure mock to return our temp secrets file - # Since the file actually exists, os.path.isfile will return True naturally - mock_dashboard_settings.rel_path.return_value = secrets_file - - response = await dashboard.fetch("/secret_keys", method="GET") - assert response.code == 200 - data = json.loads(response.body.decode()) - assert "wifi_ssid" in data - assert "wifi_password" in data - assert "api_key" in data - - -@pytest.mark.asyncio -async def test_json_config_handler( - dashboard: DashboardTestHelper, - mock_async_run_system_command: MagicMock, -) -> None: - """Test the JsonConfigRequestHandler.get method.""" - # This will actually run the esphome config command on pico.yaml - mock_output = json.dumps( - { - "esphome": {"name": "pico"}, - "esp32": {"board": "esp32dev"}, - } - ) - mock_async_run_system_command.return_value = (0, mock_output, "") - - response = await dashboard.fetch( - "/json-config?configuration=pico.yaml", method="GET" - ) - assert response.code == 200 - data = json.loads(response.body.decode()) - assert data["esphome"]["name"] == "pico" - - -@pytest.mark.asyncio -async def test_json_config_handler_invalid_config( - dashboard: DashboardTestHelper, - mock_async_run_system_command: MagicMock, -) -> None: - """Test the JsonConfigRequestHandler.get with invalid config.""" - # Simulate esphome config command failure - mock_async_run_system_command.return_value = (1, "", "Error: Invalid configuration") - - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch("/json-config?configuration=pico.yaml", method="GET") - assert exc_info.value.code == 422 - - -@pytest.mark.asyncio -async def test_json_config_handler_not_found(dashboard: DashboardTestHelper) -> None: - """Test the JsonConfigRequestHandler.get with non-existent file.""" - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/json-config?configuration=nonexistent.yaml", method="GET" - ) - assert exc_info.value.code == 404 - - -def test_start_web_server_with_address_port( - tmp_path: Path, - mock_trash_storage_path: MagicMock, - mock_archive_storage_path: MagicMock, -) -> None: - """Test the start_web_server function with address and port.""" - app = Mock() - trash_dir = mock_trash_storage_path.return_value - archive_dir = mock_archive_storage_path.return_value - - # Create trash dir to test migration - trash_dir.mkdir() - (trash_dir / "old.yaml").write_text("old") - - web_server.start_web_server(app, None, "127.0.0.1", 6052, str(tmp_path / "config")) - - # The function calls app.listen directly for non-socket mode - app.listen.assert_called_once_with(6052, "127.0.0.1") - - # Verify trash was moved to archive - assert not trash_dir.exists() - assert archive_dir.exists() - assert (archive_dir / "old.yaml").exists() - - -@pytest.mark.asyncio -async def test_edit_request_handler_get(dashboard: DashboardTestHelper) -> None: - """Test EditRequestHandler.get method.""" - # Test getting a valid yaml file - response = await dashboard.fetch("/edit?configuration=pico.yaml") - assert response.code == 200 - assert response.headers["content-type"] == "application/yaml" - content = response.body.decode() - assert "esphome:" in content # Verify it's a valid ESPHome config - - # Test getting a non-existent file - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch("/edit?configuration=nonexistent.yaml") - assert exc_info.value.code == 404 - - # Test getting a non-yaml file - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch("/edit?configuration=test.txt") - assert exc_info.value.code == 404 - - # Test path traversal attempt - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch("/edit?configuration=../../../etc/passwd") - assert exc_info.value.code == 404 - - -@pytest.mark.asyncio -async def test_archive_request_handler_post( - dashboard: DashboardTestHelper, - mock_archive_storage_path: MagicMock, - mock_ext_storage_path: MagicMock, - tmp_path: Path, -) -> None: - """Test ArchiveRequestHandler.post method without storage_json.""" - - # Set up temp directories - config_dir = Path(get_fixture_path("conf")) - archive_dir = tmp_path / "archive" - - # Create a test configuration file - test_config = config_dir / "test_archive.yaml" - test_config.write_text("esphome:\n name: test_archive\n") - - # Archive the configuration - response = await dashboard.fetch( - "/archive", - method="POST", - body="configuration=test_archive.yaml", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - assert response.code == 200 - - # Verify file was moved to archive - assert not test_config.exists() - assert (archive_dir / "test_archive.yaml").exists() - assert ( - archive_dir / "test_archive.yaml" - ).read_text() == "esphome:\n name: test_archive\n" - - -@pytest.mark.asyncio -async def test_archive_handler_with_build_folder( - dashboard: DashboardTestHelper, - mock_archive_storage_path: MagicMock, - mock_ext_storage_path: MagicMock, - mock_dashboard_settings: MagicMock, - mock_storage_json: MagicMock, - tmp_path: Path, -) -> None: - """Test ArchiveRequestHandler.post with storage_json and build folder.""" - config_dir = tmp_path / "config" - config_dir.mkdir() - archive_dir = tmp_path / "archive" - archive_dir.mkdir() - build_dir = tmp_path / "build" - build_dir.mkdir() - - configuration = "test_device.yaml" - test_config = config_dir / configuration - test_config.write_text("esphome:\n name: test_device\n") - - build_folder = build_dir / "test_device" - build_folder.mkdir() - (build_folder / "firmware.bin").write_text("binary content") - (build_folder / ".pioenvs").mkdir() - - mock_dashboard_settings.config_dir = str(config_dir) - mock_dashboard_settings.rel_path.return_value = test_config - mock_archive_storage_path.return_value = archive_dir - - mock_storage = MagicMock() - mock_storage.name = "test_device" - mock_storage.build_path = build_folder - mock_storage_json.load.return_value = mock_storage - - response = await dashboard.fetch( - "/archive", - method="POST", - body=f"configuration={configuration}", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - assert response.code == 200 - - assert not test_config.exists() - assert (archive_dir / configuration).exists() - - assert not build_folder.exists() - assert not (archive_dir / "test_device").exists() - - -@pytest.mark.asyncio -async def test_archive_handler_no_build_folder( - dashboard: DashboardTestHelper, - mock_archive_storage_path: MagicMock, - mock_ext_storage_path: MagicMock, - mock_dashboard_settings: MagicMock, - mock_storage_json: MagicMock, - tmp_path: Path, -) -> None: - """Test ArchiveRequestHandler.post with storage_json but no build folder.""" - config_dir = tmp_path / "config" - config_dir.mkdir() - archive_dir = tmp_path / "archive" - archive_dir.mkdir() - - configuration = "test_device.yaml" - test_config = config_dir / configuration - test_config.write_text("esphome:\n name: test_device\n") - - mock_dashboard_settings.config_dir = str(config_dir) - mock_dashboard_settings.rel_path.return_value = test_config - mock_archive_storage_path.return_value = archive_dir - - mock_storage = MagicMock() - mock_storage.name = "test_device" - mock_storage.build_path = None - mock_storage_json.load.return_value = mock_storage - - response = await dashboard.fetch( - "/archive", - method="POST", - body=f"configuration={configuration}", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - assert response.code == 200 - - assert not test_config.exists() - assert (archive_dir / configuration).exists() - assert not (archive_dir / "test_device").exists() - - -@pytest.mark.skipif(os.name == "nt", reason="Unix sockets are not supported on Windows") -@pytest.mark.usefixtures("mock_trash_storage_path", "mock_archive_storage_path") -def test_start_web_server_with_unix_socket(tmp_path: Path) -> None: - """Test the start_web_server function with unix socket.""" - app = Mock() - socket_path = tmp_path / "test.sock" - - # Don't create trash_dir - it doesn't exist, so no migration needed - with ( - patch("tornado.httpserver.HTTPServer") as mock_server_class, - patch("tornado.netutil.bind_unix_socket") as mock_bind, - ): - server = Mock() - mock_server_class.return_value = server - mock_bind.return_value = Mock() - - web_server.start_web_server( - app, str(socket_path), None, None, str(tmp_path / "config") - ) - - mock_server_class.assert_called_once_with(app) - mock_bind.assert_called_once_with(str(socket_path), mode=0o666) - server.add_socket.assert_called_once() - - -def test_build_cache_arguments_no_entry(mock_dashboard: Mock) -> None: - """Test with no entry returns empty list.""" - result = web_server.build_cache_arguments(None, mock_dashboard, 0.0) - assert result == [] - - -def test_build_cache_arguments_no_address_no_name(mock_dashboard: Mock) -> None: - """Test with entry but no address or name.""" - entry = Mock(spec=web_server.DashboardEntry) - entry.address = None - entry.name = None - result = web_server.build_cache_arguments(entry, mock_dashboard, 0.0) - assert result == [] - - -def test_build_cache_arguments_mdns_address_cached(mock_dashboard: Mock) -> None: - """Test with .local address that has cached mDNS results.""" - entry = Mock(spec=web_server.DashboardEntry) - entry.address = "device.local" - entry.name = None - mock_dashboard.mdns_status = Mock() - mock_dashboard.mdns_status.get_cached_addresses.return_value = [ - "192.168.1.10", - "fe80::1", - ] - - result = web_server.build_cache_arguments(entry, mock_dashboard, 0.0) - - assert result == [ - "--mdns-address-cache", - "device.local=192.168.1.10,fe80::1", - ] - mock_dashboard.mdns_status.get_cached_addresses.assert_called_once_with( - "device.local" - ) - - -def test_build_cache_arguments_dns_address_cached(mock_dashboard: Mock) -> None: - """Test with non-.local address that has cached DNS results.""" - entry = Mock(spec=web_server.DashboardEntry) - entry.address = "example.com" - entry.name = None - mock_dashboard.dns_cache = Mock() - mock_dashboard.dns_cache.get_cached_addresses.return_value = [ - "93.184.216.34", - "2606:2800:220:1:248:1893:25c8:1946", - ] - - now = 100.0 - result = web_server.build_cache_arguments(entry, mock_dashboard, now) - - # IPv6 addresses are sorted before IPv4 - assert result == [ - "--dns-address-cache", - "example.com=2606:2800:220:1:248:1893:25c8:1946,93.184.216.34", - ] - mock_dashboard.dns_cache.get_cached_addresses.assert_called_once_with( - "example.com", now - ) - - -def test_build_cache_arguments_name_without_address(mock_dashboard: Mock) -> None: - """Test with name but no address - should check mDNS with .local suffix.""" - entry = Mock(spec=web_server.DashboardEntry) - entry.name = "my-device" - entry.address = None - mock_dashboard.mdns_status = Mock() - mock_dashboard.mdns_status.get_cached_addresses.return_value = ["192.168.1.20"] - - result = web_server.build_cache_arguments(entry, mock_dashboard, 0.0) - - assert result == [ - "--mdns-address-cache", - "my-device.local=192.168.1.20", - ] - mock_dashboard.mdns_status.get_cached_addresses.assert_called_once_with( - "my-device.local" - ) - - -@pytest.mark.asyncio -async def test_websocket_connection_initial_state( - dashboard: DashboardTestHelper, -) -> None: - """Test WebSocket connection and initial state.""" - async with websocket_connection(dashboard) as ws: - # Should receive initial state with configured and importable devices - msg = await ws.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "initial_state" - assert "devices" in data["data"] - assert "configured" in data["data"]["devices"] - assert "importable" in data["data"]["devices"] - - # Check configured devices - configured = data["data"]["devices"]["configured"] - assert len(configured) > 0 - assert configured[0]["name"] == "pico" # From test fixtures - - -@pytest.mark.asyncio -async def test_websocket_ping_pong( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket ping/pong mechanism.""" - # Send ping - await websocket_client.write_message(json.dumps({"event": "ping"})) - - # Should receive pong - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "pong" - - -@pytest.mark.asyncio -async def test_websocket_invalid_json( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket handling of invalid JSON.""" - # Send invalid JSON - await websocket_client.write_message("not valid json {]") - - # Send a valid ping to verify connection is still alive - await websocket_client.write_message(json.dumps({"event": "ping"})) - - # Should receive pong, confirming the connection wasn't closed by invalid JSON - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "pong" - - -@pytest.mark.asyncio -async def test_websocket_authentication_required( - dashboard: DashboardTestHelper, -) -> None: - """Test WebSocket authentication when auth is required.""" - with patch( - "esphome.dashboard.web_server.is_authenticated" - ) as mock_is_authenticated: - mock_is_authenticated.return_value = False - - # Try to connect - should be rejected with 401 - url = f"ws://127.0.0.1:{dashboard.port}/events" - with pytest.raises(HTTPClientError) as exc_info: - await websocket_connect(url) - # Should get HTTP 401 Unauthorized - assert exc_info.value.code == 401 - - -@pytest.mark.asyncio -async def test_websocket_authentication_not_required( - dashboard: DashboardTestHelper, -) -> None: - """Test WebSocket connection when no auth is required.""" - with patch( - "esphome.dashboard.web_server.is_authenticated" - ) as mock_is_authenticated: - mock_is_authenticated.return_value = True - - # Should be able to connect successfully - async with websocket_connection(dashboard) as ws: - msg = await ws.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "initial_state" - - -@pytest.mark.asyncio -async def test_websocket_entry_state_changed( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket entry state changed event.""" - # Simulate entry state change - entry = DASHBOARD.entries.async_all()[0] - state = bool_to_entry_state(True, EntryStateSource.MDNS) - DASHBOARD.bus.async_fire( - DashboardEvent.ENTRY_STATE_CHANGED, {"entry": entry, "state": state} - ) - - # Should receive state change event - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "entry_state_changed" - assert data["data"]["filename"] == entry.filename - assert data["data"]["name"] == entry.name - assert data["data"]["state"] is True - - -@pytest.mark.asyncio -async def test_websocket_entry_added( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket entry added event.""" - # Create a mock entry - mock_entry = Mock(spec=DashboardEntry) - mock_entry.filename = "test.yaml" - mock_entry.name = "test_device" - mock_entry.to_dict.return_value = { - "name": "test_device", - "filename": "test.yaml", - "configuration": "test.yaml", - } - - # Simulate entry added - DASHBOARD.bus.async_fire(DashboardEvent.ENTRY_ADDED, {"entry": mock_entry}) - - # Should receive entry added event - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "entry_added" - assert data["data"]["device"]["name"] == "test_device" - assert data["data"]["device"]["filename"] == "test.yaml" - - -@pytest.mark.asyncio -async def test_websocket_entry_removed( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket entry removed event.""" - # Create a mock entry - mock_entry = Mock(spec=DashboardEntry) - mock_entry.filename = "removed.yaml" - mock_entry.name = "removed_device" - mock_entry.to_dict.return_value = { - "name": "removed_device", - "filename": "removed.yaml", - "configuration": "removed.yaml", - } - - # Simulate entry removed - DASHBOARD.bus.async_fire(DashboardEvent.ENTRY_REMOVED, {"entry": mock_entry}) - - # Should receive entry removed event - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "entry_removed" - assert data["data"]["device"]["name"] == "removed_device" - assert data["data"]["device"]["filename"] == "removed.yaml" - - -@pytest.mark.asyncio -async def test_websocket_importable_device_added( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket importable device added event with real DiscoveredImport.""" - # Create a real DiscoveredImport object - discovered = DiscoveredImport( - device_name="new_import_device", - friendly_name="New Import Device", - package_import_url="https://example.com/package", - project_name="test_project", - project_version="1.0.0", - network="wifi", - ) - - # Directly fire the event as the mDNS system would - device_dict = build_importable_device_dict(DASHBOARD, discovered) - DASHBOARD.bus.async_fire( - DashboardEvent.IMPORTABLE_DEVICE_ADDED, {"device": device_dict} - ) - - # Should receive importable device added event - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "importable_device_added" - assert data["data"]["device"]["name"] == "new_import_device" - assert data["data"]["device"]["friendly_name"] == "New Import Device" - assert data["data"]["device"]["project_name"] == "test_project" - assert data["data"]["device"]["network"] == "wifi" - assert data["data"]["device"]["ignored"] is False - - -@pytest.mark.asyncio -async def test_websocket_importable_device_added_ignored( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket importable device added event for ignored device.""" - # Add device to ignored list - DASHBOARD.ignored_devices.add("ignored_device") - - # Create a real DiscoveredImport object - discovered = DiscoveredImport( - device_name="ignored_device", - friendly_name="Ignored Device", - package_import_url="https://example.com/package", - project_name="test_project", - project_version="1.0.0", - network="ethernet", - ) - - # Directly fire the event as the mDNS system would - device_dict = build_importable_device_dict(DASHBOARD, discovered) - DASHBOARD.bus.async_fire( - DashboardEvent.IMPORTABLE_DEVICE_ADDED, {"device": device_dict} - ) - - # Should receive importable device added event with ignored=True - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "importable_device_added" - assert data["data"]["device"]["name"] == "ignored_device" - assert data["data"]["device"]["friendly_name"] == "Ignored Device" - assert data["data"]["device"]["network"] == "ethernet" - assert data["data"]["device"]["ignored"] is True - - -@pytest.mark.asyncio -async def test_websocket_importable_device_removed( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket importable device removed event.""" - # Simulate importable device removed - DASHBOARD.bus.async_fire( - DashboardEvent.IMPORTABLE_DEVICE_REMOVED, - {"name": "removed_import_device"}, - ) - - # Should receive importable device removed event - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "importable_device_removed" - assert data["data"]["name"] == "removed_import_device" - - -@pytest.mark.asyncio -async def test_websocket_importable_device_already_configured( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test that importable device event is not sent if device is already configured.""" - # Get an existing configured device name - existing_entry = DASHBOARD.entries.async_all()[0] - - # Simulate importable device added with same name as configured device - DASHBOARD.bus.async_fire( - DashboardEvent.IMPORTABLE_DEVICE_ADDED, - { - "device": { - "name": existing_entry.name, - "friendly_name": "Should Not Be Sent", - "package_import_url": "https://example.com/package", - "project_name": "test_project", - "project_version": "1.0.0", - "network": "wifi", - } - }, - ) - - # Send a ping to ensure connection is still alive - await websocket_client.write_message(json.dumps({"event": "ping"})) - - # Should only receive pong, not the importable device event - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "pong" - - -@pytest.mark.asyncio -async def test_websocket_multiple_connections(dashboard: DashboardTestHelper) -> None: - """Test multiple WebSocket connections.""" - async with ( - websocket_connection(dashboard) as ws1, - websocket_connection(dashboard) as ws2, - ): - # Both should receive initial state - msg1 = await ws1.read_message() - assert msg1 is not None - data1 = json.loads(msg1) - assert data1["event"] == "initial_state" - - msg2 = await ws2.read_message() - assert msg2 is not None - data2 = json.loads(msg2) - assert data2["event"] == "initial_state" - - # Fire an event - both should receive it - entry = DASHBOARD.entries.async_all()[0] - state = bool_to_entry_state(False, EntryStateSource.MDNS) - DASHBOARD.bus.async_fire( - DashboardEvent.ENTRY_STATE_CHANGED, {"entry": entry, "state": state} - ) - - msg1 = await ws1.read_message() - assert msg1 is not None - data1 = json.loads(msg1) - assert data1["event"] == "entry_state_changed" - - msg2 = await ws2.read_message() - assert msg2 is not None - data2 = json.loads(msg2) - assert data2["event"] == "entry_state_changed" - - -@pytest.mark.asyncio -async def test_dashboard_subscriber_lifecycle(dashboard: DashboardTestHelper) -> None: - """Test DashboardSubscriber lifecycle.""" - subscriber = DashboardSubscriber() - - # Initially no subscribers - assert len(subscriber._subscribers) == 0 - assert subscriber._event_loop_task is None - - # Add a subscriber - mock_websocket = Mock() - unsubscribe = subscriber.subscribe(mock_websocket) - - # Should have started the event loop task - assert len(subscriber._subscribers) == 1 - assert subscriber._event_loop_task is not None - - # Unsubscribe - unsubscribe() - - # Should have stopped the task - assert len(subscriber._subscribers) == 0 - - -@pytest.mark.asyncio -async def test_dashboard_subscriber_entries_update_interval( - dashboard: DashboardTestHelper, -) -> None: - """Test DashboardSubscriber entries update interval.""" - # Patch the constants to make the test run faster - with ( - patch("esphome.dashboard.web_server.DASHBOARD_POLL_INTERVAL", 0.01), - patch("esphome.dashboard.web_server.DASHBOARD_ENTRIES_UPDATE_ITERATIONS", 2), - patch("esphome.dashboard.web_server.settings") as mock_settings, - patch("esphome.dashboard.web_server.DASHBOARD") as mock_dashboard, - ): - mock_settings.status_use_mqtt = False - - # Mock dashboard dependencies - mock_dashboard.ping_request = Mock() - mock_dashboard.ping_request.set = Mock() - mock_dashboard.entries = Mock() - mock_dashboard.entries.async_request_update_entries = Mock() - - subscriber = DashboardSubscriber() - mock_websocket = Mock() - - # Subscribe to start the event loop - unsubscribe = subscriber.subscribe(mock_websocket) - - # Wait for a few iterations to ensure entries update is called - await asyncio.sleep(0.05) # Should be enough for 2+ iterations - - # Unsubscribe to stop the task - unsubscribe() - - # Verify entries update was called - assert mock_dashboard.entries.async_request_update_entries.call_count >= 1 - # Verify ping request was set multiple times - assert mock_dashboard.ping_request.set.call_count >= 2 - - -@pytest.mark.asyncio -async def test_websocket_refresh_command( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket refresh command triggers dashboard update.""" - with patch("esphome.dashboard.web_server.DASHBOARD_SUBSCRIBER") as mock_subscriber: - # Signal an asyncio.Event when request_refresh is invoked so the - # test can deterministically wait for the server-side handler to run - # instead of relying on a fixed sleep (flaky on Windows CI under load). - called = asyncio.Event() - mock_subscriber.request_refresh = Mock(side_effect=called.set) - - # Send refresh command - await websocket_client.write_message(json.dumps({"event": "refresh"})) - - # Wait for the server to process the message and invoke request_refresh - async with asyncio.timeout(5): - await called.wait() - - # Verify request_refresh was called - mock_subscriber.request_refresh.assert_called_once() - - -@pytest.mark.asyncio -async def test_dashboard_subscriber_refresh_event( - dashboard: DashboardTestHelper, -) -> None: - """Test DashboardSubscriber refresh event triggers immediate update.""" - # Patch the constants to make the test run faster - with ( - patch( - "esphome.dashboard.web_server.DASHBOARD_POLL_INTERVAL", 1.0 - ), # Long timeout - patch( - "esphome.dashboard.web_server.DASHBOARD_ENTRIES_UPDATE_ITERATIONS", 100 - ), # Won't reach naturally - patch("esphome.dashboard.web_server.settings") as mock_settings, - patch("esphome.dashboard.web_server.DASHBOARD") as mock_dashboard, - ): - mock_settings.status_use_mqtt = False - - # Mock dashboard dependencies - mock_dashboard.ping_request = Mock() - mock_dashboard.ping_request.set = Mock() - mock_dashboard.entries = Mock() - mock_dashboard.entries.async_request_update_entries = AsyncMock() - - subscriber = DashboardSubscriber() - mock_websocket = Mock() - - # Subscribe to start the event loop - unsubscribe = subscriber.subscribe(mock_websocket) - - # Wait a bit to ensure loop is running - await asyncio.sleep(0.01) - - # Verify entries update hasn't been called yet (iterations not reached) - assert mock_dashboard.entries.async_request_update_entries.call_count == 0 - - # Request refresh - subscriber.request_refresh() - - # Wait for the refresh to be processed - await asyncio.sleep(0.01) - - # Now entries update should have been called - assert mock_dashboard.entries.async_request_update_entries.call_count == 1 - - # Unsubscribe to stop the task - unsubscribe() - - # Give it a moment to clean up - await asyncio.sleep(0.01) - - -@pytest.mark.asyncio -async def test_dashboard_yaml_loading_with_packages_and_secrets( - tmp_path: Path, -) -> None: - """Test dashboard YAML loading with packages referencing secrets. - - This is a regression test for issue #11280 where binary download failed - when using packages with secrets after the Path migration in 2025.10.0. - - This test verifies that CORE.config_path initialization in the dashboard - allows yaml_util.load_yaml() to correctly resolve secrets from packages. - """ - # Create test directory structure with secrets and packages - config_dir = tmp_path / "config" - config_dir.mkdir() - - # Create secrets.yaml with obviously fake test values - secrets_file = config_dir / "secrets.yaml" - secrets_file.write_text( - "wifi_ssid: TEST-DUMMY-SSID\n" - "wifi_password: not-a-real-password-just-for-testing\n" - ) - - # Create package file that uses secrets - package_file = config_dir / "common.yaml" - package_file.write_text( - "wifi:\n ssid: !secret wifi_ssid\n password: !secret wifi_password\n" - ) - - # Create main device config that includes the package - device_config = config_dir / "test-download-secrets.yaml" - device_config.write_text( - "esphome:\n name: test-download-secrets\n platform: ESP32\n board: esp32dev\n\n" - "packages:\n common: !include common.yaml\n" - ) - - # Initialize DASHBOARD settings with our test config directory - # This is what sets CORE.config_path - the critical code path for the bug - args = Namespace( - configuration=str(config_dir), - password=None, - username=None, - ha_addon=False, - verbose=False, - ) - DASHBOARD.settings.parse_args(args) - - # With the fix: CORE.config_path should be config_dir / "___DASHBOARD_SENTINEL___.yaml" - # so CORE.config_path.parent would be config_dir - # Without the fix: CORE.config_path is config_dir / "." which normalizes to config_dir - # so CORE.config_path.parent would be tmp_path (the parent of config_dir) - - # The fix ensures CORE.config_path.parent points to config_dir - assert CORE.config_path.parent == config_dir.resolve(), ( - f"CORE.config_path.parent should point to config_dir. " - f"Got {CORE.config_path.parent}, expected {config_dir.resolve()}. " - f"CORE.config_path is {CORE.config_path}" - ) - - # Now load the YAML with packages that reference secrets - # This is where the bug would manifest - yaml_util.load_yaml would fail - # to find secrets.yaml because CORE.config_path.parent pointed to the wrong place - config = yaml_util.load_yaml(device_config) - # If we get here, secret resolution worked! - assert "esphome" in config - assert config["esphome"]["name"] == "test-download-secrets" - - -@pytest.mark.asyncio -async def test_websocket_check_origin_default_same_origin( - dashboard: DashboardTestHelper, -) -> None: - """Test WebSocket uses default same-origin check when ESPHOME_TRUSTED_DOMAINS not set.""" - # Ensure ESPHOME_TRUSTED_DOMAINS is not set - env = os.environ.copy() - env.pop("ESPHOME_TRUSTED_DOMAINS", None) - with patch.dict(os.environ, env, clear=True): - from tornado.httpclient import HTTPRequest - - url = f"ws://127.0.0.1:{dashboard.port}/events" - # Same origin should work (default Tornado behavior) - request = HTTPRequest( - url, headers={"Origin": f"http://127.0.0.1:{dashboard.port}"} - ) - ws = await websocket_connect(request) - try: - msg = await ws.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "initial_state" - finally: - ws.close() - - -@pytest.mark.asyncio -async def test_websocket_check_origin_trusted_domain( - dashboard: DashboardTestHelper, -) -> None: - """Test WebSocket accepts connections from trusted domains.""" - with patch.dict(os.environ, {"ESPHOME_TRUSTED_DOMAINS": "trusted.example.com"}): - from tornado.httpclient import HTTPRequest - - url = f"ws://127.0.0.1:{dashboard.port}/events" - request = HTTPRequest(url, headers={"Origin": "https://trusted.example.com"}) - ws = await websocket_connect(request) - try: - # Should receive initial state - msg = await ws.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "initial_state" - finally: - ws.close() - - -@pytest.mark.asyncio -async def test_websocket_check_origin_untrusted_domain( - dashboard: DashboardTestHelper, -) -> None: - """Test WebSocket rejects connections from untrusted domains.""" - with patch.dict(os.environ, {"ESPHOME_TRUSTED_DOMAINS": "trusted.example.com"}): - from tornado.httpclient import HTTPRequest - - url = f"ws://127.0.0.1:{dashboard.port}/events" - request = HTTPRequest(url, headers={"Origin": "https://untrusted.example.com"}) - with pytest.raises(HTTPClientError) as exc_info: - await websocket_connect(request) - # Should get HTTP 403 Forbidden due to origin check failure - assert exc_info.value.code == 403 - - -@pytest.mark.asyncio -async def test_websocket_check_origin_multiple_trusted_domains( - dashboard: DashboardTestHelper, -) -> None: - """Test WebSocket accepts connections from multiple trusted domains.""" - with patch.dict( - os.environ, - {"ESPHOME_TRUSTED_DOMAINS": "first.example.com, second.example.com"}, - ): - from tornado.httpclient import HTTPRequest - - url = f"ws://127.0.0.1:{dashboard.port}/events" - # Test second domain in list (with space after comma) - request = HTTPRequest(url, headers={"Origin": "https://second.example.com"}) - ws = await websocket_connect(request) - try: - msg = await ws.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "initial_state" - finally: - ws.close() - - -def test_proc_on_exit_calls_close() -> None: - """Test _proc_on_exit sends exit event and closes the WebSocket.""" - handler = Mock(spec=EsphomeCommandWebSocket) - handler._is_closed = False - - EsphomeCommandWebSocket._proc_on_exit(handler, 0) - - handler.write_message.assert_called_once_with({"event": "exit", "code": 0}) - handler.close.assert_called_once() - - -def test_proc_on_exit_skips_when_already_closed() -> None: - """Test _proc_on_exit does nothing when WebSocket is already closed.""" - handler = Mock(spec=EsphomeCommandWebSocket) - handler._is_closed = True - - EsphomeCommandWebSocket._proc_on_exit(handler, 0) - - handler.write_message.assert_not_called() - 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() - handler.request = Mock() - if auth_header is not None: - handler.request.headers = {"Authorization": auth_header} - else: - handler.request.headers = {} - handler.get_secure_cookie = Mock(return_value=None) - return handler - - -@pytest.fixture -def mock_auth_settings(mock_dashboard_settings: MagicMock) -> MagicMock: - """Fixture to configure mock dashboard settings with auth enabled.""" - mock_dashboard_settings.using_auth = True - mock_dashboard_settings.on_ha_addon = False - return mock_dashboard_settings - - -@pytest.mark.usefixtures("mock_auth_settings") -def test_is_authenticated_malformed_base64() -> None: - """Test that invalid base64 in Authorization header returns False.""" - handler = _make_auth_handler("Basic !!!not-valid-base64!!!") - assert web_server.is_authenticated(handler) is False - - -@pytest.mark.usefixtures("mock_auth_settings") -def test_is_authenticated_bad_base64_padding() -> None: - """Test that incorrect base64 padding (binascii.Error) returns False.""" - handler = _make_auth_handler("Basic abc") - assert web_server.is_authenticated(handler) is False - - -@pytest.mark.usefixtures("mock_auth_settings") -def test_is_authenticated_invalid_utf8() -> None: - """Test that base64 decoding to invalid UTF-8 returns False.""" - # \xff\xfe is invalid UTF-8 - bad_payload = base64.b64encode(b"\xff\xfe").decode("ascii") - handler = _make_auth_handler(f"Basic {bad_payload}") - assert web_server.is_authenticated(handler) is False - - -@pytest.mark.usefixtures("mock_auth_settings") -def test_is_authenticated_no_colon() -> None: - """Test that base64 payload without ':' separator returns False.""" - no_colon = base64.b64encode(b"nocolonhere").decode("ascii") - handler = _make_auth_handler(f"Basic {no_colon}") - assert web_server.is_authenticated(handler) is False - - -def test_is_authenticated_valid_credentials( - mock_auth_settings: MagicMock, -) -> None: - """Test that valid Basic auth credentials are checked.""" - creds = base64.b64encode(b"admin:secret").decode("ascii") - mock_auth_settings.check_password.return_value = True - handler = _make_auth_handler(f"Basic {creds}") - assert web_server.is_authenticated(handler) is True - mock_auth_settings.check_password.assert_called_once_with("admin", "secret") - - -def test_is_authenticated_wrong_credentials( - mock_auth_settings: MagicMock, -) -> None: - """Test that valid Basic auth with wrong credentials returns False.""" - creds = base64.b64encode(b"admin:wrong").decode("ascii") - mock_auth_settings.check_password.return_value = False - handler = _make_auth_handler(f"Basic {creds}") - assert web_server.is_authenticated(handler) is False - - -def test_is_authenticated_no_auth_configured( - mock_dashboard_settings: MagicMock, -) -> None: - """Test that requests pass when auth is not configured.""" - mock_dashboard_settings.using_auth = False - mock_dashboard_settings.on_ha_addon = False - handler = _make_auth_handler() - assert web_server.is_authenticated(handler) is True diff --git a/tests/dashboard/test_web_server_paths.py b/tests/dashboard/test_web_server_paths.py deleted file mode 100644 index efeafbf3b5..0000000000 --- a/tests/dashboard/test_web_server_paths.py +++ /dev/null @@ -1,219 +0,0 @@ -"""Tests for dashboard web_server Path-related functionality.""" - -from __future__ import annotations - -import gzip -import os -from pathlib import Path -from unittest.mock import MagicMock, patch - -from esphome.dashboard import web_server - - -def test_get_base_frontend_path_production() -> None: - """Test get_base_frontend_path in production mode.""" - mock_module = MagicMock() - mock_module.where.return_value = Path("/usr/local/lib/esphome_dashboard") - - with ( - patch.dict(os.environ, {}, clear=True), - patch.dict("sys.modules", {"esphome_dashboard": mock_module}), - ): - result = web_server.get_base_frontend_path() - assert result == Path("/usr/local/lib/esphome_dashboard") - mock_module.where.assert_called_once() - - -def test_get_base_frontend_path_dev_mode() -> None: - """Test get_base_frontend_path in development mode.""" - test_path = "/home/user/esphome/dashboard" - - with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}): - result = web_server.get_base_frontend_path() - - # The function uses Path.resolve() which resolves symlinks - # The actual function adds "/" to the path, so we simulate that - test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/" - expected = (Path.cwd() / test_path_with_slash / "esphome_dashboard").resolve() - assert result == expected - - -def test_get_base_frontend_path_dev_mode_with_trailing_slash() -> None: - """Test get_base_frontend_path in dev mode with trailing slash.""" - test_path = "/home/user/esphome/dashboard/" - - with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}): - result = web_server.get_base_frontend_path() - - # The function uses Path.resolve() which resolves symlinks - expected = (Path.cwd() / test_path / "esphome_dashboard").resolve() - assert result == expected - - -def test_get_base_frontend_path_dev_mode_relative_path() -> None: - """Test get_base_frontend_path with relative dev path.""" - test_path = "./dashboard" - - with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}): - result = web_server.get_base_frontend_path() - - # The function uses Path.resolve() which resolves symlinks - # The actual function adds "/" to the path, so we simulate that - test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/" - expected = (Path.cwd() / test_path_with_slash / "esphome_dashboard").resolve() - assert result == expected - assert result.is_absolute() - - -def test_get_static_path_single_component() -> None: - """Test get_static_path with single path component.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path("/base/frontend") - - result = web_server.get_static_path("file.js") - - assert result == Path("/base/frontend") / "static" / "file.js" - - -def test_get_static_path_multiple_components() -> None: - """Test get_static_path with multiple path components.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path("/base/frontend") - - result = web_server.get_static_path("js", "esphome", "index.js") - - assert ( - result == Path("/base/frontend") / "static" / "js" / "esphome" / "index.js" - ) - - -def test_get_static_path_empty_args() -> None: - """Test get_static_path with no arguments.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path("/base/frontend") - - result = web_server.get_static_path() - - assert result == Path("/base/frontend") / "static" - - -def test_get_static_path_with_pathlib_path() -> None: - """Test get_static_path with Path objects.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path("/base/frontend") - - path_obj = Path("js") / "app.js" - result = web_server.get_static_path(str(path_obj)) - - assert result == Path("/base/frontend") / "static" / "js" / "app.js" - - -def test_get_static_file_url_production() -> None: - """Test get_static_file_url in production mode.""" - web_server.get_static_file_url.cache_clear() - mock_module = MagicMock() - mock_path = MagicMock(spec=Path) - mock_path.read_bytes.return_value = b"test content" - - with ( - patch.dict(os.environ, {}, clear=True), - patch.dict("sys.modules", {"esphome_dashboard": mock_module}), - patch("esphome.dashboard.web_server.get_static_path") as mock_get_path, - ): - mock_get_path.return_value = mock_path - result = web_server.get_static_file_url("js/app.js") - assert result.startswith("./static/js/app.js?hash=") - - -def test_get_static_file_url_dev_mode() -> None: - """Test get_static_file_url in development mode.""" - with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": "/dev/path"}): - web_server.get_static_file_url.cache_clear() - result = web_server.get_static_file_url("js/app.js") - - assert result == "./static/js/app.js" - - -def test_get_static_file_url_index_js_special_case() -> None: - """Test get_static_file_url replaces index.js with entrypoint.""" - web_server.get_static_file_url.cache_clear() - mock_module = MagicMock() - mock_module.entrypoint.return_value = "main.js" - - with ( - patch.dict(os.environ, {}, clear=True), - patch.dict("sys.modules", {"esphome_dashboard": mock_module}), - ): - result = web_server.get_static_file_url("js/esphome/index.js") - assert result == "./static/js/esphome/main.js" - - -def test_load_file_path(tmp_path: Path) -> None: - """Test loading a file.""" - test_file = tmp_path / "test.txt" - test_file.write_bytes(b"test content") - - with test_file.open("rb") as f: - content = f.read() - assert content == b"test content" - - -def test_load_file_compressed_path(tmp_path: Path) -> None: - """Test loading a compressed file.""" - test_file = tmp_path / "test.txt.gz" - - with gzip.open(test_file, "wb") as gz: - gz.write(b"compressed content") - - with gzip.open(test_file, "rb") as gz: - content = gz.read() - assert content == b"compressed content" - - -def test_path_normalization_in_static_path() -> None: - """Test that paths are normalized correctly.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path("/base/frontend") - - # Test with separate components - result1 = web_server.get_static_path("js", "app.js") - result2 = web_server.get_static_path("js", "app.js") - - assert result1 == result2 - assert result1 == Path("/base/frontend") / "static" / "js" / "app.js" - - -def test_windows_path_handling() -> None: - """Test handling of Windows-style paths.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path(r"C:\Program Files\esphome\frontend") - - result = web_server.get_static_path("js", "app.js") - - # Path should handle this correctly on the platform - expected = ( - Path(r"C:\Program Files\esphome\frontend") / "static" / "js" / "app.js" - ) - assert result == expected - - -def test_path_with_special_characters() -> None: - """Test paths with special characters.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path("/base/frontend") - - result = web_server.get_static_path("js-modules", "app_v1.0.js") - - assert ( - result == Path("/base/frontend") / "static" / "js-modules" / "app_v1.0.js" - ) - - -def test_path_with_spaces() -> None: - """Test paths with spaces.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path("/base/my frontend") - - result = web_server.get_static_path("my js", "my app.js") - - assert result == Path("/base/my frontend") / "static" / "my js" / "my app.js" diff --git a/tests/dashboard/util/__init__.py b/tests/dashboard/util/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index a9876632bd..d4c13fd3fb 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -562,7 +562,7 @@ def test_determine_integration_tests( with patch.object( determine_jobs, "changed_files", - return_value=["esphome/dashboard/web_server.py"], + return_value=["esphome/analyze_memory/helpers.py"], ): run_all, test_files = determine_jobs.determine_integration_tests() assert run_all is False @@ -914,7 +914,6 @@ def test_should_run_core_ci_with_branch() -> None: # picks them up because esphome's pyproject sets # include-package-data = true. (["esphome/idf_component.yml"], True), - (["esphome/dashboard/templates/index.html"], True), (["esphome/components/api/api_pb2_service.json"], True), # Mixed: any triggering file is enough (["docs/README.md", "esphome/config.py"], True), diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 70c4b90082..fad249b0bb 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -121,22 +121,6 @@ def test_friendly_name_slugify(value, expected): assert helpers.friendly_name_slugify(value) == expected -def test_friendly_name_slugify_back_compat_shim(): - """``esphome.dashboard.util.text`` keeps re-exporting for back-compat. - - The function moved to ``esphome.helpers`` so the new - device-builder dashboard backend can import it without depending - on the legacy dashboard package, but downstream code that still - imports from the old path keeps working until the dashboard - module is removed. - """ - from esphome.dashboard.util.text import ( - friendly_name_slugify as legacy_friendly_name_slugify, - ) - - assert legacy_friendly_name_slugify is helpers.friendly_name_slugify - - @pytest.mark.parametrize( "host", ( diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index bb06b6c930..33888956b3 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -33,6 +33,7 @@ from esphome.__main__ import ( command_clean_all, command_config, command_config_hash, + command_dashboard, command_idedata, command_rename, command_run, @@ -3740,6 +3741,45 @@ def test_command_wizard(tmp_path: Path) -> None: mock_wizard.assert_called_once_with(config_file) +def test_command_dashboard_errors_with_device_builder_redirect() -> None: + """The removed dashboard command points users to ESPHome Device Builder.""" + args = MockArgs() + + with pytest.raises(EsphomeError, match="esphome-device-builder"): + command_dashboard(args) + + +@pytest.mark.parametrize( + "argv", + [ + ["esphome", "dashboard"], + ["esphome", "dashboard", "/config"], + # Legacy flags must be accepted so old invocations reach the redirect + # instead of failing on argparse "unrecognized arguments". + ["esphome", "dashboard", "--port", "6052", "/config"], + ["esphome", "dashboard", "--username", "u", "--password", "p", "--open-ui"], + [ + "esphome", + "dashboard", + "--address", + "0.0.0.0", + "--socket", + "/x", + "--ha-addon", + ], + ], +) +def test_run_esphome_dashboard_redirects_to_device_builder( + argv: list[str], + caplog: pytest.LogCaptureFixture, +) -> None: + """`esphome dashboard` still parses but fails with the redirect message.""" + result = run_esphome(argv) + + assert result == 1 + assert "esphome-device-builder" in caplog.text + + def test_command_config_hash( tmp_path: Path, capfd: CaptureFixture[str], From 1d5d5817340617489d2cb97fe32ccd511e80b788 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 16:34:09 -0500 Subject: [PATCH 0516/1815] [esp8266] Drop stale esphome-docker-base reference (#17123) --- esphome/components/esp8266/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index db7120a9ef..4daf4549ef 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -131,7 +131,6 @@ def _format_framework_arduino_version(ver: cv.Version) -> str: # The new version needs to be thoroughly validated before changing the # recommended version as otherwise a bunch of devices could be bricked # * For all constants below, update platformio.ini (in this repo) -# and platformio.ini/platformio-lint.ini in the esphome-docker-base repository # The default/recommended arduino framework version # - https://github.com/esp8266/Arduino/releases From 0d7130c49909d92c1567a2d52690d3842d0982f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 16:48:21 -0500 Subject: [PATCH 0517/1815] [docs] Remove leftover dashboard references after dashboard removal (#17125) --- AGENTS.md | 2 +- THREAT_MODEL.md | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index be2e912d48..21905ea356 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ This document provides essential context for AI models interacting with this pro * **Build Systems:** PlatformIO is the primary build system. CMake is used as an alternative. * **Configuration:** YAML. * **Key Libraries/Dependencies:** - * **Python:** `voluptuous` (for configuration validation), `PyYAML` (for parsing configuration files), `paho-mqtt` (for MQTT communication), `tornado` (for the web server), `aioesphomeapi` (for the native API). + * **Python:** `voluptuous` (for configuration validation), `PyYAML` (for parsing configuration files), `paho-mqtt` (for MQTT communication), `aioesphomeapi` (for the native API). * **C++:** `ArduinoJson` (for JSON serialization/deserialization), `AsyncMqttClient-esphome` (for MQTT), `ESPAsyncWebServer` (for the web server). * **Package Manager(s):** `pip` (for Python dependencies), `platformio` (for C++/PlatformIO dependencies). * **Communication Protocols:** Protobuf (for native API), MQTT, HTTP. diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index a4640467c9..a4355a5055 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -88,8 +88,6 @@ These *are* security bugs in this repo, and we want to hear about them privately holds the API key / OTA / web credentials). - Anything in the dashboard / device-builder — report that in its own repository (linked at the top). -- The legacy bundled dashboard in this repo (`esphome/dashboard/`) — it is - deprecated and being replaced by Device Builder; report dashboard issues there. - Deployments where the operator removed protections or exposed credentials. See the security best practices guide: https://esphome.io/guides/security_best_practices/ From 7d7cdb6c66b8692c8b2e2a1111a4df5b71bbfae5 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 09:51:11 +1200 Subject: [PATCH 0518/1815] Mark configurable classes as final (21/21: zhlt01-zyaura) (#16972) --- esphome/components/zhlt01/zhlt01.h | 2 +- esphome/components/zigbee/automation.h | 2 +- esphome/components/zigbee/time/zigbee_time_zephyr.h | 2 +- esphome/components/zigbee/zigbee_attribute_esp32.h | 2 +- esphome/components/zigbee/zigbee_binary_sensor_zephyr.h | 2 +- esphome/components/zigbee/zigbee_esp32.h | 2 +- esphome/components/zigbee/zigbee_number_zephyr.h | 2 +- esphome/components/zigbee/zigbee_sensor_zephyr.h | 2 +- esphome/components/zigbee/zigbee_switch_zephyr.h | 2 +- esphome/components/zigbee/zigbee_zephyr.h | 2 +- esphome/components/zio_ultrasonic/zio_ultrasonic.h | 2 +- esphome/components/zwave_proxy/zwave_proxy.h | 2 +- esphome/components/zyaura/zyaura.h | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/esphome/components/zhlt01/zhlt01.h b/esphome/components/zhlt01/zhlt01.h index 61fc2cc16a..dba9ca8f3d 100644 --- a/esphome/components/zhlt01/zhlt01.h +++ b/esphome/components/zhlt01/zhlt01.h @@ -142,7 +142,7 @@ static const float AC1_TEMP_MIN = 16.0f; static const float AC1_TEMP_MAX = 32.0f; static const float AC1_TEMP_INC = 1.0f; -class ZHLT01Climate : public climate_ir::ClimateIR { +class ZHLT01Climate final : public climate_ir::ClimateIR { public: ZHLT01Climate() : climate_ir::ClimateIR( diff --git a/esphome/components/zigbee/automation.h b/esphome/components/zigbee/automation.h index 55ee9746ea..1f953100d9 100644 --- a/esphome/components/zigbee/automation.h +++ b/esphome/components/zigbee/automation.h @@ -9,7 +9,7 @@ #endif namespace esphome::zigbee { -template class FactoryResetAction : public Action, public Parented { +template class FactoryResetAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->factory_reset(); } }; diff --git a/esphome/components/zigbee/time/zigbee_time_zephyr.h b/esphome/components/zigbee/time/zigbee_time_zephyr.h index 3c2adc4b5f..be2cff786e 100644 --- a/esphome/components/zigbee/time/zigbee_time_zephyr.h +++ b/esphome/components/zigbee/time/zigbee_time_zephyr.h @@ -12,7 +12,7 @@ extern "C" { namespace esphome::zigbee { -class ZigbeeTime : public time::RealTimeClock, public ZigbeeEntity { +class ZigbeeTime final : public time::RealTimeClock, public ZigbeeEntity { public: void setup() override; void dump_config() override; diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.h b/esphome/components/zigbee/zigbee_attribute_esp32.h index 35aa60848f..e978fcf209 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.h +++ b/esphome/components/zigbee/zigbee_attribute_esp32.h @@ -27,7 +27,7 @@ enum ZigbeeReportT { ZIGBEE_REPORT_FORCE, }; -class ZigbeeAttribute : public Component { +class ZigbeeAttribute final : public Component { public: ZigbeeAttribute(ZigbeeComponent *parent, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, uint8_t attr_type, float scale, uint8_t max_size) diff --git a/esphome/components/zigbee/zigbee_binary_sensor_zephyr.h b/esphome/components/zigbee/zigbee_binary_sensor_zephyr.h index aae79fa289..bc2718ff48 100644 --- a/esphome/components/zigbee/zigbee_binary_sensor_zephyr.h +++ b/esphome/components/zigbee/zigbee_binary_sensor_zephyr.h @@ -28,7 +28,7 @@ extern "C" { namespace esphome::zigbee { -class ZigbeeBinarySensor : public ZigbeeEntity, public Component { +class ZigbeeBinarySensor final : public ZigbeeEntity, public Component { public: explicit ZigbeeBinarySensor(binary_sensor::BinarySensor *binary_sensor); void set_cluster_attributes(BinaryAttrs &cluster_attributes) { this->cluster_attributes_ = &cluster_attributes; } diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index 34b2b827b6..25f53a1d6e 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -35,7 +35,7 @@ uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size = f class ZigbeeAttribute; -class ZigbeeComponent : public Component { +class ZigbeeComponent final : public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/zigbee/zigbee_number_zephyr.h b/esphome/components/zigbee/zigbee_number_zephyr.h index aabb0392be..886e6c3223 100644 --- a/esphome/components/zigbee/zigbee_number_zephyr.h +++ b/esphome/components/zigbee/zigbee_number_zephyr.h @@ -98,7 +98,7 @@ void zb_zcl_analog_output_init_client(); namespace esphome::zigbee { -class ZigbeeNumber : public ZigbeeEntity, public Component { +class ZigbeeNumber final : public ZigbeeEntity, public Component { public: ZigbeeNumber(number::Number *n) : number_(n) {} void set_cluster_attributes(AnalogAttrsOutput &cluster_attributes) { diff --git a/esphome/components/zigbee/zigbee_sensor_zephyr.h b/esphome/components/zigbee/zigbee_sensor_zephyr.h index 37406f21d0..cd03cf8a2b 100644 --- a/esphome/components/zigbee/zigbee_sensor_zephyr.h +++ b/esphome/components/zigbee/zigbee_sensor_zephyr.h @@ -69,7 +69,7 @@ void zb_zcl_analog_input_init_client(); namespace esphome::zigbee { -class ZigbeeSensor : public ZigbeeEntity, public Component { +class ZigbeeSensor final : public ZigbeeEntity, public Component { public: explicit ZigbeeSensor(sensor::Sensor *sensor); void set_cluster_attributes(AnalogAttrs &cluster_attributes) { this->cluster_attributes_ = &cluster_attributes; } diff --git a/esphome/components/zigbee/zigbee_switch_zephyr.h b/esphome/components/zigbee/zigbee_switch_zephyr.h index b774c23b3c..d2f71ce665 100644 --- a/esphome/components/zigbee/zigbee_switch_zephyr.h +++ b/esphome/components/zigbee/zigbee_switch_zephyr.h @@ -63,7 +63,7 @@ void zb_zcl_binary_output_init_client(); namespace esphome::zigbee { -class ZigbeeSwitch : public ZigbeeEntity, public Component { +class ZigbeeSwitch final : public ZigbeeEntity, public Component { public: ZigbeeSwitch(switch_::Switch *s) : switch_(s) {} void set_cluster_attributes(BinaryAttrs &cluster_attributes) { this->cluster_attributes_ = &cluster_attributes; } diff --git a/esphome/components/zigbee/zigbee_zephyr.h b/esphome/components/zigbee/zigbee_zephyr.h index d462d2a403..3b4a465361 100644 --- a/esphome/components/zigbee/zigbee_zephyr.h +++ b/esphome/components/zigbee/zigbee_zephyr.h @@ -66,7 +66,7 @@ struct AnalogAttrsOutput : AnalogAttrs { float resolution; }; -class ZigbeeComponent : public Component { +class ZigbeeComponent final : public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/zio_ultrasonic/zio_ultrasonic.h b/esphome/components/zio_ultrasonic/zio_ultrasonic.h index d4d2ac974f..1dbce87307 100644 --- a/esphome/components/zio_ultrasonic/zio_ultrasonic.h +++ b/esphome/components/zio_ultrasonic/zio_ultrasonic.h @@ -8,7 +8,7 @@ static const char *const TAG = "Zio Ultrasonic"; namespace esphome::zio_ultrasonic { -class ZioUltrasonicComponent : public i2c::I2CDevice, public PollingComponent, public sensor::Sensor { +class ZioUltrasonicComponent final : public i2c::I2CDevice, public PollingComponent, public sensor::Sensor { public: void dump_config() override; diff --git a/esphome/components/zwave_proxy/zwave_proxy.h b/esphome/components/zwave_proxy/zwave_proxy.h index dc5dc46abc..ec52b15cd9 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.h +++ b/esphome/components/zwave_proxy/zwave_proxy.h @@ -49,7 +49,7 @@ enum ZWaveProxyFeature : uint32_t { FEATURE_ZWAVE_PROXY_ENABLED = 1 << 0, }; -class ZWaveProxy : public uart::UARTDevice, public Component { +class ZWaveProxy final : public uart::UARTDevice, public Component { public: ZWaveProxy(); diff --git a/esphome/components/zyaura/zyaura.h b/esphome/components/zyaura/zyaura.h index 7c7954dec2..5a451ea463 100644 --- a/esphome/components/zyaura/zyaura.h +++ b/esphome/components/zyaura/zyaura.h @@ -57,7 +57,7 @@ class ZaSensorStore { }; /// Component for reading temperature/co2/humidity measurements from ZyAura sensors. -class ZyAuraSensor : public PollingComponent { +class ZyAuraSensor final : public PollingComponent { public: void set_pin_clock(InternalGPIOPin *pin) { pin_clock_ = pin; } void set_pin_data(InternalGPIOPin *pin) { pin_data_ = pin; } From 73dbc8214bbad4cdb068b5f8a25744d1a085989d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 09:51:31 +1200 Subject: [PATCH 0519/1815] Mark configurable classes as final (4/21: chsc6x-dfplayer) (#16955) --- esphome/components/chsc6x/chsc6x_touchscreen.h | 2 +- esphome/components/climate/automation.h | 6 +++--- .../components/climate_ir_lg/climate_ir_lg.h | 2 +- esphome/components/cm1106/cm1106.h | 4 ++-- .../color_temperature/ct_light_output.h | 2 +- esphome/components/combination/combination.h | 18 +++++++++--------- esphome/components/coolix/coolix.h | 2 +- .../copy/binary_sensor/copy_binary_sensor.h | 2 +- esphome/components/copy/button/copy_button.h | 2 +- esphome/components/copy/cover/copy_cover.h | 2 +- esphome/components/copy/fan/copy_fan.h | 2 +- esphome/components/copy/lock/copy_lock.h | 2 +- esphome/components/copy/number/copy_number.h | 2 +- esphome/components/copy/select/copy_select.h | 2 +- esphome/components/copy/sensor/copy_sensor.h | 2 +- esphome/components/copy/switch/copy_switch.h | 2 +- esphome/components/copy/text/copy_text.h | 2 +- .../copy/text_sensor/copy_text_sensor.h | 2 +- esphome/components/cover/automation.h | 18 +++++++++--------- esphome/components/cs5460a/cs5460a.h | 8 ++++---- esphome/components/cse7761/cse7761.h | 2 +- esphome/components/cse7766/cse7766.h | 2 +- .../cst226/binary_sensor/cs226_button.h | 8 ++++---- .../cst226/touchscreen/cst226_touchscreen.h | 2 +- .../cst816/touchscreen/cst816_touchscreen.h | 2 +- esphome/components/ct_clamp/ct_clamp_sensor.h | 2 +- .../current_based/current_based_cover.h | 2 +- esphome/components/cwww/cwww_light_output.h | 2 +- esphome/components/dac7678/dac7678_output.h | 4 ++-- esphome/components/daikin/daikin.h | 2 +- esphome/components/daikin_arc/daikin_arc.h | 2 +- esphome/components/daikin_brc/daikin_brc.h | 2 +- esphome/components/dallas_temp/dallas_temp.h | 2 +- esphome/components/daly_bms/daly_bms.h | 2 +- esphome/components/datetime/date_entity.h | 2 +- esphome/components/datetime/datetime_base.h | 2 +- esphome/components/datetime/datetime_entity.h | 4 ++-- esphome/components/datetime/time_entity.h | 4 ++-- esphome/components/debug/debug_component.h | 2 +- .../deep_sleep/deep_sleep_component.h | 9 +++++---- esphome/components/delonghi/delonghi.h | 2 +- .../components/demo/demo_alarm_control_panel.h | 2 +- esphome/components/demo/demo_binary_sensor.h | 2 +- esphome/components/demo/demo_button.h | 2 +- esphome/components/demo/demo_climate.h | 2 +- esphome/components/demo/demo_cover.h | 2 +- esphome/components/demo/demo_date.h | 2 +- esphome/components/demo/demo_datetime.h | 2 +- esphome/components/demo/demo_fan.h | 2 +- esphome/components/demo/demo_light.h | 2 +- esphome/components/demo/demo_lock.h | 2 +- esphome/components/demo/demo_number.h | 2 +- esphome/components/demo/demo_select.h | 2 +- esphome/components/demo/demo_sensor.h | 2 +- esphome/components/demo/demo_switch.h | 2 +- esphome/components/demo/demo_text.h | 2 +- esphome/components/demo/demo_text_sensor.h | 2 +- esphome/components/demo/demo_time.h | 2 +- esphome/components/demo/demo_valve.h | 2 +- esphome/components/dew_point/dew_point.h | 2 +- esphome/components/dfplayer/dfplayer.h | 16 ++++++++-------- 61 files changed, 100 insertions(+), 99 deletions(-) diff --git a/esphome/components/chsc6x/chsc6x_touchscreen.h b/esphome/components/chsc6x/chsc6x_touchscreen.h index 32077b3d33..84e539e5f2 100644 --- a/esphome/components/chsc6x/chsc6x_touchscreen.h +++ b/esphome/components/chsc6x/chsc6x_touchscreen.h @@ -17,7 +17,7 @@ static const uint8_t CHSC6X_REG_STATUS_Y_COR = 0x04; static const uint8_t CHSC6X_REG_STATUS_LEN = 0x05; static const uint8_t CHSC6X_CHIP_ID = 0x2e; -class CHSC6XTouchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { +class CHSC6XTouchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { public: void setup() override; void update_touches() override; diff --git a/esphome/components/climate/automation.h b/esphome/components/climate/automation.h index 6ac9bd8bae..a8d6d778ae 100644 --- a/esphome/components/climate/automation.h +++ b/esphome/components/climate/automation.h @@ -17,7 +17,7 @@ namespace esphome::climate { // (e.g. `const T & &` if Ts already carries a reference, or `const const // T &` if Ts already carries a const). This keeps trigger args no-copy // regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class ControlAction : public Action { +template class ControlAction final : public Action { public: using ApplyFn = void (*)(ClimateCall &, const std::remove_cvref_t &...); ControlAction(Climate *climate, ApplyFn apply) : climate_(climate), apply_(apply) {} @@ -33,14 +33,14 @@ template class ControlAction : public Action { ApplyFn apply_; }; -class ControlTrigger : public Trigger { +class ControlTrigger final : public Trigger { public: ControlTrigger(Climate *climate) { climate->add_on_control_callback([this](ClimateCall &x) { this->trigger(x); }); } }; -class StateTrigger : public Trigger { +class StateTrigger final : public Trigger { public: StateTrigger(Climate *climate) { climate->add_on_state_callback([this](Climate &x) { this->trigger(x); }); diff --git a/esphome/components/climate_ir_lg/climate_ir_lg.h b/esphome/components/climate_ir_lg/climate_ir_lg.h index a09da65ac6..341f0a4ef1 100644 --- a/esphome/components/climate_ir_lg/climate_ir_lg.h +++ b/esphome/components/climate_ir_lg/climate_ir_lg.h @@ -10,7 +10,7 @@ namespace esphome::climate_ir_lg { const uint8_t TEMP_MIN = 18; // Celsius const uint8_t TEMP_MAX = 30; // Celsius -class LgIrClimate : public climate_ir::ClimateIR { +class LgIrClimate final : public climate_ir::ClimateIR { public: LgIrClimate() : climate_ir::ClimateIR(TEMP_MIN, TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/cm1106/cm1106.h b/esphome/components/cm1106/cm1106.h index 047e91d632..844bfdfa88 100644 --- a/esphome/components/cm1106/cm1106.h +++ b/esphome/components/cm1106/cm1106.h @@ -7,7 +7,7 @@ namespace esphome::cm1106 { -class CM1106Component : public PollingComponent, public uart::UARTDevice { +class CM1106Component final : public PollingComponent, public uart::UARTDevice { public: void setup() override; void update() override; @@ -23,7 +23,7 @@ class CM1106Component : public PollingComponent, public uart::UARTDevice { bool cm1106_write_command_(const uint8_t *command, size_t command_len, uint8_t *response, size_t response_len); }; -template class CM1106CalibrateZeroAction : public Action { +template class CM1106CalibrateZeroAction final : public Action { public: CM1106CalibrateZeroAction(CM1106Component *cm1106) : cm1106_(cm1106) {} diff --git a/esphome/components/color_temperature/ct_light_output.h b/esphome/components/color_temperature/ct_light_output.h index a4da1011b0..51ca21465e 100644 --- a/esphome/components/color_temperature/ct_light_output.h +++ b/esphome/components/color_temperature/ct_light_output.h @@ -6,7 +6,7 @@ namespace esphome::color_temperature { -class CTLightOutput : public light::LightOutput { +class CTLightOutput final : public light::LightOutput { public: void set_color_temperature(output::FloatOutput *color_temperature) { color_temperature_ = color_temperature; } void set_brightness(output::FloatOutput *brightness) { brightness_ = brightness; } diff --git a/esphome/components/combination/combination.h b/esphome/components/combination/combination.h index 34e9e4e2c6..00745663e0 100644 --- a/esphome/components/combination/combination.h +++ b/esphome/components/combination/combination.h @@ -58,7 +58,7 @@ class CombinationOneParameterComponent : public CombinationComponent { FixedVector sensor_sources_; }; -class KalmanCombinationComponent : public CombinationOneParameterComponent { +class KalmanCombinationComponent final : public CombinationOneParameterComponent { public: void dump_config() override; void setup() override; @@ -85,7 +85,7 @@ class KalmanCombinationComponent : public CombinationOneParameterComponent { float variance_{INFINITY}; }; -class LinearCombinationComponent : public CombinationOneParameterComponent { +class LinearCombinationComponent final : public CombinationOneParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("linear")); } void setup() override; @@ -93,49 +93,49 @@ class LinearCombinationComponent : public CombinationOneParameterComponent { void handle_new_value(float value); }; -class MaximumCombinationComponent : public CombinationNoParameterComponent { +class MaximumCombinationComponent final : public CombinationNoParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("max")); } void handle_new_value(float value) override; }; -class MeanCombinationComponent : public CombinationNoParameterComponent { +class MeanCombinationComponent final : public CombinationNoParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("mean")); } void handle_new_value(float value) override; }; -class MedianCombinationComponent : public CombinationNoParameterComponent { +class MedianCombinationComponent final : public CombinationNoParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("median")); } void handle_new_value(float value) override; }; -class MinimumCombinationComponent : public CombinationNoParameterComponent { +class MinimumCombinationComponent final : public CombinationNoParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("min")); } void handle_new_value(float value) override; }; -class MostRecentCombinationComponent : public CombinationNoParameterComponent { +class MostRecentCombinationComponent final : public CombinationNoParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("most_recently_updated")); } void handle_new_value(float value) override; }; -class RangeCombinationComponent : public CombinationNoParameterComponent { +class RangeCombinationComponent final : public CombinationNoParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("range")); } void handle_new_value(float value) override; }; -class SumCombinationComponent : public CombinationNoParameterComponent { +class SumCombinationComponent final : public CombinationNoParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("sum")); } diff --git a/esphome/components/coolix/coolix.h b/esphome/components/coolix/coolix.h index 2d8862e2b6..6a59a58a92 100644 --- a/esphome/components/coolix/coolix.h +++ b/esphome/components/coolix/coolix.h @@ -10,7 +10,7 @@ namespace esphome::coolix { const uint8_t COOLIX_TEMP_MIN = 17; // Celsius const uint8_t COOLIX_TEMP_MAX = 30; // Celsius -class CoolixClimate : public climate_ir::ClimateIR { +class CoolixClimate final : public climate_ir::ClimateIR { public: CoolixClimate() : climate_ir::ClimateIR(COOLIX_TEMP_MIN, COOLIX_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/copy/binary_sensor/copy_binary_sensor.h b/esphome/components/copy/binary_sensor/copy_binary_sensor.h index a6ce705a2a..b30ca9cb21 100644 --- a/esphome/components/copy/binary_sensor/copy_binary_sensor.h +++ b/esphome/components/copy/binary_sensor/copy_binary_sensor.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyBinarySensor : public binary_sensor::BinarySensor, public Component { +class CopyBinarySensor final : public binary_sensor::BinarySensor, public Component { public: void set_source(binary_sensor::BinarySensor *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/button/copy_button.h b/esphome/components/copy/button/copy_button.h index afd783375d..bdefcc512a 100644 --- a/esphome/components/copy/button/copy_button.h +++ b/esphome/components/copy/button/copy_button.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyButton : public button::Button, public Component { +class CopyButton final : public button::Button, public Component { public: void set_source(button::Button *source) { source_ = source; } void dump_config() override; diff --git a/esphome/components/copy/cover/copy_cover.h b/esphome/components/copy/cover/copy_cover.h index 0b493e4c3b..008cbdf28e 100644 --- a/esphome/components/copy/cover/copy_cover.h +++ b/esphome/components/copy/cover/copy_cover.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyCover : public cover::Cover, public Component { +class CopyCover final : public cover::Cover, public Component { public: void set_source(cover::Cover *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/fan/copy_fan.h b/esphome/components/copy/fan/copy_fan.h index 9090c91095..4f882ba43d 100644 --- a/esphome/components/copy/fan/copy_fan.h +++ b/esphome/components/copy/fan/copy_fan.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyFan : public fan::Fan, public Component { +class CopyFan final : public fan::Fan, public Component { public: void set_source(fan::Fan *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/lock/copy_lock.h b/esphome/components/copy/lock/copy_lock.h index c6c46467a9..0177db1708 100644 --- a/esphome/components/copy/lock/copy_lock.h +++ b/esphome/components/copy/lock/copy_lock.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyLock : public lock::Lock, public Component { +class CopyLock final : public lock::Lock, public Component { public: void set_source(lock::Lock *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/number/copy_number.h b/esphome/components/copy/number/copy_number.h index b4d8bb83e6..82af6cc8ea 100644 --- a/esphome/components/copy/number/copy_number.h +++ b/esphome/components/copy/number/copy_number.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyNumber : public number::Number, public Component { +class CopyNumber final : public number::Number, public Component { public: void set_source(number::Number *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/select/copy_select.h b/esphome/components/copy/select/copy_select.h index 1a17c7a55a..2c541d99bc 100644 --- a/esphome/components/copy/select/copy_select.h +++ b/esphome/components/copy/select/copy_select.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopySelect : public select::Select, public Component { +class CopySelect final : public select::Select, public Component { public: void set_source(select::Select *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/sensor/copy_sensor.h b/esphome/components/copy/sensor/copy_sensor.h index d6e5026ce1..136c36de95 100644 --- a/esphome/components/copy/sensor/copy_sensor.h +++ b/esphome/components/copy/sensor/copy_sensor.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopySensor : public sensor::Sensor, public Component { +class CopySensor final : public sensor::Sensor, public Component { public: void set_source(sensor::Sensor *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/switch/copy_switch.h b/esphome/components/copy/switch/copy_switch.h index 9ce6b48ed1..bef254093c 100644 --- a/esphome/components/copy/switch/copy_switch.h +++ b/esphome/components/copy/switch/copy_switch.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopySwitch : public switch_::Switch, public Component { +class CopySwitch final : public switch_::Switch, public Component { public: void set_source(switch_::Switch *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/text/copy_text.h b/esphome/components/copy/text/copy_text.h index ad28936522..4dd3b5fe5e 100644 --- a/esphome/components/copy/text/copy_text.h +++ b/esphome/components/copy/text/copy_text.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyText : public text::Text, public Component { +class CopyText final : public text::Text, public Component { public: void set_source(text::Text *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/text_sensor/copy_text_sensor.h b/esphome/components/copy/text_sensor/copy_text_sensor.h index dc4ef7a29d..e27e3bc1d7 100644 --- a/esphome/components/copy/text_sensor/copy_text_sensor.h +++ b/esphome/components/copy/text_sensor/copy_text_sensor.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyTextSensor : public text_sensor::TextSensor, public Component { +class CopyTextSensor final : public text_sensor::TextSensor, public Component { public: void set_source(text_sensor::TextSensor *source) { source_ = source; } void setup() override; diff --git a/esphome/components/cover/automation.h b/esphome/components/cover/automation.h index ee7a4f5f76..0a5a447ab9 100644 --- a/esphome/components/cover/automation.h +++ b/esphome/components/cover/automation.h @@ -6,7 +6,7 @@ namespace esphome::cover { -template class OpenAction : public Action { +template class OpenAction final : public Action { public: explicit OpenAction(Cover *cover) : cover_(cover) {} @@ -16,7 +16,7 @@ template class OpenAction : public Action { Cover *cover_; }; -template class CloseAction : public Action { +template class CloseAction final : public Action { public: explicit CloseAction(Cover *cover) : cover_(cover) {} @@ -26,7 +26,7 @@ template class CloseAction : public Action { Cover *cover_; }; -template class StopAction : public Action { +template class StopAction final : public Action { public: explicit StopAction(Cover *cover) : cover_(cover) {} @@ -36,7 +36,7 @@ template class StopAction : public Action { Cover *cover_; }; -template class ToggleAction : public Action { +template class ToggleAction final : public Action { public: explicit ToggleAction(Cover *cover) : cover_(cover) {} @@ -59,7 +59,7 @@ template class ToggleAction : public Action { // T &` if Ts already carries a const). This keeps trigger args no-copy // regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class ControlAction : public Action { +template class ControlAction final : public Action { public: using ApplyFn = void (*)(CoverCall &, const std::remove_cvref_t &...); ControlAction(Cover *cover, ApplyFn apply) : cover_(cover), apply_(apply) {} @@ -75,7 +75,7 @@ template class ControlAction : public Action { ApplyFn apply_; }; -template class CoverPublishAction : public Action { +template class CoverPublishAction final : public Action { public: using ApplyFn = void (*)(Cover *, const std::remove_cvref_t &...); CoverPublishAction(Cover *cover, ApplyFn apply) : cover_(cover), apply_(apply) {} @@ -90,7 +90,7 @@ template class CoverPublishAction : public Action { ApplyFn apply_; }; -template class CoverPositionCondition : public Condition { +template class CoverPositionCondition final : public Condition { public: CoverPositionCondition(Cover *cover) : cover_(cover) {} @@ -103,7 +103,7 @@ template class CoverPositionCondition : public Condit template using CoverIsOpenCondition = CoverPositionCondition; template using CoverIsClosedCondition = CoverPositionCondition; -template class CoverPositionTrigger : public Trigger<> { +template class CoverPositionTrigger final : public Trigger<> { public: CoverPositionTrigger(Cover *a_cover) : cover_(a_cover) { a_cover->add_on_state_callback([this]() { @@ -123,7 +123,7 @@ template class CoverPositionTrigger : public Trigger<> { using CoverOpenedTrigger = CoverPositionTrigger; using CoverClosedTrigger = CoverPositionTrigger; -template class CoverTrigger : public Trigger<> { +template class CoverTrigger final : public Trigger<> { public: CoverTrigger(Cover *a_cover) : cover_(a_cover) { a_cover->add_on_state_callback([this]() { diff --git a/esphome/components/cs5460a/cs5460a.h b/esphome/components/cs5460a/cs5460a.h index c6b02f53ee..87ea858c70 100644 --- a/esphome/components/cs5460a/cs5460a.h +++ b/esphome/components/cs5460a/cs5460a.h @@ -52,9 +52,9 @@ enum CS5460APGAGain { CS5460A_PGA_GAIN_50X = 0b1, }; -class CS5460AComponent : public Component, - public spi::SPIDevice { +class CS5460AComponent final : public Component, + public spi::SPIDevice { public: void set_samples(uint32_t samples) { samples_ = samples; } void set_phase_offset(int8_t phase_offset) { phase_offset_ = phase_offset; } @@ -108,7 +108,7 @@ class CS5460AComponent : public Component, uint32_t prev_raw_energy_{0}; }; -template class CS5460ARestartAction : public Action { +template class CS5460ARestartAction final : public Action { public: CS5460ARestartAction(CS5460AComponent *cs5460a) : cs5460a_(cs5460a) {} diff --git a/esphome/components/cse7761/cse7761.h b/esphome/components/cse7761/cse7761.h index 5f683f424b..e08ebf09cc 100644 --- a/esphome/components/cse7761/cse7761.h +++ b/esphome/components/cse7761/cse7761.h @@ -16,7 +16,7 @@ struct CSE7761DataStruct { }; /// This class implements support for the CSE7761 UART power sensor. -class CSE7761Component : public PollingComponent, public uart::UARTDevice { +class CSE7761Component final : public PollingComponent, public uart::UARTDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_active_power_1_sensor(sensor::Sensor *power_sensor_1) { power_sensor_1_ = power_sensor_1; } diff --git a/esphome/components/cse7766/cse7766.h b/esphome/components/cse7766/cse7766.h index 77b80dd824..8a57816a59 100644 --- a/esphome/components/cse7766/cse7766.h +++ b/esphome/components/cse7766/cse7766.h @@ -9,7 +9,7 @@ namespace esphome::cse7766 { static constexpr size_t CSE7766_RAW_DATA_SIZE = 24; -class CSE7766Component : public Component, public uart::UARTDevice { +class CSE7766Component final : public Component, public uart::UARTDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_current_sensor(sensor::Sensor *current_sensor) { current_sensor_ = current_sensor; } diff --git a/esphome/components/cst226/binary_sensor/cs226_button.h b/esphome/components/cst226/binary_sensor/cs226_button.h index e7e334b9bb..ec07341e21 100644 --- a/esphome/components/cst226/binary_sensor/cs226_button.h +++ b/esphome/components/cst226/binary_sensor/cs226_button.h @@ -6,10 +6,10 @@ namespace esphome::cst226 { -class CST226Button : public binary_sensor::BinarySensor, - public Component, - public CST226ButtonListener, - public Parented { +class CST226Button final : public binary_sensor::BinarySensor, + public Component, + public CST226ButtonListener, + public Parented { public: void setup() override; void dump_config() override; diff --git a/esphome/components/cst226/touchscreen/cst226_touchscreen.h b/esphome/components/cst226/touchscreen/cst226_touchscreen.h index 362eee5fc2..c68c50fb44 100644 --- a/esphome/components/cst226/touchscreen/cst226_touchscreen.h +++ b/esphome/components/cst226/touchscreen/cst226_touchscreen.h @@ -15,7 +15,7 @@ class CST226ButtonListener { virtual void update_button(bool state) = 0; }; -class CST226Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { +class CST226Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { public: void setup() override; void update_touches() override; diff --git a/esphome/components/cst816/touchscreen/cst816_touchscreen.h b/esphome/components/cst816/touchscreen/cst816_touchscreen.h index 19c169c3ec..84b561c734 100644 --- a/esphome/components/cst816/touchscreen/cst816_touchscreen.h +++ b/esphome/components/cst816/touchscreen/cst816_touchscreen.h @@ -37,7 +37,7 @@ class CST816ButtonListener { virtual void update_button(bool state) = 0; }; -class CST816Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { +class CST816Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { public: void setup() override; void update_touches() override; diff --git a/esphome/components/ct_clamp/ct_clamp_sensor.h b/esphome/components/ct_clamp/ct_clamp_sensor.h index 2055edbd3e..ae77c04390 100644 --- a/esphome/components/ct_clamp/ct_clamp_sensor.h +++ b/esphome/components/ct_clamp/ct_clamp_sensor.h @@ -7,7 +7,7 @@ namespace esphome::ct_clamp { -class CTClampSensor : public sensor::Sensor, public PollingComponent { +class CTClampSensor final : public sensor::Sensor, public PollingComponent { public: void update() override; void loop() override; diff --git a/esphome/components/current_based/current_based_cover.h b/esphome/components/current_based/current_based_cover.h index 531f8d5a4f..41dd9962b7 100644 --- a/esphome/components/current_based/current_based_cover.h +++ b/esphome/components/current_based/current_based_cover.h @@ -8,7 +8,7 @@ namespace esphome::current_based { -class CurrentBasedCover : public cover::Cover, public Component { +class CurrentBasedCover final : public cover::Cover, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/cwww/cwww_light_output.h b/esphome/components/cwww/cwww_light_output.h index 6eed8de7cc..aea844008f 100644 --- a/esphome/components/cwww/cwww_light_output.h +++ b/esphome/components/cwww/cwww_light_output.h @@ -6,7 +6,7 @@ namespace esphome::cwww { -class CWWWLightOutput : public light::LightOutput { +class CWWWLightOutput final : public light::LightOutput { public: void set_cold_white(output::FloatOutput *cold_white) { cold_white_ = cold_white; } void set_warm_white(output::FloatOutput *warm_white) { warm_white_ = warm_white; } diff --git a/esphome/components/dac7678/dac7678_output.h b/esphome/components/dac7678/dac7678_output.h index a017325939..00021e947f 100644 --- a/esphome/components/dac7678/dac7678_output.h +++ b/esphome/components/dac7678/dac7678_output.h @@ -9,7 +9,7 @@ namespace esphome::dac7678 { class DAC7678Output; -class DAC7678Channel : public output::FloatOutput, public Parented { +class DAC7678Channel final : public output::FloatOutput, public Parented { public: void set_channel(uint8_t channel) { channel_ = channel; } @@ -24,7 +24,7 @@ class DAC7678Channel : public output::FloatOutput, public Parented day_; }; -template class DateSetAction : public Action, public Parented { +template class DateSetAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(ESPTime, date) diff --git a/esphome/components/datetime/datetime_base.h b/esphome/components/datetime/datetime_base.h index 6c0a33c842..f99debb692 100644 --- a/esphome/components/datetime/datetime_base.h +++ b/esphome/components/datetime/datetime_base.h @@ -31,7 +31,7 @@ class DateTimeBase : public EntityBase { #endif }; -class DateTimeStateTrigger : public Trigger { +class DateTimeStateTrigger final : public Trigger { public: explicit DateTimeStateTrigger(DateTimeBase *parent) : parent_(parent) { parent->add_on_state_callback([this]() { this->trigger(this->parent_->state_as_esptime()); }); diff --git a/esphome/components/datetime/datetime_entity.h b/esphome/components/datetime/datetime_entity.h index b1b8a77846..159e4ccc6f 100644 --- a/esphome/components/datetime/datetime_entity.h +++ b/esphome/components/datetime/datetime_entity.h @@ -121,7 +121,7 @@ class DateTimeCall { optional second_; }; -template class DateTimeSetAction : public Action, public Parented { +template class DateTimeSetAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(ESPTime, datetime) @@ -136,7 +136,7 @@ template class DateTimeSetAction : public Action, public }; #ifdef USE_TIME -class OnDateTimeTrigger : public Trigger<>, public Component, public Parented { +class OnDateTimeTrigger final : public Trigger<>, public Component, public Parented { public: void loop() override; diff --git a/esphome/components/datetime/time_entity.h b/esphome/components/datetime/time_entity.h index 3f224684bb..643f4bd176 100644 --- a/esphome/components/datetime/time_entity.h +++ b/esphome/components/datetime/time_entity.h @@ -98,7 +98,7 @@ class TimeCall { optional second_; }; -template class TimeSetAction : public Action, public Parented { +template class TimeSetAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(ESPTime, time) @@ -113,7 +113,7 @@ template class TimeSetAction : public Action, public Pare }; #ifdef USE_TIME -class OnTimeTrigger : public Trigger<>, public Component, public Parented { +class OnTimeTrigger final : public Trigger<>, public Component, public Parented { public: void loop() override; diff --git a/esphome/components/debug/debug_component.h b/esphome/components/debug/debug_component.h index 871b7cfd25..20798cf600 100644 --- a/esphome/components/debug/debug_component.h +++ b/esphome/components/debug/debug_component.h @@ -21,7 +21,7 @@ static constexpr size_t WAKEUP_CAUSE_BUFFER_SIZE = 128; // buf_append_printf is now provided by esphome/core/helpers.h -class DebugComponent : public PollingComponent { +class DebugComponent final : public PollingComponent { public: void loop() override; void update() override; diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 2df53f1540..8edda040d3 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -70,7 +70,7 @@ template class PreventDeepSleepAction; * and set_run_duration, then set how long the deep sleep should last using set_sleep_duration and optionally * on the ESP32 set_wakeup_pin. */ -class DeepSleepComponent : public Component { +class DeepSleepComponent final : public Component { public: /// Set the duration in ms the component should sleep once it's in deep sleep mode. void set_sleep_duration(uint32_t time_ms); @@ -161,7 +161,7 @@ class DeepSleepComponent : public Component { extern bool global_has_deep_sleep; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -template class EnterDeepSleepAction : public Action { +template class EnterDeepSleepAction final : public Action { public: EnterDeepSleepAction(DeepSleepComponent *deep_sleep) : deep_sleep_(deep_sleep) {} TEMPLATABLE_VALUE(uint32_t, sleep_duration); @@ -233,12 +233,13 @@ template class EnterDeepSleepAction : public Action { #endif }; -template class PreventDeepSleepAction : public Action, public Parented { +template +class PreventDeepSleepAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->prevent_deep_sleep(); } }; -template class AllowDeepSleepAction : public Action, public Parented { +template class AllowDeepSleepAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->allow_deep_sleep(); } }; diff --git a/esphome/components/delonghi/delonghi.h b/esphome/components/delonghi/delonghi.h index c2fbc36b4f..aee7ceedda 100644 --- a/esphome/components/delonghi/delonghi.h +++ b/esphome/components/delonghi/delonghi.h @@ -39,7 +39,7 @@ const uint32_t DELONGHI_ZERO_SPACE = 670; // State Frame size const uint8_t DELONGHI_STATE_FRAME_SIZE = 8; -class DelonghiClimate : public climate_ir::ClimateIR { +class DelonghiClimate final : public climate_ir::ClimateIR { public: DelonghiClimate() : climate_ir::ClimateIR(DELONGHI_TEMP_MIN, DELONGHI_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/demo/demo_alarm_control_panel.h b/esphome/components/demo/demo_alarm_control_panel.h index 7aaf3219cf..e85d2a17ba 100644 --- a/esphome/components/demo/demo_alarm_control_panel.h +++ b/esphome/components/demo/demo_alarm_control_panel.h @@ -13,7 +13,7 @@ enum class DemoAlarmControlPanelType { TYPE_3, }; -class DemoAlarmControlPanel : public AlarmControlPanel, public Component { +class DemoAlarmControlPanel final : public AlarmControlPanel, public Component { public: void setup() override {} diff --git a/esphome/components/demo/demo_binary_sensor.h b/esphome/components/demo/demo_binary_sensor.h index 4bc3737d5a..6a98a6781b 100644 --- a/esphome/components/demo/demo_binary_sensor.h +++ b/esphome/components/demo/demo_binary_sensor.h @@ -5,7 +5,7 @@ namespace esphome::demo { -class DemoBinarySensor : public binary_sensor::BinarySensor, public PollingComponent { +class DemoBinarySensor final : public binary_sensor::BinarySensor, public PollingComponent { public: void setup() override { this->publish_initial_state(false); } void update() override { diff --git a/esphome/components/demo/demo_button.h b/esphome/components/demo/demo_button.h index a0ed92d3d8..907136cfc6 100644 --- a/esphome/components/demo/demo_button.h +++ b/esphome/components/demo/demo_button.h @@ -5,7 +5,7 @@ namespace esphome::demo { -class DemoButton : public button::Button { +class DemoButton final : public button::Button { protected: void press_action() override {} }; diff --git a/esphome/components/demo/demo_climate.h b/esphome/components/demo/demo_climate.h index d0cd2d553d..20affb909f 100644 --- a/esphome/components/demo/demo_climate.h +++ b/esphome/components/demo/demo_climate.h @@ -11,7 +11,7 @@ enum class DemoClimateType { TYPE_3, }; -class DemoClimate : public climate::Climate, public Component { +class DemoClimate final : public climate::Climate, public Component { public: void set_type(DemoClimateType type) { type_ = type; } void setup() override { diff --git a/esphome/components/demo/demo_cover.h b/esphome/components/demo/demo_cover.h index c1597a7565..aa12c885f4 100644 --- a/esphome/components/demo/demo_cover.h +++ b/esphome/components/demo/demo_cover.h @@ -12,7 +12,7 @@ enum class DemoCoverType { TYPE_4, }; -class DemoCover : public cover::Cover, public Component { +class DemoCover final : public cover::Cover, public Component { public: void set_type(DemoCoverType type) { type_ = type; } void setup() override { diff --git a/esphome/components/demo/demo_date.h b/esphome/components/demo/demo_date.h index 5a868342cd..f724c82435 100644 --- a/esphome/components/demo/demo_date.h +++ b/esphome/components/demo/demo_date.h @@ -9,7 +9,7 @@ namespace esphome::demo { -class DemoDate : public datetime::DateEntity, public Component { +class DemoDate final : public datetime::DateEntity, public Component { public: void setup() override { this->year_ = 2038; diff --git a/esphome/components/demo/demo_datetime.h b/esphome/components/demo/demo_datetime.h index 84869d1a9f..363592c554 100644 --- a/esphome/components/demo/demo_datetime.h +++ b/esphome/components/demo/demo_datetime.h @@ -9,7 +9,7 @@ namespace esphome::demo { -class DemoDateTime : public datetime::DateTimeEntity, public Component { +class DemoDateTime final : public datetime::DateTimeEntity, public Component { public: void setup() override { this->year_ = 2038; diff --git a/esphome/components/demo/demo_fan.h b/esphome/components/demo/demo_fan.h index 2e2fbce7d6..be44c06ca2 100644 --- a/esphome/components/demo/demo_fan.h +++ b/esphome/components/demo/demo_fan.h @@ -12,7 +12,7 @@ enum class DemoFanType { TYPE_4, }; -class DemoFan : public fan::Fan, public Component { +class DemoFan final : public fan::Fan, public Component { public: void set_type(DemoFanType type) { type_ = type; } fan::FanTraits get_traits() override { diff --git a/esphome/components/demo/demo_light.h b/esphome/components/demo/demo_light.h index 071adb0831..4a48a1796e 100644 --- a/esphome/components/demo/demo_light.h +++ b/esphome/components/demo/demo_light.h @@ -22,7 +22,7 @@ enum class DemoLightType { TYPE_7, }; -class DemoLight : public light::LightOutput, public Component { +class DemoLight final : public light::LightOutput, public Component { public: void set_type(DemoLightType type) { type_ = type; } light::LightTraits get_traits() override { diff --git a/esphome/components/demo/demo_lock.h b/esphome/components/demo/demo_lock.h index 85c1c238ef..473fe1a68e 100644 --- a/esphome/components/demo/demo_lock.h +++ b/esphome/components/demo/demo_lock.h @@ -4,7 +4,7 @@ namespace esphome::demo { -class DemoLock : public lock::Lock { +class DemoLock final : public lock::Lock { protected: void control(const lock::LockCall &call) override { auto state = call.get_state(); diff --git a/esphome/components/demo/demo_number.h b/esphome/components/demo/demo_number.h index 0059cdc2ee..f66aef1aff 100644 --- a/esphome/components/demo/demo_number.h +++ b/esphome/components/demo/demo_number.h @@ -11,7 +11,7 @@ enum class DemoNumberType { TYPE_3, }; -class DemoNumber : public number::Number, public Component { +class DemoNumber final : public number::Number, public Component { public: void set_type(DemoNumberType type) { type_ = type; } void setup() override { diff --git a/esphome/components/demo/demo_select.h b/esphome/components/demo/demo_select.h index 2ecb37db99..de57f2f024 100644 --- a/esphome/components/demo/demo_select.h +++ b/esphome/components/demo/demo_select.h @@ -5,7 +5,7 @@ namespace esphome::demo { -class DemoSelect : public select::Select, public Component { +class DemoSelect final : public select::Select, public Component { protected: void control(size_t index) override { this->publish_state(index); } }; diff --git a/esphome/components/demo/demo_sensor.h b/esphome/components/demo/demo_sensor.h index 867115f21b..6153c810e1 100644 --- a/esphome/components/demo/demo_sensor.h +++ b/esphome/components/demo/demo_sensor.h @@ -6,7 +6,7 @@ namespace esphome::demo { -class DemoSensor : public sensor::Sensor, public PollingComponent { +class DemoSensor final : public sensor::Sensor, public PollingComponent { public: void update() override { float val = random_float(); diff --git a/esphome/components/demo/demo_switch.h b/esphome/components/demo/demo_switch.h index b2d6e52c67..6846b8b663 100644 --- a/esphome/components/demo/demo_switch.h +++ b/esphome/components/demo/demo_switch.h @@ -6,7 +6,7 @@ namespace esphome::demo { -class DemoSwitch : public switch_::Switch, public Component { +class DemoSwitch final : public switch_::Switch, public Component { public: void setup() override { bool initial = random_float() < 0.5; diff --git a/esphome/components/demo/demo_text.h b/esphome/components/demo/demo_text.h index 56376c8c42..66dd5bc3eb 100644 --- a/esphome/components/demo/demo_text.h +++ b/esphome/components/demo/demo_text.h @@ -5,7 +5,7 @@ namespace esphome::demo { -class DemoText : public text::Text, public Component { +class DemoText final : public text::Text, public Component { public: void setup() override { this->publish_state("I am a text entity"); } diff --git a/esphome/components/demo/demo_text_sensor.h b/esphome/components/demo/demo_text_sensor.h index 03852a1e7f..fa728903d9 100644 --- a/esphome/components/demo/demo_text_sensor.h +++ b/esphome/components/demo/demo_text_sensor.h @@ -6,7 +6,7 @@ namespace esphome::demo { -class DemoTextSensor : public text_sensor::TextSensor, public PollingComponent { +class DemoTextSensor final : public text_sensor::TextSensor, public PollingComponent { public: void update() override { float val = random_float(); diff --git a/esphome/components/demo/demo_time.h b/esphome/components/demo/demo_time.h index f94678fae4..90384b3216 100644 --- a/esphome/components/demo/demo_time.h +++ b/esphome/components/demo/demo_time.h @@ -9,7 +9,7 @@ namespace esphome::demo { -class DemoTime : public datetime::TimeEntity, public Component { +class DemoTime final : public datetime::TimeEntity, public Component { public: void setup() override { this->hour_ = 3; diff --git a/esphome/components/demo/demo_valve.h b/esphome/components/demo/demo_valve.h index 3f1342959a..22183b75e8 100644 --- a/esphome/components/demo/demo_valve.h +++ b/esphome/components/demo/demo_valve.h @@ -9,7 +9,7 @@ enum class DemoValveType { TYPE_2, }; -class DemoValve : public valve::Valve { +class DemoValve final : public valve::Valve { public: valve::ValveTraits get_traits() override { valve::ValveTraits traits; diff --git a/esphome/components/dew_point/dew_point.h b/esphome/components/dew_point/dew_point.h index 833c50fba2..0e97b22a04 100644 --- a/esphome/components/dew_point/dew_point.h +++ b/esphome/components/dew_point/dew_point.h @@ -5,7 +5,7 @@ namespace esphome::dew_point { -class DewPointComponent : public Component, public sensor::Sensor { +class DewPointComponent final : public Component, public sensor::Sensor { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } diff --git a/esphome/components/dfplayer/dfplayer.h b/esphome/components/dfplayer/dfplayer.h index 5936a06b60..1db6b394c5 100644 --- a/esphome/components/dfplayer/dfplayer.h +++ b/esphome/components/dfplayer/dfplayer.h @@ -24,7 +24,7 @@ enum Device { // See the datasheet here: // https://github.com/DFRobot/DFRobotDFPlayerMini/blob/master/doc/FN-M16P%2BEmbedded%2BMP3%2BAudio%2BModule%2BDatasheet.pdf -class DFPlayer : public uart::UARTDevice, public Component { +class DFPlayer final : public uart::UARTDevice, public Component { public: void loop() override; @@ -82,7 +82,7 @@ class DFPlayer : public uart::UARTDevice, public Component { DFPLAYER_SIMPLE_ACTION(NextAction, next) DFPLAYER_SIMPLE_ACTION(PreviousAction, previous) -template class PlayMp3Action : public Action, public Parented { +template class PlayMp3Action final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, file) @@ -92,7 +92,7 @@ template class PlayMp3Action : public Action, public Pare } }; -template class PlayFileAction : public Action, public Parented { +template class PlayFileAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, file) TEMPLATABLE_VALUE(bool, loop) @@ -108,7 +108,7 @@ template class PlayFileAction : public Action, public Par } }; -template class PlayFolderAction : public Action, public Parented { +template class PlayFolderAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, folder) TEMPLATABLE_VALUE(uint16_t, file) @@ -126,7 +126,7 @@ template class PlayFolderAction : public Action, public P } }; -template class SetDeviceAction : public Action, public Parented { +template class SetDeviceAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(Device, device) @@ -136,7 +136,7 @@ template class SetDeviceAction : public Action, public Pa } }; -template class SetVolumeAction : public Action, public Parented { +template class SetVolumeAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, volume) @@ -146,7 +146,7 @@ template class SetVolumeAction : public Action, public Pa } }; -template class SetEqAction : public Action, public Parented { +template class SetEqAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(EqPreset, eq) @@ -165,7 +165,7 @@ DFPLAYER_SIMPLE_ACTION(RandomAction, random) DFPLAYER_SIMPLE_ACTION(VolumeUpAction, volume_up) DFPLAYER_SIMPLE_ACTION(VolumeDownAction, volume_down) -template class DFPlayerIsPlayingCondition : public Condition, public Parented { +template class DFPlayerIsPlayingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_playing(); } }; From 77a91853beb6b448174a70cb210b20a09741c690 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sun, 21 Jun 2026 17:52:05 -0400 Subject: [PATCH 0520/1815] [i2s_audio] Narrow wider streams to the speaker's configured bit depth (#16821) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .../components/i2s_audio/speaker/__init__.py | 15 ++- .../i2s_audio/speaker/i2s_audio_spdif.cpp | 2 + .../i2s_audio/speaker/i2s_audio_speaker.cpp | 2 +- .../i2s_audio/speaker/i2s_audio_speaker.h | 9 +- .../speaker/i2s_audio_speaker_standard.cpp | 116 ++++++++++++------ 5 files changed, 99 insertions(+), 45 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index 5ba2f4b1a5..6d3c39c68e 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -104,23 +104,26 @@ def _set_stream_limits(config): # stream it accepts is 16-bit (see start_i2s_driver); the other variants handle 8-bit. min_bits_per_sample = 16 if esp32.get_esp32_variant() == esp32.VARIANT_ESP32 else 8 + # The configured bits per sample sets the I2S slot width, but the speaker narrows wider streams down to it + # in place before clocking them out (see start_i2s_driver). Advertise up to 32-bit so those wider streams + # are accepted rather than forcing an upstream conversion. + max_bits_per_sample = 32 + if config[CONF_I2S_MODE] == CONF_PRIMARY: - # Primary mode can reconfigure the bus to the incoming sample rate and channel count, but the - # configured bits per sample is a hard ceiling: the speaker rejects any stream that exceeds the - # slot bit width it was set up with (see start_i2s_driver), so advertise that as the maximum. + # Primary mode can reconfigure the bus to the incoming sample rate and channel count. audio.set_stream_limits( min_bits_per_sample=min_bits_per_sample, - max_bits_per_sample=config[CONF_BITS_PER_SAMPLE], + max_bits_per_sample=max_bits_per_sample, min_channels=1, max_channels=2, min_sample_rate=16000, max_sample_rate=48000, )(config) else: - # Secondary mode has unmodifiable max bits per sample and min/max sample rates + # Secondary mode has unmodifiable min/max sample rates audio.set_stream_limits( min_bits_per_sample=min_bits_per_sample, - max_bits_per_sample=config[CONF_BITS_PER_SAMPLE], + max_bits_per_sample=max_bits_per_sample, min_channels=1, max_channels=2, min_sample_rate=config.get(CONF_SAMPLE_RATE), diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp index 989bcf2977..ed5145d4b0 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -404,6 +404,8 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) { this->current_stream_info_ = audio_stream_info; + // SPDIF never narrows the bit depth; the encoder consumes the input format directly. + this->output_stream_info_ = audio_stream_info; // SPDIF mode validation if (this->sample_rate_ != audio_stream_info.get_sample_rate()) { diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 691f68e912..c6ff42495f 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -354,7 +354,7 @@ void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_rea void I2SAudioSpeakerBase::swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read) { #ifdef USE_ESP32_VARIANT_ESP32 // For ESP32 16-bit mono mode, adjacent samples need to be swapped. - if (this->current_stream_info_.get_channels() == 1 && this->current_stream_info_.get_bits_per_sample() == 16) { + if (this->output_stream_info_.get_channels() == 1 && this->output_stream_info_.get_bits_per_sample() == 16) { int16_t *samples = reinterpret_cast(data); size_t sample_count = bytes_read / sizeof(int16_t); for (size_t i = 0; i + 1 < sample_count; i += 2) { diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index 34792bdbea..adb6ca5e3f 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -134,7 +134,8 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public void apply_software_volume_(uint8_t *data, size_t bytes_read); /// @brief Swap adjacent 16-bit mono samples for ESP32 (non-variant) hardware quirk. - /// Only applies when running on original ESP32 with 16-bit mono audio. + /// Only applies when running on original ESP32 with 16-bit mono output. Operates on the data that is + /// handed to the I2S peripheral, so the check uses the output (post-narrowing) stream info. /// @param data Pointer to audio sample data (modified in place) /// @param bytes_read Number of bytes of audio data void swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read); @@ -156,7 +157,11 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public int32_t q31_volume_factor_{INT32_MAX}; - audio::AudioStreamInfo current_stream_info_; // The currently loaded driver's stream info + audio::AudioStreamInfo current_stream_info_; // Format of the audio in the ring buffer (the I2S input) + // Format actually clocked out of the I2S peripheral. Same channel count and sample rate as + // current_stream_info_, but the bits per sample may be narrower when the incoming stream is wider than + // the speaker's configured slot bit width. Set by start_i2s_driver before the speaker task starts. + audio::AudioStreamInfo output_stream_info_; gpio_num_t dout_pin_; i2s_chan_handle_t tx_handle_{nullptr}; diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index 0afb67fb36..17c93763d6 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -13,6 +13,9 @@ #include "esp_timer.h" +// esp-audio-libs +#include + namespace esphome::i2s_audio { static const char *const TAG = "i2s_audio.speaker.std"; @@ -62,6 +65,12 @@ void I2SAudioSpeaker::dump_config() { break; } ESP_LOGCONFIG(TAG, " Communication format: %s", fmt_str); + if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) { + // The width of each I2S slot. It is also the narrowing ceiling: streams wider than this are narrowed to + // it. A stream narrower than the slot is left at its own width and clocked into the wider slot, so this + // is not necessarily the sample data width (which depends on the incoming stream). + ESP_LOGCONFIG(TAG, " Slot bit width: %u", (unsigned) static_cast(this->slot_bit_width_)); + } } void I2SAudioSpeaker::run_speaker_task() { @@ -71,12 +80,19 @@ void I2SAudioSpeaker::run_speaker_task() { // Ensure ring buffer duration is at least the duration of all DMA buffers const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_); - // The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info + // The ring buffer holds input-format audio (what play() receives), so size it from the input stream info. const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1); // Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and // avoids unnecessary single-frame splices. const size_t ring_buffer_size = (this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame; + + // Per-frame byte widths and whether the task must narrow the bit depth before writing to the I2S peripheral. + const uint8_t channels = this->current_stream_info_.get_channels(); + const uint8_t input_bytes_per_sample = this->current_stream_info_.get_bits_per_sample() / 8; + const uint8_t output_bytes_per_sample = this->output_stream_info_.get_bits_per_sample() / 8; + const bool narrowing = input_bytes_per_sample != output_bytes_per_sample; + // ESP-IDF may allocate smaller (or cache-line-rounded) DMA buffers than dma_buffer_frames() requested: it // clamps each descriptor to the max DMA descriptor size and, on targets that route internal memory through // the L1 cache (e.g. ESP32-P4), rounds the buffer to the cache line. Read the size the driver actually @@ -89,9 +105,12 @@ void I2SAudioSpeaker::run_speaker_task() { dma_buffer_bytes = chan_info.total_dma_buf_size / DMA_BUFFERS_COUNT; } else { // Should not happen for a READY channel; fall back to the requested size. - dma_buffer_bytes = this->current_stream_info_.frames_to_bytes(dma_buffer_frames(this->current_stream_info_)); + dma_buffer_bytes = this->output_stream_info_.frames_to_bytes(dma_buffer_frames(this->output_stream_info_)); } - const uint32_t frames_per_dma_buffer = this->current_stream_info_.bytes_to_frames(dma_buffer_bytes); + // dma_buffer_bytes counts output-format bytes; convert with the output stream info. + const uint32_t frames_per_dma_buffer = this->output_stream_info_.bytes_to_frames(dma_buffer_bytes); + // Soft cap for each source read: enough input-format bytes to fill one DMA buffer's worth of frames. + const size_t dma_buffer_input_bytes = this->current_stream_info_.frames_to_bytes(frames_per_dma_buffer); bool successful_setup = false; @@ -105,8 +124,8 @@ void I2SAudioSpeaker::run_speaker_task() { memset(silence_buffer, 0, dma_buffer_bytes); std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); - audio_source = - audio::RingBufferAudioSource::create(temp_ring_buffer, dma_buffer_bytes, static_cast(bytes_per_frame)); + audio_source = audio::RingBufferAudioSource::create(temp_ring_buffer, dma_buffer_input_bytes, + static_cast(bytes_per_frame)); if (audio_source != nullptr) { // audio_source is nullptr if the ring buffer fails to allocate @@ -237,42 +256,61 @@ void I2SAudioSpeaker::run_speaker_task() { // Compose exactly one DMA buffer's worth: drain as much real audio as the source currently // exposes (may take multiple fill() calls when crossing a ring buffer wrap), then pad any // remainder with silence. All writes pack into the next free DMA descriptor in order, so the - // descriptor ends up holding [real audio][silence padding]. + // descriptor ends up holding [real audio][silence padding]. ``bytes_written_total`` counts + // output-format bytes so it tracks how full the DMA buffer is regardless of any narrowing. size_t bytes_written_total = 0; - size_t real_bytes_total = 0; + uint32_t real_frames_total = 0; bool partial_write_failure = false; if (!this->pause_state_) { while (bytes_written_total < dma_buffer_bytes) { size_t bytes_read = audio_source->fill(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS) / 2, false); if (bytes_read > 0) { + // Apply volume at the input bit depth, before any narrowing, so the full precision is scaled. uint8_t *new_data = audio_source->mutable_data() + audio_source->available() - bytes_read; this->apply_software_volume_(new_data, bytes_read); - this->swap_esp32_mono_samples_(new_data, bytes_read); } - const size_t to_write = std::min(audio_source->available(), dma_buffer_bytes - bytes_written_total); - if (to_write == 0) { + // Convert as many whole frames as fit in the remaining DMA space, bounded by what the source + // currently exposes. Frame counts are shared between input and output; only the byte widths differ. + const uint32_t frames_available = this->current_stream_info_.bytes_to_frames(audio_source->available()); + const uint32_t frames_room = + this->output_stream_info_.bytes_to_frames(dma_buffer_bytes - bytes_written_total); + const uint32_t frames_to_write = std::min(frames_available, frames_room); + if (frames_to_write == 0) { // Ring buffer has nothing more to hand over right now; pad the rest of this DMA buffer // with silence so the lockstep invariant (one write per iteration) is preserved. break; } + const size_t input_bytes = this->current_stream_info_.frames_to_bytes(frames_to_write); + const size_t output_bytes = this->output_stream_info_.frames_to_bytes(frames_to_write); + + uint8_t *chunk = audio_source->mutable_data(); + if (narrowing) { + // Narrow the bit depth in place: output exactly aliases input with the same channel count and a + // smaller width, which copy_frames handles as a single forward pass. Only the frames about to be + // consumed are overwritten, so any unprocessed tail stays intact for the next iteration. + esp_audio_libs::pcm_convert::copy_frames(chunk, chunk, input_bytes_per_sample, channels, + output_bytes_per_sample, channels, frames_to_write); + } + this->swap_esp32_mono_samples_(chunk, output_bytes); + size_t bw = 0; - i2s_channel_write(this->tx_handle_, audio_source->data(), to_write, &bw, WRITE_TIMEOUT_TICKS); - if (bw != to_write) { + i2s_channel_write(this->tx_handle_, chunk, output_bytes, &bw, WRITE_TIMEOUT_TICKS); + if (bw != output_bytes) { // A short real-audio write breaks DMA descriptor alignment for every subsequent event; // the only safe recovery is to restart the task. - ESP_LOGV(TAG, "Partial real audio write: %u of %u bytes", (unsigned) bw, (unsigned) to_write); + ESP_LOGV(TAG, "Partial real audio write: %u of %u bytes", (unsigned) bw, (unsigned) output_bytes); xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); partial_write_failure = true; break; } - audio_source->consume(bw); - bytes_written_total += bw; - real_bytes_total += bw; + audio_source->consume(input_bytes); + bytes_written_total += output_bytes; + real_frames_total += frames_to_write; } - if (real_bytes_total > 0) { + if (real_frames_total > 0) { last_data_received_time = millis(); } } @@ -293,16 +331,15 @@ void I2SAudioSpeaker::run_speaker_task() { } } - const uint32_t real_frames_in_buffer = this->current_stream_info_.bytes_to_frames(real_bytes_total); // Push the matching write record. Capacity headroom in I2S_EVENT_QUEUE_COUNT guarantees this // succeeds even with a transient backlog of unprocessed events; if it ever fails the lockstep // invariant is broken and every subsequent timestamp would be silently wrong, so bail. - if (xQueueSend(this->write_records_queue_, &real_frames_in_buffer, 0) != pdTRUE) { + if (xQueueSend(this->write_records_queue_, &real_frames_total, 0) != pdTRUE) { ESP_LOGV(TAG, "Exiting: write records queue full"); xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); break; } - if (real_frames_in_buffer > 0) { + if (real_frames_total > 0) { pending_real_buffers++; } } @@ -334,21 +371,28 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream return ESP_ERR_NOT_SUPPORTED; } - if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO && - (i2s_slot_bit_width_t) audio_stream_info.get_bits_per_sample() > this->slot_bit_width_) { - // Currently can't handle the case when the incoming audio has more bits per sample than the configured value - ESP_LOGE(TAG, "Stream bits per sample must be less than or equal to the speaker's configuration"); - return ESP_ERR_NOT_SUPPORTED; + // When the stream is wider than the configured slot bit width, the speaker task narrows each frame in place + // before handing it to the I2S peripheral. Compute the output format here so the driver, DMA buffers, and + // the task's conversion all agree on the clocked-out width. A stream no wider than the slot width is passed + // through unchanged (the slot may still be wider than the data, the existing behavior). + uint8_t output_bits_per_sample = audio_stream_info.get_bits_per_sample(); + if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) { + const uint8_t configured_bits = static_cast(this->slot_bit_width_); + if (output_bits_per_sample > configured_bits) { + output_bits_per_sample = configured_bits; + } } + this->output_stream_info_ = audio::AudioStreamInfo(output_bits_per_sample, audio_stream_info.get_channels(), + audio_stream_info.get_sample_rate()); #ifdef USE_ESP32_VARIANT_ESP32 // The original ESP32 I2S peripheral stores each sample in a whole number of 16-bit words (a 24-bit sample // occupies 4 bytes in the DMA buffer, an 8-bit sample 2 bytes), but ESPHome's audio pipeline packs samples // tightly (3 bytes for 24-bit, 1 for 8-bit). The two layouts only line up when the bit depth is a multiple - // of 16, so reject anything else rather than emit corrupted audio. - if (audio_stream_info.get_bits_per_sample() % 16 != 0) { - ESP_LOGE(TAG, "ESP32 supports only 16- or 32-bit audio, got %u-bit", - (unsigned) audio_stream_info.get_bits_per_sample()); + // of 16. The check is on the output width since that is what reaches the peripheral; a wider input is fine + // as long as it narrows to a 16- or 32-bit slot. + if (output_bits_per_sample % 16 != 0) { + ESP_LOGE(TAG, "ESP32 supports only 16- or 32-bit output, got %u-bit", (unsigned) output_bits_per_sample); return ESP_ERR_NOT_SUPPORTED; } #endif // USE_ESP32_VARIANT_ESP32 @@ -358,7 +402,8 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream return ESP_ERR_INVALID_STATE; } - uint32_t dma_buffer_length = dma_buffer_frames(audio_stream_info); + // The DMA buffers hold output-format (post-narrowing) samples, so size them from the output stream info. + uint32_t dma_buffer_length = dma_buffer_frames(this->output_stream_info_); i2s_role_t i2s_role = this->i2s_role_; i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT; @@ -398,19 +443,18 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream slot_mask = I2S_STD_SLOT_BOTH; } + // Configure the data bit width from the output (post-narrowing) format, which is what is clocked out. + const i2s_data_bit_width_t data_bit_width = (i2s_data_bit_width_t) this->output_stream_info_.get_bits_per_sample(); i2s_std_slot_config_t slot_cfg; switch (this->i2s_comm_fmt_) { case I2SCommFmt::PCM: - slot_cfg = - I2S_STD_PCM_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode); + slot_cfg = I2S_STD_PCM_SLOT_DEFAULT_CONFIG(data_bit_width, slot_mode); break; case I2SCommFmt::MSB: - slot_cfg = - I2S_STD_MSB_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode); + slot_cfg = I2S_STD_MSB_SLOT_DEFAULT_CONFIG(data_bit_width, slot_mode); break; default: - slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), - slot_mode); + slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(data_bit_width, slot_mode); break; } From cce7cfff29ca6702910d9c5cea49f8aea4e82624 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 09:52:32 +1200 Subject: [PATCH 0521/1815] Mark configurable classes as final (3/21: ble_scanner-ch423) (#16954) --- esphome/components/b_parasite/b_parasite.h | 2 +- esphome/components/ble_scanner/ble_scanner.h | 4 +- esphome/components/bm8563/bm8563.h | 8 ++-- esphome/components/bme280_i2c/bme280_i2c.h | 2 +- esphome/components/bme280_spi/bme280_spi.h | 6 +-- esphome/components/bme680/bme680.h | 2 +- esphome/components/bme680_bsec/bme680_bsec.h | 2 +- .../bme68x_bsec2_i2c/bme68x_bsec2_i2c.h | 2 +- esphome/components/bmi160/bmi160.h | 2 +- esphome/components/bmi270/bmi270.h | 2 +- esphome/components/bmp085/bmp085.h | 2 +- esphome/components/bmp280_i2c/bmp280_i2c.h | 2 +- esphome/components/bmp280_spi/bmp280_spi.h | 6 +-- esphome/components/bmp3xx_i2c/bmp3xx_i2c.h | 2 +- esphome/components/bmp3xx_spi/bmp3xx_spi.h | 6 +-- esphome/components/bmp581_i2c/bmp581_i2c.h | 2 +- esphome/components/bmp581_spi/bmp581_spi.h | 6 +-- esphome/components/bp1658cj/bp1658cj.h | 4 +- esphome/components/bp5758d/bp5758d.h | 4 +- .../bthome_mithermometer/bthome_ble.h | 2 +- esphome/components/button/automation.h | 4 +- .../camera_encoder/encoder_buffer_impl.h | 2 +- .../esp32_camera_jpeg_encoder.h | 2 +- esphome/components/canbus/canbus.h | 4 +- esphome/components/cap1188/cap1188.h | 4 +- .../captive_portal/captive_portal.h | 2 +- esphome/components/cc1101/cc1101.h | 43 ++++++++++--------- esphome/components/ccs811/ccs811.h | 2 +- esphome/components/cd74hc4067/cd74hc4067.h | 4 +- esphome/components/ch422g/ch422g.h | 4 +- esphome/components/ch423/ch423.h | 4 +- 31 files changed, 73 insertions(+), 70 deletions(-) diff --git a/esphome/components/b_parasite/b_parasite.h b/esphome/components/b_parasite/b_parasite.h index c719599b99..1d5ac6e702 100644 --- a/esphome/components/b_parasite/b_parasite.h +++ b/esphome/components/b_parasite/b_parasite.h @@ -8,7 +8,7 @@ namespace esphome::b_parasite { -class BParasite : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class BParasite final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const std::string &bindkey); diff --git a/esphome/components/ble_scanner/ble_scanner.h b/esphome/components/ble_scanner/ble_scanner.h index c2d48741b1..c70ee637ef 100644 --- a/esphome/components/ble_scanner/ble_scanner.h +++ b/esphome/components/ble_scanner/ble_scanner.h @@ -12,7 +12,9 @@ namespace esphome::ble_scanner { -class BLEScanner : public text_sensor::TextSensor, public esp32_ble_tracker::ESPBTDeviceListener, public Component { +class BLEScanner final : public text_sensor::TextSensor, + public esp32_ble_tracker::ESPBTDeviceListener, + public Component { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override { char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; diff --git a/esphome/components/bm8563/bm8563.h b/esphome/components/bm8563/bm8563.h index eda2d1b3c0..5ca9714091 100644 --- a/esphome/components/bm8563/bm8563.h +++ b/esphome/components/bm8563/bm8563.h @@ -5,7 +5,7 @@ namespace esphome::bm8563 { -class BM8563 : public time::RealTimeClock, public i2c::I2CDevice { +class BM8563 final : public time::RealTimeClock, public i2c::I2CDevice { public: void setup() override; void update() override; @@ -34,17 +34,17 @@ class BM8563 : public time::RealTimeClock, public i2c::I2CDevice { uint8_t byte_to_bcd2_(uint8_t value); }; -template class WriteAction : public Action, public Parented { +template class WriteAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->write_time(); } }; -template class ReadAction : public Action, public Parented { +template class ReadAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->read_time(); } }; -template class TimerAction : public Action, public Parented { +template class TimerAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint32_t, duration) diff --git a/esphome/components/bme280_i2c/bme280_i2c.h b/esphome/components/bme280_i2c/bme280_i2c.h index ad4a283fc7..501556d3c4 100644 --- a/esphome/components/bme280_i2c/bme280_i2c.h +++ b/esphome/components/bme280_i2c/bme280_i2c.h @@ -7,7 +7,7 @@ namespace esphome::bme280_i2c { static const char *const TAG = "bme280_i2c.sensor"; -class BME280I2CComponent : public esphome::bme280_base::BME280Component, public i2c::I2CDevice { +class BME280I2CComponent final : public esphome::bme280_base::BME280Component, public i2c::I2CDevice { bool read_byte(uint8_t a_register, uint8_t *data) override; bool write_byte(uint8_t a_register, uint8_t data) override; bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) override; diff --git a/esphome/components/bme280_spi/bme280_spi.h b/esphome/components/bme280_spi/bme280_spi.h index 4e842e9596..3879151ea1 100644 --- a/esphome/components/bme280_spi/bme280_spi.h +++ b/esphome/components/bme280_spi/bme280_spi.h @@ -5,9 +5,9 @@ namespace esphome::bme280_spi { -class BME280SPIComponent : public esphome::bme280_base::BME280Component, - public spi::SPIDevice { +class BME280SPIComponent final : public esphome::bme280_base::BME280Component, + public spi::SPIDevice { void setup() override; bool read_byte(uint8_t a_register, uint8_t *data) override; bool write_byte(uint8_t a_register, uint8_t data) override; diff --git a/esphome/components/bme680/bme680.h b/esphome/components/bme680/bme680.h index e40daf8720..a274578fc1 100644 --- a/esphome/components/bme680/bme680.h +++ b/esphome/components/bme680/bme680.h @@ -65,7 +65,7 @@ struct BME680CalibrationData { int8_t ambient_temperature; }; -class BME680Component : public PollingComponent, public i2c::I2CDevice { +class BME680Component final : public PollingComponent, public i2c::I2CDevice { public: /// Set the temperature oversampling value. Defaults to 16X. void set_temperature_oversampling(BME680Oversampling temperature_oversampling); diff --git a/esphome/components/bme680_bsec/bme680_bsec.h b/esphome/components/bme680_bsec/bme680_bsec.h index 742b07b59b..ff974d1c6f 100644 --- a/esphome/components/bme680_bsec/bme680_bsec.h +++ b/esphome/components/bme680_bsec/bme680_bsec.h @@ -34,7 +34,7 @@ enum SampleRate { #define BME680_BSEC_SAMPLE_RATE_LOG(r) (r == SAMPLE_RATE_DEFAULT ? "Default" : (r == SAMPLE_RATE_ULP ? "ULP" : "LP")) -class BME680BSECComponent : public Component, public i2c::I2CDevice { +class BME680BSECComponent final : public Component, public i2c::I2CDevice { public: void set_device_id(const std::string &devid) { this->device_id_.assign(devid); } void set_temperature_offset(float offset) { this->temperature_offset_ = offset; } diff --git a/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.h b/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.h index 6d20b61390..896d00d096 100644 --- a/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.h +++ b/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.h @@ -11,7 +11,7 @@ namespace esphome::bme68x_bsec2_i2c { -class BME68xBSEC2I2CComponent : public bme68x_bsec2::BME68xBSEC2Component, public i2c::I2CDevice { +class BME68xBSEC2I2CComponent final : public bme68x_bsec2::BME68xBSEC2Component, public i2c::I2CDevice { void setup() override; void dump_config() override; diff --git a/esphome/components/bmi160/bmi160.h b/esphome/components/bmi160/bmi160.h index e86c353eaa..8af25a09ad 100644 --- a/esphome/components/bmi160/bmi160.h +++ b/esphome/components/bmi160/bmi160.h @@ -6,7 +6,7 @@ namespace esphome::bmi160 { -class BMI160Component : public PollingComponent, public i2c::I2CDevice { +class BMI160Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/bmi270/bmi270.h b/esphome/components/bmi270/bmi270.h index 7c5a2db015..56d6a60952 100644 --- a/esphome/components/bmi270/bmi270.h +++ b/esphome/components/bmi270/bmi270.h @@ -78,7 +78,7 @@ enum BMI270GyroODR : uint8_t { // ---Data class // Main component class -class BMI270Component : public motion::MotionComponent, public i2c::I2CDevice { +class BMI270Component final : public motion::MotionComponent, public i2c::I2CDevice { public: // Lifecycle void setup() override; diff --git a/esphome/components/bmp085/bmp085.h b/esphome/components/bmp085/bmp085.h index a64f3936f0..7012152257 100644 --- a/esphome/components/bmp085/bmp085.h +++ b/esphome/components/bmp085/bmp085.h @@ -6,7 +6,7 @@ namespace esphome::bmp085 { -class BMP085Component : public PollingComponent, public i2c::I2CDevice { +class BMP085Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_pressure(sensor::Sensor *pressure) { pressure_ = pressure; } diff --git a/esphome/components/bmp280_i2c/bmp280_i2c.h b/esphome/components/bmp280_i2c/bmp280_i2c.h index bf1c2fd624..a19203ff0a 100644 --- a/esphome/components/bmp280_i2c/bmp280_i2c.h +++ b/esphome/components/bmp280_i2c/bmp280_i2c.h @@ -8,7 +8,7 @@ namespace esphome::bmp280_i2c { static const char *const TAG = "bmp280_i2c.sensor"; /// This class implements support for the BMP280 Temperature+Pressure i2c sensor. -class BMP280I2CComponent : public esphome::bmp280_base::BMP280Component, public i2c::I2CDevice { +class BMP280I2CComponent final : public esphome::bmp280_base::BMP280Component, public i2c::I2CDevice { public: bool bmp_read_byte(uint8_t a_register, uint8_t *data) override { return read_byte(a_register, data); } bool bmp_write_byte(uint8_t a_register, uint8_t data) override { return write_byte(a_register, data); } diff --git a/esphome/components/bmp280_spi/bmp280_spi.h b/esphome/components/bmp280_spi/bmp280_spi.h index 17d3999884..449167811d 100644 --- a/esphome/components/bmp280_spi/bmp280_spi.h +++ b/esphome/components/bmp280_spi/bmp280_spi.h @@ -5,9 +5,9 @@ namespace esphome::bmp280_spi { -class BMP280SPIComponent : public esphome::bmp280_base::BMP280Component, - public spi::SPIDevice { +class BMP280SPIComponent final : public esphome::bmp280_base::BMP280Component, + public spi::SPIDevice { void setup() override; bool bmp_read_byte(uint8_t a_register, uint8_t *data) override; bool bmp_write_byte(uint8_t a_register, uint8_t data) override; diff --git a/esphome/components/bmp3xx_i2c/bmp3xx_i2c.h b/esphome/components/bmp3xx_i2c/bmp3xx_i2c.h index bec99cf9f8..93549fc890 100644 --- a/esphome/components/bmp3xx_i2c/bmp3xx_i2c.h +++ b/esphome/components/bmp3xx_i2c/bmp3xx_i2c.h @@ -4,7 +4,7 @@ namespace esphome::bmp3xx_i2c { -class BMP3XXI2CComponent : public bmp3xx_base::BMP3XXComponent, public i2c::I2CDevice { +class BMP3XXI2CComponent final : public bmp3xx_base::BMP3XXComponent, public i2c::I2CDevice { bool read_byte(uint8_t a_register, uint8_t *data) override; bool write_byte(uint8_t a_register, uint8_t data) override; bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) override; diff --git a/esphome/components/bmp3xx_spi/bmp3xx_spi.h b/esphome/components/bmp3xx_spi/bmp3xx_spi.h index fa0c0e1b47..7e101cc3a1 100644 --- a/esphome/components/bmp3xx_spi/bmp3xx_spi.h +++ b/esphome/components/bmp3xx_spi/bmp3xx_spi.h @@ -4,9 +4,9 @@ namespace esphome::bmp3xx_spi { -class BMP3XXSPIComponent : public bmp3xx_base::BMP3XXComponent, - public spi::SPIDevice { +class BMP3XXSPIComponent final : public bmp3xx_base::BMP3XXComponent, + public spi::SPIDevice { void setup() override; bool read_byte(uint8_t a_register, uint8_t *data) override; bool write_byte(uint8_t a_register, uint8_t data) override; diff --git a/esphome/components/bmp581_i2c/bmp581_i2c.h b/esphome/components/bmp581_i2c/bmp581_i2c.h index a4e43daf64..126ffd6a60 100644 --- a/esphome/components/bmp581_i2c/bmp581_i2c.h +++ b/esphome/components/bmp581_i2c/bmp581_i2c.h @@ -8,7 +8,7 @@ namespace esphome::bmp581_i2c { static const char *const TAG = "bmp581_i2c.sensor"; /// This class implements support for the BMP581 Temperature+Pressure i2c sensor. -class BMP581I2CComponent : public esphome::bmp581_base::BMP581Component, public i2c::I2CDevice { +class BMP581I2CComponent final : public esphome::bmp581_base::BMP581Component, public i2c::I2CDevice { public: bool bmp_read_byte(uint8_t a_register, uint8_t *data) override { return read_byte(a_register, data); } bool bmp_write_byte(uint8_t a_register, uint8_t data) override { return write_byte(a_register, data); } diff --git a/esphome/components/bmp581_spi/bmp581_spi.h b/esphome/components/bmp581_spi/bmp581_spi.h index 57f75588d5..e5b6cf4476 100644 --- a/esphome/components/bmp581_spi/bmp581_spi.h +++ b/esphome/components/bmp581_spi/bmp581_spi.h @@ -6,9 +6,9 @@ namespace esphome::bmp581_spi { // BMP581 is technically compatible with SPI Mode0 and Mode3. Default to Mode3. -class BMP581SPIComponent : public esphome::bmp581_base::BMP581Component, - public spi::SPIDevice { +class BMP581SPIComponent final : public esphome::bmp581_base::BMP581Component, + public spi::SPIDevice { public: void setup() override; bool bmp_read_byte(uint8_t a_register, uint8_t *data) override; diff --git a/esphome/components/bp1658cj/bp1658cj.h b/esphome/components/bp1658cj/bp1658cj.h index 8905642ec4..666a145804 100644 --- a/esphome/components/bp1658cj/bp1658cj.h +++ b/esphome/components/bp1658cj/bp1658cj.h @@ -7,7 +7,7 @@ namespace esphome::bp1658cj { -class BP1658CJ : public Component { +class BP1658CJ final : public Component { public: class Channel; @@ -29,7 +29,7 @@ class BP1658CJ : public Component { /// Send new values if they were updated. void loop() override; - class Channel : public output::FloatOutput { + class Channel final : public output::FloatOutput { public: void set_parent(BP1658CJ *parent) { parent_ = parent; } void set_channel(uint8_t channel) { channel_ = channel; } diff --git a/esphome/components/bp5758d/bp5758d.h b/esphome/components/bp5758d/bp5758d.h index f07d51fe51..572108b4e6 100644 --- a/esphome/components/bp5758d/bp5758d.h +++ b/esphome/components/bp5758d/bp5758d.h @@ -7,7 +7,7 @@ namespace esphome::bp5758d { -class BP5758D : public Component { +class BP5758D final : public Component { public: class Channel; @@ -23,7 +23,7 @@ class BP5758D : public Component { /// Send new values if they were updated. void loop() override; - class Channel : public output::FloatOutput { + class Channel final : public output::FloatOutput { public: void set_parent(BP5758D *parent) { parent_ = parent; } void set_channel(uint8_t channel) { channel_ = channel; } diff --git a/esphome/components/bthome_mithermometer/bthome_ble.h b/esphome/components/bthome_mithermometer/bthome_ble.h index 9bec8ba7a1..924858e449 100644 --- a/esphome/components/bthome_mithermometer/bthome_ble.h +++ b/esphome/components/bthome_mithermometer/bthome_ble.h @@ -12,7 +12,7 @@ namespace esphome::bthome_mithermometer { -class BTHomeMiThermometer : public esp32_ble_tracker::ESPBTDeviceListener, public Component { +class BTHomeMiThermometer final : public esp32_ble_tracker::ESPBTDeviceListener, public Component { public: void set_address(uint64_t address) { this->address_ = address; } void set_bindkey(std::initializer_list bindkey); diff --git a/esphome/components/button/automation.h b/esphome/components/button/automation.h index 6a54b141a3..d55d43ea37 100644 --- a/esphome/components/button/automation.h +++ b/esphome/components/button/automation.h @@ -6,7 +6,7 @@ namespace esphome::button { -template class PressAction : public Action { +template class PressAction final : public Action { public: explicit PressAction(Button *button) : button_(button) {} @@ -16,7 +16,7 @@ template class PressAction : public Action { Button *button_; }; -class ButtonPressTrigger : public Trigger<> { +class ButtonPressTrigger final : public Trigger<> { public: ButtonPressTrigger(Button *button) { button->add_on_press_callback([this]() { this->trigger(); }); diff --git a/esphome/components/camera_encoder/encoder_buffer_impl.h b/esphome/components/camera_encoder/encoder_buffer_impl.h index d394daff14..b506cb47e0 100644 --- a/esphome/components/camera_encoder/encoder_buffer_impl.h +++ b/esphome/components/camera_encoder/encoder_buffer_impl.h @@ -5,7 +5,7 @@ namespace esphome::camera_encoder { -class EncoderBufferImpl : public camera::EncoderBuffer { +class EncoderBufferImpl final : public camera::EncoderBuffer { public: // --- EncoderBuffer --- bool set_buffer_size(size_t size) override; diff --git a/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.h b/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.h index 0ede366e73..5ec6a98cb9 100644 --- a/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.h +++ b/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.h @@ -11,7 +11,7 @@ namespace esphome::camera_encoder { /// Encoder that uses the software-based JPEG implementation from Espressif's esp32-camera component. -class ESP32CameraJPEGEncoder : public camera::Encoder { +class ESP32CameraJPEGEncoder final : public camera::Encoder { public: /// Constructs a ESP32CameraJPEGEncoder instance. /// @param quality Sets the quality of the encoded image (1-100). diff --git a/esphome/components/canbus/canbus.h b/esphome/components/canbus/canbus.h index 691d7384f1..1bc4d6e345 100644 --- a/esphome/components/canbus/canbus.h +++ b/esphome/components/canbus/canbus.h @@ -106,7 +106,7 @@ class Canbus : public Component { virtual Error read_message(struct CanFrame *frame) = 0; }; -template class CanbusSendAction : public Action, public Parented { +template class CanbusSendAction final : public Action, public Parented { public: void set_data_template(std::vector (*func)(Ts...)) { // Stateless lambdas (generated by ESPHome) implicitly convert to function pointers @@ -154,7 +154,7 @@ template class CanbusSendAction : public Action, public P } data_; }; -class CanbusTrigger : public Trigger, uint32_t, bool>, public Component { +class CanbusTrigger final : public Trigger, uint32_t, bool>, public Component { friend class Canbus; public: diff --git a/esphome/components/cap1188/cap1188.h b/esphome/components/cap1188/cap1188.h index 848e6fe430..a4abb270e7 100644 --- a/esphome/components/cap1188/cap1188.h +++ b/esphome/components/cap1188/cap1188.h @@ -26,7 +26,7 @@ enum { CAP1188_SENSITVITY = 0x1f, }; -class CAP1188Channel : public binary_sensor::BinarySensor { +class CAP1188Channel final : public binary_sensor::BinarySensor { public: void set_channel(uint8_t channel) { channel_ = channel; } void process(uint8_t data) { this->publish_state(static_cast(data & (1 << this->channel_))); } @@ -35,7 +35,7 @@ class CAP1188Channel : public binary_sensor::BinarySensor { uint8_t channel_{0}; }; -class CAP1188Component : public Component, public i2c::I2CDevice { +class CAP1188Component final : public Component, public i2c::I2CDevice { public: void register_channel(CAP1188Channel *channel) { this->channels_.push_back(channel); } void set_touch_threshold(uint8_t touch_threshold) { this->touch_threshold_ = touch_threshold; }; diff --git a/esphome/components/captive_portal/captive_portal.h b/esphome/components/captive_portal/captive_portal.h index 8c8b43e608..b47af9d978 100644 --- a/esphome/components/captive_portal/captive_portal.h +++ b/esphome/components/captive_portal/captive_portal.h @@ -14,7 +14,7 @@ namespace esphome::captive_portal { -class CaptivePortal : public AsyncWebHandler, public Component { +class CaptivePortal final : public AsyncWebHandler, public Component { public: CaptivePortal(web_server_base::WebServerBase *base); void setup() override; diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h index 000a13d586..065ffd5250 100644 --- a/esphome/components/cc1101/cc1101.h +++ b/esphome/components/cc1101/cc1101.h @@ -16,9 +16,9 @@ class CC1101Listener { virtual void on_packet(const std::vector &packet, float freq_offset, float rssi, uint8_t lqi) = 0; }; -class CC1101Component : public Component, - public spi::SPIDevice { +class CC1101Component final : public Component, + public spi::SPIDevice { public: CC1101Component(); @@ -119,27 +119,27 @@ class CC1101Component : public Component, }; // Action Wrappers -template class BeginTxAction : public Action, public Parented { +template class BeginTxAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->begin_tx(); } }; -template class BeginRxAction : public Action, public Parented { +template class BeginRxAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->begin_rx(); } }; -template class ResetAction : public Action, public Parented { +template class ResetAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->reset(); } }; -template class SetIdleAction : public Action, public Parented { +template class SetIdleAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_idle(); } }; -template class SendPacketAction : public Action, public Parented { +template class SendPacketAction final : public Action, public Parented { public: void set_data_template(std::function(Ts...)> func) { this->data_func_ = func; } void set_data_static(const uint8_t *data, size_t len) { @@ -163,79 +163,80 @@ template class SendPacketAction : public Action, public P size_t data_static_len_{0}; }; -template class SetSymbolRateAction : public Action, public Parented { +template class SetSymbolRateAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, symbol_rate) void play(const Ts &...x) override { this->parent_->set_symbol_rate(this->symbol_rate_.value(x...)); } }; -template class SetFrequencyAction : public Action, public Parented { +template class SetFrequencyAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, frequency) void play(const Ts &...x) override { this->parent_->set_frequency(this->frequency_.value(x...)); } }; -template class SetOutputPowerAction : public Action, public Parented { +template class SetOutputPowerAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, output_power) void play(const Ts &...x) override { this->parent_->set_output_power(this->output_power_.value(x...)); } }; -template class SetModulationTypeAction : public Action, public Parented { +template class SetModulationTypeAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(Modulation, modulation_type) void play(const Ts &...x) override { this->parent_->set_modulation_type(this->modulation_type_.value(x...)); } }; -template class SetRxAttenuationAction : public Action, public Parented { +template class SetRxAttenuationAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(RxAttenuation, rx_attenuation) void play(const Ts &...x) override { this->parent_->set_rx_attenuation(this->rx_attenuation_.value(x...)); } }; -template class SetDcBlockingFilterAction : public Action, public Parented { +template +class SetDcBlockingFilterAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, dc_blocking_filter) void play(const Ts &...x) override { this->parent_->set_dc_blocking_filter(this->dc_blocking_filter_.value(x...)); } }; -template class SetManchesterAction : public Action, public Parented { +template class SetManchesterAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, manchester) void play(const Ts &...x) override { this->parent_->set_manchester(this->manchester_.value(x...)); } }; -template class SetFilterBandwidthAction : public Action, public Parented { +template class SetFilterBandwidthAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, filter_bandwidth) void play(const Ts &...x) override { this->parent_->set_filter_bandwidth(this->filter_bandwidth_.value(x...)); } }; -template class SetFskDeviationAction : public Action, public Parented { +template class SetFskDeviationAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, fsk_deviation) void play(const Ts &...x) override { this->parent_->set_fsk_deviation(this->fsk_deviation_.value(x...)); } }; -template class SetMskDeviationAction : public Action, public Parented { +template class SetMskDeviationAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, msk_deviation) void play(const Ts &...x) override { this->parent_->set_msk_deviation(this->msk_deviation_.value(x...)); } }; -template class SetChannelAction : public Action, public Parented { +template class SetChannelAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, channel) void play(const Ts &...x) override { this->parent_->set_channel(this->channel_.value(x...)); } }; -template class SetChannelSpacingAction : public Action, public Parented { +template class SetChannelSpacingAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, channel_spacing) void play(const Ts &...x) override { this->parent_->set_channel_spacing(this->channel_spacing_.value(x...)); } }; -template class SetIfFrequencyAction : public Action, public Parented { +template class SetIfFrequencyAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, if_frequency) void play(const Ts &...x) override { this->parent_->set_if_frequency(this->if_frequency_.value(x...)); } diff --git a/esphome/components/ccs811/ccs811.h b/esphome/components/ccs811/ccs811.h index fde2494753..fb83f842fd 100644 --- a/esphome/components/ccs811/ccs811.h +++ b/esphome/components/ccs811/ccs811.h @@ -8,7 +8,7 @@ namespace esphome::ccs811 { -class CCS811Component : public PollingComponent, public i2c::I2CDevice { +class CCS811Component final : public PollingComponent, public i2c::I2CDevice { public: void set_co2(sensor::Sensor *co2) { co2_ = co2; } void set_tvoc(sensor::Sensor *tvoc) { tvoc_ = tvoc; } diff --git a/esphome/components/cd74hc4067/cd74hc4067.h b/esphome/components/cd74hc4067/cd74hc4067.h index f41b5e294a..3e773a3c8c 100644 --- a/esphome/components/cd74hc4067/cd74hc4067.h +++ b/esphome/components/cd74hc4067/cd74hc4067.h @@ -7,7 +7,7 @@ namespace esphome::cd74hc4067 { -class CD74HC4067Component : public Component { +class CD74HC4067Component final : public Component { public: /// Set up the internal sensor array. void setup() override; @@ -38,7 +38,7 @@ class CD74HC4067Component : public Component { uint32_t switch_delay_; }; -class CD74HC4067Sensor : public sensor::Sensor, public PollingComponent, public voltage_sampler::VoltageSampler { +class CD74HC4067Sensor final : public sensor::Sensor, public PollingComponent, public voltage_sampler::VoltageSampler { public: CD74HC4067Sensor(CD74HC4067Component *parent); diff --git a/esphome/components/ch422g/ch422g.h b/esphome/components/ch422g/ch422g.h index f74e0c46a4..a8729225e8 100644 --- a/esphome/components/ch422g/ch422g.h +++ b/esphome/components/ch422g/ch422g.h @@ -6,7 +6,7 @@ namespace esphome::ch422g { -class CH422GComponent : public Component, public i2c::I2CDevice { +class CH422GComponent final : public Component, public i2c::I2CDevice { public: CH422GComponent() = default; @@ -42,7 +42,7 @@ class CH422GComponent : public Component, public i2c::I2CDevice { }; /// Helper class to expose a CH422G pin as a GPIO pin. -class CH422GGPIOPin : public GPIOPin { +class CH422GGPIOPin final : public GPIOPin { public: void setup() override{}; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/ch423/ch423.h b/esphome/components/ch423/ch423.h index d384971a72..fbfffb521d 100644 --- a/esphome/components/ch423/ch423.h +++ b/esphome/components/ch423/ch423.h @@ -6,7 +6,7 @@ namespace esphome::ch423 { -class CH423Component : public Component, public i2c::I2CDevice { +class CH423Component final : public Component, public i2c::I2CDevice { public: CH423Component() = default; @@ -41,7 +41,7 @@ class CH423Component : public Component, public i2c::I2CDevice { }; /// Helper class to expose a CH423 pin as a GPIO pin. -class CH423GPIOPin : public GPIOPin { +class CH423GPIOPin final : public GPIOPin { public: void setup() override{}; void pin_mode(gpio::Flags flags) override; From faabafad2b7818f6c40ad9392096f9e7766819a9 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 22 Jun 2026 07:54:05 +1000 Subject: [PATCH 0522/1815] [mipi_rgb] Fix offsets for Wave 5 1024x600 (#17057) --- esphome/components/mipi/__init__.py | 5 +++++ tests/components/mipi_rgb/test.esp32-s3-idf.yaml | 8 ++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 129befe600..caa33cd834 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -322,6 +322,9 @@ class DriverChip: - defaults.get(CONF_OFFSET_WIDTH, 0) - defaults.get(CONF_PAD_WIDTH, 0) ) + elif defaults[CONF_WIDTH] > defaults[CONF_NATIVE_WIDTH]: + defaults[CONF_NATIVE_WIDTH] = defaults[CONF_WIDTH] + else: native_width = ( defaults.get(CONF_WIDTH, 0) @@ -337,6 +340,8 @@ class DriverChip: - defaults.get(CONF_OFFSET_HEIGHT, 0) - defaults.get(CONF_PAD_HEIGHT, 0) ) + elif defaults[CONF_HEIGHT] > defaults[CONF_NATIVE_HEIGHT]: + defaults[CONF_NATIVE_HEIGHT] = defaults[CONF_HEIGHT] else: native_height = ( defaults.get(CONF_HEIGHT, 0) diff --git a/tests/components/mipi_rgb/test.esp32-s3-idf.yaml b/tests/components/mipi_rgb/test.esp32-s3-idf.yaml index 399c25c1d0..b56ebee21e 100644 --- a/tests/components/mipi_rgb/test.esp32-s3-idf.yaml +++ b/tests/components/mipi_rgb/test.esp32-s3-idf.yaml @@ -1,7 +1,11 @@ packages: - spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml + - !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml psram: mode: octal -<<: !include common.yaml +ch422g: + +display: + - platform: mipi_rgb + model: WAVESHARE-5-1024X600 From 44c54b3a756692618a4245b8384b3eecff412b30 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 18:09:54 -0500 Subject: [PATCH 0523/1815] [json] Bump ArduinoJson to 7.4.3 (#17126) --- esphome/components/json/__init__.py | 4 ++-- esphome/idf_component.yml | 2 +- platformio.ini | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/json/__init__.py b/esphome/components/json/__init__.py index 28fdcd41ef..3cb89a6cd9 100644 --- a/esphome/components/json/__init__.py +++ b/esphome/components/json/__init__.py @@ -15,8 +15,8 @@ async def to_code(config): if CORE.is_esp32: from esphome.components.esp32 import add_idf_component - add_idf_component(name="bblanchon/arduinojson", ref="7.4.2") + add_idf_component(name="bblanchon/arduinojson", ref="7.4.3") else: - cg.add_library("bblanchon/ArduinoJson", "7.4.2") + cg.add_library("bblanchon/ArduinoJson", "7.4.3") cg.add_define("USE_JSON") cg.add_global(json_ns.using) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index b3b670d77b..f8f3df57cd 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -1,6 +1,6 @@ dependencies: bblanchon/arduinojson: - version: "7.4.2" + version: "7.4.3" esphome/dlms_parser: version: 1.1.0 esphome/esp-audio-libs: diff --git a/platformio.ini b/platformio.ini index 862b7a7dbe..43a7474d35 100644 --- a/platformio.ini +++ b/platformio.ini @@ -104,7 +104,7 @@ build_unflags = [common:idf-component-libs] lib_deps = esphome/dlms_parser@1.1.0 ; dlms_meter - bblanchon/ArduinoJson@7.4.2 ; json + bblanchon/ArduinoJson@7.4.3 ; json lvgl/lvgl@9.5.0 ; lvgl ; This are common settings for the ESP8266 using Arduino. From 7fcc890e84093d3562c9b158d0476e62a8f89116 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 20:02:46 -0500 Subject: [PATCH 0524/1815] [rp2040] Bump arduino-pico framework to 5.6.1 (#17122) --- esphome/components/rp2040/__init__.py | 7 ++- esphome/components/rp2040/boards.py | 68 +++++++++++++++++++++++++++ platformio.ini | 2 +- 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index dd851b8e16..e76ce6def8 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -187,12 +187,11 @@ def _parse_platform_version(value): # * The new version needs to be thoroughly validated before changing the # recommended version as otherwise a bunch of devices could be bricked # * For all constants below, update platformio.ini (in this repo) -# and platformio.ini/platformio-lint.ini in the esphome-docker-base repository # The default/recommended arduino framework version # - https://github.com/earlephilhower/arduino-pico/releases # - https://api.registry.platformio.org/v3/packages/earlephilhower/tool/framework-arduinopico -RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(5, 6, 0) +RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(5, 6, 1) # The raspberrypi platform version to use for arduino frameworks # - https://github.com/maxgerhardt/platform-raspberrypi/tags @@ -202,8 +201,8 @@ RECOMMENDED_ARDUINO_PLATFORM_VERSION = "v1.4.0-gcc14-arduinopico460" def _arduino_check_versions(value): value = value.copy() lookups = { - "dev": (cv.Version(5, 6, 0), "https://github.com/earlephilhower/arduino-pico"), - "latest": (cv.Version(5, 6, 0), None), + "dev": (cv.Version(5, 6, 1), "https://github.com/earlephilhower/arduino-pico"), + "latest": (cv.Version(5, 6, 1), None), "recommended": (RECOMMENDED_ARDUINO_FRAMEWORK_VERSION, None), } diff --git a/esphome/components/rp2040/boards.py b/esphome/components/rp2040/boards.py index 1f2b3a93f4..0bc5c48d03 100644 --- a/esphome/components/rp2040/boards.py +++ b/esphome/components/rp2040/boards.py @@ -865,6 +865,30 @@ RP2040_BOARD_PINS = { "SS": 17, "TX": 0, }, + "pcbcupid_glyph_2040": { + "LED": 0, + "MISO": 8, + "MOSI": 7, + "RX": 13, + "SCK": 6, + "SCL": 21, + "SDA": 20, + "SS": 5, + "TX": 12, + }, + "pcbcupid_glyph_mini_2040": { + "LED": 16, + "MISO": 4, + "MOSI": 3, + "RX": 1, + "SCK": 2, + "SCL": 9, + "SCL1": 27, + "SDA": 8, + "SDA1": 26, + "SS": 5, + "TX": 0, + }, "picolume": { "LED": 25, "MISO": 16, @@ -1079,6 +1103,18 @@ RP2040_BOARD_PINS = { "SDA": 6, "TX": 0, }, + "seeed_xiao_rp2040_plus": { + "LED": 25, + "MISO": 4, + "MOSI": 3, + "RX": 1, + "SCK": 2, + "SCL": 7, + "SCL1": 21, + "SDA": 6, + "SDA1": 20, + "TX": 0, + }, "seeed_xiao_rp2350": { "LED": 25, "MISO": 4, @@ -1102,6 +1138,18 @@ RP2040_BOARD_PINS = { "SS": 21, "TX": 0, }, + "soldered_nula_ethernet_w55rp20": { + "MISO": 4, + "MOSI": 7, + "RX": 1, + "SCK": 6, + "SCL": 3, + "SCL1": 29, + "SDA": 2, + "SDA1": 28, + "SS": 5, + "TX": 0, + }, "soldered_nula_rp2350": { "MISO": 2, "MOSI": 3, @@ -1899,6 +1947,16 @@ BOARDS = { "mcu": "rp2040", "max_pin": 29, }, + "pcbcupid_glyph_2040": { + "name": "PCBCupid Glyph 2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "pcbcupid_glyph_mini_2040": { + "name": "PCBCupid Glyph Mini 2040", + "mcu": "rp2040", + "max_pin": 29, + }, "picolume": { "name": "PicoLume Transceiver", "mcu": "rp2040", @@ -2021,6 +2079,11 @@ BOARDS = { "mcu": "rp2040", "max_pin": 29, }, + "seeed_xiao_rp2040_plus": { + "name": "Seeed XIAO RP2040 Plus", + "mcu": "rp2040", + "max_pin": 29, + }, "seeed_xiao_rp2350": { "name": "Seeed XIAO RP2350", "mcu": "rp2350", @@ -2031,6 +2094,11 @@ BOARDS = { "mcu": "rp2040", "max_pin": 29, }, + "soldered_nula_ethernet_w55rp20": { + "name": "Soldered Electronics NULA Ethernet W55RP20", + "mcu": "rp2040", + "max_pin": 29, + }, "soldered_nula_rp2350": { "name": "Soldered Electronics NULA RP2350", "mcu": "rp2350", diff --git a/platformio.ini b/platformio.ini index 43a7474d35..bca2910616 100644 --- a/platformio.ini +++ b/platformio.ini @@ -206,7 +206,7 @@ board_build.filesystem_size = 0.5m platform = https://github.com/maxgerhardt/platform-raspberrypi.git#v1.4.0-gcc14-arduinopico460 platform_packages = ; earlephilhower/framework-arduinopico@~1.20602.0 ; Cannot use the platformio package until old releases stop getting deleted - earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/5.6.0/rp2040-5.6.0.zip + earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/5.6.1/rp2040-5.6.1.zip framework = arduino lib_deps = From 026bac4cd1efeed4971a41bcc381553d40e7c053 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:27:56 +1200 Subject: [PATCH 0525/1815] [ld2420] Mark configurable classes as final (#17130) --- .../ld2420/binary_sensor/ld2420_binary_sensor.h | 2 +- .../components/ld2420/button/reconfig_buttons.h | 8 ++++---- esphome/components/ld2420/ld2420.h | 2 +- .../ld2420/number/gate_config_number.h | 16 ++++++++-------- .../ld2420/select/operating_mode_select.h | 2 +- esphome/components/ld2420/sensor/ld2420_sensor.h | 2 +- .../ld2420/text_sensor/ld2420_text_sensor.h | 2 +- 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.h b/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.h index ec52312f92..47492e38c2 100644 --- a/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.h +++ b/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.h @@ -5,7 +5,7 @@ namespace esphome::ld2420 { -class LD2420BinarySensor : public LD2420Listener, public Component, binary_sensor::BinarySensor { +class LD2420BinarySensor final : public LD2420Listener, public Component, public binary_sensor::BinarySensor { public: void dump_config() override; void set_presence_sensor(binary_sensor::BinarySensor *bsensor) { this->presence_bsensor_ = bsensor; }; diff --git a/esphome/components/ld2420/button/reconfig_buttons.h b/esphome/components/ld2420/button/reconfig_buttons.h index 72171ef386..b769e18a46 100644 --- a/esphome/components/ld2420/button/reconfig_buttons.h +++ b/esphome/components/ld2420/button/reconfig_buttons.h @@ -5,7 +5,7 @@ namespace esphome::ld2420 { -class LD2420ApplyConfigButton : public button::Button, public Parented { +class LD2420ApplyConfigButton final : public button::Button, public Parented { public: LD2420ApplyConfigButton() = default; @@ -13,7 +13,7 @@ class LD2420ApplyConfigButton : public button::Button, public Parented { +class LD2420RevertConfigButton final : public button::Button, public Parented { public: LD2420RevertConfigButton() = default; @@ -21,7 +21,7 @@ class LD2420RevertConfigButton : public button::Button, public Parented { +class LD2420RestartModuleButton final : public button::Button, public Parented { public: LD2420RestartModuleButton() = default; @@ -29,7 +29,7 @@ class LD2420RestartModuleButton : public button::Button, public Parented { +class LD2420FactoryResetButton final : public button::Button, public Parented { public: LD2420FactoryResetButton() = default; diff --git a/esphome/components/ld2420/ld2420.h b/esphome/components/ld2420/ld2420.h index 358793fe64..ae44b16065 100644 --- a/esphome/components/ld2420/ld2420.h +++ b/esphome/components/ld2420/ld2420.h @@ -40,7 +40,7 @@ class LD2420Listener { virtual void on_fw_version(std::string &fw){}; }; -class LD2420Component : public Component, public uart::UARTDevice { +class LD2420Component final : public Component, public uart::UARTDevice { public: struct CmdFrameT { uint32_t header{0}; diff --git a/esphome/components/ld2420/number/gate_config_number.h b/esphome/components/ld2420/number/gate_config_number.h index 8a8b9c61b1..e1c12e023a 100644 --- a/esphome/components/ld2420/number/gate_config_number.h +++ b/esphome/components/ld2420/number/gate_config_number.h @@ -5,7 +5,7 @@ namespace esphome::ld2420 { -class LD2420TimeoutNumber : public number::Number, public Parented { +class LD2420TimeoutNumber final : public number::Number, public Parented { public: LD2420TimeoutNumber() = default; @@ -13,7 +13,7 @@ class LD2420TimeoutNumber : public number::Number, public Parented { +class LD2420MinDistanceNumber final : public number::Number, public Parented { public: LD2420MinDistanceNumber() = default; @@ -21,7 +21,7 @@ class LD2420MinDistanceNumber : public number::Number, public Parented { +class LD2420MaxDistanceNumber final : public number::Number, public Parented { public: LD2420MaxDistanceNumber() = default; @@ -29,7 +29,7 @@ class LD2420MaxDistanceNumber : public number::Number, public Parented { +class LD2420GateSelectNumber final : public number::Number, public Parented { public: LD2420GateSelectNumber() = default; @@ -37,7 +37,7 @@ class LD2420GateSelectNumber : public number::Number, public Parented { +class LD2420MoveSensFactorNumber final : public number::Number, public Parented { public: LD2420MoveSensFactorNumber() = default; @@ -45,7 +45,7 @@ class LD2420MoveSensFactorNumber : public number::Number, public Parented { +class LD2420StillSensFactorNumber final : public number::Number, public Parented { public: LD2420StillSensFactorNumber() = default; @@ -53,7 +53,7 @@ class LD2420StillSensFactorNumber : public number::Number, public Parented { +class LD2420StillThresholdNumbers final : public number::Number, public Parented { public: LD2420StillThresholdNumbers() = default; LD2420StillThresholdNumbers(uint8_t gate); @@ -63,7 +63,7 @@ class LD2420StillThresholdNumbers : public number::Number, public Parented { +class LD2420MoveThresholdNumbers final : public number::Number, public Parented { public: LD2420MoveThresholdNumbers() = default; LD2420MoveThresholdNumbers(uint8_t gate); diff --git a/esphome/components/ld2420/select/operating_mode_select.h b/esphome/components/ld2420/select/operating_mode_select.h index c1b8e0b11b..e5eb5d82bd 100644 --- a/esphome/components/ld2420/select/operating_mode_select.h +++ b/esphome/components/ld2420/select/operating_mode_select.h @@ -5,7 +5,7 @@ namespace esphome::ld2420 { -class LD2420Select : public Component, public select::Select, public Parented { +class LD2420Select final : public Component, public select::Select, public Parented { public: LD2420Select() = default; diff --git a/esphome/components/ld2420/sensor/ld2420_sensor.h b/esphome/components/ld2420/sensor/ld2420_sensor.h index 4849cfa047..4ccfc19081 100644 --- a/esphome/components/ld2420/sensor/ld2420_sensor.h +++ b/esphome/components/ld2420/sensor/ld2420_sensor.h @@ -5,7 +5,7 @@ namespace esphome::ld2420 { -class LD2420Sensor : public LD2420Listener, public Component, sensor::Sensor { +class LD2420Sensor final : public LD2420Listener, public Component, public sensor::Sensor { public: void dump_config() override; void set_distance_sensor(sensor::Sensor *sensor) { this->distance_sensor_ = sensor; } diff --git a/esphome/components/ld2420/text_sensor/ld2420_text_sensor.h b/esphome/components/ld2420/text_sensor/ld2420_text_sensor.h index 1932eaaf69..da295fe7ca 100644 --- a/esphome/components/ld2420/text_sensor/ld2420_text_sensor.h +++ b/esphome/components/ld2420/text_sensor/ld2420_text_sensor.h @@ -5,7 +5,7 @@ namespace esphome::ld2420 { -class LD2420TextSensor : public LD2420Listener, public Component, text_sensor::TextSensor { +class LD2420TextSensor final : public LD2420Listener, public Component, public text_sensor::TextSensor { public: void dump_config() override; void set_fw_version_text_sensor(text_sensor::TextSensor *tsensor) { this->fw_version_text_sensor_ = tsensor; }; From 2982d7c83499a552089dc4dca35bd44087577467 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:04:16 +1200 Subject: [PATCH 0526/1815] Mark configurable classes as final (9/21) (#16960) --- .../internal_temperature.h | 2 +- esphome/components/interval/interval.h | 2 +- esphome/components/ir_rf_proxy/ir_rf_proxy.h | 4 ++-- esphome/components/jsn_sr04t/jsn_sr04t.h | 2 +- .../components/kamstrup_kmp/kamstrup_kmp.h | 2 +- .../components/key_collector/key_collector.h | 6 +++--- esphome/components/kmeteriso/kmeteriso.h | 2 +- esphome/components/kuntze/kuntze.h | 2 +- esphome/components/lc709203f/lc709203f.h | 2 +- .../components/lcd_gpio/gpio_lcd_display.h | 2 +- esphome/components/lcd_menu/lcd_menu.h | 2 +- .../components/lcd_pcf8574/pcf8574_display.h | 2 +- esphome/components/ld2410/automation.h | 2 +- .../ld2410/button/factory_reset_button.h | 2 +- .../components/ld2410/button/query_button.h | 2 +- .../components/ld2410/button/restart_button.h | 2 +- esphome/components/ld2410/ld2410.h | 2 +- .../ld2410/number/gate_threshold_number.h | 2 +- .../ld2410/number/light_threshold_number.h | 2 +- .../number/max_distance_timeout_number.h | 2 +- .../ld2410/select/baud_rate_select.h | 2 +- .../select/distance_resolution_select.h | 2 +- .../ld2410/select/light_out_control_select.h | 2 +- .../ld2410/switch/bluetooth_switch.h | 2 +- .../ld2410/switch/engineering_mode_switch.h | 2 +- .../ld2412/button/factory_reset_button.h | 2 +- .../components/ld2412/button/query_button.h | 2 +- .../components/ld2412/button/restart_button.h | 2 +- ...art_dynamic_background_correction_button.h | 2 +- esphome/components/ld2412/ld2412.h | 2 +- .../ld2412/number/gate_threshold_number.h | 2 +- .../ld2412/number/light_threshold_number.h | 2 +- .../number/max_distance_timeout_number.h | 2 +- .../ld2412/select/baud_rate_select.h | 2 +- .../select/distance_resolution_select.h | 2 +- .../ld2412/select/light_out_control_select.h | 2 +- .../ld2412/switch/bluetooth_switch.h | 2 +- .../ld2412/switch/engineering_mode_switch.h | 2 +- esphome/components/ledc/ledc_output.h | 4 ++-- esphome/components/libretiny/gpio_arduino.h | 2 +- esphome/components/libretiny/lt_component.h | 2 +- .../components/libretiny_pwm/libretiny_pwm.h | 4 ++-- esphome/components/light/addressable_light.h | 2 +- esphome/components/light/automation.h | 20 +++++++++---------- esphome/components/lightwaverf/lightwaverf.h | 4 ++-- .../touchscreen/lilygo_t5_47_touchscreen.h | 2 +- esphome/components/lm75b/lm75b.h | 2 +- esphome/components/lock/automation.h | 8 ++++---- esphome/components/lps22/lps22.h | 2 +- esphome/components/lsm6ds/lsm6ds.h | 2 +- esphome/components/ltr390/ltr390.h | 2 +- esphome/components/ltr501/ltr501.h | 2 +- esphome/components/lvgl/light/lvgl_light.h | 2 +- esphome/components/lvgl/lvgl_esphome.h | 16 +++++++-------- esphome/components/lvgl/number/lvgl_number.h | 2 +- esphome/components/lvgl/select/lvgl_select.h | 2 +- esphome/components/lvgl/switch/lvgl_switch.h | 2 +- esphome/components/lvgl/text/lvgl_text.h | 2 +- .../m5stack_8angle_binary_sensor.h | 6 +++--- .../light/m5stack_8angle_light.h | 2 +- .../m5stack_8angle/m5stack_8angle.h | 2 +- .../sensor/m5stack_8angle_sensor.h | 6 +++--- 62 files changed, 91 insertions(+), 91 deletions(-) diff --git a/esphome/components/internal_temperature/internal_temperature.h b/esphome/components/internal_temperature/internal_temperature.h index 41fea5a255..90831cf211 100644 --- a/esphome/components/internal_temperature/internal_temperature.h +++ b/esphome/components/internal_temperature/internal_temperature.h @@ -11,7 +11,7 @@ namespace esphome::internal_temperature { -class InternalTemperatureSensor : public sensor::Sensor, public PollingComponent { +class InternalTemperatureSensor final : public sensor::Sensor, public PollingComponent { public: #if defined(USE_ESP32) || (defined(USE_ZEPHYR) && defined(USE_NRF52)) void setup() override; diff --git a/esphome/components/interval/interval.h b/esphome/components/interval/interval.h index c9d4e8ea3e..fd59d2a488 100644 --- a/esphome/components/interval/interval.h +++ b/esphome/components/interval/interval.h @@ -6,7 +6,7 @@ namespace esphome::interval { -class IntervalTrigger : public Trigger<>, public PollingComponent { +class IntervalTrigger final : public Trigger<>, public PollingComponent { public: void update() override { this->trigger(); } diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.h b/esphome/components/ir_rf_proxy/ir_rf_proxy.h index d0467e822d..5fc683354b 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.h +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.h @@ -18,7 +18,7 @@ namespace esphome::ir_rf_proxy { #ifdef USE_IR_RF /// IrRfProxy - Infrared platform implementation using remote_transmitter/receiver as backend -class IrRfProxy : public infrared::Infrared { +class IrRfProxy final : public infrared::Infrared { public: IrRfProxy() = default; @@ -47,7 +47,7 @@ class IrRfProxy : public infrared::Infrared { /// Driver-agnostic: integration with specific RF front-end chips (CC1101, RFM69, etc.) is done /// in YAML by wiring their actions to `remote_transmitter`'s on_transmit/on_complete triggers and /// to this entity's on_control trigger (see radio_frequency component docs). -class RfProxy : public radio_frequency::RadioFrequency { +class RfProxy final : public radio_frequency::RadioFrequency { public: RfProxy() = default; diff --git a/esphome/components/jsn_sr04t/jsn_sr04t.h b/esphome/components/jsn_sr04t/jsn_sr04t.h index f9d07ea539..5368ec683c 100644 --- a/esphome/components/jsn_sr04t/jsn_sr04t.h +++ b/esphome/components/jsn_sr04t/jsn_sr04t.h @@ -13,7 +13,7 @@ enum Model { AJ_SR04M, }; -class Jsnsr04tComponent : public sensor::Sensor, public PollingComponent, public uart::UARTDevice { +class Jsnsr04tComponent final : public sensor::Sensor, public PollingComponent, public uart::UARTDevice { public: void set_model(Model model) { this->model_ = model; } diff --git a/esphome/components/kamstrup_kmp/kamstrup_kmp.h b/esphome/components/kamstrup_kmp/kamstrup_kmp.h index a4eacec453..57a89f77a1 100644 --- a/esphome/components/kamstrup_kmp/kamstrup_kmp.h +++ b/esphome/components/kamstrup_kmp/kamstrup_kmp.h @@ -73,7 +73,7 @@ static const char *const UNITS[] = { "mm:dd", "", "bar", "RTC", "ASCII", "m3 x 10", "ton x 10", "GJ x 10", "minutes", "Bitfield", "s", "ms", "days", "RTC-Q", "Datetime"}; -class KamstrupKMPComponent : public PollingComponent, public uart::UARTDevice { +class KamstrupKMPComponent final : public PollingComponent, public uart::UARTDevice { public: void set_heat_energy_sensor(sensor::Sensor *sensor) { this->heat_energy_sensor_ = sensor; } void set_power_sensor(sensor::Sensor *sensor) { this->power_sensor_ = sensor; } diff --git a/esphome/components/key_collector/key_collector.h b/esphome/components/key_collector/key_collector.h index 27209c50df..c9eeabeb2d 100644 --- a/esphome/components/key_collector/key_collector.h +++ b/esphome/components/key_collector/key_collector.h @@ -7,7 +7,7 @@ namespace esphome::key_collector { -class KeyCollector : public Component { +class KeyCollector final : public Component { public: void loop() override; void dump_config() override; @@ -54,11 +54,11 @@ class KeyCollector : public Component { bool enabled_{}; }; -template class EnableAction : public Action, public Parented { +template class EnableAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_enabled(true); } }; -template class DisableAction : public Action, public Parented { +template class DisableAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_enabled(false); } }; diff --git a/esphome/components/kmeteriso/kmeteriso.h b/esphome/components/kmeteriso/kmeteriso.h index d5a2f9a01b..bd92a6011f 100644 --- a/esphome/components/kmeteriso/kmeteriso.h +++ b/esphome/components/kmeteriso/kmeteriso.h @@ -8,7 +8,7 @@ namespace esphome::kmeteriso { /// This class implements support for the KMeterISO thermocouple sensor. -class KMeterISOComponent : public PollingComponent, public i2c::I2CDevice { +class KMeterISOComponent final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *t) { this->temperature_sensor_ = t; } void set_internal_temperature_sensor(sensor::Sensor *t) { this->internal_temperature_sensor_ = t; } diff --git a/esphome/components/kuntze/kuntze.h b/esphome/components/kuntze/kuntze.h index bbd93a22ce..99dd78e5b6 100644 --- a/esphome/components/kuntze/kuntze.h +++ b/esphome/components/kuntze/kuntze.h @@ -6,7 +6,7 @@ namespace esphome::kuntze { -class Kuntze : public PollingComponent, public modbus::ModbusDevice { +class Kuntze final : public PollingComponent, public modbus::ModbusDevice { public: void set_ph_sensor(sensor::Sensor *ph_sensor) { ph_sensor_ = ph_sensor; } void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } diff --git a/esphome/components/lc709203f/lc709203f.h b/esphome/components/lc709203f/lc709203f.h index 42aa9a15a1..46f773873a 100644 --- a/esphome/components/lc709203f/lc709203f.h +++ b/esphome/components/lc709203f/lc709203f.h @@ -19,7 +19,7 @@ enum LC709203FBatteryVoltage { LC709203F_BATTERY_VOLTAGE_3_7 = 0x0001, }; -class Lc709203f : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class Lc709203f final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; void update() override; diff --git a/esphome/components/lcd_gpio/gpio_lcd_display.h b/esphome/components/lcd_gpio/gpio_lcd_display.h index dd9ea5929c..17fcf7ea23 100644 --- a/esphome/components/lcd_gpio/gpio_lcd_display.h +++ b/esphome/components/lcd_gpio/gpio_lcd_display.h @@ -10,7 +10,7 @@ class GPIOLCDDisplay; using gpio_lcd_writer_t = display::DisplayWriter; -class GPIOLCDDisplay : public lcd_base::LCDDisplay { +class GPIOLCDDisplay final : public lcd_base::LCDDisplay { public: void set_writer(gpio_lcd_writer_t &&writer) { this->writer_ = std::move(writer); } void setup() override; diff --git a/esphome/components/lcd_menu/lcd_menu.h b/esphome/components/lcd_menu/lcd_menu.h index ae1c2502fe..6fa61fdf6a 100644 --- a/esphome/components/lcd_menu/lcd_menu.h +++ b/esphome/components/lcd_menu/lcd_menu.h @@ -11,7 +11,7 @@ namespace esphome::lcd_menu { /** Class to display a hierarchical menu. * */ -class LCDCharacterMenuComponent : public display_menu_base::DisplayMenuComponent { +class LCDCharacterMenuComponent final : public display_menu_base::DisplayMenuComponent { public: void set_display(lcd_base::LCDDisplay *display) { this->display_ = display; } void set_dimensions(uint8_t columns, uint8_t rows) { diff --git a/esphome/components/lcd_pcf8574/pcf8574_display.h b/esphome/components/lcd_pcf8574/pcf8574_display.h index 9ec5ad71af..5af087add5 100644 --- a/esphome/components/lcd_pcf8574/pcf8574_display.h +++ b/esphome/components/lcd_pcf8574/pcf8574_display.h @@ -11,7 +11,7 @@ class PCF8574LCDDisplay; using pcf8574_lcd_writer_t = display::DisplayWriter; -class PCF8574LCDDisplay : public lcd_base::LCDDisplay, public i2c::I2CDevice { +class PCF8574LCDDisplay final : public lcd_base::LCDDisplay, public i2c::I2CDevice { public: void set_writer(pcf8574_lcd_writer_t &&writer) { this->writer_ = std::move(writer); } void setup() override; diff --git a/esphome/components/ld2410/automation.h b/esphome/components/ld2410/automation.h index 614453b575..b0b9591d37 100644 --- a/esphome/components/ld2410/automation.h +++ b/esphome/components/ld2410/automation.h @@ -6,7 +6,7 @@ namespace esphome::ld2410 { -template class BluetoothPasswordSetAction : public Action { +template class BluetoothPasswordSetAction final : public Action { public: explicit BluetoothPasswordSetAction(LD2410Component *ld2410_comp) : ld2410_comp_(ld2410_comp) {} TEMPLATABLE_VALUE(std::string, password) diff --git a/esphome/components/ld2410/button/factory_reset_button.h b/esphome/components/ld2410/button/factory_reset_button.h index 715a8c4056..1da7c81337 100644 --- a/esphome/components/ld2410/button/factory_reset_button.h +++ b/esphome/components/ld2410/button/factory_reset_button.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class FactoryResetButton : public button::Button, public Parented { +class FactoryResetButton final : public button::Button, public Parented { public: FactoryResetButton() = default; diff --git a/esphome/components/ld2410/button/query_button.h b/esphome/components/ld2410/button/query_button.h index 7a786901ae..4f3f147e67 100644 --- a/esphome/components/ld2410/button/query_button.h +++ b/esphome/components/ld2410/button/query_button.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class QueryButton : public button::Button, public Parented { +class QueryButton final : public button::Button, public Parented { public: QueryButton() = default; diff --git a/esphome/components/ld2410/button/restart_button.h b/esphome/components/ld2410/button/restart_button.h index 9bf8639a8c..70e0a74c9a 100644 --- a/esphome/components/ld2410/button/restart_button.h +++ b/esphome/components/ld2410/button/restart_button.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class RestartButton : public button::Button, public Parented { +class RestartButton final : public button::Button, public Parented { public: RestartButton() = default; diff --git a/esphome/components/ld2410/ld2410.h b/esphome/components/ld2410/ld2410.h index 31186b135f..a0cce36d16 100644 --- a/esphome/components/ld2410/ld2410.h +++ b/esphome/components/ld2410/ld2410.h @@ -38,7 +38,7 @@ using namespace ld24xx; static constexpr uint8_t MAX_LINE_LENGTH = 50; static constexpr uint8_t TOTAL_GATES = 9; // Total number of gates supported by the LD2410 -class LD2410Component : public Component, public uart::UARTDevice { +class LD2410Component final : public Component, public uart::UARTDevice { #ifdef USE_BINARY_SENSOR SUB_BINARY_SENSOR(out_pin_presence_status) SUB_BINARY_SENSOR(moving_target) diff --git a/esphome/components/ld2410/number/gate_threshold_number.h b/esphome/components/ld2410/number/gate_threshold_number.h index 63491f18d3..68359a10d4 100644 --- a/esphome/components/ld2410/number/gate_threshold_number.h +++ b/esphome/components/ld2410/number/gate_threshold_number.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class GateThresholdNumber : public number::Number, public Parented { +class GateThresholdNumber final : public number::Number, public Parented { public: GateThresholdNumber(uint8_t gate); diff --git a/esphome/components/ld2410/number/light_threshold_number.h b/esphome/components/ld2410/number/light_threshold_number.h index 3c5e433416..6e1a5ca4a4 100644 --- a/esphome/components/ld2410/number/light_threshold_number.h +++ b/esphome/components/ld2410/number/light_threshold_number.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class LightThresholdNumber : public number::Number, public Parented { +class LightThresholdNumber final : public number::Number, public Parented { public: LightThresholdNumber() = default; diff --git a/esphome/components/ld2410/number/max_distance_timeout_number.h b/esphome/components/ld2410/number/max_distance_timeout_number.h index 35f4cbbfae..29b19c2022 100644 --- a/esphome/components/ld2410/number/max_distance_timeout_number.h +++ b/esphome/components/ld2410/number/max_distance_timeout_number.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class MaxDistanceTimeoutNumber : public number::Number, public Parented { +class MaxDistanceTimeoutNumber final : public number::Number, public Parented { public: MaxDistanceTimeoutNumber() = default; diff --git a/esphome/components/ld2410/select/baud_rate_select.h b/esphome/components/ld2410/select/baud_rate_select.h index fb1d016b1f..b06ce139ad 100644 --- a/esphome/components/ld2410/select/baud_rate_select.h +++ b/esphome/components/ld2410/select/baud_rate_select.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class BaudRateSelect : public select::Select, public Parented { +class BaudRateSelect final : public select::Select, public Parented { public: BaudRateSelect() = default; diff --git a/esphome/components/ld2410/select/distance_resolution_select.h b/esphome/components/ld2410/select/distance_resolution_select.h index be2389d36e..0c5409b7b1 100644 --- a/esphome/components/ld2410/select/distance_resolution_select.h +++ b/esphome/components/ld2410/select/distance_resolution_select.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class DistanceResolutionSelect : public select::Select, public Parented { +class DistanceResolutionSelect final : public select::Select, public Parented { public: DistanceResolutionSelect() = default; diff --git a/esphome/components/ld2410/select/light_out_control_select.h b/esphome/components/ld2410/select/light_out_control_select.h index 608c311af4..a8a16598b1 100644 --- a/esphome/components/ld2410/select/light_out_control_select.h +++ b/esphome/components/ld2410/select/light_out_control_select.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class LightOutControlSelect : public select::Select, public Parented { +class LightOutControlSelect final : public select::Select, public Parented { public: LightOutControlSelect() = default; diff --git a/esphome/components/ld2410/switch/bluetooth_switch.h b/esphome/components/ld2410/switch/bluetooth_switch.h index 07804e2292..cc56b2cda0 100644 --- a/esphome/components/ld2410/switch/bluetooth_switch.h +++ b/esphome/components/ld2410/switch/bluetooth_switch.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class BluetoothSwitch : public switch_::Switch, public Parented { +class BluetoothSwitch final : public switch_::Switch, public Parented { public: BluetoothSwitch() = default; diff --git a/esphome/components/ld2410/switch/engineering_mode_switch.h b/esphome/components/ld2410/switch/engineering_mode_switch.h index 4dd8e16653..49243a73ad 100644 --- a/esphome/components/ld2410/switch/engineering_mode_switch.h +++ b/esphome/components/ld2410/switch/engineering_mode_switch.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class EngineeringModeSwitch : public switch_::Switch, public Parented { +class EngineeringModeSwitch final : public switch_::Switch, public Parented { public: EngineeringModeSwitch() = default; diff --git a/esphome/components/ld2412/button/factory_reset_button.h b/esphome/components/ld2412/button/factory_reset_button.h index 1ef6b23b80..a6ea8f7365 100644 --- a/esphome/components/ld2412/button/factory_reset_button.h +++ b/esphome/components/ld2412/button/factory_reset_button.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class FactoryResetButton : public button::Button, public Parented { +class FactoryResetButton final : public button::Button, public Parented { public: FactoryResetButton() = default; diff --git a/esphome/components/ld2412/button/query_button.h b/esphome/components/ld2412/button/query_button.h index 373e135802..71e2ab14e8 100644 --- a/esphome/components/ld2412/button/query_button.h +++ b/esphome/components/ld2412/button/query_button.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class QueryButton : public button::Button, public Parented { +class QueryButton final : public button::Button, public Parented { public: QueryButton() = default; diff --git a/esphome/components/ld2412/button/restart_button.h b/esphome/components/ld2412/button/restart_button.h index 80c79f5e7d..668ce1a8e6 100644 --- a/esphome/components/ld2412/button/restart_button.h +++ b/esphome/components/ld2412/button/restart_button.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class RestartButton : public button::Button, public Parented { +class RestartButton final : public button::Button, public Parented { public: RestartButton() = default; diff --git a/esphome/components/ld2412/button/start_dynamic_background_correction_button.h b/esphome/components/ld2412/button/start_dynamic_background_correction_button.h index b1f2127896..3b24f5dcf8 100644 --- a/esphome/components/ld2412/button/start_dynamic_background_correction_button.h +++ b/esphome/components/ld2412/button/start_dynamic_background_correction_button.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class StartDynamicBackgroundCorrectionButton : public button::Button, public Parented { +class StartDynamicBackgroundCorrectionButton final : public button::Button, public Parented { public: StartDynamicBackgroundCorrectionButton() = default; diff --git a/esphome/components/ld2412/ld2412.h b/esphome/components/ld2412/ld2412.h index 306e7ae31d..f722f938ae 100644 --- a/esphome/components/ld2412/ld2412.h +++ b/esphome/components/ld2412/ld2412.h @@ -36,7 +36,7 @@ using namespace ld24xx; static constexpr uint8_t MAX_LINE_LENGTH = 54; // Max characters for serial buffer static constexpr uint8_t TOTAL_GATES = 14; // Total number of gates supported by the LD2412 -class LD2412Component : public Component, public uart::UARTDevice { +class LD2412Component final : public Component, public uart::UARTDevice { #ifdef USE_BINARY_SENSOR SUB_BINARY_SENSOR(dynamic_background_correction_status) SUB_BINARY_SENSOR(moving_target) diff --git a/esphome/components/ld2412/number/gate_threshold_number.h b/esphome/components/ld2412/number/gate_threshold_number.h index 78c2e54d82..918b6dfad1 100644 --- a/esphome/components/ld2412/number/gate_threshold_number.h +++ b/esphome/components/ld2412/number/gate_threshold_number.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class GateThresholdNumber : public number::Number, public Parented { +class GateThresholdNumber final : public number::Number, public Parented { public: GateThresholdNumber(uint8_t gate); diff --git a/esphome/components/ld2412/number/light_threshold_number.h b/esphome/components/ld2412/number/light_threshold_number.h index 81fd73111c..f62d523af3 100644 --- a/esphome/components/ld2412/number/light_threshold_number.h +++ b/esphome/components/ld2412/number/light_threshold_number.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class LightThresholdNumber : public number::Number, public Parented { +class LightThresholdNumber final : public number::Number, public Parented { public: LightThresholdNumber() = default; diff --git a/esphome/components/ld2412/number/max_distance_timeout_number.h b/esphome/components/ld2412/number/max_distance_timeout_number.h index c1e947fa19..4a3478d48a 100644 --- a/esphome/components/ld2412/number/max_distance_timeout_number.h +++ b/esphome/components/ld2412/number/max_distance_timeout_number.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class MaxDistanceTimeoutNumber : public number::Number, public Parented { +class MaxDistanceTimeoutNumber final : public number::Number, public Parented { public: MaxDistanceTimeoutNumber() = default; diff --git a/esphome/components/ld2412/select/baud_rate_select.h b/esphome/components/ld2412/select/baud_rate_select.h index 4666dd2fa0..46ec9be1d1 100644 --- a/esphome/components/ld2412/select/baud_rate_select.h +++ b/esphome/components/ld2412/select/baud_rate_select.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class BaudRateSelect : public select::Select, public Parented { +class BaudRateSelect final : public select::Select, public Parented { public: BaudRateSelect() = default; diff --git a/esphome/components/ld2412/select/distance_resolution_select.h b/esphome/components/ld2412/select/distance_resolution_select.h index d3b7fad2f9..be8dba90b5 100644 --- a/esphome/components/ld2412/select/distance_resolution_select.h +++ b/esphome/components/ld2412/select/distance_resolution_select.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class DistanceResolutionSelect : public select::Select, public Parented { +class DistanceResolutionSelect final : public select::Select, public Parented { public: DistanceResolutionSelect() = default; diff --git a/esphome/components/ld2412/select/light_out_control_select.h b/esphome/components/ld2412/select/light_out_control_select.h index 9f86189878..c8988fda78 100644 --- a/esphome/components/ld2412/select/light_out_control_select.h +++ b/esphome/components/ld2412/select/light_out_control_select.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class LightOutControlSelect : public select::Select, public Parented { +class LightOutControlSelect final : public select::Select, public Parented { public: LightOutControlSelect() = default; diff --git a/esphome/components/ld2412/switch/bluetooth_switch.h b/esphome/components/ld2412/switch/bluetooth_switch.h index 0c0d1fa550..8fd4a86e43 100644 --- a/esphome/components/ld2412/switch/bluetooth_switch.h +++ b/esphome/components/ld2412/switch/bluetooth_switch.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class BluetoothSwitch : public switch_::Switch, public Parented { +class BluetoothSwitch final : public switch_::Switch, public Parented { public: BluetoothSwitch() = default; diff --git a/esphome/components/ld2412/switch/engineering_mode_switch.h b/esphome/components/ld2412/switch/engineering_mode_switch.h index 4e75a8a185..defeb4c76b 100644 --- a/esphome/components/ld2412/switch/engineering_mode_switch.h +++ b/esphome/components/ld2412/switch/engineering_mode_switch.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class EngineeringModeSwitch : public switch_::Switch, public Parented { +class EngineeringModeSwitch final : public switch_::Switch, public Parented { public: EngineeringModeSwitch() = default; diff --git a/esphome/components/ledc/ledc_output.h b/esphome/components/ledc/ledc_output.h index bf5cdb9305..b0a243f2e4 100644 --- a/esphome/components/ledc/ledc_output.h +++ b/esphome/components/ledc/ledc_output.h @@ -13,7 +13,7 @@ namespace esphome::ledc { // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) extern uint8_t next_ledc_channel; -class LEDCOutput : public output::FloatOutput, public Component { +class LEDCOutput final : public output::FloatOutput, public Component { public: explicit LEDCOutput(InternalGPIOPin *pin) : pin_(pin) { this->channel_ = next_ledc_channel++; } @@ -43,7 +43,7 @@ class LEDCOutput : public output::FloatOutput, public Component { bool initialized_ = false; }; -template class SetFrequencyAction : public Action { +template class SetFrequencyAction final : public Action { public: SetFrequencyAction(LEDCOutput *parent) : parent_(parent) {} TEMPLATABLE_VALUE(float, frequency); diff --git a/esphome/components/libretiny/gpio_arduino.h b/esphome/components/libretiny/gpio_arduino.h index 5f1fa3fec7..da477fde36 100644 --- a/esphome/components/libretiny/gpio_arduino.h +++ b/esphome/components/libretiny/gpio_arduino.h @@ -5,7 +5,7 @@ namespace esphome::libretiny { -class ArduinoInternalGPIOPin : public InternalGPIOPin { +class ArduinoInternalGPIOPin final : public InternalGPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } diff --git a/esphome/components/libretiny/lt_component.h b/esphome/components/libretiny/lt_component.h index 896f1901e3..3850a679b2 100644 --- a/esphome/components/libretiny/lt_component.h +++ b/esphome/components/libretiny/lt_component.h @@ -14,7 +14,7 @@ namespace esphome::libretiny { -class LTComponent : public Component { +class LTComponent final : public Component { public: float get_setup_priority() const override; void dump_config() override; diff --git a/esphome/components/libretiny_pwm/libretiny_pwm.h b/esphome/components/libretiny_pwm/libretiny_pwm.h index f7737be386..f0ea0228b7 100644 --- a/esphome/components/libretiny_pwm/libretiny_pwm.h +++ b/esphome/components/libretiny_pwm/libretiny_pwm.h @@ -9,7 +9,7 @@ namespace esphome::libretiny_pwm { -class LibreTinyPWM : public output::FloatOutput, public Component { +class LibreTinyPWM final : public output::FloatOutput, public Component { public: explicit LibreTinyPWM(InternalGPIOPin *pin) : pin_(pin) {} @@ -34,7 +34,7 @@ class LibreTinyPWM : public output::FloatOutput, public Component { bool initialized_ = false; }; -template class SetFrequencyAction : public Action { +template class SetFrequencyAction final : public Action { public: SetFrequencyAction(LibreTinyPWM *parent) : parent_(parent) {} TEMPLATABLE_VALUE(float, frequency); diff --git a/esphome/components/light/addressable_light.h b/esphome/components/light/addressable_light.h index 0202ad380a..57e9caf289 100644 --- a/esphome/components/light/addressable_light.h +++ b/esphome/components/light/addressable_light.h @@ -21,7 +21,7 @@ Color color_from_light_color_values(LightColorValues val); /// Use a custom state class for addressable lights, to allow type system to discriminate between addressable and /// non-addressable lights. -class AddressableLightState : public LightState { +class AddressableLightState final : public LightState { using LightState::LightState; }; diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index 260414f033..ced15dfc60 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -8,7 +8,7 @@ namespace esphome::light { enum class LimitMode { CLAMP, DO_NOTHING }; -template class ToggleAction : public Action { +template class ToggleAction final : public Action { public: explicit ToggleAction(LightState *state) : state_(state) {} @@ -43,7 +43,7 @@ template class ToggleAction : public A // (e.g. `const T & &` if Ts already carries a reference, or `const const // T &` if Ts already carries a const). This keeps trigger args no-copy // regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class LightControlAction : public Action { +template class LightControlAction final : public Action { public: using ApplyFn = void (*)(LightState *, LightCall &, const std::remove_cvref_t &...); LightControlAction(LightState *parent, ApplyFn apply) : parent_(parent), apply_(apply) {} @@ -59,7 +59,7 @@ template class LightControlAction : public Action { ApplyFn apply_; }; -template class DimRelativeAction : public Action { +template class DimRelativeAction final : public Action { public: explicit DimRelativeAction(LightState *parent) : parent_(parent) {} @@ -108,7 +108,7 @@ template class DimRelativeAction : pub // at compile time so the chosen branch is the only one that gets instantiated // per action site. `include_none` is runtime so a single set of templates // covers both the "wrap through None" and "skip None" variants. -template class LightEffectCycleAction : public Action { +template class LightEffectCycleAction final : public Action { public: explicit LightEffectCycleAction(LightState *parent) : parent_(parent) {} @@ -145,7 +145,7 @@ template class LightEffectCycleAction : public Act bool include_none_{false}; }; -template class LightIsOnCondition : public Condition { +template class LightIsOnCondition final : public Condition { public: explicit LightIsOnCondition(LightState *state) : state_(state) {} bool check(const Ts &...x) override { return this->state_->current_values.is_on(); } @@ -153,7 +153,7 @@ template class LightIsOnCondition : public Condition { protected: LightState *state_; }; -template class LightIsOffCondition : public Condition { +template class LightIsOffCondition final : public Condition { public: explicit LightIsOffCondition(LightState *state) : state_(state) {} bool check(const Ts &...x) override { return !this->state_->current_values.is_on(); } @@ -162,7 +162,7 @@ template class LightIsOffCondition : public Condition { LightState *state_; }; -class LightTurnOnTrigger : public Trigger<>, public LightRemoteValuesListener { +class LightTurnOnTrigger final : public Trigger<>, public LightRemoteValuesListener { public: explicit LightTurnOnTrigger(LightState *a_light) : light_(a_light) { a_light->add_remote_values_listener(this); @@ -187,7 +187,7 @@ class LightTurnOnTrigger : public Trigger<>, public LightRemoteValuesListener { bool last_on_; }; -class LightTurnOffTrigger : public Trigger<>, public LightTargetStateReachedListener { +class LightTurnOffTrigger final : public Trigger<>, public LightTargetStateReachedListener { public: explicit LightTurnOffTrigger(LightState *a_light) : light_(a_light) { a_light->add_target_state_reached_listener(this); @@ -205,7 +205,7 @@ class LightTurnOffTrigger : public Trigger<>, public LightTargetStateReachedList LightState *light_; }; -class LightStateTrigger : public Trigger<>, public LightRemoteValuesListener { +class LightStateTrigger final : public Trigger<>, public LightRemoteValuesListener { public: explicit LightStateTrigger(LightState *a_light) { a_light->add_remote_values_listener(this); } @@ -216,7 +216,7 @@ class LightStateTrigger : public Trigger<>, public LightRemoteValuesListener { // due to the template. It's just a temporary warning anyway. void addressableset_warn_about_scale(const char *field); -template class AddressableSet : public Action { +template class AddressableSet final : public Action { public: explicit AddressableSet(LightState *parent) : parent_(parent) {} diff --git a/esphome/components/lightwaverf/lightwaverf.h b/esphome/components/lightwaverf/lightwaverf.h index 224da6315f..36dac3c86f 100644 --- a/esphome/components/lightwaverf/lightwaverf.h +++ b/esphome/components/lightwaverf/lightwaverf.h @@ -15,7 +15,7 @@ namespace esphome::lightwaverf { #ifdef USE_ESP8266 -class LightWaveRF : public PollingComponent { +class LightWaveRF final : public PollingComponent { public: void set_pin(InternalGPIOPin *pin_tx, InternalGPIOPin *pin_rx) { pin_tx_ = pin_tx; @@ -37,7 +37,7 @@ class LightWaveRF : public PollingComponent { LwTx lwtx_; }; -template class SendRawAction : public Action { +template class SendRawAction final : public Action { public: SendRawAction(LightWaveRF *parent) : parent_(parent){}; TEMPLATABLE_VALUE(int, repeat); diff --git a/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.h b/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.h index 8b345515ab..ad82e6c3a0 100644 --- a/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.h +++ b/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.h @@ -12,7 +12,7 @@ namespace esphome::lilygo_t5_47 { using namespace touchscreen; -class LilygoT547Touchscreen : public Touchscreen, public i2c::I2CDevice { +class LilygoT547Touchscreen final : public Touchscreen, public i2c::I2CDevice { public: void setup() override; diff --git a/esphome/components/lm75b/lm75b.h b/esphome/components/lm75b/lm75b.h index eaf1b46550..3d5b97ae5b 100644 --- a/esphome/components/lm75b/lm75b.h +++ b/esphome/components/lm75b/lm75b.h @@ -8,7 +8,7 @@ namespace esphome::lm75b { static const uint8_t LM75B_REG_TEMPERATURE = 0x00; -class LM75BComponent : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { +class LM75BComponent final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: void dump_config() override; void update() override; diff --git a/esphome/components/lock/automation.h b/esphome/components/lock/automation.h index c140bc568f..ec6ead79f3 100644 --- a/esphome/components/lock/automation.h +++ b/esphome/components/lock/automation.h @@ -6,7 +6,7 @@ namespace esphome::lock { -template class LockAction : public Action { +template class LockAction final : public Action { public: explicit LockAction(Lock *a_lock) : lock_(a_lock) {} @@ -16,7 +16,7 @@ template class LockAction : public Action { Lock *lock_; }; -template class UnlockAction : public Action { +template class UnlockAction final : public Action { public: explicit UnlockAction(Lock *a_lock) : lock_(a_lock) {} @@ -26,7 +26,7 @@ template class UnlockAction : public Action { Lock *lock_; }; -template class OpenAction : public Action { +template class OpenAction final : public Action { public: explicit OpenAction(Lock *a_lock) : lock_(a_lock) {} @@ -36,7 +36,7 @@ template class OpenAction : public Action { Lock *lock_; }; -template class LockCondition : public Condition { +template class LockCondition final : public Condition { public: LockCondition(Lock *parent, bool state) : parent_(parent), state_(state) {} bool check(const Ts &...x) override { diff --git a/esphome/components/lps22/lps22.h b/esphome/components/lps22/lps22.h index c6746f2343..020c14296c 100644 --- a/esphome/components/lps22/lps22.h +++ b/esphome/components/lps22/lps22.h @@ -6,7 +6,7 @@ namespace esphome::lps22 { -class LPS22Component : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class LPS22Component final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_pressure_sensor(sensor::Sensor *pressure_sensor) { this->pressure_sensor_ = pressure_sensor; } diff --git a/esphome/components/lsm6ds/lsm6ds.h b/esphome/components/lsm6ds/lsm6ds.h index 75462ff1fb..47d6f55939 100644 --- a/esphome/components/lsm6ds/lsm6ds.h +++ b/esphome/components/lsm6ds/lsm6ds.h @@ -82,7 +82,7 @@ enum LSM6DSGyroODR : uint8_t { }; // ── Main component class ───────────────────────────────────────────────────── -class LSM6DSComponent : public motion::MotionComponent, public i2c::I2CDevice { +class LSM6DSComponent final : public motion::MotionComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ltr390/ltr390.h b/esphome/components/ltr390/ltr390.h index 1ead84b4a8..1e3b6494bb 100644 --- a/esphome/components/ltr390/ltr390.h +++ b/esphome/components/ltr390/ltr390.h @@ -39,7 +39,7 @@ enum LTR390RESOLUTION { LTR390_RESOLUTION_13BIT, }; -class LTR390Component : public PollingComponent, public i2c::I2CDevice { +class LTR390Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ltr501/ltr501.h b/esphome/components/ltr501/ltr501.h index c7eccbeea9..d1f7648d4c 100644 --- a/esphome/components/ltr501/ltr501.h +++ b/esphome/components/ltr501/ltr501.h @@ -19,7 +19,7 @@ enum LtrType : uint8_t { LTR_TYPE_ALS_AND_PS = 3, }; -class LTRAlsPs501Component : public PollingComponent, public i2c::I2CDevice { +class LTRAlsPs501Component final : public PollingComponent, public i2c::I2CDevice { public: // // EspHome framework functions diff --git a/esphome/components/lvgl/light/lvgl_light.h b/esphome/components/lvgl/light/lvgl_light.h index bf019964c7..37dc4135ce 100644 --- a/esphome/components/lvgl/light/lvgl_light.h +++ b/esphome/components/lvgl/light/lvgl_light.h @@ -6,7 +6,7 @@ namespace esphome::lvgl { -class LVLight : public light::LightOutput { +class LVLight final : public light::LightOutput { public: light::LightTraits get_traits() override { auto traits = light::LightTraits(); diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 3f7f1dce14..8840b0ad30 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -155,7 +155,7 @@ class LvPageType : public Parented { using event_callback_t = void(lv_event_t *); -class LvLambdaComponent : public Component { +class LvLambdaComponent final : public Component { public: LvLambdaComponent(void (*callback)()) : callback_(callback) {} @@ -167,7 +167,7 @@ class LvLambdaComponent : public Component { void (*callback_)(); }; -template class ObjUpdateAction : public Action { +template class ObjUpdateAction final : public Action { public: explicit ObjUpdateAction(std::function &&lamb) : lamb_(std::move(lamb)) {} @@ -185,7 +185,7 @@ enum RotationType : uint8_t { ROTATION_HARDWARE, }; -class LvglComponent : public PollingComponent { +class LvglComponent final : public PollingComponent { constexpr static const char *const TAG = "lvgl"; public: @@ -339,7 +339,7 @@ class LvglComponent : public PollingComponent { #endif }; -class IdleTrigger : public Trigger<> { +class IdleTrigger final : public Trigger<> { public: explicit IdleTrigger(LvglComponent *parent, TemplatableFn timeout); @@ -348,7 +348,7 @@ class IdleTrigger : public Trigger<> { bool is_idle_{}; }; -template class LvglAction : public Action, public Parented { +template class LvglAction final : public Action, public Parented { public: explicit LvglAction(std::function &&lamb) : action_(std::move(lamb)) {} @@ -357,7 +357,7 @@ template class LvglAction : public Action, public Parente std::function action_{}; }; -template class LvglCondition : public Condition, public Parented { +template class LvglCondition final : public Condition, public Parented { public: LvglCondition(std::function &&condition_lambda) : condition_lambda_(std::move(condition_lambda)) {} bool check(const Ts &...x) override { return this->condition_lambda_(this->parent_); } @@ -367,7 +367,7 @@ template class LvglCondition : public Condition { +class LVTouchListener final : public touchscreen::TouchListener, public Parented { public: LVTouchListener(uint16_t long_press_time, uint16_t long_press_repeat_time, LvglComponent *parent); void update(const touchscreen::TouchPoints_t &tpoints) override; @@ -403,7 +403,7 @@ class IndicatorLine : public LvCompound { #endif #ifdef USE_LVGL_KEY_LISTENER -class LVEncoderListener : public Parented { +class LVEncoderListener final : public Parented { public: LVEncoderListener(lv_indev_type_t type, uint16_t long_press_time, uint16_t long_press_repeat_time); diff --git a/esphome/components/lvgl/number/lvgl_number.h b/esphome/components/lvgl/number/lvgl_number.h index 3fda9427c5..eb2f70b4da 100644 --- a/esphome/components/lvgl/number/lvgl_number.h +++ b/esphome/components/lvgl/number/lvgl_number.h @@ -8,7 +8,7 @@ namespace esphome::lvgl { -class LVGLNumber : public number::Number, public Component { +class LVGLNumber final : public number::Number, public Component { public: LVGLNumber(std::function control_lambda, std::function value_lambda, bool restore) : control_lambda_(std::move(control_lambda)), value_lambda_(std::move(value_lambda)), restore_(restore) {} diff --git a/esphome/components/lvgl/select/lvgl_select.h b/esphome/components/lvgl/select/lvgl_select.h index ffbe29d701..e36357328c 100644 --- a/esphome/components/lvgl/select/lvgl_select.h +++ b/esphome/components/lvgl/select/lvgl_select.h @@ -10,7 +10,7 @@ namespace esphome::lvgl { -class LVGLSelect : public select::Select, public Component { +class LVGLSelect final : public select::Select, public Component { public: LVGLSelect(LvSelectable *widget, lv_anim_enable_t anim, bool restore) : widget_(widget), anim_(anim), restore_(restore) {} diff --git a/esphome/components/lvgl/switch/lvgl_switch.h b/esphome/components/lvgl/switch/lvgl_switch.h index 8f5502a7d5..ea15767ba8 100644 --- a/esphome/components/lvgl/switch/lvgl_switch.h +++ b/esphome/components/lvgl/switch/lvgl_switch.h @@ -9,7 +9,7 @@ namespace esphome::lvgl { -class LVGLSwitch : public switch_::Switch, public Component { +class LVGLSwitch final : public switch_::Switch, public Component { public: LVGLSwitch(std::function state_lambda) : state_lambda_(std::move(state_lambda)) {} diff --git a/esphome/components/lvgl/text/lvgl_text.h b/esphome/components/lvgl/text/lvgl_text.h index fead48d6fe..8d83f323ab 100644 --- a/esphome/components/lvgl/text/lvgl_text.h +++ b/esphome/components/lvgl/text/lvgl_text.h @@ -6,7 +6,7 @@ namespace esphome::lvgl { -class LVGLText : public text::Text { +class LVGLText final : public text::Text { public: void set_control_lambda(const std::function &control_lambda) { this->control_lambda_ = control_lambda; diff --git a/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.h b/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.h index 14400bcea1..a49f6dbb54 100644 --- a/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.h +++ b/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.h @@ -7,9 +7,9 @@ namespace esphome::m5stack_8angle { -class M5Stack8AngleSwitchBinarySensor : public binary_sensor::BinarySensor, - public PollingComponent, - public Parented { +class M5Stack8AngleSwitchBinarySensor final : public binary_sensor::BinarySensor, + public PollingComponent, + public Parented { public: void update() override; }; diff --git a/esphome/components/m5stack_8angle/light/m5stack_8angle_light.h b/esphome/components/m5stack_8angle/light/m5stack_8angle_light.h index 0a5a50f2a8..ee204c239b 100644 --- a/esphome/components/m5stack_8angle/light/m5stack_8angle_light.h +++ b/esphome/components/m5stack_8angle/light/m5stack_8angle_light.h @@ -10,7 +10,7 @@ namespace esphome::m5stack_8angle { static const uint8_t M5STACK_8ANGLE_NUM_LEDS = 9; static const uint8_t M5STACK_8ANGLE_BYTES_PER_LED = 4; -class M5Stack8AngleLightOutput : public light::AddressableLight, public Parented { +class M5Stack8AngleLightOutput final : public light::AddressableLight, public Parented { public: void setup() override; diff --git a/esphome/components/m5stack_8angle/m5stack_8angle.h b/esphome/components/m5stack_8angle/m5stack_8angle.h index ab2e232204..058949cad5 100644 --- a/esphome/components/m5stack_8angle/m5stack_8angle.h +++ b/esphome/components/m5stack_8angle/m5stack_8angle.h @@ -16,7 +16,7 @@ enum AnalogBits : uint8_t { BITS_12 = 12, }; -class M5Stack8AngleComponent : public i2c::I2CDevice, public Component { +class M5Stack8AngleComponent final : public i2c::I2CDevice, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.h b/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.h index 418503d7c8..a270661ad6 100644 --- a/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.h +++ b/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.h @@ -7,9 +7,9 @@ namespace esphome::m5stack_8angle { -class M5Stack8AngleKnobSensor : public sensor::Sensor, - public PollingComponent, - public Parented { +class M5Stack8AngleKnobSensor final : public sensor::Sensor, + public PollingComponent, + public Parented { public: void update() override; void set_channel(uint8_t channel) { this->channel_ = channel; }; From 089147328057c1dcb82d652bd7a6ac77f9537d3f Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:04:34 +1200 Subject: [PATCH 0527/1815] Mark configurable classes as final (10/21: matrix_keypad-micronova) (#16961) --- .../matrix_keypad_binary_sensor.h | 2 +- .../components/matrix_keypad/matrix_keypad.h | 4 ++-- esphome/components/max17043/automation.h | 2 +- esphome/components/max17043/max17043.h | 2 +- esphome/components/max31855/max31855.h | 8 ++++---- esphome/components/max31856/max31856.h | 8 ++++---- esphome/components/max31865/max31865.h | 8 ++++---- esphome/components/max44009/max44009.h | 2 +- esphome/components/max6675/max6675.h | 8 ++++---- esphome/components/max6956/automation.h | 4 ++-- esphome/components/max6956/max6956.h | 4 ++-- .../max6956/output/max6956_led_output.h | 2 +- esphome/components/max7219/max7219.h | 6 +++--- esphome/components/max7219digit/automation.h | 8 ++++---- .../components/max7219digit/max7219digit.h | 6 +++--- esphome/components/max9611/max9611.h | 2 +- esphome/components/mcp23008/mcp23008.h | 2 +- esphome/components/mcp23016/mcp23016.h | 4 ++-- esphome/components/mcp23017/mcp23017.h | 2 +- esphome/components/mcp23s08/mcp23s08.h | 6 +++--- esphome/components/mcp23s17/mcp23s17.h | 6 +++--- .../components/mcp23xxx_base/mcp23xxx_base.h | 2 +- esphome/components/mcp2515/mcp2515.h | 6 +++--- esphome/components/mcp3008/mcp3008.h | 8 ++++---- .../mcp3008/sensor/mcp3008_sensor.h | 8 ++++---- esphome/components/mcp3204/mcp3204.h | 6 +++--- .../mcp3204/sensor/mcp3204_sensor.h | 8 ++++---- esphome/components/mcp3221/mcp3221_sensor.h | 8 ++++---- esphome/components/mcp4461/mcp4461.h | 2 +- .../mcp4461/output/mcp4461_output.h | 2 +- esphome/components/mcp4725/mcp4725.h | 2 +- esphome/components/mcp4728/mcp4728.h | 2 +- .../mcp4728/output/mcp4728_output.h | 2 +- esphome/components/mcp47a1/mcp47a1.h | 2 +- esphome/components/mcp9600/mcp9600.h | 2 +- esphome/components/mcp9808/mcp9808.h | 2 +- esphome/components/media_player/automation.h | 20 +++++++++---------- esphome/components/mhz19/mhz19.h | 11 +++++----- .../micronova/button/micronova_button.h | 2 +- esphome/components/micronova/micronova.h | 2 +- .../micronova/number/micronova_number.h | 2 +- .../micronova/sensor/micronova_sensor.h | 2 +- .../micronova/switch/micronova_switch.h | 2 +- .../text_sensor/micronova_text_sensor.h | 2 +- 44 files changed, 101 insertions(+), 100 deletions(-) diff --git a/esphome/components/matrix_keypad/binary_sensor/matrix_keypad_binary_sensor.h b/esphome/components/matrix_keypad/binary_sensor/matrix_keypad_binary_sensor.h index 53ae0b5c03..000a9e5de3 100644 --- a/esphome/components/matrix_keypad/binary_sensor/matrix_keypad_binary_sensor.h +++ b/esphome/components/matrix_keypad/binary_sensor/matrix_keypad_binary_sensor.h @@ -5,7 +5,7 @@ namespace esphome::matrix_keypad { -class MatrixKeypadBinarySensor : public MatrixKeypadListener, public binary_sensor::BinarySensorInitiallyOff { +class MatrixKeypadBinarySensor final : public MatrixKeypadListener, public binary_sensor::BinarySensorInitiallyOff { public: MatrixKeypadBinarySensor(uint8_t key) : has_key_(true), key_(key){}; MatrixKeypadBinarySensor(const char *key) : has_key_(true), key_((uint8_t) key[0]){}; diff --git a/esphome/components/matrix_keypad/matrix_keypad.h b/esphome/components/matrix_keypad/matrix_keypad.h index 1e263842ea..8c9acc8e0c 100644 --- a/esphome/components/matrix_keypad/matrix_keypad.h +++ b/esphome/components/matrix_keypad/matrix_keypad.h @@ -18,9 +18,9 @@ class MatrixKeypadListener { virtual void key_released(uint8_t key){}; }; -class MatrixKeyTrigger : public Trigger {}; +class MatrixKeyTrigger final : public Trigger {}; -class MatrixKeypad : public key_provider::KeyProvider, public Component { +class MatrixKeypad final : public key_provider::KeyProvider, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/max17043/automation.h b/esphome/components/max17043/automation.h index c98516d259..6b19e5bd5e 100644 --- a/esphome/components/max17043/automation.h +++ b/esphome/components/max17043/automation.h @@ -5,7 +5,7 @@ namespace esphome::max17043 { -template class SleepAction : public Action { +template class SleepAction final : public Action { public: explicit SleepAction(MAX17043Component *max17043) : max17043_(max17043) {} diff --git a/esphome/components/max17043/max17043.h b/esphome/components/max17043/max17043.h index dd2e35df55..ffe4d916ba 100644 --- a/esphome/components/max17043/max17043.h +++ b/esphome/components/max17043/max17043.h @@ -6,7 +6,7 @@ namespace esphome::max17043 { -class MAX17043Component : public PollingComponent, public i2c::I2CDevice { +class MAX17043Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/max31855/max31855.h b/esphome/components/max31855/max31855.h index dd7a205268..527d26f99d 100644 --- a/esphome/components/max31855/max31855.h +++ b/esphome/components/max31855/max31855.h @@ -8,10 +8,10 @@ namespace esphome::max31855 { -class MAX31855Sensor : public sensor::Sensor, - public PollingComponent, - public spi::SPIDevice { +class MAX31855Sensor final : public sensor::Sensor, + public PollingComponent, + public spi::SPIDevice { public: void set_reference_sensor(sensor::Sensor *temperature_sensor) { temperature_reference_ = temperature_sensor; } diff --git a/esphome/components/max31856/max31856.h b/esphome/components/max31856/max31856.h index 0a983b72d9..83aa815aa0 100644 --- a/esphome/components/max31856/max31856.h +++ b/esphome/components/max31856/max31856.h @@ -68,10 +68,10 @@ enum MAX31856ConfigFilter { FILTER_50HZ = 1, }; -class MAX31856Sensor : public sensor::Sensor, - public PollingComponent, - public spi::SPIDevice { +class MAX31856Sensor final : public sensor::Sensor, + public PollingComponent, + public spi::SPIDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/max31865/max31865.h b/esphome/components/max31865/max31865.h index 3362cd30de..27c107ba0b 100644 --- a/esphome/components/max31865/max31865.h +++ b/esphome/components/max31865/max31865.h @@ -22,10 +22,10 @@ enum MAX31865ConfigFilter { FILTER_50HZ = 1, }; -class MAX31865Sensor : public sensor::Sensor, - public PollingComponent, - public spi::SPIDevice { +class MAX31865Sensor final : public sensor::Sensor, + public PollingComponent, + public spi::SPIDevice { public: void set_reference_resistance(float reference_resistance) { reference_resistance_ = reference_resistance; } void set_nominal_resistance(float nominal_resistance) { rtd_nominal_resistance_ = nominal_resistance; } diff --git a/esphome/components/max44009/max44009.h b/esphome/components/max44009/max44009.h index 12fd0b1ce0..b62aed7a56 100644 --- a/esphome/components/max44009/max44009.h +++ b/esphome/components/max44009/max44009.h @@ -9,7 +9,7 @@ namespace esphome::max44009 { enum MAX44009Mode { MAX44009_MODE_AUTO, MAX44009_MODE_LOW_POWER, MAX44009_MODE_CONTINUOUS }; /// This class implements support for the MAX44009 Illuminance i2c sensor. -class MAX44009Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class MAX44009Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: MAX44009Sensor() {} diff --git a/esphome/components/max6675/max6675.h b/esphome/components/max6675/max6675.h index e7b5c4dbde..fc46c8c047 100644 --- a/esphome/components/max6675/max6675.h +++ b/esphome/components/max6675/max6675.h @@ -6,10 +6,10 @@ namespace esphome::max6675 { -class MAX6675Sensor : public sensor::Sensor, - public PollingComponent, - public spi::SPIDevice { +class MAX6675Sensor final : public sensor::Sensor, + public PollingComponent, + public spi::SPIDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/max6956/automation.h b/esphome/components/max6956/automation.h index 547ed5a865..f1db2e3240 100644 --- a/esphome/components/max6956/automation.h +++ b/esphome/components/max6956/automation.h @@ -6,7 +6,7 @@ namespace esphome::max6956 { -template class SetCurrentGlobalAction : public Action { +template class SetCurrentGlobalAction final : public Action { public: SetCurrentGlobalAction(MAX6956 *max6956) : max6956_(max6956) {} @@ -21,7 +21,7 @@ template class SetCurrentGlobalAction : public Action { MAX6956 *max6956_; }; -template class SetCurrentModeAction : public Action { +template class SetCurrentModeAction final : public Action { public: SetCurrentModeAction(MAX6956 *max6956) : max6956_(max6956) {} diff --git a/esphome/components/max6956/max6956.h b/esphome/components/max6956/max6956.h index 83ccfab559..4dbee16528 100644 --- a/esphome/components/max6956/max6956.h +++ b/esphome/components/max6956/max6956.h @@ -35,7 +35,7 @@ enum MAX6956GPIOFlag { FLAG_LED = 0x20 }; enum MAX6956CURRENTMODE { GLOBAL = 0x00, SEGMENT = 0x01 }; -class MAX6956 : public Component, public i2c::I2CDevice { +class MAX6956 final : public Component, public i2c::I2CDevice { public: MAX6956() = default; @@ -69,7 +69,7 @@ class MAX6956 : public Component, public i2c::I2CDevice { int8_t prev_bright_[28] = {0}; }; -class MAX6956GPIOPin : public GPIOPin { +class MAX6956GPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/max6956/output/max6956_led_output.h b/esphome/components/max6956/output/max6956_led_output.h index 49e5b9ef84..c40e41371d 100644 --- a/esphome/components/max6956/output/max6956_led_output.h +++ b/esphome/components/max6956/output/max6956_led_output.h @@ -7,7 +7,7 @@ namespace esphome::max6956 { class MAX6956; -class MAX6956LedChannel : public output::FloatOutput, public Component { +class MAX6956LedChannel final : public output::FloatOutput, public Component { public: void set_parent(MAX6956 *parent) { this->parent_ = parent; } void set_pin(uint8_t pin) { pin_ = pin; } diff --git a/esphome/components/max7219/max7219.h b/esphome/components/max7219/max7219.h index ef38628f28..3eb4b8e27f 100644 --- a/esphome/components/max7219/max7219.h +++ b/esphome/components/max7219/max7219.h @@ -12,9 +12,9 @@ class MAX7219Component; using max7219_writer_t = display::DisplayWriter; -class MAX7219Component : public PollingComponent, - public spi::SPIDevice { +class MAX7219Component final : public PollingComponent, + public spi::SPIDevice { public: explicit MAX7219Component(uint8_t num_chips); diff --git a/esphome/components/max7219digit/automation.h b/esphome/components/max7219digit/automation.h index 485a34075e..f06dfd5087 100644 --- a/esphome/components/max7219digit/automation.h +++ b/esphome/components/max7219digit/automation.h @@ -7,7 +7,7 @@ namespace esphome::max7219digit { -template class DisplayInvertAction : public Action, public Parented { +template class DisplayInvertAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, state) @@ -17,7 +17,7 @@ template class DisplayInvertAction : public Action, publi } }; -template class DisplayVisibilityAction : public Action, public Parented { +template class DisplayVisibilityAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, state) @@ -27,7 +27,7 @@ template class DisplayVisibilityAction : public Action, p } }; -template class DisplayReverseAction : public Action, public Parented { +template class DisplayReverseAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, state) @@ -37,7 +37,7 @@ template class DisplayReverseAction : public Action, publ } }; -template class DisplayIntensityAction : public Action, public Parented { +template class DisplayIntensityAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, state) diff --git a/esphome/components/max7219digit/max7219digit.h b/esphome/components/max7219digit/max7219digit.h index bbf43059dd..9e6db20444 100644 --- a/esphome/components/max7219digit/max7219digit.h +++ b/esphome/components/max7219digit/max7219digit.h @@ -24,9 +24,9 @@ class MAX7219Component; using max7219_writer_t = display::DisplayWriter; -class MAX7219Component : public display::DisplayBuffer, - public spi::SPIDevice { +class MAX7219Component final : public display::DisplayBuffer, + public spi::SPIDevice { public: void set_writer(max7219_writer_t &&writer) { this->writer_local_ = writer; }; diff --git a/esphome/components/max9611/max9611.h b/esphome/components/max9611/max9611.h index b6fb5d8127..54e6414c79 100644 --- a/esphome/components/max9611/max9611.h +++ b/esphome/components/max9611/max9611.h @@ -33,7 +33,7 @@ enum MAX9611RegisterMap { CONTROL_REGISTER_2_ADRR = 0x0B, }; -class MAX9611Component : public PollingComponent, public i2c::I2CDevice { +class MAX9611Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mcp23008/mcp23008.h b/esphome/components/mcp23008/mcp23008.h index ae2f9e1f3c..38bd9c1ac4 100644 --- a/esphome/components/mcp23008/mcp23008.h +++ b/esphome/components/mcp23008/mcp23008.h @@ -7,7 +7,7 @@ namespace esphome::mcp23008 { -class MCP23008 : public mcp23x08_base::MCP23X08Base, public i2c::I2CDevice { +class MCP23008 final : public mcp23x08_base::MCP23X08Base, public i2c::I2CDevice { public: MCP23008() = default; diff --git a/esphome/components/mcp23016/mcp23016.h b/esphome/components/mcp23016/mcp23016.h index 4a936a5b02..14c0c9a2fc 100644 --- a/esphome/components/mcp23016/mcp23016.h +++ b/esphome/components/mcp23016/mcp23016.h @@ -24,7 +24,7 @@ enum MCP23016GPIORegisters { MCP23016_IOCON1 = 0x0B, }; -class MCP23016 : public Component, public i2c::I2CDevice, public gpio_expander::CachedGpioExpander { +class MCP23016 final : public Component, public i2c::I2CDevice, public gpio_expander::CachedGpioExpander { public: MCP23016() = default; @@ -56,7 +56,7 @@ class MCP23016 : public Component, public i2c::I2CDevice, public gpio_expander:: InternalGPIOPin *interrupt_pin_{nullptr}; }; -class MCP23016GPIOPin : public GPIOPin { +class MCP23016GPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/mcp23017/mcp23017.h b/esphome/components/mcp23017/mcp23017.h index 86b84f9ad8..c745322bdf 100644 --- a/esphome/components/mcp23017/mcp23017.h +++ b/esphome/components/mcp23017/mcp23017.h @@ -7,7 +7,7 @@ namespace esphome::mcp23017 { -class MCP23017 : public mcp23x17_base::MCP23X17Base, public i2c::I2CDevice { +class MCP23017 final : public mcp23x17_base::MCP23X17Base, public i2c::I2CDevice { public: MCP23017() = default; diff --git a/esphome/components/mcp23s08/mcp23s08.h b/esphome/components/mcp23s08/mcp23s08.h index 441525469f..270d1467e2 100644 --- a/esphome/components/mcp23s08/mcp23s08.h +++ b/esphome/components/mcp23s08/mcp23s08.h @@ -7,9 +7,9 @@ namespace esphome::mcp23s08 { -class MCP23S08 : public mcp23x08_base::MCP23X08Base, - public spi::SPIDevice { +class MCP23S08 final : public mcp23x08_base::MCP23X08Base, + public spi::SPIDevice { public: MCP23S08() = default; diff --git a/esphome/components/mcp23s17/mcp23s17.h b/esphome/components/mcp23s17/mcp23s17.h index 0cc9321c88..5346b2c8e2 100644 --- a/esphome/components/mcp23s17/mcp23s17.h +++ b/esphome/components/mcp23s17/mcp23s17.h @@ -7,9 +7,9 @@ namespace esphome::mcp23s17 { -class MCP23S17 : public mcp23x17_base::MCP23X17Base, - public spi::SPIDevice { +class MCP23S17 final : public mcp23x17_base::MCP23X17Base, + public spi::SPIDevice { public: MCP23S17() = default; diff --git a/esphome/components/mcp23xxx_base/mcp23xxx_base.h b/esphome/components/mcp23xxx_base/mcp23xxx_base.h index 5904a1eef6..1c45b0f4af 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.h +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.h @@ -56,7 +56,7 @@ template class MCP23XXXBase : public Component, public gpio_expander: InternalGPIOPin *interrupt_pin_{nullptr}; }; -template class MCP23XXXGPIOPin : public GPIOPin { +template class MCP23XXXGPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/mcp2515/mcp2515.h b/esphome/components/mcp2515/mcp2515.h index b77d9a2582..960e150800 100644 --- a/esphome/components/mcp2515/mcp2515.h +++ b/esphome/components/mcp2515/mcp2515.h @@ -51,9 +51,9 @@ enum STAT : uint8_t { STAT_RX0IF = (1 << 0), STAT_RX1IF = (1 << 1) }; static const uint8_t STAT_RXIF_MASK = STAT_RX0IF | STAT_RX1IF; static const uint8_t EFLG_ERRORMASK = EFLG_RX1OVR | EFLG_RX0OVR | EFLG_TXBO | EFLG_TXEP | EFLG_RXEP; -class MCP2515 : public canbus::Canbus, - public spi::SPIDevice { +class MCP2515 final : public canbus::Canbus, + public spi::SPIDevice { public: MCP2515(){}; void set_mcp_clock(CanClock clock) { this->mcp_clock_ = clock; }; diff --git a/esphome/components/mcp3008/mcp3008.h b/esphome/components/mcp3008/mcp3008.h index 1b1b50c793..d45d587ae8 100644 --- a/esphome/components/mcp3008/mcp3008.h +++ b/esphome/components/mcp3008/mcp3008.h @@ -6,10 +6,10 @@ namespace esphome::mcp3008 { -class MCP3008 : public Component, - public spi::SPIDevice { // Running at the slowest max speed supported by the - // mcp3008. 2.7v = 75ksps +class MCP3008 final : public Component, + public spi::SPIDevice { // Running at the slowest max speed supported by + // the mcp3008. 2.7v = 75ksps public: void setup() override; void dump_config() override; diff --git a/esphome/components/mcp3008/sensor/mcp3008_sensor.h b/esphome/components/mcp3008/sensor/mcp3008_sensor.h index 9267f80ea8..d72d521f65 100644 --- a/esphome/components/mcp3008/sensor/mcp3008_sensor.h +++ b/esphome/components/mcp3008/sensor/mcp3008_sensor.h @@ -8,10 +8,10 @@ namespace esphome::mcp3008 { -class MCP3008Sensor : public PollingComponent, - public sensor::Sensor, - public voltage_sampler::VoltageSampler, - public Parented { +class MCP3008Sensor final : public PollingComponent, + public sensor::Sensor, + public voltage_sampler::VoltageSampler, + public Parented { public: void set_reference_voltage(float reference_voltage) { this->reference_voltage_ = reference_voltage; } void set_pin(uint8_t pin) { this->pin_ = pin; } diff --git a/esphome/components/mcp3204/mcp3204.h b/esphome/components/mcp3204/mcp3204.h index 8ce592f386..6b835b67df 100644 --- a/esphome/components/mcp3204/mcp3204.h +++ b/esphome/components/mcp3204/mcp3204.h @@ -6,9 +6,9 @@ namespace esphome::mcp3204 { -class MCP3204 : public Component, - public spi::SPIDevice { +class MCP3204 final : public Component, + public spi::SPIDevice { public: MCP3204() = default; diff --git a/esphome/components/mcp3204/sensor/mcp3204_sensor.h b/esphome/components/mcp3204/sensor/mcp3204_sensor.h index 5fe5f54d1b..54835c232b 100644 --- a/esphome/components/mcp3204/sensor/mcp3204_sensor.h +++ b/esphome/components/mcp3204/sensor/mcp3204_sensor.h @@ -9,10 +9,10 @@ namespace esphome::mcp3204 { -class MCP3204Sensor : public PollingComponent, - public Parented, - public sensor::Sensor, - public voltage_sampler::VoltageSampler { +class MCP3204Sensor final : public PollingComponent, + public Parented, + public sensor::Sensor, + public voltage_sampler::VoltageSampler { public: MCP3204Sensor(uint8_t pin, bool differential_mode) : pin_(pin), differential_mode_(differential_mode) {} diff --git a/esphome/components/mcp3221/mcp3221_sensor.h b/esphome/components/mcp3221/mcp3221_sensor.h index deef14e14d..38b62c609f 100644 --- a/esphome/components/mcp3221/mcp3221_sensor.h +++ b/esphome/components/mcp3221/mcp3221_sensor.h @@ -10,10 +10,10 @@ namespace esphome::mcp3221 { -class MCP3221Sensor : public sensor::Sensor, - public PollingComponent, - public voltage_sampler::VoltageSampler, - public i2c::I2CDevice { +class MCP3221Sensor final : public sensor::Sensor, + public PollingComponent, + public voltage_sampler::VoltageSampler, + public i2c::I2CDevice { public: void set_reference_voltage(float reference_voltage) { this->reference_voltage_ = reference_voltage; } void update() override; diff --git a/esphome/components/mcp4461/mcp4461.h b/esphome/components/mcp4461/mcp4461.h index 3a76f855b8..a577a4b482 100644 --- a/esphome/components/mcp4461/mcp4461.h +++ b/esphome/components/mcp4461/mcp4461.h @@ -57,7 +57,7 @@ enum class Mcp4461TerminalIdx : uint8_t { MCP4461_TERMINAL_0 = 0, MCP4461_TERMIN class Mcp4461Wiper; // Mcp4461Component -class Mcp4461Component : public Component, public i2c::I2CDevice { +class Mcp4461Component final : public Component, public i2c::I2CDevice { public: Mcp4461Component(bool disable_wiper_0, bool disable_wiper_1, bool disable_wiper_2, bool disable_wiper_3) : wiper_0_disabled_(disable_wiper_0), diff --git a/esphome/components/mcp4461/output/mcp4461_output.h b/esphome/components/mcp4461/output/mcp4461_output.h index 73eadceb50..20d81d825a 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.h +++ b/esphome/components/mcp4461/output/mcp4461_output.h @@ -7,7 +7,7 @@ namespace esphome::mcp4461 { -class Mcp4461Wiper : public output::FloatOutput, public Parented { +class Mcp4461Wiper final : public output::FloatOutput, public Parented { public: Mcp4461Wiper(Mcp4461Component *parent, Mcp4461WiperIdx wiper) : parent_(parent), wiper_(wiper) {} /// @brief Set level of wiper diff --git a/esphome/components/mcp4725/mcp4725.h b/esphome/components/mcp4725/mcp4725.h index 1acefc3ee4..4f1f128e52 100644 --- a/esphome/components/mcp4725/mcp4725.h +++ b/esphome/components/mcp4725/mcp4725.h @@ -8,7 +8,7 @@ static const uint8_t MCP4725_ADDR = 0x60; static const uint8_t MCP4725_RES = 12; namespace esphome::mcp4725 { -class MCP4725 : public Component, public output::FloatOutput, public i2c::I2CDevice { +class MCP4725 final : public Component, public output::FloatOutput, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mcp4728/mcp4728.h b/esphome/components/mcp4728/mcp4728.h index 13076b3c4c..e7511e5237 100644 --- a/esphome/components/mcp4728/mcp4728.h +++ b/esphome/components/mcp4728/mcp4728.h @@ -38,7 +38,7 @@ struct DACInputData { class MCP4728Channel; /// MCP4728 float output component. -class MCP4728Component : public Component, public i2c::I2CDevice { +class MCP4728Component final : public Component, public i2c::I2CDevice { public: MCP4728Component(bool store_in_eeprom) : store_in_eeprom_(store_in_eeprom) {} diff --git a/esphome/components/mcp4728/output/mcp4728_output.h b/esphome/components/mcp4728/output/mcp4728_output.h index 3ea65ecc7b..827ce1517d 100644 --- a/esphome/components/mcp4728/output/mcp4728_output.h +++ b/esphome/components/mcp4728/output/mcp4728_output.h @@ -7,7 +7,7 @@ namespace esphome::mcp4728 { -class MCP4728Channel : public output::FloatOutput { +class MCP4728Channel final : public output::FloatOutput { public: MCP4728Channel(MCP4728Component *parent, MCP4728ChannelIdx channel, MCP4728Vref vref, MCP4728Gain gain, MCP4728PwrDown pwrdown) diff --git a/esphome/components/mcp47a1/mcp47a1.h b/esphome/components/mcp47a1/mcp47a1.h index da9794e5aa..b72c125574 100644 --- a/esphome/components/mcp47a1/mcp47a1.h +++ b/esphome/components/mcp47a1/mcp47a1.h @@ -6,7 +6,7 @@ namespace esphome::mcp47a1 { -class MCP47A1 : public Component, public output::FloatOutput, public i2c::I2CDevice { +class MCP47A1 final : public Component, public output::FloatOutput, public i2c::I2CDevice { public: void dump_config() override; void write_state(float state) override; diff --git a/esphome/components/mcp9600/mcp9600.h b/esphome/components/mcp9600/mcp9600.h index b7c0c834ab..523c9ace2f 100644 --- a/esphome/components/mcp9600/mcp9600.h +++ b/esphome/components/mcp9600/mcp9600.h @@ -17,7 +17,7 @@ enum MCP9600ThermocoupleType : uint8_t { MCP9600_THERMOCOUPLE_TYPE_R = 0b111, }; -class MCP9600Component : public PollingComponent, public i2c::I2CDevice { +class MCP9600Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mcp9808/mcp9808.h b/esphome/components/mcp9808/mcp9808.h index 89530d9ed0..b4ae51bf6f 100644 --- a/esphome/components/mcp9808/mcp9808.h +++ b/esphome/components/mcp9808/mcp9808.h @@ -6,7 +6,7 @@ namespace esphome::mcp9808 { -class MCP9808Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class MCP9808Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/media_player/automation.h b/esphome/components/media_player/automation.h index 9319335872..899acfefdf 100644 --- a/esphome/components/media_player/automation.h +++ b/esphome/components/media_player/automation.h @@ -6,7 +6,7 @@ namespace esphome::media_player { template -class MediaPlayerCommandAction : public Action, public Parented { +class MediaPlayerCommandAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, announcement); void play(const Ts &...x) override { @@ -54,7 +54,7 @@ template using ClearPlaylistAction = MediaPlayerCommandAction; template -class MediaPlayerMediaAction : public Action, public Parented { +class MediaPlayerMediaAction final : public Action, public Parented { TEMPLATABLE_VALUE(std::string, media_url) TEMPLATABLE_VALUE(bool, announcement) void play(const Ts &...x) override { @@ -70,7 +70,7 @@ using PlayMediaAction = MediaPlayerMediaAction using EnqueueMediaAction = MediaPlayerMediaAction; -template class VolumeSetAction : public Action, public Parented { +template class VolumeSetAction final : public Action, public Parented { TEMPLATABLE_VALUE(float, volume) void play(const Ts &...x) override { this->parent_->make_call().set_volume(this->volume_.value(x...)).perform(); } }; @@ -97,39 +97,39 @@ static_assert(std::is_trivially_copyable_v); static_assert(sizeof(StateEnterForwarder) <= sizeof(void *)); static_assert(std::is_trivially_copyable_v>); -template class IsIdleCondition : public Condition, public Parented { +template class IsIdleCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_IDLE; } }; -template class IsPlayingCondition : public Condition, public Parented { +template class IsPlayingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_PLAYING; } }; -template class IsPausedCondition : public Condition, public Parented { +template class IsPausedCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_PAUSED; } }; -template class IsAnnouncingCondition : public Condition, public Parented { +template class IsAnnouncingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_ANNOUNCING; } }; -template class IsOnCondition : public Condition, public Parented { +template class IsOnCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_ON; } }; -template class IsOffCondition : public Condition, public Parented { +template class IsOffCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_OFF; } }; -template class IsMutedCondition : public Condition, public Parented { +template class IsMutedCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_muted(); } }; diff --git a/esphome/components/mhz19/mhz19.h b/esphome/components/mhz19/mhz19.h index e577b98537..3cef3a3930 100644 --- a/esphome/components/mhz19/mhz19.h +++ b/esphome/components/mhz19/mhz19.h @@ -20,7 +20,7 @@ enum MHZ19DetectionRange { MHZ19_DETECTION_RANGE_0_10000PPM, }; -class MHZ19Component : public PollingComponent, public uart::UARTDevice { +class MHZ19Component final : public PollingComponent, public uart::UARTDevice { public: void setup() override; void update() override; @@ -49,22 +49,23 @@ class MHZ19Component : public PollingComponent, public uart::UARTDevice { MHZ19DetectionRange detection_range_{MHZ19_DETECTION_RANGE_DEFAULT}; }; -template class MHZ19CalibrateZeroAction : public Action, public Parented { +template class MHZ19CalibrateZeroAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->calibrate_zero(); } }; -template class MHZ19ABCEnableAction : public Action, public Parented { +template class MHZ19ABCEnableAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->abc_enable(); } }; -template class MHZ19ABCDisableAction : public Action, public Parented { +template class MHZ19ABCDisableAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->abc_disable(); } }; -template class MHZ19DetectionRangeSetAction : public Action, public Parented { +template +class MHZ19DetectionRangeSetAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(MHZ19DetectionRange, detection_range) diff --git a/esphome/components/micronova/button/micronova_button.h b/esphome/components/micronova/button/micronova_button.h index 0258dbb53c..9f8f66ee02 100644 --- a/esphome/components/micronova/button/micronova_button.h +++ b/esphome/components/micronova/button/micronova_button.h @@ -6,7 +6,7 @@ namespace esphome::micronova { -class MicroNovaButton : public Component, public button::Button, public MicroNovaBaseListener { +class MicroNovaButton final : public Component, public button::Button, public MicroNovaBaseListener { public: MicroNovaButton(MicroNova *m) : MicroNovaBaseListener(m) {} void dump_config() override; diff --git a/esphome/components/micronova/micronova.h b/esphome/components/micronova/micronova.h index 58cca30b83..c57286db6c 100644 --- a/esphome/components/micronova/micronova.h +++ b/esphome/components/micronova/micronova.h @@ -58,7 +58,7 @@ class MicroNovaListener : public MicroNovaBaseListener, public PollingComponent ///////////////////////////////////////////////////////////////////// // Main component class -class MicroNova : public Component, public uart::UARTDevice { +class MicroNova final : public Component, public uart::UARTDevice { public: MicroNova(GPIOPin *enable_rx_pin) : enable_rx_pin_(enable_rx_pin) {} diff --git a/esphome/components/micronova/number/micronova_number.h b/esphome/components/micronova/number/micronova_number.h index 73666b632b..91e4253b46 100644 --- a/esphome/components/micronova/number/micronova_number.h +++ b/esphome/components/micronova/number/micronova_number.h @@ -5,7 +5,7 @@ namespace esphome::micronova { -class MicroNovaNumber : public number::Number, public MicroNovaListener { +class MicroNovaNumber final : public number::Number, public MicroNovaListener { public: MicroNovaNumber(MicroNova *m) : MicroNovaListener(m) {} void dump_config() override; diff --git a/esphome/components/micronova/sensor/micronova_sensor.h b/esphome/components/micronova/sensor/micronova_sensor.h index f3b06d140e..8263ad0948 100644 --- a/esphome/components/micronova/sensor/micronova_sensor.h +++ b/esphome/components/micronova/sensor/micronova_sensor.h @@ -5,7 +5,7 @@ namespace esphome::micronova { -class MicroNovaSensor : public sensor::Sensor, public MicroNovaListener { +class MicroNovaSensor final : public sensor::Sensor, public MicroNovaListener { public: MicroNovaSensor(MicroNova *m) : MicroNovaListener(m) {} void dump_config() override; diff --git a/esphome/components/micronova/switch/micronova_switch.h b/esphome/components/micronova/switch/micronova_switch.h index fee3c73976..4a4d5eb721 100644 --- a/esphome/components/micronova/switch/micronova_switch.h +++ b/esphome/components/micronova/switch/micronova_switch.h @@ -6,7 +6,7 @@ namespace esphome::micronova { -class MicroNovaSwitch : public switch_::Switch, public MicroNovaListener { +class MicroNovaSwitch final : public switch_::Switch, public MicroNovaListener { public: MicroNovaSwitch(MicroNova *m) : MicroNovaListener(m) {} void dump_config() override; diff --git a/esphome/components/micronova/text_sensor/micronova_text_sensor.h b/esphome/components/micronova/text_sensor/micronova_text_sensor.h index 6918a372e8..2de93404a5 100644 --- a/esphome/components/micronova/text_sensor/micronova_text_sensor.h +++ b/esphome/components/micronova/text_sensor/micronova_text_sensor.h @@ -17,7 +17,7 @@ static const char *const STOVE_STATES[11] = {"Off", "No ignition alarm", "Undefined alarm"}; -class MicroNovaTextSensor : public text_sensor::TextSensor, public MicroNovaListener { +class MicroNovaTextSensor final : public text_sensor::TextSensor, public MicroNovaListener { public: MicroNovaTextSensor(MicroNova *m) : MicroNovaListener(m) {} void dump_config() override; From 9f5ed6fdfd3d4fc68c4444c94bac71522e8c4c19 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:04:45 +1200 Subject: [PATCH 0528/1815] Mark configurable classes as final (6/21) (#16957) --- .../esp32_ble_client/ble_characteristic.h | 2 +- .../components/esp32_ble_client/ble_service.h | 2 +- .../esp32_ble_server/ble_characteristic.h | 2 +- .../components/esp32_ble_server/ble_server.h | 2 +- .../esp32_ble_server/ble_server_automations.h | 6 ++--- .../components/esp32_ble_server/ble_service.h | 2 +- .../components/esp32_ble_tracker/automation.h | 12 ++++----- .../esp32_ble_tracker/esp32_ble_tracker.h | 6 ++--- .../components/esp32_camera/esp32_camera.h | 8 +++--- .../camera_web_server.h | 2 +- esphome/components/esp32_can/esp32_can.h | 2 +- esphome/components/esp32_dac/esp32_dac.h | 2 +- .../esp32_hosted/update/esp32_hosted_update.h | 2 +- esphome/components/esp32_improv/automation.h | 10 +++---- .../esp32_improv/esp32_improv_component.h | 2 +- .../esp32_rmt_led_strip/led_strip.h | 2 +- esphome/components/esp32_touch/esp32_touch.h | 2 +- esphome/components/esp8266/gpio.h | 2 +- esphome/components/esp8266_pwm/esp8266_pwm.h | 4 +-- esphome/components/esp_ldo/esp_ldo.h | 4 +-- esphome/components/espnow/automation.h | 20 +++++++------- esphome/components/espnow/espnow_component.h | 2 +- .../packet_transport/espnow_transport.h | 8 +++--- esphome/components/ethernet/automation.h | 8 +++--- esphome/components/event/automation.h | 4 +-- .../exposure_notifications.h | 4 +-- esphome/components/ezo/ezo.h | 2 +- esphome/components/ezo_pmp/ezo_pmp.h | 26 +++++++++---------- .../button/factory_reset_button.h | 2 +- .../components/factory_reset/factory_reset.h | 2 +- .../switch/factory_reset_switch.h | 2 +- esphome/components/fan/automation.h | 26 +++++++++---------- .../components/fastled_base/fastled_light.h | 2 +- esphome/components/feedback/feedback_cover.h | 2 +- .../fingerprint_grow/fingerprint_grow.h | 17 +++++++----- esphome/components/font/font.h | 4 +-- esphome/components/fs3000/fs3000.h | 2 +- .../ft5x06/touchscreen/ft5x06_touchscreen.h | 2 +- esphome/components/ft63x6/ft63x6.h | 2 +- .../fujitsu_general/fujitsu_general.h | 2 +- 40 files changed, 109 insertions(+), 106 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_characteristic.h b/esphome/components/esp32_ble_client/ble_characteristic.h index 1428b42739..7834d99c9b 100644 --- a/esphome/components/esp32_ble_client/ble_characteristic.h +++ b/esphome/components/esp32_ble_client/ble_characteristic.h @@ -17,7 +17,7 @@ namespace espbt = esphome::esp32_ble_tracker; class BLEService; -class BLECharacteristic { +class BLECharacteristic final { public: ~BLECharacteristic(); bool parsed = false; diff --git a/esphome/components/esp32_ble_client/ble_service.h b/esphome/components/esp32_ble_client/ble_service.h index 00ecc777e7..bb1fd2b9fa 100644 --- a/esphome/components/esp32_ble_client/ble_service.h +++ b/esphome/components/esp32_ble_client/ble_service.h @@ -17,7 +17,7 @@ namespace espbt = esphome::esp32_ble_tracker; class BLEClientBase; -class BLEService { +class BLEService final { public: ~BLEService(); bool parsed = false; diff --git a/esphome/components/esp32_ble_server/ble_characteristic.h b/esphome/components/esp32_ble_server/ble_characteristic.h index 933177a399..b7a3fae1a5 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.h +++ b/esphome/components/esp32_ble_server/ble_characteristic.h @@ -24,7 +24,7 @@ using namespace bytebuffer; class BLEService; -class BLECharacteristic { +class BLECharacteristic final { public: BLECharacteristic(ESPBTUUID uuid, uint32_t properties); ~BLECharacteristic(); diff --git a/esphome/components/esp32_ble_server/ble_server.h b/esphome/components/esp32_ble_server/ble_server.h index 9ba108499e..fdd92812cd 100644 --- a/esphome/components/esp32_ble_server/ble_server.h +++ b/esphome/components/esp32_ble_server/ble_server.h @@ -23,7 +23,7 @@ namespace esphome::esp32_ble_server { using namespace esp32_ble; using namespace bytebuffer; -class BLEServer : public Component, public Parented { +class BLEServer final : public Component, public Parented { public: void setup() override; void loop() override; diff --git a/esphome/components/esp32_ble_server/ble_server_automations.h b/esphome/components/esp32_ble_server/ble_server_automations.h index b4e9ed004e..c6cba14b9b 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.h +++ b/esphome/components/esp32_ble_server/ble_server_automations.h @@ -64,7 +64,7 @@ class BLECharacteristicSetValueActionManager { void remove_listener_(BLECharacteristic *characteristic); }; -template class BLECharacteristicSetValueAction : public Action { +template class BLECharacteristicSetValueAction final : public Action { public: BLECharacteristicSetValueAction(BLECharacteristic *characteristic) : parent_(characteristic) {} TEMPLATABLE_VALUE(std::vector, buffer) @@ -92,7 +92,7 @@ template class BLECharacteristicSetValueAction : public Action class BLECharacteristicNotifyAction : public Action { +template class BLECharacteristicNotifyAction final : public Action { public: BLECharacteristicNotifyAction(BLECharacteristic *characteristic) : parent_(characteristic) {} void play(const Ts &...x) override { @@ -110,7 +110,7 @@ template class BLECharacteristicNotifyAction : public Action class BLEDescriptorSetValueAction : public Action { +template class BLEDescriptorSetValueAction final : public Action { public: BLEDescriptorSetValueAction(BLEDescriptor *descriptor) : parent_(descriptor) {} TEMPLATABLE_VALUE(std::vector, buffer) diff --git a/esphome/components/esp32_ble_server/ble_service.h b/esphome/components/esp32_ble_server/ble_service.h index 03fa8093ac..a0592d0a80 100644 --- a/esphome/components/esp32_ble_server/ble_service.h +++ b/esphome/components/esp32_ble_server/ble_service.h @@ -19,7 +19,7 @@ class BLEServer; using namespace esp32_ble; -class BLEService { +class BLEService final { public: BLEService(ESPBTUUID uuid, uint16_t num_handles, uint8_t inst_id, bool advertise); ~BLEService(); diff --git a/esphome/components/esp32_ble_tracker/automation.h b/esphome/components/esp32_ble_tracker/automation.h index 6d26040ccb..b653325f56 100644 --- a/esphome/components/esp32_ble_tracker/automation.h +++ b/esphome/components/esp32_ble_tracker/automation.h @@ -7,7 +7,7 @@ namespace esphome::esp32_ble_tracker { #ifdef USE_ESP32_BLE_DEVICE -class ESPBTAdvertiseTrigger : public Trigger, public ESPBTDeviceListener { +class ESPBTAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { public: explicit ESPBTAdvertiseTrigger(ESP32BLETracker *parent) { parent->register_listener(this); } void set_addresses(std::initializer_list addresses) { this->address_vec_ = addresses; } @@ -28,7 +28,7 @@ class ESPBTAdvertiseTrigger : public Trigger, public ESPBTD std::vector address_vec_; }; -class BLEServiceDataAdvertiseTrigger : public Trigger, public ESPBTDeviceListener { +class BLEServiceDataAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { public: explicit BLEServiceDataAdvertiseTrigger(ESP32BLETracker *parent) { parent->register_listener(this); } void set_address(uint64_t address) { this->address_ = address; } @@ -54,7 +54,7 @@ class BLEServiceDataAdvertiseTrigger : public Trigger, publi ESPBTUUID uuid_; }; -class BLEManufacturerDataAdvertiseTrigger : public Trigger, public ESPBTDeviceListener { +class BLEManufacturerDataAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { public: explicit BLEManufacturerDataAdvertiseTrigger(ESP32BLETracker *parent) { parent->register_listener(this); } void set_address(uint64_t address) { this->address_ = address; } @@ -82,7 +82,7 @@ class BLEManufacturerDataAdvertiseTrigger : public Trigger, #endif // USE_ESP32_BLE_DEVICE -class BLEEndOfScanTrigger : public Trigger<>, public ESPBTDeviceListener { +class BLEEndOfScanTrigger final : public Trigger<>, public ESPBTDeviceListener { public: explicit BLEEndOfScanTrigger(ESP32BLETracker *parent) { parent->register_listener(this); } @@ -92,7 +92,7 @@ class BLEEndOfScanTrigger : public Trigger<>, public ESPBTDeviceListener { void on_scan_end() override { this->trigger(); } }; -template class ESP32BLEStartScanAction : public Action { +template class ESP32BLEStartScanAction final : public Action { public: ESP32BLEStartScanAction(ESP32BLETracker *parent) : parent_(parent) {} TEMPLATABLE_VALUE(bool, continuous) @@ -111,7 +111,7 @@ template class ESP32BLEStartScanAction : public Action { ESP32BLETracker *parent_; }; -template class ESP32BLEStopScanAction : public Action, public Parented { +template class ESP32BLEStopScanAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->stop_scan(); } }; diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 78ff60f374..3415196a11 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -294,11 +294,11 @@ class ESPBTClient : public ESPBTDeviceListener { uint8_t *tracker_state_version_{nullptr}; }; -class ESP32BLETracker : public Component, +class ESP32BLETracker final : public Component, #ifdef USE_OTA_STATE_LISTENER - public ota::OTAGlobalStateListener, + public ota::OTAGlobalStateListener, #endif - public Parented { + public Parented { public: void set_scan_duration(uint32_t scan_duration) { scan_duration_ = scan_duration; } void set_scan_interval(uint32_t scan_interval) { scan_interval_ = scan_interval; } diff --git a/esphome/components/esp32_camera/esp32_camera.h b/esphome/components/esp32_camera/esp32_camera.h index 7d020b5caf..83dab5f77a 100644 --- a/esphome/components/esp32_camera/esp32_camera.h +++ b/esphome/components/esp32_camera/esp32_camera.h @@ -119,7 +119,7 @@ class ESP32CameraImageReader : public camera::CameraImageReader { }; /* ---------------- ESP32Camera class ---------------- */ -class ESP32Camera : public camera::Camera { +class ESP32Camera final : public camera::Camera { public: ESP32Camera(); @@ -235,7 +235,7 @@ class ESP32Camera : public camera::Camera { RAMAllocator fb_allocator_{RAMAllocator::ALLOC_INTERNAL}; }; -class ESP32CameraImageTrigger : public Trigger, public camera::CameraListener { +class ESP32CameraImageTrigger final : public Trigger, public camera::CameraListener { public: explicit ESP32CameraImageTrigger(ESP32Camera *parent) { parent->add_listener(this); } void on_camera_image(const std::shared_ptr &image) override { @@ -246,13 +246,13 @@ class ESP32CameraImageTrigger : public Trigger, public camera:: } }; -class ESP32CameraStreamStartTrigger : public Trigger<>, public camera::CameraListener { +class ESP32CameraStreamStartTrigger final : public Trigger<>, public camera::CameraListener { public: explicit ESP32CameraStreamStartTrigger(ESP32Camera *parent) { parent->add_listener(this); } void on_stream_start() override { this->trigger(); } }; -class ESP32CameraStreamStopTrigger : public Trigger<>, public camera::CameraListener { +class ESP32CameraStreamStopTrigger final : public Trigger<>, public camera::CameraListener { public: explicit ESP32CameraStreamStopTrigger(ESP32Camera *parent) { parent->add_listener(this); } void on_stream_stop() override { this->trigger(); } diff --git a/esphome/components/esp32_camera_web_server/camera_web_server.h b/esphome/components/esp32_camera_web_server/camera_web_server.h index 568dc68c46..76f8317248 100644 --- a/esphome/components/esp32_camera_web_server/camera_web_server.h +++ b/esphome/components/esp32_camera_web_server/camera_web_server.h @@ -17,7 +17,7 @@ namespace esphome::esp32_camera_web_server { enum Mode { STREAM, SNAPSHOT }; -class CameraWebServer : public Component, public camera::CameraListener { +class CameraWebServer final : public Component, public camera::CameraListener { public: CameraWebServer(); ~CameraWebServer(); diff --git a/esphome/components/esp32_can/esp32_can.h b/esphome/components/esp32_can/esp32_can.h index 2e10d254e6..f224e10be3 100644 --- a/esphome/components/esp32_can/esp32_can.h +++ b/esphome/components/esp32_can/esp32_can.h @@ -14,7 +14,7 @@ enum CanMode : uint8_t { CAN_MODE_LISTEN_ONLY = 1, }; -class ESP32Can : public canbus::Canbus { +class ESP32Can final : public canbus::Canbus { public: void set_rx(int rx) { rx_ = rx; } void set_tx(int tx) { tx_ = tx; } diff --git a/esphome/components/esp32_dac/esp32_dac.h b/esphome/components/esp32_dac/esp32_dac.h index 108b96cd39..ee6b506211 100644 --- a/esphome/components/esp32_dac/esp32_dac.h +++ b/esphome/components/esp32_dac/esp32_dac.h @@ -11,7 +11,7 @@ namespace esphome::esp32_dac { -class ESP32DAC : public output::FloatOutput, public Component { +class ESP32DAC final : public output::FloatOutput, public Component { public: void set_pin(InternalGPIOPin *pin) { pin_ = pin; } diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.h b/esphome/components/esp32_hosted/update/esp32_hosted_update.h index 005e6a6f21..4f9d04738d 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.h +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.h @@ -13,7 +13,7 @@ namespace esphome::esp32_hosted { -class Esp32HostedUpdate : public update::UpdateEntity, public PollingComponent { +class Esp32HostedUpdate final : public update::UpdateEntity, public PollingComponent { public: void setup() override; void dump_config() override; diff --git a/esphome/components/esp32_improv/automation.h b/esphome/components/esp32_improv/automation.h index 19e1b6e7e3..b3b61f4778 100644 --- a/esphome/components/esp32_improv/automation.h +++ b/esphome/components/esp32_improv/automation.h @@ -9,7 +9,7 @@ namespace esphome::esp32_improv { -class ESP32ImprovProvisionedTrigger : public Trigger<> { +class ESP32ImprovProvisionedTrigger final : public Trigger<> { public: explicit ESP32ImprovProvisionedTrigger(ESP32ImprovComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { @@ -23,7 +23,7 @@ class ESP32ImprovProvisionedTrigger : public Trigger<> { ESP32ImprovComponent *parent_; }; -class ESP32ImprovProvisioningTrigger : public Trigger<> { +class ESP32ImprovProvisioningTrigger final : public Trigger<> { public: explicit ESP32ImprovProvisioningTrigger(ESP32ImprovComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { @@ -37,7 +37,7 @@ class ESP32ImprovProvisioningTrigger : public Trigger<> { ESP32ImprovComponent *parent_; }; -class ESP32ImprovStartTrigger : public Trigger<> { +class ESP32ImprovStartTrigger final : public Trigger<> { public: explicit ESP32ImprovStartTrigger(ESP32ImprovComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { @@ -52,7 +52,7 @@ class ESP32ImprovStartTrigger : public Trigger<> { ESP32ImprovComponent *parent_; }; -class ESP32ImprovStateTrigger : public Trigger { +class ESP32ImprovStateTrigger final : public Trigger { public: explicit ESP32ImprovStateTrigger(ESP32ImprovComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { @@ -66,7 +66,7 @@ class ESP32ImprovStateTrigger : public Trigger { ESP32ImprovComponent *parent_; }; -class ESP32ImprovStoppedTrigger : public Trigger<> { +class ESP32ImprovStoppedTrigger final : public Trigger<> { public: explicit ESP32ImprovStoppedTrigger(ESP32ImprovComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { diff --git a/esphome/components/esp32_improv/esp32_improv_component.h b/esphome/components/esp32_improv/esp32_improv_component.h index 400006cfb3..d948dba3b3 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.h +++ b/esphome/components/esp32_improv/esp32_improv_component.h @@ -32,7 +32,7 @@ namespace esphome::esp32_improv { using namespace esp32_ble_server; -class ESP32ImprovComponent : public Component, public improv_base::ImprovBase { +class ESP32ImprovComponent final : public Component, public improv_base::ImprovBase { public: ESP32ImprovComponent(); void dump_config() override; diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.h b/esphome/components/esp32_rmt_led_strip/led_strip.h index 8fb6b63afe..d7ba2aafbf 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.h +++ b/esphome/components/esp32_rmt_led_strip/led_strip.h @@ -30,7 +30,7 @@ struct LedParams { rmt_symbol_word_t reset; }; -class ESP32RMTLEDStripLightOutput : public light::AddressableLight { +class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { public: void setup() override; void write_state(light::LightState *state) override; diff --git a/esphome/components/esp32_touch/esp32_touch.h b/esphome/components/esp32_touch/esp32_touch.h index d51b2d4922..55ac5c5b63 100644 --- a/esphome/components/esp32_touch/esp32_touch.h +++ b/esphome/components/esp32_touch/esp32_touch.h @@ -172,7 +172,7 @@ class ESP32TouchComponent final : public Component { }; /// Simple helper class to expose a touch pad value as a binary sensor. -class ESP32TouchBinarySensor : public binary_sensor::BinarySensor { +class ESP32TouchBinarySensor final : public binary_sensor::BinarySensor { public: ESP32TouchBinarySensor(int channel_id, uint32_t threshold, uint32_t wakeup_threshold) : channel_id_(channel_id), threshold_(threshold), wakeup_threshold_(wakeup_threshold) {} diff --git a/esphome/components/esp8266/gpio.h b/esphome/components/esp8266/gpio.h index ff149abfbe..57ef06106a 100644 --- a/esphome/components/esp8266/gpio.h +++ b/esphome/components/esp8266/gpio.h @@ -7,7 +7,7 @@ namespace esphome::esp8266 { -class ESP8266GPIOPin : public InternalGPIOPin { +class ESP8266GPIOPin final : public InternalGPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } diff --git a/esphome/components/esp8266_pwm/esp8266_pwm.h b/esphome/components/esp8266_pwm/esp8266_pwm.h index 51c4ea1602..be58a098b6 100644 --- a/esphome/components/esp8266_pwm/esp8266_pwm.h +++ b/esphome/components/esp8266_pwm/esp8266_pwm.h @@ -9,7 +9,7 @@ namespace esphome::esp8266_pwm { -class ESP8266PWM : public output::FloatOutput, public Component { +class ESP8266PWM final : public output::FloatOutput, public Component { public: void set_pin(InternalGPIOPin *pin) { pin_ = pin; } void set_frequency(float frequency) { this->frequency_ = frequency; } @@ -34,7 +34,7 @@ class ESP8266PWM : public output::FloatOutput, public Component { float last_output_{0.0}; }; -template class SetFrequencyAction : public Action { +template class SetFrequencyAction final : public Action { public: SetFrequencyAction(ESP8266PWM *parent) : parent_(parent) {} TEMPLATABLE_VALUE(float, frequency); diff --git a/esphome/components/esp_ldo/esp_ldo.h b/esphome/components/esp_ldo/esp_ldo.h index bb1579e83d..0451c338dd 100644 --- a/esphome/components/esp_ldo/esp_ldo.h +++ b/esphome/components/esp_ldo/esp_ldo.h @@ -6,7 +6,7 @@ namespace esphome::esp_ldo { -class EspLdo : public Component { +class EspLdo final : public Component { public: EspLdo(int channel) : channel_(channel) {} @@ -27,7 +27,7 @@ class EspLdo : public Component { esp_ldo_channel_handle_t handle_{}; }; -template class AdjustAction : public Action { +template class AdjustAction final : public Action { public: explicit AdjustAction(EspLdo *ldo) : ldo_(ldo) {} diff --git a/esphome/components/espnow/automation.h b/esphome/components/espnow/automation.h index 9c3c55e4ef..5e995aff53 100644 --- a/esphome/components/espnow/automation.h +++ b/esphome/components/espnow/automation.h @@ -9,7 +9,7 @@ namespace esphome::espnow { -template class SendAction : public Action, public Parented { +template class SendAction final : public Action, public Parented { TEMPLATABLE_VALUE(peer_address_t, address); TEMPLATABLE_VALUE(std::vector, data); @@ -86,7 +86,7 @@ template class SendAction : public Action, public Parente } flags_{0}; }; -template class AddPeerAction : public Action, public Parented { +template class AddPeerAction final : public Action, public Parented { TEMPLATABLE_VALUE(peer_address_t, address); protected: @@ -96,7 +96,7 @@ template class AddPeerAction : public Action, public Pare } }; -template class DeletePeerAction : public Action, public Parented { +template class DeletePeerAction final : public Action, public Parented { TEMPLATABLE_VALUE(peer_address_t, address); protected: @@ -106,7 +106,7 @@ template class DeletePeerAction : public Action, public P } }; -template class SetChannelAction : public Action, public Parented { +template class SetChannelAction final : public Action, public Parented { TEMPLATABLE_VALUE(uint8_t, channel) protected: @@ -119,8 +119,8 @@ template class SetChannelAction : public Action, public P } }; -class OnReceiveTrigger : public Trigger, - public ESPNowReceivedPacketHandler { +class OnReceiveTrigger final : public Trigger, + public ESPNowReceivedPacketHandler { public: explicit OnReceiveTrigger(std::array address) : has_address_(true) { memcpy(this->address_, address.data(), ESP_NOW_ETH_ALEN); @@ -141,16 +141,16 @@ class OnReceiveTrigger : public Trigger, - public ESPNowUnknownPeerHandler { +class OnUnknownPeerTrigger final : public Trigger, + public ESPNowUnknownPeerHandler { public: bool on_unknown_peer(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override { this->trigger(info, data, size); return false; // Return false to continue processing other internal handlers } }; -class OnBroadcastTrigger : public Trigger, - public ESPNowBroadcastHandler { +class OnBroadcastTrigger final : public Trigger, + public ESPNowBroadcastHandler { public: explicit OnBroadcastTrigger(std::array address) : has_address_(true) { memcpy(this->address_, address.data(), ESP_NOW_ETH_ALEN); diff --git a/esphome/components/espnow/espnow_component.h b/esphome/components/espnow/espnow_component.h index ff9581ec2f..eacc3eb886 100644 --- a/esphome/components/espnow/espnow_component.h +++ b/esphome/components/espnow/espnow_component.h @@ -88,7 +88,7 @@ class ESPNowBroadcastHandler { virtual bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; }; -class ESPNowComponent : public Component { +class ESPNowComponent final : public Component { public: ESPNowComponent(); void setup() override; diff --git a/esphome/components/espnow/packet_transport/espnow_transport.h b/esphome/components/espnow/packet_transport/espnow_transport.h index 5916a7fa5f..7e1d08618b 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.h +++ b/esphome/components/espnow/packet_transport/espnow_transport.h @@ -11,10 +11,10 @@ namespace esphome::espnow { -class ESPNowTransport : public packet_transport::PacketTransport, - public Parented, - public ESPNowReceivedPacketHandler, - public ESPNowBroadcastHandler { +class ESPNowTransport final : public packet_transport::PacketTransport, + public Parented, + public ESPNowReceivedPacketHandler, + public ESPNowBroadcastHandler { public: void setup() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } diff --git a/esphome/components/ethernet/automation.h b/esphome/components/ethernet/automation.h index c16abc5bda..f975f52bff 100644 --- a/esphome/components/ethernet/automation.h +++ b/esphome/components/ethernet/automation.h @@ -6,22 +6,22 @@ namespace esphome::ethernet { -template class EthernetConnectedCondition : public Condition { +template class EthernetConnectedCondition final : public Condition { public: bool check(const Ts &...x) override { return global_eth_component->is_connected(); } }; -template class EthernetEnabledCondition : public Condition { +template class EthernetEnabledCondition final : public Condition { public: bool check(const Ts &...x) override { return global_eth_component->is_enabled(); } }; -template class EthernetEnableAction : public Action { +template class EthernetEnableAction final : public Action { public: void play(const Ts &...x) override { global_eth_component->enable(); } }; -template class EthernetDisableAction : public Action { +template class EthernetDisableAction final : public Action { public: void play(const Ts &...x) override { global_eth_component->disable(); } }; diff --git a/esphome/components/event/automation.h b/esphome/components/event/automation.h index 3444a7b1bb..73a6336f78 100644 --- a/esphome/components/event/automation.h +++ b/esphome/components/event/automation.h @@ -6,14 +6,14 @@ namespace esphome::event { -template class TriggerEventAction : public Action, public Parented { +template class TriggerEventAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(std::string, event_type) void play(const Ts &...x) override { this->parent_->trigger(this->event_type_.value(x...)); } }; -class EventTrigger : public Trigger { +class EventTrigger final : public Trigger { public: EventTrigger(Event *event) { event->add_on_event_callback([this](StringRef event_type) { this->trigger(event_type); }); diff --git a/esphome/components/exposure_notifications/exposure_notifications.h b/esphome/components/exposure_notifications/exposure_notifications.h index 80184f9cfd..6a703a9a92 100644 --- a/esphome/components/exposure_notifications/exposure_notifications.h +++ b/esphome/components/exposure_notifications/exposure_notifications.h @@ -16,8 +16,8 @@ struct ExposureNotification { std::array associated_encrypted_metadata; }; -class ExposureNotificationTrigger : public Trigger, - public esp32_ble_tracker::ESPBTDeviceListener { +class ExposureNotificationTrigger final : public Trigger, + public esp32_ble_tracker::ESPBTDeviceListener { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; }; diff --git a/esphome/components/ezo/ezo.h b/esphome/components/ezo/ezo.h index aea276e001..a20419eceb 100644 --- a/esphome/components/ezo/ezo.h +++ b/esphome/components/ezo/ezo.h @@ -32,7 +32,7 @@ class EzoCommand { }; /// This class implements support for the EZO circuits in i2c mode -class EZOSensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class EZOSensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void loop() override; void dump_config() override; diff --git a/esphome/components/ezo_pmp/ezo_pmp.h b/esphome/components/ezo_pmp/ezo_pmp.h index 8a6da5fe74..55283f2d09 100644 --- a/esphome/components/ezo_pmp/ezo_pmp.h +++ b/esphome/components/ezo_pmp/ezo_pmp.h @@ -19,7 +19,7 @@ namespace esphome::ezo_pmp { -class EzoPMP : public PollingComponent, public i2c::I2CDevice { +class EzoPMP final : public PollingComponent, public i2c::I2CDevice { public: void dump_config() override; @@ -114,7 +114,7 @@ class EzoPMP : public PollingComponent, public i2c::I2CDevice { }; // Action Templates -template class EzoPMPFindAction : public Action { +template class EzoPMPFindAction final : public Action { public: EzoPMPFindAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -124,7 +124,7 @@ template class EzoPMPFindAction : public Action { EzoPMP *ezopmp_; }; -template class EzoPMPDoseContinuouslyAction : public Action { +template class EzoPMPDoseContinuouslyAction final : public Action { public: EzoPMPDoseContinuouslyAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -134,7 +134,7 @@ template class EzoPMPDoseContinuouslyAction : public Action class EzoPMPDoseVolumeAction : public Action { +template class EzoPMPDoseVolumeAction final : public Action { public: EzoPMPDoseVolumeAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -145,7 +145,7 @@ template class EzoPMPDoseVolumeAction : public Action { EzoPMP *ezopmp_; }; -template class EzoPMPDoseVolumeOverTimeAction : public Action { +template class EzoPMPDoseVolumeOverTimeAction final : public Action { public: EzoPMPDoseVolumeOverTimeAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -159,7 +159,7 @@ template class EzoPMPDoseVolumeOverTimeAction : public Action class EzoPMPDoseWithConstantFlowRateAction : public Action { +template class EzoPMPDoseWithConstantFlowRateAction final : public Action { public: EzoPMPDoseWithConstantFlowRateAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -173,7 +173,7 @@ template class EzoPMPDoseWithConstantFlowRateAction : public Act EzoPMP *ezopmp_; }; -template class EzoPMPSetCalibrationVolumeAction : public Action { +template class EzoPMPSetCalibrationVolumeAction final : public Action { public: EzoPMPSetCalibrationVolumeAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -184,7 +184,7 @@ template class EzoPMPSetCalibrationVolumeAction : public Action< EzoPMP *ezopmp_; }; -template class EzoPMPClearTotalVolumeDispensedAction : public Action { +template class EzoPMPClearTotalVolumeDispensedAction final : public Action { public: EzoPMPClearTotalVolumeDispensedAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -194,7 +194,7 @@ template class EzoPMPClearTotalVolumeDispensedAction : public Ac EzoPMP *ezopmp_; }; -template class EzoPMPClearCalibrationAction : public Action { +template class EzoPMPClearCalibrationAction final : public Action { public: EzoPMPClearCalibrationAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -204,7 +204,7 @@ template class EzoPMPClearCalibrationAction : public Action class EzoPMPPauseDosingAction : public Action { +template class EzoPMPPauseDosingAction final : public Action { public: EzoPMPPauseDosingAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -214,7 +214,7 @@ template class EzoPMPPauseDosingAction : public Action { EzoPMP *ezopmp_; }; -template class EzoPMPStopDosingAction : public Action { +template class EzoPMPStopDosingAction final : public Action { public: EzoPMPStopDosingAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -224,7 +224,7 @@ template class EzoPMPStopDosingAction : public Action { EzoPMP *ezopmp_; }; -template class EzoPMPChangeI2CAddressAction : public Action { +template class EzoPMPChangeI2CAddressAction final : public Action { public: EzoPMPChangeI2CAddressAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -235,7 +235,7 @@ template class EzoPMPChangeI2CAddressAction : public Action class EzoPMPArbitraryCommandAction : public Action { +template class EzoPMPArbitraryCommandAction final : public Action { public: EzoPMPArbitraryCommandAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} diff --git a/esphome/components/factory_reset/button/factory_reset_button.h b/esphome/components/factory_reset/button/factory_reset_button.h index a8cb897614..0bb8a62f5e 100644 --- a/esphome/components/factory_reset/button/factory_reset_button.h +++ b/esphome/components/factory_reset/button/factory_reset_button.h @@ -7,7 +7,7 @@ namespace esphome::factory_reset { -class FactoryResetButton : public button::Button, public Component { +class FactoryResetButton final : public button::Button, public Component { public: void dump_config() override; #ifdef USE_OPENTHREAD diff --git a/esphome/components/factory_reset/factory_reset.h b/esphome/components/factory_reset/factory_reset.h index 41ee627c4b..d80d2d2406 100644 --- a/esphome/components/factory_reset/factory_reset.h +++ b/esphome/components/factory_reset/factory_reset.h @@ -10,7 +10,7 @@ #endif namespace esphome::factory_reset { -class FactoryResetComponent : public Component { +class FactoryResetComponent final : public Component { public: FactoryResetComponent(uint8_t required_count, uint16_t max_interval) : max_interval_(max_interval), required_count_(required_count) {} diff --git a/esphome/components/factory_reset/switch/factory_reset_switch.h b/esphome/components/factory_reset/switch/factory_reset_switch.h index be80356b31..fb76b10cf3 100644 --- a/esphome/components/factory_reset/switch/factory_reset_switch.h +++ b/esphome/components/factory_reset/switch/factory_reset_switch.h @@ -6,7 +6,7 @@ namespace esphome::factory_reset { -class FactoryResetSwitch : public switch_::Switch, public Component { +class FactoryResetSwitch final : public switch_::Switch, public Component { public: void dump_config() override; #ifdef USE_OPENTHREAD diff --git a/esphome/components/fan/automation.h b/esphome/components/fan/automation.h index 964ebe77a0..cbd994e749 100644 --- a/esphome/components/fan/automation.h +++ b/esphome/components/fan/automation.h @@ -18,7 +18,7 @@ namespace esphome::fan { // (e.g. `const T & &` if Ts already carries a reference, or `const const // T &` if Ts already carries a const). This keeps trigger args no-copy // regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class TurnOnAction : public Action { +template class TurnOnAction final : public Action { public: using ApplyFn = void (*)(FanCall &, const std::remove_cvref_t &...); TurnOnAction(Fan *state, ApplyFn apply) : state_(state), apply_(apply) {} @@ -33,7 +33,7 @@ template class TurnOnAction : public Action { ApplyFn apply_; }; -template class TurnOffAction : public Action { +template class TurnOffAction final : public Action { public: explicit TurnOffAction(Fan *state) : state_(state) {} @@ -42,7 +42,7 @@ template class TurnOffAction : public Action { Fan *state_; }; -template class ToggleAction : public Action { +template class ToggleAction final : public Action { public: explicit ToggleAction(Fan *state) : state_(state) {} @@ -51,7 +51,7 @@ template class ToggleAction : public Action { Fan *state_; }; -template class CycleSpeedAction : public Action { +template class CycleSpeedAction final : public Action { public: explicit CycleSpeedAction(Fan *state) : state_(state) {} @@ -95,7 +95,7 @@ template class CycleSpeedAction : public Action { Fan *state_; }; -template class FanIsOnCondition : public Condition { +template class FanIsOnCondition final : public Condition { public: explicit FanIsOnCondition(Fan *state) : state_(state) {} bool check(const Ts &...x) override { return this->state_->state; } @@ -103,7 +103,7 @@ template class FanIsOnCondition : public Condition { protected: Fan *state_; }; -template class FanIsOffCondition : public Condition { +template class FanIsOffCondition final : public Condition { public: explicit FanIsOffCondition(Fan *state) : state_(state) {} bool check(const Ts &...x) override { return !this->state_->state; } @@ -112,7 +112,7 @@ template class FanIsOffCondition : public Condition { Fan *state_; }; -class FanStateTrigger : public Trigger { +class FanStateTrigger final : public Trigger { public: FanStateTrigger(Fan *state) : fan_(state) { state->add_on_state_callback([this]() { this->trigger(this->fan_); }); @@ -122,7 +122,7 @@ class FanStateTrigger : public Trigger { Fan *fan_; }; -class FanTurnOnTrigger : public Trigger<> { +class FanTurnOnTrigger final : public Trigger<> { public: FanTurnOnTrigger(Fan *state) : fan_(state) { state->add_on_state_callback([this]() { @@ -141,7 +141,7 @@ class FanTurnOnTrigger : public Trigger<> { bool last_on_; }; -class FanTurnOffTrigger : public Trigger<> { +class FanTurnOffTrigger final : public Trigger<> { public: FanTurnOffTrigger(Fan *state) : fan_(state) { state->add_on_state_callback([this]() { @@ -160,7 +160,7 @@ class FanTurnOffTrigger : public Trigger<> { bool last_on_; }; -class FanDirectionSetTrigger : public Trigger { +class FanDirectionSetTrigger final : public Trigger { public: FanDirectionSetTrigger(Fan *state) : fan_(state) { state->add_on_state_callback([this]() { @@ -179,7 +179,7 @@ class FanDirectionSetTrigger : public Trigger { FanDirection last_direction_; }; -class FanOscillatingSetTrigger : public Trigger { +class FanOscillatingSetTrigger final : public Trigger { public: FanOscillatingSetTrigger(Fan *state) : fan_(state) { state->add_on_state_callback([this]() { @@ -198,7 +198,7 @@ class FanOscillatingSetTrigger : public Trigger { bool last_oscillating_; }; -class FanSpeedSetTrigger : public Trigger { +class FanSpeedSetTrigger final : public Trigger { public: FanSpeedSetTrigger(Fan *state) : fan_(state) { state->add_on_state_callback([this]() { @@ -217,7 +217,7 @@ class FanSpeedSetTrigger : public Trigger { int last_speed_; }; -class FanPresetSetTrigger : public Trigger { +class FanPresetSetTrigger final : public Trigger { public: FanPresetSetTrigger(Fan *state) : fan_(state) { state->add_on_state_callback([this]() { diff --git a/esphome/components/fastled_base/fastled_light.h b/esphome/components/fastled_base/fastled_light.h index f8535eb628..1261b742a1 100644 --- a/esphome/components/fastled_base/fastled_light.h +++ b/esphome/components/fastled_base/fastled_light.h @@ -17,7 +17,7 @@ namespace esphome::fastled_base { -class FastLEDLightOutput : public light::AddressableLight { +class FastLEDLightOutput final : public light::AddressableLight { public: /// Only for custom effects: Get the internal controller. CLEDController *get_controller() const { return this->controller_; } diff --git a/esphome/components/feedback/feedback_cover.h b/esphome/components/feedback/feedback_cover.h index ed6f7490f8..3e4600acd2 100644 --- a/esphome/components/feedback/feedback_cover.h +++ b/esphome/components/feedback/feedback_cover.h @@ -10,7 +10,7 @@ namespace esphome::feedback { -class FeedbackCover : public cover::Cover, public Component { +class FeedbackCover final : public cover::Cover, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/fingerprint_grow/fingerprint_grow.h b/esphome/components/fingerprint_grow/fingerprint_grow.h index 7cecb7dc82..67662192ee 100644 --- a/esphome/components/fingerprint_grow/fingerprint_grow.h +++ b/esphome/components/fingerprint_grow/fingerprint_grow.h @@ -92,7 +92,7 @@ enum GrowAuraLEDColor { WHITE = 0x07, }; -class FingerprintGrowComponent : public PollingComponent, public uart::UARTDevice { +class FingerprintGrowComponent final : public PollingComponent, public uart::UARTDevice { public: void update() override; void setup() override; @@ -209,7 +209,8 @@ class FingerprintGrowComponent : public PollingComponent, public uart::UARTDevic CallbackManager enrollment_failed_callback_; }; -template class EnrollmentAction : public Action, public Parented { +template +class EnrollmentAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, finger_id) TEMPLATABLE_VALUE(uint8_t, num_scans) @@ -226,12 +227,12 @@ template class EnrollmentAction : public Action, public P }; template -class CancelEnrollmentAction : public Action, public Parented { +class CancelEnrollmentAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->finish_enrollment(1); } }; -template class DeleteAction : public Action, public Parented { +template class DeleteAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, finger_id) @@ -241,12 +242,13 @@ template class DeleteAction : public Action, public Paren } }; -template class DeleteAllAction : public Action, public Parented { +template class DeleteAllAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->delete_all_fingerprints(); } }; -template class LEDControlAction : public Action, public Parented { +template +class LEDControlAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, state) @@ -256,7 +258,8 @@ template class LEDControlAction : public Action, public P } }; -template class AuraLEDControlAction : public Action, public Parented { +template +class AuraLEDControlAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, state) TEMPLATABLE_VALUE(uint8_t, speed) diff --git a/esphome/components/font/font.h b/esphome/components/font/font.h index 9c9cfa0f6d..fa24181bd0 100644 --- a/esphome/components/font/font.h +++ b/esphome/components/font/font.h @@ -14,7 +14,7 @@ namespace esphome::font { class Font; -class Glyph { +class Glyph final { public: constexpr Glyph(uint32_t code_point, const uint8_t *data, int advance, int offset_x, int offset_y, int width, int height) @@ -37,7 +37,7 @@ class Glyph { int height; }; -class Font +class Font final #ifdef USE_DISPLAY : public display::BaseFont #endif diff --git a/esphome/components/fs3000/fs3000.h b/esphome/components/fs3000/fs3000.h index c019b1366b..e98e72fa8e 100644 --- a/esphome/components/fs3000/fs3000.h +++ b/esphome/components/fs3000/fs3000.h @@ -11,7 +11,7 @@ namespace esphome::fs3000 { // 1015 has a max speed detection of 15 m/s enum FS3000Model { FIVE, FIFTEEN }; -class FS3000Component : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { +class FS3000Component final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: void setup() override; void update() override; diff --git a/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.h b/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.h index 7cf8769f7a..d788b2044c 100644 --- a/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.h +++ b/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.h @@ -34,7 +34,7 @@ enum FTMode : uint8_t { static const size_t MAX_TOUCHES = 5; // max number of possible touches reported -class FT5x06Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { +class FT5x06Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ft63x6/ft63x6.h b/esphome/components/ft63x6/ft63x6.h index efa03168d9..5e72cf77d8 100644 --- a/esphome/components/ft63x6/ft63x6.h +++ b/esphome/components/ft63x6/ft63x6.h @@ -17,7 +17,7 @@ using namespace touchscreen; static const uint8_t FT6X36_DEFAULT_THRESHOLD = 22; -class FT63X6Touchscreen : public Touchscreen, public i2c::I2CDevice { +class FT63X6Touchscreen final : public Touchscreen, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/fujitsu_general/fujitsu_general.h b/esphome/components/fujitsu_general/fujitsu_general.h index ca93e4b300..8d2ec883da 100644 --- a/esphome/components/fujitsu_general/fujitsu_general.h +++ b/esphome/components/fujitsu_general/fujitsu_general.h @@ -46,7 +46,7 @@ const uint8_t FUJITSU_GENERAL_TEMP_MAX = 30; // Celsius */ // clang-format on -class FujitsuGeneralClimate : public climate_ir::ClimateIR { +class FujitsuGeneralClimate final : public climate_ir::ClimateIR { public: FujitsuGeneralClimate() : ClimateIR(FUJITSU_GENERAL_TEMP_MIN, FUJITSU_GENERAL_TEMP_MAX, 1.0f, true, true, From c69cfd44be1676eb3898b1ea86a983deda96ea5d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:04:55 +1200 Subject: [PATCH 0529/1815] Mark configurable classes as final (5/21) (#16956) --- .../components/dfrobot_sen0395/automation.h | 4 +-- .../dfrobot_sen0395/dfrobot_sen0395.h | 2 +- .../switch/dfrobot_sen0395_switch.h | 8 +++--- esphome/components/dht/dht.h | 2 +- esphome/components/dht12/dht12.h | 2 +- esphome/components/display/display.h | 12 ++++---- .../components/display_menu_base/automation.h | 28 +++++++++---------- .../components/display_menu_base/menu_item.h | 12 ++++---- esphome/components/dlms_meter/dlms_meter.h | 2 +- esphome/components/dps310/dps310.h | 2 +- esphome/components/ds1307/ds1307.h | 6 ++-- esphome/components/ds2484/ds2484.h | 2 +- esphome/components/dsmr/dsmr.h | 2 +- .../components/duty_cycle/duty_cycle_sensor.h | 2 +- .../components/duty_time/duty_time_sensor.h | 4 +-- esphome/components/e131/e131.h | 2 +- esphome/components/ee895/ee895.h | 2 +- .../ektf2232/touchscreen/ektf2232.h | 2 +- esphome/components/emc2101/emc2101.h | 2 +- .../emc2101/output/emc2101_output.h | 2 +- .../emc2101/sensor/emc2101_sensor.h | 2 +- esphome/components/emmeti/emmeti.h | 2 +- esphome/components/emontx/emontx.h | 4 +-- .../components/emontx/sensor/emontx_sensor.h | 2 +- esphome/components/endstop/endstop_cover.h | 2 +- esphome/components/ens160_i2c/ens160_i2c.h | 2 +- esphome/components/ens160_spi/ens160_spi.h | 6 ++-- esphome/components/ens210/ens210.h | 2 +- esphome/components/es7210/es7210.h | 2 +- esphome/components/es7243e/es7243e.h | 2 +- esphome/components/es8156/es8156.h | 2 +- esphome/components/es8311/es8311.h | 2 +- esphome/components/es8388/es8388.h | 2 +- .../es8388/select/adc_input_mic_select.h | 2 +- .../es8388/select/dac_output_select.h | 2 +- esphome/components/esp32/gpio.h | 2 +- esphome/components/esp32_ble/ble.h | 8 +++--- .../esp32_ble_beacon/esp32_ble_beacon.h | 2 +- 38 files changed, 74 insertions(+), 74 deletions(-) diff --git a/esphome/components/dfrobot_sen0395/automation.h b/esphome/components/dfrobot_sen0395/automation.h index bd91381d47..a5f4c99014 100644 --- a/esphome/components/dfrobot_sen0395/automation.h +++ b/esphome/components/dfrobot_sen0395/automation.h @@ -8,13 +8,13 @@ namespace esphome::dfrobot_sen0395 { template -class DfrobotSen0395ResetAction : public Action, public Parented { +class DfrobotSen0395ResetAction final : public Action, public Parented { public: void play(const Ts &...x) { this->parent_->enqueue(make_unique()); } }; template -class DfrobotSen0395SettingsAction : public Action, public Parented { +class DfrobotSen0395SettingsAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(int8_t, factory_reset) TEMPLATABLE_VALUE(int8_t, start_after_power_on) diff --git a/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h b/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h index 03e3b6b6ec..448a18a477 100644 --- a/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h +++ b/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h @@ -36,7 +36,7 @@ class CircularCommandQueue { std::unique_ptr commands_[COMMAND_QUEUE_SIZE]; }; -class DfrobotSen0395Component : public uart::UARTDevice, public Component { +class DfrobotSen0395Component final : public uart::UARTDevice, public Component { #ifdef USE_SWITCH SUB_SWITCH(sensor_active) SUB_SWITCH(turn_on_led) diff --git a/esphome/components/dfrobot_sen0395/switch/dfrobot_sen0395_switch.h b/esphome/components/dfrobot_sen0395/switch/dfrobot_sen0395_switch.h index d83734b034..1c2e929b24 100644 --- a/esphome/components/dfrobot_sen0395/switch/dfrobot_sen0395_switch.h +++ b/esphome/components/dfrobot_sen0395/switch/dfrobot_sen0395_switch.h @@ -9,22 +9,22 @@ namespace esphome::dfrobot_sen0395 { class DfrobotSen0395Switch : public switch_::Switch, public Component, public Parented {}; -class Sen0395PowerSwitch : public DfrobotSen0395Switch { +class Sen0395PowerSwitch final : public DfrobotSen0395Switch { public: void write_state(bool state) override; }; -class Sen0395LedSwitch : public DfrobotSen0395Switch { +class Sen0395LedSwitch final : public DfrobotSen0395Switch { public: void write_state(bool state) override; }; -class Sen0395UartPresenceSwitch : public DfrobotSen0395Switch { +class Sen0395UartPresenceSwitch final : public DfrobotSen0395Switch { public: void write_state(bool state) override; }; -class Sen0395StartAfterBootSwitch : public DfrobotSen0395Switch { +class Sen0395StartAfterBootSwitch final : public DfrobotSen0395Switch { public: void write_state(bool state) override; }; diff --git a/esphome/components/dht/dht.h b/esphome/components/dht/dht.h index 0c535f7cf6..86292e144f 100644 --- a/esphome/components/dht/dht.h +++ b/esphome/components/dht/dht.h @@ -18,7 +18,7 @@ enum DHTModel : uint8_t { }; /// Component for reading temperature/humidity measurements from DHT11/DHT22 sensors. -class DHT : public PollingComponent { +class DHT final : public PollingComponent { public: /** Manually select the DHT model. * diff --git a/esphome/components/dht12/dht12.h b/esphome/components/dht12/dht12.h index 5f4f822e70..b835ac1648 100644 --- a/esphome/components/dht12/dht12.h +++ b/esphome/components/dht12/dht12.h @@ -6,7 +6,7 @@ namespace esphome::dht12 { -class DHT12Component : public PollingComponent, public i2c::I2CDevice { +class DHT12Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index 6d0b7acfe8..3a136937f6 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -796,7 +796,7 @@ class Display : public PollingComponent { bool show_test_card_{false}; }; -class DisplayPage { +class DisplayPage final { public: DisplayPage(display_writer_t writer); void show(); @@ -814,7 +814,7 @@ class DisplayPage { DisplayPage *next_{nullptr}; }; -template class DisplayPageShowAction : public Action { +template class DisplayPageShowAction final : public Action { public: TEMPLATABLE_VALUE(DisplayPage *, page) @@ -826,7 +826,7 @@ template class DisplayPageShowAction : public Action { } }; -template class DisplayPageShowNextAction : public Action { +template class DisplayPageShowNextAction final : public Action { public: DisplayPageShowNextAction(Display *buffer) : buffer_(buffer) {} @@ -835,7 +835,7 @@ template class DisplayPageShowNextAction : public Action Display *buffer_; }; -template class DisplayPageShowPrevAction : public Action { +template class DisplayPageShowPrevAction final : public Action { public: DisplayPageShowPrevAction(Display *buffer) : buffer_(buffer) {} @@ -844,7 +844,7 @@ template class DisplayPageShowPrevAction : public Action Display *buffer_; }; -template class DisplayIsDisplayingPageCondition : public Condition { +template class DisplayIsDisplayingPageCondition final : public Condition { public: DisplayIsDisplayingPageCondition(Display *parent) : parent_(parent) {} @@ -856,7 +856,7 @@ template class DisplayIsDisplayingPageCondition : public Conditi DisplayPage *page_; }; -class DisplayOnPageChangeTrigger : public Trigger { +class DisplayOnPageChangeTrigger final : public Trigger { public: explicit DisplayOnPageChangeTrigger(Display *parent) { parent->add_on_page_change_trigger(this); } void process(DisplayPage *from, DisplayPage *to); diff --git a/esphome/components/display_menu_base/automation.h b/esphome/components/display_menu_base/automation.h index d4f83055d1..be0044ffa4 100644 --- a/esphome/components/display_menu_base/automation.h +++ b/esphome/components/display_menu_base/automation.h @@ -5,7 +5,7 @@ namespace esphome::display_menu_base { -template class UpAction : public Action { +template class UpAction final : public Action { public: explicit UpAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -15,7 +15,7 @@ template class UpAction : public Action { DisplayMenuComponent *menu_; }; -template class DownAction : public Action { +template class DownAction final : public Action { public: explicit DownAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -25,7 +25,7 @@ template class DownAction : public Action { DisplayMenuComponent *menu_; }; -template class LeftAction : public Action { +template class LeftAction final : public Action { public: explicit LeftAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -35,7 +35,7 @@ template class LeftAction : public Action { DisplayMenuComponent *menu_; }; -template class RightAction : public Action { +template class RightAction final : public Action { public: explicit RightAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -45,7 +45,7 @@ template class RightAction : public Action { DisplayMenuComponent *menu_; }; -template class EnterAction : public Action { +template class EnterAction final : public Action { public: explicit EnterAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -55,7 +55,7 @@ template class EnterAction : public Action { DisplayMenuComponent *menu_; }; -template class ShowAction : public Action { +template class ShowAction final : public Action { public: explicit ShowAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -65,7 +65,7 @@ template class ShowAction : public Action { DisplayMenuComponent *menu_; }; -template class HideAction : public Action { +template class HideAction final : public Action { public: explicit HideAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -75,7 +75,7 @@ template class HideAction : public Action { DisplayMenuComponent *menu_; }; -template class ShowMainAction : public Action { +template class ShowMainAction final : public Action { public: explicit ShowMainAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -84,7 +84,7 @@ template class ShowMainAction : public Action { protected: DisplayMenuComponent *menu_; }; -template class IsActiveCondition : public Condition { +template class IsActiveCondition final : public Condition { public: explicit IsActiveCondition(DisplayMenuComponent *menu) : menu_(menu) {} bool check(const Ts &...x) override { return this->menu_->is_active(); } @@ -93,7 +93,7 @@ template class IsActiveCondition : public Condition { DisplayMenuComponent *menu_; }; -class DisplayMenuOnEnterTrigger : public Trigger { +class DisplayMenuOnEnterTrigger final : public Trigger { public: explicit DisplayMenuOnEnterTrigger(MenuItem *parent) : parent_(parent) { parent->add_on_enter_callback([this]() { this->trigger(this->parent_); }); @@ -103,7 +103,7 @@ class DisplayMenuOnEnterTrigger : public Trigger { MenuItem *parent_; }; -class DisplayMenuOnLeaveTrigger : public Trigger { +class DisplayMenuOnLeaveTrigger final : public Trigger { public: explicit DisplayMenuOnLeaveTrigger(MenuItem *parent) : parent_(parent) { parent->add_on_leave_callback([this]() { this->trigger(this->parent_); }); @@ -113,7 +113,7 @@ class DisplayMenuOnLeaveTrigger : public Trigger { MenuItem *parent_; }; -class DisplayMenuOnValueTrigger : public Trigger { +class DisplayMenuOnValueTrigger final : public Trigger { public: explicit DisplayMenuOnValueTrigger(MenuItem *parent) : parent_(parent) { parent->add_on_value_callback([this]() { this->trigger(this->parent_); }); @@ -123,7 +123,7 @@ class DisplayMenuOnValueTrigger : public Trigger { MenuItem *parent_; }; -class DisplayMenuOnNextTrigger : public Trigger { +class DisplayMenuOnNextTrigger final : public Trigger { public: explicit DisplayMenuOnNextTrigger(MenuItemCustom *parent) : parent_(parent) { parent->add_on_next_callback([this]() { this->trigger(this->parent_); }); @@ -133,7 +133,7 @@ class DisplayMenuOnNextTrigger : public Trigger { MenuItemCustom *parent_; }; -class DisplayMenuOnPrevTrigger : public Trigger { +class DisplayMenuOnPrevTrigger final : public Trigger { public: explicit DisplayMenuOnPrevTrigger(MenuItemCustom *parent) : parent_(parent) { parent->add_on_prev_callback([this]() { this->trigger(this->parent_); }); diff --git a/esphome/components/display_menu_base/menu_item.h b/esphome/components/display_menu_base/menu_item.h index f3c41583f7..d5732377e3 100644 --- a/esphome/components/display_menu_base/menu_item.h +++ b/esphome/components/display_menu_base/menu_item.h @@ -70,7 +70,7 @@ class MenuItem { CallbackManager on_value_callbacks_{}; }; -class MenuItemMenu : public MenuItem { +class MenuItemMenu final : public MenuItem { public: explicit MenuItemMenu() : MenuItem(MENU_ITEM_MENU) {} void add_item(MenuItem *item) { @@ -97,7 +97,7 @@ class MenuItemEditable : public MenuItem { }; #ifdef USE_SELECT -class MenuItemSelect : public MenuItemEditable { +class MenuItemSelect final : public MenuItemEditable { public: explicit MenuItemSelect() : MenuItemEditable(MENU_ITEM_SELECT) {} void set_select_variable(select::Select *var) { this->select_var_ = var; } @@ -114,7 +114,7 @@ class MenuItemSelect : public MenuItemEditable { #endif #ifdef USE_NUMBER -class MenuItemNumber : public MenuItemEditable { +class MenuItemNumber final : public MenuItemEditable { public: explicit MenuItemNumber() : MenuItemEditable(MENU_ITEM_NUMBER) {} void set_number_variable(number::Number *var) { this->number_var_ = var; } @@ -135,7 +135,7 @@ class MenuItemNumber : public MenuItemEditable { #endif #ifdef USE_SWITCH -class MenuItemSwitch : public MenuItemEditable { +class MenuItemSwitch final : public MenuItemEditable { public: explicit MenuItemSwitch() : MenuItemEditable(MENU_ITEM_SWITCH) {} void set_switch_variable(switch_::Switch *var) { this->switch_var_ = var; } @@ -158,7 +158,7 @@ class MenuItemSwitch : public MenuItemEditable { }; #endif -class MenuItemCommand : public MenuItem { +class MenuItemCommand final : public MenuItem { public: explicit MenuItemCommand() : MenuItem(MENU_ITEM_COMMAND) {} @@ -166,7 +166,7 @@ class MenuItemCommand : public MenuItem { bool select_prev() override; }; -class MenuItemCustom : public MenuItemEditable { +class MenuItemCustom final : public MenuItemEditable { public: explicit MenuItemCustom() : MenuItemEditable(MENU_ITEM_CUSTOM) {} template void add_on_next_callback(F &&cb) { this->on_next_callbacks_.add(std::forward(cb)); } diff --git a/esphome/components/dlms_meter/dlms_meter.h b/esphome/components/dlms_meter/dlms_meter.h index cdc53d5685..fc4721843f 100644 --- a/esphome/components/dlms_meter/dlms_meter.h +++ b/esphome/components/dlms_meter/dlms_meter.h @@ -98,7 +98,7 @@ struct CustomPattern { std::optional> default_obis; }; -class DlmsMeterComponent : public Component, public uart::UARTDevice { +class DlmsMeterComponent final : public Component, public uart::UARTDevice { public: DlmsMeterComponent(uint32_t receive_timeout_ms, bool skip_crc_check, std::optional> decryption_key, diff --git a/esphome/components/dps310/dps310.h b/esphome/components/dps310/dps310.h index 09143bf6b8..4dd23985d7 100644 --- a/esphome/components/dps310/dps310.h +++ b/esphome/components/dps310/dps310.h @@ -35,7 +35,7 @@ static const uint8_t DPS310_INIT_TIMEOUT = 20; // How long to wait for DPS static const uint8_t DPS310_NUM_COEF_REGS = 18; // Number of coefficients we need to read from the device static const int32_t DPS310_SCALE_FACTOR = 1572864; // Measurement compensation scale factor -class DPS310Component : public PollingComponent, public i2c::I2CDevice { +class DPS310Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ds1307/ds1307.h b/esphome/components/ds1307/ds1307.h index 2004978cc6..238fc7b21a 100644 --- a/esphome/components/ds1307/ds1307.h +++ b/esphome/components/ds1307/ds1307.h @@ -6,7 +6,7 @@ namespace esphome::ds1307 { -class DS1307Component : public time::RealTimeClock, public i2c::I2CDevice { +class DS1307Component final : public time::RealTimeClock, public i2c::I2CDevice { public: void setup() override; void update() override; @@ -55,12 +55,12 @@ class DS1307Component : public time::RealTimeClock, public i2c::I2CDevice { } ds1307_; }; -template class WriteAction : public Action, public Parented { +template class WriteAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->write_time(); } }; -template class ReadAction : public Action, public Parented { +template class ReadAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->read_time(); } }; diff --git a/esphome/components/ds2484/ds2484.h b/esphome/components/ds2484/ds2484.h index 9e6bb08858..b3337539ce 100644 --- a/esphome/components/ds2484/ds2484.h +++ b/esphome/components/ds2484/ds2484.h @@ -8,7 +8,7 @@ namespace esphome::ds2484 { -class DS2484OneWireBus : public one_wire::OneWireBus, public i2c::I2CDevice, public Component { +class DS2484OneWireBus final : public one_wire::OneWireBus, public i2c::I2CDevice, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/dsmr/dsmr.h b/esphome/components/dsmr/dsmr.h index 3642309c26..321fbab824 100644 --- a/esphome/components/dsmr/dsmr.h +++ b/esphome/components/dsmr/dsmr.h @@ -65,7 +65,7 @@ using MyData = dsmr_parser::ParsedData; #endif -class Dsmr : public Component, public uart::UARTDevice { +class Dsmr final : public Component, public uart::UARTDevice { public: Dsmr(uart::UARTComponent *uart, bool crc_check, size_t max_telegram_length, uint32_t request_interval, uint32_t receive_timeout, GPIOPin *request_pin, const char *decryption_key) diff --git a/esphome/components/duty_cycle/duty_cycle_sensor.h b/esphome/components/duty_cycle/duty_cycle_sensor.h index 58beee946a..564c47a2aa 100644 --- a/esphome/components/duty_cycle/duty_cycle_sensor.h +++ b/esphome/components/duty_cycle/duty_cycle_sensor.h @@ -16,7 +16,7 @@ struct DutyCycleSensorStore { static void gpio_intr(DutyCycleSensorStore *arg); }; -class DutyCycleSensor : public sensor::Sensor, public PollingComponent { +class DutyCycleSensor final : public sensor::Sensor, public PollingComponent { public: void set_pin(InternalGPIOPin *pin) { pin_ = pin; } diff --git a/esphome/components/duty_time/duty_time_sensor.h b/esphome/components/duty_time/duty_time_sensor.h index 9b1e10ea8c..a9e91de0b1 100644 --- a/esphome/components/duty_time/duty_time_sensor.h +++ b/esphome/components/duty_time/duty_time_sensor.h @@ -12,7 +12,7 @@ namespace esphome::duty_time_sensor { -class DutyTimeSensor : public sensor::Sensor, public PollingComponent { +class DutyTimeSensor final : public sensor::Sensor, public PollingComponent { public: void setup() override; void update() override; @@ -61,7 +61,7 @@ template class ResetAction : public BaseAction { void play(const Ts &...x) override { this->parent_->reset(); } }; -template class RunningCondition : public Condition, public Parented { +template class RunningCondition final : public Condition, public Parented { public: explicit RunningCondition(DutyTimeSensor *parent, bool state) : Parented(parent), state_(state) {} diff --git a/esphome/components/e131/e131.h b/esphome/components/e131/e131.h index 6574037efb..b0a8b4f83f 100644 --- a/esphome/components/e131/e131.h +++ b/esphome/components/e131/e131.h @@ -30,7 +30,7 @@ struct UniverseConsumer { uint16_t consumers; }; -class E131Component : public esphome::Component { +class E131Component final : public esphome::Component { public: E131Component(); ~E131Component(); diff --git a/esphome/components/ee895/ee895.h b/esphome/components/ee895/ee895.h index ba8e594fea..1682e33146 100644 --- a/esphome/components/ee895/ee895.h +++ b/esphome/components/ee895/ee895.h @@ -7,7 +7,7 @@ namespace esphome::ee895 { /// This class implements support for the ee895 of temperature i2c sensors. -class EE895Component : public PollingComponent, public i2c::I2CDevice { +class EE895Component final : public PollingComponent, public i2c::I2CDevice { public: void set_co2_sensor(sensor::Sensor *co2) { co2_sensor_ = co2; } void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } diff --git a/esphome/components/ektf2232/touchscreen/ektf2232.h b/esphome/components/ektf2232/touchscreen/ektf2232.h index 45da74a2a5..a4b9cbd574 100644 --- a/esphome/components/ektf2232/touchscreen/ektf2232.h +++ b/esphome/components/ektf2232/touchscreen/ektf2232.h @@ -10,7 +10,7 @@ namespace esphome::ektf2232 { using namespace touchscreen; -class EKTF2232Touchscreen : public Touchscreen, public i2c::I2CDevice { +class EKTF2232Touchscreen final : public Touchscreen, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/emc2101/emc2101.h b/esphome/components/emc2101/emc2101.h index 1fe03a2630..b3ec0c5dc1 100644 --- a/esphome/components/emc2101/emc2101.h +++ b/esphome/components/emc2101/emc2101.h @@ -25,7 +25,7 @@ enum Emc2101DACConversionRate { /// This class includes support for the EMC2101 i2c fan controller. /// The device has an output (PWM or DAC) and several sensors and this /// class is for the EMC2101 configuration. -class Emc2101Component : public Component, public i2c::I2CDevice { +class Emc2101Component final : public Component, public i2c::I2CDevice { public: /** Sets the mode of the output. * diff --git a/esphome/components/emc2101/output/emc2101_output.h b/esphome/components/emc2101/output/emc2101_output.h index 95077f5524..9a7ab0659a 100644 --- a/esphome/components/emc2101/output/emc2101_output.h +++ b/esphome/components/emc2101/output/emc2101_output.h @@ -6,7 +6,7 @@ namespace esphome::emc2101 { /// This class allows to control the EMC2101 output. -class EMC2101Output : public output::FloatOutput { +class EMC2101Output final : public output::FloatOutput { public: EMC2101Output(Emc2101Component *parent) : parent_(parent) {} diff --git a/esphome/components/emc2101/sensor/emc2101_sensor.h b/esphome/components/emc2101/sensor/emc2101_sensor.h index 2336ac2f15..943e468e7d 100644 --- a/esphome/components/emc2101/sensor/emc2101_sensor.h +++ b/esphome/components/emc2101/sensor/emc2101_sensor.h @@ -7,7 +7,7 @@ namespace esphome::emc2101 { /// This class exposes the EMC2101 sensors. -class EMC2101Sensor : public PollingComponent { +class EMC2101Sensor final : public PollingComponent { public: EMC2101Sensor(Emc2101Component *parent) : parent_(parent) {} /** Used by ESPHome framework. */ diff --git a/esphome/components/emmeti/emmeti.h b/esphome/components/emmeti/emmeti.h index 9dc78ce07c..2203bfdec7 100644 --- a/esphome/components/emmeti/emmeti.h +++ b/esphome/components/emmeti/emmeti.h @@ -60,7 +60,7 @@ struct EmmetiState { uint8_t checksum = 0; }; -class EmmetiClimate : public climate_ir::ClimateIR { +class EmmetiClimate final : public climate_ir::ClimateIR { public: EmmetiClimate() : climate_ir::ClimateIR(EMMETI_TEMP_MIN, EMMETI_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h index 67e7f5bffc..6db197a78c 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -26,7 +26,7 @@ static constexpr size_t MAX_LINE_LENGTH = 1024; * The EmonTx processes incoming data frames via UART, * extracts tags and values, and publishes them to registered sensors. */ -class EmonTx : public Component, public uart::UARTDevice { +class EmonTx final : public Component, public uart::UARTDevice { public: EmonTx() = default; @@ -59,7 +59,7 @@ class EmonTx : public Component, public uart::UARTDevice { }; // Action to send command to emonTx -template class EmonTxSendCommandAction : public Action, public Parented { +template class EmonTxSendCommandAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(std::string, command) diff --git a/esphome/components/emontx/sensor/emontx_sensor.h b/esphome/components/emontx/sensor/emontx_sensor.h index 9714acdf0d..88b396bbc4 100644 --- a/esphome/components/emontx/sensor/emontx_sensor.h +++ b/esphome/components/emontx/sensor/emontx_sensor.h @@ -5,7 +5,7 @@ namespace esphome::emontx { -class EmonTxSensor : public sensor::Sensor, public Component { +class EmonTxSensor final : public sensor::Sensor, public Component { public: void dump_config() override; }; diff --git a/esphome/components/endstop/endstop_cover.h b/esphome/components/endstop/endstop_cover.h index b910139bcd..5319c74d7b 100644 --- a/esphome/components/endstop/endstop_cover.h +++ b/esphome/components/endstop/endstop_cover.h @@ -7,7 +7,7 @@ namespace esphome::endstop { -class EndstopCover : public cover::Cover, public Component { +class EndstopCover final : public cover::Cover, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/ens160_i2c/ens160_i2c.h b/esphome/components/ens160_i2c/ens160_i2c.h index 98318a7eca..d5a0d21c62 100644 --- a/esphome/components/ens160_i2c/ens160_i2c.h +++ b/esphome/components/ens160_i2c/ens160_i2c.h @@ -5,7 +5,7 @@ namespace esphome::ens160_i2c { -class ENS160I2CComponent : public esphome::ens160_base::ENS160Component, public i2c::I2CDevice { +class ENS160I2CComponent final : public esphome::ens160_base::ENS160Component, public i2c::I2CDevice { void dump_config() override; bool read_byte(uint8_t a_register, uint8_t *data) override; diff --git a/esphome/components/ens160_spi/ens160_spi.h b/esphome/components/ens160_spi/ens160_spi.h index d4d3cf3ae9..821e89515f 100644 --- a/esphome/components/ens160_spi/ens160_spi.h +++ b/esphome/components/ens160_spi/ens160_spi.h @@ -5,9 +5,9 @@ namespace esphome::ens160_spi { -class ENS160SPIComponent : public esphome::ens160_base::ENS160Component, - public spi::SPIDevice { +class ENS160SPIComponent final : public esphome::ens160_base::ENS160Component, + public spi::SPIDevice { void setup() override; void dump_config() override; diff --git a/esphome/components/ens210/ens210.h b/esphome/components/ens210/ens210.h index f1520fc483..fca20133b8 100644 --- a/esphome/components/ens210/ens210.h +++ b/esphome/components/ens210/ens210.h @@ -7,7 +7,7 @@ namespace esphome::ens210 { /// This class implements support for the ENS210 relative humidity and temperature i2c sensor. -class ENS210Component : public PollingComponent, public i2c::I2CDevice { +class ENS210Component final : public PollingComponent, public i2c::I2CDevice { public: void dump_config() override; void setup() override; diff --git a/esphome/components/es7210/es7210.h b/esphome/components/es7210/es7210.h index 914fbd633b..42c667b658 100644 --- a/esphome/components/es7210/es7210.h +++ b/esphome/components/es7210/es7210.h @@ -16,7 +16,7 @@ enum ES7210BitsPerSample : uint8_t { ES7210_BITS_PER_SAMPLE_32 = 32, }; -class ES7210 : public audio_adc::AudioAdc, public Component, public i2c::I2CDevice { +class ES7210 final : public audio_adc::AudioAdc, public Component, public i2c::I2CDevice { /* Class for configuring an ES7210 ADC for microphone input. * Based on code from: * - https://github.com/espressif/esp-bsp/ (accessed 20241219) diff --git a/esphome/components/es7243e/es7243e.h b/esphome/components/es7243e/es7243e.h index 6386ea529a..47dc6122c9 100644 --- a/esphome/components/es7243e/es7243e.h +++ b/esphome/components/es7243e/es7243e.h @@ -6,7 +6,7 @@ namespace esphome::es7243e { -class ES7243E : public audio_adc::AudioAdc, public Component, public i2c::I2CDevice { +class ES7243E final : public audio_adc::AudioAdc, public Component, public i2c::I2CDevice { /* Class for configuring an ES7243E ADC for microphone input. * Based on code from: * - https://github.com/espressif/esp-adf/ (accessed 20250116) diff --git a/esphome/components/es8156/es8156.h b/esphome/components/es8156/es8156.h index c3cec3dc14..d29e8d1685 100644 --- a/esphome/components/es8156/es8156.h +++ b/esphome/components/es8156/es8156.h @@ -6,7 +6,7 @@ namespace esphome::es8156 { -class ES8156 : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { +class ES8156 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { public: ///////////////////////// // Component overrides // diff --git a/esphome/components/es8311/es8311.h b/esphome/components/es8311/es8311.h index 1190bcb0aa..ecc4b1014d 100644 --- a/esphome/components/es8311/es8311.h +++ b/esphome/components/es8311/es8311.h @@ -42,7 +42,7 @@ struct ES8311Coefficient { uint8_t dac_osr; // dac osr }; -class ES8311 : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { +class ES8311 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { public: ///////////////////////// // Component overrides // diff --git a/esphome/components/es8388/es8388.h b/esphome/components/es8388/es8388.h index 1f744e25b3..b01acb69c1 100644 --- a/esphome/components/es8388/es8388.h +++ b/esphome/components/es8388/es8388.h @@ -25,7 +25,7 @@ enum AdcInputMicLine : uint8_t { ADC_INPUT_MIC_DIFFERENCE, }; -class ES8388 : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { +class ES8388 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { #ifdef USE_SELECT SUB_SELECT(dac_output) SUB_SELECT(adc_input_mic) diff --git a/esphome/components/es8388/select/adc_input_mic_select.h b/esphome/components/es8388/select/adc_input_mic_select.h index 29978f1623..2d4e8d72db 100644 --- a/esphome/components/es8388/select/adc_input_mic_select.h +++ b/esphome/components/es8388/select/adc_input_mic_select.h @@ -5,7 +5,7 @@ namespace esphome::es8388 { -class ADCInputMicSelect : public select::Select, public Parented { +class ADCInputMicSelect final : public select::Select, public Parented { protected: void control(size_t index) override; }; diff --git a/esphome/components/es8388/select/dac_output_select.h b/esphome/components/es8388/select/dac_output_select.h index 030f12406e..f63ee8d1ba 100644 --- a/esphome/components/es8388/select/dac_output_select.h +++ b/esphome/components/es8388/select/dac_output_select.h @@ -5,7 +5,7 @@ namespace esphome::es8388 { -class DacOutputSelect : public select::Select, public Parented { +class DacOutputSelect final : public select::Select, public Parented { protected: void control(size_t index) override; }; diff --git a/esphome/components/esp32/gpio.h b/esphome/components/esp32/gpio.h index a140eeef77..aeff5af51c 100644 --- a/esphome/components/esp32/gpio.h +++ b/esphome/components/esp32/gpio.h @@ -10,7 +10,7 @@ namespace esphome::esp32 { static_assert(GPIO_NUM_MAX <= 256, "gpio_num_t has too many values for uint8_t"); static_assert(GPIO_DRIVE_CAP_MAX <= 4, "gpio_drive_cap_t has too many values for 2-bit field"); -class ESP32InternalGPIOPin : public InternalGPIOPin { +class ESP32InternalGPIOPin final : public InternalGPIOPin { public: void set_pin(gpio_num_t pin) { this->pin_ = static_cast(pin); } void set_inverted(bool inverted) { this->pin_flags_.inverted = inverted; } diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index de8c8c2343..c85ddfc983 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -87,7 +87,7 @@ enum BLEComponentState : uint8_t { BLE_COMPONENT_STATE_ACTIVE, }; -class ESP32BLE : public Component { +class ESP32BLE final : public Component { public: void set_io_capability(IoCapability io_capability) { this->io_cap_ = (esp_ble_io_cap_t) io_capability; } @@ -236,12 +236,12 @@ class ESP32BLE : public Component { // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) extern ESP32BLE *global_ble; -template class BLEEnabledCondition : public Condition { +template class BLEEnabledCondition final : public Condition { public: bool check(const Ts &...x) override { return global_ble != nullptr && global_ble->is_active(); } }; -template class BLEEnableAction : public Action { +template class BLEEnableAction final : public Action { public: void play(const Ts &...x) override { if (global_ble != nullptr) @@ -249,7 +249,7 @@ template class BLEEnableAction : public Action { } }; -template class BLEDisableAction : public Action { +template class BLEDisableAction final : public Action { public: void play(const Ts &...x) override { if (global_ble != nullptr) diff --git a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h index 8b3899a681..986778de57 100644 --- a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h +++ b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h @@ -34,7 +34,7 @@ using esp_ble_ibeacon_t = struct { using namespace esp32_ble; -class ESP32BLEBeacon : public Component { +class ESP32BLEBeacon final : public Component { public: explicit ESP32BLEBeacon(const std::array &uuid) : uuid_(uuid) {} From e88f69b5f81550ecd50036e922014526a10d5629 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:05:05 +1200 Subject: [PATCH 0530/1815] Mark configurable classes as final (7/21: gcja5-hlw8032) (#16958) --- esphome/components/gcja5/gcja5.h | 2 +- esphome/components/gdk101/gdk101.h | 2 +- esphome/components/gl_r01_i2c/gl_r01_i2c.h | 2 +- .../components/globals/globals_component.h | 4 +-- .../components/gp2y1010au0f/gp2y1010au0f.h | 2 +- esphome/components/gp8403/gp8403.h | 2 +- .../components/gp8403/output/gp8403_output.h | 2 +- .../components/gpio/one_wire/gpio_one_wire.h | 2 +- .../gpio/output/gpio_binary_output.h | 2 +- esphome/components/gps/gps.h | 2 +- esphome/components/gps/time/gps_time.h | 2 +- esphome/components/graph/graph.h | 6 ++--- esphome/components/gree/gree.h | 2 +- esphome/components/gree/switch/gree_switch.h | 2 +- .../grove_gas_mc_v2/grove_gas_mc_v2.h | 2 +- .../grove_tb6612fng/grove_tb6612fng.h | 14 +++++----- .../components/growatt_solar/growatt_solar.h | 2 +- .../gt911/binary_sensor/gt911_button.h | 8 +++--- .../gt911/touchscreen/gt911_touchscreen.h | 2 +- esphome/components/haier/automation.h | 26 +++++++++---------- .../components/haier/button/self_cleaning.h | 2 +- .../components/haier/button/steri_cleaning.h | 2 +- esphome/components/haier/hon_climate.h | 2 +- esphome/components/haier/smartair2_climate.h | 2 +- esphome/components/haier/switch/beeper.h | 2 +- esphome/components/haier/switch/display.h | 2 +- esphome/components/haier/switch/health_mode.h | 2 +- esphome/components/haier/switch/quiet_mode.h | 2 +- .../components/havells_solar/havells_solar.h | 2 +- esphome/components/hbridge/fan/hbridge_fan.h | 4 +-- .../hbridge/light/hbridge_light_output.h | 2 +- .../hbridge/switch/hbridge_switch.h | 2 +- esphome/components/hc8/hc8.h | 4 +-- esphome/components/hdc1080/hdc1080.h | 2 +- esphome/components/hdc2010/hdc2010.h | 2 +- esphome/components/hdc2080/hdc2080.h | 2 +- esphome/components/hdc302x/hdc302x.h | 6 ++--- esphome/components/he60r/he60r.h | 2 +- esphome/components/heatpumpir/heatpumpir.h | 2 +- .../components/hitachi_ac344/hitachi_ac344.h | 2 +- .../components/hitachi_ac424/hitachi_ac424.h | 2 +- esphome/components/hlk_fm22x/hlk_fm22x.h | 12 ++++----- esphome/components/hlw8012/hlw8012.h | 2 +- esphome/components/hlw8032/hlw8032.h | 2 +- 44 files changed, 77 insertions(+), 77 deletions(-) diff --git a/esphome/components/gcja5/gcja5.h b/esphome/components/gcja5/gcja5.h index 30c9464b4a..f25d864f1a 100644 --- a/esphome/components/gcja5/gcja5.h +++ b/esphome/components/gcja5/gcja5.h @@ -7,7 +7,7 @@ namespace esphome::gcja5 { -class GCJA5Component : public Component, public uart::UARTDevice { +class GCJA5Component final : public Component, public uart::UARTDevice { public: void dump_config() override; void loop() override; diff --git a/esphome/components/gdk101/gdk101.h b/esphome/components/gdk101/gdk101.h index 2ef7526294..5a91594081 100644 --- a/esphome/components/gdk101/gdk101.h +++ b/esphome/components/gdk101/gdk101.h @@ -22,7 +22,7 @@ static const uint8_t GDK101_REG_READ_MEASURING_TIME = 0xB1; // Mesuring time static const uint8_t GDK101_REG_READ_10MIN_AVG = 0xB2; // Average radiation dose per 10 min static const uint8_t GDK101_REG_READ_1MIN_AVG = 0xB3; // Average radiation dose per 1 min -class GDK101Component : public PollingComponent, public i2c::I2CDevice { +class GDK101Component final : public PollingComponent, public i2c::I2CDevice { #ifdef USE_SENSOR SUB_SENSOR(rad_1m) SUB_SENSOR(rad_10m) diff --git a/esphome/components/gl_r01_i2c/gl_r01_i2c.h b/esphome/components/gl_r01_i2c/gl_r01_i2c.h index 1d023c245a..23a1dec336 100644 --- a/esphome/components/gl_r01_i2c/gl_r01_i2c.h +++ b/esphome/components/gl_r01_i2c/gl_r01_i2c.h @@ -6,7 +6,7 @@ namespace esphome::gl_r01_i2c { -class GLR01I2CComponent : public sensor::Sensor, public i2c::I2CDevice, public PollingComponent { +class GLR01I2CComponent final : public sensor::Sensor, public i2c::I2CDevice, public PollingComponent { public: void setup() override; void dump_config() override; diff --git a/esphome/components/globals/globals_component.h b/esphome/components/globals/globals_component.h index 520c068e6f..78d2bc5910 100644 --- a/esphome/components/globals/globals_component.h +++ b/esphome/components/globals/globals_component.h @@ -7,7 +7,7 @@ namespace esphome::globals { -template class GlobalsComponent : public Component { +template class GlobalsComponent final : public Component { public: using value_type = T; explicit GlobalsComponent() = default; @@ -127,7 +127,7 @@ template class RestoringGlobalStringComponent : public P ESPPreferenceObject rtc_; }; -template class GlobalVarSetAction : public Action { +template class GlobalVarSetAction final : public Action { public: explicit GlobalVarSetAction(C *parent) : parent_(parent) {} diff --git a/esphome/components/gp2y1010au0f/gp2y1010au0f.h b/esphome/components/gp2y1010au0f/gp2y1010au0f.h index f3398ac4a3..648e66d2ff 100644 --- a/esphome/components/gp2y1010au0f/gp2y1010au0f.h +++ b/esphome/components/gp2y1010au0f/gp2y1010au0f.h @@ -7,7 +7,7 @@ namespace esphome::gp2y1010au0f { -class GP2Y1010AU0FSensor : public sensor::Sensor, public PollingComponent { +class GP2Y1010AU0FSensor final : public sensor::Sensor, public PollingComponent { public: void update() override; void loop() override; diff --git a/esphome/components/gp8403/gp8403.h b/esphome/components/gp8403/gp8403.h index d30d967479..5d969c20d2 100644 --- a/esphome/components/gp8403/gp8403.h +++ b/esphome/components/gp8403/gp8403.h @@ -15,7 +15,7 @@ enum GP8403Model : uint8_t { GP8413, }; -class GP8403Component : public Component, public i2c::I2CDevice { +class GP8403Component final : public Component, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/gp8403/output/gp8403_output.h b/esphome/components/gp8403/output/gp8403_output.h index 8b1f920680..ea3b7cd6f6 100644 --- a/esphome/components/gp8403/output/gp8403_output.h +++ b/esphome/components/gp8403/output/gp8403_output.h @@ -7,7 +7,7 @@ namespace esphome::gp8403 { -class GP8403Output : public Component, public output::FloatOutput, public Parented { +class GP8403Output final : public Component, public output::FloatOutput, public Parented { public: void dump_config() override; float get_setup_priority() const override { return setup_priority::DATA - 1; } diff --git a/esphome/components/gpio/one_wire/gpio_one_wire.h b/esphome/components/gpio/one_wire/gpio_one_wire.h index 02797b5737..e457b599e5 100644 --- a/esphome/components/gpio/one_wire/gpio_one_wire.h +++ b/esphome/components/gpio/one_wire/gpio_one_wire.h @@ -6,7 +6,7 @@ namespace esphome::gpio { -class GPIOOneWireBus : public one_wire::OneWireBus, public Component { +class GPIOOneWireBus final : public one_wire::OneWireBus, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/gpio/output/gpio_binary_output.h b/esphome/components/gpio/output/gpio_binary_output.h index 4100cb94c2..496afd131b 100644 --- a/esphome/components/gpio/output/gpio_binary_output.h +++ b/esphome/components/gpio/output/gpio_binary_output.h @@ -6,7 +6,7 @@ namespace esphome::gpio { -class GPIOBinaryOutput : public output::BinaryOutput, public Component { +class GPIOBinaryOutput final : public output::BinaryOutput, public Component { public: void set_pin(GPIOPin *pin) { pin_ = pin; } diff --git a/esphome/components/gps/gps.h b/esphome/components/gps/gps.h index 9cd79e25b4..7771286edf 100644 --- a/esphome/components/gps/gps.h +++ b/esphome/components/gps/gps.h @@ -22,7 +22,7 @@ class GPSListener { GPS *parent_; }; -class GPS : public PollingComponent, public uart::UARTDevice { +class GPS final : public PollingComponent, public uart::UARTDevice { public: void set_latitude_sensor(sensor::Sensor *latitude_sensor) { this->latitude_sensor_ = latitude_sensor; } void set_longitude_sensor(sensor::Sensor *longitude_sensor) { this->longitude_sensor_ = longitude_sensor; } diff --git a/esphome/components/gps/time/gps_time.h b/esphome/components/gps/time/gps_time.h index 3d6d870efc..bd2049c46c 100644 --- a/esphome/components/gps/time/gps_time.h +++ b/esphome/components/gps/time/gps_time.h @@ -6,7 +6,7 @@ namespace esphome::gps { -class GPSTime : public time::RealTimeClock, public GPSListener { +class GPSTime final : public time::RealTimeClock, public GPSListener { public: void update() override { this->from_tiny_gps_(this->get_tiny_gps()); }; void on_update(TinyGPSPlus &tiny_gps) override { diff --git a/esphome/components/graph/graph.h b/esphome/components/graph/graph.h index a601e9eeb1..dbedab6085 100644 --- a/esphome/components/graph/graph.h +++ b/esphome/components/graph/graph.h @@ -42,7 +42,7 @@ enum ValuePositionType { VALUE_POSITION_TYPE_BELOW }; -class GraphLegend { +class GraphLegend final { public: void init(Graph *g); void set_name_font(display::BaseFont *font) { this->font_label_ = font; } @@ -105,7 +105,7 @@ class HistoryData { std::vector samples_; }; -class GraphTrace { +class GraphTrace final { public: void init(Graph *g); void set_name(std::string name) { name_ = std::move(name); } @@ -134,7 +134,7 @@ class GraphTrace { friend GraphLegend; }; -class Graph : public Component { +class Graph final : public Component { public: void draw(display::Display *buff, uint16_t x_offset, uint16_t y_offset, Color color); void draw_legend(display::Display *buff, uint16_t x_offset, uint16_t y_offset, Color color); diff --git a/esphome/components/gree/gree.h b/esphome/components/gree/gree.h index 1eb812ae46..2f10be3e6b 100644 --- a/esphome/components/gree/gree.h +++ b/esphome/components/gree/gree.h @@ -79,7 +79,7 @@ static constexpr uint8_t GREE_PRESET_SLEEP_BIT = 0x80; // Model codes enum Model { GREE_GENERIC, GREE_YAN, GREE_YAA, GREE_YAC, GREE_YAC1FB9, GREE_YX1FF, GREE_YAG }; -class GreeClimate : public climate_ir::ClimateIR { +class GreeClimate final : public climate_ir::ClimateIR { public: GreeClimate() : climate_ir::ClimateIR(GREE_TEMP_MIN, GREE_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/gree/switch/gree_switch.h b/esphome/components/gree/switch/gree_switch.h index 9d9f187f9d..1e82c83ae6 100644 --- a/esphome/components/gree/switch/gree_switch.h +++ b/esphome/components/gree/switch/gree_switch.h @@ -6,7 +6,7 @@ namespace esphome::gree { -class GreeModeBitSwitch : public switch_::Switch, public Component, public Parented { +class GreeModeBitSwitch final : public switch_::Switch, public Component, public Parented { public: GreeModeBitSwitch(const char *name, uint8_t bit_mask) : name_(name), bit_mask_(bit_mask) {} diff --git a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.h b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.h index 38165ab68c..545b6df97a 100644 --- a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.h +++ b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.h @@ -7,7 +7,7 @@ namespace esphome::grove_gas_mc_v2 { -class GroveGasMultichannelV2Component : public PollingComponent, public i2c::I2CDevice { +class GroveGasMultichannelV2Component final : public PollingComponent, public i2c::I2CDevice { SUB_SENSOR(tvoc) SUB_SENSOR(carbon_monoxide) SUB_SENSOR(nitrogen_dioxide) diff --git a/esphome/components/grove_tb6612fng/grove_tb6612fng.h b/esphome/components/grove_tb6612fng/grove_tb6612fng.h index c021680519..a8648025b9 100644 --- a/esphome/components/grove_tb6612fng/grove_tb6612fng.h +++ b/esphome/components/grove_tb6612fng/grove_tb6612fng.h @@ -47,7 +47,7 @@ enum StepperModeTypeT { MICRO_STEPPING = 3, }; -class GroveMotorDriveTB6612FNG : public Component, public i2c::I2CDevice { +class GroveMotorDriveTB6612FNG final : public Component, public i2c::I2CDevice { public: void setup() override; void dump_config() override; @@ -162,7 +162,7 @@ class GroveMotorDriveTB6612FNG : public Component, public i2c::I2CDevice { }; template -class GROVETB6612FNGMotorRunAction : public Action, public Parented { +class GROVETB6612FNGMotorRunAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, channel) TEMPLATABLE_VALUE(uint16_t, speed) @@ -183,7 +183,7 @@ class GROVETB6612FNGMotorRunAction : public Action, public Parented -class GROVETB6612FNGMotorBrakeAction : public Action, public Parented { +class GROVETB6612FNGMotorBrakeAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, channel) @@ -191,7 +191,7 @@ class GROVETB6612FNGMotorBrakeAction : public Action, public Parented -class GROVETB6612FNGMotorStopAction : public Action, public Parented { +class GROVETB6612FNGMotorStopAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, channel) @@ -199,19 +199,19 @@ class GROVETB6612FNGMotorStopAction : public Action, public Parented -class GROVETB6612FNGMotorStandbyAction : public Action, public Parented { +class GROVETB6612FNGMotorStandbyAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->standby(); } }; template -class GROVETB6612FNGMotorNoStandbyAction : public Action, public Parented { +class GROVETB6612FNGMotorNoStandbyAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->not_standby(); } }; template -class GROVETB6612FNGMotorChangeAddressAction : public Action, public Parented { +class GROVETB6612FNGMotorChangeAddressAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, address) diff --git a/esphome/components/growatt_solar/growatt_solar.h b/esphome/components/growatt_solar/growatt_solar.h index 27ae32cc46..76d430737a 100644 --- a/esphome/components/growatt_solar/growatt_solar.h +++ b/esphome/components/growatt_solar/growatt_solar.h @@ -65,7 +65,7 @@ constexpr size_t RTU2_TODAY_PRODUCTION = 53; // length = 2 constexpr size_t RTU2_TOTAL_ENERGY_PRODUCTION = 55; // length = 2 constexpr size_t RTU2_INVERTER_MODULE_TEMP = 93; // length = 1 -class GrowattSolar : public PollingComponent, public modbus::ModbusDevice { +class GrowattSolar final : public PollingComponent, public modbus::ModbusDevice { public: void loop() override; void update() override; diff --git a/esphome/components/gt911/binary_sensor/gt911_button.h b/esphome/components/gt911/binary_sensor/gt911_button.h index 5aab457095..ccb725b50f 100644 --- a/esphome/components/gt911/binary_sensor/gt911_button.h +++ b/esphome/components/gt911/binary_sensor/gt911_button.h @@ -7,10 +7,10 @@ namespace esphome::gt911 { -class GT911Button : public binary_sensor::BinarySensor, - public Component, - public GT911ButtonListener, - public Parented { +class GT911Button final : public binary_sensor::BinarySensor, + public Component, + public GT911ButtonListener, + public Parented { public: void setup() override; void dump_config() override; diff --git a/esphome/components/gt911/touchscreen/gt911_touchscreen.h b/esphome/components/gt911/touchscreen/gt911_touchscreen.h index 0f1eeae720..465df528e5 100644 --- a/esphome/components/gt911/touchscreen/gt911_touchscreen.h +++ b/esphome/components/gt911/touchscreen/gt911_touchscreen.h @@ -12,7 +12,7 @@ class GT911ButtonListener { virtual void update_button(uint8_t index, bool state) = 0; }; -class GT911Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { +class GT911Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { public: /// @brief Initialize the GT911 touchscreen. /// diff --git a/esphome/components/haier/automation.h b/esphome/components/haier/automation.h index e345867d6f..a81fd4bdb7 100644 --- a/esphome/components/haier/automation.h +++ b/esphome/components/haier/automation.h @@ -6,7 +6,7 @@ namespace esphome::haier { -template class DisplayOnAction : public Action { +template class DisplayOnAction final : public Action { public: DisplayOnAction(HaierClimateBase *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->set_display_state(true); } @@ -15,7 +15,7 @@ template class DisplayOnAction : public Action { HaierClimateBase *parent_; }; -template class DisplayOffAction : public Action { +template class DisplayOffAction final : public Action { public: DisplayOffAction(HaierClimateBase *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->set_display_state(false); } @@ -24,7 +24,7 @@ template class DisplayOffAction : public Action { HaierClimateBase *parent_; }; -template class BeeperOnAction : public Action { +template class BeeperOnAction final : public Action { public: BeeperOnAction(HonClimate *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->set_beeper_state(true); } @@ -33,7 +33,7 @@ template class BeeperOnAction : public Action { HonClimate *parent_; }; -template class BeeperOffAction : public Action { +template class BeeperOffAction final : public Action { public: BeeperOffAction(HonClimate *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->set_beeper_state(false); } @@ -42,7 +42,7 @@ template class BeeperOffAction : public Action { HonClimate *parent_; }; -template class VerticalAirflowAction : public Action { +template class VerticalAirflowAction final : public Action { public: VerticalAirflowAction(HonClimate *parent) : parent_(parent) {} TEMPLATABLE_VALUE(hon_protocol::VerticalSwingMode, direction) @@ -52,7 +52,7 @@ template class VerticalAirflowAction : public Action { HonClimate *parent_; }; -template class HorizontalAirflowAction : public Action { +template class HorizontalAirflowAction final : public Action { public: HorizontalAirflowAction(HonClimate *parent) : parent_(parent) {} TEMPLATABLE_VALUE(hon_protocol::HorizontalSwingMode, direction) @@ -62,7 +62,7 @@ template class HorizontalAirflowAction : public Action { HonClimate *parent_; }; -template class HealthOnAction : public Action { +template class HealthOnAction final : public Action { public: HealthOnAction(HaierClimateBase *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->set_health_mode(true); } @@ -71,7 +71,7 @@ template class HealthOnAction : public Action { HaierClimateBase *parent_; }; -template class HealthOffAction : public Action { +template class HealthOffAction final : public Action { public: HealthOffAction(HaierClimateBase *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->set_health_mode(false); } @@ -80,7 +80,7 @@ template class HealthOffAction : public Action { HaierClimateBase *parent_; }; -template class StartSelfCleaningAction : public Action { +template class StartSelfCleaningAction final : public Action { public: StartSelfCleaningAction(HonClimate *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->start_self_cleaning(); } @@ -89,7 +89,7 @@ template class StartSelfCleaningAction : public Action { HonClimate *parent_; }; -template class StartSteriCleaningAction : public Action { +template class StartSteriCleaningAction final : public Action { public: StartSteriCleaningAction(HonClimate *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->start_steri_cleaning(); } @@ -98,7 +98,7 @@ template class StartSteriCleaningAction : public Action { HonClimate *parent_; }; -template class PowerOnAction : public Action { +template class PowerOnAction final : public Action { public: PowerOnAction(HaierClimateBase *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->send_power_on_command(); } @@ -107,7 +107,7 @@ template class PowerOnAction : public Action { HaierClimateBase *parent_; }; -template class PowerOffAction : public Action { +template class PowerOffAction final : public Action { public: PowerOffAction(HaierClimateBase *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->send_power_off_command(); } @@ -116,7 +116,7 @@ template class PowerOffAction : public Action { HaierClimateBase *parent_; }; -template class PowerToggleAction : public Action { +template class PowerToggleAction final : public Action { public: PowerToggleAction(HaierClimateBase *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->toggle_power(); } diff --git a/esphome/components/haier/button/self_cleaning.h b/esphome/components/haier/button/self_cleaning.h index 9d330e4dfe..fc5a73b1e8 100644 --- a/esphome/components/haier/button/self_cleaning.h +++ b/esphome/components/haier/button/self_cleaning.h @@ -5,7 +5,7 @@ namespace esphome::haier { -class SelfCleaningButton : public button::Button, public Parented { +class SelfCleaningButton final : public button::Button, public Parented { public: SelfCleaningButton() = default; diff --git a/esphome/components/haier/button/steri_cleaning.h b/esphome/components/haier/button/steri_cleaning.h index cac02dd267..4799c0e2ae 100644 --- a/esphome/components/haier/button/steri_cleaning.h +++ b/esphome/components/haier/button/steri_cleaning.h @@ -5,7 +5,7 @@ namespace esphome::haier { -class SteriCleaningButton : public button::Button, public Parented { +class SteriCleaningButton final : public button::Button, public Parented { public: SteriCleaningButton() = default; diff --git a/esphome/components/haier/hon_climate.h b/esphome/components/haier/hon_climate.h index 5b477a5cea..ba36e6a8fb 100644 --- a/esphome/components/haier/hon_climate.h +++ b/esphome/components/haier/hon_climate.h @@ -35,7 +35,7 @@ struct HonSettings { bool quiet_mode_state{false}; }; -class HonClimate : public HaierClimateBase { +class HonClimate final : public HaierClimateBase { #ifdef USE_SENSOR public: enum class SubSensorType { diff --git a/esphome/components/haier/smartair2_climate.h b/esphome/components/haier/smartair2_climate.h index 68b0e4a0db..dc9a60f06f 100644 --- a/esphome/components/haier/smartair2_climate.h +++ b/esphome/components/haier/smartair2_climate.h @@ -5,7 +5,7 @@ namespace esphome::haier { -class Smartair2Climate : public HaierClimateBase { +class Smartair2Climate final : public HaierClimateBase { public: Smartair2Climate(); Smartair2Climate(const Smartair2Climate &) = delete; diff --git a/esphome/components/haier/switch/beeper.h b/esphome/components/haier/switch/beeper.h index 2d20f1cd83..f27b419a2e 100644 --- a/esphome/components/haier/switch/beeper.h +++ b/esphome/components/haier/switch/beeper.h @@ -5,7 +5,7 @@ namespace esphome::haier { -class BeeperSwitch : public switch_::Switch, public Parented { +class BeeperSwitch final : public switch_::Switch, public Parented { public: BeeperSwitch() = default; diff --git a/esphome/components/haier/switch/display.h b/esphome/components/haier/switch/display.h index 9baf3b9fb8..bf60538e11 100644 --- a/esphome/components/haier/switch/display.h +++ b/esphome/components/haier/switch/display.h @@ -5,7 +5,7 @@ namespace esphome::haier { -class DisplaySwitch : public switch_::Switch, public Parented { +class DisplaySwitch final : public switch_::Switch, public Parented { public: DisplaySwitch() = default; diff --git a/esphome/components/haier/switch/health_mode.h b/esphome/components/haier/switch/health_mode.h index ec77b1638a..f5d3dad0f2 100644 --- a/esphome/components/haier/switch/health_mode.h +++ b/esphome/components/haier/switch/health_mode.h @@ -5,7 +5,7 @@ namespace esphome::haier { -class HealthModeSwitch : public switch_::Switch, public Parented { +class HealthModeSwitch final : public switch_::Switch, public Parented { public: HealthModeSwitch() = default; diff --git a/esphome/components/haier/switch/quiet_mode.h b/esphome/components/haier/switch/quiet_mode.h index 8ef7b5bb89..f1ab85f4e3 100644 --- a/esphome/components/haier/switch/quiet_mode.h +++ b/esphome/components/haier/switch/quiet_mode.h @@ -5,7 +5,7 @@ namespace esphome::haier { -class QuietModeSwitch : public switch_::Switch, public Parented { +class QuietModeSwitch final : public switch_::Switch, public Parented { public: QuietModeSwitch() = default; diff --git a/esphome/components/havells_solar/havells_solar.h b/esphome/components/havells_solar/havells_solar.h index c54b0dcf14..ec6d5b5657 100644 --- a/esphome/components/havells_solar/havells_solar.h +++ b/esphome/components/havells_solar/havells_solar.h @@ -8,7 +8,7 @@ namespace esphome::havells_solar { -class HavellsSolar : public PollingComponent, public modbus::ModbusDevice { +class HavellsSolar final : public PollingComponent, public modbus::ModbusDevice { public: void set_voltage_sensor(uint8_t phase, sensor::Sensor *voltage_sensor) { this->phases_[phase].setup = true; diff --git a/esphome/components/hbridge/fan/hbridge_fan.h b/esphome/components/hbridge/fan/hbridge_fan.h index 62149d99cd..187b6d2a97 100644 --- a/esphome/components/hbridge/fan/hbridge_fan.h +++ b/esphome/components/hbridge/fan/hbridge_fan.h @@ -12,7 +12,7 @@ enum DecayMode { DECAY_MODE_FAST = 1, }; -class HBridgeFan : public Component, public fan::Fan { +class HBridgeFan final : public Component, public fan::Fan { public: HBridgeFan(int speed_count, DecayMode decay_mode) : speed_count_(speed_count), decay_mode_(decay_mode) {} @@ -46,7 +46,7 @@ class HBridgeFan : public Component, public fan::Fan { void set_hbridge_levels_(float a_level, float b_level, float enable); }; -template class BrakeAction : public Action { +template class BrakeAction final : public Action { public: explicit BrakeAction(HBridgeFan *parent) : parent_(parent) {} diff --git a/esphome/components/hbridge/light/hbridge_light_output.h b/esphome/components/hbridge/light/hbridge_light_output.h index 16408f24f1..c0107fdc0d 100644 --- a/esphome/components/hbridge/light/hbridge_light_output.h +++ b/esphome/components/hbridge/light/hbridge_light_output.h @@ -7,7 +7,7 @@ namespace esphome::hbridge { -class HBridgeLightOutput : public Component, public light::LightOutput { +class HBridgeLightOutput final : public Component, public light::LightOutput { public: void set_pina_pin(output::FloatOutput *pina_pin) { this->pina_pin_ = pina_pin; } void set_pinb_pin(output::FloatOutput *pinb_pin) { this->pinb_pin_ = pinb_pin; } diff --git a/esphome/components/hbridge/switch/hbridge_switch.h b/esphome/components/hbridge/switch/hbridge_switch.h index de867271fe..5c03958991 100644 --- a/esphome/components/hbridge/switch/hbridge_switch.h +++ b/esphome/components/hbridge/switch/hbridge_switch.h @@ -16,7 +16,7 @@ enum RelayState : uint8_t { RELAY_STATE_UNKNOWN = 4, }; -class HBridgeSwitch : public switch_::Switch, public Component { +class HBridgeSwitch final : public switch_::Switch, public Component { public: void set_on_pin(GPIOPin *pin) { this->on_pin_ = pin; } void set_off_pin(GPIOPin *pin) { this->off_pin_ = pin; } diff --git a/esphome/components/hc8/hc8.h b/esphome/components/hc8/hc8.h index b060f38a80..681dffe4f6 100644 --- a/esphome/components/hc8/hc8.h +++ b/esphome/components/hc8/hc8.h @@ -9,7 +9,7 @@ namespace esphome::hc8 { -class HC8Component : public PollingComponent, public uart::UARTDevice { +class HC8Component final : public PollingComponent, public uart::UARTDevice { public: void setup() override; void update() override; @@ -26,7 +26,7 @@ class HC8Component : public PollingComponent, public uart::UARTDevice { bool warmup_complete_{false}; }; -template class HC8CalibrateAction : public Action, public Parented { +template class HC8CalibrateAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, baseline) diff --git a/esphome/components/hdc1080/hdc1080.h b/esphome/components/hdc1080/hdc1080.h index 1e3bf77788..21580ff9ab 100644 --- a/esphome/components/hdc1080/hdc1080.h +++ b/esphome/components/hdc1080/hdc1080.h @@ -6,7 +6,7 @@ namespace esphome::hdc1080 { -class HDC1080Component : public PollingComponent, public i2c::I2CDevice { +class HDC1080Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } diff --git a/esphome/components/hdc2010/hdc2010.h b/esphome/components/hdc2010/hdc2010.h index ad6df3ff48..95c8c24e60 100644 --- a/esphome/components/hdc2010/hdc2010.h +++ b/esphome/components/hdc2010/hdc2010.h @@ -6,7 +6,7 @@ namespace esphome::hdc2010 { -class HDC2010Component : public PollingComponent, public i2c::I2CDevice { +class HDC2010Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature) { this->temperature_sensor_ = temperature; } diff --git a/esphome/components/hdc2080/hdc2080.h b/esphome/components/hdc2080/hdc2080.h index daa10d371d..8d86a7d41c 100644 --- a/esphome/components/hdc2080/hdc2080.h +++ b/esphome/components/hdc2080/hdc2080.h @@ -6,7 +6,7 @@ namespace esphome::hdc2080 { -class HDC2080Component : public PollingComponent, public i2c::I2CDevice { +class HDC2080Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature(sensor::Sensor *temperature) { this->temperature_sensor_ = temperature; } void set_humidity(sensor::Sensor *humidity) { this->humidity_sensor_ = humidity; } diff --git a/esphome/components/hdc302x/hdc302x.h b/esphome/components/hdc302x/hdc302x.h index 6afea0a8c0..cc5343ee89 100644 --- a/esphome/components/hdc302x/hdc302x.h +++ b/esphome/components/hdc302x/hdc302x.h @@ -20,7 +20,7 @@ enum HDC302XPowerMode : uint8_t { Datasheet: https://www.ti.com/lit/ds/symlink/hdc3020.pdf */ -class HDC302XComponent : public PollingComponent, public i2c::I2CDevice { +class HDC302XComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; @@ -48,7 +48,7 @@ class HDC302XComponent : public PollingComponent, public i2c::I2CDevice { uint32_t conversion_delay_ms_(); }; -template class HeaterOnAction : public Action, public Parented { +template class HeaterOnAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, power) TEMPLATABLE_VALUE(uint32_t, duration) @@ -60,7 +60,7 @@ template class HeaterOnAction : public Action, public Par } }; -template class HeaterOffAction : public Action, public Parented { +template class HeaterOffAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->stop_heater(); } }; diff --git a/esphome/components/he60r/he60r.h b/esphome/components/he60r/he60r.h index e7b5c97969..ef8dde4804 100644 --- a/esphome/components/he60r/he60r.h +++ b/esphome/components/he60r/he60r.h @@ -7,7 +7,7 @@ namespace esphome::he60r { -class HE60rCover : public cover::Cover, public Component, public uart::UARTDevice { +class HE60rCover final : public cover::Cover, public Component, public uart::UARTDevice { public: void setup() override; void loop() override; diff --git a/esphome/components/heatpumpir/heatpumpir.h b/esphome/components/heatpumpir/heatpumpir.h index a277424df6..8e0668d59d 100644 --- a/esphome/components/heatpumpir/heatpumpir.h +++ b/esphome/components/heatpumpir/heatpumpir.h @@ -93,7 +93,7 @@ enum VerticalDirection { const float TEMP_MIN = 0; // Celsius const float TEMP_MAX = 100; // Celsius -class HeatpumpIRClimate : public climate_ir::ClimateIR { +class HeatpumpIRClimate final : public climate_ir::ClimateIR { public: HeatpumpIRClimate() : climate_ir::ClimateIR(TEMP_MIN, TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/hitachi_ac344/hitachi_ac344.h b/esphome/components/hitachi_ac344/hitachi_ac344.h index b9d776cc59..c5773ac222 100644 --- a/esphome/components/hitachi_ac344/hitachi_ac344.h +++ b/esphome/components/hitachi_ac344/hitachi_ac344.h @@ -75,7 +75,7 @@ const uint16_t HITACHI_AC344_BITS = HITACHI_AC344_STATE_LENGTH * 8; #define GETBIT8(a, b) ((a) & ((uint8_t) 1 << (b))) #define GETBITS8(data, offset, size) (((data) & (((uint8_t) UINT8_MAX >> (8 - (size))) << (offset))) >> (offset)) -class HitachiClimate : public climate_ir::ClimateIR { +class HitachiClimate final : public climate_ir::ClimateIR { public: HitachiClimate() : climate_ir::ClimateIR(HITACHI_AC344_TEMP_MIN, HITACHI_AC344_TEMP_MAX, 1.0F, true, true, diff --git a/esphome/components/hitachi_ac424/hitachi_ac424.h b/esphome/components/hitachi_ac424/hitachi_ac424.h index ef7f128a5a..31efd98c3d 100644 --- a/esphome/components/hitachi_ac424/hitachi_ac424.h +++ b/esphome/components/hitachi_ac424/hitachi_ac424.h @@ -77,7 +77,7 @@ const uint16_t HITACHI_AC424_BITS = HITACHI_AC424_STATE_LENGTH * 8; #define HITACHI_AC424_GETBITS8(data, offset, size) \ (((data) & (((uint8_t) UINT8_MAX >> (8 - (size))) << (offset))) >> (offset)) -class HitachiClimate : public climate_ir::ClimateIR { +class HitachiClimate final : public climate_ir::ClimateIR { public: HitachiClimate() : climate_ir::ClimateIR(HITACHI_AC424_TEMP_MIN, HITACHI_AC424_TEMP_MAX, 1.0F, true, true, diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.h b/esphome/components/hlk_fm22x/hlk_fm22x.h index fd8257b435..34246f52f0 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.h +++ b/esphome/components/hlk_fm22x/hlk_fm22x.h @@ -71,7 +71,7 @@ enum HlkFm22xFaceDirection { FACE_DIRECTION_UP = 0x10, }; -class HlkFm22xComponent : public PollingComponent, public uart::UARTDevice { +class HlkFm22xComponent final : public PollingComponent, public uart::UARTDevice { public: void setup() override; void update() override; @@ -141,7 +141,7 @@ class HlkFm22xComponent : public PollingComponent, public uart::UARTDevice { CallbackManager enrollment_failed_callback_; }; -template class EnrollmentAction : public Action, public Parented { +template class EnrollmentAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(std::string, name) TEMPLATABLE_VALUE(uint8_t, direction) @@ -153,7 +153,7 @@ template class EnrollmentAction : public Action, public P } }; -template class DeleteAction : public Action, public Parented { +template class DeleteAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(int16_t, face_id) @@ -163,17 +163,17 @@ template class DeleteAction : public Action, public Paren } }; -template class DeleteAllAction : public Action, public Parented { +template class DeleteAllAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->delete_all_faces(); } }; -template class ScanAction : public Action, public Parented { +template class ScanAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->scan_face(); } }; -template class ResetAction : public Action, public Parented { +template class ResetAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->reset(); } }; diff --git a/esphome/components/hlw8012/hlw8012.h b/esphome/components/hlw8012/hlw8012.h index d1d340bf45..0691132498 100644 --- a/esphome/components/hlw8012/hlw8012.h +++ b/esphome/components/hlw8012/hlw8012.h @@ -23,7 +23,7 @@ enum HLW8012SensorModels { #define USE_PCNT false #endif -class HLW8012Component : public PollingComponent { +class HLW8012Component final : public PollingComponent { public: HLW8012Component() : cf_store_(*pulse_counter::get_storage(USE_PCNT)), cf1_store_(*pulse_counter::get_storage(USE_PCNT)) {} diff --git a/esphome/components/hlw8032/hlw8032.h b/esphome/components/hlw8032/hlw8032.h index d4c7dbd26c..56fd27a15a 100644 --- a/esphome/components/hlw8032/hlw8032.h +++ b/esphome/components/hlw8032/hlw8032.h @@ -6,7 +6,7 @@ namespace esphome::hlw8032 { -class HLW8032Component : public Component, public uart::UARTDevice { +class HLW8032Component final : public Component, public uart::UARTDevice { public: void loop() override; void dump_config() override; From 2fe67a6eda5b7a69746e7dcedd363acadb03d18a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:17:47 +1200 Subject: [PATCH 0531/1815] [graphical_display_menu] Mark configurable classes as final (#17129) --- .../graphical_display_menu.cpp | 12 ++++++------ .../graphical_display_menu/graphical_display_menu.h | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/esphome/components/graphical_display_menu/graphical_display_menu.cpp b/esphome/components/graphical_display_menu/graphical_display_menu.cpp index 81971e457c..b3c3b27e06 100644 --- a/esphome/components/graphical_display_menu/graphical_display_menu.cpp +++ b/esphome/components/graphical_display_menu/graphical_display_menu.cpp @@ -118,7 +118,7 @@ void GraphicalDisplayMenu::draw_menu_internal_(display::Display *display, const for (size_t i = 0; max_item_index >= 0 && i <= static_cast(max_item_index); i++) { const auto *item = this->displayed_item_->get_item(i); const bool selected = i == this->cursor_index_; - const display::Rect item_dimensions = this->measure_item(display, item, bounds, selected); + const display::Rect item_dimensions = this->measure_item_(display, item, bounds, selected); menu_dimensions.push_back(item_dimensions); total_height += item_dimensions.h + (i == 0 ? 0 : y_padding); @@ -181,7 +181,7 @@ void GraphicalDisplayMenu::draw_menu_internal_(display::Display *display, const dimensions.y = y_offset; dimensions.x = bounds->x; - this->draw_item(display, item, &dimensions, selected); + this->draw_item_(display, item, &dimensions, selected); y_offset += dimensions.h + y_padding; } @@ -189,8 +189,8 @@ void GraphicalDisplayMenu::draw_menu_internal_(display::Display *display, const display->end_clipping(); } -display::Rect GraphicalDisplayMenu::measure_item(display::Display *display, const display_menu_base::MenuItem *item, - const display::Rect *bounds, const bool selected) { +display::Rect GraphicalDisplayMenu::measure_item_(display::Display *display, const display_menu_base::MenuItem *item, + const display::Rect *bounds, const bool selected) { display::Rect dimensions(0, 0, 0, 0); if (selected) { @@ -218,8 +218,8 @@ display::Rect GraphicalDisplayMenu::measure_item(display::Display *display, cons return dimensions; } -inline void GraphicalDisplayMenu::draw_item(display::Display *display, const display_menu_base::MenuItem *item, - const display::Rect *bounds, const bool selected) { +inline void GraphicalDisplayMenu::draw_item_(display::Display *display, const display_menu_base::MenuItem *item, + const display::Rect *bounds, const bool selected) { const auto background_color = selected ? this->foreground_color_ : this->background_color_; const auto foreground_color = selected ? this->background_color_ : this->foreground_color_; diff --git a/esphome/components/graphical_display_menu/graphical_display_menu.h b/esphome/components/graphical_display_menu/graphical_display_menu.h index ce1db18525..ccdf3d304c 100644 --- a/esphome/components/graphical_display_menu/graphical_display_menu.h +++ b/esphome/components/graphical_display_menu/graphical_display_menu.h @@ -33,7 +33,7 @@ struct MenuItemValueArguments { bool is_menu_editing; }; -class GraphicalDisplayMenu : public display_menu_base::DisplayMenuComponent { +class GraphicalDisplayMenu final : public display_menu_base::DisplayMenuComponent { public: void setup() override; void dump_config() override; @@ -53,10 +53,10 @@ class GraphicalDisplayMenu : public display_menu_base::DisplayMenuComponent { void draw_menu() override; void draw_menu_internal_(display::Display *display, const display::Rect *bounds); void draw_item(const display_menu_base::MenuItem *item, uint8_t row, bool selected) override; - virtual display::Rect measure_item(display::Display *display, const display_menu_base::MenuItem *item, - const display::Rect *bounds, bool selected); - virtual void draw_item(display::Display *display, const display_menu_base::MenuItem *item, - const display::Rect *bounds, bool selected); + display::Rect measure_item_(display::Display *display, const display_menu_base::MenuItem *item, + const display::Rect *bounds, bool selected); + void draw_item_(display::Display *display, const display_menu_base::MenuItem *item, const display::Rect *bounds, + bool selected); void update() override; void on_before_show() override; @@ -73,7 +73,7 @@ class GraphicalDisplayMenu : public display_menu_base::DisplayMenuComponent { CallbackManager on_redraw_callbacks_{}; }; -class GraphicalDisplayMenuOnRedrawTrigger : public Trigger { +class GraphicalDisplayMenuOnRedrawTrigger final : public Trigger { public: explicit GraphicalDisplayMenuOnRedrawTrigger(GraphicalDisplayMenu *parent) : parent_(parent) { parent->add_on_redraw_callback([this]() { this->trigger(this->parent_); }); From 614eae7a3b29915d24130e81bf4055b1268cf7f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 23:18:06 -0500 Subject: [PATCH 0532/1815] [dashboard_import] Store package_import_url in flash on ESP8266 (#17127) --- esphome/components/dashboard_import/__init__.py | 2 +- esphome/components/dashboard_import/dashboard_import.cpp | 7 ++++--- esphome/components/dashboard_import/dashboard_import.h | 6 ++++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/esphome/components/dashboard_import/__init__.py b/esphome/components/dashboard_import/__init__.py index 911fc387a0..000db307b9 100644 --- a/esphome/components/dashboard_import/__init__.py +++ b/esphome/components/dashboard_import/__init__.py @@ -77,7 +77,7 @@ async def to_code(config): url = config[CONF_PACKAGE_IMPORT_URL] if config[CONF_IMPORT_FULL_CONFIG]: url += "?full_config" - cg.add(dashboard_import_ns.set_package_import_url(url)) + cg.add(dashboard_import_ns.set_package_import_url(cg.FlashStringLiteral(url))) def import_config( diff --git a/esphome/components/dashboard_import/dashboard_import.cpp b/esphome/components/dashboard_import/dashboard_import.cpp index f553adf273..adc01cc0a8 100644 --- a/esphome/components/dashboard_import/dashboard_import.cpp +++ b/esphome/components/dashboard_import/dashboard_import.cpp @@ -2,9 +2,10 @@ namespace esphome::dashboard_import { -static const char *g_package_import_url = ""; // NOLINT +static const char EMPTY_URL[] PROGMEM = ""; // NOLINT +static ProgmemStr g_package_import_url = reinterpret_cast(EMPTY_URL); // NOLINT -const char *get_package_import_url() { return g_package_import_url; } -void set_package_import_url(const char *url) { g_package_import_url = url; } +ProgmemStr get_package_import_url() { return g_package_import_url; } +void set_package_import_url(ProgmemStr url) { g_package_import_url = url; } } // namespace esphome::dashboard_import diff --git a/esphome/components/dashboard_import/dashboard_import.h b/esphome/components/dashboard_import/dashboard_import.h index 19f69b8546..166fd8b7be 100644 --- a/esphome/components/dashboard_import/dashboard_import.h +++ b/esphome/components/dashboard_import/dashboard_import.h @@ -1,8 +1,10 @@ #pragma once +#include "esphome/core/progmem.h" + namespace esphome::dashboard_import { -const char *get_package_import_url(); -void set_package_import_url(const char *url); +ProgmemStr get_package_import_url(); +void set_package_import_url(ProgmemStr url); } // namespace esphome::dashboard_import From 0df1db62057c0d35c91cf07b2c834bc364fd1099 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 00:01:22 -0500 Subject: [PATCH 0533/1815] Bump bundled esphome-device-builder to 1.0.13 (#17132) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 1d39644ab8..214d6c7841 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.12 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.13 RUN \ platformio settings set enable_telemetry No \ From 24835769098daa93e16a72247d6c2b3a4efa2d46 Mon Sep 17 00:00:00 2001 From: "Joseph C. Lehner" Date: Mon, 22 Jun 2026 16:51:10 +0200 Subject: [PATCH 0534/1815] [sx126x] Add data whitening options (#17102) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/sx126x/__init__.py | 8 ++++++++ esphome/components/sx126x/sx126x.cpp | 16 +++++++++++++++- esphome/components/sx126x/sx126x.h | 4 ++++ esphome/components/sx126x/sx126x_reg.h | 1 + tests/components/sx126x/common.yaml | 2 ++ 5 files changed, 30 insertions(+), 1 deletion(-) diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index a4ba5c34f3..29e3ad5359 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -41,6 +41,8 @@ CONF_SPREADING_FACTOR = "spreading_factor" CONF_SYNC_VALUE = "sync_value" CONF_TCXO_VOLTAGE = "tcxo_voltage" CONF_TCXO_DELAY = "tcxo_delay" +CONF_WHITENING_ENABLE = "whitening_enable" +CONF_WHITENING_INITIAL = "whitening_initial" sx126x_ns = cg.esphome_ns.namespace("sx126x") SX126x = sx126x_ns.class_("SX126x", cg.Component, spi.SPIDevice) @@ -232,6 +234,10 @@ CONFIG_SCHEMA = ( cv.positive_time_period_microseconds, cv.Range(max=TimePeriod(microseconds=262144000)), ), + cv.Optional(CONF_WHITENING_ENABLE, default=False): cv.boolean, + cv.Optional(CONF_WHITENING_INITIAL, default=0x0100): cv.All( + cv.hex_int, cv.Range(min=0, max=0x1FF) + ), }, ) .extend(cv.COMPONENT_SCHEMA) @@ -285,6 +291,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_rf_switch(config[CONF_RF_SWITCH])) cg.add(var.set_tcxo_voltage(config[CONF_TCXO_VOLTAGE])) cg.add(var.set_tcxo_delay(config[CONF_TCXO_DELAY])) + cg.add(var.set_whitening_enable(config[CONF_WHITENING_ENABLE])) + cg.add(var.set_whitening_initial(config[CONF_WHITENING_INITIAL])) NO_ARGS_ACTION_SCHEMA = automation.maybe_simple_id( diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index aed0105e1f..af42c63bf4 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -251,6 +251,16 @@ void SX126x::configure() { this->write_register_(REG_CRC_POLYNOMIAL, buf, 2); } + // set whitening params + if (this->whitening_enable_) { + // according to the datasheet, section 12 table 12-1 "The user should not + // change the value of the 7 MSB of this register" + this->read_register_(REG_WHITENING_INITIAL, buf, 1); + buf[0] = (buf[0] & 0xFE) | ((this->whitening_initial_ >> 8) & 0x01); + buf[1] = this->whitening_initial_ & 0xFF; + this->write_register_(REG_WHITENING_INITIAL, buf, 2); + } + // set packet params and sync word this->set_packet_params_(this->get_max_packet_size()); if (!this->sync_value_.empty()) { @@ -297,7 +307,7 @@ void SX126x::set_packet_params_(uint8_t payload_length) { } else { buf[7] = 0x01; } - buf[8] = 0x00; + buf[8] = (this->whitening_enable_) ? 0x01 : 0x00; this->write_opcode_(RADIO_SET_PACKETPARAMS, buf, 9); } } @@ -541,6 +551,10 @@ void SX126x::dump_config() { ESP_LOGCONFIG(TAG, " Sync Value: 0x%s", format_hex_to(hex_buf, this->sync_value_.data(), this->sync_value_.size())); } + ESP_LOGCONFIG(TAG, " Whitening Enable: %s", TRUEFALSE(this->whitening_enable_)); + if (this->whitening_enable_) { + ESP_LOGCONFIG(TAG, " Whitening Initial: 0x%03x", this->whitening_initial_); + } if (this->is_failed()) { ESP_LOGE(TAG, "Configuring SX126x failed"); } diff --git a/esphome/components/sx126x/sx126x.h b/esphome/components/sx126x/sx126x.h index 8298beb36e..6816084df0 100644 --- a/esphome/components/sx126x/sx126x.h +++ b/esphome/components/sx126x/sx126x.h @@ -71,6 +71,8 @@ class SX126x : public Component, void set_crc_size(uint8_t crc_size) { this->crc_size_ = crc_size; } void set_crc_polynomial(uint16_t crc_polynomial) { this->crc_polynomial_ = crc_polynomial; } void set_crc_initial(uint16_t crc_initial) { this->crc_initial_ = crc_initial; } + void set_whitening_enable(bool whitening_enable) { this->whitening_enable_ = whitening_enable; } + void set_whitening_initial(uint16_t whitening_initial) { this->whitening_initial_ = whitening_initial; } void set_deviation(uint32_t deviation) { this->deviation_ = deviation; } void set_dio1_pin(GPIOPin *dio1_pin) { this->dio1_pin_ = dio1_pin; } void set_frequency(uint32_t frequency) { this->frequency_ = frequency; } @@ -128,6 +130,8 @@ class SX126x : public Component, uint8_t crc_size_{0}; uint16_t crc_polynomial_{0}; uint16_t crc_initial_{0}; + bool whitening_enable_{false}; + uint16_t whitening_initial_{0}; uint32_t deviation_{0}; uint32_t frequency_{0}; uint32_t payload_length_{0}; diff --git a/esphome/components/sx126x/sx126x_reg.h b/esphome/components/sx126x/sx126x_reg.h index c70817364f..197a2aaadb 100644 --- a/esphome/components/sx126x/sx126x_reg.h +++ b/esphome/components/sx126x/sx126x_reg.h @@ -52,6 +52,7 @@ enum SX126xOpCode : uint8_t { enum SX126xRegister : uint16_t { REG_VERSION_STRING = 0x0320, + REG_WHITENING_INITIAL = 0x06B8, REG_CRC_INITIAL = 0x06BC, REG_CRC_POLYNOMIAL = 0x06BE, REG_GFSK_SYNCWORD = 0x06C0, diff --git a/tests/components/sx126x/common.yaml b/tests/components/sx126x/common.yaml index a4a24d8da7..05794ad1a8 100644 --- a/tests/components/sx126x/common.yaml +++ b/tests/components/sx126x/common.yaml @@ -21,6 +21,8 @@ sx126x: coding_rate: CR_4_6 tcxo_voltage: 1_8V tcxo_delay: 5ms + whitening_enable: false + whitening_initial: 0x1FF on_packet: then: - lambda: |- From 6c1724874b9ad35e921e18980444b581704d3043 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:04:22 -0500 Subject: [PATCH 0535/1815] Bump zeroconf from 0.149.16 to 0.150.0 (#17137) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b01b2a4c6b..462438016e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ platformio==6.1.19 esptool==5.3.0 click==8.3.3 aioesphomeapi==45.3.1 -zeroconf==0.149.16 +zeroconf==0.150.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From 3a4831bd7e4aa74d800c67274236c574b921ed05 Mon Sep 17 00:00:00 2001 From: Anunay Kulshrestha Date: Tue, 23 Jun 2026 02:34:11 +0530 Subject: [PATCH 0536/1815] [ble_nus] Atomic log-line framing (no partial ring-buffer writes) (#17105) Co-authored-by: Claude Opus 4.8 Co-authored-by: tomaszduda23 --- esphome/components/ble_nus/ble_nus.cpp | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/esphome/components/ble_nus/ble_nus.cpp b/esphome/components/ble_nus/ble_nus.cpp index 71d98332e0..b566122f8a 100644 --- a/esphome/components/ble_nus/ble_nus.cpp +++ b/esphome/components/ble_nus/ble_nus.cpp @@ -25,11 +25,14 @@ void BLENUS::write_array(const uint8_t *data, size_t len) { if (atomic_get(&this->tx_status_) == TX_DISABLED) { return; } - auto sent = ring_buf_put(&global_ble_tx_ring_buf, data, len); - if (sent < len) { - ESP_LOGE(TAG, "TX dropping %u bytes", len - sent); + // ring_buf_put() performs a partial write when the buffer is nearly full, which would commit a + // truncated fragment and corrupt the stream. Only write when the whole payload fits, so the byte + // stream never contains a partial message. + if (ring_buf_space_get(&global_ble_tx_ring_buf) < len) { + ESP_LOGE(TAG, "TX dropping %u bytes", len); return; } + ring_buf_put(&global_ble_tx_ring_buf, data, len); #ifdef USE_UART_DEBUGGER for (size_t i = 0; i < len; i++) { this->debug_callback_.call(uart::UART_DIRECTION_TX, data[i]); @@ -197,6 +200,10 @@ void BLENUS::setup() { void BLENUS::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { (void) level; (void) tag; + // make sure there is space for '\n' or entire message is dropped + if (ring_buf_space_get(&global_ble_tx_ring_buf) < message_len + 1) { + return; + } this->write_array(reinterpret_cast(message), message_len); const char c = '\n'; this->write_array(reinterpret_cast(&c), 1); From 1ace836744572002a305cc14b439e9f783ca066f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:41:21 -0400 Subject: [PATCH 0537/1815] [espidf] Don't fail framework check on broken unrelated PATH tools (#17053) --- esphome/espidf/framework.py | 16 +++++++++------- tests/unit_tests/test_espidf_framework.py | 16 ++++++++++++---- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 6f4aeef9f0..4053898a8e 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -609,14 +609,16 @@ def _check_esphome_idf_framework_install( install = True if _check_stamp(env_stamp_file, stamp_info): _LOGGER.info("Checking ESP-IDF %s framework installation ...", version) - cmd = [ - get_system_python_path(), - str(idf_tools_path), - "--non-interactive", - "check", - ] - if run_command_ok(cmd, msg=f"ESP-IDF {version} check", env=env): + # Validate via the managed tool-path resolution, not ``idf_tools.py check``: + # ``check`` probes tools on the system PATH and aborts if any fail to run (e.g. a + # broken Homebrew openocd), which forced a toolchain reinstall on every build. + try: + _get_idf_tool_paths(framework_path, env) install = False + except RuntimeError as err: + _LOGGER.debug( + "ESP-IDF %s tool resolution failed, reinstalling: %s", version, err + ) # 4. Install framework tools if not installed or needs update if install: diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index d89b93f478..525cd55146 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -298,6 +298,9 @@ def espidf_mocks(setup_core: Path): patch("esphome.espidf.framework.archive_extract_all") as extract, patch("esphome.espidf.framework.create_venv") as venv, patch("esphome.espidf.framework.run_command_ok", return_value=True) as run_ok, + patch( + "esphome.espidf.framework._get_idf_tool_paths", return_value=([], {}) + ) as tool_paths, patch("esphome.espidf.framework._clone_idf_with_submodules") as clone, patch("esphome.espidf.framework._write_idf_version_txt"), patch("esphome.espidf.framework._patch_tools_json_for_linux_arm64"), @@ -308,7 +311,12 @@ def espidf_mocks(setup_core: Path): patch("esphome.espidf.framework.get_system_python_path", return_value="python"), ): yield SimpleNamespace( - download=download, extract=extract, venv=venv, run_ok=run_ok, clone=clone + download=download, + extract=extract, + venv=venv, + run_ok=run_ok, + tool_paths=tool_paths, + clone=clone, ) @@ -403,10 +411,10 @@ def test_check_esp_idf_install_stamp_mismatch_reinstalls( def test_check_esp_idf_install_check_command_failure_reinstalls( espidf_mocks: SimpleNamespace, ) -> None: - """A failing idf_tools check reinstalls tools (marker present, no re-extract).""" + """A failing tool-path resolution reinstalls tools (marker present, no re-extract).""" _mark_installed() - # idf_tools check fails -> install stays True; the later installs succeed. - espidf_mocks.run_ok.side_effect = [False, True, True, True] + # Managed tool resolution fails -> install stays True; the later installs succeed. + espidf_mocks.tool_paths.side_effect = RuntimeError("missing ESP-IDF tool") check_esp_idf_install(_IDF_VERSION, features=["fb"]) espidf_mocks.extract.assert_not_called() From 5fcf656806e93667b9a268cf81513f1847d76c64 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:45:22 -0500 Subject: [PATCH 0538/1815] Bump bundled esphome-device-builder to 1.0.14 (#17139) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 214d6c7841..bf37d6d88b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.13 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.14 RUN \ platformio settings set enable_telemetry No \ From 69d700727d6a0456a03ea30a8a0728ebffc85b4c Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:25:24 +1200 Subject: [PATCH 0539/1815] [docker] Remove dead HA addon env exports (streamer_mode, relative_url) (#17140) --- docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run | 8 -------- 1 file changed, 8 deletions(-) diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run index d4628ffa83..dff61fd2f3 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run @@ -19,14 +19,6 @@ if bashio::config.true 'leave_front_door_open'; then export DISABLE_HA_AUTHENTICATION=true fi -if bashio::config.true 'streamer_mode'; then - export ESPHOME_STREAMER_MODE=true -fi - -if bashio::config.has_value 'relative_url'; then - export ESPHOME_DASHBOARD_RELATIVE_URL=$(bashio::config 'relative_url') -fi - if bashio::config.has_value 'default_compile_process_limit'; then export ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT=$(bashio::config 'default_compile_process_limit') else From c70d56807fd5fe7eb42fb551cf18ac173066f0e7 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:44:58 +1200 Subject: [PATCH 0540/1815] [motion] Make motion test configs mergeable in CI (#17149) --- tests/components/bmi270/common.yaml | 34 ++++++++++++++++++--------- tests/components/lsm6ds/common.yaml | 36 +++++++++++++++++++---------- 2 files changed, 47 insertions(+), 23 deletions(-) diff --git a/tests/components/bmi270/common.yaml b/tests/components/bmi270/common.yaml index 0ffb1c6281..0f9b70631c 100644 --- a/tests/components/bmi270/common.yaml +++ b/tests/components/bmi270/common.yaml @@ -3,52 +3,64 @@ sensor: name: "BMI270 Temperature" - platform: motion + motion_id: bmi270_motion type: acceleration_x - name: "Accel X" + name: "BMI270 Accel X" accuracy_decimals: 4 filters: - sliding_window_moving_average: window_size: 4 send_every: 1 - platform: motion + motion_id: bmi270_motion type: acceleration_y - name: "Accel Y" + name: "BMI270 Accel Y" accuracy_decimals: 4 - platform: motion + motion_id: bmi270_motion type: acceleration_z - name: "Accel Z" + name: "BMI270 Accel Z" accuracy_decimals: 4 # Gyroscope axes (unit: °/s) - platform: motion + motion_id: bmi270_motion type: gyroscope_x - name: "Gyro X" + name: "BMI270 Gyro X" - platform: motion + motion_id: bmi270_motion type: gyroscope_y - name: "Gyro Y" + name: "BMI270 Gyro Y" - platform: motion + motion_id: bmi270_motion type: gyroscope_z - name: "Gyro Z" + name: "BMI270 Gyro Z" - platform: motion + motion_id: bmi270_motion type: angular_rate_x - name: "Angular Rate X" + name: "BMI270 Angular Rate X" - platform: motion + motion_id: bmi270_motion type: angular_rate_y - name: "Angular Rate Y" + name: "BMI270 Angular Rate Y" - platform: motion + motion_id: bmi270_motion type: angular_rate_z - name: "Angular Rate Z" + name: "BMI270 Angular Rate Z" - platform: motion + motion_id: bmi270_motion type: pitch - name: "Pitch" + name: "BMI270 Pitch" - platform: motion + motion_id: bmi270_motion type: roll - name: "Roll" + name: "BMI270 Roll" motion: - platform: bmi270 + id: bmi270_motion # Accelerometer full-scale range: 2G | 4G | 8G | 16G accelerometer_range: 4G diff --git a/tests/components/lsm6ds/common.yaml b/tests/components/lsm6ds/common.yaml index 832254781f..aeacd31448 100644 --- a/tests/components/lsm6ds/common.yaml +++ b/tests/components/lsm6ds/common.yaml @@ -1,54 +1,66 @@ sensor: - platform: lsm6ds - name: "lsm6ds Temperature" + name: "LSM6DS Temperature" - platform: motion + motion_id: lsm6ds_motion type: acceleration_x - name: "Accel X" + name: "LSM6DS Accel X" accuracy_decimals: 4 filters: - sliding_window_moving_average: window_size: 4 send_every: 1 - platform: motion + motion_id: lsm6ds_motion type: acceleration_y - name: "Accel Y" + name: "LSM6DS Accel Y" accuracy_decimals: 4 - platform: motion + motion_id: lsm6ds_motion type: acceleration_z - name: "Accel Z" + name: "LSM6DS Accel Z" accuracy_decimals: 4 # Gyroscope axes (unit: °/s) - platform: motion + motion_id: lsm6ds_motion type: gyroscope_x - name: "Gyro X" + name: "LSM6DS Gyro X" - platform: motion + motion_id: lsm6ds_motion type: gyroscope_y - name: "Gyro Y" + name: "LSM6DS Gyro Y" - platform: motion + motion_id: lsm6ds_motion type: gyroscope_z - name: "Gyro Z" + name: "LSM6DS Gyro Z" - platform: motion + motion_id: lsm6ds_motion type: angular_rate_x - name: "Angular Rate X" + name: "LSM6DS Angular Rate X" - platform: motion + motion_id: lsm6ds_motion type: angular_rate_y - name: "Angular Rate Y" + name: "LSM6DS Angular Rate Y" - platform: motion + motion_id: lsm6ds_motion type: angular_rate_z - name: "Angular Rate Z" + name: "LSM6DS Angular Rate Z" - platform: motion + motion_id: lsm6ds_motion type: pitch - name: "Pitch" + name: "LSM6DS Pitch" - platform: motion + motion_id: lsm6ds_motion type: roll - name: "Roll" + name: "LSM6DS Roll" motion: - platform: lsm6ds + id: lsm6ds_motion # Accelerometer full-scale range: 2G | 4G | 8G | 16G accelerometer_range: 4G From 41747c2de736f8f84d8970c69e7e788a3b5f5f82 Mon Sep 17 00:00:00 2001 From: arunderwood Date: Mon, 22 Jun 2026 23:50:02 -0700 Subject: [PATCH 0541/1815] [epaper_spi] Add support for the Inkplate 2 (#16856) --- esphome/components/epaper_spi/colorconv.h | 17 ++ .../epaper_spi/epaper_spi_inkplate2.cpp | 148 ++++++++++++++++++ .../epaper_spi/epaper_spi_inkplate2.h | 33 ++++ .../components/epaper_spi/models/inkplate2.py | 52 ++++++ .../epaper_spi/test.esp32-s3-idf.yaml | 21 +++ 5 files changed, 271 insertions(+) create mode 100644 esphome/components/epaper_spi/epaper_spi_inkplate2.cpp create mode 100644 esphome/components/epaper_spi/epaper_spi_inkplate2.h create mode 100644 esphome/components/epaper_spi/models/inkplate2.py diff --git a/esphome/components/epaper_spi/colorconv.h b/esphome/components/epaper_spi/colorconv.h index a2ea28f4b6..d4ffd034a1 100644 --- a/esphome/components/epaper_spi/colorconv.h +++ b/esphome/components/epaper_spi/colorconv.h @@ -64,4 +64,21 @@ constexpr NATIVE_COLOR color_to_bwyr(Color color, NATIVE_COLOR hw_black, NATIVE_ } } +/** Map RGB color to discrete BWR (black/white/red) 3 color key + * + * Convenience wrapper over color_to_bwyr for panels without a yellow ink; the yellow corner is + * folded into white. + * + * @tparam NATIVE_COLOR Type of native hardware color values + * @param color RGB color to convert from + * @param hw_black Native value for black + * @param hw_white Native value for white + * @param hw_red Native value for red + * @return Converted native hardware color value + */ +template +constexpr NATIVE_COLOR color_to_bwr(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white, NATIVE_COLOR hw_red) { + return color_to_bwyr(color, hw_black, hw_white, /*hw_yellow=*/hw_white, hw_red); +} + } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_inkplate2.cpp b/esphome/components/epaper_spi/epaper_spi_inkplate2.cpp new file mode 100644 index 0000000000..fc0d674246 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_inkplate2.cpp @@ -0,0 +1,148 @@ +// Reference: https://github.com/SolderedElectronics/Inkplate-Arduino-library (src/boards/Inkplate2) + +#include "epaper_spi_inkplate2.h" +#include "colorconv.h" +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { + +static constexpr const char *const TAG = "epaper_spi.inkplate2"; + +// Map RGB to the panel's black/white/red via the shared converter. +enum class Inkplate2Color : uint8_t { BLACK, WHITE, RED }; + +static Inkplate2Color to_inkplate2_color(Color color) { + return color_to_bwr(color, Inkplate2Color::BLACK, Inkplate2Color::WHITE, Inkplate2Color::RED); +} + +void EPaperInkplate2::power_on() { + // Power-on (0x04) leads the init sequence, so there is nothing to do here. + ESP_LOGV(TAG, "Power on"); +} + +void EPaperInkplate2::power_off() { + ESP_LOGV(TAG, "Power off"); + this->cmd_data(0x50, {0xF7}); // VCOM and data interval + this->command(0x02); // power off +} + +void EPaperInkplate2::refresh_screen(bool partial) { + ESP_LOGV(TAG, "Refresh screen"); // full refresh only; partial is unused + // Send 0x11 then 0x12 back-to-back: 0x11 raises busy until the refresh finishes, so waiting for idle + // between them (as the state machine does between states) would add a ~16s stall. + this->cmd_data(0x11, {0x00}); // stop data transfer + this->command(0x12); // display refresh +} + +void EPaperInkplate2::deep_sleep() { + ESP_LOGV(TAG, "Deep sleep"); + this->cmd_data(0x07, {0xA5}); +} + +void EPaperInkplate2::fill(Color color) { + if (this->get_clipping().is_set()) { + EPaperBase::fill(color); // clipping active: defer to the base per-pixel path + return; + } + + const size_t half_buffer = this->buffer_length_ / 2; + + // Plane encoding: B/W plane 1=white, 0=black; red plane 0=red, 1=no-red. + uint8_t bw_byte; + uint8_t red_byte; + switch (to_inkplate2_color(color)) { + case Inkplate2Color::BLACK: + bw_byte = 0x00; + red_byte = 0xFF; + break; + case Inkplate2Color::RED: + bw_byte = 0xFF; + red_byte = 0x00; + break; + case Inkplate2Color::WHITE: + default: + bw_byte = 0xFF; + red_byte = 0xFF; + break; + } + + for (size_t i = 0; i < half_buffer; i++) + this->buffer_[i] = bw_byte; + for (size_t i = half_buffer; i < this->buffer_length_; i++) + this->buffer_[i] = red_byte; + + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; +} + +void EPaperInkplate2::clear() { this->fill(COLOR_ON); } + +void HOT EPaperInkplate2::draw_pixel_at(int x, int y, Color color) { + if (!this->rotate_coordinates_(x, y)) + return; + + const size_t half_buffer = this->buffer_length_ / 2; + const size_t pos = y * this->row_width_ + x / 8; + const uint8_t mask = 0x80 >> (x & 0x07); // MSB first; see fill() for plane encoding + + switch (to_inkplate2_color(color)) { + case Inkplate2Color::BLACK: + this->buffer_[pos] &= ~mask; + this->buffer_[pos + half_buffer] |= mask; + break; + case Inkplate2Color::RED: + this->buffer_[pos] |= mask; + this->buffer_[pos + half_buffer] &= ~mask; + break; + case Inkplate2Color::WHITE: + default: + this->buffer_[pos] |= mask; + this->buffer_[pos + half_buffer] |= mask; + break; + } +} + +bool HOT EPaperInkplate2::send_buffer_range_(size_t end, uint32_t start_time) { + uint8_t bytes_to_send[MAX_TRANSFER_SIZE]; + size_t buf_idx = 0; + while (this->current_data_index_ < end) { + bytes_to_send[buf_idx++] = this->buffer_[this->current_data_index_++]; + if (buf_idx == sizeof bytes_to_send) { + this->start_data_(); + this->write_array(bytes_to_send, buf_idx); + this->disable(); + buf_idx = 0; + if (millis() - start_time > MAX_TRANSFER_TIME) + return false; // yield; resume next loop + } + } + if (buf_idx != 0) { + this->start_data_(); + this->write_array(bytes_to_send, buf_idx); + this->disable(); + } + return true; +} + +bool HOT EPaperInkplate2::transfer_data() { + const uint32_t start_time = millis(); + const size_t half_buffer = this->buffer_length_ / 2; + + // Black/white plane (first half) then red plane (second half). + if (this->current_data_index_ == 0) + this->command(0x10); + if (this->current_data_index_ < half_buffer && !this->send_buffer_range_(half_buffer, start_time)) + return false; + + if (this->current_data_index_ == half_buffer) + this->command(0x13); + if (!this->send_buffer_range_(this->buffer_length_, start_time)) + return false; + + this->current_data_index_ = 0; + return true; +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_inkplate2.h b/esphome/components/epaper_spi/epaper_spi_inkplate2.h new file mode 100644 index 0000000000..657eca4759 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_inkplate2.h @@ -0,0 +1,33 @@ +#pragma once + +#include "epaper_spi.h" + +namespace esphome::epaper_spi { + +// Soldered Inkplate 2: 104x212 black/white/red (BWR) e-paper, UC8xxx-family controller. +class EPaperInkplate2 final : public EPaperBase { + public: + EPaperInkplate2(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length) + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR) { + // Dual-plane buffer: black/white plane followed by red plane, 1 bit per pixel each. + this->buffer_length_ = this->row_width_ * this->height_ * 2; + } + + void fill(Color color) override; + void clear() override; + void draw_pixel_at(int x, int y, Color color) override; + + protected: + void refresh_screen(bool partial) override; + void power_on() override; + void power_off() override; + void deep_sleep() override; + + bool transfer_data() override; + + // Streams buffer_[current_data_index_ .. end) in chunks; returns false if it yields on MAX_TRANSFER_TIME. + bool send_buffer_range_(size_t end, uint32_t start_time); +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/inkplate2.py b/esphome/components/epaper_spi/models/inkplate2.py new file mode 100644 index 0000000000..f1a952ce10 --- /dev/null +++ b/esphome/components/epaper_spi/models/inkplate2.py @@ -0,0 +1,52 @@ +# Reference: https://github.com/SolderedElectronics/Inkplate-Arduino-library + +from . import EpaperModel + + +class Inkplate2Model(EpaperModel): + def __init__(self, name, class_name="EPaperInkplate2", **kwargs): + super().__init__(name, class_name, **kwargs) + + def get_init_sequence(self, config: dict): + width, height = self.get_dimensions(config) + return ( + (0x04,), # power on + ( + 0x00, # panel setting + 0x0F, # LUT from OTP + 0x89, # temperature/boost/timing + ), + ( + 0x61, # resolution + width, # width: 1 byte + height >> 8, # height: 2 bytes, high byte first ... + height & 0xFF, # ... then low byte + ), + ( + 0x50, # VCOM and data interval + 0x77, + ), + ) + + +# Native orientation is portrait (104x212); use `rotation: 90` for the board's landscape orientation. +inkplate2 = Inkplate2Model( + "inkplate2", + width=104, + height=212, + data_rate="10MHz", + # A full 3-color refresh takes ~20s, so don't allow updates faster than that. + minimum_update_interval="30s", + # Default GPIO pins for the on-board Inkplate 2 wiring. + reset_pin=19, + dc_pin=33, + cs_pin=15, + busy_pin={ + "number": 32, + "inverted": True, # hardware: LOW=busy, HIGH=idle + "mode": { + "input": True, + "pullup": True, + }, + }, +) diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 8a420f299a..6d5e276582 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -161,3 +161,24 @@ display: busy_pin: allow_other_uses: true number: GPIO4 + + # Soldered Inkplate 2 3-color e-paper (104x212, BWR) + - platform: epaper_spi + spi_id: spi_bus + model: inkplate2 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK); + it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0)); From 9614bc20a0c4b08216d33223b9c50f9f3b457966 Mon Sep 17 00:00:00 2001 From: Zach Isbach Date: Tue, 23 Jun 2026 04:00:19 -0700 Subject: [PATCH 0542/1815] [epaper_spi] Add support for Waveshare 2.13" V4 series B (R/B/W) (#16828) --- .../epaper_spi/epaper_waveshare_b.cpp | 13 ++++++++++ .../epaper_spi/epaper_waveshare_b.h | 19 ++++++++++++++ .../components/epaper_spi/epaper_weact_3c.cpp | 2 +- .../components/epaper_spi/epaper_weact_3c.h | 3 +++ .../epaper_spi/models/waveshare_b.py | 26 +++++++++++++++++++ .../epaper_spi/test.esp32-s3-idf.yaml | 21 +++++++++++++++ 6 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 esphome/components/epaper_spi/epaper_waveshare_b.cpp create mode 100644 esphome/components/epaper_spi/epaper_waveshare_b.h create mode 100644 esphome/components/epaper_spi/models/waveshare_b.py diff --git a/esphome/components/epaper_spi/epaper_waveshare_b.cpp b/esphome/components/epaper_spi/epaper_waveshare_b.cpp new file mode 100644 index 0000000000..6875811b9b --- /dev/null +++ b/esphome/components/epaper_spi/epaper_waveshare_b.cpp @@ -0,0 +1,13 @@ +#include "epaper_waveshare_b.h" + +namespace esphome::epaper_spi { + +bool EpaperWaveshareB::reset() { + if (EPaperBase::reset()) { + this->command(0x12); + return true; + } + return false; +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_waveshare_b.h b/esphome/components/epaper_spi/epaper_waveshare_b.h new file mode 100644 index 0000000000..3a391731d8 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_waveshare_b.h @@ -0,0 +1,19 @@ +#pragma once +#include "epaper_weact_3c.h" + +namespace esphome::epaper_spi { + +/** + * Waveshare (B) series BWR e-paper displays using SSD1680-compatible controllers. + * Waveshare uses 0=red, 1=no-red, the inverse of EPaperWeAct3C + */ +class EpaperWaveshareB : public EPaperWeAct3C { + public: + using EPaperWeAct3C::EPaperWeAct3C; + + protected: + bool reset() override; + uint8_t transform_red_byte(uint8_t byte) const override { return static_cast(~byte); } +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_weact_3c.cpp b/esphome/components/epaper_spi/epaper_weact_3c.cpp index d4dac7076c..ad2021ed64 100644 --- a/esphome/components/epaper_spi/epaper_weact_3c.cpp +++ b/esphome/components/epaper_spi/epaper_weact_3c.cpp @@ -144,7 +144,7 @@ bool HOT EPaperWeAct3C::transfer_data() { size_t bytes_to_copy = std::min(MAX_TRANSFER_SIZE, half_buffer - this->current_data_index_); for (size_t i = 0; i < bytes_to_copy; i++) { - bytes_to_send[i] = this->buffer_[red_offset + this->current_data_index_ + i]; + bytes_to_send[i] = this->transform_red_byte(this->buffer_[red_offset + this->current_data_index_ + i]); } this->write_array(bytes_to_send, bytes_to_copy); diff --git a/esphome/components/epaper_spi/epaper_weact_3c.h b/esphome/components/epaper_spi/epaper_weact_3c.h index 2df6f1ba09..a31c2be817 100644 --- a/esphome/components/epaper_spi/epaper_weact_3c.h +++ b/esphome/components/epaper_spi/epaper_weact_3c.h @@ -34,6 +34,9 @@ class EPaperWeAct3C : public EPaperBase { void draw_pixel_at(int x, int y, Color color) override; bool transfer_data() override; + + // Hook for subclasses to transform red plane bytes before they go on the wire. + virtual uint8_t transform_red_byte(uint8_t byte) const { return byte; } }; } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/waveshare_b.py b/esphome/components/epaper_spi/models/waveshare_b.py new file mode 100644 index 0000000000..688e716456 --- /dev/null +++ b/esphome/components/epaper_spi/models/waveshare_b.py @@ -0,0 +1,26 @@ +from . import EpaperModel + + +class WaveshareB(EpaperModel): + def __init__(self, name, **defaults): + super().__init__(name, "EpaperWaveshareB", **defaults) + + def get_init_sequence(self, config): + _, height = self.get_dimensions(config) + h = height - 1 + return ( + (0x01, h & 0xFF, h >> 8, 0x00), # Driver output control + (0x11, 0x03), # Data entry mode + (0x3C, 0x05), # Border waveform + (0x18, 0x80), # Internal temperature sensor + (0x21, 0x80, 0x80), # Display update control + ) + + +WaveshareB( + "waveshare-2.13in-bv4", + width=122, + height=250, + data_rate="10MHz", + minimum_update_interval="1s", +) diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 6d5e276582..60e4008f4f 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -162,6 +162,27 @@ display: allow_other_uses: true number: GPIO4 + # Waveshare 2.13" V4 B series 3-color e-paper (122x250, BWR, SSD1680) + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-2.13in-bv4 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK); + it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0)); + # Soldered Inkplate 2 3-color e-paper (104x212, BWR) - platform: epaper_spi spi_id: spi_bus From 225d426d95426bd756b8c0c9c69251c298bcc50e Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:40:16 +1200 Subject: [PATCH 0543/1815] [core] Use CORE.is_* platform helpers in __main__ (#17144) --- esphome/__main__.py | 20 +++------- tests/unit_tests/test_main.py | 70 +++++++++++++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 17 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 35ab767cf7..48fee1e97e 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -52,11 +52,6 @@ from esphome.const import ( CONF_WEB_SERVER, CONF_WIFI, ENV_NOGITIGNORE, - KEY_CORE, - KEY_TARGET_PLATFORM, - PLATFORM_ESP32, - PLATFORM_ESP8266, - PLATFORM_RP2040, SECRETS_FILES, Toolchain, ) @@ -359,7 +354,7 @@ def choose_upload_log_host( bootsel_permission_error = False if ( purpose == Purpose.UPLOADING - and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040 + and CORE.is_rp2040 and (picotool := _find_picotool()) is not None ): bootsel = detect_rp2040_bootsel(picotool) @@ -406,7 +401,7 @@ def choose_upload_log_host( # Show helpful BOOTSEL instructions for RP2040 when no BOOTSEL device is found if ( purpose == Purpose.UPLOADING - and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040 + and CORE.is_rp2040 and not any(get_port_type(opt[1]) == PortType.BOOTSEL for opt in options) ): if bootsel_permission_error: @@ -984,7 +979,7 @@ def upload_using_platformio(config: ConfigType, port: str) -> int: # RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for # the upload target, but 'nobuild' skips the build phase that creates it. # Create it here so the upload doesn't fail. - if CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040: + if CORE.is_rp2040: idedata = toolchain.get_idedata(config) build_dir = Path(idedata.firmware_elf_path).parent firmware_bin = build_dir / "firmware.bin" @@ -1169,10 +1164,10 @@ def upload_program( check_permissions(host) exit_code = 1 - if CORE.target_platform in (PLATFORM_ESP32, PLATFORM_ESP8266): + if CORE.is_esp32 or CORE.is_esp8266: file = getattr(args, "file", None) exit_code = upload_using_esptool(config, host, file, args.upload_speed) - elif CORE.target_platform == PLATFORM_RP2040 or CORE.is_libretiny: + elif CORE.is_rp2040 or CORE.is_libretiny: exit_code = upload_using_platformio(config, host) # else: Unknown target platform, exit_code remains 1 @@ -1629,10 +1624,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: # After BOOTSEL upload, wait for a new serial port to appear # so it shows up in the log chooser - if ( - successful_device is None - and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040 - ): + if successful_device is None and CORE.is_rp2040: _wait_for_serial_port(known_ports=pre_upload_ports) # If exactly one new serial port appeared, use it directly serial_ports = get_serial_ports() diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 33888956b3..b2011259c1 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -159,9 +159,12 @@ def setup_core( CORE.config = config CORE.toolchain = Toolchain.PLATFORMIO - if platform is not None: - CORE.data[KEY_CORE] = {} - CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = platform + # Production always populates CORE.data[KEY_CORE] before upload/logs run + # (the platform validator sets it during read_config, and + # StorageJSON.apply_to_core sets it on the cache fast path), so mirror + # that here. Tests that exercise platform-specific behavior pass a + # platform explicitly; the rest get a platform-agnostic None. + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} if tmp_path is not None: CORE.config_path = str(tmp_path / f"{name}.yaml") @@ -1660,6 +1663,29 @@ def test_upload_program_serial_platformio_platforms( mock_upload_using_platformio.assert_called_once_with(config, device) +@patch("esphome.__main__.importlib.import_module") +def test_upload_program_serial_unknown_platform( + mock_import: Mock, + mock_get_port_type: Mock, + mock_check_permissions: Mock, +) -> None: + """Serial upload on an unsupported platform falls through to exit_code 1.""" + setup_core(platform="custom_platform") + # Module has no upload_program handler, so the SERIAL branch is reached. + mock_import.return_value = MagicMock(spec=[]) + mock_get_port_type.return_value = "SERIAL" + + config = {} + args = MockArgs() + devices = ["/dev/ttyUSB0"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 1 + assert host is None + mock_check_permissions.assert_called_once_with("/dev/ttyUSB0") + + def test_upload_using_platformio_creates_signed_bin_for_rp2040( tmp_path: Path, ) -> None: @@ -6350,6 +6376,44 @@ def test_command_run_defaults_subscribe_states_true( ) +def test_command_run_rp2040_bootsel_redetects_serial_port() -> None: + """After a BOOTSEL upload (no device) on RP2040, command_run waits for and + picks up the newly enumerated serial port before showing logs.""" + setup_core( + config={"logger": {}, CONF_API: {}, CONF_MDNS: {CONF_DISABLED: False}}, + platform=PLATFORM_RP2040, + ) + + args = MockArgs() + args.no_logs = False + args.device = None + + new_port = MockSerialPort("/dev/ttyACM0", "RP2040 Serial") + + with ( + patch("esphome.__main__.write_cpp", return_value=0), + patch("esphome.__main__.compile_program", return_value=0), + patch( + "esphome.__main__.choose_upload_log_host", + side_effect=[[], ["/dev/ttyACM0"]], + ) as mock_choose, + patch("esphome.__main__.upload_program", return_value=(0, None)), + patch( + "esphome.__main__.get_serial_ports", + side_effect=[[], [new_port]], + ), + patch("esphome.__main__._wait_for_serial_port") as mock_wait, + patch("esphome.__main__.show_logs", return_value=0) as mock_show_logs, + ): + result = command_run(args, CORE.config) + + assert result == 0 + mock_wait.assert_called_once_with(known_ports=set()) + # The re-detected serial port is used as the preferred logging device. + assert mock_choose.call_args_list[-1].kwargs["default"] == "/dev/ttyACM0" + mock_show_logs.assert_called_once_with(CORE.config, args, ["/dev/ttyACM0"]) + + def test_command_idedata_esp_idf_prints_json(capsys: CaptureFixture) -> None: """Under the native ESP-IDF toolchain, idedata is emitted as JSON.""" setup_core() From eae65a6b881388bf9f478a700e98dd2f9671d117 Mon Sep 17 00:00:00 2001 From: Berik Visschers Date: Tue, 23 Jun 2026 17:44:48 +0200 Subject: [PATCH 0544/1815] [bme680_bsec][bme68x_bsec2][const] Move BME sensor constants to shared component consts (#17160) --- esphome/components/bme680_bsec/__init__.py | 2 +- esphome/components/bme680_bsec/sensor.py | 8 +++++--- esphome/components/bme68x_bsec2/__init__.py | 2 +- esphome/components/bme68x_bsec2/sensor.py | 8 +++++--- esphome/components/const/__init__.py | 4 ++++ 5 files changed, 16 insertions(+), 8 deletions(-) diff --git a/esphome/components/bme680_bsec/__init__.py b/esphome/components/bme680_bsec/__init__.py index 2365f8d107..e1e01facd0 100644 --- a/esphome/components/bme680_bsec/__init__.py +++ b/esphome/components/bme680_bsec/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import esp32, i2c +from esphome.components.const import CONF_STATE_SAVE_INTERVAL import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_SAMPLE_RATE, CONF_TEMPERATURE_OFFSET, Framework @@ -12,7 +13,6 @@ MULTI_CONF = True CONF_BME680_BSEC_ID = "bme680_bsec_id" CONF_IAQ_MODE = "iaq_mode" CONF_SUPPLY_VOLTAGE = "supply_voltage" -CONF_STATE_SAVE_INTERVAL = "state_save_interval" bme680_bsec_ns = cg.esphome_ns.namespace("bme680_bsec") diff --git a/esphome/components/bme680_bsec/sensor.py b/esphome/components/bme680_bsec/sensor.py index 8d3ae76e3f..bdc8d8f2d3 100644 --- a/esphome/components/bme680_bsec/sensor.py +++ b/esphome/components/bme680_bsec/sensor.py @@ -1,5 +1,10 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.components.const import ( + CONF_BREATH_VOC_EQUIVALENT, + CONF_CO2_EQUIVALENT, + CONF_IAQ, +) import esphome.config_validation as cv from esphome.const import ( CONF_GAS_RESISTANCE, @@ -29,9 +34,6 @@ from . import CONF_BME680_BSEC_ID, SAMPLE_RATE_OPTIONS, BME680BSECComponent DEPENDENCIES = ["bme680_bsec"] -CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" -CONF_CO2_EQUIVALENT = "co2_equivalent" -CONF_IAQ = "iaq" ICON_ACCURACY = "mdi:checkbox-marked-circle-outline" UNIT_IAQ = "IAQ" diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index 62cd9e2e36..63f63c5da2 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -3,6 +3,7 @@ from pathlib import Path from esphome import core, external_files import esphome.codegen as cg +from esphome.components.const import CONF_STATE_SAVE_INTERVAL import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -24,7 +25,6 @@ CONF_ALGORITHM_OUTPUT = "algorithm_output" CONF_BME68X_BSEC2_ID = "bme68x_bsec2_id" CONF_IAQ_MODE = "iaq_mode" CONF_OPERATING_AGE = "operating_age" -CONF_STATE_SAVE_INTERVAL = "state_save_interval" CONF_SUPPLY_VOLTAGE = "supply_voltage" bme68x_bsec2_ns = cg.esphome_ns.namespace("bme68x_bsec2") diff --git a/esphome/components/bme68x_bsec2/sensor.py b/esphome/components/bme68x_bsec2/sensor.py index f21a9b8138..52587dba99 100644 --- a/esphome/components/bme68x_bsec2/sensor.py +++ b/esphome/components/bme68x_bsec2/sensor.py @@ -1,5 +1,10 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.components.const import ( + CONF_BREATH_VOC_EQUIVALENT, + CONF_CO2_EQUIVALENT, + CONF_IAQ, +) import esphome.config_validation as cv from esphome.const import ( CONF_GAS_RESISTANCE, @@ -29,9 +34,6 @@ from . import CONF_BME68X_BSEC2_ID, SAMPLE_RATE_OPTIONS, BME68xBSEC2Component DEPENDENCIES = ["bme68x_bsec2"] -CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" -CONF_CO2_EQUIVALENT = "co2_equivalent" -CONF_IAQ = "iaq" CONF_IAQ_STATIC = "iaq_static" ICON_ACCURACY = "mdi:checkbox-marked-circle-outline" UNIT_IAQ = "IAQ" diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 9951243f0d..85878a6306 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -8,8 +8,10 @@ BYTE_ORDER_BIG = "big_endian" CONF_ACCELEROMETER_ODR = "accelerometer_odr" CONF_ACCELEROMETER_RANGE = "accelerometer_range" CONF_B_CONSTANT = "b_constant" +CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" CONF_BYTE_ORDER = "byte_order" CONF_CLIMATE_ID = "climate_id" +CONF_CO2_EQUIVALENT = "co2_equivalent" CONF_COLOR_DEPTH = "color_depth" CONF_CRC_ENABLE = "crc_enable" CONF_DATA_BITS = "data_bits" @@ -17,6 +19,7 @@ CONF_DRAW_ROUNDING = "draw_rounding" CONF_ENABLED = "enabled" CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" +CONF_IAQ = "iaq" CONF_IGNORE_NOT_FOUND = "ignore_not_found" CONF_LIBRETINY = "libretiny" CONF_LOOP = "loop" @@ -28,6 +31,7 @@ CONF_RECEIVER_FREQUENCY = "receiver_frequency" CONF_REQUEST_HEADERS = "request_headers" CONF_ROWS = "rows" CONF_SHA256 = "sha256" +CONF_STATE_SAVE_INTERVAL = "state_save_interval" CONF_STOP_BITS = "stop_bits" CONF_USE_PSRAM = "use_psram" CONF_VOLUME_INCREMENT = "volume_increment" From e0377bbbd31cd9d043d44e01a721a50fa3fb409f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:04:23 -0400 Subject: [PATCH 0545/1815] [ci] Enable ccache for component batch builds (~7% faster) (#17136) --- .github/workflows/ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10ace8c179..c4149e2049 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -822,8 +822,8 @@ jobs: - name: Cache apt packages uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3 with: - packages: libsdl2-dev - version: 1.0 + packages: libsdl2-dev ccache + version: 1.1 - name: Check out code from GitHub uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -941,6 +941,11 @@ jobs: echo "All components in this batch are validate-only -- skipping compile stage." fi + - name: Print ccache statistics + # esphome stores the cache under the IDF tools path; expand the leading + # ~ in ESPHOME_ESP_IDF_PREFIX so ccache reads the dir the build used. + run: CCACHE_DIR="${ESPHOME_ESP_IDF_PREFIX/#\~/$HOME}/ccache" ccache -s + test-esp32-platformio: name: Test esp32 components with PlatformIO runs-on: ubuntu-24.04 From c2d79c972c9d5e64c540e3714e8ae1557ca27d18 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:04:42 -0400 Subject: [PATCH 0546/1815] [docker] Install ccache in the image (#17157) --- docker/Dockerfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index bf37d6d88b..1fe380552a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -17,8 +17,11 @@ RUN git config --system --add safe.directory "*" \ # validate openocd-esp32 (it dynamically links libusb-1.0.so.0); without # it idf_tools.py rejects the openocd install with exit 127 and aborts # the whole framework setup. +# ccache speeds up repeat ESP-IDF compiles (enabled via IDF_CCACHE_ENABLE); +# ESP-IDF silently skips it when the binary isn't on PATH, so it must be +# present in the image for the dashboard/Device Builder to benefit. RUN apt-get update \ - && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \ + && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 ccache \ && rm -rf /var/lib/apt/lists/* ENV PIP_DISABLE_PIP_VERSION_CHECK=1 From ff001b9e4570e229b24c47bd28c37c2e83688f5c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:21:26 -0400 Subject: [PATCH 0547/1815] [esp32_ble_server] Fix set_value action with by-reference triggers (#17156) --- .../esp32_ble_server/ble_server_automations.h | 6 ++-- tests/components/esp32_ble_server/common.yaml | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_ble_server/ble_server_automations.h b/esphome/components/esp32_ble_server/ble_server_automations.h index c6cba14b9b..e5463847fa 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.h +++ b/esphome/components/esp32_ble_server/ble_server_automations.h @@ -77,13 +77,15 @@ template class BLECharacteristicSetValueAction final : public Ac // Set initial value this->parent_->set_value(this->buffer_.value(x...)); // Set the listener for read events - this->parent_->on_read([this, x...](uint16_t id) { + // ``mutable`` keeps by-copy captures non-const for triggers passing args by reference + // (e.g. climate on_control's ClimateCall&). See #17142. + this->parent_->on_read([this, x...](uint16_t id) mutable { // Set the value of the characteristic every time it is read this->parent_->set_value(this->buffer_.value(x...)); }); // Set the listener in the global manager so only one BLECharacteristicSetValueAction is set for each characteristic BLECharacteristicSetValueActionManager::get_instance()->set_listener( - this->parent_, [this, x...]() { this->parent_->set_value(this->buffer_.value(x...)); }); + this->parent_, [this, x...]() mutable { this->parent_->set_value(this->buffer_.value(x...)); }); } protected: diff --git a/tests/components/esp32_ble_server/common.yaml b/tests/components/esp32_ble_server/common.yaml index 4e34049038..c617a73f87 100644 --- a/tests/components/esp32_ble_server/common.yaml +++ b/tests/components/esp32_ble_server/common.yaml @@ -77,3 +77,34 @@ esp32_ble_server: id: test_change_descriptor value: data: [0x01, 0x02, 0x03] + +# Regression test for #17142: the set_value action used from a trigger that passes +# its argument by reference (climate on_control supplies ClimateCall&) previously +# failed to compile. +sensor: + - platform: template + id: ble_test_temp + lambda: "return 20.0;" + +output: + - platform: template + id: ble_test_output + type: float + write_action: + - logger.log: "out" + +climate: + - platform: pid + name: "BLE Test Climate" + id: ble_test_climate + sensor: ble_test_temp + default_target_temperature: 20 + heat_output: ble_test_output + control_parameters: + kp: 0.1 + ki: 0.001 + kd: 0.1 + on_control: + - ble_server.characteristic.set_value: + id: test_notify_characteristic + value: !lambda "return std::vector{0, 1, 2};" From 7763ce958d3cd92eb5b0b7348976d42323efcbdb Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:21:40 -0400 Subject: [PATCH 0548/1815] [tests] Disable Hypothesis deadline on IP validation property tests (#17138) --- tests/unit_tests/test_config_validation.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 74d9a5047a..f1a6118870 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1,6 +1,6 @@ import string -from hypothesis import example, given +from hypothesis import example, given, settings from hypothesis.strategies import builds, integers, ip_addresses, one_of, text import pytest import voluptuous as vol @@ -276,6 +276,10 @@ def test_boolean__invalid(value): config_validation.boolean(value) +# deadline disabled: the validator is trivially fast, but Hypothesis's per-example +# deadline can spuriously trip on slow/loaded CI runners (e.g. one example hitting +# a GC pause), making this a flaky failure. Matches test_helpers.py. +@settings(deadline=None) @given(value=ip_addresses(v=4).map(str)) def test_ipv4__valid(value): config_validation.ipv4address(value) @@ -287,6 +291,7 @@ def test_ipv4__invalid(value): config_validation.ipv4address(value) +@settings(deadline=None) @given(value=ip_addresses(v=6).map(str)) def test_ipv6__valid(value): config_validation.ipaddress(value) From e3b644c2a0d85fd58e6ce3a5d48119bc8548dd60 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:03:16 -0500 Subject: [PATCH 0549/1815] Bump actions/cache from 5.0.5 to 6.0.0 in /.github/actions/restore-python (#17169) Signed-off-by: dependabot[bot] --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 66d016b42d..96a3be53c6 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -22,7 +22,7 @@ runs: python-version: ${{ inputs.python-version }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: venv # yamllint disable-line rule:line-length From a24a63e61b588a00aceb325f9e12c73ae8534edb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:03:28 -0500 Subject: [PATCH 0550/1815] Bump actions/cache from 5.0.5 to 6.0.0 (#17168) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4149e2049..0a7233b3a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: venv # yamllint disable-line rule:line-length @@ -250,7 +250,7 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/save@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: venv key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -295,7 +295,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Restore components graph cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -339,7 +339,7 @@ jobs: echo "benchmarks=$(echo "$output" | jq -r '.benchmarks')" >> $GITHUB_OUTPUT - name: Save components graph cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/save@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -365,7 +365,7 @@ jobs: python-version: "3.13" - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -509,14 +509,14 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' && matrix.pio_cache_key - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' && matrix.pio_cache_key - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} @@ -1098,7 +1098,7 @@ jobs: - name: Restore cached memory analysis id: cache-memory-analysis if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} @@ -1122,7 +1122,7 @@ jobs: - name: Cache platformio if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} @@ -1164,7 +1164,7 @@ jobs: - name: Save memory analysis to cache if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && steps.build.outcome == 'success' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/save@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} @@ -1211,7 +1211,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache platformio - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} From 49536693b71e9f9a5cd1cd81ccf0478414e2befd Mon Sep 17 00:00:00 2001 From: mnewton25 <83018731+mnewton25@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:40:49 +0100 Subject: [PATCH 0551/1815] [esp32] Use POSIX path for secure-boot signing/verification keys Fixes #17164 (#17166) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/esp32/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 8ba1ac4608..945eda3912 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2368,14 +2368,14 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES", True) add_idf_sdkconfig_option( "CONFIG_SECURE_BOOT_SIGNING_KEY", - str(signed_ota[CONF_SIGNING_KEY].resolve()), + signed_ota[CONF_SIGNING_KEY].resolve().as_posix(), ) else: # Public key mode — verification only, external signing required add_idf_sdkconfig_option("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES", False) add_idf_sdkconfig_option( "CONFIG_SECURE_BOOT_VERIFICATION_KEY", - str(signed_ota[CONF_VERIFICATION_KEY].resolve()), + signed_ota[CONF_VERIFICATION_KEY].resolve().as_posix(), ) cg.add_define("USE_OTA_SIGNED_VERIFICATION") From 1d32b6c9e0789f9662b021e8a9d351e2edbf181e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:41:13 -0400 Subject: [PATCH 0552/1815] [espidf] Enable ccache by default for ESP-IDF builds (#17163) --- esphome/espidf/framework.py | 55 +++++++++++++- tests/unit_tests/test_espidf_framework.py | 87 +++++++++++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 4053898a8e..f0715ce3b2 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -23,7 +23,7 @@ from esphome.framework_helpers import ( run_command_ok, str_to_lst_of_str, ) -from esphome.helpers import get_str_env, write_file_if_changed +from esphome.helpers import get_bool_env, get_str_env, write_file_if_changed _LOGGER = logging.getLogger(__name__) @@ -814,6 +814,56 @@ def check_esp_idf_install( return framework_path, python_env_path +def _ccache_env() -> dict[str, str]: + """Return ccache settings for ESP-IDF compiles. + + Enabled by default whenever the ``ccache`` binary is on PATH; set + ``IDF_CCACHE_ENABLE=0`` in the environment to opt out. The cache lives under + the IDF tools path. How widely it is shared depends on where that resolves: + across projects (and surviving ``clean-all``) when it is a common location + (``ESPHOME_ESP_IDF_PREFIX`` or the add-on ``/data``), but per-project under + ``.esphome/idf`` for a default pip install, where ``clean-all`` clears it + along with the framework. + + Depend mode keeps cache-miss overhead low (hashes the compiler's depfiles + instead of preprocessing). ``CCACHE_BASEDIR`` rewrites the per-build + absolute paths (generated ``sdkconfig`` include, etc.) so different devices + share framework cache entries; it is scoped to the build dir on purpose -- + a broader base would also rewrite the shared IDF path under the cache dir + and lose those hits. + + Only values the user has not already set in the environment are returned, so + a custom ``CCACHE_DIR`` / ``CCACHE_MAXSIZE`` / etc. is respected. + """ + # Honor an explicit choice already in the environment (opt-out or opt-in). + if "IDF_CCACHE_ENABLE" in os.environ: + if not get_bool_env("IDF_CCACHE_ENABLE"): + return {} + elif shutil.which("ccache") is None: + # ESP-IDF silently skips ccache without the binary; don't enable it. + return {} + + # ccache is enabled past here. build_path is set during preload for every + # config-loading command, so it being unset means a caller built the IDF env + # too early -- fail loudly rather than silently drop CCACHE_BASEDIR (which + # would quietly cost cross-device cache hits). + if CORE.build_path is None: + raise ValueError( + "CORE.build_path must be set before constructing the ESP-IDF build " + "environment" + ) + + defaults = { + "IDF_CCACHE_ENABLE": "1", + "CCACHE_DIR": str(_get_idf_tools_path() / "ccache"), + "CCACHE_NOHASHDIR": "true", + "CCACHE_DEPEND": "1", + "CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()), + } + # Don't override CCACHE_* values the user already set in their environment. + return {k: v for k, v in defaults.items() if k not in os.environ} + + def get_framework_env( framework_path: PathType, python_env_path: PathType | None = None, @@ -856,4 +906,7 @@ def get_framework_env( env.update(export_vars) env["PATH"] = os.pathsep.join(paths_to_export + path_list) + # 6. Enable ccache for the compile toolchain (default on when available). + env.update(_ccache_env()) + return env diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index 525cd55146..b5fa0e2698 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -15,6 +15,7 @@ from unittest.mock import patch import pytest from esphome.espidf.framework import ( + _ccache_env, _check_stamp, _check_windows_path_length, _clone_idf_with_submodules, @@ -620,6 +621,8 @@ def test_get_framework_env_with_python_env(tmp_path: Path) -> None: "esphome.espidf.framework._get_idf_tool_paths", return_value=(["/tool/bin"], {"IDF_X": "1"}), ), + # ccache env is covered separately; keep this test host-independent. + patch("esphome.espidf.framework._ccache_env", return_value={}), ): env = get_framework_env( tmp_path / "fw", tmp_path / "penv", {"PATH": "/usr/bin"} @@ -640,6 +643,8 @@ def test_get_framework_env_without_python_env_uses_os_path(tmp_path: Path) -> No ), patch("esphome.espidf.framework._get_idf_version", return_value="5.1.2"), patch("esphome.espidf.framework._get_idf_tool_paths", return_value=([], {})), + # ccache env is covered separately; keep this test host-independent. + patch("esphome.espidf.framework._ccache_env", return_value={}), ): env = get_framework_env(tmp_path / "fw") @@ -647,6 +652,88 @@ def test_get_framework_env_without_python_env_uses_os_path(tmp_path: Path) -> No assert env["PATH"] # taken from os.environ +# --------------------------------------------------------------------------- +# _ccache_env +# --------------------------------------------------------------------------- + + +def _ccache_patches(tmp_path: Path, which: str | None, build_path: Path | None): + return ( + patch("esphome.espidf.framework.shutil.which", return_value=which), + patch( + "esphome.espidf.framework._get_idf_tools_path", + return_value=tmp_path / "tools", + ), + patch( + "esphome.espidf.framework.CORE", + SimpleNamespace(build_path=build_path), + ), + ) + + +def test_ccache_env_default_enabled_when_available(tmp_path: Path) -> None: + p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", tmp_path / "build") + with patch.dict("os.environ", {}, clear=True), p1, p2, p3: + env = _ccache_env() + assert env["IDF_CCACHE_ENABLE"] == "1" + assert env["CCACHE_DIR"] == str(tmp_path / "tools" / "ccache") + assert env["CCACHE_NOHASHDIR"] == "true" + assert env["CCACHE_DEPEND"] == "1" + assert env["CCACHE_BASEDIR"] == str((tmp_path / "build").resolve()) + + +def test_ccache_env_disabled_when_binary_missing(tmp_path: Path) -> None: + # build_path is None here too: a disabled cache must not require it. + p1, p2, p3 = _ccache_patches(tmp_path, None, None) + with patch.dict("os.environ", {}, clear=True), p1, p2, p3: + assert _ccache_env() == {} + + +def test_ccache_env_opt_out_via_env(tmp_path: Path) -> None: + # Explicit IDF_CCACHE_ENABLE=0 wins even when the binary is present, and + # short-circuits before build_path is needed. + p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", None) + with patch.dict("os.environ", {"IDF_CCACHE_ENABLE": "0"}, clear=True), p1, p2, p3: + assert _ccache_env() == {} + + +def test_ccache_env_opt_in_without_binary(tmp_path: Path) -> None: + # Explicit IDF_CCACHE_ENABLE=1 forces it on without probing PATH. It's + # already in the environment, so it isn't re-emitted, but the rest is. + p1, p2, p3 = _ccache_patches(tmp_path, None, tmp_path / "build") + with patch.dict("os.environ", {"IDF_CCACHE_ENABLE": "1"}, clear=True), p1, p2, p3: + env = _ccache_env() + assert "IDF_CCACHE_ENABLE" not in env + assert env["CCACHE_DIR"] == str(tmp_path / "tools" / "ccache") + assert env["CCACHE_DEPEND"] == "1" + + +def test_ccache_env_preserves_user_overrides(tmp_path: Path) -> None: + # User-set CCACHE_* values must not be clobbered; unset ones still default. + p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", tmp_path / "build") + user_env = {"CCACHE_DIR": "/my/cache", "CCACHE_MAXSIZE": "9G"} + with patch.dict("os.environ", user_env, clear=True), p1, p2, p3: + env = _ccache_env() + assert "CCACHE_DIR" not in env + assert "CCACHE_MAXSIZE" not in env + assert env["IDF_CCACHE_ENABLE"] == "1" + assert env["CCACHE_DEPEND"] == "1" + + +def test_ccache_env_raises_without_build_path(tmp_path: Path) -> None: + # Enabled but no build_path means the IDF env was built too early -- fail + # loudly instead of silently dropping CCACHE_BASEDIR. + p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", None) + with ( + patch.dict("os.environ", {}, clear=True), + p1, + p2, + p3, + pytest.raises(ValueError, match="build_path"), + ): + _ccache_env() + + # --------------------------------------------------------------------------- # _check_stamp / _write_idf_version_txt / _get_idf_tools_path # --------------------------------------------------------------------------- From 84de814e6f7236696da9787ac027e21b2aaf80db Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:05:39 +1200 Subject: [PATCH 0553/1815] [config_validation] Make bind_key a sensitive dual-mode validator (#17146) --- esphome/components/dlms_meter/__init__.py | 8 +- esphome/components/dsmr/__init__.py | 4 +- esphome/config_validation.py | 67 +++++++++++++---- tests/unit_tests/test_config_validation.py | 86 ++++++++++++++++++++++ 4 files changed, 142 insertions(+), 23 deletions(-) diff --git a/esphome/components/dlms_meter/__init__.py b/esphome/components/dlms_meter/__init__.py index 7094699b0b..b747f73a14 100644 --- a/esphome/components/dlms_meter/__init__.py +++ b/esphome/components/dlms_meter/__init__.py @@ -136,12 +136,8 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(DlmsMeterComponent), - cv.Optional(CONF_DECRYPTION_KEY): lambda value: cv.bind_key( - value, name="Decryption key" - ), - cv.Optional(CONF_AUTH_KEY): lambda value: cv.bind_key( - value, name="Authentication key" - ), + cv.Optional(CONF_DECRYPTION_KEY): cv.bind_key(name="Decryption key"), + cv.Optional(CONF_AUTH_KEY): cv.bind_key(name="Authentication key"), cv.Optional(CONF_CUSTOM_PATTERNS): cv.ensure_list(CUSTOM_PATTERN_SCHEMA), cv.Optional(CONF_SKIP_CRC, default=False): cv.boolean, cv.Optional(CONF_PROVIDER): cv.string, diff --git a/esphome/components/dsmr/__init__.py b/esphome/components/dsmr/__init__.py index 1dc3664602..34f37ace35 100644 --- a/esphome/components/dsmr/__init__.py +++ b/esphome/components/dsmr/__init__.py @@ -40,9 +40,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(Dsmr), - cv.Optional(CONF_DECRYPTION_KEY): lambda value: cv.bind_key( - value, name="Decryption key" - ), + cv.Optional(CONF_DECRYPTION_KEY): cv.bind_key(name="Decryption key"), cv.Optional(CONF_CRC_CHECK, default=True): cv.boolean, cv.Optional(CONF_GAS_MBUS_ID, default=1): cv.int_, cv.Optional(CONF_WATER_MBUS_ID, default=2): cv.int_, diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 0ef6d212fe..0fdce85dc3 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1220,21 +1220,60 @@ def mac_address(value): return core.MACAddress(*parts_int) -def bind_key(value, *, name="Bind key"): - value = string_strict(value) - parts = [value[i : i + 2] for i in range(0, len(value), 2)] - if len(parts) != 16: - raise Invalid(f"{name} must consist of 16 hexadecimal numbers") - parts_int = [] - if any(len(part) != 2 for part in parts): - raise Invalid(f"{name} must be format XX") - for part in parts: - try: - parts_int.append(int(part, 16)) - except ValueError: - raise Invalid(f"{name} must be hex values from 00 to FF") from None +_BIND_KEY_MISSING = object() - return "".join(f"{part:02X}" for part in parts_int) + +class BindKeyValidator(SensitiveValidator): + """Sensitive validator for a 16-byte hex bind/encryption key. + + Use bare as a validator (``cv.bind_key``) for the default error wording, or + call it with a custom ``name`` (``cv.bind_key(name="Decryption key")``) to + get a validator with tailored error messages. Either way the value is marked + sensitive so frontends mask it and dump tooling redacts it. + """ + + def __init__(self, name: str = "Bind key") -> None: + self._name = name + super().__init__(self._validate) + + def _validate(self, value: typing.Any) -> str: + value = string_strict(value) + parts = [value[i : i + 2] for i in range(0, len(value), 2)] + if len(parts) != 16: + raise Invalid(f"{self._name} must consist of 16 hexadecimal numbers") + parts_int = [] + if any(len(part) != 2 for part in parts): + raise Invalid(f"{self._name} must be format XX") + for part in parts: + try: + parts_int.append(int(part, 16)) + except ValueError: + raise Invalid( + f"{self._name} must be hex values from 00 to FF" + ) from None + + return "".join(f"{part:02X}" for part in parts_int) + + def __call__( + self, value: typing.Any = _BIND_KEY_MISSING, *, name: str | None = None + ) -> typing.Any: + if value is _BIND_KEY_MISSING: + # Factory usage: return a validator with customized error wording. + return BindKeyValidator(name if name is not None else self._name) + if name is not None and name != self._name: + # Direct validation with a one-off custom name. + return BindKeyValidator(name)(value) + return super().__call__(value) + + def __repr__(self) -> str: + # ``self.inner`` is a bound method of this instance, so the inherited + # ``SensitiveValidator.__repr__`` (which returns ``repr(self.inner)``) + # would recurse infinitely. Provide a stable, name-keyed repr instead so + # ``build_language_schema`` dedup and voluptuous errors stay sane. + return f"bind_key({self._name!r})" + + +bind_key = BindKeyValidator() def uuid(value): diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index f1a6118870..9b9f003b0d 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -188,6 +188,92 @@ def test_sensitive__is_detectable_via_isinstance() -> None: assert isinstance(validator, config_validation.SensitiveValidator) +def test_bind_key__bare_usage_validates_and_is_sensitive() -> None: + # Used bare (cv.bind_key) it is itself a sensitive validator: detectable for + # frontend masking and validating a value directly tags the result. + assert isinstance(config_validation.bind_key, config_validation.SensitiveValidator) + + result = config_validation.bind_key("0123456789ABCDEF0123456789ABCDEF") + + assert isinstance(result, SensitiveStr) + assert result == "0123456789ABCDEF0123456789ABCDEF" + + +def test_bind_key__bare_usage_in_schema() -> None: + # Voluptuous calls the bare validator with the config value; the result must + # come through tagged sensitive. + schema = config_validation.Schema( + {config_validation.Required("key"): config_validation.bind_key} + ) + out = schema({"key": "0123456789ABCDEF0123456789ABCDEF"}) + + assert isinstance(out["key"], SensitiveStr) + + +def test_bind_key__factory_returns_sensitive_validator() -> None: + # Called with a name (cv.bind_key(name=...)) it returns a new sensitive + # validator rather than validating. + validator = config_validation.bind_key(name="Decryption key") + + assert isinstance(validator, config_validation.SensitiveValidator) + assert validator is not config_validation.bind_key + assert isinstance(validator("0123456789ABCDEF0123456789ABCDEF"), SensitiveStr) + + +@pytest.mark.parametrize( + ("value", "error"), + ( + ("00", "Decryption key must consist of 16 hexadecimal numbers"), + ("0123456789ABCDEF0123456789ABCDEG", "Decryption key must be hex values"), + ), +) +def test_bind_key__custom_name_in_error(value: str, error: str) -> None: + # The ``name`` argument (used by dsmr/dlms_meter) customizes error messages. + validator = config_validation.bind_key(name="Decryption key") + with pytest.raises(Invalid, match=error): + validator(value) + + +def test_bind_key__rejects_non_hex_pair_length() -> None: + # Odd-length input yields a trailing single-char part, hitting the + # "format XX" branch rather than the hex-value branch. + with pytest.raises(Invalid, match="Bind key must be format XX"): + config_validation.bind_key("0123456789ABCDEF0123456789ABCDE") + + +def test_bind_key__direct_call_with_name_validates_with_that_name() -> None: + # Passing both a value and a name validates immediately using the custom + # name for error wording, and still tags the result sensitive. + result = config_validation.bind_key( + "0123456789ABCDEF0123456789ABCDEF", name="Decryption key" + ) + assert isinstance(result, SensitiveStr) + + with pytest.raises(Invalid, match="Decryption key must consist of"): + config_validation.bind_key("00", name="Decryption key") + + +def test_bind_key__factory_without_name_keeps_existing_name() -> None: + # Re-invoking a named validator without a name preserves its name rather + # than resetting to the default. + named = config_validation.bind_key(name="Decryption key") + rederived = named() + + with pytest.raises(Invalid, match="Decryption key must consist of"): + rederived("00") + + +def test_bind_key__repr_is_name_keyed_and_non_recursive() -> None: + # ``self.inner`` is a bound method of the instance, so the inherited + # ``repr(self.inner)`` would recurse infinitely; the override keeps repr + # finite and keyed on the name for schema-dump dedup. + assert repr(config_validation.bind_key) == "bind_key('Bind key')" + assert ( + repr(config_validation.bind_key(name="Decryption key")) + == "bind_key('Decryption key')" + ) + + def test_sensitive__repr_mirrors_inner() -> None: # The schema dump dedups on ``repr(schema)``; mirroring the inner # validator's repr keeps two ``cv.sensitive(cv.string)`` wrappers From 344da7c4f4a8a2e32478792a26647a5511392a90 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:35:07 -0400 Subject: [PATCH 0554/1815] [docker] Move build deps to base image, drop app apt step (#17167) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- docker/Dockerfile | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 1fe380552a..c1baa51ae3 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ ARG BUILD_VERSION=dev -ARG BUILD_BASE_VERSION=2026.06.0 +ARG BUILD_BASE_VERSION=2026.06.1 ARG BUILD_TYPE=docker FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base-source-docker @@ -11,19 +11,6 @@ FROM base-source-${BUILD_TYPE} AS base RUN git config --system --add safe.directory "*" \ && git config --system advice.detachedHead false -# Install build tools for Python packages that require compilation -# (e.g., ruamel.yaml.clib used by ESP-IDF's idf-component-manager). -# Also install libusb-1.0 at runtime so the ESP-IDF tools installer can -# validate openocd-esp32 (it dynamically links libusb-1.0.so.0); without -# it idf_tools.py rejects the openocd install with exit 127 and aborts -# the whole framework setup. -# ccache speeds up repeat ESP-IDF compiles (enabled via IDF_CCACHE_ENABLE); -# ESP-IDF silently skips it when the binary isn't on PATH, so it must be -# present in the image for the dashboard/Device Builder to benefit. -RUN apt-get update \ - && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 ccache \ - && rm -rf /var/lib/apt/lists/* - ENV PIP_DISABLE_PIP_VERSION_CHECK=1 RUN pip install --no-cache-dir -U pip uv==0.10.1 From 72686bd4aff439b5b303b09b1b9940bc9126937b Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:43:39 +1000 Subject: [PATCH 0555/1815] [mipi_spi] Warn on MODE3 default for display without CS pin (#17153) --- esphome/components/mipi_spi/display.py | 10 ++++- tests/component_tests/mipi_spi/test_init.py | 44 +++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index abb7eaa458..d613d0a1ab 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -172,13 +172,19 @@ def model_schema(config): if bus_mode == TYPE_SINGLE: other_options.append(CONF_SPI_16) # Calculate default SPI mode. Mode3 for octal bus or single bus with no cs pin, mode0 otherwise. - spi_mode = model.get_default(CONF_SPI_MODE) + spi_mode = ( + cv.UNDEFINED if CONF_SPI_MODE in config else model.get_default(CONF_SPI_MODE) + ) if not spi_mode: if bus_mode == TYPE_OCTAL or ( bus_mode == TYPE_SINGLE - and not config.get(CONF_CS_PIN, model.get_default(CONF_CS_PIN)) + and config.get(CONF_CS_PIN, model.get_default(CONF_CS_PIN)) is False ): spi_mode = "MODE3" + if bus_mode == TYPE_SINGLE: + LOGGER.warning( + "No SPI mode specified, defaulting to MODE3 due to lack of CS pin. If you experience issues, try setting SPI mode explicitly to MODE0 or MODE3." + ) else: spi_mode = "MODE0" diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index d681908027..dbd8e15702 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -306,6 +306,50 @@ def test_all_predefined_models( run_schema_validation(config) +def test_single_bus_no_cs_no_mode_warns( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """A single-bus display with no CS pin and no explicit SPI mode warns about MODE3 default.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + run_schema_validation({"model": "ili9488", "dc_pin": 14}) + + assert "defaulting to MODE3 due to lack of CS pin" in caplog.text + + +@pytest.mark.parametrize( + "config", + [ + pytest.param( + {"model": "ili9488", "dc_pin": 14, "cs_pin": 0}, + id="cs_pin_provided", + ), + pytest.param( + {"model": "ili9488", "dc_pin": 14, "spi_mode": "mode0"}, + id="spi_mode_provided", + ), + ], +) +def test_single_bus_no_mode_warning_suppressed( + config: ConfigType, + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """No MODE3 warning when a CS pin or an explicit SPI mode is provided.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + run_schema_validation(config) + + assert "defaulting to MODE3 due to lack of CS pin" not in caplog.text + + def test_native_generation( generate_main: Callable[[str | Path], str], component_fixture_path: Callable[[str], Path], From dae078fc56a8350c6260997af4fcddada4589e21 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:06:15 +1200 Subject: [PATCH 0556/1815] Bump bundled esphome-device-builder to 1.0.15 (#17170) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c1baa51ae3..1cd3372255 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.14 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.15 RUN \ platformio settings set enable_telemetry No \ From 2b8916fc4e40f33ad908e7d2c2bfa0c3159edfb6 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:13:34 +1200 Subject: [PATCH 0557/1815] [ci] Exclude test changes from small-pr/medium-pr size labels (#17172) --- .github/scripts/auto-label-pr/detectors.js | 31 +++++--- .github/scripts/auto-label-pr/index.js | 2 +- .../auto-label-pr/tests/detectors.test.js | 78 ++++++++++++++++++- 3 files changed, 97 insertions(+), 14 deletions(-) diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index 81bb77843d..4406370a27 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -147,19 +147,9 @@ async function detectCoreChanges(changedFiles) { } // Strategy: PR size detection -async function detectPRSize(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD) { +async function detectPRSize(prFiles, totalAdditions, totalDeletions, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD) { const labels = new Set(); - if (totalChanges <= SMALL_PR_THRESHOLD) { - labels.add('small-pr'); - return labels; - } - - if (totalChanges <= MEDIUM_PR_THRESHOLD) { - labels.add('medium-pr'); - return labels; - } - const testAdditions = prFiles .filter(file => file.filename.startsWith('tests/')) .reduce((sum, file) => sum + (file.additions || 0), 0); @@ -167,7 +157,24 @@ async function detectPRSize(prFiles, totalAdditions, totalDeletions, totalChange .filter(file => file.filename.startsWith('tests/')) .reduce((sum, file) => sum + (file.deletions || 0), 0); - const nonTestChanges = (totalAdditions - testAdditions) - (totalDeletions - testDeletions); + const nonTestAdditions = totalAdditions - testAdditions; + const nonTestDeletions = totalDeletions - testDeletions; + + // small/medium count churn (additions + deletions) so a balanced refactor isn't undersized. + const nonTestChurn = nonTestAdditions + nonTestDeletions; + + if (nonTestChurn <= SMALL_PR_THRESHOLD) { + labels.add('small-pr'); + return labels; + } + + if (nonTestChurn <= MEDIUM_PR_THRESHOLD) { + labels.add('medium-pr'); + return labels; + } + + // too-big uses net line delta (additions - deletions), matching the review message in reviews.js. + const nonTestChanges = nonTestAdditions - nonTestDeletions; // Don't add too-big if mega-pr label is already present if (nonTestChanges > TOO_BIG_THRESHOLD && !isMegaPR) { diff --git a/.github/scripts/auto-label-pr/index.js b/.github/scripts/auto-label-pr/index.js index 9769cd8060..c8bdcfb2f3 100644 --- a/.github/scripts/auto-label-pr/index.js +++ b/.github/scripts/auto-label-pr/index.js @@ -123,7 +123,7 @@ module.exports = async ({ github, context }) => { detectNewComponents(github, context, prFiles), detectNewPlatforms(github, context, prFiles, apiData), detectCoreChanges(changedFiles), - detectPRSize(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD), + detectPRSize(prFiles, totalAdditions, totalDeletions, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD), detectDashboardChanges(changedFiles), detectGitHubActionsChanges(changedFiles), detectCodeOwner(github, context, changedFiles), diff --git a/.github/scripts/auto-label-pr/tests/detectors.test.js b/.github/scripts/auto-label-pr/tests/detectors.test.js index 02d69ca95e..aab1827c44 100644 --- a/.github/scripts/auto-label-pr/tests/detectors.test.js +++ b/.github/scripts/auto-label-pr/tests/detectors.test.js @@ -1,6 +1,6 @@ const { describe, it } = require('node:test'); const assert = require('node:assert/strict'); -const { detectNewPlatforms, detectNewComponents } = require('../detectors'); +const { detectNewPlatforms, detectNewComponents, detectPRSize } = require('../detectors'); // Minimal GitHub API mock — only repos.getContent is called by detectNewPlatforms/detectNewComponents // to check for CONFIG_SCHEMA in newly added files. @@ -145,3 +145,79 @@ describe('detectNewComponents', () => { assert.equal(result.labels.size, 0); }); }); + +// --------------------------------------------------------------------------- +// detectPRSize +// --------------------------------------------------------------------------- + +describe('detectPRSize', () => { + const SMALL = 30; + const MEDIUM = 100; + const TOO_BIG = 1000; + + function size(prFiles, isMegaPR = false) { + const totalAdditions = prFiles.reduce((sum, file) => sum + (file.additions || 0), 0); + const totalDeletions = prFiles.reduce((sum, file) => sum + (file.deletions || 0), 0); + return detectPRSize(prFiles, totalAdditions, totalDeletions, isMegaPR, SMALL, MEDIUM, TOO_BIG); + } + + it('counts only non-test changes toward small-pr', async () => { + // 10 source + 5000 test lines -> non-test churn of 10 is still small. + const labels = await size([ + { filename: 'esphome/components/foo/foo.cpp', additions: 10, deletions: 0 }, + { filename: 'tests/components/foo/test.esp32-idf.yaml', additions: 5000, deletions: 0 }, + ]); + assert.ok(labels.has('small-pr')); + assert.equal(labels.size, 1); + }); + + it('counts additions and deletions as churn (not net delta)', async () => { + // A balanced refactor (40 added, 40 removed) is 80 lines of churn -> medium, not small. + const labels = await size([ + { filename: 'esphome/components/foo/foo.cpp', additions: 40, deletions: 40 }, + ]); + assert.ok(labels.has('medium-pr')); + assert.equal(labels.size, 1); + }); + + it('labels medium-pr when non-test changes exceed small threshold', async () => { + const labels = await size([ + { filename: 'esphome/components/foo/foo.cpp', additions: 60, deletions: 0 }, + { filename: 'tests/components/foo/test.esp32-idf.yaml', additions: 5000, deletions: 0 }, + ]); + assert.ok(labels.has('medium-pr')); + assert.equal(labels.size, 1); + }); + + it('uses net delta (not churn) for too-big', async () => { + // 600 added + 600 removed: 1200 churn (above too-big) but 0 net delta -> not too-big. + const labels = await size([ + { filename: 'esphome/components/foo/foo.cpp', additions: 600, deletions: 600 }, + ]); + assert.equal(labels.size, 0); + }); + + it('labels too-big when non-test changes exceed the big threshold', async () => { + const labels = await size([ + { filename: 'esphome/components/foo/foo.cpp', additions: 2000, deletions: 0 }, + { filename: 'tests/components/foo/test.esp32-idf.yaml', additions: 5000, deletions: 0 }, + ]); + assert.ok(labels.has('too-big')); + assert.equal(labels.size, 1); + }); + + it('does not label too-big when mega-pr is set', async () => { + const labels = await size([ + { filename: 'esphome/components/foo/foo.cpp', additions: 2000, deletions: 0 }, + ], true); + assert.equal(labels.size, 0); + }); + + it('produces no size label for a large mega-pr in the gap above medium', async () => { + // Non-test changes land between MEDIUM and TOO_BIG: not small/medium, and mega-pr suppresses too-big. + const labels = await size([ + { filename: 'esphome/components/foo/foo.cpp', additions: 500, deletions: 0 }, + ], true); + assert.equal(labels.size, 0); + }); +}); From e6455c5b448296f3e99de5e50deda6702f0b9ced Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:27:07 +1200 Subject: [PATCH 0558/1815] Mark configurable classes as final (12/21: msa3xx-pm2005) (#16963) --- esphome/components/msa3xx/msa3xx.h | 2 +- esphome/components/my9231/my9231.h | 4 ++-- esphome/components/nau7802/nau7802.h | 8 ++++---- esphome/components/network/network_component.h | 2 +- esphome/components/nextion/automation.h | 8 ++++---- .../nextion/binary_sensor/nextion_binarysensor.h | 6 +++--- esphome/components/nextion/nextion.h | 2 +- esphome/components/nextion/sensor/nextion_sensor.h | 2 +- esphome/components/nextion/switch/nextion_switch.h | 2 +- .../nextion/text_sensor/nextion_textsensor.h | 2 +- esphome/components/nfc/automation.h | 2 +- .../components/nfc/binary_sensor/nfc_binary_sensor.h | 8 ++++---- esphome/components/noblex/noblex.h | 2 +- esphome/components/npi19/npi19.h | 2 +- esphome/components/nrf52/dfu.h | 2 +- esphome/components/ntc/ntc.h | 2 +- esphome/components/number/automation.h | 10 +++++----- esphome/components/number/sensor/number_sensor.h | 2 +- esphome/components/online_image/online_image.h | 10 +++++----- esphome/components/opentherm/automation.h | 4 ++-- esphome/components/opentherm/hub.h | 2 +- esphome/components/opentherm/number/opentherm_number.h | 2 +- esphome/components/opentherm/output/opentherm_output.h | 2 +- esphome/components/opentherm/switch/opentherm_switch.h | 2 +- esphome/components/openthread/openthread.h | 4 ++-- esphome/components/opt3001/opt3001.h | 2 +- esphome/components/output/automation.h | 10 +++++----- esphome/components/output/button/output_button.h | 2 +- esphome/components/output/lock/output_lock.h | 2 +- esphome/components/output/switch/output_switch.h | 2 +- esphome/components/partition/light_partition.h | 2 +- esphome/components/pca6416a/pca6416a.h | 8 ++++---- esphome/components/pca9554/pca9554.h | 8 ++++---- esphome/components/pca9685/pca9685_output.h | 4 ++-- esphome/components/pcd8544/pcd_8544.h | 6 +++--- esphome/components/pcf85063/pcf85063.h | 6 +++--- esphome/components/pcf8563/pcf8563.h | 6 +++--- esphome/components/pcf8574/pcf8574.h | 8 ++++---- esphome/components/pcm5122/pcm5122.h | 2 +- esphome/components/pcm5122/pcm5122_gpio.h | 2 +- esphome/components/pi4ioe5v6408/pi4ioe5v6408.h | 8 ++++---- esphome/components/pid/pid_climate.h | 8 ++++---- esphome/components/pid/sensor/pid_climate_sensor.h | 2 +- esphome/components/pipsolar/output/pipsolar_output.h | 4 ++-- esphome/components/pipsolar/pipsolar.h | 2 +- esphome/components/pipsolar/switch/pipsolar_switch.h | 2 +- esphome/components/pm1006/pm1006.h | 2 +- esphome/components/pm2005/pm2005.h | 2 +- 48 files changed, 97 insertions(+), 97 deletions(-) diff --git a/esphome/components/msa3xx/msa3xx.h b/esphome/components/msa3xx/msa3xx.h index 345afc50ab..212ee10a48 100644 --- a/esphome/components/msa3xx/msa3xx.h +++ b/esphome/components/msa3xx/msa3xx.h @@ -211,7 +211,7 @@ union RegTapDuration { uint8_t raw{0x04}; }; -class MSA3xxComponent : public PollingComponent, public i2c::I2CDevice { +class MSA3xxComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/my9231/my9231.h b/esphome/components/my9231/my9231.h index 60b113079e..ababfd7fc7 100644 --- a/esphome/components/my9231/my9231.h +++ b/esphome/components/my9231/my9231.h @@ -8,7 +8,7 @@ namespace esphome::my9231 { /// MY9231 float output component. -class MY9231OutputComponent : public Component { +class MY9231OutputComponent final : public Component { public: class Channel; void set_pin_di(GPIOPin *pin_di) { pin_di_ = pin_di; } @@ -26,7 +26,7 @@ class MY9231OutputComponent : public Component { /// Send new values if they were updated. void loop() override; - class Channel : public output::FloatOutput { + class Channel final : public output::FloatOutput { public: void set_parent(MY9231OutputComponent *parent) { parent_ = parent; } void set_channel(uint16_t channel) { channel_ = channel; } diff --git a/esphome/components/nau7802/nau7802.h b/esphome/components/nau7802/nau7802.h index 67f36ca677..c53a018234 100644 --- a/esphome/components/nau7802/nau7802.h +++ b/esphome/components/nau7802/nau7802.h @@ -47,7 +47,7 @@ enum NAU7802CalibrationModes { NAU7802_CALIBRATE_GAIN = 0b11, }; -class NAU7802Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class NAU7802Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void set_samples_per_second(NAU7802SPS sps) { this->sps_ = sps; } void set_ldo_voltage(NAU7802LDO ldo) { this->ldo_ = ldo; } @@ -97,18 +97,18 @@ class NAU7802Sensor : public sensor::Sensor, public PollingComponent, public i2c }; template -class NAU7802CalbrateExternalOffsetAction : public Action, public Parented { +class NAU7802CalbrateExternalOffsetAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->calibrate_external_offset(); } }; template -class NAU7802CalbrateInternalOffsetAction : public Action, public Parented { +class NAU7802CalbrateInternalOffsetAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->calibrate_internal_offset(); } }; -template class NAU7802CalbrateGainAction : public Action, public Parented { +template class NAU7802CalbrateGainAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->calibrate_gain(); } }; diff --git a/esphome/components/network/network_component.h b/esphome/components/network/network_component.h index dde15940e4..2e76a95673 100644 --- a/esphome/components/network/network_component.h +++ b/esphome/components/network/network_component.h @@ -4,7 +4,7 @@ #include "esphome/core/component.h" namespace esphome::network { -class NetworkComponent : public Component { +class NetworkComponent final : public Component { public: void setup() override; // AFTER_BLUETOOTH: BLE controller must initialize before esp_netif_init per IDF guidance. diff --git a/esphome/components/nextion/automation.h b/esphome/components/nextion/automation.h index e039dae615..0226c65be6 100644 --- a/esphome/components/nextion/automation.h +++ b/esphome/components/nextion/automation.h @@ -7,7 +7,7 @@ namespace esphome::nextion { -template class NextionSetBrightnessAction : public Action { +template class NextionSetBrightnessAction final : public Action { public: explicit NextionSetBrightnessAction(Nextion *component) : component_(component) {} @@ -24,7 +24,7 @@ template class NextionSetBrightnessAction : public Action Nextion *component_; }; -template class NextionPublishFloatAction : public Action { +template class NextionPublishFloatAction final : public Action { public: explicit NextionPublishFloatAction(NextionComponent *component) : component_(component) {} @@ -47,7 +47,7 @@ template class NextionPublishFloatAction : public Action NextionComponent *component_; }; -template class NextionPublishTextAction : public Action { +template class NextionPublishTextAction final : public Action { public: explicit NextionPublishTextAction(NextionComponent *component) : component_(component) {} @@ -70,7 +70,7 @@ template class NextionPublishTextAction : public Action { NextionComponent *component_; }; -template class NextionPublishBoolAction : public Action { +template class NextionPublishBoolAction final : public Action { public: explicit NextionPublishBoolAction(NextionComponent *component) : component_(component) {} diff --git a/esphome/components/nextion/binary_sensor/nextion_binarysensor.h b/esphome/components/nextion/binary_sensor/nextion_binarysensor.h index 7637957222..9970db1c01 100644 --- a/esphome/components/nextion/binary_sensor/nextion_binarysensor.h +++ b/esphome/components/nextion/binary_sensor/nextion_binarysensor.h @@ -8,9 +8,9 @@ namespace esphome::nextion { class NextionBinarySensor; -class NextionBinarySensor : public NextionComponent, - public binary_sensor::BinarySensorInitiallyOff, - public PollingComponent { +class NextionBinarySensor final : public NextionComponent, + public binary_sensor::BinarySensorInitiallyOff, + public PollingComponent { public: NextionBinarySensor(NextionBase *nextion) { this->nextion_ = nextion; } diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index ef030e71da..d361d9725b 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -76,7 +76,7 @@ class NextionCommandPacer { }; #endif // USE_NEXTION_COMMAND_SPACING -class Nextion : public NextionBase, public PollingComponent, public uart::UARTDevice { +class Nextion final : public NextionBase, public PollingComponent, public uart::UARTDevice { public: #ifdef USE_NEXTION_MAX_COMMANDS_PER_LOOP /** diff --git a/esphome/components/nextion/sensor/nextion_sensor.h b/esphome/components/nextion/sensor/nextion_sensor.h index 72e3982b3a..bc0875fdff 100644 --- a/esphome/components/nextion/sensor/nextion_sensor.h +++ b/esphome/components/nextion/sensor/nextion_sensor.h @@ -8,7 +8,7 @@ namespace esphome::nextion { class NextionSensor; -class NextionSensor : public NextionComponent, public sensor::Sensor, public PollingComponent { +class NextionSensor final : public NextionComponent, public sensor::Sensor, public PollingComponent { public: NextionSensor(NextionBase *nextion) { this->nextion_ = nextion; } void send_state_to_nextion() override { this->set_state(this->state, false, true); }; diff --git a/esphome/components/nextion/switch/nextion_switch.h b/esphome/components/nextion/switch/nextion_switch.h index 7e0593d217..2cac733b49 100644 --- a/esphome/components/nextion/switch/nextion_switch.h +++ b/esphome/components/nextion/switch/nextion_switch.h @@ -8,7 +8,7 @@ namespace esphome::nextion { class NextionSwitch; -class NextionSwitch : public NextionComponent, public switch_::Switch, public PollingComponent { +class NextionSwitch final : public NextionComponent, public switch_::Switch, public PollingComponent { public: NextionSwitch(NextionBase *nextion) { this->nextion_ = nextion; } diff --git a/esphome/components/nextion/text_sensor/nextion_textsensor.h b/esphome/components/nextion/text_sensor/nextion_textsensor.h index 42cd5dcef4..5ef2bb222f 100644 --- a/esphome/components/nextion/text_sensor/nextion_textsensor.h +++ b/esphome/components/nextion/text_sensor/nextion_textsensor.h @@ -8,7 +8,7 @@ namespace esphome::nextion { class NextionTextSensor; -class NextionTextSensor : public NextionComponent, public text_sensor::TextSensor, public PollingComponent { +class NextionTextSensor final : public NextionComponent, public text_sensor::TextSensor, public PollingComponent { public: NextionTextSensor(NextionBase *nextion) { this->nextion_ = nextion; } void update() override; diff --git a/esphome/components/nfc/automation.h b/esphome/components/nfc/automation.h index 0ac3e3b8b6..ec3a979b64 100644 --- a/esphome/components/nfc/automation.h +++ b/esphome/components/nfc/automation.h @@ -7,7 +7,7 @@ namespace esphome::nfc { -class NfcOnTagTrigger : public Trigger { +class NfcOnTagTrigger final : public Trigger { public: void process(const std::unique_ptr &tag); }; diff --git a/esphome/components/nfc/binary_sensor/nfc_binary_sensor.h b/esphome/components/nfc/binary_sensor/nfc_binary_sensor.h index b3448a57cc..6354e16967 100644 --- a/esphome/components/nfc/binary_sensor/nfc_binary_sensor.h +++ b/esphome/components/nfc/binary_sensor/nfc_binary_sensor.h @@ -8,10 +8,10 @@ namespace esphome::nfc { -class NfcTagBinarySensor : public binary_sensor::BinarySensor, - public Component, - public NfcTagListener, - public Parented { +class NfcTagBinarySensor final : public binary_sensor::BinarySensor, + public Component, + public NfcTagListener, + public Parented { public: void setup() override; void dump_config() override; diff --git a/esphome/components/noblex/noblex.h b/esphome/components/noblex/noblex.h index 62070e5dee..e505a5ba3f 100644 --- a/esphome/components/noblex/noblex.h +++ b/esphome/components/noblex/noblex.h @@ -8,7 +8,7 @@ namespace esphome::noblex { const uint8_t NOBLEX_TEMP_MIN = 16; // Celsius const uint8_t NOBLEX_TEMP_MAX = 30; // Celsius -class NoblexClimate : public climate_ir::ClimateIR { +class NoblexClimate final : public climate_ir::ClimateIR { public: NoblexClimate() : climate_ir::ClimateIR(NOBLEX_TEMP_MIN, NOBLEX_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/npi19/npi19.h b/esphome/components/npi19/npi19.h index d1f74141ac..f18a0989de 100644 --- a/esphome/components/npi19/npi19.h +++ b/esphome/components/npi19/npi19.h @@ -7,7 +7,7 @@ namespace esphome::npi19 { /// This class implements support for the npi19 pressure and temperature i2c sensors. -class NPI19Component : public PollingComponent, public i2c::I2CDevice { +class NPI19Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_raw_pressure_sensor(sensor::Sensor *raw_pressure_sensor) { diff --git a/esphome/components/nrf52/dfu.h b/esphome/components/nrf52/dfu.h index 82c7d9f54e..4f7ad89b18 100644 --- a/esphome/components/nrf52/dfu.h +++ b/esphome/components/nrf52/dfu.h @@ -6,7 +6,7 @@ #include "esphome/core/gpio.h" namespace esphome::nrf52 { -class DeviceFirmwareUpdate : public Component { +class DeviceFirmwareUpdate final : public Component { public: void setup() override; void set_reset_pin(GPIOPin *reset) { this->reset_pin_ = reset; } diff --git a/esphome/components/ntc/ntc.h b/esphome/components/ntc/ntc.h index 466d03f789..25fbf3c85d 100644 --- a/esphome/components/ntc/ntc.h +++ b/esphome/components/ntc/ntc.h @@ -5,7 +5,7 @@ namespace esphome::ntc { -class NTC : public Component, public sensor::Sensor { +class NTC final : public Component, public sensor::Sensor { public: void set_sensor(Sensor *sensor) { sensor_ = sensor; } void set_a(double a) { a_ = a; } diff --git a/esphome/components/number/automation.h b/esphome/components/number/automation.h index 2843aa6bf5..4efcfd30d8 100644 --- a/esphome/components/number/automation.h +++ b/esphome/components/number/automation.h @@ -6,14 +6,14 @@ namespace esphome::number { -class NumberStateTrigger : public Trigger { +class NumberStateTrigger final : public Trigger { public: explicit NumberStateTrigger(Number *parent) { parent->add_on_state_callback([this](float value) { this->trigger(value); }); } }; -template class NumberSetAction : public Action { +template class NumberSetAction final : public Action { public: NumberSetAction(Number *number) : number_(number) {} TEMPLATABLE_VALUE(float, value) @@ -28,7 +28,7 @@ template class NumberSetAction : public Action { Number *number_; }; -template class NumberOperationAction : public Action { +template class NumberOperationAction final : public Action { public: explicit NumberOperationAction(Number *number) : number_(number) {} TEMPLATABLE_VALUE(NumberOperation, operation) @@ -47,7 +47,7 @@ template class NumberOperationAction : public Action { Number *number_; }; -class ValueRangeTrigger : public Trigger, public Component { +class ValueRangeTrigger final : public Trigger, public Component { public: explicit ValueRangeTrigger(Number *parent) : parent_(parent) {} @@ -67,7 +67,7 @@ class ValueRangeTrigger : public Trigger, public Component { TemplatableFn max_{[](float) -> float { return NAN; }}; }; -template class NumberInRangeCondition : public Condition { +template class NumberInRangeCondition final : public Condition { public: NumberInRangeCondition(Number *parent) : parent_(parent) {} diff --git a/esphome/components/number/sensor/number_sensor.h b/esphome/components/number/sensor/number_sensor.h index 2d6825a298..ba3cec150c 100644 --- a/esphome/components/number/sensor/number_sensor.h +++ b/esphome/components/number/sensor/number_sensor.h @@ -6,7 +6,7 @@ namespace esphome::number { -class NumberSensor : public sensor::Sensor, public Component { +class NumberSensor final : public sensor::Sensor, public Component { public: explicit NumberSensor(Number *source) : source_(source) {} void setup() override; diff --git a/esphome/components/online_image/online_image.h b/esphome/components/online_image/online_image.h index a967bb6c0e..3e386f8cc8 100644 --- a/esphome/components/online_image/online_image.h +++ b/esphome/components/online_image/online_image.h @@ -21,9 +21,9 @@ using t_http_codes = enum { * The image will then be stored in a buffer, so that it can be re-displayed without the * need to re-download or re-decode. */ -class OnlineImage : public PollingComponent, - public runtime_image::RuntimeImage, - public Parented { +class OnlineImage final : public PollingComponent, + public runtime_image::RuntimeImage, + public Parented { public: /** * @brief Construct a new OnlineImage object. @@ -104,7 +104,7 @@ class OnlineImage : public PollingComponent, uint32_t start_time_{0}; }; -template class OnlineImageSetUrlAction : public Action { +template class OnlineImageSetUrlAction final : public Action { public: OnlineImageSetUrlAction(OnlineImage *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, url) @@ -120,7 +120,7 @@ template class OnlineImageSetUrlAction : public Action { OnlineImage *parent_; }; -template class OnlineImageReleaseAction : public Action { +template class OnlineImageReleaseAction final : public Action { public: OnlineImageReleaseAction(OnlineImage *parent) : parent_(parent) {} void play(const Ts &...x) override { this->parent_->release(); } diff --git a/esphome/components/opentherm/automation.h b/esphome/components/opentherm/automation.h index aa20a4ec5a..365992b280 100644 --- a/esphome/components/opentherm/automation.h +++ b/esphome/components/opentherm/automation.h @@ -6,14 +6,14 @@ namespace esphome::opentherm { -class BeforeSendTrigger : public Trigger { +class BeforeSendTrigger final : public Trigger { public: BeforeSendTrigger(OpenthermHub *hub) { hub->add_on_before_send_callback([this](OpenthermData &x) { this->trigger(x); }); } }; -class BeforeProcessResponseTrigger : public Trigger { +class BeforeProcessResponseTrigger final : public Trigger { public: BeforeProcessResponseTrigger(OpenthermHub *hub) { hub->add_on_before_process_response_callback([this](OpenthermData &x) { this->trigger(x); }); diff --git a/esphome/components/opentherm/hub.h b/esphome/components/opentherm/hub.h index 2638137668..268c6210f0 100644 --- a/esphome/components/opentherm/hub.h +++ b/esphome/components/opentherm/hub.h @@ -41,7 +41,7 @@ static const uint8_t REPEATING_MESSAGE_ORDER = 255; static const uint8_t INITIAL_UNORDERED_MESSAGE_ORDER = 254; // OpenTherm component for ESPHome -class OpenthermHub : public Component { +class OpenthermHub final : public Component { protected: // Communication pins for the OpenTherm interface InternalGPIOPin *in_pin_, *out_pin_; diff --git a/esphome/components/opentherm/number/opentherm_number.h b/esphome/components/opentherm/number/opentherm_number.h index c110bed2eb..c97692ce2a 100644 --- a/esphome/components/opentherm/number/opentherm_number.h +++ b/esphome/components/opentherm/number/opentherm_number.h @@ -8,7 +8,7 @@ namespace esphome::opentherm { // Just a simple number, which stores the number -class OpenthermNumber : public number::Number, public Component, public OpenthermInput { +class OpenthermNumber final : public number::Number, public Component, public OpenthermInput { protected: void control(float value) override; void setup() override; diff --git a/esphome/components/opentherm/output/opentherm_output.h b/esphome/components/opentherm/output/opentherm_output.h index e789d72702..24d5052076 100644 --- a/esphome/components/opentherm/output/opentherm_output.h +++ b/esphome/components/opentherm/output/opentherm_output.h @@ -6,7 +6,7 @@ namespace esphome::opentherm { -class OpenthermOutput : public output::FloatOutput, public Component, public OpenthermInput { +class OpenthermOutput final : public output::FloatOutput, public Component, public OpenthermInput { protected: bool has_state_ = false; const char *id_ = nullptr; diff --git a/esphome/components/opentherm/switch/opentherm_switch.h b/esphome/components/opentherm/switch/opentherm_switch.h index ca930d4f7c..235bc23401 100644 --- a/esphome/components/opentherm/switch/opentherm_switch.h +++ b/esphome/components/opentherm/switch/opentherm_switch.h @@ -6,7 +6,7 @@ namespace esphome::opentherm { -class OpenthermSwitch : public switch_::Switch, public Component { +class OpenthermSwitch final : public switch_::Switch, public Component { protected: void write_state(bool state) override; diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index f1c79fb9cb..488aad1166 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -19,7 +19,7 @@ namespace esphome::openthread { class InstanceLock; -class OpenThreadComponent : public Component { +class OpenThreadComponent final : public Component { public: OpenThreadComponent(); ~OpenThreadComponent(); @@ -68,7 +68,7 @@ class OpenThreadComponent : public Component { extern OpenThreadComponent *global_openthread_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -class OpenThreadSrpComponent : public Component { +class OpenThreadSrpComponent final : public Component { public: void set_mdns(esphome::mdns::MDNSComponent *mdns); // This has to run after the mdns component or else no services are available to advertise diff --git a/esphome/components/opt3001/opt3001.h b/esphome/components/opt3001/opt3001.h index e5de536353..92f5136bf7 100644 --- a/esphome/components/opt3001/opt3001.h +++ b/esphome/components/opt3001/opt3001.h @@ -7,7 +7,7 @@ namespace esphome::opt3001 { /// This class implements support for the i2c-based OPT3001 ambient light sensor. -class OPT3001Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class OPT3001Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void dump_config() override; void update() override; diff --git a/esphome/components/output/automation.h b/esphome/components/output/automation.h index 301f568388..efe775ba57 100644 --- a/esphome/components/output/automation.h +++ b/esphome/components/output/automation.h @@ -8,7 +8,7 @@ namespace esphome::output { -template class TurnOffAction : public Action { +template class TurnOffAction final : public Action { public: TurnOffAction(BinaryOutput *output) : output_(output) {} @@ -18,7 +18,7 @@ template class TurnOffAction : public Action { BinaryOutput *output_; }; -template class TurnOnAction : public Action { +template class TurnOnAction final : public Action { public: TurnOnAction(BinaryOutput *output) : output_(output) {} @@ -28,7 +28,7 @@ template class TurnOnAction : public Action { BinaryOutput *output_; }; -template class SetLevelAction : public Action { +template class SetLevelAction final : public Action { public: SetLevelAction(FloatOutput *output) : output_(output) {} @@ -41,7 +41,7 @@ template class SetLevelAction : public Action { }; #ifdef USE_OUTPUT_FLOAT_POWER_SCALING -template class SetMinPowerAction : public Action { +template class SetMinPowerAction final : public Action { public: SetMinPowerAction(FloatOutput *output) : output_(output) {} @@ -53,7 +53,7 @@ template class SetMinPowerAction : public Action { FloatOutput *output_; }; -template class SetMaxPowerAction : public Action { +template class SetMaxPowerAction final : public Action { public: SetMaxPowerAction(FloatOutput *output) : output_(output) {} diff --git a/esphome/components/output/button/output_button.h b/esphome/components/output/button/output_button.h index 1a2997bdcf..bf6be8afe1 100644 --- a/esphome/components/output/button/output_button.h +++ b/esphome/components/output/button/output_button.h @@ -6,7 +6,7 @@ namespace esphome::output { -class OutputButton : public button::Button, public Component { +class OutputButton final : public button::Button, public Component { public: void dump_config() override; diff --git a/esphome/components/output/lock/output_lock.h b/esphome/components/output/lock/output_lock.h index 7be96e1e82..8e5f4ff7df 100644 --- a/esphome/components/output/lock/output_lock.h +++ b/esphome/components/output/lock/output_lock.h @@ -6,7 +6,7 @@ namespace esphome::output { -class OutputLock : public lock::Lock, public Component { +class OutputLock final : public lock::Lock, public Component { public: void set_output(BinaryOutput *output) { output_ = output; } diff --git a/esphome/components/output/switch/output_switch.h b/esphome/components/output/switch/output_switch.h index b0d85678be..878104f14c 100644 --- a/esphome/components/output/switch/output_switch.h +++ b/esphome/components/output/switch/output_switch.h @@ -6,7 +6,7 @@ namespace esphome::output { -class OutputSwitch : public switch_::Switch, public Component { +class OutputSwitch final : public switch_::Switch, public Component { public: void set_output(BinaryOutput *output) { output_ = output; } diff --git a/esphome/components/partition/light_partition.h b/esphome/components/partition/light_partition.h index 7a2f3678c1..adadde068c 100644 --- a/esphome/components/partition/light_partition.h +++ b/esphome/components/partition/light_partition.h @@ -31,7 +31,7 @@ class AddressableSegment { bool reversed_; }; -class PartitionLightOutput : public light::AddressableLight { +class PartitionLightOutput final : public light::AddressableLight { public: explicit PartitionLightOutput(std::vector segments) : segments_(std::move(segments)) { int32_t off = 0; diff --git a/esphome/components/pca6416a/pca6416a.h b/esphome/components/pca6416a/pca6416a.h index 3170033b28..39011d53ab 100644 --- a/esphome/components/pca6416a/pca6416a.h +++ b/esphome/components/pca6416a/pca6416a.h @@ -7,9 +7,9 @@ namespace esphome::pca6416a { -class PCA6416AComponent : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander { +class PCA6416AComponent final : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { public: PCA6416AComponent() = default; @@ -49,7 +49,7 @@ class PCA6416AComponent : public Component, }; /// Helper class to expose a PCA6416A pin as an internal input GPIO pin. -class PCA6416AGPIOPin : public GPIOPin { +class PCA6416AGPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/pca9554/pca9554.h b/esphome/components/pca9554/pca9554.h index 9fa398cf29..05e945d176 100644 --- a/esphome/components/pca9554/pca9554.h +++ b/esphome/components/pca9554/pca9554.h @@ -7,9 +7,9 @@ namespace esphome::pca9554 { -class PCA9554Component : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander { +class PCA9554Component final : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { public: PCA9554Component() = default; @@ -53,7 +53,7 @@ class PCA9554Component : public Component, }; /// Helper class to expose a PCA9554 pin as an internal input GPIO pin. -class PCA9554GPIOPin : public GPIOPin { +class PCA9554GPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/pca9685/pca9685_output.h b/esphome/components/pca9685/pca9685_output.h index 33819f23ee..dad722888f 100644 --- a/esphome/components/pca9685/pca9685_output.h +++ b/esphome/components/pca9685/pca9685_output.h @@ -24,7 +24,7 @@ inline constexpr uint8_t PCA9685_MODE_OUTNE_LOW = 0x01; class PCA9685Output; -class PCA9685Channel : public output::FloatOutput { +class PCA9685Channel final : public output::FloatOutput { public: void set_channel(uint8_t channel) { channel_ = channel; } void set_parent(PCA9685Output *parent) { parent_ = parent; } @@ -39,7 +39,7 @@ class PCA9685Channel : public output::FloatOutput { }; /// PCA9685 float output component. -class PCA9685Output : public Component, public i2c::I2CDevice { +class PCA9685Output final : public Component, public i2c::I2CDevice { public: PCA9685Output(uint8_t mode = PCA9685_MODE_OUTPUT_ONACK | PCA9685_MODE_OUTPUT_TOTEM_POLE) : mode_(mode) {} diff --git a/esphome/components/pcd8544/pcd_8544.h b/esphome/components/pcd8544/pcd_8544.h index 9e4ee93035..3368c39551 100644 --- a/esphome/components/pcd8544/pcd_8544.h +++ b/esphome/components/pcd8544/pcd_8544.h @@ -6,9 +6,9 @@ namespace esphome::pcd8544 { -class PCD8544 : public display::DisplayBuffer, - public spi::SPIDevice { +class PCD8544 final : public display::DisplayBuffer, + public spi::SPIDevice { public: const uint8_t PCD8544_POWERDOWN = 0x04; const uint8_t PCD8544_ENTRYMODE = 0x02; diff --git a/esphome/components/pcf85063/pcf85063.h b/esphome/components/pcf85063/pcf85063.h index 1c6b6bf36d..659260ba5e 100644 --- a/esphome/components/pcf85063/pcf85063.h +++ b/esphome/components/pcf85063/pcf85063.h @@ -6,7 +6,7 @@ namespace esphome::pcf85063 { -class PCF85063Component : public time::RealTimeClock, public i2c::I2CDevice { +class PCF85063Component final : public time::RealTimeClock, public i2c::I2CDevice { public: void setup() override; void update() override; @@ -81,12 +81,12 @@ class PCF85063Component : public time::RealTimeClock, public i2c::I2CDevice { } pcf85063_; }; -template class WriteAction : public Action, public Parented { +template class WriteAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->write_time(); } }; -template class ReadAction : public Action, public Parented { +template class ReadAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->read_time(); } }; diff --git a/esphome/components/pcf8563/pcf8563.h b/esphome/components/pcf8563/pcf8563.h index 72b600d9ba..e208774c2c 100644 --- a/esphome/components/pcf8563/pcf8563.h +++ b/esphome/components/pcf8563/pcf8563.h @@ -6,7 +6,7 @@ namespace esphome::pcf8563 { -class PCF8563Component : public time::RealTimeClock, public i2c::I2CDevice { +class PCF8563Component final : public time::RealTimeClock, public i2c::I2CDevice { public: void setup() override; void update() override; @@ -109,12 +109,12 @@ class PCF8563Component : public time::RealTimeClock, public i2c::I2CDevice { } pcf8563_; }; -template class WriteAction : public Action, public Parented { +template class WriteAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->write_time(); } }; -template class ReadAction : public Action, public Parented { +template class ReadAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->read_time(); } }; diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index ece472c4bb..e8f78bae50 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -9,9 +9,9 @@ namespace esphome::pcf8574 { // PCF8574(8 pins)/PCF8575(16 pins) always read/write all pins in a single I2C transaction // so we use uint16_t as bank type to ensure all pins are in one bank and cached together -class PCF8574Component : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander { +class PCF8574Component final : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { public: PCF8574Component() = default; @@ -49,7 +49,7 @@ class PCF8574Component : public Component, }; /// Helper class to expose a PCF8574 pin as an internal input GPIO pin. -class PCF8574GPIOPin : public GPIOPin { +class PCF8574GPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/pcm5122/pcm5122.h b/esphome/components/pcm5122/pcm5122.h index f86b096c82..3c42e4d8d2 100644 --- a/esphome/components/pcm5122/pcm5122.h +++ b/esphome/components/pcm5122/pcm5122.h @@ -41,7 +41,7 @@ enum PCM5122BitsPerSample : uint8_t { PCM5122_BITS_PER_SAMPLE_32 = 32, }; -class PCM5122 : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { +class PCM5122 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/pcm5122/pcm5122_gpio.h b/esphome/components/pcm5122/pcm5122_gpio.h index 8edaa6d3e8..0c750ab278 100644 --- a/esphome/components/pcm5122/pcm5122_gpio.h +++ b/esphome/components/pcm5122/pcm5122_gpio.h @@ -6,7 +6,7 @@ namespace esphome::pcm5122 { -class PCM5122GPIOPin : public GPIOPin, public Parented { +class PCM5122GPIOPin final : public GPIOPin, public Parented { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h index 6225956430..9909dc2217 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h @@ -6,9 +6,9 @@ #include "esphome/core/hal.h" namespace esphome::pi4ioe5v6408 { -class PI4IOE5V6408Component : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander { +class PI4IOE5V6408Component final : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { public: PI4IOE5V6408Component() = default; @@ -49,7 +49,7 @@ class PI4IOE5V6408Component : public Component, bool read_gpio_outputs_(); }; -class PI4IOE5V6408GPIOPin : public GPIOPin, public Parented { +class PI4IOE5V6408GPIOPin final : public GPIOPin, public Parented { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/pid/pid_climate.h b/esphome/components/pid/pid_climate.h index 9e3c89ca4d..7269709ab9 100644 --- a/esphome/components/pid/pid_climate.h +++ b/esphome/components/pid/pid_climate.h @@ -11,7 +11,7 @@ namespace esphome::pid { -class PIDClimate : public climate::Climate, public Component { +class PIDClimate final : public climate::Climate, public Component { public: PIDClimate() = default; void setup() override; @@ -108,7 +108,7 @@ class PIDClimate : public climate::Climate, public Component { bool do_publish_ = false; }; -template class PIDAutotuneAction : public Action { +template class PIDAutotuneAction final : public Action { public: PIDAutotuneAction(PIDClimate *parent) : parent_(parent) {} @@ -131,7 +131,7 @@ template class PIDAutotuneAction : public Action { PIDClimate *parent_; }; -template class PIDResetIntegralTermAction : public Action { +template class PIDResetIntegralTermAction final : public Action { public: PIDResetIntegralTermAction(PIDClimate *parent) : parent_(parent) {} @@ -141,7 +141,7 @@ template class PIDResetIntegralTermAction : public Action PIDClimate *parent_; }; -template class PIDSetControlParametersAction : public Action { +template class PIDSetControlParametersAction final : public Action { public: PIDSetControlParametersAction(PIDClimate *parent) : parent_(parent) {} diff --git a/esphome/components/pid/sensor/pid_climate_sensor.h b/esphome/components/pid/sensor/pid_climate_sensor.h index d6bdc66a46..b62d597780 100644 --- a/esphome/components/pid/sensor/pid_climate_sensor.h +++ b/esphome/components/pid/sensor/pid_climate_sensor.h @@ -18,7 +18,7 @@ enum PIDClimateSensorType { PID_SENSOR_TYPE_KD, }; -class PIDClimateSensor : public sensor::Sensor, public Component { +class PIDClimateSensor final : public sensor::Sensor, public Component { public: void setup() override; void set_parent(PIDClimate *parent) { parent_ = parent; } diff --git a/esphome/components/pipsolar/output/pipsolar_output.h b/esphome/components/pipsolar/output/pipsolar_output.h index 4a6e4c29d7..6fc013c276 100644 --- a/esphome/components/pipsolar/output/pipsolar_output.h +++ b/esphome/components/pipsolar/output/pipsolar_output.h @@ -10,7 +10,7 @@ namespace esphome::pipsolar { class Pipsolar; -class PipsolarOutput : public output::FloatOutput { +class PipsolarOutput final : public output::FloatOutput { public: PipsolarOutput() {} void set_parent(Pipsolar *parent) { this->parent_ = parent; } @@ -27,7 +27,7 @@ class PipsolarOutput : public output::FloatOutput { std::vector possible_values_; }; -template class SetOutputAction : public Action { +template class SetOutputAction final : public Action { public: SetOutputAction(PipsolarOutput *output) : output_(output) {} diff --git a/esphome/components/pipsolar/pipsolar.h b/esphome/components/pipsolar/pipsolar.h index 59332080cf..06c920a6e4 100644 --- a/esphome/components/pipsolar/pipsolar.h +++ b/esphome/components/pipsolar/pipsolar.h @@ -56,7 +56,7 @@ struct QFLAGValues { PIPSOLAR_ENTITY_(binary_sensor::BinarySensor, name, polling_command) #define PIPSOLAR_TEXT_SENSOR(name, polling_command) PIPSOLAR_ENTITY_(text_sensor::TextSensor, name, polling_command) -class Pipsolar : public uart::UARTDevice, public PollingComponent { +class Pipsolar final : public uart::UARTDevice, public PollingComponent { // QPIGS values PIPSOLAR_SENSOR(grid_voltage, QPIGS) PIPSOLAR_SENSOR(grid_frequency, QPIGS) diff --git a/esphome/components/pipsolar/switch/pipsolar_switch.h b/esphome/components/pipsolar/switch/pipsolar_switch.h index 20d2640d90..2b8cda9d59 100644 --- a/esphome/components/pipsolar/switch/pipsolar_switch.h +++ b/esphome/components/pipsolar/switch/pipsolar_switch.h @@ -6,7 +6,7 @@ namespace esphome::pipsolar { class Pipsolar; -class PipsolarSwitch : public switch_::Switch, public Component { +class PipsolarSwitch final : public switch_::Switch, public Component { public: void set_parent(Pipsolar *parent) { this->parent_ = parent; } void set_on_command(const char *command) { this->on_command_ = command; } diff --git a/esphome/components/pm1006/pm1006.h b/esphome/components/pm1006/pm1006.h index 38ab284f47..b32bb2ba8e 100644 --- a/esphome/components/pm1006/pm1006.h +++ b/esphome/components/pm1006/pm1006.h @@ -7,7 +7,7 @@ namespace esphome::pm1006 { -class PM1006Component : public PollingComponent, public uart::UARTDevice { +class PM1006Component final : public PollingComponent, public uart::UARTDevice { public: PM1006Component() = default; diff --git a/esphome/components/pm2005/pm2005.h b/esphome/components/pm2005/pm2005.h index 9661d082d1..e4ab9ff328 100644 --- a/esphome/components/pm2005/pm2005.h +++ b/esphome/components/pm2005/pm2005.h @@ -11,7 +11,7 @@ enum SensorType { PM2105, }; -class PM2005Component : public PollingComponent, public i2c::I2CDevice { +class PM2005Component final : public PollingComponent, public i2c::I2CDevice { public: void set_sensor_type(SensorType sensor_type) { this->sensor_type_ = sensor_type; } From cbcf23426d8d64069d4da7f0e96a0065aa9750d6 Mon Sep 17 00:00:00 2001 From: Anton Viktorov Date: Wed, 24 Jun 2026 10:08:59 +0000 Subject: [PATCH 0559/1815] [waveshare_io_ch32v003] Waveshare I/O Expander component (#10071) --- CODEOWNERS | 1 + .../waveshare_io_ch32v003/__init__.py | 84 +++++++++ .../waveshare_io_ch32v003/output/__init__.py | 70 ++++++++ .../output/waveshare_io_ch32v003_output.cpp | 19 ++ .../output/waveshare_io_ch32v003_output.h | 22 +++ .../waveshare_io_ch32v003/sensor/__init__.py | 55 ++++++ .../sensor/waveshare_io_ch32v003_sensor.cpp | 27 +++ .../sensor/waveshare_io_ch32v003_sensor.h | 29 +++ .../waveshare_io_ch32v003.cpp | 168 ++++++++++++++++++ .../waveshare_io_ch32v003.h | 65 +++++++ .../waveshare_io_ch32v003/common.yaml | 33 ++++ .../waveshare_io_ch32v003/test.esp32-idf.yaml | 4 + 12 files changed, 577 insertions(+) create mode 100644 esphome/components/waveshare_io_ch32v003/__init__.py create mode 100644 esphome/components/waveshare_io_ch32v003/output/__init__.py create mode 100644 esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.cpp create mode 100644 esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.h create mode 100644 esphome/components/waveshare_io_ch32v003/sensor/__init__.py create mode 100644 esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp create mode 100644 esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.h create mode 100644 esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp create mode 100644 esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h create mode 100644 tests/components/waveshare_io_ch32v003/common.yaml create mode 100644 tests/components/waveshare_io_ch32v003/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index d425614582..70ad580e77 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -578,6 +578,7 @@ esphome/components/wake_on_lan/* @clydebarrow @willwill2will54 esphome/components/watchdog/* @oarcher esphome/components/water_heater/* @dhoeben esphome/components/waveshare_epaper/* @clydebarrow +esphome/components/waveshare_io_ch32v003/* @latonita esphome/components/web_server/ota/* @esphome/core esphome/components/web_server_base/* @esphome/core esphome/components/web_server_idf/* @dentra diff --git a/esphome/components/waveshare_io_ch32v003/__init__.py b/esphome/components/waveshare_io_ch32v003/__init__.py new file mode 100644 index 0000000000..b692b858a3 --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/__init__.py @@ -0,0 +1,84 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_INPUT, + CONF_INVERTED, + CONF_MODE, + CONF_NUMBER, + CONF_OUTPUT, +) + +CODEOWNERS = ["@latonita"] + +AUTO_LOAD = ["gpio_expander"] +DEPENDENCIES = ["i2c"] +MULTI_CONF = True + +waveshare_io_ch32v003_ns = cg.esphome_ns.namespace("waveshare_io_ch32v003") + +WaveshareIOCH32V003Component = waveshare_io_ch32v003_ns.class_( + "WaveshareIOCH32V003Component", cg.Component, i2c.I2CDevice +) +WaveshareIOCH32V003GPIOPin = waveshare_io_ch32v003_ns.class_( + "WaveshareIOCH32V003GPIOPin", + cg.GPIOPin, + cg.Parented.template(WaveshareIOCH32V003Component), +) + +CONF_WAVESHARE_IO_CH32V003 = "waveshare_io_ch32v003" +CONF_WAVESHARE_IO_CH32V003_ID = "waveshare_io_ch32v003_id" +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(CONF_ID): cv.declare_id(WaveshareIOCH32V003Component), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x24)) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + +def validate_mode(value): + if not (value[CONF_INPUT] or value[CONF_OUTPUT]): + raise cv.Invalid("Mode must be either input or output") + if value[CONF_INPUT] and value[CONF_OUTPUT]: + raise cv.Invalid("Mode must be either input or output") + return value + + +WAVESHARE_IO_PIN_SCHEMA = pins.gpio_base_schema( + WaveshareIOCH32V003GPIOPin, + cv.int_range(min=0, max=7), + modes=[CONF_INPUT, CONF_OUTPUT], + mode_validator=validate_mode, + invertible=True, +).extend( + { + cv.Required(CONF_WAVESHARE_IO_CH32V003): cv.use_id( + WaveshareIOCH32V003Component + ), + } +) + + +@pins.PIN_SCHEMA_REGISTRY.register(CONF_WAVESHARE_IO_CH32V003, WAVESHARE_IO_PIN_SCHEMA) +async def waveshare_io_pin_to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + parent = await cg.get_variable(config[CONF_WAVESHARE_IO_CH32V003]) + + cg.add(var.set_parent(parent)) + + num = config[CONF_NUMBER] + cg.add(var.set_pin(num)) + cg.add(var.set_inverted(config[CONF_INVERTED])) + cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) + return var diff --git a/esphome/components/waveshare_io_ch32v003/output/__init__.py b/esphome/components/waveshare_io_ch32v003/output/__init__.py new file mode 100644 index 0000000000..9af9ce7e4b --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/output/__init__.py @@ -0,0 +1,70 @@ +import esphome.codegen as cg +from esphome.components import output +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_MAX_VALUE, CONF_MIN_VALUE + +from .. import ( + CONF_WAVESHARE_IO_CH32V003_ID, + WaveshareIOCH32V003Component, + waveshare_io_ch32v003_ns, +) + +DEPENDENCIES = ["waveshare_io_ch32v003"] + +WaveshareIOCH32V003Output = waveshare_io_ch32v003_ns.class_( + "WaveshareIOCH32V003Output", + output.FloatOutput, + cg.Parented.template(WaveshareIOCH32V003Component), +) + +CONF_SAFE_PWM_LEVELS = "safe_pwm_levels" + +DUTY_DEFAULT_MIN = 1 +DUTY_DEFAULT_MAX = 247 + + +def validate_pwm_limits(config): + """Validate that safe_pwm_levels.min_value <= safe_pwm_levels.max_value.""" + + min_val = config.get(CONF_SAFE_PWM_LEVELS, {}).get(CONF_MIN_VALUE, DUTY_DEFAULT_MIN) + max_val = config.get(CONF_SAFE_PWM_LEVELS, {}).get(CONF_MAX_VALUE, DUTY_DEFAULT_MAX) + if min_val > max_val: + raise cv.Invalid( + f"safe_pwm_levels.min_value ({min_val}) cannot be greater than " + f"safe_pwm_levels.max_value ({max_val})" + ) + return config + + +CONF_SAFE_PWM_LEVELS_SCHEMA = cv.Schema( + { + cv.Optional(CONF_MIN_VALUE, default=DUTY_DEFAULT_MIN): cv.int_range( + min=0, max=255 + ), + cv.Optional(CONF_MAX_VALUE, default=DUTY_DEFAULT_MAX): cv.int_range( + min=0, max=255 + ), + } +) + +CONFIG_SCHEMA = cv.All( + output.FLOAT_OUTPUT_SCHEMA.extend( + { + cv.Required(CONF_ID): cv.declare_id(WaveshareIOCH32V003Output), + cv.GenerateID(CONF_WAVESHARE_IO_CH32V003_ID): cv.use_id( + WaveshareIOCH32V003Component + ), + cv.Optional(CONF_SAFE_PWM_LEVELS): CONF_SAFE_PWM_LEVELS_SCHEMA, + } + ), + validate_pwm_limits, +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await output.register_output(var, config) + await cg.register_parented(var, config[CONF_WAVESHARE_IO_CH32V003_ID]) + min_val = config.get(CONF_SAFE_PWM_LEVELS, {}).get(CONF_MIN_VALUE, DUTY_DEFAULT_MIN) + max_val = config.get(CONF_SAFE_PWM_LEVELS, {}).get(CONF_MAX_VALUE, DUTY_DEFAULT_MAX) + cg.add(var.set_pwm_safe_range(min_val, max_val)) diff --git a/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.cpp b/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.cpp new file mode 100644 index 0000000000..30458310ce --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.cpp @@ -0,0 +1,19 @@ +#include "waveshare_io_ch32v003_output.h" +#include "esphome/core/log.h" +#include + +namespace esphome::waveshare_io_ch32v003 { + +static const char *const TAG = "waveshare_io_ch32v003.output"; + +void WaveshareIOCH32V003Output::write_state(float state) { + uint8_t pwm_value = static_cast(state * 255.0f); + uint8_t final_pwm_value = std::clamp(pwm_value, this->pwm_min_value_, this->pwm_max_value_); + if (final_pwm_value != pwm_value) { + ESP_LOGVV(TAG, "Clamping PWM value %u to safe range [%u, %u]", pwm_value, this->pwm_min_value_, + this->pwm_max_value_); + } + this->parent_->set_pwm_value(final_pwm_value); +} + +} // namespace esphome::waveshare_io_ch32v003 diff --git a/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.h b/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.h new file mode 100644 index 0000000000..abe8183692 --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.h @@ -0,0 +1,22 @@ +#pragma once + +#include "../waveshare_io_ch32v003.h" +#include "esphome/components/output/float_output.h" + +namespace esphome::waveshare_io_ch32v003 { + +class WaveshareIOCH32V003Output : public output::FloatOutput, public Parented { + public: + void set_pwm_safe_range(uint8_t min_value, uint8_t max_value) { + this->pwm_min_value_ = min_value; + this->pwm_max_value_ = max_value; + } + + protected: + void write_state(float state) override; + + uint8_t pwm_min_value_{1}; + uint8_t pwm_max_value_{247}; +}; + +} // namespace esphome::waveshare_io_ch32v003 diff --git a/esphome/components/waveshare_io_ch32v003/sensor/__init__.py b/esphome/components/waveshare_io_ch32v003/sensor/__init__.py new file mode 100644 index 0000000000..1e060bdfe4 --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/sensor/__init__.py @@ -0,0 +1,55 @@ +import esphome.codegen as cg +from esphome.components import sensor, voltage_sampler +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_REFERENCE_VOLTAGE, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + UNIT_VOLT, +) + +from .. import ( + CONF_WAVESHARE_IO_CH32V003_ID, + WaveshareIOCH32V003Component, + waveshare_io_ch32v003_ns, +) + +AUTO_LOAD = ["voltage_sampler"] +DEPENDENCIES = ["waveshare_io_ch32v003"] + +WaveshareIOCH32V003Sensor = waveshare_io_ch32v003_ns.class_( + "WaveshareIOCH32V003Sensor", + sensor.Sensor, + cg.PollingComponent, + voltage_sampler.VoltageSampler, + cg.Parented.template(WaveshareIOCH32V003Component), +) + +CONFIG_SCHEMA = ( + sensor.sensor_schema( + WaveshareIOCH32V003Sensor, + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ) + .extend( + { + cv.GenerateID(CONF_WAVESHARE_IO_CH32V003_ID): cv.use_id( + WaveshareIOCH32V003Component + ), + cv.Optional(CONF_REFERENCE_VOLTAGE, default="9.9V"): cv.voltage, + } + ) + .extend(cv.polling_component_schema("60s")) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_parented(var, config[CONF_WAVESHARE_IO_CH32V003_ID]) + await cg.register_component(var, config) + await sensor.register_sensor(var, config) + + cg.add(var.set_reference_voltage(config[CONF_REFERENCE_VOLTAGE])) diff --git a/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp new file mode 100644 index 0000000000..82da7451f9 --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp @@ -0,0 +1,27 @@ +#include "waveshare_io_ch32v003_sensor.h" + +#include "esphome/core/log.h" + +namespace esphome::waveshare_io_ch32v003 { + +static const char *const TAG = "waveshare_io_ch32v003.sensor"; + +float WaveshareIOCH32V003Sensor::get_setup_priority() const { return setup_priority::DATA; } + +void WaveshareIOCH32V003Sensor::dump_config() { + ESP_LOGCONFIG(TAG, + "WaveshareIOCH32V003Sensor:\n" + " Reference Voltage: %.2fV", + this->reference_voltage_); +} + +float WaveshareIOCH32V003Sensor::sample() { + uint16_t adc_value = this->parent_->get_adc_value(); + // Convert the ADC value to voltage. 10-bit ADC + float voltage = adc_value * this->reference_voltage_ / 1023.0f; + return voltage; +} + +void WaveshareIOCH32V003Sensor::update() { this->publish_state(this->sample()); } + +} // namespace esphome::waveshare_io_ch32v003 diff --git a/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.h b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.h new file mode 100644 index 0000000000..01beab5137 --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.h @@ -0,0 +1,29 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/voltage_sampler/voltage_sampler.h" + +#include "../waveshare_io_ch32v003.h" + +namespace esphome::waveshare_io_ch32v003 { + +class WaveshareIOCH32V003Sensor : public sensor::Sensor, + public PollingComponent, + public voltage_sampler::VoltageSampler, + public Parented { + public: + void set_reference_voltage(float reference_voltage) { this->reference_voltage_ = reference_voltage; } + + void update() override; + void dump_config() override; + float get_setup_priority() const override; + float sample() override; + + protected: + float reference_voltage_{9.9f}; // Default reference voltage for ADC calculations, can be overridden by user config +}; + +} // namespace esphome::waveshare_io_ch32v003 diff --git a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp new file mode 100644 index 0000000000..8a58c7e7bb --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp @@ -0,0 +1,168 @@ +#include "waveshare_io_ch32v003.h" +#include "esphome/core/log.h" + +namespace esphome::waveshare_io_ch32v003 { + +static const uint8_t IO_EXTENSION_DIRECTION = 0x02; +static const uint8_t IO_EXTENSION_IO_OUTPUT_ADDR = 0x03; +static const uint8_t IO_EXTENSION_IO_INPUT_ADDR = 0x04; +static const uint8_t IO_EXTENSION_PWM_ADDR = 0x05; +static const uint8_t IO_EXTENSION_ADC_ADDR = 0x06; +static const uint8_t IO_EXTENSION_RTC_INT_ADDR = 0x07; + +static const char *const TAG = "waveshare_io_ch32v003"; + +void WaveshareIOCH32V003Component::setup() { + this->mode_mask_ = 0xFF; // Set all pins to output mode + this->output_mask_ = 0xFF; // Set all pins to high (output mode) + + bool step1 = this->write_gpio_modes_(); + bool step2 = this->write_gpio_outputs_(); + + if (!step1 || !step2) { + ESP_LOGE(TAG, "Failed to initialize Waveshare IO expander"); + this->mark_failed(); + return; + } + + this->disable_loop(); +} + +void WaveshareIOCH32V003Component::pin_mode(uint8_t pin, gpio::Flags flags) { + // bits: 0 = input, 1 = output + if (flags == gpio::FLAG_INPUT) { + // Clear mode mask bit + this->mode_mask_ &= ~(1 << pin); + this->enable_loop(); + } else if (flags == gpio::FLAG_OUTPUT) { + // Set mode mask bit + this->mode_mask_ |= 1 << pin; + } + this->write_gpio_modes_(); +} + +void WaveshareIOCH32V003Component::loop() { this->reset_pin_cache_(); } + +void WaveshareIOCH32V003Component::dump_config() { + ESP_LOGCONFIG(TAG, "WaveshareIO:"); + LOG_I2C_DEVICE(this) + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +uint16_t WaveshareIOCH32V003Component::get_adc_value() { + if (this->is_failed()) + return 0; + + uint8_t data[2]; + if (!this->read_bytes(IO_EXTENSION_ADC_ADDR, data, 2)) { + this->status_set_warning(LOG_STR("Failed to read ADC register")); + return 0; + } + uint16_t adc_value = (data[1] << 8) | data[0]; + this->status_clear_warning(); + return adc_value; +} + +uint8_t WaveshareIOCH32V003Component::get_rtc_interrupt_status() { + if (this->is_failed()) + return 0; + + uint8_t data = 0; + if (!this->read_bytes(IO_EXTENSION_RTC_INT_ADDR, &data, 1)) { + this->status_set_warning(LOG_STR("Failed to read RTC interrupt register")); + return 0; + } + this->status_clear_warning(); + return data; +} + +void WaveshareIOCH32V003Component::set_pwm_value(uint8_t value) { + if (this->is_failed()) + return; + + // PWM limits are enforced at the output component level to protect hardware + // based on circuit schematic requirements. This follows the pattern from the + // original Waveshare IO library function "void IO_EXTENSION_Pwm_Output(uint8_t Value)". + + if (!this->write_byte(IO_EXTENSION_PWM_ADDR, value)) { + this->status_set_warning(LOG_STR("Failed to set PWM duty cycle")); + return; + } + + this->status_clear_warning(); +} + +bool WaveshareIOCH32V003Component::write_gpio_modes_() { + if (this->is_failed()) + return false; + if (!this->write_byte(IO_EXTENSION_DIRECTION, this->mode_mask_)) { + this->status_set_warning(LOG_STR("Failed to write mode register")); + return false; + } + this->status_clear_warning(); + return true; +} + +bool WaveshareIOCH32V003Component::write_gpio_outputs_() { + if (this->is_failed()) + return false; + if (!this->write_byte(IO_EXTENSION_IO_OUTPUT_ADDR, this->output_mask_)) { + this->status_set_warning(LOG_STR("Failed to write output register")); + return false; + } + this->status_clear_warning(); + return true; +} + +bool WaveshareIOCH32V003Component::digital_read_hw(uint8_t pin) { + if (this->is_failed()) + return false; + + uint8_t data = 0; + if (!this->read_bytes(IO_EXTENSION_IO_INPUT_ADDR, &data, 1)) { + this->status_set_warning(LOG_STR("Failed to read input register")); + return false; + } + this->input_mask_ = data; + + this->status_clear_warning(); + return true; +} + +void WaveshareIOCH32V003Component::digital_write_hw(uint8_t pin, bool value) { + if (this->is_failed()) + return; + + if (value) { + this->output_mask_ |= (1 << pin); + } else { + this->output_mask_ &= ~(1 << pin); + } + + uint8_t data = this->output_mask_; + if (!this->write_byte(IO_EXTENSION_IO_OUTPUT_ADDR, data)) { + this->status_set_warning(LOG_STR("Failed to write output register")); + return; + } + + this->status_clear_warning(); +} + +bool WaveshareIOCH32V003Component::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } +float WaveshareIOCH32V003Component::get_setup_priority() const { return setup_priority::IO; } + +void WaveshareIOCH32V003GPIOPin::setup() { this->pin_mode(this->flags_); } +void WaveshareIOCH32V003GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } +bool WaveshareIOCH32V003GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) ^ this->inverted_; } + +void WaveshareIOCH32V003GPIOPin::digital_write(bool value) { + this->parent_->digital_write(this->pin_, value ^ this->inverted_); +} + +size_t WaveshareIOCH32V003GPIOPin::dump_summary(char *buffer, size_t len) const { + return buf_append_printf(buffer, len, 0, "EXIO%u via WaveshareIO", this->pin_); +} + +} // namespace esphome::waveshare_io_ch32v003 diff --git a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h new file mode 100644 index 0000000000..4a31602fa4 --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h @@ -0,0 +1,65 @@ +#pragma once + +#include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" + +namespace esphome::waveshare_io_ch32v003 { + +class WaveshareIOCH32V003Component : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { + public: + WaveshareIOCH32V003Component() = default; + + void setup() override; + void pin_mode(uint8_t pin, gpio::Flags flags); + + float get_setup_priority() const override; + + void dump_config() override; + + void loop() override; + + uint16_t get_adc_value(); + uint8_t get_rtc_interrupt_status(); + void set_pwm_value(uint8_t value); // 0 - 255 + + protected: + friend class WaveshareIOCH32V003GPIOPin; + + bool digital_read_hw(uint8_t pin) override; + bool digital_read_cache(uint8_t pin) override; + void digital_write_hw(uint8_t pin, bool value) override; + + uint8_t mode_mask_{0x00}; // Mask for the pin mode - 1 means output, 0 means input + uint8_t output_mask_{0x00}; // The mask to write as output state - 1 means HIGH, 0 means LOW + uint8_t input_mask_{0x00}; // The state read in digital_read_hw - 1 means HIGH, 0 means LOW + + bool write_gpio_modes_(); + bool write_gpio_outputs_(); +}; + +/// Helper class to expose a WaveshareIO pin as a GPIO pin. +class WaveshareIOCH32V003GPIOPin : public GPIOPin, public Parented { + public: + void setup() override; + void pin_mode(gpio::Flags flags) override; + bool digital_read() override; + void digital_write(bool value) override; + size_t dump_summary(char *buffer, size_t len) const override; + + void set_pin(uint8_t pin) { this->pin_ = pin; } + void set_inverted(bool inverted) { this->inverted_ = inverted; } + void set_flags(gpio::Flags flags) { this->flags_ = flags; } + + gpio::Flags get_flags() const override { return this->flags_; } + + protected: + uint8_t pin_{}; + bool inverted_{}; + gpio::Flags flags_{}; +}; + +} // namespace esphome::waveshare_io_ch32v003 diff --git a/tests/components/waveshare_io_ch32v003/common.yaml b/tests/components/waveshare_io_ch32v003/common.yaml new file mode 100644 index 0000000000..086b27ab96 --- /dev/null +++ b/tests/components/waveshare_io_ch32v003/common.yaml @@ -0,0 +1,33 @@ +waveshare_io_ch32v003: + - id: wave_io + address: 0x24 + +binary_sensor: + - platform: gpio + id: wave_io_binary_sensor + pin: + waveshare_io_ch32v003: wave_io + number: 3 + mode: INPUT + inverted: false + +output: + - platform: gpio + id: wave_io_output + pin: + waveshare_io_ch32v003: wave_io + number: 0 + mode: OUTPUT + inverted: false + + - platform: waveshare_io_ch32v003 + id: wave_io_pwm_output + inverted: true + zero_means_zero: true + safe_pwm_levels: + min_value: 0 + max_value: 247 + +sensor: + - platform: waveshare_io_ch32v003 + id: wave_io_adc diff --git a/tests/components/waveshare_io_ch32v003/test.esp32-idf.yaml b/tests/components/waveshare_io_ch32v003/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/waveshare_io_ch32v003/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml From 18f29f8d2b78f2e6fbceb1a2bab34c95dce73032 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:05:23 +1000 Subject: [PATCH 0560/1815] [mipi_spi] Suppress sequence errors when page selection used (#17176) --- esphome/components/mipi/__init__.py | 1 + esphome/components/mipi_spi/display.py | 20 ++-- esphome/components/mipi_spi/mipi_spi.h | 8 -- .../mipi_spi/test_page_selection.py | 113 ++++++++++++++++++ 4 files changed, 126 insertions(+), 16 deletions(-) create mode 100644 tests/component_tests/mipi_spi/test_page_selection.py diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index caa33cd834..2244a316b7 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -120,6 +120,7 @@ CSCON = 0xF0 PWCTR6 = 0xF6 ADJCTL3 = 0xF7 PAGESEL = 0xFE +PAGESEL1 = 0xFF MADCTL_MY = 0x80 # Bit 7 Bottom to top MADCTL_MX = 0x40 # Bit 6 Right to left diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index d613d0a1ab..0231d12529 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -17,6 +17,8 @@ from esphome.components.mipi import ( MADCTL, MODE_BGR, MODE_RGB, + PAGESEL, + PAGESEL1, PIXFMT, DriverChip, dimension_schema, @@ -276,14 +278,16 @@ def customise_schema(config): # Check for invalid combinations of MADCTL config if init_sequence := config.get(CONF_INIT_SEQUENCE): commands = [x[0] for x in init_sequence] - if MADCTL in commands and CONF_TRANSFORM in config: - raise cv.Invalid( - f"transform is not supported when MADCTL ({MADCTL:#X}) is in the init sequence" - ) - if PIXFMT in commands: - raise cv.Invalid( - f"PIXFMT ({PIXFMT:#X}) should not be in the init sequence, it will be set automatically" - ) + # If there is page swapping, we can't rely on recognising common commands + if PAGESEL not in commands and PAGESEL1 not in commands: + if MADCTL in commands and CONF_TRANSFORM in config: + raise cv.Invalid( + f"transform is not supported when MADCTL ({MADCTL:#X}) is in the init sequence" + ) + if PIXFMT in commands: + raise cv.Invalid( + f"PIXFMT ({PIXFMT:#X}) should not be in the init sequence, it will be set automatically" + ) if bus_mode == TYPE_QUAD and CONF_DC_PIN in config: raise cv.Invalid("DC pin is not supported in quad mode") diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index a594e48209..d9627899e0 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -176,7 +176,6 @@ class MipiSpi : public display::Display, this->mark_failed(); return; } - auto arg_byte = vec[index]; switch (cmd) { case SLEEP_OUT: { // are we ready, boots? @@ -187,13 +186,6 @@ class MipiSpi : public display::Display, } } break; - case INVERT_ON: - this->invert_colors_ = true; - break; - case BRIGHTNESS: - this->brightness_ = arg_byte; - break; - default: break; } diff --git a/tests/component_tests/mipi_spi/test_page_selection.py b/tests/component_tests/mipi_spi/test_page_selection.py new file mode 100644 index 0000000000..4b1ec22271 --- /dev/null +++ b/tests/component_tests/mipi_spi/test_page_selection.py @@ -0,0 +1,113 @@ +"""Combined tests for PAGESEL/PAGESEL1 behaviour with MADCTL/PIXFMT. + +Covers both the suppression behaviour (when PAGESEL or PAGESEL1 are present) +and the error behaviour when neither page-selection command is present. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.mipi import MADCTL, PAGESEL, PAGESEL1, PIXFMT +from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA +import esphome.config_validation as cv +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def validated_config(config: dict[str, Any]) -> dict[str, Any]: + """Run schema + final validation and return the validated config.""" + cfg = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(cfg) + return cfg + + +def test_madctl_error_suppressed_when_pagesel_present( + set_core_config: SetCoreConfigCallable, +) -> None: + """If PAGESEL is present in init_sequence, MADCTL presence must not raise an error.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + cfg = { + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": 320, "height": 240}, + "transform": {"mirror_x": True, "mirror_y": True, "swap_xy": False}, + "init_sequence": [[PAGESEL, 0x00], [MADCTL, 0x01]], + } + + # Should not raise + validated = validated_config(cfg) + assert validated is not None + + +def test_pixfmt_error_suppressed_when_pagesel1_present( + set_core_config: SetCoreConfigCallable, +) -> None: + """If PAGESEL1 is present in init_sequence, PIXFMT presence must not raise an error.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + cfg = { + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": 320, "height": 240}, + "init_sequence": [[PAGESEL1, 0x00], [PIXFMT, 0x01]], + } + + # Should not raise + validated = validated_config(cfg) + assert validated is not None + + +def test_madctl_raises_without_pagesel( + set_core_config: SetCoreConfigCallable, +) -> None: + """MADCTL in the init_sequence should raise when a transform is configured and + no PAGESEL/PAGESEL1 is present. + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + cfg: dict[str, Any] = { + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": 320, "height": 240}, + "transform": {"mirror_x": True, "mirror_y": True, "swap_xy": False}, + "init_sequence": [[MADCTL, 0x01]], + } + + with pytest.raises(cv.Invalid, match=r"MADCTL .* in the init sequence"): + CONFIG_SCHEMA(cfg) + + +def test_pixfmt_raises_without_pagesel1( + set_core_config: SetCoreConfigCallable, +) -> None: + """PIXFMT in the init_sequence should raise when no PAGESEL/PAGESEL1 is present.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + cfg: dict[str, Any] = { + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": 320, "height": 240}, + "init_sequence": [[PIXFMT, 0x01]], + } + + with pytest.raises( + cv.Invalid, match=r"PIXFMT .* should not be in the init sequence" + ): + CONFIG_SCHEMA(cfg) From e8acd24fd9f2d8f111195a6d62996065f1cb2b49 Mon Sep 17 00:00:00 2001 From: Geoffrey Frogeye Date: Wed, 24 Jun 2026 15:29:57 +0200 Subject: [PATCH 0561/1815] [opentherm] Support power scaling disabled (#17183) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../components/opentherm/output/opentherm_output.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/esphome/components/opentherm/output/opentherm_output.cpp b/esphome/components/opentherm/output/opentherm_output.cpp index 2735c85d06..4092358d75 100644 --- a/esphome/components/opentherm/output/opentherm_output.cpp +++ b/esphome/components/opentherm/output/opentherm_output.cpp @@ -7,9 +7,13 @@ static const char *const TAG = "opentherm.output"; void opentherm::OpenthermOutput::write_state(float state) { ESP_LOGD(TAG, "Received state: %.2f. Min value: %.2f, max value: %.2f", state, min_value_, max_value_); - this->state = state < 0.003 && this->zero_means_zero_ - ? 0.0 - : clamp(std::lerp(min_value_, max_value_, state), min_value_, max_value_); +#ifdef USE_OUTPUT_FLOAT_POWER_SCALING + bool zero_means_zero = this->zero_means_zero_; +#else + bool zero_means_zero = false; +#endif + this->state = + state < 0.003 && zero_means_zero ? 0.0 : clamp(std::lerp(min_value_, max_value_, state), min_value_, max_value_); this->has_state_ = true; ESP_LOGD(TAG, "Output %s set to %.2f", this->id_, this->state); } From f471329d606017e0f1df58a3d153486c45de22d0 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:15:00 +0200 Subject: [PATCH 0562/1815] Bump bundled esphome-device-builder to 1.0.16 (#17182) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 1cd3372255..c4a49b778f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.15 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.16 RUN \ platformio settings set enable_telemetry No \ From 72b663fc40b6e7fe647ccf111c7e2ce0b48a9ce9 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 02:04:22 +0200 Subject: [PATCH 0563/1815] Bump bundled esphome-device-builder to 1.0.17 (#17199) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c4a49b778f..c02aba093c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.16 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.17 RUN \ platformio settings set enable_telemetry No \ From b68847444440bd6986d2d5ac48a7156bfdfc63a4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 02:04:37 +0200 Subject: [PATCH 0564/1815] Bump ruff from 0.15.18 to 0.15.19 (#17195) Signed-off-by: dependabot[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 4e498abc21..6e53a4c14f 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.6 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.18 # also change in .pre-commit-config.yaml when updating +ruff==0.15.19 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From fa34c679500f4b076e12c9908204bfb34a8d723d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 02:04:52 +0200 Subject: [PATCH 0565/1815] Bump CodSpeedHQ/action from 4.17.6 to 4.18.1 (#17198) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a7233b3a8..f8c1410cec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -456,7 +456,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@63f3e98b61959fe67f146a3ff022e4136fe9bb9c # v4.17.6 + uses: CodSpeedHQ/action@a4a36bb07c0638b0b4ca52bf1f3dad1b4289e52f # v4.18.1 with: run: | . venv/bin/activate From 155439be74710c8227ccf8b9442415b93d7e11bf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 02:05:13 +0200 Subject: [PATCH 0566/1815] Bump actions/setup-python from 6.2.0 to 6.3.0 (#17197) Signed-off-by: dependabot[bot] --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci-docker.yml | 4 ++-- .github/workflows/ci.yml | 6 +++--- .github/workflows/release.yml | 4 ++-- .github/workflows/sync-device-classes.yml | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 2155b67b25..17234e811a 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -23,7 +23,7 @@ jobs: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.11" - name: Set up uv diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 8301f8e9e3..9678831b50 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -63,7 +63,7 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.11" - name: Set up Docker Buildx @@ -147,7 +147,7 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.11" - name: Set up Docker Buildx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8c1410cec..eaa04ceca6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: 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 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python virtual environment @@ -162,7 +162,7 @@ jobs: ref: main path: device-builder - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.13" - name: Set up uv @@ -360,7 +360,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python 3.13 id: python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.13" - name: Restore Python virtual environment diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3056d9e7d6..2b23b561bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,7 +62,7 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.x" - name: Build @@ -94,7 +94,7 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.11" diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 05036f3500..0501d6d364 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -37,7 +37,7 @@ jobs: path: lib/home-assistant - name: Setup Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.14" From aff5e248edea456706daad0f713dda9a185d6608 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 02:05:30 +0200 Subject: [PATCH 0567/1815] Bump actions/setup-python from 6.2.0 to 6.3.0 in /.github/actions/restore-python (#17194) Signed-off-by: dependabot[bot] --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 96a3be53c6..6290e25d7c 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -17,7 +17,7 @@ runs: steps: - name: Set up Python ${{ inputs.python-version }} id: python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ inputs.python-version }} - name: Restore Python virtual environment From e96717f6cd12e6371bf83fb8e143f494d44973f7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:16:53 -0400 Subject: [PATCH 0568/1815] [waveshare_io_ch32v003] Pin i2c_id in test to avoid grouping conflict (#17191) --- tests/components/waveshare_io_ch32v003/common.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/waveshare_io_ch32v003/common.yaml b/tests/components/waveshare_io_ch32v003/common.yaml index 086b27ab96..c8805583c7 100644 --- a/tests/components/waveshare_io_ch32v003/common.yaml +++ b/tests/components/waveshare_io_ch32v003/common.yaml @@ -1,5 +1,6 @@ waveshare_io_ch32v003: - id: wave_io + i2c_id: i2c_bus address: 0x24 binary_sensor: From 538f554bdb0d384d7627cbdcc2e7772845004a88 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:38:55 -0400 Subject: [PATCH 0569/1815] [psram] Support ESP32-S31/H4 (#17192) --- esphome/components/psram/__init__.py | 12 ++++++++---- tests/component_tests/psram/test_psram.py | 10 +++++++++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index 296ea6c08c..84683e9a25 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -10,9 +10,11 @@ from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32C5, VARIANT_ESP32C61, + VARIANT_ESP32H4, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, add_idf_sdkconfig_option, get_esp32_variant, idf_version, @@ -57,8 +59,10 @@ SPIRAM_MODES = { VARIANT_ESP32: (TYPE_QUAD,), VARIANT_ESP32C5: (TYPE_QUAD,), VARIANT_ESP32C61: (TYPE_QUAD,), + VARIANT_ESP32H4: (TYPE_QUAD,), VARIANT_ESP32S2: (TYPE_QUAD,), VARIANT_ESP32S3: (TYPE_QUAD, TYPE_OCTAL), + VARIANT_ESP32S31: (TYPE_OCTAL,), VARIANT_ESP32P4: (TYPE_HEX,), } @@ -67,8 +71,10 @@ SPIRAM_SPEEDS = { VARIANT_ESP32: (40, 80, 120), VARIANT_ESP32C5: (40, 80, 120), VARIANT_ESP32C61: (40, 80), + VARIANT_ESP32H4: (32, 64), VARIANT_ESP32S2: (40, 80, 120), VARIANT_ESP32S3: (40, 80, 120), + VARIANT_ESP32S31: (40, 100, 200, 250), VARIANT_ESP32P4: (20, 100, 200), } @@ -145,10 +151,8 @@ def validate_psram_mode(config): raise cv.Invalid("ECC is only available in octal mode.") if config[CONF_MODE] == TYPE_OCTAL: variant = get_esp32_variant() - if variant != VARIANT_ESP32S3: - raise cv.Invalid( - f"Octal PSRAM is only supported on ESP32-S3, not {variant}" - ) + if TYPE_OCTAL not in SPIRAM_MODES.get(variant, ()): + raise cv.Invalid(f"Octal PSRAM is not supported on {variant}") return config diff --git a/tests/component_tests/psram/test_psram.py b/tests/component_tests/psram/test_psram.py index ea4adc69a9..4a1ed2c72a 100644 --- a/tests/component_tests/psram/test_psram.py +++ b/tests/component_tests/psram/test_psram.py @@ -12,9 +12,12 @@ from esphome.components.esp32 import ( VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, ) import esphome.config_validation as cv from esphome.const import CONF_ESPHOME, PlatformFramework @@ -25,21 +28,26 @@ UNSUPPORTED_PSRAM_VARIANTS = [ VARIANT_ESP32C3, VARIANT_ESP32C6, VARIANT_ESP32H2, + VARIANT_ESP32H21, ] SUPPORTED_PSRAM_VARIANTS = [ VARIANT_ESP32, VARIANT_ESP32C5, + VARIANT_ESP32H4, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, ] SUPPORTED_PSRAM_MODES = { VARIANT_ESP32: ["quad"], VARIANT_ESP32C5: ["quad"], + VARIANT_ESP32H4: ["quad"], VARIANT_ESP32P4: ["hex"], VARIANT_ESP32S2: ["quad"], VARIANT_ESP32S3: ["quad", "octal"], + VARIANT_ESP32S31: ["octal"], } @@ -187,7 +195,7 @@ def _setup_psram_final_validation_test( {"mode": "octal"}, {"variant": "ESP32"}, True, - r"Octal PSRAM is only supported on ESP32-S3", + r"Octal PSRAM is not supported on ESP32", id="octal_mode_only_esp32s3", ), pytest.param( From 91e515ca7cccb749645551038990911c4cbaf717 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:40:28 -0400 Subject: [PATCH 0570/1815] [esp32] Accept '#' as ESP-IDF source ref separator (#17193) --- esphome/espidf/framework.py | 9 ++++++--- tests/unit_tests/test_espidf_framework.py | 13 +++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index f0715ce3b2..c994ce2410 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -337,16 +337,19 @@ print(".".join([str(x) for x in sys.version_info])) _GITHUB_SHORTHAND_RE = re.compile( - r"^github://([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+?)(?:@([a-zA-Z0-9\-_.\./]+))?$" + r"^github://([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+?)(?:[@#]([a-zA-Z0-9\-_.\./]+))?$" ) _GITHUB_HTTPS_RE = re.compile( - r"^(https://github\.com/[a-zA-Z0-9\-]+/[a-zA-Z0-9\-\._]+?\.git)(?:@([a-zA-Z0-9\-_.\./]+))?$" + r"^(https://github\.com/[a-zA-Z0-9\-]+/[a-zA-Z0-9\-\._]+?\.git)(?:[@#]([a-zA-Z0-9\-_.\./]+))?$" ) def _parse_git_source(source_url: str) -> tuple[str, str | None] | None: """Return ``(url, ref)`` for ``github://owner/repo[@ref]`` or - ``https://github.com/owner/repo.git[@ref]``, else ``None``.""" + ``https://github.com/owner/repo.git[@ref]``, else ``None``. + + The ref may be separated with ``@`` or ``#``; ``#`` matches the PlatformIO + convention used for ``platform_version`` URLs.""" if m := _GITHUB_SHORTHAND_RE.match(source_url): owner, repo, ref = m.group(1), m.group(2), m.group(3) # Tolerate a trailing ".git" on the shorthand repo so the diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index b5fa0e2698..fe888ac8b9 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -65,6 +65,19 @@ from esphome.framework_helpers import _tar_extract_all, get_python_env_executabl "https://github.com/espressif/esp-idf.git@v6.0.1", ("https://github.com/espressif/esp-idf.git", "v6.0.1"), ), + # '#' ref separator (PlatformIO/git-web convention) works on both forms + ( + "https://github.com/espressif/esp-idf.git#release/v6.1", + ("https://github.com/espressif/esp-idf.git", "release/v6.1"), + ), + ( + "github://espressif/esp-idf#release/v6.1", + ("https://github.com/espressif/esp-idf.git", "release/v6.1"), + ), + ( + "github://espressif/esp-idf.git#master", + ("https://github.com/espressif/esp-idf.git", "master"), + ), # Tolerate a trailing ".git" on the shorthand so the user doesn't # silently end up with a doubled "...esp-idf.git.git" URL. ( From 23aff5202b1a73e63cecd0c13f394e1266fff1b6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:42:41 -0400 Subject: [PATCH 0571/1815] [wifi][openthread] Wire ESP32-S31/H4/H21 radio support (#17186) --- esphome/components/openthread/__init__.py | 12 +++++++++++- esphome/components/wifi/__init__.py | 12 +++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 215f921229..2dc8a783df 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -3,6 +3,9 @@ from esphome.components.esp32 import ( VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, + VARIANT_ESP32S31, add_idf_sdkconfig_option, get_esp32_variant, include_builtin_idf_component, @@ -187,7 +190,14 @@ def _validate_platform(config): if CORE.using_zephyr: return config return only_on_variant( - supported=[VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2] + supported=[ + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, + VARIANT_ESP32S31, + ] )(config) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 1cfd2b9821..512fd63e12 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -76,14 +76,20 @@ _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["network"] -NO_WIFI_VARIANTS = [const.VARIANT_ESP32H2, const.VARIANT_ESP32P4] +NO_WIFI_VARIANTS = [ + const.VARIANT_ESP32H2, + const.VARIANT_ESP32H4, + const.VARIANT_ESP32H21, + const.VARIANT_ESP32P4, +] def variant_has_wifi(variant: str) -> bool: """Return True if *variant* has a native WiFi PHY. - Variants without a native PHY (ESP32-H2, ESP32-P4) need the - ``esp32_hosted`` co-processor to use ``wifi:``. + Variants without a native PHY (see ``NO_WIFI_VARIANTS`` — currently + ESP32-H2, ESP32-H4, ESP32-H21, ESP32-P4) need the ``esp32_hosted`` + co-processor to use ``wifi:``. Case-insensitive on *variant* so external callers can pass either the upstream uppercase form (e.g. ``"ESP32H2"`` from From abbcfd213fa66093a680fc955dfc2d4050e1ae28 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:51:03 -0400 Subject: [PATCH 0572/1815] [tinyusb][usb_cdc_acm][usb_host][usb_uart] Support ESP32-S31/H4 (#17190) --- esphome/components/tinyusb/__init__.py | 15 ++++++++++++--- esphome/components/tinyusb/tinyusb_component.cpp | 6 ++++-- esphome/components/tinyusb/tinyusb_component.h | 6 ++++-- esphome/components/usb_cdc_acm/__init__.py | 10 +++++++++- esphome/components/usb_cdc_acm/usb_cdc_acm.cpp | 3 ++- esphome/components/usb_cdc_acm/usb_cdc_acm.h | 3 ++- .../components/usb_cdc_acm/usb_cdc_acm_esp32.cpp | 3 ++- esphome/components/usb_host/__init__.py | 14 ++++++++++++-- esphome/components/usb_host/usb_host.h | 6 ++++-- esphome/components/usb_host/usb_host_client.cpp | 6 ++++-- .../components/usb_host/usb_host_component.cpp | 6 ++++-- esphome/components/usb_uart/ch34x.cpp | 6 ++++-- esphome/components/usb_uart/cp210x.cpp | 6 ++++-- esphome/components/usb_uart/ft23xx.cpp | 6 ++++-- esphome/components/usb_uart/pl2303.cpp | 6 ++++-- esphome/components/usb_uart/usb_uart.cpp | 6 ++++-- esphome/components/usb_uart/usb_uart.h | 6 ++++-- esphome/idf_component.yml | 8 ++++---- 18 files changed, 87 insertions(+), 35 deletions(-) diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 0e02ff8724..9e1ad3afc4 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -2,9 +2,11 @@ from esphome import final_validate as fv import esphome.codegen as cg from esphome.components import esp32 from esphome.components.esp32 import ( + VARIANT_ESP32H4, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, add_idf_component, add_idf_sdkconfig_option, ) @@ -44,7 +46,13 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), esp32.only_on_variant( - supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3], + supported=[ + VARIANT_ESP32H4, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + ], ), ) @@ -64,7 +72,8 @@ def _final_validate(config): "'tinyusb' cannot be used with 'logger.hardware_uart: USB_CDC' " "because both share the USB OTG peripheral. Set " "'logger.hardware_uart' to a hardware UART (e.g. UART0), or to " - "USB_SERIAL_JTAG on variants that support it (ESP32-S3, ESP32-P4)" + "USB_SERIAL_JTAG on variants that support it " + "(ESP32-S3, ESP32-S31, ESP32-P4, ESP32-H4)" ) return config @@ -85,7 +94,7 @@ async def to_code(config): if config[CONF_USB_SERIAL_STR]: cg.add(var.set_usb_desc_serial(config[CONF_USB_SERIAL_STR])) - add_idf_component(name="espressif/esp_tinyusb", ref="2.1.1") + add_idf_component(name="espressif/esp_tinyusb", ref="2.2.1") add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_USE_ESPRESSIF_VID", False) add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_USE_DEFAULT_PID", False) diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index 567a84f8c3..b748959571 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -1,4 +1,5 @@ -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "tinyusb_component.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -61,4 +62,5 @@ void TinyUSB::dump_config() { } } // namespace esphome::tinyusb -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/tinyusb/tinyusb_component.h b/esphome/components/tinyusb/tinyusb_component.h index 56c33a708f..7ec3da118c 100644 --- a/esphome/components/tinyusb/tinyusb_component.h +++ b/esphome/components/tinyusb/tinyusb_component.h @@ -1,5 +1,6 @@ #pragma once -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "esphome/core/component.h" #include "tinyusb.h" @@ -69,4 +70,5 @@ class TinyUSB : public Component { }; } // namespace esphome::tinyusb -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_cdc_acm/__init__.py b/esphome/components/usb_cdc_acm/__init__.py index bfe177a4da..8cd078ab49 100644 --- a/esphome/components/usb_cdc_acm/__init__.py +++ b/esphome/components/usb_cdc_acm/__init__.py @@ -1,9 +1,11 @@ import esphome.codegen as cg from esphome.components import esp32, uart from esphome.components.esp32 import ( + VARIANT_ESP32H4, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, add_idf_sdkconfig_option, ) import esphome.config_validation as cv @@ -48,7 +50,13 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), esp32.only_on_variant( - supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3], + supported=[ + VARIANT_ESP32H4, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + ], ), ) diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp index 40f7f2e28b..454c049da3 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp @@ -1,4 +1,5 @@ -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_cdc_acm.h" #include "esphome/core/application.h" #include "esphome/core/helpers.h" diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.h b/esphome/components/usb_cdc_acm/usb_cdc_acm.h index 89405ab893..10692fd436 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.h +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.h @@ -1,5 +1,6 @@ #pragma once -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "esphome/core/component.h" #include "esphome/core/event_pool.h" diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp index 592207efa8..859d6cbaea 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp @@ -1,4 +1,5 @@ -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_cdc_acm.h" #include "esphome/core/application.h" #include "esphome/core/log.h" diff --git a/esphome/components/usb_host/__init__.py b/esphome/components/usb_host/__init__.py index 8e591bd80c..70425c27ca 100644 --- a/esphome/components/usb_host/__init__.py +++ b/esphome/components/usb_host/__init__.py @@ -1,8 +1,10 @@ import esphome.codegen as cg from esphome.components.esp32 import ( + VARIANT_ESP32H4, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, add_idf_component, add_idf_sdkconfig_option, idf_version, @@ -70,7 +72,15 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_DEVICES): cv.ensure_list(usb_device_schema()), } ), - only_on_variant(supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3]), + only_on_variant( + supported=[ + VARIANT_ESP32H4, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + ] + ), _set_max_packet_size, ) @@ -84,7 +94,7 @@ async def register_usb_client(config): async def to_code(config: ConfigType) -> None: # IDF 6.0 moved USB host to an external component if idf_version() >= cv.Version(6, 0, 0): - add_idf_component(name="espressif/usb", ref="1.3.0") + add_idf_component(name="espressif/usb", ref="1.4.1") add_idf_sdkconfig_option("CONFIG_USB_HOST_CONTROL_TRANSFER_MAX_SIZE", 1024) if config.get(CONF_ENABLE_HUBS): add_idf_sdkconfig_option("CONFIG_USB_HOST_HUBS_SUPPORTED", True) diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index a9f07a5422..57640e8691 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -1,7 +1,8 @@ #pragma once // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "esphome/core/defines.h" #include "esphome/core/component.h" #include @@ -195,4 +196,5 @@ class USBHost : public Component { } // namespace esphome::usb_host -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 45e2be17c7..7bc2b0a16b 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -1,5 +1,6 @@ // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_host.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -581,4 +582,5 @@ void USBClient::release_trq(TransferRequest *trq) { } } // namespace esphome::usb_host -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_host/usb_host_component.cpp b/esphome/components/usb_host/usb_host_component.cpp index 8ce0a70dc9..102311348a 100644 --- a/esphome/components/usb_host/usb_host_component.cpp +++ b/esphome/components/usb_host/usb_host_component.cpp @@ -1,5 +1,6 @@ // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_host.h" #include #include "esphome/core/log.h" @@ -29,4 +30,5 @@ void USBHost::loop() { } // namespace esphome::usb_host -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index c5f904ead1..e84384be5d 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -1,4 +1,5 @@ -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_uart.h" #include "usb/usb_host.h" #include "esphome/core/log.h" @@ -170,4 +171,5 @@ std::vector USBUartTypeCH34X::parse_descriptors(usb_device_handle_t dev_ } } // namespace esphome::usb_uart -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index 67fd03a813..c4edaed038 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -1,4 +1,5 @@ -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_uart.h" #include "usb/usb_host.h" #include "esphome/core/log.h" @@ -122,4 +123,5 @@ void USBUartTypeCP210X::enable_channels() { } } // namespace esphome::usb_uart -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index c2c8993805..3b0e05ba53 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -1,4 +1,5 @@ -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) +#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_uart.h" #include "usb/usb_host.h" #include "esphome/core/log.h" @@ -455,4 +456,5 @@ void USBUartTypeFT23XX::enable_channels() { } } // namespace esphome::usb_uart -#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32P4 +#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32P4 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index a50f1cf2d4..3685debef4 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -1,4 +1,5 @@ -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_uart.h" #include "usb/usb_host.h" #include "esphome/core/log.h" @@ -295,4 +296,5 @@ void USBUartTypePL2303::enable_channels() { } } // namespace esphome::usb_uart -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 3fdf35a472..b8749b6a76 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -1,5 +1,6 @@ // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_uart.h" #include "esphome/core/log.h" #include "esphome/core/application.h" @@ -541,4 +542,5 @@ void USBUartTypeCdcAcm::start_channels_() { } // namespace esphome::usb_uart -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index d0dccf42b9..c4fb77bdb2 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -1,6 +1,7 @@ #pragma once -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/string_ref.h" @@ -286,4 +287,5 @@ class USBUartTypePL2303 : public USBUartTypeCdcAcm { } // namespace esphome::usb_uart -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index f8f3df57cd..81c16f2e38 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -84,9 +84,9 @@ dependencies: rules: - if: "idf_version >=6.0.0" espressif/esp_tinyusb: - version: "2.1.1" + version: "2.2.1" rules: - - if: "target in [esp32s2, esp32s3, esp32p4]" + - if: "target in [esp32s2, esp32s3, esp32s31, esp32p4, esp32h4]" esphome/esp-hub75: version: 0.3.5 rules: @@ -96,9 +96,9 @@ dependencies: rules: - if: "idf_version >=6.0.0" espressif/usb: - version: "1.3.0" + version: "1.4.1" rules: - - if: "idf_version >=6.0.0 && target in [esp32s2, esp32s3, esp32p4]" + - if: "idf_version >=6.0.0 && target in [esp32s2, esp32s3, esp32s31, esp32p4, esp32h4]" esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: From 1dfafce06a55d87e5d83ab326f0e48e6f3aa4b09 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:05:29 -0400 Subject: [PATCH 0573/1815] [i2c][spi] Wire ESP32-S31/H4/H21 bus capabilities (#17188) --- esphome/components/esp32/gpio_esp32_s31.py | 17 +++++++++++++++-- esphome/components/i2c/__init__.py | 8 ++++++++ esphome/components/spi/__init__.py | 2 ++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/gpio_esp32_s31.py b/esphome/components/esp32/gpio_esp32_s31.py index 6a19e3fee4..d49240723b 100644 --- a/esphome/components/esp32/gpio_esp32_s31.py +++ b/esphome/components/esp32/gpio_esp32_s31.py @@ -2,13 +2,16 @@ import logging from typing import Any import esphome.config_validation as cv -from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA from esphome.pins import check_strapping_pin # Per the ESP32-S31 datasheet (page 96): # https://documentation.espressif.com/esp32-s31_datasheet_en.pdf _ESP32S31_SPI_FLASH_PINS: set[int] = {27, 28, 29, 31, 32, 33} -_ESP32S31_STRAPPING_PINS: set[int] = {60, 61} +# GPIO60/GPIO61 set the boot mode; GPIO37 selects the JTAG signal source. +_ESP32S31_STRAPPING_PINS: set[int] = {37, 60, 61} +# LP I2C is fixed to GPIO6 (SCL) / GPIO7 (SDA) per the datasheet IO MUX table. +_ESP32S31_I2C_LP_PINS = {"SDA": 7, "SCL": 6} _LOGGER = logging.getLogger(__name__) @@ -36,3 +39,13 @@ def esp32_s31_validate_supports(value: dict[str, Any]) -> dict[str, Any]: check_strapping_pin(value, _ESP32S31_STRAPPING_PINS, _LOGGER) return value + + +def esp32_s31_validate_lp_i2c(value): + lp_sda_pin = _ESP32S31_I2C_LP_PINS["SDA"] + lp_scl_pin = _ESP32S31_I2C_LP_PINS["SCL"] + if int(value[CONF_SDA]) != lp_sda_pin or int(value[CONF_SCL]) != lp_scl_pin: + raise cv.Invalid( + f"Low power i2c interface is only supported on GPIO{lp_sda_pin} SDA and GPIO{lp_scl_pin} SCL for ESP32-S31" + ) + return value diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index d9dd6d5ee2..eec2211a96 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -13,14 +13,18 @@ from esphome.components.esp32 import ( VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, get_esp32_variant, ) from esphome.components.esp32.gpio_esp32_c5 import esp32_c5_validate_lp_i2c from esphome.components.esp32.gpio_esp32_c6 import esp32_c6_validate_lp_i2c from esphome.components.esp32.gpio_esp32_p4 import esp32_p4_validate_lp_i2c +from esphome.components.esp32.gpio_esp32_s31 import esp32_s31_validate_lp_i2c from esphome.components.zephyr import ( zephyr_add_overlay, zephyr_add_prj_conf, @@ -72,14 +76,18 @@ ESP32_I2C_CAPABILITIES = { VARIANT_ESP32C6: {"NUM": 2, "HP": 1, "LP": 1}, VARIANT_ESP32C61: {"NUM": 1, "HP": 1}, VARIANT_ESP32H2: {"NUM": 2, "HP": 2}, + VARIANT_ESP32H4: {"NUM": 2, "HP": 2}, + VARIANT_ESP32H21: {"NUM": 2, "HP": 2}, VARIANT_ESP32P4: {"NUM": 3, "HP": 2, "LP": 1}, VARIANT_ESP32S2: {"NUM": 2, "HP": 2}, VARIANT_ESP32S3: {"NUM": 2, "HP": 2}, + VARIANT_ESP32S31: {"NUM": 3, "HP": 2, "LP": 1}, } VALIDATE_LP_I2C = { VARIANT_ESP32C5: esp32_c5_validate_lp_i2c, VARIANT_ESP32C6: esp32_c6_validate_lp_i2c, VARIANT_ESP32P4: esp32_p4_validate_lp_i2c, + VARIANT_ESP32S31: esp32_s31_validate_lp_i2c, } LP_I2C_VARIANT = list(VALIDATE_LP_I2C.keys()) diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index 33ccfbb5ee..d1961cec59 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -11,6 +11,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32H21, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, @@ -174,6 +175,7 @@ def get_hw_interface_list(): VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32H21, ]: return [["spi", "spi2"]] return [["spi", "spi2"], ["spi3"]] From 92554f4e67176e950ff98e5af16b95bc2ff920fe Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:28:06 -0400 Subject: [PATCH 0574/1815] [network] Set IPv4 type tag on all lwIP platforms, not just esp32 (#17200) --- esphome/components/network/ip_address.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/network/ip_address.h b/esphome/components/network/ip_address.h index 55bb2a1c89..d8a127f4a0 100644 --- a/esphome/components/network/ip_address.h +++ b/esphome/components/network/ip_address.h @@ -119,7 +119,7 @@ struct IPAddress { IPAddress(const std::string &in_address) { ipaddr_aton(in_address.c_str(), &ip_addr_); } IPAddress(ip4_addr_t *other_ip) { memcpy((void *) &ip_addr_, (void *) other_ip, sizeof(ip4_addr_t)); -#if USE_ESP32 && LWIP_IPV6 +#if LWIP_IPV6 ip_addr_.type = IPADDR_TYPE_V4; #endif } From 8c9f4fba8fdeb5a942f764e14f0e0194ec8a42fd Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:28:15 -0400 Subject: [PATCH 0575/1815] [wifi] Report STA IP, not SoftAP IP, in wifi_info on ESP8266 (#17185) --- esphome/components/wifi/wifi_component_esp8266.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 717d542fbe..84b864c0c5 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -218,9 +218,18 @@ network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() { return {}; network::IPAddresses addresses; uint8_t index = 0; + // addrList enumerates all lwIP netifs, including the SoftAP / fallback hotspot. Filter out + // the AP address so the STA address is reported as the device IP (see issue #17181). + struct ip_info ap_ip {}; + wifi_get_ip_info(SOFTAP_IF, &ap_ip); + network::IPAddress ap_address(&ap_ip.ip); + bool filter_ap = ap_address.is_set(); for (auto &addr : addrList) { + network::IPAddress ip(addr.ipFromNetifNum()); + if (filter_ap && ip == ap_address) + continue; assert(index < addresses.size()); - addresses[index++] = addr.ipFromNetifNum(); + addresses[index++] = ip; } return addresses; } From 23933c1b58065bd600f9159f2a8f29728b19b976 Mon Sep 17 00:00:00 2001 From: Julian Lunz <117189+jlunz@users.noreply.github.com> Date: Thu, 25 Jun 2026 08:21:41 +0200 Subject: [PATCH 0576/1815] [adc] Only call cyw43_thread_enter/exit for VSYS when WiFi is active on RP2040 (#17203) --- esphome/components/adc/adc_sensor_rp2040.cpp | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/esphome/components/adc/adc_sensor_rp2040.cpp b/esphome/components/adc/adc_sensor_rp2040.cpp index 8d41edb814..894c346588 100644 --- a/esphome/components/adc/adc_sensor_rp2040.cpp +++ b/esphome/components/adc/adc_sensor_rp2040.cpp @@ -66,15 +66,18 @@ float ADCSensor::sample() { } uint8_t pin = this->pin_->get_pin(); -#ifdef CYW43_USES_VSYS_PIN +#if defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI) if (pin == PICO_VSYS_PIN) { // Measuring VSYS on Raspberry Pico W needs to be wrapped with // `cyw43_thread_enter()`/`cyw43_thread_exit()` as discussed in // https://github.com/raspberrypi/pico-sdk/issues/1222, since Wifi chip and - // VSYS ADC both share GPIO29 + // VSYS ADC both share GPIO29. + // The USE_WIFI guard is required because CYW43_USES_VSYS_PIN can be defined + // transitively (e.g. via lwip_wrap.h) even on non-WiFi boards where the CYW43 + // driver is never initialized; calling cyw43_thread_enter() there hard-faults. cyw43_thread_enter(); } -#endif // CYW43_USES_VSYS_PIN +#endif // defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI) adc_gpio_init(pin); adc_select_input(pin - 26); @@ -84,11 +87,11 @@ float ADCSensor::sample() { aggr.add_sample(raw); } -#ifdef CYW43_USES_VSYS_PIN +#if defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI) if (pin == PICO_VSYS_PIN) { cyw43_thread_exit(); } -#endif // CYW43_USES_VSYS_PIN +#endif // defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI) if (this->output_raw_) { return aggr.aggregate(); From d8eee03556dcd6e61e014f6ae63ca6ae537d8877 Mon Sep 17 00:00:00 2001 From: Fae Date: Thu, 25 Jun 2026 11:20:15 +0100 Subject: [PATCH 0577/1815] [host] Fix handling of directory for preferences (#11160) --- esphome/components/host/preference_backend.h | 4 +- esphome/components/host/preferences.cpp | 52 ++++-- esphome/components/host/preferences.h | 8 +- tests/components/host/preferences_test.cpp | 174 +++++++++++++++++++ 4 files changed, 214 insertions(+), 24 deletions(-) create mode 100644 tests/components/host/preferences_test.cpp diff --git a/esphome/components/host/preference_backend.h b/esphome/components/host/preference_backend.h index 68537cad28..ab1443ed49 100644 --- a/esphome/components/host/preference_backend.h +++ b/esphome/components/host/preference_backend.h @@ -10,8 +10,8 @@ class HostPreferenceBackend final { public: explicit HostPreferenceBackend(uint32_t key) : key_(key) {} - bool save(const uint8_t *data, size_t len); - bool load(uint8_t *data, size_t len); + bool save(const uint8_t *data, size_t len) const; + bool load(uint8_t *data, size_t len) const; protected: uint32_t key_{}; diff --git a/esphome/components/host/preferences.cpp b/esphome/components/host/preferences.cpp index c0be270062..497b9d11e5 100644 --- a/esphome/components/host/preferences.cpp +++ b/esphome/components/host/preferences.cpp @@ -14,21 +14,31 @@ static const char *const TAG = "preferences"; void HostPreferences::setup_() { if (this->setup_complete_) return; - const char *home = getenv("HOME"); - if (home == nullptr) { - ESP_LOGE(TAG, "HOME environment variable is not set"); - abort(); + const char *prefdir = getenv("ESPHOME_PREFDIR"); + std::string pref_path; + if (prefdir != nullptr) { + pref_path = prefdir; + } else { + const char *home = getenv("HOME"); + if (home == nullptr) { + ESP_LOGE(TAG, "ESPHOME_PREFDIR and HOME environment variables not set, unable to save preferences"); + return; + } + pref_path = std::string(home) + "/.esphome/prefs"; } - this->filename_.append(home); - this->filename_.append("/.esphome"); - this->filename_.append("/prefs"); - fs::create_directories(this->filename_); + std::error_code ec; + fs::create_directories(pref_path, ec); + if (ec) { + ESP_LOGE(TAG, "Failed to create preferences directory: %s (%s)", pref_path.c_str(), ec.message().c_str()); + return; + } + this->filename_ = pref_path; this->filename_.append("/"); this->filename_.append(App.get_name()); this->filename_.append(".prefs"); FILE *fp = fopen(this->filename_.c_str(), "rb"); if (fp != nullptr) { - while (!feof((fp))) { + while (!feof(fp)) { uint32_t key; uint8_t len; if (fread(&key, sizeof(key), 1, fp) != 1) @@ -39,7 +49,7 @@ void HostPreferences::setup_() { if (fread(data, sizeof(uint8_t), len, fp) != len) break; std::vector vec(data, data + len); - this->data[key] = vec; + this->data_[key] = vec; } fclose(fp); } @@ -48,29 +58,33 @@ void HostPreferences::setup_() { bool HostPreferences::sync() { this->setup_(); + if (this->filename_.empty()) { + ESP_LOGE(TAG, "Preferences filename not set, unable to save preferences"); + return false; + } FILE *fp = fopen(this->filename_.c_str(), "wb"); if (fp == nullptr) { ESP_LOGE(TAG, "Failed to open preferences file for writing: %s", this->filename_.c_str()); return false; } - for (auto it = this->data.begin(); it != this->data.end(); ++it) { - fwrite(&it->first, sizeof(uint32_t), 1, fp); - uint8_t len = it->second.size(); + for (auto &it : this->data_) { + fwrite(&it.first, sizeof(uint32_t), 1, fp); + uint8_t len = it.second.size(); fwrite(&len, sizeof(len), 1, fp); - fwrite(it->second.data(), sizeof(uint8_t), it->second.size(), fp); + fwrite(it.second.data(), sizeof(uint8_t), it.second.size(), fp); } fclose(fp); return true; } bool HostPreferences::reset() { - host_preferences->data.clear(); + host_preferences->data_.clear(); return true; } ESPPreferenceObject HostPreferences::make_preference(size_t length, uint32_t type, bool in_flash) { - auto backend = new HostPreferenceBackend(type); + auto *backend = new HostPreferenceBackend(type); return ESPPreferenceObject(backend); }; @@ -83,11 +97,13 @@ void setup_preferences() { global_preferences = &s_preferences; } -bool HostPreferenceBackend::save(const uint8_t *data, size_t len) { +bool HostPreferenceBackend::save(const uint8_t *data, size_t len) const { return host_preferences->save(this->key_, data, len); } -bool HostPreferenceBackend::load(uint8_t *data, size_t len) { return host_preferences->load(this->key_, data, len); } +bool HostPreferenceBackend::load(uint8_t *data, size_t len) const { + return host_preferences->load(this->key_, data, len); +} HostPreferences *host_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/host/preferences.h b/esphome/components/host/preferences.h index 25858799ff..5f723e0675 100644 --- a/esphome/components/host/preferences.h +++ b/esphome/components/host/preferences.h @@ -23,7 +23,7 @@ class HostPreferences final : public PreferencesMixin { return false; this->setup_(); std::vector vec(data, data + len); - this->data[key] = vec; + this->data_[key] = vec; return true; } @@ -31,8 +31,8 @@ class HostPreferences final : public PreferencesMixin { if (len > 255) return false; this->setup_(); - auto it = this->data.find(key); - if (it == this->data.end()) + auto it = this->data_.find(key); + if (it == this->data_.end()) return false; const auto &vec = it->second; if (vec.size() != len) @@ -45,7 +45,7 @@ class HostPreferences final : public PreferencesMixin { void setup_(); bool setup_complete_{}; std::string filename_{}; - std::map> data{}; + std::map> data_{}; }; void setup_preferences(); diff --git a/tests/components/host/preferences_test.cpp b/tests/components/host/preferences_test.cpp new file mode 100644 index 0000000000..8e79db04f5 --- /dev/null +++ b/tests/components/host/preferences_test.cpp @@ -0,0 +1,174 @@ +#ifdef USE_HOST +#include +#include +#include +#include "esphome/components/host/preferences.h" +#include "esphome/core/application.h" + +namespace esphome::host::testing { +namespace fs = std::filesystem; + +/// RAII helper to save and restore an environment variable. +class ScopedEnvVar { + public: + explicit ScopedEnvVar(const char *name) : name_(name) { + const char *val = getenv(name); + if (val != nullptr) { + saved_value_ = val; + was_set_ = true; + } + } + ~ScopedEnvVar() { + if (this->was_set_) { + setenv(this->name_.c_str(), this->saved_value_.c_str(), 1); + } else { + unsetenv(this->name_.c_str()); + } + } + ScopedEnvVar(const ScopedEnvVar &) = delete; + ScopedEnvVar &operator=(const ScopedEnvVar &) = delete; + + private: + std::string name_; + std::string saved_value_; + bool was_set_{false}; +}; + +class HostPreferencesTest : public ::testing::Test { + protected: + void SetUp() override { + // Create a unique temp directory for this test + this->temp_dir_ = fs::temp_directory_path() / "esphome_prefs_test"; + fs::create_directories(this->temp_dir_); + + // Set up App name — string literal has static storage so StringRef is safe + App.pre_setup("test_prefs", 10, "", 0); + } + + void TearDown() override { + std::error_code ec; + fs::remove_all(this->temp_dir_, ec); + } + + fs::path temp_dir_; +}; + +TEST_F(HostPreferencesTest, BothVarsUnset_SyncReturnsFalse) { + ScopedEnvVar home_guard("HOME"); + ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR"); + unsetenv("HOME"); + unsetenv("ESPHOME_PREFDIR"); + + HostPreferences prefs; + EXPECT_FALSE(prefs.sync()); +} + +TEST_F(HostPreferencesTest, BothVarsUnset_SaveSucceedsInMemory) { + ScopedEnvVar home_guard("HOME"); + ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR"); + unsetenv("HOME"); + unsetenv("ESPHOME_PREFDIR"); + + HostPreferences prefs; + uint32_t value = 42; + // save() stores in memory even without a valid file path + EXPECT_TRUE(prefs.save(0x1234, reinterpret_cast(&value), sizeof(value))); + + // But sync to disk should fail + EXPECT_FALSE(prefs.sync()); +} + +TEST_F(HostPreferencesTest, PrefDirSet_SaveAndSync) { + ScopedEnvVar home_guard("HOME"); + ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR"); + + auto prefdir = this->temp_dir_ / "prefdir"; + setenv("ESPHOME_PREFDIR", prefdir.c_str(), 1); + unsetenv("HOME"); + + HostPreferences prefs; + uint32_t value = 42; + EXPECT_TRUE(prefs.save(0x1234, reinterpret_cast(&value), sizeof(value))); + EXPECT_TRUE(prefs.sync()); + + // Verify file was created in ESPHOME_PREFDIR + auto expected_file = prefdir / "test_prefs.prefs"; + EXPECT_TRUE(fs::exists(expected_file)); +} + +TEST_F(HostPreferencesTest, HomeSet_SaveAndSync) { + ScopedEnvVar home_guard("HOME"); + ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR"); + + auto home = this->temp_dir_ / "home"; + setenv("HOME", home.c_str(), 1); + unsetenv("ESPHOME_PREFDIR"); + + HostPreferences prefs; + uint32_t value = 42; + EXPECT_TRUE(prefs.save(0x1234, reinterpret_cast(&value), sizeof(value))); + EXPECT_TRUE(prefs.sync()); + + // Verify file was created in HOME/.esphome/prefs + auto expected_file = home / ".esphome" / "prefs" / "test_prefs.prefs"; + EXPECT_TRUE(fs::exists(expected_file)); +} + +TEST_F(HostPreferencesTest, PrefDirTakesPrecedenceOverHome) { + ScopedEnvVar home_guard("HOME"); + ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR"); + + auto prefdir = this->temp_dir_ / "prefdir"; + auto home = this->temp_dir_ / "home"; + setenv("ESPHOME_PREFDIR", prefdir.c_str(), 1); + setenv("HOME", home.c_str(), 1); + + HostPreferences prefs; + uint32_t value = 42; + EXPECT_TRUE(prefs.save(0x1234, reinterpret_cast(&value), sizeof(value))); + EXPECT_TRUE(prefs.sync()); + + // File should be in ESPHOME_PREFDIR, not HOME + auto prefdir_file = prefdir / "test_prefs.prefs"; + auto home_file = home / ".esphome" / "prefs" / "test_prefs.prefs"; + EXPECT_TRUE(fs::exists(prefdir_file)); + EXPECT_FALSE(fs::exists(home_file)); +} + +TEST_F(HostPreferencesTest, SaveAndLoadRoundTrip) { + ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR"); + + auto prefdir = this->temp_dir_ / "roundtrip"; + setenv("ESPHOME_PREFDIR", prefdir.c_str(), 1); + + // Save data with one instance + { + HostPreferences prefs; + uint32_t value = 0xDEADBEEF; + EXPECT_TRUE(prefs.save(0xABCD, reinterpret_cast(&value), sizeof(value))); + EXPECT_TRUE(prefs.sync()); + } + + // Load with a fresh instance (reads from file) + { + HostPreferences prefs; + uint32_t loaded = 0; + EXPECT_TRUE(prefs.load(0xABCD, reinterpret_cast(&loaded), sizeof(loaded))); + EXPECT_EQ(loaded, 0xDEADBEEFu); + } +} + +TEST_F(HostPreferencesTest, LoadNonExistentKeyReturnsFalse) { + ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR"); + + auto prefdir = this->temp_dir_ / "nokey"; + setenv("ESPHOME_PREFDIR", prefdir.c_str(), 1); + + HostPreferences prefs; + uint32_t loaded = 0; + EXPECT_FALSE(prefs.load(0x9999, reinterpret_cast(&loaded), sizeof(loaded))); +} + +} // namespace esphome::host::testing + +#endif From 8c68e9556872b85010e2622033609f376cd56225 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:33:28 +1200 Subject: [PATCH 0578/1815] [config_validation] Add tests for 100% validator coverage (#17204) --- tests/unit_tests/test_config_validation.py | 1908 +++++++++++++++++--- 1 file changed, 1694 insertions(+), 214 deletions(-) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 9b9f003b0d..2715f9c644 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1,3 +1,4 @@ +from pathlib import Path import string from hypothesis import example, given, settings @@ -5,7 +6,7 @@ from hypothesis.strategies import builds, integers, ip_addresses, one_of, text import pytest import voluptuous as vol -from esphome import config_validation +from esphome import config_validation as cv from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32C2, @@ -17,6 +18,22 @@ from esphome.components.esp32 import ( ) from esphome.config_validation import Invalid from esphome.const import ( + CONF_DAY, + CONF_HOUR, + CONF_ID, + CONF_INTERNAL, + CONF_MINUTE, + CONF_MONTH, + CONF_NAME, + CONF_REF, + CONF_SECOND, + CONF_TYPE, + CONF_VALUE, + CONF_YEAR, + KEY_CORE, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, @@ -25,19 +42,35 @@ from esphome.const import ( PLATFORM_RP2040, PLATFORM_RTL87XX, SCHEDULER_DONT_RUN, + TYPE_GIT, + TYPE_LOCAL, + Framework, ) -from esphome.core import CORE, HexInt, Lambda -from esphome.yaml_util import SensitiveStr +from esphome.core import ( + CORE, + ID, + HexInt, + Lambda, + MACAddress, + TimePeriod, + TimePeriodMicroseconds, + TimePeriodMinutes, + TimePeriodNanoseconds, + TimePeriodSeconds, +) +from esphome.schema_extractors import SCHEMA_EXTRACT +from esphome.util import Registry +from esphome.yaml_util import ESPHomeDataBase, SensitiveStr, make_data_base def test_check_not_templatable__invalid(): with pytest.raises(Invalid, match="This option is not templatable!"): - config_validation.check_not_templatable(Lambda("")) + cv.check_not_templatable(Lambda("")) @pytest.mark.parametrize("value", ("foo", 1, "D12", False)) def test_alphanumeric__valid(value): - actual = config_validation.alphanumeric(value) + actual = cv.alphanumeric(value) assert actual == str(value) @@ -45,12 +78,12 @@ def test_alphanumeric__valid(value): @pytest.mark.parametrize("value", ("£23", "Foo!")) def test_alphanumeric__invalid(value): with pytest.raises(Invalid): - config_validation.alphanumeric(value) + cv.alphanumeric(value) @given(value=text(alphabet=string.ascii_lowercase + string.digits + "-_")) def test_valid_name__valid(value): - actual = config_validation.valid_name(value) + actual = cv.valid_name(value) assert actual == value @@ -58,29 +91,29 @@ def test_valid_name__valid(value): @pytest.mark.parametrize("value", ("foo bar", "FooBar", "foo::bar")) def test_valid_name__invalid(value): with pytest.raises(Invalid): - config_validation.valid_name(value) + cv.valid_name(value) @pytest.mark.parametrize("value", ("${name}", "${NAME}", "$NAME", "${name}_name")) def test_valid_name__substitution_valid(value): CORE.vscode = True - actual = config_validation.valid_name(value) + actual = cv.valid_name(value) assert actual == value CORE.vscode = False with pytest.raises(Invalid): - actual = config_validation.valid_name(value) + actual = cv.valid_name(value) @pytest.mark.parametrize("value", ("{NAME}", "${A NAME}")) def test_valid_name__substitution_like_invalid(value): with pytest.raises(Invalid): - config_validation.valid_name(value) + cv.valid_name(value) @pytest.mark.parametrize("value", ("myid", "anID", "SOME_ID_test", "MYID_99")) def test_validate_id_name__valid(value): - actual = config_validation.validate_id_name(value) + actual = cv.validate_id_name(value) assert actual == value @@ -88,23 +121,23 @@ def test_validate_id_name__valid(value): @pytest.mark.parametrize("value", ("id of mine", "id-4", "{name_id}", "id::name")) def test_validate_id_name__invalid(value): with pytest.raises(Invalid): - config_validation.validate_id_name(value) + cv.validate_id_name(value) @pytest.mark.parametrize("value", ("${id}", "${ID}", "${ID}_test_1", "$MYID")) def test_validate_id_name__substitution_valid(value): CORE.vscode = True - actual = config_validation.validate_id_name(value) + actual = cv.validate_id_name(value) assert actual == value CORE.vscode = False with pytest.raises(Invalid): - config_validation.validate_id_name(value) + cv.validate_id_name(value) @given(one_of(integers(), text())) def test_string__valid(value): - actual = config_validation.string(value) + actual = cv.string(value) assert actual == str(value) @@ -112,12 +145,12 @@ def test_string__valid(value): @pytest.mark.parametrize("value", ({}, [], True, False, None)) def test_string__invalid(value): with pytest.raises(Invalid): - config_validation.string(value) + cv.string(value) @given(text()) def test_strict_string__valid(value): - actual = config_validation.string_strict(value) + actual = cv.string_strict(value) assert actual == value @@ -125,29 +158,29 @@ def test_strict_string__valid(value): @pytest.mark.parametrize("value", (None, 123)) def test_string_string__invalid(value): with pytest.raises(Invalid, match="Must be string, got"): - config_validation.string_strict(value) + cv.string_strict(value) def test_sensitive__default_delegates_to_string() -> None: - validator = config_validation.sensitive() + validator = cv.sensitive() - assert isinstance(validator, config_validation.SensitiveValidator) - assert validator.inner is config_validation.string + assert isinstance(validator, cv.SensitiveValidator) + assert validator.inner is cv.string assert validator("hunter2") == "hunter2" assert validator(42) == "42" def test_sensitive__custom_inner_delegates_validation() -> None: - validator = config_validation.sensitive(config_validation.string_strict) + validator = cv.sensitive(cv.string_strict) - assert validator.inner is config_validation.string_strict + assert validator.inner is cv.string_strict assert validator("abc") == "abc" with pytest.raises(Invalid, match="Must be string, got"): validator(123) def test_sensitive__wraps_string_result_in_sensitive_str() -> None: - validator = config_validation.sensitive() + validator = cv.sensitive() result = validator("hunter2") assert isinstance(result, SensitiveStr) @@ -164,7 +197,7 @@ def test_sensitive__does_not_double_tag_already_sensitive() -> None: def inner(_value): return pre_tagged - validator = config_validation.sensitive(inner) + validator = cv.sensitive(inner) result = validator("anything") assert result is pre_tagged @@ -178,22 +211,22 @@ def test_sensitive__non_string_result_passes_through() -> None: def inner(_value): return sentinel - validator = config_validation.sensitive(inner) + validator = cv.sensitive(inner) assert validator("anything") is sentinel def test_sensitive__is_detectable_via_isinstance() -> None: - validator = config_validation.sensitive() + validator = cv.sensitive() - assert isinstance(validator, config_validation.SensitiveValidator) + assert isinstance(validator, cv.SensitiveValidator) def test_bind_key__bare_usage_validates_and_is_sensitive() -> None: # Used bare (cv.bind_key) it is itself a sensitive validator: detectable for # frontend masking and validating a value directly tags the result. - assert isinstance(config_validation.bind_key, config_validation.SensitiveValidator) + assert isinstance(cv.bind_key, cv.SensitiveValidator) - result = config_validation.bind_key("0123456789ABCDEF0123456789ABCDEF") + result = cv.bind_key("0123456789ABCDEF0123456789ABCDEF") assert isinstance(result, SensitiveStr) assert result == "0123456789ABCDEF0123456789ABCDEF" @@ -202,9 +235,7 @@ def test_bind_key__bare_usage_validates_and_is_sensitive() -> None: def test_bind_key__bare_usage_in_schema() -> None: # Voluptuous calls the bare validator with the config value; the result must # come through tagged sensitive. - schema = config_validation.Schema( - {config_validation.Required("key"): config_validation.bind_key} - ) + schema = cv.Schema({cv.Required("key"): cv.bind_key}) out = schema({"key": "0123456789ABCDEF0123456789ABCDEF"}) assert isinstance(out["key"], SensitiveStr) @@ -213,10 +244,10 @@ def test_bind_key__bare_usage_in_schema() -> None: def test_bind_key__factory_returns_sensitive_validator() -> None: # Called with a name (cv.bind_key(name=...)) it returns a new sensitive # validator rather than validating. - validator = config_validation.bind_key(name="Decryption key") + validator = cv.bind_key(name="Decryption key") - assert isinstance(validator, config_validation.SensitiveValidator) - assert validator is not config_validation.bind_key + assert isinstance(validator, cv.SensitiveValidator) + assert validator is not cv.bind_key assert isinstance(validator("0123456789ABCDEF0123456789ABCDEF"), SensitiveStr) @@ -229,7 +260,7 @@ def test_bind_key__factory_returns_sensitive_validator() -> None: ) def test_bind_key__custom_name_in_error(value: str, error: str) -> None: # The ``name`` argument (used by dsmr/dlms_meter) customizes error messages. - validator = config_validation.bind_key(name="Decryption key") + validator = cv.bind_key(name="Decryption key") with pytest.raises(Invalid, match=error): validator(value) @@ -238,25 +269,23 @@ def test_bind_key__rejects_non_hex_pair_length() -> None: # Odd-length input yields a trailing single-char part, hitting the # "format XX" branch rather than the hex-value branch. with pytest.raises(Invalid, match="Bind key must be format XX"): - config_validation.bind_key("0123456789ABCDEF0123456789ABCDE") + cv.bind_key("0123456789ABCDEF0123456789ABCDE") def test_bind_key__direct_call_with_name_validates_with_that_name() -> None: # Passing both a value and a name validates immediately using the custom # name for error wording, and still tags the result sensitive. - result = config_validation.bind_key( - "0123456789ABCDEF0123456789ABCDEF", name="Decryption key" - ) + result = cv.bind_key("0123456789ABCDEF0123456789ABCDEF", name="Decryption key") assert isinstance(result, SensitiveStr) with pytest.raises(Invalid, match="Decryption key must consist of"): - config_validation.bind_key("00", name="Decryption key") + cv.bind_key("00", name="Decryption key") def test_bind_key__factory_without_name_keeps_existing_name() -> None: # Re-invoking a named validator without a name preserves its name rather # than resetting to the default. - named = config_validation.bind_key(name="Decryption key") + named = cv.bind_key(name="Decryption key") rederived = named() with pytest.raises(Invalid, match="Decryption key must consist of"): @@ -267,11 +296,8 @@ def test_bind_key__repr_is_name_keyed_and_non_recursive() -> None: # ``self.inner`` is a bound method of the instance, so the inherited # ``repr(self.inner)`` would recurse infinitely; the override keeps repr # finite and keyed on the name for schema-dump dedup. - assert repr(config_validation.bind_key) == "bind_key('Bind key')" - assert ( - repr(config_validation.bind_key(name="Decryption key")) - == "bind_key('Decryption key')" - ) + assert repr(cv.bind_key) == "bind_key('Bind key')" + assert repr(cv.bind_key(name="Decryption key")) == "bind_key('Decryption key')" def test_sensitive__repr_mirrors_inner() -> None: @@ -279,18 +305,14 @@ def test_sensitive__repr_mirrors_inner() -> None: # validator's repr keeps two ``cv.sensitive(cv.string)`` wrappers # interchangeable for that purpose and avoids leaking the wrapper as # noise in voluptuous error messages. - assert repr(config_validation.sensitive(config_validation.string)) == repr( - config_validation.string - ) - assert repr(config_validation.sensitive(config_validation.string)) == repr( - config_validation.sensitive(config_validation.string) - ) + assert repr(cv.sensitive(cv.string)) == repr(cv.string) + assert repr(cv.sensitive(cv.string)) == repr(cv.sensitive(cv.string)) def test_sensitive_key_fragments__covers_common_terms() -> None: - assert isinstance(config_validation.SENSITIVE_KEY_FRAGMENTS, frozenset) + assert isinstance(cv.SENSITIVE_KEY_FRAGMENTS, frozenset) for term in ("password", "passcode", "secret", "token", "api_key", "apikey", "psk"): - assert term in config_validation.SENSITIVE_KEY_FRAGMENTS + assert term in cv.SENSITIVE_KEY_FRAGMENTS @given( @@ -305,31 +327,31 @@ def test_sensitive_key_fragments__covers_common_terms() -> None: ) @example("") def test_icon__valid(value): - actual = config_validation.icon(value) + actual = cv.icon(value) assert actual == value def test_icon__invalid(): with pytest.raises(Invalid, match="Icons must match the format "): - config_validation.icon("foo") + cv.icon("foo") def test_icon__max_length(): """Test that icons exceeding 63 bytes are rejected.""" # Exactly 63 bytes should pass max_icon = "mdi:" + "a" * 59 # 63 bytes total - assert config_validation.icon(max_icon) == max_icon + assert cv.icon(max_icon) == max_icon # 64 bytes should fail too_long = "mdi:" + "a" * 60 # 64 bytes total with pytest.raises(Invalid, match="Icon string is too long"): - config_validation.icon(too_long) + cv.icon(too_long) def test_byte_length() -> None: """Test ByteLength validator checks UTF-8 byte length, not char count.""" - validator = config_validation.ByteLength(max=10) # pylint: disable=no-member + validator = cv.ByteLength(max=10) # pylint: disable=no-member # ASCII: 10 chars = 10 bytes, should pass assert validator("a" * 10) == "a" * 10 @@ -348,18 +370,18 @@ def test_byte_length() -> None: @pytest.mark.parametrize("value", ("True", "YES", "on", "enAblE", True)) def test_boolean__valid_true(value): - assert config_validation.boolean(value) is True + assert cv.boolean(value) is True @pytest.mark.parametrize("value", ("False", "NO", "off", "disAblE", False)) def test_boolean__valid_false(value): - assert config_validation.boolean(value) is False + assert cv.boolean(value) is False @pytest.mark.parametrize("value", (None, 1, 0, "foo")) def test_boolean__invalid(value): with pytest.raises(Invalid, match="Expected boolean value"): - config_validation.boolean(value) + cv.boolean(value) # deadline disabled: the validator is trivially fast, but Hypothesis's per-example @@ -368,31 +390,31 @@ def test_boolean__invalid(value): @settings(deadline=None) @given(value=ip_addresses(v=4).map(str)) def test_ipv4__valid(value): - config_validation.ipv4address(value) + cv.ipv4address(value) @pytest.mark.parametrize("value", ("127.0.0", "localhost", "")) def test_ipv4__invalid(value): with pytest.raises(Invalid, match="is not a valid IPv4 address"): - config_validation.ipv4address(value) + cv.ipv4address(value) @settings(deadline=None) @given(value=ip_addresses(v=6).map(str)) def test_ipv6__valid(value): - config_validation.ipaddress(value) + cv.ipaddress(value) @pytest.mark.parametrize("value", ("127.0.0", "localhost", "", "2001:db8::2::3")) def test_ipv6__invalid(value): with pytest.raises(Invalid, match="is not a valid IP address"): - config_validation.ipaddress(value) + cv.ipaddress(value) # TODO: ensure_list @given(integers()) def hex_int__valid(value): - actual = config_validation.hex_int(value) + actual = cv.hex_int(value) assert isinstance(actual, HexInt) assert actual == value @@ -472,18 +494,14 @@ def test_split_default(framework, platform, variant, full, idf, arduino, simple) "esp32_h2_idf": "19", } - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.SplitDefault( + cv.SplitDefault( "full", **common_mappings, **idf_mappings, **arduino_mappings ): str, - config_validation.SplitDefault( - "idf", **common_mappings, **idf_mappings - ): str, - config_validation.SplitDefault( - "arduino", **common_mappings, **arduino_mappings - ): str, - config_validation.SplitDefault("simple", **common_mappings): str, + cv.SplitDefault("idf", **common_mappings, **idf_mappings): str, + cv.SplitDefault("arduino", **common_mappings, **arduino_mappings): str, + cv.SplitDefault("simple", **common_mappings): str, } ) @@ -515,16 +533,16 @@ def test_require_framework_version(framework, platform, message): CORE.data[KEY_CORE] = {} CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = platform CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = framework - CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = config_validation.Version(1, 0, 0) + CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version(1, 0, 0) assert ( - config_validation.require_framework_version( - esp_idf=config_validation.Version(0, 5, 0), - esp32_arduino=config_validation.Version(0, 5, 0), - esp8266_arduino=config_validation.Version(0, 5, 0), - rp2040_arduino=config_validation.Version(0, 5, 0), - bk72xx_arduino=config_validation.Version(0, 5, 0), - host=config_validation.Version(0, 5, 0), + cv.require_framework_version( + esp_idf=cv.Version(0, 5, 0), + esp32_arduino=cv.Version(0, 5, 0), + esp8266_arduino=cv.Version(0, 5, 0), + rp2040_arduino=cv.Version(0, 5, 0), + bk72xx_arduino=cv.Version(0, 5, 0), + host=cv.Version(0, 5, 0), extra_message="test 1", )("test") == "test" @@ -534,24 +552,24 @@ def test_require_framework_version(framework, platform, message): vol.error.Invalid, match="This feature requires at least framework version 2.0.0. test 2", ): - config_validation.require_framework_version( - esp_idf=config_validation.Version(2, 0, 0), - esp32_arduino=config_validation.Version(2, 0, 0), - esp8266_arduino=config_validation.Version(2, 0, 0), - rp2040_arduino=config_validation.Version(2, 0, 0), - bk72xx_arduino=config_validation.Version(2, 0, 0), - host=config_validation.Version(2, 0, 0), + cv.require_framework_version( + esp_idf=cv.Version(2, 0, 0), + esp32_arduino=cv.Version(2, 0, 0), + esp8266_arduino=cv.Version(2, 0, 0), + rp2040_arduino=cv.Version(2, 0, 0), + bk72xx_arduino=cv.Version(2, 0, 0), + host=cv.Version(2, 0, 0), extra_message="test 2", )("test") assert ( - config_validation.require_framework_version( - esp_idf=config_validation.Version(1, 5, 0), - esp32_arduino=config_validation.Version(1, 5, 0), - esp8266_arduino=config_validation.Version(1, 5, 0), - rp2040_arduino=config_validation.Version(1, 5, 0), - bk72xx_arduino=config_validation.Version(1, 5, 0), - host=config_validation.Version(1, 5, 0), + cv.require_framework_version( + esp_idf=cv.Version(1, 5, 0), + esp32_arduino=cv.Version(1, 5, 0), + esp8266_arduino=cv.Version(1, 5, 0), + rp2040_arduino=cv.Version(1, 5, 0), + bk72xx_arduino=cv.Version(1, 5, 0), + host=cv.Version(1, 5, 0), max_version=True, extra_message="test 3", )("test") @@ -562,13 +580,13 @@ def test_require_framework_version(framework, platform, message): vol.error.Invalid, match="This feature requires framework version 0.5.0 or lower. test 4", ): - config_validation.require_framework_version( - esp_idf=config_validation.Version(0, 5, 0), - esp32_arduino=config_validation.Version(0, 5, 0), - esp8266_arduino=config_validation.Version(0, 5, 0), - rp2040_arduino=config_validation.Version(0, 5, 0), - bk72xx_arduino=config_validation.Version(0, 5, 0), - host=config_validation.Version(0, 5, 0), + cv.require_framework_version( + esp_idf=cv.Version(0, 5, 0), + esp32_arduino=cv.Version(0, 5, 0), + esp8266_arduino=cv.Version(0, 5, 0), + rp2040_arduino=cv.Version(0, 5, 0), + bk72xx_arduino=cv.Version(0, 5, 0), + host=cv.Version(0, 5, 0), max_version=True, extra_message="test 4", )("test") @@ -576,7 +594,7 @@ def test_require_framework_version(framework, platform, message): with pytest.raises( vol.error.Invalid, match=f"This feature is incompatible with {message}. test 5" ): - config_validation.require_framework_version( + cv.require_framework_version( extra_message="test 5", )("test") @@ -585,9 +603,9 @@ def test_only_with_single_component_loaded() -> None: """Test OnlyWith with single component when component is loaded.""" CORE.loaded_integrations = {"mqtt"} - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith("mqtt_id", "mqtt", default="test_mqtt"): str, + cv.OnlyWith("mqtt_id", "mqtt", default="test_mqtt"): str, } ) @@ -599,9 +617,9 @@ def test_only_with_single_component_not_loaded() -> None: """Test OnlyWith with single component when component is not loaded.""" CORE.loaded_integrations = set() - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith("mqtt_id", "mqtt", default="test_mqtt"): str, + cv.OnlyWith("mqtt_id", "mqtt", default="test_mqtt"): str, } ) @@ -613,11 +631,9 @@ def test_only_with_list_all_components_loaded() -> None: """Test OnlyWith with list when all components are loaded.""" CORE.loaded_integrations = {"zigbee", "nrf52"} - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith( - "zigbee_id", ["zigbee", "nrf52"], default="test_zigbee" - ): str, + cv.OnlyWith("zigbee_id", ["zigbee", "nrf52"], default="test_zigbee"): str, } ) @@ -629,11 +645,9 @@ def test_only_with_list_partial_components_loaded() -> None: """Test OnlyWith with list when only some components are loaded.""" CORE.loaded_integrations = {"zigbee"} # Only zigbee, not nrf52 - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith( - "zigbee_id", ["zigbee", "nrf52"], default="test_zigbee" - ): str, + cv.OnlyWith("zigbee_id", ["zigbee", "nrf52"], default="test_zigbee"): str, } ) @@ -645,11 +659,9 @@ def test_only_with_list_no_components_loaded() -> None: """Test OnlyWith with list when no components are loaded.""" CORE.loaded_integrations = set() - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith( - "zigbee_id", ["zigbee", "nrf52"], default="test_zigbee" - ): str, + cv.OnlyWith("zigbee_id", ["zigbee", "nrf52"], default="test_zigbee"): str, } ) @@ -661,9 +673,9 @@ def test_only_with_list_multiple_components() -> None: """Test OnlyWith with list requiring three components.""" CORE.loaded_integrations = {"comp1", "comp2", "comp3"} - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith( + cv.OnlyWith( "test_id", ["comp1", "comp2", "comp3"], default="test_value" ): str, } @@ -682,9 +694,9 @@ def test_only_with_empty_list() -> None: """Test OnlyWith with empty list (edge case).""" CORE.loaded_integrations = set() - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith("test_id", [], default="test_value"): str, + cv.OnlyWith("test_id", [], default="test_value"): str, } ) @@ -697,9 +709,9 @@ def test_only_with_user_value_overrides_default() -> None: """Test OnlyWith respects user-provided values over defaults.""" CORE.loaded_integrations = {"mqtt"} - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith("mqtt_id", "mqtt", default="default_id"): str, + cv.OnlyWith("mqtt_id", "mqtt", default="default_id"): str, } ) @@ -709,7 +721,7 @@ def test_only_with_user_value_overrides_default() -> None: @pytest.mark.parametrize("value", ("hello", "Hello World", "test_name", "温度")) def test_string_no_slash__valid(value: str) -> None: - actual = config_validation.string_no_slash(value) + actual = cv.string_no_slash(value) assert actual == value @@ -726,7 +738,7 @@ def test_string_no_slash__slash_replaced_with_warning( value: str, expected: str, caplog: pytest.LogCaptureFixture ) -> None: """Test that '/' is auto-replaced with fraction slash and warning is logged.""" - actual = config_validation.string_no_slash(value) + actual = cv.string_no_slash(value) assert actual == expected assert "reserved as a URL path separator" in caplog.text assert "will become an error in ESPHome 2026.7.0" in caplog.text @@ -735,16 +747,16 @@ def test_string_no_slash__slash_replaced_with_warning( def test_string_no_slash__long_string_allowed() -> None: # string_no_slash doesn't enforce length - use cv.Length() separately long_value = "x" * 200 - assert config_validation.string_no_slash(long_value) == long_value + assert cv.string_no_slash(long_value) == long_value def test_string_no_slash__empty() -> None: - assert config_validation.string_no_slash("") == "" + assert cv.string_no_slash("") == "" @pytest.mark.parametrize("value", ("Temperature", "Living Room Light", "温度传感器")) def test_validate_entity_name__valid(value: str) -> None: - actual = config_validation._validate_entity_name(value) + actual = cv._validate_entity_name(value) assert actual == value @@ -752,40 +764,40 @@ def test_validate_entity_name__slash_replaced_with_warning( caplog: pytest.LogCaptureFixture, ) -> None: """Test that '/' in entity names is auto-replaced with fraction slash.""" - actual = config_validation._validate_entity_name("has/slash") + actual = cv._validate_entity_name("has/slash") assert actual == "has⁄slash" assert "reserved as a URL path separator" in caplog.text def test_validate_entity_name__max_length() -> None: # 120 bytes should pass - assert config_validation._validate_entity_name("x" * 120) == "x" * 120 + assert cv._validate_entity_name("x" * 120) == "x" * 120 # 121 bytes should fail with pytest.raises(Invalid, match="too long.*121 bytes.*Maximum.*120"): - config_validation._validate_entity_name("x" * 121) + cv._validate_entity_name("x" * 121) def test_validate_entity_name__multibyte_byte_length() -> None: # 40 chars of 3-byte UTF-8 = 120 bytes, should pass - assert config_validation._validate_entity_name("温" * 40) == "温" * 40 + assert cv._validate_entity_name("温" * 40) == "温" * 40 # 41 chars of 3-byte UTF-8 = 123 bytes, should fail (over 120 byte limit) with pytest.raises(Invalid, match="too long.*123 bytes.*Maximum.*120"): - config_validation._validate_entity_name("温" * 41) + cv._validate_entity_name("温" * 41) def test_validate_entity_name__none_without_friendly_name() -> None: # When name is "None" and friendly_name is not set, it should fail CORE.friendly_name = None with pytest.raises(Invalid, match="friendly_name is not set"): - config_validation._validate_entity_name("None") + cv._validate_entity_name("None") def test_validate_entity_name__none_with_friendly_name() -> None: # When name is "None" but friendly_name is set, it should return None CORE.friendly_name = "My Device" - result = config_validation._validate_entity_name("None") + result = cv._validate_entity_name("None") assert result is None CORE.friendly_name = None # Reset @@ -808,7 +820,7 @@ def test_validate_entity_name__none_with_friendly_name() -> None: ), ) def test_percentage__valid(value: object, expected: float) -> None: - assert config_validation.percentage(value) == expected + assert cv.percentage(value) == expected @pytest.mark.parametrize( @@ -826,7 +838,7 @@ def test_percentage__valid(value: object, expected: float) -> None: ) def test_percentage__invalid(value: object) -> None: with pytest.raises(Invalid): - config_validation.percentage(value) + cv.percentage(value) @pytest.mark.parametrize( @@ -845,7 +857,7 @@ def test_percentage__invalid(value: object) -> None: ), ) def test_possibly_negative_percentage__valid(value: object, expected: float) -> None: - assert config_validation.possibly_negative_percentage(value) == expected + assert cv.possibly_negative_percentage(value) == expected @pytest.mark.parametrize( @@ -861,7 +873,7 @@ def test_possibly_negative_percentage__valid(value: object, expected: float) -> ) def test_possibly_negative_percentage__invalid(value: object) -> None: with pytest.raises(Invalid): - config_validation.possibly_negative_percentage(value) + cv.possibly_negative_percentage(value) @pytest.mark.parametrize( @@ -878,7 +890,7 @@ def test_possibly_negative_percentage__invalid(value: object) -> None: ), ) def test_unbounded_percentage__valid(value: object, expected: float) -> None: - assert config_validation.unbounded_percentage(value) == expected + assert cv.unbounded_percentage(value) == expected @pytest.mark.parametrize( @@ -893,7 +905,7 @@ def test_unbounded_percentage__valid(value: object, expected: float) -> None: ) def test_unbounded_percentage__invalid(value: object) -> None: with pytest.raises(Invalid): - config_validation.unbounded_percentage(value) + cv.unbounded_percentage(value) @pytest.mark.parametrize( @@ -916,13 +928,13 @@ def test_unbounded_percentage__invalid(value: object) -> None: def test_unbounded_possibly_negative_percentage__valid( value: object, expected: float ) -> None: - assert config_validation.unbounded_possibly_negative_percentage(value) == expected + assert cv.unbounded_possibly_negative_percentage(value) == expected @pytest.mark.parametrize("value", ("foo", None)) def test_unbounded_possibly_negative_percentage__invalid(value: object) -> None: with pytest.raises(Invalid): - config_validation.unbounded_possibly_negative_percentage(value) + cv.unbounded_possibly_negative_percentage(value) @pytest.mark.parametrize( @@ -934,9 +946,9 @@ def test_percentage_validators__raw_number_above_one_without_percent_sign( ) -> None: """Raw numeric values outside [-1, 1] must use a percent sign.""" with pytest.raises(Invalid, match="percent sign"): - config_validation.unbounded_percentage(value) + cv.unbounded_percentage(value) with pytest.raises(Invalid, match="percent sign"): - config_validation.unbounded_possibly_negative_percentage(value) + cv.unbounded_possibly_negative_percentage(value) def test_update_interval__coerces_zero_to_one_ms( @@ -947,7 +959,7 @@ def test_update_interval__coerces_zero_to_one_ms( existing configs compiling on upgrade while emitting a user-facing warning that directs them to set a non-zero value.""" with caplog.at_level("WARNING"): - result = config_validation.update_interval("0ms") + result = cv.update_interval("0ms") assert result.total_milliseconds == 1 assert "update_interval of 0ms is not supported" in caplog.text assert "1ms" in caplog.text @@ -955,14 +967,14 @@ def test_update_interval__coerces_zero_to_one_ms( def test_update_interval__preserves_nonzero_values() -> None: """Non-zero update_interval values must pass through unchanged.""" - assert config_validation.update_interval("1ms").total_milliseconds == 1 - assert config_validation.update_interval("50ms").total_milliseconds == 50 - assert config_validation.update_interval("60s").total_milliseconds == 60000 + assert cv.update_interval("1ms").total_milliseconds == 1 + assert cv.update_interval("50ms").total_milliseconds == 50 + assert cv.update_interval("60s").total_milliseconds == 60000 def test_update_interval__never_passes_through() -> None: """update_interval: never must still map to SCHEDULER_DONT_RUN.""" - result = config_validation.update_interval("never") + result = cv.update_interval("never") assert result.total_milliseconds == SCHEDULER_DONT_RUN @@ -978,24 +990,20 @@ def test_optional_default_visibility_is_none() -> None: access; absence (``None``) means "render on the editor's main form." """ - o = config_validation.Optional("foo") + o = cv.Optional("foo") assert o.visibility is None def test_optional_visibility_advanced() -> None: """``visibility=Visibility.ADVANCED`` is recorded on the marker.""" - o = config_validation.Optional( - "foo", visibility=config_validation.Visibility.ADVANCED - ) - assert o.visibility is config_validation.Visibility.ADVANCED + o = cv.Optional("foo", visibility=cv.Visibility.ADVANCED) + assert o.visibility is cv.Visibility.ADVANCED def test_optional_visibility_yaml_only() -> None: """``visibility=Visibility.YAML_ONLY`` is recorded on the marker.""" - o = config_validation.Optional( - "foo", visibility=config_validation.Visibility.YAML_ONLY - ) - assert o.visibility is config_validation.Visibility.YAML_ONLY + o = cv.Optional("foo", visibility=cv.Visibility.YAML_ONLY) + assert o.visibility is cv.Visibility.YAML_ONLY def test_visibility_str_values_match_dump_emission() -> None: @@ -1007,8 +1015,8 @@ def test_visibility_str_values_match_dump_emission() -> None: field — pinning the on-the-wire spelling here keeps the dump contract stable. """ - assert str(config_validation.Visibility.ADVANCED) == "advanced" - assert str(config_validation.Visibility.YAML_ONLY) == "yaml_only" + assert str(cv.Visibility.ADVANCED) == "advanced" + assert str(cv.Visibility.YAML_ONLY) == "yaml_only" def test_optional_visibility_does_not_affect_validation() -> None: @@ -1016,16 +1024,14 @@ def test_optional_visibility_does_not_affect_validation() -> None: validator behaves. A schema with ``visibility`` applied must accept and reject the same values it would without it. """ - plain = config_validation.Schema( - {config_validation.Optional("foo", default=42): config_validation.int_} - ) - flagged = config_validation.Schema( + plain = cv.Schema({cv.Optional("foo", default=42): cv.int_}) + flagged = cv.Schema( { - config_validation.Optional( + cv.Optional( "foo", default=42, - visibility=config_validation.Visibility.YAML_ONLY, - ): config_validation.int_ + visibility=cv.Visibility.YAML_ONLY, + ): cv.int_ } ) # Same accept / default-fill behavior. @@ -1040,7 +1046,7 @@ def test_optional_visibility_does_not_affect_validation() -> None: def test_required_default_visibility_is_none() -> None: """``Required`` mirrors ``Optional`` for the ``visibility`` kwarg.""" - r = config_validation.Required("foo") + r = cv.Required("foo") assert r.visibility is None @@ -1050,10 +1056,8 @@ def test_required_visibility_kwarg() -> None: Required fields rarely need the kwarg, but exposing it lets consumers apply uniform logic across key markers. """ - r = config_validation.Required( - "foo", visibility=config_validation.Visibility.ADVANCED - ) - assert r.visibility is config_validation.Visibility.ADVANCED + r = cv.Required("foo", visibility=cv.Visibility.ADVANCED) + assert r.visibility is cv.Visibility.ADVANCED def test_polling_component_schema_visibility_opt_in() -> None: @@ -1062,28 +1066,17 @@ def test_polling_component_schema_visibility_opt_in() -> None: Time platforms pass ``Visibility.ADVANCED``; sensors and other polling components leave it ``None`` and keep the un-flagged shape. """ - default = config_validation.polling_component_schema("15min") - advanced = config_validation.polling_component_schema( - "15min", visibility=config_validation.Visibility.ADVANCED - ) + default = cv.polling_component_schema("15min") + advanced = cv.polling_component_schema("15min", visibility=cv.Visibility.ADVANCED) default_keys = {str(k): k for k in default.schema} advanced_keys = {str(k): k for k in advanced.schema} assert default_keys["update_interval"].visibility is None - assert ( - advanced_keys["update_interval"].visibility - is config_validation.Visibility.ADVANCED - ) + assert advanced_keys["update_interval"].visibility is cv.Visibility.ADVANCED # The opt-in only touches update_interval — setup_priority # still inherits its YAML_ONLY visibility from COMPONENT_SCHEMA # in both shapes. - assert ( - default_keys["setup_priority"].visibility - is config_validation.Visibility.YAML_ONLY - ) - assert ( - advanced_keys["setup_priority"].visibility - is config_validation.Visibility.YAML_ONLY - ) + assert default_keys["setup_priority"].visibility is cv.Visibility.YAML_ONLY + assert advanced_keys["setup_priority"].visibility is cv.Visibility.YAML_ONLY def test_polling_component_schema_no_default_ignores_visibility() -> None: @@ -1096,11 +1089,9 @@ def test_polling_component_schema_no_default_ignores_visibility() -> None: required field. The helper accepts the kwarg unconditionally for caller ergonomics but doesn't honour it on this branch. """ - schema = config_validation.polling_component_schema( - None, visibility=config_validation.Visibility.ADVANCED - ) + schema = cv.polling_component_schema(None, visibility=cv.Visibility.ADVANCED) keys = {str(k): k for k in schema.schema} - assert isinstance(keys["update_interval"], config_validation.Required) + assert isinstance(keys["update_interval"], cv.Required) assert keys["update_interval"].visibility is None @@ -1123,28 +1114,1517 @@ def test_visibility_marker_is_per_field_no_mutation() -> None: detail this test deliberately doesn't pin, since it's a consumer concern). """ - inner_unset = config_validation.Optional("baz") - inner_yaml_only = config_validation.Optional( - "qux", visibility=config_validation.Visibility.YAML_ONLY - ) - parent = config_validation.Optional( - "foo", visibility=config_validation.Visibility.ADVANCED - ) + inner_unset = cv.Optional("baz") + inner_yaml_only = cv.Optional("qux", visibility=cv.Visibility.YAML_ONLY) + parent = cv.Optional("foo", visibility=cv.Visibility.ADVANCED) # Wire them into a nested schema — none of the markers' own # ``visibility`` should change as a result. - schema = config_validation.Schema( + schema = cv.Schema( { - parent: config_validation.Schema( + parent: cv.Schema( { - inner_unset: config_validation.int_, - inner_yaml_only: config_validation.string, + inner_unset: cv.int_, + inner_yaml_only: cv.string, } ) } ) assert schema # touch the schema so any deferred mutation runs - assert parent.visibility is config_validation.Visibility.ADVANCED + assert parent.visibility is cv.Visibility.ADVANCED assert inner_unset.visibility is None - assert inner_yaml_only.visibility is config_validation.Visibility.YAML_ONLY + assert inner_yaml_only.visibility is cv.Visibility.YAML_ONLY + + +def _wrap_str(value: str) -> ESPHomeDataBase: + """Wrap a raw string as an ESPHomeDataBase, mimicking a YAML-loaded value.""" + return make_data_base(value) + + +def _set_core_target(platform: str, framework: str) -> None: + """Set CORE target platform/framework for validators that depend on them.""" + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: platform, + KEY_TARGET_FRAMEWORK: framework, + } + + +def _set_framework_version(platform: str, framework: str, version: cv.Version) -> None: + """Set CORE target platform/framework and framework version.""" + _set_core_target(platform, framework) + CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = version + + +# --------------------------------------------------------------------------- +# Version +# --------------------------------------------------------------------------- + + +def test_version_str_with_extra() -> None: + assert str(cv.Version(1, 2, 3, "b1")) == "1.2.3-b1" + + +def test_version_str_without_extra() -> None: + assert str(cv.Version(1, 2, 3)) == "1.2.3" + + +def test_version_parse_valid() -> None: + version = cv.Version.parse("2024.5.1") + assert (version.major, version.minor, version.patch, version.extra) == ( + 2024, + 5, + 1, + "", + ) + + +def test_version_parse_with_extra() -> None: + version = cv.Version.parse("2024.5.1-dev20240101") + assert version.extra == "dev20240101" + + +def test_version_parse_invalid() -> None: + with pytest.raises(ValueError, match="Not a valid version number"): + cv.Version.parse("not.a.version") + + +def test_version_is_beta() -> None: + assert cv.Version.parse("2024.5.0b1").is_beta is True + assert cv.Version.parse("2024.5.0").is_beta is False + + +def test_version_is_dev() -> None: + assert cv.Version.parse("2024.5.0-dev").is_dev is True + assert cv.Version.parse("2024.5.0").is_dev is False + + +# --------------------------------------------------------------------------- +# alphanumeric / valid_name / validate_id_name +# --------------------------------------------------------------------------- + + +def test_alphanumeric_none() -> None: + with pytest.raises(Invalid, match="string value is None"): + cv.alphanumeric(None) + + +def test_valid_name_vscode_no_substitution() -> None: + CORE.vscode = True + assert cv.valid_name("plainname") == "plainname" + + +def test_validate_id_name_empty() -> None: + with pytest.raises(Invalid, match="ID must not be empty"): + cv.validate_id_name("") + + +def test_validate_id_name_digit_first() -> None: + with pytest.raises(Invalid, match="First character in ID cannot be a digit"): + cv.validate_id_name("1abc") + + +def test_validate_id_name_vscode_no_substitution() -> None: + CORE.vscode = True + assert cv.validate_id_name("validid") == "validid" + + +def test_validate_id_name_reserved() -> None: + with pytest.raises(Invalid, match="reserved internally"): + cv.validate_id_name("alarm") + + +def test_validate_id_name_integration_conflict() -> None: + CORE.loaded_integrations = {"mqtt"} + with pytest.raises( + Invalid, match="conflicts with the name of an esphome integration" + ): + cv.validate_id_name("mqtt") + + +# --------------------------------------------------------------------------- +# sub_device_id +# --------------------------------------------------------------------------- + + +def test_sub_device_id_schema_extract() -> None: + from esphome.core.config import Device + + assert cv.sub_device_id(SCHEMA_EXTRACT) is Device + + +def test_sub_device_id_empty() -> None: + assert cv.sub_device_id(None) is None + assert cv.sub_device_id("") is None + + +def test_sub_device_id_valid() -> None: + result = cv.sub_device_id("my_device") + assert isinstance(result, ID) + assert result.id == "my_device" + + +# --------------------------------------------------------------------------- +# boolean_false / ensure_list +# --------------------------------------------------------------------------- + + +def test_boolean_false_valid() -> None: + assert cv.boolean_false(False) is False + assert cv.boolean_false("no") is False + + +def test_boolean_false_invalid() -> None: + with pytest.raises(Invalid, match="Expected boolean value to be false"): + cv.boolean_false(True) + + +def test_ensure_list_none() -> None: + assert cv.ensure_list(cv.int_)(None) == [] + + +def test_ensure_list_empty_dict() -> None: + assert cv.ensure_list(cv.int_)({}) == [] + + +def test_ensure_list_single_value() -> None: + assert cv.ensure_list(cv.int_)(5) == [5] + + +def test_ensure_list_actual_list() -> None: + assert cv.ensure_list(cv.int_)([1, 2, 3]) == [1, 2, 3] + + +# --------------------------------------------------------------------------- +# hex_int / int_to_hex_string / int_ +# --------------------------------------------------------------------------- + + +def test_hex_int() -> None: + result = cv.hex_int(255) + assert result == 255 + assert isinstance(result, HexInt) + + +def test_int_to_hex_string_int() -> None: + assert cv.int_to_hex_string(64) == "0x40" + + +def test_int_to_hex_string_passthrough() -> None: + assert cv.int_to_hex_string("already") == "already" + + +def test_int_float_whole() -> None: + assert cv.int_(5.0) == 5 + + +def test_int_float_fractional() -> None: + with pytest.raises(Invalid, match="only accepts integers with no fractional part"): + cv.int_(5.5) + + +def test_int_hex_string() -> None: + assert cv.int_("0xFF") == 255 + + +# --------------------------------------------------------------------------- +# int_range / float_range no-min branches +# --------------------------------------------------------------------------- + + +def test_int_range_no_min() -> None: + validator = cv.int_range(max=10) + assert validator(5) == 5 + + +def test_float_range_no_min() -> None: + validator = cv.float_range(max=10.0) + assert validator(5.0) == 5.0 + + +# --------------------------------------------------------------------------- +# use_id / declare_id / templatable +# --------------------------------------------------------------------------- + + +def test_use_id_schema_extract() -> None: + assert cv.use_id(int)(SCHEMA_EXTRACT) is int + + +def test_use_id_none() -> None: + result = cv.use_id(int)(None) + assert isinstance(result, ID) + assert result.is_declaration is False + + +def test_use_id_existing_id_passthrough() -> None: + existing = ID("foo", is_declaration=False, type=int) + assert cv.use_id(int)(existing) is existing + + +def test_use_id_from_string() -> None: + result = cv.use_id(int)("foo") + assert isinstance(result, ID) + assert result.id == "foo" + assert result.is_declaration is False + + +def test_declare_id_schema_extract() -> None: + assert cv.declare_id(int)(SCHEMA_EXTRACT) is int + + +def test_declare_id_none() -> None: + result = cv.declare_id(int)(None) + assert isinstance(result, ID) + assert result.is_declaration is True + + +def test_declare_id_from_string() -> None: + result = cv.declare_id(int)("foo") + assert result.id == "foo" + assert result.is_declaration is True + + +def test_templatable_schema_extract() -> None: + assert cv.templatable(cv.int_)(SCHEMA_EXTRACT) is cv.int_ + + +def test_templatable_lambda() -> None: + result = cv.templatable(cv.int_)(Lambda("return 5;")) + assert isinstance(result, Lambda) + + +def test_templatable_plain_value() -> None: + assert cv.templatable(cv.int_)(5) == 5 + + +def test_templatable_dict_validators() -> None: + validator = cv.templatable({cv.Required("x"): cv.int_}) + assert validator({"x": 5}) == {"x": 5} + + +# --------------------------------------------------------------------------- +# only_on / only_with_framework +# --------------------------------------------------------------------------- + + +def test_only_on_list_platform_match() -> None: + _set_core_target(PLATFORM_ESP32, "arduino") + validator = cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266]) + assert validator("x") == "x" + + +def test_only_on_wrong_platform() -> None: + _set_core_target(PLATFORM_ESP8266, "arduino") + validator = cv.only_on(PLATFORM_ESP32) + with pytest.raises(Invalid, match="only available on"): + validator("x") + + +def test_only_with_framework_match() -> None: + _set_core_target(PLATFORM_ESP32, "arduino") + validator = cv.only_with_framework([Framework.ARDUINO]) + assert validator("x") == "x" + + +def test_only_with_framework_mismatch_with_suggestion() -> None: + _set_core_target(PLATFORM_ESP32, "esp-idf") + validator = cv.only_with_framework( + Framework.ARDUINO, + suggestions={Framework.ESP_IDF: ("some_component", "some/path")}, + ) + with pytest.raises(Invalid, match="some/path"): + validator("x") + + +def test_only_with_framework_mismatch_no_suggestion() -> None: + _set_core_target(PLATFORM_ESP32, "esp-idf") + validator = cv.only_with_framework(Framework.ARDUINO) + with pytest.raises(Invalid, match="only available with framework"): + validator("x") + + +def test_only_with_framework_suggestion_without_docs_path() -> None: + _set_core_target(PLATFORM_ESP32, "esp-idf") + validator = cv.only_with_framework( + Framework.ARDUINO, + suggestions={Framework.ESP_IDF: ("some_component", None)}, + ) + with pytest.raises(Invalid, match="Please use 'some_component'"): + validator("x") + + +# --------------------------------------------------------------------------- +# has_*_key helpers +# --------------------------------------------------------------------------- + + +def test_has_at_least_one_key_not_dict() -> None: + with pytest.raises(Invalid, match="expected dictionary"): + cv.has_at_least_one_key("a", "b")([]) + + +def test_has_at_least_one_key_none() -> None: + with pytest.raises(Invalid, match="at least one of"): + cv.has_at_least_one_key("a", "b")({"c": 1}) + + +def test_has_at_least_one_key_ok() -> None: + obj = {"a": 1} + assert cv.has_at_least_one_key("a", "b")(obj) is obj + + +def test_has_exactly_one_key_not_dict() -> None: + with pytest.raises(Invalid, match="expected dictionary"): + cv.has_exactly_one_key("a", "b")("notdict") + + +def test_has_exactly_one_key_too_many() -> None: + with pytest.raises(Invalid, match="Cannot specify more than one"): + cv.has_exactly_one_key("a", "b")({"a": 1, "b": 2}) + + +def test_has_exactly_one_key_too_few() -> None: + with pytest.raises(Invalid, match="Must contain exactly one"): + cv.has_exactly_one_key("a", "b")({"c": 1}) + + +def test_has_exactly_one_key_ok() -> None: + obj = {"a": 1} + assert cv.has_exactly_one_key("a", "b")(obj) is obj + + +def test_has_at_most_one_key_not_dict() -> None: + with pytest.raises(Invalid, match="expected dictionary"): + cv.has_at_most_one_key("a", "b")(5) + + +def test_has_at_most_one_key_too_many() -> None: + with pytest.raises(vol.MultipleInvalid, match="Cannot specify more than one"): + cv.has_at_most_one_key("a", "b")({"a": 1, "b": 2}) + + +def test_has_at_most_one_key_ok() -> None: + obj = {"a": 1} + assert cv.has_at_most_one_key("a", "b")(obj) is obj + + +def test_has_none_or_all_keys_not_dict() -> None: + with pytest.raises(Invalid, match="expected dictionary"): + cv.has_none_or_all_keys("a", "b")(5) + + +def test_has_none_or_all_keys_partial() -> None: + with pytest.raises(Invalid, match="none or all"): + cv.has_none_or_all_keys("a", "b")({"a": 1}) + + +def test_has_none_or_all_keys_all() -> None: + obj = {"a": 1, "b": 2} + assert cv.has_none_or_all_keys("a", "b")(obj) is obj + + +def test_has_none_or_all_keys_none() -> None: + obj = {"c": 3} + assert cv.has_none_or_all_keys("a", "b")(obj) is obj + + +# --------------------------------------------------------------------------- +# time_period_str_colon / time_period_str_unit +# --------------------------------------------------------------------------- + + +def test_time_period_str_colon_int() -> None: + with pytest.raises(Invalid, match="wrap time values in quotes"): + cv.time_period_str_colon(5) + + +def test_time_period_str_colon_not_str() -> None: + with pytest.raises(Invalid): + cv.time_period_str_colon([1, 2]) + + +def test_time_period_str_colon_bad_value() -> None: + with pytest.raises(Invalid): + cv.time_period_str_colon("aa:bb") + + +def test_time_period_str_colon_hh_mm() -> None: + assert cv.time_period_str_colon("01:30") == TimePeriod(hours=1, minutes=30) + + +def test_time_period_str_colon_hh_mm_ss() -> None: + assert cv.time_period_str_colon("01:30:15") == TimePeriod( + hours=1, minutes=30, seconds=15 + ) + + +def test_time_period_str_colon_too_many_parts() -> None: + with pytest.raises(Invalid): + cv.time_period_str_colon("1:2:3:4") + + +def test_time_period_str_unit_int() -> None: + with pytest.raises(Invalid, match=r"no time \*unit\*"): + cv.time_period_str_unit(5) + + +def test_time_period_str_unit_timeperiod_input() -> None: + assert cv.time_period_str_unit(TimePeriod(seconds=5)) == TimePeriod(seconds=5) + + +def test_time_period_str_unit_not_str() -> None: + with pytest.raises(Invalid, match="Expected string for time period"): + cv.time_period_str_unit([1]) + + +def test_time_period_str_unit_no_match() -> None: + with pytest.raises(Invalid, match="Expected time period with unit"): + cv.time_period_str_unit("5/3") + + +def test_time_period_str_unit_empty_mantissa() -> None: + with pytest.raises(Invalid): + cv.time_period_str_unit("s") + + +# --------------------------------------------------------------------------- +# time_period_in_* converters +# --------------------------------------------------------------------------- + + +def test_time_period_in_milliseconds_too_precise() -> None: + with pytest.raises(Invalid, match="Maximum precision is milliseconds"): + cv.time_period_in_milliseconds_(TimePeriod(microseconds=5)) + + +def test_time_period_in_microseconds_too_precise() -> None: + with pytest.raises(Invalid, match="Maximum precision is microseconds"): + cv.time_period_in_microseconds_(TimePeriod(nanoseconds=5)) + + +def test_time_period_in_microseconds_ok() -> None: + assert cv.time_period_in_microseconds_( + TimePeriod(microseconds=5) + ) == TimePeriodMicroseconds(microseconds=5) + + +def test_time_period_in_nanoseconds_ok() -> None: + assert cv.time_period_in_nanoseconds_( + TimePeriod(nanoseconds=5) + ) == TimePeriodNanoseconds(nanoseconds=5) + + +@pytest.mark.parametrize( + "value", + [ + TimePeriod(nanoseconds=1), + TimePeriod(microseconds=1), + TimePeriod(milliseconds=1), + ], +) +def test_time_period_in_seconds_too_precise(value: TimePeriod) -> None: + with pytest.raises(Invalid, match="Maximum precision is seconds"): + cv.time_period_in_seconds_(value) + + +def test_time_period_in_seconds_ok() -> None: + assert cv.time_period_in_seconds_(TimePeriod(seconds=5)) == TimePeriodSeconds( + seconds=5 + ) + + +@pytest.mark.parametrize( + "value", + [ + TimePeriod(nanoseconds=1), + TimePeriod(microseconds=1), + TimePeriod(milliseconds=1), + TimePeriod(seconds=1), + ], +) +def test_time_period_in_minutes_too_precise(value: TimePeriod) -> None: + with pytest.raises(Invalid, match="Maximum precision is minutes"): + cv.time_period_in_minutes_(value) + + +def test_time_period_in_minutes_ok() -> None: + assert cv.time_period_in_minutes_(TimePeriod(minutes=5)) == TimePeriodMinutes( + minutes=5 + ) + + +# --------------------------------------------------------------------------- +# time_of_day / date_time +# --------------------------------------------------------------------------- + + +def test_time_of_day_valid() -> None: + assert cv.time_of_day("12:34:56") == { + CONF_HOUR: 12, + CONF_MINUTE: 34, + CONF_SECOND: 56, + } + + +def test_date_time_dict_input() -> None: + validator = cv.date_time(date=True, time=False) + result = validator({CONF_YEAR: 2024, CONF_MONTH: 5, CONF_DAY: 1}) + assert result[CONF_YEAR] == 2024 + + +def test_date_time_date_only_string() -> None: + validator = cv.date_time(date=True, time=False) + assert validator("2024-5-1") == {CONF_YEAR: 2024, CONF_MONTH: 5, CONF_DAY: 1} + + +def test_date_time_date_and_time_string() -> None: + validator = cv.date_time(date=True, time=True) + result = validator("2024-05-01 13:30:00") + assert result[CONF_HOUR] == 13 + assert result[CONF_YEAR] == 2024 + + +def test_date_time_invalid_format() -> None: + validator = cv.date_time(date=False, time=True) + with pytest.raises(Invalid, match="Invalid time"): + validator("notatime") + + +def test_date_time_ampm() -> None: + validator = cv.date_time(date=False, time=True) + assert validator("1:30 PM")[CONF_HOUR] == 13 + + +def test_date_time_no_seconds() -> None: + validator = cv.date_time(date=False, time=True) + assert validator("13:30")[CONF_SECOND] == 0 + + +def test_date_time_strptime_error() -> None: + validator = cv.date_time(date=False, time=True) + with pytest.raises(Invalid, match="Invalid time"): + validator("25:99") + + +# --------------------------------------------------------------------------- +# mac_address / uuid +# --------------------------------------------------------------------------- + + +def test_mac_address_valid() -> None: + result = cv.mac_address("AA:BB:CC:DD:EE:FF") + assert isinstance(result, MACAddress) + + +def test_mac_address_wrong_parts() -> None: + with pytest.raises(Invalid, match="6 : .colon. separated parts"): + cv.mac_address("AA:BB:CC") + + +def test_mac_address_wrong_length() -> None: + with pytest.raises(Invalid, match="format XX:XX"): + cv.mac_address("A:BB:CC:DD:EE:FF") + + +def test_mac_address_non_hex() -> None: + with pytest.raises(Invalid, match="hexadecimal values"): + cv.mac_address("GG:BB:CC:DD:EE:FF") + + +def test_uuid_valid() -> None: + result = cv.uuid("12345678-1234-5678-1234-567812345678") + assert str(result) == "12345678-1234-5678-1234-567812345678" + + +# --------------------------------------------------------------------------- +# float_with_unit family +# --------------------------------------------------------------------------- + + +def test_float_with_unit_optional_unit_plain_float() -> None: + assert cv.angle("1.5") == 1.5 + + +def test_float_with_unit_optional_unit_with_suffix() -> None: + assert cv.angle("45deg") == 45.0 + + +def test_float_with_unit_with_suffix() -> None: + assert cv.frequency("10kHz") == 10000.0 + + +def test_float_with_unit_no_match() -> None: + with pytest.raises(Invalid, match="Expected frequency with unit"): + cv.frequency("!!") + + +def test_float_with_unit_invalid_suffix() -> None: + with pytest.raises(Invalid, match="Invalid frequency suffix"): + cv.frequency("10xHz") + + +def test_temperature_celsius() -> None: + assert cv.temperature("25°C") == 25.0 + + +def test_temperature_kelvin() -> None: + assert cv.temperature("300K") == pytest.approx(300 - 273.15) + + +def test_temperature_fahrenheit() -> None: + assert cv.temperature("32°F") == pytest.approx(0.0) + + +def test_temperature_invalid() -> None: + with pytest.raises(Invalid, match="Invalid temperature suffix"): + cv.temperature("5x") + + +def test_temperature_delta_celsius() -> None: + assert cv.temperature_delta("5°C") == 5.0 + + +def test_temperature_delta_kelvin() -> None: + assert cv.temperature_delta("5K") == 5.0 + + +def test_temperature_delta_fahrenheit() -> None: + assert cv.temperature_delta("9°F") == pytest.approx(5.0) + + +def test_temperature_delta_invalid() -> None: + with pytest.raises(Invalid, match="Invalid temperature suffix"): + cv.temperature_delta("5x") + + +def test_color_temperature_mireds() -> None: + assert cv.color_temperature("153 mireds") == pytest.approx(153.0) + + +def test_color_temperature_kelvin() -> None: + assert cv.color_temperature("6536 K") == pytest.approx(1000000.0 / 6536) + + +def test_color_temperature_negative() -> None: + with pytest.raises(Invalid, match="cannot be negative"): + cv.color_temperature("-1 mireds") + + +# --------------------------------------------------------------------------- +# validate_bytes +# --------------------------------------------------------------------------- + + +def test_validate_bytes_plain() -> None: + assert cv.validate_bytes("100") == 100 + + +def test_validate_bytes_with_unit() -> None: + assert cv.validate_bytes("2kB") == 2000 + + +def test_validate_bytes_no_match() -> None: + with pytest.raises(Invalid, match="Expected number of bytes"): + cv.validate_bytes("abc") + + +def test_validate_bytes_invalid_suffix() -> None: + with pytest.raises(Invalid, match="Invalid metric suffix"): + cv.validate_bytes("5xx") + + +def test_validate_bytes_negative_exponent() -> None: + with pytest.raises(Invalid, match="positive exponents"): + cv.validate_bytes("5m") + + +# --------------------------------------------------------------------------- +# hostname / domain / domain_name / ssid +# --------------------------------------------------------------------------- + + +def test_hostname_valid() -> None: + assert cv.hostname("my-host01") == "my-host01" + + +def test_hostname_invalid() -> None: + with pytest.raises(Invalid, match="Invalid hostname"): + cv.hostname("invalid_host!") + + +def test_domain_valid_name() -> None: + assert cv.domain("example.com") == "example.com" + + +def test_domain_ip_fallback() -> None: + assert cv.domain("::1") == "::1" + + +def test_domain_invalid() -> None: + with pytest.raises(Invalid, match="Invalid domain"): + cv.domain("::not::valid::") + + +def test_domain_name_empty() -> None: + assert cv.domain_name("") == "" + + +def test_domain_name_valid() -> None: + assert cv.domain_name(".local") == ".local" + + +def test_domain_name_no_leading_dot() -> None: + with pytest.raises(Invalid, match="must start with"): + cv.domain_name("local") + + +def test_domain_name_double_dot() -> None: + with pytest.raises(Invalid, match="single"): + cv.domain_name("..local") + + +def test_domain_name_invalid_char() -> None: + with pytest.raises(Invalid, match="alphanumeric"): + cv.domain_name(".local!") + + +def test_ssid_valid() -> None: + assert cv.ssid("MyNetwork") == "MyNetwork" + + +def test_ssid_empty() -> None: + with pytest.raises(Invalid, match="can't be empty"): + cv.ssid("") + + +def test_ssid_too_long() -> None: + with pytest.raises(Invalid, match="longer than 32"): + cv.ssid("x" * 33) + + +# --------------------------------------------------------------------------- +# IP address / network validators +# --------------------------------------------------------------------------- + + +def test_ipv6address_valid() -> None: + assert str(cv.ipv6address("::1")) == "::1" + + +def test_ipv6address_invalid() -> None: + with pytest.raises(Invalid, match="not a valid IPv6 address"): + cv.ipv6address("not-ipv6") + + +def test_ipv4address_multi_broadcast_multicast() -> None: + assert str(cv.ipv4address_multi_broadcast("224.0.0.1")) == "224.0.0.1" + + +def test_ipv4address_multi_broadcast_broadcast() -> None: + assert str(cv.ipv4address_multi_broadcast("255.255.255.255")) == "255.255.255.255" + + +def test_ipv4address_multi_broadcast_invalid() -> None: + with pytest.raises(Invalid, match="not a multicasst"): + cv.ipv4address_multi_broadcast("192.168.0.1") + + +def test_ipv4network_valid() -> None: + assert str(cv.ipv4network("192.168.0.0/24")) == "192.168.0.0/24" + + +def test_ipv4network_invalid() -> None: + with pytest.raises(Invalid, match="not a valid IPv4 network"): + cv.ipv4network("notanetwork") + + +def test_ipv6network_valid() -> None: + assert str(cv.ipv6network("2001:db8::/32")) == "2001:db8::/32" + + +def test_ipv6network_invalid() -> None: + with pytest.raises(Invalid, match="not a valid IPv6 network"): + cv.ipv6network("notanetwork") + + +def test_ipnetwork_valid() -> None: + assert str(cv.ipnetwork("10.0.0.0/8")) == "10.0.0.0/8" + + +def test_ipnetwork_invalid() -> None: + with pytest.raises(Invalid, match="not a valid IP network"): + cv.ipnetwork("notanetwork") + + +# --------------------------------------------------------------------------- +# MQTT topic validators +# --------------------------------------------------------------------------- + + +def test_valid_topic_none() -> None: + assert cv._valid_topic(None) == "" + + +def test_valid_topic_dict() -> None: + with pytest.raises(Invalid, match="dictionary with topic"): + cv._valid_topic({"a": 1}) + + +def test_valid_topic_unicode_error() -> None: + with pytest.raises(Invalid, match="valid UTF-8"): + cv._valid_topic("\ud800") + + +def test_valid_topic_empty() -> None: + with pytest.raises(Invalid, match="must not be empty"): + cv._valid_topic("") + + +def test_valid_topic_too_long() -> None: + with pytest.raises(Invalid, match="not be longer than 65535"): + cv._valid_topic("x" * 65536) + + +def test_valid_topic_null_char() -> None: + with pytest.raises(Invalid, match="null character"): + cv._valid_topic("a\0b") + + +def test_subscribe_topic_valid() -> None: + assert cv.subscribe_topic("home/+/temp") == "home/+/temp" + + +def test_subscribe_topic_multilevel() -> None: + assert cv.subscribe_topic("home/#") == "home/#" + + +def test_subscribe_topic_bad_plus() -> None: + with pytest.raises(Invalid, match="Single-level wildcard"): + cv.subscribe_topic("home/a+/temp") + + +def test_subscribe_topic_hash_not_last() -> None: + with pytest.raises(Invalid, match="Multi-level wildcard must be the last"): + cv.subscribe_topic("home/#/temp") + + +def test_subscribe_topic_hash_not_after_separator() -> None: + with pytest.raises(Invalid, match="must be after a topic level separator"): + cv.subscribe_topic("home#") + + +def test_publish_topic_valid() -> None: + assert cv.publish_topic("home/temp") == "home/temp" + + +def test_publish_topic_wildcard() -> None: + with pytest.raises(Invalid, match="Wildcards can not be used"): + cv.publish_topic("home/+") + + +def test_mqtt_payload_none() -> None: + assert cv.mqtt_payload(None) == "" + + +def test_mqtt_payload_value() -> None: + assert cv.mqtt_payload("hello") == "hello" + + +def test_mqtt_qos_valid() -> None: + assert cv.mqtt_qos("1") == 1 + + +def test_mqtt_qos_not_int() -> None: + with pytest.raises(Invalid, match="must be integer"): + cv.mqtt_qos("abc") + + +def test_mqtt_qos_out_of_range() -> None: + with pytest.raises(Invalid): + cv.mqtt_qos(5) + + +# --------------------------------------------------------------------------- +# requires_component / conflicts_with_component +# --------------------------------------------------------------------------- + + +def test_requires_component_loaded() -> None: + CORE.loaded_integrations = {"mqtt"} + assert cv.requires_component("mqtt")("x") == "x" + + +def test_requires_component_not_loaded() -> None: + CORE.loaded_integrations = set() + with pytest.raises(Invalid, match="requires component mqtt"): + cv.requires_component("mqtt")("x") + + +def test_conflicts_with_component_loaded() -> None: + CORE.loaded_integrations = {"mqtt"} + with pytest.raises(Invalid, match="not compatible with component mqtt"): + cv.conflicts_with_component("mqtt")("x") + + +def test_conflicts_with_component_not_loaded() -> None: + CORE.loaded_integrations = set() + assert cv.conflicts_with_component("mqtt")("x") == "x" + + +# --------------------------------------------------------------------------- +# percentage_int / invalid / valid +# --------------------------------------------------------------------------- + + +def test_percentage_int_with_percent() -> None: + assert cv.percentage_int("50%") == 50 + + +def test_percentage_int_plain() -> None: + assert cv.percentage_int(50) == 50 + + +def test_invalid_always_raises() -> None: + with pytest.raises(Invalid, match="my message"): + cv.invalid("my message")("anything") + + +def test_valid_returns_value() -> None: + obj = object() + assert cv.valid(obj) is obj + + +# --------------------------------------------------------------------------- +# prepend_path / remove_prepend_path +# --------------------------------------------------------------------------- + + +def test_prepend_path_single() -> None: + with pytest.raises(Invalid) as exc_info, cv.prepend_path("foo"): + raise Invalid("bad") + assert list(exc_info.value.path) == ["foo"] + + +def test_prepend_path_list() -> None: + with pytest.raises(Invalid) as exc_info, cv.prepend_path(["a", "b"]): + raise Invalid("bad") + assert list(exc_info.value.path) == ["a", "b"] + + +def test_remove_prepend_path_matching() -> None: + with pytest.raises(Invalid) as exc_info, cv.remove_prepend_path(["a"]): + raise Invalid("bad", path=["a", "b"]) + assert list(exc_info.value.path) == ["b"] + + +def test_remove_prepend_path_non_matching() -> None: + with pytest.raises(Invalid) as exc_info, cv.remove_prepend_path("x"): + raise Invalid("bad", path=["a", "b"]) + assert list(exc_info.value.path) == ["a", "b"] + + +# --------------------------------------------------------------------------- +# one_of / enum +# --------------------------------------------------------------------------- + + +def test_one_of_extra_kwargs() -> None: + with pytest.raises(ValueError): + cv.one_of(1, 2, bogus=True) + + +def test_one_of_schema_extract() -> None: + assert cv.one_of("a", "b")(SCHEMA_EXTRACT) == ("a", "b") + + +def test_one_of_string_and_space() -> None: + assert cv.one_of("a_b", string=True, space="_")("a b") == "a_b" + + +def test_one_of_int() -> None: + assert cv.one_of(1, 2, int=True)("2") == 2 + + +def test_one_of_float() -> None: + assert cv.one_of(1.0, 2.0, float=True)("2.0") == 2.0 + + +def test_one_of_lower() -> None: + assert cv.one_of("abc", lower=True)("ABC") == "abc" + + +def test_one_of_upper() -> None: + assert cv.one_of("ABC", upper=True)("abc") == "ABC" + + +def test_one_of_unknown_with_suggestion() -> None: + with pytest.raises(Invalid, match="did you mean"): + cv.one_of("apple", "banana")("aple") + + +def test_one_of_unknown_no_suggestion() -> None: + with pytest.raises(Invalid, match="valid options are"): + cv.one_of("apple", "banana")("zzzzzz") + + +def test_enum_schema_extract() -> None: + mapping = {"a": 1, "b": 2} + assert cv.enum(mapping)(SCHEMA_EXTRACT) == mapping + + +def test_enum_valid() -> None: + mapping = {"a": 10, "b": 20} + result = cv.enum(mapping)("a") + assert result == "a" + assert result.enum_value == 10 + + +# --------------------------------------------------------------------------- +# lambda_ / returning_lambda +# --------------------------------------------------------------------------- + + +def test_lambda_from_string() -> None: + result = cv.lambda_(_wrap_str("return 5;")) + assert isinstance(result, Lambda) + assert result.value == "return 5;" + + +def test_lambda_existing_lambda() -> None: + lam = Lambda("x") + assert cv.lambda_(lam) is lam + + +def test_lambda_entity_id_reference() -> None: + with pytest.raises(Invalid, match="entity-id-style ID"): + cv.lambda_(Lambda("return id(light.living_room);")) + + +def test_returning_lambda_valid() -> None: + assert isinstance(cv.returning_lambda(_wrap_str("return 5;")), Lambda) + + +def test_returning_lambda_no_return() -> None: + with pytest.raises(Invalid, match="return statement"): + cv.returning_lambda(Lambda("int x = 5;")) + + +# --------------------------------------------------------------------------- +# dimensions +# --------------------------------------------------------------------------- + + +def test_dimensions_list_valid() -> None: + assert cv.dimensions([320, 240]) == [320, 240] + + +def test_dimensions_list_wrong_length() -> None: + with pytest.raises(Invalid, match="length of two"): + cv.dimensions([1, 2, 3]) + + +def test_dimensions_list_non_int() -> None: + with pytest.raises(Invalid, match="must be integers"): + cv.dimensions(["a", "b"]) + + +def test_dimensions_list_non_positive() -> None: + with pytest.raises(Invalid, match="at least be 1"): + cv.dimensions([0, 240]) + + +def test_dimensions_string_valid() -> None: + assert cv.dimensions("320x240") == [320, 240] + + +def test_dimensions_number_invalid() -> None: + with pytest.raises(Invalid, match="must be a string"): + cv.dimensions(320) + + +def test_dimensions_string_invalid() -> None: + with pytest.raises(Invalid, match="Only WIDTHxHEIGHT"): + cv.dimensions("notdimensions") + + +# --------------------------------------------------------------------------- +# entity_id +# --------------------------------------------------------------------------- + + +def test_entity_id_valid() -> None: + assert cv.entity_id("Light.Living_Room") == "light.living_room" + + +def test_entity_id_no_dot() -> None: + with pytest.raises(Invalid, match="exactly one dot"): + cv.entity_id("nodot") + + +def test_entity_id_invalid_char() -> None: + with pytest.raises(Invalid, match="Invalid character"): + cv.entity_id("light.living!room") + + +# --------------------------------------------------------------------------- +# extract_keys / typed_schema +# --------------------------------------------------------------------------- + + +def test_extract_keys_from_schema() -> None: + schema = cv.Schema({cv.Optional("b"): cv.int_, cv.Required("a"): cv.int_}) + assert cv.extract_keys(schema) == ["a", "b"] + + +def test_extract_keys_from_dict() -> None: + assert cv.extract_keys({"x": cv.int_, cv.Optional("y"): cv.int_}) == ["x", "y"] + + +def test_extract_keys_invalid_key() -> None: + with pytest.raises(ValueError): + cv.extract_keys({1: cv.int_}) + + +def test_typed_schema_basic() -> None: + schema = cv.typed_schema({"foo": cv.Schema({cv.Optional("x"): cv.int_})}) + assert schema({"type": "foo", "x": 5}) == {"type": "foo", "x": 5} + + +def test_typed_schema_not_dict() -> None: + schema = cv.typed_schema({"foo": cv.Schema({})}) + with pytest.raises(Invalid, match="must be dict"): + schema("notdict") + + +def test_typed_schema_missing_key() -> None: + schema = cv.typed_schema({"foo": cv.Schema({})}) + with pytest.raises(Invalid, match="type not specified"): + schema({"x": 5}) + + +def test_typed_schema_default_type() -> None: + schema = cv.typed_schema({"foo": cv.Schema({})}, default_type="foo") + assert schema({}) == {"type": "foo"} + + +def test_typed_schema_with_enum() -> None: + schema = cv.typed_schema({"foo": cv.Schema({})}, enum={"foo": 42}) + result = schema({"type": "foo"}) + assert result["type"] == "foo" + assert result["type"].enum_value == 42 + + +# --------------------------------------------------------------------------- +# SplitDefault / OnlyWithout +# --------------------------------------------------------------------------- + + +def test_split_default_no_match() -> None: + _set_core_target(PLATFORM_ESP8266, "arduino") + schema = cv.Schema({cv.SplitDefault("key", esp32="value"): cv.string}) + assert "key" not in schema({}) + + +def test_only_without_component_absent() -> None: + CORE.loaded_integrations = set() + schema = cv.Schema({cv.OnlyWithout("key", "mqtt", default="dval"): cv.string}) + assert schema({})["key"] == "dval" + + +def test_only_without_component_present() -> None: + CORE.loaded_integrations = {"mqtt"} + schema = cv.Schema({cv.OnlyWithout("key", "mqtt", default="dval"): cv.string}) + assert "key" not in schema({}) + + +# --------------------------------------------------------------------------- +# _entity_base_validator / ensure_schema +# --------------------------------------------------------------------------- + + +def test_entity_base_validator_name_present() -> None: + result = cv._entity_base_validator({CONF_NAME: "My Name"}) + assert result[CONF_NAME] == "My Name" + + +def test_entity_base_validator_neither() -> None: + with pytest.raises(Invalid, match="'id:' or 'name:' is required"): + cv._entity_base_validator({}) + + +def test_entity_base_validator_id_not_manual() -> None: + config = {CONF_ID: ID("auto", is_declaration=True, type=int, is_manual=False)} + with pytest.raises(Invalid, match="'id:' or 'name:' is required"): + cv._entity_base_validator(config) + + +def test_entity_base_validator_id_manual() -> None: + config = {CONF_ID: ID("myid", is_declaration=True, type=int, is_manual=True)} + result = cv._entity_base_validator(config) + assert result[CONF_NAME] == "myid" + assert result[CONF_INTERNAL] is True + + +def test_entity_base_validator_name_none() -> None: + result = cv._entity_base_validator({CONF_NAME: None}) + assert result[CONF_NAME] == "" + + +def test_ensure_schema_passthrough() -> None: + schema = cv.Schema({}) + assert cv.ensure_schema(schema) is schema + + +def test_ensure_schema_wraps() -> None: + result = cv.ensure_schema({cv.Optional("x"): cv.int_}) + assert isinstance(result, cv.Schema) + + +# --------------------------------------------------------------------------- +# validate_registry_entry +# --------------------------------------------------------------------------- + + +def _make_registry(*names: str, type_id: object = int) -> Registry: + registry = Registry() + for name in names: + registry.register(name, type_id, cv.Schema({cv.Optional("param"): cv.int_}))( + lambda: None + ) + return registry + + +def test_validate_registry_entry_string_shorthand() -> None: + registry = _make_registry("foo") + result = cv.validate_registry_entry("action", registry)("foo") + assert "foo" in result + + +def test_validate_registry_entry_not_mapping() -> None: + registry = _make_registry() + with pytest.raises(Invalid, match="must consist of key-value mapping"): + cv.validate_registry_entry("action", registry)(5) + + +def test_validate_registry_entry_missing_key() -> None: + registry = _make_registry() + with pytest.raises(Invalid, match="Key missing"): + cv.validate_registry_entry("action", registry)({}) + + +def test_validate_registry_entry_unknown_key() -> None: + registry = _make_registry() + with pytest.raises(Invalid, match="Unable to find action"): + cv.validate_registry_entry("action", registry)({"unknown": {}}) + + +def test_validate_registry_entry_two_keys() -> None: + registry = _make_registry("foo", "bar") + with pytest.raises(Invalid, match="Cannot have two action"): + cv.validate_registry_entry("action", registry)({"foo": {}, "bar": {}}) + + +def test_validate_registry_entry_none_value() -> None: + registry = _make_registry("foo") + result = cv.validate_registry_entry("action", registry)({"foo": None}) + assert "foo" in result + + +def test_validate_registry_entry_no_type_id() -> None: + registry = _make_registry("foo", type_id=None) + result = cv.validate_registry_entry("action", registry)({"foo": {}}) + assert "foo" in result + + +# --------------------------------------------------------------------------- +# maybe_simple_value / entity_category +# --------------------------------------------------------------------------- + + +def test_maybe_simple_value_schema_extract() -> None: + schema = cv.Schema({cv.Required(CONF_VALUE): cv.string}) + validator, key = cv.maybe_simple_value(schema)(SCHEMA_EXTRACT) + assert key == CONF_VALUE + + +def test_maybe_simple_value_dict_with_key() -> None: + schema = cv.Schema({cv.Required(CONF_VALUE): cv.string}) + assert cv.maybe_simple_value(schema)({"value": "x"}) == {"value": "x"} + + +def test_maybe_simple_value_plain() -> None: + schema = cv.Schema({cv.Required(CONF_VALUE): cv.string}) + assert cv.maybe_simple_value(schema)("x") == {"value": "x"} + + +def test_maybe_simple_value_custom_key() -> None: + schema = cv.Schema({cv.Required("name"): cv.string}) + assert cv.maybe_simple_value(schema, key="name")({"name": "x"}) == {"name": "x"} + + +def test_entity_category_valid() -> None: + assert cv.entity_category("config") == "config" + + +def test_entity_category_invalid() -> None: + with pytest.raises(Invalid): + cv.entity_category("bogus") + + +# --------------------------------------------------------------------------- +# url / git_ref / source_refresh / version helpers +# --------------------------------------------------------------------------- + + +def test_url_valid() -> None: + assert cv.url("https://example.com/path") == "https://example.com/path" + + +def test_url_file_scheme() -> None: + assert cv.url("file:///tmp/x") == "file:///tmp/x" + + +def test_url_invalid_value_error() -> None: + with pytest.raises(Invalid, match="Not a valid URL"): + cv.url("http://[::1") + + +def test_url_no_host() -> None: + with pytest.raises(Invalid, match="Expected a file scheme"): + cv.url("notaurl") + + +def test_git_ref_valid() -> None: + assert cv.git_ref("v1.2.3") == "v1.2.3" + + +def test_git_ref_invalid() -> None: + with pytest.raises(Invalid, match="Not a valid git ref"): + cv.git_ref("!!!") + + +def test_source_refresh_always() -> None: + assert cv.source_refresh("always").total_seconds == 0 + + +def test_source_refresh_never() -> None: + assert cv.source_refresh("never").total_seconds == 365250 * 24 * 3600 + + +def test_source_refresh_value() -> None: + assert cv.source_refresh("60s").total_seconds == 60 + + +def test_version_number_valid() -> None: + assert cv.version_number("2024.5.1") == "2024.5.1" + + +def test_version_number_invalid() -> None: + with pytest.raises(Invalid, match="Not a valid version number"): + cv.version_number("notaversion") + + +def test_validate_esphome_version_ok() -> None: + assert cv.validate_esphome_version("1.0.0") == "1.0.0" + + +def test_validate_esphome_version_too_old() -> None: + with pytest.raises(Invalid, match="ESPHome version is too old"): + cv.validate_esphome_version("9999.0.0") + + +def test_platformio_version_constraint_no_op() -> None: + assert cv.platformio_version_constraint("1.2.3") == [(None, "1.2.3")] + + +def test_platformio_version_constraint_with_ops() -> None: + assert cv.platformio_version_constraint(">=1.2.3,<2.0.0") == [ + (">=", "1.2.3"), + ("<", "2.0.0"), + ] + + +# --------------------------------------------------------------------------- +# require_framework_version (no extra_message) / require_esphome_version +# --------------------------------------------------------------------------- + + +def test_require_framework_version_incompatible_no_extra() -> None: + _set_framework_version(PLATFORM_ESP32, "arduino", cv.Version(1, 0, 0)) + with pytest.raises(Invalid, match="incompatible with ESP32"): + cv.require_framework_version()("test") + + +def test_require_framework_version_too_low_no_extra() -> None: + _set_framework_version(PLATFORM_ESP32, "arduino", cv.Version(1, 0, 0)) + with pytest.raises(Invalid, match="at least framework version 2.0.0"): + cv.require_framework_version(esp32_arduino=cv.Version(2, 0, 0))("test") + + +def test_require_framework_version_too_high_no_extra() -> None: + _set_framework_version(PLATFORM_ESP32, "arduino", cv.Version(2, 0, 0)) + with pytest.raises(Invalid, match="version 1.0.0 or lower"): + cv.require_framework_version( + esp32_arduino=cv.Version(1, 0, 0), max_version=True + )("test") + + +def test_require_esphome_version_ok() -> None: + assert cv.require_esphome_version(1, 0, 0)("test") == "test" + + +def test_require_esphome_version_too_old() -> None: + with pytest.raises(Invalid, match="at least ESPHome version 9999.0.0"): + cv.require_esphome_version(9999, 0, 0)("test") + + +# --------------------------------------------------------------------------- +# suppress_invalid / validate_source_shorthand / rename_key +# --------------------------------------------------------------------------- + + +def test_suppress_invalid() -> None: + with cv.suppress_invalid(): + raise Invalid("suppressed") + + +def test_validate_source_shorthand_not_string() -> None: + with pytest.raises(Invalid, match="Shorthand only for strings"): + cv.validate_source_shorthand(123) + + +def test_validate_source_shorthand_local_path(setup_core: Path) -> None: + (setup_core / "mydir").mkdir() + result = cv.validate_source_shorthand("mydir") + assert result[CONF_TYPE] == TYPE_LOCAL + + +def test_validate_source_shorthand_github(setup_core: Path) -> None: + result = cv.validate_source_shorthand("github://user/repo@main") + assert result[CONF_TYPE] == TYPE_GIT + assert result[CONF_REF] == "main" + + +def test_validate_source_shorthand_github_no_ref(setup_core: Path) -> None: + result = cv.validate_source_shorthand("github://user/repo") + assert result[CONF_TYPE] == TYPE_GIT + assert CONF_REF not in result + + +def test_validate_source_shorthand_github_pr(setup_core: Path) -> None: + result = cv.validate_source_shorthand("github://pr#1234") + assert result[CONF_REF] == "pull/1234/head" + + +def test_validate_source_shorthand_invalid(setup_core: Path) -> None: + with pytest.raises(Invalid, match="not a file system path"): + cv.validate_source_shorthand("notvalid") + + +def test_rename_key_present() -> None: + assert cv.rename_key("old", "new")({"old": 5}) == {"new": 5} + + +def test_rename_key_absent() -> None: + assert cv.rename_key("old", "new")({"other": 5}) == {"other": 5} From 0fcf512148ac2e948e2e03d43738fa139923099b Mon Sep 17 00:00:00 2001 From: Tomasz Witke Date: Thu, 25 Jun 2026 13:03:50 +0200 Subject: [PATCH 0579/1815] [image] Use LVGL 9 color formats (#16871) --- esphome/components/image/image.cpp | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/esphome/components/image/image.cpp b/esphome/components/image/image.cpp index c95b693cf0..9b603683ab 100644 --- a/esphome/components/image/image.cpp +++ b/esphome/components/image/image.cpp @@ -123,26 +123,18 @@ lv_image_dsc_t *Image::get_lv_image_dsc() { break; case IMAGE_TYPE_RGB: -#if LV_COLOR_DEPTH == 32 switch (this->transparency_) { case TRANSPARENCY_ALPHA_CHANNEL: - this->dsc_.header.cf = LV_IMG_CF_TRUE_COLOR_ALPHA; + this->dsc_.header.cf = LV_COLOR_FORMAT_ARGB8888; break; case TRANSPARENCY_CHROMA_KEY: - this->dsc_.header.cf = LV_IMG_CF_TRUE_COLOR_CHROMA_KEYED; - break; default: - this->dsc_.header.cf = LV_IMG_CF_TRUE_COLOR; + this->dsc_.header.cf = LV_COLOR_FORMAT_RGB888; break; } -#else - this->dsc_.header.cf = - this->transparency_ == TRANSPARENCY_ALPHA_CHANNEL ? LV_COLOR_FORMAT_ARGB8888 : LV_COLOR_FORMAT_RGB888; -#endif break; case IMAGE_TYPE_RGB565: -#if LV_COLOR_DEPTH == 16 switch (this->transparency_) { case TRANSPARENCY_ALPHA_CHANNEL: this->dsc_.header.cf = LV_COLOR_FORMAT_RGB565A8; @@ -150,10 +142,6 @@ lv_image_dsc_t *Image::get_lv_image_dsc() { default: this->dsc_.header.cf = LV_COLOR_FORMAT_RGB565; } -#else - this->dsc_.header.cf = - this->transparency_ == TRANSPARENCY_ALPHA_CHANNEL ? LV_IMG_CF_RGB565A8 : LV_IMG_CF_RGB565; -#endif break; } } From f769457bb0e37ef6163ee593058e640146653d5a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:19:30 +1200 Subject: [PATCH 0580/1815] Mark configurable classes as final (15/21: script-slow_pwm) (#16966) --- esphome/components/script/script.h | 8 ++++---- esphome/components/sdl/sdl_esphome.h | 2 +- esphome/components/sdl/touchscreen/sdl_touchscreen.h | 2 +- esphome/components/sdm_meter/sdm_meter.h | 2 +- esphome/components/sdp3x/sdp3x.h | 4 +++- esphome/components/sds011/sds011.h | 2 +- .../seeed_mr24hpc1/button/custom_mode_end_button.h | 2 +- .../seeed_mr24hpc1/button/restart_button.h | 2 +- .../seeed_mr24hpc1/number/custom_mode_number.h | 2 +- .../seeed_mr24hpc1/number/custom_unman_time_number.h | 2 +- .../number/existence_threshold_number.h | 2 +- .../seeed_mr24hpc1/number/motion_threshold_number.h | 2 +- .../number/motion_trigger_time_number.h | 2 +- .../seeed_mr24hpc1/number/motiontorest_time_number.h | 2 +- .../seeed_mr24hpc1/number/sensitivity_number.h | 2 +- esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h | 4 ++-- .../select/existence_boundary_select.h | 2 +- .../seeed_mr24hpc1/select/motion_boundary_select.h | 2 +- .../seeed_mr24hpc1/select/scene_mode_select.h | 2 +- .../seeed_mr24hpc1/select/unman_time_select.h | 2 +- .../seeed_mr24hpc1/switch/underlyFuc_switch.h | 2 +- esphome/components/seeed_mr60bha2/seeed_mr60bha2.h | 4 ++-- .../button/get_radar_parameters_button.h | 2 +- .../seeed_mr60fda2/button/reset_radar_button.h | 2 +- esphome/components/seeed_mr60fda2/seeed_mr60fda2.h | 4 ++-- .../seeed_mr60fda2/select/height_threshold_select.h | 2 +- .../seeed_mr60fda2/select/install_height_select.h | 2 +- .../seeed_mr60fda2/select/sensitivity_select.h | 2 +- esphome/components/selec_meter/selec_meter.h | 2 +- esphome/components/select/automation.h | 12 ++++++------ esphome/components/sen0321/sen0321.h | 2 +- esphome/components/sen21231/sen21231.h | 2 +- esphome/components/sen5x/automation.h | 2 +- esphome/components/sen5x/sen5x.h | 2 +- esphome/components/sen6x/sen6x.h | 2 +- esphome/components/sendspin/automation.h | 2 +- .../sendspin/media_player/sendspin_media_player.h | 2 +- .../components/sendspin/media_source/automations.h | 4 ++-- .../sendspin/media_source/sendspin_media_source.h | 6 +++--- esphome/components/sendspin/sensor/sendspin_sensor.h | 4 ++-- .../sendspin/text_sensor/sendspin_text_sensor.h | 2 +- esphome/components/senseair/senseair.h | 12 ++++++------ esphome/components/sensor/automation.h | 10 +++++----- esphome/components/serial_proxy/serial_proxy.h | 2 +- esphome/components/servo/servo.h | 6 +++--- esphome/components/sfa30/sfa30.h | 2 +- esphome/components/sgp30/sgp30.h | 2 +- esphome/components/sgp4x/sgp4x.h | 4 +++- esphome/components/shelly_dimmer/shelly_dimmer.h | 2 +- esphome/components/sht3xd/sht3xd.h | 2 +- esphome/components/sht4x/sht4x.h | 2 +- esphome/components/shtcx/shtcx.h | 2 +- esphome/components/shutdown/button/shutdown_button.h | 2 +- esphome/components/shutdown/switch/shutdown_switch.h | 2 +- .../sigma_delta_output/sigma_delta_output.h | 2 +- esphome/components/sim800l/sim800l.h | 12 ++++++------ esphome/components/slow_pwm/slow_pwm_output.h | 2 +- 57 files changed, 92 insertions(+), 88 deletions(-) diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 6cd33e566c..790ac107c5 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -216,7 +216,7 @@ template class ParallelScript : public Script { template class ScriptExecuteAction; -template class ScriptExecuteAction, Ts...> : public Action { +template class ScriptExecuteAction, Ts...> final : public Action { public: ScriptExecuteAction(Script *script) : script_(script) {} @@ -254,7 +254,7 @@ template class ScriptExecuteAction, T Args args_; }; -template class ScriptStopAction : public Action { +template class ScriptStopAction final : public Action { public: ScriptStopAction(C *script) : script_(script) {} @@ -264,7 +264,7 @@ template class ScriptStopAction : public Action C *script_; }; -template class IsRunningCondition : public Condition { +template class IsRunningCondition final : public Condition { public: explicit IsRunningCondition(C *parent) : parent_(parent) {} @@ -281,7 +281,7 @@ template class IsRunningCondition : public Condition class ScriptWaitAction : public Action, public Component { +template class ScriptWaitAction final : public Action, public Component { public: ScriptWaitAction(C *script) : script_(script) {} diff --git a/esphome/components/sdl/sdl_esphome.h b/esphome/components/sdl/sdl_esphome.h index a5ebf44c38..635eb1e3f8 100644 --- a/esphome/components/sdl/sdl_esphome.h +++ b/esphome/components/sdl/sdl_esphome.h @@ -13,7 +13,7 @@ namespace esphome::sdl { constexpr static const char *const TAG = "sdl"; -class Sdl : public display::Display { +class Sdl final : public display::Display { public: display::DisplayType get_display_type() override { return display::DISPLAY_TYPE_COLOR; } void update() override; diff --git a/esphome/components/sdl/touchscreen/sdl_touchscreen.h b/esphome/components/sdl/touchscreen/sdl_touchscreen.h index cf2fd65088..50a584949b 100644 --- a/esphome/components/sdl/touchscreen/sdl_touchscreen.h +++ b/esphome/components/sdl/touchscreen/sdl_touchscreen.h @@ -6,7 +6,7 @@ namespace esphome::sdl { -class SdlTouchscreen : public touchscreen::Touchscreen, public Parented { +class SdlTouchscreen final : public touchscreen::Touchscreen, public Parented { public: void setup() override { this->x_raw_max_ = this->display_->get_width(); diff --git a/esphome/components/sdm_meter/sdm_meter.h b/esphome/components/sdm_meter/sdm_meter.h index e729e29d6c..a4dbde016c 100644 --- a/esphome/components/sdm_meter/sdm_meter.h +++ b/esphome/components/sdm_meter/sdm_meter.h @@ -8,7 +8,7 @@ namespace esphome::sdm_meter { -class SDMMeter : public PollingComponent, public modbus::ModbusDevice { +class SDMMeter final : public PollingComponent, public modbus::ModbusDevice { public: void set_voltage_sensor(uint8_t phase, sensor::Sensor *voltage_sensor) { this->phases_[phase].setup = true; diff --git a/esphome/components/sdp3x/sdp3x.h b/esphome/components/sdp3x/sdp3x.h index c4ef6a4a1e..19c8d0f678 100644 --- a/esphome/components/sdp3x/sdp3x.h +++ b/esphome/components/sdp3x/sdp3x.h @@ -8,7 +8,9 @@ namespace esphome::sdp3x { enum MeasurementMode { MASS_FLOW_AVG, DP_AVG }; -class SDP3XComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice, public sensor::Sensor { +class SDP3XComponent final : public PollingComponent, + public sensirion_common::SensirionI2CDevice, + public sensor::Sensor { public: /// Schedule temperature+pressure readings. void update() override; diff --git a/esphome/components/sds011/sds011.h b/esphome/components/sds011/sds011.h index 56d46d118f..4f4571ab69 100644 --- a/esphome/components/sds011/sds011.h +++ b/esphome/components/sds011/sds011.h @@ -7,7 +7,7 @@ namespace esphome::sds011 { -class SDS011Component : public Component, public uart::UARTDevice { +class SDS011Component final : public Component, public uart::UARTDevice { public: SDS011Component() = default; diff --git a/esphome/components/seeed_mr24hpc1/button/custom_mode_end_button.h b/esphome/components/seeed_mr24hpc1/button/custom_mode_end_button.h index bc98bb93b6..fc0cbbdc76 100644 --- a/esphome/components/seeed_mr24hpc1/button/custom_mode_end_button.h +++ b/esphome/components/seeed_mr24hpc1/button/custom_mode_end_button.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class CustomSetEndButton : public button::Button, public Parented { +class CustomSetEndButton final : public button::Button, public Parented { public: CustomSetEndButton() = default; diff --git a/esphome/components/seeed_mr24hpc1/button/restart_button.h b/esphome/components/seeed_mr24hpc1/button/restart_button.h index 49a4f46138..c6c530004b 100644 --- a/esphome/components/seeed_mr24hpc1/button/restart_button.h +++ b/esphome/components/seeed_mr24hpc1/button/restart_button.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class RestartButton : public button::Button, public Parented { +class RestartButton final : public button::Button, public Parented { public: RestartButton() = default; diff --git a/esphome/components/seeed_mr24hpc1/number/custom_mode_number.h b/esphome/components/seeed_mr24hpc1/number/custom_mode_number.h index f51e592fc0..842530a379 100644 --- a/esphome/components/seeed_mr24hpc1/number/custom_mode_number.h +++ b/esphome/components/seeed_mr24hpc1/number/custom_mode_number.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class CustomModeNumber : public number::Number, public Parented { +class CustomModeNumber final : public number::Number, public Parented { public: CustomModeNumber() = default; diff --git a/esphome/components/seeed_mr24hpc1/number/custom_unman_time_number.h b/esphome/components/seeed_mr24hpc1/number/custom_unman_time_number.h index 281e727a36..0ef2073195 100644 --- a/esphome/components/seeed_mr24hpc1/number/custom_unman_time_number.h +++ b/esphome/components/seeed_mr24hpc1/number/custom_unman_time_number.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class CustomUnmanTimeNumber : public number::Number, public Parented { +class CustomUnmanTimeNumber final : public number::Number, public Parented { public: CustomUnmanTimeNumber() = default; diff --git a/esphome/components/seeed_mr24hpc1/number/existence_threshold_number.h b/esphome/components/seeed_mr24hpc1/number/existence_threshold_number.h index c811b2d6b6..11aa45a6dc 100644 --- a/esphome/components/seeed_mr24hpc1/number/existence_threshold_number.h +++ b/esphome/components/seeed_mr24hpc1/number/existence_threshold_number.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class ExistenceThresholdNumber : public number::Number, public Parented { +class ExistenceThresholdNumber final : public number::Number, public Parented { public: ExistenceThresholdNumber() = default; diff --git a/esphome/components/seeed_mr24hpc1/number/motion_threshold_number.h b/esphome/components/seeed_mr24hpc1/number/motion_threshold_number.h index 748119f198..01f62f67fb 100644 --- a/esphome/components/seeed_mr24hpc1/number/motion_threshold_number.h +++ b/esphome/components/seeed_mr24hpc1/number/motion_threshold_number.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class MotionThresholdNumber : public number::Number, public Parented { +class MotionThresholdNumber final : public number::Number, public Parented { public: MotionThresholdNumber() = default; diff --git a/esphome/components/seeed_mr24hpc1/number/motion_trigger_time_number.h b/esphome/components/seeed_mr24hpc1/number/motion_trigger_time_number.h index dd7947b2a5..44cf89837e 100644 --- a/esphome/components/seeed_mr24hpc1/number/motion_trigger_time_number.h +++ b/esphome/components/seeed_mr24hpc1/number/motion_trigger_time_number.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class MotionTriggerTimeNumber : public number::Number, public Parented { +class MotionTriggerTimeNumber final : public number::Number, public Parented { public: MotionTriggerTimeNumber() = default; diff --git a/esphome/components/seeed_mr24hpc1/number/motiontorest_time_number.h b/esphome/components/seeed_mr24hpc1/number/motiontorest_time_number.h index 47493e7954..c12f14e79f 100644 --- a/esphome/components/seeed_mr24hpc1/number/motiontorest_time_number.h +++ b/esphome/components/seeed_mr24hpc1/number/motiontorest_time_number.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class MotionToRestTimeNumber : public number::Number, public Parented { +class MotionToRestTimeNumber final : public number::Number, public Parented { public: MotionToRestTimeNumber() = default; diff --git a/esphome/components/seeed_mr24hpc1/number/sensitivity_number.h b/esphome/components/seeed_mr24hpc1/number/sensitivity_number.h index c1d5435151..954c004e67 100644 --- a/esphome/components/seeed_mr24hpc1/number/sensitivity_number.h +++ b/esphome/components/seeed_mr24hpc1/number/sensitivity_number.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class SensitivityNumber : public number::Number, public Parented { +class SensitivityNumber final : public number::Number, public Parented { public: SensitivityNumber() = default; diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h index b62504ba0e..b231bab33e 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h @@ -92,8 +92,8 @@ static const char *const S_BOUNDARY_STR[10] = {"0.5m", "1.0m", "1.5m", "2.0m", " "3.0m", "3.5m", "4.0m", "4.5m", "5.0m"}; // uint: m static const float S_PRESENCE_OF_DETECTION_RANGE_STR[7] = {0.0f, 0.5f, 1.0f, 1.5f, 2.0f, 2.5f, 3.0f}; // uint: m -class MR24HPC1Component : public Component, - public uart::UARTDevice { // The class name must be the name defined by text_sensor.py +class MR24HPC1Component final : public Component, + public uart::UARTDevice { // The class name must be the name defined by text_sensor.py #ifdef USE_TEXT_SENSOR SUB_TEXT_SENSOR(heartbeat_state) SUB_TEXT_SENSOR(product_model) diff --git a/esphome/components/seeed_mr24hpc1/select/existence_boundary_select.h b/esphome/components/seeed_mr24hpc1/select/existence_boundary_select.h index 878d0525c9..1fce716ed6 100644 --- a/esphome/components/seeed_mr24hpc1/select/existence_boundary_select.h +++ b/esphome/components/seeed_mr24hpc1/select/existence_boundary_select.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class ExistenceBoundarySelect : public select::Select, public Parented { +class ExistenceBoundarySelect final : public select::Select, public Parented { public: ExistenceBoundarySelect() = default; diff --git a/esphome/components/seeed_mr24hpc1/select/motion_boundary_select.h b/esphome/components/seeed_mr24hpc1/select/motion_boundary_select.h index eecdef2019..721bc67f69 100644 --- a/esphome/components/seeed_mr24hpc1/select/motion_boundary_select.h +++ b/esphome/components/seeed_mr24hpc1/select/motion_boundary_select.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class MotionBoundarySelect : public select::Select, public Parented { +class MotionBoundarySelect final : public select::Select, public Parented { public: MotionBoundarySelect() = default; diff --git a/esphome/components/seeed_mr24hpc1/select/scene_mode_select.h b/esphome/components/seeed_mr24hpc1/select/scene_mode_select.h index 377c61b32f..40e365aa7b 100644 --- a/esphome/components/seeed_mr24hpc1/select/scene_mode_select.h +++ b/esphome/components/seeed_mr24hpc1/select/scene_mode_select.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class SceneModeSelect : public select::Select, public Parented { +class SceneModeSelect final : public select::Select, public Parented { public: SceneModeSelect() = default; diff --git a/esphome/components/seeed_mr24hpc1/select/unman_time_select.h b/esphome/components/seeed_mr24hpc1/select/unman_time_select.h index e68ae5e54f..bba5363565 100644 --- a/esphome/components/seeed_mr24hpc1/select/unman_time_select.h +++ b/esphome/components/seeed_mr24hpc1/select/unman_time_select.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class UnmanTimeSelect : public select::Select, public Parented { +class UnmanTimeSelect final : public select::Select, public Parented { public: UnmanTimeSelect() = default; diff --git a/esphome/components/seeed_mr24hpc1/switch/underlyFuc_switch.h b/esphome/components/seeed_mr24hpc1/switch/underlyFuc_switch.h index 3224640ce7..8b8dbdf5de 100644 --- a/esphome/components/seeed_mr24hpc1/switch/underlyFuc_switch.h +++ b/esphome/components/seeed_mr24hpc1/switch/underlyFuc_switch.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class UnderlyOpenFunctionSwitch : public switch_::Switch, public Parented { +class UnderlyOpenFunctionSwitch final : public switch_::Switch, public Parented { public: UnderlyOpenFunctionSwitch() = default; diff --git a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.h b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.h index 008acc6a57..0ce25790cc 100644 --- a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.h +++ b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.h @@ -21,8 +21,8 @@ static const uint16_t HEART_RATE_TYPE_BUFFER = 0x0A15; static const uint16_t DISTANCE_TYPE_BUFFER = 0x0A16; static const uint16_t PRINT_CLOUD_BUFFER = 0x0A04; -class MR60BHA2Component : public Component, - public uart::UARTDevice { // The class name must be the name defined by text_sensor.py +class MR60BHA2Component final : public Component, + public uart::UARTDevice { // The class name must be the name defined by text_sensor.py #ifdef USE_BINARY_SENSOR SUB_BINARY_SENSOR(has_target); #endif diff --git a/esphome/components/seeed_mr60fda2/button/get_radar_parameters_button.h b/esphome/components/seeed_mr60fda2/button/get_radar_parameters_button.h index c1b96d5f08..7a604592c0 100644 --- a/esphome/components/seeed_mr60fda2/button/get_radar_parameters_button.h +++ b/esphome/components/seeed_mr60fda2/button/get_radar_parameters_button.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr60fda2 { -class GetRadarParametersButton : public button::Button, public Parented { +class GetRadarParametersButton final : public button::Button, public Parented { public: GetRadarParametersButton() = default; diff --git a/esphome/components/seeed_mr60fda2/button/reset_radar_button.h b/esphome/components/seeed_mr60fda2/button/reset_radar_button.h index 174ef5425e..cdfb259909 100644 --- a/esphome/components/seeed_mr60fda2/button/reset_radar_button.h +++ b/esphome/components/seeed_mr60fda2/button/reset_radar_button.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr60fda2 { -class ResetRadarButton : public button::Button, public Parented { +class ResetRadarButton final : public button::Button, public Parented { public: ResetRadarButton() = default; diff --git a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.h b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.h index 0e97447074..f231de5eec 100644 --- a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.h +++ b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.h @@ -56,8 +56,8 @@ static const char *const INSTALL_HEIGHT_STR[7] = {"2.4m", "2.5m", "2.6", "2.7m", static const char *const HEIGHT_THRESHOLD_STR[7] = {"0.0m", "0.1m", "0.2m", "0.3m", "0.4m", "0.5m", "0.6m"}; static const char *const SENSITIVITY_STR[3] = {"1", "2", "3"}; -class MR60FDA2Component : public Component, - public uart::UARTDevice { // The class name must be the name defined by text_sensor.py +class MR60FDA2Component final : public Component, + public uart::UARTDevice { // The class name must be the name defined by text_sensor.py #ifdef USE_BINARY_SENSOR SUB_BINARY_SENSOR(people_exist) SUB_BINARY_SENSOR(fall_detected) diff --git a/esphome/components/seeed_mr60fda2/select/height_threshold_select.h b/esphome/components/seeed_mr60fda2/select/height_threshold_select.h index 0e49576658..0c93085337 100644 --- a/esphome/components/seeed_mr60fda2/select/height_threshold_select.h +++ b/esphome/components/seeed_mr60fda2/select/height_threshold_select.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr60fda2 { -class HeightThresholdSelect : public select::Select, public Parented { +class HeightThresholdSelect final : public select::Select, public Parented { public: HeightThresholdSelect() = default; diff --git a/esphome/components/seeed_mr60fda2/select/install_height_select.h b/esphome/components/seeed_mr60fda2/select/install_height_select.h index c1e2a3eeb1..964edfa127 100644 --- a/esphome/components/seeed_mr60fda2/select/install_height_select.h +++ b/esphome/components/seeed_mr60fda2/select/install_height_select.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr60fda2 { -class InstallHeightSelect : public select::Select, public Parented { +class InstallHeightSelect final : public select::Select, public Parented { public: InstallHeightSelect() = default; diff --git a/esphome/components/seeed_mr60fda2/select/sensitivity_select.h b/esphome/components/seeed_mr60fda2/select/sensitivity_select.h index f2e0307dc1..1d96257871 100644 --- a/esphome/components/seeed_mr60fda2/select/sensitivity_select.h +++ b/esphome/components/seeed_mr60fda2/select/sensitivity_select.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr60fda2 { -class SensitivitySelect : public select::Select, public Parented { +class SensitivitySelect final : public select::Select, public Parented { public: SensitivitySelect() = default; diff --git a/esphome/components/selec_meter/selec_meter.h b/esphome/components/selec_meter/selec_meter.h index 159acab124..6b5552a098 100644 --- a/esphome/components/selec_meter/selec_meter.h +++ b/esphome/components/selec_meter/selec_meter.h @@ -15,7 +15,7 @@ namespace esphome::selec_meter { public: \ void set_##name##_sensor(sensor::Sensor *(name)) { this->name##_sensor_ = name; } -class SelecMeter : public PollingComponent, public modbus::ModbusDevice { +class SelecMeter final : public PollingComponent, public modbus::ModbusDevice { public: SELEC_METER_SENSOR(total_active_energy) SELEC_METER_SENSOR(import_active_energy) diff --git a/esphome/components/select/automation.h b/esphome/components/select/automation.h index ffdabd5f7c..8e5da893ad 100644 --- a/esphome/components/select/automation.h +++ b/esphome/components/select/automation.h @@ -6,7 +6,7 @@ namespace esphome::select { -class SelectStateTrigger : public Trigger { +class SelectStateTrigger final : public Trigger { public: explicit SelectStateTrigger(Select *parent) : parent_(parent) { parent->add_on_state_callback( @@ -17,7 +17,7 @@ class SelectStateTrigger : public Trigger { Select *parent_; }; -template class SelectSetAction : public Action { +template class SelectSetAction final : public Action { public: explicit SelectSetAction(Select *select) : select_(select) {} TEMPLATABLE_VALUE(std::string, option) @@ -32,7 +32,7 @@ template class SelectSetAction : public Action { Select *select_; }; -template class SelectSetIndexAction : public Action { +template class SelectSetIndexAction final : public Action { public: explicit SelectSetIndexAction(Select *select) : select_(select) {} TEMPLATABLE_VALUE(size_t, index) @@ -47,7 +47,7 @@ template class SelectSetIndexAction : public Action { Select *select_; }; -template class SelectOperationAction : public Action { +template class SelectOperationAction final : public Action { public: explicit SelectOperationAction(Select *select) : select_(select) {} TEMPLATABLE_VALUE(bool, cycle) @@ -66,7 +66,7 @@ template class SelectOperationAction : public Action { Select *select_; }; -template class SelectIsCondition : public Condition { +template class SelectIsCondition final : public Condition { public: SelectIsCondition(Select *parent, const char *const *option_list) : parent_(parent), option_list_(option_list) {} @@ -85,7 +85,7 @@ template class SelectIsCondition : public Condition class SelectIsCondition<0, Ts...> : public Condition { +template class SelectIsCondition<0, Ts...> final : public Condition { public: SelectIsCondition(Select *parent, std::function &&f) : parent_(parent), f_(f) {} diff --git a/esphome/components/sen0321/sen0321.h b/esphome/components/sen0321/sen0321.h index 6d5aa20a61..ed7df3fcaf 100644 --- a/esphome/components/sen0321/sen0321.h +++ b/esphome/components/sen0321/sen0321.h @@ -20,7 +20,7 @@ static const uint8_t SET_REGISTER = 0x04; static const uint8_t SENSOR_PASS_READ_REG = 0x07; static const uint8_t SENSOR_AUTO_READ_REG = 0x09; -class Sen0321Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class Sen0321Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void update() override; void dump_config() override; diff --git a/esphome/components/sen21231/sen21231.h b/esphome/components/sen21231/sen21231.h index 486a9473d2..ad05966011 100644 --- a/esphome/components/sen21231/sen21231.h +++ b/esphome/components/sen21231/sen21231.h @@ -63,7 +63,7 @@ using person_sensor_results_t = struct __attribute__((__packed__)) { uint16_t checksum; // Bytes 38-39. }; -class Sen21231Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class Sen21231Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void update() override; void dump_config() override; diff --git a/esphome/components/sen5x/automation.h b/esphome/components/sen5x/automation.h index e6111f4a8f..21d938c4fe 100644 --- a/esphome/components/sen5x/automation.h +++ b/esphome/components/sen5x/automation.h @@ -6,7 +6,7 @@ namespace esphome::sen5x { -template class StartFanAction : public Action { +template class StartFanAction final : public Action { public: explicit StartFanAction(SEN5XComponent *sen5x) : sen5x_(sen5x) {} diff --git a/esphome/components/sen5x/sen5x.h b/esphome/components/sen5x/sen5x.h index ec8f9cc544..6b5a1f8510 100644 --- a/esphome/components/sen5x/sen5x.h +++ b/esphome/components/sen5x/sen5x.h @@ -44,7 +44,7 @@ struct TemperatureCompensation { // Prevents wear of the flash because of too many write operations static const uint32_t SHORTEST_BASELINE_STORE_INTERVAL = 2 * 60 * 60 * 1000; -class SEN5XComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SEN5XComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index bc44611882..041bf3b1aa 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -6,7 +6,7 @@ namespace esphome::sen6x { -class SEN6XComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SEN6XComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { SUB_SENSOR(pm_1_0) SUB_SENSOR(pm_2_5) SUB_SENSOR(pm_4_0) diff --git a/esphome/components/sendspin/automation.h b/esphome/components/sendspin/automation.h index be3b1eb39d..0b408b1235 100644 --- a/esphome/components/sendspin/automation.h +++ b/esphome/components/sendspin/automation.h @@ -10,7 +10,7 @@ namespace esphome::sendspin_ { #ifdef USE_SENDSPIN_CONTROLLER -template class SendspinSwitchCommandAction : public Action, public Parented { +template class SendspinSwitchCommandAction final : public Action, public Parented { public: void play(const Ts &...x) override { // Clear any EXTERNAL_SOURCE state so the switch command is followed diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.h b/esphome/components/sendspin/media_player/sendspin_media_player.h index 52786d6d7b..651e1562be 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.h +++ b/esphome/components/sendspin/media_player/sendspin_media_player.h @@ -9,7 +9,7 @@ namespace esphome::sendspin_ { -class SendspinMediaPlayer : public SendspinChild, public media_player::MediaPlayer { +class SendspinMediaPlayer final : public SendspinChild, public media_player::MediaPlayer { public: void setup() override; void dump_config() override; diff --git a/esphome/components/sendspin/media_source/automations.h b/esphome/components/sendspin/media_source/automations.h index 08d2b2004b..f5c35f107a 100644 --- a/esphome/components/sendspin/media_source/automations.h +++ b/esphome/components/sendspin/media_source/automations.h @@ -10,13 +10,13 @@ namespace esphome::sendspin_ { template -class EnableStaticDelayAdjustmentAction : public Action, public Parented { +class EnableStaticDelayAdjustmentAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_static_delay_adjustable(true); } }; template -class DisableStaticDelayAdjustmentAction : public Action, public Parented { +class DisableStaticDelayAdjustmentAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_static_delay_adjustable(false); } }; diff --git a/esphome/components/sendspin/media_source/sendspin_media_source.h b/esphome/components/sendspin/media_source/sendspin_media_source.h index 843578783e..1c5cb625bf 100644 --- a/esphome/components/sendspin/media_source/sendspin_media_source.h +++ b/esphome/components/sendspin/media_source/sendspin_media_source.h @@ -17,9 +17,9 @@ namespace esphome::sendspin_ { /// Implements PlayerRoleListener to receive audio data from the sendspin-cpp library's /// SyncTask and bridges it to ESPHome's MediaSource output pipeline. Also forwards /// transport commands to the hub's controller role. -class SendspinMediaSource : public SendspinChild, - public media_source::MediaSource, - public sendspin::PlayerRoleListener { +class SendspinMediaSource final : public SendspinChild, + public media_source::MediaSource, + public sendspin::PlayerRoleListener { public: void setup() override; void dump_config() override; diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.h b/esphome/components/sendspin/sensor/sendspin_sensor.h index cbfe1742c9..5b29fff55f 100644 --- a/esphome/components/sendspin/sensor/sendspin_sensor.h +++ b/esphome/components/sendspin/sensor/sendspin_sensor.h @@ -11,7 +11,7 @@ namespace esphome::sendspin_ { -class SendspinTrackProgressSensor : public sensor::Sensor, public SendspinPollingChild { +class SendspinTrackProgressSensor final : public sensor::Sensor, public SendspinPollingChild { public: void dump_config() override; void setup() override; @@ -24,7 +24,7 @@ enum class SendspinNumericMetadataTypes { TRACK, }; -class SendspinMetadataSensor : public sensor::Sensor, public SendspinChild { +class SendspinMetadataSensor final : public sensor::Sensor, public SendspinChild { public: void dump_config() override; void setup() override; diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h index 203b01d024..d38f360d94 100644 --- a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h @@ -18,7 +18,7 @@ enum class SendspinTextMetadataTypes { ALBUM_ARTIST, }; -class SendspinTextSensor : public SendspinChild, public text_sensor::TextSensor { +class SendspinTextSensor final : public SendspinChild, public text_sensor::TextSensor { public: void dump_config() override; void setup() override; diff --git a/esphome/components/senseair/senseair.h b/esphome/components/senseair/senseair.h index 333c003f48..48154a53d9 100644 --- a/esphome/components/senseair/senseair.h +++ b/esphome/components/senseair/senseair.h @@ -18,7 +18,7 @@ enum SenseAirStatus : uint8_t { RESERVED = 1 << 7 }; -class SenseAirComponent : public PollingComponent, public uart::UARTDevice { +class SenseAirComponent final : public PollingComponent, public uart::UARTDevice { public: void set_co2_sensor(sensor::Sensor *co2_sensor) { co2_sensor_ = co2_sensor; } @@ -37,7 +37,7 @@ class SenseAirComponent : public PollingComponent, public uart::UARTDevice { sensor::Sensor *co2_sensor_{nullptr}; }; -template class SenseAirBackgroundCalibrationAction : public Action { +template class SenseAirBackgroundCalibrationAction final : public Action { public: SenseAirBackgroundCalibrationAction(SenseAirComponent *senseair) : senseair_(senseair) {} @@ -47,7 +47,7 @@ template class SenseAirBackgroundCalibrationAction : public Acti SenseAirComponent *senseair_; }; -template class SenseAirBackgroundCalibrationResultAction : public Action { +template class SenseAirBackgroundCalibrationResultAction final : public Action { public: SenseAirBackgroundCalibrationResultAction(SenseAirComponent *senseair) : senseair_(senseair) {} @@ -57,7 +57,7 @@ template class SenseAirBackgroundCalibrationResultAction : publi SenseAirComponent *senseair_; }; -template class SenseAirABCEnableAction : public Action { +template class SenseAirABCEnableAction final : public Action { public: SenseAirABCEnableAction(SenseAirComponent *senseair) : senseair_(senseair) {} @@ -67,7 +67,7 @@ template class SenseAirABCEnableAction : public Action { SenseAirComponent *senseair_; }; -template class SenseAirABCDisableAction : public Action { +template class SenseAirABCDisableAction final : public Action { public: SenseAirABCDisableAction(SenseAirComponent *senseair) : senseair_(senseair) {} @@ -77,7 +77,7 @@ template class SenseAirABCDisableAction : public Action { SenseAirComponent *senseair_; }; -template class SenseAirABCGetPeriodAction : public Action { +template class SenseAirABCGetPeriodAction final : public Action { public: SenseAirABCGetPeriodAction(SenseAirComponent *senseair) : senseair_(senseair) {} diff --git a/esphome/components/sensor/automation.h b/esphome/components/sensor/automation.h index 37578f5320..35a4a29e0d 100644 --- a/esphome/components/sensor/automation.h +++ b/esphome/components/sensor/automation.h @@ -6,21 +6,21 @@ namespace esphome::sensor { -class SensorStateTrigger : public Trigger { +class SensorStateTrigger final : public Trigger { public: explicit SensorStateTrigger(Sensor *parent) { parent->add_on_state_callback([this](float value) { this->trigger(value); }); } }; -class SensorRawStateTrigger : public Trigger { +class SensorRawStateTrigger final : public Trigger { public: explicit SensorRawStateTrigger(Sensor *parent) { parent->add_on_raw_state_callback([this](float value) { this->trigger(value); }); } }; -template class SensorPublishAction : public Action { +template class SensorPublishAction final : public Action { public: SensorPublishAction(Sensor *sensor) : sensor_(sensor) {} TEMPLATABLE_VALUE(float, state) @@ -31,7 +31,7 @@ template class SensorPublishAction : public Action { Sensor *sensor_; }; -class ValueRangeTrigger : public Trigger, public Component { +class ValueRangeTrigger final : public Trigger, public Component { public: explicit ValueRangeTrigger(Sensor *parent) : parent_(parent) {} @@ -83,7 +83,7 @@ class ValueRangeTrigger : public Trigger, public Component { TemplatableFn max_{[](float) -> float { return NAN; }}; }; -template class SensorInRangeCondition : public Condition { +template class SensorInRangeCondition final : public Condition { public: SensorInRangeCondition(Sensor *parent) : parent_(parent) {} diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index c435787a61..e35fab3d42 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -41,7 +41,7 @@ enum SerialProxyLineStateFlag : uint32_t { /// Maximum bytes to read from UART in a single loop iteration inline constexpr size_t SERIAL_PROXY_MAX_READ_SIZE = 256; -class SerialProxy : public uart::UARTDevice, public Component { +class SerialProxy final : public uart::UARTDevice, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/servo/servo.h b/esphome/components/servo/servo.h index 31e9357947..156dab6dc1 100644 --- a/esphome/components/servo/servo.h +++ b/esphome/components/servo/servo.h @@ -10,7 +10,7 @@ namespace esphome::servo { extern uint32_t global_servo_id; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -class Servo : public Component { +class Servo final : public Component { public: void set_output(output::FloatOutput *output) { output_ = output; } void loop() override; @@ -51,7 +51,7 @@ class Servo : public Component { }; }; -template class ServoWriteAction : public Action { +template class ServoWriteAction final : public Action { public: ServoWriteAction(Servo *servo) : servo_(servo) {} TEMPLATABLE_VALUE(float, value) @@ -62,7 +62,7 @@ template class ServoWriteAction : public Action { Servo *servo_; }; -template class ServoDetachAction : public Action { +template class ServoDetachAction final : public Action { public: ServoDetachAction(Servo *servo) : servo_(servo) {} diff --git a/esphome/components/sfa30/sfa30.h b/esphome/components/sfa30/sfa30.h index d2f2520a57..13985b1a29 100644 --- a/esphome/components/sfa30/sfa30.h +++ b/esphome/components/sfa30/sfa30.h @@ -6,7 +6,7 @@ namespace esphome::sfa30 { -class SFA30Component : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SFA30Component final : public PollingComponent, public sensirion_common::SensirionI2CDevice { enum ErrorCode { DEVICE_MARKING_READ_FAILED, MEASUREMENT_INIT_FAILED, UNKNOWN }; public: diff --git a/esphome/components/sgp30/sgp30.h b/esphome/components/sgp30/sgp30.h index cb4aa1c1bb..fac3c01b58 100644 --- a/esphome/components/sgp30/sgp30.h +++ b/esphome/components/sgp30/sgp30.h @@ -16,7 +16,7 @@ struct SGP30Baselines { } PACKED; /// This class implements support for the Sensirion SGP30 i2c GAS (VOC and CO2eq) sensors. -class SGP30Component : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SGP30Component final : public PollingComponent, public sensirion_common::SensirionI2CDevice { public: void set_eco2_sensor(sensor::Sensor *eco2) { eco2_sensor_ = eco2; } void set_tvoc_sensor(sensor::Sensor *tvoc) { tvoc_sensor_ = tvoc; } diff --git a/esphome/components/sgp4x/sgp4x.h b/esphome/components/sgp4x/sgp4x.h index 23bf6319a9..a40188e629 100644 --- a/esphome/components/sgp4x/sgp4x.h +++ b/esphome/components/sgp4x/sgp4x.h @@ -54,7 +54,9 @@ const float MAXIMUM_STORAGE_DIFF = 50.0f; class SGP4xComponent; /// This class implements support for the Sensirion sgp4x i2c GAS (VOC) sensors. -class SGP4xComponent : public PollingComponent, public sensor::Sensor, public sensirion_common::SensirionI2CDevice { +class SGP4xComponent final : public PollingComponent, + public sensor::Sensor, + public sensirion_common::SensirionI2CDevice { enum ErrorCode { COMMUNICATION_FAILED, MEASUREMENT_INIT_FAILED, diff --git a/esphome/components/shelly_dimmer/shelly_dimmer.h b/esphome/components/shelly_dimmer/shelly_dimmer.h index c6d0e20afe..e3ddd7f268 100644 --- a/esphome/components/shelly_dimmer/shelly_dimmer.h +++ b/esphome/components/shelly_dimmer/shelly_dimmer.h @@ -12,7 +12,7 @@ namespace esphome::shelly_dimmer { -class ShellyDimmer : public PollingComponent, public light::LightOutput, public uart::UARTDevice { +class ShellyDimmer final : public PollingComponent, public light::LightOutput, public uart::UARTDevice { private: static constexpr uint16_t SHELLY_DIMMER_BUFFER_SIZE = 256; diff --git a/esphome/components/sht3xd/sht3xd.h b/esphome/components/sht3xd/sht3xd.h index 6df5587507..93663118e5 100644 --- a/esphome/components/sht3xd/sht3xd.h +++ b/esphome/components/sht3xd/sht3xd.h @@ -7,7 +7,7 @@ namespace esphome::sht3xd { /// This class implements support for the SHT3x-DIS family of temperature+humidity i2c sensors. -class SHT3XDComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SHT3XDComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } void set_humidity_sensor(sensor::Sensor *humidity_sensor) { humidity_sensor_ = humidity_sensor; } diff --git a/esphome/components/sht4x/sht4x.h b/esphome/components/sht4x/sht4x.h index d1fa9033df..0d5723f72a 100644 --- a/esphome/components/sht4x/sht4x.h +++ b/esphome/components/sht4x/sht4x.h @@ -14,7 +14,7 @@ enum SHT4XHEATERPOWER { SHT4X_HEATERPOWER_HIGH, SHT4X_HEATERPOWER_MED, SHT4X_HEA enum SHT4XHEATERTIME : uint16_t { SHT4X_HEATERTIME_LONG = 1100, SHT4X_HEATERTIME_SHORT = 110 }; -class SHT4XComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SHT4XComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/shtcx/shtcx.h b/esphome/components/shtcx/shtcx.h index a86b204e2b..ea50a084ef 100644 --- a/esphome/components/shtcx/shtcx.h +++ b/esphome/components/shtcx/shtcx.h @@ -13,7 +13,7 @@ enum SHTCXType : uint8_t { }; /// This class implements support for the SHT3x-DIS family of temperature+humidity i2c sensors. -class SHTCXComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SHTCXComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } void set_humidity_sensor(sensor::Sensor *humidity_sensor) { humidity_sensor_ = humidity_sensor; } diff --git a/esphome/components/shutdown/button/shutdown_button.h b/esphome/components/shutdown/button/shutdown_button.h index d4247ec0f9..4fc534030e 100644 --- a/esphome/components/shutdown/button/shutdown_button.h +++ b/esphome/components/shutdown/button/shutdown_button.h @@ -5,7 +5,7 @@ namespace esphome::shutdown { -class ShutdownButton : public button::Button, public Component { +class ShutdownButton final : public button::Button, public Component { public: void dump_config() override; diff --git a/esphome/components/shutdown/switch/shutdown_switch.h b/esphome/components/shutdown/switch/shutdown_switch.h index 933345915f..bb7fea7e03 100644 --- a/esphome/components/shutdown/switch/shutdown_switch.h +++ b/esphome/components/shutdown/switch/shutdown_switch.h @@ -5,7 +5,7 @@ namespace esphome::shutdown { -class ShutdownSwitch : public switch_::Switch, public Component { +class ShutdownSwitch final : public switch_::Switch, public Component { public: void dump_config() override; diff --git a/esphome/components/sigma_delta_output/sigma_delta_output.h b/esphome/components/sigma_delta_output/sigma_delta_output.h index a5df3c6c7c..71aedf9b07 100644 --- a/esphome/components/sigma_delta_output/sigma_delta_output.h +++ b/esphome/components/sigma_delta_output/sigma_delta_output.h @@ -6,7 +6,7 @@ namespace esphome::sigma_delta_output { -class SigmaDeltaOutput : public PollingComponent, public output::FloatOutput { +class SigmaDeltaOutput final : public PollingComponent, public output::FloatOutput { public: Trigger<> *get_turn_on_trigger() { if (!this->turn_on_trigger_) diff --git a/esphome/components/sim800l/sim800l.h b/esphome/components/sim800l/sim800l.h index 0b3259ede0..276131cfed 100644 --- a/esphome/components/sim800l/sim800l.h +++ b/esphome/components/sim800l/sim800l.h @@ -46,7 +46,7 @@ enum State { STATE_RECEIVED_USSD }; -class Sim800LComponent : public uart::UARTDevice, public PollingComponent { +class Sim800LComponent final : public uart::UARTDevice, public PollingComponent { public: /// Retrieve the latest sensor values. This operation takes approximately 16ms. void update() override; @@ -120,7 +120,7 @@ class Sim800LComponent : public uart::UARTDevice, public PollingComponent { CallbackManager ussd_received_callback_; }; -template class Sim800LSendSmsAction : public Action { +template class Sim800LSendSmsAction final : public Action { public: Sim800LSendSmsAction(Sim800LComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, recipient) @@ -136,7 +136,7 @@ template class Sim800LSendSmsAction : public Action { Sim800LComponent *parent_; }; -template class Sim800LSendUssdAction : public Action { +template class Sim800LSendUssdAction final : public Action { public: Sim800LSendUssdAction(Sim800LComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, ussd) @@ -150,7 +150,7 @@ template class Sim800LSendUssdAction : public Action { Sim800LComponent *parent_; }; -template class Sim800LDialAction : public Action { +template class Sim800LDialAction final : public Action { public: Sim800LDialAction(Sim800LComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, recipient) @@ -163,7 +163,7 @@ template class Sim800LDialAction : public Action { protected: Sim800LComponent *parent_; }; -template class Sim800LConnectAction : public Action { +template class Sim800LConnectAction final : public Action { public: Sim800LConnectAction(Sim800LComponent *parent) : parent_(parent) {} @@ -173,7 +173,7 @@ template class Sim800LConnectAction : public Action { Sim800LComponent *parent_; }; -template class Sim800LDisconnectAction : public Action { +template class Sim800LDisconnectAction final : public Action { public: Sim800LDisconnectAction(Sim800LComponent *parent) : parent_(parent) {} diff --git a/esphome/components/slow_pwm/slow_pwm_output.h b/esphome/components/slow_pwm/slow_pwm_output.h index d866435af1..aa517a3bc5 100644 --- a/esphome/components/slow_pwm/slow_pwm_output.h +++ b/esphome/components/slow_pwm/slow_pwm_output.h @@ -6,7 +6,7 @@ namespace esphome::slow_pwm { -class SlowPWMOutput : public output::FloatOutput, public Component { +class SlowPWMOutput final : public output::FloatOutput, public Component { public: void set_pin(GPIOPin *pin) { pin_ = pin; }; void set_period(unsigned int period) { period_ = period; }; From eb9ca517e32d09e68979080bafe959c33af98aa0 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:19:39 +1200 Subject: [PATCH 0581/1815] Mark configurable classes as final (14/21: rc522_i2c-scd4x) (#16965) --- esphome/components/rc522_i2c/rc522_i2c.h | 2 +- esphome/components/rc522_spi/rc522_spi.h | 6 +++--- esphome/components/rd03d/rd03d.h | 2 +- esphome/components/rdm6300/rdm6300.h | 6 +++--- esphome/components/remote_base/raw_protocol.h | 2 +- esphome/components/remote_base/remote_base.h | 2 +- .../remote_receiver/remote_receiver.h | 6 +++--- .../components/remote_transmitter/automation.h | 3 ++- .../remote_transmitter/remote_transmitter.h | 6 +++--- .../resampler/speaker/resampler_speaker.h | 2 +- .../components/resistance/resistance_sensor.h | 2 +- .../components/restart/switch/restart_switch.h | 2 +- esphome/components/rf_bridge/rf_bridge.h | 18 +++++++++--------- esphome/components/rgb/rgb_light_output.h | 2 +- esphome/components/rgbct/rgbct_light_output.h | 2 +- esphome/components/rgbw/rgbw_light_output.h | 2 +- esphome/components/rgbww/rgbww_light_output.h | 2 +- .../components/rotary_encoder/rotary_encoder.h | 4 ++-- .../components/router/speaker/router_speaker.h | 4 ++-- esphome/components/rp2040/gpio.h | 2 +- esphome/components/rp2040_ble/rp2040_ble.h | 2 +- .../rp2040_pio_led_strip/led_strip.h | 2 +- esphome/components/rp2040_pwm/rp2040_pwm.h | 4 ++-- esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h | 2 +- esphome/components/rtttl/rtttl.h | 8 ++++---- .../components/runtime_stats/runtime_stats.h | 2 +- esphome/components/ruuvi_ble/ruuvi_ble.h | 2 +- esphome/components/ruuvitag/ruuvitag.h | 2 +- esphome/components/rx8130/rx8130.h | 6 +++--- esphome/components/safe_mode/automation.h | 2 +- .../safe_mode/switch/safe_mode_switch.h | 2 +- esphome/components/scd30/automation.h | 3 ++- esphome/components/scd30/scd30.h | 2 +- esphome/components/scd4x/automation.h | 5 +++-- esphome/components/scd4x/scd4x.h | 2 +- 35 files changed, 63 insertions(+), 60 deletions(-) diff --git a/esphome/components/rc522_i2c/rc522_i2c.h b/esphome/components/rc522_i2c/rc522_i2c.h index bd6f2269d8..9144241fe7 100644 --- a/esphome/components/rc522_i2c/rc522_i2c.h +++ b/esphome/components/rc522_i2c/rc522_i2c.h @@ -6,7 +6,7 @@ namespace esphome::rc522_i2c { -class RC522I2C : public rc522::RC522, public i2c::I2CDevice { +class RC522I2C final : public rc522::RC522, public i2c::I2CDevice { public: void dump_config() override; diff --git a/esphome/components/rc522_spi/rc522_spi.h b/esphome/components/rc522_spi/rc522_spi.h index 54caf5c117..2809718308 100644 --- a/esphome/components/rc522_spi/rc522_spi.h +++ b/esphome/components/rc522_spi/rc522_spi.h @@ -14,9 +14,9 @@ */ namespace esphome::rc522_spi { -class RC522Spi : public rc522::RC522, - public spi::SPIDevice { +class RC522Spi final : public rc522::RC522, + public spi::SPIDevice { public: void setup() override; diff --git a/esphome/components/rd03d/rd03d.h b/esphome/components/rd03d/rd03d.h index 8bf7b423be..e4ec6aafb2 100644 --- a/esphome/components/rd03d/rd03d.h +++ b/esphome/components/rd03d/rd03d.h @@ -37,7 +37,7 @@ struct TargetSensor { }; #endif -class RD03DComponent : public Component, public uart::UARTDevice { +class RD03DComponent final : public Component, public uart::UARTDevice { public: void setup() override; void loop() override; diff --git a/esphome/components/rdm6300/rdm6300.h b/esphome/components/rdm6300/rdm6300.h index f088f7de4c..4aa31b8d60 100644 --- a/esphome/components/rdm6300/rdm6300.h +++ b/esphome/components/rdm6300/rdm6300.h @@ -13,7 +13,7 @@ namespace esphome::rdm6300 { class RDM6300BinarySensor; class RDM6300Trigger; -class RDM6300Component : public Component, public uart::UARTDevice { +class RDM6300Component final : public Component, public uart::UARTDevice { public: void loop() override; @@ -28,7 +28,7 @@ class RDM6300Component : public Component, public uart::UARTDevice { uint32_t last_id_{0}; }; -class RDM6300BinarySensor : public binary_sensor::BinarySensorInitiallyOff { +class RDM6300BinarySensor final : public binary_sensor::BinarySensorInitiallyOff { public: void set_id(uint32_t id) { id_ = id; } @@ -46,7 +46,7 @@ class RDM6300BinarySensor : public binary_sensor::BinarySensorInitiallyOff { uint32_t id_; }; -class RDM6300Trigger : public Trigger { +class RDM6300Trigger final : public Trigger { public: void process(uint32_t uid) { this->trigger(uid); } }; diff --git a/esphome/components/remote_base/raw_protocol.h b/esphome/components/remote_base/raw_protocol.h index 1bcf390b62..f043d95eb4 100644 --- a/esphome/components/remote_base/raw_protocol.h +++ b/esphome/components/remote_base/raw_protocol.h @@ -31,7 +31,7 @@ class RawBinarySensor : public RemoteReceiverBinarySensorBase { size_t len_; }; -class RawTrigger : public Trigger, public Component, public RemoteReceiverListener { +class RawTrigger final : public Trigger, public Component, public RemoteReceiverListener { protected: bool on_receive(RemoteReceiveData src) override { this->trigger(src.get_raw_data()); diff --git a/esphome/components/remote_base/remote_base.h b/esphome/components/remote_base/remote_base.h index 0b1109267f..4e2ed4b71c 100644 --- a/esphome/components/remote_base/remote_base.h +++ b/esphome/components/remote_base/remote_base.h @@ -256,7 +256,7 @@ template class RemoteReceiverBinarySensor : public RemoteReceiverBin }; template -class RemoteReceiverTrigger : public Trigger, public RemoteReceiverListener { +class RemoteReceiverTrigger final : public Trigger, public RemoteReceiverListener { protected: bool on_receive(RemoteReceiveData src) override { auto proto = T(); diff --git a/esphome/components/remote_receiver/remote_receiver.h b/esphome/components/remote_receiver/remote_receiver.h index cc707346eb..2ed6a4c251 100644 --- a/esphome/components/remote_receiver/remote_receiver.h +++ b/esphome/components/remote_receiver/remote_receiver.h @@ -55,11 +55,11 @@ struct RemoteReceiverComponentStore { }; #endif -class RemoteReceiverComponent : public remote_base::RemoteReceiverBase, - public Component +class RemoteReceiverComponent final : public remote_base::RemoteReceiverBase, + public Component #if defined(USE_ESP32) && SOC_RMT_SUPPORTED , - public remote_base::RemoteRMTChannel + public remote_base::RemoteRMTChannel #endif { diff --git a/esphome/components/remote_transmitter/automation.h b/esphome/components/remote_transmitter/automation.h index 8da4cfd95d..a1b0926451 100644 --- a/esphome/components/remote_transmitter/automation.h +++ b/esphome/components/remote_transmitter/automation.h @@ -7,7 +7,8 @@ namespace esphome::remote_transmitter { -template class DigitalWriteAction : public Action, public Parented { +template +class DigitalWriteAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, value) void play(const Ts &...x) override { this->parent_->digital_write(this->value_.value(x...)); } diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index d30966e3da..bcb07038ea 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -33,11 +33,11 @@ struct RemoteTransmitterComponentStore { #endif #endif -class RemoteTransmitterComponent : public remote_base::RemoteTransmitterBase, - public Component +class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBase, + public Component #if defined(USE_ESP32) && SOC_RMT_SUPPORTED , - public remote_base::RemoteRMTChannel + public remote_base::RemoteRMTChannel #endif { public: diff --git a/esphome/components/resampler/speaker/resampler_speaker.h b/esphome/components/resampler/speaker/resampler_speaker.h index f482ce4b88..3255bf1fe8 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.h +++ b/esphome/components/resampler/speaker/resampler_speaker.h @@ -14,7 +14,7 @@ namespace esphome::resampler { -class ResamplerSpeaker : public Component, public speaker::Speaker { +class ResamplerSpeaker final : public Component, public speaker::Speaker { public: float get_setup_priority() const override { return esphome::setup_priority::DATA; } void dump_config() override; diff --git a/esphome/components/resistance/resistance_sensor.h b/esphome/components/resistance/resistance_sensor.h index b646fb509a..ecb77795ff 100644 --- a/esphome/components/resistance/resistance_sensor.h +++ b/esphome/components/resistance/resistance_sensor.h @@ -10,7 +10,7 @@ enum ResistanceConfiguration { DOWNSTREAM, }; -class ResistanceSensor : public Component, public sensor::Sensor { +class ResistanceSensor final : public Component, public sensor::Sensor { public: void set_sensor(Sensor *sensor) { sensor_ = sensor; } void set_configuration(ResistanceConfiguration configuration) { configuration_ = configuration; } diff --git a/esphome/components/restart/switch/restart_switch.h b/esphome/components/restart/switch/restart_switch.h index 67b4a2bfd1..dc9ec8eadc 100644 --- a/esphome/components/restart/switch/restart_switch.h +++ b/esphome/components/restart/switch/restart_switch.h @@ -5,7 +5,7 @@ namespace esphome::restart { -class RestartSwitch : public switch_::Switch, public Component { +class RestartSwitch final : public switch_::Switch, public Component { public: void dump_config() override; diff --git a/esphome/components/rf_bridge/rf_bridge.h b/esphome/components/rf_bridge/rf_bridge.h index 2f91459076..5ad75650ab 100644 --- a/esphome/components/rf_bridge/rf_bridge.h +++ b/esphome/components/rf_bridge/rf_bridge.h @@ -44,7 +44,7 @@ struct RFBridgeAdvancedData { std::string code; }; -class RFBridgeComponent : public uart::UARTDevice, public Component { +class RFBridgeComponent final : public uart::UARTDevice, public Component { public: void loop() override; void dump_config() override; @@ -76,7 +76,7 @@ class RFBridgeComponent : public uart::UARTDevice, public Component { CallbackManager advanced_data_callback_; }; -template class RFBridgeSendCodeAction : public Action { +template class RFBridgeSendCodeAction final : public Action { public: RFBridgeSendCodeAction(RFBridgeComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(uint16_t, sync) @@ -97,7 +97,7 @@ template class RFBridgeSendCodeAction : public Action { RFBridgeComponent *parent_; }; -template class RFBridgeSendAdvancedCodeAction : public Action { +template class RFBridgeSendAdvancedCodeAction final : public Action { public: RFBridgeSendAdvancedCodeAction(RFBridgeComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(uint8_t, length) @@ -116,7 +116,7 @@ template class RFBridgeSendAdvancedCodeAction : public Action class RFBridgeLearnAction : public Action { +template class RFBridgeLearnAction final : public Action { public: RFBridgeLearnAction(RFBridgeComponent *parent) : parent_(parent) {} @@ -126,7 +126,7 @@ template class RFBridgeLearnAction : public Action { RFBridgeComponent *parent_; }; -template class RFBridgeStartAdvancedSniffingAction : public Action { +template class RFBridgeStartAdvancedSniffingAction final : public Action { public: RFBridgeStartAdvancedSniffingAction(RFBridgeComponent *parent) : parent_(parent) {} @@ -136,7 +136,7 @@ template class RFBridgeStartAdvancedSniffingAction : public Acti RFBridgeComponent *parent_; }; -template class RFBridgeStopAdvancedSniffingAction : public Action { +template class RFBridgeStopAdvancedSniffingAction final : public Action { public: RFBridgeStopAdvancedSniffingAction(RFBridgeComponent *parent) : parent_(parent) {} @@ -146,7 +146,7 @@ template class RFBridgeStopAdvancedSniffingAction : public Actio RFBridgeComponent *parent_; }; -template class RFBridgeStartBucketSniffingAction : public Action { +template class RFBridgeStartBucketSniffingAction final : public Action { public: RFBridgeStartBucketSniffingAction(RFBridgeComponent *parent) : parent_(parent) {} @@ -156,7 +156,7 @@ template class RFBridgeStartBucketSniffingAction : public Action RFBridgeComponent *parent_; }; -template class RFBridgeSendRawAction : public Action { +template class RFBridgeSendRawAction final : public Action { public: RFBridgeSendRawAction(RFBridgeComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, raw) @@ -167,7 +167,7 @@ template class RFBridgeSendRawAction : public Action { RFBridgeComponent *parent_; }; -template class RFBridgeBeepAction : public Action { +template class RFBridgeBeepAction final : public Action { public: RFBridgeBeepAction(RFBridgeComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(uint16_t, duration) diff --git a/esphome/components/rgb/rgb_light_output.h b/esphome/components/rgb/rgb_light_output.h index f0d599cf57..5893abf1d7 100644 --- a/esphome/components/rgb/rgb_light_output.h +++ b/esphome/components/rgb/rgb_light_output.h @@ -6,7 +6,7 @@ namespace esphome::rgb { -class RGBLightOutput : public light::LightOutput { +class RGBLightOutput final : public light::LightOutput { public: void set_red(output::FloatOutput *red) { red_ = red; } void set_green(output::FloatOutput *green) { green_ = green; } diff --git a/esphome/components/rgbct/rgbct_light_output.h b/esphome/components/rgbct/rgbct_light_output.h index 84ecb232cc..d6f7aaef78 100644 --- a/esphome/components/rgbct/rgbct_light_output.h +++ b/esphome/components/rgbct/rgbct_light_output.h @@ -7,7 +7,7 @@ namespace esphome::rgbct { -class RGBCTLightOutput : public light::LightOutput { +class RGBCTLightOutput final : public light::LightOutput { public: void set_red(output::FloatOutput *red) { red_ = red; } void set_green(output::FloatOutput *green) { green_ = green; } diff --git a/esphome/components/rgbw/rgbw_light_output.h b/esphome/components/rgbw/rgbw_light_output.h index ae96eb2024..a957104457 100644 --- a/esphome/components/rgbw/rgbw_light_output.h +++ b/esphome/components/rgbw/rgbw_light_output.h @@ -6,7 +6,7 @@ namespace esphome::rgbw { -class RGBWLightOutput : public light::LightOutput { +class RGBWLightOutput final : public light::LightOutput { public: void set_red(output::FloatOutput *red) { red_ = red; } void set_green(output::FloatOutput *green) { green_ = green; } diff --git a/esphome/components/rgbww/rgbww_light_output.h b/esphome/components/rgbww/rgbww_light_output.h index de5ee993f8..6608c65d9c 100644 --- a/esphome/components/rgbww/rgbww_light_output.h +++ b/esphome/components/rgbww/rgbww_light_output.h @@ -6,7 +6,7 @@ namespace esphome::rgbww { -class RGBWWLightOutput : public light::LightOutput { +class RGBWWLightOutput final : public light::LightOutput { public: void set_red(output::FloatOutput *red) { red_ = red; } void set_green(output::FloatOutput *green) { green_ = green; } diff --git a/esphome/components/rotary_encoder/rotary_encoder.h b/esphome/components/rotary_encoder/rotary_encoder.h index 8a56da4fe2..286267baed 100644 --- a/esphome/components/rotary_encoder/rotary_encoder.h +++ b/esphome/components/rotary_encoder/rotary_encoder.h @@ -41,7 +41,7 @@ struct RotaryEncoderSensorStore { static void gpio_intr(RotaryEncoderSensorStore *arg); }; -class RotaryEncoderSensor : public sensor::Sensor, public Component { +class RotaryEncoderSensor final : public sensor::Sensor, public Component { public: void set_pin_a(InternalGPIOPin *pin_a) { pin_a_ = pin_a; } void set_pin_b(InternalGPIOPin *pin_b) { pin_b_ = pin_b; } @@ -106,7 +106,7 @@ class RotaryEncoderSensor : public sensor::Sensor, public Component { CallbackManager listeners_{}; }; -template class RotaryEncoderSetValueAction : public Action { +template class RotaryEncoderSetValueAction final : public Action { public: RotaryEncoderSetValueAction(RotaryEncoderSensor *encoder) : encoder_(encoder) {} TEMPLATABLE_VALUE(int, value) diff --git a/esphome/components/router/speaker/router_speaker.h b/esphome/components/router/speaker/router_speaker.h index 13b58a1c72..801d0906ce 100644 --- a/esphome/components/router/speaker/router_speaker.h +++ b/esphome/components/router/speaker/router_speaker.h @@ -13,7 +13,7 @@ namespace esphome::router { -class Router : public Component, public speaker::Speaker { +class Router final : public Component, public speaker::Speaker { public: float get_setup_priority() const override { return setup_priority::DATA; } @@ -77,7 +77,7 @@ class Router : public Component, public speaker::Speaker { std::atomic active_output_idx_{0}; }; -template class SwitchOutputAction : public Action { +template class SwitchOutputAction final : public Action { public: explicit SwitchOutputAction(Router *parent) : parent_(parent) {} TEMPLATABLE_VALUE(speaker::Speaker *, target) diff --git a/esphome/components/rp2040/gpio.h b/esphome/components/rp2040/gpio.h index da97cff9b1..b9aa497b47 100644 --- a/esphome/components/rp2040/gpio.h +++ b/esphome/components/rp2040/gpio.h @@ -7,7 +7,7 @@ namespace esphome::rp2040 { -class RP2040GPIOPin : public InternalGPIOPin { +class RP2040GPIOPin final : public InternalGPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } diff --git a/esphome/components/rp2040_ble/rp2040_ble.h b/esphome/components/rp2040_ble/rp2040_ble.h index 24b3860cc1..885e49f690 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.h +++ b/esphome/components/rp2040_ble/rp2040_ble.h @@ -18,7 +18,7 @@ enum class BLEComponentState : uint8_t { DISABLED, }; -class RP2040BLE : public Component { +class RP2040BLE final : public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.h b/esphome/components/rp2040_pio_led_strip/led_strip.h index ebc3bbbaa5..aaa5b0842d 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.h +++ b/esphome/components/rp2040_pio_led_strip/led_strip.h @@ -57,7 +57,7 @@ inline const char *rgb_order_to_string(RGBOrder order) { using init_fn = void (*)(PIO pio, uint sm, uint offset, uint pin, float freq); -class RP2040PIOLEDStripLightOutput : public light::AddressableLight { +class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { public: void setup() override; void write_state(light::LightState *state) override; diff --git a/esphome/components/rp2040_pwm/rp2040_pwm.h b/esphome/components/rp2040_pwm/rp2040_pwm.h index 58d3955a31..49980a7d76 100644 --- a/esphome/components/rp2040_pwm/rp2040_pwm.h +++ b/esphome/components/rp2040_pwm/rp2040_pwm.h @@ -9,7 +9,7 @@ namespace esphome::rp2040_pwm { -class RP2040PWM : public output::FloatOutput, public Component { +class RP2040PWM final : public output::FloatOutput, public Component { public: void set_pin(InternalGPIOPin *pin) { pin_ = pin; } void set_frequency(float frequency) { this->frequency_ = frequency; } @@ -39,7 +39,7 @@ class RP2040PWM : public output::FloatOutput, public Component { bool frequency_changed_{false}; }; -template class SetFrequencyAction : public Action { +template class SetFrequencyAction final : public Action { public: SetFrequencyAction(RP2040PWM *parent) : parent_(parent) {} TEMPLATABLE_VALUE(float, frequency); diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h index 8b1457926c..df0e2b0b16 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h @@ -18,7 +18,7 @@ namespace esphome::rpi_dpi_rgb { constexpr static const char *const TAG = "rpi_dpi_rgb"; -class RpiDpiRgb : public display::Display { +class RpiDpiRgb final : public display::Display { public: void update() override { this->do_update_(); } void setup() override; diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index d060b6b024..256bdce5f2 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -27,7 +27,7 @@ enum class State : uint8_t { STOPPING, }; -class Rtttl : public Component { +class Rtttl final : public Component { public: #ifdef USE_OUTPUT void set_output(output::FloatOutput *output) { this->output_ = output; } @@ -116,7 +116,7 @@ class Rtttl : public Component { #endif }; -template class PlayAction : public Action { +template class PlayAction final : public Action { public: PlayAction(Rtttl *rtttl) : rtttl_(rtttl) {} TEMPLATABLE_VALUE(std::string, value) @@ -127,12 +127,12 @@ template class PlayAction : public Action { Rtttl *rtttl_; }; -template class StopAction : public Action, public Parented { +template class StopAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->stop(); } }; -template class IsPlayingCondition : public Condition, public Parented { +template class IsPlayingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_playing(); } }; diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 1e4910453a..2c783a0df3 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -17,7 +17,7 @@ namespace runtime_stats { static const char *const TAG = "runtime_stats"; -class RuntimeStatsCollector { +class RuntimeStatsCollector final { public: RuntimeStatsCollector(); diff --git a/esphome/components/ruuvi_ble/ruuvi_ble.h b/esphome/components/ruuvi_ble/ruuvi_ble.h index 80b07d410b..e372b24944 100644 --- a/esphome/components/ruuvi_ble/ruuvi_ble.h +++ b/esphome/components/ruuvi_ble/ruuvi_ble.h @@ -25,7 +25,7 @@ bool parse_ruuvi_data_byte(uint8_t data_type, const uint8_t *data, uint8_t data_ optional parse_ruuvi(const esp32_ble_tracker::ESPBTDevice &device); -class RuuviListener : public esp32_ble_tracker::ESPBTDeviceListener { +class RuuviListener final : public esp32_ble_tracker::ESPBTDeviceListener { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; }; diff --git a/esphome/components/ruuvitag/ruuvitag.h b/esphome/components/ruuvitag/ruuvitag.h index 259675835d..9602b82afc 100644 --- a/esphome/components/ruuvitag/ruuvitag.h +++ b/esphome/components/ruuvitag/ruuvitag.h @@ -9,7 +9,7 @@ namespace esphome::ruuvitag { -class RuuviTag : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class RuuviTag final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/rx8130/rx8130.h b/esphome/components/rx8130/rx8130.h index 152bd10f27..0c738a9b78 100644 --- a/esphome/components/rx8130/rx8130.h +++ b/esphome/components/rx8130/rx8130.h @@ -6,7 +6,7 @@ namespace esphome::rx8130 { -class RX8130Component : public time::RealTimeClock, public i2c::I2CDevice { +class RX8130Component final : public time::RealTimeClock, public i2c::I2CDevice { public: void setup() override; void update() override; @@ -18,12 +18,12 @@ class RX8130Component : public time::RealTimeClock, public i2c::I2CDevice { void stop_(bool stop); }; -template class WriteAction : public Action, public Parented { +template class WriteAction final : public Action, public Parented { public: void play(const Ts... x) override { this->parent_->write_time(); } }; -template class ReadAction : public Action, public Parented { +template class ReadAction final : public Action, public Parented { public: void play(const Ts... x) override { this->parent_->read_time(); } }; diff --git a/esphome/components/safe_mode/automation.h b/esphome/components/safe_mode/automation.h index 79b53c0881..e2858dff34 100644 --- a/esphome/components/safe_mode/automation.h +++ b/esphome/components/safe_mode/automation.h @@ -4,7 +4,7 @@ namespace esphome::safe_mode { -template class MarkSuccessfulAction : public Action, public Parented { +template class MarkSuccessfulAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->mark_successful(); } }; diff --git a/esphome/components/safe_mode/switch/safe_mode_switch.h b/esphome/components/safe_mode/switch/safe_mode_switch.h index c73a2087d7..cbd79cd520 100644 --- a/esphome/components/safe_mode/switch/safe_mode_switch.h +++ b/esphome/components/safe_mode/switch/safe_mode_switch.h @@ -6,7 +6,7 @@ namespace esphome::safe_mode { -class SafeModeSwitch : public switch_::Switch, public Component { +class SafeModeSwitch final : public switch_::Switch, public Component { public: void dump_config() override; void set_safe_mode(SafeModeComponent *safe_mode_component); diff --git a/esphome/components/scd30/automation.h b/esphome/components/scd30/automation.h index 1f04739893..a816ae1f26 100644 --- a/esphome/components/scd30/automation.h +++ b/esphome/components/scd30/automation.h @@ -6,7 +6,8 @@ namespace esphome::scd30 { -template class ForceRecalibrationWithReference : public Action, public Parented { +template +class ForceRecalibrationWithReference final : public Action, public Parented { public: void play(const Ts &...x) override { if (this->value_.has_value()) { diff --git a/esphome/components/scd30/scd30.h b/esphome/components/scd30/scd30.h index a5a5df1903..0605ab4175 100644 --- a/esphome/components/scd30/scd30.h +++ b/esphome/components/scd30/scd30.h @@ -7,7 +7,7 @@ namespace esphome::scd30 { /// This class implements support for the Sensirion scd30 i2c GAS (VOC and CO2eq) sensors. -class SCD30Component : public Component, public sensirion_common::SensirionI2CDevice { +class SCD30Component final : public Component, public sensirion_common::SensirionI2CDevice { public: void set_co2_sensor(sensor::Sensor *co2) { co2_sensor_ = co2; } void set_humidity_sensor(sensor::Sensor *humidity) { humidity_sensor_ = humidity; } diff --git a/esphome/components/scd4x/automation.h b/esphome/components/scd4x/automation.h index e485289c95..4746c0c879 100644 --- a/esphome/components/scd4x/automation.h +++ b/esphome/components/scd4x/automation.h @@ -6,7 +6,8 @@ namespace esphome::scd4x { -template class PerformForcedCalibrationAction : public Action, public Parented { +template +class PerformForcedCalibrationAction final : public Action, public Parented { public: void play(const Ts &...x) override { if (this->value_.has_value()) { @@ -18,7 +19,7 @@ template class PerformForcedCalibrationAction : public Action class FactoryResetAction : public Action, public Parented { +template class FactoryResetAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->factory_reset(); } }; diff --git a/esphome/components/scd4x/scd4x.h b/esphome/components/scd4x/scd4x.h index 3e4827ef14..4d5dedb5e9 100644 --- a/esphome/components/scd4x/scd4x.h +++ b/esphome/components/scd4x/scd4x.h @@ -22,7 +22,7 @@ enum MeasurementMode : uint8_t { SINGLE_SHOT_RHT_ONLY, }; -class SCD4XComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SCD4XComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { public: void setup() override; void dump_config() override; From d511f0614d1c151313928b819c06b0f4d4e643da Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:19:51 +1200 Subject: [PATCH 0582/1815] Mark configurable classes as final (18/21: template-tx20) (#16969) --- esphome/components/template/lock/automation.h | 2 +- esphome/components/template/valve/automation.h | 2 +- .../template/water_heater/automation.h | 2 +- .../water_heater/template_water_heater.h | 2 +- esphome/components/text/automation.h | 4 ++-- .../text/text_sensor/text_text_sensor.h | 2 +- esphome/components/text_sensor/automation.h | 8 ++++---- esphome/components/thermopro_ble/thermopro_ble.h | 2 +- .../components/thermostat/thermostat_climate.h | 2 +- esphome/components/time/automation.h | 4 ++-- esphome/components/time/real_time_clock.h | 2 +- .../time_based/cover/time_based_cover.h | 2 +- esphome/components/tinyusb/tinyusb_component.h | 2 +- esphome/components/tlc59208f/tlc59208f_output.h | 4 ++-- .../components/tlc5947/output/tlc5947_output.h | 2 +- esphome/components/tlc5947/tlc5947.h | 2 +- .../components/tlc5971/output/tlc5971_output.h | 2 +- esphome/components/tlc5971/tlc5971.h | 2 +- esphome/components/tm1621/tm1621.h | 2 +- esphome/components/tm1637/tm1637.h | 4 ++-- .../components/tm1638/binary_sensor/tm1638_key.h | 2 +- .../components/tm1638/output/tm1638_output_led.h | 2 +- .../components/tm1638/switch/tm1638_switch_led.h | 2 +- esphome/components/tm1638/tm1638.h | 2 +- esphome/components/tm1651/tm1651.h | 12 ++++++------ esphome/components/tmp102/tmp102.h | 2 +- esphome/components/tmp1075/tmp1075.h | 2 +- esphome/components/tmp117/tmp117.h | 2 +- esphome/components/tof10120/tof10120_sensor.h | 2 +- esphome/components/tormatic/tormatic_cover.h | 2 +- esphome/components/toshiba/toshiba.h | 2 +- .../total_daily_energy/total_daily_energy.h | 2 +- .../binary_sensor/touchscreen_binary_sensor.h | 8 ++++---- esphome/components/tsl2561/tsl2561.h | 2 +- esphome/components/tsl2591/tsl2591.h | 2 +- .../tt21100/binary_sensor/tt21100_button.h | 8 ++++---- esphome/components/tt21100/touchscreen/tt21100.h | 2 +- esphome/components/ttp229_bsf/ttp229_bsf.h | 4 ++-- esphome/components/ttp229_lsf/ttp229_lsf.h | 4 ++-- esphome/components/tuya/automation.h | 16 ++++++++-------- .../tuya/binary_sensor/tuya_binary_sensor.h | 2 +- esphome/components/tuya/climate/tuya_climate.h | 2 +- esphome/components/tuya/cover/tuya_cover.h | 2 +- esphome/components/tuya/fan/tuya_fan.h | 2 +- esphome/components/tuya/light/tuya_light.h | 2 +- esphome/components/tuya/number/tuya_number.h | 2 +- esphome/components/tuya/select/tuya_select.h | 2 +- esphome/components/tuya/sensor/tuya_sensor.h | 2 +- esphome/components/tuya/switch/tuya_switch.h | 2 +- .../tuya/text_sensor/tuya_text_sensor.h | 2 +- esphome/components/tuya/tuya.h | 2 +- esphome/components/tx20/tx20.h | 2 +- 52 files changed, 79 insertions(+), 79 deletions(-) diff --git a/esphome/components/template/lock/automation.h b/esphome/components/template/lock/automation.h index 42a2a826e2..a979291b78 100644 --- a/esphome/components/template/lock/automation.h +++ b/esphome/components/template/lock/automation.h @@ -6,7 +6,7 @@ namespace esphome::template_ { -template class TemplateLockPublishAction : public Action, public Parented { +template class TemplateLockPublishAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(lock::LockState, state) diff --git a/esphome/components/template/valve/automation.h b/esphome/components/template/valve/automation.h index a27e98b25c..ec9d784ab6 100644 --- a/esphome/components/template/valve/automation.h +++ b/esphome/components/template/valve/automation.h @@ -6,7 +6,7 @@ namespace esphome::template_ { -template class TemplateValvePublishAction : public Action, public Parented { +template class TemplateValvePublishAction final : public Action, public Parented { TEMPLATABLE_VALUE(float, position) TEMPLATABLE_VALUE(valve::ValveOperation, current_operation) diff --git a/esphome/components/template/water_heater/automation.h b/esphome/components/template/water_heater/automation.h index d19542db41..3301a15af1 100644 --- a/esphome/components/template/water_heater/automation.h +++ b/esphome/components/template/water_heater/automation.h @@ -6,7 +6,7 @@ namespace esphome::template_ { template -class TemplateWaterHeaterPublishAction : public Action, public Parented { +class TemplateWaterHeaterPublishAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, current_temperature) TEMPLATABLE_VALUE(float, target_temperature) diff --git a/esphome/components/template/water_heater/template_water_heater.h b/esphome/components/template/water_heater/template_water_heater.h index 045a142e40..7d5f198553 100644 --- a/esphome/components/template/water_heater/template_water_heater.h +++ b/esphome/components/template/water_heater/template_water_heater.h @@ -13,7 +13,7 @@ enum TemplateWaterHeaterRestoreMode { WATER_HEATER_RESTORE_AND_CALL, }; -class TemplateWaterHeater : public Component, public water_heater::WaterHeater { +class TemplateWaterHeater final : public Component, public water_heater::WaterHeater { public: TemplateWaterHeater(); diff --git a/esphome/components/text/automation.h b/esphome/components/text/automation.h index ac8166d0be..916d86340d 100644 --- a/esphome/components/text/automation.h +++ b/esphome/components/text/automation.h @@ -6,14 +6,14 @@ namespace esphome::text { -class TextStateTrigger : public Trigger { +class TextStateTrigger final : public Trigger { public: explicit TextStateTrigger(Text *parent) { parent->add_on_state_callback([this](const std::string &value) { this->trigger(value); }); } }; -template class TextSetAction : public Action { +template class TextSetAction final : public Action { public: explicit TextSetAction(Text *text) : text_(text) {} TEMPLATABLE_VALUE(std::string, value) diff --git a/esphome/components/text/text_sensor/text_text_sensor.h b/esphome/components/text/text_sensor/text_text_sensor.h index fd70ea3451..59fa04a75e 100644 --- a/esphome/components/text/text_sensor/text_text_sensor.h +++ b/esphome/components/text/text_sensor/text_text_sensor.h @@ -6,7 +6,7 @@ namespace esphome::text { -class TextTextSensor : public text_sensor::TextSensor, public Component { +class TextTextSensor final : public text_sensor::TextSensor, public Component { public: explicit TextTextSensor(Text *source) : source_(source) {} void setup() override; diff --git a/esphome/components/text_sensor/automation.h b/esphome/components/text_sensor/automation.h index ab30362774..628b9b84a0 100644 --- a/esphome/components/text_sensor/automation.h +++ b/esphome/components/text_sensor/automation.h @@ -8,21 +8,21 @@ namespace esphome::text_sensor { -class TextSensorStateTrigger : public Trigger { +class TextSensorStateTrigger final : public Trigger { public: explicit TextSensorStateTrigger(TextSensor *parent) { parent->add_on_state_callback([this](const std::string &value) { this->trigger(value); }); } }; -class TextSensorStateRawTrigger : public Trigger { +class TextSensorStateRawTrigger final : public Trigger { public: explicit TextSensorStateRawTrigger(TextSensor *parent) { parent->add_on_raw_state_callback([this](const std::string &value) { this->trigger(value); }); } }; -template class TextSensorStateCondition : public Condition { +template class TextSensorStateCondition final : public Condition { public: explicit TextSensorStateCondition(TextSensor *parent) : parent_(parent) {} @@ -34,7 +34,7 @@ template class TextSensorStateCondition : public Condition class TextSensorPublishAction : public Action { +template class TextSensorPublishAction final : public Action { public: TextSensorPublishAction(TextSensor *sensor) : sensor_(sensor) {} TEMPLATABLE_VALUE(std::string, state) diff --git a/esphome/components/thermopro_ble/thermopro_ble.h b/esphome/components/thermopro_ble/thermopro_ble.h index 38bed82102..2d7523e07a 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.h +++ b/esphome/components/thermopro_ble/thermopro_ble.h @@ -17,7 +17,7 @@ struct ParseResult { using DeviceParser = optional (*)(const uint8_t *data, std::size_t data_size); -class ThermoProBLE : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class ThermoProBLE final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { this->address_ = address; }; diff --git a/esphome/components/thermostat/thermostat_climate.h b/esphome/components/thermostat/thermostat_climate.h index 4268d5c582..f30659a8a6 100644 --- a/esphome/components/thermostat/thermostat_climate.h +++ b/esphome/components/thermostat/thermostat_climate.h @@ -81,7 +81,7 @@ struct ThermostatCustomPresetEntry { ThermostatClimateTargetTempConfig config; }; -class ThermostatClimate : public climate::Climate, public Component { +class ThermostatClimate final : public climate::Climate, public Component { public: using PresetEntry = ThermostatPresetEntry; using CustomPresetEntry = ThermostatCustomPresetEntry; diff --git a/esphome/components/time/automation.h b/esphome/components/time/automation.h index 546c4a10de..7be195903a 100644 --- a/esphome/components/time/automation.h +++ b/esphome/components/time/automation.h @@ -10,7 +10,7 @@ namespace esphome::time { -class CronTrigger : public Trigger<>, public Component { +class CronTrigger final : public Trigger<>, public Component { public: explicit CronTrigger(RealTimeClock *rtc); void add_second(uint8_t second); @@ -41,7 +41,7 @@ class CronTrigger : public Trigger<>, public Component { optional last_check_; }; -class SyncTrigger : public Trigger<>, public Component { +class SyncTrigger final : public Trigger<>, public Component { public: explicit SyncTrigger(RealTimeClock *rtc); diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index 06ee2ea5af..7a9175f39c 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -72,7 +72,7 @@ class RealTimeClock : public PollingComponent { LazyCallbackManager time_sync_callback_; }; -template class TimeHasTimeCondition : public Condition { +template class TimeHasTimeCondition final : public Condition { public: TimeHasTimeCondition(RealTimeClock *parent) : parent_(parent) {} bool check(const Ts &...x) override { return this->parent_->now().is_valid(); } diff --git a/esphome/components/time_based/cover/time_based_cover.h b/esphome/components/time_based/cover/time_based_cover.h index ce0b105ceb..1e1f51ff23 100644 --- a/esphome/components/time_based/cover/time_based_cover.h +++ b/esphome/components/time_based/cover/time_based_cover.h @@ -6,7 +6,7 @@ namespace esphome::time_based { -class TimeBasedCover : public cover::Cover, public Component { +class TimeBasedCover final : public cover::Cover, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/tinyusb/tinyusb_component.h b/esphome/components/tinyusb/tinyusb_component.h index 7ec3da118c..e85fea9d21 100644 --- a/esphome/components/tinyusb/tinyusb_component.h +++ b/esphome/components/tinyusb/tinyusb_component.h @@ -20,7 +20,7 @@ enum USBDStringDescriptor : uint8_t { static const char *const DEFAULT_USB_STR = "ESPHome"; -class TinyUSB : public Component { +class TinyUSB final : public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tlc59208f/tlc59208f_output.h b/esphome/components/tlc59208f/tlc59208f_output.h index 46f88de01f..678e309f59 100644 --- a/esphome/components/tlc59208f/tlc59208f_output.h +++ b/esphome/components/tlc59208f/tlc59208f_output.h @@ -21,7 +21,7 @@ inline constexpr uint8_t TLC59208F_MODE2_WDT_35MS = (3 << 0); class TLC59208FOutput; -class TLC59208FChannel : public output::FloatOutput, public Parented { +class TLC59208FChannel final : public output::FloatOutput, public Parented { public: void set_channel(uint8_t channel) { channel_ = channel; } @@ -34,7 +34,7 @@ class TLC59208FChannel : public output::FloatOutput, public Parented { +class TLC5947Channel final : public output::FloatOutput, public Parented { public: void set_channel(uint16_t channel) { this->channel_ = channel; } diff --git a/esphome/components/tlc5947/tlc5947.h b/esphome/components/tlc5947/tlc5947.h index 18acffa25f..a9519d784f 100644 --- a/esphome/components/tlc5947/tlc5947.h +++ b/esphome/components/tlc5947/tlc5947.h @@ -9,7 +9,7 @@ namespace esphome::tlc5947 { -class TLC5947 : public Component { +class TLC5947 final : public Component { public: const uint8_t N_CHANNELS_PER_CHIP = 24; diff --git a/esphome/components/tlc5971/output/tlc5971_output.h b/esphome/components/tlc5971/output/tlc5971_output.h index 2a24a19b6c..fd0f5e82b5 100644 --- a/esphome/components/tlc5971/output/tlc5971_output.h +++ b/esphome/components/tlc5971/output/tlc5971_output.h @@ -8,7 +8,7 @@ namespace esphome::tlc5971 { -class TLC5971Channel : public output::FloatOutput, public Parented { +class TLC5971Channel final : public output::FloatOutput, public Parented { public: void set_channel(uint16_t channel) { this->channel_ = channel; } diff --git a/esphome/components/tlc5971/tlc5971.h b/esphome/components/tlc5971/tlc5971.h index 080249c89c..75e4c57027 100644 --- a/esphome/components/tlc5971/tlc5971.h +++ b/esphome/components/tlc5971/tlc5971.h @@ -9,7 +9,7 @@ namespace esphome::tlc5971 { -class TLC5971 : public Component { +class TLC5971 final : public Component { public: const uint8_t N_CHANNELS_PER_CHIP = 12; diff --git a/esphome/components/tm1621/tm1621.h b/esphome/components/tm1621/tm1621.h index 7708ee6c98..806e69f993 100644 --- a/esphome/components/tm1621/tm1621.h +++ b/esphome/components/tm1621/tm1621.h @@ -11,7 +11,7 @@ class TM1621Display; using tm1621_writer_t = display::DisplayWriter; -class TM1621Display : public PollingComponent { +class TM1621Display final : public PollingComponent { public: void set_writer(tm1621_writer_t &&writer) { this->writer_ = writer; } diff --git a/esphome/components/tm1637/tm1637.h b/esphome/components/tm1637/tm1637.h index 1ad56ae75a..a3dd50fb37 100644 --- a/esphome/components/tm1637/tm1637.h +++ b/esphome/components/tm1637/tm1637.h @@ -21,7 +21,7 @@ class TM1637Key; using tm1637_writer_t = display::DisplayWriter; -class TM1637Display : public PollingComponent { +class TM1637Display final : public PollingComponent { public: void set_writer(tm1637_writer_t &&writer) { this->writer_ = writer; } @@ -92,7 +92,7 @@ class TM1637Display : public PollingComponent { }; #ifdef USE_BINARY_SENSOR -class TM1637Key : public binary_sensor::BinarySensor { +class TM1637Key final : public binary_sensor::BinarySensor { friend class TM1637Display; public: diff --git a/esphome/components/tm1638/binary_sensor/tm1638_key.h b/esphome/components/tm1638/binary_sensor/tm1638_key.h index fba1e43bde..1e6336a1f4 100644 --- a/esphome/components/tm1638/binary_sensor/tm1638_key.h +++ b/esphome/components/tm1638/binary_sensor/tm1638_key.h @@ -5,7 +5,7 @@ namespace esphome::tm1638 { -class TM1638Key : public binary_sensor::BinarySensor, public KeyListener { +class TM1638Key final : public binary_sensor::BinarySensor, public KeyListener { public: void set_keycode(uint8_t key_code) { key_code_ = key_code; }; void keys_update(uint8_t keys) override; diff --git a/esphome/components/tm1638/output/tm1638_output_led.h b/esphome/components/tm1638/output/tm1638_output_led.h index b1c1090447..e0bf5d31d9 100644 --- a/esphome/components/tm1638/output/tm1638_output_led.h +++ b/esphome/components/tm1638/output/tm1638_output_led.h @@ -6,7 +6,7 @@ namespace esphome::tm1638 { -class TM1638OutputLed : public output::BinaryOutput, public Component { +class TM1638OutputLed final : public output::BinaryOutput, public Component { public: void dump_config() override; diff --git a/esphome/components/tm1638/switch/tm1638_switch_led.h b/esphome/components/tm1638/switch/tm1638_switch_led.h index c7154eefb3..8df4678d62 100644 --- a/esphome/components/tm1638/switch/tm1638_switch_led.h +++ b/esphome/components/tm1638/switch/tm1638_switch_led.h @@ -6,7 +6,7 @@ namespace esphome::tm1638 { -class TM1638SwitchLed : public switch_::Switch, public Component { +class TM1638SwitchLed final : public switch_::Switch, public Component { public: void dump_config() override; diff --git a/esphome/components/tm1638/tm1638.h b/esphome/components/tm1638/tm1638.h index 24d49f4a9f..9ebea05089 100644 --- a/esphome/components/tm1638/tm1638.h +++ b/esphome/components/tm1638/tm1638.h @@ -20,7 +20,7 @@ class TM1638Component; using tm1638_writer_t = display::DisplayWriter; -class TM1638Component : public PollingComponent { +class TM1638Component final : public PollingComponent { public: void set_writer(tm1638_writer_t &&writer) { this->writer_ = writer; } void setup() override; diff --git a/esphome/components/tm1651/tm1651.h b/esphome/components/tm1651/tm1651.h index f1abbcc792..2021f90266 100644 --- a/esphome/components/tm1651/tm1651.h +++ b/esphome/components/tm1651/tm1651.h @@ -12,7 +12,7 @@ enum TM1651Brightness : uint8_t { TM1651_BRIGHTEST = 3, }; -class TM1651Display : public Component { +class TM1651Display final : public Component { public: void set_clk_pin(InternalGPIOPin *pin) { clk_pin_ = pin; } void set_dio_pin(InternalGPIOPin *pin) { dio_pin_ = pin; } @@ -56,7 +56,7 @@ class TM1651Display : public Component { uint8_t level_{0}; }; -template class SetBrightnessAction : public Action, public Parented { +template class SetBrightnessAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, brightness) @@ -66,7 +66,7 @@ template class SetBrightnessAction : public Action, publi } }; -template class SetLevelAction : public Action, public Parented { +template class SetLevelAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, level) @@ -76,7 +76,7 @@ template class SetLevelAction : public Action, public Par } }; -template class SetLevelPercentAction : public Action, public Parented { +template class SetLevelPercentAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, level_percent) @@ -86,12 +86,12 @@ template class SetLevelPercentAction : public Action, pub } }; -template class TurnOnAction : public Action, public Parented { +template class TurnOnAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->turn_on(); } }; -template class TurnOffAction : public Action, public Parented { +template class TurnOffAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->turn_off(); } }; diff --git a/esphome/components/tmp102/tmp102.h b/esphome/components/tmp102/tmp102.h index aedfefd052..f9eda32e98 100644 --- a/esphome/components/tmp102/tmp102.h +++ b/esphome/components/tmp102/tmp102.h @@ -6,7 +6,7 @@ namespace esphome::tmp102 { -class TMP102Component : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { +class TMP102Component final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: void dump_config() override; void update() override; diff --git a/esphome/components/tmp1075/tmp1075.h b/esphome/components/tmp1075/tmp1075.h index 4dc9449597..519d48ad29 100644 --- a/esphome/components/tmp1075/tmp1075.h +++ b/esphome/components/tmp1075/tmp1075.h @@ -52,7 +52,7 @@ enum EAlertFunction { ALERT_INTERRUPT = 1, }; -class TMP1075Sensor : public PollingComponent, public sensor::Sensor, public i2c::I2CDevice { +class TMP1075Sensor final : public PollingComponent, public sensor::Sensor, public i2c::I2CDevice { public: void setup() override; void update() override; diff --git a/esphome/components/tmp117/tmp117.h b/esphome/components/tmp117/tmp117.h index a8fe7ac7ce..a42a14ca73 100644 --- a/esphome/components/tmp117/tmp117.h +++ b/esphome/components/tmp117/tmp117.h @@ -6,7 +6,7 @@ namespace esphome::tmp117 { -class TMP117Component : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { +class TMP117Component final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tof10120/tof10120_sensor.h b/esphome/components/tof10120/tof10120_sensor.h index 8bf92b50a0..932b89ce49 100644 --- a/esphome/components/tof10120/tof10120_sensor.h +++ b/esphome/components/tof10120/tof10120_sensor.h @@ -6,7 +6,7 @@ namespace esphome::tof10120 { -class TOF10120Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class TOF10120Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; diff --git a/esphome/components/tormatic/tormatic_cover.h b/esphome/components/tormatic/tormatic_cover.h index 2a83213ffe..bde5525d10 100644 --- a/esphome/components/tormatic/tormatic_cover.h +++ b/esphome/components/tormatic/tormatic_cover.h @@ -9,7 +9,7 @@ namespace esphome::tormatic { using namespace esphome::cover; -class Tormatic : public cover::Cover, public uart::UARTDevice, public PollingComponent { +class Tormatic final : public cover::Cover, public uart::UARTDevice, public PollingComponent { public: void setup() override; void loop() override; diff --git a/esphome/components/toshiba/toshiba.h b/esphome/components/toshiba/toshiba.h index 4525d6bffe..a853730f31 100644 --- a/esphome/components/toshiba/toshiba.h +++ b/esphome/components/toshiba/toshiba.h @@ -23,7 +23,7 @@ const float TOSHIBA_RAC_PT1411HWRU_TEMP_F_MAX = 86.0; const float TOSHIBA_RAS_2819T_TEMP_C_MIN = 18.0; const float TOSHIBA_RAS_2819T_TEMP_C_MAX = 30.0; -class ToshibaClimate : public climate_ir::ClimateIR { +class ToshibaClimate final : public climate_ir::ClimateIR { public: ToshibaClimate() : climate_ir::ClimateIR(TOSHIBA_GENERIC_TEMP_C_MIN, TOSHIBA_GENERIC_TEMP_C_MAX, 1.0f, true, true, diff --git a/esphome/components/total_daily_energy/total_daily_energy.h b/esphome/components/total_daily_energy/total_daily_energy.h index 9a20ecea01..a683d43d0f 100644 --- a/esphome/components/total_daily_energy/total_daily_energy.h +++ b/esphome/components/total_daily_energy/total_daily_energy.h @@ -14,7 +14,7 @@ enum TotalDailyEnergyMethod { TOTAL_DAILY_ENERGY_METHOD_RIGHT, }; -class TotalDailyEnergy : public sensor::Sensor, public Component { +class TotalDailyEnergy final : public sensor::Sensor, public Component { public: void set_restore(bool restore) { restore_ = restore; } void set_time(time::RealTimeClock *time) { time_ = time; } diff --git a/esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.h b/esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.h index 2f86bc9749..f95c7a82b1 100644 --- a/esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.h +++ b/esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.h @@ -10,10 +10,10 @@ namespace esphome::touchscreen { -class TouchscreenBinarySensor : public binary_sensor::BinarySensor, - public Component, - public TouchListener, - public Parented { +class TouchscreenBinarySensor final : public binary_sensor::BinarySensor, + public Component, + public TouchListener, + public Parented { public: void setup() override; diff --git a/esphome/components/tsl2561/tsl2561.h b/esphome/components/tsl2561/tsl2561.h index 0fbb59c648..8997d19f53 100644 --- a/esphome/components/tsl2561/tsl2561.h +++ b/esphome/components/tsl2561/tsl2561.h @@ -26,7 +26,7 @@ enum TSL2561Gain { }; /// This class includes support for the TSL2561 i2c ambient light sensor. -class TSL2561Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class TSL2561Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: /** Set the time that sensor values should be accumulated for. * diff --git a/esphome/components/tsl2591/tsl2591.h b/esphome/components/tsl2591/tsl2591.h index 4b63c8ec40..3fde340412 100644 --- a/esphome/components/tsl2591/tsl2591.h +++ b/esphome/components/tsl2591/tsl2591.h @@ -63,7 +63,7 @@ enum TSL2591SensorChannel { /// light. They are reported as separate sensors, and the difference /// between the values is reported as a third sensor as a convenience /// for visible light only. -class TSL2591Component : public PollingComponent, public i2c::I2CDevice { +class TSL2591Component final : public PollingComponent, public i2c::I2CDevice { public: /** Set device integration time and gain. * diff --git a/esphome/components/tt21100/binary_sensor/tt21100_button.h b/esphome/components/tt21100/binary_sensor/tt21100_button.h index a1f5946447..f4073caf3d 100644 --- a/esphome/components/tt21100/binary_sensor/tt21100_button.h +++ b/esphome/components/tt21100/binary_sensor/tt21100_button.h @@ -7,10 +7,10 @@ namespace esphome::tt21100 { -class TT21100Button : public binary_sensor::BinarySensor, - public Component, - public TT21100ButtonListener, - public Parented { +class TT21100Button final : public binary_sensor::BinarySensor, + public Component, + public TT21100ButtonListener, + public Parented { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tt21100/touchscreen/tt21100.h b/esphome/components/tt21100/touchscreen/tt21100.h index 3c6030c9c1..31af9085b5 100644 --- a/esphome/components/tt21100/touchscreen/tt21100.h +++ b/esphome/components/tt21100/touchscreen/tt21100.h @@ -16,7 +16,7 @@ class TT21100ButtonListener { virtual void update_button(uint8_t index, uint16_t state) = 0; }; -class TT21100Touchscreen : public Touchscreen, public i2c::I2CDevice { +class TT21100Touchscreen final : public Touchscreen, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ttp229_bsf/ttp229_bsf.h b/esphome/components/ttp229_bsf/ttp229_bsf.h index 07f0c638c2..109764e51d 100644 --- a/esphome/components/ttp229_bsf/ttp229_bsf.h +++ b/esphome/components/ttp229_bsf/ttp229_bsf.h @@ -8,7 +8,7 @@ namespace esphome::ttp229_bsf { -class TTP229BSFChannel : public binary_sensor::BinarySensor { +class TTP229BSFChannel final : public binary_sensor::BinarySensor { public: void set_channel(uint8_t channel) { channel_ = channel; } void process(uint16_t data) { this->publish_state(data & (1 << this->channel_)); } @@ -17,7 +17,7 @@ class TTP229BSFChannel : public binary_sensor::BinarySensor { uint8_t channel_; }; -class TTP229BSFComponent : public Component { +class TTP229BSFComponent final : public Component { public: void set_sdo_pin(GPIOPin *sdo_pin) { sdo_pin_ = sdo_pin; } void set_scl_pin(GPIOPin *scl_pin) { scl_pin_ = scl_pin; } diff --git a/esphome/components/ttp229_lsf/ttp229_lsf.h b/esphome/components/ttp229_lsf/ttp229_lsf.h index 09e7745d25..50e2baa7f7 100644 --- a/esphome/components/ttp229_lsf/ttp229_lsf.h +++ b/esphome/components/ttp229_lsf/ttp229_lsf.h @@ -8,7 +8,7 @@ namespace esphome::ttp229_lsf { -class TTP229Channel : public binary_sensor::BinarySensor { +class TTP229Channel final : public binary_sensor::BinarySensor { public: void set_channel(uint8_t channel) { channel_ = channel; } void process(uint16_t data) { this->publish_state(data & (1 << this->channel_)); } @@ -17,7 +17,7 @@ class TTP229Channel : public binary_sensor::BinarySensor { uint8_t channel_; }; -class TTP229LSFComponent : public Component, public i2c::I2CDevice { +class TTP229LSFComponent final : public Component, public i2c::I2CDevice { public: void register_channel(TTP229Channel *channel) { this->channels_.push_back(channel); } void setup() override; diff --git a/esphome/components/tuya/automation.h b/esphome/components/tuya/automation.h index f5c806b013..0cd63a76be 100644 --- a/esphome/components/tuya/automation.h +++ b/esphome/components/tuya/automation.h @@ -8,44 +8,44 @@ namespace esphome::tuya { -class TuyaDatapointUpdateTrigger : public Trigger { +class TuyaDatapointUpdateTrigger final : public Trigger { public: explicit TuyaDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id) { parent->register_listener(sensor_id, [this](const TuyaDatapoint &dp) { this->trigger(dp); }); } }; -class TuyaRawDatapointUpdateTrigger : public Trigger> { +class TuyaRawDatapointUpdateTrigger final : public Trigger> { public: explicit TuyaRawDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); }; -class TuyaBoolDatapointUpdateTrigger : public Trigger { +class TuyaBoolDatapointUpdateTrigger final : public Trigger { public: explicit TuyaBoolDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); }; -class TuyaIntDatapointUpdateTrigger : public Trigger { +class TuyaIntDatapointUpdateTrigger final : public Trigger { public: explicit TuyaIntDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); }; -class TuyaUIntDatapointUpdateTrigger : public Trigger { +class TuyaUIntDatapointUpdateTrigger final : public Trigger { public: explicit TuyaUIntDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); }; -class TuyaStringDatapointUpdateTrigger : public Trigger { +class TuyaStringDatapointUpdateTrigger final : public Trigger { public: explicit TuyaStringDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); }; -class TuyaEnumDatapointUpdateTrigger : public Trigger { +class TuyaEnumDatapointUpdateTrigger final : public Trigger { public: explicit TuyaEnumDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); }; -class TuyaBitmaskDatapointUpdateTrigger : public Trigger { +class TuyaBitmaskDatapointUpdateTrigger final : public Trigger { public: explicit TuyaBitmaskDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); }; diff --git a/esphome/components/tuya/binary_sensor/tuya_binary_sensor.h b/esphome/components/tuya/binary_sensor/tuya_binary_sensor.h index f92652d087..76d7da4604 100644 --- a/esphome/components/tuya/binary_sensor/tuya_binary_sensor.h +++ b/esphome/components/tuya/binary_sensor/tuya_binary_sensor.h @@ -6,7 +6,7 @@ namespace esphome::tuya { -class TuyaBinarySensor : public binary_sensor::BinarySensor, public Component { +class TuyaBinarySensor final : public binary_sensor::BinarySensor, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/climate/tuya_climate.h b/esphome/components/tuya/climate/tuya_climate.h index b9fb45257a..015da1930c 100644 --- a/esphome/components/tuya/climate/tuya_climate.h +++ b/esphome/components/tuya/climate/tuya_climate.h @@ -6,7 +6,7 @@ namespace esphome::tuya { -class TuyaClimate : public climate::Climate, public Component { +class TuyaClimate final : public climate::Climate, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/tuya/cover/tuya_cover.h b/esphome/components/tuya/cover/tuya_cover.h index ab63975683..fb38c81377 100644 --- a/esphome/components/tuya/cover/tuya_cover.h +++ b/esphome/components/tuya/cover/tuya_cover.h @@ -12,7 +12,7 @@ enum TuyaCoverRestoreMode { COVER_RESTORE_AND_CALL, }; -class TuyaCover : public cover::Cover, public Component { +class TuyaCover final : public cover::Cover, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/fan/tuya_fan.h b/esphome/components/tuya/fan/tuya_fan.h index bfb6bdeca0..70b127c10e 100644 --- a/esphome/components/tuya/fan/tuya_fan.h +++ b/esphome/components/tuya/fan/tuya_fan.h @@ -6,7 +6,7 @@ namespace esphome::tuya { -class TuyaFan : public Component, public fan::Fan { +class TuyaFan final : public Component, public fan::Fan { public: TuyaFan(Tuya *parent, int speed_count) : parent_(parent), speed_count_(speed_count) {} void setup() override; diff --git a/esphome/components/tuya/light/tuya_light.h b/esphome/components/tuya/light/tuya_light.h index d990eea72a..c921efc145 100644 --- a/esphome/components/tuya/light/tuya_light.h +++ b/esphome/components/tuya/light/tuya_light.h @@ -8,7 +8,7 @@ namespace esphome::tuya { enum TuyaColorType { RGB, HSV, RGBHSV }; -class TuyaLight : public Component, public light::LightOutput { +class TuyaLight final : public Component, public light::LightOutput { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/number/tuya_number.h b/esphome/components/tuya/number/tuya_number.h index 51c53a4442..a7289bb803 100644 --- a/esphome/components/tuya/number/tuya_number.h +++ b/esphome/components/tuya/number/tuya_number.h @@ -8,7 +8,7 @@ namespace esphome::tuya { -class TuyaNumber : public number::Number, public Component { +class TuyaNumber final : public number::Number, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/select/tuya_select.h b/esphome/components/tuya/select/tuya_select.h index f8d2d89ea8..4da01411b7 100644 --- a/esphome/components/tuya/select/tuya_select.h +++ b/esphome/components/tuya/select/tuya_select.h @@ -8,7 +8,7 @@ namespace esphome::tuya { -class TuyaSelect : public select::Select, public Component { +class TuyaSelect final : public select::Select, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/sensor/tuya_sensor.h b/esphome/components/tuya/sensor/tuya_sensor.h index b700fc8bd7..65f9dc599a 100644 --- a/esphome/components/tuya/sensor/tuya_sensor.h +++ b/esphome/components/tuya/sensor/tuya_sensor.h @@ -6,7 +6,7 @@ namespace esphome::tuya { -class TuyaSensor : public sensor::Sensor, public Component { +class TuyaSensor final : public sensor::Sensor, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/switch/tuya_switch.h b/esphome/components/tuya/switch/tuya_switch.h index 7e0109c34c..4cd137a6c8 100644 --- a/esphome/components/tuya/switch/tuya_switch.h +++ b/esphome/components/tuya/switch/tuya_switch.h @@ -6,7 +6,7 @@ namespace esphome::tuya { -class TuyaSwitch : public switch_::Switch, public Component { +class TuyaSwitch final : public switch_::Switch, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/text_sensor/tuya_text_sensor.h b/esphome/components/tuya/text_sensor/tuya_text_sensor.h index c9ac64deb8..2969bbf74b 100644 --- a/esphome/components/tuya/text_sensor/tuya_text_sensor.h +++ b/esphome/components/tuya/text_sensor/tuya_text_sensor.h @@ -6,7 +6,7 @@ namespace esphome::tuya { -class TuyaTextSensor : public text_sensor::TextSensor, public Component { +class TuyaTextSensor final : public text_sensor::TextSensor, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/tuya.h b/esphome/components/tuya/tuya.h index 470b97e7e7..4e7ab5c7f9 100644 --- a/esphome/components/tuya/tuya.h +++ b/esphome/components/tuya/tuya.h @@ -84,7 +84,7 @@ struct TuyaCommand { std::vector payload; }; -class Tuya : public Component, public uart::UARTDevice { +class Tuya final : public Component, public uart::UARTDevice { public: float get_setup_priority() const override { return setup_priority::LATE; } void setup() override; diff --git a/esphome/components/tx20/tx20.h b/esphome/components/tx20/tx20.h index 7ca29eaf3b..e4dc7dfab0 100644 --- a/esphome/components/tx20/tx20.h +++ b/esphome/components/tx20/tx20.h @@ -21,7 +21,7 @@ struct Tx20ComponentStore { }; /// This class implements support for the Tx20 Wind sensor. -class Tx20Component : public Component { +class Tx20Component final : public Component { public: /// Get the textual representation of the wind direction ('N', 'SSE', ..). std::string get_wind_cardinal_direction() const; From 64acb358a515d26ed2f4e4cad4d4745a110e7e36 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:20:00 +1200 Subject: [PATCH 0583/1815] Mark configurable classes as final (8/21: hm3301-integration) (#16959) --- esphome/components/hm3301/hm3301.h | 2 +- esphome/components/hmc5883l/hmc5883l.h | 2 +- .../binary_sensor/homeassistant_binary_sensor.h | 2 +- .../components/homeassistant/number/homeassistant_number.h | 2 +- .../components/homeassistant/sensor/homeassistant_sensor.h | 2 +- .../components/homeassistant/switch/homeassistant_switch.h | 2 +- .../homeassistant/text_sensor/homeassistant_text_sensor.h | 2 +- esphome/components/honeywell_hih_i2c/honeywell_hih.h | 2 +- esphome/components/honeywellabp/honeywellabp.h | 6 +++--- esphome/components/honeywellabp2_i2c/honeywellabp2.h | 2 +- esphome/components/host/gpio.h | 2 +- esphome/components/host/time/host_time.h | 2 +- esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h | 2 +- esphome/components/hte501/hte501.h | 2 +- esphome/components/http_request/http_request.h | 4 ++-- esphome/components/http_request/http_request_arduino.h | 2 +- esphome/components/http_request/http_request_host.h | 2 +- esphome/components/http_request/http_request_idf.h | 2 +- esphome/components/http_request/ota/automation.h | 2 +- esphome/components/htu21d/htu21d.h | 6 +++--- esphome/components/htu31d/htu31d.h | 2 +- esphome/components/hub75/hub75_component.h | 4 ++-- esphome/components/hx711/hx711.h | 2 +- esphome/components/hydreon_rgxx/hydreon_rgxx.h | 4 ++-- esphome/components/hyt271/hyt271.h | 2 +- esphome/components/i2c/i2c_bus_arduino.h | 2 +- esphome/components/i2c/i2c_bus_esp_idf.h | 2 +- esphome/components/i2c/i2c_bus_host.h | 2 +- esphome/components/i2c/i2c_bus_zephyr.h | 2 +- esphome/components/i2c_device/i2c_device.h | 2 +- esphome/components/i2s_audio/i2s_audio.h | 2 +- .../components/i2s_audio/microphone/i2s_audio_microphone.h | 2 +- .../i2s_audio/speaker/i2s_audio_speaker_standard.h | 2 +- esphome/components/iaqcore/iaqcore.h | 2 +- esphome/components/improv_serial/improv_serial_component.h | 2 +- esphome/components/ina219/ina219.h | 2 +- esphome/components/ina226/ina226.h | 2 +- esphome/components/ina260/ina260.h | 2 +- esphome/components/ina2xx_i2c/ina2xx_i2c.h | 2 +- esphome/components/ina2xx_spi/ina2xx_spi.h | 6 +++--- esphome/components/ina3221/ina3221.h | 2 +- .../components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h | 2 +- esphome/components/inkplate/inkplate.h | 2 +- esphome/components/integration/integration_sensor.h | 6 +++--- 44 files changed, 55 insertions(+), 55 deletions(-) diff --git a/esphome/components/hm3301/hm3301.h b/esphome/components/hm3301/hm3301.h index 55e708e34a..adbd8450ed 100644 --- a/esphome/components/hm3301/hm3301.h +++ b/esphome/components/hm3301/hm3301.h @@ -9,7 +9,7 @@ namespace esphome::hm3301 { static const uint8_t SELECT_COMM_CMD = 0x88; -class HM3301Component : public PollingComponent, public i2c::I2CDevice { +class HM3301Component final : public PollingComponent, public i2c::I2CDevice { public: HM3301Component() = default; diff --git a/esphome/components/hmc5883l/hmc5883l.h b/esphome/components/hmc5883l/hmc5883l.h index 4f170d7401..d23eb1a0f4 100644 --- a/esphome/components/hmc5883l/hmc5883l.h +++ b/esphome/components/hmc5883l/hmc5883l.h @@ -34,7 +34,7 @@ enum HMC5883LRange { HMC5883L_RANGE_810_UT = 0b111, }; -class HMC5883LComponent : public PollingComponent, public i2c::I2CDevice { +class HMC5883LComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h b/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h index 6d95ea2c60..c713b143af 100644 --- a/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h +++ b/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h @@ -5,7 +5,7 @@ namespace esphome::homeassistant { -class HomeassistantBinarySensor : public binary_sensor::BinarySensor, public Component { +class HomeassistantBinarySensor final : public binary_sensor::BinarySensor, public Component { public: void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } void set_attribute(const char *attribute) { this->attribute_ = attribute; } diff --git a/esphome/components/homeassistant/number/homeassistant_number.h b/esphome/components/homeassistant/number/homeassistant_number.h index a1e351fdf4..c9673234ee 100644 --- a/esphome/components/homeassistant/number/homeassistant_number.h +++ b/esphome/components/homeassistant/number/homeassistant_number.h @@ -6,7 +6,7 @@ namespace esphome::homeassistant { -class HomeassistantNumber : public number::Number, public Component { +class HomeassistantNumber final : public number::Number, public Component { public: void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } diff --git a/esphome/components/homeassistant/sensor/homeassistant_sensor.h b/esphome/components/homeassistant/sensor/homeassistant_sensor.h index afc4935537..e787039ee3 100644 --- a/esphome/components/homeassistant/sensor/homeassistant_sensor.h +++ b/esphome/components/homeassistant/sensor/homeassistant_sensor.h @@ -5,7 +5,7 @@ namespace esphome::homeassistant { -class HomeassistantSensor : public sensor::Sensor, public Component { +class HomeassistantSensor final : public sensor::Sensor, public Component { public: void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } void set_attribute(const char *attribute) { this->attribute_ = attribute; } diff --git a/esphome/components/homeassistant/switch/homeassistant_switch.h b/esphome/components/homeassistant/switch/homeassistant_switch.h index c6c178c205..3dd1ab1525 100644 --- a/esphome/components/homeassistant/switch/homeassistant_switch.h +++ b/esphome/components/homeassistant/switch/homeassistant_switch.h @@ -5,7 +5,7 @@ namespace esphome::homeassistant { -class HomeassistantSwitch : public switch_::Switch, public Component { +class HomeassistantSwitch final : public switch_::Switch, public Component { public: void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } void setup() override; diff --git a/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h b/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h index 8af81cefcb..63ec136b57 100644 --- a/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h +++ b/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h @@ -5,7 +5,7 @@ namespace esphome::homeassistant { -class HomeassistantTextSensor : public text_sensor::TextSensor, public Component { +class HomeassistantTextSensor final : public text_sensor::TextSensor, public Component { public: void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } void set_attribute(const char *attribute) { this->attribute_ = attribute; } diff --git a/esphome/components/honeywell_hih_i2c/honeywell_hih.h b/esphome/components/honeywell_hih_i2c/honeywell_hih.h index d9ea6401ce..6d02044cfc 100644 --- a/esphome/components/honeywell_hih_i2c/honeywell_hih.h +++ b/esphome/components/honeywell_hih_i2c/honeywell_hih.h @@ -7,7 +7,7 @@ namespace esphome::honeywell_hih_i2c { -class HoneywellHIComponent : public PollingComponent, public i2c::I2CDevice { +class HoneywellHIComponent final : public PollingComponent, public i2c::I2CDevice { public: void dump_config() override; void loop() override; diff --git a/esphome/components/honeywellabp/honeywellabp.h b/esphome/components/honeywellabp/honeywellabp.h index 3c31968c49..067311b8d4 100644 --- a/esphome/components/honeywellabp/honeywellabp.h +++ b/esphome/components/honeywellabp/honeywellabp.h @@ -8,9 +8,9 @@ namespace esphome::honeywellabp { -class HONEYWELLABPSensor : public PollingComponent, - public spi::SPIDevice { +class HONEYWELLABPSensor final : public PollingComponent, + public spi::SPIDevice { public: void set_pressure_sensor(sensor::Sensor *pressure_sensor) { pressure_sensor_ = pressure_sensor; } void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } diff --git a/esphome/components/honeywellabp2_i2c/honeywellabp2.h b/esphome/components/honeywellabp2_i2c/honeywellabp2.h index 41ea21344b..70f435f8b0 100644 --- a/esphome/components/honeywellabp2_i2c/honeywellabp2.h +++ b/esphome/components/honeywellabp2_i2c/honeywellabp2.h @@ -11,7 +11,7 @@ namespace esphome::honeywellabp2_i2c { enum ABP2TRANFERFUNCTION { ABP2_TRANS_FUNC_A = 0, ABP2_TRANS_FUNC_B = 1 }; -class HONEYWELLABP2Sensor : public PollingComponent, public i2c::I2CDevice { +class HONEYWELLABP2Sensor final : public PollingComponent, public i2c::I2CDevice { public: void set_pressure_sensor(sensor::Sensor *pressure_sensor) { this->pressure_sensor_ = pressure_sensor; }; void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; }; diff --git a/esphome/components/host/gpio.h b/esphome/components/host/gpio.h index 6f2bccf102..bd2d09257b 100644 --- a/esphome/components/host/gpio.h +++ b/esphome/components/host/gpio.h @@ -6,7 +6,7 @@ namespace esphome::host { -class HostGPIOPin : public InternalGPIOPin { +class HostGPIOPin final : public InternalGPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } diff --git a/esphome/components/host/time/host_time.h b/esphome/components/host/time/host_time.h index 19e1af99d1..4462108b6d 100644 --- a/esphome/components/host/time/host_time.h +++ b/esphome/components/host/time/host_time.h @@ -5,7 +5,7 @@ namespace esphome::host { -class HostTime : public time::RealTimeClock { +class HostTime final : public time::RealTimeClock { public: void update() override {} }; diff --git a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h index e98eeea723..36346c8293 100644 --- a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h +++ b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h @@ -6,7 +6,7 @@ namespace esphome::hrxl_maxsonar_wr { -class HrxlMaxsonarWrComponent : public sensor::Sensor, public Component, public uart::UARTDevice { +class HrxlMaxsonarWrComponent final : public sensor::Sensor, public Component, public uart::UARTDevice { public: // Nothing really public. diff --git a/esphome/components/hte501/hte501.h b/esphome/components/hte501/hte501.h index 310073f88b..403d3c1de6 100644 --- a/esphome/components/hte501/hte501.h +++ b/esphome/components/hte501/hte501.h @@ -7,7 +7,7 @@ namespace esphome::hte501 { /// This class implements support for the hte501 of temperature i2c sensors. -class HTE501Component : public PollingComponent, public i2c::I2CDevice { +class HTE501Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } void set_humidity_sensor(sensor::Sensor *humidity_sensor) { humidity_sensor_ = humidity_sensor; } diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 2477e26bc1..5025a5c12d 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -311,7 +311,7 @@ inline HttpReadResult http_read_fully(HttpContainer *container, uint8_t *buffer, return {HttpReadStatus::OK, 0}; } -class HttpRequestResponseTrigger : public Trigger, std::string &> { +class HttpRequestResponseTrigger final : public Trigger, std::string &> { public: void process(const std::shared_ptr &container, std::string &response_body) { this->trigger(container, response_body); @@ -447,7 +447,7 @@ class HttpRequestComponent : public Component { uint32_t watchdog_timeout_{0}; }; -template class HttpRequestSendAction : public Action { +template class HttpRequestSendAction final : public Action { public: HttpRequestSendAction(HttpRequestComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, url) diff --git a/esphome/components/http_request/http_request_arduino.h b/esphome/components/http_request/http_request_arduino.h index b009d45b1c..8da40798ec 100644 --- a/esphome/components/http_request/http_request_arduino.h +++ b/esphome/components/http_request/http_request_arduino.h @@ -46,7 +46,7 @@ class HttpContainerArduino : public HttpContainer { size_t chunk_remaining_{0}; ///< Bytes remaining in current chunk }; -class HttpRequestArduino : public HttpRequestComponent { +class HttpRequestArduino final : public HttpRequestComponent { public: #ifdef USE_ESP8266 void set_tls_buffer_size_rx(uint16_t size) { this->tls_buffer_size_rx_ = size; } diff --git a/esphome/components/http_request/http_request_host.h b/esphome/components/http_request/http_request_host.h index 52be0e8a16..9045702f46 100644 --- a/esphome/components/http_request/http_request_host.h +++ b/esphome/components/http_request/http_request_host.h @@ -16,7 +16,7 @@ class HttpContainerHost : public HttpContainer { std::vector response_body_{}; }; -class HttpRequestHost : public HttpRequestComponent { +class HttpRequestHost final : public HttpRequestComponent { public: std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, const std::vector
&request_headers, diff --git a/esphome/components/http_request/http_request_idf.h b/esphome/components/http_request/http_request_idf.h index 9ed1a97b1a..8a803b5469 100644 --- a/esphome/components/http_request/http_request_idf.h +++ b/esphome/components/http_request/http_request_idf.h @@ -26,7 +26,7 @@ class HttpContainerIDF : public HttpContainer { esp_http_client_handle_t client_; }; -class HttpRequestIDF : public HttpRequestComponent { +class HttpRequestIDF final : public HttpRequestComponent { public: void dump_config() override; diff --git a/esphome/components/http_request/ota/automation.h b/esphome/components/http_request/ota/automation.h index f6f49b14b1..487f6b70a1 100644 --- a/esphome/components/http_request/ota/automation.h +++ b/esphome/components/http_request/ota/automation.h @@ -5,7 +5,7 @@ namespace esphome::http_request { -template class OtaHttpRequestComponentFlashAction : public Action { +template class OtaHttpRequestComponentFlashAction final : public Action { public: OtaHttpRequestComponentFlashAction(OtaHttpRequestComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, md5_url) diff --git a/esphome/components/htu21d/htu21d.h b/esphome/components/htu21d/htu21d.h index a111722dc7..f86d62c5e8 100644 --- a/esphome/components/htu21d/htu21d.h +++ b/esphome/components/htu21d/htu21d.h @@ -9,7 +9,7 @@ namespace esphome::htu21d { enum HTU21DSensorModels { HTU21D_SENSOR_MODEL_HTU21D = 0, HTU21D_SENSOR_MODEL_SI7021, HTU21D_SENSOR_MODEL_SHT21 }; -class HTU21DComponent : public PollingComponent, public i2c::I2CDevice { +class HTU21DComponent final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } @@ -34,7 +34,7 @@ class HTU21DComponent : public PollingComponent, public i2c::I2CDevice { HTU21DSensorModels sensor_model_{HTU21D_SENSOR_MODEL_HTU21D}; }; -template class SetHeaterLevelAction : public Action, public Parented { +template class SetHeaterLevelAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, level) @@ -45,7 +45,7 @@ template class SetHeaterLevelAction : public Action, publ } }; -template class SetHeaterAction : public Action, public Parented { +template class SetHeaterAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, status) diff --git a/esphome/components/htu31d/htu31d.h b/esphome/components/htu31d/htu31d.h index 451918cb3b..c25a979600 100644 --- a/esphome/components/htu31d/htu31d.h +++ b/esphome/components/htu31d/htu31d.h @@ -7,7 +7,7 @@ namespace esphome::htu31d { -class HTU31DComponent : public PollingComponent, public i2c::I2CDevice { +class HTU31DComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; /// Setup (reset) the sensor and check connection. void update() override; /// Update the sensor values (temperature+humidity). diff --git a/esphome/components/hub75/hub75_component.h b/esphome/components/hub75/hub75_component.h index ab7e3fc5b1..98bc2e52e6 100644 --- a/esphome/components/hub75/hub75_component.h +++ b/esphome/components/hub75/hub75_component.h @@ -16,7 +16,7 @@ namespace esphome::hub75 { using esphome::display::ColorBitness; using esphome::display::ColorOrder; -class HUB75Display : public display::Display { +class HUB75Display final : public display::Display { public: // Constructor accepting config explicit HUB75Display(const Hub75Config &config); @@ -51,7 +51,7 @@ class HUB75Display : public display::Display { bool enabled_{false}; }; -template class SetBrightnessAction : public Action, public Parented { +template class SetBrightnessAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, brightness) diff --git a/esphome/components/hx711/hx711.h b/esphome/components/hx711/hx711.h index 43ab4c0f56..62d0171d8f 100644 --- a/esphome/components/hx711/hx711.h +++ b/esphome/components/hx711/hx711.h @@ -14,7 +14,7 @@ enum HX711Gain : uint8_t { HX711_GAIN_64 = 3, }; -class HX711Sensor : public sensor::Sensor, public PollingComponent { +class HX711Sensor final : public sensor::Sensor, public PollingComponent { public: void set_dout_pin(GPIOPin *dout_pin) { dout_pin_ = dout_pin; } void set_sck_pin(GPIOPin *sck_pin) { sck_pin_ = sck_pin; } diff --git a/esphome/components/hydreon_rgxx/hydreon_rgxx.h b/esphome/components/hydreon_rgxx/hydreon_rgxx.h index 2ae46907c1..a7bde6105c 100644 --- a/esphome/components/hydreon_rgxx/hydreon_rgxx.h +++ b/esphome/components/hydreon_rgxx/hydreon_rgxx.h @@ -32,7 +32,7 @@ static const uint8_t NUM_SENSORS = 1; #define HYDREON_RGXX_IGNORE_LIST(F, SEP) F("Emitters") SEP F("Event") SEP F("Reset") -class HydreonRGxxComponent : public PollingComponent, public uart::UARTDevice { +class HydreonRGxxComponent final : public PollingComponent, public uart::UARTDevice { public: void set_sensor(sensor::Sensor *sensor, int index) { this->sensors_[index] = sensor; } #ifdef USE_BINARY_SENSOR @@ -86,7 +86,7 @@ class HydreonRGxxComponent : public PollingComponent, public uart::UARTDevice { int sensors_received_ = -1; }; -class HydreonRGxxBinaryComponent : public Component { +class HydreonRGxxBinaryComponent final : public Component { public: HydreonRGxxBinaryComponent(HydreonRGxxComponent *parent) {} }; diff --git a/esphome/components/hyt271/hyt271.h b/esphome/components/hyt271/hyt271.h index d08b3779ad..b373c26466 100644 --- a/esphome/components/hyt271/hyt271.h +++ b/esphome/components/hyt271/hyt271.h @@ -6,7 +6,7 @@ namespace esphome::hyt271 { -class HYT271Component : public PollingComponent, public i2c::I2CDevice { +class HYT271Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } diff --git a/esphome/components/i2c/i2c_bus_arduino.h b/esphome/components/i2c/i2c_bus_arduino.h index edc14af7bc..ded28dd80c 100644 --- a/esphome/components/i2c/i2c_bus_arduino.h +++ b/esphome/components/i2c/i2c_bus_arduino.h @@ -14,7 +14,7 @@ enum RecoveryCode { RECOVERY_COMPLETED, }; -class ArduinoI2CBus : public InternalI2CBus, public Component { +class ArduinoI2CBus final : public InternalI2CBus, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/i2c/i2c_bus_esp_idf.h b/esphome/components/i2c/i2c_bus_esp_idf.h index c23f9f0c54..92e96f649b 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.h +++ b/esphome/components/i2c/i2c_bus_esp_idf.h @@ -14,7 +14,7 @@ enum RecoveryCode { RECOVERY_COMPLETED, }; -class IDFI2CBus : public InternalI2CBus, public Component { +class IDFI2CBus final : public InternalI2CBus, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/i2c/i2c_bus_host.h b/esphome/components/i2c/i2c_bus_host.h index 8e3aff7977..8064016a52 100644 --- a/esphome/components/i2c/i2c_bus_host.h +++ b/esphome/components/i2c/i2c_bus_host.h @@ -8,7 +8,7 @@ namespace esphome::i2c { -class HostI2CBus : public I2CBus, public Component { +class HostI2CBus final : public I2CBus, public Component { public: ~HostI2CBus() override; diff --git a/esphome/components/i2c/i2c_bus_zephyr.h b/esphome/components/i2c/i2c_bus_zephyr.h index 3c4aa9ed1d..3ada1e0a0f 100644 --- a/esphome/components/i2c/i2c_bus_zephyr.h +++ b/esphome/components/i2c/i2c_bus_zephyr.h @@ -9,7 +9,7 @@ struct device; // NOLINT(readability-identifier-naming) - forward decl of Zephy namespace esphome::i2c { -class ZephyrI2CBus : public InternalI2CBus, public Component { +class ZephyrI2CBus final : public InternalI2CBus, public Component { public: explicit ZephyrI2CBus(const device *i2c_dev) : i2c_dev_(i2c_dev) {} void setup() override; diff --git a/esphome/components/i2c_device/i2c_device.h b/esphome/components/i2c_device/i2c_device.h index aeae622c2e..d5a49a2caa 100644 --- a/esphome/components/i2c_device/i2c_device.h +++ b/esphome/components/i2c_device/i2c_device.h @@ -5,7 +5,7 @@ namespace esphome::i2c_device { -class I2CDeviceComponent : public Component, public i2c::I2CDevice { +class I2CDeviceComponent final : public Component, public i2c::I2CDevice { public: void dump_config() override; diff --git a/esphome/components/i2s_audio/i2s_audio.h b/esphome/components/i2s_audio/i2s_audio.h index 6b32b556d9..00a9705807 100644 --- a/esphome/components/i2s_audio/i2s_audio.h +++ b/esphome/components/i2s_audio/i2s_audio.h @@ -36,7 +36,7 @@ class I2SAudioIn : public I2SAudioBase {}; class I2SAudioOut : public I2SAudioBase {}; -class I2SAudioComponent : public Component { +class I2SAudioComponent final : public Component { public: i2s_std_gpio_config_t get_pin_config() const { return {.mclk = (gpio_num_t) this->mclk_pin_, diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h index 06f2de7610..65ad7df1af 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h @@ -14,7 +14,7 @@ namespace esphome::i2s_audio { -class I2SAudioMicrophone : public I2SAudioIn, public microphone::Microphone, public Component { +class I2SAudioMicrophone final : public I2SAudioIn, public microphone::Microphone, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h index 7b7f8b647d..4b52dcd52a 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h @@ -14,7 +14,7 @@ enum class I2SCommFmt : uint8_t { /// @brief Standard I2S speaker implementation. /// Outputs PCM audio data directly to an I2S DAC using the standard I2S protocol. -class I2SAudioSpeaker : public I2SAudioSpeakerBase { +class I2SAudioSpeaker final : public I2SAudioSpeakerBase { public: void dump_config() override; diff --git a/esphome/components/iaqcore/iaqcore.h b/esphome/components/iaqcore/iaqcore.h index 39f290e120..6fdf9cbce8 100644 --- a/esphome/components/iaqcore/iaqcore.h +++ b/esphome/components/iaqcore/iaqcore.h @@ -6,7 +6,7 @@ namespace esphome::iaqcore { -class IAQCore : public PollingComponent, public i2c::I2CDevice { +class IAQCore final : public PollingComponent, public i2c::I2CDevice { public: void set_co2(sensor::Sensor *co2) { co2_ = co2; } void set_tvoc(sensor::Sensor *tvoc) { tvoc_ = tvoc; } diff --git a/esphome/components/improv_serial/improv_serial_component.h b/esphome/components/improv_serial/improv_serial_component.h index 70f9214e2d..4df6f6df2d 100644 --- a/esphome/components/improv_serial/improv_serial_component.h +++ b/esphome/components/improv_serial/improv_serial_component.h @@ -44,7 +44,7 @@ enum ImprovSerialType : uint8_t { static const uint16_t IMPROV_SERIAL_TIMEOUT = 100; static const uint8_t IMPROV_SERIAL_VERSION = 1; -class ImprovSerialComponent : public Component, public improv_base::ImprovBase { +class ImprovSerialComponent final : public Component, public improv_base::ImprovBase { public: void setup() override; void loop() override; diff --git a/esphome/components/ina219/ina219.h b/esphome/components/ina219/ina219.h index 7462c07272..a78c1653f4 100644 --- a/esphome/components/ina219/ina219.h +++ b/esphome/components/ina219/ina219.h @@ -8,7 +8,7 @@ namespace esphome::ina219 { -class INA219Component : public PollingComponent, public i2c::I2CDevice { +class INA219Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ina226/ina226.h b/esphome/components/ina226/ina226.h index 7d6b526f40..00d62fad76 100644 --- a/esphome/components/ina226/ina226.h +++ b/esphome/components/ina226/ina226.h @@ -40,7 +40,7 @@ union ConfigurationRegister { } __attribute__((packed)); }; -class INA226Component : public PollingComponent, public i2c::I2CDevice { +class INA226Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ina260/ina260.h b/esphome/components/ina260/ina260.h index 856e715774..bbcb7a7acb 100644 --- a/esphome/components/ina260/ina260.h +++ b/esphome/components/ina260/ina260.h @@ -6,7 +6,7 @@ namespace esphome::ina260 { -class INA260Component : public PollingComponent, public i2c::I2CDevice { +class INA260Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ina2xx_i2c/ina2xx_i2c.h b/esphome/components/ina2xx_i2c/ina2xx_i2c.h index 783723b396..d9945be5ef 100644 --- a/esphome/components/ina2xx_i2c/ina2xx_i2c.h +++ b/esphome/components/ina2xx_i2c/ina2xx_i2c.h @@ -6,7 +6,7 @@ namespace esphome::ina2xx_i2c { -class INA2XXI2C : public ina2xx_base::INA2XX, public i2c::I2CDevice { +class INA2XXI2C final : public ina2xx_base::INA2XX, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ina2xx_spi/ina2xx_spi.h b/esphome/components/ina2xx_spi/ina2xx_spi.h index 8e065de816..efe9cf257d 100644 --- a/esphome/components/ina2xx_spi/ina2xx_spi.h +++ b/esphome/components/ina2xx_spi/ina2xx_spi.h @@ -6,9 +6,9 @@ namespace esphome::ina2xx_spi { -class INA2XXSPI : public ina2xx_base::INA2XX, - public spi::SPIDevice { +class INA2XXSPI final : public ina2xx_base::INA2XX, + public spi::SPIDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ina3221/ina3221.h b/esphome/components/ina3221/ina3221.h index 9d9762caf3..48226c743a 100644 --- a/esphome/components/ina3221/ina3221.h +++ b/esphome/components/ina3221/ina3221.h @@ -6,7 +6,7 @@ namespace esphome::ina3221 { -class INA3221Component : public PollingComponent, public i2c::I2CDevice { +class INA3221Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h index 37e50943f3..4c90d6d35b 100644 --- a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h +++ b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h @@ -8,7 +8,7 @@ namespace esphome::inkbird_ibsth1_mini { -class InkbirdIbstH1Mini : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class InkbirdIbstH1Mini final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/inkplate/inkplate.h b/esphome/components/inkplate/inkplate.h index 40e32c4cc4..4f9f4109ee 100644 --- a/esphome/components/inkplate/inkplate.h +++ b/esphome/components/inkplate/inkplate.h @@ -31,7 +31,7 @@ static constexpr uint8_t LUTB[16] = {0xFF, 0xFD, 0xF7, 0xF5, 0xDF, 0xDD, 0xD7, 0 static constexpr uint8_t PIXEL_MASK_LUT[8] = {0x1, 0x2, 0x4, 0x8, 0x10, 0x20, 0x40, 0x80}; static constexpr uint8_t PIXEL_MASK_GLUT[2] = {0x0F, 0xF0}; -class Inkplate : public display::DisplayBuffer, public i2c::I2CDevice { +class Inkplate final : public display::DisplayBuffer, public i2c::I2CDevice { public: void set_greyscale(bool greyscale) { this->greyscale_ = greyscale; diff --git a/esphome/components/integration/integration_sensor.h b/esphome/components/integration/integration_sensor.h index 1c5edfcba5..019c3ee074 100644 --- a/esphome/components/integration/integration_sensor.h +++ b/esphome/components/integration/integration_sensor.h @@ -22,7 +22,7 @@ enum IntegrationMethod { INTEGRATION_METHOD_RIGHT, }; -class IntegrationSensor : public sensor::Sensor, public Component { +class IntegrationSensor final : public sensor::Sensor, public Component { public: void setup() override; void dump_config() override; @@ -71,12 +71,12 @@ class IntegrationSensor : public sensor::Sensor, public Component { float last_value_{0.0f}; }; -template class ResetAction : public Action, public Parented { +template class ResetAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->reset(); } }; -template class SetValueAction : public Action, public Parented { +template class SetValueAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, value) From e5d8c22b47ae2d15ba5ea84c4499d1e8b963332b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:20:11 +1200 Subject: [PATCH 0584/1815] Mark configurable classes as final (13/21: pmsa003i-rc522) (#16964) --- esphome/components/pmsa003i/pmsa003i.h | 2 +- esphome/components/pmsx003/pmsx003.h | 2 +- esphome/components/pmwcs3/pmwcs3.h | 8 +++---- esphome/components/pn532/pn532.h | 4 ++-- esphome/components/pn532_i2c/pn532_i2c.h | 2 +- esphome/components/pn532_spi/pn532_spi.h | 6 ++--- esphome/components/pn7150/automation.h | 22 +++++++++---------- esphome/components/pn7150_i2c/pn7150_i2c.h | 2 +- esphome/components/pn7160/automation.h | 22 +++++++++---------- esphome/components/pn7160_i2c/pn7160_i2c.h | 2 +- esphome/components/pn7160_spi/pn7160_spi.h | 6 ++--- .../components/power_supply/power_supply.h | 2 +- .../prometheus/prometheus_handler.h | 2 +- esphome/components/psram/psram.h | 2 +- esphome/components/pulse_counter/automation.h | 2 +- .../pulse_counter/pulse_counter_sensor.h | 2 +- esphome/components/pulse_meter/automation.h | 2 +- .../pulse_meter/pulse_meter_sensor.h | 2 +- esphome/components/pulse_width/pulse_width.h | 2 +- .../pvvx_mithermometer/display/pvvx_display.h | 2 +- .../pvvx_mithermometer/pvvx_mithermometer.h | 2 +- esphome/components/pylontech/pylontech.h | 2 +- .../pylontech/sensor/pylontech_sensor.h | 2 +- .../text_sensor/pylontech_text_sensor.h | 2 +- esphome/components/pzem004t/pzem004t.h | 2 +- esphome/components/pzemac/pzemac.h | 4 ++-- esphome/components/pzemdc/pzemdc.h | 4 ++-- esphome/components/qmc5883l/qmc5883l.h | 2 +- esphome/components/qmp6988/qmp6988.h | 2 +- esphome/components/qr_code/qr_code.h | 2 +- esphome/components/qspi_dbi/qspi_dbi.h | 6 ++--- esphome/components/qwiic_pir/qwiic_pir.h | 2 +- .../radon_eye_ble/radon_eye_listener.h | 2 +- .../radon_eye_rd200/radon_eye_rd200.h | 2 +- esphome/components/rc522/rc522.h | 4 ++-- 35 files changed, 68 insertions(+), 68 deletions(-) diff --git a/esphome/components/pmsa003i/pmsa003i.h b/esphome/components/pmsa003i/pmsa003i.h index aebe80b711..908b073be1 100644 --- a/esphome/components/pmsa003i/pmsa003i.h +++ b/esphome/components/pmsa003i/pmsa003i.h @@ -26,7 +26,7 @@ struct PM25AQIData { uint16_t checksum; ///< Packet checksum }; -class PMSA003IComponent : public PollingComponent, public i2c::I2CDevice { +class PMSA003IComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/pmsx003/pmsx003.h b/esphome/components/pmsx003/pmsx003.h index d559f2dec0..c62960e7c3 100644 --- a/esphome/components/pmsx003/pmsx003.h +++ b/esphome/components/pmsx003/pmsx003.h @@ -29,7 +29,7 @@ enum class State : uint8_t { WAITING, }; -class PMSX003Component : public uart::UARTDevice, public Component { +class PMSX003Component final : public uart::UARTDevice, public Component { public: PMSX003Component() = default; void setup() override; diff --git a/esphome/components/pmwcs3/pmwcs3.h b/esphome/components/pmwcs3/pmwcs3.h index d669147819..4ce4a5ce9c 100644 --- a/esphome/components/pmwcs3/pmwcs3.h +++ b/esphome/components/pmwcs3/pmwcs3.h @@ -9,7 +9,7 @@ namespace esphome::pmwcs3 { -class PMWCS3Component : public PollingComponent, public i2c::I2CDevice { +class PMWCS3Component final : public PollingComponent, public i2c::I2CDevice { public: void update() override; void dump_config() override; @@ -32,7 +32,7 @@ class PMWCS3Component : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *vwc_sensor_{nullptr}; }; -template class PMWCS3AirCalibrationAction : public Action { +template class PMWCS3AirCalibrationAction final : public Action { public: PMWCS3AirCalibrationAction(PMWCS3Component *parent) : parent_(parent) {} @@ -42,7 +42,7 @@ template class PMWCS3AirCalibrationAction : public Action PMWCS3Component *parent_; }; -template class PMWCS3WaterCalibrationAction : public Action { +template class PMWCS3WaterCalibrationAction final : public Action { public: PMWCS3WaterCalibrationAction(PMWCS3Component *parent) : parent_(parent) {} @@ -52,7 +52,7 @@ template class PMWCS3WaterCalibrationAction : public Action class PMWCS3NewI2cAddressAction : public Action { +template class PMWCS3NewI2cAddressAction final : public Action { public: PMWCS3NewI2cAddressAction(PMWCS3Component *parent) : parent_(parent) {} TEMPLATABLE_VALUE(int, new_address) diff --git a/esphome/components/pn532/pn532.h b/esphome/components/pn532/pn532.h index a26f27ed54..629a697aa5 100644 --- a/esphome/components/pn532/pn532.h +++ b/esphome/components/pn532/pn532.h @@ -114,7 +114,7 @@ class PN532 : public PollingComponent { CallbackManager on_finished_write_callback_; }; -class PN532BinarySensor : public binary_sensor::BinarySensor { +class PN532BinarySensor final : public binary_sensor::BinarySensor { public: void set_uid(const nfc::NfcTagUid &uid) { uid_ = uid; } @@ -132,7 +132,7 @@ class PN532BinarySensor : public binary_sensor::BinarySensor { bool found_{false}; }; -template class PN532IsWritingCondition : public Condition, public Parented { +template class PN532IsWritingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_writing(); } }; diff --git a/esphome/components/pn532_i2c/pn532_i2c.h b/esphome/components/pn532_i2c/pn532_i2c.h index b2a2ac2e18..6495f17599 100644 --- a/esphome/components/pn532_i2c/pn532_i2c.h +++ b/esphome/components/pn532_i2c/pn532_i2c.h @@ -8,7 +8,7 @@ namespace esphome::pn532_i2c { -class PN532I2C : public pn532::PN532, public i2c::I2CDevice { +class PN532I2C final : public pn532::PN532, public i2c::I2CDevice { public: void dump_config() override; diff --git a/esphome/components/pn532_spi/pn532_spi.h b/esphome/components/pn532_spi/pn532_spi.h index 2bfd4accf7..f29950c423 100644 --- a/esphome/components/pn532_spi/pn532_spi.h +++ b/esphome/components/pn532_spi/pn532_spi.h @@ -8,9 +8,9 @@ namespace esphome::pn532_spi { -class PN532Spi : public pn532::PN532, - public spi::SPIDevice { +class PN532Spi final : public pn532::PN532, + public spi::SPIDevice { public: void setup() override; diff --git a/esphome/components/pn7150/automation.h b/esphome/components/pn7150/automation.h index 0b2e5f5d24..c3f8d3e5d3 100644 --- a/esphome/components/pn7150/automation.h +++ b/esphome/components/pn7150/automation.h @@ -6,40 +6,40 @@ namespace esphome::pn7150 { -template class PN7150IsWritingCondition : public Condition, public Parented { +template class PN7150IsWritingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_writing(); } }; -template class EmulationOffAction : public Action, public Parented { +template class EmulationOffAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_tag_emulation_off(); } }; -template class EmulationOnAction : public Action, public Parented { +template class EmulationOnAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_tag_emulation_on(); } }; -template class PollingOffAction : public Action, public Parented { +template class PollingOffAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_polling_off(); } }; -template class PollingOnAction : public Action, public Parented { +template class PollingOnAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_polling_on(); } }; -template class SetCleanModeAction : public Action, public Parented { +template class SetCleanModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->clean_mode(); } }; -template class SetFormatModeAction : public Action, public Parented { +template class SetFormatModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->format_mode(); } }; -template class SetReadModeAction : public Action, public Parented { +template class SetReadModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->read_mode(); } }; -template class SetEmulationMessageAction : public Action, public Parented { +template class SetEmulationMessageAction final : public Action, public Parented { TEMPLATABLE_VALUE(std::string, message) TEMPLATABLE_VALUE(bool, include_android_app_record) @@ -49,7 +49,7 @@ template class SetEmulationMessageAction : public Action, } }; -template class SetWriteMessageAction : public Action, public Parented { +template class SetWriteMessageAction final : public Action, public Parented { TEMPLATABLE_VALUE(std::string, message) TEMPLATABLE_VALUE(bool, include_android_app_record) @@ -59,7 +59,7 @@ template class SetWriteMessageAction : public Action, pub } }; -template class SetWriteModeAction : public Action, public Parented { +template class SetWriteModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->write_mode(); } }; diff --git a/esphome/components/pn7150_i2c/pn7150_i2c.h b/esphome/components/pn7150_i2c/pn7150_i2c.h index 2ea8c8f75c..25b0f3b855 100644 --- a/esphome/components/pn7150_i2c/pn7150_i2c.h +++ b/esphome/components/pn7150_i2c/pn7150_i2c.h @@ -8,7 +8,7 @@ namespace esphome::pn7150_i2c { -class PN7150I2C : public pn7150::PN7150, public i2c::I2CDevice { +class PN7150I2C final : public pn7150::PN7150, public i2c::I2CDevice { public: void dump_config() override; diff --git a/esphome/components/pn7160/automation.h b/esphome/components/pn7160/automation.h index 7300c4a8d6..9f03a5a3d6 100644 --- a/esphome/components/pn7160/automation.h +++ b/esphome/components/pn7160/automation.h @@ -6,40 +6,40 @@ namespace esphome::pn7160 { -template class PN7160IsWritingCondition : public Condition, public Parented { +template class PN7160IsWritingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_writing(); } }; -template class EmulationOffAction : public Action, public Parented { +template class EmulationOffAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_tag_emulation_off(); } }; -template class EmulationOnAction : public Action, public Parented { +template class EmulationOnAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_tag_emulation_on(); } }; -template class PollingOffAction : public Action, public Parented { +template class PollingOffAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_polling_off(); } }; -template class PollingOnAction : public Action, public Parented { +template class PollingOnAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_polling_on(); } }; -template class SetCleanModeAction : public Action, public Parented { +template class SetCleanModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->clean_mode(); } }; -template class SetFormatModeAction : public Action, public Parented { +template class SetFormatModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->format_mode(); } }; -template class SetReadModeAction : public Action, public Parented { +template class SetReadModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->read_mode(); } }; -template class SetEmulationMessageAction : public Action, public Parented { +template class SetEmulationMessageAction final : public Action, public Parented { TEMPLATABLE_VALUE(std::string, message) TEMPLATABLE_VALUE(bool, include_android_app_record) @@ -49,7 +49,7 @@ template class SetEmulationMessageAction : public Action, } }; -template class SetWriteMessageAction : public Action, public Parented { +template class SetWriteMessageAction final : public Action, public Parented { TEMPLATABLE_VALUE(std::string, message) TEMPLATABLE_VALUE(bool, include_android_app_record) @@ -59,7 +59,7 @@ template class SetWriteMessageAction : public Action, pub } }; -template class SetWriteModeAction : public Action, public Parented { +template class SetWriteModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->write_mode(); } }; diff --git a/esphome/components/pn7160_i2c/pn7160_i2c.h b/esphome/components/pn7160_i2c/pn7160_i2c.h index d29fd04fac..2a3b767765 100644 --- a/esphome/components/pn7160_i2c/pn7160_i2c.h +++ b/esphome/components/pn7160_i2c/pn7160_i2c.h @@ -8,7 +8,7 @@ namespace esphome::pn7160_i2c { -class PN7160I2C : public pn7160::PN7160, public i2c::I2CDevice { +class PN7160I2C final : public pn7160::PN7160, public i2c::I2CDevice { public: void dump_config() override; diff --git a/esphome/components/pn7160_spi/pn7160_spi.h b/esphome/components/pn7160_spi/pn7160_spi.h index 2d9c1fda11..4f22e5edec 100644 --- a/esphome/components/pn7160_spi/pn7160_spi.h +++ b/esphome/components/pn7160_spi/pn7160_spi.h @@ -12,9 +12,9 @@ namespace esphome::pn7160_spi { static constexpr uint8_t TDD_SPI_READ = 0xFF; static constexpr uint8_t TDD_SPI_WRITE = 0x0A; -class PN7160Spi : public pn7160::PN7160, - public spi::SPIDevice { +class PN7160Spi final : public pn7160::PN7160, + public spi::SPIDevice { public: void setup() override; diff --git a/esphome/components/power_supply/power_supply.h b/esphome/components/power_supply/power_supply.h index e096f69e3b..eaf77af32e 100644 --- a/esphome/components/power_supply/power_supply.h +++ b/esphome/components/power_supply/power_supply.h @@ -7,7 +7,7 @@ namespace esphome::power_supply { -class PowerSupply : public Component { +class PowerSupply final : public Component { public: void set_pin(GPIOPin *pin) { pin_ = pin; } void set_enable_time(uint32_t enable_time) { enable_time_ = enable_time; } diff --git a/esphome/components/prometheus/prometheus_handler.h b/esphome/components/prometheus/prometheus_handler.h index 008081f586..bc256c6885 100644 --- a/esphome/components/prometheus/prometheus_handler.h +++ b/esphome/components/prometheus/prometheus_handler.h @@ -14,7 +14,7 @@ namespace esphome::prometheus { -class PrometheusHandler : public AsyncWebHandler, public Component { +class PrometheusHandler final : public AsyncWebHandler, public Component { public: PrometheusHandler(web_server_base::WebServerBase *base) : base_(base) {} diff --git a/esphome/components/psram/psram.h b/esphome/components/psram/psram.h index 22a49588b4..8549ef2959 100644 --- a/esphome/components/psram/psram.h +++ b/esphome/components/psram/psram.h @@ -6,7 +6,7 @@ namespace esphome::psram { -class PsramComponent : public Component { +class PsramComponent final : public Component { void dump_config() override; }; diff --git a/esphome/components/pulse_counter/automation.h b/esphome/components/pulse_counter/automation.h index 14264e87b3..380ef02304 100644 --- a/esphome/components/pulse_counter/automation.h +++ b/esphome/components/pulse_counter/automation.h @@ -6,7 +6,7 @@ namespace esphome::pulse_counter { -template class SetTotalPulsesAction : public Action { +template class SetTotalPulsesAction final : public Action { public: SetTotalPulsesAction(PulseCounterSensor *pulse_counter) : pulse_counter_(pulse_counter) {} diff --git a/esphome/components/pulse_counter/pulse_counter_sensor.h b/esphome/components/pulse_counter/pulse_counter_sensor.h index 4f23ef1548..6704d3dc31 100644 --- a/esphome/components/pulse_counter/pulse_counter_sensor.h +++ b/esphome/components/pulse_counter/pulse_counter_sensor.h @@ -59,7 +59,7 @@ struct HwPulseCounterStorage : public PulseCounterStorageBase { PulseCounterStorageBase *get_storage(bool hw_pcnt = false); -class PulseCounterSensor : public sensor::Sensor, public PollingComponent { +class PulseCounterSensor final : public sensor::Sensor, public PollingComponent { public: explicit PulseCounterSensor(bool hw_pcnt = false) : storage_(*get_storage(hw_pcnt)) {} diff --git a/esphome/components/pulse_meter/automation.h b/esphome/components/pulse_meter/automation.h index 1def89c3d3..885922a22a 100644 --- a/esphome/components/pulse_meter/automation.h +++ b/esphome/components/pulse_meter/automation.h @@ -6,7 +6,7 @@ namespace esphome::pulse_meter { -template class SetTotalPulsesAction : public Action { +template class SetTotalPulsesAction final : public Action { public: SetTotalPulsesAction(PulseMeterSensor *pulse_meter) : pulse_meter_(pulse_meter) {} diff --git a/esphome/components/pulse_meter/pulse_meter_sensor.h b/esphome/components/pulse_meter/pulse_meter_sensor.h index 243a64bf05..9fc99a440b 100644 --- a/esphome/components/pulse_meter/pulse_meter_sensor.h +++ b/esphome/components/pulse_meter/pulse_meter_sensor.h @@ -9,7 +9,7 @@ namespace esphome::pulse_meter { -class PulseMeterSensor : public sensor::Sensor, public Component { +class PulseMeterSensor final : public sensor::Sensor, public Component { public: enum InternalFilterMode { FILTER_EDGE = 0, diff --git a/esphome/components/pulse_width/pulse_width.h b/esphome/components/pulse_width/pulse_width.h index f77766a961..7a79b80678 100644 --- a/esphome/components/pulse_width/pulse_width.h +++ b/esphome/components/pulse_width/pulse_width.h @@ -26,7 +26,7 @@ class PulseWidthSensorStore { volatile uint32_t last_rise_{0}; }; -class PulseWidthSensor : public sensor::Sensor, public PollingComponent { +class PulseWidthSensor final : public sensor::Sensor, public PollingComponent { public: void set_pin(InternalGPIOPin *pin) { pin_ = pin; } void setup() override { this->store_.setup(this->pin_); } diff --git a/esphome/components/pvvx_mithermometer/display/pvvx_display.h b/esphome/components/pvvx_mithermometer/display/pvvx_display.h index e1aebae7a5..d231111c58 100644 --- a/esphome/components/pvvx_mithermometer/display/pvvx_display.h +++ b/esphome/components/pvvx_mithermometer/display/pvvx_display.h @@ -31,7 +31,7 @@ enum UNIT { using pvvx_writer_t = display::DisplayWriter; -class PVVXDisplay : public ble_client::BLEClientNode, public PollingComponent { +class PVVXDisplay final : public ble_client::BLEClientNode, public PollingComponent { public: void set_writer(pvvx_writer_t &&writer) { this->writer_ = writer; } void set_auto_clear(bool auto_clear_enabled) { this->auto_clear_enabled_ = auto_clear_enabled; } diff --git a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h index b5d6da21ef..382e41d210 100644 --- a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h +++ b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h @@ -18,7 +18,7 @@ struct ParseResult { int raw_offset; }; -class PVVXMiThermometer : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class PVVXMiThermometer final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; diff --git a/esphome/components/pylontech/pylontech.h b/esphome/components/pylontech/pylontech.h index 1d86803cc2..eae5e6e7bc 100644 --- a/esphome/components/pylontech/pylontech.h +++ b/esphome/components/pylontech/pylontech.h @@ -21,7 +21,7 @@ class PylontechListener { virtual void dump_config(); }; -class PylontechComponent : public PollingComponent, public uart::UARTDevice { +class PylontechComponent final : public PollingComponent, public uart::UARTDevice { public: PylontechComponent(); diff --git a/esphome/components/pylontech/sensor/pylontech_sensor.h b/esphome/components/pylontech/sensor/pylontech_sensor.h index 36576e8332..1403d3445d 100644 --- a/esphome/components/pylontech/sensor/pylontech_sensor.h +++ b/esphome/components/pylontech/sensor/pylontech_sensor.h @@ -5,7 +5,7 @@ namespace esphome::pylontech { -class PylontechSensor : public PylontechListener { +class PylontechSensor final : public PylontechListener { public: PylontechSensor(int8_t bat_num); void dump_config() override; diff --git a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h index 30921b13f4..3ba4f1fd4e 100644 --- a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h +++ b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h @@ -5,7 +5,7 @@ namespace esphome::pylontech { -class PylontechTextSensor : public PylontechListener { +class PylontechTextSensor final : public PylontechListener { public: PylontechTextSensor(int8_t bat_num); void dump_config() override; diff --git a/esphome/components/pzem004t/pzem004t.h b/esphome/components/pzem004t/pzem004t.h index 71fc1e70ad..42135f0fbd 100644 --- a/esphome/components/pzem004t/pzem004t.h +++ b/esphome/components/pzem004t/pzem004t.h @@ -6,7 +6,7 @@ namespace esphome::pzem004t { -class PZEM004T : public PollingComponent, public uart::UARTDevice { +class PZEM004T final : public PollingComponent, public uart::UARTDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_current_sensor(sensor::Sensor *current_sensor) { current_sensor_ = current_sensor; } diff --git a/esphome/components/pzemac/pzemac.h b/esphome/components/pzemac/pzemac.h index 264604fedc..a25a8cb631 100644 --- a/esphome/components/pzemac/pzemac.h +++ b/esphome/components/pzemac/pzemac.h @@ -11,7 +11,7 @@ namespace esphome::pzemac { template class ResetEnergyAction; -class PZEMAC : public PollingComponent, public modbus::ModbusDevice { +class PZEMAC final : public PollingComponent, public modbus::ModbusDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_current_sensor(sensor::Sensor *current_sensor) { current_sensor_ = current_sensor; } @@ -38,7 +38,7 @@ class PZEMAC : public PollingComponent, public modbus::ModbusDevice { void reset_energy_(); }; -template class ResetEnergyAction : public Action { +template class ResetEnergyAction final : public Action { public: ResetEnergyAction(PZEMAC *pzemac) : pzemac_(pzemac) {} diff --git a/esphome/components/pzemdc/pzemdc.h b/esphome/components/pzemdc/pzemdc.h index 6a7e840448..e398330cd3 100644 --- a/esphome/components/pzemdc/pzemdc.h +++ b/esphome/components/pzemdc/pzemdc.h @@ -9,7 +9,7 @@ namespace esphome::pzemdc { -class PZEMDC : public PollingComponent, public modbus::ModbusDevice { +class PZEMDC final : public PollingComponent, public modbus::ModbusDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_current_sensor(sensor::Sensor *current_sensor) { current_sensor_ = current_sensor; } @@ -31,7 +31,7 @@ class PZEMDC : public PollingComponent, public modbus::ModbusDevice { sensor::Sensor *energy_sensor_{nullptr}; }; -template class ResetEnergyAction : public Action { +template class ResetEnergyAction final : public Action { public: ResetEnergyAction(PZEMDC *pzemdc) : pzemdc_(pzemdc) {} diff --git a/esphome/components/qmc5883l/qmc5883l.h b/esphome/components/qmc5883l/qmc5883l.h index 6b8ffa0f40..faef423f8c 100644 --- a/esphome/components/qmc5883l/qmc5883l.h +++ b/esphome/components/qmc5883l/qmc5883l.h @@ -26,7 +26,7 @@ enum QMC5883LOversampling { QMC5883L_SAMPLING_64 = 0b11, }; -class QMC5883LComponent : public PollingComponent, public i2c::I2CDevice { +class QMC5883LComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/qmp6988/qmp6988.h b/esphome/components/qmp6988/qmp6988.h index 41759478b8..ffea32eb18 100644 --- a/esphome/components/qmp6988/qmp6988.h +++ b/esphome/components/qmp6988/qmp6988.h @@ -67,7 +67,7 @@ using qmp6988_data_t = struct Qmp6988Data { qmp6988_ik_data_t ik; }; -class QMP6988Component : public PollingComponent, public i2c::I2CDevice { +class QMP6988Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_pressure_sensor(sensor::Sensor *pressure_sensor) { this->pressure_sensor_ = pressure_sensor; } diff --git a/esphome/components/qr_code/qr_code.h b/esphome/components/qr_code/qr_code.h index ab4c587b6d..f8ca1660f6 100644 --- a/esphome/components/qr_code/qr_code.h +++ b/esphome/components/qr_code/qr_code.h @@ -13,7 +13,7 @@ class Display; } // namespace display namespace qr_code { -class QrCode : public Component { +class QrCode final : public Component { public: void draw(display::Display *buff, uint16_t x_offset, uint16_t y_offset, Color color, int scale); diff --git a/esphome/components/qspi_dbi/qspi_dbi.h b/esphome/components/qspi_dbi/qspi_dbi.h index fa77cc5f76..8a1bf0d4c2 100644 --- a/esphome/components/qspi_dbi/qspi_dbi.h +++ b/esphome/components/qspi_dbi/qspi_dbi.h @@ -53,9 +53,9 @@ enum Model { RM67162, }; -class QspiDbi : public display::DisplayBuffer, - public spi::SPIDevice { +class QspiDbi final : public display::DisplayBuffer, + public spi::SPIDevice { public: void set_model(const char *model) { this->model_ = model; } void update() override; diff --git a/esphome/components/qwiic_pir/qwiic_pir.h b/esphome/components/qwiic_pir/qwiic_pir.h index 339632a508..8d3b8fb321 100644 --- a/esphome/components/qwiic_pir/qwiic_pir.h +++ b/esphome/components/qwiic_pir/qwiic_pir.h @@ -29,7 +29,7 @@ enum DebounceMode { static const uint8_t QWIIC_PIR_DEVICE_ID = 0x72; -class QwiicPIRComponent : public Component, public i2c::I2CDevice, public binary_sensor::BinarySensor { +class QwiicPIRComponent final : public Component, public i2c::I2CDevice, public binary_sensor::BinarySensor { public: void setup() override; void loop() override; diff --git a/esphome/components/radon_eye_ble/radon_eye_listener.h b/esphome/components/radon_eye_ble/radon_eye_listener.h index ceca736e78..30e3ccc1ea 100644 --- a/esphome/components/radon_eye_ble/radon_eye_listener.h +++ b/esphome/components/radon_eye_ble/radon_eye_listener.h @@ -7,7 +7,7 @@ namespace esphome::radon_eye_ble { -class RadonEyeListener : public esp32_ble_tracker::ESPBTDeviceListener { +class RadonEyeListener final : public esp32_ble_tracker::ESPBTDeviceListener { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; }; diff --git a/esphome/components/radon_eye_rd200/radon_eye_rd200.h b/esphome/components/radon_eye_rd200/radon_eye_rd200.h index 48e075c2d6..401402a137 100644 --- a/esphome/components/radon_eye_rd200/radon_eye_rd200.h +++ b/esphome/components/radon_eye_rd200/radon_eye_rd200.h @@ -13,7 +13,7 @@ namespace esphome::radon_eye_rd200 { -class RadonEyeRD200 : public PollingComponent, public ble_client::BLEClientNode { +class RadonEyeRD200 final : public PollingComponent, public ble_client::BLEClientNode { public: RadonEyeRD200(); diff --git a/esphome/components/rc522/rc522.h b/esphome/components/rc522/rc522.h index 45473e04b0..fd3c819696 100644 --- a/esphome/components/rc522/rc522.h +++ b/esphome/components/rc522/rc522.h @@ -251,7 +251,7 @@ class RC522 : public PollingComponent { } error_code_{NONE}; }; -class RC522BinarySensor : public binary_sensor::BinarySensor { +class RC522BinarySensor final : public binary_sensor::BinarySensor { public: void set_uid(const std::vector &uid) { uid_ = uid; } @@ -269,7 +269,7 @@ class RC522BinarySensor : public binary_sensor::BinarySensor { bool found_{false}; }; -class RC522Trigger : public Trigger { +class RC522Trigger final : public Trigger { public: void process(std::vector &data); }; From 46cf052ec5b677e93152169682c115f5a9ec2e7d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:28:43 +1200 Subject: [PATCH 0585/1815] [config_validation] Fix multicast typo in error message (#17206) --- esphome/config_validation.py | 4 +--- tests/unit_tests/test_config_validation.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 0fdce85dc3..b77e22a6fb 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1494,9 +1494,7 @@ def ipv6address(value): def ipv4address_multi_broadcast(value): address = ipv4address(value) if not (address.is_multicast or (address == IPv4Address("255.255.255.255"))): - raise Invalid( - f"{value} is not a multicasst address nor local broadcast address" - ) + raise Invalid(f"{value} is not a multicast address nor local broadcast address") return address diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 2715f9c644..ea3a4ecb53 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1926,7 +1926,7 @@ def test_ipv4address_multi_broadcast_broadcast() -> None: def test_ipv4address_multi_broadcast_invalid() -> None: - with pytest.raises(Invalid, match="not a multicasst"): + with pytest.raises(Invalid, match="not a multicast"): cv.ipv4address_multi_broadcast("192.168.0.1") From 29a610573037682afab5cd67b3f2321094392d0b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:31:15 +1200 Subject: [PATCH 0586/1815] [ms8607] Mark configurable classes as final (#17147) --- esphome/components/ms8607/ms8607.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/ms8607/ms8607.h b/esphome/components/ms8607/ms8607.h index 8f9cc9cb88..f2c4d65f13 100644 --- a/esphome/components/ms8607/ms8607.h +++ b/esphome/components/ms8607/ms8607.h @@ -10,7 +10,7 @@ namespace esphome::ms8607 { Class for I2CDevice used to communicate with the Humidity sensor on the chip. See MS8607Component instead */ -class MS8607HumidityDevice : public i2c::I2CDevice { +class MS8607HumidityDevice final : public i2c::I2CDevice { public: uint8_t get_address() { return address_; } }; @@ -30,9 +30,9 @@ class MS8607HumidityDevice : public i2c::I2CDevice { - https://github.com/adafruit/Adafruit_MS8607 - https://github.com/sparkfun/SparkFun_PHT_MS8607_Arduino_Library */ -class MS8607Component : public PollingComponent, public i2c::I2CDevice { +class MS8607Component final : public PollingComponent, public i2c::I2CDevice { public: - virtual ~MS8607Component() = default; + ~MS8607Component() = default; void setup() override; void update() override; void dump_config() override; From 4f70f6b2a6e5f4aef59eb2ee179e1aef199abd71 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:36:36 +1000 Subject: [PATCH 0587/1815] [mipi][mipi_spi] Swap native dimensions for swap_xy hardware transform (#17201) Co-authored-by: Claude Opus 4.8 --- esphome/components/mipi/__init__.py | 36 ++-- esphome/components/mipi_spi/display.py | 16 +- .../mipi_spi/test_padding_and_offsets.py | 167 +++++++++++++++++- 3 files changed, 191 insertions(+), 28 deletions(-) diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 2244a316b7..1d6c8277e8 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -393,6 +393,16 @@ class DriverChip: return {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY} return {CONF_MIRROR_X, CONF_MIRROR_Y} + def has_hardware_transform(self, config) -> bool: + """ + Check if the model supports hardware transforms for the given configuration. + """ + return config.get(CONF_TRANSFORM) != CONF_DISABLED and self.transforms == { + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_SWAP_XY, + } + def option(self, name, fallback=False) -> cv.Optional: return cv.Optional(name, default=self.get_default(name, fallback)) @@ -423,10 +433,15 @@ class DriverChip: :return: A tuple (width, height, offset_width, offset_height, pad_width, pad_height). """ + transform = self.get_transform(config) if CONF_DIMENSIONS in config: # Explicit dimensions, just use as is dimensions = config[CONF_DIMENSIONS] if isinstance(dimensions, dict): + native_width = self.get_default(CONF_NATIVE_WIDTH, 0) + native_height = self.get_default(CONF_NATIVE_HEIGHT, 0) + if transform.get(CONF_SWAP_XY) is True: + native_width, native_height = native_height, native_width width = dimensions[CONF_WIDTH] height = dimensions[CONF_HEIGHT] offset_width = dimensions[CONF_OFFSET_WIDTH] @@ -434,23 +449,19 @@ class DriverChip: if CONF_PAD_WIDTH in dimensions: pad_width = dimensions[CONF_PAD_WIDTH] native_width = width + offset_width + pad_width + elif native_width == 0: + pad_width = 0 + native_width = width + offset_width else: - native_width = self.get_default(CONF_NATIVE_WIDTH, 0) - if native_width == 0: - pad_width = 0 - native_width = width + offset_width - else: - pad_width = native_width - width - offset_width + pad_width = native_width - width - offset_width if CONF_PAD_HEIGHT in dimensions: pad_height = dimensions[CONF_PAD_HEIGHT] native_height = height + offset_height + pad_height + elif native_height == 0: + pad_height = 0 + native_height = height + offset_height else: - native_height = self.get_default(CONF_NATIVE_HEIGHT, 0) - if native_height == 0: - pad_height = 0 - native_height = height + offset_height - else: - pad_height = native_height - height - offset_height + pad_height = native_height - height - offset_height if ( pad_width + offset_width >= native_width or pad_height + offset_height >= native_height @@ -466,7 +477,6 @@ class DriverChip: return width, height, 0, 0, 0, 0 # Default dimensions, use model defaults - transform = self.get_transform(config) width = self.get_default(CONF_WIDTH) height = self.get_default(CONF_HEIGHT) diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 0231d12529..4162459058 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -295,13 +295,7 @@ def customise_schema(config): raise cv.Invalid(f"DC pin is required in {bus_mode} mode") denominator(config) model = MODELS[config[CONF_MODEL]] - has_hardware_transform = config.get( - CONF_TRANSFORM - ) != CONF_DISABLED and model.transforms == { - CONF_MIRROR_X, - CONF_MIRROR_Y, - CONF_SWAP_XY, - } + has_hardware_transform = model.has_hardware_transform(config) width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( model.get_dimensions(config, not has_hardware_transform) ) @@ -366,13 +360,7 @@ def get_instance(config): :return: type, template arguments """ model = MODELS[config[CONF_MODEL]] - has_hardware_transform = config.get( - CONF_TRANSFORM - ) != CONF_DISABLED and model.transforms == { - CONF_MIRROR_X, - CONF_MIRROR_Y, - CONF_SWAP_XY, - } + has_hardware_transform = model.has_hardware_transform(config) width, height, offset_width, offset_height, pad_width, pad_height = ( model.get_dimensions(config, not has_hardware_transform) ) diff --git a/tests/component_tests/mipi_spi/test_padding_and_offsets.py b/tests/component_tests/mipi_spi/test_padding_and_offsets.py index 82adf88b7e..7ae6f0e61f 100644 --- a/tests/component_tests/mipi_spi/test_padding_and_offsets.py +++ b/tests/component_tests/mipi_spi/test_padding_and_offsets.py @@ -13,6 +13,16 @@ from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32S3, ) +from esphome.components.mipi import ( + CONF_DIMENSIONS, + CONF_HEIGHT, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_OFFSET_HEIGHT, + CONF_OFFSET_WIDTH, + CONF_SWAP_XY, + CONF_WIDTH, +) from esphome.components.mipi_spi.display import ( CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA, @@ -20,7 +30,13 @@ from esphome.components.mipi_spi.display import ( get_instance, ) from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE -from esphome.const import CONF_CS_PIN, CONF_DC_PIN, PlatformFramework +from esphome.const import ( + CONF_CS_PIN, + CONF_DC_PIN, + CONF_DISABLED, + CONF_TRANSFORM, + PlatformFramework, +) from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable @@ -432,3 +448,152 @@ class TestUserConfiguredPadding: assert config["dimensions"]["width"] == 240 assert config["dimensions"]["height"] == 240 assert config["dimensions"]["pad_height"] == 16 + + +class TestHasHardwareTransform: + """Test DriverChip.has_hardware_transform().""" + + def test_full_transform_model_without_transform_key(self) -> None: + """A model supporting swap_xy uses a hardware transform by default.""" + model = MODELS["ST7789V"] + assert model.has_hardware_transform({}) is True + + def test_full_transform_model_with_transform_dict(self) -> None: + """A configured (non-disabled) transform still uses the hardware path.""" + model = MODELS["ST7789V"] + assert ( + model.has_hardware_transform({CONF_TRANSFORM: {CONF_SWAP_XY: True}}) is True + ) + + def test_full_transform_model_with_transform_disabled(self) -> None: + """Disabling the transform falls back to software transforms.""" + model = MODELS["ST7789V"] + assert model.has_hardware_transform({CONF_TRANSFORM: CONF_DISABLED}) is False + + def test_model_without_swap_xy_support(self) -> None: + """Models that cannot swap axes never use a hardware transform.""" + # AXS15231 only supports mirror_x/mirror_y, not swap_xy. + model = MODELS["AXS15231"] + assert model.transforms == {CONF_MIRROR_X, CONF_MIRROR_Y} + assert model.has_hardware_transform({}) is False + + +class TestSwapXYNativeDimensions: + """Test that native dimensions are swapped when a swap_xy transform is active. + + When explicit dimensions are given in the swapped (rotated) orientation and the + model applies a hardware swap_xy transform, the model's native_width/native_height + defaults must be swapped to match, otherwise padding is computed against the wrong + axis and validation fails. + """ + + def test_explicit_swapped_dimensions_with_swap_xy_transform( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Explicit landscape dimensions on a portrait-native model with swap_xy.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # ST7789V is natively 240x320 (portrait). Provide landscape dimensions + # together with a swap_xy transform. + model = MODELS["ST7789V"] + assert model.get_default("native_width") == 240 + assert model.get_default("native_height") == 320 + + config = { + "model": "ST7789V", + CONF_DIMENSIONS: { + CONF_WIDTH: 320, + CONF_HEIGHT: 240, + CONF_OFFSET_WIDTH: 0, + CONF_OFFSET_HEIGHT: 0, + }, + CONF_TRANSFORM: { + CONF_SWAP_XY: True, + CONF_MIRROR_X: False, + CONF_MIRROR_Y: False, + }, + } + + # swap=False because the buffer is laid out in the requested orientation. + width, height, offset_w, offset_h, pad_w, pad_h = model.get_dimensions( + config, swap=False + ) + # Native dims are swapped to 320x240, so padding works out to zero rather + # than going negative (which previously raised "Invalid offsets"). + assert (width, height) == (320, 240) + assert (offset_w, offset_h) == (0, 0) + assert (pad_w, pad_h) == (0, 0) + + def test_explicit_dimensions_without_swap_keeps_native_orientation( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Without swap_xy the native dimensions keep their original orientation.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + model = MODELS["ST7789V"] + config = { + "model": "ST7789V", + CONF_DIMENSIONS: { + CONF_WIDTH: 240, + CONF_HEIGHT: 320, + CONF_OFFSET_WIDTH: 0, + CONF_OFFSET_HEIGHT: 0, + }, + CONF_TRANSFORM: { + CONF_SWAP_XY: False, + CONF_MIRROR_X: False, + CONF_MIRROR_Y: False, + }, + } + + width, height, offset_w, offset_h, pad_w, pad_h = model.get_dimensions( + config, swap=False + ) + assert (width, height) == (240, 320) + assert (offset_w, offset_h) == (0, 0) + assert (pad_w, pad_h) == (0, 0) + + def test_swapped_native_dimensions_compute_padding( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Padding is derived from the swapped native size when swap_xy is active.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # ILI9341 is natively 240x320. Request a 300x240 area in landscape; the + # swapped native size is 320x240, leaving 20px of horizontal padding. + model = MODELS["ILI9341"] + assert model.get_default("native_width") == 240 + assert model.get_default("native_height") == 320 + + config = { + "model": "ILI9341", + CONF_DIMENSIONS: { + CONF_WIDTH: 300, + CONF_HEIGHT: 240, + CONF_OFFSET_WIDTH: 0, + CONF_OFFSET_HEIGHT: 0, + }, + CONF_TRANSFORM: { + CONF_SWAP_XY: True, + CONF_MIRROR_X: False, + CONF_MIRROR_Y: False, + }, + } + + width, height, _, _, pad_w, pad_h = model.get_dimensions(config, swap=False) + assert (width, height) == (300, 240) + # native_width swapped to 320 -> pad_width = 320 - 300 - 0 = 20 + assert pad_w == 20 + assert pad_h == 0 From 18c7f604108bfa7aa49afa905144e5e9c6f2f056 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:19:05 -0400 Subject: [PATCH 0588/1815] [uart] Validate fixed UART settings at config time for fixed-baud components (#17207) --- esphome/components/bl0940/sensor.py | 11 +++++++++++ esphome/components/midea/climate.py | 5 +++++ esphome/components/pzem004t/sensor.py | 4 ++++ esphome/components/rdm6300/__init__.py | 4 ++++ esphome/components/rf_bridge/__init__.py | 10 ++++++++++ esphome/components/rf_bridge/rf_bridge.cpp | 5 +---- esphome/components/sds011/sds011.cpp | 1 - esphome/components/sds011/sensor.py | 18 ++++++++++++++++++ esphome/components/senseair/senseair.cpp | 1 - esphome/components/senseair/sensor.py | 10 ++++++++++ esphome/components/shelly_dimmer/light.py | 4 ++++ esphome/components/sm300d2/sensor.py | 4 ++++ esphome/components/sm300d2/sm300d2.cpp | 1 - tests/components/bl0940/test.esp32-idf.yaml | 2 +- tests/components/bl0940/test.esp8266-ard.yaml | 2 +- tests/components/bl0940/test.rp2040-ard.yaml | 2 +- tests/components/rf_bridge/test.esp32-idf.yaml | 2 +- .../components/rf_bridge/test.esp8266-ard.yaml | 2 +- .../components/rf_bridge/test.rp2040-ard.yaml | 2 +- 19 files changed, 77 insertions(+), 13 deletions(-) diff --git a/esphome/components/bl0940/sensor.py b/esphome/components/bl0940/sensor.py index 992064943b..96445d5c38 100644 --- a/esphome/components/bl0940/sensor.py +++ b/esphome/components/bl0940/sensor.py @@ -211,6 +211,17 @@ CONFIG_SCHEMA = ( .add_extra(set_reference_values) ) +# BL0940 datasheet: 4800 baud, 8 data bits, no parity (stop bits are 1.5 -- not +# representable in the uart schema, so it isn't asserted). +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "bl0940", + baud_rate=4800, + data_bits=8, + parity="NONE", + require_rx=True, + require_tx=True, +) + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/midea/climate.py b/esphome/components/midea/climate.py index c954b45033..4a75464b90 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -260,6 +260,11 @@ async def power_inv_to_code(var, config, args): pass +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "midea", baud_rate=9600, require_rx=True, require_tx=True +) + + async def to_code(config): var = await climate.new_climate(config) await cg.register_component(var, config) diff --git a/esphome/components/pzem004t/sensor.py b/esphome/components/pzem004t/sensor.py index 51b1ab2d80..7e55fd9e7e 100644 --- a/esphome/components/pzem004t/sensor.py +++ b/esphome/components/pzem004t/sensor.py @@ -58,6 +58,10 @@ CONFIG_SCHEMA = ( .extend(uart.UART_DEVICE_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "pzem004t", baud_rate=9600, require_rx=True, require_tx=True +) + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/rdm6300/__init__.py b/esphome/components/rdm6300/__init__.py index cbc54ad02b..a65213d576 100644 --- a/esphome/components/rdm6300/__init__.py +++ b/esphome/components/rdm6300/__init__.py @@ -29,6 +29,10 @@ CONFIG_SCHEMA = ( .extend(uart.UART_DEVICE_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "rdm6300", baud_rate=9600, require_rx=True +) + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/rf_bridge/__init__.py b/esphome/components/rf_bridge/__init__.py index 9ca47fe862..9863379b79 100644 --- a/esphome/components/rf_bridge/__init__.py +++ b/esphome/components/rf_bridge/__init__.py @@ -80,6 +80,16 @@ _CALLBACK_AUTOMATIONS = ( ), ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "rf_bridge", + baud_rate=19200, + require_rx=True, + require_tx=True, + data_bits=8, + parity="NONE", + stop_bits=1, +) + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/rf_bridge/rf_bridge.cpp b/esphome/components/rf_bridge/rf_bridge.cpp index cec32e0406..549cce72df 100644 --- a/esphome/components/rf_bridge/rf_bridge.cpp +++ b/esphome/components/rf_bridge/rf_bridge.cpp @@ -195,10 +195,7 @@ void RFBridgeComponent::learn() { this->flush(); } -void RFBridgeComponent::dump_config() { - ESP_LOGCONFIG(TAG, "RF_Bridge:"); - this->check_uart_settings(19200); -} +void RFBridgeComponent::dump_config() { ESP_LOGCONFIG(TAG, "RF_Bridge:"); } void RFBridgeComponent::start_advanced_sniffing() { ESP_LOGI(TAG, "Advanced Sniffing on"); diff --git a/esphome/components/sds011/sds011.cpp b/esphome/components/sds011/sds011.cpp index b1f89f18bf..1c222e5e80 100644 --- a/esphome/components/sds011/sds011.cpp +++ b/esphome/components/sds011/sds011.cpp @@ -73,7 +73,6 @@ void SDS011Component::dump_config() { this->update_interval_min_, ONOFF(this->rx_mode_only_)); LOG_SENSOR(" ", "PM2.5", this->pm_2_5_sensor_); LOG_SENSOR(" ", "PM10.0", this->pm_10_0_sensor_); - this->check_uart_settings(9600); } void SDS011Component::loop() { diff --git a/esphome/components/sds011/sensor.py b/esphome/components/sds011/sensor.py index 76abc70bb7..2d7b6b07e5 100644 --- a/esphome/components/sds011/sensor.py +++ b/esphome/components/sds011/sensor.py @@ -63,6 +63,24 @@ CONFIG_SCHEMA = cv.All( ) +def _final_validate(config): + # In the default mode setup() writes config commands, so tx is required; + # rx_only mode never writes, so tx is optional. + uart.final_validate_device_schema( + "sds011", + baud_rate=9600, + require_rx=True, + require_tx=not config.get(CONF_RX_ONLY, False), + data_bits=8, + parity="NONE", + stop_bits=1, + )(config) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): # Pop update_interval before register_component so it doesn't generate # a set_update_interval call — sds011 handles this via set_update_interval_min diff --git a/esphome/components/senseair/senseair.cpp b/esphome/components/senseair/senseair.cpp index 8ed9fbb53b..0e8e4cef97 100644 --- a/esphome/components/senseair/senseair.cpp +++ b/esphome/components/senseair/senseair.cpp @@ -146,7 +146,6 @@ bool SenseAirComponent::senseair_write_command_(const uint8_t *command, uint8_t void SenseAirComponent::dump_config() { ESP_LOGCONFIG(TAG, "SenseAir:"); LOG_SENSOR(" ", "CO2", this->co2_sensor_); - this->check_uart_settings(9600); } } // namespace esphome::senseair diff --git a/esphome/components/senseair/sensor.py b/esphome/components/senseair/sensor.py index c5bef76741..277648137a 100644 --- a/esphome/components/senseair/sensor.py +++ b/esphome/components/senseair/sensor.py @@ -51,6 +51,16 @@ CONFIG_SCHEMA = ( .extend(uart.UART_DEVICE_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "senseair", + baud_rate=9600, + require_rx=True, + require_tx=True, + data_bits=8, + parity="NONE", + stop_bits=1, +) + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/shelly_dimmer/light.py b/esphome/components/shelly_dimmer/light.py index ddf7fa161b..f2ab5a4bc1 100644 --- a/esphome/components/shelly_dimmer/light.py +++ b/esphome/components/shelly_dimmer/light.py @@ -186,6 +186,10 @@ CONFIG_SCHEMA = ( .extend(uart.UART_DEVICE_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "shelly_dimmer", baud_rate=115200, require_rx=True, require_tx=True +) + async def to_code(config): fw_hex = get_firmware(config[CONF_FIRMWARE]) diff --git a/esphome/components/sm300d2/sensor.py b/esphome/components/sm300d2/sensor.py index 60c9ccc40d..29e0cfe9b1 100644 --- a/esphome/components/sm300d2/sensor.py +++ b/esphome/components/sm300d2/sensor.py @@ -88,6 +88,10 @@ CONFIG_SCHEMA = cv.All( .extend(uart.UART_DEVICE_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "sm300d2", baud_rate=9600, require_rx=True, data_bits=8, parity="NONE", stop_bits=1 +) + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/sm300d2/sm300d2.cpp b/esphome/components/sm300d2/sm300d2.cpp index 391cc0ac11..882959a454 100644 --- a/esphome/components/sm300d2/sm300d2.cpp +++ b/esphome/components/sm300d2/sm300d2.cpp @@ -100,7 +100,6 @@ void SM300D2Sensor::dump_config() { LOG_SENSOR(" ", "PM10", this->pm_10_0_sensor_); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); - this->check_uart_settings(9600); } } // namespace esphome::sm300d2 diff --git a/tests/components/bl0940/test.esp32-idf.yaml b/tests/components/bl0940/test.esp32-idf.yaml index 64baa4ec9d..e74af834d4 100644 --- a/tests/components/bl0940/test.esp32-idf.yaml +++ b/tests/components/bl0940/test.esp32-idf.yaml @@ -3,6 +3,6 @@ substitutions: rx_pin: GPIO14 packages: - uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + uart_4800: !include ../../test_build_components/common/uart_4800/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/bl0940/test.esp8266-ard.yaml b/tests/components/bl0940/test.esp8266-ard.yaml index 89ca3ab5ae..f614b0a395 100644 --- a/tests/components/bl0940/test.esp8266-ard.yaml +++ b/tests/components/bl0940/test.esp8266-ard.yaml @@ -3,6 +3,6 @@ substitutions: rx_pin: GPIO3 packages: - uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + uart_4800: !include ../../test_build_components/common/uart_4800/esp8266-ard.yaml <<: !include common.yaml diff --git a/tests/components/bl0940/test.rp2040-ard.yaml b/tests/components/bl0940/test.rp2040-ard.yaml index b28f2b5e05..c8e2e3b55a 100644 --- a/tests/components/bl0940/test.rp2040-ard.yaml +++ b/tests/components/bl0940/test.rp2040-ard.yaml @@ -3,6 +3,6 @@ substitutions: rx_pin: GPIO5 packages: - uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + uart_4800: !include ../../test_build_components/common/uart_4800/rp2040-ard.yaml <<: !include common.yaml diff --git a/tests/components/rf_bridge/test.esp32-idf.yaml b/tests/components/rf_bridge/test.esp32-idf.yaml index 2d29656c94..76222997a8 100644 --- a/tests/components/rf_bridge/test.esp32-idf.yaml +++ b/tests/components/rf_bridge/test.esp32-idf.yaml @@ -1,4 +1,4 @@ packages: - uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + uart_19200: !include ../../test_build_components/common/uart_19200/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/rf_bridge/test.esp8266-ard.yaml b/tests/components/rf_bridge/test.esp8266-ard.yaml index 5a05efa259..aaedec5aaa 100644 --- a/tests/components/rf_bridge/test.esp8266-ard.yaml +++ b/tests/components/rf_bridge/test.esp8266-ard.yaml @@ -1,4 +1,4 @@ packages: - uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + uart_19200: !include ../../test_build_components/common/uart_19200/esp8266-ard.yaml <<: !include common.yaml diff --git a/tests/components/rf_bridge/test.rp2040-ard.yaml b/tests/components/rf_bridge/test.rp2040-ard.yaml index f1df2daf83..ed0cd431e3 100644 --- a/tests/components/rf_bridge/test.rp2040-ard.yaml +++ b/tests/components/rf_bridge/test.rp2040-ard.yaml @@ -1,4 +1,4 @@ packages: - uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + uart_19200: !include ../../test_build_components/common/uart_19200/rp2040-ard.yaml <<: !include common.yaml From 1d5490fd910b18565842e801f612b73de6bd60e2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:38:34 -0400 Subject: [PATCH 0589/1815] [modbus] Only apply turnaround delay after broadcasts (#17209) --- esphome/components/modbus/modbus.cpp | 16 ++++-- esphome/components/modbus/modbus.h | 1 + .../fixtures/uart_mock_modbus_broadcast.yaml | 56 +++++++++++++++++++ tests/integration/test_uart_mock_modbus.py | 25 +++++++++ 4 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 tests/integration/fixtures/uart_mock_modbus_broadcast.yaml diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 136fc73db6..c9ba2e837e 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -92,10 +92,14 @@ int32_t Modbus::tx_delay_remaining() { int32_t ModbusClientHub::tx_delay_remaining() { const uint32_t now = millis(); - return std::max({(int32_t) 0, - (int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ + this->turnaround_delay_ms_ - - (now - this->last_send_)), - (int32_t) (this->frame_delay_ms_ + this->turnaround_delay_ms_ - (now - this->last_modbus_byte_))}); + // Turnaround delay only applies after a broadcast: no response is expected, so we must give listening devices + // quiet time to process it before the next request. For normal unicast request/response the received reply already + // provides the inter-frame timing, so adding turnaround there just throttles throughput. + const uint16_t turnaround = this->last_send_was_broadcast_ ? this->turnaround_delay_ms_ : 0; + return std::max( + {(int32_t) 0, + (int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ + turnaround - (now - this->last_send_)), + (int32_t) (this->frame_delay_ms_ + turnaround - (now - this->last_modbus_byte_))}); } bool Modbus::tx_blocked() { @@ -396,6 +400,7 @@ bool Modbus::send_frame_(const ModbusFrame &frame) { format_hex_pretty_to(hex_buf, frame.data.get(), frame.size), now - this->last_send_, now - this->last_modbus_byte_); this->last_send_ = now; + this->last_send_was_broadcast_ = frame.size > 0 && frame.data[0] == 0; return true; } @@ -411,7 +416,8 @@ void ModbusClientHub::send_next_frame_() { ModbusDeviceCommand &command = this->tx_buffer_.front(); if (this->send_frame_(command.frame)) { - this->waiting_for_response_ = std::move(command); + if (!this->last_send_was_broadcast_) + this->waiting_for_response_ = std::move(command); } else { if (command.device) command.device->on_modbus_not_sent(); diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 86337442c6..da0db13a07 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -63,6 +63,7 @@ class Modbus : public uart::UARTDevice, public Component { uint32_t last_receive_check_{0}; uint32_t last_send_{0}; uint32_t last_send_tx_offset_{0}; + bool last_send_was_broadcast_{false}; uint16_t frame_delay_ms_{5}; uint16_t long_rx_buffer_delay_ms_{0}; diff --git a/tests/integration/fixtures/uart_mock_modbus_broadcast.yaml b/tests/integration/fixtures/uart_mock_modbus_broadcast.yaml new file mode 100644 index 0000000000..a5ce02b342 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_broadcast.yaml @@ -0,0 +1,56 @@ +esphome: + name: uart-mock-modbus-bcast + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +# No on_tx injection: a broadcast (address 0) gets no reply on a real bus. +uart_mock: + - id: virtual_uart + baud_rate: 9600 + auto_start: true + debug: + +modbus: + - uart_id: virtual_uart + id: virtual_modbus + role: client + send_wait_time: 200ms + turnaround_time: 10ms + +modbus_controller: + - address: 0 + modbus_id: virtual_modbus + update_interval: 60s + id: modbus_controller_bcast + +number: + - platform: modbus_controller + modbus_controller_id: modbus_controller_bcast + id: bcast_write + name: "bcast_write" + address: 0x01 + register_type: holding + value_type: U_WORD + min_value: 0 + max_value: 65535 + +interval: + - interval: 400ms + then: + - number.set: + id: bcast_write + value: 42 diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 2c437341c6..385707d849 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -330,3 +330,28 @@ async def test_uart_mock_modbus_server_controller_multiple( await tracker.setup_and_start_scenario(client) await tracker.await_all(futures) _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_broadcast( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that broadcast writes (address 0) don't wait for a response. + + A controller at address 0 sends broadcast writes that get no reply. The + client must not arm the response timeout for them: otherwise every write + blocks for send_wait_time and logs a spurious "no response from 0" warning. + """ + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected(), + ): + # Several broadcast writes fire on the 400ms interval; send_wait_time is + # 200ms, so the old behaviour would have warned on each one by now. + await asyncio.sleep(3.0) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) From 6f36ce6429f690811e9d8b872eadc005cf521251 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Thu, 25 Jun 2026 12:35:15 -0400 Subject: [PATCH 0590/1815] [openthread] Provide action to control poll_period when device MTD (#11766) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 --- esphome/components/openthread/__init__.py | 37 ++++++++++- esphome/components/openthread/automation.cpp | 37 +++++++++++ esphome/components/openthread/automation.h | 61 +++++++++++++++++++ esphome/components/openthread/openthread.cpp | 29 +++++++++ esphome/components/openthread/openthread.h | 13 ++++ .../components/openthread/openthread_esp.cpp | 27 +------- tests/components/openthread/common.yaml | 2 + .../openthread/test-tlv.esp32-c6-idf.yaml | 20 ++++++ .../openthread/test.esp32-c6-idf.yaml | 13 +--- 9 files changed, 200 insertions(+), 39 deletions(-) create mode 100644 esphome/components/openthread/automation.cpp create mode 100644 esphome/components/openthread/automation.h create mode 100644 tests/components/openthread/common.yaml create mode 100644 tests/components/openthread/test-tlv.esp32-c6-idf.yaml diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 2dc8a783df..b54fe2b218 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -1,3 +1,4 @@ +from esphome import automation import esphome.codegen as cg from esphome.components.esp32 import ( VARIANT_ESP32C5, @@ -226,11 +227,11 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_FORCE_DATASET): cv.boolean, cv.Optional(CONF_TLV): cv.All(cv.string_strict, _validate_tlv_hex), cv.Optional(CONF_USE_ADDRESS): cv.string_strict, - cv.Optional(CONF_POLL_PERIOD): cv.positive_time_period_milliseconds, cv.Optional(CONF_OUTPUT_POWER): cv.All( cv.decibel, _validate_txpower, ), + cv.Optional(CONF_POLL_PERIOD): cv.positive_time_period_milliseconds, } ).extend(_CONNECTION_SCHEMA), cv.has_exactly_one_key(CONF_NETWORK_KEY, CONF_TLV), @@ -309,3 +310,37 @@ async def to_code(config): ) zephyr_add_prj_conf(f"OPENTHREAD_{config.get(CONF_DEVICE_TYPE)}", True) zephyr_add_prj_conf("MAIN_STACK_SIZE", 4096) + + +# Actions +OpenThreadComponentPollPeriodAction = openthread_ns.class_( + "OpenThreadComponentPollPeriodAction", + automation.Action, + cg.Parented.template(OpenThreadComponent), +) + +POLL_PERIOD_ACTION_SCHEMA = automation.maybe_conf( + CONF_POLL_PERIOD, + cv.Schema( + { + cv.GenerateID(): cv.use_id(OpenThreadComponent), + cv.Required(CONF_POLL_PERIOD): cv.templatable( + cv.positive_time_period_milliseconds + ), + } + ), +) + + +@automation.register_action( + "openthread.set_poll_period", + OpenThreadComponentPollPeriodAction, + POLL_PERIOD_ACTION_SCHEMA, + synchronous=True, +) +async def openthread_poll_period_action_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, paren) + template_ = await cg.templatable(config[CONF_POLL_PERIOD], args, cg.uint32) + cg.add(var.set_poll_period(template_)) + return var diff --git a/esphome/components/openthread/automation.cpp b/esphome/components/openthread/automation.cpp new file mode 100644 index 0000000000..770bf124c5 --- /dev/null +++ b/esphome/components/openthread/automation.cpp @@ -0,0 +1,37 @@ +#include "esphome/core/defines.h" + +#ifdef USE_OPENTHREAD + +#include "automation.h" +#include "esphome/core/log.h" + +namespace esphome::openthread { + +static const char *const TAG = "openthread.automation"; + +void OpenThreadComponentBaseAction::warn_ftd_no_op_() { + ESP_LOGW(TAG, "OpenThread action has no effect on FTD devices (MTD only)"); +} + +void OpenThreadComponentBaseAction::lock_and_apply_() { + if (this->parent_->is_ready()) { + if (auto lock = InstanceLock::try_acquire(LOCK_ACQUIRE_TIMEOUT_MS); lock) { + if (auto *instance = lock.get_instance(); instance != nullptr) { + this->apply_locked(instance); + } + } else { + ESP_LOGW(TAG, "Failed to acquire lock in action"); + } + } else { + // Action may trigger early before setup, e.g. due to enabled "restore mode". + // Trying to acquire lock would fail! + // + // But default component values already have been overwritten. + // It is sufficient to let component apply those later during setup. + ESP_LOGD(TAG, "Not (yet) ready to apply"); + } +} + +} // namespace esphome::openthread + +#endif diff --git a/esphome/components/openthread/automation.h b/esphome/components/openthread/automation.h new file mode 100644 index 0000000000..3706499fda --- /dev/null +++ b/esphome/components/openthread/automation.h @@ -0,0 +1,61 @@ +#pragma once +#include "esphome/core/defines.h" +#ifdef USE_OPENTHREAD +#include "openthread.h" + +#include "esphome/core/automation.h" +#include "esphome/core/helpers.h" + +namespace esphome::openthread { + +/** Base class allowing to fetch OpenThread lock from parent component + * while applying action + * + * - Nontemplate aspects belong here to avoid template bloat. + * - Subclasses implement virtual action method that is called under lock. + * - Seal leaf subclasses via @a final to support devirtualization. + */ +class OpenThreadComponentBaseAction : public Parented { + public: + // Enforce ctor with parent argument (not without args) + explicit OpenThreadComponentBaseAction(OpenThreadComponent *ot) : Parented(ot) {} + + protected: + /** Handler to implement in subclass for applying action parts that need lock */ + virtual void apply_locked(otInstance *instance) = 0; + + /** Fetch OT lock and then call @a apply_locked */ + void lock_and_apply_(); + + /** Log a warning that this action has no effect on FTD devices */ + void warn_ftd_no_op_(); + + /** Timeout (ms) for acquiring OT lock */ + static constexpr uint32_t LOCK_ACQUIRE_TIMEOUT_MS = 100; +}; + +/** Action to set single poll period parameter */ +template +class OpenThreadComponentPollPeriodAction final : public Action, public OpenThreadComponentBaseAction { + TEMPLATABLE_VALUE(uint32_t, poll_period) + + public: + /* Passthrough ctor */ + using OpenThreadComponentBaseAction::OpenThreadComponentBaseAction; + + protected: + void play(const Ts &...x) override { +#if CONFIG_OPENTHREAD_MTD + this->parent_->set_poll_period(this->poll_period_.value(x...)); + + this->lock_and_apply_(); +#else + this->warn_ftd_no_op_(); +#endif + } + + void apply_locked(otInstance *instance) override { this->parent_->apply_linkmode_(instance); } +}; + +} // namespace esphome::openthread +#endif diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index 102424c62e..8bfc16b2e0 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -266,5 +266,34 @@ void OpenThreadComponent::on_factory_reset(std::function callback) { ESP_LOGD(TAG, "Waiting on Confirmation Removal SRP Host and Services"); } +void OpenThreadComponent::apply_linkmode_(otInstance *instance) { + otLinkModeConfig link_mode_config{}; +#if CONFIG_OPENTHREAD_FTD + link_mode_config.mRxOnWhenIdle = true; + link_mode_config.mDeviceType = true; + link_mode_config.mNetworkData = true; +#elif CONFIG_OPENTHREAD_MTD + if (this->poll_period_ > 0) { + if (otLinkSetPollPeriod(instance, this->poll_period_) != OT_ERROR_NONE) { + ESP_LOGE(TAG, "Failed to set pollperiod"); + } + ESP_LOGD(TAG, "Link Polling Period: %" PRIu32, otLinkGetPollPeriod(instance)); + } + link_mode_config.mRxOnWhenIdle = this->poll_period_ == 0; + link_mode_config.mDeviceType = false; + link_mode_config.mNetworkData = false; +#endif + + if (otThreadSetLinkMode(instance, link_mode_config) != OT_ERROR_NONE) { + ESP_LOGE(TAG, "Failed to set linkmode"); + } +#ifdef ESPHOME_LOG_HAS_DEBUG // Fetch link mode from OT only when DEBUG + link_mode_config = otThreadGetLinkMode(instance); + ESP_LOGD(TAG, "Link Mode Device Type: %s, Network Data: %s, RX On When Idle: %s", + TRUEFALSE(link_mode_config.mDeviceType), TRUEFALSE(link_mode_config.mNetworkData), + TRUEFALSE(link_mode_config.mRxOnWhenIdle)); +#endif +} + } // namespace esphome::openthread #endif diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index 488aad1166..a96941325c 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -19,6 +19,8 @@ namespace esphome::openthread { class InstanceLock; +template class OpenThreadComponentPollPeriodAction; + class OpenThreadComponent final : public Component { public: OpenThreadComponent(); @@ -41,12 +43,23 @@ class OpenThreadComponent final : public Component { void set_use_address(const char *use_address) { this->use_address_ = use_address; } #if CONFIG_OPENTHREAD_MTD void set_poll_period(uint32_t poll_period) { this->poll_period_ = poll_period; } + uint32_t get_poll_period() const { return this->poll_period_; } #endif void set_output_power(int8_t output_power) { this->output_power_ = output_power; } void set_connected(bool connected) { this->connected_ = connected; } static void on_state_changed(otChangedFlags flags, void *context); protected: + // Actions re-apply link mode under the OT lock; allow them to call apply_linkmode_() + // without exposing this lock-sensitive, raw-instance method on the public API. + template friend class OpenThreadComponentPollPeriodAction; + + /** Apply Link Mode settings (incl poll period). + * Callers running outside the OpenThread task must hold InstanceLock. + * ot_main() runs on the OpenThread task itself and must not acquire the lock. + */ + void apply_linkmode_(otInstance *instance); + std::optional get_omr_address_(InstanceLock &lock); otInstance *get_openthread_instance_(); int openthread_stop_(); diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index 6edaa98524..4f6e618f49 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -111,32 +111,7 @@ void OpenThreadComponent::ot_main() { ESP_LOGD(TAG, "Thread Version: %" PRIu16, otThreadGetVersion()); - otLinkModeConfig link_mode_config{}; -#if CONFIG_OPENTHREAD_FTD - link_mode_config.mRxOnWhenIdle = true; - link_mode_config.mDeviceType = true; - link_mode_config.mNetworkData = true; -#elif CONFIG_OPENTHREAD_MTD - if (this->poll_period_ > 0) { - if (otLinkSetPollPeriod(instance, this->poll_period_) != OT_ERROR_NONE) { - ESP_LOGE(TAG, "Failed to set pollperiod"); - } - ESP_LOGD(TAG, "Link Polling Period: %" PRIu32, otLinkGetPollPeriod(instance)); - } - link_mode_config.mRxOnWhenIdle = this->poll_period_ == 0; - link_mode_config.mDeviceType = false; - link_mode_config.mNetworkData = false; -#endif - - if (otThreadSetLinkMode(instance, link_mode_config) != OT_ERROR_NONE) { - ESP_LOGE(TAG, "Failed to set linkmode"); - } -#ifdef ESPHOME_LOG_HAS_DEBUG // Fetch link mode from OT only when DEBUG - link_mode_config = otThreadGetLinkMode(instance); - ESP_LOGD(TAG, "Link Mode Device Type: %s, Network Data: %s, RX On When Idle: %s", - TRUEFALSE(link_mode_config.mDeviceType), TRUEFALSE(link_mode_config.mNetworkData), - TRUEFALSE(link_mode_config.mRxOnWhenIdle)); -#endif + this->apply_linkmode_(instance); if (this->output_power_.has_value()) { if (const auto err = otPlatRadioSetTransmitPower(instance, *this->output_power_); err != OT_ERROR_NONE) { diff --git a/tests/components/openthread/common.yaml b/tests/components/openthread/common.yaml new file mode 100644 index 0000000000..d9eeab89ea --- /dev/null +++ b/tests/components/openthread/common.yaml @@ -0,0 +1,2 @@ +network: + enable_ipv6: true diff --git a/tests/components/openthread/test-tlv.esp32-c6-idf.yaml b/tests/components/openthread/test-tlv.esp32-c6-idf.yaml new file mode 100644 index 0000000000..c61efd4d3c --- /dev/null +++ b/tests/components/openthread/test-tlv.esp32-c6-idf.yaml @@ -0,0 +1,20 @@ +<<: !include common.yaml + +openthread: + device_type: MTD + force_dataset: false + use_address: open-thread-test.local + tlv: 0e080000000000010000000300001035060004001fffe00208e227ac6a7f24052f0708fdb753eb517cb4d3051062b2442a928d9ea3b947a1618fc4085a030f4f70656e5468726561642d393837330102987304105330d857354330133c05e1fd7ae81a910c0402a0f7f8 + poll_period: 5s + +switch: + - platform: template + name: "Radio Always On" + optimistic: true + restore_mode: ALWAYS_OFF + turn_on_action: + then: + - openthread.set_poll_period: 0s + turn_off_action: + then: + - openthread.set_poll_period: 5s diff --git a/tests/components/openthread/test.esp32-c6-idf.yaml b/tests/components/openthread/test.esp32-c6-idf.yaml index 008edd5397..92d120e5d1 100644 --- a/tests/components/openthread/test.esp32-c6-idf.yaml +++ b/tests/components/openthread/test.esp32-c6-idf.yaml @@ -1,14 +1,6 @@ -esp32: - board: esp32-c6-devkitc-1 - framework: - type: esp-idf - log_level: DEBUG - -network: - enable_ipv6: true +<<: !include common.yaml openthread: - device_type: MTD channel: 13 network_name: OpenThread-8f28 network_key: 0xdfd34f0f05cad978ec4e32b0413038ff @@ -16,7 +8,4 @@ openthread: ext_pan_id: 0xd63e8e3e495ebbc3 pskc: 0xc23a76e98f1a6483639b1ac1271e2e27 mesh_local_prefix: fd53:145f:ed22:ad81::/64 - force_dataset: true - use_address: open-thread-test.local - poll_period: 20sec output_power: 1dBm From e27390bddb87508ad04595055e328a7c1bead5b6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:36:10 -0400 Subject: [PATCH 0591/1815] [hbridge] Fix light stuck on one polarity (#17162) --- esphome/components/hbridge/light/__init__.py | 6 ++-- .../hbridge/light/hbridge_light_output.h | 30 +++++++++++-------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/esphome/components/hbridge/light/__init__.py b/esphome/components/hbridge/light/__init__.py index ccb47237b6..f9451e2594 100644 --- a/esphome/components/hbridge/light/__init__.py +++ b/esphome/components/hbridge/light/__init__.py @@ -1,14 +1,14 @@ import esphome.codegen as cg from esphome.components import light, output import esphome.config_validation as cv -from esphome.const import CONF_OUTPUT_ID, CONF_PIN_A, CONF_PIN_B +from esphome.const import CONF_OUTPUT_ID, CONF_PIN_A, CONF_PIN_B, CONF_UPDATE_INTERVAL from .. import hbridge_ns CODEOWNERS = ["@DotNetDann"] HBridgeLightOutput = hbridge_ns.class_( - "HBridgeLightOutput", cg.Component, light.LightOutput + "HBridgeLightOutput", cg.PollingComponent, light.LightOutput ) CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend( @@ -16,12 +16,14 @@ CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend( cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(HBridgeLightOutput), cv.Required(CONF_PIN_A): cv.use_id(output.FloatOutput), cv.Required(CONF_PIN_B): cv.use_id(output.FloatOutput), + cv.Optional(CONF_UPDATE_INTERVAL, default="8ms"): cv.update_interval, } ) async def to_code(config): var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) + cg.add(var.set_update_interval(config.pop(CONF_UPDATE_INTERVAL))) await cg.register_component(var, config) await light.register_light(var, config) diff --git a/esphome/components/hbridge/light/hbridge_light_output.h b/esphome/components/hbridge/light/hbridge_light_output.h index c0107fdc0d..9dcf7adfd8 100644 --- a/esphome/components/hbridge/light/hbridge_light_output.h +++ b/esphome/components/hbridge/light/hbridge_light_output.h @@ -3,11 +3,10 @@ #include "esphome/components/light/light_output.h" #include "esphome/components/output/float_output.h" #include "esphome/core/component.h" -#include "esphome/core/helpers.h" namespace esphome::hbridge { -class HBridgeLightOutput final : public Component, public light::LightOutput { +class HBridgeLightOutput final : public PollingComponent, public light::LightOutput { public: void set_pina_pin(output::FloatOutput *pina_pin) { this->pina_pin_ = pina_pin; } void set_pinb_pin(output::FloatOutput *pinb_pin) { this->pinb_pin_ = pinb_pin; } @@ -20,11 +19,12 @@ class HBridgeLightOutput final : public Component, public light::LightOutput { return traits; } - void setup() override { this->disable_loop(); } + void setup() override { this->stop_poller(); } - void loop() override { - // Only called when both channels are active — alternate H-bridge direction - // each iteration to multiplex cold and warm white. + void update() override { + // Flip the H-bridge direction to multiplex cold/warm white. update_interval must stay + // slower than the output's PWM period (flipping faster collapses the output onto one + // channel) but fast enough to avoid flicker (issue #17030). if (!this->forward_direction_) { this->pina_pin_->set_level(this->pina_duty_); this->pinb_pin_->set_level(0); @@ -46,13 +46,17 @@ class HBridgeLightOutput final : public Component, public light::LightOutput { this->pinb_duty_ = new_pinb; if (new_pina != 0.0f && new_pinb != 0.0f) { - // Both channels active — need loop to alternate H-bridge direction - this->high_freq_.start(); - this->enable_loop(); + // Both channels active — multiplex the H-bridge direction via the poller. + if (!this->multiplexing_) { + this->multiplexing_ = true; + this->start_poller(); + } } else { - // Zero or one channel active — drive pins directly, no multiplexing needed - this->high_freq_.stop(); - this->disable_loop(); + // Zero or one channel active — drive pins directly, no multiplexing needed. + if (this->multiplexing_) { + this->multiplexing_ = false; + this->stop_poller(); + } this->pina_pin_->set_level(new_pina); this->pinb_pin_->set_level(new_pinb); } @@ -64,7 +68,7 @@ class HBridgeLightOutput final : public Component, public light::LightOutput { float pina_duty_{0}; float pinb_duty_{0}; bool forward_direction_{false}; - HighFrequencyLoopRequester high_freq_; + bool multiplexing_{false}; }; } // namespace esphome::hbridge From e304c318fb75d168dff3de74c394ddf80e5f4cdb Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:47:24 +0200 Subject: [PATCH 0592/1815] Bump bundled esphome-device-builder to 1.0.18 (#17212) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c02aba093c..8159f1d32e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.17 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.18 RUN \ platformio settings set enable_telemetry No \ From ddf075a2dd399c22f450f1fbb921c111442f77c4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 18:00:52 +0000 Subject: [PATCH 0593/1815] Bump aioesphomeapi from 45.3.1 to 45.5.2 (#17211) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 462438016e..956f3633dc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.0 click==8.3.3 -aioesphomeapi==45.3.1 +aioesphomeapi==45.5.2 zeroconf==0.150.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From cc646b22135d2cbc72a76045543f5496e4ba6b24 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 25 Jun 2026 21:34:33 +0200 Subject: [PATCH 0594/1815] [core] Defer requests import in framework_helpers to speed up config validation (#17215) --- esphome/framework_helpers.py | 6 ++-- tests/unit_tests/test_framework_helpers.py | 37 ++++++++++++++++++---- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 6bf389240b..a8e5cf75a8 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -11,8 +11,6 @@ import sys import time from typing import IO -import requests - from esphome.helpers import ProgressBar, rmtree PathType = str | os.PathLike @@ -635,6 +633,10 @@ def download_from_mirrors( ValueError: If mirrors list is empty. Exception: If all download attempts fail. """ + # Imported lazily: requests is a heavy import (~85ms) and is only needed + # when actually downloading a toolchain, never during config validation. + import requests + # 1. Open target file for writing if path given with ExitStack() as stack: if isinstance(target, (str, os.PathLike)): diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index f6e783b5e8..fd807ed05d 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -526,7 +526,7 @@ class TestDownloadFromMirrors: def test_success_returns_url_and_writes_content(self, tmp_path: Path) -> None: target = tmp_path / "out.bin" with patch( - "esphome.framework_helpers.requests.get", + "requests.get", return_value=_mock_response(b"filedata"), ): url = download_from_mirrors(["https://example.com/f"], {}, target) @@ -535,7 +535,7 @@ class TestDownloadFromMirrors: def test_substitutions_applied_to_url(self, tmp_path: Path) -> None: with patch( - "esphome.framework_helpers.requests.get", + "requests.get", return_value=_mock_response(b"x"), ) as mock_get: download_from_mirrors( @@ -547,7 +547,7 @@ class TestDownloadFromMirrors: def test_falls_back_to_second_mirror(self, tmp_path: Path) -> None: with patch( - "esphome.framework_helpers.requests.get", + "requests.get", side_effect=[_mock_response(b"", ok=False), _mock_response(b"second")], ): url = download_from_mirrors( @@ -561,7 +561,7 @@ class TestDownloadFromMirrors: def test_all_mirrors_fail_reraises_last_exception(self, tmp_path: Path) -> None: with ( patch( - "esphome.framework_helpers.requests.get", + "requests.get", return_value=_mock_response(b"", ok=False), ), pytest.raises(req.HTTPError), @@ -579,7 +579,7 @@ class TestDownloadFromMirrors: def test_file_like_target_written(self) -> None: buf = io.BytesIO() with patch( - "esphome.framework_helpers.requests.get", + "requests.get", return_value=_mock_response(b"bytes"), ): download_from_mirrors(["https://example.com/f"], {}, buf) @@ -590,7 +590,7 @@ class TestDownloadFromMirrors: r = _mock_response(b"1234567890") r.headers = {"content-length": "10"} with ( - patch("esphome.framework_helpers.requests.get", return_value=r), + patch("requests.get", return_value=r), patch("esphome.framework_helpers.ProgressBar") as mock_pb, ): download_from_mirrors(["https://example.com/f"], {}, tmp_path / "out.bin") @@ -606,12 +606,35 @@ class TestDownloadFromMirrors: r.headers = {"content-length": "0"} r.iter_content.return_value = [b""] # one empty chunk target = tmp_path / "out.bin" - with patch("esphome.framework_helpers.requests.get", return_value=r): + with patch("requests.get", return_value=r): download_from_mirrors(["https://example.com/f"], {}, target) assert target.exists() assert target.read_bytes() == b"" +def test_importing_framework_helpers_does_not_import_requests() -> None: + """Importing framework_helpers must not drag in requests. + + requests is a heavy import (~85ms) only needed by download_from_mirrors to + fetch toolchains during a build. framework_helpers is loaded during config + validation (esp-idf framework, host platform), so the import is deferred to + the function that uses it. A fresh interpreter is required because the test + process has already imported requests. + """ + result = subprocess.run( + [ + sys.executable, + "-c", + "import sys\nimport esphome.framework_helpers\n" + "print('\\n'.join(sys.modules))", + ], + capture_output=True, + text=True, + check=True, + ) + assert "requests" not in result.stdout.split() + + # --------------------------------------------------------------------------- # get_python_env_executable_path — Windows branch # --------------------------------------------------------------------------- From 239211e5210dd2eb111c30e9520201e241908be7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 25 Jun 2026 21:34:44 +0200 Subject: [PATCH 0595/1815] [time] Defer aioesphomeapi import to speed up config validation (#17214) --- esphome/components/time/__init__.py | 32 +++++++++++------- tests/unit_tests/components/test_time.py | 42 ++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index b3bf2d44d7..35fad0a450 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -1,11 +1,8 @@ import errno +import functools from importlib import resources import logging -from aioesphomeapi.posix_tz import ( - DSTRuleType as PyDSTRuleType, - parse_posix_tz as parse_posix_tz_python, -) import tzlocal from esphome import automation @@ -57,13 +54,20 @@ DSTRuleType_cpp = time_ns.enum("DSTRuleType", is_class=True) DSTRule_cpp = time_ns.struct("DSTRule") ParsedTimezone_cpp = time_ns.struct("ParsedTimezone") -# Map Python DSTRuleType enum values to C++ enum expressions -_DST_RULE_TYPE_MAP = { - PyDSTRuleType.NONE: DSTRuleType_cpp.NONE, - PyDSTRuleType.MONTH_WEEK_DAY: DSTRuleType_cpp.MONTH_WEEK_DAY, - PyDSTRuleType.JULIAN_NO_LEAP: DSTRuleType_cpp.JULIAN_NO_LEAP, - PyDSTRuleType.DAY_OF_YEAR: DSTRuleType_cpp.DAY_OF_YEAR, -} + +# Map Python DSTRuleType enum values to C++ enum expressions. Built lazily to +# avoid importing aioesphomeapi (a heavy import) when the time component is only +# auto-loaded for its schema and never reaches code generation. +@functools.cache +def _dst_rule_type_map() -> dict: + from aioesphomeapi.posix_tz import DSTRuleType as PyDSTRuleType + + return { + PyDSTRuleType.NONE: DSTRuleType_cpp.NONE, + PyDSTRuleType.MONTH_WEEK_DAY: DSTRuleType_cpp.MONTH_WEEK_DAY, + PyDSTRuleType.JULIAN_NO_LEAP: DSTRuleType_cpp.JULIAN_NO_LEAP, + PyDSTRuleType.DAY_OF_YEAR: DSTRuleType_cpp.DAY_OF_YEAR, + } def _load_tzdata(iana_key: str) -> bytes | None: @@ -317,6 +321,8 @@ def validate_tz(value: str) -> str: # Validate that the POSIX TZ string is parseable (skip empty strings) if value: + from aioesphomeapi.posix_tz import parse_posix_tz as parse_posix_tz_python + try: parse_posix_tz_python(value) except ValueError as e: @@ -372,7 +378,7 @@ def _emit_dst_rule_fields(prefix, rule): """Emit field-by-field assignments for a DSTRule to avoid rodata struct blob.""" cg.add(cg.RawExpression(f"{prefix}.time_seconds = {rule.time_seconds}")) cg.add(cg.RawExpression(f"{prefix}.day = {rule.day}")) - cg.add(cg.RawExpression(f"{prefix}.type = {_DST_RULE_TYPE_MAP[rule.type]}")) + cg.add(cg.RawExpression(f"{prefix}.type = {_dst_rule_type_map()[rule.type]}")) cg.add(cg.RawExpression(f"{prefix}.month = {rule.month}")) cg.add(cg.RawExpression(f"{prefix}.week = {rule.week}")) cg.add(cg.RawExpression(f"{prefix}.day_of_week = {rule.day_of_week}")) @@ -409,6 +415,8 @@ async def setup_time_core_(time_var, config): cg.add(time_var.set_timezone(timezone)) else: # Embedded: pre-parse at codegen time, emit struct directly + from aioesphomeapi.posix_tz import parse_posix_tz as parse_posix_tz_python + try: parsed = parse_posix_tz_python(timezone) _emit_parsed_timezone_fields(parsed) diff --git a/tests/unit_tests/components/test_time.py b/tests/unit_tests/components/test_time.py index 5ae9d787d6..6f3b4bb14f 100644 --- a/tests/unit_tests/components/test_time.py +++ b/tests/unit_tests/components/test_time.py @@ -1,6 +1,8 @@ """Tests for time component cron expression parsing.""" import errno +import subprocess +import sys from unittest.mock import MagicMock, patch import pytest @@ -143,3 +145,43 @@ def test_validate_tz_accepts_posix_string_when_read_bytes_raises_einval() -> Non _mock_resources_with_error(OSError(errno.EINVAL, "Invalid argument")), ): assert validate_tz("<+08>-8") == "<+08>-8" + + +def _modules_after(code: str) -> set[str]: + """Run code in a fresh interpreter and return the imported module names. + + A subprocess is required because the test process itself has already + imported aioesphomeapi via other tests, so sys.modules here is useless. + """ + result = subprocess.run( + [sys.executable, "-c", f"import sys\n{code}\nprint('\\n'.join(sys.modules))"], + capture_output=True, + text=True, + check=True, + ) + return set(result.stdout.split()) + + +def test_importing_time_does_not_import_aioesphomeapi() -> None: + """Importing the time component must not drag in aioesphomeapi. + + aioesphomeapi is a heavy import (it builds a large number of dataclasses at + import time). The time component is auto-loaded by many components, so + importing it for its schema during config validation must not pay that + cost. The import is deferred to the functions that actually need it. + """ + modules = _modules_after("import esphome.components.time") + assert "aioesphomeapi" not in modules + + +def test_validate_tz_imports_aioesphomeapi_lazily() -> None: + """Validating a non-empty timezone is what triggers the lazy import. + + Documents the boundary: the cost is only paid when a timezone is actually + validated, not merely by loading the component. + """ + modules = _modules_after( + "from esphome.components.time import validate_tz\n" + "validate_tz('EST5EDT,M3.2.0,M11.1.0')" + ) + assert "aioesphomeapi" in modules From f49bed47de91fdac0954e714970aa8a530a98511 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:07:55 -0400 Subject: [PATCH 0596/1815] Bump ruff from 0.15.19 to 0.15.20 (#17216) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 6e53a4c14f..ebd93ea390 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.6 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.19 # also change in .pre-commit-config.yaml when updating +ruff==0.15.20 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From be8523a73c30efe3df499c5f968742f3e942f55a Mon Sep 17 00:00:00 2001 From: Ardumine <61353807+Ardumine@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:33:38 +0100 Subject: [PATCH 0597/1815] [mdns] Add mDNS to Zephyr and nRF52 (#16924) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/mdns/mdns_component.cpp | 5 ++++- esphome/components/mdns/mdns_zephyr.cpp | 13 ++++++++++--- tests/components/mdns/test.nrf52-adafruit.yaml | 4 ++++ 3 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 tests/components/mdns/test.nrf52-adafruit.yaml diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 9bf27e71e4..e11cb1abaa 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -100,7 +100,7 @@ void MDNSComponent::compile_records_(StaticVector &) {} -void MDNSComponent::setup() { ESP_LOGW(TAG, "mDNS is not implemented for Zephyr"); } +void MDNSComponent::setup() { this->setup_buffers_and_register_(register_zephyr); } +#else +// No responder and nothing consuming the records, so skip the boot-time compile. +void MDNSComponent::setup() {} +#endif void MDNSComponent::on_shutdown() {} diff --git a/tests/components/mdns/test.nrf52-adafruit.yaml b/tests/components/mdns/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..6aff688ff4 --- /dev/null +++ b/tests/components/mdns/test.nrf52-adafruit.yaml @@ -0,0 +1,4 @@ +network: + enable_ipv6: true + +mdns: From f9f28a6a007a99ad2594204dc45c2c5a3fb4e30e Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 07:48:52 +0200 Subject: [PATCH 0598/1815] Bump bundled esphome-device-builder to 1.0.19 (#17217) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 8159f1d32e..66dad179bb 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.18 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.19 RUN \ platformio settings set enable_telemetry No \ From 75cdabee3d59cca25a788bb28873533130f41a4e Mon Sep 17 00:00:00 2001 From: Ardumine <61353807+Ardumine@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:30:07 +0100 Subject: [PATCH 0599/1815] [socket] Add BSD socket support for nRF52 (#16699) Co-authored-by: tomaszduda23 Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/socket/__init__.py | 6 +++ .../components/socket/bsd_sockets_impl.cpp | 6 ++- esphome/components/socket/bsd_sockets_impl.h | 28 +++++++++- esphome/components/socket/headers.h | 6 +++ esphome/components/socket/socket.cpp | 52 ++++++++++++++++++- esphome/components/socket/socket.h | 4 +- .../socket/test.nrf52-adafruit.yaml | 1 + .../components/socket/test.nrf52-mcumgr.yaml | 1 + .../socket/test.nrf52-xiao-ble.yaml | 1 + 9 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 tests/components/socket/test.nrf52-adafruit.yaml create mode 100644 tests/components/socket/test.nrf52-mcumgr.yaml create mode 100644 tests/components/socket/test.nrf52-xiao-ble.yaml diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index abbbb0f056..38d787c20a 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -149,6 +149,7 @@ CONFIG_SCHEMA = cv.Schema( ln882x=IMPLEMENTATION_LWIP_SOCKETS, rtl87xx=IMPLEMENTATION_LWIP_SOCKETS, host=IMPLEMENTATION_BSD_SOCKETS, + nrf52=IMPLEMENTATION_BSD_SOCKETS, ): cv.one_of( IMPLEMENTATION_LWIP_TCP, IMPLEMENTATION_LWIP_SOCKETS, @@ -168,6 +169,11 @@ async def to_code(config): cg.add_define("USE_SOCKET_IMPL_LWIP_SOCKETS") elif impl == IMPLEMENTATION_BSD_SOCKETS: cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS") + if CORE.using_zephyr: + from esphome.components.zephyr import zephyr_add_prj_conf + + zephyr_add_prj_conf("NET_SOCKETS", True) + zephyr_add_prj_conf("POSIX_API", True) # ESP32 and LibreTiny both have LwIP >= 2.1.3 with lwip_socket_dbg_get_socket() # and FreeRTOS task notifications — enable fast select to bypass lwip_select(). # Only when not using lwip_tcp, which does not provide select() support. diff --git a/esphome/components/socket/bsd_sockets_impl.cpp b/esphome/components/socket/bsd_sockets_impl.cpp index ee22e4b97b..0d4284f145 100644 --- a/esphome/components/socket/bsd_sockets_impl.cpp +++ b/esphome/components/socket/bsd_sockets_impl.cpp @@ -22,11 +22,13 @@ BSDSocketImpl::BSDSocketImpl(int fd, bool monitor_loop) { if (flags >= 0) ::fcntl(this->fd_, F_SETFD, flags | FD_CLOEXEC); #endif + // Guard structure matches socket_ready_fd(): non-HOST platforms (nRF52/OpenThread) + // do not register fds with the esphome select loop, so monitor_loop is a no-op there. if (!monitor_loop) return; #ifdef USE_LWIP_FAST_SELECT this->cached_sock_ = hook_fd_for_fast_select(this->fd_); -#else +#elif defined(USE_HOST) this->loop_monitored_ = wake_register_fd(this->fd_); #endif } @@ -45,7 +47,7 @@ int BSDSocketImpl::close() { // touch an unrelated socket's pcb. No per-socket callback unhook is needed — // all LwIP sockets share the same static event_callback. this->cached_sock_ = nullptr; -#else +#elif defined(USE_HOST) if (this->loop_monitored_) { wake_unregister_fd(this->fd_); } diff --git a/esphome/components/socket/bsd_sockets_impl.h b/esphome/components/socket/bsd_sockets_impl.h index 57c1a430a2..1b5ea9ebcd 100644 --- a/esphome/components/socket/bsd_sockets_impl.h +++ b/esphome/components/socket/bsd_sockets_impl.h @@ -76,7 +76,7 @@ class BSDSocketImpl { #endif } ssize_t recvfrom(void *buf, size_t len, sockaddr *addr, socklen_t *addr_len) { -#if defined(USE_ESP32) || defined(USE_HOST) +#if defined(USE_ESP32) || defined(USE_HOST) || defined(USE_ZEPHYR) return ::recvfrom(this->fd_, buf, len, 0, addr, addr_len); #else return ::lwip_recvfrom(this->fd_, buf, len, 0, addr, addr_len); @@ -85,6 +85,19 @@ class BSDSocketImpl { ssize_t readv(const struct iovec *iov, int iovcnt) { #if defined(USE_ESP32) return ::lwip_readv(this->fd_, iov, iovcnt); +#elif defined(USE_ZEPHYR) + // Zephyr does not provide readv(); emulate with a read() loop. Stream sockets only: + // on a datagram socket each read() would consume a separate datagram, not scatter one. + ssize_t total = 0; + for (int i = 0; i < iovcnt; i++) { + ssize_t n = ::read(this->fd_, iov[i].iov_base, iov[i].iov_len); + if (n < 0) + return total > 0 ? total : n; + total += n; + if (static_cast(n) < iov[i].iov_len) + break; + } + return total; #else return ::readv(this->fd_, iov, iovcnt); #endif @@ -100,6 +113,19 @@ class BSDSocketImpl { ssize_t writev(const struct iovec *iov, int iovcnt) { #if defined(USE_ESP32) return ::lwip_writev(this->fd_, iov, iovcnt); +#elif defined(USE_ZEPHYR) + // Zephyr does not provide writev(); emulate with a write() loop. Stream sockets only: + // on a datagram socket each write() would emit a separate datagram, not gather one. + ssize_t total = 0; + for (int i = 0; i < iovcnt; i++) { + ssize_t n = ::write(this->fd_, iov[i].iov_base, iov[i].iov_len); + if (n < 0) + return total > 0 ? total : n; + total += n; + if (static_cast(n) < iov[i].iov_len) + break; // partial write: stop so caller resumes from the correct stream offset + } + return total; #else return ::writev(this->fd_, iov, iovcnt); #endif diff --git a/esphome/components/socket/headers.h b/esphome/components/socket/headers.h index 0eece6480f..f9b652f14a 100644 --- a/esphome/components/socket/headers.h +++ b/esphome/components/socket/headers.h @@ -158,7 +158,9 @@ using socklen_t = uint32_t; #include #include #include +#ifndef USE_ZEPHYR #include +#endif #include #ifdef USE_HOST @@ -167,6 +169,10 @@ using socklen_t = uint32_t; #include #include #endif // USE_HOST +#ifdef USE_ZEPHYR +#include +#include +#endif // USE_ZEPHYR #ifdef USE_ARDUINO // arduino-esp32 declares a global var called INADDR_NONE which is replaced diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index f14ac1e2d5..212da80312 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -12,9 +12,19 @@ namespace esphome::socket { #ifdef USE_HOST -// Shared ready() implementation for fd-based socket implementations (BSD and LWIP sockets). -// Checks if the host wake select() loop has marked this fd as ready. +// Host: ready when the wake select() loop has flagged this fd (or it isn't monitored). bool socket_ready_fd(int fd, bool loop_monitored) { return !loop_monitored || wake_fd_ready(fd); } +#elif defined(USE_ZEPHYR) +// Zephyr (nRF52): fd monitoring isn't wired into the esphome select loop +// (wake_register_fd is USE_HOST-only), so loop_monitored is always false. Always +// return true — the caller handles EAGAIN/EWOULDBLOCK on read. +// +// Cost (known trade-off, not an oversight): loop-monitored sockets (API, web_server) +// are read every loop() iteration and bail on EAGAIN; there is no event-driven wake, +// so the main loop busy-polls at loop frequency and cannot idle between packets. +// TODO: wire Zephyr fds into an event-driven wake source (e.g. zsock_poll/k_poll) so +// the loop can sleep between packets on battery/OpenThread targets. +bool socket_ready_fd(int /*fd*/, bool /*loop_monitored*/) { return true; } #endif // Platform-specific inet_ntop wrappers @@ -40,6 +50,19 @@ static inline const char *esphome_inet_ntop6(const void *addr, char *buf, size_t return lwip_inet_ntop(AF_INET6, addr, buf, size); } #endif +#elif defined(USE_ZEPHYR) +// Zephyr BSD sockets — use Zephyr native address formatting via POSIX-subset wrappers. +// is already included transitively through . +static inline const char *esphome_inet_ntop4(const void *addr, char *buf, size_t size) { + return zsock_inet_ntop(AF_INET, addr, buf, size); +} +// IPv6 is always enabled on nRF52 (config validation enforces enable_ipv6=True), +// but the guard is retained for consistency with other platform blocks. +#if USE_NETWORK_IPV6 +static inline const char *esphome_inet_ntop6(const void *addr, char *buf, size_t size) { + return zsock_inet_ntop(AF_INET6, addr, buf, size); +} +#endif #else // BSD sockets (host, ESP32-IDF) static inline const char *esphome_inet_ntop4(const void *addr, char *buf, size_t size) { @@ -68,6 +91,15 @@ size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::s esphome_inet_ntop4(&addr->sin6_addr.s6_addr[12], buf.data(), buf.size()) != nullptr) { return strlen(buf.data()); } +#elif defined(USE_ZEPHYR) + // Format IPv4-mapped IPv6 addresses as regular IPv4. Zephyr uses the standard POSIX + // s6_addr layout (not the LWIP union) but provides no IN6_IS_ADDR_V4MAPPED macro, so + // detect the ::ffff:0:0/96 prefix directly on the address words. + if (addr->sin6_addr.s6_addr32[0] == 0 && addr->sin6_addr.s6_addr32[1] == 0 && + addr->sin6_addr.s6_addr32[2] == htonl(0xFFFF) && + esphome_inet_ntop4(&addr->sin6_addr.s6_addr32[3], buf.data(), buf.size()) != nullptr) { + return strlen(buf.data()); + } #elif !defined(USE_SOCKET_IMPL_LWIP_TCP) // Format IPv4-mapped IPv6 addresses as regular IPv4 (LWIP layout) if (addr->sin6_addr.un.u32_addr[0] == 0 && addr->sin6_addr.un.u32_addr[1] == 0 && @@ -117,11 +149,19 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_ server->sin6_port = htons(port); #ifdef USE_SOCKET_IMPL_BSD_SOCKETS +#if defined(USE_ZEPHYR) + // Zephyr BSD sockets: use native address conversion + if (zsock_inet_pton(AF_INET6, ip_address, &server->sin6_addr) != 1) { + errno = EINVAL; + return 0; + } +#else // Use standard inet_pton for BSD sockets if (inet_pton(AF_INET6, ip_address, &server->sin6_addr) != 1) { errno = EINVAL; return 0; } +#endif #else // Use LWIP-specific functions ip6_addr_t ip6; @@ -138,7 +178,15 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_ auto *server = reinterpret_cast(addr); memset(server, 0, sizeof(sockaddr_in)); server->sin_family = AF_INET; +#if defined(USE_ZEPHYR) + // Zephyr BSD sockets: use native address conversion + if (zsock_inet_pton(AF_INET, ip_address, &server->sin_addr) != 1) { + errno = EINVAL; + return 0; + } +#else server->sin_addr.s_addr = inet_addr(ip_address); +#endif server->sin_port = htons(port); return sizeof(sockaddr_in); } diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index 204113e4b2..eb8870786d 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -60,11 +60,11 @@ inline struct lwip_sock *hook_fd_for_fast_select(int fd) { } return sock; } -#elif defined(USE_HOST) +#elif defined(USE_HOST) || defined(USE_ZEPHYR) /// Shared ready() helper for fd-based socket implementations. /// Checks if the Application's select() loop has marked this fd as ready. bool socket_ready_fd(int fd, bool loop_monitored); -#endif +#endif // USE_LWIP_FAST_SELECT // Inline ready() — defined here because it depends on socket_ready/socket_ready_fd // declared above, while the impl headers are included before those declarations. diff --git a/tests/components/socket/test.nrf52-adafruit.yaml b/tests/components/socket/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..d55dfd1557 --- /dev/null +++ b/tests/components/socket/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +socket: diff --git a/tests/components/socket/test.nrf52-mcumgr.yaml b/tests/components/socket/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..d55dfd1557 --- /dev/null +++ b/tests/components/socket/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +socket: diff --git a/tests/components/socket/test.nrf52-xiao-ble.yaml b/tests/components/socket/test.nrf52-xiao-ble.yaml new file mode 100644 index 0000000000..d55dfd1557 --- /dev/null +++ b/tests/components/socket/test.nrf52-xiao-ble.yaml @@ -0,0 +1 @@ +socket: From da5e11d1966cc26bbe9c2a614914d7a8c86f9e39 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 27 Jun 2026 15:49:56 +0200 Subject: [PATCH 0600/1815] [core] Fix area saved as null in storage.json (#17219) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/core/config.py | 12 +++++++++++- tests/unit_tests/core/test_config.py | 29 +++++++++++++++++++++++++--- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index b925f0b7d9..59c96035b8 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -407,6 +407,17 @@ def preload_core_config(config, result) -> str: CORE.name = conf[CONF_NAME] CORE.friendly_name = conf.get(CONF_FRIENDLY_NAME) + # Record the node's area name now (substitutions are already resolved at this + # point). storage.json is written before to_code() runs, so deferring this to + # to_code() left the area as null in storage.json. The value here is the raw + # post-substitution form (a plain string or a {name: ...} mapping). Assign + # unconditionally (like friendly_name) so a config without an area never + # inherits a stale value from a previous load in a long-running process, and + # use .get() so a malformed mapping surfaces later as a proper validation + # error rather than a KeyError here. to_code() sets it again from the + # validated config, which yields the same name. + area = conf.get(CONF_AREA) + CORE.area = area.get(CONF_NAME) if isinstance(area, dict) else area CORE.data[KEY_CORE] = {} if CONF_BUILD_PATH not in conf: @@ -760,7 +771,6 @@ async def to_code(config: ConfigType) -> None: # Process areas all_areas: list[dict[str, str | core.ID]] = [] if CONF_AREA in config: - CORE.area = config[CONF_AREA][CONF_NAME] all_areas.append(config[CONF_AREA]) all_areas.extend(config[CONF_AREAS]) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index e2b34d92d8..b3d87f6857 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -152,15 +152,21 @@ def test_multiple_areas_and_devices(yaml_file: Callable[[str], str]) -> None: ("multiple_areas_devices.yaml", "Main Area"), ], ) -async def test_to_code_records_core_area( +async def test_core_area_recorded_at_config_load( yaml_file: Callable[[str], Path], fixture: str, expected_area: str, ) -> None: - """``to_code`` records the node's area name on CORE for StorageJSON.""" + """The node's area name is recorded on CORE for StorageJSON. + + It must be set during config load (preload_core_config), not deferred to + to_code(): storage.json is written before to_code() runs, so a late + assignment left the area as null in storage.json (regression #17218). + """ result = load_config_from_fixture(yaml_file, fixture, FIXTURES_DIR) assert result is not None - assert CORE.area is None + # Recorded already at config-load time, before any code generation. + assert CORE.area == expected_area with patch("esphome.core.config.cg") as mock_cg: mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock() @@ -170,6 +176,23 @@ async def test_to_code_records_core_area( assert CORE.area == expected_area +def test_config_load_without_area_clears_stale_core_area( + yaml_file: Callable[[str], Path], +) -> None: + """A config without an area must not inherit a stale CORE.area. + + preload_core_config assigns CORE.area unconditionally, so the area from a + previous load in a long-running process cannot leak into a config that + omits it. + """ + CORE.area = "Stale Area From Previous Load" + result = load_config_from_fixture( + yaml_file, "device_without_area.yaml", FIXTURES_DIR + ) + assert result is not None + assert CORE.area is None + + def test_legacy_string_area( yaml_file: Callable[[str], str], caplog: pytest.LogCaptureFixture ) -> None: From 7811781a9608898774d34b35265257b84044790a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 27 Jun 2026 16:21:36 +0200 Subject: [PATCH 0601/1815] [es8388] Fix DAC unable to unmute once muted (#17221) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/es8388/es8388.cpp | 8 +++++++- esphome/components/es8388/es8388_const.h | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/components/es8388/es8388.cpp b/esphome/components/es8388/es8388.cpp index c015393e14..0b97240230 100644 --- a/esphome/components/es8388/es8388.cpp +++ b/esphome/components/es8388/es8388.cpp @@ -173,8 +173,14 @@ bool ES8388::set_mute_state_(bool mute_state) { ES8388_ERROR_CHECK(this->read_byte(ES8388_DACCONTROL3, &value)); ESP_LOGV(TAG, "Read ES8388_DACCONTROL3: 0x%02X", value); + // Only toggle the DACMute bit; the other bits of this register hold unrelated + // DAC settings that must be preserved. Previously muting overwrote the whole + // register with 0x3C and unmuting never cleared the bit, so once muted the DAC + // could not be unmuted again. if (mute_state) { - value = 0x3C; + value |= ES8388_DACCONTROL3_DAC_MUTE; + } else { + value &= ~ES8388_DACCONTROL3_DAC_MUTE; } ESP_LOGV(TAG, "Setting ES8388_DACCONTROL3 to 0x%02X (muted: %s)", value, YESNO(mute_state)); diff --git a/esphome/components/es8388/es8388_const.h b/esphome/components/es8388/es8388_const.h index 451c9cc026..e081c55dbd 100644 --- a/esphome/components/es8388/es8388_const.h +++ b/esphome/components/es8388/es8388_const.h @@ -38,6 +38,7 @@ static const uint8_t ES8388_ADCCONTROL14 = 0x16; static const uint8_t ES8388_DACCONTROL1 = 0x17; static const uint8_t ES8388_DACCONTROL2 = 0x18; static const uint8_t ES8388_DACCONTROL3 = 0x19; +static const uint8_t ES8388_DACCONTROL3_DAC_MUTE = 0x04; // DACMute, bit 2 of DACCONTROL3 static const uint8_t ES8388_DACCONTROL4 = 0x1a; static const uint8_t ES8388_DACCONTROL5 = 0x1b; static const uint8_t ES8388_DACCONTROL6 = 0x1c; From 88875daf52f3e72daf1a467e4e093547e80ab236 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 10:32:39 -0400 Subject: [PATCH 0602/1815] Bump actions/cache/restore from 6.0.0 to 6.1.0 in /.github/actions/restore-python (#17228) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 6290e25d7c..1364e95602 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -22,7 +22,7 @@ runs: python-version: ${{ inputs.python-version }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: venv # yamllint disable-line rule:line-length From 7ad4cbf46fc5d9df6feb7d2d3b8d9c01f3f6544d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 10:32:50 -0400 Subject: [PATCH 0603/1815] Bump actions/cache/save from 6.0.0 to 6.1.0 (#17229) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eaa04ceca6..8700060198 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -250,7 +250,7 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: venv key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -339,7 +339,7 @@ jobs: echo "benchmarks=$(echo "$output" | jq -r '.benchmarks')" >> $GITHUB_OUTPUT - name: Save components graph cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -1164,7 +1164,7 @@ jobs: - name: Save memory analysis to cache if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && steps.build.outcome == 'success' - uses: actions/cache/save@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} From 063c4371dee33c594cb311d448654029a15d06c6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 10:33:01 -0400 Subject: [PATCH 0604/1815] Bump actions/cache from 6.0.0 to 6.1.0 (#17230) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8700060198..7a4c1ebc23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: venv # yamllint disable-line rule:line-length @@ -365,7 +365,7 @@ jobs: python-version: "3.13" - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -509,7 +509,7 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' && matrix.pio_cache_key - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} From 436938b931771ab726afbfc476bc494da05cf26f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 10:33:13 -0400 Subject: [PATCH 0605/1815] Bump actions/cache/restore from 6.0.0 to 6.1.0 (#17231) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a4c1ebc23..72519e421a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -295,7 +295,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Restore components graph cache - uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -516,7 +516,7 @@ jobs: - name: Cache platformio if: github.ref != 'refs/heads/dev' && matrix.pio_cache_key - uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} @@ -1098,7 +1098,7 @@ jobs: - name: Restore cached memory analysis id: cache-memory-analysis if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' - uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} @@ -1122,7 +1122,7 @@ jobs: - name: Cache platformio if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' - uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} @@ -1211,7 +1211,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache platformio - uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} From 24ec65e68eb56b5e56a0bc007047af2ce7a3a034 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 27 Jun 2026 17:26:47 +0200 Subject: [PATCH 0606/1815] [esp32] Only warn about S3 PSRAM pins (GPIO33-37) in octal mode (#17222) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/esp32/__init__.py | 4 ++ esphome/components/esp32/gpio.py | 16 ++++++- esphome/components/esp32/gpio_esp32_s3.py | 45 ++++++++++++++++--- .../config/psram_octal_disabled_gpio34.yaml | 16 +++++++ .../esp32/config/psram_octal_gpio34.yaml | 15 +++++++ .../esp32/config/psram_quad_gpio34.yaml | 15 +++++++ tests/component_tests/esp32/test_esp32.py | 26 +++++++++++ 7 files changed, 129 insertions(+), 8 deletions(-) create mode 100644 tests/component_tests/esp32/config/psram_octal_disabled_gpio34.yaml create mode 100644 tests/component_tests/esp32/config/psram_octal_gpio34.yaml create mode 100644 tests/component_tests/esp32/config/psram_quad_gpio34.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 945eda3912..a5528da672 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1102,6 +1102,8 @@ def final_validate(config): # Imported locally to avoid circular import issues from esphome.components.psram import DOMAIN as PSRAM_DOMAIN + from .gpio import final_validate_pins + errs = [] conf_fw = config[CONF_FRAMEWORK] advanced = conf_fw[CONF_ADVANCED] @@ -1185,6 +1187,8 @@ def final_validate(config): ) ) + final_validate_pins(full_config) + if ( config[CONF_FLASH_SIZE] == "32MB" and "ota" in full_config diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index 2ff39cab69..321dd3d498 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -18,6 +18,7 @@ from esphome.const import ( PLATFORM_ESP32, ) from esphome.core import CORE +from esphome.types import ConfigType from . import boards from .const import ( @@ -50,7 +51,11 @@ from .gpio_esp32_h4 import esp32_h4_validate_gpio_pin, esp32_h4_validate_support from .gpio_esp32_h21 import esp32_h21_validate_gpio_pin, esp32_h21_validate_supports from .gpio_esp32_p4 import esp32_p4_validate_gpio_pin, esp32_p4_validate_supports from .gpio_esp32_s2 import esp32_s2_validate_gpio_pin, esp32_s2_validate_supports -from .gpio_esp32_s3 import esp32_s3_validate_gpio_pin, esp32_s3_validate_supports +from .gpio_esp32_s3 import ( + esp32_s3_final_validate_pins, + esp32_s3_validate_gpio_pin, + esp32_s3_validate_supports, +) from .gpio_esp32_s31 import esp32_s31_validate_gpio_pin, esp32_s31_validate_supports ESP32InternalGPIOPin = esp32_ns.class_("ESP32InternalGPIOPin", cg.InternalGPIOPin) @@ -96,6 +101,7 @@ def _translate_pin(value): class ESP32ValidationFunctions: pin_validation: Callable[[int], int] usage_validation: Callable[[dict[str, Any]], dict[str, Any]] + final_validate: Callable[[ConfigType], None] | None = None _esp32_validations = { @@ -145,6 +151,7 @@ _esp32_validations = { VARIANT_ESP32S3: ESP32ValidationFunctions( pin_validation=esp32_s3_validate_gpio_pin, usage_validation=esp32_s3_validate_supports, + final_validate=esp32_s3_final_validate_pins, ), VARIANT_ESP32S31: ESP32ValidationFunctions( pin_validation=esp32_s31_validate_gpio_pin, @@ -261,3 +268,10 @@ async def esp32_pin_to_code(config): cg.add(var.set_drive_strength(config[CONF_DRIVE_STRENGTH])) cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) return var + + +def final_validate_pins(full_config: ConfigType) -> None: + """Run the active variant's pin final-validation, if it defines one.""" + funcs = _esp32_validations.get(CORE.data[KEY_ESP32][KEY_VARIANT]) + if funcs is not None and funcs.final_validate is not None: + funcs.final_validate(full_config) diff --git a/esphome/components/esp32/gpio_esp32_s3.py b/esphome/components/esp32/gpio_esp32_s3.py index f528de4ccd..db8c520533 100644 --- a/esphome/components/esp32/gpio_esp32_s3.py +++ b/esphome/components/esp32/gpio_esp32_s3.py @@ -2,8 +2,15 @@ import logging from typing import Any import esphome.config_validation as cv -from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER -from esphome.pins import check_strapping_pin +from esphome.const import ( + CONF_DISABLED, + CONF_INPUT, + CONF_MODE, + CONF_NUMBER, + PLATFORM_ESP32, +) +from esphome.pins import PIN_SCHEMA_REGISTRY, check_strapping_pin +from esphome.types import ConfigType _ESP32S3_SPI_PSRAM_PINS = { 26: "SPICS1", @@ -38,11 +45,9 @@ def esp32_s3_validate_gpio_pin(value: int) -> int: raise cv.Invalid( f"This pin cannot be used on ESP32-S3s and is already used by the SPI/PSRAM interface(function: {_ESP32S3_SPI_PSRAM_PINS[value]})" ) - if value in _ESP32S3R8_PSRAM_PINS: - _LOGGER.warning( - "GPIO%d is used by the PSRAM interface on ESP32-S3R8 / ESP32-S3R8V and should be avoided on these models", - value, - ) + # GPIO33-37 (_ESP32S3R8_PSRAM_PINS) are only taken by the PSRAM interface in + # octal mode -- whether that applies isn't known here, so the warning is + # deferred to final_validate_pins() in gpio.py once the PSRAM mode is resolved. if value in (22, 23, 24, 25): # These pins are not exposed in GPIO mux (reason unknown) @@ -71,3 +76,29 @@ def esp32_s3_validate_supports(value: dict[str, Any]) -> dict[str, Any]: check_strapping_pin(value, _ESP32S3_STRAPPING_PINS, _LOGGER) return value + + +def esp32_s3_final_validate_pins(full_config: ConfigType) -> None: + """Warn about GPIO33-37 usage, but only when octal PSRAM (which uses them) is set. + + These pins are only taken by the PSRAM interface in octal mode (ESP32-S3R8 / + S3R8V); on quad-PSRAM variants -- or when the psram block is disabled, so the + octal interface is never configured -- they are free. The per-pin validator + can't know the PSRAM mode, so the check is deferred here, where + PIN_SCHEMA_REGISTRY.pins_used already lists every used pin. + """ + # Imported locally to avoid circular import issues + from esphome.components.psram import DOMAIN as PSRAM_DOMAIN, TYPE_OCTAL + + psram_config = full_config.get(PSRAM_DOMAIN, {}) + if psram_config.get(CONF_DISABLED) or psram_config.get(CONF_MODE) != TYPE_OCTAL: + return + for number in sorted( + number + for key, _client_id, number in PIN_SCHEMA_REGISTRY.pins_used + if key == PLATFORM_ESP32 and number in _ESP32S3R8_PSRAM_PINS + ): + _LOGGER.warning( + "GPIO%d is used by the PSRAM interface in octal mode and should be avoided", + number, + ) diff --git a/tests/component_tests/esp32/config/psram_octal_disabled_gpio34.yaml b/tests/component_tests/esp32/config/psram_octal_disabled_gpio34.yaml new file mode 100644 index 0000000000..450e1bb345 --- /dev/null +++ b/tests/component_tests/esp32/config/psram_octal_disabled_gpio34.yaml @@ -0,0 +1,16 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + +psram: + mode: octal + disabled: true + +binary_sensor: + - platform: gpio + pin: GPIO34 + name: test diff --git a/tests/component_tests/esp32/config/psram_octal_gpio34.yaml b/tests/component_tests/esp32/config/psram_octal_gpio34.yaml new file mode 100644 index 0000000000..b385057d79 --- /dev/null +++ b/tests/component_tests/esp32/config/psram_octal_gpio34.yaml @@ -0,0 +1,15 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + +psram: + mode: octal + +binary_sensor: + - platform: gpio + pin: GPIO34 + name: test diff --git a/tests/component_tests/esp32/config/psram_quad_gpio34.yaml b/tests/component_tests/esp32/config/psram_quad_gpio34.yaml new file mode 100644 index 0000000000..9612edb75b --- /dev/null +++ b/tests/component_tests/esp32/config/psram_quad_gpio34.yaml @@ -0,0 +1,15 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + +psram: + mode: quad + +binary_sensor: + - platform: gpio + pin: GPIO34 + name: test diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index bdba981c44..cea34bef7c 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -213,6 +213,32 @@ def test_execute_from_psram_p4_sdkconfig( assert "CONFIG_SPIRAM_RODATA" not in sdkconfig +@pytest.mark.parametrize( + ("fixture", "expect_warning"), + [ + ("psram_quad_gpio34.yaml", False), + ("psram_octal_gpio34.yaml", True), + ("psram_octal_disabled_gpio34.yaml", False), + ], +) +def test_s3_psram_pin_warning_only_for_octal( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, + fixture: str, + expect_warning: bool, +) -> None: + """GPIO33-37 are only used by the PSRAM interface in octal mode. + + Using such a pin must only warn when octal PSRAM is configured; on quad + PSRAM the pins are free and warning would be a false positive (#16857). + """ + with caplog.at_level("WARNING"): + generate_main(component_config_path(fixture)) + warned = "GPIO34 is used by the PSRAM interface in octal mode" in caplog.text + assert warned == expect_warning + + def test_ignore_pin_validation_error_on_clean_pin_warns( set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture, From ccc57475b76a928b0aaaefbc95019a405b0fac1f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 27 Jun 2026 12:04:45 -0400 Subject: [PATCH 0607/1815] [deep_sleep] Add ESP32-C5 support (#17237) --- .../deep_sleep/deep_sleep_component.h | 3 ++- .../components/deep_sleep/deep_sleep_esp32.cpp | 17 ++++++++++------- .../deep_sleep/test.esp32-c5-idf.yaml | 5 +++++ .../deep_sleep/test.esp32-c61-idf.yaml | 5 +++++ 4 files changed, 22 insertions(+), 8 deletions(-) create mode 100644 tests/components/deep_sleep/test.esp32-c5-idf.yaml create mode 100644 tests/components/deep_sleep/test.esp32-c61-idf.yaml diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 8edda040d3..896ed092aa 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -96,7 +96,8 @@ class DeepSleepComponent final : public Component { #endif #if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ - !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) + !defined(USE_ESP32_VARIANT_ESP32C5) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ + !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) void set_touch_wakeup(bool touch_wakeup); #endif diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index c905b8fcbc..7cb8e53efd 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -16,7 +16,7 @@ namespace esphome::deep_sleep { // | ESP32-S3 | ✓ | ✓ | ✓ | | // | ESP32-C2 | | | | ✓ | // | ESP32-C3 | | | | ✓ | -// | ESP32-C5 | | (✓) | | (✓) | +// | ESP32-C5 | | ✓ | | ✓ | // | ESP32-C6 | | ✓ | | ✓ | // | ESP32-C61 | | ✓ | | ✓ | // | ESP32-H2 | | ✓ | | | @@ -56,7 +56,8 @@ void DeepSleepComponent::set_ext1_wakeup(Ext1Wakeup ext1_wakeup) { this->ext1_wa #endif #if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ - !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) + !defined(USE_ESP32_VARIANT_ESP32C5) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ + !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) void DeepSleepComponent::set_touch_wakeup(bool touch_wakeup) { this->touch_wakeup_ = touch_wakeup; } #endif @@ -99,7 +100,8 @@ void DeepSleepComponent::deep_sleep_() { // Single pin wakeup (ext0) - ESP32, S2, S3 only #if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ - !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32H2) + !defined(USE_ESP32_VARIANT_ESP32C5) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ + !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) if (this->wakeup_pin_ != nullptr) { const auto gpio_pin = gpio_num_t(this->wakeup_pin_->get_pin()); if (this->wakeup_pin_->get_flags() & gpio::FLAG_PULLUP) { @@ -122,9 +124,9 @@ void DeepSleepComponent::deep_sleep_() { } #endif - // GPIO wakeup - C2, C3, C6, C61 only -#if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || \ - defined(USE_ESP32_VARIANT_ESP32C61) + // GPIO wakeup - C2, C3, C5, C6, C61 only +#if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ + defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) if (this->wakeup_pin_ != nullptr) { const auto gpio_pin = gpio_num_t(this->wakeup_pin_->get_pin()); // Make sure GPIO is in input mode, not all RTC GPIO pins are input by default @@ -154,7 +156,8 @@ void DeepSleepComponent::deep_sleep_() { // Touch wakeup - ESP32, S2, S3 only #if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ - !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) + !defined(USE_ESP32_VARIANT_ESP32C5) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ + !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) if (this->touch_wakeup_.has_value() && *(this->touch_wakeup_)) { esp_sleep_enable_touchpad_wakeup(); esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); diff --git a/tests/components/deep_sleep/test.esp32-c5-idf.yaml b/tests/components/deep_sleep/test.esp32-c5-idf.yaml new file mode 100644 index 0000000000..11abe70711 --- /dev/null +++ b/tests/components/deep_sleep/test.esp32-c5-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + wakeup_pin: GPIO4 + +<<: !include common.yaml +<<: !include common-esp32-ext1.yaml diff --git a/tests/components/deep_sleep/test.esp32-c61-idf.yaml b/tests/components/deep_sleep/test.esp32-c61-idf.yaml new file mode 100644 index 0000000000..11abe70711 --- /dev/null +++ b/tests/components/deep_sleep/test.esp32-c61-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + wakeup_pin: GPIO4 + +<<: !include common.yaml +<<: !include common-esp32-ext1.yaml From a0742a953558a25e86597e747b44787c74a2a7b3 Mon Sep 17 00:00:00 2001 From: Ardumine <61353807+Ardumine@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:22:34 +0100 Subject: [PATCH 0608/1815] [api] Add nRF52 support (#17226) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/api/__init__.py | 2 ++ esphome/components/api/api_pb2_includes.h | 7 +++++++ esphome/components/network/__init__.py | 6 ++++++ tests/components/api/test.nrf52-adafruit.yaml | 4 ++++ 4 files changed, 19 insertions(+) create mode 100644 tests/components/api/test.nrf52-adafruit.yaml diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 932702d47a..0f5cd936f5 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -305,6 +305,7 @@ CONFIG_SCHEMA = cv.All( rtl87xx=4, # Moderate RAM, BSD-style sockets host=4, # Abundant resources ln882x=4, # Moderate RAM + nrf52=4, # ~256KB RAM, BSD sockets ): cv.int_range(min=1, max=10), cv.SplitDefault( CONF_MAX_CONNECTIONS, @@ -315,6 +316,7 @@ CONFIG_SCHEMA = cv.All( rtl87xx=5, # Moderate RAM host=8, # Abundant resources ln882x=5, # Moderate RAM + nrf52=4, # ~256KB RAM, BSD sockets, Thread (single HA controller) ): cv.int_range(min=1, max=20), # Maximum queued send buffers per connection before dropping connection # Each buffer uses ~8-12 bytes overhead plus actual message size diff --git a/esphome/components/api/api_pb2_includes.h b/esphome/components/api/api_pb2_includes.h index f45e091c6f..70ba579fcc 100644 --- a/esphome/components/api/api_pb2_includes.h +++ b/esphome/components/api/api_pb2_includes.h @@ -31,6 +31,13 @@ #include #include +#if defined(LOG_LEVEL_NONE) +// Zephyr defines LOG_LEVEL_NONE as a logging macro that collides with the LogLevel enum value of +// the same name in the generated api_pb2.h. Undefine it for the rest of this translation unit so +// the enum parses; nothing below needs Zephyr's logging macro. +#undef LOG_LEVEL_NONE +#endif + namespace esphome::api { // This file only provides includes, no actual code diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index b662293ab5..846c3afc59 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -221,6 +221,12 @@ async def to_code(config): zephyr_add_prj_conf("NET_IPV6", True) zephyr_add_prj_conf("NET_TCP", True) zephyr_add_prj_conf("NET_UDP", True) + # The nRF Connect SDK replaces mbedTLS with PSA/Oberon crypto and does not provide the + # legacy mbedtls_md5() symbol that Zephyr's RFC 6528 TCP ISN generator links against + # (selecting MBEDTLS_MAC_MD5_ENABLED does not bring in the legacy C API here). Disable it so + # TCP links; Zephyr falls back to sys_rand32_get() for the ISN (randomized, but not the + # RFC 6528 keyed hash). + zephyr_add_prj_conf("NET_TCP_ISN_RFC6528", False) if (enable_ipv6 := config.get(CONF_ENABLE_IPV6, None)) is not None: cg.add_define("USE_NETWORK_IPV6", enable_ipv6) diff --git a/tests/components/api/test.nrf52-adafruit.yaml b/tests/components/api/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..9229d68aa3 --- /dev/null +++ b/tests/components/api/test.nrf52-adafruit.yaml @@ -0,0 +1,4 @@ +network: + enable_ipv6: true + +api: From 690e8c3fb964d82b2b3a42f5a1007f825d0a4d3c Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 27 Jun 2026 21:50:28 +0200 Subject: [PATCH 0609/1815] [nrf52] add upload for native build (#17100) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/nrf52/__init__.py | 173 ++++++++++++- esphome/components/nrf52/framework.py | 5 +- esphome/components/nrf52/requirements.txt | 1 + esphome/storage_json.py | 16 ++ tests/unit_tests/test_nrf52_upload.py | 292 ++++++++++++++++++++++ tests/unit_tests/test_storage_json.py | 96 +++++++ 6 files changed, 571 insertions(+), 12 deletions(-) create mode 100644 tests/unit_tests/test_nrf52_upload.py diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index d87318b03d..00271c97c7 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -4,6 +4,7 @@ import asyncio import logging from pathlib import Path import re +import shutil import subprocess from esphome import pins @@ -486,6 +487,16 @@ def upload_program(config: ConfigType, args, host: str) -> bool: from esphome.__main__ import check_permissions from esphome.upload_targets import PortType, get_port_type + if KEY_ZEPHYR not in CORE.data: + platform_config = config.get(CORE.target_platform) + if not platform_config: + raise EsphomeError( + "nRF52 platform configuration is missing; " + "please re-validate and recompile." + ) + set_core_data(platform_config) + set_framework(platform_config) + mcumgr_device: str | None = None if get_port_type(host) == PortType.SERIAL: @@ -494,17 +505,122 @@ def upload_program(config: ConfigType, args, host: str) -> bool: mcumgr_device = host else: if not CORE.using_toolchain_platformio: - raise EsphomeError("Not implemented yet") - result = _upload_using_platformio(config, host, ["-t", "upload"]) - if result != 0: - raise EsphomeError(f"Upload failed with result: {result}") - return True # Handled: platformio serial upload + bootloader = zephyr_data()[KEY_BOOTLOADER] + if bootloader not in ( + BOOTLOADER_ADAFRUIT, + BOOTLOADER_ADAFRUIT_NRF52_SD132, + BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, + ): + raise EsphomeError("Not implemented yet") + check_and_install() + paths = get_build_paths() + env = get_build_env() + build_dir = CORE.relative_pioenvs_path(CORE.name) + dfu_package = build_dir / "firmware.zip" + if not dfu_package.is_file(): + raise EsphomeError("Firmware not found. Please compile first.") + import time as _time + + import serial as _serial + import serial.tools.list_ports as _list_ports + + try: + ser = _serial.Serial(host, baudrate=1200, timeout=1) + ser.close() + except _serial.SerialException as err: + raise EsphomeError(f"Failed to open {host}: {err}") from err + + # Wait for device to reset (port disappears) + deadline = _time.monotonic() + 5 + while _time.monotonic() < deadline: + _time.sleep(0.1) + if host not in {p.device for p in _list_ports.comports()}: + break + else: + _LOGGER.warning( + "Device did not leave %s within 5 s; " + "it may not have entered bootloader mode", + host, + ) + + # Wait for DFU port to reappear + deadline = _time.monotonic() + 10 + while _time.monotonic() < deadline: + _time.sleep(0.1) + if host in {p.device for p in _list_ports.comports()}: + break + else: + raise EsphomeError( + f"DFU port {host!r} did not reappear within 10 s. " + "Check that the device entered DFU mode." + ) + + # Wait for udev to finish setting up device permissions + deadline = _time.monotonic() + 5 + while _time.monotonic() < deadline: + try: + check_permissions(host) + break + except EsphomeError: + _time.sleep(0.05) + else: + check_permissions(host) # raises with helpful message + + python = str(paths["python_executable"]) + if not run_command_ok( + [ + python, + "-m", + "nordicsemi.__main__", + "dfu", + "serial", + "-pkg", + str(dfu_package), + "-p", + host, + "-b", + "115200", + "--singlebank", + ], + env=env, + stream_output=True, + ): + raise EsphomeError("nRF52 serial DFU upload failed") + else: + result = _upload_using_platformio(config, host, ["-t", "upload"]) + if result != 0: + raise EsphomeError(f"Upload failed with result: {result}") + return True # Handled: serial upload if host == "PYOCD": - result = _upload_using_platformio(config, host, ["-t", "flash_pyocd"]) - if result != 0: - raise EsphomeError(f"Upload failed with result: {result}") - return True # Handled: platformio PYOCD upload + if not CORE.using_toolchain_platformio: + check_and_install() + paths = get_build_paths() + env = get_build_env() + build_dir = CORE.relative_pioenvs_path(CORE.name) + west_cmd = [ + str(paths["python_executable"]), + "-m", + "west", + "flash", + "--runner", + "pyocd", + "-d", + str(build_dir), + ] + if not run_command_ok( + west_cmd, + env=env, + stream_output=True, + cwd=str(paths["framework_path"]), + ): + raise EsphomeError("nRF52 pyocd flash failed") + else: + result = _upload_using_platformio(config, host, ["-t", "flash_pyocd"]) + if result != 0: + raise EsphomeError(f"Upload failed with result: {result}") + return True # Handled: PYOCD upload # Deferred imports: bleak/smpclient are heavy, only load for BLE/mcumgr paths from .ble_logger import is_mac_address @@ -662,4 +778,43 @@ def run_compile(args, config: ConfigType) -> bool: ): raise EsphomeError("nRF52 native build failed") + # Zephyr's cmake places kernel artifacts in build_dir/zephyr/zephyr/ and + # merged.hex at build_dir/. Normalize to build_dir/zephyr/ so paths match + # get_download_types (which mirrors the platformio build output layout). + zephyr_dir = build_dir / "zephyr" + west_out = zephyr_dir / "zephyr" + for filename in ["zephyr.uf2"]: + src = west_out / filename + if src.is_file(): + shutil.copy2(src, zephyr_dir / filename) + + # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes + _GENPKG_PARAMS = { + BOOTLOADER_ADAFRUIT_NRF52_SD132: ("0x0051", "0x009D"), + BOOTLOADER_ADAFRUIT_NRF52_SD140_V6: ("0x0052", "0x00B6"), + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA"), + } + bootloader = zephyr_data()[KEY_BOOTLOADER] + if bootloader in ( + BOOTLOADER_ADAFRUIT, + BOOTLOADER_ADAFRUIT_NRF52_SD132, + BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, + ): + hex_file = west_out / "zephyr.hex" + dfu_package = build_dir / "firmware.zip" + genpkg_cmd = [ + str(paths["python_executable"]), + "-m", + "nordicsemi.__main__", + "dfu", + "genpkg", + ] + if bootloader in _GENPKG_PARAMS: + dev_type, sd_req = _GENPKG_PARAMS[bootloader] + genpkg_cmd += ["--dev-type", dev_type, "--sd-req", sd_req] + genpkg_cmd += ["--application", str(hex_file), str(dfu_package)] + if not run_command_ok(genpkg_cmd, env=env, stream_output=True): + raise EsphomeError("Failed to create adafruit DFU package") + return True diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index a35ba3ef85..05feadb001 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -111,10 +111,9 @@ def _get_version_str() -> str: def get_build_paths() -> dict: version = _get_version_str() + env_path = _get_python_env_path(version) return { - "python_executable": get_python_env_executable_path( - _get_python_env_path(version), "python" - ), + "python_executable": get_python_env_executable_path(env_path, "python"), "framework_path": _get_framework_path(version), } diff --git a/esphome/components/nrf52/requirements.txt b/esphome/components/nrf52/requirements.txt index 250d3a29cf..c55d35b2b1 100644 --- a/esphome/components/nrf52/requirements.txt +++ b/esphome/components/nrf52/requirements.txt @@ -1,3 +1,4 @@ west==1.5.0 ninja==1.13.0 cmake==4.3.2 +adafruit-nrfutil @ git+https://github.com/adafruit/Adafruit_nRF52_nrfutil.git@7fdfe15feee5f304fb7d9b031721dcefa1f72b58 diff --git a/esphome/storage_json.py b/esphome/storage_json.py index f754673b79..9d662df8f8 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -12,6 +12,7 @@ from esphome.const import ( CONF_DISABLED, CONF_MDNS, KEY_CORE, + KEY_FRAMEWORK_VERSION, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, Toolchain, @@ -179,6 +180,8 @@ class StorageJSON: hardware = esp32.get_esp32_variant(esph) framework_version = str(esp32.idf_version()) + elif esph.is_nrf52: + framework_version = str(esph.data[KEY_CORE][KEY_FRAMEWORK_VERSION]) return StorageJSON( storage_version=1, name=esph.name, @@ -334,6 +337,19 @@ class StorageJSON: f"Please clean the build files and recompile." ) from err CORE.data[KEY_ESP32] = esp32_data + elif target_platform == const.PLATFORM_NRF52 and self.framework_version: + import esphome.config_validation as cv + + try: + CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version.parse( + self.framework_version + ) + except ValueError as err: + raise EsphomeError( + f"Could not parse the framework version " + f"{self.framework_version!r} from {storage_path()}. " + f"Please clean the build files and recompile." + ) from err def __eq__(self, o) -> bool: return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict() diff --git a/tests/unit_tests/test_nrf52_upload.py b/tests/unit_tests/test_nrf52_upload.py new file mode 100644 index 0000000000..a60e23a337 --- /dev/null +++ b/tests/unit_tests/test_nrf52_upload.py @@ -0,0 +1,292 @@ +"""Tests for esphome.components.nrf52 upload_program and run_compile.""" + +from contextlib import ExitStack +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from esphome.components.nrf52.const import BOOTLOADER_ADAFRUIT_NRF52_SD140_V7 +from esphome.components.zephyr.const import ( + KEY_BOARD, + KEY_BOOTLOADER, + KEY_EXTRA_BUILD_FILES, + KEY_KCONFIG, + KEY_OVERLAY, + KEY_PM_STATIC, + KEY_PRJ_CONF, + KEY_USER, + KEY_ZEPHYR, +) +import esphome.config_validation as cv +from esphome.const import ( + KEY_CORE, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + PLATFORM_NRF52, + Toolchain, +) +from esphome.core import CORE, EsphomeError + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _setup_nrf52_core( + bootloader: str = BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, + toolchain: Toolchain = Toolchain.SDK_NRF, + build_path: Path | None = None, +) -> None: + CORE.name = "test_device" + if build_path is not None: + CORE.build_path = build_path + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: PLATFORM_NRF52, + KEY_TARGET_FRAMEWORK: KEY_ZEPHYR, + KEY_FRAMEWORK_VERSION: cv.Version(2, 9, 2), + } + CORE.toolchain = toolchain + CORE.data[KEY_ZEPHYR] = { + KEY_BOARD: "adafruit_feather_nrf52840", + KEY_BOOTLOADER: bootloader, + KEY_PRJ_CONF: {}, + KEY_OVERLAY: {"": ""}, + KEY_EXTRA_BUILD_FILES: {}, + KEY_PM_STATIC: [], + KEY_USER: {}, + KEY_KCONFIG: "", + } + + +def _make_paths(tmp_path: Path) -> dict: + return { + "python_executable": tmp_path / "penv" / "python", + "framework_path": tmp_path / "framework", + } + + +# --------------------------------------------------------------------------- +# Config-reconstruction guard +# --------------------------------------------------------------------------- + + +class TestUploadProgramConfigGuard: + def test_missing_platform_config_raises(self, setup_core: Path) -> None: + """upload_program raises EsphomeError when the platform config section is absent.""" + from esphome.components.nrf52 import upload_program + + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: PLATFORM_NRF52, + KEY_TARGET_FRAMEWORK: KEY_ZEPHYR, + } + # KEY_ZEPHYR absent → reconstruction branch is entered + assert KEY_ZEPHYR not in CORE.data + + with pytest.raises(EsphomeError, match="platform configuration"): + upload_program(config={}, args=None, host="PYOCD") + + +# --------------------------------------------------------------------------- +# PYOCD upload path +# --------------------------------------------------------------------------- + + +class TestUploadProgramPyocd: + def test_pyocd_assembles_west_command( + self, setup_core: Path, tmp_path: Path + ) -> None: + """West flash command must include --runner pyocd and the build dir.""" + from esphome.components.nrf52 import upload_program + + _setup_nrf52_core(build_path=tmp_path / "build") + CORE.config_path = tmp_path / "test.yaml" + paths = _make_paths(tmp_path) + build_dir = CORE.relative_pioenvs_path(CORE.name) + + with ( + patch("esphome.components.nrf52.check_and_install"), + patch("esphome.components.nrf52.get_build_paths", return_value=paths), + patch("esphome.components.nrf52.get_build_env", return_value={}), + patch( + "esphome.components.nrf52.run_command_ok", return_value=True + ) as mock_run, + ): + result = upload_program(config={}, args=None, host="PYOCD") + + assert result is True + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + assert str(paths["python_executable"]) == cmd[0] + assert "west" in cmd + assert "flash" in cmd + assert "--runner" in cmd + assert "pyocd" in cmd + assert "-d" in cmd + assert str(build_dir) in cmd + + def test_pyocd_failure_raises(self, setup_core: Path, tmp_path: Path) -> None: + """A failed west flash must raise EsphomeError.""" + from esphome.components.nrf52 import upload_program + + _setup_nrf52_core(build_path=tmp_path / "build") + CORE.config_path = tmp_path / "test.yaml" + + with ( + patch("esphome.components.nrf52.check_and_install"), + patch( + "esphome.components.nrf52.get_build_paths", + return_value=_make_paths(tmp_path), + ), + patch("esphome.components.nrf52.get_build_env", return_value={}), + patch("esphome.components.nrf52.run_command_ok", return_value=False), + pytest.raises(EsphomeError, match="pyocd"), + ): + upload_program(config={}, args=None, host="PYOCD") + + +# --------------------------------------------------------------------------- +# Serial DFU upload path +# --------------------------------------------------------------------------- + + +def _enter_serial_dfu_patches( + stack: ExitStack, host: str, tmp_path: Path, paths: dict +) -> MagicMock: + """Enter all context managers needed for the serial DFU happy path. + + Returns the mock for ``run_command_ok`` so callers can inspect calls. + comports() returns [] on the first call (port disappeared) and a list + containing the host on every subsequent call (port reappeared). Patches + are applied directly on the real pyserial module attributes so they are + visible to the deferred ``import serial[.tools.list_ports] as _x`` + statements inside upload_program. + """ + import serial + import serial.tools.list_ports + + from esphome.upload_targets import PortType + + _comports_calls = [0] + + def _comports(): + _comports_calls[0] += 1 + if _comports_calls[0] == 1: + return [] # port disappeared → disappear loop breaks + return [MagicMock(device=host)] # port back → reappear loop breaks + + stack.enter_context( + patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL) + ) + stack.enter_context(patch("esphome.__main__.check_permissions")) + stack.enter_context(patch("esphome.components.nrf52.check_and_install")) + stack.enter_context( + patch("esphome.components.nrf52.get_build_paths", return_value=paths) + ) + stack.enter_context( + patch("esphome.components.nrf52.get_build_env", return_value={}) + ) + stack.enter_context(patch("time.sleep")) + # Patch directly on the real pyserial module so the deferred imports inside + # upload_program see our mocks regardless of how sys.modules is cached. + stack.enter_context(patch.object(serial, "Serial")) + stack.enter_context( + patch.object(serial.tools.list_ports, "comports", side_effect=_comports) + ) + return stack.enter_context( + patch("esphome.components.nrf52.run_command_ok", return_value=True) + ) + + +class TestUploadProgramSerialDfu: + def test_unsupported_bootloader_raises( + self, setup_core: Path, tmp_path: Path + ) -> None: + """An unknown bootloader must raise EsphomeError before touching the port.""" + from esphome.components.nrf52 import upload_program + from esphome.upload_targets import PortType + + _setup_nrf52_core( + bootloader="unknown_bootloader", build_path=tmp_path / "build" + ) + CORE.config_path = tmp_path / "test.yaml" + + with ( + patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL), + patch("esphome.__main__.check_permissions"), + pytest.raises(EsphomeError, match="Not implemented"), + ): + upload_program(config={}, args=None, host="/dev/ttyACM0") + + def test_missing_firmware_raises(self, setup_core: Path, tmp_path: Path) -> None: + """Missing firmware.zip must raise EsphomeError before opening the serial port.""" + from esphome.components.nrf52 import upload_program + from esphome.upload_targets import PortType + + _setup_nrf52_core(build_path=tmp_path / "build") + CORE.config_path = tmp_path / "test.yaml" + + with ( + patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL), + patch("esphome.__main__.check_permissions"), + patch("esphome.components.nrf52.check_and_install"), + patch( + "esphome.components.nrf52.get_build_paths", + return_value=_make_paths(tmp_path), + ), + patch("esphome.components.nrf52.get_build_env", return_value={}), + pytest.raises(EsphomeError, match="Firmware not found"), + ): + # firmware.zip does not exist on disk → is_file() returns False + upload_program(config={}, args=None, host="/dev/ttyACM0") + + def test_serial_dfu_assembles_nordicsemi_command( + self, setup_core: Path, tmp_path: Path + ) -> None: + """Nordicsemi DFU command must include pkg path, port, and --singlebank.""" + from esphome.components.nrf52 import upload_program + + _setup_nrf52_core(build_path=tmp_path / "build") + CORE.config_path = tmp_path / "test.yaml" + paths = _make_paths(tmp_path) + build_dir = CORE.relative_pioenvs_path(CORE.name) + dfu_package = build_dir / "firmware.zip" + dfu_package.parent.mkdir(parents=True, exist_ok=True) + dfu_package.touch() + + host = "/dev/ttyACM0" + with ExitStack() as stack: + mock_run = _enter_serial_dfu_patches(stack, host, tmp_path, paths) + result = upload_program(config={}, args=None, host=host) + + assert result is True + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + assert "nordicsemi.__main__" in cmd + assert "dfu" in cmd + assert "serial" in cmd + assert "-pkg" in cmd + assert str(dfu_package) in cmd + assert "-p" in cmd + assert host in cmd + assert "--singlebank" in cmd + + def test_serial_dfu_failure_raises(self, setup_core: Path, tmp_path: Path) -> None: + """A failed nordicsemi DFU must raise EsphomeError.""" + from esphome.components.nrf52 import upload_program + + _setup_nrf52_core(build_path=tmp_path / "build") + CORE.config_path = tmp_path / "test.yaml" + paths = _make_paths(tmp_path) + build_dir = CORE.relative_pioenvs_path(CORE.name) + dfu_package = build_dir / "firmware.zip" + dfu_package.parent.mkdir(parents=True, exist_ok=True) + dfu_package.touch() + + host = "/dev/ttyACM0" + with ExitStack() as stack: + mock_run = _enter_serial_dfu_patches(stack, host, tmp_path, paths) + mock_run.return_value = False + with pytest.raises(EsphomeError, match="serial DFU upload failed"): + upload_program(config={}, args=None, host=host) diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index 7ba56b05f4..01683507c1 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -352,6 +352,7 @@ def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: mock_core.web_port = None mock_core.target_platform = "esp8266" mock_core.is_esp32 = False + mock_core.is_nrf52 = False mock_core.build_path = "/build" mock_core.firmware_bin = "/build/firmware.bin" mock_core.loaded_integrations = set() @@ -366,6 +367,34 @@ def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: assert result.toolchain is None +def test_storage_json_from_esphome_core_nrf52(setup_core: Path) -> None: + """Test from_esphome_core captures the framework version on nRF52.""" + from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION + + mock_core = MagicMock() + mock_core.name = "nrf_device" + mock_core.friendly_name = "nRF Device" + mock_core.comment = None + mock_core.address = "nrf.local" + mock_core.web_port = None + mock_core.target_platform = "nrf52" + mock_core.is_esp32 = False + mock_core.is_nrf52 = True + mock_core.data = {KEY_CORE: {KEY_FRAMEWORK_VERSION: cv.Version(2, 9, 2)}} + mock_core.build_path = "/build/nrf_device" + mock_core.firmware_bin = "/build/nrf_device/firmware.bin" + mock_core.loaded_integrations = set() + mock_core.loaded_platforms = set() + mock_core.config = {} + mock_core.target_framework = "zephyr" + mock_core.toolchain = None + + result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None) + + assert result.target_platform == "NRF52" + assert result.framework_version == "2.9.2" + + def test_storage_json_load_valid_file(tmp_path: Path) -> None: """Test StorageJSON.load with valid JSON file.""" storage_data = { @@ -787,6 +816,73 @@ def test_storage_json_load_legacy_esphomeyaml_version(tmp_path: Path) -> None: assert result.esphome_version == "1.14.0" # Should map to esphome_version +def _make_nrf52_storage( + framework_version: str | None = None, +) -> storage_json.StorageJSON: + return storage_json.StorageJSON( + storage_version=1, + name="dev", + friendly_name=None, + comment=None, + esphome_version="2024.1.0", + src_version=1, + address="dev.local", + web_port=None, + target_platform="NRF52", + build_path=Path("/build"), + firmware_bin_path=Path("/build/zephyr/zephyr.bin"), + loaded_integrations=set(), + loaded_platforms=set(), + no_mdns=False, + framework="zephyr", + core_platform="nrf52", + framework_version=framework_version, + ) + + +def test_storage_json_nrf52_framework_version_round_trip(setup_core: Path) -> None: + """Sidecar framework_version restores CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION].""" + from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION + + storage = _make_nrf52_storage("2.9.2") + path = setup_core / "storage.json" + path.write_text(storage.to_json()) + + assert json.loads(path.read_text())["framework_version"] == "2.9.2" + + loaded = storage_json.StorageJSON.load(path) + assert loaded is not None + assert loaded.framework_version == "2.9.2" + + loaded.apply_to_core() + assert CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] == cv.Version(2, 9, 2) + + +def test_storage_json_nrf52_apply_to_core_without_framework_version( + setup_core: Path, +) -> None: + """Older sidecars lacking framework_version don't populate KEY_FRAMEWORK_VERSION.""" + from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION + + loaded = _make_nrf52_storage(framework_version=None) + assert loaded.framework_version is None + + loaded.apply_to_core() + assert KEY_FRAMEWORK_VERSION not in CORE.data[KEY_CORE] + + +def test_storage_json_nrf52_apply_to_core_raises_on_invalid_framework_version( + setup_core: Path, +) -> None: + """A malformed version string fails with an actionable error at parse time.""" + from esphome.core import EsphomeError + + loaded = _make_nrf52_storage(framework_version="not-a-version") + + with pytest.raises(EsphomeError, match="clean the build"): + loaded.apply_to_core() + + def test_storage_json_load_area(tmp_path: Path) -> None: """``area`` round-trips through load; absence loads as None.""" file_path = tmp_path / "with_area.json" From fd7fc6b8e8f398c96b230db8f01b4d03135d37d2 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 15:00:23 -0700 Subject: [PATCH 0610/1815] Bump bundled esphome-device-builder to 1.0.20 (#17244) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 66dad179bb..5626d18fcc 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.19 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.20 RUN \ platformio settings set enable_telemetry No \ From 0fb100f2d12a5453abbaade229bd9fca419ef163 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:05:00 -0400 Subject: [PATCH 0611/1815] [core] Suppress unactionable legacy-redaction warning for substitutions (#17242) --- esphome/__main__.py | 27 ++++++++++++++++++++++----- tests/unit_tests/test_main.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 48fee1e97e..1062df7167 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1488,12 +1488,29 @@ _LEGACY_REDACTION_REMOVAL = "2026.12.0" def _redact_with_legacy_fallback(output: str) -> str: unmarked: set[str] = set() + # Track the top-level ``substitutions:`` block. Its keys are arbitrary + # user-chosen names with no schema validator, so the ``cv.sensitive(...)`` + # migration named in the warning can't be applied to them. Their values are + # still redacted, but emitting the (unactionable) deprecation warning would + # only confuse users. + in_substitutions = False - def _replace(m: re.Match[str]) -> str: - unmarked.add(m.group("key")) - return f"{m.group('key')}: \\033[8m{m.group('val')}\\033[28m" - - output = _LEGACY_REDACTION_RE.sub(_replace, output) + lines = output.split("\n") + for i, line in enumerate(lines): + # A non-indented, non-blank line is a top-level key that opens or + # closes the substitutions block. + if line and not line[0].isspace(): + in_substitutions = line.startswith(f"{CONF_SUBSTITUTIONS}:") + m = _LEGACY_REDACTION_RE.search(line) + if m is None: + continue + if not in_substitutions: + unmarked.add(m.group("key")) + lines[i] = ( + f"{line[: m.start()]}{m.group('key')}: " + f"\\033[8m{m.group('val')}\\033[28m{line[m.end() :]}" + ) + output = "\n".join(lines) for key in sorted(unmarked): _LOGGER.warning( "Field '%s' is being redacted by a legacy substring heuristic. " diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index b2011259c1..65bf4a583e 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -442,6 +442,36 @@ def test_redact_with_legacy_fallback__does_not_match_fragment_as_suffix( assert not any("legacy substring" in rec.message for rec in caplog.records) +def test_redact_with_legacy_fallback__substitutions_redacted_without_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + """Substitution keys have no schema validator, so their values are still + redacted but the unactionable cv.sensitive migration warning is suppressed + (see issue #17225).""" + text = "substitutions:\n ota_password: apolloautomation\nesphome:\n name: x\n" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback(text) + assert "ota_password: \\033[8mapolloautomation\\033[28m" in out + assert not any("legacy substring" in rec.message for rec in caplog.records) + + +def test_redact_with_legacy_fallback__warns_after_substitutions_block( + caplog: pytest.LogCaptureFixture, +) -> None: + """The suppression ends at the next top-level key; a sensitive-shaped field + in a later block (a real schema field) still warns, while the substitution + above it does not.""" + text = ( + "substitutions:\n ota_password: apolloautomation\nwifi:\n password: hunter2\n" + ) + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback(text) + assert "ota_password: \\033[8mapolloautomation\\033[28m" in out + assert "password: \\033[8mhunter2\\033[28m" in out + assert any("'password'" in rec.message for rec in caplog.records) + assert not any("ota_password" in rec.message for rec in caplog.records) + + def test_command_config__invokes_legacy_fallback_when_redacting( tmp_path: Path, capfd: CaptureFixture[str] ) -> None: From bda789052d67332ea89c811a0b64a77b475e9b3f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:17:09 -0400 Subject: [PATCH 0612/1815] [espnow] Don't throttle ESP-NOW RX when deep_sleep is present (#17240) --- esphome/components/espnow/espnow_component.cpp | 8 -------- 1 file changed, 8 deletions(-) diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index 403e6f4944..f89b4a2ff1 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -28,9 +28,6 @@ namespace esphome::espnow { static constexpr const char *TAG = "espnow"; -static const esp_err_t CONFIG_ESPNOW_WAKE_WINDOW = 50; -static const esp_err_t CONFIG_ESPNOW_WAKE_INTERVAL = 100; - ESPNowComponent *global_esp_now = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static const LogString *espnow_error_to_str(esp_err_t error) { @@ -204,11 +201,6 @@ void ESPNowComponent::enable_() { esp_wifi_get_mac(WIFI_IF_STA, this->own_address_); -#ifdef USE_DEEP_SLEEP - esp_now_set_wake_window(CONFIG_ESPNOW_WAKE_WINDOW); - esp_wifi_connectionless_module_set_wake_interval(CONFIG_ESPNOW_WAKE_INTERVAL); -#endif - this->state_ = ESPNOW_STATE_ENABLED; for (auto peer : this->peers_) { From d3892b8399c7f015bbbced0b50e943dcbacd2b17 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 28 Jun 2026 07:03:09 -0400 Subject: [PATCH 0613/1815] [platformio] Extract toolchain-agnostic PlatformIO library converter (#17243) --- esphome/espidf/component.py | 724 ++------------------ esphome/platformio/library.py | 717 +++++++++++++++++++ tests/unit_tests/test_espidf_component.py | 83 +-- tests/unit_tests/test_platformio_library.py | 229 +++++++ 4 files changed, 1023 insertions(+), 730 deletions(-) create mode 100644 esphome/platformio/library.py create mode 100644 tests/unit_tests/test_platformio_library.py diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index cfd42916b2..5029e014a4 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -1,163 +1,42 @@ -from collections import deque -from collections.abc import Callable -from dataclasses import dataclass, field -import glob -import hashlib -import itertools -import json +"""ESP-IDF backend for the shared PlatformIO library converter. + +The toolchain-agnostic resolution/download/caching pipeline lives in +``esphome.platformio.library``; this module only adds the ESP-IDF specifics: +emitting an ``idf_component_register`` ``CMakeLists.txt`` + ``idf_component.yml`` +for each resolved library, running any PlatformIO ``extraScript``, and the +ESP-IDF platform/framework compatibility defaults. +""" + import logging import os from pathlib import Path -import re -import tempfile -from typing import Any, TypeVar -from urllib.parse import urlparse, urlsplit, urlunsplit -from esphome import git, yaml_util from esphome.core import CORE, Library -from esphome.espidf.framework import archive_extract_all, download_from_mirrors, rmdir from esphome.helpers import write_file_if_changed +from esphome.platformio.library import ( + DEFAULT_BUILD_FLAGS, + DEFAULT_BUILD_INCLUDE_DIR, + DEFAULT_BUILD_SRC_FILTER, + ESPHOME_DATA_EXTRA_CMAKE_KEY, + ESPHOME_DATA_KEY, + SRC_FILE_EXTENSIONS, + ConvertedLibrary as IDFComponent, + LibraryBackend, + PathType, + collect_filtered_files, + convert_libraries, + ensure_list, + split_list_by_condition, +) _LOGGER = logging.getLogger(__name__) -PathType = str | os.PathLike - -# -# Constants from platformio -# - -FILTER_REGEX = re.compile(r"([+-])<([^>]+)>") -DEFAULT_BUILD_SRC_FILTER = ( - "+<*> -<.git/> -<.svn/> - - - -" -) -DEFAULT_BUILD_SRC_DIRS = "src" -DEFAULT_BUILD_INCLUDE_DIR = "include" -DEFAULT_BUILD_FLAGS = [] -SRC_FILE_EXTENSIONS = [ - ".c", - ".cpp", - ".cc", - ".cxx", - ".c++", - ".S", - ".spp", - ".SPP", - ".sx", - ".s", - ".asm", - ".ASM", -] - ESP32_PLATFORM = "espressif32" -DOMAIN = "pio_components" - -ESPHOME_DATA_KEY = "ESPHOME" -ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE" -class Source: - def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: - raise NotImplementedError - - -class URLSource(Source): - def __init__(self, url: str): - self.url = url - - def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: - base_dir = Path(CORE.data_dir) / DOMAIN - h = hashlib.new("sha256") - h.update(self.url.encode()) - if salt: - h.update(salt.encode()) - path = base_dir / h.hexdigest()[:8] / dir_suffix - # Marker file written last to signal a complete extraction. Using a - # marker (instead of just `path.is_dir()`) means an interrupted - # extraction is correctly detected and re-run on the next invocation, - # and lets us extract directly into ``path`` — avoiding a - # post-extraction rename that races with antivirus on Windows. - extracted_marker = path / ".esphome_extracted" - if not extracted_marker.is_file() or force: - rmdir(path, msg=f"Clean up library directory {path}") - - # Download in temporary file - with tempfile.NamedTemporaryFile() as tmp: - _LOGGER.info("Downloading %s ...", self.url) - _LOGGER.debug("Location: %s", path) - - download_from_mirrors([self.url], {}, tmp.file) - - _LOGGER.debug("Extracting archive to %s ...", path) - archive_extract_all(tmp.file, path) - extracted_marker.touch() - return path - - def __str__(self): - return self.url - - -class GitSource(Source): - def __init__(self, url: str, ref: str | None): - self.url = url - self.ref = ref - - def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: - path, _ = git.clone_or_update( - url=self.url, - ref=self.ref, - refresh=git.NEVER_REFRESH if not force else None, - domain=f"{DOMAIN}/{salt}" if salt else DOMAIN, - submodules=[], - subpath=Path(dir_suffix), - ) - return path - - def __str__(self): - return f"{self.url}#{self.ref}" if self.ref else self.url - - -class InvalidIDFComponent(Exception): - pass - - -class IDFComponent: - def __init__(self, name: str, version: str, source: Source | None): - self.name = name - self.version = version - self.source = source - self.data = {} - self.dependencies: list[IDFComponent] = [] - self._path: Path | None = None - - def __str__(self): - return f"{self.name}@{self.version}={self.source}" - - @property - def path(self) -> Path: - if self._path is None: - raise RuntimeError(f"path not set for component {self}") - return self._path - - @path.setter - def path(self, value: Path) -> None: - self._path = value - - def get_sanitized_name(self): - return re.sub(r"[^a-zA-Z0-9_.\-/]", "_", self.name) - - def get_require_name(self): - return self.get_sanitized_name().replace("/", "__") - - def download(self, force: bool = False, salt: str = ""): - """ - The dependency name should match the directory name at the end of the override path. - The ESP-IDF build system uses the directory name as the component name, so the directory of the override_path should match the component name. - If you want to specify the full name of the component with the namespace, replace / in the component name with __. - @see https://docs.espressif.com/projects/idf-component-manager/en/latest/reference/manifest_file.html - """ - self.path = self.source.download( - self.get_sanitized_name(), force=force, salt=salt - ) +def _idf_framework() -> str: + """The framework token an ESP-IDF library manifest is expected to declare.""" + return "arduino" if CORE.using_arduino else "espidf" def _apply_extra_script(component: IDFComponent) -> None: @@ -190,119 +69,6 @@ def _apply_extra_script(component: IDFComponent) -> None: component.data["build"]["flags"] = flags -T = TypeVar("T") - - -def _ensure_list(obj: T | list[T]) -> list[T]: - """ - Convert an object to a list if it isn't already a list. - - Args: - obj: Object that may or may not already be a list. - - Returns: - list[T]: The original list if ``obj`` is a list, otherwise a single-item - list containing ``obj``. - """ - return [obj] if not isinstance(obj, list) else obj - - -def _owner_pkgname_to_name(owner: str | None, pkgname: str) -> str: - """ - Convert owner and package name to a standardized component name. - - This function combines owner and package name with a forward slash when - both are provided, otherwise returns just the package name. - - Args: - owner: The owner/username of the package (can be None) - pkgname: The name of the package - - Returns: - str: The standardized component name in "owner/pkgname" format or just "pkgname" - """ - return f"{owner}/{pkgname}" if owner else pkgname - - -def _collect_filtered_files(src_dir: PathType, src_filters: list[str]) -> list[str]: - """ - Recursively match files in a directory according to include/exclude patterns. - - This function processes a list of filter strings that indicate which files - to include or exclude. Each filter is parsed into patterns with a sign: - '+' for inclusion and '-' for exclusion. Directory patterns ending with '/' - are normalized to include all their contents recursively. - - Args: - src_dir (PathType): Root directory to search within. - src_filters (list[str]): List of filter strings, which may contain multiple - patterns. Each pattern can start with '+' or '-' to indicate inclusion - or exclusion. - - Returns: - list[str]: List of matched file paths as strings. Only files (not directories) - are returned, even if a directory matches a pattern. - """ - matches = list( - itertools.chain.from_iterable( - FILTER_REGEX.findall(src_filter) for src_filter in src_filters - ) - ) - - selected = set() - - for sign, pattern in matches: - pattern = pattern.strip() - - if pattern.endswith("/"): - pattern = pattern.rstrip("/") + "/**" - - # glob.escape has no pathlib equivalent and the matcher works on raw - # path strings, so PTH118/PTH207 don't apply here. - full_pattern = os.path.join(glob.escape(str(src_dir)), pattern) # noqa: PTH118 - - matched = [] - for item in glob.glob(full_pattern, recursive=True): # noqa: PTH207 - if not Path(item).is_dir(): - matched.append(item) - else: - # PlatformIO quirk: a directory matched with "*" should include all its - # nested files and subdirectories, not just the directory itself. - for root, _, files in os.walk(item): - matched.extend([str(Path(root) / f) for f in files]) - - if sign == "+": - selected.update(matched) - elif sign == "-": - selected.difference_update(matched) - - return [r for r in selected if Path(r).is_file()] - - -def _split_list_by_condition( - items: list[str], match_fn: Callable[[str], str | None] -) -> tuple[list[str], list[str]]: - """ - Splits a list into two lists based on a matching function. - - Args: - items: List of items to split. - match_fn: Function that returns a value for items that should go into the "matched" list. - - Returns: - A tuple (matched, non_matched) - """ - matched = [] - non_matched = [] - for item in items: - result = match_fn(item) - if result: - matched.append(result) - else: - non_matched.append(item) - return matched, non_matched - - def generate_cmakelists_txt(component: IDFComponent) -> str: """ Generate a CMakeLists.txt file for an ESP-IDF component. @@ -333,15 +99,15 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: build_include_dir = component.data.get("build", {}).get( "includeDir", DEFAULT_BUILD_INCLUDE_DIR ) - build_src_filter = _ensure_list( + build_src_filter = ensure_list( component.data.get("build", {}).get("srcFilter", DEFAULT_BUILD_SRC_FILTER) ) - build_flags = _ensure_list( + build_flags = ensure_list( component.data.get("build", {}).get("flags", DEFAULT_BUILD_FLAGS) ) # List all sources files - build_src_files = _collect_filtered_files( + build_src_files = collect_filtered_files( component.path / Path(build_src_dir), build_src_filter ) @@ -361,13 +127,13 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: ] # Handle build flags - include_dir_flags, build_flags = _split_list_by_condition( + include_dir_flags, build_flags = split_list_by_condition( build_flags, lambda a: a[2:].strip() if a.startswith("-I") else None ) - link_directories, build_flags = _split_list_by_condition( + link_directories, build_flags = split_list_by_condition( build_flags, lambda a: a[2:].strip() if a.startswith("-L") else None ) - link_libraries, build_flags = _split_list_by_condition( + link_libraries, build_flags = split_list_by_condition( build_flags, lambda a: a[2:].strip() if a.startswith("-l") else None ) @@ -379,7 +145,7 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: ] # Split build_flags list into private and public lists - private_build_flags, public_build_flags = _split_list_by_condition( + private_build_flags, public_build_flags = split_list_by_condition( build_flags, lambda a: a if a.startswith("-W") else None ) @@ -453,6 +219,8 @@ def generate_idf_component_yml(component: IDFComponent) -> str: Returns: YAML string representation of ESP-IDF component configuration """ + from esphome import yaml_util + data = {} description = component.data.get("description") @@ -477,410 +245,24 @@ def generate_idf_component_yml(component: IDFComponent) -> str: return yaml_util.dump(data) -def _check_library_data(data: dict): - """ - Check if a library data is compatible with the ESP-IDF framework. - - A platform mismatch (e.g. an AVR-only library on ESP32) raises - ``InvalidIDFComponent`` so the caller skips the library. A framework - mismatch only logs a warning — PIO manifests often understate the - frameworks they actually compile under, and IDF (unlike PIO's - ``lib_compat_mode``) has no opt-out, so we include the library anyway. - - Args: - data: PIO library manifest dict being processed. - - Raises: - InvalidIDFComponent: If the library does not support the ESP32 platform. - """ - platforms = data.get("platforms", "*") - if isinstance(platforms, str): - platforms = [a.strip() for a in platforms.split(",")] - platforms = _ensure_list(platforms) - - # Check if library supports ESP-IDF platform - valid_platforms = "*" in platforms or ESP32_PLATFORM in platforms - - if not valid_platforms: - raise InvalidIDFComponent(f"Unsupported library platforms: {platforms}") - - frameworks = data.get("frameworks", "*") - if isinstance(frameworks, str): - frameworks = [a.strip() for a in frameworks.split(",")] - frameworks = _ensure_list(frameworks) - - # Check if library declares the active framework. PIO library manifests - # often list only "arduino" even when the library actually compiles fine - # under ESP-IDF, and IDF (unlike PIO with `lib_compat_mode`) has no way to - # opt out of the check. Warn instead of failing so the user isn't forced to - # fork the library to fix the manifest. - framework = "arduino" if CORE.using_arduino else "espidf" - valid_framework = "*" in frameworks or framework in frameworks - - if not valid_framework: - _LOGGER.warning( - "Library %s declares frameworks %s that do not include '%s'; including anyway", - data.get("name", ""), - frameworks, - framework, - ) - - -def _parse_library_json(library_json_path: PathType): - """ - Load and parse a JSON file describing a library. - - Args: - library_json_path (PathType): Path to the JSON file. - - Returns: - dict: Parsed JSON content as a Python dictionary. - """ - with Path(library_json_path).open(encoding="utf8") as fp: - return json.load(fp) - - -def _parse_library_properties(library_properties_path: PathType): - """ - Parse a key-value platformio .properties style file into a dictionary. - - Args: - library_properties_path (PathType): Path to the properties file. - - Returns: - dict[str, str]: Mapping of parsed property keys to values. - """ - with Path(library_properties_path).open(encoding="utf8") as fp: - data = {} - for line in fp.read().splitlines(): - line = line.strip() - if not line or "=" not in line: - continue - # skip comments - if line.startswith("#"): - continue - key, value = line.split("=", 1) - if not value.strip(): - continue - data[key.strip()] = value.strip() - return data - - -def _make_registry_client() -> Any: - """Create a minimal PlatformIO registry client with no system filtering. - - ``is_system_compatible`` is forced True so version selection is driven purely - by the requested version requirements -- ESP-IDF/target compatibility is - handled elsewhere, not by the PlatformIO registry. - """ - from platformio.package.manager._registry import PackageManagerRegistryMixin - - class _Registry(PackageManagerRegistryMixin): - def __init__(self) -> None: - self._registry_client = None - self.pkg_type = "library" - - @staticmethod - def is_system_compatible(value: Any, custom_system: Any = None) -> bool: - return True - - return _Registry() - - -def _resolve_registry_version( - owner: str | None, pkgname: str, requirements: set[str] -) -> tuple[str, str, str, str]: - """Resolve a registry package to the single highest version satisfying ALL - the given requirements; return ``(owner, name, version, download_url)``. - - Intersecting every requirement (rather than resolving each consumer in - isolation) makes the result independent of processing order and guarantees - no stated constraint is violated -- e.g. ``esphome/libsodium`` requested as - both ``==1.10021.0`` and ``^1.10018.1`` resolves to ``1.10021.0``. - """ - from platformio.package.meta import PackageSpec - - registry = _make_registry_client() - package = registry.fetch_registry_package(PackageSpec(owner=owner, name=pkgname)) - owner = package["owner"]["username"] - name = package["name"] - - # Chaining the per-requirement filter intersects all constraints. - versions = package.get("versions") or [] - for requirement in sorted(requirements): - versions = registry.get_compatible_registry_versions( - versions, PackageSpec(owner=owner, name=name, requirements=requirement) - ) - if not versions: - raise RuntimeError( - f"No version of {owner}/{name} satisfies all requirements " - f"{sorted(requirements)} requested across the library tree" - ) - - best = registry.pick_best_registry_version(versions) - pkgfile = registry.pick_compatible_pkg_file(best["files"]) - if not pkgfile: - raise RuntimeError(f"No package file for {owner}/{name}@{best['name']}") - return owner, name, best["name"], pkgfile["download_url"] - - -def _normalize_dependencies(dependencies: Any) -> list[dict]: - """Normalize a library manifest's ``dependencies`` to a list of dicts. - - PIO's library.json accepts both the list-of-dicts form and the shorthand - dict form (``{"owner/Name": "version_spec"}``); normalize the latter so - callers see a uniform list. - """ - if not dependencies: - return [] - if isinstance(dependencies, dict): - normalized = [] - for raw_name, spec in dependencies.items(): - if "/" in raw_name: - owner, pkgname = raw_name.split("/", 1) - else: - owner, pkgname = None, raw_name - entry = {"name": pkgname, "owner": owner} - if isinstance(spec, dict): - entry.update(spec) - else: - entry["version"] = spec - normalized.append(entry) - return normalized - return [d for d in dependencies if isinstance(d, dict)] - - -@dataclass -class _LibNode: - """A node in the library dependency graph being resolved as a batch.""" - - key: str - is_git: bool - owner: str | None = None - pkgname: str | None = None - requirements: set[str] = field(default_factory=set) - url: str | None = None - ref: str | None = None - edges: set[str] = field(default_factory=set) - - -def _node_key( - name: str | None, version: str | None, repository: str | None -) -> tuple[str, bool, tuple[str | None, str | None]]: - """Return ``(key, is_git, locator)`` for a library or dependency spec. - - The key is derived from the *input* spec (the registry name as written, or - the git URL path), not the resolved canonical name. So a package referenced - inconsistently -- bare ``name`` vs ``owner/name``, or git vs registry -- maps - to distinct keys and isn't deduplicated; ``generate_idf_components`` warns - about that after resolution rather than merging the nodes. - """ - if repository: - split_result = urlsplit(repository) - key = str(split_result.path).strip("/").removesuffix(".git") - ref = split_result.fragment.strip() or None - url = urlunsplit(split_result._replace(fragment="")) - return key, True, (url, ref) - if name and "/" in name: - owner, pkgname = name.split("/", 1) - else: - owner, pkgname = None, name - return name, False, (owner, pkgname) +def _emit_idf_component(component: IDFComponent) -> None: + """Write the ESP-IDF build files for a resolved library into its cache dir.""" + _apply_extra_script(component) + write_file_if_changed( + component.path / "CMakeLists.txt", + generate_cmakelists_txt(component), + ) + write_file_if_changed( + component.path / "idf_component.yml", + generate_idf_component_yml(component), + ) def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: - """Resolve and convert a batch of PlatformIO libraries to IDF components. - - Resolves the whole set together rather than each library independently: it - walks the dependency graph collecting every version *requirement* per - component name, then resolves each name once to a single version satisfying - all of them. So a transitive dependency shared under - different specs (e.g. ``esphome/libsodium``, pulled by both ``noise-c`` and - ``esp_wireguard``) becomes one component instead of two clashing - ``override_path`` entries -- order-independently, and without ever violating - a stated constraint. - - The returned list holds the top-level components (those directly requested); - transitive dependencies are converted too and wired into each component's - generated manifest. - - ``lib_ignore`` from ``esphome->platformio_options`` excludes libraries by - short name (part after the ``/``), matched against both the top-level - libraries and every dependency discovered during the graph walk. - """ - nodes: dict[str, _LibNode] = {} - - lib_ignore = { - name.split("/")[-1].lower() - for name in CORE.platformio_options.get("lib_ignore", []) - } - - # The generated CMakeLists.txt/idf_component.yml inside the shared cache - # bake in the dependency wiring, which lib_ignore changes; salt the cache - # path so configs with different lib_ignore values don't fight over (and - # constantly rewrite) the same converted component files. - salt = ( - hashlib.sha256(",".join(sorted(lib_ignore)).encode()).hexdigest()[:8] - if lib_ignore - else "" + """Resolve and convert a batch of PlatformIO libraries to IDF components.""" + backend = LibraryBackend( + platform=ESP32_PLATFORM, + framework=_idf_framework(), + emit=_emit_idf_component, ) - - def is_ignored(name: str | None) -> bool: - if not lib_ignore or name is None: - return False - return name.split("/")[-1].lower() in lib_ignore - - def add_spec(name: str | None, version: str | None, repository: str | None) -> str: - key, is_git, locator = _node_key(name, version, repository) - node = nodes.get(key) or _LibNode(key=key, is_git=is_git) - nodes[key] = node - if is_git: - node.is_git = True - node.url, node.ref = locator - else: - node.owner, node.pkgname = locator - if version: - node.requirements.add(version) - return key - - top_level = [ - add_spec(library.name, library.version, library.repository) - for library in libraries - if not is_ignored(library.name) - ] - - # Collect + resolve to a fixpoint: a node is (re)resolved whenever its - # requirement set has grown since the last time, so every requirement in the - # graph is accounted for before conversion. - components: dict[str, IDFComponent] = {} - resolved_requirements: dict[str, frozenset[str]] = {} - top_level_keys = set(top_level) - worklist = deque(dict.fromkeys(top_level)) - while worklist: - key = worklist.popleft() - node = nodes[key] - - # A node is queued once per referring edge; skip the (uncached) registry - # lookup + download + dependency walk unless its requirement set grew - # since the last resolve. Requirements only ever grow, so this still - # converges the fixpoint and terminates dependency cycles. - requirements = frozenset(node.requirements) - if resolved_requirements.get(key) == requirements: - continue - resolved_requirements[key] = requirements - - if node.is_git: - component = IDFComponent(key, "*", GitSource(node.url, node.ref)) - else: - owner, name, version, url = _resolve_registry_version( - node.owner, node.pkgname, node.requirements - ) - component = IDFComponent( - _owner_pkgname_to_name(owner, name), version, URLSource(url) - ) - component.download(salt=salt) - - library_json_path = component.path / "library.json" - library_properties_path = component.path / "library.properties" - if library_json_path.is_file(): - component.data = _parse_library_json(library_json_path) - elif library_properties_path.is_file(): - component.data = _parse_library_properties(library_properties_path) - else: - raise RuntimeError( - f"Invalid PIO library {key}: missing library.json and " - "library.properties" - ) - - try: - _check_library_data(component.data) - except InvalidIDFComponent as e: - # Skip an incompatible transitive dependency, but fail fast if a - # top-level library the build explicitly requested is incompatible. - if key in top_level_keys: - raise RuntimeError( - f"Requested library {key} is not compatible with ESP-IDF: {e}" - ) from e - _LOGGER.debug("Skip incompatible dependency %s: %s", key, str(e)) - continue - components[key] = component - - # Requirements changed (we got past the short-circuit above), so - # (re)walk this component's dependencies. - node.edges = set() - for dependency in _normalize_dependencies(component.data.get("dependencies")): - if "name" not in dependency or "version" not in dependency: - continue - try: - _check_library_data(dependency) - except InvalidIDFComponent as e: - _LOGGER.debug("Skip dependency %s: %s", dependency.get("name"), str(e)) - continue - dep_name = _owner_pkgname_to_name( - dependency.get("owner"), dependency.get("name") - ) - if is_ignored(dep_name): - _LOGGER.debug("Skip ignored dependency %s", dep_name) - continue - # The version field may actually be a URL (git/archive dependency). - dep_version = dependency["version"] - dep_url = None - try: - parsed = urlparse(dep_version) - if all([parsed.scheme, parsed.netloc]): - dep_url, dep_version = dep_version, None - except (TypeError, ValueError): - pass - dep_key = add_spec(dep_name, dep_version, dep_url) - node.edges.add(dep_key) - worklist.append(dep_key) - - # A git source wins over any registry version requested for the same - # component. That's intentional, but warn so a dropped registry pin isn't a - # silent surprise. - for node in nodes.values(): - if node.is_git and node.requirements: - _LOGGER.warning( - "Library %s is requested both from a git source (%s) and as " - "registry version(s) %s; using the git source.", - node.key, - node.url, - sorted(node.requirements), - ) - - # Two graph nodes that resolve to the same component name (e.g. a package - # referenced both bare and as ``owner/name``) are not deduplicated and can - # produce conflicting component definitions. Warn so it's not silent. - canonical_keys: dict[str, str] = {} - for node_key, component in components.items(): - canonical = component.get_sanitized_name() - if canonical_keys.setdefault(canonical, node_key) != node_key: - _LOGGER.warning( - "Library %s is referenced under multiple names (%s and %s); these " - "are not deduplicated. Reference it consistently as %s.", - canonical, - canonical_keys[canonical], - node_key, - canonical, - ) - - # Wire each component's dependencies to the single resolved instances, then - # regenerate build files. - for key, component in components.items(): - component.dependencies = [ - components[dep_key] - for dep_key in sorted(nodes[key].edges) - if dep_key in components - ] - for component in components.values(): - _apply_extra_script(component) - write_file_if_changed( - component.path / "CMakeLists.txt", - generate_cmakelists_txt(component), - ) - write_file_if_changed( - component.path / "idf_component.yml", - generate_idf_component_yml(component), - ) - - return [components[key] for key in top_level if key in components] + return convert_libraries(libraries, backend) diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py new file mode 100644 index 0000000000..43282c7aa0 --- /dev/null +++ b/esphome/platformio/library.py @@ -0,0 +1,717 @@ +"""Toolchain-agnostic PlatformIO library converter. + +Resolves a batch of PlatformIO/Arduino library specs (added via +``cg.add_library(...)``) into local, build-ready directories: it fetches each +library (registry/git/url), parses its ``library.json`` / ``library.properties`` +manifest, resolves the whole dependency graph to a single version per name, and +caches the result under ``/pio_components``. + +The toolchain-specific part — turning a resolved library into build files +(ESP-IDF ``idf_component_register`` CMakeLists, or a Zephyr module) — is supplied +by a :class:`LibraryBackend`. This module owns everything that is the same +regardless of which toolchain consumes the result. +""" + +from collections import deque +from collections.abc import Callable +from dataclasses import dataclass, field +import glob +import hashlib +import itertools +import json +import logging +import os +from pathlib import Path +import re +import tempfile +from typing import Any, TypeVar +from urllib.parse import urlparse, urlsplit, urlunsplit + +from esphome import git +from esphome.core import CORE, Library +from esphome.framework_helpers import archive_extract_all, download_from_mirrors, rmdir + +_LOGGER = logging.getLogger(__name__) + +PathType = str | os.PathLike + +# +# Constants from platformio +# + +FILTER_REGEX = re.compile(r"([+-])<([^>]+)>") +DEFAULT_BUILD_SRC_FILTER = ( + "+<*> -<.git/> -<.svn/> - - - -" +) +DEFAULT_BUILD_SRC_DIRS = "src" +DEFAULT_BUILD_INCLUDE_DIR = "include" +DEFAULT_BUILD_FLAGS = [] +SRC_FILE_EXTENSIONS = [ + ".c", + ".cpp", + ".cc", + ".cxx", + ".c++", + ".S", + ".spp", + ".SPP", + ".sx", + ".s", + ".asm", + ".ASM", +] + +DOMAIN = "pio_components" + +ESPHOME_DATA_KEY = "ESPHOME" +ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE" + + +class Source: + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: + raise NotImplementedError + + +class URLSource(Source): + def __init__(self, url: str): + self.url = url + + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: + base_dir = Path(CORE.data_dir) / DOMAIN + h = hashlib.new("sha256") + h.update(self.url.encode()) + if salt: + h.update(salt.encode()) + path = base_dir / h.hexdigest()[:8] / dir_suffix + # Marker file written last to signal a complete extraction. Using a + # marker (instead of just `path.is_dir()`) means an interrupted + # extraction is correctly detected and re-run on the next invocation, + # and lets us extract directly into ``path`` — avoiding a + # post-extraction rename that races with antivirus on Windows. + extracted_marker = path / ".esphome_extracted" + if not extracted_marker.is_file() or force: + rmdir(path, msg=f"Clean up library directory {path}") + + # Download in temporary file + with tempfile.NamedTemporaryFile() as tmp: + _LOGGER.info("Downloading %s ...", self.url) + _LOGGER.debug("Location: %s", path) + + download_from_mirrors([self.url], {}, tmp.file) + + _LOGGER.debug("Extracting archive to %s ...", path) + archive_extract_all(tmp.file, path) + extracted_marker.touch() + return path + + def __str__(self): + return self.url + + +class GitSource(Source): + def __init__(self, url: str, ref: str | None): + self.url = url + self.ref = ref + + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: + path, _ = git.clone_or_update( + url=self.url, + ref=self.ref, + refresh=git.NEVER_REFRESH if not force else None, + domain=f"{DOMAIN}/{salt}" if salt else DOMAIN, + submodules=[], + subpath=Path(dir_suffix), + ) + return path + + def __str__(self): + return f"{self.url}#{self.ref}" if self.ref else self.url + + +class InvalidLibrary(Exception): + pass + + +class ConvertedLibrary: + """A resolved PlatformIO library plus its parsed manifest and on-disk path. + + Toolchain-neutral: ESP-IDF treats it as a component, Zephyr as a module. The + backend reads ``name``/``version``/``data``/``dependencies``/``path`` to emit + its build files. + """ + + def __init__(self, name: str, version: str, source: Source | None): + self.name = name + self.version = version + self.source = source + self.data = {} + self.dependencies: list[ConvertedLibrary] = [] + self._path: Path | None = None + + def __str__(self): + return f"{self.name}@{self.version}={self.source}" + + @property + def path(self) -> Path: + if self._path is None: + raise RuntimeError(f"path not set for library {self}") + return self._path + + @path.setter + def path(self, value: Path) -> None: + self._path = value + + def get_sanitized_name(self): + return re.sub(r"[^a-zA-Z0-9_.\-/]", "_", self.name) + + def get_require_name(self): + return self.get_sanitized_name().replace("/", "__") + + def download(self, force: bool = False, salt: str = ""): + """Fetch the library into the shared cache and record its ``path``. + + The cache directory is named after the sanitized library name; backends + rely on that name to identify the unit they build (e.g. ESP-IDF uses the + directory name as the component name, replacing ``/`` with ``__`` via + ``get_require_name``). + """ + self.path = self.source.download( + self.get_sanitized_name(), force=force, salt=salt + ) + + +@dataclass +class LibraryBackend: + """Toolchain hooks for :func:`convert_libraries`. + + ``platform``/``framework`` drive the manifest compatibility check. + ``emit`` writes the toolchain-specific build files into a resolved library's + ``path`` (e.g. the ESP-IDF ``CMakeLists.txt`` + ``idf_component.yml``, or a + Zephyr ``module.yml`` + ``CMakeLists.txt``). + """ + + platform: str + framework: str + emit: Callable[["ConvertedLibrary"], None] + + +T = TypeVar("T") + + +def ensure_list(obj: T | list[T]) -> list[T]: + """ + Convert an object to a list if it isn't already a list. + + Args: + obj: Object that may or may not already be a list. + + Returns: + list[T]: The original list if ``obj`` is a list, otherwise a single-item + list containing ``obj``. + """ + return [obj] if not isinstance(obj, list) else obj + + +def _owner_pkgname_to_name(owner: str | None, pkgname: str) -> str: + """ + Convert owner and package name to a standardized component name. + + This function combines owner and package name with a forward slash when + both are provided, otherwise returns just the package name. + + Args: + owner: The owner/username of the package (can be None) + pkgname: The name of the package + + Returns: + str: The standardized component name in "owner/pkgname" format or just "pkgname" + """ + return f"{owner}/{pkgname}" if owner else pkgname + + +def collect_filtered_files(src_dir: PathType, src_filters: list[str]) -> list[str]: + """ + Recursively match files in a directory according to include/exclude patterns. + + This function processes a list of filter strings that indicate which files + to include or exclude. Each filter is parsed into patterns with a sign: + '+' for inclusion and '-' for exclusion. Directory patterns ending with '/' + are normalized to include all their contents recursively. + + Args: + src_dir (PathType): Root directory to search within. + src_filters (list[str]): List of filter strings, which may contain multiple + patterns. Each pattern can start with '+' or '-' to indicate inclusion + or exclusion. + + Returns: + list[str]: List of matched file paths as strings. Only files (not directories) + are returned, even if a directory matches a pattern. + """ + matches = list( + itertools.chain.from_iterable( + FILTER_REGEX.findall(src_filter) for src_filter in src_filters + ) + ) + + selected = set() + + for sign, pattern in matches: + pattern = pattern.strip() + + if pattern.endswith("/"): + pattern = pattern.rstrip("/") + "/**" + + # glob.escape has no pathlib equivalent and the matcher works on raw + # path strings, so PTH118/PTH207 don't apply here. + full_pattern = os.path.join(glob.escape(str(src_dir)), pattern) # noqa: PTH118 + + matched = [] + for item in glob.glob(full_pattern, recursive=True): # noqa: PTH207 + if not Path(item).is_dir(): + matched.append(item) + else: + # PlatformIO quirk: a directory matched with "*" should include all its + # nested files and subdirectories, not just the directory itself. + for root, _, files in os.walk(item): + matched.extend([str(Path(root) / f) for f in files]) + + # FILTER_REGEX only ever captures "+" or "-", so the else is the "-" case. + if sign == "+": + selected.update(matched) + else: + selected.difference_update(matched) + + return [r for r in selected if Path(r).is_file()] + + +def split_list_by_condition( + items: list[str], match_fn: Callable[[str], str | None] +) -> tuple[list[str], list[str]]: + """ + Splits a list into two lists based on a matching function. + + Args: + items: List of items to split. + match_fn: Function that returns a value for items that should go into the "matched" list. + + Returns: + A tuple (matched, non_matched) + """ + matched = [] + non_matched = [] + for item in items: + result = match_fn(item) + if result: + matched.append(result) + else: + non_matched.append(item) + return matched, non_matched + + +def check_library_data(data: dict, platform: str, framework: str): + """ + Check whether a library manifest is compatible with the target toolchain. + + A platform mismatch (e.g. an AVR-only library on ESP32) raises + ``InvalidLibrary`` so the caller skips the library. A framework mismatch only + logs a warning — PIO manifests often understate the frameworks they actually + compile under, and there's no opt-out at this layer, so we include the library + anyway. + + Args: + data: PIO library manifest dict being processed. + platform: The PlatformIO platform token the build targets (e.g. + ``espressif32``). + framework: The active framework name (e.g. ``espidf``, ``arduino``, + ``zephyr``) the manifest is expected to declare. + + Raises: + InvalidLibrary: If the library does not support the target platform. + """ + platforms = data.get("platforms", "*") + if isinstance(platforms, str): + platforms = [a.strip() for a in platforms.split(",")] + platforms = ensure_list(platforms) + + # Check if library supports the target platform + valid_platforms = "*" in platforms or platform in platforms + + if not valid_platforms: + raise InvalidLibrary(f"Unsupported library platforms: {platforms}") + + frameworks = data.get("frameworks", "*") + if isinstance(frameworks, str): + frameworks = [a.strip() for a in frameworks.split(",")] + frameworks = ensure_list(frameworks) + + # Check if library declares the active framework. PIO library manifests + # often list only "arduino" even when the library actually compiles fine + # under the target framework, and there's no way to opt out of the check at + # this layer. Warn instead of failing so the user isn't forced to fork the + # library to fix the manifest. + valid_framework = "*" in frameworks or framework in frameworks + + if not valid_framework: + _LOGGER.warning( + "Library %s declares frameworks %s that do not include '%s'; including anyway", + data.get("name", ""), + frameworks, + framework, + ) + + +def _parse_library_json(library_json_path: PathType): + """ + Load and parse a JSON file describing a library. + + Args: + library_json_path (PathType): Path to the JSON file. + + Returns: + dict: Parsed JSON content as a Python dictionary. + """ + with Path(library_json_path).open(encoding="utf8") as fp: + return json.load(fp) + + +def _parse_library_properties(library_properties_path: PathType): + """ + Parse a key-value platformio .properties style file into a dictionary. + + Args: + library_properties_path (PathType): Path to the properties file. + + Returns: + dict[str, str]: Mapping of parsed property keys to values. + """ + with Path(library_properties_path).open(encoding="utf8") as fp: + data = {} + for line in fp.read().splitlines(): + line = line.strip() + if not line or "=" not in line: + continue + # skip comments + if line.startswith("#"): + continue + key, value = line.split("=", 1) + if not value.strip(): + continue + data[key.strip()] = value.strip() + return data + + +def _make_registry_client() -> Any: + """Create a minimal PlatformIO registry client with no system filtering. + + ``is_system_compatible`` is forced True so version selection is driven purely + by the requested version requirements -- target compatibility is handled + elsewhere, not by the PlatformIO registry. + """ + from platformio.package.manager._registry import PackageManagerRegistryMixin + + class _Registry(PackageManagerRegistryMixin): + def __init__(self) -> None: + self._registry_client = None + self.pkg_type = "library" + + @staticmethod + def is_system_compatible(value: Any, custom_system: Any = None) -> bool: + return True + + return _Registry() + + +def _resolve_registry_version( + owner: str | None, pkgname: str, requirements: set[str] +) -> tuple[str, str, str, str]: + """Resolve a registry package to the single highest version satisfying ALL + the given requirements; return ``(owner, name, version, download_url)``. + + Intersecting every requirement (rather than resolving each consumer in + isolation) makes the result independent of processing order and guarantees + no stated constraint is violated -- e.g. ``esphome/libsodium`` requested as + both ``==1.10021.0`` and ``^1.10018.1`` resolves to ``1.10021.0``. + """ + from platformio.package.meta import PackageSpec + + registry = _make_registry_client() + package = registry.fetch_registry_package(PackageSpec(owner=owner, name=pkgname)) + owner = package["owner"]["username"] + name = package["name"] + + # Chaining the per-requirement filter intersects all constraints. + versions = package.get("versions") or [] + for requirement in sorted(requirements): + versions = registry.get_compatible_registry_versions( + versions, PackageSpec(owner=owner, name=name, requirements=requirement) + ) + if not versions: + raise RuntimeError( + f"No version of {owner}/{name} satisfies all requirements " + f"{sorted(requirements)} requested across the library tree" + ) + + best = registry.pick_best_registry_version(versions) + pkgfile = registry.pick_compatible_pkg_file(best["files"]) + if not pkgfile: + raise RuntimeError(f"No package file for {owner}/{name}@{best['name']}") + return owner, name, best["name"], pkgfile["download_url"] + + +def _normalize_dependencies(dependencies: Any) -> list[dict]: + """Normalize a library manifest's ``dependencies`` to a list of dicts. + + PIO's library.json accepts both the list-of-dicts form and the shorthand + dict form (``{"owner/Name": "version_spec"}``); normalize the latter so + callers see a uniform list. + """ + if not dependencies: + return [] + if isinstance(dependencies, dict): + normalized = [] + for raw_name, spec in dependencies.items(): + if "/" in raw_name: + owner, pkgname = raw_name.split("/", 1) + else: + owner, pkgname = None, raw_name + entry = {"name": pkgname, "owner": owner} + if isinstance(spec, dict): + entry.update(spec) + else: + entry["version"] = spec + normalized.append(entry) + return normalized + return [d for d in dependencies if isinstance(d, dict)] + + +@dataclass +class _LibNode: + """A node in the library dependency graph being resolved as a batch.""" + + key: str + is_git: bool + owner: str | None = None + pkgname: str | None = None + requirements: set[str] = field(default_factory=set) + url: str | None = None + ref: str | None = None + edges: set[str] = field(default_factory=set) + + +def _node_key( + name: str | None, version: str | None, repository: str | None +) -> tuple[str, bool, tuple[str | None, str | None]]: + """Return ``(key, is_git, locator)`` for a library or dependency spec. + + The key is derived from the *input* spec (the registry name as written, or + the git URL path), not the resolved canonical name. So a package referenced + inconsistently -- bare ``name`` vs ``owner/name``, or git vs registry -- maps + to distinct keys and isn't deduplicated; ``convert_libraries`` warns about + that after resolution rather than merging the nodes. + """ + if repository: + split_result = urlsplit(repository) + key = str(split_result.path).strip("/").removesuffix(".git") + ref = split_result.fragment.strip() or None + url = urlunsplit(split_result._replace(fragment="")) + return key, True, (url, ref) + if name and "/" in name: + owner, pkgname = name.split("/", 1) + else: + owner, pkgname = None, name + return name, False, (owner, pkgname) + + +def convert_libraries( + libraries: list[Library], backend: LibraryBackend +) -> list[ConvertedLibrary]: + """Resolve and convert a batch of PlatformIO libraries for ``backend``. + + Resolves the whole set together rather than each library independently: it + walks the dependency graph collecting every version *requirement* per + component name, then resolves each name once to a single version satisfying + all of them. So a transitive dependency shared under + different specs (e.g. ``esphome/libsodium``, pulled by both ``noise-c`` and + ``esp_wireguard``) becomes one component instead of two clashing + ``override_path`` entries -- order-independently, and without ever violating + a stated constraint. + + The returned list holds the top-level components (those directly requested); + transitive dependencies are converted too and wired into each component's + generated manifest. ``backend.emit`` is called once per converted library to + write its toolchain-specific build files. + + ``lib_ignore`` from ``esphome->platformio_options`` excludes libraries by + short name (part after the ``/``), matched against both the top-level + libraries and every dependency discovered during the graph walk. + """ + nodes: dict[str, _LibNode] = {} + + lib_ignore = { + name.split("/")[-1].lower() + for name in CORE.platformio_options.get("lib_ignore", []) + } + + # The generated build files inside the shared cache bake in the dependency + # wiring, which lib_ignore changes; salt the cache path so configs with + # different lib_ignore values don't fight over (and constantly rewrite) the + # same converted component files. + salt = ( + hashlib.sha256(",".join(sorted(lib_ignore)).encode()).hexdigest()[:8] + if lib_ignore + else "" + ) + + def is_ignored(name: str | None) -> bool: + if not lib_ignore or name is None: + return False + return name.split("/")[-1].lower() in lib_ignore + + def add_spec(name: str | None, version: str | None, repository: str | None) -> str: + key, is_git, locator = _node_key(name, version, repository) + node = nodes.get(key) or _LibNode(key=key, is_git=is_git) + nodes[key] = node + if is_git: + node.is_git = True + node.url, node.ref = locator + else: + node.owner, node.pkgname = locator + if version: + node.requirements.add(version) + return key + + top_level = [ + add_spec(library.name, library.version, library.repository) + for library in libraries + if not is_ignored(library.name) + ] + + # Collect + resolve to a fixpoint: a node is (re)resolved whenever its + # requirement set has grown since the last time, so every requirement in the + # graph is accounted for before conversion. + components: dict[str, ConvertedLibrary] = {} + resolved_requirements: dict[str, frozenset[str]] = {} + top_level_keys = set(top_level) + worklist = deque(dict.fromkeys(top_level)) + while worklist: + key = worklist.popleft() + node = nodes[key] + + # A node is queued once per referring edge; skip the (uncached) registry + # lookup + download + dependency walk unless its requirement set grew + # since the last resolve. Requirements only ever grow, so this still + # converges the fixpoint and terminates dependency cycles. + requirements = frozenset(node.requirements) + if resolved_requirements.get(key) == requirements: + continue + resolved_requirements[key] = requirements + + if node.is_git: + component = ConvertedLibrary(key, "*", GitSource(node.url, node.ref)) + else: + owner, name, version, url = _resolve_registry_version( + node.owner, node.pkgname, node.requirements + ) + component = ConvertedLibrary( + _owner_pkgname_to_name(owner, name), version, URLSource(url) + ) + component.download(salt=salt) + + library_json_path = component.path / "library.json" + library_properties_path = component.path / "library.properties" + if library_json_path.is_file(): + component.data = _parse_library_json(library_json_path) + elif library_properties_path.is_file(): + component.data = _parse_library_properties(library_properties_path) + else: + raise RuntimeError( + f"Invalid PIO library {key}: missing library.json and " + "library.properties" + ) + + try: + check_library_data(component.data, backend.platform, backend.framework) + except InvalidLibrary as e: + # Skip an incompatible transitive dependency, but fail fast if a + # top-level library the build explicitly requested is incompatible. + if key in top_level_keys: + raise RuntimeError( + f"Requested library {key} is not compatible with " + f"{backend.framework}: {e}" + ) from e + _LOGGER.debug("Skip incompatible dependency %s: %s", key, str(e)) + continue + components[key] = component + + # Requirements changed (we got past the short-circuit above), so + # (re)walk this component's dependencies. + node.edges = set() + for dependency in _normalize_dependencies(component.data.get("dependencies")): + if "name" not in dependency or "version" not in dependency: + continue + try: + check_library_data(dependency, backend.platform, backend.framework) + except InvalidLibrary as e: + _LOGGER.debug("Skip dependency %s: %s", dependency.get("name"), str(e)) + continue + dep_name = _owner_pkgname_to_name( + dependency.get("owner"), dependency.get("name") + ) + if is_ignored(dep_name): + _LOGGER.debug("Skip ignored dependency %s", dep_name) + continue + # The version field may actually be a URL (git/archive dependency). + dep_version = dependency["version"] + dep_url = None + try: + parsed = urlparse(dep_version) + if all([parsed.scheme, parsed.netloc]): + dep_url, dep_version = dep_version, None + except (TypeError, ValueError): + pass + dep_key = add_spec(dep_name, dep_version, dep_url) + node.edges.add(dep_key) + worklist.append(dep_key) + + # A git source wins over any registry version requested for the same + # component. That's intentional, but warn so a dropped registry pin isn't a + # silent surprise. + for node in nodes.values(): + if node.is_git and node.requirements: + _LOGGER.warning( + "Library %s is requested both from a git source (%s) and as " + "registry version(s) %s; using the git source.", + node.key, + node.url, + sorted(node.requirements), + ) + + # Two graph nodes that resolve to the same component name (e.g. a package + # referenced both bare and as ``owner/name``) are not deduplicated and can + # produce conflicting component definitions. Warn so it's not silent. + canonical_keys: dict[str, str] = {} + for node_key, component in components.items(): + canonical = component.get_sanitized_name() + if canonical_keys.setdefault(canonical, node_key) != node_key: + _LOGGER.warning( + "Library %s is referenced under multiple names (%s and %s); these " + "are not deduplicated. Reference it consistently as %s.", + canonical, + canonical_keys[canonical], + node_key, + canonical, + ) + + # Wire each component's dependencies to the single resolved instances, then + # emit build files. + for key, component in components.items(): + component.dependencies = [ + components[dep_key] + for dep_key in sorted(nodes[key].edges) + if dep_key in components + ] + for component in components.values(): + backend.emit(component) + + return [components[key] for key in top_level if key in components] diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 87e168dc94..d43a1d5276 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -14,23 +14,23 @@ from esphome.const import ( Platform, ) from esphome.core import CORE, Library -import esphome.espidf.component from esphome.espidf.component import ( + generate_cmakelists_txt, + generate_idf_component_yml, + generate_idf_components, +) +import esphome.platformio.library +from esphome.platformio.library import ( + ConvertedLibrary as IDFComponent, GitSource, - IDFComponent, - InvalidIDFComponent, URLSource, - _check_library_data, - _collect_filtered_files, _node_key, _normalize_dependencies, _parse_library_json, _parse_library_properties, _resolve_registry_version, - _split_list_by_condition, - generate_cmakelists_txt, - generate_idf_component_yml, - generate_idf_components, + collect_filtered_files, + split_list_by_condition, ) @@ -70,7 +70,7 @@ def test_collect_filtered_files_basic(tmp_path): f2.parent.mkdir(parents=True) f2.write_text("int b;") - result = _collect_filtered_files(tmp_path, ["+<*>"]) + result = collect_filtered_files(tmp_path, ["+<*>"]) assert str(f1) in result assert str(f2) in result @@ -81,7 +81,7 @@ def test_collect_filtered_files_exclude(tmp_path): f1.write_text("int a;") f2.write_text("int b;") - result = _collect_filtered_files(tmp_path, ["+<*> -<*.cpp>"]) + result = collect_filtered_files(tmp_path, ["+<*> -<*.cpp>"]) assert str(f1) in result assert str(f2) not in result @@ -89,7 +89,7 @@ def test_collect_filtered_files_exclude(tmp_path): def test_split_list_by_condition(): items = ["-Iinclude", "-Llib", "-Wall"] - matched, rest = _split_list_by_condition( + matched, rest = split_list_by_condition( items, lambda x: x[2:] if x.startswith("-I") else None ) @@ -202,41 +202,6 @@ def test_generate_idf_component_yml_missing_path_raises(tmp_component): generate_idf_component_yml(tmp_component) -def test_check_library_data_valid(esp32_idf_core): - _check_library_data({"platforms": "*", "frameworks": "*"}) - - -def test_check_library_data_valid2(esp32_idf_core): - _check_library_data({"platforms": "*"}) - - -def test_check_library_data_valid3(esp32_idf_core): - _check_library_data({}) - - -def test_check_library_data_valid4(esp32_idf_core): - _check_library_data({"platforms": "espressif32", "frameworks": "*"}) - - -def test_check_library_data_valid5(esp32_idf_core): - _check_library_data({"platforms": "*", "frameworks": "espidf"}) - - -def test_check_library_data_invalid_platform(esp32_idf_core): - with pytest.raises(InvalidIDFComponent): - _check_library_data({"platforms": ["other"], "frameworks": "*"}) - - -def test_check_library_data_invalid_framework( - esp32_idf_core: None, caplog: pytest.LogCaptureFixture -) -> None: - # Framework mismatch is a warning, not a hard skip: the library is still - # included so that PIO manifests that only list "arduino" (but actually - # compile under IDF) can be used without forking them. - _check_library_data({"name": "lib", "platforms": "*", "frameworks": ["other"]}) - assert "do not include 'espidf'" in caplog.text - - def test_extra_script_captures_libpath_libs_and_defines(tmp_path): from esphome.espidf.extra_script import captured_as_build_flags, run_extra_script @@ -453,7 +418,7 @@ def _patch_registry(monkeypatch, versions): ``get_compatible_registry_versions`` / ``pick_best_registry_version`` run on the canned data so the intersection logic is exercised for real. """ - registry = esphome.espidf.component._make_registry_client() + registry = esphome.platformio.library._make_registry_client() monkeypatch.setattr( registry, "fetch_registry_package", @@ -467,7 +432,7 @@ def _patch_registry(monkeypatch, versions): }, ) monkeypatch.setattr( - esphome.espidf.component, "_make_registry_client", lambda: registry + esphome.platformio.library, "_make_registry_client", lambda: registry ) @@ -535,7 +500,7 @@ def test_generate_idf_components_dedupes_shared_dependency( return owner, pkgname, version, f"http://x/{pkgname}.tar.gz" monkeypatch.setattr( - esphome.espidf.component, "_resolve_registry_version", fake_resolve + esphome.platformio.library, "_resolve_registry_version", fake_resolve ) top = generate_idf_components( @@ -594,7 +559,7 @@ def test_generate_idf_components_lib_ignore_filters_top_level_and_dependencies( return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz" monkeypatch.setattr( - esphome.espidf.component, "_resolve_registry_version", fake_resolve + esphome.platformio.library, "_resolve_registry_version", fake_resolve ) # lib_ignore is read from CORE.platformio_options (stored there by # _add_platformio_options); matched by lowercase short name. @@ -640,7 +605,7 @@ def test_generate_idf_components_handles_dependency_cycle( monkeypatch.setattr(IDFComponent, "download", fake_download) monkeypatch.setattr( - esphome.espidf.component, + esphome.platformio.library, "_resolve_registry_version", lambda owner, pkgname, requirements: ( owner, @@ -697,7 +662,7 @@ def test_generate_idf_components_git_overrides_registry_warns( monkeypatch.setattr(IDFComponent, "download", fake_download) monkeypatch.setattr( - esphome.espidf.component, + esphome.platformio.library, "_resolve_registry_version", lambda owner, pkgname, requirements: ( owner, @@ -733,7 +698,7 @@ def test_generate_idf_components_missing_manifest_raises( monkeypatch.setattr(IDFComponent, "download", fake_download) monkeypatch.setattr( - esphome.espidf.component, + esphome.platformio.library, "_resolve_registry_version", lambda owner, pkgname, requirements: ( owner, @@ -777,7 +742,7 @@ def test_generate_idf_components_warns_on_noncanonical_duplicate( monkeypatch.setattr(IDFComponent, "download", fake_download) # Bare "shared" and "owner/shared" both resolve to canonical owner/shared. monkeypatch.setattr( - esphome.espidf.component, + esphome.platformio.library, "_resolve_registry_version", lambda owner, pkgname, requirements: ( owner or "owner", @@ -810,7 +775,7 @@ def test_generate_idf_components_incompatible_top_level_raises( monkeypatch.setattr(IDFComponent, "download", fake_download) monkeypatch.setattr( - esphome.espidf.component, + esphome.platformio.library, "_resolve_registry_version", lambda owner, pkgname, requirements: ( owner, @@ -820,7 +785,7 @@ def test_generate_idf_components_incompatible_top_level_raises( ), ) - with pytest.raises(RuntimeError, match="not compatible with ESP-IDF"): + with pytest.raises(RuntimeError, match="not compatible with espidf"): generate_idf_components([Library("esphome/A", "1.0.0", None)]) @@ -846,7 +811,7 @@ def test_generate_idf_components_incompatible_dependency_skipped( monkeypatch.setattr(IDFComponent, "download", fake_download) monkeypatch.setattr( - esphome.espidf.component, + esphome.platformio.library, "_resolve_registry_version", lambda owner, pkgname, requirements: ( owner, @@ -892,7 +857,7 @@ def test_git_source_salt_scopes_domain(monkeypatch: pytest.MonkeyPatch) -> None: return Path("/cloned"), None monkeypatch.setattr( - esphome.espidf.component.git, "clone_or_update", fake_clone_or_update + esphome.platformio.library.git, "clone_or_update", fake_clone_or_update ) source = GitSource("https://github.com/esphome/noise-c.git", "v1.0") diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py new file mode 100644 index 0000000000..55bc396c25 --- /dev/null +++ b/tests/unit_tests/test_platformio_library.py @@ -0,0 +1,229 @@ +"""Tests for the toolchain-agnostic PlatformIO library converter. + +Covers the shared download/parse/resolve/dependency-walk paths in +``esphome.platformio.library`` directly (the ESP-IDF and Zephyr backends are +exercised in their own test modules).""" + +import json +import logging +from pathlib import Path + +import pytest + +from esphome.core import Library +import esphome.platformio.library as lib +from esphome.platformio.library import ( + ConvertedLibrary, + GitSource, + InvalidLibrary, + LibraryBackend, + Source, + URLSource, + _resolve_registry_version, + check_library_data, + convert_libraries, +) + + +def _backend(emit=lambda component: None) -> LibraryBackend: + return LibraryBackend(platform="espressif32", framework="espidf", emit=emit) + + +def test_check_library_data_accepts_wildcards(): + check_library_data({"platforms": "*", "frameworks": "*"}, "espressif32", "espidf") + + +def test_check_library_data_accepts_missing_frameworks(): + check_library_data({"platforms": "*"}, "espressif32", "espidf") + + +def test_check_library_data_accepts_empty_manifest(): + check_library_data({}, "espressif32", "espidf") + + +def test_check_library_data_accepts_matching_platform(): + check_library_data( + {"platforms": "espressif32", "frameworks": "*"}, "espressif32", "espidf" + ) + + +def test_check_library_data_accepts_matching_framework(): + check_library_data( + {"platforms": "*", "frameworks": "espidf"}, "espressif32", "espidf" + ) + + +def test_check_library_data_rejects_unsupported_platform(): + with pytest.raises(InvalidLibrary): + check_library_data( + {"platforms": ["other"], "frameworks": "*"}, "espressif32", "espidf" + ) + + +def test_check_library_data_warns_on_framework_mismatch( + caplog: pytest.LogCaptureFixture, +): + # Framework mismatch is a warning, not a hard skip: the library is still + # included so manifests that only list "arduino" (but compile fine under the + # target framework) can be used without forking them. + with caplog.at_level(logging.WARNING, logger="esphome.platformio.library"): + check_library_data( + {"name": "lib", "platforms": "*", "frameworks": ["other"]}, + "espressif32", + "espidf", + ) + assert "do not include 'espidf'" in caplog.text + + +def test_source_download_not_implemented(): + with pytest.raises(NotImplementedError): + Source().download("x") + + +def test_gitsource_str_includes_ref_when_present(): + assert str(GitSource("http://git/repo.git", "main")) == "http://git/repo.git#main" + assert str(GitSource("http://git/repo.git", None)) == "http://git/repo.git" + + +def test_urlsource_download_extracts_then_reuses_marker(setup_core, monkeypatch): + monkeypatch.setattr(lib, "rmdir", lambda path, msg="": None) + dl_calls: list[list[str]] = [] + monkeypatch.setattr( + lib, "download_from_mirrors", lambda urls, headers, f: dl_calls.append(urls) + ) + + def fake_extract(fileobj, path): + Path(path).mkdir(parents=True, exist_ok=True) + + monkeypatch.setattr(lib, "archive_extract_all", fake_extract) + + src = URLSource("http://example.test/lib.tar.gz") + out = src.download("mylib") + + assert (out / ".esphome_extracted").is_file() + assert dl_calls == [["http://example.test/lib.tar.gz"]] + + # The completion marker means a second download is skipped (cache hit). + out2 = src.download("mylib") + assert out2 == out + assert len(dl_calls) == 1 + + +def test_resolve_registry_version_raises_without_pkg_file(monkeypatch): + registry = lib._make_registry_client() + monkeypatch.setattr( + registry, + "fetch_registry_package", + lambda spec: { + "owner": {"username": spec.owner or "owner"}, + "name": spec.name, + "versions": [{"name": "1.0.0", "files": [{}]}], + }, + ) + # A best version exists but none of its files is a compatible package. + monkeypatch.setattr( + registry, "pick_best_registry_version", lambda versions: versions[0] + ) + monkeypatch.setattr(registry, "pick_compatible_pkg_file", lambda files: None) + monkeypatch.setattr(lib, "_make_registry_client", lambda: registry) + + with pytest.raises(RuntimeError, match="No package file"): + _resolve_registry_version("owner", "pkg", set()) + + +def _patch_download_with_manifests(monkeypatch, tmp_path, manifests, *, properties=()): + """Fake ConvertedLibrary.download to materialize canned manifests on disk.""" + + def fake_download(self, force=False, salt=""): + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + self.path.mkdir(parents=True, exist_ok=True) + if self.name in properties: + (self.path / "library.properties").write_text(manifests[self.name]) + else: + (self.path / "library.json").write_text(json.dumps(manifests[self.name])) + + monkeypatch.setattr(ConvertedLibrary, "download", fake_download) + monkeypatch.setattr( + lib, + "_resolve_registry_version", + lambda owner, pkgname, requirements: ( + owner, + pkgname, + "1.0.0", + f"http://x/{pkgname}.tar.gz", + ), + ) + + +def test_convert_libraries_parses_library_properties(tmp_path, monkeypatch): + # A manifest provided as library.properties (Arduino style) instead of + # library.json must still be parsed and converted. + _patch_download_with_manifests( + monkeypatch, + tmp_path, + {"esphome/A": "name=A\nversion=1.0\n"}, + properties=("esphome/A",), + ) + + emitted: list[ConvertedLibrary] = [] + top = convert_libraries( + [Library("esphome/A", "1.0.0", None)], _backend(emitted.append) + ) + + assert [c.name for c in top] == ["esphome/A"] + assert top[0].data["name"] == "A" + assert emitted[0].data["version"] == "1.0" + + +def test_convert_libraries_skips_dependency_without_version(tmp_path, monkeypatch): + # A dependency entry lacking a version is malformed and silently skipped. + _patch_download_with_manifests( + monkeypatch, + tmp_path, + {"esphome/A": {"name": "A", "dependencies": [{"name": "C"}]}}, + ) + + # No version on the top-level spec exercises the "no requirement" path too. + top = convert_libraries([Library("esphome/A", None, None)], _backend()) + + assert top[0].dependencies == [] + + +def test_convert_libraries_handles_unparsable_dependency_version(tmp_path, monkeypatch): + # If the git/archive URL probe (urlparse) raises on a malformed value, the + # dependency is still kept and treated as a plain version spec. + _patch_download_with_manifests( + monkeypatch, + tmp_path, + { + "esphome/A": { + "name": "A", + # An unterminated IPv6 URL makes urlparse raise ValueError. + "dependencies": [{"name": "C", "version": "http://[::1"}], + }, + "C": {"name": "C"}, + }, + ) + + top = convert_libraries([Library("esphome/A", "1.0.0", None)], _backend()) + + assert [d.name for d in top[0].dependencies] == ["C"] + + +def test_convert_libraries_skips_incompatible_dependency(tmp_path, monkeypatch): + # A dependency that declares an incompatible platform is skipped (the + # top-level library still builds). + _patch_download_with_manifests( + monkeypatch, + tmp_path, + { + "esphome/A": { + "name": "A", + "dependencies": [{"name": "C", "version": "1.0", "platforms": ["avr"]}], + } + }, + ) + + top = convert_libraries([Library("esphome/A", "1.0.0", None)], _backend()) + + assert top[0].dependencies == [] From 8e23065b86798ad3a96216c05d9da76f4a1ac7ba Mon Sep 17 00:00:00 2001 From: alorente Date: Sun, 28 Jun 2026 13:14:05 +0200 Subject: [PATCH 0614/1815] [it8951] Add IT8951 e-paper controller support to epaper_spi (#15346) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Copilot Co-authored-by: Citric Li <37475446+limengdu@users.noreply.github.com> Co-authored-by: koosoli Co-authored-by: Cursor Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 --- CODEOWNERS | 1 + esphome/components/it8951/__init__.py | 1 + esphome/components/it8951/display.py | 433 +++++++ esphome/components/it8951/it8951.cpp | 1091 +++++++++++++++++ esphome/components/it8951/it8951.h | 373 ++++++ esphome/components/it8951/it8951_defs.h | 168 +++ .../components/it8951/test.esp32-s3-idf.yaml | 109 ++ tests/components/ld2450/common.h | 3 + 8 files changed, 2179 insertions(+) create mode 100644 esphome/components/it8951/__init__.py create mode 100644 esphome/components/it8951/display.py create mode 100644 esphome/components/it8951/it8951.cpp create mode 100644 esphome/components/it8951/it8951.h create mode 100644 esphome/components/it8951/it8951_defs.h create mode 100644 tests/components/it8951/test.esp32-s3-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 70ad580e77..21121ff476 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -266,6 +266,7 @@ esphome/components/integration/* @OttoWinter esphome/components/internal_temperature/* @Mat931 esphome/components/interval/* @esphome/core esphome/components/ir_rf_proxy/* @kbx81 +esphome/components/it8951/* @koosoli @limengdu @Passific esphome/components/jsn_sr04t/* @Mafus1 esphome/components/json/* @esphome/core esphome/components/kamstrup_kmp/* @cfeenstra1024 diff --git a/esphome/components/it8951/__init__.py b/esphome/components/it8951/__init__.py new file mode 100644 index 0000000000..7fc4ae2cd0 --- /dev/null +++ b/esphome/components/it8951/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@Passific", "@koosoli", "@limengdu"] diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py new file mode 100644 index 0000000000..51c5fc6118 --- /dev/null +++ b/esphome/components/it8951/display.py @@ -0,0 +1,433 @@ +""" +ESPHome configuration for the IT8951 e-paper controller. +""" + +from esphome import automation, core, pins +import esphome.codegen as cg +from esphome.components import display, spi +from esphome.components.display import CONF_SHOW_TEST_CARD, validate_rotation +import esphome.config_validation as cv +from esphome.config_validation import update_interval +from esphome.const import ( + CONF_BUSY_PIN, + CONF_CS_PIN, + CONF_DATA_RATE, + CONF_DIMENSIONS, + CONF_ENABLE_PIN, + CONF_FULL_UPDATE_EVERY, + CONF_HEIGHT, + CONF_ID, + CONF_INVERT_COLORS, + CONF_LAMBDA, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_MODE, + CONF_MODEL, + CONF_PAGES, + CONF_RESET_DURATION, + CONF_RESET_PIN, + CONF_ROTATION, + CONF_SLEEP_WHEN_DONE, + CONF_SWAP_XY, + CONF_TRANSFORM, + CONF_UPDATE_INTERVAL, + CONF_WIDTH, +) +from esphome.cpp_generator import RawExpression +from esphome.final_validate import full_config + +AUTO_LOAD = ["split_buffer"] +DEPENDENCIES = ["spi"] + +CONF_VCOM = "vcom" +CONF_VCOM_REGISTER = "vcom_register" +CONF_FORCE_TEMPERATURE = "force_temperature" +CONF_GRAYSCALE = "grayscale" +CONF_DITHERING = "dithering" +CONF_UPDATE_MODE = "update_mode" +CONF_USE_LEGACY_DPY_AREA = "use_legacy_dpy_area" + +# VCOM SET sub-command selectors. The IT8951 firmware accepts different +# values across panels; most respond to 0x0001, but a few — e.g. the Seeed +# reTerminal E1003 — only respond to 0x0002 and silently drop 0x0001. +VCOM_REGISTER_DEFAULT = 0x0001 +VCOM_REGISTER_ALT = 0x0002 +VCOM_REGISTER_OPTIONS = (VCOM_REGISTER_DEFAULT, VCOM_REGISTER_ALT) + +it8951_ns = cg.esphome_ns.namespace("it8951") +IT8951Display = it8951_ns.class_("IT8951Display", display.Display, spi.SPIDevice) +IT8951UpdateAction = it8951_ns.class_("IT8951UpdateAction", automation.Action) + +# Hardware waveform modes exposed to YAML. Strings are mapped to the C++ +# UpdateMode enum so the runtime can store the mode as a uint16_t rather +# than a std::string (avoiding a heap-resident member; see ESPHome +# CLAUDE.md "STL Container Guidelines"). "fast" and "full" are +# convenience aliases for DU and GC16 respectively. +UpdateMode = it8951_ns.enum("UpdateMode") +UPDATE_MODE_OPTIONS = { + "INIT": UpdateMode.UPDATE_MODE_INIT, + "DU": UpdateMode.UPDATE_MODE_DU, + "GC16": UpdateMode.UPDATE_MODE_GC16, + "GL16": UpdateMode.UPDATE_MODE_GL16, + "GLR16": UpdateMode.UPDATE_MODE_GLR16, + "GLD16": UpdateMode.UPDATE_MODE_GLD16, + "DU4": UpdateMode.UPDATE_MODE_DU4, + "A2": UpdateMode.UPDATE_MODE_A2, + "FAST": UpdateMode.UPDATE_MODE_DU, + "FULL": UpdateMode.UPDATE_MODE_GC16, +} +# Maps the YAML mode string directly to the C++ UpdateMode enum value, so the +# config option and the it8951.update action share one validator. +update_mode = cv.enum(UPDATE_MODE_OPTIONS, upper=True) + +# Transform flag values mirror the C++ TRANSFORM_* constants. +_TRANSFORM_NONE = 0 +_TRANSFORM_MIRROR_X = 1 +_TRANSFORM_MIRROR_Y = 2 +_TRANSFORM_SWAP_XY = 4 +_TRANSFORM_FLAGS = { + CONF_MIRROR_X: _TRANSFORM_MIRROR_X, + CONF_MIRROR_Y: _TRANSFORM_MIRROR_Y, + CONF_SWAP_XY: _TRANSFORM_SWAP_XY, +} + + +class IT8951Model: + """A specific board / panel preset for the IT8951 controller.""" + + models: dict[str, "IT8951Model"] = {} + + def __init__(self, name: str, **defaults): + name = name.upper() + self.name = name + self.defaults = defaults + IT8951Model.models[name] = self + + def get_default(self, key, fallback=None): + return self.defaults.get(key, fallback) + + def get_dimensions(self, config) -> tuple[int, int]: + # If dimensions are in config, use them; otherwise fall back to model defaults. + if CONF_DIMENSIONS in config: + dimensions = config[CONF_DIMENSIONS] + if isinstance(dimensions, dict): + return dimensions[CONF_WIDTH], dimensions[CONF_HEIGHT] + return tuple(dimensions) + # Model must have defaults if dimensions not in config. + return self.get_default(CONF_WIDTH), self.get_default(CONF_HEIGHT) + + +# --- Model presets ---------------------------------------------------------- +# The generic model leaves dimensions and pin choices up to the user. +IT8951Model("it8951", vcom=2300, sleep_when_done=True, data_rate=12_000_000) + +IT8951Model( + "m5stack-m5paper", + width=960, + height=540, + busy_pin=27, + reset_pin=23, + cs_pin=15, + vcom=2300, + sleep_when_done=True, + data_rate=20_000_000, +) + +IT8951Model( + "seeed-reterminal-e1003", + width=1872, + height=1404, + busy_pin=13, + reset_pin=12, + cs_pin=10, + # Board power-enable rails: 1.8V logic supply (GPIO21) and the EPD supply + # (GPIO11). Driven high during setup so no separate power_supply is needed. + enable_pin=[21, 11], + vcom=1400, + # reTerminal E1003 panel firmware only accepts the 0x0002 VCOM SET + # selector; using the default 0x0001 leaves VCOM unchanged and breaks + # grayscale waveforms (GC16/GL16) — INIT still works because it does + # not depend on VCOM accuracy. + vcom_register=VCOM_REGISTER_ALT, + # The reTerminal E1003 ships with on-die temperature sensing disabled, + # so the host must declare an operating temperature; otherwise the + # waveform LUT defaults to a value that produces no visible change + # for grayscale modes. + force_temperature=25, + sleep_when_done=False, + data_rate=20_000_000, + mirror_x=True, +) + +IT8951Model( + "seeed-ee03", + width=1872, + height=1404, + busy_pin=4, + reset_pin=38, + cs_pin=44, + vcom=1400, + sleep_when_done=False, + data_rate=4_000_000, +) + +# --------------------------------------------------------------------------- + +DIMENSION_SCHEMA = cv.Schema( + { + cv.Required(CONF_WIDTH): cv.int_, + cv.Required(CONF_HEIGHT): cv.int_, + } +) + + +def _model_pin_option(model, key, schema): + default = model.get_default(key) + if default is None: + return cv.Required(key), schema + return cv.Optional(key, default=default), schema + + +def _model_schema(config): + model = IT8951Model.models[config[CONF_MODEL]] + has_default_dimensions = ( + model.get_default(CONF_WIDTH) is not None + and model.get_default(CONF_HEIGHT) is not None + ) + dimensions_key = ( + cv.Optional( + CONF_DIMENSIONS, + default={ + CONF_WIDTH: model.get_default(CONF_WIDTH), + CONF_HEIGHT: model.get_default(CONF_HEIGHT), + }, + ) + if has_default_dimensions + else cv.Required(CONF_DIMENSIONS) + ) + + schema = display.FULL_DISPLAY_SCHEMA.extend( + spi.spi_device_schema( + cs_pin_required=False, + default_mode="MODE0", + default_data_rate=model.get_default(CONF_DATA_RATE, 10_000_000), + ) + ).extend( + { + cv.GenerateID(): cv.declare_id(IT8951Display), + cv.Required(CONF_MODEL): cv.one_of(model.name, upper=True, space="-"), + cv.Optional(CONF_ROTATION, default=0): validate_rotation, + cv.Optional(CONF_UPDATE_INTERVAL, default=cv.UNDEFINED): update_interval, + cv.Optional(CONF_FULL_UPDATE_EVERY, default=30): cv.int_range(1, 255), + cv.Optional(CONF_TRANSFORM): cv.Schema( + { + cv.Required(CONF_MIRROR_X): cv.boolean, + cv.Required(CONF_MIRROR_Y): cv.boolean, + cv.Optional(CONF_SWAP_XY, default=False): cv.boolean, + } + ), + cv.Optional( + CONF_INVERT_COLORS, default=model.get_default(CONF_INVERT_COLORS, False) + ): cv.boolean, + cv.Optional( + CONF_SLEEP_WHEN_DONE, + default=model.get_default(CONF_SLEEP_WHEN_DONE, False), + ): cv.boolean, + # Pixel format: true = 4bpp grayscale, false = packed 1bpp + # monochrome. Monochrome halves the framebuffer and enables fast DU + # partial refreshes; grayscale gives 16 levels but always uses GC16. + cv.Optional( + CONF_GRAYSCALE, default=model.get_default(CONF_GRAYSCALE, True) + ): cv.boolean, + # Monochrome only: ordered-dither pale colours so they render as + # visible stipple. Disable for a crisp hard black/white threshold + # (better for purely black/white text). No effect in grayscale mode. + cv.Optional( + CONF_DITHERING, default=model.get_default(CONF_DITHERING, True) + ): cv.boolean, + cv.Optional( + CONF_VCOM, default=model.get_default(CONF_VCOM, 2300) + ): cv.int_range(0, 5000), + cv.Optional( + CONF_VCOM_REGISTER, + default=model.get_default(CONF_VCOM_REGISTER, VCOM_REGISTER_DEFAULT), + ): cv.one_of(*VCOM_REGISTER_OPTIONS, int=True), + **( + { + cv.Optional( + CONF_FORCE_TEMPERATURE, + default=model.get_default(CONF_FORCE_TEMPERATURE), + ): cv.int_range(min=-40, max=85) + } + if model.get_default(CONF_FORCE_TEMPERATURE) is not None + else {} + ), + cv.Optional( + CONF_USE_LEGACY_DPY_AREA, + default=model.get_default(CONF_USE_LEGACY_DPY_AREA, False), + ): cv.boolean, + cv.Optional(CONF_UPDATE_MODE): update_mode, + # One or more GPIOs driven high during setup to power on the panel + # (e.g. board power-enable rails), before reset and init. + cv.Optional( + CONF_ENABLE_PIN, default=model.get_default(CONF_ENABLE_PIN, []) + ): cv.ensure_list(pins.gpio_output_pin_schema), + cv.Optional(CONF_RESET_DURATION): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(max=core.TimePeriod(milliseconds=500)), + ), + dimensions_key: DIMENSION_SCHEMA, + } + ) + + # Pin options: required if the model doesn't supply a default. + pin_specs = ( + (CONF_BUSY_PIN, pins.gpio_input_pin_schema), + (CONF_RESET_PIN, pins.gpio_output_pin_schema), + (CONF_CS_PIN, pins.gpio_output_pin_schema), + ) + pin_extra = {} + for key, schema_value in pin_specs: + opt, sv = _model_pin_option(model, key, schema_value) + pin_extra[opt] = sv + return schema.extend(pin_extra) + + +def _customise_schema(config): + config = cv.Schema( + { + cv.Required(CONF_MODEL): cv.one_of( + *IT8951Model.models, upper=True, space="-" + ) + }, + extra=cv.ALLOW_EXTRA, + )(config) + + model_config = _model_schema(config)(config) + + model = IT8951Model.models[config[CONF_MODEL].upper()] + width, height = model.get_dimensions(model_config) + + display.add_metadata( + model_config[CONF_ID], + width, + height, + # Rotation is applied per-pixel in draw_pixel_at at no extra cost, so we + # advertise hardware rotation: LVGL routes its rotation to the driver via + # set_rotation rather than rotating the framebuffer in software. + has_hardware_rotation=True, + has_writer=any( + model_config.get(key) + for key in (CONF_LAMBDA, CONF_PAGES, CONF_SHOW_TEST_CARD) + ), + # Report the configured rotation so LVGL can detect (and reject) a + # rotation set in the display config instead of the LVGL config. + rotation=model_config.get(CONF_ROTATION, 0), + # The IT8951 snaps partial display refreshes to a 32-pixel X boundary + # (see prepare_update_region_), so have LVGL round its redraw areas to + # 32px too — this keeps flush rectangles aligned with what the panel + # actually refreshes and avoids redundant re-rounding/over-draw. + draw_rounding=32, + ) + + return model_config + + +CONFIG_SCHEMA = _customise_schema + + +def _final_validate(config): + # IT8951 reads from SPI (DevInfo, VCOM, register reads) so MISO is required. + spi.final_validate_device_schema("it8951", require_miso=True, require_mosi=True)( + config + ) + + global_config = full_config.get() + from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN + + if CONF_LAMBDA not in config and CONF_PAGES not in config: + if LVGL_DOMAIN in global_config: + if CONF_UPDATE_INTERVAL not in config: + config[CONF_UPDATE_INTERVAL] = update_interval("never") + else: + config[CONF_SHOW_TEST_CARD] = True + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config): + model = IT8951Model.models[config[CONF_MODEL]] + width, height = model.get_dimensions(config) + + var = cg.new_Pvariable(config[CONF_ID], model.name, width, height) + await display.register_display(var, config) + await spi.register_spi_device(var, config, write_only=False) + + if lambda_config := config.get(CONF_LAMBDA): + lambda_ = await cg.process_lambda( + lambda_config, [(display.DisplayRef, "it")], return_type=cg.void + ) + cg.add(var.set_writer(lambda_)) + if reset_pin := config.get(CONF_RESET_PIN): + cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) + if busy_pin := config.get(CONF_BUSY_PIN): + cg.add(var.set_busy_pin(await cg.gpio_pin_expression(busy_pin))) + if enable_pins := config.get(CONF_ENABLE_PIN): + cg.add( + var.set_enable_pins( + [await cg.gpio_pin_expression(pin) for pin in enable_pins] + ) + ) + cg.add(var.set_full_update_every(config[CONF_FULL_UPDATE_EVERY])) + if (reset_duration := config.get(CONF_RESET_DURATION)) is not None: + cg.add(var.set_reset_duration(reset_duration)) + if config.get(CONF_INVERT_COLORS): + cg.add(var.set_invert_colors(True)) + if config.get(CONF_SLEEP_WHEN_DONE): + cg.add(var.set_sleep_when_done(True)) + cg.add(var.set_vcom(config[CONF_VCOM])) + cg.add(var.set_vcom_register(config[CONF_VCOM_REGISTER])) + if CONF_FORCE_TEMPERATURE in config: + cg.add(var.set_force_temperature(config[CONF_FORCE_TEMPERATURE])) + if config.get(CONF_USE_LEGACY_DPY_AREA): + cg.add(var.set_use_legacy_dpy_area(True)) + cg.add(var.set_grayscale(config[CONF_GRAYSCALE])) + cg.add(var.set_dithering(config[CONF_DITHERING])) + if (mode := config.get(CONF_UPDATE_MODE)) is not None: + cg.add(var.set_update_mode(mode)) + + transform = config.get( + CONF_TRANSFORM, + { + CONF_MIRROR_X: model.get_default(CONF_MIRROR_X), + CONF_MIRROR_Y: model.get_default(CONF_MIRROR_Y), + }, + ) + + transform_value = sum( + flag for key, flag in _TRANSFORM_FLAGS.items() if transform.get(key) + ) + if transform_value: + cg.add(var.set_transform(RawExpression(str(transform_value)))) + + +@automation.register_action( + "it8951.update", + IT8951UpdateAction, + automation.maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(IT8951Display), + cv.Optional(CONF_MODE): cv.templatable(update_mode), + } + ), + synchronous=True, +) +async def it8951_update_action_to_code(config, action_id, template_arg, args): + display_var = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, display_var) + if mode := config.get(CONF_MODE): + mode = await cg.templatable(mode, args, UpdateMode) + cg.add(var.set_mode(mode)) + return var diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp new file mode 100644 index 0000000000..cc2bddeda7 --- /dev/null +++ b/esphome/components/it8951/it8951.cpp @@ -0,0 +1,1091 @@ +#include "it8951.h" + +#include +#include + +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::it8951 { + +static const char *const TAG = "it8951"; + +// Soft cap for time spent in a single XFER_ROWS Op so we yield back to the +// loop within one tick budget. +static constexpr uint32_t MAX_TRANSFER_TIME_MS = 20; + +// --- Loop / scheduling ------------------------------------------------------- + +void IT8951Display::enqueue_(OpType type, uint16_t a, uint16_t b) { + if (!this->queue_.push_back(Op{type, a, b})) { + ESP_LOGE(TAG, "Op queue overflow (cap=%u); dropping op type=%u", static_cast(OP_QUEUE_SIZE), + static_cast(type)); + } +} + +void IT8951Display::prepend_(OpType type, uint16_t a, uint16_t b) { + if (!this->queue_.push_front(Op{type, a, b})) { + ESP_LOGE(TAG, "Op queue overflow (cap=%u); dropping op type=%u", static_cast(OP_QUEUE_SIZE), + static_cast(type)); + } +} + +bool IT8951Display::is_busy_() const { + // IT8951 Hardware Ready (HW_RDY): HIGH = ready, LOW = busy. + return !this->busy_pin_->digital_read(); +} + +void IT8951Display::loop() { + const uint32_t now = millis(); + if (static_cast(now - this->delay_until_) < 0) + return; + + // Nothing queued — either the current phase has more work to enqueue, or + // we're done. + if (this->queue_.empty()) { + if (this->phase_ == Phase::IDLE) { + this->disable_loop(); + return; + } + this->advance_phase_(); + if (this->queue_.empty()) + return; + } + + // Gate SPI ops on HW_RDY. GPIO/DELAY ops run unconditionally — they're how + // we get the controller out of a stuck-busy state in the first place + // (e.g. during reset, HW_RDY is undefined/low until ROM boot completes). + Op queued_op = this->queue_.front(); + const bool needs_hardware_ready = queued_op.type != OpType::GPIO_RESET_LOW && + queued_op.type != OpType::GPIO_RESET_HIGH && queued_op.type != OpType::DELAY_MS; + if (needs_hardware_ready && this->is_busy_()) { + // Signed elapsed: any pending DELAY_MS or scheduled work in the near + // future shows up as <= 0 elapsed and won't trigger a false timeout. + const int32_t elapsed = static_cast(now - this->phase_started_at_); + ESP_LOGV(TAG, "HW_RDY is LOW (busy) in phase %u, elapsed=%" PRId32 "ms", static_cast(this->phase_), + elapsed); + if (elapsed > static_cast(BUSY_TIMEOUT_MS)) { + ESP_LOGW(TAG, "Busy timeout (%" PRIu32 "ms) in phase %u, recovering", elapsed, + static_cast(this->phase_)); + this->recover_(); + } + return; + } + + this->queue_.pop_front(); + this->process_op_(queued_op); +} + +void IT8951Display::process_op_(const Op &op) { + ESP_LOGV(TAG, "Processing op type=%u a=0x%04X b=0x%04X", static_cast(op.type), op.a, op.b); + switch (op.type) { + case OpType::CMD: + this->spi_cmd_(op.a); + break; + case OpType::WRITE_W: + this->spi_write_word_(op.a); + break; + case OpType::WRITE_REG: + this->spi_write_reg_(op.a, op.b); + break; + case OpType::READ_DEV_INFO: + this->spi_read_dev_info_(); + break; + case OpType::READ_WORD: + this->read_result_ = this->spi_read_word_(); + break; + case OpType::CHECK_LUT_IDLE: + this->op_check_lut_idle_(); + break; + case OpType::SET_1BPP: + this->op_set_1bpp_(); + break; + case OpType::XFER_LISAR: + this->op_xfer_lisar_(); + break; + case OpType::XFER_AREA_CMD: + this->spi_cmd_(TCON_LD_IMG_AREA); + break; + case OpType::XFER_AREA_ARGS: + this->op_xfer_area_args_(); + break; + case OpType::XFER_ROWS: + // Stream rows into the single open LD_IMG_AREA load. The load stays open + // across loop iterations (CS toggles between bursts, matching the + // reference driver), so a partial slice just re-queues another XFER_ROWS + // pass to resume; only when all rows are sent do we close it with one + // LD_IMG_END. This avoids an LD_IMG_END / LD_IMG_AREA round-trip per slice. + if (this->op_xfer_rows_()) { + this->enqueue_(OpType::XFER_AREA_END); + } else { + this->enqueue_(OpType::XFER_ROWS); + } + break; + case OpType::XFER_AREA_END: + this->op_xfer_area_end_(); + break; + case OpType::DPY_BUF_CMD: + // Some panel firmwares (notably Seeed reTerminal E1003) silently drop + // I80_CMD_DPY_BUF_AREA (0x0037) — the LUT engine never starts and the + // host eventually times out after ~12s. Fall back to the basic + // I80_CMD_DPY_AREA (0x0034) for those panels; the buffer address is + // already programmed via LISAR during the transfer phase. + this->spi_cmd_(this->use_legacy_dpy_area_ ? I80_CMD_DPY_AREA : I80_CMD_DPY_BUF_AREA); + break; + case OpType::DPY_BUF_ARGS: + this->op_dpy_buf_args_(); + break; + case OpType::GPIO_RESET_LOW: + if (this->reset_pin_ != nullptr) + this->reset_pin_->digital_write(false); + break; + case OpType::GPIO_RESET_HIGH: + if (this->reset_pin_ != nullptr) + this->reset_pin_->digital_write(true); + break; + case OpType::DELAY_MS: + this->delay_until_ = millis() + op.a; + break; + } +} + +void IT8951Display::set_phase_(Phase next) { + ESP_LOGV(TAG, "Phase %u -> %u", static_cast(this->phase_), static_cast(next)); + // Run the loop continuously for the whole active sequence, returning to normal + // throttling only at IDLE. Each queued op is processed one per loop iteration, + // so at the default ~16ms loop interval the dozens of small ops in the refresh + // and restore phases (register polls, 1bpp enable/restore, DPY) would dominate + // a partial update's latency. The LUT-idle polls are DELAY_MS-paced, so this + // doesn't hammer SPI — it only spends a little extra CPU during the (short, + // infrequent) update instead of sleeping between ops. start()/stop() are + // idempotent, so driving them off the transition is safe. + if (next == Phase::IDLE) { + this->high_freq_.stop(); + } else { + this->high_freq_.start(); + } + this->phase_ = next; + this->phase_started_at_ = millis(); +} + +void IT8951Display::advance_phase_() { + switch (this->phase_) { + case Phase::IDLE: + if (this->initialised_ && this->update_pending_) { + this->update_pending_ = false; + this->active_mode_ = this->pending_update_mode_; + this->update_started_at_ = millis(); + this->set_phase_(Phase::UPDATE_PREPARE); + this->advance_phase_(); + } else { + this->disable_loop(); + } + break; + + case Phase::INIT_RESET: + this->set_phase_(Phase::INIT_DEV_INFO); + this->enqueue_init_dev_info_(); + break; + + case Phase::INIT_DEV_INFO: + if (this->dev_info_.panel_width == 0 || this->dev_info_.panel_width > 2048 || this->dev_info_.panel_height == 0 || + this->dev_info_.panel_height > 2048 || this->dev_info_.panel_width == 0xFFFF || + this->dev_info_.panel_height == 0xFFFF) { + if (++this->dev_info_attempts_ < 5) { + ESP_LOGW(TAG, "DevInfo attempt %u returned invalid data (W=%u H=%u), retrying...", this->dev_info_attempts_, + this->dev_info_.panel_width, this->dev_info_.panel_height); + // Give the controller more time, then re-read. + this->enqueue_(OpType::DELAY_MS, 100); + this->enqueue_init_dev_info_(); + return; + } + ESP_LOGE(TAG, "DevInfo invalid after %u attempts (W=%u H=%u)", this->dev_info_attempts_, + this->dev_info_.panel_width, this->dev_info_.panel_height); + this->mark_failed(LOG_STR("Failed to read IT8951 device info")); + this->set_phase_(Phase::IDLE); + return; + } + + if (this->dev_info_.panel_width != this->width_ || this->dev_info_.panel_height != this->height_) { + ESP_LOGE(TAG, "Panel dimension mismatch: configured=%ux%u, DevInfo=%ux%u. Check model/dimensions settings.", + this->width_, this->height_, this->dev_info_.panel_width, this->dev_info_.panel_height); + this->mark_failed(LOG_STR("IT8951 panel dimensions do not match DevInfo")); + this->set_phase_(Phase::IDLE); + return; + } + + this->dev_info_attempts_ = 0; + this->row_width_ = this->compute_row_width_(); + this->buffer_length_ = static_cast(this->row_width_) * static_cast(this->height_); + this->img_buf_addr_l_ = this->dev_info_.img_buf_addr_l; + this->img_buf_addr_h_ = this->dev_info_.img_buf_addr_h; + ESP_LOGI(TAG, "DevInfo: %ux%u, ImgBuf 0x%04X%04X", this->width_, this->height_, this->img_buf_addr_h_, + this->img_buf_addr_l_); + this->set_phase_(Phase::INIT_VCOM); + this->enqueue_init_vcom_(); + break; + + case Phase::INIT_VCOM: + this->set_phase_(Phase::INIT_TEMP); + if (this->force_temperature_set_) { + this->enqueue_init_temp_(); + } else { + this->advance_phase_(); + } + break; + + case Phase::INIT_TEMP: + this->set_phase_(Phase::INIT_DONE); + this->advance_phase_(); + break; + + case Phase::INIT_DONE: + if (this->configured_data_rate_ != 0 && this->configured_data_rate_ != this->data_rate_) { + this->spi_teardown(); + this->set_data_rate(this->configured_data_rate_); + this->spi_setup(); + } + this->initialised_ = true; + this->recovery_attempts_ = 0; + ESP_LOGCONFIG(TAG, "IT8951 setup complete"); + this->set_phase_(Phase::IDLE); + this->advance_phase_(); + break; + + case Phase::UPDATE_PREPARE: { + this->do_update_(); + UpdateMode mode = this->active_mode_; + if (!this->prepare_update_region_(mode)) { + ESP_LOGD(TAG, "Nothing to update"); + this->set_phase_(Phase::IDLE); + this->advance_phase_(); + return; + } + this->active_mode_ = mode; + this->set_phase_(Phase::UPDATE_TRANSFER); + this->enqueue_update_transfer_(); + break; + } + + case Phase::UPDATE_TRANSFER: + this->set_phase_(Phase::UPDATE_REFRESH); + this->enqueue_update_refresh_(); + break; + + case Phase::UPDATE_REFRESH: + // Fire-and-forget: don't block here waiting for the refresh to complete. + // The next update's pre-display LUT-idle poll (and the HW_RDY-gated + // TCON_SLEEP) wait as needed, so the refresh time stays off this update's + // critical path. The 1bpp display mode is left enabled rather than + // restored after every update: on a monochrome display every update + // (DU partials and the periodic GC16 cleans) runs in 1bpp mode, so the + // bit never needs clearing — and clearing it required a full + // refresh-length LUT-idle wait. + this->set_phase_(Phase::UPDATE_SLEEP); + this->enqueue_update_sleep_(); + break; + + case Phase::UPDATE_SLEEP: + ESP_LOGV(TAG, "Update took %" PRIu32 "ms (mode=%u area=%ux%u@%u,%u)", millis() - this->update_started_at_, + static_cast(this->active_mode_), this->area_w_, this->area_h_, this->area_x_, this->area_y_); + this->set_phase_(Phase::IDLE); + this->advance_phase_(); + break; + } +} + +// --- Setup ------------------------------------------------------------------- + +void IT8951Display::setup() { + ESP_LOGCONFIG(TAG, "Setting up IT8951..."); + this->configured_data_rate_ = this->data_rate_; + this->data_rate_ = SPI_PROBE_FREQUENCY; + this->spi_setup(); + + // Power on the panel before reset and the init handshake. + for (auto *pin : this->enable_pins_) { + pin->setup(); + pin->digital_write(true); + } + + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + } + if (this->busy_pin_ != nullptr) { + this->busy_pin_->setup(); + } + + this->update_effective_transform_(); + this->reset_dirty_region_(); + + // Allocate the framebuffer now: its size is fixed by the configured pixel + // format and dimensions, so there's no need to defer to the async controller + // init. LVGL (and other writers) can push pixels via draw_pixels_at as soon + // as the component is set up — before init completes — and without a buffer + // those writes would dereference a null pointer and crash. + this->row_width_ = this->compute_row_width_(); + this->buffer_length_ = static_cast(this->row_width_) * static_cast(this->height_); + RAMAllocator allocator{}; + this->buffer_ = allocator.allocate(this->buffer_length_); + if (this->buffer_ == nullptr) { + this->mark_failed(LOG_STR("Failed to allocate IT8951 framebuffer")); + return; + } + // The allocator does not zero memory; start blank (white) so undrawn regions + // (e.g. with auto_clear disabled) don't show garbage on the first update. + this->fill(Color::WHITE); + + // Kick off async init via the queue. Reset pulse + boot delay + wake + + // packed-write enable; everything blocking lives as DELAY_MS Ops gated by + // the loop scheduler. + this->set_phase_(Phase::INIT_RESET); + this->enqueue_init_reset_(); + this->enable_loop(); +} + +void IT8951Display::on_safe_shutdown() { + // Best-effort synchronous sleep — runs during shutdown so we don't queue. + this->spi_cmd_(TCON_SLEEP); +} + +// --- Init op enqueuers ------------------------------------------------------- + +void IT8951Display::enqueue_init_reset_() { + // A reset (including recovery) re-runs SYS_RUN below, so the controller is + // awake once this sequence completes. + this->asleep_ = false; + // Reset pulse: high -> low (reset_duration) -> high -> wait for ROM boot. + this->enqueue_(OpType::GPIO_RESET_HIGH); + this->enqueue_(OpType::GPIO_RESET_LOW); + this->enqueue_(OpType::DELAY_MS, static_cast(this->reset_duration_)); + this->enqueue_(OpType::GPIO_RESET_HIGH); + // SPI ROM boot. HW_RDY gating in loop() handles the actual wait, but a small + // floor avoids hammering SPI before HW_RDY has settled high. 300ms matches + // what most IT8951 reference drivers use for safety. + this->enqueue_(OpType::DELAY_MS, 300); + this->enqueue_(OpType::CMD, TCON_SYS_RUN); + this->enqueue_(OpType::DELAY_MS, 10); // clocks settle after SYS_RUN + this->enqueue_(OpType::CMD, TCON_REG_WR); // packed write mode + this->enqueue_(OpType::WRITE_REG, I80CPCR, 0x0001); +} + +void IT8951Display::enqueue_init_dev_info_() { + // CMD triggers the controller to prepare DevInfo. HW_RDY drops while it works. + // The loop-level HW_RDY gate non-blockingly waits before dispatching READ_DEV_INFO. + this->enqueue_(OpType::CMD, I80_CMD_GET_DEV_INFO); + this->enqueue_(OpType::READ_DEV_INFO); +} + +void IT8951Display::enqueue_init_vcom_() { + // Always write configured VCOM. The IT8951 stores it in OTP-backed RAM; + // rewriting the same value is harmless. The VCOM SET selector is + // panel-specific (see I80_CMD_VCOM_WRITE / I80_CMD_VCOM_WRITE_ALT in + // it8951_defs.h) and is supplied via the model preset. + this->enqueue_(OpType::CMD, I80_CMD_VCOM); + this->enqueue_(OpType::WRITE_W, this->vcom_register_); + this->enqueue_(OpType::WRITE_W, this->vcom_); +} + +void IT8951Display::enqueue_init_temp_() { + // Force panel temperature (in degrees C) so the controller selects the + // correct waveform LUT. Some panels (e.g. Seeed reTerminal E1003) ship + // with auto-temperature disabled and rely on the host to declare the + // operating temperature; without this, grayscale waveforms run against + // a mismatched LUT and pixels do not visibly change even though the LUT + // engine completes a full cycle. + this->enqueue_(OpType::CMD, I80_CMD_FORCE_TEMP); + this->enqueue_(OpType::WRITE_W, I80_CMD_FORCE_TEMP_WRITE); + this->enqueue_(OpType::WRITE_W, static_cast(this->force_temperature_)); +} + +// --- Update op enqueuers ----------------------------------------------------- + +void IT8951Display::enqueue_update_transfer_() { + // If the controller was put to sleep after the previous update, wake it + // before touching the display engine. TCON_SLEEP gates off all clocks; a + // register read (e.g. the LUTAFSR poll in UPDATE_REFRESH) returns a frozen + // value while asleep, so without this the next update stalls forever in + // op_check_lut_idle_(). SRAM/registers (packed-write mode, VCOM, LUT) are + // retained across sleep, so SYS_RUN + a short settle is all that's needed. + if (this->asleep_) { + this->enqueue_(OpType::CMD, TCON_SYS_RUN); + this->enqueue_(OpType::DELAY_MS, 10); // clocks settle after SYS_RUN + this->asleep_ = false; + } + this->transfer_row_ = 0; + // Open a single LD_IMG_AREA load for the whole region. XFER_ROWS streams into + // it across as many time-sliced passes as needed and emits the one matching + // LD_IMG_END when the last row is sent (see the XFER_ROWS handler). + this->enqueue_(OpType::XFER_LISAR); + this->enqueue_(OpType::XFER_AREA_CMD); + this->enqueue_(OpType::XFER_AREA_ARGS); + this->enqueue_(OpType::XFER_ROWS); +} + +void IT8951Display::enqueue_update_refresh_() { + ESP_LOGV(TAG, "Enqueueing refresh ops: grayscale=%u", this->grayscale_); + // Poll LUT idle: CMD(REG_RD) → WRITE_W(LUTAFSR) → READ_WORD → CHECK_LUT_IDLE + this->enqueue_(OpType::CMD, TCON_REG_RD); + this->enqueue_(OpType::WRITE_W, LUTAFSR); + this->enqueue_(OpType::READ_WORD); + this->enqueue_(OpType::CHECK_LUT_IDLE); + if (!this->grayscale_) { + // Read UP1SR+2: CMD(REG_RD) → WRITE_W(UP1SR+2) → READ_WORD → SET_1BPP + this->enqueue_(OpType::CMD, TCON_REG_RD); + this->enqueue_(OpType::WRITE_W, static_cast(UP1SR + 2)); + this->enqueue_(OpType::READ_WORD); + this->enqueue_(OpType::SET_1BPP); + } + this->enqueue_(OpType::DPY_BUF_CMD); + this->enqueue_(OpType::DPY_BUF_ARGS); +} + +void IT8951Display::enqueue_update_sleep_() { + if (this->sleep_when_done_) { + this->enqueue_(OpType::CMD, TCON_SLEEP); + // Remember that the controller is now asleep so the next update wakes it + // (see enqueue_update_transfer_) before polling any register. + this->asleep_ = true; + } +} + +// --- SPI primitives ---------------------------------------------------------- +// +// IT8951 SPI protocol: no DC pin. 16-bit preamble word identifies whether +// the transaction is command (0x6000), write-data (0x0000), or read-data +// (0x1000). +// +// All ops are fully non-blocking at the loop level. The loop-level HW_RDY gate +// guarantees the controller is ready before any op is dispatched. +// +// Within a single CS-asserted transaction, the IT8951 requires HW_RDY to be +// checked after the preamble word before sending the first data word. This +// is a hardware protocol requirement — the controller needs a few clock +// cycles to latch the preamble and configure its internal bus direction. +// In practice this completes in <1µs for write ops; we use a short spin +// (max ~50µs) that never triggers under normal operation. + +static constexpr uint32_t INTRA_CS_READY_TIMEOUT_US = 50; + +static inline void wait_for_hardware_ready(GPIOPin *busy_pin) { + if (busy_pin == nullptr) + return; + uint32_t waited = 0; + while (!busy_pin->digital_read()) { + if (waited >= INTRA_CS_READY_TIMEOUT_US) + return; + delayMicroseconds(1); + waited += 1; + } +} + +void IT8951Display::spi_cmd_(uint16_t cmd) { + this->enable(); + this->write_byte16(PACKET_TYPE_CMD); + wait_for_hardware_ready(this->busy_pin_); + this->write_byte16(cmd); + this->disable(); +} + +void IT8951Display::spi_write_word_(uint16_t value) { + this->enable(); + this->write_byte16(PACKET_TYPE_WRITE); + wait_for_hardware_ready(this->busy_pin_); + this->write_byte16(value); + this->disable(); +} + +void IT8951Display::spi_write_reg_(uint16_t addr, uint16_t value) { + // Single CS transaction: WRITE preamble + addr + value. + // Caller must have already sent CMD(TCON_REG_WR) as a prior op. + this->enable(); + this->write_byte16(PACKET_TYPE_WRITE); + wait_for_hardware_ready(this->busy_pin_); + this->write_byte16(addr); + this->write_byte16(value); + this->disable(); +} + +void IT8951Display::spi_write_args_(const uint16_t *args, uint16_t count) { + // Single CS transaction: WRITE preamble + N data words. + this->enable(); + this->write_byte16(PACKET_TYPE_WRITE); + wait_for_hardware_ready(this->busy_pin_); + for (uint16_t i = 0; i < count; i++) + this->write_byte16(args[i]); + this->disable(); +} + +uint16_t IT8951Display::spi_read_word_() { + // Single CS read transaction. HW_RDY was confirmed HIGH by the loop gate + // before this op was dispatched, so data is ready. + this->enable(); + this->write_byte16(PACKET_TYPE_READ); + wait_for_hardware_ready(this->busy_pin_); + this->write_byte16(0x0000); // dummy — provides clock cycles for controller + wait_for_hardware_ready(this->busy_pin_); + // Read byte-by-byte: a 2-byte transfer_array can lose the low byte on + // ESP-IDF SPI DMA due to 4-byte alignment requirements. + const uint8_t hi = this->transfer_byte(0); + const uint8_t lo = this->transfer_byte(0); + this->disable(); + return encode_uint16(hi, lo); +} + +void IT8951Display::spi_read_dev_info_() { + // Read DevInfo struct. The CMD(GET_DEV_INFO) was already sent as a prior op, + // and the loop HW_RDY gate waited for the controller to prepare data. + std::memset(&this->dev_info_, 0, sizeof(this->dev_info_)); + this->enable(); + this->write_byte16(PACKET_TYPE_READ); + wait_for_hardware_ready(this->busy_pin_); + this->write_byte16(0x0000); // dummy + wait_for_hardware_ready(this->busy_pin_); + auto *words = reinterpret_cast(&this->dev_info_); + constexpr uint32_t word_count = sizeof(this->dev_info_) / sizeof(uint16_t); + for (uint32_t i = 0; i < word_count; i++) { + const uint8_t hi = this->transfer_byte(0); + const uint8_t lo = this->transfer_byte(0); + words[i] = encode_uint16(hi, lo); + } + this->disable(); +} + +// --- Compound Ops ------------------------------------------------------------ + +void IT8951Display::op_xfer_lisar_() { + // Set image-buffer target address. Two register writes = 4 CS transactions. + // Push to FRONT in reverse order so they execute before the rest of the queue. + this->prepend_(OpType::WRITE_REG, LISAR, this->img_buf_addr_l_); + this->prepend_(OpType::CMD, TCON_REG_WR, 0); + this->prepend_(OpType::WRITE_REG, static_cast(LISAR + 2), this->img_buf_addr_h_); + this->prepend_(OpType::CMD, TCON_REG_WR, 0); +} + +void IT8951Display::op_xfer_area_args_() { + // Single CS transaction: WRITE preamble + 5 area-parameter words describing + // the full update region. Sent once when the load is opened (transfer_row_ is + // 0); XFER_ROWS then streams every row into this one area. + uint16_t args[5]; + if (this->grayscale_) { + args[0] = static_cast((LDIMG_B_ENDIAN << 8) | (PIXEL_4BPP << 4)); + args[1] = this->area_x_; + args[2] = this->area_y_; + args[3] = this->area_w_; + args[4] = this->area_h_; + } else { + // Monochrome is loaded via the 8bpp-packed trick: x and width are expressed + // in bytes (8 pixels each) and the controller unpacks one bit per pixel. + args[0] = static_cast((LDIMG_L_ENDIAN << 8) | (PIXEL_8BPP << 4)); + args[1] = static_cast(this->area_x_ / 8); + args[2] = this->area_y_; + args[3] = static_cast(this->area_w_ / 8); + args[4] = this->area_h_; + } + this->spi_write_args_(args, 5); +} + +void IT8951Display::op_xfer_area_end_() { this->spi_cmd_(TCON_LD_IMG_END); } + +bool IT8951Display::op_xfer_rows_() { + const uint32_t start_time = millis(); + const uint16_t area_y = this->area_y_; + const uint16_t area_h = this->area_h_; + + // Bytes per source row, and the byte offset of area_x within a row, in the + // framebuffer's native packing. These match the per-row byte count the + // controller expects from op_xfer_area_args_: area_w/2 for 4bpp grayscale, + // area_w/8 for the 1bpp-packed monochrome trick. area_x / area_w are + // 16-pixel aligned (see prepare_update_region_), so both divisions are exact. + const uint16_t bytes_per_row = + this->grayscale_ ? static_cast(this->area_w_ >> 1) : static_cast(this->area_w_ >> 3); + const uint16_t row_x_bytes = + this->grayscale_ ? static_cast(this->area_x_ >> 1) : static_cast(this->area_x_ >> 3); + + // Single CS write transaction — HW_RDY was confirmed high by the loop gate. + this->enable(); + this->write_byte16(PACKET_TYPE_WRITE); + wait_for_hardware_ready(this->busy_pin_); + + // Each source row is a contiguous slice of the framebuffer in both formats — + // the buffer already holds the wire bytes — so stream it straight to SPI with + // no per-pixel packing or temporary buffer. + while (this->transfer_row_ < area_h) { + const uint32_t offset = (static_cast(area_y) + this->transfer_row_) * this->row_width_ + row_x_bytes; + this->write_array(&this->buffer_[offset], bytes_per_row); + this->transfer_row_++; + if (millis() - start_time >= MAX_TRANSFER_TIME_MS) + break; + } + + this->disable(); + return this->transfer_row_ >= area_h; +} + +void IT8951Display::op_dpy_buf_args_() { + // I80_CMD_DPY_BUF_AREA (0x0037) takes 7 args (with explicit buffer addr). + // I80_CMD_DPY_AREA (0x0034) takes 5 args; the buffer address is taken + // from LISAR which we program during the transfer phase, so this is safe. + if (this->use_legacy_dpy_area_) { + const uint16_t args[5] = { + this->area_x_, this->area_y_, this->area_w_, this->area_h_, static_cast(this->active_mode_), + }; + this->spi_write_args_(args, 5); + return; + } + const uint16_t args[7] = { + this->area_x_, + this->area_y_, + this->area_w_, + this->area_h_, + static_cast(this->active_mode_), + this->img_buf_addr_l_, + this->img_buf_addr_h_, + }; + this->spi_write_args_(args, 7); +} + +void IT8951Display::op_check_lut_idle_() { + ESP_LOGV(TAG, "Checking LUT idle, read_result_=0x%04X", this->read_result_); + // read_result_ holds LUTAFSR value from the preceding READ_WORD op. + if (this->read_result_ != 0) { + // LUT still busy — re-enqueue the full read sequence after a short delay. + this->prepend_(OpType::CHECK_LUT_IDLE, 0, 0); + this->prepend_(OpType::READ_WORD, 0, 0); + this->prepend_(OpType::WRITE_W, LUTAFSR, 0); + this->prepend_(OpType::CMD, TCON_REG_RD, 0); + this->prepend_(OpType::DELAY_MS, 5, 0); + } +} + +void IT8951Display::op_set_1bpp_() { + // read_result_ holds UP1SR+2 value. Set bit 2 and write back, then set BGVR. + // Push to FRONT in reverse order so they execute before DPY_BUF_CMD/ARGS + // that are already in the queue. + const uint16_t modified = static_cast(this->read_result_ | (1U << 2)); + this->prepend_(OpType::WRITE_REG, BGVR, 0xFF00); + this->prepend_(OpType::CMD, TCON_REG_WR, 0); + this->prepend_(OpType::WRITE_REG, UP1SR + 2, modified); + this->prepend_(OpType::CMD, TCON_REG_WR, 0); +} + +// --- Update prep / public API ------------------------------------------------ + +bool IT8951Display::prepare_update_region_(UpdateMode &mode) { + this->partial_update_count_++; + const bool full_update = this->partial_update_count_ >= this->full_update_every_; + if (full_update) { + this->partial_update_count_ = 0; + mode = UPDATE_MODE_GC16; + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; + } else { + // Align the partial region's X extent to 32 pixels. The IT8951's partial + // display refresh snaps the X start/width to a 32-pixel boundary (the panel + // source driver fetches 32-pixel chunks); refreshing a region whose X is + // only 16-aligned makes the panel snap it down to the previous boundary, + // shifting that update ~16px to the left. 32-alignment also satisfies the + // load constraints (4bpp X must be a multiple of 4; the 8bpp-packed mono + // load needs x/8 even, i.e. X a multiple of 16). + this->x_low_ &= 0xFFE0; + uint16_t temp_max = this->x_high_ > 0 ? static_cast(this->x_high_ - 1) : 0; + temp_max = static_cast(temp_max | 0x001F); + if (temp_max >= this->width_) + temp_max = static_cast(this->width_ - 1); + this->x_high_ = static_cast(temp_max + 1); + } + + if (this->x_high_ <= this->x_low_ || this->y_high_ <= this->y_low_) { + this->reset_dirty_region_(); + return false; + } + + const uint16_t x = this->x_low_; + const uint16_t y = this->y_low_; + const uint16_t width = static_cast(this->x_high_ - this->x_low_); + const uint16_t height = static_cast(this->y_high_ - this->y_low_); + + if (x >= this->width_ || y >= this->height_ || (x + width) > this->width_ || (y + height) > this->height_) { + ESP_LOGE(TAG, "Dirty region (%u,%u %ux%u) out of bounds", x, y, width, height); + this->reset_dirty_region_(); + return false; + } + + this->area_x_ = x; + this->area_y_ = y; + this->area_w_ = width; + this->area_h_ = height; + this->transfer_row_ = 0; + + // On non-full updates, downgrade monochrome frames from the full, flashy GC16 + // clear to DU — a fast, low-flash absolute waveform — so full_update_every + // buys cheaper refreshes between the periodic GC16 cleans that clear + // accumulated ghosting. + // + // Grayscale frames are deliberately left on GC16: every reduced grayscale + // waveform this controller exposes (the non-flashing GL family GL16/GLR16/ + // GLD16, and the 4-tone DU4) renders incorrectly on the supported panels — + // a white background is driven to grey rather than staying white. GC16 is the + // only waveform that reproduces grayscale faithfully, so we keep it. + // + // An explicitly configured non-GC16 update_mode is honoured as-is. + if (!full_update && mode == UPDATE_MODE_GC16 && !this->grayscale_) + mode = UPDATE_MODE_DU; + + this->reset_dirty_region_(); + + ESP_LOGV(TAG, "Update: %ux%u@%u,%u mode=%u (%s)", width, height, x, y, static_cast(mode), + this->grayscale_ ? "grayscale" : "mono"); + return true; +} + +void IT8951Display::reset_dirty_region_() { + this->x_low_ = this->width_; + this->x_high_ = 0; + this->y_low_ = this->height_; + this->y_high_ = 0; +} + +void IT8951Display::start_update_(UpdateMode mode) { + if (this->phase_ == Phase::IDLE && this->initialised_) { + this->update_started_at_ = millis(); + this->active_mode_ = mode; + this->set_phase_(Phase::UPDATE_PREPARE); + this->enable_loop(); + this->advance_phase_(); + } else { + // Coalesce: latest pending mode wins. + this->update_pending_ = true; + this->pending_update_mode_ = mode; + this->enable_loop(); + } +} + +void IT8951Display::update() { + if (!this->is_ready()) + return; + if (this->default_update_mode_ != UPDATE_MODE_NONE) { + this->start_update_(this->default_update_mode_); + return; + } + this->start_update_(UPDATE_MODE_GC16); +} + +void IT8951Display::update_mode(UpdateMode mode) { + if (!this->is_ready()) + return; + if (mode == UPDATE_MODE_NONE) { + ESP_LOGW(TAG, "Unknown update mode"); + return; + } + this->start_update_(mode); +} + +// --- Recovery ---------------------------------------------------------------- + +void IT8951Display::recover_() { + if (++this->recovery_attempts_ > 3) { + ESP_LOGE(TAG, "Recovery failed after %u attempts; giving up. Check BUSY pin wiring and power.", + this->recovery_attempts_); + this->mark_failed(LOG_STR("IT8951 recovery exhausted")); + this->queue_.clear(); + this->set_phase_(Phase::IDLE); + this->disable_loop(); + return; + } + ESP_LOGW(TAG, "Recovering (attempt %u): hardware-resetting controller (was in phase %u)", this->recovery_attempts_, + static_cast(this->phase_)); + this->queue_.clear(); + this->update_pending_ = false; + this->transfer_row_ = 0; + this->initialised_ = false; + this->dev_info_attempts_ = 0; + + // Drop SPI clock back to the safe probe rate for the re-init handshake. + if (this->configured_data_rate_ != 0 && this->data_rate_ != SPI_PROBE_FREQUENCY) { + this->spi_teardown(); + this->set_data_rate(SPI_PROBE_FREQUENCY); + this->spi_setup(); + } + + // Force a full redraw on next opportunity. + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; + + this->set_phase_(Phase::INIT_RESET); + this->enqueue_init_reset_(); + this->update_pending_ = true; + this->pending_update_mode_ = UPDATE_MODE_GC16; + this->enable_loop(); +} + +// --- Coordinate transform ---------------------------------------------------- + +void IT8951Display::update_effective_transform_() { + switch (this->rotation_) { + case DISPLAY_ROTATION_90_DEGREES: + this->effective_transform_ = this->transform_ ^ (TRANSFORM_SWAP_XY | TRANSFORM_MIRROR_X); + break; + case DISPLAY_ROTATION_180_DEGREES: + this->effective_transform_ = this->transform_ ^ (TRANSFORM_MIRROR_Y | TRANSFORM_MIRROR_X); + break; + case DISPLAY_ROTATION_270_DEGREES: + this->effective_transform_ = this->transform_ ^ (TRANSFORM_SWAP_XY | TRANSFORM_MIRROR_Y); + break; + default: + this->effective_transform_ = this->transform_; + break; + } +} + +void IT8951Display::apply_transform_(int &x, int &y) const { + if (this->effective_transform_ & TRANSFORM_SWAP_XY) + std::swap(x, y); + if (this->effective_transform_ & TRANSFORM_MIRROR_X) + x = this->width_ - x - 1; + if (this->effective_transform_ & TRANSFORM_MIRROR_Y) + y = this->height_ - y - 1; +} + +bool IT8951Display::rotate_coordinates_(int &x, int &y) { + if (!this->get_clipping().inside(x, y)) + return false; + this->apply_transform_(x, y); + if (x >= this->width_ || y >= this->height_ || x < 0 || y < 0) + return false; + this->x_low_ = clamp_at_most(this->x_low_, x); + this->x_high_ = clamp_at_least(this->x_high_, x + 1); + this->y_low_ = clamp_at_most(this->y_low_, y); + this->y_high_ = clamp_at_least(this->y_high_, y + 1); + return true; +} + +// --- Color / drawing --------------------------------------------------------- + +static uint8_t quantize_8bit_to_nibble(uint8_t value) { + uint8_t nibble = static_cast((static_cast(value) + 8) >> 4); + return nibble > 0x0F ? 0x0F : nibble; +} + +static uint8_t color_to_nibble(const Color &color) { + // Grayscale images are emitted as Color(gray, gray, gray, 0xFF). + // Handle this shape first so endpoint values don't alias COLOR_ON/OFF. + if (color.w == 0xFF && color.r == color.g && color.g == color.b) + return quantize_8bit_to_nibble(color.r); + + if (color.raw_32 == 0) + return 0x00; // black + if (color.raw_32 == 0xFFFFFFFF) + return 0x0F; // white + + // Derive luma from RGB using Rec.601 weights (0.299/0.587/0.114, scaled by + // 256). Rec.601 is the standard for converting SDR images to grayscale and + // spreads saturated colours across the mid-range; Rec.709 instead crams them + // against white/black where the 16 panel levels are hard to tell apart. + auto luma = static_cast((77u * color.r + 150u * color.g + 29u * color.b + 128u) >> 8); + return quantize_8bit_to_nibble(luma); +} + +// 4x4 ordered (Bayer) dither threshold over the weighted-luma range (0..65535). +// A pixel whose luma is below the threshold renders black, so lighter pixels +// produce progressively sparser black dots instead of vanishing to white. The +// matrix averages to 32768, matching the conventional monochrome cut, while the +// per-pixel variation reproduces intermediate gray levels. +static uint16_t dither_threshold(uint16_t x, uint16_t y) { + static const uint8_t BAYER4[16] = {0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5}; + return static_cast(BAYER4[((y & 3) << 2) | (x & 3)] * 4096u + 2048u); +} + +void IT8951Display::fill(Color color) { + if (this->buffer_ == nullptr) + return; + if (this->get_clipping().is_set()) { + Display::fill(color); + return; + } + uint8_t packed = color_to_nibble(color); + if (this->invert_colors_) + packed = 0x0F - packed; + uint8_t fill_byte; + if (this->grayscale_) { + fill_byte = static_cast((packed << 4) | packed); + } else { + fill_byte = (packed <= 0x07) ? 0xFF : 0x00; + } + memset(this->buffer_, fill_byte, this->buffer_length_); + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; +} + +void HOT IT8951Display::draw_pixel_at(int x, int y, Color color) { + if (this->buffer_ == nullptr) + return; + App.feed_wdt(); + if (!this->rotate_coordinates_(x, y)) + return; + this->write_pixel_native_(static_cast(x), static_cast(y), color); +} + +void HOT IT8951Display::write_pixel_native_(uint16_t x, uint16_t y, const Color &color) const { + if (this->grayscale_) { + uint8_t nibble = color_to_nibble(color); + if (this->invert_colors_) + nibble = static_cast(0x0F - nibble); + this->set_gray_pixel_(x, y, nibble); + } else { + // Rec.601 luma (see color_to_nibble). Weights sum to 257 so white maps to + // exactly 65535, using the full 16-bit range without overflow. + auto lum = static_cast(77u * color.r + 151u * color.g + 29u * color.b); + if (this->invert_colors_) + lum = static_cast(65535u - lum); + // Set the bit (foreground/black) when this pixel is darker than its + // threshold. With dithering the threshold varies per pixel so pale colours + // render as visible texture; otherwise it's the fixed ~50% cut (r+g+b<32768). + const uint16_t threshold = this->dithering_ ? dither_threshold(x, y) : 32768; + this->set_mono_pixel_(x, y, lum < threshold); + } +} + +void HOT IT8951Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, + ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { + // A writer (e.g. LVGL) may push pixels before the framebuffer is ready or + // after an allocation failure; ignore those rather than dereferencing null. + if (this->buffer_ == nullptr) + return; + // A clipping rectangle would need a per-pixel test; that's rare for the bulk + // blit callers (LVGL, images), so fall back to the base per-pixel path then. + if (this->get_clipping().is_set()) { + Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + return; + } + + const size_t line_stride = static_cast(x_offset) + w + x_pad; // source line length in pixels + for (int y = 0; y < h; y++) { + App.feed_wdt(); + size_t source_idx = (static_cast(y_offset) + y) * line_stride + x_offset; + for (int x = 0; x < w; x++, source_idx++) { + uint32_t color_value; + switch (bitness) { + case COLOR_BITNESS_565: { + const size_t i = source_idx * 2; + color_value = big_endian ? (static_cast(ptr[i]) << 8) | ptr[i + 1] + : ptr[i] | (static_cast(ptr[i + 1]) << 8); + break; + } + case COLOR_BITNESS_888: { + const size_t i = source_idx * 3; + color_value = + big_endian + ? (static_cast(ptr[i]) << 16) | (static_cast(ptr[i + 1]) << 8) | ptr[i + 2] + : ptr[i] | (static_cast(ptr[i + 1]) << 8) | (static_cast(ptr[i + 2]) << 16); + break; + } + default: + color_value = ptr[source_idx]; + break; + } + int nx = x_start + x; + int ny = y_start + y; + this->apply_transform_(nx, ny); + if (nx < 0 || ny < 0 || nx >= this->width_ || ny >= this->height_) + continue; + this->write_pixel_native_(static_cast(nx), static_cast(ny), + ColorUtil::to_color(color_value, order, bitness)); + } + } + + // Expand the dirty bounding box once from the transformed block corners: the + // image of an axis-aligned rectangle under swap/mirror is still axis-aligned, + // so its two opposite corners bound it. + int x0 = x_start, y0 = y_start; + int x1 = x_start + w - 1, y1 = y_start + h - 1; + this->apply_transform_(x0, y0); + this->apply_transform_(x1, y1); + const int nx_lo = std::max(0, std::min(x0, x1)); + const int ny_lo = std::max(0, std::min(y0, y1)); + const int nx_hi = std::min(this->width_ - 1, std::max(x0, x1)); + const int ny_hi = std::min(this->height_ - 1, std::max(y0, y1)); + if (nx_hi >= nx_lo && ny_hi >= ny_lo) { + this->x_low_ = clamp_at_most(this->x_low_, nx_lo); + this->x_high_ = clamp_at_least(this->x_high_, nx_hi + 1); + this->y_low_ = clamp_at_most(this->y_low_, ny_lo); + this->y_high_ = clamp_at_least(this->y_high_, ny_hi + 1); + } +} + +void IT8951Display::set_mono_pixel_(uint16_t x, uint16_t y, bool value) const { + // The monochrome framebuffer holds the exact bytes streamed to the + // controller for the 8bpp-load / 1bpp-display trick (L_ENDIAN). Pixels are + // grouped in 16s; on the wire the high byte (pixels 8..15) precedes the low + // byte (pixels 0..7), and the bit index within a byte is the pixel's offset + // (LSB = lowest x). Storing in that order lets op_xfer_rows_ copy rows + // verbatim with no packing or byte-swapping. + const uint16_t group = static_cast(x >> 4); + const uint8_t sub = static_cast(x & 0x0F); + const uint16_t byte_index = static_cast(group * 2u + (sub < 8u ? 1u : 0u)); + const uint8_t mask = static_cast(1u << (sub & 0x07)); + const uint32_t index = static_cast(y) * this->row_width_ + byte_index; + if (value) { + this->buffer_[index] |= mask; + } else { + this->buffer_[index] &= static_cast(~mask); + } +} + +void IT8951Display::set_gray_pixel_(uint16_t x, uint16_t y, uint8_t nibble) const { + const uint32_t index = static_cast(y) * this->row_width_ + (static_cast(x) >> 1); + uint8_t buf = this->buffer_[index]; + if (x & 0x1) { + buf = (buf & 0xF0) | nibble; + } else { + buf = (buf & 0x0F) | static_cast(nibble << 4); + } + this->buffer_[index] = buf; +} + +// --- Diagnostics ------------------------------------------------------------- + +void IT8951Display::dump_config() { + LOG_DISPLAY("", "IT8951 E-Paper", this); + char force_temperature[24]; + if (this->force_temperature_set_) { + snprintf(force_temperature, sizeof(force_temperature), "%d °C", this->force_temperature_); + } else { + strncpy(force_temperature, "(controller default)", sizeof(force_temperature)); + force_temperature[sizeof(force_temperature) - 1] = '\0'; + } + ESP_LOGCONFIG(TAG, + " Model preset: %s" + "\n Dimensions: %dx%d" + "\n Buffer: %u bytes" + "\n Image buffer addr: 0x%04X%04X" + "\n VCOM: %.02fV (set selector 0x%04X)" + "\n Force temperature: %s" + "\n Display command: %s" + "\n Sleep when done: %s" + "\n Full update every: %u" + "\n Inverted colors: %s" + "\n Pixel format: %s" + "\n Reset duration: %" PRIu32 "ms", + this->name_ != nullptr ? this->name_ : "(unknown)", this->get_width_internal(), + this->get_height_internal(), static_cast(this->buffer_length_), this->img_buf_addr_h_, + this->img_buf_addr_l_, static_cast(this->vcom_) / 1000.0f, this->vcom_register_, + force_temperature, this->use_legacy_dpy_area_ ? "DPY_AREA (0x0034, legacy)" : "DPY_BUF_AREA (0x0037)", + YESNO(this->sleep_when_done_), this->full_update_every_, YESNO(this->invert_colors_), + this->grayscale_ ? "4bpp grayscale" : "1bpp monochrome", this->reset_duration_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); + LOG_PIN(" Busy Pin: ", this->busy_pin_); + LOG_PIN(" CS Pin: ", this->cs_); + LOG_UPDATE_INTERVAL(this); +} + +} // namespace esphome::it8951 diff --git a/esphome/components/it8951/it8951.h b/esphome/components/it8951/it8951.h new file mode 100644 index 0000000000..a5ed03e8c4 --- /dev/null +++ b/esphome/components/it8951/it8951.h @@ -0,0 +1,373 @@ +#pragma once + +#include +#include +#include + +#include "esphome/components/display/display.h" +#include "esphome/components/spi/spi.h" +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include "it8951_defs.h" + +namespace esphome::it8951 { + +using namespace display; + +// --- Bounded op queue -------------------------------------------------------- +// Fixed-capacity ring buffer used by the loop scheduler. Replaces std::deque +// to comply with ESPHome's STL container guidelines (std::deque allocates in +// 512-byte blocks regardless of element size). Size analysis: the deepest +// observed scenario is UPDATE_REFRESH (10 enqueued ops) + CHECK_LUT_IDLE's +// 5 push_front rescheduling = 14 simultaneous entries. We use 32 for a +// comfortable margin while keeping RAM cost low (~192 bytes per instance vs +// 512+ bytes for std::deque). +template class StaticOpQueue { + public: + bool empty() const { return this->count_ == 0; } + size_t size() const { return this->count_; } + static constexpr size_t capacity() { return N; } + + bool push_back(const T &value) { + if (this->count_ >= N) + return false; + this->data_[(this->head_ + this->count_) % N] = value; + ++this->count_; + return true; + } + + bool push_front(const T &value) { + if (this->count_ >= N) + return false; + this->head_ = (this->head_ + N - 1) % N; + this->data_[this->head_] = value; + ++this->count_; + return true; + } + + void pop_front() { + if (this->count_ == 0) + return; + this->head_ = (this->head_ + 1) % N; + --this->count_; + } + + const T &front() const { return this->data_[this->head_]; } + T &front() { return this->data_[this->head_]; } + + void clear() { + this->head_ = 0; + this->count_ = 0; + } + + private: + T data_[N]{}; + size_t head_{0}; + size_t count_{0}; +}; + +// Op queue capacity. See StaticOpQueue comment for sizing analysis. +static constexpr size_t OP_QUEUE_SIZE = 32; + +// --- Op queue --------------------------------------------------------------- +// Each Op is a single CS-asserted SPI transaction (or a tiny bookkeeping +// step). The loop processes one Op per iteration after gating on HW_RDY, so +// the natural ESPHome loop cadence (~8-16 ms) provides inter-op pacing +// without any blocking waits. +// +// Compound Ops (READ_DEV_INFO, XFER_*, DPY_BUF_AREA, ENABLE_1BPP, ...) are +// short self-contained methods that do all their SPI work inside a single +// CS cycle (or a small handful of cycles) and complete well under 2ms, so +// they don't break the no-blocking budget. +// +// Each write-type op is a SINGLE CS-asserted transaction. The loop-level +// HW_RDY gate ensures the controller is ready before dispatching any op, so +// no blocking waits are needed within write ops. +// +// Read ops are decomposed: the command/address that triggers data preparation +// is sent as write ops (CMD, WRITE_W), then a separate read op runs only +// after the loop confirms HW_RDY is back HIGH (data ready). No blocking. +enum class OpType : uint8_t { + CMD, // single CS: CMD preamble + command word (a) + WRITE_W, // single CS: WRITE preamble + data word (a) + WRITE_REG, // single CS: WRITE preamble + addr(a) + value(b) + // (caller must enqueue CMD(TCON_REG_WR) before this) + READ_DEV_INFO, // single CS: READ preamble + dummy + read DevInfo struct + // (caller enqueues CMD(GET_DEV_INFO) first; loop HW_RDY gate + // ensures data is ready before this op runs) + READ_WORD, // single CS: READ preamble + dummy + read one 16-bit word + // into read_result_. Loop HW_RDY gate ensures data ready. + CHECK_LUT_IDLE, // checks read_result_; if non-zero, re-enqueues read sequence + SET_1BPP, // uses read_result_ to set UP1SR bit 2, enqueues writes + XFER_LISAR, // set image-buffer target address (2× reg write: 4 CS transactions) + XFER_AREA_CMD, // single CS: CMD preamble + TCON_LD_IMG_AREA + XFER_AREA_ARGS, // single CS: WRITE preamble + 5 area-parameter words + XFER_ROWS, // single CS: WRITE preamble + row pixel data (time-sliced) + XFER_AREA_END, // single CS: CMD preamble + TCON_LD_IMG_END + DPY_BUF_CMD, // single CS: CMD preamble + I80_CMD_DPY_BUF_AREA + DPY_BUF_ARGS, // single CS: WRITE preamble + 7 display-area words + GPIO_RESET_LOW, // drive RESET pin low + GPIO_RESET_HIGH, // drive RESET pin high + DELAY_MS, // park `delay_until_` for a few ms (no SPI) +}; + +struct Op { + OpType type; + uint16_t a{0}; + uint16_t b{0}; +}; + +// High-level controller phases. Each phase enqueues a sequence of Ops; when +// the queue drains, advance_phase_() runs the next phase. +// This separation keeps per-Op work tiny and predictable. +enum class Phase : uint8_t { + IDLE, + // Initialisation + INIT_RESET, // reset pulse + wake controller + packed-write enable + INIT_DEV_INFO, // GET_DEV_INFO and validate + INIT_VCOM, // write configured VCOM + INIT_TEMP, // force temperature for waveform LUT selection + INIT_DONE, // allocate framebuffer; transition to IDLE + // Update flow + UPDATE_PREPARE, // do_update_, compute dirty region, decide 4bpp/1bpp + UPDATE_TRANSFER, // one LD_IMG_AREA, time-sliced row streaming, one LD_IMG_END + UPDATE_REFRESH, // wait LUT idle, optionally enable 1bpp, send DPY_BUF_AREA + UPDATE_SLEEP, // optional deep sleep +}; + +class IT8951Display : public Display, + public spi::SPIDevice { + public: + IT8951Display(const char *name, uint16_t width, uint16_t height) : name_(name), width_(width), height_(height) { + this->row_width_ = this->compute_row_width_(); + this->buffer_length_ = static_cast(this->row_width_) * static_cast(height); + } + + // --- Component lifecycle --- + void setup() override; + void loop() override; + void dump_config() override; + void on_safe_shutdown() override; + float get_setup_priority() const override { return setup_priority::PROCESSOR; } + + // --- Config setters (called from generated code) --- + void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; } + void set_busy_pin(GPIOPin *pin) { this->busy_pin_ = pin; } + void set_enable_pins(std::vector pins) { this->enable_pins_ = std::move(pins); } + void set_reset_duration(uint32_t ms) { this->reset_duration_ = ms; } + void set_full_update_every(uint8_t n) { + this->full_update_every_ = n; + // Seed the counter so the very first update trips the full-update branch in + // prepare_update_region_, giving a freshly-booted panel a clean GC16 refresh + // before any partial (fast-waveform) updates begin. + this->partial_update_count_ = n; + } + void set_invert_colors(bool invert_colors) { this->invert_colors_ = invert_colors; } + void set_sleep_when_done(bool s) { this->sleep_when_done_ = s; } + void set_vcom(uint16_t vcom_mv) { this->vcom_ = vcom_mv; } + void set_vcom_register(uint16_t selector) { this->vcom_register_ = selector; } + void set_force_temperature(int16_t celsius) { + this->force_temperature_ = celsius; + this->force_temperature_set_ = true; + } + void set_use_legacy_dpy_area(bool use) { this->use_legacy_dpy_area_ = use; } + // Pixel format: true = 4bpp grayscale framebuffer, false = packed 1bpp + // monochrome framebuffer. Chosen at config time; the framebuffer is stored + // in this native format and every update uses the matching transfer path. + void set_grayscale(bool g) { this->grayscale_ = g; } + // Monochrome only: ordered-dither pale colours (true) vs a hard 50% threshold. + void set_dithering(bool d) { this->dithering_ = d; } + void set_update_mode(uint16_t m) { this->default_update_mode_ = static_cast(m); } + void set_transform(uint8_t t) { + this->transform_ = t; + this->update_effective_transform_(); + } + void set_rotation(DisplayRotation rotation) override { + Display::set_rotation(rotation); + this->update_effective_transform_(); + } + + // --- Display API --- + void update() override; + void update_mode(UpdateMode mode); + DisplayType get_display_type() override { return this->grayscale_ ? DISPLAY_TYPE_GRAYSCALE : DISPLAY_TYPE_BINARY; } + void fill(Color color) override; + void clear() override { this->fill(Color::WHITE); } + void draw_pixel_at(int x, int y, Color color) override; + // Bulk pixel blit (used by LVGL and image rendering). Overridden to write + // straight into the framebuffer, avoiding the base class's per-pixel + // draw_pixel_at overhead (watchdog feed, clipping test, dirty-box clamps). + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, + ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; + int get_width() override { return (this->effective_transform_ & TRANSFORM_SWAP_XY) ? this->height_ : this->width_; } + int get_height() override { return (this->effective_transform_ & TRANSFORM_SWAP_XY) ? this->width_ : this->height_; } + + protected: + int get_height_internal() override { return this->height_; } + int get_width_internal() override { return this->width_; } + + // --- Coord transform / dirty region --- + void update_effective_transform_(); + // Map display (logical) coordinates to native framebuffer coordinates by + // applying effective_transform_ (swap/mirror). Shared by rotate_coordinates_ + // and the bulk draw_pixels_at path. + void apply_transform_(int &x, int &y) const; + bool rotate_coordinates_(int &x, int &y); + void reset_dirty_region_(); + + // --- Framebuffer geometry / monochrome packing --- + // Bytes per row for the configured pixel format: 4bpp grayscale packs two + // pixels per byte; monochrome packs eight bits per byte, rounded up to a + // whole 16-pixel group (matching the controller's 8bpp-load / 1bpp trick). + uint16_t compute_row_width_() const { + return this->grayscale_ ? static_cast((static_cast(this->width_) + 1) / 2) + : static_cast(((static_cast(this->width_) + 15) / 16) * 2); + } + void set_mono_pixel_(uint16_t x, uint16_t y, bool value) const; + // Write a 4bpp grayscale nibble into the framebuffer (two pixels per byte). + void set_gray_pixel_(uint16_t x, uint16_t y, uint8_t nibble) const; + // Convert a color and write it at native framebuffer coordinates: a 4bpp + // nibble in grayscale mode, or an ordered-dithered bit in monochrome mode. + void write_pixel_native_(uint16_t x, uint16_t y, const Color &color) const; + + // --- Op queue / loop machinery --- + void enqueue_(OpType type, uint16_t a = 0, uint16_t b = 0); + void prepend_(OpType type, uint16_t a = 0, uint16_t b = 0); + bool is_busy_() const; + void process_op_(const Op &op); + void advance_phase_(); + void set_phase_(Phase next); + void start_update_(UpdateMode mode); + + // --- SPI primitives (each is one CS-asserted burst, fully non-blocking) --- + void spi_cmd_(uint16_t cmd); + void spi_write_word_(uint16_t value); + void spi_write_reg_(uint16_t addr, uint16_t value); + void spi_write_args_(const uint16_t *args, uint16_t count); + uint16_t spi_read_word_(); // non-blocking: HW_RDY confirmed by loop gate + void spi_read_dev_info_(); // non-blocking: HW_RDY confirmed by loop gate + + // --- Compound Ops (small bounded helpers) --- + void op_xfer_lisar_(); + void op_xfer_area_args_(); + void op_xfer_area_end_(); + bool op_xfer_rows_(); // returns true when current update area fully sent + void op_dpy_buf_args_(); + void op_check_lut_idle_(); + void op_set_1bpp_(); + + // --- Phase enqueuers --- + void enqueue_init_reset_(); + void enqueue_init_dev_info_(); + void enqueue_init_vcom_(); + void enqueue_init_temp_(); + void enqueue_update_transfer_(); + void enqueue_update_refresh_(); + void enqueue_update_sleep_(); + + bool prepare_update_region_(UpdateMode &mode); + + // --- Recovery --- + void recover_(); + + // --- State --- + static constexpr uint32_t BUSY_TIMEOUT_MS = 5000; + + StaticOpQueue queue_; + Phase phase_{Phase::IDLE}; + uint32_t delay_until_{0}; + uint32_t phase_started_at_{0}; + // Requests a continuous (non-throttled) main loop while streaming image data + // so 20ms transfer slices aren't separated by the ~16ms default loop interval. + HighFrequencyLoopRequester high_freq_; + + // Pending update bookkeeping + bool update_pending_{false}; + UpdateMode pending_update_mode_{UPDATE_MODE_NONE}; + UpdateMode active_mode_{UPDATE_MODE_NONE}; + uint16_t area_x_{0}, area_y_{0}, area_w_{0}, area_h_{0}; + uint16_t transfer_row_{0}; + bool initialised_{false}; + // True once TCON_SLEEP has been sent and the controller has not been woken + // since. The next update must issue TCON_SYS_RUN before any SPI op. + bool asleep_{false}; + uint32_t partial_update_count_{0}; + uint32_t update_started_at_{0}; + + // Read result storage for decomposed read-modify-write op sequences + uint16_t read_result_{0}; + + // Device info + DevInfo dev_info_{}; + uint16_t img_buf_addr_l_{0}; + uint16_t img_buf_addr_h_{0}; + + // Configured properties + const char *name_; + uint16_t width_; + uint16_t height_; + uint16_t row_width_; + size_t buffer_length_{}; + uint8_t *buffer_{}; + uint8_t transform_{0}; + uint8_t effective_transform_{0}; + uint8_t full_update_every_{1}; + uint32_t reset_duration_{10}; + uint16_t vcom_{2300}; + uint16_t vcom_register_{I80_CMD_VCOM_WRITE}; + int16_t force_temperature_{DEFAULT_FORCE_TEMP_C}; + bool force_temperature_set_{false}; + bool use_legacy_dpy_area_{false}; + bool invert_colors_{false}; + bool sleep_when_done_{false}; + // Pixel format selector (see set_grayscale): true = 4bpp grayscale, + // false = packed 1bpp monochrome. + bool grayscale_{true}; + // Monochrome dithering (see set_dithering): true = ordered dither. + bool dithering_{true}; + UpdateMode default_update_mode_{UPDATE_MODE_NONE}; + GPIOPin *reset_pin_{nullptr}; + GPIOPin *busy_pin_{nullptr}; + // GPIOs driven high during setup to power on the panel (empty if unused). + std::vector enable_pins_; + + // Dirty region (pixel coordinates of bounding box of changes since last update) + uint16_t x_low_{0}, y_low_{0}, x_high_{0}, y_high_{0}; + + // Saved data rate so we can probe slow then run fast + uint32_t configured_data_rate_{0}; + + // Consecutive recovery attempts; used to give up rather than infinite-loop + // when the controller is unresponsive (e.g. wiring issue). + uint8_t recovery_attempts_{0}; + + // DevInfo read retry counter (controller often returns garbage on the first + // read after reset; the original driver retried up to 3 times with 100ms + // between attempts). + uint8_t dev_info_attempts_{0}; +}; + +// --- Automation action --- +template class IT8951UpdateAction : public Action { + public: + explicit IT8951UpdateAction(IT8951Display *display) : display_(display) {} + TEMPLATABLE_VALUE(UpdateMode, mode) + + protected: + void play(const Ts &...x) override { + if (!this->display_->is_ready()) + return; + if (this->mode_.has_value()) { + this->display_->update_mode(this->mode_.value(x...)); + } else { + this->display_->update(); + } + } + + IT8951Display *display_; +}; + +} // namespace esphome::it8951 diff --git a/esphome/components/it8951/it8951_defs.h b/esphome/components/it8951/it8951_defs.h new file mode 100644 index 0000000000..9a7291eb4a --- /dev/null +++ b/esphome/components/it8951/it8951_defs.h @@ -0,0 +1,168 @@ +#pragma once + +#include + +namespace esphome::it8951 { + +struct DevInfo { + uint16_t panel_width{0}; + uint16_t panel_height{0}; + uint16_t img_buf_addr_l{0}; + uint16_t img_buf_addr_h{0}; + uint16_t fw_version[8]{}; + uint16_t lut_version[8]{}; +}; + +// --- IT8951 SPI packet preambles --- +static constexpr uint16_t PACKET_TYPE_CMD = 0x6000; +static constexpr uint16_t PACKET_TYPE_WRITE = 0x0000; +static constexpr uint16_t PACKET_TYPE_READ = 0x1000; + +// --- Built-in I80 commands --- +static constexpr uint16_t TCON_SYS_RUN = 0x0001; +static constexpr uint16_t TCON_STANDBY = 0x0002; +static constexpr uint16_t TCON_SLEEP = 0x0003; +static constexpr uint16_t TCON_REG_RD = 0x0010; +static constexpr uint16_t TCON_REG_WR = 0x0011; + +static constexpr uint16_t TCON_LD_IMG = 0x0020; +static constexpr uint16_t TCON_LD_IMG_AREA = 0x0021; +static constexpr uint16_t TCON_LD_IMG_END = 0x0022; + +// --- I80 user-defined commands --- +static constexpr uint16_t I80_CMD_DPY_AREA = 0x0034; +static constexpr uint16_t I80_CMD_GET_DEV_INFO = 0x0302; +static constexpr uint16_t I80_CMD_DPY_BUF_AREA = 0x0037; +static constexpr uint16_t I80_CMD_VCOM = 0x0039; +static constexpr uint16_t I80_CMD_VCOM_READ = 0x0000; +// VCOM write selectors. Different IT8951-driven panels accept different +// selector values for the VCOM SET sub-command. Most panels (m5stack-m5paper, +// generic dev kits) accept 0x0001. Some panels — notably the Seeed +// reTerminal E1003 — only respond to selector 0x0002 and silently ignore +// 0x0001, leaving VCOM at its default and making grayscale waveforms +// (GC16/GL16) ineffective even though INIT still works. +static constexpr uint16_t I80_CMD_VCOM_WRITE = 0x0001; +static constexpr uint16_t I80_CMD_VCOM_WRITE_ALT = 0x0002; + +// Force temperature command. The IT8951 selects waveform LUTs based on +// panel temperature; if it is left at the controller default, panels with +// auto-temperature disabled (notably the Seeed reTerminal E1003) will +// run waveforms against a mismatched LUT, leaving pixels visually +// unchanged even though the LUT engine completes a full cycle. The +// selector word selects the operation (0x0001 = write); the value word +// is the temperature in degrees Celsius. +static constexpr uint16_t I80_CMD_FORCE_TEMP = 0x0040; +static constexpr uint16_t I80_CMD_FORCE_TEMP_WRITE = 0x0001; +static constexpr int16_t DEFAULT_FORCE_TEMP_C = 25; + +// --- Pixel mode (bits per pixel encoding) --- +static constexpr uint8_t PIXEL_2BPP = 0; +static constexpr uint8_t PIXEL_3BPP = 1; +static constexpr uint8_t PIXEL_4BPP = 2; +static constexpr uint8_t PIXEL_8BPP = 3; + +// --- Endian flags for LD_IMG_AREA --- +static constexpr uint8_t LDIMG_L_ENDIAN = 0; +static constexpr uint8_t LDIMG_B_ENDIAN = 1; + +// --- SPI probe frequency used for initial controller handshake --- +static constexpr uint32_t SPI_PROBE_FREQUENCY = 1'000'000; + +// --- Refresh modes --- +/* + INIT The initialization (INIT) mode is + used to completely erase the display and leave it in the white state. It is + useful for situations where the display information in memory is not a faithful + representation of the optical state of the display, for example, after the + device receives power after it has been fully powered down. This waveform + switches the display several times and leaves it in the white state. + + DU + The direct update (DU) is a very fast, non-flashy update. This mode supports + transitions from any graytone to black or white only. It cannot be used to + update to any graytone other than black or white. The fast update time for this + mode makes it useful for response to touch sensor or pen input or menu selection + indictors. + + GC16 + The grayscale clearing (GC16) mode is used to update the full display and + provide a high image quality. When GC16 is used with Full Display Update the + entire display will update as the new image is written. If a Partial Update + command is used the only pixels with changing graytone values will update. The + GC16 mode has 16 unique gray levels. + + GL16 + The GL16 waveform is primarily used to update sparse content on a white + background, such as a page of anti-aliased text, with reduced flash. The + GL16 waveform has 16 unique gray levels. + + GLR16 + The GLR16 mode is used in conjunction with an image preprocessing algorithm to + update sparse content on a white background with reduced flash and reduced image + artifacts. The GLR16 mode supports 16 graytones. If only the even pixel states + are used (0, 2, 4, … 30), the mode will behave exactly as a traditional GL16 + waveform mode. If a separately-supplied image preprocessing algorithm is used, + the transitions invoked by the pixel states 29 and 31 are used to improve + display quality. For the AF waveform, it is assured that the GLR16 waveform data + will point to the same voltage lists as the GL16 data and does not need to be + stored in a separate memory. + + GLD16 + The GLD16 mode is used in conjunction with an image preprocessing algorithm to + update sparse content on a white background with reduced flash and reduced image + artifacts. It is recommended to be used only with the full display update. The + GLD16 mode supports 16 graytones. If only the even pixel states are used (0, 2, + 4, … 30), the mode will behave exactly as a traditional GL16 waveform mode. If a + separately-supplied image preprocessing algorithm is used, the transitions + invoked by the pixel states 29 and 31 are used to refresh the background with a + lighter flash compared to GC16 mode following a predetermined pixel map as + encoded in the waveform file, and reduce image artifacts even more compared to + the GLR16 mode. For the AF waveform, it is assured that the GLD16 waveform data + will point to the same voltage lists as the GL16 data and does not need to be + stored in a separate memory. + + DU4 + The DU4 is a fast update time (similar to DU), non-flashy waveform. This mode + supports transitions from any gray tone to gray tones 1,6,11,16 represented by + pixel states [0 10 20 30]. The combination of fast update time and four gray + tones make it useful for anti-aliased text in menus. There is a moderate + increase in ghosting compared with GC16. + + A2 + The A2 mode is a fast, non-flash update mode designed for fast paging turning or + simple black/white animation. This mode supports transitions from and to black + or white only. It cannot be used to update to any graytone other than black or + white. The recommended update sequence to transition into repeated A2 updates is + shown in Figure 1. The use of a white image in the transition from 4-bit to + 1-bit images will reduce ghosting and improve image quality for A2 updates. + */ +enum UpdateMode : uint16_t { + UPDATE_MODE_INIT = 0, + UPDATE_MODE_DU = 1, + UPDATE_MODE_GC16 = 2, + UPDATE_MODE_GL16 = 3, + UPDATE_MODE_GLR16 = 4, + UPDATE_MODE_GLD16 = 5, + UPDATE_MODE_DU4 = 6, + UPDATE_MODE_A2 = 7, + UPDATE_MODE_NONE = 8, +}; + +// --- Registers --- +static constexpr uint16_t DISPLAY_REG_BASE = 0x1000; +static constexpr uint16_t UP1SR = DISPLAY_REG_BASE + 0x138; +static constexpr uint16_t LUTAFSR = DISPLAY_REG_BASE + 0x224; +static constexpr uint16_t BGVR = DISPLAY_REG_BASE + 0x250; + +static constexpr uint16_t I80CPCR = 0x0004; + +static constexpr uint16_t MCSR_BASE_ADDR = 0x0200; +static constexpr uint16_t LISAR = MCSR_BASE_ADDR + 0x0008; + +// Display orientation flags +static constexpr uint8_t TRANSFORM_NONE = 0; +static constexpr uint8_t TRANSFORM_MIRROR_X = 1; +static constexpr uint8_t TRANSFORM_MIRROR_Y = 2; +static constexpr uint8_t TRANSFORM_SWAP_XY = 4; + +} // namespace esphome::it8951 diff --git a/tests/components/it8951/test.esp32-s3-idf.yaml b/tests/components/it8951/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..c362f7f28c --- /dev/null +++ b/tests/components/it8951/test.esp32-s3-idf.yaml @@ -0,0 +1,109 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml + +display: + # Generic IT8951 with explicit dimensions + - platform: it8951 + spi_id: spi_bus + model: it8951 + dimensions: + width: 1872 + height: 1404 + cs_pin: + allow_other_uses: true + number: GPIO5 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + enable_pin: + - GPIO17 + - GPIO18 + vcom: 1500 + update_interval: 60s + # Exercise an alias for the update_mode config option. + update_mode: fast + lambda: |- + it.circle(64, 64, 50, Color::BLACK); + + # m5stack-m5paper (960x540) — model supplies pin defaults + - platform: it8951 + id: m5epd_display + spi_id: spi_bus + model: m5stack-m5paper + cs_pin: + allow_other_uses: true + number: GPIO5 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + full_update_every: 30 + invert_colors: false + sleep_when_done: true + grayscale: true + update_mode: GC16 + rotation: 270 + transform: + mirror_x: false + mirror_y: false + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 30, Color::BLACK); + + # seeed-reterminal-e1003 (1872x1404) + - platform: it8951 + spi_id: spi_bus + model: seeed-reterminal-e1003 + cs_pin: + allow_other_uses: true + number: GPIO5 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + vcom: 1400 + sleep_when_done: false + lambda: |- + it.filled_rectangle(0, 0, 128, 128, Color::BLACK); + + # seeed-ee03 (1872x1404), monochrome fast path + - platform: it8951 + spi_id: spi_bus + model: seeed-ee03 + cs_pin: + allow_other_uses: true + number: GPIO5 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + grayscale: false + dithering: false + update_mode: DU + lambda: |- + it.circle(128, 128, 64, Color::BLACK); + +# Exercise the it8951.update automation: alias modes, a direct enum-name mode, +# and the bare (default-mode) form. +interval: + - interval: 30s + then: + - it8951.update: + id: m5epd_display + mode: fast + - it8951.update: + id: m5epd_display + mode: full + - it8951.update: + id: m5epd_display + mode: A2 + - it8951.update: m5epd_display diff --git a/tests/components/ld2450/common.h b/tests/components/ld2450/common.h index 304634edca..de912ddcbc 100644 --- a/tests/components/ld2450/common.h +++ b/tests/components/ld2450/common.h @@ -18,6 +18,9 @@ class MockUARTComponent : public uart::UARTComponent { MOCK_METHOD(size_t, available, (), (override)); MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif // USE_ESP8266 || USE_ESP32 }; // Expose protected members for testing. From 45c712b17be94d71b309678f97549a7dfe224278 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 28 Jun 2026 09:41:49 -0700 Subject: [PATCH 0615/1815] Bump bundled esphome-device-builder to 1.0.21 (#17257) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 5626d18fcc..1085076137 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.20 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.21 RUN \ platformio settings set enable_telemetry No \ From 6210dfb4d099651ea1731a19bd569eef0970109f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:18:01 -0400 Subject: [PATCH 0616/1815] [core] Use single-precision float math to avoid double promotion (#17252) --- esphome/core/helpers.cpp | 10 +++++----- esphome/core/scheduler.cpp | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 112dde7c45..a7b63643a4 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -669,11 +669,11 @@ void rgb_to_hsv(float red, float green, float blue, int &hue, float &saturation, if (delta == 0) { hue = 0; } else if (max_color_value == red) { - hue = int(fmod(((60 * ((green - blue) / delta)) + 360), 360)); + hue = int(fmodf((60.0f * ((green - blue) / delta)) + 360.0f, 360.0f)); } else if (max_color_value == green) { - hue = int(fmod(((60 * ((blue - red) / delta)) + 120), 360)); + hue = int(fmodf((60.0f * ((blue - red) / delta)) + 120.0f, 360.0f)); } else if (max_color_value == blue) { - hue = int(fmod(((60 * ((red - green) / delta)) + 240), 360)); + hue = int(fmodf((60.0f * ((red - green) / delta)) + 240.0f, 360.0f)); } if (max_color_value == 0) { @@ -686,8 +686,8 @@ void rgb_to_hsv(float red, float green, float blue, int &hue, float &saturation, } void hsv_to_rgb(int hue, float saturation, float value, float &red, float &green, float &blue) { float chroma = value * saturation; - float hue_prime = fmod(hue / 60.0, 6); - float intermediate = chroma * (1 - fabs(fmod(hue_prime, 2) - 1)); + float hue_prime = fmodf(hue / 60.0f, 6.0f); + float intermediate = chroma * (1.0f - fabsf(fmodf(hue_prime, 2.0f) - 1.0f)); float delta = value - chroma; if (0 <= hue_prime && hue_prime < 1) { diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 9c5557bdfc..8449cba5e8 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -356,7 +356,7 @@ void HOT Scheduler::set_retry_common_(Component *component, NameType name_type, } #endif - if (backoff_increase_factor < 0.0001) { + if (backoff_increase_factor < 0.0001f) { ESP_LOGE(TAG, "set_retry: backoff_factor %0.1f too small, using 1.0: %s", backoff_increase_factor, (name_type == NameType::STATIC_STRING && static_name) ? static_name : ""); backoff_increase_factor = 1; From 40820287f17e51b74fbb091d6829f9df6c7ae7c7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:18:13 -0400 Subject: [PATCH 0617/1815] [multiple] Single-precision float math, avoid double promotion (batch 1/4) (#17253) --- esphome/components/daikin_brc/daikin_brc.cpp | 2 +- .../components/dfrobot_sen0395/commands.cpp | 54 +++++++++---------- esphome/components/display/display.cpp | 2 +- .../hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp | 2 +- esphome/components/light/transformers.h | 2 +- .../mcp4461/output/mcp4461_output.cpp | 4 +- esphome/components/opentherm/opentherm.cpp | 2 +- esphome/components/qmp6988/qmp6988.cpp | 2 +- .../shelly_dimmer/shelly_dimmer.cpp | 2 +- .../speaker_source_media_player.cpp | 2 +- esphome/components/veml7700/veml7700.cpp | 2 +- .../xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp | 2 +- 12 files changed, 40 insertions(+), 38 deletions(-) diff --git a/esphome/components/daikin_brc/daikin_brc.cpp b/esphome/components/daikin_brc/daikin_brc.cpp index 5fe3d30a85..1b085013f1 100644 --- a/esphome/components/daikin_brc/daikin_brc.cpp +++ b/esphome/components/daikin_brc/daikin_brc.cpp @@ -151,7 +151,7 @@ uint8_t DaikinBrcClimate::temperature_() { // Temperature in remote is in F if (this->fahrenheit_) { temperature = (uint8_t) roundf( - clamp(((this->target_temperature * 1.8) + 32), DAIKIN_BRC_TEMP_MIN_F, DAIKIN_BRC_TEMP_MAX_F)); + clamp(((this->target_temperature * 1.8f) + 32), DAIKIN_BRC_TEMP_MIN_F, DAIKIN_BRC_TEMP_MAX_F)); } else { temperature = ((uint8_t) roundf(this->target_temperature) - 9) << 1; } diff --git a/esphome/components/dfrobot_sen0395/commands.cpp b/esphome/components/dfrobot_sen0395/commands.cpp index 29ee166f51..570bfef943 100644 --- a/esphome/components/dfrobot_sen0395/commands.cpp +++ b/esphome/components/dfrobot_sen0395/commands.cpp @@ -121,51 +121,51 @@ DetRangeCfgCommand::DetRangeCfgCommand(float min1, float max1, float min2, float this->cmd_ = "detRangeCfg -1 0 0"; } else if (min2 < 0 || max2 < 0) { - this->min1_ = min1 = round(min1 / 0.15) * 0.15; - this->max1_ = max1 = round(max1 / 0.15) * 0.15; + this->min1_ = min1 = roundf(min1 / 0.15f) * 0.15f; + this->max1_ = max1 = roundf(max1 / 0.15f) * 0.15f; this->min2_ = min2 = this->max2_ = max2 = this->min3_ = min3 = this->max3_ = max3 = this->min4_ = min4 = this->max4_ = max4 = -1; char buf[72]; // max 72: "detRangeCfg -1 "(15) + 8 * (float(5) + space(1)) + null - snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f", min1 / 0.15, max1 / 0.15); + snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f", min1 / 0.15f, max1 / 0.15f); this->cmd_ = buf; } else if (min3 < 0 || max3 < 0) { - this->min1_ = min1 = round(min1 / 0.15) * 0.15; - this->max1_ = max1 = round(max1 / 0.15) * 0.15; - this->min2_ = min2 = round(min2 / 0.15) * 0.15; - this->max2_ = max2 = round(max2 / 0.15) * 0.15; + this->min1_ = min1 = roundf(min1 / 0.15f) * 0.15f; + this->max1_ = max1 = roundf(max1 / 0.15f) * 0.15f; + this->min2_ = min2 = roundf(min2 / 0.15f) * 0.15f; + this->max2_ = max2 = roundf(max2 / 0.15f) * 0.15f; this->min3_ = min3 = this->max3_ = max3 = this->min4_ = min4 = this->max4_ = max4 = -1; char buf[72]; // max 72: "detRangeCfg -1 "(15) + 8 * (float(5) + space(1)) + null - snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f %.0f %.0f", min1 / 0.15, max1 / 0.15, min2 / 0.15, - max2 / 0.15); + snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f %.0f %.0f", min1 / 0.15f, max1 / 0.15f, min2 / 0.15f, + max2 / 0.15f); this->cmd_ = buf; } else if (min4 < 0 || max4 < 0) { - this->min1_ = min1 = round(min1 / 0.15) * 0.15; - this->max1_ = max1 = round(max1 / 0.15) * 0.15; - this->min2_ = min2 = round(min2 / 0.15) * 0.15; - this->max2_ = max2 = round(max2 / 0.15) * 0.15; - this->min3_ = min3 = round(min3 / 0.15) * 0.15; - this->max3_ = max3 = round(max3 / 0.15) * 0.15; + this->min1_ = min1 = roundf(min1 / 0.15f) * 0.15f; + this->max1_ = max1 = roundf(max1 / 0.15f) * 0.15f; + this->min2_ = min2 = roundf(min2 / 0.15f) * 0.15f; + this->max2_ = max2 = roundf(max2 / 0.15f) * 0.15f; + this->min3_ = min3 = roundf(min3 / 0.15f) * 0.15f; + this->max3_ = max3 = roundf(max3 / 0.15f) * 0.15f; this->min4_ = min4 = this->max4_ = max4 = -1; char buf[72]; // max 72: "detRangeCfg -1 "(15) + 8 * (float(5) + space(1)) + null - snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f %.0f %.0f %.0f %.0f", min1 / 0.15, max1 / 0.15, min2 / 0.15, - max2 / 0.15, min3 / 0.15, max3 / 0.15); + snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f %.0f %.0f %.0f %.0f", min1 / 0.15f, max1 / 0.15f, min2 / 0.15f, + max2 / 0.15f, min3 / 0.15f, max3 / 0.15f); this->cmd_ = buf; } else { - this->min1_ = min1 = round(min1 / 0.15) * 0.15; - this->max1_ = max1 = round(max1 / 0.15) * 0.15; - this->min2_ = min2 = round(min2 / 0.15) * 0.15; - this->max2_ = max2 = round(max2 / 0.15) * 0.15; - this->min3_ = min3 = round(min3 / 0.15) * 0.15; - this->max3_ = max3 = round(max3 / 0.15) * 0.15; - this->min4_ = min4 = round(min4 / 0.15) * 0.15; - this->max4_ = max4 = round(max4 / 0.15) * 0.15; + this->min1_ = min1 = roundf(min1 / 0.15f) * 0.15f; + this->max1_ = max1 = roundf(max1 / 0.15f) * 0.15f; + this->min2_ = min2 = roundf(min2 / 0.15f) * 0.15f; + this->max2_ = max2 = roundf(max2 / 0.15f) * 0.15f; + this->min3_ = min3 = roundf(min3 / 0.15f) * 0.15f; + this->max3_ = max3 = roundf(max3 / 0.15f) * 0.15f; + this->min4_ = min4 = roundf(min4 / 0.15f) * 0.15f; + this->max4_ = max4 = roundf(max4 / 0.15f) * 0.15f; char buf[72]; // max 72: "detRangeCfg -1 "(15) + 8 * (float(5) + space(1)) + null - snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f %.0f %.0f %.0f %.0f %.0f %.0f", min1 / 0.15, max1 / 0.15, - min2 / 0.15, max2 / 0.15, min3 / 0.15, max3 / 0.15, min4 / 0.15, max4 / 0.15); + snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f %.0f %.0f %.0f %.0f %.0f %.0f", min1 / 0.15f, max1 / 0.15f, + min2 / 0.15f, max2 / 0.15f, min3 / 0.15f, max3 / 0.15f, min4 / 0.15f, max4 / 0.15f); this->cmd_ = buf; } diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index b24c099bce..b30f444d6d 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -228,7 +228,7 @@ void Display::filled_gauge(int center_x, int center_y, int radius1, int radius2, int e2max, e2min; progress = std::max(0, std::min(progress, 100)); // 0..100 int draw_progress = progress > 50 ? (100 - progress) : progress; - float tan_a = (progress == 50) ? 65535 : tan(float(draw_progress) * M_PI / 100); // slope + float tan_a = (progress == 50) ? 65535 : tanf(float(draw_progress) * std::numbers::pi_v / 100); // slope do { // outer dots diff --git a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp index 0b3a746c34..270bb2709d 100644 --- a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp +++ b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp @@ -55,7 +55,7 @@ void HrxlMaxsonarWrComponent::check_buffer_() { millimeters = millimeters * 10; } - float meters = float(millimeters) / 1000.0; + float meters = float(millimeters) / 1000.0f; ESP_LOGV(TAG, "Distance from sensor: %d mm, %f m", millimeters, meters); this->publish_state(meters); } else { diff --git a/esphome/components/light/transformers.h b/esphome/components/light/transformers.h index 61fe098ad7..34e192a034 100644 --- a/esphome/components/light/transformers.h +++ b/esphome/components/light/transformers.h @@ -47,7 +47,7 @@ class LightTransitionTransformer : public LightTransformer { LightColorValues &start = this->changing_color_mode_ && p > 0.5f ? this->intermediate_values_ : this->start_values_; LightColorValues &end = this->changing_color_mode_ && p < 0.5f ? this->intermediate_values_ : this->end_values_; if (this->changing_color_mode_) - p = p < 0.5f ? p * 2 : (p - 0.5) * 2; + p = p < 0.5f ? p * 2 : (p - 0.5f) * 2; float v = LightTransformer::smoothed_progress(p); return LightColorValues::lerp(start, end, v); diff --git a/esphome/components/mcp4461/output/mcp4461_output.cpp b/esphome/components/mcp4461/output/mcp4461_output.cpp index 6912ad5f36..3892372cab 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.cpp +++ b/esphome/components/mcp4461/output/mcp4461_output.cpp @@ -29,7 +29,9 @@ void Mcp4461Wiper::write_state(float state) { } } -float Mcp4461Wiper::read_state() { return (static_cast(this->parent_->get_wiper_level_(this->wiper_)) / 256.0); } +float Mcp4461Wiper::read_state() { + return (static_cast(this->parent_->get_wiper_level_(this->wiper_)) / 256.0f); +} float Mcp4461Wiper::update_state() { this->state_ = this->read_state(); diff --git a/esphome/components/opentherm/opentherm.cpp b/esphome/components/opentherm/opentherm.cpp index 1ee4c9191b..5cf7c19880 100644 --- a/esphome/components/opentherm/opentherm.cpp +++ b/esphome/components/opentherm/opentherm.cpp @@ -541,7 +541,7 @@ void OpenTherm::debug_error(OpenThermError &error) const { error.capture, error.bit_pos); } -float OpenthermData::f88() { return ((float) this->s16()) / 256.0; } +float OpenthermData::f88() { return ((float) this->s16()) / 256.0f; } void OpenthermData::f88(float value) { this->s16((int16_t) (value * 256)); } diff --git a/esphome/components/qmp6988/qmp6988.cpp b/esphome/components/qmp6988/qmp6988.cpp index bb47e7b0f5..547991f75e 100644 --- a/esphome/components/qmp6988/qmp6988.cpp +++ b/esphome/components/qmp6988/qmp6988.cpp @@ -276,7 +276,7 @@ void QMP6988Component::write_oversampling_temperature_(QMP6988Oversampling overs void QMP6988Component::calculate_altitude_(float pressure, float temp) { float altitude; - altitude = (pow((101325 / pressure), 1 / 5.257) - 1) * (temp + 273.15) / 0.0065; + altitude = (powf((101325 / pressure), 1 / 5.257f) - 1) * (temp + 273.15f) / 0.0065f; this->qmp6988_data_.altitude = altitude; } diff --git a/esphome/components/shelly_dimmer/shelly_dimmer.cpp b/esphome/components/shelly_dimmer/shelly_dimmer.cpp index b0f43f0ffc..b69e417591 100644 --- a/esphome/components/shelly_dimmer/shelly_dimmer.cpp +++ b/esphome/components/shelly_dimmer/shelly_dimmer.cpp @@ -207,7 +207,7 @@ bool ShellyDimmer::upgrade_firmware_() { uint16_t ShellyDimmer::convert_brightness_(float brightness) { // Special case for zero as only zero means turn off completely. - if (brightness == 0.0) { + if (brightness == 0.0f) { return 0; } diff --git a/esphome/components/speaker_source/speaker_source_media_player.cpp b/esphome/components/speaker_source/speaker_source_media_player.cpp index 87fd4fe9ed..a33a1a1650 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.cpp +++ b/esphome/components/speaker_source/speaker_source_media_player.cpp @@ -831,7 +831,7 @@ void SpeakerSourceMediaPlayer::set_volume_(float volume, bool publish) { // Turn on the mute state if the volume is effectively zero, off otherwise. // Pass publish=false to avoid saving twice. - if (volume < 0.001) { + if (volume < 0.001f) { this->set_mute_state_(true, false); } else { this->set_mute_state_(false, false); diff --git a/esphome/components/veml7700/veml7700.cpp b/esphome/components/veml7700/veml7700.cpp index 80e6f872ab..594c9da170 100644 --- a/esphome/components/veml7700/veml7700.cpp +++ b/esphome/components/veml7700/veml7700.cpp @@ -380,7 +380,7 @@ void VEML7700Component::apply_lux_compensation_(Readings &data) { // if this light level is exceeded" auto compensate = [&local_data](float &lux) { auto calculate_high_lux_compensation = [](float lux_veml) -> float { - return (((6.0135e-13 * lux_veml - 9.3924e-9) * lux_veml + 8.1488e-5) * lux_veml + 1.0023) * lux_veml; + return (((6.0135e-13f * lux_veml - 9.3924e-9f) * lux_veml + 8.1488e-5f) * lux_veml + 1.0023f) * lux_veml; }; if (lux > 1000.0f || local_data.actual_gain == Gain::X_1_8 || local_data.actual_gain == Gain::X_1_4) { diff --git a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp index a4303b055a..c2b3ec1437 100644 --- a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp +++ b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp @@ -49,7 +49,7 @@ bool XiaomiXMWSDJ04MMC::parse_device(const esp32_ble_tracker::ESPBTDevice &devic } if (res->humidity.has_value() && this->humidity_ != nullptr) { // see https://github.com/custom-components/sensor.mitemp_bt/issues/7#issuecomment-595948254 - *res->humidity = trunc(*res->humidity); + *res->humidity = truncf(*res->humidity); } if (!(xiaomi_ble::report_xiaomi_results(res, addr_str))) { continue; From b7803cf9b5a29613fef70f253281ffc168330452 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:18:20 -0400 Subject: [PATCH 0618/1815] [multiple] Single-precision float math, avoid double promotion (batch 2/4) (#17254) --- esphome/components/a01nyub/a01nyub.cpp | 2 +- .../binary_sensor_map/binary_sensor_map.cpp | 2 +- esphome/components/bl0942/bl0942.cpp | 4 ++-- .../components/dallas_temp/dallas_temp.cpp | 2 +- esphome/components/ds2484/ds2484.h | 2 +- .../grove_tb6612fng/grove_tb6612fng.cpp | 4 ++-- .../components/honeywellabp/honeywellabp.cpp | 8 ++++--- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 2 +- esphome/components/ltr390/ltr390.cpp | 2 +- esphome/components/mcp4725/mcp4725.cpp | 2 +- esphome/components/mics_4514/mics_4514.cpp | 22 +++++++++---------- .../opentherm/output/opentherm_output.cpp | 5 +++-- .../runtime_stats/runtime_stats.cpp | 2 +- .../components/sound_level/sound_level.cpp | 2 +- esphome/components/sx127x/sx127x.cpp | 6 ++--- .../thermopro_ble/thermopro_ble.cpp | 2 +- esphome/components/x9c/x9c.cpp | 2 +- 17 files changed, 37 insertions(+), 34 deletions(-) diff --git a/esphome/components/a01nyub/a01nyub.cpp b/esphome/components/a01nyub/a01nyub.cpp index 344456854b..6111af2b7e 100644 --- a/esphome/components/a01nyub/a01nyub.cpp +++ b/esphome/components/a01nyub/a01nyub.cpp @@ -25,7 +25,7 @@ void A01nyubComponent::check_buffer_() { if (this->buffer_[3] == checksum) { float distance = (this->buffer_[1] << 8) + this->buffer_[2]; if (distance > 280) { - float meters = distance / 1000.0; + float meters = distance / 1000.0f; ESP_LOGV(TAG, "Distance from sensor: %f mm, %f m", distance, meters); this->publish_state(meters); } else { diff --git a/esphome/components/binary_sensor_map/binary_sensor_map.cpp b/esphome/components/binary_sensor_map/binary_sensor_map.cpp index 316d44ba59..3185f15697 100644 --- a/esphome/components/binary_sensor_map/binary_sensor_map.cpp +++ b/esphome/components/binary_sensor_map/binary_sensor_map.cpp @@ -112,7 +112,7 @@ float BinarySensorMap::bayesian_predicate_(bool sensor_state, float prior, float prob_state_source_false = 1 - prob_given_false; } - return prob_state_source_true / (prior * prob_state_source_true + (1.0 - prior) * prob_state_source_false); + return prob_state_source_true / (prior * prob_state_source_true + (1.0f - prior) * prob_state_source_false); } void BinarySensorMap::add_channel(binary_sensor::BinarySensor *sensor, float value) { diff --git a/esphome/components/bl0942/bl0942.cpp b/esphome/components/bl0942/bl0942.cpp index 1c57616c82..e952df21be 100644 --- a/esphome/components/bl0942/bl0942.cpp +++ b/esphome/components/bl0942/bl0942.cpp @@ -124,14 +124,14 @@ void BL0942::setup() { // If either current or voltage references are set explicitly by the user, // calculate the power reference from it unless that is also explicitly set. if ((this->current_reference_set_ || this->voltage_reference_set_) && !this->power_reference_set_) { - this->power_reference_ = (this->voltage_reference_ * this->current_reference_ * 3537.0 / 305978.0) / 73989.0; + this->power_reference_ = (this->voltage_reference_ * this->current_reference_ * 3537.0f / 305978.0f) / 73989.0f; this->power_reference_set_ = true; } // Similarly for energy reference, if the power reference was set by the user // either implicitly or explicitly. if (this->power_reference_set_ && !this->energy_reference_set_) { - this->energy_reference_ = this->power_reference_ * 3600000 / 419430.4; + this->energy_reference_ = this->power_reference_ * 3600000 / 419430.4f; this->energy_reference_set_ = true; } diff --git a/esphome/components/dallas_temp/dallas_temp.cpp b/esphome/components/dallas_temp/dallas_temp.cpp index 35488eab03..ab4a8c458f 100644 --- a/esphome/components/dallas_temp/dallas_temp.cpp +++ b/esphome/components/dallas_temp/dallas_temp.cpp @@ -138,7 +138,7 @@ float DallasTemperatureSensor::get_temp_c_() { if (this->scratch_pad_[7] == 0) { return NAN; } - return (temp >> 1) + (this->scratch_pad_[7] - this->scratch_pad_[6]) / float(this->scratch_pad_[7]) - 0.25; + return (temp >> 1) + (this->scratch_pad_[7] - this->scratch_pad_[6]) / float(this->scratch_pad_[7]) - 0.25f; } switch (this->resolution_) { case 9: diff --git a/esphome/components/ds2484/ds2484.h b/esphome/components/ds2484/ds2484.h index b3337539ce..819b9456c1 100644 --- a/esphome/components/ds2484/ds2484.h +++ b/esphome/components/ds2484/ds2484.h @@ -12,7 +12,7 @@ class DS2484OneWireBus final : public one_wire::OneWireBus, public i2c::I2CDevic public: void setup() override; void dump_config() override; - float get_setup_priority() const override { return setup_priority::BUS - 1.0; } + float get_setup_priority() const override { return setup_priority::BUS - 1.0f; } bool reset_device(); int reset_int() override; diff --git a/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp b/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp index eaa1440c4d..2c68eef623 100644 --- a/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp +++ b/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp @@ -122,7 +122,7 @@ void GroveMotorDriveTB6612FNG::stepper_run(StepperModeTypeT mode, int16_t steps, rpm = clamp(rpm, 1, 300); - ms_per_step = (uint16_t) (3000.0 / (float) rpm); + ms_per_step = (uint16_t) (3000.0f / (float) rpm); buffer_[0] = mode; buffer_[1] = cw; //(cw=1) => cw; (cw=0) => ccw buffer_[2] = steps; @@ -153,7 +153,7 @@ void GroveMotorDriveTB6612FNG::stepper_keep_run(StepperModeTypeT mode, uint16_t uint16_t ms_per_step = 0; rpm = clamp(rpm, 1, 300); - ms_per_step = (uint16_t) (3000.0 / (float) rpm); + ms_per_step = (uint16_t) (3000.0f / (float) rpm); buffer_[0] = mode; buffer_[1] = cw; //(cw=1) => cw; (cw=0) => ccw diff --git a/esphome/components/honeywellabp/honeywellabp.cpp b/esphome/components/honeywellabp/honeywellabp.cpp index 8bfc5e4f4f..dd86b95c78 100644 --- a/esphome/components/honeywellabp/honeywellabp.cpp +++ b/esphome/components/honeywellabp/honeywellabp.cpp @@ -55,7 +55,9 @@ float HONEYWELLABPSensor::countstopressure_(const int counts, const float min_pr // Converts a digital temperature measurement in counts to temperature in C // This will be invalid if sensore daoes not have temperature measurement capability -float HONEYWELLABPSensor::countstotemperatures_(const int counts) { return (((float) counts / 2047.0) * 200.0) - 50.0; } +float HONEYWELLABPSensor::countstotemperatures_(const int counts) { + return (((float) counts / 2047.0f) * 200.0f) - 50.0f; +} // Pressure value from the most recent reading in units float HONEYWELLABPSensor::read_pressure_() { @@ -69,9 +71,9 @@ void HONEYWELLABPSensor::update() { ESP_LOGV(TAG, "Update Honeywell ABP Sensor"); if (readsensor_() == 0) { if (this->pressure_sensor_ != nullptr) - this->pressure_sensor_->publish_state(read_pressure_() * 1.0); + this->pressure_sensor_->publish_state(read_pressure_() * 1.0f); if (this->temperature_sensor_ != nullptr) - this->temperature_sensor_->publish_state(read_temperature_() * 1.0); + this->temperature_sensor_->publish_state(read_temperature_() * 1.0f); } } diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index c6ff42495f..5e271e671e 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -139,7 +139,7 @@ void I2SAudioSpeakerBase::set_volume(float volume) { this->volume_ = volume; #ifdef USE_AUDIO_DAC if (this->audio_dac_ != nullptr) { - if (volume > 0.0) { + if (volume > 0.0f) { this->audio_dac_->set_mute_off(); } this->audio_dac_->set_volume(volume); diff --git a/esphome/components/ltr390/ltr390.cpp b/esphome/components/ltr390/ltr390.cpp index 62a0d2290a..dd78b20f2c 100644 --- a/esphome/components/ltr390/ltr390.cpp +++ b/esphome/components/ltr390/ltr390.cpp @@ -75,7 +75,7 @@ void LTR390Component::read_als_() { uint32_t als = *val; if (this->light_sensor_ != nullptr) { - float lux = ((0.6 * als) / (GAINVALUES[this->gain_als_] * RESOLUTIONVALUE[this->res_als_])) * this->wfac_; + float lux = ((0.6f * als) / (GAINVALUES[this->gain_als_] * RESOLUTIONVALUE[this->res_als_])) * this->wfac_; this->light_sensor_->publish_state(lux); } diff --git a/esphome/components/mcp4725/mcp4725.cpp b/esphome/components/mcp4725/mcp4725.cpp index a32527c725..21aff90fae 100644 --- a/esphome/components/mcp4725/mcp4725.cpp +++ b/esphome/components/mcp4725/mcp4725.cpp @@ -24,7 +24,7 @@ void MCP4725::dump_config() { // https://learn.sparkfun.com/tutorials/mcp4725-digital-to-analog-converter-hookup-guide?_ga=2.176055202.1402343014.1607953301-893095255.1606753886 void MCP4725::write_state(float state) { - const uint16_t value = (uint16_t) round(state * (pow(2, MCP4725_RES) - 1)); + const uint16_t value = (uint16_t) roundf(state * (powf(2, MCP4725_RES) - 1)); this->write_byte_16(64, value << 4); } diff --git a/esphome/components/mics_4514/mics_4514.cpp b/esphome/components/mics_4514/mics_4514.cpp index d99d4fd772..14a73bc15f 100644 --- a/esphome/components/mics_4514/mics_4514.cpp +++ b/esphome/components/mics_4514/mics_4514.cpp @@ -71,10 +71,10 @@ void MICS4514Component::update() { float co = 0.0f; if (red_f > 3.4f) { co = 0.0; - } else if (red_f < 0.01) { + } else if (red_f < 0.01f) { co = 1000.0; } else { - co = 4.2 / pow(red_f, 1.2); + co = 4.2f / powf(red_f, 1.2f); } this->carbon_monoxide_sensor_->publish_state(co); } @@ -84,47 +84,47 @@ void MICS4514Component::update() { if (ox_f < 0.3f) { nitrogendioxide = 0.0; } else { - nitrogendioxide = 0.164 * pow(ox_f, 0.975); + nitrogendioxide = 0.164f * powf(ox_f, 0.975f); } this->nitrogen_dioxide_sensor_->publish_state(nitrogendioxide); } if (this->methane_sensor_ != nullptr) { float methane = 0.0f; - if (red_f > 0.9f || red_f < 0.5) { // outside the range->unlikely + if (red_f > 0.9f || red_f < 0.5f) { // outside the range->unlikely methane = 0.0; } else { - methane = 630 / pow(red_f, 4.4); + methane = 630 / powf(red_f, 4.4f); } this->methane_sensor_->publish_state(methane); } if (this->ethanol_sensor_ != nullptr) { float ethanol = 0.0f; - if (red_f > 1.0f || red_f < 0.02) { // outside the range->unlikely + if (red_f > 1.0f || red_f < 0.02f) { // outside the range->unlikely ethanol = 0.0; } else { - ethanol = 1.52 / pow(red_f, 1.55); + ethanol = 1.52f / powf(red_f, 1.55f); } this->ethanol_sensor_->publish_state(ethanol); } if (this->hydrogen_sensor_ != nullptr) { float hydrogen = 0.0f; - if (red_f > 0.9f || red_f < 0.02) { // outside the range->unlikely + if (red_f > 0.9f || red_f < 0.02f) { // outside the range->unlikely hydrogen = 0.0; } else { - hydrogen = 0.85 / pow(red_f, 1.75); + hydrogen = 0.85f / powf(red_f, 1.75f); } this->hydrogen_sensor_->publish_state(hydrogen); } if (this->ammonia_sensor_ != nullptr) { float ammonia = 0.0f; - if (red_f > 0.98f || red_f < 0.2532) { // outside the ammonia range->unlikely + if (red_f > 0.98f || red_f < 0.2532f) { // outside the ammonia range->unlikely ammonia = 0.0; } else { - ammonia = 0.9 / pow(red_f, 4.6); + ammonia = 0.9f / powf(red_f, 4.6f); } this->ammonia_sensor_->publish_state(ammonia); } diff --git a/esphome/components/opentherm/output/opentherm_output.cpp b/esphome/components/opentherm/output/opentherm_output.cpp index 4092358d75..9b87cd8d12 100644 --- a/esphome/components/opentherm/output/opentherm_output.cpp +++ b/esphome/components/opentherm/output/opentherm_output.cpp @@ -12,8 +12,9 @@ void opentherm::OpenthermOutput::write_state(float state) { #else bool zero_means_zero = false; #endif - this->state = - state < 0.003 && zero_means_zero ? 0.0 : clamp(std::lerp(min_value_, max_value_, state), min_value_, max_value_); + this->state = state < 0.003f && zero_means_zero + ? 0.0f + : clamp(std::lerp(min_value_, max_value_, state), min_value_, max_value_); this->has_state_ = true; ESP_LOGD(TAG, "Output %s set to %.2f", this->id_, this->state); } diff --git a/esphome/components/runtime_stats/runtime_stats.cpp b/esphome/components/runtime_stats/runtime_stats.cpp index d733394b78..12e4d14ba2 100644 --- a/esphome/components/runtime_stats/runtime_stats.cpp +++ b/esphome/components/runtime_stats/runtime_stats.cpp @@ -95,7 +95,7 @@ void RuntimeStatsCollector::log_stats_() { ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.total_count, stats.total_count > 0 ? stats.total_time_us / (float) stats.total_count / 1000.0f : 0.0f, - stats.total_max_time_us / 1000.0f, stats.total_time_us / 1000.0); + stats.total_max_time_us / 1000.0f, stats.total_time_us / 1000.0f); } } diff --git a/esphome/components/sound_level/sound_level.cpp b/esphome/components/sound_level/sound_level.cpp index a93e396367..99ab7932d6 100644 --- a/esphome/components/sound_level/sound_level.cpp +++ b/esphome/components/sound_level/sound_level.cpp @@ -121,7 +121,7 @@ void SoundLevelComponent::loop() { if (this->sample_count_ == samples_in_window) { // Processed enough samples for the measurement window, compute and publish the sensor values if (this->peak_sensor_ != nullptr) { - const float peak_db = 10.0f * log10(static_cast(this->squared_peak_) / MAX_SAMPLE_SQUARED_DENOMINATOR); + const float peak_db = 10.0f * log10f(static_cast(this->squared_peak_) / MAX_SAMPLE_SQUARED_DENOMINATOR); this->peak_sensor_->publish_state(peak_db); this->squared_peak_ = 0; // reset accumulator diff --git a/esphome/components/sx127x/sx127x.cpp b/esphome/components/sx127x/sx127x.cpp index 0596e91ccc..040a3064bc 100644 --- a/esphome/components/sx127x/sx127x.cpp +++ b/esphome/components/sx127x/sx127x.cpp @@ -201,8 +201,8 @@ void SX127x::configure_fsk_ook_() { this->write_register_(REG_OOK_AVG, OOK_AVG_RESERVED | OOK_THRESH_DEC_1_8); // set rx floor - this->write_register_(REG_OOK_FIX, 256 + int(this->rx_floor_ * 2.0)); - this->write_register_(REG_RSSI_THRESH, std::abs(int(this->rx_floor_ * 2.0))); + this->write_register_(REG_OOK_FIX, 256 + int(this->rx_floor_ * 2.0f)); + this->write_register_(REG_RSSI_THRESH, std::abs(int(this->rx_floor_ * 2.0f))); } void SX127x::configure_lora_() { @@ -225,7 +225,7 @@ void SX127x::configure_lora_() { } // optimize detection - float duration = 1000.0f * std::pow(2, this->spreading_factor_) / BW_HZ[this->bandwidth_]; + float duration = 1000.0f * (1UL << this->spreading_factor_) / BW_HZ[this->bandwidth_]; if (duration > 16) { this->write_register_(REG_MODEM_CONFIG3, MODEM_AGC_AUTO_ON | LOW_DATA_RATE_OPTIMIZE_ON); } else { diff --git a/esphome/components/thermopro_ble/thermopro_ble.cpp b/esphome/components/thermopro_ble/thermopro_ble.cpp index 1ccf59a2f6..2a950d3664 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.cpp +++ b/esphome/components/thermopro_ble/thermopro_ble.cpp @@ -196,7 +196,7 @@ static optional parse_tp3(const uint8_t *data, std::size_t data_siz result.humidity = static_cast(data[3]); // battery level, 2 bits (0-2) - result.battery_level = static_cast(data[4] & 0x3) * 50.0; + result.battery_level = static_cast(data[4] & 0x3) * 50.0f; return result; } diff --git a/esphome/components/x9c/x9c.cpp b/esphome/components/x9c/x9c.cpp index 52ce328b3c..b0ad79e51c 100644 --- a/esphome/components/x9c/x9c.cpp +++ b/esphome/components/x9c/x9c.cpp @@ -44,7 +44,7 @@ void X9cOutput::setup() { this->ud_pin_->get_pin(); this->ud_pin_->setup(); - if (this->initial_value_ <= 0.50) { + if (this->initial_value_ <= 0.50f) { this->trim_value(-101); // Set min value (beyond 0) this->trim_value(lroundf(this->initial_value_ * 100)); } else { From 556def78aaaec597f7bd737de77c7c89e46e08a3 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:18:30 -0400 Subject: [PATCH 0619/1815] [multiple] Single-precision float math, avoid double promotion (batch 3/4) (#17255) --- esphome/components/anova/anova_base.cpp | 4 ++-- esphome/components/bl0906/bl0906.cpp | 2 +- esphome/components/combination/combination.cpp | 2 +- esphome/components/demo/demo_sensor.h | 2 +- esphome/components/demo/demo_text_sensor.h | 4 ++-- esphome/components/es7243e/es7243e.cpp | 8 ++++---- esphome/components/haier/hon_climate.cpp | 2 +- esphome/components/ina219/ina219.cpp | 2 +- esphome/components/light/light_color_values.h | 4 ++-- esphome/components/ltr501/ltr501.cpp | 12 ++++++------ esphome/components/max17043/max17043.cpp | 2 +- esphome/components/msa3xx/msa3xx.cpp | 2 +- esphome/components/openthread/openthread.h | 2 +- esphome/components/spa06_base/spa06_base.cpp | 2 +- esphome/components/tcs34725/tcs34725.cpp | 4 ++-- esphome/components/toshiba/toshiba.cpp | 2 +- esphome/components/ufire_ise/ufire_ise.cpp | 8 ++++---- .../xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp | 2 +- 18 files changed, 33 insertions(+), 33 deletions(-) diff --git a/esphome/components/anova/anova_base.cpp b/esphome/components/anova/anova_base.cpp index 84dd4393eb..806a441dcd 100644 --- a/esphome/components/anova/anova_base.cpp +++ b/esphome/components/anova/anova_base.cpp @@ -6,9 +6,9 @@ namespace esphome::anova { -float ftoc(float f) { return (f - 32.0) * (5.0f / 9.0f); } +float ftoc(float f) { return (f - 32.0f) * (5.0f / 9.0f); } -float ctof(float c) { return (c * 9.0f / 5.0f) + 32.0; } +float ctof(float c) { return (c * 9.0f / 5.0f) + 32.0f; } AnovaPacket *AnovaCodec::clean_packet_() { this->packet_.length = strlen((char *) this->packet_.data); diff --git a/esphome/components/bl0906/bl0906.cpp b/esphome/components/bl0906/bl0906.cpp index d387757051..9a27cffd04 100644 --- a/esphome/components/bl0906/bl0906.cpp +++ b/esphome/components/bl0906/bl0906.cpp @@ -205,7 +205,7 @@ void BL0906::read_data_(const uint8_t address, const float reference, sensor::Se // Chip temperature if (reference == BL0906_TREF) { value = (float) to_int32_t(data_s24); - value = (value - 64) * 12.5 / 59 - 40; + value = (value - 64) * 12.5f / 59 - 40; } sensor->publish_state(value); } diff --git a/esphome/components/combination/combination.cpp b/esphome/components/combination/combination.cpp index ddf1a105e0..8ef0976e3b 100644 --- a/esphome/components/combination/combination.cpp +++ b/esphome/components/combination/combination.cpp @@ -204,7 +204,7 @@ void MedianCombinationComponent::handle_new_value(float value) { median = sensor_states[sensor_states_size / 2]; } else { // Even number of measurements, use the average of the two middle measurements - median = (sensor_states[sensor_states_size / 2] + sensor_states[sensor_states_size / 2 - 1]) / 2.0; + median = (sensor_states[sensor_states_size / 2] + sensor_states[sensor_states_size / 2 - 1]) / 2.0f; } } diff --git a/esphome/components/demo/demo_sensor.h b/esphome/components/demo/demo_sensor.h index 6153c810e1..ff2163776c 100644 --- a/esphome/components/demo/demo_sensor.h +++ b/esphome/components/demo/demo_sensor.h @@ -15,7 +15,7 @@ class DemoSensor final : public sensor::Sensor, public PollingComponent { float base = std::isnan(this->state) ? 0.0f : this->state; this->publish_state(base + val * 10); } else { - if (val < 0.1) { + if (val < 0.1f) { this->publish_state(NAN); } else { this->publish_state(val * 100); diff --git a/esphome/components/demo/demo_text_sensor.h b/esphome/components/demo/demo_text_sensor.h index fa728903d9..8eaa6c6b46 100644 --- a/esphome/components/demo/demo_text_sensor.h +++ b/esphome/components/demo/demo_text_sensor.h @@ -10,9 +10,9 @@ class DemoTextSensor final : public text_sensor::TextSensor, public PollingCompo public: void update() override { float val = random_float(); - if (val < 0.33) { + if (val < 0.33f) { this->publish_state("foo"); - } else if (val < 0.66) { + } else if (val < 0.66f) { this->publish_state("bar"); } else { this->publish_state("foobar"); diff --git a/esphome/components/es7243e/es7243e.cpp b/esphome/components/es7243e/es7243e.cpp index b4d9fba4c5..fc3cba7ae4 100644 --- a/esphome/components/es7243e/es7243e.cpp +++ b/esphome/components/es7243e/es7243e.cpp @@ -105,14 +105,14 @@ bool ES7243E::configure_mic_gain_() { uint8_t ES7243E::es7243e_gain_reg_value_(float mic_gain) { // reg: 12 - 34.5dB, 13 - 36dB, 14 - 37.5dB - mic_gain += 0.5; - if (mic_gain <= 33.0) { + mic_gain += 0.5f; + if (mic_gain <= 33.0f) { return (uint8_t) mic_gain / 3; } - if (mic_gain < 36.0) { + if (mic_gain < 36.0f) { return 12; } - if (mic_gain < 37.0) { + if (mic_gain < 37.0f) { return 13; } return 14; diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index 0ad9b00ce4..f68404afd9 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -607,7 +607,7 @@ haier_protocol::HaierMessage HonClimate::get_control_message() { if (climate_control.target_temperature.has_value()) { float target_temp = climate_control.target_temperature.value(); out_data->set_point = ((int) target_temp) - 16; // set the temperature with offset 16 - out_data->half_degree = (target_temp - ((int) target_temp) >= 0.49) ? 1 : 0; + out_data->half_degree = (target_temp - ((int) target_temp) >= 0.49f) ? 1 : 0; } if (out_data->ac_power == 0) { // If AC is off - no presets allowed diff --git a/esphome/components/ina219/ina219.cpp b/esphome/components/ina219/ina219.cpp index 85da196584..833d1989c8 100644 --- a/esphome/components/ina219/ina219.cpp +++ b/esphome/components/ina219/ina219.cpp @@ -119,7 +119,7 @@ void INA219Component::setup() { } this->calibration_lsb_ = lsb; - auto calibration = uint32_t(0.04096f / (0.000001 * lsb * this->shunt_resistance_ohm_)); + auto calibration = uint32_t(0.04096f / (0.000001f * lsb * this->shunt_resistance_ohm_)); ESP_LOGV(TAG, " Using LSB=%" PRIu32 " calibration=%" PRIu32, lsb, calibration); if (!this->write_byte_16(INA219_REGISTER_CALIBRATION, calibration)) { this->mark_failed(); diff --git a/esphome/components/light/light_color_values.h b/esphome/components/light/light_color_values.h index 5cafa9fe82..e431a06df6 100644 --- a/esphome/components/light/light_color_values.h +++ b/esphome/components/light/light_color_values.h @@ -315,14 +315,14 @@ class LightColorValues { if (this->color_temperature_ <= 0) { return this->color_temperature_; } - return 1000000.0 / this->color_temperature_; + return 1000000.0f / this->color_temperature_; } /// Set the color temperature property of these light color values in kelvin. void set_color_temperature_kelvin(float color_temperature) { if (color_temperature <= 0) { return; } - this->color_temperature_ = 1000000.0 / color_temperature; + this->color_temperature_ = 1000000.0f / color_temperature; } /// Get the cold white property of these light color values. In range 0.0 to 1.0. diff --git a/esphome/components/ltr501/ltr501.cpp b/esphome/components/ltr501/ltr501.cpp index 9cba06e483..afdc271167 100644 --- a/esphome/components/ltr501/ltr501.cpp +++ b/esphome/components/ltr501/ltr501.cpp @@ -500,12 +500,12 @@ void LTRAlsPs501Component::apply_lux_calculation_(AlsReadings &data) { // method from // https://github.com/fards/Ainol_fire_kernel/blob/83832cf8a3082fd8e963230f4b1984479d1f1a84/customer/drivers/lightsensor/ltr501als.c#L295 - if (ratio < 0.45) { - lux = 1.7743 * ch0 + 1.1059 * ch1; - } else if (ratio < 0.64) { - lux = 3.7725 * ch0 - 1.3363 * ch1; - } else if (ratio < 0.85) { - lux = 1.6903 * ch0 - 0.1693 * ch1; + if (ratio < 0.45f) { + lux = 1.7743f * ch0 + 1.1059f * ch1; + } else if (ratio < 0.64f) { + lux = 3.7725f * ch0 - 1.3363f * ch1; + } else if (ratio < 0.85f) { + lux = 1.6903f * ch0 - 0.1693f * ch1; } else { ESP_LOGW(TAG, "Impossible ch1/(ch0 + ch1) ratio"); lux = 0.0f; diff --git a/esphome/components/max17043/max17043.cpp b/esphome/components/max17043/max17043.cpp index b59bac7ebf..8776bb5558 100644 --- a/esphome/components/max17043/max17043.cpp +++ b/esphome/components/max17043/max17043.cpp @@ -23,7 +23,7 @@ void MAX17043Component::update() { if (!this->read_byte_16(MAX17043_VCELL, &raw_voltage)) { this->status_set_warning(LOG_STR("Unable to read MAX17043_VCELL")); } else { - float voltage = (1.25 * (float) (raw_voltage >> 4)) / 1000.0; + float voltage = (1.25f * (float) (raw_voltage >> 4)) / 1000.0f; this->voltage_sensor_->publish_state(voltage); this->status_clear_warning(); } diff --git a/esphome/components/msa3xx/msa3xx.cpp b/esphome/components/msa3xx/msa3xx.cpp index f23fcfc8ea..ecde0cb117 100644 --- a/esphome/components/msa3xx/msa3xx.cpp +++ b/esphome/components/msa3xx/msa3xx.cpp @@ -10,7 +10,7 @@ static const char *const TAG = "msa3xx"; const uint8_t MSA_3XX_PART_ID = 0x13; const float GRAVITY_EARTH = 9.80665f; -const float LSB_COEFF = 1000.0f / (GRAVITY_EARTH * 3.9); // LSB to 1 LSB = 3.9mg = 0.0039g +const float LSB_COEFF = 1000.0f / (GRAVITY_EARTH * 3.9f); // LSB to 1 LSB = 3.9mg = 0.0039g const float G_OFFSET_MIN = -4.5f; // -127...127 LSB = +- 0.4953g = +- 4.857 m/s^2 => +- 4.5 for the safe const float G_OFFSET_MAX = 4.5f; // -127...127 LSB = +- 0.4953g = +- 4.857 m/s^2 => +- 4.5 for the safe diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index a96941325c..eb48d8a74a 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -85,7 +85,7 @@ class OpenThreadSrpComponent final : public Component { public: void set_mdns(esphome::mdns::MDNSComponent *mdns); // This has to run after the mdns component or else no services are available to advertise - float get_setup_priority() const override { return this->mdns_->get_setup_priority() - 1.0; } + float get_setup_priority() const override { return this->mdns_->get_setup_priority() - 1.0f; } void setup() override; static void srp_callback(otError err, const otSrpClientHostInfo *host_info, const otSrpClientService *services, const otSrpClientService *removed_services, void *context); diff --git a/esphome/components/spa06_base/spa06_base.cpp b/esphome/components/spa06_base/spa06_base.cpp index b0490628cb..d3de5168e4 100644 --- a/esphome/components/spa06_base/spa06_base.cpp +++ b/esphome/components/spa06_base/spa06_base.cpp @@ -224,7 +224,7 @@ bool SPA06Component::soft_reset_() { } // Temperature conversion formula. See datasheet pg. 14 -float SPA06Component::convert_temperature_(const float &t_raw_sc) { return this->c0_ * 0.5 + this->c1_ * t_raw_sc; } +float SPA06Component::convert_temperature_(const float &t_raw_sc) { return this->c0_ * 0.5f + this->c1_ * t_raw_sc; } // Pressure conversion formula. See datasheet pg. 14 float SPA06Component::convert_pressure_(const float &p_raw_sc, const float &t_raw_sc) { float p2_raw_sc = p_raw_sc * p_raw_sc; diff --git a/esphome/components/tcs34725/tcs34725.cpp b/esphome/components/tcs34725/tcs34725.cpp index 40c65e9f84..b585392790 100644 --- a/esphome/components/tcs34725/tcs34725.cpp +++ b/esphome/components/tcs34725/tcs34725.cpp @@ -256,7 +256,7 @@ void TCS34725Component::update() { // increase only if not already maximum // do not use max gain, as ist will not get better if (this->gain_reg_ < 3) { - if (((float) raw_c / 655.35 < 20.f) && (this->integration_time_ > 600.f)) { + if (((float) raw_c / 655.35f < 20.f) && (this->integration_time_ > 600.f)) { gain_reg_val_new = this->gain_reg_ + 1; // update integration time to new situation integration_time_ideal = integration_time_ideal / 4; @@ -265,7 +265,7 @@ void TCS34725Component::update() { // decrease gain, if very high clear values and integration times alreadey low if (this->gain_reg_ > 0) { - if (70 < ((float) raw_c / 655.35) && (this->integration_time_ < 200)) { + if (70 < ((float) raw_c / 655.35f) && (this->integration_time_ < 200)) { gain_reg_val_new = this->gain_reg_ - 1; // update integration time to new situation integration_time_ideal = integration_time_ideal * 4; diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index 1b37c6897d..19950bbd15 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -593,7 +593,7 @@ void ToshibaClimate::transmit_rac_pt1411hwru_() { message[3] = ~message[2]; // Byte 4u: Temp if (this->model_ == MODEL_RAC_PT1411HWRU_F) { - temperature = (temperature * 1.8) + 32; + temperature = (temperature * 1.8f) + 32; temp_adjd = temperature - TOSHIBA_RAC_PT1411HWRU_TEMP_F_MIN; } diff --git a/esphome/components/ufire_ise/ufire_ise.cpp b/esphome/components/ufire_ise/ufire_ise.cpp index bd2dc2836e..d595b37a83 100644 --- a/esphome/components/ufire_ise/ufire_ise.cpp +++ b/esphome/components/ufire_ise/ufire_ise.cpp @@ -70,19 +70,19 @@ float UFireISEComponent::measure_ph_(float temperature) { if (mv == -1) return -1; - ph = fabs(7.0 - (mv / PROBE_MV_TO_PH)); + ph = fabsf(7.0f - (mv / PROBE_MV_TO_PH)); // Determine the temperature correction float distance_from_7 = std::abs(7 - roundf(ph)); float distance_from_25 = std::floor(std::abs(25 - roundf(temperature)) / 10); float temp_multiplier = (distance_from_25 * distance_from_7) * PROBE_TMP_CORRECTION; - if ((ph >= 8.0) && (temperature >= 35)) + if ((ph >= 8.0f) && (temperature >= 35)) temp_multiplier *= -1; - if ((ph <= 6.0) && (temperature <= 15)) + if ((ph <= 6.0f) && (temperature <= 15)) temp_multiplier *= -1; ph += temp_multiplier; - if ((ph <= 0.0) || (ph > 14.0)) + if ((ph <= 0.0f) || (ph > 14.0f)) ph = -1; if (std::isinf(ph)) ph = -1; diff --git a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp index a0a9260156..7aa4809e24 100644 --- a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp +++ b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp @@ -49,7 +49,7 @@ bool XiaomiLYWSD03MMC::parse_device(const esp32_ble_tracker::ESPBTDevice &device } if (res->humidity.has_value() && this->humidity_ != nullptr) { // see https://github.com/custom-components/sensor.mitemp_bt/issues/7#issuecomment-595948254 - *res->humidity = trunc(*res->humidity); + *res->humidity = truncf(*res->humidity); } if (!(xiaomi_ble::report_xiaomi_results(res, addr_str))) { continue; From 95449068e72b10be110a0d4fc070e4b2ae87c1f9 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:18:41 -0400 Subject: [PATCH 0620/1815] [multiple] Single-precision float math, avoid double promotion (batch 4/4) (#17256) --- esphome/components/am43/cover/am43_cover.cpp | 10 +++++----- esphome/components/bl0940/bl0940.cpp | 2 +- .../components/current_based/current_based_cover.cpp | 2 +- esphome/components/demo/demo_switch.h | 2 +- esphome/components/es7210/es7210.cpp | 8 ++++---- esphome/components/graph/graph.cpp | 4 ++-- esphome/components/haier/smartair2_climate.cpp | 2 +- esphome/components/ina226/ina226.cpp | 2 +- esphome/components/ltr_als_ps/ltr_als_ps.cpp | 12 ++++++------ esphome/components/mcp3204/mcp3204.cpp | 2 +- esphome/components/mpl3115a2/mpl3115a2.cpp | 6 +++--- esphome/components/mqtt/mqtt_climate.cpp | 4 ++-- esphome/components/nextion/nextion_commands.cpp | 2 +- esphome/components/pid/pid_autotuner.cpp | 2 +- esphome/components/servo/servo.cpp | 2 +- .../speaker/media_player/speaker_media_player.cpp | 2 +- esphome/components/veml3235/veml3235.cpp | 2 +- esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp | 2 +- 18 files changed, 34 insertions(+), 34 deletions(-) diff --git a/esphome/components/am43/cover/am43_cover.cpp b/esphome/components/am43/cover/am43_cover.cpp index 35366dbaa6..4b096983a4 100644 --- a/esphome/components/am43/cover/am43_cover.cpp +++ b/esphome/components/am43/cover/am43_cover.cpp @@ -114,13 +114,13 @@ void Am43Component::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->decoder_->decode(param->notify.value, param->notify.value_len); if (this->decoder_->has_position()) { - this->position = ((float) this->decoder_->position_ / 100.0); + this->position = ((float) this->decoder_->position_ / 100.0f); if (!this->invert_position_) this->position = 1 - this->position; - if (this->position > 0.97) - this->position = 1.0; - if (this->position < 0.02) - this->position = 0.0; + if (this->position > 0.97f) + this->position = 1.0f; + if (this->position < 0.02f) + this->position = 0.0f; this->publish_state(); } diff --git a/esphome/components/bl0940/bl0940.cpp b/esphome/components/bl0940/bl0940.cpp index b7df603f2f..642368d93f 100644 --- a/esphome/components/bl0940/bl0940.cpp +++ b/esphome/components/bl0940/bl0940.cpp @@ -120,7 +120,7 @@ float BL0940::calculate_power_reference_() { float BL0940::calculate_energy_reference_() { // formula: 3600000 * 4046 * RL * R1 * 1000 / (1638.4 * 256) / Vref² / (R1 + R2) // or: power_reference_ * 3600000 / (1638.4 * 256) - return this->power_reference_cal_ * 3600000 / (1638.4 * 256); + return this->power_reference_cal_ * 3600000 / (1638.4f * 256); } float BL0940::calculate_calibration_value_(float state) { return (100 + state) / 100; } diff --git a/esphome/components/current_based/current_based_cover.cpp b/esphome/components/current_based/current_based_cover.cpp index 5a499d54a4..d15b310a37 100644 --- a/esphome/components/current_based/current_based_cover.cpp +++ b/esphome/components/current_based/current_based_cover.cpp @@ -39,7 +39,7 @@ void CurrentBasedCover::control(const CoverCall &call) { auto opt_pos = call.get_position(); if (opt_pos.has_value()) { auto pos = *opt_pos; - if (fabsf(this->position - pos) < 0.01) { + if (fabsf(this->position - pos) < 0.01f) { // already at target } else { auto op = pos < this->position ? COVER_OPERATION_CLOSING : COVER_OPERATION_OPENING; diff --git a/esphome/components/demo/demo_switch.h b/esphome/components/demo/demo_switch.h index 6846b8b663..dea975a770 100644 --- a/esphome/components/demo/demo_switch.h +++ b/esphome/components/demo/demo_switch.h @@ -9,7 +9,7 @@ namespace esphome::demo { class DemoSwitch final : public switch_::Switch, public Component { public: void setup() override { - bool initial = random_float() < 0.5; + bool initial = random_float() < 0.5f; this->publish_state(initial); } diff --git a/esphome/components/es7210/es7210.cpp b/esphome/components/es7210/es7210.cpp index bbd966fbe0..892b67b270 100644 --- a/esphome/components/es7210/es7210.cpp +++ b/esphome/components/es7210/es7210.cpp @@ -169,14 +169,14 @@ bool ES7210::configure_mic_gain_() { uint8_t ES7210::es7210_gain_reg_value_(float mic_gain) { // reg: 12 - 34.5dB, 13 - 36dB, 14 - 37.5dB - mic_gain += 0.5; - if (mic_gain <= 33.0) { + mic_gain += 0.5f; + if (mic_gain <= 33.0f) { return (uint8_t) (mic_gain / 3); } - if (mic_gain < 36.0) { + if (mic_gain < 36.0f) { return 12; } - if (mic_gain < 37.0) { + if (mic_gain < 37.0f) { return 13; } return 14; diff --git a/esphome/components/graph/graph.cpp b/esphome/components/graph/graph.cpp index 5d59f60509..9ceb2f2ba0 100644 --- a/esphome/components/graph/graph.cpp +++ b/esphome/components/graph/graph.cpp @@ -139,7 +139,7 @@ void Graph::draw(Display *buff, uint16_t x_offset, uint16_t y_offset, Color colo /// Draw grid if (!std::isnan(this->gridspacing_y_)) { for (int y = yn; y <= ym; y++) { - int16_t py = (int16_t) roundf((this->height_ - 1) * (1.0 - (float) (y - yn) / (ym - yn))); + int16_t py = (int16_t) roundf((this->height_ - 1) * (1.0f - (float) (y - yn) / (ym - yn))); for (uint32_t x = 0; x < this->width_; x += 2) { buff->draw_pixel_at(x_offset + x, y_offset + py, color); } @@ -177,7 +177,7 @@ void Graph::draw(Display *buff, uint16_t x_offset, uint16_t y_offset, Color colo uint8_t bit = 1 << ((i % (thick * LineType::PATTERN_LENGTH)) / thick); bool b = (trace->get_line_type() & bit) == bit; if (b) { - int16_t y = (int16_t) roundf((this->height_ - 1) * (1.0 - v)) - thick / 2 + y_offset; + int16_t y = (int16_t) roundf((this->height_ - 1) * (1.0f - v)) - thick / 2 + y_offset; auto draw_pixel_at = [&buff, c, y_offset, this](int16_t x, int16_t y) { if (y >= y_offset && static_cast(y) < y_offset + this->height_) buff->draw_pixel_at(x, y, c); diff --git a/esphome/components/haier/smartair2_climate.cpp b/esphome/components/haier/smartair2_climate.cpp index bd5678a425..a013371649 100644 --- a/esphome/components/haier/smartair2_climate.cpp +++ b/esphome/components/haier/smartair2_climate.cpp @@ -341,7 +341,7 @@ haier_protocol::HaierMessage Smartair2Climate::get_control_message() { if (climate_control.target_temperature.has_value()) { float target_temp = climate_control.target_temperature.value(); out_data->set_point = ((int) target_temp) - 16; // set the temperature with offset 16 - out_data->half_degree = (target_temp - ((int) target_temp) >= 0.49) ? 1 : 0; + out_data->half_degree = (target_temp - ((int) target_temp) >= 0.49f) ? 1 : 0; } if (out_data->ac_power == 0) { // If AC is off - no presets allowed diff --git a/esphome/components/ina226/ina226.cpp b/esphome/components/ina226/ina226.cpp index 695de57c61..c22237d144 100644 --- a/esphome/components/ina226/ina226.cpp +++ b/esphome/components/ina226/ina226.cpp @@ -70,7 +70,7 @@ void INA226Component::setup() { this->calibration_lsb_ = lsb; - auto calibration = uint32_t(0.00512 / (lsb * this->shunt_resistance_ohm_ / 1000000.0f)); + auto calibration = uint32_t(0.00512f / (lsb * this->shunt_resistance_ohm_ / 1000000.0f)); ESP_LOGV(TAG, " Using LSB=%" PRIu32 " calibration=%" PRIu32, lsb, calibration); diff --git a/esphome/components/ltr_als_ps/ltr_als_ps.cpp b/esphome/components/ltr_als_ps/ltr_als_ps.cpp index b7fad2e876..0d43aac20e 100644 --- a/esphome/components/ltr_als_ps/ltr_als_ps.cpp +++ b/esphome/components/ltr_als_ps/ltr_als_ps.cpp @@ -480,12 +480,12 @@ void LTRAlsPsComponent::apply_lux_calculation_(AlsReadings &data) { float inv_pfactor = this->glass_attenuation_factor_; float lux = 0.0f; - if (ratio < 0.45) { - lux = (1.7743 * ch0 + 1.1059 * ch1); - } else if (ratio < 0.64 && ratio >= 0.45) { - lux = (4.2785 * ch0 - 1.9548 * ch1); - } else if (ratio < 0.85 && ratio >= 0.64) { - lux = (0.5926 * ch0 + 0.1185 * ch1); + if (ratio < 0.45f) { + lux = (1.7743f * ch0 + 1.1059f * ch1); + } else if (ratio < 0.64f && ratio >= 0.45f) { + lux = (4.2785f * ch0 - 1.9548f * ch1); + } else if (ratio < 0.85f && ratio >= 0.64f) { + lux = (0.5926f * ch0 + 0.1185f * ch1); } else { ESP_LOGW(TAG, "Impossible ch1/(ch0 + ch1) ratio"); lux = 0.0f; diff --git a/esphome/components/mcp3204/mcp3204.cpp b/esphome/components/mcp3204/mcp3204.cpp index 5351d6a2cb..33abbe847a 100644 --- a/esphome/components/mcp3204/mcp3204.cpp +++ b/esphome/components/mcp3204/mcp3204.cpp @@ -31,7 +31,7 @@ float MCP3204::read_data(uint8_t pin, bool differential) { this->disable(); uint16_t digital_value = encode_uint16(b0, b1) >> 4; - return float(digital_value) / 4096.000 * this->reference_voltage_; // in V + return float(digital_value) / 4096.000f * this->reference_voltage_; // in V } } // namespace esphome::mcp3204 diff --git a/esphome/components/mpl3115a2/mpl3115a2.cpp b/esphome/components/mpl3115a2/mpl3115a2.cpp index d7994327b1..238e37aff0 100644 --- a/esphome/components/mpl3115a2/mpl3115a2.cpp +++ b/esphome/components/mpl3115a2/mpl3115a2.cpp @@ -75,16 +75,16 @@ void MPL3115A2Component::update() { float altitude = 0, pressure = 0; if (this->altitude_ != nullptr) { int32_t alt = encode_uint32(buffer[0], buffer[1], buffer[2], 0); - altitude = float(alt) / 65536.0; + altitude = float(alt) / 65536.0f; this->altitude_->publish_state(altitude); } else { uint32_t p = encode_uint32(0, buffer[0], buffer[1], buffer[2]); - pressure = float(p) / 6400.0; + pressure = float(p) / 6400.0f; if (this->pressure_ != nullptr) this->pressure_->publish_state(pressure); } int16_t t = encode_uint16(buffer[3], buffer[4]); - float temperature = float(t) / 256.0; + float temperature = float(t) / 256.0f; if (this->temperature_ != nullptr) this->temperature_->publish_state(temperature); diff --git a/esphome/components/mqtt/mqtt_climate.cpp b/esphome/components/mqtt/mqtt_climate.cpp index 443c983efe..d5ee4c6a9b 100644 --- a/esphome/components/mqtt/mqtt_climate.cpp +++ b/esphome/components/mqtt/mqtt_climate.cpp @@ -115,9 +115,9 @@ void MQTTClimateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCo // max_temp root[MQTT_MAX_TEMP] = traits.get_visual_max_temperature(); // target_temp_step - root[MQTT_TARGET_TEMPERATURE_STEP] = roundf(traits.get_visual_target_temperature_step() * 10) * 0.1; + root[MQTT_TARGET_TEMPERATURE_STEP] = roundf(traits.get_visual_target_temperature_step() * 10) * 0.1f; // current_temp_step - root[MQTT_CURRENT_TEMPERATURE_STEP] = roundf(traits.get_visual_current_temperature_step() * 10) * 0.1; + root[MQTT_CURRENT_TEMPERATURE_STEP] = roundf(traits.get_visual_current_temperature_step() * 10) * 0.1f; // temperature units are always coerced to Celsius internally root[MQTT_TEMPERATURE_UNIT] = "C"; diff --git a/esphome/components/nextion/nextion_commands.cpp b/esphome/components/nextion/nextion_commands.cpp index a332d342ee..a356d54e2f 100644 --- a/esphome/components/nextion/nextion_commands.cpp +++ b/esphome/components/nextion/nextion_commands.cpp @@ -176,7 +176,7 @@ void Nextion::goto_page(const char *page) { this->add_no_result_to_queue_with_pr void Nextion::goto_page(uint8_t page) { this->add_no_result_to_queue_with_printf_("page", "page %i", page); } void Nextion::set_backlight_brightness(float brightness) { - if (brightness < 0 || brightness > 1.0) { + if (brightness < 0 || brightness > 1.0f) { ESP_LOGD(TAG, "Brightness out of bounds (0-1.0)"); return; } diff --git a/esphome/components/pid/pid_autotuner.cpp b/esphome/components/pid/pid_autotuner.cpp index 1988f574db..3672d164c4 100644 --- a/esphome/components/pid/pid_autotuner.cpp +++ b/esphome/components/pid/pid_autotuner.cpp @@ -300,7 +300,7 @@ bool PIDAutotuner::OscillationFrequencyDetector::is_increase_decrease_symmetrica min_interval = std::min(min_interval, interval); } float ratio = min_interval / float(max_interval); - return ratio >= 0.66; + return ratio >= 0.66f; } // ================== OscillationAmplitudeDetector ================== diff --git a/esphome/components/servo/servo.cpp b/esphome/components/servo/servo.cpp index d2028ce9bd..8d5344cf44 100644 --- a/esphome/components/servo/servo.cpp +++ b/esphome/components/servo/servo.cpp @@ -86,7 +86,7 @@ void Servo::write(float value) { void Servo::internal_write(float value) { value = clamp(value, -1.0f, 1.0f); float level; - if (value < 0.0) { + if (value < 0.0f) { level = std::lerp(this->idle_level_, this->min_level_, -value); } else { level = std::lerp(this->idle_level_, this->max_level_, value); diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index 7d9cfecfdf..fe994f440d 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -612,7 +612,7 @@ void SpeakerMediaPlayer::set_volume_(float volume, bool publish) { } // Turn on the mute state if the volume is effectively zero, off otherwise - if (volume < 0.001) { + if (volume < 0.001f) { this->set_mute_state_(true); } else { this->set_mute_state_(false); diff --git a/esphome/components/veml3235/veml3235.cpp b/esphome/components/veml3235/veml3235.cpp index fd6cf1e2ed..59892936b0 100644 --- a/esphome/components/veml3235/veml3235.cpp +++ b/esphome/components/veml3235/veml3235.cpp @@ -215,7 +215,7 @@ void VEML3235Sensor::dump_config() { " Auto-gain upper threshold: %f%%\n" " Auto-gain lower threshold: %f%%\n" " Values below will be used as initial values only", - this->auto_gain_threshold_high_ * 100.0, this->auto_gain_threshold_low_ * 100.0); + this->auto_gain_threshold_high_ * 100.0f, this->auto_gain_threshold_low_ * 100.0f); } ESP_LOGCONFIG(TAG, " Digital gain: %uX\n" diff --git a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp index 1cf0de14d3..958ac59bde 100644 --- a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp +++ b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp @@ -49,7 +49,7 @@ bool XiaomiMHOC401::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { } if (res->humidity.has_value() && this->humidity_ != nullptr) { // see https://github.com/custom-components/sensor.mitemp_bt/issues/7#issuecomment-595948254 - *res->humidity = trunc(*res->humidity); + *res->humidity = truncf(*res->humidity); } if (!(xiaomi_ble::report_xiaomi_results(res, addr_str))) { continue; From 2f32c88ae51d29d52dc508543792434d0ddaf2d3 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:16:42 -0400 Subject: [PATCH 0621/1815] [wifi] Fix crash when WiFi is enabled late alongside ESP-NOW (#17239) --- .../wifi/wifi_component_esp_idf.cpp | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index b395c77141..2ade015a25 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -179,12 +179,53 @@ void WiFiComponent::wifi_lazy_init_() { // nor re-register the default WiFi handlers. if (s_sta_netif == nullptr) s_sta_netif = esp_netif_create_default_wifi_sta(); + if (s_sta_netif == nullptr) { + // Allocation failed; leave wifi_initialized_ false so a later enable() retries. + ESP_LOGE(TAG, "esp_netif_create_default_wifi_sta failed"); + return; + } #ifdef USE_WIFI_AP if (s_ap_netif == nullptr) s_ap_netif = esp_netif_create_default_wifi_ap(); #endif // USE_WIFI_AP + // The WiFi driver was started (e.g. by ESP-NOW with the wifi component disabled at + // boot) before our STA netif existed. The default WIFI_EVENT_STA_START handler + // therefore ran with no netif and never called esp_wifi_register_if_rxcb() -- the + // only thing that points the driver's RX path at a netif (it sets + // s_wifi_netifs[WIFI_IF_STA]). A bare esp_netif_action_start() would stop the + // immediate crash (#17232) but leaves RX unbound, so the first association + // associates at L2 yet never receives DHCP replies and times out (#17239). Restart + // the driver now that the netif exists so STA_START re-runs the default handler and + // wires RX correctly. ESP-NOW survives the stop/start (its peer state persists). + // This also matches a self-retry: if esp_wifi_set_storage() below failed on a + // previous wifi_lazy_init_() it returned without setting wifi_initialized_, and + // esp_wifi_init() has since run, so esp_wifi_get_mode() now succeeds here too. + wifi_mode_t mode; + if (esp_wifi_get_mode(&mode) == ESP_OK) { + ESP_LOGD(TAG, "WiFi driver already started without STA netif; restarting to bind it"); + esp_err_t err = esp_wifi_stop(); + if (err != ESP_OK) { + ESP_LOGW(TAG, "esp_wifi_stop failed: %s", esp_err_to_name(err)); + } + // Re-apply RAM storage; the normal init path does this, but it is skipped on + // the self-retry case above, which would otherwise let the driver persist + // credentials to NVS for the rest of the boot. + err = esp_wifi_set_storage(WIFI_STORAGE_RAM); + if (err != ESP_OK) { + ESP_LOGW(TAG, "esp_wifi_set_storage failed: %s", esp_err_to_name(err)); + } + err = esp_wifi_start(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_wifi_start failed: %s", esp_err_to_name(err)); + return; + } + s_wifi_started = true; + this->wifi_initialized_ = true; + return; + } + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); if (global_preferences->nvs_handle == 0) { ESP_LOGW(TAG, "starting wifi without nvs"); From 4ebecf514a6ef85f36aa9af99c490dded191ad1d Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 28 Jun 2026 12:41:47 -0700 Subject: [PATCH 0622/1815] [modbus_server] Simplify server response handling (#12376) Co-authored-by: Claude Opus 4.8 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/modbus/__init__.py | 1 - esphome/components/modbus/modbus.cpp | 161 +++++++++++++---- esphome/components/modbus/modbus.h | 60 ++++--- .../components/modbus/modbus_definitions.h | 2 +- esphome/components/modbus/modbus_helpers.cpp | 74 ++++---- esphome/components/modbus/modbus_helpers.h | 71 +++++++- .../modbus_server/modbus_server.cpp | 167 ++++++------------ .../components/modbus_server/modbus_server.h | 6 +- .../components/modbus/modbus_helpers_test.cpp | 36 ++++ .../modbus_server/modbus_server_test.cpp | 124 +++++++++++++ 10 files changed, 476 insertions(+), 226 deletions(-) create mode 100644 tests/components/modbus_server/modbus_server_test.cpp diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 492dfcaafe..cf1d409393 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -124,7 +124,6 @@ async def register_modbus_client_device(var, config): async def register_modbus_server_device(var, config): parent = await cg.get_variable(config[CONF_MODBUS_ID]) - cg.add(var.set_parent(parent)) cg.add(var.set_address(config[CONF_ADDRESS])) cg.add(parent.register_device(var)) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index c9ba2e837e..5b771c6282 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -258,7 +258,7 @@ bool ModbusServerHub::parse_modbus_client_frame_() { std::memcpy(data, this->rx_buffer_.data() + data_offset, data_len); this->clear_rx_buffer_(LOG_STR("parse succeeded"), false, frame_length); - this->process_modbus_client_frame_(address, function_code, data, data_len); + this->process_modbus_client_frame_(address, function_code, data); return true; } @@ -321,10 +321,8 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, uint8_t funct } void ModbusServerHub::process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *, uint16_t) { - for (auto *device : this->devices_) { - if (device->address_ == address) { - ESP_LOGE(TAG, "Unexpected response from address %" PRIu8 ", which is mapped to this device.", address); - } + if (this->find_device_(address) != nullptr) { + ESP_LOGE(TAG, "Unexpected response from address %" PRIu8 ", which is mapped to this device.", address); } if (this->expecting_peer_response_ == address) { @@ -338,31 +336,124 @@ void ModbusServerHub::process_modbus_server_frame(uint8_t address, uint8_t funct this->expecting_peer_response_ = 0; } -void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data, - uint16_t len) { - bool found = false; - +ModbusServerDevice *ModbusServerHub::find_device_(uint8_t address) { for (auto *device : this->devices_) { - if (device->address_ == address) { - found = true; - - if (static_cast(function_code) == ModbusFunctionCode::READ_HOLDING_REGISTERS || - static_cast(function_code) == ModbusFunctionCode::READ_INPUT_REGISTERS) { - device->on_modbus_read_registers(function_code, helpers::get_data(data, 0), - helpers::get_data(data, 2)); - } else if (static_cast(function_code) == ModbusFunctionCode::WRITE_SINGLE_REGISTER || - static_cast(function_code) == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { - device->on_modbus_write_registers(function_code, std::vector(data, data + len)); - } else { - ESP_LOGW(TAG, "Unsupported function code %" PRIu8, function_code); - device->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); - } + if (device->get_address() == address) { + return device; } } + return nullptr; +} - if (!found) { +bool ModbusServerHub::check_register_range_(uint8_t address, uint8_t function_code, uint16_t start_address, + uint16_t number_of_registers) { + if ((uint32_t) start_address + number_of_registers > 0x10000u) { + ESP_LOGW(TAG, "Register address out of range - start: %" PRIu16 " num: %" PRIu16, start_address, + number_of_registers); + this->send_exception_(address, function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + return false; + } + return true; +} + +void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data) { + ModbusServerDevice *device = this->find_device_(address); + if (device == nullptr) { this->expecting_peer_response_ = address; ESP_LOGV(TAG, "Request to peer %" PRIu8 " received", address); + return; + } + + ServerResponseStatus status; + uint8_t response_buffer[modbus::MAX_RAW_SIZE]; + const uint8_t *response_data = response_buffer; + uint16_t response_len = 0; + + switch (static_cast(function_code)) { + case ModbusFunctionCode::READ_HOLDING_REGISTERS: + case ModbusFunctionCode::READ_INPUT_REGISTERS: { + // PDU data: start address(2) + quantity(2). + uint16_t start_address = helpers::get_data(data, 0); + uint16_t number_of_registers = helpers::get_data(data, 2); + if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_READ) { + ESP_LOGW(TAG, "Invalid number of registers %" PRIu16, number_of_registers); + this->send_exception_(address, function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); + return; + } + if (!this->check_register_range_(address, function_code, start_address, number_of_registers)) { + return; + } + RegisterValues registers; + if (static_cast(function_code) == ModbusFunctionCode::READ_HOLDING_REGISTERS) { + status = device->on_modbus_read_holding_registers(start_address, number_of_registers, registers); + } else { + status = device->on_modbus_read_input_registers(start_address, number_of_registers, registers); + } + + // A handler that returns an exception leaves registers partially filled, so check the exception + // first and forward it before validating the register count on the success path. + if (status.has_value()) { + this->send_exception_(address, function_code, status.value()); + return; + } + + if (registers.size() != number_of_registers) { + ESP_LOGE(TAG, "Incorrect response %" PRIu16 " requested, %zu returned", number_of_registers, registers.size()); + this->send_exception_(address, function_code, ModbusExceptionCode::SERVICE_DEVICE_FAILURE); + return; + } + + response_buffer[response_len++] = static_cast(number_of_registers * 2); // actual byte count + for (auto r : registers) { + auto register_bytes = decode_value(r); + response_buffer[response_len++] = register_bytes[0]; + response_buffer[response_len++] = register_bytes[1]; + } + break; + } + case ModbusFunctionCode::WRITE_SINGLE_REGISTER: + case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS: { + // PDU data: start address(2) [+ quantity(2) + byte count(1)] + register values. + // A single-register write always targets one register; for a multiple-register write the + // quantity is in the frame and its byte count must equal quantity * 2. The register values are + // assembled into registers below so the handler doesn't have to know the request framing. + uint16_t start_address = helpers::get_data(data, 0); + uint16_t number_of_registers = 1; + uint16_t values_offset = 2; // single write: values follow the 2-byte start address + if (static_cast(function_code) == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { + number_of_registers = helpers::get_data(data, 2); + uint8_t number_of_bytes = helpers::get_data(data, 4); + values_offset = 5; // multiple write: values follow start address(2) + quantity(2) + byte count(1) + if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_WRITE || + number_of_registers * 2 != number_of_bytes) { + ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 " or bytes %" PRIu8, number_of_registers, + number_of_bytes); + this->send_exception_(address, function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); + return; + } + if (!this->check_register_range_(address, function_code, start_address, number_of_registers)) { + return; + } + } + // Assemble the register values (host byte order) so the handler never sees wire framing. + RegisterValues registers; + for (uint16_t i = 0; i < number_of_registers; i++) { + registers.push_back(helpers::get_data(data, values_offset + i * 2)); + } + status = device->on_modbus_write_registers(start_address, registers); + response_data = data; // echo the request header per Modbus 6.6, 6.12 + response_len = 4; + break; + } + default: + ESP_LOGW(TAG, "Unsupported function code %" PRIu8, function_code); + this->send_exception_(address, function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); + return; + } + if (status.has_value()) { + this->send_exception_(address, function_code, status.value()); + } else { + this->send_response_(address, function_code, response_data, response_len); } } @@ -455,17 +546,27 @@ float Modbus::get_setup_priority() const { return setup_priority::BUS - 1.0f; } -void ModbusServerHub::send(uint8_t address, uint8_t function_code, const std::vector &payload) { - const uint16_t len = static_cast(2 + payload.size()); - if (len > MAX_RAW_SIZE) { - ESP_LOGE(TAG, "Server send frame too large (%" PRIu16 " bytes)", len); +void ModbusServerHub::send_response_(uint8_t address, uint8_t function_code, const uint8_t *payload, + uint16_t payload_len) { + // Build the raw frame (address + function code + payload) in a stack buffer; it's consumed + // immediately by send_raw_ and a full raw frame never exceeds MAX_RAW_SIZE. + if (payload_len + 2 > MAX_RAW_SIZE) { + ESP_LOGE(TAG, "Server response too large (%" PRIu16 " bytes)", static_cast(payload_len + 2)); return; } uint8_t raw_frame[MAX_RAW_SIZE]; raw_frame[0] = address; raw_frame[1] = function_code; - std::memcpy(raw_frame + 2, payload.data(), payload.size()); - this->send_raw_(raw_frame, len); + std::memcpy(raw_frame + 2, payload, payload_len); + this->send_raw_(raw_frame, payload_len + 2); +} + +void ModbusServerHub::send_exception_(uint8_t address, uint8_t function_code, ModbusExceptionCode exception_code) { + uint8_t raw_frame[3]; + raw_frame[0] = address; + raw_frame[1] = function_code | FUNCTION_CODE_EXCEPTION_MASK; + raw_frame[2] = static_cast(exception_code); + this->send_raw_(raw_frame, 3); } // Raw send for client: pushes to tx queue. Everything except the CRC must be contained in payload. diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index da0db13a07..95b7a770b6 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -130,22 +130,22 @@ class ModbusServerHub : public Modbus { public: ModbusServerHub() = default; void dump_config() override; - void send(uint8_t address, uint8_t function_code, const std::vector &payload); - ESPDEPRECATED("Use ModbusServerDevice::send_raw instead. Removed in 2026.10.0", "2026.4.0") - void send_raw(const std::vector &payload) { - this->send_raw_(payload.data(), static_cast(payload.size())); - }; void register_device(ModbusServerDevice *device) { this->devices_.push_back(device); } protected: - friend class ModbusServerDevice; - void parse_modbus_frames() override; bool parse_modbus_client_frame_(); // Parsers need to handle standard (ModbusFunctionCode) and custom (uint8_t) function codes, so we use uint8_t here. void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) override; - void process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len); + void process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data); + ModbusServerDevice *find_device_(uint8_t address); + // Returns true if [start_address, start_address + number_of_registers) fits in the 16-bit address space. + // On failure, logs and sends an ILLEGAL_DATA_ADDRESS exception to the client. + bool check_register_range_(uint8_t address, uint8_t function_code, uint16_t start_address, + uint16_t number_of_registers); void send_raw_(const uint8_t *payload, uint16_t len); + void send_exception_(uint8_t address, uint8_t function_code, ModbusExceptionCode exception_code); + void send_response_(uint8_t address, uint8_t function_code, const uint8_t *payload, uint16_t payload_len); uint8_t expecting_peer_response_{0}; std::vector devices_; @@ -200,35 +200,41 @@ class ModbusClientDevice { // This is for compatibility with external components using the former class name using ModbusDevice = ModbusClientDevice; +// Result of a server register handler: std::nullopt means success, otherwise the Modbus exception code to return. +using ServerResponseStatus = std::optional; +// Register values exchanged with server handlers, in host byte order. Sized at the larger of the two protocol +// maxima (read = 125 / 0x7D, write = 123 / 0x7B); the per-direction count limit is enforced by the hub, not by +// the capacity of this type. +using RegisterValues = StaticVector; + class ModbusServerDevice { public: - ModbusServerDevice() = default; - ModbusServerDevice(ModbusServerHub *parent, uint8_t address) : parent_(parent), address_(address) {} virtual ~ModbusServerDevice() = default; + ModbusServerDevice() = default; + // Polymorphic base: non-copyable and non-movable to prevent slicing (Rule of Five). ModbusServerDevice(const ModbusServerDevice &) = delete; ModbusServerDevice &operator=(const ModbusServerDevice &) = delete; ModbusServerDevice(ModbusServerDevice &&) = delete; ModbusServerDevice &operator=(ModbusServerDevice &&) = delete; - void set_parent(ModbusServerHub *parent) { this->parent_ = parent; } void set_address(uint8_t address) { this->address_ = address; } - virtual void on_modbus_read_registers(uint8_t function_code, uint16_t start_address, uint16_t number_of_registers){}; - virtual void on_modbus_write_registers(uint8_t function_code, const std::vector &data){}; - void send(uint8_t function, const std::vector &payload) { - this->parent_->send(this->address_, function, payload); - } - void send_raw(const std::vector &payload) { - this->parent_->send_raw_(payload.data(), static_cast(payload.size())); - } - void send_error(uint8_t function_code, ModbusExceptionCode exception_code) { - uint8_t error_response[3] = {this->address_, uint8_t(function_code | FUNCTION_CODE_EXCEPTION_MASK), - static_cast(exception_code)}; - this->parent_->send_raw_(error_response, 3); - } + uint8_t get_address() const { return this->address_; } + virtual ServerResponseStatus on_modbus_read_registers(uint16_t start_address, uint16_t number_of_registers, + RegisterValues ®isters) { + return ModbusExceptionCode::ILLEGAL_FUNCTION; + }; + virtual ServerResponseStatus on_modbus_read_input_registers(uint16_t start_address, uint16_t number_of_registers, + RegisterValues ®isters) { + return this->on_modbus_read_registers(start_address, number_of_registers, registers); + }; + virtual ServerResponseStatus on_modbus_read_holding_registers(uint16_t start_address, uint16_t number_of_registers, + RegisterValues ®isters) { + return this->on_modbus_read_registers(start_address, number_of_registers, registers); + }; + virtual ServerResponseStatus on_modbus_write_registers(uint16_t start_address, const RegisterValues ®isters) { + return ModbusExceptionCode::ILLEGAL_FUNCTION; + }; protected: - friend ModbusServerHub; - - ModbusServerHub *parent_{nullptr}; uint8_t address_{0}; }; diff --git a/esphome/components/modbus/modbus_definitions.h b/esphome/components/modbus/modbus_definitions.h index 49172b9dca..1c03498f1d 100644 --- a/esphome/components/modbus/modbus_definitions.h +++ b/esphome/components/modbus/modbus_definitions.h @@ -82,7 +82,7 @@ static constexpr uint8_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D // Smallest possible frame is 4 bytes (custom function with no data): address(1) + function(1) + CRC(2) static constexpr uint16_t MIN_FRAME_SIZE = 4; static constexpr uint16_t MAX_PDU_SIZE = 253; // Max PDU size is 256 - address(1) - CRC(2) = 253 -static constexpr uint16_t MAX_RAW_SIZE = 254; // Max RAW size is 255 - CRC(2) = 254 +static constexpr uint16_t MAX_RAW_SIZE = 254; // Max RAW size is 256 - CRC(2) = 254 static constexpr uint16_t MAX_FRAME_SIZE = 256; /// End of Modbus definitions } // namespace esphome::modbus diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index 4cddfca104..53fa6afacb 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -101,53 +101,19 @@ static size_t required_payload_size(SensorValueType sensor_value_type) { } } -void number_to_payload(std::vector &data, int64_t value, SensorValueType value_type) { - switch (value_type) { - case SensorValueType::U_WORD: - case SensorValueType::S_WORD: - data.push_back(value & 0xFFFF); - break; - case SensorValueType::U_DWORD: - case SensorValueType::S_DWORD: - case SensorValueType::FP32: - data.push_back((value & 0xFFFF0000) >> 16); - data.push_back(value & 0xFFFF); - break; - case SensorValueType::U_DWORD_R: - case SensorValueType::S_DWORD_R: - case SensorValueType::FP32_R: - data.push_back(value & 0xFFFF); - data.push_back((value & 0xFFFF0000) >> 16); - break; - case SensorValueType::U_QWORD: - case SensorValueType::S_QWORD: - data.push_back((value & 0xFFFF000000000000) >> 48); - data.push_back((value & 0xFFFF00000000) >> 32); - data.push_back((value & 0xFFFF0000) >> 16); - data.push_back(value & 0xFFFF); - break; - case SensorValueType::U_QWORD_R: - case SensorValueType::S_QWORD_R: - data.push_back(value & 0xFFFF); - data.push_back((value & 0xFFFF0000) >> 16); - data.push_back((value & 0xFFFF00000000) >> 32); - data.push_back((value & 0xFFFF000000000000) >> 48); - break; - default: - ESP_LOGE(TAG, "Invalid data type for modbus number to payload conversion: %d", static_cast(value_type)); - break; - } +void log_unsupported_value_type(SensorValueType value_type) { + ESP_LOGE(TAG, "Invalid data type for modbus number to payload conversion: %d", static_cast(value_type)); } -int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, +int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sensor_value_type, uint8_t offset, uint32_t bitmask, bool *error_return) { int64_t value = 0; // int64_t because it can hold signed and unsigned 32 bits // Validate offset against the buffer for all types, including RAW/unsupported, so // a malformed or misconfigured frame still produces an error log. - if (static_cast(offset) > data.size()) { + if (static_cast(offset) > size) { ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu", static_cast(sensor_value_type), - static_cast(offset), data.size()); + static_cast(offset), size); if (error_return) *error_return = true; return value; @@ -158,10 +124,9 @@ int64_t payload_to_number(const std::vector &data, SensorValueType sens return value; } - if (data.size() - offset < required_size) { + if (size - offset < required_size) { ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu required=%zu", - static_cast(sensor_value_type), static_cast(offset), data.size(), - required_size); + static_cast(sensor_value_type), static_cast(offset), size, required_size); if (error_return) *error_return = true; return value; @@ -214,6 +179,31 @@ int64_t payload_to_number(const std::vector &data, SensorValueType sens return value; } +int64_t registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type, + bool *error_return) { + const size_t required_size = required_payload_size(sensor_value_type); + if (required_size == 0) { + return 0; // RAW/unsupported: nothing to read + } + const size_t required_words = required_size / 2; + if (required_words > count) { + ESP_LOGE(TAG, "not enough registers for value type=%u count=%zu required=%zu", + static_cast(sensor_value_type), count, required_words); + if (error_return) + *error_return = true; + return 0; + } + // Serialize the needed words back to big-endian bytes and reuse the audited byte decoder so the + // sign-extension behaviour stays identical to the wire path. + uint8_t bytes[8]; // at most 4 registers (QWORD) + for (size_t i = 0; i < required_words; i++) { + uint16_t reg = registers[i]; + bytes[i * 2] = static_cast(reg >> 8); + bytes[i * 2 + 1] = static_cast(reg & 0xFF); + } + return payload_to_number(bytes, required_size, sensor_value_type, 0, 0xFFFFFFFF, error_return); +} + StaticVector create_client_pdu(ModbusFunctionCode function_code, uint16_t start_address, uint16_t number_of_entities, const uint8_t *values, size_t values_len) { diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index b637d872cf..b7b9020945 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -224,24 +224,77 @@ template N mask_and_shift_by_rightbit(N data, uint32_t mask) { return 0; } -/** Convert float value to vector suitable for sending - * @param data target for payload - * @param value float value to convert - * @param value_type defines if 16/32 or FP32 is used - * @return vector containing the modbus register words in correct order - */ -void number_to_payload(std::vector &data, int64_t value, SensorValueType value_type); +// Logs an error for an unsupported value type. Defined in the .cpp so logging stays out of headers. +void log_unsupported_value_type(SensorValueType value_type); -/** Convert vector response payload to number. +/** Append the Modbus register words for value to data. + * Works with any container exposing push_back(uint16_t) (e.g. std::vector or StaticVector). + */ +template void number_to_payload(Container &data, int64_t value, SensorValueType value_type) { + switch (value_type) { + case SensorValueType::U_WORD: + case SensorValueType::S_WORD: + data.push_back(value & 0xFFFF); + break; + case SensorValueType::U_DWORD: + case SensorValueType::S_DWORD: + case SensorValueType::FP32: + data.push_back((value & 0xFFFF0000) >> 16); + data.push_back(value & 0xFFFF); + break; + case SensorValueType::U_DWORD_R: + case SensorValueType::S_DWORD_R: + case SensorValueType::FP32_R: + data.push_back(value & 0xFFFF); + data.push_back((value & 0xFFFF0000) >> 16); + break; + case SensorValueType::U_QWORD: + case SensorValueType::S_QWORD: + data.push_back((value & 0xFFFF000000000000) >> 48); + data.push_back((value & 0xFFFF00000000) >> 32); + data.push_back((value & 0xFFFF0000) >> 16); + data.push_back(value & 0xFFFF); + break; + case SensorValueType::U_QWORD_R: + case SensorValueType::S_QWORD_R: + data.push_back(value & 0xFFFF); + data.push_back((value & 0xFFFF0000) >> 16); + data.push_back((value & 0xFFFF00000000) >> 32); + data.push_back((value & 0xFFFF000000000000) >> 48); + break; + default: + log_unsupported_value_type(value_type); + break; + } +} + +/** Convert a raw response payload to a number. * @param data payload with the data to convert + * @param size number of bytes available in data * @param sensor_value_type defines if 16/32/64 bits or FP32 is used * @param offset offset to the data in data * @param bitmask bitmask used for masking and shifting * @return 64-bit number of the payload */ -int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, +int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sensor_value_type, uint8_t offset, uint32_t bitmask, bool *error_return = nullptr); +/** Convert vector response payload to number. */ +inline int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, + uint32_t bitmask, bool *error_return = nullptr) { + return payload_to_number(data.data(), data.size(), sensor_value_type, offset, bitmask, error_return); +} + +/** Reconstruct a number from register words (host byte order). Inverse of number_to_payload. + * Decodes the value at the start of the given span; advance the pointer to read successive values. + * @param registers register values in host byte order + * @param count number of registers available in registers + * @param sensor_value_type defines if 16/32/64 bits or FP32 is used + * @return 64-bit number of the registers + */ +int64_t registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type, + bool *error_return = nullptr); + /** Create a modbus clinet pdu for reading/writing single/multiple coils/register/inputs. * @param function_code the modbus function code to use. One of: * READ_COILS diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp index c294d08888..bb264eb993 100644 --- a/esphome/components/modbus_server/modbus_server.cpp +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -3,26 +3,18 @@ #include "esphome/core/log.h" namespace esphome::modbus_server { -using modbus::ModbusFunctionCode; using modbus::ModbusExceptionCode; -using modbus::helpers::payload_to_number; +using modbus::helpers::registers_to_number; 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) { +modbus::ServerResponseStatus ModbusServer::on_modbus_read_registers(uint16_t start_address, + uint16_t number_of_registers, + modbus::RegisterValues ®isters) { ESP_LOGV(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); + "Received read holding/input registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%X.", + this->address_, 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 %" PRIu16 ". 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_) { @@ -36,10 +28,7 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star server_register->address, static_cast(server_register->value_type), server_register->register_count, server_register->format_value(value, value_buf, sizeof(value_buf))); - 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()); + modbus::helpers::number_to_payload(registers, value, server_register->value_type); current_address += server_register->register_count; found = true; break; @@ -53,92 +42,37 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star "Could not match any register to address 0x%02X, but default allowed. " "Returning default value: %" PRIu16 ".", current_address, this->server_courtesy_response_.register_value); - sixteen_bit_response.push_back(this->server_courtesy_response_.register_value); + registers.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; + return ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; } } } - std::vector response; - if (number_of_registers != sixteen_bit_response.size()) - ESP_LOGW(TAG, "Response size not matched to request register count."); - response.push_back(sixteen_bit_response.size() * 2); // actual byte count - 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, response); + return {}; } -void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::vector &data) { - uint16_t number_of_registers; - uint16_t payload_offset; +modbus::ServerResponseStatus ModbusServer::on_modbus_write_registers(uint16_t start_address, + const modbus::RegisterValues ®isters) { + // registers holds the values to write in host byte order; its size is the register count. + ESP_LOGV(TAG, "Received write registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%zX.", + this->address_, start_address, registers.size()); - if (static_cast(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 %" PRIu16 ". 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 %" PRIu16 " bytes is not 2 times the number of registers (%" PRIu16 - "). 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 (static_cast(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;) { + auto for_each_register = + [this, start_address, + ®isters](const std::function &callback) -> bool { + uint16_t register_offset = 0; + for (uint32_t current_address = start_address; current_address < start_address + registers.size();) { bool ok = false; for (auto *server_register : this->server_registers_) { if (server_register->address == current_address) { - ok = callback(server_register, offset); + ok = callback(server_register, register_offset); current_address += server_register->register_count; - offset += server_register->register_count * sizeof(uint16_t); + register_offset += server_register->register_count; break; } } @@ -150,36 +84,41 @@ void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::v 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; - })) { - ESP_LOGW(TAG, "Invalid register address. Sending exception response."); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); - return; - } - - // Actually write to the registers: - if (!for_each_register([&data](ServerRegister *server_register, uint16_t offset) { - bool error = false; - int64_t number = payload_to_number(data, server_register->value_type, offset, 0xFFFFFFFF, &error); - if (error) { - return false; - } else { - return server_register->write_lambda(number); + // Pre-flight: every targeted register must be writable AND have its full value present in the request, + // so we never apply a partial write before discovering a problem. The commit pass below re-runs + // registers_to_number rather than caching the decoded values: using the same function for the check and + // the write keeps a single source of truth for the decode bound, independent of how register_count was set. + ModbusExceptionCode precheck = ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; // unmatched or unwritable register + if (!for_each_register([&precheck, ®isters](ServerRegister *server_register, uint16_t register_offset) -> bool { + if (server_register->write_lambda == nullptr) { + return false; // unwritable -> ILLEGAL_DATA_ADDRESS } + bool error = false; + registers_to_number(registers.data() + register_offset, registers.size() - register_offset, + server_register->value_type, &error); + if (error) { + precheck = ModbusExceptionCode::ILLEGAL_DATA_VALUE; // request doesn't supply the full value + return false; + } + return true; })) { - ESP_LOGW(TAG, "Could not write all registers. Sending exception response."); - this->send_error(function_code, ModbusExceptionCode::SERVICE_DEVICE_FAILURE); - return; + ESP_LOGW(TAG, "Write request rejected before applying any register. Sending exception response."); + return precheck; } - 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); + // Commit: every value is known writable and decodable, so the only failure now is a user write callback + // rejecting the value at runtime -- which cannot be rolled back. + if (!for_each_register([®isters](ServerRegister *server_register, uint16_t register_offset) { + int64_t number = registers_to_number(registers.data() + register_offset, registers.size() - register_offset, + server_register->value_type); + return server_register->write_lambda(number); + })) { + ESP_LOGW(TAG, "A register write callback failed mid-sequence; earlier writes were already applied."); + return ModbusExceptionCode::SERVICE_DEVICE_FAILURE; + } + + // Success: the caller builds the write response (an echo of the request header). + return {}; } void ModbusServer::dump_config() { diff --git a/esphome/components/modbus_server/modbus_server.h b/esphome/components/modbus_server/modbus_server.h index fa1376542c..0c22454528 100644 --- a/esphome/components/modbus_server/modbus_server.h +++ b/esphome/components/modbus_server/modbus_server.h @@ -98,9 +98,11 @@ class ModbusServer : public Component, public modbus::ModbusServerDevice { /// 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; + modbus::ServerResponseStatus on_modbus_read_registers(uint16_t start_address, uint16_t number_of_registers, + modbus::RegisterValues ®isters) 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; + modbus::ServerResponseStatus on_modbus_write_registers(uint16_t start_address, + const modbus::RegisterValues ®isters) 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; diff --git a/tests/components/modbus/modbus_helpers_test.cpp b/tests/components/modbus/modbus_helpers_test.cpp index cd260f410a..ecdca4df6d 100644 --- a/tests/components/modbus/modbus_helpers_test.cpp +++ b/tests/components/modbus/modbus_helpers_test.cpp @@ -194,4 +194,40 @@ TEST(ModbusHelpersTest, PayloadToNumberDecodesValidWord) { EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 0, 0xFFFFFFFF), 0x1234); } +// --- registers_to_number --------------------------------------------------- +// Register words are host byte order; results must match the byte-based payload_to_number. + +TEST(ModbusHelpersTest, RegistersToNumberDecodesWord) { + const uint16_t registers[] = {0x1234}; + EXPECT_EQ(registers_to_number(registers, 1, SensorValueType::U_WORD), 0x1234); +} + +TEST(ModbusHelpersTest, RegistersToNumberDecodesDwordHighWordFirst) { + const uint16_t registers[] = {0x1234, 0x5678}; + EXPECT_EQ(registers_to_number(registers, 2, SensorValueType::U_DWORD), 0x12345678); +} + +TEST(ModbusHelpersTest, RegistersToNumberDecodesAtSpanStart) { + // The function decodes the value at the start of the span; the caller advances the pointer. + const uint16_t registers[] = {0xAAAA, 0x1234}; + EXPECT_EQ(registers_to_number(registers + 1, 1, SensorValueType::U_WORD), 0x1234); +} + +TEST(ModbusHelpersTest, RegistersToNumberMatchesPayloadToNumber) { + // Same value via both decoders: registers (host order) vs big-endian bytes. + const uint16_t registers[] = {0x8001, 0x0002}; + const std::vector bytes{0x80, 0x01, 0x00, 0x02}; + for (auto value_type : {SensorValueType::S_DWORD, SensorValueType::U_DWORD, SensorValueType::S_DWORD_R}) { + EXPECT_EQ(registers_to_number(registers, 2, value_type), payload_to_number(bytes, value_type, 0, 0xFFFFFFFF)) + << "value_type=" << static_cast(value_type); + } +} + +TEST(ModbusHelpersTest, RegistersToNumberRejectsTruncatedMultiRegisterValue) { + const uint16_t registers[] = {0x1234}; + bool error = false; + EXPECT_EQ(registers_to_number(registers, 1, SensorValueType::U_DWORD, &error), 0); + EXPECT_TRUE(error); +} + } // namespace esphome::modbus::helpers diff --git a/tests/components/modbus_server/modbus_server_test.cpp b/tests/components/modbus_server/modbus_server_test.cpp new file mode 100644 index 0000000000..0c8f5d04cf --- /dev/null +++ b/tests/components/modbus_server/modbus_server_test.cpp @@ -0,0 +1,124 @@ +#include + +#include "esphome/components/modbus_server/modbus_server.h" + +namespace esphome::modbus_server { + +using modbus::ModbusExceptionCode; +using modbus::RegisterValues; + +namespace { + +RegisterValues make_registers(std::initializer_list values) { + RegisterValues registers; + for (uint16_t value : values) + registers.push_back(value); + return registers; +} + +} // namespace + +// A single writable WORD register is applied and the handler reports success (nullopt). +TEST(ModbusServerWrite, SingleWordSucceeds) { + ModbusServer server; + int64_t written = -1; + ServerRegister reg(0x0000, SensorValueType::U_WORD, 1); + reg.write_lambda = [&written](int64_t value) { + written = value; + return true; + }; + server.add_server_register(®); + + auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1234})); + EXPECT_FALSE(status.has_value()); // nullopt == success + EXPECT_EQ(written, 0x1234); +} + +// A multi-register value is decoded high word first and applied as a single number. +TEST(ModbusServerWrite, DwordSucceeds) { + ModbusServer server; + int64_t written = -1; + ServerRegister reg(0x0000, SensorValueType::U_DWORD, 2); + reg.write_lambda = [&written](int64_t value) { + written = value; + return true; + }; + server.add_server_register(®); + + auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1234, 0x5678})); + EXPECT_FALSE(status.has_value()); + EXPECT_EQ(written, 0x12345678); +} + +// Regression: a request that under-supplies a multi-register value is rejected before any +// write_lambda runs, so no register is partially written. +TEST(ModbusServerWrite, UnderSuppliedValueAppliesNothing) { + ModbusServer server; + bool word_written = false; + ServerRegister word_reg(0x0000, SensorValueType::U_WORD, 1); + word_reg.write_lambda = [&word_written](int64_t) { + word_written = true; + return true; + }; + bool dword_written = false; + ServerRegister dword_reg(0x0001, SensorValueType::U_DWORD, 2); // needs two registers + dword_reg.write_lambda = [&dword_written](int64_t) { + dword_written = true; + return true; + }; + server.add_server_register(&word_reg); + server.add_server_register(&dword_reg); + + // Two words supplied: one for the WORD at 0x0000, but only one of the two the DWORD at 0x0001 needs. + auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1111, 0x2222})); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_VALUE); + EXPECT_FALSE(word_written); // the writable WORD must NOT have been applied + EXPECT_FALSE(dword_written); +} + +// A read-only register (no write_lambda) yields ILLEGAL_DATA_ADDRESS and applies nothing. +TEST(ModbusServerWrite, UnwritableRegisterRejected) { + ModbusServer server; + ServerRegister read_only(0x0000, SensorValueType::U_WORD, 1); // no write_lambda set + server.add_server_register(&read_only); + + auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1234})); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +// An address with no registered register yields ILLEGAL_DATA_ADDRESS. +TEST(ModbusServerWrite, UnmatchedAddressRejected) { + ModbusServer server; + auto status = server.on_modbus_write_registers(0x0005, make_registers({0x1234})); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +// A write_lambda failing at runtime is the one non-atomic case: the earlier register is already +// applied, and the handler reports SERVICE_DEVICE_FAILURE. +TEST(ModbusServerWrite, CallbackFailureIsServiceDeviceFailure) { + ModbusServer server; + bool first_written = false; + ServerRegister first(0x0000, SensorValueType::U_WORD, 1); + first.write_lambda = [&first_written](int64_t) { + first_written = true; + return true; + }; + ServerRegister second(0x0001, SensorValueType::U_WORD, 1); + second.write_lambda = [](int64_t) { return false; }; // rejects at runtime + server.add_server_register(&first); + server.add_server_register(&second); + + auto status = server.on_modbus_write_registers(0x0000, make_registers({0xAAAA, 0xBBBB})); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::SERVICE_DEVICE_FAILURE); + EXPECT_TRUE(first_written); // pre-validation passed, so the first write applied before the failure +} + +} // namespace esphome::modbus_server From b62f7a41c92bca055ace95fce303db06b1dbf64c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:15:38 -0400 Subject: [PATCH 0623/1815] [multiple] Single-precision float math, avoid double promotion (stragglers) (#17260) --- esphome/components/ac_dimmer/ac_dimmer.cpp | 2 +- esphome/components/display/display.cpp | 14 +++++++------- esphome/components/hmc5883l/hmc5883l.cpp | 4 +++- esphome/components/mmc5603/mmc5603.cpp | 4 +++- esphome/components/pid/pid_autotuner.cpp | 7 ++----- esphome/components/qmc5883l/qmc5883l.cpp | 3 ++- esphome/components/rd03d/rd03d.cpp | 3 ++- esphome/components/sx126x/sx126x.cpp | 2 +- 8 files changed, 21 insertions(+), 18 deletions(-) diff --git a/esphome/components/ac_dimmer/ac_dimmer.cpp b/esphome/components/ac_dimmer/ac_dimmer.cpp index 3e21d6981d..477962a040 100644 --- a/esphome/components/ac_dimmer/ac_dimmer.cpp +++ b/esphome/components/ac_dimmer/ac_dimmer.cpp @@ -216,7 +216,7 @@ void AcDimmer::setup() { } void AcDimmer::write_state(float state) { - state = std::acos(1 - (2 * state)) / std::numbers::pi; // RMS power compensation + state = std::acos(1 - (2 * state)) / std::numbers::pi_v; // RMS power compensation auto new_value = static_cast(roundf(state * 65535)); if (new_value != 0 && this->store_.value == 0) this->store_.init_cycle = this->init_with_half_cycle_; diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index b30f444d6d..115adf503a 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -42,10 +42,10 @@ void Display::line_at_angle(int x, int y, int angle, int length, Color color) { void Display::line_at_angle(int x, int y, int angle, int start_radius, int stop_radius, Color color) { // Calculate start and end points - int x1 = (start_radius * cos(angle * M_PI / 180)) + x; - int y1 = (start_radius * sin(angle * M_PI / 180)) + y; - int x2 = (stop_radius * cos(angle * M_PI / 180)) + x; - int y2 = (stop_radius * sin(angle * M_PI / 180)) + y; + int x1 = (start_radius * std::cos(angle * std::numbers::pi_v / 180)) + x; + int y1 = (start_radius * std::sin(angle * std::numbers::pi_v / 180)) + y; + int x2 = (stop_radius * std::cos(angle * std::numbers::pi_v / 180)) + x; + int y2 = (stop_radius * std::sin(angle * std::numbers::pi_v / 180)) + y; // Draw line this->line(x1, y1, x2, y2, color); @@ -444,15 +444,15 @@ void HOT Display::get_regular_polygon_vertex(int vertex_id, int *vertex_x, int * // hence we rotate the shape by 270° to orient the polygon up. rotation_degrees += ROTATION_270_DEGREES; // Convert the rotation to radians, easier to use in trigonometrical calculations - float rotation_radians = rotation_degrees * std::numbers::pi / 180; + float rotation_radians = rotation_degrees * std::numbers::pi_v / 180; // A pointy top variation means the first vertex of the polygon is at the top center of the shape, this requires no // additional rotation of the shape. // A flat top variation means the first point of the polygon has to be rotated so that the first edge is horizontal, // this requires to rotate the shape by π/edges radians counter-clockwise so that the first point is located on the // left side of the first horizontal edge. - rotation_radians -= (variation == VARIATION_FLAT_TOP) ? std::numbers::pi / edges : 0.0; + rotation_radians -= (variation == VARIATION_FLAT_TOP) ? std::numbers::pi_v / edges : 0.0f; - float vertex_angle = ((float) vertex_id) / edges * 2 * std::numbers::pi + rotation_radians; + float vertex_angle = ((float) vertex_id) / edges * 2 * std::numbers::pi_v + rotation_radians; *vertex_x = (int) std::round(std::cos(vertex_angle) * radius) + center_x; *vertex_y = (int) std::round(std::sin(vertex_angle) * radius) + center_y; } diff --git a/esphome/components/hmc5883l/hmc5883l.cpp b/esphome/components/hmc5883l/hmc5883l.cpp index 7930df7a38..c6b7da6610 100644 --- a/esphome/components/hmc5883l/hmc5883l.cpp +++ b/esphome/components/hmc5883l/hmc5883l.cpp @@ -2,6 +2,8 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" +#include + namespace esphome::hmc5883l { static const char *const TAG = "hmc5883l"; @@ -126,7 +128,7 @@ void HMC5883LComponent::update() { const float y = int16_t(raw_y) * mg_per_bit * 0.1f; const float z = int16_t(raw_z) * mg_per_bit * 0.1f; - float heading = atan2f(0.0f - x, y) * 180.0f / M_PI; + float heading = atan2f(0.0f - x, y) * 180.0f / std::numbers::pi_v; ESP_LOGD(TAG, "Got x=%0.02fµT y=%0.02fµT z=%0.02fµT heading=%0.01f°", x, y, z, heading); if (this->x_sensor_ != nullptr) diff --git a/esphome/components/mmc5603/mmc5603.cpp b/esphome/components/mmc5603/mmc5603.cpp index 79c580c6b7..15e715e675 100644 --- a/esphome/components/mmc5603/mmc5603.cpp +++ b/esphome/components/mmc5603/mmc5603.cpp @@ -1,6 +1,8 @@ #include "mmc5603.h" #include "esphome/core/log.h" +#include + namespace esphome::mmc5603 { static const char *const TAG = "mmc5603"; @@ -143,7 +145,7 @@ void MMC5603Component::update() { const float z = 0.00625 * (raw_z - 524288); - const float heading = atan2f(0.0f - x, y) * 180.0f / M_PI; + const float heading = atan2f(0.0f - x, y) * 180.0f / std::numbers::pi_v; ESP_LOGD(TAG, "Got x=%0.02fµT y=%0.02fµT z=%0.02fµT heading=%0.01f°", x, y, z, heading); if (this->x_sensor_ != nullptr) diff --git a/esphome/components/pid/pid_autotuner.cpp b/esphome/components/pid/pid_autotuner.cpp index 3672d164c4..a7ae631956 100644 --- a/esphome/components/pid/pid_autotuner.cpp +++ b/esphome/components/pid/pid_autotuner.cpp @@ -1,10 +1,7 @@ #include "pid_autotuner.h" #include "esphome/core/log.h" #include - -#ifndef M_PI -#define M_PI 3.1415926535897932384626433 -#endif +#include namespace esphome::pid { @@ -126,7 +123,7 @@ PIDAutotuner::PIDAutotuneResult PIDAutotuner::update(float setpoint, float proce float osc_ampl = this->amplitude_detector_.get_mean_oscillation_amplitude(); float d = (this->relay_function_.output_positive - this->relay_function_.output_negative) / 2.0f; ESP_LOGVV(TAG, " Relay magnitude: %f", d); - this->ku_ = 4.0f * d / float(M_PI * osc_ampl); + this->ku_ = 4.0f * d / (std::numbers::pi_v * osc_ampl); this->pu_ = this->frequency_detector_.get_mean_oscillation_period(); this->state_ = AUTOTUNE_SUCCEEDED; diff --git a/esphome/components/qmc5883l/qmc5883l.cpp b/esphome/components/qmc5883l/qmc5883l.cpp index 5b04a904b5..ba6a71f97d 100644 --- a/esphome/components/qmc5883l/qmc5883l.cpp +++ b/esphome/components/qmc5883l/qmc5883l.cpp @@ -3,6 +3,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" #include +#include namespace esphome::qmc5883l { @@ -173,7 +174,7 @@ void QMC5883LComponent::read_sensor_() { const float y = int16_t(raw[1]) * mg_per_bit * 0.1f; const float z = int16_t(raw[2]) * mg_per_bit * 0.1f; - float heading = atan2f(0.0f - x, y) * 180.0f / M_PI; + float heading = atan2f(0.0f - x, y) * 180.0f / std::numbers::pi_v; float temp = NAN; if (this->temperature_sensor_ != nullptr) { diff --git a/esphome/components/rd03d/rd03d.cpp b/esphome/components/rd03d/rd03d.cpp index c9c6a546ab..2eb76a1087 100644 --- a/esphome/components/rd03d/rd03d.cpp +++ b/esphome/components/rd03d/rd03d.cpp @@ -4,6 +4,7 @@ #include #include +#include namespace esphome::rd03d { @@ -233,7 +234,7 @@ void RD03DComponent::publish_target_(uint8_t target_num, int16_t x, int16_t y, i // Angle is measured from the Y axis (radar forward direction) if (target.angle != nullptr) { if (valid) { - float angle = std::atan2(static_cast(x), static_cast(y)) * 180.0f / M_PI; + float angle = std::atan2(static_cast(x), static_cast(y)) * 180.0f / std::numbers::pi_v; target.angle->publish_state(angle); } else { target.angle->publish_state(NAN); diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index af42c63bf4..376676ce85 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -215,7 +215,7 @@ void SX126x::configure() { // configure modem if (this->modulation_ == PACKET_TYPE_LORA) { // set modulation params - float duration = 1000.0f * std::pow(2, this->spreading_factor_) / BW_HZ[this->bandwidth_]; + float duration = 1000.0f * (1UL << this->spreading_factor_) / BW_HZ[this->bandwidth_]; buf[0] = this->spreading_factor_; buf[1] = BW_LORA[this->bandwidth_ - SX126X_BW_7810]; buf[2] = this->coding_rate_; From 8434d54cc785808494b1d0834a4611cc6896e83c Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 28 Jun 2026 14:07:25 -0700 Subject: [PATCH 0624/1815] [modbus] Reinstate turnaround delay after broadcasts (Revert #17209) (#17263) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/modbus/modbus.cpp | 16 ++---- esphome/components/modbus/modbus.h | 1 - .../fixtures/uart_mock_modbus_broadcast.yaml | 56 ------------------- tests/integration/test_uart_mock_modbus.py | 25 --------- 4 files changed, 5 insertions(+), 93 deletions(-) delete mode 100644 tests/integration/fixtures/uart_mock_modbus_broadcast.yaml diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 5b771c6282..488bcf1459 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -92,14 +92,10 @@ int32_t Modbus::tx_delay_remaining() { int32_t ModbusClientHub::tx_delay_remaining() { const uint32_t now = millis(); - // Turnaround delay only applies after a broadcast: no response is expected, so we must give listening devices - // quiet time to process it before the next request. For normal unicast request/response the received reply already - // provides the inter-frame timing, so adding turnaround there just throttles throughput. - const uint16_t turnaround = this->last_send_was_broadcast_ ? this->turnaround_delay_ms_ : 0; - return std::max( - {(int32_t) 0, - (int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ + turnaround - (now - this->last_send_)), - (int32_t) (this->frame_delay_ms_ + turnaround - (now - this->last_modbus_byte_))}); + return std::max({(int32_t) 0, + (int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ + this->turnaround_delay_ms_ - + (now - this->last_send_)), + (int32_t) (this->frame_delay_ms_ + this->turnaround_delay_ms_ - (now - this->last_modbus_byte_))}); } bool Modbus::tx_blocked() { @@ -491,7 +487,6 @@ bool Modbus::send_frame_(const ModbusFrame &frame) { format_hex_pretty_to(hex_buf, frame.data.get(), frame.size), now - this->last_send_, now - this->last_modbus_byte_); this->last_send_ = now; - this->last_send_was_broadcast_ = frame.size > 0 && frame.data[0] == 0; return true; } @@ -507,8 +502,7 @@ void ModbusClientHub::send_next_frame_() { ModbusDeviceCommand &command = this->tx_buffer_.front(); if (this->send_frame_(command.frame)) { - if (!this->last_send_was_broadcast_) - this->waiting_for_response_ = std::move(command); + this->waiting_for_response_ = std::move(command); } else { if (command.device) command.device->on_modbus_not_sent(); diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 95b7a770b6..4aa3a16c3a 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -63,7 +63,6 @@ class Modbus : public uart::UARTDevice, public Component { uint32_t last_receive_check_{0}; uint32_t last_send_{0}; uint32_t last_send_tx_offset_{0}; - bool last_send_was_broadcast_{false}; uint16_t frame_delay_ms_{5}; uint16_t long_rx_buffer_delay_ms_{0}; diff --git a/tests/integration/fixtures/uart_mock_modbus_broadcast.yaml b/tests/integration/fixtures/uart_mock_modbus_broadcast.yaml deleted file mode 100644 index a5ce02b342..0000000000 --- a/tests/integration/fixtures/uart_mock_modbus_broadcast.yaml +++ /dev/null @@ -1,56 +0,0 @@ -esphome: - name: uart-mock-modbus-bcast - -host: -api: -logger: - level: VERBOSE - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - -# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] -# The actual UART bus used is the uart_mock component below -uart: - baud_rate: 115200 - port: /dev/null - -# No on_tx injection: a broadcast (address 0) gets no reply on a real bus. -uart_mock: - - id: virtual_uart - baud_rate: 9600 - auto_start: true - debug: - -modbus: - - uart_id: virtual_uart - id: virtual_modbus - role: client - send_wait_time: 200ms - turnaround_time: 10ms - -modbus_controller: - - address: 0 - modbus_id: virtual_modbus - update_interval: 60s - id: modbus_controller_bcast - -number: - - platform: modbus_controller - modbus_controller_id: modbus_controller_bcast - id: bcast_write - name: "bcast_write" - address: 0x01 - register_type: holding - value_type: U_WORD - min_value: 0 - max_value: 65535 - -interval: - - interval: 400ms - then: - - number.set: - id: bcast_write - value: 42 diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 385707d849..2c437341c6 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -330,28 +330,3 @@ async def test_uart_mock_modbus_server_controller_multiple( await tracker.setup_and_start_scenario(client) await tracker.await_all(futures) _assert_no_modbus_errors(error_log_lines, warning_log_lines) - - -@pytest.mark.asyncio -async def test_uart_mock_modbus_broadcast( - yaml_config: str, - run_compiled: RunCompiledFunction, - api_client_connected: APIClientConnectedFactory, -) -> None: - """Test that broadcast writes (address 0) don't wait for a response. - - A controller at address 0 sends broadcast writes that get no reply. The - client must not arm the response timeout for them: otherwise every write - blocks for send_wait_time and logs a spurious "no response from 0" warning. - """ - - line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() - - async with ( - run_compiled(yaml_config, line_callback=line_callback), - api_client_connected(), - ): - # Several broadcast writes fire on the 400ms interval; send_wait_time is - # 200ms, so the old behaviour would have warned on each one by now. - await asyncio.sleep(3.0) - _assert_no_modbus_errors(error_log_lines, warning_log_lines) From a336ad6732ef0ddf5f76abacc0b52e2b8566102c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Jun 2026 14:07:51 -0700 Subject: [PATCH 0625/1815] [mcp4725] Use constexpr bit shift instead of powf for full-scale value (#17261) --- esphome/components/mcp4725/mcp4725.cpp | 3 ++- esphome/components/mcp4725/mcp4725.h | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/esphome/components/mcp4725/mcp4725.cpp b/esphome/components/mcp4725/mcp4725.cpp index 21aff90fae..8e94623de3 100644 --- a/esphome/components/mcp4725/mcp4725.cpp +++ b/esphome/components/mcp4725/mcp4725.cpp @@ -24,7 +24,8 @@ void MCP4725::dump_config() { // https://learn.sparkfun.com/tutorials/mcp4725-digital-to-analog-converter-hookup-guide?_ga=2.176055202.1402343014.1607953301-893095255.1606753886 void MCP4725::write_state(float state) { - const uint16_t value = (uint16_t) roundf(state * (powf(2, MCP4725_RES) - 1)); + constexpr uint16_t max_value = (1U << MCP4725_RES) - 1; + const uint16_t value = (uint16_t) roundf(state * max_value); this->write_byte_16(64, value << 4); } diff --git a/esphome/components/mcp4725/mcp4725.h b/esphome/components/mcp4725/mcp4725.h index 4f1f128e52..a0838dc33e 100644 --- a/esphome/components/mcp4725/mcp4725.h +++ b/esphome/components/mcp4725/mcp4725.h @@ -4,10 +4,11 @@ #include "esphome/core/component.h" #include "esphome/components/i2c/i2c.h" -static const uint8_t MCP4725_ADDR = 0x60; -static const uint8_t MCP4725_RES = 12; - namespace esphome::mcp4725 { + +static constexpr uint8_t MCP4725_ADDR = 0x60; +static constexpr uint8_t MCP4725_RES = 12; + class MCP4725 final : public Component, public output::FloatOutput, public i2c::I2CDevice { public: void setup() override; From 5f311d281e78ef38b621eddfc41ab1459754afe1 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 29 Jun 2026 07:17:01 +1000 Subject: [PATCH 0626/1815] [esphome] Warn when a YAML merge (`<<:`) drops a key (#17246) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/config.py | 19 +++++++ esphome/const.py | 1 + esphome/core/config.py | 2 + esphome/yaml_util.py | 27 +++++++++ script/ci-custom.py | 2 +- tests/unit_tests/test_config_normalization.py | 55 +++++++++++++++++++ tests/unit_tests/test_yaml_util.py | 45 +++++++++++++++ 7 files changed, 150 insertions(+), 1 deletion(-) diff --git a/esphome/config.py b/esphome/config.py index 33e687137f..fc8f46909f 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -20,6 +20,7 @@ from esphome.const import ( CONF_ESPHOME, CONF_EXTERNAL_COMPONENTS, CONF_ID, + CONF_MERGE_WARNINGS, CONF_MIN_VERSION, CONF_PACKAGES, CONF_PLATFORM, @@ -1184,6 +1185,24 @@ def validate_config( ) return result + # Warn about any keys silently dropped by `<<` merge includes (shallow, + # first-wins). The esphome: section is now known, so we can honor its + # `merge_warnings:` opt-out. Always drain the queue to keep it from leaking + # into a later run. + if (dropped := yaml_util.take_dropped_merge_keys()) and ( + not isinstance(esphome_conf := config[CONF_ESPHOME], dict) + or esphome_conf.get(CONF_MERGE_WARNINGS, True) + ): + for key, location in dict.fromkeys(dropped): + _LOGGER.warning( + "Key '%s' (%s) was dropped while processing a '<<' merge because it " + "is already defined. Merge keys don't combine sections - the first " + "definition wins. Use 'packages:' to merge sections, or set " + "'esphome: { merge_warnings: false }' to silence this.", + key, + location, + ) + # Snapshot the user's config before any schema validation defaults are # applied. preload_core_config and later validation steps rewrite entries # in-place with defaulted values; deep-copying here preserves the diff --git a/esphome/const.py b/esphome/const.py index 3ca7b2e618..5fa6f00b59 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -613,6 +613,7 @@ CONF_MEASUREMENT_SEQUENCE_NUMBER = "measurement_sequence_number" CONF_MEDIA_PLAYER = "media_player" CONF_MEDIUM = "medium" CONF_MEMORY_BLOCKS = "memory_blocks" +CONF_MERGE_WARNINGS = "merge_warnings" CONF_MESSAGE = "message" CONF_METHANE = "methane" CONF_METHOD = "method" diff --git a/esphome/core/config.py b/esphome/core/config.py index 59c96035b8..0670fde0ff 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -26,6 +26,7 @@ from esphome.const import ( CONF_INCLUDES, CONF_INCLUDES_C, CONF_LIBRARIES, + CONF_MERGE_WARNINGS, CONF_MIN_VERSION, CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, @@ -316,6 +317,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_INCLUDES_C, default=[]): cv.ensure_list(valid_include), cv.Optional(CONF_LIBRARIES, default=[]): cv.ensure_list(cv.string_strict), cv.Optional(CONF_NAME_ADD_MAC_SUFFIX, default=False): cv.boolean, + cv.Optional(CONF_MERGE_WARNINGS, default=True): cv.boolean, cv.Optional(CONF_DEBUG_SCHEDULER, default=False): cv.boolean, cv.Optional(CONF_PROJECT): cv.Schema( { diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index bfe1fb0136..0009cde551 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -51,6 +51,29 @@ _load_listeners: list[Callable[[Path], None]] = [] DocumentPath = list[str | int] +# Key under CORE.data used to accumulate keys that a `<<` merge silently +# dropped. The warning is emitted later (see esphome.config.validate_config), +# because the esphome: option that suppresses it isn't known while parsing. +_MERGE_WARNINGS_KEY = "yaml_dropped_merge_keys" + + +def _record_dropped_merge_key(parent_file: Path, key: Any) -> None: + """Record a mapping key that a ``<<`` merge silently dropped. + + Merge keys follow the YAML spec's shallow, first-wins semantics: a key that + already exists in the mapping (or came from an earlier merge) is discarded + rather than deep-merged the way ``packages:`` would combine it. We collect + these so a single warning can be emitted once the config is loaded. + """ + esp_range = getattr(key, "esp_range", None) + location = str(esp_range.start_mark) if esp_range is not None else str(parent_file) + CORE.data.setdefault(_MERGE_WARNINGS_KEY, []).append((str(key), location)) + + +def take_dropped_merge_keys() -> list[tuple[str, str]]: + """Return and clear the keys dropped during ``<<`` merges so far.""" + return CORE.data.pop(_MERGE_WARNINGS_KEY, []) + class SensitiveStr(str): """Marker subclass for validated strings that should be masked in @@ -551,6 +574,10 @@ class ESPHomeLoaderMixin: # is expected to contain mapping nodes and each of these nodes is merged in # turn according to its order in the sequence. Keys in mapping nodes earlier # in the sequence override keys specified in later mapping nodes." + # + # This is a silent shallow drop (unlike `packages:`, which deep-merges). + # Record it so a warning can be emitted after the config loads. + _record_dropped_merge_key(self.name, key) continue pairs.append((key, value)) # Add key node to seen keys, for sequence merge values. diff --git a/script/ci-custom.py b/script/ci-custom.py index 6c5ad5bb69..4568732b88 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -555,7 +555,7 @@ def lint_constants_usage(): # Maximum allowed CONF_ constants in esphome/const.py. # This file is frozen — new constants go in esphome/components/const/__init__.py. # Decrease this number when constants are moved out of const.py. -CONST_PY_MAX_CONF = 1013 +CONST_PY_MAX_CONF = 1014 @lint_content_check(include=["esphome/const.py"]) diff --git a/tests/unit_tests/test_config_normalization.py b/tests/unit_tests/test_config_normalization.py index 4ec17b3c7c..a06b2da621 100644 --- a/tests/unit_tests/test_config_normalization.py +++ b/tests/unit_tests/test_config_normalization.py @@ -1,6 +1,7 @@ """Unit tests for esphome.config module.""" from collections.abc import Generator +import logging from pathlib import Path from unittest.mock import MagicMock, Mock, patch @@ -113,3 +114,57 @@ def test_ota_with_platform_list_and_captive_portal(fixtures_dir: Path) -> None: platforms = {p.get("platform") for p in result["ota"]} assert "esphome" in platforms, f"Expected esphome platform in {platforms}" assert "web_server" in platforms, f"Expected web_server platform in {platforms}" + + +def _write_merge_conflict_config(tmp_path: Path, *, suppress: bool) -> Path: + """Create a config where two `<<` includes both define `logger:`. + + The second `logger:` is dropped by the shallow merge. Returns the main file. + """ + (tmp_path / "a.yaml").write_text("logger:\n level: DEBUG\n") + (tmp_path / "b.yaml").write_text("logger:\n level: INFO\n") + esphome_section = "esphome:\n name: test\n" + if suppress: + esphome_section += " merge_warnings: false\n" + main = tmp_path / "main.yaml" + main.write_text(f"{esphome_section}<<: !include a.yaml\n<<: !include b.yaml\n") + return main + + +def test_validate_config_warns_on_dropped_merge_key( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """By default, a `<<` merge that drops a key logs a warning.""" + main = _write_merge_conflict_config(tmp_path, suppress=False) + CORE.config_path = main + raw_config = yaml_util.load_yaml(main) + + with caplog.at_level(logging.WARNING, logger="esphome.config"): + config.validate_config(raw_config, {}) + + assert any( + "was dropped while processing a '<<' merge" in record.message + and "logger" in record.message + for record in caplog.records + ) + # The queue is drained so the warning cannot leak into a later run. + assert yaml_util.take_dropped_merge_keys() == [] + + +def test_validate_config_suppresses_merge_warning( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """`esphome: merge_warnings: false` hides the warning but still drains the queue.""" + main = _write_merge_conflict_config(tmp_path, suppress=True) + CORE.config_path = main + raw_config = yaml_util.load_yaml(main) + + with caplog.at_level(logging.WARNING, logger="esphome.config"): + config.validate_config(raw_config, {}) + + assert not any( + "was dropped while processing a '<<' merge" in record.message + for record in caplog.records + ) + # The queue is drained even when the warning is suppressed. + assert yaml_util.take_dropped_merge_keys() == [] diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 6be090b869..fa1c0fcce2 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -1395,3 +1395,48 @@ def test_dump__redaction_flag_does_not_leak_between_calls() -> None: assert "\\033[8m" in redacted assert "\\033[8m" not in raw assert "\\033[8m" in redacted_again + + +@pytest.fixture(autouse=True) +def clear_dropped_merge_keys() -> None: + """Reset the dropped-merge-key queue between tests.""" + core.CORE.data.pop(yaml_util._MERGE_WARNINGS_KEY, None) + yield + core.CORE.data.pop(yaml_util._MERGE_WARNINGS_KEY, None) + + +def test_merge_include_records_dropped_keys(tmp_path: Path) -> None: + """A `<<` merge that overlaps an existing key records it (shallow first-wins).""" + (tmp_path / "a.yaml").write_text("api:\n reboot_timeout: 5min\n") + (tmp_path / "b.yaml").write_text("api:\n password: secret\n") + test_yaml = tmp_path / "test.yaml" + test_yaml.write_text("<<: !include a.yaml\n<<: !include b.yaml\n") + + with patch.object(core.CORE, "config_path", test_yaml): + result = yaml_util.load_yaml(test_yaml) + + # First definition wins; the second `api` block is dropped entirely. + assert result["api"] == {"reboot_timeout": "5min"} + + dropped = yaml_util.take_dropped_merge_keys() + assert len(dropped) == 1 + key, location = dropped[0] + assert key == "api" + assert "b.yaml" in location + # Queue is drained after being taken. + assert yaml_util.take_dropped_merge_keys() == [] + + +def test_merge_include_no_overlap_records_nothing(tmp_path: Path) -> None: + """A `<<` merge with distinct top-level keys drops nothing.""" + (tmp_path / "a.yaml").write_text("api:\n reboot_timeout: 5min\n") + (tmp_path / "b.yaml").write_text("logger:\n level: DEBUG\n") + test_yaml = tmp_path / "test.yaml" + test_yaml.write_text("<<: !include a.yaml\n<<: !include b.yaml\n") + + with patch.object(core.CORE, "config_path", test_yaml): + result = yaml_util.load_yaml(test_yaml) + + assert result["api"] == {"reboot_timeout": "5min"} + assert result["logger"] == {"level": "DEBUG"} + assert yaml_util.take_dropped_merge_keys() == [] From 9e8261056cae22002ab84f245faef71540bae01e Mon Sep 17 00:00:00 2001 From: Tom <7723105+thomasfw@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:06:12 +0100 Subject: [PATCH 0627/1815] [espnow] Fix espnow crash when send() is called without a callback (#17266) --- esphome/components/espnow/espnow_component.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index f89b4a2ff1..2756b615a1 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -303,7 +303,9 @@ void ESPNowComponent::loop() { ESP_LOGV(TAG, ">>> [%s] %s", addr_buf, LOG_STR_ARG(espnow_error_to_str(packet->packet_.sent.status))); #endif if (this->current_send_packet_ != nullptr) { - this->current_send_packet_->callback_(packet->packet_.sent.status); + if (this->current_send_packet_->callback_ != nullptr) { + this->current_send_packet_->callback_(packet->packet_.sent.status); + } this->send_packet_pool_.release(this->current_send_packet_); this->current_send_packet_ = nullptr; // Reset current packet after sending } From d8ffb732b7217685fee617adabe23dd5518a7507 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:04:22 -0500 Subject: [PATCH 0628/1815] Bump zeroconf from 0.149.16 to 0.150.0 (#17137) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a825cd9bff..2510ca61e9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ esptool==5.3.0 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==45.3.1 -zeroconf==0.149.16 +zeroconf==0.150.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From bf0d31b3abdda74f79761a7af35c6d6a4a7e27a9 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:41:21 -0400 Subject: [PATCH 0629/1815] [espidf] Don't fail framework check on broken unrelated PATH tools (#17053) --- esphome/espidf/framework.py | 16 +++++++++------- tests/unit_tests/test_espidf_framework.py | 16 ++++++++++++---- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 6f4aeef9f0..4053898a8e 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -609,14 +609,16 @@ def _check_esphome_idf_framework_install( install = True if _check_stamp(env_stamp_file, stamp_info): _LOGGER.info("Checking ESP-IDF %s framework installation ...", version) - cmd = [ - get_system_python_path(), - str(idf_tools_path), - "--non-interactive", - "check", - ] - if run_command_ok(cmd, msg=f"ESP-IDF {version} check", env=env): + # Validate via the managed tool-path resolution, not ``idf_tools.py check``: + # ``check`` probes tools on the system PATH and aborts if any fail to run (e.g. a + # broken Homebrew openocd), which forced a toolchain reinstall on every build. + try: + _get_idf_tool_paths(framework_path, env) install = False + except RuntimeError as err: + _LOGGER.debug( + "ESP-IDF %s tool resolution failed, reinstalling: %s", version, err + ) # 4. Install framework tools if not installed or needs update if install: diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index d89b93f478..525cd55146 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -298,6 +298,9 @@ def espidf_mocks(setup_core: Path): patch("esphome.espidf.framework.archive_extract_all") as extract, patch("esphome.espidf.framework.create_venv") as venv, patch("esphome.espidf.framework.run_command_ok", return_value=True) as run_ok, + patch( + "esphome.espidf.framework._get_idf_tool_paths", return_value=([], {}) + ) as tool_paths, patch("esphome.espidf.framework._clone_idf_with_submodules") as clone, patch("esphome.espidf.framework._write_idf_version_txt"), patch("esphome.espidf.framework._patch_tools_json_for_linux_arm64"), @@ -308,7 +311,12 @@ def espidf_mocks(setup_core: Path): patch("esphome.espidf.framework.get_system_python_path", return_value="python"), ): yield SimpleNamespace( - download=download, extract=extract, venv=venv, run_ok=run_ok, clone=clone + download=download, + extract=extract, + venv=venv, + run_ok=run_ok, + tool_paths=tool_paths, + clone=clone, ) @@ -403,10 +411,10 @@ def test_check_esp_idf_install_stamp_mismatch_reinstalls( def test_check_esp_idf_install_check_command_failure_reinstalls( espidf_mocks: SimpleNamespace, ) -> None: - """A failing idf_tools check reinstalls tools (marker present, no re-extract).""" + """A failing tool-path resolution reinstalls tools (marker present, no re-extract).""" _mark_installed() - # idf_tools check fails -> install stays True; the later installs succeed. - espidf_mocks.run_ok.side_effect = [False, True, True, True] + # Managed tool resolution fails -> install stays True; the later installs succeed. + espidf_mocks.tool_paths.side_effect = RuntimeError("missing ESP-IDF tool") check_esp_idf_install(_IDF_VERSION, features=["fb"]) espidf_mocks.extract.assert_not_called() From 6d559a32df2fe427355a7c9f5cdb8c1e4e7a047e Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:45:22 -0500 Subject: [PATCH 0630/1815] Bump bundled esphome-device-builder to 1.0.14 (#17139) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 1d39644ab8..bf37d6d88b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.12 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.14 RUN \ platformio settings set enable_telemetry No \ From 8d36167e114eb3bca37d88388eb8dde1b426bde4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:21:26 -0400 Subject: [PATCH 0631/1815] [esp32_ble_server] Fix set_value action with by-reference triggers (#17156) --- .../esp32_ble_server/ble_server_automations.h | 6 ++-- tests/components/esp32_ble_server/common.yaml | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_ble_server/ble_server_automations.h b/esphome/components/esp32_ble_server/ble_server_automations.h index b4e9ed004e..b1f887e2fa 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.h +++ b/esphome/components/esp32_ble_server/ble_server_automations.h @@ -77,13 +77,15 @@ template class BLECharacteristicSetValueAction : public Actionparent_->set_value(this->buffer_.value(x...)); // Set the listener for read events - this->parent_->on_read([this, x...](uint16_t id) { + // ``mutable`` keeps by-copy captures non-const for triggers passing args by reference + // (e.g. climate on_control's ClimateCall&). See #17142. + this->parent_->on_read([this, x...](uint16_t id) mutable { // Set the value of the characteristic every time it is read this->parent_->set_value(this->buffer_.value(x...)); }); // Set the listener in the global manager so only one BLECharacteristicSetValueAction is set for each characteristic BLECharacteristicSetValueActionManager::get_instance()->set_listener( - this->parent_, [this, x...]() { this->parent_->set_value(this->buffer_.value(x...)); }); + this->parent_, [this, x...]() mutable { this->parent_->set_value(this->buffer_.value(x...)); }); } protected: diff --git a/tests/components/esp32_ble_server/common.yaml b/tests/components/esp32_ble_server/common.yaml index 4e34049038..c617a73f87 100644 --- a/tests/components/esp32_ble_server/common.yaml +++ b/tests/components/esp32_ble_server/common.yaml @@ -77,3 +77,34 @@ esp32_ble_server: id: test_change_descriptor value: data: [0x01, 0x02, 0x03] + +# Regression test for #17142: the set_value action used from a trigger that passes +# its argument by reference (climate on_control supplies ClimateCall&) previously +# failed to compile. +sensor: + - platform: template + id: ble_test_temp + lambda: "return 20.0;" + +output: + - platform: template + id: ble_test_output + type: float + write_action: + - logger.log: "out" + +climate: + - platform: pid + name: "BLE Test Climate" + id: ble_test_climate + sensor: ble_test_temp + default_target_temperature: 20 + heat_output: ble_test_output + control_parameters: + kp: 0.1 + ki: 0.001 + kd: 0.1 + on_control: + - ble_server.characteristic.set_value: + id: test_notify_characteristic + value: !lambda "return std::vector{0, 1, 2};" From ee118d384a9aac9687fa6d79df3a92e08813f3e6 Mon Sep 17 00:00:00 2001 From: mnewton25 <83018731+mnewton25@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:40:49 +0100 Subject: [PATCH 0632/1815] [esp32] Use POSIX path for secure-boot signing/verification keys Fixes #17164 (#17166) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/esp32/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 5d4b3b8b47..9c68fec69b 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2316,14 +2316,14 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES", True) add_idf_sdkconfig_option( "CONFIG_SECURE_BOOT_SIGNING_KEY", - str(signed_ota[CONF_SIGNING_KEY].resolve()), + signed_ota[CONF_SIGNING_KEY].resolve().as_posix(), ) else: # Public key mode — verification only, external signing required add_idf_sdkconfig_option("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES", False) add_idf_sdkconfig_option( "CONFIG_SECURE_BOOT_VERIFICATION_KEY", - str(signed_ota[CONF_VERIFICATION_KEY].resolve()), + signed_ota[CONF_VERIFICATION_KEY].resolve().as_posix(), ) cg.add_define("USE_OTA_SIGNED_VERIFICATION") From b3dcaac2626f777b0fd07cafb46e9e748b1b97d5 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:43:39 +1000 Subject: [PATCH 0633/1815] [mipi_spi] Warn on MODE3 default for display without CS pin (#17153) --- esphome/components/mipi_spi/display.py | 10 ++++- tests/component_tests/mipi_spi/test_init.py | 44 +++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index abb7eaa458..d613d0a1ab 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -172,13 +172,19 @@ def model_schema(config): if bus_mode == TYPE_SINGLE: other_options.append(CONF_SPI_16) # Calculate default SPI mode. Mode3 for octal bus or single bus with no cs pin, mode0 otherwise. - spi_mode = model.get_default(CONF_SPI_MODE) + spi_mode = ( + cv.UNDEFINED if CONF_SPI_MODE in config else model.get_default(CONF_SPI_MODE) + ) if not spi_mode: if bus_mode == TYPE_OCTAL or ( bus_mode == TYPE_SINGLE - and not config.get(CONF_CS_PIN, model.get_default(CONF_CS_PIN)) + and config.get(CONF_CS_PIN, model.get_default(CONF_CS_PIN)) is False ): spi_mode = "MODE3" + if bus_mode == TYPE_SINGLE: + LOGGER.warning( + "No SPI mode specified, defaulting to MODE3 due to lack of CS pin. If you experience issues, try setting SPI mode explicitly to MODE0 or MODE3." + ) else: spi_mode = "MODE0" diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index d681908027..dbd8e15702 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -306,6 +306,50 @@ def test_all_predefined_models( run_schema_validation(config) +def test_single_bus_no_cs_no_mode_warns( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """A single-bus display with no CS pin and no explicit SPI mode warns about MODE3 default.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + run_schema_validation({"model": "ili9488", "dc_pin": 14}) + + assert "defaulting to MODE3 due to lack of CS pin" in caplog.text + + +@pytest.mark.parametrize( + "config", + [ + pytest.param( + {"model": "ili9488", "dc_pin": 14, "cs_pin": 0}, + id="cs_pin_provided", + ), + pytest.param( + {"model": "ili9488", "dc_pin": 14, "spi_mode": "mode0"}, + id="spi_mode_provided", + ), + ], +) +def test_single_bus_no_mode_warning_suppressed( + config: ConfigType, + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """No MODE3 warning when a CS pin or an explicit SPI mode is provided.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + run_schema_validation(config) + + assert "defaulting to MODE3 due to lack of CS pin" not in caplog.text + + def test_native_generation( generate_main: Callable[[str | Path], str], component_fixture_path: Callable[[str], Path], From 4f7faa771274d3e274012ca5a17c4f45669a9209 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:06:15 +1200 Subject: [PATCH 0634/1815] Bump bundled esphome-device-builder to 1.0.15 (#17170) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index bf37d6d88b..9b2519b426 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.14 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.15 RUN \ platformio settings set enable_telemetry No \ From 2ec24505d07361ab417fc8493651f5a041a77402 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:05:23 +1000 Subject: [PATCH 0635/1815] [mipi_spi] Suppress sequence errors when page selection used (#17176) --- esphome/components/mipi/__init__.py | 1 + esphome/components/mipi_spi/display.py | 20 ++-- esphome/components/mipi_spi/mipi_spi.h | 8 -- .../mipi_spi/test_page_selection.py | 113 ++++++++++++++++++ 4 files changed, 126 insertions(+), 16 deletions(-) create mode 100644 tests/component_tests/mipi_spi/test_page_selection.py diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 129befe600..3c59345162 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -120,6 +120,7 @@ CSCON = 0xF0 PWCTR6 = 0xF6 ADJCTL3 = 0xF7 PAGESEL = 0xFE +PAGESEL1 = 0xFF MADCTL_MY = 0x80 # Bit 7 Bottom to top MADCTL_MX = 0x40 # Bit 6 Right to left diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index d613d0a1ab..0231d12529 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -17,6 +17,8 @@ from esphome.components.mipi import ( MADCTL, MODE_BGR, MODE_RGB, + PAGESEL, + PAGESEL1, PIXFMT, DriverChip, dimension_schema, @@ -276,14 +278,16 @@ def customise_schema(config): # Check for invalid combinations of MADCTL config if init_sequence := config.get(CONF_INIT_SEQUENCE): commands = [x[0] for x in init_sequence] - if MADCTL in commands and CONF_TRANSFORM in config: - raise cv.Invalid( - f"transform is not supported when MADCTL ({MADCTL:#X}) is in the init sequence" - ) - if PIXFMT in commands: - raise cv.Invalid( - f"PIXFMT ({PIXFMT:#X}) should not be in the init sequence, it will be set automatically" - ) + # If there is page swapping, we can't rely on recognising common commands + if PAGESEL not in commands and PAGESEL1 not in commands: + if MADCTL in commands and CONF_TRANSFORM in config: + raise cv.Invalid( + f"transform is not supported when MADCTL ({MADCTL:#X}) is in the init sequence" + ) + if PIXFMT in commands: + raise cv.Invalid( + f"PIXFMT ({PIXFMT:#X}) should not be in the init sequence, it will be set automatically" + ) if bus_mode == TYPE_QUAD and CONF_DC_PIN in config: raise cv.Invalid("DC pin is not supported in quad mode") diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index a594e48209..d9627899e0 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -176,7 +176,6 @@ class MipiSpi : public display::Display, this->mark_failed(); return; } - auto arg_byte = vec[index]; switch (cmd) { case SLEEP_OUT: { // are we ready, boots? @@ -187,13 +186,6 @@ class MipiSpi : public display::Display, } } break; - case INVERT_ON: - this->invert_colors_ = true; - break; - case BRIGHTNESS: - this->brightness_ = arg_byte; - break; - default: break; } diff --git a/tests/component_tests/mipi_spi/test_page_selection.py b/tests/component_tests/mipi_spi/test_page_selection.py new file mode 100644 index 0000000000..4b1ec22271 --- /dev/null +++ b/tests/component_tests/mipi_spi/test_page_selection.py @@ -0,0 +1,113 @@ +"""Combined tests for PAGESEL/PAGESEL1 behaviour with MADCTL/PIXFMT. + +Covers both the suppression behaviour (when PAGESEL or PAGESEL1 are present) +and the error behaviour when neither page-selection command is present. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.mipi import MADCTL, PAGESEL, PAGESEL1, PIXFMT +from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA +import esphome.config_validation as cv +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def validated_config(config: dict[str, Any]) -> dict[str, Any]: + """Run schema + final validation and return the validated config.""" + cfg = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(cfg) + return cfg + + +def test_madctl_error_suppressed_when_pagesel_present( + set_core_config: SetCoreConfigCallable, +) -> None: + """If PAGESEL is present in init_sequence, MADCTL presence must not raise an error.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + cfg = { + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": 320, "height": 240}, + "transform": {"mirror_x": True, "mirror_y": True, "swap_xy": False}, + "init_sequence": [[PAGESEL, 0x00], [MADCTL, 0x01]], + } + + # Should not raise + validated = validated_config(cfg) + assert validated is not None + + +def test_pixfmt_error_suppressed_when_pagesel1_present( + set_core_config: SetCoreConfigCallable, +) -> None: + """If PAGESEL1 is present in init_sequence, PIXFMT presence must not raise an error.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + cfg = { + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": 320, "height": 240}, + "init_sequence": [[PAGESEL1, 0x00], [PIXFMT, 0x01]], + } + + # Should not raise + validated = validated_config(cfg) + assert validated is not None + + +def test_madctl_raises_without_pagesel( + set_core_config: SetCoreConfigCallable, +) -> None: + """MADCTL in the init_sequence should raise when a transform is configured and + no PAGESEL/PAGESEL1 is present. + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + cfg: dict[str, Any] = { + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": 320, "height": 240}, + "transform": {"mirror_x": True, "mirror_y": True, "swap_xy": False}, + "init_sequence": [[MADCTL, 0x01]], + } + + with pytest.raises(cv.Invalid, match=r"MADCTL .* in the init sequence"): + CONFIG_SCHEMA(cfg) + + +def test_pixfmt_raises_without_pagesel1( + set_core_config: SetCoreConfigCallable, +) -> None: + """PIXFMT in the init_sequence should raise when no PAGESEL/PAGESEL1 is present.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + cfg: dict[str, Any] = { + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": 320, "height": 240}, + "init_sequence": [[PIXFMT, 0x01]], + } + + with pytest.raises( + cv.Invalid, match=r"PIXFMT .* should not be in the init sequence" + ): + CONFIG_SCHEMA(cfg) From 94ccddf176b4fd6521358bba3cba33c9fa6daa7f Mon Sep 17 00:00:00 2001 From: Geoffrey Frogeye Date: Wed, 24 Jun 2026 15:29:57 +0200 Subject: [PATCH 0636/1815] [opentherm] Support power scaling disabled (#17183) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../components/opentherm/output/opentherm_output.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/esphome/components/opentherm/output/opentherm_output.cpp b/esphome/components/opentherm/output/opentherm_output.cpp index 2735c85d06..4092358d75 100644 --- a/esphome/components/opentherm/output/opentherm_output.cpp +++ b/esphome/components/opentherm/output/opentherm_output.cpp @@ -7,9 +7,13 @@ static const char *const TAG = "opentherm.output"; void opentherm::OpenthermOutput::write_state(float state) { ESP_LOGD(TAG, "Received state: %.2f. Min value: %.2f, max value: %.2f", state, min_value_, max_value_); - this->state = state < 0.003 && this->zero_means_zero_ - ? 0.0 - : clamp(std::lerp(min_value_, max_value_, state), min_value_, max_value_); +#ifdef USE_OUTPUT_FLOAT_POWER_SCALING + bool zero_means_zero = this->zero_means_zero_; +#else + bool zero_means_zero = false; +#endif + this->state = + state < 0.003 && zero_means_zero ? 0.0 : clamp(std::lerp(min_value_, max_value_, state), min_value_, max_value_); this->has_state_ = true; ESP_LOGD(TAG, "Output %s set to %.2f", this->id_, this->state); } From 26cf373ae72f0a1f07563483ec2fcd1dc071013c Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:15:00 +0200 Subject: [PATCH 0637/1815] Bump bundled esphome-device-builder to 1.0.16 (#17182) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 9b2519b426..5b9c7b8b55 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.15 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.16 RUN \ platformio settings set enable_telemetry No \ From dfe14f9c3ae0750beba518f1d2e3ecc27e75d45d Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 02:04:22 +0200 Subject: [PATCH 0638/1815] Bump bundled esphome-device-builder to 1.0.17 (#17199) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 5b9c7b8b55..c0795de60f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.16 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.17 RUN \ platformio settings set enable_telemetry No \ From 7a64163c4fec2fd221aa7fb241284b9f1e16c150 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:40:28 -0400 Subject: [PATCH 0639/1815] [esp32] Accept '#' as ESP-IDF source ref separator (#17193) --- esphome/espidf/framework.py | 9 ++++++--- tests/unit_tests/test_espidf_framework.py | 13 +++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 4053898a8e..7213f2dfc0 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -337,16 +337,19 @@ print(".".join([str(x) for x in sys.version_info])) _GITHUB_SHORTHAND_RE = re.compile( - r"^github://([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+?)(?:@([a-zA-Z0-9\-_.\./]+))?$" + r"^github://([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+?)(?:[@#]([a-zA-Z0-9\-_.\./]+))?$" ) _GITHUB_HTTPS_RE = re.compile( - r"^(https://github\.com/[a-zA-Z0-9\-]+/[a-zA-Z0-9\-\._]+?\.git)(?:@([a-zA-Z0-9\-_.\./]+))?$" + r"^(https://github\.com/[a-zA-Z0-9\-]+/[a-zA-Z0-9\-\._]+?\.git)(?:[@#]([a-zA-Z0-9\-_.\./]+))?$" ) def _parse_git_source(source_url: str) -> tuple[str, str | None] | None: """Return ``(url, ref)`` for ``github://owner/repo[@ref]`` or - ``https://github.com/owner/repo.git[@ref]``, else ``None``.""" + ``https://github.com/owner/repo.git[@ref]``, else ``None``. + + The ref may be separated with ``@`` or ``#``; ``#`` matches the PlatformIO + convention used for ``platform_version`` URLs.""" if m := _GITHUB_SHORTHAND_RE.match(source_url): owner, repo, ref = m.group(1), m.group(2), m.group(3) # Tolerate a trailing ".git" on the shorthand repo so the diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index 525cd55146..e0f63e89b4 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -64,6 +64,19 @@ from esphome.framework_helpers import _tar_extract_all, get_python_env_executabl "https://github.com/espressif/esp-idf.git@v6.0.1", ("https://github.com/espressif/esp-idf.git", "v6.0.1"), ), + # '#' ref separator (PlatformIO/git-web convention) works on both forms + ( + "https://github.com/espressif/esp-idf.git#release/v6.1", + ("https://github.com/espressif/esp-idf.git", "release/v6.1"), + ), + ( + "github://espressif/esp-idf#release/v6.1", + ("https://github.com/espressif/esp-idf.git", "release/v6.1"), + ), + ( + "github://espressif/esp-idf.git#master", + ("https://github.com/espressif/esp-idf.git", "master"), + ), # Tolerate a trailing ".git" on the shorthand so the user doesn't # silently end up with a doubled "...esp-idf.git.git" URL. ( From 8bc5b97298be6f25f411c267c450e670861aeb82 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:28:06 -0400 Subject: [PATCH 0640/1815] [network] Set IPv4 type tag on all lwIP platforms, not just esp32 (#17200) --- esphome/components/network/ip_address.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/network/ip_address.h b/esphome/components/network/ip_address.h index 55bb2a1c89..d8a127f4a0 100644 --- a/esphome/components/network/ip_address.h +++ b/esphome/components/network/ip_address.h @@ -119,7 +119,7 @@ struct IPAddress { IPAddress(const std::string &in_address) { ipaddr_aton(in_address.c_str(), &ip_addr_); } IPAddress(ip4_addr_t *other_ip) { memcpy((void *) &ip_addr_, (void *) other_ip, sizeof(ip4_addr_t)); -#if USE_ESP32 && LWIP_IPV6 +#if LWIP_IPV6 ip_addr_.type = IPADDR_TYPE_V4; #endif } From 29dfd820c68b803a1255ef812533ac0485bbf481 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:28:15 -0400 Subject: [PATCH 0641/1815] [wifi] Report STA IP, not SoftAP IP, in wifi_info on ESP8266 (#17185) --- esphome/components/wifi/wifi_component_esp8266.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 717d542fbe..84b864c0c5 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -218,9 +218,18 @@ network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() { return {}; network::IPAddresses addresses; uint8_t index = 0; + // addrList enumerates all lwIP netifs, including the SoftAP / fallback hotspot. Filter out + // the AP address so the STA address is reported as the device IP (see issue #17181). + struct ip_info ap_ip {}; + wifi_get_ip_info(SOFTAP_IF, &ap_ip); + network::IPAddress ap_address(&ap_ip.ip); + bool filter_ap = ap_address.is_set(); for (auto &addr : addrList) { + network::IPAddress ip(addr.ipFromNetifNum()); + if (filter_ap && ip == ap_address) + continue; assert(index < addresses.size()); - addresses[index++] = addr.ipFromNetifNum(); + addresses[index++] = ip; } return addresses; } From f3d61ca3e12703d076be6b71e0565edad7fe25a4 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:36:36 +1000 Subject: [PATCH 0642/1815] [mipi][mipi_spi] Swap native dimensions for swap_xy hardware transform (#17201) Co-authored-by: Claude Opus 4.8 --- esphome/components/mipi/__init__.py | 36 ++-- esphome/components/mipi_spi/display.py | 16 +- .../mipi_spi/test_padding_and_offsets.py | 167 +++++++++++++++++- 3 files changed, 191 insertions(+), 28 deletions(-) diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 3c59345162..63c3ddf9c2 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -388,6 +388,16 @@ class DriverChip: return {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY} return {CONF_MIRROR_X, CONF_MIRROR_Y} + def has_hardware_transform(self, config) -> bool: + """ + Check if the model supports hardware transforms for the given configuration. + """ + return config.get(CONF_TRANSFORM) != CONF_DISABLED and self.transforms == { + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_SWAP_XY, + } + def option(self, name, fallback=False) -> cv.Optional: return cv.Optional(name, default=self.get_default(name, fallback)) @@ -418,10 +428,15 @@ class DriverChip: :return: A tuple (width, height, offset_width, offset_height, pad_width, pad_height). """ + transform = self.get_transform(config) if CONF_DIMENSIONS in config: # Explicit dimensions, just use as is dimensions = config[CONF_DIMENSIONS] if isinstance(dimensions, dict): + native_width = self.get_default(CONF_NATIVE_WIDTH, 0) + native_height = self.get_default(CONF_NATIVE_HEIGHT, 0) + if transform.get(CONF_SWAP_XY) is True: + native_width, native_height = native_height, native_width width = dimensions[CONF_WIDTH] height = dimensions[CONF_HEIGHT] offset_width = dimensions[CONF_OFFSET_WIDTH] @@ -429,23 +444,19 @@ class DriverChip: if CONF_PAD_WIDTH in dimensions: pad_width = dimensions[CONF_PAD_WIDTH] native_width = width + offset_width + pad_width + elif native_width == 0: + pad_width = 0 + native_width = width + offset_width else: - native_width = self.get_default(CONF_NATIVE_WIDTH, 0) - if native_width == 0: - pad_width = 0 - native_width = width + offset_width - else: - pad_width = native_width - width - offset_width + pad_width = native_width - width - offset_width if CONF_PAD_HEIGHT in dimensions: pad_height = dimensions[CONF_PAD_HEIGHT] native_height = height + offset_height + pad_height + elif native_height == 0: + pad_height = 0 + native_height = height + offset_height else: - native_height = self.get_default(CONF_NATIVE_HEIGHT, 0) - if native_height == 0: - pad_height = 0 - native_height = height + offset_height - else: - pad_height = native_height - height - offset_height + pad_height = native_height - height - offset_height if ( pad_width + offset_width >= native_width or pad_height + offset_height >= native_height @@ -461,7 +472,6 @@ class DriverChip: return width, height, 0, 0, 0, 0 # Default dimensions, use model defaults - transform = self.get_transform(config) width = self.get_default(CONF_WIDTH) height = self.get_default(CONF_HEIGHT) diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 0231d12529..4162459058 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -295,13 +295,7 @@ def customise_schema(config): raise cv.Invalid(f"DC pin is required in {bus_mode} mode") denominator(config) model = MODELS[config[CONF_MODEL]] - has_hardware_transform = config.get( - CONF_TRANSFORM - ) != CONF_DISABLED and model.transforms == { - CONF_MIRROR_X, - CONF_MIRROR_Y, - CONF_SWAP_XY, - } + has_hardware_transform = model.has_hardware_transform(config) width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( model.get_dimensions(config, not has_hardware_transform) ) @@ -366,13 +360,7 @@ def get_instance(config): :return: type, template arguments """ model = MODELS[config[CONF_MODEL]] - has_hardware_transform = config.get( - CONF_TRANSFORM - ) != CONF_DISABLED and model.transforms == { - CONF_MIRROR_X, - CONF_MIRROR_Y, - CONF_SWAP_XY, - } + has_hardware_transform = model.has_hardware_transform(config) width, height, offset_width, offset_height, pad_width, pad_height = ( model.get_dimensions(config, not has_hardware_transform) ) diff --git a/tests/component_tests/mipi_spi/test_padding_and_offsets.py b/tests/component_tests/mipi_spi/test_padding_and_offsets.py index 82adf88b7e..7ae6f0e61f 100644 --- a/tests/component_tests/mipi_spi/test_padding_and_offsets.py +++ b/tests/component_tests/mipi_spi/test_padding_and_offsets.py @@ -13,6 +13,16 @@ from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32S3, ) +from esphome.components.mipi import ( + CONF_DIMENSIONS, + CONF_HEIGHT, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_OFFSET_HEIGHT, + CONF_OFFSET_WIDTH, + CONF_SWAP_XY, + CONF_WIDTH, +) from esphome.components.mipi_spi.display import ( CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA, @@ -20,7 +30,13 @@ from esphome.components.mipi_spi.display import ( get_instance, ) from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE -from esphome.const import CONF_CS_PIN, CONF_DC_PIN, PlatformFramework +from esphome.const import ( + CONF_CS_PIN, + CONF_DC_PIN, + CONF_DISABLED, + CONF_TRANSFORM, + PlatformFramework, +) from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable @@ -432,3 +448,152 @@ class TestUserConfiguredPadding: assert config["dimensions"]["width"] == 240 assert config["dimensions"]["height"] == 240 assert config["dimensions"]["pad_height"] == 16 + + +class TestHasHardwareTransform: + """Test DriverChip.has_hardware_transform().""" + + def test_full_transform_model_without_transform_key(self) -> None: + """A model supporting swap_xy uses a hardware transform by default.""" + model = MODELS["ST7789V"] + assert model.has_hardware_transform({}) is True + + def test_full_transform_model_with_transform_dict(self) -> None: + """A configured (non-disabled) transform still uses the hardware path.""" + model = MODELS["ST7789V"] + assert ( + model.has_hardware_transform({CONF_TRANSFORM: {CONF_SWAP_XY: True}}) is True + ) + + def test_full_transform_model_with_transform_disabled(self) -> None: + """Disabling the transform falls back to software transforms.""" + model = MODELS["ST7789V"] + assert model.has_hardware_transform({CONF_TRANSFORM: CONF_DISABLED}) is False + + def test_model_without_swap_xy_support(self) -> None: + """Models that cannot swap axes never use a hardware transform.""" + # AXS15231 only supports mirror_x/mirror_y, not swap_xy. + model = MODELS["AXS15231"] + assert model.transforms == {CONF_MIRROR_X, CONF_MIRROR_Y} + assert model.has_hardware_transform({}) is False + + +class TestSwapXYNativeDimensions: + """Test that native dimensions are swapped when a swap_xy transform is active. + + When explicit dimensions are given in the swapped (rotated) orientation and the + model applies a hardware swap_xy transform, the model's native_width/native_height + defaults must be swapped to match, otherwise padding is computed against the wrong + axis and validation fails. + """ + + def test_explicit_swapped_dimensions_with_swap_xy_transform( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Explicit landscape dimensions on a portrait-native model with swap_xy.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # ST7789V is natively 240x320 (portrait). Provide landscape dimensions + # together with a swap_xy transform. + model = MODELS["ST7789V"] + assert model.get_default("native_width") == 240 + assert model.get_default("native_height") == 320 + + config = { + "model": "ST7789V", + CONF_DIMENSIONS: { + CONF_WIDTH: 320, + CONF_HEIGHT: 240, + CONF_OFFSET_WIDTH: 0, + CONF_OFFSET_HEIGHT: 0, + }, + CONF_TRANSFORM: { + CONF_SWAP_XY: True, + CONF_MIRROR_X: False, + CONF_MIRROR_Y: False, + }, + } + + # swap=False because the buffer is laid out in the requested orientation. + width, height, offset_w, offset_h, pad_w, pad_h = model.get_dimensions( + config, swap=False + ) + # Native dims are swapped to 320x240, so padding works out to zero rather + # than going negative (which previously raised "Invalid offsets"). + assert (width, height) == (320, 240) + assert (offset_w, offset_h) == (0, 0) + assert (pad_w, pad_h) == (0, 0) + + def test_explicit_dimensions_without_swap_keeps_native_orientation( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Without swap_xy the native dimensions keep their original orientation.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + model = MODELS["ST7789V"] + config = { + "model": "ST7789V", + CONF_DIMENSIONS: { + CONF_WIDTH: 240, + CONF_HEIGHT: 320, + CONF_OFFSET_WIDTH: 0, + CONF_OFFSET_HEIGHT: 0, + }, + CONF_TRANSFORM: { + CONF_SWAP_XY: False, + CONF_MIRROR_X: False, + CONF_MIRROR_Y: False, + }, + } + + width, height, offset_w, offset_h, pad_w, pad_h = model.get_dimensions( + config, swap=False + ) + assert (width, height) == (240, 320) + assert (offset_w, offset_h) == (0, 0) + assert (pad_w, pad_h) == (0, 0) + + def test_swapped_native_dimensions_compute_padding( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Padding is derived from the swapped native size when swap_xy is active.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # ILI9341 is natively 240x320. Request a 300x240 area in landscape; the + # swapped native size is 320x240, leaving 20px of horizontal padding. + model = MODELS["ILI9341"] + assert model.get_default("native_width") == 240 + assert model.get_default("native_height") == 320 + + config = { + "model": "ILI9341", + CONF_DIMENSIONS: { + CONF_WIDTH: 300, + CONF_HEIGHT: 240, + CONF_OFFSET_WIDTH: 0, + CONF_OFFSET_HEIGHT: 0, + }, + CONF_TRANSFORM: { + CONF_SWAP_XY: True, + CONF_MIRROR_X: False, + CONF_MIRROR_Y: False, + }, + } + + width, height, _, _, pad_w, pad_h = model.get_dimensions(config, swap=False) + assert (width, height) == (300, 240) + # native_width swapped to 320 -> pad_width = 320 - 300 - 0 = 20 + assert pad_w == 20 + assert pad_h == 0 From 9a1daa5247372902d15413eff0b8d0d9e0804670 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:36:10 -0400 Subject: [PATCH 0643/1815] [hbridge] Fix light stuck on one polarity (#17162) --- esphome/components/hbridge/light/__init__.py | 6 ++-- .../hbridge/light/hbridge_light_output.h | 30 +++++++++++-------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/esphome/components/hbridge/light/__init__.py b/esphome/components/hbridge/light/__init__.py index ccb47237b6..f9451e2594 100644 --- a/esphome/components/hbridge/light/__init__.py +++ b/esphome/components/hbridge/light/__init__.py @@ -1,14 +1,14 @@ import esphome.codegen as cg from esphome.components import light, output import esphome.config_validation as cv -from esphome.const import CONF_OUTPUT_ID, CONF_PIN_A, CONF_PIN_B +from esphome.const import CONF_OUTPUT_ID, CONF_PIN_A, CONF_PIN_B, CONF_UPDATE_INTERVAL from .. import hbridge_ns CODEOWNERS = ["@DotNetDann"] HBridgeLightOutput = hbridge_ns.class_( - "HBridgeLightOutput", cg.Component, light.LightOutput + "HBridgeLightOutput", cg.PollingComponent, light.LightOutput ) CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend( @@ -16,12 +16,14 @@ CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend( cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(HBridgeLightOutput), cv.Required(CONF_PIN_A): cv.use_id(output.FloatOutput), cv.Required(CONF_PIN_B): cv.use_id(output.FloatOutput), + cv.Optional(CONF_UPDATE_INTERVAL, default="8ms"): cv.update_interval, } ) async def to_code(config): var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) + cg.add(var.set_update_interval(config.pop(CONF_UPDATE_INTERVAL))) await cg.register_component(var, config) await light.register_light(var, config) diff --git a/esphome/components/hbridge/light/hbridge_light_output.h b/esphome/components/hbridge/light/hbridge_light_output.h index 16408f24f1..9dcf7adfd8 100644 --- a/esphome/components/hbridge/light/hbridge_light_output.h +++ b/esphome/components/hbridge/light/hbridge_light_output.h @@ -3,11 +3,10 @@ #include "esphome/components/light/light_output.h" #include "esphome/components/output/float_output.h" #include "esphome/core/component.h" -#include "esphome/core/helpers.h" namespace esphome::hbridge { -class HBridgeLightOutput : public Component, public light::LightOutput { +class HBridgeLightOutput final : public PollingComponent, public light::LightOutput { public: void set_pina_pin(output::FloatOutput *pina_pin) { this->pina_pin_ = pina_pin; } void set_pinb_pin(output::FloatOutput *pinb_pin) { this->pinb_pin_ = pinb_pin; } @@ -20,11 +19,12 @@ class HBridgeLightOutput : public Component, public light::LightOutput { return traits; } - void setup() override { this->disable_loop(); } + void setup() override { this->stop_poller(); } - void loop() override { - // Only called when both channels are active — alternate H-bridge direction - // each iteration to multiplex cold and warm white. + void update() override { + // Flip the H-bridge direction to multiplex cold/warm white. update_interval must stay + // slower than the output's PWM period (flipping faster collapses the output onto one + // channel) but fast enough to avoid flicker (issue #17030). if (!this->forward_direction_) { this->pina_pin_->set_level(this->pina_duty_); this->pinb_pin_->set_level(0); @@ -46,13 +46,17 @@ class HBridgeLightOutput : public Component, public light::LightOutput { this->pinb_duty_ = new_pinb; if (new_pina != 0.0f && new_pinb != 0.0f) { - // Both channels active — need loop to alternate H-bridge direction - this->high_freq_.start(); - this->enable_loop(); + // Both channels active — multiplex the H-bridge direction via the poller. + if (!this->multiplexing_) { + this->multiplexing_ = true; + this->start_poller(); + } } else { - // Zero or one channel active — drive pins directly, no multiplexing needed - this->high_freq_.stop(); - this->disable_loop(); + // Zero or one channel active — drive pins directly, no multiplexing needed. + if (this->multiplexing_) { + this->multiplexing_ = false; + this->stop_poller(); + } this->pina_pin_->set_level(new_pina); this->pinb_pin_->set_level(new_pinb); } @@ -64,7 +68,7 @@ class HBridgeLightOutput : public Component, public light::LightOutput { float pina_duty_{0}; float pinb_duty_{0}; bool forward_direction_{false}; - HighFrequencyLoopRequester high_freq_; + bool multiplexing_{false}; }; } // namespace esphome::hbridge From eb711381d33749bb291170f333c88cb2750fb438 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:47:24 +0200 Subject: [PATCH 0644/1815] Bump bundled esphome-device-builder to 1.0.18 (#17212) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c0795de60f..24710365cf 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.17 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.18 RUN \ platformio settings set enable_telemetry No \ From f78cbf920033cb72ce965ad14d69bb71c53f0510 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 07:48:52 +0200 Subject: [PATCH 0645/1815] Bump bundled esphome-device-builder to 1.0.19 (#17217) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 24710365cf..4a375ed15b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.18 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.19 RUN \ platformio settings set enable_telemetry No \ From 84d1c34c28cb6d45545c0803659f124ac23b8242 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 27 Jun 2026 15:49:56 +0200 Subject: [PATCH 0646/1815] [core] Fix area saved as null in storage.json (#17219) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/core/config.py | 12 +++++++++++- tests/unit_tests/core/test_config.py | 29 +++++++++++++++++++++++++--- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index b925f0b7d9..59c96035b8 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -407,6 +407,17 @@ def preload_core_config(config, result) -> str: CORE.name = conf[CONF_NAME] CORE.friendly_name = conf.get(CONF_FRIENDLY_NAME) + # Record the node's area name now (substitutions are already resolved at this + # point). storage.json is written before to_code() runs, so deferring this to + # to_code() left the area as null in storage.json. The value here is the raw + # post-substitution form (a plain string or a {name: ...} mapping). Assign + # unconditionally (like friendly_name) so a config without an area never + # inherits a stale value from a previous load in a long-running process, and + # use .get() so a malformed mapping surfaces later as a proper validation + # error rather than a KeyError here. to_code() sets it again from the + # validated config, which yields the same name. + area = conf.get(CONF_AREA) + CORE.area = area.get(CONF_NAME) if isinstance(area, dict) else area CORE.data[KEY_CORE] = {} if CONF_BUILD_PATH not in conf: @@ -760,7 +771,6 @@ async def to_code(config: ConfigType) -> None: # Process areas all_areas: list[dict[str, str | core.ID]] = [] if CONF_AREA in config: - CORE.area = config[CONF_AREA][CONF_NAME] all_areas.append(config[CONF_AREA]) all_areas.extend(config[CONF_AREAS]) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index e2b34d92d8..b3d87f6857 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -152,15 +152,21 @@ def test_multiple_areas_and_devices(yaml_file: Callable[[str], str]) -> None: ("multiple_areas_devices.yaml", "Main Area"), ], ) -async def test_to_code_records_core_area( +async def test_core_area_recorded_at_config_load( yaml_file: Callable[[str], Path], fixture: str, expected_area: str, ) -> None: - """``to_code`` records the node's area name on CORE for StorageJSON.""" + """The node's area name is recorded on CORE for StorageJSON. + + It must be set during config load (preload_core_config), not deferred to + to_code(): storage.json is written before to_code() runs, so a late + assignment left the area as null in storage.json (regression #17218). + """ result = load_config_from_fixture(yaml_file, fixture, FIXTURES_DIR) assert result is not None - assert CORE.area is None + # Recorded already at config-load time, before any code generation. + assert CORE.area == expected_area with patch("esphome.core.config.cg") as mock_cg: mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock() @@ -170,6 +176,23 @@ async def test_to_code_records_core_area( assert CORE.area == expected_area +def test_config_load_without_area_clears_stale_core_area( + yaml_file: Callable[[str], Path], +) -> None: + """A config without an area must not inherit a stale CORE.area. + + preload_core_config assigns CORE.area unconditionally, so the area from a + previous load in a long-running process cannot leak into a config that + omits it. + """ + CORE.area = "Stale Area From Previous Load" + result = load_config_from_fixture( + yaml_file, "device_without_area.yaml", FIXTURES_DIR + ) + assert result is not None + assert CORE.area is None + + def test_legacy_string_area( yaml_file: Callable[[str], str], caplog: pytest.LogCaptureFixture ) -> None: From 62e19bcb274c5deef15a8a2d05b4dc6236c0ea77 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 15:00:23 -0700 Subject: [PATCH 0647/1815] Bump bundled esphome-device-builder to 1.0.20 (#17244) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 4a375ed15b..99b4b1b39d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.19 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.20 RUN \ platformio settings set enable_telemetry No \ From 1793ca5eac9e055c3dd19a882f45485e777b627b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:05:00 -0400 Subject: [PATCH 0648/1815] [core] Suppress unactionable legacy-redaction warning for substitutions (#17242) --- esphome/__main__.py | 27 ++++++++++++++++++++++----- tests/unit_tests/test_main.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index bda3dcbd05..bec00cca60 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1470,12 +1470,29 @@ _LEGACY_REDACTION_REMOVAL = "2026.12.0" def _redact_with_legacy_fallback(output: str) -> str: unmarked: set[str] = set() + # Track the top-level ``substitutions:`` block. Its keys are arbitrary + # user-chosen names with no schema validator, so the ``cv.sensitive(...)`` + # migration named in the warning can't be applied to them. Their values are + # still redacted, but emitting the (unactionable) deprecation warning would + # only confuse users. + in_substitutions = False - def _replace(m: re.Match[str]) -> str: - unmarked.add(m.group("key")) - return f"{m.group('key')}: \\033[8m{m.group('val')}\\033[28m" - - output = _LEGACY_REDACTION_RE.sub(_replace, output) + lines = output.split("\n") + for i, line in enumerate(lines): + # A non-indented, non-blank line is a top-level key that opens or + # closes the substitutions block. + if line and not line[0].isspace(): + in_substitutions = line.startswith(f"{CONF_SUBSTITUTIONS}:") + m = _LEGACY_REDACTION_RE.search(line) + if m is None: + continue + if not in_substitutions: + unmarked.add(m.group("key")) + lines[i] = ( + f"{line[: m.start()]}{m.group('key')}: " + f"\\033[8m{m.group('val')}\\033[28m{line[m.end() :]}" + ) + output = "\n".join(lines) for key in sorted(unmarked): _LOGGER.warning( "Field '%s' is being redacted by a legacy substring heuristic. " diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index acd39cedc6..eb39ceab2a 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -437,6 +437,36 @@ def test_redact_with_legacy_fallback__does_not_match_fragment_as_suffix( assert not any("legacy substring" in rec.message for rec in caplog.records) +def test_redact_with_legacy_fallback__substitutions_redacted_without_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + """Substitution keys have no schema validator, so their values are still + redacted but the unactionable cv.sensitive migration warning is suppressed + (see issue #17225).""" + text = "substitutions:\n ota_password: apolloautomation\nesphome:\n name: x\n" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback(text) + assert "ota_password: \\033[8mapolloautomation\\033[28m" in out + assert not any("legacy substring" in rec.message for rec in caplog.records) + + +def test_redact_with_legacy_fallback__warns_after_substitutions_block( + caplog: pytest.LogCaptureFixture, +) -> None: + """The suppression ends at the next top-level key; a sensitive-shaped field + in a later block (a real schema field) still warns, while the substitution + above it does not.""" + text = ( + "substitutions:\n ota_password: apolloautomation\nwifi:\n password: hunter2\n" + ) + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback(text) + assert "ota_password: \\033[8mapolloautomation\\033[28m" in out + assert "password: \\033[8mhunter2\\033[28m" in out + assert any("'password'" in rec.message for rec in caplog.records) + assert not any("ota_password" in rec.message for rec in caplog.records) + + def test_command_config__invokes_legacy_fallback_when_redacting( tmp_path: Path, capfd: CaptureFixture[str] ) -> None: From 14b6a0ede1a90f6a67e4b3d93e192fef808b6560 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:17:09 -0400 Subject: [PATCH 0649/1815] [espnow] Don't throttle ESP-NOW RX when deep_sleep is present (#17240) --- esphome/components/espnow/espnow_component.cpp | 8 -------- 1 file changed, 8 deletions(-) diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index 403e6f4944..f89b4a2ff1 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -28,9 +28,6 @@ namespace esphome::espnow { static constexpr const char *TAG = "espnow"; -static const esp_err_t CONFIG_ESPNOW_WAKE_WINDOW = 50; -static const esp_err_t CONFIG_ESPNOW_WAKE_INTERVAL = 100; - ESPNowComponent *global_esp_now = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static const LogString *espnow_error_to_str(esp_err_t error) { @@ -204,11 +201,6 @@ void ESPNowComponent::enable_() { esp_wifi_get_mac(WIFI_IF_STA, this->own_address_); -#ifdef USE_DEEP_SLEEP - esp_now_set_wake_window(CONFIG_ESPNOW_WAKE_WINDOW); - esp_wifi_connectionless_module_set_wake_interval(CONFIG_ESPNOW_WAKE_INTERVAL); -#endif - this->state_ = ESPNOW_STATE_ENABLED; for (auto peer : this->peers_) { From 24d8e99c507e7b44019c427af8987350fbba4ee6 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 28 Jun 2026 09:41:49 -0700 Subject: [PATCH 0650/1815] Bump bundled esphome-device-builder to 1.0.21 (#17257) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 99b4b1b39d..8dce7861df 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.20 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.21 RUN \ platformio settings set enable_telemetry No \ From 4fbe0d87ec3ff43e32f68ad515b359511c0e5863 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:16:42 -0400 Subject: [PATCH 0651/1815] [wifi] Fix crash when WiFi is enabled late alongside ESP-NOW (#17239) --- .../wifi/wifi_component_esp_idf.cpp | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index b395c77141..2ade015a25 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -179,12 +179,53 @@ void WiFiComponent::wifi_lazy_init_() { // nor re-register the default WiFi handlers. if (s_sta_netif == nullptr) s_sta_netif = esp_netif_create_default_wifi_sta(); + if (s_sta_netif == nullptr) { + // Allocation failed; leave wifi_initialized_ false so a later enable() retries. + ESP_LOGE(TAG, "esp_netif_create_default_wifi_sta failed"); + return; + } #ifdef USE_WIFI_AP if (s_ap_netif == nullptr) s_ap_netif = esp_netif_create_default_wifi_ap(); #endif // USE_WIFI_AP + // The WiFi driver was started (e.g. by ESP-NOW with the wifi component disabled at + // boot) before our STA netif existed. The default WIFI_EVENT_STA_START handler + // therefore ran with no netif and never called esp_wifi_register_if_rxcb() -- the + // only thing that points the driver's RX path at a netif (it sets + // s_wifi_netifs[WIFI_IF_STA]). A bare esp_netif_action_start() would stop the + // immediate crash (#17232) but leaves RX unbound, so the first association + // associates at L2 yet never receives DHCP replies and times out (#17239). Restart + // the driver now that the netif exists so STA_START re-runs the default handler and + // wires RX correctly. ESP-NOW survives the stop/start (its peer state persists). + // This also matches a self-retry: if esp_wifi_set_storage() below failed on a + // previous wifi_lazy_init_() it returned without setting wifi_initialized_, and + // esp_wifi_init() has since run, so esp_wifi_get_mode() now succeeds here too. + wifi_mode_t mode; + if (esp_wifi_get_mode(&mode) == ESP_OK) { + ESP_LOGD(TAG, "WiFi driver already started without STA netif; restarting to bind it"); + esp_err_t err = esp_wifi_stop(); + if (err != ESP_OK) { + ESP_LOGW(TAG, "esp_wifi_stop failed: %s", esp_err_to_name(err)); + } + // Re-apply RAM storage; the normal init path does this, but it is skipped on + // the self-retry case above, which would otherwise let the driver persist + // credentials to NVS for the rest of the boot. + err = esp_wifi_set_storage(WIFI_STORAGE_RAM); + if (err != ESP_OK) { + ESP_LOGW(TAG, "esp_wifi_set_storage failed: %s", esp_err_to_name(err)); + } + err = esp_wifi_start(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_wifi_start failed: %s", esp_err_to_name(err)); + return; + } + s_wifi_started = true; + this->wifi_initialized_ = true; + return; + } + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); if (global_preferences->nvs_handle == 0) { ESP_LOGW(TAG, "starting wifi without nvs"); From 6251c26cc6c16f0b4318a4d6d96f90ce5706bf94 Mon Sep 17 00:00:00 2001 From: Tom <7723105+thomasfw@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:06:12 +0100 Subject: [PATCH 0652/1815] [espnow] Fix espnow crash when send() is called without a callback (#17266) --- esphome/components/espnow/espnow_component.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index f89b4a2ff1..2756b615a1 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -303,7 +303,9 @@ void ESPNowComponent::loop() { ESP_LOGV(TAG, ">>> [%s] %s", addr_buf, LOG_STR_ARG(espnow_error_to_str(packet->packet_.sent.status))); #endif if (this->current_send_packet_ != nullptr) { - this->current_send_packet_->callback_(packet->packet_.sent.status); + if (this->current_send_packet_->callback_ != nullptr) { + this->current_send_packet_->callback_(packet->packet_.sent.status); + } this->send_packet_pool_.release(this->current_send_packet_); this->current_send_packet_ = nullptr; // Reset current packet after sending } From a618ee11b41a9baab68ac718fd7124382a39f598 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:30:24 +1200 Subject: [PATCH 0653/1815] Bump version to 2026.6.3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index ea36d45fee..bc92241937 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.6.2 +PROJECT_NUMBER = 2026.6.3 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 7cc9b604d9..b7ffb9121d 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.6.2" +__version__ = "2026.6.3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 2778c62d07ba56f6dcb9db3e8aafd600a6b0b910 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 29 Jun 2026 11:33:56 -0400 Subject: [PATCH 0654/1815] [audio] Bump microMP3 to v0.4.0 (#17279) --- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 091f496e33..d87f32fc36 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -395,7 +395,7 @@ async def to_code(config): ) if data.mp3_support: cg.add_define("USE_AUDIO_MP3_SUPPORT") - add_idf_component(name="esphome/micro-mp3", ref="0.3.0") + add_idf_component(name="esphome/micro-mp3", ref="0.4.0") _emit_memory_pair( data.mp3.buffer_memory, "CONFIG_MICRO_MP3_PREFER_PSRAM", diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 81c16f2e38..4f36e4dbe6 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -12,7 +12,7 @@ dependencies: esphome/micro-flac: version: 0.2.0 esphome/micro-mp3: - version: 0.3.0 + version: 0.4.0 esphome/micro-opus: version: 0.4.1 esphome/micro-wav: From b8690c8e31600201439a6ffb8325c436cde8a46e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:32:28 -0400 Subject: [PATCH 0655/1815] [core] Drop Python 3.11 support (#17280) --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci-docker.yml | 4 +- .../workflows/ci-memory-impact-comment.yml | 2 +- .github/workflows/ci.yml | 6 +- .github/workflows/release.yml | 2 +- .pre-commit-config.yaml | 2 +- AGENTS.md | 2 +- esphome/async_thread.py | 9 +- esphome/components/nrf52/framework.py | 17 +-- esphome/framework_helpers.py | 124 ++++-------------- esphome/helpers.py | 10 +- esphome/platformio/library.py | 7 +- pyproject.toml | 6 +- script/lint-python | 2 +- tests/integration/state_utils.py | 5 +- tests/unit_tests/test_framework_helpers.py | 10 -- 16 files changed, 58 insertions(+), 152 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 17234e811a..4c0c330a19 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -25,7 +25,7 @@ jobs: - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: - python-version: "3.11" + python-version: "3.12" - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 9678831b50..d6ad28dffe 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -65,7 +65,7 @@ jobs: - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: - python-version: "3.11" + python-version: "3.12" - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 @@ -149,7 +149,7 @@ jobs: - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: - python-version: "3.11" + python-version: "3.12" - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 diff --git a/.github/workflows/ci-memory-impact-comment.yml b/.github/workflows/ci-memory-impact-comment.yml index 4bef082aab..ac0322e2fa 100644 --- a/.github/workflows/ci-memory-impact-comment.yml +++ b/.github/workflows/ci-memory-impact-comment.yml @@ -60,7 +60,7 @@ jobs: if: steps.pr.outputs.skip != 'true' uses: ./.github/actions/restore-python with: - python-version: "3.11" + python-version: "3.12" cache-key: ${{ hashFiles('.cache-key') }} - name: Download memory analysis artifacts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72519e421a..751241f563 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,8 +12,8 @@ permissions: contents: read # actions/checkout for all jobs; individual jobs add their own scopes when they need to write env: - DEFAULT_PYTHON: "3.11" - PYUPGRADE_TARGET: "--py311-plus" + DEFAULT_PYTHON: "3.12" + PYUPGRADE_TARGET: "--py312-plus" concurrency: # yamllint disable-line rule:line-length @@ -203,7 +203,7 @@ jobs: fail-fast: false matrix: python-version: - - "3.11" + - "3.12" - "3.13" - "3.14" os: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b23b561bd..20a77b152d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,7 +96,7 @@ jobs: - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: - python-version: "3.11" + python-version: "3.12" - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ba74aff07c..da424f516f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,7 +40,7 @@ repos: rev: v3.21.2 hooks: - id: pyupgrade - args: [--py311-plus] + args: [--py312-plus] - repo: https://github.com/adrienverge/yamllint.git rev: v1.37.1 hooks: diff --git a/AGENTS.md b/AGENTS.md index 21905ea356..46caea3aec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ This document provides essential context for AI models interacting with this pro ## 2. Core Technologies & Stack -* **Languages:** Python (>=3.11), C++ (gnu++20) +* **Languages:** Python (>=3.12), C++ (gnu++20) * **Frameworks & Runtimes:** PlatformIO, Arduino, ESP-IDF. * **Build Systems:** PlatformIO is the primary build system. CMake is used as an alternative. * **Configuration:** YAML. diff --git a/esphome/async_thread.py b/esphome/async_thread.py index c5225a7a14..3972d735f5 100644 --- a/esphome/async_thread.py +++ b/esphome/async_thread.py @@ -12,12 +12,9 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable import threading -from typing import Generic, TypeVar - -_T = TypeVar("_T") -class AsyncThreadRunner(threading.Thread, Generic[_T]): +class AsyncThreadRunner[T](threading.Thread): """Run an async coroutine in a daemon thread and expose its result. The runner catches all exceptions from the coroutine and stores them in @@ -35,10 +32,10 @@ class AsyncThreadRunner(threading.Thread, Generic[_T]): result = runner.result """ - def __init__(self, coro_factory: Callable[[], Awaitable[_T]]) -> None: + def __init__(self, coro_factory: Callable[[], Awaitable[T]]) -> None: super().__init__(daemon=True) self._coro_factory = coro_factory - self.result: _T | None = None + self.result: T | None = None self.exception: BaseException | None = None self.event = threading.Event() diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 05feadb001..7aec6b088e 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -54,22 +54,15 @@ def _get_toolchain_path(version: str) -> Path: return _get_tools_path() / "toolchains" / version -# onexc/dir_fd were added to shutil.rmtree in 3.12; the 3.11 branch uses onerror. _SITECUSTOMIZE = """\ -import os, stat, shutil, sys +import os, stat, shutil _orig = shutil.rmtree def _handler(func, path, exc): os.chmod(path, stat.S_IWRITE); func(path) -if sys.version_info >= (3, 12): - def _rmtree(path, ignore_errors=False, onerror=None, *, onexc=None, dir_fd=None): - if onerror is None and onexc is None: - onexc = _handler - return _orig(path, ignore_errors=ignore_errors, onerror=onerror, onexc=onexc, dir_fd=dir_fd) -else: - def _rmtree(path, ignore_errors=False, onerror=None): - if onerror is None: - onerror = _handler - return _orig(path, ignore_errors=ignore_errors, onerror=onerror) +def _rmtree(path, ignore_errors=False, onerror=None, *, onexc=None, dir_fd=None): + if onerror is None and onexc is None: + onexc = _handler + return _orig(path, ignore_errors=ignore_errors, onerror=onerror, onexc=onexc, dir_fd=dir_fd) shutil.rmtree = _rmtree """ diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index a8e5cf75a8..69cecc58e2 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -239,22 +239,19 @@ def _tar_extract_all( """ Extract a TAR archive to the specified directory. - Implementation is inspired by Python 3.12's tarfile data filtering logic. - This can be replaced with the standard library implementation once - support for Python 3.11 is no longer required. + Path-traversal, link, permission and ownership sanitization is delegated to + the stdlib ``tarfile.data_filter`` (PEP 706). We keep the wrapper-directory + stripping (no stdlib equivalent) and the absolute-path reject (data_filter's + check is os.path-dependent and would miss a Windows drive path when + extracting on POSIX). Args: data: File-like object containing the TAR archive extract_dir: Directory to extract contents to progress_header: If set, show a progress bar with this header """ - import stat import tarfile - # Tar extraction safety: os.path.realpath / commonpath / normpath have no - # pathlib equivalents and Path.resolve() would follow symlinks unsafely. - # Use os.path for the security-sensitive parts; the simple checks move to - # Path. extract_dir = os.fspath(extract_dir) abs_dest = os.path.abspath(extract_dir) # noqa: PTH100 @@ -269,18 +266,14 @@ def _tar_extract_all( safe_members = [] for member in all_members: - name = member.name - - # 1. Strip leading slashes - name = name.lstrip("/" + os.sep) - - # 2. Reject absolute paths (incl. Windows drive) + # Strip leading slashes, then reject absolute / Windows-drive paths + name = member.name.lstrip("/" + os.sep) if Path(name).is_absolute() or ( os.name == "nt" and ":" in name.split(os.sep)[0] # noqa: PTH206 ): continue - # 3. Strip wrapper directory if one was detected + # Strip wrapper directory if one was detected if strip_prefix is not None: norm = name.replace("\\", "/") if norm in (strip_root, strip_prefix): @@ -288,88 +281,29 @@ def _tar_extract_all( if not norm.startswith(strip_prefix): continue name = norm[len(strip_prefix) :] - - # 4. Compute final path - target_path = os.path.realpath(os.path.join(abs_dest, name)) # noqa: PTH118 - if os.path.commonpath([abs_dest, target_path]) != abs_dest: - continue - - # 5. Validate links properly - if member.issym() or member.islnk(): - linkname = member.linkname - - # Reject absolute link targets - if Path(linkname).is_absolute(): - continue - - if member.islnk() and strip_prefix is not None: - # Hard-link linknames reference another archive member - # by its archive name. We've stripped the wrapper prefix - # from member.name above (step 3); strip it here too so - # tarfile._find_link_target can resolve the target during - # extraction. Symlink linknames are filesystem-relative - # paths, not archive-member references, so they don't - # need this treatment. - norm_link = linkname.replace("\\", "/") - if norm_link in (strip_root, strip_prefix): - continue - if not norm_link.startswith(strip_prefix): - continue - linkname = norm_link[len(strip_prefix) :] - - # Strip leading slashes - linkname = os.path.normpath(linkname) - - if member.issym(): - link_target = os.path.join( # noqa: PTH118 - abs_dest, - os.path.dirname(name), # noqa: PTH120 - linkname, - ) - else: - link_target = os.path.join(abs_dest, linkname) # noqa: PTH118 - link_target = os.path.realpath(link_target) - - if os.path.commonpath([abs_dest, link_target]) != abs_dest: - continue - - # write back normalized linkname - member.linkname = linkname - - # 6. Sanitize permissions - mode = member.mode - if mode is not None: - # Strip high bits & group/other write bits - mode &= ( - stat.S_IRWXU - | stat.S_IRGRP - | stat.S_IXGRP - | stat.S_IROTH - | stat.S_IXOTH - ) - if member.isfile() or member.islnk(): - # remove exec bits unless explicitly user-executable - if not (mode & stat.S_IXUSR): - mode &= ~(stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - mode |= stat.S_IRUSR | stat.S_IWUSR - elif not (member.isdir() or member.issym()): - # Block special files. Directories and symlinks keep - # their masked-original mode — passing None here would - # crash tarfile.extract on Python <3.12 (its chmod - # path calls os.chmod unconditionally). - continue - - member.mode = mode - - # 7. Strip ownership - member.uid = None - member.gid = None - member.uname = None - member.gname = None - - # 8. Assign sanitized name back member.name = name + # Hard-link linknames reference another archive member by its + # archive name; strip the wrapper prefix here too so + # tarfile._find_link_target can resolve the target during + # extraction. Symlink linknames are filesystem-relative paths, + # not archive-member references, so they don't need this. + if member.islnk() and strip_prefix is not None: + norm_link = member.linkname.replace("\\", "/") + if norm_link in (strip_root, strip_prefix): + continue + if not norm_link.startswith(strip_prefix): + continue + member.linkname = norm_link[len(strip_prefix) :] + + # Delegate traversal, link, permission and ownership sanitization + # to the stdlib data filter; it raises FilterError for unsafe + # members (path traversal, links outside dest, special files). + try: + member = tarfile.data_filter(member, abs_dest) + except tarfile.FilterError: + continue + safe_members.append(member) total = len(safe_members) diff --git a/esphome/helpers.py b/esphome/helpers.py index 62dfd0fb09..631bcb6f39 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -397,17 +397,13 @@ def rmtree(path: Path | str) -> None: read-only flag and retrying. """ - def _onerror(func, path, exc_info): + def _onexc(func, path, exc): if os.access(path, os.W_OK): - raise exc_info[1].with_traceback(exc_info[2]) + raise exc Path(path).chmod(stat.S_IWUSR | stat.S_IRUSR) func(path) - # ``onerror`` is deprecated in 3.12 in favour of ``onexc`` (different - # callable signature); keep the existing handler shape for now and - # silence the lint locally so this PR doesn't bundle an unrelated - # migration. - shutil.rmtree(path, onerror=_onerror) # pylint: disable=deprecated-argument + shutil.rmtree(path, onexc=_onexc) def walk_files(path: Path): diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 43282c7aa0..c2d783ecbe 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -24,7 +24,7 @@ import os from pathlib import Path import re import tempfile -from typing import Any, TypeVar +from typing import Any from urllib.parse import urlparse, urlsplit, urlunsplit from esphome import git @@ -195,10 +195,7 @@ class LibraryBackend: emit: Callable[["ConvertedLibrary"], None] -T = TypeVar("T") - - -def ensure_list(obj: T | list[T]) -> list[T]: +def ensure_list[T](obj: T | list[T]) -> list[T]: """ Convert an object to a list if it isn't already a list. diff --git a/pyproject.toml b/pyproject.toml index a292377835..e959578553 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ "Topic :: Home Automation", ] -requires-python = ">=3.11.0,<3.15" +requires-python = ">=3.12.0,<3.15" dynamic = ["dependencies", "optional-dependencies", "version"] @@ -62,7 +62,7 @@ addopts = [ ] [tool.pylint.MAIN] -py-version = "3.11" +py-version = "3.12" ignore = [ "api_pb2.py", ] @@ -106,7 +106,7 @@ expected-line-ending-format = "LF" [tool.ruff] required-version = ">=0.5.0" -target-version = "py311" +target-version = "py312" exclude = ['generated'] [tool.ruff.lint] diff --git a/script/lint-python b/script/lint-python index e4b3314d2a..6bd95778fa 100755 --- a/script/lint-python +++ b/script/lint-python @@ -139,7 +139,7 @@ def main(): print() print("Running pyupgrade...") print() - PYUPGRADE_TARGET = "--py311-plus" + PYUPGRADE_TARGET = "--py312-plus" for files in filesets: cmd = ["pyupgrade", PYUPGRADE_TARGET] + files log = get_err(*cmd) diff --git a/tests/integration/state_utils.py b/tests/integration/state_utils.py index c8517aff09..65af57b944 100644 --- a/tests/integration/state_utils.py +++ b/tests/integration/state_utils.py @@ -19,7 +19,6 @@ from aioesphomeapi import ( _LOGGER = logging.getLogger(__name__) -T = TypeVar("T", bound=EntityInfo) S = TypeVar("S", bound=EntityState) @@ -58,7 +57,7 @@ async def wait_for_state( return await asyncio.wait_for(future, timeout=timeout) -def find_entity( +def find_entity[T: EntityInfo]( entities: list[EntityInfo], object_id_substring: str, entity_type: type[T] | None = None, @@ -86,7 +85,7 @@ def find_entity( return None -def require_entity( +def require_entity[T: EntityInfo]( entities: list[EntityInfo], object_id_substring: str, entity_type: type[T] | None = None, diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index fd807ed05d..6fe62dcc8c 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -658,11 +658,6 @@ def test_get_python_env_executable_path_nt() -> None: class TestTarExtractAllBranches: - @pytest.mark.skipif( - sys.version_info < (3, 12), - reason="patching os.name makes pathlib build a WindowsPath, which only " - "instantiates on POSIX in 3.12+", - ) def test_windows_drive_path_skipped(self, tmp_path: Path) -> None: """Windows-style drive path (C:/...) is skipped when os.name == 'nt'.""" info = tarfile.TarInfo(name="C:/secret.txt") @@ -755,11 +750,6 @@ class TestTarExtractAllBranches: class TestZipExtractAllBranches: - @pytest.mark.skipif( - sys.version_info < (3, 12), - reason="patching os.name makes pathlib build a WindowsPath, which only " - "instantiates on POSIX in 3.12+", - ) def test_windows_drive_path_skipped(self, tmp_path: Path) -> None: """Windows-style drive path (C:/...) is skipped when os.name == 'nt'.""" buf = _make_zip([("C:/secret.txt", "bad")]) From 136e343988cd12311d1b04e5a18d0bb0e7b82f00 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:13:11 -0400 Subject: [PATCH 0656/1815] [ethernet] Generic and YT8531 PHY over RGMII (gigabit) for ESP32-S31 (#17277) --- esphome/components/ethernet/__init__.py | 55 ++++++++ .../components/ethernet/ethernet_component.h | 7 ++ .../ethernet/ethernet_component_esp32.cpp | 117 +++++++++++++++++- esphome/core/defines.h | 2 + 4 files changed, 177 insertions(+), 4 deletions(-) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 6af68e4e3c..8f927cf3e9 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -126,6 +126,8 @@ ETHERNET_TYPES = { "ENC28J60": EthernetType.ETHERNET_TYPE_ENC28J60, "W6100": EthernetType.ETHERNET_TYPE_W6100, "W6300": EthernetType.ETHERNET_TYPE_W6300, + "GENERIC": EthernetType.ETHERNET_TYPE_GENERIC, + "YT8531": EthernetType.ETHERNET_TYPE_YT8531, } # PHY types that need compile-time defines for conditional compilation @@ -145,6 +147,8 @@ _PHY_TYPE_TO_DEFINE = { "ENC28J60": "USE_ETHERNET_ENC28J60", "W6100": "USE_ETHERNET_W6100", "W6300": "USE_ETHERNET_W6300", + "GENERIC": "USE_ETHERNET_GENERIC", + "YT8531": "USE_ETHERNET_YT8531", } @@ -309,6 +313,24 @@ def _validate(config): f"({CORE.target_framework} {CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]}), " f"'{CONF_INTERRUPT_PIN}' is a required option for [ethernet]." ) + elif config[CONF_TYPE] in ("GENERIC", "YT8531"): + from esphome.components.esp32 import ( + VARIANT_ESP32S31, + get_esp32_variant, + idf_version, + ) + + eth_type = config[CONF_TYPE] + variant = get_esp32_variant() + if variant != VARIANT_ESP32S31: + raise cv.Invalid( + f"The '{eth_type}' (RGMII) PHY is only supported on gigabit-capable " + f"variants (ESP32-S31), not {variant}" + ) + if idf_version() < cv.Version(6, 0, 0): + raise cv.Invalid( + f"The '{eth_type}' (RGMII) PHY requires ESP-IDF 6.0 or newer." + ) elif config[CONF_TYPE] != "OPENETH": from esphome.components.esp32 import ( VARIANT_ESP32, @@ -392,6 +414,23 @@ RMII_SCHEMA = cv.All( cv.only_on([Platform.ESP32]), ) +# Generic IEEE 802.3 PHY over the internal EMAC RGMII interface (e.g. ESP32-S31). +# RGMII data pins come from the IDF per-target default config. +GENERIC_SCHEMA = cv.All( + BASE_SCHEMA.extend( + cv.Schema( + { + cv.Required(CONF_MDC_PIN): pins.internal_gpio_output_pin_number, + cv.Required(CONF_MDIO_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_PHY_ADDR, default=0): cv.int_range(min=0, max=31), + cv.Optional(CONF_POWER_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_PHY_REGISTERS): cv.ensure_list(PHY_REGISTER_SCHEMA), + } + ) + ), + cv.only_on([Platform.ESP32]), +) + SPI_SCHEMA = cv.All( BASE_SCHEMA.extend( cv.Schema( @@ -442,6 +481,8 @@ CONFIG_SCHEMA = cv.All( "W6100": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2040])), "W6300": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2040])), "LAN8670": RMII_SCHEMA, + "GENERIC": GENERIC_SCHEMA, + "YT8531": GENERIC_SCHEMA, }, upper=True, ), @@ -571,6 +612,20 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: elif config[CONF_TYPE] == "OPENETH": cg.add_define("USE_ETHERNET_OPENETH") add_idf_sdkconfig_option("CONFIG_ETH_USE_OPENETH", True) + elif config[CONF_TYPE] in ("GENERIC", "YT8531"): + # RGMII data pins come from the IDF default config; set MDC/MDIO + PHY addr. + cg.add(var.set_phy_addr(config[CONF_PHY_ADDR])) + cg.add(var.set_mdc_pin(config[CONF_MDC_PIN])) + cg.add(var.set_mdio_pin(config[CONF_MDIO_PIN])) + if CONF_POWER_PIN in config: + cg.add(var.set_power_pin(config[CONF_POWER_PIN])) + for register_value in config.get(CONF_PHY_REGISTERS, []): + reg = phy_register( + register_value.get(CONF_ADDRESS), + register_value.get(CONF_VALUE), + register_value.get(CONF_PAGE_ID), + ) + cg.add(var.add_phy_register(reg)) else: cg.add(var.set_phy_addr(config[CONF_PHY_ADDR])) cg.add(var.set_mdc_pin(config[CONF_MDC_PIN])) diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 7d06377f90..e0fe920ea1 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -86,6 +86,8 @@ enum EthernetType : uint8_t { ETHERNET_TYPE_ENC28J60, ETHERNET_TYPE_W6100, ETHERNET_TYPE_W6300, + ETHERNET_TYPE_GENERIC, + ETHERNET_TYPE_YT8531, }; struct ManualIP { @@ -229,6 +231,11 @@ class EthernetComponent final : public Component { #ifdef USE_ETHERNET_KSZ8081 /// @brief Set `RMII Reference Clock Select` bit for KSZ8081. void ksz8081_set_clock_reference_(esp_eth_mac_t *mac); +#endif +#ifdef USE_ETHERNET_YT8531 + /// @brief Apply YT8531-specific config: re-enable auto-negotiation (disabled on + /// reset) and set the RGMII Tx/Rx clock delays needed for reliable data sampling. + void yt8531_phy_init_(); #endif /// @brief Set arbitratry PHY registers from config. void write_phy_register_(esp_eth_mac_t *mac, PHYRegister register_data); diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 544ec79c32..7a1bcae42f 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -254,9 +254,14 @@ void EthernetComponent::ethernet_lazy_init_() { esp32_emac_config.smi_mdc_gpio_num = this->mdc_pin_; esp32_emac_config.smi_mdio_gpio_num = this->mdio_pin_; #endif - esp32_emac_config.clock_config.rmii.clock_mode = this->clk_mode_; - esp32_emac_config.clock_config.rmii.clock_gpio = - static_cast(this->clk_pin_); + // The RGMII types (GENERIC, YT8531) use the RGMII interface and default GPIO map from + // eth_esp32_emac_default_config(); writing the RMII clock config would clobber that + // union, so skip the RMII clock override for them. + if (this->type_ != ETHERNET_TYPE_GENERIC && this->type_ != ETHERNET_TYPE_YT8531) { + esp32_emac_config.clock_config.rmii.clock_mode = this->clk_mode_; + esp32_emac_config.clock_config.rmii.clock_gpio = + static_cast(this->clk_pin_); + } esp_eth_mac_t *mac = esp_eth_mac_new_esp32(&esp32_emac_config, &mac_config); #endif @@ -319,6 +324,20 @@ void EthernetComponent::ethernet_lazy_init_() { break; } #endif +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + // GENERIC and YT8531 both use the built-in generic 802.3 PHY driver; YT8531 gets + // extra chip-specific tuning applied later in ethernet_lazy_init_(). +#ifdef USE_ETHERNET_GENERIC + case ETHERNET_TYPE_GENERIC: +#endif +#ifdef USE_ETHERNET_YT8531 + case ETHERNET_TYPE_YT8531: +#endif +#if defined(USE_ETHERNET_GENERIC) || defined(USE_ETHERNET_YT8531) + this->phy_ = esp_eth_phy_new_generic(&phy_config); + break; +#endif +#endif #endif #ifdef USE_ETHERNET_SPI #if defined(USE_ETHERNET_W5500) @@ -363,7 +382,30 @@ void EthernetComponent::ethernet_lazy_init_() { for (const auto &phy_register : this->phy_registers_) { this->write_phy_register_(mac, phy_register); } + +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +#ifdef USE_ETHERNET_GENERIC + // The generic 802.3 PHY driver only resets the PHY in its init; it never enables + // auto-negotiation. A PHY that resets into a forced-speed mode (BMCR auto-nego bit + // clear) therefore stays there, and esp_eth_start() skips negotiation because the + // driver cached auto_nego_en=false at install time. Force auto-negotiation on here + // (which also updates that cached state) so esp_eth_start() restarts a proper + // negotiation. (YT8531 does this as part of its own chip-specific init below.) + if (this->type_ == ETHERNET_TYPE_GENERIC) { + bool autoneg_enable = true; + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_S_AUTONEGO, &autoneg_enable); + ESPHL_ERROR_CHECK(err, "Enable auto-negotiation failed"); + } #endif +#ifdef USE_ETHERNET_YT8531 + if (this->type_ == ETHERNET_TYPE_YT8531) { + this->yt8531_phy_init_(); + if (this->is_failed()) + return; + } +#endif +#endif // ESP_IDF_VERSION >= 6.0.0 +#endif // !USE_ETHERNET_SPI // use ESP internal eth mac uint8_t mac_addr[6]; @@ -486,6 +528,16 @@ void EthernetComponent::dump_config() { eth_type = "LAN8670"; break; #endif +#ifdef USE_ETHERNET_GENERIC + case ETHERNET_TYPE_GENERIC: + eth_type = "Generic (RGMII)"; + break; +#endif +#ifdef USE_ETHERNET_YT8531 + case ETHERNET_TYPE_YT8531: + eth_type = "YT8531 (RGMII)"; + break; +#endif default: eth_type = "Unknown"; @@ -782,6 +834,19 @@ void EthernetComponent::dump_connect_params_() { char dns1_buf[network::IP_ADDRESS_BUFFER_SIZE]; char dns2_buf[network::IP_ADDRESS_BUFFER_SIZE]; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + uint16_t link_speed = 10; + switch (this->get_link_speed()) { + case ETH_SPEED_100M: + link_speed = 100; + break; +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + case ETH_SPEED_1000M: + link_speed = 1000; + break; +#endif + default: + break; + } ESP_LOGCONFIG(TAG, " IP Address: %s\n" " Hostname: '%s'\n" @@ -796,7 +861,7 @@ void EthernetComponent::dump_connect_params_() { network::IPAddress(&ip.netmask).str_to(subnet_buf), network::IPAddress(&ip.gw).str_to(gateway_buf), network::IPAddress(dns_ip1).str_to(dns1_buf), network::IPAddress(dns_ip2).str_to(dns2_buf), this->get_eth_mac_address_pretty_into_buffer(mac_buf), - YESNO(this->get_duplex_mode() == ETH_DUPLEX_FULL), this->get_link_speed() == ETH_SPEED_100M ? 100 : 10); + YESNO(this->get_duplex_mode() == ETH_DUPLEX_FULL), link_speed); #if USE_NETWORK_IPV6 struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; @@ -958,6 +1023,50 @@ void EthernetComponent::write_phy_register_(esp_eth_mac_t *mac, PHYRegister regi #endif } +#ifdef USE_ETHERNET_YT8531 +void EthernetComponent::yt8531_phy_init_() { + esp_err_t err; + + // The YT8531 disables auto-negotiation on hardware reset (undocumented behavior), and the + // generic 802.3 driver only resets the PHY, so re-enable it (this also updates the driver's + // cached auto-nego state used by esp_eth_start()). + bool autoneg_enable = true; + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_S_AUTONEGO, &autoneg_enable); + ESPHL_ERROR_CHECK(err, "YT8531 enable auto-negotiation failed"); + + // RGMII needs ~2 ns Tx and Rx clock delays for reliable data sampling. These are set through + // the YT8531 extended-register interface: write the ext-register address to 0x1E, then + // read/modify/write its value via 0x1F. + esp_eth_phy_reg_rw_data_t phy_reg; + uint32_t reg_val; + phy_reg.reg_value_p = ®_val; + + // RX ~2 ns coarse delay: EXT_CHIP_CONFIG (0xA001), set rxc_dly_en (bit 8). + reg_val = 0xA001; + phy_reg.reg_addr = 0x1E; + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_WRITE_PHY_REG, &phy_reg); + ESPHL_ERROR_CHECK(err, "YT8531 select Chip_Config failed"); + phy_reg.reg_addr = 0x1F; + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_READ_PHY_REG, &phy_reg); + ESPHL_ERROR_CHECK(err, "YT8531 read Chip_Config failed"); + reg_val |= (1U << 8); + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_WRITE_PHY_REG, &phy_reg); + ESPHL_ERROR_CHECK(err, "YT8531 write Chip_Config failed"); + + // TX ~2 ns delay: EXT_RGMII_CONFIG1 (0xA003), tx_delay_sel[3:0] and tx_delay_sel_fe[7:4] = 13. + reg_val = 0xA003; + phy_reg.reg_addr = 0x1E; + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_WRITE_PHY_REG, &phy_reg); + ESPHL_ERROR_CHECK(err, "YT8531 select RGMII_Config1 failed"); + phy_reg.reg_addr = 0x1F; + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_READ_PHY_REG, &phy_reg); + ESPHL_ERROR_CHECK(err, "YT8531 read RGMII_Config1 failed"); + reg_val = (reg_val & ~0x00FFU) | (13U << 4) | (13U << 0); + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_WRITE_PHY_REG, &phy_reg); + ESPHL_ERROR_CHECK(err, "YT8531 write RGMII_Config1 failed"); +} +#endif + #endif } // namespace esphome::ethernet diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 17b5e64862..1c0138f9d1 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -327,6 +327,8 @@ #define USE_ETHERNET_JL1101 #define USE_ETHERNET_KSZ8081 #define USE_ETHERNET_LAN8670 +#define USE_ETHERNET_GENERIC +#define USE_ETHERNET_YT8531 #define USE_ETHERNET_SPI #define USE_ETHERNET_SPI_POLLING_SUPPORT #define USE_ETHERNET_OPENETH From 8780c7e0ac26251d213ccf7232ab95b317f8f6c3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:01:41 -0400 Subject: [PATCH 0657/1815] Bump awalsh128/cache-apt-pkgs-action from 1.6.0 to 1.6.2 (#17286) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 751241f563..73c93bc336 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -820,7 +820,7 @@ jobs: run: echo ${{ matrix.components }} - name: Cache apt packages - uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3 + uses: awalsh128/cache-apt-pkgs-action@5513791f75b039e2a79653b1a92238d3fb8d99b4 # v1.6.2 with: packages: libsdl2-dev ccache version: 1.1 From 797ed237655ec03165789d068ea630c7095ee617 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:02:11 -0400 Subject: [PATCH 0658/1815] Bump tzlocal from 5.4.3 to 5.4.4 (#17283) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 956f3633dc..9f485db38e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 -tzlocal==5.4.3 # from time +tzlocal==5.4.4 # from time tzdata>=2026.2 # from time pyserial==3.5 platformio==6.1.19 From 405607e9d29d408bc35e766450df679130c110c1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:06:36 -0400 Subject: [PATCH 0659/1815] Bump esptool from 5.3.0 to 5.3.1 (#17284) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9f485db38e..d39d52caf4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ tzlocal==5.4.4 # from time tzdata>=2026.2 # from time pyserial==3.5 platformio==6.1.19 -esptool==5.3.0 +esptool==5.3.1 click==8.3.3 aioesphomeapi==45.5.2 zeroconf==0.150.0 From e308075e3fb027db56c39cdb61f2ab9499ce5120 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:12:23 -0400 Subject: [PATCH 0660/1815] Bump puremagic from 1.30 to 2.2.0 (#17285) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d39d52caf4..85f4b56c07 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ esptool==5.3.1 click==8.3.3 aioesphomeapi==45.5.2 zeroconf==0.150.0 -puremagic==1.30 +puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 From 93eb6f78e0e651b5ff8ab54cdc86ebb043deb2a6 Mon Sep 17 00:00:00 2001 From: Ardumine <61353807+Ardumine@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:32:57 +0100 Subject: [PATCH 0661/1815] [network] Enlarge Zephyr net buffer pool and TCP windows on nRF52/Zephyr plataform (#17278) --- esphome/components/network/__init__.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 846c3afc59..d2683e4bba 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -227,6 +227,21 @@ async def to_code(config): # TCP links; Zephyr falls back to sys_rand32_get() for the ISN (randomized, but not the # RFC 6528 keyed hash). zephyr_add_prj_conf("NET_TCP_ISN_RFC6528", False) + # Enlarge the Zephyr network buffer pool and TCP windows for the Thread path. + # Zephyr's defaults are tiny: NET_BUF_TX_COUNT=16 * NET_BUF_DATA_SIZE=128 is only + # ~2 KB of TX data -- barely one 1280-byte IPv6 packet once 6LoWPAN fragments it. + # The ESPHome API entity-sync burst overruns that instantly, so socket writes fail + # with ENOBUFS ("Buffer full") and the connection is dropped. ESP32 sidesteps this + # by enlarging the lwIP TCP window (CONFIG_LWIP_TCP_* above); give Zephyr the + # equivalent headroom, sized to RAM and the Thread 1280-byte MTU (not ESP32's 64 KB). + # The bounded send window also provides flow control so TCP stops queueing past + # what the buffer pool can hold instead of erroring. + zephyr_add_prj_conf("NET_PKT_RX_COUNT", 24) + zephyr_add_prj_conf("NET_PKT_TX_COUNT", 24) + zephyr_add_prj_conf("NET_BUF_RX_COUNT", 48) + zephyr_add_prj_conf("NET_BUF_TX_COUNT", 48) + zephyr_add_prj_conf("NET_TCP_MAX_RECV_WINDOW_SIZE", 2280) + zephyr_add_prj_conf("NET_TCP_MAX_SEND_WINDOW_SIZE", 2280) if (enable_ipv6 := config.get(CONF_ENABLE_IPV6, None)) is not None: cg.add_define("USE_NETWORK_IPV6", enable_ipv6) From b36e20d60b20777daed1fd7a256e0d1027dd01a3 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:25:16 -0400 Subject: [PATCH 0662/1815] Revert "Bump awalsh128/cache-apt-pkgs-action from 1.6.0 to 1.6.2 (#17286)" (#17289) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73c93bc336..4ac55aa006 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -820,7 +820,7 @@ jobs: run: echo ${{ matrix.components }} - name: Cache apt packages - uses: awalsh128/cache-apt-pkgs-action@5513791f75b039e2a79653b1a92238d3fb8d99b4 # v1.6.2 + uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0 with: packages: libsdl2-dev ccache version: 1.1 From 1611345c5520818b09738c6a1f900bb5a2867054 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 30 Jun 2026 08:37:17 +1000 Subject: [PATCH 0663/1815] [agents] Add English language AI guidelines for documentation (#17290) --- AGENTS.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 46caea3aec..9a01626ee4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -709,3 +709,9 @@ This document provides essential context for AI models interacting with this pro _LOGGER.warning(f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0") config[CONF_NEW_KEY] = config.pop(CONF_OLD_KEY) # Auto-migrate ``` +## 9. English Language + +The project uses English for non-code content. When drafting documentation, code comments, commit messages, +PR descriptions, and similar text, avoid technical jargon. Instead, express concepts in plain English, +using standard technical terms only when required. Ensure the text is readily comprehensible to a wide +audience, including non-native English speakers. From 5c7245dfcd5766cb737f28878ab2a7c857cc2ecd Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:04:25 +1000 Subject: [PATCH 0664/1815] [qmi8658] Motion platform for QMI8658 IMU (#16889) --- CODEOWNERS | 1 + esphome/components/qmi8658/__init__.py | 13 ++ esphome/components/qmi8658/motion.py | 93 ++++++++++++ esphome/components/qmi8658/qmi8658.cpp | 136 ++++++++++++++++++ esphome/components/qmi8658/qmi8658.h | 112 +++++++++++++++ esphome/components/qmi8658/sensor.py | 39 +++++ tests/components/qmi8658/common.yaml | 69 +++++++++ tests/components/qmi8658/test.esp32-idf.yaml | 4 + .../components/qmi8658/test.esp8266-ard.yaml | 4 + 9 files changed, 471 insertions(+) create mode 100644 esphome/components/qmi8658/__init__.py create mode 100644 esphome/components/qmi8658/motion.py create mode 100644 esphome/components/qmi8658/qmi8658.cpp create mode 100644 esphome/components/qmi8658/qmi8658.h create mode 100644 esphome/components/qmi8658/sensor.py create mode 100644 tests/components/qmi8658/common.yaml create mode 100644 tests/components/qmi8658/test.esp32-idf.yaml create mode 100644 tests/components/qmi8658/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 21121ff476..8fc7d4a0a7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -405,6 +405,7 @@ esphome/components/psram/* @esphome/core esphome/components/pulse_meter/* @cstaahl @stevebaxter @TrentHouliston esphome/components/pvvx_mithermometer/* @pasiz esphome/components/pylontech/* @functionpointer +esphome/components/qmi8658/* @clydebarrow esphome/components/qmp6988/* @andrewpc esphome/components/qr_code/* @wjtje esphome/components/qspi_dbi/* @clydebarrow diff --git a/esphome/components/qmi8658/__init__.py b/esphome/components/qmi8658/__init__.py new file mode 100644 index 0000000000..67838dbc3c --- /dev/null +++ b/esphome/components/qmi8658/__init__.py @@ -0,0 +1,13 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.motion import MotionComponent + +CODEOWNERS = ["@clydebarrow"] +DEPENDENCIES = ["i2c", "motion"] + +CONF_QMI8658_ID = "qmi8658_id" +# C++ namespace / class +qmi8658_ns = cg.esphome_ns.namespace("qmi8658") +QMI8658Component = qmi8658_ns.class_("QMI8658Component", MotionComponent, i2c.I2CDevice) + +CONFIG_SCHEMA = {} diff --git a/esphome/components/qmi8658/motion.py b/esphome/components/qmi8658/motion.py new file mode 100644 index 0000000000..26169189c2 --- /dev/null +++ b/esphome/components/qmi8658/motion.py @@ -0,0 +1,93 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.const import ( + CONF_ACCELEROMETER_ODR, + CONF_ACCELEROMETER_RANGE, + CONF_GYROSCOPE_ODR, + CONF_GYROSCOPE_RANGE, +) +from esphome.components.motion import motion_schema, new_motion_component +import esphome.config_validation as cv + +from . import QMI8658Component, qmi8658_ns + +# Enum proxies (must match the C++ enum values exactly) +QMI8658AccelRange = qmi8658_ns.enum("QMI8658AccelRange") +ACCEL_RANGE_OPTIONS = { + "2G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_2G, + "4G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_4G, + "8G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_8G, + "16G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_16G, +} + +QMI8658GyroRange = qmi8658_ns.enum("QMI8658GyroRange") +GYRO_RANGE_OPTIONS = { + "16DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_16, + "32DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_32, + "64DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_64, + "128DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_128, + "256DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_256, + "512DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_512, + "1024DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_1024, + "2048DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_2048, +} + +QMI8658AccelODR = qmi8658_ns.enum("QMI8658AccelODR") +ACCEL_ODR_OPTIONS = { + "31_25HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_31_25, + "62_5HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_62_5, + "125HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_125, + "250HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_250, + "500HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_500, + "1000HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_1000, + "2000HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_2000, + "4000HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_4000, + "8000HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_8000, +} + +QMI8658GyroODR = qmi8658_ns.enum("QMI8658GyroODR") +GYRO_ODR_OPTIONS = { + "31_25HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_31_25, + "62_5HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_62_5, + "125HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_125, + "250HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_250, + "500HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_500, + "1000HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_1000, + "2000HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_2000, + "4000HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_4000, + "8000HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_8000, +} + +# Top-level CONFIG_SCHEMA +CONFIG_SCHEMA = ( + motion_schema(QMI8658Component, has_accel=True, has_gyro=True) + .extend( + { + cv.Optional(CONF_ACCELEROMETER_RANGE, default="4G"): cv.enum( + ACCEL_RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_ACCELEROMETER_ODR, default="1000HZ"): cv.enum( + ACCEL_ODR_OPTIONS, upper=True + ), + cv.Optional(CONF_GYROSCOPE_RANGE, default="2048DPS"): cv.enum( + GYRO_RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_GYROSCOPE_ODR, default="1000HZ"): cv.enum( + GYRO_ODR_OPTIONS, upper=True + ), + } + ) + .extend(i2c.i2c_device_schema(0x6B)) +) + + +# Code generation +async def to_code(config): + var = await new_motion_component(config) + await i2c.register_i2c_device(var, config) + + # Hardware configuration + cg.add(var.set_accel_range(config[CONF_ACCELEROMETER_RANGE])) + cg.add(var.set_accel_odr(config[CONF_ACCELEROMETER_ODR])) + cg.add(var.set_gyro_range(config[CONF_GYROSCOPE_RANGE])) + cg.add(var.set_gyro_odr(config[CONF_GYROSCOPE_ODR])) diff --git a/esphome/components/qmi8658/qmi8658.cpp b/esphome/components/qmi8658/qmi8658.cpp new file mode 100644 index 0000000000..2fd457d290 --- /dev/null +++ b/esphome/components/qmi8658/qmi8658.cpp @@ -0,0 +1,136 @@ +#include "qmi8658.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" + +namespace esphome::qmi8658 { + +static const char *const TAG = "qmi8658"; + +// Acceleration scale (g per LSB), indexed by accel_range_ >> 4. +// Full-scale = range_g, mapped over a signed 16-bit value (2^15 counts). +static constexpr float ACCEL_SCALE[] = { + 2.0f / 32768.0f, + 4.0f / 32768.0f, + 8.0f / 32768.0f, + 16.0f / 32768.0f, +}; + +// Angular rate scale (°/s per LSB), indexed by gyro_range_ >> 4. +static constexpr float GYRO_SCALE[] = { + 16.0f / 32768.0f, 32.0f / 32768.0f, 64.0f / 32768.0f, 128.0f / 32768.0f, + 256.0f / 32768.0f, 512.0f / 32768.0f, 1024.0f / 32768.0f, 2048.0f / 32768.0f, +}; + +void QMI8658Component::setup() { + MotionComponent::setup(); + + // 1. Verify chip ID + uint8_t who_am_i = 0; + if (!this->read_byte(QMI8658_REG_WHO_AM_I, &who_am_i)) { + ESP_LOGE(TAG, "Failed to read chip ID - check wiring / address"); + this->mark_failed(); + return; + } + if (who_am_i != QMI8658_WHO_AM_I_VALUE) { + ESP_LOGE(TAG, "Wrong chip ID: 0x%02X (expected 0x%02X)", who_am_i, QMI8658_WHO_AM_I_VALUE); + this->mark_failed(); + return; + } + + // 2. Soft reset + if (!this->write_byte(QMI8658_REG_RESET, QMI8658_RESET_CMD)) { + this->mark_failed(); + return; + } + delay(15); // spec: wait for reset to complete + + // 3. Serial interface: enable register address auto-increment + if (!this->write_byte(QMI8658_REG_CTRL1, QMI8658_CTRL1_VALUE)) { + this->mark_failed(LOG_STR("Failed to write REG_CTRL1")); + return; + } + + // 4. Configure accelerometer (CTRL2 = range | ODR) + if (!this->write_byte(QMI8658_REG_CTRL2, (uint8_t) (this->accel_range_) | (uint8_t) (this->accel_odr_))) { + this->mark_failed(LOG_STR("Failed to write REG_CTRL2")); + return; + } + + // 5. Configure gyroscope (CTRL3 = range | ODR) + if (!this->write_byte(QMI8658_REG_CTRL3, (uint8_t) (this->gyro_range_) | (uint8_t) (this->gyro_odr_))) { + this->mark_failed(LOG_STR("Failed to write REG_CTRL3")); + return; + } + + // 6. Disable the built-in low-pass filters (leave raw data to the motion pipeline) + if (!this->write_byte(QMI8658_REG_CTRL5, 0x00)) { + this->mark_failed(LOG_STR("Failed to write REG_CTRL5")); + this->mark_failed(); + return; + } + + // 7. Enable accelerometer and gyroscope + if (!this->write_byte(QMI8658_REG_CTRL7, QMI8658_CTRL7_ACC_EN | QMI8658_CTRL7_GYR_EN)) { + this->mark_failed(LOG_STR("Failed to write REG_CTRL7")); + return; + } + + ESP_LOGCONFIG(TAG, "QMI8658 initialised successfully"); +} + +void QMI8658Component::dump_config() { + ESP_LOGCONFIG(TAG, "QMI8658 IMU:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, " Communication failed!"); + return; + } + + static constexpr const char *const ACCEL_RANGE_STRS[] = {"±2g", "±4g", "±8g", "±16g"}; + static constexpr const char *const GYRO_RANGE_STRS[] = {"±16°/s", "±32°/s", "±64°/s", "±128°/s", + "±256°/s", "±512°/s", "±1024°/s", "±2048°/s"}; + + ESP_LOGCONFIG(TAG, " Accel range : %s", ACCEL_RANGE_STRS[this->accel_range_ >> 4]); + ESP_LOGCONFIG(TAG, " Gyro range : %s", GYRO_RANGE_STRS[this->gyro_range_ >> 4]); + MotionComponent::dump_config(); +} + +bool QMI8658Component::update_data(motion::MotionData &data) { + if (this->is_failed()) + return false; + + // Read temperature + accel + gyro in one contiguous block starting at TEMP_L. + uint8_t raw_data[REG_READ_LEN]; + if (!this->read_bytes(QMI8658_REG_TEMP_L, raw_data, REG_READ_LEN)) { + ESP_LOGW(TAG, "Failed to read IMU data"); + return false; + } + + // Data is little-endian (low byte first). + float scale = ACCEL_SCALE[this->accel_range_ >> 4]; + int16_t raw_x = encode_uint16(raw_data[ACC_OFFS + 1], raw_data[ACC_OFFS + 0]); + int16_t raw_y = encode_uint16(raw_data[ACC_OFFS + 3], raw_data[ACC_OFFS + 2]); + int16_t raw_z = encode_uint16(raw_data[ACC_OFFS + 5], raw_data[ACC_OFFS + 4]); + ESP_LOGV(TAG, "Read raw accel data: %d, %d, %d", raw_x, raw_y, raw_z); + data.acceleration[motion::X_AXIS] = raw_x * scale; + data.acceleration[motion::Y_AXIS] = raw_y * scale; + data.acceleration[motion::Z_AXIS] = raw_z * scale; + + scale = GYRO_SCALE[this->gyro_range_ >> 4]; + raw_x = encode_uint16(raw_data[GYR_OFFS + 1], raw_data[GYR_OFFS + 0]); + raw_y = encode_uint16(raw_data[GYR_OFFS + 3], raw_data[GYR_OFFS + 2]); + raw_z = encode_uint16(raw_data[GYR_OFFS + 5], raw_data[GYR_OFFS + 4]); + ESP_LOGV(TAG, "Read raw gyro data: %d, %d, %d", raw_x, raw_y, raw_z); + data.angular_rate[motion::X_AXIS] = raw_x * scale; + data.angular_rate[motion::Y_AXIS] = raw_y * scale; + data.angular_rate[motion::Z_AXIS] = raw_z * scale; + + if (this->temperature_callback_.empty()) + return true; + // Temperature: signed 16-bit, °C = raw / 256 + int16_t raw_t = (int16_t) ((raw_data[TEMP_OFFS + 1] << 8) | raw_data[TEMP_OFFS + 0]); + this->temperature_callback_.call(raw_t / 256.0f); + return true; +} + +} // namespace esphome::qmi8658 diff --git a/esphome/components/qmi8658/qmi8658.h b/esphome/components/qmi8658/qmi8658.h new file mode 100644 index 0000000000..ce31a2b7a9 --- /dev/null +++ b/esphome/components/qmi8658/qmi8658.h @@ -0,0 +1,112 @@ +#pragma once + +#include "esphome/components/motion/motion_component.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::qmi8658 { + +// Register map +static constexpr uint8_t QMI8658_REG_WHO_AM_I = 0x00; +static constexpr uint8_t QMI8658_REG_REVISION = 0x01; +static constexpr uint8_t QMI8658_REG_CTRL1 = 0x02; // serial interface / auto-increment +static constexpr uint8_t QMI8658_REG_CTRL2 = 0x03; // accelerometer ODR / range +static constexpr uint8_t QMI8658_REG_CTRL3 = 0x04; // gyroscope ODR / range +static constexpr uint8_t QMI8658_REG_CTRL5 = 0x06; // low-pass filter +static constexpr uint8_t QMI8658_REG_CTRL7 = 0x08; // sensor enable +static constexpr uint8_t QMI8658_REG_STATUS0 = 0x2E; +static constexpr uint8_t QMI8658_REG_TEMP_BASE = 0x33; // start of the data block +static constexpr uint8_t QMI8658_REG_TEMP_L = 0x33; // Low byte of temperature +static constexpr uint8_t QMI8658_REG_AX_L = 0x35; +static constexpr uint8_t QMI8658_REG_GX_L = 0x3B; +static constexpr uint8_t QMI8658_REG_RESET = 0x60; + +// One contiguous read covers temperature (2) + accel (6) + gyro (6) starting at TEMP_L. +static constexpr uint8_t REG_READ_LEN = QMI8658_REG_GX_L + 6 - QMI8658_REG_TEMP_BASE; // 0x41 - 0x33 = 14 +static constexpr uint8_t TEMP_OFFS = QMI8658_REG_TEMP_L - QMI8658_REG_TEMP_BASE; // 0 +static constexpr uint8_t ACC_OFFS = QMI8658_REG_AX_L - QMI8658_REG_TEMP_BASE; // 2 +static constexpr uint8_t GYR_OFFS = QMI8658_REG_GX_L - QMI8658_REG_TEMP_BASE; // 8 + +static constexpr uint8_t QMI8658_WHO_AM_I_VALUE = 0x05; +static constexpr uint8_t QMI8658_RESET_CMD = 0xB0; +// CTRL1: bit6 ADDR_AI (register address auto-increment); little-endian, 4-wire SPI +static constexpr uint8_t QMI8658_CTRL1_VALUE = 0x40; +// CTRL7: aEN (bit0) | gEN (bit1) +static constexpr uint8_t QMI8658_CTRL7_ACC_EN = 0x01; +static constexpr uint8_t QMI8658_CTRL7_GYR_EN = 0x02; + +// Accelerometer range options (CTRL2 bits 6:4) +enum QMI8658AccelRange : uint8_t { + QMI8658_ACCEL_RANGE_2G = 0x00, + QMI8658_ACCEL_RANGE_4G = 0x10, + QMI8658_ACCEL_RANGE_8G = 0x20, + QMI8658_ACCEL_RANGE_16G = 0x30, +}; + +// Accelerometer ODR options (CTRL2 bits 3:0) +enum QMI8658AccelODR : uint8_t { + QMI8658_ACCEL_ODR_8000 = 0x00, + QMI8658_ACCEL_ODR_4000 = 0x01, + QMI8658_ACCEL_ODR_2000 = 0x02, + QMI8658_ACCEL_ODR_1000 = 0x03, + QMI8658_ACCEL_ODR_500 = 0x04, + QMI8658_ACCEL_ODR_250 = 0x05, + QMI8658_ACCEL_ODR_125 = 0x06, + QMI8658_ACCEL_ODR_62_5 = 0x07, + QMI8658_ACCEL_ODR_31_25 = 0x08, +}; + +// Gyroscope range options (CTRL3 bits 6:4) +enum QMI8658GyroRange : uint8_t { + QMI8658_GYRO_RANGE_16 = 0x00, + QMI8658_GYRO_RANGE_32 = 0x10, + QMI8658_GYRO_RANGE_64 = 0x20, + QMI8658_GYRO_RANGE_128 = 0x30, + QMI8658_GYRO_RANGE_256 = 0x40, + QMI8658_GYRO_RANGE_512 = 0x50, + QMI8658_GYRO_RANGE_1024 = 0x60, + QMI8658_GYRO_RANGE_2048 = 0x70, +}; + +// Gyroscope ODR options (CTRL3 bits 3:0) +enum QMI8658GyroODR : uint8_t { + QMI8658_GYRO_ODR_8000 = 0x00, + QMI8658_GYRO_ODR_4000 = 0x01, + QMI8658_GYRO_ODR_2000 = 0x02, + QMI8658_GYRO_ODR_1000 = 0x03, + QMI8658_GYRO_ODR_500 = 0x04, + QMI8658_GYRO_ODR_250 = 0x05, + QMI8658_GYRO_ODR_125 = 0x06, + QMI8658_GYRO_ODR_62_5 = 0x07, + QMI8658_GYRO_ODR_31_25 = 0x08, +}; + +// Main component class +class QMI8658Component : public motion::MotionComponent, public i2c::I2CDevice { + public: + // Lifecycle + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::DATA; } + + // Configuration setters + void set_accel_range(QMI8658AccelRange r) { this->accel_range_ = r; } + void set_accel_odr(QMI8658AccelODR o) { this->accel_odr_ = o; } + void set_gyro_range(QMI8658GyroRange r) { this->gyro_range_ = r; } + void set_gyro_odr(QMI8658GyroODR o) { this->gyro_odr_ = o; } + template void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward(cb)); } + + protected: + bool update_data(motion::MotionData &data) override; + + // Config + QMI8658AccelRange accel_range_{QMI8658_ACCEL_RANGE_4G}; + QMI8658AccelODR accel_odr_{QMI8658_ACCEL_ODR_1000}; + QMI8658GyroRange gyro_range_{QMI8658_GYRO_RANGE_2048}; + QMI8658GyroODR gyro_odr_{QMI8658_GYRO_ODR_1000}; + + LazyCallbackManager temperature_callback_{}; +}; + +} // namespace esphome::qmi8658 diff --git a/esphome/components/qmi8658/sensor.py b/esphome/components/qmi8658/sensor.py new file mode 100644 index 0000000000..80b0512361 --- /dev/null +++ b/esphome/components/qmi8658/sensor.py @@ -0,0 +1,39 @@ +# YAML config keys +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_TEMPERATURE, + CONF_TYPE, + DEVICE_CLASS_TEMPERATURE, + ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) +from esphome.cpp_generator import MockObj + +from . import CONF_QMI8658_ID, QMI8658Component + +CONFIG_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_TEMPERATURE, +).extend( + { + cv.Optional(CONF_TYPE): cv.one_of(CONF_TEMPERATURE), + cv.GenerateID(CONF_QMI8658_ID): cv.use_id(QMI8658Component), + } +) + + +async def to_code(config): + var = await sensor.new_sensor(config) + parent = await cg.get_variable(config[CONF_QMI8658_ID]) + data = MockObj("data") + value_lambda = await cg.process_lambda( + var.publish_state(data), + [(cg.float_, str(data))], + ) + cg.add(parent.add_temperature_listener(value_lambda)) diff --git a/tests/components/qmi8658/common.yaml b/tests/components/qmi8658/common.yaml new file mode 100644 index 0000000000..cfb0f3e129 --- /dev/null +++ b/tests/components/qmi8658/common.yaml @@ -0,0 +1,69 @@ +sensor: + - platform: qmi8658 + name: "QMI8658 Temperature" + + - platform: motion + type: acceleration_x + name: "Accel X" + accuracy_decimals: 4 + filters: + - sliding_window_moving_average: + window_size: 4 + send_every: 1 + - platform: motion + type: acceleration_y + name: "Accel Y" + accuracy_decimals: 4 + - platform: motion + type: acceleration_z + name: "Accel Z" + accuracy_decimals: 4 + + # Gyroscope axes (unit: °/s) + - platform: motion + type: gyroscope_x + name: "Gyro X" + - platform: motion + type: gyroscope_y + name: "Gyro Y" + - platform: motion + type: gyroscope_z + name: "Gyro Z" + + - platform: motion + type: angular_rate_x + name: "Angular Rate X" + - platform: motion + type: angular_rate_y + name: "Angular Rate Y" + - platform: motion + type: angular_rate_z + name: "Angular Rate Z" + + - platform: motion + type: pitch + name: "Pitch" + - platform: motion + type: roll + name: "Roll" + +motion: + - platform: qmi8658 + # Accelerometer full-scale range: 2G | 4G | 8G | 16G + accelerometer_range: 4G + + # Accelerometer output data rate: 31_25HZ | 62_5HZ | 125HZ | 250HZ | + # 500HZ | 1000HZ | 2000HZ | 4000HZ | 8000HZ + accelerometer_odr: 1000HZ + + # Gyroscope full-scale range: 16DPS | 32DPS | 64DPS | 128DPS | + # 256DPS | 512DPS | 1024DPS | 2048DPS + gyroscope_range: 2048DPS + + # Gyroscope output data rate: 31_25HZ | 62_5HZ | 125HZ | 250HZ | + # 500HZ | 1000HZ | 2000HZ | 4000HZ | 8000HZ + gyroscope_odr: 1000HZ + axis_map: + x: y + y: x + z: -z diff --git a/tests/components/qmi8658/test.esp32-idf.yaml b/tests/components/qmi8658/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/qmi8658/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/qmi8658/test.esp8266-ard.yaml b/tests/components/qmi8658/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4a98b9388a --- /dev/null +++ b/tests/components/qmi8658/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml From 3e1a6b4e11c9783139a85a27bc900b4ec649d734 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:18:01 +1000 Subject: [PATCH 0665/1815] [cst9220] Add CST9220 and CST9217 touchscreen support (#16888) --- CODEOWNERS | 1 + esphome/components/cst9220/__init__.py | 6 + .../cst9220/touchscreen/__init__.py | 36 +++++ .../touchscreen/cst9220_touchscreen.cpp | 141 ++++++++++++++++++ .../cst9220/touchscreen/cst9220_touchscreen.h | 50 +++++++ tests/components/cst9220/common.yaml | 16 ++ tests/components/cst9220/test.esp32-idf.yaml | 12 ++ 7 files changed, 262 insertions(+) create mode 100644 esphome/components/cst9220/__init__.py create mode 100644 esphome/components/cst9220/touchscreen/__init__.py create mode 100644 esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp create mode 100644 esphome/components/cst9220/touchscreen/cst9220_touchscreen.h create mode 100644 tests/components/cst9220/common.yaml create mode 100644 tests/components/cst9220/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 8fc7d4a0a7..467b1b7326 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -123,6 +123,7 @@ esphome/components/cs5460a/* @balrog-kun esphome/components/cse7761/* @berfenger esphome/components/cst226/* @clydebarrow esphome/components/cst816/* @clydebarrow +esphome/components/cst9220/* @clydebarrow esphome/components/ct_clamp/* @jesserockz esphome/components/current_based/* @djwmarcx esphome/components/dac7678/* @NickB1 diff --git a/esphome/components/cst9220/__init__.py b/esphome/components/cst9220/__init__.py new file mode 100644 index 0000000000..f97c8944ef --- /dev/null +++ b/esphome/components/cst9220/__init__.py @@ -0,0 +1,6 @@ +import esphome.codegen as cg + +CODEOWNERS = ["@clydebarrow"] +DEPENDENCIES = ["i2c"] + +cst9220_ns = cg.esphome_ns.namespace("cst9220") diff --git a/esphome/components/cst9220/touchscreen/__init__.py b/esphome/components/cst9220/touchscreen/__init__.py new file mode 100644 index 0000000000..6d8fc5e2f6 --- /dev/null +++ b/esphome/components/cst9220/touchscreen/__init__.py @@ -0,0 +1,36 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c, touchscreen +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN + +from .. import cst9220_ns + +CST9220Touchscreen = cst9220_ns.class_( + "CST9220Touchscreen", + touchscreen.Touchscreen, + i2c.I2CDevice, +) + +CONFIG_SCHEMA = ( + touchscreen.touchscreen_schema("100ms") + .extend( + { + cv.GenerateID(): cv.declare_id(CST9220Touchscreen), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + } + ) + .extend(i2c.i2c_device_schema(0x5A)) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await touchscreen.register_touchscreen(var, config) + await i2c.register_i2c_device(var, config) + + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + if reset_pin := config.get(CONF_RESET_PIN): + cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) diff --git a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp new file mode 100644 index 0000000000..366b1846d7 --- /dev/null +++ b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp @@ -0,0 +1,141 @@ +#include "cst9220_touchscreen.h" +#include "esphome/core/helpers.h" + +#include + +namespace esphome::cst9220 { + +void CST9220Touchscreen::setup() { + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + delay(5); + this->reset_pin_->digital_write(false); + delay(10); + this->reset_pin_->digital_write(true); + } + // Wait for the controller to leave its bootloader before talking to it. + this->set_timeout(30, [this] { this->continue_setup_(); }); +} + +void CST9220Touchscreen::continue_setup_() { + uint8_t buffer[4]; + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); + } + + // Enter command mode so the configuration registers can be read. + if (this->write_register16(REG_CMD_MODE, buffer, 0) != i2c::ERROR_OK) { + this->status_set_error(LOG_STR("Failed to enter command mode")); + this->mark_failed(); + return; + } + delay(10); + + // The firmware check code confirms that valid firmware is loaded. + if (this->read_register16(REG_CHECKCODE, buffer, 4) != i2c::ERROR_OK) { + this->status_set_error(LOG_STR("Failed to read check code")); + this->mark_failed(); + return; + } + uint32_t checkcode = encode_uint32(buffer[3], buffer[2], buffer[1], buffer[0]); + if ((checkcode & 0xFFFF0000) != 0xCACA0000) { + ESP_LOGE(TAG, "Invalid firmware check code: 0x%08" PRIX32, checkcode); + this->status_set_error(LOG_STR("Invalid firmware check code")); + this->mark_failed(); + return; + } + + // Read the panel resolution unless the user supplied calibration values. + if (this->read_register16(REG_RESOLUTION, buffer, 4) == i2c::ERROR_OK) { + if (this->x_raw_max_ == this->x_raw_min_) + this->x_raw_max_ = encode_uint16(buffer[1], buffer[0]); + if (this->y_raw_max_ == this->y_raw_min_) + this->y_raw_max_ = encode_uint16(buffer[3], buffer[2]); + } + + // Read the chip type and project id and validate the controller. + if (this->read_register16(REG_CHIP_INFO, buffer, 4) != i2c::ERROR_OK) { + this->status_set_error(LOG_STR("Failed to read chip ID")); + this->mark_failed(); + return; + } + this->chip_id_ = encode_uint16(buffer[3], buffer[2]); + this->project_id_ = encode_uint16(buffer[1], buffer[0]); + if (this->chip_id_ != CST9220_CHIP_ID && this->chip_id_ != CST9217_CHIP_ID) { + ESP_LOGE(TAG, "Unknown chip ID: 0x%04X", this->chip_id_); + this->status_set_error(LOG_STR("Unknown chip ID")); + this->mark_failed(); + return; + } + + // Fall back to the display dimensions if the resolution read failed. + if (this->x_raw_max_ == this->x_raw_min_) + this->x_raw_max_ = this->display_->get_native_width(); + if (this->y_raw_max_ == this->y_raw_min_) + this->y_raw_max_ = this->display_->get_native_height(); + + this->setup_complete_ = true; +} + +void CST9220Touchscreen::update_touches() { + if (!this->setup_complete_) + return; + uint8_t data[CST9220_DATA_LENGTH]; + // Only an actual I2C failure should skip the update; a successful read with no + // touches is a real "all fingers lifted" state that must flow through so the + // base class can generate the release event. + if (this->read_register16(REG_TOUCH_DATA, data, sizeof(data)) != i2c::ERROR_OK) { + this->status_set_warning(); + this->skip_update_ = true; + return; + } + this->status_clear_warning(); + + // Acknowledge the report so the controller can prepare the next one. + uint8_t ack = TOUCH_ACK; + this->write_register16(REG_TOUCH_DATA, &ack, 1); + + // A valid report carries the ACK marker at offset 6; offset 0 holds the first + // point and must be neither the ACK marker nor empty. Anything else means no + // valid touch data this cycle, which we report as zero touches (not a skip). + if (data[0] == TOUCH_ACK || data[0] == 0x00 || data[6] != TOUCH_ACK) + return; + + uint8_t num_touches = data[5] & 0x7F; + if (num_touches > CST9220_MAX_TOUCHES) + num_touches = CST9220_MAX_TOUCHES; + + for (uint8_t i = 0; i < num_touches; i++) { + // The first point starts at offset 0; subsequent points are offset by the + // two status bytes that follow it. + const uint8_t *p = data + i * 5 + (i == 0 ? 0 : 2); + uint8_t id = p[0] >> 4; + uint8_t event = p[0] & 0x0F; + if (event != TOUCH_EVENT_DOWN) + continue; + // p[3] is shared: high nibble holds the X LSBs, low nibble the Y LSBs. + uint16_t x = (p[1] << 4) | (p[3] >> 4); + uint16_t y = (p[2] << 4) | (p[3] & 0x0F); + ESP_LOGV(TAG, "Read touch %d: %d/%d", id, x, y); + this->add_raw_touch_position_(id, x, y); + } +} + +void CST9220Touchscreen::dump_config() { + ESP_LOGCONFIG(TAG, + "CST9220 Touchscreen:\n" + " Chip ID: 0x%04X\n" + " Project ID: 0x%04X\n" + " X Raw Min: %d, X Raw Max: %d\n" + " Y Raw Min: %d, Y Raw Max: %d", + this->chip_id_, this->project_id_, this->x_raw_min_, this->x_raw_max_, this->y_raw_min_, + this->y_raw_max_); + LOG_I2C_DEVICE(this); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); +} + +} // namespace esphome::cst9220 diff --git a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h new file mode 100644 index 0000000000..17050e2429 --- /dev/null +++ b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h @@ -0,0 +1,50 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/touchscreen/touchscreen.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::cst9220 { + +static const char *const TAG = "cst9220.touchscreen"; + +// The CST92xx family uses 16-bit (big-endian) register addresses. +static const uint16_t REG_TOUCH_DATA = 0xD000; // touch report +static const uint16_t REG_CMD_MODE = 0xD101; // enter command mode +static const uint16_t REG_CHECKCODE = 0xD1FC; // firmware check code +static const uint16_t REG_RESOLUTION = 0xD1F8; // panel resolution +static const uint16_t REG_CHIP_INFO = 0xD204; // chip type + project id + +static const uint8_t TOUCH_ACK = 0xAB; +static const uint8_t TOUCH_EVENT_DOWN = 0x06; + +static const uint16_t CST9220_CHIP_ID = 0x9220; +static const uint16_t CST9217_CHIP_ID = 0x9217; + +// Maximum simultaneous touch points reported by the family. +static const uint8_t CST9220_MAX_TOUCHES = 5; +// Report layout: 5 bytes per touch point plus 5 bytes of status/ack overhead. +static const size_t CST9220_DATA_LENGTH = CST9220_MAX_TOUCHES * 5 + 5; + +class CST9220Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; } + + protected: + void update_touches() override; + void continue_setup_(); + + InternalGPIOPin *interrupt_pin_{}; + GPIOPin *reset_pin_{}; + uint16_t chip_id_{}; + uint16_t project_id_{}; + bool setup_complete_{}; +}; + +} // namespace esphome::cst9220 diff --git a/tests/components/cst9220/common.yaml b/tests/components/cst9220/common.yaml new file mode 100644 index 0000000000..99e14f47ae --- /dev/null +++ b/tests/components/cst9220/common.yaml @@ -0,0 +1,16 @@ +display: + - id: cst9220_display + platform: ili9xxx + model: ili9342 + cs_pin: ${cs_pin} + dc_pin: ${dc_pin} + reset_pin: ${disp_reset_pin} + invert_colors: false + +touchscreen: + - id: ts_cst9220 + i2c_id: i2c_bus + platform: cst9220 + display: cst9220_display + interrupt_pin: ${interrupt_pin} + reset_pin: ${reset_pin} diff --git a/tests/components/cst9220/test.esp32-idf.yaml b/tests/components/cst9220/test.esp32-idf.yaml new file mode 100644 index 0000000000..984f08db47 --- /dev/null +++ b/tests/components/cst9220/test.esp32-idf.yaml @@ -0,0 +1,12 @@ +substitutions: + cs_pin: GPIO4 + dc_pin: GPIO5 + disp_reset_pin: GPIO12 + interrupt_pin: GPIO15 + reset_pin: GPIO25 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + +<<: !include common.yaml From cf9d97d5ae3c6967647723bbfd51da21de7b2328 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:20:48 +1200 Subject: [PATCH 0666/1815] [pixoo] Add Divoom Pixoo display component (#16974) --- CODEOWNERS | 1 + esphome/components/pixoo/__init__.py | 1 + esphome/components/pixoo/display.py | 43 ++++ esphome/components/pixoo/light/__init__.py | 24 +++ esphome/components/pixoo/light/pixoo_light.h | 26 +++ esphome/components/pixoo/pixoo.cpp | 201 +++++++++++++++++++ esphome/components/pixoo/pixoo.h | 64 ++++++ tests/components/pixoo/common.yaml | 26 +++ tests/components/pixoo/test.esp32-idf.yaml | 4 + 9 files changed, 390 insertions(+) create mode 100644 esphome/components/pixoo/__init__.py create mode 100644 esphome/components/pixoo/display.py create mode 100644 esphome/components/pixoo/light/__init__.py create mode 100644 esphome/components/pixoo/light/pixoo_light.h create mode 100644 esphome/components/pixoo/pixoo.cpp create mode 100644 esphome/components/pixoo/pixoo.h create mode 100644 tests/components/pixoo/common.yaml create mode 100644 tests/components/pixoo/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 467b1b7326..d2c92f44ce 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -387,6 +387,7 @@ esphome/components/pcm5122/* @remcom esphome/components/pi4ioe5v6408/* @jesserockz esphome/components/pid/* @OttoWinter esphome/components/pipsolar/* @andreashergert1984 +esphome/components/pixoo/* @jesserockz esphome/components/pm1006/* @habbie esphome/components/pm2005/* @andrewjswan esphome/components/pmsa003i/* @sjtrny diff --git a/esphome/components/pixoo/__init__.py b/esphome/components/pixoo/__init__.py new file mode 100644 index 0000000000..b1de57df8f --- /dev/null +++ b/esphome/components/pixoo/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@jesserockz"] diff --git a/esphome/components/pixoo/display.py b/esphome/components/pixoo/display.py new file mode 100644 index 0000000000..764f06d603 --- /dev/null +++ b/esphome/components/pixoo/display.py @@ -0,0 +1,43 @@ +import esphome.codegen as cg +from esphome.components import display, spi +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_LAMBDA, CONF_MODEL +from esphome.types import ConfigType + +DEPENDENCIES = ["spi"] +AUTO_LOAD = ["split_buffer"] + +CONF_PIXOO_ID = "pixoo_id" + +pixoo_ns = cg.esphome_ns.namespace("pixoo") +Pixoo = pixoo_ns.class_("Pixoo", cg.PollingComponent, display.Display, spi.SPIDevice) +PixooModel = pixoo_ns.enum("PixooModel") + +# Only the 64x64 panel is hardware-verified. Smaller Pixoo panels are assumed to share the +# same protocol; add them here once confirmed. +MODELS = { + "64X64": PixooModel.PIXOO_64, +} + +CONFIG_SCHEMA = display.FULL_DISPLAY_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(Pixoo), + cv.Optional(CONF_MODEL, default="64X64"): cv.enum(MODELS, upper=True), + } +).extend(spi.spi_device_schema(cs_pin_required=True, default_data_rate=8e6)) + +FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( + "pixoo", require_miso=False, require_mosi=True +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID], config[CONF_MODEL]) + await display.register_display(var, config) + await spi.register_spi_device(var, config, write_only=True) + + if (lambda_config := config.get(CONF_LAMBDA)) is not None: + lambda_ = await cg.process_lambda( + lambda_config, [(display.DisplayRef, "it")], return_type=cg.void + ) + cg.add(var.set_writer(lambda_)) diff --git a/esphome/components/pixoo/light/__init__.py b/esphome/components/pixoo/light/__init__.py new file mode 100644 index 0000000000..7151cdde0b --- /dev/null +++ b/esphome/components/pixoo/light/__init__.py @@ -0,0 +1,24 @@ +import esphome.codegen as cg +from esphome.components import light +import esphome.config_validation as cv +from esphome.const import CONF_GAMMA_CORRECT, CONF_OUTPUT_ID +from esphome.types import ConfigType + +from ..display import CONF_PIXOO_ID, Pixoo, pixoo_ns + +PixooLight = pixoo_ns.class_("PixooLight", light.LightOutput) + +CONFIG_SCHEMA = light.BRIGHTNESS_ONLY_LIGHT_SCHEMA.extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(PixooLight), + cv.GenerateID(CONF_PIXOO_ID): cv.use_id(Pixoo), + # The LED board applies its own gamma, so default to no gamma correction here. + cv.Optional(CONF_GAMMA_CORRECT, default=0.0): cv.positive_float, + } +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) + await light.register_light(var, config) + await cg.register_parented(var, config[CONF_PIXOO_ID]) diff --git a/esphome/components/pixoo/light/pixoo_light.h b/esphome/components/pixoo/light/pixoo_light.h new file mode 100644 index 0000000000..67f3cd5024 --- /dev/null +++ b/esphome/components/pixoo/light/pixoo_light.h @@ -0,0 +1,26 @@ +#pragma once + +#include "esphome/components/light/light_output.h" +#include "esphome/components/light/light_state.h" +#include "esphome/components/pixoo/pixoo.h" +#include "esphome/core/helpers.h" + +namespace esphome::pixoo { + +// Brightness-only light that drives the Pixoo panel's LIGHT command. +class PixooLight : public light::LightOutput, public Parented { + public: + light::LightTraits get_traits() override { + auto traits = light::LightTraits(); + traits.set_supported_color_modes({light::ColorMode::BRIGHTNESS}); + return traits; + } + + void write_state(light::LightState *state) override { + float brightness; + state->current_values_as_brightness(&brightness); + this->parent_->set_panel_brightness(brightness); + } +}; + +} // namespace esphome::pixoo diff --git a/esphome/components/pixoo/pixoo.cpp b/esphome/components/pixoo/pixoo.cpp new file mode 100644 index 0000000000..4436b1fb17 --- /dev/null +++ b/esphome/components/pixoo/pixoo.cpp @@ -0,0 +1,201 @@ +#include "pixoo.h" + +#include "esphome/core/log.h" + +#include +#include +#include + +namespace esphome::pixoo { + +static const char *const TAG = "pixoo"; + +// Divoom LED-board packet protocol. +static constexpr uint8_t PACKET_HEAD = 0xAA; +static constexpr uint8_t PACKET_TAIL = 0xBB; +static constexpr uint8_t CMD_DATA = 0x00; +static constexpr uint8_t CMD_LIGHT = 0x01; +static constexpr uint8_t CMD_UNUSED = 0x21; +static constexpr uint8_t CMD_SET_RGB_IOUT = 0x22; +static constexpr size_t PACKET_HEADER_LEN = 4; // head + len(2) + cmd +static constexpr size_t PACKET_STATIC_LEN = 5; // header + tail +static constexpr uint8_t DEFAULT_IOUT = 75; // per-channel LED current / white balance default + +// Pack a `0xAA len cmd data 0xBB` packet into buf; returns the packet length. +static inline size_t build_packet(uint8_t *buf, uint8_t cmd, const uint8_t *data, uint16_t len) { + buf[0] = PACKET_HEAD; + buf[1] = static_cast(len & 0xFF); + buf[2] = static_cast((len >> 8) & 0xFF); + buf[3] = cmd; + if (data != nullptr && len > 0) + std::memcpy(buf + PACKET_HEADER_LEN, data, len); + buf[PACKET_HEADER_LEN + len] = PACKET_TAIL; + return len + PACKET_STATIC_LEN; +} + +// Fill `total` bytes at buf with a single UNUSED padding packet. +static inline void pad_unused(uint8_t *buf, size_t total) { + const uint16_t len = static_cast(total - PACKET_STATIC_LEN); + buf[0] = PACKET_HEAD; + buf[1] = static_cast(len & 0xFF); + buf[2] = static_cast((len >> 8) & 0xFF); + buf[3] = CMD_UNUSED; + buf[total - 1] = PACKET_TAIL; +} + +float Pixoo::get_setup_priority() const { return setup_priority::PROCESSOR; } + +void Pixoo::setup() { + const uint32_t num_pixels = static_cast(this->model_) * this->model_; + this->data_size_ = num_pixels * 3; + // The frame is a DATA packet (header + RGB888 + tail) followed by a DMA-chunk-sized UNUSED + // packet, so the LED board completes its final DMA block. + this->frame_size_ = this->data_size_ + PACKET_STATIC_LEN + DMA_CHUNK; + + if (!this->buffer_.init(this->data_size_)) { + this->mark_failed(LOG_STR("Failed to allocate draw buffer")); + return; + } + + // The frame is shipped in one SPI transfer, so keep it in DMA-capable internal RAM. + RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); + this->frame_buffer_ = allocator.allocate(this->frame_size_); + if (this->frame_buffer_ == nullptr) { + this->buffer_.free(); + this->mark_failed(LOG_STR("Failed to allocate frame buffer")); + return; + } + std::memset(this->frame_buffer_, 0, this->frame_size_); + // Pre-build the constant DATA-packet framing; only the RGB888 payload changes per frame. + this->frame_buffer_[0] = PACKET_HEAD; + this->frame_buffer_[1] = static_cast(this->data_size_ & 0xFF); + this->frame_buffer_[2] = static_cast((this->data_size_ >> 8) & 0xFF); + this->frame_buffer_[3] = CMD_DATA; + this->frame_buffer_[PACKET_HEADER_LEN + this->data_size_] = PACKET_TAIL; + pad_unused(this->frame_buffer_ + this->data_size_ + PACKET_STATIC_LEN, DMA_CHUNK); + + this->spi_setup(); + + this->buffer_.fill(0x00); + + // Set the per-channel LED current. Brightness is controlled separately via the light platform. + const uint8_t iout[3] = {DEFAULT_IOUT, DEFAULT_IOUT, DEFAULT_IOUT}; + this->send_command_(CMD_SET_RGB_IOUT, iout, 3); + + // Frames are pushed synchronously inside update(), so there is no loop() work to do and the + // component is idle between updates. Marking it done (LOOP_DONE) lets LVGL's + // update_when_display_idle option treat the panel as idle and drive frames on demand. + this->disable_loop(); +} + +void Pixoo::send_command_(uint8_t cmd, const uint8_t *data, uint16_t len) { + std::memset(this->cmd_buffer_, 0, DMA_CHUNK); + const size_t used = build_packet(this->cmd_buffer_, cmd, data, len); + if (DMA_CHUNK - used >= PACKET_STATIC_LEN) + pad_unused(this->cmd_buffer_ + used, DMA_CHUNK - used); + this->enable(); + this->write_array(this->cmd_buffer_, DMA_CHUNK); + this->disable(); +} + +void Pixoo::set_panel_brightness(float brightness) { + const uint8_t pct = static_cast(lroundf(clamp(brightness, 0.0f, 1.0f) * 100.0f)); + this->send_command_(CMD_LIGHT, &pct, 1); +} + +void Pixoo::update() { + this->do_update_(); + for (size_t i = 0; i < this->data_size_; i++) + this->frame_buffer_[PACKET_HEADER_LEN + i] = this->buffer_[i]; + this->enable(); + this->write_array(this->frame_buffer_, this->frame_size_); + this->disable(); +} + +void Pixoo::set_pixel_(uint32_t index, Color color) { + const size_t off = static_cast(index) * 3; + this->buffer_[off] = color.r; + this->buffer_[off + 1] = color.g; + this->buffer_[off + 2] = color.b; +} + +void HOT Pixoo::draw_pixel_at(int x, int y, Color color) { + if (!this->get_clipping().inside(x, y)) + return; + const int side = static_cast(this->model_); + switch (this->rotation_) { + case display::DISPLAY_ROTATION_0_DEGREES: + break; + case display::DISPLAY_ROTATION_90_DEGREES: + std::swap(x, y); + x = side - x - 1; + break; + case display::DISPLAY_ROTATION_180_DEGREES: + x = side - x - 1; + y = side - y - 1; + break; + case display::DISPLAY_ROTATION_270_DEGREES: + std::swap(x, y); + y = side - y - 1; + break; + } + if (x < 0 || x >= side || y < 0 || y >= side) + return; + this->set_pixel_(static_cast(y) * side + x, color); +} + +void Pixoo::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { + // Fast path for the common LVGL/image blit: RGB565, RGB order, no rotation, no active clipping. + // Anything else defers to the base implementation, which decodes per pixel and routes through + // draw_pixel_at() so rotation, clipping and other color formats stay correct. + // NOTE: the stride/index math and 565->888 expansion below mirror Display::draw_pixels_at (the + // source of truth) -- keep them in sync if the base ever changes its source layout or decoding. + if (bitness != display::COLOR_BITNESS_565 || order != display::COLOR_ORDER_RGB || + this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || this->is_clipping()) { + display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, + x_pad); + return; + } + const int side = static_cast(this->model_); + const size_t line_stride = static_cast(x_offset) + w + x_pad; + for (int y = 0; y != h; y++) { + const int dst_y = y_start + y; + if (dst_y < 0 || dst_y >= side) + continue; + size_t source_idx = (static_cast(y_offset) + y) * line_stride + x_offset; + for (int x = 0; x != w; x++, source_idx++) { + const int dst_x = x_start + x; + if (dst_x < 0 || dst_x >= side) + continue; + const size_t byte_idx = source_idx * 2; + const uint16_t rgb565 = + big_endian ? (ptr[byte_idx] << 8) | ptr[byte_idx + 1] : ptr[byte_idx] | (ptr[byte_idx + 1] << 8); + const uint8_t r5 = (rgb565 >> 11) & 0x1F; + const uint8_t g6 = (rgb565 >> 5) & 0x3F; + const uint8_t b5 = rgb565 & 0x1F; + this->set_pixel_(static_cast(dst_y) * side + dst_x, + Color((r5 << 3) | (r5 >> 2), (g6 << 2) | (g6 >> 4), (b5 << 3) | (b5 >> 2))); + } + } +} + +void Pixoo::fill(Color color) { + if (this->is_clipping()) { + display::Display::fill(color); + return; + } + for (size_t i = 0; i < this->data_size_; i += 3) { + this->buffer_[i] = color.r; + this->buffer_[i + 1] = color.g; + this->buffer_[i + 2] = color.b; + } +} + +void Pixoo::dump_config() { + LOG_DISPLAY("", "Divoom Pixoo", this); + ESP_LOGCONFIG(TAG, " Model: %ux%u", (unsigned) this->model_, (unsigned) this->model_); + LOG_UPDATE_INTERVAL(this); +} + +} // namespace esphome::pixoo diff --git a/esphome/components/pixoo/pixoo.h b/esphome/components/pixoo/pixoo.h new file mode 100644 index 0000000000..4913ef85db --- /dev/null +++ b/esphome/components/pixoo/pixoo.h @@ -0,0 +1,64 @@ +#pragma once + +#include "esphome/components/display/display.h" +#include "esphome/components/spi/spi.h" +#include "esphome/components/split_buffer/split_buffer.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::pixoo { + +// The Pixoo's main board (where ESPHome runs) talks to a separate LED-driver board (a GD32/AT32 +// MCU) over SPI using Divoom's packet protocol: +// 0xAA, len_lo, len_hi, cmd, , 0xBB +// The image is sent as a DATA (0x00) packet carrying width*height*3 bytes of RGB888; brightness is +// a separate LIGHT (0x01) command; the LED current is set once via SET_RGB_IOUT (0x22). Command +// packets are padded out to the LED board's 240-byte DMA chunk with an UNUSED (0x21) packet. +// The model selects the (square) panel side length. +enum PixooModel : uint8_t { + PIXOO_64 = 64, +}; + +class Pixoo : public display::Display, + public spi::SPIDevice { + public: + explicit Pixoo(PixooModel model) : model_(model) {} + + void setup() override; + void update() override; + void dump_config() override; + float get_setup_priority() const override; + + // Brightness is controlled exclusively via the light platform: send a LIGHT command to the LED + // board (brightness 0..1 -> 0..100%). + void set_panel_brightness(float brightness); + + display::DisplayType get_display_type() override { return display::DISPLAY_TYPE_COLOR; } + + void fill(Color color) override; + void draw_pixel_at(int x, int y, Color color) override; + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; + + protected: + int get_width_internal() override { return static_cast(this->model_); } + int get_height_internal() override { return static_cast(this->model_); } + + void set_pixel_(uint32_t index, Color color); + void send_command_(uint8_t cmd, const uint8_t *data, uint16_t len); + + // Size of the LED board's SPI DMA chunk; the command scratch buffer is one chunk. + static constexpr size_t DMA_CHUNK = 240; + + PixooModel model_; + + size_t data_size_{0}; // RGB888 image bytes: model^2 * 3 + size_t frame_size_{0}; // full SPI frame: DATA packet + trailing UNUSED packet + + split_buffer::SplitBuffer buffer_{}; + uint8_t *frame_buffer_{nullptr}; + uint8_t cmd_buffer_[DMA_CHUNK]{}; +}; + +} // namespace esphome::pixoo diff --git a/tests/components/pixoo/common.yaml b/tests/components/pixoo/common.yaml new file mode 100644 index 0000000000..e854ce8863 --- /dev/null +++ b/tests/components/pixoo/common.yaml @@ -0,0 +1,26 @@ +display: + - platform: pixoo + id: pixoo_display + model: 64x64 + cs_pin: GPIO5 + data_rate: 10MHz + update_interval: 1s + lambda: |- + it.fill(Color(0, 0, 0)); + it.filled_rectangle(0, 0, 16, 16, Color(255, 0, 0)); + it.line(0, 0, 63, 63, Color(0, 255, 0)); + + - platform: pixoo + id: pixoo_display_pages + model: 64x64 + cs_pin: GPIO21 + rotation: 90 + pages: + - id: pixoo_page + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height(), Color(0, 0, 255)); + +light: + - platform: pixoo + pixoo_id: pixoo_display + name: Pixoo Brightness diff --git a/tests/components/pixoo/test.esp32-idf.yaml b/tests/components/pixoo/test.esp32-idf.yaml new file mode 100644 index 0000000000..a8e18ca503 --- /dev/null +++ b/tests/components/pixoo/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + +<<: !include common.yaml From 091b6a0ba0d2da50a63658f2f7f34969772c85fc Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 02:07:06 +0000 Subject: [PATCH 0667/1815] Bump bundled esphome-device-builder to 1.0.22 --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 1085076137..04e7998f77 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.21 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.22 RUN \ platformio settings set enable_telemetry No \ From 359c6a7265c23f814d442c451a3d870137dcc7c8 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:34:32 -0400 Subject: [PATCH 0668/1815] [libretiny] Update LibreTiny to v1.13.0 (#17288) --- docker/test_configs/ln882x-arduino.yaml | 2 +- esphome/components/bk72xx/boards.py | 2178 ++++++------- esphome/components/libretiny/__init__.py | 10 +- .../libretiny/generate_components.py | 4 +- esphome/components/ln882x/boards.py | 497 ++- esphome/components/rtl87xx/boards.py | 2742 ++++++++--------- platformio.ini | 4 +- .../build_components_base.ln882x-ard.yaml | 2 +- 8 files changed, 2922 insertions(+), 2517 deletions(-) diff --git a/docker/test_configs/ln882x-arduino.yaml b/docker/test_configs/ln882x-arduino.yaml index 4cff3a4883..38e96630ba 100644 --- a/docker/test_configs/ln882x-arduino.yaml +++ b/docker/test_configs/ln882x-arduino.yaml @@ -2,6 +2,6 @@ esphome: name: docker-test-ln882x-arduino ln882x: - board: generic-ln882hki + board: generic-ln882h logger: diff --git a/esphome/components/bk72xx/boards.py b/esphome/components/bk72xx/boards.py index f8bedce329..6054b03f78 100644 --- a/esphome/components/bk72xx/boards.py +++ b/esphome/components/bk72xx/boards.py @@ -21,38 +21,6 @@ from esphome.components.libretiny.const import ( ) BK72XX_BOARDS = { - "wb2l-m1": { - "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)", - "family": FAMILY_BK7231T, - }, - "generic-bk7231n-qfn32-tuya": { - "name": "Generic - BK7231N (Tuya)", - "family": FAMILY_BK7231N, - }, "cb1s": { "name": "CB1S Wi-Fi Module", "family": FAMILY_BK7231N, @@ -61,623 +29,117 @@ BK72XX_BOARDS = { "name": "CB2L Wi-Fi Module", "family": FAMILY_BK7231N, }, - "cblc5": { - "name": "CBLC5 Wi-Fi Module", + "cb2s": { + "name": "CB2S Wi-Fi Module", + "family": FAMILY_BK7231N, + }, + "cb3l": { + "name": "CB3L Wi-Fi Module", "family": FAMILY_BK7231N, }, "cb3s": { "name": "CB3S Wi-Fi Module", "family": FAMILY_BK7231N, }, - "wb3s": { - "name": "WB3S Wi-Fi Module", - "family": FAMILY_BK7231T, - }, - "lsc-lma35": { - "name": "LSC LMA35 BK7231N", + "cb3se": { + "name": "CB3SE Wi-Fi Module", "family": FAMILY_BK7231N, }, - "generic-bk7252": { - "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, - }, - "wb1s": { - "name": "WB1S Wi-Fi Module", - "family": FAMILY_BK7231T, - }, - "wblc5": { - "name": "WBLC5 Wi-Fi Module", - "family": FAMILY_BK7231T, - }, - "cb2s": { - "name": "CB2S Wi-Fi Module", + "cblc5": { + "name": "CBLC5 Wi-Fi Module", "family": FAMILY_BK7231N, }, + "cbu": { + "name": "CBU Wi-Fi Module", + "family": FAMILY_BK7231N, + }, + "generic-bk7231n-qfn32": { + "name": "Generic - BK7231N", + "family": FAMILY_BK7231N, + }, + "generic-bk7231n-qfn32-tuya": { + "name": "Generic - BK7231N (Tuya)", + "family": FAMILY_BK7231N, + }, + "generic-bk7231t-qfn32-tuya": { + "name": "Generic - BK7231T (Tuya)", + "family": FAMILY_BK7231T, + }, "generic-bk7238": { "name": "Generic - BK7238", "family": FAMILY_BK7238, }, - "wa2": { - "name": "WA2 Wi-Fi Module", - "family": FAMILY_BK7231Q, + "generic-bk7238-tuya": { + "name": "Generic - BK7238 (Tuya T1)", + "family": FAMILY_BK7238, }, - "cb3l": { - "name": "CB3L Wi-Fi Module", + "generic-bk7252": { + "name": "Generic - BK7252", + "family": FAMILY_BK7251, + }, + "lsc-lma35": { + "name": "LSC LMA35 BK7231N", "family": FAMILY_BK7231N, }, "lsc-lma35-t": { "name": "LSC LMA35 BK7231T", "family": FAMILY_BK7231T, }, - "cb3se": { - "name": "CB3SE Wi-Fi Module", - "family": FAMILY_BK7231N, - }, - "wb3l": { - "name": "WB3L Wi-Fi Module", - "family": FAMILY_BK7231T, - }, "t1-2s": { "name": "T1-2S Wi-Fi Module", "family": FAMILY_BK7238, }, + "t1-3s": { + "name": "T1-3S Wi-Fi Module", + "family": FAMILY_BK7238, + }, + "t1-m": { + "name": "T1-M Wi-Fi Module", + "family": FAMILY_BK7238, + }, + "t1-u": { + "name": "T1-U Wi-Fi Module", + "family": FAMILY_BK7238, + }, + "wa2": { + "name": "WA2 Wi-Fi Module", + "family": FAMILY_BK7231Q, + }, + "wb1s": { + "name": "WB1S Wi-Fi Module", + "family": FAMILY_BK7231T, + }, + "wb2l": { + "name": "WB2L Wi-Fi Module", + "family": FAMILY_BK7231T, + }, + "wb2l-m1": { + "name": "WB2L_M1 Wi-Fi Module", + "family": FAMILY_BK7231N, + }, "wb2s": { "name": "WB2S Wi-Fi Module", "family": FAMILY_BK7231T, }, + "wb3l": { + "name": "WB3L Wi-Fi Module", + "family": FAMILY_BK7231T, + }, + "wb3s": { + "name": "WB3S Wi-Fi Module", + "family": FAMILY_BK7231T, + }, + "wblc5": { + "name": "WBLC5 Wi-Fi Module", + "family": FAMILY_BK7231T, + }, + "xh-wb3s": { + "name": "NiceMCU XH-WB3S", + "family": FAMILY_BK7238, + }, } BK72XX_BOARD_PINS = { - "wb2l-m1": { - "WIRE1_SCL": 20, - "WIRE1_SDA": 21, - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P10": 10, - "P11": 11, - "P20": 20, - "P21": 21, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCL1": 20, - "SCL2": 0, - "SDA1": 21, - "SDA2": 1, - "TX1": 11, - "TX2": 0, - "D0": 8, - "D1": 7, - "D2": 6, - "D3": 26, - "D4": 24, - "D5": 10, - "D6": 11, - "D7": 1, - "D8": 0, - "D9": 20, - "D10": 21, - "D11": 23, - "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, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE1_SCL": 20, - "WIRE1_SDA": 21, - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "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, - "SCL1": 20, - "SCL2": 0, - "SDA1": 21, - "SDA2": 1, - "TX1": 11, - "TX2": 0, - "D0": 14, - "D1": 16, - "D2": 20, - "D3": 22, - "D4": 23, - "D5": 1, - "D6": 0, - "D7": 8, - "D8": 7, - "D9": 6, - "D10": 26, - "D11": 24, - "D12": 11, - "D13": 10, - "D14": 28, - "D15": 9, - "D16": 17, - "D17": 15, - "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, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE1_SCL": 20, - "WIRE1_SDA": 21, - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "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, - "SCL1": 20, - "SCL2": 0, - "SDA1": 21, - "SDA2": 1, - "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": 23, - }, - "generic-bk7231n-qfn32-tuya": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE1_SCL": 20, - "WIRE1_SDA": 21, - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "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, - "SCL1": 20, - "SCL2": 0, - "SDA1": 21, - "SDA2": 1, - "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": 23, - }, "cb1s": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, @@ -765,22 +227,28 @@ BK72XX_BOARD_PINS = { "D7": 11, "D8": 21, }, - "cblc5": { + "cb2s": { "WIRE2_SCL": 0, "WIRE2_SDA": 1, "SERIAL1_RX": 10, "SERIAL1_TX": 11, "SERIAL2_RX": 1, "SERIAL2_TX": 0, + "ADC3": 23, "P0": 0, "P1": 1, "P6": 6, + "P7": 7, + "P8": 8, "P10": 10, "P11": 11, "P21": 21, + "P23": 23, "P24": 24, "P26": 26, "PWM0": 6, + "PWM1": 7, + "PWM2": 8, "PWM4": 24, "PWM5": 26, "RX1": 10, @@ -790,14 +258,61 @@ BK72XX_BOARD_PINS = { "SDA2": 1, "TX1": 11, "TX2": 0, - "D0": 24, - "D1": 6, - "D2": 26, - "D3": 11, + "D0": 6, + "D1": 7, + "D2": 8, + "D3": 23, "D4": 10, - "D5": 1, + "D5": 11, + "D6": 24, + "D7": 26, + "D8": 0, + "D9": 1, + "D10": 21, + "A0": 23, + }, + "cb3l": { + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_TX": 0, + "ADC3": 23, + "P0": 0, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P21": 21, + "P23": 23, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "SCK": 14, + "SCL2": 0, + "SDA1": 21, + "TX1": 11, + "TX2": 0, + "D0": 23, + "D1": 14, + "D2": 26, + "D3": 24, + "D4": 6, + "D5": 9, "D6": 0, "D7": 21, + "D8": 8, + "D9": 7, + "D10": 10, + "D11": 11, + "A0": 23, }, "cb3s": { "WIRE1_SCL": 20, @@ -849,9 +364,11 @@ BK72XX_BOARD_PINS = { "D13": 20, "A0": 23, }, - "wb3s": { - "WIRE1_SCL": 20, - "WIRE1_SDA": 21, + "cb3se": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, "WIRE2_SCL": 0, "WIRE2_SDA": 1, "SERIAL1_RX": 10, @@ -859,6 +376,9 @@ BK72XX_BOARD_PINS = { "SERIAL2_RX": 1, "SERIAL2_TX": 0, "ADC3": 23, + "CS": 15, + "MISO": 17, + "MOSI": 16, "P0": 0, "P1": 1, "P6": 6, @@ -868,8 +388,10 @@ BK72XX_BOARD_PINS = { "P10": 10, "P11": 11, "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, "P20": 20, - "P21": 21, "P22": 22, "P23": 23, "P24": 24, @@ -885,7 +407,6 @@ BK72XX_BOARD_PINS = { "SCK": 14, "SCL1": 20, "SCL2": 0, - "SDA1": 21, "SDA2": 1, "TX1": 11, "TX2": 0, @@ -894,19 +415,61 @@ BK72XX_BOARD_PINS = { "D2": 26, "D3": 24, "D4": 6, - "D5": 7, + "D5": 9, "D6": 0, "D7": 1, - "D8": 9, - "D9": 8, + "D8": 8, + "D9": 7, "D10": 10, "D11": 11, - "D12": 22, - "D13": 21, + "D12": 15, + "D13": 22, "D14": 20, + "D15": 17, + "D16": 16, "A0": 23, }, - "lsc-lma35": { + "cblc5": { + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "P0": 0, + "P1": 1, + "P6": 6, + "P10": 10, + "P11": 11, + "P21": 21, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCL2": 0, + "SDA1": 21, + "SDA2": 1, + "TX1": 11, + "TX2": 0, + "D0": 24, + "D1": 6, + "D2": 26, + "D3": 11, + "D4": 10, + "D5": 1, + "D6": 0, + "D7": 21, + }, + "cbu": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE1_SCL": 20, + "WIRE1_SDA": 21, "WIRE2_SCL": 0, "WIRE2_SDA": 1, "SERIAL1_RX": 10, @@ -914,6 +477,8 @@ BK72XX_BOARD_PINS = { "SERIAL2_RX": 1, "SERIAL2_TX": 0, "ADC3": 23, + "CS": 15, + "MISO": 17, "MOSI": 16, "P0": 0, "P1": 1, @@ -924,12 +489,16 @@ BK72XX_BOARD_PINS = { "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, @@ -939,28 +508,405 @@ BK72XX_BOARD_PINS = { "RX1": 10, "RX2": 1, "SCK": 14, + "SCL1": 20, "SCL2": 0, "SDA1": 21, "SDA2": 1, "TX1": 11, "TX2": 0, - "D0": 26, - "D1": 14, - "D2": 16, - "D3": 24, - "D4": 22, - "D5": 0, - "D6": 23, + "D0": 14, + "D1": 16, + "D2": 20, + "D3": 22, + "D4": 23, + "D5": 1, + "D6": 0, "D7": 8, - "D8": 9, - "D9": 21, - "D10": 6, - "D11": 7, - "D12": 10, - "D13": 11, - "D14": 1, + "D8": 7, + "D9": 6, + "D10": 26, + "D11": 24, + "D12": 11, + "D13": 10, + "D14": 28, + "D15": 9, + "D16": 17, + "D17": 15, + "D18": 21, "A0": 23, }, + "generic-bk7231n-qfn32": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE1_SCL": 20, + "WIRE1_SDA": 21, + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC3": 23, + "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, + "SCL1": 20, + "SCL2": 0, + "SDA1": 21, + "SDA2": 1, + "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": 23, + }, + "generic-bk7231n-qfn32-tuya": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE1_SCL": 20, + "WIRE1_SDA": 21, + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC3": 23, + "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, + "SCL1": 20, + "SCL2": 0, + "SDA1": 21, + "SDA2": 1, + "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": 23, + }, + "generic-bk7231t-qfn32-tuya": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE1_SCL": 20, + "WIRE1_SDA": 21, + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC3": 23, + "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, + "SCL1": 20, + "SCL2": 0, + "SDA1": 21, + "SDA2": 1, + "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": 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, + }, + "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, + }, "generic-bk7252": { "SPI0_CS": 15, "SPI0_MISO": 17, @@ -1085,6 +1031,161 @@ BK72XX_BOARD_PINS = { "A6": 12, "A7": 13, }, + "lsc-lma35": { + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC3": 23, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P16": 16, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "SCL2": 0, + "SDA1": 21, + "SDA2": 1, + "TX1": 11, + "TX2": 0, + "D0": 26, + "D1": 14, + "D2": 16, + "D3": 24, + "D4": 22, + "D5": 0, + "D6": 23, + "D7": 8, + "D8": 9, + "D9": 21, + "D10": 6, + "D11": 7, + "D12": 10, + "D13": 11, + "D14": 1, + "A0": 23, + }, + "lsc-lma35-t": { + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC3": 23, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P16": 16, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "SCL2": 0, + "SDA1": 21, + "SDA2": 1, + "TX1": 11, + "TX2": 0, + "D0": 26, + "D1": 14, + "D2": 16, + "D3": 24, + "D4": 22, + "D5": 0, + "D6": 23, + "D7": 8, + "D8": 9, + "D9": 21, + "D10": 6, + "D11": 7, + "D12": 10, + "D13": 11, + "D14": 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, + }, "t1-3s": { "SPI0_CS": 15, "SPI0_MISO": 17, @@ -1154,6 +1255,217 @@ BK72XX_BOARD_PINS = { "A3": 26, "A4": 10, }, + "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, + }, + "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, + }, + "wa2": { + "WIRE1_SCL": 20, + "WIRE1_SDA": 21, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_TX": 0, + "ADC1": 4, + "ADC3": 23, + "P0": 0, + "P4": 4, + "P6": 6, + "P7": 7, + "P8": 8, + "P10": 10, + "P11": 11, + "P18": 18, + "P19": 19, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM4": 18, + "PWM5": 19, + "RX1": 10, + "SCL1": 20, + "SCL2": 0, + "SDA1": 21, + "TX1": 11, + "TX2": 0, + "D0": 8, + "D1": 7, + "D2": 6, + "D3": 23, + "D4": 10, + "D5": 11, + "D6": 18, + "D7": 19, + "D8": 20, + "D9": 4, + "D10": 0, + "D11": 21, + "D12": 22, + "A0": 23, + }, + "wb1s": { + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC3": 23, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P23": 23, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCL2": 0, + "SDA2": 1, + "TX1": 11, + "TX2": 0, + "D0": 11, + "D1": 10, + "D2": 26, + "D3": 24, + "D4": 0, + "D5": 8, + "D6": 7, + "D7": 1, + "D8": 9, + "D9": 6, + "D10": 23, + "A0": 23, + }, "wb2l": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, @@ -1205,51 +1517,7 @@ BK72XX_BOARD_PINS = { "D12": 22, "A0": 23, }, - "wb1s": { - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P23": 23, - "P24": 24, - "P26": 26, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCL2": 0, - "SDA2": 1, - "TX1": 11, - "TX2": 0, - "D0": 11, - "D1": 10, - "D2": 26, - "D3": 24, - "D4": 0, - "D5": 8, - "D6": 7, - "D7": 1, - "D8": 9, - "D9": 6, - "D10": 23, - "A0": 23, - }, - "wblc5": { + "wb2l-m1": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, "WIRE2_SCL": 0, @@ -1262,6 +1530,8 @@ BK72XX_BOARD_PINS = { "P0": 0, "P1": 1, "P6": 6, + "P7": 7, + "P8": 8, "P10": 10, "P11": 11, "P20": 20, @@ -1271,95 +1541,43 @@ BK72XX_BOARD_PINS = { "P24": 24, "P26": 26, "PWM0": 6, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCL1": 20, - "SCL2": 0, - "SDA1": 21, - "SDA2": 1, - "TX1": 11, - "TX2": 0, - "D0": 24, - "D1": 6, - "D2": 26, - "D3": 10, - "D4": 11, - "D5": 1, - "D6": 0, - "D7": 20, - "D8": 21, - "D9": 22, - "D10": 23, - "A0": 23, - }, - "cb2s": { - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P10": 10, - "P11": 11, - "P21": 21, - "P23": 23, - "P24": 24, - "P26": 26, - "PWM0": 6, "PWM1": 7, "PWM2": 8, "PWM4": 24, "PWM5": 26, "RX1": 10, "RX2": 1, + "SCL1": 20, "SCL2": 0, "SDA1": 21, "SDA2": 1, "TX1": 11, "TX2": 0, - "D0": 6, + "D0": 8, "D1": 7, - "D2": 8, - "D3": 23, - "D4": 10, - "D5": 11, - "D6": 24, - "D7": 26, + "D2": 6, + "D3": 26, + "D4": 24, + "D5": 10, + "D6": 11, + "D7": 1, "D8": 0, - "D9": 1, + "D9": 20, "D10": 21, + "D11": 23, + "D12": 22, "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, + "wb2s": { + "WIRE1_SCL": 20, + "WIRE1_SDA": 21, + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, "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, + "ADC3": 23, "P0": 0, "P1": 1, "P6": 6, @@ -1368,17 +1586,12 @@ BK72XX_BOARD_PINS = { "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, @@ -1387,65 +1600,10 @@ BK72XX_BOARD_PINS = { "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, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_TX": 0, - "ADC1": 4, - "ADC3": 23, - "P0": 0, - "P4": 4, - "P6": 6, - "P7": 7, - "P8": 8, - "P10": 10, - "P11": 11, - "P18": 18, - "P19": 19, - "P20": 20, - "P21": 21, - "P22": 22, - "P23": 23, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM4": 18, - "PWM5": 19, - "RX1": 10, "SCL1": 20, "SCL2": 0, "SDA1": 21, + "SDA2": 1, "TX1": 11, "TX2": 0, "D0": 8, @@ -1454,176 +1612,14 @@ BK72XX_BOARD_PINS = { "D3": 23, "D4": 10, "D5": 11, - "D6": 18, - "D7": 19, + "D6": 24, + "D7": 26, "D8": 20, - "D9": 4, - "D10": 0, - "D11": 21, - "D12": 22, - "A0": 23, - }, - "cb3l": { - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_TX": 0, - "ADC3": 23, - "P0": 0, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P21": 21, - "P23": 23, - "P24": 24, - "P26": 26, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "SCK": 14, - "SCL2": 0, - "SDA1": 21, - "TX1": 11, - "TX2": 0, - "D0": 23, - "D1": 14, - "D2": 26, - "D3": 24, - "D4": 6, - "D5": 9, - "D6": 0, - "D7": 21, - "D8": 8, - "D9": 7, - "D10": 10, - "D11": 11, - "A0": 23, - }, - "lsc-lma35-t": { - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "MOSI": 16, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P16": 16, - "P21": 21, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "SCL2": 0, - "SDA1": 21, - "SDA2": 1, - "TX1": 11, - "TX2": 0, - "D0": 26, - "D1": 14, - "D2": 16, - "D3": 24, - "D4": 22, - "D5": 0, - "D6": 23, - "D7": 8, - "D8": 9, - "D9": 21, - "D10": 6, - "D11": 7, - "D12": 10, - "D13": 11, - "D14": 1, - "A0": 23, - }, - "cb3se": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "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, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "SCL1": 20, - "SCL2": 0, - "SDA2": 1, - "TX1": 11, - "TX2": 0, - "D0": 23, - "D1": 14, - "D2": 26, - "D3": 24, - "D4": 6, - "D5": 9, - "D6": 0, - "D7": 1, - "D8": 8, - "D9": 7, - "D10": 10, - "D11": 11, - "D12": 15, + "D9": 9, + "D10": 1, + "D11": 0, + "D12": 21, "D13": 22, - "D14": 20, - "D15": 17, - "D16": 16, "A0": 23, }, "wb3l": { @@ -1686,52 +1682,7 @@ 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": { + "wb3s": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, "WIRE2_SCL": 0, @@ -1749,6 +1700,7 @@ BK72XX_BOARD_PINS = { "P9": 9, "P10": 10, "P11": 11, + "P14": 14, "P20": 20, "P21": 21, "P22": 22, @@ -1763,28 +1715,152 @@ BK72XX_BOARD_PINS = { "PWM5": 26, "RX1": 10, "RX2": 1, + "SCK": 14, "SCL1": 20, "SCL2": 0, "SDA1": 21, "SDA2": 1, "TX1": 11, "TX2": 0, - "D0": 8, - "D1": 7, - "D2": 6, - "D3": 23, - "D4": 10, - "D5": 11, - "D6": 24, - "D7": 26, - "D8": 20, - "D9": 9, - "D10": 1, - "D11": 0, - "D12": 21, - "D13": 22, + "D0": 23, + "D1": 14, + "D2": 26, + "D3": 24, + "D4": 6, + "D5": 7, + "D6": 0, + "D7": 1, + "D8": 9, + "D9": 8, + "D10": 10, + "D11": 11, + "D12": 22, + "D13": 21, + "D14": 20, "A0": 23, }, + "wblc5": { + "WIRE1_SCL": 20, + "WIRE1_SDA": 21, + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC3": 23, + "P0": 0, + "P1": 1, + "P6": 6, + "P10": 10, + "P11": 11, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCL1": 20, + "SCL2": 0, + "SDA1": 21, + "SDA2": 1, + "TX1": 11, + "TX2": 0, + "D0": 24, + "D1": 6, + "D2": 26, + "D3": 10, + "D4": 11, + "D5": 1, + "D6": 0, + "D7": 20, + "D8": 21, + "D9": 22, + "D10": 23, + "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, + }, } BOARDS = BK72XX_BOARDS diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index bcc393f3fd..079bb32aab 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -211,14 +211,14 @@ def _notify_old_style(config): # The dev and latest branches will be at *least* this version, which is what matters. # Use GitHub releases directly to avoid PlatformIO moderation delays. ARDUINO_VERSIONS = { - "dev": (cv.Version(1, 12, 1), "https://github.com/libretiny-eu/libretiny.git"), + "dev": (cv.Version(1, 13, 0), "https://github.com/libretiny-eu/libretiny.git"), "latest": ( - cv.Version(1, 12, 1), - "https://github.com/libretiny-eu/libretiny.git#v1.12.1", + cv.Version(1, 13, 0), + "https://github.com/libretiny-eu/libretiny.git#v1.13.0", ), "recommended": ( - cv.Version(1, 12, 1), - "https://github.com/libretiny-eu/libretiny.git#v1.12.1", + cv.Version(1, 13, 0), + "https://github.com/libretiny-eu/libretiny.git#v1.13.0", ), } diff --git a/esphome/components/libretiny/generate_components.py b/esphome/components/libretiny/generate_components.py index 6ca16f277f..791a2659a9 100644 --- a/esphome/components/libretiny/generate_components.py +++ b/esphome/components/libretiny/generate_components.py @@ -359,7 +359,9 @@ if __name__ == "__main__": check_base_code(BASE_CODE_INIT) # list all boards from ltchiptool components_dir = Path(__file__).parent.parent - boards = [Board(b) for b in Board.get_list()] + # Board.get_list() returns glob (filesystem) order, which is non-deterministic + # and produces noisy diffs on regeneration; sort by board id for stable output. + boards = sorted((Board(b) for b in Board.get_list()), key=lambda b: b.name) # keep track of all supported root- and chip-families components = set() families = {} diff --git a/esphome/components/ln882x/boards.py b/esphome/components/ln882x/boards.py index df44419ed2..bcd3ffbd9e 100644 --- a/esphome/components/ln882x/boards.py +++ b/esphome/components/ln882x/boards.py @@ -15,26 +15,38 @@ Any manual changes WILL BE LOST on regeneration. from esphome.components.libretiny.const import FAMILY_LN882H LN882X_BOARDS = { - "generic-ln882hki": { - "name": "Generic - LN882HKI", + "generic-ln882h": { + "name": "Generic - LN882H", "family": FAMILY_LN882H, }, - "wb02a": { - "name": "WB02A Wi-Fi/BLE Module", - "family": FAMILY_LN882H, - }, - "wl2s": { - "name": "WL2S Wi-Fi/BLE Module", + "generic-ln882h-tuya": { + "name": "Generic - LN882H (Tuya)", "family": FAMILY_LN882H, }, "ln-02": { "name": "LN-02 Wi-Fi/BLE Module", "family": FAMILY_LN882H, }, + "ln-cb3s-v1.0": { + "name": "LN-CB3S V1.0", + "family": FAMILY_LN882H, + }, + "wb02a": { + "name": "WB02A Wi-Fi/BLE Module", + "family": FAMILY_LN882H, + }, + "wl2h-u": { + "name": "WL2H-U Wi-Fi/BLE Module", + "family": FAMILY_LN882H, + }, + "wl2s": { + "name": "WL2S Wi-Fi/BLE Module", + "family": FAMILY_LN882H, + }, } LN882X_BOARD_PINS = { - "generic-ln882hki": { + "generic-ln882h": { "WIRE0_SCL_0": 0, "WIRE0_SCL_1": 1, "WIRE0_SCL_2": 2, @@ -153,27 +165,292 @@ LN882X_BOARD_PINS = { "A6": 20, "A7": 21, }, + "generic-ln882h-tuya": { + "WIRE0_SCL_0": 0, + "WIRE0_SCL_1": 1, + "WIRE0_SCL_2": 2, + "WIRE0_SCL_3": 3, + "WIRE0_SCL_4": 4, + "WIRE0_SCL_5": 5, + "WIRE0_SCL_6": 6, + "WIRE0_SCL_7": 7, + "WIRE0_SCL_8": 8, + "WIRE0_SCL_9": 9, + "WIRE0_SCL_10": 10, + "WIRE0_SCL_11": 11, + "WIRE0_SCL_12": 12, + "WIRE0_SCL_13": 19, + "WIRE0_SCL_14": 20, + "WIRE0_SCL_15": 21, + "WIRE0_SCL_16": 22, + "WIRE0_SCL_17": 23, + "WIRE0_SCL_18": 24, + "WIRE0_SCL_19": 25, + "WIRE0_SDA_0": 0, + "WIRE0_SDA_1": 1, + "WIRE0_SDA_2": 2, + "WIRE0_SDA_3": 3, + "WIRE0_SDA_4": 4, + "WIRE0_SDA_5": 5, + "WIRE0_SDA_6": 6, + "WIRE0_SDA_7": 7, + "WIRE0_SDA_8": 8, + "WIRE0_SDA_9": 9, + "WIRE0_SDA_10": 10, + "WIRE0_SDA_11": 11, + "WIRE0_SDA_12": 12, + "WIRE0_SDA_13": 19, + "WIRE0_SDA_14": 20, + "WIRE0_SDA_15": 21, + "WIRE0_SDA_16": 22, + "WIRE0_SDA_17": 23, + "WIRE0_SDA_18": 24, + "WIRE0_SDA_19": 25, + "SERIAL0_RX": 3, + "SERIAL0_TX": 2, + "SERIAL1_RX": 24, + "SERIAL1_TX": 25, + "ADC2": 0, + "ADC3": 1, + "ADC4": 4, + "ADC5": 19, + "ADC6": 20, + "ADC7": 21, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA05": 5, + "PA5": 5, + "PA06": 6, + "PA6": 6, + "PA07": 7, + "PA7": 7, + "PA08": 8, + "PA8": 8, + "PA09": 9, + "PA9": 9, + "PA10": 10, + "PA11": 11, + "PA12": 12, + "PB03": 19, + "PB3": 19, + "PB04": 20, + "PB4": 20, + "PB05": 21, + "PB5": 21, + "PB06": 22, + "PB6": 22, + "PB07": 23, + "PB7": 23, + "PB08": 24, + "PB8": 24, + "PB09": 25, + "PB9": 25, + "RX0": 3, + "RX1": 24, + "TX0": 2, + "TX1": 25, + "D0": 0, + "D1": 1, + "D2": 2, + "D3": 3, + "D4": 4, + "D5": 5, + "D6": 6, + "D7": 7, + "D8": 8, + "D9": 9, + "D10": 10, + "D11": 11, + "D12": 12, + "D13": 19, + "D14": 20, + "D15": 21, + "D16": 22, + "D17": 23, + "D18": 24, + "D19": 25, + "A2": 0, + "A3": 1, + "A4": 4, + "A5": 19, + "A6": 20, + "A7": 21, + }, + "ln-02": { + "WIRE0_SCL_0": 0, + "WIRE0_SCL_1": 1, + "WIRE0_SCL_2": 2, + "WIRE0_SCL_3": 3, + "WIRE0_SCL_4": 9, + "WIRE0_SCL_5": 11, + "WIRE0_SCL_6": 19, + "WIRE0_SCL_7": 24, + "WIRE0_SCL_8": 25, + "WIRE0_SDA_0": 0, + "WIRE0_SDA_1": 1, + "WIRE0_SDA_2": 2, + "WIRE0_SDA_3": 3, + "WIRE0_SDA_4": 9, + "WIRE0_SDA_5": 11, + "WIRE0_SDA_6": 19, + "WIRE0_SDA_7": 24, + "WIRE0_SDA_8": 25, + "SERIAL0_RX": 3, + "SERIAL0_TX": 2, + "SERIAL1_RX": 24, + "SERIAL1_TX": 25, + "ADC2": 0, + "ADC3": 1, + "ADC5": 19, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA09": 9, + "PA9": 9, + "PA11": 11, + "PB03": 19, + "PB3": 19, + "PB08": 24, + "PB8": 24, + "PB09": 25, + "PB9": 25, + "RX0": 3, + "RX1": 24, + "SCL0": 9, + "SDA0": 9, + "TX0": 2, + "TX1": 25, + "D0": 11, + "D1": 19, + "D2": 3, + "D3": 24, + "D4": 2, + "D5": 25, + "D6": 1, + "D7": 0, + "D8": 9, + "A0": 19, + "A1": 1, + "A2": 0, + }, + "ln-cb3s-v1.0": { + "WIRE0_SCL_0": 0, + "WIRE0_SCL_1": 1, + "WIRE0_SCL_2": 2, + "WIRE0_SCL_3": 3, + "WIRE0_SCL_4": 4, + "WIRE0_SCL_5": 5, + "WIRE0_SCL_6": 6, + "WIRE0_SCL_7": 9, + "WIRE0_SCL_8": 11, + "WIRE0_SCL_9": 20, + "WIRE0_SCL_10": 21, + "WIRE0_SCL_11": 22, + "WIRE0_SCL_12": 25, + "WIRE0_SDA_0": 0, + "WIRE0_SDA_1": 1, + "WIRE0_SDA_2": 2, + "WIRE0_SDA_3": 3, + "WIRE0_SDA_4": 4, + "WIRE0_SDA_5": 5, + "WIRE0_SDA_6": 6, + "WIRE0_SDA_7": 9, + "WIRE0_SDA_8": 11, + "WIRE0_SDA_9": 20, + "WIRE0_SDA_10": 21, + "WIRE0_SDA_11": 22, + "WIRE0_SDA_12": 25, + "SERIAL0_RX": 3, + "SERIAL0_TX": 2, + "SERIAL1_TX": 25, + "ADC2": 0, + "ADC3": 1, + "ADC4": 4, + "ADC6": 20, + "ADC7": 21, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA05": 5, + "PA5": 5, + "PA06": 6, + "PA6": 6, + "PA09": 9, + "PA9": 9, + "PA11": 11, + "PB04": 20, + "PB4": 20, + "PB05": 21, + "PB5": 21, + "PB06": 22, + "PB6": 22, + "PB09": 25, + "PB9": 25, + "RX0": 3, + "TX0": 2, + "TX1": 25, + "D0": 0, + "D1": 1, + "D2": 4, + "D3": 5, + "D4": 6, + "D5": 20, + "D6": 25, + "D7": 9, + "D8": 21, + "D9": 22, + "D10": 3, + "D11": 2, + "D12": 11, + "A0": 0, + "A1": 1, + "A2": 4, + "A3": 20, + "A4": 21, + }, "wb02a": { "WIRE0_SCL_0": 1, "WIRE0_SCL_1": 2, "WIRE0_SCL_2": 3, "WIRE0_SCL_3": 4, "WIRE0_SCL_4": 5, - "WIRE0_SCL_5": 7, - "WIRE0_SCL_6": 9, - "WIRE0_SCL_7": 10, - "WIRE0_SCL_8": 24, - "WIRE0_SCL_9": 25, + "WIRE0_SCL_5": 6, + "WIRE0_SCL_6": 7, + "WIRE0_SCL_7": 9, + "WIRE0_SCL_8": 10, + "WIRE0_SCL_9": 24, + "WIRE0_SCL_10": 25, "WIRE0_SDA_0": 1, "WIRE0_SDA_1": 2, "WIRE0_SDA_2": 3, "WIRE0_SDA_3": 4, "WIRE0_SDA_4": 5, - "WIRE0_SDA_5": 7, - "WIRE0_SDA_6": 9, - "WIRE0_SDA_7": 10, - "WIRE0_SDA_8": 24, - "WIRE0_SDA_9": 25, + "WIRE0_SDA_5": 6, + "WIRE0_SDA_6": 7, + "WIRE0_SDA_7": 9, + "WIRE0_SDA_8": 10, + "WIRE0_SDA_9": 24, + "WIRE0_SDA_10": 25, "SERIAL0_RX": 3, "SERIAL0_TX": 2, "SERIAL1_RX": 24, @@ -190,6 +467,8 @@ LN882X_BOARD_PINS = { "PA4": 4, "PA05": 5, "PA5": 5, + "PA06": 6, + "PA6": 6, "PA07": 7, "PA7": 7, "PA09": 9, @@ -206,18 +485,128 @@ LN882X_BOARD_PINS = { "TX0": 2, "TX1": 25, "D0": 7, - "D1": 5, + "D1": 6, "D2": 3, "D3": 10, "D4": 2, "D5": 1, "D6": 4, - "D7": 9, - "D8": 24, - "D9": 25, + "D7": 5, + "D8": 9, + "D9": 24, + "D10": 25, "A0": 1, "A1": 4, }, + "wl2h-u": { + "WIRE0_SCL_0": 0, + "WIRE0_SCL_1": 1, + "WIRE0_SCL_2": 2, + "WIRE0_SCL_3": 3, + "WIRE0_SCL_4": 4, + "WIRE0_SCL_5": 5, + "WIRE0_SCL_6": 6, + "WIRE0_SCL_7": 7, + "WIRE0_SCL_8": 10, + "WIRE0_SCL_9": 11, + "WIRE0_SCL_10": 12, + "WIRE0_SCL_11": 19, + "WIRE0_SCL_12": 20, + "WIRE0_SCL_13": 21, + "WIRE0_SCL_14": 22, + "WIRE0_SCL_15": 23, + "WIRE0_SCL_16": 24, + "WIRE0_SCL_17": 25, + "WIRE0_SDA_0": 0, + "WIRE0_SDA_1": 1, + "WIRE0_SDA_2": 2, + "WIRE0_SDA_3": 3, + "WIRE0_SDA_4": 4, + "WIRE0_SDA_5": 5, + "WIRE0_SDA_6": 6, + "WIRE0_SDA_7": 7, + "WIRE0_SDA_8": 10, + "WIRE0_SDA_9": 11, + "WIRE0_SDA_10": 12, + "WIRE0_SDA_11": 19, + "WIRE0_SDA_12": 20, + "WIRE0_SDA_13": 21, + "WIRE0_SDA_14": 22, + "WIRE0_SDA_15": 23, + "WIRE0_SDA_16": 24, + "WIRE0_SDA_17": 25, + "SERIAL0_RX": 3, + "SERIAL0_TX": 2, + "SERIAL1_RX": 24, + "SERIAL1_TX": 25, + "ADC2": 0, + "ADC3": 1, + "ADC4": 4, + "ADC5": 19, + "ADC6": 20, + "ADC7": 21, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA05": 5, + "PA5": 5, + "PA06": 6, + "PA6": 6, + "PA07": 7, + "PA7": 7, + "PA10": 10, + "PA11": 11, + "PA12": 12, + "PB03": 19, + "PB3": 19, + "PB04": 20, + "PB4": 20, + "PB05": 21, + "PB5": 21, + "PB06": 22, + "PB6": 22, + "PB07": 23, + "PB7": 23, + "PB08": 24, + "PB8": 24, + "PB09": 25, + "PB9": 25, + "RX0": 3, + "RX1": 24, + "TX0": 2, + "TX1": 25, + "D0": 5, + "D1": 6, + "D2": 4, + "D3": 1, + "D4": 0, + "D5": 24, + "D6": 25, + "D7": 7, + "D8": 10, + "D9": 11, + "D10": 12, + "D11": 19, + "D12": 2, + "D13": 3, + "D14": 20, + "D15": 21, + "D16": 22, + "D17": 23, + "A0": 4, + "A1": 1, + "A2": 0, + "A3": 19, + "A4": 20, + "A5": 21, + }, "wl2s": { "WIRE0_SCL_0": 0, "WIRE0_SCL_1": 1, @@ -298,68 +687,6 @@ LN882X_BOARD_PINS = { "A1": 19, "A2": 1, }, - "ln-02": { - "WIRE0_SCL_0": 0, - "WIRE0_SCL_1": 1, - "WIRE0_SCL_2": 2, - "WIRE0_SCL_3": 3, - "WIRE0_SCL_4": 9, - "WIRE0_SCL_5": 11, - "WIRE0_SCL_6": 19, - "WIRE0_SCL_7": 24, - "WIRE0_SCL_8": 25, - "WIRE0_SDA_0": 0, - "WIRE0_SDA_1": 1, - "WIRE0_SDA_2": 2, - "WIRE0_SDA_3": 3, - "WIRE0_SDA_4": 9, - "WIRE0_SDA_5": 11, - "WIRE0_SDA_6": 19, - "WIRE0_SDA_7": 24, - "WIRE0_SDA_8": 25, - "SERIAL0_RX": 3, - "SERIAL0_TX": 2, - "SERIAL1_RX": 24, - "SERIAL1_TX": 25, - "ADC2": 0, - "ADC3": 1, - "ADC5": 19, - "PA00": 0, - "PA0": 0, - "PA01": 1, - "PA1": 1, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA09": 9, - "PA9": 9, - "PA11": 11, - "PB03": 19, - "PB3": 19, - "PB08": 24, - "PB8": 24, - "PB09": 25, - "PB9": 25, - "RX0": 3, - "RX1": 24, - "SCL0": 9, - "SDA0": 9, - "TX0": 2, - "TX1": 25, - "D0": 11, - "D1": 19, - "D2": 3, - "D3": 24, - "D4": 2, - "D5": 25, - "D6": 1, - "D7": 0, - "D8": 9, - "A0": 19, - "A1": 1, - "A2": 0, - }, } BOARDS = LN882X_BOARDS diff --git a/esphome/components/rtl87xx/boards.py b/esphome/components/rtl87xx/boards.py index 3a5ee853f2..23d220a91e 100644 --- a/esphome/components/rtl87xx/boards.py +++ b/esphome/components/rtl87xx/boards.py @@ -15,40 +15,24 @@ Any manual changes WILL BE LOST on regeneration. from esphome.components.libretiny.const import FAMILY_RTL8710B, FAMILY_RTL8720C RTL87XX_BOARDS = { - "wr3le": { - "name": "WR3LE Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, - "wr2": { - "name": "WR2 Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, - "wbr3": { - "name": "WBR3 Wi-Fi Module", - "family": FAMILY_RTL8720C, - }, - "generic-rtl8710bn-2mb-468k": { - "name": "Generic - RTL8710BN (2M/468k)", - "family": FAMILY_RTL8710B, - }, - "wr1e": { - "name": "WR1E Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, - "wr3e": { - "name": "WR3E Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, - "wr3": { - "name": "WR3 Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, "afw121t": { "name": "AFW121T", "family": FAMILY_RTL8710B, }, - "wr3n": { - "name": "WR3N Wi-Fi Module", + "bw12": { + "name": "BW12", + "family": FAMILY_RTL8710B, + }, + "bw15": { + "name": "BW15", + "family": FAMILY_RTL8720C, + }, + "cr3l": { + "name": "CR3L Wi-Fi Module", + "family": FAMILY_RTL8720C, + }, + "generic-rtl8710bn-2mb-468k": { + "name": "Generic - RTL8710BN (2M/468k)", "family": FAMILY_RTL8710B, }, "generic-rtl8710bn-2mb-788k": { @@ -59,42 +43,6 @@ RTL87XX_BOARDS = { "name": "Generic - RTL8710BX (4M/980k)", "family": FAMILY_RTL8710B, }, - "wr2e": { - "name": "WR2E Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, - "t112-v1.1": { - "name": "T112_V1.1", - "family": FAMILY_RTL8710B, - }, - "wr3l": { - "name": "WR3L Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, - "wbru": { - "name": "WBRU Wi-Fi Module", - "family": FAMILY_RTL8720C, - }, - "wr2le": { - "name": "WR2LE Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, - "bw15": { - "name": "BW15", - "family": FAMILY_RTL8720C, - }, - "t103-v1.0": { - "name": "T103_V1.0", - "family": FAMILY_RTL8710B, - }, - "cr3l": { - "name": "CR3L Wi-Fi Module", - "family": FAMILY_RTL8720C, - }, - "generic-rtl8720cm-4mb-1712k": { - "name": "Generic - RTL8720CM (4M/1712k)", - "family": FAMILY_RTL8720C, - }, "generic-rtl8720cf-2mb-896k": { "name": "Generic - RTL8720CF (2M/896k)", "family": FAMILY_RTL8720C, @@ -103,521 +51,81 @@ RTL87XX_BOARDS = { "name": "Generic - RTL8720CF (2M/992k)", "family": FAMILY_RTL8720C, }, - "bw12": { - "name": "BW12", - "family": FAMILY_RTL8710B, + "generic-rtl8720cm-4mb-1712k": { + "name": "Generic - RTL8720CM (4M/1712k)", + "family": FAMILY_RTL8720C, }, "t102-v1.1": { "name": "T102_V1.1", "family": FAMILY_RTL8710B, }, - "wr2l": { - "name": "WR2L Wi-Fi Module", + "t103-v1.0": { + "name": "T103_V1.0", + "family": FAMILY_RTL8710B, + }, + "t112-v1.1": { + "name": "T112_V1.1", "family": FAMILY_RTL8710B, }, "wbr1": { "name": "WBR1 Wi-Fi Module", "family": FAMILY_RTL8720C, }, + "wbr3": { + "name": "WBR3 Wi-Fi Module", + "family": FAMILY_RTL8720C, + }, + "wbru": { + "name": "WBRU Wi-Fi Module", + "family": FAMILY_RTL8720C, + }, "wr1": { "name": "WR1 Wi-Fi Module", "family": FAMILY_RTL8710B, }, + "wr1e": { + "name": "WR1E Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr2": { + "name": "WR2 Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr2e": { + "name": "WR2E Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr2l": { + "name": "WR2L Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr2le": { + "name": "WR2LE Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr3": { + "name": "WR3 Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr3e": { + "name": "WR3E Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr3l": { + "name": "WR3L Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr3le": { + "name": "WR3LE Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr3n": { + "name": "WR3N Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, } RTL87XX_BOARD_PINS = { - "wr3le": { - "SPI0_CS": 19, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "ADC2": 41, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 5, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 29, - "D1": 14, - "D2": 15, - "D3": 22, - "D4": 0, - "D5": 30, - "D6": 19, - "D7": 5, - "D8": 12, - "D9": 18, - "D10": 23, - "A0": 19, - "A1": 41, - }, - "wr2": { - "WIRE0_SCL": 29, - "WIRE0_SDA": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC2": 41, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 29, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL0": 29, - "SCL1": 18, - "SDA0": 30, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 12, - "D1": 0, - "D2": 5, - "D4": 18, - "D5": 23, - "D6": 14, - "D7": 15, - "D8": 30, - "D9": 29, - "A1": 41, - }, - "wbr3": { - "WIRE0_SCL_0": 2, - "WIRE0_SCL_1": 11, - "WIRE0_SCL_2": 15, - "WIRE0_SCL_3": 19, - "WIRE0_SDA_0": 3, - "WIRE0_SDA_1": 12, - "WIRE0_SDA_2": 16, - "SERIAL0_RX_0": 12, - "SERIAL0_RX_1": 13, - "SERIAL0_TX_0": 11, - "SERIAL0_TX_1": 14, - "SERIAL1_CTS": 4, - "SERIAL1_RX_0": 0, - "SERIAL1_RX_1": 2, - "SERIAL1_TX_0": 1, - "SERIAL1_TX_1": 3, - "SERIAL2_CTS": 19, - "SERIAL2_RX": 15, - "SERIAL2_TX": 16, - "CS0": 15, - "CTS1": 4, - "CTS2": 19, - "PA00": 0, - "PA0": 0, - "PA01": 1, - "PA1": 1, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA04": 4, - "PA4": 4, - "PA07": 7, - "PA7": 7, - "PA11": 11, - "PA12": 12, - "PA13": 13, - "PA14": 14, - "PA15": 15, - "PA16": 16, - "PA17": 17, - "PA18": 18, - "PA19": 19, - "PWM5": 17, - "PWM6": 18, - "RX2": 15, - "SDA0": 16, - "TX2": 16, - "D0": 7, - "D1": 11, - "D2": 2, - "D3": 3, - "D4": 4, - "D5": 12, - "D6": 16, - "D7": 17, - "D8": 18, - "D9": 19, - "D10": 13, - "D11": 14, - "D12": 15, - "D13": 0, - "D14": 1, - }, - "generic-rtl8710bn-2mb-468k": { - "SPI0_CS": 19, - "SPI0_FCS": 6, - "SPI0_FD0": 9, - "SPI0_FD1": 7, - "SPI0_FD2": 8, - "SPI0_FD3": 11, - "SPI0_FSCK": 10, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "ADC2": 41, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "FCS": 6, - "FD0": 9, - "FD1": 7, - "FD2": 8, - "FD3": 11, - "FSCK": 10, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA06": 6, - "PA6": 6, - "PA07": 7, - "PA7": 7, - "PA08": 8, - "PA8": 8, - "PA09": 9, - "PA9": 9, - "PA10": 10, - "PA11": 11, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 30, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 0, - "D1": 5, - "D2": 6, - "D3": 7, - "D4": 8, - "D5": 9, - "D6": 10, - "D7": 11, - "D8": 12, - "D9": 14, - "D10": 15, - "D11": 18, - "D12": 19, - "D13": 22, - "D14": 23, - "D15": 29, - "D16": 30, - "A0": 19, - "A1": 41, - }, - "wr1e": { - "SPI0_CS": 19, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "ADC2": 41, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM3": 12, - "PWM4": 29, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 23, - "D1": 18, - "D2": 14, - "D3": 15, - "D4": 30, - "D5": 12, - "D6": 5, - "D7": 29, - "D8": 19, - "D9": 22, - "A0": 19, - "A1": 41, - }, - "wr3e": { - "SPI0_CS": 19, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "ADC2": 41, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 5, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 29, - "D1": 14, - "D2": 15, - "D3": 22, - "D4": 0, - "D5": 30, - "D6": 19, - "D7": 5, - "D8": 12, - "D9": 18, - "D10": 23, - "A0": 19, - "A1": 41, - }, - "wr3": { - "SPI0_CS": 19, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "ADC2": 41, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 5, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 22, - "D1": 19, - "D2": 14, - "D3": 15, - "D4": 0, - "D5": 29, - "D6": 30, - "D7": 5, - "D8": 12, - "D9": 18, - "D10": 23, - "A0": 19, - "A1": 41, - }, "afw121t": { "SPI0_CS": 19, "SPI0_MISO": 22, @@ -686,16 +194,33 @@ RTL87XX_BOARD_PINS = { "D9": 23, "D10": 30, }, - "wr3n": { - "WIRE0_SCL": 29, - "WIRE0_SDA": 30, + "bw12": { + "SPI0_CS": 19, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, "WIRE1_SCL": 18, "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, "SERIAL0_RX": 18, "SERIAL0_TX": 23, "SERIAL2_RX": 29, "SERIAL2_TX": 30, - "ADC2": 41, + "ADC1": 19, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "MISO0": 22, + "MISO1": 22, "MOSI0": 23, "MOSI1": 23, "PA00": 0, @@ -706,32 +231,269 @@ RTL87XX_BOARD_PINS = { "PA14": 14, "PA15": 15, "PA18": 18, + "PA19": 19, + "PA22": 22, "PA23": 23, "PA29": 29, "PA30": 30, "PWM1": 15, "PWM2": 0, "PWM3": 12, - "PWM4": 5, + "PWM4": 30, + "PWM5": 22, + "RTS0": 22, "RX0": 18, "RX2": 29, "SCK0": 18, "SCK1": 18, - "SCL0": 29, "SCL1": 18, - "SDA0": 30, "SDA1": 23, "TX0": 23, "TX2": 30, - "D0": 29, - "D1": 14, - "D2": 15, - "D3": 0, - "D4": 30, - "D5": 5, - "D6": 12, + "D0": 5, + "D1": 29, + "D2": 0, + "D3": 19, + "D4": 22, + "D5": 30, + "D6": 14, + "D7": 12, + "D8": 15, + "D9": 18, + "D10": 23, + "A0": 19, + }, + "bw15": { + "SPI0_CS_0": 2, + "SPI0_CS_1": 15, + "SPI0_MISO": 20, + "SPI0_MOSI_0": 4, + "SPI0_MOSI_1": 19, + "SPI0_SCK_0": 3, + "SPI0_SCK_1": 16, + "WIRE0_SCL_0": 2, + "WIRE0_SCL_1": 15, + "WIRE0_SCL_2": 19, + "WIRE0_SDA_0": 3, + "WIRE0_SDA_1": 16, + "WIRE0_SDA_2": 20, + "SERIAL0_RX": 13, + "SERIAL0_TX": 14, + "SERIAL1_CTS": 4, + "SERIAL1_RX_0": 0, + "SERIAL1_RX_1": 2, + "SERIAL1_TX_0": 1, + "SERIAL1_TX_1": 3, + "SERIAL2_CTS": 19, + "SERIAL2_RTS": 20, + "SERIAL2_RX": 15, + "SERIAL2_TX": 16, + "CTS1": 4, + "CTS2": 19, + "MISO0": 20, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA13": 13, + "PA14": 14, + "PA15": 15, + "PA16": 16, + "PA17": 17, + "PA18": 18, + "PA19": 19, + "PA20": 20, + "PWM1": 1, + "PWM5": 17, + "PWM6": 18, + "RTS2": 20, + "RX0": 13, + "RX2": 15, + "SCL0": 19, + "SDA0": 3, + "TX0": 14, + "TX2": 16, + "D0": 17, + "D1": 18, + "D2": 2, + "D3": 15, + "D4": 4, + "D5": 19, + "D6": 20, + "D7": 16, + "D8": 0, + "D9": 3, + "D10": 1, + "D11": 13, + "D12": 14, + }, + "cr3l": { + "SPI0_CS_0": 2, + "SPI0_CS_1": 15, + "SPI0_MISO": 20, + "SPI0_MOSI_0": 4, + "SPI0_MOSI_1": 19, + "SPI0_SCK_0": 3, + "SPI0_SCK_1": 16, + "WIRE0_SCL_0": 2, + "WIRE0_SCL_1": 15, + "WIRE0_SCL_2": 19, + "WIRE0_SDA_0": 3, + "WIRE0_SDA_1": 16, + "WIRE0_SDA_2": 20, + "SERIAL0_RX": 13, + "SERIAL0_TX": 14, + "SERIAL1_CTS": 4, + "SERIAL1_RX": 2, + "SERIAL1_TX": 3, + "SERIAL2_CTS": 19, + "SERIAL2_RTS": 20, + "SERIAL2_RX": 15, + "SERIAL2_TX": 16, + "CTS1": 4, + "CTS2": 19, + "MISO0": 20, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA13": 13, + "PA14": 14, + "PA15": 15, + "PA16": 16, + "PA17": 17, + "PA18": 18, + "PA19": 19, + "PA20": 20, + "PWM0": 20, + "PWM5": 17, + "PWM6": 18, + "RTS2": 20, + "RX0": 13, + "RX1": 2, + "RX2": 15, + "SCL0": 19, + "SDA0": 16, + "TX0": 14, + "TX1": 3, + "TX2": 16, + "D0": 20, + "D1": 2, + "D2": 3, + "D3": 4, + "D4": 15, + "D5": 16, + "D6": 17, "D7": 18, - "D8": 23, + "D8": 19, + "D9": 13, + "D10": 14, + }, + "generic-rtl8710bn-2mb-468k": { + "SPI0_CS": 19, + "SPI0_FCS": 6, + "SPI0_FD0": 9, + "SPI0_FD1": 7, + "SPI0_FD2": 8, + "SPI0_FD3": 11, + "SPI0_FSCK": 10, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC1": 19, + "ADC2": 41, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "FCS": 6, + "FD0": 9, + "FD1": 7, + "FD2": 8, + "FD3": 11, + "FSCK": 10, + "MISO0": 22, + "MISO1": 22, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA06": 6, + "PA6": 6, + "PA07": 7, + "PA7": 7, + "PA08": 8, + "PA8": 8, + "PA09": 9, + "PA9": 9, + "PA10": 10, + "PA11": 11, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA19": 19, + "PA22": 22, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 30, + "PWM5": 22, + "RTS0": 22, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL1": 18, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 0, + "D1": 5, + "D2": 6, + "D3": 7, + "D4": 8, + "D5": 9, + "D6": 10, + "D7": 11, + "D8": 12, + "D9": 14, + "D10": 15, + "D11": 18, + "D12": 19, + "D13": 22, + "D14": 23, + "D15": 29, + "D16": 30, + "A0": 19, "A1": 41, }, "generic-rtl8710bn-2mb-788k": { @@ -930,13 +692,363 @@ RTL87XX_BOARD_PINS = { "D16": 30, "A0": 19, }, - "wr2e": { + "generic-rtl8720cf-2mb-896k": { + "SPI0_CS_0": 2, + "SPI0_CS_1": 7, + "SPI0_CS_2": 15, + "SPI0_MISO_0": 10, + "SPI0_MISO_1": 20, + "SPI0_MOSI_0": 4, + "SPI0_MOSI_1": 9, + "SPI0_MOSI_2": 19, + "SPI0_SCK_0": 3, + "SPI0_SCK_1": 8, + "SPI0_SCK_2": 16, + "WIRE0_SCL_0": 2, + "WIRE0_SCL_1": 11, + "WIRE0_SCL_2": 15, + "WIRE0_SCL_3": 19, + "WIRE0_SDA_0": 3, + "WIRE0_SDA_1": 12, + "WIRE0_SDA_2": 16, + "WIRE0_SDA_3": 20, + "SERIAL0_CTS": 10, + "SERIAL0_RTS": 9, + "SERIAL0_RX_0": 12, + "SERIAL0_RX_1": 13, + "SERIAL0_TX_0": 11, + "SERIAL0_TX_1": 14, + "SERIAL1_CTS": 4, + "SERIAL1_RX_0": 0, + "SERIAL1_RX_1": 2, + "SERIAL1_TX_0": 1, + "SERIAL1_TX_1": 3, + "SERIAL2_CTS": 19, + "SERIAL2_RTS": 20, + "SERIAL2_RX": 15, + "SERIAL2_TX": 16, + "CS0": 15, + "CTS0": 10, + "CTS1": 4, + "CTS2": 19, + "MOSI0": 19, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA07": 7, + "PA7": 7, + "PA08": 8, + "PA8": 8, + "PA09": 9, + "PA9": 9, + "PA10": 10, + "PA11": 11, + "PA12": 12, + "PA13": 13, + "PA14": 14, + "PA15": 15, + "PA16": 16, + "PA17": 17, + "PA18": 18, + "PA19": 19, + "PA20": 20, + "PA23": 23, + "PWM0": 20, + "PWM5": 17, + "PWM6": 18, + "PWM7": 23, + "RTS0": 9, + "RTS2": 20, + "RX2": 15, + "SCK0": 16, + "TX2": 16, + "D0": 0, + "D1": 1, + "D2": 2, + "D3": 3, + "D4": 4, + "D5": 7, + "D6": 8, + "D7": 9, + "D8": 10, + "D9": 11, + "D10": 12, + "D11": 13, + "D12": 14, + "D13": 15, + "D14": 16, + "D15": 17, + "D16": 18, + "D17": 19, + "D18": 20, + "D19": 23, + }, + "generic-rtl8720cf-2mb-992k": { + "SPI0_CS_0": 2, + "SPI0_CS_1": 7, + "SPI0_CS_2": 15, + "SPI0_MISO_0": 10, + "SPI0_MISO_1": 20, + "SPI0_MOSI_0": 4, + "SPI0_MOSI_1": 9, + "SPI0_MOSI_2": 19, + "SPI0_SCK_0": 3, + "SPI0_SCK_1": 8, + "SPI0_SCK_2": 16, + "WIRE0_SCL_0": 2, + "WIRE0_SCL_1": 11, + "WIRE0_SCL_2": 15, + "WIRE0_SCL_3": 19, + "WIRE0_SDA_0": 3, + "WIRE0_SDA_1": 12, + "WIRE0_SDA_2": 16, + "WIRE0_SDA_3": 20, + "SERIAL0_CTS": 10, + "SERIAL0_RTS": 9, + "SERIAL0_RX_0": 12, + "SERIAL0_RX_1": 13, + "SERIAL0_TX_0": 11, + "SERIAL0_TX_1": 14, + "SERIAL1_CTS": 4, + "SERIAL1_RX_0": 0, + "SERIAL1_RX_1": 2, + "SERIAL1_TX_0": 1, + "SERIAL1_TX_1": 3, + "SERIAL2_CTS": 19, + "SERIAL2_RTS": 20, + "SERIAL2_RX": 15, + "SERIAL2_TX": 16, + "CS0": 15, + "CTS0": 10, + "CTS1": 4, + "CTS2": 19, + "MOSI0": 19, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA07": 7, + "PA7": 7, + "PA08": 8, + "PA8": 8, + "PA09": 9, + "PA9": 9, + "PA10": 10, + "PA11": 11, + "PA12": 12, + "PA13": 13, + "PA14": 14, + "PA15": 15, + "PA16": 16, + "PA17": 17, + "PA18": 18, + "PA19": 19, + "PA20": 20, + "PA23": 23, + "PWM0": 20, + "PWM5": 17, + "PWM6": 18, + "PWM7": 23, + "RTS0": 9, + "RTS2": 20, + "RX2": 15, + "SCK0": 16, + "TX2": 16, + "D0": 0, + "D1": 1, + "D2": 2, + "D3": 3, + "D4": 4, + "D5": 7, + "D6": 8, + "D7": 9, + "D8": 10, + "D9": 11, + "D10": 12, + "D11": 13, + "D12": 14, + "D13": 15, + "D14": 16, + "D15": 17, + "D16": 18, + "D17": 19, + "D18": 20, + "D19": 23, + }, + "generic-rtl8720cm-4mb-1712k": { + "SPI0_CS_0": 2, + "SPI0_CS_1": 7, + "SPI0_CS_2": 15, + "SPI0_MISO_0": 10, + "SPI0_MISO_1": 20, + "SPI0_MOSI_0": 4, + "SPI0_MOSI_1": 9, + "SPI0_MOSI_2": 19, + "SPI0_SCK_0": 3, + "SPI0_SCK_1": 8, + "SPI0_SCK_2": 16, + "WIRE0_SCL_0": 2, + "WIRE0_SCL_1": 11, + "WIRE0_SCL_2": 15, + "WIRE0_SCL_3": 19, + "WIRE0_SDA_0": 3, + "WIRE0_SDA_1": 12, + "WIRE0_SDA_2": 16, + "WIRE0_SDA_3": 20, + "SERIAL0_CTS": 10, + "SERIAL0_RTS": 9, + "SERIAL0_RX_0": 12, + "SERIAL0_RX_1": 13, + "SERIAL0_TX_0": 11, + "SERIAL0_TX_1": 14, + "SERIAL1_CTS": 4, + "SERIAL1_RX_0": 0, + "SERIAL1_RX_1": 2, + "SERIAL1_TX_0": 1, + "SERIAL1_TX_1": 3, + "SERIAL2_CTS": 19, + "SERIAL2_RTS": 20, + "SERIAL2_RX": 15, + "SERIAL2_TX": 16, + "CS0": 15, + "CTS0": 10, + "CTS1": 4, + "CTS2": 19, + "MOSI0": 19, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA07": 7, + "PA7": 7, + "PA08": 8, + "PA8": 8, + "PA09": 9, + "PA9": 9, + "PA10": 10, + "PA11": 11, + "PA12": 12, + "PA13": 13, + "PA14": 14, + "PA15": 15, + "PA16": 16, + "PA17": 17, + "PA18": 18, + "PA19": 19, + "PA20": 20, + "PA23": 23, + "PWM0": 20, + "PWM5": 17, + "PWM6": 18, + "PWM7": 23, + "RTS0": 9, + "RTS2": 20, + "RX2": 15, + "SCK0": 16, + "TX2": 16, + "D0": 0, + "D1": 1, + "D2": 2, + "D3": 3, + "D4": 4, + "D5": 7, + "D6": 8, + "D7": 9, + "D8": 10, + "D9": 11, + "D10": 12, + "D11": 13, + "D12": 14, + "D13": 15, + "D14": 16, + "D15": 17, + "D16": 18, + "D17": 19, + "D18": 20, + "D19": 23, + }, + "t102-v1.1": { "WIRE0_SCL": 29, + "WIRE0_SDA": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 29, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL0": 29, + "SCL1": 18, + "SDA0": 30, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 12, + "D1": 0, + "D2": 5, + "D3": 30, + "D4": 29, + "D5": 18, + "D6": 23, + "D7": 14, + "D8": 15, + }, + "t103-v1.0": { + "SPI0_CS": 19, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, "WIRE0_SDA_0": 19, "WIRE0_SDA_1": 30, "WIRE1_SCL": 18, "WIRE1_SDA": 23, "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, "SERIAL0_RX": 18, "SERIAL0_TX": 23, "SERIAL2_RX": 29, @@ -946,8 +1058,12 @@ RTL87XX_BOARD_PINS = { "CS0": 19, "CS1": 19, "CTS0": 19, + "MISO0": 22, + "MISO1": 22, "MOSI0": 23, "MOSI1": 23, + "PA00": 0, + "PA0": 0, "PA05": 5, "PA5": 5, "PA12": 12, @@ -955,30 +1071,35 @@ RTL87XX_BOARD_PINS = { "PA15": 15, "PA18": 18, "PA19": 19, + "PA22": 22, "PA23": 23, "PA29": 29, "PA30": 30, "PWM1": 15, + "PWM2": 0, "PWM3": 12, - "PWM4": 29, + "PWM4": 5, + "PWM5": 22, + "RTS0": 22, "RX0": 18, "RX2": 29, "SCK0": 18, "SCK1": 18, - "SCL0": 29, "SCL1": 18, "SDA1": 23, "TX0": 23, "TX2": 30, - "D0": 12, - "D1": 19, - "D2": 5, - "D3": 18, - "D4": 23, - "D5": 14, - "D6": 15, - "D7": 30, - "D8": 29, + "D0": 19, + "D1": 14, + "D2": 15, + "D3": 0, + "D4": 22, + "D5": 29, + "D6": 30, + "D7": 5, + "D8": 12, + "D9": 18, + "D10": 23, "A0": 19, "A1": 41, }, @@ -1051,76 +1172,129 @@ RTL87XX_BOARD_PINS = { "D10": 30, "A0": 19, }, - "wr3l": { - "SPI0_CS": 19, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "ADC2": 41, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, + "wbr1": { + "WIRE0_SCL_0": 2, + "WIRE0_SCL_1": 11, + "WIRE0_SCL_2": 15, + "WIRE0_SDA_0": 3, + "WIRE0_SDA_1": 12, + "WIRE0_SDA_2": 16, + "SERIAL0_RX_0": 12, + "SERIAL0_RX_1": 13, + "SERIAL0_TX_0": 11, + "SERIAL0_TX_1": 14, + "SERIAL1_CTS": 4, + "SERIAL1_RX_0": 0, + "SERIAL1_RX_1": 2, + "SERIAL1_TX_0": 1, + "SERIAL1_TX_1": 3, + "SERIAL2_RX": 15, + "SERIAL2_TX": 16, + "CTS1": 4, + "MOSI0": 4, "PA00": 0, "PA0": 0, - "PA05": 5, - "PA5": 5, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA11": 11, "PA12": 12, + "PA13": 13, "PA14": 14, "PA15": 15, + "PA16": 16, + "PA17": 17, + "PA18": 18, + "PWM5": 17, + "PWM6": 18, + "PWM7": 13, + "RX2": 15, + "SCL0": 15, + "SDA0": 12, + "TX2": 16, + "D0": 14, + "D1": 13, + "D2": 2, + "D3": 3, + "D4": 16, + "D5": 4, + "D6": 11, + "D7": 15, + "D8": 12, + "D9": 17, + "D10": 18, + "D11": 0, + "D12": 1, + }, + "wbr3": { + "WIRE0_SCL_0": 2, + "WIRE0_SCL_1": 11, + "WIRE0_SCL_2": 15, + "WIRE0_SCL_3": 19, + "WIRE0_SDA_0": 3, + "WIRE0_SDA_1": 12, + "WIRE0_SDA_2": 16, + "SERIAL0_RX_0": 12, + "SERIAL0_RX_1": 13, + "SERIAL0_TX_0": 11, + "SERIAL0_TX_1": 14, + "SERIAL1_CTS": 4, + "SERIAL1_RX_0": 0, + "SERIAL1_RX_1": 2, + "SERIAL1_TX_0": 1, + "SERIAL1_TX_1": 3, + "SERIAL2_CTS": 19, + "SERIAL2_RX": 15, + "SERIAL2_TX": 16, + "CS0": 15, + "CTS1": 4, + "CTS2": 19, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA07": 7, + "PA7": 7, + "PA11": 11, + "PA12": 12, + "PA13": 13, + "PA14": 14, + "PA15": 15, + "PA16": 16, + "PA17": 17, "PA18": 18, "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 5, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 22, - "D1": 19, - "D2": 14, - "D3": 15, - "D4": 0, - "D5": 29, - "D6": 30, - "D7": 5, - "D8": 12, - "D9": 18, - "D10": 23, - "A0": 19, - "A1": 41, + "PWM5": 17, + "PWM6": 18, + "RX2": 15, + "SDA0": 16, + "TX2": 16, + "D0": 7, + "D1": 11, + "D2": 2, + "D3": 3, + "D4": 4, + "D5": 12, + "D6": 16, + "D7": 17, + "D8": 18, + "D9": 19, + "D10": 13, + "D11": 14, + "D12": 15, + "D13": 0, + "D14": 1, }, "wbru": { "SPI0_CS_0": 2, @@ -1215,724 +1389,6 @@ RTL87XX_BOARD_PINS = { "D16": 10, "D17": 7, }, - "wr2le": { - "MISO0": 22, - "MISO1": 22, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA22": 22, - "PWM0": 14, - "PWM1": 15, - "PWM3": 12, - "PWM4": 5, - "PWM5": 22, - "RTS0": 22, - "SCL0": 22, - "D0": 15, - "D1": 14, - "D2": 5, - "D3": 22, - "D4": 12, - }, - "bw15": { - "SPI0_CS_0": 2, - "SPI0_CS_1": 15, - "SPI0_MISO": 20, - "SPI0_MOSI_0": 4, - "SPI0_MOSI_1": 19, - "SPI0_SCK_0": 3, - "SPI0_SCK_1": 16, - "WIRE0_SCL_0": 2, - "WIRE0_SCL_1": 15, - "WIRE0_SCL_2": 19, - "WIRE0_SDA_0": 3, - "WIRE0_SDA_1": 16, - "WIRE0_SDA_2": 20, - "SERIAL0_RX": 13, - "SERIAL0_TX": 14, - "SERIAL1_CTS": 4, - "SERIAL1_RX_0": 0, - "SERIAL1_RX_1": 2, - "SERIAL1_TX_0": 1, - "SERIAL1_TX_1": 3, - "SERIAL2_CTS": 19, - "SERIAL2_RTS": 20, - "SERIAL2_RX": 15, - "SERIAL2_TX": 16, - "CTS1": 4, - "CTS2": 19, - "MISO0": 20, - "PA00": 0, - "PA0": 0, - "PA01": 1, - "PA1": 1, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA04": 4, - "PA4": 4, - "PA13": 13, - "PA14": 14, - "PA15": 15, - "PA16": 16, - "PA17": 17, - "PA18": 18, - "PA19": 19, - "PA20": 20, - "PWM1": 1, - "PWM5": 17, - "PWM6": 18, - "RTS2": 20, - "RX0": 13, - "RX2": 15, - "SCL0": 19, - "SDA0": 3, - "TX0": 14, - "TX2": 16, - "D0": 17, - "D1": 18, - "D2": 2, - "D3": 15, - "D4": 4, - "D5": 19, - "D6": 20, - "D7": 16, - "D8": 0, - "D9": 3, - "D10": 1, - "D11": 13, - "D12": 14, - }, - "t103-v1.0": { - "SPI0_CS": 19, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "ADC2": 41, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 5, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 19, - "D1": 14, - "D2": 15, - "D3": 0, - "D4": 22, - "D5": 29, - "D6": 30, - "D7": 5, - "D8": 12, - "D9": 18, - "D10": 23, - "A0": 19, - "A1": 41, - }, - "cr3l": { - "SPI0_CS_0": 2, - "SPI0_CS_1": 15, - "SPI0_MISO": 20, - "SPI0_MOSI_0": 4, - "SPI0_MOSI_1": 19, - "SPI0_SCK_0": 3, - "SPI0_SCK_1": 16, - "WIRE0_SCL_0": 2, - "WIRE0_SCL_1": 15, - "WIRE0_SCL_2": 19, - "WIRE0_SDA_0": 3, - "WIRE0_SDA_1": 16, - "WIRE0_SDA_2": 20, - "SERIAL0_RX": 13, - "SERIAL0_TX": 14, - "SERIAL1_CTS": 4, - "SERIAL1_RX": 2, - "SERIAL1_TX": 3, - "SERIAL2_CTS": 19, - "SERIAL2_RTS": 20, - "SERIAL2_RX": 15, - "SERIAL2_TX": 16, - "CTS1": 4, - "CTS2": 19, - "MISO0": 20, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA04": 4, - "PA4": 4, - "PA13": 13, - "PA14": 14, - "PA15": 15, - "PA16": 16, - "PA17": 17, - "PA18": 18, - "PA19": 19, - "PA20": 20, - "PWM0": 20, - "PWM5": 17, - "PWM6": 18, - "RTS2": 20, - "RX0": 13, - "RX1": 2, - "RX2": 15, - "SCL0": 19, - "SDA0": 16, - "TX0": 14, - "TX1": 3, - "TX2": 16, - "D0": 20, - "D1": 2, - "D2": 3, - "D3": 4, - "D4": 15, - "D5": 16, - "D6": 17, - "D7": 18, - "D8": 19, - "D9": 13, - "D10": 14, - }, - "generic-rtl8720cm-4mb-1712k": { - "SPI0_CS_0": 2, - "SPI0_CS_1": 7, - "SPI0_CS_2": 15, - "SPI0_MISO_0": 10, - "SPI0_MISO_1": 20, - "SPI0_MOSI_0": 4, - "SPI0_MOSI_1": 9, - "SPI0_MOSI_2": 19, - "SPI0_SCK_0": 3, - "SPI0_SCK_1": 8, - "SPI0_SCK_2": 16, - "WIRE0_SCL_0": 2, - "WIRE0_SCL_1": 11, - "WIRE0_SCL_2": 15, - "WIRE0_SCL_3": 19, - "WIRE0_SDA_0": 3, - "WIRE0_SDA_1": 12, - "WIRE0_SDA_2": 16, - "WIRE0_SDA_3": 20, - "SERIAL0_CTS": 10, - "SERIAL0_RTS": 9, - "SERIAL0_RX_0": 12, - "SERIAL0_RX_1": 13, - "SERIAL0_TX_0": 11, - "SERIAL0_TX_1": 14, - "SERIAL1_CTS": 4, - "SERIAL1_RX_0": 0, - "SERIAL1_RX_1": 2, - "SERIAL1_TX_0": 1, - "SERIAL1_TX_1": 3, - "SERIAL2_CTS": 19, - "SERIAL2_RTS": 20, - "SERIAL2_RX": 15, - "SERIAL2_TX": 16, - "CS0": 15, - "CTS0": 10, - "CTS1": 4, - "CTS2": 19, - "MOSI0": 19, - "PA00": 0, - "PA0": 0, - "PA01": 1, - "PA1": 1, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA04": 4, - "PA4": 4, - "PA07": 7, - "PA7": 7, - "PA08": 8, - "PA8": 8, - "PA09": 9, - "PA9": 9, - "PA10": 10, - "PA11": 11, - "PA12": 12, - "PA13": 13, - "PA14": 14, - "PA15": 15, - "PA16": 16, - "PA17": 17, - "PA18": 18, - "PA19": 19, - "PA20": 20, - "PA23": 23, - "PWM0": 20, - "PWM5": 17, - "PWM6": 18, - "PWM7": 23, - "RTS0": 9, - "RTS2": 20, - "RX2": 15, - "SCK0": 16, - "TX2": 16, - "D0": 0, - "D1": 1, - "D2": 2, - "D3": 3, - "D4": 4, - "D5": 7, - "D6": 8, - "D7": 9, - "D8": 10, - "D9": 11, - "D10": 12, - "D11": 13, - "D12": 14, - "D13": 15, - "D14": 16, - "D15": 17, - "D16": 18, - "D17": 19, - "D18": 20, - "D19": 23, - }, - "generic-rtl8720cf-2mb-896k": { - "SPI0_CS_0": 2, - "SPI0_CS_1": 7, - "SPI0_CS_2": 15, - "SPI0_MISO_0": 10, - "SPI0_MISO_1": 20, - "SPI0_MOSI_0": 4, - "SPI0_MOSI_1": 9, - "SPI0_MOSI_2": 19, - "SPI0_SCK_0": 3, - "SPI0_SCK_1": 8, - "SPI0_SCK_2": 16, - "WIRE0_SCL_0": 2, - "WIRE0_SCL_1": 11, - "WIRE0_SCL_2": 15, - "WIRE0_SCL_3": 19, - "WIRE0_SDA_0": 3, - "WIRE0_SDA_1": 12, - "WIRE0_SDA_2": 16, - "WIRE0_SDA_3": 20, - "SERIAL0_CTS": 10, - "SERIAL0_RTS": 9, - "SERIAL0_RX_0": 12, - "SERIAL0_RX_1": 13, - "SERIAL0_TX_0": 11, - "SERIAL0_TX_1": 14, - "SERIAL1_CTS": 4, - "SERIAL1_RX_0": 0, - "SERIAL1_RX_1": 2, - "SERIAL1_TX_0": 1, - "SERIAL1_TX_1": 3, - "SERIAL2_CTS": 19, - "SERIAL2_RTS": 20, - "SERIAL2_RX": 15, - "SERIAL2_TX": 16, - "CS0": 15, - "CTS0": 10, - "CTS1": 4, - "CTS2": 19, - "MOSI0": 19, - "PA00": 0, - "PA0": 0, - "PA01": 1, - "PA1": 1, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA04": 4, - "PA4": 4, - "PA07": 7, - "PA7": 7, - "PA08": 8, - "PA8": 8, - "PA09": 9, - "PA9": 9, - "PA10": 10, - "PA11": 11, - "PA12": 12, - "PA13": 13, - "PA14": 14, - "PA15": 15, - "PA16": 16, - "PA17": 17, - "PA18": 18, - "PA19": 19, - "PA20": 20, - "PA23": 23, - "PWM0": 20, - "PWM5": 17, - "PWM6": 18, - "PWM7": 23, - "RTS0": 9, - "RTS2": 20, - "RX2": 15, - "SCK0": 16, - "TX2": 16, - "D0": 0, - "D1": 1, - "D2": 2, - "D3": 3, - "D4": 4, - "D5": 7, - "D6": 8, - "D7": 9, - "D8": 10, - "D9": 11, - "D10": 12, - "D11": 13, - "D12": 14, - "D13": 15, - "D14": 16, - "D15": 17, - "D16": 18, - "D17": 19, - "D18": 20, - "D19": 23, - }, - "generic-rtl8720cf-2mb-992k": { - "SPI0_CS_0": 2, - "SPI0_CS_1": 7, - "SPI0_CS_2": 15, - "SPI0_MISO_0": 10, - "SPI0_MISO_1": 20, - "SPI0_MOSI_0": 4, - "SPI0_MOSI_1": 9, - "SPI0_MOSI_2": 19, - "SPI0_SCK_0": 3, - "SPI0_SCK_1": 8, - "SPI0_SCK_2": 16, - "WIRE0_SCL_0": 2, - "WIRE0_SCL_1": 11, - "WIRE0_SCL_2": 15, - "WIRE0_SCL_3": 19, - "WIRE0_SDA_0": 3, - "WIRE0_SDA_1": 12, - "WIRE0_SDA_2": 16, - "WIRE0_SDA_3": 20, - "SERIAL0_CTS": 10, - "SERIAL0_RTS": 9, - "SERIAL0_RX_0": 12, - "SERIAL0_RX_1": 13, - "SERIAL0_TX_0": 11, - "SERIAL0_TX_1": 14, - "SERIAL1_CTS": 4, - "SERIAL1_RX_0": 0, - "SERIAL1_RX_1": 2, - "SERIAL1_TX_0": 1, - "SERIAL1_TX_1": 3, - "SERIAL2_CTS": 19, - "SERIAL2_RTS": 20, - "SERIAL2_RX": 15, - "SERIAL2_TX": 16, - "CS0": 15, - "CTS0": 10, - "CTS1": 4, - "CTS2": 19, - "MOSI0": 19, - "PA00": 0, - "PA0": 0, - "PA01": 1, - "PA1": 1, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA04": 4, - "PA4": 4, - "PA07": 7, - "PA7": 7, - "PA08": 8, - "PA8": 8, - "PA09": 9, - "PA9": 9, - "PA10": 10, - "PA11": 11, - "PA12": 12, - "PA13": 13, - "PA14": 14, - "PA15": 15, - "PA16": 16, - "PA17": 17, - "PA18": 18, - "PA19": 19, - "PA20": 20, - "PA23": 23, - "PWM0": 20, - "PWM5": 17, - "PWM6": 18, - "PWM7": 23, - "RTS0": 9, - "RTS2": 20, - "RX2": 15, - "SCK0": 16, - "TX2": 16, - "D0": 0, - "D1": 1, - "D2": 2, - "D3": 3, - "D4": 4, - "D5": 7, - "D6": 8, - "D7": 9, - "D8": 10, - "D9": 11, - "D10": 12, - "D11": 13, - "D12": 14, - "D13": 15, - "D14": 16, - "D15": 17, - "D16": 18, - "D17": 19, - "D18": 20, - "D19": 23, - }, - "bw12": { - "SPI0_CS": 19, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 30, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 5, - "D1": 29, - "D2": 0, - "D3": 19, - "D4": 22, - "D5": 30, - "D6": 14, - "D7": 12, - "D8": 15, - "D9": 18, - "D10": 23, - "A0": 19, - }, - "t102-v1.1": { - "WIRE0_SCL": 29, - "WIRE0_SDA": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 29, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL0": 29, - "SCL1": 18, - "SDA0": 30, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 12, - "D1": 0, - "D2": 5, - "D3": 30, - "D4": 29, - "D5": 18, - "D6": 23, - "D7": 14, - "D8": 15, - }, - "wr2l": { - "ADC1": 19, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA19": 19, - "PWM0": 14, - "PWM1": 15, - "PWM3": 12, - "PWM4": 5, - "SDA0": 19, - "D0": 15, - "D1": 14, - "D2": 5, - "D3": 19, - "D4": 12, - "A0": 19, - }, - "wbr1": { - "WIRE0_SCL_0": 2, - "WIRE0_SCL_1": 11, - "WIRE0_SCL_2": 15, - "WIRE0_SDA_0": 3, - "WIRE0_SDA_1": 12, - "WIRE0_SDA_2": 16, - "SERIAL0_RX_0": 12, - "SERIAL0_RX_1": 13, - "SERIAL0_TX_0": 11, - "SERIAL0_TX_1": 14, - "SERIAL1_CTS": 4, - "SERIAL1_RX_0": 0, - "SERIAL1_RX_1": 2, - "SERIAL1_TX_0": 1, - "SERIAL1_TX_1": 3, - "SERIAL2_RX": 15, - "SERIAL2_TX": 16, - "CTS1": 4, - "MOSI0": 4, - "PA00": 0, - "PA0": 0, - "PA01": 1, - "PA1": 1, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA04": 4, - "PA4": 4, - "PA11": 11, - "PA12": 12, - "PA13": 13, - "PA14": 14, - "PA15": 15, - "PA16": 16, - "PA17": 17, - "PA18": 18, - "PWM5": 17, - "PWM6": 18, - "PWM7": 13, - "RX2": 15, - "SCL0": 15, - "SDA0": 12, - "TX2": 16, - "D0": 14, - "D1": 13, - "D2": 2, - "D3": 3, - "D4": 16, - "D5": 4, - "D6": 11, - "D7": 15, - "D8": 12, - "D9": 17, - "D10": 18, - "D11": 0, - "D12": 1, - }, "wr1": { "SPI0_CS": 19, "SPI0_MISO": 22, @@ -2001,6 +1457,550 @@ RTL87XX_BOARD_PINS = { "A0": 19, "A1": 41, }, + "wr1e": { + "SPI0_CS": 19, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC1": 19, + "ADC2": 41, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "MISO0": 22, + "MISO1": 22, + "MOSI0": 23, + "MOSI1": 23, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA19": 19, + "PA22": 22, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM3": 12, + "PWM4": 29, + "PWM5": 22, + "RTS0": 22, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL1": 18, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 23, + "D1": 18, + "D2": 14, + "D3": 15, + "D4": 30, + "D5": 12, + "D6": 5, + "D7": 29, + "D8": 19, + "D9": 22, + "A0": 19, + "A1": 41, + }, + "wr2": { + "WIRE0_SCL": 29, + "WIRE0_SDA": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC2": 41, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 29, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL0": 29, + "SCL1": 18, + "SDA0": 30, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 12, + "D1": 0, + "D2": 5, + "D4": 18, + "D5": 23, + "D6": 14, + "D7": 15, + "D8": 30, + "D9": 29, + "A1": 41, + }, + "wr2e": { + "WIRE0_SCL": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC1": 19, + "ADC2": 41, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "MOSI0": 23, + "MOSI1": 23, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA19": 19, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM3": 12, + "PWM4": 29, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL0": 29, + "SCL1": 18, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 12, + "D1": 19, + "D2": 5, + "D3": 18, + "D4": 23, + "D5": 14, + "D6": 15, + "D7": 30, + "D8": 29, + "A0": 19, + "A1": 41, + }, + "wr2l": { + "ADC1": 19, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA19": 19, + "PWM0": 14, + "PWM1": 15, + "PWM3": 12, + "PWM4": 5, + "SDA0": 19, + "D0": 15, + "D1": 14, + "D2": 5, + "D3": 19, + "D4": 12, + "A0": 19, + }, + "wr2le": { + "MISO0": 22, + "MISO1": 22, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA22": 22, + "PWM0": 14, + "PWM1": 15, + "PWM3": 12, + "PWM4": 5, + "PWM5": 22, + "RTS0": 22, + "SCL0": 22, + "D0": 15, + "D1": 14, + "D2": 5, + "D3": 22, + "D4": 12, + }, + "wr3": { + "SPI0_CS": 19, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC1": 19, + "ADC2": 41, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "MISO0": 22, + "MISO1": 22, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA19": 19, + "PA22": 22, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 5, + "PWM5": 22, + "RTS0": 22, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL1": 18, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 22, + "D1": 19, + "D2": 14, + "D3": 15, + "D4": 0, + "D5": 29, + "D6": 30, + "D7": 5, + "D8": 12, + "D9": 18, + "D10": 23, + "A0": 19, + "A1": 41, + }, + "wr3e": { + "SPI0_CS": 19, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC1": 19, + "ADC2": 41, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "MISO0": 22, + "MISO1": 22, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA19": 19, + "PA22": 22, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 5, + "PWM5": 22, + "RTS0": 22, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL1": 18, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 29, + "D1": 14, + "D2": 15, + "D3": 22, + "D4": 0, + "D5": 30, + "D6": 19, + "D7": 5, + "D8": 12, + "D9": 18, + "D10": 23, + "A0": 19, + "A1": 41, + }, + "wr3l": { + "SPI0_CS": 19, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC1": 19, + "ADC2": 41, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "MISO0": 22, + "MISO1": 22, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA19": 19, + "PA22": 22, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 5, + "PWM5": 22, + "RTS0": 22, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL1": 18, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 22, + "D1": 19, + "D2": 14, + "D3": 15, + "D4": 0, + "D5": 29, + "D6": 30, + "D7": 5, + "D8": 12, + "D9": 18, + "D10": 23, + "A0": 19, + "A1": 41, + }, + "wr3le": { + "SPI0_CS": 19, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC1": 19, + "ADC2": 41, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "MISO0": 22, + "MISO1": 22, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA19": 19, + "PA22": 22, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 5, + "PWM5": 22, + "RTS0": 22, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL1": 18, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 29, + "D1": 14, + "D2": 15, + "D3": 22, + "D4": 0, + "D5": 30, + "D6": 19, + "D7": 5, + "D8": 12, + "D9": 18, + "D10": 23, + "A0": 19, + "A1": 41, + }, + "wr3n": { + "WIRE0_SCL": 29, + "WIRE0_SDA": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC2": 41, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 5, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL0": 29, + "SCL1": 18, + "SDA0": 30, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 29, + "D1": 14, + "D2": 15, + "D3": 0, + "D4": 30, + "D5": 5, + "D6": 12, + "D7": 18, + "D8": 23, + "A1": 41, + }, } BOARDS = RTL87XX_BOARDS diff --git a/platformio.ini b/platformio.ini index bca2910616..061e92a64a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -224,7 +224,7 @@ build_unflags = ; This are common settings for the LibreTiny (all variants) using Arduino. [common:libretiny-arduino] extends = common:arduino -platform = https://github.com/libretiny-eu/libretiny.git#v1.12.1 +platform = https://github.com/libretiny-eu/libretiny.git#v1.13.0 framework = arduino lib_compat_mode = soft lib_deps = @@ -525,7 +525,7 @@ build_unflags = [env:ln882h-arduino] extends = common:libretiny-arduino -board = generic-ln882hki +board = generic-ln882h build_flags = ${common:libretiny-arduino.build_flags} ${flags:runtime.build_flags} diff --git a/tests/test_build_components/build_components_base.ln882x-ard.yaml b/tests/test_build_components/build_components_base.ln882x-ard.yaml index 80fc6690f9..34abcb5a77 100644 --- a/tests/test_build_components/build_components_base.ln882x-ard.yaml +++ b/tests/test_build_components/build_components_base.ln882x-ard.yaml @@ -3,7 +3,7 @@ esphome: friendly_name: $component_name ln882x: - board: generic-ln882hki + board: generic-ln882h logger: level: VERY_VERBOSE From faa5f72500c341c6ec12d8711c32ff8455c84852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Tue, 30 Jun 2026 15:16:18 +0300 Subject: [PATCH 0669/1815] [mqtt] Add LN882X (LN882H) platform support (#17297) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/mqtt/__init__.py | 11 ++++++++++- tests/components/mqtt/test.ln882x-ard.yaml | 2 ++ 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 tests/components/mqtt/test.ln882x-ard.yaml diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 86bba11a60..4a5eacf449 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -57,6 +57,7 @@ from esphome.const import ( PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, + PLATFORM_LN882X, PLATFORM_RTL87XX, PlatformFramework, ) @@ -318,7 +319,15 @@ CONFIG_SCHEMA = cv.All( } ), validate_config, - cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX, PLATFORM_RTL87XX]), + cv.only_on( + [ + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_LN882X, + PLATFORM_RTL87XX, + ] + ), _consume_mqtt_sockets, ) diff --git a/tests/components/mqtt/test.ln882x-ard.yaml b/tests/components/mqtt/test.ln882x-ard.yaml new file mode 100644 index 0000000000..25cb37a0b4 --- /dev/null +++ b/tests/components/mqtt/test.ln882x-ard.yaml @@ -0,0 +1,2 @@ +packages: + common: !include common.yaml From 9e72027b6455a90bc41498942e45ee28c962ae85 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 30 Jun 2026 05:33:33 -0700 Subject: [PATCH 0670/1815] [devcontainer] Align base image with production, fix Python venv and build tools (#17296) Co-authored-by: Claude Opus 4.8 --- .devcontainer/Dockerfile | 2 +- .devcontainer/devcontainer.json | 7 +++++-- script/setup | 15 +++++++++++++-- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 51e2232d24..6f7e892284 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -ARG BUILD_BASE_VERSION=2025.04.0 +ARG BUILD_BASE_VERSION=2026.06.1 FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 29f63b54b5..9181275269 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -15,7 +15,6 @@ // uncomment and edit the path in order to pass through local USB serial to the container // , "--device=/dev/ttyACM0" ], - "appPort": 6052, // if you are using avahi in the host device, uncomment these to allow the // devcontainer to find devices via mdns //"mounts": [ @@ -41,7 +40,11 @@ ], "settings": { "python.languageServer": "Pylance", - "python.pythonPath": "/usr/bin/python3", + // Use the container's pre-provisioned venv (built by the Dockerfile, outside the + // bind-mounted workspace) rather than a ./venv that may leak in from the host and + // mismatch the container's Python. See .devcontainer/Dockerfile (esphome-venv). + "python.defaultInterpreterPath": "/home/esphome/.local/esphome-venv/bin/python", + "python.terminal.activateEnvironment": true, "pylint.args": [ "--rcfile=${workspaceFolder}/pyproject.toml" ], diff --git a/script/setup b/script/setup index 8cad7017ff..709eaee0f3 100755 --- a/script/setup +++ b/script/setup @@ -4,7 +4,12 @@ set -e cd "$(dirname "$0")/.." -if [ ! -n "$VIRTUAL_ENV" ]; then +if [ -n "$VIRTUAL_ENV" ]; then + # A virtual environment is already active (e.g. the devcontainer's pre-provisioned + # esphome-venv). Install into it rather than creating a ./venv in the workspace. + created_venv=false +else + created_venv=true if [ -x "$(command -v uv)" ]; then uv venv --seed venv else @@ -26,4 +31,10 @@ mkdir -p .temp echo echo -echo "Virtual environment created. Run 'source venv/bin/activate' to use it." +if [ "$created_venv" = true ]; then + echo "Virtual environment created at ./venv. Run 'source venv/bin/activate' to use it." +else + echo "Dependencies installed into the active virtual environment:" + echo " $VIRTUAL_ENV" + echo "It is already active in this shell, so no 'source venv/bin/activate' is needed." +fi From fb5d8b5d4c07818fd75ae4e2306d96a8ba42164c Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:43:01 +1000 Subject: [PATCH 0671/1815] [mipi_spi] Bug fixes (#17247) --- esphome/components/mipi_spi/display.py | 2 ++ esphome/components/mipi_spi/mipi_spi.h | 3 +++ esphome/components/mipi_spi/models/ili.py | 7 +------ tests/component_tests/mipi_spi/test_init.py | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 4162459058..871736abd1 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -425,6 +425,8 @@ async def to_code(config): dc_pin = await cg.gpio_pin_expression(dc_pin) cg.add(var.set_dc_pin(dc_pin)) + if config.get(CONF_INVERT_COLORS): + cg.add(var.set_invert_colors(True)) if lamb := config.get(CONF_LAMBDA): lambda_ = await cg.process_lambda( lamb, [(display.DisplayRef, "it")], return_type=cg.void diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index d9627899e0..48184fa5c1 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -151,6 +151,9 @@ class MipiSpi : public display::Display, this->reset_pin_->digital_write(false); delay(5); this->reset_pin_->digital_write(true); + } else { + // no reset pin, send software reset command + this->write_command_(SW_RESET_CMD); } // need to know when the display is ready for SLPOUT command - will be 120ms after reset diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index 5df7a275df..5598a51073 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -24,13 +24,11 @@ from esphome.components.mipi import ( PWSET, PWSETN, SETEXTC, - SWRESET, VMCTR, VMCTR1, VMCTR2, VSCRSADD, DriverChip, - delay, ) from esphome.components.spi import TYPE_OCTAL @@ -367,7 +365,6 @@ ST7796 = DriverChip( width=320, height=480, initsequence=( - (SWRESET,), (CSCON, 0xC3), (CSCON, 0x96), (VMCTR1, 0x1C), @@ -728,8 +725,6 @@ DriverChip( width=128, height=160, initsequence=( - SWRESET, - delay(10), (FRMCTR1, 0x01, 0x2C, 0x2D), (FRMCTR2, 0x01, 0x2C, 0x2D), (FRMCTR3, 0x01, 0x2C, 0x2D, 0x01, 0x2C, 0x2D), @@ -786,7 +781,7 @@ ST7796.extend( bus_mode=TYPE_OCTAL, mirror_x=True, reset_pin=4, - dc_pin=0, + dc_pin={"number": 0, "ignore_strapping_warning": True}, invert_colors=True, ) diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index dbd8e15702..8edbe095b7 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -377,6 +377,6 @@ def test_lvgl_generation( "mipi_spi::MipiSpi();" in main_cpp ) - assert "set_init_sequence({1, 0, 10, 255, 177" in main_cpp + assert "set_init_sequence({177, 3, 1, 44, 45, 178" in main_cpp assert "show_test_card();" not in main_cpp assert "set_auto_clear(false);" in main_cpp From 12b78e7c47abcae5dd518ca07c7c0439bae3232d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:37:17 -0400 Subject: [PATCH 0672/1815] [qmi8658] Pin i2c_id in test config to fix grouped component test conflict (#17303) --- tests/components/qmi8658/common.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/qmi8658/common.yaml b/tests/components/qmi8658/common.yaml index cfb0f3e129..7d4de0f97e 100644 --- a/tests/components/qmi8658/common.yaml +++ b/tests/components/qmi8658/common.yaml @@ -49,6 +49,7 @@ sensor: motion: - platform: qmi8658 + i2c_id: i2c_bus # Accelerometer full-scale range: 2G | 4G | 8G | 16G accelerometer_range: 4G From 43b3aa0712dd654495abb0243b95837e379ae6f7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:37:59 -0400 Subject: [PATCH 0673/1815] [ci] Fix nRF52 zigbee/network test-grouping conflict (#17295) --- script/helpers.py | 99 +++++++++++++++---- script/test_build_components.py | 38 +++++-- tests/components/api/test.nrf52-adafruit.yaml | 3 + .../components/mdns/test.nrf52-adafruit.yaml | 3 + .../network/test.nrf52-adafruit.yaml | 4 + .../components/network/test.nrf52-mcumgr.yaml | 4 + .../network/test.nrf52-xiao-ble.yaml | 4 + tests/script/test_helpers.py | 45 +++++++++ 8 files changed, 171 insertions(+), 29 deletions(-) diff --git a/script/helpers.py b/script/helpers.py index fc2a3607fb..0086a00e85 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -238,6 +238,72 @@ class _ConflictWalk: rejects: set[str] +@cache +def _get_test_config_components(component: str, platform: str) -> frozenset[str]: + """Return the components referenced by a component's test config for a platform. + + Loads ``tests/components//test..yaml`` and extracts the + top-level component keys (and list ``platform:`` values). This lets the + conflict splitter see components that are only pulled in via a test config + (e.g. nRF52 ``network`` tests that also enable ``openthread``), which a + purely static AUTO_LOAD/CONFLICTS_WITH parse cannot discover -- notably for + components like ``api`` whose ``AUTO_LOAD`` is a callable. + + Failures (missing file, parse error) are treated as empty so the splitter + never crashes on a malformed or absent test config. + """ + from esphome import yaml_util + + test_file = ( + Path(root_path) / "tests" / "components" / component / f"test.{platform}.yaml" + ) + if not test_file.exists(): + return frozenset() + try: + config = yaml_util.load_yaml(test_file) + except Exception: # noqa: BLE001 - never let a bad test config crash grouping + # Matches analyze_component_buses, which loads these same files and + # silently tolerates parse failures; surfacing it only here would be + # inconsistent and noisy. + return frozenset() + if not isinstance(config, dict): + return frozenset() + return frozenset(_extract_components_from_yaml(config)) + + +@cache +def _conflict_walk(comp: str, platform: str) -> _ConflictWalk: + """Build the platform-aware conflict walk for a single component. + + Seeds the walk with the component itself plus any components pulled in via + its ``test..yaml`` config, then folds in each seed's static + AUTO_LOAD closure and CONFLICTS_WITH declarations. Cached per + ``(component, platform)`` since the test-config seeds are platform-specific. + """ + seeds = {comp} | set(_get_test_config_components(comp, platform)) + walk = _ConflictWalk(loaded=set(seeds), rejects=set()) + stack = list(seeds) + while stack: + metadata = parse_component_metadata(stack.pop()) + walk.rejects |= metadata.conflicts_with + new = metadata.auto_load - walk.loaded + walk.loaded |= new + stack.extend(new) + return walk + + +def components_conflict(a: str, b: str, platform: str) -> bool: + """Return True if components ``a`` and ``b`` cannot share a build on ``platform``. + + Uses the same platform-aware conflict walk as :func:`split_conflicting_groups` + so callers (e.g. the no-bus redistribution in ``test_build_components.py``) + agree with how groups were originally split. The conflict relation is + symmetric even when only one side declares CONFLICTS_WITH. + """ + wa, wb = _conflict_walk(a, platform), _conflict_walk(b, platform) + return not wa.rejects.isdisjoint(wb.loaded) or not wb.rejects.isdisjoint(wa.loaded) + + def split_conflicting_groups( grouped_components: dict[tuple[str, str], list[str]], ) -> dict[tuple[str, str], list[str]]: @@ -250,33 +316,24 @@ def split_conflicting_groups( conflict relation is treated as symmetric even when only one side declares it (e.g. ethernet rejects wifi but wifi does not declare the reverse). + + The walk is platform-aware: in addition to the static AUTO_LOAD closure, + each ``(component, platform)`` walk is seeded with the components found in + that component's ``test..yaml`` config. This catches conflicts + that only exist on a given platform and are expressed through the test + config rather than static metadata -- e.g. on nRF52 the ``network``/``api`` + test configs also enable ``openthread``, which ``zigbee`` declares a + conflict with, so ``api`` and ``zigbee`` end up split there. On ESP32 those + test configs have no ``openthread``, so the components still group together. """ - batch = {c for comps in grouped_components.values() for c in comps} - - walks: dict[str, _ConflictWalk] = {} - for comp in batch: - walk = _ConflictWalk(loaded={comp}, rejects=set()) - stack = [comp] - while stack: - metadata = parse_component_metadata(stack.pop()) - walk.rejects |= metadata.conflicts_with - new = metadata.auto_load - walk.loaded - walk.loaded |= new - stack.extend(new) - walks[comp] = walk - - def conflicts(a: str, b: str) -> bool: - wa, wb = walks[a], walks[b] - return not wa.rejects.isdisjoint(wb.loaded) or not wb.rejects.isdisjoint( - wa.loaded - ) - result: dict[tuple[str, str], list[str]] = {} for (platform, signature), components in grouped_components.items(): buckets: list[list[str]] = [] for comp in components: for bucket in buckets: - if not any(conflicts(comp, other) for other in bucket): + if not any( + components_conflict(comp, other, platform) for other in bucket + ): bucket.append(comp) break else: diff --git a/script/test_build_components.py b/script/test_build_components.py index 651268609e..ce2a35add3 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -40,6 +40,7 @@ from script.analyze_component_buses import ( uses_local_file_references, ) from script.helpers import ( + components_conflict, get_component_test_files, is_validate_only_file, parse_test_filename, @@ -788,14 +789,35 @@ def run_grouped_component_tests( if plat == platform and sig != NO_BUSES_SIGNATURE ] - if platform_groups: - # Distribute no_buses components round-robin across existing groups - for i, comp in enumerate(no_buses_comps): - sig, _ = platform_groups[i % len(platform_groups)] - grouped_components[(platform, sig)].append(comp) - else: - # No other groups for this platform - keep no_buses components together - grouped_components[(platform, NO_BUSES_SIGNATURE)] = no_buses_comps + # Distribute no_buses components round-robin across existing groups, + # but never place a component into a group it conflicts with. Conflict + # splitting (split_conflicting_groups) may have created sibling groups + # like "no_buses__conflict1" precisely to keep incompatible components + # apart (e.g. on nRF52, network pulls in openthread which zigbee + # conflicts with); redistribution must not silently undo that split. + leftover: list[str] = [] + for i, comp in enumerate(no_buses_comps): + placed = False + # Try groups starting at the round-robin offset to keep the spread. + for offset in range(len(platform_groups)): + sig, comps = platform_groups[(i + offset) % len(platform_groups)] + if any(components_conflict(comp, other, platform) for other in comps): + continue + # comps is the same list object stored in grouped_components, so + # this also extends the group in grouped_components. + comps.append(comp) + placed = True + break + if not placed: + leftover.append(comp) + + if leftover: + # Components that conflict with every existing group stay together in + # their own no_buses group (they were grouped before, so they don't + # conflict with each other). + grouped_components.setdefault((platform, NO_BUSES_SIGNATURE), []).extend( + leftover + ) groups_to_test = [] individual_tests = set() # Use set to avoid duplicates diff --git a/tests/components/api/test.nrf52-adafruit.yaml b/tests/components/api/test.nrf52-adafruit.yaml index 9229d68aa3..18bf23d710 100644 --- a/tests/components/api/test.nrf52-adafruit.yaml +++ b/tests/components/api/test.nrf52-adafruit.yaml @@ -1,4 +1,7 @@ network: enable_ipv6: true +openthread: + tlv: 0E080000000000010000 + api: diff --git a/tests/components/mdns/test.nrf52-adafruit.yaml b/tests/components/mdns/test.nrf52-adafruit.yaml index 6aff688ff4..c24d0a1908 100644 --- a/tests/components/mdns/test.nrf52-adafruit.yaml +++ b/tests/components/mdns/test.nrf52-adafruit.yaml @@ -1,4 +1,7 @@ network: enable_ipv6: true +openthread: + tlv: 0E080000000000010000 + mdns: diff --git a/tests/components/network/test.nrf52-adafruit.yaml b/tests/components/network/test.nrf52-adafruit.yaml index 61889b0361..ac2fe63739 100644 --- a/tests/components/network/test.nrf52-adafruit.yaml +++ b/tests/components/network/test.nrf52-adafruit.yaml @@ -1 +1,5 @@ network: + enable_ipv6: true + +openthread: + tlv: 0E080000000000010000 diff --git a/tests/components/network/test.nrf52-mcumgr.yaml b/tests/components/network/test.nrf52-mcumgr.yaml index 61889b0361..ac2fe63739 100644 --- a/tests/components/network/test.nrf52-mcumgr.yaml +++ b/tests/components/network/test.nrf52-mcumgr.yaml @@ -1 +1,5 @@ network: + enable_ipv6: true + +openthread: + tlv: 0E080000000000010000 diff --git a/tests/components/network/test.nrf52-xiao-ble.yaml b/tests/components/network/test.nrf52-xiao-ble.yaml index 61889b0361..ac2fe63739 100644 --- a/tests/components/network/test.nrf52-xiao-ble.yaml +++ b/tests/components/network/test.nrf52-xiao-ble.yaml @@ -1 +1,5 @@ network: + enable_ipv6: true + +openthread: + tlv: 0E080000000000010000 diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 82ff5e1411..886d413ccf 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -35,6 +35,8 @@ def clear_helpers_cache() -> None: helpers._get_github_event_data.cache_clear() helpers._get_changed_files_github_actions.cache_clear() helpers.get_components_per_integration_fixture.cache_clear() + helpers._get_test_config_components.cache_clear() + helpers._conflict_walk.cache_clear() @pytest.mark.parametrize( @@ -1504,6 +1506,8 @@ def fake_components(tmp_path: Path) -> Path: write("callable_auto", "def AUTO_LOAD():\n return ['beta']\n") write("broken", "this is not valid python !!!") helpers.parse_component_metadata.cache_clear() + helpers._get_test_config_components.cache_clear() + helpers._conflict_walk.cache_clear() return tmp_path @@ -1624,6 +1628,47 @@ def test_split_conflicting_groups_preserves_original_signature_for_first_bucket( assert signature.startswith("i2c__conflict") +def test_split_conflicting_groups_seeds_from_test_config( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + """A conflict reachable only via a component's test config splits the group. + + ``host_user`` declares no static conflict with ``beta``, but its + ``test..yaml`` pulls in ``beta_variant`` (which AUTO_LOADs + ``beta``). On that platform the group must split; on another platform + (no such test config) it must stay together. + """ + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + + # host_user has no static metadata, but its esp32 test config references + # beta_variant -> AUTO_LOAD beta, which conflicts with alpha. + tests_dir = fake_components / "tests" / "components" / "host_user" + tests_dir.mkdir(parents=True) + (tests_dir / "test.esp32.yaml").write_text("beta_variant:\n") + (fake_components / "esphome" / "components" / "host_user").mkdir() + ( + fake_components / "esphome" / "components" / "host_user" / "__init__.py" + ).write_text("") + + helpers.parse_component_metadata.cache_clear() + helpers._get_test_config_components.cache_clear() + helpers._conflict_walk.cache_clear() + + # On esp32, host_user pulls in beta (via its test config) -> conflicts with alpha. + result = helpers.split_conflicting_groups( + {("esp32", "no_buses"): ["alpha", "host_user"]} + ) + buckets = list(result.values()) + for bucket in buckets: + assert not ({"alpha", "host_user"} <= set(bucket)) + + # On a platform without that test config, they stay grouped together. + result_other = helpers.split_conflicting_groups( + {("rp2040", "no_buses"): ["alpha", "host_user"]} + ) + assert result_other == {("rp2040", "no_buses"): ["alpha", "host_user"]} + + # --------------------------------------------------------------------------- # get_component_test_files / is_validate_only_file # --------------------------------------------------------------------------- From 3035355c0ade9c2f9d6ac885cf95582c3e19c50d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:38:31 -0400 Subject: [PATCH 0674/1815] [ci] Widen import-time margin for CI runner variance (#17287) --- script/import_time_budget.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/import_time_budget.json b/script/import_time_budget.json index af3aa83511..855d89c56d 100644 --- a/script/import_time_budget.json +++ b/script/import_time_budget.json @@ -1,5 +1,5 @@ { "target_module": "esphome.__main__", - "margin_pct": 15, + "margin_pct": 20, "cumulative_us": 91000 } From afb5922f3748bbade779fbee840a57cc3fd5e7ea Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 30 Jun 2026 11:26:51 -0700 Subject: [PATCH 0675/1815] [modbus] Update client components to use ModbusClientDevice (#11987) --- esphome/components/growatt_solar/growatt_solar.h | 2 +- esphome/components/growatt_solar/sensor.py | 12 ++++++++++-- esphome/components/havells_solar/havells_solar.h | 2 +- esphome/components/havells_solar/sensor.py | 12 ++++++++++-- esphome/components/kuntze/kuntze.h | 2 +- esphome/components/kuntze/sensor.py | 12 ++++++++++-- esphome/components/modbus/__init__.py | 8 ++++++++ esphome/components/modbus/modbus.h | 4 +++- esphome/components/modbus_controller/__init__.py | 7 ++++--- esphome/components/pzemac/pzemac.h | 2 +- esphome/components/pzemac/sensor.py | 12 ++++++++++-- esphome/components/pzemdc/pzemdc.h | 2 +- esphome/components/pzemdc/sensor.py | 12 ++++++++++-- esphome/components/sdm_meter/sdm_meter.h | 2 +- esphome/components/sdm_meter/sensor.py | 14 ++++++++++++-- esphome/components/selec_meter/selec_meter.h | 2 +- esphome/components/selec_meter/sensor.py | 12 ++++++++++-- 17 files changed, 94 insertions(+), 25 deletions(-) diff --git a/esphome/components/growatt_solar/growatt_solar.h b/esphome/components/growatt_solar/growatt_solar.h index 76d430737a..18a7c917d5 100644 --- a/esphome/components/growatt_solar/growatt_solar.h +++ b/esphome/components/growatt_solar/growatt_solar.h @@ -65,7 +65,7 @@ constexpr size_t RTU2_TODAY_PRODUCTION = 53; // length = 2 constexpr size_t RTU2_TOTAL_ENERGY_PRODUCTION = 55; // length = 2 constexpr size_t RTU2_INVERTER_MODULE_TEMP = 93; // length = 1 -class GrowattSolar final : public PollingComponent, public modbus::ModbusDevice { +class GrowattSolar final : public PollingComponent, public modbus::ModbusClientDevice { public: void loop() override; void update() override; diff --git a/esphome/components/growatt_solar/sensor.py b/esphome/components/growatt_solar/sensor.py index 7458b88b72..d1f0069341 100644 --- a/esphome/components/growatt_solar/sensor.py +++ b/esphome/components/growatt_solar/sensor.py @@ -25,6 +25,7 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.types import ConfigType CONF_ENERGY_PRODUCTION_DAY = "energy_production_day" CONF_TOTAL_ENERGY_PRODUCTION = "total_energy_production" @@ -47,7 +48,7 @@ CODEOWNERS = ["@leeuwte"] growatt_solar_ns = cg.esphome_ns.namespace("growatt_solar") GrowattSolar = growatt_solar_ns.class_( - "GrowattSolar", cg.PollingComponent, modbus.ModbusDevice + "GrowattSolar", cg.PollingComponent, modbus.ModbusClientDevice ) PHASE_SENSORS = { @@ -162,10 +163,17 @@ CONFIG_SCHEMA = ( ) +def _final_validate(config: ConfigType) -> ConfigType: + return modbus.final_validate_modbus_device("growatt_solar", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await modbus.register_modbus_device(var, config) + await modbus.register_modbus_client_device(var, config) cg.add(var.set_protocol_version(config[CONF_PROTOCOL_VERSION])) diff --git a/esphome/components/havells_solar/havells_solar.h b/esphome/components/havells_solar/havells_solar.h index ec6d5b5657..02e999c56c 100644 --- a/esphome/components/havells_solar/havells_solar.h +++ b/esphome/components/havells_solar/havells_solar.h @@ -8,7 +8,7 @@ namespace esphome::havells_solar { -class HavellsSolar final : public PollingComponent, public modbus::ModbusDevice { +class HavellsSolar final : public PollingComponent, public modbus::ModbusClientDevice { public: void set_voltage_sensor(uint8_t phase, sensor::Sensor *voltage_sensor) { this->phases_[phase].setup = true; diff --git a/esphome/components/havells_solar/sensor.py b/esphome/components/havells_solar/sensor.py index f0683e1d9c..d18ae0d9af 100644 --- a/esphome/components/havells_solar/sensor.py +++ b/esphome/components/havells_solar/sensor.py @@ -28,6 +28,7 @@ from esphome.const import ( UNIT_VOLT_AMPS_REACTIVE, UNIT_WATT, ) +from esphome.types import ConfigType CONF_ENERGY_PRODUCTION_DAY = "energy_production_day" CONF_TOTAL_ENERGY_PRODUCTION = "total_energy_production" @@ -58,7 +59,7 @@ CODEOWNERS = ["@sourabhjaiswal"] havells_solar_ns = cg.esphome_ns.namespace("havells_solar") HavellsSolar = havells_solar_ns.class_( - "HavellsSolar", cg.PollingComponent, modbus.ModbusDevice + "HavellsSolar", cg.PollingComponent, modbus.ModbusClientDevice ) PHASE_SENSORS = { @@ -216,10 +217,17 @@ CONFIG_SCHEMA = ( ) +def _final_validate(config: ConfigType) -> ConfigType: + return modbus.final_validate_modbus_device("havells_solar", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await modbus.register_modbus_device(var, config) + await modbus.register_modbus_client_device(var, config) if CONF_FREQUENCY in config: sens = await sensor.new_sensor(config[CONF_FREQUENCY]) diff --git a/esphome/components/kuntze/kuntze.h b/esphome/components/kuntze/kuntze.h index 99dd78e5b6..46681843d2 100644 --- a/esphome/components/kuntze/kuntze.h +++ b/esphome/components/kuntze/kuntze.h @@ -6,7 +6,7 @@ namespace esphome::kuntze { -class Kuntze final : public PollingComponent, public modbus::ModbusDevice { +class Kuntze final : public PollingComponent, public modbus::ModbusClientDevice { public: void set_ph_sensor(sensor::Sensor *ph_sensor) { ph_sensor_ = ph_sensor; } void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } diff --git a/esphome/components/kuntze/sensor.py b/esphome/components/kuntze/sensor.py index 96b6334730..c11ede9db6 100644 --- a/esphome/components/kuntze/sensor.py +++ b/esphome/components/kuntze/sensor.py @@ -15,13 +15,14 @@ from esphome.const import ( UNIT_EMPTY, UNIT_PH, ) +from esphome.types import ConfigType CODEOWNERS = ["@ssieb"] AUTO_LOAD = ["modbus"] kuntze_ns = cg.esphome_ns.namespace("kuntze") -Kuntze = kuntze_ns.class_("Kuntze", cg.PollingComponent, modbus.ModbusDevice) +Kuntze = kuntze_ns.class_("Kuntze", cg.PollingComponent, modbus.ModbusClientDevice) CONF_DIS1 = "dis1" CONF_DIS2 = "dis2" @@ -88,10 +89,17 @@ CONFIG_SCHEMA = ( ) +def _final_validate(config: ConfigType) -> ConfigType: + return modbus.final_validate_modbus_device("kuntze", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await modbus.register_modbus_device(var, config) + await modbus.register_modbus_client_device(var, config) if CONF_PH in config: conf = config[CONF_PH] diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index cf1d409393..9e64540382 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from typing import Literal from esphome import pins @@ -10,6 +11,8 @@ from esphome.const import CONF_ADDRESS, CONF_DISABLE_CRC, CONF_FLOW_CONTROL_PIN, from esphome.cpp_helpers import gpio_pin_expression import esphome.final_validate as fv +_LOGGER = logging.getLogger(__name__) + DEPENDENCIES = ["uart"] modbus_ns = cg.esphome_ns.namespace("modbus") @@ -129,4 +132,9 @@ async def register_modbus_server_device(var, config): async def register_modbus_device(var, config): + # Remove before 2026.12.0 + _LOGGER.warning( + "'register_modbus_device' is deprecated, use 'register_modbus_client_device' " + "instead. Will be removed in 2026.12.0" + ) return await register_modbus_client_device(var, config) diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 4aa3a16c3a..b0f2aed9f8 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -197,7 +197,9 @@ class ModbusClientDevice { }; // This is for compatibility with external components using the former class name -using ModbusDevice = ModbusClientDevice; +// Remove before 2026.12.0 +using ModbusDevice ESPDEPRECATED("Use ModbusClientDevice instead. Removed in 2026.12.0", + "2026.6.0") = ModbusClientDevice; // Result of a server register handler: std::nullopt means success, otherwise the Modbus exception code to return. using ServerResponseStatus = std::optional; diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index 67e5757397..cdbba54c1f 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -11,6 +11,7 @@ from esphome.components.modbus.helpers import ( import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_NAME, CONF_OFFSET from esphome.cpp_helpers import logging +from esphome.types import ConfigType from .const import ( CONF_ALLOW_DUPLICATE_COMMANDS, @@ -42,7 +43,7 @@ MULTI_CONF = True modbus_controller_ns = cg.esphome_ns.namespace("modbus_controller") ModbusController = modbus_controller_ns.class_( - "ModbusController", cg.PollingComponent, modbus.ModbusDevice + "ModbusController", cg.PollingComponent, modbus.ModbusClientDevice ) SensorItem = modbus_controller_ns.struct("SensorItem") @@ -117,7 +118,7 @@ def validate_modbus_register(config): return config -def _final_validate(config): +def _final_validate(config: ConfigType) -> ConfigType: return modbus.final_validate_modbus_device("modbus_controller", role="client")( config ) @@ -211,7 +212,7 @@ async def to_code(config): async def register_modbus_device(var, config): cg.add(var.set_address(config[CONF_ADDRESS])) await cg.register_component(var, config) - return await modbus.register_modbus_device(var, config) + return await modbus.register_modbus_client_device(var, config) def function_code_to_register(function_code): diff --git a/esphome/components/pzemac/pzemac.h b/esphome/components/pzemac/pzemac.h index a25a8cb631..a3ad7e1167 100644 --- a/esphome/components/pzemac/pzemac.h +++ b/esphome/components/pzemac/pzemac.h @@ -11,7 +11,7 @@ namespace esphome::pzemac { template class ResetEnergyAction; -class PZEMAC final : public PollingComponent, public modbus::ModbusDevice { +class PZEMAC final : public PollingComponent, public modbus::ModbusClientDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_current_sensor(sensor::Sensor *current_sensor) { current_sensor_ = current_sensor; } diff --git a/esphome/components/pzemac/sensor.py b/esphome/components/pzemac/sensor.py index c134bc19c1..4e228f6aa3 100644 --- a/esphome/components/pzemac/sensor.py +++ b/esphome/components/pzemac/sensor.py @@ -26,11 +26,12 @@ from esphome.const import ( UNIT_WATT, UNIT_WATT_HOURS, ) +from esphome.types import ConfigType AUTO_LOAD = ["modbus"] pzemac_ns = cg.esphome_ns.namespace("pzemac") -PZEMAC = pzemac_ns.class_("PZEMAC", cg.PollingComponent, modbus.ModbusDevice) +PZEMAC = pzemac_ns.class_("PZEMAC", cg.PollingComponent, modbus.ModbusClientDevice) # Actions ResetEnergyAction = pzemac_ns.class_("ResetEnergyAction", automation.Action) @@ -97,10 +98,17 @@ async def reset_energy_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, paren) +def _final_validate(config: ConfigType) -> ConfigType: + return modbus.final_validate_modbus_device("pzemac", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await modbus.register_modbus_device(var, config) + await modbus.register_modbus_client_device(var, config) if CONF_VOLTAGE in config: conf = config[CONF_VOLTAGE] diff --git a/esphome/components/pzemdc/pzemdc.h b/esphome/components/pzemdc/pzemdc.h index e398330cd3..7d14a5ed4b 100644 --- a/esphome/components/pzemdc/pzemdc.h +++ b/esphome/components/pzemdc/pzemdc.h @@ -9,7 +9,7 @@ namespace esphome::pzemdc { -class PZEMDC final : public PollingComponent, public modbus::ModbusDevice { +class PZEMDC final : public PollingComponent, public modbus::ModbusClientDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_current_sensor(sensor::Sensor *current_sensor) { current_sensor_ = current_sensor; } diff --git a/esphome/components/pzemdc/sensor.py b/esphome/components/pzemdc/sensor.py index 3291be4c34..40cfe7b08a 100644 --- a/esphome/components/pzemdc/sensor.py +++ b/esphome/components/pzemdc/sensor.py @@ -20,11 +20,12 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.types import ConfigType AUTO_LOAD = ["modbus"] pzemdc_ns = cg.esphome_ns.namespace("pzemdc") -PZEMDC = pzemdc_ns.class_("PZEMDC", cg.PollingComponent, modbus.ModbusDevice) +PZEMDC = pzemdc_ns.class_("PZEMDC", cg.PollingComponent, modbus.ModbusClientDevice) # Actions ResetEnergyAction = pzemdc_ns.class_("ResetEnergyAction", automation.Action) @@ -79,10 +80,17 @@ async def reset_energy_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, paren) +def _final_validate(config: ConfigType) -> ConfigType: + return modbus.final_validate_modbus_device("pzemdc", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await modbus.register_modbus_device(var, config) + await modbus.register_modbus_client_device(var, config) if CONF_VOLTAGE in config: conf = config[CONF_VOLTAGE] diff --git a/esphome/components/sdm_meter/sdm_meter.h b/esphome/components/sdm_meter/sdm_meter.h index a4dbde016c..aa71fcaa47 100644 --- a/esphome/components/sdm_meter/sdm_meter.h +++ b/esphome/components/sdm_meter/sdm_meter.h @@ -8,7 +8,7 @@ namespace esphome::sdm_meter { -class SDMMeter final : public PollingComponent, public modbus::ModbusDevice { +class SDMMeter final : public PollingComponent, public modbus::ModbusClientDevice { public: void set_voltage_sensor(uint8_t phase, sensor::Sensor *voltage_sensor) { this->phases_[phase].setup = true; diff --git a/esphome/components/sdm_meter/sensor.py b/esphome/components/sdm_meter/sensor.py index 8006d0b4ba..46f5025080 100644 --- a/esphome/components/sdm_meter/sensor.py +++ b/esphome/components/sdm_meter/sensor.py @@ -41,12 +41,15 @@ from esphome.const import ( UNIT_VOLT_AMPS_REACTIVE, UNIT_WATT, ) +from esphome.types import ConfigType AUTO_LOAD = ["modbus"] CODEOWNERS = ["@polyfaces", "@jesserockz"] sdm_meter_ns = cg.esphome_ns.namespace("sdm_meter") -SDMMeter = sdm_meter_ns.class_("SDMMeter", cg.PollingComponent, modbus.ModbusDevice) +SDMMeter = sdm_meter_ns.class_( + "SDMMeter", cg.PollingComponent, modbus.ModbusClientDevice +) PHASE_SENSORS = { CONF_VOLTAGE: sensor.sensor_schema( @@ -145,10 +148,17 @@ CONFIG_SCHEMA = ( ) +def _final_validate(config: ConfigType) -> ConfigType: + return modbus.final_validate_modbus_device("sdm_meter", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await modbus.register_modbus_device(var, config) + await modbus.register_modbus_client_device(var, config) if CONF_TOTAL_POWER in config: sens = await sensor.new_sensor(config[CONF_TOTAL_POWER]) diff --git a/esphome/components/selec_meter/selec_meter.h b/esphome/components/selec_meter/selec_meter.h index 6b5552a098..c367d1d15d 100644 --- a/esphome/components/selec_meter/selec_meter.h +++ b/esphome/components/selec_meter/selec_meter.h @@ -15,7 +15,7 @@ namespace esphome::selec_meter { public: \ void set_##name##_sensor(sensor::Sensor *(name)) { this->name##_sensor_ = name; } -class SelecMeter final : public PollingComponent, public modbus::ModbusDevice { +class SelecMeter final : public PollingComponent, public modbus::ModbusClientDevice { public: SELEC_METER_SENSOR(total_active_energy) SELEC_METER_SENSOR(import_active_energy) diff --git a/esphome/components/selec_meter/sensor.py b/esphome/components/selec_meter/sensor.py index 1a53eb5c37..ef4929c375 100644 --- a/esphome/components/selec_meter/sensor.py +++ b/esphome/components/selec_meter/sensor.py @@ -32,6 +32,7 @@ from esphome.const import ( UNIT_VOLT_AMPS_REACTIVE, UNIT_WATT, ) +from esphome.types import ConfigType AUTO_LOAD = ["modbus"] CODEOWNERS = ["@sourabhjaiswal"] @@ -49,7 +50,7 @@ UNIT_KILOVOLT_AMPS_REACTIVE_HOURS = "kVARh" selec_meter_ns = cg.esphome_ns.namespace("selec_meter") SelecMeter = selec_meter_ns.class_( - "SelecMeter", cg.PollingComponent, modbus.ModbusDevice + "SelecMeter", cg.PollingComponent, modbus.ModbusClientDevice ) SENSORS = { @@ -163,10 +164,17 @@ CONFIG_SCHEMA = ( ) +def _final_validate(config: ConfigType) -> ConfigType: + return modbus.final_validate_modbus_device("selec_meter", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await modbus.register_modbus_device(var, config) + await modbus.register_modbus_client_device(var, config) for name in SENSORS: if name in config: sens = await sensor.new_sensor(config[name]) From b79cbcbde77dce3582c5d4ae9dd0045b08a596e2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:56:18 -0400 Subject: [PATCH 0676/1815] [espidf] Install native ESP-IDF into a machine-global cache dir (#17306) --- docker/docker_entrypoint.sh | 4 ++ .../etc/s6-overlay/s6-rc.d/esphome/run | 4 ++ esphome/__main__.py | 5 +- esphome/espidf/clang_tidy.py | 6 +-- esphome/espidf/framework.py | 35 +++++++++----- esphome/writer.py | 9 ++++ requirements.txt | 1 + tests/unit_tests/test_espidf_framework.py | 47 +++++++++++++++++++ tests/unit_tests/test_writer.py | 38 +++++++++++++-- 9 files changed, 129 insertions(+), 20 deletions(-) diff --git a/docker/docker_entrypoint.sh b/docker/docker_entrypoint.sh index 18baf40c29..598b553c08 100755 --- a/docker/docker_entrypoint.sh +++ b/docker/docker_entrypoint.sh @@ -21,6 +21,10 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms" export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages" export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache" +# Keep the native ESP-IDF install on the persistent cache root, not the +# container's ephemeral user cache dir (re-downloaded on every restart). +export ESPHOME_ESP_IDF_PREFIX="$(dirname "${pio_cache_base}")/idf" + # If /build is mounted, use that as the build path # otherwise use path in /config (so that builds aren't lost on container restart) if [[ -d /build ]]; then diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run index dff61fd2f3..f50de659b9 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run @@ -15,6 +15,10 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms" export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages" export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache" +# Keep the native ESP-IDF install on the persistent /data volume, not the +# container's ephemeral user cache dir (wiped on every add-on update/restart). +export ESPHOME_ESP_IDF_PREFIX=/data/cache/idf + if bashio::config.true 'leave_front_door_open'; then export DISABLE_HA_AUTHENTICATION=true fi diff --git a/esphome/__main__.py b/esphome/__main__.py index 1062df7167..1767d3b7ca 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2386,7 +2386,10 @@ def parse_args(argv): ) parser_clean_all = subparsers.add_parser( - "clean-all", help="Clean all build and platform files." + "clean-all", + help="Clean all build and platform files, including machine-global " + "toolchain caches shared by all configurations, so other projects will " + "re-download them on next build.", ) parser_clean_all.add_argument( "configuration", help="Your YAML file or configuration directory.", nargs="*" diff --git a/esphome/espidf/clang_tidy.py b/esphome/espidf/clang_tidy.py index d3f4d151c2..88ecda60b9 100644 --- a/esphome/espidf/clang_tidy.py +++ b/esphome/espidf/clang_tidy.py @@ -147,9 +147,9 @@ def _setup_core(work_dir: Path, settings: _Settings) -> None: from esphome.core import CORE CORE.name = TIDY_PROJECT_NAME - # config_path's parent is the data dir root: the IDF install lives at - # ``/.esphome/idf`` -- keep it beside (not inside) the per-run - # project dir so clearing the project doesn't force an IDF re-download. + # config_path's parent is the data dir root for per-run artifacts (idedata, + # converted pio_components). The IDF install is in the global cache dir, + # independent of this path. CORE.config_path = work_dir.parent / "tidy.yaml" CORE.build_path = work_dir esp32 = CORE.data.setdefault(KEY_ESP32, {}) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index c994ce2410..25283e3c99 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -9,6 +9,8 @@ import re import shutil import tempfile +import platformdirs + from esphome.config_validation import Version from esphome.core import CORE from esphome.framework_helpers import ( @@ -80,10 +82,18 @@ def _get_idf_tools_path() -> Path: Returns: Path object pointing to the ESP-IDF tools directory """ - if "ESPHOME_ESP_IDF_PREFIX" in os.environ: - path = Path(get_str_env("ESPHOME_ESP_IDF_PREFIX", None)).expanduser() + # Treat an empty/whitespace ESPHOME_ESP_IDF_PREFIX as unset: Path("") + # resolves to the CWD, which would install into (and let clean-all delete) + # the working directory by accident. + if prefix := get_str_env("ESPHOME_ESP_IDF_PREFIX", "").strip(): + path = Path(prefix).expanduser() else: - path = CORE.data_dir / "idf" + # Machine-global so all projects share the multi-GB install instead of + # a per-config-directory copy. The user cache dir (not ~/.esphome) + # avoids colliding with data_dir when configs live in the home dir. + # appauthor=False drops the redundant \ segment on Windows + # (which otherwise repeats "esphome\esphome\") to keep the path short. + path = Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf" # Resolve so an unnormalized config path (e.g. compiling ``../config/x.yaml``) # doesn't leave ``..`` segments in the IDF_TOOLS_PATH handed to idf.py, which # otherwise warns that the venv interpreter path doesn't match the install. @@ -145,10 +155,11 @@ def _check_windows_path_length() -> None: " fatal error: bits/c++config.h: No such file or directory\n" " cannot execute 'as': CreateProcess: No such file or directory\n" "To fix, either:\n" - " - Enable Windows long path support: set\n" - " HKLM\\SYSTEM\\CurrentControlSet\\Control\\FileSystem\\LongPathsEnabled\n" - " to 1 and reboot, or\n" - " - Move your ESPHome project to a shorter path\n" + " - Enable Windows long path support, then reboot. In an elevated\n" + " PowerShell run:\n" + " Set-ItemProperty 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\FileSystem' LongPathsEnabled 1\n" + " Details: https://learn.microsoft.com/windows/win32/fileio/maximum-file-path-limitation\n" + " - Or set ESPHOME_ESP_IDF_PREFIX to a shorter path (e.g. C:\\ESPHome\\idf)\n" "Then delete the ESP-IDF tools directory above so the toolchain " "reinstalls cleanly.", tools_path, @@ -553,7 +564,7 @@ def _check_esphome_idf_framework_install( # Logged every invocation (not just on install) so the user can verify the # override. A changed URL needs ``esphome clean-all`` to force a re-download # (``esphome clean`` only wipes the build dir, not the extracted framework - # under /idf/frameworks/). + # under the global install dir's ``frameworks/``). if source_url: _LOGGER.info("Using framework source override: %s", source_url) @@ -822,11 +833,9 @@ def _ccache_env() -> dict[str, str]: Enabled by default whenever the ``ccache`` binary is on PATH; set ``IDF_CCACHE_ENABLE=0`` in the environment to opt out. The cache lives under - the IDF tools path. How widely it is shared depends on where that resolves: - across projects (and surviving ``clean-all``) when it is a common location - (``ESPHOME_ESP_IDF_PREFIX`` or the add-on ``/data``), but per-project under - ``.esphome/idf`` for a default pip install, where ``clean-all`` clears it - along with the framework. + the IDF tools path (the machine-global cache dir, or + ``ESPHOME_ESP_IDF_PREFIX``), so it is shared across all projects and removed + by ``esphome clean-all`` along with the framework. Depend mode keeps cache-miss overhead low (hashes the compiler's depfiles instead of preprocessing). ``CCACHE_BASEDIR`` rewrites the per-build diff --git a/esphome/writer.py b/esphome/writer.py index a9c072f156..52f2d169b3 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -653,6 +653,15 @@ def clean_all(configuration: list[str]): elif item.is_dir() and item.name != "storage": rmtree(item) + # The native ESP-IDF install lives in a machine-global cache dir, outside + # any .esphome data dir, so the per-config loop above won't reach it. + from esphome.espidf.framework import _get_idf_tools_path + + idf_install_path = _get_idf_tools_path() + if idf_install_path.is_dir(): + _LOGGER.info("Deleting %s", idf_install_path) + rmtree(idf_install_path) + # Clean PlatformIO project files try: from platformio.project.config import ProjectConfig diff --git a/requirements.txt b/requirements.txt index 85f4b56c07..3832045cfc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,6 +23,7 @@ bleak==2.1.1 smpclient==6.0.0 requests==2.34.2 py7zr==1.1.3 +platformdirs==4.9.4 # native esp-idf toolchain global cache dir # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index fe888ac8b9..f3e160925a 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -36,6 +36,19 @@ from esphome.espidf.framework import ( from esphome.framework_helpers import _tar_extract_all, get_python_env_executable_path +@pytest.fixture(autouse=True) +def _isolate_idf_install_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Pin the ESP-IDF install root to a tmp dir for every test. + + The default location is the OS user cache dir, so without this any test + that builds framework paths or pre-creates the framework dir would touch + the real ``~/.cache/esphome`` on the developer's machine. Tests that need + to exercise the override or default-resolution logic clear/override the env + themselves. + """ + monkeypatch.setenv("ESPHOME_ESP_IDF_PREFIX", str(tmp_path / "idf_install")) + + @pytest.mark.parametrize( ("source", "expected"), [ @@ -791,6 +804,38 @@ def test_get_idf_tools_path_env_override(tmp_path: Path) -> None: assert _get_idf_tools_path() == Path(override) +@pytest.mark.parametrize("value", ["", " "]) +def test_get_idf_tools_path_blank_env_falls_back_to_default( + value: str, monkeypatch: pytest.MonkeyPatch +) -> None: + """A blank ESPHOME_ESP_IDF_PREFIX is treated as unset, not as CWD. + + Path("") would resolve to the working directory, which clean-all could then + delete by accident. + """ + import platformdirs + + monkeypatch.setenv("ESPHOME_ESP_IDF_PREFIX", value) + expected = ( + Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf" + ).resolve() + assert _get_idf_tools_path() == expected + + +def test_get_idf_tools_path_default_uses_user_cache( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Without the env override the install root is the machine-global OS user + cache dir, not the per-config ``/idf``.""" + import platformdirs + + monkeypatch.delenv("ESPHOME_ESP_IDF_PREFIX", raising=False) + expected = ( + Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf" + ).resolve() + assert _get_idf_tools_path() == expected + + def test_write_idf_version_txt_warns_on_write_error(tmp_path: Path) -> None: with patch("pathlib.Path.write_text", side_effect=OSError("denied")): # write failure is caught and warned, not raised @@ -908,3 +953,5 @@ def test_check_windows_path_length_long_path_warns( message = caplog.records[0].getMessage() assert _LONG_IDF_PATH in message assert "long path support" in message + # The install is global now; the remedy is the prefix env, not moving the project. + assert "ESPHOME_ESP_IDF_PREFIX" in message diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index c8cf68ff3e..18d08e7cb1 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -67,15 +67,23 @@ def _isolate_platformio_paths(tmp_path_factory: pytest.TempPathFactory) -> Any: want to verify the PIO-cleanup branch (e.g. test_clean_all, test_clean_all_partial_exists) install their own inner patch which stacks on top of this one and wins for the duration of their block. + + Also pin ``ESPHOME_ESP_IDF_PREFIX`` to a nonexistent tmp dir for the + same reason: ``clean_all`` removes the now machine-global ESP-IDF + install, which otherwise defaults to the real ``~/.cache/esphome``. """ pio_root = tmp_path_factory.mktemp("isolated_pio") / "nonexistent" + idf_root = tmp_path_factory.mktemp("isolated_idf") / "nonexistent" mock_cfg = MagicMock() mock_cfg.get.side_effect = lambda section, option: ( str(pio_root / option) if section == "platformio" else "" ) - with patch( - "platformio.project.config.ProjectConfig.get_instance", - return_value=mock_cfg, + with ( + patch( + "platformio.project.config.ProjectConfig.get_instance", + return_value=mock_cfg, + ), + patch.dict("os.environ", {"ESPHOME_ESP_IDF_PREFIX": str(idf_root)}), ): yield @@ -990,6 +998,30 @@ def test_clean_all_with_yaml_file( assert str(build_dir) in caplog.text +@patch("esphome.writer.CORE") +def test_clean_all_removes_global_idf_install( + mock_core: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """clean_all removes the machine-global native ESP-IDF install dir.""" + idf_install = tmp_path / "idf_install" + (idf_install / "frameworks").mkdir(parents=True) + monkeypatch.setenv("ESPHOME_ESP_IDF_PREFIX", str(idf_install)) + + config_dir = tmp_path / "config" + config_dir.mkdir() + + from esphome.writer import clean_all + + with caplog.at_level("INFO"): + clean_all([str(config_dir)]) + + assert not idf_install.exists() + assert str(idf_install.resolve()) in caplog.text + + @patch("esphome.writer.CORE") def test_clean_all_with_yaml_build_path( mock_core: MagicMock, From 990431aa5bf201c02d8560ddc30efe34d195d748 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:56:30 -0400 Subject: [PATCH 0677/1815] [bluetooth_proxy] Fix -Wtype-limits warning with active: false (#17273) --- esphome/components/bluetooth_proxy/bluetooth_proxy.h | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 10449f21f1..2b6d29da43 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -68,11 +68,15 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, void loop() override; esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; - void register_connection(BluetoothConnection *connection) { + // maybe_unused: in a passive proxy (active: false) MAX is 0, the body below is removed, and connection is unused. + void register_connection([[maybe_unused]] BluetoothConnection *connection) { + // Guard the always-false comparison (-Wtype-limits) in a passive proxy (active: false), where MAX is 0. +#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0 if (this->connection_count_ < BLUETOOTH_PROXY_MAX_CONNECTIONS) { this->connections_[this->connection_count_++] = connection; connection->proxy_ = this; } +#endif } void bluetooth_device_request(const api::BluetoothDeviceRequest &msg); From 9468ad628cdf848590ea8238cf5a77e604b54c9f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:57:34 -0400 Subject: [PATCH 0678/1815] [espnow] Drop oversized received frames to prevent buffer overflow (#17271) --- esphome/components/espnow/espnow_component.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index 2756b615a1..91f2c067ca 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -94,6 +94,15 @@ void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status) } void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size) { + // Drop oversized frames before copying. ESP-NOW v2 peers (IDF >= 5.4 builds a + // v2 stack with no opt-out) can send up to ESP_NOW_MAX_DATA_LEN_V2 (1470 B), + // but our receive buffer is ESP_NOW_MAX_DATA_LEN (250 B); copying a larger + // frame would overflow packet_.receive.data. + if (size < 0 || size > ESP_NOW_MAX_DATA_LEN) { + global_esp_now->receive_packet_queue_.increment_dropped_count(); + return; + } + // Allocate an event from the pool ESPNowPacket *packet = global_esp_now->receive_packet_pool_.allocate(); if (packet == nullptr) { @@ -327,13 +336,13 @@ void ESPNowComponent::loop() { // Log dropped received packets periodically uint16_t received_dropped = this->receive_packet_queue_.get_and_reset_dropped_count(); if (received_dropped > 0) { - ESP_LOGW(TAG, "Dropped %u received packets due to buffer overflow", received_dropped); + ESP_LOGW(TAG, "Dropped %u received packets (queue full or oversized frame)", received_dropped); } // Log dropped send packets periodically uint16_t send_dropped = this->send_packet_queue_.get_and_reset_dropped_count(); if (send_dropped > 0) { - ESP_LOGW(TAG, "Dropped %u send packets due to buffer overflow", send_dropped); + ESP_LOGW(TAG, "Dropped %u send packets (queue full)", send_dropped); } } From 8a3d0aeafb61c8a10f8f118918b983c7f0279bbc Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:33:17 -0400 Subject: [PATCH 0679/1815] [tests] Add esp32-c61-idf base file for grouped component tests (#17293) --- .../build_components_base.esp32-c61-idf.yaml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 tests/test_build_components/build_components_base.esp32-c61-idf.yaml diff --git a/tests/test_build_components/build_components_base.esp32-c61-idf.yaml b/tests/test_build_components/build_components_base.esp32-c61-idf.yaml new file mode 100644 index 0000000000..e1bd4645cc --- /dev/null +++ b/tests/test_build_components/build_components_base.esp32-c61-idf.yaml @@ -0,0 +1,18 @@ +esphome: + name: componenttestesp32c61idf + friendly_name: $component_name + +esp32: + variant: ESP32C61 + flash_size: 8MB + framework: + type: esp-idf + +logger: + level: VERY_VERBOSE + +packages: + component_under_test: !include + file: $component_test_file + vars: + component_test_file: $component_test_file From 1b556f5d0cd45c8d3ae790aaead09676a9858137 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:17:47 -0400 Subject: [PATCH 0680/1815] [ethernet] Fix ETH_SPEED_1000M build on IDF 6.0 (enum added in 6.1) (#17311) --- esphome/components/ethernet/ethernet_component_esp32.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 7a1bcae42f..5ad1e7d483 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -839,7 +839,7 @@ void EthernetComponent::dump_connect_params_() { case ETH_SPEED_100M: link_speed = 100; break; -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0) case ETH_SPEED_1000M: link_speed = 1000; break; From c8b37fb1c8bb735707988b65c96f913358d1b189 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:18:33 -0400 Subject: [PATCH 0681/1815] Bump platformdirs from 4.9.4 to 4.10.0 (#17309) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3832045cfc..4237ad0f81 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,7 +23,7 @@ bleak==2.1.1 smpclient==6.0.0 requests==2.34.2 py7zr==1.1.3 -platformdirs==4.9.4 # native esp-idf toolchain global cache dir +platformdirs==4.10.0 # native esp-idf toolchain global cache dir # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 From 4c9ed129cfb825a30ecd989c2d76f265f6332cb7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:28:04 -0400 Subject: [PATCH 0682/1815] Bump awalsh128/cache-apt-pkgs-action from 1.6.0 to 1.6.3 (#17310) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ac55aa006..2016739c4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -820,7 +820,7 @@ jobs: run: echo ${{ matrix.components }} - name: Cache apt packages - uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0 + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 with: packages: libsdl2-dev ccache version: 1.1 From 848defedd87eb9943afacec249b9a4f6b6300a1e Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:09:48 +1200 Subject: [PATCH 0683/1815] Bump bundled esphome-device-builder to 1.0.23 (#17316) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 04e7998f77..af80d01496 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.22 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.23 RUN \ platformio settings set enable_telemetry No \ From 3b2be021b23bd0b2a31817026264517b6771331b Mon Sep 17 00:00:00 2001 From: Julian Lunz <117189+jlunz@users.noreply.github.com> Date: Thu, 25 Jun 2026 08:21:41 +0200 Subject: [PATCH 0684/1815] [adc] Only call cyw43_thread_enter/exit for VSYS when WiFi is active on RP2040 (#17203) --- esphome/components/adc/adc_sensor_rp2040.cpp | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/esphome/components/adc/adc_sensor_rp2040.cpp b/esphome/components/adc/adc_sensor_rp2040.cpp index 8d41edb814..894c346588 100644 --- a/esphome/components/adc/adc_sensor_rp2040.cpp +++ b/esphome/components/adc/adc_sensor_rp2040.cpp @@ -66,15 +66,18 @@ float ADCSensor::sample() { } uint8_t pin = this->pin_->get_pin(); -#ifdef CYW43_USES_VSYS_PIN +#if defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI) if (pin == PICO_VSYS_PIN) { // Measuring VSYS on Raspberry Pico W needs to be wrapped with // `cyw43_thread_enter()`/`cyw43_thread_exit()` as discussed in // https://github.com/raspberrypi/pico-sdk/issues/1222, since Wifi chip and - // VSYS ADC both share GPIO29 + // VSYS ADC both share GPIO29. + // The USE_WIFI guard is required because CYW43_USES_VSYS_PIN can be defined + // transitively (e.g. via lwip_wrap.h) even on non-WiFi boards where the CYW43 + // driver is never initialized; calling cyw43_thread_enter() there hard-faults. cyw43_thread_enter(); } -#endif // CYW43_USES_VSYS_PIN +#endif // defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI) adc_gpio_init(pin); adc_select_input(pin - 26); @@ -84,11 +87,11 @@ float ADCSensor::sample() { aggr.add_sample(raw); } -#ifdef CYW43_USES_VSYS_PIN +#if defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI) if (pin == PICO_VSYS_PIN) { cyw43_thread_exit(); } -#endif // CYW43_USES_VSYS_PIN +#endif // defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI) if (this->output_raw_) { return aggr.aggregate(); From 054c8ba48593774ce83fe7d4d497fa267a030311 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:28:43 +1200 Subject: [PATCH 0685/1815] [config_validation] Fix multicast typo in error message (#17206) --- esphome/config_validation.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 0ef6d212fe..3ff2c975a1 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1455,9 +1455,7 @@ def ipv6address(value): def ipv4address_multi_broadcast(value): address = ipv4address(value) if not (address.is_multicast or (address == IPv4Address("255.255.255.255"))): - raise Invalid( - f"{value} is not a multicasst address nor local broadcast address" - ) + raise Invalid(f"{value} is not a multicast address nor local broadcast address") return address From 5de508ad8caa279ba7b2ba5bcbd474f42f5a9612 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 27 Jun 2026 16:21:36 +0200 Subject: [PATCH 0686/1815] [es8388] Fix DAC unable to unmute once muted (#17221) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/es8388/es8388.cpp | 8 +++++++- esphome/components/es8388/es8388_const.h | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/components/es8388/es8388.cpp b/esphome/components/es8388/es8388.cpp index c015393e14..0b97240230 100644 --- a/esphome/components/es8388/es8388.cpp +++ b/esphome/components/es8388/es8388.cpp @@ -173,8 +173,14 @@ bool ES8388::set_mute_state_(bool mute_state) { ES8388_ERROR_CHECK(this->read_byte(ES8388_DACCONTROL3, &value)); ESP_LOGV(TAG, "Read ES8388_DACCONTROL3: 0x%02X", value); + // Only toggle the DACMute bit; the other bits of this register hold unrelated + // DAC settings that must be preserved. Previously muting overwrote the whole + // register with 0x3C and unmuting never cleared the bit, so once muted the DAC + // could not be unmuted again. if (mute_state) { - value = 0x3C; + value |= ES8388_DACCONTROL3_DAC_MUTE; + } else { + value &= ~ES8388_DACCONTROL3_DAC_MUTE; } ESP_LOGV(TAG, "Setting ES8388_DACCONTROL3 to 0x%02X (muted: %s)", value, YESNO(mute_state)); diff --git a/esphome/components/es8388/es8388_const.h b/esphome/components/es8388/es8388_const.h index 451c9cc026..e081c55dbd 100644 --- a/esphome/components/es8388/es8388_const.h +++ b/esphome/components/es8388/es8388_const.h @@ -38,6 +38,7 @@ static const uint8_t ES8388_ADCCONTROL14 = 0x16; static const uint8_t ES8388_DACCONTROL1 = 0x17; static const uint8_t ES8388_DACCONTROL2 = 0x18; static const uint8_t ES8388_DACCONTROL3 = 0x19; +static const uint8_t ES8388_DACCONTROL3_DAC_MUTE = 0x04; // DACMute, bit 2 of DACCONTROL3 static const uint8_t ES8388_DACCONTROL4 = 0x1a; static const uint8_t ES8388_DACCONTROL5 = 0x1b; static const uint8_t ES8388_DACCONTROL6 = 0x1c; From 782b58bbeb6a7b2c0386ae3f3cd38ef6f0ee54f8 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 02:07:06 +0000 Subject: [PATCH 0687/1815] Bump bundled esphome-device-builder to 1.0.22 --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 8dce7861df..079fd0602b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.21 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.22 RUN \ platformio settings set enable_telemetry No \ From b127363fa0a8391473bdff934b0aaab264d332e8 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:43:01 +1000 Subject: [PATCH 0688/1815] [mipi_spi] Bug fixes (#17247) --- esphome/components/mipi_spi/display.py | 2 ++ esphome/components/mipi_spi/mipi_spi.h | 3 +++ esphome/components/mipi_spi/models/ili.py | 7 +------ tests/component_tests/mipi_spi/test_init.py | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 4162459058..871736abd1 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -425,6 +425,8 @@ async def to_code(config): dc_pin = await cg.gpio_pin_expression(dc_pin) cg.add(var.set_dc_pin(dc_pin)) + if config.get(CONF_INVERT_COLORS): + cg.add(var.set_invert_colors(True)) if lamb := config.get(CONF_LAMBDA): lambda_ = await cg.process_lambda( lamb, [(display.DisplayRef, "it")], return_type=cg.void diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index d9627899e0..48184fa5c1 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -151,6 +151,9 @@ class MipiSpi : public display::Display, this->reset_pin_->digital_write(false); delay(5); this->reset_pin_->digital_write(true); + } else { + // no reset pin, send software reset command + this->write_command_(SW_RESET_CMD); } // need to know when the display is ready for SLPOUT command - will be 120ms after reset diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index 5df7a275df..5598a51073 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -24,13 +24,11 @@ from esphome.components.mipi import ( PWSET, PWSETN, SETEXTC, - SWRESET, VMCTR, VMCTR1, VMCTR2, VSCRSADD, DriverChip, - delay, ) from esphome.components.spi import TYPE_OCTAL @@ -367,7 +365,6 @@ ST7796 = DriverChip( width=320, height=480, initsequence=( - (SWRESET,), (CSCON, 0xC3), (CSCON, 0x96), (VMCTR1, 0x1C), @@ -728,8 +725,6 @@ DriverChip( width=128, height=160, initsequence=( - SWRESET, - delay(10), (FRMCTR1, 0x01, 0x2C, 0x2D), (FRMCTR2, 0x01, 0x2C, 0x2D), (FRMCTR3, 0x01, 0x2C, 0x2D, 0x01, 0x2C, 0x2D), @@ -786,7 +781,7 @@ ST7796.extend( bus_mode=TYPE_OCTAL, mirror_x=True, reset_pin=4, - dc_pin=0, + dc_pin={"number": 0, "ignore_strapping_warning": True}, invert_colors=True, ) diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index dbd8e15702..8edbe095b7 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -377,6 +377,6 @@ def test_lvgl_generation( "mipi_spi::MipiSpi();" in main_cpp ) - assert "set_init_sequence({1, 0, 10, 255, 177" in main_cpp + assert "set_init_sequence({177, 3, 1, 44, 45, 178" in main_cpp assert "show_test_card();" not in main_cpp assert "set_auto_clear(false);" in main_cpp From 6c44775bf594ad1d8f51fda1dc0bd71eef91b4a2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:56:30 -0400 Subject: [PATCH 0689/1815] [bluetooth_proxy] Fix -Wtype-limits warning with active: false (#17273) --- esphome/components/bluetooth_proxy/bluetooth_proxy.h | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 10449f21f1..2b6d29da43 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -68,11 +68,15 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, void loop() override; esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; - void register_connection(BluetoothConnection *connection) { + // maybe_unused: in a passive proxy (active: false) MAX is 0, the body below is removed, and connection is unused. + void register_connection([[maybe_unused]] BluetoothConnection *connection) { + // Guard the always-false comparison (-Wtype-limits) in a passive proxy (active: false), where MAX is 0. +#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0 if (this->connection_count_ < BLUETOOTH_PROXY_MAX_CONNECTIONS) { this->connections_[this->connection_count_++] = connection; connection->proxy_ = this; } +#endif } void bluetooth_device_request(const api::BluetoothDeviceRequest &msg); From 06c5bcbc668bcf4591fe69569e0558917be389d2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:57:34 -0400 Subject: [PATCH 0690/1815] [espnow] Drop oversized received frames to prevent buffer overflow (#17271) --- esphome/components/espnow/espnow_component.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index 2756b615a1..91f2c067ca 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -94,6 +94,15 @@ void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status) } void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size) { + // Drop oversized frames before copying. ESP-NOW v2 peers (IDF >= 5.4 builds a + // v2 stack with no opt-out) can send up to ESP_NOW_MAX_DATA_LEN_V2 (1470 B), + // but our receive buffer is ESP_NOW_MAX_DATA_LEN (250 B); copying a larger + // frame would overflow packet_.receive.data. + if (size < 0 || size > ESP_NOW_MAX_DATA_LEN) { + global_esp_now->receive_packet_queue_.increment_dropped_count(); + return; + } + // Allocate an event from the pool ESPNowPacket *packet = global_esp_now->receive_packet_pool_.allocate(); if (packet == nullptr) { @@ -327,13 +336,13 @@ void ESPNowComponent::loop() { // Log dropped received packets periodically uint16_t received_dropped = this->receive_packet_queue_.get_and_reset_dropped_count(); if (received_dropped > 0) { - ESP_LOGW(TAG, "Dropped %u received packets due to buffer overflow", received_dropped); + ESP_LOGW(TAG, "Dropped %u received packets (queue full or oversized frame)", received_dropped); } // Log dropped send packets periodically uint16_t send_dropped = this->send_packet_queue_.get_and_reset_dropped_count(); if (send_dropped > 0) { - ESP_LOGW(TAG, "Dropped %u send packets due to buffer overflow", send_dropped); + ESP_LOGW(TAG, "Dropped %u send packets (queue full)", send_dropped); } } From 4472d3b61bd45f8b8b8ff3c04a90c3fba1410879 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:09:48 +1200 Subject: [PATCH 0691/1815] Bump bundled esphome-device-builder to 1.0.23 (#17316) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 079fd0602b..0c3b27a04d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.22 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.23 RUN \ platformio settings set enable_telemetry No \ From e47feace11b22d4d7fc30068f18fa983ee828668 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:18:54 +1200 Subject: [PATCH 0692/1815] Bump version to 2026.6.4 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index bc92241937..e38f280006 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.6.3 +PROJECT_NUMBER = 2026.6.4 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index b7ffb9121d..81bde6dfa2 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.6.3" +__version__ = "2026.6.4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 0e260e5cbbe14e5a8aa7f0f8aeeac8ea20e1a931 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:52:09 -0500 Subject: [PATCH 0693/1815] Bump bundled esphome-device-builder to 1.0.24 (#17332) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index af80d01496..064ba2a358 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.23 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.24 RUN \ platformio settings set enable_telemetry No \ From d25d1606867972060c22d2c7dea9118ae89a408d Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Wed, 1 Jul 2026 13:01:48 -0700 Subject: [PATCH 0694/1815] [modbus_server] Fix register range issues and allow partial reads (#17205) Co-authored-by: Claude Opus 4.8 Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/modbus_server/__init__.py | 55 +++++- esphome/components/modbus_server/const.py | 1 + .../modbus_server/modbus_server.cpp | 107 ++++++++---- .../components/modbus_server/modbus_server.h | 6 + .../modbus_server/test_modbus_server.py | 84 +++++++++ tests/components/modbus_server/common.yaml | 1 + .../modbus_server/modbus_server_test.cpp | 161 ++++++++++++++++++ 7 files changed, 382 insertions(+), 33 deletions(-) create mode 100644 tests/component_tests/modbus_server/test_modbus_server.py diff --git a/esphome/components/modbus_server/__init__.py b/esphome/components/modbus_server/__init__.py index 2ba7f41b83..14f4ca8a4d 100644 --- a/esphome/components/modbus_server/__init__.py +++ b/esphome/components/modbus_server/__init__.py @@ -8,8 +8,10 @@ from esphome.components.modbus.helpers import ( ) import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID +from esphome.types import ConfigType from .const import ( + CONF_ALLOW_PARTIAL_READ, CONF_COURTESY_RESPONSE, CONF_READ_LAMBDA, CONF_REGISTER_LAST_ADDRESS, @@ -41,17 +43,62 @@ SERVER_COURTESY_RESPONSE_SCHEMA = cv.Schema( } ) +# RAW has no numeric encoding, so it is not a valid server register type: a server value is produced by a +# lambda and encoded into registers, and on the server a RAW register would just be a single 16-bit word -- +# use U_WORD for that. Restrict the choices to the encodable types. +SERVER_SENSOR_VALUE_TYPE = { + key: value for key, value in SENSOR_VALUE_TYPE.items() if key != "RAW" +} + ModbusServerRegisterSchema = cv.Schema( { cv.GenerateID(): cv.declare_id(ServerRegister), cv.Required(CONF_ADDRESS): cv.hex_uint16_t, - cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum(SENSOR_VALUE_TYPE), + cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum( + SERVER_SENSOR_VALUE_TYPE + ), cv.Required(CONF_READ_LAMBDA): cv.returning_lambda, cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, + cv.Optional(CONF_ALLOW_PARTIAL_READ, default=False): cv.boolean, } ) +def _validate_register_ranges(config: ConfigType) -> ConfigType: + # Each register occupies [address, address + register_count); the whole span must fit inside the 16-bit + # Modbus address space (0x0000-0xFFFF). + for register in config.get(CONF_REGISTERS, []): + address = register[CONF_ADDRESS] + register_count = TYPE_REGISTER_MAP[register[CONF_VALUE_TYPE]] + if address + register_count > 0x10000: + raise cv.Invalid( + f"Register at 0x{address:04X} spans {register_count} register(s) and runs past " + "the end of the 16-bit address space (0xFFFF)", + path=[CONF_REGISTERS], + ) + return config + + +def _validate_no_overlapping_registers(config: ConfigType) -> ConfigType: + # Each register occupies [address, address + register_count). Reject configs where any two ranges + # overlap -- the same address twice, or a multi-register value straddling a neighbour -- since the + # server resolves a request by the value containing an address and overlaps are ambiguous. + spans = sorted( + (register[CONF_ADDRESS], TYPE_REGISTER_MAP[register[CONF_VALUE_TYPE]]) + for register in config.get(CONF_REGISTERS, []) + ) + for (address, register_count), (next_address, _) in zip( + spans, spans[1:], strict=False + ): + if next_address < address + register_count: + raise cv.Invalid( + f"Register address 0x{next_address:04X} overlaps the register at 0x{address:04X}, " + f"which spans {register_count} register(s); each register's address range must be unique", + path=[CONF_REGISTERS], + ) + return config + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -62,10 +109,12 @@ CONFIG_SCHEMA = cv.All( ): cv.ensure_list(ModbusServerRegisterSchema), } ).extend(modbus.modbus_device_schema(0x01, role="server")), + _validate_register_ranges, + _validate_no_overlapping_registers, ) -def _final_validate(config): +def _final_validate(config: ConfigType) -> ConfigType: return modbus.final_validate_modbus_device("modbus_server", role="server")(config) @@ -118,6 +167,8 @@ async def to_code(config): ), ) ) + if server_register[CONF_ALLOW_PARTIAL_READ]: + cg.add(server_register_var.set_allow_partial_read(True)) cg.add(var.add_server_register(server_register_var)) await cg.register_component(var, config) return await modbus.register_modbus_server_device(var, config) diff --git a/esphome/components/modbus_server/const.py b/esphome/components/modbus_server/const.py index f83211c207..f2a8c53f45 100644 --- a/esphome/components/modbus_server/const.py +++ b/esphome/components/modbus_server/const.py @@ -5,3 +5,4 @@ CONF_COURTESY_RESPONSE = "courtesy_response" CONF_READ_LAMBDA = "read_lambda" CONF_WRITE_LAMBDA = "write_lambda" CONF_REGISTERS = "registers" +CONF_ALLOW_PARTIAL_READ = "allow_partial_read" diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp index bb264eb993..44b1b160a5 100644 --- a/esphome/components/modbus_server/modbus_server.cpp +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -8,6 +8,25 @@ using modbus::helpers::registers_to_number; static const char *const TAG = "modbus_server"; +// The widest Modbus value type (QWORD) spans four registers. +static constexpr uint8_t MAX_REGISTERS_PER_VALUE = 4; +// number_to_payload() encodes the 64-bit value returned by read_lambda() into 16-bit registers, so the +// widest possible value spans exactly sizeof(int64_t) / sizeof(uint16_t) registers. Tie the bound to that +// source so a future wider value type -- which would require widening the encoded value itself -- can't +// silently overflow the value_words buffer below (StaticVector::push_back drops words past capacity). +static_assert(MAX_REGISTERS_PER_VALUE == sizeof(int64_t) / sizeof(uint16_t), + "MAX_REGISTERS_PER_VALUE must match the register span of the widest encodable value"); + +ServerRegister *ModbusServer::find_containing_register_(uint32_t address) const { + for (auto *server_register : this->server_registers_) { + if (address >= server_register->address && + address < static_cast(server_register->address) + server_register->register_count) { + return server_register; + } + } + return nullptr; +} + modbus::ServerResponseStatus ModbusServer::on_modbus_read_registers(uint16_t start_address, uint16_t number_of_registers, modbus::RegisterValues ®isters) { @@ -15,42 +34,68 @@ modbus::ServerResponseStatus ModbusServer::on_modbus_read_registers(uint16_t sta "Received read holding/input registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%X.", this->address_, start_address, number_of_registers); - 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(); - char value_buf[ServerRegister::FORMAT_VALUE_BUF_SIZE]; - ESP_LOGV(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, value_buf, sizeof(value_buf))); + const uint32_t end_address = static_cast(start_address) + number_of_registers; + uint32_t current_address = start_address; + while (current_address < end_address) { + ServerRegister *server_register = this->find_containing_register_(current_address); - modbus::helpers::number_to_payload(registers, value, server_register->value_type); - current_address += server_register->register_count; - found = true; - break; - } - } - - if (!found) { + if (server_register == nullptr) { + // Unregistered address: optionally answer with the courtesy default, otherwise reject. if (this->server_courtesy_response_.enabled && - (current_address <= this->server_courtesy_response_.register_last_address)) { - ESP_LOGV(TAG, - "Could not match any register to address 0x%02X, but default allowed. " - "Returning default value: %" PRIu16 ".", - current_address, this->server_courtesy_response_.register_value); + current_address <= this->server_courtesy_response_.register_last_address) { + ESP_LOGV(TAG, "No register at 0x%04X; returning courtesy default %" PRIu16 ".", + static_cast(current_address), this->server_courtesy_response_.register_value); registers.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); - return ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; + current_address += 1; // the courtesy default is always a single register + continue; } + ESP_LOGW(TAG, "No register at 0x%04X and courtesy default not allowed. Sending exception response.", + static_cast(current_address)); + return ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; } + + if (!server_register->read_lambda) { + // Registered but not readable (write-only); don't mask it with the courtesy default. + ESP_LOGW(TAG, "Register at 0x%04X is not readable. Sending exception response.", server_register->address); + return ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; + } + + // A multi-register value is normally atomic: the request must start at its first register and cover all of + // it. A value may opt in to partial reads, in which case the request may start inside it or stop short of + // its end and we return only the covered words. + const uint16_t value_offset = static_cast(current_address - server_register->address); + const uint16_t words_available = static_cast(server_register->register_count - value_offset); + const uint16_t words_wanted = static_cast(end_address - current_address); + const uint16_t take = words_available < words_wanted ? words_available : words_wanted; + const bool clipped = value_offset != 0 || take != server_register->register_count; + if (clipped && !server_register->allow_partial_read) { + ESP_LOGW(TAG, + "Read clips the multi-register value at 0x%04X, which does not allow partial reads. " + "Sending exception response.", + server_register->address); + return ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; + } + + int64_t value = server_register->read_lambda(); + char value_buf[ServerRegister::FORMAT_VALUE_BUF_SIZE]; + ESP_LOGV(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, value_buf, sizeof(value_buf))); + + // Encode the whole value once (wire word order) and emit only the covered words. Slicing the encoded words + // handles the reversed value types for free, since number_to_payload already emits in wire order. + StaticVector value_words; + modbus::helpers::number_to_payload(value_words, value, server_register->value_type); + if (value_offset + take > value_words.size()) { + // The value encoded to fewer words than its register span (e.g. a RAW register); treat as a device fault. + ESP_LOGE(TAG, "Register at 0x%04X did not encode to %u registers", server_register->address, + server_register->register_count); + return ModbusExceptionCode::SERVICE_DEVICE_FAILURE; + } + for (uint16_t i = 0; i < take; i++) { + registers.push_back(value_words[value_offset + i]); + } + current_address += take; } return {}; diff --git a/esphome/components/modbus_server/modbus_server.h b/esphome/components/modbus_server/modbus_server.h index 0c22454528..f68d1c4a30 100644 --- a/esphome/components/modbus_server/modbus_server.h +++ b/esphome/components/modbus_server/modbus_server.h @@ -84,9 +84,13 @@ class ServerRegister { } } + void set_allow_partial_read(bool allow_partial_read) { this->allow_partial_read = allow_partial_read; } + uint16_t address{0}; SensorValueType value_type{SensorValueType::RAW}; uint8_t register_count{0}; + // When true, a read may cover only part of this multi-register value; otherwise it must read the whole value. + bool allow_partial_read{false}; ReadLambda read_lambda; WriteLambda write_lambda; }; @@ -111,6 +115,8 @@ class ModbusServer : public Component, public modbus::ModbusServerDevice { ServerCourtesyResponse get_server_courtesy_response() const { return this->server_courtesy_response_; } protected: + /// Find the registered value whose register span contains address, or nullptr if none does. + ServerRegister *find_containing_register_(uint32_t address) const; /// Collection of all server registers for this component std::vector server_registers_{}; /// Server courtesy response diff --git a/tests/component_tests/modbus_server/test_modbus_server.py b/tests/component_tests/modbus_server/test_modbus_server.py new file mode 100644 index 0000000000..7c978a5cd5 --- /dev/null +++ b/tests/component_tests/modbus_server/test_modbus_server.py @@ -0,0 +1,84 @@ +"""Tests for modbus_server configuration validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.modbus_server import ( + SERVER_SENSOR_VALUE_TYPE, + _validate_no_overlapping_registers, + _validate_register_ranges, +) +from esphome.components.modbus_server.const import CONF_REGISTERS, CONF_VALUE_TYPE +from esphome.const import CONF_ADDRESS + + +def _config(registers: list[tuple[int, str]]) -> dict: + return { + CONF_REGISTERS: [ + {CONF_ADDRESS: address, CONF_VALUE_TYPE: value_type} + for address, value_type in registers + ] + } + + +def test_non_overlapping_registers_pass() -> None: + # Values that tile the address space without gaps or overlaps are accepted. + config = _config([(0x00, "U_WORD"), (0x01, "U_DWORD"), (0x03, "U_WORD")]) + assert _validate_no_overlapping_registers(config) is config + + +def test_registers_with_gaps_pass() -> None: + config = _config([(0x00, "U_WORD"), (0x05, "U_QWORD"), (0x20, "U_WORD")]) + assert _validate_no_overlapping_registers(config) is config + + +def test_no_registers_pass() -> None: + assert _validate_no_overlapping_registers({}) == {} + + +def test_duplicate_address_rejected() -> None: + config = _config([(0x10, "U_WORD"), (0x10, "U_WORD")]) + with pytest.raises(cv.Invalid, match="overlaps"): + _validate_no_overlapping_registers(config) + + +def test_multi_register_value_overlapping_neighbour_rejected() -> None: + # U_DWORD at 0x10 occupies 0x10 and 0x11; a U_WORD at 0x11 collides with its low word. + config = _config([(0x10, "U_DWORD"), (0x11, "U_WORD")]) + with pytest.raises(cv.Invalid, match="overlaps"): + _validate_no_overlapping_registers(config) + + +def test_overlap_detected_regardless_of_order() -> None: + # The U_DWORD at 0x10 covers 0x10-0x11 and overlaps the U_WORD at 0x11 even when declared after it. + config = _config([(0x11, "U_WORD"), (0x10, "U_DWORD")]) + with pytest.raises(cv.Invalid, match="overlaps"): + _validate_no_overlapping_registers(config) + + +def test_register_span_within_address_space_pass() -> None: + # A value whose span ends exactly at 0xFFFF is fine (U_QWORD at 0xFFFC covers 0xFFFC-0xFFFF). + config = _config([(0xFFFF, "U_WORD"), (0xFFFC, "U_QWORD")]) + assert _validate_register_ranges(config) is config + + +def test_register_span_past_end_rejected() -> None: + # U_QWORD at 0xFFFE would need 0xFFFE-0x10001, running off the 16-bit address space. + config = _config([(0xFFFE, "U_QWORD")]) + with pytest.raises(cv.Invalid, match="past the end"): + _validate_register_ranges(config) + + +def test_multi_register_value_at_last_address_rejected() -> None: + # A U_DWORD at 0xFFFF needs a second register at 0x10000, which does not exist. + config = _config([(0xFFFF, "U_DWORD")]) + with pytest.raises(cv.Invalid, match="past the end"): + _validate_register_ranges(config) + + +def test_raw_value_type_rejected() -> None: + # RAW has no numeric encoding, so it is not offered as a server register type. + validator = cv.enum(SERVER_SENSOR_VALUE_TYPE) + with pytest.raises(cv.Invalid): + validator("RAW") + assert validator("U_WORD") == "U_WORD" diff --git a/tests/components/modbus_server/common.yaml b/tests/components/modbus_server/common.yaml index 2e4a81a1aa..8b2316b6e3 100644 --- a/tests/components/modbus_server/common.yaml +++ b/tests/components/modbus_server/common.yaml @@ -18,6 +18,7 @@ modbus_server: registers: - address: 0x9 value_type: S_DWORD + allow_partial_read: true read_lambda: |- return 31; write_lambda: |- diff --git a/tests/components/modbus_server/modbus_server_test.cpp b/tests/components/modbus_server/modbus_server_test.cpp index 0c8f5d04cf..419bb9cf25 100644 --- a/tests/components/modbus_server/modbus_server_test.cpp +++ b/tests/components/modbus_server/modbus_server_test.cpp @@ -121,4 +121,165 @@ TEST(ModbusServerWrite, CallbackFailureIsServiceDeviceFailure) { EXPECT_TRUE(first_written); // pre-validation passed, so the first write applied before the failure } +// --- on_modbus_read_registers -------------------------------------------------- + +TEST(ModbusServerRead, SingleWordSucceeds) { + ModbusServer server; + ServerRegister reg(0x0000, SensorValueType::U_WORD, 1); + reg.read_lambda = []() -> int64_t { return 0x1234; }; + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_modbus_read_registers(0x0000, 1, out); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0], 0x1234); +} + +TEST(ModbusServerRead, DwordReturnsTwoWordsHighFirst) { + ModbusServer server; + ServerRegister reg(0x0000, SensorValueType::U_DWORD, 2); + reg.read_lambda = []() -> int64_t { return 0x12345678; }; + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_modbus_read_registers(0x0000, 2, out); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(out.size(), 2u); + EXPECT_EQ(out[0], 0x1234); + EXPECT_EQ(out[1], 0x5678); +} + +// Starting inside a multi-register value is rejected with ILLEGAL_DATA_ADDRESS -- not masked by the courtesy +// default -- and the read_lambda is never invoked. +TEST(ModbusServerRead, StartInsideValueRejected) { + ModbusServer server; + bool read_called = false; + ServerRegister reg(0x0010, SensorValueType::U_DWORD, 2); // occupies 0x0010 and 0x0011 + reg.read_lambda = [&read_called]() -> int64_t { + read_called = true; + return 0; + }; + server.set_server_courtesy_response( + ServerCourtesyResponse{.enabled = true, .register_last_address = 0xFFFF, .register_value = 0xABCD}); + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_modbus_read_registers(0x0011, 1, out); // the second cell of the DWORD + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_FALSE(read_called); +} + +// A read that stops short of a value's end clips it -> ILLEGAL_DATA_ADDRESS, and the read_lambda is not invoked. +TEST(ModbusServerRead, ClippedTailRejected) { + ModbusServer server; + bool read_called = false; + ServerRegister reg(0x0000, SensorValueType::U_DWORD, 2); + reg.read_lambda = [&read_called]() -> int64_t { + read_called = true; + return 0; + }; + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_modbus_read_registers(0x0000, 1, out); // only 1 of the DWORD's 2 registers + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_FALSE(read_called); +} + +// A write-only register (no read_lambda) is not readable -> ILLEGAL_DATA_ADDRESS, not a courtesy default. +TEST(ModbusServerRead, WriteOnlyRegisterRejected) { + ModbusServer server; + ServerRegister reg(0x0000, SensorValueType::U_WORD, 1); // no read_lambda set + server.set_server_courtesy_response( + ServerCourtesyResponse{.enabled = true, .register_last_address = 0xFFFF, .register_value = 0xABCD}); + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_modbus_read_registers(0x0000, 1, out); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +// An unregistered address with courtesy enabled returns the default value for each cell. +TEST(ModbusServerRead, CourtesyDefaultForUnregistered) { + ModbusServer server; + server.set_server_courtesy_response( + ServerCourtesyResponse{.enabled = true, .register_last_address = 0xFFFF, .register_value = 0xABCD}); + + RegisterValues out; + auto status = server.on_modbus_read_registers(0x0005, 2, out); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(out.size(), 2u); + EXPECT_EQ(out[0], 0xABCD); + EXPECT_EQ(out[1], 0xABCD); +} + +// An unregistered address with courtesy disabled is rejected. +TEST(ModbusServerRead, UnregisteredRejectedWithoutCourtesy) { + ModbusServer server; + RegisterValues out; + auto status = server.on_modbus_read_registers(0x0005, 1, out); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +// --- partial reads (opt-in) ---------------------------------------------------- + +// With allow_partial_read, reading only the first register of a DWORD returns its high word. +TEST(ModbusServerRead, PartialReadHighWord) { + ModbusServer server; + ServerRegister reg(0x0010, SensorValueType::U_DWORD, 2); + reg.allow_partial_read = true; + reg.read_lambda = []() -> int64_t { return 0x12345678; }; + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_modbus_read_registers(0x0010, 1, out); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0], 0x1234); +} + +// With allow_partial_read, starting at the interior cell returns the low word. +TEST(ModbusServerRead, PartialReadLowWordFromInterior) { + ModbusServer server; + ServerRegister reg(0x0010, SensorValueType::U_DWORD, 2); + reg.allow_partial_read = true; + reg.read_lambda = []() -> int64_t { return 0x12345678; }; + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_modbus_read_registers(0x0011, 1, out); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0], 0x5678); +} + +// Slicing is in wire order, so a reversed value type partials correctly: U_DWORD_R emits the low word +// first, so 0x0010 holds 0x5678 and 0x0011 holds 0x1234. +TEST(ModbusServerRead, PartialReadReversedType) { + ModbusServer server; + ServerRegister reg(0x0010, SensorValueType::U_DWORD_R, 2); + reg.allow_partial_read = true; + reg.read_lambda = []() -> int64_t { return 0x12345678; }; + server.add_server_register(®); + + RegisterValues first; + ASSERT_FALSE(server.on_modbus_read_registers(0x0010, 1, first).has_value()); + ASSERT_EQ(first.size(), 1u); + EXPECT_EQ(first[0], 0x5678); + + RegisterValues second; + ASSERT_FALSE(server.on_modbus_read_registers(0x0011, 1, second).has_value()); + ASSERT_EQ(second.size(), 1u); + EXPECT_EQ(second[0], 0x1234); +} + } // namespace esphome::modbus_server From 0427d20c5b87e808801f95c98427635a441e8762 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:02:01 -0500 Subject: [PATCH 0695/1815] Bump bundled esphome-device-builder to 1.0.25 (#17333) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 064ba2a358..543f17db56 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.24 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.25 RUN \ platformio settings set enable_telemetry No \ From e4a68c2da3461663ce4dcd24409e5e5494469a48 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:25:55 -0500 Subject: [PATCH 0696/1815] Bump pillow from 12.2.0 to 12.3.0 (#17335) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4237ad0f81..baa8b5efd2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 -pillow==12.2.0 +pillow==12.3.0 resvg-py==0.3.3 freetype-py==2.5.1 jinja2==3.1.6 From 7522780c67c7d4846526c91bd11bf8f9d8153a8d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Jul 2026 18:25:34 -0500 Subject: [PATCH 0697/1815] [esp8266] Strip dead libstdc++ throw message strings from DRAM (#17341) --- esphome/components/esp8266/__init__.py | 8 +++++ esphome/components/esp8266/throw_stubs.h | 41 ++++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 esphome/components/esp8266/throw_stubs.h diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 4daf4549ef..b658feb76a 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -310,6 +310,14 @@ async def to_code(config): # For cases where nullptrs can be handled, use nothrow: `new (std::nothrow) T;` cg.add_build_flag("-DNEW_OOM_ABORT") + # Force-include inline std::__throw_* overrides so GCC dead-strips the unused + # libstdc++ error message strings (e.g. "basic_string::_M_create") from DRAM. + # See throw_stubs.h for details. Must be prepended before , so this + # uses build_src_flags with -include. + cg.add_platformio_option( + "build_src_flags", "-include esphome/components/esp8266/throw_stubs.h" + ) + # In testing mode, fake larger memory to allow linking grouped component tests # Real ESP8266 hardware only has 32KB IRAM and ~80KB RAM, but for CI testing # we pretend it has much larger memory to test that components compile together diff --git a/esphome/components/esp8266/throw_stubs.h b/esphome/components/esp8266/throw_stubs.h new file mode 100644 index 0000000000..a650935a5e --- /dev/null +++ b/esphome/components/esp8266/throw_stubs.h @@ -0,0 +1,41 @@ +#pragma once +/* + * Inline overrides for std::__throw_* helpers (ESP8266). + * + * ESP8266 Arduino compiles with -fno-exceptions and ships a libstdc++ whose + * std::__throw_* functions already just call abort() -- they never read their + * const char* message argument. But the compiler still emits the message load + * at every throw site (inside header-instantiated std::string / std::vector + * code), so --gc-sections keeps those libstdc++ error strings alive. On + * ESP8266 .rodata lives in DRAM, so each one wastes scarce RAM (e.g. + * "basic_string::_M_construct null not valid", "basic_string::_M_create", + * "cannot create std::vector larger than max_size()", "array::at: ..."). + * + * Providing inline definitions here lets GCC see the message argument is + * unused, dead-strip the load, and drop the string entirely -- no LTO needed. + * Behavior is identical to today: a bare abort() (the message was never + * printed). This header MUST be force-included before , so it is + * wired up via build_src_flags "-include ..." in this component's __init__.py. + * + * Note: this defines functions in namespace std (technically UB). It is safe + * here because the definitions match the existing abort() behavior exactly. + */ + +#ifdef __cplusplus + +// Empty namespace so the CI namespace check is satisfied; the overrides below +// must live in namespace std, so they cannot go in the component namespace. +namespace esphome::esp8266 {} // namespace esphome::esp8266 + +// NOLINTBEGIN(bugprone-reserved-identifier,bugprone-std-namespace-modification,cert-dcl37-c,cert-dcl51-cpp,cert-dcl58-cpp,readability-identifier-naming) +namespace std { + +__attribute__((__noreturn__)) inline void __throw_logic_error(const char *) { __builtin_abort(); } +__attribute__((__noreturn__)) inline void __throw_length_error(const char *) { __builtin_abort(); } +__attribute__((__noreturn__)) inline void __throw_out_of_range(const char *) { __builtin_abort(); } +__attribute__((__noreturn__)) inline void __throw_out_of_range_fmt(const char *, ...) { __builtin_abort(); } + +} // namespace std +// NOLINTEND(bugprone-reserved-identifier,bugprone-std-namespace-modification,cert-dcl37-c,cert-dcl51-cpp,cert-dcl58-cpp,readability-identifier-naming) + +#endif // __cplusplus From 4a7c58d5aed36741527ae890aec3ae6cb9d96b11 Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Thu, 2 Jul 2026 03:26:56 +0200 Subject: [PATCH 0698/1815] [usb_uart] Fix format specifier warnings for uint32_t in ft23xx and pl2303 (#17342) --- esphome/components/usb_uart/ft23xx.cpp | 7 ++++--- esphome/components/usb_uart/pl2303.cpp | 5 +++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index 3b0e05ba53..2e8ff8bcb5 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -6,6 +6,7 @@ #include "esphome/components/uart/uart_debugger.h" #include "esphome/components/bytebuffer/bytebuffer.h" +#include namespace esphome::usb_uart { @@ -288,16 +289,16 @@ int USBUartTypeFT23XX::set_baudrate_(USBUartChannel *channel, uint32_t baudrate) ESP_LOGE(TAG, "Set baudrate failed, status=%s", esp_err_to_name(status.error_code)); channel->initialised_.store(false); } else { - ESP_LOGD(TAG, "Baudrate %d set, setting line properties...", channel->baud_rate_); + ESP_LOGD(TAG, "Baudrate %" PRIu32 " set, setting line properties...", channel->baud_rate_); this->set_line_properties_(channel); } }; if (baudrate == 0) { baudrate = channel->baud_rate_; } - uint16_t value, ftdi_index; + uint16_t value = 0, ftdi_index = 0; ftdi_convert_baudrate(baudrate, this->chip_type_, channel->index_, &value, &ftdi_index); - ESP_LOGD(TAG, "Baudrate: %d, value=0x%04X, ftdi_index=0x%04X", baudrate, value, ftdi_index); + ESP_LOGD(TAG, "Baudrate: %" PRIu32 ", value=0x%04X, ftdi_index=0x%04X", baudrate, value, ftdi_index); uint16_t usb_index = (ftdi_index & 0xFF00) | (channel->cdc_dev_.bulk_interface_number + 1); bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x03, value, usb_index, callback); if (!ok) { diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index 3685debef4..134c51198d 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -3,6 +3,7 @@ #include "usb_uart.h" #include "usb/usb_host.h" #include "esphome/core/log.h" +#include namespace esphome::usb_uart { @@ -282,8 +283,8 @@ void USBUartTypePL2303::enable_channels() { // Data bits line_coding[6] = channel->get_data_bits(); - ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%u stop=%u parity=%u data=%u", baud, line_coding[4], line_coding[5], - line_coding[6]); + ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%" PRIu32 " stop=%u parity=%u data=%u", baud, line_coding[4], + line_coding[5], line_coding[6]); std::vector lc_vec(line_coding, line_coding + 7); uint16_t iface = channel->cdc_dev_.bulk_interface_number; From 5b8bf510226d47c8b33fe0e4a7342e1c81001e77 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:56:01 +1000 Subject: [PATCH 0699/1815] [power_supply] Make enable_on_boot high priority (#16914) --- .../components/power_supply/power_supply.cpp | 6 ++- esphome/core/component.h | 2 + .../power_supply/test_setup_priority.cpp | 47 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 tests/components/power_supply/test_setup_priority.cpp diff --git a/esphome/components/power_supply/power_supply.cpp b/esphome/components/power_supply/power_supply.cpp index 4da73e76ae..f094f6e2e9 100644 --- a/esphome/components/power_supply/power_supply.cpp +++ b/esphome/components/power_supply/power_supply.cpp @@ -21,7 +21,11 @@ void PowerSupply::dump_config() { LOG_PIN(" Pin: ", this->pin_); } -float PowerSupply::get_setup_priority() const { return setup_priority::IO; } +float PowerSupply::get_setup_priority() const { + if (this->pin_->is_internal() && this->enable_on_boot_) + return setup_priority::POWER; + return setup_priority::IO; +} bool PowerSupply::is_enabled() const { return this->active_requests_ != 0; } diff --git a/esphome/core/component.h b/esphome/core/component.h index 1ae70371a1..70a051ca0b 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -33,6 +33,8 @@ class RuntimeStatsCollector; */ namespace setup_priority { +/// For power supply components that must be on before buses like i2c can work. +inline constexpr float POWER = 1200.0f; /// For communication buses like i2c/spi inline constexpr float BUS = 1000.0f; /// For components that represent GPIO pins like PCF8573 diff --git a/tests/components/power_supply/test_setup_priority.cpp b/tests/components/power_supply/test_setup_priority.cpp new file mode 100644 index 0000000000..401fc72654 --- /dev/null +++ b/tests/components/power_supply/test_setup_priority.cpp @@ -0,0 +1,47 @@ +#include + +#include "esphome/components/power_supply/power_supply.h" +#include "esphome/core/gpio.h" +#include "esphome/core/component.h" + +namespace esphome::power_supply::testing { + +// Minimal dummy internal GPIO pin implementation for testing +class DummyInternalPin : public InternalGPIOPin { + public: + DummyInternalPin() = default; + void setup() override {} + void pin_mode(esphome::gpio::Flags) override {} + esphome::gpio::Flags get_flags() const override { return esphome::gpio::FLAG_NONE; } + bool digital_read() override { return false; } + void digital_write(bool) override {} + void detach_interrupt() const override {} + ISRInternalGPIOPin to_isr() const override { return ISRInternalGPIOPin(); } + uint8_t get_pin() const override { return 0; } + bool is_inverted() const override { return false; } + + protected: + // Implement protected attach_interrupt required by InternalGPIOPin + void attach_interrupt(void (*func)(void *), void *arg, esphome::gpio::InterruptType type) const override {} +}; + +TEST(PowerSupply, HasHigherPriorityThanBusWhenInternalAndEnableOnBoot) { + power_supply::PowerSupply ps; + DummyInternalPin pin; + ps.set_pin(&pin); + ps.set_enable_on_boot(true); + + // POWER priority should be greater than BUS priority + EXPECT_GT(ps.get_setup_priority(), setup_priority::BUS); +} + +TEST(PowerSupply, FallsBackToIOWhenNotEnableOnBoot) { + power_supply::PowerSupply ps; + DummyInternalPin pin; + ps.set_pin(&pin); + ps.set_enable_on_boot(false); + + EXPECT_EQ(ps.get_setup_priority(), setup_priority::IO); +} + +} // namespace esphome::power_supply::testing From 0666cb86355731ebf6bf429c6f7e06c7b8e11b35 Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Thu, 2 Jul 2026 05:44:09 +0200 Subject: [PATCH 0700/1815] [usb_uart] Add per-device-type maximum baud rate cap (#17259) --- esphome/components/usb_uart/__init__.py | 70 ++++++++++++++++--------- 1 file changed, 45 insertions(+), 25 deletions(-) diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index e42a2c092b..a921b6fbf0 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -46,42 +46,59 @@ DEFAULT_BAUD_RATE = 9600 class Type: - def __init__(self, name, vid, pid, cls, max_channels=1, baud_rate_required=True): + def __init__( + self, + name, + vid, + pid, + cls, + max_channels=1, + baud_rate_required=True, + max_baud=1_000_000, + ): self.name = name cls = cls or name self.vid = vid self.pid = pid self.cls = usb_uart_ns.class_(f"USBUartType{cls}", USBUartComponent) - self.max_channels = max_channels + self._max_channels = max_channels self.baud_rate_required = baud_rate_required + self.max_baud = max_baud + + @property + def max_channels(self) -> int: + return ( + 3 + if ( + CORE.is_esp32 + and get_esp32_variant() != VARIANT_ESP32P4 + and self._max_channels > 3 + ) + else self._max_channels + ) uart_types = ( Type("CDC_ACM", 0, 0, "CdcAcm", 1, baud_rate_required=False), - Type("CH34X", 0x1A86, 0x55D5, "CH34X", 4), - Type("CH340", 0x1A86, 0x7523, "CH34X", 1), - Type("CP210X", 0x10C4, 0xEA60, "CP210X", 3), + Type("CH34X", 0x1A86, 0x55D5, "CH34X", 4, max_baud=2_000_000), + Type("CH340", 0x1A86, 0x7523, "CH34X", 1, max_baud=2_000_000), + Type("CP210X", 0x10C4, 0xEA60, "CP210X", 3, max_baud=2_000_000), Type("ESP_JTAG", 0x303A, 0x1001, "CdcAcm", 1, baud_rate_required=False), - Type("FT232", 0x0403, 0x6001, "FT23XX", 1), - Type("FT2232", 0x0403, 0x6010, "FT23XX", 2), - Type("FT4232", 0x0403, 0x6011, "FT23XX", 4), - Type("PL2303", 0x067B, 0x2303, "PL2303", 1), - Type("PL2303GB", 0x067B, 0x23B3, "PL2303", 1), - Type("PL2303GC", 0x067B, 0x23A3, "PL2303", 1), - Type("PL2303GE", 0x067B, 0x23E3, "PL2303", 1), - Type("PL2303GL", 0x067B, 0x23D3, "PL2303", 1), - Type("PL2303GS", 0x067B, 0x23F3, "PL2303", 1), - Type("PL2303GT", 0x067B, 0x23C3, "PL2303", 1), + Type("FT232", 0x0403, 0x6001, "FT23XX", 1, max_baud=3_000_000), + Type("FT2232", 0x0403, 0x6010, "FT23XX", 2, max_baud=12_000_000), + Type("FT4232", 0x0403, 0x6011, "FT23XX", 4, max_baud=12_000_000), + Type("PL2303", 0x067B, 0x2303, "PL2303", 1, max_baud=6_000_000), + Type("PL2303GB", 0x067B, 0x23B3, "PL2303", 1, max_baud=6_000_000), + Type("PL2303GC", 0x067B, 0x23A3, "PL2303", 1, max_baud=6_000_000), + Type("PL2303GE", 0x067B, 0x23E3, "PL2303", 1, max_baud=6_000_000), + Type("PL2303GL", 0x067B, 0x23D3, "PL2303", 1, max_baud=6_000_000), + Type("PL2303GS", 0x067B, 0x23F3, "PL2303", 1, max_baud=6_000_000), + Type("PL2303GT", 0x067B, 0x23C3, "PL2303", 1, max_baud=6_000_000), Type("STM32_VCP", 0x0483, 0x5740, "CdcAcm", 1, baud_rate_required=False), ) -def channel_schema(channels, baud_rate_required): - # For now S3 is restricted to 3 channels since each needs 2 endpoints, plus the control endpoint, and - # there are only a total of 8 endpoints available. - # This will need updating when the 8 channel devices that multiplex over an endpoint are added. - if CORE.is_esp32 and get_esp32_variant() != VARIANT_ESP32P4 and channels > 3: - channels = 3 +def channel_schema(type_: "Type") -> cv.Schema: return cv.Schema( { cv.Required(CONF_CHANNELS): cv.All( @@ -94,11 +111,11 @@ def channel_schema(channels, baud_rate_required): ), ( cv.Required(CONF_BAUD_RATE) - if baud_rate_required + if type_.baud_rate_required else cv.Optional( CONF_BAUD_RATE, default=DEFAULT_BAUD_RATE ) - ): cv.int_range(min=300, max=1000000), + ): cv.int_range(min=300, max=type_.max_baud), cv.Optional(CONF_STOP_BITS, default="1"): cv.enum( UART_STOP_BITS_OPTIONS, upper=True ), @@ -117,7 +134,10 @@ def channel_schema(channels, baud_rate_required): } ) ), - cv.Length(max=channels), + cv.Length( + max=type_.max_channels, + msg=f"Device type {type_.name} supports a maximum of {type_.max_channels} channels", + ), ) } ) @@ -127,7 +147,7 @@ CONFIG_SCHEMA = cv.ensure_list( cv.typed_schema( { it.name: usb_device_schema(it.cls, it.vid, it.pid).extend( - channel_schema(it.max_channels, it.baud_rate_required) + channel_schema(it) ) for it in uart_types }, From 06c7ac37d13eaeadfbe16db9150d9b28aa66b3d9 Mon Sep 17 00:00:00 2001 From: Twisterss Date: Thu, 2 Jul 2026 09:33:58 +0200 Subject: [PATCH 0701/1815] [epaper_spi] Add Waveshare 7.5" V2 BWR support (#15719) --- .../epaper_spi/epaper_waveshare_bwr.cpp | 146 ++++++++++++++++++ .../epaper_spi/epaper_waveshare_bwr.h | 40 +++++ .../epaper_spi/models/waveshare_bwr.py | 56 +++++++ .../epaper_spi/test.esp32-s3-idf.yaml | 21 +++ 4 files changed, 263 insertions(+) create mode 100644 esphome/components/epaper_spi/epaper_waveshare_bwr.cpp create mode 100644 esphome/components/epaper_spi/epaper_waveshare_bwr.h create mode 100644 esphome/components/epaper_spi/models/waveshare_bwr.py diff --git a/esphome/components/epaper_spi/epaper_waveshare_bwr.cpp b/esphome/components/epaper_spi/epaper_waveshare_bwr.cpp new file mode 100644 index 0000000000..004597b72b --- /dev/null +++ b/esphome/components/epaper_spi/epaper_waveshare_bwr.cpp @@ -0,0 +1,146 @@ +#include "epaper_waveshare_bwr.h" + +#include + +namespace esphome::epaper_spi { + +enum class BwrState : uint8_t { + BWR_BLACK, + BWR_WHITE, + BWR_RED, +}; + +static BwrState color_to_bwr(Color color) { + if (color.r > color.g + color.b && color.r > 127) { + return BwrState::BWR_RED; + } + if (color.r + color.g + color.b >= 382) { + return BwrState::BWR_WHITE; + } + return BwrState::BWR_BLACK; +} + +// UC8179 3-color display buffer layout: +// - 1 bit per pixel, 8 pixels per byte +// - Buffer first half: Black/White plane (1=black, 0=white) +// - Buffer second half: Red plane (1=red, 0=white) +// - Total: row_width * height * 2 bytes + +void EPaperWaveshareBWR::draw_pixel_at(int x, int y, Color color) { + if (!this->rotate_coordinates_(x, y)) + return; + + const uint32_t pos = (x / 8) + (y * this->row_width_); + const uint8_t bit = 0x80 >> (x & 0x07); + const uint32_t red_offset = this->buffer_length_ / 2u; + + const auto bwr = color_to_bwr(color); + + if (bwr == BwrState::BWR_BLACK) { + this->buffer_[pos] |= bit; + } else { + this->buffer_[pos] &= ~bit; + } + + if (bwr == BwrState::BWR_RED) { + this->buffer_[red_offset + pos] |= bit; + } else { + this->buffer_[red_offset + pos] &= ~bit; + } +} + +void EPaperWaveshareBWR::fill(Color color) { + const size_t half_buffer = this->buffer_length_ / 2u; + const auto bwr = color_to_bwr(color); + + if (bwr == BwrState::BWR_BLACK) { + // Black plane: 0xFF (black), Red plane: 0x00 (no red) + for (size_t i = 0; i < half_buffer; i++) + this->buffer_[i] = 0xFF; + for (size_t i = 0; i < half_buffer; i++) + this->buffer_[half_buffer + i] = 0x00; + } else if (bwr == BwrState::BWR_RED) { + // Black plane: 0x00 (no black), Red plane: 0xFF (red) + for (size_t i = 0; i < half_buffer; i++) + this->buffer_[i] = 0x00; + for (size_t i = 0; i < half_buffer; i++) + this->buffer_[half_buffer + i] = 0xFF; + } else { + // Black plane: 0x00 (no black), Red plane: 0x00 (no red) + this->buffer_.fill(0x00); + } +} + +bool HOT EPaperWaveshareBWR::transfer_data() { + const uint32_t start_time = millis(); + const size_t buffer_length = this->buffer_length_; + const size_t half_buffer = buffer_length / 2u; + + uint8_t bytes_to_send[MAX_TRANSFER_SIZE]; + + // Phase 1: send Black/White plane (first half) via command 0x10 (DTM1) + // UC8179 DTM1 (0x10): inverted to get 0=black, 1=white + if (this->current_data_index_ < half_buffer) { + if (this->current_data_index_ == 0) { + this->command(0x10); // DATA START TRANSMISSION 1 (black channel) + } + this->start_data_(); + while (this->current_data_index_ < half_buffer) { + const size_t bytes_to_copy = std::min(MAX_TRANSFER_SIZE, half_buffer - this->current_data_index_); + for (size_t i = 0; i < bytes_to_copy; i++) { + bytes_to_send[i] = ~this->buffer_[this->current_data_index_ + i]; + } + this->write_array(bytes_to_send, bytes_to_copy); + this->current_data_index_ += bytes_to_copy; + if (millis() - start_time > MAX_TRANSFER_TIME) { + this->disable(); + return false; + } + } + this->disable(); + } + + // Phase 2: send Red plane (second half) via command 0x13 (DTM2) + // UC8179 DTM2 (0x13): 1=red, 0=white + if (this->current_data_index_ < buffer_length) { + if (this->current_data_index_ == half_buffer) { + this->command(0x13); // DATA START TRANSMISSION 2 (red channel) + } + this->start_data_(); + while (this->current_data_index_ < buffer_length) { + const size_t bytes_to_copy = std::min(MAX_TRANSFER_SIZE, buffer_length - this->current_data_index_); + for (size_t i = 0; i < bytes_to_copy; i++) { + bytes_to_send[i] = this->buffer_[this->current_data_index_ + i]; + } + this->write_array(bytes_to_send, bytes_to_copy); + this->current_data_index_ += bytes_to_copy; + if (millis() - start_time > MAX_TRANSFER_TIME) { + this->disable(); + return false; + } + } + this->disable(); + } + + this->current_data_index_ = 0; + return true; +} + +void EPaperWaveshareBWR::power_on() { + this->cmd_data(0x01, {0x07, 0x17, 0x3F, 0x3F}); // POWER SETTING + this->command(0x04); // POWER ON +} + +void EPaperWaveshareBWR::refresh_screen(bool /*partial*/) { + this->command(0x12); // DISPLAY REFRESH +} + +void EPaperWaveshareBWR::power_off() { + this->command(0x02); // POWER OFF +} + +void EPaperWaveshareBWR::deep_sleep() { + this->cmd_data(0x07, {0xA5}); // DEEP SLEEP with check code +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_waveshare_bwr.h b/esphome/components/epaper_spi/epaper_waveshare_bwr.h new file mode 100644 index 0000000000..a090faa14d --- /dev/null +++ b/esphome/components/epaper_spi/epaper_waveshare_bwr.h @@ -0,0 +1,40 @@ +#pragma once + +#include "epaper_spi.h" + +namespace esphome::epaper_spi { + +/** + * Waveshare 3-color e-paper displays (UC8179 controller). + * Supports: 7.5" V2 BWR (EDP_7in5b_V2), 800x480 pixels. + * + * Color scheme: Black, White, Red (BWR) + * Buffer layout: 1 bit per pixel, separate planes + * - Buffer first half: Black/White plane (1=black, 0=white) + * - Buffer second half: Red plane (1=red, 0=no red) + * - Total buffer: width * height / 4 bytes (2 * width * height / 8) + * + * The init sequence (INITIALISE state) sends panel configuration only. + * Power-on (0x01 + 0x04) is sent in the POWER_ON state after data transfer; + * the state machine then busy-waits before triggering REFRESH_SCREEN (0x12). + */ +class EPaperWaveshareBWR : public EPaperBase { + public: + EPaperWaveshareBWR(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length) + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) { + this->buffer_length_ = this->row_width_ * height * 2; + } + + void fill(Color color) override; + + protected: + void draw_pixel_at(int x, int y, Color color) override; + bool transfer_data() override; + void refresh_screen(bool partial) override; + void power_on() override; + void power_off() override; + void deep_sleep() override; +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/waveshare_bwr.py b/esphome/components/epaper_spi/models/waveshare_bwr.py new file mode 100644 index 0000000000..e124ea7083 --- /dev/null +++ b/esphome/components/epaper_spi/models/waveshare_bwr.py @@ -0,0 +1,56 @@ +"""Waveshare Black/White/Red e-paper displays using UC8179 controller. + +Supported models: +- waveshare-7.5in-bv2-bwr: 800x480 pixels (7.5" BWR display, EDP_7in5b_V2) + +These displays use the UC8179 controller. Panel configuration is sent during +the INITIALISE state. Power-on is handled in the POWER_ON state, after data +transfer, so the state machine's built-in busy wait covers the power-on delay. +""" + +from . import EpaperModel + + +class WaveshareBWR(EpaperModel): + """EpaperModel class for Waveshare Black/White/Red displays using UC8179 controller.""" + + def __init__(self, name, **defaults): + super().__init__(name, "EPaperWaveshareBWR", **defaults) + + def get_init_sequence(self, config): + """Generate initialization sequence for UC8179 BWR displays. + + Panel configuration only — power-on is handled separately in power_on() + after data transfer, with the state machine busy-waiting before refresh. + """ + width, height = self.get_dimensions(config) + return ( + # PANEL SETTING (KWR mode) + (0x00, 0x0F), + # RESOLUTION SETTING (width x height) + ( + 0x61, + (width >> 8) & 0xFF, + width & 0xFF, + (height >> 8) & 0xFF, + height & 0xFF, + ), + # DUAL SPI MODE (disabled) + (0x15, 0x00), + # VCOM AND DATA INTERVAL SETTING + (0x50, 0x11, 0x07), + # TCON SETTING + (0x60, 0x22), + # RESOLUTION GATE SETTING + (0x65, 0x00, 0x00, 0x00, 0x00), + ) + + +# Model: Waveshare 7.5" V2 BWR (EDP_7in5b_V2) — 800x480, UC8179 controller +WaveshareBWR( + "waveshare-7.5in-bv2-bwr", + width=800, + height=480, + data_rate="10MHz", + minimum_update_interval="30s", +) diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 60e4008f4f..bb771f2132 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -203,3 +203,24 @@ display: it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK); it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0)); + + # Waveshare 7.5" V2 BWR (800x480, UC8179 controller, EDP_7in5b_V2) + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-7.5in-bv2-bwr + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 100, Color::BLACK); + it.circle(it.get_width() / 2, it.get_height() / 2, 60, Color(255, 0, 0)); From 792dfbcbbf116002468ed3692596fa5aa88b9448 Mon Sep 17 00:00:00 2001 From: Sven Kocksch Date: Thu, 2 Jul 2026 10:40:59 +0200 Subject: [PATCH 0702/1815] [st7123] add ST7123 touch controller component (M5Stack Tab5) (#12075) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/st7123/__init__.py | 6 + .../components/st7123/touchscreen/__init__.py | 32 ++++++ .../st7123/touchscreen/st7123_touchscreen.cpp | 108 ++++++++++++++++++ .../st7123/touchscreen/st7123_touchscreen.h | 48 ++++++++ tests/components/st7123/common.yaml | 18 +++ tests/components/st7123/test.esp32-idf.yaml | 9 ++ 7 files changed, 222 insertions(+) create mode 100644 esphome/components/st7123/__init__.py create mode 100644 esphome/components/st7123/touchscreen/__init__.py create mode 100644 esphome/components/st7123/touchscreen/st7123_touchscreen.cpp create mode 100644 esphome/components/st7123/touchscreen/st7123_touchscreen.h create mode 100644 tests/components/st7123/common.yaml create mode 100644 tests/components/st7123/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index d2c92f44ce..b222c44214 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -501,6 +501,7 @@ esphome/components/ssd1331_base/* @kbx81 esphome/components/ssd1331_spi/* @kbx81 esphome/components/ssd1351_base/* @kbx81 esphome/components/ssd1351_spi/* @kbx81 +esphome/components/st7123/* @miniskipper esphome/components/st7567_base/* @latonita esphome/components/st7567_i2c/* @latonita esphome/components/st7567_spi/* @latonita diff --git a/esphome/components/st7123/__init__.py b/esphome/components/st7123/__init__.py new file mode 100644 index 0000000000..335bc238be --- /dev/null +++ b/esphome/components/st7123/__init__.py @@ -0,0 +1,6 @@ +import esphome.codegen as cg + +CODEOWNERS = ["@miniskipper"] +DEPENDENCIES = ["i2c"] + +st7123_ns = cg.esphome_ns.namespace("st7123") diff --git a/esphome/components/st7123/touchscreen/__init__.py b/esphome/components/st7123/touchscreen/__init__.py new file mode 100644 index 0000000000..5ebd08066f --- /dev/null +++ b/esphome/components/st7123/touchscreen/__init__.py @@ -0,0 +1,32 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c, touchscreen +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN + +from .. import st7123_ns + +ST7123Touchscreen = st7123_ns.class_( + "ST7123Touchscreen", + touchscreen.Touchscreen, + i2c.I2CDevice, +) + +CONFIG_SCHEMA = touchscreen.TOUCHSCREEN_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(ST7123Touchscreen), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + } +).extend(i2c.i2c_device_schema(0x55)) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await touchscreen.register_touchscreen(var, config) + await i2c.register_i2c_device(var, config) + + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + if reset_pin := config.get(CONF_RESET_PIN): + cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) diff --git a/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp b/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp new file mode 100644 index 0000000000..117f975264 --- /dev/null +++ b/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp @@ -0,0 +1,108 @@ +#include "st7123_touchscreen.h" + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::st7123 { + +static const char *const TAG = "st7123.touchscreen"; + +void ST7123Touchscreen::setup() { + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + delay(5); + this->reset_pin_->digital_write(false); // TP_RESX is active low, assert for at least tRSTW (2ms) + delay(5); + this->reset_pin_->digital_write(true); + // The controller needs up to 20ms to initialize after reset before it can be accessed. + this->setup_time_ = millis() + 30; + } +} + +void ST7123Touchscreen::update() { + // check if setup is complete + if (this->setup_time_ != 0) { + if (this->setup_time_ > millis()) + return; + + uint8_t status; + if (this->read_register16(ST7123_REG_STATUS, &status, 1) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Failed to read status register")); // will stop updates + return; + } + if ((status & 0x0F) == ST7123_STATUS_INIT) { + ESP_LOGD(TAG, "Controller still initializing"); + return; + } + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + // INT is held high when idle and pulses low when touch data is ready. + this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); + } + + ESP_LOGD(TAG, "Status is %X", status); + + uint8_t data; + if (this->read_register16(ST7123_REG_MAX_TOUCHES, &data, 1) == i2c::ERROR_OK && data != 0 && + data <= ST7123_MAX_TOUCHES) { + this->max_touches_ = data; + } + + // If no calibration was supplied, read the native coordinate resolution from the controller. + if (this->x_raw_max_ == this->x_raw_min_ || this->y_raw_max_ == this->y_raw_min_) { + uint8_t res[4]; + if (this->read_register16(ST7123_REG_MAX_X, res, sizeof(res)) == i2c::ERROR_OK) { + this->x_raw_max_ = encode_uint16(res[0] & ST7123_COORD_HIGH_MASK, res[1]); + this->y_raw_max_ = encode_uint16(res[2] & ST7123_COORD_HIGH_MASK, res[3]); + if (this->swap_x_y_) + std::swap(this->x_raw_max_, this->y_raw_max_); + } else { + this->mark_failed(LOG_STR("Failed to read calibration")); + return; + } + ESP_LOGD(TAG, "Read dimensions %d/%d", this->x_raw_max_, this->y_raw_max_); + } + this->setup_time_ = 0; // flag setup complete + } + Touchscreen::update(); +} + +void ST7123Touchscreen::update_touches() { + // Read the reporting table from the advanced touch info register through the last touch point. + // Reading from this register also clears the INT pin so the controller can report the next frame. + uint8_t data[(ST7123_REG_TOUCH_DATA - ST7123_REG_ADV_TOUCH_INFO) + ST7123_MAX_TOUCHES * ST7123_TOUCH_STRIDE]; + const size_t len = (ST7123_REG_TOUCH_DATA - ST7123_REG_ADV_TOUCH_INFO) + this->max_touches_ * ST7123_TOUCH_STRIDE; + if (this->read_register16(ST7123_REG_ADV_TOUCH_INFO, data, len) != i2c::ERROR_OK) { + this->skip_update_ = true; + this->status_set_warning(); + return; + } + this->status_clear_warning(); + + const uint8_t *points = data + (ST7123_REG_TOUCH_DATA - ST7123_REG_ADV_TOUCH_INFO); + for (uint8_t i = 0; i != this->max_touches_; i++) { + const uint8_t *p = points + i * ST7123_TOUCH_STRIDE; + if ((p[0] & ST7123_TOUCH_VALID) == 0) + continue; + uint16_t x = encode_uint16(p[0] & ST7123_COORD_HIGH_MASK, p[1]); + uint16_t y = encode_uint16(p[2] & ST7123_COORD_HIGH_MASK, p[3]); + uint8_t intensity = p[5]; + ESP_LOGV(TAG, "Touch %u: x=%u, y=%u, intensity=%u", i, x, y, intensity); + this->add_raw_touch_position_(i, x, y, intensity); + } +} + +void ST7123Touchscreen::dump_config() { + ESP_LOGCONFIG(TAG, + "ST7123 Touchscreen:\n" + " Max touches: %u\n" + " X Raw Min: %d, X Raw Max: %d\n" + " Y Raw Min: %d, Y Raw Max: %d", + this->max_touches_, this->x_raw_min_, this->x_raw_max_, this->y_raw_min_, this->y_raw_max_); + LOG_I2C_DEVICE(this); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); +} + +} // namespace esphome::st7123 diff --git a/esphome/components/st7123/touchscreen/st7123_touchscreen.h b/esphome/components/st7123/touchscreen/st7123_touchscreen.h new file mode 100644 index 0000000000..633eba7a82 --- /dev/null +++ b/esphome/components/st7123/touchscreen/st7123_touchscreen.h @@ -0,0 +1,48 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/touchscreen/touchscreen.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" + +namespace esphome::st7123 { + +// Sitronix ST7123 capacitive touch controller. +// Registers are addressed with a 16-bit big-endian address (sent MSB first). +static constexpr uint16_t ST7123_REG_STATUS = 0x0001; // [7:4] error code, [3:0] device status +static constexpr uint16_t ST7123_REG_MAX_X = 0x0005; // 0x0005..0x0006 X resolution, 0x0007..0x0008 Y resolution +static constexpr uint16_t ST7123_REG_MAX_TOUCHES = 0x0009; +static constexpr uint16_t ST7123_REG_ADV_TOUCH_INFO = 0x0010; // start of the reporting table +static constexpr uint16_t ST7123_REG_TOUCH_DATA = 0x0014; // first touch point + +// Device status field of the status register. +static constexpr uint8_t ST7123_STATUS_INIT = 0x1; + +// Each touch point occupies 7 bytes: X high, X low, Y high, Y low, area, intensity, reserved. +static constexpr uint8_t ST7123_TOUCH_STRIDE = 7; +// Bit 7 of the X high byte indicates a valid touch point. +static constexpr uint8_t ST7123_TOUCH_VALID = 0x80; +// The X and Y high bytes only use the low 6 bits. +static constexpr uint8_t ST7123_COORD_HIGH_MASK = 0x3F; +// The ST7123 can report at most 10 touch points. +static constexpr uint8_t ST7123_MAX_TOUCHES = 10; + +class ST7123Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; } + + protected: + void update_touches() override; + + InternalGPIOPin *interrupt_pin_{nullptr}; + GPIOPin *reset_pin_{nullptr}; + uint8_t max_touches_{ST7123_MAX_TOUCHES}; + uint32_t setup_time_{1}; +}; + +} // namespace esphome::st7123 diff --git a/tests/components/st7123/common.yaml b/tests/components/st7123/common.yaml new file mode 100644 index 0000000000..b34eb669e0 --- /dev/null +++ b/tests/components/st7123/common.yaml @@ -0,0 +1,18 @@ +display: + - platform: ssd1306_i2c + i2c_id: i2c_bus + id: st7123_ssd1306_i2c_display + model: SSD1306_128X64 + reset_pin: ${display_reset_pin} + pages: + - id: st7123_page1 + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + +touchscreen: + - platform: st7123 + i2c_id: i2c_bus + id: st7123_touchscreen + display: st7123_ssd1306_i2c_display + interrupt_pin: ${interrupt_pin} + reset_pin: ${reset_pin} diff --git a/tests/components/st7123/test.esp32-idf.yaml b/tests/components/st7123/test.esp32-idf.yaml new file mode 100644 index 0000000000..3bce86d9a3 --- /dev/null +++ b/tests/components/st7123/test.esp32-idf.yaml @@ -0,0 +1,9 @@ +substitutions: + display_reset_pin: "10" + interrupt_pin: "20" + reset_pin: "21" + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml From 41cf842d5d9ca377c6338760f91bcb4e7755080f Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Thu, 2 Jul 2026 16:13:56 +0200 Subject: [PATCH 0703/1815] [zephyr][nrf52] Rebuild native build when config inputs change (#17318) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/nrf52/__init__.py | 20 ++++++++++--- esphome/components/zephyr/__init__.py | 41 +++++++++++++++++++++------ 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 00271c97c7..64946e3cd1 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -58,7 +58,7 @@ from esphome.framework_helpers import ( get_project_link_flags, run_command_ok, ) -from esphome.helpers import write_file_if_changed +from esphome.helpers import rmtree, write_file_if_changed from esphome.storage_json import StorageJSON from esphome.types import ConfigType @@ -697,7 +697,8 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> return False -def _generate_cmake_lists() -> None: +def _generate_cmake_lists() -> bool: + """Write the project CMakeLists.txt, returning True if it changed.""" compile_flags = get_project_compile_flags() link_flags = get_project_link_flags() @@ -732,7 +733,7 @@ def _generate_cmake_lists() -> None: ")", ] - write_file_if_changed( + return write_file_if_changed( CORE.relative_build_path("zephyr", "CMakeLists.txt"), "\n".join(lines) + "\n", ) @@ -751,12 +752,23 @@ def run_compile(args, config: ConfigType) -> bool: paths = get_build_paths() env = get_build_env() - _generate_cmake_lists() + cmake_lists_changed = _generate_cmake_lists() board = zephyr_data()[KEY_BOARD] build_dir = CORE.relative_pioenvs_path(CORE.name) source_dir = CORE.relative_build_path("zephyr") + # A missing CMake cache (dropped by zephyr's copy_files() on config + # change) or a changed CMakeLists.txt requires a pristine build: Zephyr + # caches Kconfig/devicetree state that survives a plain cmake re-run. + # West can't do the wipe — its pristine modes only recognize a build dir + # by reading ZEPHYR_BASE from the very cache that was dropped. + if ( + cmake_lists_changed or not (build_dir / "CMakeCache.txt").is_file() + ) and build_dir.is_dir(): + _LOGGER.info("Build inputs changed, cleaning %s", build_dir) + rmtree(build_dir) + west_cmd = [ str(paths["python_executable"]), "-m", diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index bd5f01aa3a..cd077a142f 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -8,6 +8,7 @@ from esphome.const import CONF_BOARD, KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.helpers import copy_file_if_changed, write_file_if_changed from esphome.types import ConfigType +from esphome.writer import clean_cmake_cache from .const import ( CONF_CDC_ACM, @@ -203,7 +204,20 @@ def zephyr_add_user(key, value): user[key] += [value] -def copy_files(): +def _write_file_if_changed_or_remove_when_empty(path: Path, content: str) -> bool: + """Write content to path, or remove a stale file when content is empty. + + Returns True if the file changed on disk. + """ + if content: + return write_file_if_changed(path, content) + if path.is_file(): + path.unlink() + return True + return False + + +def copy_files() -> None: user = zephyr_data()[KEY_USER] if user: entries = " ".join( @@ -219,6 +233,8 @@ def copy_files(): """ ) + changed = False + for image, want_opts in zephyr_data()[KEY_PRJ_CONF].items(): prj_conf = ( "\n".join( @@ -233,26 +249,25 @@ def copy_files(): else: path = CORE.relative_build_path("zephyr/prj.conf") - write_file_if_changed(CORE.relative_build_path(path), prj_conf) + changed |= write_file_if_changed(path, prj_conf) for image, content in zephyr_data()[KEY_OVERLAY].items(): if image: path = CORE.relative_build_path(f"sysbuild/{image}.overlay") else: path = CORE.relative_build_path("zephyr/app.overlay") - write_file_if_changed(path, content) + changed |= write_file_if_changed(path, content) for filename, path in zephyr_data()[KEY_EXTRA_BUILD_FILES].items(): - copy_file_if_changed( + changed |= copy_file_if_changed( path, CORE.relative_build_path(filename), ) pm_static = "\n".join(str(item) for item in zephyr_data()[KEY_PM_STATIC]) - if pm_static: - write_file_if_changed( - CORE.relative_build_path("zephyr/pm_static.yml"), pm_static - ) + changed |= _write_file_if_changed_or_remove_when_empty( + CORE.relative_build_path("zephyr/pm_static.yml"), pm_static + ) kconfig = zephyr_data()[KEY_KCONFIG] if kconfig: @@ -267,4 +282,12 @@ def copy_files(): + "\n" + kconfig ) - write_file_if_changed(CORE.relative_build_path("zephyr/Kconfig"), kconfig) + changed |= _write_file_if_changed_or_remove_when_empty( + CORE.relative_build_path("zephyr/Kconfig"), kconfig + ) + + if changed: + # A configure-time input changed; drop the CMake cache so the build + # can't reuse stale configure results (the native sdk-nrf toolchain + # rebuilds pristine when the cache is missing). + clean_cmake_cache() From 65fc10d627f0ab8343694521562a4cd0edbdca4c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:26:34 -0400 Subject: [PATCH 0704/1815] [nrf52] Build PlatformIO libraries as Zephyr modules (sdk-nrf) (#17250) --- esphome/components/nrf52/__init__.py | 26 +++ esphome/components/zephyr/library.py | 180 ++++++++++++++++++++ esphome/espidf/component.py | 1 + esphome/platformio/library.py | 45 +++-- tests/unit_tests/test_espidf_component.py | 38 +++-- tests/unit_tests/test_platformio_library.py | 6 +- tests/unit_tests/test_zephyr_library.py | 117 +++++++++++++ 7 files changed, 388 insertions(+), 25 deletions(-) create mode 100644 esphome/components/zephyr/library.py create mode 100644 tests/unit_tests/test_zephyr_library.py diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 64946e3cd1..184d41e0f3 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -411,6 +411,17 @@ async def _dfu_to_code(dfu_config): def copy_files() -> None: """Copy files to the build directory.""" + # Library conversion to Zephyr modules is wired into the sdk-nrf + # CMakeLists only; the PlatformIO toolchain's forked platform package + # cannot compile external libraries at all, so the build would fail at + # link time anyway. Fail fast with a clear message instead. + if CORE.using_toolchain_platformio and CORE.platformio_libraries: + raise EsphomeError( + f"Libraries ({', '.join(sorted(CORE.platformio_libraries))}) are " + "not supported on the nRF52 'platformio' toolchain; use toolchain " + "'sdk-nrf' to build them as Zephyr modules." + ) + if CORE.using_toolchain_platformio and ( zephyr_data()[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT or zephyr_data()[KEY_BOARD] == "xiao_ble" @@ -702,11 +713,26 @@ def _generate_cmake_lists() -> bool: compile_flags = get_project_compile_flags() link_flags = get_project_link_flags() + # Convert any PlatformIO libraries added via cg.add_library() into Zephyr + # modules and discover them through EXTRA_ZEPHYR_MODULES (a CMake list, set + # before find_package(Zephyr) so the modules are picked up). Only + # framework-agnostic libraries actually compile under Zephyr. + from esphome.components.zephyr.library import generate_zephyr_modules + + module_dirs = generate_zephyr_modules(list(CORE.platformio_libraries.values())) + lines = [ "cmake_minimum_required(VERSION 3.20.0)", "", 'set(Zephyr_DIR "$ENV{ZEPHYR_BASE}/share/zephyr-package/cmake/")', "", + ] + + if module_dirs: + modules = ";".join(str(d).replace("\\", "/") for d in module_dirs) + lines += [f'set(EXTRA_ZEPHYR_MODULES "{modules}")', ""] + + lines += [ "find_package(Zephyr REQUIRED)", "", f"project({CORE.name})", diff --git a/esphome/components/zephyr/library.py b/esphome/components/zephyr/library.py new file mode 100644 index 0000000000..7654e63700 --- /dev/null +++ b/esphome/components/zephyr/library.py @@ -0,0 +1,180 @@ +"""Zephyr backend for the shared PlatformIO library converter. + +For each PlatformIO library added via ``cg.add_library()``, emit a Zephyr +external module (``zephyr/module.yml`` + ``zephyr/CMakeLists.txt`` built with the +``zephyr_library*`` API) into the shared ``pio_components`` cache. The caller +wires the resulting module directories into the build via +``EXTRA_ZEPHYR_MODULES``; Zephyr then compiles each module and links it into the +final image. + +Only framework-agnostic libraries (plain C/C++ that doesn't depend on the Arduino +API) will actually compile under Zephyr — this converter shares the +fetch/parse/cache plumbing, not API compatibility. +""" + +from pathlib import Path + +from esphome import yaml_util +from esphome.core import EsphomeError, Library +from esphome.helpers import write_file_if_changed +from esphome.platformio.library import ( + DEFAULT_BUILD_FLAGS, + DEFAULT_BUILD_INCLUDE_DIR, + DEFAULT_BUILD_SRC_FILTER, + SRC_FILE_EXTENSIONS, + ConvertedLibrary, + LibraryBackend, + PathType, + collect_filtered_files, + convert_libraries, + ensure_list, + split_list_by_condition, +) + +# Zephyr libraries declare frameworks rarely and the PIO ``platforms`` token for +# nRF is seldom present, so the platform check is disabled (None) and only the +# framework mismatch warning fires. +ZEPHYR_FRAMEWORK = "zephyr" + + +def _escape(p: PathType) -> str: + # In CMakeLists.txt, backslashes need to be escaped (mirrors the ESP-IDF + # backend's escape_entry). Doubling -- rather than rewriting '\' -> '/' -- + # preserves content, so it's safe for arbitrary build flags (e.g. a -D value + # containing a backslash) as well as Windows paths. + return f'"{str(p)}"'.replace("\\", "\\\\") + + +def generate_module_yml(component: ConvertedLibrary) -> str: + """Render the ``zephyr/module.yml`` manifest for a converted library.""" + return yaml_util.dump( + { + "name": component.get_require_name(), + "build": {"cmake": "zephyr"}, + } + ) + + +def generate_cmakelists_txt(component: ConvertedLibrary) -> str: + """Render the ``zephyr/CMakeLists.txt`` that builds a converted library. + + Sources/includes are emitted as absolute paths since the CMakeLists lives in + the library's ``zephyr/`` subdir while its sources sit alongside it. Include + dirs are published globally so the app (and sibling libraries) can include the + library's headers, mirroring ESP-IDF's public ``INCLUDE_DIRS``. + """ + build = component.data.get("build", {}) + + build_src_dir = build.get("srcDir") + if not build_src_dir: + for d in ["src", "Src", "."]: + if (component.path / Path(d)).is_dir(): + build_src_dir = d + break + + build_include_dir = build.get("includeDir", DEFAULT_BUILD_INCLUDE_DIR) + build_src_filter = ensure_list(build.get("srcFilter", DEFAULT_BUILD_SRC_FILTER)) + build_flags = ensure_list(build.get("flags", DEFAULT_BUILD_FLAGS)) + + src_files = collect_filtered_files( + component.path / Path(build_src_dir), build_src_filter + ) + src_files = sorted( + str(Path(p).resolve()) + for p in src_files + if Path(p).suffix in SRC_FILE_EXTENSIONS + ) + + include_dir_flags, build_flags = split_list_by_condition( + build_flags, lambda a: a[2:].strip() if a.startswith("-I") else None + ) + link_directories, build_flags = split_list_by_condition( + build_flags, lambda a: a[2:].strip() if a.startswith("-L") else None + ) + link_libraries, build_flags = split_list_by_condition( + build_flags, lambda a: a[2:].strip() if a.startswith("-l") else None + ) + + include_dirs = [build_include_dir, build_src_dir, *include_dir_flags] + include_dirs = [ + str((component.path / Path(d)).resolve()) + for d in include_dirs + if (component.path / Path(d)).is_dir() + ] + + lines = [f"zephyr_library_named({component.get_require_name()})"] + if src_files: + lines += [ + "zephyr_library_sources(", + *[f" {_escape(p)}" for p in src_files], + ")", + ] + if include_dirs: + lines += [ + "zephyr_include_directories(", + *[f" {_escape(p)}" for p in include_dirs], + ")", + ] + if build_flags: + lines += [ + "zephyr_library_compile_options(", + *[f" {_escape(f)}" for f in build_flags], + ")", + ] + # Best-effort link wiring; most Zephyr-portable libraries don't need it. + link_flags = [f"-L{d}" for d in link_directories] + [ + f"-l{lib}" for lib in link_libraries + ] + if link_flags: + lines += [ + "zephyr_link_libraries(", + *[f" {_escape(f)}" for f in link_flags], + ")", + ] + + return "\n".join(lines) + "\n" + + +def _emit_zephyr_module(component: ConvertedLibrary) -> None: + zephyr_dir = component.path / "zephyr" + write_file_if_changed(zephyr_dir / "module.yml", generate_module_yml(component)) + write_file_if_changed( + zephyr_dir / "CMakeLists.txt", generate_cmakelists_txt(component) + ) + + +def generate_zephyr_modules(libraries: list[Library]) -> list[Path]: + """Convert ``libraries`` to Zephyr modules and return all module directories. + + The returned list includes transitive dependencies (each converted library is + its own module). Every directory should be added to ``EXTRA_ZEPHYR_MODULES``; + Zephyr links all module libraries into the image, so cross-library symbols + resolve without explicit dependency declarations. + + Raises ``EsphomeError`` if two libraries resolve to the same Zephyr module + name -- each module's CMakeLists calls ``zephyr_library_named()``, so a + duplicate would otherwise fail the build with a CMake "target already exists". + The converter already warns when a library is referenced under inconsistent + specs (bare ``name`` vs ``owner/name``, git vs registry); this turns that into + an actionable error at the Zephyr boundary where it is fatal. + """ + module_dirs: list[Path] = [] + by_name: dict[str, Path] = {} + + def emit(component: ConvertedLibrary) -> None: + name = component.get_require_name() + if name in by_name: + raise EsphomeError( + f"Two libraries resolve to the same Zephyr module '{name}' " + f"({by_name[name]} and {component.path}). Reference the library " + f"consistently (e.g. always as 'owner/name') so it resolves once." + ) + by_name[name] = component.path + _emit_zephyr_module(component) + module_dirs.append(component.path) + + backend = LibraryBackend( + platform=None, framework=ZEPHYR_FRAMEWORK, emit=emit, cache_key="zephyr" + ) + convert_libraries(libraries, backend) + return module_dirs diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 5029e014a4..e9ec170a5e 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -264,5 +264,6 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: platform=ESP32_PLATFORM, framework=_idf_framework(), emit=_emit_idf_component, + cache_key="idf", ) return convert_libraries(libraries, backend) diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index c2d783ecbe..291bedb5cd 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -68,7 +68,9 @@ ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE" class Source: - def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: + def download( + self, dir_suffix: str, force: bool = False, salt: str = "", namespace: str = "" + ) -> Path: raise NotImplementedError @@ -76,8 +78,14 @@ class URLSource(Source): def __init__(self, url: str): self.url = url - def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: + def download( + self, dir_suffix: str, force: bool = False, salt: str = "", namespace: str = "" + ) -> Path: + # Namespace the cache per backend (e.g. pio_components/idf, .../zephyr) so + # the build files each backend writes into the library dir can't collide. base_dir = Path(CORE.data_dir) / DOMAIN + if namespace: + base_dir = base_dir / namespace h = hashlib.new("sha256") h.update(self.url.encode()) if salt: @@ -113,12 +121,19 @@ class GitSource(Source): self.url = url self.ref = ref - def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: + def download( + self, dir_suffix: str, force: bool = False, salt: str = "", namespace: str = "" + ) -> Path: + domain = DOMAIN + if namespace: + domain = f"{domain}/{namespace}" + if salt: + domain = f"{domain}/{salt}" path, _ = git.clone_or_update( url=self.url, ref=self.ref, refresh=git.NEVER_REFRESH if not force else None, - domain=f"{DOMAIN}/{salt}" if salt else DOMAIN, + domain=domain, submodules=[], subpath=Path(dir_suffix), ) @@ -167,16 +182,16 @@ class ConvertedLibrary: def get_require_name(self): return self.get_sanitized_name().replace("/", "__") - def download(self, force: bool = False, salt: str = ""): + def download(self, force: bool = False, salt: str = "", namespace: str = ""): """Fetch the library into the shared cache and record its ``path``. The cache directory is named after the sanitized library name; backends rely on that name to identify the unit they build (e.g. ESP-IDF uses the directory name as the component name, replacing ``/`` with ``__`` via - ``get_require_name``). + ``get_require_name``). ``namespace`` keeps each backend's cache separate. """ self.path = self.source.download( - self.get_sanitized_name(), force=force, salt=salt + self.get_sanitized_name(), force=force, salt=salt, namespace=namespace ) @@ -188,11 +203,15 @@ class LibraryBackend: ``emit`` writes the toolchain-specific build files into a resolved library's ``path`` (e.g. the ESP-IDF ``CMakeLists.txt`` + ``idf_component.yml``, or a Zephyr ``module.yml`` + ``CMakeLists.txt``). + ``cache_key`` namespaces the download cache (``pio_components//``) + so the differing build files two backends emit into a library dir never + collide when the same config dir hosts both an ESP-IDF and a Zephyr build. """ - platform: str + platform: str | None framework: str emit: Callable[["ConvertedLibrary"], None] + cache_key: str def ensure_list[T](obj: T | list[T]) -> list[T]: @@ -306,7 +325,7 @@ def split_list_by_condition( return matched, non_matched -def check_library_data(data: dict, platform: str, framework: str): +def check_library_data(data: dict, platform: str | None, framework: str): """ Check whether a library manifest is compatible with the target toolchain. @@ -319,7 +338,9 @@ def check_library_data(data: dict, platform: str, framework: str): Args: data: PIO library manifest dict being processed. platform: The PlatformIO platform token the build targets (e.g. - ``espressif32``). + ``espressif32``). ``None`` skips the platform check entirely — useful + for targets (e.g. Zephyr) where PIO manifests rarely declare the + platform yet portable libraries still build. framework: The active framework name (e.g. ``espidf``, ``arduino``, ``zephyr``) the manifest is expected to declare. @@ -332,7 +353,7 @@ def check_library_data(data: dict, platform: str, framework: str): platforms = ensure_list(platforms) # Check if library supports the target platform - valid_platforms = "*" in platforms or platform in platforms + valid_platforms = platform is None or "*" in platforms or platform in platforms if not valid_platforms: raise InvalidLibrary(f"Unsupported library platforms: {platforms}") @@ -613,7 +634,7 @@ def convert_libraries( component = ConvertedLibrary( _owner_pkgname_to_name(owner, name), version, URLSource(url) ) - component.download(salt=salt) + component.download(salt=salt, namespace=backend.cache_key) library_json_path = component.path / "library.json" library_properties_path = component.path / "library.properties" diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index d43a1d5276..a50024b8e9 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -481,7 +481,7 @@ def test_generate_idf_components_dedupes_shared_dependency( "esphome/C": {"name": "C"}, } - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -543,7 +543,7 @@ def test_generate_idf_components_lib_ignore_filters_top_level_and_dependencies( download_salts: list[str] = [] - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): download_salts.append(salt) self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) @@ -597,7 +597,7 @@ def test_generate_idf_components_handles_dependency_cycle( }, } - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -654,7 +654,7 @@ def test_generate_idf_components_git_overrides_registry_warns( "esphome/shared": {"name": "shared"}, } - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -691,7 +691,7 @@ def test_generate_idf_components_missing_manifest_raises( ) -> None: # A library with neither library.json nor library.properties is invalid; # fail loudly rather than silently generating build files for it. - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) # no library.json / library.properties written @@ -733,7 +733,7 @@ def test_generate_idf_components_warns_on_noncanonical_duplicate( "owner/shared": {"name": "shared"}, } - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -766,7 +766,7 @@ def test_generate_idf_components_incompatible_top_level_raises( ) -> None: # A top-level library that isn't ESP-IDF/esp32 compatible must fail fast, # not be silently dropped. - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "library.json").write_text( @@ -804,7 +804,7 @@ def test_generate_idf_components_incompatible_dependency_skipped( "esphome/B": {"name": "B", "platforms": ["espressif8266"]}, } - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "library.json").write_text(json.dumps(manifests[self.name])) @@ -847,6 +847,13 @@ def test_url_source_salt_changes_cache_path( assert source.download("lib") == expected[""] assert source.download("lib", salt="abcd1234") == expected["abcd1234"] + # A backend namespace adds a pio_components// subdir. + digest = hashlib.sha256(url.encode()).hexdigest()[:8] + ns_expected = base / "idf" / digest / "lib" + ns_expected.mkdir(parents=True) + (ns_expected / ".esphome_extracted").touch() + assert source.download("lib", namespace="idf") == ns_expected + def test_git_source_salt_scopes_domain(monkeypatch: pytest.MonkeyPatch) -> None: """The salt becomes a subdirectory of the git clone domain.""" @@ -863,7 +870,14 @@ def test_git_source_salt_scopes_domain(monkeypatch: pytest.MonkeyPatch) -> None: source = GitSource("https://github.com/esphome/noise-c.git", "v1.0") source.download("noise-c") source.download("noise-c", salt="abcd1234") - assert domains == ["pio_components", "pio_components/abcd1234"] + source.download("noise-c", namespace="idf") + source.download("noise-c", namespace="zephyr", salt="abcd1234") + assert domains == [ + "pio_components", + "pio_components/abcd1234", + "pio_components/idf", + "pio_components/zephyr/abcd1234", + ] def test_idf_component_download_passes_salt() -> None: @@ -873,7 +887,9 @@ def test_idf_component_download_passes_salt() -> None: source.download.return_value = Path("/converted/owner/name") c = IDFComponent("owner/name", "1.0", source=source) - c.download(force=True, salt="abcd1234") + c.download(force=True, salt="abcd1234", namespace="idf") - source.download.assert_called_once_with("owner/name", force=True, salt="abcd1234") + source.download.assert_called_once_with( + "owner/name", force=True, salt="abcd1234", namespace="idf" + ) assert c.path == Path("/converted/owner/name") diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index 55bc396c25..03360eab37 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -26,7 +26,9 @@ from esphome.platformio.library import ( def _backend(emit=lambda component: None) -> LibraryBackend: - return LibraryBackend(platform="espressif32", framework="espidf", emit=emit) + return LibraryBackend( + platform="espressif32", framework="espidf", emit=emit, cache_key="idf" + ) def test_check_library_data_accepts_wildcards(): @@ -134,7 +136,7 @@ def test_resolve_registry_version_raises_without_pkg_file(monkeypatch): def _patch_download_with_manifests(monkeypatch, tmp_path, manifests, *, properties=()): """Fake ConvertedLibrary.download to materialize canned manifests on disk.""" - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") self.path.mkdir(parents=True, exist_ok=True) if self.name in properties: diff --git a/tests/unit_tests/test_zephyr_library.py b/tests/unit_tests/test_zephyr_library.py new file mode 100644 index 0000000000..0ba3577fa7 --- /dev/null +++ b/tests/unit_tests/test_zephyr_library.py @@ -0,0 +1,117 @@ +"""Tests for the Zephyr backend of the shared PlatformIO library converter.""" + +from pathlib import Path + +import pytest + +import esphome.components.zephyr.library as zlib +from esphome.components.zephyr.library import ( + generate_cmakelists_txt, + generate_module_yml, + generate_zephyr_modules, +) +from esphome.core import EsphomeError, Library +from esphome.platformio.library import ConvertedLibrary, URLSource + + +def _make_component(path: Path, name: str = "mylib") -> ConvertedLibrary: + c = ConvertedLibrary(name, "1.0", source=URLSource("http://dummy")) + c.path = path + return c + + +def test_generate_module_yml_uses_sanitized_name(): + c = ConvertedLibrary("owner/My Lib", "1.0", source=URLSource("http://dummy")) + out = generate_module_yml(c) + # "/" -> "__" and " " -> "_" so it's a valid Zephyr module name. + assert "name: owner__My_Lib" in out + assert "cmake: zephyr" in out + + +def test_generate_cmakelists_txt_basic(tmp_path): + c = _make_component(tmp_path) + src = tmp_path / "src" + src.mkdir() + (src / "main.c").write_text("int main() {}") + c.data = {} + + out = generate_cmakelists_txt(c) + + assert "zephyr_library_named(mylib)" in out + assert "zephyr_library_sources(" in out + # Sources are emitted as absolute paths (CMakeLists lives in zephyr/ subdir), + # backslash-escaped for CMake (matching the output on Windows). + assert str((src / "main.c").resolve()).replace("\\", "\\\\") in out + + +def test_generate_cmakelists_txt_flags_and_includes(tmp_path): + c = _make_component(tmp_path) + (tmp_path / "src").mkdir() + (tmp_path / "src" / "a.c").write_text("") + (tmp_path / "include").mkdir() + c.data = {"build": {"flags": ["-Iinclude", "-DFOO", "-Wall", "-Llibdir", "-lm"]}} + + out = generate_cmakelists_txt(c) + + assert "zephyr_include_directories(" in out + assert str((tmp_path / "include").resolve()).replace("\\", "\\\\") in out + assert "zephyr_library_compile_options(" in out + assert "-DFOO" in out + assert "-Wall" in out + assert "zephyr_link_libraries(" in out + assert "-Llibdir" in out + assert "-lm" in out + + +def test_generate_zephyr_modules_collects_all_dirs_and_writes(tmp_path, monkeypatch): + # Two converted libraries: one top-level, one transitive dependency. The + # converter calls backend.emit for both; generate_zephyr_modules must return + # *all* module dirs (not just top-level) so every module is discoverable. + top = _make_component(tmp_path / "top", "top") + (top.path / "src").mkdir(parents=True) + (top.path / "src" / "t.c").write_text("") + dep = _make_component(tmp_path / "dep", "dep") + (dep.path / "src").mkdir(parents=True) + (dep.path / "src" / "d.c").write_text("") + + captured = {} + + def fake_convert(libraries, backend): + captured["platform"] = backend.platform + captured["framework"] = backend.framework + backend.emit(top) + backend.emit(dep) + return [top] + + monkeypatch.setattr(zlib, "convert_libraries", fake_convert) + + dirs = generate_zephyr_modules([Library("top", "1.0", None)]) + + assert dirs == [top.path, dep.path] + # Platform check disabled for Zephyr; framework declared as zephyr. + assert captured["platform"] is None + assert captured["framework"] == "zephyr" + for comp in (top, dep): + assert (comp.path / "zephyr" / "module.yml").is_file() + assert (comp.path / "zephyr" / "CMakeLists.txt").is_file() + + +def test_generate_zephyr_modules_errors_on_duplicate_module_name(tmp_path, monkeypatch): + # The same library referenced under inconsistent specs (e.g. bare vs + # owner-qualified, or git vs registry) resolves to two components with the + # same Zephyr module name, which would collide in zephyr_library_named(). + a = _make_component(tmp_path / "a", "esphome/noise-c") + a.path.mkdir(parents=True) + b = _make_component(tmp_path / "b", "esphome/noise-c") + b.path.mkdir(parents=True) + assert a.get_require_name() == b.get_require_name() + + def fake_convert(libraries, backend): + backend.emit(a) + backend.emit(b) + return [a] + + monkeypatch.setattr(zlib, "convert_libraries", fake_convert) + + with pytest.raises(EsphomeError, match="same Zephyr module"): + generate_zephyr_modules([Library("esphome/noise-c", "1.0", None)]) From 648f5e1b068201c64d5382dc9b035395e997226b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:42:59 -0400 Subject: [PATCH 0705/1815] [nrf52] Install native sdk-nrf into a machine-global cache dir (#17353) --- docker/docker_entrypoint.sh | 3 +- .../etc/s6-overlay/s6-rc.d/esphome/run | 3 +- esphome/components/nrf52/framework.py | 21 ++++-- esphome/espidf/framework.py | 16 ++--- esphome/writer.py | 21 ++++-- tests/unit_tests/test_espidf_framework.py | 28 ++++---- tests/unit_tests/test_nrf52_framework.py | 61 ++++++++++++++++- tests/unit_tests/test_writer.py | 68 +++++++++++++++++-- 8 files changed, 180 insertions(+), 41 deletions(-) diff --git a/docker/docker_entrypoint.sh b/docker/docker_entrypoint.sh index 598b553c08..c88a78f97e 100755 --- a/docker/docker_entrypoint.sh +++ b/docker/docker_entrypoint.sh @@ -21,9 +21,10 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms" export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages" export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache" -# Keep the native ESP-IDF install on the persistent cache root, not the +# Keep the native toolchain installs on the persistent cache root, not the # container's ephemeral user cache dir (re-downloaded on every restart). export ESPHOME_ESP_IDF_PREFIX="$(dirname "${pio_cache_base}")/idf" +export ESPHOME_SDK_NRF_PREFIX="$(dirname "${pio_cache_base}")/sdk-nrf" # If /build is mounted, use that as the build path # otherwise use path in /config (so that builds aren't lost on container restart) diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run index f50de659b9..20fada5f13 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run @@ -15,9 +15,10 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms" export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages" export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache" -# Keep the native ESP-IDF install on the persistent /data volume, not the +# Keep the native toolchain installs on the persistent /data volume, not the # container's ephemeral user cache dir (wiped on every add-on update/restart). export ESPHOME_ESP_IDF_PREFIX=/data/cache/idf +export ESPHOME_SDK_NRF_PREFIX=/data/cache/sdk-nrf if bashio::config.true 'leave_front_door_open'; then export DISABLE_HA_AUTHENTICATION=true diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 7aec6b088e..7cb1164482 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -4,6 +4,8 @@ from pathlib import Path import platform import tempfile +import platformdirs + from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, EsphomeError from esphome.framework_helpers import ( @@ -15,6 +17,7 @@ from esphome.framework_helpers import ( run_command_ok, str_to_lst_of_str, ) +from esphome.helpers import get_str_env _LOGGER = logging.getLogger(__name__) @@ -38,20 +41,28 @@ SDK_NG_MINIMAL_MIRRORS = str_to_lst_of_str( ) -def _get_tools_path() -> Path: - return CORE.data_dir / "sdk-nrf" +def get_sdk_nrf_tools_path() -> Path: + # A blank ESPHOME_SDK_NRF_PREFIX must be treated as unset: Path("") + # resolves to the CWD, which clean-all would then delete. + if prefix := get_str_env("ESPHOME_SDK_NRF_PREFIX", "").strip(): + path = Path(prefix).expanduser() + else: + # Machine-global (OS user cache dir) so all projects share one install; + # see espidf.framework.get_idf_tools_path for the location rationale. + path = Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "sdk-nrf" + return path.resolve() def _get_python_env_path(version: str) -> Path: - return _get_tools_path() / "penvs" / version + return get_sdk_nrf_tools_path() / "penvs" / version def _get_framework_path(version: str) -> Path: - return _get_tools_path() / "frameworks" / version + return get_sdk_nrf_tools_path() / "frameworks" / version def _get_toolchain_path(version: str) -> Path: - return _get_tools_path() / "toolchains" / version + return get_sdk_nrf_tools_path() / "toolchains" / version _SITECUSTOMIZE = """\ diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 25283e3c99..810a63476f 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -75,7 +75,7 @@ ESP_IDF_CONSTRAINTS_MIRRORS = str_to_lst_of_str( ) -def _get_idf_tools_path() -> Path: +def get_idf_tools_path() -> Path: """ Get the path to the ESP-IDF tools directory. @@ -141,7 +141,7 @@ def _check_windows_path_length() -> None: """ if platform.system() != "Windows" or _windows_long_paths_enabled(): return - tools_path = str(_get_idf_tools_path()) + tools_path = str(get_idf_tools_path()) projected = len(tools_path) + _TOOLCHAIN_NESTED_PATH_LEN if projected <= _WINDOWS_MAX_PATH: return @@ -180,7 +180,7 @@ def _get_framework_path(version: str) -> Path: Returns: Path object pointing to the framework directory """ - return _get_idf_tools_path() / "frameworks" / f"{version}" + return get_idf_tools_path() / "frameworks" / f"{version}" def _get_python_env_path(version: str) -> Path: @@ -193,7 +193,7 @@ def _get_python_env_path(version: str) -> Path: Returns: Path object pointing to the Python environment directory """ - return _get_idf_tools_path() / "penvs" / f"{version}" + return get_idf_tools_path() / "penvs" / f"{version}" def _check_stamp(file: PathType, data: dict[str, str]) -> bool: @@ -707,7 +707,7 @@ def _check_esp_idf_python_env_install( esp_idf_version = _get_idf_version(framework_path, env=env) constraint_file_path = ( - _get_idf_tools_path() / f"espidf.constraints.v{esp_idf_version}.txt" + get_idf_tools_path() / f"espidf.constraints.v{esp_idf_version}.txt" ) _LOGGER.debug("ESP-IDF version %s", esp_idf_version) @@ -798,7 +798,7 @@ def check_esp_idf_install( _check_windows_path_length() env = {} - env["IDF_TOOLS_PATH"] = str(_get_idf_tools_path()) + env["IDF_TOOLS_PATH"] = str(get_idf_tools_path()) env["IDF_PATH"] = "" targets = targets or ESPHOME_IDF_DEFAULT_TARGETS @@ -867,7 +867,7 @@ def _ccache_env() -> dict[str, str]: defaults = { "IDF_CCACHE_ENABLE": "1", - "CCACHE_DIR": str(_get_idf_tools_path() / "ccache"), + "CCACHE_DIR": str(get_idf_tools_path() / "ccache"), "CCACHE_NOHASHDIR": "true", "CCACHE_DEPEND": "1", "CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()), @@ -894,7 +894,7 @@ def get_framework_env( """ # 1. Initialize base environment with extra ESP-IDF environment variables env = env.copy() if env else {} - env["IDF_TOOLS_PATH"] = str(_get_idf_tools_path()) + env["IDF_TOOLS_PATH"] = str(get_idf_tools_path()) env["IDF_PATH"] = "" # 2. Get existing PATH from env or os.environ diff --git a/esphome/writer.py b/esphome/writer.py index 52f2d169b3..b7eeec916d 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -653,14 +653,21 @@ def clean_all(configuration: list[str]): elif item.is_dir() and item.name != "storage": rmtree(item) - # The native ESP-IDF install lives in a machine-global cache dir, outside - # any .esphome data dir, so the per-config loop above won't reach it. - from esphome.espidf.framework import _get_idf_tools_path + # The native toolchain installs live in a machine-global cache dir that + # the per-config loop above can't reach. Wipe the default cache root + # (also catches leftovers from older install layouts), then the resolved + # install paths for the ESPHOME_*_PREFIX overrides (docker/add-on/CI) + # that live outside it. + import platformdirs - idf_install_path = _get_idf_tools_path() - if idf_install_path.is_dir(): - _LOGGER.info("Deleting %s", idf_install_path) - rmtree(idf_install_path) + from esphome.components.nrf52.framework import get_sdk_nrf_tools_path + from esphome.espidf.framework import get_idf_tools_path + + cache_root = Path(platformdirs.user_cache_dir("esphome", appauthor=False)).resolve() + for install_path in (cache_root, get_idf_tools_path(), get_sdk_nrf_tools_path()): + if install_path.is_dir(): + _LOGGER.info("Deleting %s", install_path) + rmtree(install_path) # Clean PlatformIO project files try: diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index f3e160925a..c5d9ddbaf1 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -21,7 +21,6 @@ from esphome.espidf.framework import ( _clone_idf_with_submodules, _get_framework_path, _get_idf_tool_paths, - _get_idf_tools_path, _get_idf_version, _get_python_env_path, _get_python_version, @@ -32,6 +31,7 @@ from esphome.espidf.framework import ( _write_stamp, check_esp_idf_install, get_framework_env, + get_idf_tools_path, ) from esphome.framework_helpers import _tar_extract_all, get_python_env_executable_path @@ -639,7 +639,7 @@ def test_write_stamp_writes_json(tmp_path: Path) -> None: def test_get_framework_env_with_python_env(tmp_path: Path) -> None: with ( patch( - "esphome.espidf.framework._get_idf_tools_path", + "esphome.espidf.framework.get_idf_tools_path", return_value=tmp_path / "tools", ), patch("esphome.espidf.framework._get_idf_version", return_value="5.1.2"), @@ -664,7 +664,7 @@ def test_get_framework_env_with_python_env(tmp_path: Path) -> None: def test_get_framework_env_without_python_env_uses_os_path(tmp_path: Path) -> None: with ( patch( - "esphome.espidf.framework._get_idf_tools_path", + "esphome.espidf.framework.get_idf_tools_path", return_value=tmp_path / "tools", ), patch("esphome.espidf.framework._get_idf_version", return_value="5.1.2"), @@ -687,7 +687,7 @@ def _ccache_patches(tmp_path: Path, which: str | None, build_path: Path | None): return ( patch("esphome.espidf.framework.shutil.which", return_value=which), patch( - "esphome.espidf.framework._get_idf_tools_path", + "esphome.espidf.framework.get_idf_tools_path", return_value=tmp_path / "tools", ), patch( @@ -761,7 +761,7 @@ def test_ccache_env_raises_without_build_path(tmp_path: Path) -> None: # --------------------------------------------------------------------------- -# _check_stamp / _write_idf_version_txt / _get_idf_tools_path +# _check_stamp / _write_idf_version_txt / get_idf_tools_path # --------------------------------------------------------------------------- @@ -798,14 +798,14 @@ def test_write_idf_version_txt_skips_when_present(tmp_path: Path) -> None: assert (tmp_path / "version.txt").read_text(encoding="utf-8") == "existing\n" -def test_get_idf_tools_path_env_override(tmp_path: Path) -> None: +def testget_idf_tools_path_env_override(tmp_path: Path) -> None: override = str(tmp_path / "custom-idf") with patch.dict("os.environ", {"ESPHOME_ESP_IDF_PREFIX": override}): - assert _get_idf_tools_path() == Path(override) + assert get_idf_tools_path() == Path(override) @pytest.mark.parametrize("value", ["", " "]) -def test_get_idf_tools_path_blank_env_falls_back_to_default( +def testget_idf_tools_path_blank_env_falls_back_to_default( value: str, monkeypatch: pytest.MonkeyPatch ) -> None: """A blank ESPHOME_ESP_IDF_PREFIX is treated as unset, not as CWD. @@ -819,10 +819,10 @@ def test_get_idf_tools_path_blank_env_falls_back_to_default( expected = ( Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf" ).resolve() - assert _get_idf_tools_path() == expected + assert get_idf_tools_path() == expected -def test_get_idf_tools_path_default_uses_user_cache( +def testget_idf_tools_path_default_uses_user_cache( monkeypatch: pytest.MonkeyPatch, ) -> None: """Without the env override the install root is the machine-global OS user @@ -833,7 +833,7 @@ def test_get_idf_tools_path_default_uses_user_cache( expected = ( Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf" ).resolve() - assert _get_idf_tools_path() == expected + assert get_idf_tools_path() == expected def test_write_idf_version_txt_warns_on_write_error(tmp_path: Path) -> None: @@ -908,7 +908,7 @@ def test_check_windows_path_length_noop_when_long_paths_enabled( patch( "esphome.espidf.framework._windows_long_paths_enabled", return_value=True ), - patch("esphome.espidf.framework._get_idf_tools_path") as get_path_mock, + patch("esphome.espidf.framework.get_idf_tools_path") as get_path_mock, caplog.at_level(logging.WARNING), ): _check_windows_path_length() @@ -925,7 +925,7 @@ def test_check_windows_path_length_short_path_silent( "esphome.espidf.framework._windows_long_paths_enabled", return_value=False ), patch( - "esphome.espidf.framework._get_idf_tools_path", + "esphome.espidf.framework.get_idf_tools_path", return_value=_SHORT_IDF_PATH, ), caplog.at_level(logging.WARNING), @@ -943,7 +943,7 @@ def test_check_windows_path_length_long_path_warns( "esphome.espidf.framework._windows_long_paths_enabled", return_value=False ), patch( - "esphome.espidf.framework._get_idf_tools_path", + "esphome.espidf.framework.get_idf_tools_path", return_value=_LONG_IDF_PATH, ), caplog.at_level(logging.WARNING), diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index 04c712f0b7..2b3d1f6db8 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -10,12 +10,28 @@ from esphome.components.nrf52.framework import ( _TOOLCHAIN_VERSION, _get_toolchain_platform_info, check_and_install, + get_sdk_nrf_tools_path, ) from esphome.config_validation import Version from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, EsphomeError +@pytest.fixture(autouse=True) +def _isolate_sdk_nrf_install_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Pin the sdk-nrf install root to a tmp dir for every test. + + The default location is the OS user cache dir, so without this any test + that builds framework paths or pre-creates the install dir would touch + the real ``~/.cache/esphome`` on the developer's machine. Tests that need + to exercise the override or default-resolution logic clear/override the + env themselves. + """ + monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", str(tmp_path / "sdk_nrf_install")) + + @pytest.mark.parametrize( ("system", "machine", "expected"), [ @@ -52,7 +68,7 @@ _TEST_SDK_VERSION = "2.9.0" def nrf52_dirs(setup_core: Path) -> SimpleNamespace: """Populate CORE and pre-create SDK directories so sentinel.touch() succeeds.""" CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: Version.parse(_TEST_SDK_VERSION)} - tools = CORE.data_dir / "sdk-nrf" + tools = get_sdk_nrf_tools_path() python_env = tools / "penvs" / f"v{_TEST_SDK_VERSION}" framework = tools / "frameworks" / f"v{_TEST_SDK_VERSION}" toolchain_dir = tools / "toolchains" / _TOOLCHAIN_VERSION @@ -226,3 +242,46 @@ class TestCheckAndInstall: assert substitutions["sysname"] == "linux" assert substitutions["machine"] == "x86_64" assert substitutions["extension"] == "tar.xz" + + +# --------------------------------------------------------------------------- +# get_sdk_nrf_tools_path tests +# --------------------------------------------------------------------------- + + +def testget_tools_path_env_override( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + override = tmp_path / "custom" / "sdk-nrf" + monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", str(override)) + assert get_sdk_nrf_tools_path() == override.resolve() + + +@pytest.mark.parametrize("value", ["", " "]) +def testget_tools_path_blank_env_falls_back_to_default( + value: str, monkeypatch: pytest.MonkeyPatch +) -> None: + """A blank ESPHOME_SDK_NRF_PREFIX is treated as unset, not as CWD. + + Path("") would resolve to the working directory, which clean-all could + then delete by accident. + """ + import platformdirs + + monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", value) + expected = ( + Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "sdk-nrf" + ).resolve() + assert get_sdk_nrf_tools_path() == expected + + +def testget_tools_path_default_is_global_cache( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import platformdirs + + monkeypatch.delenv("ESPHOME_SDK_NRF_PREFIX", raising=False) + expected = ( + Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "sdk-nrf" + ).resolve() + assert get_sdk_nrf_tools_path() == expected diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 18d08e7cb1..07f334d350 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -68,12 +68,16 @@ def _isolate_platformio_paths(tmp_path_factory: pytest.TempPathFactory) -> Any: test_clean_all_partial_exists) install their own inner patch which stacks on top of this one and wins for the duration of their block. - Also pin ``ESPHOME_ESP_IDF_PREFIX`` to a nonexistent tmp dir for the - same reason: ``clean_all`` removes the now machine-global ESP-IDF - install, which otherwise defaults to the real ``~/.cache/esphome``. + Also pin ``ESPHOME_ESP_IDF_PREFIX`` and ``ESPHOME_SDK_NRF_PREFIX`` to + nonexistent tmp dirs, and patch ``platformdirs.user_cache_dir``, for the + same reason: ``clean_all`` removes the machine-global toolchain installs + and their default cache root, which otherwise resolve to the real + ``~/.cache/esphome``. """ pio_root = tmp_path_factory.mktemp("isolated_pio") / "nonexistent" idf_root = tmp_path_factory.mktemp("isolated_idf") / "nonexistent" + sdk_nrf_root = tmp_path_factory.mktemp("isolated_sdk_nrf") / "nonexistent" + cache_root = tmp_path_factory.mktemp("isolated_cache") / "nonexistent" mock_cfg = MagicMock() mock_cfg.get.side_effect = lambda section, option: ( str(pio_root / option) if section == "platformio" else "" @@ -83,7 +87,14 @@ def _isolate_platformio_paths(tmp_path_factory: pytest.TempPathFactory) -> Any: "platformio.project.config.ProjectConfig.get_instance", return_value=mock_cfg, ), - patch.dict("os.environ", {"ESPHOME_ESP_IDF_PREFIX": str(idf_root)}), + patch.dict( + "os.environ", + { + "ESPHOME_ESP_IDF_PREFIX": str(idf_root), + "ESPHOME_SDK_NRF_PREFIX": str(sdk_nrf_root), + }, + ), + patch("platformdirs.user_cache_dir", return_value=str(cache_root)), ): yield @@ -1022,6 +1033,55 @@ def test_clean_all_removes_global_idf_install( assert str(idf_install.resolve()) in caplog.text +@patch("esphome.writer.CORE") +def test_clean_all_removes_global_sdk_nrf_install( + mock_core: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """clean_all removes the machine-global native sdk-nrf install dir.""" + sdk_nrf_install = tmp_path / "sdk_nrf_install" + (sdk_nrf_install / "frameworks").mkdir(parents=True) + monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", str(sdk_nrf_install)) + + config_dir = tmp_path / "config" + config_dir.mkdir() + + from esphome.writer import clean_all + + with caplog.at_level("INFO"): + clean_all([str(config_dir)]) + + assert not sdk_nrf_install.exists() + assert str(sdk_nrf_install.resolve()) in caplog.text + + +@patch("esphome.writer.CORE") +def test_clean_all_removes_default_cache_root( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """clean_all removes the default cache root (stale/orphaned installs).""" + cache_root = tmp_path / "cache_root" + (cache_root / "some-old-toolchain").mkdir(parents=True) + + config_dir = tmp_path / "config" + config_dir.mkdir() + + from esphome.writer import clean_all + + with ( + patch("platformdirs.user_cache_dir", return_value=str(cache_root)), + caplog.at_level("INFO"), + ): + clean_all([str(config_dir)]) + + assert not cache_root.exists() + assert str(cache_root.resolve()) in caplog.text + + @patch("esphome.writer.CORE") def test_clean_all_with_yaml_build_path( mock_core: MagicMock, From 4f0968f1df0b57751133f4aab4f7d54aea85b537 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:35:46 -0400 Subject: [PATCH 0706/1815] Bump github/codeql-action/analyze from 4.36.2 to 4.36.3 (#17363) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5a448c4003..6ca3e065cc 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 with: category: "/language:${{matrix.language}}" From bef6773281f2fa0e0047df26f1eea597791945fa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:36:01 -0400 Subject: [PATCH 0707/1815] Bump github/codeql-action/init from 4.36.2 to 4.36.3 (#17362) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6ca3e065cc..610e6ed020 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} From 0b48ca00278f692dbc5236743f73aa995807557e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:36:33 -0400 Subject: [PATCH 0708/1815] Bump the docker-actions group with 2 updates (#17361) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-docker.yml | 8 ++++---- .github/workflows/release.yml | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index d6ad28dffe..07a792df08 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -67,7 +67,7 @@ jobs: with: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Determine tag and whether to push id: tag @@ -96,7 +96,7 @@ jobs: - name: Log in to the GitHub container registry if: steps.tag.outputs.push == 'true' - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -151,10 +151,10 @@ jobs: with: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to the GitHub container registry - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 20a77b152d..d00c6523c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -99,15 +99,15 @@ jobs: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to docker hub - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -178,17 +178,17 @@ jobs: merge-multiple: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to docker hub if: matrix.registry == 'dockerhub' - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry if: matrix.registry == 'ghcr' - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: registry: ghcr.io username: ${{ github.actor }} From f447c88b4c032a3461ba3f7ee93f55263f605cad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:37:12 -0400 Subject: [PATCH 0709/1815] Bump docker/build-push-action from 7.2.0 to 7.3.0 in /.github/actions/build-image (#17336) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/build-image/action.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/build-image/action.yaml b/.github/actions/build-image/action.yaml index 494c0cebe8..133d7ca8d8 100644 --- a/.github/actions/build-image/action.yaml +++ b/.github/actions/build-image/action.yaml @@ -42,7 +42,7 @@ runs: - name: Build and push to ghcr by digest id: build-ghcr - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false @@ -67,7 +67,7 @@ runs: - name: Build and push to dockerhub by digest id: build-dockerhub - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false From 5417a16f9dc6d67f175ddbc5a5c8b02fb8674fce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:37:22 -0400 Subject: [PATCH 0710/1815] Update argcomplete requirement from >=3.6.3 to >=3.7.0 (#17334) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index baa8b5efd2..95388f278f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,4 +29,4 @@ platformdirs==4.10.0 # native esp-idf toolchain global cache dir pyparsing >= 3.3.2 # For autocompletion -argcomplete>=3.6.3 +argcomplete>=3.7.0 From 9f589ec4fcad48e61e2d6ccbf74889e089a6640e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:47:12 -0400 Subject: [PATCH 0711/1815] [api] Register homeassistant.action with synchronous=False to fix stale trigger args in response callbacks (#17367) --- esphome/components/api/__init__.py | 9 +++- tests/component_tests/api/__init__.py | 0 .../api/test_homeassistant_action.py | 28 ++++++++++++ .../api/test_homeassistant_action.yaml | 43 +++++++++++++++++++ tests/components/api/common-base.yaml | 28 ++++++++++++ 5 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/api/__init__.py create mode 100644 tests/component_tests/api/test_homeassistant_action.py create mode 100644 tests/component_tests/api/test_homeassistant_action.yaml diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 0f5cd936f5..1146b43596 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -540,17 +540,20 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All( ) +# synchronous=False: when on_success/on_error is configured, play() stores the +# trigger args until the HomeassistantActionResponse arrives, so non-owning args +# (StringRef into the API receive buffer) must not be used. @automation.register_action( "homeassistant.action", HomeAssistantServiceCallAction, HOMEASSISTANT_ACTION_ACTION_SCHEMA, - synchronous=True, + synchronous=False, ) @automation.register_action( "homeassistant.service", HomeAssistantServiceCallAction, HOMEASSISTANT_ACTION_ACTION_SCHEMA, - synchronous=True, + synchronous=False, ) async def homeassistant_service_to_code( config: ConfigType, @@ -644,6 +647,8 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema( ) +# synchronous=True is safe here: the event schema has no on_success/on_error, +# so play() never stores the trigger args. @automation.register_action( "homeassistant.event", HomeAssistantServiceCallAction, diff --git a/tests/component_tests/api/__init__.py b/tests/component_tests/api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/api/test_homeassistant_action.py b/tests/component_tests/api/test_homeassistant_action.py new file mode 100644 index 0000000000..611353e7c5 --- /dev/null +++ b/tests/component_tests/api/test_homeassistant_action.py @@ -0,0 +1,28 @@ +"""Tests for arg-type selection of api user-defined services with homeassistant.action.""" + +CONFIG = "tests/component_tests/api/test_homeassistant_action.yaml" + + +def test_synchronous_chain_keeps_zero_copy_args(generate_main): + """A chain of synchronous actions keeps the non-owning StringRef arg type.""" + main_cpp = generate_main(CONFIG) + + assert ( + "api::UserServiceTrigger" + '("zero_copy_args", {"message"})' in main_cpp + ) + + +def test_response_callback_args_are_owning(generate_main): + """homeassistant.action with on_success/on_error stores the trigger args + until the HomeassistantActionResponse arrives, so string args must fall + back to owning std::string; StringRef would point into the connection's + receive buffer, which is reused before the response arrives.""" + main_cpp = generate_main(CONFIG) + + assert ( + "api::UserServiceTrigger" + '("response_args", {"message"})' in main_cpp + ) + assert "api::HomeAssistantServiceCallAction" in main_cpp + assert "api::HomeAssistantServiceCallAction" not in main_cpp diff --git a/tests/component_tests/api/test_homeassistant_action.yaml b/tests/component_tests/api/test_homeassistant_action.yaml new file mode 100644 index 0000000000..4561494c9e --- /dev/null +++ b/tests/component_tests/api/test_homeassistant_action.yaml @@ -0,0 +1,43 @@ +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: SomeNetwork + password: SomePassword + +logger: + +api: + actions: + # Chain of synchronous actions that never store the args: + # keeps the zero-copy StringRef arg type. + - action: zero_copy_args + variables: + message: string + then: + - logger.log: + format: "%s" + args: [message.c_str()] + # homeassistant.action with on_success/on_error stores the trigger args + # until the action response arrives, so the codegen must fall back to + # owning std::string args (StringRef would dangle once the receive + # buffer is reused). + - action: response_args + variables: + message: string + then: + - homeassistant.action: + action: notify.notify + data: + message: !lambda return message; + on_success: + - logger.log: + format: "sent %s" + args: [message.c_str()] + on_error: + - logger.log: + format: "failed (%s): %s" + args: [error.c_str(), message.c_str()] diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index 060254990d..d7470ee4b3 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -109,6 +109,34 @@ api: - name.c_str() - int_arr.size() - string_arr.size() + # Test string + array args used by homeassistant.action's deferred + # on_success/on_error response callback. homeassistant.action registers + # synchronous=False, so the api codegen must fall back to owning + # std::string / std::vector args here: the non-owning defaults would + # dangle once rx_buf_ is reused before the response arrives, and the + # non-copyable FixedVector would fail to compile when captured into + # the response callback. + - action: action_response_args + variables: + name: string + int_arr: int[] + then: + - homeassistant.action: + action: notify.notify + data: + message: !lambda 'return name;' + on_success: + - logger.log: + format: "Notified %s (%u ints)" + args: + - name.c_str() + - int_arr.size() + on_error: + - logger.log: + format: "Notify failed (%s): %s" + args: + - error.c_str() + - name.c_str() # Test ContinuationAction (IfAction with then/else branches) - action: test_if_action variables: From 0725157bf50521298866ee7e7a22c8e222f51be9 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:48:08 -0500 Subject: [PATCH 0712/1815] Bump bundled esphome-device-builder to 1.0.26 (#17369) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 543f17db56..7d56b04041 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.25 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.26 RUN \ platformio settings set enable_telemetry No \ From 5fe36a45edf7309a65260fb181ea3c845705a15c Mon Sep 17 00:00:00 2001 From: Joseph Spiros Date: Thu, 2 Jul 2026 19:48:55 -0400 Subject: [PATCH 0713/1815] [core] Skip MAC-suffix mDNS discovery for non-mDNS addresses (#16874) --- esphome/__main__.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 1767d3b7ca..2cc904ff4b 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -225,8 +225,9 @@ def _discover_mac_suffix_devices() -> list[str] | None: Returns: - ``None`` when discovery isn't applicable (``name_add_mac_suffix`` off, - mDNS disabled, or ``CORE.address`` is already an IP). Callers should - then fall back to whatever default OTA address they normally use. + mDNS disabled, or ``CORE.address`` isn't a ``.local`` mDNS address). + Callers should then fall back to whatever default OTA address they + normally use. - ``[]`` when discovery ran but found nothing. Callers should NOT fall back to the base name: with ``name_add_mac_suffix`` enabled, the base name by definition doesn't exist on the network. @@ -236,7 +237,7 @@ def _discover_mac_suffix_devices() -> list[str] | None: ``aioesphomeapi`` via :func:`_resolve_network_devices`) reuses the IPs we already have without opening a second Zeroconf client. """ - if not (has_name_add_mac_suffix() and has_mdns() and has_non_ip_address()): + if not (has_name_add_mac_suffix() and has_mdns() and has_mdns_address()): return None from esphome.zeroconf import discover_mdns_devices @@ -503,17 +504,22 @@ def has_mdns() -> bool: def has_non_ip_address() -> bool: - """Check if CORE.address is set and is not an IP address.""" + """Check if ``CORE.address`` is set and is not an IP address.""" return CORE.address is not None and not is_ip_address(CORE.address) +def has_mdns_address() -> bool: + """Check if ``CORE.address`` is a ``.local`` mDNS hostname.""" + return CORE.address is not None and CORE.address.endswith(".local") + + def has_ip_address() -> bool: - """Check if CORE.address is a valid IP address.""" + """Check if ``CORE.address`` is a valid IP address.""" return CORE.address is not None and is_ip_address(CORE.address) def has_resolvable_address() -> bool: - """Check if CORE.address is resolvable (via mDNS, DNS, or is an IP address).""" + """Check if ``CORE.address`` is resolvable (via mDNS, DNS, or is an IP address).""" # Any address (IP, mDNS hostname, or regular DNS hostname) is resolvable # The resolve_ip_address() function in helpers.py handles all types via AsyncResolver if CORE.address is None: @@ -532,7 +538,7 @@ def has_resolvable_address() -> bool: return True # .local mDNS hostnames are only resolvable if mDNS is enabled - return not CORE.address.endswith(".local") + return not has_mdns_address() def has_name_add_mac_suffix() -> bool: From c3233739c591d321cb7277ee6665beb8a5a85967 Mon Sep 17 00:00:00 2001 From: Anunay Kulshrestha Date: Fri, 3 Jul 2026 15:13:21 +0530 Subject: [PATCH 0714/1815] [zephyr] Implement GPIO interrupts (ISRInternalGPIOPin) (#17077) Co-authored-by: Claude Opus 4.8 Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: tomaszduda23 --- .../components/gpio/binary_sensor/__init__.py | 3 +- esphome/components/zephyr/gpio.cpp | 82 ++++++++++++++++++- esphome/components/zephyr/gpio.h | 15 ++++ .../components/gpio/test.nrf52-adafruit.yaml | 24 ++++++ 4 files changed, 118 insertions(+), 6 deletions(-) diff --git a/esphome/components/gpio/binary_sensor/__init__.py b/esphome/components/gpio/binary_sensor/__init__.py index f14a920c24..2f1aa936a3 100644 --- a/esphome/components/gpio/binary_sensor/__init__.py +++ b/esphome/components/gpio/binary_sensor/__init__.py @@ -39,7 +39,6 @@ CONFIG_SCHEMA = ( # due to hardware limitations or lack of reliable interrupt support. This ensures # stable operation on these platforms. Future maintainers should verify platform # capabilities before changing this default behavior. - # nrf52 has no gpio interrupts implemented yet cv.SplitDefault( CONF_USE_INTERRUPT, bk72xx=False, @@ -47,7 +46,7 @@ CONFIG_SCHEMA = ( esp8266=True, host=True, ln882x=False, - nrf52=False, + nrf52=True, rp2040=True, rtl87xx=False, ): cv.boolean, diff --git a/esphome/components/zephyr/gpio.cpp b/esphome/components/zephyr/gpio.cpp index 1d5b0f282b..1e4201d8f5 100644 --- a/esphome/components/zephyr/gpio.cpp +++ b/esphome/components/zephyr/gpio.cpp @@ -1,6 +1,7 @@ #ifdef USE_ZEPHYR #include "gpio.h" #include +#include #include "esphome/core/log.h" namespace esphome { @@ -33,20 +34,80 @@ static gpio_flags_t flags_to_mode(gpio::Flags flags, bool inverted, bool value) return ret; } +// ESPHome's InterruptType is expressed in logical levels, but the pin is configured active-high in Zephyr (inversion is +// applied in software by digital_read()/digital_write(), see the `!= inverted_` convention below). So when the pin is +// inverted we must swap the physical edge/level the interrupt arms on: a logical rising edge is a physical falling +// edge, etc. GPIO_INT_EDGE_BOTH is symmetric and needs no swap. +static gpio_flags_t interrupt_type_to_flags(gpio::InterruptType type, bool inverted) { + switch (type) { + case gpio::INTERRUPT_RISING_EDGE: + return inverted ? GPIO_INT_EDGE_FALLING : GPIO_INT_EDGE_RISING; + case gpio::INTERRUPT_FALLING_EDGE: + return inverted ? GPIO_INT_EDGE_RISING : GPIO_INT_EDGE_FALLING; + case gpio::INTERRUPT_ANY_EDGE: + return GPIO_INT_EDGE_BOTH; + case gpio::INTERRUPT_LOW_LEVEL: + return inverted ? GPIO_INT_LEVEL_HIGH : GPIO_INT_LEVEL_LOW; + case gpio::INTERRUPT_HIGH_LEVEL: + return inverted ? GPIO_INT_LEVEL_LOW : GPIO_INT_LEVEL_HIGH; + } + return inverted ? GPIO_INT_EDGE_FALLING : GPIO_INT_EDGE_RISING; +} + +// Zephyr calls this with a pointer to the gpio_callback the interrupt fired on. +// Recover the owning ZephyrGPIOInterrupt and dispatch to the ESPHome ISR. +static void gpio_interrupt_handler(const device * /*dev*/, gpio_callback *cb, uint32_t /*pins*/) { + auto *interrupt = CONTAINER_OF(cb, ZephyrGPIOInterrupt, callback); + if (interrupt->func != nullptr) { + interrupt->func(interrupt->arg); + } +} + struct ISRPinArg { + const device *gpio; uint8_t pin; + uint8_t gpio_size; bool inverted; }; ISRInternalGPIOPin ZephyrGPIOPin::to_isr() const { auto *arg = new ISRPinArg{}; // NOLINT(cppcoreguidelines-owning-memory) + arg->gpio = this->gpio_; arg->pin = this->pin_; + arg->gpio_size = this->gpio_size_; arg->inverted = this->inverted_; return ISRInternalGPIOPin((void *) arg); } void ZephyrGPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const { - // TODO + if (!device_is_ready(this->gpio_)) { + ESP_LOGE(TAG, "Cannot attach interrupt: GPIO device not ready"); + return; + } + + // Drop any interrupt previously attached to this pin before re-registering. + this->detach_interrupt(); + + this->interrupt_.func = func; + this->interrupt_.arg = arg; + + uint8_t port_pin = this->pin_ % this->gpio_size_; + gpio_init_callback(&this->interrupt_.callback, gpio_interrupt_handler, BIT(port_pin)); + + int ret = gpio_add_callback(this->gpio_, &this->interrupt_.callback); + if (ret != 0) { + ESP_LOGE(TAG, "gpio_add_callback failed for pin %u: %d", this->pin_, ret); + return; + } + + ret = gpio_pin_interrupt_configure(this->gpio_, port_pin, interrupt_type_to_flags(type, this->inverted_)); + if (ret != 0) { + ESP_LOGE(TAG, "gpio_pin_interrupt_configure failed for pin %u: %d", this->pin_, ret); + gpio_remove_callback(this->gpio_, &this->interrupt_.callback); + return; + } + + ESP_LOGD(TAG, "Interrupt attached to pin %u (type=%d)", this->pin_, (int) type); } void ZephyrGPIOPin::setup() { @@ -88,15 +149,28 @@ void ZephyrGPIOPin::digital_write(bool value) { } gpio_pin_set(this->gpio_, this->pin_ % this->gpio_size_, value != this->inverted_ ? 1 : 0); } + void ZephyrGPIOPin::detach_interrupt() const { - // TODO + if (this->gpio_ == nullptr) { + return; + } + + uint8_t port_pin = this->pin_ % this->gpio_size_; + gpio_pin_interrupt_configure(this->gpio_, port_pin, GPIO_INT_DISABLE); + gpio_remove_callback(this->gpio_, &this->interrupt_.callback); + + this->interrupt_.func = nullptr; + this->interrupt_.arg = nullptr; } } // namespace zephyr bool IRAM_ATTR ISRInternalGPIOPin::digital_read() { - // TODO - return false; + auto *arg = (zephyr::ISRPinArg *) this->arg_; + if (arg == nullptr || arg->gpio == nullptr) { + return false; + } + return bool(gpio_pin_get(arg->gpio, arg->pin % arg->gpio_size) != arg->inverted); } } // namespace esphome diff --git a/esphome/components/zephyr/gpio.h b/esphome/components/zephyr/gpio.h index 907fbe9f9c..19d68cfb2b 100644 --- a/esphome/components/zephyr/gpio.h +++ b/esphome/components/zephyr/gpio.h @@ -3,8 +3,19 @@ #ifdef USE_ZEPHYR #include "esphome/core/hal.h" #include +#include namespace esphome::zephyr { +// Bundles the Zephyr gpio_callback together with the ESPHome ISR function and +// argument. Keeping them in one POD struct lets the static handler recover the +// owning data straight from the callback pointer via CONTAINER_OF, so no global +// pin->instance lookup table is needed. +struct ZephyrGPIOInterrupt { + struct gpio_callback callback; + void (*func)(void *){nullptr}; + void *arg{nullptr}; +}; + class ZephyrGPIOPin : public InternalGPIOPin { public: ZephyrGPIOPin(const device *gpio, int gpio_size, const char *pin_name_prefix) { @@ -36,6 +47,10 @@ class ZephyrGPIOPin : public InternalGPIOPin { uint8_t gpio_size_{}; bool inverted_{}; bool value_{false}; + + // attach_interrupt()/detach_interrupt() are const (matching the base class), so + // the interrupt state they manage has to be mutable. + mutable ZephyrGPIOInterrupt interrupt_{}; }; } // namespace esphome::zephyr diff --git a/tests/components/gpio/test.nrf52-adafruit.yaml b/tests/components/gpio/test.nrf52-adafruit.yaml index fb3f368e03..d034736524 100644 --- a/tests/components/gpio/test.nrf52-adafruit.yaml +++ b/tests/components/gpio/test.nrf52-adafruit.yaml @@ -1,7 +1,31 @@ +# P0.2, P0.4 and P0.5 all live on the same Zephyr port device (gpio0) and each +# attaches its own interrupt. This locks in shared-port behavior: every pin owns +# a separate gpio_callback initialized with its own BIT(pin) mask, so Zephyr +# dispatches to each pin independently even though the port device is shared. binary_sensor: - platform: gpio pin: 2 id: gpio_binary_sensor + use_interrupt: true + interrupt_type: ANY + + # Inverted pin with an edge-specific interrupt: exercises the inversion-aware + # interrupt-arming path (logical RISING must arm on the physical falling edge). + - platform: gpio + pin: + number: P0.4 + inverted: true + id: gpio_binary_sensor_inverted + use_interrupt: true + interrupt_type: RISING + + # Second non-inverted interrupt on the same port (gpio0) as P0.2 above: verifies + # multiple pins sharing one port device each get their own callback/pin_mask. + - platform: gpio + pin: P0.5 + id: gpio_binary_sensor_shared_port + use_interrupt: true + interrupt_type: FALLING output: - platform: gpio From 711d8bb0ade3d32361e9e129cfaa99347b0a1675 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 08:33:00 -0400 Subject: [PATCH 0715/1815] Synchronise Device Classes from Home Assistant (#17372) Co-authored-by: esphomebot --- esphome/components/number/__init__.py | 2 ++ esphome/components/sensor/__init__.py | 2 ++ esphome/const.py | 1 + 3 files changed, 5 insertions(+) diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index ee2d53c65a..bcc609de65 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -59,6 +59,7 @@ from esphome.const import ( DEVICE_CLASS_PRECIPITATION, DEVICE_CLASS_PRECIPITATION_INTENSITY, DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_RADON, DEVICE_CLASS_REACTIVE_ENERGY, DEVICE_CLASS_REACTIVE_POWER, DEVICE_CLASS_SIGNAL_STRENGTH, @@ -131,6 +132,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_PRECIPITATION, DEVICE_CLASS_PRECIPITATION_INTENSITY, DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_RADON, DEVICE_CLASS_REACTIVE_ENERGY, DEVICE_CLASS_REACTIVE_POWER, DEVICE_CLASS_SIGNAL_STRENGTH, diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 5a2ebf03c0..da8a540d8d 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -87,6 +87,7 @@ from esphome.const import ( DEVICE_CLASS_PRECIPITATION, DEVICE_CLASS_PRECIPITATION_INTENSITY, DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_RADON, DEVICE_CLASS_REACTIVE_ENERGY, DEVICE_CLASS_REACTIVE_POWER, DEVICE_CLASS_SIGNAL_STRENGTH, @@ -166,6 +167,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_PRECIPITATION, DEVICE_CLASS_PRECIPITATION_INTENSITY, DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_RADON, DEVICE_CLASS_REACTIVE_ENERGY, DEVICE_CLASS_REACTIVE_POWER, DEVICE_CLASS_SIGNAL_STRENGTH, diff --git a/esphome/const.py b/esphome/const.py index 5fa6f00b59..331eb5011d 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1351,6 +1351,7 @@ DEVICE_CLASS_PRECIPITATION_INTENSITY = "precipitation_intensity" DEVICE_CLASS_PRESENCE = "presence" DEVICE_CLASS_PRESSURE = "pressure" DEVICE_CLASS_PROBLEM = "problem" +DEVICE_CLASS_RADON = "radon" DEVICE_CLASS_REACTIVE_ENERGY = "reactive_energy" DEVICE_CLASS_REACTIVE_POWER = "reactive_power" DEVICE_CLASS_RESTART = "restart" From c456fc98ab59f6fbbafecb53c41156c782aac545 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 09:31:21 -0400 Subject: [PATCH 0716/1815] Bump bundled esphome-device-builder to 1.0.27 (#17370) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 7d56b04041..9dec23db1b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.26 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.27 RUN \ platformio settings set enable_telemetry No \ From ea14a93e67c7be20610920aeeb616f5bc12c5529 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Fri, 3 Jul 2026 15:33:42 +0200 Subject: [PATCH 0717/1815] [nrf52] fix crash report for native build (#17371) --- esphome/components/nrf52/__init__.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 184d41e0f3..7ce973a2a9 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -697,10 +697,22 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> addr2line = find_tool("addr2line") if addr2line is None: return False - elf = CORE.relative_pioenvs_path(CORE.name, "firmware.elf") - if not elf.exists(): - _LOGGER.warning("%s does not exists", elf) + + candidates = [ + CORE.relative_pioenvs_path(CORE.name, "zephyr", "zephyr", "zephyr.elf"), + CORE.relative_pioenvs_path(CORE.name, "zephyr", "zephyr.elf"), + CORE.relative_pioenvs_path(CORE.name, "firmware.elf"), + ] + + elf = next((path for path in candidates if path.exists()), None) + + if elf is None: + _LOGGER.warning( + "None of the expected ELF files exist:\n%s", + "\n".join(str(p) for p in candidates), + ) return False + _LOGGER.error("=== CRASH ===") _LOGGER.error("PC: %s", _addr2line(addr2line, elf, pc)) _LOGGER.error("LR: %s", _addr2line(addr2line, elf, lr)) From fd16eec416d29b17c9e85e7744a27fef3e1bd4fe Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Fri, 3 Jul 2026 18:16:55 +0200 Subject: [PATCH 0718/1815] [nrf52] switch nrf52 builds to native sdk by default (#17319) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: ESPHome Device Builder --- esphome/components/nrf52/__init__.py | 30 ++++++--- esphome/components/nrf52/framework.py | 22 ++++++ esphome/components/zephyr/__init__.py | 13 ++++ esphome/components/zephyr/const.py | 1 + .../components/zephyr_mcumgr/ota/__init__.py | 17 ++++- script/ci_memory_impact_extract.py | 67 +++++++++++++++---- tests/components/api/test.nrf52-adafruit.yaml | 4 +- .../components/nrf52/test.nrf52-adafruit.yaml | 2 - 8 files changed, 126 insertions(+), 30 deletions(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 7ce973a2a9..7c17eadd1a 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -117,7 +117,7 @@ def set_core_data(config: ConfigType) -> ConfigType: def _resolve_toolchain(config: ConfigType) -> ConfigType: if CORE.toolchain is None: - CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO) + CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.SDK_NRF) return config @@ -439,8 +439,8 @@ def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: types = [] UF2_PATH = "zephyr/zephyr.uf2" DFU_PATH = "firmware.zip" - HEX_PATH = "zephyr/zephyr.hex" - HEX_MERGED_PATH = "zephyr/merged.hex" + HEX_PATH = "zephyr/zephyr.hex" # SDK 2.6.1, only generated when OTA is disabled + HEX_MERGED_PATH = "zephyr/merged.hex" # SDK 2.9.2, always generated APP_IMAGE_PATH = "zephyr/app_update.bin" build_dir = Path(storage_json.firmware_bin_path).parent if (build_dir / UF2_PATH).is_file(): @@ -777,6 +777,11 @@ def _generate_cmake_lists() -> bool: ) +def _copy_if_exists(src: Path, dst: Path) -> None: + if src.is_file(): + shutil.copy2(src, dst) + + def run_compile(args, config: ConfigType) -> bool: if CORE.using_toolchain_platformio: return False @@ -828,15 +833,18 @@ def run_compile(args, config: ConfigType) -> bool: ): raise EsphomeError("nRF52 native build failed") - # Zephyr's cmake places kernel artifacts in build_dir/zephyr/zephyr/ and - # merged.hex at build_dir/. Normalize to build_dir/zephyr/ so paths match - # get_download_types (which mirrors the platformio build output layout). zephyr_dir = build_dir / "zephyr" - west_out = zephyr_dir / "zephyr" - for filename in ["zephyr.uf2"]: - src = west_out / filename - if src.is_file(): - shutil.copy2(src, zephyr_dir / filename) + framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] + # SDK < 2.9.2 places artifacts directly in build_dir/zephyr/. + # SDK >= 2.9.2 nests them one level deeper (build_dir/zephyr/zephyr/); + # copy files to match get_download_types layout. + if framework_ver < cv.Version(2, 9, 2): + west_out = zephyr_dir + else: + west_out = zephyr_dir / "zephyr" + _copy_if_exists(west_out / "zephyr.uf2", zephyr_dir / "zephyr.uf2") + _copy_if_exists(west_out / "zephyr.signed.bin", zephyr_dir / "app_update.bin") + _copy_if_exists(build_dir / "merged.hex", zephyr_dir / "merged.hex") # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes _GENPKG_PARAMS = { diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 7cb1164482..640aa07fbf 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -2,10 +2,12 @@ import logging import os from pathlib import Path import platform +import shutil import tempfile import platformdirs +import esphome.config_validation as cv from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, EsphomeError from esphome.framework_helpers import ( @@ -134,6 +136,23 @@ def get_build_env() -> dict: return env +def _patch_uf2conv_escape_sequences(framework_path: Path) -> None: + # SDK v2.6.1 ships uf2conv.py with '\s+' — an unrecognised escape that + # Python 3.12+ flags with SyntaxWarning (a future version will reject it). + uf2conv = framework_path / "zephyr" / "scripts" / "build" / "uf2conv.py" + if not uf2conv.exists(): + return + content = uf2conv.read_text(encoding="utf-8") + patched = content.replace("re.split('\\s+', line)", "re.split('\\\\s+', line)") + if patched == content: + return + # Write atomically so a concurrent build never sees a truncated file + tmp = uf2conv.with_suffix(".py.tmp") + tmp.write_text(patched, encoding="utf-8") + shutil.copymode(uf2conv, tmp) + tmp.replace(uf2conv) + + def check_and_install() -> None: version = _get_version_str() python_env_path = _get_python_env_path(version) @@ -195,6 +214,9 @@ def check_and_install() -> None: ] if not run_command_ok(cmd, cwd=framework_path): raise EsphomeError(f"Can't update nRF Connect SDK {version}") + framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] + if framework_ver < cv.Version(2, 9, 2): + _patch_uf2conv_escape_sequences(framework_path) sentinel.touch() zephyr_sentinel = python_env_path / ".zephyr_reqs_ready" diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index cd077a142f..d6c45a744c 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -18,6 +18,7 @@ from .const import ( KEY_OVERLAY, KEY_PM_STATIC, KEY_PRJ_CONF, + KEY_SYSBUILD, KEY_USER, KEY_ZEPHYR, zephyr_ns, @@ -55,6 +56,7 @@ class ZephyrData(TypedDict): pm_static: list[Section] user: dict[str, list[str]] kconfig: str + sysbuild: bool def zephyr_set_core_data(config: ConfigType) -> None: @@ -69,6 +71,10 @@ def zephyr_set_core_data(config: ConfigType) -> None: pm_static=[], user={}, kconfig="", + # When OTA is disabled, the image is built without a bootloader even if the + # config says `bootloader: mcuboot`, so the image can be smaller. This was + # the default behaviour in SDK 2.6.1. + sysbuild=False, ) @@ -286,6 +292,13 @@ def copy_files() -> None: CORE.relative_build_path("zephyr/Kconfig"), kconfig ) + sysbuild_conf = "" + if zephyr_data()[KEY_SYSBUILD]: + sysbuild_conf = "SB_CONFIG_BOOTLOADER_MCUBOOT=y\n" + changed |= _write_file_if_changed_or_remove_when_empty( + CORE.relative_build_path("zephyr/sysbuild.conf"), sysbuild_conf + ) + if changed: # A configure-time input changed; drop the CMake cache so the build # can't reuse stale configure results (the native sdk-nrf toolchain diff --git a/esphome/components/zephyr/const.py b/esphome/components/zephyr/const.py index f2de861e31..497e5f3ce5 100644 --- a/esphome/components/zephyr/const.py +++ b/esphome/components/zephyr/const.py @@ -13,6 +13,7 @@ KEY_PRJ_CONF: Final = "prj_conf" KEY_ZEPHYR = "zephyr" KEY_BOARD: Final = "board" KEY_USER: Final = "user" +KEY_SYSBUILD: Final = "sysbuild" zephyr_ns = cg.esphome_ns.namespace("zephyr") CdcAcm = zephyr_ns.class_("CdcAcm", cg.Component) diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index b0d86190b8..0ff1825bd1 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -6,9 +6,19 @@ from esphome.components.zephyr import ( zephyr_add_prj_conf, zephyr_data, ) -from esphome.components.zephyr.const import BOOTLOADER_MCUBOOT, KEY_BOOTLOADER +from esphome.components.zephyr.const import ( + BOOTLOADER_MCUBOOT, + KEY_BOOTLOADER, + KEY_SYSBUILD, +) import esphome.config_validation as cv -from esphome.const import CONF_HARDWARE_UART, CONF_ID, Framework +from esphome.const import ( + CONF_HARDWARE_UART, + CONF_ID, + KEY_CORE, + KEY_FRAMEWORK_VERSION, + Framework, +) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority from esphome.types import ConfigType @@ -139,3 +149,6 @@ async def to_code(config: ConfigType) -> None: }}; """ ) + framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] + if framework_ver >= cv.Version(2, 9, 2): + zephyr_data()[KEY_SYSBUILD] = True diff --git a/script/ci_memory_impact_extract.py b/script/ci_memory_impact_extract.py index feacc2b1af..20a737cdbf 100755 --- a/script/ci_memory_impact_extract.py +++ b/script/ci_memory_impact_extract.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """Extract memory usage statistics from ESPHome build output. -This script parses the PlatformIO build output to extract RAM and flash -usage statistics for a compiled component. It's used by the CI workflow to +This script parses the build output to extract RAM and flash usage +statistics for a compiled component. It's used by the CI workflow to compare memory usage between branches. The script reads compile output from stdin and looks for the standard @@ -10,6 +10,13 @@ PlatformIO output format: RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes) Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes) +or the linker memory usage table printed by Zephyr native builds +(e.g. nRF52 with the sdk-nrf toolchain): + Memory region Used Size Region Size %age Used + FLASH: 90624 B 796 KB 11.12% + RAM: 22432 B 256 KB 8.56% + IDT_LIST: 0 GB 32 KB 0.00% + Optionally performs detailed memory analysis if a build directory is provided. """ @@ -34,20 +41,43 @@ _RAM_PATTERN = re.compile(r"RAM:\s+\[.*?\]\s+\d+\.\d+%\s+\(used\s+(\d+)\s+bytes" _FLASH_PATTERN = re.compile(r"Flash:\s+\[.*?\]\s+\d+\.\d+%\s+\(used\s+(\d+)\s+bytes") _BUILD_PATH_PATTERN = re.compile(r"Build path: (.+)") +# Zephyr native builds print the GNU ld --print-memory-usage table instead of +# the PlatformIO summary. Only the FLASH and RAM regions are real memory +# (IDT_LIST is a build-time pseudo-region discarded from the final image). +# Each cell is humanized to the largest unit that divides evenly, so used +# sizes are not always plain bytes (zero prints as "0 GB"). +_ZEPHYR_RAM_PATTERN = re.compile( + r"^\s*RAM:\s+(\d+)\s*([KMG]?B)\s+\d+\s*[KMG]?B\s+\d+\.\d+%", re.MULTILINE +) +_ZEPHYR_FLASH_PATTERN = re.compile( + r"^\s*FLASH:\s+(\d+)\s*([KMG]?B)\s+\d+\s*[KMG]?B\s+\d+\.\d+%", re.MULTILINE +) +_ZEPHYR_UNIT_MULTIPLIERS = {"B": 1, "KB": 1024, "MB": 1024**2, "GB": 1024**3} + + +def _zephyr_bytes(matches: list[tuple[str, str]]) -> int: + """Sum humanized (value, unit) pairs from the Zephyr memory table.""" + return sum(int(value) * _ZEPHYR_UNIT_MULTIPLIERS[unit] for value, unit in matches) + def extract_from_compile_output( output_text: str, ) -> tuple[int | None, int | None, str | None]: - """Extract memory usage and build directory from PlatformIO compile output. + """Extract memory usage and build directory from compile output. Supports multiple builds (for component groups or isolated components). When test_build_components.py creates multiple builds, this sums the memory usage across all builds. - Looks for lines like: + Looks for PlatformIO lines like: RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes) Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes) + and Zephyr (native west build) linker table rows like: + Memory region Used Size Region Size %age Used + FLASH: 90624 B 796 KB 11.12% + RAM: 22432 B 256 KB 8.56% + Also extracts build directory from lines like: INFO Compiling app... Build path: /path/to/build @@ -61,12 +91,20 @@ def extract_from_compile_output( ram_matches = _RAM_PATTERN.findall(output_text) flash_matches = _FLASH_PATTERN.findall(output_text) - if not ram_matches or not flash_matches: + # Zephyr native builds print the linker memory table instead + zephyr_ram_matches = _ZEPHYR_RAM_PATTERN.findall(output_text) + zephyr_flash_matches = _ZEPHYR_FLASH_PATTERN.findall(output_text) + + if not (ram_matches or zephyr_ram_matches) or not ( + flash_matches or zephyr_flash_matches + ): return None, None, None # Sum all builds (handles multiple component groups) total_ram = sum(int(match) for match in ram_matches) total_flash = sum(int(match) for match in flash_matches) + total_ram += _zephyr_bytes(zephyr_ram_matches) + total_flash += _zephyr_bytes(zephyr_flash_matches) # Extract build directory from ESPHome's explicit build path output # Look for: INFO Compiling app... Build path: /path/to/build @@ -202,20 +240,23 @@ def main() -> int: ) if ram_bytes is None or flash_bytes is None: - print("Failed to extract memory usage from compile output", file=sys.stderr) - print("Expected lines like:", file=sys.stderr) print( - " RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes)", - file=sys.stderr, - ) - print( - " Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes)", + "Failed to extract memory usage from compile output\n" + "Expected lines like:\n" + " RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes)\n" + " Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes)\n" + "or a Zephyr linker memory usage table like:\n" + " Memory region Used Size Region Size %age Used\n" + " FLASH: 90624 B 796 KB 11.12%\n" + " RAM: 22432 B 256 KB 8.56%", file=sys.stderr, ) return 1 # Count how many builds were found - num_builds = len(_RAM_PATTERN.findall(compile_output)) + num_builds = len(_RAM_PATTERN.findall(compile_output)) + len( + _ZEPHYR_RAM_PATTERN.findall(compile_output) + ) if num_builds > 1: print( diff --git a/tests/components/api/test.nrf52-adafruit.yaml b/tests/components/api/test.nrf52-adafruit.yaml index 18bf23d710..347480bab6 100644 --- a/tests/components/api/test.nrf52-adafruit.yaml +++ b/tests/components/api/test.nrf52-adafruit.yaml @@ -1,7 +1,7 @@ +<<: !include common.yaml + network: enable_ipv6: true openthread: tlv: 0E080000000000010000 - -api: diff --git a/tests/components/nrf52/test.nrf52-adafruit.yaml b/tests/components/nrf52/test.nrf52-adafruit.yaml index 3ae48b2a5f..5fa0d6e88f 100644 --- a/tests/components/nrf52/test.nrf52-adafruit.yaml +++ b/tests/components/nrf52/test.nrf52-adafruit.yaml @@ -19,5 +19,3 @@ nrf52: reg0: voltage: 2.1V uicr_erase: true - framework: - version: "2.6.1-b" From 187cd51867387475431dcb17c87d3e7cd3da9e11 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:47:08 -0400 Subject: [PATCH 0719/1815] [ci] Carry native-toolchain needs on component test batches (#17359) --- .github/workflows/ci.yml | 14 ++++++++------ script/determine-jobs.py | 20 +++++++++++++++----- tests/script/test_determine_jobs.py | 18 ++++++++++-------- 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2016739c4f..9310b45b4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -795,7 +795,7 @@ jobs: if: always() test-build-components-split: - name: Test components batch (${{ matrix.components }}) + name: Test components batch (${{ matrix.batch.components }}) runs-on: ubuntu-24.04 needs: - common @@ -809,7 +809,7 @@ jobs: fail-fast: false max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 8 || 4 }} matrix: - components: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }} + batch: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }} steps: - name: Show disk space run: | @@ -817,7 +817,7 @@ jobs: df -h - name: List components - run: echo ${{ matrix.components }} + run: echo ${{ matrix.batch.components }} - name: Cache apt packages uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 @@ -833,8 +833,10 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache ESP-IDF install (restore-only) - # A batch may contain no esp32 build, so never save -- just reuse the - # shared install the dev tidy jobs already cached when present. + # Only batches whose test platforms include esp32 need the native + # ESP-IDF install; never save -- just reuse the shared install the + # dev tidy jobs already cached when present. + if: matrix.batch.needs_idf uses: ./.github/actions/cache-esp-idf with: restore-only: true @@ -868,7 +870,7 @@ jobs: fi # Convert space-separated components to comma-separated for Python script - components_csv=$(echo "${{ matrix.components }}" | tr ' ' ',') + components_csv=$(echo "${{ matrix.batch.components }}" | tr ' ' ',') # Only isolate directly changed components when targeting dev branch # For beta/release branches, group everything for faster CI diff --git a/script/determine-jobs.py b/script/determine-jobs.py index af3e83f96b..756f3884b8 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -1338,7 +1338,7 @@ def main() -> None: # Split components into batches for CI testing # This intelligently groups components with similar bus configurations - component_test_batches: list[str] + component_test_batches: list[dict[str, Any]] = [] if changed_components_with_tests: tests_dir = Path(root_path) / ESPHOME_TESTS_COMPONENTS_PATH @@ -1363,10 +1363,20 @@ def main() -> None: batch_size=COMPONENT_TEST_BATCH_SIZE, directly_changed=batch_directly_changed, ) - # Convert batches to space-separated strings for CI matrix - component_test_batches = [" ".join(batch) for batch in batches] - else: - component_test_batches = [] + # Convert batches to CI matrix entries: the component list plus which + # native toolchain installs the batch's test platforms need, so the + # workflow only restores the matching multi-GB toolchain caches. + for batch in batches: + platforms: set[str] = set() + for component in batch: + platforms.update(get_component_test_platforms(component)) + component_test_batches.append( + { + "components": " ".join(batch), + "needs_idf": any(p.startswith("esp32") for p in platforms), + "needs_nrf": any(p.startswith("nrf52") for p in platforms), + } + ) output: dict[str, Any] = { "core_ci": run_core_ci, diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index d4c13fd3fb..2f038155c0 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -231,14 +231,16 @@ def test_main_all_tests_should_run( assert output["memory_impact"]["should_run"] == "false" assert output["cpp_unit_tests_run_all"] is False assert output["cpp_unit_tests_components"] == ["wifi", "api", "sensor"] - # component_test_batches should be present and be a list of space-separated strings + # component_test_batches should be a list of matrix entries carrying the + # space-separated component list and the toolchain-need flags assert "component_test_batches" in output assert isinstance(output["component_test_batches"], list) - # Each batch should be a space-separated string of component names for batch in output["component_test_batches"]: - assert isinstance(batch, str) + assert isinstance(batch, dict) # Should contain at least one component (no empty batches) - assert len(batch) > 0 + assert len(batch["components"]) > 0 + assert isinstance(batch["needs_idf"], bool) + assert isinstance(batch["needs_nrf"], bool) def test_main_no_tests_should_run( @@ -2417,16 +2419,16 @@ def test_component_batching_beta_branch_40_per_batch( assert len(batches) == 3, f"Expected 3 batches, got {len(batches)}" # Each batch should have approximately 40 components (all weight=1, groupable) - for i, batch_str in enumerate(batches): - batch_components = batch_str.split() + for i, batch in enumerate(batches): + batch_components = batch["components"].split() assert len(batch_components) == 40, ( f"Batch {i} should have 40 components, got {len(batch_components)}" ) # Verify all 120 components are in batches all_components = [] - for batch_str in batches: - all_components.extend(batch_str.split()) + for batch in batches: + all_components.extend(batch["components"].split()) assert len(all_components) == 120 assert set(all_components) == set(component_names) From 7ad43358c2e6001656f3181ac6dec11609fcc51b Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Fri, 3 Jul 2026 20:28:29 +0200 Subject: [PATCH 0720/1815] [zigbee] Bump zigbee sdk to 2.0.2 (#16869) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/zigbee/__init__.py | 16 + esphome/components/zigbee/const.py | 15 +- esphome/components/zigbee/const_esp32.py | 30 +- .../zigbee/zigbee_attribute_esp32.cpp | 75 ++-- .../zigbee/zigbee_attribute_esp32.h | 7 +- esphome/components/zigbee/zigbee_ep_esp32.py | 12 +- esphome/components/zigbee/zigbee_esp32.cpp | 353 +++++++++--------- esphome/components/zigbee/zigbee_esp32.h | 57 +-- esphome/components/zigbee/zigbee_esp32.py | 36 +- .../components/zigbee/zigbee_helpers_esp32.c | 99 ++--- .../components/zigbee/zigbee_helpers_esp32.h | 13 +- esphome/components/zigbee/zigbee_zephyr.py | 2 +- esphome/idf_component.yml | 6 +- sdkconfig.defaults.esp32c6 | 1 - tests/components/zigbee/common_esp32.yaml | 2 +- 15 files changed, 358 insertions(+), 366 deletions(-) diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index c75b0773d2..444012bcd8 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -8,6 +8,9 @@ from esphome.components.esp32.const import ( VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, + VARIANT_ESP32S31, ) import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_INTERNAL, CONF_MODEL, CONF_NAME @@ -52,11 +55,21 @@ CODEOWNERS = ["@luar123", "@tomaszduda23"] CONFLICTS_WITH = ["openthread"] + +def _check_report_deprecation(value: str) -> str: + if str(value).lower() in ("coordinator", "enable"): + _LOGGER.warning( + "Report options 'coordinator' and 'enable' are deprecated and will be removed in a future release. Use 'default' instead." + ) + return value + + BASE_SCHEMA = cv.Schema( { cv.Optional(CONF_REPORT): cv.All( cv.requires_component("zigbee"), cv.requires_component("esp32"), + _check_report_deprecation, cv.enum(REPORT, lower=True), ) } @@ -111,7 +124,10 @@ CONFIG_SCHEMA = cv.All( cv.only_on_esp32, only_on_variant( supported=[ + VARIANT_ESP32S31, VARIANT_ESP32H2, + VARIANT_ESP32H21, + VARIANT_ESP32H4, VARIANT_ESP32C5, VARIANT_ESP32C6, ] diff --git a/esphome/components/zigbee/const.py b/esphome/components/zigbee/const.py index 7d0e14c67a..dd36f815ab 100644 --- a/esphome/components/zigbee/const.py +++ b/esphome/components/zigbee/const.py @@ -55,6 +55,7 @@ REPORT = { "coordinator": report.ZIGBEE_REPORT_COORDINATOR, "enable": report.ZIGBEE_REPORT_ENABLE, "force": report.ZIGBEE_REPORT_FORCE, + "default": report.ZIGBEE_REPORT_DEFAULT, } CONF_ON_JOIN = "on_join" @@ -63,13 +64,13 @@ CONF_REPORT = "report" CONF_ROUTER = "router" CONF_POWER_SOURCE = "power_source" POWER_SOURCE = { - "UNKNOWN": "ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN", - "MAINS_SINGLE_PHASE": "ZB_ZCL_BASIC_POWER_SOURCE_MAINS_SINGLE_PHASE", - "MAINS_THREE_PHASE": "ZB_ZCL_BASIC_POWER_SOURCE_MAINS_THREE_PHASE", - "BATTERY": "ZB_ZCL_BASIC_POWER_SOURCE_BATTERY", - "DC_SOURCE": "ZB_ZCL_BASIC_POWER_SOURCE_DC_SOURCE", - "EMERGENCY_MAINS_CONST": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_CONST", - "EMERGENCY_MAINS_TRANSF": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_TRANSF", + "UNKNOWN": 0x00, # ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN + "MAINS_SINGLE_PHASE": 0x01, # ZB_ZCL_BASIC_POWER_SOURCE_MAINS_SINGLE_PHASE + "MAINS_THREE_PHASE": 0x02, # ZB_ZCL_BASIC_POWER_SOURCE_MAINS_THREE_PHASE + "BATTERY": 0x03, # ZB_ZCL_BASIC_POWER_SOURCE_BATTERY + "DC_SOURCE": 0x04, # ZB_ZCL_BASIC_POWER_SOURCE_DC_SOURCE + "EMERGENCY_MAINS_CONST": 0x05, # ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_CONST + "EMERGENCY_MAINS_TRANSF": 0x06, # ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_TRANSF } KEY_ZIGBEE = "zigbee" diff --git a/esphome/components/zigbee/const_esp32.py b/esphome/components/zigbee/const_esp32.py index bb507320eb..81a8fc52cd 100644 --- a/esphome/components/zigbee/const_esp32.py +++ b/esphome/components/zigbee/const_esp32.py @@ -13,27 +13,25 @@ CONF_ATTRIBUTE_ID = "attribute_id" KEY_BS_EP = "binary_sensor_ep" KEY_SENSOR_EP = "sensor_ep" -ha_standard_devices = cg.esphome_ns.enum("zb_ha_standard_devs_e") DEVICE_ID = { - "RANGE_EXTENDER": ha_standard_devices.ZB_HA_RANGE_EXTENDER_DEVICE_ID, - "SIMPLE_SENSOR": ha_standard_devices.ZB_HA_SIMPLE_SENSOR_DEVICE_ID, - "CUSTOM_ATTR": ha_standard_devices.ZB_HA_CUSTOM_ATTR_DEVICE_ID, + "RANGE_EXTENDER": cg.RawExpression("EZB_ZHA_RANGE_EXTENDER_DEVICE_ID"), + "SIMPLE_SENSOR": cg.RawExpression("EZB_ZHA_SIMPLE_SENSOR_DEVICE_ID"), + "CUSTOM_ATTR": 0xFFF2, } -cluster_id = cg.esphome_ns.enum("esp_zb_zcl_cluster_id_t") +cluster_id = cg.esphome_ns.enum("ezb_zcl_cluster_id_e") CLUSTER_ID = { - "BASIC": cluster_id.ESP_ZB_ZCL_CLUSTER_ID_BASIC, - "BINARY_INPUT": cluster_id.ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT, - "ANALOG_INPUT": cluster_id.ESP_ZB_ZCL_CLUSTER_ID_ANALOG_INPUT, + "BASIC": cluster_id.EZB_ZCL_CLUSTER_ID_BASIC, + "BINARY_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_BINARY_INPUT, + "ANALOG_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_ANALOG_INPUT, } -cluster_role = cg.esphome_ns.enum("esp_zb_zcl_cluster_role_t") CLUSTER_ROLE = { - "SERVER": cluster_role.ESP_ZB_ZCL_CLUSTER_SERVER_ROLE, + "SERVER": cg.RawExpression("EZB_ZCL_CLUSTER_SERVER"), } -attr_type = cg.esphome_ns.enum("esp_zb_zcl_attr_type_t") +attr_type = cg.esphome_ns.enum("ezb_zcl_attr_type_e") ATTR_TYPE = { - "BOOL": attr_type.ESP_ZB_ZCL_ATTR_TYPE_BOOL, - "8BITMAP": attr_type.ESP_ZB_ZCL_ATTR_TYPE_8BITMAP, - "CHAR_STRING": attr_type.ESP_ZB_ZCL_ATTR_TYPE_CHAR_STRING, - "SINGLE": attr_type.ESP_ZB_ZCL_ATTR_TYPE_SINGLE, - "DOUBLE": attr_type.ESP_ZB_ZCL_ATTR_TYPE_DOUBLE, + "BOOL": attr_type.EZB_ZCL_ATTR_TYPE_BOOL, + "MAP8": attr_type.EZB_ZCL_ATTR_TYPE_MAP8, + "STRING": attr_type.EZB_ZCL_ATTR_TYPE_STRING, + "SINGLE": attr_type.EZB_ZCL_ATTR_TYPE_SINGLE, + "DOUBLE": attr_type.EZB_ZCL_ATTR_TYPE_DOUBLE, } diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.cpp b/esphome/components/zigbee/zigbee_attribute_esp32.cpp index 0a06792c59..c6f2aa0af6 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.cpp +++ b/esphome/components/zigbee/zigbee_attribute_esp32.cpp @@ -12,63 +12,68 @@ void ZigbeeAttribute::set_attr_() { if (!this->zb_->is_connected()) { return; } - if (esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) { - esp_zb_zcl_status_t state = esp_zb_zcl_set_attribute_val(this->endpoint_id_, this->cluster_id_, this->role_, - this->attr_id_, this->value_p_, false); + if (esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { + ezb_zcl_status_t state = ezb_zcl_set_attr_value(this->endpoint_id_, this->cluster_id_, this->role_, this->attr_id_, + EZB_ZCL_STD_MANUF_CODE, this->value_p_, false); if (this->force_report_) { this->report_(true); } this->set_attr_requested_ = false; // Check for error - if (state != ESP_ZB_ZCL_STATUS_SUCCESS) { + if (state != EZB_ZCL_STATUS_SUCCESS) { ESP_LOGE(TAG, "Setting attribute failed, ZCL status: %u", static_cast(state)); } - esp_zb_lock_release(); + esp_zigbee_lock_release(); } } void ZigbeeAttribute::report_(bool has_lock) { - if (!this->zb_->is_connected()) { + if (!this->zb_->is_connected() || !this->report_enabled) { return; } - if (has_lock or esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) { - esp_zb_zcl_report_attr_cmd_t cmd = {}; - cmd.address_mode = ESP_ZB_APS_ADDR_MODE_16_ENDP_PRESENT; - cmd.direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_CLI; - cmd.zcl_basic_cmd.dst_addr_u.addr_short = 0x0000; - cmd.zcl_basic_cmd.dst_endpoint = 1; - cmd.zcl_basic_cmd.src_endpoint = this->endpoint_id_; - cmd.clusterID = this->cluster_id_; - cmd.attributeID = this->attr_id_; + if (has_lock or esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { + ezb_zcl_report_attr_cmd_t cmd = {}; + cmd.cmd_ctrl.fc.direction = EZB_ZCL_CMD_DIRECTION_TO_CLI; + cmd.cmd_ctrl.fc.dis_default_rsp = 1; + cmd.cmd_ctrl.dst_addr.addr_mode = EZB_ADDR_MODE_SHORT; + cmd.cmd_ctrl.dst_addr.u.short_addr = 0x0000; + cmd.cmd_ctrl.dst_ep = 1; + cmd.cmd_ctrl.src_ep = this->endpoint_id_; + cmd.cmd_ctrl.cluster_id = this->cluster_id_; + cmd.cmd_ctrl.fc.manuf_specific = 0; + cmd.payload.attr_id = this->attr_id_; - esp_zb_zcl_report_attr_cmd_req(&cmd); + ezb_zcl_report_attr_cmd_req(&cmd); if (!has_lock) { - esp_zb_lock_release(); + esp_zigbee_lock_release(); } } } -esp_zb_zcl_reporting_info_t ZigbeeAttribute::get_reporting_info() { - esp_zb_zcl_reporting_info_t reporting_info = {}; - reporting_info.direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_SRV; - reporting_info.ep = this->endpoint_id_; - reporting_info.cluster_id = this->cluster_id_; - reporting_info.cluster_role = this->role_; - reporting_info.attr_id = this->attr_id_; - reporting_info.manuf_code = ESP_ZB_ZCL_ATTR_NON_MANUFACTURER_SPECIFIC; - reporting_info.dst.profile_id = ESP_ZB_AF_HA_PROFILE_ID; - reporting_info.u.send_info.min_interval = 10; /*!< Actual minimum reporting interval */ - reporting_info.u.send_info.max_interval = 0; /*!< Actual maximum reporting interval */ - reporting_info.u.send_info.def_min_interval = 10; /*!< Default minimum reporting interval */ - reporting_info.u.send_info.def_max_interval = 0; /*!< Default maximum reporting interval */ - reporting_info.u.send_info.delta.s16 = 0; /*!< Actual reportable change */ - - return reporting_info; +void ZigbeeAttribute::setup_reporting() { + ezb_zcl_reporting_info_t reporting_info = ezb_zcl_reporting_info_find( + this->endpoint_id_, this->cluster_id_, this->role_, this->attr_id_, EZB_ZCL_STD_MANUF_CODE); + if (reporting_info == EZB_ZCL_INVALID_REPORTING_INFO) { + ESP_LOGD(TAG, "Could not find reporting info for attribute 0x%04X in cluster 0x%04X in endpoint %u", this->attr_id_, + this->cluster_id_, this->endpoint_id_); + this->report_enabled = false; + this->force_report_ = false; + } else { + ESP_LOGD(TAG, "Found reporting info for attr 0x%04X in cluster 0x%04X", this->attr_id_, this->cluster_id_); + ezb_zcl_attr_variable_t delta = {.u64 = 0}; + ezb_zcl_reporting_info_update_default_interval(reporting_info, 0, 65000); + ezb_zcl_reporting_info_update(reporting_info, 0, 65000, &delta); + if (ezb_zcl_reporting_start_attr_report(reporting_info) != EZB_ERR_NONE) { + ESP_LOGE(TAG, "Could not start reporting for attribute"); + } + } } -void ZigbeeAttribute::set_report(bool force) { +void ZigbeeAttribute::set_report(ZigbeeReportT report) { this->report_enabled = true; - this->force_report_ = force; + if (report == ZigbeeReportT::ZIGBEE_REPORT_FORCE) { + this->force_report_ = true; + } } void ZigbeeAttribute::loop() { diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.h b/esphome/components/zigbee/zigbee_attribute_esp32.h index e978fcf209..b5afb57910 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.h +++ b/esphome/components/zigbee/zigbee_attribute_esp32.h @@ -9,7 +9,7 @@ #ifdef USE_ESP32 #ifdef USE_ZIGBEE -#include "esp_zigbee_core.h" +#include "esp_zigbee.h" #include "zigbee_esp32.h" #ifdef USE_SENSOR @@ -22,6 +22,7 @@ namespace esphome::zigbee { enum ZigbeeReportT { + ZIGBEE_REPORT_DEFAULT, ZIGBEE_REPORT_COORDINATOR, ZIGBEE_REPORT_ENABLE, ZIGBEE_REPORT_FORCE, @@ -41,10 +42,10 @@ class ZigbeeAttribute final : public Component { scale_(scale) {} void loop() override; template void add_attr(T value); - esp_zb_zcl_reporting_info_t get_reporting_info(); + void setup_reporting(); template void set_attr(const T &value); uint8_t attr_type() { return attr_type_; } - void set_report(bool force); + void set_report(ZigbeeReportT report); #ifdef USE_SENSOR template void connect(sensor::Sensor *sensor); #endif diff --git a/esphome/components/zigbee/zigbee_ep_esp32.py b/esphome/components/zigbee/zigbee_ep_esp32.py index 5dd76e9903..f4efa7bf4e 100644 --- a/esphome/components/zigbee/zigbee_ep_esp32.py +++ b/esphome/components/zigbee/zigbee_ep_esp32.py @@ -27,7 +27,7 @@ ep_configs: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x55, CONF_TYPE: "BOOL", - CONF_REPORT: REPORT["enable"], + CONF_REPORT: REPORT["default"], CONF_DEVICE: None, }, { @@ -36,11 +36,11 @@ ep_configs: dict[str, dict[str, Any]] = { }, { CONF_ATTRIBUTE_ID: 0x6F, - CONF_TYPE: "8BITMAP", + CONF_TYPE: "MAP8", }, { CONF_ATTRIBUTE_ID: 0x1C, - CONF_TYPE: "CHAR_STRING", + CONF_TYPE: "STRING", }, ], }, @@ -56,7 +56,7 @@ ep_configs: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x55, CONF_TYPE: "SINGLE", - CONF_REPORT: REPORT["enable"], + CONF_REPORT: REPORT["default"], CONF_DEVICE: None, }, { @@ -65,11 +65,11 @@ ep_configs: dict[str, dict[str, Any]] = { }, { CONF_ATTRIBUTE_ID: 0x6F, - CONF_TYPE: "8BITMAP", + CONF_TYPE: "MAP8", }, { CONF_ATTRIBUTE_ID: 0x1C, - CONF_TYPE: "CHAR_STRING", + CONF_TYPE: "STRING", }, ], }, diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 1809f181be..03457312be 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -36,121 +36,143 @@ uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size) { return zcl_str; } -static void bdb_start_top_level_commissioning_cb(uint8_t mode_mask) { - if (esp_zb_bdb_start_top_level_commissioning(mode_mask) != ESP_OK) { - ESP_LOGE(TAG, "Start network steering failed!"); +void ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_t mode) { + if (!esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { + global_zigbee->set_timeout("zb_init", 10, [mode]() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(mode); }); + return; } + if (ezb_bdb_start_top_level_commissioning(mode) != EZB_ERR_NONE) { + ESP_LOGE(TAG, "Start top level commissioning failed!"); + } + esp_zigbee_lock_release(); } -extern "C" void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct) { +bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { static uint8_t steering_retry_count = 0; - uint32_t *p_sg_p = signal_struct->p_app_signal; - esp_err_t err_status = signal_struct->esp_err_status; - esp_zb_app_signal_type_t sig_type = (esp_zb_app_signal_type_t) *p_sg_p; - esp_zb_zdo_signal_leave_params_t *leave_params = NULL; - switch (sig_type) { - case ESP_ZB_ZDO_SIGNAL_SKIP_STARTUP: + ezb_app_signal_type_t signal_type = ezb_app_signal_get_type(app_signal); + switch (signal_type) { + case EZB_ZDO_SIGNAL_SKIP_STARTUP: ESP_LOGD(TAG, "Zigbee stack initialized"); - esp_zb_bdb_start_top_level_commissioning(ESP_ZB_BDB_MODE_INITIALIZATION); + if (ezb_bdb_is_factory_new()) { + global_zigbee->defer([]() { global_zigbee->setup_reporting(); }); + } else { + ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_INITIALIZATION); + } break; - case ESP_ZB_BDB_SIGNAL_DEVICE_FIRST_START: - case ESP_ZB_BDB_SIGNAL_DEVICE_REBOOT: - if (err_status == ESP_OK) { - ESP_LOGD(TAG, "Device started up in %sfactory-reset mode", esp_zb_bdb_is_factory_new() ? "" : "non "); + case EZB_BDB_SIGNAL_DEVICE_FIRST_START: + case EZB_BDB_SIGNAL_DEVICE_REBOOT: { + ezb_bdb_comm_status_t status = *((ezb_bdb_comm_status_t *) ezb_app_signal_get_params(app_signal)); + if (status == EZB_BDB_STATUS_SUCCESS) { + ESP_LOGD(TAG, "Device started up in %sfactory-reset mode", ezb_bdb_is_factory_new() ? "" : "non "); global_zigbee->started = true; - if (esp_zb_bdb_is_factory_new()) { + if (ezb_bdb_is_factory_new()) { global_zigbee->factory_new = true; ESP_LOGD(TAG, "Start network steering"); - esp_zb_bdb_start_top_level_commissioning(ESP_ZB_BDB_MODE_NETWORK_STEERING); + ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_NETWORK_STEERING); } else { ESP_LOGD(TAG, "Device rebooted"); global_zigbee->joined = true; global_zigbee->enable_loop_soon_any_context(); } } else { - ESP_LOGE(TAG, "FIRST_START. Device started up in %sfactory-reset mode with an error %d (%s)", - esp_zb_bdb_is_factory_new() ? "" : "non ", err_status, esp_err_to_name(err_status)); - ESP_LOGW(TAG, "Failed to initialize Zigbee stack (status: %s)", esp_err_to_name(err_status)); - esp_zb_scheduler_alarm((esp_zb_callback_t) bdb_start_top_level_commissioning_cb, ESP_ZB_BDB_MODE_INITIALIZATION, - 1000); + ESP_LOGW(TAG, "The %s failed with status(0x%02x), please retry", ezb_app_signal_to_string(signal_type), status); + global_zigbee->set_timeout("zb_init", 1000, []() { + ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_INITIALIZATION); + }); } - break; - case ESP_ZB_BDB_SIGNAL_STEERING: - if (err_status == ESP_OK) { + } break; + case EZB_BDB_SIGNAL_STEERING: { + ezb_bdb_comm_status_t status = *((ezb_bdb_comm_status_t *) ezb_app_signal_get_params(app_signal)); + if (status == EZB_BDB_STATUS_SUCCESS) { steering_retry_count = 0; - ESP_LOGI(TAG, "Joined network successfully (PAN ID: 0x%04hx, Channel:%d)", esp_zb_get_pan_id(), - esp_zb_get_current_channel()); + ezb_extpanid_t extended_pan_id; + ezb_nwk_get_extended_panid(&extended_pan_id); + ESP_LOGD(TAG, "Joined network successfully: PAN ID(0x%04hx, EXT: 0x%llx), Channel(%d), Short Address(0x%04hx)", + ezb_nwk_get_panid(), extended_pan_id.u64, ezb_nwk_get_current_channel(), ezb_nwk_get_short_address()); global_zigbee->joined = true; global_zigbee->enable_loop_soon_any_context(); } else { - ESP_LOGI(TAG, "Network steering was not successful (status: %s)", esp_err_to_name(err_status)); + ESP_LOGD(TAG, "Failed to join network with status(0x%02x)", status); if (steering_retry_count < 10) { steering_retry_count++; - esp_zb_scheduler_alarm((esp_zb_callback_t) bdb_start_top_level_commissioning_cb, - ESP_ZB_BDB_MODE_NETWORK_STEERING, 1000); + global_zigbee->set_timeout("zb_init", 1000, []() { + ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_NETWORK_STEERING); + }); } else { - esp_zb_scheduler_alarm((esp_zb_callback_t) bdb_start_top_level_commissioning_cb, - ESP_ZB_BDB_MODE_NETWORK_STEERING, 600 * 1000); + global_zigbee->set_timeout("zb_init", 600 * 1000, []() { + ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_NETWORK_STEERING); + }); } } - break; - case ESP_ZB_ZDO_SIGNAL_LEAVE: - leave_params = (esp_zb_zdo_signal_leave_params_t *) esp_zb_app_signal_get_params(p_sg_p); - if (leave_params->leave_type == ESP_ZB_NWK_LEAVE_TYPE_RESET) { - esp_zb_factory_reset(); + } break; + case EZB_ZDO_SIGNAL_LEAVE: { + const ezb_zdo_signal_leave_params_t *leave_params = + (const ezb_zdo_signal_leave_params_t *) ezb_app_signal_get_params(app_signal); + if (leave_params->leave_type == EZB_ZDO_LEAVE_TYPE_RESET) { + esp_zigbee_factory_reset(); } - break; + } break; default: - ESP_LOGD(TAG, "ZDO signal: %s (0x%x), status: %s", esp_zb_zdo_signal_to_string(sig_type), sig_type, - esp_err_to_name(err_status)); + ESP_LOGD(TAG, "Zigbee APP Signal: %s(type: 0x%02x)", ezb_app_signal_to_string(signal_type), signal_type); break; } + return true; } -static esp_err_t zb_attribute_handler(const esp_zb_zcl_set_attr_value_message_t *message) { - esp_err_t ret = ESP_OK; - ESP_RETURN_ON_FALSE(message, ESP_FAIL, TAG, "Empty message"); - ESP_RETURN_ON_FALSE(message->info.status == ESP_ZB_ZCL_STATUS_SUCCESS, ESP_ERR_INVALID_ARG, TAG, - "Received message: error status(%d)", message->info.status); - ESP_LOGD(TAG, "Received message: endpoint(%d), cluster(0x%x), attribute(0x%x), data size(%d)", - message->info.dst_endpoint, message->info.cluster, message->attribute.id, message->attribute.data.size); - return ret; +static void zb_attribute_handler(ezb_zcl_set_attr_value_message_t *message) { + ESP_RETURN_ON_FALSE(message, , TAG, "Empty message"); + ESP_RETURN_ON_FALSE(message->info.status == EZB_ZCL_STATUS_SUCCESS, , TAG, "Received message: error status(%d)", + message->info.status); + ESP_LOGD(TAG, "ZCL SetAttributeValue message for endpoint(%d) cluster(0x%04x) %s with status(0x%02x)", + message->info.dst_ep, message->info.cluster_id, + message->info.cluster_role == EZB_ZCL_CLUSTER_SERVER ? "server" : "client", message->info.status); } -static esp_err_t zb_action_handler(esp_zb_core_action_callback_id_t callback_id, const void *message) { - esp_err_t ret = ESP_OK; +static void zb_action_handler(ezb_zcl_core_action_callback_id_t callback_id, void *message) { switch (callback_id) { - case ESP_ZB_CORE_SET_ATTR_VALUE_CB_ID: - ret = zb_attribute_handler((esp_zb_zcl_set_attr_value_message_t *) message); + case EZB_ZCL_CORE_SET_ATTR_VALUE_CB_ID: + zb_attribute_handler((ezb_zcl_set_attr_value_message_t *) message); break; +#ifdef ESPHOME_LOG_HAS_VERBOSE + case EZB_ZCL_CORE_DEFAULT_RSP_CB_ID: { + ezb_zcl_cmd_default_rsp_message_t *default_rsp = (ezb_zcl_cmd_default_rsp_message_t *) message; + ESP_LOGV(TAG, "Received ZCL Default Response: 0x%02x", default_rsp->in.status_code); + } break; +#endif default: - ESP_LOGD(TAG, "Receive Zigbee action(0x%x) callback", callback_id); + ESP_LOGD(TAG, "Receive Zigbee action(0x%04x) callback", static_cast(callback_id)); break; } - return ret; } -void ZigbeeComponent::create_default_cluster(uint8_t endpoint_id, zb_ha_standard_devs_e device_id) { - esp_zb_cluster_list_t *cluster_list = esp_zb_zcl_cluster_list_create(); - this->endpoint_list_[endpoint_id] = - std::tuple(device_id, cluster_list); - // Add basic cluster - this->add_cluster(endpoint_id, ESP_ZB_ZCL_CLUSTER_ID_BASIC, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE); - // Add identify cluster if not already present - if (esp_zb_cluster_list_get_cluster(cluster_list, ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE) == - nullptr) { - this->add_cluster(endpoint_id, ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE); +void ZigbeeComponent::create_default_cluster(uint8_t endpoint_id, uint16_t device_id) { + ezb_af_ep_config_t config = { + .ep_id = endpoint_id, + .app_profile_id = EZB_AF_HA_PROFILE_ID, + .app_device_id = device_id, + .app_device_version = 0, + }; + ezb_af_ep_desc_t ep_desc = ezb_af_create_endpoint_desc(&config); + if (ezb_af_device_add_endpoint_desc(this->dev_desc_, ep_desc) != EZB_ERR_NONE) { + ESP_LOGE(TAG, "Could not create endpoint %u", endpoint_id); } + // Add basic cluster + this->update_basic_cluster_(ep_desc); + // Add identify cluster if not already present + this->add_cluster(endpoint_id, EZB_ZCL_CLUSTER_ID_IDENTIFY, EZB_ZCL_CLUSTER_SERVER); } void ZigbeeComponent::add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role) { - esp_zb_attribute_list_t *attr_list; - if (cluster_id == 0) { - attr_list = create_basic_cluster_(); - } else { - attr_list = esphome_zb_default_attr_list_create(cluster_id); + if (cluster_id == EZB_ZCL_CLUSTER_ID_BASIC) { + return; } - this->attribute_list_[{endpoint_id, cluster_id, role}] = attr_list; + ezb_af_ep_desc_t ep_desc = ezb_af_device_get_endpoint_desc(this->dev_desc_, endpoint_id); + if (ep_desc == NULL) { + ESP_LOGE(TAG, "Endpoint %u does not exist, cannot add cluster 0x%04X", endpoint_id, cluster_id); + return; + } + esphome_zb_add_or_update_cluster(cluster_id, ep_desc, role); + ESP_LOGD(TAG, "Endpoint %u: Added cluster 0x%04X with role %u", endpoint_id, cluster_id, role); } void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufacturer, uint8_t power_source) { @@ -166,131 +188,117 @@ void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufactu }; } -esp_zb_attribute_list_t *ZigbeeComponent::create_basic_cluster_() { - esp_zb_basic_cluster_cfg_t basic_cluster_cfg = { - .zcl_version = ESP_ZB_ZCL_BASIC_ZCL_VERSION_DEFAULT_VALUE, - .power_source = this->basic_cluster_data_.power_source, - }; - esp_zb_attribute_list_t *attr_list = esp_zb_basic_cluster_create(&basic_cluster_cfg); - esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_MANUFACTURER_NAME_ID, - this->basic_cluster_data_.manufacturer); - esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_MODEL_IDENTIFIER_ID, this->basic_cluster_data_.model); - esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_DATE_CODE_ID, this->basic_cluster_data_.date); - return attr_list; +void ZigbeeComponent::update_basic_cluster_(ezb_af_ep_desc_t ep_desc) { + ezb_zcl_cluster_desc_t cluster_desc = + ezb_af_endpoint_get_cluster_desc(ep_desc, EZB_ZCL_CLUSTER_ID_BASIC, EZB_ZCL_CLUSTER_SERVER); + if (cluster_desc == NULL) { + ezb_zcl_basic_cluster_config_t basic_cluster_cfg = { + .zcl_version = EZB_ZCL_BASIC_ZCL_VERSION_DEFAULT_VALUE, + .power_source = this->basic_cluster_data_.power_source, + }; + cluster_desc = ezb_zcl_basic_create_cluster_desc(&basic_cluster_cfg, EZB_ZCL_CLUSTER_SERVER); + } + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_MANUFACTURER_NAME_ID, + this->basic_cluster_data_.manufacturer); + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_MODEL_IDENTIFIER_ID, + this->basic_cluster_data_.model); + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_DATE_CODE_ID, this->basic_cluster_data_.date); + ezb_af_endpoint_add_cluster_desc(ep_desc, cluster_desc); } -esp_err_t ZigbeeComponent::create_endpoint(uint8_t endpoint_id, zb_ha_standard_devs_e device_id, - esp_zb_cluster_list_t *esp_zb_cluster_list) { - esp_zb_endpoint_config_t endpoint_config = {.endpoint = endpoint_id, - .app_profile_id = ESP_ZB_AF_HA_PROFILE_ID, - .app_device_id = static_cast(device_id), - .app_device_version = 0}; - return esp_zb_ep_list_add_ep(this->esp_zb_ep_list_, esp_zb_cluster_list, endpoint_config); +void ZigbeeComponent::setup_reporting() { + ESP_LOGD(TAG, "Setting up reporting for all attributes"); + esp_zigbee_lock_acquire(portMAX_DELAY); + for (auto &[_, attribute] : this->attributes_) { + attribute->setup_reporting(); + } + ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_INITIALIZATION); + esp_zigbee_lock_release(); } -static void esp_zb_task(void *pv_parameters) { - if (esp_zb_start(false) != ESP_OK) { +static void ezb_task(void *pv_parameters) { + if (esp_zigbee_start(false) != ESP_OK) { ESP_LOGE(TAG, "Could not setup Zigbee"); vTaskDelete(NULL); } - if (global_zigbee->is_battery_powered()) { - ESP_LOGD(TAG, "Battery powered!"); - esp_zb_set_node_descriptor_power_source(false); - } else { - esp_zb_set_node_descriptor_power_source(true); + esp_zigbee_launch_mainloop(); + + esp_zigbee_deinit(); + + vTaskDelete(NULL); +} + +ZigbeeComponent::ZigbeeComponent() { + esp_zigbee_platform_config_t platform_config = { + .storage_partition_name = "nvs", + .radio_config = EZB_DEFAULT_RADIO_CONFIG(), + }; + esp_zigbee_device_config_t device_config = { + .device_type = this->device_role_, + .install_code_policy = false, + }; +#ifdef CONFIG_ZB_ZCZR + esp_zigbee_zczr_config_s zb_zczr_cfg = { + .max_children = MAX_CHILDREN, + }; + device_config.zczr_config = zb_zczr_cfg; +#else + esp_zigbee_zed_config_s zb_zed_cfg = { + .ed_timeout = EZB_NWK_ED_TIMEOUT_64MIN, + .keep_alive = ED_KEEP_ALIVE, + }; + device_config.zed_config = zb_zed_cfg; +#endif + esp_zigbee_config_t config = {.device_config = device_config, .platform_config = platform_config}; + if (esp_zigbee_init(&config) != ESP_OK) { + ESP_LOGE(TAG, "Could not initialize Zigbee"); + this->mark_failed(); + return; } - esp_zb_stack_main_loop(); + this->dev_desc_ = ezb_af_create_device_desc(); } void ZigbeeComponent::setup() { global_zigbee = this; - esp_zb_platform_config_t config = {}; - config.radio_config = ESP_ZB_DEFAULT_RADIO_CONFIG(); - config.host_config = ESP_ZB_DEFAULT_HOST_CONFIG(); #ifdef USE_WIFI if (esp_coex_wifi_i154_enable() != ESP_OK) { this->mark_failed(); return; } #endif - if (esp_zb_platform_config(&config) != ESP_OK) { + ezb_aps_secur_enable_distributed_security(false); + ezb_nwk_set_min_join_lqi(32); + if (ezb_app_signal_add_handler(ZigbeeComponent::app_signal_handler) != ESP_OK) { + ESP_LOGE(TAG, "Could not set application signal handler"); this->mark_failed(); return; } - esp_zb_cfg_t zb_nwk_cfg = { - .esp_zb_role = this->device_role_, - .install_code_policy = false, - }; -#ifdef ZB_ROUTER_ROLE - esp_zb_zczr_cfg_t zb_zczr_cfg = { - .max_children = MAX_CHILDREN, - }; - zb_nwk_cfg.nwk_cfg.zczr_cfg = zb_zczr_cfg; -#else - esp_zb_zed_cfg_t zb_zed_cfg = { - .ed_timeout = ESP_ZB_ED_AGING_TIMEOUT_64MIN, - .keep_alive = ED_KEEP_ALIVE, - }; - zb_nwk_cfg.nwk_cfg.zed_cfg = zb_zed_cfg; -#endif - esp_zb_init(&zb_nwk_cfg); - - esp_err_t ret; - for (auto const &[key, val] : this->attribute_list_) { - esp_zb_cluster_list_t *esp_zb_cluster_list = std::get<1>(this->endpoint_list_[std::get<0>(key)]); - ret = esphome_zb_cluster_list_add_or_update_cluster(std::get<1>(key), esp_zb_cluster_list, val, std::get<2>(key)); - if (ret != ESP_OK) { - ESP_LOGE(TAG, "Could not create cluster 0x%04X with role %u: %s", std::get<1>(key), std::get<2>(key), - esp_err_to_name(ret)); - } else { - ESP_LOGD(TAG, "Endpoint %u: Added cluster 0x%04X with role %u", std::get<0>(key), std::get<1>(key), - std::get<2>(key)); -#ifdef ESPHOME_LOG_HAS_VERBOSE - // Dump cluster attributes in verbose log - ESP_LOGV(TAG, "Cluster 0x%04X attributes:", std::get<1>(key)); - esp_zb_attribute_list_t *attr_list = val; - while (attr_list) { - esp_zb_zcl_attr_t *attr = &attr_list->attribute; - ESP_LOGV(TAG, " Attr ID: 0x%04X, Type: 0x%02X, Access: 0x%02X", attr->id, attr->type, attr->access); - attr_list = attr_list->next; - } -#endif - } - } - this->attribute_list_.clear(); - - for (auto const &[ep_id, dev_id] : this->endpoint_list_) { - if (create_endpoint(ep_id, std::get<0>(dev_id), std::get<1>(dev_id)) != ESP_OK) { - ESP_LOGE(TAG, "Could not create endpoint %u", ep_id); - } - } - this->endpoint_list_.clear(); - - if (esp_zb_device_register(this->esp_zb_ep_list_) != ESP_OK) { + if (ezb_af_device_desc_register(this->dev_desc_) != EZB_ERR_NONE) { ESP_LOGE(TAG, "Could not register the endpoint list"); this->mark_failed(); return; } - esp_zb_core_action_handler_register(zb_action_handler); + ezb_zcl_core_action_handler_register(zb_action_handler); - if (esp_zb_set_primary_network_channel_set(ESP_ZB_TRANSCEIVER_ALL_CHANNELS_MASK) != ESP_OK) { + if (ezb_bdb_set_primary_channel_set(EZB_PRIMARY_CHANNEL_MASK) != ESP_OK) { ESP_LOGE(TAG, "Could not setup Zigbee"); this->mark_failed(); return; } - for (auto &[_, attribute] : this->attributes_) { - if (attribute->report_enabled) { - esp_zb_zcl_reporting_info_t reporting_info = attribute->get_reporting_info(); - ESP_LOGD(TAG, "set reporting for cluster: %u", reporting_info.cluster_id); - if (esp_zb_zcl_update_reporting_info(&reporting_info) != ESP_OK) { - ESP_LOGE(TAG, "Could not configure reporting for attribute 0x%04X in cluster 0x%04X in endpoint %u", - reporting_info.attr_id, reporting_info.cluster_id, reporting_info.ep); - } - } - } - xTaskCreate(esp_zb_task, "Zigbee_main", 4096, NULL, 24, NULL); + + uint8_t power_source = static_cast(this->is_battery_powered() ? EZB_AF_NODE_POWER_SOURCE_RECHARGEABLE_BATTERY + : EZB_AF_NODE_POWER_SOURCE_CONSTANT_POWER); + ezb_af_node_power_desc_t desc = { + .current_power_mode = EZB_AF_NODE_POWER_MODE_SYNC_ON_WHEN_IDLE, + .available_power_sources = power_source, + .current_power_source = power_source, + .current_power_source_level = EZB_AF_NODE_POWER_SOURCE_LEVEL_100_PERCENT, + }; + ezb_af_set_node_power_desc(&desc); + + xTaskCreate(ezb_task, "Zigbee_main", 4096, NULL, 24, NULL); this->disable_loop(); // loop is only needed for processing events, so disable until we join a network } @@ -303,25 +311,28 @@ void ZigbeeComponent::loop() { } void ZigbeeComponent::dump_config() { - if (esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) { + if (esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { ESP_LOGCONFIG(TAG, "Zigbee\n" - " Model: %s\n" + " Model: %.*s\n" " Router: %s\n" " Device is joined to the network: %s\n" " Current channel: %d\n" " Short addr: 0x%04X\n" " Short pan id: 0x%04X", - this->basic_cluster_data_.model, YESNO(this->device_role_ == ESP_ZB_DEVICE_TYPE_ROUTER), - YESNO(esp_zb_bdb_dev_joined()), esp_zb_get_current_channel(), esp_zb_get_short_address(), - esp_zb_get_pan_id()); - esp_zb_lock_release(); + this->basic_cluster_data_.model[0], + reinterpret_cast(this->basic_cluster_data_.model + 1), + YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER), YESNO(ezb_bdb_dev_joined()), + ezb_nwk_get_current_channel(), ezb_nwk_get_short_address(), ezb_nwk_get_panid()); + esp_zigbee_lock_release(); } else { ESP_LOGCONFIG(TAG, "Zigbee\n" - " Model: %s\n" + " Model: %.*s\n" " Router: %s\n", - this->basic_cluster_data_.model, YESNO(this->device_role_ == ESP_ZB_DEVICE_TYPE_ROUTER)); + this->basic_cluster_data_.model[0], + reinterpret_cast(this->basic_cluster_data_.model + 1), + YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER)); } } } // namespace esphome::zigbee diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index 25f53a1d6e..11289843a8 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -8,9 +8,8 @@ #include #include -#include "esp_zigbee_core.h" -#include "zboss_api.h" -#include "ha/esp_zigbee_ha_standard.h" +#include "esp_zigbee.h" +#include "ezbee/zha.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "zigbee_helpers_esp32.h" @@ -24,12 +23,10 @@ namespace esphome::zigbee { /* Zigbee configuration */ static const uint16_t ED_KEEP_ALIVE = 3000; /* 3000 millisecond */ static const uint8_t MAX_CHILDREN = 10; +static const uint32_t EZB_PRIMARY_CHANNEL_MASK = 0x07FFF800U; /* channels 11-26 */ -#define ESP_ZB_DEFAULT_RADIO_CONFIG() \ - { .radio_mode = ZB_RADIO_MODE_NATIVE, } - -#define ESP_ZB_DEFAULT_HOST_CONFIG() \ - { .host_connection_mode = ZB_HOST_CONNECTION_MODE_NONE, } +#define EZB_DEFAULT_RADIO_CONFIG() \ + { .radio_mode = ESP_ZIGBEE_RADIO_MODE_NATIVE, } uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size = false); @@ -37,14 +34,15 @@ class ZigbeeAttribute; class ZigbeeComponent final : public Component { public: + ZigbeeComponent(); void setup() override; void loop() override; void dump_config() override; - esp_err_t create_endpoint(uint8_t endpoint_id, zb_ha_standard_devs_e device_id, - esp_zb_cluster_list_t *esp_zb_cluster_list); + void set_basic_cluster(const char *model, const char *manufacturer, uint8_t power_source); void add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role); - void create_default_cluster(uint8_t endpoint_id, zb_ha_standard_devs_e device_id); + void create_default_cluster(uint8_t endpoint_id, uint16_t device_id); + void setup_reporting(); template void add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, @@ -53,15 +51,18 @@ class ZigbeeComponent final : public Component { template void add_attr(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, uint8_t max_size, T value); + static bool app_signal_handler(const ezb_app_signal_t *app_signal); + static void esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_t mode); + void factory_reset() { - esp_zb_lock_acquire(portMAX_DELAY); - esp_zb_factory_reset(); // triggers a reboot - esp_zb_lock_release(); + esp_zigbee_lock_acquire(portMAX_DELAY); + esp_zigbee_factory_reset(); // triggers a reboot + esp_zigbee_lock_release(); } template void add_on_join_callback(F &&cb) { this->join_cb_.add(std::forward(cb)); } - bool is_battery_powered() { return this->basic_cluster_data_.power_source == ESP_ZB_ZCL_BASIC_POWER_SOURCE_BATTERY; } + bool is_battery_powered() { return this->basic_cluster_data_.power_source == EZB_ZCL_BASIC_POWER_SOURCE_BATTERY; } bool is_started() { return this->started; } bool is_connected() { return this->connected_; } std::atomic started = false; @@ -76,25 +77,20 @@ class ZigbeeComponent final : public Component { uint8_t power_source; } basic_cluster_data_; bool connected_ = false; -#ifdef ZB_ED_ROLE - esp_zb_nwk_device_type_t device_role_ = ESP_ZB_DEVICE_TYPE_ED; +#ifdef CONFIG_ZB_ZED + ezb_nwk_device_type_t device_role_ = EZB_NWK_DEVICE_TYPE_END_DEVICE; #else - esp_zb_nwk_device_type_t device_role_ = ESP_ZB_DEVICE_TYPE_ROUTER; + ezb_nwk_device_type_t device_role_ = EZB_NWK_DEVICE_TYPE_ROUTER; #endif - esp_zb_attribute_list_t *create_basic_cluster_(); + void update_basic_cluster_(ezb_af_ep_desc_t ep_desc); template void add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, T *value_p); - // endpoint_list_ and attribute_list_ are only used during setup and are cleared afterwards - // value tuple could be replaced by struct - std::map> endpoint_list_; - // key tuple could be replaced by single 32 bit int with bit fields for endpoint, cluster and role - std::map, esp_zb_attribute_list_t *> attribute_list_; // attributes_ will be used during operation in zigbee callbacks to update the attribute values and trigger // automations // key tuple could be replaced by single 64 (48) bit int with bit fields for endpoint, cluster, role and attr_id std::map, ZigbeeAttribute *> attributes_; - esp_zb_ep_list_t *esp_zb_ep_list_ = esp_zb_ep_list_create(); + ezb_af_device_desc_t dev_desc_; CallbackManager join_cb_{}; }; @@ -125,8 +121,15 @@ void ZigbeeComponent::add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint1 template void ZigbeeComponent::add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, T *value_p) { - esp_zb_attribute_list_t *attr_list = this->attribute_list_[{endpoint_id, cluster_id, role}]; - esphome_zb_cluster_add_or_update_attr(cluster_id, attr_list, attr_id, value_p); + ezb_af_ep_desc_t ep_desc = ezb_af_device_get_endpoint_desc(this->dev_desc_, endpoint_id); + if (ep_desc == NULL) { + return; + } + ezb_zcl_cluster_desc_t cluster_desc = ezb_af_endpoint_get_cluster_desc(ep_desc, cluster_id, role); + if (cluster_desc == NULL) { + return; + } + esphome_zb_cluster_add_or_update_attr(cluster_id, cluster_desc, attr_id, value_p); if (attr != nullptr) { this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 086cdcc267..f19bc97be7 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -9,7 +9,6 @@ from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, add_partition, - require_libc_picolibc_newlib_compat, require_vfs_select, ) import esphome.config_validation as cv @@ -41,7 +40,6 @@ from .const import ( CONF_ROUTER, KEY_ZIGBEE, POWER_SOURCE, - REPORT, ZigbeeAttribute, ) from .const_esp32 import ( @@ -76,7 +74,7 @@ def get_c_type(attr_type: str) -> Any | None: return cg.double if "STRING" in attr_type: return cg.std_string - test = re.match(r"(^U?)(\d{1,2})(BITMAP$|BIT$|BIT_ENUM$|$)", attr_type) + test = re.match(r"^(DATA|UINT|MAP|ENUM)(\d{1,2})$", attr_type) if test and test.group(2): return getattr(cg, "uint" + get_c_size(test.group(2), [8, 16, 32, 64])) return None @@ -89,14 +87,14 @@ def get_cv_by_type(attr_type: str) -> Any | None: return cv.float_ if "STRING" in attr_type: return cv.string - test = re.match(r"(^U?)(\d{1,2})(BITMAP$|BIT$|BIT_ENUM$|$)", attr_type) + test = re.match(r"^(DATA|UINT|MAP|ENUM)(\d{1,2})$", attr_type) if test and test.group(2): return cv.positive_int raise cv.Invalid(f"Zigbee: type {attr_type} not supported or implemented") def get_default_by_type(attr_type: str) -> str | bool | int | float: - if attr_type == "CHAR_STRING": + if attr_type == "STRING": return "" if attr_type == "BOOL": return False @@ -134,7 +132,6 @@ def final_validate_esp32(config: ConfigType) -> ConfigType: ) as f: partitions_tab = f.read() for partition, types in [ - ("zb_storage", {"type": "data", "subtype": "fat", "size": 0x4000}), ("zb_fct", {"type": "data", "subtype": "fat", "size": 0x1000}), ]: if partition not in partitions_tab: @@ -191,14 +188,14 @@ def validate_sensor_esp32(config: ConfigType) -> ConfigType: { CONF_ATTRIBUTE_ID: 0x100, CONF_VALUE: (apptype << 16) | 0xFFFF, - CONF_TYPE: "U32", + CONF_TYPE: "UINT32", }, ) ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append( { CONF_ATTRIBUTE_ID: 0x75, CONF_VALUE: bacunit, - CONF_TYPE: "16BIT_ENUM", + CONF_TYPE: "ENUM16", }, ) setup_attributes(config, ep[CONF_CLUSTERS]) @@ -233,15 +230,8 @@ async def _zigbee_add_sdkconfigs(config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_ZB_ZCZR", True) else: add_idf_sdkconfig_option("CONFIG_ZB_ZED", True) - add_idf_sdkconfig_option("CONFIG_ZB_RADIO_NATIVE", True) if CONF_WIFI in CORE.config: add_idf_sdkconfig_option("CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE", 4096) - # The pre-built Zigbee library uses esp_log_default_level which requires - # dynamic log level control to be enabled - add_idf_sdkconfig_option("CONFIG_LOG_DYNAMIC_LEVEL_CONTROL", True) - # The pre-built Zigbee library is compiled against newlib which requires newlib - # reentrancy to be enabled with picolibc compatibility (IDF 6.0+ only). - require_libc_picolibc_newlib_compat() async def attributes_to_code( @@ -274,11 +264,8 @@ async def attributes_to_code( await cg.register_component(attr_var, attr) cg.add(attr_var.add_attr(attr[CONF_VALUE])) - if CONF_REPORT in attr and attr[CONF_REPORT] in [ - REPORT["enable"], - REPORT["force"], - ]: - cg.add(attr_var.set_report(attr[CONF_REPORT] == REPORT["force"])) + if CONF_REPORT in attr: + cg.add(attr_var.set_report(attr[CONF_REPORT])) if CONF_DEVICE in attr: device = await cg.get_variable(attr[CONF_DEVICE]) @@ -287,20 +274,15 @@ async def attributes_to_code( async def esp32_to_code(config: ConfigType) -> "MockObj": - add_idf_component( - name="espressif/esp-zboss-lib", - ref="1.6.4", - ) add_idf_component( name="espressif/esp-zigbee-lib", - ref="1.6.8", + ref="2.0.2", ) # add sdkconfigs later so they can overwrite esp32 defaults CORE.add_job(_zigbee_add_sdkconfigs, config) # add partitions for zigbee - add_partition("zb_storage", "data", "fat", 0x4000) # 16KB add_partition("zb_fct", "data", "fat", 0x1000) # 4KB, minimum size # create endpoints @@ -316,7 +298,7 @@ async def esp32_to_code(config: ConfigType) -> "MockObj": var.set_basic_cluster( config[CONF_MODEL], "esphome", - cg.RawExpression(POWER_SOURCE[config[CONF_POWER_SOURCE]]), + POWER_SOURCE[config[CONF_POWER_SOURCE]], ) ) for ep in ep_list: diff --git a/esphome/components/zigbee/zigbee_helpers_esp32.c b/esphome/components/zigbee/zigbee_helpers_esp32.c index 5254818df4..150be612f6 100644 --- a/esphome/components/zigbee/zigbee_helpers_esp32.c +++ b/esphome/components/zigbee/zigbee_helpers_esp32.c @@ -2,78 +2,59 @@ #ifdef USE_ESP32 #ifdef USE_ZIGBEE -#include "ha/esp_zigbee_ha_standard.h" #include "zigbee_helpers_esp32.h" +#include "ezbee/zha.h" -esp_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, +ezb_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, ezb_zcl_cluster_desc_t cluster_desc, uint16_t attr_id, void *value_p) { - esp_err_t ret; - ret = esp_zb_cluster_update_attr(attr_list, attr_id, value_p); - if (ret != ESP_OK) { - ESP_LOGE("zigbee_helper", "Ignore previous attribute not found error"); - ret = esphome_zb_cluster_add_attr(cluster_id, attr_list, attr_id, value_p); + ezb_zcl_attr_desc_t attr_desc = ezb_zcl_cluster_get_attr_desc(cluster_desc, attr_id, EZB_ZCL_STD_MANUF_CODE); + if (attr_desc != NULL) { + return ezb_zcl_attr_desc_set_value(attr_desc, value_p); } - if (ret != ESP_OK) { - ESP_LOGE("zigbee_helper", "Could not add attribute 0x%04X to cluster 0x%04X: %s", attr_id, cluster_id, - esp_err_to_name(ret)); - } - return ret; + return esphome_zb_cluster_add_attr(cluster_id, cluster_desc, attr_id, value_p); } -esp_err_t esphome_zb_cluster_list_add_or_update_cluster(uint16_t cluster_id, esp_zb_cluster_list_t *cluster_list, - esp_zb_attribute_list_t *attr_list, uint8_t role_mask) { - esp_err_t ret; - ret = esp_zb_cluster_list_update_cluster(cluster_list, attr_list, cluster_id, role_mask); - if (ret != ESP_OK) { - ESP_LOGE("zigbee_helper", "Ignore previous cluster not found error"); - switch (cluster_id) { - case ESP_ZB_ZCL_CLUSTER_ID_BASIC: - ret = esp_zb_cluster_list_add_basic_cluster(cluster_list, attr_list, role_mask); - break; - case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY: - ret = esp_zb_cluster_list_add_identify_cluster(cluster_list, attr_list, role_mask); - break; - case ESP_ZB_ZCL_CLUSTER_ID_ANALOG_INPUT: - ret = esp_zb_cluster_list_add_analog_input_cluster(cluster_list, attr_list, role_mask); - break; - case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT: - ret = esp_zb_cluster_list_add_binary_input_cluster(cluster_list, attr_list, role_mask); - break; - default: - ret = esp_zb_cluster_list_add_custom_cluster(cluster_list, attr_list, role_mask); +ezb_err_t esphome_zb_add_or_update_cluster(uint16_t cluster_id, ezb_af_ep_desc_t ep_desc, uint8_t role_mask) { + if (ezb_af_endpoint_get_cluster_desc(ep_desc, cluster_id, role_mask) != NULL) { + // Cluster already exists, nothing to do + return EZB_ERR_NONE; + } + ezb_zcl_cluster_desc_t cluster_desc; + cluster_desc = esphome_zb_default_cluster_dscr_create(cluster_id, role_mask); + return ezb_af_endpoint_add_cluster_desc(ep_desc, cluster_desc); +} + +ezb_zcl_cluster_desc_t esphome_zb_default_cluster_dscr_create(uint16_t cluster_id, uint8_t role_mask) { + switch (cluster_id) { + case EZB_ZCL_CLUSTER_ID_BASIC: + return ezb_zcl_basic_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_IDENTIFY: + return ezb_zcl_identify_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_ANALOG_INPUT: + return ezb_zcl_analog_input_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_BINARY_INPUT: + return ezb_zcl_binary_input_create_cluster_desc(NULL, role_mask); + default: { + ezb_zcl_custom_cluster_config_t config = {0}; + config.cluster_id = cluster_id; + return ezb_zcl_custom_create_cluster_desc(&config, role_mask); } } - return ret; } -esp_zb_attribute_list_t *esphome_zb_default_attr_list_create(uint16_t cluster_id) { - switch (cluster_id) { - case ESP_ZB_ZCL_CLUSTER_ID_BASIC: - return esp_zb_basic_cluster_create(NULL); - case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY: - return esp_zb_identify_cluster_create(NULL); - case ESP_ZB_ZCL_CLUSTER_ID_ANALOG_INPUT: - return esp_zb_analog_input_cluster_create(NULL); - case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT: - return esp_zb_binary_input_cluster_create(NULL); - default: - return esp_zb_zcl_attr_list_create(cluster_id); - } -} - -esp_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, uint16_t attr_id, +ezb_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, ezb_zcl_cluster_desc_t cluster_desc, uint16_t attr_id, void *value_p) { switch (cluster_id) { - case ESP_ZB_ZCL_CLUSTER_ID_BASIC: - return esp_zb_basic_cluster_add_attr(attr_list, attr_id, value_p); - case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY: - return esp_zb_identify_cluster_add_attr(attr_list, attr_id, value_p); - case ESP_ZB_ZCL_CLUSTER_ID_ANALOG_INPUT: - return esp_zb_analog_input_cluster_add_attr(attr_list, attr_id, value_p); - case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT: - return esp_zb_binary_input_cluster_add_attr(attr_list, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_BASIC: + return ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_IDENTIFY: + return ezb_zcl_identify_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_ANALOG_INPUT: + return ezb_zcl_analog_input_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_BINARY_INPUT: + return ezb_zcl_binary_input_cluster_desc_add_attr(cluster_desc, attr_id, value_p); default: - return ESP_FAIL; + return EZB_ERR_NOT_FOUND; } } diff --git a/esphome/components/zigbee/zigbee_helpers_esp32.h b/esphome/components/zigbee/zigbee_helpers_esp32.h index 0650c1689f..6898068b44 100644 --- a/esphome/components/zigbee/zigbee_helpers_esp32.h +++ b/esphome/components/zigbee/zigbee_helpers_esp32.h @@ -8,15 +8,14 @@ extern "C" { #endif -#include "esp_zigbee_core.h" +#include "esp_zigbee.h" -esp_err_t esphome_zb_cluster_list_add_or_update_cluster(uint16_t cluster_id, esp_zb_cluster_list_t *cluster_list, - esp_zb_attribute_list_t *attr_list, uint8_t role_mask); -esp_zb_attribute_list_t *esphome_zb_default_attr_list_create(uint16_t cluster_id); -esp_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, uint16_t attr_id, - void *value_p); -esp_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, +ezb_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, ezb_zcl_cluster_desc_t cluster_desc, uint16_t attr_id, void *value_p); +ezb_err_t esphome_zb_add_or_update_cluster(uint16_t cluster_id, ezb_af_ep_desc_t ep_desc, uint8_t role_mask); +ezb_zcl_cluster_desc_t esphome_zb_default_cluster_dscr_create(uint16_t cluster_id, uint8_t role_mask); +ezb_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, ezb_zcl_cluster_desc_t cluster_desc, uint16_t attr_id, + void *value_p); #ifdef __cplusplus } diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index 39ecadfddf..1647fb28ae 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -168,7 +168,7 @@ async def _attr_to_code(config: ConfigType) -> None: ), zigbee_assign( basic_attrs.power_source, - cg.RawExpression(POWER_SOURCE[config[CONF_POWER_SOURCE]]), + POWER_SOURCE[config[CONF_POWER_SOURCE]], ), zigbee_set_string(basic_attrs.location_id, ""), zigbee_assign( diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 4f36e4dbe6..7ad41fa978 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -47,12 +47,8 @@ dependencies: version: "2.0.0" rules: - if: "target in [esp32, esp32p4]" - espressif/esp-zboss-lib: - version: 1.6.4 - rules: - - if: "target in [esp32h2, esp32c5, esp32c6]" espressif/esp-zigbee-lib: - version: 1.6.8 + version: 2.0.2 rules: - if: "target in [esp32h2, esp32c5, esp32c6]" espressif/lan87xx: diff --git a/sdkconfig.defaults.esp32c6 b/sdkconfig.defaults.esp32c6 index 6dd5f4f329..63dbeffd77 100644 --- a/sdkconfig.defaults.esp32c6 +++ b/sdkconfig.defaults.esp32c6 @@ -11,4 +11,3 @@ CONFIG_OPENTHREAD_RADIO_NATIVE=y # zigbee CONFIG_ZB_ENABLED=y CONFIG_ZB_ZED=y -CONFIG_ZB_RADIO_NATIVE=y diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml index 82a523fc7c..787afc4476 100644 --- a/tests/components/zigbee/common_esp32.yaml +++ b/tests/components/zigbee/common_esp32.yaml @@ -4,7 +4,7 @@ packages: binary_sensor: - platform: template name: "Garage Door Open 10" - report: "enable" + report: "default" - platform: template name: "Garage Door Open 12" report: "force" From a035d844749a6c9d4f128fd0c923b0a3522e07a0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:55:47 -0400 Subject: [PATCH 0721/1815] Bump docker/login-action from 4.3.0 to 4.4.0 in the docker-actions group (#17380) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-docker.yml | 4 ++-- .github/workflows/release.yml | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 07a792df08..2740ca76ca 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -96,7 +96,7 @@ jobs: - name: Log in to the GitHub container registry if: steps.tag.outputs.push == 'true' - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -154,7 +154,7 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to the GitHub container registry - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d00c6523c7..b63067ab4b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,12 +102,12 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to docker hub - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -182,13 +182,13 @@ jobs: - name: Log in to docker hub if: matrix.registry == 'dockerhub' - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry if: matrix.registry == 'ghcr' - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.actor }} From 5738c60206b2792634ac4dfe05712d675235d0ec Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:09:01 -0400 Subject: [PATCH 0722/1815] [nrf52] Run clang-tidy against the native sdk-nrf toolchain (#17364) --- .github/actions/cache-sdk-nrf/action.yml | 49 ++++ .github/workflows/ci.yml | 19 +- .../components/http_request/http_request.h | 2 +- esphome/components/logger/logger_zephyr.cpp | 2 +- esphome/components/nrf52/__init__.py | 11 +- esphome/components/nrf52/clang_tidy.py | 249 ++++++++++++++++++ esphome/components/nrf52/framework.py | 24 +- esphome/core/defines.h | 2 +- script/clang-tidy | 19 +- script/clang_tidy_hash.py | 2 + script/helpers_zephyr.py | 149 ++++------- tests/unit_tests/test_nrf52_framework.py | 26 +- 12 files changed, 432 insertions(+), 122 deletions(-) create mode 100644 .github/actions/cache-sdk-nrf/action.yml create mode 100644 esphome/components/nrf52/clang_tidy.py diff --git a/.github/actions/cache-sdk-nrf/action.yml b/.github/actions/cache-sdk-nrf/action.yml new file mode 100644 index 0000000000..71c09bfe14 --- /dev/null +++ b/.github/actions/cache-sdk-nrf/action.yml @@ -0,0 +1,49 @@ +name: Cache sdk-nrf +description: > + Resolve the pinned sdk-nrf version and cache the native sdk-nrf install + (west workspace, Zephyr SDK toolchain, python env) at ~/.esphome-sdk-nrf. + Every job that installs sdk-nrf natively (the nrf52 clang-tidy job and, + once the component tests build natively, their batches) shares one cache. + Callers must set env ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf and have + the Python venv already restored. +inputs: + restore-only: + description: > + When "true", only restore -- never save the cache, even on dev. Use from + jobs that may not produce a complete install (e.g. a component batch + that fails mid-install), so a partial install is never written. + default: "false" +runs: + using: composite + steps: + - name: Resolve sdk-nrf and toolchain versions for cache key + # Both versions are pinned in code, not in any file that feeds the + # other cache keys, so resolve them explicitly. Keying on them means + # the cache invalidates when either is bumped (actions/cache never + # overwrites a key). + id: version + shell: bash + run: | + . venv/bin/activate + version=$(python -c ' + from esphome.components.nrf52 import RECOMMENDED_SDK_NRF_VERSION + from esphome.components.nrf52.framework import TOOLCHAIN_VERSION + print(f"{RECOMMENDED_SDK_NRF_VERSION}-{TOOLCHAIN_VERSION}")') + echo "version=$version" >> "$GITHUB_OUTPUT" + # Mirror cache-esp-idf: only dev-branch runs write the shared cache (so it + # lives in the default-branch scope readable by all PRs); PRs are + # restore-only and never push multi-GB artifacts into their own scope. + - name: Cache sdk-nrf install (write on dev) + if: github.ref == 'refs/heads/dev' && inputs.restore-only != 'true' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.esphome-sdk-nrf + # yamllint disable-line rule:line-length + key: ${{ runner.os }}-esphome-sdk-nrf-${{ steps.version.outputs.version }}-${{ hashFiles('esphome/components/nrf52/requirements.txt') }} + - name: Cache sdk-nrf install (restore-only off dev) + if: github.ref != 'refs/heads/dev' || inputs.restore-only == 'true' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.esphome-sdk-nrf + # yamllint disable-line rule:line-length + key: ${{ runner.os }}-esphome-sdk-nrf-${{ steps.version.outputs.version }}-${{ hashFiles('esphome/components/nrf52/requirements.txt') }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9310b45b4a..caf6453c1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -475,6 +475,8 @@ jobs: GH_TOKEN: ${{ github.token }} # esp32-arduino-tidy installs ESP-IDF natively; share the native IDF cache. ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf + # nrf52-tidy installs sdk-nrf natively; pin it to a cacheable path. + ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf strategy: fail-fast: false max-parallel: 2 @@ -491,7 +493,7 @@ jobs: - id: clang-tidy name: Run script/clang-tidy for ZEPHYR options: --environment nrf52-tidy --grep USE_ZEPHYR --grep USE_NRF52 - pio_cache_key: tidy-zephyr + cache_sdk_nrf: true ignore_errors: false steps: @@ -527,6 +529,10 @@ jobs: with: framework: arduino + - name: Cache sdk-nrf install + if: matrix.cache_sdk_nrf + uses: ./.github/actions/cache-sdk-nrf + - name: Register problem matchers run: | echo "::add-matcher::.github/workflows/matchers/gcc.json" @@ -805,6 +811,9 @@ jobs: # esp32 component builds use the native ESP-IDF toolchain (default), so # share the tidy jobs' install location -- the restore below lands here. ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf + # nrf52 component builds install sdk-nrf natively; pin it to the shared + # cacheable path so the restore below lands where the build looks. + ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf strategy: fail-fast: false max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 8 || 4 }} @@ -840,6 +849,14 @@ jobs: uses: ./.github/actions/cache-esp-idf with: restore-only: true + - name: Cache sdk-nrf install (restore-only) + # Only batches whose test platforms include nrf52 need the native + # sdk-nrf install; never save -- just reuse the shared install the + # dev nrf52 tidy job cached when present. + if: matrix.batch.needs_nrf + uses: ./.github/actions/cache-sdk-nrf + with: + restore-only: true - name: Validate and compile components with intelligent grouping run: | . venv/bin/activate diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 5025a5c12d..df1bb462ab 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -510,9 +510,9 @@ template class HttpRequestSendAction final : public Actionmax_response_buffer_size_; #ifdef USE_HTTP_REQUEST_RESPONSE if (this->capture_response_.value(x...)) { + size_t max_length = this->max_response_buffer_size_; std::string response_body; RAMAllocator allocator; uint8_t *buf = allocator.allocate(max_length); diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index 240bcc57c7..b7884b702b 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -57,7 +57,7 @@ void Logger::pre_setup() { if (this->baud_rate_ > 0) { static const struct device *uart_dev = nullptr; switch (this->uart_) { - case UART_SELECTION_UART0: + case UART_SELECTION_UART0: // NOLINT(bugprone-branch-clone) uart_dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(uart0)); break; case UART_SELECTION_UART1: diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 7c17eadd1a..a5f2018d55 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -79,6 +79,11 @@ AUTO_LOAD = ["zephyr", "preferences"] IS_TARGET_PLATFORM = True _LOGGER = logging.getLogger(__name__) +# Default framework versions per toolchain. The sdk-nrf one also keys the CI +# sdk-nrf install cache and pins the clang-tidy project's SDK. +RECOMMENDED_PLATFORMIO_VERSION = "2.6.1-b" +RECOMMENDED_SDK_NRF_VERSION = "2.9.2" + FAKE_BOARD_MANIFEST = """ { "frameworks": [ @@ -123,7 +128,11 @@ def _resolve_toolchain(config: ConfigType) -> ConfigType: def set_framework(config: ConfigType) -> ConfigType: if CONF_VERSION not in config[CONF_FRAMEWORK]: - default_version = "2.6.1-b" if CORE.using_toolchain_platformio else "2.9.2" + default_version = ( + RECOMMENDED_PLATFORMIO_VERSION + if CORE.using_toolchain_platformio + else RECOMMENDED_SDK_NRF_VERSION + ) config = { **config, CONF_FRAMEWORK: {**config[CONF_FRAMEWORK], CONF_VERSION: default_version}, diff --git a/esphome/components/nrf52/clang_tidy.py b/esphome/components/nrf52/clang_tidy.py new file mode 100644 index 0000000000..2dd4b7bd09 --- /dev/null +++ b/esphome/components/nrf52/clang_tidy.py @@ -0,0 +1,249 @@ +"""Generate clang-tidy compile commands via the native sdk-nrf toolchain. + +Produces a ``compile_commands.json`` for the nrf52/Zephyr clang-tidy +environment **without an ESPHome YAML config**, mirroring +``esphome.espidf.clang_tidy``: generate a minimal Zephyr application, run a +configure-only west build with the native sdk-nrf toolchain, and let +``script/helpers_zephyr.py`` extract idedata from the resulting compile +commands. + +* the stub app is C++ so the compile commands carry C++ flags, matching how + clang-tidy analyzes ESPHome's sources; +* ``prj.conf`` enables the Kconfig superset ESPHome components need (BT, ADC, + mcumgr, zigbee) so their include paths land in the compile commands; +* the platform defines (USE_ZEPHYR, USE_NRF52) match what a real ESPHome + nrf52 build adds via its generated project. + +``ESPHOME_ZEPHYR_COMPILE_COMMANDS`` may point at an existing build's +``compile_commands.json`` to skip generation (fast iteration). +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +TIDY_PROJECT_NAME = "esphome_tidy" + +# Analyzed against the native toolchain's default SDK version +# (RECOMMENDED_SDK_NRF_VERSION), which also keys the CI install cache. +_TIDY_BOARD = "adafruit_itsybitsy_nrf52840" + +# Never compiled (the build is configure-only): the file exists only so the +# app target emits a C++ compile command to harvest flags/includes from. +_TIDY_MAIN_CPP = "int main() { return 0; }\n" + +# Kconfig superset enabling every subsystem an ESPHome nrf52 component may +# use, so the compile commands carry all of their include paths. +_TIDY_PRJ_CONF = """\ +CONFIG_CPP=y +CONFIG_STD_CPP20=y +CONFIG_REQUIRES_FULL_LIBCPP=y +CONFIG_NEWLIB_LIBC=y +CONFIG_BT=y +CONFIG_ADC=y +# posix (time sets POSIX_CLOCK, socket sets POSIX_API); without it the +# Zephyr POSIX headers clash with the libc ones under analysis +CONFIG_POSIX_API=y +#mcumgr begin +CONFIG_NET_BUF=y +CONFIG_ZCBOR=y +CONFIG_MCUMGR=y +CONFIG_MCUMGR_GRP_IMG=y +CONFIG_IMG_MANAGER=y +CONFIG_STREAM_FLASH=y +CONFIG_FLASH_MAP=y +CONFIG_FLASH=y +CONFIG_IMG_ERASE_PROGRESSIVELY=y +CONFIG_BOOTLOADER_MCUBOOT=y +CONFIG_MCUMGR_MGMT_NOTIFICATION_HOOKS=y +CONFIG_MCUMGR_GRP_IMG_STATUS_HOOKS=y +CONFIG_MCUMGR_GRP_IMG_UPLOAD_CHECK_HOOK=y +CONFIG_MCUMGR_TRANSPORT_UART=y +#mcumgr end +#zigbee begin +CONFIG_ZIGBEE=y +CONFIG_CRYPTO=y +CONFIG_NVS=y +CONFIG_SETTINGS=y +#zigbee end +""" + + +def _tidy_cmakelists(library_include_dirs: str) -> str: + # The defines a real ESPHome nrf52 build puts on the app target. + # ESPHOME_LOG_LEVEL must be set up front -- otherwise log.h's ``#ifndef`` + # sets it to NONE, a macro-redefined warning across nearly every source. + return f"""\ +# Auto-generated by ESPHome (clang-tidy compile-commands project) +cmake_minimum_required(VERSION 3.20.0) +set(Zephyr_DIR "$ENV{{ZEPHYR_BASE}}/share/zephyr-package/cmake/") +find_package(Zephyr REQUIRED) +project({TIDY_PROJECT_NAME}) +target_sources(app PRIVATE main.cpp) +target_compile_definitions(app PRIVATE + USE_ZEPHYR + USE_NRF52 + ESPHOME_LOG_LEVEL=ESPHOME_LOG_LEVEL_VERY_VERBOSE +) +target_include_directories(app PRIVATE +{library_include_dirs} +) +""" + + +def _parse_lib_deps(platformio_ini: Path) -> list: + """Parse the nrf52 env's ``lib_deps`` from platformio.ini into Library specs. + + These are the PlatformIO libraries ESPHome components pull in via + ``cg.add_library`` (ArduinoJson, dlms_parser, ...); their headers must be + on the tidy translation unit's include path. Mirrors the pio nrf52 env's + ``lib_deps`` composition (``common.lib_deps_base`` + + ``common:idf-component-libs``). + """ + import configparser + + from esphome.core import Library + + parser = configparser.ConfigParser(interpolation=None, strict=False) + parser.read(platformio_ini) + + tokens: list[str] = [] + for section, key in ( + ("common", "lib_deps_base"), + ("common:idf-component-libs", "lib_deps"), + ): + if parser.has_option(section, key): + tokens += parser.get(section, key).splitlines() + + libs: list[Library] = [] + for token in tokens: + token = token.split(";", 1)[0].strip() # drop trailing ; comment + if not token or token.startswith(("${", "+<")): + continue + if "://" in token or ".git" in token: + libs.append(Library(token, None, token)) # git repository (with #ref) + elif "@" in token: + name, _, version = token.partition("@") + libs.append(Library(name, version)) + return libs + + +def _library_include_dirs(platformio_ini: Path) -> list[str]: + """Resolve the pio libraries and return their include roots.""" + from esphome.platformio.library import LibraryBackend, convert_libraries + + dirs: list[str] = [] + + def emit(component) -> None: + build = component.data.get("build", {}) + candidates = {build.get("includeDir", "include"), build.get("srcDir", "src")} + candidates.update({"src", "."}) + for candidate in sorted(candidates): + path = (component.path / candidate).resolve() + if path.is_dir(): + dirs.append(str(path)) + + backend = LibraryBackend( + platform="nordicnrf52", framework="zephyr", emit=emit, cache_key="zephyr" + ) + convert_libraries(_parse_lib_deps(platformio_ini), backend) + return sorted(set(dirs)) + + +def _setup_core(work_dir: Path) -> None: + """Point CORE at the tidy project + SDK version, without any YAML config.""" + from esphome.components.zephyr.const import KEY_ZEPHYR + import esphome.config_validation as cv + from esphome.const import ( + KEY_CORE, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + PLATFORM_NRF52, + Toolchain, + ) + from esphome.core import CORE + + from . import RECOMMENDED_SDK_NRF_VERSION + + CORE.name = TIDY_PROJECT_NAME + # config_path's parent is the data-dir root for per-run artifacts. The + # sdk-nrf install is in the global cache dir, independent of this path. + CORE.config_path = work_dir.parent / "tidy.yaml" + CORE.build_path = work_dir + CORE.toolchain = Toolchain.SDK_NRF + CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = PLATFORM_NRF52 + CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = KEY_ZEPHYR + CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version.parse( + RECOMMENDED_SDK_NRF_VERSION + ) + + +def generate_compile_commands(work_dir: Path, platformio_ini: Path) -> Path: + """Generate the tidy Zephyr project and run a configure-only west build. + + Returns the path to the generated ``compile_commands.json``. + """ + from esphome.core import EsphomeError + from esphome.framework_helpers import run_command_ok + from esphome.helpers import rmtree + + from .framework import check_and_install, get_build_env, get_build_paths + + # Surface ESPHome's INFO logs (sdk-nrf download/west update) -- they go + # through logging, which the clang-tidy script otherwise leaves at + # WARNING, so the first-run installation looks silent without this. + logging.basicConfig(level=logging.INFO, format="%(message)s") + + _setup_core(work_dir) + check_and_install() + + library_include_dirs = "\n".join( + f' "{d}"' for d in _library_include_dirs(platformio_ini) + ) + source_dir = work_dir / "zephyr" + source_dir.mkdir(parents=True, exist_ok=True) + (source_dir / "CMakeLists.txt").write_text( + _tidy_cmakelists(library_include_dirs), encoding="utf-8" + ) + (source_dir / "main.cpp").write_text(_TIDY_MAIN_CPP, encoding="utf-8") + (source_dir / "prj.conf").write_text(_TIDY_PRJ_CONF, encoding="utf-8") + + # Always configure from scratch: west can't pristine a dir whose CMake + # cache is stale/missing, and a configure-only run is cheap. + build_dir = work_dir / "build" + if build_dir.is_dir(): + rmtree(build_dir) + + paths = get_build_paths() + # Build only the generated-headers target (syscall_list.h, offsets.h, ...) + # on top of the configure: clang-tidy needs those headers to exist, but a + # full firmware build would be wasted work. --no-sysbuild keeps sdk-nrf + # 2.9+ from wrapping the build in a multi-image sysbuild project, which + # would nest the compile commands and hide the headers target. + west_cmd = [ + str(paths["python_executable"]), + "-m", + "west", + "build", + "--no-sysbuild", + "-b", + _TIDY_BOARD, + "-d", + str(build_dir), + str(source_dir), + "-t", + "zephyr_generated_headers", + "--", + "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", + ] + if not run_command_ok( + west_cmd, + env=get_build_env(), + stream_output=True, + cwd=str(paths["framework_path"]), + ): + raise EsphomeError("nRF52 clang-tidy configure failed") + + return build_dir / "compile_commands.json" diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 640aa07fbf..fa6f7d57ad 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -1,3 +1,4 @@ +import hashlib import logging import os from pathlib import Path @@ -24,7 +25,7 @@ from esphome.helpers import get_str_env _LOGGER = logging.getLogger(__name__) _REQUIREMENTS = Path(__file__).parent / "requirements.txt" -_TOOLCHAIN_VERSION = "0.17.4" +TOOLCHAIN_VERSION = "0.17.4" SDK_NG_TOOLCHAIN_MIRRORS = str_to_lst_of_str( os.environ.get( @@ -132,7 +133,7 @@ def get_build_env() -> dict: env = os.environ.copy() env["PATH"] = str(venv_bin_dir) + os.pathsep + env.get("PATH", "") env["ZEPHYR_BASE"] = str(_get_framework_path(version) / "zephyr") - env["Zephyr-sdk_DIR"] = str(_get_toolchain_path(_TOOLCHAIN_VERSION) / "cmake") + env["Zephyr-sdk_DIR"] = str(_get_toolchain_path(TOOLCHAIN_VERSION) / "cmake") return env @@ -158,9 +159,10 @@ def check_and_install() -> None: python_env_path = _get_python_env_path(version) env_python_path = get_python_env_executable_path(python_env_path, "python") sentinel = python_env_path / ".ready" + requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest() install_venv = ( not sentinel.exists() - or _REQUIREMENTS.stat().st_mtime > sentinel.stat().st_mtime + or sentinel.read_text(encoding="utf-8") != requirements_hash ) if install_venv: rmdir(python_env_path, msg=f"Clean up {version} Python environment") @@ -182,7 +184,7 @@ def check_and_install() -> None: raise EsphomeError( f"Install requirements for {version} Python environment failure" ) - sentinel.touch() + sentinel.write_text(requirements_hash, encoding="utf-8") framework_path = _get_framework_path(version) sentinel = framework_path / ".ready" @@ -238,19 +240,17 @@ def check_and_install() -> None: raise EsphomeError(f"Install Zephyr requirements for {version} failure") zephyr_sentinel.touch() - toolchains_dir = _get_toolchain_path(_TOOLCHAIN_VERSION) + toolchains_dir = _get_toolchain_path(TOOLCHAIN_VERSION) sentinel = toolchains_dir / ".ready" if not sentinel.exists(): - rmdir( - toolchains_dir, msg=f"Clean up {_TOOLCHAIN_VERSION} toolchain environment" - ) + rmdir(toolchains_dir, msg=f"Clean up {TOOLCHAIN_VERSION} toolchain environment") sysname, machine, extension = _get_toolchain_platform_info() with tempfile.NamedTemporaryFile() as tmp: - _LOGGER.info("Downloading Zephyr SDK %s minimal ...", _TOOLCHAIN_VERSION) + _LOGGER.info("Downloading Zephyr SDK %s minimal ...", TOOLCHAIN_VERSION) download_from_mirrors( SDK_NG_MINIMAL_MIRRORS, { - "VERSION": _TOOLCHAIN_VERSION, + "VERSION": TOOLCHAIN_VERSION, "sysname": sysname, "machine": machine, "extension": extension, @@ -259,11 +259,11 @@ def check_and_install() -> None: ) archive_extract_all(tmp.file, toolchains_dir, progress_header="Extracting") with tempfile.NamedTemporaryFile() as tmp: - _LOGGER.info("Downloading %s toolchain ...", _TOOLCHAIN_VERSION) + _LOGGER.info("Downloading %s toolchain ...", TOOLCHAIN_VERSION) download_from_mirrors( SDK_NG_TOOLCHAIN_MIRRORS, { - "VERSION": _TOOLCHAIN_VERSION, + "VERSION": TOOLCHAIN_VERSION, "sysname": sysname, "machine": machine, "extension": extension, diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 1c0138f9d1..ff4bccc693 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -148,6 +148,7 @@ #define USE_NEXTION_TRIGGER_CUSTOM_TEXT_SENSOR #define USE_NEXTION_WAVEFORM #define USE_NUMBER +#define USE_OTA_STATE_LISTENER #define USE_OUTPUT #define USE_OUTPUT_FLOAT_POWER_SCALING #define USE_POWER_SUPPLY @@ -211,7 +212,6 @@ #define USE_RUNTIME_STATS #define USE_OTA #define USE_OTA_PASSWORD -#define USE_OTA_STATE_LISTENER #define USE_OTA_VERSION 2 #define USE_TIME_TIMEZONE #define USE_WIFI diff --git a/script/clang-tidy b/script/clang-tidy index 1416b9b332..7df46cb2d2 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -145,14 +145,16 @@ def clang_options(idedata): # defines cmd.extend(f"-D{define}" for define in idedata["defines"]) - # add toolchain include directories using -isystem to suppress their errors + # toolchain include directories, using -isystem to suppress their errors # idedata contains include directories for all toolchains of this platform, only use those from the one in use toolchain_dir = os.path.normpath(f"{idedata['cxx_path']}/../../") + toolchain_includes = [] for directory in idedata["includes"]["toolchain"]: if directory.startswith(toolchain_dir) and "picolibc" not in directory: - cmd.extend(["-isystem", directory]) + toolchain_includes.extend(["-isystem", directory]) - # add library include directories using -isystem to suppress their errors + # library include directories, using -isystem to suppress their errors + build_includes = [] for directory in list(idedata["includes"]["build"]): # skip our own directories, we add those later if ( @@ -166,7 +168,16 @@ def clang_options(idedata): ) or (directory.startswith(f"{root_path}") and "/.pio/" in directory) ): - cmd.extend(["-isystem", directory]) + build_includes.extend(["-isystem", directory]) + + if "zephyr" in triplet: + # Zephyr's POSIX layer shadows libc headers (sys/select.h, ...) with + # coherently-guarded versions; the real build searches the Zephyr + # include dirs before the toolchain's, and the shadowed headers clash + # (e.g. newlib's sigset_t vs Zephyr's) in the opposite order. + cmd.extend(build_includes + toolchain_includes) + else: + cmd.extend(toolchain_includes + build_includes) # add the esphome include directory using -I cmd.extend(["-I", root_path]) diff --git a/script/clang_tidy_hash.py b/script/clang_tidy_hash.py index 00bcaf45b0..57ca90711c 100644 --- a/script/clang_tidy_hash.py +++ b/script/clang_tidy_hash.py @@ -21,6 +21,8 @@ CLANG_TIDY_GLOBAL_FILES = ( "platformio.ini", "requirements_dev.txt", "esphome/idf_component.yml", + "esphome/components/esp32/__init__.py", + "esphome/components/nrf52/__init__.py", ) # sdkconfig.defaults and per-target sdkconfig.defaults. files flip the diff --git a/script/helpers_zephyr.py b/script/helpers_zephyr.py index 66ef6ffc98..c26ad7f2cd 100644 --- a/script/helpers_zephyr.py +++ b/script/helpers_zephyr.py @@ -1,59 +1,32 @@ +"""Load clang-tidy idedata for the nrf52/Zephyr environment. + +The compile commands come from a configure-only build of a minimal Zephyr +project using the native sdk-nrf toolchain (see +``esphome.components.nrf52.clang_tidy``); this module extracts the include +paths, defines and compiler flags clang-tidy needs from them. +""" + import json +import os from pathlib import Path import re +import shlex import subprocess def load_idedata(environment, temp_folder, platformio_ini): - build_environment = environment.replace("-tidy", "") - build_dir = Path(temp_folder) / f"build-{build_environment}" - Path(build_dir).mkdir(exist_ok=True) - Path(build_dir / "platformio.ini").write_text( - Path(platformio_ini).read_text(encoding="utf-8"), encoding="utf-8" - ) - esphome_dir = Path(build_dir / "esphome") - esphome_dir.mkdir(exist_ok=True) - Path(esphome_dir / "main.cpp").write_text( - """ -#include -int main() { return 0;} -extern "C" void zboss_signal_handler() {}; -""", - encoding="utf-8", - ) - zephyr_dir = Path(build_dir / "zephyr") - zephyr_dir.mkdir(exist_ok=True) - Path(zephyr_dir / "prj.conf").write_text( - """ -CONFIG_NEWLIB_LIBC=y -CONFIG_BT=y -CONFIG_ADC=y -#mcumgr begin -CONFIG_NET_BUF=y -CONFIG_ZCBOR=y -CONFIG_MCUMGR=y -CONFIG_MCUMGR_GRP_IMG=y -CONFIG_IMG_MANAGER=y -CONFIG_STREAM_FLASH=y -CONFIG_FLASH_MAP=y -CONFIG_FLASH=y -CONFIG_IMG_ERASE_PROGRESSIVELY=y -CONFIG_BOOTLOADER_MCUBOOT=y -CONFIG_MCUMGR_MGMT_NOTIFICATION_HOOKS=y -CONFIG_MCUMGR_GRP_IMG_STATUS_HOOKS=y -CONFIG_MCUMGR_GRP_IMG_UPLOAD_CHECK_HOOK=y -CONFIG_MCUMGR_TRANSPORT_UART=y -#mcumgr end -#zigbee begin -CONFIG_ZIGBEE=y -CONFIG_CRYPTO=y -CONFIG_NVS=y -CONFIG_SETTINGS=y -#zigbee end -""", - encoding="utf-8", - ) - subprocess.run(["pio", "run", "-e", build_environment, "-d", build_dir], check=True) + if explicit := os.environ.get("ESPHOME_ZEPHYR_COMPILE_COMMANDS"): + compile_commands_path = Path(explicit) + else: + from esphome.components.nrf52.clang_tidy import generate_compile_commands + + work_dir = (Path(temp_folder) / f"zephyr-{environment}").resolve() + compile_commands_path = generate_compile_commands( + work_dir, Path(platformio_ini) + ) + + if not compile_commands_path.is_file(): + raise RuntimeError(f"compile_commands.json not found: {compile_commands_path}") def extract_include_paths(command): include_paths = [] @@ -62,7 +35,7 @@ CONFIG_SETTINGS=y split_strings = re.split( r"\s*-\s*(?:I|isystem)", list(filter(lambda x: x, match))[0] ) - include_paths.append(split_strings[1]) + include_paths.append(split_strings[1].strip()) return include_paths def extract_defines(command): @@ -74,15 +47,6 @@ CONFIG_SETTINGS=y if not any(match.startswith(prefix) for prefix in ignore_prefixes) ] - def find_cxx_path(commands): - for entry in commands: - command = entry["command"] - cxx_path = command.split()[0] - if not cxx_path.endswith("++"): - continue - return cxx_path - return None - def get_builtin_include_paths(compiler): result = subprocess.run( [compiler, "-E", "-x", "c++", "-", "-v"], @@ -105,47 +69,48 @@ CONFIG_SETTINGS=y return include_paths def extract_cxx_flags(command): - # Extracts CXXFLAGS from the command string, excluding includes and defines. + # Extracts CXXFLAGS from the command string, excluding includes and + # defines. Anchored per token: a substring match would extract a bogus + # "-format-zero-length" from -Wno-format-zero-length. flag_pattern = re.compile( - r"(-O[0-3s]|-g|-std=[^\s]+|-Wall|-Wextra|-Werror|--[^\s]+|-f[^\s]+|-m[^\s]+|-imacros\s*[^\s]+)" + r"^(-O[0-3s]|-g|-std=.+|-Wall|-Wextra|-Werror|--.+|-f.+|-m.+|-imacros.+)$" ) - return [ - match.replace("-imacros ", "-imacros") - for match in flag_pattern.findall(command) - ] + flags = [] + tokens = shlex.split(command) + for i, token in enumerate(tokens): + if token == "-imacros" and i + 1 < len(tokens): + flags.append(f"-imacros{tokens[i + 1]}") + elif flag_pattern.match(token): + flags.append(token) + return flags def transform_to_idedata_format(compile_commands): - cxx_path = find_cxx_path(compile_commands) - idedata = { + # Use only the tidy app TU (main.cpp): as the app target, its compile + # command already carries the full Zephyr include set. Unioning every + # TU instead would drag in per-library internal include dirs (e.g. the + # Zephyr POSIX shim, whose signal.h redefines newlib's sigset_t) that + # no ESPHome source compiles against. + entry = next( + (e for e in compile_commands if e["file"].endswith("main.cpp")), None + ) + if entry is None: + raise RuntimeError("tidy main.cpp not found in compile_commands.json") + command = entry["command"] + # Find the compiler by name: the command may be prefixed with a + # launcher (Zephyr auto-enables ccache when present). + cxx_path = next((t for t in shlex.split(command) if t.endswith("++")), None) + if cxx_path is None: + raise RuntimeError(f"no C++ compiler in compile command: {command}") + + return { "includes": { "toolchain": get_builtin_include_paths(cxx_path), - "build": set(), + "build": extract_include_paths(command), }, - "defines": set(), + "defines": extract_defines(command), "cxx_path": cxx_path, - "cxx_flags": set(), + "cxx_flags": extract_cxx_flags(command), } - for entry in compile_commands: - command = entry["command"] - exec = command.split()[0] - if exec != cxx_path: - continue - - idedata["includes"]["build"].update(extract_include_paths(command)) - idedata["defines"].update(extract_defines(command)) - idedata["cxx_flags"].update(extract_cxx_flags(command)) - - # Convert sets to lists for JSON serialization - idedata["includes"]["build"] = list(idedata["includes"]["build"]) - idedata["defines"] = list(idedata["defines"]) - idedata["cxx_flags"] = list(idedata["cxx_flags"]) - - return idedata - - compile_commands = json.loads( - Path( - build_dir / ".pio" / "build" / build_environment / "compile_commands.json" - ).read_text(encoding="utf-8") - ) + compile_commands = json.loads(compile_commands_path.read_text(encoding="utf-8")) return transform_to_idedata_format(compile_commands) diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index 2b3d1f6db8..bb5bc8c064 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -1,5 +1,6 @@ """Tests for esphome.components.nrf52.framework helpers.""" +import hashlib from pathlib import Path from types import SimpleNamespace from unittest.mock import patch @@ -7,7 +8,8 @@ from unittest.mock import patch import pytest from esphome.components.nrf52.framework import ( - _TOOLCHAIN_VERSION, + _REQUIREMENTS, + TOOLCHAIN_VERSION, _get_toolchain_platform_info, check_and_install, get_sdk_nrf_tools_path, @@ -71,7 +73,7 @@ def nrf52_dirs(setup_core: Path) -> SimpleNamespace: tools = get_sdk_nrf_tools_path() python_env = tools / "penvs" / f"v{_TEST_SDK_VERSION}" framework = tools / "frameworks" / f"v{_TEST_SDK_VERSION}" - toolchain_dir = tools / "toolchains" / _TOOLCHAIN_VERSION + toolchain_dir = tools / "toolchains" / TOOLCHAIN_VERSION for d in (python_env, framework, toolchain_dir): d.mkdir(parents=True, exist_ok=True) zephyr_scripts = framework / "zephyr" / "scripts" @@ -113,6 +115,12 @@ def mock_nrf52_ops(): # --------------------------------------------------------------------------- +def _mark_venv_ready(python_env: Path) -> None: + """Write the venv sentinel with the current requirements hash.""" + requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest() + (python_env / ".ready").write_text(requirements_hash, encoding="utf-8") + + class TestCheckAndInstall: def test_all_installed_skips_all_steps( self, @@ -120,7 +128,7 @@ class TestCheckAndInstall: mock_nrf52_ops: SimpleNamespace, ) -> None: """All three sentinels present → nothing downloaded or compiled.""" - (nrf52_dirs.python_env / ".ready").touch() + _mark_venv_ready(nrf52_dirs.python_env) (nrf52_dirs.python_env / ".zephyr_reqs_ready").touch() (nrf52_dirs.framework / ".ready").touch() (nrf52_dirs.toolchain / ".ready").touch() @@ -157,7 +165,7 @@ class TestCheckAndInstall: mock_nrf52_ops: SimpleNamespace, ) -> None: """Venv ready but framework missing → skip venv creation, run SDK init+update.""" - (nrf52_dirs.python_env / ".ready").touch() + _mark_venv_ready(nrf52_dirs.python_env) check_and_install() @@ -173,7 +181,7 @@ class TestCheckAndInstall: mock_nrf52_ops: SimpleNamespace, ) -> None: """Venv and framework ready → only toolchain downloaded and extracted.""" - (nrf52_dirs.python_env / ".ready").touch() + _mark_venv_ready(nrf52_dirs.python_env) (nrf52_dirs.python_env / ".zephyr_reqs_ready").touch() (nrf52_dirs.framework / ".ready").touch() @@ -202,7 +210,7 @@ class TestCheckAndInstall: mock_nrf52_ops: SimpleNamespace, ) -> None: """Failing west init raises EsphomeError.""" - (nrf52_dirs.python_env / ".ready").touch() + _mark_venv_ready(nrf52_dirs.python_env) mock_nrf52_ops.run_command_ok.return_value = False with pytest.raises(EsphomeError, match="Can't initialize"): @@ -214,7 +222,7 @@ class TestCheckAndInstall: mock_nrf52_ops: SimpleNamespace, ) -> None: """Failing west update raises EsphomeError.""" - (nrf52_dirs.python_env / ".ready").touch() + _mark_venv_ready(nrf52_dirs.python_env) # init succeeds, update fails mock_nrf52_ops.run_command_ok.side_effect = [True, False] @@ -227,7 +235,7 @@ class TestCheckAndInstall: mock_nrf52_ops: SimpleNamespace, ) -> None: """download_from_mirrors receives VERSION + platform triple from _get_toolchain_platform_info.""" - (nrf52_dirs.python_env / ".ready").touch() + _mark_venv_ready(nrf52_dirs.python_env) (nrf52_dirs.framework / ".ready").touch() with patch( @@ -238,7 +246,7 @@ class TestCheckAndInstall: args, _ = mock_nrf52_ops.download_from_mirrors.call_args substitutions = args[1] - assert substitutions["VERSION"] == _TOOLCHAIN_VERSION + assert substitutions["VERSION"] == TOOLCHAIN_VERSION assert substitutions["sysname"] == "linux" assert substitutions["machine"] == "x86_64" assert substitutions["extension"] == "tar.xz" From e94fcda8b7df65797274a97527dfcc0b89533485 Mon Sep 17 00:00:00 2001 From: Anton Viktorov Date: Sat, 4 Jul 2026 02:13:22 +0000 Subject: [PATCH 0723/1815] [cst328] Touch screen (Waveshare ESP32-S3-Touch-LCD-2.8) (#8011) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/cst328/__init__.py | 6 + .../cst328/binary_sensor/__init__.py | 28 +++ .../cst328/binary_sensor/cst328_button.cpp | 16 ++ .../cst328/binary_sensor/cst328_button.h | 20 +++ .../components/cst328/touchscreen/__init__.py | 38 ++++ .../cst328/touchscreen/cst328_touchscreen.cpp | 168 ++++++++++++++++++ .../cst328/touchscreen/cst328_touchscreen.h | 61 +++++++ tests/components/cst328/common.yaml | 22 +++ tests/components/cst328/test.esp32-idf.yaml | 8 + 10 files changed, 368 insertions(+) create mode 100644 esphome/components/cst328/__init__.py create mode 100644 esphome/components/cst328/binary_sensor/__init__.py create mode 100644 esphome/components/cst328/binary_sensor/cst328_button.cpp create mode 100644 esphome/components/cst328/binary_sensor/cst328_button.h create mode 100644 esphome/components/cst328/touchscreen/__init__.py create mode 100644 esphome/components/cst328/touchscreen/cst328_touchscreen.cpp create mode 100644 esphome/components/cst328/touchscreen/cst328_touchscreen.h create mode 100644 tests/components/cst328/common.yaml create mode 100644 tests/components/cst328/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index b222c44214..571f8492f1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -122,6 +122,7 @@ esphome/components/cover/* @esphome/core esphome/components/cs5460a/* @balrog-kun esphome/components/cse7761/* @berfenger esphome/components/cst226/* @clydebarrow +esphome/components/cst328/* @latonita esphome/components/cst816/* @clydebarrow esphome/components/cst9220/* @clydebarrow esphome/components/ct_clamp/* @jesserockz diff --git a/esphome/components/cst328/__init__.py b/esphome/components/cst328/__init__.py new file mode 100644 index 0000000000..374df64898 --- /dev/null +++ b/esphome/components/cst328/__init__.py @@ -0,0 +1,6 @@ +import esphome.codegen as cg + +CODEOWNERS = ["@latonita"] +DEPENDENCIES = ["i2c"] + +cst328_ns = cg.esphome_ns.namespace("cst328") diff --git a/esphome/components/cst328/binary_sensor/__init__.py b/esphome/components/cst328/binary_sensor/__init__.py new file mode 100644 index 0000000000..6d881cc6c1 --- /dev/null +++ b/esphome/components/cst328/binary_sensor/__init__.py @@ -0,0 +1,28 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv + +from .. import cst328_ns +from ..touchscreen import CST328ButtonListener, CST328Touchscreen + +CONF_CST328_ID = "cst328_id" + +CST328Button = cst328_ns.class_( + "CST328Button", + binary_sensor.BinarySensor, + cg.Component, + CST328ButtonListener, + cg.Parented.template(CST328Touchscreen), +) + +CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(CST328Button).extend( + { + cv.GenerateID(CONF_CST328_ID): cv.use_id(CST328Touchscreen), + } +) + + +async def to_code(config): + var = await binary_sensor.new_binary_sensor(config) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_CST328_ID]) diff --git a/esphome/components/cst328/binary_sensor/cst328_button.cpp b/esphome/components/cst328/binary_sensor/cst328_button.cpp new file mode 100644 index 0000000000..b58f4b4b9f --- /dev/null +++ b/esphome/components/cst328/binary_sensor/cst328_button.cpp @@ -0,0 +1,16 @@ +#include "cst328_button.h" +#include "esphome/core/log.h" + +namespace esphome::cst328 { +static const char *const TAG = "cst328.binary_sensor"; + +void CST328Button::setup() { + this->parent_->register_button_listener(this); + this->publish_initial_state(false); +} + +void CST328Button::dump_config() { LOG_BINARY_SENSOR("", "CST328 Button", this); } + +void CST328Button::update_button(bool state) { this->publish_state(state); } + +} // namespace esphome::cst328 diff --git a/esphome/components/cst328/binary_sensor/cst328_button.h b/esphome/components/cst328/binary_sensor/cst328_button.h new file mode 100644 index 0000000000..a9ed4785e5 --- /dev/null +++ b/esphome/components/cst328/binary_sensor/cst328_button.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "../touchscreen/cst328_touchscreen.h" + +namespace esphome::cst328 { + +class CST328Button : public binary_sensor::BinarySensor, + public Component, + public CST328ButtonListener, + public Parented { + public: + void setup() override; + void dump_config() override; + void update_button(bool state) override; +}; + +} // namespace esphome::cst328 diff --git a/esphome/components/cst328/touchscreen/__init__.py b/esphome/components/cst328/touchscreen/__init__.py new file mode 100644 index 0000000000..18c00bb6c5 --- /dev/null +++ b/esphome/components/cst328/touchscreen/__init__.py @@ -0,0 +1,38 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c, touchscreen +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN + +from .. import cst328_ns + +CST328Touchscreen = cst328_ns.class_( + "CST328Touchscreen", + touchscreen.Touchscreen, + i2c.I2CDevice, +) + +CST328ButtonListener = cst328_ns.class_("CST328ButtonListener") + +CONFIG_SCHEMA = ( + touchscreen.touchscreen_schema("100ms") + .extend( + { + cv.GenerateID(): cv.declare_id(CST328Touchscreen), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + } + ) + .extend(i2c.i2c_device_schema(0x1A)) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await touchscreen.register_touchscreen(var, config) + await i2c.register_i2c_device(var, config) + + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + if reset_pin := config.get(CONF_RESET_PIN): + cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) diff --git a/esphome/components/cst328/touchscreen/cst328_touchscreen.cpp b/esphome/components/cst328/touchscreen/cst328_touchscreen.cpp new file mode 100644 index 0000000000..5e1a2ebf72 --- /dev/null +++ b/esphome/components/cst328/touchscreen/cst328_touchscreen.cpp @@ -0,0 +1,168 @@ +#include "cst328_touchscreen.h" +#include "esphome/core/log.h" + +namespace esphome::cst328 { + +static const char *const TAG = "cst328.touchscreen"; + +static const uint32_t CST328_BEFORE_RESET_TIMEOUT = 50; // 50 ms from datasheet +static const uint32_t CST328_TRANSITION_TIMEOUT = 300; // 200 ms from datasheet, but typically much less +static const uint16_t CST328_FW_CRC = 0xCACA; // Expected firmware CRC value +static const uint8_t CST328_SYNC_BYTE = 0xAB; // Sync byte used in communication + +static const uint8_t ZERO_BYTE = 0; + +#define I2C_WARN_ON_ERROR(x, log_tag, format, ...) \ + do { \ + i2c::ErrorCode err_rc_ = (x); \ + if (err_rc_ != i2c::ERROR_OK) { \ + ESP_LOGW(log_tag, "%s(%d): [error %d] " format, __FUNCTION__, __LINE__, err_rc_, ##__VA_ARGS__); \ + this->status_set_warning(format); \ + } \ + } while (0) + +#define I2C_FAIL_ON_ERROR(x, log_tag, format, ...) \ + do { \ + i2c::ErrorCode err_rc_ = (x); \ + if (err_rc_ != i2c::ERROR_OK) { \ + ESP_LOGE(log_tag, "%s(%d): [error %d] " format, __FUNCTION__, __LINE__, err_rc_, ##__VA_ARGS__); \ + this->mark_failed(); \ + return; \ + } \ + } while (0) + +void CST328Touchscreen::setup() { + ESP_LOGCONFIG(TAG, "Setting up CST328 Touchscreen..."); + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + this->set_timeout(CST328_BEFORE_RESET_TIMEOUT, [this] { this->reset_device_(); }); + } else { + this->continue_setup_(); + } +} + +void CST328Touchscreen::reset_device_() { + this->reset_pin_->digital_write(false); + delay(5); + this->reset_pin_->digital_write(true); + this->set_timeout(CST328_TRANSITION_TIMEOUT, [this] { this->continue_setup_(); }); +} + +void CST328Touchscreen::continue_setup_() { + ESP_LOGV(TAG, "Continuing CST328 setup..."); + + uint8_t data_byte{0}; + uint8_t buf[24]{}; + + I2C_FAIL_ON_ERROR(this->write_register16(CST_WM_DEBUG_INFO, buf, 0), TAG, "Failed to enter debug/info mode"); + I2C_FAIL_ON_ERROR(this->read_register16(CST_REG_FW_CRC_AND_BOOT_TIME, buf, 4), TAG, + "Failed to read FW CRC and boot time"); + + uint16_t fw_crc = buf[2] + (buf[3] << 8); + if (fw_crc != CST328_FW_CRC) { + ESP_LOGE(TAG, "Error: Firmware CRC mismatch, expected 0x%04X but got 0x%04X", CST328_FW_CRC, fw_crc); + this->mark_failed(); + return; + } + + I2C_FAIL_ON_ERROR(this->read_register16(CST_REG_CHIP_TYPE_AND_PROJECT_ID, buf, 4), TAG, + "Failed to read chip and project ID"); + + this->chip_id_ = buf[2] + (buf[3] << 8); + this->project_id_ = buf[0] + (buf[1] << 8); + ESP_LOGD(TAG, "Chip ID %X, project ID %X", this->chip_id_, this->project_id_); + I2C_FAIL_ON_ERROR(this->read_register16(CST_REG_FW_REVISION, buf, 4), TAG, "Failed to read FW version"); + + this->fw_ver_major_ = buf[3]; + this->fw_ver_minor_ = buf[2]; + this->fw_build_ = buf[0] + (buf[1] << 8); + ESP_LOGV(TAG, "FW version %d.%d.%d", this->fw_ver_major_, this->fw_ver_minor_, this->fw_build_); + + if (i2c::ERROR_OK == this->read_register16(CST_REG_X_Y_RESOLUTION, buf, 4)) { + this->x_raw_max_ = buf[0] + (buf[1] << 8); + this->y_raw_max_ = buf[2] + (buf[3] << 8); + } else { + this->x_raw_max_ = this->display_->get_native_width(); + this->y_raw_max_ = this->display_->get_native_height(); + } + + I2C_WARN_ON_ERROR(this->write_register16(CST_WM_NORMAL, buf, 0), TAG, "Failed to enter normal mode"); + I2C_WARN_ON_ERROR(this->read_register16(CST_REG_TOUCH_INFORMATION, &data_byte, 1), TAG, "Failed to read sync"); + I2C_WARN_ON_ERROR(this->write_register16(CST_REG_TOUCH_INFORMATION, &CST328_SYNC_BYTE, 1), TAG, + "Failed to write sync"); + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); + } + + this->setup_complete_ = true; + ESP_LOGV(TAG, "CST328 setup complete"); +} + +void CST328Touchscreen::dump_config() { + ESP_LOGCONFIG(TAG, "CST328 Touchscreen:"); + LOG_I2C_DEVICE(this); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); + ESP_LOGCONFIG(TAG, " Chip ID: 0x%04X, Project ID: 0x%04X", this->chip_id_, this->project_id_); + ESP_LOGCONFIG(TAG, " FW version: %d.%d.%d", this->fw_ver_major_, this->fw_ver_minor_, this->fw_build_); + ESP_LOGCONFIG(TAG, " X/Y resolution: %d/%d", this->x_raw_max_, this->y_raw_max_); +} + +void CST328Touchscreen::update_button_state_(bool state) { + if (this->button_touched_ == state) { + return; + } + this->button_touched_ = state; + for (auto *listener : this->button_listeners_) { + listener->update_button(state); + } +} + +void CST328Touchscreen::update_touches() { + if (!this->setup_complete_) { + this->skip_update_ = true; + return; + } + + uint8_t touch_data[CST328_TOUCH_DATA_SIZE]; + + this->status_clear_warning(); + + if (i2c::ERROR_OK != this->read_register16(CST_REG_TOUCH_INFORMATION, touch_data, CST328_TOUCH_DATA_SIZE)) { + ESP_LOGW(TAG, "Failed to read touch data"); + this->status_set_warning(); + this->skip_update_ = true; + return; + } + + uint8_t touch_cnt = touch_data[CST_REG_FINGER_COUNT_IDX] & 0x0F; + if (touch_cnt == 0 || touch_cnt > CST328_TOUCH_MAX_POINTS) { + this->update_button_state_(false); + } else { + this->update_button_state_(true); + + uint8_t data_idx = 0; + for (uint8_t i = 0; i < touch_cnt; i++) { + uint8_t id = touch_data[data_idx] >> 4; + int16_t x = (touch_data[data_idx + 1] << 4) | ((touch_data[data_idx + 3] >> 4) & 0x0F); + int16_t y = (touch_data[data_idx + 2] << 4) | (touch_data[data_idx + 3] & 0x0F); + int16_t z = touch_data[data_idx + 4]; + + this->add_raw_touch_position_(id, x, y, z); + data_idx += (i == 0) ? 7 : 5; + } + } + + bool cleanup_error = false; + cleanup_error |= (i2c::ERROR_OK != this->write_register16(CST_REG_TOUCH_FINGER_NUMBER, &ZERO_BYTE, 1)); + cleanup_error |= (i2c::ERROR_OK != this->write_register16(CST_REG_TOUCH_INFORMATION, &CST328_SYNC_BYTE, 1)); + + if (cleanup_error) { + ESP_LOGW(TAG, "Failed to clean up touch registers"); + } +} + +} // namespace esphome::cst328 diff --git a/esphome/components/cst328/touchscreen/cst328_touchscreen.h b/esphome/components/cst328/touchscreen/cst328_touchscreen.h new file mode 100644 index 0000000000..234ec6eee0 --- /dev/null +++ b/esphome/components/cst328/touchscreen/cst328_touchscreen.h @@ -0,0 +1,61 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/touchscreen/touchscreen.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" + +namespace esphome::cst328 { + +static const uint8_t CST328_TOUCH_MAX_POINTS = 5; +static const uint8_t CST328_TOUCH_DATA_SIZE = CST328_TOUCH_MAX_POINTS * 5 + 2; + +static const uint16_t CST_REG_TOUCH_INFORMATION = 0xD000; +static const uint16_t CST_REG_TOUCH_FINGER_NUMBER = 0xD005; + +static const uint16_t CST_REG_FINGER_COUNT_IDX = CST_REG_TOUCH_FINGER_NUMBER - CST_REG_TOUCH_INFORMATION; + +static const uint16_t CST_REG_X_Y_RESOLUTION = 0xD1F8; +static const uint16_t CST_REG_FW_CRC_AND_BOOT_TIME = 0xD1FC; +static const uint16_t CST_REG_CHIP_TYPE_AND_PROJECT_ID = 0xD204; +static const uint16_t CST_REG_FW_REVISION = 0xD208; + +static const uint16_t CST_WM_DEBUG_INFO = 0xD101; +static const uint16_t CST_WM_NORMAL = 0xD109; + +class CST328ButtonListener { + public: + virtual void update_button(bool state) = 0; +}; + +class CST328Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { + public: + void setup() override; + void register_button_listener(CST328ButtonListener *listener) { this->button_listeners_.push_back(listener); } + void dump_config() override; + + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; } + + protected: + void update_touches() override; + void reset_device_(); + void continue_setup_(); + void update_button_state_(bool state); + + InternalGPIOPin *interrupt_pin_{}; + GPIOPin *reset_pin_{}; + + std::vector button_listeners_; + bool button_touched_{}; + + uint16_t chip_id_{}; + uint16_t project_id_{}; + uint8_t fw_ver_major_{}; + uint8_t fw_ver_minor_{}; + uint16_t fw_build_{}; + + bool setup_complete_{}; +}; + +} // namespace esphome::cst328 diff --git a/tests/components/cst328/common.yaml b/tests/components/cst328/common.yaml new file mode 100644 index 0000000000..286dbf587f --- /dev/null +++ b/tests/components/cst328/common.yaml @@ -0,0 +1,22 @@ +display: + - platform: ssd1306_i2c + i2c_id: i2c_bus + id: cst328_ssd1306_i2c_display + model: SSD1306_128X64 + reset_pin: ${display_reset_pin} + pages: + - id: cst328_page1 + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + +touchscreen: + - platform: cst328 + i2c_id: i2c_bus + id: cst328_touchscreen + display: cst328_ssd1306_i2c_display + interrupt_pin: ${interrupt_pin} + reset_pin: ${reset_pin} + +binary_sensor: + - platform: cst328 + id: touch_key_cst328 diff --git a/tests/components/cst328/test.esp32-idf.yaml b/tests/components/cst328/test.esp32-idf.yaml new file mode 100644 index 0000000000..3dc184e328 --- /dev/null +++ b/tests/components/cst328/test.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + display_reset_pin: "4" + interrupt_pin: "20" + reset_pin: "21" + +packages: + - !include ../../test_build_components/common/i2c/esp32-idf.yaml + - !include common.yaml From 787805253393551a4a5cfa09a4e351a8cc548ed8 Mon Sep 17 00:00:00 2001 From: Citric Li <37475446+limengdu@users.noreply.github.com> Date: Sat, 4 Jul 2026 11:50:54 +0800 Subject: [PATCH 0724/1815] [epaper_spi] Add T133A01 6-color e-paper driver for reTerminal E1004 (#16706) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/epaper_spi/display.py | 2 + .../epaper_spi/epaper_spi_t133a01.cpp | 367 ++++++++++++++++++ .../epaper_spi/epaper_spi_t133a01.h | 77 ++++ .../components/epaper_spi/models/__init__.py | 23 ++ .../components/epaper_spi/models/t133a01.py | 71 ++++ tests/component_tests/epaper_spi/test_init.py | 8 + .../epaper_spi/test.esp32-s3-idf.yaml | 8 + .../validate-e1004.esp32-s3-idf.yaml | 38 ++ 8 files changed, 594 insertions(+) create mode 100644 esphome/components/epaper_spi/epaper_spi_t133a01.cpp create mode 100644 esphome/components/epaper_spi/epaper_spi_t133a01.h create mode 100644 esphome/components/epaper_spi/models/t133a01.py create mode 100644 tests/components/epaper_spi/validate-e1004.esp32-s3-idf.yaml diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index ce28fb0d67..0b82850f1e 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -112,6 +112,7 @@ def model_schema(config): cv.positive_time_period_milliseconds, cv.Range(max=core.TimePeriod(milliseconds=500)), ), + **model.get_config_options(), } ) @@ -198,6 +199,7 @@ async def to_code(config): ) await display.register_display(var, config) + config = await model.to_code(var, config) await spi.register_spi_device(var, config, write_only=True) dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) diff --git a/esphome/components/epaper_spi/epaper_spi_t133a01.cpp b/esphome/components/epaper_spi/epaper_spi_t133a01.cpp new file mode 100644 index 0000000000..5735333761 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_t133a01.cpp @@ -0,0 +1,367 @@ +#include "epaper_spi_t133a01.h" + +#include + +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { + +static constexpr const char *const TAG = "epaper_spi.t133a01"; + +// Color indices used in the 4bpp buffer (sprite-side) +// These MUST match the Arduino GFX TFT_eSPI.h color definitions and +// the remap_color()/COLOR_GET mapping: +// 0x0F=BLACK, 0x00=WHITE, 0x02=GREEN, 0x06=RED, 0x0B=YELLOW, 0x0D=BLUE +static constexpr uint8_t T133A01_BLACK = 0x0F; +static constexpr uint8_t T133A01_WHITE = 0x00; +static constexpr uint8_t T133A01_GREEN = 0x02; +static constexpr uint8_t T133A01_RED = 0x06; +static constexpr uint8_t T133A01_YELLOW = 0x0B; +static constexpr uint8_t T133A01_BLUE = 0x0D; + +// T133A01 register addresses +static constexpr uint8_t R00_PSR = 0x00; +static constexpr uint8_t R01_PWR = 0x01; +static constexpr uint8_t R02_POF = 0x02; +static constexpr uint8_t R04_PON = 0x04; +static constexpr uint8_t R05_BTST_N = 0x05; +static constexpr uint8_t R06_BTST_P = 0x06; +static constexpr uint8_t R10_DTM = 0x10; +static constexpr uint8_t R12_DRF = 0x12; +static constexpr uint8_t R50_CDI = 0x50; +static constexpr uint8_t R61_TRES = 0x61; +static constexpr uint8_t RA5_DCDC = 0xA5; +static constexpr uint8_t RE0_CCSET = 0xE0; +static constexpr uint8_t RE3_PWS = 0xE3; + +/** + * COLOR_GET remap table from T133A01_Defines.h. + * Translates 4bpp sprite color index to the hardware pixel encoding. + * Sprite: 0x0F=BLACK 0x00=WHITE 0x02=GREEN 0x06=RED 0x0B=YELLOW 0x0D=BLUE + * HW: 0x00=BLACK 0x01=WHITE 0x06=GREEN 0x03=RED 0x02=YELLOW 0x05=BLUE + */ +uint8_t EPaperT133A01::remap_color(uint8_t index) { + switch (index & 0x0F) { + case 0x0F: + return 0x00; // Black + case 0x00: + return 0x01; // White + case 0x02: + return 0x06; // Green + case 0x06: + return 0x03; // Red + case 0x0B: + return 0x02; // Yellow + case 0x0D: + return 0x05; // Blue + default: + return 0x01; // White fallback + } +} + +/** + * Map an ESPHome Color to a 4-bit sprite color index. + * Index values match the Arduino GFX TFT_eSPI color definitions: + * 0x00=WHITE, 0x02=GREEN, 0x06=RED, 0x0B=YELLOW, 0x0D=BLUE, 0x0F=BLACK + */ +uint8_t EPaperT133A01::color_to_index(Color color) { + unsigned char max_rgb = std::max({color.r, color.g, color.b}); + unsigned char min_rgb = std::min({color.r, color.g, color.b}); + + // Check for grayscale + if ((max_rgb - min_rgb) < 50) { + if ((static_cast(color.r) + color.g + color.b) > 382) { + return T133A01_WHITE; + } + return T133A01_BLACK; + } + + bool r_on = (color.r > 128); + bool g_on = (color.g > 128); + bool b_on = (color.b > 128); + + if (r_on && g_on && !b_on) + return T133A01_YELLOW; + if (r_on && !g_on && !b_on) + return T133A01_RED; + if (!r_on && g_on && !b_on) + return T133A01_GREEN; + if (!r_on && !g_on && b_on) + return T133A01_BLUE; + // Handle mixed colors: map to nearest primary + if (!r_on && g_on && b_on) + return T133A01_GREEN; // Cyan -> Green + if (r_on && !g_on) + return T133A01_RED; // Magenta -> Red + if (r_on) + return T133A01_WHITE; + return T133A01_BLACK; +} + +void EPaperT133A01::setup() { + // Base setup initialises the buffer, the standard pins and the SPI bus. + EPaperBase::setup(); + + // Both chip-selects are driven directly by this driver (the dual-CS + // protocol needs CS held HIGH while CS1 receives data, which the SPI + // bus cannot do). Start both deselected (HIGH). + this->cs_pin_->setup(); + this->cs_pin_->digital_write(true); + this->cs1_pin_->setup(); + this->cs1_pin_->digital_write(true); +} + +bool EPaperT133A01::reset() { + for (auto *enable_pin : this->enable_pins_) { + enable_pin->digital_write(true); + } + if (this->reset_pin_ != nullptr) { + if (this->state_ == EPaperState::RESET) { + this->reset_pin_->digital_write(false); + return false; + } + this->reset_pin_->digital_write(true); + } + return true; +} + +/** + * Initialise the T133A01 display. + * + * The init sequence uses a mix of CS and CS1 commands as per the Arduino driver. + * The base class init_sequence is NOT used for T133A01 because the dual-CS + * protocol requires per-command routing. + */ +bool EPaperT133A01::initialise(bool partial) { + // Init sequence mirrors the Arduino GFX library's EPD_INIT() macro + // (T133A01_Defines.h). Commands routed to CS only leave CS1 deselected; + // commands routed to both controllers assert CS and CS1 together. + + // 0x74 - panel config (CS only) + this->write_command_(0x74, {0x00, 0x0C, 0x0C, 0xD9, 0xDD, 0xDD, 0x15, 0x15, 0x55}, true, false); + delay(10); + + // 0xF0 - panel config (CS + CS1) + this->write_command_(0xF0, {0x49, 0x55, 0x13, 0x5D, 0x05, 0x10}, true, true); + delay(10); + + // PSR - Panel Setting Register (CS + CS1) + this->write_command_(0x00, {0xDF, 0x69}, true, true); + delay(10); + + // DCDC (CS only) + this->write_command_(RA5_DCDC, {0x44, 0x54, 0x00}, true, false); + delay(10); + + // CDI (CS + CS1) + this->write_command_(R50_CDI, {0x37}, true, true); + delay(10); + + // 0x60 (CS + CS1) + this->write_command_(0x60, {0x03, 0x03}, true, true); + delay(10); + + // 0x86 (CS + CS1) + this->write_command_(0x86, {0x10}, true, true); + delay(10); + + // PWS - Phase Width Setting (CS + CS1) + this->write_command_(RE3_PWS, {0x22}, true, true); + delay(10); + + // TRES - Resolution Setting (CS + CS1). + // With width=1200, height=1600: first word = width = 1200, second word = height/2 = 800. + this->write_command_(R61_TRES, + {(uint8_t) (this->width_ >> 8), (uint8_t) (this->width_ & 0xFF), + (uint8_t) ((this->height_ / 2) >> 8), (uint8_t) ((this->height_ / 2) & 0xFF)}, + true, true); + delay(10); + + // PWR - Power Setting (CS only) + this->write_command_(R01_PWR, {0x0F, 0x00, 0x28, 0x2C, 0x28, 0x38}, true, false); + delay(10); + + // 0xB6 (CS only) + this->write_command_(0xB6, {0x07}, true, false); + delay(10); + + // BTST_P (CS only) + this->write_command_(R06_BTST_P, {0xE0, 0x20}, true, false); + delay(10); + + // 0xB7 (CS only) + this->write_command_(0xB7, {0x01}, true, false); + delay(10); + + // BTST_N (CS only) + this->write_command_(R05_BTST_N, {0xE0, 0x20}, true, false); + delay(10); + + // 0xB0 (CS only) + this->write_command_(0xB0, {0x01}, true, false); + delay(10); + + // 0xB1 (CS only) + this->write_command_(0xB1, {0x02}, true, false); + delay(10); + + return true; +} + +void EPaperT133A01::write_command_(uint8_t command, const uint8_t *data, size_t length, bool use_cs, bool use_cs1) { + ESP_LOGV(TAG, "Command: 0x%02X, Length: %u, CS: %d, CS1: %d", command, (unsigned) length, use_cs, use_cs1); + // Chip-selects are active-low: assert the requested controllers. + this->cs_pin_->digital_write(!use_cs); + this->cs1_pin_->digital_write(!use_cs1); + this->dc_pin_->digital_write(false); + this->enable(); + this->write_byte(command); + if (length > 0) { + this->dc_pin_->digital_write(true); + this->write_array(data, length); + } + this->disable(); + this->cs_pin_->digital_write(true); + this->cs1_pin_->digital_write(true); +} + +void EPaperT133A01::fill(Color color) { + if (this->get_clipping().is_set()) { + EPaperBase::fill(color); + return; + } + auto pixel_color = color_to_index(color); + this->buffer_.fill(pixel_color + (pixel_color << 4)); +} + +void EPaperT133A01::draw_pixel_at(int x, int y, Color color) { + if (!this->rotate_coordinates_(x, y)) + return; + auto pixel_bits = color_to_index(color); + uint32_t pixel_position = x + y * this->get_width_internal(); + uint32_t byte_position = pixel_position / 2; + auto original = this->buffer_[byte_position]; + if ((pixel_position & 1) != 0) { + this->buffer_[byte_position] = (original & 0xF0) | pixel_bits; + } else { + this->buffer_[byte_position] = (original & 0x0F) | (pixel_bits << 4); + } +} + +void EPaperT133A01::power_on() { + ESP_LOGV(TAG, "Power on"); + this->write_command_(R04_PON, true, true); +} + +void EPaperT133A01::power_off() { + ESP_LOGV(TAG, "Power off"); + this->write_command_(R02_POF, {0x00}, true, true); +} + +void EPaperT133A01::refresh_screen(bool partial) { + ESP_LOGV(TAG, "Refresh screen"); + // Display Refresh + this->write_command_(R12_DRF, {0x01}, true, true); +} + +void EPaperT133A01::deep_sleep() { + ESP_LOGV(TAG, "Deep sleep"); + this->write_command_(0x07, {0xA5}, true, true); +} + +bool HOT EPaperT133A01::transfer_data() { + const uint32_t start_time = millis(); + const uint16_t bytes_per_half_row = this->width_ / 4; + const uint16_t total_rows = this->height_; + const uint16_t bytes_per_row = this->width_ / 2; + uint8_t line_data[400] = {}; + + size_t half = this->current_data_index_; + + // --- CCSET: select color set before data transfer (CS + CS1) --- + if (half == 0) { + this->write_command_(RE0_CCSET, {0x01}, true, true); + this->wait_for_idle_(true); + delay(10); + } + + // --- CS phase: left half of each row via CS --- + // T133A01 requires CS to stay LOW for the ENTIRE DTM data stream. + // Toggling CS between chunks resets the controller's data pointer, + // causing only the last chunk to be retained. Keep CS asserted + // across timeout boundaries by NOT deselecting on yield. + if (half < total_rows) { + if (half == 0) { + this->cs_pin_->digital_write(false); // select CS + this->cs1_pin_->digital_write(true); // deselect CS1 + this->dc_pin_->digital_write(false); + this->enable(); + this->write_byte(R10_DTM); + this->dc_pin_->digital_write(true); + } + + while (half < total_rows) { + size_t buf_offset = half * bytes_per_row; + for (uint16_t col = 0; col < bytes_per_half_row; col++) { + uint8_t b = this->buffer_[buf_offset + col]; + line_data[col] = (remap_color(b >> 4) << 4) | remap_color(b & 0x0F); + } + this->write_array(line_data, bytes_per_half_row); + half++; + this->current_data_index_ = half; + + if (millis() - start_time > MAX_TRANSFER_TIME) { + return false; + } + } + ESP_LOGD(TAG, "CS phase done"); + this->disable(); + this->cs_pin_->digital_write(true); // deselect CS + } + + // --- CS1 phase: right half of each row via CS1 --- + // Same continuous-transaction requirement as the CS phase. + // CS is held HIGH so only CS1 receives the data. + if (half >= total_rows && half < total_rows * 2) { + size_t cs1_row = half - total_rows; + + if (cs1_row == 0) { + this->cs_pin_->digital_write(true); // deselect CS + this->cs1_pin_->digital_write(false); // select CS1 + this->enable(); + this->dc_pin_->digital_write(false); + this->write_byte(R10_DTM); + this->dc_pin_->digital_write(true); + } + + while (half < total_rows * 2) { + size_t row = half - total_rows; + size_t buf_offset = row * bytes_per_row + bytes_per_half_row; + for (uint16_t col = 0; col < bytes_per_half_row; col++) { + uint8_t b = this->buffer_[buf_offset + col]; + line_data[col] = (remap_color(b >> 4) << 4) | remap_color(b & 0x0F); + } + this->write_array(line_data, bytes_per_half_row); + half++; + this->current_data_index_ = half; + + if (millis() - start_time > MAX_TRANSFER_TIME) { + return false; + } + } + ESP_LOGD(TAG, "CS1 phase done"); + this->disable(); + this->cs1_pin_->digital_write(true); // deselect CS1 + } + + this->current_data_index_ = 0; + return true; +} + +void EPaperT133A01::dump_config() { + EPaperBase::dump_config(); + LOG_PIN(" CS Pin: ", this->cs_pin_); + LOG_PIN(" CS1 Pin: ", this->cs1_pin_); +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_t133a01.h b/esphome/components/epaper_spi/epaper_spi_t133a01.h new file mode 100644 index 0000000000..0d07fc03ae --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_t133a01.h @@ -0,0 +1,77 @@ +#pragma once + +#include "epaper_spi.h" + +namespace esphome::epaper_spi { + +/** + * T133A01-based 6-color e-paper display driver. + * + * The T133A01 controller uses a dual-CS SPI architecture: + * - CS (primary): Controls the first half of pixel data transfer + * - CS1 (secondary): Controls panel commands (init, power, refresh) and + * the second half of pixel data transfer + * + * Color depth: 4 bits per pixel, supporting 6 colors: + * White, Green, Red, Yellow, Blue, Black + * + * Buffer layout: 2 pixels per byte (4bpp packed), total buffer size + * is width * height / 2 bytes. + */ +class EPaperT133A01 : public EPaperBase { + public: + EPaperT133A01(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length) + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR) { + this->buffer_length_ = (size_t) width * height / 2; // 2 pixels per byte at 4bpp + } + + void set_cs_pins(GPIOPin *cs, GPIOPin *cs1) { + this->cs_pin_ = cs; + this->cs1_pin_ = cs1; + } + + void fill(Color color) override; + + void setup() override; + void dump_config() override; + void draw_pixel_at(int x, int y, Color color) override; + + protected: + bool reset() override; + bool initialise(bool partial) override; + void refresh_screen(bool partial) override; + void power_on() override; + void power_off() override; + void deep_sleep() override; + + bool transfer_data() override; + + /** + * Send a command (and optional data) selecting one or both controllers. + * Both chip-selects are active-low and managed directly by this driver. + * @param command The command byte to send + * @param data Optional pointer to data bytes to send after the command + * @param length Number of data bytes to send after the command + * @param use_cs assert CS (left controller) for this transaction + * @param use_cs1 assert CS1 (right controller) for this transaction + */ + void write_command_(uint8_t command, const uint8_t *data, size_t length, bool use_cs, bool use_cs1); + void write_command_(uint8_t command, std::initializer_list data, bool use_cs, bool use_cs1) { + this->write_command_(command, data.begin(), data.size(), use_cs, use_cs1); + } + void write_command_(uint8_t command, bool use_cs, bool use_cs1) { + this->write_command_(command, nullptr, 0, use_cs, use_cs1); + } + + /// Convert Color to 4-bit T133A01 color index + static uint8_t color_to_index(Color color); + + /// Apply COLOR_GET remap table to translate sprite indices to hardware values + static uint8_t remap_color(uint8_t index); + + GPIOPin *cs_pin_{nullptr}; + GPIOPin *cs1_pin_{nullptr}; +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/__init__.py b/esphome/components/epaper_spi/models/__init__.py index 3fcf3217ec..2360b090ff 100644 --- a/esphome/components/epaper_spi/models/__init__.py +++ b/esphome/components/epaper_spi/models/__init__.py @@ -2,11 +2,15 @@ from typing import Any, Self import esphome.config_validation as cv from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_WIDTH +from esphome.cpp_generator import MockObj class EpaperModel: models: dict[str, Self] = {} + # Whether the driver manages chip-select itself instead of via the SPI bus. + manages_cs: bool = False + def __init__( self, name: str, @@ -35,6 +39,25 @@ class EpaperModel: def get_constructor_args(self, config) -> tuple: return () + def get_config_options(self) -> dict: + """ + Return model-specific configuration schema options. + The base implementation adds nothing; specific models override this to + declare extra options without cluttering the shared schema. + :return: A mapping suitable for cv.Schema.extend() + """ + return {} + + async def to_code(self, var: MockObj, config: dict) -> dict: + """ + Generate model-specific code for the options added by add_options(). + The base implementation does nothing; specific models override this. + The config can be updated in place to add or remove options. + :param var: The component variable + :param config: The validated configuration + """ + return config + def get_dimensions(self, config) -> tuple[int, int]: if CONF_DIMENSIONS in config: # Explicit dimensions, just use as is diff --git a/esphome/components/epaper_spi/models/t133a01.py b/esphome/components/epaper_spi/models/t133a01.py new file mode 100644 index 0000000000..0a57b95795 --- /dev/null +++ b/esphome/components/epaper_spi/models/t133a01.py @@ -0,0 +1,71 @@ +"""T133A01-based e-paper displays. + +The T133A01 is a 6-color e-paper controller IC that drives large panels +(1200x1600 portrait). It uses a dual-CS SPI architecture where CS +controls one half of the pixel data and CS1 controls the other half, +as well as panel-level commands (power on, refresh, power off). + +Supported models: +- Seeed-reTerminal-E1004: 1200x1600 pixels, 6-color (T133A01 panel) +""" + +from esphome import pins +import esphome.codegen as cg +from esphome.const import CONF_CS_PIN +from esphome.cpp_generator import MockObj + +from . import EpaperModel + +CONF_CS1_PIN = "cs1_pin" + + +class T133A01Model(EpaperModel): + """EpaperModel subclass for T133A01-based 6-color e-paper displays.""" + + # The driver drives CS and CS1 directly for the dual-CS protocol. + manages_cs = True + + def __init__(self, name, class_name="EPaperT133A01", **defaults): + super().__init__(name, class_name, **defaults) + + def get_config_options(self) -> dict: + # CS1 is the second chip-select required by the dual-CS architecture. + # fallback=None makes it required unless the model provides a default. + return { + self.option(CONF_CS1_PIN, fallback=None): pins.gpio_output_pin_schema, + } + + async def to_code(self, var: MockObj, config: dict) -> dict: + cs = await cg.gpio_pin_expression(config[CONF_CS_PIN]) + cs1 = await cg.gpio_pin_expression(config[CONF_CS1_PIN]) + cg.add(var.set_cs_pins(cs, cs1)) + # Remove CS and CS1 from the config so that the base class doesn't try to handle them. + return {k: v for k, v in config.items() if k not in (CONF_CS_PIN, CONF_CS1_PIN)} + + +t133a01_base = T133A01Model( + "t133a01", + minimum_update_interval="30s", + data_rate="10MHz", +) + +# Seeed reTerminal E1004 - 13.3" 6-color e-paper (1200x1600, T133A01) +# Portrait orientation (1200 wide × 1600 tall), matching the Arduino +# Setup523 defines TFT_WIDTH=1200, TFT_HEIGHT=1600. +# CS and CS1 each receive half of each row's pixel data +# (300 bytes = 600 pixels per controller, for all 1600 rows). +Seeed_reTerminal_E1004 = t133a01_base.extend( + "Seeed-reTerminal-E1004", + width=1200, + height=1600, + cs_pin=10, + cs1_pin=2, + dc_pin=11, + reset_pin=38, + busy_pin={ + "number": 13, + "inverted": True, + "mode": {"input": True}, + }, + enable_pin=12, +) diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index c7f34d7dd2..1396c18e3b 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -154,6 +154,10 @@ def test_all_predefined_models( if not model.get_default(CONF_CS_PIN): config[CONF_CS_PIN] = 5 + # Dual-CS models (e.g. T133A01) require a second chip-select pin + if model.manages_cs and not model.get_default("cs1_pin"): + config["cs1_pin"] = 4 + # Select an ESP32 variant on which all of this model's pins are valid # (some models default to high-numbered pins only present on the S3). choose_variant_with_pins(_pins_for(model, config)) @@ -204,6 +208,10 @@ def test_individual_models( if not model.get_default(CONF_CS_PIN): config[CONF_CS_PIN] = 5 + # Dual-CS models (e.g. T133A01) require a second chip-select pin + if model.manages_cs and not model.get_default("cs1_pin"): + config["cs1_pin"] = 4 + # Select an ESP32 variant on which all of this model's pins are valid # (some models default to high-numbered pins only present on the S3). choose_variant_with_pins(_pins_for(model, config)) diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index bb771f2132..fb43b06567 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -76,6 +76,14 @@ display: - platform: epaper_spi model: seeed-reterminal-e1002 + - platform: epaper_spi + model: seeed-reterminal-e1004 + cs_pin: 33 + cs1_pin: 34 + dc_pin: 35 + reset_pin: 36 + busy_pin: 37 + enable_pin: 39 - platform: epaper_spi model: seeed-ee04-mono-4.26 full_update_every: 10 diff --git a/tests/components/epaper_spi/validate-e1004.esp32-s3-idf.yaml b/tests/components/epaper_spi/validate-e1004.esp32-s3-idf.yaml new file mode 100644 index 0000000000..27066710e0 --- /dev/null +++ b/tests/components/epaper_spi/validate-e1004.esp32-s3-idf.yaml @@ -0,0 +1,38 @@ +esphome: + name: e1004-test + friendly_name: E1004 Test + +esp32: + board: esp32-s3-devkitc-1 + variant: esp32s3 + framework: + type: esp-idf + +psram: + mode: octal + +spi: + - id: epaper_spi_bus + clk_pin: GPIO7 + mosi_pin: GPIO9 + +display: + - platform: epaper_spi + spi_id: epaper_spi_bus + model: seeed-reterminal-e1004 + update_interval: never + lambda: |- + it.fill(Color::WHITE); + it.rectangle(10, 10, it.get_width() - 20, it.get_height() - 20, Color::BLACK); + it.print(it.get_width() / 2, it.get_height() / 2, id(my_font), Color::BLACK, TextAlign::CENTER, "E1004 Test"); + it.circle(100, 100, 30, Color(255, 0, 0)); + it.circle(200, 100, 30, Color(0, 255, 0)); + it.circle(300, 100, 30, Color(0, 0, 255)); + it.circle(400, 100, 30, Color(255, 255, 0)); + +font: + - file: "gfonts://Roboto" + id: my_font + size: 20 + +logger: From 4c8e45a222cbc505b0f102541dcbd091ef3918ae Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 23:17:49 -0500 Subject: [PATCH 0725/1815] Bump bundled esphome-device-builder to 1.0.28 (#17382) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 9dec23db1b..9367831a4c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.27 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.28 RUN \ platformio settings set enable_telemetry No \ From fcfaa43e1eb9662179e12c375a57dda0959440d4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:46:16 -0400 Subject: [PATCH 0726/1815] [ci] Name the sdk-nrf cache steps after the nRF Connect SDK (#17392) --- .github/actions/cache-sdk-nrf/action.yml | 6 +++--- .github/workflows/ci.yml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/actions/cache-sdk-nrf/action.yml b/.github/actions/cache-sdk-nrf/action.yml index 71c09bfe14..6cbb87cc66 100644 --- a/.github/actions/cache-sdk-nrf/action.yml +++ b/.github/actions/cache-sdk-nrf/action.yml @@ -1,4 +1,4 @@ -name: Cache sdk-nrf +name: Cache nRF Connect SDK description: > Resolve the pinned sdk-nrf version and cache the native sdk-nrf install (west workspace, Zephyr SDK toolchain, python env) at ~/.esphome-sdk-nrf. @@ -33,14 +33,14 @@ runs: # Mirror cache-esp-idf: only dev-branch runs write the shared cache (so it # lives in the default-branch scope readable by all PRs); PRs are # restore-only and never push multi-GB artifacts into their own scope. - - name: Cache sdk-nrf install (write on dev) + - name: Cache nRF Connect SDK install (write on dev) if: github.ref == 'refs/heads/dev' && inputs.restore-only != 'true' uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.esphome-sdk-nrf # yamllint disable-line rule:line-length key: ${{ runner.os }}-esphome-sdk-nrf-${{ steps.version.outputs.version }}-${{ hashFiles('esphome/components/nrf52/requirements.txt') }} - - name: Cache sdk-nrf install (restore-only off dev) + - name: Cache nRF Connect SDK install (restore-only off dev) if: github.ref != 'refs/heads/dev' || inputs.restore-only == 'true' uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index caf6453c1b..34f8ed4878 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -529,7 +529,7 @@ jobs: with: framework: arduino - - name: Cache sdk-nrf install + - name: Cache nRF Connect SDK install if: matrix.cache_sdk_nrf uses: ./.github/actions/cache-sdk-nrf @@ -849,7 +849,7 @@ jobs: uses: ./.github/actions/cache-esp-idf with: restore-only: true - - name: Cache sdk-nrf install (restore-only) + - name: Cache nRF Connect SDK install (restore-only) # Only batches whose test platforms include nrf52 need the native # sdk-nrf install; never save -- just reuse the shared install the # dev nrf52 tidy job cached when present. From 2c24e82ba3ea058a1a5a3752c213f64cb62f6163 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:20:48 -0500 Subject: [PATCH 0727/1815] Bump bundled esphome-device-builder to 1.0.29 (#17384) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 9367831a4c..b325a42436 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.28 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.29 RUN \ platformio settings set enable_telemetry No \ From 8ab19c0242a8af2eb3c3d8b04bfbffa33986a9c9 Mon Sep 17 00:00:00 2001 From: Chris Boot Date: Sun, 5 Jul 2026 02:48:30 +0100 Subject: [PATCH 0728/1815] [esp32] Add RTC-backed preferences (honor in_flash flag) (#17073) Co-authored-by: Claude Opus 4.8 --- esphome/components/esp32/preference_backend.h | 7 +- esphome/components/esp32/preferences.cpp | 103 ++++++++++++ esphome/components/esp32/preferences.h | 21 ++- esphome/components/esp8266/preferences.cpp | 28 +--- esphome/components/preferences/__init__.py | 10 ++ esphome/components/safe_mode/__init__.py | 5 +- esphome/components/safe_mode/safe_mode.cpp | 6 +- esphome/components/safe_mode/safe_mode.h | 2 +- esphome/components/wifi/__init__.py | 31 +++- esphome/components/wifi/wifi_component.cpp | 8 +- esphome/const.py | 1 + esphome/core/defines.h | 2 + esphome/core/preferences_rtc.h | 54 +++++++ esphome/preferences.py | 106 +++++++++++++ script/ci-custom.py | 2 +- .../validate-rtc-storage.esp32-idf.yaml | 4 + .../safe_mode/test-rtc.esp32-idf.yaml | 4 + ...lidate-fast-connect-storage.esp32-idf.yaml | 7 + ...date-fast-connect-storage.esp8266-ard.yaml | 7 + tests/unit_tests/test_preferences.py | 149 ++++++++++++++++++ 20 files changed, 520 insertions(+), 37 deletions(-) create mode 100644 esphome/core/preferences_rtc.h create mode 100644 esphome/preferences.py create mode 100644 tests/components/preferences/validate-rtc-storage.esp32-idf.yaml create mode 100644 tests/components/safe_mode/test-rtc.esp32-idf.yaml create mode 100644 tests/components/wifi/validate-fast-connect-storage.esp32-idf.yaml create mode 100644 tests/components/wifi/validate-fast-connect-storage.esp8266-ard.yaml create mode 100644 tests/unit_tests/test_preferences.py diff --git a/esphome/components/esp32/preference_backend.h b/esphome/components/esp32/preference_backend.h index 893bc35f0c..b0771b3128 100644 --- a/esphome/components/esp32/preference_backend.h +++ b/esphome/components/esp32/preference_backend.h @@ -11,8 +11,11 @@ class ESP32PreferenceBackend final { bool save(const uint8_t *data, size_t len); bool load(uint8_t *data, size_t len); - uint32_t key; - uint32_t nvs_handle; + uint32_t key{0}; + uint32_t nvs_handle{0}; // NVS (flash) path + uint16_t rtc_offset{0}; // RTC path: word offset into the RTC storage region + uint8_t length_words{0}; // RTC path: data length in 32-bit words + bool in_flash{true}; // true: store in NVS (flash); false: store in RTC memory }; class ESP32Preferences; diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index 09835385ac..dc2b40455c 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -3,7 +3,10 @@ #include "preferences.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/preferences_rtc.h" +#include #include +#include #include #include @@ -18,6 +21,48 @@ struct NVSData { static std::vector s_pending_save; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +// RTC memory backend for preferences requested with in_flash=false. Survives deep sleep and +// software/CPU resets, but not power loss; integrity is guarded by a per-record checksum so +// power-on garbage is detected on load. Keep this small: RTC memory is scarce and shared. +// +// Only compiled in when USE_ESP32_RTC_PREFERENCES_STORAGE is set (see preferences.h): the storage +// buffer reserves RTC memory, so it exists only when some config option actually selected RTC +// storage AND the variant has RTC memory (the ESP32-C2 and -C61 have none, so RTC_NOINIT_ATTR would +// have no section to land in and fail to link). Otherwise in_flash=false transparently falls back +// to NVS (see make_preference below). +// +// On variants with only RTC fast memory (C3/C6/H2/P4/C5/...) RTC_NOINIT_ATTR lands in RTC fast memory. +// This is still safe: the linker reserves .rtc_noinit ahead of any RTC-fast-as-heap pool +// (CONFIG_ESP_SYSTEM_ALLOW_RTC_FAST_MEM_AS_HEAP), and IDF keeps the RTC fast power domain on in deep +// sleep (forced on whether or not it is used as heap), so the data is retained across both resets and +// deep sleep -- only power loss clears it. +#ifdef USE_ESP32_RTC_PREFERENCES_STORAGE +static constexpr size_t RTC_PREF_SIZE_WORDS = 64; // 256 bytes +static constexpr size_t RTC_PREF_MAX_WORDS = 255; // length_words field is a uint8_t + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +static RTC_NOINIT_ATTR uint32_t s_rtc_storage[RTC_PREF_SIZE_WORDS]; + +static bool save_to_rtc(uint16_t offset, uint32_t key, uint8_t length_words, const uint8_t *data, size_t len) { + if (rtc_pref_bytes_to_words(len) != length_words) + return false; + const size_t buffer_size = static_cast(length_words) + 1; + if (static_cast(offset) + buffer_size > RTC_PREF_SIZE_WORDS) + return false; + rtc_pref_encode(&s_rtc_storage[offset], key, length_words, data, len); + return true; +} + +static bool load_from_rtc(uint16_t offset, uint32_t key, uint8_t length_words, uint8_t *data, size_t len) { + if (rtc_pref_bytes_to_words(len) != length_words) + return false; + const size_t buffer_size = static_cast(length_words) + 1; + if (static_cast(offset) + buffer_size > RTC_PREF_SIZE_WORDS) + return false; + return rtc_pref_decode(&s_rtc_storage[offset], key, length_words, data, len); +} +#endif // USE_ESP32_RTC_PREFERENCES_STORAGE + // 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 @@ -25,6 +70,10 @@ static std::vector s_pending_save; // NOLINT(cppcoreguidelines-avoid-n 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) { +#ifdef USE_ESP32_RTC_PREFERENCES_STORAGE + if (!this->in_flash) + return save_to_rtc(this->rtc_offset, this->key, this->length_words, data, len); +#endif // try find in pending saves and update that for (auto &obj : s_pending_save) { if (obj.key == this->key) { @@ -41,6 +90,10 @@ bool ESP32PreferenceBackend::save(const uint8_t *data, size_t len) { } bool ESP32PreferenceBackend::load(uint8_t *data, size_t len) { +#ifdef USE_ESP32_RTC_PREFERENCES_STORAGE + if (!this->in_flash) + return load_from_rtc(this->rtc_offset, this->key, this->length_words, data, len); +#endif // try find in pending saves and load from that for (auto &obj : s_pending_save) { if (obj.key == this->key) { @@ -94,6 +147,26 @@ void ESP32Preferences::open() { } } +ESPPreferenceObject ESP32Preferences::make_preference(size_t length, uint32_t type, bool in_flash) { +#ifdef USE_ESP32_RTC_PREFERENCES_STORAGE + if (!in_flash) + return this->make_rtc_preference_(length, type); +#else + if (!in_flash) { + // RTC storage is not compiled in (no config option selected it), so this request + // falls back to NVS -- the historic ESP32 behavior. Warn once so callers explicitly + // asking for RTC storage can discover the fallback. + static bool warned = false; + if (!warned) { + ESP_LOGW(TAG, "RTC preference storage not compiled in; using NVS (enable with 'preferences: rtc_storage: true')"); + warned = true; + } + } +#endif + // in_flash, or RTC storage not compiled in: fall back to NVS. + return this->make_preference(length, type); +} + ESPPreferenceObject ESP32Preferences::make_preference(size_t length, uint32_t type) { if (s_open_err != ESP_OK) { if (this->nvs_handle == 0) { @@ -106,10 +179,34 @@ ESPPreferenceObject ESP32Preferences::make_preference(size_t length, uint32_t ty auto *pref = new ESP32PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) pref->nvs_handle = this->nvs_handle; pref->key = type; + pref->in_flash = true; return ESPPreferenceObject(pref); } +#ifdef USE_ESP32_RTC_PREFERENCES_STORAGE +ESPPreferenceObject ESP32Preferences::make_rtc_preference_(size_t length, uint32_t type) { + const uint32_t length_words = rtc_pref_bytes_to_words(length); + if (length_words > RTC_PREF_MAX_WORDS) { + ESP_LOGE(TAG, "RTC preference too large: %" PRIu32 " words", length_words); + return {}; + } + const uint32_t total_words = length_words + 1; // +1 for checksum + if (static_cast(this->current_rtc_offset_) + total_words > RTC_PREF_SIZE_WORDS) { + ESP_LOGE(TAG, "RTC preference storage full, cannot allocate %" PRIu32 " words", total_words); + return {}; + } + auto *pref = new ESP32PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) + pref->key = type; + pref->in_flash = false; + pref->rtc_offset = this->current_rtc_offset_; + pref->length_words = static_cast(length_words); + this->current_rtc_offset_ += static_cast(total_words); + + return ESPPreferenceObject(pref); +} +#endif // USE_ESP32_RTC_PREFERENCES_STORAGE + bool ESP32Preferences::sync() { if (s_pending_save.empty()) return true; @@ -186,6 +283,12 @@ bool ESP32Preferences::is_changed_(uint32_t nvs_handle, const NVSData &to_save, bool ESP32Preferences::reset() { ESP_LOGD(TAG, "Erasing storage"); s_pending_save.clear(); +#ifdef USE_ESP32_RTC_PREFERENCES_STORAGE + // Invalidate RTC-backed preferences too (checksum will no longer match). current_rtc_offset_ is + // deliberately left alone: existing backends keep pointing at their allocated slots, and reset() + // is always followed by a restart (same reason nvs_handle is zeroed below). + memset(s_rtc_storage, 0, sizeof(s_rtc_storage)); +#endif nvs_flash_deinit(); nvs_flash_erase(); diff --git a/esphome/components/esp32/preferences.h b/esphome/components/esp32/preferences.h index 0e187d87a9..864d22312b 100644 --- a/esphome/components/esp32/preferences.h +++ b/esphome/components/esp32/preferences.h @@ -2,6 +2,15 @@ #ifdef USE_ESP32 #include "esphome/core/preference_backend.h" +#include + +// RTC-backed preference storage is compiled in only when a config option actually selects it +// (USE_ESP32_RTC_PREFERENCES, emitted during code generation) and the variant has RTC memory +// (SOC_RTC_MEM_SUPPORTED; the ESP32-C2 and -C61 have none). Otherwise in_flash=false falls +// back to NVS and no RTC memory is reserved. +#if defined(USE_ESP32_RTC_PREFERENCES) && SOC_RTC_MEM_SUPPORTED +#define USE_ESP32_RTC_PREFERENCES_STORAGE +#endif namespace esphome::esp32 { @@ -11,9 +20,8 @@ class ESP32Preferences final : public PreferencesMixin { public: using PreferencesMixin::make_preference; void open(); - ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash) { - return this->make_preference(length, type); - } + ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash); + // Two-argument form defaults to NVS (flash) storage, preserving historic ESP32 behavior. ESPPreferenceObject make_preference(size_t length, uint32_t type); bool sync(); bool reset(); @@ -22,6 +30,13 @@ class ESP32Preferences final : public PreferencesMixin { protected: bool is_changed_(uint32_t nvs_handle, const NVSData &to_save, const char *key_str); + +#ifdef USE_ESP32_RTC_PREFERENCES_STORAGE + // RTC-backed storage (in_flash=false). + ESPPreferenceObject make_rtc_preference_(size_t length, uint32_t type); + // Next free word offset in the RTC storage region (bump allocated in make_preference order). + uint16_t current_rtc_offset_{0}; +#endif }; void setup_preferences(); diff --git a/esphome/components/esp8266/preferences.cpp b/esphome/components/esp8266/preferences.cpp index 696f83bce1..d954ae4a0f 100644 --- a/esphome/components/esp8266/preferences.cpp +++ b/esphome/components/esp8266/preferences.cpp @@ -8,6 +8,7 @@ extern "C" { #include "preferences.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/preferences_rtc.h" #include @@ -80,16 +81,6 @@ static uint32_t get_esp8266_flash_sector() { } static uint32_t get_esp8266_flash_address() { return get_esp8266_flash_sector() * SPI_FLASH_SEC_SIZE; } -static inline size_t bytes_to_words(size_t bytes) { return (bytes + 3) / 4; } - -template uint32_t calculate_crc(It first, It last, uint32_t type) { - uint32_t crc = type; - while (first != last) { - crc ^= (*first++ * 2654435769UL) >> 1; - } - return crc; -} - static bool save_to_flash(size_t offset, const uint32_t *data, size_t len) { for (uint32_t i = 0; i < len; i++) { uint32_t j = offset + i; @@ -137,21 +128,19 @@ static constexpr size_t PREF_MAX_BUFFER_WORDS = ESP8266_FLASH_STORAGE_SIZE > RTC_NORMAL_REGION_WORDS ? ESP8266_FLASH_STORAGE_SIZE : RTC_NORMAL_REGION_WORDS; bool ESP8266PreferenceBackend::save(const uint8_t *data, size_t len) { - if (bytes_to_words(len) != this->length_words) + if (rtc_pref_bytes_to_words(len) != this->length_words) return false; const size_t buffer_size = static_cast(this->length_words) + 1; if (buffer_size > PREF_MAX_BUFFER_WORDS) return false; uint32_t buffer[PREF_MAX_BUFFER_WORDS]; - memset(buffer, 0, buffer_size * sizeof(uint32_t)); - memcpy(buffer, data, len); - buffer[this->length_words] = calculate_crc(buffer, buffer + this->length_words, this->type); + rtc_pref_encode(buffer, this->type, this->length_words, data, len); return this->in_flash ? save_to_flash(this->offset, buffer, buffer_size) : save_to_rtc(this->offset, buffer, buffer_size); } bool ESP8266PreferenceBackend::load(uint8_t *data, size_t len) { - if (bytes_to_words(len) != this->length_words) + if (rtc_pref_bytes_to_words(len) != this->length_words) return false; const size_t buffer_size = static_cast(this->length_words) + 1; if (buffer_size > PREF_MAX_BUFFER_WORDS) @@ -161,10 +150,7 @@ bool ESP8266PreferenceBackend::load(uint8_t *data, size_t len) { : load_from_rtc(this->offset, buffer, buffer_size); if (!ret) return false; - if (buffer[this->length_words] != calculate_crc(buffer, buffer + this->length_words, this->type)) - return false; - memcpy(data, buffer, len); - return true; + return rtc_pref_decode(buffer, this->type, this->length_words, data, len); } void ESP8266Preferences::setup() { @@ -177,13 +163,13 @@ void ESP8266Preferences::setup() { } ESPPreferenceObject ESP8266Preferences::make_preference(size_t length, uint32_t type, bool in_flash) { - const uint32_t length_words = bytes_to_words(length); + const uint32_t length_words = rtc_pref_bytes_to_words(length); if (length_words > MAX_PREFERENCE_WORDS) { ESP_LOGE(TAG, "Preference too large: %u words", static_cast(length_words)); return {}; } - const uint32_t total_words = length_words + 1; // +1 for CRC + const uint32_t total_words = length_words + 1; // +1 for checksum uint16_t offset; if (in_flash) { diff --git a/esphome/components/preferences/__init__.py b/esphome/components/preferences/__init__.py index c426872728..f3f2f632c9 100644 --- a/esphome/components/preferences/__init__.py +++ b/esphome/components/preferences/__init__.py @@ -1,3 +1,4 @@ +from esphome import preferences import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID @@ -10,10 +11,17 @@ preferences_ns = cg.esphome_ns.namespace("preferences") IntervalSyncer = preferences_ns.class_("IntervalSyncer", cg.Component) CONF_FLASH_WRITE_INTERVAL = "flash_write_interval" +CONF_RTC_STORAGE = "rtc_storage" CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(IntervalSyncer), cv.Optional(CONF_FLASH_WRITE_INTERVAL, default="60s"): cv.update_interval, + # Compile the RTC-backed storage into the ESP32 preferences backend even + # when no other option selects it, so components (including external + # ones) requesting in_flash=false are honoured instead of falling back + # to NVS. No default: absence means "no request" (see + # preferences.validate_rtc_storage for the per-platform rules). + cv.Optional(CONF_RTC_STORAGE): preferences.validate_rtc_storage, } ).extend(cv.COMPONENT_SCHEMA) @@ -26,4 +34,6 @@ async def to_code(config): cg.add_define("USE_PREFERENCES_SYNC_EVERY_LOOP") else: cg.add(var.set_write_interval(write_interval)) + if config.get(CONF_RTC_STORAGE): + preferences.request_rtc_storage() await cg.register_component(var, config) diff --git a/esphome/components/safe_mode/__init__.py b/esphome/components/safe_mode/__init__.py index 578376258a..c11447e604 100644 --- a/esphome/components/safe_mode/__init__.py +++ b/esphome/components/safe_mode/__init__.py @@ -1,4 +1,4 @@ -from esphome import automation +from esphome import automation, preferences import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import ( @@ -7,6 +7,7 @@ from esphome.const import ( CONF_NUM_ATTEMPTS, CONF_REBOOT_TIMEOUT, CONF_SAFE_MODE, + CONF_STORAGE, KEY_PAST_SAFE_MODE, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority @@ -42,6 +43,7 @@ CONFIG_SCHEMA = cv.All( CONF_REBOOT_TIMEOUT, default="5min" ): cv.positive_time_period_milliseconds, cv.Optional(CONF_ON_SAFE_MODE): automation.validate_automation({}), + **preferences.storage_schema(), } ).extend(cv.COMPONENT_SCHEMA), _remove_id_if_disabled, @@ -87,6 +89,7 @@ async def to_code(config): config[CONF_NUM_ATTEMPTS], config[CONF_REBOOT_TIMEOUT], config[CONF_BOOT_IS_GOOD_AFTER], + preferences.is_in_flash(config[CONF_STORAGE]), ) cg.add(RawExpression(f"if ({condition}) return")) diff --git a/esphome/components/safe_mode/safe_mode.cpp b/esphome/components/safe_mode/safe_mode.cpp index 5c0047dca0..2eb1085ee5 100644 --- a/esphome/components/safe_mode/safe_mode.cpp +++ b/esphome/components/safe_mode/safe_mode.cpp @@ -162,13 +162,13 @@ bool SafeModeComponent::get_safe_mode_pending() { return this->read_rtc_() == SafeModeComponent::ENTER_SAFE_MODE_MAGIC; } -bool SafeModeComponent::should_enter_safe_mode(uint8_t num_attempts, uint32_t enable_time, - uint32_t boot_is_good_after) { +bool SafeModeComponent::should_enter_safe_mode(uint8_t num_attempts, uint32_t enable_time, uint32_t boot_is_good_after, + bool in_flash) { this->safe_mode_start_time_ = millis(); this->safe_mode_enable_time_ = enable_time; this->safe_mode_boot_is_good_after_ = boot_is_good_after; this->safe_mode_num_attempts_ = num_attempts; - this->rtc_ = global_preferences->make_preference(RTC_KEY, false); + this->rtc_ = global_preferences->make_preference(RTC_KEY, in_flash); #if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK) // Check partition state to detect if bootloader supports rollback diff --git a/esphome/components/safe_mode/safe_mode.h b/esphome/components/safe_mode/safe_mode.h index 94db4357eb..d81b8a42d1 100644 --- a/esphome/components/safe_mode/safe_mode.h +++ b/esphome/components/safe_mode/safe_mode.h @@ -17,7 +17,7 @@ constexpr uint32_t RTC_KEY = 233825507UL; /// SafeModeComponent provides a safe way to recover from repeated boot failures class SafeModeComponent final : public Component { public: - bool should_enter_safe_mode(uint8_t num_attempts, uint32_t enable_time, uint32_t boot_is_good_after); + bool should_enter_safe_mode(uint8_t num_attempts, uint32_t enable_time, uint32_t boot_is_good_after, bool in_flash); /// Set to true if the next startup will enter safe mode void set_safe_mode_pending(const bool &pending); diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 512fd63e12..111f4cfc84 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -1,10 +1,10 @@ import logging import math -from esphome import automation +from esphome import automation, preferences from esphome.automation import Condition import esphome.codegen as cg -from esphome.components.const import CONF_USE_PSRAM +from esphome.components.const import CONF_ENABLED, CONF_USE_PSRAM from esphome.components.esp32 import ( add_idf_sdkconfig_option, const, @@ -50,6 +50,7 @@ from esphome.const import ( CONF_REBOOT_TIMEOUT, CONF_SSID, CONF_STATIC_IP, + CONF_STORAGE, CONF_SUBNET, CONF_TIMEOUT, CONF_TTLS_PHASE_2, @@ -434,6 +435,22 @@ def _validate(config): CONF_PASSIVE_SCAN = "passive_scan" + +FAST_CONNECT_SCHEMA = cv.Schema( + { + cv.Optional(CONF_ENABLED, default=True): cv.boolean, + **preferences.storage_schema(), + } +) + + +def _fast_connect_schema(value): + """Accept the historic plain boolean or a dict with enabled/storage keys.""" + if isinstance(value, bool): + value = {CONF_ENABLED: value} + return FAST_CONNECT_SCHEMA(value) + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -459,7 +476,7 @@ CONFIG_SCHEMA = cv.All( rtl87xx="none", ln882x="light", ): cv.enum(WIFI_POWER_SAVE_MODES, upper=True), - cv.Optional(CONF_FAST_CONNECT, default=False): cv.boolean, + cv.Optional(CONF_FAST_CONNECT, default=False): _fast_connect_schema, cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_MIN_AUTH_MODE): cv.All( VALIDATE_WIFI_MIN_AUTH_MODE, @@ -619,8 +636,14 @@ async def to_code(config): cg.add(var.set_power_save_mode(config[CONF_POWER_SAVE_MODE])) if CONF_MIN_AUTH_MODE in config: cg.add(var.set_min_auth_mode(config[CONF_MIN_AUTH_MODE])) - if config[CONF_FAST_CONNECT]: + fast_connect = config[CONF_FAST_CONNECT] + if fast_connect[CONF_ENABLED]: cg.add_define("USE_WIFI_FAST_CONNECT") + # The storage default preserves this preference's historic location: + # ESP8266 has always used RTC memory; every other platform effectively + # used flash (the in_flash flag was previously ignored outside ESP8266). + if preferences.is_in_flash(fast_connect[CONF_STORAGE]): + cg.add_define("USE_WIFI_FAST_CONNECT_IN_FLASH") # passive_scan defaults to false in C++ - only set if true if config[CONF_PASSIVE_SCAN]: cg.add(var.set_passive_scan(True)) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index ffc6ea8e14..2f6bec6bb2 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -649,7 +649,13 @@ void WiFiComponent::start() { this->pref_ = global_preferences->make_preference(hash, true); #ifdef USE_WIFI_FAST_CONNECT - this->fast_connect_pref_ = global_preferences->make_preference(hash + 1, false); +#ifdef USE_WIFI_FAST_CONNECT_IN_FLASH + const bool fast_connect_in_flash = true; +#else + const bool fast_connect_in_flash = false; +#endif + this->fast_connect_pref_ = + global_preferences->make_preference(hash + 1, fast_connect_in_flash); #endif SavedWifiSettings save{}; diff --git a/esphome/const.py b/esphome/const.py index 331eb5011d..24bb4ea31f 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -976,6 +976,7 @@ CONF_STEP_PIN = "step_pin" CONF_STILL_THRESHOLD = "still_threshold" CONF_STOP = "stop" CONF_STOP_ACTION = "stop_action" +CONF_STORAGE = "storage" CONF_STORE_BASELINE = "store_baseline" CONF_SUBNET = "subnet" CONF_SUBSCRIBE_QOS = "subscribe_qos" diff --git a/esphome/core/defines.h b/esphome/core/defines.h index ff4bccc693..987e2d7a2a 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -240,6 +240,7 @@ #define USE_OTA_ROLLBACK #define USE_OTA_SIGNED_VERIFICATION #define USE_ESP32_MIN_CHIP_REVISION_SET +#define USE_ESP32_RTC_PREFERENCES #define USE_ESP32_SRAM1_AS_IRAM #define USE_BLUETOOTH_PROXY @@ -300,6 +301,7 @@ #define USE_CAPTIVE_PORTAL_GZIP #define USE_WIFI_11KV_SUPPORT #define USE_WIFI_FAST_CONNECT +#define USE_WIFI_FAST_CONNECT_IN_FLASH #define USE_WIFI_PHY_MODE #define USE_WIFI_IP_STATE_LISTENERS #define USE_WIFI_SCAN_RESULTS_LISTENERS diff --git a/esphome/core/preferences_rtc.h b/esphome/core/preferences_rtc.h new file mode 100644 index 0000000000..30b004f994 --- /dev/null +++ b/esphome/core/preferences_rtc.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include + +namespace esphome { + +// Shared storage format for word-addressable preference backends. +// +// Several platforms persist preferences as a buffer of 32-bit words followed by a +// single checksum word, seeded with the preference's `type` (its hashed key). This +// format is used for RTC user memory (ESP8266, ESP32) and for the ESP8266 +// flash-emulation buffer. The helpers here are platform independent; each backend +// supplies its own word read/write primitives and offset allocation. + +/// Round a byte count up to whole 32-bit words. +inline size_t rtc_pref_bytes_to_words(size_t bytes) { return (bytes + 3) / 4; } + +/// Compute the integrity checksum over [first, last), seeded with `type`. +/// Iterates over 32-bit words; the result is stored as the trailing word of a record. +/// (Not a true CRC -- it XORs each word after a Fibonacci-hash multiply -- but the +/// algorithm is kept as-is for compatibility with records written by old firmware.) +template uint32_t rtc_pref_calculate_checksum(It first, It last, uint32_t type) { + uint32_t checksum = type; + while (first != last) { + // UINT32_C keeps the multiply wrapping at 32 bits regardless of the width of + // unsigned long, so 64-bit host builds compute the same value as the devices. + checksum ^= (*first++ * UINT32_C(2654435769)) >> 1; + } + return checksum; +} + +/// Encode `len` data bytes into `buffer` (length_words data words + 1 trailing checksum word). +/// `buffer` must have capacity for at least `length_words + 1` words. Trailing padding in +/// the final data word is zeroed so the checksum is deterministic. +inline void rtc_pref_encode(uint32_t *buffer, uint32_t type, uint8_t length_words, const uint8_t *data, size_t len) { + memset(buffer, 0, (static_cast(length_words) + 1) * sizeof(uint32_t)); + memcpy(buffer, data, len); + buffer[length_words] = rtc_pref_calculate_checksum(buffer, buffer + length_words, type); +} + +/// Verify the checksum of a record held in `buffer` (length_words data words + 1 checksum +/// word) and, on success, copy `len` bytes out to `data`. Returns false on checksum mismatch +/// (e.g. the record was never written or RTC memory holds power-on garbage). +inline bool rtc_pref_decode(const uint32_t *buffer, uint32_t type, uint8_t length_words, uint8_t *data, size_t len) { + if (buffer[length_words] != rtc_pref_calculate_checksum(buffer, buffer + length_words, type)) { + return false; + } + memcpy(data, buffer, len); + return true; +} + +} // namespace esphome diff --git a/esphome/preferences.py b/esphome/preferences.py new file mode 100644 index 0000000000..fce8519130 --- /dev/null +++ b/esphome/preferences.py @@ -0,0 +1,106 @@ +"""Helpers for letting a component choose where a preference is persisted. + +Preferences can be stored either in flash (durable across power loss) or in RTC +memory (fast, survives deep sleep and soft resets but not power loss). The +flash-vs-RTC choice is only meaningful on platforms whose preferences backend +honors the ``in_flash`` flag — currently ESP32 and ESP8266. On other platforms +the value is accepted only as ``flash`` (the sole supported backend). + +Components include :func:`storage_schema` in their config and convert the chosen +value with :func:`is_in_flash` when calling ``make_preference``. +""" + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_STORAGE +from esphome.core import CORE + +STORAGE_FLASH = "flash" +STORAGE_RTC = "rtc" + + +def _rtc_supported() -> bool: + """Whether the active platform has an RTC-backed preferences backend. + + Mirrors the C++ ``SOC_RTC_MEM_SUPPORTED`` guard in the ESP32 backend: the ESP32-C2 + and -C61 have no RTC memory at all, so RTC storage is unavailable there. + """ + if CORE.is_esp8266: + return True + if CORE.is_esp32: + from esphome.components.esp32 import get_esp32_variant + from esphome.components.esp32.const import VARIANT_ESP32C2, VARIANT_ESP32C61 + + return get_esp32_variant() not in (VARIANT_ESP32C2, VARIANT_ESP32C61) + return False + + +def _default_storage() -> str: + """Default that preserves each platform's historic behavior. + + ESP8266 has always stored these preferences in RTC memory; every other + platform effectively used flash. Evaluated at validation time. + """ + return STORAGE_RTC if CORE.is_esp8266 else STORAGE_FLASH + + +def _validate_storage(value): + value = cv.one_of(STORAGE_FLASH, STORAGE_RTC, lower=True)(value) + if value == STORAGE_RTC and not _rtc_supported(): + raise cv.Invalid( + f"'{STORAGE_RTC}' storage is not supported on this platform; only " + f"'{STORAGE_FLASH}' is available" + ) + return value + + +def storage_schema(): + """Return an Optional(CONF_STORAGE) entry for merging into a component schema.""" + return {cv.Optional(CONF_STORAGE, default=_default_storage): _validate_storage} + + +def request_rtc_storage() -> None: + """Compile the RTC-backed storage into the ESP32 preferences backend. + + The RTC storage region is left out of ESP32 builds unless something asks for + it, so unused builds don't reserve RTC memory. Call this from ``to_code`` + when a config option selects RTC storage. No-op on other platforms (ESP8266 + always has its RTC backend). + """ + if CORE.is_esp32: + cg.add_define("USE_ESP32_RTC_PREFERENCES") + + +def validate_rtc_storage(value): + """Validate a boolean option that requests RTC-backed preference storage. + + ``false`` means "no request", not "disable": it never turns RTC storage off + (another option selecting ``storage: rtc`` still compiles it in). On ESP8266 + the backend is integral and always enabled, so ``false`` is rejected rather + than silently ignored; ``true`` is a tolerated no-op there so shared config + packages work across mixed fleets. + """ + value = cv.boolean(value) + if not value: + if CORE.is_esp8266: + raise cv.Invalid( + "RTC preference storage is always enabled on ESP8266 and cannot " + "be disabled" + ) + return value + if not _rtc_supported(): + raise cv.Invalid("RTC preference storage is not supported on this platform") + return value + + +def is_in_flash(value: str) -> bool: + """Map a CONF_STORAGE value to the ``in_flash`` argument of make_preference. + + Call this from ``to_code``: when RTC storage is selected on ESP32 it also emits + the define that compiles the RTC storage buffer into the ESP32 backend (see + :func:`request_rtc_storage`). + """ + in_flash = value == STORAGE_FLASH + if not in_flash: + request_rtc_storage() + return in_flash diff --git a/script/ci-custom.py b/script/ci-custom.py index 4568732b88..75f4d71ba4 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -555,7 +555,7 @@ def lint_constants_usage(): # Maximum allowed CONF_ constants in esphome/const.py. # This file is frozen — new constants go in esphome/components/const/__init__.py. # Decrease this number when constants are moved out of const.py. -CONST_PY_MAX_CONF = 1014 +CONST_PY_MAX_CONF = 1015 @lint_content_check(include=["esphome/const.py"]) diff --git a/tests/components/preferences/validate-rtc-storage.esp32-idf.yaml b/tests/components/preferences/validate-rtc-storage.esp32-idf.yaml new file mode 100644 index 0000000000..1808e09f5d --- /dev/null +++ b/tests/components/preferences/validate-rtc-storage.esp32-idf.yaml @@ -0,0 +1,4 @@ +# Exercises the opt-in that compiles the RTC-backed preference storage into +# the ESP32 backend without any other option selecting it. +preferences: + rtc_storage: true diff --git a/tests/components/safe_mode/test-rtc.esp32-idf.yaml b/tests/components/safe_mode/test-rtc.esp32-idf.yaml new file mode 100644 index 0000000000..113a2b6ab5 --- /dev/null +++ b/tests/components/safe_mode/test-rtc.esp32-idf.yaml @@ -0,0 +1,4 @@ +# Exercises the ESP32 RTC-backed preferences path (storage: rtc) for safe_mode. +safe_mode: + num_attempts: 3 + storage: rtc diff --git a/tests/components/wifi/validate-fast-connect-storage.esp32-idf.yaml b/tests/components/wifi/validate-fast-connect-storage.esp32-idf.yaml new file mode 100644 index 0000000000..93d223b908 --- /dev/null +++ b/tests/components/wifi/validate-fast-connect-storage.esp32-idf.yaml @@ -0,0 +1,7 @@ +# Exercises the dict form of fast_connect with RTC-backed preference storage. +wifi: + ssid: MySSID + password: password1 + fast_connect: + enabled: true + storage: rtc diff --git a/tests/components/wifi/validate-fast-connect-storage.esp8266-ard.yaml b/tests/components/wifi/validate-fast-connect-storage.esp8266-ard.yaml new file mode 100644 index 0000000000..070e22fd5b --- /dev/null +++ b/tests/components/wifi/validate-fast-connect-storage.esp8266-ard.yaml @@ -0,0 +1,7 @@ +# Exercises the dict form of fast_connect overriding the ESP8266 default (rtc) +# back to flash storage. +wifi: + ssid: MySSID + password: password1 + fast_connect: + storage: flash diff --git a/tests/unit_tests/test_preferences.py b/tests/unit_tests/test_preferences.py new file mode 100644 index 0000000000..677eeee7f2 --- /dev/null +++ b/tests/unit_tests/test_preferences.py @@ -0,0 +1,149 @@ +"""Tests for esphome.preferences storage backend selection.""" + +import pytest + +from esphome import preferences +from esphome.components.esp32 import KEY_ESP32 +from esphome.components.esp32.const import ( + VARIANT_ESP32, + VARIANT_ESP32C2, + VARIANT_ESP32C3, + VARIANT_ESP32C61, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_STORAGE, + KEY_CORE, + KEY_TARGET_PLATFORM, + KEY_VARIANT, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_RP2040, +) +from esphome.core import CORE + + +def _set_platform(platform: str) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} + + +def _set_esp32(variant: str) -> None: + _set_platform(PLATFORM_ESP32) + CORE.data[KEY_ESP32] = {KEY_VARIANT: variant} + + +def _validate(value: dict): + return cv.Schema(preferences.storage_schema())(value) + + +def _define_names() -> set[str]: + return {define.name for define in CORE.defines} + + +def test_is_in_flash() -> None: + _set_platform(PLATFORM_ESP8266) + assert preferences.is_in_flash(preferences.STORAGE_FLASH) is True + assert preferences.is_in_flash(preferences.STORAGE_RTC) is False + # The RTC storage define is ESP32-specific. + assert "USE_ESP32_RTC_PREFERENCES" not in _define_names() + + +def test_is_in_flash_esp32_rtc_emits_define() -> None: + _set_esp32(VARIANT_ESP32) + assert preferences.is_in_flash(preferences.STORAGE_FLASH) is True + assert "USE_ESP32_RTC_PREFERENCES" not in _define_names() + assert preferences.is_in_flash(preferences.STORAGE_RTC) is False + assert "USE_ESP32_RTC_PREFERENCES" in _define_names() + + +def test_request_rtc_storage_esp32_only() -> None: + _set_platform(PLATFORM_ESP8266) + preferences.request_rtc_storage() + # ESP8266 always has its RTC backend; no define is needed or emitted. + assert "USE_ESP32_RTC_PREFERENCES" not in _define_names() + + +def test_request_rtc_storage_esp32_emits_define() -> None: + _set_esp32(VARIANT_ESP32) + preferences.request_rtc_storage() + assert "USE_ESP32_RTC_PREFERENCES" in _define_names() + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32, VARIANT_ESP32C3]) +def test_validate_rtc_storage_accepted(variant: str) -> None: + _set_esp32(variant) + assert preferences.validate_rtc_storage(True) is True + assert preferences.validate_rtc_storage(False) is False + + +def test_validate_rtc_storage_esp8266() -> None: + _set_platform(PLATFORM_ESP8266) + # Tolerated no-op: the ESP8266 backend always has RTC storage. + assert preferences.validate_rtc_storage(True) is True + # But it cannot be disabled, so an explicit false is an error. + with pytest.raises(cv.Invalid, match="always enabled on ESP8266"): + preferences.validate_rtc_storage(False) + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32C2, VARIANT_ESP32C61]) +def test_validate_rtc_storage_rejected_without_rtc_memory(variant: str) -> None: + _set_esp32(variant) + with pytest.raises(cv.Invalid, match="not supported on this platform"): + preferences.validate_rtc_storage(True) + # Disabling it is always fine. + assert preferences.validate_rtc_storage(False) is False + + +def test_validate_rtc_storage_rejected_on_unsupported_platform() -> None: + _set_platform(PLATFORM_RP2040) + with pytest.raises(cv.Invalid, match="not supported on this platform"): + preferences.validate_rtc_storage(True) + + +@pytest.mark.parametrize( + ("platform", "expected"), + [ + # Defaults preserve each platform's historic behavior. + (PLATFORM_ESP8266, preferences.STORAGE_RTC), + (PLATFORM_RP2040, preferences.STORAGE_FLASH), + ], +) +def test_default_storage_per_platform(platform: str, expected: str) -> None: + _set_platform(platform) + assert _validate({})[CONF_STORAGE] == expected + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32, VARIANT_ESP32C2]) +def test_default_storage_esp32_is_flash(variant: str) -> None: + # ESP32 defaults to flash on every variant, including those without RTC memory. + _set_esp32(variant) + assert _validate({})[CONF_STORAGE] == preferences.STORAGE_FLASH + + +def test_rtc_allowed_on_esp8266() -> None: + _set_platform(PLATFORM_ESP8266) + assert _validate({CONF_STORAGE: "rtc"})[CONF_STORAGE] == preferences.STORAGE_RTC + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32, VARIANT_ESP32C3]) +def test_rtc_allowed_on_esp32_with_rtc_memory(variant: str) -> None: + _set_esp32(variant) + assert _validate({CONF_STORAGE: "rtc"})[CONF_STORAGE] == preferences.STORAGE_RTC + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32C2, VARIANT_ESP32C61]) +def test_rtc_rejected_on_esp32_without_rtc_memory(variant: str) -> None: + _set_esp32(variant) + with pytest.raises(cv.Invalid, match="not supported on this platform"): + _validate({CONF_STORAGE: "rtc"}) + + +def test_rtc_rejected_on_unsupported_platform() -> None: + _set_platform(PLATFORM_RP2040) + with pytest.raises(cv.Invalid, match="not supported on this platform"): + _validate({CONF_STORAGE: "rtc"}) + + +def test_flash_allowed_on_unsupported_platform() -> None: + _set_platform(PLATFORM_RP2040) + assert _validate({CONF_STORAGE: "flash"})[CONF_STORAGE] == preferences.STORAGE_FLASH From c720186170c45425910e1cf1d604aaef25f244bb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 06:50:13 +0200 Subject: [PATCH 0729/1815] Bump smpclient from 6.0.0 to 7.2.0 (#16928) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 95388f278f..8b028554a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,7 +20,7 @@ resvg-py==0.3.3 freetype-py==2.5.1 jinja2==3.1.6 bleak==2.1.1 -smpclient==6.0.0 +smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 platformdirs==4.10.0 # native esp-idf toolchain global cache dir From f6c260a2c5050902aa48ffac85102d927e89aa4b Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:01:48 +1000 Subject: [PATCH 0730/1815] [ci] Make import time budget more realistic (#17406) --- script/import_time_budget.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/import_time_budget.json b/script/import_time_budget.json index 855d89c56d..e810817507 100644 --- a/script/import_time_budget.json +++ b/script/import_time_budget.json @@ -1,5 +1,5 @@ { "target_module": "esphome.__main__", "margin_pct": 20, - "cumulative_us": 91000 + "cumulative_us": 95000 } From 105d1362a20d52ffe251a93de368bd93b625f1e4 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:31:07 -0500 Subject: [PATCH 0731/1815] Bump bundled esphome-device-builder to 1.1.0 (#17412) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index b325a42436..a54bf3e79e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.29 +RUN uv pip install --no-cache-dir esphome-device-builder==1.1.0 RUN \ platformio settings set enable_telemetry No \ From b9588a898497d407be1c263dffae07542d5ed01b Mon Sep 17 00:00:00 2001 From: crimike Date: Mon, 6 Jul 2026 01:29:03 +0200 Subject: [PATCH 0732/1815] [mipi_spi] Add Waveshare-ESP32-S3-TOUCH-AMOLED-1.64 (#17386) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/mipi_spi/models/amoled.py | 4 ++++ esphome/components/mipi_spi/models/waveshare.py | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/esphome/components/mipi_spi/models/amoled.py b/esphome/components/mipi_spi/models/amoled.py index 32cad70ac0..30e815d68e 100644 --- a/esphome/components/mipi_spi/models/amoled.py +++ b/esphome/components/mipi_spi/models/amoled.py @@ -16,6 +16,7 @@ from esphome.components.mipi import ( delay, ) from esphome.components.spi import TYPE_QUAD +from esphome.config_validation import UNDEFINED DriverChip( "T-DISPLAY-S3-AMOLED", @@ -97,6 +98,9 @@ CO5300 = DriverChip( color_order=MODE_RGB, bus_mode=TYPE_QUAD, no_slpout=True, + swap_xy=UNDEFINED, + width=480, + height=480, initsequence=( (SLPOUT,), # Requires early SLPOUT (PAGESEL, 0x00), diff --git a/esphome/components/mipi_spi/models/waveshare.py b/esphome/components/mipi_spi/models/waveshare.py index 3c719b0f5e..8fc5b2acc5 100644 --- a/esphome/components/mipi_spi/models/waveshare.py +++ b/esphome/components/mipi_spi/models/waveshare.py @@ -282,3 +282,13 @@ ST7789V.extend( invert_colors=True, data_rate="40MHz", ) + +CO5300.extend( + "WAVESHARE-ESP32-S3-TOUCH-AMOLED-1.64", + width=280, + height=456, + offset_width=20, + cs_pin=9, + reset_pin=21, + enable_pin=1, +) From 3f94e6dcbbec9d3b6c9e8f83308c605b19216b4c Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 6 Jul 2026 01:52:39 +0200 Subject: [PATCH 0733/1815] [nrf52] let user select libc version (#17408) --- esphome/components/nrf52/__init__.py | 13 +++++++++++++ esphome/components/zephyr/__init__.py | 2 -- tests/components/nrf52/test.nrf52-xiao-ble.yaml | 2 ++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index a5f2018d55..661fc0758e 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -200,6 +200,7 @@ DeviceFirmwareUpdate = nrf52_ns.class_("DeviceFirmwareUpdate", cg.Component) CONF_DFU = "dfu" CONF_DCDC = "dcdc" +CONF_LIBC_NANO = "libc_nano" CONF_REG0 = "reg0" CONF_UICR_ERASE = "uicr_erase" @@ -248,6 +249,7 @@ CONFIG_SCHEMA = cv.All( ): cv.Schema( { cv.Optional(CONF_VERSION): cv.string_strict, + cv.Optional(CONF_LIBC_NANO, default=True): cv.boolean, cv.Optional(CONF_ADVANCED, default={}): cv.Schema( { cv.Optional( @@ -273,6 +275,7 @@ def _validate_mcumgr(config): def _final_validate(config): + if CONF_DFU in config: _validate_mcumgr(config) if config[KEY_BOOTLOADER] == BOOTLOADER_ADAFRUIT: @@ -283,6 +286,13 @@ def _final_validate(config): conf = config[CONF_FRAMEWORK] advanced = conf[CONF_ADVANCED] + if conf[CONF_LIBC_NANO] and "logger" in CORE.loaded_integrations: + _LOGGER.warning( + "Logger is enabled with newlib-nano (libc_nano: true). Some format specifiers " + "such as %%zu are not supported and will print incorrectly. " + "Set 'libc_nano: false' under 'framework:' to use the full newlib." + ) + if advanced[CONF_ENABLE_OTA_ROLLBACK]: # "disabled: false" means safe mode *is* enabled. safe_mode_config = full_config.get(CONF_SAFE_MODE, {CONF_DISABLED: True}) @@ -379,6 +389,9 @@ async def to_code(config: ConfigType) -> None: # Enable OTA rollback support if advanced[CONF_ENABLE_OTA_ROLLBACK]: cg.add_define("USE_OTA_ROLLBACK") + zephyr_add_prj_conf("NEWLIB_LIBC", True) + zephyr_add_prj_conf("NEWLIB_LIBC_FLOAT_PRINTF", True) + zephyr_add_prj_conf("NEWLIB_LIBC_NANO", conf[CONF_LIBC_NANO]) # c++ support if framework_ver < cv.Version(2, 9, 2): zephyr_add_prj_conf("CPLUSPLUS", True) diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index d6c45a744c..b98f94d37a 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -134,9 +134,7 @@ def zephyr_to_code(config: ConfigType) -> None: cg.add_define("USE_NATIVE_64BIT_TIME") cg.set_cpp_standard("gnu++20") # c++ support - zephyr_add_prj_conf("NEWLIB_LIBC", True) zephyr_add_prj_conf("FPU", True) - zephyr_add_prj_conf("NEWLIB_LIBC_FLOAT_PRINTF", True) zephyr_add_prj_conf("STD_CPP20", True) # random_bytes() uses sys_rand_get() which requires the entropy subsystem zephyr_add_prj_conf("ENTROPY_GENERATOR", True) diff --git a/tests/components/nrf52/test.nrf52-xiao-ble.yaml b/tests/components/nrf52/test.nrf52-xiao-ble.yaml index de4c0c6e00..e1b5f088bb 100644 --- a/tests/components/nrf52/test.nrf52-xiao-ble.yaml +++ b/tests/components/nrf52/test.nrf52-xiao-ble.yaml @@ -2,3 +2,5 @@ nrf52: dfu: true reg0: voltage: 1.8V + framework: + libc_nano: false From 39c0f9cc848a68c18b0e4d61149ec82fcf15df36 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:57:56 +1200 Subject: [PATCH 0734/1815] [cst328] Use dict-style packages so batch grouping deduplicates the i2c bus (#17413) --- tests/components/cst328/test.esp32-idf.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/components/cst328/test.esp32-idf.yaml b/tests/components/cst328/test.esp32-idf.yaml index 3dc184e328..9c4594510f 100644 --- a/tests/components/cst328/test.esp32-idf.yaml +++ b/tests/components/cst328/test.esp32-idf.yaml @@ -4,5 +4,6 @@ substitutions: reset_pin: "21" packages: - - !include ../../test_build_components/common/i2c/esp32-idf.yaml - - !include common.yaml + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml From e095c457ff831c36d3c2ece3f1d4148a348e6f07 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 Jul 2026 22:02:12 -0500 Subject: [PATCH 0735/1815] [esp32] Suppress -Wvolatile in the direct ESP-IDF build (#17404) --- esphome/build_gen/espidf.py | 16 +++++++++- esphome/build_gen/platformio.py | 5 ++-- esphome/codegen.py | 1 + esphome/core/__init__.py | 9 ++++++ esphome/core/config.py | 9 ++++++ esphome/cpp_generator.py | 9 ++++++ esphome/framework_helpers.py | 7 +++++ tests/unit_tests/build_gen/test_espidf.py | 23 +++++++++++++++ tests/unit_tests/build_gen/test_platformio.py | 29 +++++++++++++++++++ tests/unit_tests/test_framework_helpers.py | 23 +++++++++++++++ 10 files changed, 127 insertions(+), 4 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index dec6ea04de..cc2fc5c4cd 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -6,7 +6,11 @@ from pathlib import Path from esphome.components.esp32 import get_esp32_variant, idf_version import esphome.config_validation as cv from esphome.core import CORE -from esphome.framework_helpers import get_project_compile_flags, get_project_link_flags +from esphome.framework_helpers import ( + get_project_compile_flags, + get_project_cxx_compile_flags, + get_project_link_flags, +) from esphome.helpers import mkdir_p, write_file_if_changed # Replaces the IDF default C++ standard (-std=gnu++2b appended to @@ -91,6 +95,14 @@ def get_project_cmakelists(minimal: bool = False) -> str: for flag in project_compile_opts ) + # Flags registered via cg.add_cxx_build_flag() go on CXX_COMPILE_OPTIONS + # (not COMPILE_OPTIONS) because GCC warns when a C++-only flag such as + # -Wno-volatile is passed on a C compile. + cxx_compile_options = "\n".join( + f'idf_build_set_property(CXX_COMPILE_OPTIONS "{flag}" APPEND)' + for flag in get_project_cxx_compile_flags() + ) + cpp_standard_options = ( CPP_STANDARD_TEMPLATE.format(standard=CORE.cpp_standard) if CORE.cpp_standard @@ -155,6 +167,8 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) {cpp_standard_options} +{cxx_compile_options} + {extra_compile_options} {managed_components_property} diff --git a/esphome/build_gen/platformio.py b/esphome/build_gen/platformio.py index a583279ea7..b63c4b733d 100644 --- a/esphome/build_gen/platformio.py +++ b/esphome/build_gen/platformio.py @@ -108,7 +108,6 @@ Import("env") def write_cxx_flags_script() -> None: path = CORE.relative_build_path(CXX_FLAGS_FILE_NAME) contents = CXX_FLAGS_FILE_CONTENTS - if not CORE.is_host: - contents += 'env.Append(CXXFLAGS=["-Wno-volatile"])' - contents += "\n" + for flag in sorted(CORE.cxx_build_flags): + contents += f'env.Append(CXXFLAGS=["{flag}"])\n' write_file_if_changed(path, contents) diff --git a/esphome/codegen.py b/esphome/codegen.py index a5b5abe447..56a47d146e 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -25,6 +25,7 @@ from esphome.cpp_generator import ( # noqa: F401 add, add_build_flag, add_build_unflag, + add_cxx_build_flag, add_define, add_global, add_library, diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 21ff7ef07c..89ce27a8b9 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -591,6 +591,9 @@ class EsphomeCore: self.platformio_libraries: dict[str, Library] = {} # A set of build flags to set in the platformio project self.build_flags: set[str] = set() + # A set of build flags that apply to C++ compiles only (CXXFLAGS / + # CXX_COMPILE_OPTIONS), for flags GCC rejects or warns about on C + self.cxx_build_flags: set[str] = set() # A set of build unflags to set in the platformio project self.build_unflags: set[str] = set() # The C++ language standard for the build (e.g. "gnu++20"), set via cg.set_cpp_standard() @@ -650,6 +653,7 @@ class EsphomeCore: self.global_statements = [] self.platformio_libraries = {} self.build_flags = set() + self.cxx_build_flags = set() self.build_unflags = set() self.cpp_standard = None self.defines = set() @@ -957,6 +961,11 @@ class EsphomeCore: _LOGGER.debug("Adding build flag: %s", build_flag) return build_flag + def add_cxx_build_flag(self, build_flag: str) -> str: + self.cxx_build_flags.add(build_flag) + _LOGGER.debug("Adding C++ build flag: %s", build_flag) + return build_flag + def add_build_unflag(self, build_unflag: str) -> None: if self.using_toolchain_esp_idf: # The native ESP-IDF build generator does not consume build_unflags diff --git a/esphome/core/config.py b/esphome/core/config.py index 0670fde0ff..ebad5cf165 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -723,6 +723,15 @@ async def to_code(config: ConfigType) -> None: cg.add_build_flag("-Wno-unused-variable") cg.add_build_flag("-Wno-unused-but-set-variable") cg.add_build_flag("-Wno-sign-compare") + # C++20 deprecated ++/--, compound assignment, and chained assignment on + # volatile lvalues; GCC warns via -Wvolatile, on by default at gnu++20. + # C++23 (P2327R1) removed the deprecation for compound assignment, so the + # warning flags patterns that are valid again under newer standards. + # C++-only flag: GCC warns when it is passed on a C compile, hence + # add_cxx_build_flag. Skipped for host builds, where the compiler may be + # clang, which does not know this GCC option. + if not CORE.is_host: + cg.add_cxx_build_flag("-Wno-volatile") if config[CONF_DEBUG_SCHEDULER]: cg.add_define("ESPHOME_DEBUG_SCHEDULER") diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 582b8fc74d..6bcf4eed77 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -699,6 +699,15 @@ def add_build_flag(build_flag: str): CORE.add_build_flag(build_flag) +def add_cxx_build_flag(build_flag: str) -> None: + """Add a global build flag that applies to C++ compiles only. + + Use for flags GCC rejects or warns about when passed on C compiles + (e.g. ``-Wno-volatile``). + """ + CORE.add_cxx_build_flag(build_flag) + + def add_build_unflag(build_unflag: str) -> None: """Add a global build unflag to the compiler flags.""" CORE.add_build_unflag(build_unflag) diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 69cecc58e2..70d440d995 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -37,6 +37,13 @@ def get_project_compile_flags() -> list[str]: ] +def get_project_cxx_compile_flags() -> list[str]: + """Return the sorted flags that apply to C++ compiles only.""" + from esphome.core import CORE # local import to avoid circular dependency + + return sorted(CORE.cxx_build_flags) + + def str_to_lst_of_str(a: str | list[str]) -> list[str]: """ Convert a string to a list of string diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index 0f4444f719..bcd9fa655a 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -243,6 +243,7 @@ def test_get_project_cmakelists_no_cpp_standard(tmp_path: Path) -> None: patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), patch.object(CORE, "name", "test"), patch.object(CORE, "cpp_standard", None), + patch.object(CORE, "cxx_build_flags", set()), ): from esphome.build_gen.espidf import get_project_cmakelists @@ -251,6 +252,28 @@ def test_get_project_cmakelists_no_cpp_standard(tmp_path: Path) -> None: assert "CXX_COMPILE_OPTIONS" not in content +def test_get_project_cmakelists_cxx_build_flags(tmp_path: Path) -> None: + """Flags registered via cg.add_cxx_build_flag() are appended to + CXX_COMPILE_OPTIONS (C++-only, GCC warns if they reach C compiles) + between include(project.cmake) and project().""" + with ( + patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), + patch.object(CORE, "name", "test"), + patch.object(CORE, "cpp_standard", None), + patch.object(CORE, "cxx_build_flags", {"-Wno-volatile"}), + ): + from esphome.build_gen.espidf import get_project_cmakelists + + content = get_project_cmakelists(minimal=True) + + flag_line = 'idf_build_set_property(CXX_COMPILE_OPTIONS "-Wno-volatile" APPEND)' + assert flag_line in content + include_pos = content.index("tools/cmake/project.cmake") + flag_pos = content.index(flag_line) + project_pos = content.index("project(test)") + assert include_pos < flag_pos < project_pos + + def test_get_component_cmakelists_no_compile_features() -> None: """The C++ standard is pinned project-wide via CXX_COMPILE_OPTIONS in the top-level CMakeLists; the src component must not set its own.""" diff --git a/tests/unit_tests/build_gen/test_platformio.py b/tests/unit_tests/build_gen/test_platformio.py index 2ae3836a25..3df2fb1036 100644 --- a/tests/unit_tests/build_gen/test_platformio.py +++ b/tests/unit_tests/build_gen/test_platformio.py @@ -200,3 +200,32 @@ def test_get_ini_content_no_cpp_standard( content = platformio.get_ini_content() assert "-std=" not in content + + +def test_write_cxx_flags_script_emits_registered_flags( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Flags registered via cg.add_cxx_build_flag() are emitted as CXXFLAGS, + sorted, so they apply to C++ compiles only.""" + CORE.build_path = str(tmp_path) + monkeypatch.setattr(CORE, "cxx_build_flags", {"-Wno-volatile", "-Wno-deprecated"}) + + platformio.write_cxx_flags_script() + + content = (tmp_path / platformio.CXX_FLAGS_FILE_NAME).read_text() + assert ( + 'env.Append(CXXFLAGS=["-Wno-deprecated"])\n' + 'env.Append(CXXFLAGS=["-Wno-volatile"])\n' + ) in content + + +def test_write_cxx_flags_script_no_flags( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + CORE.build_path = str(tmp_path) + monkeypatch.setattr(CORE, "cxx_build_flags", set()) + + platformio.write_cxx_flags_script() + + content = (tmp_path / platformio.CXX_FLAGS_FILE_NAME).read_text() + assert "CXXFLAGS" not in content diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index 6fe62dcc8c..69b9f20eaa 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -26,6 +26,7 @@ from esphome.framework_helpers import ( create_venv, download_from_mirrors, get_project_compile_flags, + get_project_cxx_compile_flags, get_project_link_flags, get_python_env_executable_path, get_system_python_path, @@ -1048,3 +1049,25 @@ class TestGetProjectLinkFlags: ): result = get_project_link_flags() assert result == sorted(result) + + +def _make_core_cxx(flags: set[str]) -> MagicMock: + core = MagicMock() + core.cxx_build_flags = flags + return core + + +class TestGetProjectCxxCompileFlags: + def test_returns_sorted_flags(self) -> None: + with patch( + "esphome.core.CORE", + _make_core_cxx({"-Wno-volatile", "-Wno-deprecated"}), + ): + assert get_project_cxx_compile_flags() == [ + "-Wno-deprecated", + "-Wno-volatile", + ] + + def test_empty_flags(self) -> None: + with patch("esphome.core.CORE", _make_core_cxx(set())): + assert get_project_cxx_compile_flags() == [] From fd86417bf56a587aefae62adb82ab527db35cf64 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:29:45 +1200 Subject: [PATCH 0736/1815] [cst328] Update test package (#17415) --- tests/components/cst328/test.esp32-idf.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/components/cst328/test.esp32-idf.yaml b/tests/components/cst328/test.esp32-idf.yaml index 9c4594510f..ac4ad140a8 100644 --- a/tests/components/cst328/test.esp32-idf.yaml +++ b/tests/components/cst328/test.esp32-idf.yaml @@ -5,5 +5,4 @@ substitutions: packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - -<<: !include common.yaml + cst328: !include common.yaml From af7b6e35895bca7d8b92951a374e7f4da2ffa243 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:32:26 +1200 Subject: [PATCH 0737/1815] Mark configurable classes as final (17/21: ssd1351_spi-tem3200) (#16968) --- esphome/components/ssd1351_spi/ssd1351_spi.h | 6 +++--- esphome/components/st7567_i2c/st7567_i2c.h | 2 +- esphome/components/st7567_spi/st7567_spi.h | 6 +++--- esphome/components/st7701s/st7701s.h | 6 +++--- esphome/components/st7735/st7735.h | 6 +++--- esphome/components/st7789v/st7789v.h | 6 +++--- esphome/components/st7920/st7920.h | 6 +++--- esphome/components/statsd/statsd.h | 2 +- .../components/status/status_binary_sensor.h | 2 +- .../status_led/light/status_led_light.h | 2 +- esphome/components/status_led/status_led.h | 2 +- esphome/components/stepper/stepper.h | 10 +++++----- esphome/components/sts3x/sts3x.h | 4 +++- esphome/components/stts22h/stts22h.h | 2 +- esphome/components/sun/sensor/sun_sensor.h | 2 +- esphome/components/sun/sun.h | 6 +++--- .../sun/text_sensor/sun_text_sensor.h | 2 +- esphome/components/sun_gtil2/sun_gtil2.h | 2 +- esphome/components/switch/automation.h | 18 +++++++++--------- .../binary_sensor/switch_binary_sensor.h | 2 +- esphome/components/sx126x/automation.h | 12 ++++++------ .../sx126x/packet_transport/sx126x_transport.h | 2 +- esphome/components/sx126x/sx126x.h | 6 +++--- esphome/components/sx127x/automation.h | 12 ++++++------ .../sx127x/packet_transport/sx127x_transport.h | 2 +- esphome/components/sx127x/sx127x.h | 6 +++--- .../sx1509_binary_keypad_sensor.h | 2 +- .../sx1509/output/sx1509_float_output.h | 2 +- esphome/components/sx1509/sx1509.h | 10 +++++----- esphome/components/sx1509/sx1509_gpio_pin.h | 2 +- .../binary_sensor/sy6970_binary_sensor.h | 4 ++-- .../components/sy6970/sensor/sy6970_sensor.h | 2 +- esphome/components/sy6970/sy6970.h | 2 +- .../sy6970/text_sensor/sy6970_text_sensor.h | 6 +++--- esphome/components/syslog/esphome_syslog.h | 2 +- esphome/components/t6615/t6615.h | 2 +- esphome/components/tc74/tc74.h | 2 +- esphome/components/tca9548a/tca9548a.h | 4 ++-- esphome/components/tca9555/tca9555.h | 8 ++++---- esphome/components/tcl112/tcl112.h | 2 +- esphome/components/tcs34725/tcs34725.h | 2 +- esphome/components/tee501/tee501.h | 2 +- .../teleinfo/sensor/teleinfo_sensor.h | 2 +- esphome/components/teleinfo/teleinfo.h | 2 +- .../text_sensor/teleinfo_text_sensor.h | 2 +- esphome/components/tem3200/tem3200.h | 2 +- 46 files changed, 99 insertions(+), 97 deletions(-) diff --git a/esphome/components/ssd1351_spi/ssd1351_spi.h b/esphome/components/ssd1351_spi/ssd1351_spi.h index 5ce41c1f9e..307807d19f 100644 --- a/esphome/components/ssd1351_spi/ssd1351_spi.h +++ b/esphome/components/ssd1351_spi/ssd1351_spi.h @@ -6,9 +6,9 @@ namespace esphome::ssd1351_spi { -class SPISSD1351 : public ssd1351_base::SSD1351, - public spi::SPIDevice { +class SPISSD1351 final : public ssd1351_base::SSD1351, + public spi::SPIDevice { public: void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } diff --git a/esphome/components/st7567_i2c/st7567_i2c.h b/esphome/components/st7567_i2c/st7567_i2c.h index 49489d79e6..eea3068e03 100644 --- a/esphome/components/st7567_i2c/st7567_i2c.h +++ b/esphome/components/st7567_i2c/st7567_i2c.h @@ -6,7 +6,7 @@ namespace esphome::st7567_i2c { -class I2CST7567 : public st7567_base::ST7567, public i2c::I2CDevice { +class I2CST7567 final : public st7567_base::ST7567, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/st7567_spi/st7567_spi.h b/esphome/components/st7567_spi/st7567_spi.h index fb6f9501a9..e4699437ad 100644 --- a/esphome/components/st7567_spi/st7567_spi.h +++ b/esphome/components/st7567_spi/st7567_spi.h @@ -6,9 +6,9 @@ namespace esphome::st7567_spi { -class SPIST7567 : public st7567_base::ST7567, - public spi::SPIDevice { +class SPIST7567 final : public st7567_base::ST7567, + public spi::SPIDevice { public: void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } diff --git a/esphome/components/st7701s/st7701s.h b/esphome/components/st7701s/st7701s.h index c65a213929..d44f8c6859 100644 --- a/esphome/components/st7701s/st7701s.h +++ b/esphome/components/st7701s/st7701s.h @@ -26,9 +26,9 @@ const uint8_t CMD2_BKSEL = 0xFF; const uint8_t CMD2_BK0[5] = {0x77, 0x01, 0x00, 0x00, 0x10}; const uint8_t ST7701S_DELAY_FLAG = 0xFF; -class ST7701S : public display::Display, - public spi::SPIDevice { +class ST7701S final : public display::Display, + public spi::SPIDevice { public: void update() override { this->do_update_(); } void setup() override; diff --git a/esphome/components/st7735/st7735.h b/esphome/components/st7735/st7735.h index 7fa0ad7335..28bc0916f9 100644 --- a/esphome/components/st7735/st7735.h +++ b/esphome/components/st7735/st7735.h @@ -31,9 +31,9 @@ enum ST7735Model { ST7735_INITR_18REDTAB = INITR_18REDTAB }; -class ST7735 : public display::DisplayBuffer, - public spi::SPIDevice { +class ST7735 final : public display::DisplayBuffer, + public spi::SPIDevice { public: ST7735(ST7735Model model, int width, int height, int colstart, int rowstart, bool eightbitcolor, bool usebgr, bool invert_colors); diff --git a/esphome/components/st7789v/st7789v.h b/esphome/components/st7789v/st7789v.h index 3f9942b117..1b7ba318a6 100644 --- a/esphome/components/st7789v/st7789v.h +++ b/esphome/components/st7789v/st7789v.h @@ -106,9 +106,9 @@ static const uint8_t ST7789_MADCTL_GS = 0x01; static const uint8_t ST7789_MADCTL_COLOR_ORDER = ST7789_MADCTL_BGR; -class ST7789V : public display::DisplayBuffer, - public spi::SPIDevice { +class ST7789V final : public display::DisplayBuffer, + public spi::SPIDevice { public: void set_model_str(const char *model_str); void set_dc_pin(GPIOPin *dc_pin) { this->dc_pin_ = dc_pin; } diff --git a/esphome/components/st7920/st7920.h b/esphome/components/st7920/st7920.h index 71fe7aa89c..0160c5270f 100644 --- a/esphome/components/st7920/st7920.h +++ b/esphome/components/st7920/st7920.h @@ -10,9 +10,9 @@ class ST7920; using st7920_writer_t = display::DisplayWriter; -class ST7920 : public display::DisplayBuffer, - public spi::SPIDevice { +class ST7920 final : public display::DisplayBuffer, + public spi::SPIDevice { public: void set_writer(st7920_writer_t &&writer) { this->writer_local_ = writer; } void set_height(uint16_t height) { this->height_ = height; } diff --git a/esphome/components/statsd/statsd.h b/esphome/components/statsd/statsd.h index 77f3d797c5..7cbde6d743 100644 --- a/esphome/components/statsd/statsd.h +++ b/esphome/components/statsd/statsd.h @@ -27,7 +27,7 @@ namespace esphome::statsd { -class StatsdComponent : public PollingComponent { +class StatsdComponent final : public PollingComponent { public: ~StatsdComponent(); diff --git a/esphome/components/status/status_binary_sensor.h b/esphome/components/status/status_binary_sensor.h index 7e8c31d741..28cf4cd083 100644 --- a/esphome/components/status/status_binary_sensor.h +++ b/esphome/components/status/status_binary_sensor.h @@ -5,7 +5,7 @@ namespace esphome::status { -class StatusBinarySensor : public binary_sensor::BinarySensor, public PollingComponent { +class StatusBinarySensor final : public binary_sensor::BinarySensor, public PollingComponent { public: void update() override; diff --git a/esphome/components/status_led/light/status_led_light.h b/esphome/components/status_led/light/status_led_light.h index 0483669d0a..5eb0d3c085 100644 --- a/esphome/components/status_led/light/status_led_light.h +++ b/esphome/components/status_led/light/status_led_light.h @@ -7,7 +7,7 @@ namespace esphome::status_led { -class StatusLEDLightOutput : public light::LightOutput, public Component { +class StatusLEDLightOutput final : public light::LightOutput, public Component { public: void set_pin(GPIOPin *pin) { pin_ = pin; } void set_output(output::BinaryOutput *output) { output_ = output; } diff --git a/esphome/components/status_led/status_led.h b/esphome/components/status_led/status_led.h index bda144d2cd..3688dba8d6 100644 --- a/esphome/components/status_led/status_led.h +++ b/esphome/components/status_led/status_led.h @@ -5,7 +5,7 @@ namespace esphome::status_led { -class StatusLED : public Component { +class StatusLED final : public Component { public: explicit StatusLED(GPIOPin *pin); diff --git a/esphome/components/stepper/stepper.h b/esphome/components/stepper/stepper.h index 9fbd0d92e6..06ef3bab37 100644 --- a/esphome/components/stepper/stepper.h +++ b/esphome/components/stepper/stepper.h @@ -37,7 +37,7 @@ class Stepper { uint32_t last_step_{0}; }; -template class SetTargetAction : public Action { +template class SetTargetAction final : public Action { public: explicit SetTargetAction(Stepper *parent) : parent_(parent) {} @@ -49,7 +49,7 @@ template class SetTargetAction : public Action { Stepper *parent_; }; -template class ReportPositionAction : public Action { +template class ReportPositionAction final : public Action { public: explicit ReportPositionAction(Stepper *parent) : parent_(parent) {} @@ -61,7 +61,7 @@ template class ReportPositionAction : public Action { Stepper *parent_; }; -template class SetSpeedAction : public Action { +template class SetSpeedAction final : public Action { public: explicit SetSpeedAction(Stepper *parent) : parent_(parent) {} @@ -77,7 +77,7 @@ template class SetSpeedAction : public Action { Stepper *parent_; }; -template class SetAccelerationAction : public Action { +template class SetAccelerationAction final : public Action { public: explicit SetAccelerationAction(Stepper *parent) : parent_(parent) {} @@ -92,7 +92,7 @@ template class SetAccelerationAction : public Action { Stepper *parent_; }; -template class SetDecelerationAction : public Action { +template class SetDecelerationAction final : public Action { public: explicit SetDecelerationAction(Stepper *parent) : parent_(parent) {} diff --git a/esphome/components/sts3x/sts3x.h b/esphome/components/sts3x/sts3x.h index 038fa0dd80..6752cf689b 100644 --- a/esphome/components/sts3x/sts3x.h +++ b/esphome/components/sts3x/sts3x.h @@ -9,7 +9,9 @@ namespace esphome::sts3x { /// This class implements support for the ST3x-DIS family of temperature i2c sensors. -class STS3XComponent : public sensor::Sensor, public PollingComponent, public sensirion_common::SensirionI2CDevice { +class STS3XComponent final : public sensor::Sensor, + public PollingComponent, + public sensirion_common::SensirionI2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/stts22h/stts22h.h b/esphome/components/stts22h/stts22h.h index 442a263e49..d8d7a485cf 100644 --- a/esphome/components/stts22h/stts22h.h +++ b/esphome/components/stts22h/stts22h.h @@ -6,7 +6,7 @@ namespace esphome::stts22h { -class STTS22HComponent : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class STTS22HComponent final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; void update() override; diff --git a/esphome/components/sun/sensor/sun_sensor.h b/esphome/components/sun/sensor/sun_sensor.h index 148e5297d9..bec1a1af67 100644 --- a/esphome/components/sun/sensor/sun_sensor.h +++ b/esphome/components/sun/sensor/sun_sensor.h @@ -11,7 +11,7 @@ enum SensorType { SUN_SENSOR_AZIMUTH, }; -class SunSensor : public sensor::Sensor, public PollingComponent { +class SunSensor final : public sensor::Sensor, public PollingComponent { public: void set_parent(Sun *parent) { parent_ = parent; } void set_type(SensorType type) { type_ = type; } diff --git a/esphome/components/sun/sun.h b/esphome/components/sun/sun.h index 2999c93c71..ea9e05042d 100644 --- a/esphome/components/sun/sun.h +++ b/esphome/components/sun/sun.h @@ -51,7 +51,7 @@ struct HorizontalCoordinate { } // namespace internal -class Sun { +class Sun final { public: void set_time(time::RealTimeClock *time) { time_ = time; } time::RealTimeClock *get_time() const { return time_; } @@ -78,7 +78,7 @@ class Sun { internal::GeoLocation location_; }; -class SunTrigger : public Trigger<>, public PollingComponent, public Parented { +class SunTrigger final : public Trigger<>, public PollingComponent, public Parented { public: SunTrigger() : PollingComponent(60000) {} @@ -109,7 +109,7 @@ class SunTrigger : public Trigger<>, public PollingComponent, public Parented class SunCondition : public Condition, public Parented { +template class SunCondition final : public Condition, public Parented { public: TEMPLATABLE_VALUE(double, elevation); void set_above(bool above) { above_ = above; } diff --git a/esphome/components/sun/text_sensor/sun_text_sensor.h b/esphome/components/sun/text_sensor/sun_text_sensor.h index 65b0e358d0..a247a95e06 100644 --- a/esphome/components/sun/text_sensor/sun_text_sensor.h +++ b/esphome/components/sun/text_sensor/sun_text_sensor.h @@ -8,7 +8,7 @@ namespace esphome::sun { -class SunTextSensor : public text_sensor::TextSensor, public PollingComponent { +class SunTextSensor final : public text_sensor::TextSensor, public PollingComponent { public: void set_parent(Sun *parent) { parent_ = parent; } void set_elevation(double elevation) { elevation_ = elevation; } diff --git a/esphome/components/sun_gtil2/sun_gtil2.h b/esphome/components/sun_gtil2/sun_gtil2.h index e774fefcf8..dc3516f2b5 100644 --- a/esphome/components/sun_gtil2/sun_gtil2.h +++ b/esphome/components/sun_gtil2/sun_gtil2.h @@ -15,7 +15,7 @@ namespace esphome::sun_gtil2 { -class SunGTIL2 : public Component, public uart::UARTDevice { +class SunGTIL2 final : public Component, public uart::UARTDevice { public: float get_setup_priority() const override { return setup_priority::LATE; } void setup() override; diff --git a/esphome/components/switch/automation.h b/esphome/components/switch/automation.h index ed1f056c8b..158fb08baf 100644 --- a/esphome/components/switch/automation.h +++ b/esphome/components/switch/automation.h @@ -6,7 +6,7 @@ namespace esphome::switch_ { -template class TurnOnAction : public Action { +template class TurnOnAction final : public Action { public: explicit TurnOnAction(Switch *a_switch) : switch_(a_switch) {} @@ -16,7 +16,7 @@ template class TurnOnAction : public Action { Switch *switch_; }; -template class TurnOffAction : public Action { +template class TurnOffAction final : public Action { public: explicit TurnOffAction(Switch *a_switch) : switch_(a_switch) {} @@ -26,7 +26,7 @@ template class TurnOffAction : public Action { Switch *switch_; }; -template class ToggleAction : public Action { +template class ToggleAction final : public Action { public: explicit ToggleAction(Switch *a_switch) : switch_(a_switch) {} @@ -36,7 +36,7 @@ template class ToggleAction : public Action { Switch *switch_; }; -template class ControlAction : public Action { +template class ControlAction final : public Action { public: explicit ControlAction(Switch *a_switch) : switch_(a_switch) {} @@ -53,7 +53,7 @@ template class ControlAction : public Action { Switch *switch_; }; -template class SwitchCondition : public Condition { +template class SwitchCondition final : public Condition { public: SwitchCondition(Switch *parent, bool state) : parent_(parent), state_(state) {} bool check(const Ts &...x) override { return this->parent_->state == this->state_; } @@ -63,14 +63,14 @@ template class SwitchCondition : public Condition { bool state_; }; -class SwitchStateTrigger : public Trigger { +class SwitchStateTrigger final : public Trigger { public: SwitchStateTrigger(Switch *a_switch) { a_switch->add_on_state_callback([this](bool state) { this->trigger(state); }); } }; -class SwitchTurnOnTrigger : public Trigger<> { +class SwitchTurnOnTrigger final : public Trigger<> { public: SwitchTurnOnTrigger(Switch *a_switch) { a_switch->add_on_state_callback([this](bool state) { @@ -81,7 +81,7 @@ class SwitchTurnOnTrigger : public Trigger<> { } }; -class SwitchTurnOffTrigger : public Trigger<> { +class SwitchTurnOffTrigger final : public Trigger<> { public: SwitchTurnOffTrigger(Switch *a_switch) { a_switch->add_on_state_callback([this](bool state) { @@ -92,7 +92,7 @@ class SwitchTurnOffTrigger : public Trigger<> { } }; -template class SwitchPublishAction : public Action { +template class SwitchPublishAction final : public Action { public: SwitchPublishAction(Switch *a_switch) : switch_(a_switch) {} TEMPLATABLE_VALUE(bool, state) diff --git a/esphome/components/switch/binary_sensor/switch_binary_sensor.h b/esphome/components/switch/binary_sensor/switch_binary_sensor.h index 0b77cdd920..5c4184ecfa 100644 --- a/esphome/components/switch/binary_sensor/switch_binary_sensor.h +++ b/esphome/components/switch/binary_sensor/switch_binary_sensor.h @@ -6,7 +6,7 @@ namespace esphome::switch_ { -class SwitchBinarySensor : public binary_sensor::BinarySensor, public Component { +class SwitchBinarySensor final : public binary_sensor::BinarySensor, public Component { public: void set_source(Switch *source) { source_ = source; } void setup() override; diff --git a/esphome/components/sx126x/automation.h b/esphome/components/sx126x/automation.h index 2721cbfbbf..4eb33abaa1 100644 --- a/esphome/components/sx126x/automation.h +++ b/esphome/components/sx126x/automation.h @@ -6,12 +6,12 @@ namespace esphome::sx126x { -template class RunImageCalAction : public Action, public Parented { +template class RunImageCalAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->run_image_cal(); } }; -template class SendPacketAction : public Action, public Parented { +template class SendPacketAction final : public Action, public Parented { public: void set_data_template(std::vector (*func)(Ts...)) { this->data_.func = func; @@ -43,23 +43,23 @@ template class SendPacketAction : public Action, public P } data_; }; -template class SetModeTxAction : public Action, public Parented { +template class SetModeTxAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_mode_tx(); } }; -template class SetModeRxAction : public Action, public Parented { +template class SetModeRxAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_mode_rx(); } }; -template class SetModeSleepAction : public Action, public Parented { +template class SetModeSleepAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, cold) void play(const Ts &...x) override { this->parent_->set_mode_sleep(this->cold_.value(x...)); } }; -template class SetModeStandbyAction : public Action, public Parented { +template class SetModeStandbyAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_mode_standby(STDBY_XOSC); } }; diff --git a/esphome/components/sx126x/packet_transport/sx126x_transport.h b/esphome/components/sx126x/packet_transport/sx126x_transport.h index 7590e35c28..ccd20755e5 100644 --- a/esphome/components/sx126x/packet_transport/sx126x_transport.h +++ b/esphome/components/sx126x/packet_transport/sx126x_transport.h @@ -7,7 +7,7 @@ namespace esphome::sx126x { -class SX126xTransport : public packet_transport::PacketTransport, public Parented, public SX126xListener { +class SX126xTransport final : public packet_transport::PacketTransport, public Parented, public SX126xListener { public: void setup() override; void on_packet(const std::vector &packet, float rssi, float snr) override; diff --git a/esphome/components/sx126x/sx126x.h b/esphome/components/sx126x/sx126x.h index 6816084df0..b3dfe6590a 100644 --- a/esphome/components/sx126x/sx126x.h +++ b/esphome/components/sx126x/sx126x.h @@ -53,9 +53,9 @@ class SX126xListener { virtual void on_packet(const std::vector &packet, float rssi, float snr) = 0; }; -class SX126x : public Component, - public spi::SPIDevice { +class SX126x final : public Component, + public spi::SPIDevice { public: size_t get_max_packet_size(); float get_setup_priority() const override { return setup_priority::PROCESSOR; } diff --git a/esphome/components/sx127x/automation.h b/esphome/components/sx127x/automation.h index 7a2eb7ee8d..f6a4537e23 100644 --- a/esphome/components/sx127x/automation.h +++ b/esphome/components/sx127x/automation.h @@ -6,12 +6,12 @@ namespace esphome::sx127x { -template class RunImageCalAction : public Action, public Parented { +template class RunImageCalAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->run_image_cal(); } }; -template class SendPacketAction : public Action, public Parented { +template class SendPacketAction final : public Action, public Parented { public: void set_data_template(std::vector (*func)(Ts...)) { this->data_.func = func; @@ -43,22 +43,22 @@ template class SendPacketAction : public Action, public P } data_; }; -template class SetModeTxAction : public Action, public Parented { +template class SetModeTxAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_mode_tx(); } }; -template class SetModeRxAction : public Action, public Parented { +template class SetModeRxAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_mode_rx(); } }; -template class SetModeSleepAction : public Action, public Parented { +template class SetModeSleepAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_mode_sleep(); } }; -template class SetModeStandbyAction : public Action, public Parented { +template class SetModeStandbyAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_mode_standby(); } }; diff --git a/esphome/components/sx127x/packet_transport/sx127x_transport.h b/esphome/components/sx127x/packet_transport/sx127x_transport.h index 5dcfe02c33..fb38fc15bc 100644 --- a/esphome/components/sx127x/packet_transport/sx127x_transport.h +++ b/esphome/components/sx127x/packet_transport/sx127x_transport.h @@ -7,7 +7,7 @@ namespace esphome::sx127x { -class SX127xTransport : public packet_transport::PacketTransport, public Parented, public SX127xListener { +class SX127xTransport final : public packet_transport::PacketTransport, public Parented, public SX127xListener { public: void setup() override; void on_packet(const std::vector &packet, float rssi, float snr) override; diff --git a/esphome/components/sx127x/sx127x.h b/esphome/components/sx127x/sx127x.h index 376c987ed1..070a6eeb96 100644 --- a/esphome/components/sx127x/sx127x.h +++ b/esphome/components/sx127x/sx127x.h @@ -41,9 +41,9 @@ class SX127xListener { virtual void on_packet(const std::vector &packet, float rssi, float snr) = 0; }; -class SX127x : public Component, - public spi::SPIDevice { +class SX127x final : public Component, + public spi::SPIDevice { public: size_t get_max_packet_size(); float get_setup_priority() const override { return setup_priority::PROCESSOR; } diff --git a/esphome/components/sx1509/binary_sensor/sx1509_binary_keypad_sensor.h b/esphome/components/sx1509/binary_sensor/sx1509_binary_keypad_sensor.h index bcd8901530..5d26a37283 100644 --- a/esphome/components/sx1509/binary_sensor/sx1509_binary_keypad_sensor.h +++ b/esphome/components/sx1509/binary_sensor/sx1509_binary_keypad_sensor.h @@ -5,7 +5,7 @@ namespace esphome::sx1509 { -class SX1509BinarySensor : public sx1509::SX1509Processor, public binary_sensor::BinarySensor { +class SX1509BinarySensor final : public sx1509::SX1509Processor, public binary_sensor::BinarySensor { public: void set_row_col(uint8_t row, uint8_t col) { this->key_ = (1 << (col + 8)) | (1 << row); } void process(uint16_t data) override { this->publish_state(static_cast(data == key_)); } diff --git a/esphome/components/sx1509/output/sx1509_float_output.h b/esphome/components/sx1509/output/sx1509_float_output.h index ee53cef637..8790b2fcd7 100644 --- a/esphome/components/sx1509/output/sx1509_float_output.h +++ b/esphome/components/sx1509/output/sx1509_float_output.h @@ -7,7 +7,7 @@ namespace esphome::sx1509 { class SX1509Component; -class SX1509FloatOutputChannel : public output::FloatOutput, public Component { +class SX1509FloatOutputChannel final : public output::FloatOutput, public Component { public: void set_parent(SX1509Component *parent) { this->parent_ = parent; } void set_pin(uint8_t pin) { pin_ = pin; } diff --git a/esphome/components/sx1509/sx1509.h b/esphome/components/sx1509/sx1509.h index 35883eed5b..c7aed2cddd 100644 --- a/esphome/components/sx1509/sx1509.h +++ b/esphome/components/sx1509/sx1509.h @@ -28,12 +28,12 @@ class SX1509Processor { virtual void process(uint16_t data){}; }; -class SX1509KeyTrigger : public Trigger {}; +class SX1509KeyTrigger final : public Trigger {}; -class SX1509Component : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander, - public key_provider::KeyProvider { +class SX1509Component final : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander, + public key_provider::KeyProvider { public: SX1509Component() = default; diff --git a/esphome/components/sx1509/sx1509_gpio_pin.h b/esphome/components/sx1509/sx1509_gpio_pin.h index 9dcad37b27..3bd3d90bd9 100644 --- a/esphome/components/sx1509/sx1509_gpio_pin.h +++ b/esphome/components/sx1509/sx1509_gpio_pin.h @@ -6,7 +6,7 @@ namespace esphome::sx1509 { class SX1509Component; -class SX1509GPIOPin : public GPIOPin { +class SX1509GPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/sy6970/binary_sensor/sy6970_binary_sensor.h b/esphome/components/sy6970/binary_sensor/sy6970_binary_sensor.h index 4a374d7e3d..b94c89d123 100644 --- a/esphome/components/sy6970/binary_sensor/sy6970_binary_sensor.h +++ b/esphome/components/sy6970/binary_sensor/sy6970_binary_sensor.h @@ -6,7 +6,7 @@ namespace esphome::sy6970 { template -class StatusBinarySensor : public SY6970Listener, public binary_sensor::BinarySensor { +class StatusBinarySensor final : public SY6970Listener, public binary_sensor::BinarySensor { public: void on_data(const SY6970Data &data) override { uint8_t value = (data.registers[REG] >> SHIFT) & MASK; @@ -24,7 +24,7 @@ class InverseStatusBinarySensor : public SY6970Listener, public binary_sensor::B }; // Custom binary sensor for charging (true when pre-charge or fast charge) -class SY6970ChargingBinarySensor : public SY6970Listener, public binary_sensor::BinarySensor { +class SY6970ChargingBinarySensor final : public SY6970Listener, public binary_sensor::BinarySensor { public: void on_data(const SY6970Data &data) override { uint8_t chrg_stat = (data.registers[SY6970_REG_STATUS] >> 3) & 0x03; diff --git a/esphome/components/sy6970/sensor/sy6970_sensor.h b/esphome/components/sy6970/sensor/sy6970_sensor.h index f912d726b2..61abbc3e36 100644 --- a/esphome/components/sy6970/sensor/sy6970_sensor.h +++ b/esphome/components/sy6970/sensor/sy6970_sensor.h @@ -34,7 +34,7 @@ using SY6970SystemVoltageSensor = VoltageSensor; // Precharge current sensor needs special handling (bit shift) -class SY6970PrechargeCurrentSensor : public SY6970Listener, public sensor::Sensor { +class SY6970PrechargeCurrentSensor final : public SY6970Listener, public sensor::Sensor { public: void on_data(const SY6970Data &data) override { uint8_t iprechg = (data.registers[SY6970_REG_PRECHARGE_CURRENT] >> 4) & 0x0F; diff --git a/esphome/components/sy6970/sy6970.h b/esphome/components/sy6970/sy6970.h index 2225dd781b..06f0615ab4 100644 --- a/esphome/components/sy6970/sy6970.h +++ b/esphome/components/sy6970/sy6970.h @@ -73,7 +73,7 @@ class SY6970Listener { virtual void on_data(const SY6970Data &data) = 0; }; -class SY6970Component : public PollingComponent, public i2c::I2CDevice { +class SY6970Component final : public PollingComponent, public i2c::I2CDevice { public: SY6970Component(bool led_enabled, uint16_t input_current_limit, uint16_t charge_voltage, uint16_t charge_current, uint16_t precharge_current, bool charge_enabled, bool enable_adc) diff --git a/esphome/components/sy6970/text_sensor/sy6970_text_sensor.h b/esphome/components/sy6970/text_sensor/sy6970_text_sensor.h index 665c5eca64..e569bd0b90 100644 --- a/esphome/components/sy6970/text_sensor/sy6970_text_sensor.h +++ b/esphome/components/sy6970/text_sensor/sy6970_text_sensor.h @@ -6,7 +6,7 @@ namespace esphome::sy6970 { // Bus status text sensor -class SY6970BusStatusTextSensor : public SY6970Listener, public text_sensor::TextSensor { +class SY6970BusStatusTextSensor final : public SY6970Listener, public text_sensor::TextSensor { public: void on_data(const SY6970Data &data) override { uint8_t status = (data.registers[SY6970_REG_STATUS] >> 5) & 0x07; @@ -40,7 +40,7 @@ class SY6970BusStatusTextSensor : public SY6970Listener, public text_sensor::Tex }; // Charge status text sensor -class SY6970ChargeStatusTextSensor : public SY6970Listener, public text_sensor::TextSensor { +class SY6970ChargeStatusTextSensor final : public SY6970Listener, public text_sensor::TextSensor { public: void on_data(const SY6970Data &data) override { uint8_t status = (data.registers[SY6970_REG_STATUS] >> 3) & 0x03; @@ -66,7 +66,7 @@ class SY6970ChargeStatusTextSensor : public SY6970Listener, public text_sensor:: }; // NTC status text sensor -class SY6970NtcStatusTextSensor : public SY6970Listener, public text_sensor::TextSensor { +class SY6970NtcStatusTextSensor final : public SY6970Listener, public text_sensor::TextSensor { public: void on_data(const SY6970Data &data) override { uint8_t status = data.registers[SY6970_REG_FAULT] & 0x07; diff --git a/esphome/components/syslog/esphome_syslog.h b/esphome/components/syslog/esphome_syslog.h index be4fa91436..4a76f9ac62 100644 --- a/esphome/components/syslog/esphome_syslog.h +++ b/esphome/components/syslog/esphome_syslog.h @@ -7,7 +7,7 @@ #ifdef USE_NETWORK namespace esphome::syslog { -class Syslog : public Component, public Parented { +class Syslog final : public Component, public Parented { public: Syslog(int level, time::RealTimeClock *time) : log_level_(level), time_(time) {} void setup() override; diff --git a/esphome/components/t6615/t6615.h b/esphome/components/t6615/t6615.h index 0c2088f7b0..7ad2ae23c7 100644 --- a/esphome/components/t6615/t6615.h +++ b/esphome/components/t6615/t6615.h @@ -19,7 +19,7 @@ enum class T6615Command : uint8_t { SET_ELEVATION, }; -class T6615Component : public PollingComponent, public uart::UARTDevice { +class T6615Component final : public PollingComponent, public uart::UARTDevice { public: void loop() override; void update() override; diff --git a/esphome/components/tc74/tc74.h b/esphome/components/tc74/tc74.h index 4a53f39bc1..c48303c009 100644 --- a/esphome/components/tc74/tc74.h +++ b/esphome/components/tc74/tc74.h @@ -6,7 +6,7 @@ namespace esphome::tc74 { -class TC74Component : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { +class TC74Component final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: /// Setup the sensor and check connection. void setup() override; diff --git a/esphome/components/tca9548a/tca9548a.h b/esphome/components/tca9548a/tca9548a.h index f0417ac7f7..a98c226d32 100644 --- a/esphome/components/tca9548a/tca9548a.h +++ b/esphome/components/tca9548a/tca9548a.h @@ -8,7 +8,7 @@ namespace esphome::tca9548a { static const uint8_t TCA9548A_DISABLE_CHANNELS_COMMAND = 0x00; class TCA9548AComponent; -class TCA9548AChannel : public i2c::I2CBus { +class TCA9548AChannel final : public i2c::I2CBus { public: void set_channel(uint8_t channel) { channel_ = channel; } void set_parent(TCA9548AComponent *parent) { parent_ = parent; } @@ -21,7 +21,7 @@ class TCA9548AChannel : public i2c::I2CBus { TCA9548AComponent *parent_; }; -class TCA9548AComponent : public Component, public i2c::I2CDevice { +class TCA9548AComponent final : public Component, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tca9555/tca9555.h b/esphome/components/tca9555/tca9555.h index 19773a0e93..50037cbe92 100644 --- a/esphome/components/tca9555/tca9555.h +++ b/esphome/components/tca9555/tca9555.h @@ -7,9 +7,9 @@ namespace esphome::tca9555 { -class TCA9555Component : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander { +class TCA9555Component final : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { public: TCA9555Component() = default; @@ -47,7 +47,7 @@ class TCA9555Component : public Component, }; /// Helper class to expose a TCA9555 pin as an internal input GPIO pin. -class TCA9555GPIOPin : public GPIOPin, public Parented { +class TCA9555GPIOPin final : public GPIOPin, public Parented { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/tcl112/tcl112.h b/esphome/components/tcl112/tcl112.h index 0aef2decc8..21eb618947 100644 --- a/esphome/components/tcl112/tcl112.h +++ b/esphome/components/tcl112/tcl112.h @@ -8,7 +8,7 @@ namespace esphome::tcl112 { const float TCL112_TEMP_MAX = 31.0; const float TCL112_TEMP_MIN = 16.0; -class Tcl112Climate : public climate_ir::ClimateIR { +class Tcl112Climate final : public climate_ir::ClimateIR { public: Tcl112Climate() : climate_ir::ClimateIR(TCL112_TEMP_MIN, TCL112_TEMP_MAX, .5f, true, true, diff --git a/esphome/components/tcs34725/tcs34725.h b/esphome/components/tcs34725/tcs34725.h index 15e4fae52f..79b49bc810 100644 --- a/esphome/components/tcs34725/tcs34725.h +++ b/esphome/components/tcs34725/tcs34725.h @@ -35,7 +35,7 @@ enum TCS34725Gain { TCS34725_GAIN_60X = 0x03, }; -class TCS34725Component : public PollingComponent, public i2c::I2CDevice { +class TCS34725Component final : public PollingComponent, public i2c::I2CDevice { public: void set_integration_time(TCS34725IntegrationTime integration_time); void set_gain(TCS34725Gain gain); diff --git a/esphome/components/tee501/tee501.h b/esphome/components/tee501/tee501.h index 4a08291318..bbd63a4e2b 100644 --- a/esphome/components/tee501/tee501.h +++ b/esphome/components/tee501/tee501.h @@ -7,7 +7,7 @@ namespace esphome::tee501 { /// This class implements support for the tee501 of temperature i2c sensors. -class TEE501Component : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class TEE501Component final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/teleinfo/sensor/teleinfo_sensor.h b/esphome/components/teleinfo/sensor/teleinfo_sensor.h index 37736c4e73..f4a27fa08b 100644 --- a/esphome/components/teleinfo/sensor/teleinfo_sensor.h +++ b/esphome/components/teleinfo/sensor/teleinfo_sensor.h @@ -4,7 +4,7 @@ namespace esphome::teleinfo { -class TeleInfoSensor : public TeleInfoListener, public sensor::Sensor, public Component { +class TeleInfoSensor final : public TeleInfoListener, public sensor::Sensor, public Component { public: TeleInfoSensor(const char *tag); void publish_val(const std::string &val) override; diff --git a/esphome/components/teleinfo/teleinfo.h b/esphome/components/teleinfo/teleinfo.h index eeab3b5103..83ea1474f2 100644 --- a/esphome/components/teleinfo/teleinfo.h +++ b/esphome/components/teleinfo/teleinfo.h @@ -20,7 +20,7 @@ class TeleInfoListener { std::string tag; virtual void publish_val(const std::string &val){}; }; -class TeleInfo : public PollingComponent, public uart::UARTDevice { +class TeleInfo final : public PollingComponent, public uart::UARTDevice { public: TeleInfo(bool historical_mode); void register_teleinfo_listener(TeleInfoListener *listener); diff --git a/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.h b/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.h index f4c04a03a0..24ec00e671 100644 --- a/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.h +++ b/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.h @@ -3,7 +3,7 @@ #include "esphome/components/text_sensor/text_sensor.h" namespace esphome::teleinfo { -class TeleInfoTextSensor : public TeleInfoListener, public text_sensor::TextSensor, public Component { +class TeleInfoTextSensor final : public TeleInfoListener, public text_sensor::TextSensor, public Component { public: TeleInfoTextSensor(const char *tag); void publish_val(const std::string &val) override; diff --git a/esphome/components/tem3200/tem3200.h b/esphome/components/tem3200/tem3200.h index 5c73a25fbb..ad8d0154f3 100644 --- a/esphome/components/tem3200/tem3200.h +++ b/esphome/components/tem3200/tem3200.h @@ -7,7 +7,7 @@ namespace esphome::tem3200 { /// This class implements support for the tem3200 pressure and temperature i2c sensors. -class TEM3200Component : public PollingComponent, public i2c::I2CDevice { +class TEM3200Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_raw_pressure_sensor(sensor::Sensor *raw_pressure_sensor) { From bdd51bd4768e174e8e6ccb097530b1aab9f795f0 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:32:35 +1200 Subject: [PATCH 0738/1815] Mark configurable classes as final (16/21: sm10bit_base-ssd1331_spi) (#16967) --- .../components/sm10bit_base/sm10bit_base.h | 2 +- esphome/components/sm16716/sm16716.h | 4 +-- esphome/components/sm2135/sm2135.h | 4 +-- esphome/components/sm2235/sm2235.h | 2 +- esphome/components/sm2335/sm2335.h | 2 +- esphome/components/sm300d2/sm300d2.h | 2 +- esphome/components/sml/sensor/sml_sensor.h | 2 +- esphome/components/sml/sml.h | 2 +- .../sml/text_sensor/sml_text_sensor.h | 2 +- esphome/components/smt100/smt100.h | 2 +- esphome/components/sn74hc165/sn74hc165.h | 4 +-- esphome/components/sn74hc595/sn74hc595.h | 10 +++---- esphome/components/sntp/sntp_component.h | 2 +- esphome/components/sonoff_d1/sonoff_d1.h | 2 +- esphome/components/sound_level/sound_level.h | 6 ++-- esphome/components/spa06_i2c/spa06_i2c.h | 2 +- esphome/components/spa06_spi/spa06_spi.h | 6 ++-- esphome/components/speaker/automation.h | 16 +++++----- .../speaker/media_player/audio_pipeline.h | 2 +- .../speaker/media_player/automation.h | 3 +- .../media_player/speaker_media_player.h | 6 ++-- .../components/speaker_source/automation.h | 2 +- .../speaker_source_media_player.h | 2 +- esphome/components/speed/fan/speed_fan.h | 2 +- esphome/components/spi/spi.h | 2 +- esphome/components/spi_device/spi_device.h | 6 ++-- .../components/spi_led_strip/spi_led_strip.h | 6 ++-- esphome/components/sprinkler/automation.h | 30 +++++++++---------- esphome/components/sprinkler/sprinkler.h | 6 ++-- esphome/components/sps30/automation.h | 6 ++-- esphome/components/sps30/sps30.h | 2 +- esphome/components/ssd1306_i2c/ssd1306_i2c.h | 2 +- esphome/components/ssd1306_spi/ssd1306_spi.h | 6 ++-- esphome/components/ssd1322_spi/ssd1322_spi.h | 6 ++-- esphome/components/ssd1325_spi/ssd1325_spi.h | 6 ++-- esphome/components/ssd1327_i2c/ssd1327_i2c.h | 2 +- esphome/components/ssd1327_spi/ssd1327_spi.h | 6 ++-- esphome/components/ssd1331_spi/ssd1331_spi.h | 6 ++-- 38 files changed, 91 insertions(+), 90 deletions(-) diff --git a/esphome/components/sm10bit_base/sm10bit_base.h b/esphome/components/sm10bit_base/sm10bit_base.h index b419b86dbf..a22c4da36e 100644 --- a/esphome/components/sm10bit_base/sm10bit_base.h +++ b/esphome/components/sm10bit_base/sm10bit_base.h @@ -27,7 +27,7 @@ class Sm10BitBase : public Component { void dump_config() override; void loop() override; - class Channel : public output::FloatOutput { + class Channel final : public output::FloatOutput { public: void set_parent(Sm10BitBase *parent) { parent_ = parent; } void set_channel(uint8_t channel) { channel_ = channel; } diff --git a/esphome/components/sm16716/sm16716.h b/esphome/components/sm16716/sm16716.h index 09deb2e8bf..8a76fd86f0 100644 --- a/esphome/components/sm16716/sm16716.h +++ b/esphome/components/sm16716/sm16716.h @@ -7,7 +7,7 @@ namespace esphome::sm16716 { -class SM16716 : public Component { +class SM16716 final : public Component { public: class Channel; @@ -25,7 +25,7 @@ class SM16716 : public Component { /// Send new values if they were updated. void loop() override; - class Channel : public output::FloatOutput { + class Channel final : public output::FloatOutput { public: void set_parent(SM16716 *parent) { parent_ = parent; } void set_channel(uint8_t channel) { channel_ = channel; } diff --git a/esphome/components/sm2135/sm2135.h b/esphome/components/sm2135/sm2135.h index 040ec14b7f..6bf77cf554 100644 --- a/esphome/components/sm2135/sm2135.h +++ b/esphome/components/sm2135/sm2135.h @@ -21,7 +21,7 @@ enum SM2135Current : uint8_t { SM2135_CURRENT_60MA = 0x0A, }; -class SM2135 : public Component { +class SM2135 final : public Component { public: class Channel; @@ -49,7 +49,7 @@ class SM2135 : public Component { /// Send new values if they were updated. void loop() override; - class Channel : public output::FloatOutput { + class Channel final : public output::FloatOutput { public: void set_parent(SM2135 *parent) { parent_ = parent; } void set_channel(uint8_t channel) { channel_ = channel; } diff --git a/esphome/components/sm2235/sm2235.h b/esphome/components/sm2235/sm2235.h index cdb754e298..dbb51945f6 100644 --- a/esphome/components/sm2235/sm2235.h +++ b/esphome/components/sm2235/sm2235.h @@ -6,7 +6,7 @@ namespace esphome::sm2235 { -class SM2235 : public sm10bit_base::Sm10BitBase { +class SM2235 final : public sm10bit_base::Sm10BitBase { public: SM2235() = default; diff --git a/esphome/components/sm2335/sm2335.h b/esphome/components/sm2335/sm2335.h index 44e0e5b03f..7c4f0269aa 100644 --- a/esphome/components/sm2335/sm2335.h +++ b/esphome/components/sm2335/sm2335.h @@ -6,7 +6,7 @@ namespace esphome::sm2335 { -class SM2335 : public sm10bit_base::Sm10BitBase { +class SM2335 final : public sm10bit_base::Sm10BitBase { public: SM2335() = default; diff --git a/esphome/components/sm300d2/sm300d2.h b/esphome/components/sm300d2/sm300d2.h index 629e758e30..87c60e92a1 100644 --- a/esphome/components/sm300d2/sm300d2.h +++ b/esphome/components/sm300d2/sm300d2.h @@ -6,7 +6,7 @@ namespace esphome::sm300d2 { -class SM300D2Sensor : public PollingComponent, public uart::UARTDevice { +class SM300D2Sensor final : public PollingComponent, public uart::UARTDevice { public: void set_co2_sensor(sensor::Sensor *co2_sensor) { co2_sensor_ = co2_sensor; } void set_formaldehyde_sensor(sensor::Sensor *formaldehyde_sensor) { formaldehyde_sensor_ = formaldehyde_sensor; } diff --git a/esphome/components/sml/sensor/sml_sensor.h b/esphome/components/sml/sensor/sml_sensor.h index d2f8a7743f..a73af28f66 100644 --- a/esphome/components/sml/sensor/sml_sensor.h +++ b/esphome/components/sml/sensor/sml_sensor.h @@ -4,7 +4,7 @@ namespace esphome::sml { -class SmlSensor : public SmlListener, public sensor::Sensor, public Component { +class SmlSensor final : public SmlListener, public sensor::Sensor, public Component { public: SmlSensor(std::string server_id, std::string obis_code); void publish_val(const ObisInfo &obis_info) override; diff --git a/esphome/components/sml/sml.h b/esphome/components/sml/sml.h index 60a80e3ad8..b59526648d 100644 --- a/esphome/components/sml/sml.h +++ b/esphome/components/sml/sml.h @@ -17,7 +17,7 @@ class SmlListener { virtual void publish_val(const ObisInfo &obis_info){}; }; -class Sml : public Component, public uart::UARTDevice { +class Sml final : public Component, public uart::UARTDevice { public: void register_sml_listener(SmlListener *listener); void loop() override; diff --git a/esphome/components/sml/text_sensor/sml_text_sensor.h b/esphome/components/sml/text_sensor/sml_text_sensor.h index 6194f22349..d445d514e9 100644 --- a/esphome/components/sml/text_sensor/sml_text_sensor.h +++ b/esphome/components/sml/text_sensor/sml_text_sensor.h @@ -6,7 +6,7 @@ namespace esphome::sml { -class SmlTextSensor : public SmlListener, public text_sensor::TextSensor, public Component { +class SmlTextSensor final : public SmlListener, public text_sensor::TextSensor, public Component { public: SmlTextSensor(std::string server_id, std::string obis_code, SmlType format); void publish_val(const ObisInfo &obis_info) override; diff --git a/esphome/components/smt100/smt100.h b/esphome/components/smt100/smt100.h index b68151eeb4..55977a5caf 100644 --- a/esphome/components/smt100/smt100.h +++ b/esphome/components/smt100/smt100.h @@ -6,7 +6,7 @@ namespace esphome::smt100 { -class SMT100Component : public PollingComponent, public uart::UARTDevice { +class SMT100Component final : public PollingComponent, public uart::UARTDevice { static const uint16_t MAX_LINE_LENGTH = 31; public: diff --git a/esphome/components/sn74hc165/sn74hc165.h b/esphome/components/sn74hc165/sn74hc165.h index 596f2eb4f5..9e80aa67bf 100644 --- a/esphome/components/sn74hc165/sn74hc165.h +++ b/esphome/components/sn74hc165/sn74hc165.h @@ -8,7 +8,7 @@ namespace esphome::sn74hc165 { -class SN74HC165Component : public Component { +class SN74HC165Component final : public Component { public: SN74HC165Component() = default; @@ -40,7 +40,7 @@ class SN74HC165Component : public Component { }; /// Helper class to expose a SC74HC165 pin as an internal input GPIO pin. -class SN74HC165GPIOPin : public GPIOPin, public Parented { +class SN74HC165GPIOPin final : public GPIOPin, public Parented { public: void setup() override {} void pin_mode(gpio::Flags flags) override {} diff --git a/esphome/components/sn74hc595/sn74hc595.h b/esphome/components/sn74hc595/sn74hc595.h index 23977e3d04..0b291b9ee5 100644 --- a/esphome/components/sn74hc595/sn74hc595.h +++ b/esphome/components/sn74hc595/sn74hc595.h @@ -47,7 +47,7 @@ class SN74HC595Component : public Component { }; /// Helper class to expose a SC74HC595 pin as an internal output GPIO pin. -class SN74HC595GPIOPin : public GPIOPin, public Parented { +class SN74HC595GPIOPin final : public GPIOPin, public Parented { public: void setup() override {} void pin_mode(gpio::Flags flags) override {} @@ -66,7 +66,7 @@ class SN74HC595GPIOPin : public GPIOPin, public Parented { bool inverted_; }; -class SN74HC595GPIOComponent : public SN74HC595Component { +class SN74HC595GPIOComponent final : public SN74HC595Component { public: void setup() override; void set_data_pin(GPIOPin *pin) { data_pin_ = pin; } @@ -80,9 +80,9 @@ class SN74HC595GPIOComponent : public SN74HC595Component { }; #ifdef USE_SPI -class SN74HC595SPIComponent : public SN74HC595Component, - public spi::SPIDevice { +class SN74HC595SPIComponent final : public SN74HC595Component, + public spi::SPIDevice { public: void setup() override; diff --git a/esphome/components/sntp/sntp_component.h b/esphome/components/sntp/sntp_component.h index ef737c1978..686fb30d25 100644 --- a/esphome/components/sntp/sntp_component.h +++ b/esphome/components/sntp/sntp_component.h @@ -15,7 +15,7 @@ namespace esphome::sntp { /// The C library (newlib) available on ESPs only supports TZ strings that specify an offset and DST info; /// you cannot specify zone names or paths to zoneinfo files. /// \see https://www.gnu.org/software/libc/manual/html_node/TZ-Variable.html -class SNTPComponent : public time::RealTimeClock { +class SNTPComponent final : public time::RealTimeClock { public: SNTPComponent(const std::array &servers) : servers_(servers) {} diff --git a/esphome/components/sonoff_d1/sonoff_d1.h b/esphome/components/sonoff_d1/sonoff_d1.h index a92877e6c8..b7fcb1efa7 100644 --- a/esphome/components/sonoff_d1/sonoff_d1.h +++ b/esphome/components/sonoff_d1/sonoff_d1.h @@ -41,7 +41,7 @@ namespace esphome::sonoff_d1 { -class SonoffD1Output : public light::LightOutput, public uart::UARTDevice, public Component { +class SonoffD1Output final : public light::LightOutput, public uart::UARTDevice, public Component { public: // LightOutput methods light::LightTraits get_traits() override; diff --git a/esphome/components/sound_level/sound_level.h b/esphome/components/sound_level/sound_level.h index aabea62ca4..94c18421ba 100644 --- a/esphome/components/sound_level/sound_level.h +++ b/esphome/components/sound_level/sound_level.h @@ -12,7 +12,7 @@ namespace esphome::sound_level { -class SoundLevelComponent : public Component { +class SoundLevelComponent final : public Component { public: void dump_config() override; void setup() override; @@ -59,12 +59,12 @@ class SoundLevelComponent : public Component { uint32_t measurement_duration_ms_; }; -template class StartAction : public Action, public Parented { +template class StartAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->start(); } }; -template class StopAction : public Action, public Parented { +template class StopAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->stop(); } }; diff --git a/esphome/components/spa06_i2c/spa06_i2c.h b/esphome/components/spa06_i2c/spa06_i2c.h index 6b4bce3a4e..05e60cbb5d 100644 --- a/esphome/components/spa06_i2c/spa06_i2c.h +++ b/esphome/components/spa06_i2c/spa06_i2c.h @@ -4,7 +4,7 @@ namespace esphome::spa06_i2c { -class SPA06I2CComponent : public spa06_base::SPA06Component, public i2c::I2CDevice { +class SPA06I2CComponent final : public spa06_base::SPA06Component, public i2c::I2CDevice { public: bool spa_read_byte(uint8_t a_register, uint8_t *data) override { return read_byte(a_register, data); } bool spa_write_byte(uint8_t a_register, uint8_t data) override { return write_byte(a_register, data); } diff --git a/esphome/components/spa06_spi/spa06_spi.h b/esphome/components/spa06_spi/spa06_spi.h index ffbc162d6f..56d72df620 100644 --- a/esphome/components/spa06_spi/spa06_spi.h +++ b/esphome/components/spa06_spi/spa06_spi.h @@ -5,9 +5,9 @@ namespace esphome::spa06_spi { -class SPA06SPIComponent : public spa06_base::SPA06Component, - public spi::SPIDevice { +class SPA06SPIComponent final : public spa06_base::SPA06Component, + public spi::SPIDevice { void setup() override; bool spa_read_byte(uint8_t a_register, uint8_t *data) override; bool spa_write_byte(uint8_t a_register, uint8_t data) override; diff --git a/esphome/components/speaker/automation.h b/esphome/components/speaker/automation.h index 9997b064d5..443588a04c 100644 --- a/esphome/components/speaker/automation.h +++ b/esphome/components/speaker/automation.h @@ -7,7 +7,7 @@ namespace esphome::speaker { -template class PlayAction : public Action, public Parented { +template class PlayAction final : public Action, public Parented { public: void set_data_template(std::vector (*func)(Ts...)) { this->data_.func = func; @@ -38,12 +38,12 @@ template class PlayAction : public Action, public Parente } data_; }; -template class VolumeSetAction : public Action, public Parented { +template class VolumeSetAction final : public Action, public Parented { TEMPLATABLE_VALUE(float, volume) void play(const Ts &...x) override { this->parent_->set_volume(this->volume_.value(x...)); } }; -template class MuteOnAction : public Action { +template class MuteOnAction final : public Action { public: explicit MuteOnAction(Speaker *speaker) : speaker_(speaker) {} @@ -53,7 +53,7 @@ template class MuteOnAction : public Action { Speaker *speaker_; }; -template class MuteOffAction : public Action { +template class MuteOffAction final : public Action { public: explicit MuteOffAction(Speaker *speaker) : speaker_(speaker) {} @@ -63,22 +63,22 @@ template class MuteOffAction : public Action { Speaker *speaker_; }; -template class StopAction : public Action, public Parented { +template class StopAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->stop(); } }; -template class FinishAction : public Action, public Parented { +template class FinishAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->finish(); } }; -template class IsPlayingCondition : public Condition, public Parented { +template class IsPlayingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_running(); } }; -template class IsStoppedCondition : public Condition, public Parented { +template class IsStoppedCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_stopped(); } }; diff --git a/esphome/components/speaker/media_player/audio_pipeline.h b/esphome/components/speaker/media_player/audio_pipeline.h index 89f4707ab3..02dad15de9 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.h +++ b/esphome/components/speaker/media_player/audio_pipeline.h @@ -56,7 +56,7 @@ struct InfoErrorEvent { optional decoding_err; }; -class AudioPipeline { +class AudioPipeline final { public: /// @param speaker ESPHome speaker component for pipeline's audio output /// @param buffer_size Size of the buffer in bytes between the reader and decoder diff --git a/esphome/components/speaker/media_player/automation.h b/esphome/components/speaker/media_player/automation.h index 7843399866..f9e2127993 100644 --- a/esphome/components/speaker/media_player/automation.h +++ b/esphome/components/speaker/media_player/automation.h @@ -9,7 +9,8 @@ namespace esphome::speaker { -template class PlayOnDeviceMediaAction : public Action, public Parented { +template +class PlayOnDeviceMediaAction final : public Action, public Parented { TEMPLATABLE_VALUE(audio::AudioFile *, audio_file) TEMPLATABLE_VALUE(bool, announcement) TEMPLATABLE_VALUE(bool, enqueue) diff --git a/esphome/components/speaker/media_player/speaker_media_player.h b/esphome/components/speaker/media_player/speaker_media_player.h index 2d80377312..6470fb925c 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.h +++ b/esphome/components/speaker/media_player/speaker_media_player.h @@ -42,11 +42,11 @@ struct VolumeRestoreState { bool is_muted; }; -class SpeakerMediaPlayer : public Component, - public media_player::MediaPlayer +class SpeakerMediaPlayer final : public Component, + public media_player::MediaPlayer #ifdef USE_OTA_STATE_LISTENER , - public ota::OTAGlobalStateListener + public ota::OTAGlobalStateListener #endif { public: diff --git a/esphome/components/speaker_source/automation.h b/esphome/components/speaker_source/automation.h index b436149a03..a03fa42477 100644 --- a/esphome/components/speaker_source/automation.h +++ b/esphome/components/speaker_source/automation.h @@ -9,7 +9,7 @@ namespace esphome::speaker_source { -template class SetPlaylistDelayAction : public Action { +template class SetPlaylistDelayAction final : public Action { public: explicit SetPlaylistDelayAction(SpeakerSourceMediaPlayer *parent) : parent_(parent) {} diff --git a/esphome/components/speaker_source/speaker_source_media_player.h b/esphome/components/speaker_source/speaker_source_media_player.h index 652390edd2..ab1f8edfed 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.h +++ b/esphome/components/speaker_source/speaker_source_media_player.h @@ -146,7 +146,7 @@ struct VolumeRestoreState { bool is_muted; }; -class SpeakerSourceMediaPlayer : public Component, public media_player::MediaPlayer { +class SpeakerSourceMediaPlayer final : public Component, public media_player::MediaPlayer { friend struct SourceBinding; public: diff --git a/esphome/components/speed/fan/speed_fan.h b/esphome/components/speed/fan/speed_fan.h index c618d6bc5f..510b3e9621 100644 --- a/esphome/components/speed/fan/speed_fan.h +++ b/esphome/components/speed/fan/speed_fan.h @@ -7,7 +7,7 @@ namespace esphome::speed { -class SpeedFan : public Component, public fan::Fan { +class SpeedFan final : public Component, public fan::Fan { public: SpeedFan(int speed_count) : speed_count_(speed_count) {} void setup() override; diff --git a/esphome/components/spi/spi.h b/esphome/components/spi/spi.h index e6f592c6e4..cada29b0d7 100644 --- a/esphome/components/spi/spi.h +++ b/esphome/components/spi/spi.h @@ -334,7 +334,7 @@ class SPIBus { class SPIClient; -class SPIComponent : public Component { +class SPIComponent final : public Component { public: SPIDelegate *register_device(SPIClient *device, SPIMode mode, SPIBitOrder bit_order, uint32_t data_rate, GPIOPin *cs_pin, bool release_device, bool write_only); diff --git a/esphome/components/spi_device/spi_device.h b/esphome/components/spi_device/spi_device.h index 3a2523fbab..506090fc58 100644 --- a/esphome/components/spi_device/spi_device.h +++ b/esphome/components/spi_device/spi_device.h @@ -5,9 +5,9 @@ namespace esphome::spi_device { -class SPIDeviceComponent : public Component, - public spi::SPIDevice { +class SPIDeviceComponent final : public Component, + public spi::SPIDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/spi_led_strip/spi_led_strip.h b/esphome/components/spi_led_strip/spi_led_strip.h index e2bcd5af63..20b9c25c2e 100644 --- a/esphome/components/spi_led_strip/spi_led_strip.h +++ b/esphome/components/spi_led_strip/spi_led_strip.h @@ -8,9 +8,9 @@ namespace esphome::spi_led_strip { static const char *const TAG = "spi_led_strip"; -class SpiLedStrip : public light::AddressableLight, - public spi::SPIDevice { +class SpiLedStrip final : public light::AddressableLight, + public spi::SPIDevice { public: SpiLedStrip(uint16_t num_leds); void setup() override; diff --git a/esphome/components/sprinkler/automation.h b/esphome/components/sprinkler/automation.h index c6fe2e4e02..beeec96b98 100644 --- a/esphome/components/sprinkler/automation.h +++ b/esphome/components/sprinkler/automation.h @@ -6,7 +6,7 @@ namespace esphome::sprinkler { -template class SetDividerAction : public Action { +template class SetDividerAction final : public Action { public: explicit SetDividerAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -18,7 +18,7 @@ template class SetDividerAction : public Action { Sprinkler *sprinkler_; }; -template class SetMultiplierAction : public Action { +template class SetMultiplierAction final : public Action { public: explicit SetMultiplierAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -30,7 +30,7 @@ template class SetMultiplierAction : public Action { Sprinkler *sprinkler_; }; -template class QueueValveAction : public Action { +template class QueueValveAction final : public Action { public: explicit QueueValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -46,7 +46,7 @@ template class QueueValveAction : public Action { Sprinkler *sprinkler_; }; -template class ClearQueuedValvesAction : public Action { +template class ClearQueuedValvesAction final : public Action { public: explicit ClearQueuedValvesAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -56,7 +56,7 @@ template class ClearQueuedValvesAction : public Action { Sprinkler *sprinkler_; }; -template class SetRepeatAction : public Action { +template class SetRepeatAction final : public Action { public: explicit SetRepeatAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -68,7 +68,7 @@ template class SetRepeatAction : public Action { Sprinkler *sprinkler_; }; -template class SetRunDurationAction : public Action { +template class SetRunDurationAction final : public Action { public: explicit SetRunDurationAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -84,7 +84,7 @@ template class SetRunDurationAction : public Action { Sprinkler *sprinkler_; }; -template class StartFromQueueAction : public Action { +template class StartFromQueueAction final : public Action { public: explicit StartFromQueueAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -94,7 +94,7 @@ template class StartFromQueueAction : public Action { Sprinkler *sprinkler_; }; -template class StartFullCycleAction : public Action { +template class StartFullCycleAction final : public Action { public: explicit StartFullCycleAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -104,7 +104,7 @@ template class StartFullCycleAction : public Action { Sprinkler *sprinkler_; }; -template class StartSingleValveAction : public Action { +template class StartSingleValveAction final : public Action { public: explicit StartSingleValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -122,7 +122,7 @@ template class StartSingleValveAction : public Action { TemplatableValue valve_to_start_{}; }; -template class ShutdownAction : public Action { +template class ShutdownAction final : public Action { public: explicit ShutdownAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -132,7 +132,7 @@ template class ShutdownAction : public Action { Sprinkler *sprinkler_; }; -template class NextValveAction : public Action { +template class NextValveAction final : public Action { public: explicit NextValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -142,7 +142,7 @@ template class NextValveAction : public Action { Sprinkler *sprinkler_; }; -template class PreviousValveAction : public Action { +template class PreviousValveAction final : public Action { public: explicit PreviousValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -152,7 +152,7 @@ template class PreviousValveAction : public Action { Sprinkler *sprinkler_; }; -template class PauseAction : public Action { +template class PauseAction final : public Action { public: explicit PauseAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -162,7 +162,7 @@ template class PauseAction : public Action { Sprinkler *sprinkler_; }; -template class ResumeAction : public Action { +template class ResumeAction final : public Action { public: explicit ResumeAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -172,7 +172,7 @@ template class ResumeAction : public Action { Sprinkler *sprinkler_; }; -template class ResumeOrStartAction : public Action { +template class ResumeOrStartAction final : public Action { public: explicit ResumeOrStartAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} diff --git a/esphome/components/sprinkler/sprinkler.h b/esphome/components/sprinkler/sprinkler.h index 2598a5606a..bd610f7ad3 100644 --- a/esphome/components/sprinkler/sprinkler.h +++ b/esphome/components/sprinkler/sprinkler.h @@ -70,7 +70,7 @@ struct SprinklerValve { std::unique_ptr> valve_turn_on_automation; }; -class SprinklerControllerNumber : public number::Number, public Component { +class SprinklerControllerNumber final : public number::Number, public Component { public: void setup() override; void dump_config() override; @@ -89,7 +89,7 @@ class SprinklerControllerNumber : public number::Number, public Component { ESPPreferenceObject pref_; }; -class SprinklerControllerSwitch : public switch_::Switch, public Component { +class SprinklerControllerSwitch final : public switch_::Switch, public Component { public: SprinklerControllerSwitch(); @@ -173,7 +173,7 @@ class SprinklerValveRunRequest { SprinklerValveRunRequestOrigin origin_{USER}; }; -class Sprinkler : public Component { +class Sprinkler final : public Component { public: Sprinkler(); Sprinkler(const char *name); diff --git a/esphome/components/sps30/automation.h b/esphome/components/sps30/automation.h index e58f857eb3..ba978e7770 100644 --- a/esphome/components/sps30/automation.h +++ b/esphome/components/sps30/automation.h @@ -6,17 +6,17 @@ namespace esphome::sps30 { -template class StartFanAction : public Action, public Parented { +template class StartFanAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->start_fan_cleaning(); } }; -template class StartMeasurementAction : public Action, public Parented { +template class StartMeasurementAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->start_measurement(); } }; -template class StopMeasurementAction : public Action, public Parented { +template class StopMeasurementAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->stop_measurement(); } }; diff --git a/esphome/components/sps30/sps30.h b/esphome/components/sps30/sps30.h index ccb3e8ff41..10b89c844b 100644 --- a/esphome/components/sps30/sps30.h +++ b/esphome/components/sps30/sps30.h @@ -8,7 +8,7 @@ namespace esphome::sps30 { /// This class implements support for the Sensirion SPS30 i2c/UART Particulate Matter /// PM1.0, PM2.5, PM4, PM10 Air Quality sensors. -class SPS30Component : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SPS30Component final : public PollingComponent, public sensirion_common::SensirionI2CDevice { public: void set_pm_1_0_sensor(sensor::Sensor *pm_1_0) { pm_1_0_sensor_ = pm_1_0; } void set_pm_2_5_sensor(sensor::Sensor *pm_2_5) { pm_2_5_sensor_ = pm_2_5; } diff --git a/esphome/components/ssd1306_i2c/ssd1306_i2c.h b/esphome/components/ssd1306_i2c/ssd1306_i2c.h index 0316da0e77..54c7d86287 100644 --- a/esphome/components/ssd1306_i2c/ssd1306_i2c.h +++ b/esphome/components/ssd1306_i2c/ssd1306_i2c.h @@ -6,7 +6,7 @@ namespace esphome::ssd1306_i2c { -class I2CSSD1306 : public ssd1306_base::SSD1306, public i2c::I2CDevice { +class I2CSSD1306 final : public ssd1306_base::SSD1306, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ssd1306_spi/ssd1306_spi.h b/esphome/components/ssd1306_spi/ssd1306_spi.h index f8346033b3..948d099d0f 100644 --- a/esphome/components/ssd1306_spi/ssd1306_spi.h +++ b/esphome/components/ssd1306_spi/ssd1306_spi.h @@ -6,9 +6,9 @@ namespace esphome::ssd1306_spi { -class SPISSD1306 : public ssd1306_base::SSD1306, - public spi::SPIDevice { +class SPISSD1306 final : public ssd1306_base::SSD1306, + public spi::SPIDevice { public: void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } diff --git a/esphome/components/ssd1322_spi/ssd1322_spi.h b/esphome/components/ssd1322_spi/ssd1322_spi.h index 31d17d0ef1..1ac9654109 100644 --- a/esphome/components/ssd1322_spi/ssd1322_spi.h +++ b/esphome/components/ssd1322_spi/ssd1322_spi.h @@ -6,9 +6,9 @@ namespace esphome::ssd1322_spi { -class SPISSD1322 : public ssd1322_base::SSD1322, - public spi::SPIDevice { +class SPISSD1322 final : public ssd1322_base::SSD1322, + public spi::SPIDevice { public: void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } diff --git a/esphome/components/ssd1325_spi/ssd1325_spi.h b/esphome/components/ssd1325_spi/ssd1325_spi.h index 32cbb28fd8..3202eabec5 100644 --- a/esphome/components/ssd1325_spi/ssd1325_spi.h +++ b/esphome/components/ssd1325_spi/ssd1325_spi.h @@ -6,9 +6,9 @@ namespace esphome::ssd1325_spi { -class SPISSD1325 : public ssd1325_base::SSD1325, - public spi::SPIDevice { +class SPISSD1325 final : public ssd1325_base::SSD1325, + public spi::SPIDevice { public: void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } diff --git a/esphome/components/ssd1327_i2c/ssd1327_i2c.h b/esphome/components/ssd1327_i2c/ssd1327_i2c.h index f08ef94fef..75f854d3da 100644 --- a/esphome/components/ssd1327_i2c/ssd1327_i2c.h +++ b/esphome/components/ssd1327_i2c/ssd1327_i2c.h @@ -6,7 +6,7 @@ namespace esphome::ssd1327_i2c { -class I2CSSD1327 : public ssd1327_base::SSD1327, public i2c::I2CDevice { +class I2CSSD1327 final : public ssd1327_base::SSD1327, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ssd1327_spi/ssd1327_spi.h b/esphome/components/ssd1327_spi/ssd1327_spi.h index fd1ed0357f..cb7d5e2181 100644 --- a/esphome/components/ssd1327_spi/ssd1327_spi.h +++ b/esphome/components/ssd1327_spi/ssd1327_spi.h @@ -6,9 +6,9 @@ namespace esphome::ssd1327_spi { -class SPISSD1327 : public ssd1327_base::SSD1327, - public spi::SPIDevice { +class SPISSD1327 final : public ssd1327_base::SSD1327, + public spi::SPIDevice { public: void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } diff --git a/esphome/components/ssd1331_spi/ssd1331_spi.h b/esphome/components/ssd1331_spi/ssd1331_spi.h index acdc004b26..add010712c 100644 --- a/esphome/components/ssd1331_spi/ssd1331_spi.h +++ b/esphome/components/ssd1331_spi/ssd1331_spi.h @@ -6,9 +6,9 @@ namespace esphome::ssd1331_spi { -class SPISSD1331 : public ssd1331_base::SSD1331, - public spi::SPIDevice { +class SPISSD1331 final : public ssd1331_base::SSD1331, + public spi::SPIDevice { public: void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } From cfdd6d383f3d074a9730ed41f1d55baf5d9534ee Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:32:42 +1200 Subject: [PATCH 0739/1815] Mark configurable classes as final (19/21: uart-wl_134) (#16970) --- esphome/components/uart/automation.h | 2 +- esphome/components/uart/button/uart_button.h | 2 +- esphome/components/uart/event/uart_event.h | 2 +- .../uart/packet_transport/uart_transport.h | 2 +- esphome/components/uart/switch/uart_switch.h | 2 +- .../components/uart/uart_component_esp8266.h | 2 +- .../components/uart/uart_component_esp_idf.h | 2 +- esphome/components/uart/uart_component_host.h | 2 +- .../components/uart/uart_component_libretiny.h | 2 +- .../components/uart/uart_component_rp2040.h | 2 +- esphome/components/uart/uart_debugger.h | 4 ++-- esphome/components/udp/automation.h | 2 +- .../udp/packet_transport/udp_transport.h | 2 +- esphome/components/udp/udp_component.h | 2 +- esphome/components/ufire_ec/ufire_ec.h | 6 +++--- esphome/components/ufire_ise/ufire_ise.h | 8 ++++---- esphome/components/uln2003/uln2003.h | 2 +- .../components/ultrasonic/ultrasonic_sensor.h | 2 +- esphome/components/update/automation.h | 6 +++--- .../climate/uponor_smatrix_climate.h | 2 +- .../sensor/uponor_smatrix_sensor.h | 2 +- .../components/uponor_smatrix/uponor_smatrix.h | 2 +- .../uptime/sensor/uptime_seconds_sensor.h | 2 +- .../uptime/sensor/uptime_timestamp_sensor.h | 2 +- .../uptime/text_sensor/uptime_text_sensor.h | 2 +- esphome/components/usb_cdc_acm/usb_cdc_acm.h | 4 ++-- esphome/components/usb_host/usb_host.h | 2 +- esphome/components/usb_uart/usb_uart.h | 2 +- esphome/components/valve/automation.h | 18 +++++++++--------- .../vbus/binary_sensor/vbus_binary_sensor.h | 18 +++++++++--------- esphome/components/vbus/vbus.h | 2 +- esphome/components/veml3235/veml3235.h | 2 +- esphome/components/veml7700/veml7700.h | 2 +- esphome/components/vl53l0x/vl53l0x_sensor.h | 2 +- .../voice_assistant/voice_assistant.h | 12 ++++++------ esphome/components/wake_on_lan/wake_on_lan.h | 2 +- .../web_server_base/web_server_base.h | 2 +- esphome/components/weikai_i2c/weikai_i2c.h | 2 +- esphome/components/weikai_spi/weikai_spi.h | 6 +++--- esphome/components/whirlpool/whirlpool.h | 2 +- esphome/components/whynter/whynter.h | 2 +- esphome/components/wiegand/wiegand.h | 8 ++++---- esphome/components/wifi/automation.h | 12 ++++++------ .../wifi_signal/wifi_signal_sensor.h | 4 ++-- esphome/components/wireguard/wireguard.h | 11 ++++++----- esphome/components/wl_134/wl_134.h | 2 +- 46 files changed, 92 insertions(+), 91 deletions(-) diff --git a/esphome/components/uart/automation.h b/esphome/components/uart/automation.h index c99caac97b..e5a9fa7c7b 100644 --- a/esphome/components/uart/automation.h +++ b/esphome/components/uart/automation.h @@ -7,7 +7,7 @@ namespace esphome::uart { -template class UARTWriteAction : public Action, public Parented { +template class UARTWriteAction final : public Action, public Parented { public: void set_data_template(std::vector (*func)(Ts...)) { // Stateless lambdas (generated by ESPHome) implicitly convert to function pointers diff --git a/esphome/components/uart/button/uart_button.h b/esphome/components/uart/button/uart_button.h index 2b530d3c4b..47f45d4899 100644 --- a/esphome/components/uart/button/uart_button.h +++ b/esphome/components/uart/button/uart_button.h @@ -8,7 +8,7 @@ namespace esphome::uart { -class UARTButton : public button::Button, public UARTDevice, public Component { +class UARTButton final : public button::Button, public UARTDevice, public Component { public: void set_data(std::vector &&data) { this->data_ = std::move(data); } void set_data(std::initializer_list data) { this->data_ = std::vector(data); } diff --git a/esphome/components/uart/event/uart_event.h b/esphome/components/uart/event/uart_event.h index 8a00b5894b..3960ffd5bb 100644 --- a/esphome/components/uart/event/uart_event.h +++ b/esphome/components/uart/event/uart_event.h @@ -7,7 +7,7 @@ namespace esphome::uart { -class UARTEvent : public event::Event, public UARTDevice, public Component { +class UARTEvent final : public event::Event, public UARTDevice, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/uart/packet_transport/uart_transport.h b/esphome/components/uart/packet_transport/uart_transport.h index 1c92af536e..b1ce8ac590 100644 --- a/esphome/components/uart/packet_transport/uart_transport.h +++ b/esphome/components/uart/packet_transport/uart_transport.h @@ -20,7 +20,7 @@ static const uint16_t MAX_PACKET_SIZE = 508; static const uint8_t FLAG_BYTE = 0x7E; static const uint8_t CONTROL_BYTE = 0x7D; -class UARTTransport : public packet_transport::PacketTransport, public UARTDevice { +class UARTTransport final : public packet_transport::PacketTransport, public UARTDevice { public: void loop() override; float get_setup_priority() const override { return setup_priority::PROCESSOR; } diff --git a/esphome/components/uart/switch/uart_switch.h b/esphome/components/uart/switch/uart_switch.h index 5730fc9b4b..c924c7d4e5 100644 --- a/esphome/components/uart/switch/uart_switch.h +++ b/esphome/components/uart/switch/uart_switch.h @@ -9,7 +9,7 @@ namespace esphome::uart { -class UARTSwitch : public switch_::Switch, public UARTDevice, public Component { +class UARTSwitch final : public switch_::Switch, public UARTDevice, public Component { public: void loop() override; diff --git a/esphome/components/uart/uart_component_esp8266.h b/esphome/components/uart/uart_component_esp8266.h index 7f844d9b65..ee3be3cd3a 100644 --- a/esphome/components/uart/uart_component_esp8266.h +++ b/esphome/components/uart/uart_component_esp8266.h @@ -46,7 +46,7 @@ class ESP8266SoftwareSerial { ISRInternalGPIOPin rx_pin_; }; -class ESP8266UartComponent : public UARTComponent, public Component { +class ESP8266UartComponent final : public UARTComponent, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/uart/uart_component_esp_idf.h b/esphome/components/uart/uart_component_esp_idf.h index ec4f2884b2..3b86368797 100644 --- a/esphome/components/uart/uart_component_esp_idf.h +++ b/esphome/components/uart/uart_component_esp_idf.h @@ -16,7 +16,7 @@ namespace esphome::uart { /// Thread safety: All public methods must only be called from the main loop. /// The ESP-IDF UART driver API does not guarantee thread safety, and ESPHome's /// peek byte state (has_peek_/peek_byte_) is not synchronized. -class IDFUARTComponent : public UARTComponent, public Component { +class IDFUARTComponent final : public UARTComponent, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/uart/uart_component_host.h b/esphome/components/uart/uart_component_host.h index a47e5649be..bca62debf1 100644 --- a/esphome/components/uart/uart_component_host.h +++ b/esphome/components/uart/uart_component_host.h @@ -8,7 +8,7 @@ namespace esphome::uart { -class HostUartComponent : public UARTComponent, public Component { +class HostUartComponent final : public UARTComponent, public Component { public: virtual ~HostUartComponent(); void setup() override; diff --git a/esphome/components/uart/uart_component_libretiny.h b/esphome/components/uart/uart_component_libretiny.h index 872ea86601..aa13a01392 100644 --- a/esphome/components/uart/uart_component_libretiny.h +++ b/esphome/components/uart/uart_component_libretiny.h @@ -10,7 +10,7 @@ namespace esphome::uart { -class LibreTinyUARTComponent : public UARTComponent, public Component { +class LibreTinyUARTComponent final : public UARTComponent, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/uart/uart_component_rp2040.h b/esphome/components/uart/uart_component_rp2040.h index 198c698af9..b16d8b12d9 100644 --- a/esphome/components/uart/uart_component_rp2040.h +++ b/esphome/components/uart/uart_component_rp2040.h @@ -13,7 +13,7 @@ namespace esphome::uart { -class RP2040UartComponent : public UARTComponent, public Component { +class RP2040UartComponent final : public UARTComponent, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/uart/uart_debugger.h b/esphome/components/uart/uart_debugger.h index da33bea70c..b69dcf0676 100644 --- a/esphome/components/uart/uart_debugger.h +++ b/esphome/components/uart/uart_debugger.h @@ -18,7 +18,7 @@ namespace esphome::uart { /// 'appropriate time' means exactly, is determined by a number of /// configurable constraints. E.g. when a given number of bytes is gathered /// and/or when no more data has been seen for a given time interval. -class UARTDebugger : public Component, public Trigger, StringRef> { +class UARTDebugger final : public Component, public Trigger, StringRef> { public: explicit UARTDebugger(UARTComponent *parent); void loop() override; @@ -73,7 +73,7 @@ class UARTDebugger : public Component, public Trigger class UDPWriteAction : public Action, public Parented { +template class UDPWriteAction final : public Action, public Parented { public: void set_data_template(std::vector (*func)(Ts...)) { this->data_.func = func; diff --git a/esphome/components/udp/packet_transport/udp_transport.h b/esphome/components/udp/packet_transport/udp_transport.h index 8621ddca48..e91a3e2a5a 100644 --- a/esphome/components/udp/packet_transport/udp_transport.h +++ b/esphome/components/udp/packet_transport/udp_transport.h @@ -8,7 +8,7 @@ namespace esphome::udp { -class UDPTransport : public packet_transport::PacketTransport, public Parented { +class UDPTransport final : public packet_transport::PacketTransport, public Parented { public: void setup() override; diff --git a/esphome/components/udp/udp_component.h b/esphome/components/udp/udp_component.h index fb0edf2ebd..274e0119ee 100644 --- a/esphome/components/udp/udp_component.h +++ b/esphome/components/udp/udp_component.h @@ -18,7 +18,7 @@ namespace esphome::udp { static const size_t MAX_PACKET_SIZE = 508; -class UDPComponent : public Component { +class UDPComponent final : public Component { public: void set_addresses(std::initializer_list addresses) { this->addresses_ = addresses; } /// Prevent accidental use of std::string which would dangle diff --git a/esphome/components/ufire_ec/ufire_ec.h b/esphome/components/ufire_ec/ufire_ec.h index fce6258632..0928fda9ee 100644 --- a/esphome/components/ufire_ec/ufire_ec.h +++ b/esphome/components/ufire_ec/ufire_ec.h @@ -24,7 +24,7 @@ static const uint8_t COMMAND_CALIBRATE_PROBE = 20; static const uint8_t COMMAND_MEASURE_TEMP = 40; static const uint8_t COMMAND_MEASURE_EC = 80; -class UFireECComponent : public PollingComponent, public i2c::I2CDevice { +class UFireECComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void update() override; @@ -58,7 +58,7 @@ class UFireECComponent : public PollingComponent, public i2c::I2CDevice { float temperature_coefficient_{0.0}; }; -template class UFireECCalibrateProbeAction : public Action { +template class UFireECCalibrateProbeAction final : public Action { public: UFireECCalibrateProbeAction(UFireECComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(float, solution) @@ -72,7 +72,7 @@ template class UFireECCalibrateProbeAction : public Action class UFireECResetAction : public Action { +template class UFireECResetAction final : public Action { public: UFireECResetAction(UFireECComponent *parent) : parent_(parent) {} diff --git a/esphome/components/ufire_ise/ufire_ise.h b/esphome/components/ufire_ise/ufire_ise.h index bff8eeff9d..85916f227e 100644 --- a/esphome/components/ufire_ise/ufire_ise.h +++ b/esphome/components/ufire_ise/ufire_ise.h @@ -29,7 +29,7 @@ static const uint8_t COMMAND_CALIBRATE_LOW = 10; static const uint8_t COMMAND_MEASURE_TEMP = 40; static const uint8_t COMMAND_MEASURE_MV = 80; -class UFireISEComponent : public PollingComponent, public i2c::I2CDevice { +class UFireISEComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void update() override; @@ -58,7 +58,7 @@ class UFireISEComponent : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *ph_sensor_{nullptr}; }; -template class UFireISECalibrateProbeLowAction : public Action { +template class UFireISECalibrateProbeLowAction final : public Action { public: UFireISECalibrateProbeLowAction(UFireISEComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(float, solution) @@ -69,7 +69,7 @@ template class UFireISECalibrateProbeLowAction : public Action class UFireISECalibrateProbeHighAction : public Action { +template class UFireISECalibrateProbeHighAction final : public Action { public: UFireISECalibrateProbeHighAction(UFireISEComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(float, solution) @@ -80,7 +80,7 @@ template class UFireISECalibrateProbeHighAction : public Action< UFireISEComponent *parent_; }; -template class UFireISEResetAction : public Action { +template class UFireISEResetAction final : public Action { public: UFireISEResetAction(UFireISEComponent *parent) : parent_(parent) {} diff --git a/esphome/components/uln2003/uln2003.h b/esphome/components/uln2003/uln2003.h index 70f55f72bf..1b1a16f95e 100644 --- a/esphome/components/uln2003/uln2003.h +++ b/esphome/components/uln2003/uln2003.h @@ -12,7 +12,7 @@ enum ULN2003StepMode { ULN2003_STEP_MODE_WAVE_DRIVE, }; -class ULN2003 : public stepper::Stepper, public Component { +class ULN2003 final : public stepper::Stepper, public Component { public: void set_pin_a(GPIOPin *pin_a) { pin_a_ = pin_a; } void set_pin_b(GPIOPin *pin_b) { pin_b_ = pin_b; } diff --git a/esphome/components/ultrasonic/ultrasonic_sensor.h b/esphome/components/ultrasonic/ultrasonic_sensor.h index 7d333a1b24..ea8fcbf72e 100644 --- a/esphome/components/ultrasonic/ultrasonic_sensor.h +++ b/esphome/components/ultrasonic/ultrasonic_sensor.h @@ -18,7 +18,7 @@ struct UltrasonicSensorStore { volatile bool echo_end{false}; }; -class UltrasonicSensorComponent : public sensor::Sensor, public PollingComponent { +class UltrasonicSensorComponent final : public sensor::Sensor, public PollingComponent { public: void set_trigger_pin(InternalGPIOPin *trigger_pin) { this->trigger_pin_ = trigger_pin; } void set_echo_pin(InternalGPIOPin *echo_pin) { this->echo_pin_ = echo_pin; } diff --git a/esphome/components/update/automation.h b/esphome/components/update/automation.h index 821151f67c..8ba7b71a9c 100644 --- a/esphome/components/update/automation.h +++ b/esphome/components/update/automation.h @@ -6,19 +6,19 @@ namespace esphome::update { -template class PerformAction : public Action, public Parented { +template class PerformAction final : public Action, public Parented { TEMPLATABLE_VALUE(bool, force) public: void play(const Ts &...x) override { this->parent_->perform(this->force_.value(x...)); } }; -template class CheckAction : public Action, public Parented { +template class CheckAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->check(); } }; -template class IsAvailableCondition : public Condition, public Parented { +template class IsAvailableCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->state == UPDATE_STATE_AVAILABLE; } }; diff --git a/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.h b/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.h index 4cc5a4a3bc..4755655747 100644 --- a/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.h +++ b/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.h @@ -6,7 +6,7 @@ namespace esphome::uponor_smatrix { -class UponorSmatrixClimate : public climate::Climate, public Component, public UponorSmatrixDevice { +class UponorSmatrixClimate final : public climate::Climate, public Component, public UponorSmatrixDevice { public: void dump_config() override; void loop() override; diff --git a/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.h b/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.h index 346fe1e3d6..b507642fce 100644 --- a/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.h +++ b/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.h @@ -6,7 +6,7 @@ namespace esphome::uponor_smatrix { -class UponorSmatrixSensor : public sensor::Sensor, public Component, public UponorSmatrixDevice { +class UponorSmatrixSensor final : public sensor::Sensor, public Component, public UponorSmatrixDevice { SUB_SENSOR(temperature) SUB_SENSOR(external_temperature) SUB_SENSOR(humidity) diff --git a/esphome/components/uponor_smatrix/uponor_smatrix.h b/esphome/components/uponor_smatrix/uponor_smatrix.h index e9e772feab..8476c6bac2 100644 --- a/esphome/components/uponor_smatrix/uponor_smatrix.h +++ b/esphome/components/uponor_smatrix/uponor_smatrix.h @@ -62,7 +62,7 @@ struct UponorSmatrixData { class UponorSmatrixDevice; -class UponorSmatrixComponent : public uart::UARTDevice, public Component { +class UponorSmatrixComponent final : public uart::UARTDevice, public Component { public: UponorSmatrixComponent() = default; diff --git a/esphome/components/uptime/sensor/uptime_seconds_sensor.h b/esphome/components/uptime/sensor/uptime_seconds_sensor.h index 1b80a4480a..b0b12954b2 100644 --- a/esphome/components/uptime/sensor/uptime_seconds_sensor.h +++ b/esphome/components/uptime/sensor/uptime_seconds_sensor.h @@ -5,7 +5,7 @@ namespace esphome::uptime { -class UptimeSecondsSensor : public sensor::Sensor, public PollingComponent { +class UptimeSecondsSensor final : public sensor::Sensor, public PollingComponent { public: void update() override; void dump_config() override; diff --git a/esphome/components/uptime/sensor/uptime_timestamp_sensor.h b/esphome/components/uptime/sensor/uptime_timestamp_sensor.h index 912c0b7655..5b837cbce1 100644 --- a/esphome/components/uptime/sensor/uptime_timestamp_sensor.h +++ b/esphome/components/uptime/sensor/uptime_timestamp_sensor.h @@ -10,7 +10,7 @@ namespace esphome::uptime { -class UptimeTimestampSensor : public sensor::Sensor, public Component { +class UptimeTimestampSensor final : public sensor::Sensor, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/uptime/text_sensor/uptime_text_sensor.h b/esphome/components/uptime/text_sensor/uptime_text_sensor.h index a97ba332bb..0bdc7fe404 100644 --- a/esphome/components/uptime/text_sensor/uptime_text_sensor.h +++ b/esphome/components/uptime/text_sensor/uptime_text_sensor.h @@ -7,7 +7,7 @@ namespace esphome::uptime { -class UptimeTextSensor : public text_sensor::TextSensor, public PollingComponent { +class UptimeTextSensor final : public text_sensor::TextSensor, public PollingComponent { public: UptimeTextSensor(const char *days_text, const char *hours_text, const char *minutes_text, const char *seconds_text, const char *separator, bool expand) diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.h b/esphome/components/usb_cdc_acm/usb_cdc_acm.h index 10692fd436..2251c600e7 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.h +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.h @@ -51,7 +51,7 @@ struct CDCEvent { class USBCDCACMComponent; /// Represents a single CDC ACM interface instance -class USBCDCACMInstance : public uart::UARTComponent, public Parented { +class USBCDCACMInstance final : public uart::UARTComponent, public Parented { public: void setup(); void loop(); @@ -112,7 +112,7 @@ class USBCDCACMInstance : public uart::UARTComponent, public Parented { +class USBUartChannel final : public uart::UARTComponent, public Parented { friend class USBUartComponent; friend class USBUartTypeCdcAcm; friend class USBUartTypeCP210X; diff --git a/esphome/components/valve/automation.h b/esphome/components/valve/automation.h index 08c9f4e011..63d03a889b 100644 --- a/esphome/components/valve/automation.h +++ b/esphome/components/valve/automation.h @@ -6,7 +6,7 @@ namespace esphome::valve { -template class OpenAction : public Action { +template class OpenAction final : public Action { public: explicit OpenAction(Valve *valve) : valve_(valve) {} @@ -16,7 +16,7 @@ template class OpenAction : public Action { Valve *valve_; }; -template class CloseAction : public Action { +template class CloseAction final : public Action { public: explicit CloseAction(Valve *valve) : valve_(valve) {} @@ -26,7 +26,7 @@ template class CloseAction : public Action { Valve *valve_; }; -template class StopAction : public Action { +template class StopAction final : public Action { public: explicit StopAction(Valve *valve) : valve_(valve) {} @@ -36,7 +36,7 @@ template class StopAction : public Action { Valve *valve_; }; -template class ToggleAction : public Action { +template class ToggleAction final : public Action { public: explicit ToggleAction(Valve *valve) : valve_(valve) {} @@ -58,7 +58,7 @@ template class ToggleAction : public Action { // (e.g. `const T & &` if Ts already carries a reference, or `const const // T &` if Ts already carries a const). This keeps trigger args no-copy // regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class ControlAction : public Action { +template class ControlAction final : public Action { public: using ApplyFn = void (*)(ValveCall &, const std::remove_cvref_t &...); ControlAction(Valve *valve, ApplyFn apply) : valve_(valve), apply_(apply) {} @@ -74,7 +74,7 @@ template class ControlAction : public Action { ApplyFn apply_; }; -template class ValveIsOpenCondition : public Condition { +template class ValveIsOpenCondition final : public Condition { public: ValveIsOpenCondition(Valve *valve) : valve_(valve) {} bool check(const Ts &...x) override { return this->valve_->is_fully_open(); } @@ -83,7 +83,7 @@ template class ValveIsOpenCondition : public Condition { Valve *valve_; }; -template class ValveIsClosedCondition : public Condition { +template class ValveIsClosedCondition final : public Condition { public: ValveIsClosedCondition(Valve *valve) : valve_(valve) {} bool check(const Ts &...x) override { return this->valve_->is_fully_closed(); } @@ -92,7 +92,7 @@ template class ValveIsClosedCondition : public Condition Valve *valve_; }; -class ValveOpenTrigger : public Trigger<> { +class ValveOpenTrigger final : public Trigger<> { public: ValveOpenTrigger(Valve *a_valve) : valve_(a_valve) { a_valve->add_on_state_callback([this]() { @@ -106,7 +106,7 @@ class ValveOpenTrigger : public Trigger<> { Valve *valve_; }; -class ValveClosedTrigger : public Trigger<> { +class ValveClosedTrigger final : public Trigger<> { public: ValveClosedTrigger(Valve *a_valve) : valve_(a_valve) { a_valve->add_on_state_callback([this]() { diff --git a/esphome/components/vbus/binary_sensor/vbus_binary_sensor.h b/esphome/components/vbus/binary_sensor/vbus_binary_sensor.h index 8d372f45d6..a77fc7f56a 100644 --- a/esphome/components/vbus/binary_sensor/vbus_binary_sensor.h +++ b/esphome/components/vbus/binary_sensor/vbus_binary_sensor.h @@ -5,7 +5,7 @@ namespace esphome::vbus { -class DeltaSolBSPlusBSensor : public VBusListener, public Component { +class DeltaSolBSPlusBSensor final : public VBusListener, public Component { public: void dump_config() override; void set_relay1_bsensor(binary_sensor::BinarySensor *bsensor) { this->relay1_bsensor_ = bsensor; } @@ -38,7 +38,7 @@ class DeltaSolBSPlusBSensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; -class DeltaSolBS2009BSensor : public VBusListener, public Component { +class DeltaSolBS2009BSensor final : public VBusListener, public Component { public: void dump_config() override; void set_s1_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s1_error_bsensor_ = bsensor; } @@ -59,7 +59,7 @@ class DeltaSolBS2009BSensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; -class DeltaSolCBSensor : public VBusListener, public Component { +class DeltaSolCBSensor final : public VBusListener, public Component { public: void dump_config() override; void set_s1_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s1_error_bsensor_ = bsensor; } @@ -76,7 +76,7 @@ class DeltaSolCBSensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; -class DeltaSolCS2BSensor : public VBusListener, public Component { +class DeltaSolCS2BSensor final : public VBusListener, public Component { public: void dump_config() override; void set_s1_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s1_error_bsensor_ = bsensor; } @@ -93,7 +93,7 @@ class DeltaSolCS2BSensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; -class DeltaSolCS4BSensor : public VBusListener, public Component { +class DeltaSolCS4BSensor final : public VBusListener, public Component { public: void dump_config() override; void set_s1_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s1_error_bsensor_ = bsensor; } @@ -110,7 +110,7 @@ class DeltaSolCS4BSensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; -class DeltaSolCSPlusBSensor : public VBusListener, public Component { +class DeltaSolCSPlusBSensor final : public VBusListener, public Component { public: void dump_config() override; void set_s1_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s1_error_bsensor_ = bsensor; } @@ -127,7 +127,7 @@ class DeltaSolCSPlusBSensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; -class DeltaSolBS2BSensor : public VBusListener, public Component { +class DeltaSolBS2BSensor final : public VBusListener, public Component { public: void dump_config() override; void set_s1_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s1_error_bsensor_ = bsensor; } @@ -146,7 +146,7 @@ class DeltaSolBS2BSensor : public VBusListener, public Component { class VBusCustomSubBSensor; -class VBusCustomBSensor : public VBusListener, public Component { +class VBusCustomBSensor final : public VBusListener, public Component { public: void dump_config() override; void set_bsensors(std::vector bsensors) { this->bsensors_ = std::move(bsensors); }; @@ -156,7 +156,7 @@ class VBusCustomBSensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; -class VBusCustomSubBSensor : public binary_sensor::BinarySensor, public Component { +class VBusCustomSubBSensor final : public binary_sensor::BinarySensor, public Component { public: void set_message_parser(message_parser_t parser) { this->message_parser_ = std::move(parser); }; void parse_message(std::vector &message); diff --git a/esphome/components/vbus/vbus.h b/esphome/components/vbus/vbus.h index ff523178ef..c8cd0cb4a4 100644 --- a/esphome/components/vbus/vbus.h +++ b/esphome/components/vbus/vbus.h @@ -25,7 +25,7 @@ class VBusListener { virtual void handle_message(std::vector &message) = 0; }; -class VBus : public uart::UARTDevice, public Component { +class VBus final : public uart::UARTDevice, public Component { public: void dump_config() override; void loop() override; diff --git a/esphome/components/veml3235/veml3235.h b/esphome/components/veml3235/veml3235.h index df88bc6ff5..cda6d177aa 100644 --- a/esphome/components/veml3235/veml3235.h +++ b/esphome/components/veml3235/veml3235.h @@ -59,7 +59,7 @@ enum VEML3235ComponentGain { VEML3235_GAIN_4X = 0b11, }; -class VEML3235Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class VEML3235Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/veml7700/veml7700.h b/esphome/components/veml7700/veml7700.h index a036bdf002..4a1e25fb8a 100644 --- a/esphome/components/veml7700/veml7700.h +++ b/esphome/components/veml7700/veml7700.h @@ -95,7 +95,7 @@ union PSMRegister { } __attribute__((packed)); }; -class VEML7700Component : public PollingComponent, public i2c::I2CDevice { +class VEML7700Component final : public PollingComponent, public i2c::I2CDevice { public: // // EspHome framework functions diff --git a/esphome/components/vl53l0x/vl53l0x_sensor.h b/esphome/components/vl53l0x/vl53l0x_sensor.h index 7c916f4fde..0aa01685c4 100644 --- a/esphome/components/vl53l0x/vl53l0x_sensor.h +++ b/esphome/components/vl53l0x/vl53l0x_sensor.h @@ -22,7 +22,7 @@ struct SequenceStepTimeouts { enum VcselPeriodType { VCSEL_PERIOD_PRE_RANGE, VCSEL_PERIOD_FINAL_RANGE }; -class VL53L0XSensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class VL53L0XSensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: VL53L0XSensor(); diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index 76b076a366..dd9d205aff 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -110,7 +110,7 @@ enum class MediaPlayerResponseState { }; #endif -class VoiceAssistant : public Component { +class VoiceAssistant final : public Component { public: VoiceAssistant(); @@ -353,7 +353,7 @@ class VoiceAssistant : public Component { #endif }; -template class StartAction : public Action, public Parented { +template class StartAction final : public Action, public Parented { TEMPLATABLE_VALUE(std::string, wake_word); public: @@ -368,22 +368,22 @@ template class StartAction : public Action, public Parent bool silence_detection_; }; -template class StartContinuousAction : public Action, public Parented { +template class StartContinuousAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->request_start(true, true); } }; -template class StopAction : public Action, public Parented { +template class StopAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->request_stop(); } }; -template class IsRunningCondition : public Condition, public Parented { +template class IsRunningCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_running() || this->parent_->is_continuous(); } }; -template class ConnectedCondition : public Condition, public Parented { +template class ConnectedCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->get_api_connection() != nullptr; } }; diff --git a/esphome/components/wake_on_lan/wake_on_lan.h b/esphome/components/wake_on_lan/wake_on_lan.h index 84bc26e064..ddf3433e7d 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.h +++ b/esphome/components/wake_on_lan/wake_on_lan.h @@ -11,7 +11,7 @@ namespace esphome::wake_on_lan { -class WakeOnLanButton : public button::Button, public Component { +class WakeOnLanButton final : public button::Button, public Component { public: void set_macaddr(uint8_t a, uint8_t b, uint8_t c, uint8_t d, uint8_t e, uint8_t f); diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index c7162c139a..19c2185fb9 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -88,7 +88,7 @@ class AuthMiddlewareHandler : public MiddlewareHandler { } // namespace internal -class WebServerBase { +class WebServerBase final { public: void init() { if (this->initialized_) { diff --git a/esphome/components/weikai_i2c/weikai_i2c.h b/esphome/components/weikai_i2c/weikai_i2c.h index 940dbad9f2..6d8da031ac 100644 --- a/esphome/components/weikai_i2c/weikai_i2c.h +++ b/esphome/components/weikai_i2c/weikai_i2c.h @@ -38,7 +38,7 @@ class WeikaiRegisterI2C : public weikai::WeikaiRegister { /// @brief The WeikaiComponentI2C class stores the information to the WeiKai component /// connected through an I2C bus. //////////////////////////////////////////////////////////////////////////////////// -class WeikaiComponentI2C : public weikai::WeikaiComponent, public i2c::I2CDevice { +class WeikaiComponentI2C final : public weikai::WeikaiComponent, public i2c::I2CDevice { public: weikai::WeikaiRegister ®(uint8_t reg, uint8_t channel) override { reg_i2c_.register_ = reg; diff --git a/esphome/components/weikai_spi/weikai_spi.h b/esphome/components/weikai_spi/weikai_spi.h index 3b581ef44c..cdfa148c24 100644 --- a/esphome/components/weikai_spi/weikai_spi.h +++ b/esphome/components/weikai_spi/weikai_spi.h @@ -31,9 +31,9 @@ class WeikaiRegisterSPI : public weikai::WeikaiRegister { /// @brief The WeikaiComponentSPI class stores the information to the WeiKai component /// connected through an SPI bus. //////////////////////////////////////////////////////////////////////////////////// -class WeikaiComponentSPI : public weikai::WeikaiComponent, - public spi::SPIDevice { +class WeikaiComponentSPI final : public weikai::WeikaiComponent, + public spi::SPIDevice { public: weikai::WeikaiRegister ®(uint8_t reg, uint8_t channel) override { reg_spi_.register_ = reg; diff --git a/esphome/components/whirlpool/whirlpool.h b/esphome/components/whirlpool/whirlpool.h index 03b4cf21a8..b705ee95fa 100644 --- a/esphome/components/whirlpool/whirlpool.h +++ b/esphome/components/whirlpool/whirlpool.h @@ -16,7 +16,7 @@ const float WHIRLPOOL_DG11J1_3A_TEMP_MIN = 18.0; const float WHIRLPOOL_DG11J1_91_TEMP_MAX = 30.0; const float WHIRLPOOL_DG11J1_91_TEMP_MIN = 16.0; -class WhirlpoolClimate : public climate_ir::ClimateIR { +class WhirlpoolClimate final : public climate_ir::ClimateIR { public: WhirlpoolClimate(); diff --git a/esphome/components/whynter/whynter.h b/esphome/components/whynter/whynter.h index d67bfa8fa0..fa8f201b05 100644 --- a/esphome/components/whynter/whynter.h +++ b/esphome/components/whynter/whynter.h @@ -12,7 +12,7 @@ const uint8_t TEMP_MAX_C = 32; // Celsius const uint8_t TEMP_MIN_F = 61; // Fahrenheit const uint8_t TEMP_MAX_F = 89; // Fahrenheit -class Whynter : public climate_ir::ClimateIR { +class Whynter final : public climate_ir::ClimateIR { public: Whynter() : climate_ir::ClimateIR(TEMP_MIN_C, TEMP_MAX_C, 1.0, true, true, diff --git a/esphome/components/wiegand/wiegand.h b/esphome/components/wiegand/wiegand.h index 33d81ba086..079f02ed68 100644 --- a/esphome/components/wiegand/wiegand.h +++ b/esphome/components/wiegand/wiegand.h @@ -21,13 +21,13 @@ struct WiegandStore { static void d1_gpio_intr(WiegandStore *arg); }; -class WiegandTagTrigger : public Trigger {}; +class WiegandTagTrigger final : public Trigger {}; -class WiegandRawTrigger : public Trigger {}; +class WiegandRawTrigger final : public Trigger {}; -class WiegandKeyTrigger : public Trigger {}; +class WiegandKeyTrigger final : public Trigger {}; -class Wiegand : public key_provider::KeyProvider, public Component { +class Wiegand final : public key_provider::KeyProvider, public Component { public: float get_setup_priority() const override { return setup_priority::HARDWARE; } void setup() override; diff --git a/esphome/components/wifi/automation.h b/esphome/components/wifi/automation.h index 1ad69b3992..e63faa18ab 100644 --- a/esphome/components/wifi/automation.h +++ b/esphome/components/wifi/automation.h @@ -6,32 +6,32 @@ namespace esphome::wifi { -template class WiFiConnectedCondition : public Condition { +template class WiFiConnectedCondition final : public Condition { public: bool check(const Ts &...x) override { return global_wifi_component->is_connected(); } }; -template class WiFiEnabledCondition : public Condition { +template class WiFiEnabledCondition final : public Condition { public: bool check(const Ts &...x) override { return !global_wifi_component->is_disabled(); } }; -template class WiFiAPActiveCondition : public Condition { +template class WiFiAPActiveCondition final : public Condition { public: bool check(const Ts &...x) override { return global_wifi_component->is_ap_active(); } }; -template class WiFiEnableAction : public Action { +template class WiFiEnableAction final : public Action { public: void play(const Ts &...x) override { global_wifi_component->enable(); } }; -template class WiFiDisableAction : public Action { +template class WiFiDisableAction final : public Action { public: void play(const Ts &...x) override { global_wifi_component->disable(); } }; -template class WiFiConfigureAction : public Action, public Component { +template class WiFiConfigureAction final : public Action, public Component { public: TEMPLATABLE_VALUE(std::string, ssid) TEMPLATABLE_VALUE(std::string, password) diff --git a/esphome/components/wifi_signal/wifi_signal_sensor.h b/esphome/components/wifi_signal/wifi_signal_sensor.h index 9ff4cc54a0..af41465e71 100644 --- a/esphome/components/wifi_signal/wifi_signal_sensor.h +++ b/esphome/components/wifi_signal/wifi_signal_sensor.h @@ -10,9 +10,9 @@ namespace esphome::wifi_signal { #ifdef USE_WIFI_CONNECT_STATE_LISTENERS -class WiFiSignalSensor : public sensor::Sensor, public PollingComponent, public wifi::WiFiConnectStateListener { +class WiFiSignalSensor final : public sensor::Sensor, public PollingComponent, public wifi::WiFiConnectStateListener { #else -class WiFiSignalSensor : public sensor::Sensor, public PollingComponent { +class WiFiSignalSensor final : public sensor::Sensor, public PollingComponent { #endif public: #ifdef USE_WIFI_CONNECT_STATE_LISTENERS diff --git a/esphome/components/wireguard/wireguard.h b/esphome/components/wireguard/wireguard.h index c11d592cd1..1fda802415 100644 --- a/esphome/components/wireguard/wireguard.h +++ b/esphome/components/wireguard/wireguard.h @@ -32,7 +32,7 @@ struct AllowedIP { }; /// Main Wireguard component class. -class Wireguard : public PollingComponent { +class Wireguard final : public PollingComponent { public: void setup() override; void loop() override; @@ -165,25 +165,26 @@ static constexpr size_t MASK_KEY_BUFFER_SIZE = 12; void mask_key_to(char *buffer, size_t len, const char *key); /// Condition to check if remote peer is online. -template class WireguardPeerOnlineCondition : public Condition, public Parented { +template +class WireguardPeerOnlineCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_peer_up(); } }; /// Condition to check if Wireguard component is enabled. -template class WireguardEnabledCondition : public Condition, public Parented { +template class WireguardEnabledCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_enabled(); } }; /// Action to enable Wireguard component. -template class WireguardEnableAction : public Action, public Parented { +template class WireguardEnableAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->enable(); } }; /// Action to disable Wireguard component. -template class WireguardDisableAction : public Action, public Parented { +template class WireguardDisableAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->disable(); } }; diff --git a/esphome/components/wl_134/wl_134.h b/esphome/components/wl_134/wl_134.h index 973e5a1e7c..fad64bd8ff 100644 --- a/esphome/components/wl_134/wl_134.h +++ b/esphome/components/wl_134/wl_134.h @@ -8,7 +8,7 @@ namespace esphome::wl_134 { -class Wl134Component : public text_sensor::TextSensor, public Component, public uart::UARTDevice { +class Wl134Component final : public text_sensor::TextSensor, public Component, public uart::UARTDevice { public: enum Rfid134Error { RFID134_ERROR_NONE, From 2067da4ff5aa43a6aa6596265bc3d0a446027397 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:33:07 +1200 Subject: [PATCH 0740/1815] Mark configurable classes as final (11/21: microphone-ms8607) (#16962) --- .../components/micro_wake_word/automation.h | 12 +++++------ .../micro_wake_word/micro_wake_word.h | 4 ++-- esphome/components/microphone/automation.h | 14 ++++++------- .../components/microphone/microphone_source.h | 2 +- esphome/components/mics_4514/mics_4514.h | 2 +- esphome/components/midea/air_conditioner.h | 2 +- esphome/components/midea_ir/midea_ir.h | 2 +- esphome/components/mipi_dsi/mipi_dsi.h | 2 +- esphome/components/mipi_rgb/mipi_rgb.h | 6 +++--- esphome/components/mitsubishi/mitsubishi.h | 2 +- esphome/components/mixer/speaker/automation.h | 2 +- .../components/mixer/speaker/mixer_speaker.h | 4 ++-- esphome/components/mlx90393/sensor_mlx90393.h | 2 +- esphome/components/mlx90614/mlx90614.h | 2 +- esphome/components/mmc5603/mmc5603.h | 2 +- esphome/components/mmc5983/mmc5983.h | 2 +- .../binary_sensor/modbus_binarysensor.h | 2 +- .../modbus_controller/modbus_controller.h | 2 +- .../modbus_controller/number/modbus_number.h | 2 +- .../modbus_controller/output/modbus_output.h | 4 ++-- .../modbus_controller/select/modbus_select.h | 2 +- .../modbus_controller/sensor/modbus_sensor.h | 2 +- .../modbus_controller/switch/modbus_switch.h | 2 +- .../text_sensor/modbus_textsensor.h | 2 +- .../components/modbus_server/modbus_server.h | 2 +- .../monochromatic_light_output.h | 2 +- esphome/components/mopeka_ble/mopeka_ble.h | 2 +- .../mopeka_pro_check/mopeka_pro_check.h | 2 +- .../mopeka_std_check/mopeka_std_check.h | 2 +- esphome/components/motion/motion_component.h | 6 +++--- esphome/components/mpl3115a2/mpl3115a2.h | 2 +- .../binary_sensor/mpr121_binary_sensor.h | 4 +++- esphome/components/mpr121/mpr121.h | 4 ++-- esphome/components/mpu6050/mpu6050.h | 2 +- esphome/components/mpu6886/mpu6886.h | 2 +- .../mqtt/mqtt_alarm_control_panel.h | 2 +- esphome/components/mqtt/mqtt_binary_sensor.h | 2 +- esphome/components/mqtt/mqtt_button.h | 2 +- esphome/components/mqtt/mqtt_client.h | 20 +++++++++---------- esphome/components/mqtt/mqtt_climate.h | 2 +- esphome/components/mqtt/mqtt_cover.h | 2 +- esphome/components/mqtt/mqtt_date.h | 2 +- esphome/components/mqtt/mqtt_datetime.h | 2 +- esphome/components/mqtt/mqtt_event.h | 2 +- esphome/components/mqtt/mqtt_fan.h | 2 +- esphome/components/mqtt/mqtt_light.h | 2 +- esphome/components/mqtt/mqtt_lock.h | 2 +- esphome/components/mqtt/mqtt_number.h | 2 +- esphome/components/mqtt/mqtt_select.h | 2 +- esphome/components/mqtt/mqtt_sensor.h | 2 +- esphome/components/mqtt/mqtt_switch.h | 2 +- esphome/components/mqtt/mqtt_text.h | 2 +- esphome/components/mqtt/mqtt_text_sensor.h | 2 +- esphome/components/mqtt/mqtt_time.h | 2 +- esphome/components/mqtt/mqtt_update.h | 2 +- esphome/components/mqtt/mqtt_valve.h | 2 +- .../sensor/mqtt_subscribe_sensor.h | 2 +- .../text_sensor/mqtt_subscribe_text_sensor.h | 2 +- esphome/components/ms5611/ms5611.h | 2 +- 59 files changed, 89 insertions(+), 87 deletions(-) diff --git a/esphome/components/micro_wake_word/automation.h b/esphome/components/micro_wake_word/automation.h index e3b35583fb..59dfc624fa 100644 --- a/esphome/components/micro_wake_word/automation.h +++ b/esphome/components/micro_wake_word/automation.h @@ -7,22 +7,22 @@ namespace esphome::micro_wake_word { -template class StartAction : public Action, public Parented { +template class StartAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->start(); } }; -template class StopAction : public Action, public Parented { +template class StopAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->stop(); } }; -template class IsRunningCondition : public Condition, public Parented { +template class IsRunningCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_running(); } }; -template class EnableModelAction : public Action { +template class EnableModelAction final : public Action { public: explicit EnableModelAction(WakeWordModel *wake_word_model) : wake_word_model_(wake_word_model) {} void play(const Ts &...x) override { this->wake_word_model_->enable(); } @@ -31,7 +31,7 @@ template class EnableModelAction : public Action { WakeWordModel *wake_word_model_; }; -template class DisableModelAction : public Action { +template class DisableModelAction final : public Action { public: explicit DisableModelAction(WakeWordModel *wake_word_model) : wake_word_model_(wake_word_model) {} void play(const Ts &...x) override { this->wake_word_model_->disable(); } @@ -40,7 +40,7 @@ template class DisableModelAction : public Action { WakeWordModel *wake_word_model_; }; -template class ModelIsEnabledCondition : public Condition { +template class ModelIsEnabledCondition final : public Condition { public: explicit ModelIsEnabledCondition(WakeWordModel *wake_word_model) : wake_word_model_(wake_word_model) {} bool check(const Ts &...x) override { return this->wake_word_model_->is_enabled(); } diff --git a/esphome/components/micro_wake_word/micro_wake_word.h b/esphome/components/micro_wake_word/micro_wake_word.h index e4c590a423..aebb5b2595 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.h +++ b/esphome/components/micro_wake_word/micro_wake_word.h @@ -31,10 +31,10 @@ enum State { STOPPED, }; -class MicroWakeWord : public Component +class MicroWakeWord final : public Component #ifdef USE_OTA_STATE_LISTENER , - public ota::OTAGlobalStateListener + public ota::OTAGlobalStateListener #endif { public: diff --git a/esphome/components/microphone/automation.h b/esphome/components/microphone/automation.h index 1dfd91f903..c28616a290 100644 --- a/esphome/components/microphone/automation.h +++ b/esphome/components/microphone/automation.h @@ -7,34 +7,34 @@ namespace esphome::microphone { -template class CaptureAction : public Action, public Parented { +template class CaptureAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->start(); } }; -template class StopCaptureAction : public Action, public Parented { +template class StopCaptureAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->stop(); } }; -template class MuteAction : public Action, public Parented { +template class MuteAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_mute_state(true); } }; -template class UnmuteAction : public Action, public Parented { +template class UnmuteAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_mute_state(false); } }; -class DataTrigger : public Trigger &> { +class DataTrigger final : public Trigger &> { public: explicit DataTrigger(Microphone *mic) { mic->add_data_callback([this](const std::vector &data) { this->trigger(data); }); } }; -template class IsCapturingCondition : public Condition, public Parented { +template class IsCapturingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_running(); } }; -template class IsMutedCondition : public Condition, public Parented { +template class IsMutedCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->get_mute_state(); } }; diff --git a/esphome/components/microphone/microphone_source.h b/esphome/components/microphone/microphone_source.h index c3c675e854..7be3b8cdb5 100644 --- a/esphome/components/microphone/microphone_source.h +++ b/esphome/components/microphone/microphone_source.h @@ -13,7 +13,7 @@ namespace esphome::microphone { static const int32_t MAX_GAIN_FACTOR = 64; -class MicrophoneSource { +class MicrophoneSource final { /* * @brief Helper class that handles converting raw microphone data to a requested format. * Components requesting microphone audio should register a callback through this class instead of registering a diff --git a/esphome/components/mics_4514/mics_4514.h b/esphome/components/mics_4514/mics_4514.h index 4f8b970f06..d8c422808a 100644 --- a/esphome/components/mics_4514/mics_4514.h +++ b/esphome/components/mics_4514/mics_4514.h @@ -7,7 +7,7 @@ namespace esphome::mics_4514 { -class MICS4514Component : public PollingComponent, public i2c::I2CDevice { +class MICS4514Component final : public PollingComponent, public i2c::I2CDevice { SUB_SENSOR(carbon_monoxide) SUB_SENSOR(nitrogen_dioxide) SUB_SENSOR(methane) diff --git a/esphome/components/midea/air_conditioner.h b/esphome/components/midea/air_conditioner.h index 6ed5a82ff5..bea6c2eadb 100644 --- a/esphome/components/midea/air_conditioner.h +++ b/esphome/components/midea/air_conditioner.h @@ -21,7 +21,7 @@ using climate::ClimateModeMask; using climate::ClimateSwingModeMask; using climate::ClimatePresetMask; -class AirConditioner : public ApplianceBase, public climate::Climate { +class AirConditioner final : public ApplianceBase, public climate::Climate { public: void dump_config() override; void set_outdoor_temperature_sensor(Sensor *sensor) { this->outdoor_sensor_ = sensor; } diff --git a/esphome/components/midea_ir/midea_ir.h b/esphome/components/midea_ir/midea_ir.h index dd883172d4..e89eaf0110 100644 --- a/esphome/components/midea_ir/midea_ir.h +++ b/esphome/components/midea_ir/midea_ir.h @@ -11,7 +11,7 @@ const uint8_t MIDEA_TEMPC_MAX = 30; // Celsius const uint8_t MIDEA_TEMPF_MIN = 62; // Fahrenheit const uint8_t MIDEA_TEMPF_MAX = 86; // Fahrenheit -class MideaIR : public climate_ir::ClimateIR { +class MideaIR final : public climate_ir::ClimateIR { public: MideaIR() : climate_ir::ClimateIR( diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index c99f69989a..7bf2feb73c 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -35,7 +35,7 @@ const uint8_t MADCTL_MV = 0x20; // row/column swap const uint8_t MADCTL_XFLIP = 0x02; // Mirror the display horizontally const uint8_t MADCTL_YFLIP = 0x01; // Mirror the display vertically -class MipiDsi : public display::Display { +class MipiDsi final : public display::Display { public: MipiDsi(size_t width, size_t height, display::ColorBitness color_depth, uint8_t pixel_mode) : width_(width), height_(height), color_depth_(color_depth), pixel_mode_(pixel_mode) {} diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index dfa8a36e1a..1480004833 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.h +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -98,9 +98,9 @@ class MipiRgb : public display::Display { }; #ifdef USE_SPI -class MipiRgbSpi : public MipiRgb, - public spi::SPIDevice { +class MipiRgbSpi final : public MipiRgb, + public spi::SPIDevice { public: MipiRgbSpi(int width, int height) : MipiRgb(width, height) {} diff --git a/esphome/components/mitsubishi/mitsubishi.h b/esphome/components/mitsubishi/mitsubishi.h index 769390ce3a..7925b7ce44 100644 --- a/esphome/components/mitsubishi/mitsubishi.h +++ b/esphome/components/mitsubishi/mitsubishi.h @@ -38,7 +38,7 @@ enum VerticalDirection { VERTICAL_DIRECTION_DOWN = 0x28, }; -class MitsubishiClimate : public climate_ir::ClimateIR { +class MitsubishiClimate final : public climate_ir::ClimateIR { public: MitsubishiClimate() : climate_ir::ClimateIR(MITSUBISHI_TEMP_MIN, MITSUBISHI_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/mixer/speaker/automation.h b/esphome/components/mixer/speaker/automation.h index cdfda0c700..ea51b6b889 100644 --- a/esphome/components/mixer/speaker/automation.h +++ b/esphome/components/mixer/speaker/automation.h @@ -6,7 +6,7 @@ #ifdef USE_ESP32 namespace esphome::mixer_speaker { -template class DuckingApplyAction : public Action, public Parented { +template class DuckingApplyAction final : public Action, public Parented { TEMPLATABLE_VALUE(uint8_t, decibel_reduction); TEMPLATABLE_VALUE(uint32_t, duration); void play(const Ts &...x) override { diff --git a/esphome/components/mixer/speaker/mixer_speaker.h b/esphome/components/mixer/speaker/mixer_speaker.h index f1ae919b50..00e89d1782 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.h +++ b/esphome/components/mixer/speaker/mixer_speaker.h @@ -44,7 +44,7 @@ namespace esphome::mixer_speaker { class MixerSpeaker; -class SourceSpeaker : public speaker::Speaker, public Component { +class SourceSpeaker final : public speaker::Speaker, public Component { public: void dump_config() override; void setup() override; @@ -118,7 +118,7 @@ class SourceSpeaker : public speaker::Speaker, public Component { uint32_t stopping_start_ms_{0}; }; -class MixerSpeaker : public Component { +class MixerSpeaker final : public Component { public: void dump_config() override; void setup() override; diff --git a/esphome/components/mlx90393/sensor_mlx90393.h b/esphome/components/mlx90393/sensor_mlx90393.h index 28053216e2..e3b7ae5d93 100644 --- a/esphome/components/mlx90393/sensor_mlx90393.h +++ b/esphome/components/mlx90393/sensor_mlx90393.h @@ -20,7 +20,7 @@ enum MLX90393Setting { MLX90393_LAST, }; -class MLX90393Cls : public PollingComponent, public i2c::I2CDevice, public MLX90393Hal { +class MLX90393Cls final : public PollingComponent, public i2c::I2CDevice, public MLX90393Hal { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mlx90614/mlx90614.h b/esphome/components/mlx90614/mlx90614.h index 12081f20ac..882ee45186 100644 --- a/esphome/components/mlx90614/mlx90614.h +++ b/esphome/components/mlx90614/mlx90614.h @@ -6,7 +6,7 @@ namespace esphome::mlx90614 { -class MLX90614Component : public PollingComponent, public i2c::I2CDevice { +class MLX90614Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mmc5603/mmc5603.h b/esphome/components/mmc5603/mmc5603.h index 0d8eb152a7..d291e6d272 100644 --- a/esphome/components/mmc5603/mmc5603.h +++ b/esphome/components/mmc5603/mmc5603.h @@ -12,7 +12,7 @@ enum MMC5603Datarate { MMC5603_DATARATE_255_0_HZ, }; -class MMC5603Component : public PollingComponent, public i2c::I2CDevice { +class MMC5603Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mmc5983/mmc5983.h b/esphome/components/mmc5983/mmc5983.h index 020d3b2e4c..3ab9e86dcd 100644 --- a/esphome/components/mmc5983/mmc5983.h +++ b/esphome/components/mmc5983/mmc5983.h @@ -6,7 +6,7 @@ namespace esphome::mmc5983 { -class MMC5983Component : public PollingComponent, public i2c::I2CDevice { +class MMC5983Component final : public PollingComponent, public i2c::I2CDevice { public: void update() override; void setup() override; diff --git a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h index 98c6840e15..3f7c6b4dd6 100644 --- a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h +++ b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h @@ -8,7 +8,7 @@ namespace esphome::modbus_controller { -class ModbusBinarySensor : public Component, public binary_sensor::BinarySensor, public SensorItem { +class ModbusBinarySensor final : public Component, public binary_sensor::BinarySensor, public SensorItem { public: ModbusBinarySensor(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, uint16_t skip_updates, bool force_new_range) { diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 4f674b2675..501fadbcf1 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -279,7 +279,7 @@ class ModbusCommandItem { * Responses for the commands are dispatched to the modbus sensor items. */ -class ModbusController : public PollingComponent, public modbus::ModbusClientDevice { +class ModbusController final : public PollingComponent, public modbus::ModbusClientDevice { public: void dump_config() override; void loop() override; diff --git a/esphome/components/modbus_controller/number/modbus_number.h b/esphome/components/modbus_controller/number/modbus_number.h index dd8f418bfc..ce64099170 100644 --- a/esphome/components/modbus_controller/number/modbus_number.h +++ b/esphome/components/modbus_controller/number/modbus_number.h @@ -10,7 +10,7 @@ namespace esphome::modbus_controller { using value_to_data_t = std::function(float); -class ModbusNumber : public number::Number, public Component, public SensorItem { +class ModbusNumber final : public number::Number, public Component, public SensorItem { public: ModbusNumber(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, SensorValueType value_type, int register_count, uint16_t skip_updates, bool force_new_range) { diff --git a/esphome/components/modbus_controller/output/modbus_output.h b/esphome/components/modbus_controller/output/modbus_output.h index c5323e3bf3..d904e58bd7 100644 --- a/esphome/components/modbus_controller/output/modbus_output.h +++ b/esphome/components/modbus_controller/output/modbus_output.h @@ -8,7 +8,7 @@ namespace esphome::modbus_controller { -class ModbusFloatOutput : public output::FloatOutput, public Component, public SensorItem { +class ModbusFloatOutput final : public output::FloatOutput, public Component, public SensorItem { public: ModbusFloatOutput(uint16_t start_address, uint8_t offset, SensorValueType value_type, int register_count) { this->register_type = ModbusRegisterType::HOLDING; @@ -41,7 +41,7 @@ class ModbusFloatOutput : public output::FloatOutput, public Component, public S bool use_write_multiple_{false}; }; -class ModbusBinaryOutput : public output::BinaryOutput, public Component, public SensorItem { +class ModbusBinaryOutput final : public output::BinaryOutput, public Component, public SensorItem { public: ModbusBinaryOutput(uint16_t start_address, uint8_t offset) { this->register_type = ModbusRegisterType::COIL; diff --git a/esphome/components/modbus_controller/select/modbus_select.h b/esphome/components/modbus_controller/select/modbus_select.h index a736abd0db..fb9283305c 100644 --- a/esphome/components/modbus_controller/select/modbus_select.h +++ b/esphome/components/modbus_controller/select/modbus_select.h @@ -9,7 +9,7 @@ namespace esphome::modbus_controller { -class ModbusSelect : public Component, public select::Select, public SensorItem { +class ModbusSelect final : public Component, public select::Select, public SensorItem { public: ModbusSelect(SensorValueType sensor_value_type, uint16_t start_address, uint8_t register_count, uint16_t skip_updates, bool force_new_range, std::vector mapping) { diff --git a/esphome/components/modbus_controller/sensor/modbus_sensor.h b/esphome/components/modbus_controller/sensor/modbus_sensor.h index 2e6967b07c..ea4f560b9c 100644 --- a/esphome/components/modbus_controller/sensor/modbus_sensor.h +++ b/esphome/components/modbus_controller/sensor/modbus_sensor.h @@ -8,7 +8,7 @@ namespace esphome::modbus_controller { -class ModbusSensor : public Component, public sensor::Sensor, public SensorItem { +class ModbusSensor final : public Component, public sensor::Sensor, public SensorItem { public: ModbusSensor(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, SensorValueType value_type, int register_count, uint16_t skip_updates, bool force_new_range) { diff --git a/esphome/components/modbus_controller/switch/modbus_switch.h b/esphome/components/modbus_controller/switch/modbus_switch.h index 541a23706d..d6e991582d 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.h +++ b/esphome/components/modbus_controller/switch/modbus_switch.h @@ -8,7 +8,7 @@ namespace esphome::modbus_controller { -class ModbusSwitch : public Component, public switch_::Switch, public SensorItem { +class ModbusSwitch final : public Component, public switch_::Switch, public SensorItem { public: ModbusSwitch(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, uint16_t skip_updates, bool force_new_range) { diff --git a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h index a99fea5860..e9130c98d4 100644 --- a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h +++ b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h @@ -10,7 +10,7 @@ namespace esphome::modbus_controller { enum class RawEncoding { NONE = 0, HEXBYTES = 1, COMMA = 2, ANSI = 3 }; -class ModbusTextSensor : public Component, public text_sensor::TextSensor, public SensorItem { +class ModbusTextSensor final : public Component, public text_sensor::TextSensor, public SensorItem { public: ModbusTextSensor(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint8_t register_count, uint16_t response_bytes, RawEncoding encode, uint16_t skip_updates, bool force_new_range) { diff --git a/esphome/components/modbus_server/modbus_server.h b/esphome/components/modbus_server/modbus_server.h index f68d1c4a30..a5d193cb41 100644 --- a/esphome/components/modbus_server/modbus_server.h +++ b/esphome/components/modbus_server/modbus_server.h @@ -95,7 +95,7 @@ class ServerRegister { WriteLambda write_lambda; }; -class ModbusServer : public Component, public modbus::ModbusServerDevice { +class ModbusServer final : public Component, public modbus::ModbusServerDevice { public: void dump_config() override; diff --git a/esphome/components/monochromatic/monochromatic_light_output.h b/esphome/components/monochromatic/monochromatic_light_output.h index 458140ef09..eb81a10ee4 100644 --- a/esphome/components/monochromatic/monochromatic_light_output.h +++ b/esphome/components/monochromatic/monochromatic_light_output.h @@ -6,7 +6,7 @@ namespace esphome::monochromatic { -class MonochromaticLightOutput : public light::LightOutput { +class MonochromaticLightOutput final : public light::LightOutput { public: void set_output(output::FloatOutput *output) { output_ = output; } light::LightTraits get_traits() override { diff --git a/esphome/components/mopeka_ble/mopeka_ble.h b/esphome/components/mopeka_ble/mopeka_ble.h index cc91ef17d6..e6fae23aee 100644 --- a/esphome/components/mopeka_ble/mopeka_ble.h +++ b/esphome/components/mopeka_ble/mopeka_ble.h @@ -9,7 +9,7 @@ namespace esphome::mopeka_ble { -class MopekaListener : public esp32_ble_tracker::ESPBTDeviceListener { +class MopekaListener final : public esp32_ble_tracker::ESPBTDeviceListener { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; void set_show_sensors_without_sync(bool show_sensors_without_sync) { diff --git a/esphome/components/mopeka_pro_check/mopeka_pro_check.h b/esphome/components/mopeka_pro_check/mopeka_pro_check.h index bfdfe80c48..40fb338350 100644 --- a/esphome/components/mopeka_pro_check/mopeka_pro_check.h +++ b/esphome/components/mopeka_pro_check/mopeka_pro_check.h @@ -27,7 +27,7 @@ enum SensorType { // measurement may be inaccurate. enum SensorReadQuality { QUALITY_HIGH = 0x3, QUALITY_MED = 0x2, QUALITY_LOW = 0x1, QUALITY_ZERO = 0x0 }; -class MopekaProCheck : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class MopekaProCheck final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; diff --git a/esphome/components/mopeka_std_check/mopeka_std_check.h b/esphome/components/mopeka_std_check/mopeka_std_check.h index a38abeabf0..2f1681f6ea 100644 --- a/esphome/components/mopeka_std_check/mopeka_std_check.h +++ b/esphome/components/mopeka_std_check/mopeka_std_check.h @@ -42,7 +42,7 @@ struct mopeka_std_package { // NOLINT(readability-identifier-naming,altera-stru mopeka_std_values val[3]; } __attribute__((packed)); -class MopekaStdCheck : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class MopekaStdCheck final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; diff --git a/esphome/components/motion/motion_component.h b/esphome/components/motion/motion_component.h index 00310c16fe..b0a074a17c 100644 --- a/esphome/components/motion/motion_component.h +++ b/esphome/components/motion/motion_component.h @@ -85,7 +85,7 @@ class MotionComponent : public PollingComponent { // --- Actions --- -template class CalibrateLevelAction : public Action { +template class CalibrateLevelAction final : public Action { public: explicit CalibrateLevelAction(MotionComponent *parent) : parent_(parent) {} void set_save(bool save) { this->save_ = save; } @@ -110,7 +110,7 @@ template class CalibrateLevelAction : public Action { bool save_{false}; }; -template class CalibrateHeadingAction : public Action { +template class CalibrateHeadingAction final : public Action { public: explicit CalibrateHeadingAction(MotionComponent *parent) : parent_(parent) {} void set_save(bool save) { this->save_ = save; } @@ -135,7 +135,7 @@ template class CalibrateHeadingAction : public Action { bool save_{false}; }; -template class ClearCalibrationAction : public Action { +template class ClearCalibrationAction final : public Action { public: explicit ClearCalibrationAction(MotionComponent *parent) : parent_(parent) {} void set_save(bool save) { this->save_ = save; } diff --git a/esphome/components/mpl3115a2/mpl3115a2.h b/esphome/components/mpl3115a2/mpl3115a2.h index d78c9d571c..a6163673cb 100644 --- a/esphome/components/mpl3115a2/mpl3115a2.h +++ b/esphome/components/mpl3115a2/mpl3115a2.h @@ -80,7 +80,7 @@ enum { MPL3115A2_CTRL_REG1_OS128 = 0x38, }; -class MPL3115A2Component : public PollingComponent, public i2c::I2CDevice { +class MPL3115A2Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_altitude(sensor::Sensor *altitude) { altitude_ = altitude; } diff --git a/esphome/components/mpr121/binary_sensor/mpr121_binary_sensor.h b/esphome/components/mpr121/binary_sensor/mpr121_binary_sensor.h index 5fa10bf598..c0a4a36f1f 100644 --- a/esphome/components/mpr121/binary_sensor/mpr121_binary_sensor.h +++ b/esphome/components/mpr121/binary_sensor/mpr121_binary_sensor.h @@ -6,7 +6,9 @@ namespace esphome::mpr121 { -class MPR121BinarySensor : public binary_sensor::BinarySensor, public MPR121Channel, public Parented { +class MPR121BinarySensor final : public binary_sensor::BinarySensor, + public MPR121Channel, + public Parented { public: void set_channel(uint8_t channel) { this->channel_ = channel; } void set_touch_threshold(uint8_t touch_threshold) { this->touch_threshold_ = touch_threshold; }; diff --git a/esphome/components/mpr121/mpr121.h b/esphome/components/mpr121/mpr121.h index 54b5c8abf4..64c4b291b3 100644 --- a/esphome/components/mpr121/mpr121.h +++ b/esphome/components/mpr121/mpr121.h @@ -57,7 +57,7 @@ class MPR121Channel { virtual void process(uint16_t data) = 0; }; -class MPR121Component : public Component, public i2c::I2CDevice { +class MPR121Component final : public Component, public i2c::I2CDevice { public: void register_channel(MPR121Channel *channel) { this->channels_.push_back(channel); } void set_touch_debounce(uint8_t debounce); @@ -102,7 +102,7 @@ class MPR121Component : public Component, public i2c::I2CDevice { }; /// Helper class to expose a MPR121 pin as an internal input GPIO pin. -class MPR121GPIOPin : public GPIOPin { +class MPR121GPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/mpu6050/mpu6050.h b/esphome/components/mpu6050/mpu6050.h index bac07cb4a5..4410bf0164 100644 --- a/esphome/components/mpu6050/mpu6050.h +++ b/esphome/components/mpu6050/mpu6050.h @@ -6,7 +6,7 @@ namespace esphome::mpu6050 { -class MPU6050Component : public PollingComponent, public i2c::I2CDevice { +class MPU6050Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mpu6886/mpu6886.h b/esphome/components/mpu6886/mpu6886.h index a23858a7b7..b795d5f690 100644 --- a/esphome/components/mpu6886/mpu6886.h +++ b/esphome/components/mpu6886/mpu6886.h @@ -6,7 +6,7 @@ namespace esphome::mpu6886 { -class MPU6886Component : public PollingComponent, public i2c::I2CDevice { +class MPU6886Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mqtt/mqtt_alarm_control_panel.h b/esphome/components/mqtt/mqtt_alarm_control_panel.h index 89a0ff1be8..b2da7ed6a2 100644 --- a/esphome/components/mqtt/mqtt_alarm_control_panel.h +++ b/esphome/components/mqtt/mqtt_alarm_control_panel.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTAlarmControlPanelComponent : public mqtt::MQTTComponent { +class MQTTAlarmControlPanelComponent final : public mqtt::MQTTComponent { public: explicit MQTTAlarmControlPanelComponent(alarm_control_panel::AlarmControlPanel *alarm_control_panel); diff --git a/esphome/components/mqtt/mqtt_binary_sensor.h b/esphome/components/mqtt/mqtt_binary_sensor.h index 5917a9966c..75c224c65a 100644 --- a/esphome/components/mqtt/mqtt_binary_sensor.h +++ b/esphome/components/mqtt/mqtt_binary_sensor.h @@ -9,7 +9,7 @@ namespace esphome::mqtt { -class MQTTBinarySensorComponent : public mqtt::MQTTComponent { +class MQTTBinarySensorComponent final : public mqtt::MQTTComponent { public: /** Construct a MQTTBinarySensorComponent. * diff --git a/esphome/components/mqtt/mqtt_button.h b/esphome/components/mqtt/mqtt_button.h index a2db64d39d..7e2c77e29b 100644 --- a/esphome/components/mqtt/mqtt_button.h +++ b/esphome/components/mqtt/mqtt_button.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTButtonComponent : public mqtt::MQTTComponent { +class MQTTButtonComponent final : public mqtt::MQTTComponent { public: explicit MQTTButtonComponent(button::Button *button); diff --git a/esphome/components/mqtt/mqtt_client.h b/esphome/components/mqtt/mqtt_client.h index 14473f737a..f741be561c 100644 --- a/esphome/components/mqtt/mqtt_client.h +++ b/esphome/components/mqtt/mqtt_client.h @@ -99,7 +99,7 @@ enum MQTTClientState { class MQTTComponent; -class MQTTClientComponent : public Component { +class MQTTClientComponent final : public Component { public: MQTTClientComponent(); @@ -340,7 +340,7 @@ class MQTTClientComponent : public Component { extern MQTTClientComponent *global_mqtt_client; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -class MQTTMessageTrigger : public Trigger, public Component { +class MQTTMessageTrigger final : public Trigger, public Component { public: explicit MQTTMessageTrigger(std::string topic); @@ -356,7 +356,7 @@ class MQTTMessageTrigger : public Trigger, public Component { optional payload_; }; -class MQTTJsonMessageTrigger : public Trigger { +class MQTTJsonMessageTrigger final : public Trigger { public: explicit MQTTJsonMessageTrigger(const std::string &topic, uint8_t qos) { global_mqtt_client->subscribe_json( @@ -364,21 +364,21 @@ class MQTTJsonMessageTrigger : public Trigger { } }; -class MQTTConnectTrigger : public Trigger { +class MQTTConnectTrigger final : public Trigger { public: explicit MQTTConnectTrigger(MQTTClientComponent *client) { client->set_on_connect([this](bool session_present) { this->trigger(session_present); }); } }; -class MQTTDisconnectTrigger : public Trigger { +class MQTTDisconnectTrigger final : public Trigger { public: explicit MQTTDisconnectTrigger(MQTTClientComponent *client) { client->set_on_disconnect([this](MQTTClientDisconnectReason reason) { this->trigger(reason); }); } }; -template class MQTTPublishAction : public Action { +template class MQTTPublishAction final : public Action { public: MQTTPublishAction(MQTTClientComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, topic) @@ -395,7 +395,7 @@ template class MQTTPublishAction : public Action { MQTTClientComponent *parent_; }; -template class MQTTPublishJsonAction : public Action { +template class MQTTPublishJsonAction final : public Action { public: MQTTPublishJsonAction(MQTTClientComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, topic) @@ -417,7 +417,7 @@ template class MQTTPublishJsonAction : public Action { MQTTClientComponent *parent_; }; -template class MQTTConnectedCondition : public Condition { +template class MQTTConnectedCondition final : public Condition { public: MQTTConnectedCondition(MQTTClientComponent *parent) : parent_(parent) {} bool check(const Ts &...x) override { return this->parent_->is_connected(); } @@ -426,7 +426,7 @@ template class MQTTConnectedCondition : public Condition MQTTClientComponent *parent_; }; -template class MQTTEnableAction : public Action { +template class MQTTEnableAction final : public Action { public: MQTTEnableAction(MQTTClientComponent *parent) : parent_(parent) {} @@ -436,7 +436,7 @@ template class MQTTEnableAction : public Action { MQTTClientComponent *parent_; }; -template class MQTTDisableAction : public Action { +template class MQTTDisableAction final : public Action { public: MQTTDisableAction(MQTTClientComponent *parent) : parent_(parent) {} diff --git a/esphome/components/mqtt/mqtt_climate.h b/esphome/components/mqtt/mqtt_climate.h index f0715929d4..b862db85ae 100644 --- a/esphome/components/mqtt/mqtt_climate.h +++ b/esphome/components/mqtt/mqtt_climate.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTClimateComponent : public mqtt::MQTTComponent { +class MQTTClimateComponent final : public mqtt::MQTTComponent { public: MQTTClimateComponent(climate::Climate *device); void send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) override; diff --git a/esphome/components/mqtt/mqtt_cover.h b/esphome/components/mqtt/mqtt_cover.h index f801af5d12..3b07733993 100644 --- a/esphome/components/mqtt/mqtt_cover.h +++ b/esphome/components/mqtt/mqtt_cover.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTCoverComponent : public mqtt::MQTTComponent { +class MQTTCoverComponent final : public mqtt::MQTTComponent { public: explicit MQTTCoverComponent(cover::Cover *cover); diff --git a/esphome/components/mqtt/mqtt_date.h b/esphome/components/mqtt/mqtt_date.h index 4a626becb2..1c24422856 100644 --- a/esphome/components/mqtt/mqtt_date.h +++ b/esphome/components/mqtt/mqtt_date.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTDateComponent : public mqtt::MQTTComponent { +class MQTTDateComponent final : public mqtt::MQTTComponent { public: /** Construct this MQTTDateComponent instance with the provided friendly_name and date * diff --git a/esphome/components/mqtt/mqtt_datetime.h b/esphome/components/mqtt/mqtt_datetime.h index d02d6f579c..09af806fe3 100644 --- a/esphome/components/mqtt/mqtt_datetime.h +++ b/esphome/components/mqtt/mqtt_datetime.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTDateTimeComponent : public mqtt::MQTTComponent { +class MQTTDateTimeComponent final : public mqtt::MQTTComponent { public: /** Construct this MQTTDateTimeComponent instance with the provided friendly_name and time * diff --git a/esphome/components/mqtt/mqtt_event.h b/esphome/components/mqtt/mqtt_event.h index e6d5b6f278..424de3f603 100644 --- a/esphome/components/mqtt/mqtt_event.h +++ b/esphome/components/mqtt/mqtt_event.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTEventComponent : public mqtt::MQTTComponent { +class MQTTEventComponent final : public mqtt::MQTTComponent { public: explicit MQTTEventComponent(event::Event *event); diff --git a/esphome/components/mqtt/mqtt_fan.h b/esphome/components/mqtt/mqtt_fan.h index 43ef67e733..ff984bb77d 100644 --- a/esphome/components/mqtt/mqtt_fan.h +++ b/esphome/components/mqtt/mqtt_fan.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTFanComponent : public mqtt::MQTTComponent { +class MQTTFanComponent final : public mqtt::MQTTComponent { public: explicit MQTTFanComponent(fan::Fan *state); diff --git a/esphome/components/mqtt/mqtt_light.h b/esphome/components/mqtt/mqtt_light.h index 41981655ef..2ca8d70dd4 100644 --- a/esphome/components/mqtt/mqtt_light.h +++ b/esphome/components/mqtt/mqtt_light.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTJSONLightComponent : public mqtt::MQTTComponent, public light::LightRemoteValuesListener { +class MQTTJSONLightComponent final : public mqtt::MQTTComponent, public light::LightRemoteValuesListener { public: explicit MQTTJSONLightComponent(light::LightState *state); diff --git a/esphome/components/mqtt/mqtt_lock.h b/esphome/components/mqtt/mqtt_lock.h index 666882c73d..7f36a51789 100644 --- a/esphome/components/mqtt/mqtt_lock.h +++ b/esphome/components/mqtt/mqtt_lock.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTLockComponent : public mqtt::MQTTComponent { +class MQTTLockComponent final : public mqtt::MQTTComponent { public: explicit MQTTLockComponent(lock::Lock *a_lock); diff --git a/esphome/components/mqtt/mqtt_number.h b/esphome/components/mqtt/mqtt_number.h index 021a539988..5e21544691 100644 --- a/esphome/components/mqtt/mqtt_number.h +++ b/esphome/components/mqtt/mqtt_number.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTNumberComponent : public mqtt::MQTTComponent { +class MQTTNumberComponent final : public mqtt::MQTTComponent { public: /** Construct this MQTTNumberComponent instance with the provided friendly_name and number * diff --git a/esphome/components/mqtt/mqtt_select.h b/esphome/components/mqtt/mqtt_select.h index aaf174ff72..46140ad456 100644 --- a/esphome/components/mqtt/mqtt_select.h +++ b/esphome/components/mqtt/mqtt_select.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTSelectComponent : public mqtt::MQTTComponent { +class MQTTSelectComponent final : public mqtt::MQTTComponent { public: /** Construct this MQTTSelectComponent instance with the provided friendly_name and select * diff --git a/esphome/components/mqtt/mqtt_sensor.h b/esphome/components/mqtt/mqtt_sensor.h index e8202aa8e2..1d5ee8095c 100644 --- a/esphome/components/mqtt/mqtt_sensor.h +++ b/esphome/components/mqtt/mqtt_sensor.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTSensorComponent : public mqtt::MQTTComponent { +class MQTTSensorComponent final : public mqtt::MQTTComponent { public: /** Construct this MQTTSensorComponent instance with the provided friendly_name and sensor * diff --git a/esphome/components/mqtt/mqtt_switch.h b/esphome/components/mqtt/mqtt_switch.h index 5f6cb841fd..f35784ed5c 100644 --- a/esphome/components/mqtt/mqtt_switch.h +++ b/esphome/components/mqtt/mqtt_switch.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTSwitchComponent : public mqtt::MQTTComponent { +class MQTTSwitchComponent final : public mqtt::MQTTComponent { public: explicit MQTTSwitchComponent(switch_::Switch *a_switch); diff --git a/esphome/components/mqtt/mqtt_text.h b/esphome/components/mqtt/mqtt_text.h index 8ae0b9e29a..d42eefc690 100644 --- a/esphome/components/mqtt/mqtt_text.h +++ b/esphome/components/mqtt/mqtt_text.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTTextComponent : public mqtt::MQTTComponent { +class MQTTTextComponent final : public mqtt::MQTTComponent { public: /** Construct this MQTTTextComponent instance with the provided friendly_name and text * diff --git a/esphome/components/mqtt/mqtt_text_sensor.h b/esphome/components/mqtt/mqtt_text_sensor.h index d8f9315c1e..1fe9651fa1 100644 --- a/esphome/components/mqtt/mqtt_text_sensor.h +++ b/esphome/components/mqtt/mqtt_text_sensor.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTTextSensor : public mqtt::MQTTComponent { +class MQTTTextSensor final : public mqtt::MQTTComponent { public: explicit MQTTTextSensor(text_sensor::TextSensor *sensor); diff --git a/esphome/components/mqtt/mqtt_time.h b/esphome/components/mqtt/mqtt_time.h index cf5780da2d..3e60176e90 100644 --- a/esphome/components/mqtt/mqtt_time.h +++ b/esphome/components/mqtt/mqtt_time.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTTimeComponent : public mqtt::MQTTComponent { +class MQTTTimeComponent final : public mqtt::MQTTComponent { public: /** Construct this MQTTTimeComponent instance with the provided friendly_name and time * diff --git a/esphome/components/mqtt/mqtt_update.h b/esphome/components/mqtt/mqtt_update.h index ec1adb1fcd..04b0b09da1 100644 --- a/esphome/components/mqtt/mqtt_update.h +++ b/esphome/components/mqtt/mqtt_update.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTUpdateComponent : public mqtt::MQTTComponent { +class MQTTUpdateComponent final : public mqtt::MQTTComponent { public: explicit MQTTUpdateComponent(update::UpdateEntity *update); diff --git a/esphome/components/mqtt/mqtt_valve.h b/esphome/components/mqtt/mqtt_valve.h index d3b724a8ba..dd2cca514a 100644 --- a/esphome/components/mqtt/mqtt_valve.h +++ b/esphome/components/mqtt/mqtt_valve.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTValveComponent : public mqtt::MQTTComponent { +class MQTTValveComponent final : public mqtt::MQTTComponent { public: explicit MQTTValveComponent(valve::Valve *valve); diff --git a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h index 229c0586ab..739e8456ee 100644 --- a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h +++ b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h @@ -10,7 +10,7 @@ namespace esphome::mqtt_subscribe { -class MQTTSubscribeSensor : public sensor::Sensor, public Component { +class MQTTSubscribeSensor final : public sensor::Sensor, public Component { public: void set_parent(mqtt::MQTTClientComponent *parent) { parent_ = parent; } void set_topic(const std::string &topic) { topic_ = topic; } diff --git a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h index f218bf2a8a..8641825fca 100644 --- a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h +++ b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h @@ -10,7 +10,7 @@ namespace esphome::mqtt_subscribe { -class MQTTSubscribeTextSensor : public text_sensor::TextSensor, public Component { +class MQTTSubscribeTextSensor final : public text_sensor::TextSensor, public Component { public: void set_parent(mqtt::MQTTClientComponent *parent) { parent_ = parent; } void set_topic(const std::string &topic) { topic_ = topic; } diff --git a/esphome/components/ms5611/ms5611.h b/esphome/components/ms5611/ms5611.h index c6ad5b231a..535acdd357 100644 --- a/esphome/components/ms5611/ms5611.h +++ b/esphome/components/ms5611/ms5611.h @@ -6,7 +6,7 @@ namespace esphome::ms5611 { -class MS5611Component : public PollingComponent, public i2c::I2CDevice { +class MS5611Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; From 66ab807596555b5516561abe774995ff25b3a119 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Mon, 6 Jul 2026 09:43:31 -0700 Subject: [PATCH 0741/1815] [modbus] API naming (#17378) Co-authored-by: Claude Fable 5 --- esphome/components/modbus/helpers.py | 2 +- esphome/components/modbus/modbus.cpp | 8 ++--- esphome/components/modbus/modbus.h | 23 ++++++------ .../components/modbus/modbus_definitions.h | 7 +++- esphome/components/modbus/modbus_helpers.h | 4 +-- .../components/modbus_controller/__init__.py | 2 +- .../modbus_server/modbus_server.cpp | 9 +++-- .../components/modbus_server/modbus_server.h | 7 ++-- .../modbus_server/modbus_server_test.cpp | 36 +++++++++---------- 9 files changed, 51 insertions(+), 47 deletions(-) diff --git a/esphome/components/modbus/helpers.py b/esphome/components/modbus/helpers.py index 6f97f1e605..9d7dc71547 100644 --- a/esphome/components/modbus/helpers.py +++ b/esphome/components/modbus/helpers.py @@ -29,7 +29,7 @@ MODBUS_WRITE_REGISTER_TYPE = { MODBUS_REGISTER_TYPE = { **MODBUS_WRITE_REGISTER_TYPE, "discrete_input": ModbusRegisterType.DISCRETE_INPUT, - "read": ModbusRegisterType.READ, + "read": ModbusRegisterType.INPUT_REGISTER, } SensorValueType_ns = modbus_helpers_ns.namespace("SensorValueType") diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 488bcf1459..eefab7967f 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -360,7 +360,7 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func return; } - ServerResponseStatus status; + ResponseStatus status; uint8_t response_buffer[modbus::MAX_RAW_SIZE]; const uint8_t *response_data = response_buffer; uint16_t response_len = 0; @@ -381,9 +381,9 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func } RegisterValues registers; if (static_cast(function_code) == ModbusFunctionCode::READ_HOLDING_REGISTERS) { - status = device->on_modbus_read_holding_registers(start_address, number_of_registers, registers); + status = device->on_read_holding_registers(start_address, number_of_registers, registers); } else { - status = device->on_modbus_read_input_registers(start_address, number_of_registers, registers); + status = device->on_read_input_registers(start_address, number_of_registers, registers); } // A handler that returns an exception leaves registers partially filled, so check the exception @@ -436,7 +436,7 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func for (uint16_t i = 0; i < number_of_registers; i++) { registers.push_back(helpers::get_data(data, values_offset + i * 2)); } - status = device->on_modbus_write_registers(start_address, registers); + status = device->on_write_registers(start_address, registers); response_data = data; // echo the request header per Modbus 6.6, 6.12 response_len = 4; break; diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index b0f2aed9f8..d995c441ad 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -201,8 +201,9 @@ class ModbusClientDevice { using ModbusDevice ESPDEPRECATED("Use ModbusClientDevice instead. Removed in 2026.12.0", "2026.6.0") = ModbusClientDevice; -// Result of a server register handler: std::nullopt means success, otherwise the Modbus exception code to return. -using ServerResponseStatus = std::optional; +// Transaction status: std::nullopt on success, otherwise the Modbus exception code. Server handlers return it; +// (future) client response callbacks receive it. Named without a side prefix so both directions share it. +using ResponseStatus = std::optional; // Register values exchanged with server handlers, in host byte order. Sized at the larger of the two protocol // maxima (read = 125 / 0x7D, write = 123 / 0x7B); the per-direction count limit is enforced by the hub, not by // the capacity of this type. @@ -219,19 +220,19 @@ class ModbusServerDevice { ModbusServerDevice &operator=(ModbusServerDevice &&) = delete; void set_address(uint8_t address) { this->address_ = address; } uint8_t get_address() const { return this->address_; } - virtual ServerResponseStatus on_modbus_read_registers(uint16_t start_address, uint16_t number_of_registers, - RegisterValues ®isters) { + virtual ResponseStatus on_read_registers(uint16_t start_address, uint16_t number_of_registers, + RegisterValues ®isters) { return ModbusExceptionCode::ILLEGAL_FUNCTION; }; - virtual ServerResponseStatus on_modbus_read_input_registers(uint16_t start_address, uint16_t number_of_registers, - RegisterValues ®isters) { - return this->on_modbus_read_registers(start_address, number_of_registers, registers); + virtual ResponseStatus on_read_input_registers(uint16_t start_address, uint16_t number_of_registers, + RegisterValues ®isters) { + return this->on_read_registers(start_address, number_of_registers, registers); }; - virtual ServerResponseStatus on_modbus_read_holding_registers(uint16_t start_address, uint16_t number_of_registers, - RegisterValues ®isters) { - return this->on_modbus_read_registers(start_address, number_of_registers, registers); + virtual ResponseStatus on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers, + RegisterValues ®isters) { + return this->on_read_registers(start_address, number_of_registers, registers); }; - virtual ServerResponseStatus on_modbus_write_registers(uint16_t start_address, const RegisterValues ®isters) { + virtual ResponseStatus on_write_registers(uint16_t start_address, const RegisterValues ®isters) { return ModbusExceptionCode::ILLEGAL_FUNCTION; }; diff --git a/esphome/components/modbus/modbus_definitions.h b/esphome/components/modbus/modbus_definitions.h index 1c03498f1d..a5bcc1e3fc 100644 --- a/esphome/components/modbus/modbus_definitions.h +++ b/esphome/components/modbus/modbus_definitions.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/helpers.h" namespace esphome::modbus { @@ -48,7 +49,11 @@ enum class ModbusRegisterType : uint8_t { COIL = 0x01, DISCRETE_INPUT = 0x02, HOLDING = 0x03, - READ = 0x04, + // Named INPUT_REGISTER (not INPUT) because Arduino cores define INPUT as a macro. + INPUT_REGISTER = 0x04, + // Remove before 2027.2.0 + READ ESPDEPRECATED("Use ModbusRegisterType::INPUT_REGISTER instead. Removed in 2027.2.0", "2026.7.0") = + INPUT_REGISTER, }; // 7 MODBUS Exception Responses: diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index b7b9020945..fef0f915ea 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -90,7 +90,7 @@ inline ModbusFunctionCode modbus_register_read_function(ModbusRegisterType reg_t return ModbusFunctionCode::READ_DISCRETE_INPUTS; case ModbusRegisterType::HOLDING: return ModbusFunctionCode::READ_HOLDING_REGISTERS; - case ModbusRegisterType::READ: + case ModbusRegisterType::INPUT_REGISTER: return ModbusFunctionCode::READ_INPUT_REGISTERS; default: return ModbusFunctionCode::INVALID; @@ -104,7 +104,7 @@ inline ModbusFunctionCode modbus_register_write_function(ModbusRegisterType reg_ case ModbusRegisterType::HOLDING: return multiple ? ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS : ModbusFunctionCode::WRITE_SINGLE_REGISTER; // These register types can't be written (per spec) - case ModbusRegisterType::READ: + case ModbusRegisterType::INPUT_REGISTER: case ModbusRegisterType::DISCRETE_INPUT: default: return ModbusFunctionCode::INVALID; diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index cdbba54c1f..527e9b047f 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -220,7 +220,7 @@ def function_code_to_register(function_code): "read_coils": ModbusRegisterType.COIL, "read_discrete_inputs": ModbusRegisterType.DISCRETE_INPUT, "read_holding_registers": ModbusRegisterType.HOLDING, - "read_input_registers": ModbusRegisterType.READ, + "read_input_registers": ModbusRegisterType.INPUT_REGISTER, "write_single_coil": ModbusRegisterType.COIL, "write_single_register": ModbusRegisterType.HOLDING, "write_multiple_coils": ModbusRegisterType.COIL, diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp index 44b1b160a5..1f787a0b61 100644 --- a/esphome/components/modbus_server/modbus_server.cpp +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -27,9 +27,8 @@ ServerRegister *ModbusServer::find_containing_register_(uint32_t address) const return nullptr; } -modbus::ServerResponseStatus ModbusServer::on_modbus_read_registers(uint16_t start_address, - uint16_t number_of_registers, - modbus::RegisterValues ®isters) { +modbus::ResponseStatus ModbusServer::on_read_registers(uint16_t start_address, uint16_t number_of_registers, + modbus::RegisterValues ®isters) { ESP_LOGV(TAG, "Received read holding/input registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%X.", this->address_, start_address, number_of_registers); @@ -101,8 +100,8 @@ modbus::ServerResponseStatus ModbusServer::on_modbus_read_registers(uint16_t sta return {}; } -modbus::ServerResponseStatus ModbusServer::on_modbus_write_registers(uint16_t start_address, - const modbus::RegisterValues ®isters) { +modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address, + const modbus::RegisterValues ®isters) { // registers holds the values to write in host byte order; its size is the register count. ESP_LOGV(TAG, "Received write registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%zX.", this->address_, start_address, registers.size()); diff --git a/esphome/components/modbus_server/modbus_server.h b/esphome/components/modbus_server/modbus_server.h index a5d193cb41..4fddd9854d 100644 --- a/esphome/components/modbus_server/modbus_server.h +++ b/esphome/components/modbus_server/modbus_server.h @@ -102,11 +102,10 @@ class ModbusServer final : public Component, public modbus::ModbusServerDevice { /// 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 - modbus::ServerResponseStatus on_modbus_read_registers(uint16_t start_address, uint16_t number_of_registers, - modbus::RegisterValues ®isters) final; + modbus::ResponseStatus on_read_registers(uint16_t start_address, uint16_t number_of_registers, + modbus::RegisterValues ®isters) final; /// called when a modbus request (function code 0x06 or 0x10) was parsed without errors - modbus::ServerResponseStatus on_modbus_write_registers(uint16_t start_address, - const modbus::RegisterValues ®isters) final; + modbus::ResponseStatus on_write_registers(uint16_t start_address, const modbus::RegisterValues ®isters) 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; diff --git a/tests/components/modbus_server/modbus_server_test.cpp b/tests/components/modbus_server/modbus_server_test.cpp index 419bb9cf25..d95bb473c9 100644 --- a/tests/components/modbus_server/modbus_server_test.cpp +++ b/tests/components/modbus_server/modbus_server_test.cpp @@ -29,7 +29,7 @@ TEST(ModbusServerWrite, SingleWordSucceeds) { }; server.add_server_register(®); - auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1234})); + auto status = server.on_write_registers(0x0000, make_registers({0x1234})); EXPECT_FALSE(status.has_value()); // nullopt == success EXPECT_EQ(written, 0x1234); } @@ -45,7 +45,7 @@ TEST(ModbusServerWrite, DwordSucceeds) { }; server.add_server_register(®); - auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1234, 0x5678})); + auto status = server.on_write_registers(0x0000, make_registers({0x1234, 0x5678})); EXPECT_FALSE(status.has_value()); EXPECT_EQ(written, 0x12345678); } @@ -70,7 +70,7 @@ TEST(ModbusServerWrite, UnderSuppliedValueAppliesNothing) { server.add_server_register(&dword_reg); // Two words supplied: one for the WORD at 0x0000, but only one of the two the DWORD at 0x0001 needs. - auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1111, 0x2222})); + auto status = server.on_write_registers(0x0000, make_registers({0x1111, 0x2222})); ASSERT_TRUE(status.has_value()); if (status.has_value()) EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_VALUE); @@ -84,7 +84,7 @@ TEST(ModbusServerWrite, UnwritableRegisterRejected) { ServerRegister read_only(0x0000, SensorValueType::U_WORD, 1); // no write_lambda set server.add_server_register(&read_only); - auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1234})); + auto status = server.on_write_registers(0x0000, make_registers({0x1234})); ASSERT_TRUE(status.has_value()); if (status.has_value()) EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); @@ -93,7 +93,7 @@ TEST(ModbusServerWrite, UnwritableRegisterRejected) { // An address with no registered register yields ILLEGAL_DATA_ADDRESS. TEST(ModbusServerWrite, UnmatchedAddressRejected) { ModbusServer server; - auto status = server.on_modbus_write_registers(0x0005, make_registers({0x1234})); + auto status = server.on_write_registers(0x0005, make_registers({0x1234})); ASSERT_TRUE(status.has_value()); if (status.has_value()) EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); @@ -114,14 +114,14 @@ TEST(ModbusServerWrite, CallbackFailureIsServiceDeviceFailure) { server.add_server_register(&first); server.add_server_register(&second); - auto status = server.on_modbus_write_registers(0x0000, make_registers({0xAAAA, 0xBBBB})); + auto status = server.on_write_registers(0x0000, make_registers({0xAAAA, 0xBBBB})); ASSERT_TRUE(status.has_value()); if (status.has_value()) EXPECT_EQ(status.value(), ModbusExceptionCode::SERVICE_DEVICE_FAILURE); EXPECT_TRUE(first_written); // pre-validation passed, so the first write applied before the failure } -// --- on_modbus_read_registers -------------------------------------------------- +// --- on_read_registers -------------------------------------------------- TEST(ModbusServerRead, SingleWordSucceeds) { ModbusServer server; @@ -130,7 +130,7 @@ TEST(ModbusServerRead, SingleWordSucceeds) { server.add_server_register(®); RegisterValues out; - auto status = server.on_modbus_read_registers(0x0000, 1, out); + auto status = server.on_read_registers(0x0000, 1, out); EXPECT_FALSE(status.has_value()); ASSERT_EQ(out.size(), 1u); EXPECT_EQ(out[0], 0x1234); @@ -143,7 +143,7 @@ TEST(ModbusServerRead, DwordReturnsTwoWordsHighFirst) { server.add_server_register(®); RegisterValues out; - auto status = server.on_modbus_read_registers(0x0000, 2, out); + auto status = server.on_read_registers(0x0000, 2, out); EXPECT_FALSE(status.has_value()); ASSERT_EQ(out.size(), 2u); EXPECT_EQ(out[0], 0x1234); @@ -165,7 +165,7 @@ TEST(ModbusServerRead, StartInsideValueRejected) { server.add_server_register(®); RegisterValues out; - auto status = server.on_modbus_read_registers(0x0011, 1, out); // the second cell of the DWORD + auto status = server.on_read_registers(0x0011, 1, out); // the second cell of the DWORD ASSERT_TRUE(status.has_value()); if (status.has_value()) EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); @@ -184,7 +184,7 @@ TEST(ModbusServerRead, ClippedTailRejected) { server.add_server_register(®); RegisterValues out; - auto status = server.on_modbus_read_registers(0x0000, 1, out); // only 1 of the DWORD's 2 registers + auto status = server.on_read_registers(0x0000, 1, out); // only 1 of the DWORD's 2 registers ASSERT_TRUE(status.has_value()); if (status.has_value()) EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); @@ -200,7 +200,7 @@ TEST(ModbusServerRead, WriteOnlyRegisterRejected) { server.add_server_register(®); RegisterValues out; - auto status = server.on_modbus_read_registers(0x0000, 1, out); + auto status = server.on_read_registers(0x0000, 1, out); ASSERT_TRUE(status.has_value()); if (status.has_value()) EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); @@ -213,7 +213,7 @@ TEST(ModbusServerRead, CourtesyDefaultForUnregistered) { ServerCourtesyResponse{.enabled = true, .register_last_address = 0xFFFF, .register_value = 0xABCD}); RegisterValues out; - auto status = server.on_modbus_read_registers(0x0005, 2, out); + auto status = server.on_read_registers(0x0005, 2, out); EXPECT_FALSE(status.has_value()); ASSERT_EQ(out.size(), 2u); EXPECT_EQ(out[0], 0xABCD); @@ -224,7 +224,7 @@ TEST(ModbusServerRead, CourtesyDefaultForUnregistered) { TEST(ModbusServerRead, UnregisteredRejectedWithoutCourtesy) { ModbusServer server; RegisterValues out; - auto status = server.on_modbus_read_registers(0x0005, 1, out); + auto status = server.on_read_registers(0x0005, 1, out); ASSERT_TRUE(status.has_value()); if (status.has_value()) EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); @@ -241,7 +241,7 @@ TEST(ModbusServerRead, PartialReadHighWord) { server.add_server_register(®); RegisterValues out; - auto status = server.on_modbus_read_registers(0x0010, 1, out); + auto status = server.on_read_registers(0x0010, 1, out); EXPECT_FALSE(status.has_value()); ASSERT_EQ(out.size(), 1u); EXPECT_EQ(out[0], 0x1234); @@ -256,7 +256,7 @@ TEST(ModbusServerRead, PartialReadLowWordFromInterior) { server.add_server_register(®); RegisterValues out; - auto status = server.on_modbus_read_registers(0x0011, 1, out); + auto status = server.on_read_registers(0x0011, 1, out); EXPECT_FALSE(status.has_value()); ASSERT_EQ(out.size(), 1u); EXPECT_EQ(out[0], 0x5678); @@ -272,12 +272,12 @@ TEST(ModbusServerRead, PartialReadReversedType) { server.add_server_register(®); RegisterValues first; - ASSERT_FALSE(server.on_modbus_read_registers(0x0010, 1, first).has_value()); + ASSERT_FALSE(server.on_read_registers(0x0010, 1, first).has_value()); ASSERT_EQ(first.size(), 1u); EXPECT_EQ(first[0], 0x5678); RegisterValues second; - ASSERT_FALSE(server.on_modbus_read_registers(0x0011, 1, second).has_value()); + ASSERT_FALSE(server.on_read_registers(0x0011, 1, second).has_value()); ASSERT_EQ(second.size(), 1u); EXPECT_EQ(second[0], 0x1234); } From b79db760999ae6bcaeacf56bfc6d32dc9205e493 Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Mon, 6 Jul 2026 20:54:20 +0200 Subject: [PATCH 0742/1815] [core] helpers.h - Implement pop_back method for vector (#17390) --- esphome/core/helpers.h | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 07bcb7a74f..a212019628 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -683,6 +683,15 @@ template class FixedVector { T &back() { return data_[size_ - 1]; } const T &back() const { return data_[size_ - 1]; } + /// Remove the last element in place (no reallocation, keeps capacity) + /// Caller must ensure vector is not empty (size() > 0) + void pop_back() { + if constexpr (!std::is_trivially_destructible::value) { + data_[size_ - 1].~T(); + } + size_--; + } + size_t size() const { return size_; } bool empty() const { return size_ == 0; } size_t capacity() const { return capacity_; } From e64a79f43137627c32fc60b6f0ef34b27f44ffa6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:41:29 -0500 Subject: [PATCH 0743/1815] Bump setuptools from 82.0.1 to 83.0.0 (#17426) Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e959578553..f38633b4ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools==82.0.1", "wheel>=0.43,<0.48"] +requires = ["setuptools==83.0.0", "wheel>=0.43,<0.48"] build-backend = "setuptools.build_meta" [project] From 104c2f86f6de5eefd49e281ae1926feda700e829 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:41:43 -0500 Subject: [PATCH 0744/1815] Bump astral-sh/setup-uv from 8.2.0 to 8.3.0 in /.github/actions/restore-python (#17427) Signed-off-by: dependabot[bot] --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 1364e95602..8ef0bca2ec 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -32,7 +32,7 @@ runs: # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # downstream jobs rely on is preserved. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the From 27d4b63a8a36e09b404505a18e5a1e176c8aeb44 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:41:55 -0500 Subject: [PATCH 0745/1815] Bump astral-sh/setup-uv from 8.2.0 to 8.3.0 (#17428) Signed-off-by: dependabot[bot] --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci.yml | 6 +++--- .github/workflows/sync-device-classes.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 4c0c330a19..721585a44d 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -29,7 +29,7 @@ jobs: - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34f8ed4878..11e29db94a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the @@ -171,7 +171,7 @@ jobs: # install step (order-of-magnitude faster on cold boots, # with its own wheel cache). actions/setup-python still # provides the interpreter. - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the @@ -372,7 +372,7 @@ jobs: - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 0501d6d364..2efaec4e94 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -47,7 +47,7 @@ jobs: # setup-python interpreter so subsequent ``pre-commit`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the From 51fa25856d68300862fc5668e1705536d3b03b2e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Jul 2026 14:42:14 -0500 Subject: [PATCH 0746/1815] [bluetooth_proxy] Take over stale advertisement subscription instead of rejecting the new subscriber (#17423) --- .../components/bluetooth_proxy/bluetooth_proxy.cpp | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index ca30aab943..37ebcad8b4 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -379,9 +379,17 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn } void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) { - if (this->api_connection_ != nullptr) { - ESP_LOGE(TAG, "Only one API subscription is allowed at a time"); - return; + if (this->api_connection_ != nullptr && this->api_connection_ != api_connection) { + // A previous subscriber still holds the slot. This is almost always a stale + // connection from a client that dropped without a clean disconnect and has + // not yet hit the keepalive timeout; rejecting the new subscriber would + // silently starve it of advertisements until it reconnects, so the newest + // subscriber wins instead. + char old_peername[socket::SOCKADDR_STR_LEN]; + char new_peername[socket::SOCKADDR_STR_LEN]; + ESP_LOGW(TAG, "Subscription from %s (%s) replaces %s (%s)", api_connection->get_name(), + api_connection->get_peername_to(new_peername), this->api_connection_->get_name(), + this->api_connection_->get_peername_to(old_peername)); } this->api_connection_ = api_connection; this->parent_->recalculate_advertisement_parser_types(); From 90403576c407a1610c71261b93be08b2eb3d2cef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Jul 2026 14:42:30 -0500 Subject: [PATCH 0747/1815] [wifi] Accept boolean-like strings for fast_connect again (#17414) --- esphome/components/wifi/__init__.py | 8 +++++--- .../validate-fast-connect-substitution.esp8266-ard.yaml | 9 +++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 tests/components/wifi/validate-fast-connect-substitution.esp8266-ard.yaml diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 111f4cfc84..abce1fd5c0 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -1,5 +1,6 @@ import logging import math +from typing import Any from esphome import automation, preferences from esphome.automation import Condition @@ -444,9 +445,10 @@ FAST_CONNECT_SCHEMA = cv.Schema( ) -def _fast_connect_schema(value): - """Accept the historic plain boolean or a dict with enabled/storage keys.""" - if isinstance(value, bool): +def _fast_connect_schema(value: Any) -> ConfigType: + """Accept the historic plain boolean (including boolean-like strings from + substitutions) or a dict with enabled/storage keys.""" + if not isinstance(value, dict): value = {CONF_ENABLED: value} return FAST_CONNECT_SCHEMA(value) diff --git a/tests/components/wifi/validate-fast-connect-substitution.esp8266-ard.yaml b/tests/components/wifi/validate-fast-connect-substitution.esp8266-ard.yaml new file mode 100644 index 0000000000..f9fab8261a --- /dev/null +++ b/tests/components/wifi/validate-fast-connect-substitution.esp8266-ard.yaml @@ -0,0 +1,9 @@ +# fast_connect passed through a substitution arrives as a string ("false"), +# which must be accepted like the historic plain boolean form. +substitutions: + fast_connect_value: "false" + +wifi: + ssid: MySSID + password: password1 + fast_connect: ${fast_connect_value} From 39ad583b39f8f14fb3abf5063659488f80abf6ad Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 6 Jul 2026 21:48:24 +0200 Subject: [PATCH 0748/1815] [nrf52] allow to build for non nrf52840 boards (#17373) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/nrf52/__init__.py | 23 ++++++++++--------- .../components/nrf52/test.nrf52-microbit.yaml | 1 + .../build_components_base.nrf52-microbit.yaml | 16 +++++++++++++ 3 files changed, 29 insertions(+), 11 deletions(-) create mode 100644 tests/components/nrf52/test.nrf52-microbit.yaml create mode 100644 tests/test_build_components/build_components_base.nrf52-microbit.yaml diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 661fc0758e..692b2637b2 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -233,7 +233,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(KEY_BOOTLOADER): cv.one_of(*BOOTLOADERS, lower=True), cv.Optional(CONF_DFU): _dfu_schema, - cv.Optional(CONF_DCDC, default=True): cv.boolean, + cv.Optional(CONF_DCDC): cv.boolean, cv.Optional(CONF_REG0): cv.Schema( { cv.Required(CONF_VOLTAGE): cv.All( @@ -367,16 +367,17 @@ async def to_code(config: ConfigType) -> None: if dfu_config := config.get(CONF_DFU): CORE.add_job(_dfu_to_code, dfu_config) framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] - if framework_ver < cv.Version(2, 9, 2): - zephyr_add_prj_conf("BOARD_ENABLE_DCDC", config[CONF_DCDC]) - else: - zephyr_add_overlay( - f""" - ®1 {{ - regulator-initial-mode = <{"NRF5X_REG_MODE_DCDC" if config[CONF_DCDC] else "NRF5X_REG_MODE_LDO"}>; - }}; - """ - ) + if CONF_DCDC in config: + if framework_ver < cv.Version(2, 9, 2): + zephyr_add_prj_conf("BOARD_ENABLE_DCDC", config[CONF_DCDC]) + else: + zephyr_add_overlay( + f""" + ®1 {{ + regulator-initial-mode = <{"NRF5X_REG_MODE_DCDC" if config[CONF_DCDC] else "NRF5X_REG_MODE_LDO"}>; + }}; + """ + ) if reg0_config := config.get(CONF_REG0): value = VOLTAGE_LEVELS.index(reg0_config[CONF_VOLTAGE]) diff --git a/tests/components/nrf52/test.nrf52-microbit.yaml b/tests/components/nrf52/test.nrf52-microbit.yaml new file mode 100644 index 0000000000..d27f9ff699 --- /dev/null +++ b/tests/components/nrf52/test.nrf52-microbit.yaml @@ -0,0 +1 @@ +nrf52: diff --git a/tests/test_build_components/build_components_base.nrf52-microbit.yaml b/tests/test_build_components/build_components_base.nrf52-microbit.yaml new file mode 100644 index 0000000000..37728b4b64 --- /dev/null +++ b/tests/test_build_components/build_components_base.nrf52-microbit.yaml @@ -0,0 +1,16 @@ +esphome: + name: componenttestnrf52 + friendly_name: $component_name + +nrf52: + board: bbc_microbit + +logger: + level: VERY_VERBOSE + hardware_uart: UART0 + +packages: + component_under_test: !include + file: $component_test_file + vars: + component_test_file: $component_test_file From 468b32b9865989a349eab2712dc5bb1dda2ea941 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 6 Jul 2026 21:50:57 +0200 Subject: [PATCH 0749/1815] [nrf52] add better error message for OTA error (#17407) --- esphome/components/nrf52/ota.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/nrf52/ota.py b/esphome/components/nrf52/ota.py index 5d608acbac..cafeda6478 100644 --- a/esphome/components/nrf52/ota.py +++ b/esphome/components/nrf52/ota.py @@ -5,7 +5,7 @@ import logging from pathlib import Path from bleak import BleakScanner -from bleak.exc import BleakDeviceNotFoundError +from bleak.exc import BleakDBusError, BleakDeviceNotFoundError from smp.exceptions import SMPBadStartDelimiter from smpclient import SMPClient from smpclient.generics import error, success @@ -98,6 +98,12 @@ async def _smpmgr_upload(device: str, firmware: Path) -> None: await smp_client.connect() except BleakDeviceNotFoundError as exc: raise EsphomeError(f"Device {device} not found") from exc + except BleakDBusError as exc: + if "NotPermitted" in exc.dbus_error: + raise EsphomeError( + f"Cannot connect to {device}: Make sure the device is paired." + ) from exc + raise EsphomeError(f"BLE error connecting to {device}: {exc}") from exc except SMPBLETransportException as exc: raise EsphomeError(f"Connection error with {device}") from exc From 9caf4317403bb7bfeae4ab31801532f4895e98a5 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 7 Jul 2026 07:55:53 +1200 Subject: [PATCH 0750/1815] [tests] Document dict-style packages requirement for batch grouping (#17420) --- AGENTS.md | 9 +++++---- tests/test_build_components/common/README.md | 5 ++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9a01626ee4..75a9cdb2bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -427,13 +427,14 @@ This document provides essential context for AI models interacting with this pro When a PR's only edits to a component are `validate.*.yaml` files (no source changes, no `test.*.yaml` changes, and the component isn't pulled in as a dependency of another changed component), CI skips the compile stage for that component entirely and only runs config validation. This is decided in `script/determine-jobs.py` via `_component_change_is_validate_only` and surfaced as the `validate_only_components` output that the `test-build-components-split` job consumes. - * **Test Grouping with Packages:** Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define buses (uart, i2c, spi, modbus) directly in test YAML files** — always use packages from `test_build_components/common/`: + * **Test Grouping with Packages:** Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define buses (uart, i2c, spi, modbus) directly in test YAML files** — always use packages from `test_build_components/common/`. + + All includes in test files must go through dict-style `packages:` so that batch grouping works correctly — the grouping scripts only understand dict-style packages. Never use list-style packages (`packages: [- !include ...]`) or top-level merge keys (`<<: !include common.yaml`). Bus packages are keyed by the bus name; the component's `common.yaml` is keyed by the component name (e.g. `cst328: !include common.yaml`): ```yaml - # test.esp32-idf.yaml — use packages for buses + # test.esp32-idf.yaml — everything included via named packages packages: uart: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml - - <<: !include common.yaml + my_component: !include common.yaml ``` ```yaml # common.yaml — component config only, NO bus definitions diff --git a/tests/test_build_components/common/README.md b/tests/test_build_components/common/README.md index 5e925d0067..a3c6f476e0 100644 --- a/tests/test_build_components/common/README.md +++ b/tests/test_build_components/common/README.md @@ -45,14 +45,13 @@ common/ ## How It Works ### Component Test Structure -Each component test includes the common bus config: +Each component test includes the common bus config and its own `common.yaml` through dict-style `packages:`. Always use packages for every include — the grouping scripts only understand dict-style packages, so list-style packages or top-level `<<:` merge keys prevent correct batch grouping. Key the bus package by the bus name and the component's `common.yaml` by the component name: ```yaml # tests/components/bh1750/test.esp32-idf.yaml packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - -<<: !include common.yaml + bh1750: !include common.yaml ``` The common config provides: From cdd334284ecb99fb127c79d45e10104f99ca49ae Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:20:24 +1200 Subject: [PATCH 0751/1815] [rp2] Rename rp2040 platform to rp2 (#17145) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- CODEOWNERS | 2 +- esphome/__main__.py | 10 +- esphome/components/__init__.py | 6 + esphome/components/adc/__init__.py | 8 +- esphome/components/adc/adc_sensor.h | 8 +- ...c_sensor_rp2040.cpp => adc_sensor_rp2.cpp} | 6 +- esphome/components/adc/sensor.py | 2 +- esphome/components/api/__init__.py | 6 +- esphome/components/api/api_connection.cpp | 2 +- esphome/components/api/api_connection.h | 8 +- esphome/components/async_tcp/__init__.py | 6 +- esphome/components/async_tcp/async_tcp.h | 2 +- .../components/async_tcp/async_tcp_socket.cpp | 2 +- .../components/async_tcp/async_tcp_socket.h | 2 +- esphome/components/captive_portal/__init__.py | 6 +- esphome/components/debug/__init__.py | 2 +- .../debug/{debug_rp2040.cpp => debug_rp2.cpp} | 10 +- esphome/components/esp8266/helpers.cpp | 2 +- esphome/components/esphome/ota/__init__.py | 2 +- .../components/esphome/ota/ota_esphome.cpp | 2 +- esphome/components/ethernet/__init__.py | 28 +- .../components/ethernet/ethernet_component.h | 10 +- ..._rp2040.cpp => ethernet_component_rp2.cpp} | 8 +- .../factory_reset/factory_reset.cpp | 4 +- .../components/factory_reset/factory_reset.h | 4 +- .../components/gpio/binary_sensor/__init__.py | 2 +- .../components/hmac_sha256/hmac_sha256.cpp | 2 +- esphome/components/hmac_sha256/hmac_sha256.h | 2 +- esphome/components/http_request/__init__.py | 12 +- .../http_request/http_request_arduino.cpp | 2 +- .../http_request/http_request_arduino.h | 2 +- .../components/http_request/ota/__init__.py | 2 +- esphome/components/i2c/__init__.py | 16 +- esphome/components/i2c/i2c_bus_arduino.cpp | 8 +- ...p2040.cpp => internal_temperature_rp2.cpp} | 6 +- .../components/internal_temperature/sensor.py | 6 +- esphome/components/logger/__init__.py | 18 +- esphome/components/logger/logger.cpp | 2 +- esphome/components/logger/logger.h | 16 +- .../{logger_rp2040.cpp => logger_rp2.cpp} | 12 +- .../logger/{logger_rp2040.h => logger_rp2.h} | 2 +- esphome/components/lvgl/lvgl_esphome.cpp | 2 +- esphome/components/md5/md5.cpp | 8 +- esphome/components/md5/md5.h | 2 +- esphome/components/mdns/__init__.py | 12 +- esphome/components/mdns/mdns_component.cpp | 8 +- esphome/components/mdns/mdns_component.h | 4 +- .../mdns/{mdns_rp2040.cpp => mdns_rp2.cpp} | 6 +- esphome/components/mqtt/mqtt_component.cpp | 2 +- esphome/components/network/__init__.py | 6 +- esphome/components/nextion/__init__.py | 2 +- esphome/components/online_image/__init__.py | 2 +- esphome/components/ota/__init__.py | 4 +- ...rp2040.cpp => ota_backend_arduino_rp2.cpp} | 26 +- ...ino_rp2040.h => ota_backend_arduino_rp2.h} | 8 +- esphome/components/ota/ota_backend_factory.h | 4 +- .../components/remote_receiver/__init__.py | 4 +- .../remote_receiver/remote_receiver.cpp | 2 +- .../remote_receiver/remote_receiver.h | 6 +- .../components/remote_transmitter/__init__.py | 2 +- .../remote_transmitter/remote_transmitter.cpp | 2 +- .../remote_transmitter/remote_transmitter.h | 2 +- .../components/{rp2040 => rp2}/__init__.py | 59 +- .../components/{rp2040 => rp2}/boards.jinja2 | 13 +- esphome/components/{rp2040 => rp2}/boards.py | 13 +- .../{rp2040 => rp2}/build_pio.py.script | 0 esphome/components/{rp2040 => rp2}/const.py | 4 +- esphome/components/rp2/core.cpp | 6 + esphome/components/{rp2040 => rp2}/core.h | 6 +- .../{rp2040 => rp2}/crash_handler.cpp | 14 +- .../{rp2040 => rp2}/crash_handler.h | 12 +- .../{rp2040 => rp2}/generate_boards.py | 2 +- esphome/components/{rp2040 => rp2}/gpio.cpp | 28 +- esphome/components/{rp2040 => rp2}/gpio.h | 10 +- esphome/components/{rp2040 => rp2}/gpio.py | 26 +- esphome/components/{rp2040 => rp2}/hal.cpp | 22 +- esphome/components/{rp2040 => rp2}/hal.h | 6 +- .../components/{rp2040 => rp2}/helpers.cpp | 4 +- .../inject_lwip_include.py.script | 0 .../{rp2040 => rp2}/lwipopts.h.jinja | 0 .../{rp2040 => rp2}/post_build.py.script | 0 esphome/components/rp2/preference_backend.h | 27 + .../{rp2040 => rp2}/preferences.cpp | 28 +- .../components/{rp2040 => rp2}/preferences.h | 16 +- .../{rp2040 => rp2}/printf_stubs.cpp | 6 +- esphome/components/rp2040/core.cpp | 6 - .../components/rp2040/preference_backend.h | 27 - esphome/components/rp2040_ble/__init__.py | 2 +- .../rp2040_pio_led_strip/led_strip.cpp | 2 +- .../rp2040_pio_led_strip/led_strip.h | 4 +- .../components/rp2040_pio_led_strip/light.py | 10 +- esphome/components/rp2040_pwm/output.py | 2 +- esphome/components/rp2040_pwm/rp2040_pwm.cpp | 2 +- esphome/components/rp2040_pwm/rp2040_pwm.h | 4 +- .../{rp2040_pio => rp2_pio}/__init__.py | 2 +- esphome/components/sha256/sha256.cpp | 4 +- esphome/components/sha256/sha256.h | 6 +- esphome/components/sntp/time.py | 4 +- esphome/components/socket/__init__.py | 2 +- esphome/components/socket/headers.h | 2 +- .../components/socket/lwip_raw_tcp_impl.cpp | 2 +- esphome/components/spi/__init__.py | 14 +- esphome/components/spi/spi.h | 2 +- esphome/components/spi/spi_arduino.cpp | 6 +- esphome/components/time/real_time_clock.cpp | 2 +- esphome/components/uart/__init__.py | 10 +- ...nent_rp2040.cpp => uart_component_rp2.cpp} | 24 +- ...omponent_rp2040.h => uart_component_rp2.h} | 6 +- esphome/components/wake_on_lan/button.py | 2 +- esphome/components/watchdog/watchdog.cpp | 6 +- esphome/components/web_server/__init__.py | 4 +- .../components/web_server_base/__init__.py | 2 +- esphome/components/wifi/__init__.py | 18 +- esphome/components/wifi/wifi_component.cpp | 2 +- esphome/components/wifi/wifi_component.h | 6 +- .../components/wifi/wifi_component_pico_w.cpp | 2 +- esphome/config_validation.py | 118 +++- esphome/const.py | 13 +- esphome/core/__init__.py | 38 +- esphome/core/config.py | 4 +- esphome/core/defines.h | 13 +- esphome/core/hal.h | 4 +- esphome/core/helpers.h | 10 +- esphome/core/preference_backend.h | 6 +- esphome/core/preferences.h | 4 +- esphome/core/wake.h | 6 +- .../wake/{wake_rp2040.cpp => wake_rp2.cpp} | 4 +- .../core/wake/{wake_rp2040.h => wake_rp2.h} | 6 +- esphome/storage_json.py | 2 +- esphome/wizard.py | 56 +- script/build_language_schema.py | 23 + script/ci-custom.py | 7 +- script/determine-jobs.py | 19 +- ...p2040-boards.py => generate-rp2-boards.py} | 12 +- ...40-pico2-ard.yaml => test.rp2350-ard.yaml} | 0 .../{rp2040 => rp2}/test.rp2040-ard.yaml | 2 +- .../test.rp2350-ard.yaml} | 2 +- ...40-pico2-ard.yaml => test.rp2350-ard.yaml} | 0 tests/script/test_determine_jobs.py | 27 +- .../build_components_base.rp2040-ard.yaml | 2 +- ... => build_components_base.rp2350-ard.yaml} | 2 +- ...{rp2040-pico2-ard.yaml => rp2350-ard.yaml} | 0 tests/unit_tests/components/test_rp2.py | 95 +++ tests/unit_tests/components/test_rp2040.py | 92 --- ..._boards.py => test_rp2_generate_boards.py} | 4 +- tests/unit_tests/components/test_wifi.py | 6 +- tests/unit_tests/test_config_validation.py | 204 ++++++- tests/unit_tests/test_core.py | 30 + tests/unit_tests/test_loader.py | 544 ++++-------------- tests/unit_tests/test_main.py | 81 ++- tests/unit_tests/test_wizard.py | 56 +- tests/unit_tests/test_writer.py | 4 +- 153 files changed, 1297 insertions(+), 1062 deletions(-) rename esphome/components/adc/{adc_sensor_rp2040.cpp => adc_sensor_rp2.cpp} (97%) rename esphome/components/debug/{debug_rp2040.cpp => debug_rp2.cpp} (93%) rename esphome/components/ethernet/{ethernet_component_rp2040.cpp => ethernet_component_rp2.cpp} (98%) rename esphome/components/internal_temperature/{internal_temperature_rp2040.cpp => internal_temperature_rp2.cpp} (85%) rename esphome/components/logger/{logger_rp2040.cpp => logger_rp2.cpp} (85%) rename esphome/components/logger/{logger_rp2040.h => logger_rp2.h} (94%) rename esphome/components/mdns/{mdns_rp2040.cpp => mdns_rp2.cpp} (94%) rename esphome/components/ota/{ota_backend_arduino_rp2040.cpp => ota_backend_arduino_rp2.cpp} (70%) rename esphome/components/ota/{ota_backend_arduino_rp2040.h => ota_backend_arduino_rp2.h} (78%) rename esphome/components/{rp2040 => rp2}/__init__.py (92%) rename esphome/components/{rp2040 => rp2}/boards.jinja2 (56%) rename esphome/components/{rp2040 => rp2}/boards.py (99%) rename esphome/components/{rp2040 => rp2}/build_pio.py.script (100%) rename esphome/components/{rp2040 => rp2}/const.py (91%) create mode 100644 esphome/components/rp2/core.cpp rename esphome/components/{rp2040 => rp2}/core.h (53%) rename esphome/components/{rp2040 => rp2}/crash_handler.cpp (97%) rename esphome/components/{rp2040 => rp2}/crash_handler.h (66%) rename esphome/components/{rp2040 => rp2}/generate_boards.py (98%) rename esphome/components/{rp2040 => rp2}/gpio.cpp (81%) rename esphome/components/{rp2040 => rp2}/gpio.h (85%) rename esphome/components/{rp2040 => rp2}/gpio.py (82%) rename esphome/components/{rp2040 => rp2}/hal.cpp (58%) rename esphome/components/{rp2040 => rp2}/hal.h (96%) rename esphome/components/{rp2040 => rp2}/helpers.cpp (98%) rename esphome/components/{rp2040 => rp2}/inject_lwip_include.py.script (100%) rename esphome/components/{rp2040 => rp2}/lwipopts.h.jinja (100%) rename esphome/components/{rp2040 => rp2}/post_build.py.script (100%) create mode 100644 esphome/components/rp2/preference_backend.h rename esphome/components/{rp2040 => rp2}/preferences.cpp (82%) rename esphome/components/{rp2040 => rp2}/preferences.h (59%) rename esphome/components/{rp2040 => rp2}/printf_stubs.cpp (94%) delete mode 100644 esphome/components/rp2040/core.cpp delete mode 100644 esphome/components/rp2040/preference_backend.h rename esphome/components/{rp2040_pio => rp2_pio}/__init__.py (98%) rename esphome/components/uart/{uart_component_rp2040.cpp => uart_component_rp2.cpp} (91%) rename esphome/components/uart/{uart_component_rp2040.h => uart_component_rp2.h} (88%) rename esphome/core/wake/{wake_rp2040.cpp => wake_rp2.cpp} (97%) rename esphome/core/wake/{wake_rp2040.h => wake_rp2.h} (88%) rename script/{generate-rp2040-boards.py => generate-rp2-boards.py} (77%) rename tests/components/adc/{test.rp2040-pico2-ard.yaml => test.rp2350-ard.yaml} (100%) rename tests/components/{rp2040 => rp2}/test.rp2040-ard.yaml (97%) rename tests/components/{rp2040/test.rp2040-pico2-ard.yaml => rp2/test.rp2350-ard.yaml} (90%) rename tests/components/spi/{test.rp2040-pico2-ard.yaml => test.rp2350-ard.yaml} (100%) rename tests/test_build_components/{build_components_base.rp2040-pico2-ard.yaml => build_components_base.rp2350-ard.yaml} (97%) rename tests/test_build_components/common/spi/{rp2040-pico2-ard.yaml => rp2350-ard.yaml} (100%) create mode 100644 tests/unit_tests/components/test_rp2.py delete mode 100644 tests/unit_tests/components/test_rp2040.py rename tests/unit_tests/components/{test_rp2040_generate_boards.py => test_rp2_generate_boards.py} (98%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11e29db94a..0fd6a79cb5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,7 +112,7 @@ jobs: script/build_codeowners.py --check script/build_language_schema.py --check script/generate-esp32-boards.py --check - script/generate-rp2040-boards.py --check + script/generate-rp2-boards.py --check script/ci_check_duplicate_test_ids.py import-time: diff --git a/CODEOWNERS b/CODEOWNERS index 571f8492f1..34ec4bc2bd 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -426,7 +426,7 @@ esphome/components/rf_bridge/* @jesserockz esphome/components/rgbct/* @jesserockz esphome/components/ring_buffer/* @kahrendt esphome/components/router/speaker/* @kahrendt -esphome/components/rp2040/* @jesserockz +esphome/components/rp2/* @jesserockz esphome/components/rp2040_ble/* @bdraco esphome/components/rp2040_pio_led_strip/* @Papa-DMan esphome/components/rp2040_pwm/* @jesserockz diff --git a/esphome/__main__.py b/esphome/__main__.py index 2cc904ff4b..4abd18d239 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -355,7 +355,7 @@ def choose_upload_log_host( bootsel_permission_error = False if ( purpose == Purpose.UPLOADING - and CORE.is_rp2040 + and CORE.is_rp2 and (picotool := _find_picotool()) is not None ): bootsel = detect_rp2040_bootsel(picotool) @@ -402,7 +402,7 @@ def choose_upload_log_host( # Show helpful BOOTSEL instructions for RP2040 when no BOOTSEL device is found if ( purpose == Purpose.UPLOADING - and CORE.is_rp2040 + and CORE.is_rp2 and not any(get_port_type(opt[1]) == PortType.BOOTSEL for opt in options) ): if bootsel_permission_error: @@ -985,7 +985,7 @@ def upload_using_platformio(config: ConfigType, port: str) -> int: # RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for # the upload target, but 'nobuild' skips the build phase that creates it. # Create it here so the upload doesn't fail. - if CORE.is_rp2040: + if CORE.is_rp2: idedata = toolchain.get_idedata(config) build_dir = Path(idedata.firmware_elf_path).parent firmware_bin = build_dir / "firmware.bin" @@ -1173,7 +1173,7 @@ def upload_program( if CORE.is_esp32 or CORE.is_esp8266: file = getattr(args, "file", None) exit_code = upload_using_esptool(config, host, file, args.upload_speed) - elif CORE.is_rp2040 or CORE.is_libretiny: + elif CORE.is_rp2 or CORE.is_libretiny: exit_code = upload_using_platformio(config, host) # else: Unknown target platform, exit_code remains 1 @@ -1647,7 +1647,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: # After BOOTSEL upload, wait for a new serial port to appear # so it shows up in the log chooser - if successful_device is None and CORE.is_rp2040: + if successful_device is None and CORE.is_rp2: _wait_for_serial_port(known_ports=pre_upload_ports) # If exactly one new serial port appeared, use it directly serial_ports = get_serial_ports() diff --git a/esphome/components/__init__.py b/esphome/components/__init__.py index e69de29bb2..3d7a546253 100644 --- a/esphome/components/__init__.py +++ b/esphome/components/__init__.py @@ -0,0 +1,6 @@ +# Importing `esphome.loader` here installs the component-alias +# ``sys.meta_path`` finder before any submodule lookup runs. Without this, +# `from esphome.components import ` from a fresh interpreter +# can race the finder install and raise ImportError, since the legacy +# alias dir no longer exists on disk. +from esphome import loader as _loader # noqa: F401 diff --git a/esphome/components/adc/__init__.py b/esphome/components/adc/__init__.py index 96c8334a6d..555d511f6e 100644 --- a/esphome/components/adc/__init__.py +++ b/esphome/components/adc/__init__.py @@ -227,12 +227,12 @@ ESP32_VARIANT_ADC2_PIN_TO_CHANNEL = { def validate_adc_pin(value): if str(value).upper() == "VCC": - if CORE.is_rp2040: + if CORE.is_rp2: return pins.internal_gpio_input_pin_schema(29) return cv.only_on([PLATFORM_ESP8266])("VCC") if str(value).upper() == "TEMPERATURE": - return cv.only_on_rp2040("TEMPERATURE") + return cv.only_on_rp2("TEMPERATURE") if CORE.is_esp32: conf = pins.internal_gpio_input_pin_schema(value) @@ -261,11 +261,11 @@ def validate_adc_pin(value): raise cv.Invalid("ESP8266: Only pin A0 (GPIO17) supports ADC") return conf - if CORE.is_rp2040: + if CORE.is_rp2: conf = pins.internal_gpio_input_pin_schema(value) number = conf[CONF_NUMBER] if number not in (26, 27, 28, 29): - raise cv.Invalid("RP2040: Only pins 26, 27, 28 and 29 support ADC") + raise cv.Invalid("RP2: Only pins 26, 27, 28 and 29 support ADC") return conf if CORE.is_libretiny: diff --git a/esphome/components/adc/adc_sensor.h b/esphome/components/adc/adc_sensor.h index 03de6f8b4b..7131898747 100644 --- a/esphome/components/adc/adc_sensor.h +++ b/esphome/components/adc/adc_sensor.h @@ -123,9 +123,9 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v void set_autorange(bool autorange) { this->autorange_ = autorange; } #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 void set_is_temperature() { this->is_temperature_ = true; } -#endif // USE_RP2040 +#endif // USE_RP2 protected: uint8_t sample_count_{1}; @@ -152,9 +152,9 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v static adc_oneshot_unit_handle_t shared_adc_handles[2]; #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 bool is_temperature_{false}; -#endif // USE_RP2040 +#endif // USE_RP2 #ifdef USE_ZEPHYR const struct adc_dt_spec *channel_ = nullptr; diff --git a/esphome/components/adc/adc_sensor_rp2040.cpp b/esphome/components/adc/adc_sensor_rp2.cpp similarity index 97% rename from esphome/components/adc/adc_sensor_rp2040.cpp rename to esphome/components/adc/adc_sensor_rp2.cpp index 894c346588..6cb9ef113f 100644 --- a/esphome/components/adc/adc_sensor_rp2040.cpp +++ b/esphome/components/adc/adc_sensor_rp2.cpp @@ -1,4 +1,4 @@ -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "adc_sensor.h" #include "esphome/core/log.h" @@ -17,7 +17,7 @@ namespace esphome::adc { -static const char *const TAG = "adc.rp2040"; +static const char *const TAG = "adc.rp2"; void ADCSensor::setup() { static bool initialized = false; @@ -102,4 +102,4 @@ float ADCSensor::sample() { } // namespace esphome::adc -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index 09e09f0dc1..86e2b771ab 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -201,7 +201,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.ESP32_IDF, }, "adc_sensor_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, - "adc_sensor_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "adc_sensor_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "adc_sensor_libretiny.cpp": { PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 1146b43596..11ada7e970 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -300,7 +300,7 @@ CONFIG_SCHEMA = cv.All( CONF_LISTEN_BACKLOG, esp8266=1, # Limited RAM (~40KB free), LWIP raw sockets esp32=4, # More RAM (520KB), BSD sockets - rp2040=1, # Limited RAM (264KB), LWIP raw sockets like ESP8266 + rp2=1, # Limited RAM (264KB), LWIP raw sockets like ESP8266 bk72xx=4, # Moderate RAM, BSD-style sockets rtl87xx=4, # Moderate RAM, BSD-style sockets host=4, # Abundant resources @@ -311,7 +311,7 @@ CONFIG_SCHEMA = cv.All( CONF_MAX_CONNECTIONS, esp8266=4, # ~40KB free RAM, each connection uses ~500-1000 bytes esp32=5, # 520KB RAM available - rp2040=4, # 264KB RAM but LWIP constraints + rp2=4, # 264KB RAM but LWIP constraints bk72xx=5, # Moderate RAM rtl87xx=5, # Moderate RAM host=8, # Abundant resources @@ -326,7 +326,7 @@ CONFIG_SCHEMA = cv.All( CONF_MAX_SEND_QUEUE, esp8266=4, # Limited RAM, need to fail fast esp32=8, # More RAM, can buffer more - rp2040=8, # Moderate RAM + rp2=8, # Moderate RAM bk72xx=8, # Moderate RAM nrf52=8, # Moderate RAM rtl87xx=8, # Moderate RAM diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index acdf24e747..cb7d1b9d1e 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1759,7 +1759,7 @@ bool APIConnection::send_device_info_response_() { // Manufacturer string - define once, handle ESP8266 PROGMEM separately #if defined(USE_ESP8266) || defined(USE_ESP32) #define ESPHOME_MANUFACTURER "Espressif" -#elif defined(USE_RP2040) +#elif defined(USE_RP2) #define ESPHOME_MANUFACTURER "Raspberry Pi" #elif defined(USE_BK72XX) #define ESPHOME_MANUFACTURER "Beken" diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 92f7065730..dae5fc92fd 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -18,8 +18,8 @@ #ifdef USE_ESP32_CRASH_HANDLER #include "esphome/components/esp32/crash_handler.h" #endif -#ifdef USE_RP2040_CRASH_HANDLER -#include "esphome/components/rp2040/crash_handler.h" +#ifdef USE_RP2_CRASH_HANDLER +#include "esphome/components/rp2/crash_handler.h" #endif #ifdef USE_ESP8266_CRASH_HANDLER #include "esphome/components/esp8266/crash_handler.h" @@ -279,8 +279,8 @@ class APIConnection final : public APIServerConnectionBase { esp32::crash_handler_log(); esp32::crash_handler_clear(); #endif -#ifdef USE_RP2040_CRASH_HANDLER - rp2040::crash_handler_log(); +#ifdef USE_RP2_CRASH_HANDLER + rp2::crash_handler_log(); #endif #ifdef USE_ESP8266_CRASH_HANDLER esp8266::crash_handler_log(); diff --git a/esphome/components/async_tcp/__init__.py b/esphome/components/async_tcp/__init__.py index 2a07903b68..22d544ba37 100644 --- a/esphome/components/async_tcp/__init__.py +++ b/esphome/components/async_tcp/__init__.py @@ -13,7 +13,7 @@ def AUTO_LOAD() -> list[str]: if ( not CORE.is_esp32 and not CORE.is_esp8266 - and not CORE.is_rp2040 + and not CORE.is_rp2 and not CORE.is_libretiny ): return ["socket"] @@ -37,7 +37,7 @@ async def to_code(config): elif CORE.is_esp8266: # https://github.com/ESP32Async/ESPAsyncTCP cg.add_library("ESP32Async/ESPAsyncTCP", "2.0.0") - elif CORE.is_rp2040: + elif CORE.is_rp2: # https://github.com/ayushsharma82/RPAsyncTCP # RPAsyncTCP is a drop-in replacement for AsyncTCP_RP2040W with better # ESPAsyncWebServer compatibility @@ -47,6 +47,6 @@ async def to_code(config): def FILTER_SOURCE_FILES() -> list[str]: # Exclude socket implementation for platforms that use AsyncTCP libraries - if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2040 or CORE.is_libretiny: + if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2 or CORE.is_libretiny: return ["async_tcp_socket.cpp"] return [] diff --git a/esphome/components/async_tcp/async_tcp.h b/esphome/components/async_tcp/async_tcp.h index 21fcfe239f..0906a07844 100644 --- a/esphome/components/async_tcp/async_tcp.h +++ b/esphome/components/async_tcp/async_tcp.h @@ -7,7 +7,7 @@ #elif defined(USE_ESP8266) // Use ESPAsyncTCP library for ESP8266 (always Arduino) #include -#elif defined(USE_RP2040) +#elif defined(USE_RP2) // Use RPAsyncTCP library for RP2040 #include #else diff --git a/esphome/components/async_tcp/async_tcp_socket.cpp b/esphome/components/async_tcp/async_tcp_socket.cpp index e8c0f163b3..10cbc981c7 100644 --- a/esphome/components/async_tcp/async_tcp_socket.cpp +++ b/esphome/components/async_tcp/async_tcp_socket.cpp @@ -1,6 +1,6 @@ #include "async_tcp_socket.h" -#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2040) && !defined(USE_LIBRETINY) && \ +#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2) && !defined(USE_LIBRETINY) && \ (defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS)) #include "esphome/components/network/util.h" diff --git a/esphome/components/async_tcp/async_tcp_socket.h b/esphome/components/async_tcp/async_tcp_socket.h index 28714a7752..3b17fe14df 100644 --- a/esphome/components/async_tcp/async_tcp_socket.h +++ b/esphome/components/async_tcp/async_tcp_socket.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2040) && !defined(USE_LIBRETINY) && \ +#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2) && !defined(USE_LIBRETINY) && \ (defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS)) #include "esphome/components/socket/socket.h" diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index cd877fc879..703ae98392 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -13,7 +13,7 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_LN882X, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, PlatformFramework, ) @@ -54,7 +54,7 @@ CONFIG_SCHEMA = cv.All( PLATFORM_ESP8266, PLATFORM_BK72XX, PLATFORM_LN882X, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, ] ), @@ -105,7 +105,7 @@ async def to_code(config): if config[CONF_COMPRESSION] == "gzip": cg.add_define("USE_CAPTIVE_PORTAL_GZIP") - if CORE.using_arduino and (CORE.is_esp8266 or CORE.is_libretiny or CORE.is_rp2040): + if CORE.using_arduino and (CORE.is_esp8266 or CORE.is_libretiny or CORE.is_rp2): cg.add_library("DNSServer", None) diff --git a/esphome/components/debug/__init__.py b/esphome/components/debug/__init__.py index dc032f442e..3e94d04f21 100644 --- a/esphome/components/debug/__init__.py +++ b/esphome/components/debug/__init__.py @@ -70,7 +70,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( }, "debug_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, "debug_host.cpp": {PlatformFramework.HOST_NATIVE}, - "debug_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "debug_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "debug_libretiny.cpp": { PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, diff --git a/esphome/components/debug/debug_rp2040.cpp b/esphome/components/debug/debug_rp2.cpp similarity index 93% rename from esphome/components/debug/debug_rp2040.cpp rename to esphome/components/debug/debug_rp2.cpp index adc23dbf51..ba6081963f 100644 --- a/esphome/components/debug/debug_rp2040.cpp +++ b/esphome/components/debug/debug_rp2.cpp @@ -1,5 +1,5 @@ #include "debug_component.h" -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/defines.h" #include "esphome/core/log.h" #include @@ -9,8 +9,8 @@ #else #include #endif -#ifdef USE_RP2040_CRASH_HANDLER -#include "esphome/components/rp2040/crash_handler.h" +#ifdef USE_RP2_CRASH_HANDLER +#include "esphome/components/rp2/crash_handler.h" #endif namespace esphome::debug { @@ -41,8 +41,8 @@ const char *DebugComponent::get_reset_reason_(std::span None: cg.add(var.set_reset_pin(config[CONF_RESET_PIN])) cg.add_define("USE_ETHERNET_SPI") - cg.add_library(_RP2040_SPI_LIBRARIES[config[CONF_TYPE]], None) + cg.add_library(_RP2_SPI_LIBRARIES[config[CONF_TYPE]], None) def _final_validate_rmii_pins(config: ConfigType) -> None: @@ -752,7 +754,7 @@ _platform_filter = filter_source_files_from_platform( PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO, }, - "ethernet_component_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "ethernet_component_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "esp_eth_phy_jl1101.c": { PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO, diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index e0fe920ea1..16f09a45f0 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -25,7 +25,7 @@ extern "C" eth_esp32_emac_config_t eth_esp32_emac_default_config(void); #endif #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 #if defined(USE_ETHERNET_W5500) #include #elif defined(USE_ETHERNET_W5100) @@ -182,14 +182,14 @@ class EthernetComponent final : public Component { #endif // USE_ETHERNET_SPI #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 void set_clk_pin(uint8_t clk_pin); void set_miso_pin(uint8_t miso_pin); void set_mosi_pin(uint8_t mosi_pin); void set_cs_pin(uint8_t cs_pin); void set_interrupt_pin(int8_t interrupt_pin); void set_reset_pin(int8_t reset_pin); -#endif // USE_RP2040 +#endif // USE_RP2 #ifdef USE_ETHERNET_IP_STATE_LISTENERS void add_ip_state_listener(EthernetIPStateListener *listener) { this->ip_state_listeners_.push_back(listener); } @@ -272,7 +272,7 @@ class EthernetComponent final : public Component { esp_eth_phy_t *phy_{nullptr}; #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 static constexpr uint32_t LINK_CHECK_INTERVAL = 500; // ms between link/IP polls #if defined(USE_ETHERNET_W5100) static constexpr uint32_t RESET_DELAY_MS = 150; // W5100S PLL lock time @@ -301,7 +301,7 @@ class EthernetComponent final : public Component { uint8_t cs_pin_; int8_t interrupt_pin_{-1}; int8_t reset_pin_{-1}; -#endif // USE_RP2040 +#endif // USE_RP2 // Common members #ifdef USE_ETHERNET_MANUAL_IP diff --git a/esphome/components/ethernet/ethernet_component_rp2040.cpp b/esphome/components/ethernet/ethernet_component_rp2.cpp similarity index 98% rename from esphome/components/ethernet/ethernet_component_rp2040.cpp rename to esphome/components/ethernet/ethernet_component_rp2.cpp index 250297ddb5..d2e3f14e02 100644 --- a/esphome/components/ethernet/ethernet_component_rp2040.cpp +++ b/esphome/components/ethernet/ethernet_component_rp2.cpp @@ -1,12 +1,12 @@ #include "ethernet_component.h" -#if defined(USE_ETHERNET) && defined(USE_RP2040) +#if defined(USE_ETHERNET) && defined(USE_RP2) #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include "esphome/components/rp2040/gpio.h" +#include "esphome/components/rp2/gpio.h" #include #include @@ -29,7 +29,7 @@ void EthernetComponent::setup() { // Toggle reset pin if configured if (this->reset_pin_ >= 0) { - rp2040::RP2040GPIOPin reset_pin; + rp2::RP2GPIOPin reset_pin; reset_pin.set_pin(this->reset_pin_); reset_pin.set_flags(gpio::FLAG_OUTPUT); reset_pin.setup(); @@ -380,4 +380,4 @@ void EthernetComponent::disable() { } // namespace esphome::ethernet -#endif // USE_ETHERNET && USE_RP2040 +#endif // USE_ETHERNET && USE_RP2 diff --git a/esphome/components/factory_reset/factory_reset.cpp b/esphome/components/factory_reset/factory_reset.cpp index cd4134e9ae..bceaf6e40f 100644 --- a/esphome/components/factory_reset/factory_reset.cpp +++ b/esphome/components/factory_reset/factory_reset.cpp @@ -7,7 +7,7 @@ #include -#if !defined(USE_RP2040) && !defined(USE_HOST) +#if !defined(USE_RP2) && !defined(USE_HOST) namespace esphome::factory_reset { @@ -73,4 +73,4 @@ void FactoryResetComponent::setup() { } // namespace esphome::factory_reset -#endif // !defined(USE_RP2040) && !defined(USE_HOST) +#endif // !defined(USE_RP2) && !defined(USE_HOST) diff --git a/esphome/components/factory_reset/factory_reset.h b/esphome/components/factory_reset/factory_reset.h index d80d2d2406..b0a899c719 100644 --- a/esphome/components/factory_reset/factory_reset.h +++ b/esphome/components/factory_reset/factory_reset.h @@ -3,7 +3,7 @@ #include "esphome/core/component.h" #include "esphome/core/automation.h" #include "esphome/core/preferences.h" -#if !defined(USE_RP2040) && !defined(USE_HOST) +#if !defined(USE_RP2) && !defined(USE_HOST) #ifdef USE_ESP32 #include @@ -32,4 +32,4 @@ class FactoryResetComponent final : public Component { } // namespace esphome::factory_reset -#endif // !defined(USE_RP2040) && !defined(USE_HOST) +#endif // !defined(USE_RP2) && !defined(USE_HOST) diff --git a/esphome/components/gpio/binary_sensor/__init__.py b/esphome/components/gpio/binary_sensor/__init__.py index 2f1aa936a3..43358baedb 100644 --- a/esphome/components/gpio/binary_sensor/__init__.py +++ b/esphome/components/gpio/binary_sensor/__init__.py @@ -47,7 +47,7 @@ CONFIG_SCHEMA = ( host=True, ln882x=False, nrf52=True, - rp2040=True, + rp2=True, rtl87xx=False, ): cv.boolean, cv.Optional(CONF_INTERRUPT_TYPE, default="ANY"): cv.enum( diff --git a/esphome/components/hmac_sha256/hmac_sha256.cpp b/esphome/components/hmac_sha256/hmac_sha256.cpp index c113cb48a6..d8e1f059a6 100644 --- a/esphome/components/hmac_sha256/hmac_sha256.cpp +++ b/esphome/components/hmac_sha256/hmac_sha256.cpp @@ -1,6 +1,6 @@ #include #include "hmac_sha256.h" -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_HOST) #include "esphome/core/helpers.h" namespace esphome::hmac_sha256 { diff --git a/esphome/components/hmac_sha256/hmac_sha256.h b/esphome/components/hmac_sha256/hmac_sha256.h index 22129b1182..74ac4c23de 100644 --- a/esphome/components/hmac_sha256/hmac_sha256.h +++ b/esphome/components/hmac_sha256/hmac_sha256.h @@ -1,7 +1,7 @@ #pragma once #include "esphome/core/defines.h" -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_HOST) #include diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index fd033dac7f..54d7f5c77b 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -73,7 +73,7 @@ def validate_url(value): def validate_ssl_verification(config): error_message = "" - if CORE.is_rp2040 and config[CONF_VERIFY_SSL]: + if CORE.is_rp2 and config[CONF_VERIFY_SSL]: error_message = "ESPHome does not support certificate verification on RP2040" if ( @@ -96,7 +96,7 @@ def _declare_request_class(value): return cv.declare_id(HttpRequestHost)(value) if CORE.is_esp32: return cv.declare_id(HttpRequestIDF)(value) - if CORE.is_esp8266 or CORE.is_rp2040: + if CORE.is_esp8266 or CORE.is_rp2: return cv.declare_id(HttpRequestArduino)(value) return NotImplementedError @@ -118,7 +118,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, cv.Optional(CONF_WATCHDOG_TIMEOUT): cv.All( - cv.Any(cv.only_on_esp32, cv.only_on_rp2040), + cv.Any(cv.only_on_esp32, cv.only_on_rp2), cv.positive_not_null_time_period, cv.positive_time_period_milliseconds, ), @@ -144,7 +144,7 @@ CONFIG_SCHEMA = cv.All( esp8266_arduino=cv.Version(2, 5, 1), esp32_arduino=cv.Version(0, 0, 0), esp_idf=cv.Version(0, 0, 0), - rp2040_arduino=cv.Version(0, 0, 0), + rp2_arduino=cv.Version(0, 0, 0), host=cv.Version(0, 0, 0), ), validate_ssl_verification, @@ -204,7 +204,7 @@ async def to_code(config): ) if CORE.is_esp8266: cg.add_library("ESP8266HTTPClient", None) - if CORE.is_rp2040 and CORE.using_arduino: + if CORE.is_rp2 and CORE.using_arduino: cg.add_library("HTTPClient", None) if CORE.is_host: if IS_MACOS: @@ -368,7 +368,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( "http_request_host.cpp": {PlatformFramework.HOST_NATIVE}, "http_request_arduino.cpp": { PlatformFramework.ESP8266_ARDUINO, - PlatformFramework.RP2040_ARDUINO, + PlatformFramework.RP2_ARDUINO, PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index bb5e9427dd..1760cb9395 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -72,7 +72,7 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur bool status = container->client_.begin(*stream_ptr, url.c_str()); -#elif defined(USE_RP2040) +#elif defined(USE_RP2) if (secure) { container->client_.setInsecure(); } diff --git a/esphome/components/http_request/http_request_arduino.h b/esphome/components/http_request/http_request_arduino.h index 8da40798ec..c109de8a39 100644 --- a/esphome/components/http_request/http_request_arduino.h +++ b/esphome/components/http_request/http_request_arduino.h @@ -4,7 +4,7 @@ #if defined(USE_ARDUINO) && !defined(USE_ESP32) -#if defined(USE_RP2040) +#if defined(USE_RP2) #include #include #endif diff --git a/esphome/components/http_request/ota/__init__.py b/esphome/components/http_request/ota/__init__.py index 1bb54599dc..b7026e0f55 100644 --- a/esphome/components/http_request/ota/__init__.py +++ b/esphome/components/http_request/ota/__init__.py @@ -36,7 +36,7 @@ CONFIG_SCHEMA = cv.All( esp8266_arduino=cv.Version(2, 5, 1), esp32_arduino=cv.Version(0, 0, 0), esp_idf=cv.Version(0, 0, 0), - rp2040_arduino=cv.Version(0, 0, 0), + rp2_arduino=cv.Version(0, 0, 0), ), ) diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index eec2211a96..7b163d065e 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -49,7 +49,7 @@ from esphome.const import ( PLATFORM_ESP8266, PLATFORM_HOST, PLATFORM_NRF52, - PLATFORM_RP2040, + PLATFORM_RP2, PlatformFramework, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority @@ -130,7 +130,7 @@ def validate_config(config): return cv.require_framework_version( esp_idf=cv.Version(5, 4, 2), esp32_arduino=cv.Version(3, 2, 1) )(config) - if CORE.is_rp2040: + if CORE.is_rp2: sda_controller = _rp2040_i2c_controller(config[CONF_SDA]) scl_controller = _rp2040_i2c_controller(config[CONF_SCL]) if sda_controller != scl_controller: @@ -171,7 +171,7 @@ CONFIG_SCHEMA = cv.All( CONF_SDA, esp32="SDA", esp8266="SDA", - rp2040="SDA", + rp2="SDA", nrf52="SDA", ): pins.internal_gpio_pin_number, cv.SplitDefault(CONF_SDA_PULLUP_ENABLED, esp32=True): cv.All( @@ -181,7 +181,7 @@ CONFIG_SCHEMA = cv.All( CONF_SCL, esp32="SCL", esp8266="SCL", - rp2040="SCL", + rp2="SCL", nrf52="SCL", ): pins.internal_gpio_pin_number, cv.SplitDefault(CONF_SCL_PULLUP_ENABLED, esp32=True): cv.All( @@ -191,7 +191,7 @@ CONFIG_SCHEMA = cv.All( CONF_FREQUENCY, esp32="50kHz", esp8266="50kHz", - rp2040="50kHz", + rp2="50kHz", nrf52="100kHz", host="50kHz", ): cv.All( @@ -219,7 +219,7 @@ CONFIG_SCHEMA = cv.All( [ PLATFORM_ESP32, PLATFORM_ESP8266, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_NRF52, PLATFORM_HOST, ] @@ -233,7 +233,7 @@ def _final_validate(config): full_config = fv.full_config.get()[CONF_I2C] if CORE.using_zephyr and len(full_config) > 1: raise cv.Invalid("Second i2c is not implemented on Zephyr yet") - if CORE.is_rp2040: + if CORE.is_rp2: if len(full_config) > 2: raise cv.Invalid( "The maximum number of I2C interfaces for RP2040/RP2350 is 2" @@ -443,7 +443,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( { "i2c_bus_arduino.cpp": { PlatformFramework.ESP8266_ARDUINO, - PlatformFramework.RP2040_ARDUINO, + PlatformFramework.RP2_ARDUINO, PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index 47a06abe9e..871f67a4c8 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -19,7 +19,7 @@ void ArduinoI2CBus::setup() { #if defined(USE_ESP8266) wire_ = new TwoWire(); // NOLINT(cppcoreguidelines-owning-memory) -#elif defined(USE_RP2040) +#elif defined(USE_RP2) // Select Wire instance based on pin assignment, not definition order. // I2C controller = (gpio / 2) % 2: even pairs (0-1,4-5,...) → I2C0, odd pairs (2-3,6-7,...) → I2C1 // RP2040 datasheet Table 2 (section 1.4.3): https://datasheets.raspberrypi.com/rp2040/rp2040-datasheet.pdf @@ -41,7 +41,7 @@ void ArduinoI2CBus::setup() { } void ArduinoI2CBus::set_pins_and_clock_() { -#ifdef USE_RP2040 +#ifdef USE_RP2 wire_->setSDA(this->sda_pin_); wire_->setSCL(this->scl_pin_); wire_->begin(); @@ -52,7 +52,7 @@ void ArduinoI2CBus::set_pins_and_clock_() { #if defined(USE_ESP8266) // https://github.com/esp8266/Arduino/blob/master/libraries/Wire/Wire.h wire_->setClockStretchLimit(timeout_); // unit: us -#elif defined(USE_RP2040) +#elif defined(USE_RP2) // https://github.com/earlephilhower/ArduinoCore-API/blob/e37df85425e0ac020bfad226d927f9b00d2e0fb7/api/Stream.h wire_->setTimeout(timeout_ / 1000); // unit: ms #endif @@ -70,7 +70,7 @@ void ArduinoI2CBus::dump_config() { if (timeout_ > 0) { #if defined(USE_ESP8266) ESP_LOGCONFIG(TAG, " Timeout: %u us", this->timeout_); -#elif defined(USE_RP2040) +#elif defined(USE_RP2) ESP_LOGCONFIG(TAG, " Timeout: %u ms", this->timeout_ / 1000); #endif } diff --git a/esphome/components/internal_temperature/internal_temperature_rp2040.cpp b/esphome/components/internal_temperature/internal_temperature_rp2.cpp similarity index 85% rename from esphome/components/internal_temperature/internal_temperature_rp2040.cpp rename to esphome/components/internal_temperature/internal_temperature_rp2.cpp index 66dee9faf7..11f8e27fc3 100644 --- a/esphome/components/internal_temperature/internal_temperature_rp2040.cpp +++ b/esphome/components/internal_temperature/internal_temperature_rp2.cpp @@ -1,4 +1,4 @@ -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/log.h" #include "internal_temperature.h" @@ -7,7 +7,7 @@ namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature.rp2040"; +static const char *const TAG = "internal_temperature.rp2"; void InternalTemperatureSensor::update() { float temperature = NAN; @@ -28,4 +28,4 @@ void InternalTemperatureSensor::update() { } // namespace esphome::internal_temperature -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/internal_temperature/sensor.py b/esphome/components/internal_temperature/sensor.py index 02730b6862..805138071e 100644 --- a/esphome/components/internal_temperature/sensor.py +++ b/esphome/components/internal_temperature/sensor.py @@ -10,7 +10,7 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_LN882X, PLATFORM_NRF52, - PLATFORM_RP2040, + PLATFORM_RP2, STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, PlatformFramework, @@ -34,7 +34,7 @@ CONFIG_SCHEMA = cv.All( cv.only_on( [ PLATFORM_ESP32, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_BK72XX, PLATFORM_NRF52, PLATFORM_LN882X, @@ -58,7 +58,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP32_IDF, }, - "internal_temperature_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "internal_temperature_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "internal_temperature_bk72xx.cpp": { PlatformFramework.BK72XX_ARDUINO, }, diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 9629dce0bf..77a875dd8f 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -54,7 +54,7 @@ from esphome.const import ( PLATFORM_ESP8266, PLATFORM_LN882X, PLATFORM_NRF52, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, PlatformFramework, ) @@ -154,7 +154,7 @@ HARDWARE_UART_TO_SERIAL = { UART2: cg.global_ns.Serial2, DEFAULT: cg.global_ns.Serial, }, - PLATFORM_RP2040: { + PLATFORM_RP2: { UART0: cg.global_ns.Serial1, UART1: cg.global_ns.Serial2, USB_CDC: cg.global_ns.Serial, @@ -171,7 +171,7 @@ def uart_selection(value): return cv.one_of(*UART_SELECTION_ESP32[variant], upper=True)(value) if CORE.is_esp8266: return cv.one_of(*UART_SELECTION_ESP8266, upper=True)(value) - if CORE.is_rp2040: + if CORE.is_rp2: return cv.one_of(*UART_SELECTION_RP2040, upper=True)(value) if CORE.is_libretiny: family = get_libretiny_family() @@ -282,7 +282,7 @@ CONFIG_SCHEMA = cv.All( esp32_s2=USB_CDC, esp32_s3=USB_SERIAL_JTAG, esp32_s31=USB_SERIAL_JTAG, - rp2040=USB_CDC, + rp2=USB_CDC, bk72xx=DEFAULT, ln882x=DEFAULT, rtl87xx=DEFAULT, @@ -292,7 +292,7 @@ CONFIG_SCHEMA = cv.All( [ PLATFORM_ESP8266, PLATFORM_ESP32, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_BK72XX, PLATFORM_LN882X, PLATFORM_RTL87XX, @@ -417,11 +417,7 @@ async def _late_logger_init(config: ConfigType) -> None: cg.add_define("USE_ESP8266_LOGGER_SERIAL1") enable_serial1() - if ( - (CORE.is_esp8266 or CORE.is_rp2040) - and has_serial_logging - and is_at_least_verbose - ): + if (CORE.is_esp8266 or CORE.is_rp2) and has_serial_logging and is_at_least_verbose: debug_serial_port = HARDWARE_UART_TO_SERIAL[CORE.target_platform][ config.get(CONF_HARDWARE_UART) ] @@ -605,7 +601,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( }, "logger_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, "logger_host.cpp": {PlatformFramework.HOST_NATIVE}, - "logger_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "logger_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "logger_libretiny.cpp": { PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 684da0202e..6527b6aa8c 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -206,7 +206,7 @@ void Logger::set_baud_rate(uint32_t baud_rate) { this->baud_rate_ = baud_rate; } void Logger::set_log_level(const char *tag, uint8_t log_level) { this->log_levels_[tag] = log_level; } #endif -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) UARTSelection Logger::get_uart() const { return this->uart_; } #endif diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 784cbea67e..69d8e6d32a 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -23,10 +23,10 @@ #if defined(USE_ESP8266) #include #endif // USE_ESP8266 -#ifdef USE_RP2040 +#ifdef USE_RP2 #include #include -#endif // USE_RP2040 +#endif // USE_RP2 #endif // USE_ARDUINO #ifdef USE_ESP32 @@ -96,7 +96,7 @@ struct CStrCompare { // macOS allows up to 64 bytes, Linux up to 16 static constexpr size_t THREAD_NAME_BUF_SIZE = 64; -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) /** Enum for logging UART selection * * Advanced configuration (pin selection, etc) is not supported. @@ -122,7 +122,7 @@ enum UARTSelection : uint8_t { UART_SELECTION_UART0_SWAP, #endif // USE_ESP8266 }; -#endif // USE_ESP32 || USE_ESP8266 || USE_RP2040 || USE_LIBRETINY || USE_ZEPHYR +#endif // USE_ESP32 || USE_ESP8266 || USE_RP2 || USE_LIBRETINY || USE_ZEPHYR /** * @brief Logger component for all ESPHome logging. @@ -160,7 +160,7 @@ class Logger final : public Component { #ifdef USE_HOST void create_pthread_key() { pthread_key_create(&log_recursion_key_, nullptr); } #endif -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) void set_uart_selection(UARTSelection uart_selection) { uart_ = uart_selection; } /// Get the UART used by the logger. UARTSelection get_uart() const; @@ -351,7 +351,7 @@ class Logger final : public Component { #endif // Group smaller types together at the end uint8_t current_level_{ESPHOME_LOG_LEVEL_VERY_VERBOSE}; -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_ZEPHYR) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_ZEPHYR) UARTSelection uart_{UART_SELECTION_UART0}; #endif #ifdef USE_LIBRETINY @@ -505,8 +505,8 @@ class LoggerMessageTrigger final : public Triggerdigest_, 0, 16); MD5Init(&this->ctx_); @@ -14,7 +14,7 @@ void MD5Digest::init() { void MD5Digest::add(const uint8_t *data, size_t len) { MD5Update(&this->ctx_, data, len); } void MD5Digest::calculate() { MD5Final(this->digest_, &this->ctx_); } -#endif // USE_ARDUINO && !USE_RP2040 +#endif // USE_ARDUINO && !USE_RP2 #ifdef USE_ESP32 void MD5Digest::init() { @@ -27,7 +27,7 @@ void MD5Digest::add(const uint8_t *data, size_t len) { esp_rom_md5_update(&this- void MD5Digest::calculate() { esp_rom_md5_final(this->digest_, &this->ctx_); } #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 void MD5Digest::init() { memset(this->digest_, 0, 16); br_md5_init(&this->ctx_); @@ -36,7 +36,7 @@ void MD5Digest::init() { void MD5Digest::add(const uint8_t *data, size_t len) { br_md5_update(&this->ctx_, data, len); } void MD5Digest::calculate() { br_md5_out(&this->ctx_, this->digest_); } -#endif // USE_RP2040 +#endif // USE_RP2 #ifdef USE_HOST MD5Digest::~MD5Digest() { diff --git a/esphome/components/md5/md5.h b/esphome/components/md5/md5.h index 5e841edd83..ff0f2852c8 100644 --- a/esphome/components/md5/md5.h +++ b/esphome/components/md5/md5.h @@ -19,7 +19,7 @@ #define MD5_CTX_TYPE md5_context_t #endif -#ifdef USE_RP2040 +#ifdef USE_RP2 #include #define MD5_CTX_TYPE br_md5_context #endif diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 2de67542b2..3670098bcf 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -70,11 +70,11 @@ def _require_network_interface(config: ConfigType) -> ConfigType: 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): + if config.get(CONF_DISABLED) or not (CORE.is_esp8266 or CORE.is_rp2): return config full_config = fv.full_config.get() has_wifi = "wifi" in full_config - has_ethernet = CORE.is_rp2040 and "ethernet" in full_config + has_ethernet = CORE.is_rp2 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( @@ -192,18 +192,18 @@ async def to_code(config): if CORE.using_arduino: if CORE.is_esp8266: cg.add_library("ESP8266mDNS", None) - elif CORE.is_rp2040: + elif CORE.is_rp2: 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 CORE.is_esp8266 or CORE.is_rp2: 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: + if CORE.is_rp2 and "ethernet" in CORE.config: from esphome.components import ethernet ethernet.request_ethernet_ip_state_listener() @@ -274,7 +274,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( }, "mdns_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, "mdns_host.cpp": {PlatformFramework.HOST_NATIVE}, - "mdns_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "mdns_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "mdns_libretiny.cpp": { PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index e11cb1abaa..02b825605c 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -100,7 +100,7 @@ void MDNSComponent::compile_records_(StaticVector services_{}; #endif -#if defined(USE_RP2040) && defined(USE_MDNS_EVENT_DRIVEN_POLLING) +#if defined(USE_RP2) && defined(USE_MDNS_EVENT_DRIVEN_POLLING) // RP2040 defers MDNS.begin() until the first IP-up event; this tracks that. bool initialized_{false}; #endif diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2.cpp similarity index 94% rename from esphome/components/mdns/mdns_rp2040.cpp rename to esphome/components/mdns/mdns_rp2.cpp index f5848893a3..7eaac594fb 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2.cpp @@ -1,5 +1,5 @@ #include "esphome/core/defines.h" -#if defined(USE_RP2040) && defined(USE_MDNS) +#if defined(USE_RP2) && defined(USE_MDNS) #include "esphome/components/network/ip_address.h" #include "esphome/components/network/util.h" @@ -17,7 +17,7 @@ namespace esphome::mdns { -static void register_rp2040(MDNSComponent *, StaticVector &services) { +static void register_rp2(MDNSComponent *, StaticVector &services) { MDNS.begin(App.get_name().c_str()); for (const auto &service : services) { @@ -82,7 +82,7 @@ void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network:: return; } if (!this->initialized_) { - this->setup_buffers_and_register_(register_rp2040); + this->setup_buffers_and_register_(register_rp2); this->initialized_ = true; } else { MDNS.notifyAPChange(); diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index afc514609c..3bbc1cdfa3 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -319,7 +319,7 @@ bool MQTTComponent::send_discovery_() { device_info[MQTT_DEVICE_MODEL] = ESPHOME_BOARD; #if defined(USE_ESP8266) || defined(USE_ESP32) device_info[MQTT_DEVICE_MANUFACTURER] = "Espressif"; -#elif defined(USE_RP2040) +#elif defined(USE_RP2) device_info[MQTT_DEVICE_MANUFACTURER] = "Raspberry Pi"; #elif defined(USE_BK72XX) device_info[MQTT_DEVICE_MANUFACTURER] = "Beken"; diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index d2683e4bba..616a189226 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -124,7 +124,7 @@ CONFIG_SCHEMA = cv.Schema( esp32=False, esp8266=False, host=False, - rp2040=False, + rp2=False, nrf52=True, ): cv.All( cv.boolean, @@ -135,7 +135,7 @@ CONFIG_SCHEMA = cv.Schema( esp32_arduino=cv.Version(0, 0, 0), esp8266_arduino=cv.Version(0, 0, 0), host=cv.Version(0, 0, 0), - rp2040_arduino=cv.Version(0, 0, 0), + rp2_arduino=cv.Version(0, 0, 0), nrf52_zephyr=cv.Version(0, 0, 0), ), cv.boolean_false, @@ -263,7 +263,7 @@ async def to_code(config): cg.add_build_flag("-DCONFIG_IPV6") if CORE.is_esp8266: cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY") - if CORE.is_rp2040: + if CORE.is_rp2: cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_ENABLE_IPV6") # Pvariable creation lives in a separate coroutine at NETWORK_SERVICES so it # emits after wifi/ethernet at COMMUNICATION. This keeps compile-time config diff --git a/esphome/components/nextion/__init__.py b/esphome/components/nextion/__init__.py index 38f449dc03..d51155b0a4 100644 --- a/esphome/components/nextion/__init__.py +++ b/esphome/components/nextion/__init__.py @@ -19,7 +19,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( }, "nextion_upload_arduino.cpp": { PlatformFramework.ESP8266_ARDUINO, - PlatformFramework.RP2040_ARDUINO, + PlatformFramework.RP2_ARDUINO, PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, diff --git a/esphome/components/online_image/__init__.py b/esphome/components/online_image/__init__.py index ee4d5abb1c..d47c2e8b44 100644 --- a/esphome/components/online_image/__init__.py +++ b/esphome/components/online_image/__init__.py @@ -61,7 +61,7 @@ CONFIG_SCHEMA = cv.Schema( # esp8266_arduino=cv.Version(2, 7, 0), esp32_arduino=cv.Version(0, 0, 0), esp_idf=cv.Version(4, 0, 0), - rp2040_arduino=cv.Version(0, 0, 0), + rp2_arduino=cv.Version(0, 0, 0), host=cv.Version(0, 0, 0), ), runtime_image.validate_runtime_image_settings, diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 83d8c611d5..8296410f2f 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -99,7 +99,7 @@ async def to_code(config): cg.add_define("USE_OTA") CORE.add_job(final_step) - if CORE.is_rp2040 and CORE.using_arduino: + if CORE.is_rp2 and CORE.using_arduino: cg.add_library("Updater", None) @@ -158,7 +158,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.ESP32_IDF, }, "ota_backend_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, - "ota_backend_arduino_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "ota_backend_arduino_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "ota_backend_arduino_libretiny.cpp": { PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, diff --git a/esphome/components/ota/ota_backend_arduino_rp2040.cpp b/esphome/components/ota/ota_backend_arduino_rp2.cpp similarity index 70% rename from esphome/components/ota/ota_backend_arduino_rp2040.cpp rename to esphome/components/ota/ota_backend_arduino_rp2.cpp index 0ca0602519..b35eb38c12 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2040.cpp +++ b/esphome/components/ota/ota_backend_arduino_rp2.cpp @@ -1,9 +1,9 @@ #ifdef USE_ARDUINO -#ifdef USE_RP2040 -#include "ota_backend_arduino_rp2040.h" +#ifdef USE_RP2 +#include "ota_backend_arduino_rp2.h" #include "ota_backend.h" -#include "esphome/components/rp2040/preferences.h" +#include "esphome/components/rp2/preferences.h" #include "esphome/core/defines.h" #include "esphome/core/log.h" @@ -11,11 +11,11 @@ namespace esphome::ota { -static const char *const TAG = "ota.arduino_rp2040"; +static const char *const TAG = "ota.arduino_rp2"; -std::unique_ptr make_ota_backend() { return make_unique(); } +std::unique_ptr make_ota_backend() { return make_unique(); } -OTAResponseTypes ArduinoRP2040OTABackend::begin(size_t image_size, OTAType ota_type) { +OTAResponseTypes ArduinoRP2OTABackend::begin(size_t image_size, OTAType ota_type) { if (ota_type != OTA_TYPE_UPDATE_APP) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } @@ -23,7 +23,7 @@ OTAResponseTypes ArduinoRP2040OTABackend::begin(size_t image_size, OTAType ota_t // web_server is not supported for RP2040, so this is not an issue. bool ret = Update.begin(image_size, U_FLASH); if (ret) { - rp2040::preferences_prevent_write(true); + rp2::preferences_prevent_write(true); return OTA_RESPONSE_OK; } @@ -42,12 +42,12 @@ OTAResponseTypes ArduinoRP2040OTABackend::begin(size_t image_size, OTAType ota_t return OTA_RESPONSE_ERROR_UNKNOWN; } -void ArduinoRP2040OTABackend::set_update_md5(const char *md5) { +void ArduinoRP2OTABackend::set_update_md5(const char *md5) { Update.setMD5(md5); this->md5_set_ = true; } -OTAResponseTypes ArduinoRP2040OTABackend::write(uint8_t *data, size_t len) { +OTAResponseTypes ArduinoRP2OTABackend::write(uint8_t *data, size_t len) { size_t written = Update.write(data, len); if (written == len) { return OTA_RESPONSE_OK; @@ -59,7 +59,7 @@ OTAResponseTypes ArduinoRP2040OTABackend::write(uint8_t *data, size_t len) { return OTA_RESPONSE_ERROR_WRITING_FLASH; } -OTAResponseTypes ArduinoRP2040OTABackend::end() { +OTAResponseTypes ArduinoRP2OTABackend::end() { // Use strict validation (false) when MD5 is set, lenient validation (true) when no MD5 // This matches the behavior of the old web_server OTA implementation if (Update.end(!this->md5_set_)) { @@ -72,11 +72,11 @@ OTAResponseTypes ArduinoRP2040OTABackend::end() { return OTA_RESPONSE_ERROR_UPDATE_END; } -void ArduinoRP2040OTABackend::abort() { +void ArduinoRP2OTABackend::abort() { Update.end(); - rp2040::preferences_prevent_write(false); + rp2::preferences_prevent_write(false); } } // namespace esphome::ota -#endif // USE_RP2040 +#endif // USE_RP2 #endif // USE_ARDUINO diff --git a/esphome/components/ota/ota_backend_arduino_rp2040.h b/esphome/components/ota/ota_backend_arduino_rp2.h similarity index 78% rename from esphome/components/ota/ota_backend_arduino_rp2040.h rename to esphome/components/ota/ota_backend_arduino_rp2.h index d04d5c1a84..f7c0037bd2 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2040.h +++ b/esphome/components/ota/ota_backend_arduino_rp2.h @@ -1,6 +1,6 @@ #pragma once #ifdef USE_ARDUINO -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "ota_backend.h" #include "esphome/core/defines.h" @@ -8,7 +8,7 @@ namespace esphome::ota { -class ArduinoRP2040OTABackend final { +class ArduinoRP2OTABackend final { public: OTAResponseTypes begin(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP); void set_update_md5(const char *md5); @@ -21,8 +21,8 @@ class ArduinoRP2040OTABackend final { bool md5_set_{false}; }; -std::unique_ptr make_ota_backend(); +std::unique_ptr make_ota_backend(); } // namespace esphome::ota -#endif // USE_RP2040 +#endif // USE_RP2 #endif // USE_ARDUINO diff --git a/esphome/components/ota/ota_backend_factory.h b/esphome/components/ota/ota_backend_factory.h index 7c79f02702..c543983d8d 100644 --- a/esphome/components/ota/ota_backend_factory.h +++ b/esphome/components/ota/ota_backend_factory.h @@ -8,8 +8,8 @@ #include "ota_backend_esp8266.h" #elif defined(USE_ESP32) #include "ota_backend_esp_idf.h" -#elif defined(USE_RP2040) -#include "ota_backend_arduino_rp2040.h" +#elif defined(USE_RP2) +#include "ota_backend_arduino_rp2.h" #elif defined(USE_LIBRETINY) #include "ota_backend_arduino_libretiny.h" #elif defined(USE_HOST) diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index 53a0f8fb77..ad9c4b5a18 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -118,7 +118,7 @@ CONFIG_SCHEMA = remote_base.validate_triggers( bk72xx="1000b", ln882x="1000b", rtl87xx="1000b", - rp2040="1000b", + rp2="1000b", ): cv.validate_bytes, cv.Optional(CONF_FILTER, default="50us"): cv.All( cv.positive_time_period_microseconds, @@ -248,7 +248,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, - PlatformFramework.RP2040_ARDUINO, + PlatformFramework.RP2_ARDUINO, }, } ) diff --git a/esphome/components/remote_receiver/remote_receiver.cpp b/esphome/components/remote_receiver/remote_receiver.cpp index 222dae8f7f..36152d8854 100644 --- a/esphome/components/remote_receiver/remote_receiver.cpp +++ b/esphome/components/remote_receiver/remote_receiver.cpp @@ -3,7 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#if defined(USE_LIBRETINY) || defined(USE_ESP8266) || defined(USE_RP2040) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) +#if defined(USE_LIBRETINY) || defined(USE_ESP8266) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) namespace esphome::remote_receiver { diff --git a/esphome/components/remote_receiver/remote_receiver.h b/esphome/components/remote_receiver/remote_receiver.h index 2ed6a4c251..f9ec054fe3 100644 --- a/esphome/components/remote_receiver/remote_receiver.h +++ b/esphome/components/remote_receiver/remote_receiver.h @@ -14,7 +14,7 @@ namespace esphome::remote_receiver { -#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) struct RemoteReceiverComponentStore { static void gpio_intr(RemoteReceiverComponentStore *arg); @@ -93,11 +93,11 @@ class RemoteReceiverComponent final : public remote_base::RemoteReceiverBase, std::string error_string_; #endif -#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) || defined(USE_ESP32) +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_ESP32) RemoteReceiverComponentStore store_; #endif -#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) HighFrequencyLoopRequester high_freq_; #endif diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index 1163fc86eb..521c3daf87 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -185,7 +185,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, - PlatformFramework.RP2040_ARDUINO, + PlatformFramework.RP2_ARDUINO, }, } ) diff --git a/esphome/components/remote_transmitter/remote_transmitter.cpp b/esphome/components/remote_transmitter/remote_transmitter.cpp index 51a3c0b1d4..49c711330b 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter.cpp @@ -2,7 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -#if defined(USE_LIBRETINY) || defined(USE_ESP8266) || defined(USE_RP2040) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) +#if defined(USE_LIBRETINY) || defined(USE_ESP8266) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) namespace esphome::remote_transmitter { diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index bcb07038ea..e2d33d13cc 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -64,7 +64,7 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa protected: void send_internal(uint32_t send_times, uint32_t send_wait) override; -#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) void calculate_on_off_time_(uint32_t carrier_frequency, uint32_t *on_time_period, uint32_t *off_time_period); void mark_(uint32_t on_time, uint32_t off_time, uint32_t usec); diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2/__init__.py similarity index 92% rename from esphome/components/rp2040/__init__.py rename to esphome/components/rp2/__init__.py index e76ce6def8..21a885a7cf 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -21,7 +21,7 @@ from esphome.const import ( KEY_FRAMEWORK_VERSION, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, - PLATFORM_RP2040, + PLATFORM_RP2, ThreadModel, ) from esphome.core import ( @@ -40,27 +40,34 @@ from .const import ( KEY_BOARD, KEY_LWIP_OPTS, KEY_PIO_FILES, - KEY_RP2040, + KEY_RP2, KEY_VARIANT, MCU_TO_VARIANT, STANDARD_BOARDS, VARIANT_FRIENDLY, VARIANTS, - rp2040_ns, + rp2_ns, ) # force import gpio to register pin schema -from .gpio import rp2040_pin_to_code # noqa: F401 +from .gpio import rp2_pin_to_code # noqa: F401 _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@jesserockz"] AUTO_LOAD = ["preferences"] IS_TARGET_PLATFORM = True +# Legacy top-level YAML keys that route here. The framework +# (esphome/loader.py + esphome/config.py) handles both the deprecation +# warning and the key-rename pass; this declaration is the only place a +# component needs to opt in. See ComponentManifest.aliases for details. +ALIASES = ["rp2040"] +ALIAS_REMOVAL_VERSION = "2027.7.0" + def get_board() -> str: """Return the configured board name.""" - return CORE.data[KEY_RP2040][KEY_BOARD] + return CORE.data[KEY_RP2][KEY_BOARD] def board_has_wifi() -> bool: @@ -90,22 +97,22 @@ def board_id_has_wifi(board_id: str) -> bool: def set_core_data(config: ConfigType) -> ConfigType: - CORE.data[KEY_RP2040] = {} - CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_RP2040 + CORE.data[KEY_RP2] = {} + CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_RP2 CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = "arduino" CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version.parse( config[CONF_FRAMEWORK][CONF_VERSION] ) - CORE.data[KEY_RP2040][KEY_BOARD] = config[CONF_BOARD] - CORE.data[KEY_RP2040][KEY_VARIANT] = config[CONF_VARIANT] + CORE.data[KEY_RP2][KEY_BOARD] = config[CONF_BOARD] + CORE.data[KEY_RP2][KEY_VARIANT] = config[CONF_VARIANT] - CORE.data[KEY_RP2040][KEY_PIO_FILES] = {} + CORE.data[KEY_RP2][KEY_PIO_FILES] = {} return config def get_rp2040_variant(core_obj: EsphomeCore | None = None) -> str: - return (core_obj or CORE).data[KEY_RP2040][KEY_VARIANT] + return (core_obj or CORE).data[KEY_RP2][KEY_VARIANT] def only_on_variant( @@ -121,7 +128,7 @@ def only_on_variant( unsupported = [unsupported] def validator_(obj: Any) -> Any: - if not CORE.is_rp2040: + if not CORE.is_rp2: raise cv.Invalid(f"{msg_prefix} is only available on RP2040") variant = get_rp2040_variant() if supported is not None and variant not in supported: @@ -306,13 +313,18 @@ CONFIG_SCHEMA = cv.All( @coroutine_with_priority(CoroPriority.PLATFORM) async def to_code(config): - cg.add(rp2040_ns.setup_preferences()) + cg.add(rp2_ns.setup_preferences()) # Allow LDF to properly discover dependency including those in preprocessor # conditionals cg.add_platformio_option("lib_ldf_mode", "chain+") cg.add_platformio_option("lib_compat_mode", "strict") cg.add_platformio_option("board", config[CONF_BOARD]) + cg.add_build_flag("-DUSE_RP2") + # USE_RP2040 kept defined as a backwards-compat alias for external + # custom components that may still test for it. Internal code uses + # USE_RP2 (the canonical name for the RP2 chip family — covers + # RP2040, RP2350, and any future RP2-series chips). cg.add_build_flag("-DUSE_RP2040") cg.add_define("USE_NATIVE_64BIT_TIME") cg.set_cpp_standard("gnu++20") @@ -327,7 +339,8 @@ async def to_code(config): conf = config[CONF_FRAMEWORK] cg.add_platformio_option("framework", "arduino") cg.add_build_flag("-DUSE_ARDUINO") - cg.add_build_flag("-DUSE_RP2040_FRAMEWORK_ARDUINO") + cg.add_build_flag("-DUSE_RP2_FRAMEWORK_ARDUINO") + cg.add_build_flag("-DUSE_RP2040_FRAMEWORK_ARDUINO") # back-compat alias # cg.add_build_flag("-DPICO_BOARD=pico_w") cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION]) cg.add_platformio_option( @@ -359,8 +372,12 @@ async def to_code(config): cg.RawExpression(f"VERSION_CODE({ver.major}, {ver.minor}, {ver.patch})"), ) - cg.add_define("USE_RP2040_WATCHDOG_TIMEOUT", config[CONF_WATCHDOG_TIMEOUT]) - cg.add_define("USE_RP2040_CRASH_HANDLER") + cg.add_define("USE_RP2_WATCHDOG_TIMEOUT", config[CONF_WATCHDOG_TIMEOUT]) + cg.add_define( + "USE_RP2040_WATCHDOG_TIMEOUT", config[CONF_WATCHDOG_TIMEOUT] + ) # back-compat alias + cg.add_define("USE_RP2_CRASH_HANDLER") + cg.add_define("USE_RP2040_CRASH_HANDLER") # back-compat alias _configure_lwip() @@ -465,7 +482,7 @@ def _configure_lwip() -> None: } # Store for copy_files() to generate the header - CORE.data[KEY_RP2040][KEY_LWIP_OPTS] = lwip_defines + CORE.data[KEY_RP2][KEY_LWIP_OPTS] = lwip_defines # Add a pre-build extra script that injects our lwip_override directory # into CCFLAGS so our lwipopts.h shadows the framework's version. @@ -500,7 +517,7 @@ def _generate_lwipopts_h() -> None: """ from jinja2 import Environment - lwip_defines = CORE.data[KEY_RP2040].get(KEY_LWIP_OPTS) + lwip_defines = CORE.data[KEY_RP2].get(KEY_LWIP_OPTS) if not lwip_defines: return @@ -527,7 +544,7 @@ def add_pio_file(component: str, key: str, data: str): raise EsphomeError( f"[{component}] Invalid PIO key: {key}. Allowed characters: [{ascii_letters}{digits}_]\nPlease report an issue https://github.com/esphome/esphome/issues" ) from e - CORE.data[KEY_RP2040][KEY_PIO_FILES][key] = data + CORE.data[KEY_RP2][KEY_PIO_FILES][key] = data def generate_pio_files() -> bool: @@ -536,7 +553,7 @@ def generate_pio_files() -> bool: shutil.rmtree(CORE.relative_build_path("src/pio"), ignore_errors=True) includes: list[str] = [] - files = CORE.data[KEY_RP2040][KEY_PIO_FILES] + files = CORE.data[KEY_RP2][KEY_PIO_FILES] if not files: return False for key, data in files.items(): @@ -581,7 +598,7 @@ def copy_files(): # RP2040 crash handler stacktrace decoding -# Matches output from esphome/components/rp2040/crash_handler.cpp +# Matches output from esphome/components/rp2/crash_handler.cpp _CRASH_RE = re.compile(r"CRASH DETECTED ON PREVIOUS BOOT") _CRASH_ADDR_RE = re.compile( r"(?:PC|LR|BT\d):\s+(0x[0-9a-fA-F]{8})\s+\((?:fault location|return address|stack backtrace)\)" diff --git a/esphome/components/rp2040/boards.jinja2 b/esphome/components/rp2/boards.jinja2 similarity index 56% rename from esphome/components/rp2040/boards.jinja2 rename to esphome/components/rp2/boards.jinja2 index 989fb83701..9223009c26 100644 --- a/esphome/components/rp2040/boards.jinja2 +++ b/esphome/components/rp2/boards.jinja2 @@ -1,14 +1,14 @@ # Auto-generated by generate_boards.py — do not edit manually -# To regenerate: python esphome/components/rp2040/generate_boards.py +# To regenerate: python esphome/components/rp2/generate_boards.py # arduino-pico maps pins >= {{ cyw43_gpio_offset }} to CYW43 wireless chip GPIOs CYW43_GPIO_OFFSET = {{ cyw43_gpio_offset }} CYW43_MAX_GPIO = {{ cyw43_max_gpio }} DEFAULT_MAX_PIN = {{ default_max_pin }} -RP2040_BASE_PINS = {} +RP2_BASE_PINS = {} -RP2040_BOARD_PINS = { +RP2_BOARD_PINS = { {%- for name, pins in board_pins %} {{ name | repr }}: {{ pins | format_pins }}, {%- endfor %} @@ -23,3 +23,10 @@ BOARDS = { }, {%- endfor %} } + +# Deprecated: use RP2_BASE_PINS / RP2_BOARD_PINS instead. Kept as back-compat +# aliases so external custom components / tooling that imported the legacy +# names via the ``rp2040`` package alias keep working. +# Scheduled for removal in 2027.7.0. +RP2040_BASE_PINS = RP2_BASE_PINS +RP2040_BOARD_PINS = RP2_BOARD_PINS diff --git a/esphome/components/rp2040/boards.py b/esphome/components/rp2/boards.py similarity index 99% rename from esphome/components/rp2040/boards.py rename to esphome/components/rp2/boards.py index 0bc5c48d03..94d0ebbb60 100644 --- a/esphome/components/rp2040/boards.py +++ b/esphome/components/rp2/boards.py @@ -1,14 +1,14 @@ # Auto-generated by generate_boards.py — do not edit manually -# To regenerate: python esphome/components/rp2040/generate_boards.py +# To regenerate: python esphome/components/rp2/generate_boards.py # arduino-pico maps pins >= 64 to CYW43 wireless chip GPIOs CYW43_GPIO_OFFSET = 64 CYW43_MAX_GPIO = 66 DEFAULT_MAX_PIN = 29 -RP2040_BASE_PINS = {} +RP2_BASE_PINS = {} -RP2040_BOARD_PINS = { +RP2_BOARD_PINS = { "0xcb_helios": { "LED": 17, "MISO": 20, @@ -2299,3 +2299,10 @@ BOARDS = { "max_pin": 29, }, } + +# Deprecated: use RP2_BASE_PINS / RP2_BOARD_PINS instead. Kept as back-compat +# aliases so external custom components / tooling that imported the legacy +# names via the ``rp2040`` package alias keep working. +# Scheduled for removal in 2027.7.0. +RP2040_BASE_PINS = RP2_BASE_PINS +RP2040_BOARD_PINS = RP2_BOARD_PINS diff --git a/esphome/components/rp2040/build_pio.py.script b/esphome/components/rp2/build_pio.py.script similarity index 100% rename from esphome/components/rp2040/build_pio.py.script rename to esphome/components/rp2/build_pio.py.script diff --git a/esphome/components/rp2040/const.py b/esphome/components/rp2/const.py similarity index 91% rename from esphome/components/rp2040/const.py rename to esphome/components/rp2/const.py index 959753d95b..515f9f007c 100644 --- a/esphome/components/rp2040/const.py +++ b/esphome/components/rp2/const.py @@ -2,7 +2,7 @@ import esphome.codegen as cg KEY_BOARD = "board" KEY_LWIP_OPTS = "lwip_opts" -KEY_RP2040 = "rp2040" +KEY_RP2 = "rp2" KEY_PIO_FILES = "pio_files" KEY_VARIANT = "variant" @@ -31,4 +31,4 @@ STANDARD_BOARDS = { VARIANT_RP2350: "rpipico2w", } -rp2040_ns = cg.esphome_ns.namespace("rp2040") +rp2_ns = cg.esphome_ns.namespace("rp2") diff --git a/esphome/components/rp2/core.cpp b/esphome/components/rp2/core.cpp new file mode 100644 index 0000000000..2509f47a86 --- /dev/null +++ b/esphome/components/rp2/core.cpp @@ -0,0 +1,6 @@ +#ifdef USE_RP2 + +// HAL functions live in hal.cpp. core.cpp is intentionally empty for +// rp2 — there is no extra component bootstrap to keep here. + +#endif // USE_RP2 diff --git a/esphome/components/rp2040/core.h b/esphome/components/rp2/core.h similarity index 53% rename from esphome/components/rp2040/core.h rename to esphome/components/rp2/core.h index db8937a8a3..c53c3719eb 100644 --- a/esphome/components/rp2040/core.h +++ b/esphome/components/rp2/core.h @@ -1,12 +1,12 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include #include extern "C" unsigned long ulMainGetRunTimeCounterValue(); -namespace esphome::rp2040 {} // namespace esphome::rp2040 +namespace esphome::rp2 {} // namespace esphome::rp2 -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/crash_handler.cpp b/esphome/components/rp2/crash_handler.cpp similarity index 97% rename from esphome/components/rp2040/crash_handler.cpp rename to esphome/components/rp2/crash_handler.cpp index f9eb42a0f8..5553a24a60 100644 --- a/esphome/components/rp2040/crash_handler.cpp +++ b/esphome/components/rp2/crash_handler.cpp @@ -1,7 +1,7 @@ -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/defines.h" -#ifdef USE_RP2040_CRASH_HANDLER +#ifdef USE_RP2_CRASH_HANDLER #include "crash_handler.h" #include "esphome/core/log.h" @@ -51,9 +51,9 @@ static inline bool is_code_addr(uint32_t val) { static constexpr size_t MAX_BACKTRACE = 4; -namespace esphome::rp2040 { +namespace esphome::rp2 { -static const char *const TAG = "rp2040.crash"; +static const char *const TAG = "rp2.crash"; // Placed in .noinit so BSS zero-init cannot race with crash_handler_read_and_clear(). // The valid field is explicitly cleared in crash_handler_read_and_clear() instead. @@ -117,7 +117,7 @@ void crash_handler_log() { ESP_LOGE(TAG, "%s", hint); } -} // namespace esphome::rp2040 +} // namespace esphome::rp2 // --- HardFault handler --- // Overrides the weak isr_hardfault from arduino-pico's crt0.S. @@ -236,5 +236,5 @@ extern "C" void __attribute__((naked, used)) isr_hardfault() { : "i"(hard_fault_handler_c)); } -#endif // USE_RP2040_CRASH_HANDLER -#endif // USE_RP2040 +#endif // USE_RP2_CRASH_HANDLER +#endif // USE_RP2 diff --git a/esphome/components/rp2040/crash_handler.h b/esphome/components/rp2/crash_handler.h similarity index 66% rename from esphome/components/rp2040/crash_handler.h rename to esphome/components/rp2/crash_handler.h index 78e8ede08c..8c43d9fd3b 100644 --- a/esphome/components/rp2040/crash_handler.h +++ b/esphome/components/rp2/crash_handler.h @@ -1,12 +1,12 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/defines.h" -#ifdef USE_RP2040_CRASH_HANDLER +#ifdef USE_RP2_CRASH_HANDLER -namespace esphome::rp2040 { +namespace esphome::rp2 { /// Read crash data from watchdog scratch registers and clear them. void crash_handler_read_and_clear(); @@ -17,7 +17,7 @@ void crash_handler_log(); /// Returns true if crash data was found this boot. bool crash_handler_has_data(); -} // namespace esphome::rp2040 +} // namespace esphome::rp2 -#endif // USE_RP2040_CRASH_HANDLER -#endif // USE_RP2040 +#endif // USE_RP2_CRASH_HANDLER +#endif // USE_RP2 diff --git a/esphome/components/rp2040/generate_boards.py b/esphome/components/rp2/generate_boards.py similarity index 98% rename from esphome/components/rp2040/generate_boards.py rename to esphome/components/rp2/generate_boards.py index b1a0b17ca3..33eb1b3058 100644 --- a/esphome/components/rp2040/generate_boards.py +++ b/esphome/components/rp2/generate_boards.py @@ -1,6 +1,6 @@ """Generate boards.py from arduino-pico board definitions. -Usage: python esphome/components/rp2040/generate_boards.py +Usage: python esphome/components/rp2/generate_boards.py """ import json diff --git a/esphome/components/rp2040/gpio.cpp b/esphome/components/rp2/gpio.cpp similarity index 81% rename from esphome/components/rp2040/gpio.cpp rename to esphome/components/rp2/gpio.cpp index 4b3c98104c..0dbb124a26 100644 --- a/esphome/components/rp2040/gpio.cpp +++ b/esphome/components/rp2/gpio.cpp @@ -1,12 +1,12 @@ -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "gpio.h" #include "esphome/core/log.h" namespace esphome { -namespace rp2040 { +namespace rp2 { -static const char *const TAG = "rp2040"; +static const char *const TAG = "rp2"; static int flags_to_mode(gpio::Flags flags, uint8_t pin) { if (flags == gpio::FLAG_INPUT) { // NOLINT(bugprone-branch-clone) @@ -30,7 +30,7 @@ struct ISRPinArg { bool inverted; }; -ISRInternalGPIOPin RP2040GPIOPin::to_isr() const { +ISRInternalGPIOPin RP2GPIOPin::to_isr() const { auto *arg = new ISRPinArg{}; // NOLINT(cppcoreguidelines-owning-memory) arg->pin = this->pin_; arg->inverted = this->inverted_; @@ -38,7 +38,7 @@ ISRInternalGPIOPin RP2040GPIOPin::to_isr() const { return ISRInternalGPIOPin((void *) arg); } -void RP2040GPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const { +void RP2GPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const { PinStatus arduino_mode = LOW; switch (type) { case gpio::INTERRUPT_RISING_EDGE: @@ -60,25 +60,23 @@ void RP2040GPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::Inte attachInterrupt(pin_, func, arduino_mode, arg); } -void RP2040GPIOPin::pin_mode(gpio::Flags flags) { +void RP2GPIOPin::pin_mode(gpio::Flags flags) { pinMode(pin_, flags_to_mode(flags, pin_)); // NOLINT } -size_t RP2040GPIOPin::dump_summary(char *buffer, size_t len) const { - return snprintf(buffer, len, "GPIO%u", this->pin_); -} +size_t RP2GPIOPin::dump_summary(char *buffer, size_t len) const { return snprintf(buffer, len, "GPIO%u", this->pin_); } -bool RP2040GPIOPin::digital_read() { +bool RP2GPIOPin::digital_read() { return bool(digitalRead(pin_)) != inverted_; // NOLINT } -void RP2040GPIOPin::digital_write(bool value) { +void RP2GPIOPin::digital_write(bool value) { digitalWrite(pin_, value != inverted_ ? 1 : 0); // NOLINT } -void RP2040GPIOPin::detach_interrupt() const { detachInterrupt(pin_); } +void RP2GPIOPin::detach_interrupt() const { detachInterrupt(pin_); } -} // namespace rp2040 +} // namespace rp2 -using namespace rp2040; +using namespace rp2; bool IRAM_ATTR ISRInternalGPIOPin::digital_read() { auto *arg = reinterpret_cast(this->arg_); @@ -115,4 +113,4 @@ void IRAM_ATTR ISRInternalGPIOPin::pin_mode(gpio::Flags flags) { } // namespace esphome -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/gpio.h b/esphome/components/rp2/gpio.h similarity index 85% rename from esphome/components/rp2040/gpio.h rename to esphome/components/rp2/gpio.h index b9aa497b47..538fef619a 100644 --- a/esphome/components/rp2040/gpio.h +++ b/esphome/components/rp2/gpio.h @@ -1,13 +1,13 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include #include "esphome/core/hal.h" -namespace esphome::rp2040 { +namespace esphome::rp2 { -class RP2040GPIOPin final : public InternalGPIOPin { +class RP2GPIOPin final : public InternalGPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } @@ -32,6 +32,6 @@ class RP2040GPIOPin final : public InternalGPIOPin { gpio::Flags flags_{}; }; -} // namespace esphome::rp2040 +} // namespace esphome::rp2 -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/gpio.py b/esphome/components/rp2/gpio.py similarity index 82% rename from esphome/components/rp2040/gpio.py rename to esphome/components/rp2/gpio.py index 18fb09f76a..e4db6a831c 100644 --- a/esphome/components/rp2040/gpio.py +++ b/esphome/components/rp2/gpio.py @@ -16,22 +16,22 @@ from esphome.const import ( from esphome.core import CORE from . import boards -from .const import KEY_BOARD, KEY_RP2040, rp2040_ns +from .const import KEY_BOARD, KEY_RP2, rp2_ns -RP2040GPIOPin = rp2040_ns.class_("RP2040GPIOPin", cg.InternalGPIOPin) +RP2GPIOPin = rp2_ns.class_("RP2GPIOPin", cg.InternalGPIOPin) def _lookup_pin(value): - board = CORE.data[KEY_RP2040][KEY_BOARD] - board_pins = boards.RP2040_BOARD_PINS.get(board, {}) + board = CORE.data[KEY_RP2][KEY_BOARD] + board_pins = boards.RP2_BOARD_PINS.get(board, {}) while isinstance(board_pins, str): - board_pins = boards.RP2040_BOARD_PINS[board_pins] + board_pins = boards.RP2_BOARD_PINS[board_pins] if value in board_pins: return board_pins[value] - if value in boards.RP2040_BASE_PINS: - return boards.RP2040_BASE_PINS[value] + if value in boards.RP2_BASE_PINS: + return boards.RP2_BASE_PINS[value] raise cv.Invalid(f"Cannot resolve pin name '{value}' for board {board}.") @@ -61,7 +61,7 @@ def _board_max_virtual_pin(board): def validate_gpio_pin(value): value = _translate_pin(value) - board = CORE.data[KEY_RP2040][KEY_BOARD] + board = CORE.data[KEY_RP2][KEY_BOARD] max_virtual = _board_max_virtual_pin(board) if max_virtual is not None and boards.CYW43_GPIO_OFFSET <= value <= max_virtual: return value @@ -72,7 +72,7 @@ def validate_gpio_pin(value): def validate_supports(value): - board = CORE.data[KEY_RP2040][KEY_BOARD] + board = CORE.data[KEY_RP2][KEY_BOARD] if ( _board_max_virtual_pin(board) is None or value[CONF_NUMBER] < boards.CYW43_GPIO_OFFSET @@ -89,9 +89,9 @@ def validate_supports(value): return value -RP2040_PIN_SCHEMA = cv.All( +RP2_PIN_SCHEMA = cv.All( pins.gpio_base_schema( - RP2040GPIOPin, + RP2GPIOPin, validate_gpio_pin, modes=pins.GPIO_STANDARD_MODES + (CONF_ANALOG,), ), @@ -99,8 +99,8 @@ RP2040_PIN_SCHEMA = cv.All( ) -@pins.PIN_SCHEMA_REGISTRY.register("rp2040", RP2040_PIN_SCHEMA) -async def rp2040_pin_to_code(config): +@pins.PIN_SCHEMA_REGISTRY.register("rp2", RP2_PIN_SCHEMA) +async def rp2_pin_to_code(config): var = cg.new_Pvariable(config[CONF_ID]) num = config[CONF_NUMBER] cg.add(var.set_pin(num)) diff --git a/esphome/components/rp2040/hal.cpp b/esphome/components/rp2/hal.cpp similarity index 58% rename from esphome/components/rp2040/hal.cpp rename to esphome/components/rp2/hal.cpp index e71d3fd54d..28535cacbb 100644 --- a/esphome/components/rp2040/hal.cpp +++ b/esphome/components/rp2/hal.cpp @@ -1,23 +1,23 @@ -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "core.h" #include "esphome/core/defines.h" #include "esphome/core/hal.h" -#ifdef USE_RP2040_CRASH_HANDLER +#ifdef USE_RP2_CRASH_HANDLER #include "crash_handler.h" #endif #include "hardware/watchdog.h" -// Empty rp2040 namespace block to satisfy ci-custom's lint_namespace check. +// Empty rp2 namespace block to satisfy ci-custom's lint_namespace check. // HAL functions live in namespace esphome (root) — they are not part of the -// rp2040 component's API. -namespace esphome::rp2040 {} // namespace esphome::rp2040 +// rp2 component's API. +namespace esphome::rp2 {} // namespace esphome::rp2 namespace esphome { // yield(), delay(), micros(), millis(), millis_64(), delayMicroseconds(), -// arch_feed_wdt(), arch_get_cpu_cycle_count() inlined in components/rp2040/hal.h. +// arch_feed_wdt(), arch_get_cpu_cycle_count() inlined in components/rp2/hal.h. void arch_restart() { watchdog_reboot(0, 0, 10); while (1) { @@ -26,11 +26,11 @@ void arch_restart() { } void arch_init() { -#ifdef USE_RP2040_CRASH_HANDLER - rp2040::crash_handler_read_and_clear(); +#ifdef USE_RP2_CRASH_HANDLER + rp2::crash_handler_read_and_clear(); #endif -#if USE_RP2040_WATCHDOG_TIMEOUT > 0 - watchdog_enable(USE_RP2040_WATCHDOG_TIMEOUT, false); +#if USE_RP2_WATCHDOG_TIMEOUT > 0 + watchdog_enable(USE_RP2_WATCHDOG_TIMEOUT, false); #endif } @@ -38,4 +38,4 @@ uint32_t arch_get_cpu_freq_hz() { return RP2040::f_cpu(); } } // namespace esphome -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/hal.h b/esphome/components/rp2/hal.h similarity index 96% rename from esphome/components/rp2040/hal.h rename to esphome/components/rp2/hal.h index c9c61c921d..b16f31d797 100644 --- a/esphome/components/rp2040/hal.h +++ b/esphome/components/rp2/hal.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include @@ -25,7 +25,7 @@ extern "C" uint64_t time_us_64(void); extern "C" void watchdog_update(void); extern "C" unsigned long ulMainGetRunTimeCounterValue(void); -namespace esphome::rp2040 {} +namespace esphome::rp2 {} namespace esphome { @@ -58,4 +58,4 @@ uint32_t arch_get_cpu_freq_hz(); } // namespace esphome -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/helpers.cpp b/esphome/components/rp2/helpers.cpp similarity index 98% rename from esphome/components/rp2040/helpers.cpp rename to esphome/components/rp2/helpers.cpp index 6e5ddad236..a54bcf80f7 100644 --- a/esphome/components/rp2040/helpers.cpp +++ b/esphome/components/rp2/helpers.cpp @@ -1,7 +1,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/defines.h" -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/hal.h" @@ -89,4 +89,4 @@ void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parame } // namespace esphome -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/inject_lwip_include.py.script b/esphome/components/rp2/inject_lwip_include.py.script similarity index 100% rename from esphome/components/rp2040/inject_lwip_include.py.script rename to esphome/components/rp2/inject_lwip_include.py.script diff --git a/esphome/components/rp2040/lwipopts.h.jinja b/esphome/components/rp2/lwipopts.h.jinja similarity index 100% rename from esphome/components/rp2040/lwipopts.h.jinja rename to esphome/components/rp2/lwipopts.h.jinja diff --git a/esphome/components/rp2040/post_build.py.script b/esphome/components/rp2/post_build.py.script similarity index 100% rename from esphome/components/rp2040/post_build.py.script rename to esphome/components/rp2/post_build.py.script diff --git a/esphome/components/rp2/preference_backend.h b/esphome/components/rp2/preference_backend.h new file mode 100644 index 0000000000..c5e8a757da --- /dev/null +++ b/esphome/components/rp2/preference_backend.h @@ -0,0 +1,27 @@ +#pragma once +#ifdef USE_RP2 + +#include +#include + +namespace esphome::rp2 { + +class RP2PreferenceBackend final { + public: + bool save(const uint8_t *data, size_t len); + bool load(uint8_t *data, size_t len); + + size_t offset = 0; + uint32_t type = 0; +}; + +class RP2Preferences; +RP2Preferences *get_preferences(); + +} // namespace esphome::rp2 + +namespace esphome { +using PreferenceBackend = rp2::RP2PreferenceBackend; +} // namespace esphome + +#endif // USE_RP2 diff --git a/esphome/components/rp2040/preferences.cpp b/esphome/components/rp2/preferences.cpp similarity index 82% rename from esphome/components/rp2040/preferences.cpp rename to esphome/components/rp2/preferences.cpp index cfc802b28f..778ce070a9 100644 --- a/esphome/components/rp2040/preferences.cpp +++ b/esphome/components/rp2/preferences.cpp @@ -1,4 +1,4 @@ -#ifdef USE_RP2040 +#ifdef USE_RP2 #include @@ -12,7 +12,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome::rp2040 { +namespace esphome::rp2 { static const char *const TAG = "preferences"; @@ -37,7 +37,7 @@ template uint8_t calculate_crc(It first, It last, uint32_t type) { return crc; } -bool RP2040PreferenceBackend::save(const uint8_t *data, size_t len) { +bool RP2PreferenceBackend::save(const uint8_t *data, size_t len) { const size_t buffer_size = len + 1; if (buffer_size > PREF_MAX_BUFFER_SIZE) return false; @@ -58,7 +58,7 @@ bool RP2040PreferenceBackend::save(const uint8_t *data, size_t len) { return true; } -bool RP2040PreferenceBackend::load(uint8_t *data, size_t len) { +bool RP2PreferenceBackend::load(uint8_t *data, size_t len) { const size_t buffer_size = len + 1; if (buffer_size > PREF_MAX_BUFFER_SIZE) return false; @@ -80,27 +80,27 @@ bool RP2040PreferenceBackend::load(uint8_t *data, size_t len) { return true; } -RP2040Preferences::RP2040Preferences() : eeprom_sector_(&_EEPROM_start) {} +RP2Preferences::RP2Preferences() : eeprom_sector_(&_EEPROM_start) {} -void RP2040Preferences::setup() { +void RP2Preferences::setup() { ESP_LOGVV(TAG, "Loading preferences from flash"); memcpy(s_flash_storage, this->eeprom_sector_, RP2040_FLASH_STORAGE_SIZE); } -ESPPreferenceObject RP2040Preferences::make_preference(size_t length, uint32_t type) { +ESPPreferenceObject RP2Preferences::make_preference(size_t length, uint32_t type) { uint32_t start = this->current_flash_offset; uint32_t end = start + length + 1; if (end > RP2040_FLASH_STORAGE_SIZE) { return {}; } - auto *pref = new RP2040PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) + auto *pref = new RP2PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) pref->offset = start; pref->type = type; this->current_flash_offset = end; return ESPPreferenceObject(pref); } -bool RP2040Preferences::sync() { +bool RP2Preferences::sync() { if (!s_flash_dirty) return true; if (s_prevent_write) @@ -121,7 +121,7 @@ bool RP2040Preferences::sync() { return true; } -bool RP2040Preferences::reset() { +bool RP2Preferences::reset() { ESP_LOGD(TAG, "Erasing storage"); { InterruptLock lock; @@ -133,9 +133,9 @@ bool RP2040Preferences::reset() { return true; } -static RP2040Preferences s_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static RP2Preferences s_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -RP2040Preferences *get_preferences() { return &s_preferences; } +RP2Preferences *get_preferences() { return &s_preferences; } void setup_preferences() { s_preferences.setup(); @@ -143,10 +143,10 @@ void setup_preferences() { } void preferences_prevent_write(bool prevent) { s_prevent_write = prevent; } -} // namespace esphome::rp2040 +} // namespace esphome::rp2 namespace esphome { ESPPreferences *global_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esphome -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/preferences.h b/esphome/components/rp2/preferences.h similarity index 59% rename from esphome/components/rp2040/preferences.h rename to esphome/components/rp2/preferences.h index eb8c3e5f64..95f7263883 100644 --- a/esphome/components/rp2040/preferences.h +++ b/esphome/components/rp2/preferences.h @@ -1,14 +1,14 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/preference_backend.h" -namespace esphome::rp2040 { +namespace esphome::rp2 { -class RP2040Preferences final : public PreferencesMixin { +class RP2Preferences final : public PreferencesMixin { public: - using PreferencesMixin::make_preference; - RP2040Preferences(); + using PreferencesMixin::make_preference; + RP2Preferences(); void setup(); ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash) { return this->make_preference(length, type); @@ -26,8 +26,8 @@ class RP2040Preferences final : public PreferencesMixin { void setup_preferences(); void preferences_prevent_write(bool prevent); -} // namespace esphome::rp2040 +} // namespace esphome::rp2 -DECLARE_PREFERENCE_ALIASES(esphome::rp2040::RP2040Preferences) +DECLARE_PREFERENCE_ALIASES(esphome::rp2::RP2Preferences) -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/printf_stubs.cpp b/esphome/components/rp2/printf_stubs.cpp similarity index 94% rename from esphome/components/rp2040/printf_stubs.cpp rename to esphome/components/rp2/printf_stubs.cpp index c2174a1dec..bf03565f30 100644 --- a/esphome/components/rp2040/printf_stubs.cpp +++ b/esphome/components/rp2/printf_stubs.cpp @@ -13,12 +13,12 @@ * Saves ~8.9 KB of flash. */ -#if defined(USE_RP2040) && !defined(USE_FULL_PRINTF) +#if defined(USE_RP2) && !defined(USE_FULL_PRINTF) #include #include #include -namespace esphome::rp2040 {} +namespace esphome::rp2 {} static constexpr size_t PRINTF_BUFFER_SIZE = 512; @@ -71,4 +71,4 @@ int __wrap_fprintf(FILE *stream, const char *fmt, ...) { } // extern "C" // NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) -#endif // USE_RP2040 && !USE_FULL_PRINTF +#endif // USE_RP2 && !USE_FULL_PRINTF diff --git a/esphome/components/rp2040/core.cpp b/esphome/components/rp2040/core.cpp deleted file mode 100644 index 11f23ccfef..0000000000 --- a/esphome/components/rp2040/core.cpp +++ /dev/null @@ -1,6 +0,0 @@ -#ifdef USE_RP2040 - -// HAL functions live in hal.cpp. core.cpp is intentionally empty for -// rp2040 — there is no extra component bootstrap to keep here. - -#endif // USE_RP2040 diff --git a/esphome/components/rp2040/preference_backend.h b/esphome/components/rp2040/preference_backend.h deleted file mode 100644 index 790ee8831d..0000000000 --- a/esphome/components/rp2040/preference_backend.h +++ /dev/null @@ -1,27 +0,0 @@ -#pragma once -#ifdef USE_RP2040 - -#include -#include - -namespace esphome::rp2040 { - -class RP2040PreferenceBackend final { - public: - bool save(const uint8_t *data, size_t len); - bool load(uint8_t *data, size_t len); - - size_t offset = 0; - uint32_t type = 0; -}; - -class RP2040Preferences; -RP2040Preferences *get_preferences(); - -} // namespace esphome::rp2040 - -namespace esphome { -using PreferenceBackend = rp2040::RP2040PreferenceBackend; -} // namespace esphome - -#endif // USE_RP2040 diff --git a/esphome/components/rp2040_ble/__init__.py b/esphome/components/rp2040_ble/__init__.py index 648f22691c..ac012b5e85 100644 --- a/esphome/components/rp2040_ble/__init__.py +++ b/esphome/components/rp2040_ble/__init__.py @@ -3,7 +3,7 @@ import esphome.config_validation as cv from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID from esphome.types import ConfigType -DEPENDENCIES = ["rp2040"] +DEPENDENCIES = ["rp2"] CODEOWNERS = ["@bdraco"] rp2040_ble_ns = cg.esphome_ns.namespace("rp2040_ble") diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.cpp b/esphome/components/rp2040_pio_led_strip/led_strip.cpp index 8afba6ba1d..b9c0a9c257 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.cpp +++ b/esphome/components/rp2040_pio_led_strip/led_strip.cpp @@ -1,6 +1,6 @@ #include "led_strip.h" -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/helpers.h" #include "esphome/core/log.h" diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.h b/esphome/components/rp2040_pio_led_strip/led_strip.h index aaa5b0842d..b74dd14108 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.h +++ b/esphome/components/rp2040_pio_led_strip/led_strip.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/color.h" #include "esphome/core/component.h" @@ -128,4 +128,4 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { } // namespace esphome::rp2040_pio_led_strip -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040_pio_led_strip/light.py b/esphome/components/rp2040_pio_led_strip/light.py index 274f059bd5..b3f816102a 100644 --- a/esphome/components/rp2040_pio_led_strip/light.py +++ b/esphome/components/rp2040_pio_led_strip/light.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from esphome import pins import esphome.codegen as cg -from esphome.components import light, rp2040 +from esphome.components import light, rp2 import esphome.config_validation as cv from esphome.const import ( CONF_CHIPSET, @@ -130,9 +130,9 @@ def time_to_cycles(time_us): CONF_PIO = "pio" -AUTO_LOAD = ["rp2040_pio"] +AUTO_LOAD = ["rp2_pio"] CODEOWNERS = ["@Papa-DMan"] -DEPENDENCIES = ["rp2040"] +DEPENDENCIES = ["rp2"] rp2040_pio_led_strip_ns = cg.esphome_ns.namespace("rp2040_pio_led_strip") RP2040PIOLEDStripLightOutput = rp2040_pio_led_strip_ns.class_( @@ -250,7 +250,7 @@ async def to_code(config): if chipset := config.get(CONF_CHIPSET): cg.add(var.set_chipset(chipset)) _LOGGER.info("Generating PIO assembly code") - rp2040.add_pio_file( + rp2.add_pio_file( __name__, key, generate_assembly_code( @@ -265,7 +265,7 @@ async def to_code(config): else: cg.add(var.set_chipset(Chipset.CHIPSET_CUSTOM)) _LOGGER.info("Generating custom PIO assembly code") - rp2040.add_pio_file( + rp2.add_pio_file( __name__, key, generate_assembly_code( diff --git a/esphome/components/rp2040_pwm/output.py b/esphome/components/rp2040_pwm/output.py index ad37926954..a2fda58c9e 100644 --- a/esphome/components/rp2040_pwm/output.py +++ b/esphome/components/rp2040_pwm/output.py @@ -5,7 +5,7 @@ import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID, CONF_PIN CODEOWNERS = ["@jesserockz"] -DEPENDENCIES = ["rp2040"] +DEPENDENCIES = ["rp2"] rp2040_pwm_ns = cg.esphome_ns.namespace("rp2040_pwm") diff --git a/esphome/components/rp2040_pwm/rp2040_pwm.cpp b/esphome/components/rp2040_pwm/rp2040_pwm.cpp index c9b9e6739d..270cc33551 100644 --- a/esphome/components/rp2040_pwm/rp2040_pwm.cpp +++ b/esphome/components/rp2040_pwm/rp2040_pwm.cpp @@ -1,4 +1,4 @@ -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "rp2040_pwm.h" #include "esphome/core/defines.h" diff --git a/esphome/components/rp2040_pwm/rp2040_pwm.h b/esphome/components/rp2040_pwm/rp2040_pwm.h index 49980a7d76..8263113168 100644 --- a/esphome/components/rp2040_pwm/rp2040_pwm.h +++ b/esphome/components/rp2040_pwm/rp2040_pwm.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/components/output/float_output.h" #include "esphome/core/automation.h" @@ -54,4 +54,4 @@ template class SetFrequencyAction final : public Action { } // namespace esphome::rp2040_pwm -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040_pio/__init__.py b/esphome/components/rp2_pio/__init__.py similarity index 98% rename from esphome/components/rp2040_pio/__init__.py rename to esphome/components/rp2_pio/__init__.py index eecfedaa75..9046d2ae6b 100644 --- a/esphome/components/rp2040_pio/__init__.py +++ b/esphome/components/rp2_pio/__init__.py @@ -3,7 +3,7 @@ import platform import esphome.codegen as cg import esphome.config_validation as cv -DEPENDENCIES = ["rp2040"] +DEPENDENCIES = ["rp2"] PIOASM_REPO_VERSION = "1.5.0-b" diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index 079665c959..136d0f1d58 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -1,7 +1,7 @@ #include "sha256.h" // Only compile SHA256 implementation on platforms that support it -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_HOST) #include "esphome/core/helpers.h" #include @@ -76,7 +76,7 @@ void SHA256::add(const uint8_t *data, size_t len) { mbedtls_sha256_update(&this- void SHA256::calculate() { mbedtls_sha256_finish(&this->ctx_, this->digest_); } -#elif defined(USE_ESP8266) || defined(USE_RP2040) +#elif defined(USE_ESP8266) || defined(USE_RP2) SHA256::~SHA256() = default; diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index d10d418c7a..26afe9e33e 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -3,7 +3,7 @@ #include "esphome/core/defines.h" // Only define SHA256 on platforms that support it -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_HOST) #include #include @@ -25,7 +25,7 @@ #elif defined(USE_LIBRETINY) #define USE_SHA256_MBEDTLS #include "mbedtls/sha256.h" -#elif defined(USE_ESP8266) || defined(USE_RP2040) +#elif defined(USE_ESP8266) || defined(USE_RP2) #include #elif defined(USE_HOST) #include @@ -70,7 +70,7 @@ class SHA256 final : public esphome::HashBase { // The mbedtls context for ESP32-S3 hardware SHA requires proper alignment and stack frame constraints. // See class documentation above for critical requirements. mbedtls_sha256_context ctx_{}; -#elif defined(USE_ESP8266) || defined(USE_RP2040) +#elif defined(USE_ESP8266) || defined(USE_RP2) br_sha256_context ctx_{}; bool calculated_{false}; #elif defined(USE_HOST) diff --git a/esphome/components/sntp/time.py b/esphome/components/sntp/time.py index 69a2436d3d..7d592f8ef8 100644 --- a/esphome/components/sntp/time.py +++ b/esphome/components/sntp/time.py @@ -13,7 +13,7 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_LN882X, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, ) from esphome.core import CORE @@ -98,7 +98,7 @@ CONFIG_SCHEMA = cv.All( [ PLATFORM_ESP32, PLATFORM_ESP8266, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_BK72XX, PLATFORM_LN882X, PLATFORM_RTL87XX, diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index 38d787c20a..cd002d9eb0 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -144,7 +144,7 @@ CONFIG_SCHEMA = cv.Schema( CONF_IMPLEMENTATION, esp8266=IMPLEMENTATION_LWIP_TCP, esp32=IMPLEMENTATION_BSD_SOCKETS, - rp2040=IMPLEMENTATION_LWIP_TCP, + rp2=IMPLEMENTATION_LWIP_TCP, bk72xx=IMPLEMENTATION_LWIP_SOCKETS, ln882x=IMPLEMENTATION_LWIP_SOCKETS, rtl87xx=IMPLEMENTATION_LWIP_SOCKETS, diff --git a/esphome/components/socket/headers.h b/esphome/components/socket/headers.h index f9b652f14a..528d201799 100644 --- a/esphome/components/socket/headers.h +++ b/esphome/components/socket/headers.h @@ -104,7 +104,7 @@ struct iovec { size_t iov_len; }; -#if defined(USE_ESP8266) || defined(USE_RP2040) +#if defined(USE_ESP8266) || defined(USE_RP2) // arduino-esp8266 declares a global vars called INADDR_NONE/ANY which are invalid with the define #ifdef INADDR_ANY #undef INADDR_ANY diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index c6692b0165..4fcec553fa 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -17,7 +17,7 @@ extern "C" void esphome_wake_ota_component_any_context(); #ifdef USE_ESP8266 #include // For esp_schedule() -#elif defined(USE_RP2040) +#elif defined(USE_RP2) #include // For __sev(), __wfe() #include // For add_alarm_in_ms(), cancel_alarm() #endif diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index d1961cec59..608adc7514 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -35,7 +35,7 @@ from esphome.const import ( KEY_VARIANT, PLATFORM_ESP32, PLATFORM_ESP8266, - PLATFORM_RP2040, + PLATFORM_RP2, PlatformFramework, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority @@ -54,7 +54,7 @@ SPIMode = spi_ns.enum("SPIMode") PLATFORM_SPI_CLOCKS = { PLATFORM_ESP8266: 40e6, PLATFORM_ESP32: 80e6, - PLATFORM_RP2040: 62.5e6, + PLATFORM_RP2: 62.5e6, } MAX_DATA_RATE_ERROR = 0.05 # Max allowable actual data rate difference from requested @@ -179,7 +179,7 @@ def get_hw_interface_list(): ]: return [["spi", "spi2"]] return [["spi", "spi2"], ["spi3"]] - if target_platform == PLATFORM_RP2040: + if target_platform == PLATFORM_RP2: return [["spi"], ["spi1"]] return [] @@ -247,7 +247,7 @@ def validate_hw_pins(spi, index=-1): if target_platform == PLATFORM_ESP32: return clk_pin_no >= 0 - if target_platform == PLATFORM_RP2040: + if target_platform == PLATFORM_RP2: if index == -1: matches = list( filter(lambda s: clk_pin_no in s[CONF_CLK_PIN], RP_SPI_PINSETS) @@ -323,7 +323,7 @@ def get_spi_interface(index): # ESP32 uses ESP-IDF SPI driver for both Arduino and IDF frameworks return ["SPI2_HOST", "SPI3_HOST"][index] # Arduino code follows - if platform == PLATFORM_RP2040: + if platform == PLATFORM_RP2: return ["&SPI", "&SPI1"][index] if index == 0: return "&SPI" @@ -349,7 +349,7 @@ SPI_SINGLE_SCHEMA = cv.All( } ), cv.has_at_least_one_key(CONF_MISO_PIN, CONF_MOSI_PIN), - cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040]), + cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2]), ) @@ -500,7 +500,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( { "spi_arduino.cpp": { PlatformFramework.ESP8266_ARDUINO, - PlatformFramework.RP2040_ARDUINO, + PlatformFramework.RP2_ARDUINO, PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, diff --git a/esphome/components/spi/spi.h b/esphome/components/spi/spi.h index cada29b0d7..c038426f61 100644 --- a/esphome/components/spi/spi.h +++ b/esphome/components/spi/spi.h @@ -17,7 +17,7 @@ using SPIInterface = spi_host_device_t; #include -#ifdef USE_RP2040 +#ifdef USE_RP2 using SPIInterface = SPIClassRP2040 *; #else using SPIInterface = SPIClass *; diff --git a/esphome/components/spi/spi_arduino.cpp b/esphome/components/spi/spi_arduino.cpp index 4267fe63ce..a3e09d2800 100644 --- a/esphome/components/spi/spi_arduino.cpp +++ b/esphome/components/spi/spi_arduino.cpp @@ -11,7 +11,7 @@ class SPIDelegateHw : public SPIDelegate { : SPIDelegate(data_rate, bit_order, mode, cs_pin), channel_(channel) {} void begin_transaction() override { -#ifdef USE_RP2040 +#ifdef USE_RP2 SPISettings const settings(this->data_rate_, static_cast(this->bit_order_), this->mode_); #elif defined(ESP8266) // Arduino ESP8266 library has mangled values for SPI modes :-( @@ -41,7 +41,7 @@ class SPIDelegateHw : public SPIDelegate { this->channel_->transfer(*ptr); return; } -#ifdef USE_RP2040 +#ifdef USE_RP2 this->channel_->transfer(ptr, nullptr, length); #elif defined(USE_ESP8266) // ESP8266 SPI library requires the pointer to be word aligned, but the data may not be @@ -75,7 +75,7 @@ class SPIBusHw : public SPIBus { #ifdef USE_ESP32 channel->begin(Utility::get_pin_no(clk), Utility::get_pin_no(sdi), Utility::get_pin_no(sdo), -1); #endif -#ifdef USE_RP2040 +#ifdef USE_RP2 if (Utility::get_pin_no(sdi) != -1) channel->setRX(Utility::get_pin_no(sdi)); if (Utility::get_pin_no(sdo) != -1) diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 4e623942ac..6a52348ae9 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -10,7 +10,7 @@ #ifdef USE_ESP8266 #include "sys/time.h" #endif -#if defined(USE_RP2040) || defined(USE_ZEPHYR) +#if defined(USE_RP2) || defined(USE_ZEPHYR) #include #endif #include diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 4ea32e26a3..7e3701bb07 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -49,7 +49,7 @@ IDFUARTComponent = uart_ns.class_("IDFUARTComponent", UARTComponent, cg.Componen ESP8266UartComponent = uart_ns.class_( "ESP8266UartComponent", UARTComponent, cg.Component ) -RP2040UartComponent = uart_ns.class_("RP2040UartComponent", UARTComponent, cg.Component) +RP2UartComponent = uart_ns.class_("RP2UartComponent", UARTComponent, cg.Component) LibreTinyUARTComponent = uart_ns.class_( "LibreTinyUARTComponent", UARTComponent, cg.Component ) @@ -59,7 +59,7 @@ HostUartComponent = uart_ns.class_("HostUartComponent", UARTComponent, cg.Compon NATIVE_UART_CLASSES = ( str(IDFUARTComponent), str(ESP8266UartComponent), - str(RP2040UartComponent), + str(RP2UartComponent), str(LibreTinyUARTComponent), ) @@ -157,8 +157,8 @@ def _uart_declare_type(value): return cv.declare_id(ESP8266UartComponent)(value) if CORE.is_esp32: return cv.declare_id(IDFUARTComponent)(value) - if CORE.is_rp2040: - return cv.declare_id(RP2040UartComponent)(value) + if CORE.is_rp2: + return cv.declare_id(RP2UartComponent)(value) if CORE.is_libretiny: return cv.declare_id(LibreTinyUARTComponent)(value) if CORE.is_host: @@ -529,7 +529,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( }, "uart_component_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, "uart_component_host.cpp": {PlatformFramework.HOST_NATIVE}, - "uart_component_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "uart_component_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "uart_component_libretiny.cpp": { PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, diff --git a/esphome/components/uart/uart_component_rp2040.cpp b/esphome/components/uart/uart_component_rp2.cpp similarity index 91% rename from esphome/components/uart/uart_component_rp2040.cpp rename to esphome/components/uart/uart_component_rp2.cpp index 1aaf98dc84..9cc3009a22 100644 --- a/esphome/components/uart/uart_component_rp2040.cpp +++ b/esphome/components/uart/uart_component_rp2.cpp @@ -1,5 +1,5 @@ -#ifdef USE_RP2040 -#include "uart_component_rp2040.h" +#ifdef USE_RP2 +#include "uart_component_rp2.h" #include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" @@ -13,9 +13,9 @@ namespace esphome::uart { -static const char *const TAG = "uart.arduino_rp2040"; +static const char *const TAG = "uart.arduino_rp2"; -uint16_t RP2040UartComponent::get_config() { +uint16_t RP2UartComponent::get_config() { uint16_t config = 0; if (this->parity_ == UART_CONFIG_PARITY_NONE) { @@ -50,7 +50,7 @@ uint16_t RP2040UartComponent::get_config() { return config; } -void RP2040UartComponent::setup() { +void RP2UartComponent::setup() { auto setup_pin_if_needed = [](InternalGPIOPin *pin) { if (!pin) { return; @@ -162,7 +162,7 @@ void RP2040UartComponent::setup() { } } -void RP2040UartComponent::dump_config() { +void RP2UartComponent::dump_config() { ESP_LOGCONFIG(TAG, "UART Bus:"); LOG_PIN(" TX Pin: ", tx_pin_); LOG_PIN(" RX Pin: ", rx_pin_); @@ -182,7 +182,7 @@ void RP2040UartComponent::dump_config() { } } -void RP2040UartComponent::write_array(const uint8_t *data, size_t len) { +void RP2UartComponent::write_array(const uint8_t *data, size_t len) { this->serial_->write(data, len); #ifdef USE_UART_DEBUGGER for (size_t i = 0; i < len; i++) { @@ -190,13 +190,13 @@ void RP2040UartComponent::write_array(const uint8_t *data, size_t len) { } #endif } -bool RP2040UartComponent::peek_byte(uint8_t *data) { +bool RP2UartComponent::peek_byte(uint8_t *data) { if (!this->check_read_timeout_()) return false; *data = this->serial_->peek(); return true; } -bool RP2040UartComponent::read_array(uint8_t *data, size_t len) { +bool RP2UartComponent::read_array(uint8_t *data, size_t len) { if (!this->check_read_timeout_(len)) return false; this->serial_->readBytes(data, len); @@ -207,12 +207,12 @@ bool RP2040UartComponent::read_array(uint8_t *data, size_t len) { #endif return true; } -size_t RP2040UartComponent::available() { return this->serial_->available(); } -UARTFlushResult RP2040UartComponent::flush() { +size_t RP2UartComponent::available() { return this->serial_->available(); } +UARTFlushResult RP2UartComponent::flush() { ESP_LOGVV(TAG, " Flushing"); this->serial_->flush(); return UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS; } } // namespace esphome::uart -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/uart/uart_component_rp2040.h b/esphome/components/uart/uart_component_rp2.h similarity index 88% rename from esphome/components/uart/uart_component_rp2040.h rename to esphome/components/uart/uart_component_rp2.h index b16d8b12d9..734bc6022e 100644 --- a/esphome/components/uart/uart_component_rp2040.h +++ b/esphome/components/uart/uart_component_rp2.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include #include @@ -13,7 +13,7 @@ namespace esphome::uart { -class RP2040UartComponent final : public UARTComponent, public Component { +class RP2UartComponent final : public UARTComponent, public Component { public: void setup() override; void dump_config() override; @@ -40,4 +40,4 @@ class RP2040UartComponent final : public UARTComponent, public Component { }; } // namespace esphome::uart -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/wake_on_lan/button.py b/esphome/components/wake_on_lan/button.py index b09e87e811..e1a4e4f4b0 100644 --- a/esphome/components/wake_on_lan/button.py +++ b/esphome/components/wake_on_lan/button.py @@ -8,7 +8,7 @@ DEPENDENCIES = ["network"] def AUTO_LOAD(): - if CORE.is_esp8266 or CORE.is_rp2040: + if CORE.is_esp8266 or CORE.is_rp2: return [] return ["socket"] diff --git a/esphome/components/watchdog/watchdog.cpp b/esphome/components/watchdog/watchdog.cpp index b05d7d4f6d..2063faeb91 100644 --- a/esphome/components/watchdog/watchdog.cpp +++ b/esphome/components/watchdog/watchdog.cpp @@ -9,7 +9,7 @@ #include "esp_idf_version.h" #include "esp_task_wdt.h" #endif -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "hardware/watchdog.h" #include "pico/stdlib.h" #endif @@ -53,7 +53,7 @@ void WatchdogManager::set_timeout_(uint32_t timeout_ms) { esp_task_wdt_reconfigure(&wdt_config); #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 watchdog_enable(timeout_ms, true); #endif } @@ -65,7 +65,7 @@ uint32_t WatchdogManager::get_timeout_() { timeout_ms = (uint32_t) CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000; #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 timeout_ms = watchdog_get_count() / 1000; #endif diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 788bedec34..f4e9eae763 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -32,7 +32,7 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_LN882X, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority @@ -227,7 +227,7 @@ CONFIG_SCHEMA = cv.All( PLATFORM_ESP8266, PLATFORM_BK72XX, PLATFORM_LN882X, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, ] ), diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index b587841dfd..fc575d1c06 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -63,7 +63,7 @@ async def to_code(config): cg.add_library("ESP8266WiFi", None) if CORE.is_libretiny: CORE.add_platformio_option("lib_ignore", ["ESPAsyncTCP", "RPAsyncTCP"]) - if CORE.is_rp2040: + if CORE.is_rp2: # Ignore bundled AsyncTCP libraries - we use RPAsyncTCP from async_tcp component CORE.add_platformio_option( "lib_ignore", ["ESPAsyncTCP", "AsyncTCP", "AsyncTCP_RP2040W"] diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index abce1fd5c0..af600647c1 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -143,8 +143,8 @@ def has_native_wifi( """ if platform == Platform.ESP32: return variant_has_wifi(variant) if variant else True - if platform == Platform.RP2040: - from esphome.components.rp2040 import board_id_has_wifi + if platform == Platform.RP2: + from esphome.components.rp2 import board_id_has_wifi return board_id_has_wifi(board) if board else True return platform in _WIFI_FIRST_PLATFORMS @@ -301,7 +301,7 @@ def wifi_network_ap(value): if value is None: value = {} config = WIFI_NETWORK_AP(value) - if CONF_MANUAL_IP in config and CORE.is_rp2040: + if CONF_MANUAL_IP in config and CORE.is_rp2: raise cv.Invalid( "Manual AP IP configuration is not supported on RP2040. " "The AP uses the default IP 192.168.4.1" @@ -324,8 +324,8 @@ def validate_variant(_): variant = get_esp32_variant() if variant in NO_WIFI_VARIANTS and "esp32_hosted" not in fv.full_config.get(): raise cv.Invalid(f"WiFi requires component esp32_hosted on {variant}") - if CORE.is_rp2040: - from esphome.components.rp2040 import board_has_wifi, get_board + if CORE.is_rp2: + from esphome.components.rp2 import board_has_wifi, get_board if not board_has_wifi(): raise cv.Invalid( @@ -369,7 +369,7 @@ def _consume_wifi_sockets(config: ConfigType) -> ConfigType: DHCP/DNS). On ESP32, CONFIG_LWIP_MAX_SOCKETS only controls the POSIX socket layer — DHCP/DNS use raw udp_new() which bypasses it entirely. """ - if not (CORE.is_bk72xx or CORE.is_rtl87xx or CORE.is_ln882x or CORE.is_rp2040): + if not (CORE.is_bk72xx or CORE.is_rtl87xx or CORE.is_ln882x or CORE.is_rp2): return config from esphome.components import socket @@ -473,7 +473,7 @@ CONFIG_SCHEMA = cv.All( CONF_POWER_SAVE_MODE, esp8266="none", esp32="light", - rp2040="light", + rp2="light", bk72xx="none", rtl87xx="none", ln882x="light", @@ -676,7 +676,7 @@ async def to_code(config): if CONF_PHY_MODE in config: cg.add_define("USE_WIFI_PHY_MODE") cg.add(var.set_phy_mode(config[CONF_PHY_MODE])) - elif CORE.is_rp2040: + elif CORE.is_rp2: cg.add_library("WiFi", None) if CORE.is_esp32: @@ -944,7 +944,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, - "wifi_component_pico_w.cpp": {PlatformFramework.RP2040_ARDUINO}, + "wifi_component_pico_w.cpp": {PlatformFramework.RP2_ARDUINO}, } ) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 2f6bec6bb2..c951e74358 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -2388,7 +2388,7 @@ void WiFiComponent::clear_roaming_state_() { void WiFiComponent::release_scan_results_() { if (!this->keep_scan_results_) { -#if defined(USE_RP2040) || defined(USE_ESP32) +#if defined(USE_RP2) || defined(USE_ESP32) // std::vector - use swap trick since shrink_to_fit is non-binding decltype(this->scan_result_)().swap(this->scan_result_); #else diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index c774e3a68e..0db85c4d75 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -46,7 +46,7 @@ extern "C" { #endif #endif -#ifdef USE_RP2040 +#ifdef USE_RP2 extern "C" { #include "cyw43.h" #include "cyw43_country.h" @@ -181,7 +181,7 @@ static constexpr size_t WIFI_SCAN_RESULT_FILTERED_RESERVE = 8; // Use std::vector for RP2040 (callback-based) and ESP32 (destructive scan API) // Use FixedVector for ESP8266 and LibreTiny where two-pass exact allocation is possible -#if defined(USE_RP2040) || defined(USE_ESP32) +#if defined(USE_RP2) || defined(USE_ESP32) template using wifi_scan_vector_t = std::vector; #else template using wifi_scan_vector_t = FixedVector; @@ -815,7 +815,7 @@ class WiFiComponent final : public Component { friend void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data); #endif -#ifdef USE_RP2040 +#ifdef USE_RP2 static int s_wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result); void wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result); #endif diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 596fd2729b..1a70f81a2b 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -1,7 +1,7 @@ #include "wifi_component.h" #ifdef USE_WIFI -#ifdef USE_RP2040 +#ifdef USE_RP2 #include diff --git a/esphome/config_validation.py b/esphome/config_validation.py index b77e22a6fb..45fd94fd1a 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -71,7 +71,7 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_NRF52, - PLATFORM_RP2040, + PLATFORM_RP2, SCHEDULER_DONT_RUN, TYPE_GIT, TYPE_LOCAL, @@ -859,7 +859,38 @@ def only_with_framework( only_on_esp32 = only_on(PLATFORM_ESP32) only_on_esp8266 = only_on(PLATFORM_ESP8266) only_on_nrf52 = only_on(PLATFORM_NRF52) -only_on_rp2040 = only_on(PLATFORM_RP2040) +only_on_rp2 = only_on(PLATFORM_RP2) + +# CORE.data key for the "deprecation warning already fired this run" flag. +# Deduped via CORE.data (cleared between runs) to match the framework-alias +# pattern; one warning per `esphome config|compile|run` invocation is enough. +_ONLY_ON_RP2040_DEPRECATED_KEY = "_cv_only_on_rp2040_deprecated_warned" + + +def only_on_rp2040(obj): + """Deprecated — kept as a back-compat shim for external custom components. + + Pre-RP2350, this was the family check for the RP2 platform; with RP2350 + landing under the same target platform, the variant axis is now exposed + by the rp2 component itself. New code should use one of: + + * :func:`only_on_rp2` — family-level gate (matches the esp32 pattern; + same semantics as the pre-RP2350 ``only_on_rp2040``). + * ``rp2.only_on_variant(supported=[VARIANT_RP2040])`` — variant-level + gate, rejects RP2350 boards on the rp2 platform. + + Scheduled for removal in 2027.7.0. + """ + if not CORE.data.get(_ONLY_ON_RP2040_DEPRECATED_KEY): + _LOGGER.warning( + "cv.only_on_rp2040 is deprecated; use cv.only_on_rp2 for the " + "family gate, or rp2.only_on_variant(supported=[VARIANT_RP2040]) " + "for the variant gate. Removed in 2027.7.0." + ) + CORE.data[_ONLY_ON_RP2040_DEPRECATED_KEY] = True + return only_on_rp2(obj) + + only_with_arduino = only_with_framework(Framework.ARDUINO) @@ -1990,7 +2021,24 @@ def _get_default_key(*args): class SplitDefault(Optional): - """Mark this key to have a split default for ESP8266/ESP32.""" + """Mark this key to have a split default per target platform / variant / framework. + + Defaults are passed as kwargs keyed on the platform identifier; the most + specific match wins. Lookup order (first hit wins): + + 1. ``__`` — e.g. ``esp32_c3_arduino``, + ``rp2_2040_arduino`` + 2. ``_`` — e.g. ``esp32_c3``, ``rp2_2040`` + 3. ``_`` — e.g. ``esp32_arduino``, + ``rp2_arduino`` + 4. ```` — e.g. ``esp32``, ``rp2`` + + For ESP32 the variant strips the ``ESP32`` prefix from + :data:`esp32.VARIANT_*` constants (``ESP32C3`` → ``c3``). For RP2 the + variant strips just ``RP`` (``RP2040`` → ``2040``, ``RP2350`` → ``2350``) + so kwargs read naturally — `rp2_2040=...` is the override for the + Pico / Pico W and `rp2_2350=...` is the override for the Pico 2. + """ def __init__(self, key, **kwargs): super().__init__(key) @@ -2012,6 +2060,22 @@ class SplitDefault(Optional): keys += _get_default_key(variant, framework) keys += _get_default_key(variant) keys += _get_default_key(framework) + elif CORE.is_rp2: + # Strip the "RP" prefix to leave the chip number, mirroring + # the ESP32 "platform stripped from variant" convention so + # kwargs stay short (``rp2_2040`` rather than ``rp2_rp2040``). + # Variant lookup is defensive: validators may run before the + # rp2 component's ``set_core_data`` (or in tests that wire a + # partial ``CORE.data``); in that case we just skip the + # variant-specific keys and fall through to the base + # platform/framework defaults. + raw_variant = CORE.data.get("rp2", {}).get("variant") + framework = CORE.target_framework + if raw_variant: + variant = raw_variant.removeprefix("RP").lower() + keys += _get_default_key(variant, framework) + keys += _get_default_key(variant) + keys += _get_default_key(framework) keys += _get_default_key() for key in keys: if self._defaults.get(key) is not None: @@ -2443,18 +2507,58 @@ def require_framework_version( extra_message=None, **kwargs, ): + """Constrain the configured framework version per target platform / variant. + + Kwargs are keyed by ``_`` (e.g. ``esp32_arduino``, + ``rp2_arduino``) with optional variant-specific overrides keyed by + ``__`` (e.g. ``esp32_c3_arduino``, + ``rp2_2040_arduino``, ``rp2_2350_arduino``). Variant overrides win when + the configured variant matches; otherwise the base platform key is used. + + Special cases: ``host`` (with host framework) and ``esp_idf`` (any ESP32 + on ESP-IDF) bypass variant lookup. + """ + def validator(value): core_data = CORE.data[KEY_CORE] framework = core_data[KEY_TARGET_FRAMEWORK] + keys_to_try: list[str] = [] if CORE.is_host and framework == "host": - key = "host" + keys_to_try.append("host") elif framework == "esp-idf": - key = "esp_idf" + keys_to_try.append("esp_idf") else: - key = CORE.target_platform + "_" + framework + # Try variant-specific key first (mirrors the SplitDefault + # precedence). ESP32 strips its platform prefix from variant + # constants; RP2 strips just ``RP`` to keep chip-number kwargs + # (``rp2_2040``, ``rp2_2350``). + if CORE.is_esp32: + from esphome.components.esp32 import VARIANT_ESP32, get_esp32_variant - if key not in kwargs: + # Guard against tests that wire CORE.data without an + # esp32 variant block; same defensive intent as the rp2 + # branch below. + try: + variant = get_esp32_variant().replace(VARIANT_ESP32, "").lower() + except (KeyError, AttributeError): + variant = "" + if variant: + keys_to_try.append(f"{CORE.target_platform}_{variant}_{framework}") + elif CORE.is_rp2: + # Defensive lookup — see the matching block in + # ``SplitDefault.default``: the rp2 component's + # ``set_core_data`` may not have populated + # ``CORE.data["rp2"]["variant"]`` yet (validators run + # during schema validation, before code-gen). + raw_variant = CORE.data.get("rp2", {}).get("variant") + if raw_variant: + variant = raw_variant.removeprefix("RP").lower() + keys_to_try.append(f"{CORE.target_platform}_{variant}_{framework}") + keys_to_try.append(f"{CORE.target_platform}_{framework}") + + key = next((k for k in keys_to_try if k in kwargs), None) + if key is None: msg = f"This feature is incompatible with {CORE.target_platform.upper()} using {framework} framework" if extra_message: msg += f". {extra_message}" diff --git a/esphome/const.py b/esphome/const.py index 24bb4ea31f..16d11d3a18 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -33,7 +33,12 @@ class Platform(StrEnum): LIBRETINY_OLDSTYLE = "libretiny" LN882X = "ln882x" NRF52 = "nrf52" - RP2040 = "rp2040" + RP2 = "rp2" # canonical name for the RP2 family (RP2040, RP2350, …) + # Deprecated: use Platform.RP2 instead. Python enum aliasing makes this + # the same member as RP2 (same string value), so ``Platform.RP2040`` and + # ``Platform.RP2`` remain interchangeable for external custom components. + # Scheduled for removal in 2027.7.0. + RP2040 = "rp2" RTL87XX = "rtl87xx" @@ -86,6 +91,9 @@ class PlatformFramework(Enum): # Arduino framework platforms ESP8266_ARDUINO = (Platform.ESP8266, Framework.ARDUINO) + RP2_ARDUINO = (Platform.RP2, Framework.ARDUINO) + # Deprecated: use PlatformFramework.RP2_ARDUINO instead. Kept as an + # alias for backwards compatibility; scheduled for removal in 2027.7.0. RP2040_ARDUINO = (Platform.RP2040, Framework.ARDUINO) BK72XX_ARDUINO = (Platform.BK72XX, Framework.ARDUINO) RTL87XX_ARDUINO = (Platform.RTL87XX, Framework.ARDUINO) @@ -106,6 +114,9 @@ PLATFORM_HOST = Platform.HOST PLATFORM_LIBRETINY_OLDSTYLE = Platform.LIBRETINY_OLDSTYLE PLATFORM_LN882X = Platform.LN882X PLATFORM_NRF52 = Platform.NRF52 +PLATFORM_RP2 = Platform.RP2 +# Deprecated: use PLATFORM_RP2 instead. Kept as a back-compat alias; +# scheduled for removal in 2027.7.0. PLATFORM_RP2040 = Platform.RP2040 PLATFORM_RTL87XX = Platform.RTL87XX diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 89ce27a8b9..803ddba6b7 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -25,7 +25,7 @@ from esphome.const import ( PLATFORM_HOST, PLATFORM_LN882X, PLATFORM_NRF52, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, Toolchain, ) @@ -52,6 +52,11 @@ _LOGGER = logging.getLogger(__name__) # Key for tracking controller count in CORE.data for ControllerRegistry StaticVector sizing KEY_CONTROLLER_REGISTRY_COUNT = "controller_registry_count" +# CORE.data key for the "is_rp2040 deprecation warning already fired this +# run" flag. Mirrors the ``cv.only_on_rp2040`` dedupe pattern; cleared +# between runs so each fresh invocation warns once. +_IS_RP2040_DEPRECATED_KEY = "_core_is_rp2040_deprecated_warned" + class EsphomeError(Exception): """General ESPHome exception occurred.""" @@ -830,9 +835,38 @@ class EsphomeCore: def is_esp32(self): return self.target_platform == PLATFORM_ESP32 + @property + def is_rp2(self): + """Return True if the target platform is the RP2 chip family. + + Canonical umbrella check covering RP2040, RP2350, and any future + RP2-series chip. Mirrors :attr:`is_esp32` for the ESP32 family. + For variant-specific gating (RP2040 vs RP2350), use + ``rp2.get_rp2040_variant()`` or ``rp2.only_on_variant(...)`` from + the rp2 component — variant detection doesn't belong on ``CORE``. + """ + return self.target_platform == PLATFORM_RP2 + @property def is_rp2040(self): - return self.target_platform == PLATFORM_RP2040 + """Deprecated: use :attr:`is_rp2` for the family check, or + ``rp2.get_rp2040_variant() == rp2.VARIANT_RP2040`` for the + variant-specific check. Kept as an alias since pre-RP2350 + callers used it as a family check, identical to ``is_rp2``. + + Scheduled for removal in 2027.7.0. Logs a one-shot deprecation + warning per run (deduped via ``self.data`` so repeated reads in + the same invocation don't spam) to match the parallel + ``cv.only_on_rp2040`` shim. + """ + if not self.data.get(_IS_RP2040_DEPRECATED_KEY): + _LOGGER.warning( + "CORE.is_rp2040 is deprecated; use CORE.is_rp2 for the family " + "gate, or rp2.get_rp2040_variant() == rp2.VARIANT_RP2040 for " + "the variant-specific check. Removed in 2027.7.0." + ) + self.data[_IS_RP2040_DEPRECATED_KEY] = True + return self.is_rp2 @property def is_bk72xx(self): diff --git a/esphome/core/config.py b/esphome/core/config.py index ebad5cf165..5b95ac3a50 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -868,8 +868,8 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( "wake/wake_esp8266.cpp": { PlatformFramework.ESP8266_ARDUINO, }, - "wake/wake_rp2040.cpp": { - PlatformFramework.RP2040_ARDUINO, + "wake/wake_rp2.cpp": { + PlatformFramework.RP2_ARDUINO, }, "wake/wake_host.cpp": { PlatformFramework.HOST_NATIVE, diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 987e2d7a2a..3e8b0829c5 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -19,13 +19,13 @@ // 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_ESP8266 / USE_RP2 / 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) +#if defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_NRF52) #define ESPHOME_THREAD_SINGLE #elif defined(USE_BK72XX) #define ESPHOME_THREAD_MULTI_NO_ATOMICS @@ -227,7 +227,7 @@ #endif // Platforms with native 64-bit time sources (no rollover tracking needed) -#if defined(USE_ESP32) || defined(USE_HOST) || defined(USE_ZEPHYR) || defined(USE_RP2040) +#if defined(USE_ESP32) || defined(USE_HOST) || defined(USE_ZEPHYR) || defined(USE_RP2) #define USE_NATIVE_64BIT_TIME #endif @@ -405,9 +405,12 @@ #define USE_WEBSERVER_PORT 80 // NOLINT #endif -#ifdef USE_RP2040 +// USE_RP2 is the canonical platform define for the RP2 chip family. The +// rp2/__init__.py codegen also defines USE_RP2040 as a back-compat alias +// for external custom components that may still test for it. +#ifdef USE_RP2 #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 0) -#define USE_RP2040_CRASH_HANDLER +#define USE_RP2_CRASH_HANDLER #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C #define USE_LOGGER_USB_CDC diff --git a/esphome/core/hal.h b/esphome/core/hal.h index b44a422836..4c5a19c6d1 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -19,8 +19,8 @@ #include "esphome/components/esp8266/hal.h" #elif defined(USE_LIBRETINY) #include "esphome/components/libretiny/hal.h" -#elif defined(USE_RP2040) -#include "esphome/components/rp2040/hal.h" +#elif defined(USE_RP2) +#include "esphome/components/rp2/hal.h" #elif defined(USE_HOST) #include "esphome/components/host/hal.h" #elif defined(USE_ZEPHYR) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index a212019628..f39b5aa4d0 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -33,7 +33,7 @@ #include #endif -#ifdef USE_RP2040 +#ifdef USE_RP2 #include #endif @@ -1895,7 +1895,7 @@ class Mutex { Mutex(const Mutex &) = delete; Mutex &operator=(const Mutex &) = delete; -#if defined(USE_ESP8266) || defined(USE_RP2040) +#if defined(USE_ESP8266) || defined(USE_RP2) // Single-threaded platforms: inline no-ops so the compiler eliminates all call overhead. Mutex() = default; ~Mutex() = default; @@ -1964,7 +1964,7 @@ class InterruptLock { ~InterruptLock(); protected: -#if defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_ZEPHYR) +#if defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_ZEPHYR) uint32_t state_; #endif }; @@ -1982,7 +1982,7 @@ class LwIPLock { LwIPLock(const LwIPLock &) = delete; LwIPLock &operator=(const LwIPLock &) = delete; -#if defined(USE_ESP32) || defined(USE_RP2040) +#if defined(USE_ESP32) || defined(USE_RP2) // Platforms with potential lwIP core locking — out-of-line implementations in helpers.cpp LwIPLock(); ~LwIPLock(); @@ -2132,7 +2132,7 @@ template class RAMAllocator { auto max_external = this->flags_ & ALLOC_EXTERNAL ? heap_caps_get_free_size(MALLOC_CAP_8BIT | MALLOC_CAP_SPIRAM) : 0; return max_internal + max_external; -#elif defined(USE_RP2040) +#elif defined(USE_RP2) return ::rp2040.getFreeHeap(); #elif defined(USE_LIBRETINY) return lt_heap_get_free(); diff --git a/esphome/core/preference_backend.h b/esphome/core/preference_backend.h index 431de205af..34bf84409d 100644 --- a/esphome/core/preference_backend.h +++ b/esphome/core/preference_backend.h @@ -12,8 +12,8 @@ #include "esphome/components/esp32/preference_backend.h" #elif defined(USE_ESP8266) #include "esphome/components/esp8266/preference_backend.h" -#elif defined(USE_RP2040) -#include "esphome/components/rp2040/preference_backend.h" +#elif defined(USE_RP2) +#include "esphome/components/rp2/preference_backend.h" #elif defined(USE_LIBRETINY) #include "esphome/components/libretiny/preference_backend.h" #elif defined(USE_HOST) @@ -24,7 +24,7 @@ namespace esphome { -#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2040) && !defined(USE_LIBRETINY) && \ +#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2) && !defined(USE_LIBRETINY) && \ !defined(USE_HOST) && !(defined(USE_ZEPHYR) && defined(CONFIG_SETTINGS)) // Stub for static analysis when no platform is defined. struct PreferenceBackend { diff --git a/esphome/core/preferences.h b/esphome/core/preferences.h index 64a0a927e6..1efce5af51 100644 --- a/esphome/core/preferences.h +++ b/esphome/core/preferences.h @@ -9,8 +9,8 @@ #include "esphome/components/esp32/preferences.h" #elif defined(USE_ESP8266) #include "esphome/components/esp8266/preferences.h" -#elif defined(USE_RP2040) -#include "esphome/components/rp2040/preferences.h" +#elif defined(USE_RP2) +#include "esphome/components/rp2/preferences.h" #elif defined(USE_LIBRETINY) #include "esphome/components/libretiny/preferences.h" #elif defined(USE_HOST) diff --git a/esphome/core/wake.h b/esphome/core/wake.h index 5a5d27ceff..a48e52fb73 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -18,7 +18,7 @@ namespace esphome { // === Wake flag for ESP8266/RP2040 === -#if defined(USE_ESP8266) || defined(USE_RP2040) +#if defined(USE_ESP8266) || defined(USE_RP2) // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) extern volatile bool g_main_loop_woke; #endif @@ -65,8 +65,8 @@ __attribute__((always_inline)) inline bool wake_request_take() { #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_RP2) +#include "esphome/core/wake/wake_rp2.h" #elif defined(USE_HOST) #include "esphome/core/wake/wake_host.h" #elif defined(USE_ZEPHYR) diff --git a/esphome/core/wake/wake_rp2040.cpp b/esphome/core/wake/wake_rp2.cpp similarity index 97% rename from esphome/core/wake/wake_rp2040.cpp rename to esphome/core/wake/wake_rp2.cpp index bdcbb1ad00..101c87c818 100644 --- a/esphome/core/wake/wake_rp2040.cpp +++ b/esphome/core/wake/wake_rp2.cpp @@ -1,6 +1,6 @@ #include "esphome/core/defines.h" -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/hal.h" #include "esphome/core/wake.h" @@ -59,4 +59,4 @@ void wakeable_delay(uint32_t ms) { } // namespace esphome -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/core/wake/wake_rp2040.h b/esphome/core/wake/wake_rp2.h similarity index 88% rename from esphome/core/wake/wake_rp2040.h rename to esphome/core/wake/wake_rp2.h index ea1242f535..715e5aca0c 100644 --- a/esphome/core/wake/wake_rp2040.h +++ b/esphome/core/wake/wake_rp2.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/hal.h" @@ -21,11 +21,11 @@ inline void wake_loop_any_context() { inline void wake_loop_threadsafe() { wake_loop_any_context(); } -/// RP2040 wakeable delay uses file-scope state (alarm callback + flag) — defined in wake_rp2040.cpp. +/// RP2040 wakeable delay uses file-scope state (alarm callback + flag) — defined in wake_rp2.cpp. namespace internal { void wakeable_delay(uint32_t ms); } // namespace internal } // namespace esphome -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 9d662df8f8..6376e573c4 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -133,7 +133,7 @@ class StorageJSON: self.no_mdns = no_mdns # The framework used to compile the firmware self.framework = framework - # The core platform of this firmware. Like "esp32", "rp2040", "host" etc. + # The core platform of this firmware. Like "esp32", "rp2", "host" etc. self.core_platform = core_platform # The toolchain used for the build ("platformio" / "esp-idf") self.toolchain = toolchain diff --git a/esphome/wizard.py b/esphome/wizard.py index f83342cc6a..f7706928e9 100644 --- a/esphome/wizard.py +++ b/esphome/wizard.py @@ -75,8 +75,8 @@ esp32: type: esp-idf """ -RP2040_CONFIG = """ -rp2040: +RP2_CONFIG = """ +rp2: board: {board} """ @@ -98,7 +98,7 @@ rtl87xx: HARDWARE_BASE_CONFIGS = { "ESP8266": ESP8266_CONFIG, "ESP32": ESP32_CONFIG, - "RP2040": RP2040_CONFIG, + "RP2": RP2_CONFIG, "BK72XX": BK72XX_CONFIG, "LN882X": LN882X_CONFIG, "RTL87XX": RTL87XX_CONFIG, @@ -113,7 +113,7 @@ class WizardFileKwargs(TypedDict): """Keyword arguments for wizard_file function.""" name: str - platform: Literal["ESP8266", "ESP32", "RP2040", "BK72XX", "LN882X", "RTL87XX"] + platform: Literal["ESP8266", "ESP32", "RP2", "BK72XX", "LN882X", "RTL87XX"] board: str ssid: NotRequired[str] psk: NotRequired[str] @@ -213,7 +213,7 @@ def wizard_write(path: Path, **kwargs: Unpack[WizardWriteKwargs]) -> bool: from esphome.components.esp32 import boards as esp32_boards from esphome.components.esp8266 import boards as esp8266_boards from esphome.components.ln882x import boards as ln882x_boards - from esphome.components.rp2040 import boards as rp2040_boards + from esphome.components.rp2 import boards as rp2_boards from esphome.components.rtl87xx import boards as rtl87xx_boards name = kwargs["name"] @@ -235,8 +235,8 @@ def wizard_write(path: Path, **kwargs: Unpack[WizardWriteKwargs]) -> bool: platform = "ESP8266" elif board in esp32_boards.BOARDS: platform = "ESP32" - elif board in rp2040_boards.BOARDS: - platform = "RP2040" + elif board in rp2_boards.BOARDS: + platform = "RP2" elif board in bk72xx_boards.BOARDS: platform = "BK72XX" elif board in ln882x_boards.BOARDS: @@ -301,7 +301,7 @@ def wizard(path: Path) -> int: from esphome.components.esp32 import boards as esp32_boards from esphome.components.esp8266 import boards as esp8266_boards from esphome.components.ln882x import boards as ln882x_boards - from esphome.components.rp2040 import boards as rp2040_boards + from esphome.components.rp2 import boards as rp2_boards from esphome.components.rtl87xx import boards as rtl87xx_boards if path.suffix not in (".yaml", ".yml"): @@ -373,7 +373,7 @@ def wizard(path: Path) -> int: "firmwares for it." ) - wizard_platforms = ["ESP32", "ESP8266", "BK72XX", "LN882X", "RTL87XX", "RP2040"] + wizard_platforms = ["ESP32", "ESP8266", "BK72XX", "LN882X", "RTL87XX", "RP2"] safe_print( "Please choose one of the supported microcontrollers " "(Use ESP8266 for Sonoff devices)." @@ -405,7 +405,7 @@ def wizard(path: Path) -> int: board_link = ( "https://docs.platformio.org/en/latest/platforms/espressif8266.html#boards" ) - elif platform == "RP2040": + elif platform == "RP2": board_link = "https://www.raspberrypi.com/documentation/microcontrollers/silicon.html#rp2040" elif platform in ["BK72XX", "LN882X", "RTL87XX"]: board_link = "https://docs.libretiny.eu/docs/status/supported/" @@ -421,27 +421,21 @@ def wizard(path: Path) -> int: safe_print(f"(Type {color(AnsiFore.GREEN, 'esp01_1m')} for Sonoff devices)") safe_print() # Don't sleep because user needs to copy link - if platform == "ESP32": - safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "nodemcu-32s")}".') - boards_list = esp32_boards.BOARDS.items() - elif platform == "ESP8266": - safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "nodemcuv2")}".') - boards_list = esp8266_boards.BOARDS.items() - elif platform == "BK72XX": - safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "cb2s")}".') - boards_list = bk72xx_boards.BOARDS.items() - elif platform == "LN882X": - safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "wl2s")}".') - boards_list = ln882x_boards.BOARDS.items() - elif platform == "RTL87XX": - safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "wr3")}".') - boards_list = rtl87xx_boards.BOARDS.items() - elif platform == "RP2040": - safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "rpipicow")}".') - boards_list = rp2040_boards.BOARDS.items() - - else: - raise NotImplementedError("Unknown platform!") + # Platform-to-(example board, boards module) lookup. Dict-driven so the + # set of supported platforms has a single source of truth and the elif + # chain — which left the last entry's "False" branch structurally + # unreachable in tests — is gone. + example_boards = { + "ESP32": ("nodemcu-32s", esp32_boards), + "ESP8266": ("nodemcuv2", esp8266_boards), + "BK72XX": ("cb2s", bk72xx_boards), + "LN882X": ("wl2s", ln882x_boards), + "RTL87XX": ("wr3", rtl87xx_boards), + "RP2": ("rpipicow", rp2_boards), + } + example, boards_module = example_boards[platform] + safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, example)}".') + boards_list = boards_module.BOARDS.items() boards = [] safe_print("Options:") diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 974957245a..bc97a0d603 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -785,6 +785,29 @@ def build_schema(): # bundle core inside esphome data["esphome"]["core"] = data.pop("core")["core"] + # Surface deprecated component aliases (declared via ``ALIASES = [...]`` + # on the canonical component) so language servers / dashboard + # autocomplete still accept legacy top-level keys instead of flagging + # them as unknown. Each alias gets its own bundle that mirrors the + # canonical schema; ``alias_of`` and the optional ``removal_version`` + # metadata let consumers render a deprecation hint and point users at + # the canonical name. Without this, configs migrated only at runtime + # (via the ``_resolve_component_aliases`` pre-pass) would still light + # up as errors in the editor. + for domain, manifest in components.items(): + aliases = manifest.aliases + if not aliases or domain not in data: + continue + canonical_bundle = data[domain].get(domain) + if canonical_bundle is None: + continue + for alias in aliases: + alias_entry = dict(canonical_bundle) + alias_entry["alias_of"] = domain + if manifest.alias_removal_version is not None: + alias_entry["removal_version"] = manifest.alias_removal_version + data[alias] = {alias: alias_entry} + if GENERATED_ID_TYPES: print( "Unconsumed id_type matchers:", diff --git a/script/ci-custom.py b/script/ci-custom.py index 75f4d71ba4..4b16734ebe 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -621,6 +621,9 @@ def convert_path_to_relative(abspath, current): "esphome/components/web_server/__init__.py", # const.py has absolute import in docstring example for external components "esphome/components/esp8266/const.py", + # rp2040/__init__.py is the deprecation shim that documents the canonical + # rp2 module path and its own legacy import paths in docstrings/comments. + "esphome/components/rp2040/__init__.py", ], ) def lint_relative_py_import(fname: Path, line, col, content): @@ -650,13 +653,13 @@ def lint_relative_py_import(fname: Path, line, col, content): "esphome/components/async_tcp/async_tcp.h", "esphome/components/esp32/core.cpp", "esphome/components/esp8266/core.cpp", - "esphome/components/rp2040/core.cpp", + "esphome/components/rp2/core.cpp", "esphome/components/libretiny/core.cpp", "esphome/components/host/core.cpp", "esphome/components/zephyr/core.cpp", "esphome/components/esp32/helpers.cpp", "esphome/components/esp8266/helpers.cpp", - "esphome/components/rp2040/helpers.cpp", + "esphome/components/rp2/helpers.cpp", "esphome/components/libretiny/helpers.cpp", "esphome/components/host/helpers.cpp", "esphome/components/zephyr/helpers.cpp", diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 756f3884b8..061485c76c 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -160,7 +160,8 @@ class Platform(StrEnum): BK72XX_ARD = "bk72xx-ard" # LibreTiny BK7231N RTL87XX_ARD = "rtl87xx-ard" # LibreTiny RTL8720x LN882X_ARD = "ln882x-ard" # LibreTiny LN882x - RP2040_ARD = "rp2040-ard" # Raspberry Pi Pico + RP2040_ARD = "rp2040-ard" # RP2 family, RP2040 chip (Pico / Pico W) + RP2350_ARD = "rp2350-ard" # RP2 family, RP2350 chip (Pico 2 / Pico 2 W) NRF52_ZEPHYR = "nrf52-adafruit" # Nordic nRF52 (Zephyr) @@ -190,7 +191,8 @@ MEMORY_IMPACT_PLATFORM_PREFERENCE = [ Platform.BK72XX_ARD, # LibreTiny BK7231N Platform.RTL87XX_ARD, # LibreTiny RTL8720x Platform.LN882X_ARD, # LibreTiny LN882x - Platform.RP2040_ARD, # Raspberry Pi Pico + Platform.RP2040_ARD, # Raspberry Pi Pico (RP2040) + Platform.RP2350_ARD, # Raspberry Pi Pico 2 (RP2350) Platform.NRF52_ZEPHYR, # Nordic nRF52 (Zephyr) ] @@ -859,7 +861,8 @@ def _detect_platform_hint_from_filename(filename: str) -> Platform | None: - *_libretiny.cpp, *_bk72*.* -> BK72XX (LibreTiny) - *_rtl87*.* -> RTL87XX (LibreTiny Realtek) - *_ln882*.* -> LN882X (LibreTiny Lightning) - - *_pico.cpp, *_rp2040.* -> RP2040_ARD + - *_rp2350*.*, *_pico2*.* -> RP2350_ARD (RP2 family, RP2350 chip) + - *_rp2040*.*, *_pico*.* -> RP2040_ARD (RP2 family, RP2040 chip) Args: filename: File path to check @@ -901,8 +904,14 @@ def _detect_platform_hint_from_filename(filename: str) -> Platform | None: if "libretiny" in filename_lower or "bk72" in filename_lower: return Platform.BK72XX_ARD - # RP2040 / Raspberry Pi Pico - if "pico" in filename_lower or "rp2040" in filename_lower: + # RP2 family (Raspberry Pi Pico): explicit chip names only. Family- + # wide files (named ``_rp2.*``) are shared between RP2040 and RP2350 + # and intentionally don't preferentially route to either chip. + # Check the RP2350 patterns first since ``pico2`` substring-matches + # ``pico``. + if "rp2350" in filename_lower or "pico2" in filename_lower: + return Platform.RP2350_ARD + if "rp2040" in filename_lower or "pico" in filename_lower: return Platform.RP2040_ARD # nRF52 / Zephyr diff --git a/script/generate-rp2040-boards.py b/script/generate-rp2-boards.py similarity index 77% rename from script/generate-rp2040-boards.py rename to script/generate-rp2-boards.py index 1b4846fd2b..94a5cc018a 100755 --- a/script/generate-rp2040-boards.py +++ b/script/generate-rp2-boards.py @@ -8,14 +8,14 @@ import subprocess import sys import tempfile -from esphome.components.rp2040 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION -from esphome.components.rp2040.generate_boards import generate +from esphome.components.rp2 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION +from esphome.components.rp2.generate_boards import generate from esphome.helpers import write_file_if_changed ver = RECOMMENDED_ARDUINO_FRAMEWORK_VERSION version_tag: str = f"{ver.major}.{ver.minor}.{ver.patch}" root: Path = Path(__file__).parent.parent -boards_file_path: Path = root / "esphome" / "components" / "rp2040" / "boards.py" +boards_file_path: Path = root / "esphome" / "components" / "rp2" / "boards.py" def main(check: bool) -> None: @@ -42,10 +42,10 @@ def main(check: bool) -> None: if check: existing_content: str = boards_file_path.read_text(encoding="utf-8") if existing_content != content: - print("esphome/components/rp2040/boards.py is not up to date.") - print("Please run `script/generate-rp2040-boards.py`") + print("esphome/components/rp2/boards.py is not up to date.") + print("Please run `script/generate-rp2-boards.py`") sys.exit(1) - print("esphome/components/rp2040/boards.py is up to date") + print("esphome/components/rp2/boards.py is up to date") elif write_file_if_changed(boards_file_path, content): print("RP2040 boards updated successfully.") diff --git a/tests/components/adc/test.rp2040-pico2-ard.yaml b/tests/components/adc/test.rp2350-ard.yaml similarity index 100% rename from tests/components/adc/test.rp2040-pico2-ard.yaml rename to tests/components/adc/test.rp2350-ard.yaml diff --git a/tests/components/rp2040/test.rp2040-ard.yaml b/tests/components/rp2/test.rp2040-ard.yaml similarity index 97% rename from tests/components/rp2040/test.rp2040-ard.yaml rename to tests/components/rp2/test.rp2040-ard.yaml index 09531f914e..eaa494a01a 100644 --- a/tests/components/rp2040/test.rp2040-ard.yaml +++ b/tests/components/rp2/test.rp2040-ard.yaml @@ -1,4 +1,4 @@ -rp2040: +rp2: variant: rp2040 enable_full_printf: false diff --git a/tests/components/rp2040/test.rp2040-pico2-ard.yaml b/tests/components/rp2/test.rp2350-ard.yaml similarity index 90% rename from tests/components/rp2040/test.rp2040-pico2-ard.yaml rename to tests/components/rp2/test.rp2350-ard.yaml index c9d795840d..84ee39a81e 100644 --- a/tests/components/rp2040/test.rp2040-pico2-ard.yaml +++ b/tests/components/rp2/test.rp2350-ard.yaml @@ -1,4 +1,4 @@ -rp2040: +rp2: variant: rp2350 enable_full_printf: false diff --git a/tests/components/spi/test.rp2040-pico2-ard.yaml b/tests/components/spi/test.rp2350-ard.yaml similarity index 100% rename from tests/components/spi/test.rp2040-pico2-ard.yaml rename to tests/components/spi/test.rp2350-ard.yaml diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 2f038155c0..d018c6dbd0 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -2225,15 +2225,33 @@ def test_detect_memory_impact_config_runs_at_component_limit(tmp_path: Path) -> "esphome/components/libretiny/wifi_ln882x.cpp", determine_jobs.Platform.LN882X_ARD, ), - # RP2040 / Raspberry Pi Pico detection + # RP2 family detection — explicit chip names only. + # RP2040 chip: _rp2040.*, _pico.* (Pico / Pico W) ("esphome/components/gpio/gpio_rp2040.cpp", determine_jobs.Platform.RP2040_ARD), ("esphome/components/wifi/wifi_rp2040.cpp", determine_jobs.Platform.RP2040_ARD), ("esphome/components/i2c/i2c_pico.cpp", determine_jobs.Platform.RP2040_ARD), ("esphome/components/spi/spi_pico.cpp", determine_jobs.Platform.RP2040_ARD), ( - "tests/components/rp2040/test.rp2040-ard.yaml", + "tests/components/rp2/test.rp2040-ard.yaml", determine_jobs.Platform.RP2040_ARD, ), + # RP2350 chip: _rp2350.*, _pico2.* (Pico 2 / Pico 2 W) + ( + "esphome/components/foo/foo_rp2350.cpp", + determine_jobs.Platform.RP2350_ARD, + ), + ( + "esphome/components/wifi/wifi_pico2.cpp", + determine_jobs.Platform.RP2350_ARD, + ), + ( + "tests/components/rp2/test.rp2350-ard.yaml", + determine_jobs.Platform.RP2350_ARD, + ), + # Family-wide files (_rp2.*) intentionally do NOT get a hint — + # they apply to both RP2040 and RP2350 chips. + ("esphome/components/debug/debug_rp2.cpp", None), + ("esphome/components/logger/logger_rp2.h", None), # nRF52 / Zephyr detection ( "tests/components/logger/test.nrf52-adafruit.yaml", @@ -2280,6 +2298,11 @@ def test_detect_memory_impact_config_runs_at_component_limit(tmp_path: Path) -> "pico_i2c", "pico_spi", "rp2040_test_yaml", + "rp2350_cpp", + "pico2_cpp", + "rp2350_test_yaml", + "rp2_family_debug_no_hint", + "rp2_family_logger_h_no_hint", "nrf52_test_yaml", "nrf52_gpio", "zephyr_core", diff --git a/tests/test_build_components/build_components_base.rp2040-ard.yaml b/tests/test_build_components/build_components_base.rp2040-ard.yaml index 4fb8d51333..4d26a38b69 100644 --- a/tests/test_build_components/build_components_base.rp2040-ard.yaml +++ b/tests/test_build_components/build_components_base.rp2040-ard.yaml @@ -2,7 +2,7 @@ esphome: name: componenttestrp2040ard friendly_name: $component_name -rp2040: +rp2: board: rpipicow logger: diff --git a/tests/test_build_components/build_components_base.rp2040-pico2-ard.yaml b/tests/test_build_components/build_components_base.rp2350-ard.yaml similarity index 97% rename from tests/test_build_components/build_components_base.rp2040-pico2-ard.yaml rename to tests/test_build_components/build_components_base.rp2350-ard.yaml index 0922a5238e..5df1670862 100644 --- a/tests/test_build_components/build_components_base.rp2040-pico2-ard.yaml +++ b/tests/test_build_components/build_components_base.rp2350-ard.yaml @@ -2,7 +2,7 @@ esphome: name: componenttestrp2040pico2ard friendly_name: $component_name -rp2040: +rp2: board: rpipico2 logger: diff --git a/tests/test_build_components/common/spi/rp2040-pico2-ard.yaml b/tests/test_build_components/common/spi/rp2350-ard.yaml similarity index 100% rename from tests/test_build_components/common/spi/rp2040-pico2-ard.yaml rename to tests/test_build_components/common/spi/rp2350-ard.yaml diff --git a/tests/unit_tests/components/test_rp2.py b/tests/unit_tests/components/test_rp2.py new file mode 100644 index 0000000000..023d926dc4 --- /dev/null +++ b/tests/unit_tests/components/test_rp2.py @@ -0,0 +1,95 @@ +"""Tests for the ``rp2`` target-platform component. + +``rp2`` is the canonical name for the Raspberry Pi RP-series target +platform. ``rp2040`` is a deprecated alias declared via +``ALIASES = ["rp2040"]`` on the rp2 component — the framework +(see ``esphome/loader.py`` and ``esphome/config.py``) handles both +Python-import aliasing (via a ``sys.meta_path`` finder) and YAML-key +aliasing (via a pre-pass in ``validate_config``), so there is no +hand-rolled shim in ``esphome/components/rp2040/``. + +These tests pin down the canonical board helpers; the alias contract +itself (Python imports, YAML key rename, deprecation warning) is covered +by the framework tests under ``tests/unit_tests/``. +""" + + +def test_board_id_has_wifi_for_known_wifi_board() -> None: + """``rpipicow`` is the canonical Pico W → True.""" + from esphome.components import rp2 + + assert rp2.board_id_has_wifi("rpipicow") is True + + +def test_board_id_has_wifi_for_known_non_wifi_board() -> None: + """Plain ``rpipico`` has no CYW43 → False.""" + from esphome.components import rp2 + + assert rp2.board_id_has_wifi("rpipico") is False + + +def test_board_id_has_wifi_for_rp2350_w_variant() -> None: + """``rpipico2w`` is the RP2350 Pico 2 W → True.""" + from esphome.components import rp2 + + assert rp2.board_id_has_wifi("rpipico2w") is True + + +def test_board_id_has_wifi_for_unknown_board_returns_true() -> None: + """Unknown ids fail open so a custom board is not rejected. + + The validator falls back to ESPHome's compile-time check; the + helper returning True here means the wizard emits a ``wifi:`` + block and any genuinely-unsupported config trips the existing + "no CYW43" guard at compile time. + """ + from esphome.components import rp2 + + assert rp2.board_id_has_wifi("not-a-real-board-id") is True + + +def test_rp2_declares_rp2040_as_alias() -> None: + """The framework-level deprecation hook is on the ``rp2`` component. + + The legacy ``rp2040:`` YAML key works because the rp2 component + opts in via ``ALIASES``; without this declaration the rename + framework wouldn't route legacy configs. + """ + from esphome.components import rp2 + + assert "rp2040" in rp2.ALIASES + assert rp2.ALIAS_REMOVAL_VERSION == "2027.7.0" + + +def test_rp2040_python_import_resolves_to_rp2() -> None: + """``from esphome.components import rp2040`` must work for external + custom components and external tooling (device-builder, the dashboard + wizard, etc.) that still import from the legacy module path. + + The ``_AliasFinder`` on ``sys.meta_path`` rewrites the lookup to + the canonical module — both should be the same object. + """ + from esphome.components import ( + rp2, + rp2040, # routed via _AliasFinder + ) + + assert rp2040 is rp2 + + +def test_rp2040_submodule_imports_resolve_to_rp2_submodules() -> None: + """Submodule imports (e.g. ``esphome.components.rp2040.boards``) must + also route to the canonical equivalents — the board-generator script + and the dashboard wizard both rely on this path. + """ + from esphome.components.rp2 import ( + boards as rp2_boards, + generate_boards as rp2_generate, + ) + from esphome.components.rp2040 import ( + boards as rp2040_boards, + generate_boards as rp2040_generate, + ) + + assert rp2040_boards is rp2_boards + assert rp2040_generate is rp2_generate diff --git a/tests/unit_tests/components/test_rp2040.py b/tests/unit_tests/components/test_rp2040.py deleted file mode 100644 index 8e726933ed..0000000000 --- a/tests/unit_tests/components/test_rp2040.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Tests for RP2040 component public helpers and variant detection.""" - -import pytest - -from esphome.components.rp2040 import _detect_variant, board_id_has_wifi -from esphome.components.rp2040.const import VARIANT_RP2040, VARIANT_RP2350 -import esphome.config_validation as cv -from esphome.const import CONF_BOARD, CONF_VARIANT - - -def test_board_id_has_wifi_for_known_wifi_board() -> None: - """``rpipicow`` is the canonical Pico W → True.""" - assert board_id_has_wifi("rpipicow") is True - - -def test_board_id_has_wifi_for_known_non_wifi_board() -> None: - """Plain ``rpipico`` has no CYW43 → False.""" - assert board_id_has_wifi("rpipico") is False - - -def test_board_id_has_wifi_for_rp2350_w_variant() -> None: - """``rpipico2w`` is the RP2350 Pico 2 W → True.""" - assert board_id_has_wifi("rpipico2w") is True - - -def test_board_id_has_wifi_for_unknown_board_returns_true() -> None: - """Unknown ids fail open so a custom board is not rejected. - - The validator falls back to ESPHome's compile-time check; the - helper returning True here means the wizard emits a ``wifi:`` - block and any genuinely-unsupported config trips the existing - "no CYW43" guard at compile time. - """ - assert board_id_has_wifi("not-a-real-board-id") is True - - -def test_detect_variant_derives_variant_from_board() -> None: - """Board alone resolves to the matching variant.""" - result = _detect_variant({CONF_BOARD: "rpipicow"}) - assert result[CONF_BOARD] == "rpipicow" - assert result[CONF_VARIANT] == VARIANT_RP2040 - - -def test_detect_variant_derives_variant_from_rp2350_board() -> None: - """An RP2350 board resolves to ``RP2350``.""" - result = _detect_variant({CONF_BOARD: "rpipico2"}) - assert result[CONF_BOARD] == "rpipico2" - assert result[CONF_VARIANT] == VARIANT_RP2350 - - -def test_detect_variant_only_picks_default_board_rp2040() -> None: - """Variant alone picks Pico W as the canonical RP2040 board.""" - result = _detect_variant({CONF_VARIANT: VARIANT_RP2040}) - assert result[CONF_BOARD] == "rpipicow" - assert result[CONF_VARIANT] == VARIANT_RP2040 - - -def test_detect_variant_only_picks_default_board_rp2350() -> None: - """Variant alone picks Pico 2 W as the canonical RP2350 board.""" - result = _detect_variant({CONF_VARIANT: VARIANT_RP2350}) - assert result[CONF_BOARD] == "rpipico2w" - assert result[CONF_VARIANT] == VARIANT_RP2350 - - -def test_detect_variant_matching_explicit_variant_passes() -> None: - """Specifying both a board and the matching variant is allowed.""" - result = _detect_variant({CONF_BOARD: "rpipico2", CONF_VARIANT: VARIANT_RP2350}) - assert result[CONF_BOARD] == "rpipico2" - assert result[CONF_VARIANT] == VARIANT_RP2350 - - -def test_detect_variant_mismatched_variant_raises() -> None: - """Board/variant mismatch must be rejected and name the offending board.""" - with pytest.raises( - cv.Invalid, match=r"does not match the selected board 'rpipicow'" - ): - _detect_variant({CONF_BOARD: "rpipicow", CONF_VARIANT: VARIANT_RP2350}) - - -def test_detect_variant_unknown_board_without_variant_raises() -> None: - """Unknown board with no variant tells the user how to recover.""" - with pytest.raises(cv.Invalid, match="please specify the chip variant"): - _detect_variant({CONF_BOARD: "not-a-real-board"}) - - -def test_detect_variant_unknown_board_with_variant_passes() -> None: - """Unknown board + explicit variant is accepted (with a warning).""" - result = _detect_variant( - {CONF_BOARD: "not-a-real-board", CONF_VARIANT: VARIANT_RP2040} - ) - assert result[CONF_BOARD] == "not-a-real-board" - assert result[CONF_VARIANT] == VARIANT_RP2040 diff --git a/tests/unit_tests/components/test_rp2040_generate_boards.py b/tests/unit_tests/components/test_rp2_generate_boards.py similarity index 98% rename from tests/unit_tests/components/test_rp2040_generate_boards.py rename to tests/unit_tests/components/test_rp2_generate_boards.py index 551e88f6f6..68bbada59b 100644 --- a/tests/unit_tests/components/test_rp2040_generate_boards.py +++ b/tests/unit_tests/components/test_rp2_generate_boards.py @@ -1,4 +1,4 @@ -"""Tests for rp2040 generate_boards.py.""" +"""Tests for rp2 generate_boards.py.""" from __future__ import annotations @@ -8,7 +8,7 @@ import textwrap import pytest -from esphome.components.rp2040.generate_boards import load_boards, parse_variant_pins +from esphome.components.rp2.generate_boards import load_boards, parse_variant_pins PICO_PINS_HEADER = textwrap.dedent("""\ #pragma once diff --git a/tests/unit_tests/components/test_wifi.py b/tests/unit_tests/components/test_wifi.py index 9598c1bdd8..3899b3d854 100644 --- a/tests/unit_tests/components/test_wifi.py +++ b/tests/unit_tests/components/test_wifi.py @@ -87,8 +87,8 @@ def test_has_native_wifi_esp32_variant_case_insensitive() -> None: def test_has_native_wifi_dispatches_rp2040_to_board_check() -> None: """RP2040 platform routes through ``rp2040.board_id_has_wifi``.""" - assert has_native_wifi(platform=Platform.RP2040, board="rpipicow") is True - assert has_native_wifi(platform=Platform.RP2040, board="rpipico") is False + assert has_native_wifi(platform=Platform.RP2, board="rpipicow") is True + assert has_native_wifi(platform=Platform.RP2, board="rpipico") is False def test_has_native_wifi_returns_false_for_nrf52() -> None: @@ -134,7 +134,7 @@ def test_has_native_wifi_esp32_without_variant_assumes_wifi() -> None: def test_has_native_wifi_rp2040_without_board_assumes_wifi() -> None: """RP2040 without a board id falls open to True (custom-board default).""" - assert has_native_wifi(platform=Platform.RP2040) is True + assert has_native_wifi(platform=Platform.RP2) is True def _wifi_config( diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index ea3a4ecb53..6580564c65 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -39,7 +39,7 @@ from esphome.const import ( PLATFORM_ESP8266, PLATFORM_HOST, PLATFORM_LN882X, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, SCHEDULER_DONT_RUN, TYPE_GIT, @@ -438,7 +438,7 @@ def hex_int__valid(value): ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32C6, "16", "16", "14", "14"), ("arduino", PLATFORM_ESP32, VARIANT_ESP32H2, "18", "17", "18", "17"), ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32H2, "19", "19", "17", "17"), - ("arduino", PLATFORM_RP2040, None, "20", "20", "20", "20"), + ("arduino", PLATFORM_RP2, None, "20", "20", "20", "20"), ("arduino", PLATFORM_BK72XX, None, "21", "21", "21", "21"), ("arduino", PLATFORM_RTL87XX, None, "22", "22", "22", "22"), ("arduino", PLATFORM_LN882X, None, "23", "23", "23", "23"), @@ -469,7 +469,7 @@ def test_split_default(framework, platform, variant, full, idf, arduino, simple) "esp32_c3": "11", "esp32_c6": "14", "esp32_h2": "17", - "rp2040": "20", + "rp2": "20", "bk72xx": "21", "rtl87xx": "22", "ln882x": "23", @@ -517,7 +517,7 @@ def test_split_default(framework, platform, variant, full, idf, arduino, simple) ("arduino", PLATFORM_ESP32, "ESP32 using arduino framework"), ("esp-idf", PLATFORM_ESP32, "ESP32 using esp-idf framework"), ("arduino", PLATFORM_ESP8266, "ESP8266 using arduino framework"), - ("arduino", PLATFORM_RP2040, "RP2040 using arduino framework"), + ("arduino", PLATFORM_RP2, "RP2 using arduino framework"), ("arduino", PLATFORM_BK72XX, "BK72XX using arduino framework"), ("host", PLATFORM_HOST, "HOST using host framework"), ], @@ -540,7 +540,7 @@ def test_require_framework_version(framework, platform, message): esp_idf=cv.Version(0, 5, 0), esp32_arduino=cv.Version(0, 5, 0), esp8266_arduino=cv.Version(0, 5, 0), - rp2040_arduino=cv.Version(0, 5, 0), + rp2_arduino=cv.Version(0, 5, 0), bk72xx_arduino=cv.Version(0, 5, 0), host=cv.Version(0, 5, 0), extra_message="test 1", @@ -556,7 +556,7 @@ def test_require_framework_version(framework, platform, message): esp_idf=cv.Version(2, 0, 0), esp32_arduino=cv.Version(2, 0, 0), esp8266_arduino=cv.Version(2, 0, 0), - rp2040_arduino=cv.Version(2, 0, 0), + rp2_arduino=cv.Version(2, 0, 0), bk72xx_arduino=cv.Version(2, 0, 0), host=cv.Version(2, 0, 0), extra_message="test 2", @@ -567,7 +567,7 @@ def test_require_framework_version(framework, platform, message): esp_idf=cv.Version(1, 5, 0), esp32_arduino=cv.Version(1, 5, 0), esp8266_arduino=cv.Version(1, 5, 0), - rp2040_arduino=cv.Version(1, 5, 0), + rp2_arduino=cv.Version(1, 5, 0), bk72xx_arduino=cv.Version(1, 5, 0), host=cv.Version(1, 5, 0), max_version=True, @@ -584,7 +584,7 @@ def test_require_framework_version(framework, platform, message): esp_idf=cv.Version(0, 5, 0), esp32_arduino=cv.Version(0, 5, 0), esp8266_arduino=cv.Version(0, 5, 0), - rp2040_arduino=cv.Version(0, 5, 0), + rp2_arduino=cv.Version(0, 5, 0), bk72xx_arduino=cv.Version(0, 5, 0), host=cv.Version(0, 5, 0), max_version=True, @@ -599,6 +599,194 @@ def test_require_framework_version(framework, platform, message): )("test") +def _setup_core_for_framework(platform: str, framework: str) -> None: + """Wire CORE.data with the minimum keys for require_framework_version / + SplitDefault to evaluate without raising KeyError.""" + from esphome.const import ( + KEY_CORE, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + ) + + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: platform, + KEY_TARGET_FRAMEWORK: framework, + KEY_FRAMEWORK_VERSION: cv.Version(1, 0, 0), + } + + +def test_only_on_rp2_passes_on_rp2_platform() -> None: + """``cv.only_on_rp2`` is the canonical family gate. It accepts any value + untouched when the configured platform is rp2.""" + _setup_core_for_framework(PLATFORM_RP2, "arduino") + assert cv.only_on_rp2("anything") == "anything" + + +def test_only_on_rp2_rejects_other_platforms() -> None: + """The same gate raises ``Invalid`` outside the rp2 platform.""" + _setup_core_for_framework(PLATFORM_ESP32, "arduino") + with pytest.raises(Invalid, match="rp2"): + cv.only_on_rp2("anything") + + +def test_only_on_rp2040_delegates_and_warns_once(caplog) -> None: + """``cv.only_on_rp2040`` is a deprecation shim — it logs a one-shot + warning, dedupes via CORE.data, and delegates to ``only_on_rp2``. + Repeated calls in the same run must not log again.""" + import logging + + _setup_core_for_framework(PLATFORM_RP2, "arduino") + # Reset the dedupe flag so this test is independent of order. + CORE.data.pop(cv._ONLY_ON_RP2040_DEPRECATED_KEY, None) + + with caplog.at_level(logging.WARNING, logger="esphome.config_validation"): + assert cv.only_on_rp2040("ok") == "ok" + first_warnings = [r for r in caplog.records if "only_on_rp2040" in r.message] + assert len(first_warnings) == 1 + assert "2027.7.0" in first_warnings[0].message + + # Second call dedupes — no additional warning is emitted. + assert cv.only_on_rp2040("ok") == "ok" + warnings_after_second = [ + r for r in caplog.records if "only_on_rp2040" in r.message + ] + assert len(warnings_after_second) == 1 + + +def test_only_on_rp2040_still_gates_on_non_rp2(caplog) -> None: + """The deprecation shim must still raise on non-rp2 platforms — it + delegates to ``only_on_rp2``, so the gating behavior is preserved.""" + import logging + + _setup_core_for_framework(PLATFORM_ESP32, "arduino") + CORE.data.pop(cv._ONLY_ON_RP2040_DEPRECATED_KEY, None) + + with ( + caplog.at_level(logging.WARNING, logger="esphome.config_validation"), + pytest.raises(Invalid, match="rp2"), + ): + cv.only_on_rp2040("anything") + + +def test_require_framework_version_esp32_variant_specific_key() -> None: + """ESP32 variant-specific kwargs (``esp32_c3_arduino``) must win over + the base ``esp32_arduino`` key when the configured variant matches.""" + from esphome.components.esp32 import KEY_ESP32 + from esphome.const import ( + KEY_CORE, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + KEY_VARIANT, + ) + + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: PLATFORM_ESP32, + KEY_TARGET_FRAMEWORK: "arduino", + KEY_FRAMEWORK_VERSION: cv.Version(1, 2, 0), + } + CORE.data[KEY_ESP32] = {KEY_VARIANT: VARIANT_ESP32C3} + + # Variant-specific entry permits this version; base key would reject it. + assert ( + cv.require_framework_version( + esp32_arduino=cv.Version(5, 0, 0), # would reject + esp32_c3_arduino=cv.Version(1, 0, 0), # wins, ok + )("test") + == "test" + ) + + +def test_require_framework_version_rp2_variant_specific_key() -> None: + """RP2 variant kwargs (``rp2_2040_arduino``) must win over the base + ``rp2_arduino`` key when ``CORE.data['rp2']['variant']`` is wired.""" + from esphome.const import ( + KEY_CORE, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + ) + + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: PLATFORM_RP2, + KEY_TARGET_FRAMEWORK: "arduino", + KEY_FRAMEWORK_VERSION: cv.Version(1, 2, 0), + } + CORE.data["rp2"] = {"variant": "RP2040"} + + # Variant key wins — base ``rp2_arduino`` (which would reject) is ignored. + assert ( + cv.require_framework_version( + rp2_arduino=cv.Version(5, 0, 0), # would reject + rp2_2040_arduino=cv.Version(1, 0, 0), # wins, ok + )("test") + == "test" + ) + + # Without a variant kwarg the base ``rp2_arduino`` is used (fallback). + CORE.data["rp2"] = {"variant": "RP2350"} + assert ( + cv.require_framework_version( + rp2_arduino=cv.Version(1, 0, 0), + )("test") + == "test" + ) + + +def test_split_default_rp2_variant_keys() -> None: + """``SplitDefault`` resolves ``rp2__`` first, falling + back to ``rp2_`` and ``rp2_`` before the base key.""" + from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM + + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: PLATFORM_RP2, + KEY_TARGET_FRAMEWORK: "arduino", + } + CORE.data["rp2"] = {"variant": "RP2040"} + + schema = cv.Schema( + { + cv.SplitDefault( + "full", + rp2="base", + rp2_arduino="base-framework", + rp2_2040="variant-only", + rp2_2040_arduino="variant-framework", + ): str, + } + ) + # Most specific (variant + framework) wins. + assert schema({}).get("full") == "variant-framework" + + # Drop the most-specific kwarg → variant-only wins. + schema = cv.Schema( + { + cv.SplitDefault( + "full", + rp2="base", + rp2_arduino="base-framework", + rp2_2040="variant-only", + ): str, + } + ) + assert schema({}).get("full") == "variant-only" + + # RP2350 variant — no rp2_2350_* kwargs → fall through to base framework. + CORE.data["rp2"] = {"variant": "RP2350"} + schema = cv.Schema( + { + cv.SplitDefault( + "full", + rp2="base", + rp2_arduino="base-framework", + rp2_2040="not-this", + ): str, + } + ) + assert schema({}).get("full") == "base-framework" + + def test_only_with_single_component_loaded() -> None: """Test OnlyWith with single component when component is loaded.""" CORE.loaded_integrations = {"mqtt"} diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index a61b6ae7ae..0cb0c1f62d 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -591,6 +591,36 @@ class TestEsphomeCore: assert target.is_esp32 is False assert target.is_esp8266 is True + def test_is_rp2(self, target): + """The canonical RP2 family gate flips on for the rp2 platform.""" + target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "rp2"} + + assert target.is_rp2 is True + assert target.is_esp32 is False + assert target.is_esp8266 is False + + def test_is_rp2040_deprecated_alias_matches_is_rp2(self, target, caplog): + """``is_rp2040`` is kept as a deprecation shim that returns whatever + ``is_rp2`` returns; both must agree across platform values. A + one-shot deprecation warning is emitted on first access and + deduped via ``CORE.data`` for the rest of the run.""" + import logging + + target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "rp2"} + with caplog.at_level(logging.WARNING, logger="esphome.core"): + assert target.is_rp2040 is True + assert target.is_rp2040 == target.is_rp2 + + warnings = [r for r in caplog.records if "is_rp2040" in r.message] + assert len(warnings) == 1 + assert "2027.7.0" in warnings[0].message + + # Reset the dedupe so the False-platform branch also runs the shim. + target.data.pop("_core_is_rp2040_deprecated_warned", None) + target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "esp32"} + assert target.is_rp2040 is False + assert target.is_rp2040 == target.is_rp2 + def test_firmware_bin__default(self, target): """Default platforms produce //firmware.bin.""" target.name = "test-device" diff --git a/tests/unit_tests/test_loader.py b/tests/unit_tests/test_loader.py index 42e5203a73..41dd462678 100644 --- a/tests/unit_tests/test_loader.py +++ b/tests/unit_tests/test_loader.py @@ -1,19 +1,13 @@ """Unit tests for esphome.loader module.""" import ast -import logging from pathlib import Path import sys import textwrap -from types import ModuleType -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, patch import pytest -import voluptuous as vol -from esphome import config as esphome_config, config_validation as cv -from esphome.core import CORE -import esphome.loader as loader_mod from esphome.loader import ( AliasMeta, ComponentManifest, @@ -21,6 +15,7 @@ from esphome.loader import ( _build_alias_map, _read_aliases, _replace_component_manifest, + get_alias_metadata, get_component, ) from tests.testing_helpers import ComponentManifestOverride @@ -348,17 +343,12 @@ def test_component_manifest_resources_recursive_filter_source_files_supports_sub # Component aliases (renamed-platform back-compat) # --------------------------------------------------------------------------- # -# These tests pin down the substrate behind `ALIASES = [...]` on component -# `__init__.py` files: the AST scanner, the resulting global alias map, the -# Python-import `sys.meta_path` finder, the `get_component` integration, and -# the YAML pre-pass that rewrites legacy top-level keys. -# -# The framework is component-agnostic, so the integration tests inject a -# synthetic alias map (pointing a fake legacy name at the real `esp32` -# component) rather than depending on any specific renamed component. - -# A legacy name that is NOT a real component, used as a synthetic alias. -_FAKE_ALIAS = "esp32_legacy_alias" +# The framework here is the substrate behind `ALIASES = [...]` on component +# `__init__.py` files. These tests pin down the AST scanner, the resulting +# global alias map, the Python-import `sys.meta_path` finder, and the +# integration with `get_component`. The rp2 → rp2040 actual mapping in this +# repo is used as a real-world fixture; other cases use temp dirs / mocks so +# the framework's behavior is testable in isolation. def _write_component(root: Path, name: str, body: str) -> None: @@ -383,12 +373,12 @@ def test_read_aliases_extracts_removal_version(tmp_path: Path) -> None: init.write_text( textwrap.dedent("""\ ALIASES = ['old'] - ALIAS_REMOVAL_VERSION = "2027.6.0" + ALIAS_REMOVAL_VERSION = "2027.7.0" """) ) aliases, removal = _read_aliases(init, ast) assert aliases == ["old"] - assert removal == "2027.6.0" + assert removal == "2027.7.0" def test_read_aliases_skips_dynamic_forms(tmp_path: Path) -> None: @@ -409,28 +399,19 @@ def test_read_aliases_returns_empty_for_missing_declaration(tmp_path: Path) -> N assert removal is None -def test_read_aliases_handles_syntax_error( - tmp_path: Path, caplog: pytest.LogCaptureFixture -) -> None: +def test_read_aliases_handles_syntax_error(tmp_path: Path) -> None: """A broken __init__.py shouldn't crash the alias scanner — it'll - surface as an ImportError elsewhere, but the scanner logs a warning and - yields nothing so other components keep working. The substring pre-filter - only skips files with no ``ALIASES`` token, so this file (which has one) - still reaches the parse.""" + surface as an ImportError elsewhere, but the scanner just yields + nothing so other components keep working. + + The source must contain the substring ``ALIASES`` so the scanner + actually attempts to parse the file; otherwise the early-return + optimization would short-circuit before reaching the parser and + this test would not exercise the syntax-error branch. + """ init = tmp_path / "__init__.py" - init.write_text("ALIASES = ['x']\ndef broken( :\n") + init.write_text("ALIASES = ['oops'\ndef broken( :\n") assert _read_aliases(init, ast) == ([], None) - assert "Could not parse" in caplog.text - - -def test_read_aliases_handles_read_error( - tmp_path: Path, caplog: pytest.LogCaptureFixture -) -> None: - """An unreadable __init__.py logs a warning and yields nothing rather - than aborting the whole component scan.""" - missing = tmp_path / "nope" / "__init__.py" - assert _read_aliases(missing, ast) == ([], None) - assert "Could not read" in caplog.text def test_build_alias_map_aggregates_components(tmp_path: Path) -> None: @@ -480,96 +461,64 @@ def test_build_alias_map_handles_missing_dir(tmp_path: Path) -> None: but possible in some test contexts), we want an empty map rather than a crash — the rest of the loader can still function.""" fake = tmp_path / "does-not-exist" + assert not fake.exists() with patch("esphome.loader.CORE_COMPONENTS_PATH", fake): alias_map, meta_map = _build_alias_map() assert alias_map == {} assert meta_map == {} -def test_build_alias_map_rejects_alias_shadowing_component(tmp_path: Path) -> None: - """An alias that names an existing component package is refused: it would - hijack a live domain, and a self-alias (alias == canonical) would send - ``_lookup_module`` into infinite recursion.""" - # `newcomp` declares itself as an alias — its own package already exists. - _write_component(tmp_path, "newcomp", "ALIASES = ['newcomp']\n") - - from esphome.core import EsphomeError - - with ( - patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path), - pytest.raises(EsphomeError, match="shadows an existing component"), - ): - _build_alias_map() +# ---- Live integration against the real rp2/rp2040 mapping in this repo ---- -# ---- Integration against a synthetic alias map (fake legacy -> esp32) ---- +def test_real_alias_map_includes_rp2040() -> None: + """The rp2 component declares ``ALIASES = ['rp2040']`` in this repo; + the live alias map should surface it. This guards against future + refactors silently dropping the declaration.""" + meta = get_alias_metadata() + assert "rp2040" in meta + assert meta["rp2040"].canonical == "rp2" + assert meta["rp2040"].removal_version == "2027.7.0" -def _patch_alias_map(monkeypatch: pytest.MonkeyPatch, mapping: dict[str, str]) -> None: - """Force the loader's alias map (used by the finder and get_component). - - Patches the lazily-built caches so both ``_get_alias_map`` and the - installed meta-path finder resolve against ``mapping`` regardless of - what the real on-disk scan would produce. - """ - monkeypatch.setattr("esphome.loader._get_alias_map", lambda: mapping) - - -def test_get_component_resolves_alias(monkeypatch: pytest.MonkeyPatch) -> None: - """``get_component()`` should return the canonical manifest — every +def test_get_component_resolves_alias() -> None: + """``get_component('rp2040')`` should return the rp2 manifest — every caller of the loader (dep checker, schema validator, codegen) hits the canonical component without knowing about the alias.""" - import esphome.loader as loader_mod - - _patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"}) - loader_mod._COMPONENT_CACHE.pop(_FAKE_ALIAS, None) - - canonical = get_component("esp32") - aliased = get_component(_FAKE_ALIAS) - assert canonical is not None - assert aliased is canonical + rp2 = get_component("rp2") + rp2040 = get_component("rp2040") + assert rp2 is not None + assert rp2040 is rp2 -def test_alias_finder_resolves_top_level_import( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """``import esphome.components.`` resolves to the canonical - module via the meta-path finder. ``_FAKE_ALIAS`` == ``esp32_legacy_alias``.""" - _patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"}) - sys.modules.pop(f"esphome.components.{_FAKE_ALIAS}", None) - +def test_alias_finder_resolves_top_level_import() -> None: + """``import esphome.components.rp2040`` resolves to the canonical + module via the meta-path finder.""" + # Remove any cached entry so we exercise the finder, not sys.modules cache. + sys.modules.pop("esphome.components.rp2040", None) finder = _AliasFinder() - spec = finder.find_spec(f"esphome.components.{_FAKE_ALIAS}", None) + spec = finder.find_spec("esphome.components.rp2040", None) assert spec is not None - import esphome.components.esp32 - import esphome.components.esp32_legacy_alias + import esphome.components.rp2 + import esphome.components.rp2040 - assert esphome.components.esp32_legacy_alias is esphome.components.esp32 + assert esphome.components.rp2040 is esphome.components.rp2 -def test_alias_finder_resolves_submodule_import( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """``from esphome.components. import boards`` routes through to - ``esphome.components.esp32.boards`` — same submodule object on both paths. - - The canonical submodule is imported first so its parent module carries - the ``boards`` attribute; ``from import boards`` then resolves - the aliased parent (via the finder) and reads that same attribute, - rather than triggering a fresh file load under the alias name. - ``_FAKE_ALIAS`` == ``esp32_legacy_alias``.""" - _patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"}) - sys.modules.pop(f"esphome.components.{_FAKE_ALIAS}", None) - +def test_alias_finder_resolves_submodule_import() -> None: + """``from esphome.components.rp2040 import boards`` routes through to + ``esphome.components.rp2.boards`` — same submodule object on both + paths.""" + sys.modules.pop("esphome.components.rp2040.boards", None) finder = _AliasFinder() - spec = finder.find_spec(f"esphome.components.{_FAKE_ALIAS}.boards", None) + spec = finder.find_spec("esphome.components.rp2040.boards", None) assert spec is not None - from esphome.components.esp32 import boards as canonical_boards - from esphome.components.esp32_legacy_alias import boards as aliased_boards + from esphome.components.rp2 import boards as rp2_boards + from esphome.components.rp2040 import boards as rp2040_boards - assert aliased_boards is canonical_boards + assert rp2040_boards is rp2_boards def test_alias_finder_ignores_non_components_path() -> None: @@ -581,9 +530,6 @@ def test_alias_finder_ignores_non_components_path() -> None: assert finder.find_spec("os.path", None) is None # `esphome.components` itself (no domain segment) is not a candidate. assert finder.find_spec("esphome.components", None) is None - # A real, non-aliased component domain defers to normal import machinery - # (no component declares an alias in this repo, so the live map is empty). - assert finder.find_spec("esphome.components.logger", None) is None # --------------------------------------------------------------------------- @@ -593,391 +539,121 @@ def test_alias_finder_ignores_non_components_path() -> None: # The companion to the loader-side alias map: ``esphome.config`` runs a # pre-pass over the user's parsed YAML that rewrites legacy top-level keys # to their canonical names, surfacing a one-shot deprecation warning. These -# tests inject a synthetic alias-metadata map so the rewrite behavior, the -# warning text, and the both-keys-present conflict can be tested in isolation. - - -def _patch_alias_metadata( - monkeypatch: pytest.MonkeyPatch, mapping: dict[str, AliasMeta] -) -> None: - monkeypatch.setattr("esphome.loader.get_alias_metadata", lambda: mapping) +# tests pin down the rewrite behavior, the warning text, and the +# both-keys-present conflict. def test_resolve_component_aliases_renames_legacy_key( - monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + caplog: pytest.LogCaptureFixture, ) -> None: - """A legacy alias key should be renamed to the canonical key and a - deprecation warning citing the removal version logged.""" + """A legacy alias key ``rp2040:`` should be renamed to the canonical + ``rp2:`` and a deprecation warning citing the removal version logged.""" + import logging + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases from esphome.core import CORE - _patch_alias_metadata( - monkeypatch, - {"oldcomp": AliasMeta(canonical="newcomp", removal_version="2027.6.0")}, - ) CORE.data.pop(_ALIAS_WARNED_KEY, None) # ensure the warning fires - config = {"esphome": {"name": "test"}, "oldcomp": {"board": "x"}} + config = {"esphome": {"name": "test"}, "rp2040": {"board": "rpipicow"}} with caplog.at_level(logging.WARNING, logger="esphome.config"): _resolve_component_aliases(config) - assert "oldcomp" not in config - assert config["newcomp"] == {"board": "x"} + assert "rp2040" not in config + assert config["rp2"] == {"board": "rpipicow"} assert any( - "'oldcomp:' top-level key is deprecated" in record.message - and "rename it to 'newcomp:'" in record.message - and "2027.6.0" in record.message + "'rp2040:' top-level key is deprecated" in record.message + and "rename it to 'rp2:'" in record.message + and "2027.7.0" in record.message for record in caplog.records ) def test_resolve_component_aliases_dedupes_warning_within_a_run( - monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + caplog: pytest.LogCaptureFixture, ) -> None: """Schema validators can run twice (auto-load discovery + final pass) so the rename pass must emit the warning only once per alias per run. Deduped via ``CORE.data``; cleared between runs.""" + import logging + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases from esphome.core import CORE - _patch_alias_metadata( - monkeypatch, - {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, - ) CORE.data.pop(_ALIAS_WARNED_KEY, None) with caplog.at_level(logging.WARNING, logger="esphome.config"): - _resolve_component_aliases({"oldcomp": {"board": "a"}}) - _resolve_component_aliases({"oldcomp": {"board": "b"}}) + _resolve_component_aliases({"rp2040": {"board": "rpipicow"}}) + _resolve_component_aliases({"rp2040": {"board": "rpipico2w"}}) matches = [ r for r in caplog.records - if "'oldcomp:' top-level key is deprecated" in r.message + if "'rp2040:' top-level key is deprecated" in r.message ] assert len(matches) == 1 -def test_resolve_component_aliases_rejects_both_keys_present( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_resolve_component_aliases_rejects_both_keys_present() -> None: """If the user has BOTH legacy and canonical keys, silently dropping one would hide a real misconfiguration. Raise instead.""" + import voluptuous as vol + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases from esphome.core import CORE - _patch_alias_metadata( - monkeypatch, - {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, - ) CORE.data.pop(_ALIAS_WARNED_KEY, None) - config = {"newcomp": {"board": "x"}, "oldcomp": {"board": "x"}} - with pytest.raises(vol.Invalid, match="Both 'oldcomp:'"): + config = { + "rp2": {"board": "rpipicow"}, + "rp2040": {"board": "rpipicow"}, + } + with pytest.raises(vol.Invalid, match="Both 'rp2040:'"): _resolve_component_aliases(config) -def test_resolve_component_aliases_rejects_canonical_key_after_legacy( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The both-keys conflict must be detected even when the canonical key - appears *after* the legacy key in the config (the up-front conflict - scan, not a position-dependent check).""" - from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases - from esphome.core import CORE - - _patch_alias_metadata( - monkeypatch, - {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, - ) - CORE.data.pop(_ALIAS_WARNED_KEY, None) - config = {"oldcomp": {"board": "x"}, "newcomp": {"board": "x"}} - with pytest.raises(vol.Invalid, match="Both 'oldcomp:'"): - _resolve_component_aliases(config) - - -def test_resolve_component_aliases_rejects_multiple_aliases_of_one_component( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Two different deprecated aliases of the same canonical component is - ambiguous — silently keeping one would hide a misconfiguration.""" - from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases - from esphome.core import CORE - - _patch_alias_metadata( - monkeypatch, - { - "oldcomp": AliasMeta(canonical="newcomp", removal_version=None), - "legacycomp": AliasMeta(canonical="newcomp", removal_version=None), - }, - ) - CORE.data.pop(_ALIAS_WARNED_KEY, None) - config = {"oldcomp": {"board": "x"}, "legacycomp": {"board": "y"}} - with pytest.raises(vol.Invalid, match=r"Multiple deprecated aliases of 'newcomp:'"): - _resolve_component_aliases(config) - - -def test_resolve_component_aliases_preserves_key_position( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The renamed canonical key keeps the legacy key's original position - rather than being moved to the end of the config.""" - from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases - from esphome.core import CORE - - _patch_alias_metadata( - monkeypatch, - {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, - ) - CORE.data.pop(_ALIAS_WARNED_KEY, None) - config = {"esphome": {"name": "t"}, "oldcomp": {"board": "x"}, "logger": {}} - - _resolve_component_aliases(config) - - assert list(config) == ["esphome", "newcomp", "logger"] - - -def test_resolve_component_aliases_no_op_when_no_legacy_keys( - monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture -) -> None: +def test_resolve_component_aliases_no_op_when_no_legacy_keys() -> None: """The pre-pass must be a no-op (no warning, no mutation) for configs that already use canonical keys.""" + import logging + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases from esphome.core import CORE - _patch_alias_metadata( - monkeypatch, - {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, - ) CORE.data.pop(_ALIAS_WARNED_KEY, None) - config = {"esphome": {"name": "test"}, "newcomp": {"board": "x"}} + config = {"esphome": {"name": "test"}, "rp2": {"board": "rpipicow"}} original = dict(config) - with caplog.at_level(logging.WARNING, logger="esphome.config"): + with caplog_at_warning() as records: _resolve_component_aliases(config) assert config == original - assert not any("deprecated" in r.message for r in caplog.records) + assert not any("deprecated" in r.message for r in records) + _ = logging # silence unused-import in branches that don't read records -# --------------------------------------------------------------------------- -# ComponentManifest alias properties -# --------------------------------------------------------------------------- +# Helper context manager — small enough to inline rather than pull in +# caplog for the simple "did anything warn?" case above. +import contextlib # noqa: E402 -def test_component_manifest_alias_properties_default_empty() -> None: - """``aliases`` / ``alias_removal_version`` fall back to ``[]`` / ``None`` - when the component module declares neither. +@contextlib.contextmanager +def caplog_at_warning(): + """Minimal in-test caplog substitute: collect WARNING records on a + dedicated handler attached to ``esphome.config``.""" + import logging - Uses a real ``ModuleType`` rather than a ``MagicMock`` so that the - ``getattr(..., default)`` fallback is actually exercised — a bare mock - auto-creates any attribute on access and would never hit the default.""" - mod = ModuleType("fake_component") - manifest = ComponentManifest(mod) - assert manifest.aliases == [] - assert manifest.alias_removal_version is None + logger = logging.getLogger("esphome.config") + records: list[logging.LogRecord] = [] + class _Handler(logging.Handler): + def emit(self, record): # noqa: D401 + records.append(record) -def test_component_manifest_alias_properties_read_module_values() -> None: - """The properties surface the module's declared values verbatim.""" - mod = MagicMock() - mod.ALIASES = ["legacy"] - mod.ALIAS_REMOVAL_VERSION = "2027.6.0" - manifest = ComponentManifest(mod) - assert manifest.aliases == ["legacy"] - assert manifest.alias_removal_version == "2027.6.0" - - -# --------------------------------------------------------------------------- -# Real (unpatched) lazy build + cache and remaining scanner branches -# --------------------------------------------------------------------------- - - -def test_get_alias_map_real_build_and_caches(monkeypatch: pytest.MonkeyPatch) -> None: - """Exercise the real lazy build over the actual components dir (no patch): - the first call scans and caches, the second returns the cached object.""" - monkeypatch.setattr(loader_mod, "_ALIAS_MAP_CACHE", None) - first = loader_mod._get_alias_map() - second = loader_mod._get_alias_map() - assert isinstance(first, dict) - assert first is second # cached, not rebuilt on the second call - - -def test_get_alias_metadata_real_build_and_caches( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr(loader_mod, "_ALIAS_META_CACHE", None) - first = loader_mod.get_alias_metadata() - second = loader_mod.get_alias_metadata() - assert isinstance(first, dict) - assert first is second - - -def test_build_alias_map_skips_files_and_initless_dirs(tmp_path: Path) -> None: - """Loose files and directories without an ``__init__.py`` are ignored; - only real component packages contribute to the map.""" - (tmp_path / "loose_file.py").write_text("ALIASES = ['ignored']\n") - (tmp_path / "initless").mkdir() # a dir, but no __init__.py - _write_component(tmp_path, "realcomp", "ALIASES = ['legacy']\n") - - with patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path): - alias_map, _ = _build_alias_map() - - assert alias_map == {"legacy": "realcomp"} - - -def test_read_aliases_ignores_non_assignment_and_complex_targets( - tmp_path: Path, -) -> None: - """Non-assignment statements and assignments to non-Name targets are - skipped; only simple ``NAME = ...`` assignments are read.""" - init = tmp_path / "__init__.py" - init.write_text( - "import os\n" # non-Assign (Import) node -> skipped - "obj.attr = 'v'\n" # Assign with an Attribute target -> skipped - "ALIASES = ['legacy']\n" - ) - aliases, _ = _read_aliases(init, ast) - assert aliases == ["legacy"] - - -# --------------------------------------------------------------------------- -# Finder / loader edge branches -# --------------------------------------------------------------------------- - - -def test_alias_finder_returns_none_when_canonical_missing( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """If an alias points at a canonical *target* that doesn't exist, the - finder declines (returns None) and lets normal import machinery report - the missing module.""" - _patch_alias_map(monkeypatch, {"broken_alias": "definitely_not_a_real_component"}) - finder = _AliasFinder() - assert finder.find_spec("esphome.components.broken_alias", None) is None - - -def test_alias_finder_reraises_when_canonical_dependency_missing( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """If the canonical module exists but fails to import one of its own - dependencies, the finder surfaces that real error instead of masking it - as an unresolved alias (which would silently fall through to a confusing - 'no module named ').""" - _patch_alias_map(monkeypatch, {"some_alias": "real_canonical"}) - - def boom(name: str) -> None: - raise ModuleNotFoundError("No module named 'missing_dep'", name="missing_dep") - - monkeypatch.setattr("esphome.loader.importlib.import_module", boom) - finder = _AliasFinder() - with pytest.raises(ModuleNotFoundError, match="missing_dep"): - finder.find_spec("esphome.components.some_alias", None) - - -def test_install_alias_finder_is_idempotent() -> None: - """The finder is installed once at import; calling the installer again is - a no-op (no duplicate ``_AliasFinder`` on ``sys.meta_path``).""" - before = [e for e in sys.meta_path if isinstance(e, _AliasFinder)] - assert len(before) == 1 # installed at module import time - loader_mod._install_alias_finder() - after = [e for e in sys.meta_path if isinstance(e, _AliasFinder)] - assert len(after) == 1 - - -def test_get_component_alias_to_missing_canonical_returns_none( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """If an alias resolves to a canonical component that can't be loaded, - ``get_component`` returns None and caches no bogus manifest.""" - _patch_alias_map(monkeypatch, {"ghost_alias": "definitely_not_a_real_component"}) - loader_mod._COMPONENT_CACHE.pop("ghost_alias", None) - - assert get_component("ghost_alias") is None - assert "ghost_alias" not in loader_mod._COMPONENT_CACHE - - -# --------------------------------------------------------------------------- -# YAML pre-pass: empty-map fast path + validate_config integration -# --------------------------------------------------------------------------- - - -def test_resolve_component_aliases_noop_when_no_aliases_declared( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """When no component declares an alias, the pre-pass returns immediately - without inspecting or mutating the config.""" - from esphome.config import _resolve_component_aliases - - monkeypatch.setattr("esphome.loader.get_alias_metadata", dict) # empty map - config = {"esphome": {"name": "t"}, "rp2040": {"board": "x"}} - original = dict(config) - _resolve_component_aliases(config) - assert config == original - - -def _default_component_mock() -> Mock: - """A permissive component mock that validates any config (ALLOW_EXTRA).""" - return Mock( - auto_load=[], - is_platform_component=False, - is_platform=False, - multi_conf=False, - multi_conf_no_default=False, - dependencies=[], - conflicts_with=[], - config_schema=cv.Schema({}, extra=cv.ALLOW_EXTRA), - ) - - -@pytest.mark.usefixtures("setup_core") -def test_validate_config_renames_alias_key( - mock_get_component: Mock, monkeypatch: pytest.MonkeyPatch -) -> None: - """End-to-end: a legacy top-level key is renamed to its canonical name - before the rest of ``validate_config`` runs, and validation succeeds. - - A real ``esp32`` target platform is included so ``preload_core_config`` - is satisfied and validation runs to completion (the renamed canonical - key is loaded via the mocked, permissive component).""" - mock_get_component.side_effect = lambda name: _default_component_mock() - monkeypatch.setattr( - "esphome.loader.get_alias_metadata", - lambda: { - "legacyfoo": AliasMeta(canonical="newcomp", removal_version="2027.6.0") - }, - ) - CORE.data.pop("_component_aliases_warned", None) - - raw_config = { - "esphome": {"name": "test"}, - "esp32": {"board": "esp32dev"}, - "legacyfoo": {"opt": 1}, - } - result = esphome_config.validate_config(raw_config, {}) - - assert not result.errors, f"unexpected errors: {result.errors}" - assert "newcomp" in result - assert "legacyfoo" not in result - - -@pytest.mark.usefixtures("setup_core") -def test_validate_config_reports_alias_conflict_as_error( - mock_get_component: Mock, monkeypatch: pytest.MonkeyPatch -) -> None: - """If both the legacy and canonical keys are present, ``validate_config`` - surfaces the conflict as a config error (the ``vol.Invalid`` path).""" - mock_get_component.return_value = _default_component_mock() - monkeypatch.setattr( - "esphome.loader.get_alias_metadata", - lambda: {"legacyfoo": AliasMeta(canonical="newcomp", removal_version=None)}, - ) - CORE.data.pop("_component_aliases_warned", None) - - raw_config = { - "esphome": {"name": "test"}, - "newcomp": {"opt": 1}, - "legacyfoo": {"opt": 2}, - } - result = esphome_config.validate_config(raw_config, {}) - - assert result.errors - assert "Both 'legacyfoo:'" in str(result.errors) + handler = _Handler(level=logging.WARNING) + logger.addHandler(handler) + prev_level = logger.level + logger.setLevel(logging.WARNING) + try: + yield records + finally: + logger.removeHandler(handler) + logger.setLevel(prev_level) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 65bf4a583e..0442c1db16 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -94,7 +94,7 @@ from esphome.const import ( PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, - PLATFORM_RP2040, + PLATFORM_RP2, Toolchain, ) from esphome.core import CORE, EsphomeError @@ -1226,7 +1226,7 @@ def test_choose_upload_log_host_no_defaults_with_rp2040_bootsel( mock_choose_prompt: Mock, ) -> None: """Test interactive mode shows RP2040 BOOTSEL option via picotool.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) with ( patch( @@ -1249,7 +1249,7 @@ def test_choose_upload_log_host_no_defaults_with_rp2040_bootsel( @pytest.mark.usefixtures("mock_no_serial_ports") def test_choose_upload_log_host_rp2040_no_device_shows_bootsel_help() -> None: """Test BOOTSEL instructions shown when no RP2040 device found.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) with ( patch( @@ -1271,7 +1271,7 @@ def test_choose_upload_log_host_rp2040_bootsel_tip_with_ota( ) -> None: """Test BOOTSEL tip shown when only OTA options exist for RP2040.""" setup_core( - platform=PLATFORM_RP2040, + platform=PLATFORM_RP2, config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100", ) @@ -1300,7 +1300,7 @@ def test_choose_upload_log_host_rp2040_bootsel_tip_with_serial_ports( mock_choose_prompt: Mock, ) -> None: """Test BOOTSEL tip shown when serial ports exist but no BOOTSEL device.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) mock_ports = [MockSerialPort("/dev/ttyACM0", "RP2040 Serial")] with ( @@ -1325,7 +1325,7 @@ def test_choose_upload_log_host_rp2040_permission_error_no_options( caplog: pytest.LogCaptureFixture, ) -> None: """Test permission warning shown when BOOTSEL device found but not accessible.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) with ( patch( @@ -1355,7 +1355,7 @@ def test_choose_upload_log_host_rp2040_permission_error_with_ota( ) -> None: """Test permission warning shown with OTA fallback available.""" setup_core( - platform=PLATFORM_RP2040, + platform=PLATFORM_RP2, config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100", ) @@ -1412,7 +1412,7 @@ def test_choose_upload_log_host_rp2040_serial_and_bootsel( mock_choose_prompt: Mock, ) -> None: """Test both serial ports and BOOTSEL option shown for RP2040.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) mock_ports = [MockSerialPort("/dev/ttyACM0", "RP2040 Serial")] with ( @@ -1665,7 +1665,7 @@ def test_upload_using_esptool_with_file_path( @pytest.mark.parametrize( "platform,device", [ - (PLATFORM_RP2040, "/dev/ttyACM0"), + (PLATFORM_RP2, "/dev/ttyACM0"), (PLATFORM_BK72XX, "/dev/ttyUSB0"), # LibreTiny platform ], ) @@ -1720,7 +1720,7 @@ def test_upload_using_platformio_creates_signed_bin_for_rp2040( tmp_path: Path, ) -> None: """Test that upload_using_platformio creates firmware.bin.signed for RP2040.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) build_dir = tmp_path / "build" build_dir.mkdir() @@ -1756,6 +1756,53 @@ def test_upload_using_platformio_skips_signed_bin_for_non_rp2040( assert result == 0 +def test_upload_using_platformio_skips_signed_bin_when_already_present( + tmp_path: Path, +) -> None: + """The signed-bin copy is idempotent: if ``firmware.bin.signed`` already + exists on the RP2 build path, the upload step must not overwrite it + (and must not fail when the unsigned ``firmware.bin`` is absent).""" + setup_core(platform=PLATFORM_RP2) + + build_dir = tmp_path / "build" + build_dir.mkdir() + # Pre-existing signed bin with distinct content — must be preserved. + signed_bin = build_dir / "firmware.bin.signed" + signed_bin.write_bytes(b"already signed") + # No unsigned firmware.bin on disk — the `is_file()` guard must hold. + firmware_elf = build_dir / "firmware.elf" + firmware_elf.write_bytes(b"elf") + + mock_idedata = MagicMock() + mock_idedata.firmware_elf_path = str(firmware_elf) + + with ( + patch("esphome.platformio.toolchain.get_idedata", return_value=mock_idedata), + patch("esphome.platformio.toolchain.run_platformio_cli_run", return_value=0), + ): + result = upload_using_platformio({}, "/dev/ttyACM0") + + assert result == 0 + # Pre-existing signed bin is untouched. + assert signed_bin.read_bytes() == b"already signed" + + +def test_upload_using_platformio_handles_port_none(tmp_path: Path) -> None: + """The upload step must work without a serial port (PlatformIO picks the + target itself); the ``--upload-port`` flag is only appended when a port + is provided.""" + setup_core(platform=PLATFORM_ESP32) + + with patch( + "esphome.platformio.toolchain.run_platformio_cli_run", return_value=0 + ) as mock_run: + result = upload_using_platformio({}, None) + + assert result == 0 + args = mock_run.call_args.args + assert "--upload-port" not in args + + def test_upload_program_serial_upload_failed( mock_upload_using_esptool: Mock, mock_get_port_type: Mock, @@ -1783,7 +1830,7 @@ def test_upload_program_bootsel( mock_get_port_type: Mock, ) -> None: """Test upload_program with BOOTSEL for RP2040.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) mock_get_port_type.return_value = "BOOTSEL" mock_upload_using_picotool.return_value = 0 @@ -1804,7 +1851,7 @@ def test_upload_program_bootsel_failed( mock_get_port_type: Mock, ) -> None: """Test upload_program when BOOTSEL upload fails.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) mock_get_port_type.return_value = "BOOTSEL" mock_upload_using_picotool.return_value = 1 @@ -1821,7 +1868,7 @@ def test_upload_program_bootsel_failed( def test_upload_using_picotool_success(tmp_path: Path) -> None: """Test upload_using_picotool succeeds.""" - setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path) + setup_core(platform=PLATFORM_RP2, tmp_path=tmp_path) build_dir = tmp_path / "build" build_dir.mkdir() @@ -1858,7 +1905,7 @@ def test_upload_using_picotool_success(tmp_path: Path) -> None: def test_upload_using_picotool_no_elf(tmp_path: Path) -> None: """Test upload_using_picotool when ELF file is missing.""" - setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path) + setup_core(platform=PLATFORM_RP2, tmp_path=tmp_path) build_dir = tmp_path / "build" build_dir.mkdir() @@ -1876,7 +1923,7 @@ def test_upload_using_picotool_no_elf(tmp_path: Path) -> None: def test_upload_using_picotool_not_found(tmp_path: Path) -> None: """Test upload_using_picotool when picotool binary not found.""" - setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path) + setup_core(platform=PLATFORM_RP2, tmp_path=tmp_path) build_dir = tmp_path / "build" build_dir.mkdir() @@ -1896,7 +1943,7 @@ def test_upload_using_picotool_not_found(tmp_path: Path) -> None: def test_upload_using_picotool_permission_error(tmp_path: Path) -> None: """Test upload_using_picotool shows helpful message on permission error.""" - setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path) + setup_core(platform=PLATFORM_RP2, tmp_path=tmp_path) build_dir = tmp_path / "build" build_dir.mkdir() @@ -6411,7 +6458,7 @@ def test_command_run_rp2040_bootsel_redetects_serial_port() -> None: picks up the newly enumerated serial port before showing logs.""" setup_core( config={"logger": {}, CONF_API: {}, CONF_MDNS: {CONF_DISABLED: False}}, - platform=PLATFORM_RP2040, + platform=PLATFORM_RP2, ) args = MockArgs() diff --git a/tests/unit_tests/test_wizard.py b/tests/unit_tests/test_wizard.py index 0ce89230d8..244e4eb5a1 100644 --- a/tests/unit_tests/test_wizard.py +++ b/tests/unit_tests/test_wizard.py @@ -11,6 +11,7 @@ from esphome.components.bk72xx.boards import BK72XX_BOARD_PINS from esphome.components.esp32.boards import ESP32_BOARD_PINS from esphome.components.esp8266.boards import ESP8266_BOARD_PINS from esphome.components.ln882x.boards import LN882X_BOARD_PINS +from esphome.components.rp2.boards import RP2_BOARD_PINS from esphome.components.rtl87xx.boards import RTL87XX_BOARD_PINS from esphome.core import CORE import esphome.wizard as wz @@ -300,6 +301,31 @@ def test_wizard_write_defaults_platform_from_board_rtl87xx( assert "rtl87xx:" in generated_config +def test_wizard_write_defaults_platform_from_board_rp2( + default_config: dict[str, Any], tmp_path: Path, monkeypatch: MonkeyPatch +): + """ + If the platform is not explicitly set, use "RP2" when the board is in + the RP2 boards list. The generated config must use the canonical + ``rp2:`` top-level key (not the deprecated ``rp2040:`` alias). + """ + # Given + del default_config["platform"] + default_config["board"] = [*RP2_BOARD_PINS][0] + + monkeypatch.setattr(wz, "write_file", MagicMock()) + monkeypatch.setattr(CORE, "config_path", tmp_path.parent) + + # When + wz.wizard_write(tmp_path, **default_config) + + # Then + generated_config = wz.write_file.call_args.args[1] + assert "rp2:" in generated_config + # Guard against regressing to the legacy alias key. + assert "rp2040:" not in generated_config + + def test_safe_print_step_prints_step_number_and_description(monkeypatch: MonkeyPatch): """ The safe_print_step function prints the step number and the passed description @@ -450,6 +476,34 @@ def test_wizard_accepts_default_answers_esp32( assert retval == 0 +def test_wizard_accepts_default_answers_bk72xx( + tmp_path: Path, monkeypatch: MonkeyPatch, wizard_answers: list[str] +): + """ + The wizard should accept the given default answers for bk72xx. The + libretiny branch also exercises the False side of the + ``elif platform == "RP2":`` checks in the platform / board-link + elif chain (without this, those branches show as partial coverage + because only the rpipico interactive test reaches them with platform + == "RP2"). + """ + # Given + wizard_answers[1] = "BK72XX" + wizard_answers[2] = next(iter(BK72XX_BOARD_PINS)) + config_file = tmp_path / "test.yaml" + input_mock = MagicMock(side_effect=wizard_answers) + monkeypatch.setattr("builtins.input", input_mock) + monkeypatch.setattr(wz, "safe_print", lambda t=None, end=None: 0) + monkeypatch.setattr(wz, "sleep", lambda _: 0) + monkeypatch.setattr(wz, "wizard_write", MagicMock()) + + # When + retval = wz.wizard(config_file) + + # Then + assert retval == 0 + + def test_wizard_offers_better_node_name( tmp_path: Path, monkeypatch: MonkeyPatch, wizard_answers: list[str] ): @@ -612,7 +666,7 @@ def test_wizard_accepts_rpipico_board(tmp_path: Path, monkeypatch: MonkeyPatch): # Given wizard_answers_rp2040 = [ "test-node", # Name of the node - "RP2040", # platform + "RP2", # platform (canonical name; ``RP2040`` was the legacy alias) "rpipico", # board (no WiFi support) ] config_file = tmp_path / "test.yaml" diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 07f334d350..46e60ebd8e 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -18,7 +18,7 @@ from esphome.const import ( PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, ) from esphome.core import EsphomeError @@ -338,7 +338,7 @@ def test_storage_should_not_update_cmake_cache_when_nothing_changes( @pytest.mark.parametrize( "core_platform", - [PLATFORM_ESP8266, PLATFORM_RP2040, PLATFORM_BK72XX, PLATFORM_RTL87XX], + [PLATFORM_ESP8266, PLATFORM_RP2, PLATFORM_BK72XX, PLATFORM_RTL87XX], ) def test_storage_should_not_update_cmake_cache_for_non_esp32( create_storage: Callable[..., StorageJSON], From b22def399f7c1fc47f6827ad9c15636ba7711d45 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:08:56 -0400 Subject: [PATCH 0752/1815] [espnow] Add max_payload_size option for ESP-NOW v2 frames (#17360) --- esphome/components/espnow/__init__.py | 32 +++++++++++++++-- esphome/components/espnow/automation.h | 12 +++---- .../components/espnow/espnow_component.cpp | 14 +++++--- esphome/components/espnow/espnow_component.h | 6 ++-- esphome/components/espnow/espnow_packet.h | 35 ++++++++++++++----- .../packet_transport/espnow_transport.cpp | 14 ++++---- .../packet_transport/espnow_transport.h | 6 ++-- esphome/core/defines.h | 2 ++ tests/components/espnow/common.yaml | 1 + 9 files changed, 86 insertions(+), 36 deletions(-) diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index 13f278d3bc..c6c90ed67a 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -41,7 +41,7 @@ DeletePeerAction = espnow_ns.class_("DeletePeerAction", automation.Action) ESPNowHandlerTrigger = automation.Trigger.template( ESPNowRecvInfoConstRef, cg.uint8.operator("const").operator("ptr"), - cg.uint8, + cg.uint16, ) OnUnknownPeerTrigger = espnow_ns.class_( @@ -56,6 +56,20 @@ OnBroadcastTrigger = espnow_ns.class_( CONF_AUTO_ADD_PEER = "auto_add_peer" +CONF_MAX_PAYLOAD_SIZE = "max_payload_size" + +# Payload limits of ESP-NOW v1 and v2 frames. The radio negotiates the +# protocol version per peer on its own; the option only sizes this device's +# packet buffers, whose static RAM cost is proportional to it (~8 KB at 250 +# bytes, ~44 KB at 1470). +ESPNOW_PAYLOAD_V1 = 250 +ESPNOW_PAYLOAD_V2 = 1470 + +# Config-time cap for action payloads. The per-device limit is the +# ``max_payload_size`` option, which the action schema cannot see; send() +# enforces it at runtime. +MAX_ESPNOW_PACKET_SIZE = ESPNOW_PAYLOAD_V2 + CONF_PEERS = "peers" CONF_ON_SENT = "on_sent" CONF_ON_UNKNOWN_PEER = "on_unknown_peer" @@ -63,7 +77,15 @@ CONF_ON_BROADCAST = "on_broadcast" CONF_CONTINUE_ON_ERROR = "continue_on_error" CONF_WAIT_FOR_SENT = "wait_for_sent" -MAX_ESPNOW_PACKET_SIZE = 250 # Maximum size of the payload in bytes + +def _validate_max_payload_size(value: int) -> int: + if value > ESPNOW_PAYLOAD_V1: + return cv.require_framework_version( + esp_idf=cv.Version(5, 4, 0), + esp32_arduino=cv.Version(3, 2, 0), + extra_message="ESP-NOW v2 frames need an ESP-NOW v2 capable framework", + )(value) + return value def validate_channel(value): @@ -78,6 +100,9 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(): cv.declare_id(ESPNowComponent), cv.OnlyWithout(CONF_CHANNEL, CONF_WIFI): validate_channel, cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean, + cv.Optional(CONF_MAX_PAYLOAD_SIZE, default=ESPNOW_PAYLOAD_V1): cv.All( + cv.int_range(min=1, max=ESPNOW_PAYLOAD_V2), _validate_max_payload_size + ), cv.Optional(CONF_AUTO_ADD_PEER, default=False): cv.boolean, cv.Optional(CONF_PEERS): cv.ensure_list(cv.mac_address), cv.Optional(CONF_ON_UNKNOWN_PEER): automation.validate_automation( @@ -113,7 +138,7 @@ async def _trigger_to_code(config): [ (ESPNowRecvInfoConstRef, "info"), (cg.uint8.operator("const").operator("ptr"), "data"), - (cg.uint8, "size"), + (cg.uint16, "size"), ], config, ) @@ -125,6 +150,7 @@ async def to_code(config): await cg.register_component(var, config) cg.add_define("USE_ESPNOW") + cg.add_define("USE_ESPNOW_MAX_PAYLOAD_SIZE", config[CONF_MAX_PAYLOAD_SIZE]) if wifi_channel := config.get(CONF_CHANNEL): cg.add(var.set_wifi_channel(wifi_channel)) diff --git a/esphome/components/espnow/automation.h b/esphome/components/espnow/automation.h index 5e995aff53..e4d01bb1a8 100644 --- a/esphome/components/espnow/automation.h +++ b/esphome/components/espnow/automation.h @@ -119,7 +119,7 @@ template class SetChannelAction final : public Action, pu } }; -class OnReceiveTrigger final : public Trigger, +class OnReceiveTrigger final : public Trigger, public ESPNowReceivedPacketHandler { public: explicit OnReceiveTrigger(std::array address) : has_address_(true) { @@ -128,7 +128,7 @@ class OnReceiveTrigger final : public Triggerhas_address_ || (memcmp(this->address_, info.src_addr, ESP_NOW_ETH_ALEN) == 0); if (!match) return false; @@ -141,15 +141,15 @@ class OnReceiveTrigger final : public Trigger, +class OnUnknownPeerTrigger final : public Trigger, public ESPNowUnknownPeerHandler { public: - bool on_unknown_peer(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override { + bool on_unknown_peer(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) override { this->trigger(info, data, size); return false; // Return false to continue processing other internal handlers } }; -class OnBroadcastTrigger final : public Trigger, +class OnBroadcastTrigger final : public Trigger, public ESPNowBroadcastHandler { public: explicit OnBroadcastTrigger(std::array address) : has_address_(true) { @@ -157,7 +157,7 @@ class OnBroadcastTrigger final : public Triggerhas_address_ || (memcmp(this->address_, info.src_addr, ESP_NOW_ETH_ALEN) == 0); if (!match) return false; diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index 91f2c067ca..f28d7f3354 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -4,6 +4,7 @@ #include "espnow_err.h" +#include #include #include "esphome/core/application.h" @@ -96,9 +97,9 @@ void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status) void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size) { // Drop oversized frames before copying. ESP-NOW v2 peers (IDF >= 5.4 builds a // v2 stack with no opt-out) can send up to ESP_NOW_MAX_DATA_LEN_V2 (1470 B), - // but our receive buffer is ESP_NOW_MAX_DATA_LEN (250 B); copying a larger - // frame would overflow packet_.receive.data. - if (size < 0 || size > ESP_NOW_MAX_DATA_LEN) { + // but the receive buffer only fits v2 frames with ``max_payload_size``; copying a + // larger frame would overflow packet_.receive.data. + if (size < 0 || size > ESPNOW_MAX_DATA_LEN) { global_esp_now->receive_packet_queue_.increment_dropped_count(); return; } @@ -285,11 +286,14 @@ void ESPNowComponent::loop() { #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char src_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; char dst_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + // Cap the hex dump at a v1 frame: a full v2 frame would need a + // ~4.4 KB stack buffer. char hex_buf[format_hex_pretty_size(ESP_NOW_MAX_DATA_LEN)]; format_mac_addr_upper(info.src_addr, src_buf); format_mac_addr_upper(info.des_addr, dst_buf); ESP_LOGV(TAG, "<<< [%s -> %s] %s", src_buf, dst_buf, - format_hex_pretty_to(hex_buf, packet->packet_.receive.data, packet->packet_.receive.size)); + format_hex_pretty_to(hex_buf, packet->packet_.receive.data, + std::min(packet->packet_.receive.size, ESP_NOW_MAX_DATA_LEN))); #endif if (memcmp(info.des_addr, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0) { for (auto *handler : this->broadcast_handlers_) { @@ -362,7 +366,7 @@ esp_err_t ESPNowComponent::send(const uint8_t *peer_address, const uint8_t *payl return ESP_ERR_ESPNOW_PEER_NOT_SET; } else if (memcmp(peer_address, this->own_address_, ESP_NOW_ETH_ALEN) == 0) { return ESP_ERR_ESPNOW_OWN_ADDRESS; - } else if (size > ESP_NOW_MAX_DATA_LEN) { + } else if (size > ESPNOW_MAX_DATA_LEN) { return ESP_ERR_ESPNOW_DATA_SIZE; } else if (!esp_now_is_peer_exist(peer_address)) { if (memcmp(peer_address, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0 || this->auto_add_peer_) { diff --git a/esphome/components/espnow/espnow_component.h b/esphome/components/espnow/espnow_component.h index eacc3eb886..d95255c5df 100644 --- a/esphome/components/espnow/espnow_component.h +++ b/esphome/components/espnow/espnow_component.h @@ -62,7 +62,7 @@ class ESPNowUnknownPeerHandler { /// @param data Pointer to the received data payload /// @param size Size of the received data in bytes /// @return true if the packet was handled, false otherwise - virtual bool on_unknown_peer(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; + virtual bool on_unknown_peer(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) = 0; }; /// Handler interface for receiving ESPNow packets @@ -74,7 +74,7 @@ class ESPNowReceivedPacketHandler { /// @param data Pointer to the received data payload /// @param size Size of the received data in bytes /// @return true if the packet was handled, false otherwise - virtual bool on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; + virtual bool on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) = 0; }; /// Handler interface for receiving ESPNow broadcast packets /// Components should inherit from this class to handle incoming ESPNow data @@ -85,7 +85,7 @@ class ESPNowBroadcastHandler { /// @param data Pointer to the received data payload /// @param size Size of the received data in bytes /// @return true if the packet was handled, false otherwise - virtual bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; + virtual bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) = 0; }; class ESPNowComponent final : public Component { diff --git a/esphome/components/espnow/espnow_packet.h b/esphome/components/espnow/espnow_packet.h index b6192a0d41..fb125864fb 100644 --- a/esphome/components/espnow/espnow_packet.h +++ b/esphome/components/espnow/espnow_packet.h @@ -19,6 +19,23 @@ namespace esphome::espnow { static const uint8_t ESPNOW_BROADCAST_ADDR[ESP_NOW_ETH_ALEN] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; static const uint8_t ESPNOW_MULTICAST_ADDR[ESP_NOW_ETH_ALEN] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE}; +// Maximum payload this component sends and receives, from the +// ``max_payload_size`` option. The radio stack speaks ESP-NOW v2 regardless +// (negotiated per peer); payloads beyond the v1 limit (250 bytes) are opt-in +// because the packet pools are statically sized from this, so their RAM cost +// is proportional (~8 KB at 250 bytes, ~44 KB at the v2 limit of 1470). +#ifndef USE_ESPNOW_MAX_PAYLOAD_SIZE +#define USE_ESPNOW_MAX_PAYLOAD_SIZE ESP_NOW_MAX_DATA_LEN +#endif +static constexpr uint16_t ESPNOW_MAX_DATA_LEN = USE_ESPNOW_MAX_PAYLOAD_SIZE; +#ifdef ESP_NOW_MAX_DATA_LEN_V2 +static_assert(ESPNOW_MAX_DATA_LEN <= ESP_NOW_MAX_DATA_LEN_V2, + "espnow max_payload_size cannot exceed the ESP-NOW v2 frame limit"); +#else +static_assert(ESPNOW_MAX_DATA_LEN <= ESP_NOW_MAX_DATA_LEN, + "espnow max_payload_size beyond 250 bytes requires an ESP-IDF with ESP-NOW v2 support (5.4+)"); +#endif + struct WifiPacketRxControl { int8_t rssi; // Received Signal Strength Indicator (RSSI) of packet, unit: dBm uint32_t timestamp; // Timestamp in microseconds when the packet was received, precise only if modem sleep or @@ -78,10 +95,10 @@ class ESPNowPacket { union { // NOLINTNEXTLINE(readability-identifier-naming) struct received_data { - ESPNowRecvInfo info; // Information about the received packet - uint8_t data[ESP_NOW_MAX_DATA_LEN]; // Data received in the packet - uint8_t size; // Size of the received data - WifiPacketRxControl rx_ctrl; // Status of the received packet + ESPNowRecvInfo info; // Information about the received packet + uint8_t data[ESPNOW_MAX_DATA_LEN]; // Data received in the packet + uint16_t size; // Size of the received data + WifiPacketRxControl rx_ctrl; // Status of the received packet } receive; // NOLINTNEXTLINE(readability-identifier-naming) @@ -144,15 +161,15 @@ class ESPNowSendPacket { this->callback_ = nullptr; // Reset callback } - uint8_t address_[ESP_NOW_ETH_ALEN]{0}; // MAC address of the peer to send the packet to - uint8_t data_[ESP_NOW_MAX_DATA_LEN]{0}; // Data to send - uint8_t size_{0}; // Size of the data to send, must be <= ESP_NOW_MAX_DATA_LEN - send_callback_t callback_{nullptr}; // Callback to call when the send operation is complete + uint8_t address_[ESP_NOW_ETH_ALEN]{0}; // MAC address of the peer to send the packet to + uint8_t data_[ESPNOW_MAX_DATA_LEN]{0}; // Data to send + uint16_t size_{0}; // Size of the data to send, must be <= ESPNOW_MAX_DATA_LEN + send_callback_t callback_{nullptr}; // Callback to call when the send operation is complete private: void init_data_(const uint8_t *peer_address, const uint8_t *payload, size_t size) { memcpy(this->address_, peer_address, ESP_NOW_ETH_ALEN); - if (size > ESP_NOW_MAX_DATA_LEN) { + if (size > ESPNOW_MAX_DATA_LEN) { this->size_ = 0; return; } diff --git a/esphome/components/espnow/packet_transport/espnow_transport.cpp b/esphome/components/espnow/packet_transport/espnow_transport.cpp index 1e37073321..b7686f23d6 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.cpp +++ b/esphome/components/espnow/packet_transport/espnow_transport.cpp @@ -42,8 +42,8 @@ void ESPNowTransport::send_packet(const std::vector &buf) const { return; } - if (buf.size() > ESP_NOW_MAX_DATA_LEN) { - ESP_LOGE(TAG, "Packet too large: %zu bytes (max %d)", buf.size(), ESP_NOW_MAX_DATA_LEN); + if (buf.size() > ESPNOW_MAX_DATA_LEN) { + ESP_LOGE(TAG, "Packet too large: %zu bytes (max %u)", buf.size(), (unsigned) ESPNOW_MAX_DATA_LEN); return; } @@ -55,8 +55,8 @@ void ESPNowTransport::send_packet(const std::vector &buf) const { }); } -bool ESPNowTransport::on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) { - ESP_LOGV(TAG, "Received packet of size %u from %02X:%02X:%02X:%02X:%02X:%02X", size, info.src_addr[0], +bool ESPNowTransport::on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) { + ESP_LOGV(TAG, "Received packet of size %u from %02X:%02X:%02X:%02X:%02X:%02X", (unsigned) size, info.src_addr[0], info.src_addr[1], info.src_addr[2], info.src_addr[3], info.src_addr[4], info.src_addr[5]); if (data == nullptr || size == 0) { @@ -70,9 +70,9 @@ bool ESPNowTransport::on_receive(const ESPNowRecvInfo &info, const uint8_t *data return false; // Allow other handlers to run } -bool ESPNowTransport::on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) { - ESP_LOGV(TAG, "Received broadcast packet of size %u from %02X:%02X:%02X:%02X:%02X:%02X", size, info.src_addr[0], - info.src_addr[1], info.src_addr[2], info.src_addr[3], info.src_addr[4], info.src_addr[5]); +bool ESPNowTransport::on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) { + ESP_LOGV(TAG, "Received broadcast packet of size %u from %02X:%02X:%02X:%02X:%02X:%02X", (unsigned) size, + info.src_addr[0], info.src_addr[1], info.src_addr[2], info.src_addr[3], info.src_addr[4], info.src_addr[5]); if (data == nullptr || size == 0) { ESP_LOGW(TAG, "Received empty or null broadcast packet"); diff --git a/esphome/components/espnow/packet_transport/espnow_transport.h b/esphome/components/espnow/packet_transport/espnow_transport.h index 7e1d08618b..51069b6415 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.h +++ b/esphome/components/espnow/packet_transport/espnow_transport.h @@ -24,12 +24,12 @@ class ESPNowTransport final : public packet_transport::PacketTransport, } // ESPNow handler interface - bool on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override; - bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override; + bool on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) override; + bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) override; protected: void send_packet(const std::vector &buf) const override; - size_t get_max_packet_size() override { return ESP_NOW_MAX_DATA_LEN; } + size_t get_max_packet_size() override { return ESPNOW_MAX_DATA_LEN; } bool should_send() override; peer_address_t peer_address_{{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}}; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 3e8b0829c5..cdc26c9222 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -242,6 +242,8 @@ #define USE_ESP32_MIN_CHIP_REVISION_SET #define USE_ESP32_RTC_PREFERENCES #define USE_ESP32_SRAM1_AS_IRAM +#define USE_ESPNOW +#define USE_ESPNOW_MAX_PAYLOAD_SIZE 1470 #define USE_BLUETOOTH_PROXY #define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml index f05735e8f4..ae43baa41a 100644 --- a/tests/components/espnow/common.yaml +++ b/tests/components/espnow/common.yaml @@ -2,6 +2,7 @@ espnow: id: espnow_component auto_add_peer: false channel: 1 + max_payload_size: 1470 peers: - 11:22:33:44:55:66 on_receive: From 0b311962b5ff529c1eb854233ac80d6e7b291475 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:09:51 -0500 Subject: [PATCH 0753/1815] Bump bundled esphome-device-builder to 1.2.0 (#17430) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index a54bf3e79e..c01a2069f7 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.1.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.2.0 RUN \ platformio settings set enable_telemetry No \ From ebff49072e0acf19102991c6521ae8038abbe952 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Mon, 6 Jul 2026 16:12:25 -0700 Subject: [PATCH 0754/1815] [modbus] Store ModbusFrame inline to cut per-frame heap churn (#17282) Co-authored-by: Claude --- esphome/components/modbus/modbus.cpp | 25 ++--- esphome/components/modbus/modbus.h | 30 ++++-- esphome/core/helpers.h | 11 +- tests/components/modbus/heap_probe_test.cpp | 106 ++++++++++++++++++++ 4 files changed, 146 insertions(+), 26 deletions(-) create mode 100644 tests/components/modbus/heap_probe_test.cpp diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index eefab7967f..527d57fcd7 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -51,7 +51,7 @@ void ModbusClientHub::loop() { // If we're past the send_wait_time timeout and response buffer doesn't have the start of the expected response if (this->waiting_for_response_.has_value()) { ModbusDeviceCommand &wfr = this->waiting_for_response_.value(); - uint8_t expected_address = wfr.frame.data.get()[0]; + uint8_t expected_address = wfr.frame.data.data()[0]; if (this->last_receive_check_ - this->last_send_ > this->last_send_tx_offset_ + this->send_wait_time_ && (this->rx_buffer_.empty() || this->rx_buffer_[0] != expected_address)) { ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send", expected_address, @@ -270,8 +270,8 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, uint8_t funct // Check if the response matches the expected address and function code ModbusDeviceCommand &wfr = this->waiting_for_response_.value(); - uint8_t expected_address = wfr.frame.data.get()[0]; - uint8_t expected_function_code = wfr.frame.data.get()[1]; + uint8_t expected_address = wfr.frame.data.data()[0]; + uint8_t expected_function_code = wfr.frame.data.data()[1]; if (expected_address != address || expected_function_code != (function_code & FUNCTION_CODE_MASK)) { ESP_LOGW(TAG, "Received incorrect frame address %" PRIu8 " <> %" PRIu8 " or function code 0x%X <> 0x%X, %" PRIu32 @@ -458,7 +458,7 @@ bool Modbus::send_frame_(const ModbusFrame &frame) { ESP_LOGE(TAG, "Attempted to send while transmission blocked"); return false; } - if (frame.size > MAX_FRAME_SIZE) { + if (frame.size() > MAX_FRAME_SIZE) { ESP_LOGE(TAG, "Attempted to send frame larger than max frame size of %" PRIu16 " bytes", MAX_FRAME_SIZE); return false; } @@ -470,13 +470,13 @@ bool Modbus::send_frame_(const ModbusFrame &frame) { if (this->flow_control_pin_ != nullptr) { this->flow_control_pin_->digital_write(true); - this->write_array(frame.data.get(), frame.size); + this->write_array(frame.data.data(), frame.size()); this->flush(); this->flow_control_pin_->digital_write(false); this->last_send_tx_offset_ = 0; } else { - this->write_array(frame.data.get(), frame.size); - this->last_send_tx_offset_ = frame.size * MODBUS_BITS_PER_CHAR * MS_PER_SEC / this->parent_->get_baud_rate() + 1; + this->write_array(frame.data.data(), frame.size()); + this->last_send_tx_offset_ = frame.size() * MODBUS_BITS_PER_CHAR * MS_PER_SEC / this->parent_->get_baud_rate() + 1; } uint32_t now = millis(); @@ -484,7 +484,7 @@ bool Modbus::send_frame_(const ModbusFrame &frame) { char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; #endif ESP_LOGV(TAG, "Write: %s %" PRIu32 "ms after last send, %" PRIu32 "ms after last receive", - format_hex_pretty_to(hex_buf, frame.data.get(), frame.size), now - this->last_send_, + format_hex_pretty_to(hex_buf, frame.data.data(), frame.size()), now - this->last_send_, now - this->last_modbus_byte_); this->last_send_ = now; return true; @@ -590,12 +590,13 @@ void ModbusClientHub::queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t p void ModbusClientHub::clear_tx_queue_for_address(uint8_t address, bool clear_sent) { // Remove any pending commands for this address from the tx buffer auto &tx_buffer = this->tx_buffer_; - tx_buffer.erase(std::remove_if(tx_buffer.begin(), tx_buffer.end(), - [address](const ModbusDeviceCommand &cmd) { return cmd.frame.data[0] == address; }), - tx_buffer.end()); + tx_buffer.erase( + std::remove_if(tx_buffer.begin(), tx_buffer.end(), + [address](const ModbusDeviceCommand &cmd) { return cmd.frame.data.data()[0] == address; }), + tx_buffer.end()); if (clear_sent && this->waiting_for_response_.has_value() && this->waiting_for_response_.value().device) { - if (this->waiting_for_response_.value().frame.data[0] == address) { + if (this->waiting_for_response_.value().frame.data.data()[0] == address) { ESP_LOGV(TAG, "Clearing waiting for response for address %" PRIu8, address); // Invalidate the waiting device so it won't process a response. this->waiting_for_response_.value().device = nullptr; diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index d995c441ad..e48c8c298a 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -18,19 +18,27 @@ namespace esphome::modbus { static constexpr uint16_t MODBUS_TX_BUFFER_SIZE = 15; static constexpr uint16_t MODBUS_TX_MAX_DELAY_MS = 5; -struct ModbusFrame { - // Frame with exact-size allocation to avoid std::vector overhead - std::unique_ptr data; - uint16_t size; // Modbus RTU max is 256 bytes +// Typical frames -- reads and single-register/coil writes -- are exactly 8 bytes +// (address + 5-byte PDU + 2-byte CRC) and fit inline with no heap allocation. +static constexpr uint16_t MODBUS_FRAME_INLINE_SIZE = 8; - ModbusFrame(uint8_t address, const uint8_t *pdu, uint16_t pdu_len) - : data(std::make_unique(pdu_len + 3)), size(pdu_len + 3) { - data[0] = address; - memcpy(data.get() + 1, pdu, pdu_len); - auto crc = crc16(data.get(), pdu_len + 1); - data[pdu_len + 1] = crc >> 0; - data[pdu_len + 2] = crc >> 8; +struct ModbusFrame { + // Frame held in a small-buffer-optimized buffer. Typical frames fit inline; only larger + // multi-register or custom frames spill to a single heap allocation. This keeps the common, + // high-frequency tx traffic off the heap entirely, avoiding per-frame alloc/free churn. + // The buffer tracks its own length, so no separate size field is needed. + SmallInlineBuffer data; // Modbus RTU max is 256 bytes + + ModbusFrame(uint8_t address, const uint8_t *pdu, uint16_t pdu_len) { + uint8_t *buf = this->data.init(pdu_len + 3); + buf[0] = address; + memcpy(buf + 1, pdu, pdu_len); + auto crc = crc16(buf, pdu_len + 1); + buf[pdu_len + 1] = crc >> 0; + buf[pdu_len + 2] = crc >> 8; } + + uint16_t size() const { return static_cast(this->data.size()); } }; class Modbus : public uart::UARTDevice, public Component { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index f39b5aa4d0..e862d015da 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -184,8 +184,10 @@ template class SmallInlineBuffer { SmallInlineBuffer(const SmallInlineBuffer &) = delete; SmallInlineBuffer &operator=(const SmallInlineBuffer &) = delete; - /// Set buffer contents, allocating heap if needed - void set(const uint8_t *src, size_t size) { + /// Resize to `size` bytes of (uninitialized) storage and return a writable pointer to fill. + /// Allocates heap only when `size` exceeds the inline capacity. Use this when the contents are + /// built in place (e.g. assembling a frame and appending a checksum) to avoid a staging copy. + uint8_t *init(size_t size) { // Free existing heap allocation if switching from heap to inline or different heap size if (!this->is_inline_() && (size <= InlineSize || size != this->len_)) { delete[] this->heap_; @@ -196,9 +198,12 @@ template class SmallInlineBuffer { this->heap_ = new uint8_t[size]; // NOLINT(cppcoreguidelines-owning-memory) } this->len_ = size; - memcpy(this->data(), src, size); + return this->data(); } + /// Set buffer contents, allocating heap if needed + void set(const uint8_t *src, size_t size) { memcpy(this->init(size), src, size); } + uint8_t *data() { return this->is_inline_() ? this->inline_ : this->heap_; } const uint8_t *data() const { return this->is_inline_() ? this->inline_ : this->heap_; } size_t size() const { return this->len_; } diff --git a/tests/components/modbus/heap_probe_test.cpp b/tests/components/modbus/heap_probe_test.cpp new file mode 100644 index 0000000000..af43c6e5e3 --- /dev/null +++ b/tests/components/modbus/heap_probe_test.cpp @@ -0,0 +1,106 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include "esphome/components/modbus/modbus.h" + +// The allocation counters rely on AddressSanitizer's malloc hooks. The cpp_unit_test harness always +// builds with ASan, so this is exercised in CI; the fallback only applies to out-of-harness builds. +#ifndef __has_feature +#define __has_feature(x) 0 +#endif +#if defined(__SANITIZE_ADDRESS__) || __has_feature(address_sanitizer) +#define HEAP_PROBE_HAS_ASAN +#endif + +#ifdef HEAP_PROBE_HAS_ASAN + +// Allocation counters fed by ASan's malloc hooks; sampled tightly around the calls under test. +static std::atomic g_alloc_count{0}; +static std::atomic g_alloc_bytes{0}; + +static void malloc_hook(const volatile void *, size_t size) { + g_alloc_count++; + g_alloc_bytes += size; +} +static void free_hook(const volatile void *) {} + +extern "C" int __sanitizer_install_malloc_and_free_hooks(void (*malloc_hook)(const volatile void *, size_t), + void (*free_hook)(const volatile void *)); + +[[maybe_unused]] static const int g_hooks_installed = __sanitizer_install_malloc_and_free_hooks(malloc_hook, free_hook); + +namespace esphome::modbus::testing { + +namespace { + +struct Sample { + size_t count; + size_t bytes; +}; + +template Sample sample(F &&f) { + size_t c0 = g_alloc_count.load(), b0 = g_alloc_bytes.load(); + f(); + return {g_alloc_count.load() - c0, g_alloc_bytes.load() - b0}; +} + +} // namespace + +// Typical frames (reads and single-register/coil writes are exactly address + 5-byte PDU + CRC = 8 +// bytes) fit the SmallInlineBuffer and are built with zero heap allocations; only larger frames spill +// to a single allocation. +TEST(HeapProbe, TypicalFrameConstructionIsAllocationFree) { + const uint8_t read_pdu[] = {0x03, 0x01, 0x00, 0x00, 0x02}; // 5 bytes -> 8-byte frame, inline + Sample typical = sample([&] { + ModbusFrame frame(0x02, read_pdu, sizeof(read_pdu)); + (void) frame; + }); + printf("HEAPPROBE frame_typical count=%zu bytes=%zu\n", typical.count, typical.bytes); + EXPECT_EQ(typical.count, 0u); + + uint8_t large_pdu[250] = {0x10}; // multi-register write -> 253-byte frame, spills once + Sample large = sample([&] { + ModbusFrame frame(0x02, large_pdu, sizeof(large_pdu)); + (void) frame; + }); + printf("HEAPPROBE frame_large count=%zu bytes=%zu\n", large.count, large.bytes); + EXPECT_EQ(large.count, 1u); +} + +// Queueing typical commands is fully allocation-free: the frame fits the inline buffer and the tx +// deque's first block is already allocated when the hub is constructed. (A queue deeper than one +// deque block - roughly a dozen commands - would allocate further blocks.) +TEST(HeapProbe, QueueingTypicalCommandsIsAllocationFree) { + ModbusClientHub hub; + ModbusClientDevice device(&hub, 0x02); + + StaticVector req; + const uint8_t read_pdu[] = {0x03, 0x01, 0x00, 0x00, 0x02}; + req.assign(read_pdu, read_pdu + sizeof(read_pdu)); + + constexpr int n = 12; + size_t total = 0; + for (int i = 0; i != n; i++) { + total += sample([&] { device.send_pdu(req); }).count; + } + printf("HEAPPROBE queue_%d_typical_commands total_allocs=%zu\n", n, total); + EXPECT_EQ(total, 0u); +} + +} // namespace esphome::modbus::testing + +#else // !HEAP_PROBE_HAS_ASAN + +namespace esphome::modbus::testing { +TEST(HeapProbe, TypicalFrameConstructionIsAllocationFree) { + GTEST_SKIP() << "allocation counting requires an AddressSanitizer build"; +} +} // namespace esphome::modbus::testing + +#endif // HEAP_PROBE_HAS_ASAN From c4689989c78aee50f8e065d9d7e0e31f0dcb2acd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:42:18 +1200 Subject: [PATCH 0755/1815] Mark configurable classes as final (20/21: wts01-zephyr_ble_server) (#16971) --- esphome/components/wts01/wts01.h | 2 +- esphome/components/x9c/x9c.h | 2 +- esphome/components/xdb401/xdb401.h | 2 +- esphome/components/xgzp68xx/xgzp68xx.h | 2 +- esphome/components/xiaomi_ble/xiaomi_ble.h | 2 +- esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h | 2 +- esphome/components/xiaomi_cgg1/xiaomi_cgg1.h | 2 +- esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h | 6 +++--- esphome/components/xiaomi_gcls002/xiaomi_gcls002.h | 2 +- esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h | 2 +- esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h | 2 +- esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h | 2 +- esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h | 2 +- esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h | 2 +- esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h | 2 +- esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h | 2 +- esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h | 2 +- esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h | 2 +- esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h | 2 +- esphome/components/xiaomi_miscale/xiaomi_miscale.h | 2 +- esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h | 6 +++--- esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h | 6 +++--- esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h | 2 +- esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h | 6 +++--- esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h | 2 +- esphome/components/xl9535/xl9535.h | 4 ++-- esphome/components/xpt2046/touchscreen/xpt2046.h | 6 +++--- esphome/components/yashima/yashima.h | 2 +- esphome/components/zephyr/cdc_acm.h | 2 +- esphome/components/zephyr/gpio.h | 2 +- esphome/components/zephyr_ble_server/ble_server.h | 4 ++-- 31 files changed, 43 insertions(+), 43 deletions(-) diff --git a/esphome/components/wts01/wts01.h b/esphome/components/wts01/wts01.h index 17d4dc57a2..2a284ac86e 100644 --- a/esphome/components/wts01/wts01.h +++ b/esphome/components/wts01/wts01.h @@ -8,7 +8,7 @@ namespace esphome::wts01 { constexpr uint8_t PACKET_SIZE = 9; -class WTS01Sensor : public sensor::Sensor, public uart::UARTDevice, public Component { +class WTS01Sensor final : public sensor::Sensor, public uart::UARTDevice, public Component { public: void loop() override; void dump_config() override; diff --git a/esphome/components/x9c/x9c.h b/esphome/components/x9c/x9c.h index 112f0405d7..1cea15c26f 100644 --- a/esphome/components/x9c/x9c.h +++ b/esphome/components/x9c/x9c.h @@ -6,7 +6,7 @@ namespace esphome::x9c { -class X9cOutput : public output::FloatOutput, public Component { +class X9cOutput final : public output::FloatOutput, public Component { public: void set_cs_pin(InternalGPIOPin *pin) { cs_pin_ = pin; } void set_inc_pin(InternalGPIOPin *pin) { inc_pin_ = pin; } diff --git a/esphome/components/xdb401/xdb401.h b/esphome/components/xdb401/xdb401.h index 674d26fe8e..670425e69e 100644 --- a/esphome/components/xdb401/xdb401.h +++ b/esphome/components/xdb401/xdb401.h @@ -6,7 +6,7 @@ namespace esphome::xdb401 { -class XDB401Component : public PollingComponent, public i2c::I2CDevice { +class XDB401Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_pressure_sensor(sensor::Sensor *pressure_sensor) { this->pressure_sensor_ = pressure_sensor; } diff --git a/esphome/components/xgzp68xx/xgzp68xx.h b/esphome/components/xgzp68xx/xgzp68xx.h index 1bab9b091a..d9aec6e5cc 100644 --- a/esphome/components/xgzp68xx/xgzp68xx.h +++ b/esphome/components/xgzp68xx/xgzp68xx.h @@ -20,7 +20,7 @@ enum XGZP68XXOversampling : uint8_t { XGZP68XX_OVERSAMPLING_UNKNOWN = (uint8_t) -1, }; -class XGZP68XXComponent : public PollingComponent, public sensor::Sensor, public i2c::I2CDevice { +class XGZP68XXComponent final : public PollingComponent, public sensor::Sensor, public i2c::I2CDevice { public: SUB_SENSOR(temperature) SUB_SENSOR(pressure) diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.h b/esphome/components/xiaomi_ble/xiaomi_ble.h index a4ecca0c66..1ebcf0e2f5 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.h +++ b/esphome/components/xiaomi_ble/xiaomi_ble.h @@ -72,7 +72,7 @@ optional parse_xiaomi_header(const esp32_ble_tracker::Service bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, const uint64_t &address); bool report_xiaomi_results(const optional &result, const char *address); -class XiaomiListener : public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiListener final : public esp32_ble_tracker::ESPBTDeviceListener { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; }; diff --git a/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h b/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h index 02d098c31b..36068ae227 100644 --- a/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h +++ b/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_cgdk2 { -class XiaomiCGDK2 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiCGDK2 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h b/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h index d49e3a08d1..7633458cb8 100644 --- a/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h +++ b/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_cgg1 { -class XiaomiCGG1 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiCGG1 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h b/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h index 28a7a3ae2d..0fa6c76e54 100644 --- a/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h +++ b/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h @@ -10,9 +10,9 @@ namespace esphome::xiaomi_cgpr1 { -class XiaomiCGPR1 : public Component, - public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiCGPR1 final : public Component, + public binary_sensor::BinarySensorInitiallyOff, + public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h b/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h index e14077adb0..668133f364 100644 --- a/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h +++ b/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_gcls002 { -class XiaomiGCLS002 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiGCLS002 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h b/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h index 8bc6399065..cb53b47f6f 100644 --- a/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h +++ b/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_hhccjcy01 { -class XiaomiHHCCJCY01 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiHHCCJCY01 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h index 812e3a7d8f..fa2f461534 100644 --- a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h +++ b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h @@ -8,7 +8,7 @@ namespace esphome::xiaomi_hhccjcy10 { -class XiaomiHHCCJCY10 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiHHCCJCY10 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { this->address_ = address; } diff --git a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h index 2bdd6102be..3eda1b9859 100644 --- a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h +++ b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_hhccpot002 { -class XiaomiHHCCPOT002 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiHHCCPOT002 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h b/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h index aaf34f899f..122c6776c9 100644 --- a/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h +++ b/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_jqjcy01ym { -class XiaomiJQJCY01YM : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiJQJCY01YM final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h b/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h index e45596f966..09256047ae 100644 --- a/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h +++ b/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_lywsd02 { -class XiaomiLYWSD02 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiLYWSD02 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h b/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h index 23efcbf8fc..efd758b972 100644 --- a/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h +++ b/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_lywsd02mmc { -class XiaomiLYWSD02MMC : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiLYWSD02MMC final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { this->address_ = address; } void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h index 03462b850f..ecdbd412cb 100644 --- a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h +++ b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_lywsd03mmc { -class XiaomiLYWSD03MMC : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiLYWSD03MMC final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h b/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h index e169afc651..86afef4571 100644 --- a/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h +++ b/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_lywsdcgq { -class XiaomiLYWSDCGQ : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiLYWSDCGQ final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h b/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h index daacd6be86..042a5034f1 100644 --- a/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h +++ b/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_mhoc303 { -class XiaomiMHOC303 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiMHOC303 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h index 225c9ff189..3570f70a16 100644 --- a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h +++ b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_mhoc401 { -class XiaomiMHOC401 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiMHOC401 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_miscale/xiaomi_miscale.h b/esphome/components/xiaomi_miscale/xiaomi_miscale.h index c75a22c9fb..3213f5d6de 100644 --- a/esphome/components/xiaomi_miscale/xiaomi_miscale.h +++ b/esphome/components/xiaomi_miscale/xiaomi_miscale.h @@ -16,7 +16,7 @@ struct ParseResult { optional impedance; }; -class XiaomiMiscale : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiMiscale final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; diff --git a/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h b/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h index ee4ed52520..da02dee003 100644 --- a/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h +++ b/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h @@ -10,9 +10,9 @@ namespace esphome::xiaomi_mjyd02yla { -class XiaomiMJYD02YLA : public Component, - public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiMJYD02YLA final : public Component, + public binary_sensor::BinarySensorInitiallyOff, + public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h index a6d8abc5bf..4751e35e65 100644 --- a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h +++ b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h @@ -9,9 +9,9 @@ namespace esphome::xiaomi_mue4094rt { -class XiaomiMUE4094RT : public Component, - public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiMUE4094RT final : public Component, + public binary_sensor::BinarySensorInitiallyOff, + public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h index cc6a334a20..0d3427cc4d 100644 --- a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h +++ b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h @@ -15,7 +15,7 @@ namespace esphome::xiaomi_rtcgq02lm { -class XiaomiRTCGQ02LM : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiRTCGQ02LM final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h b/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h index 0b0cb8db0b..0573959473 100644 --- a/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h +++ b/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h @@ -10,9 +10,9 @@ namespace esphome::xiaomi_wx08zm { -class XiaomiWX08ZM : public Component, - public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiWX08ZM final : public Component, + public binary_sensor::BinarySensorInitiallyOff, + public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h index 9bab943ab9..c7d20aa356 100644 --- a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h +++ b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_xmwsdj04mmc { -class XiaomiXMWSDJ04MMC : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiXMWSDJ04MMC final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { this->address_ = address; } void set_bindkey(const char *bindkey); diff --git a/esphome/components/xl9535/xl9535.h b/esphome/components/xl9535/xl9535.h index 253ce76273..11fb3acc8d 100644 --- a/esphome/components/xl9535/xl9535.h +++ b/esphome/components/xl9535/xl9535.h @@ -17,7 +17,7 @@ enum { XL9535_CONFIG_PORT_1_REGISTER = 0x07, }; -class XL9535Component : public Component, public i2c::I2CDevice { +class XL9535Component final : public Component, public i2c::I2CDevice { public: bool digital_read(uint8_t pin); void digital_write(uint8_t pin, bool value); @@ -28,7 +28,7 @@ class XL9535Component : public Component, public i2c::I2CDevice { float get_setup_priority() const override { return setup_priority::IO; } }; -class XL9535GPIOPin : public GPIOPin { +class XL9535GPIOPin final : public GPIOPin { public: void set_parent(XL9535Component *parent) { this->parent_ = parent; } void set_pin(uint8_t pin) { this->pin_ = pin; } diff --git a/esphome/components/xpt2046/touchscreen/xpt2046.h b/esphome/components/xpt2046/touchscreen/xpt2046.h index f619e06fb7..8fe9b7cc43 100644 --- a/esphome/components/xpt2046/touchscreen/xpt2046.h +++ b/esphome/components/xpt2046/touchscreen/xpt2046.h @@ -11,9 +11,9 @@ namespace esphome::xpt2046 { using namespace touchscreen; -class XPT2046Component : public Touchscreen, - public spi::SPIDevice { +class XPT2046Component final : public Touchscreen, + public spi::SPIDevice { public: /// Set the threshold for the touch detection. void set_threshold(int16_t threshold) { this->threshold_ = threshold; } diff --git a/esphome/components/yashima/yashima.h b/esphome/components/yashima/yashima.h index 336b28f5c5..864b3fce66 100644 --- a/esphome/components/yashima/yashima.h +++ b/esphome/components/yashima/yashima.h @@ -9,7 +9,7 @@ namespace esphome::yashima { -class YashimaClimate : public climate::Climate, public Component { +class YashimaClimate final : public climate::Climate, public Component { public: void setup() override; void set_transmitter(remote_transmitter::RemoteTransmitterComponent *transmitter) { diff --git a/esphome/components/zephyr/cdc_acm.h b/esphome/components/zephyr/cdc_acm.h index 4dc14397d8..9d11d4b575 100644 --- a/esphome/components/zephyr/cdc_acm.h +++ b/esphome/components/zephyr/cdc_acm.h @@ -7,7 +7,7 @@ namespace esphome::zephyr { -class CdcAcm : public Component { +class CdcAcm final : public Component { public: CdcAcm(); void setup() override; diff --git a/esphome/components/zephyr/gpio.h b/esphome/components/zephyr/gpio.h index 19d68cfb2b..71d1620a67 100644 --- a/esphome/components/zephyr/gpio.h +++ b/esphome/components/zephyr/gpio.h @@ -16,7 +16,7 @@ struct ZephyrGPIOInterrupt { void *arg{nullptr}; }; -class ZephyrGPIOPin : public InternalGPIOPin { +class ZephyrGPIOPin final : public InternalGPIOPin { public: ZephyrGPIOPin(const device *gpio, int gpio_size, const char *pin_name_prefix) { this->gpio_ = gpio; diff --git a/esphome/components/zephyr_ble_server/ble_server.h b/esphome/components/zephyr_ble_server/ble_server.h index bf69c52b12..223dbf7ac9 100644 --- a/esphome/components/zephyr_ble_server/ble_server.h +++ b/esphome/components/zephyr_ble_server/ble_server.h @@ -6,7 +6,7 @@ namespace esphome::zephyr_ble_server { -class BLEServer : public Component { +class BLEServer final : public Component { public: void setup() override; void dump_config() override; @@ -21,7 +21,7 @@ class BLEServer : public Component { CallbackManager passkey_cb_; }; -template class BLENumericComparisonReplyAction : public Action { +template class BLENumericComparisonReplyAction final : public Action { public: explicit BLENumericComparisonReplyAction(BLEServer *parent) : parent_(parent) {} From 2dd7ac090f66212357cf4d1dbff4e08ccaace2bf Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:37:44 +1000 Subject: [PATCH 0756/1815] [mipi_spi] Add M5STACK ATOM3SR display (#17344) --- esphome/components/mipi_spi/models/ili.py | 72 ------------------- esphome/components/mipi_spi/models/m5stack.py | 71 ++++++++++++++++++ 2 files changed, 71 insertions(+), 72 deletions(-) create mode 100644 esphome/components/mipi_spi/models/m5stack.py diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index 5598a51073..812e491c62 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -10,7 +10,6 @@ from esphome.components.mipi import ( GMCTR, GMCTRN1, GMCTRP1, - IDMOFF, IFCTR, IFMODE, INVCTR, @@ -23,7 +22,6 @@ from esphome.components.mipi import ( PWCTR5, PWSET, PWSETN, - SETEXTC, VMCTR, VMCTR1, VMCTR2, @@ -32,60 +30,6 @@ from esphome.components.mipi import ( ) from esphome.components.spi import TYPE_OCTAL -DriverChip( - "M5CORE", - width=320, - height=240, - cs_pin=14, - dc_pin=27, - reset_pin=33, - initsequence=( - (SETEXTC, 0xFF, 0x93, 0x42), - (PWCTR1, 0x12, 0x12), - (PWCTR2, 0x03), - (VMCTR1, 0xF2), - (IFMODE, 0xE0), - (0xF6, 0x01, 0x00, 0x00), - ( - GMCTRP1, - 0x00, - 0x0C, - 0x11, - 0x04, - 0x11, - 0x08, - 0x37, - 0x89, - 0x4C, - 0x06, - 0x0C, - 0x0A, - 0x2E, - 0x34, - 0x0F, - ), - ( - GMCTRN1, - 0x00, - 0x0B, - 0x11, - 0x05, - 0x13, - 0x09, - 0x33, - 0x67, - 0x48, - 0x07, - 0x0E, - 0x0B, - 0x2E, - 0x33, - 0x0F, - ), - (DFUNCTR, 0x08, 0x82, 0x1D, 0x04), - (IDMOFF,), - ), -) ILI9341 = DriverChip( "ILI9341", mirror_x=True, @@ -174,22 +118,6 @@ ILI9342 = DriverChip( ), ) -# M5Stack Core2 uses ILI9341 chip - mirror_x disabled for correct orientation -ILI9341.extend( - "M5CORE2", - # Reset native dimensions due to axis swap. - native_width=320, - native_height=240, - width=320, - height=240, - mirror_x=False, - cs_pin=5, - dc_pin=15, - invert_colors=True, - pixel_mode="18bit", - data_rate="40MHz", -) - DriverChip( "ILI9481", mirror_x=True, diff --git a/esphome/components/mipi_spi/models/m5stack.py b/esphome/components/mipi_spi/models/m5stack.py new file mode 100644 index 0000000000..81bb186278 --- /dev/null +++ b/esphome/components/mipi_spi/models/m5stack.py @@ -0,0 +1,71 @@ +from esphome.components.mipi import ( + DFUNCTR, + GMCTRN1, + GMCTRP1, + IDMOFF, + IFMODE, + PWCTR1, + PWCTR2, + SETEXTC, + VMCTR1, + DriverChip, +) + +from .ili import ILI9341, ST7789V + +# fmt: off +DriverChip( + "M5CORE", + width=320, + height=240, + cs_pin=14, + dc_pin=27, + reset_pin=33, + initsequence=( + (SETEXTC, 0xFF, 0x93, 0x42), + (PWCTR1, 0x12, 0x12), + (PWCTR2, 0x03), + (VMCTR1, 0xF2), + (IFMODE, 0xE0), + (0xF6, 0x01, 0x00, 0x00), + (GMCTRP1, 0x00, 0x0C, 0x11, 0x04, 0x11, 0x08, 0x37, 0x89, 0x4C, 0x06, 0x0C, 0x0A, 0x2E, 0x34, 0x0F,), + (GMCTRN1, 0x00, 0x0B, 0x11, 0x05, 0x13, 0x09, 0x33, 0x67, 0x48, 0x07, 0x0E, 0x0B, 0x2E, 0x33, 0x0F,), + (DFUNCTR, 0x08, 0x82, 0x1D, 0x04), + (IDMOFF,), + ), +) + +# M5Stack Core2 uses ILI9341 chip - mirror_x disabled for correct orientation +ILI9341.extend( + "M5CORE2", + # Reset native dimensions due to axis swap. + native_width=320, + native_height=240, + width=320, + height=240, + mirror_x=False, + cs_pin=5, + dc_pin=15, + invert_colors=True, + pixel_mode="18bit", + data_rate="40MHz", +) + +GC9107 = ST7789V.extend( + "GC9107", + width=128, + height=128, + offset_width=2, + offset_height=1, + pad_width=2, + pad_height=1, +) + +GC9107.extend( + "M5STACK-ATOMS3R-GC9107", + data_rate="40MHz", + invert_colors=True, + reset_pin=48, + dc_pin=42, + cs_pin=14, +) From d9998eff20fbc02f21d41484fa68ddcb891305ab Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 6 Jul 2026 19:39:18 -0500 Subject: [PATCH 0757/1815] [esp32] Add software OTA downgrade protection (#17315) Co-authored-by: Claude Opus 4.8 (1M context) --- esphome/components/const/__init__.py | 1 + esphome/components/esp32/__init__.py | 67 +++++++++++++++++++ esphome/components/ota/ota_backend.cpp | 28 ++++++++ esphome/components/ota/ota_backend.h | 15 +++++ .../components/ota/ota_backend_esp_idf.cpp | 20 ++++++ esphome/core/defines.h | 1 + esphome/espota2.py | 6 ++ tests/component_tests/esp32/test_esp32.py | 33 +++++++++ ...ota_downgrade_protection.esp32-s3-idf.yaml | 22 ++++++ tests/components/md5/__init__.py | 9 +++ tests/components/ota/test_version_compare.cpp | 52 ++++++++++++++ 11 files changed, 254 insertions(+) create mode 100644 tests/components/esp32/test-ota_downgrade_protection.esp32-s3-idf.yaml create mode 100644 tests/components/md5/__init__.py create mode 100644 tests/components/ota/test_version_compare.cpp diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 85878a6306..6f4fa9aaa7 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -16,6 +16,7 @@ CONF_COLOR_DEPTH = "color_depth" CONF_CRC_ENABLE = "crc_enable" CONF_DATA_BITS = "data_bits" CONF_DRAW_ROUNDING = "draw_rounding" +CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection" CONF_ENABLED = "enabled" CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index a5528da672..5a7ddb6c76 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -11,6 +11,7 @@ from typing import Any from esphome import yaml_util import esphome.codegen as cg +from esphome.components.const import CONF_ENABLE_OTA_DOWNGRADE_PROTECTION import esphome.config_validation as cv from esphome.const import ( CONF_ADVANCED, @@ -29,6 +30,7 @@ from esphome.const import ( CONF_PATH, CONF_PLATFORM_VERSION, CONF_PLATFORMIO_OPTIONS, + CONF_PROJECT, CONF_REF, CONF_SAFE_MODE, CONF_SIZE, @@ -1098,6 +1100,50 @@ def _detect_variant(value): return value +def _ota_downgrade_protection_errors( + project_version: str | None, signed_ota_enabled: bool +) -> list[cv.Invalid]: + """Validate prerequisites for OTA downgrade protection. + + Called only when the feature is enabled. Returns a ``cv.Invalid`` for each + unmet requirement: a dotted-numeric project version (the firmware version + compared on-device) and signed OTA (so the embedded version cannot be + forged). + """ + path = [CONF_FRAMEWORK, CONF_ADVANCED, CONF_ENABLE_OTA_DOWNGRADE_PROTECTION] + errs: list[cv.Invalid] = [] + if not project_version: + errs.append( + cv.Invalid( + f"'{CONF_ENABLE_OTA_DOWNGRADE_PROTECTION}' requires a " + f"'{CONF_PROJECT}' with a '{CONF_VERSION}' to be set in the " + f"'{CONF_ESPHOME}' section; this version is the firmware version " + "compared during OTA.", + path=path, + ) + ) + elif not re.fullmatch(r"\d+(\.\d+)*", project_version): + # The on-device comparison parses dotted-numeric versions only. + errs.append( + cv.Invalid( + f"'{CONF_ENABLE_OTA_DOWNGRADE_PROTECTION}' requires the " + f"'{CONF_PROJECT}' '{CONF_VERSION}' to be dotted-numeric (such " + f"as '1.2.3'), got '{project_version}'.", + path=path, + ) + ) + if not signed_ota_enabled: + errs.append( + cv.Invalid( + f"'{CONF_ENABLE_OTA_DOWNGRADE_PROTECTION}' requires " + f"'{CONF_SIGNED_OTA_VERIFICATION}' to be enabled; without signed " + "OTA the embedded version cannot be trusted.", + path=path, + ) + ) + return errs + + def final_validate(config): # Imported locally to avoid circular import issues from esphome.components.psram import DOMAIN as PSRAM_DOMAIN @@ -1303,6 +1349,14 @@ def final_validate(config): "Binaries will NOT be signed automatically during build. " "You must sign them externally before flashing." ) + if advanced[CONF_ENABLE_OTA_DOWNGRADE_PROTECTION]: + project = full_config[CONF_ESPHOME].get(CONF_PROJECT) + errs.extend( + _ota_downgrade_protection_errors( + project[CONF_VERSION] if project else None, + bool(advanced.get(CONF_SIGNED_OTA_VERIFICATION)), + ) + ) if errs: raise cv.MultipleInvalid(errs) @@ -1540,6 +1594,9 @@ FRAMEWORK_SCHEMA = cv.Schema( min=8192, max=32768 ), cv.Optional(CONF_ENABLE_OTA_ROLLBACK, default=True): cv.boolean, + cv.Optional( + CONF_ENABLE_OTA_DOWNGRADE_PROTECTION, default=False + ): cv.boolean, cv.Optional(CONF_SIGNED_OTA_VERIFICATION): cv.All( cv.Schema( { @@ -2358,6 +2415,16 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE", True) cg.add_define("USE_OTA_ROLLBACK") + # Enable software OTA downgrade protection. Embed the project version into + # the image's esp_app_desc_t so the OTA backend can compare it against the + # running version (final_validate guarantees a dotted-numeric project + # version and that signed OTA is enabled). + if advanced[CONF_ENABLE_OTA_DOWNGRADE_PROTECTION]: + project_version = CORE.config[CONF_ESPHOME][CONF_PROJECT][CONF_VERSION] + add_idf_sdkconfig_option("CONFIG_APP_PROJECT_VER_FROM_CONFIG", True) + add_idf_sdkconfig_option("CONFIG_APP_PROJECT_VER", project_version) + cg.add_define("USE_OTA_DOWNGRADE_PROTECTION") + # Enable signed app verification without hardware secure boot if signed_ota := advanced.get(CONF_SIGNED_OTA_VERIFICATION): add_idf_sdkconfig_option("CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT", True) diff --git a/esphome/components/ota/ota_backend.cpp b/esphome/components/ota/ota_backend.cpp index 17949de642..0447b968a3 100644 --- a/esphome/components/ota/ota_backend.cpp +++ b/esphome/components/ota/ota_backend.cpp @@ -2,6 +2,34 @@ namespace esphome::ota { +bool version_is_older(const char *candidate, const char *reference) { + if (candidate == nullptr || reference == nullptr) + return false; + while (true) { + uint32_t a = 0; + while (*candidate >= '0' && *candidate <= '9') { + a = a * 10 + static_cast(*candidate - '0'); + candidate++; + } + uint32_t b = 0; + while (*reference >= '0' && *reference <= '9') { + b = b * 10 + static_cast(*reference - '0'); + reference++; + } + if (a != b) + return a < b; + // Components equal so far; advance past a single separator on each side. + const bool a_more = (*candidate == '.'); + const bool b_more = (*reference == '.'); + if (a_more) + candidate++; + if (b_more) + reference++; + if (!a_more && !b_more) + return false; // Both strings exhausted with all components equal. + } +} + #ifdef USE_OTA_STATE_LISTENER OTAGlobalCallback *global_ota_callback{nullptr}; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index de236c1951..01be46a518 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -46,9 +46,24 @@ enum OTAResponseTypes { OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE = 0x90, OTA_RESPONSE_ERROR_BOOTLOADER_VERIFY = 0x91, OTA_RESPONSE_ERROR_BOOTLOADER_UPDATE = 0x92, + OTA_RESPONSE_ERROR_VERSION_DOWNGRADE = 0x93, OTA_RESPONSE_ERROR_UNKNOWN = 0xFF, }; +/** Compare two dotted-numeric version strings (such as "1.2.3"). + * + * Returns true when @p candidate represents a strictly older (lower) firmware + * version than @p reference. Each dot-separated component is parsed as an + * integer and compared left-to-right; absent trailing components count as 0, + * so "1.2" and "1.2.0" are equal. Equal versions return false so that + * re-flashing the same version is permitted. + * + * Used for software OTA downgrade protection. Inputs come from the project + * version embedded in the signed firmware image, which is validated to be + * dotted-numeric at config time. Non-digit characters terminate a component. + */ +bool version_is_older(const char *candidate, const char *reference); + enum OTAState { OTA_COMPLETED = 0, OTA_STARTED, diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index ac765d8018..8fd21f42bd 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -9,6 +9,9 @@ #include #include #include +#ifdef USE_OTA_DOWNGRADE_PROTECTION +#include +#endif namespace esphome::ota { @@ -159,6 +162,23 @@ OTAResponseTypes IDFOTABackend::end() { } #endif if (err == ESP_OK) { +#ifdef USE_OTA_DOWNGRADE_PROTECTION + // The image is written and (when signing is enabled) signature-verified by + // esp_ota_end(), so its embedded project version can be trusted. Reject the + // update if it is older than the running version by leaving the boot + // partition unchanged -- the staged image simply never boots. + esp_app_desc_t incoming; + esp_err_t desc_err = esp_ota_get_partition_description(this->partition_, &incoming); + if (desc_err != ESP_OK) { + // Couldn't read the staged image's version, so the comparison is skipped. + // Warn so the bypassed check is observable rather than silent. + ESP_LOGW(TAG, "Downgrade protection: could not read image version (err=0x%X); allowing update", desc_err); + } else if (version_is_older(incoming.version, ESPHOME_PROJECT_VERSION)) { + ESP_LOGE(TAG, "Rejecting downgrade: image version '%s' is older than running version '%s'", incoming.version, + ESPHOME_PROJECT_VERSION); + return OTA_RESPONSE_ERROR_VERSION_DOWNGRADE; + } +#endif err = esp_ota_set_boot_partition(this->partition_); if (err == ESP_OK) { return OTA_RESPONSE_OK; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index cdc26c9222..1d09bb5c5c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -239,6 +239,7 @@ #define ESPHOME_TASK_LOG_BUFFER_SIZE 768 #define USE_OTA_ROLLBACK #define USE_OTA_SIGNED_VERIFICATION +#define USE_OTA_DOWNGRADE_PROTECTION #define USE_ESP32_MIN_CHIP_REVISION_SET #define USE_ESP32_RTC_PREFERENCES #define USE_ESP32_SRAM1_AS_IRAM diff --git a/esphome/espota2.py b/esphome/espota2.py index 266702c142..fa15c1dda2 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -52,6 +52,7 @@ RESPONSE_ERROR_PARTITION_TABLE_VERIFY = 0x8F RESPONSE_ERROR_PARTITION_TABLE_UPDATE = 0x90 RESPONSE_ERROR_BOOTLOADER_VERIFY = 0x91 RESPONSE_ERROR_BOOTLOADER_UPDATE = 0x92 +RESPONSE_ERROR_VERSION_DOWNGRADE = 0x93 RESPONSE_ERROR_UNKNOWN = 0xFF OTA_VERSION_1_0 = 1 @@ -157,6 +158,11 @@ _ERROR_MESSAGES: dict[int, str] = { "the bootloader update without rebooting the device. If the device " "fails to boot, recover it via a serial flash." ), + RESPONSE_ERROR_VERSION_DOWNGRADE: ( + "The device rejected the update because it has OTA downgrade protection " + "enabled: the new firmware's version must be newer than the version the " + "device is currently running." + ), RESPONSE_ERROR_UNKNOWN: "Unknown error from ESP", } diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index cea34bef7c..1b189c6331 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -13,6 +13,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32, VARIANTS, NetworkSdkconfigData, + _ota_downgrade_protection_errors, _reconcile_network_sdkconfig, ) from esphome.components.esp32.const import ( @@ -560,3 +561,35 @@ def test_network_wifi_ble_coexistence_reconciles_end_to_end( assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False # WiFi present alongside BT -> WiFi stack must stay enabled. assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig + + +def test_downgrade_protection_passes_with_numeric_version_and_signing() -> None: + assert _ota_downgrade_protection_errors("1.2.3", signed_ota_enabled=True) == [] + + +def test_downgrade_protection_accepts_calendar_version() -> None: + assert _ota_downgrade_protection_errors("2024.12.0", signed_ota_enabled=True) == [] + + +def test_downgrade_protection_requires_project_version() -> None: + errs = _ota_downgrade_protection_errors(None, signed_ota_enabled=True) + assert len(errs) == 1 + assert "version" in str(errs[0]) + + +def test_downgrade_protection_rejects_non_numeric_version() -> None: + errs = _ota_downgrade_protection_errors("1.0-beta", signed_ota_enabled=True) + assert len(errs) == 1 + assert "dotted-numeric" in str(errs[0]) + + +def test_downgrade_protection_requires_signed_ota() -> None: + errs = _ota_downgrade_protection_errors("1.2.3", signed_ota_enabled=False) + assert len(errs) == 1 + assert "signed_ota_verification" in str(errs[0]) + + +def test_downgrade_protection_reports_all_unmet_requirements() -> None: + # No project version and no signing -> two distinct errors. + errs = _ota_downgrade_protection_errors(None, signed_ota_enabled=False) + assert len(errs) == 2 diff --git a/tests/components/esp32/test-ota_downgrade_protection.esp32-s3-idf.yaml b/tests/components/esp32/test-ota_downgrade_protection.esp32-s3-idf.yaml new file mode 100644 index 0000000000..5d6ab455ac --- /dev/null +++ b/tests/components/esp32/test-ota_downgrade_protection.esp32-s3-idf.yaml @@ -0,0 +1,22 @@ +esphome: + project: + name: esphome.downgrade_test + version: "1.2.3" + +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + enable_ota_downgrade_protection: true + signed_ota_verification: + signing_key: ../../components/esp32/dummy_signing_key.pem + signing_scheme: rsa3072 + +# wifi + ota so the IDF OTA backend compiles with USE_OTA_DOWNGRADE_PROTECTION. +wifi: + ssid: MySSID + password: password1 + +ota: + - platform: esphome diff --git a/tests/components/md5/__init__.py b/tests/components/md5/__init__.py new file mode 100644 index 0000000000..cf4ad47363 --- /dev/null +++ b/tests/components/md5/__init__.py @@ -0,0 +1,9 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # md5's to_code calls cg.add_define("USE_MD5"), which gates md5.h. C++ unit + # test builds that pull md5 in transitively (e.g. ota's host backend, which + # has an md5::MD5Digest member) need that define, otherwise md5.h compiles to + # nothing and the dependent headers fail to find md5::MD5Digest. + manifest.enable_codegen() diff --git a/tests/components/ota/test_version_compare.cpp b/tests/components/ota/test_version_compare.cpp new file mode 100644 index 0000000000..4072a45792 --- /dev/null +++ b/tests/components/ota/test_version_compare.cpp @@ -0,0 +1,52 @@ +#include + +#include "esphome/components/ota/ota_backend.h" + +namespace esphome::ota::testing { + +// version_is_older(candidate, reference) == true means candidate is a downgrade +// and should be rejected. + +TEST(VersionIsOlder, PatchOlder) { + EXPECT_TRUE(version_is_older("1.2.3", "1.2.4")); + EXPECT_FALSE(version_is_older("1.2.4", "1.2.3")); +} + +TEST(VersionIsOlder, NumericNotLexical) { + // "1.10.0" is newer than "1.9.0" even though '1' < '9' lexically. + EXPECT_TRUE(version_is_older("1.9.0", "1.10.0")); + EXPECT_FALSE(version_is_older("1.10.0", "1.9.0")); +} + +TEST(VersionIsOlder, MajorMinor) { + EXPECT_TRUE(version_is_older("1.9.9", "2.0.0")); + EXPECT_TRUE(version_is_older("1.2.9", "1.3.0")); + EXPECT_FALSE(version_is_older("2.0.0", "1.9.9")); +} + +TEST(VersionIsOlder, EqualVersionsAllowed) { + // Re-flashing the same version must be permitted. + EXPECT_FALSE(version_is_older("1.2.3", "1.2.3")); + EXPECT_FALSE(version_is_older("2024.1.0", "2024.1.0")); +} + +TEST(VersionIsOlder, DifferingComponentCounts) { + // Missing trailing components count as 0. + EXPECT_FALSE(version_is_older("1.2", "1.2.0")); + EXPECT_FALSE(version_is_older("1.2.0", "1.2")); + EXPECT_TRUE(version_is_older("1.2", "1.2.1")); + EXPECT_FALSE(version_is_older("1.2.1", "1.2")); +} + +TEST(VersionIsOlder, CalendarVersions) { + EXPECT_TRUE(version_is_older("2024.12.0", "2025.1.0")); + EXPECT_FALSE(version_is_older("2025.1.0", "2024.12.0")); +} + +TEST(VersionIsOlder, NullInputsAreSafe) { + EXPECT_FALSE(version_is_older(nullptr, "1.2.3")); + EXPECT_FALSE(version_is_older("1.2.3", nullptr)); + EXPECT_FALSE(version_is_older(nullptr, nullptr)); +} + +} // namespace esphome::ota::testing From a10e005bb44cebb933b3c5f0263006f950d8ff15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Jul 2026 19:54:44 -0500 Subject: [PATCH 0758/1815] [esp8266] Strip lwIP glue dhcp stub message strings from DRAM (#17395) --- esphome/components/esp8266/__init__.py | 6 ++++ .../components/esp8266/lwip_glue_stubs.cpp | 35 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 esphome/components/esp8266/lwip_glue_stubs.cpp diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index b658feb76a..ab742db065 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -332,6 +332,12 @@ async def to_code(config): for symbol in ("vprintf", "printf", "fprintf"): cg.add_build_flag(f"-Wl,--wrap={symbol}") + # Wrap the lwIP2 glue's do-nothing dhcp_cleanup()/dhcp_release() stubs so the + # linker can drop their "STUB: ..." message strings from DRAM. + # See lwip_glue_stubs.cpp for implementation. + for symbol in ("dhcp_cleanup", "dhcp_release"): + 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. diff --git a/esphome/components/esp8266/lwip_glue_stubs.cpp b/esphome/components/esp8266/lwip_glue_stubs.cpp new file mode 100644 index 0000000000..a86c8d75a2 --- /dev/null +++ b/esphome/components/esp8266/lwip_glue_stubs.cpp @@ -0,0 +1,35 @@ +/* + * Linker wrap stubs for the lwIP2 glue's dead DHCP entry points. + * + * The ESP8266 SDK blobs call dhcp_cleanup() and dhcp_release() when the + * station leaves an access point (cnx_sta_leave, wifi_station_dhcpc_stop). + * In the prebuilt lwIP2 glue (liblwip2-*.a, glue-esp/lwip-esp.c) these are + * stubs whose only effect is printing "STUB: dhcp_cleanup" and + * "STUB: dhcp_release"; the real DHCP teardown happens through lwIP2's + * renamed dhcp_cleanup_LWIP2()/dhcp_release_LWIP2() functions. + * + * On ESP8266 .rodata lives in DRAM, so those message strings waste scarce + * RAM. Wrapping the stubs with silent equivalents lets the linker garbage + * collect the glue stub bodies together with their strings. + * + * Saves 38 bytes of RAM and removes the "STUB:" log noise on Wi-Fi + * disconnect. Behavior is otherwise unchanged. + */ + +#if defined(USE_ESP8266) + +namespace esphome::esp8266 {} + +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" { + +// The callers are closed-source SDK blobs; the netif argument is unused. +void __wrap_dhcp_cleanup(void * /*netif*/) {} + +// The glue stub returns ERR_ABRT (-8; lwIP 1.4 err_t is a signed char). +signed char __wrap_dhcp_release(void * /*netif*/) { return -8; } + +} // extern "C" +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +#endif // USE_ESP8266 From 902cf6a67967ce8bf03ab90ca646712666778c30 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Jul 2026 19:54:53 -0500 Subject: [PATCH 0759/1815] [analyze_memory] Report aliased RAM symbols once in the RAM strings report (#17397) --- esphome/analyze_memory/ram_strings.py | 44 +++++-- .../analyze_memory/test_ram_strings.py | 123 ++++++++++++++++++ 2 files changed, 157 insertions(+), 10 deletions(-) create mode 100644 tests/unit_tests/analyze_memory/test_ram_strings.py diff --git a/esphome/analyze_memory/ram_strings.py b/esphome/analyze_memory/ram_strings.py index fbcbeeca61..03da86de94 100644 --- a/esphome/analyze_memory/ram_strings.py +++ b/esphome/analyze_memory/ram_strings.py @@ -8,7 +8,7 @@ memory-constrained platforms like ESP8266. from __future__ import annotations from collections import defaultdict -from dataclasses import dataclass +from dataclasses import dataclass, field import logging from pathlib import Path import re @@ -65,6 +65,7 @@ class RamSymbol: size: int section: str demangled: str = "" # Demangled name, set after batch demangling + aliases: list[str] = field(default_factory=list) # Other names at same address class RamStringsAnalyzer: @@ -235,6 +236,11 @@ class RamStringsAnalyzer: except (subprocess.CalledProcessError, FileNotFoundError): return + # Track symbols by address so aliases (multiple names for the same + # object, e.g. the newlib __lock___* mutexes that all alias one + # StaticSemaphore_t) are reported once instead of once per name. + symbols_by_addr: dict[int, RamSymbol] = {} + for line in output.split("\n"): parts = line.split() if len(parts) < 4: @@ -253,6 +259,18 @@ class RamStringsAnalyzer: if sym_type not in DATA_SYMBOL_TYPES: continue + if (existing := symbols_by_addr.get(addr)) is not None: + # Prefer a global (uppercase type) name as the primary so + # nm output order can't hide it behind a local alias. + if sym_type.isupper() and existing.sym_type.islower(): + existing.aliases.append(existing.name) + existing.name = name + existing.sym_type = sym_type + else: + existing.aliases.append(name) + existing.size = max(existing.size, size) + continue + # Check if symbol is in a RAM section for section_name in self.ram_sections: if section_name not in self.sections: @@ -260,15 +278,15 @@ class RamStringsAnalyzer: section = self.sections[section_name] if section.address <= addr < section.address + section.size: - self.ram_symbols.append( - RamSymbol( - name=name, - sym_type=sym_type, - address=addr, - size=size, - section=section_name, - ) + symbol = RamSymbol( + name=name, + sym_type=sym_type, + address=addr, + size=size, + section=section_name, ) + symbols_by_addr[addr] = symbol + self.ram_symbols.append(symbol) break def _demangle_symbols(self) -> None: @@ -436,7 +454,13 @@ class RamStringsAnalyzer: for symbol in largest_symbols: # Use demangled name if available, otherwise raw name display_name = symbol.demangled or symbol.name - name_display = display_name[:49] if len(display_name) > 49 else display_name + # Truncate the name, not the alias note, so merged aliases stay + # visible even for long demangled C++ names. + alias_note = f" (+{len(symbol.aliases)} aliases)" if symbol.aliases else "" + max_name_len = 49 - len(alias_note) + if len(display_name) > max_name_len: + display_name = display_name[:max_name_len] + name_display = display_name + alias_note lines.append( f"{name_display:<50} {symbol.sym_type:<6} {symbol.size:>8} B {symbol.section}" ) diff --git a/tests/unit_tests/analyze_memory/test_ram_strings.py b/tests/unit_tests/analyze_memory/test_ram_strings.py new file mode 100644 index 0000000000..dda793a7f1 --- /dev/null +++ b/tests/unit_tests/analyze_memory/test_ram_strings.py @@ -0,0 +1,123 @@ +"""Tests for RAM symbol analysis in the RAM strings analyzer.""" + +from pathlib import Path +from unittest.mock import patch + +from esphome.analyze_memory.ram_strings import RamStringsAnalyzer, SectionInfo + +# nm -S --size-sort output with the newlib lock mutexes: nine global +# symbols that are all aliases of two local StaticSemaphore_t objects. +NM_OUTPUT_WITH_ALIASES = """\ +3ffb4400 00000010 B small_symbol +3ffb43c8 00000054 B __lock___atexit_recursive_mutex +3ffb43c8 00000054 B __lock___env_recursive_mutex +3ffb43c8 00000054 B __lock___malloc_recursive_mutex +3ffb43c8 00000054 B __lock___sfp_recursive_mutex +3ffb43c8 00000054 B __lock___sinit_recursive_mutex +3ffb43c8 00000054 b s_common_recursive_mutex +3ffb441c 00000054 B __lock___arc4random_mutex +3ffb441c 00000054 B __lock___at_quick_exit_mutex +3ffb441c 00000054 B __lock___dd_hash_mutex +3ffb441c 00000054 B __lock___tz_mutex +3ffb441c 00000054 b s_common_mutex +""" + + +def _make_analyzer(tmp_path) -> RamStringsAnalyzer: + """Create an analyzer with a dummy ELF and a .dram0.bss section.""" + elf = tmp_path / "firmware.elf" + elf.write_bytes(b"\x7fELF") + analyzer = RamStringsAnalyzer(str(elf), platform="esp32") + analyzer.sections[".dram0.bss"] = SectionInfo(".dram0.bss", 0x3FFB0000, 0x10000) + return analyzer + + +def _run_symbol_analysis(analyzer: RamStringsAnalyzer, nm_output: str) -> None: + """Run _analyze_symbols with mocked nm output.""" + with ( + patch( + "esphome.analyze_memory.ram_strings.find_tool", + return_value="nm", + ), + patch.object(analyzer, "_run_command", return_value=nm_output), + ): + analyzer._analyze_symbols() + + +def test_aliased_symbols_counted_once(tmp_path: Path) -> None: + """Symbols sharing an address are one object, not one per name.""" + analyzer = _make_analyzer(tmp_path) + _run_symbol_analysis(analyzer, NM_OUTPUT_WITH_ALIASES) + + # Three distinct addresses, so three symbols + assert len(analyzer.ram_symbols) == 3 + total = sum(s.size for s in analyzer.ram_symbols) + assert total == 0x10 + 0x54 + 0x54 + + +def test_aliases_recorded_on_first_symbol(tmp_path: Path) -> None: + """Extra names at the same address are kept as aliases.""" + analyzer = _make_analyzer(tmp_path) + _run_symbol_analysis(analyzer, NM_OUTPUT_WITH_ALIASES) + + by_addr = {s.address: s for s in analyzer.ram_symbols} + assert len(by_addr[0x3FFB43C8].aliases) == 5 + assert len(by_addr[0x3FFB441C].aliases) == 4 + assert by_addr[0x3FFB4400].aliases == [] + assert "s_common_mutex" in by_addr[0x3FFB441C].aliases + + +def test_alias_count_shown_in_report(tmp_path: Path) -> None: + """The large symbols table notes how many aliases were merged.""" + analyzer = _make_analyzer(tmp_path) + _run_symbol_analysis(analyzer, NM_OUTPUT_WITH_ALIASES) + + report = analyzer.generate_report() + assert "(+5 aliases)" in report + assert "(+4 aliases)" in report + # Each lock name appears at most once in the report + assert report.count("__lock___") == 2 + + +def test_global_name_preferred_over_local_alias(tmp_path: Path) -> None: + """A global name becomes the primary even when nm lists a local first.""" + analyzer = _make_analyzer(tmp_path) + nm_output = """\ +3ffb43c8 00000054 b s_common_recursive_mutex +3ffb43c8 00000054 B __lock___atexit_recursive_mutex +3ffb43c8 00000054 B __lock___malloc_recursive_mutex +""" + _run_symbol_analysis(analyzer, nm_output) + + (symbol,) = analyzer.ram_symbols + assert symbol.name == "__lock___atexit_recursive_mutex" + assert symbol.sym_type == "B" + assert sorted(symbol.aliases) == [ + "__lock___malloc_recursive_mutex", + "s_common_recursive_mutex", + ] + + +def test_alias_note_survives_name_truncation(tmp_path: Path) -> None: + """Long names are truncated but the alias note is kept intact.""" + analyzer = _make_analyzer(tmp_path) + long_name = "a_very_long_symbol_name_that_exceeds_the_column_width_by_far" + nm_output = f"""\ +3ffb43c8 00000054 B {long_name} +3ffb43c8 00000054 B other_name +""" + _run_symbol_analysis(analyzer, nm_output) + + report = analyzer.generate_report() + row = next(line for line in report.splitlines() if "(+1 aliases)" in line) + name_column = row[:50].rstrip() + assert name_column.endswith("(+1 aliases)") + assert name_column.startswith("a_very_long_symbol_name") + + +def test_symbols_outside_ram_sections_skipped(tmp_path: Path) -> None: + """Symbols outside known RAM sections are ignored entirely.""" + analyzer = _make_analyzer(tmp_path) + nm_output = "40080000 00000100 B not_in_ram\n" + _run_symbol_analysis(analyzer, nm_output) + assert analyzer.ram_symbols == [] From a36c3063b2c8e302092f4440070b7d194e5f8c4b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Jul 2026 19:55:02 -0500 Subject: [PATCH 0760/1815] [web_server] Use known message length in SSE send path (#17400) --- esphome/components/web_server/web_server.cpp | 23 +++++++------- esphome/components/web_server/web_server.h | 6 ++-- .../web_server_idf/web_server_idf.cpp | 30 +++++++++---------- .../web_server_idf/web_server_idf.h | 6 ++-- 4 files changed, 35 insertions(+), 30 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index cdb8544fbb..96195a8270 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -257,8 +257,10 @@ void DeferredUpdateEventSource::deferrable_send_state(void *source, const char * } // used for logs plus the initial ping/config -void DeferredUpdateEventSource::try_send_nodefer(const char *message, const char *event, uint32_t id, - uint32_t reconnect) { +void DeferredUpdateEventSource::try_send_nodefer(const char *message, size_t message_len, const char *event, + uint32_t id, uint32_t reconnect) { + // ESPAsyncWebServer's send() only accepts null-terminated strings + (void) message_len; this->send(message, event, id, reconnect); } @@ -279,10 +281,10 @@ void DeferredUpdateEventSourceList::deferrable_send_state(void *source, const ch } } -void DeferredUpdateEventSourceList::try_send_nodefer(const char *message, const char *event, uint32_t id, - uint32_t reconnect) { +void DeferredUpdateEventSourceList::try_send_nodefer(const char *message, size_t message_len, const char *event, + uint32_t id, uint32_t reconnect) { for (DeferredUpdateEventSource *dues : *this) { - dues->try_send_nodefer(message, event, id, reconnect); + dues->try_send_nodefer(message, message_len, event, id, reconnect); } } @@ -304,7 +306,7 @@ void DeferredUpdateEventSourceList::on_client_connect_(DeferredUpdateEventSource // Configure reconnect timeout and send config // this should always go through since the AsyncEventSourceClient event queue is empty on connect auto message = ws->get_config_json(); - source->try_send_nodefer(message.c_str(), "ping", millis(), 30000); + source->try_send_nodefer(message.c_str(), message.size(), "ping", millis(), 30000); #ifdef USE_WEBSERVER_SORTING for (auto &group : ws->sorting_groups_) { @@ -315,7 +317,7 @@ void DeferredUpdateEventSourceList::on_client_connect_(DeferredUpdateEventSource auto group_msg = builder.serialize(); // up to 31 groups should be able to be queued initially without defer - source->try_send_nodefer(group_msg.c_str(), "sorting_group"); + source->try_send_nodefer(group_msg.c_str(), group_msg.size(), "sorting_group"); } #endif @@ -395,8 +397,8 @@ void WebServer::setup() { return; char buf[32]; auto uptime = static_cast(millis_64() / 1000); - buf_append_printf(buf, sizeof(buf), 0, "{\"uptime\":%" PRIu32 "}", uptime); - this->events_.try_send_nodefer(buf, "ping", millis(), 30000); + size_t len = buf_append_printf(buf, sizeof(buf), 0, "{\"uptime\":%" PRIu32 "}", uptime); + this->events_.try_send_nodefer(buf, len, "ping", millis(), 30000); }); } void WebServer::loop() { @@ -414,8 +416,7 @@ void WebServer::loop() { void WebServer::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { (void) level; (void) tag; - (void) message_len; - this->events_.try_send_nodefer(message, "log", millis()); + this->events_.try_send_nodefer(message, message_len, "log", millis()); } #endif diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index e4defdbd9a..42182fe510 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -160,7 +160,8 @@ class DeferredUpdateEventSource final : public AsyncEventSource { void loop(); void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator); - void try_send_nodefer(const char *message, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); + void try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0, + uint32_t reconnect = 0); }; class DeferredUpdateEventSourceList final : public std::list { @@ -173,7 +174,8 @@ class DeferredUpdateEventSourceList final : public std::listsessions_) { if (ses->fd_.load() != 0) { // Skip dead sessions - ses->try_send_nodefer(message, event, id, reconnect); + ses->try_send_nodefer(message, message_len, event, id, reconnect); } } } @@ -600,7 +601,7 @@ void AsyncEventSourceResponse::start_session_main_loop_() { // tcp send buffer is empty on connect, so these should always go through auto message = ws->get_config_json(); - this->try_send_nodefer(message.c_str(), "ping", millis(), 30000); + this->try_send_nodefer(message.c_str(), message.size(), "ping", millis(), 30000); #ifdef USE_WEBSERVER_SORTING for (auto &group : ws->sorting_groups_) { @@ -612,7 +613,7 @@ void AsyncEventSourceResponse::start_session_main_loop_() { // a (very) large number of these should be able to be queued initially without defer // since the only thing in the send buffer at this point is the initial ping/config - this->try_send_nodefer(message.c_str(), "sorting_group"); + this->try_send_nodefer(message.c_str(), message.size(), "sorting_group"); } #endif @@ -647,7 +648,7 @@ void AsyncEventSourceResponse::process_deferred_queue_() { while (!deferred_queue_.empty()) { DeferredEvent &de = deferred_queue_.front(); auto message = de.message_generator_(web_server_, de.source_); - if (this->try_send_nodefer(message.c_str(), "state")) { + if (this->try_send_nodefer(message.c_str(), message.size(), "state")) { // O(n) but memory efficiency is more important than speed here which is why std::vector was chosen deferred_queue_.erase(deferred_queue_.begin()); } else { @@ -718,7 +719,7 @@ void AsyncEventSourceResponse::loop() { this->entities_iterator_.advance(); } -bool AsyncEventSourceResponse::try_send_nodefer(const char *message, const char *event, uint32_t id, +bool AsyncEventSourceResponse::try_send_nodefer(const char *message, size_t message_len, const char *event, uint32_t id, uint32_t reconnect) { if (this->fd_.load() == 0) { return false; @@ -764,19 +765,18 @@ bool AsyncEventSourceResponse::try_send_nodefer(const char *message, const char // Fast path: check if message contains any newlines at all // Most SSE messages (JSON state updates) have no newlines - const char *first_n = strchr(message, '\n'); - const char *first_r = strchr(message, '\r'); + const char *first_n = static_cast(memchr(message, '\n', message_len)); + const char *first_r = static_cast(memchr(message, '\r', message_len)); if (first_n == nullptr && first_r == nullptr) { // No newlines - fast path (most common case) event_buffer_.append("data: ", sizeof("data: ") - 1); - event_buffer_.append(message); + event_buffer_.append(message, message_len); event_buffer_.append(CRLF_STR CRLF_STR, CRLF_LEN * 2); // data line + blank line terminator } else { // Has newlines - handle multi-line message const char *line_start = message; - size_t msg_len = strlen(message); - const char *msg_end = message + msg_len; + const char *msg_end = message + message_len; // Reuse the first search results const char *next_n = first_n; @@ -789,7 +789,7 @@ bool AsyncEventSourceResponse::try_send_nodefer(const char *message, const char if (next_n == nullptr && next_r == nullptr) { // No more line breaks - output remaining text as final line event_buffer_.append("data: ", sizeof("data: ") - 1); - event_buffer_.append(line_start); + event_buffer_.append(line_start, msg_end - line_start); event_buffer_.append(CRLF_STR, CRLF_LEN); break; } @@ -828,8 +828,8 @@ bool AsyncEventSourceResponse::try_send_nodefer(const char *message, const char } // Search for next newlines only in remaining string - next_n = strchr(line_start, '\n'); - next_r = strchr(line_start, '\r'); + next_n = static_cast(memchr(line_start, '\n', msg_end - line_start)); + next_r = static_cast(memchr(line_start, '\r', msg_end - line_start)); } // Terminate message with blank line @@ -884,7 +884,7 @@ void AsyncEventSourceResponse::deferrable_send_state(void *source, const char *e deq_push_back_with_dedup_(source, message_generator); } else { auto message = message_generator(web_server_, source); - if (!this->try_send_nodefer(message.c_str(), "state")) { + if (!this->try_send_nodefer(message.c_str(), message.size(), "state")) { deq_push_back_with_dedup_(source, message_generator); } } diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index c622d53e89..c631cd1453 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -291,7 +291,8 @@ class AsyncEventSourceResponse { friend class AsyncEventSource; public: - bool try_send_nodefer(const char *message, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); + bool try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0, + uint32_t reconnect = 0); void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator); void loop(); @@ -343,7 +344,8 @@ class AsyncEventSource : public AsyncWebHandler { // NOLINTNEXTLINE(readability-identifier-naming) void onConnect(connect_handler_t &&cb) { this->on_connect_ = std::move(cb); } - void try_send_nodefer(const char *message, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); + void try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0, + uint32_t reconnect = 0); void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator); /// Returns true if there are sessions remaining (including pending cleanup). bool loop(); From e8d37e5bd362bb49710dd90485b45200b6efa31c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Jul 2026 19:55:12 -0500 Subject: [PATCH 0761/1815] [libretiny] Use standard logger tag names (#17431) --- esphome/components/libretiny/gpio_arduino.cpp | 2 +- esphome/components/libretiny/lt_component.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/libretiny/gpio_arduino.cpp b/esphome/components/libretiny/gpio_arduino.cpp index 1af0dce16d..b1a37cb225 100644 --- a/esphome/components/libretiny/gpio_arduino.cpp +++ b/esphome/components/libretiny/gpio_arduino.cpp @@ -5,7 +5,7 @@ namespace esphome::libretiny { -static const char *const TAG = "lt.gpio"; +static const char *const TAG = "libretiny.gpio"; static int IRAM_ATTR flags_to_mode(gpio::Flags flags) { if (flags == gpio::FLAG_INPUT) { diff --git a/esphome/components/libretiny/lt_component.cpp b/esphome/components/libretiny/lt_component.cpp index c01661b3a6..9bbbd66be4 100644 --- a/esphome/components/libretiny/lt_component.cpp +++ b/esphome/components/libretiny/lt_component.cpp @@ -6,7 +6,7 @@ namespace esphome::libretiny { -static const char *const TAG = "lt.component"; +static const char *const TAG = "libretiny"; void LTComponent::dump_config() { ESP_LOGCONFIG(TAG, From ad7c980c4b46b1464f68bea405e94b412f02bd2c Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:16:29 +1000 Subject: [PATCH 0762/1815] [lvgl] Continue activity while display busy (#17374) --- esphome/components/lvgl/__init__.py | 3 ++ esphome/components/lvgl/defines.py | 1 + esphome/components/lvgl/lvgl_esphome.cpp | 60 ++++++++++++++--------- esphome/components/lvgl/lvgl_esphome.h | 20 +++++++- tests/components/lvgl/lvgl-package.yaml | 16 +----- tests/components/lvgl/test.esp32-idf.yaml | 5 +- 6 files changed, 64 insertions(+), 41 deletions(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 9137412abe..08369927b9 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -414,6 +414,8 @@ async def to_code(configs): await cg.register_component(lv_component, config) if rotation := config.get(CONF_ROTATION): cg.add(lv_component.set_rotation(rotation)) + if refr_time := config.get(df.CONF_REFRESH_INTERVAL): + cg.add(lv_component.set_refresh_interval(refr_time.total_milliseconds)) Widget.create(config[CONF_ID], lv_component, LvScrActType(), config) lv_scr_act = get_screen_active(lv_component) @@ -598,6 +600,7 @@ LVGL_TOP_LEVEL_SCHEMA = ( cv.Optional(df.CONF_DEFAULT_FONT, default="montserrat_14"): lvalid.lv_font, cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean, cv.Optional(df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False): cv.boolean, + cv.Optional(df.CONF_REFRESH_INTERVAL): cv.positive_time_period_milliseconds, cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int, cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage, cv.Optional(CONF_ROTATION): validate_rotation, diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index d9be881a7f..53499503d4 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -764,6 +764,7 @@ CONF_PLACEHOLDER_TEXT = "placeholder_text" CONF_POINTS = "points" CONF_PREVIOUS = "previous" CONF_RADIUS = "radius" +CONF_REFRESH_INTERVAL = "refresh_interval" CONF_REPEAT_COUNT = "repeat_count" CONF_RECOLOR = "recolor" CONF_RESUME_ON_INPUT = "resume_on_input" diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 15c2d238be..1db5992389 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -401,7 +401,10 @@ void LvglComponent::draw_buffer_(const lv_area_t *area, lv_color_data *ptr) { } void LvglComponent::flush_cb_(lv_display_t *disp_drv, const lv_area_t *area, uint8_t *color_p) { - if (!this->is_paused()) { + // no guard here for display busy, since LVGL will not call flush_cb until the refresh timer fires, + // and while the display is busy this is reset to 5 minutes. If that expires and the display is still + // busy there are bigger problems. + if (!this->paused_) { auto now = millis(); this->draw_buffer_(area, reinterpret_cast(color_p)); ESP_LOGV(TAG, "flush_cb, area=%d/%d, %d/%d took %dms", (int) area->x1, (int) area->y1, @@ -620,20 +623,20 @@ void LvKeyboardType::set_obj(lv_obj_t *lv_obj) { void LvglComponent::draw_end_() { if (this->draw_end_callback_ != nullptr) this->draw_end_callback_->trigger(); + // Only reachable once the display is idle again: while busy, the display's refr_timer_ is + // paused (see loop()), so LVGL never renders/flushes and this event never fires. if (this->update_when_display_idle_) { for (auto *disp : this->displays_) disp->update(); } } -bool LvglComponent::is_paused() const { - if (this->paused_) - return true; - if (this->update_when_display_idle_) { - for (auto *disp : this->displays_) { - if (!disp->is_idle()) - return true; - } +bool LvglComponent::displays_busy_() const { + if (!this->update_when_display_idle_) + return false; + for (auto *disp : this->displays_) { + if (!disp->is_idle()) + return true; } return false; } @@ -777,6 +780,8 @@ void LvglComponent::setup() { if (this->draw_end_callback_ != nullptr || this->update_when_display_idle_) { lv_display_add_event_cb(this->disp_, render_end_cb, LV_EVENT_REFR_READY, this); } + this->refr_timer_ = lv_display_get_refr_timer(this->disp_); + lv_timer_set_period(this->refr_timer_, this->refr_timer_period_); #if LV_USE_LOG lv_log_register_print_cb([](lv_log_level_t level, const char *buf) { auto next = strchr(buf, ')'); @@ -802,21 +807,32 @@ void LvglComponent::update() { } void LvglComponent::loop() { - if (this->is_paused()) { - if (this->paused_ && this->show_snow_) + if (this->paused_) { + if (this->show_snow_) this->write_random_(); - } else { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - auto now = millis(); - lv_timer_handler(); - auto elapsed = millis() - now; - if (elapsed > 15) { - ESP_LOGV(TAG, "lv_timer_handler took %dms", (int) (millis() - now)); - } -#else - lv_timer_handler(); -#endif + return; } + // Pause/resume the display's own refresh timer to track its busy state. While paused, LVGL + // still keeps track of invalidated areas but won't render or flush them, so nothing needs to + // be discarded or replayed: once resumed, the accumulated areas are simply drawn as normal. + // Input events and other timers keep being processed below regardless of this state. + if (this->update_when_display_idle_) { + bool busy = this->displays_busy_(); + if (busy && !this->refr_timer_paused_) { + this->refr_timer_paused_ = true; + // calling lv_timer_pause() here would be ineffective; LVGL pauses and resumes the timer based on its own internal + // state, which is not aware of the display's busy state. Instead, we extend the timer period to avoid it firing + // while the display is busy. + lv_timer_set_period(this->refr_timer_, 5 * 60 * 1000); + } else if (!busy && this->refr_timer_paused_) { + this->refr_timer_paused_ = false; + lv_timer_set_period(this->refr_timer_, this->refr_timer_period_); + // Don't wait for the timer's next natural period: refresh right away now that the + // display is idle again. + lv_timer_ready(this->refr_timer_); + } + } + lv_timer_handler(); } #ifdef USE_LVGL_ANIMIMG diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 8840b0ad30..dcbf490bce 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -214,9 +214,14 @@ class LvglComponent final : public PollingComponent { // @param paused If true, pause the display. If false, resume the display. // @param show_snow If true, show the snow effect when paused. void set_paused(bool paused, bool show_snow); + void set_refresh_interval(uint32_t period) { + this->refr_timer_period_ = period; + if (this->refr_timer_ != nullptr) + lv_timer_set_period(this->refr_timer_, period); + } - // Returns true if the display is explicitly paused, or a blocking display update is in progress. - bool is_paused() const; + // Returns true if the display has been explicitly paused via set_paused(). + bool is_paused() const { return this->paused_; } // If the display is paused and we have resume_on_input_ set to true, resume the display. void maybe_wakeup() { if (this->paused_ && this->resume_on_input_) { @@ -299,6 +304,9 @@ class LvglComponent final : public PollingComponent { // Not checking for non-null callback since the // LVGL callback that calls it is not set in that case void draw_start_() const { this->draw_start_callback_->trigger(); } + // Returns true if update_when_display_idle is enabled and at least one underlying display + // component is currently busy (e.g. mid-refresh). + bool displays_busy_() const; void write_random_(); void draw_buffer_(const lv_area_t *area, lv_color_data *ptr); @@ -316,6 +324,14 @@ class LvglComponent final : public PollingComponent { uint8_t *draw_buf_{}; lv_display_t *disp_{}; + // The display's own periodic refresh timer, effectively paused while the display is busy (see + // displays_busy_()) so LVGL neither renders nor flushes to it, without losing track of + // invalidated areas. Other timers (indev reading, animations, ...) keep running as normal. + lv_timer_t *refr_timer_{}; + // Tracks whether refr_timer_ is currently paused, so loop() can detect the busy -> idle edge + // and kick off an immediate refresh instead of waiting for the timer's next natural period. + bool refr_timer_paused_{}; + uint32_t refr_timer_period_{16}; uint16_t width_{}; uint16_t height_{}; bool paused_{}; diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 7af058e6b8..4f043db7cb 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -24,20 +24,6 @@ binary_sensor: 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: - id: checkbox_id - state: - checked: !lambda |- - auto y = x; // block inlining of one line return - return y; - - platform: lvgl id: button_presser name: Button pressed @@ -49,6 +35,8 @@ lvgl: rotation: 90 log_level: debug resume_on_input: true + update_when_display_idle: true + refresh_interval: 30ms on_pause: - logger.log: LVGL is Paused - lvgl.display.set_rotation: 90 diff --git a/tests/components/lvgl/test.esp32-idf.yaml b/tests/components/lvgl/test.esp32-idf.yaml index 79ea06f16a..d938017fd9 100644 --- a/tests/components/lvgl/test.esp32-idf.yaml +++ b/tests/components/lvgl/test.esp32-idf.yaml @@ -1,7 +1,8 @@ packages: - lvgl: !include lvgl-package.yaml + lvgl_package: !include lvgl-package.yaml spi: !include ../../test_build_components/common/spi/esp32-idf.yaml i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + lvgl: !include common.yaml sensor: - platform: rotary_encoder @@ -77,5 +78,3 @@ lvgl: - component.update: tft_display - delay: 60s - lvgl.resume: - -<<: !include common.yaml From 9857d508d95efb7403e882cfccd7fc6053ce4e7e Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:57:06 +1000 Subject: [PATCH 0763/1815] [light] Preserve brightness on turn-off. (#17103) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/components/light/light_call.cpp | 18 ++++++----- tests/integration/test_light_calls.py | 43 +++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 7b28065e4e..2b13b40a16 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -213,17 +213,19 @@ LightColorValues LightCall::validate_() { // Flag whether an explicit turn off was requested, in which case we'll also stop the effect. bool explicit_turn_off_request = this->has_state() && !this->state_; - // Turn off when brightness is set to zero, and reset brightness (so that it has nonzero brightness when turned on). - if (this->has_brightness() && this->brightness_ == 0.0f) { + // Treat zero brightness as an implicit turn-off when no state was explicitly requested. + if (this->has_brightness() && this->brightness_ == 0.0f && !this->has_state()) { this->state_ = false; this->set_flag_(FLAG_HAS_STATE); - if (color_mode & ColorCapability::BRIGHTNESS) { - // Reset brightness so the light has nonzero brightness when turned back on. + } + + // Make sure a turn-on makes the light visible: if the resulting brightness would be zero + // (e.g. restored from a brightness=0 turn-off), reset it to full brightness. + if (this->has_state() && this->state_ && (color_mode & ColorCapability::BRIGHTNESS)) { + float brightness = this->has_brightness() ? this->brightness_ : this->parent_->remote_values.get_brightness(); + if (brightness == 0.0f) { this->brightness_ = 1.0f; - } else { - // Light doesn't support brightness; clear the flag to avoid a spurious - // "brightness not supported" warning during capability validation. - this->clear_flag_(FLAG_HAS_BRIGHTNESS); + this->set_flag_(FLAG_HAS_BRIGHTNESS); } } diff --git a/tests/integration/test_light_calls.py b/tests/integration/test_light_calls.py index 0eaf5af91b..a3a4103f5c 100644 --- a/tests/integration/test_light_calls.py +++ b/tests/integration/test_light_calls.py @@ -322,6 +322,49 @@ async def test_light_calls( assert state.state is True assert state.brightness == pytest.approx(0.75) + # Test 31: Setting brightness to 0 without an explicit state implicitly turns + # the light off; turning it back on (without an explicit brightness) then + # restores full brightness so the light is visible again. + client.light_command(key=rgbcw_light.key, state=True, brightness=0.5) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is True + assert state.brightness == pytest.approx(0.5) + + # Brightness 0 with no explicit state -> implicit turn-off + client.light_command(key=rgbcw_light.key, brightness=0.0) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is False + assert state.brightness == pytest.approx(0.0) + # Turning on without an explicit brightness restores it to full brightness + client.light_command(key=rgbcw_light.key, state=True) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is True + assert state.brightness == pytest.approx(1.0) + + # Test 31b: An explicit turn-on with brightness 0 still resets to full + # brightness - a turn-on must never leave the light on-but-invisible. This + # is the same path the restore logic exercises (set_state(true) + + # set_brightness(0) from a persisted brightness=0 turn-off). + client.light_command(key=rgbcw_light.key, state=True, brightness=0.0) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is True + assert state.brightness == pytest.approx(1.0) + + # Test 32: Turning a light on when it already has nonzero brightness leaves + # the brightness unchanged (the reset only happens when brightness is 0). + client.light_command(key=rgbcw_light.key, state=True, brightness=0.4) + state = await wait_for_state_change(rgbcw_light.key) + assert state.brightness == pytest.approx(0.4) + + client.light_command(key=rgbcw_light.key, state=False) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is False + + client.light_command(key=rgbcw_light.key, state=True) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is True + assert state.brightness == pytest.approx(0.4) + # Final cleanup - turn all lights off for light in lights: client.light_command( From 9aed1d2700681390cfe0df5301cb82f4744faaff Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 6 Jul 2026 23:04:38 -0500 Subject: [PATCH 0764/1815] [esp32] Add NVS encryption (HMAC scheme) (#17004) --- esphome/components/esp32/__init__.py | 62 +++++++++++++++++++ .../esp32/config/nvs_encryption_s3.yaml | 10 +++ tests/component_tests/esp32/test_esp32.py | 38 ++++++++++++ .../test-nvs_encryption.esp32-s3-idf.yaml | 9 +++ 4 files changed, 119 insertions(+) create mode 100644 tests/component_tests/esp32/config/nvs_encryption_s3.yaml create mode 100644 tests/components/esp32/test-nvs_encryption.esp32-s3-idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 5a7ddb6c76..e8d1fe73c7 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -109,7 +109,9 @@ CONF_ENGINEERING_SAMPLE = "engineering_sample" CONF_INCLUDE_BUILTIN_IDF_COMPONENTS = "include_builtin_idf_components" CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert" CONF_EXECUTE_FROM_PSRAM = "execute_from_psram" +CONF_KEY_ID = "key_id" CONF_MINIMUM_CHIP_REVISION = "minimum_chip_revision" +CONF_NVS_ENCRYPTION = "nvs_encryption" CONF_RELEASE = "release" CONF_SIGNED_OTA_VERIFICATION = "signed_ota_verification" CONF_SIGNING_KEY = "signing_key" @@ -167,6 +169,20 @@ SIGNED_OTA_V1_ECDSA_VARIANTS = { VARIANT_ESP32, } +# NVS encryption (HMAC peripheral scheme) is only available on variants that +# expose the HMAC peripheral (SOC_HMAC_SUPPORTED in soc_caps.h). The original +# ESP32 and ESP32-C2 do not have it. New variants with an HMAC peripheral +# should be added here. +NVS_ENCRYPTION_HMAC_VARIANTS = { + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32H2, + VARIANT_ESP32P4, +} + COMPILER_OPTIMIZATIONS = { "DEBUG": "CONFIG_COMPILER_OPTIMIZATION_DEBUG", "NONE": "CONFIG_COMPILER_OPTIMIZATION_NONE", @@ -1349,6 +1365,29 @@ def final_validate(config): "Binaries will NOT be signed automatically during build. " "You must sign them externally before flashing." ) + if (nvs_enc := advanced.get(CONF_NVS_ENCRYPTION)) is not None: + variant = config[CONF_VARIANT] + if variant in NVS_ENCRYPTION_HMAC_VARIANTS: + _LOGGER.warning( + "NVS encryption will burn an HMAC key into eFuse key block %d on the " + "first boot of each device. This is PERMANENT and IRREVERSIBLE: " + "the block cannot be erased or reused afterwards. Enabling (or " + "later disabling) encryption also wipes any previously saved " + "preferences once, because the older data can no longer be read.", + nvs_enc[CONF_KEY_ID], + ) + else: + supported = ", ".join( + sorted(VARIANT_FRIENDLY[v] for v in NVS_ENCRYPTION_HMAC_VARIANTS) + ) + errs.append( + cv.Invalid( + f"NVS encryption (HMAC scheme) is not supported on " + f"{VARIANT_FRIENDLY[variant]} (it has no HMAC peripheral). " + f"Supported variants: {supported}.", + path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_NVS_ENCRYPTION], + ) + ) if advanced[CONF_ENABLE_OTA_DOWNGRADE_PROTECTION]: project = full_config[CONF_ESPHOME].get(CONF_PROJECT) errs.extend( @@ -1609,6 +1648,15 @@ FRAMEWORK_SCHEMA = cv.Schema( ), cv.has_exactly_one_key(CONF_SIGNING_KEY, CONF_VERIFICATION_KEY), ), + cv.Optional(CONF_NVS_ENCRYPTION): cv.Schema( + { + # eFuse key block (0-5) that stores the HMAC key from + # which the NVS encryption keys are derived. The block is + # written on first boot if empty -- an irreversible + # operation -- so it must be chosen explicitly. + cv.Required(CONF_KEY_ID): cv.int_range(min=0, max=5), + } + ), cv.Optional( CONF_USE_FULL_CERTIFICATE_BUNDLE, default=False ): cv.boolean, @@ -2451,6 +2499,20 @@ async def to_code(config): cg.add_define("USE_OTA_SIGNED_VERIFICATION") + # Encrypt NVS using the HMAC peripheral scheme. The NVS encryption keys are + # derived at runtime from an HMAC key stored in the configured eFuse block + # (no flash encryption required). The HMAC key is generated and burned into + # the eFuse block on first boot if it is empty. With the scheme selected, + # nvs_sec_provider registers it at startup and the default nvs_flash_init() + # (used in esp32/preferences.cpp) transparently performs the secure init, so + # no C++ changes are needed. + if (nvs_enc := advanced.get(CONF_NVS_ENCRYPTION)) is not None: + add_idf_sdkconfig_option("CONFIG_NVS_ENCRYPTION", True) + add_idf_sdkconfig_option("CONFIG_NVS_SEC_KEY_PROTECT_USING_HMAC", True) + add_idf_sdkconfig_option( + "CONFIG_NVS_SEC_HMAC_EFUSE_KEY_ID", nvs_enc[CONF_KEY_ID] + ) + cg.add_define("ESPHOME_LOOP_TASK_STACK_SIZE", advanced[CONF_LOOP_TASK_STACK_SIZE]) cg.add_define( diff --git a/tests/component_tests/esp32/config/nvs_encryption_s3.yaml b/tests/component_tests/esp32/config/nvs_encryption_s3.yaml new file mode 100644 index 0000000000..371f2e28ca --- /dev/null +++ b/tests/component_tests/esp32/config/nvs_encryption_s3.yaml @@ -0,0 +1,10 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + nvs_encryption: + key_id: 0 diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 1b189c6331..d53e119e9f 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -175,6 +175,29 @@ def test_esp32_default_toolchain_is_esp_idf( r"'ignore_efuse_mac_crc' is not supported on ESP32S3 @ data\['framework'\]\['advanced'\]\['ignore_efuse_mac_crc'\]", id="ignore_efuse_mac_crc_only_on_esp32", ), + pytest.param( + { + "variant": "esp32", + "board": "esp32dev", + "framework": { + "type": "esp-idf", + "advanced": {"nvs_encryption": {"key_id": 0}}, + }, + }, + r"NVS encryption \(HMAC scheme\) is not supported on ESP32 .* @ data\['framework'\]\['advanced'\]\['nvs_encryption'\]", + id="nvs_encryption_unsupported_on_esp32", + ), + pytest.param( + { + "variant": "esp32s3", + "framework": { + "type": "esp-idf", + "advanced": {"nvs_encryption": {"key_id": 6}}, + }, + }, + r"value must be at most 5 .* @ data\['framework'\]\['advanced'\]\['nvs_encryption'\]\['key_id'\]", + id="nvs_encryption_key_id_out_of_range", + ), ], ) def test_esp32_configuration_errors( @@ -214,6 +237,21 @@ def test_execute_from_psram_p4_sdkconfig( assert "CONFIG_SPIRAM_RODATA" not in sdkconfig +def test_nvs_encryption_sdkconfig( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that nvs_encryption sets the HMAC scheme sdkconfig options.""" + generate_main(component_config_path("nvs_encryption_s3.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_NVS_ENCRYPTION") is True + assert sdkconfig.get("CONFIG_NVS_SEC_KEY_PROTECT_USING_HMAC") is True + assert sdkconfig.get("CONFIG_NVS_SEC_HMAC_EFUSE_KEY_ID") == 0 + # The permanent/irreversible eFuse burn is warned about at config time. + assert "PERMANENT and IRREVERSIBLE" in caplog.text + + @pytest.mark.parametrize( ("fixture", "expect_warning"), [ diff --git a/tests/components/esp32/test-nvs_encryption.esp32-s3-idf.yaml b/tests/components/esp32/test-nvs_encryption.esp32-s3-idf.yaml new file mode 100644 index 0000000000..ab9001efec --- /dev/null +++ b/tests/components/esp32/test-nvs_encryption.esp32-s3-idf.yaml @@ -0,0 +1,9 @@ +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + nvs_encryption: + key_id: 0 + +<<: !include common.yaml From f823a23ea412be94c87bd0f8204747a25076f6cf Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Tue, 7 Jul 2026 07:15:37 +0200 Subject: [PATCH 0765/1815] [pcm5122] Add analog gain, channel mixing, volume range, standby/powerdown switch, and XSMT enable pin support (#17313) --- esphome/components/pcm5122/audio_dac.py | 51 ++++++++- esphome/components/pcm5122/pcm5122.cpp | 104 +++++++++++++++++- esphome/components/pcm5122/pcm5122.h | 49 ++++++++- esphome/components/pcm5122/switch/__init__.py | 32 ++++++ .../pcm5122/switch/power_switch.cpp | 12 ++ .../components/pcm5122/switch/power_switch.h | 24 ++++ tests/components/pcm5122/common.yaml | 11 ++ 7 files changed, 274 insertions(+), 9 deletions(-) create mode 100644 esphome/components/pcm5122/switch/__init__.py create mode 100644 esphome/components/pcm5122/switch/power_switch.cpp create mode 100644 esphome/components/pcm5122/switch/power_switch.h diff --git a/esphome/components/pcm5122/audio_dac.py b/esphome/components/pcm5122/audio_dac.py index 0017a1ef5a..c18fb3993e 100644 --- a/esphome/components/pcm5122/audio_dac.py +++ b/esphome/components/pcm5122/audio_dac.py @@ -5,6 +5,7 @@ from esphome.components.audio_dac import AudioDac import esphome.config_validation as cv from esphome.const import ( CONF_BITS_PER_SAMPLE, + CONF_ENABLE_PIN, CONF_ID, CONF_INPUT, CONF_INVERTED, @@ -16,6 +17,11 @@ from esphome.const import ( CODEOWNERS = ["@remcom"] DEPENDENCIES = ["i2c"] +CONF_ANALOG_GAIN = "analog_gain" +CONF_CHANNEL_MIX = "channel_mix" +CONF_VOLUME_MIN_DB = "volume_min_db" +CONF_VOLUME_MAX_DB = "volume_max_db" + pcm5122_ns = cg.esphome_ns.namespace("pcm5122") PCM5122 = pcm5122_ns.class_("PCM5122", AudioDac, cg.Component, i2c.I2CDevice) CONF_PCM5122 = "pcm5122" @@ -27,26 +33,60 @@ PCM5122_BITS_PER_SAMPLE_ENUM = { 32: pcm5122_bits_per_sample.PCM5122_BITS_PER_SAMPLE_32, } +pcm5122_analog_gain = pcm5122_ns.enum("PCM5122AnalogGain") +PCM5122_ANALOG_GAIN_ENUM = { + "0db": pcm5122_analog_gain.PCM5122_ANALOG_GAIN_0DB, + "-6db": pcm5122_analog_gain.PCM5122_ANALOG_GAIN_MINUS_6DB, +} + +pcm5122_channel_mix = pcm5122_ns.enum("PCM5122ChannelMix") +PCM5122_CHANNEL_MIX_ENUM = { + "stereo": pcm5122_channel_mix.PCM5122_CHANNEL_MIX_STEREO, + "left": pcm5122_channel_mix.PCM5122_CHANNEL_MIX_LEFT_ONLY, + "right": pcm5122_channel_mix.PCM5122_CHANNEL_MIX_RIGHT_ONLY, + "swapped": pcm5122_channel_mix.PCM5122_CHANNEL_MIX_SWAPPED, +} + _validate_bits = cv.float_with_unit("bits", "bit") +def _validate_volume_range(config): + if config[CONF_VOLUME_MIN_DB] >= config[CONF_VOLUME_MAX_DB]: + raise cv.Invalid(f"{CONF_VOLUME_MIN_DB} must be less than {CONF_VOLUME_MAX_DB}") + return config + + PCM5122GPIOPin = pcm5122_ns.class_( "PCM5122GPIOPin", cg.GPIOPin, cg.Parented.template(PCM5122), ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(PCM5122), cv.Optional(CONF_BITS_PER_SAMPLE, default="16bit"): cv.All( _validate_bits, cv.enum(PCM5122_BITS_PER_SAMPLE_ENUM) ), + cv.Optional(CONF_ANALOG_GAIN, default="0db"): cv.enum( + PCM5122_ANALOG_GAIN_ENUM, lower=True + ), + cv.Optional(CONF_CHANNEL_MIX, default="stereo"): cv.enum( + PCM5122_CHANNEL_MIX_ENUM, lower=True + ), + cv.Optional(CONF_VOLUME_MIN_DB, default="-52.5dB"): cv.All( + cv.decibel, cv.float_range(min=-103.0, max=24.0) + ), + cv.Optional(CONF_VOLUME_MAX_DB, default="0dB"): cv.All( + cv.decibel, cv.float_range(min=-103.0, max=24.0) + ), + cv.Optional(CONF_ENABLE_PIN): pins.gpio_output_pin_schema, } ) .extend(cv.COMPONENT_SCHEMA) - .extend(i2c.i2c_device_schema(0x4D)) + .extend(i2c.i2c_device_schema(0x4D)), + _validate_volume_range, ) @@ -96,3 +136,10 @@ async def to_code(config): await i2c.register_i2c_device(var, config) cg.add(var.set_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) + cg.add(var.set_analog_gain(config[CONF_ANALOG_GAIN])) + cg.add(var.set_channel_mix(config[CONF_CHANNEL_MIX])) + cg.add(var.set_volume_min_db(config[CONF_VOLUME_MIN_DB])) + cg.add(var.set_volume_max_db(config[CONF_VOLUME_MAX_DB])) + if enable_pin_config := config.get(CONF_ENABLE_PIN): + enable_pin = await cg.gpio_pin_expression(enable_pin_config) + cg.add(var.set_enable_pin(enable_pin)) diff --git a/esphome/components/pcm5122/pcm5122.cpp b/esphome/components/pcm5122/pcm5122.cpp index 68bbd50e4f..d178cb83b8 100644 --- a/esphome/components/pcm5122/pcm5122.cpp +++ b/esphome/components/pcm5122/pcm5122.cpp @@ -10,6 +10,12 @@ namespace esphome::pcm5122 { static const char *const TAG = "pcm5122"; void PCM5122::setup() { + // Hold XSMT low (soft mute asserted) until init completes + if (this->enable_pin_ != nullptr) { + this->enable_pin_->setup(); + this->enable_pin_->digital_write(false); + } + // Select page 0 and verify chip presence via I2C ACK if (!this->select_page_(0)) { ESP_LOGE(TAG, "Write failed"); @@ -51,7 +57,22 @@ void PCM5122::setup() { } this->reg(PCM5122_REG_AUDIO_FORMAT) = PCM5122_AUDIO_FORMAT_I2S | alen; + if (!this->write_channel_mix_()) { + this->mark_failed(); + return; + } + + if (!this->write_analog_gain_()) { + this->mark_failed(); + return; + } + // PLL reference clock: BCK + if (!this->select_page_(0)) { + ESP_LOGE(TAG, "Write failed"); + this->mark_failed(); + return; + } optional pll_ref = this->read_byte(PCM5122_REG_PLL_REF); if (!pll_ref.has_value()) { ESP_LOGE(TAG, "Failed to read PLL_REF"); @@ -67,15 +88,40 @@ void PCM5122::setup() { this->mark_failed(); return; } + + // Release XSMT (soft un-mute) now that init has completed + if (this->enable_pin_ != nullptr) { + this->enable_pin_->digital_write(true); + } } void PCM5122::dump_config() { + const char *channel_mix_str; + switch (this->channel_mix_) { + case PCM5122_CHANNEL_MIX_LEFT_ONLY: + channel_mix_str = "left only"; + break; + case PCM5122_CHANNEL_MIX_RIGHT_ONLY: + channel_mix_str = "right only"; + break; + case PCM5122_CHANNEL_MIX_SWAPPED: + channel_mix_str = "swapped"; + break; + default: + channel_mix_str = "stereo"; + break; + } ESP_LOGCONFIG(TAG, "Audio DAC:"); LOG_I2C_DEVICE(this); ESP_LOGCONFIG(TAG, " Bits per sample: %u\n" + " Analog gain: %s\n" + " Channel mix: %s\n" + " Volume range: %.1f dB to %.1f dB\n" " Muted: %s", - this->bits_per_sample_, YESNO(this->is_muted_)); + this->bits_per_sample_, this->analog_gain_ == PCM5122_ANALOG_GAIN_0DB ? "0 dB" : "-6 dB", + channel_mix_str, this->volume_min_db_, this->volume_max_db_, YESNO(this->is_muted_)); + LOG_PIN(" Enable Pin: ", this->enable_pin_); } bool PCM5122::set_mute_off() { @@ -118,11 +164,11 @@ bool PCM5122::write_mute_() { } bool PCM5122::write_volume_() { - // DVOL register: 0x00 = +24 dB, 0x30 = 0 dB, 0xFF = mute (-0.5 dB/step). - // Note: volume=0.0 maps to -52.5 dB (still audible), not true silence. + // DVOL register: 0x00 = +24 dB, 0x30 = 0 dB, 0xFE = -103 dB, 0xFF = mute (-0.5 dB/step). + // Note: volume=0.0 maps to volume_min_db_, which is not true silence unless set to -103 dB. // Use set_mute_on() for silence. - const uint8_t dvol_max_volume = 0x30; // 0 dB at full scale - const uint8_t dvol_min_volume = 0x99; // -52.5 dB at minimum + const uint8_t dvol_max_volume = static_cast(lroundf(0x30 - this->volume_max_db_ * 2.0f)); + const uint8_t dvol_min_volume = static_cast(lroundf(0x30 - this->volume_min_db_ * 2.0f)); const uint8_t volume_byte = dvol_max_volume + static_cast(lroundf((1.0f - this->volume_) * (dvol_min_volume - dvol_max_volume))); @@ -137,4 +183,52 @@ bool PCM5122::write_volume_() { return true; } +bool PCM5122::write_analog_gain_() { + uint8_t gain_byte = this->analog_gain_; + if (!this->select_page_(1) || !this->write_byte(PCM5122_REG_ANALOG_GAIN, gain_byte)) { + ESP_LOGE(TAG, "Writing analog gain failed"); + return false; + } + return true; +} + +bool PCM5122::write_channel_mix_() { + uint8_t channel_mix_byte = this->channel_mix_; + if (!this->select_page_(0) || !this->write_byte(PCM5122_REG_DAC_DATA_PATH, channel_mix_byte)) { + ESP_LOGE(TAG, "Writing channel mix failed"); + return false; + } + return true; +} + +bool PCM5122::set_standby(bool enable) { + bool prev_standby = this->standby_; + this->standby_ = enable; + if (!this->write_power_control_()) { + this->standby_ = prev_standby; + return false; + } + return true; +} + +bool PCM5122::set_powerdown(bool enable) { + bool prev_powerdown = this->powerdown_; + this->powerdown_ = enable; + if (!this->write_power_control_()) { + this->powerdown_ = prev_powerdown; + return false; + } + return true; +} + +bool PCM5122::write_power_control_() { + uint8_t power_byte = + (this->standby_ ? PCM5122_POWER_CONTROL_RQST : 0) | (this->powerdown_ ? PCM5122_POWER_CONTROL_RQPD : 0); + if (!this->select_page_(0) || !this->write_byte(PCM5122_REG_POWER_CONTROL, power_byte)) { + ESP_LOGE(TAG, "Writing power control failed"); + return false; + } + return true; +} + } // namespace esphome::pcm5122 diff --git a/esphome/components/pcm5122/pcm5122.h b/esphome/components/pcm5122/pcm5122.h index 3c42e4d8d2..199818b06e 100644 --- a/esphome/components/pcm5122/pcm5122.h +++ b/esphome/components/pcm5122/pcm5122.h @@ -3,6 +3,7 @@ #include "esphome/components/audio_dac/audio_dac.h" #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" +#include "esphome/core/gpio.h" #include "esphome/core/hal.h" namespace esphome::pcm5122 { @@ -10,11 +11,13 @@ namespace esphome::pcm5122 { // Page 0 register addresses static const uint8_t PCM5122_REG_PAGE_SELECT = 0x00; static const uint8_t PCM5122_REG_RESET = 0x01; +static const uint8_t PCM5122_REG_POWER_CONTROL = 0x02; static const uint8_t PCM5122_REG_MUTE = 0x03; static const uint8_t PCM5122_REG_GPIO_ENABLE = 0x08; static const uint8_t PCM5122_REG_PLL_REF = 0x0D; static const uint8_t PCM5122_REG_ERROR_DETECT = 0x25; static const uint8_t PCM5122_REG_AUDIO_FORMAT = 0x28; +static const uint8_t PCM5122_REG_DAC_DATA_PATH = 0x2A; static const uint8_t PCM5122_REG_DVOL_LEFT = 0x3D; static const uint8_t PCM5122_REG_DVOL_RIGHT = 0x3E; static const uint8_t PCM5122_REG_GPIO_OUTPUT_SELECT = 0x50; // Base address; GPIO n uses offset n-1 @@ -23,6 +26,9 @@ static const uint8_t PCM5122_REG_GPIO_OUTPUT = 0x56; static const uint8_t PCM5122_REG_GPIO_INVERT = 0x57; static const uint8_t PCM5122_REG_GPIO_INPUT = 0x77; +// Page 1 register addresses +static const uint8_t PCM5122_REG_ANALOG_GAIN = 0x02; + // Register values for init sequence static const uint8_t PCM5122_RESET_MODULES = 0x10; // RSTM: reset audio modules static const uint8_t PCM5122_AUDIO_FORMAT_I2S = 0x00; // AFMT = I2S (bits [5:4] = 00) @@ -35,12 +41,33 @@ static const uint8_t PCM5122_ERROR_DETECT_DISABLE_DIV_AUTOSET = (1 << 1); static const uint8_t PCM5122_PLL_REF_MASK = (7 << 4); // SREF bits [6:4] static const uint8_t PCM5122_PLL_REF_SOURCE_BCK = (1 << 4); // SREF = 001 (BCK) +// Page 0, Register 2 (Power Control): RQST = standby request, RQPD = powerdown request (§10.5.3) +static const uint8_t PCM5122_POWER_CONTROL_RQST = (1 << 4); +static const uint8_t PCM5122_POWER_CONTROL_RQPD = (1 << 0); + +// Page 1, Register 2 (Analog Gain Control): LAGN/RAGN select 0 dB or -6 dB analog gain (§8.3.5.5) +static const uint8_t PCM5122_ANALOG_GAIN_LAGN = (1 << 4); +static const uint8_t PCM5122_ANALOG_GAIN_RAGN = (1 << 0); + enum PCM5122BitsPerSample : uint8_t { PCM5122_BITS_PER_SAMPLE_16 = 16, PCM5122_BITS_PER_SAMPLE_24 = 24, PCM5122_BITS_PER_SAMPLE_32 = 32, }; +enum PCM5122AnalogGain : uint8_t { + PCM5122_ANALOG_GAIN_0DB = 0x00, + PCM5122_ANALOG_GAIN_MINUS_6DB = PCM5122_ANALOG_GAIN_LAGN | PCM5122_ANALOG_GAIN_RAGN, +}; + +// Page 0, Register 0x2A (DAC Data Path): AUPL/AUPR select which channel's data feeds each output (§7.4.2.42) +enum PCM5122ChannelMix : uint8_t { + PCM5122_CHANNEL_MIX_STEREO = 0x11, // Left data -> left out, right data -> right out + PCM5122_CHANNEL_MIX_LEFT_ONLY = 0x12, // Left data -> both outputs + PCM5122_CHANNEL_MIX_RIGHT_ONLY = 0x21, // Right data -> both outputs + PCM5122_CHANNEL_MIX_SWAPPED = 0x22, // Left/right outputs swapped +}; + class PCM5122 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { public: void setup() override; @@ -48,6 +75,11 @@ class PCM5122 final : public audio_dac::AudioDac, public Component, public i2c:: float get_setup_priority() const override { return setup_priority::IO; } void set_bits_per_sample(PCM5122BitsPerSample bits_per_sample) { this->bits_per_sample_ = bits_per_sample; } + void set_analog_gain(PCM5122AnalogGain analog_gain) { this->analog_gain_ = analog_gain; } + void set_channel_mix(PCM5122ChannelMix channel_mix) { this->channel_mix_ = channel_mix; } + void set_volume_min_db(float volume_min_db) { this->volume_min_db_ = volume_min_db; } + void set_volume_max_db(float volume_max_db) { this->volume_max_db_ = volume_max_db; } + void set_enable_pin(GPIOPin *enable_pin) { this->enable_pin_ = enable_pin; } bool set_mute_off() override; bool set_mute_on() override; @@ -56,17 +88,30 @@ class PCM5122 final : public audio_dac::AudioDac, public Component, public i2c:: bool is_muted() override; float volume() override; + bool set_standby(bool enable); + bool set_powerdown(bool enable); + friend class PCM5122GPIOPin; protected: bool select_page_(uint8_t page); bool write_mute_(); bool write_volume_(); + bool write_analog_gain_(); + bool write_channel_mix_(); + bool write_power_control_(); - float volume_{1.0f}; // Matches chip post-reset DVOL default (0x30 = 0 dB) - int16_t current_page_{-1}; // -1 = unknown; cached to skip redundant page-select writes + GPIOPin *enable_pin_{nullptr}; + float volume_{1.0f}; // Matches chip post-reset DVOL default (0x30 = 0 dB) + float volume_min_db_{-52.5f}; // Matches the previous hardcoded minimum (0x99) + float volume_max_db_{0.0f}; // Matches the previous hardcoded maximum (0x30) + int16_t current_page_{-1}; // -1 = unknown; cached to skip redundant page-select writes bool is_muted_{false}; + bool standby_{false}; + bool powerdown_{false}; PCM5122BitsPerSample bits_per_sample_{PCM5122_BITS_PER_SAMPLE_16}; + PCM5122AnalogGain analog_gain_{PCM5122_ANALOG_GAIN_0DB}; + PCM5122ChannelMix channel_mix_{PCM5122_CHANNEL_MIX_STEREO}; }; } // namespace esphome::pcm5122 diff --git a/esphome/components/pcm5122/switch/__init__.py b/esphome/components/pcm5122/switch/__init__.py new file mode 100644 index 0000000000..10519da895 --- /dev/null +++ b/esphome/components/pcm5122/switch/__init__.py @@ -0,0 +1,32 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import CONF_POWER_MODE, ENTITY_CATEGORY_CONFIG + +from ..audio_dac import CONF_PCM5122, PCM5122, pcm5122_ns + +PCM5122PowerSwitch = pcm5122_ns.class_("PCM5122PowerSwitch", switch.Switch) + +pcm5122_power_switch_mode = pcm5122_ns.enum("PCM5122PowerSwitchMode") +PCM5122_POWER_SWITCH_MODE_ENUM = { + "standby": pcm5122_power_switch_mode.PCM5122_POWER_SWITCH_MODE_STANDBY, + "powerdown": pcm5122_power_switch_mode.PCM5122_POWER_SWITCH_MODE_POWERDOWN, +} + +CONFIG_SCHEMA = switch.switch_schema( + PCM5122PowerSwitch, + entity_category=ENTITY_CATEGORY_CONFIG, +).extend( + { + cv.GenerateID(CONF_PCM5122): cv.use_id(PCM5122), + cv.Optional(CONF_POWER_MODE, default="powerdown"): cv.enum( + PCM5122_POWER_SWITCH_MODE_ENUM, lower=True + ), + } +) + + +async def to_code(config): + var = await switch.new_switch(config) + await cg.register_parented(var, config[CONF_PCM5122]) + cg.add(var.set_power_mode(config[CONF_POWER_MODE])) diff --git a/esphome/components/pcm5122/switch/power_switch.cpp b/esphome/components/pcm5122/switch/power_switch.cpp new file mode 100644 index 0000000000..45f0be715d --- /dev/null +++ b/esphome/components/pcm5122/switch/power_switch.cpp @@ -0,0 +1,12 @@ +#include "power_switch.h" + +namespace esphome::pcm5122 { + +void PCM5122PowerSwitch::write_state(bool state) { + bool ok = (this->mode_ == PCM5122_POWER_SWITCH_MODE_STANDBY) ? this->parent_->set_standby(state) + : this->parent_->set_powerdown(state); + if (ok) + this->publish_state(state); +} + +} // namespace esphome::pcm5122 diff --git a/esphome/components/pcm5122/switch/power_switch.h b/esphome/components/pcm5122/switch/power_switch.h new file mode 100644 index 0000000000..47d30f1a9f --- /dev/null +++ b/esphome/components/pcm5122/switch/power_switch.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/components/switch/switch.h" + +#include "../pcm5122.h" + +namespace esphome::pcm5122 { + +enum PCM5122PowerSwitchMode : uint8_t { + PCM5122_POWER_SWITCH_MODE_STANDBY, + PCM5122_POWER_SWITCH_MODE_POWERDOWN, +}; + +class PCM5122PowerSwitch final : public switch_::Switch, public Parented { + public: + void set_power_mode(PCM5122PowerSwitchMode mode) { this->mode_ = mode; } + + protected: + void write_state(bool state) override; + + PCM5122PowerSwitchMode mode_{PCM5122_POWER_SWITCH_MODE_POWERDOWN}; +}; + +} // namespace esphome::pcm5122 diff --git a/tests/components/pcm5122/common.yaml b/tests/components/pcm5122/common.yaml index cf96f57464..a8ae1e6975 100644 --- a/tests/components/pcm5122/common.yaml +++ b/tests/components/pcm5122/common.yaml @@ -4,6 +4,11 @@ audio_dac: i2c_id: i2c_bus address: 0x4D bits_per_sample: 32bit + analog_gain: -6db + channel_mix: swapped + volume_min_db: -60dB + volume_max_db: -3dB + enable_pin: GPIO12 output: - platform: gpio @@ -22,3 +27,9 @@ binary_sensor: number: 4 mode: input: true + +switch: + - platform: pcm5122 + pcm5122: pcm5122_dac + name: PCM5122 Power Down + power_mode: powerdown From 3c2dad67f4b81447f7330aa11a2eae9b454325ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Jul 2026 02:07:34 -0500 Subject: [PATCH 0766/1815] [network] Fix logged use_address with MAC suffix and build it at runtime (#17432) --- esphome/components/api/api_server.cpp | 3 +- .../components/esphome/ota/ota_esphome.cpp | 3 +- esphome/components/ethernet/__init__.py | 4 +- .../components/ethernet/ethernet_component.h | 4 +- esphome/components/network/__init__.py | 13 ++++ esphome/components/network/util.cpp | 23 ++++++ esphome/components/network/util.h | 31 ++------ esphome/components/openthread/__init__.py | 3 +- esphome/components/openthread/openthread.h | 4 +- esphome/components/web_server/web_server.cpp | 3 +- esphome/components/wifi/__init__.py | 3 +- esphome/components/wifi/wifi_component.h | 4 +- .../fixtures/use_address_runtime.yaml | 8 ++ .../use_address_runtime_mac_suffix.yaml | 9 +++ tests/integration/test_use_address_runtime.py | 73 +++++++++++++++++++ 15 files changed, 154 insertions(+), 34 deletions(-) create mode 100644 tests/integration/fixtures/use_address_runtime.yaml create mode 100644 tests/integration/fixtures/use_address_runtime_mac_suffix.yaml create mode 100644 tests/integration/test_use_address_runtime.py diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index ddd03ace4a..efdeb6991b 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -240,12 +240,13 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() { } void APIServer::dump_config() { + char addr_buf[network::USE_ADDRESS_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, "Server:\n" " Address: %s:%u\n" " Listen backlog: %u\n" " Max connections: %u", - network::get_use_address(), this->port_, this->listen_backlog_, MAX_API_CONNECTIONS); + network::get_use_address_to(addr_buf), this->port_, this->listen_backlog_, MAX_API_CONNECTIONS); #ifdef USE_API_NOISE ESP_LOGCONFIG(TAG, " Noise encryption: %s", YESNO(this->noise_ctx_.has_psk())); if (!this->noise_ctx_.has_psk()) { diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index db4a2015a7..cab725f704 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -94,11 +94,12 @@ void ESPHomeOTAComponent::setup() { } void ESPHomeOTAComponent::dump_config() { + char addr_buf[network::USE_ADDRESS_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, "Over-The-Air updates:\n" " Address: %s:%u\n" " Version: %d", - network::get_use_address(), this->port_, USE_OTA_VERSION); + network::get_use_address_to(addr_buf), this->port_, USE_OTA_VERSION); #ifdef USE_OTA_PASSWORD if (!this->password_.empty()) { ESP_LOGCONFIG(TAG, " Password configured"); diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index dc4cbda45c..03fba7164d 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -4,7 +4,7 @@ import logging from esphome import automation, pins from esphome.automation import Condition import esphome.codegen as cg -from esphome.components.network import ip_address_literal +from esphome.components.network import add_use_address, ip_address_literal from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -543,7 +543,7 @@ async def to_code(config): await _to_code_rp2040(var, config) cg.add(var.set_type(ETHERNET_TYPES[config[CONF_TYPE]])) - cg.add(var.set_use_address(config[CONF_USE_ADDRESS])) + add_use_address(var, config[CONF_USE_ADDRESS]) # enable_on_boot defaults to true in C++ - only set if false if not config[CONF_ENABLE_ON_BOOT]: cg.add(var.set_enable_on_boot(False)) diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 16f09a45f0..7160351727 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -145,6 +145,8 @@ class EthernetComponent final : public Component { network::IPAddresses get_ip_addresses(); network::IPAddress get_dns_address(uint8_t num); + /// Returns nullptr when no explicit use_address is configured and the address is + /// derived at runtime from the device name (see network::get_use_address_to()). const char *get_use_address() const { return this->use_address_; } void set_use_address(const char *use_address) { this->use_address_ = use_address; } void get_eth_mac_address_raw(uint8_t *mac); @@ -346,7 +348,7 @@ class EthernetComponent final : public Component { private: // Stores a pointer to a string literal (static storage duration). // ONLY set from Python-generated code with string literals - never dynamic strings. - const char *use_address_{""}; + const char *use_address_{nullptr}; }; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 616a189226..b7dfb8d6d2 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -59,6 +59,19 @@ def ip_address_literal(ip: str | int | None) -> cg.MockObj: return IPAddress(str(ip)) +def add_use_address(var: cg.MockObj, use_address: str) -> None: + """Generate a set_use_address() call only when the address must be baked in. + + The default ".local" is not stored in the firmware; it is rebuilt at + runtime from the device name (see network::get_use_address_to()), which also + picks up the MAC suffix when name_add_mac_suffix is enabled. A compile-time + string could never include that suffix, so baking it in would log the wrong + address. + """ + if use_address != f"{CORE.name}.local": + cg.add(var.set_use_address(use_address)) + + def require_high_performance_networking() -> None: """Request high performance networking for network and WiFi. diff --git a/esphome/components/network/util.cpp b/esphome/components/network/util.cpp index 79ddd3844c..ae250c6a1f 100644 --- a/esphome/components/network/util.cpp +++ b/esphome/components/network/util.cpp @@ -1,5 +1,7 @@ #include "util.h" +#include "esphome/core/application.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #ifdef USE_NETWORK namespace esphome::network { @@ -20,6 +22,27 @@ bool is_disabled() { return false; } +const char *get_use_address_to(std::span buf) { + // Global component pointers are guaranteed to be set by component constructors when USE_* is defined + const char *addr = nullptr; +#if defined(USE_ETHERNET) + addr = ethernet::global_eth_component->get_use_address(); +#elif defined(USE_MODEM) + addr = modem::global_modem_component->get_use_address(); +#elif defined(USE_WIFI) + addr = wifi::global_wifi_component->get_use_address(); +#elif defined(USE_OPENTHREAD) + addr = openthread::global_openthread_component->get_use_address(); +#endif + if (addr != nullptr && addr[0] != '\0') + return addr; + // No explicit use_address configured: the address is the runtime device name + // (which includes the MAC suffix when name_add_mac_suffix is enabled) plus ".local" + const auto &name = App.get_name(); + make_name_with_suffix_to(buf.data(), buf.size(), name.c_str(), name.size(), '.', "local", 5); + return buf.data(); +} + network::IPAddresses get_ip_addresses() { #ifdef USE_ETHERNET if (ethernet::global_eth_component != nullptr) diff --git a/esphome/components/network/util.h b/esphome/components/network/util.h index e4e8a01f8c..17a2ff0977 100644 --- a/esphome/components/network/util.h +++ b/esphome/components/network/util.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/defines.h" #ifdef USE_NETWORK +#include #include #include "esphome/core/helpers.h" #include "ip_address.h" @@ -53,30 +54,12 @@ ESPHOME_ALWAYS_INLINE inline bool is_connected() { /// Return whether the network is disabled (only wifi for now) bool is_disabled(); -/// Get the active network hostname -ESPHOME_ALWAYS_INLINE inline const char *get_use_address() { - // Global component pointers are guaranteed to be set by component constructors when USE_* is defined -#ifdef USE_ETHERNET - return ethernet::global_eth_component->get_use_address(); -#endif - -#ifdef USE_MODEM - return modem::global_modem_component->get_use_address(); -#endif - -#ifdef USE_WIFI - return wifi::global_wifi_component->get_use_address(); -#endif - -#ifdef USE_OPENTHREAD - return openthread::global_openthread_component->get_use_address(); -#endif - -#if !defined(USE_ETHERNET) && !defined(USE_MODEM) && !defined(USE_WIFI) && !defined(USE_OPENTHREAD) - // Fallback when no network component is defined (e.g., host platform) - return ""; -#endif -} +/// Buffer size for get_use_address_to(): 63-char DNS label + ".local" + null terminator +static constexpr size_t USE_ADDRESS_BUFFER_SIZE = 70; +/// Get the active network address for logging. Returns the explicitly configured +/// use_address when one was set, otherwise formats ".local" from the runtime +/// device name into buf (so it includes the MAC suffix from name_add_mac_suffix). +const char *get_use_address_to(std::span buf); IPAddresses get_ip_addresses(); } // namespace esphome::network diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index b54fe2b218..4018ad81e7 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -14,6 +14,7 @@ from esphome.components.esp32 import ( require_vfs_select, ) from esphome.components.mdns import MDNSComponent, enable_mdns_storage +from esphome.components.network import add_use_address from esphome.components.zephyr import zephyr_add_prj_conf from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv @@ -288,7 +289,7 @@ async def to_code(config): enable_mdns_storage() ot = cg.new_Pvariable(config[CONF_ID]) - cg.add(ot.set_use_address(config[CONF_USE_ADDRESS])) + add_use_address(ot, config[CONF_USE_ADDRESS]) await cg.register_component(ot, config) if (poll_period := config.get(CONF_POLL_PERIOD)) is not None: cg.add(ot.set_poll_period(poll_period)) diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index eb48d8a74a..b4654af21f 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -39,6 +39,8 @@ class OpenThreadComponent final : public Component { void on_factory_reset(std::function callback); void defer_factory_reset_external_callback(); + /// Returns nullptr when no explicit use_address is configured and the address is + /// derived at runtime from the device name (see network::get_use_address_to()). const char *get_use_address() const { return this->use_address_; } void set_use_address(const char *use_address) { this->use_address_ = use_address; } #if CONFIG_OPENTHREAD_MTD @@ -76,7 +78,7 @@ class OpenThreadComponent final : public Component { private: // Stores a pointer to a string literal (static storage duration). // ONLY set from Python-generated code with string literals - never dynamic strings. - const char *use_address_{""}; + const char *use_address_{nullptr}; }; extern OpenThreadComponent *global_openthread_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 96195a8270..c8f66755bc 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -421,10 +421,11 @@ void WebServer::on_log(uint8_t level, const char *tag, const char *message, size #endif void WebServer::dump_config() { + char addr_buf[network::USE_ADDRESS_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, "Web Server:\n" " Address: %s:%u", - network::get_use_address(), this->base_->get_port()); + network::get_use_address_to(addr_buf), this->base_->get_port()); } float WebServer::get_setup_priority() const { return setup_priority::WIFI - 1.0f; } diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index af600647c1..dc5c8be4d7 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -14,6 +14,7 @@ from esphome.components.esp32 import ( request_wifi, ) from esphome.components.network import ( + add_use_address, has_high_performance_networking, ip_address_literal, ) @@ -585,7 +586,7 @@ def wifi_network(config, ap, static_ip): @coroutine_with_priority(CoroPriority.COMMUNICATION) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) - cg.add(var.set_use_address(config[CONF_USE_ADDRESS])) + add_use_address(var, config[CONF_USE_ADDRESS]) # Track if any network uses Enterprise authentication has_eap = False diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 0db85c4d75..23b7558564 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -501,6 +501,8 @@ class WiFiComponent final : public Component { network::IPAddress get_dns_address(int num); network::IPAddresses get_ip_addresses(); + /// Returns nullptr when no explicit use_address is configured and the address is + /// derived at runtime from the device name (see network::get_use_address_to()). const char *get_use_address() const { return this->use_address_; } void set_use_address(const char *use_address) { this->use_address_ = use_address; } @@ -996,7 +998,7 @@ class WiFiComponent final : public Component { private: // Stores a pointer to a string literal (static storage duration). // ONLY set from Python-generated code with string literals - never dynamic strings. - const char *use_address_{""}; + const char *use_address_{nullptr}; }; extern WiFiComponent *global_wifi_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/tests/integration/fixtures/use_address_runtime.yaml b/tests/integration/fixtures/use_address_runtime.yaml new file mode 100644 index 0000000000..29f3369285 --- /dev/null +++ b/tests/integration/fixtures/use_address_runtime.yaml @@ -0,0 +1,8 @@ +esphome: + name: use-address-runtime + +host: + +api: + +logger: diff --git a/tests/integration/fixtures/use_address_runtime_mac_suffix.yaml b/tests/integration/fixtures/use_address_runtime_mac_suffix.yaml new file mode 100644 index 0000000000..9785724cd5 --- /dev/null +++ b/tests/integration/fixtures/use_address_runtime_mac_suffix.yaml @@ -0,0 +1,9 @@ +esphome: + name: use-address-mac + name_add_mac_suffix: true + +host: + +api: + +logger: diff --git a/tests/integration/test_use_address_runtime.py b/tests/integration/test_use_address_runtime.py new file mode 100644 index 0000000000..a4cbbb9c5f --- /dev/null +++ b/tests/integration/test_use_address_runtime.py @@ -0,0 +1,73 @@ +"""Integration tests for the runtime-built use_address. + +The default ".local" address is no longer stored as a compile-time string; +it is built at runtime from the device name. This also fixes the logged address +when name_add_mac_suffix is enabled: the baked string used to miss the MAC +suffix, so it never matched the actual mDNS hostname. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Host platform default MAC: 98:35:69:ab:f6:79 -> suffix "abf679" +MAC_SUFFIX = "abf679" + + +@pytest.mark.asyncio +async def test_use_address_runtime( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """The API dump_config logs ".local" built from the device name.""" + address_seen = asyncio.Event() + + def check_output(line: str) -> None: + if "Address: use-address-runtime.local:" in line: + address_seen.set() + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "use-address-runtime" + + try: + await asyncio.wait_for(address_seen.wait(), timeout=10.0) + except TimeoutError: + pytest.fail("Did not log 'Address: use-address-runtime.local:'") + + +@pytest.mark.asyncio +async def test_use_address_runtime_mac_suffix( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """With name_add_mac_suffix the logged address includes the MAC suffix.""" + address_seen = asyncio.Event() + expected = f"Address: use-address-mac-{MAC_SUFFIX}.local:" + + def check_output(line: str) -> None: + if expected in line: + address_seen.set() + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == f"use-address-mac-{MAC_SUFFIX}" + + try: + await asyncio.wait_for(address_seen.wait(), timeout=10.0) + except TimeoutError: + pytest.fail(f"Did not log '{expected}'") From 40c3a4320f1a44c18cd9f3d883ecbdf152383d89 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:44:44 +0200 Subject: [PATCH 0767/1815] [core] add const for litre per hour (#17389) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/components/kamstrup_kmp/sensor.py | 2 +- esphome/const.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/kamstrup_kmp/sensor.py b/esphome/components/kamstrup_kmp/sensor.py index 134ac245bf..75ec432ad9 100644 --- a/esphome/components/kamstrup_kmp/sensor.py +++ b/esphome/components/kamstrup_kmp/sensor.py @@ -21,6 +21,7 @@ from esphome.const import ( UNIT_EMPTY, UNIT_KELVIN, UNIT_KILOWATT, + UNIT_LITRE_PER_HOUR, ) CODEOWNERS = ["@cfeenstra1024"] @@ -37,7 +38,6 @@ CONF_TEMP2 = "temp2" CONF_TEMP_DIFF = "temp_diff" UNIT_GIGA_JOULE = "GJ" -UNIT_LITRE_PER_HOUR = "l/h" # Note: The sensor units are set automatically based un the received data from the meter CONFIG_SCHEMA = ( diff --git a/esphome/const.py b/esphome/const.py index 16d11d3a18..988134fa46 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1255,6 +1255,7 @@ UNIT_KILOVOLT_AMPS_REACTIVE_HOURS = "kvarh" UNIT_KILOWATT = "kW" UNIT_KILOWATT_HOURS = "kWh" UNIT_LITRE = "L" +UNIT_LITRE_PER_HOUR = "L/h" UNIT_LITRE_PER_SECOND = "L/s" UNIT_LUX = "lx" UNIT_MEGAJOULE = "MJ" From af4a6e7ec3d5ec05139f7f3df2d6475d5600dadb Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Tue, 7 Jul 2026 13:55:49 +0200 Subject: [PATCH 0768/1815] [usb_uart] Fix FTDI RX data stall / corruption and input restart reliability (#17348) Co-authored-by: Oliver Kleinecke Co-authored-by: Claude Sonnet 4.6 --- esphome/components/usb_uart/ft23xx.cpp | 52 +++++++++++++++++------- esphome/components/usb_uart/usb_uart.cpp | 5 +++ esphome/components/usb_uart/usb_uart.h | 9 +++- 3 files changed, 50 insertions(+), 16 deletions(-) diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index 2e8ff8bcb5..25e4cc524f 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -3,6 +3,7 @@ #include "usb_uart.h" #include "usb/usb_host.h" #include "esphome/core/log.h" +#include "esphome/core/application.h" #include "esphome/components/uart/uart_debugger.h" #include "esphome/components/bytebuffer/bytebuffer.h" @@ -396,7 +397,14 @@ int USBUartTypeFT23XX::set_dtr_rts_(USBUartChannel *channel) { } void USBUartTypeFT23XX::start_input(USBUartChannel *channel) { - if (!channel->initialised_.load() || channel->input_started_.load()) + if (!channel->initialised_.load()) + return; + + // Use compare_exchange_strong to avoid a check-then-act race: start_input() is called + // from both the USB task (self-restart on success) and the main loop (backpressure + // restart), so a plain load()/store() pair can let both threads submit a transfer. + auto started = false; + if (!channel->input_started_.compare_exchange_strong(started, true)) return; const auto *ep = channel->cdc_dev_.in_ep; @@ -408,39 +416,55 @@ void USBUartTypeFT23XX::start_input(USBUartChannel *channel) { return; } + // FTDI prepends a 2-byte modem/line status header to every bulk IN packet. size_t uart_data_len = (status.data_len > 2) ? (status.data_len - 2) : 0; if (uart_data_len > 0) { ESP_LOGV(TAG, "RX callback: Received %zu bytes, channel=%d", uart_data_len, channel->index_); if (!channel->dummy_receiver_) { - // Copy the entire received UART payload into the ring buffer in one - // operation to avoid per-byte overhead and reduce the chance of - // heap activity in hot paths. - channel->input_buffer_.push(status.data + 2, uart_data_len); + UsbDataChunk *chunk = this->chunk_pool_.allocate(); + if (chunk == nullptr) { + this->usb_data_queue_.increment_dropped_count(); + channel->input_started_.store(false); + // Queue is full — wake the main loop to drain it, then let read_array() + // retrigger start_input() rather than spinning here in the USB task. + this->enable_loop_soon_any_context(); + App.wake_loop_threadsafe(); + return; + } + // Strip the 2-byte FTDI header before queuing. + memcpy(chunk->data, status.data + 2, uart_data_len); + chunk->length = static_cast(uart_data_len); + chunk->channel = channel; + this->usb_data_queue_.push(chunk); #ifdef USE_UART_DEBUGGER if (channel->debug_) { - // Debug path creates a temporary vector for logging only; this is - // acceptable because debug mode is opt-in and not used in release. uart::UARTDebug::log_hex(uart::UART_DIRECTION_RX, std::vector(status.data + 2, status.data + 2 + uart_data_len), ',', channel->debug_prefix_); } #endif + this->enable_loop_soon_any_context(); + App.wake_loop_threadsafe(); } - } else { + } else if (status.data_len >= 2) { ESP_LOGVV(TAG, "RX: Status packet, modem=0x%02X line=0x%02X, ch=%d", status.data[0], status.data[1], channel->index_); } channel->input_started_.store(false); - if (channel->dummy_receiver_ || - channel->input_buffer_.get_free_space() >= channel->cdc_dev_.in_ep->wMaxPacketSize) { - this->start_input(channel); - } + this->start_input(channel); }; - channel->input_started_.store(true); - this->transfer_in(ep->bEndpointAddress, callback, ep->wMaxPacketSize); + if (!this->transfer_in(ep->bEndpointAddress, callback, ep->wMaxPacketSize)) { + ESP_LOGE(TAG, "RX transfer submission failed for ep=0x%02X", ep->bEndpointAddress); + channel->input_started_.store(false); + } +} + +void USBUartTypeFT23XX::on_rx_overflow(USBUartChannel *channel) { + ESP_LOGW(TAG, "RX buffer overflow on channel %d, clearing to resync", channel->index_); + channel->input_buffer_.clear(); } void USBUartTypeFT23XX::enable_channels() { diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index b8749b6a76..a995e93e15 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -228,6 +228,11 @@ void USBUartComponent::loop() { } #endif + // If there is not enough space for the full chunk, let the device subclass + // handle it (e.g. FTDI clears the buffer to prevent mid-telegram corruption). + if (channel->input_buffer_.get_free_space() < chunk->length) { + this->on_rx_overflow(channel); + } // Push data to ring buffer (now safe in main loop) channel->input_buffer_.push(chunk->data, chunk->length); diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index a3501fc8cf..6d60809b38 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -192,9 +192,13 @@ class USBUartComponent : public usb_host::USBClient { void add_channel(USBUartChannel *channel) { this->channels_.push_back(channel); } - void start_input(USBUartChannel *channel); + virtual void start_input(USBUartChannel *channel); void start_output(USBUartChannel *channel); + // Called from loop() when input_buffer_ has insufficient space for the incoming chunk. + // Default is a no-op; override in device-specific subclasses that need resync on overflow. + virtual void on_rx_overflow(USBUartChannel *channel) {} + // Lock-free data transfer from USB task to main loop static constexpr int USB_DATA_QUEUE_SIZE = 32; LockFreeQueue usb_data_queue_; @@ -248,7 +252,8 @@ class USBUartTypeFT23XX : public USBUartTypeCdcAcm { public: USBUartTypeFT23XX(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) {} - void start_input(USBUartChannel *channel); + void start_input(USBUartChannel *channel) override; + void on_rx_overflow(USBUartChannel *channel) override; protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; From 76ee3fe8875764bc6755e6dba413254fec9b33c3 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Tue, 7 Jul 2026 14:56:48 +0200 Subject: [PATCH 0769/1815] [audio_file] Accept mp1/mp2 puremagic detections as MP3 (#17436) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/components/audio_file/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/audio_file/__init__.py b/esphome/components/audio_file/__init__.py index 53193c8008..d59ed7411a 100644 --- a/esphome/components/audio_file/__init__.py +++ b/esphome/components/audio_file/__init__.py @@ -113,7 +113,9 @@ def read_audio_file_and_type(file_config: ConfigType) -> tuple[bytes, MockObj]: media_file_type = audio.AUDIO_FILE_TYPE_ENUM["NONE"] if file_type == "wav": media_file_type = audio.AUDIO_FILE_TYPE_ENUM["WAV"] - elif file_type in ("mp3", "mpeg", "mpga"): + elif file_type in ("mp1", "mp2", "mp3", "mpeg", "mpga"): + # With puremagic >=2.0 this can cause some MP3 (Layer III) files to be labeled as "mp1"/"mp2". + # Treat those labels as MP3 so we still pick the MP3 decoder. media_file_type = audio.AUDIO_FILE_TYPE_ENUM["MP3"] elif file_type == "flac": media_file_type = audio.AUDIO_FILE_TYPE_ENUM["FLAC"] From 7f0e826c323772a3e146693d41ea66b68427e556 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:50:36 +1000 Subject: [PATCH 0770/1815] [lvgl] Add paused option to suppress updates on boot (#16973) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/lvgl/__init__.py | 3 ++ esphome/components/lvgl/defines.py | 1 + .../lvgl/config/not_paused.yaml | 26 ++++++++++++++ tests/component_tests/lvgl/config/paused.yaml | 27 ++++++++++++++ tests/component_tests/lvgl/test_paused.py | 35 +++++++++++++++++++ tests/components/lvgl/lvgl-package.yaml | 1 + 6 files changed, 93 insertions(+) create mode 100644 tests/component_tests/lvgl/config/not_paused.yaml create mode 100644 tests/component_tests/lvgl/config/paused.yaml create mode 100644 tests/component_tests/lvgl/test_paused.py diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 08369927b9..ecc4b0a777 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -414,6 +414,8 @@ async def to_code(configs): await cg.register_component(lv_component, config) if rotation := config.get(CONF_ROTATION): cg.add(lv_component.set_rotation(rotation)) + if paused := config[df.CONF_PAUSED]: + cg.add(lv_component.set_paused(paused, False)) if refr_time := config.get(df.CONF_REFRESH_INTERVAL): cg.add(lv_component.set_refresh_interval(refr_time.total_milliseconds)) Widget.create(config[CONF_ID], lv_component, LvScrActType(), config) @@ -645,6 +647,7 @@ LVGL_TOP_LEVEL_SCHEMA = ( cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG, cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t), cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean, + cv.Optional(df.CONF_PAUSED, default=False): cv.boolean, } ) .extend(DISP_BG_SCHEMA) diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 53499503d4..15e593b3f6 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -758,6 +758,7 @@ CONF_PAD_COLUMN = "pad_column" CONF_PAGE = "page" CONF_PAGE_WRAP = "page_wrap" CONF_PASSWORD_MODE = "password_mode" +CONF_PAUSED = "paused" CONF_PIVOT_X = "pivot_x" CONF_PIVOT_Y = "pivot_y" CONF_PLACEHOLDER_TEXT = "placeholder_text" diff --git a/tests/component_tests/lvgl/config/not_paused.yaml b/tests/component_tests/lvgl/config/not_paused.yaml new file mode 100644 index 0000000000..1dfe8f4ee9 --- /dev/null +++ b/tests/component_tests/lvgl/config/not_paused.yaml @@ -0,0 +1,26 @@ +esphome: + name: test-not-paused + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + +lvgl: + widgets: + - obj: + id: root_obj diff --git a/tests/component_tests/lvgl/config/paused.yaml b/tests/component_tests/lvgl/config/paused.yaml new file mode 100644 index 0000000000..ea747ec75b --- /dev/null +++ b/tests/component_tests/lvgl/config/paused.yaml @@ -0,0 +1,27 @@ +esphome: + name: test-paused + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + +lvgl: + paused: true + widgets: + - obj: + id: root_obj diff --git a/tests/component_tests/lvgl/test_paused.py b/tests/component_tests/lvgl/test_paused.py new file mode 100644 index 0000000000..eede17ec19 --- /dev/null +++ b/tests/component_tests/lvgl/test_paused.py @@ -0,0 +1,35 @@ +"""Tests for the LVGL ``paused`` option code generation.""" + +from __future__ import annotations + +import re + +_SET_PAUSED_RE = re.compile(r"->set_paused\((.+?)\);") + + +def _extract_set_paused(main_cpp: str) -> list[str]: + """Return the normalised argument text of every set_paused() call found. + + Whitespace within and around the arguments is collapsed so unrelated + code-generation formatting changes don't break these tests. + """ + return [" ".join(m.group(1).split()) for m in _SET_PAUSED_RE.finditer(main_cpp)] + + +class TestPausedCodeGeneration: + """Verify that the ``paused`` option drives the set_paused() call.""" + + def test_paused_true_generates_set_paused( + self, generate_main, component_config_path + ): + """``paused: true`` emits a set_paused(true, false) call.""" + main_cpp = generate_main(component_config_path("paused.yaml")) + calls = _extract_set_paused(main_cpp) + assert calls == ["true, false"] + + def test_paused_default_omits_set_paused( + self, generate_main, component_config_path + ): + """Without ``paused`` (default false) no set_paused call is generated.""" + main_cpp = generate_main(component_config_path("not_paused.yaml")) + assert _extract_set_paused(main_cpp) == [] diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 4f043db7cb..4ec4eb3bd6 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -35,6 +35,7 @@ lvgl: rotation: 90 log_level: debug resume_on_input: true + paused: true update_when_display_idle: true refresh_interval: 30ms on_pause: From b4ad0eb86bab936163ab90cb1b1f659f1032c8f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Jul 2026 09:59:18 -0500 Subject: [PATCH 0771/1815] [esp32_ble] Fix boot loop when the hosted co-processor does not answer BT bring-up (#17429) --- esphome/components/esp32_ble/ble.cpp | 52 +++++++++++++++++++-- esphome/components/esp32_hosted/__init__.py | 2 + 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 6bbf0d6a26..a2d19f1042 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -9,6 +9,8 @@ #ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID #include #else +#include "esphome/components/watchdog/watchdog.h" +#include extern "C" { #include #include @@ -33,6 +35,19 @@ namespace esphome::esp32_ble { static const char *const TAG = "esp32_ble"; +#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +// Bringing up the remote BT controller issues synchronous RPCs to the +// co-processor with 5 second response timeouts, and the default task watchdog +// is also 5 seconds. If the co-processor firmware does not answer (for example +// factory firmware without Bluetooth support), the watchdog would reboot the +// device before the RPC could return an error, causing a boot loop. Raise the +// watchdog for the duration of the bring-up so failures surface as error +// returns instead. 60 seconds covers the worst case: transport reconnect +// (up to ~20s), version preflight (1s), controller init/enable (5s each) and +// the bluedroid host bring-up over the hosted HCI transport. +static constexpr uint32_t HOSTED_BT_WDT_TIMEOUT_MS = 60000; +#endif + // GAP event groups for deduplication across gap_event_handler and dispatch_gap_event_ #define GAP_SCAN_COMPLETE_EVENTS \ case ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT: \ @@ -164,6 +179,9 @@ void ESP32BLE::advertising_init_() { bool ESP32BLE::ble_setup_() { esp_err_t err; +#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID + watchdog::WatchdogManager wdt(HOSTED_BT_WDT_TIMEOUT_MS); +#endif #ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID if (esp_bt_controller_get_status() != ESP_BT_CONTROLLER_STATUS_ENABLED) { // start bt controller @@ -192,15 +210,35 @@ bool ESP32BLE::ble_setup_() { esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT); #else - esp_hosted_connect_to_slave(); // NOLINT + if (esp_hosted_connect_to_slave() != ESP_OK) { // NOLINT + ESP_LOGE(TAG, "Co-processor transport failed; BLE disabled"); + return false; + } + + // Fast preflight (1 second RPC timeout): verifies the co-processor answers + // RPCs at all before the 5 second timeout BT controller RPCs below, and + // before hosted_hci_bluedroid_open(), which aborts if the transport is down. + esp_hosted_coprocessor_fwver_t fw_ver{}; + if (esp_hosted_get_coprocessor_fwversion(&fw_ver) != ESP_OK) { + ESP_LOGE(TAG, "Co-processor not responding; BLE disabled. Update its firmware with the esp32_hosted " + "update component"); + return false; + } + ESP_LOGD(TAG, "Co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32, fw_ver.major1, fw_ver.minor1, fw_ver.patch1); if (esp_hosted_bt_controller_init() != ESP_OK) { - ESP_LOGW(TAG, "esp_hosted_bt_controller_init failed"); + ESP_LOGE(TAG, + "BT controller init failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32 + " may lack BT support. Update it with the esp32_hosted update component; BLE disabled", + fw_ver.major1, fw_ver.minor1, fw_ver.patch1); return false; } if (esp_hosted_bt_controller_enable() != ESP_OK) { - ESP_LOGW(TAG, "esp_hosted_bt_controller_enable failed"); + ESP_LOGE(TAG, + "BT controller enable failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32 + " may lack BT support. Update it with the esp32_hosted update component; BLE disabled", + fw_ver.major1, fw_ver.minor1, fw_ver.patch1); return false; } @@ -332,6 +370,10 @@ bool ESP32BLE::ble_setup_() { } bool ESP32BLE::ble_dismantle_() { +#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID + // Same 5 second RPCs as the bring-up path; see HOSTED_BT_WDT_TIMEOUT_MS + watchdog::WatchdogManager wdt(HOSTED_BT_WDT_TIMEOUT_MS); +#endif esp_err_t err = esp_bluedroid_disable(); if (err != ESP_OK) { // ESP_ERR_INVALID_STATE means Bluedroid is already disabled, which is fine @@ -377,12 +419,12 @@ bool ESP32BLE::ble_dismantle_() { } #else if (esp_hosted_bt_controller_disable() != ESP_OK) { - ESP_LOGW(TAG, "esp_hosted_bt_controller_disable failed"); + ESP_LOGE(TAG, "esp_hosted_bt_controller_disable failed"); return false; } if (esp_hosted_bt_controller_deinit(false) != ESP_OK) { - ESP_LOGW(TAG, "esp_hosted_bt_controller_deinit failed"); + ESP_LOGE(TAG, "esp_hosted_bt_controller_deinit failed"); return false; } diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 7f420f27d8..16e9d49782 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -18,6 +18,8 @@ from esphome.const import ( from esphome.cpp_generator import add_define CODEOWNERS = ["@swoboda1337"] +# esp32_ble raises the task watchdog around the remote BT controller bring-up +AUTO_LOAD = ["watchdog"] CONF_ACTIVE_HIGH = "active_high" CONF_BUS_WIDTH = "bus_width" From 1913818b1cd2b8a39001ed6d456e1a7b4fa490dc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:02:16 -0500 Subject: [PATCH 0772/1815] Bump astral-sh/setup-uv from 8.3.0 to 8.3.1 in /.github/actions/restore-python (#17442) Signed-off-by: dependabot[bot] --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 8ef0bca2ec..64b1cabea1 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -32,7 +32,7 @@ runs: # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # downstream jobs rely on is preserved. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 with: enable-cache: true # Pin uv version so the action does not have to fetch the From 71b746b6fadac7d51b89cd05f180d4476df2e15c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:02:26 -0500 Subject: [PATCH 0773/1815] Bump astral-sh/setup-uv from 8.3.0 to 8.3.1 (#17444) Signed-off-by: dependabot[bot] --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci.yml | 6 +++--- .github/workflows/sync-device-classes.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 721585a44d..1757959a51 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -29,7 +29,7 @@ jobs: - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. - uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0fd6a79cb5..c7e1c67fb6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 with: enable-cache: true # Pin uv version so the action does not have to fetch the @@ -171,7 +171,7 @@ jobs: # install step (order-of-magnitude faster on cold boots, # with its own wheel cache). actions/setup-python still # provides the interpreter. - uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 with: enable-cache: true # Pin uv version so the action does not have to fetch the @@ -372,7 +372,7 @@ jobs: - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 2efaec4e94..7e0047ee0d 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -47,7 +47,7 @@ jobs: # setup-python interpreter so subsequent ``pre-commit`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 with: enable-cache: true # Pin uv version so the action does not have to fetch the From 35c7496cd7ed39d28fb4286dd7adfff44c650354 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:02:38 -0500 Subject: [PATCH 0774/1815] Bump CodSpeedHQ/action from 4.18.1 to 4.18.2 (#17445) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7e1c67fb6..e08241681b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -456,7 +456,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@a4a36bb07c0638b0b4ca52bf1f3dad1b4289e52f # v4.18.1 + uses: CodSpeedHQ/action@4e969336ab9acd4f6f8d025fdd793292b0835df0 # v4.18.2 with: run: | . venv/bin/activate From 731e9fda031e5e0f4d1ddf93fad2ff8572cf364b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:12:57 -0400 Subject: [PATCH 0775/1815] [internal_temperature] Support all ESP32 variants with a temperature sensor (#17438) --- .../internal_temperature_esp32.cpp | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/esphome/components/internal_temperature/internal_temperature_esp32.cpp b/esphome/components/internal_temperature/internal_temperature_esp32.cpp index 1c44a9a238..64fe3707b1 100644 --- a/esphome/components/internal_temperature/internal_temperature_esp32.cpp +++ b/esphome/components/internal_temperature/internal_temperature_esp32.cpp @@ -3,17 +3,16 @@ #include "esphome/core/log.h" #include "internal_temperature.h" +#include + #if defined(USE_ESP32_VARIANT_ESP32) // there is no official API available on the original ESP32 extern "C" { uint8_t temprature_sens_read(); } -#elif defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || \ - defined(USE_ESP32_VARIANT_ESP32C5) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || \ - defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || \ - defined(USE_ESP32_VARIANT_ESP32S3) +#elif SOC_TEMP_SENSOR_SUPPORTED #include "driver/temperature_sensor.h" -#endif // USE_ESP32_VARIANT +#endif namespace esphome::internal_temperature { @@ -27,10 +26,7 @@ void InternalTemperatureSensor::update() { ESP_LOGV(TAG, "Raw temperature value: %d", raw); temperature = (raw - 32) / 1.8f; success = (raw != 128); -#elif defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || \ - defined(USE_ESP32_VARIANT_ESP32C5) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || \ - defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || \ - defined(USE_ESP32_VARIANT_ESP32S3) +#elif SOC_TEMP_SENSOR_SUPPORTED esp_err_t result = temperature_sensor_get_celsius(this->tsens_, &temperature); success = (result == ESP_OK); if (!success) { @@ -49,9 +45,7 @@ void InternalTemperatureSensor::update() { } void InternalTemperatureSensor::setup() { -#if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ - defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || \ - defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if SOC_TEMP_SENSOR_SUPPORTED temperature_sensor_config_t tsens_config = TEMPERATURE_SENSOR_CONFIG_DEFAULT(-10, 80); esp_err_t result = temperature_sensor_install(&tsens_config, &this->tsens_); From 731486d9b0fdc23d89a2264745008052c78ffd00 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 7 Jul 2026 17:20:14 -0700 Subject: [PATCH 0776/1815] [modbus] Finalize unreleased API surface before 2026.7 (#17434) Co-authored-by: Claude Fable 5 --- esphome/components/modbus/modbus.cpp | 34 +++- esphome/components/modbus/modbus.h | 13 +- .../components/modbus/modbus_definitions.h | 2 +- esphome/components/modbus/modbus_helpers.cpp | 21 +-- esphome/components/modbus/modbus_helpers.h | 35 ++-- .../binary_sensor/modbus_binarysensor.cpp | 2 +- .../modbus_controller/modbus_controller.h | 13 +- .../select/modbus_select.cpp | 4 +- .../switch/modbus_switch.cpp | 2 +- .../modbus_server/modbus_server.cpp | 10 +- .../modbus/modbus_client_hub_test.cpp | 178 ++++++++++++++++++ .../components/modbus/modbus_helpers_test.cpp | 13 +- 12 files changed, 272 insertions(+), 55 deletions(-) create mode 100644 tests/components/modbus/modbus_client_hub_test.cpp diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 527d57fcd7..ecb2e4461c 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -56,8 +56,7 @@ void ModbusClientHub::loop() { (this->rx_buffer_.empty() || this->rx_buffer_[0] != expected_address)) { ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send", expected_address, this->last_receive_check_ - this->last_send_); - if (wfr.device) - wfr.device->on_modbus_no_response(); + this->notify_no_response_(wfr); this->waiting_for_response_.reset(); } } @@ -278,11 +277,10 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, uint8_t funct "ms after last send", address, expected_address, (function_code & FUNCTION_CODE_MASK), expected_function_code, this->last_modbus_byte_ - this->last_send_); - // Invalidate the waiting device so it won't process this response. - if (wfr.device) - wfr.device->on_modbus_no_response(); + // Invalidate the device; the entry survives as an interrupted shell so the late response is ignored. + // A retry requested here stays queued behind the shell until the send-wait timeout clears it. + this->notify_no_response_(wfr); wfr.interrupted = true; - wfr.device = nullptr; return; } @@ -564,6 +562,30 @@ void ModbusServerHub::send_exception_(uint8_t address, uint8_t function_code, Mo } // Raw send for client: pushes to tx queue. Everything except the CRC must be contained in payload. +void ModbusClientHub::notify_no_response_(ModbusDeviceCommand &wfr) { + if (wfr.device == nullptr) + return; + const bool retry = wfr.device->on_modbus_no_response(); + // The callback may have detached the device (e.g. clear_tx_queue_for_device()); honor the detach + // over the retry request rather than re-queueing a frame that can no longer be routed. + if (retry && wfr.device != nullptr) + this->requeue_waiting_frame_(wfr); + // The old transaction is over either way; never deliver anything else to the device through it. + wfr.device = nullptr; +} + +void ModbusClientHub::requeue_waiting_frame_(ModbusDeviceCommand &wfr) { + const ModbusFrame &frame = wfr.frame; + if (this->tx_buffer_.size() >= MODBUS_TX_BUFFER_SIZE) { + ESP_LOGE(TAG, "Write buffer full, dropped retry for address %" PRIu8, frame.data.data()[0]); + if (wfr.device != nullptr) + wfr.device->on_modbus_not_sent(); + return; + } + // Re-queue a copy (not a move): the waiting entry may have to survive as an interrupted shell. + this->tx_buffer_.emplace_back(wfr.device, frame.data.data()[0], frame.data.data() + 1, frame.size() - 3); +} + void ModbusClientHub::queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device) { if (pdu_len == 0) { if (device) diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index e48c8c298a..eeba00f6b1 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -108,7 +108,7 @@ class ModbusClientHub : public Modbus { payload, payload_len), device); }; - void send_pdu(uint8_t address, const StaticVector &pdu, ModbusClientDevice *device = nullptr) { + void send_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device = nullptr) { this->queue_raw_(address, pdu.data(), pdu.size(), device); } void send_raw(const std::vector &payload, ModbusClientDevice *device = nullptr); @@ -121,6 +121,10 @@ class ModbusClientHub : public Modbus { // Parsers need to handle standard (ModbusFunctionCode) and custom (uint8_t) function codes, so we use uint8_t here. void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) override; void send_next_frame_(); + // Notify the waiting device of no response; re-queues the frame if on_modbus_no_response() returns true. + // wfr is the caller's checked reference to waiting_for_response_. + void notify_no_response_(ModbusDeviceCommand &wfr); + void requeue_waiting_frame_(ModbusDeviceCommand &wfr); void queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device = nullptr); uint16_t send_wait_time_{2000}; @@ -179,7 +183,10 @@ class ModbusClientDevice { virtual void on_modbus_data(const std::vector &data) {} virtual void on_modbus_error(uint8_t function_code, uint8_t exception_code) {} virtual void on_modbus_not_sent() {} - virtual void on_modbus_no_response() {} + /// Called when no (valid) response arrived; return true to have the hub re-queue the frame for a retry. + /// The hub does not bound retries: the device is responsible for limiting them (e.g. track a counter and + /// return false when exhausted), or an unresponsive peer will starve other traffic on the bus. + virtual bool on_modbus_no_response() { return false; } void send(uint8_t function, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0, const uint8_t *payload = nullptr) { this->parent_->send_pdu(this->address_, @@ -187,7 +194,7 @@ class ModbusClientDevice { payload, payload_len), this); } - void send_pdu(const StaticVector &pdu) { this->parent_->send_pdu(this->address_, pdu, this); } + void send_pdu(std::span pdu) { this->parent_->send_pdu(this->address_, pdu, this); } void send_raw(const std::vector &payload) { this->parent_->send_raw(payload, this); } inline void clear_tx_queue_for_address(bool clear_sent = true) { this->parent_->clear_tx_queue_for_address(this->address_, clear_sent); diff --git a/esphome/components/modbus/modbus_definitions.h b/esphome/components/modbus/modbus_definitions.h index a5bcc1e3fc..d11748bcd9 100644 --- a/esphome/components/modbus/modbus_definitions.h +++ b/esphome/components/modbus/modbus_definitions.h @@ -82,7 +82,7 @@ static constexpr uint16_t MAX_NUM_OF_DISCRETE_INPUTS_TO_READ = 2000; // 0x7D0 // 6.3 03 (0x03) Read Holding Registers // 6.4 04 (0x04) Read Input Registers -static constexpr uint8_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D +static constexpr uint16_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D // Smallest possible frame is 4 bytes (custom function with no data): address(1) + function(1) + CRC(2) static constexpr uint16_t MIN_FRAME_SIZE = 4; diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index 53fa6afacb..de109606cb 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -105,8 +105,8 @@ void log_unsupported_value_type(SensorValueType value_type) { ESP_LOGE(TAG, "Invalid data type for modbus number to payload conversion: %d", static_cast(value_type)); } -int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sensor_value_type, uint8_t offset, - uint32_t bitmask, bool *error_return) { +std::optional payload_to_number(const uint8_t *data, size_t size, SensorValueType sensor_value_type, + uint8_t offset, uint32_t bitmask) { int64_t value = 0; // int64_t because it can hold signed and unsigned 32 bits // Validate offset against the buffer for all types, including RAW/unsupported, so @@ -114,9 +114,7 @@ int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sens if (static_cast(offset) > size) { ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu", static_cast(sensor_value_type), static_cast(offset), size); - if (error_return) - *error_return = true; - return value; + return std::nullopt; } const size_t required_size = required_payload_size(sensor_value_type); @@ -127,9 +125,7 @@ int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sens if (size - offset < required_size) { ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu required=%zu", static_cast(sensor_value_type), static_cast(offset), size, required_size); - if (error_return) - *error_return = true; - return value; + return std::nullopt; } switch (sensor_value_type) { @@ -179,8 +175,7 @@ int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sens return value; } -int64_t registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type, - bool *error_return) { +std::optional registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type) { const size_t required_size = required_payload_size(sensor_value_type); if (required_size == 0) { return 0; // RAW/unsupported: nothing to read @@ -189,9 +184,7 @@ int64_t registers_to_number(const uint16_t *registers, size_t count, SensorValue if (required_words > count) { ESP_LOGE(TAG, "not enough registers for value type=%u count=%zu required=%zu", static_cast(sensor_value_type), count, required_words); - if (error_return) - *error_return = true; - return 0; + return std::nullopt; } // Serialize the needed words back to big-endian bytes and reuse the audited byte decoder so the // sign-extension behaviour stays identical to the wire path. @@ -201,7 +194,7 @@ int64_t registers_to_number(const uint16_t *registers, size_t count, SensorValue bytes[i * 2] = static_cast(reg >> 8); bytes[i * 2 + 1] = static_cast(reg & 0xFF); } - return payload_to_number(bytes, required_size, sensor_value_type, 0, 0xFFFFFFFF, error_return); + return payload_to_number(bytes, required_size, sensor_value_type, 0, 0xFFFFFFFF); } StaticVector create_client_pdu(ModbusFunctionCode function_code, uint16_t start_address, diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index fef0f915ea..45a13f7582 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -1,8 +1,10 @@ #pragma once +#include +#include +#include #include #include -#include #include "esphome/core/helpers.h" #include "esphome/components/modbus/modbus_definitions.h" @@ -197,11 +199,15 @@ template T get_data(const std::vector &data, size_t buffer_ * @param data modbus response buffer (uint8_t) * @return content of coil register */ -inline bool coil_from_vector(int coil, const std::vector &data) { - auto data_byte = coil / 8; - return (data[data_byte] & (1 << (coil % 8))) > 0; +inline bool bit_from_packed(int bit, std::span data) { + auto data_byte = bit / 8; + return (data[data_byte] & (1 << (bit % 8))) > 0; } +// Remove before 2027.2.0 +ESPDEPRECATED("Use bit_from_packed() instead. Removed in 2027.2.0", "2026.8.0") +inline bool coil_from_vector(int coil, std::span data) { return bit_from_packed(coil, data); } + /** Extract bits from value and shift right according to the bitmask * if the bitmask is 0x00F0 we want the values frrom bit 5 - 8. * the result is then shifted right by the position if the first right set bit in the mask @@ -276,13 +282,21 @@ template void number_to_payload(Container &data, int64_t val * @param bitmask bitmask used for masking and shifting * @return 64-bit number of the payload */ -int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sensor_value_type, uint8_t offset, - uint32_t bitmask, bool *error_return = nullptr); +std::optional payload_to_number(const uint8_t *data, size_t size, SensorValueType sensor_value_type, + uint8_t offset, uint32_t bitmask); -/** Convert vector response payload to number. */ +/** Convert a response payload span to number; std::nullopt if the payload is too short. */ +inline std::optional payload_to_number(std::span data, SensorValueType sensor_value_type, + uint8_t offset, uint32_t bitmask) { + return payload_to_number(data.data(), data.size(), sensor_value_type, offset, bitmask); +} + +// Remove before 2027.2.0 +ESPDEPRECATED("Use the std::span overload returning std::optional instead. Removed in 2027.2.0", "2026.8.0") inline int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, - uint32_t bitmask, bool *error_return = nullptr) { - return payload_to_number(data.data(), data.size(), sensor_value_type, offset, bitmask, error_return); + uint32_t bitmask) { + // Released behavior: a too-short payload logs an error and decodes to 0. + return payload_to_number(std::span(data), sensor_value_type, offset, bitmask).value_or(0); } /** Reconstruct a number from register words (host byte order). Inverse of number_to_payload. @@ -292,8 +306,7 @@ inline int64_t payload_to_number(const std::vector &data, SensorValueTy * @param sensor_value_type defines if 16/32/64 bits or FP32 is used * @return 64-bit number of the registers */ -int64_t registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type, - bool *error_return = nullptr); +std::optional registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type); /** Create a modbus clinet pdu for reading/writing single/multiple coils/register/inputs. * @param function_code the modbus function code to use. One of: diff --git a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp index 60c19bb66a..9656013a5f 100644 --- a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp +++ b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp @@ -14,7 +14,7 @@ void ModbusBinarySensor::parse_and_publish(const std::vector &data) { case ModbusRegisterType::DISCRETE_INPUT: case ModbusRegisterType::COIL: // offset for coil is the actual number of the coil not the byte offset - value = modbus::helpers::coil_from_vector(this->offset, data); + value = modbus::helpers::bit_from_packed(this->offset, data); break; default: value = modbus::helpers::get_data(data, this->offset) & this->bitmask; diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 501fadbcf1..484b59ede3 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -64,9 +64,10 @@ T get_data(const std::vector &data, size_t buffer_offset) { return modbus::helpers::get_data(data, buffer_offset); } -ESPDEPRECATED("Use modbus::helpers::coil_from_vector() instead. Removed in 2026.10.0", "2026.4.0") +// Remove before 2027.2.0 (window restarted when the migration target changed to bit_from_packed()) +ESPDEPRECATED("Use modbus::helpers::bit_from_packed() instead. Removed in 2027.2.0", "2026.4.0") inline bool coil_from_vector(int coil, const std::vector &data) { - return modbus::helpers::coil_from_vector(coil, data); + return modbus::helpers::bit_from_packed(coil, data); } template @@ -83,7 +84,8 @@ inline void number_to_payload(std::vector &data, int64_t value, Sensor ESPDEPRECATED("Use modbus::helpers::payload_to_number() instead. Removed in 2026.10.0", "2026.4.0") inline int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, uint32_t bitmask) { - return modbus::helpers::payload_to_number(data, sensor_value_type, offset, bitmask); + return modbus::helpers::payload_to_number(std::span(data), sensor_value_type, offset, bitmask) + .value_or(0); } ESPDEPRECATED("Use modbus::helpers::float_to_payload() instead. Removed in 2026.10.0", "2026.4.0") @@ -377,8 +379,9 @@ class ModbusController final : public PollingComponent, public modbus::ModbusCli * @param item SensorItem object * @return float value of data */ -inline float payload_to_float(const std::vector &data, const SensorItem &item) { - int64_t number = modbus::helpers::payload_to_number(data, item.sensor_value_type, item.offset, item.bitmask); +inline float payload_to_float(std::span data, const SensorItem &item) { + int64_t number = + modbus::helpers::payload_to_number(data, item.sensor_value_type, item.offset, item.bitmask).value_or(0); float float_value; if (modbus::helpers::value_type_is_float(item.sensor_value_type)) { diff --git a/esphome/components/modbus_controller/select/modbus_select.cpp b/esphome/components/modbus_controller/select/modbus_select.cpp index 859828f5f6..c650ca7641 100644 --- a/esphome/components/modbus_controller/select/modbus_select.cpp +++ b/esphome/components/modbus_controller/select/modbus_select.cpp @@ -8,7 +8,9 @@ static const char *const TAG = "modbus_controller.select"; void ModbusSelect::dump_config() { LOG_SELECT(TAG, "Modbus Controller Select", this); } void ModbusSelect::parse_and_publish(const std::vector &data) { - int64_t value = modbus::helpers::payload_to_number(data, this->sensor_value_type, this->offset, this->bitmask); + int64_t value = modbus::helpers::payload_to_number(std::span(data), this->sensor_value_type, + this->offset, this->bitmask) + .value_or(0); ESP_LOGD(TAG, "New select value %lld from payload", value); diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index 044ca2f8cc..c8b3868bdc 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -33,7 +33,7 @@ void ModbusSwitch::parse_and_publish(const std::vector &data) { case ModbusRegisterType::DISCRETE_INPUT: case ModbusRegisterType::COIL: // offset for coil is the actual number of the coil not the byte offset - value = modbus::helpers::coil_from_vector(this->offset, data); + value = modbus::helpers::bit_from_packed(this->offset, data); break; default: value = modbus::helpers::get_data(data, this->offset) & this->bitmask; diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp index 1f787a0b61..4c4e72a086 100644 --- a/esphome/components/modbus_server/modbus_server.cpp +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -137,10 +137,9 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address, if (server_register->write_lambda == nullptr) { return false; // unwritable -> ILLEGAL_DATA_ADDRESS } - bool error = false; - registers_to_number(registers.data() + register_offset, registers.size() - register_offset, - server_register->value_type, &error); - if (error) { + if (!registers_to_number(registers.data() + register_offset, registers.size() - register_offset, + server_register->value_type) + .has_value()) { precheck = ModbusExceptionCode::ILLEGAL_DATA_VALUE; // request doesn't supply the full value return false; } @@ -154,7 +153,8 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address, // rejecting the value at runtime -- which cannot be rolled back. if (!for_each_register([®isters](ServerRegister *server_register, uint16_t register_offset) { int64_t number = registers_to_number(registers.data() + register_offset, registers.size() - register_offset, - server_register->value_type); + server_register->value_type) + .value_or(0); return server_register->write_lambda(number); })) { ESP_LOGW(TAG, "A register write callback failed mid-sequence; earlier writes were already applied."); diff --git a/tests/components/modbus/modbus_client_hub_test.cpp b/tests/components/modbus/modbus_client_hub_test.cpp new file mode 100644 index 0000000000..d04c4fe10c --- /dev/null +++ b/tests/components/modbus/modbus_client_hub_test.cpp @@ -0,0 +1,178 @@ +#include + +#include +#include + +#include "esphome/components/modbus/modbus.h" + +namespace esphome::modbus::testing { + +namespace { + +// Exposes the protected tx queue and waiting-for-response slot so tests can drive the +// no-response path without a UART: force_send_front() mimics send_next_frame_() moving the +// front frame in flight, timeout_waiting() mimics the loop() no-response timeout handling. +class NoResponseProbeHub : public ModbusClientHub { + public: + size_t queued_frames() const { return this->tx_buffer_.size(); } + const ModbusDeviceCommand &front() const { return this->tx_buffer_.front(); } + bool waiting() const { return this->waiting_for_response_.has_value(); } + const ModbusDeviceCommand &waiting_command() const { + EXPECT_TRUE(this->waiting_for_response_.has_value()); + return *this->waiting_for_response_; // NOLINT(bugprone-unchecked-optional-access) + } + + void force_send_front() { + this->waiting_for_response_ = std::move(this->tx_buffer_.front()); + this->tx_buffer_.pop_front(); + } + // Drives the real unexpected-frame branch in process_modbus_server_frame(). + void receive_frame_for_test(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) { + this->process_modbus_server_frame(address, function_code, data, len); + } + void timeout_waiting() { + if (this->waiting_for_response_.has_value()) + this->notify_no_response_(*this->waiting_for_response_); + this->waiting_for_response_.reset(); + } +}; + +// A device with a scripted answer to on_modbus_no_response(). +class RetryingDevice : public ModbusClientDevice { + public: + RetryingDevice(ModbusClientHub *hub, uint8_t address, bool retry) : ModbusClientDevice(hub, address), retry_(retry) {} + bool on_modbus_no_response() override { + this->no_response_count_++; + return this->retry_; + } + int no_response_count_{0}; + + protected: + bool retry_{false}; +}; + +// A device that clears its own queued traffic from inside the no-response callback, then asks for a retry. +class ClearingRetryDevice : public ModbusClientDevice { + public: + ClearingRetryDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + bool on_modbus_no_response() override { + this->no_response_count_++; + this->clear_tx_queue_for_device(); // detaches this device from the waiting slot mid-callback + return true; // and still requests a retry + } + int no_response_count_{0}; +}; + +constexpr uint8_t READ_PDU[] = {0x03, 0x01, 0x00, 0x00, 0x02}; // read 2 holding registers at 0x100 + +StaticVector read_pdu() { + StaticVector pdu; + pdu.assign(READ_PDU, READ_PDU + sizeof(READ_PDU)); + return pdu; +} + +} // namespace + +// A device that requests a retry gets the frame the hub was holding re-queued on its behalf, +// byte-identical and still routed to the same device. +TEST(ModbusClientHubNoResponse, RetryRequeuesWaitingFrame) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/true); + + device.send_pdu(read_pdu()); + ASSERT_EQ(hub.queued_frames(), 1u); + hub.force_send_front(); + ASSERT_EQ(hub.queued_frames(), 0u); + ASSERT_TRUE(hub.waiting()); + + hub.timeout_waiting(); + + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_FALSE(hub.waiting()); + ASSERT_EQ(hub.queued_frames(), 1u); + const ModbusDeviceCommand &requeued = hub.front(); + EXPECT_EQ(requeued.device, &device); + // address + PDU + CRC + ASSERT_EQ(requeued.frame.size(), sizeof(READ_PDU) + 3); + EXPECT_EQ(requeued.frame.data.data()[0], 0x02); + EXPECT_EQ(0, memcmp(requeued.frame.data.data() + 1, READ_PDU, sizeof(READ_PDU))); +} + +// A device that declines the retry has the frame dropped. +TEST(ModbusClientHubNoResponse, NoRetryDropsWaitingFrame) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + device.send_pdu(read_pdu()); + hub.force_send_front(); + + hub.timeout_waiting(); + + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.queued_frames(), 0u); +} + +// After the device is detached from the waiting frame (e.g. clear_tx_queue_for_device on +// destruction), a timeout must not deliver a callback or re-queue anything. +TEST(ModbusClientHubNoResponse, DetachedDeviceIsNotNotified) { + NoResponseProbeHub hub; + { + RetryingDevice device(&hub, 0x02, /*retry=*/true); + device.send_pdu(read_pdu()); + hub.force_send_front(); + // device destructor clears its queue entries, including the waiting frame's device pointer + } + ASSERT_TRUE(hub.waiting()); + EXPECT_EQ(hub.waiting_command().device, nullptr); + + hub.timeout_waiting(); + + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.queued_frames(), 0u); +} + +// An unexpected frame interrupts the transaction: the retry is re-queued immediately, but the +// waiting entry survives as an interrupted shell (device detached) that keeps tx blocked until the +// send-wait timeout clears it - without a second no-response callback or a duplicate requeue. +TEST(ModbusClientHubNoResponse, RetryBehindInterruptedShell) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/true); + + device.send_pdu(read_pdu()); + hub.force_send_front(); + + // A frame from the wrong address (0x07, expected 0x02) hits the unexpected-frame branch. + const uint8_t stray_payload[] = {0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x07, 0x03, stray_payload, sizeof(stray_payload)); + + EXPECT_EQ(device.no_response_count_, 1); + ASSERT_EQ(hub.queued_frames(), 1u); // exactly one requeue... + EXPECT_EQ(hub.front().device, &device); + ASSERT_TRUE(hub.waiting()); // ...while the shell stays in the waiting slot + EXPECT_TRUE(hub.waiting_command().interrupted); + EXPECT_EQ(hub.waiting_command().device, nullptr); + + // The send-wait timeout clears the shell without a second callback or another requeue. + hub.timeout_waiting(); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_EQ(hub.queued_frames(), 1u); +} + +// A callback that detaches the device (clear_tx_queue_for_device()) wins over its own retry request: +// no orphaned frame with a null device is re-queued. +TEST(ModbusClientHubNoResponse, MidCallbackClearCancelsRetry) { + NoResponseProbeHub hub; + ClearingRetryDevice device(&hub, 0x02); + + device.send_pdu(read_pdu()); + hub.force_send_front(); + hub.timeout_waiting(); + + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_EQ(hub.queued_frames(), 0u); // the retry was not re-queued for a detached device + EXPECT_FALSE(hub.waiting()); +} + +} // namespace esphome::modbus::testing diff --git a/tests/components/modbus/modbus_helpers_test.cpp b/tests/components/modbus/modbus_helpers_test.cpp index ecdca4df6d..1c57a81e6f 100644 --- a/tests/components/modbus/modbus_helpers_test.cpp +++ b/tests/components/modbus/modbus_helpers_test.cpp @@ -181,17 +181,17 @@ TEST(ModbusCreateClientPdu, WriteMultipleOverEntityLimitReturnsEmpty) { TEST(ModbusHelpersTest, PayloadToNumberRejectsOffsetAtEndOfBuffer) { const std::vector data{0x12, 0x34}; - EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 2, 0xFFFFFFFF), 0); + EXPECT_FALSE(payload_to_number(std::span(data), SensorValueType::U_WORD, 2, 0xFFFFFFFF).has_value()); } TEST(ModbusHelpersTest, PayloadToNumberRejectsTruncatedMultiRegisterValue) { const std::vector data{0x12, 0x34, 0x56}; - EXPECT_EQ(payload_to_number(data, SensorValueType::U_DWORD, 0, 0xFFFFFFFF), 0); + EXPECT_FALSE(payload_to_number(std::span(data), SensorValueType::U_DWORD, 0, 0xFFFFFFFF).has_value()); } TEST(ModbusHelpersTest, PayloadToNumberDecodesValidWord) { const std::vector data{0x12, 0x34}; - EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 0, 0xFFFFFFFF), 0x1234); + EXPECT_EQ(payload_to_number(std::span(data), SensorValueType::U_WORD, 0, 0xFFFFFFFF), 0x1234); } // --- registers_to_number --------------------------------------------------- @@ -218,16 +218,15 @@ TEST(ModbusHelpersTest, RegistersToNumberMatchesPayloadToNumber) { const uint16_t registers[] = {0x8001, 0x0002}; const std::vector bytes{0x80, 0x01, 0x00, 0x02}; for (auto value_type : {SensorValueType::S_DWORD, SensorValueType::U_DWORD, SensorValueType::S_DWORD_R}) { - EXPECT_EQ(registers_to_number(registers, 2, value_type), payload_to_number(bytes, value_type, 0, 0xFFFFFFFF)) + EXPECT_EQ(registers_to_number(registers, 2, value_type), + payload_to_number(std::span(bytes), value_type, 0, 0xFFFFFFFF)) << "value_type=" << static_cast(value_type); } } TEST(ModbusHelpersTest, RegistersToNumberRejectsTruncatedMultiRegisterValue) { const uint16_t registers[] = {0x1234}; - bool error = false; - EXPECT_EQ(registers_to_number(registers, 1, SensorValueType::U_DWORD, &error), 0); - EXPECT_TRUE(error); + EXPECT_FALSE(registers_to_number(registers, 1, SensorValueType::U_DWORD).has_value()); } } // namespace esphome::modbus::helpers From 65ef05dd1f388c7ff9793e8a074c0c4babecfb4d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:51:00 +1200 Subject: [PATCH 0777/1815] [web_server_idf] Deliver raw POST bodies to custom handlers via handleBody() (#17433) --- .../web_server_idf/web_server_idf.cpp | 65 ++++++++++++++++--- .../web_server_idf/web_server_idf.h | 1 + 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index cd06f80687..69b27e90ed 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -41,6 +41,11 @@ namespace esphome::web_server_idf { static const char *const TAG = "web_server_idf"; +// Chunk size for streaming request bodies; matches the Arduino AsyncWebServer buffer size. +// Buffers of this size must live on the heap - the httpd task stack is too small. +static constexpr size_t RECV_CHUNK_SIZE = 1460; +static constexpr size_t YIELD_INTERVAL_BYTES = 16 * 1024; // Yield every 16KB to prevent watchdog + // Global instance to avoid guard variable (saves 8 bytes) // This is initialized at program startup before any threads namespace { @@ -184,9 +189,10 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { return server->handle_multipart_upload_(r, content_type_char); #endif } else { - ESP_LOGW(TAG, "Unsupported content type for POST: %s", content_type_char); - // fallback to get handler to support backward compatibility - return AsyncWebServer::request_handler(r); + // Other content types (e.g. application/json) are delivered raw to a matching + // custom handler via handleBody(), like the Arduino AsyncWebServer does + auto *server = static_cast(r->user_ctx); + return server->handle_raw_body_(r, content_type_char); } } @@ -237,6 +243,51 @@ esp_err_t AsyncWebServer::request_handler_(AsyncWebServerRequest *request) const return ESP_ERR_NOT_FOUND; } +esp_err_t AsyncWebServer::handle_raw_body_(httpd_req_t *r, const char *content_type) { + AsyncWebServerRequest req(r); + AsyncWebHandler *handler = nullptr; + for (auto *h : this->handlers_) { + if (h->canHandle(&req)) { + handler = h; + break; + } + } + + if (handler == nullptr) { + ESP_LOGW(TAG, "Unsupported content type for POST: %s", content_type); + // fallback to get handler to support backward compatibility + return this->request_handler_(&req); + } + + const size_t total = r->content_len; + if (total > 0) { + auto buffer = std::make_unique_for_overwrite(RECV_CHUNK_SIZE); + size_t bytes_since_yield = 0; + + for (size_t index = 0; index < total;) { + int recv_len = httpd_req_recv(r, buffer.get(), std::min(total - index, RECV_CHUNK_SIZE)); + + if (recv_len <= 0) { + httpd_resp_send_err(r, recv_len == HTTPD_SOCK_ERR_TIMEOUT ? HTTPD_408_REQ_TIMEOUT : HTTPD_400_BAD_REQUEST, + nullptr); + return recv_len == HTTPD_SOCK_ERR_TIMEOUT ? ESP_ERR_TIMEOUT : ESP_FAIL; + } + + handler->handleBody(&req, reinterpret_cast(buffer.get()), recv_len, index, total); + index += recv_len; + bytes_since_yield += recv_len; + + if (bytes_since_yield > YIELD_INTERVAL_BYTES) { + vTaskDelay(1); + bytes_since_yield = 0; + } + } + } + + handler->handleRequest(&req); + return ESP_OK; +} + AsyncWebServerRequest::~AsyncWebServerRequest() { delete this->rsp_; for (auto *param : this->params_) { @@ -893,9 +944,6 @@ void AsyncEventSourceResponse::deferrable_send_state(void *source, const char *e #ifdef USE_WEBSERVER_OTA esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *content_type) { - static constexpr size_t MULTIPART_CHUNK_SIZE = 1460; // Match Arduino AsyncWebServer buffer size - static constexpr size_t YIELD_INTERVAL_BYTES = 16 * 1024; // Yield every 16KB to prevent watchdog - // Parse boundary and create reader const char *boundary_start; size_t boundary_len; @@ -949,12 +997,11 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c } }); - // Use heap buffer - 1460 bytes is too large for the httpd task stack - auto buffer = std::make_unique_for_overwrite(MULTIPART_CHUNK_SIZE); + auto buffer = std::make_unique_for_overwrite(RECV_CHUNK_SIZE); size_t bytes_since_yield = 0; for (size_t remaining = r->content_len; remaining > 0;) { - int recv_len = httpd_req_recv(r, buffer.get(), std::min(remaining, MULTIPART_CHUNK_SIZE)); + int recv_len = httpd_req_recv(r, buffer.get(), std::min(remaining, RECV_CHUNK_SIZE)); if (recv_len <= 0) { httpd_resp_send_err(r, recv_len == HTTPD_SOCK_ERR_TIMEOUT ? HTTPD_408_REQ_TIMEOUT : HTTPD_400_BAD_REQUEST, diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index c631cd1453..8b5fd5b726 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -233,6 +233,7 @@ class AsyncWebServer { static esp_err_t request_post_handler(httpd_req_t *r); esp_err_t request_handler_(AsyncWebServerRequest *request) const; static void safe_close_with_shutdown(httpd_handle_t hd, int sockfd); + esp_err_t handle_raw_body_(httpd_req_t *r, const char *content_type); #ifdef USE_WEBSERVER_OTA esp_err_t handle_multipart_upload_(httpd_req_t *r, const char *content_type); #endif From b8af90750fde582cf1f109d0ab14e94b482a76bc Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:18:46 +1200 Subject: [PATCH 0778/1815] [web_server_idf] Map more common HTTP status codes in responses (#17447) --- .../web_server_idf/web_server_idf.cpp | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 69b27e90ed..46a389f359 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -32,9 +32,16 @@ namespace esphome::web_server_idf { +// Status strings not provided by esp_http_server.h +#ifndef HTTPD_401 +#define HTTPD_401 "401 Unauthorized" +#endif #ifndef HTTPD_409 #define HTTPD_409 "409 Conflict" #endif +#ifndef HTTPD_422 +#define HTTPD_422 "422 Unprocessable Entity" +#endif #define CRLF_STR "\r\n" #define CRLF_LEN (sizeof(CRLF_STR) - 1) @@ -327,12 +334,24 @@ void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code case 200: status = HTTPD_200; break; + case 204: + status = HTTPD_204; + break; + case 400: + status = HTTPD_400; + break; + case 401: + status = HTTPD_401; + break; case 404: status = HTTPD_404; break; case 409: status = HTTPD_409; break; + case 422: + status = HTTPD_422; + break; default: status = HTTPD_500; break; From 93bc02b3085b8c6e9c7c330164a6d34dd8120828 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:39:55 -0500 Subject: [PATCH 0779/1815] Bump bundled esphome-device-builder to 1.3.0 (#17448) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c01a2069f7..3a7d5e8bbe 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.2.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.3.0 RUN \ platformio settings set enable_telemetry No \ From 9c40ed5d711e7720567a6ccfae5f6db31ea3b99d Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 8 Jul 2026 01:01:20 -0500 Subject: [PATCH 0780/1815] [provisioning] Add provisioning window (#17152) Co-authored-by: Claude Opus 4.8 (1M context) --- CODEOWNERS | 1 + esphome/components/api/__init__.py | 18 +++ esphome/components/api/api.proto | 14 +++ esphome/components/api/api_connection.cpp | 28 ++++- esphome/components/api/api_connection.h | 2 +- esphome/components/api/api_pb2.cpp | 20 ++++ esphome/components/api/api_pb2.h | 12 +- esphome/components/api/api_pb2_dump.cpp | 13 ++- esphome/components/api/api_pb2_service.cpp | 6 +- esphome/components/api/api_pb2_service.h | 2 +- esphome/components/api/api_server.cpp | 61 ++++++++-- esphome/components/api/api_server.h | 21 +++- .../esp32_improv/esp32_improv_component.cpp | 31 ++++++ esphome/components/network/__init__.py | 75 ++++++++----- esphome/components/provisioning/__init__.py | 104 ++++++++++++++++++ .../components/provisioning/provisioning.cpp | 92 ++++++++++++++++ .../components/provisioning/provisioning.h | 96 ++++++++++++++++ esphome/components/wifi/__init__.py | 16 +++ esphome/components/wifi/wifi_component.cpp | 20 +++- esphome/core/defines.h | 1 + .../provisioning/test_provisioning.py | 84 ++++++++++++++ .../provisioning/test.esp32-idf.yaml | 25 +++++ .../provisioning/test.esp8266-ard.yaml | 16 +++ .../provisioning/validate.esp32-idf.yaml | 15 +++ 24 files changed, 724 insertions(+), 49 deletions(-) create mode 100644 esphome/components/provisioning/__init__.py create mode 100644 esphome/components/provisioning/provisioning.cpp create mode 100644 esphome/components/provisioning/provisioning.h create mode 100644 tests/component_tests/provisioning/test_provisioning.py create mode 100644 tests/components/provisioning/test.esp32-idf.yaml create mode 100644 tests/components/provisioning/test.esp8266-ard.yaml create mode 100644 tests/components/provisioning/validate.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 34ec4bc2bd..821d2e5e74 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -404,6 +404,7 @@ esphome/components/pn7160_i2c/* @jesserockz @kbx81 esphome/components/pn7160_spi/* @jesserockz @kbx81 esphome/components/power_supply/* @esphome/core esphome/components/preferences/* @esphome/core +esphome/components/provisioning/* @esphome/core esphome/components/psram/* @esphome/core esphome/components/pulse_meter/* @cstaahl @stevebaxter @TrentHouliston esphome/components/pvvx_mithermometer/* @pasiz diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 11ada7e970..64b025fee1 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -112,6 +112,23 @@ CONF_MAX_SEND_QUEUE = "max_send_queue" CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only" +def _register_provisioning_source(config: ConfigType) -> ConfigType: + """Register the API as a provisioning source when encryption is enabled. + + With no ``key`` the device boots unprovisioned and is set up on first + connection; a YAML ``key`` means it is born provisioned. Either way the API + drives the provisioning manager, so it counts as a source for `provisioning:`. + A hardcoded ``key`` is reported so `provisioning:` can warn about it. + """ + if (encryption := config.get(CONF_ENCRYPTION)) is not None: + from esphome.components import provisioning + + provisioning.register_source("api") + if CONF_KEY in encryption: + provisioning.report_hardcoded_credentials("api") + return config + + def validate_encryption_key(value): value = cv.string_strict(value) try: @@ -337,6 +354,7 @@ CONFIG_SCHEMA = cv.All( ).extend(cv.COMPONENT_SCHEMA), cv.rename_key(CONF_SERVICES, CONF_ACTIONS), _consume_api_sockets, + _register_provisioning_source, ) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index f4f15c1042..86707d9810 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -158,6 +158,16 @@ message AuthenticationResponse { bool invalid_password = 1; } +// Reason a party is requesting the connection be closed. +enum DisconnectReason { + // No specific reason / not provided (default for older peers). + DISCONNECT_REASON_UNSPECIFIED = 0; + // The device's provisioning window has expired. The device must be reset + // (power-cycled) to reopen the provisioning window before it will accept a + // connection again. + DISCONNECT_REASON_PROVISIONING_CLOSED = 1; +} + // Request to close the connection. // Can be sent by both the client and server message DisconnectRequest { @@ -166,6 +176,10 @@ message DisconnectRequest { option (no_delay) = true; // Do not close the connection before the acknowledgement arrives + + // Optional reason the connection is being closed. Older peers that do not + // send this field will report DISCONNECT_REASON_UNSPECIFIED (0). + DisconnectReason reason = 1; } message DisconnectResponse { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index cb7d1b9d1e..dcb1478ec8 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -25,6 +25,9 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esphome/core/version.h" +#ifdef USE_PROVISIONING +#include "esphome/components/provisioning/provisioning.h" +#endif #ifdef USE_DEEP_SLEEP #include "esphome/components/deep_sleep/deep_sleep_component.h" @@ -1724,6 +1727,19 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { resp.server_info = ESPHOME_VERSION_REF; resp.name = StringRef(App.get_name()); +#ifdef USE_PROVISIONING + if (provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed()) { + // The provisioning window has closed without the device being provisioned. + // Acknowledge the hello so the client can read the server name, then request + // disconnect with the reason. Authentication is intentionally not completed. + this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Provisioning closed; rejecting connection")); + this->send_message(resp); + DisconnectRequest req; + req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED; + return this->send_message(req); + } +#endif + // Auto-authenticate - password auth was removed in ESPHome 2026.1.0 this->complete_authentication_(); @@ -1874,7 +1890,8 @@ void APIConnection::on_hello_request(const HelloRequest &msg) { this->on_fatal_error(); } } -void APIConnection::on_disconnect_request() { +void APIConnection::on_disconnect_request(const DisconnectRequest & /*msg*/) { + // The reason is informational when a client disconnects us; we always ack and close. if (!this->send_disconnect_response_()) { this->on_fatal_error(); } @@ -2002,6 +2019,15 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio NoiseEncryptionSetKeyResponse resp; resp.success = false; +#ifdef USE_PROVISIONING + // Refuse to set a key once the provisioning window has closed (defense in depth; + // such connections are already rejected at hello). + if (provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed()) { + ESP_LOGW(TAG, "Provisioning closed; rejecting key set"); + return this->send_message(resp); + } +#endif + psk_t psk{}; if (msg.key_len == 0) { if (this->parent_->clear_noise_psk(true)) { diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index dae5fc92fd..d6d3e4d26b 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -259,7 +259,7 @@ class APIConnection final : public APIServerConnectionBase { void on_get_time_response(const GetTimeResponse &value); #endif void on_hello_request(const HelloRequest &msg); - void on_disconnect_request(); + void on_disconnect_request(const DisconnectRequest &msg); void on_ping_request(); void on_device_info_request(); void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index c711ef167c..de6ae4751e 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -47,6 +47,26 @@ uint32_t HelloResponse::calculate_size() const { size += 2 + this->name.size(); return size; } +bool DisconnectRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { + switch (field_id) { + case 1: + this->reason = static_cast(value); + break; + default: + return false; + } + return true; +} +uint8_t *DisconnectRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->reason)); + return pos; +} +uint32_t DisconnectRequest::calculate_size() const { + uint32_t size = 0; + size += this->reason ? 2 : 0; + return size; +} #ifdef USE_AREAS uint8_t *AreaInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 7e926ee0d4..d268a40c56 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -11,6 +11,10 @@ namespace esphome::api { namespace enums { +enum DisconnectReason : uint32_t { + DISCONNECT_REASON_UNSPECIFIED = 0, + DISCONNECT_REASON_PROVISIONING_CLOSED = 1, +}; enum SerialProxyPortType : uint32_t { SERIAL_PROXY_PORT_TYPE_TTL = 0, SERIAL_PROXY_PORT_TYPE_RS232 = 1, @@ -427,18 +431,22 @@ class HelloResponse final : public ProtoMessage { protected: }; -class DisconnectRequest final : public ProtoMessage { +class DisconnectRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 5; - static constexpr uint8_t ESTIMATED_SIZE = 0; + static constexpr uint8_t ESTIMATED_SIZE = 2; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("disconnect_request"); } #endif + enums::DisconnectReason reason{}; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; + uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class DisconnectResponse final : public ProtoMessage { public: diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 850ad37bc9..3a1ceba95f 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -125,6 +125,16 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint } #pragma GCC diagnostic pop +template<> const char *proto_enum_to_string(enums::DisconnectReason value) { + switch (value) { + case enums::DISCONNECT_REASON_UNSPECIFIED: + return ESPHOME_PSTR("DISCONNECT_REASON_UNSPECIFIED"); + case enums::DISCONNECT_REASON_PROVISIONING_CLOSED: + return ESPHOME_PSTR("DISCONNECT_REASON_PROVISIONING_CLOSED"); + default: + return ESPHOME_PSTR("UNKNOWN"); + } +} template<> const char *proto_enum_to_string(enums::SerialProxyPortType value) { switch (value) { case enums::SERIAL_PROXY_PORT_TYPE_TTL: @@ -864,7 +874,8 @@ const char *HelloResponse::dump_to(DumpBuffer &out) const { return out.c_str(); } const char *DisconnectRequest::dump_to(DumpBuffer &out) const { - out.append_p(ESPHOME_PSTR("DisconnectRequest {}")); + MessageDumpHelper helper(out, ESPHOME_PSTR("DisconnectRequest")); + dump_field(out, ESPHOME_PSTR("reason"), static_cast(this->reason)); return out.c_str(); } const char *DisconnectResponse::dump_to(DumpBuffer &out) const { diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 0ba2961a13..5c9df433dd 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -51,10 +51,12 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } case DisconnectRequest::MESSAGE_TYPE: { + DisconnectRequest msg; + msg.decode(msg_data, msg_size); #ifdef HAS_PROTO_MESSAGE_DUMP - this->log_receive_message_(LOG_STR("on_disconnect_request")); + this->log_receive_message_(LOG_STR("on_disconnect_request"), msg); #endif - this->on_disconnect_request(); + this->on_disconnect_request(msg); break; } case DisconnectResponse::MESSAGE_TYPE: { diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index aca42ca303..d1b51f4846 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -21,7 +21,7 @@ class APIServerConnectionBase { void on_hello_request(const HelloRequest &value){}; - void on_disconnect_request(){}; + void on_disconnect_request(const DisconnectRequest &value){}; void on_disconnect_response(){}; void on_ping_request(){}; void on_ping_response(){}; diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index efdeb6991b..1062dfeb39 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -107,8 +107,30 @@ void APIServer::setup() { // Initialize last_connected_ for reboot timeout tracking this->last_connected_ = App.get_loop_component_start_time(); - // Set warning status if reboot timeout is enabled - if (this->reboot_timeout_ != 0) { +#if defined(USE_PROVISIONING) && defined(USE_API_NOISE) + // Register with the provisioning manager (provisioning:) as a source and + // report our current state (provisioned == an encryption key is set). When the + // window closes, disconnect any client still attempting to provision so it learns + // the reason. The manager owns the timeout, window state and on_timeout automation. + if (provisioning::global_provisioning_manager != nullptr) { + this->provisioning_source_ = provisioning::global_provisioning_manager->register_source(); + provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_, + this->noise_ctx_.has_psk()); + provisioning::global_provisioning_manager->add_on_closed_callback([this]() { + for (auto &c : this->active_clients()) { + DisconnectRequest req; + req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED; + // Best-effort: if the send buffer is full the reason is dropped, but the + // client still learns the window is closed when it reconnects (rejected at + // hello) or via the socket close. + c->send_message(req); + } + }); + } +#endif + // Set warning status if reboot timeout is enabled (suppressed while provisioning + // is pending so the device waits to be onboarded instead of rebooting). + if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) { this->status_set_warning(LOG_STR("waiting for client connection")); } } @@ -121,8 +143,10 @@ void APIServer::loop() { if (this->api_connection_count_ == 0) { // Check reboot timeout - done in loop to avoid scheduler heap churn - // (cancelled scheduler items sit in heap memory until their scheduled time) - if (this->reboot_timeout_ != 0) { + // (cancelled scheduler items sit in heap memory until their scheduled time). + // Suppressed while a provisioning window is pending so the device waits to be + // onboarded / reset instead of rebooting itself; resumes once provisioned. + if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) { const uint32_t now = App.get_loop_component_start_time(); if (now - this->last_connected_ > this->reboot_timeout_) { ESP_LOGE(TAG, "No clients; rebooting"); @@ -194,7 +218,8 @@ void APIServer::remove_client_(uint8_t client_index) { this->clients_[last_index].reset(); // Last client disconnected - set warning and start tracking for reboot timeout - if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0) { + // (suppressed while provisioning is pending - see loop()). + if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) { this->status_set_warning(LOG_STR("waiting for client connection")); this->last_connected_ = App.get_loop_component_start_time(); } @@ -232,7 +257,7 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() { conn->start(); // First client connected - clear warning and update timestamp - if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0) { + if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) { this->status_clear_warning(); this->last_connected_ = App.get_loop_component_start_time(); } @@ -572,8 +597,16 @@ bool APIServer::save_noise_psk(psk_t psk, bool make_active) { } SavedNoisePsk new_saved_psk{psk}; - return this->update_noise_psk_(new_saved_psk, LOG_STR("Noise PSK saved"), LOG_STR("Failed to save Noise PSK"), - make_active); + bool result = this->update_noise_psk_(new_saved_psk, LOG_STR("Noise PSK saved"), LOG_STR("Failed to save Noise PSK"), + make_active); +#ifdef USE_PROVISIONING + // The device now has a key; report provisioned so the provisioning window is + // satisfied and the reboot timeout resumes normal operation. + if (result && provisioning::global_provisioning_manager != nullptr) { + provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_, true); + } +#endif + return result; #endif } bool APIServer::clear_noise_psk(bool make_active) { @@ -584,8 +617,16 @@ bool APIServer::clear_noise_psk(bool make_active) { return false; #else SavedNoisePsk empty_psk{}; - return this->update_noise_psk_(empty_psk, LOG_STR("Noise PSK cleared"), LOG_STR("Failed to clear Noise PSK"), - make_active); + bool result = this->update_noise_psk_(empty_psk, LOG_STR("Noise PSK cleared"), LOG_STR("Failed to clear Noise PSK"), + make_active); +#ifdef USE_PROVISIONING + // The key was cleared; report unprovisioned so a subsequent reboot reopens the + // provisioning window. + if (result && provisioning::global_provisioning_manager != nullptr) { + provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_, false); + } +#endif + return result; #endif } #endif diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 16b5762f68..248b83a0ff 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -14,6 +14,9 @@ #include "esphome/core/controller.h" #include "esphome/core/log.h" #include "esphome/core/string_ref.h" +#ifdef USE_PROVISIONING +#include "esphome/components/provisioning/provisioning.h" +#endif #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" #endif @@ -255,6 +258,19 @@ class APIServer final : public Component, // Remove a disconnected client by index. Swaps with the last populated slot and resets it. void __attribute__((noinline)) remove_client_(uint8_t client_index); +#ifdef USE_PROVISIONING + // True while a configured provisioning window is still pending (the device is + // unprovisioned). Suppresses the reboot timeout and its warning so the device is + // not auto-rebooted while waiting to be provisioned. False when no provisioning + // window is configured. + bool provisioning_pending_() const { + return provisioning::global_provisioning_manager != nullptr && + provisioning::global_provisioning_manager->window_pending(); + } +#else + bool provisioning_pending_() const { return false; } +#endif + #ifdef USE_API_NOISE bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg, bool make_active); @@ -332,7 +348,10 @@ class APIServer final : public Component, uint8_t listen_backlog_{4}; bool shutting_down_ = false; uint8_t api_connection_count_{0}; - // 7 bytes used, 1 byte padding +#if defined(USE_PROVISIONING) && defined(USE_API_NOISE) + // Index assigned by the provisioning manager for reporting this transport's state. + uint8_t provisioning_source_{0}; +#endif #ifdef USE_API_NOISE APINoiseContext noise_ctx_; diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index e6fcc018d9..6e3a4ef526 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -7,6 +7,10 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#ifdef USE_PROVISIONING +#include "esphome/components/provisioning/provisioning.h" +#endif + #ifdef USE_ESP32 namespace esphome::esp32_improv { @@ -41,6 +45,15 @@ void ESP32ImprovComponent::setup() { #endif global_ble_server->on_disconnect([this](uint16_t conn_id) { this->set_error_(improv::ERROR_NONE); }); +#ifdef USE_PROVISIONING + if (provisioning::global_provisioning_manager != nullptr) { + provisioning::global_provisioning_manager->add_on_closed_callback([this]() { + ESP_LOGD(TAG, "Provisioning window closed; stopping Improv"); + this->stop(); + }); + } +#endif + // Start with loop disabled - will be enabled by start() when needed this->disable_loop(); } @@ -282,6 +295,15 @@ void ESP32ImprovComponent::start() { if (this->should_start_ || this->state_ != improv::STATE_STOPPED) return; +#ifdef USE_PROVISIONING + // Don't (re)start advertising once the provisioning window has closed - e.g. when + // wifi tries to restart Improv after the window expired at runtime. + if (provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed()) { + ESP_LOGD(TAG, "Provisioning window closed; not starting Improv"); + return; + } +#endif + ESP_LOGD(TAG, "Setting Improv to start"); this->should_start_ = true; this->enable_loop(); @@ -338,6 +360,15 @@ void ESP32ImprovComponent::process_incoming_data_() { this->incoming_data_.clear(); return; } +#ifdef USE_PROVISIONING + if (provisioning::global_provisioning_manager != nullptr && + provisioning::global_provisioning_manager->closed()) { + ESP_LOGW(TAG, "Provisioning window closed; refusing settings"); + this->set_error_(improv::ERROR_NOT_AUTHORIZED); + this->incoming_data_.clear(); + return; + } +#endif if (wifi::global_wifi_component->is_disabled()) { // Wi-Fi is disabled, so we can't provision. Respond immediately // instead of letting the client wait out its provisioning timeout. diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index b7dfb8d6d2..0f4bcb3e16 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -25,6 +25,20 @@ NetworkComponent = network_ns.class_("NetworkComponent", cg.Component) IPAddress = network_ns.class_("IPAddress") +def _register_provisioning_source(config: ConfigType) -> ConfigType: + """Register network connectivity as a provisioning source. + + The network component is auto-loaded whenever an interface (wifi, ethernet, ...) + is configured, so a device with connectivity always has this source: it is + considered provisioned once it has connected via any interface, and + `provisioning:` is valid without another source. + """ + from esphome.components import provisioning + + provisioning.register_source("network") + return config + + def ip_address_literal(ip: str | int | None) -> cg.MockObj: """Generate an IPAddress with compile-time initialization instead of runtime parsing. @@ -128,36 +142,41 @@ def validate_ipv6(value: bool) -> bool: return value -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(NetworkComponent), - cv.SplitDefault( - CONF_ENABLE_IPV6, - bk72xx=False, - esp32=False, - esp8266=False, - host=False, - rp2=False, - nrf52=True, - ): cv.All( - cv.boolean, - cv.Any( - cv.require_framework_version( - bk72xx_arduino=cv.Version(1, 7, 0), - esp_idf=cv.Version(0, 0, 0), - esp32_arduino=cv.Version(0, 0, 0), - esp8266_arduino=cv.Version(0, 0, 0), - host=cv.Version(0, 0, 0), - rp2_arduino=cv.Version(0, 0, 0), - nrf52_zephyr=cv.Version(0, 0, 0), +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(NetworkComponent), + cv.SplitDefault( + CONF_ENABLE_IPV6, + bk72xx=False, + esp32=False, + esp8266=False, + host=False, + rp2=False, + nrf52=True, + ): cv.All( + cv.boolean, + cv.Any( + cv.require_framework_version( + bk72xx_arduino=cv.Version(1, 7, 0), + esp_idf=cv.Version(0, 0, 0), + esp32_arduino=cv.Version(0, 0, 0), + esp8266_arduino=cv.Version(0, 0, 0), + host=cv.Version(0, 0, 0), + rp2_arduino=cv.Version(0, 0, 0), + nrf52_zephyr=cv.Version(0, 0, 0), + ), + cv.boolean_false, ), - cv.boolean_false, + validate_ipv6, ), - validate_ipv6, - ), - cv.Optional(CONF_MIN_IPV6_ADDR_COUNT, default=0): cv.positive_int, - cv.Optional(CONF_ENABLE_HIGH_PERFORMANCE): cv.All(cv.boolean, cv.only_on_esp32), - } + cv.Optional(CONF_MIN_IPV6_ADDR_COUNT, default=0): cv.positive_int, + cv.Optional(CONF_ENABLE_HIGH_PERFORMANCE): cv.All( + cv.boolean, cv.only_on_esp32 + ), + } + ), + _register_provisioning_source, ) diff --git a/esphome/components/provisioning/__init__.py b/esphome/components/provisioning/__init__.py new file mode 100644 index 0000000000..36fa69357a --- /dev/null +++ b/esphome/components/provisioning/__init__.py @@ -0,0 +1,104 @@ +from dataclasses import dataclass, field +import logging + +from esphome import automation +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_ON_TIMEOUT, CONF_TIMEOUT +from esphome.core import CORE +from esphome.types import ConfigType + +CODEOWNERS = ["@esphome/core"] +DOMAIN = "provisioning" + +_LOGGER = logging.getLogger(__name__) + +provisioning_ns = cg.esphome_ns.namespace("provisioning") +ProvisioningManager = provisioning_ns.class_("ProvisioningManager", cg.Component) + + +@dataclass +class ProvisioningData: + # Names of the components that registered as a provisioning source this run. + sources: set[str] = field(default_factory=set) + # Names of source components that have their credentials set in the config. + hardcoded_credentials: set[str] = field(default_factory=set) + + +def _get_data() -> ProvisioningData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = ProvisioningData() + return CORE.data[DOMAIN] + + +def register_source(name: str) -> None: + """Record that ``name`` is a provisioning source for this configuration. + + A provisioning-capable component (a transport that boots unprovisioned and is + set up by the controller on first connection, or a network interface that + provisions once connected) calls this while its own config is being processed, + typically from a schema validator. `provisioning:` then confirms at least one + source is present without inspecting the full config or knowing about any + specific component. State lives in CORE.data, which is cleared between runs. + """ + _get_data().sources.add(name) + + +def report_hardcoded_credentials(name: str) -> None: + """Record that source component ``name`` has its credentials set in the config. + + A source component calls this from its own validator when it finds baked-in + credentials (a WiFi SSID/password, an API encryption key, ...). `provisioning:` + warns about these, since a device that ships with credentials does not need a + provisioning window. The warning is emitted here, by `provisioning:`, so the + source components stay unaware of it. + """ + _get_data().hardcoded_credentials.add(name) + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(ProvisioningManager), + cv.Required(CONF_TIMEOUT): cv.All( + cv.positive_not_null_time_period, cv.positive_time_period_milliseconds + ), + cv.Optional(CONF_ON_TIMEOUT): automation.validate_automation(single=True), + } +).extend(cv.COMPONENT_SCHEMA) + + +def _final_validate(config: ConfigType) -> ConfigType: + """Validate the provisioning setup once every component has been processed. + + Sources register during their own config validation, so by final validation + both the source set and the hardcoded-credentials set are complete. + """ + data = _get_data() + if not data.sources: + raise cv.Invalid( + "'provisioning' requires at least one provisioning-capable component: " + "configure a network interface such as 'wifi:' or 'ethernet:', or enable " + "'api:' with 'encryption:' and no 'key:' so the device boots " + "unprovisioned and is configured on first connection." + ) + if data.hardcoded_credentials: + _LOGGER.warning( + "'provisioning' is configured, but credentials are set in the " + "configuration for: %s. A device that uses a provisioning window should " + "ship without credentials so they are set on first connection; " + "hardcoding them makes the window pointless.", + ", ".join(sorted(data.hardcoded_credentials)), + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_PROVISIONING") + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + cg.add(var.set_timeout(config[CONF_TIMEOUT])) + if on_timeout := config.get(CONF_ON_TIMEOUT): + await automation.build_automation(var.get_timeout_trigger(), [], on_timeout) diff --git a/esphome/components/provisioning/provisioning.cpp b/esphome/components/provisioning/provisioning.cpp new file mode 100644 index 0000000000..02c089bfed --- /dev/null +++ b/esphome/components/provisioning/provisioning.cpp @@ -0,0 +1,92 @@ +#include "esphome/components/provisioning/provisioning.h" +#ifdef USE_PROVISIONING +#include "esphome/core/application.h" +#include "esphome/core/log.h" +#ifdef USE_NETWORK +#include "esphome/components/network/util.h" +#endif + +#include + +namespace esphome::provisioning { + +static const char *const TAG = "provisioning"; + +ProvisioningManager *global_provisioning_manager = // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + nullptr; + +ProvisioningManager::ProvisioningManager() { + global_provisioning_manager = this; +#ifdef USE_NETWORK + // Network connectivity is a built-in provisioning source. Registered here rather + // than from a source's setup() because connectivity is universal, not a pluggable + // transport; loop() latches it provisioned once the device has connected. + this->network_source_ = this->register_source(); +#endif +} + +uint8_t ProvisioningManager::register_source() { + if (this->source_count_ >= MAX_SOURCES) { + // Defensive: only a handful of sources exist in practice. Fail loudly rather + // than shifting past the mask width (undefined behavior). The returned index is + // ignored by set_source_provisioned()'s bounds check. + ESP_LOGE(TAG, "Too many provisioning sources (max %u)", MAX_SOURCES); + return this->source_count_; + } + uint8_t source = this->source_count_++; + this->registered_mask_ |= (1UL << source); + return source; +} + +void ProvisioningManager::loop() { + // Sources register during their own setup() (at various priorities), and this + // loop() also runs while waiting on a slow component during setup. Evaluating the + // provisioning state before every source has registered could conclude + // "provisioned" prematurely and disable_loop() for good, defeating the window -- + // so do nothing until all setup() calls are done. + if (!App.is_setup_complete()) + return; + +#ifdef USE_NETWORK + // Latch the built-in connectivity source once the device has been reachable via + // any interface. network::is_connected() aggregates wifi/ethernet/modem/... (OR + // across interfaces), and a disabled interface never connects so it never + // contributes. Latched: a later link drop does not un-provision -- the RAM-only + // window still reopens only on reboot. + if ((this->provisioned_mask_ & (1UL << this->network_source_)) == 0 && network::is_connected()) + this->set_source_provisioned(this->network_source_, true); +#endif + + // The window is resolved once the device is provisioned or the window has closed; + // there is nothing left to track, so stop running entirely. Config validation + // guarantees at least one source, so is_provisioned() is never vacuously true here. + if (this->closed_ || this->is_provisioned()) { + this->disable_loop(); + return; + } + // The window timer runs from boot (millis since boot). The closed state is not + // persisted, so a reboot reopens the window. + if (this->timeout_ != 0 && App.get_loop_component_start_time() > this->timeout_) { + this->close_window_(); + } +} + +void ProvisioningManager::close_window_() { + this->closed_ = true; + ESP_LOGW(TAG, "Window expired; cycle power to reopen window"); + // Notify internal consumers first (transports disconnect clients, Improv stops), + // then fire the user-facing automation. + this->closed_callback_.call(); + this->timeout_trigger_.trigger(); +} + +void ProvisioningManager::dump_config() { + ESP_LOGCONFIG(TAG, + "Provisioning:\n" + " Timeout: %" PRIu32 "ms\n" + " Provisioned: %s", + this->timeout_, YESNO(this->is_provisioned())); +} + +} // namespace esphome::provisioning +#endif // USE_PROVISIONING diff --git a/esphome/components/provisioning/provisioning.h b/esphome/components/provisioning/provisioning.h new file mode 100644 index 0000000000..e21b8f3ef0 --- /dev/null +++ b/esphome/components/provisioning/provisioning.h @@ -0,0 +1,96 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_PROVISIONING +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include + +namespace esphome::provisioning { + +// Central provisioning-window manager (EN18031). A device that ships unprovisioned +// (secure transports enabled with no credentials, configured by the controller on +// first connection) opens a provisioning window at boot. Each transport that needs +// provisioning registers as a "source" and reports its state; the device is +// considered provisioned once every registered source is provisioned. +// +// Network connectivity is a built-in source: a device with a network interface but +// no other provisioning-capable component (no api encryption, etc.) is still +// considered provisioned once it has connected via any interface -- so an +// Improv-only device reports its state correctly. +// +// If the window times out while still unprovisioned it closes: the closed state is +// RAM-only (a power cycle / reset reopens it) and the `on_timeout` automation fires. +// Components query window_pending()/closed() to suppress reboot timeouts and refuse +// further provisioning. This manager owns no transport knowledge; transports +// (api, and later mqtt/wireguard/...) drive it through the source API. +class ProvisioningManager : public Component { + public: + // Maximum number of provisioning sources, limited by the width of the state masks. + static constexpr uint8_t MAX_SOURCES = 32; + + ProvisioningManager(); + + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::BEFORE_CONNECTION; } + + void set_timeout(uint32_t timeout) { this->timeout_ = timeout; } + + // Register a provisioning source. Returns a bit index the source uses to report + // its state via set_source_provisioned(). Call once, from the source's setup(). + uint8_t register_source(); + // Report whether the given source currently holds valid credentials. + void set_source_provisioned(uint8_t source, bool provisioned) { + if (source >= MAX_SOURCES) + return; + if (provisioned) { + this->provisioned_mask_ |= (1UL << source); + } else { + this->provisioned_mask_ &= ~(1UL << source); + } + } + + // True once every registered source is provisioned. Config validation guarantees + // at least one source, and the built-in connectivity source registers in the + // constructor, so registered_mask_ is never zero in practice. + bool is_provisioned() const { return (this->provisioned_mask_ & this->registered_mask_) == this->registered_mask_; } + // True while provisioning is still pending: the device is unprovisioned, whether + // the window is still open or has already closed. Reboot timeouts are suppressed + // while this holds so the device never auto-reboots (and silently reopens the + // window) while unprovisioned. + bool window_pending() const { return !this->is_provisioned(); } + // True once the window has expired without the device being provisioned. + bool closed() const { return this->closed_; } + + // Register a callback fired once when the window closes (runtime expiry). Used + // internally by transports/Improv to stop accepting provisioning. The user-facing + // on_timeout automation is wired to get_timeout_trigger() instead. + template void add_on_closed_callback(F &&callback) { + this->closed_callback_.add(std::forward(callback)); + } + Trigger<> *get_timeout_trigger() { return &this->timeout_trigger_; } + + protected: + void close_window_(); + + Trigger<> timeout_trigger_; + LazyCallbackManager closed_callback_; + uint32_t timeout_{0}; + uint32_t registered_mask_{0}; + uint32_t provisioned_mask_{0}; + uint8_t source_count_{0}; + bool closed_{false}; +#ifdef USE_NETWORK + // Built-in connectivity source (see loop()): registered in the constructor and + // latched provisioned once the device has connected via any network interface. + uint8_t network_source_{0}; +#endif +}; + +extern ProvisioningManager *global_provisioning_manager; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +} // namespace esphome::provisioning +#endif // USE_PROVISIONING diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index dc5c8be4d7..137304c807 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -436,6 +436,21 @@ def _validate(config): return config +def _report_provisioning_credentials(config): + """Report baked-in STA credentials to the provisioning component (if used). + + `_validate` has already folded any ``ssid``/``password`` into ``networks``, so a + non-empty list means credentials are set in the config. `provisioning:` warns + about this, since a device that uses a provisioning window should get its + credentials on first connection instead. + """ + if config.get(CONF_NETWORKS): + from esphome.components import provisioning + + provisioning.report_hardcoded_credentials("wifi") + return config + + CONF_PASSIVE_SCAN = "passive_scan" FAST_CONNECT_SCHEMA = cv.Schema( @@ -517,6 +532,7 @@ CONFIG_SCHEMA = cv.All( ), _apply_min_auth_mode_default, _validate, + _report_provisioning_credentials, ) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index c951e74358..44e3cb6af9 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -45,6 +45,10 @@ #include "esphome/components/improv_serial/improv_serial_component.h" #endif +#ifdef USE_PROVISIONING +#include "esphome/components/provisioning/provisioning.h" +#endif + namespace esphome::wifi { static const char *const TAG = "wifi"; @@ -872,8 +876,20 @@ void WiFiComponent::loop() { if (!this->has_ap() && this->reboot_timeout_ != 0) { if (now - this->last_connected_ > this->reboot_timeout_) { - ESP_LOGE(TAG, "Can't connect; rebooting"); - App.reboot(); + bool suppress = false; +#ifdef USE_PROVISIONING + // Don't reboot while a provisioning window is pending (device unprovisioned). + // The device is legitimately waiting to be onboarded (Wi-Fi must come up + // before the controller can set credentials), and an auto-reboot would reopen + // the window without the deliberate power cycle / reset that is meant to be + // required. Resumes normal reboot behavior once provisioned. + suppress = provisioning::global_provisioning_manager != nullptr && + provisioning::global_provisioning_manager->window_pending(); +#endif + if (!suppress) { + ESP_LOGE(TAG, "Can't connect; rebooting"); + App.reboot(); + } } } } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 1d09bb5c5c..639508a7b2 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -153,6 +153,7 @@ #define USE_OUTPUT_FLOAT_POWER_SCALING #define USE_POWER_SUPPLY #define USE_PREFERENCES_SYNC_EVERY_LOOP +#define USE_PROVISIONING #define USE_QR_CODE #define USE_SAFE_MODE_CALLBACK #define ESPHOME_SAFE_MODE_CALLBACK_COUNT 1 diff --git a/tests/component_tests/provisioning/test_provisioning.py b/tests/component_tests/provisioning/test_provisioning.py new file mode 100644 index 0000000000..07f5065241 --- /dev/null +++ b/tests/component_tests/provisioning/test_provisioning.py @@ -0,0 +1,84 @@ +"""Tests for the provisioning component config validation.""" + +from __future__ import annotations + +import logging + +import pytest + +from esphome import config_validation as cv +from esphome.components.provisioning import ( + CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA, + register_source, + report_hardcoded_credentials, +) +from esphome.const import CONF_TIMEOUT, PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def test_provisioning_requires_a_source( + set_core_config: SetCoreConfigCallable, +) -> None: + """Provisioning with no registered source is a config error. + + Sources register themselves during their own config validation; with none + registered the window could never resolve, so validation fails. + """ + set_core_config(PlatformFramework.ESP32_IDF) + with pytest.raises(cv.Invalid, match="provisioning-capable component"): + FINAL_VALIDATE_SCHEMA({}) + + +def test_provisioning_accepts_a_registered_source( + set_core_config: SetCoreConfigCallable, +) -> None: + """A component that registered as a provisioning source satisfies validation.""" + set_core_config(PlatformFramework.ESP32_IDF) + register_source("network") + # Should not raise. + assert FINAL_VALIDATE_SCHEMA({}) == {} + + +def test_provisioning_warns_on_hardcoded_credentials( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """A source with credentials set in the config triggers a warning.""" + set_core_config(PlatformFramework.ESP32_IDF) + register_source("network") + report_hardcoded_credentials("wifi") + with caplog.at_level(logging.WARNING): + assert FINAL_VALIDATE_SCHEMA({}) == {} + assert "wifi" in caplog.text + assert "credentials" in caplog.text + + +def test_provisioning_no_warning_without_hardcoded_credentials( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """No credentials warning when no source reports hardcoded credentials.""" + set_core_config(PlatformFramework.ESP32_IDF) + register_source("network") + with caplog.at_level(logging.WARNING): + assert FINAL_VALIDATE_SCHEMA({}) == {} + assert "credentials" not in caplog.text + + +def test_provisioning_rejects_zero_timeout( + set_core_config: SetCoreConfigCallable, +) -> None: + """A zero timeout would leave the window open forever, so it is rejected.""" + set_core_config(PlatformFramework.ESP32_IDF) + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({CONF_TIMEOUT: "0s"}) + + +def test_provisioning_accepts_positive_timeout( + set_core_config: SetCoreConfigCallable, +) -> None: + """A positive timeout is accepted.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = CONFIG_SCHEMA({CONF_TIMEOUT: "5min"}) + assert config[CONF_TIMEOUT].total_milliseconds == 300000 diff --git a/tests/components/provisioning/test.esp32-idf.yaml b/tests/components/provisioning/test.esp32-idf.yaml new file mode 100644 index 0000000000..24168881fc --- /dev/null +++ b/tests/components/provisioning/test.esp32-idf.yaml @@ -0,0 +1,25 @@ +# Exercises the provisioning window: api registers as a provisioning source +# (encryption enabled, no key), the on_timeout automation, and the wifi + +# esp32_improv cross-component guards. improv_serial is intentionally NOT gated. +provisioning: + timeout: 1min + on_timeout: + then: + - logger.log: "Provisioning window expired" + +api: + encryption: + +wifi: + ssid: MySSID + password: password1 + +improv_serial: + +binary_sensor: + - platform: gpio + pin: 0 + id: io0_button + +esp32_improv: + authorizer: io0_button diff --git a/tests/components/provisioning/test.esp8266-ard.yaml b/tests/components/provisioning/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4188c00bef --- /dev/null +++ b/tests/components/provisioning/test.esp8266-ard.yaml @@ -0,0 +1,16 @@ +# Provisioning window on ESP8266 (no BLE Improv): api as a provisioning source +# and the wifi reboot guard. improv_serial is present and intentionally NOT gated. +provisioning: + timeout: 1min + on_timeout: + then: + - logger.log: "Provisioning window expired" + +api: + encryption: + +wifi: + ssid: MySSID + password: password1 + +improv_serial: diff --git a/tests/components/provisioning/validate.esp32-idf.yaml b/tests/components/provisioning/validate.esp32-idf.yaml new file mode 100644 index 0000000000..1fd3d67882 --- /dev/null +++ b/tests/components/provisioning/validate.esp32-idf.yaml @@ -0,0 +1,15 @@ +# A device provisioned over the network (wifi / Improv) with no api: network +# connectivity alone satisfies provisioning, so `provisioning:` is valid without an +# api encryption source. Config-only -- exercises the network provisioning-source +# validation path (the Improv-only case from the review). +provisioning: + timeout: 1min + on_timeout: + then: + - logger.log: "Provisioning window expired" + +wifi: + ssid: MySSID + password: password1 + +improv_serial: From 2f5465c0e85effce2792df0a8f8f3e1317591d4c Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 8 Jul 2026 12:07:42 -0400 Subject: [PATCH 0781/1815] [sendspin] Suppress WiFi roam scanning while playing (#17133) --- esphome/components/sendspin/__init__.py | 1 + esphome/components/sendspin/sendspin_hub.cpp | 2 ++ 2 files changed, 3 insertions(+) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index e8c643f9b9..97e7f4e22c 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -138,6 +138,7 @@ def _request_high_performance_networking(config: ConfigType) -> ConfigType: socket.consume_sockets(1, "sendspin_websocket_client")(config) wifi.enable_runtime_power_save_control() + wifi.enable_runtime_roaming_suppression() return config diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 57709306cd..b95d95b2bc 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -129,6 +129,7 @@ void SendspinHub::on_request_high_performance() { #ifdef USE_WIFI if (wifi::global_wifi_component != nullptr) { wifi::global_wifi_component->request_high_performance(); + wifi::global_wifi_component->request_roaming_suppression(); } #endif } @@ -137,6 +138,7 @@ void SendspinHub::on_release_high_performance() { #ifdef USE_WIFI if (wifi::global_wifi_component != nullptr) { wifi::global_wifi_component->release_high_performance(); + wifi::global_wifi_component->release_roaming_suppression(); } #endif } From bba3a9657bae2ac8edf3e1cc63c0b58154f9ac28 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 9 Jul 2026 02:17:57 +1000 Subject: [PATCH 0782/1815] [lvgl] Add animations (#16796) Co-authored-by: clydeps Co-authored-by: Claude Opus 4.8 Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/lvgl/__init__.py | 7 +- esphome/components/lvgl/animation.h | 197 +++++++++++++ esphome/components/lvgl/animation.py | 295 +++++++++++++++++++ esphome/components/lvgl/defines.py | 23 +- esphome/components/lvgl/lv_validation.py | 62 ++-- esphome/components/lvgl/types.py | 1 + esphome/core/defines.h | 1 + tests/component_tests/lvgl/test_animation.py | 201 +++++++++++++ tests/components/lvgl/lvgl-package.yaml | 57 ++++ tests/components/lvgl/test.host.yaml | 39 ++- 10 files changed, 854 insertions(+), 29 deletions(-) create mode 100644 esphome/components/lvgl/animation.h create mode 100644 esphome/components/lvgl/animation.py create mode 100644 tests/component_tests/lvgl/test_animation.py diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index ecc4b0a777..b758390f0d 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -52,9 +52,11 @@ from esphome.writer import clean_build from esphome.yaml_util import load_yaml from . import defines as df, lv_validation as lvalid, widgets +from .animation import ANIMATION_SCHEMA, add_animation_triggers, animations_to_code from .automation import layers_to_code, lvgl_update from .defines import ( CONF_ALIGN_TO_LAMBDA_ID, + CONF_ANIMATIONS, LOGGER, add_lv_use, get_focused_widgets, @@ -435,7 +437,8 @@ async def to_code(configs): await layers_to_code(lv_component, config) await lvgl_update(lv_component, config) await msgboxes_to_code(lv_component, config) - # await disp_update(lv_component.get_disp(), config) + await animations_to_code(config.get(CONF_ANIMATIONS, [])) + # Mark all widgets as completed so awaiters of ``wait_for_widgets`` proceed. set_widgets_completed(True) async with LvContext(): @@ -443,6 +446,7 @@ async def to_code(configs): await generate_align_tos(configs[0]) for config in configs: lv_component = await cg.get_variable(config[CONF_ID]) + await add_animation_triggers(config.get(CONF_ANIMATIONS, [])) await generate_page_triggers(config) await initial_focus_to_code(config) for conf in config.get(CONF_ON_IDLE, ()): @@ -636,6 +640,7 @@ LVGL_TOP_LEVEL_SCHEMA = ( for x in SIMPLE_TRIGGERS }, cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA), + cv.Optional(df.CONF_ANIMATIONS): cv.ensure_list(ANIMATION_SCHEMA), cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool, cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec), cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec), diff --git a/esphome/components/lvgl/animation.h b/esphome/components/lvgl/animation.h new file mode 100644 index 0000000000..1e0abce358 --- /dev/null +++ b/esphome/components/lvgl/animation.h @@ -0,0 +1,197 @@ +#pragma once +#include "esphome/core/defines.h" + +#ifdef USE_LVGL_ANIMATION +#include "lvgl_esphome.h" +#include "esphome/core/hal.h" + +namespace esphome::lvgl { + +enum class AnimationState { + STOPPED, + STARTED, + RUNNING, +}; + +class LvAnimationTiming { + public: + // Map progress in the range [0, 1] + virtual float map_progress(float value) = 0; +}; + +class LvAnimationTimingRoundTrip : public LvAnimationTiming { + public: + float map_progress(float value) override { + value *= 2.0f; + if (value > 1.0f) + return 2.0f - value; + return value; + } +}; + +class LvAnimationTimingGravity : public LvAnimationTiming { + public: + LvAnimationTimingGravity(float acceleration, float bounce) : acceleration_(acceleration), bounce_(bounce) {} + float map_progress(float value) override { + if (value == 0.0f) { + this->initial_position_ = 0.0f; + this->initial_speed_ = 0.0f; + this->initial_time_ = 0.0f; + } + auto position = this->calc_pos_(value); + if (position > 1.0f) { + auto initial_time = this->calc_end_time_(); + this->initial_speed_ = -this->calc_speed_(initial_time) * this->bounce_; + this->initial_position_ = 1.0f; + this->initial_time_ = initial_time; + position = calc_pos_(value); + if (position > 1.0f) { + position = 1.0f; + } + } + return position; + } + + protected: + float calc_pos_(float value) const { + value -= this->initial_time_; + return (0.5 * value * this->acceleration_ + this->initial_speed_) * value + this->initial_position_; + } + + float calc_speed_(float value) const { + value -= this->initial_time_; + return this->acceleration_ * value + this->initial_speed_; + } + + float calc_end_time_() const { + return (-this->initial_speed_ + std::sqrt(this->initial_speed_ * this->initial_speed_ - + 4.0f * this->acceleration_ / 2.0 * (this->initial_position_ - 1.0f))) / + this->acceleration_ + + this->initial_time_; + } + + float acceleration_; + float bounce_; + float initial_position_{0.0f}; + float initial_time_{0.0f}; + float initial_speed_{0.0f}; +}; + +class LvAnimationTimingEaseInOut : public LvAnimationTiming { + public: + LvAnimationTimingEaseInOut(float slope) : slope_(slope) {} + float map_progress(float value) override { + float sqr = value * value; + sqr = sqr / (2.0f * (sqr - value) + 1.0f); + return this->slope_ * sqr + (1.0 - this->slope_) * value; + } + + protected: + float slope_; +}; + +template class LvAnimation : public Component { + public: + LvAnimation(void (*update_callback)(const lv_coord_t *data), std::vector> from, + std::vector> to) + : update_callback_(update_callback) { + std::copy(from.begin(), from.end(), this->from_); + std::copy(to.begin(), to.end(), this->to_); + } + + void start() { + if (this->state_ > AnimationState::STOPPED) + this->stop(); + if (this->duration_ == 0) + return; + // evaluate any lambdas + for (size_t i = 0; i != DATA_SIZE; i++) { + this->data_from_[i] = this->from_[i].value(); + this->data_to_[i] = this->to_[i].value(); + } + this->start_time_ = millis(); + this->state_ = AnimationState::STARTED; + this->loop(); + this->start_callback_.call(); + } + + void stop() { + // Only fire the stop callback on a genuine running -> stopped transition, so that + // repeated stop() calls (e.g. start() pre-clearing a stopped animation) don't re-fire it. + if (this->state_ == AnimationState::STOPPED) + return; + this->state_ = AnimationState::STOPPED; + this->stop_callback_.call(); + } + + void setup() override { + if constexpr (AUTO_START) + this->start(); + } + + void loop() override { + if (this->state_ == AnimationState::STOPPED) + return; + uint32_t elapsed = millis() - this->start_time_; + float progress = static_cast(elapsed) / static_cast(this->duration_); + switch (this->state_) { + case AnimationState::STARTED: + if (elapsed < this->start_delay_) + return; + this->state_ = AnimationState::RUNNING; + this->start_time_ = millis(); + progress = 0.0f; + break; + case AnimationState::RUNNING: + if (progress >= 1.0f) { + progress = 1.0f; + this->stop(); + if (this->loop_) + this->start(); + } + break; + default: + return; + } + + for (auto *timing : this->timings_) { + progress = timing->map_progress(progress); + } + lv_coord_t data[DATA_SIZE]; + for (size_t i = 0; i != DATA_SIZE; i++) { + data[i] = static_cast( + roundf(this->data_from_[i] + static_cast(this->data_to_[i] - this->data_from_[i]) * progress)); + } + this->update_callback_(data); + } + + float get_setup_priority() const override { return setup_priority::PROCESSOR - 20.0; } + void set_duration(uint32_t duration) { this->duration_ = duration; } + void set_start_delay(uint32_t start_delay) { this->start_delay_ = start_delay; } + void add_timing(LvAnimationTiming *timing) { this->timings_.push_back(timing); } + void set_loop(bool loop) { this->loop_ = loop; } + + template void add_on_start_callback(F &&callback) { + this->start_callback_.add(std::forward(callback)); + } + template void add_on_stop_callback(F &&callback) { this->stop_callback_.add(std::forward(callback)); } + + protected: + void (*const update_callback_)(const lv_coord_t *data); + LazyCallbackManager start_callback_{}; + LazyCallbackManager stop_callback_{}; + TemplatableValue from_[DATA_SIZE]{}; + TemplatableValue to_[DATA_SIZE]{}; + uint32_t duration_{0}; + uint32_t start_delay_{0}; + uint32_t start_time_{0}; + lv_coord_t data_from_[DATA_SIZE]{0}; + lv_coord_t data_to_[DATA_SIZE]{0}; + AnimationState state_{AnimationState::STOPPED}; + std::vector timings_{}; + bool loop_{false}; +}; + +} // namespace esphome::lvgl + +#endif // USE_LVGL_ANIMATION diff --git a/esphome/components/lvgl/animation.py b/esphome/components/lvgl/animation.py new file mode 100644 index 0000000000..2b1500f2c4 --- /dev/null +++ b/esphome/components/lvgl/animation.py @@ -0,0 +1,295 @@ +from esphome import automation, codegen as cg, config_validation as cv +from esphome.automation import Trigger, build_automation +from esphome.config_validation import COMPONENT_SCHEMA +from esphome.const import ( + CONF_ACCELERATION, + CONF_DURATION, + CONF_FROM, + CONF_ID, + CONF_ON_START, + CONF_TIMING, + CONF_TO, + CONF_TRIGGER_ID, + CONF_TYPE, + CONF_WEIGHT, +) +from esphome.cpp_generator import MockObj, TemplateArguments + +from ..const import CONF_LOOP +from .defines import ( + CONF_AUTO_START, + CONF_LVGL_ID, + CONF_ON_STOP, + CONF_WIDGETS, + LValidator, + add_define, + literal, +) +from .lv_validation import ( + color, + get_component_colors, + lv_color, + lv_milliseconds, + lv_positive_float, + lv_zero_to_one_float, +) +from .lvcode import LVGL_COMP_ARG, LambdaContext, LvglComponent, lv_add +from .schemas import STYLE_PROPS +from .types import LvAnimation, LvglAction, lv_color_t, lv_coord_t, lv_obj_t, lvgl_ns +from .widgets import get_widgets + +LvAnimationTimingRoundTrip = lvgl_ns.class_("LvAnimationTimingRoundTrip") +LvAnimationTimingEaseInOut = lvgl_ns.class_("LvAnimationTimingEaseInOut") + +CONF_BOUNCE = "bounce" + + +def timing_class(name, extras=None): + # Convert config option to camel case + cls_name = "LvAnimationTiming" + "".join([w.capitalize() for w in name.split("_")]) + cls = lvgl_ns.class_(cls_name) + schema = cv.Schema({cv.GenerateID(): cv.declare_id(cls)}) + if extras: + schema = schema.extend(extras) + return name, schema + + +# TODO - currently the order of arguments to timing classes is expected to be alphabetical, but this is not enforced. +# It would be better to have a more robust way of passing arguments to the timing classes. +TIMING_SCHEMA = cv.maybe_simple_value( + cv.typed_schema( + dict( + [ + timing_class("round_trip"), + timing_class( + "ease_in_out", + {cv.Optional(CONF_WEIGHT, default=2.0): lv_positive_float}, + ), + timing_class( + "gravity", + { + cv.Optional(CONF_ACCELERATION, default=0.5): lv_positive_float, + cv.Optional(CONF_BOUNCE, default=0.5): lv_zero_to_one_float, + }, + ), + ] + ), + default_type="ease_in_out", + ), + key=CONF_TYPE, +) + +CONF_START_DELAY = "start_delay" + + +class LiteralColorValidator(LValidator): + def __init__(self): + super().__init__( + color, lv_color_t, retmapper=get_component_colors, animatable=True + ) + + def __call__(self, value): + if isinstance(value, cv.Lambda): + raise cv.Invalid( + "An animated color may not be set with a lambda, only a literal color value." + ) + return super().__call__(value) + + +literal_color = LiteralColorValidator() + + +def from_to(validator): + return cv.Schema( + { + cv.Required(CONF_FROM): validator, + cv.Required(CONF_TO): validator, + } + ) + + +# Colors can only be animated between constants, not lambdas. +def map_v(validator): + if validator == lv_color: + return literal_color + return validator + + +ANIMABLE_STYLES = { + k: map_v(v) + for k, v in STYLE_PROPS.items() + if isinstance(v, LValidator) and v.animatable +} + +ANIMATION_SCHEMA = cv.Schema( + { + cv.Optional(CONF_AUTO_START, default=False): cv.boolean, + cv.Optional(CONF_LOOP, default=False): cv.boolean, + cv.Optional(CONF_DURATION, default="5s"): lv_milliseconds, + cv.Optional(CONF_START_DELAY, default="0s"): lv_milliseconds, + cv.Optional(CONF_TIMING, default=[]): cv.ensure_list(TIMING_SCHEMA), + cv.Required(CONF_ID): cv.declare_id(LvAnimation), + cv.Optional(CONF_ON_START): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(Trigger.template()), + } + ), + cv.Optional(CONF_ON_STOP): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(Trigger.template()), + } + ), + cv.Required(CONF_WIDGETS): cv.ensure_list( + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(lv_obj_t), + } + ).extend({cv.Optional(k): from_to(v) for k, v in ANIMABLE_STYLES.items()}) + ), + } +).extend(COMPONENT_SCHEMA) + + +async def _process_arg(validator, arg) -> list: + # from/to values are evaluated at animation start with no arguments, so the + # generated lambda must be parameterless rather than inheriting the enclosing + # update-callback's `values` parameter. + value = await validator.process(arg, args=[], raw_lambda=True) + value = list(value) if isinstance(value, tuple) else [value] + return [literal(f"TemplatableValue({v})") for v in value] + + +async def animations_to_code(config): + for animation in config: + add_define("USE_LVGL_ANIMATION") + widgets = animation[CONF_WIDGETS] + async with LambdaContext( + [(lv_coord_t.operator("const").operator("ptr"), "values")] + ) as ctx: + froms = [] + tos = [] + for widget in widgets: + w = (await get_widgets(widget))[0] + props = [(k, v) for k, v in widget.items() if k in ANIMABLE_STYLES] + for prop, value_range in props: + # prop is the style property, value_range is a dict with from: and to: values + validator = ANIMABLE_STYLES[prop] + from_value = await _process_arg(validator, value_range[CONF_FROM]) + to_value = await _process_arg(validator, value_range[CONF_TO]) + index = len(froms) + if len(from_value) == 1: + value = f"values[{index}]" + else: + value = f"lv_color_make(values[{index}+0], values[{index}+1], values[{index}+2])" + w.set_style(prop, literal(value), 0) + # The value arrays are extended by 1 item for scalar properties, 3 for colors + froms.extend(from_value) + tos.extend(to_value) + + data_size = len(froms) + loop = animation[CONF_LOOP] + start_delay = await lv_milliseconds.process(animation.get(CONF_START_DELAY)) + var = cg.new_Pvariable( + animation[CONF_ID], + TemplateArguments(data_size, animation[CONF_AUTO_START]), + await ctx.get_lambda(), + froms, + tos, + ) + for timing in animation[CONF_TIMING]: + timing_id = timing[CONF_ID] + args = sorted( + [(k, v) for k, v in timing.items() if k not in [CONF_ID, CONF_TYPE]] + ) + args = [v for k, v in args] + timing_var = cg.new_Pvariable(timing_id, *args) + cg.add(var.add_timing(timing_var)) + + if start_delay: + cg.add(var.set_start_delay(start_delay)) + if loop: + cg.add(var.set_loop(loop)) + cg.add( + var.set_duration(await lv_milliseconds.process(animation[CONF_DURATION])) + ) + await cg.register_component(var, animation) + + +async def add_animation_triggers(config): + async def add_triggers(animation: MockObj, event: str, config: dict) -> None: + for conf in config: + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + await build_automation(trigger, [], conf) + async with LambdaContext([]) as context: + lv_add(trigger.trigger()) + lv_add( + getattr( + animation, + f"add_{event}_callback", + )(await context.get_lambda()) + ) + + for animation in config: + var = await cg.get_variable(animation[CONF_ID]) + await add_triggers(var, CONF_ON_START, animation.get(CONF_ON_START, [])) + await add_triggers(var, CONF_ON_STOP, animation.get(CONF_ON_STOP, [])) + + +@automation.register_action( + "lvgl.animation.start", + LvglAction, + cv.maybe_simple_value( + { + cv.Required(CONF_ID): cv.ensure_list(cv.use_id(LvAnimation)), + cv.GenerateID(CONF_LVGL_ID): cv.use_id(LvglComponent), + cv.Optional(CONF_DURATION): lv_milliseconds, + cv.Optional(CONF_START_DELAY): lv_milliseconds, + cv.Optional(CONF_LOOP): cv.boolean, + }, + key=CONF_ID, + ), + synchronous=True, +) +async def start_animation(config, action_id, template_arg, args): + animations = config[CONF_ID] + loop = config.get(CONF_LOOP) + async with LambdaContext(LVGL_COMP_ARG, where=action_id) as context: + for animation in animations: + anim_var = await cg.get_variable(animation) + if loop is not None: + context.add(anim_var.set_loop(loop)) + if (duration := config.get(CONF_DURATION)) is not None: + context.add( + anim_var.set_duration(await lv_milliseconds.process(duration)) + ) + if (start_delay := config.get(CONF_START_DELAY)) is not None: + context.add( + anim_var.set_start_delay(await lv_milliseconds.process(start_delay)) + ) + context.add(anim_var.start()) + var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) + await cg.register_parented(var, config[CONF_LVGL_ID]) + return var + + +@automation.register_action( + "lvgl.animation.stop", + LvglAction, + cv.maybe_simple_value( + { + cv.Required(CONF_ID): cv.ensure_list(cv.use_id(LvAnimation)), + cv.GenerateID(CONF_LVGL_ID): cv.use_id(LvglComponent), + }, + key=CONF_ID, + ), + synchronous=True, +) +async def stop_animation(config, action_id, template_arg, args): + animations = config[CONF_ID] + async with LambdaContext(LVGL_COMP_ARG, where=action_id) as context: + for animation in animations: + anim_var = await cg.get_variable(animation) + context.add(anim_var.stop()) + var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) + await cg.register_parented(var, config[CONF_LVGL_ID]) + return var diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 15e593b3f6..5c75269c64 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -214,11 +214,14 @@ class LValidator: has `process()` to convert a value during code generation """ - def __init__(self, validator, rtype: MockObj, retmapper=None, requires=None): + def __init__( + self, validator, rtype: MockObj, retmapper=None, requires=None, animatable=False + ): self.validator = validator self.rtype = rtype self.retmapper = retmapper self.requires = requires + self.animatable = animatable def __call__(self, value): if self.requires: @@ -228,7 +231,10 @@ class LValidator: return self.validator(value) async def process( - self, value: Any, args: list[tuple[SafeExpType, str]] | None = None + self, + value: Any, + args: list[tuple[SafeExpType, str]] | None = None, + raw_lambda: bool = False, ) -> Expression: if value is None: return None @@ -236,11 +242,15 @@ class LValidator: # Local import to avoid circular import from .lvcode import get_lambda_context_args - args = args or get_lambda_context_args() + # `args is None` means "inherit the enclosing lambda context"; an explicit + # empty list means "no parameters" and must be preserved as-is. + if args is None: + args = get_lambda_context_args() - return call_lambda( - await cg.process_lambda(value, args, return_type=self.rtype) - ) + lamb = await cg.process_lambda(value, args, return_type=self.rtype) + if raw_lambda: + return lamb + return call_lambda(lamb) if self.retmapper is not None: return self.retmapper(value) if isinstance(value, ID): @@ -751,6 +761,7 @@ CONF_ON_DRAW_END = "on_draw_end" CONF_ON_PAUSE = "on_pause" CONF_ON_RESUME = "on_resume" CONF_ON_SELECT = "on_select" +CONF_ON_STOP = "on_stop" CONF_OPA = "opa" CONF_NEXT = "next" CONF_PAD_ROW = "pad_row" diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 27cbfff694..d31c8324db 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -60,6 +60,7 @@ opacity = LValidator( opacity_validator, lv_opa_t, retmapper=lambda opa: StaticCastExpression(cg.uint8, opa * 255.0), + animatable=True, ) COLOR_NAMES = { @@ -223,35 +224,33 @@ def color(value): ) -def color_retmapper(value): - if isinstance(value, cv.Lambda): - return cv.returning_lambda(value) +def get_component_colors(value): if isinstance(value, str) and value in COLOR_NAMES: value = COLOR_NAMES[value] if isinstance(value, int): - return literal( - f"lv_color_make({(value >> 16) & 0xFF}, {(value >> 8) & 0xFF}, {value & 0xFF})" - ) + return value >> 16, value >> 8 & 0xFF, value & 0xFF if isinstance(value, ID): cval = [x for x in CORE.config[CONF_COLOR] if x[CONF_ID] == value][0] if CONF_HEX in cval: r, g, b = cval[CONF_HEX] else: r, g, b, _ = from_rgbw(cval) - return literal(f"lv_color_make({r}, {g}, {b})") + return r, g, b raise AssertionError(f"Unhandled lv_color value: {value!r}") -def option_string(value): - value = cv.string(value).strip() - if value.find("\n") != -1: - raise cv.Invalid("Options strings must not contain newlines") - return value +def color_retmapper(value): + if isinstance(value, cv.Lambda): + return cv.returning_lambda(value) + r, g, b = get_component_colors(value) + return literal(f"lv_color_make({r}, {g}, {b})") class LvColor(LValidator): def __init__(self): - super().__init__(color, ty.lv_color_t, retmapper=color_retmapper) + super().__init__( + color, ty.lv_color_t, retmapper=color_retmapper, animatable=True + ) def __getattr__(self, item): if item in COLOR_NAMES: @@ -262,6 +261,13 @@ class LvColor(LValidator): lv_color = LvColor() +def option_string(value): + value = cv.string(value).strip() + if value.find("\n") != -1: + raise cv.Invalid("Options strings must not contain newlines") + return value + + def pixels_or_percent_validator(value): """A length in one axis - either a number (pixels) or a percentage""" if value == SCHEMA_EXTRACT: @@ -277,6 +283,7 @@ pixels_or_percent = LValidator( pixels_or_percent_validator, lv_coord_t, retmapper=lambda x: x if isinstance(x, int) else literal(f"lv_pct({int(x * 100)})"), + animatable=True, ) @@ -315,10 +322,10 @@ def angle(value): # Validator for angles in LVGL expressed in 1/10 degree units. -lv_angle = LValidator(angle, uint32, retmapper=lambda x: int(x * 10)) +lv_angle = LValidator(angle, uint32, retmapper=lambda x: int(x * 10), animatable=True) # Validator for angles in LVGL expressed in whole degrees -lv_angle_degrees = LValidator(angle, uint32, retmapper=int) +lv_angle_degrees = LValidator(angle, uint32, retmapper=int, animatable=True) @schema_extractor("one_of") @@ -410,7 +417,10 @@ class TextValidator(LValidator): return super().__call__(value) async def process( - self, value: Any, args: list[tuple[SafeExpType, str]] | None = None + self, + value: Any, + args: list[tuple[SafeExpType, str]] | None = None, + raw_lambda: bool = False, ) -> Expression: # Local import to avoid circular import at module level from .lvcode import get_lambda_context_args @@ -455,13 +465,18 @@ class TextValidator(LValidator): return value # Either a std::string or a lambda call returning that. We need const char* return MockObj(f"({value}).c_str()") - return await super().process(value, args) + return await super().process(value, args, raw_lambda) lv_text = TextValidator() lv_float = LValidator(cv.float_, cg.float_) -lv_int = LValidator(cv.int_, cg.int_) -lv_positive_int = LValidator(cv.positive_int, cg.int_) +lv_positive_float = LValidator(cv.positive_float, cg.float_) +lv_zero_to_one_float = LValidator(cv.zero_to_one_float, cg.float_) +lv_int = LValidator(cv.int_, cg.int_, animatable=True) +lv_positive_int = LValidator(cv.positive_int, cg.int_, animatable=True) +lv_brightness = LValidator( + cv.percentage, cg.float_, retmapper=lambda x: int(x * 255), animatable=True +) def _percentage_validator(value): @@ -508,12 +523,17 @@ class LvFont(LValidator): # The inline overloads in lvgl_esphome.h handle conversion to lv_font_t* super().__init__(validator, Font.operator("ptr")) - async def process(self, value, args=()): + async def process( + self, + value: Any, + args: list[tuple[SafeExpType, str]] | None = None, + raw_lambda: bool = False, + ): if is_lv_font(value): return literal(f"&lv_font_{value}") if isinstance(value, str): return literal(f"{value}") - return await super().process(value, args) + return await super().process(value, args, raw_lambda) lv_font = LvFont() diff --git a/esphome/components/lvgl/types.py b/esphome/components/lvgl/types.py index 509d5cc782..61efe385e6 100644 --- a/esphome/components/lvgl/types.py +++ b/esphome/components/lvgl/types.py @@ -67,6 +67,7 @@ lv_obj_t = LvType("lv_obj_t") lv_page_t = LvType("LvPageType", parents=(LvCompound,)) lv_image_t = LvType("lv_image_t") lv_gradient_t = LvType("lv_grad_dsc_t") +LvAnimation = lvgl_ns.class_("LvAnimation", cg.Component) lv_event_t = LvType("lv_event_t") RotationType = lvgl_ns.enum("RotationType") lv_point_t = cg.global_ns.struct("lv_point_t") diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 639508a7b2..bdb0f27f45 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -89,6 +89,7 @@ #define USE_LOGGER_LEVEL_LISTENERS #define USE_LOGGER_RUNTIME_TAG_LEVELS #define USE_LVGL +#define USE_LVGL_ANIMATION #define USE_LVGL_ANIMIMG #define USE_LVGL_ARC #define USE_LVGL_BINARY_SENSOR diff --git a/tests/component_tests/lvgl/test_animation.py b/tests/component_tests/lvgl/test_animation.py new file mode 100644 index 0000000000..1a2cde632c --- /dev/null +++ b/tests/component_tests/lvgl/test_animation.py @@ -0,0 +1,201 @@ +"""Tests for the LVGL animation schema and configuration validation.""" + +from __future__ import annotations + +import pytest +from voluptuous import Invalid, MultipleInvalid + +from esphome.components.lvgl.animation import ( + ANIMABLE_STYLES, + ANIMATION_SCHEMA, + TIMING_SCHEMA, + from_to, + literal_color, +) +from esphome.components.lvgl.defines import LValidator +from esphome.core import Lambda + + +def _animation(**overrides) -> dict: + """A minimal valid animation config, with optional overrides applied.""" + config = { + "id": "anim_id", + "widgets": [{"id": "widget_id", "x": {"from": 0, "to": 100}}], + } + config.update(overrides) + return config + + +# --------------------------------------------------------------------------- +# Animatable property set +# --------------------------------------------------------------------------- + + +class TestAnimableStyles: + def test_all_entries_are_animatable_validators(self) -> None: + """Every animatable style must be an LValidator marked animatable.""" + assert ANIMABLE_STYLES + assert all( + isinstance(v, LValidator) and v.animatable for v in ANIMABLE_STYLES.values() + ) + + def test_known_animatable_present(self) -> None: + for prop in ("x", "y", "opa", "bg_color", "transform_rotation"): + assert prop in ANIMABLE_STYLES + + def test_non_animatable_absent(self) -> None: + # width/height set size but are not animatable; layout/padding never are. + for prop in ("width", "height", "radius", "pad_all", "align"): + assert prop not in ANIMABLE_STYLES + + +# --------------------------------------------------------------------------- +# Animation schema +# --------------------------------------------------------------------------- + + +class TestAnimationSchema: + def test_defaults(self) -> None: + config = ANIMATION_SCHEMA(_animation()) + assert config["duration"].total_milliseconds == 5000 + assert config["start_delay"].total_milliseconds == 0 + assert config["auto_start"] is False + assert config["loop"] is False + assert config["timing"] == [] + + def test_values_preserved(self) -> None: + config = ANIMATION_SCHEMA( + _animation(duration="2s", start_delay="250ms", auto_start=True, loop=True) + ) + assert config["duration"].total_milliseconds == 2000 + assert config["start_delay"].total_milliseconds == 250 + assert config["auto_start"] is True + assert config["loop"] is True + + def test_id_required(self) -> None: + with pytest.raises((Invalid, MultipleInvalid)): + ANIMATION_SCHEMA({"widgets": [{"id": "widget_id"}]}) + + def test_widgets_required(self) -> None: + with pytest.raises((Invalid, MultipleInvalid)): + ANIMATION_SCHEMA({"id": "anim_id"}) + + def test_multiple_properties_and_widgets(self) -> None: + config = ANIMATION_SCHEMA( + _animation( + widgets=[ + { + "id": "w1", + "x": {"from": 0, "to": 100}, + "opa": {"from": "0%", "to": "100%"}, + }, + {"id": "w2", "y": {"from": 10, "to": 50}}, + ] + ) + ) + assert len(config["widgets"]) == 2 + + def test_unknown_property_rejected(self) -> None: + with pytest.raises((Invalid, MultipleInvalid)): + ANIMATION_SCHEMA( + _animation(widgets=[{"id": "w1", "not_a_style": {"from": 0, "to": 1}}]) + ) + + +class TestAnimatedColorLiteral: + """A color animated via from/to must be a literal, not a lambda.""" + + def test_color_lambda_rejected_directly(self) -> None: + with pytest.raises(Invalid, match="lambda"): + literal_color(Lambda("return lv_color_hex(0xFF0000);")) + + def test_color_literal_accepted_directly(self) -> None: + # A literal color value validates without error. + literal_color(0xFF0000) + + def test_color_lambda_rejected_in_animation(self) -> None: + with pytest.raises((Invalid, MultipleInvalid), match="lambda"): + ANIMATION_SCHEMA( + _animation( + widgets=[ + { + "id": "w1", + "text_color": { + "from": Lambda("return lv_color_hex(0xFF0000);"), + "to": 0x00FF00, + }, + } + ] + ) + ) + + def test_color_literals_accepted_in_animation(self) -> None: + config = ANIMATION_SCHEMA( + _animation( + widgets=[{"id": "w1", "text_color": {"from": 0xFF0000, "to": 0x00FF00}}] + ) + ) + assert config["widgets"][0]["id"].id == "w1" + + def test_non_color_property_allows_lambda(self) -> None: + # Only colors are restricted; numeric properties may use lambdas. + config = ANIMATION_SCHEMA( + _animation( + widgets=[{"id": "w1", "x": {"from": Lambda("return 5;"), "to": 100}}] + ) + ) + assert config["widgets"][0]["id"].id == "w1" + + +class TestFromTo: + def test_requires_both(self) -> None: + validator = from_to(lambda value: value) + with pytest.raises((Invalid, MultipleInvalid)): + validator({"from": 1}) + with pytest.raises((Invalid, MultipleInvalid)): + validator({"to": 1}) + + def test_accepts_both(self) -> None: + validator = from_to(lambda value: value) + assert validator({"from": 1, "to": 2}) == {"from": 1, "to": 2} + + +# --------------------------------------------------------------------------- +# Timing schema +# --------------------------------------------------------------------------- + + +class TestTimingSchema: + def test_round_trip_string(self) -> None: + assert TIMING_SCHEMA("round_trip")["type"] == "round_trip" + + def test_ease_in_out_default_weight(self) -> None: + result = TIMING_SCHEMA("ease_in_out") + assert result["type"] == "ease_in_out" + assert result["weight"] == pytest.approx(2.0) + + def test_ease_in_out_custom_weight(self) -> None: + result = TIMING_SCHEMA({"type": "ease_in_out", "weight": 3}) + assert result["weight"] == pytest.approx(3.0) + + def test_gravity_defaults(self) -> None: + result = TIMING_SCHEMA("gravity") + assert result["type"] == "gravity" + assert result["bounce"] == pytest.approx(0.5) + assert result["acceleration"] == pytest.approx(0.5) + + def test_gravity_custom(self) -> None: + result = TIMING_SCHEMA({"type": "gravity", "bounce": 0.3, "acceleration": 0.8}) + assert result["bounce"] == pytest.approx(0.3) + assert result["acceleration"] == pytest.approx(0.8) + + def test_unknown_type_rejected(self) -> None: + with pytest.raises((Invalid, MultipleInvalid)): + TIMING_SCHEMA({"type": "not_a_timing"}) + + def test_timing_list_in_animation(self) -> None: + config = ANIMATION_SCHEMA( + _animation(timing=["round_trip", {"type": "gravity", "bounce": 0.3}]) + ) + types = [t["type"] for t in config["timing"]] + assert types == ["round_trip", "gravity"] diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 4ec4eb3bd6..4b18b99848 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -53,6 +53,12 @@ lvgl: id: meter_arc_indicator start_value: 0 end_value: 180 + - lvgl.animation.start: + id: + - anim_slide + - anim_color + duration: 3s + loop: true on_invalidate_area: logger.log: Invalidate area on_resolution_change: @@ -97,6 +103,52 @@ lvgl: - obj: bg_color: 0x000000 bg_opa: cover + top_layer: + widgets: + - obj: + id: anim_box + x: 0 + y: 0 + width: 50 + height: 50 + bg_color: 0xFF0000 + - label: + id: anim_label + text: anim + animations: + - id: anim_slide + duration: 1s + start_delay: 100ms + auto_start: true + loop: true + timing: ease_in_out + on_start: + - logger.log: anim started + on_stop: + - logger.log: anim stopped + widgets: + - id: anim_box + x: + from: 0 + to: 100 + y: + from: 0 + to: !lambda "return 80;" + opa: + from: 50% + to: 100% + - id: anim_color + duration: 2s + timing: + - round_trip + - type: gravity + bounce: 0.3 + acceleration: 0.8 + widgets: + - id: anim_label + text_color: + from: 0xFF0000 + to: color_id theme: dark_mode: true obj: @@ -199,6 +251,11 @@ lvgl: on_click: then: - lvgl.display.set_rotation: 0 + - lvgl.animation.stop: anim_slide + - lvgl.animation.stop: + id: + - anim_slide + - anim_color - lvgl.widget.hide: message_box - lvgl.style.update: id: style_test diff --git a/tests/components/lvgl/test.host.yaml b/tests/components/lvgl/test.host.yaml index 6328648fe3..90cbb3c0a5 100644 --- a/tests/components/lvgl/test.host.yaml +++ b/tests/components/lvgl/test.host.yaml @@ -22,6 +22,36 @@ lvgl: displays: sdl0 rotation: 180 top_layer: + widgets: + - obj: + id: anim_box + x: 0 + y: 0 + width: 40 + height: 40 + bg_color: 0xFF0000 + animations: + - id: anim_slide + duration: 1s + start_delay: 100ms + auto_start: true + loop: true + timing: + - round_trip + - type: ease_in_out + weight: 3 + on_start: + - logger.log: anim started + on_stop: + - logger.log: anim stopped + widgets: + - id: anim_box + x: + from: 0 + to: !lambda "return 100;" + opa: + from: 50% + to: 100% - id: lvgl_1 displays: sdl1 @@ -42,7 +72,14 @@ lvgl: - label: text: Click ME on_click: - logger.log: Clicked + then: + - logger.log: Clicked + - lvgl.animation.stop: + id: anim_slide + lvgl_id: lvgl_0 + - lvgl.animation.start: + id: anim_slide + lvgl_id: lvgl_0 font: - file: "gfonts://Roboto" From b787281388ff9edce49ef4c15ea396dd79cfe62a Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 9 Jul 2026 03:00:42 +1000 Subject: [PATCH 0783/1815] [lvgl] Add direct use of `mapping` (#15863) --- esphome/components/lvgl/defines.py | 2 + esphome/components/lvgl/lv_validation.py | 70 +++++++++++++++++++++--- esphome/components/lvgl/schemas.py | 19 +++++++ esphome/components/lvgl/widgets/img.py | 17 +++++- tests/components/lvgl/common.yaml | 4 +- tests/components/lvgl/lvgl-package.yaml | 31 ++++++++++- 6 files changed, 128 insertions(+), 15 deletions(-) diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 5c75269c64..480ba515d1 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -739,6 +739,7 @@ CONF_GRID_ROWS = "grid_rows" CONF_HEADER_BUTTONS = "header_buttons" CONF_HEADER_MODE = "header_mode" CONF_HOME = "home" +CONF_IMAGE = "image" CONF_INDICATORS = "indicators" CONF_INITIAL_FOCUS = "initial_focus" CONF_SELECTED_DIGIT = "selected_digit" @@ -752,6 +753,7 @@ CONF_LONG_PRESS_REPEAT_TIME = "long_press_repeat_time" CONF_LVGL_ID = "lvgl_id" CONF_LONG_MODE = "long_mode" CONF_MAJOR_TICKS_STYLE = "major_ticks_style" +CONF_MAPPING = "mapping" CONF_MSGBOXES = "msgboxes" CONF_OBJ = "obj" CONF_ONE_CHECKED = "one_checked" diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index d31c8324db..56ee3b47af 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -22,9 +22,12 @@ from esphome.helpers import cpp_string_escape from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import Expression, SafeExpType +from ..mapping import INDEX_TYPES, get_mapping_metadata from . import types as ty from .defines import ( CONF_END_VALUE, + CONF_IMAGE, + CONF_MAPPING, CONF_START_VALUE, CONF_TIME_FORMAT, LV_FONTS, @@ -375,21 +378,54 @@ def stop_value(value): return cv.int_range(0, 255)(value) -def image_validator(value): - value = cv.requires_component("image")(value) +def _image_validator(value): + if isinstance(value, dict) and CONF_MAPPING in value: + from .schemas import MAPPING_IMAGE_SCHEMA + + return MAPPING_IMAGE_SCHEMA(value) value = cv.use_id(Image_)(value) get_lv_images_used().add(value) add_lv_use("label") return value -lv_image = LValidator( - image_validator, - image.Image_.operator("ptr"), - requires="image", -) +class ImageValidator(LValidator): + def __init__(self): + super().__init__( + validator=_image_validator, + rtype=image.Image_.operator("ptr"), + requires=CONF_IMAGE, + ) + + async def process( + self, value: Any, args: list[tuple[SafeExpType, str]] | None = None + ) -> Expression: + # Local import to avoid circular import at module level + from .lvcode import get_lambda_context_args + + args = args or get_lambda_context_args() + if isinstance(value, dict) and CONF_MAPPING in value: + mapping_id = value[CONF_MAPPING] + mapping_var = await cg.get_variable(mapping_id) + metadata = get_mapping_metadata(mapping_id.id) + index = value[CONF_VALUE] + if isinstance(index, Lambda): + index = call_lambda( + await cg.process_lambda( + index, args, return_type=metadata.from_.data_type + ) + ) + else: + index = await metadata.from_.convert_value(index) + return mapping_var.get(index) + + return await super().process(value, args) + + +lv_image = ImageValidator() + lv_image_list = LValidator( - cv.ensure_list(image_validator), + cv.ensure_list(_image_validator), cg.std_vector.template(image.Image_.operator("ptr")), requires="image", ) @@ -440,6 +476,24 @@ class TextValidator(LValidator): f"(std::isfinite({arg_expr}) ? {sprintf_str} : {nanval})" ) return literal(sprintf_str) + if mapping_id := value.get(CONF_MAPPING): + mapping_var = await cg.get_variable(mapping_id) + metadata = get_mapping_metadata(mapping_id.id) + if metadata.to_ != INDEX_TYPES["string"]: + raise ValueError( + f"Mapping {mapping_id} does not map to strings, cannot use in text" + ) + index = value[CONF_VALUE] + if isinstance(index, Lambda): + index = call_lambda( + await cg.process_lambda( + index, args, return_type=metadata.from_.data_type + ) + ) + else: + index = await metadata.from_.convert_value(index) + return mapping_var.get(index).c_str() + if time_format := value.get(CONF_TIME_FORMAT): source = value[CONF_TIME] if isinstance(source, Lambda): diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index d7df628907..13214d459d 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -3,6 +3,7 @@ from typing import Any from esphome import config_validation as cv from esphome.automation import Trigger, validate_automation +from esphome.components.mapping import mapping_class from esphome.components.time import RealTimeClock from esphome.config_validation import prepend_path from esphome.const import ( @@ -17,6 +18,7 @@ from esphome.const import ( CONF_TEXT, CONF_TIME, CONF_TRIGGER_ID, + CONF_VALUE, CONF_X, CONF_Y, ) @@ -31,6 +33,7 @@ from esphome.schema_extractors import ( from . import defines as df, lv_validation as lvalid from .defines import ( CONF_EXT_CLICK_AREA, + CONF_MAPPING, CONF_SCROLL_DIR, CONF_SCROLL_SNAP_X, CONF_SCROLL_SNAP_Y, @@ -89,6 +92,20 @@ PRINTF_TEXT_SCHEMA = cv.All( validate_printf, ) +MAPPING_TEXT_SCHEMA = cv.Schema( + { + cv.Required(CONF_MAPPING): cv.use_id(mapping_class), + cv.Required(CONF_VALUE): cv.templatable(cv.string), + } +) + +MAPPING_IMAGE_SCHEMA = cv.Schema( + { + cv.Required(CONF_MAPPING): cv.use_id(mapping_class), + cv.Required(CONF_VALUE): cv.templatable(cv.string), + } +) + def _validate_text(value): """ @@ -100,6 +117,8 @@ def _validate_text(value): if isinstance(value, dict): if CONF_TIME_FORMAT in value: return TIME_TEXT_SCHEMA(value) + if CONF_MAPPING in value: + return MAPPING_TEXT_SCHEMA(value) return PRINTF_TEXT_SCHEMA(value) return cv.templatable(cv.string)(value) diff --git a/esphome/components/lvgl/widgets/img.py b/esphome/components/lvgl/widgets/img.py index 8a046fea33..da81ab7737 100644 --- a/esphome/components/lvgl/widgets/img.py +++ b/esphome/components/lvgl/widgets/img.py @@ -1,3 +1,5 @@ +from esphome.components.image import INSTANCE_TYPE as IMAGE_TYPE +from esphome.components.mapping import get_mapping_metadata import esphome.config_validation as cv from esphome.const import ( CONF_ANGLE, @@ -9,7 +11,9 @@ from esphome.const import ( from ..defines import ( CONF_ANTIALIAS, + CONF_IMAGE, CONF_MAIN, + CONF_MAPPING, CONF_PIVOT_X, CONF_PIVOT_Y, CONF_SCALE, @@ -21,8 +25,6 @@ from ..types import lv_image_t from . import Widget, WidgetType from .label import CONF_LABEL -CONF_IMAGE = "image" - BASE_IMG_SCHEMA = cv.Schema( { cv.Optional(CONF_PIVOT_X): size, @@ -69,5 +71,16 @@ class ImgType(WidgetType): for prop, validator in BASE_IMG_SCHEMA.schema.items(): await w.set_property(prop, config, processor=validator) + def final_validate(self, widget, update_config, widget_config, path): + src = update_config.get(CONF_SRC) + if isinstance(src, dict) and CONF_MAPPING in src: + mapping_id = src[CONF_MAPPING] + metadata = get_mapping_metadata(mapping_id.id) + if str(metadata.to_.data_type) != str(IMAGE_TYPE): + raise cv.Invalid( + f"Mapping '{mapping_id}' does not map to an image type, but '{metadata.to_.data_type}'", + path=path + [CONF_SRC, CONF_MAPPING], + ) + img_spec = ImgType() diff --git a/tests/components/lvgl/common.yaml b/tests/components/lvgl/common.yaml index f500002f40..b4d5fe0387 100644 --- a/tests/components/lvgl/common.yaml +++ b/tests/components/lvgl/common.yaml @@ -91,8 +91,8 @@ binary_sensor: animation: move_right time: 600ms - platform: lvgl - id: button_checker - name: LVGL button + id: common_button_checker + name: Common button widget: spin_up on_state: then: diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 4b18b99848..d6cd3821f9 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -263,6 +263,9 @@ lvgl: bg_opa: !lambda return 0.5; - lvgl.image.update: id: lv_image + src: + mapping: image_map + value: !lambda return round(1.0); scale: !lambda return 512; rotation: !lambda return 100; pivot_x: !lambda return 20; @@ -388,9 +391,16 @@ lvgl: text_font: montserrat_40 border_post: true on_press: - lvgl.label.update: - id: hello_label - text: Goodbye + - lvgl.label.update: + id: hello_label + text: + mapping: lvgl_string_map + value: !lambda return 2; + - lvgl.label.update: + id: hello_label + text: + mapping: lvgl_string_map + value: 2 on_click: then: - lvgl.animimg.stop: anim_img @@ -1496,6 +1506,21 @@ image: invert_alpha: true transparency: alpha_channel +mapping: + - id: image_map + from: int + to: image + entries: + 0: cat_image + 1: dog_image + - id: lvgl_string_map + from: int + to: string + entries: + 0: "First" + 1: "Second" + 2: "Third" + color: - id: light_blue hex: "3340FF" From e7933a5387fea9a67bd5a99cd7687e5f10f556f9 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:06:38 -0400 Subject: [PATCH 0784/1815] Bump bundled esphome-device-builder to 1.3.1 (#17450) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 3a7d5e8bbe..db2e01742c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.3.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.3.1 RUN \ platformio settings set enable_telemetry No \ From ce468952708d24ea2094758ac9640f1be499922d Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 9 Jul 2026 03:30:49 +1000 Subject: [PATCH 0785/1815] [uart][usb_uart] Implement runtime settings update (#16990) Co-authored-by: Claude Opus 4.8 Co-authored-by: Keith Burzinski --- esphome/components/uart/uart_component.h | 4 +- .../components/uart/uart_component_esp8266.h | 2 +- .../components/uart/uart_component_esp_idf.h | 2 +- esphome/components/usb_cdc_acm/usb_cdc_acm.h | 6 + esphome/components/usb_uart/ch34x.cpp | 184 +++++++------- esphome/components/usb_uart/cp210x.cpp | 46 ++-- esphome/components/usb_uart/ft23xx.cpp | 236 ++++++------------ esphome/components/usb_uart/pl2303.cpp | 184 +++++++------- esphome/components/usb_uart/usb_uart.cpp | 208 +++++++++++---- esphome/components/usb_uart/usb_uart.h | 66 +++-- esphome/components/weikai/weikai.h | 9 + tests/components/mitsubishi_cn105/common.h | 3 + tests/components/uart/common.h | 3 + 13 files changed, 534 insertions(+), 419 deletions(-) diff --git a/esphome/components/uart/uart_component.h b/esphome/components/uart/uart_component.h index afd3ad5777..3e52531791 100644 --- a/esphome/components/uart/uart_component.h +++ b/esphome/components/uart/uart_component.h @@ -178,7 +178,7 @@ class UARTComponent { * * This will load the current UART interface with the latest settings (baud_rate, parity, etc). */ - virtual void load_settings(bool dump_config){}; + virtual void load_settings(bool dump_config) = 0; /** * Load the UART settings. @@ -190,7 +190,7 @@ class UARTComponent { * * This will load the current UART interface with the latest settings (baud_rate, parity, etc). */ - virtual void load_settings(){}; + void load_settings() { this->load_settings(true); } #endif // USE_ESP8266 || USE_ESP32 #ifdef USE_UART_DEBUGGER diff --git a/esphome/components/uart/uart_component_esp8266.h b/esphome/components/uart/uart_component_esp8266.h index ee3be3cd3a..469885b6b6 100644 --- a/esphome/components/uart/uart_component_esp8266.h +++ b/esphome/components/uart/uart_component_esp8266.h @@ -75,7 +75,7 @@ class ESP8266UartComponent final : public UARTComponent, public Component { * This will load the current UART interface with the latest settings (baud_rate, parity, etc). */ void load_settings(bool dump_config) override; - void load_settings() override { this->load_settings(true); } + using UARTComponent::load_settings; // also bring in the no-arg overload for convenience protected: void check_logger_conflict() override; diff --git a/esphome/components/uart/uart_component_esp_idf.h b/esphome/components/uart/uart_component_esp_idf.h index 3b86368797..649dd3aa46 100644 --- a/esphome/components/uart/uart_component_esp_idf.h +++ b/esphome/components/uart/uart_component_esp_idf.h @@ -50,7 +50,7 @@ class IDFUARTComponent final : public UARTComponent, public Component { * This will load the current UART interface with the latest settings (baud_rate, parity, etc). */ void load_settings(bool dump_config) override; - void load_settings() override { this->load_settings(true); } + using UARTComponent::load_settings; // also bring in the no-arg overload for convenience protected: void check_logger_conflict() override; diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.h b/esphome/components/usb_cdc_acm/usb_cdc_acm.h index 2251c600e7..8e71fc61b2 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.h +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.h @@ -84,6 +84,12 @@ class USBCDCACMInstance final : public uart::UARTComponent, public Parenteddefer([this, error_code = status.error_code]() { - ESP_LOGE(TAG, "CH34x chip detection failed: %s", esp_err_to_name(error_code)); - this->apply_line_settings_(); - }); - return; - } - CH34xChipType chiptype = CHIP_UNKNOWN; - uint8_t num_ports = 1; - for (const auto &e : CH34X_TABLE) { - if (e.pid != this->pid_) - continue; - if (e.match != 0xFF && (status.data[e.byte_idx] & e.mask) != e.match) - continue; - chiptype = e.chiptype; - num_ports = e.num_ports; +bool USBUartTypeCH34X::config_device_step(uint8_t step, bool ok, const uint8_t *response) { + if (step == 0) { + // Vendor-specific GET_CHIP_VERSION request (bRequest=0x5F): returns chip ID bytes + // used to distinguish CH34x variants sharing the same PID. + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_IN, 0x5F, 0, 0, {0, 0, 0, 0, 0, 0, 0, 0}); + return true; + } + // step 1: parse the chip-version response (falling back to "unknown" on failure). + if (!ok) { + ESP_LOGE(TAG, "CH34x chip detection failed"); + return false; + } + CH34xChipType chiptype = CHIP_UNKNOWN; + uint8_t num_ports = 1; + for (const auto &e : CH34X_TABLE) { + if (e.pid != this->pid_) + continue; + if (e.match != 0xFF && (response[e.byte_idx] & e.mask) != e.match) + continue; + chiptype = e.chiptype; + num_ports = e.num_ports; + break; + } + // CH344L vs CH344L_V2 requires chipver (data[0]) in addition to chiptype (data[1]) + if (chiptype == CHIP_CH344L && (response[0] & 0xF0) != 0x40) + chiptype = CHIP_CH344L_V2; + const char *name = "unknown"; + for (const auto &e : CH34X_TABLE) { + if (e.chiptype == chiptype) { + name = e.name; break; } - // CH344L vs CH344L_V2 requires chipver (data[0]) in addition to chiptype (data[1]) - if (chiptype == CHIP_CH344L && (status.data[0] & 0xF0) != 0x40) - chiptype = CHIP_CH344L_V2; - const char *name = "unknown"; - for (const auto &e : CH34X_TABLE) { - if (e.chiptype == chiptype) { - name = e.name; - break; - } - } - this->defer([this, chiptype, num_ports, name]() { - this->chiptype_ = chiptype; - this->chip_name_ = name; - this->num_ports_ = num_ports; - ESP_LOGD(TAG, "CH34x chip: %s, ports: %u", name, this->num_ports_); - this->apply_line_settings_(); - }); - }; - // Vendor-specific GET_CHIP_VERSION request (bRequest=0x5F): returns chip ID bytes - // used to distinguish CH34x variants sharing the same PID. - this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_IN, 0x5F, 0, 0, cb, {0, 0, 0, 0, 0, 0, 0, 0}); + } + this->chiptype_ = chiptype; + this->chip_name_ = name; + this->num_ports_ = num_ports; + ESP_LOGD(TAG, "CH34x chip: %s, ports: %u", name, this->num_ports_); + return false; } void USBUartTypeCH34X::dump_config() { @@ -98,67 +95,64 @@ void USBUartTypeCH34X::dump_config() { ESP_LOGCONFIG(TAG, " CH34x chip: %s", this->chip_name_); } -void USBUartTypeCH34X::apply_line_settings_() { - for (auto *channel : this->channels_) { - if (!channel->initialised_.load()) - continue; - usb_host::transfer_cb_t callback = [=](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGE(TAG, "Control transfer failed, status=%s", esp_err_to_name(status.error_code)); - channel->initialised_.store(false); +bool USBUartTypeCH34X::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, + const uint8_t *response) { + uint8_t cmd = 0xA1 + channel->index_; + if (channel->index_ >= 2) + cmd += 0xE; + switch (step) { + case 0: { + uint8_t divisor = 7; + uint32_t clk = 12000000; + + auto baud_rate = channel->baud_rate_; + if (baud_rate < 256000) { + if (baud_rate > 6000000 / 255) { + divisor = 3; + clk = 6000000; + } else if (baud_rate > 750000 / 255) { + divisor = 2; + clk = 750000; + } else if (baud_rate > 93750 / 255) { + divisor = 1; + clk = 93750; + } else { + divisor = 0; + clk = 11719; + } } - }; - - uint8_t divisor = 7; - uint32_t clk = 12000000; - - auto baud_rate = channel->baud_rate_; - if (baud_rate < 256000) { - if (baud_rate > 6000000 / 255) { - divisor = 3; - clk = 6000000; - } else if (baud_rate > 750000 / 255) { - divisor = 2; - clk = 750000; - } else if (baud_rate > 93750 / 255) { - divisor = 1; - clk = 93750; - } else { - divisor = 0; - clk = 11719; + ESP_LOGV(TAG, "baud_rate: %" PRIu32 ", divisor: %d, clk: %" PRIu32, baud_rate, divisor, clk); + auto factor = static_cast(clk / baud_rate); + if (factor == 0 || factor == 0xFF) { + ESP_LOGE(TAG, "Invalid baud rate %" PRIu32, baud_rate); + return false; } - } - ESP_LOGV(TAG, "baud_rate: %" PRIu32 ", divisor: %d, clk: %" PRIu32, baud_rate, divisor, clk); - auto factor = static_cast(clk / baud_rate); - if (factor == 0 || factor == 0xFF) { - ESP_LOGE(TAG, "Invalid baud rate %" PRIu32, baud_rate); - channel->initialised_.store(false); - continue; - } - if ((clk / factor - baud_rate) > (baud_rate - clk / (factor + 1))) - factor++; - factor = 256 - factor; + if ((clk / factor - baud_rate) > (baud_rate - clk / (factor + 1))) + factor++; + factor = 256 - factor; - uint16_t value = 0xC0; - if (channel->stop_bits_ == UART_CONFIG_STOP_BITS_2) - value |= 4; - switch (channel->parity_) { - case UART_CONFIG_PARITY_NONE: - break; - default: - value |= 8 | ((channel->parity_ - 1) << 4); - break; + uint16_t value = 0xC0; + if (channel->stop_bits_ == UART_CONFIG_STOP_BITS_2) + value |= 4; + switch (channel->parity_) { + case UART_CONFIG_PARITY_NONE: + break; + default: + value |= 8 | ((channel->parity_ - 1) << 4); + break; + } + value |= channel->data_bits_ - 5; + value <<= 8; + value |= 0x8C; + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd, value, (factor << 8) | divisor); + return true; } - value |= channel->data_bits_ - 5; - value <<= 8; - value |= 0x8C; - uint8_t cmd = 0xA1 + channel->index_; - if (channel->index_ >= 2) - cmd += 0xE; - this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd, value, (factor << 8) | divisor, callback); - this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd + 3, 0x80, 0, callback); + case 1: + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd + 3, 0x80, 0); + return true; + default: + return false; } - this->start_channels_(); } std::vector USBUartTypeCH34X::parse_descriptors(usb_device_handle_t dev_hdl) { diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index c4edaed038..2722ec8555 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -97,29 +97,31 @@ std::vector USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev return cdc_devs; } -void USBUartTypeCP210X::enable_channels() { - // enable the channels - for (auto *channel : this->channels_) { - if (!channel->initialised_.load()) - continue; - usb_host::transfer_cb_t callback = [=](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGE(TAG, "Control transfer failed, status=%s", esp_err_to_name(status.error_code)); - channel->initialised_.store(false); - } - }; - this->control_transfer(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, IFC_ENABLE, 1, channel->index_, callback); - uint16_t line_control = channel->stop_bits_; - line_control |= static_cast(channel->parity_) << 4; - line_control |= channel->data_bits_ << 8; - ESP_LOGD(TAG, "Line control value 0x%X", line_control); - this->control_transfer(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_LINE_CTL, line_control, channel->index_, - callback); - auto baud = ByteBuffer::wrap(channel->baud_rate_, LITTLE); - this->control_transfer(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_BAUDRATE, 0, channel->index_, callback, - baud.get_data()); +bool USBUartTypeCP210X::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, + const uint8_t *response) { + // On reload, skip the one-time IFC_ENABLE step (the interface is already enabled). + if (reload) + step++; + switch (step) { + case 0: + this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, IFC_ENABLE, 1, channel->index_); + return true; + case 1: { + uint16_t line_control = channel->stop_bits_; + line_control |= static_cast(channel->parity_) << 4; + line_control |= channel->data_bits_ << 8; + ESP_LOGD(TAG, "Line control value 0x%X", line_control); + this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_LINE_CTL, line_control, channel->index_); + return true; + } + case 2: { + auto baud = ByteBuffer::wrap(channel->baud_rate_, LITTLE); + this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_BAUDRATE, 0, channel->index_, baud.get_data()); + return true; + } + default: + return false; } - this->start_channels_(); } } // namespace esphome::usb_uart diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index 25e4cc524f..79aa107d72 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -112,40 +112,46 @@ static int ftdi_to_clkbits(int baudrate, unsigned int clk, int clk_div, uint32_t return best_baud; } -static int ftdi_convert_baudrate(int baudrate, uint8_t chip_type, uint8_t channel_index, uint16_t *value, - uint16_t *index) { +struct FtdiConfig { + uint16_t value; + uint16_t ftdi_index; int best_baud; +}; + +static FtdiConfig ftdi_convert_baudrate(int baudrate, uint8_t chip_type, uint8_t channel_index) { uint32_t encoded_divisor; + FtdiConfig config{}; + if (baudrate <= 0) { - return -1; + return config; } static constexpr uint32_t H_CLK = 120000000; static constexpr uint32_t C_CLK = 48000000; if ((chip_type == TYPE_2232H) || (chip_type == TYPE_4232H) || (chip_type == TYPE_232H)) { if (baudrate * 10 > H_CLK / 0x3fff) { - best_baud = ftdi_to_clkbits(baudrate, H_CLK, 10, &encoded_divisor); + config.best_baud = ftdi_to_clkbits(baudrate, H_CLK, 10, &encoded_divisor); encoded_divisor |= 0x20000; /* switch on CLK/10*/ } else { - best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor); + config.best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor); } } else if ((chip_type == TYPE_BM) || (chip_type == TYPE_2232C) || (chip_type == TYPE_R) || (chip_type == TYPE_230X)) { - best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor); + config.best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor); } else { - best_baud = ftdi_to_clkbits_am(baudrate, &encoded_divisor); + config.best_baud = ftdi_to_clkbits_am(baudrate, &encoded_divisor); } - *value = (uint16_t) (encoded_divisor & 0xFFFF); + config.value = (uint16_t) (encoded_divisor & 0xFFFF); if (chip_type == TYPE_2232H || chip_type == TYPE_4232H || chip_type == TYPE_232H) { - *index = (uint16_t) (encoded_divisor >> 8); - *index &= 0xFF00; - *index |= (channel_index + 1); + config.ftdi_index = (uint16_t) (encoded_divisor >> 8); + config.ftdi_index &= 0xFF00; + config.ftdi_index |= (channel_index + 1); } else { - *index = (uint16_t) (encoded_divisor >> 16); + config.ftdi_index = (uint16_t) (encoded_divisor >> 16); } - return best_baud; + return config; } static optional get_uart(const usb_config_desc_t *config_desc, uint8_t intf_idx) { @@ -264,138 +270,6 @@ std::vector USBUartTypeFT23XX::parse_descriptors(usb_device_handle_t dev return cdc_devs; } -int USBUartTypeFT23XX::reset_(USBUartChannel *channel) { - usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGE(TAG, "Reset failed, status=%s", esp_err_to_name(status.error_code)); - channel->initialised_.store(false); - } else { - ESP_LOGD(TAG, "Reset successful, setting baudrate..."); - this->set_baudrate_(channel); - } - }; - bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x00, 0x00, - channel->cdc_dev_.bulk_interface_number + 1, callback); - if (!ok) { - ESP_LOGE(TAG, "Reset control_transfer submit failed"); - channel->initialised_.store(false); - return -1; - } - return 0; -} - -int USBUartTypeFT23XX::set_baudrate_(USBUartChannel *channel, uint32_t baudrate) { - usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGE(TAG, "Set baudrate failed, status=%s", esp_err_to_name(status.error_code)); - channel->initialised_.store(false); - } else { - ESP_LOGD(TAG, "Baudrate %" PRIu32 " set, setting line properties...", channel->baud_rate_); - this->set_line_properties_(channel); - } - }; - if (baudrate == 0) { - baudrate = channel->baud_rate_; - } - uint16_t value = 0, ftdi_index = 0; - ftdi_convert_baudrate(baudrate, this->chip_type_, channel->index_, &value, &ftdi_index); - ESP_LOGD(TAG, "Baudrate: %" PRIu32 ", value=0x%04X, ftdi_index=0x%04X", baudrate, value, ftdi_index); - uint16_t usb_index = (ftdi_index & 0xFF00) | (channel->cdc_dev_.bulk_interface_number + 1); - bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x03, value, usb_index, callback); - if (!ok) { - ESP_LOGE(TAG, "Set baudrate control_transfer submit failed"); - channel->initialised_.store(false); - return -1; - } - return 0; -} - -int USBUartTypeFT23XX::set_line_properties_(USBUartChannel *channel) { - usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGE(TAG, "Set line properties failed, status=%s", esp_err_to_name(status.error_code)); - channel->initialised_.store(false); - return; - } - ESP_LOGD(TAG, "Line properties set, setting modem control..."); - this->set_dtr_rts_(channel); - }; - - uint16_t value = channel->data_bits_; - - switch (channel->parity_) { - case UART_CONFIG_PARITY_NONE: - value |= (0x00 << 8); - break; - case UART_CONFIG_PARITY_ODD: - value |= (0x01 << 8); - break; - case UART_CONFIG_PARITY_EVEN: - value |= (0x02 << 8); - break; - case UART_CONFIG_PARITY_MARK: - value |= (0x03 << 8); - break; - case UART_CONFIG_PARITY_SPACE: - value |= (0x04 << 8); - break; - } - - switch (channel->stop_bits_) { - case UART_CONFIG_STOP_BITS_1: - value |= (0x00 << 11); - break; - case UART_CONFIG_STOP_BITS_1_5: - value |= (0x01 << 11); - break; - case UART_CONFIG_STOP_BITS_2: - value |= (0x02 << 11); - break; - } - - value |= (0x00 << 14); - - bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x04, value, - channel->cdc_dev_.bulk_interface_number + 1, callback); - if (!ok) { - ESP_LOGE(TAG, "Set line properties control_transfer submit failed"); - channel->initialised_.store(false); - return -1; - } - return 0; -} - -int USBUartTypeFT23XX::set_dtr_rts_(USBUartChannel *channel) { - usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGE(TAG, "Set modem control failed, status=%s", esp_err_to_name(status.error_code)); - channel->initialised_.store(false); - return; - } - ESP_LOGD(TAG, "Modem control set for channel %d, starting input...", channel->index_); - channel->initialised_.store(true); - this->start_input(channel); - uint8_t next_index = channel->index_ + 1; - if (next_index < this->channels_.size()) { - USBUartChannel *next_channel = this->channels_[next_index]; - ESP_LOGD(TAG, "Configuring next channel %d", next_channel->index_); - this->reset_(next_channel); - return; - } else { - ESP_LOGI(TAG, "All channels configured"); - } - }; - - bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x01, 0x0000, - channel->cdc_dev_.bulk_interface_number + 1, callback); - if (!ok) { - ESP_LOGE(TAG, "Set modem control control_transfer submit failed"); - channel->initialised_.store(false); - return -1; - } - return 0; -} - void USBUartTypeFT23XX::start_input(USBUartChannel *channel) { if (!channel->initialised_.load()) return; @@ -467,16 +341,68 @@ void USBUartTypeFT23XX::on_rx_overflow(USBUartChannel *channel) { channel->input_buffer_.clear(); } -void USBUartTypeFT23XX::enable_channels() { - if (!this->channels_.empty() && this->channels_[0]->initialised_.load()) { - this->reset_(this->channels_[0]); - } - - for (auto *channel : this->channels_) { - if (!channel->initialised_.load()) - continue; - channel->input_started_.store(false); - channel->output_started_.store(false); +bool USBUartTypeFT23XX::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, + const uint8_t *response) { + // On reload (settings change on an open channel) skip the SIO reset; the FTDI set_termios + // path only re-applies baud + line properties and does not re-assert DTR/RTS. + if (reload) + step++; + switch (step) { + case 0: // SIO reset (init only) + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x00, 0x00, + channel->cdc_dev_.bulk_interface_number + 1); + return true; + case 1: { // set baudrate + auto config = ftdi_convert_baudrate(channel->baud_rate_, this->chip_type_, channel->index_); + uint16_t usb_index = (config.ftdi_index & 0xFF00) | (channel->cdc_dev_.bulk_interface_number + 1); + ESP_LOGD(TAG, "Baudrate: %u, value=0x%04X, ftdi_index=0x%04X", (unsigned) channel->baud_rate_, config.value, + config.ftdi_index); + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x03, config.value, usb_index); + return true; + } + case 2: { // set line properties (data bits / parity / stop bits) + uint16_t value = channel->data_bits_; + switch (channel->parity_) { + case UART_CONFIG_PARITY_NONE: + value |= (0x00 << 8); + break; + case UART_CONFIG_PARITY_ODD: + value |= (0x01 << 8); + break; + case UART_CONFIG_PARITY_EVEN: + value |= (0x02 << 8); + break; + case UART_CONFIG_PARITY_MARK: + value |= (0x03 << 8); + break; + case UART_CONFIG_PARITY_SPACE: + value |= (0x04 << 8); + break; + } + switch (channel->stop_bits_) { + default: // 1 bit + value |= (0x00 << 11); + break; + case UART_CONFIG_STOP_BITS_1_5: + value |= (0x01 << 11); + break; + case UART_CONFIG_STOP_BITS_2: + value |= (0x02 << 11); + break; + } + value |= (0x00 << 14); + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x04, value, + channel->cdc_dev_.bulk_interface_number + 1); + return true; + } + case 3: // set modem control DTR+RTS (init only) + if (reload) + return false; + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x01, 0x0000, + channel->cdc_dev_.bulk_interface_number + 1); + return true; + default: + return false; } } diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index 134c51198d..3c7ecd9a83 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -200,100 +200,114 @@ std::vector USBUartTypePL2303::parse_descriptors(usb_device_handle_t dev return cdc_devs; } -void USBUartTypePL2303::enable_channels() { - if (this->channels_.empty()) - return; +// Vendor init sequence for non-HXN chips (mirrors pl2303_startup in the Linux driver): +// read 0x8484, write 0x0404=0, read 0x8484, read 0x8383, read 0x8484, write 0x0404=1, +// read 0x8484, read 0x8383, write 0=1, write 1=0, write 2=0x24 (legacy) or 0x44 (HX+). +// The final entry's wIndex is patched at runtime depending on the chip type. +struct Pl2303InitStep { + uint8_t type; + uint8_t request; + uint16_t value; + uint16_t index; + bool read; // reads need a 1-byte buffer to set wLength=1 so the IN data stage runs +}; +static const Pl2303InitStep PL2303_INIT[] = { + {VENDOR_READ_REQUEST_TYPE, VENDOR_READ_REQUEST, 0x8484, 0, true}, + {VENDOR_WRITE_REQUEST_TYPE, VENDOR_WRITE_REQUEST, 0x0404, 0, false}, + {VENDOR_READ_REQUEST_TYPE, VENDOR_READ_REQUEST, 0x8484, 0, true}, + {VENDOR_READ_REQUEST_TYPE, VENDOR_READ_REQUEST, 0x8383, 0, true}, + {VENDOR_READ_REQUEST_TYPE, VENDOR_READ_REQUEST, 0x8484, 0, true}, + {VENDOR_WRITE_REQUEST_TYPE, VENDOR_WRITE_REQUEST, 0x0404, 1, false}, + {VENDOR_READ_REQUEST_TYPE, VENDOR_READ_REQUEST, 0x8484, 0, true}, + {VENDOR_READ_REQUEST_TYPE, VENDOR_READ_REQUEST, 0x8383, 0, true}, + {VENDOR_WRITE_REQUEST_TYPE, VENDOR_WRITE_REQUEST, 0, 1, false}, + {VENDOR_WRITE_REQUEST_TYPE, VENDOR_WRITE_REQUEST, 1, 0, false}, + {VENDOR_WRITE_REQUEST_TYPE, VENDOR_WRITE_REQUEST, 2, 0, false}, +}; +static constexpr uint8_t PL2303_INIT_COUNT = sizeof(PL2303_INIT) / sizeof(PL2303_INIT[0]); - auto *channel = this->channels_[0]; +bool USBUartTypePL2303::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, + const uint8_t *response) { bool is_legacy = (this->chip_type_ == PL2303_TYPE_H); bool is_hxn = (this->chip_type_ == PL2303_TYPE_HXN); - usb_host::transfer_cb_t nop_cb = [](const usb_host::TransferStatus &status) { - if (!status.success) - ESP_LOGW(TAG, "PL2303: vendor init transfer failed"); - }; - - // Init sequence for non-HXN chips (mirrors pl2303_startup in Linux driver): - // Read 0x8484, write 0x0404=0, read 0x8484, read 0x8383, read 0x8484, - // write 0x0404=1, read 0x8484, read 0x8383, - // write 0=1, write 1=0, write 2=0x24 (legacy) or 0x44 (HX+) - if (!is_hxn) { - uint8_t req = VENDOR_READ_REQUEST; - uint8_t wreq = VENDOR_WRITE_REQUEST; - - // Fire-and-forget vendor reads: result discarded, chip requires this sequence. - // Pass a 1-byte buffer to set wLength=1 so the IN data stage is performed. - this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0}); - this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 0x0404, 0, nop_cb); - this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0}); - this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8383, 0, nop_cb, {0}); - this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0}); - this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 0x0404, 1, nop_cb); - this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0}); - this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8383, 0, nop_cb, {0}); - this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 0, 1, nop_cb); - this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 1, 0, nop_cb); - this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 2, is_legacy ? 0x24 : 0x44, nop_cb); + // Vendor init burst runs only on full init for non-HXN chips. + uint8_t init_count = (!reload && !is_hxn) ? PL2303_INIT_COUNT : 0; + if (step < init_count) { + const auto &e = PL2303_INIT[step]; + uint16_t index = (step == PL2303_INIT_COUNT - 1) ? (is_legacy ? 0x24 : 0x44) : e.index; + this->config_transfer_(e.type, e.request, e.value, index, + e.read ? std::vector{0} : std::vector{}); + return true; } + step -= init_count; - // Build 7-byte line coding structure: - // [0-3] baud rate (LE32), [4] stop bits, [5] parity, [6] data bits - uint8_t line_coding[7] = {}; - uint32_t baud = channel->get_baud_rate(); - - // Choose baud encoding based on chip type - uint32_t nearest = nearest_supported_baud(baud); - if (baud == nearest || this->chip_type_ == PL2303_TYPE_HXN) { - encode_baud_direct(line_coding, baud); - } else if (this->chip_type_ == PL2303_TYPE_TA || this->chip_type_ == PL2303_TYPE_TB) { - encode_baud_divisor_alt(line_coding, baud); - } else { - encode_baud_divisor(line_coding, baud); - } - - // Stop bits: 0=1, 1=1.5, 2=2 - switch (channel->get_stop_bits()) { - case 2: - line_coding[4] = 2; - break; - default: - line_coding[4] = 0; - break; - } - - // Parity: 0=none, 1=odd, 2=even, 3=mark, 4=space - switch (channel->parity_) { - case UART_CONFIG_PARITY_ODD: - line_coding[5] = 1; - break; - case UART_CONFIG_PARITY_EVEN: - line_coding[5] = 2; - break; - case UART_CONFIG_PARITY_MARK: - line_coding[5] = 3; - break; - case UART_CONFIG_PARITY_SPACE: - line_coding[5] = 4; - break; - default: - line_coding[5] = 0; - break; - } - - // Data bits - line_coding[6] = channel->get_data_bits(); - - ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%" PRIu32 " stop=%u parity=%u data=%u", baud, line_coding[4], - line_coding[5], line_coding[6]); - - std::vector lc_vec(line_coding, line_coding + 7); uint16_t iface = channel->cdc_dev_.bulk_interface_number; - this->control_transfer(SET_LINE_REQUEST_TYPE, SET_LINE_REQUEST, 0, iface, nop_cb, lc_vec); + switch (step) { + case 0: { + // Build 7-byte line coding structure: + // [0-3] baud rate (LE32), [4] stop bits, [5] parity, [6] data bits + uint8_t line_coding[7] = {}; + uint32_t baud = channel->get_baud_rate(); - // Assert DTR + RTS - this->control_transfer(SET_CONTROL_REQUEST_TYPE, SET_CONTROL_REQUEST, CONTROL_DTR | CONTROL_RTS, iface, nop_cb); + // Choose baud encoding based on chip type + uint32_t nearest = nearest_supported_baud(baud); + if (baud == nearest || this->chip_type_ == PL2303_TYPE_HXN) { + encode_baud_direct(line_coding, baud); + } else if (this->chip_type_ == PL2303_TYPE_TA || this->chip_type_ == PL2303_TYPE_TB) { + encode_baud_divisor_alt(line_coding, baud); + } else { + encode_baud_divisor(line_coding, baud); + } - this->start_channels_(); + // Stop bits: 0=1, 1=1.5, 2=2 + switch (channel->get_stop_bits()) { + case 2: + line_coding[4] = 2; + break; + default: + line_coding[4] = 0; + break; + } + + // Parity: 0=none, 1=odd, 2=even, 3=mark, 4=space + switch (channel->parity_) { + case UART_CONFIG_PARITY_ODD: + line_coding[5] = 1; + break; + case UART_CONFIG_PARITY_EVEN: + line_coding[5] = 2; + break; + case UART_CONFIG_PARITY_MARK: + line_coding[5] = 3; + break; + case UART_CONFIG_PARITY_SPACE: + line_coding[5] = 4; + break; + default: + line_coding[5] = 0; + break; + } + + // Data bits + line_coding[6] = channel->get_data_bits(); + + ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%u stop=%u parity=%u data=%u", baud, line_coding[4], line_coding[5], + line_coding[6]); + + std::vector lc_vec(line_coding, line_coding + 7); + this->config_transfer_(SET_LINE_REQUEST_TYPE, SET_LINE_REQUEST, 0, iface, lc_vec); + return true; + } + case 1: + // Assert DTR + RTS (init only) + if (reload) + return false; + this->config_transfer_(SET_CONTROL_REQUEST_TYPE, SET_CONTROL_REQUEST, CONTROL_DTR | CONTROL_RTS, iface); + return true; + default: + return false; + } } } // namespace esphome::usb_uart diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index a995e93e15..482b209a3f 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -6,6 +6,7 @@ #include "esphome/core/application.h" #include +#include namespace esphome::usb_uart { @@ -213,6 +214,7 @@ bool USBUartChannel::read_array(uint8_t *data, size_t len) { void USBUartComponent::setup() { USBClient::setup(); } void USBUartComponent::loop() { bool had_work = this->process_usb_events_(); + had_work |= this->run_config_machine_(); // Process USB data from the lock-free queue UsbDataChunk *chunk; @@ -489,60 +491,182 @@ void USBUartTypeCdcAcm::on_disconnected() { USBClient::on_disconnected(); } -void USBUartTypeCdcAcm::enable_channels() { +bool USBUartTypeCdcAcm::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, + const uint8_t *response) { static constexpr uint8_t CDC_REQUEST_TYPE = usb_host::USB_TYPE_CLASS | usb_host::USB_RECIP_INTERFACE; static constexpr uint8_t CDC_SET_LINE_CODING = 0x20; static constexpr uint8_t CDC_SET_CONTROL_LINE_STATE = 0x22; static constexpr uint16_t CDC_DTR_RTS = 0x0003; // D0=DTR, D1=RTS - for (auto *channel : this->channels_) { - if (!channel->initialised_.load()) - continue; - // Configure the bridge's UART parameters. A USB-UART bridge will not forward data - // at the correct speed until SET_LINE_CODING is sent; without it the UART may run - // at an indeterminate default rate so the NCP receives garbled bytes and never - // sends RSTACK. - uint32_t baud = channel->baud_rate_; - std::vector line_coding = { - static_cast(baud & 0xFF), static_cast((baud >> 8) & 0xFF), - static_cast((baud >> 16) & 0xFF), static_cast((baud >> 24) & 0xFF), - static_cast(channel->stop_bits_), // bCharFormat: 0=1stop, 1=1.5stop, 2=2stop - static_cast(channel->parity_), // bParityType: 0=None, 1=Odd, 2=Even, 3=Mark, 4=Space - static_cast(channel->data_bits_), // bDataBits - }; - ESP_LOGD(TAG, "SET_LINE_CODING: baud=%u stop=%u parity=%u data=%u", (unsigned) baud, channel->stop_bits_, - (unsigned) channel->parity_, channel->data_bits_); - this->control_transfer( - CDC_REQUEST_TYPE, CDC_SET_LINE_CODING, 0, channel->cdc_dev_.interrupt_interface_number, - [](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGW(TAG, "SET_LINE_CODING failed: %X", status.error_code); - } else { - ESP_LOGD(TAG, "SET_LINE_CODING OK"); - } - }, - line_coding); - // Assert DTR+RTS to signal DTE is present. - this->control_transfer(CDC_REQUEST_TYPE, CDC_SET_CONTROL_LINE_STATE, CDC_DTR_RTS, - channel->cdc_dev_.interrupt_interface_number, [](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGW(TAG, "SET_CONTROL_LINE_STATE failed: %X", status.error_code); - } else { - ESP_LOGD(TAG, "SET_CONTROL_LINE_STATE (DTR+RTS) OK"); - } - }); + switch (step) { + case 0: { + // Configure the bridge's UART parameters. A USB-UART bridge will not forward data + // at the correct speed until SET_LINE_CODING is sent; without it the UART may run + // at an indeterminate default rate so the NCP receives garbled bytes and never + // sends RSTACK. + uint32_t baud = channel->baud_rate_; + std::vector line_coding = { + static_cast(baud & 0xFF), static_cast((baud >> 8) & 0xFF), + static_cast((baud >> 16) & 0xFF), static_cast((baud >> 24) & 0xFF), + static_cast(channel->stop_bits_), // bCharFormat: 0=1stop, 1=1.5stop, 2=2stop + static_cast(channel->parity_), // bParityType: 0=None, 1=Odd, 2=Even, 3=Mark, 4=Space + static_cast(channel->data_bits_), // bDataBits + }; + ESP_LOGD(TAG, "SET_LINE_CODING: baud=%u stop=%u parity=%u data=%u", (unsigned) baud, channel->stop_bits_, + (unsigned) channel->parity_, channel->data_bits_); + this->config_transfer_(CDC_REQUEST_TYPE, CDC_SET_LINE_CODING, 0, channel->cdc_dev_.interrupt_interface_number, + line_coding); + return true; + } + case 1: + // Assert DTR+RTS to signal DTE is present (init only). + if (reload) + return false; + this->config_transfer_(CDC_REQUEST_TYPE, CDC_SET_CONTROL_LINE_STATE, CDC_DTR_RTS, + channel->cdc_dev_.interrupt_interface_number); + return true; + default: + return false; } - this->start_channels_(); } -void USBUartTypeCdcAcm::start_channels_() { - for (auto *channel : this->channels_) { - if (!channel->initialised_.load()) - continue; +void USBUartComponent::enable_channels() { + this->cfg_single_ = nullptr; + this->cfg_pending_reload_ = nullptr; + this->cfg_channel_idx_ = 0; + this->start_config_(false); +} + +void USBUartComponent::apply_channel_settings(USBUartChannel *channel) { + if (this->cfg_active_) { + // A config sequence is already running. Defer this reload until it finishes to preserve + // the one-control-transfer-at-a-time guarantee (restarting mid-flight would let an + // in-flight callback complete against fresh state). The pending slot coalesces multiple + // requests; the channel's live settings are read when the reload eventually runs. + // Note: multiple channel reloads are not queued; only one pending reload is supported at a time. + this->cfg_pending_reload_ = channel; + return; + } + this->cfg_single_ = channel; + this->start_config_(true); +} + +void USBUartComponent::start_config_(bool reload) { + this->cfg_reload_ = reload; + this->cfg_device_phase_ = !reload; + this->cfg_step_ = 0; + this->cfg_ok_ = true; + this->cfg_in_flight_ = false; + this->cfg_done_.store(false); + this->cfg_active_ = true; + this->enable_loop(); +} + +void USBUartComponent::config_transfer_(uint8_t type, uint8_t request, uint16_t value, uint16_t index, + const std::vector &data) { + this->cfg_done_.store(false); + // The completion callback runs in the USB-task context: it only records the result and + // wakes the loop. The next transfer is issued from run_config_machine_() on the loop thread. + bool submitted = this->control_transfer( + type, request, value, index, + [this](const usb_host::TransferStatus &status) { + this->cfg_ok_ = status.success; + if (!status.success) { + ESP_LOGW(TAG, "Config control transfer failed: %s", esp_err_to_name(status.error_code)); + } else if (status.data_len > 0) { + memcpy(this->cfg_response_, status.data, std::min(status.data_len, sizeof(this->cfg_response_))); + } + // Release: publishes cfg_ok_/cfg_response_ before the loop observes cfg_done_. + this->cfg_done_.store(true, std::memory_order_release); + this->enable_loop_soon_any_context(); + App.wake_loop_threadsafe(); + }, + data); + if (!submitted) { + // Submission failed (e.g. no free transfer request). No callback will fire, so synthesize + // a failed completion here so the state machine advances/aborts instead of hanging. + ESP_LOGW(TAG, "Config control transfer submit failed"); + this->cfg_ok_ = false; + this->cfg_done_.store(true, std::memory_order_release); + } +} + +bool USBUartComponent::run_config_machine_() { + if (!this->cfg_active_) + return false; + + if (this->cfg_in_flight_) { + // Acquire: pairs with the release in config_transfer_'s callback. + if (!this->cfg_done_.load(std::memory_order_acquire)) + return false; // still waiting; the callback will re-wake the loop (no busy spin) + this->cfg_in_flight_ = false; + this->cfg_done_.store(false); + this->cfg_step_++; + } + + // cfg_ok_ is now synchronized (we only get here on the initial entry or after observing + // cfg_done_ with acquire ordering), so it is safe to read. + ESP_LOGV(TAG, "Config machine: device_phase=%d channel_idx=%d step=%d reload=%d ok=%d", this->cfg_device_phase_, + this->cfg_channel_idx_, this->cfg_step_, this->cfg_reload_, this->cfg_ok_); + + // One-time device-level phase (init only). config_device_step() inspects cfg_ok_ itself. + if (this->cfg_device_phase_) { + if (this->config_device_step(this->cfg_step_, this->cfg_ok_, this->cfg_response_)) { + this->cfg_in_flight_ = true; + return true; + } + this->cfg_device_phase_ = false; + this->cfg_step_ = 0; + this->cfg_ok_ = true; + } + + USBUartChannel *channel = + this->cfg_single_ != nullptr + ? this->cfg_single_ + : (this->cfg_channel_idx_ < this->channels_.size() ? this->channels_[this->cfg_channel_idx_] : nullptr); + + if (channel != nullptr && channel->initialised_.load()) { + if (!this->cfg_ok_) { + // A previous step in this channel's sequence failed. Abort the rest. On a full init, + // mark the channel uninitialised so data flow isn't started on a misconfigured channel; + // on a reload, leave the already-working channel as it was. + if (!this->cfg_reload_) + channel->initialised_.store(false); + } else if (this->config_step(channel, this->cfg_step_, this->cfg_reload_, this->cfg_ok_, this->cfg_response_)) { + this->cfg_in_flight_ = true; + return true; + } + } + + // Channel finished (or aborted). On full init, kick off data flow if still initialised. + if (channel != nullptr && !this->cfg_reload_ && channel->initialised_.load()) { channel->input_started_.store(false); channel->output_started_.store(false); this->start_input(channel); } + + // Advance to the next channel (or finish). + this->cfg_step_ = 0; + this->cfg_ok_ = true; + if (this->cfg_single_ != nullptr) { + this->cfg_active_ = false; + this->cfg_single_ = nullptr; + } else if (++this->cfg_channel_idx_ >= this->channels_.size()) { + this->cfg_active_ = false; + } + + // If the machine just went idle and a reload was requested while it was busy, start it now. + if (!this->cfg_active_ && this->cfg_pending_reload_ != nullptr) { + this->cfg_single_ = this->cfg_pending_reload_; + this->cfg_pending_reload_ = nullptr; + this->start_config_(true); + } + return true; +} + +void USBUartChannel::load_settings(bool /*dump_config*/) { + // The per-channel control transfers already log their values at debug level. + this->parent_->apply_channel_settings(this); } } // namespace esphome::usb_uart diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 6d60809b38..5bb4c97796 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -146,7 +146,9 @@ class USBUartChannel final : public uart::UARTComponent, public Parentedinput_buffer_.get_available(); } bool is_connected() override { return this->initialised_.load(); } uart::UARTFlushResult flush() override; - void check_logger_conflict() override {} + // Re-apply the current line settings (baud, parity, etc) to this already-open channel. + void load_settings(bool dump_config) override; + using UARTComponent::load_settings; // also bring in the no-arg overload for convenience void set_parity(UARTParityOptions parity) { this->parity_ = parity; } void set_debug(bool debug) { this->debug_ = debug; } void set_dummy_receiver(bool dummy_receiver) { this->dummy_receiver_ = dummy_receiver; } @@ -160,6 +162,7 @@ class USBUartChannel final : public uart::UARTComponent, public Parented cb) { this->rx_callback_ = std::move(cb); } protected: + void check_logger_conflict() override {} // Larger structures first (8+ bytes) RingBuffer input_buffer_; LockFreeQueue output_queue_; @@ -195,6 +198,12 @@ class USBUartComponent : public usb_host::USBClient { virtual void start_input(USBUartChannel *channel); void start_output(USBUartChannel *channel); + // Begin configuring all channels (full initialisation). Called from on_connected(). + void enable_channels(); + // Re-apply line settings to a single, already-open channel (used by + // USBUartChannel::load_settings()). + void apply_channel_settings(USBUartChannel *channel); + // Called from loop() when input_buffer_ has insufficient space for the incoming chunk. // Default is a no-op; override in device-specific subclasses that need resync on overflow. virtual void on_rx_overflow(USBUartChannel *channel) {} @@ -206,7 +215,41 @@ class USBUartComponent : public usb_host::USBClient { EventPool chunk_pool_; protected: + // Issue one control transfer as part of the setup state machine. The completion + // callback (USB-task context) records the result/IN data, marks the step done and + // wakes the loop so run_config_machine_() advances on the loop thread. Call exactly + // once from config_step_()/config_device_step_() when issuing a step. + void config_transfer_(uint8_t type, uint8_t request, uint16_t value, uint16_t index, + const std::vector &data = {}); + // (Re)start the config state machine. reload=false runs full init over all channels; + // reload=true re-applies settings to cfg_single_ only. + void start_config_(bool reload); + // Advance the config state machine; called from loop(). Returns true if it did work. + bool run_config_machine_(); + + // Per-subclass per-channel settings sequence. For the given zero-based step, issue the + // next control transfer via config_transfer_() and return true, or return false when the + // channel has no more steps. reload=true ⇒ apply only baud/parity/stop/data (skip + // enable/reset/DTR-RTS). ok/response carry the previous step's result and IN data. + virtual bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) = 0; + // Optional one-time device-level setup run before the per-channel phase on init only + // (e.g. CH34x chip detection). Same contract as config_step_(). Default: no steps. + virtual bool config_device_step(uint8_t step, bool ok, const uint8_t *response) { return false; } + std::vector channels_{}; + + // Config state machine + USBUartChannel *cfg_single_{nullptr}; // non-null: reload of a single channel + USBUartChannel *cfg_pending_reload_{nullptr}; // reload requested while the machine was busy + std::atomic cfg_done_{false}; // synchronizes cfg_ok_/cfg_response_ across threads + uint8_t cfg_response_[8]{}; // last IN transfer payload (for detection reads) + uint8_t cfg_channel_idx_{0}; + uint8_t cfg_step_{0}; + bool cfg_active_{false}; + bool cfg_reload_{false}; + bool cfg_device_phase_{false}; + bool cfg_in_flight_{false}; + bool cfg_ok_{true}; }; class USBUartTypeCdcAcm : public USBUartComponent { @@ -217,11 +260,7 @@ class USBUartTypeCdcAcm : public USBUartComponent { virtual std::vector parse_descriptors(usb_device_handle_t dev_hdl); void on_connected() override; void on_disconnected() override; - virtual void enable_channels(); - /// Resets per-channel transfer flags and posts the first bulk IN transfer. - /// Called by enable_channels() and by vendor-specific subclass overrides that - /// handle their own line-coding setup before starting data flow. - void start_channels_(); + bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; }; class USBUartTypeCP210X : public USBUartTypeCdcAcm { @@ -230,7 +269,7 @@ class USBUartTypeCP210X : public USBUartTypeCdcAcm { protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; - void enable_channels() override; + bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; }; class USBUartTypeCH34X : public USBUartTypeCdcAcm { public: @@ -238,11 +277,11 @@ class USBUartTypeCH34X : public USBUartTypeCdcAcm { void dump_config() override; protected: - void enable_channels() override; + bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + bool config_device_step(uint8_t step, bool ok, const uint8_t *response) override; std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; private: - void apply_line_settings_(); CH34xChipType chiptype_{CHIP_UNKNOWN}; const char *chip_name_{"unknown"}; uint8_t num_ports_{1}; @@ -257,12 +296,7 @@ class USBUartTypeFT23XX : public USBUartTypeCdcAcm { protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; - void enable_channels() override; - - int reset_(USBUartChannel *channel); - int set_baudrate_(USBUartChannel *channel, uint32_t baudrate = 0); - int set_line_properties_(USBUartChannel *channel); - int set_dtr_rts_(USBUartChannel *channel); + bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; uint8_t chip_type_{255}; }; @@ -285,7 +319,7 @@ class USBUartTypePL2303 : public USBUartTypeCdcAcm { protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; - void enable_channels() override; + bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; Pl2303ChipType chip_type_{PL2303_TYPE_UNKNOWN}; }; diff --git a/esphome/components/weikai/weikai.h b/esphome/components/weikai/weikai.h index 6f38f58318..02a39d3c84 100644 --- a/esphome/components/weikai/weikai.h +++ b/esphome/components/weikai/weikai.h @@ -381,6 +381,15 @@ class WeikaiChannel : public uart::UARTComponent { /// we wait until all bytes are gone with a timeout of 100 ms uart::UARTFlushResult flush() override; +#if defined(USE_ESP8266) || defined(USE_ESP32) + /// @brief Re-apply the current line settings (baud, parity, etc) to the channel. + void load_settings(bool dump_config) override { + this->set_line_param_(); + this->set_baudrate_(); + } + using UARTComponent::load_settings; // also bring in the no-arg overload for convenience +#endif + protected: friend class WeikaiComponent; diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h index 798f7283f6..45f7b65289 100644 --- a/tests/components/mitsubishi_cn105/common.h +++ b/tests/components/mitsubishi_cn105/common.h @@ -37,6 +37,9 @@ class MockUARTComponent : public uart::UARTComponent { MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override)); MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif // defined(USE_ESP8266) || defined(USE_ESP32) }; class TestableMitsubishiCN105 : public MitsubishiCN105 { diff --git a/tests/components/uart/common.h b/tests/components/uart/common.h index de3ea3029e..5c4ba1130e 100644 --- a/tests/components/uart/common.h +++ b/tests/components/uart/common.h @@ -32,6 +32,9 @@ class MockUARTComponent : public UARTComponent { MOCK_METHOD(size_t, available, (), (override)); MOCK_METHOD(UARTFlushResult, flush, (), (override)); MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + MOCK_METHOD(void, load_settings, (bool dump_config), (override)); +#endif }; } // namespace esphome::uart::testing From 84f4fbeaa80900f52d2854511a85cafb227e0aab Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:41:36 +0200 Subject: [PATCH 0786/1815] [zigbee] Allow to combine and merge endpoints on esp32 (#17402) --- esphome/components/zigbee/__init__.py | 21 ++- esphome/components/zigbee/const.py | 3 + esphome/components/zigbee/const_esp32.py | 7 +- esphome/components/zigbee/const_zephyr.py | 2 +- esphome/components/zigbee/zigbee_ep_esp32.py | 157 +++++++++++++++--- esphome/components/zigbee/zigbee_esp32.py | 36 ++-- tests/components/zigbee/common_esp32.yaml | 12 +- .../zigbee/test-router.esp32-c6-idf.yaml | 7 + 8 files changed, 190 insertions(+), 55 deletions(-) create mode 100644 tests/components/zigbee/test-router.esp32-c6-idf.yaml diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 444012bcd8..775fb35140 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -18,10 +18,13 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType from .const import ( + CONF_ENDPOINT, + CONF_MAX_EP_NUMBER, CONF_ON_JOIN, CONF_POWER_SOURCE, CONF_REPORT, CONF_ROUTER, + CONF_USE_DEVICE_TYPE, CONF_WIPE_ON_BOOT, KEY_ZIGBEE, POWER_SOURCE, @@ -31,7 +34,7 @@ from .const import ( ) from .const_zephyr import ( CONF_IEEE802154_VENDOR_OUI, - CONF_MAX_EP_NUMBER, + CONF_MAX_EP_NUMBER_ZEPHYR, CONF_SLEEPY, CONF_ZIGBEE_ID, KEY_EP_NUMBER, @@ -71,7 +74,17 @@ BASE_SCHEMA = cv.Schema( cv.requires_component("esp32"), _check_report_deprecation, cv.enum(REPORT, lower=True), - ) + ), + cv.Optional(CONF_ENDPOINT): cv.All( + cv.requires_component("zigbee"), + cv.requires_component("esp32"), + cv.int_range(1, CONF_MAX_EP_NUMBER), + ), + cv.Optional(CONF_USE_DEVICE_TYPE): cv.All( + cv.requires_component("zigbee"), + cv.requires_component("esp32"), + cv.boolean, + ), } ) BINARY_SENSOR_SCHEMA = cv.Schema({}).extend(BASE_SCHEMA).extend(zephyr_binary_sensor) @@ -148,8 +161,8 @@ def validate_number_of_ep(config: ConfigType) -> ConfigType: _LOGGER.warning( "Single endpoint requires ZHA or at leatst Zigbee2MQTT 2.8.0. For older versions of Zigbee2MQTT use multiple endpoints" ) - if count > CONF_MAX_EP_NUMBER and not CORE.testing_mode: - raise cv.Invalid(f"Maximum number of end points is {CONF_MAX_EP_NUMBER}") + if count > CONF_MAX_EP_NUMBER_ZEPHYR and not CORE.testing_mode: + raise cv.Invalid(f"Maximum number of end points is {CONF_MAX_EP_NUMBER_ZEPHYR}") return config diff --git a/esphome/components/zigbee/const.py b/esphome/components/zigbee/const.py index dd36f815ab..cfd23b9eb2 100644 --- a/esphome/components/zigbee/const.py +++ b/esphome/components/zigbee/const.py @@ -58,11 +58,14 @@ REPORT = { "default": report.ZIGBEE_REPORT_DEFAULT, } +CONF_ENDPOINT = "endpoint" +CONF_MAX_EP_NUMBER = 239 CONF_ON_JOIN = "on_join" CONF_WIPE_ON_BOOT = "wipe_on_boot" CONF_REPORT = "report" CONF_ROUTER = "router" CONF_POWER_SOURCE = "power_source" +CONF_USE_DEVICE_TYPE = "use_device_type" POWER_SOURCE = { "UNKNOWN": 0x00, # ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN "MAINS_SINGLE_PHASE": 0x01, # ZB_ZCL_BASIC_POWER_SOURCE_MAINS_SINGLE_PHASE diff --git a/esphome/components/zigbee/const_esp32.py b/esphome/components/zigbee/const_esp32.py index 81a8fc52cd..bfc4d93d5b 100644 --- a/esphome/components/zigbee/const_esp32.py +++ b/esphome/components/zigbee/const_esp32.py @@ -2,16 +2,13 @@ import esphome.codegen as cg DEVICE_TYPE = "device_type" ROLE = "role" -CONF_MAX_EP_NUMBER = 239 -CONF_NUM = "num" CONF_CLUSTERS = "clusters" CONF_ATTRIBUTES = "attributes" -CONF_ENDPOINT = "endpoint" CONF_CLUSTER = "cluster" SCALE = "scale" CONF_ATTRIBUTE_ID = "attribute_id" -KEY_BS_EP = "binary_sensor_ep" -KEY_SENSOR_EP = "sensor_ep" +KEY_ZIGBEE_EP = "zigbee_ep" +KEY_ZIGBEE_EP_NO_NUM = "zigbee_ep_no_num" DEVICE_ID = { "RANGE_EXTENDER": cg.RawExpression("EZB_ZHA_RANGE_EXTENDER_DEVICE_ID"), diff --git a/esphome/components/zigbee/const_zephyr.py b/esphome/components/zigbee/const_zephyr.py index 63d03c7952..bf8e8287c4 100644 --- a/esphome/components/zigbee/const_zephyr.py +++ b/esphome/components/zigbee/const_zephyr.py @@ -1,4 +1,4 @@ -CONF_MAX_EP_NUMBER = 8 +CONF_MAX_EP_NUMBER_ZEPHYR = 8 CONF_ZIGBEE_ID = "zigbee_id" CONF_ZIGBEE_BINARY_SENSOR = "zigbee_binary_sensor" CONF_ZIGBEE_SENSOR = "zigbee_sensor" diff --git a/esphome/components/zigbee/zigbee_ep_esp32.py b/esphome/components/zigbee/zigbee_ep_esp32.py index f4efa7bf4e..ca96e4364f 100644 --- a/esphome/components/zigbee/zigbee_ep_esp32.py +++ b/esphome/components/zigbee/zigbee_ep_esp32.py @@ -2,16 +2,22 @@ from typing import Any import esphome.config_validation as cv from esphome.const import CONF_DEVICE, CONF_ID, CONF_TYPE +from esphome.core import CORE -from .const import CONF_REPORT, REPORT +from .const import ( + CONF_MAX_EP_NUMBER, + CONF_REPORT, + CONF_USE_DEVICE_TYPE, + KEY_ZIGBEE, + REPORT, +) from .const_esp32 import ( - CLUSTER_ROLE, CONF_ATTRIBUTE_ID, CONF_ATTRIBUTES, CONF_CLUSTERS, - CONF_MAX_EP_NUMBER, - CONF_NUM, DEVICE_TYPE, + KEY_ZIGBEE_EP, + KEY_ZIGBEE_EP_NO_NUM, ROLE, ) @@ -22,12 +28,12 @@ ep_configs: dict[str, dict[str, Any]] = { CONF_CLUSTERS: [ { CONF_ID: "BINARY_INPUT", - ROLE: CLUSTER_ROLE["SERVER"], + ROLE: "SERVER", CONF_ATTRIBUTES: [ { CONF_ATTRIBUTE_ID: 0x55, CONF_TYPE: "BOOL", - CONF_REPORT: REPORT["default"], + CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONF_DEVICE: None, }, { @@ -47,16 +53,15 @@ ep_configs: dict[str, dict[str, Any]] = { ], }, "analog_input": { - DEVICE_TYPE: "CUSTOM_ATTR", CONF_CLUSTERS: [ { CONF_ID: "ANALOG_INPUT", - ROLE: CLUSTER_ROLE["SERVER"], + ROLE: "SERVER", CONF_ATTRIBUTES: [ { CONF_ATTRIBUTE_ID: 0x55, CONF_TYPE: "SINGLE", - CONF_REPORT: REPORT["default"], + CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONF_DEVICE: None, }, { @@ -78,22 +83,126 @@ ep_configs: dict[str, dict[str, Any]] = { } -def create_ep(ep_list: list[dict[str, Any]], router: bool) -> list[dict[str, Any]]: +def get_next_ep_num(eps: list[int]) -> int: + try: + ep_num = [i for i in range(1, CONF_MAX_EP_NUMBER + 1) if i not in eps][0] + eps.append(ep_num) + except IndexError as e: + raise cv.Invalid( + f"Too many devices. Zigbee can define only {CONF_MAX_EP_NUMBER} endpoints." + ) from e + return ep_num + + +def merge_endpoint( + existing_ep: dict[str, Any], + ep_num: int | None, + ep: dict[str, Any], + use_type: bool | None, + skip_error: bool, +) -> bool: + add = True + existing_clusters = [(cl[CONF_ID], cl[ROLE]) for cl in existing_ep[CONF_CLUSTERS]] + for cl in [(cl[CONF_ID], cl[ROLE]) for cl in ep[CONF_CLUSTERS]]: + if cl in existing_clusters: + if not skip_error: + raise cv.Invalid( + f"Endpoint {ep_num} has more than one cluster with cluster id {cl[0]} and role {cl[1]}." + ) + add = False + break + if not add: + return False + if ( + use_type + and existing_ep.get(CONF_USE_DEVICE_TYPE) + and ep.get(DEVICE_TYPE) != existing_ep.get(DEVICE_TYPE) + ): + if not skip_error: + raise cv.Invalid( + f"Endpoint {ep_num} has a conflicting device type {existing_ep.get(DEVICE_TYPE, 'CUSTOM_ATTR')} and use_type is set for both." + ) + return False + if use_type: + existing_ep[CONF_USE_DEVICE_TYPE] = use_type + if ep.get(DEVICE_TYPE): + existing_ep[DEVICE_TYPE] = ep[DEVICE_TYPE] + else: + existing_ep.pop(DEVICE_TYPE, None) + existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS]) + return True + if existing_ep.get(CONF_USE_DEVICE_TYPE): + existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS]) + return True + if ( + ep.get(DEVICE_TYPE) + and existing_ep.get(DEVICE_TYPE) + and ep[DEVICE_TYPE] != existing_ep[DEVICE_TYPE] + ): + if not skip_error: + raise cv.Invalid( + f"Endpoint {ep_num} has already a conflicting device type {existing_ep[DEVICE_TYPE]} and use_type is not set for both." + ) + return False + if ep.get(DEVICE_TYPE): + existing_ep[DEVICE_TYPE] = ep[DEVICE_TYPE] + existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS]) + return True + + +def create_ep(router: bool) -> None: + zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) + ep_dict: dict[int, dict] = zb_data.setdefault(KEY_ZIGBEE_EP, {}) + ep_list: list[dict] = zb_data.setdefault(KEY_ZIGBEE_EP_NO_NUM, []) # create dummy endpoint if list is empty - if not ep_list: + if not ep_dict and not ep_list: ep_type = "CUSTOM_ATTR" if router: ep_type = "RANGE_EXTENDER" - ep_list = [ - { - DEVICE_TYPE: ep_type, - } - ] - # enumerate endpoints - for i, ep in enumerate(ep_list, 1): - ep[CONF_NUM] = i - if len(ep_list) > CONF_MAX_EP_NUMBER: - raise cv.Invalid( - f"Too many devices. Zigbee can define only {CONF_MAX_EP_NUMBER} endpoints." - ) - return ep_list + ep_dict[1] = {DEVICE_TYPE: ep_type} + if ep_list: + # merge endpoint with different clusters + ep_list_new: list[dict] = [] + for ep in ep_list: + added = False + for existing_ep in ep_list_new: + if merge_endpoint( + existing_ep, None, ep, ep.get(CONF_USE_DEVICE_TYPE), True + ): + added = True + break + if not added: + ep_list_new.append(ep) + + # Add endpoints with no number to the endpoint dict with a new number + eps = list(ep_dict.keys()) + for ep in ep_list_new: + ep_num = get_next_ep_num(eps) + ep_dict[ep_num] = ep + + # clear list so that it is not processed again + del zb_data[KEY_ZIGBEE_EP_NO_NUM] + + # Add default device type to endpoints that have none + for ep in ep_dict.values(): + if not ep.get(DEVICE_TYPE): + ep[DEVICE_TYPE] = "CUSTOM_ATTR" + + +def add_ep(ep: dict[str, Any], ep_num: int | None, use_type: bool | None) -> None: + zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) + if ep_num is None: + if use_type: + ep[CONF_USE_DEVICE_TYPE] = use_type + ep_list: list[dict] = zb_data.setdefault(KEY_ZIGBEE_EP_NO_NUM, []) + ep_list.append(ep) + else: + ep_dict: dict[int, dict] = zb_data.setdefault(KEY_ZIGBEE_EP, {}) + if ep_num in ep_dict: + # check if the existing endpoint has same clusters + existing_ep = ep_dict[ep_num] + merge_endpoint(existing_ep, ep_num, ep, use_type, False) + else: + if use_type is not None: + ep[CONF_USE_DEVICE_TYPE] = use_type + ep_dict[ep_num] = ep diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index f19bc97be7..73dcd07029 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -35,9 +35,11 @@ from .const import ( ANALOG_INPUT_APPTYPE, BACNET_UNIT_NO_UNITS, BACNET_UNITS, + CONF_ENDPOINT, CONF_POWER_SOURCE, CONF_REPORT, CONF_ROUTER, + CONF_USE_DEVICE_TYPE, KEY_ZIGBEE, POWER_SOURCE, ZigbeeAttribute, @@ -45,18 +47,17 @@ from .const import ( from .const_esp32 import ( ATTR_TYPE, CLUSTER_ID, + CLUSTER_ROLE, CONF_ATTRIBUTE_ID, CONF_ATTRIBUTES, CONF_CLUSTERS, - CONF_NUM, DEVICE_ID, DEVICE_TYPE, - KEY_BS_EP, - KEY_SENSOR_EP, + KEY_ZIGBEE_EP, ROLE, SCALE, ) -from .zigbee_ep_esp32 import create_ep, ep_configs +from .zigbee_ep_esp32 import add_ep, create_ep, ep_configs _LOGGER = logging.getLogger(__name__) @@ -146,6 +147,7 @@ def final_validate_esp32(config: ConfigType) -> ConfigType: raise cv.Invalid( f"Partition '{partition}' in your custom partition table has wrong format. It should be: '{partition}, {types['type']}, {types['subtype']}, , {types['size']},'" ) + create_ep(config.get(CONF_ROUTER)) return config @@ -199,18 +201,14 @@ def validate_sensor_esp32(config: ConfigType) -> ConfigType: }, ) setup_attributes(config, ep[CONF_CLUSTERS]) - zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) - sensor_ep: list[dict] = zb_data.setdefault(KEY_SENSOR_EP, []) - sensor_ep.append(ep) + add_ep(ep, config.get(CONF_ENDPOINT), config.get(CONF_USE_DEVICE_TYPE)) return config def validate_binary_sensor_esp32(config: ConfigType) -> ConfigType: ep = copy.deepcopy(ep_configs["binary_input"]) setup_attributes(config, ep[CONF_CLUSTERS]) - zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) - binary_sensor_ep: list[dict] = zb_data.setdefault(KEY_BS_EP, []) - binary_sensor_ep.append(ep) + add_ep(ep, config.get(CONF_ENDPOINT), config.get(CONF_USE_DEVICE_TYPE)) return config @@ -243,7 +241,7 @@ async def attributes_to_code( var.add_attr( ep_num, CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]), - cl[ROLE], + CLUSTER_ROLE[cl[ROLE]], attr[CONF_ATTRIBUTE_ID], attr.get(CONF_MAX_LENGTH, 0), attr[CONF_VALUE], @@ -255,7 +253,7 @@ async def attributes_to_code( var, ep_num, CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]), - cl[ROLE], + CLUSTER_ROLE[cl[ROLE]], attr[CONF_ATTRIBUTE_ID], ATTR_TYPE[attr[CONF_TYPE]], attr.get(SCALE, 1), @@ -287,9 +285,7 @@ async def esp32_to_code(config: ConfigType) -> "MockObj": # create endpoints zb_data = CORE.data.get(KEY_ZIGBEE, {}) - sensor_ep: list[dict] = zb_data.get(KEY_SENSOR_EP, []) - binary_sensor_ep: list[dict] = zb_data.get(KEY_BS_EP, []) - ep_list = create_ep(sensor_ep + binary_sensor_ep, config.get(CONF_ROUTER)) + ep_dict: dict[int, dict] = zb_data.get(KEY_ZIGBEE_EP, {}) # setup zigbee components var = cg.new_Pvariable(config[CONF_ID]) @@ -301,15 +297,15 @@ async def esp32_to_code(config: ConfigType) -> "MockObj": POWER_SOURCE[config[CONF_POWER_SOURCE]], ) ) - for ep in ep_list: - cg.add(var.create_default_cluster(ep[CONF_NUM], DEVICE_ID[ep[DEVICE_TYPE]])) + for ep_num, ep in ep_dict.items(): + cg.add(var.create_default_cluster(ep_num, DEVICE_ID[ep[DEVICE_TYPE]])) for cl in ep.get(CONF_CLUSTERS, []): cg.add( var.add_cluster( - ep[CONF_NUM], + ep_num, CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]), - cl[ROLE], + CLUSTER_ROLE[cl[ROLE]], ) ) - await attributes_to_code(var, ep[CONF_NUM], cl) + await attributes_to_code(var, ep_num, cl) return var diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml index 787afc4476..8e00e4471e 100644 --- a/tests/components/zigbee/common_esp32.yaml +++ b/tests/components/zigbee/common_esp32.yaml @@ -8,10 +8,20 @@ binary_sensor: - platform: template name: "Garage Door Open 12" report: "force" + endpoint: 1 + +sensor: + - platform: template + name: "Temperature Sensor" + lambda: return 10.0; + device_class: temperature + unit_of_measurement: "°C" + endpoint: 1 + use_device_type: true zigbee: model: zigbee_test - router: true + router: false power_source: MAINS_SINGLE_PHASE on_join: then: diff --git a/tests/components/zigbee/test-router.esp32-c6-idf.yaml b/tests/components/zigbee/test-router.esp32-c6-idf.yaml new file mode 100644 index 0000000000..228fe331e5 --- /dev/null +++ b/tests/components/zigbee/test-router.esp32-c6-idf.yaml @@ -0,0 +1,7 @@ +zigbee: + model: zigbee_test + router: true + power_source: MAINS_SINGLE_PHASE + on_join: + then: + - logger.log: "Joined network" From 26f48ee9ea1626a4cc1b2bfdda440e699707dcc5 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:21:23 -0400 Subject: [PATCH 0787/1815] [lvgl] Fix ImageValidator.process signature to match base (#17451) --- esphome/components/lvgl/lv_validation.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 56ee3b47af..b588e865d2 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -398,7 +398,10 @@ class ImageValidator(LValidator): ) async def process( - self, value: Any, args: list[tuple[SafeExpType, str]] | None = None + self, + value: Any, + args: list[tuple[SafeExpType, str]] | None = None, + raw_lambda: bool = False, ) -> Expression: # Local import to avoid circular import at module level from .lvcode import get_lambda_context_args @@ -419,7 +422,7 @@ class ImageValidator(LValidator): index = await metadata.from_.convert_value(index) return mapping_var.get(index) - return await super().process(value, args) + return await super().process(value, args, raw_lambda) lv_image = ImageValidator() From dd0d0942f5867d0fa44352d6599ce66ba26d101f Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 9 Jul 2026 07:38:58 +1200 Subject: [PATCH 0788/1815] [image] Restructure into a platform component (#17416) --- .gitattributes | 2 + CODEOWNERS | 1 + esphome/components/animation/__init__.py | 118 +--- esphome/components/animation/image.py | 115 ++++ esphome/components/file/__init__.py | 1 + esphome/components/file/image.py | 315 +++++++++ esphome/components/image/__init__.py | 616 ++++++------------ esphome/components/online_image/__init__.py | 157 +---- esphome/components/online_image/image.py | 152 +++++ esphome/config.py | 12 + esphome/loader.py | 13 + script/build_language_schema.py | 11 - tests/component_tests/animation/__init__.py | 0 .../animation/config/anim.apng | Bin 0 -> 12626 bytes .../component_tests/animation/config/anim.gif | Bin 0 -> 9735 bytes .../config/animation_platform_test.yaml | 30 + .../animation/config/animation_test.yaml | 25 + tests/component_tests/animation/test_init.py | 81 +++ tests/component_tests/image/test_init.py | 446 +++++++++---- .../component_tests/online_image/__init__.py | 0 .../config/online_image_platform_test.yaml | 30 + .../config/online_image_test.yaml | 29 + .../component_tests/online_image/test_init.py | 76 +++ tests/components/animation/common.yaml | 19 +- tests/components/animation/validate.host.yaml | 16 + tests/components/file/common.yaml | 17 + tests/components/file/test.esp32-idf.yaml | 14 + tests/components/file/test.host.yaml | 9 + tests/components/image/common.yaml | 57 +- tests/components/image/test.esp8266-ard.yaml | 7 +- tests/components/image/test.host.yaml | 97 +-- .../image/validate-defaults.host.yaml | 25 + .../image/validate-grouped-single.host.yaml | 24 + .../image/validate-grouped.host.yaml | 25 + .../image/validate-single.host.yaml | 16 + tests/components/image/validate.host.yaml | 18 + tests/components/online_image/common.yaml | 29 +- .../online_image/validate.host.yaml | 22 + tests/unit_tests/test_config_normalization.py | 85 ++- 39 files changed, 1827 insertions(+), 883 deletions(-) create mode 100644 esphome/components/animation/image.py create mode 100644 esphome/components/file/__init__.py create mode 100644 esphome/components/file/image.py create mode 100644 esphome/components/online_image/image.py create mode 100644 tests/component_tests/animation/__init__.py create mode 100644 tests/component_tests/animation/config/anim.apng create mode 100644 tests/component_tests/animation/config/anim.gif create mode 100644 tests/component_tests/animation/config/animation_platform_test.yaml create mode 100644 tests/component_tests/animation/config/animation_test.yaml create mode 100644 tests/component_tests/animation/test_init.py create mode 100644 tests/component_tests/online_image/__init__.py create mode 100644 tests/component_tests/online_image/config/online_image_platform_test.yaml create mode 100644 tests/component_tests/online_image/config/online_image_test.yaml create mode 100644 tests/component_tests/online_image/test_init.py create mode 100644 tests/components/animation/validate.host.yaml create mode 100644 tests/components/file/common.yaml create mode 100644 tests/components/file/test.esp32-idf.yaml create mode 100644 tests/components/file/test.host.yaml create mode 100644 tests/components/image/validate-defaults.host.yaml create mode 100644 tests/components/image/validate-grouped-single.host.yaml create mode 100644 tests/components/image/validate-grouped.host.yaml create mode 100644 tests/components/image/validate-single.host.yaml create mode 100644 tests/components/image/validate.host.yaml create mode 100644 tests/components/online_image/validate.host.yaml diff --git a/.gitattributes b/.gitattributes index 1b3fd332b4..8171cd910f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,5 @@ # Normalize line endings to LF in the repository * text eol=lf *.png binary +*.gif binary +*.apng binary diff --git a/CODEOWNERS b/CODEOWNERS index 821d2e5e74..619fc14087 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -187,6 +187,7 @@ esphome/components/ezo_pmp/* @carlos-sarmiento esphome/components/factory_reset/* @anatoly-savchenkov esphome/components/fastled_base/* @OttoWinter esphome/components/feedback/* @ianchi +esphome/components/file/* @esphome/core esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund esphome/components/font/* @clydebarrow @esphome/core esphome/components/fs3000/* @kahrendt diff --git a/esphome/components/animation/__init__.py b/esphome/components/animation/__init__.py index 9c9c7e3871..0df7c56313 100644 --- a/esphome/components/animation/__init__.py +++ b/esphome/components/animation/__init__.py @@ -1,114 +1,36 @@ -import logging +# --------------------------------------------------------------------------- +# Legacy top-level `animation:` deprecation shim -- REMOVE this whole file after +# 2027.1.0. +# +# Animations are now a platform of the `image:` component (`platform: +# animation`); the real schema, actions and codegen live in `image.py`. This +# module only keeps the deprecated top-level `animation:` key working during the +# deprecation window: it reuses that schema/codegen and adds a one-shot +# deprecation warning (with a pasteable migrated `image:` block) at validation +# time. Deleting this file drops the top-level form entirely. +# --------------------------------------------------------------------------- -from esphome import automation -import esphome.codegen as cg -from esphome.components.const import CONF_LOOP import esphome.components.image as espImage import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_REPEAT -_LOGGER = logging.getLogger(__name__) +from .image import ANIMATION_CONFIG_SCHEMA, setup_animation -AUTO_LOAD = ["image"] +AUTO_LOAD = ["image", "file"] CODEOWNERS = ["@syndlex"] DEPENDENCIES = ["display"] MULTI_CONF = True MULTI_CONF_NO_DEFAULT = True -CONF_START_FRAME = "start_frame" -CONF_END_FRAME = "end_frame" -CONF_FRAME = "frame" +DOMAIN = "animation" -animation_ns = cg.esphome_ns.namespace("animation") +LEGACY_REMOVAL_VERSION = "2027.1.0" -Animation_ = animation_ns.class_("Animation", espImage.Image_) - -# Actions -NextFrameAction = animation_ns.class_( - "AnimationNextFrameAction", automation.Action, cg.Parented.template(Animation_) -) -PrevFrameAction = animation_ns.class_( - "AnimationPrevFrameAction", automation.Action, cg.Parented.template(Animation_) -) -SetFrameAction = animation_ns.class_( - "AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_) +_capture_legacy_entry, _warn_legacy_animation = ( + espImage.legacy_platform_migration_warning(DOMAIN, DOMAIN, LEGACY_REMOVAL_VERSION) ) -CONFIG_SCHEMA = cv.All( - espImage.IMAGE_SCHEMA.extend( - { - cv.Required(CONF_ID): cv.declare_id(Animation_), - cv.Optional(CONF_LOOP): cv.All( - { - cv.Optional(CONF_START_FRAME, default=0): cv.positive_int, - cv.Optional(CONF_END_FRAME): cv.positive_int, - cv.Optional(CONF_REPEAT): cv.positive_int, - } - ), - }, - ), - espImage.validate_settings, -) +CONFIG_SCHEMA = cv.All(_capture_legacy_entry, ANIMATION_CONFIG_SCHEMA) +FINAL_VALIDATE_SCHEMA = _warn_legacy_animation -NEXT_FRAME_SCHEMA = automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(Animation_), - } -) -PREV_FRAME_SCHEMA = automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(Animation_), - } -) -SET_FRAME_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.use_id(Animation_), - cv.Required(CONF_FRAME): cv.uint16_t, - } -) - - -@automation.register_action( - "animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA, synchronous=True -) -@automation.register_action( - "animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA, synchronous=True -) -@automation.register_action( - "animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True -) -async def animation_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - if (frame := config.get(CONF_FRAME)) is not None: - template_ = await cg.templatable(frame, args, cg.uint16) - cg.add(var.set_frame(template_)) - return var - - -async def to_code(config): - ( - prog_arr, - width, - height, - image_type, - trans_value, - frame_count, - ) = await espImage.write_image(config, all_frames=True) - - var = cg.new_Pvariable( - config[CONF_ID], - prog_arr, - width, - height, - frame_count, - image_type, - trans_value, - ) - if loop_config := config.get(CONF_LOOP): - start = loop_config[CONF_START_FRAME] - end = loop_config.get(CONF_END_FRAME, frame_count) - count = loop_config.get(CONF_REPEAT, -1) - cg.add(var.set_loop(start, end, count)) +to_code = setup_animation diff --git a/esphome/components/animation/image.py b/esphome/components/animation/image.py new file mode 100644 index 0000000000..95875fe2b0 --- /dev/null +++ b/esphome/components/animation/image.py @@ -0,0 +1,115 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components.const import CONF_LOOP +from esphome.components.file.image import image_schema, write_image +from esphome.components.image import Image_, validate_settings +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_REPEAT +from esphome.types import ConfigType + +CODEOWNERS = ["@syndlex"] +AUTO_LOAD = ["file"] +DEPENDENCIES = ["display"] + +CONF_START_FRAME = "start_frame" +CONF_END_FRAME = "end_frame" +CONF_FRAME = "frame" + +animation_ns = cg.esphome_ns.namespace("animation") + +Animation_ = animation_ns.class_("Animation", Image_) + +# Actions +NextFrameAction = animation_ns.class_( + "AnimationNextFrameAction", automation.Action, cg.Parented.template(Animation_) +) +PrevFrameAction = animation_ns.class_( + "AnimationPrevFrameAction", automation.Action, cg.Parented.template(Animation_) +) +SetFrameAction = animation_ns.class_( + "AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_) +) + +ANIMATION_SCHEMA = image_schema(Animation_).extend( + { + cv.Optional(CONF_LOOP): cv.All( + { + cv.Optional(CONF_START_FRAME, default=0): cv.positive_int, + cv.Optional(CONF_END_FRAME): cv.positive_int, + cv.Optional(CONF_REPEAT): cv.positive_int, + } + ), + }, +) + +# Shared schema used by both the (deprecated) top-level `animation:` key and the +# `image:` `platform: animation` entry. +ANIMATION_CONFIG_SCHEMA = cv.All(ANIMATION_SCHEMA, validate_settings) + + +NEXT_FRAME_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(Animation_), + } +) +PREV_FRAME_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(Animation_), + } +) +SET_FRAME_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(Animation_), + cv.Required(CONF_FRAME): cv.uint16_t, + } +) + + +@automation.register_action( + "animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA, synchronous=True +) +@automation.register_action( + "animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA, synchronous=True +) +@automation.register_action( + "animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True +) +async def animation_action_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, paren) + + if (frame := config.get(CONF_FRAME)) is not None: + template_ = await cg.templatable(frame, args, cg.uint16) + cg.add(var.set_frame(template_)) + return var + + +async def setup_animation(config: ConfigType) -> None: + ( + prog_arr, + width, + height, + image_type, + trans_value, + frame_count, + ) = await write_image(config, all_frames=True) + + var = cg.new_Pvariable( + config[CONF_ID], + prog_arr, + width, + height, + frame_count, + image_type, + trans_value, + ) + if loop_config := config.get(CONF_LOOP): + start = loop_config[CONF_START_FRAME] + end = loop_config.get(CONF_END_FRAME, frame_count) + count = loop_config.get(CONF_REPEAT, -1) + cg.add(var.set_loop(start, end, count)) + + +CONFIG_SCHEMA = ANIMATION_CONFIG_SCHEMA + +to_code = setup_animation diff --git a/esphome/components/file/__init__.py b/esphome/components/file/__init__.py new file mode 100644 index 0000000000..f70ffa9520 --- /dev/null +++ b/esphome/components/file/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@esphome/core"] diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py new file mode 100644 index 0000000000..9a7c762a79 --- /dev/null +++ b/esphome/components/file/image.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +import contextlib +import hashlib +import io +import logging +from pathlib import Path +import re + +from PIL import Image, UnidentifiedImageError + +from esphome import core, external_files +import esphome.codegen as cg +from esphome.components.const import CONF_BYTE_ORDER +from esphome.components.image import ( + CONF_INVERT_ALPHA, + CONF_OPAQUE, + CONF_TRANSPARENCY, + DOMAIN, + IMAGE_TYPE, + Image_, + ImageEncoder, + add_metadata, + get_image_type_enum, + get_transparency_enum, + is_svg_file, + validate_settings, + validate_transparency, + validate_type, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_DITHER, + CONF_FILE, + CONF_ICON, + CONF_ID, + CONF_PATH, + CONF_RAW_DATA_ID, + CONF_RESIZE, + CONF_SOURCE, + CONF_TYPE, + CONF_URL, +) +from esphome.core import CORE, HexInt +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType + +CODEOWNERS = ["@esphome/core"] + +_LOGGER = logging.getLogger(__name__) + +# If the MDI file cannot be downloaded within this time, abort. +IMAGE_DOWNLOAD_TIMEOUT = 30 # seconds + +SOURCE_LOCAL = "local" +SOURCE_WEB = "web" + +SOURCE_MDI = "mdi" +SOURCE_MDIL = "mdil" +SOURCE_MEMORY = "memory" + +MDI_SOURCES = { + SOURCE_MDI: "https://raw.githubusercontent.com/Templarian/MaterialDesign/master/svg/", + SOURCE_MDIL: "https://raw.githubusercontent.com/Pictogrammers/MaterialDesignLight/refs/heads/master/svg/", + SOURCE_MEMORY: "https://raw.githubusercontent.com/Pictogrammers/Memory/refs/heads/main/src/svg/", +} + + +def compute_local_image_path(value) -> Path: + url = value[CONF_URL] if isinstance(value, dict) else value + h = hashlib.new("sha256") + h.update(url.encode()) + key = h.hexdigest()[:8] + # Downloaded files are cached under the shared `image` domain directory so + # the cache location is unaffected by which platform requested the file. + base_dir = external_files.compute_local_file_dir(DOMAIN) + return base_dir / key + + +def local_path(value): + value = value[CONF_PATH] if isinstance(value, dict) else value + return str(CORE.relative_config_path(value)) + + +def download_file(url, path): + external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT) + return str(path) + + +def download_gh_svg(value, source): + mdi_id = value[CONF_ICON] if isinstance(value, dict) else value + base_dir = external_files.compute_local_file_dir(DOMAIN) / source + path = base_dir / f"{mdi_id}.svg" + + url = MDI_SOURCES[source] + mdi_id + ".svg" + return download_file(url, path) + + +def download_image(value): + value = value[CONF_URL] if isinstance(value, dict) else value + return download_file(value, compute_local_image_path(value)) + + +def validate_file_shorthand(value): + value = cv.string_strict(value) + parts = value.strip().split(":") + if len(parts) == 2 and parts[0] in MDI_SOURCES: + match = re.match(r"^[a-zA-Z0-9\-]+$", parts[1]) + if match is None: + raise cv.Invalid(f"Could not parse mdi icon name from '{value}'.") + return download_gh_svg(parts[1], parts[0]) + + if value.startswith(("http://", "https://")): + return download_image(value) + + value = cv.file_(value) + return local_path(value) + + +LOCAL_SCHEMA = cv.All( + { + cv.Required(CONF_PATH): cv.file_, + }, + local_path, +) + + +def mdi_schema(source): + def validate_mdi(value): + return download_gh_svg(value, source) + + return cv.All( + cv.Schema( + { + cv.Required(CONF_ICON): cv.string, + } + ), + validate_mdi, + ) + + +WEB_SCHEMA = cv.All( + { + cv.Required(CONF_URL): cv.string, + }, + download_image, +) + + +TYPED_FILE_SCHEMA = cv.typed_schema( + { + SOURCE_LOCAL: LOCAL_SCHEMA, + SOURCE_WEB: WEB_SCHEMA, + } + | {source: mdi_schema(source) for source in MDI_SOURCES}, + key=CONF_SOURCE, +) + + +OPTIONS_SCHEMA = { + cv.Optional(CONF_RESIZE): cv.dimensions, + cv.Optional(CONF_DITHER, default="NONE"): cv.one_of( + "NONE", "FLOYDSTEINBERG", upper=True + ), + cv.Optional(CONF_INVERT_ALPHA, default=False): cv.boolean, + cv.Optional(CONF_BYTE_ORDER): cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True), + cv.Optional(CONF_TRANSPARENCY, default=CONF_OPAQUE): validate_transparency(), +} + + +def image_schema(class_: MockObjClass = Image_) -> cv.Schema: + """Build the validation schema for a single file-backed image entry. + + Shared by the built-in ``file`` image platform and the ``animation`` + platform (which extends it). Platforms that source their pixels elsewhere + (e.g. ``online_image``) provide their own schema instead. + + :param class_: The declared C++ class for the generated image instance. + """ + return cv.Schema( + { + cv.Required(CONF_ID): cv.declare_id(class_), + cv.Required(CONF_FILE): cv.Any(validate_file_shorthand, TYPED_FILE_SCHEMA), + cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), + **OPTIONS_SCHEMA, + cv.Required(CONF_TYPE): validate_type(IMAGE_TYPE), + } + ) + + +def validate_image_final(config: ConfigType) -> ConfigType: + """Per-entry final validation, shared by file-backed image platforms. + + For LVGL 9 the default byte order for RGB565 images is little-endian, so + fill in that default when the user did not specify a byte order and warn + when big-endian was explicitly requested. + """ + if byte_order := config.get(CONF_BYTE_ORDER): + if byte_order == "BIG_ENDIAN": + _LOGGER.warning( + "The image '%s' is configured with big-endian byte order, little-endian is expected", + config.get(CONF_FILE), + ) + else: + config[CONF_BYTE_ORDER] = "LITTLE_ENDIAN" + return config + + +async def new_image(config: ConfigType) -> MockObj: + """Generate a single file-backed ``image::Image`` instance. + + Used by the built-in ``file`` platform; encodes the image data, registers + the C++ variable and records its metadata for other components to consume. + """ + prog_arr, width, height, image_type, trans_value, _ = await write_image(config) + var = cg.new_Pvariable( + config[CONF_ID], prog_arr, width, height, image_type, trans_value + ) + add_metadata( + config[CONF_ID], width, height, config[CONF_TYPE], config[CONF_TRANSPARENCY] + ) + return var + + +async def write_image(config, all_frames=False): + path = Path(config[CONF_FILE]) + if not path.is_file(): + raise core.EsphomeError(f"Could not load image file {path}") + + resize = config.get(CONF_RESIZE) + try: + if is_svg_file(path): + import resvg_py + + resize = resize or (None, None) + image_data = resvg_py.svg_to_bytes( + svg_path=str(path), width=resize[0], height=resize[1], dpi=100 + ) + + # Convert bytes to Pillow Image + image = Image.open(io.BytesIO(image_data)) + width, height = image.size + + else: + image = Image.open(path) + width, height = image.size + if resize: + # Preserve aspect ratio + new_width_max = min(width, resize[0]) + new_height_max = min(height, resize[1]) + ratio = min(new_width_max / width, new_height_max / height) + width, height = int(width * ratio), int(height * ratio) + except (OSError, UnidentifiedImageError, ValueError) as exc: + raise core.EsphomeError(f"Could not read image file {path}: {exc}") from exc + + if not resize and (width > 500 or height > 500): + _LOGGER.warning( + 'The image "%s" you requested is very big. Please consider' + " using the resize parameter.", + path, + ) + + dither = ( + Image.Dither.NONE + if config[CONF_DITHER] == "NONE" + else Image.Dither.FLOYDSTEINBERG + ) + type = config[CONF_TYPE] + transparency = config.get(CONF_TRANSPARENCY, CONF_OPAQUE) + invert_alpha = config[CONF_INVERT_ALPHA] + frame_count = 1 + if all_frames: + with contextlib.suppress(AttributeError): + frame_count = image.n_frames + if frame_count <= 1: + _LOGGER.warning("Image file %s has no animation frames", path) + + # 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() + combined_data.extend(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) + + return prog_arr, width, height, image_type, trans_value, frame_count + + +# The built-in static-image platform: pixels embedded at compile time from a +# local file, a downloaded web image, or a Material Design Icon. +CONFIG_SCHEMA = cv.All(image_schema(Image_), validate_settings) + +FINAL_VALIDATE_SCHEMA = validate_image_final + + +async def to_code(config: ConfigType) -> None: + await new_image(config) diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 5f8e5ca132..37a9afb84d 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -1,38 +1,27 @@ from __future__ import annotations -import contextlib +from collections.abc import Callable from dataclasses import dataclass -import hashlib -import io import logging from pathlib import Path -import re from PIL import Image, UnidentifiedImageError -from esphome import core, external_files import esphome.codegen as cg from esphome.components.const import CONF_BYTE_ORDER, KEY_METADATA import esphome.config_validation as cv -from esphome.const import ( - CONF_DEFAULTS, - CONF_DITHER, - CONF_FILE, - CONF_ICON, - CONF_ID, - CONF_PATH, - CONF_RAW_DATA_ID, - CONF_RESIZE, - CONF_SOURCE, - CONF_TYPE, - CONF_URL, -) -from esphome.core import CORE, HexInt +from esphome.const import CONF_DEFAULTS, CONF_FILE, CONF_ID, CONF_PLATFORM, CONF_TYPE +from esphome.core import CORE +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) DOMAIN = "image" DEPENDENCIES = ["display"] +IS_PLATFORM_COMPONENT = True + +# Name of the built-in static-image platform (local file / web / MDI sources). +PLATFORM_FILE = "file" image_ns = cg.esphome_ns.namespace("image") @@ -135,17 +124,6 @@ class ImageEncoder: """ return False - @classmethod - def get_options(cls) -> list[str]: - """ - Get the available options for this image encoder - """ - options = [*OPTIONS] - if not cls.is_endian(): - options.remove(CONF_BYTE_ORDER) - options.append(CONF_RAW_DATA_ID) - return options - def is_alpha_only(image: Image): """ @@ -338,60 +316,11 @@ TransparencyType = image_ns.enum("TransparencyType") CONF_TRANSPARENCY = "transparency" -# If the MDI file cannot be downloaded within this time, abort. -IMAGE_DOWNLOAD_TIMEOUT = 30 # seconds - -SOURCE_LOCAL = "local" -SOURCE_WEB = "web" - -SOURCE_MDI = "mdi" -SOURCE_MDIL = "mdil" -SOURCE_MEMORY = "memory" - -MDI_SOURCES = { - SOURCE_MDI: "https://raw.githubusercontent.com/Templarian/MaterialDesign/master/svg/", - SOURCE_MDIL: "https://raw.githubusercontent.com/Pictogrammers/MaterialDesignLight/refs/heads/master/svg/", - SOURCE_MEMORY: "https://raw.githubusercontent.com/Pictogrammers/Memory/refs/heads/main/src/svg/", -} - Image_ = image_ns.class_("Image") INSTANCE_TYPE = Image_ -def compute_local_image_path(value) -> Path: - url = value[CONF_URL] if isinstance(value, dict) else value - h = hashlib.new("sha256") - h.update(url.encode()) - key = h.hexdigest()[:8] - base_dir = external_files.compute_local_file_dir(DOMAIN) - return base_dir / key - - -def local_path(value): - value = value[CONF_PATH] if isinstance(value, dict) else value - return str(CORE.relative_config_path(value)) - - -def download_file(url, path): - external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT) - return str(path) - - -def download_gh_svg(value, source): - mdi_id = value[CONF_ICON] if isinstance(value, dict) else value - base_dir = external_files.compute_local_file_dir(DOMAIN) / source - path = base_dir / f"{mdi_id}.svg" - - url = MDI_SOURCES[source] + mdi_id + ".svg" - return download_file(url, path) - - -def download_image(value): - value = value[CONF_URL] if isinstance(value, dict) else value - return download_file(value, compute_local_image_path(value)) - - def is_svg_file(file): if not file: return False @@ -399,62 +328,6 @@ def is_svg_file(file): return " 500 or height > 500): - _LOGGER.warning( - 'The image "%s" you requested is very big. Please consider' - " using the resize parameter.", - path, - ) - - dither = ( - Image.Dither.NONE - if config[CONF_DITHER] == "NONE" - else Image.Dither.FLOYDSTEINBERG - ) - type = config[CONF_TYPE] - transparency = config.get(CONF_TRANSPARENCY, CONF_OPAQUE) - invert_alpha = config[CONF_INVERT_ALPHA] - frame_count = 1 - if all_frames: - with contextlib.suppress(AttributeError): - frame_count = image.n_frames - if frame_count <= 1: - _LOGGER.warning("Image file %s has no animation frames", path) - - # 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() - combined_data.extend(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) - - return prog_arr, width, height, image_type, trans_value, frame_count - - def add_metadata(id: str, width: int, height: int, image_type: str, transparency): all_metadata = CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_METADATA, {}) all_metadata[str(id)] = ImageMetaData( @@ -780,17 +388,10 @@ def add_metadata(id: str, width: int, height: int, image_type: str, transparency ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: + # Base platform-component codegen: each entry is generated by its platform's + # own ``to_code``; here we only need the feature define to be present. cg.add_define("USE_IMAGE") - # By now the config will be a simple list. - for entry in config: - prog_arr, width, height, image_type, trans_value, _ = await write_image(entry) - cg.new_Pvariable( - entry[CONF_ID], prog_arr, width, height, image_type, trans_value - ) - add_metadata( - entry[CONF_ID], width, height, entry[CONF_TYPE], entry[CONF_TRANSPARENCY] - ) def get_all_image_metadata() -> dict[str, ImageMetaData]: @@ -801,3 +402,198 @@ def get_all_image_metadata() -> dict[str, ImageMetaData]: def get_image_metadata(image_id: str) -> ImageMetaData | None: """Get image metadata by ID for use by other components.""" return get_all_image_metadata().get(image_id) + + +# --------------------------------------------------------------------------- +# Legacy top-level component -> `image:` platform deprecation helpers +# -- REMOVE after 2027.1.0 together with the `animation:`/`online_image:` shims. +# +# `animation:` and `online_image:` used to be standalone top-level components and +# are now platforms of `image:`. Their deprecated top-level shims use this helper +# to (1) record each raw entry as it is validated and (2) print a single, +# pasteable migrated `image:` block once every entry has been seen. The block is +# emitted from FINAL_VALIDATE_SCHEMA, which always runs after every per-entry +# CONFIG_SCHEMA step, so all entries are captured before it fires. +# --------------------------------------------------------------------------- + + +def legacy_platform_migration_warning( + domain: str, platform: str, removal_version: str +) -> tuple[ + Callable[[ConfigType], ConfigType], + Callable[[ConfigType], ConfigType], +]: + """Build the per-entry capture and one-shot warning validators for a + deprecated top-level component that is now an ``image:`` platform. + + Returns ``(capture, finalize)``: + * ``capture`` is a ``CONFIG_SCHEMA`` validator placed *before* the real + schema so it sees the raw user entry; it records a copy of each entry. + * ``finalize`` is a ``FINAL_VALIDATE_SCHEMA`` validator that warns exactly + once with the migrated, pasteable ``image:`` block. + """ + entries_key = "legacy_entries" + shown_key = "legacy_warning_shown" + + def capture(config: ConfigType) -> ConfigType: + data = CORE.data.setdefault(domain, {}) + data.setdefault(entries_key, []).append(dict(config)) + return config + + def finalize(config: ConfigType) -> ConfigType: + data = CORE.data.setdefault(domain, {}) + if not data.get(shown_key): + data[shown_key] = True + + from esphome import yaml_util + + migrated = [ + {CONF_PLATFORM: platform, **entry} + for entry in data.get(entries_key, []) + ] + _LOGGER.warning( + "The top-level '%s:' configuration is deprecated and will be " + "removed in ESPHome %s. '%s' is now a platform of the 'image' " + "component. Replace your '%s:' block with:\n\n%s", + domain, + removal_version, + domain, + domain, + yaml_util.dump({DOMAIN: migrated}), + ) + return config + + return capture, finalize + + +# --------------------------------------------------------------------------- +# Legacy `image:` config migration -- REMOVE after 2027.1.0 +# +# Before `image` became a platform component, its top-level config was either a +# bare list of image dicts, a single image dict, or a dict with `defaults:`, +# `images:` and per-type group keys. This block transparently rewrites those +# forms into the new ``platform: file`` list and prints the migrated YAML. +# It is intentionally self-contained so it can be deleted in one piece together +# with the ``LEGACY_CONFIG_MIGRATE`` assignment below. +# --------------------------------------------------------------------------- + +LEGACY_REMOVAL_VERSION = "2027.1.0" + + +def _is_new_image_format(config: object) -> bool: + """True when the config is already the new ``platform:``-tagged list.""" + return isinstance(config, list) and all( + isinstance(entry, dict) and CONF_PLATFORM in entry for entry in config + ) + + +def _is_legacy_image_format(config: object) -> bool: + """True when ``config`` matches a shape the pre-platform schema accepted. + + Only these shapes are migrated. Anything else -- a list containing a + non-dict (or already platform-tagged) entry, or a dict with no recognised + image keys -- is left untouched so the platform validation surfaces a + proper error instead of the migration silently dropping the input. + """ + if isinstance(config, list): + # A bare list of (not-yet-platform-tagged) image dicts. + return bool(config) and all( + isinstance(entry, dict) and CONF_PLATFORM not in entry for entry in config + ) + if not isinstance(config, dict): + return False + # A single image dict, or the grouped `defaults:`/`images:`/type-key form. + return ( + CONF_ID in config + or CONF_FILE in config + or any( + key in (CONF_DEFAULTS, CONF_IMAGES) or key.upper() in IMAGE_TYPE + for key in config + ) + ) + + +def _flatten_legacy_image_config(config: object) -> list[dict]: + """Structurally flatten a legacy ``image:`` config into image dicts. + + No validation or file IO is performed -- the ``file`` platform schema + validates the resulting entries. Unrecognised shapes yield no entries so the + normal platform validation surfaces the error. + """ + if isinstance(config, list): + return [dict(entry) for entry in config if isinstance(entry, dict)] + if not isinstance(config, dict): + return [] + if CONF_ID in config or CONF_FILE in config: + return [dict(config)] + + defaults = config.get(CONF_DEFAULTS) or {} + result: list[dict] = [] + + def _add(entry: dict, extra: dict) -> None: + merged = {**defaults, **extra, **entry} + # The legacy `defaults:`/type-grouped forms only applied `byte_order` to + # types that support it. Replicate that so an endian default merged into + # e.g. a binary image stays valid. + type_class = IMAGE_TYPE.get(str(merged.get(CONF_TYPE, "")).upper()) + if ( + CONF_BYTE_ORDER in merged + and isinstance(type_class, type) + and issubclass(type_class, ImageEncoder) + and not type_class.is_endian() + ): + del merged[CONF_BYTE_ORDER] + result.append(merged) + + def _add_entries(entries: object, extra: dict) -> None: + # `entries` may be a single image dict or a list of them; non-dict + # members are silently skipped, mirroring the old `ensure_list` leniency. + for entry in [entries] if isinstance(entries, dict) else entries: + if isinstance(entry, dict): + _add(entry, extra) + + _add_entries(config.get(CONF_IMAGES, []), {}) + + for key, value in config.items(): + if key in (CONF_DEFAULTS, CONF_IMAGES) or key.upper() not in IMAGE_TYPE: + continue + type_extra = {CONF_TYPE: key} + if isinstance(value, dict) and ( + transparency_keys := [k for k in value if k in TRANSPARENCY_TYPES] + ): + for trans in transparency_keys: + _add_entries(value[trans], {**type_extra, CONF_TRANSPARENCY: trans}) + elif isinstance(value, (list, dict)): + _add_entries(value, type_extra) + return result + + +def _migrate_legacy_image_config(config: object) -> list[dict] | None: + """Rewrite a legacy ``image:`` config into the ``platform: file`` list. + + Returns None for the already-migrated platform form and for any shape the + pre-platform schema never accepted, so normal platform validation can + surface a proper error instead of the migration silently discarding input. + """ + if _is_new_image_format(config) or not _is_legacy_image_format(config): + return None + migrated = [ + {CONF_PLATFORM: PLATFORM_FILE, **entry} + for entry in _flatten_legacy_image_config(config) + ] + + from esphome import yaml_util + + _LOGGER.warning( + "The 'image:' configuration format is deprecated and will be removed in " + "ESPHome %s. Images are now platforms of the 'image' component. Replace " + "your 'image:' block with:\n\n%s", + LEGACY_REMOVAL_VERSION, + yaml_util.dump({DOMAIN: migrated}), + ) + return migrated + + +LEGACY_CONFIG_MIGRATE = _migrate_legacy_image_config + +# --------------------------- end legacy migration -------------------------- diff --git a/esphome/components/online_image/__init__.py b/esphome/components/online_image/__init__.py index d47c2e8b44..552a43acad 100644 --- a/esphome/components/online_image/__init__.py +++ b/esphome/components/online_image/__init__.py @@ -1,150 +1,35 @@ -import logging +# --------------------------------------------------------------------------- +# Legacy top-level `online_image:` deprecation shim -- REMOVE this whole file +# after 2027.1.0. +# +# Online images are now a platform of the `image:` component (`platform: +# online_image`); the real schema, actions and codegen live in `image.py`. This +# module only keeps the deprecated top-level `online_image:` key working during +# the deprecation window: it reuses that schema/codegen and adds a one-shot +# deprecation warning (with a pasteable migrated `image:` block) at validation +# time. Deleting this file drops the top-level form entirely. +# --------------------------------------------------------------------------- -from esphome import automation -import esphome.codegen as cg -from esphome.components import runtime_image -from esphome.components.const import CONF_REQUEST_HEADERS -from esphome.components.http_request import CONF_HTTP_REQUEST_ID, HttpRequestComponent -from esphome.components.image import CONF_TRANSPARENCY, add_metadata +import esphome.components.image as espImage import esphome.config_validation as cv -from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_ON_ERROR, CONF_TYPE, CONF_URL -from esphome.core import Lambda + +from .image import ONLINE_IMAGE_CONFIG_SCHEMA, setup_online_image AUTO_LOAD = ["image", "runtime_image"] DEPENDENCIES = ["display", "http_request"] CODEOWNERS = ["@guillempages", "@clydebarrow"] MULTI_CONF = True -CONF_ON_DOWNLOAD_FINISHED = "on_download_finished" -CONF_UPDATE = "update" +DOMAIN = "online_image" -_LOGGER = logging.getLogger(__name__) +LEGACY_REMOVAL_VERSION = "2027.1.0" -online_image_ns = cg.esphome_ns.namespace("online_image") - -OnlineImage = online_image_ns.class_( - "OnlineImage", cg.PollingComponent, runtime_image.RuntimeImage +_capture_legacy_entry, _warn_legacy_online_image = ( + espImage.legacy_platform_migration_warning(DOMAIN, DOMAIN, LEGACY_REMOVAL_VERSION) ) -# Actions -SetUrlAction = online_image_ns.class_( - "OnlineImageSetUrlAction", automation.Action, cg.Parented.template(OnlineImage) -) -ReleaseImageAction = online_image_ns.class_( - "OnlineImageReleaseAction", automation.Action, cg.Parented.template(OnlineImage) -) +CONFIG_SCHEMA = cv.All(_capture_legacy_entry, ONLINE_IMAGE_CONFIG_SCHEMA) +FINAL_VALIDATE_SCHEMA = _warn_legacy_online_image -ONLINE_IMAGE_SCHEMA = ( - runtime_image.runtime_image_schema(OnlineImage) - .extend( - { - # Online Image specific options - cv.GenerateID(CONF_HTTP_REQUEST_ID): cv.use_id(HttpRequestComponent), - cv.Required(CONF_URL): cv.url, - cv.Optional(CONF_BUFFER_SIZE, default=65536): cv.int_range(256, 65536), - cv.Optional(CONF_REQUEST_HEADERS): cv.All( - cv.Schema({cv.string: cv.templatable(cv.string)}) - ), - cv.Optional(CONF_ON_DOWNLOAD_FINISHED): automation.validate_automation({}), - cv.Optional(CONF_ON_ERROR): automation.validate_automation({}), - } - ) - .extend(cv.polling_component_schema("never")) -) - -CONFIG_SCHEMA = cv.Schema( - cv.All( - ONLINE_IMAGE_SCHEMA, - cv.require_framework_version( - # esp8266 not supported yet; if enabled in the future, minimum version of 2.7.0 is needed - # esp8266_arduino=cv.Version(2, 7, 0), - esp32_arduino=cv.Version(0, 0, 0), - esp_idf=cv.Version(4, 0, 0), - rp2_arduino=cv.Version(0, 0, 0), - host=cv.Version(0, 0, 0), - ), - runtime_image.validate_runtime_image_settings, - ) -) - -SET_URL_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.use_id(OnlineImage), - cv.Required(CONF_URL): cv.templatable(cv.url), - cv.Optional(CONF_UPDATE, default=True): cv.templatable(bool), - } -) - -RELEASE_IMAGE_SCHEMA = automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(OnlineImage), - } -) - - -@automation.register_action( - "online_image.set_url", SetUrlAction, SET_URL_SCHEMA, synchronous=True -) -@automation.register_action( - "online_image.release", - ReleaseImageAction, - RELEASE_IMAGE_SCHEMA, - synchronous=True, -) -async def online_image_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - if CONF_URL in config: - template_ = await cg.templatable(config[CONF_URL], args, cg.std_string) - cg.add(var.set_url(template_)) - if CONF_UPDATE in config: - template_ = await cg.templatable(config[CONF_UPDATE], args, cg.bool_) - cg.add(var.set_update(template_)) - return var - - -_CALLBACK_AUTOMATIONS = ( - automation.CallbackAutomation( - CONF_ON_DOWNLOAD_FINISHED, "add_on_finished_callback", [(bool, "cached")] - ), - automation.CallbackAutomation(CONF_ON_ERROR, "add_on_error_callback"), -) - - -async def to_code(config): - # Use the enhanced helper function to get all runtime image parameters - settings = await runtime_image.process_runtime_image_config(config) - add_metadata( - config[CONF_ID], - settings.width, - settings.height, - config[CONF_TYPE], - config[CONF_TRANSPARENCY], - ) - - url = config[CONF_URL] - var = cg.new_Pvariable( - config[CONF_ID], - url, - settings.width, - settings.height, - settings.format_enum, - settings.image_type_enum, - settings.transparent, - settings.placeholder or cg.nullptr, - config[CONF_BUFFER_SIZE], - settings.byte_order_big_endian, - ) - await cg.register_component(var, config) - await cg.register_parented(var, config[CONF_HTTP_REQUEST_ID]) - - for key, value in config.get(CONF_REQUEST_HEADERS, {}).items(): - if isinstance(value, Lambda): - template_ = await cg.templatable(value, [], cg.std_string) - cg.add(var.add_request_header(key, template_)) - else: - cg.add(var.add_request_header(key, value)) - - await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) +to_code = setup_online_image diff --git a/esphome/components/online_image/image.py b/esphome/components/online_image/image.py new file mode 100644 index 0000000000..cb86f93e29 --- /dev/null +++ b/esphome/components/online_image/image.py @@ -0,0 +1,152 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import runtime_image +from esphome.components.const import CONF_REQUEST_HEADERS +from esphome.components.http_request import CONF_HTTP_REQUEST_ID, HttpRequestComponent +from esphome.components.image import CONF_TRANSPARENCY, add_metadata +import esphome.config_validation as cv +from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_ON_ERROR, CONF_TYPE, CONF_URL +from esphome.core import Lambda +from esphome.types import ConfigType + +AUTO_LOAD = ["runtime_image"] +DEPENDENCIES = ["http_request"] +CODEOWNERS = ["@guillempages", "@clydebarrow"] + +CONF_ON_DOWNLOAD_FINISHED = "on_download_finished" +CONF_UPDATE = "update" + +online_image_ns = cg.esphome_ns.namespace("online_image") + +OnlineImage = online_image_ns.class_( + "OnlineImage", cg.PollingComponent, runtime_image.RuntimeImage +) + +# Actions +SetUrlAction = online_image_ns.class_( + "OnlineImageSetUrlAction", automation.Action, cg.Parented.template(OnlineImage) +) +ReleaseImageAction = online_image_ns.class_( + "OnlineImageReleaseAction", automation.Action, cg.Parented.template(OnlineImage) +) + + +ONLINE_IMAGE_SCHEMA = ( + runtime_image.runtime_image_schema(OnlineImage) + .extend( + { + # Online Image specific options + cv.GenerateID(CONF_HTTP_REQUEST_ID): cv.use_id(HttpRequestComponent), + cv.Required(CONF_URL): cv.url, + cv.Optional(CONF_BUFFER_SIZE, default=65536): cv.int_range(256, 65536), + cv.Optional(CONF_REQUEST_HEADERS): cv.All( + cv.Schema({cv.string: cv.templatable(cv.string)}) + ), + cv.Optional(CONF_ON_DOWNLOAD_FINISHED): automation.validate_automation({}), + cv.Optional(CONF_ON_ERROR): automation.validate_automation({}), + } + ) + .extend(cv.polling_component_schema("never")) +) + +# Shared schema used by both the (deprecated) top-level `online_image:` key and +# the `image:` `platform: online_image` entry. +ONLINE_IMAGE_CONFIG_SCHEMA = cv.All( + ONLINE_IMAGE_SCHEMA, + cv.require_framework_version( + # esp8266 not supported yet; if enabled in the future, minimum version of 2.7.0 is needed + # esp8266_arduino=cv.Version(2, 7, 0), + esp32_arduino=cv.Version(0, 0, 0), + esp_idf=cv.Version(4, 0, 0), + rp2_arduino=cv.Version(0, 0, 0), + host=cv.Version(0, 0, 0), + ), + runtime_image.validate_runtime_image_settings, +) + + +SET_URL_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(OnlineImage), + cv.Required(CONF_URL): cv.templatable(cv.url), + cv.Optional(CONF_UPDATE, default=True): cv.templatable(bool), + } +) + +RELEASE_IMAGE_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(OnlineImage), + } +) + + +@automation.register_action( + "online_image.set_url", SetUrlAction, SET_URL_SCHEMA, synchronous=True +) +@automation.register_action( + "online_image.release", + ReleaseImageAction, + RELEASE_IMAGE_SCHEMA, + synchronous=True, +) +async def online_image_action_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, paren) + + if CONF_URL in config: + template_ = await cg.templatable(config[CONF_URL], args, cg.std_string) + cg.add(var.set_url(template_)) + if CONF_UPDATE in config: + template_ = await cg.templatable(config[CONF_UPDATE], args, cg.bool_) + cg.add(var.set_update(template_)) + return var + + +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_DOWNLOAD_FINISHED, "add_on_finished_callback", [(bool, "cached")] + ), + automation.CallbackAutomation(CONF_ON_ERROR, "add_on_error_callback"), +) + + +async def setup_online_image(config: ConfigType) -> None: + # Use the enhanced helper function to get all runtime image parameters + settings = await runtime_image.process_runtime_image_config(config) + add_metadata( + config[CONF_ID], + settings.width, + settings.height, + config[CONF_TYPE], + config[CONF_TRANSPARENCY], + ) + + url = config[CONF_URL] + var = cg.new_Pvariable( + config[CONF_ID], + url, + settings.width, + settings.height, + settings.format_enum, + settings.image_type_enum, + settings.transparent, + settings.placeholder or cg.nullptr, + config[CONF_BUFFER_SIZE], + settings.byte_order_big_endian, + ) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_HTTP_REQUEST_ID]) + + for key, value in config.get(CONF_REQUEST_HEADERS, {}).items(): + if isinstance(value, Lambda): + template_ = await cg.templatable(value, [], cg.std_string) + cg.add(var.add_request_header(key, template_)) + else: + cg.add(var.add_request_header(key, value)) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + + +CONFIG_SCHEMA = ONLINE_IMAGE_CONFIG_SCHEMA + +to_code = setup_online_image diff --git a/esphome/config.py b/esphome/config.py index fc8f46909f..976faed447 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -599,6 +599,18 @@ class LoadValidationStep(ConfigValidationStep): CORE.loaded_integrations.add(self.domain) # For platform components, normalize conf before creating MetadataValidationStep if component.is_platform_component: + # Legacy config migration: allow a platform component to rewrite a + # pre-platform-format top-level config (e.g. a bare list or legacy + # dict form) into the normalized list of `platform:` tagged entries. + # Removable deprecation shim hook; no-op for components that do not + # define LEGACY_CONFIG_MIGRATE. + if ( + (migrate := component.legacy_config_migrate) is not None + and self.conf + and not isinstance(self.conf, core.AutoLoad) + and (migrated := migrate(self.conf)) is not None + ): + result[self.domain] = self.conf = migrated if not self.conf: result[self.domain] = self.conf = [] elif not isinstance(self.conf, list): diff --git a/esphome/loader.py b/esphome/loader.py index a9287abf86..22db8b156a 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -135,6 +135,19 @@ class ComponentManifest: """ return getattr(self.module, "FINAL_VALIDATE_SCHEMA", None) + @property + def legacy_config_migrate(self) -> Callable[[ConfigType], ConfigType | None] | None: + """Optional `LEGACY_CONFIG_MIGRATE` callable on a platform component module. + + Called once, before platform entries are processed, with the raw top-level + config for this domain. It may transform a pre-platform-format config (e.g. + a bare list or legacy dict form) into the normalized list of `platform:` + tagged entries and return it. Returning ``None`` means "already in the new + format, leave untouched". This is an intentionally removable deprecation + shim hook. + """ + return getattr(self.module, "LEGACY_CONFIG_MIGRATE", None) + @property def resources(self) -> list[FileResource]: """Return a list of all file resources defined in the package of this component. diff --git a/script/build_language_schema.py b/script/build_language_schema.py index bc97a0d603..f6dcf00851 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -390,16 +390,6 @@ def fix_mapping(): output["mapping"][S_SCHEMAS][S_CONFIG_SCHEMA] = config -def fix_image(): - if "image" not in output: - return - from esphome.components.image import IMAGE_SCHEMA - - config = convert_config(IMAGE_SCHEMA, "image/CONFIG_SCHEMA") - config["is_list"] = True - output["image"][S_SCHEMAS][S_CONFIG_SCHEMA] = config - - def fix_menu(): if "display_menu_base" not in output: return @@ -763,7 +753,6 @@ def build_schema(): fix_font() fix_globals() fix_mapping() - fix_image() add_logger_tags() shrink() fix_menu() diff --git a/tests/component_tests/animation/__init__.py b/tests/component_tests/animation/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/animation/config/anim.apng b/tests/component_tests/animation/config/anim.apng new file mode 100644 index 0000000000000000000000000000000000000000..927af5eb05a94ea8b1cdab493d2bfd8feffb7eac GIT binary patch literal 12626 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_tsI14-?iy0t*k)fqhyqJN3 zu_)8oIUqARnSr5VPU*zm-pq~y?e@a17dx87#KasIO%?1F*dpjNL4$?Uuxb6XqDsz6 znQ}qF=!0ep6mI>{`l5d!Y=an!tKboX zTYVdCnLB5)#olv&U!|$4R-2Gyh7oa6UMEQpWhlN26m)D)jSgdQSAQ{apO?Pi4`h z)m_(bIHk{3(fq{Iy-V@x<4tNV{ii)Pr+)pPAK!aq$MT@N51Xf%U;ZP}a?Msl)aUc> zD<FnGE+hE&XXJ2!HJOsM4X<>^&bX;r(O zZlq{?DX>Hy(Q@=WqJF_BOmK6+QhypCsV0jM@i286ML2<_lHSt%=NQ=wN3n6|NjYGsjE&+&8Yr*(1*e3YXjHR!`4Bz zD*cm$Oph^KH29QxkaynU8$oM76z|z7P-fawvUvMT{t2#}8edPiEq

o;kxmZTaNd1mhcgW+Dkv z%NQ>Ey}#?$sBxZwXZp6|W~v>=78^fnZtR|wR#14F$1YduAlEIv%SD@K=&2rl73FvA z+~R}(1Ao0Od-!GZl+dsxwS}ua%_eP&(#pB6AZ^AnL-L{SCP@QRbq`jiBHM2~2P}?G zIC6e*i=8WHoLgsX-nQncUw(SsTDR!_^$X8Sr-`mnWp-Hob9?AozYnRGwKRe=OwR>7 z9lflrH^p~qXlCz;?W#GSj&AcbQ;PoB%5AXW;Y#1gr3bf}Z9nh4dX3oRVyR8DtY+SQ ze`wjBR}4v_s^3Jt-egbde63@=qj8)0#rOAfq8H|ES(5i+%60QK+XT+dcVE0LTqWMa zdunO(RnF5rf3IImzwvB!?u}Di>lWzdeCK2;77I_@pu0+M_03ax&I~LpjzU+jRK~E@ zH2Up0m&5v6(^;D1)Y&ON)=rsu+j{N;_POgW^zZt6{)gu-aq3dFs_l$JM$yU4Gm-cMOY|9Bs>uwo9&9_CWIWidDa?=Ki_O z!ovB##pt8)sRw(fH0s`s`*1og{6+X5*-1XXV@9v#6o5v-s=?us6b_wwXxY;e}kzLf2)cyYdVxD)OQtPzojCd zcFaDdxzBo&kH&1R&oZ_4Z+;*AUBc7HIz={S??PWkb%t%u+beiE&0@2*Es~pb?ewL_ z1TBWe{yY^1=Nz{4?d^8&{89Wy);gB+F-N7dL-f+aQ!HiAsd&a#xWD+^zE7NG&Eb7_ z*!>%)|Cy>6Ve)VPf~?o=ww7U$*<~vd^Sd+WOBWtx3nJ*dd@sq!r}0M*WD#@FQe=uDaLJ%&iv5_(_Z8zGIZYL`Le~%uZ(qq zlU&g{HkOjYpgA(JHeZ(g^9}g+>8jA73n~o($2P=YpYz^DF3>|2NR z)-eApaND&hF4uRG$`rdE{;j&-4y(N=w9VJ%-+06$E+q2pv=0+fuOwP^9uQ$#v|TY{ zLWuOIi0QWlUfo(`UfOV*XQm@3b9g|S^rPEBrWxX0+Y~>%sn}ZD^8NU2fh$@Y=CWK8 zGqY{awGk5dBEljN&G+kqxchZ6TfWk(G8d&wn7-}Pua5rtg!j~3&wqO-d|EbN@5_QK zVeLg7bK(?5>QJ6tYM%oun#&S&BM1I5NK4%vEf zCT{6s6xvns`-bp^hSg@w_f>iP-<)5(M6Yn_Dy6-(JeE|IHw9=3%p*UzNQ5Tys_< znG^OhKO62J{*#d{-!W6T!;sY|-a+rFc(;))i@&|ZpFa6GLF@MTmdLaHzppcT$j*B$ z#l}*c&2{4PUW*B3h6+MnwT(;e9$hUw!S;{JbL}#b6W>4Pom62wxoc}wxE8XFd!pth%U7z}XHU%vUyr1J6us!_lq0nRTD^_Xoo|t29gYcTKu%q%^sE_0Oe$TG@icpE7>x%I+8M znDsU`$DypU&V~7y?nPZaqubG(6Fhgw3$agRO^b2Z7H0Z4E?(hg^Ijc^#ZD)8Ihwv+ zV)G;+wBY!Ly6pw8`Jd#L_k6z+{-MadR^i$qrjNUWOj(<189rqx3HV4~D>?&({XEI50Esj!PH*R=Pdf?OEHLg4= z+^6OlGFl$V^^!RuUm;_}I!V}V=F91UjVq?x^-C#u23$&bQ}%oE)XIF$=d7RB=r*o6 zn%?K7P`TWx?cj+fBi10p34sYdjXlEiZr@L1yjVY(`S8Ej9PM{a6k-`!xY1Qjm3)t)i9 z|GoCDV*5jfWz2^SD}#UB{oeG~xaj9f{+gXBiMMjzi+FT7uC_R@7w^S1v3v8`vp-ml z@bOvraXisIzEXd}UsHx4Q4ZM?cJb>PmfNs!y_cxESpDjIK}*N%GhFK8SCbt)9m;l} z{LXge&>`;J3#%I6D6eOjP&YNzy+3r}8Vsa}DipK5f1q zd_3r}DDz9VhBw8NY*jK>emuG?(cz0*?Tq%8yZ5xNSvfMdOnz`OcPoclgNklbCu`fn z71N(Y=3F{o=(DR!FKcGCp^we3SqiMoPcMtN{A)OGdpb}tVeyY!@0A%(ZueQEev8ZD zK+wvhnGyGUOIW-0Pg}j(w83?H?_1s_-Gywsw!QFWY|xW!i+OsB|8&piJ>F02dc#$x zo<5S*{jqq;_K6K^o~Bn{z4g9BwrT3xjS(NJZBB}<`|@Vz!$pTqEZBA3W`5JbOCir} zgeL6Su*hJ}iB;FyE9XafNSamcnZ9Sjrl{@Pm$aK4JvmA2@o`y;iY7T#hkh}G``*3h z3&Tx(i(Nl8KVIo>TEO?Mr()*SpvNoTZQ%Ls&GMWxuIGQBfQL3qQ)x9fli}}+f@}IB z_RBDCIFj_|$)t#aI(uowd4^9_xx6GrjpUvy@4HYx>5Js89alnjdF`+Fvo7j8Vf1*_ zpLx=eJ^xj+oD;Nf@N9FwvaK{N`iNPG+$HVk)J8o&DL3~A2KLo9H)p7~<^?#sdL;YM zq&?mJ-*V>8`NDH2tE_0R-5d5I{CaVmj#X1L1KYF@0xf?c+?x+SH4(cwT}qxMDJ3*= zk3{|Tt}Tik)>reL=P*jl?EVNY|WA84p%NtUY9w)ai zNe=n-yZwpp{9l33J$Ef%x9a*2-c=_oQl{)&^Vsr}VVt`|z=mwgCe?O4PPaojLurA{$)$s>4g0@Myq|P-UEXY^8r>Yu+4JsYa7jP^ zB+y+IEB}yT*NWGAhPMxS3nYl=o;)ja=jpk;Xck|W#ijPKhDrt-IX)^^CrAHKy*RO% zvHZ!mylKDwz7x6YoViTXU*Jh&ven+kWJB2^`>FhgESO_&|K7QW0n*aG-uZ6b7e3KB z5{>f(8E#3nOk`Lh$a}xm?s$+pN0!4*H=d$fN^@T1$}E$PTfC%w)4emn=TpD#jku9B zr&##>YRyF5RW@GSN7tU4S@N!eQUjiFr1^q%;tB+^YB_HQ&zB`?_NWb}3xrrF3-Ns65-5|>Yu}K+aZyPb0|Vn6&{#l6Rgc5&hUV`#3?DdNaLLfEk3aif zecJTMsMWUuzkdIEzJGVQ>!-!?>rd&N>W%np!W4h{sq6JJ)#D4?XEV;8tDkdYQQ85K zz(uxs7tY#*PSf1ua_q+JM2!ViZ0vj3C0Z7^Ii0SY>f0jBs-enLw`9$S8A^vlL&IDz zzj|&O8F@V3?QVom`-=A`n0IXxFJ#^~C6`@V?s#td*}2P?O=R|%+}N<-aH;I7^}?El zCSN$t^;JCJaO_=iyyG>O;v5a72&D|AM_+UwA54{9`l)PNVoyomjw^OoG#Uz|Zho9H zjis0osLb+M2!8dkK<>y$X+uhS`uf(zYvH`2amwkPrOQe-GuJV;gOqdT{YMUM*}t4`xrU}igh?1Aa+pL|_=XUV9TD>@Jb>Meg z(p&9>^lAyWYy7JGnZ5>_eDse=Kl!=rUBmTui!=6Lr1u@n{-f;voy)0b;J4u60mb(S-vFzLVN5HV_^FRkLe=wix zC5JHMd8@xIV_;GDp5fHN^w8X6@53chx8^>6Dqm4K@2lec>c;hv0g1AQ{zVFgUVGVe z{`PF{-vw`Mr02LkpL57ERo}3>r(Sm6rOPWePiJF3xjS{{R|UQdo|bfmHv3ld#_94F zl?>kn=l?f+Eo_&6QuNo9lARt}GV@hi%Z}#qMVZY0GxNIsrhg}X+2+sRbm8B!wz`)h zXG|+RN-s1OsH)E{Kk)Q{WP_qyp=R~N509ru&fmCh)4x-{?aP(cbZ_`SOE*1ms@dsJ zt8PBg=Q5(BvcGBVUE6XOaJuBh3$a7@hY7K!&_oh@F-}AJY->C87L&oqQ?)#od z*B(8+{^t+gxc}QGd1YvKBHr@%;|-v|A*yjCB9ZYd$rc#kt}0}wZoJ~++Vw!J?cNY&3*Qc+)Hj- z%(FJj?9ShoC|!Bu)uu|$oDLyXc-8o&(&N0_q8%@Gl;uveR*9@l)9c*+V4od_ z?UQ2p&%cbaU8isQd*bBAnV0_O{5)}M$DwT%K9}-t)@A4`=X{o3xi(>Qzv=%j-Tu|N zsYmX!b?|)rnIhSGF4|t^uH_el30wAE3{c7Ek>lh4+VXw<$6vvA+m7AN-m^FB__Vt@ zoVihonVYwKUAb-cyol;V;cp`K-NoA+)o5yVyH~YHazCB;6+rMm@)!@P7^3UNt z=fhXqcCFbX+OY9l72C&;>3;-X3(ir`OKbUNQT=hrS?M*;mz@6UbG|ZXn`7MGmsiug zvkp&WoY;7NQt8JDXC|HzSU2sg@sGwkpPha+=(QVpcO`mM*DqakHakuD#)^nWY0=eW$9_ zE06zeYdv>TYwD+tmSv^E_tI>|EsxlrNYQzgFCie^-NVAMcey;*Q)}KT9*LDn2`z=k zl&c^7Si4f~%Y_Azf%kTQa@D(OaQcfTBG5F`WG&Nx8H*9z3tsy z-?5QPS$o&M%}a!)SZx0hv^_k>gioRN@r3oUl`@;&c=1Pk>WP$X4_WXe_gEog()@@I zxvy8<(qr^=5SqZE0;bk!GQON4G@%bawNgSkjLFDV!MFaPx5F}qlpFIyg(q+_WqvMy z$Fd~ZL5))tY#2+5OM=h@J%*E70cz0)kZm$pDfVREyu%?hy~CL>jiNM{Xi zhht03;)29a2~GIOn8~ZkI8$lDn*HqCCN-1@cTFY=dZz#i|Xe-#s0o7z)2S z1o6#W)Xu}SIaT7zB9le_4%?Vg1$uJrf|)k)YAF9L5poD-GLq6z-u1j|t3Kn+K1X+s z$cB_fEK35f6vex)owuItL{@&0!=HU~IZ|Tgg)0(b@&MH zUfsy^%Idg-+LgC=?>e*{IACe2Tsm`=CgV&Imizx>a~3%m&2QlOy=4EYw>k|MENoM% zJ#A~kUf4}ITiq6a&db3|IOQ7SPxfARj39Z>q@WS>}GiuYcJt&E7PzY;V>)%a$iU-Yd*_ZM((N z$GERpKCp7ujqJs2O<5^s->dB=8g*?8QGdMs<@Qp!+g_VqFJWUaTzuT=T*vj#or`nJ zvNtlW+kEoJ(gnUVJSuk^M=$;ta?*L0+GGBQS3XrKO?y?a9IxD?(++u`cnvSws6DRF;r}t`)6V=W{^ll*V1bt?1*v)EFtS++}LdzO99 z(2@(cNj4Vvuf3?{>6EVamLk!`pC?SXa@P6L$seZe6Vxn3idJ%lA5N-Vthj7mGMh%r z+OGnGGBrYi;881N3F;{RP^Fn+oFU^aay%ysL7r$pIbEST^TQqA^WgPeY?cXMZ@13^5tuR((+MbNW*Iw&2bbij=wTwsP{ipsr z#gon~5#QE%Xi~}QH8uaguls6yY<=99Jxk7a9jOf$-tnYIzGmeHmB>U-&legmYK*jW zIv>qlYooI2i{{q@6K|aVvgE?LD`l~pxTg8s{~!A}C9X|G`Eq`)wDK>rrQz#;M|qyy znRwmL`Rb+B%N&=62VRg|8oqSS_9<~^+os2r8VcA&Rz)qe_?Wr=Zv2&!y(w1&_pPzm z*v<8Im;MXaxH&tfZerWIw~lL9T5d|>9L40`3u!l=Y;qHB{HU(|X3xtVmzWLMD6>ec#se3)%o=Obgp3J+uPU=wc)v53Qx#TLP zW{GdPVwsSlzE|yYveddyO5dG}Ic$!;TB)4F>8bL4`*s~xx|*XJ>oA6s(0OeXwsA&%N6Wd zK7H0Z+2Sal!*tu@Wihkj(hGvQEN*Jsj(Xb*bkw{wRroN8!SxeoRqPLydXMhS-ZD0e z4y`EOu5Ep)tn%!?l*BSYju*b19K69Fcs6S8+TG!@Pcem6!cb%mPgl{#itfwv7^@O^ zJlM=6a@D?i=4_776@4S-;9+&*l$kzb;W~K_M%TTn9(T?tbXI+{RnrXL;BBz)#_IHM z#kZ_&2 z?auA$%$H?r|KIw(=h>N=*Jp;$`YTIX%Ty;xhbV33+vTNlich8_=UVnI!6|MAN2Cgj+9u4HDDV9~ zr1Om4uZxzV=XF-NY*U)Fa;yHo_cM!mH?wR@J!yHY{&Ka#h0rYRgEu)0Hdf5EDixd? zvtSEHi#^XQo@O1ziMM(tGdtw1_j+cx=+VypRVj7zy~NfFXx7+76op@U&c|}%-^3{) z2k+nW&zN|<^HAN?6Y+eqs+;659C*f{HZ4YfF;6r%Z{od$+E<*4k|Y~8%BuUMeP0#w z&gf6Zsvn+HH`%QXTI#Gaf9e1A!7D!{y8qZJXjiwgGjsO(vnL;Iz2Ld2qSs4&J=>0u zcSQ}I>EBjJycDuJDlqN(za=yI6<;URA3Q7)&6e=Qjw9Pc)UH+DXS)6ROOxYOc6z2R z>sxVWS9D0(udx0UwY~=tX|GoJ_w{T^QC}*X8DG6Qc>BYMs#k88PV+8Vtj{6yIx8Yf z>9=B&(ljP{pD?ESOEMg_8lHSMmd%M;Gh?SL?DRWh%V=*_JNwj{|GQ4*{aAJ?*J7EG z@$t~IXL`{_((jH`&zza8wfo=hhnpT+FIFzuyKL{JXSvHrJ|p4Rr4>{`2jwrg$w z$~g7@p9QD#|8ATnthN2A%E@M>lA0;1(qH~fW54Kk`Pm(_yU*5szj$BX(VW43?lj%D zg-g1_C+6?$S#ahsufy>-cV;slym2g0bWQSlkR>;aIM}{({JVJQ+Wb|2zQwON^zHc6 zt)I8A+VQwt#edQtwxAmySx#!ote<@IxLf#-Un_H$&ApVCX)dpwr#v^OR>CSNcIoo` zaLr4yZTe-uHcX2AG0CjKhg+aaG=h-Jmu|Iw@9y#6yH6sEwAWyJzMNjbGgY6yGuR74q5dsEz7i@ zl+WrBs9sylk!v|CiT&KOs0|i)&G6U&i-dA zvs$PjQWMu8vqy60|M zC#d>WCo#@A}P#PRwr=Y4Y;8kI9{=tU6 z#hMlcQqDJ1mWHnp;$YR()%;!0uxf9#I?sC9ho6}%4(znDj2BUw>whb9U2Ms%cw2od zMeRh{rzd`HJ|`gLe}B#7hC+F29~y)~^!9g*=CK zCwp_*vLBy*Zn3-NCawCN>8660r7tRP72UP=;-@*=w-%Qv=SjT#_9;92BU1^}{6hzM ze+pik$*?nYsdsznlB?@lTbL?^9Bwpj?)1}(UDF(xuE{)Qo#GD#bEZRw=BLh$m}(x9 zc)t6>RB4XaLJo^IOt5K~Gl8S~ICqD~kG1cpuuN|g4WZXG?=SrLP#9pQU3uZ{$wJg@`S$WW)qNluVMd`iH|V zNa*ri)?fXt@{Z7x;4a(G^3R|AGMVxzPI8uSwdw>*T}8v60xHL&{aoHSzfhT>v1Ib6 zDU6#!{C-)8J-;wbuSq}6>$}v>1K*D*>QB71>bZ^4!(aC^mvn^2Htx9p*yBunpWCCK zasH`iO;x0ypZk~Rs&xKm_{8oHKCH|7E2L6iWrzn(ljk^Ja!%&5gP$kcJFey@ylvhQEFrrymgd9u7#)M`3k zwXXG>T9ZW^t}~pJKj1k-FU{(2CC8+tspng7ChvT?b89)%(yKf6U;J)u8*fs1qAOQ3 zNmA|oqC3k!<(nG*7wT2o?=Cm#ZjpXk*Kht7r)c&Qt4!aF@aDb1iB;#g!`K!W==EukwDO^*1eZ`%pLq~*HkN=}{ zhuNP}=AAeE6+X){&y2CTZ?3TSr0FciNNJV{&%}b({bd(F_+2H@GBo|waOpjs@?Y$ztqRob1l3CJ7;oRSK z@5L1ygVk$Ui$u0>>S@flc=`Qdc9;9pLrb13`ZkoTYFS|I%eJj2yJ1IO>|5yxTaG&9 zZESp!p6V-m-a#ioZqpCHyIqOO3c1cJc)CmHl>L?8=Mc*O#7=yI^QMgsQ^YQ5PH6n{ zWS&R4Lr-hM?%ov*B0nA;`#C*V;aQ$*o!uec6p3qB7SE5$?|x!~2&qU`+X1+f#a6)^lPV^&yh&1PiDc4=;F%Jw_=4>;Z9WeHkWlBYOV zW9@c_m|Vlo#L}~Ve6|iBSf(<5YN%^k)$e!riQ-+RkI_E*4K9zo?}#n3PEO{2X(&)H zC&ad7>TbWPg*)asb22pj=J@kk@wWVW=1E#LYpNCINIWw)@!(l~m+_ zy|~B0=umNOk>&(#29=q&MBZNMOkL)u#B|BKQ>>4{u(7B5rj$cPv6Q{Qb?5m!E@gUe z#V0V{&$(Nz!KCuRaNjM-zY!(H3br%rzB8<9>y*iNjQ_}+h>cp4SL7*J`(0)1 zOtEG0yPI?VirR#GITKAhyjO0Yu#cHD{!nQ{g;;jm1*fKlDPrfYwBCLAz31etDvJr> z#~pTU6!`x;S)qB`q`roj4UHefLO1R0FisIIw-tVI<+#JG4O8-JI^;RZH0FMd>?pk} z;o;x7BkxT@_rZ?_3d!7=d`Iv6xclqueYO*;n2*>r8M0ln&g414dgRQPu=BSa)GUJU zf8M0Ev+;+T_;H6tV!TIqqWfBPRvY>65E95vmv2a^bx4`~aPz7F(a)vV*sg^Xe!RpG zw1zG6)TUW6F`pR@=K9;P+;aH0Voh%U>O0SVbDk*VeWFkzB_Hi^Wm8PL;#Y?hz3#>* z$DPGaXX*yVHe1GLJDjq<Z= zwVK?kXq|8&UjC~&y=nKVY!?BAqRpmi zF_o{5upZg1{j!m-VaDH0U+%qMAuGDUS7#N=i`=Bw+kIj$?=Ih_{5I$6t#>=4f1Yi4 ztmm*Jd}r0UR7vq%K?m(krFzdc%{%^=-EYcMhR*cpJKN3Qu|K-!G1L6@Fabodh3yj z^w(wEr25<6{jJ-6@AuukSARr);rYMq_MXSadRtmwv8L{SS^XsGaq_pja%UfYEO_*0 z)7s|?D#WiY{XDO@a+`Mf-8~Pc6}@fZP1yA8b&Fc`^R>sy_b&K!d;WpE2R~<8fAnBy zei*1eZ=KtIN&E0=56_-X34E2*-1{SR#i6rJVg5&#=PY+%h|m6^_HRz3`74coMWU*) zyi5~s{MoM&Jbmx|;%K>5>-#R;414UoA|bP(Ak#vB!u8T4f%%H>PI2&7=gv1f_uVP7 zw2^cDvI`Q&SVPTCiYxn~_O^0Nn9L`TtC-8sY3X3`#?&t5m09eCqc?lzXX`CAzw+w4 zqq*^dxBXxDepa2Aczo+j`}h9x&%$4QpAmnV@6JY{HpR>8s|#K&Tfkj&;@RYRXMap= zzv18|%M;0ya_PQ`TQHl?J|V<;LmK!|xw5^buj}5V5Jgou~b8 zQ)*e;Sueng4g^dW{>7B2kYQCFEXT@F;25imEfMW$O73FU>V)e=mr=x#!TK z?4Tn{KDDjRxwksW^|SuY6b`R@nFqIiFtLnXenPu6Xil}~ro8??zINxko^RX4?0(Zm zQA6qS)ylnpr_Qmis|Xf67M2@#R(Y2RPf&UJL|f!PF;U`uIB4!J2ib)zONFq zWNVQzcMmRjQ1L!8f{kluPBqK5gWbVzns;tc5r642F=<_Uw)nIh#*UR=PNq67ExD&C za_j7x|Eu`w-y5V;e!G4zX!D;dx}{;;c-k5l9lacXkYn-|J7vZEJbkbAYQmGfywH4OI2))#LOb`rkxt| zZyXP3`Kjpn&x1Wf_;pL0rp|4no|XEmj~q$d*!L$jC}dLi-QvPWZdVsQ{~w`b}{v&dAuEdKSe*`JeVozU4`DX%m39vd8|H-_&1xjaO54ny2%==Um76lx8t; ze3OiEo~zTWti5h=-=ZU&-Z9KCcd04_J=xb$$fus*z3>FXPEPBpu6+VR!j(MfPk#J7 zt~$|1Vg1j)6DKg|*3V1NiF{M`xy0k3%zgD;Cu<|xG|pLCI3&pytvJ2)RqksKQ}!nQ zqmo{H@lQ7L1&ZrOF`m(R)u?X#`OyLiAO!o4os%EV)8BV+&pGdTi6!nz zcFIdjWwKKg;}qU{$0iw1vogOh&8}I*ChW(9hTiHw5+@?0n19-zIwEpO-qZRBXfoE* L)z4*}Q$iB}JU+F8 literal 0 HcmV?d00001 diff --git a/tests/component_tests/animation/config/anim.gif b/tests/component_tests/animation/config/anim.gif new file mode 100644 index 0000000000000000000000000000000000000000..9932e774483eb3516bec26187591b997e6d41859 GIT binary patch literal 9735 zcmZ?wbhEHbOkqf2_|5 z$!u&{Y;0K^?Ah#`rR=QLZ0r>r?Dgy%bsQYc?CgE)>`ff(ogD0~>>Mo|9G#pTv)S3F zaj?(kV4us*Ih~VZ4hP3V4z|^t>`OVhmT+(`;^bJ(!MTouV?8J5I!?}Q9PHb;*fw)= zZs+9K#>KIXi)$+v*G^8(9bBBdxVZLkaqZ&bJj}&$gq!mSH|Gg%&ePl+r?@yzb90^J z<~+;IeU6L!BoEhlZl2TJTo<{yE^%>P<>tJ^!+nj1>moPLC2sD!++6p$x$g0B-{t1H z%gyzWoAVJj=Rm)%kzns z=MN9}4<4SMyu818d4KWq{N?5O&&%_NkLN!h&p$ri|9rgv`Fa2H^8MlC`^C!xih@x* zKp~*`pWDwhB-q(8z|~04fSHkjfkE*n3j>(`&+Y5(ZQtmB7#J+RGB7Yt zK!~Z#XJFuOVPM#HI3hAEN&##rL(+5xhVAVP3=%gB5{nYSV$2K-3`}Wh3=E%^GcfQ* zGB5~VU|`^iPR=OGh08N6zRti9Qq92NbC!W&<}3yVwKN6>{ul-wkP1+_&A{=WfssST zW5a@j%^bp7F()=GJlrmz>@~+@n{q4@7#ioZaB;|TZCP<~vBzYs*k8pOfeUP-n^JxRI-Fm?wn^^B zsjaK8uTMDKCF?yc`x=uyyGLxq)pSN?b^(`1KNbfQ4l^;SY>QG@*K|TyBjUmWMdoa7F@qEH ze0OhqdwWOm{kF4FhqJgvHC)0z%yf8h#Ew_N!uH@HR(>Uy88hVe?)v)r#^&t%=l1^k z^8TWJ#Qzx&8g?);WeID^1z0sUKa=xW(s3}Ut@gL!r`Pw-@8AFbKLb<6{aV8%2~8Z5 z6Q&t3=`kMYS0z(=wmU%viSa*{qZ<%QT5#nMP+WMUL$Y3PY<(o-!_vRB+<4 zHV}L!(<1ch*}@LBRWBCxm{nygEbcjZgwJqG#689eI}L}-<(VIz^Tlss%v_k{6s5It zsa@8qm5bJCy;|9J=)YCg!UDTYCbsE|rl&i06~1a^B=vsCj422}mo* zY~C$&MSDJ9iohF@!&BLF1l+ zgPWgay;{B8x^Cv1MMqa@z24s;$F06sEn~v#o~mE3()Tx?d-dXoNVv_0g zcEDhL^*7B!eNA1n+EtIqob^L#n&tY5RvK|qN&1+O3QHxqVHR$$Lw0Vm6OoTe8Ob`ySH+ z%h+OHUUz#P`8hL{z3SVo3iVrGZwF=nw=%!!nLhjb1Ln;S%dWV#_rAMPn!MNKVfJ=j z(>qYEV-Re;_N5T#zT85$d`#v_IfFLL92WJ+z+r2cr(A3K`f9bmr%L|P9l}s!P zS9z4~V4k+2^vCYmT+SL!;{WoRG<+WN$XD$BT4>OAYPRC_|1KFe-*WXkoOC1vl`|}5 zrk$zTm3{eCg@oO2$&aO0DN*OoY_S)+bgrHAjly@W6{ZGzSNg>EO!>9u$6@7Xc?G## zlO>l&9FW^&*pu>^f%W1vbIo__`f8F7$o^k`Ag8tKXq02 z#3~zST5#+unlyikD#ywfUY=$fC*=D);#z*)Np04J&g3VHjkvF$iI@94d8N-H-MO63 z=3JMXK53_^H*y}y$r7IMsOBN(XLDcGZJ(y)@+>g9z0)__Zt|r2SMDnv=KSw(Q#HBA zIOfSo9#htMsn7FnoO%8sL(^h=>4J`rDswHrrXMrV5$ej8d2X7#GT^TxmruM+hWS&= z^O>I}&rbe)SfSzIA@=Qc;M zH%~fbwajncHty(;U8_P$+14$cmc86??=;2br$Y12a(jLKED`p5$<=kUu7>%2d|Fid?mVMS_8s_uQw&3kt(XHt4FX}ZwKWyh?( zZH%{?Jo|via?7hH8M%d4-_O~?z!j10?*8K9B(7OYF3tM3#qF11>g*J4&BSS5vt19g z2vsZc{$IGlWyjgId5$GYd@DOxrZoxX2s+JT-jU8EIl+&Mtwdgo<;Bhmz3b{{SU3NX z>0sv43g={Wka}uxoJo?Sv1FMsld!{b>+6h+RoA;^zec6AJZ;UdI2Jrl;$@aePb6Pf z$mGU<|I(Ou9puen^LfB@c$pcS+J+pqH?yz)Xk$9GQoq?`m&JAyHlxXn2hz;WJmkwy zW1Kg0L-Uo*a%)W3^cy@2yPh9P5?wvxXtO3ymD6X-lBp8f1)AM`SnLxq9U{3D5ZAaw=Cv#y_cX14n$$Jv6u&2t{h2;gY0{RcJInBRgbOa=i4P{W6jfeA)~5rNR)*QEXvLRYBn)5Lc~C91_n^0hmnDS$pM0)>UqKHSr`}?D!}?h;Cx0fUlPuj z1M?NZd|w6z21ZbWh@ZicA%!8IAqm`A31G-z$Y&^F$Y&@9vkDj(7znF?x)QZ%st9kJ zg00m6TMO!HFxh_qvl&L)tcbSkXqz>X=55w?P-ljRfk~8+QHGIGfss*%kqHp@o65 zg@K`!fuV(wp^br|oq@5PfuW6&p`DSjg^8h+iK&&Dv7L#bjhUgHnW2e=v4w@9gMp!w zfuVzuv6GRpi-DnoiJ_B;v6GppiwRmHj8e1=0m6N@jf;=>^D}oq`)t9hVon+zaGAuE z1nINQwkUpe#M5Yr$4urXAqg*7pY2ph#)Soj?pA*^9WokMM{Lfz3+c0Mw~|$Gcy(xg z7`KE$!Gg87w--DRPjQk&sJP%TcrHRjZVpq~EMK zyzkVStt;%LUv0Zow)*w<2Sr+Xt5arG=Iz?Jt~z`5=|j@**5A^4y>{>QN7n21z5ld( z<(`XtCTqK1aNSWm_(Up4?+}-LSMH`S@1!@*=V9jgc(gVxrtpBw=9h)Lcjn0$O&5Ot z+mK_~yq-<_-^jhvJAHN69IZ2-C#{h^%X!=8@d=~pcQ&1OW71u-TTQt3^D0aB*!7oO zrOxVJa(32TeC6r6n5|b0lHP8(W?Fo9(RC)-JzH<+PU3C2nYj4vYMz__eivTK>+dZ- zm}*~Bb~pd}f35B31U{YlzNXxKj`PZTxBBum|NYfp zAIDoI&MHm+x$^#_^tl?dGSyCQofUtnzwWo)CE@=IzNy9k)GuN`%s+u^g$dg!PL`5i z^Zy2^I5-HNZER|CU=g&?(em}E&zmeinJ>d;o9g;9r9%@tPna~YuYDIS+qIA$B zhp_hKTKc4_IiGNn`eZm&QY9`ySo-nW+`i^O?&x<;C%vL3P0HTM6)*Ku{J@`y8i|}s zHQYVD7EM^%&nxMfFz?couw9>~uB++tNqH>n|L)1O9cPy6rC*xdvE|A113b$NyqP8? zNqwGi!e^P$^pj`OtUk}Y(Bk1RIzr08ATl~aI=Dwj+rd37ZblX{MrKJSb~Pq8EhY{f zW)4$kP8$|(R~8;07TzFMzDQR76gK`WR)J(T!E6qpTsGliHj!#}(Pj?O7Iv{tPO%OS z$$oap=^PRh*`;Q4NYCSxTEr={hD&ZMxBPA%g}vPJ2l(XAaw{I?QaZ~6Lg#pu&+#ao zRn#d`#fs*c~tN7sy^gV zeZ;Hwm`n8ux7t%~wdY*w&v{gz@TfoIQGLv#{)%7yHILdGUiG)U>hE~f-|%X@=Fxb| zr}>6g^F4^I{)tchGmrWg9<{H$>fiX(Kl5sQ;e41Z)wZ8CZ zeB;&p#;f_AU*j8}<~Kf#@BA9yc{RRpYyRZc{LQ2BgIDt>zvd4FW+t%FNoN;+o z?CEK_O)RNLl~fHDq%yQIvIyuJm^C)BHZXI``Rv&6@NkDPcNoL>16Oy+i`J=RF*r7{ z^2j+f2qb<^W1r{T4QXvIQFo~L(7>3?(!bs7TJ(WWP3*!&;R_NLZfE5>(9A9Gw`a%4 z$H&V*zmv5oZ)Po6f68yh^aG8jcW7sv*s!qq`+Hfl4p?jRa{qHXxjp3{rL=r182&Qc zJ;A>E`aau(PUZaaEecE=EMh+nHY&)hc+kXS_ToXafL6qV7WS$QjjW$_8KhGIsWLe%C^B_gcvR=pseW#f&72A)3l7$B{iz7Ab5dHsz#^RTfss@H zLxPJ~McT#|K{uU;&*wL=X}ws`BK9kNe%-gu32bDM?VJ^c81v~h;>UGRc=~W z?!}TUarA|ubX!38bfd-M_H64+%>wInRFZd>SSZQWt?nyJxg)V~$8qIvhMx0&g^4on z+~&CJLI<05%5s~ zl})P3dG=GTZ2TP?58oG+{(8iH+Q+J*prxGkJNCuAn$%nMN>#?1b6bL6(TqEv6E3Um zuyEJ=+VOjh>CgK|vJ{ zwxrm(@BDx0duHyu313un-)FA+x>k7l%A)7im1ZvWlRk$%dn{rltaY~RU2y9op9l8O zj~x%OGoIofo#CjzN=xToF++6Aie*77O)`WgEU~y9v*t?cC5v-KIl{4vt!Ho3f0_AZ zq3qRXT8CEzR8+l5Pnoq+pWD)V;jSrD4X(-sIWD-8%_h?Mf_bGVcSS&mVfLy})))GT zGp;h;+BoC*m+0FKVqtx!F82GsRp5$Vxw-1+m4)6@RU`LnoXQisyt@9+;n0;E zTo-jW%k_0t$TqQS%c_^A#Q*(uZoQS*iu|SWpVguR+S<5ETaM~*UX%zw_snymyKHdA z=P17ftF2p>on_j$#9;5t|5^uDCA%tR3QI=^p53;sKlPc2>h0T;+PbzGN;qC~k&dh{ z+`OaWlX%g`uG_YCddxzq8YQ;3BVKh)EuHTzakC}VvCU{=rG53mBL8VYhdRqEpFGvu z^rKSeKG*BS=c}}ivVGV47-W>fz!(0=iFG4ij6%Y^!#Ap#&pfdIw7SpDLVSzBoqJ3% zYSI5(bDO2x)+aV3)f;`&%q~k_neqG13qSL024UG< z3wjddvb^H|Et*kv`}e(uh2IlJKFng4IB@sJLccvvZKrLM`163ne@En>zxn0+cgH$i zz<+GcW`7A`@DK26*G=c&rejx*_g WpJ(0v^UUJ>&U5AKJ~uEhSOWldK6Y9F literal 0 HcmV?d00001 diff --git a/tests/component_tests/animation/config/animation_platform_test.yaml b/tests/component_tests/animation/config/animation_platform_test.yaml new file mode 100644 index 0000000000..380434dcc3 --- /dev/null +++ b/tests/component_tests/animation/config/animation_platform_test.yaml @@ -0,0 +1,30 @@ +# New `image:` `platform: animation` form. Exercises animation/image.py through +# the real platform loader and codegen pipeline. +esphome: + name: test + +esp32: + board: esp32s3box + +image: + - platform: animation + id: test_animation + file: anim.gif + type: rgb565 + loop: + start_frame: 0 + end_frame: 2 + repeat: 3 + - platform: animation + id: test_animation_no_loop + file: anim.gif + type: rgb565 + +spi: + mosi_pin: 6 + clk_pin: 7 + +display: + - platform: mipi_spi + id: lcd_display + model: s3box diff --git a/tests/component_tests/animation/config/animation_test.yaml b/tests/component_tests/animation/config/animation_test.yaml new file mode 100644 index 0000000000..9d8fd15276 --- /dev/null +++ b/tests/component_tests/animation/config/animation_test.yaml @@ -0,0 +1,25 @@ +# Legacy top-level `animation:` form. Exercises the deprecation shim and the +# shared codegen path through the real read_config/codegen pipeline. +esphome: + name: test + +esp32: + board: esp32s3box + +animation: + - id: test_animation + file: anim.gif + type: rgb565 + loop: + start_frame: 0 + end_frame: 2 + repeat: 3 + +spi: + mosi_pin: 6 + clk_pin: 7 + +display: + - platform: mipi_spi + id: lcd_display + model: s3box diff --git a/tests/component_tests/animation/test_init.py b/tests/component_tests/animation/test_init.py new file mode 100644 index 0000000000..1b5dd0d54c --- /dev/null +++ b/tests/component_tests/animation/test_init.py @@ -0,0 +1,81 @@ +"""Tests for the animation image platform and the legacy `animation:` shim.""" + +from __future__ import annotations + +from collections.abc import Callable +import logging +from pathlib import Path + +import pytest + +from esphome.components.animation import ( + DOMAIN, + LEGACY_REMOVAL_VERSION, + _capture_legacy_entry, + _warn_legacy_animation, +) +from esphome.core import CORE +from esphome.types import ConfigType + +# --------------------------------------------------------------------------- +# Legacy top-level `animation:` deprecation shim -- REMOVE these tests after +# 2027.1.0 together with the shim in esphome/components/animation/__init__.py. +# --------------------------------------------------------------------------- + + +def test_warn_legacy_animation_warns_once( + caplog: pytest.LogCaptureFixture, +) -> None: + """The deprecation warning fires exactly once and never mutates the config.""" + config: ConfigType = {"id": "test_animation", "file": "anim.gif", "type": "rgb565"} + + # A per-entry capture (CONFIG_SCHEMA step) records the raw entry so the + # one-shot warning can print a pasteable migrated block. + assert _capture_legacy_entry(config) is config + + with caplog.at_level(logging.WARNING): + # First call: flag not yet set -> warns and records the flag. + assert _warn_legacy_animation(config) is config + # Second call: flag already set -> stays silent (the dedup branch). + assert _warn_legacy_animation(config) is config + + assert CORE.data[DOMAIN]["legacy_warning_shown"] is True + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert "deprecated" in caplog.text + assert "platform: animation" in caplog.text + assert LEGACY_REMOVAL_VERSION in caplog.text + + +def test_legacy_animation_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + """The legacy `animation:` block validates, warns, and generates codegen + through the real read_config/codegen pipeline.""" + with caplog.at_level(logging.WARNING): + main_cpp = generate_main(component_config_path("animation_test.yaml")) + + # Deprecation warning surfaced through the real validation pipeline. + assert "animation" in caplog.text + assert "deprecated" in caplog.text + + # setup_animation ran: Animation object constructed and loop configured. + assert "new(test_animation) animation::Animation(" in main_cpp + assert "test_animation->set_loop(0, 2, 3);" in main_cpp + + +def test_animation_platform_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The `image:` `platform: animation` form generates codegen through the + real platform loader (animation/image.py) without any deprecation warning.""" + main_cpp = generate_main(component_config_path("animation_platform_test.yaml")) + + assert "new(test_animation) animation::Animation(" in main_cpp + assert "test_animation->set_loop(0, 2, 3);" in main_cpp + # The loop-less entry constructs the object but never configures a loop. + assert "new(test_animation_no_loop) animation::Animation(" in main_cpp + assert "test_animation_no_loop->set_loop(" not in main_cpp diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index f7f60a1f4d..78462463b1 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable +import logging from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch @@ -11,28 +12,36 @@ from PIL import Image as PILImage import pytest from esphome import config_validation as cv +from esphome.components.const import CONF_BYTE_ORDER +from esphome.components.file import image as file_image +from esphome.components.file.image import validate_image_final, write_image from esphome.components.image import ( CONF_ALPHA_CHANNEL, CONF_INVERT_ALPHA, CONF_OPAQUE, CONF_TRANSPARENCY, - CONFIG_SCHEMA, + PLATFORM_FILE, + _flatten_legacy_image_config, + _is_legacy_image_format, + _is_new_image_format, + _migrate_legacy_image_config, get_all_image_metadata, get_image_metadata, - write_image, ) -from esphome.const import CONF_DITHER, CONF_FILE, CONF_ID, CONF_RAW_DATA_ID, CONF_TYPE +from esphome.const import ( + CONF_DITHER, + CONF_FILE, + CONF_ID, + CONF_PLATFORM, + CONF_RAW_DATA_ID, + CONF_TYPE, +) from esphome.core import CORE @pytest.mark.parametrize( ("config", "error_match"), [ - pytest.param( - "a string", - "Badly formed image configuration, expected a list or a dictionary", - id="invalid_string_config", - ), pytest.param( {"id": "image_id", "type": "rgb565"}, r"required key not provided @ data\['file'\]", @@ -43,6 +52,11 @@ from esphome.core import CORE r"required key not provided @ data\['id'\]", id="missing_id", ), + pytest.param( + {"id": "image_id", "file": "image.png"}, + r"required key not provided @ data\['type'\]", + id="missing_type", + ), pytest.param( {"id": "mdi_id", "file": "mdi:weather-##", "type": "rgb565"}, "Could not parse mdi icon name", @@ -84,155 +98,301 @@ from esphome.core import CORE "File can't be opened as image", id="invalid_image_file", ), - pytest.param( - {"defaults": {}, "images": [{"id": "image_id", "file": "image.png"}]}, - "Type is required either in the image config or in the defaults", - id="missing_type_in_defaults", - ), ], ) -def test_image_configuration_errors( +def test_file_platform_configuration_errors( config: Any, error_match: str, ) -> None: - """Test detection of invalid configuration.""" + """Invalid single-entry ``platform: file`` configs are rejected.""" with pytest.raises(cv.Invalid, match=error_match): - CONFIG_SCHEMA(config) + file_image.CONFIG_SCHEMA(config) + + +def test_file_platform_configuration_success() -> None: + """A fully-specified ``platform: file`` entry validates and keeps its keys.""" + result = file_image.CONFIG_SCHEMA( + { + "id": "image_id", + "file": "image.png", + "type": "rgb565", + "transparency": "chroma_key", + "byte_order": "little_endian", + "dither": "FloydSteinberg", + "resize": "100x100", + "invert_alpha": False, + } + ) + for key in (CONF_TYPE, CONF_ID, CONF_TRANSPARENCY, CONF_RAW_DATA_ID): + assert key in result, f"Missing key {key} in validated image configuration" + + +# --------------------------------------------------------------------------- +# Legacy `image:` config migration -- REMOVE these tests after 2027.1.0 together +# with the migration shim in esphome/components/image/__init__.py. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("config", "expected"), + [ + pytest.param( + [{CONF_PLATFORM: "file", "id": "a"}], True, id="new_platform_list" + ), + pytest.param([], True, id="empty_list"), + pytest.param([{"id": "a", "file": "x.png"}], False, id="legacy_bare_list"), + pytest.param([{CONF_PLATFORM: "file"}, {"id": "a"}], False, id="mixed_list"), + pytest.param( + [{CONF_PLATFORM: "file"}, "not-a-dict"], False, id="non_dict_entry" + ), + pytest.param({"defaults": {}}, False, id="legacy_dict"), + ], +) +def test_is_new_image_format(config: object, expected: bool) -> None: + assert _is_new_image_format(config) is expected + + +def test_flatten_bare_list_filters_non_dicts() -> None: + out = _flatten_legacy_image_config( + [{"id": "a", "file": "x.png", "type": "binary"}, "not-a-dict"] + ) + assert out == [{"id": "a", "file": "x.png", "type": "binary"}] + + +def test_flatten_non_dict_non_list_yields_nothing() -> None: + assert _flatten_legacy_image_config("a string") == [] + + +def test_flatten_single_dict_with_id() -> None: + config = {"id": "a", "file": "x.png", "type": "binary"} + assert _flatten_legacy_image_config(config) == [config] + + +def test_flatten_single_dict_with_file_only() -> None: + config = {"file": "x.png", "type": "binary"} + assert _flatten_legacy_image_config(config) == [config] + + +def test_flatten_defaults_images_list() -> None: + out = _flatten_legacy_image_config( + { + "defaults": {"type": "rgb565", "byte_order": "little_endian"}, + "images": [{"id": "a", "file": "x.png"}], + } + ) + assert out == [ + { + "id": "a", + "file": "x.png", + "type": "rgb565", + "byte_order": "little_endian", + } + ] + + +def test_flatten_defaults_images_single_dict() -> None: + out = _flatten_legacy_image_config( + { + "defaults": {"type": "rgb565"}, + "images": {"id": "a", "file": "x.png"}, + } + ) + assert out == [{"id": "a", "file": "x.png", "type": "rgb565"}] + + +def test_flatten_type_grouped_list() -> None: + out = _flatten_legacy_image_config({"binary": [{"id": "a", "file": "x.png"}]}) + assert out == [{"id": "a", "file": "x.png", "type": "binary"}] + + +def test_flatten_type_grouped_transparency_list() -> None: + out = _flatten_legacy_image_config( + {"rgb565": {"alpha_channel": [{"id": "a", "file": "x.png"}]}} + ) + assert out == [ + { + "id": "a", + "file": "x.png", + "type": "rgb565", + "transparency": "alpha_channel", + } + ] + + +def test_flatten_type_grouped_transparency_single_dict() -> None: + out = _flatten_legacy_image_config( + {"rgb565": {"alpha_channel": {"id": "a", "file": "x.png"}}} + ) + assert out == [ + { + "id": "a", + "file": "x.png", + "type": "rgb565", + "transparency": "alpha_channel", + } + ] + + +def test_flatten_type_grouped_dict_without_transparency() -> None: + out = _flatten_legacy_image_config({"binary": {"id": "a", "file": "x.png"}}) + assert out == [{"id": "a", "file": "x.png", "type": "binary"}] + + +def test_flatten_drops_byte_order_for_non_endian_type() -> None: + out = _flatten_legacy_image_config( + { + "defaults": {"byte_order": "little_endian"}, + "binary": [{"id": "a", "file": "x.png"}], + } + ) + assert out == [{"id": "a", "file": "x.png", "type": "binary"}] + assert CONF_BYTE_ORDER not in out[0] + + +def test_flatten_keeps_byte_order_for_endian_type() -> None: + out = _flatten_legacy_image_config( + { + "defaults": {"byte_order": "little_endian"}, + "rgb565": [{"id": "a", "file": "x.png"}], + } + ) + assert out[0][CONF_BYTE_ORDER] == "little_endian" + + +def test_flatten_skips_meta_and_unknown_keys() -> None: + out = _flatten_legacy_image_config( + { + "defaults": {"type": "binary"}, + "images": [], + "not_a_type": [{"id": "a", "file": "x.png"}], + } + ) + assert out == [] + + +def test_flatten_images_list_skips_non_dict_entries() -> None: + out = _flatten_legacy_image_config( + { + "defaults": {"type": "binary"}, + "images": [{"id": "a", "file": "x.png"}, "not-a-dict"], + } + ) + assert out == [{"id": "a", "file": "x.png", "type": "binary"}] + + +def test_flatten_type_grouped_list_skips_non_dict_entries() -> None: + out = _flatten_legacy_image_config( + {"binary": [{"id": "a", "file": "x.png"}, "not-a-dict"]} + ) + assert out == [{"id": "a", "file": "x.png", "type": "binary"}] + + +def test_flatten_type_grouped_scalar_value_is_ignored() -> None: + # A known type key whose value is neither a list nor a dict yields nothing. + assert _flatten_legacy_image_config({"binary": "not-a-list-or-dict"}) == [] + + +def test_flatten_type_grouped_transparency_skips_non_dict_entries() -> None: + out = _flatten_legacy_image_config( + {"rgb565": {"alpha_channel": [{"id": "a", "file": "x.png"}, "not-a-dict"]}} + ) + assert out == [ + { + "id": "a", + "file": "x.png", + "type": "rgb565", + "transparency": "alpha_channel", + } + ] + + +def test_migrate_returns_none_for_new_format() -> None: + assert _migrate_legacy_image_config([{CONF_PLATFORM: "file", "id": "a"}]) is None + + +def test_migrate_legacy_warns_and_prepends_platform( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING): + out = _migrate_legacy_image_config( + [{"id": "a", "file": "x.png", "type": "binary"}] + ) + assert out == [ + {CONF_PLATFORM: PLATFORM_FILE, "id": "a", "file": "x.png", "type": "binary"} + ] + assert "deprecated" in caplog.text + assert f"platform: {PLATFORM_FILE}" in caplog.text + + +@pytest.mark.parametrize( + ("config", "expected"), + [ + # Recognised legacy shapes -> migrate. + pytest.param([{"id": "a", "file": "x.png"}], True, id="bare_list_of_dicts"), + pytest.param({"id": "a", "file": "x.png"}, True, id="single_image_dict"), + pytest.param({"file": "x.png"}, True, id="single_dict_file_only"), + pytest.param({"defaults": {}, "images": []}, True, id="defaults_images"), + pytest.param({"rgb565": [{"id": "a"}]}, True, id="type_grouped"), + # Shapes the legacy schema never accepted -> not migrated. + pytest.param([], False, id="empty_list"), + pytest.param(["bad"], False, id="list_with_non_dict"), + pytest.param([{"id": "a"}, "bad"], False, id="list_mixed_dict_and_non_dict"), + pytest.param( + [{CONF_PLATFORM: "file", "id": "a"}], False, id="already_platform_tagged" + ), + pytest.param({"foo": 1}, False, id="dict_unknown_keys"), + pytest.param("a string", False, id="scalar"), + ], +) +def test_is_legacy_image_format(config: object, expected: bool) -> None: + assert _is_legacy_image_format(config) is expected @pytest.mark.parametrize( "config", [ - pytest.param( - { - "id": "image_id", - "file": "image.png", - "type": "rgb565", - "transparency": "chroma_key", - "byte_order": "little_endian", - "dither": "FloydSteinberg", - "resize": "100x100", - "invert_alpha": False, - }, - id="single_image_all_options", - ), - pytest.param( - [ - { - "id": "image_id", - "file": "image.png", - "type": "binary", - } - ], - id="list_of_images", - ), - pytest.param( - { - "defaults": { - "type": "rgb565", - "transparency": "chroma_key", - "byte_order": "little_endian", - "dither": "FloydSteinberg", - "resize": "100x100", - "invert_alpha": False, - }, - "images": [ - { - "id": "image_id", - "file": "image.png", - } - ], - }, - id="images_with_defaults", - ), - pytest.param( - { - "rgb565": { - "alpha_channel": [ - { - "id": "image_id", - "file": "image.png", - "transparency": "alpha_channel", - "byte_order": "little_endian", - "dither": "FloydSteinberg", - "resize": "100x100", - "invert_alpha": False, - } - ] - }, - "binary": [ - { - "id": "image_id", - "file": "image.png", - "transparency": "opaque", - "dither": "FloydSteinberg", - "resize": "100x100", - "invert_alpha": False, - } - ], - }, - id="type_based_organization", - ), - pytest.param( - { - "defaults": { - "type": "binary", - "transparency": "chroma_key", - "byte_order": "little_endian", - "dither": "FloydSteinberg", - "resize": "100x100", - "invert_alpha": False, - }, - "rgb565": { - "alpha_channel": [ - { - "id": "image_id", - "file": "image.png", - "transparency": "alpha_channel", - "dither": "none", - } - ] - }, - "binary": [ - { - "id": "image_id", - "file": "image.png", - "transparency": "opaque", - } - ], - }, - id="type_based_with_defaults", - ), - pytest.param( - { - "defaults": { - "type": "rgb565", - "transparency": "alpha_channel", - }, - "binary": { - "opaque": [ - { - "id": "image_id", - "file": "image.png", - } - ], - }, - }, - id="binary_with_defaults", - ), + pytest.param(["bad"], id="list_with_non_dict"), + pytest.param([{"id": "a"}, "bad"], id="list_mixed"), + pytest.param({"foo": 1}, id="dict_unknown_keys"), ], ) -def test_image_configuration_success( - config: dict[str, Any] | list[dict[str, Any]], +def test_migrate_returns_none_for_invalid_legacy_shapes( + config: object, caplog: pytest.LogCaptureFixture ) -> None: - """Test successful configuration validation.""" - result = CONFIG_SCHEMA(config) - # All valid configurations should return a list of images - assert isinstance(result, list) - for key in (CONF_TYPE, CONF_ID, CONF_TRANSPARENCY, CONF_RAW_DATA_ID): - assert all(key in x for x in result), ( - f"Missing key {key} in image configuration" + """Unrecognised shapes are not migrated (and emit no warning) so normal + platform validation surfaces a proper error instead of silently dropping + the offending input.""" + with caplog.at_level(logging.WARNING): + assert _migrate_legacy_image_config(config) is None + assert "deprecated" not in caplog.text + + +# --------------------------- end legacy migration -------------------------- + + +def test_validate_image_final_defaults_to_little_endian() -> None: + out = validate_image_final({CONF_FILE: "x.png"}) + assert out[CONF_BYTE_ORDER] == "LITTLE_ENDIAN" + + +def test_validate_image_final_keeps_little_endian( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING): + out = validate_image_final( + {CONF_FILE: "x.png", CONF_BYTE_ORDER: "LITTLE_ENDIAN"} ) + assert out[CONF_BYTE_ORDER] == "LITTLE_ENDIAN" + assert "big-endian" not in caplog.text + + +def test_validate_image_final_warns_on_big_endian( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING): + out = validate_image_final({CONF_FILE: "x.png", CONF_BYTE_ORDER: "BIG_ENDIAN"}) + assert out[CONF_BYTE_ORDER] == "BIG_ENDIAN" + assert "big-endian" in caplog.text def test_image_generation( @@ -369,7 +529,7 @@ def test_get_all_image_metadata_empty() -> None: @pytest.fixture def mock_progmem_array(): """Mock progmem_array to avoid needing a proper ID object in tests.""" - with patch("esphome.components.image.cg.progmem_array") as mock_progmem: + with patch("esphome.components.file.image.cg.progmem_array") as mock_progmem: mock_progmem.return_value = MagicMock() yield mock_progmem diff --git a/tests/component_tests/online_image/__init__.py b/tests/component_tests/online_image/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/online_image/config/online_image_platform_test.yaml b/tests/component_tests/online_image/config/online_image_platform_test.yaml new file mode 100644 index 0000000000..883876e401 --- /dev/null +++ b/tests/component_tests/online_image/config/online_image_platform_test.yaml @@ -0,0 +1,30 @@ +# New `image:` `platform: online_image` form. Exercises online_image/image.py +# through the real platform loader and codegen pipeline. +esphome: + name: test + +esp32: + board: esp32s3box + +wifi: + ssid: MySSID + password: password1 + +http_request: + verify_ssl: false + +image: + - platform: online_image + id: test_online_image + url: http://example.com/image.png + format: png + type: rgb565 + +spi: + mosi_pin: 6 + clk_pin: 7 + +display: + - platform: mipi_spi + id: lcd_display + model: s3box diff --git a/tests/component_tests/online_image/config/online_image_test.yaml b/tests/component_tests/online_image/config/online_image_test.yaml new file mode 100644 index 0000000000..ab0ad472f9 --- /dev/null +++ b/tests/component_tests/online_image/config/online_image_test.yaml @@ -0,0 +1,29 @@ +# Legacy top-level `online_image:` form. Exercises the deprecation shim and the +# shared codegen path through the real read_config/codegen pipeline. +esphome: + name: test + +esp32: + board: esp32s3box + +wifi: + ssid: MySSID + password: password1 + +http_request: + verify_ssl: false + +online_image: + - id: test_online_image + url: http://example.com/image.png + format: png + type: rgb565 + +spi: + mosi_pin: 6 + clk_pin: 7 + +display: + - platform: mipi_spi + id: lcd_display + model: s3box diff --git a/tests/component_tests/online_image/test_init.py b/tests/component_tests/online_image/test_init.py new file mode 100644 index 0000000000..76b00ff5ff --- /dev/null +++ b/tests/component_tests/online_image/test_init.py @@ -0,0 +1,76 @@ +"""Tests for the online_image platform and the legacy `online_image:` shim.""" + +from __future__ import annotations + +from collections.abc import Callable +import logging +from pathlib import Path + +import pytest + +from esphome.components.online_image import ( + DOMAIN, + LEGACY_REMOVAL_VERSION, + _capture_legacy_entry, + _warn_legacy_online_image, +) +from esphome.core import CORE +from esphome.types import ConfigType + +# --------------------------------------------------------------------------- +# Legacy top-level `online_image:` deprecation shim -- REMOVE these tests after +# 2027.1.0 together with the shim in esphome/components/online_image/__init__.py. +# --------------------------------------------------------------------------- + + +def test_warn_legacy_online_image_warns_once( + caplog: pytest.LogCaptureFixture, +) -> None: + """The deprecation warning fires exactly once and never mutates the config.""" + config: ConfigType = {"id": "test_online_image", "url": "http://example.com/i.png"} + + # A per-entry capture (CONFIG_SCHEMA step) records the raw entry so the + # one-shot warning can print a pasteable migrated block. + assert _capture_legacy_entry(config) is config + + with caplog.at_level(logging.WARNING): + # First call: flag not yet set -> warns and records the flag. + assert _warn_legacy_online_image(config) is config + # Second call: flag already set -> stays silent (the dedup branch). + assert _warn_legacy_online_image(config) is config + + assert CORE.data[DOMAIN]["legacy_warning_shown"] is True + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert "deprecated" in caplog.text + assert "platform: online_image" in caplog.text + assert LEGACY_REMOVAL_VERSION in caplog.text + + +def test_legacy_online_image_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + """The legacy `online_image:` block validates, warns, and generates codegen + through the real read_config/codegen pipeline.""" + with caplog.at_level(logging.WARNING): + main_cpp = generate_main(component_config_path("online_image_test.yaml")) + + # Deprecation warning surfaced through the real validation pipeline. + assert "online_image" in caplog.text + assert "deprecated" in caplog.text + + # setup_online_image ran: OnlineImage object constructed and parented. + assert "new(test_online_image) online_image::OnlineImage(" in main_cpp + + +def test_online_image_platform_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The `image:` `platform: online_image` form generates codegen through the + real platform loader (online_image/image.py) without a deprecation warning.""" + main_cpp = generate_main(component_config_path("online_image_platform_test.yaml")) + + assert "new(test_online_image) online_image::OnlineImage(" in main_cpp diff --git a/tests/components/animation/common.yaml b/tests/components/animation/common.yaml index 8bb2a2f4d8..6790e8439b 100644 --- a/tests/components/animation/common.yaml +++ b/tests/components/animation/common.yaml @@ -1,23 +1,26 @@ -animation: - - id: rgb565_animation +image: + - platform: animation + id: rgb565_animation file: $component_dir/anim.gif type: RGB565 transparency: opaque resize: 50x50 - - id: rgb_animation + - platform: animation + id: rgb_animation file: $component_dir/anim.apng type: RGB transparency: chroma_key resize: 50x50 - - id: grayscale_animation + - platform: animation + id: grayscale_animation file: $component_dir/anim.apng type: grayscale display: lambda: |- id(rgb565_animation).next_frame(); - id(rgb_animation1).next_frame(); - id(grayscale_animation2).next_frame(); + id(rgb_animation).next_frame(); + id(grayscale_animation).next_frame(); it.image(0, 0, rgb565_animation); - it.image(120, 0, rgb_animation1); - it.image(240, 0, grayscale_animation2); + it.image(120, 0, rgb_animation); + it.image(240, 0, grayscale_animation); diff --git a/tests/components/animation/validate.host.yaml b/tests/components/animation/validate.host.yaml new file mode 100644 index 0000000000..d754f34688 --- /dev/null +++ b/tests/components/animation/validate.host.yaml @@ -0,0 +1,16 @@ +# Legacy top-level `animation:` form (deprecated; migrates to +# `platform: animation`). Config-only test exercising the deprecation path. +display: + - platform: sdl + id: animation_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +animation: + - id: legacy_animation + file: $component_dir/anim.gif + type: RGB565 + transparency: opaque + resize: 50x50 diff --git a/tests/components/file/common.yaml b/tests/components/file/common.yaml new file mode 100644 index 0000000000..e95c6b01f6 --- /dev/null +++ b/tests/components/file/common.yaml @@ -0,0 +1,17 @@ +image: + - platform: file + id: file_binary_image + file: ../../pnglogo.png + type: BINARY + dither: FloydSteinberg + - platform: file + id: file_rgb565_image + file: ../../pnglogo.png + type: RGB565 + transparency: alpha_channel + resize: 50x50 + - platform: file + id: file_mdi_image + file: mdi:alert-circle-outline + type: BINARY + resize: 24x24 diff --git a/tests/components/file/test.esp32-idf.yaml b/tests/components/file/test.esp32-idf.yaml new file mode 100644 index 0000000000..29822d7b4f --- /dev/null +++ b/tests/components/file/test.esp32-idf.yaml @@ -0,0 +1,14 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + +display: + - platform: ili9xxx + id: file_main_lcd + spi_id: spi_bus + model: ili9342 + cs_pin: 15 + dc_pin: 13 + reset_pin: 21 + invert_colors: true + +<<: !include common.yaml diff --git a/tests/components/file/test.host.yaml b/tests/components/file/test.host.yaml new file mode 100644 index 0000000000..76f9e5af85 --- /dev/null +++ b/tests/components/file/test.host.yaml @@ -0,0 +1,9 @@ +display: + - platform: sdl + id: file_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +<<: !include common.yaml diff --git a/tests/components/image/common.yaml b/tests/components/image/common.yaml index 9819068970..5a8f938319 100644 --- a/tests/components/image/common.yaml +++ b/tests/components/image/common.yaml @@ -1,85 +1,104 @@ image: - - id: binary_image + - platform: file + id: binary_image file: ../../pnglogo.png type: BINARY dither: FloydSteinberg - - id: transparent_transparent_image + - platform: file + id: transparent_transparent_image file: ../../pnglogo.png type: BINARY transparency: chroma_key - - id: rgba_image + - platform: file + id: rgba_image file: ../../pnglogo.png type: RGB transparency: alpha_channel resize: 50x50 - - id: rgb24_image + - platform: file + id: rgb24_image file: ../../pnglogo.png type: RGB transparency: chroma_key - - id: rgb_image + - platform: file + id: rgb_image file: ../../pnglogo.png type: RGB transparency: opaque - - id: rgb565_image + - platform: file + id: rgb565_image file: ../../pnglogo.png type: RGB565 transparency: opaque - - id: rgb565_ck_image + - platform: file + id: rgb565_ck_image file: ../../pnglogo.png type: RGB565 transparency: chroma_key - - id: rgb565_alpha_image + - platform: file + id: rgb565_alpha_image file: ../../pnglogo.png type: RGB565 transparency: alpha_channel - - id: grayscale_alpha_image + - platform: file + id: grayscale_alpha_image file: ../../pnglogo.png type: grayscale transparency: alpha_channel resize: 50x50 - - id: grayscale_ck_image + - platform: file + id: grayscale_ck_image file: ../../pnglogo.png type: grayscale transparency: chroma_key - - id: grayscale_image + - platform: file + id: grayscale_image file: ../../pnglogo.png type: grayscale transparency: opaque - - id: web_svg_image + - platform: file + id: web_svg_image file: https://media.esphome.io/logo/logo.svg resize: 256x48 type: BINARY transparency: chroma_key - - id: web_tiff_image + - platform: file + id: web_tiff_image file: https://media.esphome.io/tests/images/SIPI_Jelly_Beans_4.1.07.tiff type: RGB resize: 48x48 - - id: web_redirect_image + - platform: file + id: web_redirect_image file: https://media.esphome.io/logo/logo.png type: RGB resize: 48x48 - - id: mdi_alert + - platform: file + id: mdi_alert type: BINARY file: mdi:alert-circle-outline resize: 50x50 - - id: another_alert_icon + - platform: file + id: another_alert_icon file: mdi:alert-outline type: BINARY - - file: mdil:arrange-bring-to-front + - platform: file + file: mdil:arrange-bring-to-front id: mdil_id resize: 50x50 type: binary transparency: chroma_key - - file: mdi:beer + - platform: file + file: mdi:beer id: mdi_id resize: 50x50 type: binary transparency: chroma_key - - file: memory:alert-octagon + - platform: file + file: memory:alert-octagon id: memory_id resize: 50x50 type: binary diff --git a/tests/components/image/test.esp8266-ard.yaml b/tests/components/image/test.esp8266-ard.yaml index 492b57c449..939a3ac39b 100644 --- a/tests/components/image/test.esp8266-ard.yaml +++ b/tests/components/image/test.esp8266-ard.yaml @@ -12,12 +12,11 @@ display: invert_colors: true image: - defaults: + - platform: file + id: test_image + file: ../../pnglogo.png type: rgb565 transparency: opaque byte_order: little_endian resize: 50x50 dither: FloydSteinberg - images: - - id: test_image - file: ../../pnglogo.png diff --git a/tests/components/image/test.host.yaml b/tests/components/image/test.host.yaml index aa45497088..455d41d0c2 100644 --- a/tests/components/image/test.host.yaml +++ b/tests/components/image/test.host.yaml @@ -7,43 +7,60 @@ display: height: 480 image: - binary: - - id: binary_image - file: ../../pnglogo.png - dither: FloydSteinberg - - id: transparent_transparent_image - file: ../../pnglogo.png - transparency: chroma_key - rgb: - alpha_channel: - - id: rgba_image - file: ../../pnglogo.png - resize: 50x50 - chroma_key: - - id: rgb24_image - file: ../../pnglogo.png - type: RGB - opaque: - - id: rgb_image - file: ../../pnglogo.png - rgb565: - - id: rgb565_image - file: ../../pnglogo.png - transparency: opaque - - id: rgb565_ck_image - file: ../../pnglogo.png - transparency: chroma_key - - id: rgb565_alpha_image - file: ../../pnglogo.png - transparency: alpha_channel - grayscale: - - id: grayscale_alpha_image - file: ../../pnglogo.png - transparency: alpha_channel - resize: 50x50 - - id: grayscale_ck_image - file: ../../pnglogo.png - transparency: chroma_key - - id: grayscale_image - file: ../../pnglogo.png - transparency: opaque + - platform: file + id: binary_image + file: ../../pnglogo.png + type: binary + dither: FloydSteinberg + - platform: file + id: transparent_transparent_image + file: ../../pnglogo.png + type: binary + transparency: chroma_key + - platform: file + id: rgba_image + file: ../../pnglogo.png + type: rgb + transparency: alpha_channel + resize: 50x50 + - platform: file + id: rgb24_image + file: ../../pnglogo.png + type: RGB + transparency: chroma_key + - platform: file + id: rgb_image + file: ../../pnglogo.png + type: rgb + transparency: opaque + - platform: file + id: rgb565_image + file: ../../pnglogo.png + type: rgb565 + transparency: opaque + - platform: file + id: rgb565_ck_image + file: ../../pnglogo.png + type: rgb565 + transparency: chroma_key + - platform: file + id: rgb565_alpha_image + file: ../../pnglogo.png + type: rgb565 + transparency: alpha_channel + - platform: file + id: grayscale_alpha_image + file: ../../pnglogo.png + type: grayscale + transparency: alpha_channel + resize: 50x50 + - platform: file + id: grayscale_ck_image + file: ../../pnglogo.png + type: grayscale + transparency: chroma_key + - platform: file + id: grayscale_image + file: ../../pnglogo.png + type: grayscale + transparency: opaque diff --git a/tests/components/image/validate-defaults.host.yaml b/tests/components/image/validate-defaults.host.yaml new file mode 100644 index 0000000000..16ea9e7b62 --- /dev/null +++ b/tests/components/image/validate-defaults.host.yaml @@ -0,0 +1,25 @@ +# Legacy top-level `image:` defaults/images form (deprecated; migrates to +# `platform: file`). Config-only test exercising the deprecation/migration path, +# including the per-type byte_order drop when an entry overrides to a non-endian +# type (binary). +display: + - platform: sdl + id: image_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + defaults: + type: rgb565 + transparency: opaque + byte_order: little_endian + resize: 50x50 + dither: FloydSteinberg + images: + - id: legacy_defaults_image + file: ../../pnglogo.png + - id: legacy_defaults_binary + file: ../../pnglogo.png + type: binary diff --git a/tests/components/image/validate-grouped-single.host.yaml b/tests/components/image/validate-grouped-single.host.yaml new file mode 100644 index 0000000000..0b6ff3d576 --- /dev/null +++ b/tests/components/image/validate-grouped-single.host.yaml @@ -0,0 +1,24 @@ +# Legacy top-level `image:` structured form using single-dict (non-list) values +# for `images:`, a type group, and a transparency group -- the old `ensure_list` +# accepted a bare dict in each of these places. Deprecated; migrates to +# `platform: file`. Config-only test exercising the deprecation/migration path. +display: + - platform: sdl + id: image_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + images: + id: legacy_images_single_dict + file: ../../pnglogo.png + type: rgb565 + rgb565: + id: legacy_grouped_type_single_dict + file: ../../pnglogo.png + rgb: + alpha_channel: + id: legacy_grouped_transparency_single_dict + file: ../../pnglogo.png diff --git a/tests/components/image/validate-grouped.host.yaml b/tests/components/image/validate-grouped.host.yaml new file mode 100644 index 0000000000..8f85aa7ca5 --- /dev/null +++ b/tests/components/image/validate-grouped.host.yaml @@ -0,0 +1,25 @@ +# Legacy top-level `image:` type-grouped form (deprecated; migrates to +# `platform: file`). Config-only test exercising the deprecation/migration path. +display: + - platform: sdl + id: image_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + binary: + - id: legacy_grouped_binary + file: ../../pnglogo.png + rgb: + alpha_channel: + - id: legacy_grouped_rgba + file: ../../pnglogo.png + opaque: + - id: legacy_grouped_rgb + file: ../../pnglogo.png + rgb565: + - id: legacy_grouped_rgb565 + file: ../../pnglogo.png + transparency: chroma_key diff --git a/tests/components/image/validate-single.host.yaml b/tests/components/image/validate-single.host.yaml new file mode 100644 index 0000000000..52a945fb67 --- /dev/null +++ b/tests/components/image/validate-single.host.yaml @@ -0,0 +1,16 @@ +# Legacy top-level `image:` single-dict form (a bare image dict instead of a +# list; deprecated, migrates to `platform: file`). Config-only test exercising +# the deprecation/migration path. +display: + - platform: sdl + id: image_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + id: legacy_single_image + file: ../../pnglogo.png + type: RGB565 + transparency: opaque diff --git a/tests/components/image/validate.host.yaml b/tests/components/image/validate.host.yaml new file mode 100644 index 0000000000..aa821ea7e2 --- /dev/null +++ b/tests/components/image/validate.host.yaml @@ -0,0 +1,18 @@ +# Legacy top-level `image:` list form (deprecated; migrates to `platform: file`). +# Config-only test exercising the deprecation/migration path. +display: + - platform: sdl + id: image_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + - id: legacy_list_binary + file: ../../pnglogo.png + type: BINARY + - id: legacy_list_rgb565 + file: ../../pnglogo.png + type: RGB565 + transparency: alpha_channel diff --git a/tests/components/online_image/common.yaml b/tests/components/online_image/common.yaml index fc3cc94217..f71cf63de9 100644 --- a/tests/components/online_image/common.yaml +++ b/tests/components/online_image/common.yaml @@ -2,11 +2,9 @@ wifi: ssid: MySSID password: password1 -# Purposely test that `online_image:` does auto-load `image:` -# Keep the `image:` undefined. -# image: -online_image: - - id: online_binary_image +image: + - platform: online_image + id: online_binary_image url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png format: PNG type: BINARY @@ -21,34 +19,41 @@ online_image: } else { ESP_LOGD("online_image", "Cache miss: fresh download"); } - - id: online_binary_transparent_image + - platform: online_image + id: online_binary_transparent_image url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png type: BINARY transparency: chroma_key format: png - - id: online_rgba_image + - platform: online_image + id: online_rgba_image url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png format: PNG type: RGB transparency: alpha_channel - - id: online_rgb24_image + - platform: online_image + id: online_rgb24_image url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png format: PNG type: RGB transparency: chroma_key - - id: online_binary_bmp + - platform: online_image + id: online_binary_bmp url: https://samples-files.com/samples/images/bmp/480-360-sample.bmp format: BMP type: BINARY - - id: online_rgb_bmp_8bit + - platform: online_image + id: online_rgb_bmp_8bit url: https://samples-files.com/samples/images/bmp/480-360-sample.bmp format: BMP type: RGB - - id: online_jpeg_image + - platform: online_image + id: online_jpeg_image url: http://www.faqs.org/images/library.jpg format: JPEG type: RGB - - id: online_jpg_image + - platform: online_image + id: online_jpg_image url: http://www.faqs.org/images/library.jpg format: JPG type: RGB565 diff --git a/tests/components/online_image/validate.host.yaml b/tests/components/online_image/validate.host.yaml new file mode 100644 index 0000000000..f0ba98c65d --- /dev/null +++ b/tests/components/online_image/validate.host.yaml @@ -0,0 +1,22 @@ +# Legacy top-level `online_image:` form (deprecated; migrates to +# `platform: online_image`). Config-only test exercising the deprecation path. +wifi: + ssid: MySSID + password: password1 + +http_request: + +display: + - platform: sdl + id: online_image_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +online_image: + - id: legacy_online_image + url: http://www.example.org/example.png + format: PNG + type: RGB565 + resize: 50x50 diff --git a/tests/unit_tests/test_config_normalization.py b/tests/unit_tests/test_config_normalization.py index a06b2da621..c8b7b63094 100644 --- a/tests/unit_tests/test_config_normalization.py +++ b/tests/unit_tests/test_config_normalization.py @@ -1,6 +1,6 @@ """Unit tests for esphome.config module.""" -from collections.abc import Generator +from collections.abc import Callable, Generator import logging from pathlib import Path from unittest.mock import MagicMock, Mock, patch @@ -8,7 +8,8 @@ from unittest.mock import MagicMock, Mock, patch import pytest from esphome import config, yaml_util -from esphome.core import CORE +from esphome.core import CORE, AutoLoad +from esphome.types import ConfigType @pytest.fixture @@ -116,6 +117,86 @@ def test_ota_with_platform_list_and_captive_portal(fixtures_dir: Path) -> None: assert "web_server" in platforms, f"Expected web_server platform in {platforms}" +# --------------------------------------------------------------------------- +# LEGACY_CONFIG_MIGRATE hook on LoadValidationStep -- the removable shim that +# lets a platform component rewrite a pre-platform top-level config. +# --------------------------------------------------------------------------- + + +def _run_load_step( + domain: str, + conf: object, + migrate: Callable[[ConfigType], list | None] | None, +) -> config.Config: + """Run a LoadValidationStep for a platform component with a given migrate hook.""" + component = Mock() + component.is_platform_component = True + component.multi_conf_no_default = False + component.legacy_config_migrate = migrate + + result = config.Config() + with ( + patch("esphome.config.get_component", return_value=component), + patch("esphome.config._process_auto_load"), + patch("esphome.config._process_platform_config"), + ): + config.LoadValidationStep(domain, conf).run(result) + return result + + +def test_legacy_migrate_rewrites_conf() -> None: + """A legacy config that the hook migrates is replaced with the new list.""" + migrated = [{"platform": "file", "id": "a"}] + migrate = Mock(return_value=migrated) + + result = _run_load_step("image", [{"id": "a", "file": "x.png"}], migrate) + + migrate.assert_called_once_with([{"id": "a", "file": "x.png"}]) + assert result["image"] == migrated + + +def test_legacy_migrate_none_keeps_new_format() -> None: + """When the hook returns None the already-new config is left untouched.""" + new_format = [{"platform": "file", "id": "a"}] + migrate = Mock(return_value=None) + + result = _run_load_step("image", new_format, migrate) + + migrate.assert_called_once_with(new_format) + assert result["image"] == new_format + + +def test_legacy_migrate_absent_hook_is_noop() -> None: + """A platform component without the hook normalizes without migration.""" + result = _run_load_step("image", {"id": "a"}, None) + + # Bare dict still gets wrapped into a list by the normal normalization path. + assert result["image"] == [{"id": "a"}] + + +def test_legacy_migrate_skipped_for_empty_conf() -> None: + """An empty config short-circuits before the hook is consulted.""" + migrate = Mock(return_value=[{"platform": "file"}]) + + result = _run_load_step("image", [], migrate) + + migrate.assert_not_called() + assert result["image"] == [] + + +def test_legacy_migrate_skipped_for_autoload() -> None: + """An auto-loaded (AutoLoad) config is never migrated.""" + migrate = Mock(return_value=[{"platform": "file"}]) + auto = AutoLoad() + auto["id"] = "a" + + result = _run_load_step("image", auto, migrate) + + migrate.assert_not_called() + # AutoLoad is dict-like, so normalization wraps it into a single-entry list. + assert result["image"] == [auto] + + def _write_merge_conflict_config(tmp_path: Path, *, suppress: bool) -> Path: """Create a config where two `<<` includes both define `logger:`. From 2db001710c3ba2c086c3f26420445d17a8a64a71 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:41:50 -0400 Subject: [PATCH 0789/1815] Bump astral-sh/setup-uv from 8.3.1 to 8.3.2 in /.github/actions/restore-python (#17452) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 64b1cabea1..9d78b2d843 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -32,7 +32,7 @@ runs: # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # downstream jobs rely on is preserved. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true # Pin uv version so the action does not have to fetch the From d4bb20d34b32bf7fa66c1c6369d3b087b9f3668d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:42:05 -0400 Subject: [PATCH 0790/1815] Bump github/codeql-action/init from 4.36.3 to 4.37.0 (#17453) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 610e6ed020..ed6523d7d8 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} From 99ff7e198aab14ec1cd06f39d70b779c4e66d053 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:42:18 -0400 Subject: [PATCH 0791/1815] Bump astral-sh/setup-uv from 8.3.1 to 8.3.2 (#17454) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci.yml | 6 +++--- .github/workflows/sync-device-classes.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 1757959a51..ebbe720463 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -29,7 +29,7 @@ jobs: - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e08241681b..583e8203ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true # Pin uv version so the action does not have to fetch the @@ -171,7 +171,7 @@ jobs: # install step (order-of-magnitude faster on cold boots, # with its own wheel cache). actions/setup-python still # provides the interpreter. - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true # Pin uv version so the action does not have to fetch the @@ -372,7 +372,7 @@ jobs: - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 7e0047ee0d..2f350d09b3 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -47,7 +47,7 @@ jobs: # setup-python interpreter so subsequent ``pre-commit`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true # Pin uv version so the action does not have to fetch the From 99ec2cc00ad8bc5c22d6a03a1ff9728e0f68dbb6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:42:38 -0400 Subject: [PATCH 0792/1815] Bump CodSpeedHQ/action from 4.18.2 to 4.18.4 (#17455) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 583e8203ef..6e93b6ece8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -456,7 +456,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@4e969336ab9acd4f6f8d025fdd793292b0835df0 # v4.18.2 + uses: CodSpeedHQ/action@9f3a37ece7abc84992501a7fcd54d1704f3458fa # v4.18.4 with: run: | . venv/bin/activate From 9088875491377ca2f960b64ebeb641ebad500893 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:42:48 -0400 Subject: [PATCH 0793/1815] Bump github/codeql-action/analyze from 4.36.3 to 4.37.0 (#17456) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ed6523d7d8..e718b481e0 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 + uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: category: "/language:${{matrix.language}}" From 640e0973acc23667e562cf0db1149bc19c7e0d20 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 9 Jul 2026 05:54:28 +1000 Subject: [PATCH 0794/1815] [lvgl] Dynamic rotation features (#16773) --- esphome/components/lvgl/__init__.py | 2 + esphome/components/lvgl/automation.py | 27 ++- esphome/components/lvgl/defines.py | 2 + esphome/components/lvgl/layout.py | 56 +++++ esphome/components/lvgl/lv_validation.py | 13 ++ esphome/components/lvgl/lvgl_esphome.cpp | 28 ++- esphome/components/lvgl/lvgl_esphome.h | 16 ++ esphome/components/lvgl/schemas.py | 2 + esphome/components/lvgl/widgets/__init__.py | 99 ++++++--- .../lvgl/config/layout_update_test.yaml | 92 ++++++++ .../lvgl/test_layout_update.py | 208 ++++++++++++++++++ tests/components/lvgl/lvgl-package.yaml | 34 +++ 12 files changed, 538 insertions(+), 41 deletions(-) create mode 100644 tests/component_tests/lvgl/config/layout_update_test.yaml create mode 100644 tests/component_tests/lvgl/test_layout_update.py diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index b758390f0d..256bf4bb3a 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -148,6 +148,8 @@ SIMPLE_TRIGGERS = ( df.CONF_ON_RESUME, df.CONF_ON_DRAW_START, df.CONF_ON_DRAW_END, + df.CONF_ON_LANDSCAPE, + df.CONF_ON_PORTRAIT, ) diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index bf9a3d74ad..b7c90a5c51 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -4,7 +4,6 @@ from typing import Any from esphome import automation from esphome.automation import StatelessLambdaAction import esphome.codegen as cg -from esphome.components.display import validate_rotation import esphome.config_validation as cv from esphome.const import CONF_ACTION, CONF_GROUP, CONF_ID, CONF_ROTATION, CONF_TIMEOUT from esphome.core import Lambda @@ -16,6 +15,7 @@ from .defines import ( CONF_BOTTOM_LAYER, CONF_EDITING, CONF_FREEZE, + CONF_LAYOUT, CONF_LVGL_ID, CONF_MAIN, CONF_OBJ, @@ -29,7 +29,8 @@ from .defines import ( get_options, get_refreshed_widgets, ) -from .lv_validation import lv_bool, lv_milliseconds +from .layout import layout_validator +from .lv_validation import lv_bool, lv_milliseconds, lv_rotation from .lvcode import ( LVGL_COMP_ARG, UPDATE_EVENT, @@ -199,7 +200,7 @@ async def lvgl_is_idle(config, condition_id, template_arg, args): def _validate_rotation(value): # Note that we need rotation get_options()[CONF_ROTATION] = True - return validate_rotation(value) + return lv_rotation(value) @automation.register_action( @@ -218,7 +219,8 @@ def _validate_rotation(value): async def lvgl_set_rotation(config, action_id, template_arg, args): lv_comp = await cg.get_variable(config[CONF_LVGL_ID]) async with LambdaContext(args, where=action_id) as context: - lv_add(lv_comp.set_rotation(config[CONF_ROTATION])) + rotation = await lv_rotation.process(config[CONF_ROTATION]) + lv_add(lv_comp.set_rotation(rotation)) return cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) @@ -254,6 +256,13 @@ layer_spec = WidgetType(CONF_OBJ, lv_obj_t, (CONF_MAIN, CONF_SCROLLBAR), is_mock DISP_PROPS = {str(x) for x in DISP_BG_SCHEMA.schema} +def _layer_update_schema() -> cv.Schema: + """Schema for updating a display layer's styling and layout options.""" + return part_schema(layer_spec.parts).extend( + {cv.Optional(CONF_LAYOUT): layout_validator} + ) + + @automation.register_action( "lvgl.update", LvglAction, @@ -262,8 +271,9 @@ DISP_PROPS = {str(x) for x in DISP_BG_SCHEMA.schema} .extend(DISP_BG_SCHEMA) .extend( { - cv.Optional(CONF_TOP_LAYER): part_schema(layer_spec.parts), - cv.Optional(CONF_BOTTOM_LAYER): part_schema(layer_spec.parts), + cv.Optional(CONF_LAYOUT): layout_validator, + cv.Optional(CONF_TOP_LAYER): _layer_update_schema(), + cv.Optional(CONF_BOTTOM_LAYER): _layer_update_schema(), } ), synchronous=True, @@ -272,7 +282,12 @@ async def lvgl_update_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config, CONF_LVGL_ID) w = widgets[0] async with LambdaContext(LVGL_COMP_ARG, where=action_id) as context: + # Apply the top-level properties (styles and layout) to the active screen... + await set_obj_properties(get_screen_active(w.var), config) + # ...the deprecated flat `disp_*` background properties... await lvgl_update(w.var, config) + # ...and the `top_layer`/`bottom_layer` keys (styling and layout updates). + await layers_to_code(w.var, config) var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) await cg.register_parented(var, w.var) return var diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 480ba515d1..4f734fe20c 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -760,7 +760,9 @@ CONF_ONE_CHECKED = "one_checked" CONF_ONE_LINE = "one_line" CONF_ON_DRAW_START = "on_draw_start" CONF_ON_DRAW_END = "on_draw_end" +CONF_ON_LANDSCAPE = "on_landscape" CONF_ON_PAUSE = "on_pause" +CONF_ON_PORTRAIT = "on_portrait" CONF_ON_RESUME = "on_resume" CONF_ON_SELECT = "on_select" CONF_ON_STOP = "on_stop" diff --git a/esphome/components/lvgl/layout.py b/esphome/components/lvgl/layout.py index 32304276d3..fd1f242d86 100644 --- a/esphome/components/lvgl/layout.py +++ b/esphome/components/lvgl/layout.py @@ -34,6 +34,7 @@ from .defines import ( TYPE_GRID, TYPE_NONE, LvConstant, + add_lv_use, ) from .lv_validation import padding, size @@ -401,6 +402,61 @@ LAYOUT_CLASSES = ( LAYOUT_CHOICES = [x.get_type() for x in LAYOUT_CLASSES] +# Layout properties that may be changed at runtime via an update action. These +# are limited to simple style properties (set via ``lv_obj_set_style_...``). +# Structural properties are deliberately excluded: +# - the layout ``type``, which determines which options are available to child +# widgets, and +# - the grid ``grid_rows``/``grid_columns`` descriptors, which define the cells +# that child widgets are placed into. +# Both are fixed at widget creation. +_GRID_LAYOUT_KEYS = ( + CONF_GRID_COLUMN_ALIGN, + CONF_GRID_ROW_ALIGN, +) +_FLEX_LAYOUT_KEYS = ( + CONF_FLEX_FLOW, + CONF_FLEX_ALIGN_MAIN, + CONF_FLEX_ALIGN_CROSS, + CONF_FLEX_ALIGN_TRACK, +) + +LAYOUT_UPDATE_SCHEMA = cv.Schema( + { + cv.Optional(CONF_FLEX_FLOW): FLEX_FLOWS.one_of, + cv.Optional(CONF_FLEX_ALIGN_MAIN): flex_alignments, + cv.Optional(CONF_FLEX_ALIGN_CROSS): LV_FLEX_CROSS_ALIGNMENTS.one_of, + cv.Optional(CONF_FLEX_ALIGN_TRACK): flex_alignments, + 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, + } +) + + +def layout_validator(value): + """ + Validate a ``layout:`` value for an update action. Only the layout options + may be changed (not the layout ``type``, which is fixed at widget creation). + :param value: The value of the ``layout:`` key + :return: The validated layout options dict + """ + result = LAYOUT_UPDATE_SCHEMA(value) + if not result: + raise cv.Invalid( + "A layout update must specify at least one layout option", [CONF_LAYOUT] + ) + # Register the relevant layout feature so its LV_USE_* define is emitted even + # when the option is set solely via an update action (whose code generation + # may run after LVGL has finished collecting its used features). + if any(key in result for key in _GRID_LAYOUT_KEYS): + add_lv_use(TYPE_GRID) + if any(key in result for key in _FLEX_LAYOUT_KEYS): + add_lv_use(TYPE_FLEX) + return result + + def append_layout_schema(schema, config: dict): """ Get the child layout schema for a given widget based on its layout type. diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index b588e865d2..42352b9602 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -331,6 +331,19 @@ lv_angle = LValidator(angle, uint32, retmapper=lambda x: int(x * 10), animatable lv_angle_degrees = LValidator(angle, uint32, retmapper=int, animatable=True) +def rotation_degrees(value): + """Validate a display rotation, returning the angle in whole degrees. + + Accepts the four supported rotations, optionally suffixed with "°". + """ + value = cv.string(value).removesuffix("°") + return cv.one_of(0, 90, 180, 270, int=True)(value) + + +# Validator for a display rotation expressed in whole degrees (templatable) +lv_rotation = LValidator(rotation_degrees, cg.int_) + + @schema_extractor("one_of") def size_validator(value): """A size in one axis - one of "size_content", a number (pixels) or a percentage""" diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 1db5992389..b66a904437 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -91,11 +91,24 @@ void LvglComponent::set_rotation(display::DisplayRotation rotation) { this->rotation_ = rotation; if (this->is_ready()) { this->set_resolution_(); + this->update_orientation_(); lv_obj_update_layout(this->get_screen_active()); lv_obj_invalidate(this->get_screen_active()); } } +void LvglComponent::set_rotation(int angle) { + // Normalize to [0, 360). The DisplayRotation enum values are the angles in degrees. + angle %= 360; + if (angle < 0) + angle += 360; + if (angle % 90 != 0) { + ESP_LOGW(TAG, "Invalid rotation angle %d; must be a multiple of 90 degrees.", angle); + return; + } + this->set_rotation(static_cast(angle)); +} + void LvglComponent::rotate_coordinates(int32_t &x, int32_t &y) const { switch (this->rotation_) { default: @@ -719,6 +732,18 @@ void LvglComponent::set_resolution_() const { } lv_display_set_resolution(this->disp_, width, height); } + +void LvglComponent::update_orientation_() { + // A square display is treated as landscape. + auto orientation = this->get_width() >= this->get_height() ? Orientation::LANDSCAPE : Orientation::PORTRAIT; + if (orientation == this->orientation_) + return; + this->orientation_ = orientation; + auto *trigger = orientation == Orientation::LANDSCAPE ? this->landscape_callback_ : this->portrait_callback_; + if (trigger != nullptr) + trigger->trigger(); +} + void LvglComponent::setup() { auto *display = this->displays_[0]; auto rounding = this->draw_rounding; @@ -757,7 +782,7 @@ void LvglComponent::setup() { lv_display_add_event_cb(this->disp_, rounder_cb, LV_EVENT_INVALIDATE_AREA, this); lv_display_set_buffers(this->disp_, this->draw_buf_, nullptr, buf_bytes, this->full_refresh_ ? LV_DISPLAY_RENDER_MODE_FULL : LV_DISPLAY_RENDER_MODE_PARTIAL); - if (this->rotation_type_ == RotationType::ROTATION_SOFTWARE) { + if (this->rotation_type_ == ROTATION_SOFTWARE) { this->rotate_buf_ = static_cast(lv_alloc_draw_buf(buf_bytes, false)); // NOLINT if (this->rotate_buf_ == nullptr) { this->status_set_error(LOG_STR("Memory allocation failure")); @@ -796,6 +821,7 @@ void LvglComponent::setup() { #endif this->show_page(0, LV_SCREEN_LOAD_ANIM_NONE, 0); lv_display_trigger_activity(this->disp_); + this->update_orientation_(); } void LvglComponent::update() { diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index dcbf490bce..9221ab9542 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -185,6 +185,12 @@ enum RotationType : uint8_t { ROTATION_HARDWARE, }; +enum class Orientation : uint8_t { + UNKNOWN, + LANDSCAPE, + PORTRAIT, +}; + class LvglComponent final : public PollingComponent { constexpr static const char *const TAG = "lvgl"; @@ -291,7 +297,11 @@ class LvglComponent final : public PollingComponent { void set_resume_trigger(Trigger<> *trigger) { this->resume_callback_ = trigger; } void set_draw_start_trigger(Trigger<> *trigger) { this->draw_start_callback_ = trigger; } void set_draw_end_trigger(Trigger<> *trigger) { this->draw_end_callback_ = trigger; } + void set_landscape_trigger(Trigger<> *trigger) { this->landscape_callback_ = trigger; } + void set_portrait_trigger(Trigger<> *trigger) { this->portrait_callback_ = trigger; } void set_rotation(display::DisplayRotation rotation); + /// Set the rotation from an angle in degrees. Must be a multiple of 90. + void set_rotation(int angle); display::DisplayRotation get_rotation() const { return this->rotation_; } void rotate_coordinates(int32_t &x, int32_t &y) const; @@ -300,6 +310,9 @@ class LvglComponent final : public PollingComponent { protected: void set_resolution_() const; + // Determine the current orientation from the effective resolution and fire the + // landscape/portrait trigger if it has changed since the last check. + void update_orientation_(); void draw_end_(); // Not checking for non-null callback since the // LVGL callback that calls it is not set in that case @@ -347,6 +360,9 @@ class LvglComponent final : public PollingComponent { Trigger<> *resume_callback_{}; Trigger<> *draw_start_callback_{}; Trigger<> *draw_end_callback_{}; + Trigger<> *landscape_callback_{}; + Trigger<> *portrait_callback_{}; + Orientation orientation_{Orientation::UNKNOWN}; void *rotate_buf_{}; display::DisplayRotation rotation_{display::DISPLAY_ROTATION_0_DEGREES}; RotationType rotation_type_; diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 13214d459d..dd4f71a346 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -55,6 +55,7 @@ from .layout import ( GRID_CELL_SCHEMA, append_layout_schema, grid_alignments, + layout_validator, ) from .lv_validation import lv_color, lv_font, lv_gradient, lv_image, opacity from .lvcode import UPDATE_EVENT, LvglComponent, lv_event_t_ptr @@ -523,6 +524,7 @@ def base_update_schema(widget_type: WidgetType | LvType, parts): ) ), cv.Optional(CONF_STATE): SET_STATE_SCHEMA, + cv.Optional(df.CONF_LAYOUT): layout_validator, } ) diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index 4d62c3de05..968db46adc 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -36,11 +36,10 @@ from ..defines import ( CONF_SCALE, CONF_STYLES, CONF_WIDGETS, + LOGGER, OBJ_FLAGS, PARTS, STATES, - TYPE_FLEX, - TYPE_GRID, LValidator, add_lv_use, call_lambda, @@ -541,44 +540,76 @@ def _size_to_str(value): return str(value) +def _grid_descriptor_array(name: str, specs) -> MockObj: + """Generate a file-scope ``static const`` grid row/column descriptor array + and return a reference to it.""" + values = ",".join(_size_to_str(x) for x in specs) + initializer = "{" + values + ", LV_GRID_TEMPLATE_LAST}" + arr_id = ID(name, is_declaration=True, type=lv_coord_t) + return cg.static_const_array(arr_id, cg.RawExpression(initializer)) + + +def _set_layout_options(w: Widget, layout: dict, base_name: str | None) -> None: + """Apply the layout options present in ``layout`` to ``w``. + + Only options actually present are applied, so this works both for widget + creation (where every option is supplied) and for update actions (where the + layout ``type`` and grid structure are fixed and only the style options are + changed). ``base_name`` names the generated grid descriptor arrays and is + only required at creation, when ``grid_rows``/``grid_columns`` are present. + """ + if (pad_row := layout.get(CONF_PAD_ROW)) is not None: + w.set_style(CONF_PAD_ROW, pad_row) + if (pad_column := layout.get(CONF_PAD_COLUMN)) is not None: + w.set_style(CONF_PAD_COLUMN, pad_column) + if (rows := layout.get(CONF_GRID_ROWS)) is not None: + w.set_style( + "grid_row_dsc_array", _grid_descriptor_array(f"{base_name}_row_dsc", rows) + ) + if (columns := layout.get(CONF_GRID_COLUMNS)) is not None: + w.set_style( + "grid_column_dsc_array", + _grid_descriptor_array(f"{base_name}_column_dsc", columns), + ) + if (align := layout.get(CONF_GRID_COLUMN_ALIGN)) is not None: + w.set_style(CONF_GRID_COLUMN_ALIGN, literal(align)) + if (align := layout.get(CONF_GRID_ROW_ALIGN)) is not None: + w.set_style(CONF_GRID_ROW_ALIGN, literal(align)) + if (flow := layout.get(CONF_FLEX_FLOW)) is not None: + lv_obj.set_flex_flow(w.obj, literal(flow)) + if (main := layout.get(CONF_FLEX_ALIGN_MAIN)) is not None: + w.set_style("flex_main_place", literal(main)) + if (cross := layout.get(CONF_FLEX_ALIGN_CROSS)) is not None: + # Stretch is implemented at creation time by sizing the children; at + # runtime we can only fall back to centering. + if cross == "LV_FLEX_ALIGN_STRETCH": + LOGGER.warning( + "Flex cross alignment 'stretch' is not supported at runtime; using 'center' instead" + ) + cross = "LV_FLEX_ALIGN_CENTER" + w.set_style("flex_cross_place", literal(cross)) + if (track := layout.get(CONF_FLEX_ALIGN_TRACK)) is not None: + w.set_style("flex_track_place", literal(track)) + + async def set_obj_properties(w: Widget, config): """Generate a list of C++ statements to apply properties to an lv_obj_t""" from ..schemas import ALL_STYLES, OBJ_PROPERTIES, remap_property if layout := config.get(CONF_LAYOUT): - layout_type: str = layout[CONF_TYPE] - add_lv_use(layout_type) - lv_obj.set_layout(w.obj, literal(f"LV_LAYOUT_{layout_type.upper()}")) - if (pad_row := layout.get(CONF_PAD_ROW)) is not None: - w.set_style(CONF_PAD_ROW, pad_row) - if (pad_column := layout.get(CONF_PAD_COLUMN)) is not None: - w.set_style(CONF_PAD_COLUMN, pad_column) - if layout_type == TYPE_GRID: - wid = config[CONF_ID] - rows = [_size_to_str(x) for x in layout[CONF_GRID_ROWS]] - rows = "{" + ",".join(rows) + ", LV_GRID_TEMPLATE_LAST}" - row_id = ID(f"{wid}_row_dsc", is_declaration=True, type=lv_coord_t) - row_array = cg.static_const_array(row_id, cg.RawExpression(rows)) - w.set_style("grid_row_dsc_array", row_array) - columns = [_size_to_str(x) for x in layout[CONF_GRID_COLUMNS]] - columns = "{" + ",".join(columns) + ", LV_GRID_TEMPLATE_LAST}" - column_id = ID(f"{wid}_column_dsc", is_declaration=True, type=lv_coord_t) - column_array = cg.static_const_array(column_id, cg.RawExpression(columns)) - w.set_style("grid_column_dsc_array", column_array) - w.set_style( - CONF_GRID_COLUMN_ALIGN, literal(layout.get(CONF_GRID_COLUMN_ALIGN)) - ) - w.set_style(CONF_GRID_ROW_ALIGN, literal(layout.get(CONF_GRID_ROW_ALIGN))) - if layout_type == TYPE_FLEX: - lv_obj.set_flex_flow(w.obj, literal(layout[CONF_FLEX_FLOW])) - main = literal(layout[CONF_FLEX_ALIGN_MAIN]) - cross = layout[CONF_FLEX_ALIGN_CROSS] - if cross == "LV_FLEX_ALIGN_STRETCH": - cross = "LV_FLEX_ALIGN_CENTER" - cross = literal(cross) - track = literal(layout[CONF_FLEX_ALIGN_TRACK]) - lv_obj.set_flex_align(w.obj, main, cross, track) + # The layout `type` (and the grid row/column structure) is only present + # when a widget is created; update actions only change the layout style + # options, leaving the type and grid structure unchanged. + layout_type = layout.get(CONF_TYPE) + if layout_type is not None: + add_lv_use(layout_type) + lv_obj.set_layout(w.obj, literal(f"LV_LAYOUT_{layout_type.upper()}")) + # The widget's own id gives the grid descriptor arrays stable names. + base_name = str(config[CONF_ID]) + else: + base_name = None + _set_layout_options(w, layout, base_name) parts = collect_parts(config) for part, states in parts.items(): part = "LV_PART_" + part.upper() diff --git a/tests/component_tests/lvgl/config/layout_update_test.yaml b/tests/component_tests/lvgl/config/layout_update_test.yaml new file mode 100644 index 0000000000..84765a60cf --- /dev/null +++ b/tests/component_tests/lvgl/config/layout_update_test.yaml @@ -0,0 +1,92 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +spi: + - id: spi_bus + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + invert_colors: false + update_interval: never + +lvgl: + id: lvgl_id + displays: tft_display + pages: + - id: main_page + widgets: + # A flex container whose layout options are changed at runtime. + - obj: + id: flex_box + layout: + type: flex + flex_flow: row + widgets: + - label: + text: a + - label: + text: b + + # A grid container whose alignment options are changed at runtime. + # The grid structure (rows/columns) is fixed here at creation. + - obj: + id: grid_box + layout: + type: grid + grid_rows: [content, content] + grid_columns: [fr(1), fr(1)] + widgets: + - label: + text: c + - label: + text: d + + # Button hosting all of the update actions under test. + - button: + id: btn_actions + on_click: + # Update flex container options (type unchanged). + - lvgl.widget.update: + id: flex_box + layout: + flex_flow: column + flex_align_main: center + flex_align_cross: end + pad_row: 7px + # Update grid container alignment options (structure unchanged). + - lvgl.widget.update: + id: grid_box + layout: + grid_column_align: space_between + grid_row_align: center + # Top-level layout applies to the active screen. + - lvgl.update: + layout: + flex_flow: column + pad_column: 5px + # Layout applied to the top display layer. + - lvgl.update: + top_layer: + layout: + flex_flow: row + # Styling applied to the bottom display layer (exercises the + # layers code path that previously generated no code). + - lvgl.update: + bottom_layer: + bg_color: 0x123456 diff --git a/tests/component_tests/lvgl/test_layout_update.py b/tests/component_tests/lvgl/test_layout_update.py new file mode 100644 index 0000000000..b9730df379 --- /dev/null +++ b/tests/component_tests/lvgl/test_layout_update.py @@ -0,0 +1,208 @@ +"""Tests for updating LVGL layout options via the update actions. + +The ``lvgl.update`` and ``lvgl.widget.update`` (and per-widget +``lvgl..update``) actions can change a container's layout *options* at +runtime. The layout ``type`` and the grid ``grid_rows``/``grid_columns`` +structure are fixed at widget creation (they determine the cells/options +available to child widgets), so only the simple style options - those applied +via ``lv_obj_set_style_...`` calls - may be changed. + +These tests cover both the ``layout_validator`` (schema/normalisation) and the +generated C++ for each target: a widget, the active screen (top-level +``lvgl.update``) and the display layers. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from voluptuous import Invalid + +from esphome.__main__ import generate_cpp_contents +from esphome.components.lvgl.defines import TYPE_FLEX, TYPE_GRID, get_lv_uses +from esphome.components.lvgl.layout import layout_validator +from esphome.config import read_config +from esphome.core import CORE + +# --------------------------------------------------------------------------- +# layout_validator - schema and normalisation +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "value,expected", + [ + ({"flex_flow": "row"}, {"flex_flow": "LV_FLEX_FLOW_ROW"}), + ({"flex_align_main": "center"}, {"flex_align_main": "LV_FLEX_ALIGN_CENTER"}), + ({"flex_align_cross": "end"}, {"flex_align_cross": "LV_FLEX_ALIGN_END"}), + ( + {"grid_column_align": "space_between"}, + {"grid_column_align": "LV_GRID_ALIGN_SPACE_BETWEEN"}, + ), + ({"grid_row_align": "center"}, {"grid_row_align": "LV_GRID_ALIGN_CENTER"}), + ({"pad_row": "7px"}, {"pad_row": 7}), + ({"pad_column": "5px"}, {"pad_column": 5}), + ], +) +def test_layout_validator_normalises_options(value: dict, expected: dict) -> None: + """Each supported option is accepted and normalised to its LVGL form.""" + assert layout_validator(value) == expected + + +def test_layout_validator_accepts_multiple_options() -> None: + """Several options may be combined in one update.""" + result = layout_validator( + {"flex_flow": "column", "flex_align_main": "center", "pad_row": "4px"} + ) + assert result == { + "flex_flow": "LV_FLEX_FLOW_COLUMN", + "flex_align_main": "LV_FLEX_ALIGN_CENTER", + "pad_row": 4, + } + + +@pytest.mark.parametrize( + "value", + [ + {"type": "flex"}, + {"type": "grid", "grid_column_align": "center"}, + {"grid_rows": 3}, + {"grid_columns": ["fr(1)"]}, + {"grid_rows": [1, 2], "flex_flow": "row"}, + ], +) +def test_layout_validator_rejects_structural_keys(value: dict) -> None: + """The layout type and grid structure are fixed at creation and must not + be changeable via an update action.""" + with pytest.raises(Invalid, match="extra keys not allowed"): + layout_validator(value) + + +def test_layout_validator_rejects_empty() -> None: + """An update must specify at least one layout option.""" + with pytest.raises(Invalid, match="at least one layout option"): + layout_validator({}) + + +def test_layout_validator_registers_flex_use() -> None: + """Validating a flex option registers the flex feature so LV_USE_FLEX is + emitted even when the option is set solely via an update action.""" + layout_validator({"flex_flow": "row"}) + assert TYPE_FLEX in get_lv_uses() + + +def test_layout_validator_registers_grid_use() -> None: + """Validating a grid option registers the grid feature.""" + layout_validator({"grid_column_align": "center"}) + assert TYPE_GRID in get_lv_uses() + + +def test_pad_only_update_registers_no_layout_use() -> None: + """Padding options belong to both layout types, so they alone do not force + either feature on.""" + layout_validator({"pad_row": "4px"}) + uses = get_lv_uses() + assert TYPE_FLEX not in uses + assert TYPE_GRID not in uses + + +# --------------------------------------------------------------------------- +# Generated C++ for the update actions +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def main_cpp(request: pytest.FixtureRequest) -> str: + """Generate the C++ output for the shared layout-update YAML config once + per module (codegen is relatively expensive).""" + config_path = Path(request.fspath).parent / "config" / "layout_update_test.yaml" + original_path = CORE.config_path + try: + CORE.config_path = config_path + CORE.config = read_config({}) + generate_cpp_contents(CORE.config) + return CORE.cpp_global_section + CORE.cpp_main_section + finally: + CORE.config_path = original_path + CORE.reset() + + +def test_widget_flex_update_applies_partial_options(main_cpp: str) -> None: + """``lvgl.widget.update`` changes only the flex options that are specified, + via the appropriate ``lv_obj_set_style_...``/``lv_obj_set_flex_flow`` + calls on the target widget.""" + assert "lv_obj_set_flex_flow(flex_box, LV_FLEX_FLOW_COLUMN)" in main_cpp + assert ( + "lv_obj_set_style_flex_main_place(flex_box, LV_FLEX_ALIGN_CENTER, LV_STATE_DEFAULT)" + in main_cpp + ) + assert ( + "lv_obj_set_style_flex_cross_place(flex_box, LV_FLEX_ALIGN_END, LV_STATE_DEFAULT)" + in main_cpp + ) + assert "lv_obj_set_style_pad_row(flex_box, 7, LV_STATE_DEFAULT)" in main_cpp + + +def test_widget_flex_update_does_not_change_type(main_cpp: str) -> None: + """The update must not re-establish the layout type: ``lv_obj_set_layout`` + is emitted once (at creation) and never from the update action.""" + assert main_cpp.count("lv_obj_set_layout(flex_box,") == 1 + + +def test_widget_flex_update_is_partial(main_cpp: str) -> None: + """An option that was not specified in the update (the track placement) is + only set at creation, not by the partial update.""" + assert main_cpp.count("lv_obj_set_style_flex_track_place(flex_box,") == 1 + + +def test_widget_grid_update_applies_alignments(main_cpp: str) -> None: + """``lvgl.widget.update`` on a grid container changes its alignment + options without touching the grid structure.""" + assert ( + "lv_obj_set_style_grid_column_align(grid_box, LV_GRID_ALIGN_SPACE_BETWEEN, " + "LV_STATE_DEFAULT)" in main_cpp + ) + assert ( + "lv_obj_set_style_grid_row_align(grid_box, LV_GRID_ALIGN_CENTER, LV_STATE_DEFAULT)" + in main_cpp + ) + + +def test_grid_update_does_not_regenerate_descriptor_arrays(main_cpp: str) -> None: + """The grid row/column descriptor arrays are structural and generated once + at creation; an update must not regenerate them.""" + assert main_cpp.count("grid_box_row_dsc") != 0 + # The descriptor array is declared once and referenced once at creation. + assert main_cpp.count("grid_box_row_dsc") == main_cpp.count("grid_box_column_dsc") + assert "lv_obj_set_layout(grid_box," in main_cpp + assert main_cpp.count("lv_obj_set_layout(grid_box,") == 1 + + +def test_top_level_layout_targets_active_screen(main_cpp: str) -> None: + """A top-level ``lvgl.update: { layout: ... }`` applies to the active + screen, not to the LVGL component object.""" + assert ( + "lv_obj_set_flex_flow(lvgl_id->get_screen_active(), LV_FLEX_FLOW_COLUMN)" + in main_cpp + ) + assert ( + "lv_obj_set_style_pad_column(lvgl_id->get_screen_active(), 5, LV_STATE_DEFAULT)" + in main_cpp + ) + + +def test_top_layer_layout_applied(main_cpp: str) -> None: + """A layout under ``top_layer`` is applied to the display's top layer.""" + assert "lv_display_get_layer_top(lvgl_id->get_disp())" in main_cpp + assert "lv_obj_set_flex_flow(top_layer_VAR_, LV_FLEX_FLOW_ROW)" in main_cpp + + +def test_bottom_layer_styling_applied(main_cpp: str) -> None: + """A ``bottom_layer`` style update generates code (previously the layer + keys of ``lvgl.update`` were silently ignored).""" + assert "lv_display_get_layer_bottom(lvgl_id->get_disp())" in main_cpp + assert ( + "lv_obj_set_style_bg_color(bottom_layer_VAR_, lv_color_make(18, 52, 86), " + "LV_PART_MAIN)" in main_cpp + ) diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index d6cd3821f9..f085b62cb6 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -46,6 +46,40 @@ lvgl: - lvgl.display.set_rotation: rotation: 0 lvgl_id: lvgl_id + - lvgl.display.set_rotation: + rotation: !lambda "return 180;" + lvgl_id: lvgl_id + on_landscape: + - logger.log: LVGL display is now landscape + # Re-layout a container in response to orientation changes. The layout type + # and grid structure are fixed at creation; only the style options change. + - lvgl.widget.update: + id: grid_rows_only_shorthand + layout: + grid_column_align: center + grid_row_align: space_between + pad_row: 4px + - lvgl.update: + top_layer: + layout: + flex_flow: row + on_portrait: + - logger.log: LVGL display is now portrait + - lvgl.widget.update: + id: grid_rows_only_shorthand + layout: + grid_column_align: start + pad_row: 2px + # Top-level layout applies to the active screen + - lvgl.update: + layout: + flex_flow: column + pad_row: 8px + - lvgl.update: + top_layer: + layout: + flex_flow: column + flex_align_main: center on_boot: - logger.log: LVGL has started From 7c130fc9706da963904d170cefaf035e7d301ac4 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:58:40 +1200 Subject: [PATCH 0795/1815] [core] Hide build & framework internals from the visual editor (#17449) --- esphome/components/esp32/__init__.py | 28 +++++++++----- esphome/components/esp8266/__init__.py | 8 +++- esphome/components/libretiny/__init__.py | 5 ++- esphome/components/nrf52/__init__.py | 4 +- esphome/components/rp2/__init__.py | 8 +++- esphome/core/config.py | 46 +++++++++++++++++------ tests/component_tests/esp32/test_esp32.py | 34 +++++++++++++++++ tests/unit_tests/core/test_config.py | 31 +++++++++++++++ 8 files changed, 136 insertions(+), 28 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index e8d1fe73c7..7c926fe28e 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1578,16 +1578,20 @@ FRAMEWORK_SCHEMA = cv.Schema( { cv.Optional(CONF_TYPE): cv.one_of(FRAMEWORK_ESP_IDF, FRAMEWORK_ARDUINO), cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict, - cv.Optional(CONF_RELEASE): cv.string_strict, - cv.Optional(CONF_SOURCE): cv.string_strict, - cv.Optional(CONF_PLATFORM_VERSION): _parse_pio_platform_version, - cv.Optional(CONF_SDKCONFIG_OPTIONS, default={}): { - cv.string_strict: cv.string_strict - }, + cv.Optional(CONF_RELEASE, visibility=cv.Visibility.YAML_ONLY): cv.string_strict, + cv.Optional(CONF_SOURCE, visibility=cv.Visibility.YAML_ONLY): cv.string_strict, + cv.Optional( + CONF_PLATFORM_VERSION, visibility=cv.Visibility.YAML_ONLY + ): _parse_pio_platform_version, + cv.Optional( + CONF_SDKCONFIG_OPTIONS, default={}, visibility=cv.Visibility.YAML_ONLY + ): {cv.string_strict: cv.string_strict}, cv.Optional(CONF_LOG_LEVEL, default="ERROR"): cv.one_of( *LOG_LEVELS_IDF, upper=True ), - cv.Optional(CONF_ADVANCED, default={}): cv.Schema( + cv.Optional( + CONF_ADVANCED, default={}, visibility=cv.Visibility.YAML_ONLY + ): cv.Schema( { cv.Optional(CONF_ASSERTION_LEVEL): cv.one_of( *ASSERTION_LEVELS, upper=True @@ -1677,7 +1681,9 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_DISABLE_FATFS, default=True): cv.boolean, } ), - cv.Optional(CONF_COMPONENTS, default=[]): cv.ensure_list( + cv.Optional( + CONF_COMPONENTS, default=[], visibility=cv.Visibility.YAML_ONLY + ): cv.ensure_list( cv.All( cv.Any( cv.All(cv.string_strict, _parse_idf_component), @@ -1777,7 +1783,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_FLASH_FREQUENCY): cv.one_of( *FLASH_FREQUENCIES, upper=True ), - cv.Optional(CONF_PARTITIONS): cv.Any( + cv.Optional(CONF_PARTITIONS, visibility=cv.Visibility.YAML_ONLY): cv.Any( cv.file_, cv.ensure_list( cv.All( @@ -1801,7 +1807,9 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_VARIANT): cv.one_of(*VARIANTS, upper=True), cv.Optional(CONF_FRAMEWORK): FRAMEWORK_SCHEMA, - cv.Optional(CONF_TOOLCHAIN): _validate_toolchain, + cv.Optional( + CONF_TOOLCHAIN, visibility=cv.Visibility.ADVANCED + ): _validate_toolchain, cv.Optional(CONF_WATCHDOG_TIMEOUT, default="5s"): cv.All( cv.positive_time_period_seconds, cv.Range(min=cv.TimePeriod(seconds=5), max=cv.TimePeriod(seconds=60)), diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index ab742db065..0e0e2f77d7 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -202,8 +202,12 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All( cv.Schema( { cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict, - cv.Optional(CONF_SOURCE): cv.string_strict, - cv.Optional(CONF_PLATFORM_VERSION): _parse_platform_version, + cv.Optional( + CONF_SOURCE, visibility=cv.Visibility.YAML_ONLY + ): cv.string_strict, + cv.Optional( + CONF_PLATFORM_VERSION, visibility=cv.Visibility.YAML_ONLY + ): _parse_platform_version, } ), _arduino_check_versions, diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 079bb32aab..3fde11b1eb 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -257,7 +257,10 @@ FRAMEWORK_SCHEMA = cv.All( cv.Schema( { cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict, - cv.Optional(CONF_SOURCE): cv.string_strict, + # Raw PlatformIO package source — build internal, not a UI field. + cv.Optional( + CONF_SOURCE, visibility=cv.Visibility.YAML_ONLY + ): cv.string_strict, cv.Optional(CONF_LOGLEVEL, default="warn"): ( cv.one_of(*LT_LOGLEVELS, upper=True) ), diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 692b2637b2..8d522a8740 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -250,7 +250,9 @@ CONFIG_SCHEMA = cv.All( { cv.Optional(CONF_VERSION): cv.string_strict, cv.Optional(CONF_LIBC_NANO, default=True): cv.boolean, - cv.Optional(CONF_ADVANCED, default={}): cv.Schema( + cv.Optional( + CONF_ADVANCED, default={}, visibility=cv.Visibility.YAML_ONLY + ): cv.Schema( { cv.Optional( CONF_ENABLE_OTA_ROLLBACK, default=True diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index 21a885a7cf..fad9d3d25b 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -244,8 +244,12 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All( cv.Schema( { cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict, - cv.Optional(CONF_SOURCE): cv.string_strict, - cv.Optional(CONF_PLATFORM_VERSION): _parse_platform_version, + cv.Optional( + CONF_SOURCE, visibility=cv.Visibility.YAML_ONLY + ): cv.string_strict, + cv.Optional( + CONF_PLATFORM_VERSION, visibility=cv.Visibility.YAML_ONLY + ): _parse_platform_version, } ), _arduino_check_versions, diff --git a/esphome/core/config.py b/esphome/core/config.py index 5b95ac3a50..6b24a55487 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -284,14 +284,24 @@ CONFIG_SCHEMA = cv.All( 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( + cv.Required(CONF_BUILD_PATH, visibility=cv.Visibility.YAML_ONLY): cv.string, + cv.Optional( + CONF_PLATFORMIO_OPTIONS, + default={}, + visibility=cv.Visibility.YAML_ONLY, + ): cv.Schema( { cv.string_strict: cv.Any([cv.string], cv.string), } ), - cv.Optional(CONF_BUILD_FLAGS, default=[]): cv.ensure_list(cv.string_strict), - cv.Optional(CONF_ENVIRONMENT_VARIABLES, default={}): cv.Schema( + cv.Optional( + CONF_BUILD_FLAGS, default=[], visibility=cv.Visibility.YAML_ONLY + ): cv.ensure_list(cv.string_strict), + cv.Optional( + CONF_ENVIRONMENT_VARIABLES, + default={}, + visibility=cv.Visibility.YAML_ONLY, + ): cv.Schema( { cv.string_strict: cv.string, } @@ -313,12 +323,20 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(LoopTrigger), } ), - cv.Optional(CONF_INCLUDES, default=[]): cv.ensure_list(valid_include), - cv.Optional(CONF_INCLUDES_C, default=[]): cv.ensure_list(valid_include), - cv.Optional(CONF_LIBRARIES, default=[]): cv.ensure_list(cv.string_strict), + cv.Optional( + CONF_INCLUDES, default=[], visibility=cv.Visibility.YAML_ONLY + ): cv.ensure_list(valid_include), + cv.Optional( + CONF_INCLUDES_C, default=[], visibility=cv.Visibility.YAML_ONLY + ): cv.ensure_list(valid_include), + cv.Optional( + CONF_LIBRARIES, default=[], visibility=cv.Visibility.YAML_ONLY + ): cv.ensure_list(cv.string_strict), cv.Optional(CONF_NAME_ADD_MAC_SUFFIX, default=False): cv.boolean, cv.Optional(CONF_MERGE_WARNINGS, default=True): cv.boolean, - cv.Optional(CONF_DEBUG_SCHEDULER, default=False): cv.boolean, + cv.Optional( + CONF_DEBUG_SCHEDULER, default=False, visibility=cv.Visibility.YAML_ONLY + ): cv.boolean, cv.Optional(CONF_PROJECT): cv.Schema( { cv.Required(CONF_NAME): cv.All( @@ -338,11 +356,15 @@ CONFIG_SCHEMA = cv.All( ), } ), - cv.Optional(CONF_MIN_VERSION, default=ESPHOME_VERSION): cv.All( - cv.version_number, cv.validate_esphome_version - ), cv.Optional( - CONF_COMPILE_PROCESS_LIMIT, default=_compile_process_limit_default + CONF_MIN_VERSION, + default=ESPHOME_VERSION, + visibility=cv.Visibility.ADVANCED, + ): cv.All(cv.version_number, cv.validate_esphome_version), + cv.Optional( + CONF_COMPILE_PROCESS_LIMIT, + default=_compile_process_limit_default, + visibility=cv.Visibility.ADVANCED, ): cv.int_range(min=1, max=get_usable_cpu_count()), cv.Optional(CONF_AREAS, default=[]): cv.ensure_list(AREA_SCHEMA), cv.Optional(CONF_DEVICES, default=[]): cv.ensure_list(DEVICE_SCHEMA), diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index d53e119e9f..dd8881e46f 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -601,6 +601,40 @@ def test_network_wifi_ble_coexistence_reconciles_end_to_end( assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig +def test_esp32_build_internals_are_yaml_only() -> None: + """ESP32 raw framework / build inputs are ``YAML_ONLY``. + + The framework block's PlatformIO package pins, raw ESP-IDF + sdkconfig options, the low-level ``advanced`` block, extra IDF + component sources, plus the partition table and toolchain override + on the main schema are build internals — never UI form fields. + User-facing choices (framework type/version, board, variant, …) + stay on the main form. + """ + from esphome.components.esp32 import CONFIG_SCHEMA, FRAMEWORK_SCHEMA + + fw_markers = {str(k): k for k in FRAMEWORK_SCHEMA.schema} + for field in ( + "release", + "source", + "platform_version", + "sdkconfig_options", + "advanced", + "components", + ): + assert fw_markers[field].visibility is cv.Visibility.YAML_ONLY, field + # Framework type/version remain user-facing. + assert fw_markers["type"].visibility is None + assert fw_markers["version"].visibility is None + + main_markers = {str(k): k for k in CONFIG_SCHEMA.validators[0].schema} + assert main_markers["partitions"].visibility is cv.Visibility.YAML_ONLY + # toolchain is a real but rarely-touched override -> advanced disclosure. + assert main_markers["toolchain"].visibility is cv.Visibility.ADVANCED + assert main_markers["board"].visibility is None + assert main_markers["flash_size"].visibility is None + + def test_downgrade_protection_passes_with_numeric_version_and_signing() -> None: assert _ota_downgrade_protection_errors("1.2.3", signed_ota_enabled=True) == [] diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index b3d87f6857..6fd9f4c22c 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -1307,3 +1307,34 @@ async def test_to_code_adds_libraries(yaml_file: Callable[[str], Path]) -> None: mock_cg.add_library.assert_any_call( "noise-c", None, "https://github.com/esphome/noise-c.git" ) + + +def test_esphome_build_internals_are_yaml_only() -> None: + """Raw build-system inputs in the ``esphome:`` block are ``YAML_ONLY``. + + These knobs (compiler flags, raw PlatformIO options, C/C++ includes, + libraries, build host parallelism, the min-version gate, …) are not + meaningful as visual-editor form fields and a wrong value breaks the + build, so they must never render in a schema-aware UI. + """ + # CONFIG_SCHEMA is cv.All(cv.Schema({...}), validate_hostname). + inner = config.CONFIG_SCHEMA.validators[0].schema + markers = {str(k): k for k in inner} + yaml_only_fields = { + CONF_BUILD_PATH, + "platformio_options", + "build_flags", + "environment_variables", + "includes", + "includes_c", + "libraries", + "debug_scheduler", + } + for field in yaml_only_fields: + assert markers[field].visibility is cv.Visibility.YAML_ONLY, field + # Packaging / build-host knobs are real but rarely-touched overrides: + # surface them under the editor's advanced disclosure, not yaml-only. + for field in ("min_version", "compile_process_limit"): + assert markers[field].visibility is cv.Visibility.ADVANCED, field + # A regular device-config field stays on the main form. + assert markers[CONF_NAME_ADD_MAC_SUFFIX].visibility is None From 8ccf0dbd37f0febd2bc990465a11d2af5bbfb9e2 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:04:43 +1000 Subject: [PATCH 0796/1815] [gsl3670] Add new touchscreen component (#16285) --- CODEOWNERS | 1 + esphome/components/gsl3670/__init__.py | 1 + .../gsl3670/gsl3670_touchscreen.cpp | 167 +++++++++++ .../components/gsl3670/gsl3670_touchscreen.h | 50 ++++ esphome/components/gsl3670/touchscreen.py | 209 ++++++++++++++ esphome/components/touchscreen/__init__.py | 89 ++++-- tests/component_tests/gsl3670/__init__.py | 0 tests/component_tests/gsl3670/test_init.py | 260 ++++++++++++++++++ .../components/gsl3670/test.esp32-s3-idf.yaml | 28 ++ 9 files changed, 780 insertions(+), 25 deletions(-) create mode 100644 esphome/components/gsl3670/__init__.py create mode 100644 esphome/components/gsl3670/gsl3670_touchscreen.cpp create mode 100644 esphome/components/gsl3670/gsl3670_touchscreen.h create mode 100644 esphome/components/gsl3670/touchscreen.py create mode 100644 tests/component_tests/gsl3670/__init__.py create mode 100644 tests/component_tests/gsl3670/test_init.py create mode 100644 tests/components/gsl3670/test.esp32-s3-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 619fc14087..0f43cd9749 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -209,6 +209,7 @@ esphome/components/gree/switch/* @nagyrobi esphome/components/grove_gas_mc_v2/* @YorkshireIoT esphome/components/grove_tb6612fng/* @max246 esphome/components/growatt_solar/* @leeuwte +esphome/components/gsl3670/* @clydebarrow esphome/components/gt911/* @clydebarrow @jesserockz esphome/components/haier/* @paveldn esphome/components/haier/binary_sensor/* @paveldn diff --git a/esphome/components/gsl3670/__init__.py b/esphome/components/gsl3670/__init__.py new file mode 100644 index 0000000000..c58ce8a01e --- /dev/null +++ b/esphome/components/gsl3670/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@clydebarrow"] diff --git a/esphome/components/gsl3670/gsl3670_touchscreen.cpp b/esphome/components/gsl3670/gsl3670_touchscreen.cpp new file mode 100644 index 0000000000..9115130f4a --- /dev/null +++ b/esphome/components/gsl3670/gsl3670_touchscreen.cpp @@ -0,0 +1,167 @@ +#include "gsl3670_touchscreen.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" + +namespace esphome::gsl3670 { + +static const char *const TAG = "gsl3670.touchscreen"; +static const size_t MAX_TOUCHES = 3; +// --------------------------------------------------------------------------- +// setup() – mirrors esp_lcd_touch_gsl3670_init() in the Seeed BSP: +// clear_reg → reset → load_fw → startup_chip → reset → startup_chip +// --------------------------------------------------------------------------- +void GSL3670Touchscreen::setup() { + ESP_LOGCONFIG(TAG, "Setting up GSL3670 touchscreen..."); + + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + } + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); + } + + if (this->x_raw_max_ == this->x_raw_min_) { + this->x_raw_max_ = this->display_->get_native_width(); + } + if (this->y_raw_max_ == this->y_raw_min_) { + this->y_raw_max_ = this->display_->get_native_height(); + } + + this->clear_reg_(); + this->reset_(); + this->load_firmware_(); + this->startup_chip_(); + this->reset_(); + this->startup_chip_(); + + ESP_LOGCONFIG(TAG, "GSL3670 initialised OK"); +} + +void GSL3670Touchscreen::dump_config() { + ESP_LOGCONFIG(TAG, + "GSL3670 Touchscreen:\n" + " X-raw-max: %d\n" + " Y-raw-max: %d\n", + this->x_raw_max_, this->y_raw_max_); + LOG_I2C_DEVICE(this); + LOG_PIN(" Reset Pin: ", this->reset_pin_); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + ESP_LOGCONFIG(TAG, " Firmware records: %zu", this->firmware_len_); +} + +// --------------------------------------------------------------------------- +// update_touches() – mirrors esp_lcd_touch_gsl3670_read_data() in Seeed BSP +// --------------------------------------------------------------------------- +void GSL3670Touchscreen::update_touches() { + uint8_t buf[44] = {}; + auto err = this->read_register(0x80, buf, sizeof(buf)); + if (err != i2c::ERROR_OK) { + ESP_LOGW(TAG, "I2C read failed (%d)", err); + return; + } + uint8_t finger_num = clamp_at_most(buf[0], MAX_TOUCHES); + + // Build gsl_touch_info exactly as the Seeed driver does + for (uint8_t j = 0; j != finger_num; j++) { + // buf[(j+1)*4 + 0..3]: byte0=y_lo, byte1=y_hi, byte2=x_lo, byte3=id|x_hi + auto x = (uint16_t) (((buf[(j + 1) * 4 + 3] & 0x0f) << 8) | buf[(j + 1) * 4 + 2]); + auto y = (uint16_t) ((buf[(j + 1) * 4 + 1] << 8) | buf[(j + 1) * 4 + 0]); + auto id = (buf[(j + 1) * 4 + 3] >> 4) & 0x0f; + ESP_LOGV(TAG, "Touch id=%u, x=%u y=%u", id, x, y); + if (x <= 8192 && y <= 8192) + this->add_raw_touch_position_(id, x, y); + } +} + +// --------------------------------------------------------------------------- +// clear_reg_() – mirrors esp_lcd_touch_gsl3670_clear_reg() +// GPIO reset → write 0x01 to 0x88 → write 0x04 to 0xe4 → write 0x00 to 0xe0 +// --------------------------------------------------------------------------- +void GSL3670Touchscreen::clear_reg_() { + ESP_LOGD(TAG, "clear_reg"); + + // GPIO reset pulse + if (this->reset_pin_ != nullptr) { + this->reset_pin_->digital_write(false); + delay(1); + this->reset_pin_->digital_write(true); + delay(5); + } + + this->write_reg8_(0x88, 0x01); + // delay(5); + this->write_reg8_(0xe4, 0x04); + // delay(5); + this->write_reg8_(0xe0, 0x00); + // delay(5); +} + +// --------------------------------------------------------------------------- +// reset_() – mirrors touch_gsl3670_reset() +// GPIO reset → write 0x04 to 0xe4 → write 4×0x00 to 0xbc +// --------------------------------------------------------------------------- +void GSL3670Touchscreen::reset_() { + ESP_LOGD(TAG, "reset"); + + if (this->reset_pin_ != nullptr) { + this->reset_pin_->digital_write(false); + delay(1); + this->reset_pin_->digital_write(true); + delay(5); + } + + this->write_reg8_(0xe4, 0x04); + + uint8_t zeros[4] = {0, 0, 0, 0}; + this->write_reg_(0xbc, zeros, 4); +} + +void GSL3670Touchscreen::load_firmware_() { + if (firmware_ == nullptr || firmware_len_ == 0) { + ESP_LOGW(TAG, "No firmware supplied – skipping"); + return; + } + + ESP_LOGD(TAG, "Loading firmware (%zu blocks)...", firmware_len_); + + static constexpr size_t FW_BLK_SIZE = 128 + 4; + + for (size_t i = 0; i != this->firmware_len_; i++) { + auto offset = i * FW_BLK_SIZE; + uint8_t val = this->firmware_[offset + 0]; + ESP_LOGV(TAG, "Firmware address 0x%02X", val); + this->write_reg_(0xf0, &val, 1); + this->write_reg_(0, this->firmware_ + offset + 4, 128); + } + ESP_LOGD(TAG, "Firmware load complete"); +} + +// --------------------------------------------------------------------------- +// startup_chip_() – mirrors esp_lcd_touch_gsl3670_startup_chip() +// write 0x00 to 0xe0 +// --------------------------------------------------------------------------- +void GSL3670Touchscreen::startup_chip_() { + ESP_LOGD(TAG, "startup_chip"); + this->write_reg8_(0xe0, 0x00); + delay(5); +} + +// --------------------------------------------------------------------------- +// I2C helpers +// --------------------------------------------------------------------------- + +bool GSL3670Touchscreen::write_reg_(uint8_t reg, const uint8_t *data, size_t len) { + auto err = this->write_register(reg, data, len); + if (err != i2c::ERROR_OK) { + ESP_LOGW(TAG, "I2C write reg 0x%02X len %zu failed (%d)", reg, len, err); + return false; + } + return true; +} + +bool GSL3670Touchscreen::write_reg8_(uint8_t reg, uint8_t val) { return write_reg_(reg, &val, 1); } + +} // namespace esphome::gsl3670 diff --git a/esphome/components/gsl3670/gsl3670_touchscreen.h b/esphome/components/gsl3670/gsl3670_touchscreen.h new file mode 100644 index 0000000000..3cce074f9b --- /dev/null +++ b/esphome/components/gsl3670/gsl3670_touchscreen.h @@ -0,0 +1,50 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/touchscreen/touchscreen.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::gsl3670 { + +// --------------------------------------------------------------------------- +// GSL3670 touchscreen ESPHome component +// --------------------------------------------------------------------------- +class GSL3670Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { + public: + /// Supply the firmware table (generated by codegen from the YAML) + void set_firmware(const uint8_t *fw, size_t len) { + this->firmware_ = fw; + this->firmware_len_ = len; + } + + void set_interrupt_pin(InternalGPIOPin *pin) { interrupt_pin_ = pin; } + void set_reset_pin(GPIOPin *pin) { reset_pin_ = pin; } + + // touchscreen::Touchscreen / Component interface + void setup() override; + void dump_config() override; + + protected: + void update_touches() override; + + private: + // ---------- init steps (mirrors esp_lcd_touch_gsl3670_init) ---------- + void clear_reg_(); // GPIO reset + 0x88/0xe4/0xe0 sequence + void reset_(); // GPIO reset + 0xe4/0xbc sequence + void load_firmware_(); // write GSLX670_FW table + void startup_chip_(); // 0x00→0xe0 + gsl_DataInit + + // ---------- I2C helpers ---------- + bool write_reg_(uint8_t reg, const uint8_t *data, size_t len); + bool write_reg8_(uint8_t reg, uint8_t val); + + InternalGPIOPin *interrupt_pin_{nullptr}; + GPIOPin *reset_pin_{nullptr}; + + const uint8_t *firmware_{nullptr}; + size_t firmware_len_{0}; +}; + +} // namespace esphome::gsl3670 diff --git a/esphome/components/gsl3670/touchscreen.py b/esphome/components/gsl3670/touchscreen.py new file mode 100644 index 0000000000..11bb24ce44 --- /dev/null +++ b/esphome/components/gsl3670/touchscreen.py @@ -0,0 +1,209 @@ +"""ESPHome codegen for the gsl3670 touchscreen sub-platform.""" + +import hashlib +import logging +from pathlib import Path + +from esphome import external_files, pins +import esphome.codegen as cg +from esphome.components import i2c, touchscreen +from esphome.components.const import CONF_SHA256 +from esphome.components.touchscreen import ( + CONF_X_MAX, + CONF_X_MIN, + CONF_Y_MAX, + CONF_Y_MIN, + option_with_default, + touchscreen_schema, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_FILE, + CONF_ID, + CONF_INTERRUPT_PIN, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_MODEL, + CONF_RESET_PIN, + CONF_SWAP_XY, + CONF_URL, +) +from esphome.core import ID + +DEPENDENCIES = ["i2c"] +AUTO_LOAD = ["touchscreen"] +LOGGER = logging.getLogger(__name__) + +DOMAIN = "gsl3670" + +gsl3670_ns = cg.esphome_ns.namespace("gsl3670") +GSL3670Touchscreen = gsl3670_ns.class_( + "GSL3670Touchscreen", + touchscreen.Touchscreen, + i2c.I2CDevice, +) + +CONF_FIRMWARE = "firmware" + +# Firmware blobs are published as release assets of the companion repository +# rather than vendored into the ESPHome source tree. The default URL/SHA-256 +# for each model point at a pinned release artifact; users may override them +# (or supply a local file via `firmware: { file: ... }`). +FIRMWARE_RELEASE = "v1.0.0" +FIRMWARE_BASE_URL = f"https://github.com/esphome-libs/gsl3670-firmware/releases/download/{FIRMWARE_RELEASE}" + +MODELS = { + "SEEED-RETERMINAL-D1001": { + CONF_SWAP_XY: True, + CONF_MIRROR_X: True, + CONF_MIRROR_Y: True, + CONF_X_MIN: 20, + CONF_Y_MIN: 20, + CONF_X_MAX: 872, + CONF_Y_MAX: 1644, + CONF_RESET_PIN: {"xl9535": None, "number": 14}, + CONF_INTERRUPT_PIN: 16, + CONF_FIRMWARE: { + CONF_URL: f"{FIRMWARE_BASE_URL}/seeed-d1001-fw.bin", + CONF_SHA256: "2e50501ad83656fb6fa3d92591f9f31add4d442c8e8a79f29f5c4d335bd127a4", + }, + }, + "CUSTOM": {}, +} + +_FW_BLK_SIZE = 128 + 4 + + +def _validate_firmware_data(data: bytes, source: str) -> None: + """Validate the structure of a decoded GSL3670 firmware blob.""" + blk_cnt = len(data) // _FW_BLK_SIZE + if blk_cnt == 0 or blk_cnt * _FW_BLK_SIZE != len(data): + raise cv.Invalid(f"Firmware file length is incorrect: {source}") + for i in range(0, len(data), _FW_BLK_SIZE): + if data[i] > 0xEF or data[i + 1] != 1 or data[i + 2] != 2 or data[i + 3] != 3: + raise cv.Invalid( + f"Corrupted firmware at block {i // _FW_BLK_SIZE} in: {source}" + ) + + +def _cache_path(url: str) -> Path: + """Cache path for a downloaded firmware blob, keyed by URL.""" + key = hashlib.sha256(url.encode()).hexdigest()[:8] + return external_files.compute_local_file_dir(DOMAIN) / key + + +def firmware_path(firmware: dict) -> Path: + """Return the path the firmware bytes will be read from at codegen time.""" + if path := firmware.get(CONF_FILE): + return path + return _cache_path(firmware[CONF_URL]) + + +def _validate_firmware(firmware: dict) -> dict: + """Require a single source, download (with caching), verify and validate.""" + if (CONF_FILE in firmware) == (CONF_URL in firmware): + raise cv.Invalid( + f"Exactly one of '{CONF_URL}' or '{CONF_FILE}' must be provided" + ) + + if path := firmware.get(CONF_FILE): + _validate_firmware_data(path.read_bytes(), str(path.absolute())) + return firmware + + url = firmware[CONF_URL] + data = external_files.download_content(url, _cache_path(url)) + + if expected := firmware.get(CONF_SHA256): + actual = hashlib.sha256(data).hexdigest() + if actual.lower() != expected.lower(): + raise cv.Invalid( + f"Firmware SHA-256 mismatch for {url}: " + f"expected {expected.lower()}, got {actual}", + [CONF_SHA256], + ) + else: + LOGGER.warning( + "No SHA256 provided for gsl3670 firmware - firmware integrity can not be checked" + ) + _validate_firmware_data(data, url) + return firmware + + +FIRMWARE_SCHEMA = cv.All( + cv.Schema( + { + cv.Optional(CONF_URL): cv.url, + cv.Optional(CONF_SHA256): cv.string_strict, + cv.Optional(CONF_FILE): cv.file_, + } + ), + _validate_firmware, +) + + +def _config_schema(config): + model_option = { + cv.Optional(CONF_MODEL, default="CUSTOM"): cv.one_of(*MODELS, upper=True) + } + config = cv.Schema(model_option, extra=True)(config) + defaults = MODELS[config[CONF_MODEL]] + schema = ( + touchscreen_schema(cv.UNDEFINED, False, defaults) + .extend( + { + cv.GenerateID(): cv.declare_id(GSL3670Touchscreen), + option_with_default( + CONF_INTERRUPT_PIN, defaults + ): pins.internal_gpio_input_pin_schema, + option_with_default( + CONF_RESET_PIN, defaults + ): pins.gpio_output_pin_schema, + **model_option, + option_with_default( + CONF_FIRMWARE, defaults, required=True + ): FIRMWARE_SCHEMA, + } + ) + .extend(i2c.i2c_device_schema(0x40)) + .extend(cv.COMPONENT_SCHEMA) + ) + return schema(config) + + +CONFIG_SCHEMA = _config_schema + + +def _read_firmware(config) -> bytes: + path = firmware_path(config[CONF_FIRMWARE]) + data = path.read_bytes() + LOGGER.info( + "Read gsl3670 touchscreen firmware file %s: %d bytes, %d blocks", + path.absolute(), + len(data), + len(data) // _FW_BLK_SIZE, + ) + return data + + +# --------------------------------------------------------------------------- +# Code generation +# --------------------------------------------------------------------------- +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await touchscreen.register_touchscreen(var, config) + await i2c.register_i2c_device(var, config) + + if CONF_INTERRUPT_PIN in config: + pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN]) + cg.add(var.set_interrupt_pin(pin)) + + if CONF_RESET_PIN in config: + pin = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) + cg.add(var.set_reset_pin(pin)) + + # Firmware table + data = _read_firmware(config) + fw_array = cg.progmem_array( + ID(config[CONF_ID].id + "_fw", type=cg.uint8), list(data) + ) + cg.add(var.set_firmware(fw_array, len(data) // _FW_BLK_SIZE)) diff --git a/esphome/components/touchscreen/__init__.py b/esphome/components/touchscreen/__init__.py index 4a5c03ace4..cf0c5fca19 100644 --- a/esphome/components/touchscreen/__init__.py +++ b/esphome/components/touchscreen/__init__.py @@ -60,40 +60,79 @@ def validate_calibration(calibration_config): return calibration_config -CALIBRATION_SCHEMA = cv.All( - cv.Schema( - { - cv.Required(CONF_X_MIN): cv.int_range(min=0, max=4095), - cv.Required(CONF_X_MAX): cv.int_range(min=0, max=4095), - cv.Required(CONF_Y_MIN): cv.int_range(min=0, max=4095), - cv.Required(CONF_Y_MAX): cv.int_range(min=0, max=4095), - } - ), - validate_calibration, -) +def option_with_default(option: str, defaults: dict, required: bool = False): + if option in defaults or not required: + return cv.Optional(option, default=defaults.get(option, cv.UNDEFINED)) + return cv.Required(option) -def touchscreen_schema(default_touch_timeout=cv.UNDEFINED, calibration_required=False): - calibration = ( - cv.Required(CONF_CALIBRATION) - if calibration_required - else cv.Optional(CONF_CALIBRATION) - ) +_CALIBRATION_KEYS = {CONF_X_MIN, CONF_X_MAX, CONF_Y_MIN, CONF_Y_MAX} +_TRANSFORM_KEYS = {CONF_SWAP_XY, CONF_MIRROR_X, CONF_MIRROR_Y} + + +def _calibration_schema(defaults: dict, required: bool) -> dict: + """ + Generate Calibration schema. If defaults are provided for all suboptions, + the entire calibration config is optional with a populated default value. + Otherwise, it's optional or required as specified. + """ + if _CALIBRATION_KEYS.issubset(defaults): + key = cv.Optional( + CONF_CALIBRATION, + default={k: v for k, v in defaults.items() if k in _CALIBRATION_KEYS}, + ) + elif required: + key = cv.Required(CONF_CALIBRATION) + else: + key = cv.Optional(CONF_CALIBRATION) + return { + key: cv.All( + cv.Schema( + { + option_with_default(x, defaults, True): cv.int_range( + min=0, max=4095 + ) + for x in _CALIBRATION_KEYS + } + ), + validate_calibration, + ) + } + + +def _transform_schema(defaults: dict) -> dict: + if _TRANSFORM_KEYS.issubset(defaults): + key = cv.Optional( + CONF_TRANSFORM, + default={k: v for k, v in defaults.items() if k in _TRANSFORM_KEYS}, + ) + else: + key = cv.Optional(CONF_TRANSFORM) + return { + key: cv.Schema( + { + cv.Optional(x, default=defaults.get(x, False)): cv.boolean + for x in _TRANSFORM_KEYS + } + ) + } + + +def touchscreen_schema( + default_touch_timeout=cv.UNDEFINED, + calibration_required=False, + defaults: dict = None, +) -> cv.Schema: + defaults = defaults or {} return cv.Schema( { cv.GenerateID(CONF_DISPLAY): cv.use_id(display.Display), - cv.Optional(CONF_TRANSFORM): cv.Schema( - { - cv.Optional(CONF_SWAP_XY, default=False): cv.boolean, - cv.Optional(CONF_MIRROR_X, default=False): cv.boolean, - cv.Optional(CONF_MIRROR_Y, default=False): cv.boolean, - } - ), cv.Optional(CONF_TOUCH_TIMEOUT, default=default_touch_timeout): cv.All( cv.positive_time_period_milliseconds, cv.Range(max=cv.TimePeriod(milliseconds=65535)), ), - calibration: CALIBRATION_SCHEMA, + **_transform_schema(defaults), + **_calibration_schema(defaults, calibration_required), cv.Optional(CONF_ON_TOUCH): automation.validate_automation(single=True), cv.Optional(CONF_ON_UPDATE): automation.validate_automation(single=True), cv.Optional(CONF_ON_RELEASE): automation.validate_automation(single=True), diff --git a/tests/component_tests/gsl3670/__init__.py b/tests/component_tests/gsl3670/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/gsl3670/test_init.py b/tests/component_tests/gsl3670/test_init.py new file mode 100644 index 0000000000..3778cf8aa5 --- /dev/null +++ b/tests/component_tests/gsl3670/test_init.py @@ -0,0 +1,260 @@ +"""Tests for the gsl3670 touchscreen configuration validation.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.gsl3670 import touchscreen as gsl +from esphome.const import ( + CONF_CALIBRATION, + CONF_INTERRUPT_PIN, + CONF_MODEL, + CONF_RESET_PIN, + CONF_TRANSFORM, + PlatformFramework, +) +from tests.component_tests.types import SetCoreConfigCallable + +VALID_URL = "https://example.com/fw.bin" + + +def _make_firmware(blocks: int = 2) -> bytes: + """Build a structurally valid firmware blob with ``blocks`` blocks. + + Each block is ``_FW_BLK_SIZE`` bytes: a 4-byte header (page address <= 0xEF + followed by the 1/2/3 marker bytes) and a 128-byte payload. + """ + out = bytearray() + for i in range(blocks): + out += bytes([i, 1, 2, 3]) + bytes(gsl._FW_BLK_SIZE - 4) + return bytes(out) + + +def _write_firmware(tmp_path: Path, data: bytes | None = None) -> Path: + """Write firmware bytes to a temp file and return its path.""" + path = tmp_path / "fw.bin" + path.write_bytes(_make_firmware() if data is None else data) + return path + + +# --------------------------------------------------------------------------- +# _validate_firmware_data - blob structure +# --------------------------------------------------------------------------- + + +def test_validate_firmware_data_accepts_valid_blob() -> None: + """A correctly structured blob passes validation.""" + gsl._validate_firmware_data(_make_firmware(3), "test") + + +@pytest.mark.parametrize("length", [0, gsl._FW_BLK_SIZE - 1, gsl._FW_BLK_SIZE + 1]) +def test_validate_firmware_data_rejects_bad_length(length: int) -> None: + """The blob length must be a non-zero multiple of the block size.""" + with pytest.raises(cv.Invalid, match="length is incorrect"): + gsl._validate_firmware_data(bytes(length), "test") + + +@pytest.mark.parametrize( + "index,value", + [ + (0, 0xF0), # page address must be <= 0xEF + (1, 0x00), # marker byte must be 1 + (2, 0x00), # marker byte must be 2 + (3, 0x00), # marker byte must be 3 + ], +) +def test_validate_firmware_data_rejects_corrupted_header( + index: int, value: int +) -> None: + """A block whose header bytes are wrong is reported as corrupted.""" + data = bytearray(_make_firmware(2)) + # Corrupt the header of the second block. + data[gsl._FW_BLK_SIZE + index] = value + with pytest.raises(cv.Invalid, match="Corrupted firmware at block 1"): + gsl._validate_firmware_data(bytes(data), "test") + + +# --------------------------------------------------------------------------- +# _cache_path / firmware_path +# --------------------------------------------------------------------------- + + +def test_cache_path_is_deterministic_per_url( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The cache path is derived from (and stable for) the URL.""" + monkeypatch.setattr( + gsl.external_files, "compute_local_file_dir", lambda _: tmp_path + ) + first = gsl._cache_path(VALID_URL) + assert first == gsl._cache_path(VALID_URL) + assert first != gsl._cache_path("https://example.com/other.bin") + assert first.parent == tmp_path + + +def test_firmware_path_prefers_local_file(tmp_path: Path) -> None: + """A ``file`` source is returned as-is, without consulting the cache.""" + path = _write_firmware(tmp_path) + assert gsl.firmware_path({"file": path}) == path + + +def test_firmware_path_uses_cache_for_url( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A ``url`` source resolves to the cache path for that URL.""" + monkeypatch.setattr( + gsl.external_files, "compute_local_file_dir", lambda _: tmp_path + ) + assert gsl.firmware_path({"url": VALID_URL}) == gsl._cache_path(VALID_URL) + + +# --------------------------------------------------------------------------- +# _validate_firmware / FIRMWARE_SCHEMA +# --------------------------------------------------------------------------- + + +def test_firmware_requires_exactly_one_source(tmp_path: Path) -> None: + """Supplying both, or neither, of url/file is an error.""" + path = _write_firmware(tmp_path) + with pytest.raises(cv.Invalid, match="Exactly one"): + gsl._validate_firmware({"url": VALID_URL, "file": path}) + with pytest.raises(cv.Invalid, match="Exactly one"): + gsl._validate_firmware({}) + + +def test_firmware_file_valid(tmp_path: Path) -> None: + """A valid firmware file passes the full FIRMWARE_SCHEMA.""" + path = _write_firmware(tmp_path) + result = gsl.FIRMWARE_SCHEMA({"file": str(path)}) + assert result["file"] == path + + +def test_firmware_file_corrupt_rejected(tmp_path: Path) -> None: + """A file whose contents fail the structural check is rejected.""" + path = _write_firmware(tmp_path, data=b"\x00" * (gsl._FW_BLK_SIZE * 2)) + with pytest.raises(cv.Invalid, match="Corrupted firmware"): + gsl._validate_firmware({"file": path}) + + +def test_firmware_url_downloads_and_validates( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A url source downloads the content and validates its structure.""" + data = _make_firmware() + monkeypatch.setattr( + gsl.external_files, "compute_local_file_dir", lambda _: tmp_path + ) + monkeypatch.setattr(gsl.external_files, "download_content", lambda url, path: data) + assert gsl._validate_firmware({"url": VALID_URL}) == {"url": VALID_URL} + + +def test_firmware_url_sha256_mismatch_rejected( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A configured SHA-256 that does not match the download is rejected.""" + data = _make_firmware() + monkeypatch.setattr( + gsl.external_files, "compute_local_file_dir", lambda _: tmp_path + ) + monkeypatch.setattr(gsl.external_files, "download_content", lambda url, path: data) + with pytest.raises(cv.Invalid, match="SHA-256 mismatch"): + gsl._validate_firmware({"url": VALID_URL, "sha256": "00" * 32}) + + +def test_firmware_url_invalid_structure_rejected( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Downloaded content that is not a valid blob is rejected.""" + monkeypatch.setattr( + gsl.external_files, "compute_local_file_dir", lambda _: tmp_path + ) + monkeypatch.setattr( + gsl.external_files, "download_content", lambda url, path: b"\x00\x01\x02" + ) + with pytest.raises(cv.Invalid, match="length is incorrect"): + gsl._validate_firmware({"url": VALID_URL}) + + +# --------------------------------------------------------------------------- +# CONFIG_SCHEMA +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _esp32_core(set_core_config: SetCoreConfigCallable) -> None: + """Configure the core as an ESP32 target for the schema tests.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + +def test_config_custom_model_minimal(tmp_path: Path) -> None: + """The CUSTOM model validates with an explicit firmware file and pins.""" + fw = _write_firmware(tmp_path) + result = gsl.CONFIG_SCHEMA( + { + "model": "custom", + "interrupt_pin": 16, + "reset_pin": 4, + "firmware": {"file": str(fw)}, + } + ) + assert result[CONF_MODEL] == "CUSTOM" + assert "id" in result + # The CUSTOM model supplies no transform/calibration defaults. + assert CONF_TRANSFORM not in result + assert CONF_CALIBRATION not in result + + +def test_config_custom_model_requires_firmware() -> None: + """The firmware option is required for the CUSTOM model (no default).""" + with pytest.raises(cv.Invalid, match=r"required key not provided.*firmware"): + gsl.CONFIG_SCHEMA({"model": "custom", "interrupt_pin": 16, "reset_pin": 4}) + + +def test_config_invalid_model_rejected() -> None: + """An unknown model name is rejected.""" + with pytest.raises(cv.Invalid, match="model"): + gsl.CONFIG_SCHEMA({"model": "nonexistent"}) + + +def test_config_seeed_model_applies_defaults(tmp_path: Path) -> None: + """The SEEED model populates transform and calibration defaults. + + ``reset_pin`` is overridden with a plain GPIO so the test does not depend on + the model's default IO-expander pin. + """ + fw = _write_firmware(tmp_path) + result = gsl.CONFIG_SCHEMA( + { + "model": "seeed-reterminal-d1001", + "reset_pin": 4, + "firmware": {"file": str(fw)}, + } + ) + assert result[CONF_MODEL] == "SEEED-RETERMINAL-D1001" + # Transform defaults from the model. + assert result[CONF_TRANSFORM] == { + "swap_xy": True, + "mirror_x": True, + "mirror_y": True, + } + # Calibration defaults from the model. + assert result[CONF_CALIBRATION]["x_min"] == 20 + assert result[CONF_CALIBRATION]["x_max"] == 872 + assert result[CONF_CALIBRATION]["y_min"] == 20 + assert result[CONF_CALIBRATION]["y_max"] == 1644 + # The interrupt pin default (16) is applied without being specified. + assert CONF_INTERRUPT_PIN in result + assert CONF_RESET_PIN in result + + +def test_config_rejects_non_dict() -> None: + """A non-dict configuration is rejected.""" + with pytest.raises(cv.Invalid, match="expected a dictionary"): + gsl.CONFIG_SCHEMA("not a dict") diff --git a/tests/components/gsl3670/test.esp32-s3-idf.yaml b/tests/components/gsl3670/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..2565d57f13 --- /dev/null +++ b/tests/components/gsl3670/test.esp32-s3-idf.yaml @@ -0,0 +1,28 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml + +xl9535: + id: expander + +display: + - platform: mipi_spi + spi_id: spi_bus + model: t-display-s3-pro + +psram: + mode: quad + +touchscreen: + # Firmware downloaded from the model's default release URL and cached. + - platform: gsl3670 + model: seeed-reterminal-d1001 + interrupt_pin: 18 + # Explicit firmware URL + SHA-256 override. + - platform: gsl3670 + model: seeed-reterminal-d1001 + reset_pin: 10 + interrupt_pin: 11 + firmware: + url: https://github.com/esphome-libs/gsl3670-firmware/releases/download/v1.0.0/seeed-d1001-fw.bin + sha256: 2e50501ad83656fb6fa3d92591f9f31add4d442c8e8a79f29f5c4d335bd127a4 From 0512dd23392e403f19ea1fbc89053b517adb64d0 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:52:09 -0500 Subject: [PATCH 0797/1815] Bump bundled esphome-device-builder to 1.0.24 (#17332) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 0c3b27a04d..3ecdd50008 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.23 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.24 RUN \ platformio settings set enable_telemetry No \ From 42ddf0870c9777d1a9e1d1e1a1ba37032931198a Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:02:01 -0500 Subject: [PATCH 0798/1815] Bump bundled esphome-device-builder to 1.0.25 (#17333) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 3ecdd50008..a7e9717c68 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.24 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.25 RUN \ platformio settings set enable_telemetry No \ From 7b92fe95af99e308caf03bb4d4be7bd51666fea1 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:48:08 -0500 Subject: [PATCH 0799/1815] Bump bundled esphome-device-builder to 1.0.26 (#17369) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index a7e9717c68..7cf0a3ceb6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.25 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.26 RUN \ platformio settings set enable_telemetry No \ From ca7f50f37f85df0f2d37b299a2dd16673a6f5a9a Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 09:31:21 -0400 Subject: [PATCH 0800/1815] Bump bundled esphome-device-builder to 1.0.27 (#17370) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 7cf0a3ceb6..ce2edf31cb 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.26 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.27 RUN \ platformio settings set enable_telemetry No \ From a1f819e9b840d2a8a27036f386e8fa3bbea3a127 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 23:17:49 -0500 Subject: [PATCH 0801/1815] Bump bundled esphome-device-builder to 1.0.28 (#17382) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index ce2edf31cb..683cf33cd4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.27 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.28 RUN \ platformio settings set enable_telemetry No \ From 263b3750886a5e22fba6c1496c293daa9a719d16 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:20:48 -0500 Subject: [PATCH 0802/1815] Bump bundled esphome-device-builder to 1.0.29 (#17384) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 683cf33cd4..08f8ab9931 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.28 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.29 RUN \ platformio settings set enable_telemetry No \ From f6221f000790d310076c44def6d9b6f51cf6fe50 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:31:07 -0500 Subject: [PATCH 0803/1815] Bump bundled esphome-device-builder to 1.1.0 (#17412) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 08f8ab9931..d0f2f4d1a1 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.29 +RUN uv pip install --no-cache-dir esphome-device-builder==1.1.0 RUN \ platformio settings set enable_telemetry No \ From 98b79b132af0d1cc34bf6ef3049358e31e64b3dc Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:09:05 +1200 Subject: [PATCH 0804/1815] Bump version to 2026.6.5 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index e38f280006..c5cae055e1 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.6.4 +PROJECT_NUMBER = 2026.6.5 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 81bde6dfa2..7c7f0d0d5f 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.6.4" +__version__ = "2026.6.5" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From f1622ac96a68af1cd077e41f31f5b64b5450a924 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:30:53 +1200 Subject: [PATCH 0805/1815] Bump version to 2026.7.0b1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 9f4e20b977..6f8b6e6664 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.7.0-dev +PROJECT_NUMBER = 2026.7.0b1 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 988134fa46..faa716bdd7 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.7.0-dev" +__version__ = "2026.7.0b1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 0a8a7e22d299fd0db8e2cf8fc18d3f01f98c24d0 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:30:54 +1200 Subject: [PATCH 0806/1815] Bump version to 2026.8.0-dev --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 9f4e20b977..3bb08e5b06 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.7.0-dev +PROJECT_NUMBER = 2026.8.0-dev # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 988134fa46..9dfa5cb835 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.7.0-dev" +__version__ = "2026.8.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From acc8381cbb26402b19d3ba77a222e8cd8550f029 Mon Sep 17 00:00:00 2001 From: Elvin Luff Date: Thu, 9 Jul 2026 03:29:39 +0200 Subject: [PATCH 0807/1815] [epaper_spi] Remove noop deep sleep command (#15595) --- esphome/components/epaper_spi/epaper_spi_mono.cpp | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/esphome/components/epaper_spi/epaper_spi_mono.cpp b/esphome/components/epaper_spi/epaper_spi_mono.cpp index ee117304c4..fffb2b5e84 100644 --- a/esphome/components/epaper_spi/epaper_spi_mono.cpp +++ b/esphome/components/epaper_spi/epaper_spi_mono.cpp @@ -14,10 +14,9 @@ void EPaperMono::refresh_screen(bool partial) { } void EPaperMono::deep_sleep() { - ESP_LOGV(TAG, "Deep sleep"); - if (this->is_using_partial_update_()) { - this->cmd_data(0x10, {0x00}); // sleep in power on mode - } else { + // Deep sleep loses RAM so cannot be used with partial update + if (!this->is_using_partial_update_()) { + ESP_LOGV(TAG, "Deep sleep"); this->cmd_data(0x10, {0x03}); // deep sleep } } From 9c92ab63fbc0bf3a324a2b276bf9d96bf59d3723 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:43:56 -0400 Subject: [PATCH 0808/1815] [gsl3670] Reference the test display explicitly so grouped CI builds validate (#17462) --- tests/components/gsl3670/test.esp32-s3-idf.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/components/gsl3670/test.esp32-s3-idf.yaml b/tests/components/gsl3670/test.esp32-s3-idf.yaml index 2565d57f13..48bb9982d9 100644 --- a/tests/components/gsl3670/test.esp32-s3-idf.yaml +++ b/tests/components/gsl3670/test.esp32-s3-idf.yaml @@ -7,6 +7,7 @@ xl9535: display: - platform: mipi_spi + id: gsl3670_display spi_id: spi_bus model: t-display-s3-pro @@ -17,10 +18,12 @@ touchscreen: # Firmware downloaded from the model's default release URL and cached. - platform: gsl3670 model: seeed-reterminal-d1001 + display: gsl3670_display interrupt_pin: 18 # Explicit firmware URL + SHA-256 override. - platform: gsl3670 model: seeed-reterminal-d1001 + display: gsl3670_display reset_pin: 10 interrupt_pin: 11 firmware: From 19e89aa7f222e74256f5630f607935720e668552 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:43:56 -0400 Subject: [PATCH 0809/1815] [gsl3670] Reference the test display explicitly so grouped CI builds validate (#17462) --- tests/components/gsl3670/test.esp32-s3-idf.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/components/gsl3670/test.esp32-s3-idf.yaml b/tests/components/gsl3670/test.esp32-s3-idf.yaml index 2565d57f13..48bb9982d9 100644 --- a/tests/components/gsl3670/test.esp32-s3-idf.yaml +++ b/tests/components/gsl3670/test.esp32-s3-idf.yaml @@ -7,6 +7,7 @@ xl9535: display: - platform: mipi_spi + id: gsl3670_display spi_id: spi_bus model: t-display-s3-pro @@ -17,10 +18,12 @@ touchscreen: # Firmware downloaded from the model's default release URL and cached. - platform: gsl3670 model: seeed-reterminal-d1001 + display: gsl3670_display interrupt_pin: 18 # Explicit firmware URL + SHA-256 override. - platform: gsl3670 model: seeed-reterminal-d1001 + display: gsl3670_display reset_pin: 10 interrupt_pin: 11 firmware: From 435dde67d09f34b5bd33d047eb6983d2fe653a04 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:29:23 -0400 Subject: [PATCH 0810/1815] [ci] Stop per-PR cache copies from crowding the 10GB Actions cache quota (#17463) --- .github/actions/restore-python/action.yml | 3 ++ .github/workflows/ci-api-proto.yml | 3 ++ .github/workflows/ci.yml | 65 ++++++++++++++++++++--- 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 9d78b2d843..9d6dc5301c 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -35,6 +35,9 @@ runs: uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true + # Pull request saves land in per-PR scopes nothing else can + # reuse; dev pushes seed the shared copy instead. + save-cache: ${{ github.event_name != 'pull_request' }} # Pin uv version so the action does not have to fetch the # manifest from raw.githubusercontent.com on every cache # miss; that fetch flakes on Windows runners. diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index ebbe720463..58fc83e3f5 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -32,6 +32,9 @@ jobs: uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true + # Pull-request-only workflow: a save could never be shared and + # would only consume quota. + save-cache: "false" # Pin uv version so the action does not have to fetch the # manifest from raw.githubusercontent.com on every cache # miss; that fetch flakes on Windows runners. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e93b6ece8..adf98478fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,9 @@ jobs: uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true + # Pull request saves land in per-PR scopes nothing else can + # reuse; dev pushes seed the shared copy instead. + save-cache: ${{ github.event_name != 'pull_request' }} # Pin uv version so the action does not have to fetch the # manifest from raw.githubusercontent.com on every cache # miss; that fetch flakes on Windows runners. @@ -174,6 +177,9 @@ jobs: uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true + # Pull request saves land in per-PR scopes nothing else can + # reuse; dev pushes seed the shared copy instead. + save-cache: ${{ github.event_name != 'pull_request' }} # Pin uv version so the action does not have to fetch the # manifest from raw.githubusercontent.com on every cache # miss; that fetch flakes on Windows runners. @@ -375,6 +381,9 @@ jobs: uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true + # Pull request saves land in per-PR scopes nothing else can + # reuse; dev pushes seed the shared copy instead. + save-cache: ${{ github.event_name != 'pull_request' }} # Pin uv version so the action does not have to fetch the # manifest from raw.githubusercontent.com on every cache # miss; that fetch flakes on Windows runners. @@ -828,11 +837,12 @@ jobs: - name: List components run: echo ${{ matrix.batch.components }} - - name: Cache apt packages - uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 - with: - packages: libsdl2-dev ccache - version: 1.1 + - name: Install apt packages + # Not cached: this job is pull-request-only, so a cache save could + # never be shared and would only consume quota. + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends libsdl2-dev ccache - name: Check out code from GitHub uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -1006,6 +1016,36 @@ jobs: # Arduino framework via PlatformIO (only components with an esp32-ard test are built): python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio + pre-commit-seed-cache: + name: Seed pre-commit cache + runs-on: ubuntu-latest + needs: + - common + # Saves a dev-scoped pre-commit cache that pull request runs can + # restore, since pre-commit.ci lite itself never runs on dev pushes. + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + steps: + - name: Check out code from GitHub + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + - name: Cache pre-commit environments + id: cache-pre-commit + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/pre-commit + # Must match the restore key in pre-commit-ci-lite + # yamllint disable-line rule:line-length + key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} + - name: Install pre-commit hook environments + if: steps.cache-pre-commit.outputs.cache-hit != 'true' + run: | + python -m pip install pre-commit + pre-commit install-hooks + pre-commit-ci-lite: name: pre-commit.ci lite runs-on: ubuntu-latest @@ -1021,9 +1061,22 @@ jobs: with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - uses: esphome/pre-commit-action@43cd1109c09c544d97196f7730ee5b2e0cc6d81e # v3.0.1 fork with pinned actions/cache + # Inlined from esphome/pre-commit-action with a restore-only cache + # step: the pre-commit-seed-cache job owns saving this cache, so + # pull request runs never write per-PR copies. + - name: Restore pre-commit cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/pre-commit + # Must match the key pre-commit-seed-cache saves + # yamllint disable-line rule:line-length + key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} + - name: Run pre-commit env: SKIP: pylint,ci-custom + run: | + python -m pip install pre-commit + pre-commit run --show-diff-on-failure --color=always --all-files - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 if: always() From 9f21fd0b55a9da2673a1b46d709cde1c6b196180 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:04:42 -0400 Subject: [PATCH 0811/1815] [usb_uart] Fix output chunk length truncated to 8 bits (#17480) --- esphome/components/usb_uart/usb_uart.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 482b209a3f..c289625f1a 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -160,7 +160,7 @@ void USBUartChannel::write_array(const uint8_t *data, size_t len) { } uint16_t chunk_len = std::min(len, UsbOutputChunk::MAX_CHUNK_SIZE); memcpy(chunk->data, data, chunk_len); - chunk->length = static_cast(chunk_len); + chunk->length = chunk_len; // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if // allocate() returned non-null, the queue cannot be full. this->output_queue_.push(chunk); From 4292e7988e578bf011082e75458b398684b2b3c1 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:27:05 -0400 Subject: [PATCH 0812/1815] [mcp4461] Fix wiper increment/decrement write length (#17487) --- esphome/components/mcp4461/mcp4461.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/mcp4461/mcp4461.cpp b/esphome/components/mcp4461/mcp4461.cpp index 4573553664..e83a6847d6 100644 --- a/esphome/components/mcp4461/mcp4461.cpp +++ b/esphome/components/mcp4461/mcp4461.cpp @@ -342,7 +342,7 @@ bool Mcp4461Component::increase_wiper_(Mcp4461WiperIdx wiper) { ESP_LOGV(TAG, "Increasing wiper %u", wiper_idx); uint8_t addr = this->get_wiper_address_(wiper_idx); uint8_t reg = addr | static_cast(Mcp4461Commands::INCREMENT); - auto err = this->write(&this->address_, reg); + auto err = this->write(®, 1); if (err != i2c::ERROR_OK) { this->error_code_ = MCP4461_STATUS_I2C_ERROR; this->status_set_warning(); @@ -373,7 +373,7 @@ bool Mcp4461Component::decrease_wiper_(Mcp4461WiperIdx wiper) { ESP_LOGV(TAG, "Decreasing wiper %u", wiper_idx); uint8_t addr = this->get_wiper_address_(wiper_idx); uint8_t reg = addr | static_cast(Mcp4461Commands::DECREMENT); - auto err = this->write(&this->address_, reg); + auto err = this->write(®, 1); if (err != i2c::ERROR_OK) { this->error_code_ = MCP4461_STATUS_I2C_ERROR; this->status_set_warning(); From 4b19de0c1a85ba86197e3775a8e6c9fdf6865aba Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:35:16 +0000 Subject: [PATCH 0813/1815] Bump CodSpeedHQ/action from 4.18.4 to 4.18.5 (#17489) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index adf98478fd..4e98999741 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -465,7 +465,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@9f3a37ece7abc84992501a7fcd54d1704f3458fa # v4.18.4 + uses: CodSpeedHQ/action@f99becdce5e5d51fd556489ebef684f4ecfd6286 # v4.18.5 with: run: | . venv/bin/activate From ba84f2ec552a9994a968a0d6ab5d9ff5789386ba Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:53:42 +0000 Subject: [PATCH 0814/1815] Bump aioesphomeapi from 45.5.2 to 45.6.0 (#17490) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8b028554a8..b36e70ef5d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.5.2 +aioesphomeapi==45.6.0 zeroconf==0.150.0 puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import From b2226b91ff0a28ad299972686d2108eaf82fad5b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:51:04 -0400 Subject: [PATCH 0815/1815] [web_server] Serialize entity state strings without a copy buffer (#17488) --- esphome/components/web_server/web_server.cpp | 52 +++++++++----------- 1 file changed, 22 insertions(+), 30 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index c8f66755bc..3f4d598d48 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -56,9 +56,8 @@ namespace esphome::web_server { static const char *const TAG = "web_server"; -// Longest: UPDATE AVAILABLE (16 chars + null terminator, rounded up) -static constexpr size_t PSTR_LOCAL_SIZE = 18; -#define PSTR_LOCAL(mode_s) ESPHOME_strncpy_P(buf, (ESPHOME_PGM_P) ((mode_s)), PSTR_LOCAL_SIZE - 1) +// View a state LogString as a ProgmemStr so ArduinoJson serializes it PROGMEM-aware on ESP8266. +static ProgmemStr json_state_str(const LogString *s) { return reinterpret_cast(s); } // Parse URL and return match info // URL formats (disambiguated by HTTP method for 3-segment case): @@ -578,9 +577,9 @@ static void set_json_value(JsonObject &root, EntityBase *obj, const char *prefix root[ESPHOME_F("value")] = value; } -template -static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const char *prefix, const char *state, - const T &value, JsonDetail start_config) { +template +static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const char *prefix, S state, const T &value, + JsonDetail start_config) { set_json_value(root, obj, prefix, value, start_config); root[ESPHOME_F("state")] = state; } @@ -1073,8 +1072,7 @@ json::SerializationBuffer<> WebServer::cover_json_(cover::Cover *obj, JsonDetail set_json_icon_state_value(root, obj, "cover", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, start_config); - char buf[PSTR_LOCAL_SIZE]; - root[ESPHOME_F("current_operation")] = PSTR_LOCAL(cover::cover_operation_to_str(obj->current_operation)); + root[ESPHOME_F("current_operation")] = json_state_str(cover::cover_operation_to_str(obj->current_operation)); if (obj->get_traits().get_supports_position()) root[ESPHOME_F("position")] = obj->position; @@ -1530,17 +1528,16 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json const auto traits = obj->get_traits(); int8_t target_accuracy = traits.get_target_temperature_accuracy_decimals(); int8_t current_accuracy = traits.get_current_temperature_accuracy_decimals(); - char buf[PSTR_LOCAL_SIZE]; char temp_buf[VALUE_ACCURACY_MAX_LEN]; if (start_config == DETAIL_ALL) { JsonArray opt = root[ESPHOME_F("modes")].to(); for (climate::ClimateMode m : traits.get_supported_modes()) - opt.add(PSTR_LOCAL(climate::climate_mode_to_string(m))); + opt.add(json_state_str(climate::climate_mode_to_string(m))); if (traits.get_supports_fan_modes()) { JsonArray opt = root[ESPHOME_F("fan_modes")].to(); for (climate::ClimateFanMode m : traits.get_supported_fan_modes()) - opt.add(PSTR_LOCAL(climate::climate_fan_mode_to_string(m))); + opt.add(json_state_str(climate::climate_fan_mode_to_string(m))); } if (!traits.get_supported_custom_fan_modes().empty()) { @@ -1551,12 +1548,12 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json if (traits.get_supports_swing_modes()) { JsonArray opt = root[ESPHOME_F("swing_modes")].to(); for (auto swing_mode : traits.get_supported_swing_modes()) - opt.add(PSTR_LOCAL(climate::climate_swing_mode_to_string(swing_mode))); + opt.add(json_state_str(climate::climate_swing_mode_to_string(swing_mode))); } if (traits.get_supports_presets()) { JsonArray opt = root[ESPHOME_F("presets")].to(); for (climate::ClimatePreset m : traits.get_supported_presets()) - opt.add(PSTR_LOCAL(climate::climate_preset_to_string(m))); + opt.add(json_state_str(climate::climate_preset_to_string(m))); } if (!traits.get_supported_custom_presets().empty()) { JsonArray opt = root[ESPHOME_F("custom_presets")].to(); @@ -1572,26 +1569,26 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json } bool has_state = false; - root[ESPHOME_F("mode")] = PSTR_LOCAL(climate_mode_to_string(obj->mode)); + root[ESPHOME_F("mode")] = json_state_str(climate_mode_to_string(obj->mode)); if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION)) { - root[ESPHOME_F("action")] = PSTR_LOCAL(climate_action_to_string(obj->action)); + root[ESPHOME_F("action")] = json_state_str(climate_action_to_string(obj->action)); root[ESPHOME_F("state")] = root[ESPHOME_F("action")]; has_state = true; } if (traits.get_supports_fan_modes() && obj->fan_mode.has_value()) { - root[ESPHOME_F("fan_mode")] = PSTR_LOCAL(climate_fan_mode_to_string(obj->fan_mode.value())); + root[ESPHOME_F("fan_mode")] = json_state_str(climate_fan_mode_to_string(obj->fan_mode.value())); } if (!traits.get_supported_custom_fan_modes().empty() && obj->has_custom_fan_mode()) { root[ESPHOME_F("custom_fan_mode")] = obj->get_custom_fan_mode(); } if (traits.get_supports_presets() && obj->preset.has_value()) { - root[ESPHOME_F("preset")] = PSTR_LOCAL(climate_preset_to_string(obj->preset.value())); + root[ESPHOME_F("preset")] = json_state_str(climate_preset_to_string(obj->preset.value())); } if (!traits.get_supported_custom_presets().empty() && obj->has_custom_preset()) { root[ESPHOME_F("custom_preset")] = obj->get_custom_preset(); } if (traits.get_supports_swing_modes()) { - root[ESPHOME_F("swing_mode")] = PSTR_LOCAL(climate_swing_mode_to_string(obj->swing_mode)); + root[ESPHOME_F("swing_mode")] = json_state_str(climate_swing_mode_to_string(obj->swing_mode)); } if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE)) { root[ESPHOME_F("current_temperature")] = @@ -1695,8 +1692,7 @@ json::SerializationBuffer<> WebServer::lock_json_(lock::Lock *obj, lock::LockSta json::JsonBuilder builder; JsonObject root = builder.root(); - char buf[PSTR_LOCAL_SIZE]; - set_json_icon_state_value(root, obj, "lock", PSTR_LOCAL(lock::lock_state_to_string(value)), value, start_config); + set_json_icon_state_value(root, obj, "lock", json_state_str(lock::lock_state_to_string(value)), value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1777,8 +1773,7 @@ json::SerializationBuffer<> WebServer::valve_json_(valve::Valve *obj, JsonDetail set_json_icon_state_value(root, obj, "valve", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, start_config); - char buf[PSTR_LOCAL_SIZE]; - root[ESPHOME_F("current_operation")] = PSTR_LOCAL(valve::valve_operation_to_str(obj->current_operation)); + root[ESPHOME_F("current_operation")] = json_state_str(valve::valve_operation_to_str(obj->current_operation)); if (obj->get_traits().get_supports_position()) root[ESPHOME_F("position")] = obj->position; @@ -1863,9 +1858,8 @@ json::SerializationBuffer<> WebServer::alarm_control_panel_json_(alarm_control_p json::JsonBuilder builder; JsonObject root = builder.root(); - char buf[PSTR_LOCAL_SIZE]; - set_json_icon_state_value(root, obj, "alarm-control-panel", PSTR_LOCAL(alarm_control_panel_state_to_string(value)), - value, start_config); + set_json_icon_state_value(root, obj, "alarm-control-panel", + json_state_str(alarm_control_panel_state_to_string(value)), value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1937,10 +1931,9 @@ json::SerializationBuffer<> WebServer::water_heater_all_json_generator(WebServer json::SerializationBuffer<> WebServer::water_heater_json_(water_heater::WaterHeater *obj, JsonDetail start_config) { json::JsonBuilder builder; JsonObject root = builder.root(); - char buf[PSTR_LOCAL_SIZE]; const auto mode = obj->get_mode(); - const char *mode_s = PSTR_LOCAL(water_heater::water_heater_mode_to_string(mode)); + ProgmemStr mode_s = json_state_str(water_heater::water_heater_mode_to_string(mode)); set_json_icon_state_value(root, obj, "water_heater", mode_s, mode, start_config); @@ -1949,7 +1942,7 @@ json::SerializationBuffer<> WebServer::water_heater_json_(water_heater::WaterHea if (start_config == DETAIL_ALL) { JsonArray modes = root[ESPHOME_F("modes")].to(); for (auto m : traits.get_supported_modes()) - modes.add(PSTR_LOCAL(water_heater::water_heater_mode_to_string(m))); + modes.add(json_state_str(water_heater::water_heater_mode_to_string(m))); root[ESPHOME_F("min_temp")] = traits.get_min_temperature(); root[ESPHOME_F("max_temp")] = traits.get_max_temperature(); root[ESPHOME_F("step")] = traits.get_target_temperature_step(); @@ -2277,8 +2270,7 @@ json::SerializationBuffer<> WebServer::update_json_(update::UpdateEntity *obj, J json::JsonBuilder builder; JsonObject root = builder.root(); - char buf[PSTR_LOCAL_SIZE]; - set_json_icon_state_value(root, obj, "update", PSTR_LOCAL(update::update_state_to_string(obj->state)), + set_json_icon_state_value(root, obj, "update", json_state_str(update::update_state_to_string(obj->state)), obj->update_info.latest_version, start_config); if (start_config == DETAIL_ALL) { root[ESPHOME_F("current_version")] = obj->update_info.current_version; From 7a1e0bbbeca958a4ecddcd0d3a816e40c34480a0 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:36:42 -1000 Subject: [PATCH 0816/1815] Bump bundled esphome-device-builder to 1.4.0 (#17495) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index db2e01742c..e0b44fb7b6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.3.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.4.0 RUN \ platformio settings set enable_telemetry No \ From 88ca0d44e0b0cc7d6c91b2d638ebc1ee773438e2 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:38:23 +1200 Subject: [PATCH 0817/1815] [docs] Document web server as an open HTTP API by design in threat model (#17465) --- THREAT_MODEL.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index a4355a5055..24a7fed4f2 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -79,6 +79,44 @@ These *are* security bugs in this repo, and we want to hear about them privately - Flaws that weaken the device's API encryption (Noise), OTA, or web server auth below their documented guarantees. +## The web server is an open HTTP API by design + +The `web_server` component exposes a plain HTTP interface for viewing and +controlling entities, and, when the `web_server` OTA platform is enabled, for +uploading firmware at `/update`. Its only access controls are the optional +`web_server` `auth:` credentials and the network the device sits on. + +When `auth:` is not configured, every endpoint is reachable by any client that +can reach the device. This is intentional; enabling `web_server` without `auth:` +is choosing an open control surface, in the same way that running native OTA +without a password leaves OTA open. The API is documented and is meant to be +called by other devices, scripts, and pages. + +The device performs no CSRF token, `Origin`, or `Referer` validation and returns +a permissive CORS policy. Cross-origin requests are handled the same as any other +network request, including requests a browser is induced to make by a page the +operator visits (the "confused deputy", or CSRF, pattern). The following are +therefore **not** vulnerabilities in this repository: + +- Cross-origin or CSRF requests to the control endpoints (for example, a page the + operator opens toggling a switch), whether or not `web_server` `auth:` is set. +- Cross-origin reads of device state permitted by the CORS policy. +- Cross-origin firmware upload through the web OTA endpoint (`/update`) when web + OTA is enabled without `web_server` `auth:`. This is the same exposure as + running OTA without a password. + +The supported defenses are `web_server` `auth:`, protecting OTA (a web password or +a native OTA password), and keeping devices on a trusted, segmented network. See +the security best practices guide linked above. + +What remains in scope is bypassing `web_server` `auth:` when it *is* configured, +and any memory-safety or protocol bug in the server reachable without credentials. + +This section documents the current design and scope; it is not a judgment that the +design is optimal or that it will not change. Optional hardening (for example an +origin allowlist or opt-in CSRF checks) is welcome as a normal enhancement PR, +framed as defense-in-depth rather than a security fix. + ## Explicitly out of scope - Local attackers who already have shell access on the host that runs `esphome`. @@ -86,6 +124,9 @@ These *are* security bugs in this repo, and we want to hear about them privately - Operator-supplied hostile YAML (covered above — config authoring is trusted). - Attacks that require an already-authenticated device peer (someone who already holds the API key / OTA / web credentials). +- Cross-site (CSRF), cross-origin, or CORS behavior of the device web server and + its web OTA endpoint. The web server is an open HTTP API by design (see above); + gate it with `web_server` `auth:` and network isolation. - Anything in the dashboard / device-builder — report that in its own repository (linked at the top). - Deployments where the operator removed protections or exposed credentials. See From 83aaed71e1fb8b5d33fad76f4fc7ca0cbb6aaf65 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:30:19 -1000 Subject: [PATCH 0818/1815] Bump bundled esphome-device-builder to 1.4.1 (#17507) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index e0b44fb7b6..e7f8fceb12 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.4.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.4.1 RUN \ platformio settings set enable_telemetry No \ From e1719cd85d74f12fde4e3e0d2c99aaedea70b33e Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:06:40 +1000 Subject: [PATCH 0819/1815] [mipi][mipi_spi][mipi_dsi][mipi_rgb] Transform cleanup (#17405) Co-authored-by: Claude Opus 4.8 --- esphome/components/mipi/__init__.py | 73 ++++++++-- esphome/components/mipi_dsi/display.py | 22 +-- .../components/mipi_dsi/models/__init__.py | 14 ++ esphome/components/mipi_dsi/models/guition.py | 12 +- esphome/components/mipi_dsi/models/m5stack.py | 9 +- esphome/components/mipi_dsi/models/seeed.py | 7 +- .../components/mipi_dsi/models/waveshare.py | 22 +-- esphome/components/mipi_rgb/display.py | 31 ++-- .../components/mipi_rgb/models/__init__.py | 14 ++ esphome/components/mipi_rgb/models/guition.py | 1 + esphome/components/mipi_rgb/models/lilygo.py | 6 +- esphome/components/mipi_rgb/models/rpi.py | 6 +- esphome/components/mipi_rgb/models/st7701s.py | 18 +-- esphome/components/mipi_rgb/models/sunton.py | 8 +- .../components/mipi_rgb/models/waveshare.py | 10 +- esphome/components/mipi_spi/display.py | 15 +- .../components/mipi_spi/models/adafruit.py | 2 + esphome/components/mipi_spi/models/amoled.py | 7 +- esphome/components/mipi_spi/models/ili.py | 3 + esphome/components/mipi_spi/models/jc.py | 14 +- esphome/components/mipi_spi/models/lanbon.py | 1 + esphome/components/mipi_spi/models/lilygo.py | 3 + esphome/components/mipi_spi/models/m5stack.py | 2 + .../components/mipi_spi/models/waveshare.py | 9 +- esphome/core/__init__.py | 4 + .../config/animation_platform_test.yaml | 3 + .../animation/config/animation_test.yaml | 3 + .../image/config/image_test.yaml | 3 + .../mipi_dsi/test_mipi_dsi_config.py | 39 +++++ tests/component_tests/mipi_rgb/__init__.py | 0 tests/component_tests/mipi_rgb/test_init.py | 89 ++++++++++++ .../mipi_rgb/test_mipi_rgb_config.py | 137 ++++++++++++++++++ .../mipi_spi/test_display_metadata.py | 84 ++++++++++- .../mipi_spi/test_final_validate.py | 77 ++++++++++ tests/component_tests/mipi_spi/test_init.py | 2 +- .../mipi_spi/test_padding_and_offsets.py | 4 + .../config/online_image_platform_test.yaml | 3 + .../config/online_image_test.yaml | 3 + 38 files changed, 630 insertions(+), 130 deletions(-) create mode 100644 esphome/components/mipi_rgb/models/__init__.py create mode 100644 tests/component_tests/mipi_rgb/__init__.py create mode 100644 tests/component_tests/mipi_rgb/test_init.py create mode 100644 tests/component_tests/mipi_rgb/test_mipi_rgb_config.py diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 1d6c8277e8..ab59d5ce5f 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -31,11 +31,16 @@ from esphome.const import ( CONF_TRANSFORM, CONF_WIDTH, ) -from esphome.core import TimePeriod +from esphome.core import CORE, TimePeriod from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor LOGGER = cv.logging.getLogger(__name__) +CONF_TRANSFORMS = "transforms" + +# All axis transforms a model may support, in the order they appear in the schema. +ALL_TRANSFORMS = (CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY) + ColorOrder = display_ns.enum("ColorMode") NOP = 0x00 @@ -302,7 +307,8 @@ class DriverChip: """ A class representing a MIPI DBI driver chip model. The parameters supplied as defaults will be used to provide default values for the display configuration. - Setting swap_xy to cv.UNDEFINED will indicate that the model does not support swapping X and Y axes. + Pass a ``transforms`` set to restrict which axis transforms (mirror_x, mirror_y, swap_xy) the model + supports; by default all three are available. """ models: dict[str, Self] = {} @@ -387,11 +393,15 @@ class DriverChip: """ Return the available transforms for this model. """ + if (transforms := self.get_default(CONF_TRANSFORMS, None)) is not None: + return transforms if self.get_default("no_transform", False): return set() if self.get_default(CONF_SWAP_XY) != cv.UNDEFINED: return {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY} - return {CONF_MIRROR_X, CONF_MIRROR_Y} + raise ValueError( + "Setting 'swap_xy' to 'cv.UNDEFINED' is no longer supported; set 'transforms' instead" + ) def has_hardware_transform(self, config) -> bool: """ @@ -533,17 +543,31 @@ class DriverChip: transform[CONF_TRANSFORM] = self.rotation_as_transform(config) return transform - def swap_xy_schema(self): - uses_swap = self.get_default(CONF_SWAP_XY, None) != cv.UNDEFINED + def transform_schema(self): + """ + Build the schema for the ``transform`` config option of this model. - def validator(value): - if value: - raise cv.Invalid("Axis swapping not supported by this model") - return cv.boolean(value) + Each transform the model supports is a required boolean. A transform the model does not + support may be omitted or set to ``false``; setting it to ``true`` reports a clear error + naming the unsupported transform instead of a generic "extra keys not allowed". + """ + supported = self.transforms - if uses_swap: - return {cv.Required(CONF_SWAP_XY): cv.boolean} - return {cv.Optional(CONF_SWAP_XY, default=False): validator} + def unsupported(name): + def validator(value): + if cv.boolean(value): + raise cv.Invalid(f"'{name}' is not supported by this model") + return False + + return validator + + schema = {} + for name in ALL_TRANSFORMS: + if name in supported: + schema[cv.Required(name)] = cv.boolean + else: + schema[cv.Optional(name, default=False)] = unsupported(name) + return cv.Any(cv.Schema(schema), cv.one_of(CONF_DISABLED, lower=True)) def get_madctl(self, transform: dict, config: dict) -> int: """ @@ -618,6 +642,31 @@ class DriverChip: # or the delay flag inserted where needed return flatten_sequence(sequence) + def check_requirements(self) -> None: + """ + Raise a friendly error if any component this model requires is not configured. + + This runs during schema validation (before ID references are resolved) so that a + model whose default pins live on a pin expander reports the missing expander clearly + instead of a cryptic "Couldn't find ID" from the unresolved pin reference. + """ + requirements = self.get_default("requires", set()) + if not requirements: + return + # ``raw_config`` is populated before any component schema runs during a real + # validation, so presence of a required component is simply a top-level key. + # When it is absent (e.g. a unit test that invokes the schema directly) there + # is no config to check against, so skip. + global_config = CORE.raw_config + if global_config is None: + return + missing = {x for x in requirements if x not in global_config} + if missing: + reqstr = ", ".join(f"'{x}'" for x in sorted(missing)) + raise cv.Invalid( + f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured" + ) + def requires_buffer(config) -> bool: """ diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 896140b4b1..e5bb3d413d 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -41,24 +41,21 @@ from esphome.const import ( CONF_AUTO_CLEAR_ENABLED, CONF_COLOR_ORDER, CONF_DIMENSIONS, - CONF_DISABLED, CONF_ENABLE_PIN, CONF_ID, CONF_INIT_SEQUENCE, CONF_INVERT_COLORS, CONF_LAMBDA, - CONF_MIRROR_X, - CONF_MIRROR_Y, CONF_MODEL, CONF_RESET_PIN, CONF_ROTATION, - CONF_SWAP_XY, CONF_TRANSFORM, CONF_WIDTH, ) from esphome.final_validate import full_config from . import mipi_dsi_ns, models +from .models import DsiDriverChip # Currently only ESP32-P4 is supported, so esp_ldo and psram are required DEPENDENCIES = ["esp32", "esp_ldo", "psram"] @@ -73,7 +70,7 @@ ColorBitness = display.display_ns.enum("ColorBitness") CONF_LANE_BIT_RATE = "lane_bit_rate" CONF_LANES = "lanes" -DriverChip("CUSTOM") +DsiDriverChip("CUSTOM") # Import all models dynamically from the models package @@ -90,19 +87,7 @@ COLOR_DEPTHS = { def model_schema(config): model = MODELS[config[CONF_MODEL].upper()] - model.defaults[CONF_SWAP_XY] = cv.UNDEFINED - transform = cv.Any( - cv.Schema( - { - cv.Required(CONF_MIRROR_X): cv.boolean, - cv.Required(CONF_MIRROR_Y): cv.boolean, - cv.Optional(CONF_SWAP_XY): cv.invalid( - "Axis swapping not supported by DSI displays" - ), - } - ), - cv.one_of(CONF_DISABLED, lower=True), - ) + transform = model.transform_schema() # CUSTOM model will need to provide a custom init sequence iseqconf = ( cv.Required(CONF_INIT_SEQUENCE) @@ -172,6 +157,7 @@ def _config_schema(config): )(config) config = model_schema(config)(config) model = MODELS[config[CONF_MODEL].upper()] + model.check_requirements() width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( model.get_dimensions(config) ) diff --git a/esphome/components/mipi_dsi/models/__init__.py b/esphome/components/mipi_dsi/models/__init__.py index e69de29bb2..3f7f8370a3 100644 --- a/esphome/components/mipi_dsi/models/__init__.py +++ b/esphome/components/mipi_dsi/models/__init__.py @@ -0,0 +1,14 @@ +from esphome.components.mipi import DriverChip +from esphome.const import CONF_SWAP_XY + + +class DsiDriverChip(DriverChip): + """A driver chip for MIPI DSI displays.""" + + @property + def transforms(self) -> set[str]: + """ + Return the set of transformations supported by this driver chip. + DSI displays do not support axis swapping, so this method removes CONF_SWAP_XY + """ + return super().transforms - {CONF_SWAP_XY} diff --git a/esphome/components/mipi_dsi/models/guition.py b/esphome/components/mipi_dsi/models/guition.py index db13c7f6cc..31a2b0ce1a 100644 --- a/esphome/components/mipi_dsi/models/guition.py +++ b/esphome/components/mipi_dsi/models/guition.py @@ -1,8 +1,7 @@ -from esphome.components.mipi import DriverChip -import esphome.config_validation as cv +from . import DsiDriverChip # fmt: off -DriverChip( +DsiDriverChip( "JC1060P470", width=1024, height=600, @@ -14,7 +13,6 @@ DriverChip( vsync_front_porch=12, pclk_frequency="54MHz", lane_bit_rate="750Mbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0x30, 0x00), (0xF7, 0x49, 0x61, 0x02, 0x00), (0x30, 0x01), (0x04, 0x0C), (0x05, 0x00), (0x06, 0x00), @@ -46,7 +44,7 @@ DriverChip( # * Horizontal Timing (hsync_pulse_width=12, hsync_back_porch=42, hsync_front_porch=42) # * Vertical Timing (vsync_pulse_width=2, vsync_back_porch=8, vsync_front_porch=166) # ---------------------------------------------------------------------------------------------------------------------- -DriverChip( +DsiDriverChip( "JC4880P443", width=480, height=800, @@ -58,7 +56,6 @@ DriverChip( vsync_front_porch=166, pclk_frequency="34MHz", lane_bit_rate="500Mbps", - swap_xy=cv.UNDEFINED, color_order="RGB", reset_pin=5, initsequence=[ @@ -111,7 +108,7 @@ DriverChip( # * Horizontal Timing (hsync_pulse_width=20, hsync_back_porch=20, hsync_front_porch=40) # * Vertical Timing (vsync_pulse_width=4, vsync_back_porch=8, vsync_front_porch=20) # ---------------------------------------------------------------------------------------------------------------------- -DriverChip( +DsiDriverChip( "JC8012P4A1", width=800, height=1280, @@ -123,7 +120,6 @@ DriverChip( vsync_front_porch=20, pclk_frequency="60MHz", lane_bit_rate="1Gbps", - swap_xy=cv.UNDEFINED, color_order="RGB", reset_pin=27, initsequence=[ diff --git a/esphome/components/mipi_dsi/models/m5stack.py b/esphome/components/mipi_dsi/models/m5stack.py index 53fac9b534..b947b9ac8a 100644 --- a/esphome/components/mipi_dsi/models/m5stack.py +++ b/esphome/components/mipi_dsi/models/m5stack.py @@ -1,8 +1,7 @@ -from esphome.components.mipi import DriverChip -import esphome.config_validation as cv +from . import DsiDriverChip # fmt: off -DriverChip( +DsiDriverChip( "M5STACK-TAB5", height=1280, width=720, @@ -14,7 +13,6 @@ DriverChip( vsync_front_porch=20, pclk_frequency="60MHz", lane_bit_rate="730Mbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0xFF, 0x98, 0x81, 0x01), # Select Page 1 @@ -56,7 +54,7 @@ DriverChip( ], ) -DriverChip( +DsiDriverChip( "M5STACK-TAB5-V2", height=1280, width=720, @@ -68,7 +66,6 @@ DriverChip( vsync_front_porch=220, pclk_frequency="80MHz", lane_bit_rate="960Mbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0x01,), diff --git a/esphome/components/mipi_dsi/models/seeed.py b/esphome/components/mipi_dsi/models/seeed.py index 290b0e07ee..84593b40e6 100644 --- a/esphome/components/mipi_dsi/models/seeed.py +++ b/esphome/components/mipi_dsi/models/seeed.py @@ -1,9 +1,8 @@ -from esphome.components.mipi import DriverChip -import esphome.config_validation as cv +from . import DsiDriverChip # Standalone display # Product page: https://www.seeedstudio.com/reTerminal-D1001-p-6729.html -DriverChip( +DsiDriverChip( "SEEED-RETERMINAL-D1001", height=1280, width=800, @@ -15,10 +14,10 @@ DriverChip( vsync_front_porch=30, pclk_frequency="80MHz", lane_bit_rate="1.5Gbps", - swap_xy=cv.UNDEFINED, color_order="RGB", enable_pin=[{"xl9535": None, "number": 0}, {"xl9535": None, "number": 7}], reset_pin={"xl9535": None, "number": 2}, + requires={"psram", "xl9535"}, initsequence=( (0xE0, 0x00), (0xE1, 0x93), diff --git a/esphome/components/mipi_dsi/models/waveshare.py b/esphome/components/mipi_dsi/models/waveshare.py index c97a0bbd02..a1702fb6a1 100644 --- a/esphome/components/mipi_dsi/models/waveshare.py +++ b/esphome/components/mipi_dsi/models/waveshare.py @@ -1,12 +1,11 @@ -from esphome.components.mipi import DriverChip -import esphome.config_validation as cv +from . import DsiDriverChip # fmt: off # Source for parameters and initsequence: # https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365_10_1 # Product page: https://www.waveshare.com/wiki/ESP32-P4-Nano-StartPage -JD9365_10_1_DSI_TOUCH_A = DriverChip( +JD9365_10_1_DSI_TOUCH_A = DsiDriverChip( "WAVESHARE-P4-NANO-10.1", height=1280, width=800, @@ -18,7 +17,6 @@ JD9365_10_1_DSI_TOUCH_A = DriverChip( vsync_front_porch=30, pclk_frequency="80MHz", lane_bit_rate="1.5Gbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0xE0, 0x00), # select userpage @@ -65,7 +63,7 @@ JD9365_10_1_DSI_TOUCH_A.extend( # Source for parameters and initsequence: # https://github.com/espressif/esp-iot-solution/tree/master/components/display/lcd/esp_lcd_st7703 # Product page: https://www.waveshare.com/wiki/ESP32-P4-86-Panel-ETH-2RO -DriverChip( +DsiDriverChip( "WAVESHARE-P4-86-PANEL", height=720, width=720, @@ -77,7 +75,6 @@ DriverChip( vsync_front_porch=20, pclk_frequency="38MHz", lane_bit_rate="480Mbps", - swap_xy=cv.UNDEFINED, color_order="RGB", reset_pin=27, initsequence=[ @@ -109,7 +106,7 @@ DriverChip( # Source for parameters and initsequence: # https://github.com/espressif/esp-iot-solution/tree/master/components/display/lcd/esp_lcd_ek79007 # Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-7B -DriverChip( +DsiDriverChip( "WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-7B", height=600, width=1024, @@ -139,7 +136,7 @@ DriverChip( # Source for parameters and initsequence: # https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365 # Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-3.4C -JD9365_3_4_DSI_TOUCH_C = DriverChip( +JD9365_3_4_DSI_TOUCH_C = DsiDriverChip( "WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-3.4C", height=800, width=800, @@ -151,7 +148,6 @@ JD9365_3_4_DSI_TOUCH_C = DriverChip( vsync_front_porch=24, pclk_frequency="80MHz", lane_bit_rate="1.5Gbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0xE0, 0x00), # select userpage @@ -197,7 +193,7 @@ JD9365_3_4_DSI_TOUCH_C.extend( # Source for parameters and initsequence: # https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365 # Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-4C -JD9365_4_DSI_TOUCH_C = DriverChip( +JD9365_4_DSI_TOUCH_C = DsiDriverChip( "WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-4C", height=720, width=720, @@ -209,7 +205,6 @@ JD9365_4_DSI_TOUCH_C = DriverChip( vsync_front_porch=24, pclk_frequency="80MHz", lane_bit_rate="1.5Gbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0xE0, 0x00), # select userpage @@ -255,7 +250,7 @@ JD9365_4_DSI_TOUCH_C.extend( # Source for parameters and initsequence: # https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365 # Product page: https://www.waveshare.com/wiki/8-DSI-TOUCH-A -DriverChip( +DsiDriverChip( "WAVESHARE-8-DSI-TOUCH-A", height=1280, width=800, @@ -267,7 +262,6 @@ DriverChip( vsync_front_porch=30, pclk_frequency="80MHz", lane_bit_rate="1.5Gbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0xE0, 0x00), # select userpage @@ -304,7 +298,7 @@ DriverChip( # Source for parameters and initsequence: # https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_ili9881c # Product page: https://www.waveshare.com/wiki/7-DSI-TOUCH-A -DriverChip( +DsiDriverChip( "WAVESHARE-7-DSI-TOUCH-A", height=1280, width=720, diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 1eacc31fc5..ebe930d37a 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -18,6 +18,8 @@ from esphome.components.mipi import ( CONF_HSYNC_BACK_PORCH, CONF_HSYNC_FRONT_PORCH, CONF_HSYNC_PULSE_WIDTH, + CONF_PCLK_FREQUENCY, + CONF_PCLK_INVERTED, CONF_PCLK_PIN, CONF_PIXEL_MODE, CONF_USE_AXIS_FLIPS, @@ -34,9 +36,11 @@ from esphome.components.mipi import ( power_of_two, requires_buffer, ) -from esphome.components.rpi_dpi_rgb.display import ( - CONF_PCLK_FREQUENCY, - CONF_PCLK_INVERTED, +from esphome.components.spi import ( + CONF_SPI_MODE, + SPI_DATA_RATE_SCHEMA, + SPI_MODE_OPTIONS, + SPIComponent, ) import esphome.config_validation as cv from esphome.const import ( @@ -48,7 +52,6 @@ from esphome.const import ( CONF_DATA_RATE, CONF_DC_PIN, CONF_DIMENSIONS, - CONF_DISABLED, CONF_ENABLE_PIN, CONF_GREEN, CONF_HSYNC_PIN, @@ -57,8 +60,6 @@ from esphome.const import ( CONF_INIT_SEQUENCE, CONF_INVERT_COLORS, CONF_LAMBDA, - CONF_MIRROR_X, - CONF_MIRROR_Y, CONF_MODEL, CONF_NUMBER, CONF_RED, @@ -72,10 +73,10 @@ from esphome.const import ( ) from esphome.final_validate import full_config -from ..spi import CONF_SPI_MODE, SPI_DATA_RATE_SCHEMA, SPI_MODE_OPTIONS, SPIComponent from . import models +from .models import RgbDriverChip -DEPENDENCIES = ["esp32", "psram"] +DEPENDENCIES = ["esp32"] mipi_rgb_ns = cg.esphome_ns.namespace("mipi_rgb") mipi_rgb = mipi_rgb_ns.class_("MipiRgb", display.Display, cg.Component) @@ -86,7 +87,7 @@ ColorOrder = display.display_ns.enum("ColorMode") DATA_PIN_SCHEMA = pins.internal_gpio_output_pin_schema -DriverChip("CUSTOM") +RgbDriverChip("CUSTOM") # Import all models dynamically from the models package @@ -120,16 +121,7 @@ def data_pin_set(length): def model_schema(config): model = MODELS[config[CONF_MODEL].upper()] - transform = cv.Any( - cv.Schema( - { - cv.Required(CONF_MIRROR_X): cv.boolean, - cv.Required(CONF_MIRROR_Y): cv.boolean, - **model.swap_xy_schema(), - } - ), - cv.one_of(CONF_DISABLED, lower=True), - ) + transform = model.transform_schema() # RPI model does not use an init sequence, indicates with empty list if model.initsequence is None: # Custom model requires an init sequence @@ -235,6 +227,7 @@ def _config_schema(config): only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4]), )(config) model = MODELS[config[CONF_MODEL].upper()] + model.check_requirements() width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( model.get_dimensions(config) ) diff --git a/esphome/components/mipi_rgb/models/__init__.py b/esphome/components/mipi_rgb/models/__init__.py new file mode 100644 index 0000000000..9e3fe2a476 --- /dev/null +++ b/esphome/components/mipi_rgb/models/__init__.py @@ -0,0 +1,14 @@ +from esphome.components.mipi import DriverChip +from esphome.const import CONF_SWAP_XY + + +class RgbDriverChip(DriverChip): + """A driver chip for MIPI RGB displays.""" + + @property + def transforms(self) -> set[str]: + """ + Return the set of transformations supported by this driver chip. + RGB displays do not support axis swapping, so this method removes CONF_SWAP_XY + """ + return super().transforms - {CONF_SWAP_XY} diff --git a/esphome/components/mipi_rgb/models/guition.py b/esphome/components/mipi_rgb/models/guition.py index 915b8beda0..c0aaf0a3d2 100644 --- a/esphome/components/mipi_rgb/models/guition.py +++ b/esphome/components/mipi_rgb/models/guition.py @@ -5,6 +5,7 @@ st7701s.extend( width=480, height=480, data_rate="2MHz", + requires={"psram"}, cs_pin=39, de_pin=18, hsync_pin=16, diff --git a/esphome/components/mipi_rgb/models/lilygo.py b/esphome/components/mipi_rgb/models/lilygo.py index c0e91cd8ae..4e0615b439 100644 --- a/esphome/components/mipi_rgb/models/lilygo.py +++ b/esphome/components/mipi_rgb/models/lilygo.py @@ -1,5 +1,3 @@ -from esphome.config_validation import UNDEFINED - from .st7701s import ST7701S # fmt: off @@ -8,10 +6,10 @@ ST7701S( width=480, height=480, invert_colors=False, - swap_xy=UNDEFINED, spi_mode="MODE3", cs_pin={"xl9535": None, "number": 17}, reset_pin={"xl9535": None, "number": 5}, + requires={"psram", "xl9535"}, hsync_pin=39, vsync_pin=40, pclk_pin=41, @@ -57,9 +55,9 @@ t_rgb = ST7701S( height=480, pixel_mode="18bit", invert_colors=False, - swap_xy=UNDEFINED, spi_mode="MODE3", cs_pin={"xl9535": None, "number": 3}, + requires={"psram", "xl9535"}, de_pin=45, hsync_pin=47, vsync_pin=41, diff --git a/esphome/components/mipi_rgb/models/rpi.py b/esphome/components/mipi_rgb/models/rpi.py index 076d96b658..1e2a6600ee 100644 --- a/esphome/components/mipi_rgb/models/rpi.py +++ b/esphome/components/mipi_rgb/models/rpi.py @@ -1,9 +1,7 @@ -from esphome.components.mipi import DriverChip -from esphome.config_validation import UNDEFINED +from . import RgbDriverChip # A driver chip for Raspberry Pi MIPI RGB displays. These require no init sequence -DriverChip( +RgbDriverChip( "RPI", - swap_xy=UNDEFINED, initsequence=(), ) diff --git a/esphome/components/mipi_rgb/models/st7701s.py b/esphome/components/mipi_rgb/models/st7701s.py index 990a1ca4f3..a20e9d1c01 100644 --- a/esphome/components/mipi_rgb/models/st7701s.py +++ b/esphome/components/mipi_rgb/models/st7701s.py @@ -1,17 +1,12 @@ -from esphome.components.mipi import ( - MADCTL, - MADCTL_ML, - MADCTL_XFLIP, - MODE_BGR, - DriverChip, -) -from esphome.config_validation import UNDEFINED +from esphome.components.mipi import MADCTL, MADCTL_ML, MADCTL_XFLIP, MODE_BGR from esphome.const import CONF_COLOR_ORDER, CONF_HEIGHT, CONF_MIRROR_X, CONF_MIRROR_Y +from . import RgbDriverChip + SDIR_CMD = 0xC7 -class ST7701S(DriverChip): +class ST7701S(RgbDriverChip): # The ST7701s does not use the standard MADCTL bits for x/y mirroring def add_madctl(self, sequence: list, config: dict): transform = self.get_transform(config) @@ -45,7 +40,6 @@ st7701s = ST7701S( "ST7701S", width=480, height=864, - swap_xy=UNDEFINED, hsync_front_porch=20, hsync_back_porch=10, hsync_pulse_width=10, @@ -85,6 +79,7 @@ st7701s.extend( height=480, invert_colors=True, pixel_mode="18bit", + requires={"psram"}, cs_pin=1, de_pin={ "number": 45, @@ -117,6 +112,7 @@ st7701s.extend( vsync_pulse_width=8, vsync_back_porch=20, cs_pin={"pca9554": None, "number": 4}, + requires={"psram", "pca9554"}, de_pin=18, hsync_pin=16, vsync_pin=17, @@ -134,6 +130,7 @@ st7701s.extend( width=480, height=480, pixel_mode="18bit", + requires={"psram"}, cs_pin=18, reset_pin=8, de_pin=17, @@ -177,6 +174,7 @@ st7701s.extend( width=480, height=480, pixel_mode="18bit", + requires={"psram"}, cs_pin=21, de_pin=39, vsync_pin=48, diff --git a/esphome/components/mipi_rgb/models/sunton.py b/esphome/components/mipi_rgb/models/sunton.py index a33625dfe4..a87d5f3c38 100644 --- a/esphome/components/mipi_rgb/models/sunton.py +++ b/esphome/components/mipi_rgb/models/sunton.py @@ -1,14 +1,13 @@ -from esphome.components.mipi import DriverChip -from esphome.config_validation import UNDEFINED +from . import RgbDriverChip # fmt: off -sunton = DriverChip( +sunton = RgbDriverChip( "ESP32-8048S070", - swap_xy=UNDEFINED, initsequence=(), width=800, height=480, pclk_frequency="12.5MHz", + requires={"psram"}, de_pin=41, hsync_pin=39, vsync_pin=40, @@ -28,7 +27,6 @@ sunton = DriverChip( sunton.extend( "ESP32-8048S050", - swap_xy=UNDEFINED, initsequence=(), width=800, height=480, diff --git a/esphome/components/mipi_rgb/models/waveshare.py b/esphome/components/mipi_rgb/models/waveshare.py index cd1fc341ef..ef1a5cd2d6 100644 --- a/esphome/components/mipi_rgb/models/waveshare.py +++ b/esphome/components/mipi_rgb/models/waveshare.py @@ -1,18 +1,18 @@ -from esphome.components.mipi import DriverChip, delay -from esphome.config_validation import UNDEFINED +from esphome.components.mipi import delay +from . import RgbDriverChip from .st7701s import st7701s # fmt: off -wave_4_3 = DriverChip( +wave_4_3 = RgbDriverChip( "ESP32-S3-TOUCH-LCD-4.3", - swap_xy=UNDEFINED, initsequence=(), width=800, height=480, pclk_frequency="16MHz", reset_pin={"ch422g": None, "number": 3}, enable_pin={"ch422g": None, "number": 2}, + requires={"psram", "ch422g"}, de_pin=5, hsync_pin={"number": 46, "ignore_strapping_warning": True}, vsync_pin={"number": 3, "ignore_strapping_warning": True}, @@ -69,6 +69,7 @@ st7701s.extend( pclk_pin=41, pclk_frequency="12MHz", pclk_inverted=False, + requires={"psram"}, data_pins={ "red": [46, 3, 8, 18, 17], "green": [14, 13, 12, 11, 10, 9], @@ -80,6 +81,7 @@ st7701s.extend( "WAVESHARE-3.16-320X820", width=320, height=820, + requires={"psram"}, de_pin=40, hsync_pin=38, vsync_pin=39, diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 871736abd1..f472e12a76 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -41,14 +41,11 @@ from esphome.const import ( CONF_DATA_RATE, CONF_DC_PIN, CONF_DIMENSIONS, - CONF_DISABLED, CONF_ENABLE_PIN, CONF_ID, CONF_INIT_SEQUENCE, CONF_INVERT_COLORS, CONF_LAMBDA, - CONF_MIRROR_X, - CONF_MIRROR_Y, CONF_MODEL, CONF_RESET_PIN, CONF_ROTATION, @@ -138,16 +135,7 @@ def denominator(config): def model_schema(config): model = MODELS[config[CONF_MODEL]] bus_mode = config[CONF_BUS_MODE] - transform = cv.Any( - cv.Schema( - { - cv.Required(CONF_MIRROR_X): cv.boolean, - cv.Required(CONF_MIRROR_Y): cv.boolean, - **model.swap_xy_schema(), - } - ), - cv.one_of(CONF_DISABLED, lower=True), - ) + transform = model.transform_schema() # CUSTOM model will need to provide a custom init sequence iseqconf = ( cv.Required(CONF_INIT_SEQUENCE) @@ -265,6 +253,7 @@ def customise_schema(config): extra=ALLOW_EXTRA, )(config) model = MODELS[config[CONF_MODEL]] + model.check_requirements() bus_modes = (TYPE_SINGLE, TYPE_QUAD, TYPE_OCTAL) config = cv.Schema( { diff --git a/esphome/components/mipi_spi/models/adafruit.py b/esphome/components/mipi_spi/models/adafruit.py index 26790b1493..cc295487eb 100644 --- a/esphome/components/mipi_spi/models/adafruit.py +++ b/esphome/components/mipi_spi/models/adafruit.py @@ -13,6 +13,7 @@ ST7789V.extend( mirror_x=True, mirror_y=True, data_rate="80MHz", + requires={"psram"}, ) ST7789V.extend( @@ -25,4 +26,5 @@ ST7789V.extend( dc_pin=39, reset_pin=40, invert_colors=True, + requires={"psram"}, ) diff --git a/esphome/components/mipi_spi/models/amoled.py b/esphome/components/mipi_spi/models/amoled.py index 30e815d68e..8a869f2284 100644 --- a/esphome/components/mipi_spi/models/amoled.py +++ b/esphome/components/mipi_spi/models/amoled.py @@ -16,7 +16,7 @@ from esphome.components.mipi import ( delay, ) from esphome.components.spi import TYPE_QUAD -from esphome.config_validation import UNDEFINED +from esphome.const import CONF_MIRROR_X, CONF_MIRROR_Y DriverChip( "T-DISPLAY-S3-AMOLED", @@ -29,6 +29,7 @@ DriverChip( brightness=0xD0, color_order=MODE_RGB, no_slpout=True, # SLPOUT is in the init sequence, early + requires={"psram"}, initsequence=(SLPOUT,), ) @@ -43,6 +44,7 @@ DriverChip( data_rate="40MHz", brightness=0xD0, color_order=MODE_RGB, + requires={"psram"}, initsequence=( (PAGESEL, 4), (0x6A, 0x00), @@ -90,6 +92,7 @@ T4_S3_AMOLED = RM690B0.extend( reset_pin=13, enable_pin=9, bus_mode=TYPE_QUAD, + requires={"psram"}, ) CO5300 = DriverChip( @@ -98,7 +101,7 @@ CO5300 = DriverChip( color_order=MODE_RGB, bus_mode=TYPE_QUAD, no_slpout=True, - swap_xy=UNDEFINED, + transforms={CONF_MIRROR_X, CONF_MIRROR_Y}, width=480, height=480, initsequence=( diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index 812e491c62..187fcfd8c0 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -314,6 +314,7 @@ DriverChip( data_rate="40MHz", dc_pin=4, cs_pin=5, + requires={"psram"}, # reset_pin={CONF_INVERTED: True, CONF_NUMBER: 48}, initsequence=( (0xEF, 0x03, 0x80, 0x02), @@ -379,6 +380,7 @@ DriverChip( cs_pin=5, dc_pin=4, reset_pin=48, + requires={"psram"}, initsequence=( (0xEF, 0x03, 0x80, 0x02), (0xCF, 0x00, 0xC1, 0x30), @@ -711,6 +713,7 @@ ST7796.extend( reset_pin=4, dc_pin={"number": 0, "ignore_strapping_warning": True}, invert_colors=True, + requires={"psram"}, ) ST7789V.extend( diff --git a/esphome/components/mipi_spi/models/jc.py b/esphome/components/mipi_spi/models/jc.py index 854814f572..d24ca5db58 100644 --- a/esphome/components/mipi_spi/models/jc.py +++ b/esphome/components/mipi_spi/models/jc.py @@ -1,12 +1,16 @@ from esphome.components.mipi import MODE_RGB, DriverChip from esphome.components.spi import TYPE_QUAD -import esphome.config_validation as cv -from esphome.const import CONF_IGNORE_STRAPPING_WARNING, CONF_NUMBER +from esphome.const import ( + CONF_IGNORE_STRAPPING_WARNING, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_NUMBER, +) AXS15231 = DriverChip( "AXS15231", draw_rounding=8, - swap_xy=cv.UNDEFINED, + transforms={CONF_MIRROR_X, CONF_MIRROR_Y}, color_order=MODE_RGB, bus_mode=TYPE_QUAD, initsequence=( @@ -22,6 +26,7 @@ AXS15231.extend( height=480, cs_pin={CONF_NUMBER: 45, CONF_IGNORE_STRAPPING_WARNING: True}, data_rate="40MHz", + requires={"psram"}, ) DriverChip( @@ -36,6 +41,7 @@ DriverChip( color_order=MODE_RGB, bus_mode=TYPE_QUAD, data_rate="40MHz", + requires={"psram"}, initsequence=( (0xF0, 0x08), (0xF2, 0x08), @@ -267,6 +273,7 @@ DriverChip( color_order=MODE_RGB, bus_mode=TYPE_QUAD, data_rate="40MHz", + requires={"psram"}, initsequence=( (0xF0, 0x28), (0xF2, 0x28), @@ -495,6 +502,7 @@ DriverChip( color_order=MODE_RGB, bus_mode=TYPE_QUAD, data_rate="20MHz", + requires={"psram"}, initsequence=( (0xFF, 0xA5), (0x41, 0x03), diff --git a/esphome/components/mipi_spi/models/lanbon.py b/esphome/components/mipi_spi/models/lanbon.py index 8cec3c8317..1188300136 100644 --- a/esphome/components/mipi_spi/models/lanbon.py +++ b/esphome/components/mipi_spi/models/lanbon.py @@ -10,4 +10,5 @@ ST7789V.extend( cs_pin=22, dc_pin=21, reset_pin=18, + requires={"psram"}, ) diff --git a/esphome/components/mipi_spi/models/lilygo.py b/esphome/components/mipi_spi/models/lilygo.py index 46ec809029..84f44a3dae 100644 --- a/esphome/components/mipi_spi/models/lilygo.py +++ b/esphome/components/mipi_spi/models/lilygo.py @@ -15,6 +15,7 @@ ST7789V.extend( dc_pin=13, reset_pin=9, data_rate="80MHz", + requires={"psram"}, ) ST7789V.extend( @@ -42,6 +43,7 @@ ST7789V.extend( enable_pin=[9, 15], data_rate="10MHz", bus_mode=TYPE_OCTAL, + requires={"psram"}, ) ST7796.extend( @@ -55,4 +57,5 @@ ST7796.extend( dc_pin=9, backlight_pin=48, invert_colors=True, + requires={"psram"}, ) diff --git a/esphome/components/mipi_spi/models/m5stack.py b/esphome/components/mipi_spi/models/m5stack.py index 81bb186278..a54bd19d88 100644 --- a/esphome/components/mipi_spi/models/m5stack.py +++ b/esphome/components/mipi_spi/models/m5stack.py @@ -49,6 +49,7 @@ ILI9341.extend( invert_colors=True, pixel_mode="18bit", data_rate="40MHz", + requires={"psram"}, ) GC9107 = ST7789V.extend( @@ -68,4 +69,5 @@ GC9107.extend( reset_pin=48, dc_pin=42, cs_pin=14, + requires={"psram"}, ) diff --git a/esphome/components/mipi_spi/models/waveshare.py b/esphome/components/mipi_spi/models/waveshare.py index 8fc5b2acc5..0caae5b939 100644 --- a/esphome/components/mipi_spi/models/waveshare.py +++ b/esphome/components/mipi_spi/models/waveshare.py @@ -12,7 +12,7 @@ from esphome.components.mipi import ( PWSET, DriverChip, ) -import esphome.config_validation as cv +from esphome.const import CONF_MIRROR_X, CONF_MIRROR_Y from .amoled import CO5300 from .ili import ILI9488_A, ST7789V @@ -155,7 +155,7 @@ ST7789P = DriverChip( ILI9488_A.extend( "PICO-RESTOUCH-LCD-3.5", - swap_xy=cv.UNDEFINED, + transforms={CONF_MIRROR_X, CONF_MIRROR_Y}, spi_16=True, pixel_mode="16bit", mirror_x=True, @@ -175,6 +175,7 @@ CO5300.extend( offset_width=6, cs_pin=12, reset_pin=39, + requires={"psram"}, ) # Waveshare ESP32-S3 Touch AMOLED 2.16" (CO5300 controller) @@ -189,6 +190,7 @@ CO5300.extend( cs_pin=12, reset_pin=39, data_rate="40MHz", + requires={"psram"}, ) AXS15231.extend( @@ -198,6 +200,7 @@ AXS15231.extend( data_rate="80MHz", cs_pin=9, reset_pin=21, + requires={"psram"}, ) # Waveshare 1.83-v2 @@ -281,6 +284,7 @@ ST7789V.extend( offset_height=40, invert_colors=True, data_rate="40MHz", + requires={"psram"}, ) CO5300.extend( @@ -291,4 +295,5 @@ CO5300.extend( cs_pin=9, reset_pin=21, enable_pin=1, + requires={"psram"}, ) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 803ddba6b7..bfdd2de7c7 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -574,6 +574,9 @@ class EsphomeCore: self.build_path: Path | None = None # The validated configuration, this is None until the config has been validated self.config: ConfigType | None = None + # The raw configuration as read from YAML (after packages/substitutions), + # available during validation before the config is fully validated + self.raw_config: ConfigType | None = None # YAML frontmatter loaded from user YAML files. Frontmatter is a leading # YAML document separated by `---` from the actual configuration. It is # ignored by config validation and code generation, but kept here so it @@ -650,6 +653,7 @@ class EsphomeCore: self.config_path = None self.build_path = None self.config = None + self.raw_config = None self.frontmatter = {} self.event_loop = _FakeEventLoop() self.task_counter = 0 diff --git a/tests/component_tests/animation/config/animation_platform_test.yaml b/tests/component_tests/animation/config/animation_platform_test.yaml index 380434dcc3..8de32ed593 100644 --- a/tests/component_tests/animation/config/animation_platform_test.yaml +++ b/tests/component_tests/animation/config/animation_platform_test.yaml @@ -24,6 +24,9 @@ spi: mosi_pin: 6 clk_pin: 7 +psram: + mode: quad + display: - platform: mipi_spi id: lcd_display diff --git a/tests/component_tests/animation/config/animation_test.yaml b/tests/component_tests/animation/config/animation_test.yaml index 9d8fd15276..1fe6ddf9a4 100644 --- a/tests/component_tests/animation/config/animation_test.yaml +++ b/tests/component_tests/animation/config/animation_test.yaml @@ -19,6 +19,9 @@ spi: mosi_pin: 6 clk_pin: 7 +psram: + mode: quad + display: - platform: mipi_spi id: lcd_display diff --git a/tests/component_tests/image/config/image_test.yaml b/tests/component_tests/image/config/image_test.yaml index c34e0993a5..31c29de21b 100644 --- a/tests/component_tests/image/config/image_test.yaml +++ b/tests/component_tests/image/config/image_test.yaml @@ -4,6 +4,9 @@ esphome: esp32: board: esp32s3box +psram: + mode: octal + image: defaults: type: rgb565 diff --git a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py index c14abdb4fd..100366b135 100644 --- a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py +++ b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py @@ -71,6 +71,18 @@ def test_configuration_errors(set_core_config: SetCoreConfigCallable) -> None: } ) + # DSI displays cannot swap axes; enabling swap_xy reports a clear error. + with pytest.raises(cv.Invalid, match="'swap_xy' is not supported by this model"): + CONFIG_SCHEMA( + { + "model": "custom", + "init_sequence": [[0xA0, 0x01]], + "lane_bit_rate": "1.5Gbps", + "dimensions": {"width": 320, "height": 240}, + "transform": {"mirror_x": True, "mirror_y": True, "swap_xy": True}, + } + ) + def test_configuration_success(set_core_config: SetCoreConfigCallable) -> None: """Test successful configuration validation.""" @@ -116,6 +128,33 @@ def test_configuration_success(set_core_config: SetCoreConfigCallable) -> None: CONFIG_SCHEMA(config) +def test_metadata_records_rotation(set_core_config: SetCoreConfigCallable) -> None: + """A configured display rotation is recorded in the metadata. + + LVGL relies on this to flag a rotation set in the display config (see the + mipi_spi tests for the end-to-end LVGL rejection). + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-p4-evboard", KEY_VARIANT: VARIANT_ESP32P4}, + ) + + from esphome.components.display import get_display_metadata + from esphome.components.mipi_dsi.display import CONFIG_SCHEMA + + base = { + "model": "custom", + "init_sequence": [[0xA0, 0x01]], + "lane_bit_rate": "1.5Gbps", + "dimensions": {"width": 320, "height": 240}, + } + config = CONFIG_SCHEMA({**base, "id": "rotated", "rotation": 90}) + assert get_display_metadata(config["id"]).rotation == 90 + + config = CONFIG_SCHEMA({**base, "id": "unrotated"}) + assert get_display_metadata(config["id"]).rotation == 0 + + def test_code_generation( generate_main: Callable[[str | Path], str], component_fixture_path: Callable[[str], Path], diff --git a/tests/component_tests/mipi_rgb/__init__.py b/tests/component_tests/mipi_rgb/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/mipi_rgb/test_init.py b/tests/component_tests/mipi_rgb/test_init.py new file mode 100644 index 0000000000..0ab6c022e6 --- /dev/null +++ b/tests/component_tests/mipi_rgb/test_init.py @@ -0,0 +1,89 @@ +"""Tests for mipi_rgb configuration validation, in particular the per-model +``requires`` component check (see esphome.components.mipi.DriverChip.check_requirements).""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32S3 +from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA + +# Importing pca9554 registers its pin schema with pins.PIN_SCHEMA_REGISTRY so that +# models (e.g. SEEED-INDICATOR-D1) that reference pca9554-backed pins in their +# defaults can be validated by the mipi_rgb CONFIG_SCHEMA in this test. +import esphome.components.pca9554 # noqa: F401 +from esphome.const import PlatformFramework +from esphome.core import CORE +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _validated(config: ConfigType) -> ConfigType: + """Run the component config schema followed by the final validation.""" + config = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(config) + return config + + +def test_model_requires_psram(set_core_config: SetCoreConfigCallable) -> None: + """A model known to have PSRAM on its board rejects a config without it. + + RGB parallel displays always need a full framebuffer, so every model in this + component is expected to carry ``requires={"psram", ...}``. This board has no + other requirements, so its check is exercised in isolation here. + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, + ) + CORE.raw_config = {} + + with pytest.raises( + cv.Invalid, + match=r"ESP32-8048S070 requires component 'psram' to be configured", + ): + _validated({"model": "ESP32-8048S070"}) + + +def test_model_requires_psram_satisfied( + set_core_config: SetCoreConfigCallable, + set_component_config: Any, +) -> None: + """The same board model validates once PSRAM is configured.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, + ) + set_component_config("psram", True) + CORE.raw_config = {"psram": True} + + config = _validated({"model": "ESP32-8048S070"}) + assert config["model"] == "ESP32-8048S070" + + +def test_model_requires_psram_and_expander( + set_core_config: SetCoreConfigCallable, + set_component_config: Any, +) -> None: + """A model that also depends on an I2C GPIO expander lists both when missing.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, + ) + # Only satisfy one of the two requirements. + set_component_config("psram", True) + CORE.raw_config = {"psram": True} + + with pytest.raises( + cv.Invalid, + match=r"SEEED-INDICATOR-D1 requires component 'pca9554' to be configured", + ): + _validated( + { + "model": "SEEED-INDICATOR-D1", + "spi_id": "spi_bus", + } + ) diff --git a/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py new file mode 100644 index 0000000000..e85327c0ab --- /dev/null +++ b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py @@ -0,0 +1,137 @@ +"""Tests for mipi_rgb configuration validation.""" + +import pytest + +from esphome import config_validation as cv + +# Importing these registers their pin schemas with pins.PIN_SCHEMA_REGISTRY so that +# models referencing IO-expander-backed pins in their defaults (e.g. the LilyGO +# T-RGB boards via xl9535, SEEED-INDICATOR-D1 via pca9554, or the Waveshare panels +# via ch422g) can be validated by the mipi_rgb CONFIG_SCHEMA in this test. +import esphome.components.ch422g # noqa: F401 +from esphome.components.display import get_display_metadata +from esphome.components.esp32 import KEY_BOARD, VARIANT_ESP32S3 +import esphome.components.pca9554 # noqa: F401 +import esphome.components.xl9535 # noqa: F401 +from esphome.const import ( + CONF_BLUE, + CONF_DIMENSIONS, + CONF_GREEN, + CONF_HEIGHT, + CONF_INIT_SEQUENCE, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_RED, + CONF_SWAP_XY, + CONF_WIDTH, + KEY_VARIANT, + PlatformFramework, +) +from tests.component_tests.types import SetCoreConfigCallable + +# A generic set of data pins so that models without a default pin assignment +# (e.g. CUSTOM and RPI) still validate. +DATA_PINS = { + CONF_RED: [1, 2, 3, 4, 5], + CONF_GREEN: [6, 7, 8, 9, 10, 11], + CONF_BLUE: [12, 13, 14, 15, 16], +} + + +def _set_s3(set_core_config: SetCoreConfigCallable) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + +def test_configuration_success(set_core_config: SetCoreConfigCallable) -> None: + """Every predefined model validates once required defaults are supplied.""" + _set_s3(set_core_config) + + from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS + + for name, model in MODELS.items(): + config = {"model": name, "data_pins": DATA_PINS, "pclk_pin": 21} + if model.initsequence is None: + config[CONF_INIT_SEQUENCE] = [[0xA0, 0x01]] + if not model.get_default(CONF_WIDTH): + config[CONF_DIMENSIONS] = {CONF_WIDTH: 480, CONF_HEIGHT: 480} + CONFIG_SCHEMA(config) + + +def test_transform_matches_model_support( + set_core_config: SetCoreConfigCallable, +) -> None: + """The transform schema only accepts the axes a model actually supports.""" + _set_s3(set_core_config) + + from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS + + # ESP32-8048S070 supports both mirror axes but not swap_xy (RGB displays + # never support axis swapping). + model = MODELS["ESP32-8048S070"] + assert model.transforms == {CONF_MIRROR_X, CONF_MIRROR_Y} + + base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21} + CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": False}}) + + # An unsupported axis may be explicitly disabled (a harmless no-op)... + CONFIG_SCHEMA( + {**base, "transform": {"mirror_x": True, "mirror_y": False, "swap_xy": False}} + ) + + # ...but enabling it reports a clear, model-specific error. + with pytest.raises(cv.Invalid, match="'swap_xy' is not supported by this model"): + CONFIG_SCHEMA( + { + **base, + "transform": {"mirror_x": True, "mirror_y": False, "swap_xy": True}, + } + ) + + +def test_st7701s_only_supports_mirror_x( + set_core_config: SetCoreConfigCallable, +) -> None: + """ST7701S panels shorter than full height only expose mirror_x. + + mirror_y only works at full height (864px), so the LilyGO 480px panels must + reject a mirror_y transform. + """ + _set_s3(set_core_config) + + from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS + + model = MODELS["T-RGB-2.1"] + assert model.transforms == {CONF_MIRROR_X} + assert CONF_SWAP_XY not in model.transforms + + base = {"model": "T-RGB-2.1"} + CONFIG_SCHEMA({**base, "transform": {"mirror_x": True}}) + + with pytest.raises(cv.Invalid, match="'mirror_y' is not supported by this model"): + CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": True}}) + + +def test_metadata_records_rotation( + set_core_config: SetCoreConfigCallable, +) -> None: + """A configured display rotation is recorded in the metadata. + + LVGL relies on this to flag a rotation set in the display config (see the + mipi_spi tests for the end-to-end LVGL rejection). + """ + _set_s3(set_core_config) + + from esphome.components.mipi_rgb.display import CONFIG_SCHEMA + + base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21} + config = CONFIG_SCHEMA({**base, "id": "rotated", "rotation": 90}) + assert get_display_metadata(config["id"]).rotation == 90 + + config = CONFIG_SCHEMA({**base, "id": "unrotated"}) + assert get_display_metadata(config["id"]).rotation == 0 diff --git a/tests/component_tests/mipi_spi/test_display_metadata.py b/tests/component_tests/mipi_spi/test_display_metadata.py index e7f5143d91..06cc8ee09a 100644 --- a/tests/component_tests/mipi_spi/test_display_metadata.py +++ b/tests/component_tests/mipi_spi/test_display_metadata.py @@ -3,6 +3,9 @@ from collections.abc import Callable from pathlib import Path +import pytest + +from esphome import config_validation as cv from esphome.components.const import BYTE_ORDER_BIG from esphome.components.display import get_all_display_metadata, get_display_metadata from esphome.components.esp32 import ( @@ -13,6 +16,7 @@ from esphome.components.esp32 import ( ) from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA from esphome.const import PlatformFramework +from esphome.core import ID from tests.component_tests.types import SetCoreConfigCallable @@ -23,6 +27,18 @@ def validated_config(config): return config +def _lvgl_config(display_id: str) -> dict: + """Build a minimal LVGL config dict referencing the given display id.""" + return { + "displays": [ID(display_id, True)], + "log_level": "WARN", + "color_depth": 16, + "transparency_key": 0x000400, + "draw_rounding": 2, + "buffer_size": 0, + } + + def test_metadata_native_quad_default_test_card( set_core_config: SetCoreConfigCallable, ) -> None: @@ -91,7 +107,7 @@ def test_metadata_no_swap_xy_not_full_hardware_rotation( PlatformFramework.ESP32_IDF, platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, ) - # JC3248W535 has swap_xy=cv.UNDEFINED -> transforms={mirror_x, mirror_y} only + # JC3248W535 has transforms={mirror_x, mirror_y} only config = CONFIG_SCHEMA({"model": "JC3248W535", "id": "jc3248w535"}) meta = get_display_metadata(config["id"]) assert meta is not None @@ -166,3 +182,69 @@ def test_metadata_via_code_generation_lvgl( assert meta.height == 160 assert meta.has_hardware_rotation is True assert meta.byte_order == BYTE_ORDER_BIG + + +def test_metadata_records_rotation( + set_core_config: SetCoreConfigCallable, +) -> None: + """A configured display rotation is recorded in the metadata.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + config = CONFIG_SCHEMA( + {"model": "ST7735", "dc_pin": 18, "id": "rotated", "rotation": 90} + ) + meta = get_display_metadata(config["id"]) + assert meta is not None + assert meta.rotation == 90 + + +def test_metadata_rotation_defaults_to_zero( + set_core_config: SetCoreConfigCallable, +) -> None: + """A display without a rotation reports rotation 0 in its metadata.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + config = CONFIG_SCHEMA({"model": "ST7735", "dc_pin": 18, "id": "unrotated"}) + meta = get_display_metadata(config["id"]) + assert meta is not None + assert meta.rotation == 0 + + +def test_rotation_flagged_when_used_with_lvgl( + set_core_config: SetCoreConfigCallable, +) -> None: + """A display with a rotation is rejected when driven by LVGL. + + LVGL manages its own rotation, so a rotation set in the display config must be + flagged and the user directed to configure it in the LVGL block instead. This + exercises the full chain: the mipi_spi schema records the rotation in the + display metadata, and LVGL's final validation reports it. + """ + from esphome.components.lvgl import final_validation + + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + CONFIG_SCHEMA({"model": "ST7735", "dc_pin": 18, "id": "rotated", "rotation": 90}) + with pytest.raises(cv.Invalid, match="rotation.*not compatible with LVGL"): + final_validation([_lvgl_config("rotated")]) + + +def test_no_rotation_accepted_with_lvgl( + set_core_config: SetCoreConfigCallable, +) -> None: + """A display without a rotation validates cleanly when driven by LVGL.""" + from esphome.components.lvgl import final_validation + + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + CONFIG_SCHEMA({"model": "ST7735", "dc_pin": 18, "id": "unrotated"}) + # Should not raise. + final_validation([_lvgl_config("unrotated")]) diff --git a/tests/component_tests/mipi_spi/test_final_validate.py b/tests/component_tests/mipi_spi/test_final_validate.py index 8c45b47752..77111ae867 100644 --- a/tests/component_tests/mipi_spi/test_final_validate.py +++ b/tests/component_tests/mipi_spi/test_final_validate.py @@ -6,10 +6,13 @@ from typing import Any import pytest +from esphome import config_validation as cv from esphome.components.display import CONF_SHOW_TEST_CARD from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.mipi import DriverChip from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA from esphome.const import CONF_BUFFER_SIZE, PlatformFramework +from esphome.core import CORE from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable @@ -183,3 +186,77 @@ def test_buffer_size_selected_when_lvgl_with_test_card( ) assert config[CONF_BUFFER_SIZE] == pytest.approx(1.0 / 4) + + +def test_requires_missing_single_component_raises() -> None: + """A model that requires a single component raises when it is absent.""" + CORE.raw_config = {} + chip = DriverChip("TEST-REQUIRES-PSRAM", requires={"psram"}) + + with pytest.raises( + cv.Invalid, + match=r"TEST-REQUIRES-PSRAM requires component 'psram' to be configured", + ): + chip.check_requirements() + + +def test_requires_missing_multiple_components_raises() -> None: + """A model that requires several components lists all the missing ones, pluralized.""" + CORE.raw_config = {} + chip = DriverChip("TEST-REQUIRES-MULTI", requires={"psram", "pca9554"}) + + with pytest.raises( + cv.Invalid, + match=r"TEST-REQUIRES-MULTI requires components '.*' to be configured", + ) as excinfo: + chip.check_requirements() + assert "psram" in str(excinfo.value) + assert "pca9554" in str(excinfo.value) + + +def test_requires_satisfied_does_not_raise() -> None: + """No error is raised once all the required components are configured.""" + CORE.raw_config = {"psram": True, "pca9554": []} + chip = DriverChip("TEST-REQUIRES-SATISFIED", requires={"psram", "pca9554"}) + + chip.check_requirements() # Should not raise + + +def test_requires_absent_does_not_raise() -> None: + """Models without a requires set are unaffected by the check.""" + CORE.raw_config = {} + chip = DriverChip("TEST-REQUIRES-NONE") + + chip.check_requirements() # Should not raise + + +def test_predefined_model_requires_psram( + set_core_config: SetCoreConfigCallable, +) -> None: + """A predefined board model known to have PSRAM rejects a config without it.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + CORE.raw_config = {} + + with pytest.raises( + cv.Invalid, match=r"S3BOX requires component 'psram' to be configured" + ): + _validated({"model": "s3box"}) + + +def test_predefined_model_requires_psram_satisfied( + set_core_config: SetCoreConfigCallable, + set_component_config: Any, +) -> None: + """The same board model validates once PSRAM is configured.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("psram", True) + CORE.raw_config = {"psram": True} + + config = _validated({"model": "s3box"}) + assert config["model"] == "S3BOX" diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index 8edbe095b7..dcecd89617 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -136,7 +136,7 @@ def test_dimension_validation( "model": "JC3248W535", "transform": {"mirror_x": False, "mirror_y": True, "swap_xy": True}, }, - "Axis swapping not supported by this model", + "'swap_xy' is not supported by this model", id="axis_swapping_not_supported", ), pytest.param( diff --git a/tests/component_tests/mipi_spi/test_padding_and_offsets.py b/tests/component_tests/mipi_spi/test_padding_and_offsets.py index 7ae6f0e61f..b2b421c1e2 100644 --- a/tests/component_tests/mipi_spi/test_padding_and_offsets.py +++ b/tests/component_tests/mipi_spi/test_padding_and_offsets.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from pathlib import Path +from typing import Any import pytest @@ -222,6 +223,7 @@ class TestNewModelVariants: def test_m5core2_with_native_dimensions( self, set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], ) -> None: """Test M5CORE2 variant with reset native_width and native_height.""" set_core_config( @@ -231,6 +233,8 @@ class TestNewModelVariants: KEY_VARIANT: VARIANT_ESP32S3, }, ) + # M5CORE2 has PSRAM on board and requires it to be configured + set_component_config("psram", True) # M5CORE2 should validate successfully config = validated_config({"model": "M5CORE2"}) diff --git a/tests/component_tests/online_image/config/online_image_platform_test.yaml b/tests/component_tests/online_image/config/online_image_platform_test.yaml index 883876e401..9b92bf75d0 100644 --- a/tests/component_tests/online_image/config/online_image_platform_test.yaml +++ b/tests/component_tests/online_image/config/online_image_platform_test.yaml @@ -24,6 +24,9 @@ spi: mosi_pin: 6 clk_pin: 7 +psram: + mode: quad + display: - platform: mipi_spi id: lcd_display diff --git a/tests/component_tests/online_image/config/online_image_test.yaml b/tests/component_tests/online_image/config/online_image_test.yaml index ab0ad472f9..4af398cdff 100644 --- a/tests/component_tests/online_image/config/online_image_test.yaml +++ b/tests/component_tests/online_image/config/online_image_test.yaml @@ -23,6 +23,9 @@ spi: mosi_pin: 6 clk_pin: 7 +psram: + mode: quad + display: - platform: mipi_spi id: lcd_display From 478bca026cefa922e7a40ca8edb71b4167410d6f Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:37:00 -1000 Subject: [PATCH 0820/1815] Bump bundled esphome-device-builder to 1.4.2 (#17512) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index e7f8fceb12..fadf3f0685 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.4.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.4.2 RUN \ platformio settings set enable_telemetry No \ From 9f62cf924338addcb1f677e58c41720d135216b2 Mon Sep 17 00:00:00 2001 From: Raymond Date: Sat, 11 Jul 2026 13:24:00 +0200 Subject: [PATCH 0821/1815] [mipi_dsi] Add JC8012P4A1-V2 (#17457) --- esphome/components/mipi_dsi/models/guition.py | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) diff --git a/esphome/components/mipi_dsi/models/guition.py b/esphome/components/mipi_dsi/models/guition.py index 31a2b0ce1a..914361a4ac 100644 --- a/esphome/components/mipi_dsi/models/guition.py +++ b/esphome/components/mipi_dsi/models/guition.py @@ -318,4 +318,232 @@ DsiDriverChip( (0xE0, 0x00), ] ) + +# JC8012P4A1 V2 Driver Configuration (jd9365) +# Some units of this model have a different LCD panel but still use the same JD9365 driver chip. +# Using parameters from esp_lcd_jd9365.h and the working full init sequence +# ---------------------------------------------------------------------------------------------------------------------- +# * Resolution: 800x1280 +# * PCLK Frequency: 70 MHz +# * DSI Lane Bit Rate: 1.5 Gbps (using 2-Lane DSI configuration) +# * Horizontal Timing (hsync_pulse_width=20, hsync_back_porch=20, hsync_front_porch=40) +# * Vertical Timing (vsync_pulse_width=4, vsync_back_porch=10, vsync_front_porch=20) +# ---------------------------------------------------------------------------------------------------------------------- +DsiDriverChip( + "JC8012P4A1-V2", + width=800, + height=1280, + hsync_back_porch=20, + hsync_pulse_width=20, + hsync_front_porch=40, + vsync_back_porch=10, + vsync_pulse_width=4, + vsync_front_porch=20, + pclk_frequency="70MHz", + lane_bit_rate="1500Mbps", + color_order="RGB", + reset_pin=27, + initsequence=[ + (0xE0, 0x00), + (0xE1, 0x93), + (0xE2, 0x65), + (0xE3, 0xF8), + (0x80, 0x01), + (0xE0, 0x01), + (0x00, 0x00), + (0x01, 0x44), + (0x03, 0x10), + (0x04, 0x38), + (0x0C, 0x74), + (0x17, 0x00), + (0x18, 0xAF), + (0x19, 0x00), + (0x1A, 0x00), + (0x1B, 0xAF), + (0x1C, 0x00), + (0x35, 0x26), + (0x37, 0x09), + (0x38, 0x04), + (0x39, 0x00), + (0x3A, 0x01), + (0x3C, 0x78), + (0x3D, 0xFF), + (0x3E, 0xFF), + (0x3F, 0x7F), + (0x40, 0x06), + (0x41, 0xA0), + (0x42, 0x81), + (0x43, 0x1E), + (0x44, 0x0D), + (0x45, 0x28), + (0x55, 0x02), + (0x57, 0x69), + (0x59, 0x0A), + (0x5A, 0x2A), + (0x5B, 0x17), + (0x5D, 0x7F), + (0x5E, 0x6B), + (0x5F, 0x5C), + (0x60, 0x50), + (0x61, 0x4C), + (0x62, 0x3E), + (0x63, 0x41), + (0x64, 0x2B), + (0x65, 0x43), + (0x66, 0x42), + (0x67, 0x43), + (0x68, 0x62), + (0x69, 0x52), + (0x6A, 0x5A), + (0x6B, 0x4C), + (0x6C, 0x48), + (0x6D, 0x3A), + (0x6E, 0x28), + (0x6F, 0x10), + (0x70, 0x7F), + (0x71, 0x6B), + (0x72, 0x5C), + (0x73, 0x50), + (0x74, 0x4C), + (0x75, 0x3E), + (0x76, 0x41), + (0x77, 0x2B), + (0x78, 0x43), + (0x79, 0x42), + (0x7A, 0x43), + (0x7B, 0x62), + (0x7C, 0x52), + (0x7D, 0x5A), + (0x7E, 0x4C), + (0x7F, 0x48), + (0x80, 0x3A), + (0x81, 0x28), + (0x82, 0x10), + (0xE0, 0x02), + (0x00, 0x42), + (0x01, 0x42), + (0x02, 0x40), + (0x03, 0x40), + (0x04, 0x5E), + (0x05, 0x5E), + (0x06, 0x5F), + (0x07, 0x5F), + (0x08, 0x5F), + (0x09, 0x57), + (0x0A, 0x57), + (0x0B, 0x77), + (0x0C, 0x77), + (0x0D, 0x47), + (0x0E, 0x47), + (0x0F, 0x45), + (0x10, 0x45), + (0x11, 0x4B), + (0x12, 0x4B), + (0x13, 0x49), + (0x14, 0x49), + (0x15, 0x5F), + (0x16, 0x41), + (0x17, 0x41), + (0x18, 0x40), + (0x19, 0x40), + (0x1A, 0x5E), + (0x1B, 0x5E), + (0x1C, 0x5F), + (0x1D, 0x5F), + (0x1E, 0x5F), + (0x1F, 0x57), + (0x20, 0x57), + (0x21, 0x77), + (0x22, 0x77), + (0x23, 0x46), + (0x24, 0x46), + (0x25, 0x44), + (0x26, 0x44), + (0x27, 0x4A), + (0x28, 0x4A), + (0x29, 0x48), + (0x2A, 0x48), + (0x2B, 0x5F), + (0x2C, 0x01), + (0x2D, 0x01), + (0x2E, 0x00), + (0x2F, 0x00), + (0x30, 0x1F), + (0x31, 0x1F), + (0x32, 0x1E), + (0x33, 0x1E), + (0x34, 0x1F), + (0x35, 0x17), + (0x36, 0x17), + (0x37, 0x37), + (0x38, 0x37), + (0x39, 0x08), + (0x3A, 0x08), + (0x3B, 0x0A), + (0x3C, 0x0A), + (0x3D, 0x04), + (0x3E, 0x04), + (0x3F, 0x06), + (0x40, 0x06), + (0x41, 0x1F), + (0x42, 0x02), + (0x43, 0x02), + (0x44, 0x00), + (0x45, 0x00), + (0x46, 0x1F), + (0x47, 0x1F), + (0x48, 0x1E), + (0x49, 0x1E), + (0x4A, 0x1F), + (0x4B, 0x17), + (0x4C, 0x17), + (0x4D, 0x37), + (0x4E, 0x37), + (0x4F, 0x09), + (0x50, 0x09), + (0x51, 0x0B), + (0x52, 0x0B), + (0x53, 0x05), + (0x54, 0x05), + (0x55, 0x07), + (0x56, 0x07), + (0x57, 0x1F), + (0x58, 0x40), + (0x5B, 0x30), + (0x5C, 0x00), + (0x5D, 0x34), + (0x5E, 0x05), + (0x5F, 0x02), + (0x63, 0x00), + (0x64, 0x6A), + (0x67, 0x73), + (0x68, 0x07), + (0x69, 0x08), + (0x6A, 0x6A), + (0x6B, 0x08), + (0x6C, 0x00), + (0x6D, 0x00), + (0x6E, 0x00), + (0x6F, 0x88), + (0x75, 0xFF), + (0x77, 0xDD), + (0x78, 0x2C), + (0x79, 0x15), + (0x7A, 0x17), + (0x7D, 0x14), + (0x7E, 0x82), + (0xE0, 0x04), + (0x00, 0x0E), + (0x02, 0xB3), + (0x09, 0x60), + (0x0E, 0x48), + (0x37, 0x58), + (0x2B, 0x0F), + (0xE0, 0x05), + (0x15, 0x1D), + (0xE0, 0x00), + (0xE6, 0x02), + (0xE7, 0x0C) + ] +) # fmt: on From e6525b5d930b3d1960ef4d78bcb8f488d6cc4fba Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:36:26 +1000 Subject: [PATCH 0822/1815] [mipi][mipi_spi] SWRESET handling improved (#17504) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/mipi/__init__.py | 20 +++++- esphome/components/mipi_spi/display.py | 2 +- esphome/components/mipi_spi/mipi_spi.h | 25 ++----- esphome/components/mipi_spi/models/jc.py | 1 + tests/component_tests/mipi_spi/test_init.py | 75 ++++++++++++++++++++- 5 files changed, 100 insertions(+), 23 deletions(-) diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index ab59d5ce5f..2b9a150419 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -26,6 +26,7 @@ from esphome.const import ( CONF_OFFSET_HEIGHT, CONF_OFFSET_WIDTH, CONF_PAGES, + CONF_RESET_PIN, CONF_ROTATION, CONF_SWAP_XY, CONF_TRANSFORM, @@ -601,12 +602,15 @@ class DriverChip: """ return self.get_default(f"no_{command.lower()}", False) - def get_sequence(self, config, add_madctl=True) -> tuple[int, ...]: + def get_sequence(self, config, add_madctl=True, add_reset=False) -> tuple[int, ...]: """ Create the init sequence for the display. Use the default sequence from the model, if any, and append any custom sequence provided in the config. Append SLPOUT (if not already in the sequence) and DISPON to the end of the sequence MADCTL will be set if add_madctl is True + If add_reset is True, a reset is prepended: a software reset when no reset pin + is configured (and the model doesn't skip it), followed by a settling delay that + both a software and a hardware reset require. Returns the init sequence """ sequence = list(self.initsequence or ()) @@ -615,6 +619,15 @@ class DriverChip: # Ensure each command is a tuple sequence = [x if isinstance(x, tuple) else (x,) for x in sequence] + if add_reset: + reset: list = [] + # A software reset is only needed when there is no hardware reset pin. + if CONF_RESET_PIN not in config and not self.skip_command("SWRESET"): + reset.append((SWRESET,)) + # Both a software and a hardware reset need a settling delay before further commands. + reset.append(delay(10)) + sequence = reset + sequence + # Set pixel format if not already in the custom sequence pixel_mode = config[CONF_PIXEL_MODE] if not isinstance(pixel_mode, int): @@ -635,8 +648,13 @@ class DriverChip: sequence.append((BRIGHTNESS, brightness)) # Add a SLPOUT command if required. if not self.skip_command("SLPOUT"): + # A zero delay will delay until 120ms after reset + sequence.append(delay(0)) sequence.append((SLPOUT,)) + sequence.append(delay(10)) sequence.append((DISPON,)) + # Add a delay here because additional commands may be added after this at runtime. + sequence.append(delay(10)) # Flatten the sequence into a list of bytes, with the length of each command # or the delay flag inserted where needed diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index f472e12a76..246db237b1 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -397,7 +397,7 @@ def get_instance(config): async def to_code(config): model = MODELS[config[CONF_MODEL]] var_id = config[CONF_ID] - init_sequence = model.get_sequence(config, False) + init_sequence = model.get_sequence(config, add_madctl=False, add_reset=True) var_id.type, templateargs = get_instance(config) var = cg.new_Pvariable(var_id, TemplateArguments(*templateargs)) cg.add(var.set_init_sequence(init_sequence)) diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 48184fa5c1..701bcd7169 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -13,6 +13,8 @@ constexpr static const char *const TAG = "display.mipi_spi"; // Maximum bytes to log for commands (truncated if larger) static constexpr size_t MIPI_SPI_MAX_CMD_LOG_BYTES = 64; + +// Command codes for MIPI SPI displays. Not all currently used, kept here for reference. static constexpr uint8_t SW_RESET_CMD = 0x01; static constexpr uint8_t SLEEP_OUT = 0x11; static constexpr uint8_t NORON = 0x13; @@ -151,14 +153,11 @@ class MipiSpi : public display::Display, this->reset_pin_->digital_write(false); delay(5); this->reset_pin_->digital_write(true); - } else { - // no reset pin, send software reset command - this->write_command_(SW_RESET_CMD); + // required delay after reset is already in the init sequence, don't duplicate } // need to know when the display is ready for SLPOUT command - will be 120ms after reset auto when = millis() + 120; - delay(10); size_t index = 0; auto &vec = this->init_sequence_; while (index != vec.size()) { @@ -170,6 +169,9 @@ class MipiSpi : public display::Display, uint8_t cmd = vec[index++]; uint8_t x = vec[index++]; if (x == DELAY_FLAG) { + if (cmd == 0) { + cmd = clamp_at_least((int) (when - millis()), 0); + } esph_log_d(TAG, "Delay %dms", cmd); delay(cmd); } else { @@ -179,24 +181,9 @@ class MipiSpi : public display::Display, this->mark_failed(); return; } - switch (cmd) { - case SLEEP_OUT: { - // are we ready, boots? - int duration = when - millis(); - if (duration > 0) { - esph_log_d(TAG, "Sleep %dms", duration); - delay(duration); - } - } break; - - default: - break; - } const auto *ptr = vec.data() + index; this->write_command_(cmd, ptr, num_args); index += num_args; - if (cmd == SLEEP_OUT) - delay(10); } } this->reset_params_(); diff --git a/esphome/components/mipi_spi/models/jc.py b/esphome/components/mipi_spi/models/jc.py index d24ca5db58..ca9adb4a72 100644 --- a/esphome/components/mipi_spi/models/jc.py +++ b/esphome/components/mipi_spi/models/jc.py @@ -13,6 +13,7 @@ AXS15231 = DriverChip( transforms={CONF_MIRROR_X, CONF_MIRROR_Y}, color_order=MODE_RGB, bus_mode=TYPE_QUAD, + no_swreset=True, initsequence=( (0xBB, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5A, 0xA5), (0xC1, 0x33), diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index dcecd89617..f29883684c 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -361,7 +361,8 @@ def test_native_generation( "mipi_spi::MipiSpiBuffer()" in main_cpp ) - assert "set_init_sequence({240, 1, 8, 242" in main_cpp + # A 10ms post-reset delay ({10, 255}) is prepended ahead of the model commands. + assert "set_init_sequence({10, 255, 240, 1, 8, 242" in main_cpp assert "show_test_card();" in main_cpp assert "set_write_only(true);" in main_cpp @@ -377,6 +378,76 @@ def test_lvgl_generation( "mipi_spi::MipiSpi();" in main_cpp ) - assert "set_init_sequence({177, 3, 1, 44, 45, 178" in main_cpp + # A 10ms post-reset delay ({10, 255}) is prepended ahead of the model commands. + assert "set_init_sequence({10, 255, 177, 3, 1, 44, 45, 178" in main_cpp assert "show_test_card();" not in main_cpp assert "set_auto_clear(false);" in main_cpp + + +# A 10ms delay (flattened to {10, 0xFF}, where 0xFF is the delay marker byte) is +# always prepended to the init sequence, since both a software and a hardware reset +# need to settle before further commands. A custom model has no reset_pin default +# and does not set no_swreset, so when no reset pin is configured the SWRESET command +# ({1, 0}: command 0x01 with no parameters) is prepended ahead of that delay. +_SWRESET_YAML = """ +esphome: + name: swreset-test +esp32: + board: esp32-s3-devkitc-1 + framework: + type: esp-idf +spi: + clk_pin: 1 + mosi_pin: 2 +display: + - platform: mipi_spi + model: custom + id: {display_id} + dc_pin: 4 + cs_pin: 8 + dimensions: + width: 320 + height: 240 + init_sequence: + - [0xA0, 0x01] +{reset_line} +""" + + +def test_swreset_prepended_without_reset_pin( + generate_main: Callable[[str | Path], str], + tmp_path: Path, +) -> None: + """A model with no reset pin (and no no_swreset) gets SWRESET prepended.""" + yaml_file = tmp_path / "swreset.yaml" + yaml_file.write_text( + _SWRESET_YAML.format(display_id="swreset_display", reset_line="") + ) + + main_cpp = generate_main(yaml_file) + + # SWRESET ({1, 0}) followed by a 10ms delay ({10, 255}) is inserted ahead of + # the model's own commands. + assert "swreset_display->set_init_sequence({1, 0, 10, 255, 160, 1, 1," in main_cpp + + +def test_swreset_not_prepended_with_reset_pin( + generate_main: Callable[[str | Path], str], + tmp_path: Path, +) -> None: + """A hardware reset pin performs the reset, so SWRESET must not be prepended. + + The post-reset delay is still required, so the sequence starts with the delay. + """ + yaml_file = tmp_path / "hwreset.yaml" + yaml_file.write_text( + _SWRESET_YAML.format( + display_id="hwreset_display", reset_line=" reset_pin: 5" + ) + ) + + main_cpp = generate_main(yaml_file) + + # The delay ({10, 255}) is still present, but no leading SWRESET ({1, 0}). + assert "hwreset_display->set_init_sequence({10, 255, 160, 1, 1," in main_cpp + assert "hwreset_display->set_init_sequence({1, 0," not in main_cpp From 54529412dcc920af79ba9d32cdf6520123f65122 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:38:01 +1000 Subject: [PATCH 0823/1815] [mipi_dsi] New model for M5Stack Tab5 (#17500) Co-authored-by: Claude Opus 4.8 --- esphome/components/mipi/__init__.py | 34 ++++++----- esphome/components/mipi_dsi/models/m5stack.py | 59 ++++++++++++++++++- .../mipi_dsi/test_mipi_dsi_config.py | 27 +++++++++ 3 files changed, 103 insertions(+), 17 deletions(-) diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 2b9a150419..3f73f96327 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -667,23 +667,27 @@ class DriverChip: This runs during schema validation (before ID references are resolved) so that a model whose default pins live on a pin expander reports the missing expander clearly instead of a cryptic "Couldn't find ID" from the unresolved pin reference. + + Also logs a warning if the model is deprecated. """ - requirements = self.get_default("requires", set()) - if not requirements: - return - # ``raw_config`` is populated before any component schema runs during a real - # validation, so presence of a required component is simply a top-level key. - # When it is absent (e.g. a unit test that invokes the schema directly) there - # is no config to check against, so skip. - global_config = CORE.raw_config - if global_config is None: - return - missing = {x for x in requirements if x not in global_config} - if missing: - reqstr = ", ".join(f"'{x}'" for x in sorted(missing)) - raise cv.Invalid( - f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured" + if deprecation_reason := self.get_default("deprecation_reason"): + LOGGER.warning( + "Display model %s is deprecated: %s", self.name, deprecation_reason ) + if requirements := self.get_default("requires", set()): + # ``raw_config`` is populated before any component schema runs during a real + # validation, so presence of a required component is simply a top-level key. + # When it is absent (e.g. a unit test that invokes the schema directly) there + # is no config to check against, so skip. + global_config = CORE.raw_config + if global_config is None: + return + missing = {x for x in requirements if x not in global_config} + if missing: + reqstr = ", ".join(f"'{x}'" for x in sorted(missing)) + raise cv.Invalid( + f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured" + ) def requires_buffer(config) -> bool: diff --git a/esphome/components/mipi_dsi/models/m5stack.py b/esphome/components/mipi_dsi/models/m5stack.py index b947b9ac8a..5b07229ec7 100644 --- a/esphome/components/mipi_dsi/models/m5stack.py +++ b/esphome/components/mipi_dsi/models/m5stack.py @@ -54,8 +54,8 @@ DsiDriverChip( ], ) -DsiDriverChip( - "M5STACK-TAB5-V2", +TAB5_ST7123 = DsiDriverChip( + "M5STACK-TAB5-ST7123", height=1280, width=720, hsync_back_porch=40, @@ -94,3 +94,58 @@ DsiDriverChip( (0xC9, 0x00, 0x00, 0x10, 0x1F, 0x36, 0x00, 0x5D, 0x04, 0x9D, 0x05, 0x10, 0xF2, 0x06, 0x60, 0x03, 0x11, 0xAD, 0x00, 0xEF, 0x01, 0x22, 0x2E, 0x0E, 0x74, 0x08, 0x32, 0xDC, 0x09, 0x33, 0x0F, 0xF3, 0x77, 0x0D, 0xB0, 0xDC, 0x03, 0xFF), ], ) + +TAB5_ST7123.extend( + "M5STACK-TAB5-V2", + deprecation_reason="Use 'M5STACK-TAB5-ST7123' or 'M5STACK-TAB5-ST7121' instead." +) + +# Some Tab5 "v2" units ship with an ST7121 controller instead of the ST7123. +# The two are distinguishable at runtime by the touch controller firmware version (the M5 +# factory firmware branches on it), but ESPHome selects the panel at compile time, so ST7121 +# units must select this model explicitly. Values taken from M5's factory source +# (m5stack/M5Tab5-UserDemo: m5stack_tab5.c is_st7121 path + esp_lcd_st7121.c default table). +DsiDriverChip( + "M5STACK-TAB5-ST7121", + height=1280, + width=720, + hsync_back_porch=40, + hsync_pulse_width=2, + hsync_front_porch=40, + vsync_back_porch=24, + vsync_pulse_width=20, + vsync_front_porch=200, + pclk_frequency="70MHz", + lane_bit_rate="965Mbps", + color_order="RGB", + initsequence=[ + (0x01,), + (0x60, 0x71, 0x21, 0xA2), + (0x60, 0x71, 0x21, 0xA3), + (0x60, 0x71, 0x21, 0xA4), + (0x78, 0x21), + (0x79, 0xEF), + (0xA4, 0x31), + (0xB7, 0x00, 0x00, 0x5F, 0x5F, 0x44, 0x1A), + (0xB0, 0x22, 0x6B, 0x11, 0x89, 0x25, 0x43, 0x43), + (0xBF, 0xA7, 0xA7), + (0xA5, 0xF0, 0x03), + (0xD7, 0x10, 0x2C, 0x14, 0x2A, 0x80, 0x80), + (0x90, 0x71, 0x23, 0x5A, 0x20, 0x24, 0x11, 0x21), + (0xA3, 0x80, 0x01, 0x8C, 0xFF, 0x45, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x00, 0x00, 0x1E, 0x5C, 0x1E, 0x80, 0x10, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x00, 0x00, 0x1E, 0x5C, 0x1E, 0x80, 0x10, 0xEF, 0x58, 0x00, 0x00, 0x00, 0xFF), + (0xA6, 0x0A, 0x00, 0x24, 0x71, 0x36, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x00, 0x24, 0x71, 0x37, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x00, 0x24, 0x71, 0x00, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x00, 0x2C, 0x71, 0x00, 0x01, 0x00, 0x00, 0x68, 0x68, 0xFF, 0xFF, 0x00, 0x08, 0x80, 0x08, 0x80, 0x06, 0x00, 0x00, 0x00, 0x00), + (0xA7, 0x1A, 0x1A, 0xC0, 0x64, 0x40, 0x04, 0x15, 0x40, 0x00, 0x40, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x64, 0x40, 0x26, 0x37, 0x40, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x64, 0x40, 0x8C, 0x9D, 0x40, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x64, 0x40, 0xAE, 0xBF, 0x00, 0x00, 0x20, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x79), + (0xAC, 0x1D, 0x18, 0x19, 0x1D, 0x18, 0x19, 0x04, 0x1C, 0x1D, 0x08, 0x0A, 0x10, 0x12, 0x0C, 0x0E, 0x14, 0x16, 0x00, 0x1D, 0x1D, 0x1D, 0x1D, 0x1D, 0x18, 0x19, 0x1D, 0x18, 0x19, 0x06, 0x1C, 0x1D, 0x09, 0x0B, 0x11, 0x13, 0x0D, 0x0F, 0x15, 0x17, 0x02, 0x1D, 0x1D, 0x1D, 0x1D), + (0xAD, 0x0C, 0x40, 0x46, 0x00, 0x07, 0x4B, 0x4B, 0xFF, 0xFF, 0xF0, 0x40, 0x0E, 0x01, 0x07, 0x42, 0x42, 0xFF, 0xFF, 0x01, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF), + (0xAE, 0xF0, 0xFF, 0x03, 0xF0, 0xFF, 0x03, 0x00), + (0xB2, 0x15, 0x19, 0x05, 0x23, 0x49, 0x2D, 0x03, 0x2E, 0x5C, 0xD2, 0xFF, 0x10, 0x60, 0xFD, 0x20, 0xC0, 0x00), + (0xE8, 0x20, 0x60, 0x04, 0x8E, 0x8E, 0x3E, 0x04, 0xDC, 0xDC, 0x3E, 0x06, 0xFA, 0x26, 0x3E), + (0x75, 0x03, 0x04), + (0xE7, 0x4B, 0x00, 0x00, 0xBE, 0x4B, 0x8C, 0x20, 0x1A, 0xF0, 0x7D, 0x14, 0x7D, 0x14, 0x7D, 0x14, 0x7D, 0x14, 0xFF, 0x00, 0x32, 0x30, 0x73, 0x00, 0x00, 0xC8, 0x6A, 0xFF, 0x5A, 0x64, 0x38, 0x88, 0x15, 0xB1, 0x01, 0x01, 0x64, 0x01, 0x01, 0x7C, 0xFF, 0x1A, 0x51), + (0xE1, 0x0C, 0x0C), + (0xEA, 0x15, 0x00, 0x01), + (0xC8, 0x00, 0x00, 0x04, 0x08, 0x10, 0x00, 0x1F, 0x01, 0x39, 0x3E, 0x00, 0x78, 0x06, 0xE2, 0x02, 0x11, 0x33, 0x01, 0x7A, 0x0D, 0x21, 0xC4, 0x0B, 0x19, 0x08, 0x32, 0xA0, 0x08, 0x1A, 0x0A, 0xF3, 0x7F, 0x0E, 0xC5, 0xE8, 0x03, 0xFF), + (0xC9, 0x00, 0x00, 0x04, 0x08, 0x10, 0x00, 0x1F, 0x01, 0x39, 0x3E, 0x00, 0x78, 0x06, 0xE2, 0x02, 0x11, 0x33, 0x01, 0x7A, 0x0D, 0x21, 0xC4, 0x0B, 0x19, 0x08, 0x32, 0xA0, 0x08, 0x1A, 0x0A, 0xF3, 0x7F, 0x0E, 0xC5, 0xE8, 0x03, 0xFF), + (0x60, 0x71, 0x21, 0x00), + ], +) diff --git a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py index 100366b135..6259d85184 100644 --- a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py +++ b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py @@ -1,6 +1,7 @@ """Tests for mpi_dsi configuration validation.""" from collections.abc import Callable +import logging from pathlib import Path import pytest @@ -128,6 +129,32 @@ def test_configuration_success(set_core_config: SetCoreConfigCallable) -> None: CONFIG_SCHEMA(config) +def test_deprecated_model_warning( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """The deprecated M5Stack-Tab5-v2 alias warns and points at the replacement models.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-p4-evboard", KEY_VARIANT: VARIANT_ESP32P4}, + ) + + from esphome.components.mipi_dsi.display import CONFIG_SCHEMA + + with caplog.at_level(logging.WARNING): + CONFIG_SCHEMA({"id": "deprecated_display", "model": "M5Stack-Tab5-v2"}) + assert "M5STACK-TAB5-V2 is deprecated" in caplog.text + # The warning names the replacement models so users know what to switch to. + assert "M5STACK-TAB5-ST7123" in caplog.text + + # The replacement models validate without emitting a deprecation warning. + caplog.clear() + with caplog.at_level(logging.WARNING): + CONFIG_SCHEMA({"id": "st7123_display", "model": "M5Stack-Tab5-ST7123"}) + CONFIG_SCHEMA({"id": "st7121_display", "model": "M5Stack-Tab5-ST7121"}) + assert "deprecated" not in caplog.text + + def test_metadata_records_rotation(set_core_config: SetCoreConfigCallable) -> None: """A configured display rotation is recorded in the metadata. From 5020179210fe636481d3ed727fa4de109d3484a0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:45:02 -0400 Subject: [PATCH 0824/1815] Bump ruff from 0.15.20 to 0.15.21 (#17508) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index ebd93ea390..7aa8dab534 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.6 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.20 # also change in .pre-commit-config.yaml when updating +ruff==0.15.21 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From 0ef85783dc3985888e1162257b366e3817fd9fb6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:45:23 -0400 Subject: [PATCH 0825/1815] Bump actions/stale from 10.3.0 to 10.4.0 (#17509) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 7003f6c482..ef79b2705a 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Stale - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 + uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 with: debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch remove-stale-when-updated: true From b6a4dd237e627e0b66ae4a7afb2624f2d00b88d6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:45:42 -0400 Subject: [PATCH 0826/1815] Update tzdata requirement from >=2026.2 to >=2026.3 (#17510) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b36e70ef5d..5f98111445 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 tzlocal==5.4.4 # from time -tzdata>=2026.2 # from time +tzdata>=2026.3 # from time pyserial==3.5 platformio==6.1.19 esptool==5.3.1 From 35a99f478eb79b03b2a4c3b5ba97d8f9514b7e9b Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 11 Jul 2026 15:48:11 +0200 Subject: [PATCH 0827/1815] [deep_sleep] feed watchdog in deep sleep (#17516) --- .../deep_sleep/deep_sleep_zephyr.cpp | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/esphome/components/deep_sleep/deep_sleep_zephyr.cpp b/esphome/components/deep_sleep/deep_sleep_zephyr.cpp index f77b73cd58..cadf7bf42d 100644 --- a/esphome/components/deep_sleep/deep_sleep_zephyr.cpp +++ b/esphome/components/deep_sleep/deep_sleep_zephyr.cpp @@ -1,13 +1,36 @@ #include "deep_sleep_component.h" #ifdef USE_ZEPHYR +#include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esphome/core/wake.h" #include +#include namespace esphome::deep_sleep { static const char *const TAG = "deep_sleep"; +// The Zephyr watchdog has a short window (2s, or 10s with Zigbee) and +// WDT_OPT_PAUSE_IN_SLEEP only pauses it during true hardware sleep — not while a +// radio thread (e.g. the Zigbee stack) keeps the CPU busy in k_sem_take(). Feed +// it at least this often while waiting so it does not reset the device. +static const uint32_t WDT_FEED_INTERVAL_MS = 1000; + +static bool wakeable_delay_feed_wdt(uint32_t ms) { + while (ms > 0) { + const uint32_t step = std::min(ms, WDT_FEED_INTERVAL_MS); + esphome::internal::wakeable_delay(step); + esphome::arch_feed_wdt(); + if (esphome::wake_request_take()) { + return true; + } + if (ms != UINT32_MAX) { + ms -= step; + } + } + return false; +} + optional DeepSleepComponent::get_run_duration_() const { return this->run_duration_; } void DeepSleepComponent::dump_config_platform_() {} @@ -15,8 +38,9 @@ void DeepSleepComponent::dump_config_platform_() {} bool DeepSleepComponent::prepare_to_sleep_() { return true; } void DeepSleepComponent::deep_sleep_() { + bool woke = false; if (this->sleep_duration_.has_value()) { - esphome::internal::wakeable_delay(static_cast(*this->sleep_duration_ / 1000)); + woke = wakeable_delay_feed_wdt(static_cast(*this->sleep_duration_ / 1000)); } else { #ifndef USE_ZIGBEE // the device can be woken up through one of the following signals: @@ -29,10 +53,9 @@ 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); + woke = wakeable_delay_feed_wdt(UINT32_MAX); #endif } - const bool woke = esphome::wake_request_take(); if (woke) { ESP_LOGD(TAG, "Woken up by another thread"); } else { From 65353006c80cb9256189dcaa315c72d5332f50d7 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:32:45 -1000 Subject: [PATCH 0828/1815] Bump bundled esphome-device-builder to 1.4.3 (#17522) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index fadf3f0685..f09280a50e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.4.2 +RUN uv pip install --no-cache-dir esphome-device-builder==1.4.3 RUN \ platformio settings set enable_telemetry No \ From c0636e2bf7585db6e98835c4670f5cd037dd626b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Jul 2026 14:30:47 -1000 Subject: [PATCH 0829/1815] [core] Make config-hash independent of machine-local paths (#17523) --- esphome/core/__init__.py | 19 ++++++++++- esphome/yaml_util.py | 36 ++++++++++++++++---- tests/unit_tests/core/test_config.py | 42 +++++++++++++++++++++++ tests/unit_tests/test_main.py | 4 +-- tests/unit_tests/test_yaml_util.py | 51 ++++++++++++++++++++++++++++ 5 files changed, 143 insertions(+), 9 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index bfdd2de7c7..bf637d4c1f 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -8,6 +8,7 @@ import re from typing import TYPE_CHECKING, Any from esphome.const import ( + CONF_BUILD_PATH, CONF_COMMENT, CONF_ESPHOME, CONF_ETHERNET, @@ -731,12 +732,28 @@ class EsphomeCore: The hash is computed lazily and cached for performance. Uses sort_keys=True to ensure deterministic ordering. + + The hash must be reproducible across machines so the device builder + can compare a locally computed hash against the one a device + advertises. Machine-local data is kept out of the input: build_path + (which embeds ESPHOME_BUILD_PATH and OS path separators) is excluded, + and Path values are dumped relative to the config directory. """ if self._config_hash is None: from esphome import yaml_util from esphome.helpers import fnv1a_32bit_hash - config_str = yaml_util.dump(self.config, show_secrets=True, sort_keys=True) + config = dict(self.config) + if (esphome_conf := config.get(CONF_ESPHOME)) is not None: + esphome_conf = dict(esphome_conf) + esphome_conf.pop(CONF_BUILD_PATH, None) + config[CONF_ESPHOME] = esphome_conf + config_str = yaml_util.dump( + config, + show_secrets=True, + sort_keys=True, + relative_to=self.config_dir if self.config_path is not None else None, + ) self._config_hash = fnv1a_32bit_hash(config_str) return self._config_hash diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 0009cde551..c2db9b97ed 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -840,17 +840,22 @@ def _load_yaml_internal_with_type( loader.dispose() -def dump(dict_, show_secrets=False, sort_keys=False): - """Dump YAML to a string and remove null.""" +def dump(dict_, show_secrets=False, sort_keys=False, relative_to: Path | None = None): + """Dump YAML to a string and remove null. + + When ``relative_to`` is given, Path values are dumped relative to that + directory (POSIX form) so the output is machine independent. + """ if show_secrets: _SECRET_VALUES.clear() _SECRET_CACHE.clear() - # Per-call subclass so the redaction flag doesn't leak across calls. + # Per-call subclass so the flags don't leak across calls. # (``_SECRET_VALUES`` / ``_SECRET_CACHE`` remain module globals; YAML - # processing is single-threaded today, so this isolates only the flag.) + # processing is single-threaded today, so this isolates only the flags.) class _Dumper(ESPHomeDumper): _redact_sensitive = not show_secrets + _relative_to = relative_to return yaml.dump( dict_, @@ -1002,9 +1007,13 @@ def format_path(path: DocumentPath, current_obj: Any) -> str: class ESPHomeDumper(yaml.SafeDumper): - # Default for the base class; per-call subclass in ``dump()`` overrides. + # Defaults for the base class; per-call subclass in ``dump()`` overrides. # When True, ``represent_sensitive`` wraps values in ANSI conceal codes. _redact_sensitive: bool = False + # When set, ``represent_path`` dumps Path values relative to this + # directory (in POSIX form) so the output does not depend on where the + # config lives on the machine that produced it. + _relative_to: Path | None = None def represent_mapping(self, tag, mapping, flow_style=None): value = [] @@ -1040,6 +1049,21 @@ class ESPHomeDumper(yaml.SafeDumper): return self.represent_secret(value) return self.represent_scalar(tag="tag:yaml.org,2002:str", value=str(value)) + def represent_path(self, value: Path) -> yaml.ScalarNode: + if self._relative_to is not None: + # Normalize both sides lexically (no symlink resolution) so ".." + # segments do not defeat the prefix match, and walk up so files + # referenced outside the anchor directory stay relative too. A + # path that still cannot be relativized (e.g. a different drive) + # keeps its POSIX form so separators stay stable across OSes. + path = Path(os.path.normpath(value)) + with suppress(ValueError): + path = path.relative_to( + os.path.normpath(self._relative_to), walk_up=True + ) + return self.represent_stringify(path.as_posix()) + return self.represent_stringify(value) + def represent_sensitive(self, value: SensitiveStr) -> yaml.ScalarNode: # Only the redact-and-not-a-secret branch is unique to sensitive # values; otherwise let ``represent_stringify`` handle ``!secret`` @@ -1138,5 +1162,5 @@ ESPHomeDumper.add_multi_representer(Extend, ESPHomeDumper.represent_extend) ESPHomeDumper.add_multi_representer(Remove, ESPHomeDumper.represent_remove) ESPHomeDumper.add_multi_representer(core.ID, ESPHomeDumper.represent_id) ESPHomeDumper.add_multi_representer(uuid.UUID, ESPHomeDumper.represent_stringify) -ESPHomeDumper.add_multi_representer(Path, ESPHomeDumper.represent_stringify) +ESPHomeDumper.add_multi_representer(Path, ESPHomeDumper.represent_path) ESPHomeDumper.add_multi_representer(IncludeFile, ESPHomeDumper.represent_include_file) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 6fd9f4c22c..0362c40bce 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -1113,6 +1113,48 @@ def test_config_hash_different_for_different_configs() -> None: assert hash1 != hash2 +def test_config_hash_ignores_build_path() -> None: + """Test that config_hash does not depend on the build_path value. + + build_path embeds ESPHOME_BUILD_PATH and OS path separators, so it must + not make the hash differ between machines. + """ + CORE.reset() + CORE.config = {"esphome": {"name": "test", "build_path": "build\\test"}} + hash1 = CORE.config_hash + + CORE.reset() + CORE.config = {"esphome": {"name": "test", "build_path": "/build/test"}} + hash2 = CORE.config_hash + + assert hash1 == hash2 + + +def test_config_hash_same_for_different_config_dirs(tmp_path: Path) -> None: + """Test that Path values under the config dir hash the same everywhere. + + Simulates the same project checked out at two different locations; the + absolute paths differ but the layout relative to the config dir is the + same, so the hashes must match. + """ + dir1 = tmp_path / "machine_a" / "project" + dir2 = tmp_path / "machine_b" / "somewhere" / "else" + dir1.mkdir(parents=True) + dir2.mkdir(parents=True) + + CORE.reset() + CORE.config_path = dir1 / "device.yaml" + CORE.config = {"esphome": {"name": "test"}, "file": dir1 / "fonts" / "arial.ttf"} + hash1 = CORE.config_hash + + CORE.reset() + CORE.config_path = dir2 / "device.yaml" + CORE.config = {"esphome": {"name": "test"}, "file": dir2 / "fonts" / "arial.ttf"} + hash2 = CORE.config_hash + + assert hash1 == hash2 + + def test_make_app_name_cpp_no_mac_simple() -> None: """Test simple name without MAC suffix returns string literal.""" cpp_expr, global_decl, byte_len = make_app_name_cpp( diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 0442c1db16..9a9aafec43 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -167,9 +167,9 @@ def setup_core( CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} if tmp_path is not None: - CORE.config_path = str(tmp_path / f"{name}.yaml") + CORE.config_path = tmp_path / f"{name}.yaml" CORE.name = name - CORE.build_path = str(tmp_path / ".esphome" / "build" / name) + CORE.build_path = tmp_path / ".esphome" / "build" / name @pytest.fixture diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index fa1c0fcce2..5c38fce105 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -1349,6 +1349,57 @@ def test_sensitive_str__is_a_str_subclass() -> None: assert value == "hunter2" +def test_dump_path_without_relative_to_is_unchanged() -> None: + """Test that Path values dump as str(path) when relative_to is not given.""" + path = Path("some") / "dir" / "file.ttf" + output = yaml_util.dump({"file": path}) + assert output.strip() == f"file: {path}" + + +def test_dump_path_relative_to_anchor_dir() -> None: + """Test that Path values under relative_to dump as relative POSIX paths.""" + anchor = Path("/config/esphome").absolute() + data = {"file": anchor / "fonts" / "arial.ttf"} + output = yaml_util.dump(data, relative_to=anchor) + assert output.strip() == "file: fonts/arial.ttf" + + +def test_dump_path_outside_anchor_dir_walks_up() -> None: + """Test that Path values outside relative_to walk up with ".." segments.""" + anchor = Path("/config/esphome").absolute() + outside = Path("/config/fonts/file.ttf").absolute() + output = yaml_util.dump({"file": outside}, relative_to=anchor) + assert output.strip() == "file: ../fonts/file.ttf" + + +def test_dump_path_with_dotdot_segments_is_normalized() -> None: + """Test that ".." segments do not defeat relativization. + + A path like /config/other/../esphome/fonts/x.ttf is under the anchor + once normalized, so it must dump as a plain relative path. + """ + anchor = Path("/config/esphome").absolute() + path = Path("/config/other/../esphome/fonts/x.ttf").absolute() + output = yaml_util.dump({"file": path}, relative_to=anchor) + assert output.strip() == "file: fonts/x.ttf" + + +def test_dump_path_dotdot_reference_outside_anchor() -> None: + """Test the relative_config_path("../...") shape stays relative.""" + anchor = Path("/config/esphome").absolute() + path = anchor / ".." / "shared" / "font.ttf" + output = yaml_util.dump({"file": path}, relative_to=anchor) + assert output.strip() == "file: ../shared/font.ttf" + + +def test_dump_relative_to_does_not_leak_between_calls() -> None: + """Test that the relative_to flag is scoped to a single dump call.""" + anchor = Path("/config/esphome").absolute() + path = anchor / "fonts" / "arial.ttf" + assert "fonts/arial.ttf" in yaml_util.dump({"file": path}, relative_to=anchor) + assert yaml_util.dump({"file": path}).strip() == f"file: {path}" + + def test_dump__redacts_sensitive_str_by_default() -> None: out = yaml_util.dump({"password": SensitiveStr("hunter2")}) assert "\\033[8mhunter2\\033[28m" in out From 614fd888297aecedd5060f38b38b0f6a4592fe9b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Jul 2026 14:31:01 -1000 Subject: [PATCH 0830/1815] [mdns] Fix missing device info TXT records when native API is not enabled (#17520) --- esphome/components/mdns/mdns_component.cpp | 27 +++++++++++++------ esphome/components/mdns/mdns_component.h | 15 ++++++++--- esphome/components/mdns/mdns_host.cpp | 2 +- .../mdns/test-fallback.esp32-idf.yaml | 7 +++++ .../mdns/test-webserver-no-api.esp32-idf.yaml | 9 +++++++ 5 files changed, 48 insertions(+), 12 deletions(-) create mode 100644 tests/components/mdns/test-fallback.esp32-idf.yaml create mode 100644 tests/components/mdns/test-webserver-no-api.esp32-idf.yaml diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 02b825605c..bb4271a6ca 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -47,7 +47,7 @@ void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_regi auto &services = services_storage; #endif -#ifdef USE_API +#ifdef USE_MDNS_DEVICE_INFO_TXT #ifdef USE_MDNS_STORE_SERVICES get_mac_address_into_buffer(this->mac_address_); char *mac_ptr = this->mac_address_; @@ -70,17 +70,20 @@ void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_regi platform_register(this, services); } -void MDNSComponent::compile_records_(StaticVector &services, char *mac_address_buf, - char *config_hash_buf) { +void MDNSComponent::compile_records_(StaticVector &services, + const char *mac_address_buf, const char *config_hash_buf) { // IMPORTANT: The #ifdef blocks below must match COMPONENTS_WITH_MDNS_SERVICES // in mdns/__init__.py. If you add a new service here, update both locations. +#ifdef USE_MDNS_DEVICE_INFO_TXT + MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version"); + MDNS_STATIC_CONST_CHAR(TXT_MAC, "mac"); + MDNS_STATIC_CONST_CHAR(TXT_CONFIG_HASH, "config_hash"); +#endif + #ifdef USE_API MDNS_STATIC_CONST_CHAR(SERVICE_ESPHOMELIB, "_esphomelib"); MDNS_STATIC_CONST_CHAR(TXT_FRIENDLY_NAME, "friendly_name"); - MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version"); - MDNS_STATIC_CONST_CHAR(TXT_CONFIG_HASH, "config_hash"); - MDNS_STATIC_CONST_CHAR(TXT_MAC, "mac"); MDNS_STATIC_CONST_CHAR(TXT_PLATFORM, "platform"); MDNS_STATIC_CONST_CHAR(TXT_BOARD, "board"); MDNS_STATIC_CONST_CHAR(TXT_NETWORK, "network"); @@ -212,12 +215,18 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_WEBSERVER_PORT; }; +#ifndef USE_API + // Without the native API there is no _esphomelib service, so publish the + // device info here for the device builder to discover. + web_service.txt_records = {{MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)}, + {MDNS_STR(TXT_MAC), MDNS_STR(mac_address_buf)}, + {MDNS_STR(TXT_CONFIG_HASH), MDNS_STR(config_hash_buf)}}; +#endif #endif #if !defined(USE_API) && !defined(USE_PROMETHEUS) && !defined(USE_SENDSPIN) && !defined(USE_WEBSERVER) && \ !defined(USE_MDNS_EXTRA_SERVICES) MDNS_STATIC_CONST_CHAR(SERVICE_HTTP, "_http"); - MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version"); // Publish "http" service if not using native API or any other services // This is just to have *some* mDNS service so that .local resolution works @@ -225,7 +234,9 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_WEBSERVER_PORT; }; - fallback_service.txt_records = {{MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)}}; + fallback_service.txt_records = {{MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)}, + {MDNS_STR(TXT_MAC), MDNS_STR(mac_address_buf)}, + {MDNS_STR(TXT_CONFIG_HASH), MDNS_STR(config_hash_buf)}}; #endif } diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 9d525abc43..4f97e8cb99 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -22,6 +22,15 @@ #endif #endif +// Device info TXT records (version, mac, config_hash) are published on the _esphomelib service +// when the native API is enabled, otherwise on the _http service (web_server's or the fallback one). +// When neither applies (only prometheus, sendspin or user-defined services are configured), no +// device info records are published and the buffers below are not needed. +#if defined(USE_API) || defined(USE_WEBSERVER) || \ + (!defined(USE_PROMETHEUS) && !defined(USE_SENDSPIN) && !defined(USE_MDNS_EXTRA_SERVICES)) +#define USE_MDNS_DEVICE_INFO_TXT +#endif + namespace esphome::mdns { // Helper struct that identifies strings that may be stored in flash storage (similar to LogString) @@ -136,7 +145,7 @@ class MDNSComponent final : public Component StaticVector dynamic_txt_values_; #endif -#if defined(USE_API) && defined(USE_MDNS_STORE_SERVICES) +#if defined(USE_MDNS_DEVICE_INFO_TXT) && defined(USE_MDNS_STORE_SERVICES) /// Fixed buffer for MAC address (only needed when services are stored) char mac_address_[MAC_ADDRESS_BUFFER_SIZE]; /// Fixed buffer for config hash hex string (only needed when services are stored) @@ -149,8 +158,8 @@ class MDNSComponent final : public Component // 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, - char *config_hash_buf); + void compile_records_(StaticVector &services, const char *mac_address_buf, + const char *config_hash_buf); }; } // namespace esphome::mdns diff --git a/esphome/components/mdns/mdns_host.cpp b/esphome/components/mdns/mdns_host.cpp index 1e66a10df0..c5d849df26 100644 --- a/esphome/components/mdns/mdns_host.cpp +++ b/esphome/components/mdns/mdns_host.cpp @@ -12,7 +12,7 @@ namespace esphome::mdns { void MDNSComponent::setup() { #ifdef USE_MDNS_STORE_SERVICES -#ifdef USE_API +#ifdef USE_MDNS_DEVICE_INFO_TXT get_mac_address_into_buffer(this->mac_address_); char *mac_ptr = this->mac_address_; format_hex_to(this->config_hash_str_, App.get_config_hash()); diff --git a/tests/components/mdns/test-fallback.esp32-idf.yaml b/tests/components/mdns/test-fallback.esp32-idf.yaml new file mode 100644 index 0000000000..b51dbb443f --- /dev/null +++ b/tests/components/mdns/test-fallback.esp32-idf.yaml @@ -0,0 +1,7 @@ +# No api, web_server or extra services so the fallback _http service +# (with version, mac and config_hash TXT records) is compiled. +wifi: + ssid: MySSID + password: password1 + +mdns: diff --git a/tests/components/mdns/test-webserver-no-api.esp32-idf.yaml b/tests/components/mdns/test-webserver-no-api.esp32-idf.yaml new file mode 100644 index 0000000000..23f3abdeb2 --- /dev/null +++ b/tests/components/mdns/test-webserver-no-api.esp32-idf.yaml @@ -0,0 +1,9 @@ +# web_server without the native api so the version, mac and config_hash +# TXT records are attached to the web_server _http service. +wifi: + ssid: MySSID + password: password1 + +web_server: + +mdns: From b098571a6f83bbd38615cb46dc26a17cff314704 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Jul 2026 14:31:14 -1000 Subject: [PATCH 0831/1815] [web_server] Fix unused function warning for json_state_str (#17524) --- esphome/components/web_server/web_server.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 3f4d598d48..3bba879823 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -57,7 +57,7 @@ namespace esphome::web_server { static const char *const TAG = "web_server"; // View a state LogString as a ProgmemStr so ArduinoJson serializes it PROGMEM-aware on ESP8266. -static ProgmemStr json_state_str(const LogString *s) { return reinterpret_cast(s); } +[[maybe_unused]] static ProgmemStr json_state_str(const LogString *s) { return reinterpret_cast(s); } // Parse URL and return match info // URL formats (disambiguated by HTTP method for 3-segment case): From a39607476a74b844f8e32b3eb3486d187d29c35d Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:31:39 +0200 Subject: [PATCH 0832/1815] [zigbee] Fix merge endpoint (#17511) --- esphome/components/zigbee/zigbee_ep_esp32.py | 108 +++++++++++-------- tests/components/zigbee/common_esp32.yaml | 1 + 2 files changed, 64 insertions(+), 45 deletions(-) diff --git a/esphome/components/zigbee/zigbee_ep_esp32.py b/esphome/components/zigbee/zigbee_ep_esp32.py index ca96e4364f..2ed3dddb67 100644 --- a/esphome/components/zigbee/zigbee_ep_esp32.py +++ b/esphome/components/zigbee/zigbee_ep_esp32.py @@ -94,66 +94,73 @@ def get_next_ep_num(eps: list[int]) -> int: return ep_num -def merge_endpoint( +def compare_clusters( existing_ep: dict[str, Any], - ep_num: int | None, ep: dict[str, Any], - use_type: bool | None, - skip_error: bool, -) -> bool: - add = True +) -> tuple[str | int, str] | None: existing_clusters = [(cl[CONF_ID], cl[ROLE]) for cl in existing_ep[CONF_CLUSTERS]] for cl in [(cl[CONF_ID], cl[ROLE]) for cl in ep[CONF_CLUSTERS]]: if cl in existing_clusters: - if not skip_error: - raise cv.Invalid( - f"Endpoint {ep_num} has more than one cluster with cluster id {cl[0]} and role {cl[1]}." - ) - add = False - break - if not add: + return cl + return None + + +def merge_endpoints( + existing_ep: dict[str, Any], + ep: dict[str, Any], + use_type: bool | None, +) -> bool: + if compare_clusters(existing_ep, ep): return False - if ( - use_type - and existing_ep.get(CONF_USE_DEVICE_TYPE) - and ep.get(DEVICE_TYPE) != existing_ep.get(DEVICE_TYPE) - ): - if not skip_error: - raise cv.Invalid( - f"Endpoint {ep_num} has a conflicting device type {existing_ep.get(DEVICE_TYPE, 'CUSTOM_ATTR')} and use_type is set for both." - ) - return False - if use_type: - existing_ep[CONF_USE_DEVICE_TYPE] = use_type - if ep.get(DEVICE_TYPE): - existing_ep[DEVICE_TYPE] = ep[DEVICE_TYPE] - else: - existing_ep.pop(DEVICE_TYPE, None) - existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS]) - return True - if existing_ep.get(CONF_USE_DEVICE_TYPE): - existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS]) - return True if ( ep.get(DEVICE_TYPE) and existing_ep.get(DEVICE_TYPE) - and ep[DEVICE_TYPE] != existing_ep[DEVICE_TYPE] + and ep.get(DEVICE_TYPE) != existing_ep.get(DEVICE_TYPE) ): - if not skip_error: - raise cv.Invalid( - f"Endpoint {ep_num} has already a conflicting device type {existing_ep[DEVICE_TYPE]} and use_type is not set for both." - ) return False + if ( + ep.get(DEVICE_TYPE) + and not existing_ep.get(DEVICE_TYPE) + and existing_ep.get(CONF_USE_DEVICE_TYPE) + ): + return False + if existing_ep.get(DEVICE_TYPE) and not ep.get(DEVICE_TYPE) and use_type: + return False + if use_type: + existing_ep[CONF_USE_DEVICE_TYPE] = use_type if ep.get(DEVICE_TYPE): existing_ep[DEVICE_TYPE] = ep[DEVICE_TYPE] existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS]) return True +def validate_endpoints(ep_dict: dict[int, dict]) -> None: + for num, ep in ep_dict.items(): + types_dict = ep.get(CONF_USE_DEVICE_TYPE) + if not types_dict: + continue + if len(types_dict) == 1: + ep[DEVICE_TYPE] = list(types_dict.keys())[0] + del ep[CONF_USE_DEVICE_TYPE] + continue + types_list = [t[0] for t in types_dict.items() if t[1]] + if len(types_list) > 1: + raise cv.Invalid( + f"There is more than one component with endpoint: {num} and {CONF_USE_DEVICE_TYPE}: True" + ) + if not types_list: + raise cv.Invalid( + f"Multiple device types on endpoint: {num}. Set {CONF_USE_DEVICE_TYPE}: True on one component." + ) + ep[DEVICE_TYPE] = types_list[0] + del ep[CONF_USE_DEVICE_TYPE] + + def create_ep(router: bool) -> None: zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) ep_dict: dict[int, dict] = zb_data.setdefault(KEY_ZIGBEE_EP, {}) ep_list: list[dict] = zb_data.setdefault(KEY_ZIGBEE_EP_NO_NUM, []) + validate_endpoints(ep_dict) # create dummy endpoint if list is empty if not ep_dict and not ep_list: ep_type = "CUSTOM_ATTR" @@ -166,9 +173,7 @@ def create_ep(router: bool) -> None: for ep in ep_list: added = False for existing_ep in ep_list_new: - if merge_endpoint( - existing_ep, None, ep, ep.get(CONF_USE_DEVICE_TYPE), True - ): + if merge_endpoints(existing_ep, ep, ep.get(CONF_USE_DEVICE_TYPE)): added = True break if not added: @@ -191,6 +196,8 @@ def create_ep(router: bool) -> None: def add_ep(ep: dict[str, Any], ep_num: int | None, use_type: bool | None) -> None: zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) + if use_type is False: + ep.pop(DEVICE_TYPE, None) if ep_num is None: if use_type: ep[CONF_USE_DEVICE_TYPE] = use_type @@ -201,8 +208,19 @@ def add_ep(ep: dict[str, Any], ep_num: int | None, use_type: bool | None) -> Non if ep_num in ep_dict: # check if the existing endpoint has same clusters existing_ep = ep_dict[ep_num] - merge_endpoint(existing_ep, ep_num, ep, use_type, False) + if cl := compare_clusters( + existing_ep, + ep, + ): + raise cv.Invalid( + f"Endpoint {ep_num} has more than one cluster with cluster id {cl[0]} and role {cl[1]}." + ) + if ep.get(DEVICE_TYPE) or use_type: + types_dict = existing_ep.setdefault(CONF_USE_DEVICE_TYPE, {}) + if not types_dict.get(ep.get(DEVICE_TYPE)) or use_type: + types_dict[ep.get(DEVICE_TYPE)] = use_type + existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS]) else: - if use_type is not None: - ep[CONF_USE_DEVICE_TYPE] = use_type + if use_type or ep.get(DEVICE_TYPE): + ep[CONF_USE_DEVICE_TYPE] = {ep.get(DEVICE_TYPE): use_type} ep_dict[ep_num] = ep diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml index 8e00e4471e..6cac9c9e2a 100644 --- a/tests/components/zigbee/common_esp32.yaml +++ b/tests/components/zigbee/common_esp32.yaml @@ -5,6 +5,7 @@ binary_sensor: - platform: template name: "Garage Door Open 10" report: "default" + use_device_type: false - platform: template name: "Garage Door Open 12" report: "force" From 91c42381f649832c285e89f5bf161e9f72560f3a Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:32:01 +0200 Subject: [PATCH 0833/1815] [zigbee] prevent task watchdog trigger with large configs. (#17506) --- .../zigbee/zigbee_attribute_esp32.cpp | 19 --------- .../zigbee/zigbee_attribute_esp32.h | 1 - esphome/components/zigbee/zigbee_esp32.cpp | 42 +++++++++---------- esphome/components/zigbee/zigbee_esp32.h | 2 +- 4 files changed, 22 insertions(+), 42 deletions(-) diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.cpp b/esphome/components/zigbee/zigbee_attribute_esp32.cpp index c6f2aa0af6..d7176e6ca5 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.cpp +++ b/esphome/components/zigbee/zigbee_attribute_esp32.cpp @@ -50,25 +50,6 @@ void ZigbeeAttribute::report_(bool has_lock) { } } -void ZigbeeAttribute::setup_reporting() { - ezb_zcl_reporting_info_t reporting_info = ezb_zcl_reporting_info_find( - this->endpoint_id_, this->cluster_id_, this->role_, this->attr_id_, EZB_ZCL_STD_MANUF_CODE); - if (reporting_info == EZB_ZCL_INVALID_REPORTING_INFO) { - ESP_LOGD(TAG, "Could not find reporting info for attribute 0x%04X in cluster 0x%04X in endpoint %u", this->attr_id_, - this->cluster_id_, this->endpoint_id_); - this->report_enabled = false; - this->force_report_ = false; - } else { - ESP_LOGD(TAG, "Found reporting info for attr 0x%04X in cluster 0x%04X", this->attr_id_, this->cluster_id_); - ezb_zcl_attr_variable_t delta = {.u64 = 0}; - ezb_zcl_reporting_info_update_default_interval(reporting_info, 0, 65000); - ezb_zcl_reporting_info_update(reporting_info, 0, 65000, &delta); - if (ezb_zcl_reporting_start_attr_report(reporting_info) != EZB_ERR_NONE) { - ESP_LOGE(TAG, "Could not start reporting for attribute"); - } - } -} - void ZigbeeAttribute::set_report(ZigbeeReportT report) { this->report_enabled = true; if (report == ZigbeeReportT::ZIGBEE_REPORT_FORCE) { diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.h b/esphome/components/zigbee/zigbee_attribute_esp32.h index b5afb57910..e5f8c8b1cf 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.h +++ b/esphome/components/zigbee/zigbee_attribute_esp32.h @@ -42,7 +42,6 @@ class ZigbeeAttribute final : public Component { scale_(scale) {} void loop() override; template void add_attr(T value); - void setup_reporting(); template void set_attr(const T &value); uint8_t attr_type() { return attr_type_; } void set_report(ZigbeeReportT report); diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 03457312be..3e0f6cd745 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -53,11 +53,7 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { switch (signal_type) { case EZB_ZDO_SIGNAL_SKIP_STARTUP: ESP_LOGD(TAG, "Zigbee stack initialized"); - if (ezb_bdb_is_factory_new()) { - global_zigbee->defer([]() { global_zigbee->setup_reporting(); }); - } else { - ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_INITIALIZATION); - } + ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_INITIALIZATION); break; case EZB_BDB_SIGNAL_DEVICE_FIRST_START: case EZB_BDB_SIGNAL_DEVICE_REBOOT: { @@ -133,12 +129,12 @@ static void zb_action_handler(ezb_zcl_core_action_callback_id_t callback_id, voi case EZB_ZCL_CORE_SET_ATTR_VALUE_CB_ID: zb_attribute_handler((ezb_zcl_set_attr_value_message_t *) message); break; -#ifdef ESPHOME_LOG_HAS_VERBOSE case EZB_ZCL_CORE_DEFAULT_RSP_CB_ID: { +#ifdef ESPHOME_LOG_HAS_VERBOSE ezb_zcl_cmd_default_rsp_message_t *default_rsp = (ezb_zcl_cmd_default_rsp_message_t *) message; ESP_LOGV(TAG, "Received ZCL Default Response: 0x%02x", default_rsp->in.status_code); - } break; #endif + } break; default: ESP_LOGD(TAG, "Receive Zigbee action(0x%04x) callback", static_cast(callback_id)); break; @@ -206,21 +202,30 @@ void ZigbeeComponent::update_basic_cluster_(ezb_af_ep_desc_t ep_desc) { ezb_af_endpoint_add_cluster_desc(ep_desc, cluster_desc); } -void ZigbeeComponent::setup_reporting() { - ESP_LOGD(TAG, "Setting up reporting for all attributes"); - esp_zigbee_lock_acquire(portMAX_DELAY); - for (auto &[_, attribute] : this->attributes_) { - attribute->setup_reporting(); +bool ZigbeeComponent::register_device() { + if (ezb_af_device_desc_register(this->dev_desc_) != EZB_ERR_NONE) { + ESP_LOGE(TAG, "Could not register the endpoint list"); + this->mark_failed(); + return false; } - ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_INITIALIZATION); - esp_zigbee_lock_release(); + return true; } static void ezb_task(void *pv_parameters) { + if (!global_zigbee->register_device()) { + vTaskDelete(NULL); + return; + } if (esp_zigbee_start(false) != ESP_OK) { ESP_LOGE(TAG, "Could not setup Zigbee"); + global_zigbee->mark_failed(); vTaskDelete(NULL); + return; // vTaskDelete(NULL) never returns, but keep intent explicit } + + // Increase priority to 5 to align with openthread or BLE + vTaskPrioritySet(NULL, 5); + esp_zigbee_launch_mainloop(); esp_zigbee_deinit(); @@ -274,12 +279,6 @@ void ZigbeeComponent::setup() { return; } - if (ezb_af_device_desc_register(this->dev_desc_) != EZB_ERR_NONE) { - ESP_LOGE(TAG, "Could not register the endpoint list"); - this->mark_failed(); - return; - } - ezb_zcl_core_action_handler_register(zb_action_handler); if (ezb_bdb_set_primary_channel_set(EZB_PRIMARY_CHANNEL_MASK) != ESP_OK) { @@ -298,7 +297,8 @@ void ZigbeeComponent::setup() { }; ezb_af_set_node_power_desc(&desc); - xTaskCreate(ezb_task, "Zigbee_main", 4096, NULL, 24, NULL); + // Start the Zigbee task with priority 1 to ensure main loop can still run even if Zigbee is busy + xTaskCreate(ezb_task, "Zigbee_main", 4096, NULL, 1, NULL); this->disable_loop(); // loop is only needed for processing events, so disable until we join a network } diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index 11289843a8..f4bafac294 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -42,7 +42,7 @@ class ZigbeeComponent final : public Component { void set_basic_cluster(const char *model, const char *manufacturer, uint8_t power_source); void add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role); void create_default_cluster(uint8_t endpoint_id, uint16_t device_id); - void setup_reporting(); + bool register_device(); template void add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, From e27a14ec709ec9cc6f8a756d2940eddb119515c7 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:33:29 +1200 Subject: [PATCH 0834/1815] [core] Classify entity metadata visibility for the visual editor (#17503) --- esphome/components/binary_sensor/__init__.py | 4 +- esphome/components/button/__init__.py | 4 +- esphome/components/cover/__init__.py | 4 +- esphome/components/event/__init__.py | 4 +- esphome/components/number/__init__.py | 12 ++- esphome/components/sensor/__init__.py | 26 +++-- esphome/components/switch/__init__.py | 4 +- esphome/components/text_sensor/__init__.py | 4 +- esphome/components/update/__init__.py | 8 +- esphome/components/valve/__init__.py | 4 +- esphome/components/web_server/__init__.py | 4 +- esphome/config_validation.py | 102 ++++++++++++------- tests/unit_tests/test_config_validation.py | 70 ++++++++++++- 13 files changed, 193 insertions(+), 57 deletions(-) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index a9a09363fc..5800e0bd9e 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -448,7 +448,9 @@ _BINARY_SENSOR_SCHEMA = ( cv.Exclusive( CONF_TRIGGER_ON_INITIAL_STATE, CONF_TRIGGER_ON_INITIAL_STATE ): cv.boolean, - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, cv.Optional(CONF_FILTERS): validate_filters, cv.Optional(CONF_ON_PRESS): automation.validate_automation({}), cv.Optional(CONF_ON_RELEASE): automation.validate_automation({}), diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index dd4fde5705..a4245f43e6 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -50,7 +50,9 @@ _BUTTON_SCHEMA = ( .extend( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTButtonComponent), - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, cv.Optional(CONF_ON_PRESS): automation.validate_automation({}), } ) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index 839ca532e6..7639e15334 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -131,7 +131,9 @@ _COVER_SCHEMA = ( cv.Optional(CONF_MQTT_JSON_STATE_PAYLOAD): cv.All( cv.requires_component("mqtt"), cv.boolean ), - cv.Optional(CONF_DEVICE_CLASS): cv.one_of(*DEVICE_CLASSES, lower=True), + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): cv.one_of(*DEVICE_CLASSES, lower=True), cv.Optional(CONF_POSITION_COMMAND_TOPIC): cv.All( cv.requires_component("mqtt"), cv.subscribe_topic ), diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 4cab1bff9b..e205e4b910 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -50,7 +50,9 @@ _EVENT_SCHEMA = ( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTEventComponent), cv.GenerateID(): cv.declare_id(Event), - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, cv.Optional(CONF_ON_EVENT): automation.validate_automation({}), } ) diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index bcc609de65..ea0c2d77f6 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -212,9 +212,15 @@ _NUMBER_SCHEMA = ( }, cv.has_at_least_one_key(CONF_ABOVE, CONF_BELOW), ), - cv.Optional(CONF_UNIT_OF_MEASUREMENT): validate_unit_of_measurement, - cv.Optional(CONF_MODE, default="AUTO"): cv.enum(NUMBER_MODES, upper=True), - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, + cv.Optional( + CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED + ): validate_unit_of_measurement, + cv.Optional( + CONF_MODE, default="AUTO", visibility=cv.Visibility.ADVANCED + ): cv.enum(NUMBER_MODES, upper=True), + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, } ) ) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index da8a540d8d..6ad76046a1 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -321,13 +321,25 @@ _SENSOR_SCHEMA = ( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTSensorComponent), cv.GenerateID(): cv.declare_id(Sensor), - cv.Optional(CONF_UNIT_OF_MEASUREMENT): validate_unit_of_measurement, - cv.Optional(CONF_ACCURACY_DECIMALS): validate_accuracy_decimals, - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, - cv.Optional(CONF_STATE_CLASS): validate_state_class, - cv.Optional(CONF_ENTITY_CATEGORY): sensor_entity_category, - cv.Optional(CONF_FORCE_UPDATE, default=False): cv.boolean, - cv.Optional(CONF_EXPIRE_AFTER): cv.All( + cv.Optional( + CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED + ): validate_unit_of_measurement, + cv.Optional( + CONF_ACCURACY_DECIMALS, visibility=cv.Visibility.ADVANCED + ): validate_accuracy_decimals, + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, + cv.Optional( + CONF_STATE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_state_class, + cv.Optional( + CONF_ENTITY_CATEGORY, visibility=cv.Visibility.ADVANCED + ): sensor_entity_category, + cv.Optional( + CONF_FORCE_UPDATE, default=False, visibility=cv.Visibility.ADVANCED + ): cv.boolean, + cv.Optional(CONF_EXPIRE_AFTER, visibility=cv.Visibility.ADVANCED): cv.All( cv.requires_component("mqtt"), cv.Any(None, cv.positive_time_period_milliseconds), ), diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index 1108652e99..18b95113cc 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -78,7 +78,9 @@ _SWITCH_SCHEMA = ( cv.Optional(CONF_ON_STATE): automation.validate_automation({}), cv.Optional(CONF_ON_TURN_ON): automation.validate_automation({}), cv.Optional(CONF_ON_TURN_OFF): automation.validate_automation({}), - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, } ) ) diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 01a57cbaa1..a3f4999a8f 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -144,7 +144,9 @@ _TEXT_SENSOR_SCHEMA = ( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTTextSensor), cv.GenerateID(): cv.declare_id(TextSensor), - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, cv.Optional(CONF_FILTERS): validate_filters, cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}), diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index ddb471be18..18d333a5ef 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -54,7 +54,9 @@ _UPDATE_SCHEMA = ( .extend( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTUpdateComponent), - cv.Optional(CONF_DEVICE_CLASS): cv.one_of(*DEVICE_CLASSES, lower=True), + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): cv.one_of(*DEVICE_CLASSES, lower=True), cv.Optional(CONF_ON_UPDATE_AVAILABLE): automation.validate_automation( single=True ), @@ -136,7 +138,9 @@ async def to_code(config): automation.maybe_simple_id( { cv.GenerateID(): cv.use_id(UpdateEntity), - cv.Optional(CONF_FORCE_UPDATE, default=False): cv.templatable(cv.boolean), + cv.Optional( + CONF_FORCE_UPDATE, default=False, visibility=cv.Visibility.ADVANCED + ): cv.templatable(cv.boolean), } ), synchronous=True, diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index d82a9fdec2..7d98af402d 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -87,7 +87,9 @@ _VALVE_SCHEMA = ( { cv.GenerateID(): cv.declare_id(Valve), cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTValveComponent), - cv.Optional(CONF_DEVICE_CLASS): cv.one_of(*DEVICE_CLASSES, lower=True), + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): cv.one_of(*DEVICE_CLASSES, lower=True), cv.Optional(CONF_POSITION_COMMAND_TOPIC): cv.All( cv.requires_component("mqtt"), cv.subscribe_topic ), diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index f4e9eae763..d9fd27dbc2 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -172,7 +172,9 @@ sorting_group = { WEBSERVER_SORTING_SCHEMA = cv.Schema( { - cv.Optional(CONF_WEB_SERVER): cv.Schema( + # The per-entity web_server block is cosmetic dashboard ordering — + # mark the whole block advanced; the children inherit via the cascade. + cv.Optional(CONF_WEB_SERVER, visibility=cv.Visibility.ADVANCED): cv.Schema( { cv.OnlyWith(CONF_WEB_SERVER_ID, "web_server"): cv.use_id(WebServer), cv.Optional(CONF_SORTING_WEIGHT): cv.All( diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 45fd94fd1a..16f0a63aa0 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -292,10 +292,14 @@ class Visibility(StrEnum): the same way. ESPHome itself ignores the value at runtime; consumers downstream of the schema dump act on it. - A field with no ``visibility`` set (the default) renders on the - editor's main form. The two values below are points along a - single axis of "how prominently to surface this": + Three points along a single axis of "how prominently to surface + this", from least to most hidden: + - ``UI`` — always render on the editor's main form. Use to + promote an ``Optional`` that would otherwise fall through to + the advanced disclosure (see the default rule below): the + "headline" config a user reaches for first (e.g. a sensor's + ``name`` or its primary pin/address). - ``ADVANCED`` — render under the editor's "advanced settings" disclosure. Use for fields whose default is right for ~all users (e.g. ``update_interval`` on time platforms — 15 min is @@ -307,25 +311,35 @@ class Visibility(StrEnum): tweaks can break boot). The YAML escape hatch stays available for the rare power-user override. - The single-axis shape encodes "yaml-only is strictly stronger - than advanced" at the type level — there's no way to ask for - both at once, and no way to set a contradictory state like - "advanced=False, yaml_only=True". + Default when unset (``visibility=None``): resolved by the + consumer, not encoded on the marker. A schema-aware editor + treats an ``Optional`` with no setting as ``ADVANCED`` (most + optional knobs have sensible defaults and would clutter the + form), and a ``Required`` with no setting as ``UI`` (a required + field needs the user's attention). Pass an explicit value to + override either default — most commonly ``UI`` to keep a + high-value ``Optional`` on the main form. + + The single-axis shape encodes the strictness ladder + (``UI`` < ``ADVANCED`` < ``YAML_ONLY``) at the type level — + there's no way to set a contradictory state. Per-field; the dumper walks recursively into nested schemas - and emits each field's setting independently. Cascading - semantics — "a stricter parent makes its descendants at-least - as strict" — belong on the consumer side: the schema marker - is faithfully what the field author wrote, and a consumer that - cares about effective visibility walks the parent chain and - takes the strictest setting. ``YAML_ONLY`` is strictly stronger - than ``ADVANCED``, which is strictly stronger than no setting. - Inner fields can declare their own visibility; an inner + and emits each field's setting independently, omitting the key + when unset so the dump stays compact and the per-field default + is the consumer's to apply. Cascading semantics — "a stricter + parent makes its descendants at-least as strict" — belong on the + consumer side: the schema marker is faithfully what the field + author wrote, and a consumer that cares about effective + visibility walks the parent chain and takes the strictest + setting. Inner fields can declare their own visibility; an inner ``YAML_ONLY`` under an ``ADVANCED`` parent stays ``YAML_ONLY``, - and the consumer's cascade keeps siblings under the parent at - ``ADVANCED`` regardless of their own (less-strict) setting. + and the consumer's cascade keeps a ``UI`` sibling under an + ``ADVANCED`` parent at ``ADVANCED`` regardless of its own + (less-strict) setting. """ + UI = "ui" ADVANCED = "advanced" YAML_ONLY = "yaml_only" @@ -347,6 +361,9 @@ class Optional(vol.Optional): See :class:`Visibility` for the ``visibility`` kwarg — a UI hint for schema-driven editors that doesn't affect validation. + Left unset, an ``Optional`` is treated as ``Visibility.ADVANCED`` + by schema-aware editors; pass ``Visibility.UI`` to keep it on the + main form. """ def __init__( @@ -369,9 +386,11 @@ class Required(vol.Required): See :class:`Visibility` for the ``visibility`` kwarg — a UI hint for schema-driven editors that doesn't affect validation. - Required fields rarely need it (a required field by definition - needs the user's attention) but the kwarg is exposed for - symmetry so consumers can apply uniform logic across key markers. + Required fields rarely need it: left unset, a ``Required`` is + treated as on the main form (``Visibility.UI``) by schema-aware + editors, since a required field needs the user's attention. The + kwarg is exposed for symmetry so consumers can apply uniform + logic across key markers. """ def __init__( @@ -2274,16 +2293,25 @@ MQTT_COMPONENT_AVAILABILITY_SCHEMA = Schema( } ) +# Per-entity MQTT plumbing — integration metadata, never a primary UI field. MQTT_COMPONENT_SCHEMA = Schema( { - Optional(CONF_QOS): All(requires_component("mqtt"), mqtt_qos), - Optional(CONF_RETAIN): All(requires_component("mqtt"), boolean), - Optional(CONF_DISCOVERY): All(requires_component("mqtt"), boolean), - Optional(CONF_SUBSCRIBE_QOS): All(requires_component("mqtt"), mqtt_qos), - Optional(CONF_STATE_TOPIC): All( + Optional(CONF_QOS, visibility=Visibility.ADVANCED): All( + requires_component("mqtt"), mqtt_qos + ), + Optional(CONF_RETAIN, visibility=Visibility.ADVANCED): All( + requires_component("mqtt"), boolean + ), + Optional(CONF_DISCOVERY, visibility=Visibility.ADVANCED): All( + requires_component("mqtt"), boolean + ), + Optional(CONF_SUBSCRIBE_QOS, visibility=Visibility.ADVANCED): All( + requires_component("mqtt"), mqtt_qos + ), + Optional(CONF_STATE_TOPIC, visibility=Visibility.ADVANCED): All( requires_component("mqtt"), templatable(publish_topic) ), - Optional(CONF_AVAILABILITY): All( + Optional(CONF_AVAILABILITY, visibility=Visibility.ADVANCED): All( requires_component("mqtt"), Any(None, MQTT_COMPONENT_AVAILABILITY_SCHEMA) ), } @@ -2291,10 +2319,12 @@ MQTT_COMPONENT_SCHEMA = Schema( MQTT_COMMAND_COMPONENT_SCHEMA = MQTT_COMPONENT_SCHEMA.extend( { - Optional(CONF_COMMAND_TOPIC): All( + Optional(CONF_COMMAND_TOPIC, visibility=Visibility.ADVANCED): All( requires_component("mqtt"), templatable(subscribe_topic) ), - Optional(CONF_COMMAND_RETAIN): All(requires_component("mqtt"), boolean), + Optional(CONF_COMMAND_RETAIN, visibility=Visibility.ADVANCED): All( + requires_component("mqtt"), boolean + ), } ) @@ -2369,12 +2399,16 @@ def string_no_slash(value): ENTITY_BASE_SCHEMA = Schema( { - Optional(CONF_NAME): _validate_entity_name, - Optional(CONF_INTERNAL): boolean, - Optional(CONF_DISABLED_BY_DEFAULT, default=False): boolean, - Optional(CONF_ICON): icon, - Optional(CONF_ENTITY_CATEGORY): entity_category, - Optional(CONF_DEVICE_ID): sub_device_id, + # The name is every entity's headline field — keep it on the + # main form rather than letting it fall through to advanced. + Optional(CONF_NAME, visibility=Visibility.UI): _validate_entity_name, + Optional(CONF_INTERNAL, visibility=Visibility.ADVANCED): boolean, + Optional( + CONF_DISABLED_BY_DEFAULT, default=False, visibility=Visibility.ADVANCED + ): boolean, + Optional(CONF_ICON, visibility=Visibility.ADVANCED): icon, + Optional(CONF_ENTITY_CATEGORY, visibility=Visibility.ADVANCED): entity_category, + Optional(CONF_DEVICE_ID, visibility=Visibility.ADVANCED): sub_device_id, } ) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 6580564c65..17dfaad9b8 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1174,9 +1174,10 @@ def test_update_interval__never_passes_through() -> None: def test_optional_default_visibility_is_none() -> None: """An ``Optional`` with no ``visibility`` kwarg reports ``None``. - Consumers can read the attribute directly with plain attribute - access; absence (``None``) means "render on the editor's main - form." + The marker stays faithful to what the author wrote: ESPHome does + not encode the default on it. Resolving ``None`` to an effective + visibility is the consumer's job — a schema-aware editor treats an + unset ``Optional`` as ``ADVANCED`` (see :class:`Visibility`). """ o = cv.Optional("foo") assert o.visibility is None @@ -1194,6 +1195,17 @@ def test_optional_visibility_yaml_only() -> None: assert o.visibility is cv.Visibility.YAML_ONLY +def test_optional_visibility_ui() -> None: + """``visibility=Visibility.UI`` is recorded on the marker. + + ``UI`` promotes an ``Optional`` onto the editor's main form, + overriding the consumer's default of ``ADVANCED`` for unset + optionals. + """ + o = cv.Optional("foo", visibility=cv.Visibility.UI) + assert o.visibility is cv.Visibility.UI + + def test_visibility_str_values_match_dump_emission() -> None: """``Visibility`` is a ``StrEnum`` whose values are the literal strings the schema dumper emits. @@ -1203,6 +1215,7 @@ def test_visibility_str_values_match_dump_emission() -> None: field — pinning the on-the-wire spelling here keeps the dump contract stable. """ + assert str(cv.Visibility.UI) == "ui" assert str(cv.Visibility.ADVANCED) == "advanced" assert str(cv.Visibility.YAML_ONLY) == "yaml_only" @@ -1325,6 +1338,57 @@ def test_visibility_marker_is_per_field_no_mutation() -> None: assert inner_yaml_only.visibility is cv.Visibility.YAML_ONLY +def test_entity_metadata_visibility_hints() -> None: + """Entity and value-describing metadata is classified for visual editors. + + The headline ``name`` stays on the main form (``UI``); descriptive + metadata (device_class, unit, …), presentation options, and per-entity + integration plumbing (MQTT, web_server ordering) fall to the advanced + disclosure (``ADVANCED``). + """ + advanced = cv.Visibility.ADVANCED + + entity_base = {str(k): k for k in cv.ENTITY_BASE_SCHEMA.schema} + assert entity_base["name"].visibility is cv.Visibility.UI + for field in ( + "icon", + "internal", + "disabled_by_default", + "entity_category", + "device_id", + ): + assert entity_base[field].visibility is advanced, field + + mqtt = {str(k): k for k in cv.MQTT_COMPONENT_SCHEMA.schema} + for field in ("qos", "retain", "discovery", "state_topic", "availability"): + assert mqtt[field].visibility is advanced, field + + from esphome.components import binary_sensor, number, sensor + from esphome.components.web_server import WEBSERVER_SORTING_SCHEMA + + sensor_markers = {str(k): k for k in sensor.sensor_schema().schema} + for field in ( + "unit_of_measurement", + "accuracy_decimals", + "device_class", + "state_class", + "force_update", + ): + assert sensor_markers[field].visibility is advanced, field + + binary = {str(k): k for k in binary_sensor.binary_sensor_schema().schema} + assert binary["device_class"].visibility is advanced + + number_markers = {str(k): k for k in number.number_schema(number.Number).schema} + assert number_markers["mode"].visibility is advanced + assert number_markers["device_class"].visibility is advanced + + # The whole per-entity web_server block is advanced; children inherit + # via the consumer cascade, so only the parent key carries the hint. + web = {str(k): k for k in WEBSERVER_SORTING_SCHEMA.schema} + assert web["web_server"].visibility is advanced + + def _wrap_str(value: str) -> ESPHomeDataBase: """Wrap a raw string as an ESPHomeDataBase, mimicking a YAML-loaded value.""" return make_data_base(value) From 2b3027a7fdb39a117078adeb77badf47da38461a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Jul 2026 10:55:30 -1000 Subject: [PATCH 0835/1815] [api] Provision encryption keys over an encrypted zero-PSK noise connection (#17482) --- esphome/components/api/__init__.py | 7 +- esphome/components/api/api.proto | 5 + esphome/components/api/api_connection.cpp | 49 +++++++ esphome/components/api/api_connection.h | 5 + esphome/components/api/api_frame_helper.cpp | 2 + esphome/components/api/api_frame_helper.h | 11 ++ .../components/api/api_frame_helper_noise.cpp | 51 +++++-- .../components/api/api_frame_helper_noise.h | 8 ++ .../api/api_frame_helper_plaintext.cpp | 11 ++ .../api/api_frame_helper_plaintext.h | 9 ++ esphome/components/api/api_noise_context.h | 17 ++- esphome/components/api/api_pb2.cpp | 6 + esphome/components/api/api_pb2.h | 5 +- esphome/components/api/api_pb2_dump.cpp | 3 + esphome/components/mdns/mdns_component.cpp | 19 ++- .../test-dynamic-encryption.esp32-idf.yaml | 8 +- .../fixtures/api_zero_psk_provisioning.yaml | 6 + .../api_zero_psk_provisioning_plaintext.yaml | 6 + .../test_api_zero_psk_provisioning.py | 127 ++++++++++++++++++ 19 files changed, 334 insertions(+), 21 deletions(-) create mode 100644 tests/integration/fixtures/api_zero_psk_provisioning.yaml create mode 100644 tests/integration/fixtures/api_zero_psk_provisioning_plaintext.yaml create mode 100644 tests/integration/test_api_zero_psk_provisioning.py diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 64b025fee1..0719cee352 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -488,8 +488,11 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_NOISE_PSK_FROM_YAML") else: # No key provided, but encryption desired - # This will allow a plaintext client to provide a noise key, - # send it to the device, and then switch to noise. + # Until a key is set, the device accepts both Noise connections + # using the well-known all-zeros PSK (preferred: the key travels + # encrypted, protecting against passive sniffing) and plaintext + # connections (deprecated, remove after 2027.2.0) so a client can + # provide a noise key and the device then switches to noise only. # The key will be saved in flash and used for future connections # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 86707d9810..4b3df62ec4 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -310,6 +310,11 @@ message DeviceInfoResponse { // Serial proxy instance metadata repeated SerialProxyInfo serial_proxies = 25 [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"]; + + // Device is unprovisioned and accepts Noise handshakes with the well-known + // all-zeros PSK, so the api encryption key can be provisioned without being + // sent in plaintext (protects against passive sniffing, not active MITM) + bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"]; } message ListEntitiesRequest { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index dcb1478ec8..2efdf0bc03 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -198,6 +198,29 @@ APIConnection::~APIConnection() { #endif } +#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT) +void APIConnection::upgrade_helper_to_noise_() { + // The client opened with a Noise hello while this device has no encryption + // key set. Replace the plaintext helper with a Noise helper so the key can + // be provisioned over an encrypted channel: the noise context PSK is all + // zeros when unprovisioned, and NNpsk0 still runs a fresh ephemeral X25519 + // exchange, so a passive listener cannot read the session. A publicly known + // PSK authenticates nobody; this protects against sniffing only. + auto *plaintext = static_cast(this->helper_.get()); + uint8_t header[3]; + uint8_t header_len = plaintext->get_consumed_header(header); + auto *noise = new APINoiseFrameHelper(plaintext->release_socket_for_switch(), this->parent_->get_noise_ctx()); + // Carry over the peername-based client name (Hello has not arrived yet) + const char *name = plaintext->get_client_name(); + noise->set_client_name(name, strlen(name)); + this->helper_.reset(noise); // destroys the plaintext helper + APIError err = noise->init_from_handoff(header, header_len); + if (err != APIError::OK) { + this->fatal_error_with_log_(LOG_STR("Noise handoff failed"), err); + } +} +#endif // USE_API_NOISE && USE_API_PLAINTEXT + void APIConnection::destroy_active_iterator_() { switch (this->active_iterator_) { case ActiveIterator::LIST_ENTITIES: @@ -256,6 +279,15 @@ void APIConnection::loop() { // No more data available break; } else if (err != APIError::OK) { +#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT) + // Checked inside the error branch to keep the hot err == OK path + // free of it; this can only fire on the first bytes of a plaintext + // helper on an unprovisioned device + if (err == APIError::PROTOCOL_SWITCH_TO_NOISE) { + this->upgrade_helper_to_noise_(); + return; + } +#endif this->fatal_error_with_log_(LOG_STR("Reading failed"), err); return; } else { @@ -1860,6 +1892,12 @@ bool APIConnection::send_device_info_response_() { #endif #ifdef USE_API_NOISE resp.api_encryption_supported = true; +#ifndef USE_API_NOISE_PSK_FROM_YAML + // No key from YAML: while no key is set, the key can be provisioned over a + // zero-PSK Noise connection. Gated on the YAML define (not the plaintext + // one) so this advertisement survives the plaintext removal in 2027.2.0. + resp.api_encryption_provisionable = !this->parent_->get_noise_ctx().has_psk(); +#endif #endif #ifdef USE_DEVICES size_t device_index = 0; @@ -2037,10 +2075,21 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio } } else if (base64_decode(msg.key, msg.key_len, psk.data(), psk.size()) != psk.size()) { ESP_LOGW(TAG, "Invalid encryption key length"); + } else if (APINoiseContext::is_all_zeros(psk)) { + // Accepting the reserved provisioning PSK would report success without + // enabling encryption (or silently clear an existing key) + ESP_LOGW(TAG, "Rejecting all-zero encryption key"); } else if (!this->parent_->save_noise_psk(psk, true)) { ESP_LOGW(TAG, "Failed to save encryption key"); } else { resp.success = true; +#ifdef USE_API_PLAINTEXT + if (this->helper_->frame_footer_size() == 0) { + // Plaintext transport has no frame footer; Noise always has the MAC footer. + // Remove after 2027.2.0 together with plaintext support on keyless devices. + ESP_LOGW(TAG, "Key received over plaintext; deprecated, will be removed in 2027.2.0"); + } +#endif } return this->send_message(resp); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index d6d3e4d26b..144973fa9d 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -626,6 +626,11 @@ class APIConnection final : public APIServerConnectionBase { void destroy_active_iterator_(); void begin_iterator_(ActiveIterator type); void finalize_iterator_sync_(); +#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT) + // Swap the plaintext helper for a Noise helper after the client opened + // with a Noise hello on an unprovisioned device (zero-PSK provisioning). + void upgrade_helper_to_noise_(); +#endif #ifdef USE_CAMERA std::unique_ptr image_reader_; #endif diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 90353b6402..7425304766 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -97,6 +97,8 @@ const LogString *api_error_to_logstr(APIError err) { return LOG_STR("BAD_HANDSHAKE_ERROR_BYTE"); } #endif + // PROTOCOL_SWITCH_TO_NOISE is intercepted in APIConnection::loop() before + // any logging can happen, so it intentionally has no entry here. return LOG_STR("UNKNOWN"); } diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index f98eca8076..9cae6ba92e 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -88,6 +88,11 @@ enum class APIError : uint16_t { HANDSHAKESTATE_SPLIT_FAILED = 1020, BAD_HANDSHAKE_ERROR_BYTE = 1021, #endif +#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT) + // Not an error: an unprovisioned device received a Noise client hello on a + // plaintext connection; the caller must hand the socket off to a Noise helper. + PROTOCOL_SWITCH_TO_NOISE = 1023, +#endif }; const LogString *api_error_to_logstr(APIError err); @@ -200,6 +205,12 @@ class APIFrameHelper { // or track that they stopped early and retry without this check. // See Socket::ready() for details. bool is_socket_ready() const { return socket_ != nullptr && socket_->ready(); } +#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT) + // Move the socket out of this helper so a replacement helper can take it + // over (plaintext to Noise handoff on unprovisioned devices). The drained + // helper must be destroyed right after. + std::unique_ptr release_socket_for_switch() { return std::move(this->socket_); } +#endif // Release excess memory from internal buffers after initial sync void release_buffers() { // rx_buf_: Safe to clear only if no partial read in progress. diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 6dba64a7f8..225bac51a6 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -109,6 +109,40 @@ APIError APINoiseFrameHelper::init() { state_ = State::CLIENT_HELLO; return APIError::OK; } +#ifdef USE_API_PLAINTEXT +APIError APINoiseFrameHelper::init_from_handoff(const uint8_t *header, uint8_t header_len) { + APIError err = this->init(); + if (err != APIError::OK) { + return err; + } + // Seed the header bytes the plaintext helper consumed before detecting the + // Noise indicator; try_read_frame_ resumes from rx_header_buf_len_. + std::memcpy(this->rx_header_buf_, header, header_len); + this->rx_header_buf_len_ = header_len; + // Pump the handshake without gating on socket_->ready(): on LWIP the + // plaintext helper's partial read can drain rcvevent while the rest of the + // client hello sits in the lastdata cache, so ready() may report false even + // though data is available. + return this->pump_handshake_(); +} +#endif // USE_API_PLAINTEXT + +/// Drive the handshake state machine until DATA, WOULD_BLOCK, or a fatal +/// error. WOULD_BLOCK is not an error: reads stop naturally on EWOULDBLOCK +/// and resume on the next loop(). +APIError APINoiseFrameHelper::pump_handshake_() { + while (this->state_ != State::DATA) { + APIError err = this->state_action_(); + if (err == APIError::WOULD_BLOCK) { + break; + } + if (err != APIError::OK) { + return err; + } + } + return APIError::OK; +} + // Helper for handling handshake frame errors APIError APINoiseFrameHelper::handle_handshake_frame_error_(APIError aerr) { if (aerr == APIError::BAD_INDICATOR) { @@ -131,16 +165,13 @@ APIError APINoiseFrameHelper::handle_noise_error_(int err, const LogString *func /// Run through handshake messages (if in that phase) APIError APINoiseFrameHelper::loop() { - // Cache ready() outside the loop. On ESP8266 LWIP raw TCP, ready() returns false once - // the rx buffer is consumed. Re-checking each iteration would block handshake writes - // that must follow reads, deadlocking the handshake. state_action() will return - // WOULD_BLOCK when no more data is available to read. - bool socket_ready = this->socket_->ready(); - while (state_ != State::DATA && socket_ready) { - APIError err = state_action_(); - if (err == APIError::WOULD_BLOCK) { - break; - } + // Check ready() once, not per state transition. On ESP8266 LWIP raw TCP, + // ready() returns false once the rx buffer is consumed. Re-checking each + // iteration would block handshake writes that must follow reads, + // deadlocking the handshake. pump_handshake_() stops on WOULD_BLOCK when + // no more data is available to read. + if (state_ != State::DATA && this->socket_->ready()) { + APIError err = this->pump_handshake_(); if (err != APIError::OK) { return err; } diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index 0676eab78d..b0ba9fd01c 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -22,12 +22,20 @@ class APINoiseFrameHelper final : public APIFrameHelper { } ~APINoiseFrameHelper() override; APIError init() override; +#ifdef USE_API_PLAINTEXT + // Take over a connection whose first bytes were consumed by a plaintext + // helper on an unprovisioned device (see APIError::PROTOCOL_SWITCH_TO_NOISE). + // Seeds the already-read header bytes and pumps the handshake state machine + // until it would block. + APIError init_from_handoff(const uint8_t *header, uint8_t header_len); +#endif APIError loop() override; APIError read_packet(ReadPacketBuffer *buffer) override; APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) override; protected: + APIError pump_handshake_(); APIError state_action_(); APIError state_action_client_hello_(); APIError state_action_server_hello_(); diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index fa611a6e33..9359f568fb 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -89,6 +89,17 @@ APIError APIPlaintextFrameHelper::try_read_frame_() { // If this was the first read, validate the indicator byte if (rx_header_buf_pos_ == 0 && received > 0) { if (rx_header_buf_[0] != 0x00) { +#ifdef USE_API_NOISE + // Dual build (encryption supported but no key set): a 0x01 first byte + // is a Noise client hello. Hand the connection off to a Noise helper + // running the all-zeros provisioning PSK so the encryption key can be + // set without crossing the wire in plaintext. Preserve the bytes we + // already consumed; they are the start of the Noise 3-byte header. + if (rx_header_buf_[0] == 0x01) { + rx_header_buf_pos_ = static_cast(received); + return APIError::PROTOCOL_SWITCH_TO_NOISE; + } +#endif state_ = State::FAILED; HELPER_LOG("Bad indicator byte %u", rx_header_buf_[0]); return APIError::BAD_INDICATOR; diff --git a/esphome/components/api/api_frame_helper_plaintext.h b/esphome/components/api/api_frame_helper_plaintext.h index 8314754715..ea3f6d7280 100644 --- a/esphome/components/api/api_frame_helper_plaintext.h +++ b/esphome/components/api/api_frame_helper_plaintext.h @@ -23,6 +23,15 @@ class APIPlaintextFrameHelper final : public APIFrameHelper { APIError read_packet(ReadPacketBuffer *buffer) override; APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) override; +#ifdef USE_API_NOISE + // After try_read_frame_ returned PROTOCOL_SWITCH_TO_NOISE: copy out the + // header bytes already consumed from the socket (at most 3, the size of the + // Noise fixed header) so the replacement Noise helper can be seeded with them. + uint8_t get_consumed_header(uint8_t out[3]) const { + memcpy(out, this->rx_header_buf_, this->rx_header_buf_pos_); + return this->rx_header_buf_pos_; + } +#endif protected: APIError try_read_frame_(); diff --git a/esphome/components/api/api_noise_context.h b/esphome/components/api/api_noise_context.h index b5f7016689..44484ffa2c 100644 --- a/esphome/components/api/api_noise_context.h +++ b/esphome/components/api/api_noise_context.h @@ -10,13 +10,20 @@ using psk_t = std::array; class APINoiseContext { public: + // The all-zeros PSK is reserved: it marks the device as unprovisioned and + // doubles as the well-known provisioning PSK that unprovisioned devices + // accept for Noise handshakes (passive-sniffing protection only, no + // authentication). It is never a valid real key. + static bool is_all_zeros(const psk_t &psk) { + uint8_t acc = 0; + for (uint8_t b : psk) { + acc |= b; + } + return acc == 0; + } void set_psk(psk_t psk) { this->psk_ = psk; - bool has_psk = false; - for (auto i : psk) { - has_psk |= i; - } - this->has_psk_ = has_psk; + this->has_psk_ = !is_all_zeros(psk); } const psk_t &get_psk() const { return this->psk_; } bool has_psk() const { return this->has_psk_; } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index de6ae4751e..190bd32425 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -170,6 +170,9 @@ uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_ for (const auto &it : this->serial_proxies) { ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 25, it); } +#endif +#ifdef USE_API_NOISE + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, this->api_encryption_provisionable); #endif return pos; } @@ -232,6 +235,9 @@ uint32_t DeviceInfoResponse::calculate_size() const { for (const auto &it : this->serial_proxies) { size += ProtoSize::calc_message_force(2, it.calculate_size()); } +#endif +#ifdef USE_API_NOISE + size += ProtoSize::calc_bool(2, this->api_encryption_provisionable); #endif return size; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index d268a40c56..4d5866da0b 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -533,7 +533,7 @@ class SerialProxyInfo final : public ProtoMessage { class DeviceInfoResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 10; - static constexpr uint16_t ESTIMATED_SIZE = 309; + static constexpr uint16_t ESTIMATED_SIZE = 312; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("device_info_response"); } #endif @@ -588,6 +588,9 @@ class DeviceInfoResponse final : public ProtoMessage { #endif #ifdef USE_SERIAL_PROXY std::array serial_proxies{}; +#endif +#ifdef USE_API_NOISE + bool api_encryption_provisionable{false}; #endif uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 3a1ceba95f..09570b09e4 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -982,6 +982,9 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const { it.dump_to(out); out.append("\n"); } +#endif +#ifdef USE_API_NOISE + dump_field(out, ESPHOME_PSTR("api_encryption_provisionable"), this->api_encryption_provisionable); #endif return out.c_str(); } diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index bb4271a6ca..fa39e86ed0 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -110,7 +110,13 @@ void MDNSComponent::compile_records_(StaticVectorget_noise_ctx().has_psk(); txt_count++; // api_encryption or api_encryption_supported +#ifndef USE_API_NOISE_PSK_FROM_YAML + if (!api_has_psk) { + txt_count++; // api_provisioning + } +#endif #endif #ifdef ESPHOME_PROJECT_NAME txt_count += 2; // project_name and project_version @@ -166,9 +172,18 @@ void MDNSComponent::compile_records_(StaticVectorget_noise_ctx().has_psk(); - const char *encryption_key = has_psk ? TXT_API_ENCRYPTION : TXT_API_ENCRYPTION_SUPPORTED; + const char *encryption_key = api_has_psk ? TXT_API_ENCRYPTION : TXT_API_ENCRYPTION_SUPPORTED; txt_records.push_back({MDNS_STR(encryption_key), MDNS_STR(NOISE_ENCRYPTION)}); +#ifndef USE_API_NOISE_PSK_FROM_YAML + if (!api_has_psk) { + // Unprovisioned device without a YAML key: advertise that the encryption + // key can be provisioned over a zero-PSK Noise connection. Gated on the + // YAML define so this survives the plaintext removal in 2027.2.0. + MDNS_STATIC_CONST_CHAR(TXT_API_PROVISIONING, "api_provisioning"); + MDNS_STATIC_CONST_CHAR(VALUE_ZERO_PSK, "zero-psk"); + txt_records.push_back({MDNS_STR(TXT_API_PROVISIONING), MDNS_STR(VALUE_ZERO_PSK)}); + } +#endif #endif #ifdef ESPHOME_PROJECT_NAME diff --git a/tests/components/api/test-dynamic-encryption.esp32-idf.yaml b/tests/components/api/test-dynamic-encryption.esp32-idf.yaml index 504871716b..7563e3e9df 100644 --- a/tests/components/api/test-dynamic-encryption.esp32-idf.yaml +++ b/tests/components/api/test-dynamic-encryption.esp32-idf.yaml @@ -1,5 +1,11 @@ -<<: !include common-base.yaml +packages: + common: !include common-base.yaml wifi: ssid: MySSID password: password1 + +# Encryption enabled without a key: compiles both frame helpers so the key +# can be provisioned at runtime (zero-PSK noise or deprecated plaintext) +api: + encryption: diff --git a/tests/integration/fixtures/api_zero_psk_provisioning.yaml b/tests/integration/fixtures/api_zero_psk_provisioning.yaml new file mode 100644 index 0000000000..1bb2a43e71 --- /dev/null +++ b/tests/integration/fixtures/api_zero_psk_provisioning.yaml @@ -0,0 +1,6 @@ +esphome: + name: zero-psk-provision-test +host: +api: + encryption: +logger: diff --git a/tests/integration/fixtures/api_zero_psk_provisioning_plaintext.yaml b/tests/integration/fixtures/api_zero_psk_provisioning_plaintext.yaml new file mode 100644 index 0000000000..a798c038d7 --- /dev/null +++ b/tests/integration/fixtures/api_zero_psk_provisioning_plaintext.yaml @@ -0,0 +1,6 @@ +esphome: + name: zero-psk-plaintext-test +host: +api: + encryption: +logger: diff --git a/tests/integration/test_api_zero_psk_provisioning.py b/tests/integration/test_api_zero_psk_provisioning.py new file mode 100644 index 0000000000..bcea2a2471 --- /dev/null +++ b/tests/integration/test_api_zero_psk_provisioning.py @@ -0,0 +1,127 @@ +"""Integration tests for provisioning the encryption key over a zero-PSK connection. + +A device with `api: encryption:` but no key accepts Noise handshakes using the +well-known all-zeros PSK. The ephemeral X25519 exchange protects the key from +passive sniffing while it is provisioned; plaintext provisioning still works +but is deprecated. +""" + +from __future__ import annotations + +import asyncio +import base64 + +from aioesphomeapi import InvalidEncryptionKeyAPIError, RequiresEncryptionAPIError +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +# The well-known provisioning PSK: base64 of 32 zero bytes +ZERO_PSK = base64.b64encode(bytes(32)).decode() +# A real key to provision +NEW_KEY = base64.b64encode(b"n" * 32) +# Time for the device to activate a newly saved key (100ms timer plus margin) +KEY_ACTIVATION_DELAY = 0.5 + + +@pytest.fixture(autouse=True) +def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + """Keep host preferences per-test so every run starts unprovisioned.""" + monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs")) + + +@pytest.mark.asyncio +async def test_api_zero_psk_provisioning( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Exercise the reject paths, then provision a key over the zero-PSK channel.""" + async with run_compiled(yaml_config): + # --- Pre-provisioning reject paths (device state is unchanged) --- + + # A wrong (non-zero) PSK fails against the zero provisioning PSK + with pytest.raises(InvalidEncryptionKeyAPIError): + async with api_client_connected( + noise_psk=base64.b64encode(b"w" * 32).decode(), timeout=5 + ) as client: + await client.device_info() + + # A plaintext client and a zero-PSK client can be connected at the + # same time while the device is unprovisioned + async with ( + api_client_connected() as plaintext_client, + api_client_connected(noise_psk=ZERO_PSK) as noise_client, + ): + plaintext_info = await plaintext_client.device_info() + noise_info = await noise_client.device_info() + # Both transports advertise provisioning support so old and new + # clients can decide how to provision + assert plaintext_info.api_encryption_provisionable is True + assert noise_info.api_encryption_provisionable is True + + # The all-zeros key is reserved as the provisioning PSK and is + # rejected on both transports + zero_key = base64.b64encode(bytes(32)) + assert await noise_client.noise_encryption_set_key(zero_key) is False + assert await plaintext_client.noise_encryption_set_key(zero_key) is False + + # --- Provision over the zero-PSK channel --- + + # The unprovisioned device accepts the all-zeros PSK; the handshake's + # ephemeral-ephemeral DH encrypts everything that follows + async with api_client_connected(noise_psk=ZERO_PSK) as client: + device_info = await client.device_info() + assert device_info.name == "zero-psk-provision-test" + assert device_info.api_encryption_supported is True + assert device_info.api_encryption_provisionable is True + + assert await client.noise_encryption_set_key(NEW_KEY) is True + + # The device activates the new key shortly after responding + await asyncio.sleep(KEY_ACTIVATION_DELAY) + + # The new key now works, and the device is no longer provisionable + async with api_client_connected(noise_psk=NEW_KEY.decode()) as client: + device_info = await client.device_info() + assert device_info.name == "zero-psk-provision-test" + assert device_info.api_encryption_provisionable is False + + # The zero PSK no longer works + with pytest.raises(InvalidEncryptionKeyAPIError): + async with api_client_connected(noise_psk=ZERO_PSK, timeout=5) as client: + await client.device_info() + + # Plaintext no longer works + with pytest.raises(RequiresEncryptionAPIError): + async with api_client_connected(timeout=5) as client: + await client.device_info() + + +@pytest.mark.asyncio +async def test_api_zero_psk_provisioning_plaintext( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """The legacy plaintext provisioning path still works and warns.""" + log_lines: list[str] = [] + async with run_compiled(yaml_config, line_callback=log_lines.append): + async with api_client_connected() as client: + device_info = await client.device_info() + assert device_info.name == "zero-psk-plaintext-test" + + assert await client.noise_encryption_set_key(NEW_KEY) is True + + await asyncio.sleep(KEY_ACTIVATION_DELAY) + + # The deprecation warning was logged + assert any("deprecated" in line for line in log_lines) + + # The new key works; the zero PSK does not + async with api_client_connected(noise_psk=NEW_KEY.decode()) as client: + assert (await client.device_info()).name == "zero-psk-plaintext-test" + + with pytest.raises(InvalidEncryptionKeyAPIError): + async with api_client_connected(noise_psk=ZERO_PSK, timeout=5) as client: + await client.device_info() From 434cffb74531e70d82fef911996471aa3bf59299 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Sun, 12 Jul 2026 15:58:17 -0500 Subject: [PATCH 0836/1815] [zwave_proxy] Fix parser gaps and harden frame and subscription handling (#17461) --- esphome/components/api/api_connection.cpp | 2 +- .../components/zwave_proxy/zwave_proxy.cpp | 125 ++++++++++++++---- esphome/components/zwave_proxy/zwave_proxy.h | 14 +- .../components/zwave_proxy/zwave_proxy.h | 2 +- 4 files changed, 109 insertions(+), 34 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 2efdf0bc03..880b7cc404 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1383,7 +1383,7 @@ void APIConnection::on_voice_assistant_set_configuration(const VoiceAssistantSet #ifdef USE_ZWAVE_PROXY void APIConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) { - zwave_proxy::global_zwave_proxy->send_frame(msg.data, msg.data_len); + zwave_proxy::global_zwave_proxy->send_frame(this, msg.data, msg.data_len); } void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) { diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index 8a24bd57d6..5f56861e6d 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -18,13 +18,22 @@ static const char *const TAG = "zwave_proxy"; static constexpr size_t ZWAVE_MAX_LOG_BYTES = 168; static constexpr uint8_t ZWAVE_COMMAND_GET_NETWORK_IDS = 0x20; -// GET_NETWORK_IDS response: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID][...] +// GET_NETWORK_IDS response: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID(1 or 2)][...] +// We only read the home ID, so the node ID (1 byte in 8-bit mode, 2 bytes in 16-bit mode) and +// anything after it are not required to be present static constexpr uint8_t ZWAVE_COMMAND_TYPE_RESPONSE = 0x01; // Response type field value -static constexpr uint8_t ZWAVE_MIN_GET_NETWORK_IDS_LENGTH = 9; // TYPE + CMD + HOME_ID(4) + NODE_ID + checksum +static constexpr uint8_t ZWAVE_MIN_GET_NETWORK_IDS_LENGTH = 7; // TYPE + CMD + HOME_ID(4) + checksum +static constexpr uint8_t ZWAVE_MIN_FRAME_LENGTH = 3; // TYPE + CMD + checksum (zero-payload frame) +static constexpr uint32_t ZWAVE_FRAME_TIMEOUT_MS = 1500; // Abandon a frame this long after its start (SOF) byte static constexpr uint32_t HOME_ID_TIMEOUT_MS = 100; // Timeout for waiting for home ID during setup static constexpr uint32_t RECONNECT_DELAY_MS = 500; // Delay between home ID query attempts after reconnect static constexpr uint8_t MAX_QUERY_RETRIES = 5; // Max attempts to query home ID after reconnect +static constexpr bool is_bootloader_menu_byte(uint8_t byte) { + // Bootloader menu output is printable ASCII plus CR/LF, ending with a NUL terminator + return byte == 0 || byte == '\r' || byte == '\n' || (byte >= 0x20 && byte <= 0x7E); +} + static uint8_t calculate_frame_checksum(const uint8_t *data, uint8_t length) { // Calculate Z-Wave frame checksum // XOR all bytes between SOF and checksum position (exclusive) @@ -74,6 +83,11 @@ bool ZWaveProxy::can_proceed() { const uint32_t now = App.get_loop_component_start_time(); if (now - this->setup_time_ > HOME_ID_TIMEOUT_MS) { ESP_LOGW(TAG, "Timeout reading Home ID during setup"); + // The modem may simply still be booting; keep querying from loop() using the same retry + // machinery as a reconnect. This adds no setup delay — clients are notified of the home ID + // via the HOME_ID_CHANGE message whenever it finally arrives. + this->reconnect_time_ = now; + this->query_retries_ = 0; return true; // Proceed anyway after timeout } @@ -98,7 +112,18 @@ void ZWaveProxy::loop() { } this->process_uart_(); - this->status_clear_warning(); + + // Abandon a stalled frame reception. The Z-Wave API specification requires a receiver to abort + // a data frame reception lasting more than 1500 ms after the SOF byte, without sending a NAK. + // Without this, the stale bytes would silently corrupt the next frame. Any SEND_* state was + // already resolved by response_handler_() above, so a state other than WAIT_START here always + // means we are mid-frame. + if (this->parsing_state_ != ZWAVE_PARSING_STATE_WAIT_START && + App.get_loop_component_start_time() - this->frame_start_time_ > ZWAVE_FRAME_TIMEOUT_MS) { + ESP_LOGW(TAG, "Timeout waiting for frame data; resetting parser"); + this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; + this->buffer_index_ = 0; + } } void ZWaveProxy::process_uart_slow_() { @@ -112,19 +137,24 @@ void ZWaveProxy::process_uart_slow_() { } if (this->parse_byte_(byte)) { // Check if this is a GET_NETWORK_IDS response frame - // Frame format: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID][...] + // Frame format: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID(1 or 2)][...] + // Bootloader output is excluded up front: a completed bootloader "frame" is menu text, so + // buffer_[1..3] would be meaningless (and possibly never written). Outside bootloader mode, + // the parser guarantees a completed frame starts with SOF, so buffer_[0] needs no check. // We verify: - // - buffer_[0]: Start of frame marker (0x01) - // - buffer_[1]: Length field must be >= 9 to contain all required data + // - buffer_[1]: Length field must be >= 7 so the frame contains the full home ID // - buffer_[2]: Command type (0x01 for response) // - buffer_[3]: Command ID (0x20 for GET_NETWORK_IDS) - if (this->buffer_[3] == ZWAVE_COMMAND_GET_NETWORK_IDS && this->buffer_[2] == ZWAVE_COMMAND_TYPE_RESPONSE && - this->buffer_[1] >= ZWAVE_MIN_GET_NETWORK_IDS_LENGTH && this->buffer_[0] == ZWAVE_FRAME_TYPE_START) { + if (!this->in_bootloader_ && this->buffer_[1] >= ZWAVE_MIN_GET_NETWORK_IDS_LENGTH && + this->buffer_[2] == ZWAVE_COMMAND_TYPE_RESPONSE && this->buffer_[3] == ZWAVE_COMMAND_GET_NETWORK_IDS) { // Store the 4-byte Home ID, which starts at offset 4, and notify connected clients if it changed // The frame parser has already validated the checksum and ensured all bytes are present if (this->set_home_id_(&this->buffer_[4])) { + char hex_buf[format_hex_pretty_size(ZWAVE_HOME_ID_SIZE)]; + ESP_LOGI(TAG, "Home ID: %s", format_hex_pretty_to(hex_buf, this->home_id_.data(), this->home_id_.size())); this->send_homeid_changed_msg_(); } + this->home_id_ready_ = true; } ESP_LOGV(TAG, "Sending to client: %s", YESNO(this->api_connection_ != nullptr)); if (this->api_connection_ != nullptr) { @@ -140,14 +170,19 @@ void ZWaveProxy::process_uart_slow_() { } } } while (this->available()); + // Reaching here means every read succeeded, so clear any earlier read-failure warning. + // (An early return on read failure skips this, leaving the warning visible until the + // next successful drain.) + this->status_clear_warning(); } void ZWaveProxy::dump_config() { char hex_buf[format_hex_pretty_size(ZWAVE_HOME_ID_SIZE)]; - ESP_LOGCONFIG(TAG, - "Z-Wave Proxy:\n" - " Home ID: %s", - format_hex_pretty_to(hex_buf, this->home_id_.data(), this->home_id_.size())); + ESP_LOGCONFIG( + TAG, + "Z-Wave Proxy:\n" + " Home ID: %s", + this->home_id_ready_ ? format_hex_pretty_to(hex_buf, this->home_id_.data(), this->home_id_.size()) : "unknown"); } void ZWaveProxy::api_connection_authenticated(api::APIConnection *conn) { @@ -160,10 +195,20 @@ void ZWaveProxy::api_connection_authenticated(api::APIConnection *conn) { void ZWaveProxy::zwave_proxy_request(api::APIConnection *api_connection, api::enums::ZWaveProxyRequestType type) { switch (type) { case api::enums::ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE: - if (this->api_connection_ != nullptr) { - ESP_LOGE(TAG, "Only one API subscription is allowed at a time"); + if (this->api_connection_ == api_connection) { + ESP_LOGV(TAG, "API connection is already subscribed"); return; } + if (this->api_connection_ != nullptr) { + // A living subscriber keeps exclusive access. Its connection may be dead without + // loop() having noticed yet (e.g. the client crashed and reconnected quickly); + // in that case let the new client take over instead of locking it out. + if (this->api_connection_->is_connection_setup()) { + ESP_LOGE(TAG, "Only one API subscription is allowed at a time"); + return; + } + ESP_LOGW(TAG, "Previous subscriber disconnected; taking over subscription"); + } this->api_connection_ = api_connection; ESP_LOGV(TAG, "API connection is now subscribed"); break; @@ -222,6 +267,7 @@ void ZWaveProxy::retry_home_id_query_() { void ZWaveProxy::clear_home_id_() { static constexpr uint8_t ZERO_HOME_ID[ZWAVE_HOME_ID_SIZE] = {}; if (this->set_home_id_(ZERO_HOME_ID)) { + ESP_LOGV(TAG, "Home ID cleared"); this->send_homeid_changed_msg_(); } this->home_id_ready_ = false; @@ -237,13 +283,20 @@ bool ZWaveProxy::set_home_id_(const uint8_t *new_home_id) { return false; // No change } std::memcpy(this->home_id_.data(), new_home_id, this->home_id_.size()); - char hex_buf[format_hex_pretty_size(ZWAVE_HOME_ID_SIZE)]; - ESP_LOGI(TAG, "Home ID: %s", format_hex_pretty_to(hex_buf, this->home_id_.data(), this->home_id_.size())); - this->home_id_ready_ = true; return true; // Home ID was changed } -void ZWaveProxy::send_frame(const uint8_t *data, size_t length) { +void ZWaveProxy::send_frame(api::APIConnection *api_connection, const uint8_t *data, size_t length) { + // Only the subscribed client may talk to the Z-Wave module; a frame from any other + // (authenticated but unsubscribed) client would interleave with the subscriber's traffic + if (api_connection != this->api_connection_) { + ESP_LOGW(TAG, "Ignoring frame from unsubscribed client"); + return; + } + this->send_frame_(data, length); +} + +void ZWaveProxy::send_frame_(const uint8_t *data, size_t length) { // Safety: validate pointer before any access if (data == nullptr) { ESP_LOGE(TAG, "Null data pointer"); @@ -289,7 +342,7 @@ void ZWaveProxy::send_simple_command_(const uint8_t command_id) { // Where LENGTH=0x03 (3 bytes: TYPE + CMD + CHECKSUM) uint8_t cmd[] = {0x01, 0x03, 0x00, command_id, 0x00}; cmd[4] = calculate_frame_checksum(cmd, sizeof(cmd)); - this->send_frame(cmd, sizeof(cmd)); + this->send_frame_(cmd, sizeof(cmd)); } bool ZWaveProxy::parse_byte_(uint8_t byte) { @@ -300,9 +353,12 @@ bool ZWaveProxy::parse_byte_(uint8_t byte) { this->parse_start_(byte); break; case ZWAVE_PARSING_STATE_WAIT_LENGTH: - if (!byte) { + if (byte < ZWAVE_MIN_FRAME_LENGTH) { ESP_LOGW(TAG, "Invalid LENGTH: %u", byte); this->parsing_state_ = ZWAVE_PARSING_STATE_SEND_NAK; + // Send the NAK now; otherwise any bytes already buffered behind this one would be + // silently discarded by the SEND_NAK case below until the next loop() iteration + this->response_handler_(); return false; } ESP_LOGVV(TAG, "Received LENGTH: %u", byte); @@ -319,7 +375,9 @@ bool ZWaveProxy::parse_byte_(uint8_t byte) { case ZWAVE_PARSING_STATE_WAIT_COMMAND_ID: this->buffer_[this->buffer_index_++] = byte; ESP_LOGVV(TAG, "Received COMMAND ID: 0x%02X", byte); - this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_PAYLOAD; + // A zero-payload frame (LENGTH == 3) has its checksum immediately after the command ID + this->parsing_state_ = this->buffer_index_ >= this->end_frame_after_ ? ZWAVE_PARSING_STATE_WAIT_CHECKSUM + : ZWAVE_PARSING_STATE_WAIT_PAYLOAD; break; case ZWAVE_PARSING_STATE_WAIT_PAYLOAD: this->buffer_[this->buffer_index_++] = byte; @@ -347,12 +405,24 @@ bool ZWaveProxy::parse_byte_(uint8_t byte) { break; } case ZWAVE_PARSING_STATE_READ_BL_MENU: - if (this->buffer_index_ >= this->buffer_.size()) { + // This state is tentative (see parse_start_): bootloader mode is committed only when a + // plausible menu — printable text ending in a NUL terminator — completes. A byte that + // cannot be menu text means the 0x0D that started this state was not a menu after all, + // so re-parse that byte as a frame start; it may be the SOF/ACK/NAK of real traffic. + if (this->buffer_index_ >= this->buffer_.size() || !is_bootloader_menu_byte(byte)) { this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; + this->parse_start_(byte); break; } this->buffer_[this->buffer_index_++] = byte; if (!byte) { + if (!this->in_bootloader_) { + ESP_LOGD(TAG, "Entered bootloader mode"); + this->in_bootloader_ = true; + // Reset response deduplication: in bootloader mode, single-byte client writes (XMODEM + // ACK/NAK/CAN) are raw data and must never be suppressed as duplicate responses + this->last_response_ = 0; + } this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; frame_completed = true; } @@ -378,15 +448,16 @@ void ZWaveProxy::parse_start_(uint8_t byte) { ESP_LOGD(TAG, "Exited bootloader mode"); this->in_bootloader_ = false; } + this->frame_start_time_ = App.get_loop_component_start_time(); this->buffer_[this->buffer_index_++] = byte; this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_LENGTH; return; case ZWAVE_FRAME_TYPE_BL_MENU: ESP_LOGV(TAG, "Received BL_MENU"); - if (!this->in_bootloader_) { - ESP_LOGD(TAG, "Entered bootloader mode"); - this->in_bootloader_ = true; - } + // Read the menu tentatively: a stray 0x0D can equally appear in garbled data after the + // parser loses frame alignment, so bootloader mode is only committed once a plausible + // menu completes (see READ_BL_MENU handling in parse_byte_) + this->frame_start_time_ = App.get_loop_component_start_time(); this->buffer_[this->buffer_index_++] = byte; this->parsing_state_ = ZWAVE_PARSING_STATE_READ_BL_MENU; return; @@ -403,7 +474,7 @@ void ZWaveProxy::parse_start_(uint8_t byte) { ESP_LOGV(TAG, "Received CAN"); break; default: - ESP_LOGW(TAG, "Unrecognized START: 0x%02X", byte); + ESP_LOGV(TAG, "Unrecognized START: 0x%02X", byte); return; } // Forward response (ACK/NAK/CAN) back to client for processing diff --git a/esphome/components/zwave_proxy/zwave_proxy.h b/esphome/components/zwave_proxy/zwave_proxy.h index ec52b15cd9..cb60139ef8 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.h +++ b/esphome/components/zwave_proxy/zwave_proxy.h @@ -68,13 +68,16 @@ class ZWaveProxy final : public uart::UARTDevice, public Component { return encode_uint32(this->home_id_[0], this->home_id_[1], this->home_id_[2], this->home_id_[3]); } - void send_frame(const uint8_t *data, size_t length); + // Send a frame from an API client to the Z-Wave module. Frames from any connection other + // than the currently subscribed one are ignored. + void send_frame(api::APIConnection *api_connection, const uint8_t *data, size_t length); protected: - bool set_home_id_(const uint8_t *new_home_id); // Store a new home ID. Returns true if it changed. - void clear_home_id_(); // Clear home ID and notify API clients - void on_connection_changed_(bool connected); // Handle modem connect/disconnect transitions - void retry_home_id_query_(); // Retry home ID query after reconnect + void send_frame_(const uint8_t *data, size_t length); // Write a frame to the Z-Wave module + bool set_home_id_(const uint8_t *new_home_id); // Store a new home ID. Returns true if it changed. + void clear_home_id_(); // Clear home ID and notify API clients + void on_connection_changed_(bool connected); // Handle modem connect/disconnect transitions + void retry_home_id_query_(); // Retry home ID query after reconnect void send_homeid_changed_msg_(api::APIConnection *conn = nullptr); void send_simple_command_(uint8_t command_id); bool parse_byte_(uint8_t byte); // Returns true if frame parsing was completed (a frame is ready in the buffer) @@ -114,6 +117,7 @@ class ZWaveProxy final : public uart::UARTDevice, public Component { api::APIConnection *api_connection_{nullptr}; // Current subscribed client uint32_t setup_time_{0}; // Time when setup() was called uint32_t reconnect_time_{0}; // Timestamp of reconnect detection (0 = no pending query) + uint32_t frame_start_time_{0}; // Timestamp of the current frame's start byte (reception timeout) // Small values (grouped by size to minimize padding) uint16_t buffer_index_{0}; // Index for populating the data buffer diff --git a/tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h b/tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h index ba97e81236..b4ccd8fd00 100644 --- a/tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h +++ b/tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h @@ -16,7 +16,7 @@ class ZWaveProxy { public: api::APIConnection *get_api_connection() { return nullptr; } void zwave_proxy_request(api::APIConnection *conn, api::enums::ZWaveProxyRequestType type) {} - void send_frame(const uint8_t *data, size_t length) {} + void send_frame(api::APIConnection *api_connection, const uint8_t *data, size_t length) {} void api_connection_authenticated(api::APIConnection *conn) {} uint32_t get_feature_flags() const { return 0; } uint32_t get_home_id() { return 0; } From 196b979df87a707d99da1ddbd94b93533adcb37f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:04:42 -0400 Subject: [PATCH 0837/1815] [usb_uart] Fix output chunk length truncated to 8 bits (#17480) --- esphome/components/usb_uart/usb_uart.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 482b209a3f..c289625f1a 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -160,7 +160,7 @@ void USBUartChannel::write_array(const uint8_t *data, size_t len) { } uint16_t chunk_len = std::min(len, UsbOutputChunk::MAX_CHUNK_SIZE); memcpy(chunk->data, data, chunk_len); - chunk->length = static_cast(chunk_len); + chunk->length = chunk_len; // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if // allocate() returned non-null, the queue cannot be full. this->output_queue_.push(chunk); From 020a6a8fd111e92a068b05b70f3addee3f5d1fa8 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:27:05 -0400 Subject: [PATCH 0838/1815] [mcp4461] Fix wiper increment/decrement write length (#17487) --- esphome/components/mcp4461/mcp4461.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/mcp4461/mcp4461.cpp b/esphome/components/mcp4461/mcp4461.cpp index 4573553664..e83a6847d6 100644 --- a/esphome/components/mcp4461/mcp4461.cpp +++ b/esphome/components/mcp4461/mcp4461.cpp @@ -342,7 +342,7 @@ bool Mcp4461Component::increase_wiper_(Mcp4461WiperIdx wiper) { ESP_LOGV(TAG, "Increasing wiper %u", wiper_idx); uint8_t addr = this->get_wiper_address_(wiper_idx); uint8_t reg = addr | static_cast(Mcp4461Commands::INCREMENT); - auto err = this->write(&this->address_, reg); + auto err = this->write(®, 1); if (err != i2c::ERROR_OK) { this->error_code_ = MCP4461_STATUS_I2C_ERROR; this->status_set_warning(); @@ -373,7 +373,7 @@ bool Mcp4461Component::decrease_wiper_(Mcp4461WiperIdx wiper) { ESP_LOGV(TAG, "Decreasing wiper %u", wiper_idx); uint8_t addr = this->get_wiper_address_(wiper_idx); uint8_t reg = addr | static_cast(Mcp4461Commands::DECREMENT); - auto err = this->write(&this->address_, reg); + auto err = this->write(®, 1); if (err != i2c::ERROR_OK) { this->error_code_ = MCP4461_STATUS_I2C_ERROR; this->status_set_warning(); From 284fe85271db003701c3e3899a4cb851fd667c83 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:53:42 +0000 Subject: [PATCH 0839/1815] Bump aioesphomeapi from 45.5.2 to 45.6.0 (#17490) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8b028554a8..b36e70ef5d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.5.2 +aioesphomeapi==45.6.0 zeroconf==0.150.0 puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import From 8518d0633b5acc32e4a4c2b0c045c4ded0ba6de0 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:51:04 -0400 Subject: [PATCH 0840/1815] [web_server] Serialize entity state strings without a copy buffer (#17488) --- esphome/components/web_server/web_server.cpp | 52 +++++++++----------- 1 file changed, 22 insertions(+), 30 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index c8f66755bc..3f4d598d48 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -56,9 +56,8 @@ namespace esphome::web_server { static const char *const TAG = "web_server"; -// Longest: UPDATE AVAILABLE (16 chars + null terminator, rounded up) -static constexpr size_t PSTR_LOCAL_SIZE = 18; -#define PSTR_LOCAL(mode_s) ESPHOME_strncpy_P(buf, (ESPHOME_PGM_P) ((mode_s)), PSTR_LOCAL_SIZE - 1) +// View a state LogString as a ProgmemStr so ArduinoJson serializes it PROGMEM-aware on ESP8266. +static ProgmemStr json_state_str(const LogString *s) { return reinterpret_cast(s); } // Parse URL and return match info // URL formats (disambiguated by HTTP method for 3-segment case): @@ -578,9 +577,9 @@ static void set_json_value(JsonObject &root, EntityBase *obj, const char *prefix root[ESPHOME_F("value")] = value; } -template -static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const char *prefix, const char *state, - const T &value, JsonDetail start_config) { +template +static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const char *prefix, S state, const T &value, + JsonDetail start_config) { set_json_value(root, obj, prefix, value, start_config); root[ESPHOME_F("state")] = state; } @@ -1073,8 +1072,7 @@ json::SerializationBuffer<> WebServer::cover_json_(cover::Cover *obj, JsonDetail set_json_icon_state_value(root, obj, "cover", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, start_config); - char buf[PSTR_LOCAL_SIZE]; - root[ESPHOME_F("current_operation")] = PSTR_LOCAL(cover::cover_operation_to_str(obj->current_operation)); + root[ESPHOME_F("current_operation")] = json_state_str(cover::cover_operation_to_str(obj->current_operation)); if (obj->get_traits().get_supports_position()) root[ESPHOME_F("position")] = obj->position; @@ -1530,17 +1528,16 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json const auto traits = obj->get_traits(); int8_t target_accuracy = traits.get_target_temperature_accuracy_decimals(); int8_t current_accuracy = traits.get_current_temperature_accuracy_decimals(); - char buf[PSTR_LOCAL_SIZE]; char temp_buf[VALUE_ACCURACY_MAX_LEN]; if (start_config == DETAIL_ALL) { JsonArray opt = root[ESPHOME_F("modes")].to(); for (climate::ClimateMode m : traits.get_supported_modes()) - opt.add(PSTR_LOCAL(climate::climate_mode_to_string(m))); + opt.add(json_state_str(climate::climate_mode_to_string(m))); if (traits.get_supports_fan_modes()) { JsonArray opt = root[ESPHOME_F("fan_modes")].to(); for (climate::ClimateFanMode m : traits.get_supported_fan_modes()) - opt.add(PSTR_LOCAL(climate::climate_fan_mode_to_string(m))); + opt.add(json_state_str(climate::climate_fan_mode_to_string(m))); } if (!traits.get_supported_custom_fan_modes().empty()) { @@ -1551,12 +1548,12 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json if (traits.get_supports_swing_modes()) { JsonArray opt = root[ESPHOME_F("swing_modes")].to(); for (auto swing_mode : traits.get_supported_swing_modes()) - opt.add(PSTR_LOCAL(climate::climate_swing_mode_to_string(swing_mode))); + opt.add(json_state_str(climate::climate_swing_mode_to_string(swing_mode))); } if (traits.get_supports_presets()) { JsonArray opt = root[ESPHOME_F("presets")].to(); for (climate::ClimatePreset m : traits.get_supported_presets()) - opt.add(PSTR_LOCAL(climate::climate_preset_to_string(m))); + opt.add(json_state_str(climate::climate_preset_to_string(m))); } if (!traits.get_supported_custom_presets().empty()) { JsonArray opt = root[ESPHOME_F("custom_presets")].to(); @@ -1572,26 +1569,26 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json } bool has_state = false; - root[ESPHOME_F("mode")] = PSTR_LOCAL(climate_mode_to_string(obj->mode)); + root[ESPHOME_F("mode")] = json_state_str(climate_mode_to_string(obj->mode)); if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION)) { - root[ESPHOME_F("action")] = PSTR_LOCAL(climate_action_to_string(obj->action)); + root[ESPHOME_F("action")] = json_state_str(climate_action_to_string(obj->action)); root[ESPHOME_F("state")] = root[ESPHOME_F("action")]; has_state = true; } if (traits.get_supports_fan_modes() && obj->fan_mode.has_value()) { - root[ESPHOME_F("fan_mode")] = PSTR_LOCAL(climate_fan_mode_to_string(obj->fan_mode.value())); + root[ESPHOME_F("fan_mode")] = json_state_str(climate_fan_mode_to_string(obj->fan_mode.value())); } if (!traits.get_supported_custom_fan_modes().empty() && obj->has_custom_fan_mode()) { root[ESPHOME_F("custom_fan_mode")] = obj->get_custom_fan_mode(); } if (traits.get_supports_presets() && obj->preset.has_value()) { - root[ESPHOME_F("preset")] = PSTR_LOCAL(climate_preset_to_string(obj->preset.value())); + root[ESPHOME_F("preset")] = json_state_str(climate_preset_to_string(obj->preset.value())); } if (!traits.get_supported_custom_presets().empty() && obj->has_custom_preset()) { root[ESPHOME_F("custom_preset")] = obj->get_custom_preset(); } if (traits.get_supports_swing_modes()) { - root[ESPHOME_F("swing_mode")] = PSTR_LOCAL(climate_swing_mode_to_string(obj->swing_mode)); + root[ESPHOME_F("swing_mode")] = json_state_str(climate_swing_mode_to_string(obj->swing_mode)); } if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE)) { root[ESPHOME_F("current_temperature")] = @@ -1695,8 +1692,7 @@ json::SerializationBuffer<> WebServer::lock_json_(lock::Lock *obj, lock::LockSta json::JsonBuilder builder; JsonObject root = builder.root(); - char buf[PSTR_LOCAL_SIZE]; - set_json_icon_state_value(root, obj, "lock", PSTR_LOCAL(lock::lock_state_to_string(value)), value, start_config); + set_json_icon_state_value(root, obj, "lock", json_state_str(lock::lock_state_to_string(value)), value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1777,8 +1773,7 @@ json::SerializationBuffer<> WebServer::valve_json_(valve::Valve *obj, JsonDetail set_json_icon_state_value(root, obj, "valve", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, start_config); - char buf[PSTR_LOCAL_SIZE]; - root[ESPHOME_F("current_operation")] = PSTR_LOCAL(valve::valve_operation_to_str(obj->current_operation)); + root[ESPHOME_F("current_operation")] = json_state_str(valve::valve_operation_to_str(obj->current_operation)); if (obj->get_traits().get_supports_position()) root[ESPHOME_F("position")] = obj->position; @@ -1863,9 +1858,8 @@ json::SerializationBuffer<> WebServer::alarm_control_panel_json_(alarm_control_p json::JsonBuilder builder; JsonObject root = builder.root(); - char buf[PSTR_LOCAL_SIZE]; - set_json_icon_state_value(root, obj, "alarm-control-panel", PSTR_LOCAL(alarm_control_panel_state_to_string(value)), - value, start_config); + set_json_icon_state_value(root, obj, "alarm-control-panel", + json_state_str(alarm_control_panel_state_to_string(value)), value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1937,10 +1931,9 @@ json::SerializationBuffer<> WebServer::water_heater_all_json_generator(WebServer json::SerializationBuffer<> WebServer::water_heater_json_(water_heater::WaterHeater *obj, JsonDetail start_config) { json::JsonBuilder builder; JsonObject root = builder.root(); - char buf[PSTR_LOCAL_SIZE]; const auto mode = obj->get_mode(); - const char *mode_s = PSTR_LOCAL(water_heater::water_heater_mode_to_string(mode)); + ProgmemStr mode_s = json_state_str(water_heater::water_heater_mode_to_string(mode)); set_json_icon_state_value(root, obj, "water_heater", mode_s, mode, start_config); @@ -1949,7 +1942,7 @@ json::SerializationBuffer<> WebServer::water_heater_json_(water_heater::WaterHea if (start_config == DETAIL_ALL) { JsonArray modes = root[ESPHOME_F("modes")].to(); for (auto m : traits.get_supported_modes()) - modes.add(PSTR_LOCAL(water_heater::water_heater_mode_to_string(m))); + modes.add(json_state_str(water_heater::water_heater_mode_to_string(m))); root[ESPHOME_F("min_temp")] = traits.get_min_temperature(); root[ESPHOME_F("max_temp")] = traits.get_max_temperature(); root[ESPHOME_F("step")] = traits.get_target_temperature_step(); @@ -2277,8 +2270,7 @@ json::SerializationBuffer<> WebServer::update_json_(update::UpdateEntity *obj, J json::JsonBuilder builder; JsonObject root = builder.root(); - char buf[PSTR_LOCAL_SIZE]; - set_json_icon_state_value(root, obj, "update", PSTR_LOCAL(update::update_state_to_string(obj->state)), + set_json_icon_state_value(root, obj, "update", json_state_str(update::update_state_to_string(obj->state)), obj->update_info.latest_version, start_config); if (start_config == DETAIL_ALL) { root[ESPHOME_F("current_version")] = obj->update_info.current_version; From 050a0064592b70ca7e1ed647d3b5375b5d4d3a2f Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:36:42 -1000 Subject: [PATCH 0841/1815] Bump bundled esphome-device-builder to 1.4.0 (#17495) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index db2e01742c..e0b44fb7b6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.3.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.4.0 RUN \ platformio settings set enable_telemetry No \ From 262ee421f6c42ce6a03e30cd30210e122f6a32e3 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:30:19 -1000 Subject: [PATCH 0842/1815] Bump bundled esphome-device-builder to 1.4.1 (#17507) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index e0b44fb7b6..e7f8fceb12 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.4.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.4.1 RUN \ platformio settings set enable_telemetry No \ From f0afd9e660c940dc48c9732d6789a10b545e8b30 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:06:40 +1000 Subject: [PATCH 0843/1815] [mipi][mipi_spi][mipi_dsi][mipi_rgb] Transform cleanup (#17405) Co-authored-by: Claude Opus 4.8 --- esphome/components/mipi/__init__.py | 73 ++++++++-- esphome/components/mipi_dsi/display.py | 22 +-- .../components/mipi_dsi/models/__init__.py | 14 ++ esphome/components/mipi_dsi/models/guition.py | 12 +- esphome/components/mipi_dsi/models/m5stack.py | 9 +- esphome/components/mipi_dsi/models/seeed.py | 7 +- .../components/mipi_dsi/models/waveshare.py | 22 +-- esphome/components/mipi_rgb/display.py | 31 ++-- .../components/mipi_rgb/models/__init__.py | 14 ++ esphome/components/mipi_rgb/models/guition.py | 1 + esphome/components/mipi_rgb/models/lilygo.py | 6 +- esphome/components/mipi_rgb/models/rpi.py | 6 +- esphome/components/mipi_rgb/models/st7701s.py | 18 +-- esphome/components/mipi_rgb/models/sunton.py | 8 +- .../components/mipi_rgb/models/waveshare.py | 10 +- esphome/components/mipi_spi/display.py | 15 +- .../components/mipi_spi/models/adafruit.py | 2 + esphome/components/mipi_spi/models/amoled.py | 7 +- esphome/components/mipi_spi/models/ili.py | 3 + esphome/components/mipi_spi/models/jc.py | 14 +- esphome/components/mipi_spi/models/lanbon.py | 1 + esphome/components/mipi_spi/models/lilygo.py | 3 + esphome/components/mipi_spi/models/m5stack.py | 2 + .../components/mipi_spi/models/waveshare.py | 9 +- esphome/core/__init__.py | 4 + .../config/animation_platform_test.yaml | 3 + .../animation/config/animation_test.yaml | 3 + .../image/config/image_test.yaml | 3 + .../mipi_dsi/test_mipi_dsi_config.py | 39 +++++ tests/component_tests/mipi_rgb/__init__.py | 0 tests/component_tests/mipi_rgb/test_init.py | 89 ++++++++++++ .../mipi_rgb/test_mipi_rgb_config.py | 137 ++++++++++++++++++ .../mipi_spi/test_display_metadata.py | 84 ++++++++++- .../mipi_spi/test_final_validate.py | 77 ++++++++++ tests/component_tests/mipi_spi/test_init.py | 2 +- .../mipi_spi/test_padding_and_offsets.py | 4 + .../config/online_image_platform_test.yaml | 3 + .../config/online_image_test.yaml | 3 + 38 files changed, 630 insertions(+), 130 deletions(-) create mode 100644 esphome/components/mipi_rgb/models/__init__.py create mode 100644 tests/component_tests/mipi_rgb/__init__.py create mode 100644 tests/component_tests/mipi_rgb/test_init.py create mode 100644 tests/component_tests/mipi_rgb/test_mipi_rgb_config.py diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 1d6c8277e8..ab59d5ce5f 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -31,11 +31,16 @@ from esphome.const import ( CONF_TRANSFORM, CONF_WIDTH, ) -from esphome.core import TimePeriod +from esphome.core import CORE, TimePeriod from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor LOGGER = cv.logging.getLogger(__name__) +CONF_TRANSFORMS = "transforms" + +# All axis transforms a model may support, in the order they appear in the schema. +ALL_TRANSFORMS = (CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY) + ColorOrder = display_ns.enum("ColorMode") NOP = 0x00 @@ -302,7 +307,8 @@ class DriverChip: """ A class representing a MIPI DBI driver chip model. The parameters supplied as defaults will be used to provide default values for the display configuration. - Setting swap_xy to cv.UNDEFINED will indicate that the model does not support swapping X and Y axes. + Pass a ``transforms`` set to restrict which axis transforms (mirror_x, mirror_y, swap_xy) the model + supports; by default all three are available. """ models: dict[str, Self] = {} @@ -387,11 +393,15 @@ class DriverChip: """ Return the available transforms for this model. """ + if (transforms := self.get_default(CONF_TRANSFORMS, None)) is not None: + return transforms if self.get_default("no_transform", False): return set() if self.get_default(CONF_SWAP_XY) != cv.UNDEFINED: return {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY} - return {CONF_MIRROR_X, CONF_MIRROR_Y} + raise ValueError( + "Setting 'swap_xy' to 'cv.UNDEFINED' is no longer supported; set 'transforms' instead" + ) def has_hardware_transform(self, config) -> bool: """ @@ -533,17 +543,31 @@ class DriverChip: transform[CONF_TRANSFORM] = self.rotation_as_transform(config) return transform - def swap_xy_schema(self): - uses_swap = self.get_default(CONF_SWAP_XY, None) != cv.UNDEFINED + def transform_schema(self): + """ + Build the schema for the ``transform`` config option of this model. - def validator(value): - if value: - raise cv.Invalid("Axis swapping not supported by this model") - return cv.boolean(value) + Each transform the model supports is a required boolean. A transform the model does not + support may be omitted or set to ``false``; setting it to ``true`` reports a clear error + naming the unsupported transform instead of a generic "extra keys not allowed". + """ + supported = self.transforms - if uses_swap: - return {cv.Required(CONF_SWAP_XY): cv.boolean} - return {cv.Optional(CONF_SWAP_XY, default=False): validator} + def unsupported(name): + def validator(value): + if cv.boolean(value): + raise cv.Invalid(f"'{name}' is not supported by this model") + return False + + return validator + + schema = {} + for name in ALL_TRANSFORMS: + if name in supported: + schema[cv.Required(name)] = cv.boolean + else: + schema[cv.Optional(name, default=False)] = unsupported(name) + return cv.Any(cv.Schema(schema), cv.one_of(CONF_DISABLED, lower=True)) def get_madctl(self, transform: dict, config: dict) -> int: """ @@ -618,6 +642,31 @@ class DriverChip: # or the delay flag inserted where needed return flatten_sequence(sequence) + def check_requirements(self) -> None: + """ + Raise a friendly error if any component this model requires is not configured. + + This runs during schema validation (before ID references are resolved) so that a + model whose default pins live on a pin expander reports the missing expander clearly + instead of a cryptic "Couldn't find ID" from the unresolved pin reference. + """ + requirements = self.get_default("requires", set()) + if not requirements: + return + # ``raw_config`` is populated before any component schema runs during a real + # validation, so presence of a required component is simply a top-level key. + # When it is absent (e.g. a unit test that invokes the schema directly) there + # is no config to check against, so skip. + global_config = CORE.raw_config + if global_config is None: + return + missing = {x for x in requirements if x not in global_config} + if missing: + reqstr = ", ".join(f"'{x}'" for x in sorted(missing)) + raise cv.Invalid( + f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured" + ) + def requires_buffer(config) -> bool: """ diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 896140b4b1..e5bb3d413d 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -41,24 +41,21 @@ from esphome.const import ( CONF_AUTO_CLEAR_ENABLED, CONF_COLOR_ORDER, CONF_DIMENSIONS, - CONF_DISABLED, CONF_ENABLE_PIN, CONF_ID, CONF_INIT_SEQUENCE, CONF_INVERT_COLORS, CONF_LAMBDA, - CONF_MIRROR_X, - CONF_MIRROR_Y, CONF_MODEL, CONF_RESET_PIN, CONF_ROTATION, - CONF_SWAP_XY, CONF_TRANSFORM, CONF_WIDTH, ) from esphome.final_validate import full_config from . import mipi_dsi_ns, models +from .models import DsiDriverChip # Currently only ESP32-P4 is supported, so esp_ldo and psram are required DEPENDENCIES = ["esp32", "esp_ldo", "psram"] @@ -73,7 +70,7 @@ ColorBitness = display.display_ns.enum("ColorBitness") CONF_LANE_BIT_RATE = "lane_bit_rate" CONF_LANES = "lanes" -DriverChip("CUSTOM") +DsiDriverChip("CUSTOM") # Import all models dynamically from the models package @@ -90,19 +87,7 @@ COLOR_DEPTHS = { def model_schema(config): model = MODELS[config[CONF_MODEL].upper()] - model.defaults[CONF_SWAP_XY] = cv.UNDEFINED - transform = cv.Any( - cv.Schema( - { - cv.Required(CONF_MIRROR_X): cv.boolean, - cv.Required(CONF_MIRROR_Y): cv.boolean, - cv.Optional(CONF_SWAP_XY): cv.invalid( - "Axis swapping not supported by DSI displays" - ), - } - ), - cv.one_of(CONF_DISABLED, lower=True), - ) + transform = model.transform_schema() # CUSTOM model will need to provide a custom init sequence iseqconf = ( cv.Required(CONF_INIT_SEQUENCE) @@ -172,6 +157,7 @@ def _config_schema(config): )(config) config = model_schema(config)(config) model = MODELS[config[CONF_MODEL].upper()] + model.check_requirements() width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( model.get_dimensions(config) ) diff --git a/esphome/components/mipi_dsi/models/__init__.py b/esphome/components/mipi_dsi/models/__init__.py index e69de29bb2..3f7f8370a3 100644 --- a/esphome/components/mipi_dsi/models/__init__.py +++ b/esphome/components/mipi_dsi/models/__init__.py @@ -0,0 +1,14 @@ +from esphome.components.mipi import DriverChip +from esphome.const import CONF_SWAP_XY + + +class DsiDriverChip(DriverChip): + """A driver chip for MIPI DSI displays.""" + + @property + def transforms(self) -> set[str]: + """ + Return the set of transformations supported by this driver chip. + DSI displays do not support axis swapping, so this method removes CONF_SWAP_XY + """ + return super().transforms - {CONF_SWAP_XY} diff --git a/esphome/components/mipi_dsi/models/guition.py b/esphome/components/mipi_dsi/models/guition.py index db13c7f6cc..31a2b0ce1a 100644 --- a/esphome/components/mipi_dsi/models/guition.py +++ b/esphome/components/mipi_dsi/models/guition.py @@ -1,8 +1,7 @@ -from esphome.components.mipi import DriverChip -import esphome.config_validation as cv +from . import DsiDriverChip # fmt: off -DriverChip( +DsiDriverChip( "JC1060P470", width=1024, height=600, @@ -14,7 +13,6 @@ DriverChip( vsync_front_porch=12, pclk_frequency="54MHz", lane_bit_rate="750Mbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0x30, 0x00), (0xF7, 0x49, 0x61, 0x02, 0x00), (0x30, 0x01), (0x04, 0x0C), (0x05, 0x00), (0x06, 0x00), @@ -46,7 +44,7 @@ DriverChip( # * Horizontal Timing (hsync_pulse_width=12, hsync_back_porch=42, hsync_front_porch=42) # * Vertical Timing (vsync_pulse_width=2, vsync_back_porch=8, vsync_front_porch=166) # ---------------------------------------------------------------------------------------------------------------------- -DriverChip( +DsiDriverChip( "JC4880P443", width=480, height=800, @@ -58,7 +56,6 @@ DriverChip( vsync_front_porch=166, pclk_frequency="34MHz", lane_bit_rate="500Mbps", - swap_xy=cv.UNDEFINED, color_order="RGB", reset_pin=5, initsequence=[ @@ -111,7 +108,7 @@ DriverChip( # * Horizontal Timing (hsync_pulse_width=20, hsync_back_porch=20, hsync_front_porch=40) # * Vertical Timing (vsync_pulse_width=4, vsync_back_porch=8, vsync_front_porch=20) # ---------------------------------------------------------------------------------------------------------------------- -DriverChip( +DsiDriverChip( "JC8012P4A1", width=800, height=1280, @@ -123,7 +120,6 @@ DriverChip( vsync_front_porch=20, pclk_frequency="60MHz", lane_bit_rate="1Gbps", - swap_xy=cv.UNDEFINED, color_order="RGB", reset_pin=27, initsequence=[ diff --git a/esphome/components/mipi_dsi/models/m5stack.py b/esphome/components/mipi_dsi/models/m5stack.py index 53fac9b534..b947b9ac8a 100644 --- a/esphome/components/mipi_dsi/models/m5stack.py +++ b/esphome/components/mipi_dsi/models/m5stack.py @@ -1,8 +1,7 @@ -from esphome.components.mipi import DriverChip -import esphome.config_validation as cv +from . import DsiDriverChip # fmt: off -DriverChip( +DsiDriverChip( "M5STACK-TAB5", height=1280, width=720, @@ -14,7 +13,6 @@ DriverChip( vsync_front_porch=20, pclk_frequency="60MHz", lane_bit_rate="730Mbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0xFF, 0x98, 0x81, 0x01), # Select Page 1 @@ -56,7 +54,7 @@ DriverChip( ], ) -DriverChip( +DsiDriverChip( "M5STACK-TAB5-V2", height=1280, width=720, @@ -68,7 +66,6 @@ DriverChip( vsync_front_porch=220, pclk_frequency="80MHz", lane_bit_rate="960Mbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0x01,), diff --git a/esphome/components/mipi_dsi/models/seeed.py b/esphome/components/mipi_dsi/models/seeed.py index 290b0e07ee..84593b40e6 100644 --- a/esphome/components/mipi_dsi/models/seeed.py +++ b/esphome/components/mipi_dsi/models/seeed.py @@ -1,9 +1,8 @@ -from esphome.components.mipi import DriverChip -import esphome.config_validation as cv +from . import DsiDriverChip # Standalone display # Product page: https://www.seeedstudio.com/reTerminal-D1001-p-6729.html -DriverChip( +DsiDriverChip( "SEEED-RETERMINAL-D1001", height=1280, width=800, @@ -15,10 +14,10 @@ DriverChip( vsync_front_porch=30, pclk_frequency="80MHz", lane_bit_rate="1.5Gbps", - swap_xy=cv.UNDEFINED, color_order="RGB", enable_pin=[{"xl9535": None, "number": 0}, {"xl9535": None, "number": 7}], reset_pin={"xl9535": None, "number": 2}, + requires={"psram", "xl9535"}, initsequence=( (0xE0, 0x00), (0xE1, 0x93), diff --git a/esphome/components/mipi_dsi/models/waveshare.py b/esphome/components/mipi_dsi/models/waveshare.py index c97a0bbd02..a1702fb6a1 100644 --- a/esphome/components/mipi_dsi/models/waveshare.py +++ b/esphome/components/mipi_dsi/models/waveshare.py @@ -1,12 +1,11 @@ -from esphome.components.mipi import DriverChip -import esphome.config_validation as cv +from . import DsiDriverChip # fmt: off # Source for parameters and initsequence: # https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365_10_1 # Product page: https://www.waveshare.com/wiki/ESP32-P4-Nano-StartPage -JD9365_10_1_DSI_TOUCH_A = DriverChip( +JD9365_10_1_DSI_TOUCH_A = DsiDriverChip( "WAVESHARE-P4-NANO-10.1", height=1280, width=800, @@ -18,7 +17,6 @@ JD9365_10_1_DSI_TOUCH_A = DriverChip( vsync_front_porch=30, pclk_frequency="80MHz", lane_bit_rate="1.5Gbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0xE0, 0x00), # select userpage @@ -65,7 +63,7 @@ JD9365_10_1_DSI_TOUCH_A.extend( # Source for parameters and initsequence: # https://github.com/espressif/esp-iot-solution/tree/master/components/display/lcd/esp_lcd_st7703 # Product page: https://www.waveshare.com/wiki/ESP32-P4-86-Panel-ETH-2RO -DriverChip( +DsiDriverChip( "WAVESHARE-P4-86-PANEL", height=720, width=720, @@ -77,7 +75,6 @@ DriverChip( vsync_front_porch=20, pclk_frequency="38MHz", lane_bit_rate="480Mbps", - swap_xy=cv.UNDEFINED, color_order="RGB", reset_pin=27, initsequence=[ @@ -109,7 +106,7 @@ DriverChip( # Source for parameters and initsequence: # https://github.com/espressif/esp-iot-solution/tree/master/components/display/lcd/esp_lcd_ek79007 # Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-7B -DriverChip( +DsiDriverChip( "WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-7B", height=600, width=1024, @@ -139,7 +136,7 @@ DriverChip( # Source for parameters and initsequence: # https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365 # Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-3.4C -JD9365_3_4_DSI_TOUCH_C = DriverChip( +JD9365_3_4_DSI_TOUCH_C = DsiDriverChip( "WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-3.4C", height=800, width=800, @@ -151,7 +148,6 @@ JD9365_3_4_DSI_TOUCH_C = DriverChip( vsync_front_porch=24, pclk_frequency="80MHz", lane_bit_rate="1.5Gbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0xE0, 0x00), # select userpage @@ -197,7 +193,7 @@ JD9365_3_4_DSI_TOUCH_C.extend( # Source for parameters and initsequence: # https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365 # Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-4C -JD9365_4_DSI_TOUCH_C = DriverChip( +JD9365_4_DSI_TOUCH_C = DsiDriverChip( "WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-4C", height=720, width=720, @@ -209,7 +205,6 @@ JD9365_4_DSI_TOUCH_C = DriverChip( vsync_front_porch=24, pclk_frequency="80MHz", lane_bit_rate="1.5Gbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0xE0, 0x00), # select userpage @@ -255,7 +250,7 @@ JD9365_4_DSI_TOUCH_C.extend( # Source for parameters and initsequence: # https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365 # Product page: https://www.waveshare.com/wiki/8-DSI-TOUCH-A -DriverChip( +DsiDriverChip( "WAVESHARE-8-DSI-TOUCH-A", height=1280, width=800, @@ -267,7 +262,6 @@ DriverChip( vsync_front_porch=30, pclk_frequency="80MHz", lane_bit_rate="1.5Gbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0xE0, 0x00), # select userpage @@ -304,7 +298,7 @@ DriverChip( # Source for parameters and initsequence: # https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_ili9881c # Product page: https://www.waveshare.com/wiki/7-DSI-TOUCH-A -DriverChip( +DsiDriverChip( "WAVESHARE-7-DSI-TOUCH-A", height=1280, width=720, diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 1eacc31fc5..ebe930d37a 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -18,6 +18,8 @@ from esphome.components.mipi import ( CONF_HSYNC_BACK_PORCH, CONF_HSYNC_FRONT_PORCH, CONF_HSYNC_PULSE_WIDTH, + CONF_PCLK_FREQUENCY, + CONF_PCLK_INVERTED, CONF_PCLK_PIN, CONF_PIXEL_MODE, CONF_USE_AXIS_FLIPS, @@ -34,9 +36,11 @@ from esphome.components.mipi import ( power_of_two, requires_buffer, ) -from esphome.components.rpi_dpi_rgb.display import ( - CONF_PCLK_FREQUENCY, - CONF_PCLK_INVERTED, +from esphome.components.spi import ( + CONF_SPI_MODE, + SPI_DATA_RATE_SCHEMA, + SPI_MODE_OPTIONS, + SPIComponent, ) import esphome.config_validation as cv from esphome.const import ( @@ -48,7 +52,6 @@ from esphome.const import ( CONF_DATA_RATE, CONF_DC_PIN, CONF_DIMENSIONS, - CONF_DISABLED, CONF_ENABLE_PIN, CONF_GREEN, CONF_HSYNC_PIN, @@ -57,8 +60,6 @@ from esphome.const import ( CONF_INIT_SEQUENCE, CONF_INVERT_COLORS, CONF_LAMBDA, - CONF_MIRROR_X, - CONF_MIRROR_Y, CONF_MODEL, CONF_NUMBER, CONF_RED, @@ -72,10 +73,10 @@ from esphome.const import ( ) from esphome.final_validate import full_config -from ..spi import CONF_SPI_MODE, SPI_DATA_RATE_SCHEMA, SPI_MODE_OPTIONS, SPIComponent from . import models +from .models import RgbDriverChip -DEPENDENCIES = ["esp32", "psram"] +DEPENDENCIES = ["esp32"] mipi_rgb_ns = cg.esphome_ns.namespace("mipi_rgb") mipi_rgb = mipi_rgb_ns.class_("MipiRgb", display.Display, cg.Component) @@ -86,7 +87,7 @@ ColorOrder = display.display_ns.enum("ColorMode") DATA_PIN_SCHEMA = pins.internal_gpio_output_pin_schema -DriverChip("CUSTOM") +RgbDriverChip("CUSTOM") # Import all models dynamically from the models package @@ -120,16 +121,7 @@ def data_pin_set(length): def model_schema(config): model = MODELS[config[CONF_MODEL].upper()] - transform = cv.Any( - cv.Schema( - { - cv.Required(CONF_MIRROR_X): cv.boolean, - cv.Required(CONF_MIRROR_Y): cv.boolean, - **model.swap_xy_schema(), - } - ), - cv.one_of(CONF_DISABLED, lower=True), - ) + transform = model.transform_schema() # RPI model does not use an init sequence, indicates with empty list if model.initsequence is None: # Custom model requires an init sequence @@ -235,6 +227,7 @@ def _config_schema(config): only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4]), )(config) model = MODELS[config[CONF_MODEL].upper()] + model.check_requirements() width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( model.get_dimensions(config) ) diff --git a/esphome/components/mipi_rgb/models/__init__.py b/esphome/components/mipi_rgb/models/__init__.py new file mode 100644 index 0000000000..9e3fe2a476 --- /dev/null +++ b/esphome/components/mipi_rgb/models/__init__.py @@ -0,0 +1,14 @@ +from esphome.components.mipi import DriverChip +from esphome.const import CONF_SWAP_XY + + +class RgbDriverChip(DriverChip): + """A driver chip for MIPI RGB displays.""" + + @property + def transforms(self) -> set[str]: + """ + Return the set of transformations supported by this driver chip. + RGB displays do not support axis swapping, so this method removes CONF_SWAP_XY + """ + return super().transforms - {CONF_SWAP_XY} diff --git a/esphome/components/mipi_rgb/models/guition.py b/esphome/components/mipi_rgb/models/guition.py index 915b8beda0..c0aaf0a3d2 100644 --- a/esphome/components/mipi_rgb/models/guition.py +++ b/esphome/components/mipi_rgb/models/guition.py @@ -5,6 +5,7 @@ st7701s.extend( width=480, height=480, data_rate="2MHz", + requires={"psram"}, cs_pin=39, de_pin=18, hsync_pin=16, diff --git a/esphome/components/mipi_rgb/models/lilygo.py b/esphome/components/mipi_rgb/models/lilygo.py index c0e91cd8ae..4e0615b439 100644 --- a/esphome/components/mipi_rgb/models/lilygo.py +++ b/esphome/components/mipi_rgb/models/lilygo.py @@ -1,5 +1,3 @@ -from esphome.config_validation import UNDEFINED - from .st7701s import ST7701S # fmt: off @@ -8,10 +6,10 @@ ST7701S( width=480, height=480, invert_colors=False, - swap_xy=UNDEFINED, spi_mode="MODE3", cs_pin={"xl9535": None, "number": 17}, reset_pin={"xl9535": None, "number": 5}, + requires={"psram", "xl9535"}, hsync_pin=39, vsync_pin=40, pclk_pin=41, @@ -57,9 +55,9 @@ t_rgb = ST7701S( height=480, pixel_mode="18bit", invert_colors=False, - swap_xy=UNDEFINED, spi_mode="MODE3", cs_pin={"xl9535": None, "number": 3}, + requires={"psram", "xl9535"}, de_pin=45, hsync_pin=47, vsync_pin=41, diff --git a/esphome/components/mipi_rgb/models/rpi.py b/esphome/components/mipi_rgb/models/rpi.py index 076d96b658..1e2a6600ee 100644 --- a/esphome/components/mipi_rgb/models/rpi.py +++ b/esphome/components/mipi_rgb/models/rpi.py @@ -1,9 +1,7 @@ -from esphome.components.mipi import DriverChip -from esphome.config_validation import UNDEFINED +from . import RgbDriverChip # A driver chip for Raspberry Pi MIPI RGB displays. These require no init sequence -DriverChip( +RgbDriverChip( "RPI", - swap_xy=UNDEFINED, initsequence=(), ) diff --git a/esphome/components/mipi_rgb/models/st7701s.py b/esphome/components/mipi_rgb/models/st7701s.py index 990a1ca4f3..a20e9d1c01 100644 --- a/esphome/components/mipi_rgb/models/st7701s.py +++ b/esphome/components/mipi_rgb/models/st7701s.py @@ -1,17 +1,12 @@ -from esphome.components.mipi import ( - MADCTL, - MADCTL_ML, - MADCTL_XFLIP, - MODE_BGR, - DriverChip, -) -from esphome.config_validation import UNDEFINED +from esphome.components.mipi import MADCTL, MADCTL_ML, MADCTL_XFLIP, MODE_BGR from esphome.const import CONF_COLOR_ORDER, CONF_HEIGHT, CONF_MIRROR_X, CONF_MIRROR_Y +from . import RgbDriverChip + SDIR_CMD = 0xC7 -class ST7701S(DriverChip): +class ST7701S(RgbDriverChip): # The ST7701s does not use the standard MADCTL bits for x/y mirroring def add_madctl(self, sequence: list, config: dict): transform = self.get_transform(config) @@ -45,7 +40,6 @@ st7701s = ST7701S( "ST7701S", width=480, height=864, - swap_xy=UNDEFINED, hsync_front_porch=20, hsync_back_porch=10, hsync_pulse_width=10, @@ -85,6 +79,7 @@ st7701s.extend( height=480, invert_colors=True, pixel_mode="18bit", + requires={"psram"}, cs_pin=1, de_pin={ "number": 45, @@ -117,6 +112,7 @@ st7701s.extend( vsync_pulse_width=8, vsync_back_porch=20, cs_pin={"pca9554": None, "number": 4}, + requires={"psram", "pca9554"}, de_pin=18, hsync_pin=16, vsync_pin=17, @@ -134,6 +130,7 @@ st7701s.extend( width=480, height=480, pixel_mode="18bit", + requires={"psram"}, cs_pin=18, reset_pin=8, de_pin=17, @@ -177,6 +174,7 @@ st7701s.extend( width=480, height=480, pixel_mode="18bit", + requires={"psram"}, cs_pin=21, de_pin=39, vsync_pin=48, diff --git a/esphome/components/mipi_rgb/models/sunton.py b/esphome/components/mipi_rgb/models/sunton.py index a33625dfe4..a87d5f3c38 100644 --- a/esphome/components/mipi_rgb/models/sunton.py +++ b/esphome/components/mipi_rgb/models/sunton.py @@ -1,14 +1,13 @@ -from esphome.components.mipi import DriverChip -from esphome.config_validation import UNDEFINED +from . import RgbDriverChip # fmt: off -sunton = DriverChip( +sunton = RgbDriverChip( "ESP32-8048S070", - swap_xy=UNDEFINED, initsequence=(), width=800, height=480, pclk_frequency="12.5MHz", + requires={"psram"}, de_pin=41, hsync_pin=39, vsync_pin=40, @@ -28,7 +27,6 @@ sunton = DriverChip( sunton.extend( "ESP32-8048S050", - swap_xy=UNDEFINED, initsequence=(), width=800, height=480, diff --git a/esphome/components/mipi_rgb/models/waveshare.py b/esphome/components/mipi_rgb/models/waveshare.py index cd1fc341ef..ef1a5cd2d6 100644 --- a/esphome/components/mipi_rgb/models/waveshare.py +++ b/esphome/components/mipi_rgb/models/waveshare.py @@ -1,18 +1,18 @@ -from esphome.components.mipi import DriverChip, delay -from esphome.config_validation import UNDEFINED +from esphome.components.mipi import delay +from . import RgbDriverChip from .st7701s import st7701s # fmt: off -wave_4_3 = DriverChip( +wave_4_3 = RgbDriverChip( "ESP32-S3-TOUCH-LCD-4.3", - swap_xy=UNDEFINED, initsequence=(), width=800, height=480, pclk_frequency="16MHz", reset_pin={"ch422g": None, "number": 3}, enable_pin={"ch422g": None, "number": 2}, + requires={"psram", "ch422g"}, de_pin=5, hsync_pin={"number": 46, "ignore_strapping_warning": True}, vsync_pin={"number": 3, "ignore_strapping_warning": True}, @@ -69,6 +69,7 @@ st7701s.extend( pclk_pin=41, pclk_frequency="12MHz", pclk_inverted=False, + requires={"psram"}, data_pins={ "red": [46, 3, 8, 18, 17], "green": [14, 13, 12, 11, 10, 9], @@ -80,6 +81,7 @@ st7701s.extend( "WAVESHARE-3.16-320X820", width=320, height=820, + requires={"psram"}, de_pin=40, hsync_pin=38, vsync_pin=39, diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 871736abd1..f472e12a76 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -41,14 +41,11 @@ from esphome.const import ( CONF_DATA_RATE, CONF_DC_PIN, CONF_DIMENSIONS, - CONF_DISABLED, CONF_ENABLE_PIN, CONF_ID, CONF_INIT_SEQUENCE, CONF_INVERT_COLORS, CONF_LAMBDA, - CONF_MIRROR_X, - CONF_MIRROR_Y, CONF_MODEL, CONF_RESET_PIN, CONF_ROTATION, @@ -138,16 +135,7 @@ def denominator(config): def model_schema(config): model = MODELS[config[CONF_MODEL]] bus_mode = config[CONF_BUS_MODE] - transform = cv.Any( - cv.Schema( - { - cv.Required(CONF_MIRROR_X): cv.boolean, - cv.Required(CONF_MIRROR_Y): cv.boolean, - **model.swap_xy_schema(), - } - ), - cv.one_of(CONF_DISABLED, lower=True), - ) + transform = model.transform_schema() # CUSTOM model will need to provide a custom init sequence iseqconf = ( cv.Required(CONF_INIT_SEQUENCE) @@ -265,6 +253,7 @@ def customise_schema(config): extra=ALLOW_EXTRA, )(config) model = MODELS[config[CONF_MODEL]] + model.check_requirements() bus_modes = (TYPE_SINGLE, TYPE_QUAD, TYPE_OCTAL) config = cv.Schema( { diff --git a/esphome/components/mipi_spi/models/adafruit.py b/esphome/components/mipi_spi/models/adafruit.py index 26790b1493..cc295487eb 100644 --- a/esphome/components/mipi_spi/models/adafruit.py +++ b/esphome/components/mipi_spi/models/adafruit.py @@ -13,6 +13,7 @@ ST7789V.extend( mirror_x=True, mirror_y=True, data_rate="80MHz", + requires={"psram"}, ) ST7789V.extend( @@ -25,4 +26,5 @@ ST7789V.extend( dc_pin=39, reset_pin=40, invert_colors=True, + requires={"psram"}, ) diff --git a/esphome/components/mipi_spi/models/amoled.py b/esphome/components/mipi_spi/models/amoled.py index 30e815d68e..8a869f2284 100644 --- a/esphome/components/mipi_spi/models/amoled.py +++ b/esphome/components/mipi_spi/models/amoled.py @@ -16,7 +16,7 @@ from esphome.components.mipi import ( delay, ) from esphome.components.spi import TYPE_QUAD -from esphome.config_validation import UNDEFINED +from esphome.const import CONF_MIRROR_X, CONF_MIRROR_Y DriverChip( "T-DISPLAY-S3-AMOLED", @@ -29,6 +29,7 @@ DriverChip( brightness=0xD0, color_order=MODE_RGB, no_slpout=True, # SLPOUT is in the init sequence, early + requires={"psram"}, initsequence=(SLPOUT,), ) @@ -43,6 +44,7 @@ DriverChip( data_rate="40MHz", brightness=0xD0, color_order=MODE_RGB, + requires={"psram"}, initsequence=( (PAGESEL, 4), (0x6A, 0x00), @@ -90,6 +92,7 @@ T4_S3_AMOLED = RM690B0.extend( reset_pin=13, enable_pin=9, bus_mode=TYPE_QUAD, + requires={"psram"}, ) CO5300 = DriverChip( @@ -98,7 +101,7 @@ CO5300 = DriverChip( color_order=MODE_RGB, bus_mode=TYPE_QUAD, no_slpout=True, - swap_xy=UNDEFINED, + transforms={CONF_MIRROR_X, CONF_MIRROR_Y}, width=480, height=480, initsequence=( diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index 812e491c62..187fcfd8c0 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -314,6 +314,7 @@ DriverChip( data_rate="40MHz", dc_pin=4, cs_pin=5, + requires={"psram"}, # reset_pin={CONF_INVERTED: True, CONF_NUMBER: 48}, initsequence=( (0xEF, 0x03, 0x80, 0x02), @@ -379,6 +380,7 @@ DriverChip( cs_pin=5, dc_pin=4, reset_pin=48, + requires={"psram"}, initsequence=( (0xEF, 0x03, 0x80, 0x02), (0xCF, 0x00, 0xC1, 0x30), @@ -711,6 +713,7 @@ ST7796.extend( reset_pin=4, dc_pin={"number": 0, "ignore_strapping_warning": True}, invert_colors=True, + requires={"psram"}, ) ST7789V.extend( diff --git a/esphome/components/mipi_spi/models/jc.py b/esphome/components/mipi_spi/models/jc.py index 854814f572..d24ca5db58 100644 --- a/esphome/components/mipi_spi/models/jc.py +++ b/esphome/components/mipi_spi/models/jc.py @@ -1,12 +1,16 @@ from esphome.components.mipi import MODE_RGB, DriverChip from esphome.components.spi import TYPE_QUAD -import esphome.config_validation as cv -from esphome.const import CONF_IGNORE_STRAPPING_WARNING, CONF_NUMBER +from esphome.const import ( + CONF_IGNORE_STRAPPING_WARNING, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_NUMBER, +) AXS15231 = DriverChip( "AXS15231", draw_rounding=8, - swap_xy=cv.UNDEFINED, + transforms={CONF_MIRROR_X, CONF_MIRROR_Y}, color_order=MODE_RGB, bus_mode=TYPE_QUAD, initsequence=( @@ -22,6 +26,7 @@ AXS15231.extend( height=480, cs_pin={CONF_NUMBER: 45, CONF_IGNORE_STRAPPING_WARNING: True}, data_rate="40MHz", + requires={"psram"}, ) DriverChip( @@ -36,6 +41,7 @@ DriverChip( color_order=MODE_RGB, bus_mode=TYPE_QUAD, data_rate="40MHz", + requires={"psram"}, initsequence=( (0xF0, 0x08), (0xF2, 0x08), @@ -267,6 +273,7 @@ DriverChip( color_order=MODE_RGB, bus_mode=TYPE_QUAD, data_rate="40MHz", + requires={"psram"}, initsequence=( (0xF0, 0x28), (0xF2, 0x28), @@ -495,6 +502,7 @@ DriverChip( color_order=MODE_RGB, bus_mode=TYPE_QUAD, data_rate="20MHz", + requires={"psram"}, initsequence=( (0xFF, 0xA5), (0x41, 0x03), diff --git a/esphome/components/mipi_spi/models/lanbon.py b/esphome/components/mipi_spi/models/lanbon.py index 8cec3c8317..1188300136 100644 --- a/esphome/components/mipi_spi/models/lanbon.py +++ b/esphome/components/mipi_spi/models/lanbon.py @@ -10,4 +10,5 @@ ST7789V.extend( cs_pin=22, dc_pin=21, reset_pin=18, + requires={"psram"}, ) diff --git a/esphome/components/mipi_spi/models/lilygo.py b/esphome/components/mipi_spi/models/lilygo.py index 46ec809029..84f44a3dae 100644 --- a/esphome/components/mipi_spi/models/lilygo.py +++ b/esphome/components/mipi_spi/models/lilygo.py @@ -15,6 +15,7 @@ ST7789V.extend( dc_pin=13, reset_pin=9, data_rate="80MHz", + requires={"psram"}, ) ST7789V.extend( @@ -42,6 +43,7 @@ ST7789V.extend( enable_pin=[9, 15], data_rate="10MHz", bus_mode=TYPE_OCTAL, + requires={"psram"}, ) ST7796.extend( @@ -55,4 +57,5 @@ ST7796.extend( dc_pin=9, backlight_pin=48, invert_colors=True, + requires={"psram"}, ) diff --git a/esphome/components/mipi_spi/models/m5stack.py b/esphome/components/mipi_spi/models/m5stack.py index 81bb186278..a54bd19d88 100644 --- a/esphome/components/mipi_spi/models/m5stack.py +++ b/esphome/components/mipi_spi/models/m5stack.py @@ -49,6 +49,7 @@ ILI9341.extend( invert_colors=True, pixel_mode="18bit", data_rate="40MHz", + requires={"psram"}, ) GC9107 = ST7789V.extend( @@ -68,4 +69,5 @@ GC9107.extend( reset_pin=48, dc_pin=42, cs_pin=14, + requires={"psram"}, ) diff --git a/esphome/components/mipi_spi/models/waveshare.py b/esphome/components/mipi_spi/models/waveshare.py index 8fc5b2acc5..0caae5b939 100644 --- a/esphome/components/mipi_spi/models/waveshare.py +++ b/esphome/components/mipi_spi/models/waveshare.py @@ -12,7 +12,7 @@ from esphome.components.mipi import ( PWSET, DriverChip, ) -import esphome.config_validation as cv +from esphome.const import CONF_MIRROR_X, CONF_MIRROR_Y from .amoled import CO5300 from .ili import ILI9488_A, ST7789V @@ -155,7 +155,7 @@ ST7789P = DriverChip( ILI9488_A.extend( "PICO-RESTOUCH-LCD-3.5", - swap_xy=cv.UNDEFINED, + transforms={CONF_MIRROR_X, CONF_MIRROR_Y}, spi_16=True, pixel_mode="16bit", mirror_x=True, @@ -175,6 +175,7 @@ CO5300.extend( offset_width=6, cs_pin=12, reset_pin=39, + requires={"psram"}, ) # Waveshare ESP32-S3 Touch AMOLED 2.16" (CO5300 controller) @@ -189,6 +190,7 @@ CO5300.extend( cs_pin=12, reset_pin=39, data_rate="40MHz", + requires={"psram"}, ) AXS15231.extend( @@ -198,6 +200,7 @@ AXS15231.extend( data_rate="80MHz", cs_pin=9, reset_pin=21, + requires={"psram"}, ) # Waveshare 1.83-v2 @@ -281,6 +284,7 @@ ST7789V.extend( offset_height=40, invert_colors=True, data_rate="40MHz", + requires={"psram"}, ) CO5300.extend( @@ -291,4 +295,5 @@ CO5300.extend( cs_pin=9, reset_pin=21, enable_pin=1, + requires={"psram"}, ) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 803ddba6b7..bfdd2de7c7 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -574,6 +574,9 @@ class EsphomeCore: self.build_path: Path | None = None # The validated configuration, this is None until the config has been validated self.config: ConfigType | None = None + # The raw configuration as read from YAML (after packages/substitutions), + # available during validation before the config is fully validated + self.raw_config: ConfigType | None = None # YAML frontmatter loaded from user YAML files. Frontmatter is a leading # YAML document separated by `---` from the actual configuration. It is # ignored by config validation and code generation, but kept here so it @@ -650,6 +653,7 @@ class EsphomeCore: self.config_path = None self.build_path = None self.config = None + self.raw_config = None self.frontmatter = {} self.event_loop = _FakeEventLoop() self.task_counter = 0 diff --git a/tests/component_tests/animation/config/animation_platform_test.yaml b/tests/component_tests/animation/config/animation_platform_test.yaml index 380434dcc3..8de32ed593 100644 --- a/tests/component_tests/animation/config/animation_platform_test.yaml +++ b/tests/component_tests/animation/config/animation_platform_test.yaml @@ -24,6 +24,9 @@ spi: mosi_pin: 6 clk_pin: 7 +psram: + mode: quad + display: - platform: mipi_spi id: lcd_display diff --git a/tests/component_tests/animation/config/animation_test.yaml b/tests/component_tests/animation/config/animation_test.yaml index 9d8fd15276..1fe6ddf9a4 100644 --- a/tests/component_tests/animation/config/animation_test.yaml +++ b/tests/component_tests/animation/config/animation_test.yaml @@ -19,6 +19,9 @@ spi: mosi_pin: 6 clk_pin: 7 +psram: + mode: quad + display: - platform: mipi_spi id: lcd_display diff --git a/tests/component_tests/image/config/image_test.yaml b/tests/component_tests/image/config/image_test.yaml index c34e0993a5..31c29de21b 100644 --- a/tests/component_tests/image/config/image_test.yaml +++ b/tests/component_tests/image/config/image_test.yaml @@ -4,6 +4,9 @@ esphome: esp32: board: esp32s3box +psram: + mode: octal + image: defaults: type: rgb565 diff --git a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py index c14abdb4fd..100366b135 100644 --- a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py +++ b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py @@ -71,6 +71,18 @@ def test_configuration_errors(set_core_config: SetCoreConfigCallable) -> None: } ) + # DSI displays cannot swap axes; enabling swap_xy reports a clear error. + with pytest.raises(cv.Invalid, match="'swap_xy' is not supported by this model"): + CONFIG_SCHEMA( + { + "model": "custom", + "init_sequence": [[0xA0, 0x01]], + "lane_bit_rate": "1.5Gbps", + "dimensions": {"width": 320, "height": 240}, + "transform": {"mirror_x": True, "mirror_y": True, "swap_xy": True}, + } + ) + def test_configuration_success(set_core_config: SetCoreConfigCallable) -> None: """Test successful configuration validation.""" @@ -116,6 +128,33 @@ def test_configuration_success(set_core_config: SetCoreConfigCallable) -> None: CONFIG_SCHEMA(config) +def test_metadata_records_rotation(set_core_config: SetCoreConfigCallable) -> None: + """A configured display rotation is recorded in the metadata. + + LVGL relies on this to flag a rotation set in the display config (see the + mipi_spi tests for the end-to-end LVGL rejection). + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-p4-evboard", KEY_VARIANT: VARIANT_ESP32P4}, + ) + + from esphome.components.display import get_display_metadata + from esphome.components.mipi_dsi.display import CONFIG_SCHEMA + + base = { + "model": "custom", + "init_sequence": [[0xA0, 0x01]], + "lane_bit_rate": "1.5Gbps", + "dimensions": {"width": 320, "height": 240}, + } + config = CONFIG_SCHEMA({**base, "id": "rotated", "rotation": 90}) + assert get_display_metadata(config["id"]).rotation == 90 + + config = CONFIG_SCHEMA({**base, "id": "unrotated"}) + assert get_display_metadata(config["id"]).rotation == 0 + + def test_code_generation( generate_main: Callable[[str | Path], str], component_fixture_path: Callable[[str], Path], diff --git a/tests/component_tests/mipi_rgb/__init__.py b/tests/component_tests/mipi_rgb/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/mipi_rgb/test_init.py b/tests/component_tests/mipi_rgb/test_init.py new file mode 100644 index 0000000000..0ab6c022e6 --- /dev/null +++ b/tests/component_tests/mipi_rgb/test_init.py @@ -0,0 +1,89 @@ +"""Tests for mipi_rgb configuration validation, in particular the per-model +``requires`` component check (see esphome.components.mipi.DriverChip.check_requirements).""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32S3 +from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA + +# Importing pca9554 registers its pin schema with pins.PIN_SCHEMA_REGISTRY so that +# models (e.g. SEEED-INDICATOR-D1) that reference pca9554-backed pins in their +# defaults can be validated by the mipi_rgb CONFIG_SCHEMA in this test. +import esphome.components.pca9554 # noqa: F401 +from esphome.const import PlatformFramework +from esphome.core import CORE +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _validated(config: ConfigType) -> ConfigType: + """Run the component config schema followed by the final validation.""" + config = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(config) + return config + + +def test_model_requires_psram(set_core_config: SetCoreConfigCallable) -> None: + """A model known to have PSRAM on its board rejects a config without it. + + RGB parallel displays always need a full framebuffer, so every model in this + component is expected to carry ``requires={"psram", ...}``. This board has no + other requirements, so its check is exercised in isolation here. + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, + ) + CORE.raw_config = {} + + with pytest.raises( + cv.Invalid, + match=r"ESP32-8048S070 requires component 'psram' to be configured", + ): + _validated({"model": "ESP32-8048S070"}) + + +def test_model_requires_psram_satisfied( + set_core_config: SetCoreConfigCallable, + set_component_config: Any, +) -> None: + """The same board model validates once PSRAM is configured.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, + ) + set_component_config("psram", True) + CORE.raw_config = {"psram": True} + + config = _validated({"model": "ESP32-8048S070"}) + assert config["model"] == "ESP32-8048S070" + + +def test_model_requires_psram_and_expander( + set_core_config: SetCoreConfigCallable, + set_component_config: Any, +) -> None: + """A model that also depends on an I2C GPIO expander lists both when missing.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, + ) + # Only satisfy one of the two requirements. + set_component_config("psram", True) + CORE.raw_config = {"psram": True} + + with pytest.raises( + cv.Invalid, + match=r"SEEED-INDICATOR-D1 requires component 'pca9554' to be configured", + ): + _validated( + { + "model": "SEEED-INDICATOR-D1", + "spi_id": "spi_bus", + } + ) diff --git a/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py new file mode 100644 index 0000000000..e85327c0ab --- /dev/null +++ b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py @@ -0,0 +1,137 @@ +"""Tests for mipi_rgb configuration validation.""" + +import pytest + +from esphome import config_validation as cv + +# Importing these registers their pin schemas with pins.PIN_SCHEMA_REGISTRY so that +# models referencing IO-expander-backed pins in their defaults (e.g. the LilyGO +# T-RGB boards via xl9535, SEEED-INDICATOR-D1 via pca9554, or the Waveshare panels +# via ch422g) can be validated by the mipi_rgb CONFIG_SCHEMA in this test. +import esphome.components.ch422g # noqa: F401 +from esphome.components.display import get_display_metadata +from esphome.components.esp32 import KEY_BOARD, VARIANT_ESP32S3 +import esphome.components.pca9554 # noqa: F401 +import esphome.components.xl9535 # noqa: F401 +from esphome.const import ( + CONF_BLUE, + CONF_DIMENSIONS, + CONF_GREEN, + CONF_HEIGHT, + CONF_INIT_SEQUENCE, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_RED, + CONF_SWAP_XY, + CONF_WIDTH, + KEY_VARIANT, + PlatformFramework, +) +from tests.component_tests.types import SetCoreConfigCallable + +# A generic set of data pins so that models without a default pin assignment +# (e.g. CUSTOM and RPI) still validate. +DATA_PINS = { + CONF_RED: [1, 2, 3, 4, 5], + CONF_GREEN: [6, 7, 8, 9, 10, 11], + CONF_BLUE: [12, 13, 14, 15, 16], +} + + +def _set_s3(set_core_config: SetCoreConfigCallable) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + +def test_configuration_success(set_core_config: SetCoreConfigCallable) -> None: + """Every predefined model validates once required defaults are supplied.""" + _set_s3(set_core_config) + + from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS + + for name, model in MODELS.items(): + config = {"model": name, "data_pins": DATA_PINS, "pclk_pin": 21} + if model.initsequence is None: + config[CONF_INIT_SEQUENCE] = [[0xA0, 0x01]] + if not model.get_default(CONF_WIDTH): + config[CONF_DIMENSIONS] = {CONF_WIDTH: 480, CONF_HEIGHT: 480} + CONFIG_SCHEMA(config) + + +def test_transform_matches_model_support( + set_core_config: SetCoreConfigCallable, +) -> None: + """The transform schema only accepts the axes a model actually supports.""" + _set_s3(set_core_config) + + from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS + + # ESP32-8048S070 supports both mirror axes but not swap_xy (RGB displays + # never support axis swapping). + model = MODELS["ESP32-8048S070"] + assert model.transforms == {CONF_MIRROR_X, CONF_MIRROR_Y} + + base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21} + CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": False}}) + + # An unsupported axis may be explicitly disabled (a harmless no-op)... + CONFIG_SCHEMA( + {**base, "transform": {"mirror_x": True, "mirror_y": False, "swap_xy": False}} + ) + + # ...but enabling it reports a clear, model-specific error. + with pytest.raises(cv.Invalid, match="'swap_xy' is not supported by this model"): + CONFIG_SCHEMA( + { + **base, + "transform": {"mirror_x": True, "mirror_y": False, "swap_xy": True}, + } + ) + + +def test_st7701s_only_supports_mirror_x( + set_core_config: SetCoreConfigCallable, +) -> None: + """ST7701S panels shorter than full height only expose mirror_x. + + mirror_y only works at full height (864px), so the LilyGO 480px panels must + reject a mirror_y transform. + """ + _set_s3(set_core_config) + + from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS + + model = MODELS["T-RGB-2.1"] + assert model.transforms == {CONF_MIRROR_X} + assert CONF_SWAP_XY not in model.transforms + + base = {"model": "T-RGB-2.1"} + CONFIG_SCHEMA({**base, "transform": {"mirror_x": True}}) + + with pytest.raises(cv.Invalid, match="'mirror_y' is not supported by this model"): + CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": True}}) + + +def test_metadata_records_rotation( + set_core_config: SetCoreConfigCallable, +) -> None: + """A configured display rotation is recorded in the metadata. + + LVGL relies on this to flag a rotation set in the display config (see the + mipi_spi tests for the end-to-end LVGL rejection). + """ + _set_s3(set_core_config) + + from esphome.components.mipi_rgb.display import CONFIG_SCHEMA + + base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21} + config = CONFIG_SCHEMA({**base, "id": "rotated", "rotation": 90}) + assert get_display_metadata(config["id"]).rotation == 90 + + config = CONFIG_SCHEMA({**base, "id": "unrotated"}) + assert get_display_metadata(config["id"]).rotation == 0 diff --git a/tests/component_tests/mipi_spi/test_display_metadata.py b/tests/component_tests/mipi_spi/test_display_metadata.py index e7f5143d91..06cc8ee09a 100644 --- a/tests/component_tests/mipi_spi/test_display_metadata.py +++ b/tests/component_tests/mipi_spi/test_display_metadata.py @@ -3,6 +3,9 @@ from collections.abc import Callable from pathlib import Path +import pytest + +from esphome import config_validation as cv from esphome.components.const import BYTE_ORDER_BIG from esphome.components.display import get_all_display_metadata, get_display_metadata from esphome.components.esp32 import ( @@ -13,6 +16,7 @@ from esphome.components.esp32 import ( ) from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA from esphome.const import PlatformFramework +from esphome.core import ID from tests.component_tests.types import SetCoreConfigCallable @@ -23,6 +27,18 @@ def validated_config(config): return config +def _lvgl_config(display_id: str) -> dict: + """Build a minimal LVGL config dict referencing the given display id.""" + return { + "displays": [ID(display_id, True)], + "log_level": "WARN", + "color_depth": 16, + "transparency_key": 0x000400, + "draw_rounding": 2, + "buffer_size": 0, + } + + def test_metadata_native_quad_default_test_card( set_core_config: SetCoreConfigCallable, ) -> None: @@ -91,7 +107,7 @@ def test_metadata_no_swap_xy_not_full_hardware_rotation( PlatformFramework.ESP32_IDF, platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, ) - # JC3248W535 has swap_xy=cv.UNDEFINED -> transforms={mirror_x, mirror_y} only + # JC3248W535 has transforms={mirror_x, mirror_y} only config = CONFIG_SCHEMA({"model": "JC3248W535", "id": "jc3248w535"}) meta = get_display_metadata(config["id"]) assert meta is not None @@ -166,3 +182,69 @@ def test_metadata_via_code_generation_lvgl( assert meta.height == 160 assert meta.has_hardware_rotation is True assert meta.byte_order == BYTE_ORDER_BIG + + +def test_metadata_records_rotation( + set_core_config: SetCoreConfigCallable, +) -> None: + """A configured display rotation is recorded in the metadata.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + config = CONFIG_SCHEMA( + {"model": "ST7735", "dc_pin": 18, "id": "rotated", "rotation": 90} + ) + meta = get_display_metadata(config["id"]) + assert meta is not None + assert meta.rotation == 90 + + +def test_metadata_rotation_defaults_to_zero( + set_core_config: SetCoreConfigCallable, +) -> None: + """A display without a rotation reports rotation 0 in its metadata.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + config = CONFIG_SCHEMA({"model": "ST7735", "dc_pin": 18, "id": "unrotated"}) + meta = get_display_metadata(config["id"]) + assert meta is not None + assert meta.rotation == 0 + + +def test_rotation_flagged_when_used_with_lvgl( + set_core_config: SetCoreConfigCallable, +) -> None: + """A display with a rotation is rejected when driven by LVGL. + + LVGL manages its own rotation, so a rotation set in the display config must be + flagged and the user directed to configure it in the LVGL block instead. This + exercises the full chain: the mipi_spi schema records the rotation in the + display metadata, and LVGL's final validation reports it. + """ + from esphome.components.lvgl import final_validation + + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + CONFIG_SCHEMA({"model": "ST7735", "dc_pin": 18, "id": "rotated", "rotation": 90}) + with pytest.raises(cv.Invalid, match="rotation.*not compatible with LVGL"): + final_validation([_lvgl_config("rotated")]) + + +def test_no_rotation_accepted_with_lvgl( + set_core_config: SetCoreConfigCallable, +) -> None: + """A display without a rotation validates cleanly when driven by LVGL.""" + from esphome.components.lvgl import final_validation + + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + CONFIG_SCHEMA({"model": "ST7735", "dc_pin": 18, "id": "unrotated"}) + # Should not raise. + final_validation([_lvgl_config("unrotated")]) diff --git a/tests/component_tests/mipi_spi/test_final_validate.py b/tests/component_tests/mipi_spi/test_final_validate.py index 8c45b47752..77111ae867 100644 --- a/tests/component_tests/mipi_spi/test_final_validate.py +++ b/tests/component_tests/mipi_spi/test_final_validate.py @@ -6,10 +6,13 @@ from typing import Any import pytest +from esphome import config_validation as cv from esphome.components.display import CONF_SHOW_TEST_CARD from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.mipi import DriverChip from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA from esphome.const import CONF_BUFFER_SIZE, PlatformFramework +from esphome.core import CORE from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable @@ -183,3 +186,77 @@ def test_buffer_size_selected_when_lvgl_with_test_card( ) assert config[CONF_BUFFER_SIZE] == pytest.approx(1.0 / 4) + + +def test_requires_missing_single_component_raises() -> None: + """A model that requires a single component raises when it is absent.""" + CORE.raw_config = {} + chip = DriverChip("TEST-REQUIRES-PSRAM", requires={"psram"}) + + with pytest.raises( + cv.Invalid, + match=r"TEST-REQUIRES-PSRAM requires component 'psram' to be configured", + ): + chip.check_requirements() + + +def test_requires_missing_multiple_components_raises() -> None: + """A model that requires several components lists all the missing ones, pluralized.""" + CORE.raw_config = {} + chip = DriverChip("TEST-REQUIRES-MULTI", requires={"psram", "pca9554"}) + + with pytest.raises( + cv.Invalid, + match=r"TEST-REQUIRES-MULTI requires components '.*' to be configured", + ) as excinfo: + chip.check_requirements() + assert "psram" in str(excinfo.value) + assert "pca9554" in str(excinfo.value) + + +def test_requires_satisfied_does_not_raise() -> None: + """No error is raised once all the required components are configured.""" + CORE.raw_config = {"psram": True, "pca9554": []} + chip = DriverChip("TEST-REQUIRES-SATISFIED", requires={"psram", "pca9554"}) + + chip.check_requirements() # Should not raise + + +def test_requires_absent_does_not_raise() -> None: + """Models without a requires set are unaffected by the check.""" + CORE.raw_config = {} + chip = DriverChip("TEST-REQUIRES-NONE") + + chip.check_requirements() # Should not raise + + +def test_predefined_model_requires_psram( + set_core_config: SetCoreConfigCallable, +) -> None: + """A predefined board model known to have PSRAM rejects a config without it.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + CORE.raw_config = {} + + with pytest.raises( + cv.Invalid, match=r"S3BOX requires component 'psram' to be configured" + ): + _validated({"model": "s3box"}) + + +def test_predefined_model_requires_psram_satisfied( + set_core_config: SetCoreConfigCallable, + set_component_config: Any, +) -> None: + """The same board model validates once PSRAM is configured.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("psram", True) + CORE.raw_config = {"psram": True} + + config = _validated({"model": "s3box"}) + assert config["model"] == "S3BOX" diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index 8edbe095b7..dcecd89617 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -136,7 +136,7 @@ def test_dimension_validation( "model": "JC3248W535", "transform": {"mirror_x": False, "mirror_y": True, "swap_xy": True}, }, - "Axis swapping not supported by this model", + "'swap_xy' is not supported by this model", id="axis_swapping_not_supported", ), pytest.param( diff --git a/tests/component_tests/mipi_spi/test_padding_and_offsets.py b/tests/component_tests/mipi_spi/test_padding_and_offsets.py index 7ae6f0e61f..b2b421c1e2 100644 --- a/tests/component_tests/mipi_spi/test_padding_and_offsets.py +++ b/tests/component_tests/mipi_spi/test_padding_and_offsets.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from pathlib import Path +from typing import Any import pytest @@ -222,6 +223,7 @@ class TestNewModelVariants: def test_m5core2_with_native_dimensions( self, set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], ) -> None: """Test M5CORE2 variant with reset native_width and native_height.""" set_core_config( @@ -231,6 +233,8 @@ class TestNewModelVariants: KEY_VARIANT: VARIANT_ESP32S3, }, ) + # M5CORE2 has PSRAM on board and requires it to be configured + set_component_config("psram", True) # M5CORE2 should validate successfully config = validated_config({"model": "M5CORE2"}) diff --git a/tests/component_tests/online_image/config/online_image_platform_test.yaml b/tests/component_tests/online_image/config/online_image_platform_test.yaml index 883876e401..9b92bf75d0 100644 --- a/tests/component_tests/online_image/config/online_image_platform_test.yaml +++ b/tests/component_tests/online_image/config/online_image_platform_test.yaml @@ -24,6 +24,9 @@ spi: mosi_pin: 6 clk_pin: 7 +psram: + mode: quad + display: - platform: mipi_spi id: lcd_display diff --git a/tests/component_tests/online_image/config/online_image_test.yaml b/tests/component_tests/online_image/config/online_image_test.yaml index ab0ad472f9..4af398cdff 100644 --- a/tests/component_tests/online_image/config/online_image_test.yaml +++ b/tests/component_tests/online_image/config/online_image_test.yaml @@ -23,6 +23,9 @@ spi: mosi_pin: 6 clk_pin: 7 +psram: + mode: quad + display: - platform: mipi_spi id: lcd_display From 692cf7abd1d406e6833a53f62ee0c0993f35806b Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:37:00 -1000 Subject: [PATCH 0844/1815] Bump bundled esphome-device-builder to 1.4.2 (#17512) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index e7f8fceb12..fadf3f0685 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.4.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.4.2 RUN \ platformio settings set enable_telemetry No \ From 1a573919d15d41d97c94e64167ef3a992e217135 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:36:26 +1000 Subject: [PATCH 0845/1815] [mipi][mipi_spi] SWRESET handling improved (#17504) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/mipi/__init__.py | 20 +++++- esphome/components/mipi_spi/display.py | 2 +- esphome/components/mipi_spi/mipi_spi.h | 25 ++----- esphome/components/mipi_spi/models/jc.py | 1 + tests/component_tests/mipi_spi/test_init.py | 75 ++++++++++++++++++++- 5 files changed, 100 insertions(+), 23 deletions(-) diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index ab59d5ce5f..2b9a150419 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -26,6 +26,7 @@ from esphome.const import ( CONF_OFFSET_HEIGHT, CONF_OFFSET_WIDTH, CONF_PAGES, + CONF_RESET_PIN, CONF_ROTATION, CONF_SWAP_XY, CONF_TRANSFORM, @@ -601,12 +602,15 @@ class DriverChip: """ return self.get_default(f"no_{command.lower()}", False) - def get_sequence(self, config, add_madctl=True) -> tuple[int, ...]: + def get_sequence(self, config, add_madctl=True, add_reset=False) -> tuple[int, ...]: """ Create the init sequence for the display. Use the default sequence from the model, if any, and append any custom sequence provided in the config. Append SLPOUT (if not already in the sequence) and DISPON to the end of the sequence MADCTL will be set if add_madctl is True + If add_reset is True, a reset is prepended: a software reset when no reset pin + is configured (and the model doesn't skip it), followed by a settling delay that + both a software and a hardware reset require. Returns the init sequence """ sequence = list(self.initsequence or ()) @@ -615,6 +619,15 @@ class DriverChip: # Ensure each command is a tuple sequence = [x if isinstance(x, tuple) else (x,) for x in sequence] + if add_reset: + reset: list = [] + # A software reset is only needed when there is no hardware reset pin. + if CONF_RESET_PIN not in config and not self.skip_command("SWRESET"): + reset.append((SWRESET,)) + # Both a software and a hardware reset need a settling delay before further commands. + reset.append(delay(10)) + sequence = reset + sequence + # Set pixel format if not already in the custom sequence pixel_mode = config[CONF_PIXEL_MODE] if not isinstance(pixel_mode, int): @@ -635,8 +648,13 @@ class DriverChip: sequence.append((BRIGHTNESS, brightness)) # Add a SLPOUT command if required. if not self.skip_command("SLPOUT"): + # A zero delay will delay until 120ms after reset + sequence.append(delay(0)) sequence.append((SLPOUT,)) + sequence.append(delay(10)) sequence.append((DISPON,)) + # Add a delay here because additional commands may be added after this at runtime. + sequence.append(delay(10)) # Flatten the sequence into a list of bytes, with the length of each command # or the delay flag inserted where needed diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index f472e12a76..246db237b1 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -397,7 +397,7 @@ def get_instance(config): async def to_code(config): model = MODELS[config[CONF_MODEL]] var_id = config[CONF_ID] - init_sequence = model.get_sequence(config, False) + init_sequence = model.get_sequence(config, add_madctl=False, add_reset=True) var_id.type, templateargs = get_instance(config) var = cg.new_Pvariable(var_id, TemplateArguments(*templateargs)) cg.add(var.set_init_sequence(init_sequence)) diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 48184fa5c1..701bcd7169 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -13,6 +13,8 @@ constexpr static const char *const TAG = "display.mipi_spi"; // Maximum bytes to log for commands (truncated if larger) static constexpr size_t MIPI_SPI_MAX_CMD_LOG_BYTES = 64; + +// Command codes for MIPI SPI displays. Not all currently used, kept here for reference. static constexpr uint8_t SW_RESET_CMD = 0x01; static constexpr uint8_t SLEEP_OUT = 0x11; static constexpr uint8_t NORON = 0x13; @@ -151,14 +153,11 @@ class MipiSpi : public display::Display, this->reset_pin_->digital_write(false); delay(5); this->reset_pin_->digital_write(true); - } else { - // no reset pin, send software reset command - this->write_command_(SW_RESET_CMD); + // required delay after reset is already in the init sequence, don't duplicate } // need to know when the display is ready for SLPOUT command - will be 120ms after reset auto when = millis() + 120; - delay(10); size_t index = 0; auto &vec = this->init_sequence_; while (index != vec.size()) { @@ -170,6 +169,9 @@ class MipiSpi : public display::Display, uint8_t cmd = vec[index++]; uint8_t x = vec[index++]; if (x == DELAY_FLAG) { + if (cmd == 0) { + cmd = clamp_at_least((int) (when - millis()), 0); + } esph_log_d(TAG, "Delay %dms", cmd); delay(cmd); } else { @@ -179,24 +181,9 @@ class MipiSpi : public display::Display, this->mark_failed(); return; } - switch (cmd) { - case SLEEP_OUT: { - // are we ready, boots? - int duration = when - millis(); - if (duration > 0) { - esph_log_d(TAG, "Sleep %dms", duration); - delay(duration); - } - } break; - - default: - break; - } const auto *ptr = vec.data() + index; this->write_command_(cmd, ptr, num_args); index += num_args; - if (cmd == SLEEP_OUT) - delay(10); } } this->reset_params_(); diff --git a/esphome/components/mipi_spi/models/jc.py b/esphome/components/mipi_spi/models/jc.py index d24ca5db58..ca9adb4a72 100644 --- a/esphome/components/mipi_spi/models/jc.py +++ b/esphome/components/mipi_spi/models/jc.py @@ -13,6 +13,7 @@ AXS15231 = DriverChip( transforms={CONF_MIRROR_X, CONF_MIRROR_Y}, color_order=MODE_RGB, bus_mode=TYPE_QUAD, + no_swreset=True, initsequence=( (0xBB, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5A, 0xA5), (0xC1, 0x33), diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index dcecd89617..f29883684c 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -361,7 +361,8 @@ def test_native_generation( "mipi_spi::MipiSpiBuffer()" in main_cpp ) - assert "set_init_sequence({240, 1, 8, 242" in main_cpp + # A 10ms post-reset delay ({10, 255}) is prepended ahead of the model commands. + assert "set_init_sequence({10, 255, 240, 1, 8, 242" in main_cpp assert "show_test_card();" in main_cpp assert "set_write_only(true);" in main_cpp @@ -377,6 +378,76 @@ def test_lvgl_generation( "mipi_spi::MipiSpi();" in main_cpp ) - assert "set_init_sequence({177, 3, 1, 44, 45, 178" in main_cpp + # A 10ms post-reset delay ({10, 255}) is prepended ahead of the model commands. + assert "set_init_sequence({10, 255, 177, 3, 1, 44, 45, 178" in main_cpp assert "show_test_card();" not in main_cpp assert "set_auto_clear(false);" in main_cpp + + +# A 10ms delay (flattened to {10, 0xFF}, where 0xFF is the delay marker byte) is +# always prepended to the init sequence, since both a software and a hardware reset +# need to settle before further commands. A custom model has no reset_pin default +# and does not set no_swreset, so when no reset pin is configured the SWRESET command +# ({1, 0}: command 0x01 with no parameters) is prepended ahead of that delay. +_SWRESET_YAML = """ +esphome: + name: swreset-test +esp32: + board: esp32-s3-devkitc-1 + framework: + type: esp-idf +spi: + clk_pin: 1 + mosi_pin: 2 +display: + - platform: mipi_spi + model: custom + id: {display_id} + dc_pin: 4 + cs_pin: 8 + dimensions: + width: 320 + height: 240 + init_sequence: + - [0xA0, 0x01] +{reset_line} +""" + + +def test_swreset_prepended_without_reset_pin( + generate_main: Callable[[str | Path], str], + tmp_path: Path, +) -> None: + """A model with no reset pin (and no no_swreset) gets SWRESET prepended.""" + yaml_file = tmp_path / "swreset.yaml" + yaml_file.write_text( + _SWRESET_YAML.format(display_id="swreset_display", reset_line="") + ) + + main_cpp = generate_main(yaml_file) + + # SWRESET ({1, 0}) followed by a 10ms delay ({10, 255}) is inserted ahead of + # the model's own commands. + assert "swreset_display->set_init_sequence({1, 0, 10, 255, 160, 1, 1," in main_cpp + + +def test_swreset_not_prepended_with_reset_pin( + generate_main: Callable[[str | Path], str], + tmp_path: Path, +) -> None: + """A hardware reset pin performs the reset, so SWRESET must not be prepended. + + The post-reset delay is still required, so the sequence starts with the delay. + """ + yaml_file = tmp_path / "hwreset.yaml" + yaml_file.write_text( + _SWRESET_YAML.format( + display_id="hwreset_display", reset_line=" reset_pin: 5" + ) + ) + + main_cpp = generate_main(yaml_file) + + # The delay ({10, 255}) is still present, but no leading SWRESET ({1, 0}). + assert "hwreset_display->set_init_sequence({10, 255, 160, 1, 1," in main_cpp + assert "hwreset_display->set_init_sequence({1, 0," not in main_cpp From eb0848d5382aadf436165358805f864b1c026efd Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:38:01 +1000 Subject: [PATCH 0846/1815] [mipi_dsi] New model for M5Stack Tab5 (#17500) Co-authored-by: Claude Opus 4.8 --- esphome/components/mipi/__init__.py | 34 ++++++----- esphome/components/mipi_dsi/models/m5stack.py | 59 ++++++++++++++++++- .../mipi_dsi/test_mipi_dsi_config.py | 27 +++++++++ 3 files changed, 103 insertions(+), 17 deletions(-) diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 2b9a150419..3f73f96327 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -667,23 +667,27 @@ class DriverChip: This runs during schema validation (before ID references are resolved) so that a model whose default pins live on a pin expander reports the missing expander clearly instead of a cryptic "Couldn't find ID" from the unresolved pin reference. + + Also logs a warning if the model is deprecated. """ - requirements = self.get_default("requires", set()) - if not requirements: - return - # ``raw_config`` is populated before any component schema runs during a real - # validation, so presence of a required component is simply a top-level key. - # When it is absent (e.g. a unit test that invokes the schema directly) there - # is no config to check against, so skip. - global_config = CORE.raw_config - if global_config is None: - return - missing = {x for x in requirements if x not in global_config} - if missing: - reqstr = ", ".join(f"'{x}'" for x in sorted(missing)) - raise cv.Invalid( - f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured" + if deprecation_reason := self.get_default("deprecation_reason"): + LOGGER.warning( + "Display model %s is deprecated: %s", self.name, deprecation_reason ) + if requirements := self.get_default("requires", set()): + # ``raw_config`` is populated before any component schema runs during a real + # validation, so presence of a required component is simply a top-level key. + # When it is absent (e.g. a unit test that invokes the schema directly) there + # is no config to check against, so skip. + global_config = CORE.raw_config + if global_config is None: + return + missing = {x for x in requirements if x not in global_config} + if missing: + reqstr = ", ".join(f"'{x}'" for x in sorted(missing)) + raise cv.Invalid( + f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured" + ) def requires_buffer(config) -> bool: diff --git a/esphome/components/mipi_dsi/models/m5stack.py b/esphome/components/mipi_dsi/models/m5stack.py index b947b9ac8a..5b07229ec7 100644 --- a/esphome/components/mipi_dsi/models/m5stack.py +++ b/esphome/components/mipi_dsi/models/m5stack.py @@ -54,8 +54,8 @@ DsiDriverChip( ], ) -DsiDriverChip( - "M5STACK-TAB5-V2", +TAB5_ST7123 = DsiDriverChip( + "M5STACK-TAB5-ST7123", height=1280, width=720, hsync_back_porch=40, @@ -94,3 +94,58 @@ DsiDriverChip( (0xC9, 0x00, 0x00, 0x10, 0x1F, 0x36, 0x00, 0x5D, 0x04, 0x9D, 0x05, 0x10, 0xF2, 0x06, 0x60, 0x03, 0x11, 0xAD, 0x00, 0xEF, 0x01, 0x22, 0x2E, 0x0E, 0x74, 0x08, 0x32, 0xDC, 0x09, 0x33, 0x0F, 0xF3, 0x77, 0x0D, 0xB0, 0xDC, 0x03, 0xFF), ], ) + +TAB5_ST7123.extend( + "M5STACK-TAB5-V2", + deprecation_reason="Use 'M5STACK-TAB5-ST7123' or 'M5STACK-TAB5-ST7121' instead." +) + +# Some Tab5 "v2" units ship with an ST7121 controller instead of the ST7123. +# The two are distinguishable at runtime by the touch controller firmware version (the M5 +# factory firmware branches on it), but ESPHome selects the panel at compile time, so ST7121 +# units must select this model explicitly. Values taken from M5's factory source +# (m5stack/M5Tab5-UserDemo: m5stack_tab5.c is_st7121 path + esp_lcd_st7121.c default table). +DsiDriverChip( + "M5STACK-TAB5-ST7121", + height=1280, + width=720, + hsync_back_porch=40, + hsync_pulse_width=2, + hsync_front_porch=40, + vsync_back_porch=24, + vsync_pulse_width=20, + vsync_front_porch=200, + pclk_frequency="70MHz", + lane_bit_rate="965Mbps", + color_order="RGB", + initsequence=[ + (0x01,), + (0x60, 0x71, 0x21, 0xA2), + (0x60, 0x71, 0x21, 0xA3), + (0x60, 0x71, 0x21, 0xA4), + (0x78, 0x21), + (0x79, 0xEF), + (0xA4, 0x31), + (0xB7, 0x00, 0x00, 0x5F, 0x5F, 0x44, 0x1A), + (0xB0, 0x22, 0x6B, 0x11, 0x89, 0x25, 0x43, 0x43), + (0xBF, 0xA7, 0xA7), + (0xA5, 0xF0, 0x03), + (0xD7, 0x10, 0x2C, 0x14, 0x2A, 0x80, 0x80), + (0x90, 0x71, 0x23, 0x5A, 0x20, 0x24, 0x11, 0x21), + (0xA3, 0x80, 0x01, 0x8C, 0xFF, 0x45, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x00, 0x00, 0x1E, 0x5C, 0x1E, 0x80, 0x10, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x00, 0x00, 0x1E, 0x5C, 0x1E, 0x80, 0x10, 0xEF, 0x58, 0x00, 0x00, 0x00, 0xFF), + (0xA6, 0x0A, 0x00, 0x24, 0x71, 0x36, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x00, 0x24, 0x71, 0x37, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x00, 0x24, 0x71, 0x00, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x00, 0x2C, 0x71, 0x00, 0x01, 0x00, 0x00, 0x68, 0x68, 0xFF, 0xFF, 0x00, 0x08, 0x80, 0x08, 0x80, 0x06, 0x00, 0x00, 0x00, 0x00), + (0xA7, 0x1A, 0x1A, 0xC0, 0x64, 0x40, 0x04, 0x15, 0x40, 0x00, 0x40, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x64, 0x40, 0x26, 0x37, 0x40, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x64, 0x40, 0x8C, 0x9D, 0x40, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x64, 0x40, 0xAE, 0xBF, 0x00, 0x00, 0x20, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x79), + (0xAC, 0x1D, 0x18, 0x19, 0x1D, 0x18, 0x19, 0x04, 0x1C, 0x1D, 0x08, 0x0A, 0x10, 0x12, 0x0C, 0x0E, 0x14, 0x16, 0x00, 0x1D, 0x1D, 0x1D, 0x1D, 0x1D, 0x18, 0x19, 0x1D, 0x18, 0x19, 0x06, 0x1C, 0x1D, 0x09, 0x0B, 0x11, 0x13, 0x0D, 0x0F, 0x15, 0x17, 0x02, 0x1D, 0x1D, 0x1D, 0x1D), + (0xAD, 0x0C, 0x40, 0x46, 0x00, 0x07, 0x4B, 0x4B, 0xFF, 0xFF, 0xF0, 0x40, 0x0E, 0x01, 0x07, 0x42, 0x42, 0xFF, 0xFF, 0x01, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF), + (0xAE, 0xF0, 0xFF, 0x03, 0xF0, 0xFF, 0x03, 0x00), + (0xB2, 0x15, 0x19, 0x05, 0x23, 0x49, 0x2D, 0x03, 0x2E, 0x5C, 0xD2, 0xFF, 0x10, 0x60, 0xFD, 0x20, 0xC0, 0x00), + (0xE8, 0x20, 0x60, 0x04, 0x8E, 0x8E, 0x3E, 0x04, 0xDC, 0xDC, 0x3E, 0x06, 0xFA, 0x26, 0x3E), + (0x75, 0x03, 0x04), + (0xE7, 0x4B, 0x00, 0x00, 0xBE, 0x4B, 0x8C, 0x20, 0x1A, 0xF0, 0x7D, 0x14, 0x7D, 0x14, 0x7D, 0x14, 0x7D, 0x14, 0xFF, 0x00, 0x32, 0x30, 0x73, 0x00, 0x00, 0xC8, 0x6A, 0xFF, 0x5A, 0x64, 0x38, 0x88, 0x15, 0xB1, 0x01, 0x01, 0x64, 0x01, 0x01, 0x7C, 0xFF, 0x1A, 0x51), + (0xE1, 0x0C, 0x0C), + (0xEA, 0x15, 0x00, 0x01), + (0xC8, 0x00, 0x00, 0x04, 0x08, 0x10, 0x00, 0x1F, 0x01, 0x39, 0x3E, 0x00, 0x78, 0x06, 0xE2, 0x02, 0x11, 0x33, 0x01, 0x7A, 0x0D, 0x21, 0xC4, 0x0B, 0x19, 0x08, 0x32, 0xA0, 0x08, 0x1A, 0x0A, 0xF3, 0x7F, 0x0E, 0xC5, 0xE8, 0x03, 0xFF), + (0xC9, 0x00, 0x00, 0x04, 0x08, 0x10, 0x00, 0x1F, 0x01, 0x39, 0x3E, 0x00, 0x78, 0x06, 0xE2, 0x02, 0x11, 0x33, 0x01, 0x7A, 0x0D, 0x21, 0xC4, 0x0B, 0x19, 0x08, 0x32, 0xA0, 0x08, 0x1A, 0x0A, 0xF3, 0x7F, 0x0E, 0xC5, 0xE8, 0x03, 0xFF), + (0x60, 0x71, 0x21, 0x00), + ], +) diff --git a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py index 100366b135..6259d85184 100644 --- a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py +++ b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py @@ -1,6 +1,7 @@ """Tests for mpi_dsi configuration validation.""" from collections.abc import Callable +import logging from pathlib import Path import pytest @@ -128,6 +129,32 @@ def test_configuration_success(set_core_config: SetCoreConfigCallable) -> None: CONFIG_SCHEMA(config) +def test_deprecated_model_warning( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """The deprecated M5Stack-Tab5-v2 alias warns and points at the replacement models.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-p4-evboard", KEY_VARIANT: VARIANT_ESP32P4}, + ) + + from esphome.components.mipi_dsi.display import CONFIG_SCHEMA + + with caplog.at_level(logging.WARNING): + CONFIG_SCHEMA({"id": "deprecated_display", "model": "M5Stack-Tab5-v2"}) + assert "M5STACK-TAB5-V2 is deprecated" in caplog.text + # The warning names the replacement models so users know what to switch to. + assert "M5STACK-TAB5-ST7123" in caplog.text + + # The replacement models validate without emitting a deprecation warning. + caplog.clear() + with caplog.at_level(logging.WARNING): + CONFIG_SCHEMA({"id": "st7123_display", "model": "M5Stack-Tab5-ST7123"}) + CONFIG_SCHEMA({"id": "st7121_display", "model": "M5Stack-Tab5-ST7121"}) + assert "deprecated" not in caplog.text + + def test_metadata_records_rotation(set_core_config: SetCoreConfigCallable) -> None: """A configured display rotation is recorded in the metadata. From 312f6f2049487571f9981d2094a71083e3c0c2e0 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 11 Jul 2026 15:48:11 +0200 Subject: [PATCH 0847/1815] [deep_sleep] feed watchdog in deep sleep (#17516) --- .../deep_sleep/deep_sleep_zephyr.cpp | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/esphome/components/deep_sleep/deep_sleep_zephyr.cpp b/esphome/components/deep_sleep/deep_sleep_zephyr.cpp index f77b73cd58..cadf7bf42d 100644 --- a/esphome/components/deep_sleep/deep_sleep_zephyr.cpp +++ b/esphome/components/deep_sleep/deep_sleep_zephyr.cpp @@ -1,13 +1,36 @@ #include "deep_sleep_component.h" #ifdef USE_ZEPHYR +#include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esphome/core/wake.h" #include +#include namespace esphome::deep_sleep { static const char *const TAG = "deep_sleep"; +// The Zephyr watchdog has a short window (2s, or 10s with Zigbee) and +// WDT_OPT_PAUSE_IN_SLEEP only pauses it during true hardware sleep — not while a +// radio thread (e.g. the Zigbee stack) keeps the CPU busy in k_sem_take(). Feed +// it at least this often while waiting so it does not reset the device. +static const uint32_t WDT_FEED_INTERVAL_MS = 1000; + +static bool wakeable_delay_feed_wdt(uint32_t ms) { + while (ms > 0) { + const uint32_t step = std::min(ms, WDT_FEED_INTERVAL_MS); + esphome::internal::wakeable_delay(step); + esphome::arch_feed_wdt(); + if (esphome::wake_request_take()) { + return true; + } + if (ms != UINT32_MAX) { + ms -= step; + } + } + return false; +} + optional DeepSleepComponent::get_run_duration_() const { return this->run_duration_; } void DeepSleepComponent::dump_config_platform_() {} @@ -15,8 +38,9 @@ void DeepSleepComponent::dump_config_platform_() {} bool DeepSleepComponent::prepare_to_sleep_() { return true; } void DeepSleepComponent::deep_sleep_() { + bool woke = false; if (this->sleep_duration_.has_value()) { - esphome::internal::wakeable_delay(static_cast(*this->sleep_duration_ / 1000)); + woke = wakeable_delay_feed_wdt(static_cast(*this->sleep_duration_ / 1000)); } else { #ifndef USE_ZIGBEE // the device can be woken up through one of the following signals: @@ -29,10 +53,9 @@ 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); + woke = wakeable_delay_feed_wdt(UINT32_MAX); #endif } - const bool woke = esphome::wake_request_take(); if (woke) { ESP_LOGD(TAG, "Woken up by another thread"); } else { From 5afe418a8eca5e252aa66c8d5545a76ca1a7bd93 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:32:45 -1000 Subject: [PATCH 0848/1815] Bump bundled esphome-device-builder to 1.4.3 (#17522) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index fadf3f0685..f09280a50e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.4.2 +RUN uv pip install --no-cache-dir esphome-device-builder==1.4.3 RUN \ platformio settings set enable_telemetry No \ From d89b4c0b5993e0936987a19e6376e48814b97b2d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Jul 2026 14:30:47 -1000 Subject: [PATCH 0849/1815] [core] Make config-hash independent of machine-local paths (#17523) --- esphome/core/__init__.py | 19 ++++++++++- esphome/yaml_util.py | 36 ++++++++++++++++---- tests/unit_tests/core/test_config.py | 42 +++++++++++++++++++++++ tests/unit_tests/test_main.py | 4 +-- tests/unit_tests/test_yaml_util.py | 51 ++++++++++++++++++++++++++++ 5 files changed, 143 insertions(+), 9 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index bfdd2de7c7..bf637d4c1f 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -8,6 +8,7 @@ import re from typing import TYPE_CHECKING, Any from esphome.const import ( + CONF_BUILD_PATH, CONF_COMMENT, CONF_ESPHOME, CONF_ETHERNET, @@ -731,12 +732,28 @@ class EsphomeCore: The hash is computed lazily and cached for performance. Uses sort_keys=True to ensure deterministic ordering. + + The hash must be reproducible across machines so the device builder + can compare a locally computed hash against the one a device + advertises. Machine-local data is kept out of the input: build_path + (which embeds ESPHOME_BUILD_PATH and OS path separators) is excluded, + and Path values are dumped relative to the config directory. """ if self._config_hash is None: from esphome import yaml_util from esphome.helpers import fnv1a_32bit_hash - config_str = yaml_util.dump(self.config, show_secrets=True, sort_keys=True) + config = dict(self.config) + if (esphome_conf := config.get(CONF_ESPHOME)) is not None: + esphome_conf = dict(esphome_conf) + esphome_conf.pop(CONF_BUILD_PATH, None) + config[CONF_ESPHOME] = esphome_conf + config_str = yaml_util.dump( + config, + show_secrets=True, + sort_keys=True, + relative_to=self.config_dir if self.config_path is not None else None, + ) self._config_hash = fnv1a_32bit_hash(config_str) return self._config_hash diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 0009cde551..c2db9b97ed 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -840,17 +840,22 @@ def _load_yaml_internal_with_type( loader.dispose() -def dump(dict_, show_secrets=False, sort_keys=False): - """Dump YAML to a string and remove null.""" +def dump(dict_, show_secrets=False, sort_keys=False, relative_to: Path | None = None): + """Dump YAML to a string and remove null. + + When ``relative_to`` is given, Path values are dumped relative to that + directory (POSIX form) so the output is machine independent. + """ if show_secrets: _SECRET_VALUES.clear() _SECRET_CACHE.clear() - # Per-call subclass so the redaction flag doesn't leak across calls. + # Per-call subclass so the flags don't leak across calls. # (``_SECRET_VALUES`` / ``_SECRET_CACHE`` remain module globals; YAML - # processing is single-threaded today, so this isolates only the flag.) + # processing is single-threaded today, so this isolates only the flags.) class _Dumper(ESPHomeDumper): _redact_sensitive = not show_secrets + _relative_to = relative_to return yaml.dump( dict_, @@ -1002,9 +1007,13 @@ def format_path(path: DocumentPath, current_obj: Any) -> str: class ESPHomeDumper(yaml.SafeDumper): - # Default for the base class; per-call subclass in ``dump()`` overrides. + # Defaults for the base class; per-call subclass in ``dump()`` overrides. # When True, ``represent_sensitive`` wraps values in ANSI conceal codes. _redact_sensitive: bool = False + # When set, ``represent_path`` dumps Path values relative to this + # directory (in POSIX form) so the output does not depend on where the + # config lives on the machine that produced it. + _relative_to: Path | None = None def represent_mapping(self, tag, mapping, flow_style=None): value = [] @@ -1040,6 +1049,21 @@ class ESPHomeDumper(yaml.SafeDumper): return self.represent_secret(value) return self.represent_scalar(tag="tag:yaml.org,2002:str", value=str(value)) + def represent_path(self, value: Path) -> yaml.ScalarNode: + if self._relative_to is not None: + # Normalize both sides lexically (no symlink resolution) so ".." + # segments do not defeat the prefix match, and walk up so files + # referenced outside the anchor directory stay relative too. A + # path that still cannot be relativized (e.g. a different drive) + # keeps its POSIX form so separators stay stable across OSes. + path = Path(os.path.normpath(value)) + with suppress(ValueError): + path = path.relative_to( + os.path.normpath(self._relative_to), walk_up=True + ) + return self.represent_stringify(path.as_posix()) + return self.represent_stringify(value) + def represent_sensitive(self, value: SensitiveStr) -> yaml.ScalarNode: # Only the redact-and-not-a-secret branch is unique to sensitive # values; otherwise let ``represent_stringify`` handle ``!secret`` @@ -1138,5 +1162,5 @@ ESPHomeDumper.add_multi_representer(Extend, ESPHomeDumper.represent_extend) ESPHomeDumper.add_multi_representer(Remove, ESPHomeDumper.represent_remove) ESPHomeDumper.add_multi_representer(core.ID, ESPHomeDumper.represent_id) ESPHomeDumper.add_multi_representer(uuid.UUID, ESPHomeDumper.represent_stringify) -ESPHomeDumper.add_multi_representer(Path, ESPHomeDumper.represent_stringify) +ESPHomeDumper.add_multi_representer(Path, ESPHomeDumper.represent_path) ESPHomeDumper.add_multi_representer(IncludeFile, ESPHomeDumper.represent_include_file) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 6fd9f4c22c..0362c40bce 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -1113,6 +1113,48 @@ def test_config_hash_different_for_different_configs() -> None: assert hash1 != hash2 +def test_config_hash_ignores_build_path() -> None: + """Test that config_hash does not depend on the build_path value. + + build_path embeds ESPHOME_BUILD_PATH and OS path separators, so it must + not make the hash differ between machines. + """ + CORE.reset() + CORE.config = {"esphome": {"name": "test", "build_path": "build\\test"}} + hash1 = CORE.config_hash + + CORE.reset() + CORE.config = {"esphome": {"name": "test", "build_path": "/build/test"}} + hash2 = CORE.config_hash + + assert hash1 == hash2 + + +def test_config_hash_same_for_different_config_dirs(tmp_path: Path) -> None: + """Test that Path values under the config dir hash the same everywhere. + + Simulates the same project checked out at two different locations; the + absolute paths differ but the layout relative to the config dir is the + same, so the hashes must match. + """ + dir1 = tmp_path / "machine_a" / "project" + dir2 = tmp_path / "machine_b" / "somewhere" / "else" + dir1.mkdir(parents=True) + dir2.mkdir(parents=True) + + CORE.reset() + CORE.config_path = dir1 / "device.yaml" + CORE.config = {"esphome": {"name": "test"}, "file": dir1 / "fonts" / "arial.ttf"} + hash1 = CORE.config_hash + + CORE.reset() + CORE.config_path = dir2 / "device.yaml" + CORE.config = {"esphome": {"name": "test"}, "file": dir2 / "fonts" / "arial.ttf"} + hash2 = CORE.config_hash + + assert hash1 == hash2 + + def test_make_app_name_cpp_no_mac_simple() -> None: """Test simple name without MAC suffix returns string literal.""" cpp_expr, global_decl, byte_len = make_app_name_cpp( diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 0442c1db16..9a9aafec43 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -167,9 +167,9 @@ def setup_core( CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} if tmp_path is not None: - CORE.config_path = str(tmp_path / f"{name}.yaml") + CORE.config_path = tmp_path / f"{name}.yaml" CORE.name = name - CORE.build_path = str(tmp_path / ".esphome" / "build" / name) + CORE.build_path = tmp_path / ".esphome" / "build" / name @pytest.fixture diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index fa1c0fcce2..5c38fce105 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -1349,6 +1349,57 @@ def test_sensitive_str__is_a_str_subclass() -> None: assert value == "hunter2" +def test_dump_path_without_relative_to_is_unchanged() -> None: + """Test that Path values dump as str(path) when relative_to is not given.""" + path = Path("some") / "dir" / "file.ttf" + output = yaml_util.dump({"file": path}) + assert output.strip() == f"file: {path}" + + +def test_dump_path_relative_to_anchor_dir() -> None: + """Test that Path values under relative_to dump as relative POSIX paths.""" + anchor = Path("/config/esphome").absolute() + data = {"file": anchor / "fonts" / "arial.ttf"} + output = yaml_util.dump(data, relative_to=anchor) + assert output.strip() == "file: fonts/arial.ttf" + + +def test_dump_path_outside_anchor_dir_walks_up() -> None: + """Test that Path values outside relative_to walk up with ".." segments.""" + anchor = Path("/config/esphome").absolute() + outside = Path("/config/fonts/file.ttf").absolute() + output = yaml_util.dump({"file": outside}, relative_to=anchor) + assert output.strip() == "file: ../fonts/file.ttf" + + +def test_dump_path_with_dotdot_segments_is_normalized() -> None: + """Test that ".." segments do not defeat relativization. + + A path like /config/other/../esphome/fonts/x.ttf is under the anchor + once normalized, so it must dump as a plain relative path. + """ + anchor = Path("/config/esphome").absolute() + path = Path("/config/other/../esphome/fonts/x.ttf").absolute() + output = yaml_util.dump({"file": path}, relative_to=anchor) + assert output.strip() == "file: fonts/x.ttf" + + +def test_dump_path_dotdot_reference_outside_anchor() -> None: + """Test the relative_config_path("../...") shape stays relative.""" + anchor = Path("/config/esphome").absolute() + path = anchor / ".." / "shared" / "font.ttf" + output = yaml_util.dump({"file": path}, relative_to=anchor) + assert output.strip() == "file: ../shared/font.ttf" + + +def test_dump_relative_to_does_not_leak_between_calls() -> None: + """Test that the relative_to flag is scoped to a single dump call.""" + anchor = Path("/config/esphome").absolute() + path = anchor / "fonts" / "arial.ttf" + assert "fonts/arial.ttf" in yaml_util.dump({"file": path}, relative_to=anchor) + assert yaml_util.dump({"file": path}).strip() == f"file: {path}" + + def test_dump__redacts_sensitive_str_by_default() -> None: out = yaml_util.dump({"password": SensitiveStr("hunter2")}) assert "\\033[8mhunter2\\033[28m" in out From 665e788cc9c040feaf649ae107ab2a37db5eb2e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Jul 2026 14:31:01 -1000 Subject: [PATCH 0850/1815] [mdns] Fix missing device info TXT records when native API is not enabled (#17520) --- esphome/components/mdns/mdns_component.cpp | 27 +++++++++++++------ esphome/components/mdns/mdns_component.h | 15 ++++++++--- esphome/components/mdns/mdns_host.cpp | 2 +- .../mdns/test-fallback.esp32-idf.yaml | 7 +++++ .../mdns/test-webserver-no-api.esp32-idf.yaml | 9 +++++++ 5 files changed, 48 insertions(+), 12 deletions(-) create mode 100644 tests/components/mdns/test-fallback.esp32-idf.yaml create mode 100644 tests/components/mdns/test-webserver-no-api.esp32-idf.yaml diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 02b825605c..bb4271a6ca 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -47,7 +47,7 @@ void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_regi auto &services = services_storage; #endif -#ifdef USE_API +#ifdef USE_MDNS_DEVICE_INFO_TXT #ifdef USE_MDNS_STORE_SERVICES get_mac_address_into_buffer(this->mac_address_); char *mac_ptr = this->mac_address_; @@ -70,17 +70,20 @@ void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_regi platform_register(this, services); } -void MDNSComponent::compile_records_(StaticVector &services, char *mac_address_buf, - char *config_hash_buf) { +void MDNSComponent::compile_records_(StaticVector &services, + const char *mac_address_buf, const char *config_hash_buf) { // IMPORTANT: The #ifdef blocks below must match COMPONENTS_WITH_MDNS_SERVICES // in mdns/__init__.py. If you add a new service here, update both locations. +#ifdef USE_MDNS_DEVICE_INFO_TXT + MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version"); + MDNS_STATIC_CONST_CHAR(TXT_MAC, "mac"); + MDNS_STATIC_CONST_CHAR(TXT_CONFIG_HASH, "config_hash"); +#endif + #ifdef USE_API MDNS_STATIC_CONST_CHAR(SERVICE_ESPHOMELIB, "_esphomelib"); MDNS_STATIC_CONST_CHAR(TXT_FRIENDLY_NAME, "friendly_name"); - MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version"); - MDNS_STATIC_CONST_CHAR(TXT_CONFIG_HASH, "config_hash"); - MDNS_STATIC_CONST_CHAR(TXT_MAC, "mac"); MDNS_STATIC_CONST_CHAR(TXT_PLATFORM, "platform"); MDNS_STATIC_CONST_CHAR(TXT_BOARD, "board"); MDNS_STATIC_CONST_CHAR(TXT_NETWORK, "network"); @@ -212,12 +215,18 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_WEBSERVER_PORT; }; +#ifndef USE_API + // Without the native API there is no _esphomelib service, so publish the + // device info here for the device builder to discover. + web_service.txt_records = {{MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)}, + {MDNS_STR(TXT_MAC), MDNS_STR(mac_address_buf)}, + {MDNS_STR(TXT_CONFIG_HASH), MDNS_STR(config_hash_buf)}}; +#endif #endif #if !defined(USE_API) && !defined(USE_PROMETHEUS) && !defined(USE_SENDSPIN) && !defined(USE_WEBSERVER) && \ !defined(USE_MDNS_EXTRA_SERVICES) MDNS_STATIC_CONST_CHAR(SERVICE_HTTP, "_http"); - MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version"); // Publish "http" service if not using native API or any other services // This is just to have *some* mDNS service so that .local resolution works @@ -225,7 +234,9 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_WEBSERVER_PORT; }; - fallback_service.txt_records = {{MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)}}; + fallback_service.txt_records = {{MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)}, + {MDNS_STR(TXT_MAC), MDNS_STR(mac_address_buf)}, + {MDNS_STR(TXT_CONFIG_HASH), MDNS_STR(config_hash_buf)}}; #endif } diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 9d525abc43..4f97e8cb99 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -22,6 +22,15 @@ #endif #endif +// Device info TXT records (version, mac, config_hash) are published on the _esphomelib service +// when the native API is enabled, otherwise on the _http service (web_server's or the fallback one). +// When neither applies (only prometheus, sendspin or user-defined services are configured), no +// device info records are published and the buffers below are not needed. +#if defined(USE_API) || defined(USE_WEBSERVER) || \ + (!defined(USE_PROMETHEUS) && !defined(USE_SENDSPIN) && !defined(USE_MDNS_EXTRA_SERVICES)) +#define USE_MDNS_DEVICE_INFO_TXT +#endif + namespace esphome::mdns { // Helper struct that identifies strings that may be stored in flash storage (similar to LogString) @@ -136,7 +145,7 @@ class MDNSComponent final : public Component StaticVector dynamic_txt_values_; #endif -#if defined(USE_API) && defined(USE_MDNS_STORE_SERVICES) +#if defined(USE_MDNS_DEVICE_INFO_TXT) && defined(USE_MDNS_STORE_SERVICES) /// Fixed buffer for MAC address (only needed when services are stored) char mac_address_[MAC_ADDRESS_BUFFER_SIZE]; /// Fixed buffer for config hash hex string (only needed when services are stored) @@ -149,8 +158,8 @@ class MDNSComponent final : public Component // 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, - char *config_hash_buf); + void compile_records_(StaticVector &services, const char *mac_address_buf, + const char *config_hash_buf); }; } // namespace esphome::mdns diff --git a/esphome/components/mdns/mdns_host.cpp b/esphome/components/mdns/mdns_host.cpp index 1e66a10df0..c5d849df26 100644 --- a/esphome/components/mdns/mdns_host.cpp +++ b/esphome/components/mdns/mdns_host.cpp @@ -12,7 +12,7 @@ namespace esphome::mdns { void MDNSComponent::setup() { #ifdef USE_MDNS_STORE_SERVICES -#ifdef USE_API +#ifdef USE_MDNS_DEVICE_INFO_TXT get_mac_address_into_buffer(this->mac_address_); char *mac_ptr = this->mac_address_; format_hex_to(this->config_hash_str_, App.get_config_hash()); diff --git a/tests/components/mdns/test-fallback.esp32-idf.yaml b/tests/components/mdns/test-fallback.esp32-idf.yaml new file mode 100644 index 0000000000..b51dbb443f --- /dev/null +++ b/tests/components/mdns/test-fallback.esp32-idf.yaml @@ -0,0 +1,7 @@ +# No api, web_server or extra services so the fallback _http service +# (with version, mac and config_hash TXT records) is compiled. +wifi: + ssid: MySSID + password: password1 + +mdns: diff --git a/tests/components/mdns/test-webserver-no-api.esp32-idf.yaml b/tests/components/mdns/test-webserver-no-api.esp32-idf.yaml new file mode 100644 index 0000000000..23f3abdeb2 --- /dev/null +++ b/tests/components/mdns/test-webserver-no-api.esp32-idf.yaml @@ -0,0 +1,9 @@ +# web_server without the native api so the version, mac and config_hash +# TXT records are attached to the web_server _http service. +wifi: + ssid: MySSID + password: password1 + +web_server: + +mdns: From 1e5cfe6b0f27ab1f8ad4a9524079b194eba48c14 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Jul 2026 14:31:14 -1000 Subject: [PATCH 0851/1815] [web_server] Fix unused function warning for json_state_str (#17524) --- esphome/components/web_server/web_server.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 3f4d598d48..3bba879823 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -57,7 +57,7 @@ namespace esphome::web_server { static const char *const TAG = "web_server"; // View a state LogString as a ProgmemStr so ArduinoJson serializes it PROGMEM-aware on ESP8266. -static ProgmemStr json_state_str(const LogString *s) { return reinterpret_cast(s); } +[[maybe_unused]] static ProgmemStr json_state_str(const LogString *s) { return reinterpret_cast(s); } // Parse URL and return match info // URL formats (disambiguated by HTTP method for 3-segment case): From a4650a23459297c29ebb5b14d191b9d4b438ebc6 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:31:39 +0200 Subject: [PATCH 0852/1815] [zigbee] Fix merge endpoint (#17511) --- esphome/components/zigbee/zigbee_ep_esp32.py | 108 +++++++++++-------- tests/components/zigbee/common_esp32.yaml | 1 + 2 files changed, 64 insertions(+), 45 deletions(-) diff --git a/esphome/components/zigbee/zigbee_ep_esp32.py b/esphome/components/zigbee/zigbee_ep_esp32.py index ca96e4364f..2ed3dddb67 100644 --- a/esphome/components/zigbee/zigbee_ep_esp32.py +++ b/esphome/components/zigbee/zigbee_ep_esp32.py @@ -94,66 +94,73 @@ def get_next_ep_num(eps: list[int]) -> int: return ep_num -def merge_endpoint( +def compare_clusters( existing_ep: dict[str, Any], - ep_num: int | None, ep: dict[str, Any], - use_type: bool | None, - skip_error: bool, -) -> bool: - add = True +) -> tuple[str | int, str] | None: existing_clusters = [(cl[CONF_ID], cl[ROLE]) for cl in existing_ep[CONF_CLUSTERS]] for cl in [(cl[CONF_ID], cl[ROLE]) for cl in ep[CONF_CLUSTERS]]: if cl in existing_clusters: - if not skip_error: - raise cv.Invalid( - f"Endpoint {ep_num} has more than one cluster with cluster id {cl[0]} and role {cl[1]}." - ) - add = False - break - if not add: + return cl + return None + + +def merge_endpoints( + existing_ep: dict[str, Any], + ep: dict[str, Any], + use_type: bool | None, +) -> bool: + if compare_clusters(existing_ep, ep): return False - if ( - use_type - and existing_ep.get(CONF_USE_DEVICE_TYPE) - and ep.get(DEVICE_TYPE) != existing_ep.get(DEVICE_TYPE) - ): - if not skip_error: - raise cv.Invalid( - f"Endpoint {ep_num} has a conflicting device type {existing_ep.get(DEVICE_TYPE, 'CUSTOM_ATTR')} and use_type is set for both." - ) - return False - if use_type: - existing_ep[CONF_USE_DEVICE_TYPE] = use_type - if ep.get(DEVICE_TYPE): - existing_ep[DEVICE_TYPE] = ep[DEVICE_TYPE] - else: - existing_ep.pop(DEVICE_TYPE, None) - existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS]) - return True - if existing_ep.get(CONF_USE_DEVICE_TYPE): - existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS]) - return True if ( ep.get(DEVICE_TYPE) and existing_ep.get(DEVICE_TYPE) - and ep[DEVICE_TYPE] != existing_ep[DEVICE_TYPE] + and ep.get(DEVICE_TYPE) != existing_ep.get(DEVICE_TYPE) ): - if not skip_error: - raise cv.Invalid( - f"Endpoint {ep_num} has already a conflicting device type {existing_ep[DEVICE_TYPE]} and use_type is not set for both." - ) return False + if ( + ep.get(DEVICE_TYPE) + and not existing_ep.get(DEVICE_TYPE) + and existing_ep.get(CONF_USE_DEVICE_TYPE) + ): + return False + if existing_ep.get(DEVICE_TYPE) and not ep.get(DEVICE_TYPE) and use_type: + return False + if use_type: + existing_ep[CONF_USE_DEVICE_TYPE] = use_type if ep.get(DEVICE_TYPE): existing_ep[DEVICE_TYPE] = ep[DEVICE_TYPE] existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS]) return True +def validate_endpoints(ep_dict: dict[int, dict]) -> None: + for num, ep in ep_dict.items(): + types_dict = ep.get(CONF_USE_DEVICE_TYPE) + if not types_dict: + continue + if len(types_dict) == 1: + ep[DEVICE_TYPE] = list(types_dict.keys())[0] + del ep[CONF_USE_DEVICE_TYPE] + continue + types_list = [t[0] for t in types_dict.items() if t[1]] + if len(types_list) > 1: + raise cv.Invalid( + f"There is more than one component with endpoint: {num} and {CONF_USE_DEVICE_TYPE}: True" + ) + if not types_list: + raise cv.Invalid( + f"Multiple device types on endpoint: {num}. Set {CONF_USE_DEVICE_TYPE}: True on one component." + ) + ep[DEVICE_TYPE] = types_list[0] + del ep[CONF_USE_DEVICE_TYPE] + + def create_ep(router: bool) -> None: zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) ep_dict: dict[int, dict] = zb_data.setdefault(KEY_ZIGBEE_EP, {}) ep_list: list[dict] = zb_data.setdefault(KEY_ZIGBEE_EP_NO_NUM, []) + validate_endpoints(ep_dict) # create dummy endpoint if list is empty if not ep_dict and not ep_list: ep_type = "CUSTOM_ATTR" @@ -166,9 +173,7 @@ def create_ep(router: bool) -> None: for ep in ep_list: added = False for existing_ep in ep_list_new: - if merge_endpoint( - existing_ep, None, ep, ep.get(CONF_USE_DEVICE_TYPE), True - ): + if merge_endpoints(existing_ep, ep, ep.get(CONF_USE_DEVICE_TYPE)): added = True break if not added: @@ -191,6 +196,8 @@ def create_ep(router: bool) -> None: def add_ep(ep: dict[str, Any], ep_num: int | None, use_type: bool | None) -> None: zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) + if use_type is False: + ep.pop(DEVICE_TYPE, None) if ep_num is None: if use_type: ep[CONF_USE_DEVICE_TYPE] = use_type @@ -201,8 +208,19 @@ def add_ep(ep: dict[str, Any], ep_num: int | None, use_type: bool | None) -> Non if ep_num in ep_dict: # check if the existing endpoint has same clusters existing_ep = ep_dict[ep_num] - merge_endpoint(existing_ep, ep_num, ep, use_type, False) + if cl := compare_clusters( + existing_ep, + ep, + ): + raise cv.Invalid( + f"Endpoint {ep_num} has more than one cluster with cluster id {cl[0]} and role {cl[1]}." + ) + if ep.get(DEVICE_TYPE) or use_type: + types_dict = existing_ep.setdefault(CONF_USE_DEVICE_TYPE, {}) + if not types_dict.get(ep.get(DEVICE_TYPE)) or use_type: + types_dict[ep.get(DEVICE_TYPE)] = use_type + existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS]) else: - if use_type is not None: - ep[CONF_USE_DEVICE_TYPE] = use_type + if use_type or ep.get(DEVICE_TYPE): + ep[CONF_USE_DEVICE_TYPE] = {ep.get(DEVICE_TYPE): use_type} ep_dict[ep_num] = ep diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml index 8e00e4471e..6cac9c9e2a 100644 --- a/tests/components/zigbee/common_esp32.yaml +++ b/tests/components/zigbee/common_esp32.yaml @@ -5,6 +5,7 @@ binary_sensor: - platform: template name: "Garage Door Open 10" report: "default" + use_device_type: false - platform: template name: "Garage Door Open 12" report: "force" From 9ba2cbbfdd99c8611fe46bb579409b7f34f5b6a8 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:32:01 +0200 Subject: [PATCH 0853/1815] [zigbee] prevent task watchdog trigger with large configs. (#17506) --- .../zigbee/zigbee_attribute_esp32.cpp | 19 --------- .../zigbee/zigbee_attribute_esp32.h | 1 - esphome/components/zigbee/zigbee_esp32.cpp | 42 +++++++++---------- esphome/components/zigbee/zigbee_esp32.h | 2 +- 4 files changed, 22 insertions(+), 42 deletions(-) diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.cpp b/esphome/components/zigbee/zigbee_attribute_esp32.cpp index c6f2aa0af6..d7176e6ca5 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.cpp +++ b/esphome/components/zigbee/zigbee_attribute_esp32.cpp @@ -50,25 +50,6 @@ void ZigbeeAttribute::report_(bool has_lock) { } } -void ZigbeeAttribute::setup_reporting() { - ezb_zcl_reporting_info_t reporting_info = ezb_zcl_reporting_info_find( - this->endpoint_id_, this->cluster_id_, this->role_, this->attr_id_, EZB_ZCL_STD_MANUF_CODE); - if (reporting_info == EZB_ZCL_INVALID_REPORTING_INFO) { - ESP_LOGD(TAG, "Could not find reporting info for attribute 0x%04X in cluster 0x%04X in endpoint %u", this->attr_id_, - this->cluster_id_, this->endpoint_id_); - this->report_enabled = false; - this->force_report_ = false; - } else { - ESP_LOGD(TAG, "Found reporting info for attr 0x%04X in cluster 0x%04X", this->attr_id_, this->cluster_id_); - ezb_zcl_attr_variable_t delta = {.u64 = 0}; - ezb_zcl_reporting_info_update_default_interval(reporting_info, 0, 65000); - ezb_zcl_reporting_info_update(reporting_info, 0, 65000, &delta); - if (ezb_zcl_reporting_start_attr_report(reporting_info) != EZB_ERR_NONE) { - ESP_LOGE(TAG, "Could not start reporting for attribute"); - } - } -} - void ZigbeeAttribute::set_report(ZigbeeReportT report) { this->report_enabled = true; if (report == ZigbeeReportT::ZIGBEE_REPORT_FORCE) { diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.h b/esphome/components/zigbee/zigbee_attribute_esp32.h index b5afb57910..e5f8c8b1cf 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.h +++ b/esphome/components/zigbee/zigbee_attribute_esp32.h @@ -42,7 +42,6 @@ class ZigbeeAttribute final : public Component { scale_(scale) {} void loop() override; template void add_attr(T value); - void setup_reporting(); template void set_attr(const T &value); uint8_t attr_type() { return attr_type_; } void set_report(ZigbeeReportT report); diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 03457312be..3e0f6cd745 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -53,11 +53,7 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { switch (signal_type) { case EZB_ZDO_SIGNAL_SKIP_STARTUP: ESP_LOGD(TAG, "Zigbee stack initialized"); - if (ezb_bdb_is_factory_new()) { - global_zigbee->defer([]() { global_zigbee->setup_reporting(); }); - } else { - ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_INITIALIZATION); - } + ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_INITIALIZATION); break; case EZB_BDB_SIGNAL_DEVICE_FIRST_START: case EZB_BDB_SIGNAL_DEVICE_REBOOT: { @@ -133,12 +129,12 @@ static void zb_action_handler(ezb_zcl_core_action_callback_id_t callback_id, voi case EZB_ZCL_CORE_SET_ATTR_VALUE_CB_ID: zb_attribute_handler((ezb_zcl_set_attr_value_message_t *) message); break; -#ifdef ESPHOME_LOG_HAS_VERBOSE case EZB_ZCL_CORE_DEFAULT_RSP_CB_ID: { +#ifdef ESPHOME_LOG_HAS_VERBOSE ezb_zcl_cmd_default_rsp_message_t *default_rsp = (ezb_zcl_cmd_default_rsp_message_t *) message; ESP_LOGV(TAG, "Received ZCL Default Response: 0x%02x", default_rsp->in.status_code); - } break; #endif + } break; default: ESP_LOGD(TAG, "Receive Zigbee action(0x%04x) callback", static_cast(callback_id)); break; @@ -206,21 +202,30 @@ void ZigbeeComponent::update_basic_cluster_(ezb_af_ep_desc_t ep_desc) { ezb_af_endpoint_add_cluster_desc(ep_desc, cluster_desc); } -void ZigbeeComponent::setup_reporting() { - ESP_LOGD(TAG, "Setting up reporting for all attributes"); - esp_zigbee_lock_acquire(portMAX_DELAY); - for (auto &[_, attribute] : this->attributes_) { - attribute->setup_reporting(); +bool ZigbeeComponent::register_device() { + if (ezb_af_device_desc_register(this->dev_desc_) != EZB_ERR_NONE) { + ESP_LOGE(TAG, "Could not register the endpoint list"); + this->mark_failed(); + return false; } - ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_INITIALIZATION); - esp_zigbee_lock_release(); + return true; } static void ezb_task(void *pv_parameters) { + if (!global_zigbee->register_device()) { + vTaskDelete(NULL); + return; + } if (esp_zigbee_start(false) != ESP_OK) { ESP_LOGE(TAG, "Could not setup Zigbee"); + global_zigbee->mark_failed(); vTaskDelete(NULL); + return; // vTaskDelete(NULL) never returns, but keep intent explicit } + + // Increase priority to 5 to align with openthread or BLE + vTaskPrioritySet(NULL, 5); + esp_zigbee_launch_mainloop(); esp_zigbee_deinit(); @@ -274,12 +279,6 @@ void ZigbeeComponent::setup() { return; } - if (ezb_af_device_desc_register(this->dev_desc_) != EZB_ERR_NONE) { - ESP_LOGE(TAG, "Could not register the endpoint list"); - this->mark_failed(); - return; - } - ezb_zcl_core_action_handler_register(zb_action_handler); if (ezb_bdb_set_primary_channel_set(EZB_PRIMARY_CHANNEL_MASK) != ESP_OK) { @@ -298,7 +297,8 @@ void ZigbeeComponent::setup() { }; ezb_af_set_node_power_desc(&desc); - xTaskCreate(ezb_task, "Zigbee_main", 4096, NULL, 24, NULL); + // Start the Zigbee task with priority 1 to ensure main loop can still run even if Zigbee is busy + xTaskCreate(ezb_task, "Zigbee_main", 4096, NULL, 1, NULL); this->disable_loop(); // loop is only needed for processing events, so disable until we join a network } diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index 11289843a8..f4bafac294 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -42,7 +42,7 @@ class ZigbeeComponent final : public Component { void set_basic_cluster(const char *model, const char *manufacturer, uint8_t power_source); void add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role); void create_default_cluster(uint8_t endpoint_id, uint16_t device_id); - void setup_reporting(); + bool register_device(); template void add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, From 27b598c5aa12a916b947fcd102018e986a179df0 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:33:29 +1200 Subject: [PATCH 0854/1815] [core] Classify entity metadata visibility for the visual editor (#17503) --- esphome/components/binary_sensor/__init__.py | 4 +- esphome/components/button/__init__.py | 4 +- esphome/components/cover/__init__.py | 4 +- esphome/components/event/__init__.py | 4 +- esphome/components/number/__init__.py | 12 ++- esphome/components/sensor/__init__.py | 26 +++-- esphome/components/switch/__init__.py | 4 +- esphome/components/text_sensor/__init__.py | 4 +- esphome/components/update/__init__.py | 8 +- esphome/components/valve/__init__.py | 4 +- esphome/components/web_server/__init__.py | 4 +- esphome/config_validation.py | 102 ++++++++++++------- tests/unit_tests/test_config_validation.py | 70 ++++++++++++- 13 files changed, 193 insertions(+), 57 deletions(-) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index a9a09363fc..5800e0bd9e 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -448,7 +448,9 @@ _BINARY_SENSOR_SCHEMA = ( cv.Exclusive( CONF_TRIGGER_ON_INITIAL_STATE, CONF_TRIGGER_ON_INITIAL_STATE ): cv.boolean, - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, cv.Optional(CONF_FILTERS): validate_filters, cv.Optional(CONF_ON_PRESS): automation.validate_automation({}), cv.Optional(CONF_ON_RELEASE): automation.validate_automation({}), diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index dd4fde5705..a4245f43e6 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -50,7 +50,9 @@ _BUTTON_SCHEMA = ( .extend( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTButtonComponent), - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, cv.Optional(CONF_ON_PRESS): automation.validate_automation({}), } ) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index 839ca532e6..7639e15334 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -131,7 +131,9 @@ _COVER_SCHEMA = ( cv.Optional(CONF_MQTT_JSON_STATE_PAYLOAD): cv.All( cv.requires_component("mqtt"), cv.boolean ), - cv.Optional(CONF_DEVICE_CLASS): cv.one_of(*DEVICE_CLASSES, lower=True), + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): cv.one_of(*DEVICE_CLASSES, lower=True), cv.Optional(CONF_POSITION_COMMAND_TOPIC): cv.All( cv.requires_component("mqtt"), cv.subscribe_topic ), diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 4cab1bff9b..e205e4b910 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -50,7 +50,9 @@ _EVENT_SCHEMA = ( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTEventComponent), cv.GenerateID(): cv.declare_id(Event), - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, cv.Optional(CONF_ON_EVENT): automation.validate_automation({}), } ) diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index bcc609de65..ea0c2d77f6 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -212,9 +212,15 @@ _NUMBER_SCHEMA = ( }, cv.has_at_least_one_key(CONF_ABOVE, CONF_BELOW), ), - cv.Optional(CONF_UNIT_OF_MEASUREMENT): validate_unit_of_measurement, - cv.Optional(CONF_MODE, default="AUTO"): cv.enum(NUMBER_MODES, upper=True), - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, + cv.Optional( + CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED + ): validate_unit_of_measurement, + cv.Optional( + CONF_MODE, default="AUTO", visibility=cv.Visibility.ADVANCED + ): cv.enum(NUMBER_MODES, upper=True), + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, } ) ) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index da8a540d8d..6ad76046a1 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -321,13 +321,25 @@ _SENSOR_SCHEMA = ( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTSensorComponent), cv.GenerateID(): cv.declare_id(Sensor), - cv.Optional(CONF_UNIT_OF_MEASUREMENT): validate_unit_of_measurement, - cv.Optional(CONF_ACCURACY_DECIMALS): validate_accuracy_decimals, - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, - cv.Optional(CONF_STATE_CLASS): validate_state_class, - cv.Optional(CONF_ENTITY_CATEGORY): sensor_entity_category, - cv.Optional(CONF_FORCE_UPDATE, default=False): cv.boolean, - cv.Optional(CONF_EXPIRE_AFTER): cv.All( + cv.Optional( + CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED + ): validate_unit_of_measurement, + cv.Optional( + CONF_ACCURACY_DECIMALS, visibility=cv.Visibility.ADVANCED + ): validate_accuracy_decimals, + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, + cv.Optional( + CONF_STATE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_state_class, + cv.Optional( + CONF_ENTITY_CATEGORY, visibility=cv.Visibility.ADVANCED + ): sensor_entity_category, + cv.Optional( + CONF_FORCE_UPDATE, default=False, visibility=cv.Visibility.ADVANCED + ): cv.boolean, + cv.Optional(CONF_EXPIRE_AFTER, visibility=cv.Visibility.ADVANCED): cv.All( cv.requires_component("mqtt"), cv.Any(None, cv.positive_time_period_milliseconds), ), diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index 1108652e99..18b95113cc 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -78,7 +78,9 @@ _SWITCH_SCHEMA = ( cv.Optional(CONF_ON_STATE): automation.validate_automation({}), cv.Optional(CONF_ON_TURN_ON): automation.validate_automation({}), cv.Optional(CONF_ON_TURN_OFF): automation.validate_automation({}), - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, } ) ) diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 01a57cbaa1..a3f4999a8f 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -144,7 +144,9 @@ _TEXT_SENSOR_SCHEMA = ( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTTextSensor), cv.GenerateID(): cv.declare_id(TextSensor), - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, cv.Optional(CONF_FILTERS): validate_filters, cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}), diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index ddb471be18..18d333a5ef 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -54,7 +54,9 @@ _UPDATE_SCHEMA = ( .extend( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTUpdateComponent), - cv.Optional(CONF_DEVICE_CLASS): cv.one_of(*DEVICE_CLASSES, lower=True), + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): cv.one_of(*DEVICE_CLASSES, lower=True), cv.Optional(CONF_ON_UPDATE_AVAILABLE): automation.validate_automation( single=True ), @@ -136,7 +138,9 @@ async def to_code(config): automation.maybe_simple_id( { cv.GenerateID(): cv.use_id(UpdateEntity), - cv.Optional(CONF_FORCE_UPDATE, default=False): cv.templatable(cv.boolean), + cv.Optional( + CONF_FORCE_UPDATE, default=False, visibility=cv.Visibility.ADVANCED + ): cv.templatable(cv.boolean), } ), synchronous=True, diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index d82a9fdec2..7d98af402d 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -87,7 +87,9 @@ _VALVE_SCHEMA = ( { cv.GenerateID(): cv.declare_id(Valve), cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTValveComponent), - cv.Optional(CONF_DEVICE_CLASS): cv.one_of(*DEVICE_CLASSES, lower=True), + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): cv.one_of(*DEVICE_CLASSES, lower=True), cv.Optional(CONF_POSITION_COMMAND_TOPIC): cv.All( cv.requires_component("mqtt"), cv.subscribe_topic ), diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index f4e9eae763..d9fd27dbc2 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -172,7 +172,9 @@ sorting_group = { WEBSERVER_SORTING_SCHEMA = cv.Schema( { - cv.Optional(CONF_WEB_SERVER): cv.Schema( + # The per-entity web_server block is cosmetic dashboard ordering — + # mark the whole block advanced; the children inherit via the cascade. + cv.Optional(CONF_WEB_SERVER, visibility=cv.Visibility.ADVANCED): cv.Schema( { cv.OnlyWith(CONF_WEB_SERVER_ID, "web_server"): cv.use_id(WebServer), cv.Optional(CONF_SORTING_WEIGHT): cv.All( diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 45fd94fd1a..16f0a63aa0 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -292,10 +292,14 @@ class Visibility(StrEnum): the same way. ESPHome itself ignores the value at runtime; consumers downstream of the schema dump act on it. - A field with no ``visibility`` set (the default) renders on the - editor's main form. The two values below are points along a - single axis of "how prominently to surface this": + Three points along a single axis of "how prominently to surface + this", from least to most hidden: + - ``UI`` — always render on the editor's main form. Use to + promote an ``Optional`` that would otherwise fall through to + the advanced disclosure (see the default rule below): the + "headline" config a user reaches for first (e.g. a sensor's + ``name`` or its primary pin/address). - ``ADVANCED`` — render under the editor's "advanced settings" disclosure. Use for fields whose default is right for ~all users (e.g. ``update_interval`` on time platforms — 15 min is @@ -307,25 +311,35 @@ class Visibility(StrEnum): tweaks can break boot). The YAML escape hatch stays available for the rare power-user override. - The single-axis shape encodes "yaml-only is strictly stronger - than advanced" at the type level — there's no way to ask for - both at once, and no way to set a contradictory state like - "advanced=False, yaml_only=True". + Default when unset (``visibility=None``): resolved by the + consumer, not encoded on the marker. A schema-aware editor + treats an ``Optional`` with no setting as ``ADVANCED`` (most + optional knobs have sensible defaults and would clutter the + form), and a ``Required`` with no setting as ``UI`` (a required + field needs the user's attention). Pass an explicit value to + override either default — most commonly ``UI`` to keep a + high-value ``Optional`` on the main form. + + The single-axis shape encodes the strictness ladder + (``UI`` < ``ADVANCED`` < ``YAML_ONLY``) at the type level — + there's no way to set a contradictory state. Per-field; the dumper walks recursively into nested schemas - and emits each field's setting independently. Cascading - semantics — "a stricter parent makes its descendants at-least - as strict" — belong on the consumer side: the schema marker - is faithfully what the field author wrote, and a consumer that - cares about effective visibility walks the parent chain and - takes the strictest setting. ``YAML_ONLY`` is strictly stronger - than ``ADVANCED``, which is strictly stronger than no setting. - Inner fields can declare their own visibility; an inner + and emits each field's setting independently, omitting the key + when unset so the dump stays compact and the per-field default + is the consumer's to apply. Cascading semantics — "a stricter + parent makes its descendants at-least as strict" — belong on the + consumer side: the schema marker is faithfully what the field + author wrote, and a consumer that cares about effective + visibility walks the parent chain and takes the strictest + setting. Inner fields can declare their own visibility; an inner ``YAML_ONLY`` under an ``ADVANCED`` parent stays ``YAML_ONLY``, - and the consumer's cascade keeps siblings under the parent at - ``ADVANCED`` regardless of their own (less-strict) setting. + and the consumer's cascade keeps a ``UI`` sibling under an + ``ADVANCED`` parent at ``ADVANCED`` regardless of its own + (less-strict) setting. """ + UI = "ui" ADVANCED = "advanced" YAML_ONLY = "yaml_only" @@ -347,6 +361,9 @@ class Optional(vol.Optional): See :class:`Visibility` for the ``visibility`` kwarg — a UI hint for schema-driven editors that doesn't affect validation. + Left unset, an ``Optional`` is treated as ``Visibility.ADVANCED`` + by schema-aware editors; pass ``Visibility.UI`` to keep it on the + main form. """ def __init__( @@ -369,9 +386,11 @@ class Required(vol.Required): See :class:`Visibility` for the ``visibility`` kwarg — a UI hint for schema-driven editors that doesn't affect validation. - Required fields rarely need it (a required field by definition - needs the user's attention) but the kwarg is exposed for - symmetry so consumers can apply uniform logic across key markers. + Required fields rarely need it: left unset, a ``Required`` is + treated as on the main form (``Visibility.UI``) by schema-aware + editors, since a required field needs the user's attention. The + kwarg is exposed for symmetry so consumers can apply uniform + logic across key markers. """ def __init__( @@ -2274,16 +2293,25 @@ MQTT_COMPONENT_AVAILABILITY_SCHEMA = Schema( } ) +# Per-entity MQTT plumbing — integration metadata, never a primary UI field. MQTT_COMPONENT_SCHEMA = Schema( { - Optional(CONF_QOS): All(requires_component("mqtt"), mqtt_qos), - Optional(CONF_RETAIN): All(requires_component("mqtt"), boolean), - Optional(CONF_DISCOVERY): All(requires_component("mqtt"), boolean), - Optional(CONF_SUBSCRIBE_QOS): All(requires_component("mqtt"), mqtt_qos), - Optional(CONF_STATE_TOPIC): All( + Optional(CONF_QOS, visibility=Visibility.ADVANCED): All( + requires_component("mqtt"), mqtt_qos + ), + Optional(CONF_RETAIN, visibility=Visibility.ADVANCED): All( + requires_component("mqtt"), boolean + ), + Optional(CONF_DISCOVERY, visibility=Visibility.ADVANCED): All( + requires_component("mqtt"), boolean + ), + Optional(CONF_SUBSCRIBE_QOS, visibility=Visibility.ADVANCED): All( + requires_component("mqtt"), mqtt_qos + ), + Optional(CONF_STATE_TOPIC, visibility=Visibility.ADVANCED): All( requires_component("mqtt"), templatable(publish_topic) ), - Optional(CONF_AVAILABILITY): All( + Optional(CONF_AVAILABILITY, visibility=Visibility.ADVANCED): All( requires_component("mqtt"), Any(None, MQTT_COMPONENT_AVAILABILITY_SCHEMA) ), } @@ -2291,10 +2319,12 @@ MQTT_COMPONENT_SCHEMA = Schema( MQTT_COMMAND_COMPONENT_SCHEMA = MQTT_COMPONENT_SCHEMA.extend( { - Optional(CONF_COMMAND_TOPIC): All( + Optional(CONF_COMMAND_TOPIC, visibility=Visibility.ADVANCED): All( requires_component("mqtt"), templatable(subscribe_topic) ), - Optional(CONF_COMMAND_RETAIN): All(requires_component("mqtt"), boolean), + Optional(CONF_COMMAND_RETAIN, visibility=Visibility.ADVANCED): All( + requires_component("mqtt"), boolean + ), } ) @@ -2369,12 +2399,16 @@ def string_no_slash(value): ENTITY_BASE_SCHEMA = Schema( { - Optional(CONF_NAME): _validate_entity_name, - Optional(CONF_INTERNAL): boolean, - Optional(CONF_DISABLED_BY_DEFAULT, default=False): boolean, - Optional(CONF_ICON): icon, - Optional(CONF_ENTITY_CATEGORY): entity_category, - Optional(CONF_DEVICE_ID): sub_device_id, + # The name is every entity's headline field — keep it on the + # main form rather than letting it fall through to advanced. + Optional(CONF_NAME, visibility=Visibility.UI): _validate_entity_name, + Optional(CONF_INTERNAL, visibility=Visibility.ADVANCED): boolean, + Optional( + CONF_DISABLED_BY_DEFAULT, default=False, visibility=Visibility.ADVANCED + ): boolean, + Optional(CONF_ICON, visibility=Visibility.ADVANCED): icon, + Optional(CONF_ENTITY_CATEGORY, visibility=Visibility.ADVANCED): entity_category, + Optional(CONF_DEVICE_ID, visibility=Visibility.ADVANCED): sub_device_id, } ) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 6580564c65..17dfaad9b8 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1174,9 +1174,10 @@ def test_update_interval__never_passes_through() -> None: def test_optional_default_visibility_is_none() -> None: """An ``Optional`` with no ``visibility`` kwarg reports ``None``. - Consumers can read the attribute directly with plain attribute - access; absence (``None``) means "render on the editor's main - form." + The marker stays faithful to what the author wrote: ESPHome does + not encode the default on it. Resolving ``None`` to an effective + visibility is the consumer's job — a schema-aware editor treats an + unset ``Optional`` as ``ADVANCED`` (see :class:`Visibility`). """ o = cv.Optional("foo") assert o.visibility is None @@ -1194,6 +1195,17 @@ def test_optional_visibility_yaml_only() -> None: assert o.visibility is cv.Visibility.YAML_ONLY +def test_optional_visibility_ui() -> None: + """``visibility=Visibility.UI`` is recorded on the marker. + + ``UI`` promotes an ``Optional`` onto the editor's main form, + overriding the consumer's default of ``ADVANCED`` for unset + optionals. + """ + o = cv.Optional("foo", visibility=cv.Visibility.UI) + assert o.visibility is cv.Visibility.UI + + def test_visibility_str_values_match_dump_emission() -> None: """``Visibility`` is a ``StrEnum`` whose values are the literal strings the schema dumper emits. @@ -1203,6 +1215,7 @@ def test_visibility_str_values_match_dump_emission() -> None: field — pinning the on-the-wire spelling here keeps the dump contract stable. """ + assert str(cv.Visibility.UI) == "ui" assert str(cv.Visibility.ADVANCED) == "advanced" assert str(cv.Visibility.YAML_ONLY) == "yaml_only" @@ -1325,6 +1338,57 @@ def test_visibility_marker_is_per_field_no_mutation() -> None: assert inner_yaml_only.visibility is cv.Visibility.YAML_ONLY +def test_entity_metadata_visibility_hints() -> None: + """Entity and value-describing metadata is classified for visual editors. + + The headline ``name`` stays on the main form (``UI``); descriptive + metadata (device_class, unit, …), presentation options, and per-entity + integration plumbing (MQTT, web_server ordering) fall to the advanced + disclosure (``ADVANCED``). + """ + advanced = cv.Visibility.ADVANCED + + entity_base = {str(k): k for k in cv.ENTITY_BASE_SCHEMA.schema} + assert entity_base["name"].visibility is cv.Visibility.UI + for field in ( + "icon", + "internal", + "disabled_by_default", + "entity_category", + "device_id", + ): + assert entity_base[field].visibility is advanced, field + + mqtt = {str(k): k for k in cv.MQTT_COMPONENT_SCHEMA.schema} + for field in ("qos", "retain", "discovery", "state_topic", "availability"): + assert mqtt[field].visibility is advanced, field + + from esphome.components import binary_sensor, number, sensor + from esphome.components.web_server import WEBSERVER_SORTING_SCHEMA + + sensor_markers = {str(k): k for k in sensor.sensor_schema().schema} + for field in ( + "unit_of_measurement", + "accuracy_decimals", + "device_class", + "state_class", + "force_update", + ): + assert sensor_markers[field].visibility is advanced, field + + binary = {str(k): k for k in binary_sensor.binary_sensor_schema().schema} + assert binary["device_class"].visibility is advanced + + number_markers = {str(k): k for k in number.number_schema(number.Number).schema} + assert number_markers["mode"].visibility is advanced + assert number_markers["device_class"].visibility is advanced + + # The whole per-entity web_server block is advanced; children inherit + # via the consumer cascade, so only the parent key carries the hint. + web = {str(k): k for k in WEBSERVER_SORTING_SCHEMA.schema} + assert web["web_server"].visibility is advanced + + def _wrap_str(value: str) -> ESPHomeDataBase: """Wrap a raw string as an ESPHomeDataBase, mimicking a YAML-loaded value.""" return make_data_base(value) From bcac3ebe2b7942295f0e71419357f25a22d7f9e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Jul 2026 10:55:30 -1000 Subject: [PATCH 0855/1815] [api] Provision encryption keys over an encrypted zero-PSK noise connection (#17482) --- esphome/components/api/__init__.py | 7 +- esphome/components/api/api.proto | 5 + esphome/components/api/api_connection.cpp | 49 +++++++ esphome/components/api/api_connection.h | 5 + esphome/components/api/api_frame_helper.cpp | 2 + esphome/components/api/api_frame_helper.h | 11 ++ .../components/api/api_frame_helper_noise.cpp | 51 +++++-- .../components/api/api_frame_helper_noise.h | 8 ++ .../api/api_frame_helper_plaintext.cpp | 11 ++ .../api/api_frame_helper_plaintext.h | 9 ++ esphome/components/api/api_noise_context.h | 17 ++- esphome/components/api/api_pb2.cpp | 6 + esphome/components/api/api_pb2.h | 5 +- esphome/components/api/api_pb2_dump.cpp | 3 + esphome/components/mdns/mdns_component.cpp | 19 ++- .../test-dynamic-encryption.esp32-idf.yaml | 8 +- .../fixtures/api_zero_psk_provisioning.yaml | 6 + .../api_zero_psk_provisioning_plaintext.yaml | 6 + .../test_api_zero_psk_provisioning.py | 127 ++++++++++++++++++ 19 files changed, 334 insertions(+), 21 deletions(-) create mode 100644 tests/integration/fixtures/api_zero_psk_provisioning.yaml create mode 100644 tests/integration/fixtures/api_zero_psk_provisioning_plaintext.yaml create mode 100644 tests/integration/test_api_zero_psk_provisioning.py diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 64b025fee1..0719cee352 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -488,8 +488,11 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_NOISE_PSK_FROM_YAML") else: # No key provided, but encryption desired - # This will allow a plaintext client to provide a noise key, - # send it to the device, and then switch to noise. + # Until a key is set, the device accepts both Noise connections + # using the well-known all-zeros PSK (preferred: the key travels + # encrypted, protecting against passive sniffing) and plaintext + # connections (deprecated, remove after 2027.2.0) so a client can + # provide a noise key and the device then switches to noise only. # The key will be saved in flash and used for future connections # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 86707d9810..4b3df62ec4 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -310,6 +310,11 @@ message DeviceInfoResponse { // Serial proxy instance metadata repeated SerialProxyInfo serial_proxies = 25 [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"]; + + // Device is unprovisioned and accepts Noise handshakes with the well-known + // all-zeros PSK, so the api encryption key can be provisioned without being + // sent in plaintext (protects against passive sniffing, not active MITM) + bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"]; } message ListEntitiesRequest { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index dcb1478ec8..2efdf0bc03 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -198,6 +198,29 @@ APIConnection::~APIConnection() { #endif } +#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT) +void APIConnection::upgrade_helper_to_noise_() { + // The client opened with a Noise hello while this device has no encryption + // key set. Replace the plaintext helper with a Noise helper so the key can + // be provisioned over an encrypted channel: the noise context PSK is all + // zeros when unprovisioned, and NNpsk0 still runs a fresh ephemeral X25519 + // exchange, so a passive listener cannot read the session. A publicly known + // PSK authenticates nobody; this protects against sniffing only. + auto *plaintext = static_cast(this->helper_.get()); + uint8_t header[3]; + uint8_t header_len = plaintext->get_consumed_header(header); + auto *noise = new APINoiseFrameHelper(plaintext->release_socket_for_switch(), this->parent_->get_noise_ctx()); + // Carry over the peername-based client name (Hello has not arrived yet) + const char *name = plaintext->get_client_name(); + noise->set_client_name(name, strlen(name)); + this->helper_.reset(noise); // destroys the plaintext helper + APIError err = noise->init_from_handoff(header, header_len); + if (err != APIError::OK) { + this->fatal_error_with_log_(LOG_STR("Noise handoff failed"), err); + } +} +#endif // USE_API_NOISE && USE_API_PLAINTEXT + void APIConnection::destroy_active_iterator_() { switch (this->active_iterator_) { case ActiveIterator::LIST_ENTITIES: @@ -256,6 +279,15 @@ void APIConnection::loop() { // No more data available break; } else if (err != APIError::OK) { +#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT) + // Checked inside the error branch to keep the hot err == OK path + // free of it; this can only fire on the first bytes of a plaintext + // helper on an unprovisioned device + if (err == APIError::PROTOCOL_SWITCH_TO_NOISE) { + this->upgrade_helper_to_noise_(); + return; + } +#endif this->fatal_error_with_log_(LOG_STR("Reading failed"), err); return; } else { @@ -1860,6 +1892,12 @@ bool APIConnection::send_device_info_response_() { #endif #ifdef USE_API_NOISE resp.api_encryption_supported = true; +#ifndef USE_API_NOISE_PSK_FROM_YAML + // No key from YAML: while no key is set, the key can be provisioned over a + // zero-PSK Noise connection. Gated on the YAML define (not the plaintext + // one) so this advertisement survives the plaintext removal in 2027.2.0. + resp.api_encryption_provisionable = !this->parent_->get_noise_ctx().has_psk(); +#endif #endif #ifdef USE_DEVICES size_t device_index = 0; @@ -2037,10 +2075,21 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio } } else if (base64_decode(msg.key, msg.key_len, psk.data(), psk.size()) != psk.size()) { ESP_LOGW(TAG, "Invalid encryption key length"); + } else if (APINoiseContext::is_all_zeros(psk)) { + // Accepting the reserved provisioning PSK would report success without + // enabling encryption (or silently clear an existing key) + ESP_LOGW(TAG, "Rejecting all-zero encryption key"); } else if (!this->parent_->save_noise_psk(psk, true)) { ESP_LOGW(TAG, "Failed to save encryption key"); } else { resp.success = true; +#ifdef USE_API_PLAINTEXT + if (this->helper_->frame_footer_size() == 0) { + // Plaintext transport has no frame footer; Noise always has the MAC footer. + // Remove after 2027.2.0 together with plaintext support on keyless devices. + ESP_LOGW(TAG, "Key received over plaintext; deprecated, will be removed in 2027.2.0"); + } +#endif } return this->send_message(resp); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index d6d3e4d26b..144973fa9d 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -626,6 +626,11 @@ class APIConnection final : public APIServerConnectionBase { void destroy_active_iterator_(); void begin_iterator_(ActiveIterator type); void finalize_iterator_sync_(); +#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT) + // Swap the plaintext helper for a Noise helper after the client opened + // with a Noise hello on an unprovisioned device (zero-PSK provisioning). + void upgrade_helper_to_noise_(); +#endif #ifdef USE_CAMERA std::unique_ptr image_reader_; #endif diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 90353b6402..7425304766 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -97,6 +97,8 @@ const LogString *api_error_to_logstr(APIError err) { return LOG_STR("BAD_HANDSHAKE_ERROR_BYTE"); } #endif + // PROTOCOL_SWITCH_TO_NOISE is intercepted in APIConnection::loop() before + // any logging can happen, so it intentionally has no entry here. return LOG_STR("UNKNOWN"); } diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index f98eca8076..9cae6ba92e 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -88,6 +88,11 @@ enum class APIError : uint16_t { HANDSHAKESTATE_SPLIT_FAILED = 1020, BAD_HANDSHAKE_ERROR_BYTE = 1021, #endif +#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT) + // Not an error: an unprovisioned device received a Noise client hello on a + // plaintext connection; the caller must hand the socket off to a Noise helper. + PROTOCOL_SWITCH_TO_NOISE = 1023, +#endif }; const LogString *api_error_to_logstr(APIError err); @@ -200,6 +205,12 @@ class APIFrameHelper { // or track that they stopped early and retry without this check. // See Socket::ready() for details. bool is_socket_ready() const { return socket_ != nullptr && socket_->ready(); } +#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT) + // Move the socket out of this helper so a replacement helper can take it + // over (plaintext to Noise handoff on unprovisioned devices). The drained + // helper must be destroyed right after. + std::unique_ptr release_socket_for_switch() { return std::move(this->socket_); } +#endif // Release excess memory from internal buffers after initial sync void release_buffers() { // rx_buf_: Safe to clear only if no partial read in progress. diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 6dba64a7f8..225bac51a6 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -109,6 +109,40 @@ APIError APINoiseFrameHelper::init() { state_ = State::CLIENT_HELLO; return APIError::OK; } +#ifdef USE_API_PLAINTEXT +APIError APINoiseFrameHelper::init_from_handoff(const uint8_t *header, uint8_t header_len) { + APIError err = this->init(); + if (err != APIError::OK) { + return err; + } + // Seed the header bytes the plaintext helper consumed before detecting the + // Noise indicator; try_read_frame_ resumes from rx_header_buf_len_. + std::memcpy(this->rx_header_buf_, header, header_len); + this->rx_header_buf_len_ = header_len; + // Pump the handshake without gating on socket_->ready(): on LWIP the + // plaintext helper's partial read can drain rcvevent while the rest of the + // client hello sits in the lastdata cache, so ready() may report false even + // though data is available. + return this->pump_handshake_(); +} +#endif // USE_API_PLAINTEXT + +/// Drive the handshake state machine until DATA, WOULD_BLOCK, or a fatal +/// error. WOULD_BLOCK is not an error: reads stop naturally on EWOULDBLOCK +/// and resume on the next loop(). +APIError APINoiseFrameHelper::pump_handshake_() { + while (this->state_ != State::DATA) { + APIError err = this->state_action_(); + if (err == APIError::WOULD_BLOCK) { + break; + } + if (err != APIError::OK) { + return err; + } + } + return APIError::OK; +} + // Helper for handling handshake frame errors APIError APINoiseFrameHelper::handle_handshake_frame_error_(APIError aerr) { if (aerr == APIError::BAD_INDICATOR) { @@ -131,16 +165,13 @@ APIError APINoiseFrameHelper::handle_noise_error_(int err, const LogString *func /// Run through handshake messages (if in that phase) APIError APINoiseFrameHelper::loop() { - // Cache ready() outside the loop. On ESP8266 LWIP raw TCP, ready() returns false once - // the rx buffer is consumed. Re-checking each iteration would block handshake writes - // that must follow reads, deadlocking the handshake. state_action() will return - // WOULD_BLOCK when no more data is available to read. - bool socket_ready = this->socket_->ready(); - while (state_ != State::DATA && socket_ready) { - APIError err = state_action_(); - if (err == APIError::WOULD_BLOCK) { - break; - } + // Check ready() once, not per state transition. On ESP8266 LWIP raw TCP, + // ready() returns false once the rx buffer is consumed. Re-checking each + // iteration would block handshake writes that must follow reads, + // deadlocking the handshake. pump_handshake_() stops on WOULD_BLOCK when + // no more data is available to read. + if (state_ != State::DATA && this->socket_->ready()) { + APIError err = this->pump_handshake_(); if (err != APIError::OK) { return err; } diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index 0676eab78d..b0ba9fd01c 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -22,12 +22,20 @@ class APINoiseFrameHelper final : public APIFrameHelper { } ~APINoiseFrameHelper() override; APIError init() override; +#ifdef USE_API_PLAINTEXT + // Take over a connection whose first bytes were consumed by a plaintext + // helper on an unprovisioned device (see APIError::PROTOCOL_SWITCH_TO_NOISE). + // Seeds the already-read header bytes and pumps the handshake state machine + // until it would block. + APIError init_from_handoff(const uint8_t *header, uint8_t header_len); +#endif APIError loop() override; APIError read_packet(ReadPacketBuffer *buffer) override; APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) override; protected: + APIError pump_handshake_(); APIError state_action_(); APIError state_action_client_hello_(); APIError state_action_server_hello_(); diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index fa611a6e33..9359f568fb 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -89,6 +89,17 @@ APIError APIPlaintextFrameHelper::try_read_frame_() { // If this was the first read, validate the indicator byte if (rx_header_buf_pos_ == 0 && received > 0) { if (rx_header_buf_[0] != 0x00) { +#ifdef USE_API_NOISE + // Dual build (encryption supported but no key set): a 0x01 first byte + // is a Noise client hello. Hand the connection off to a Noise helper + // running the all-zeros provisioning PSK so the encryption key can be + // set without crossing the wire in plaintext. Preserve the bytes we + // already consumed; they are the start of the Noise 3-byte header. + if (rx_header_buf_[0] == 0x01) { + rx_header_buf_pos_ = static_cast(received); + return APIError::PROTOCOL_SWITCH_TO_NOISE; + } +#endif state_ = State::FAILED; HELPER_LOG("Bad indicator byte %u", rx_header_buf_[0]); return APIError::BAD_INDICATOR; diff --git a/esphome/components/api/api_frame_helper_plaintext.h b/esphome/components/api/api_frame_helper_plaintext.h index 8314754715..ea3f6d7280 100644 --- a/esphome/components/api/api_frame_helper_plaintext.h +++ b/esphome/components/api/api_frame_helper_plaintext.h @@ -23,6 +23,15 @@ class APIPlaintextFrameHelper final : public APIFrameHelper { APIError read_packet(ReadPacketBuffer *buffer) override; APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) override; +#ifdef USE_API_NOISE + // After try_read_frame_ returned PROTOCOL_SWITCH_TO_NOISE: copy out the + // header bytes already consumed from the socket (at most 3, the size of the + // Noise fixed header) so the replacement Noise helper can be seeded with them. + uint8_t get_consumed_header(uint8_t out[3]) const { + memcpy(out, this->rx_header_buf_, this->rx_header_buf_pos_); + return this->rx_header_buf_pos_; + } +#endif protected: APIError try_read_frame_(); diff --git a/esphome/components/api/api_noise_context.h b/esphome/components/api/api_noise_context.h index b5f7016689..44484ffa2c 100644 --- a/esphome/components/api/api_noise_context.h +++ b/esphome/components/api/api_noise_context.h @@ -10,13 +10,20 @@ using psk_t = std::array; class APINoiseContext { public: + // The all-zeros PSK is reserved: it marks the device as unprovisioned and + // doubles as the well-known provisioning PSK that unprovisioned devices + // accept for Noise handshakes (passive-sniffing protection only, no + // authentication). It is never a valid real key. + static bool is_all_zeros(const psk_t &psk) { + uint8_t acc = 0; + for (uint8_t b : psk) { + acc |= b; + } + return acc == 0; + } void set_psk(psk_t psk) { this->psk_ = psk; - bool has_psk = false; - for (auto i : psk) { - has_psk |= i; - } - this->has_psk_ = has_psk; + this->has_psk_ = !is_all_zeros(psk); } const psk_t &get_psk() const { return this->psk_; } bool has_psk() const { return this->has_psk_; } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index de6ae4751e..190bd32425 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -170,6 +170,9 @@ uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_ for (const auto &it : this->serial_proxies) { ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 25, it); } +#endif +#ifdef USE_API_NOISE + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, this->api_encryption_provisionable); #endif return pos; } @@ -232,6 +235,9 @@ uint32_t DeviceInfoResponse::calculate_size() const { for (const auto &it : this->serial_proxies) { size += ProtoSize::calc_message_force(2, it.calculate_size()); } +#endif +#ifdef USE_API_NOISE + size += ProtoSize::calc_bool(2, this->api_encryption_provisionable); #endif return size; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index d268a40c56..4d5866da0b 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -533,7 +533,7 @@ class SerialProxyInfo final : public ProtoMessage { class DeviceInfoResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 10; - static constexpr uint16_t ESTIMATED_SIZE = 309; + static constexpr uint16_t ESTIMATED_SIZE = 312; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("device_info_response"); } #endif @@ -588,6 +588,9 @@ class DeviceInfoResponse final : public ProtoMessage { #endif #ifdef USE_SERIAL_PROXY std::array serial_proxies{}; +#endif +#ifdef USE_API_NOISE + bool api_encryption_provisionable{false}; #endif uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 3a1ceba95f..09570b09e4 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -982,6 +982,9 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const { it.dump_to(out); out.append("\n"); } +#endif +#ifdef USE_API_NOISE + dump_field(out, ESPHOME_PSTR("api_encryption_provisionable"), this->api_encryption_provisionable); #endif return out.c_str(); } diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index bb4271a6ca..fa39e86ed0 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -110,7 +110,13 @@ void MDNSComponent::compile_records_(StaticVectorget_noise_ctx().has_psk(); txt_count++; // api_encryption or api_encryption_supported +#ifndef USE_API_NOISE_PSK_FROM_YAML + if (!api_has_psk) { + txt_count++; // api_provisioning + } +#endif #endif #ifdef ESPHOME_PROJECT_NAME txt_count += 2; // project_name and project_version @@ -166,9 +172,18 @@ void MDNSComponent::compile_records_(StaticVectorget_noise_ctx().has_psk(); - const char *encryption_key = has_psk ? TXT_API_ENCRYPTION : TXT_API_ENCRYPTION_SUPPORTED; + const char *encryption_key = api_has_psk ? TXT_API_ENCRYPTION : TXT_API_ENCRYPTION_SUPPORTED; txt_records.push_back({MDNS_STR(encryption_key), MDNS_STR(NOISE_ENCRYPTION)}); +#ifndef USE_API_NOISE_PSK_FROM_YAML + if (!api_has_psk) { + // Unprovisioned device without a YAML key: advertise that the encryption + // key can be provisioned over a zero-PSK Noise connection. Gated on the + // YAML define so this survives the plaintext removal in 2027.2.0. + MDNS_STATIC_CONST_CHAR(TXT_API_PROVISIONING, "api_provisioning"); + MDNS_STATIC_CONST_CHAR(VALUE_ZERO_PSK, "zero-psk"); + txt_records.push_back({MDNS_STR(TXT_API_PROVISIONING), MDNS_STR(VALUE_ZERO_PSK)}); + } +#endif #endif #ifdef ESPHOME_PROJECT_NAME diff --git a/tests/components/api/test-dynamic-encryption.esp32-idf.yaml b/tests/components/api/test-dynamic-encryption.esp32-idf.yaml index 504871716b..7563e3e9df 100644 --- a/tests/components/api/test-dynamic-encryption.esp32-idf.yaml +++ b/tests/components/api/test-dynamic-encryption.esp32-idf.yaml @@ -1,5 +1,11 @@ -<<: !include common-base.yaml +packages: + common: !include common-base.yaml wifi: ssid: MySSID password: password1 + +# Encryption enabled without a key: compiles both frame helpers so the key +# can be provisioned at runtime (zero-PSK noise or deprecated plaintext) +api: + encryption: diff --git a/tests/integration/fixtures/api_zero_psk_provisioning.yaml b/tests/integration/fixtures/api_zero_psk_provisioning.yaml new file mode 100644 index 0000000000..1bb2a43e71 --- /dev/null +++ b/tests/integration/fixtures/api_zero_psk_provisioning.yaml @@ -0,0 +1,6 @@ +esphome: + name: zero-psk-provision-test +host: +api: + encryption: +logger: diff --git a/tests/integration/fixtures/api_zero_psk_provisioning_plaintext.yaml b/tests/integration/fixtures/api_zero_psk_provisioning_plaintext.yaml new file mode 100644 index 0000000000..a798c038d7 --- /dev/null +++ b/tests/integration/fixtures/api_zero_psk_provisioning_plaintext.yaml @@ -0,0 +1,6 @@ +esphome: + name: zero-psk-plaintext-test +host: +api: + encryption: +logger: diff --git a/tests/integration/test_api_zero_psk_provisioning.py b/tests/integration/test_api_zero_psk_provisioning.py new file mode 100644 index 0000000000..bcea2a2471 --- /dev/null +++ b/tests/integration/test_api_zero_psk_provisioning.py @@ -0,0 +1,127 @@ +"""Integration tests for provisioning the encryption key over a zero-PSK connection. + +A device with `api: encryption:` but no key accepts Noise handshakes using the +well-known all-zeros PSK. The ephemeral X25519 exchange protects the key from +passive sniffing while it is provisioned; plaintext provisioning still works +but is deprecated. +""" + +from __future__ import annotations + +import asyncio +import base64 + +from aioesphomeapi import InvalidEncryptionKeyAPIError, RequiresEncryptionAPIError +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +# The well-known provisioning PSK: base64 of 32 zero bytes +ZERO_PSK = base64.b64encode(bytes(32)).decode() +# A real key to provision +NEW_KEY = base64.b64encode(b"n" * 32) +# Time for the device to activate a newly saved key (100ms timer plus margin) +KEY_ACTIVATION_DELAY = 0.5 + + +@pytest.fixture(autouse=True) +def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + """Keep host preferences per-test so every run starts unprovisioned.""" + monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs")) + + +@pytest.mark.asyncio +async def test_api_zero_psk_provisioning( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Exercise the reject paths, then provision a key over the zero-PSK channel.""" + async with run_compiled(yaml_config): + # --- Pre-provisioning reject paths (device state is unchanged) --- + + # A wrong (non-zero) PSK fails against the zero provisioning PSK + with pytest.raises(InvalidEncryptionKeyAPIError): + async with api_client_connected( + noise_psk=base64.b64encode(b"w" * 32).decode(), timeout=5 + ) as client: + await client.device_info() + + # A plaintext client and a zero-PSK client can be connected at the + # same time while the device is unprovisioned + async with ( + api_client_connected() as plaintext_client, + api_client_connected(noise_psk=ZERO_PSK) as noise_client, + ): + plaintext_info = await plaintext_client.device_info() + noise_info = await noise_client.device_info() + # Both transports advertise provisioning support so old and new + # clients can decide how to provision + assert plaintext_info.api_encryption_provisionable is True + assert noise_info.api_encryption_provisionable is True + + # The all-zeros key is reserved as the provisioning PSK and is + # rejected on both transports + zero_key = base64.b64encode(bytes(32)) + assert await noise_client.noise_encryption_set_key(zero_key) is False + assert await plaintext_client.noise_encryption_set_key(zero_key) is False + + # --- Provision over the zero-PSK channel --- + + # The unprovisioned device accepts the all-zeros PSK; the handshake's + # ephemeral-ephemeral DH encrypts everything that follows + async with api_client_connected(noise_psk=ZERO_PSK) as client: + device_info = await client.device_info() + assert device_info.name == "zero-psk-provision-test" + assert device_info.api_encryption_supported is True + assert device_info.api_encryption_provisionable is True + + assert await client.noise_encryption_set_key(NEW_KEY) is True + + # The device activates the new key shortly after responding + await asyncio.sleep(KEY_ACTIVATION_DELAY) + + # The new key now works, and the device is no longer provisionable + async with api_client_connected(noise_psk=NEW_KEY.decode()) as client: + device_info = await client.device_info() + assert device_info.name == "zero-psk-provision-test" + assert device_info.api_encryption_provisionable is False + + # The zero PSK no longer works + with pytest.raises(InvalidEncryptionKeyAPIError): + async with api_client_connected(noise_psk=ZERO_PSK, timeout=5) as client: + await client.device_info() + + # Plaintext no longer works + with pytest.raises(RequiresEncryptionAPIError): + async with api_client_connected(timeout=5) as client: + await client.device_info() + + +@pytest.mark.asyncio +async def test_api_zero_psk_provisioning_plaintext( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """The legacy plaintext provisioning path still works and warns.""" + log_lines: list[str] = [] + async with run_compiled(yaml_config, line_callback=log_lines.append): + async with api_client_connected() as client: + device_info = await client.device_info() + assert device_info.name == "zero-psk-plaintext-test" + + assert await client.noise_encryption_set_key(NEW_KEY) is True + + await asyncio.sleep(KEY_ACTIVATION_DELAY) + + # The deprecation warning was logged + assert any("deprecated" in line for line in log_lines) + + # The new key works; the zero PSK does not + async with api_client_connected(noise_psk=NEW_KEY.decode()) as client: + assert (await client.device_info()).name == "zero-psk-plaintext-test" + + with pytest.raises(InvalidEncryptionKeyAPIError): + async with api_client_connected(noise_psk=ZERO_PSK, timeout=5) as client: + await client.device_info() From a9591d7aac939794498a860f0c23ad2a317b240a Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Sun, 12 Jul 2026 15:58:17 -0500 Subject: [PATCH 0856/1815] [zwave_proxy] Fix parser gaps and harden frame and subscription handling (#17461) --- esphome/components/api/api_connection.cpp | 2 +- .../components/zwave_proxy/zwave_proxy.cpp | 125 ++++++++++++++---- esphome/components/zwave_proxy/zwave_proxy.h | 14 +- .../components/zwave_proxy/zwave_proxy.h | 2 +- 4 files changed, 109 insertions(+), 34 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 2efdf0bc03..880b7cc404 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1383,7 +1383,7 @@ void APIConnection::on_voice_assistant_set_configuration(const VoiceAssistantSet #ifdef USE_ZWAVE_PROXY void APIConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) { - zwave_proxy::global_zwave_proxy->send_frame(msg.data, msg.data_len); + zwave_proxy::global_zwave_proxy->send_frame(this, msg.data, msg.data_len); } void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) { diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index 8a24bd57d6..5f56861e6d 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -18,13 +18,22 @@ static const char *const TAG = "zwave_proxy"; static constexpr size_t ZWAVE_MAX_LOG_BYTES = 168; static constexpr uint8_t ZWAVE_COMMAND_GET_NETWORK_IDS = 0x20; -// GET_NETWORK_IDS response: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID][...] +// GET_NETWORK_IDS response: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID(1 or 2)][...] +// We only read the home ID, so the node ID (1 byte in 8-bit mode, 2 bytes in 16-bit mode) and +// anything after it are not required to be present static constexpr uint8_t ZWAVE_COMMAND_TYPE_RESPONSE = 0x01; // Response type field value -static constexpr uint8_t ZWAVE_MIN_GET_NETWORK_IDS_LENGTH = 9; // TYPE + CMD + HOME_ID(4) + NODE_ID + checksum +static constexpr uint8_t ZWAVE_MIN_GET_NETWORK_IDS_LENGTH = 7; // TYPE + CMD + HOME_ID(4) + checksum +static constexpr uint8_t ZWAVE_MIN_FRAME_LENGTH = 3; // TYPE + CMD + checksum (zero-payload frame) +static constexpr uint32_t ZWAVE_FRAME_TIMEOUT_MS = 1500; // Abandon a frame this long after its start (SOF) byte static constexpr uint32_t HOME_ID_TIMEOUT_MS = 100; // Timeout for waiting for home ID during setup static constexpr uint32_t RECONNECT_DELAY_MS = 500; // Delay between home ID query attempts after reconnect static constexpr uint8_t MAX_QUERY_RETRIES = 5; // Max attempts to query home ID after reconnect +static constexpr bool is_bootloader_menu_byte(uint8_t byte) { + // Bootloader menu output is printable ASCII plus CR/LF, ending with a NUL terminator + return byte == 0 || byte == '\r' || byte == '\n' || (byte >= 0x20 && byte <= 0x7E); +} + static uint8_t calculate_frame_checksum(const uint8_t *data, uint8_t length) { // Calculate Z-Wave frame checksum // XOR all bytes between SOF and checksum position (exclusive) @@ -74,6 +83,11 @@ bool ZWaveProxy::can_proceed() { const uint32_t now = App.get_loop_component_start_time(); if (now - this->setup_time_ > HOME_ID_TIMEOUT_MS) { ESP_LOGW(TAG, "Timeout reading Home ID during setup"); + // The modem may simply still be booting; keep querying from loop() using the same retry + // machinery as a reconnect. This adds no setup delay — clients are notified of the home ID + // via the HOME_ID_CHANGE message whenever it finally arrives. + this->reconnect_time_ = now; + this->query_retries_ = 0; return true; // Proceed anyway after timeout } @@ -98,7 +112,18 @@ void ZWaveProxy::loop() { } this->process_uart_(); - this->status_clear_warning(); + + // Abandon a stalled frame reception. The Z-Wave API specification requires a receiver to abort + // a data frame reception lasting more than 1500 ms after the SOF byte, without sending a NAK. + // Without this, the stale bytes would silently corrupt the next frame. Any SEND_* state was + // already resolved by response_handler_() above, so a state other than WAIT_START here always + // means we are mid-frame. + if (this->parsing_state_ != ZWAVE_PARSING_STATE_WAIT_START && + App.get_loop_component_start_time() - this->frame_start_time_ > ZWAVE_FRAME_TIMEOUT_MS) { + ESP_LOGW(TAG, "Timeout waiting for frame data; resetting parser"); + this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; + this->buffer_index_ = 0; + } } void ZWaveProxy::process_uart_slow_() { @@ -112,19 +137,24 @@ void ZWaveProxy::process_uart_slow_() { } if (this->parse_byte_(byte)) { // Check if this is a GET_NETWORK_IDS response frame - // Frame format: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID][...] + // Frame format: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID(1 or 2)][...] + // Bootloader output is excluded up front: a completed bootloader "frame" is menu text, so + // buffer_[1..3] would be meaningless (and possibly never written). Outside bootloader mode, + // the parser guarantees a completed frame starts with SOF, so buffer_[0] needs no check. // We verify: - // - buffer_[0]: Start of frame marker (0x01) - // - buffer_[1]: Length field must be >= 9 to contain all required data + // - buffer_[1]: Length field must be >= 7 so the frame contains the full home ID // - buffer_[2]: Command type (0x01 for response) // - buffer_[3]: Command ID (0x20 for GET_NETWORK_IDS) - if (this->buffer_[3] == ZWAVE_COMMAND_GET_NETWORK_IDS && this->buffer_[2] == ZWAVE_COMMAND_TYPE_RESPONSE && - this->buffer_[1] >= ZWAVE_MIN_GET_NETWORK_IDS_LENGTH && this->buffer_[0] == ZWAVE_FRAME_TYPE_START) { + if (!this->in_bootloader_ && this->buffer_[1] >= ZWAVE_MIN_GET_NETWORK_IDS_LENGTH && + this->buffer_[2] == ZWAVE_COMMAND_TYPE_RESPONSE && this->buffer_[3] == ZWAVE_COMMAND_GET_NETWORK_IDS) { // Store the 4-byte Home ID, which starts at offset 4, and notify connected clients if it changed // The frame parser has already validated the checksum and ensured all bytes are present if (this->set_home_id_(&this->buffer_[4])) { + char hex_buf[format_hex_pretty_size(ZWAVE_HOME_ID_SIZE)]; + ESP_LOGI(TAG, "Home ID: %s", format_hex_pretty_to(hex_buf, this->home_id_.data(), this->home_id_.size())); this->send_homeid_changed_msg_(); } + this->home_id_ready_ = true; } ESP_LOGV(TAG, "Sending to client: %s", YESNO(this->api_connection_ != nullptr)); if (this->api_connection_ != nullptr) { @@ -140,14 +170,19 @@ void ZWaveProxy::process_uart_slow_() { } } } while (this->available()); + // Reaching here means every read succeeded, so clear any earlier read-failure warning. + // (An early return on read failure skips this, leaving the warning visible until the + // next successful drain.) + this->status_clear_warning(); } void ZWaveProxy::dump_config() { char hex_buf[format_hex_pretty_size(ZWAVE_HOME_ID_SIZE)]; - ESP_LOGCONFIG(TAG, - "Z-Wave Proxy:\n" - " Home ID: %s", - format_hex_pretty_to(hex_buf, this->home_id_.data(), this->home_id_.size())); + ESP_LOGCONFIG( + TAG, + "Z-Wave Proxy:\n" + " Home ID: %s", + this->home_id_ready_ ? format_hex_pretty_to(hex_buf, this->home_id_.data(), this->home_id_.size()) : "unknown"); } void ZWaveProxy::api_connection_authenticated(api::APIConnection *conn) { @@ -160,10 +195,20 @@ void ZWaveProxy::api_connection_authenticated(api::APIConnection *conn) { void ZWaveProxy::zwave_proxy_request(api::APIConnection *api_connection, api::enums::ZWaveProxyRequestType type) { switch (type) { case api::enums::ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE: - if (this->api_connection_ != nullptr) { - ESP_LOGE(TAG, "Only one API subscription is allowed at a time"); + if (this->api_connection_ == api_connection) { + ESP_LOGV(TAG, "API connection is already subscribed"); return; } + if (this->api_connection_ != nullptr) { + // A living subscriber keeps exclusive access. Its connection may be dead without + // loop() having noticed yet (e.g. the client crashed and reconnected quickly); + // in that case let the new client take over instead of locking it out. + if (this->api_connection_->is_connection_setup()) { + ESP_LOGE(TAG, "Only one API subscription is allowed at a time"); + return; + } + ESP_LOGW(TAG, "Previous subscriber disconnected; taking over subscription"); + } this->api_connection_ = api_connection; ESP_LOGV(TAG, "API connection is now subscribed"); break; @@ -222,6 +267,7 @@ void ZWaveProxy::retry_home_id_query_() { void ZWaveProxy::clear_home_id_() { static constexpr uint8_t ZERO_HOME_ID[ZWAVE_HOME_ID_SIZE] = {}; if (this->set_home_id_(ZERO_HOME_ID)) { + ESP_LOGV(TAG, "Home ID cleared"); this->send_homeid_changed_msg_(); } this->home_id_ready_ = false; @@ -237,13 +283,20 @@ bool ZWaveProxy::set_home_id_(const uint8_t *new_home_id) { return false; // No change } std::memcpy(this->home_id_.data(), new_home_id, this->home_id_.size()); - char hex_buf[format_hex_pretty_size(ZWAVE_HOME_ID_SIZE)]; - ESP_LOGI(TAG, "Home ID: %s", format_hex_pretty_to(hex_buf, this->home_id_.data(), this->home_id_.size())); - this->home_id_ready_ = true; return true; // Home ID was changed } -void ZWaveProxy::send_frame(const uint8_t *data, size_t length) { +void ZWaveProxy::send_frame(api::APIConnection *api_connection, const uint8_t *data, size_t length) { + // Only the subscribed client may talk to the Z-Wave module; a frame from any other + // (authenticated but unsubscribed) client would interleave with the subscriber's traffic + if (api_connection != this->api_connection_) { + ESP_LOGW(TAG, "Ignoring frame from unsubscribed client"); + return; + } + this->send_frame_(data, length); +} + +void ZWaveProxy::send_frame_(const uint8_t *data, size_t length) { // Safety: validate pointer before any access if (data == nullptr) { ESP_LOGE(TAG, "Null data pointer"); @@ -289,7 +342,7 @@ void ZWaveProxy::send_simple_command_(const uint8_t command_id) { // Where LENGTH=0x03 (3 bytes: TYPE + CMD + CHECKSUM) uint8_t cmd[] = {0x01, 0x03, 0x00, command_id, 0x00}; cmd[4] = calculate_frame_checksum(cmd, sizeof(cmd)); - this->send_frame(cmd, sizeof(cmd)); + this->send_frame_(cmd, sizeof(cmd)); } bool ZWaveProxy::parse_byte_(uint8_t byte) { @@ -300,9 +353,12 @@ bool ZWaveProxy::parse_byte_(uint8_t byte) { this->parse_start_(byte); break; case ZWAVE_PARSING_STATE_WAIT_LENGTH: - if (!byte) { + if (byte < ZWAVE_MIN_FRAME_LENGTH) { ESP_LOGW(TAG, "Invalid LENGTH: %u", byte); this->parsing_state_ = ZWAVE_PARSING_STATE_SEND_NAK; + // Send the NAK now; otherwise any bytes already buffered behind this one would be + // silently discarded by the SEND_NAK case below until the next loop() iteration + this->response_handler_(); return false; } ESP_LOGVV(TAG, "Received LENGTH: %u", byte); @@ -319,7 +375,9 @@ bool ZWaveProxy::parse_byte_(uint8_t byte) { case ZWAVE_PARSING_STATE_WAIT_COMMAND_ID: this->buffer_[this->buffer_index_++] = byte; ESP_LOGVV(TAG, "Received COMMAND ID: 0x%02X", byte); - this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_PAYLOAD; + // A zero-payload frame (LENGTH == 3) has its checksum immediately after the command ID + this->parsing_state_ = this->buffer_index_ >= this->end_frame_after_ ? ZWAVE_PARSING_STATE_WAIT_CHECKSUM + : ZWAVE_PARSING_STATE_WAIT_PAYLOAD; break; case ZWAVE_PARSING_STATE_WAIT_PAYLOAD: this->buffer_[this->buffer_index_++] = byte; @@ -347,12 +405,24 @@ bool ZWaveProxy::parse_byte_(uint8_t byte) { break; } case ZWAVE_PARSING_STATE_READ_BL_MENU: - if (this->buffer_index_ >= this->buffer_.size()) { + // This state is tentative (see parse_start_): bootloader mode is committed only when a + // plausible menu — printable text ending in a NUL terminator — completes. A byte that + // cannot be menu text means the 0x0D that started this state was not a menu after all, + // so re-parse that byte as a frame start; it may be the SOF/ACK/NAK of real traffic. + if (this->buffer_index_ >= this->buffer_.size() || !is_bootloader_menu_byte(byte)) { this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; + this->parse_start_(byte); break; } this->buffer_[this->buffer_index_++] = byte; if (!byte) { + if (!this->in_bootloader_) { + ESP_LOGD(TAG, "Entered bootloader mode"); + this->in_bootloader_ = true; + // Reset response deduplication: in bootloader mode, single-byte client writes (XMODEM + // ACK/NAK/CAN) are raw data and must never be suppressed as duplicate responses + this->last_response_ = 0; + } this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; frame_completed = true; } @@ -378,15 +448,16 @@ void ZWaveProxy::parse_start_(uint8_t byte) { ESP_LOGD(TAG, "Exited bootloader mode"); this->in_bootloader_ = false; } + this->frame_start_time_ = App.get_loop_component_start_time(); this->buffer_[this->buffer_index_++] = byte; this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_LENGTH; return; case ZWAVE_FRAME_TYPE_BL_MENU: ESP_LOGV(TAG, "Received BL_MENU"); - if (!this->in_bootloader_) { - ESP_LOGD(TAG, "Entered bootloader mode"); - this->in_bootloader_ = true; - } + // Read the menu tentatively: a stray 0x0D can equally appear in garbled data after the + // parser loses frame alignment, so bootloader mode is only committed once a plausible + // menu completes (see READ_BL_MENU handling in parse_byte_) + this->frame_start_time_ = App.get_loop_component_start_time(); this->buffer_[this->buffer_index_++] = byte; this->parsing_state_ = ZWAVE_PARSING_STATE_READ_BL_MENU; return; @@ -403,7 +474,7 @@ void ZWaveProxy::parse_start_(uint8_t byte) { ESP_LOGV(TAG, "Received CAN"); break; default: - ESP_LOGW(TAG, "Unrecognized START: 0x%02X", byte); + ESP_LOGV(TAG, "Unrecognized START: 0x%02X", byte); return; } // Forward response (ACK/NAK/CAN) back to client for processing diff --git a/esphome/components/zwave_proxy/zwave_proxy.h b/esphome/components/zwave_proxy/zwave_proxy.h index ec52b15cd9..cb60139ef8 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.h +++ b/esphome/components/zwave_proxy/zwave_proxy.h @@ -68,13 +68,16 @@ class ZWaveProxy final : public uart::UARTDevice, public Component { return encode_uint32(this->home_id_[0], this->home_id_[1], this->home_id_[2], this->home_id_[3]); } - void send_frame(const uint8_t *data, size_t length); + // Send a frame from an API client to the Z-Wave module. Frames from any connection other + // than the currently subscribed one are ignored. + void send_frame(api::APIConnection *api_connection, const uint8_t *data, size_t length); protected: - bool set_home_id_(const uint8_t *new_home_id); // Store a new home ID. Returns true if it changed. - void clear_home_id_(); // Clear home ID and notify API clients - void on_connection_changed_(bool connected); // Handle modem connect/disconnect transitions - void retry_home_id_query_(); // Retry home ID query after reconnect + void send_frame_(const uint8_t *data, size_t length); // Write a frame to the Z-Wave module + bool set_home_id_(const uint8_t *new_home_id); // Store a new home ID. Returns true if it changed. + void clear_home_id_(); // Clear home ID and notify API clients + void on_connection_changed_(bool connected); // Handle modem connect/disconnect transitions + void retry_home_id_query_(); // Retry home ID query after reconnect void send_homeid_changed_msg_(api::APIConnection *conn = nullptr); void send_simple_command_(uint8_t command_id); bool parse_byte_(uint8_t byte); // Returns true if frame parsing was completed (a frame is ready in the buffer) @@ -114,6 +117,7 @@ class ZWaveProxy final : public uart::UARTDevice, public Component { api::APIConnection *api_connection_{nullptr}; // Current subscribed client uint32_t setup_time_{0}; // Time when setup() was called uint32_t reconnect_time_{0}; // Timestamp of reconnect detection (0 = no pending query) + uint32_t frame_start_time_{0}; // Timestamp of the current frame's start byte (reception timeout) // Small values (grouped by size to minimize padding) uint16_t buffer_index_{0}; // Index for populating the data buffer diff --git a/tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h b/tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h index ba97e81236..b4ccd8fd00 100644 --- a/tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h +++ b/tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h @@ -16,7 +16,7 @@ class ZWaveProxy { public: api::APIConnection *get_api_connection() { return nullptr; } void zwave_proxy_request(api::APIConnection *conn, api::enums::ZWaveProxyRequestType type) {} - void send_frame(const uint8_t *data, size_t length) {} + void send_frame(api::APIConnection *api_connection, const uint8_t *data, size_t length) {} void api_connection_authenticated(api::APIConnection *conn) {} uint32_t get_feature_flags() const { return 0; } uint32_t get_home_id() { return 0; } From 2a67e5c5999609957baaea28c16bd24fe50f31bb Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:19:24 +1200 Subject: [PATCH 0857/1815] Bump version to 2026.7.0b2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 6f8b6e6664..1bcfded35d 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.7.0b1 +PROJECT_NUMBER = 2026.7.0b2 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index faa716bdd7..f6014176b8 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.7.0b1" +__version__ = "2026.7.0b2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 0ff11674ef2f30bcaa40efbb024e7fd089c82862 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:38:38 +1200 Subject: [PATCH 0858/1815] [mipi_rgb] Use dict-style packages in test so it can be batch-grouped (#17533) --- tests/components/mipi_rgb/test.esp32-s3-idf.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/mipi_rgb/test.esp32-s3-idf.yaml b/tests/components/mipi_rgb/test.esp32-s3-idf.yaml index b56ebee21e..12b45ee160 100644 --- a/tests/components/mipi_rgb/test.esp32-s3-idf.yaml +++ b/tests/components/mipi_rgb/test.esp32-s3-idf.yaml @@ -1,5 +1,5 @@ packages: - - !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml + i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml psram: mode: octal From 7ee7a26cad67be214794ff9b9a7a2d119ecaf6ff Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:46:13 +1200 Subject: [PATCH 0859/1815] [mipi_rgb] Use dict-style packages in test so it can be batch-grouped Convert the i2c include to a named dict-style package key so CI can group this component's build with others sharing the same bus, instead of flagging it as needing migration. --- tests/components/mipi_rgb/test.esp32-s3-idf.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/mipi_rgb/test.esp32-s3-idf.yaml b/tests/components/mipi_rgb/test.esp32-s3-idf.yaml index b56ebee21e..12b45ee160 100644 --- a/tests/components/mipi_rgb/test.esp32-s3-idf.yaml +++ b/tests/components/mipi_rgb/test.esp32-s3-idf.yaml @@ -1,5 +1,5 @@ packages: - - !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml + i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml psram: mode: octal From b3e03868b3850acdff8ead9e50553d7ad8e48603 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:00:22 -0400 Subject: [PATCH 0860/1815] [mipi_rgb] Test in isolation to avoid bus/pin merge conflicts (#17534) --- script/analyze_component_buses.py | 1 + 1 file changed, 1 insertion(+) diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index 8eb80d9943..a6ccb79544 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -90,6 +90,7 @@ ISOLATED_COMPONENTS = { "openthread_info": "Conflicts with wifi: used by most components", "matrix_keypad": "Needs isolation due to keypad", "microphone": "Defines PDM microphone requiring I2S port 0 - conflicts with micro_wake_word PDM mic when merged", + "mipi_rgb": "RGB display occupies many GPIOs (including ones used by the shared i2c bus) that conflict when merged with other bus components", "modbus_controller": "Defines multiple modbus buses for testing client/server functionality - conflicts with package modbus bus", "neopixelbus": "RMT type conflict with ESP32 Arduino/ESP-IDF headers (enum vs struct rmt_channel_t)", "packages": "cannot merge packages", From c607f64288e3ef8c7020e4c19595961887f9ca9e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:00:22 -0400 Subject: [PATCH 0861/1815] [mipi_rgb] Test in isolation to avoid bus/pin merge conflicts (#17534) --- script/analyze_component_buses.py | 1 + 1 file changed, 1 insertion(+) diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index 8eb80d9943..a6ccb79544 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -90,6 +90,7 @@ ISOLATED_COMPONENTS = { "openthread_info": "Conflicts with wifi: used by most components", "matrix_keypad": "Needs isolation due to keypad", "microphone": "Defines PDM microphone requiring I2S port 0 - conflicts with micro_wake_word PDM mic when merged", + "mipi_rgb": "RGB display occupies many GPIOs (including ones used by the shared i2c bus) that conflict when merged with other bus components", "modbus_controller": "Defines multiple modbus buses for testing client/server functionality - conflicts with package modbus bus", "neopixelbus": "RMT type conflict with ESP32 Arduino/ESP-IDF headers (enum vs struct rmt_channel_t)", "packages": "cannot merge packages", From 07460ebee443f718979b7b8703e126fb75409f97 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:28:55 +1200 Subject: [PATCH 0862/1815] [gsl3670] Fix i2c package variant in esp32-s3-idf test (#17535) --- tests/components/gsl3670/test.esp32-s3-idf.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/components/gsl3670/test.esp32-s3-idf.yaml b/tests/components/gsl3670/test.esp32-s3-idf.yaml index 48bb9982d9..5c3f4b931c 100644 --- a/tests/components/gsl3670/test.esp32-s3-idf.yaml +++ b/tests/components/gsl3670/test.esp32-s3-idf.yaml @@ -1,5 +1,5 @@ packages: - i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml xl9535: @@ -10,6 +10,9 @@ display: id: gsl3670_display spi_id: spi_bus model: t-display-s3-pro + # The model's default DC pin (GPIO9) clashes with the shared i2c bus SCL + # pin, so override it onto a free pin for this test. + dc_pin: GPIO5 psram: mode: quad From 4a82b1078354d1aecb9c434c336a97a63b3a04e0 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:29:35 +1200 Subject: [PATCH 0863/1815] [ci] Group component test output into collapsible CI log sections (#17536) --- script/test_build_components.py | 164 +++++++------- tests/script/test_test_build_components.py | 238 +++++++++++++++++++++ 2 files changed, 330 insertions(+), 72 deletions(-) create mode 100644 tests/script/test_test_build_components.py diff --git a/script/test_build_components.py b/script/test_build_components.py index ce2a35add3..c733e2fa3d 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -88,6 +88,38 @@ def show_disk_space_if_ci(esphome_command: str) -> None: sys.stdout.flush() +def start_log_group(title: str) -> None: + """Begin a collapsible log group in the GitHub Actions log viewer. + + Everything printed until the matching :func:`end_log_group` is folded away + by default, so the full ``esphome config``/``compile`` dump for one + configuration no longer pushes the pass/fail result thousands of lines down + the log. Outside CI this is a no-op so local runs stay plain. + + Args: + title: Text shown on the (collapsed) group header line. + """ + if not os.environ.get("GITHUB_ACTIONS"): + return + # Flush so the marker is ordered correctly relative to the child process + # output that follows (the subprocess writes straight to our stdout). + sys.stdout.flush() + print(f"::group::{title}") + sys.stdout.flush() + + +def end_log_group() -> None: + """Close the collapsible log group opened by :func:`start_log_group`. + + Outside CI this is a no-op. + """ + if not os.environ.get("GITHUB_ACTIONS"): + return + sys.stdout.flush() + print("::endgroup::") + sys.stdout.flush() + + def find_component_tests( components_dir: Path, component_pattern: str = "*", @@ -383,54 +415,48 @@ def run_esphome_test( # Build command string for display/logging cmd_str = " ".join(cmd) - # Run command - print(f"> [{component}] [{test_name}] [{platform_with_version}]") + # Run command inside a collapsible CI log group so the full esphome output + # for this configuration can be folded away by default. + group_title = f"[{component}] [{test_name}] [{platform_with_version}]" + start_log_group(group_title) + print(f"> {group_title}") if use_testing_mode: print(" (using --testing-mode)") start_time = time.time() test_id = f"{component}.{test_name}.{platform_with_version}" + # Always close the group, even if the subprocess or disk-space reporting + # raises, so later output is never folded into the wrong CI log section. try: result = subprocess.run(cmd, check=False) - success = result.returncode == 0 - duration = time.time() - start_time - # Show disk space after build in CI during compile show_disk_space_if_ci(esphome_command) + finally: + end_log_group() - if not success and not continue_on_fail: - # Print command immediately for failed tests - print(f"\n{'=' * 80}") - print("FAILED - Command to reproduce:") - print(f"{'=' * 80}") - print(cmd_str) - print() - raise subprocess.CalledProcessError(result.returncode, cmd) + success = result.returncode == 0 + duration = time.time() - start_time - return TestResult( - test_id=test_id, - components=[component], - platform=platform_with_version, - success=success, - duration=duration, - command=cmd_str, - test_type=esphome_command, - ) - except subprocess.CalledProcessError: - duration = time.time() - start_time - # Re-raise if we're not continuing on fail - if not continue_on_fail: - raise - return TestResult( - test_id=test_id, - components=[component], - platform=platform_with_version, - success=False, - duration=duration, - command=cmd_str, - test_type=esphome_command, - ) + if not success and not continue_on_fail: + # Print command immediately for failed tests. The group is already + # closed, so the failure and reproduce command stay visible. + print(f"\n{'=' * 80}") + print("FAILED - Command to reproduce:") + print(f"{'=' * 80}") + print(cmd_str) + print() + raise subprocess.CalledProcessError(result.returncode, cmd) + + return TestResult( + test_id=test_id, + components=[component], + platform=platform_with_version, + success=success, + duration=duration, + command=cmd_str, + test_type=esphome_command, + ) def run_grouped_test( @@ -534,54 +560,48 @@ def run_grouped_test( # Build command string for display/logging cmd_str = " ".join(cmd) - # Run command + # Run command inside a collapsible CI log group so the full esphome output + # for this grouped configuration can be folded away by default. components_str = ", ".join(components) - print(f"> [GROUPED: {components_str}] [{platform_with_version}]") + group_title = f"[GROUPED: {components_str}] [{platform_with_version}]" + start_log_group(group_title) + print(f"> {group_title}") print(" (using --testing-mode)") start_time = time.time() test_id = f"GROUPED[{','.join(components)}].{platform_with_version}" + # Always close the group, even if the subprocess or disk-space reporting + # raises, so later output is never folded into the wrong CI log section. try: result = subprocess.run(cmd, check=False) - success = result.returncode == 0 - duration = time.time() - start_time - # Show disk space after build in CI during compile show_disk_space_if_ci(esphome_command) + finally: + end_log_group() - if not success and not continue_on_fail: - # Print command immediately for failed tests - print(f"\n{'=' * 80}") - print("FAILED - Command to reproduce:") - print(f"{'=' * 80}") - print(cmd_str) - print() - raise subprocess.CalledProcessError(result.returncode, cmd) + success = result.returncode == 0 + duration = time.time() - start_time - return TestResult( - test_id=test_id, - components=components, - platform=platform_with_version, - success=success, - duration=duration, - command=cmd_str, - test_type=esphome_command, - ) - except subprocess.CalledProcessError: - duration = time.time() - start_time - # Re-raise if we're not continuing on fail - if not continue_on_fail: - raise - return TestResult( - test_id=test_id, - components=components, - platform=platform_with_version, - success=False, - duration=duration, - command=cmd_str, - test_type=esphome_command, - ) + if not success and not continue_on_fail: + # Print command immediately for failed tests. The group is already + # closed, so the failure and reproduce command stay visible. + print(f"\n{'=' * 80}") + print("FAILED - Command to reproduce:") + print(f"{'=' * 80}") + print(cmd_str) + print() + raise subprocess.CalledProcessError(result.returncode, cmd) + + return TestResult( + test_id=test_id, + components=components, + platform=platform_with_version, + success=success, + duration=duration, + command=cmd_str, + test_type=esphome_command, + ) def run_grouped_component_tests( diff --git a/tests/script/test_test_build_components.py b/tests/script/test_test_build_components.py new file mode 100644 index 0000000000..74e150380c --- /dev/null +++ b/tests/script/test_test_build_components.py @@ -0,0 +1,238 @@ +"""Unit tests for script/test_build_components.py logging helpers.""" + +from pathlib import Path +import sys + +import pytest + +# Add the script directory to the path so we can import the module under test. +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "script")) + +import test_build_components as tbc # noqa: E402 + + +class _FakeCompleted: + """Minimal stand-in for subprocess.CompletedProcess.""" + + def __init__(self, returncode: int) -> None: + self.returncode = returncode + + +@pytest.fixture +def _no_ci(monkeypatch: pytest.MonkeyPatch) -> None: + """Ensure GITHUB_ACTIONS is unset so group markers are suppressed.""" + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + + +@pytest.fixture +def _ci(monkeypatch: pytest.MonkeyPatch) -> None: + """Pretend we are running inside GitHub Actions.""" + monkeypatch.setenv("GITHUB_ACTIONS", "true") + + +def test_start_log_group_outside_ci_is_silent( + _no_ci: None, capsys: pytest.CaptureFixture[str] +) -> None: + tbc.start_log_group("hello") + assert capsys.readouterr().out == "" + + +def test_end_log_group_outside_ci_is_silent( + _no_ci: None, capsys: pytest.CaptureFixture[str] +) -> None: + tbc.end_log_group() + assert capsys.readouterr().out == "" + + +def test_start_log_group_in_ci_emits_marker( + _ci: None, capsys: pytest.CaptureFixture[str] +) -> None: + tbc.start_log_group("hello") + assert capsys.readouterr().out == "::group::hello\n" + + +def test_end_log_group_in_ci_emits_marker( + _ci: None, capsys: pytest.CaptureFixture[str] +) -> None: + tbc.end_log_group() + assert capsys.readouterr().out == "::endgroup::\n" + + +def _make_base_file(tmp_path: Path) -> Path: + base_file = tmp_path / "base.yaml" + base_file.write_text("esphome:\n name: $component_test_file\n") + return base_file + + +def test_run_esphome_test_wraps_output_in_group( + _ci: None, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """A passing single-component test is bracketed by group markers.""" + monkeypatch.setattr(tbc.subprocess, "run", lambda *a, **k: _FakeCompleted(0)) + repo_root = Path(tbc.__file__).parent.parent + test_file = repo_root / "tests" / "components" / "foo" / "test.esp32-idf.yaml" + + result = tbc.run_esphome_test( + component="foo", + test_file=test_file, + platform="esp32-idf", + platform_with_version="esp32-idf", + base_file=_make_base_file(tmp_path), + build_dir=tmp_path, + esphome_command="config", + continue_on_fail=True, + ) + + out = capsys.readouterr().out + assert result.success is True + assert "::group::[foo] [test] [esp32-idf]" in out + assert "::endgroup::" in out + # The header line is printed inside the group. + assert out.index("::group::") < out.index("> [foo]") < out.index("::endgroup::") + + +def test_run_esphome_test_closes_group_before_failure_report( + _ci: None, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """On a fail-fast failure the group closes before the reproduce report.""" + monkeypatch.setattr(tbc.subprocess, "run", lambda *a, **k: _FakeCompleted(1)) + repo_root = Path(tbc.__file__).parent.parent + test_file = repo_root / "tests" / "components" / "foo" / "test.esp32-idf.yaml" + + # continue_on_fail=False makes the failure raise after printing the + # reproduce block, which is the path that must stay outside the group. + with pytest.raises(tbc.subprocess.CalledProcessError): + tbc.run_esphome_test( + component="foo", + test_file=test_file, + platform="esp32-idf", + platform_with_version="esp32-idf", + base_file=_make_base_file(tmp_path), + build_dir=tmp_path, + esphome_command="config", + continue_on_fail=False, + ) + + out = capsys.readouterr().out + assert "::endgroup::" in out + assert "FAILED - Command to reproduce:" in out + # The group must be closed before the failure report is printed. + assert out.index("::endgroup::") < out.index("FAILED - Command to reproduce:") + + +def test_run_esphome_test_closes_group_when_subprocess_raises( + _ci: None, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """If the subprocess raises, the group is still closed (via finally).""" + + def _boom(*a: object, **k: object) -> None: + raise OSError("boom") + + monkeypatch.setattr(tbc.subprocess, "run", _boom) + repo_root = Path(tbc.__file__).parent.parent + test_file = repo_root / "tests" / "components" / "foo" / "test.esp32-idf.yaml" + + with pytest.raises(OSError, match="boom"): + tbc.run_esphome_test( + component="foo", + test_file=test_file, + platform="esp32-idf", + platform_with_version="esp32-idf", + base_file=_make_base_file(tmp_path), + build_dir=tmp_path, + esphome_command="config", + continue_on_fail=True, + ) + + assert "::endgroup::" in capsys.readouterr().out + + +def test_run_grouped_test_wraps_output_in_group( + _ci: None, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """A grouped test is bracketed by group markers listing its components.""" + monkeypatch.setattr(tbc.subprocess, "run", lambda *a, **k: _FakeCompleted(0)) + monkeypatch.setattr(tbc, "merge_component_configs", lambda **k: None) + + result = tbc.run_grouped_test( + components=["foo", "bar"], + platform="esp32-idf", + platform_with_version="esp32-idf", + base_file=_make_base_file(tmp_path), + build_dir=tmp_path, + tests_dir=tmp_path, + esphome_command="config", + continue_on_fail=True, + ) + + out = capsys.readouterr().out + assert result.success is True + assert "::group::[GROUPED: foo, bar] [esp32-idf]" in out + assert out.index("::group::") < out.index("> [GROUPED") < out.index("::endgroup::") + + +def test_run_grouped_test_closes_group_before_failure_report( + _ci: None, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """A fail-fast grouped failure closes the group before the report.""" + monkeypatch.setattr(tbc.subprocess, "run", lambda *a, **k: _FakeCompleted(1)) + monkeypatch.setattr(tbc, "merge_component_configs", lambda **k: None) + + with pytest.raises(tbc.subprocess.CalledProcessError): + tbc.run_grouped_test( + components=["foo", "bar"], + platform="esp32-idf", + platform_with_version="esp32-idf", + base_file=_make_base_file(tmp_path), + build_dir=tmp_path, + tests_dir=tmp_path, + esphome_command="config", + continue_on_fail=False, + ) + + out = capsys.readouterr().out + assert out.index("::endgroup::") < out.index("FAILED - Command to reproduce:") + + +def test_run_grouped_test_closes_group_when_subprocess_raises( + _ci: None, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """If the grouped subprocess raises, the group is still closed (finally).""" + + def _boom(*a: object, **k: object) -> None: + raise OSError("boom") + + monkeypatch.setattr(tbc.subprocess, "run", _boom) + monkeypatch.setattr(tbc, "merge_component_configs", lambda **k: None) + + with pytest.raises(OSError, match="boom"): + tbc.run_grouped_test( + components=["foo", "bar"], + platform="esp32-idf", + platform_with_version="esp32-idf", + base_file=_make_base_file(tmp_path), + build_dir=tmp_path, + tests_dir=tmp_path, + esphome_command="config", + continue_on_fail=True, + ) + + assert "::endgroup::" in capsys.readouterr().out From a8dfd00cc6cbe12ecc59a7da9e6950784310263e Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:38:48 +1200 Subject: [PATCH 0864/1815] [web_server] Add CORS origin checking with allowed_origins (#17530) --- THREAT_MODEL.md | 39 +++++---- esphome/components/web_server/__init__.py | 46 +++++++++- esphome/components/web_server/web_server.cpp | 71 +++++++++++++-- esphome/components/web_server/web_server.h | 26 ++++++ esphome/core/defines.h | 1 + .../web_server/test_private_network_access.py | 86 +++++++++++++++++++ tests/components/web_server/common_v2.yaml | 3 + tests/components/web_server/common_v3.yaml | 3 + 8 files changed, 250 insertions(+), 25 deletions(-) create mode 100644 tests/component_tests/web_server/test_private_network_access.py diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 24a7fed4f2..5816f38176 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -92,18 +92,24 @@ is choosing an open control surface, in the same way that running native OTA without a password leaves OTA open. The API is documented and is meant to be called by other devices, scripts, and pages. -The device performs no CSRF token, `Origin`, or `Referer` validation and returns -a permissive CORS policy. Cross-origin requests are handled the same as any other -network request, including requests a browser is induced to make by a page the -operator visits (the "confused deputy", or CSRF, pattern). The following are -therefore **not** vulnerabilities in this repository: +As defense-in-depth, the web server checks the `Origin` header on browser requests +to its entity control and state endpoints: a request whose `Origin` does not match +the address the device is served on is rejected, and the `allowed_origins` option +widens that list. This blocks the common "confused deputy" (CSRF) case where a page +the operator visits drives the device through their browser. It is **not** an +authentication boundary: it only constrains browsers. Any client that omits the +`Origin` header — `curl`, scripts, or other non-browser callers on the same +network — reaches every endpoint exactly as before. The check also does not cover +the web OTA `/update` endpoint. The device performs no CSRF-token or `Referer` +validation. The following are therefore **not** vulnerabilities in this repository: -- Cross-origin or CSRF requests to the control endpoints (for example, a page the - operator opens toggling a switch), whether or not `web_server` `auth:` is set. -- Cross-origin reads of device state permitted by the CORS policy. -- Cross-origin firmware upload through the web OTA endpoint (`/update`) when web - OTA is enabled without `web_server` `auth:`. This is the same exposure as - running OTA without a password. +- Requests without an `Origin` header (for example `curl`) reaching the control + endpoints, whether or not `web_server` `auth:` is set. +- Requests from an origin the operator added to `allowed_origins`. +- Cross-origin or CSRF firmware upload through the web OTA endpoint (`/update`) when + web OTA is enabled without `web_server` `auth:`. The `/update` endpoint is not + covered by the `Origin` check; this is the same exposure as running OTA without a + password. The supported defenses are `web_server` `auth:`, protecting OTA (a web password or a native OTA password), and keeping devices on a trusted, segmented network. See @@ -113,9 +119,7 @@ What remains in scope is bypassing `web_server` `auth:` when it *is* configured, and any memory-safety or protocol bug in the server reachable without credentials. This section documents the current design and scope; it is not a judgment that the -design is optimal or that it will not change. Optional hardening (for example an -origin allowlist or opt-in CSRF checks) is welcome as a normal enhancement PR, -framed as defense-in-depth rather than a security fix. +design is optimal or that it will not change. ## Explicitly out of scope @@ -124,9 +128,10 @@ framed as defense-in-depth rather than a security fix. - Operator-supplied hostile YAML (covered above — config authoring is trusted). - Attacks that require an already-authenticated device peer (someone who already holds the API key / OTA / web credentials). -- Cross-site (CSRF), cross-origin, or CORS behavior of the device web server and - its web OTA endpoint. The web server is an open HTTP API by design (see above); - gate it with `web_server` `auth:` and network isolation. +- Access to the device web server or its web OTA endpoint by non-browser clients + (those that send no `Origin` header). The web server is an open HTTP API by + design (see above); browser cross-origin requests are blocked by default, but the + real controls are `web_server` `auth:` and network isolation. - Anything in the dashboard / device-builder — report that in its own repository (linked at the top). - Deployments where the operator removed protections or exposed credentials. See diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index d9fd27dbc2..68f1c18072 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import gzip import logging +import re import esphome.codegen as cg from esphome.components import web_server_base @@ -46,6 +47,7 @@ AUTO_LOAD = ["json", "web_server_base"] CONF_SORTING_GROUP_ID = "sorting_group_id" CONF_SORTING_GROUPS = "sorting_groups" CONF_SORTING_WEIGHT = "sorting_weight" +CONF_ALLOWED_ORIGINS = "allowed_origins" web_server_ns = cg.esphome_ns.namespace("web_server") @@ -104,6 +106,41 @@ def validate_ota(config: ConfigType) -> ConfigType: return config +# An Origin header is always "scheme://host[:port]" with no path or trailing slash. +_ORIGIN_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*://[^/\s]+$") + + +def validate_origin(value: str) -> str: + # "*" is the wildcard that allows any origin. + if value == "*": + return value + value = cv.string_strict(value) + if not _ORIGIN_RE.match(value): + raise cv.Invalid( + f"'{value}' is not a valid origin. An origin must be 'scheme://host[:port]' with no " + f"path or trailing slash (e.g. 'https://example.com'), or '*' to allow any origin." + ) + # Browsers send the scheme and host lowercased in the Origin header, so normalize to match. + return value.lower() + + +def validate_private_network_access(config: ConfigType) -> ConfigType: + # PNA preflights are always cross-origin, so they can only be authorized against the + # allowed_origins list. Enabling PNA without any origins would deny every PNA request. + if ( + config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS] + and config.get(CONF_ALLOWED_ORIGINS) is None + ): + raise cv.Invalid( + f"'{CONF_ALLOWED_ORIGINS}' must be set when " + f"'{CONF_ENABLE_PRIVATE_NETWORK_ACCESS}' is enabled. List each origin that is " + f"allowed to reach the device (e.g. 'https://example.com'). '*' allows any origin " + f"but is not recommended.", + path=[CONF_ENABLE_PRIVATE_NETWORK_ACCESS], + ) + return config + + def validate_sorting_groups(config: ConfigType) -> ConfigType: if CONF_SORTING_GROUPS in config and config[CONF_VERSION] != 3: raise cv.Invalid( @@ -201,7 +238,10 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_CSS_INCLUDE): cv.file_, cv.Optional(CONF_JS_URL): cv.string, cv.Optional(CONF_JS_INCLUDE): cv.file_, - cv.Optional(CONF_ENABLE_PRIVATE_NETWORK_ACCESS, default=True): cv.boolean, + cv.Optional(CONF_ENABLE_PRIVATE_NETWORK_ACCESS, default=False): cv.boolean, + cv.Optional(CONF_ALLOWED_ORIGINS): cv.All( + cv.ensure_list(validate_origin), cv.Length(min=1) + ), cv.Optional(CONF_AUTH): cv.Schema( { cv.Required(CONF_USERNAME): cv.All( @@ -238,6 +278,7 @@ CONFIG_SCHEMA = cv.All( validate_local, validate_sorting_groups, validate_ota, + validate_private_network_access, _consume_web_server_sockets, ) @@ -334,6 +375,9 @@ async def to_code(config): request_log_listener() # Request a log listener slot for web server log streaming if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]: cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS") + if (allowed_origins := config.get(CONF_ALLOWED_ORIGINS)) is not None: + cg.add_define("USE_WEBSERVER_ALLOWED_ORIGINS") + cg.add(var.set_allowed_origins(allowed_origins)) if CONF_AUTH in config: cg.add_define("USE_WEBSERVER_AUTH") cg.add(paren.set_auth_username(config[CONF_AUTH][CONF_USERNAME])) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 3bba879823..1e6c4e8c62 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -456,9 +456,58 @@ void WebServer::handle_index_request(AsyncWebServerRequest *request) { } #endif +// Read a request header value portably across the Arduino and ESP-IDF web servers. +// Returns an empty string when the header is absent (only allocates when a value is present). +static std::string get_request_header(AsyncWebServerRequest *request, const char *name) { +#ifdef USE_ESP32 + // ESP32 (Arduino and ESP-IDF) uses the web_server_idf backend. + optional value = request->get_header(name); + return value.has_value() ? std::move(*value) : std::string(); +#else + // ESP8266, RP2040 and LibreTiny use the Arduino ESPAsyncWebServer backend. + const AsyncWebHeader *header = request->getHeader(name); + return header != nullptr ? std::string(header->value().c_str()) : std::string(); +#endif +} + +bool WebServer::is_request_origin_allowed_(AsyncWebServerRequest *request, const std::string &origin) { + // No Origin header: not a browser cross-origin request (e.g. curl, native API client). Allow. + if (origin.empty()) + return true; + + // Same-origin: the Origin authority (scheme stripped) matches the Host the request was sent to. + // This covers the device's own IP, mDNS name, or DNS name without knowing any at compile time. + const size_t scheme_sep = origin.find("://"); + if (scheme_sep != std::string::npos) { + const std::string host = get_request_header(request, "Host"); + if (!host.empty() && origin.compare(scheme_sep + 3, std::string::npos, host) == 0) + return true; + } + +#ifdef USE_WEBSERVER_ALLOWED_ORIGINS + // Otherwise the origin must be explicitly allowed via configuration. + for (const char *allowed_origin : this->allowed_origins_) { + // A single "*" entry allows any origin. + if (allowed_origin[0] == '*' && allowed_origin[1] == '\0') + return true; + if (origin == allowed_origin) + return true; + } +#endif + return false; +} + #ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS void WebServer::handle_pna_cors_request(AsyncWebServerRequest *request) { + const std::string origin = get_request_header(request, "Origin"); + if (!this->is_request_origin_allowed_(request, origin)) { + request->send(403); + return; + } + AsyncWebServerResponse *response = request->beginResponse(200, ESPHOME_F("")); + // Echo the specific origin back so the response is valid even when auth (credentials) is enabled. + response->addHeader(ESPHOME_F("Access-Control-Allow-Origin"), origin.empty() ? "*" : origin.c_str()); response->addHeader(ESPHOME_F("Access-Control-Allow-Private-Network"), ESPHOME_F("true")); response->addHeader(ESPHOME_F("Private-Network-Access-Name"), App.get_name().c_str()); char mac_s[18]; @@ -2448,6 +2497,21 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { return; } +#ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS + // Private Network Access preflight carries a cross-origin Origin by design; its handler does the + // origin check itself, so let it run before the general enforcement below. + if (request->method() == HTTP_OPTIONS && request->hasHeader(ESPHOME_F("Access-Control-Request-Private-Network"))) { + this->handle_pna_cors_request(request); + return; + } +#endif + + // Reject cross-origin browser requests unless the origin is explicitly allowed. + if (!this->is_request_origin_allowed_(request, get_request_header(request, "Origin"))) { + request->send(403); + return; + } + #if !defined(USE_ESP32) && defined(USE_ARDUINO) if (url == ESPHOME_F("/events")) { this->events_.add_new_client(this, request); @@ -2469,13 +2533,6 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { } #endif -#ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS - if (request->method() == HTTP_OPTIONS && request->hasHeader(ESPHOME_F("Access-Control-Request-Private-Network"))) { - this->handle_pna_cors_request(request); - return; - } -#endif - // Parse URL for component routing // Pass HTTP method to disambiguate 3-segment URLs (GET=sub-device state, POST=main device action) UrlMatch match = match_url(url.c_str(), url.length(), false, request->method() == HTTP_POST); diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 42182fe510..0fbe4ec551 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -242,6 +242,22 @@ class WebServer final : public Controller, public Component, public AsyncWebHand */ void set_expose_log(bool expose_log) { this->expose_log_ = expose_log; } +#ifdef USE_WEBSERVER_ALLOWED_ORIGINS + /** Set the origins that browsers are allowed to make cross-origin requests from. + * + * Requests without an `Origin` header (e.g. non-browser clients like curl or the native API) + * are always allowed. Requests whose `Origin` matches the address the device is served on + * (same-origin) are always allowed. Any other browser origin must appear in this list, or the + * request is rejected. A single "*" entry allows any origin. Each other entry must exactly match + * the requesting page's `Origin` header (e.g. "https://example.com"). + * + * This list is also used to authorize Private Network Access requests when that feature is enabled. + * + * @param origins The list of allowed origins. + */ + void set_allowed_origins(std::initializer_list origins) { this->allowed_origins_ = origins; } +#endif + // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) /// Setup the internal web server and register handlers. @@ -593,6 +609,16 @@ class WebServer final : public Controller, public Component, public AsyncWebHand const char *js_include_{nullptr}; #endif bool expose_log_{true}; +#ifdef USE_WEBSERVER_ALLOWED_ORIGINS + // Extra origins allowed to make cross-origin browser requests ("*" means any origin). + // Only compiled when allowed_origins is configured; same-origin is always allowed regardless. + FixedVector allowed_origins_; +#endif + + /// Check whether the given request Origin is permitted. Same-origin (matching the Host the + /// request was sent to) and requests without an Origin header are always allowed; any other + /// origin must be listed in allowed_origins. The caller passes the already-read Origin header. + bool is_request_origin_allowed_(AsyncWebServerRequest *request, const std::string &origin); private: #ifdef USE_SENSOR diff --git a/esphome/core/defines.h b/esphome/core/defines.h index bdb0f27f45..78f7769cf6 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -302,6 +302,7 @@ #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_WEBSERVER_GZIP #define USE_WEBSERVER_SORTING +#define USE_WEBSERVER_ALLOWED_ORIGINS #define WEB_SERVER_DEFAULT_HEADERS_COUNT 1 #define USE_CAPTIVE_PORTAL_GZIP #define USE_WIFI_11KV_SUPPORT diff --git a/tests/component_tests/web_server/test_private_network_access.py b/tests/component_tests/web_server/test_private_network_access.py new file mode 100644 index 0000000000..87911c5f9b --- /dev/null +++ b/tests/component_tests/web_server/test_private_network_access.py @@ -0,0 +1,86 @@ +"""Tests for web_server Private Network Access / allowed_origins validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.web_server import ( + CONF_ALLOWED_ORIGINS, + validate_origin, + validate_private_network_access, +) +from esphome.const import CONF_ENABLE_PRIVATE_NETWORK_ACCESS +from esphome.types import ConfigType + + +def test_pna_enabled_without_origins_fails() -> None: + """Enabling PNA without allowed_origins must fail validation.""" + config: ConfigType = {CONF_ENABLE_PRIVATE_NETWORK_ACCESS: True} + + with pytest.raises(cv.Invalid) as exc_info: + validate_private_network_access(config) + + error_msg = str(exc_info.value) + assert CONF_ALLOWED_ORIGINS in error_msg + assert "must be set" in error_msg + + +def test_pna_enabled_with_origins_passes() -> None: + """Enabling PNA with at least one allowed origin passes validation.""" + config: ConfigType = { + CONF_ENABLE_PRIVATE_NETWORK_ACCESS: True, + CONF_ALLOWED_ORIGINS: ["https://app.esphome.io"], + } + assert validate_private_network_access(config) == config + + +def test_origins_without_pna_passes() -> None: + """allowed_origins can be set without enabling PNA (they are independent).""" + config: ConfigType = { + CONF_ENABLE_PRIVATE_NETWORK_ACCESS: False, + CONF_ALLOWED_ORIGINS: ["https://app.esphome.io"], + } + assert validate_private_network_access(config) == config + + +def test_pna_disabled_without_origins_passes() -> None: + """PNA disabled and no origins specified passes validation.""" + config: ConfigType = {CONF_ENABLE_PRIVATE_NETWORK_ACCESS: False} + assert validate_private_network_access(config) == config + + +def test_validate_origin_wildcard() -> None: + """The '*' wildcard is accepted as-is.""" + assert validate_origin("*") == "*" + + +@pytest.mark.parametrize( + "value", + [ + "https://example.com", + "http://example.com:8080", + "https://192.168.1.5", + ], +) +def test_validate_origin_valid(value: str) -> None: + """Well-formed origins pass through unchanged.""" + assert validate_origin(value) == value + + +def test_validate_origin_lowercased() -> None: + """Scheme and host are normalized to lowercase to match the browser Origin header.""" + assert validate_origin("HTTPS://App.Example.com") == "https://app.example.com" + + +@pytest.mark.parametrize( + "value", + [ + "https://example.com/", # trailing slash + "https://example.com/path", # path segment + "example.com", # missing scheme + "", # empty + ], +) +def test_validate_origin_invalid(value: str) -> None: + """Malformed origins are rejected at config time instead of silently 403ing.""" + with pytest.raises(cv.Invalid, match="not a valid origin"): + validate_origin(value) diff --git a/tests/components/web_server/common_v2.yaml b/tests/components/web_server/common_v2.yaml index f2b15e484d..b9bc0bbf61 100644 --- a/tests/components/web_server/common_v2.yaml +++ b/tests/components/web_server/common_v2.yaml @@ -5,3 +5,6 @@ web_server: port: 8080 version: 2 compression: br + enable_private_network_access: true + allowed_origins: + - https://app.esphome.io diff --git a/tests/components/web_server/common_v3.yaml b/tests/components/web_server/common_v3.yaml index bdacaaddbe..354d7bb6ac 100644 --- a/tests/components/web_server/common_v3.yaml +++ b/tests/components/web_server/common_v3.yaml @@ -4,6 +4,9 @@ packages: web_server: port: 8080 version: 3 + # allowed_origins can be set independently of Private Network Access + allowed_origins: + - https://app.esphome.io sorting_groups: - id: sorting_group_1 name: "Group 1 Diplayed Last" From bcfb438a81814dab8e757e9347563ccea969c9a2 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Sun, 12 Jul 2026 23:23:40 -0500 Subject: [PATCH 0865/1815] [esp32] Do not require verification_key with Secure Boot V2 signing schemes (#17497) --- esphome/components/esp32/__init__.py | 99 +++++++++++++++---- tests/component_tests/esp32/test_esp32.py | 75 ++++++++++++++ ...date-signed_ota_external.esp32-s3-idf.yaml | 11 +++ 3 files changed, 167 insertions(+), 18 deletions(-) create mode 100644 tests/components/esp32/validate-signed_ota_external.esp32-s3-idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 7c926fe28e..9b568dd629 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1160,6 +1160,74 @@ def _ota_downgrade_protection_errors( return errs +_SIGNED_OTA_VERIFICATION_SCHEMA = cv.Schema( + { + cv.Optional(CONF_SIGNING_KEY): cv.file_, + cv.Optional(CONF_VERIFICATION_KEY): cv.file_, + cv.Optional(CONF_SIGNING_SCHEME, default="rsa3072"): cv.one_of( + *SIGNING_SCHEMES, lower=True + ), + } +) + + +@schema_extractor("schema") +def _validate_signed_ota_verification(value): + if value is SCHEMA_EXTRACT: + # Expose the inner schema so the language-schema dumper can walk the + # signing_key / verification_key / signing_scheme options. + return _SIGNED_OTA_VERIFICATION_SCHEMA + if value is None: + # A bare `signed_ota_verification:` block is valid: the default V2 + # scheme needs no keys (verify externally-signed binaries). + value = {} + return _validate_signed_ota_keys(_SIGNED_OTA_VERIFICATION_SCHEMA(value)) + + +def _validate_signed_ota_keys(config: ConfigType) -> ConfigType: + """Validate the signing/verification key combination for the selected scheme. + + A verification key is only used by the Secure Boot V1 scheme (ecdsa_v1): + the public key is compiled into the app so it can verify externally-signed + images. ESP-IDF's CONFIG_SECURE_BOOT_VERIFICATION_KEY only takes effect + when the V1 ECDSA scheme is selected and binaries are not signed during + the build (see SECURE_BOOT_VERIFICATION_KEY in the bootloader Kconfig). + + The V2 schemes (rsa3072, ecdsa256) embed the public key in the signature + block appended to each image, so verifying externally-signed binaries + needs no key in the config at all -- omitting both keys selects that + external-signing mode. + """ + has_signing_key = CONF_SIGNING_KEY in config + has_verification_key = CONF_VERIFICATION_KEY in config + scheme = config[CONF_SIGNING_SCHEME] + if has_signing_key and has_verification_key: + raise cv.Invalid( + f"Provide at most one of '{CONF_SIGNING_KEY}' and " + f"'{CONF_VERIFICATION_KEY}', not both.", + path=[CONF_VERIFICATION_KEY], + ) + if scheme == "ecdsa_v1": + if not has_signing_key and not has_verification_key: + raise cv.Invalid( + f"Signing scheme 'ecdsa_v1' requires either '{CONF_SIGNING_KEY}' " + f"(to sign binaries during the build) or '{CONF_VERIFICATION_KEY}' " + f"(to verify binaries signed externally).", + path=[CONF_SIGNING_KEY], + ) + elif has_verification_key: + raise cv.Invalid( + f"'{CONF_VERIFICATION_KEY}' is only used with signing scheme " + f"'ecdsa_v1'. With '{scheme}' the public key is embedded in each " + f"image's signature block, so no key file is needed to verify " + f"externally-signed binaries: remove '{CONF_VERIFICATION_KEY}', and " + f"set '{CONF_SIGNING_KEY}' only if binaries should be signed during " + f"the build.", + path=[CONF_VERIFICATION_KEY], + ) + return config + + def final_validate(config): # Imported locally to avoid circular import issues from esphome.components.psram import DOMAIN as PSRAM_DOMAIN @@ -1361,7 +1429,7 @@ def final_validate(config): ) else: _LOGGER.info( - "Signed OTA verification is configured with a public verification key. " + "Signed OTA verification is enabled without a signing key. " "Binaries will NOT be signed automatically during build. " "You must sign them externally before flashing." ) @@ -1640,18 +1708,9 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional( CONF_ENABLE_OTA_DOWNGRADE_PROTECTION, default=False ): cv.boolean, - cv.Optional(CONF_SIGNED_OTA_VERIFICATION): cv.All( - cv.Schema( - { - cv.Optional(CONF_SIGNING_KEY): cv.file_, - cv.Optional(CONF_VERIFICATION_KEY): cv.file_, - cv.Optional( - CONF_SIGNING_SCHEME, default="rsa3072" - ): cv.one_of(*SIGNING_SCHEMES, lower=True), - } - ), - cv.has_exactly_one_key(CONF_SIGNING_KEY, CONF_VERIFICATION_KEY), - ), + cv.Optional( + CONF_SIGNED_OTA_VERIFICATION + ): _validate_signed_ota_verification, cv.Optional(CONF_NVS_ENCRYPTION): cv.Schema( { # eFuse key block (0-5) that stores the HMAC key from @@ -2498,12 +2557,16 @@ async def to_code(config): signed_ota[CONF_SIGNING_KEY].resolve().as_posix(), ) else: - # Public key mode — verification only, external signing required + # External signing mode — binaries must be signed after the build add_idf_sdkconfig_option("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES", False) - add_idf_sdkconfig_option( - "CONFIG_SECURE_BOOT_VERIFICATION_KEY", - signed_ota[CONF_VERIFICATION_KEY].resolve().as_posix(), - ) + if CONF_VERIFICATION_KEY in signed_ota: + # V1 ECDSA only: the public key is compiled into the app to + # verify externally-signed images. V2 schemes carry the public + # key in each image's signature block and need no key here. + add_idf_sdkconfig_option( + "CONFIG_SECURE_BOOT_VERIFICATION_KEY", + signed_ota[CONF_VERIFICATION_KEY].resolve().as_posix(), + ) cg.add_define("USE_OTA_SIGNED_VERIFICATION") diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index dd8881e46f..fdca70bf2c 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -665,3 +665,78 @@ def test_downgrade_protection_reports_all_unmet_requirements() -> None: # No project version and no signing -> two distinct errors. errs = _ota_downgrade_protection_errors(None, signed_ota_enabled=False) assert len(errs) == 2 + + +@pytest.mark.parametrize( + "config", + [ + # V2 schemes: signing key (sign during build) or no key at all + # (external signing; the public key travels in the signature block). + {"signing_scheme": "rsa3072", "signing_key": "key.pem"}, + {"signing_scheme": "rsa3072"}, + {"signing_scheme": "ecdsa256", "signing_key": "key.pem"}, + {"signing_scheme": "ecdsa256"}, + # V1 ECDSA: exactly one of signing key / verification key. + {"signing_scheme": "ecdsa_v1", "signing_key": "key.pem"}, + {"signing_scheme": "ecdsa_v1", "verification_key": "key.bin"}, + ], +) +def test_signed_ota_keys_valid_combinations(config: dict) -> None: + from esphome.components.esp32 import _validate_signed_ota_keys + + assert _validate_signed_ota_keys(config) is config + + +@pytest.mark.parametrize("value", [None, {}]) +def test_signed_ota_bare_block_selects_v2_external_signing(value: dict | None) -> None: + """A bare `signed_ota_verification:` block is valid: the default V2 + scheme embeds the public key in the signature block, so verifying + externally-signed binaries needs no keys in the config.""" + from esphome.components.esp32 import _validate_signed_ota_verification + + config = _validate_signed_ota_verification(value) + assert config == {"signing_scheme": "rsa3072"} + + +@pytest.mark.parametrize( + ("config", "match"), + [ + # A verification key is meaningless with the V2 schemes -- the public + # key is embedded in each image's signature block. + ( + {"signing_scheme": "rsa3072", "verification_key": "key.bin"}, + "only used with signing scheme 'ecdsa_v1'", + ), + ( + {"signing_scheme": "ecdsa256", "verification_key": "key.bin"}, + "only used with signing scheme 'ecdsa_v1'", + ), + # V1 ECDSA needs a key either way. + ( + {"signing_scheme": "ecdsa_v1"}, + "Signing scheme 'ecdsa_v1' requires either", + ), + # Never both keys at once. + ( + { + "signing_scheme": "rsa3072", + "signing_key": "key.pem", + "verification_key": "key.bin", + }, + "not both", + ), + ( + { + "signing_scheme": "ecdsa_v1", + "signing_key": "key.pem", + "verification_key": "key.bin", + }, + "not both", + ), + ], +) +def test_signed_ota_keys_invalid_combinations(config: dict, match: str) -> None: + from esphome.components.esp32 import _validate_signed_ota_keys + + with pytest.raises(cv.Invalid, match=match): + _validate_signed_ota_keys(config) diff --git a/tests/components/esp32/validate-signed_ota_external.esp32-s3-idf.yaml b/tests/components/esp32/validate-signed_ota_external.esp32-s3-idf.yaml new file mode 100644 index 0000000000..5b57993e87 --- /dev/null +++ b/tests/components/esp32/validate-signed_ota_external.esp32-s3-idf.yaml @@ -0,0 +1,11 @@ +# Secure Boot V2 schemes carry the public key inside each image's signature +# block, so verifying externally-signed binaries needs no key in the config: +# a bare block enables verification with the default rsa3072 scheme. +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + signed_ota_verification: + +<<: !include common.yaml From d78cb09b17bb12dea1a8e6cd2d6e6b67f3004a38 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:23:55 +1200 Subject: [PATCH 0866/1815] [web_server] Use dict-style packages in tests so they can be batch-grouped (#17544) --- tests/components/web_server/test.esp32-ard.yaml | 3 ++- tests/components/web_server/test.esp32-idf.yaml | 3 ++- tests/components/web_server/test.esp8266-ard.yaml | 3 ++- tests/components/web_server/test.rp2040-ard.yaml | 3 ++- tests/components/web_server/test_v1.esp32-ard.yaml | 3 ++- tests/components/web_server/test_v1.esp32-idf.yaml | 3 ++- tests/components/web_server/test_v3.esp32-ard.yaml | 3 ++- 7 files changed, 14 insertions(+), 7 deletions(-) diff --git a/tests/components/web_server/test.esp32-ard.yaml b/tests/components/web_server/test.esp32-ard.yaml index 7e6658e20e..11ad5456ef 100644 --- a/tests/components/web_server/test.esp32-ard.yaml +++ b/tests/components/web_server/test.esp32-ard.yaml @@ -1 +1,2 @@ -<<: !include common_v2.yaml +packages: + web_server: !include common_v2.yaml diff --git a/tests/components/web_server/test.esp32-idf.yaml b/tests/components/web_server/test.esp32-idf.yaml index 24b292d0d6..858e3b0190 100644 --- a/tests/components/web_server/test.esp32-idf.yaml +++ b/tests/components/web_server/test.esp32-idf.yaml @@ -1,4 +1,5 @@ -<<: !include common_v2.yaml +packages: + web_server: !include common_v2.yaml web_server: auth: diff --git a/tests/components/web_server/test.esp8266-ard.yaml b/tests/components/web_server/test.esp8266-ard.yaml index 7e6658e20e..11ad5456ef 100644 --- a/tests/components/web_server/test.esp8266-ard.yaml +++ b/tests/components/web_server/test.esp8266-ard.yaml @@ -1 +1,2 @@ -<<: !include common_v2.yaml +packages: + web_server: !include common_v2.yaml diff --git a/tests/components/web_server/test.rp2040-ard.yaml b/tests/components/web_server/test.rp2040-ard.yaml index 7e6658e20e..11ad5456ef 100644 --- a/tests/components/web_server/test.rp2040-ard.yaml +++ b/tests/components/web_server/test.rp2040-ard.yaml @@ -1 +1,2 @@ -<<: !include common_v2.yaml +packages: + web_server: !include common_v2.yaml diff --git a/tests/components/web_server/test_v1.esp32-ard.yaml b/tests/components/web_server/test_v1.esp32-ard.yaml index 389a930284..1d563de834 100644 --- a/tests/components/web_server/test_v1.esp32-ard.yaml +++ b/tests/components/web_server/test_v1.esp32-ard.yaml @@ -1 +1,2 @@ -<<: !include common_v1.yaml +packages: + web_server: !include common_v1.yaml diff --git a/tests/components/web_server/test_v1.esp32-idf.yaml b/tests/components/web_server/test_v1.esp32-idf.yaml index 389a930284..1d563de834 100644 --- a/tests/components/web_server/test_v1.esp32-idf.yaml +++ b/tests/components/web_server/test_v1.esp32-idf.yaml @@ -1 +1,2 @@ -<<: !include common_v1.yaml +packages: + web_server: !include common_v1.yaml diff --git a/tests/components/web_server/test_v3.esp32-ard.yaml b/tests/components/web_server/test_v3.esp32-ard.yaml index 00d05521e4..956a88bc68 100644 --- a/tests/components/web_server/test_v3.esp32-ard.yaml +++ b/tests/components/web_server/test_v3.esp32-ard.yaml @@ -1 +1,2 @@ -<<: !include common_v3.yaml +packages: + web_server: !include common_v3.yaml From 5e3e2f82c9800bc232b0c9c9d962418a1b4f7d44 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:16:53 +1000 Subject: [PATCH 0867/1815] [script] Fix duplicate import in build_codeowners.py (#17543) --- script/build_codeowners.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/script/build_codeowners.py b/script/build_codeowners.py index 10ca1295b7..be8b445542 100755 --- a/script/build_codeowners.py +++ b/script/build_codeowners.py @@ -61,6 +61,13 @@ for path in components_dir.iterdir(): codeowners[f"esphome/components/{name}/*"].extend(comp.codeowners) for platform_path in path.iterdir(): + if platform_path.name == "__init__.py": + # `import pkg.__init__` is valid but distinct from `import pkg`: it re-executes + # the component's __init__.py as a second, separate module. That's harmless for + # components whose top-level code is idempotent, but not guaranteed in general + # (e.g. code that registers into a global registry with a duplicate check), so + # never treat __init__.py itself as a platform candidate. + continue platform_name = platform_path.stem platform = get_platform(platform_name, name) if platform is None: From 65d6c028cea339b1e8baa1fcb456afbe76f3d210 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:33:44 +1200 Subject: [PATCH 0868/1815] [web_server] Add HTTP digest authentication with selectable scheme (#17541) --- esphome/components/web_server/__init__.py | 52 ++++-- .../web_server_base/web_server_base.h | 8 + .../web_server_idf/web_server_idf.cpp | 170 +++++++++++++++++- .../web_server_idf/web_server_idf.h | 2 +- esphome/core/defines.h | 3 + .../web_server/test_web_server_auth.py | 65 +++++++ .../web_server/web_server_auth_basic.yaml | 18 ++ .../web_server/web_server_auth_default.yaml | 17 ++ .../web_server/web_server_auth_digest.yaml | 18 ++ .../web_server/web_server_no_auth.yaml | 14 ++ .../components/web_server/test.esp32-idf.yaml | 1 + .../web_server/test.esp8266-ard.yaml | 6 + .../web_server/test.rp2040-ard.yaml | 6 + .../web_server/validate.esp32-idf.yaml | 8 + 14 files changed, 370 insertions(+), 18 deletions(-) create mode 100644 tests/component_tests/web_server/test_web_server_auth.py create mode 100644 tests/component_tests/web_server/web_server_auth_basic.yaml create mode 100644 tests/component_tests/web_server/web_server_auth_default.yaml create mode 100644 tests/component_tests/web_server/web_server_auth_digest.yaml create mode 100644 tests/component_tests/web_server/web_server_no_auth.yaml create mode 100644 tests/components/web_server/validate.esp32-idf.yaml diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 68f1c18072..2587d13b9e 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -25,6 +25,7 @@ from esphome.const import ( CONF_OTA, CONF_PASSWORD, CONF_PORT, + CONF_TYPE, CONF_USERNAME, CONF_VERSION, CONF_WEB_SERVER, @@ -44,6 +45,9 @@ _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["json", "web_server_base"] +AUTH_TYPE_BASIC = "basic" +AUTH_TYPE_DIGEST = "digest" + CONF_SORTING_GROUP_ID = "sorting_group_id" CONF_SORTING_GROUPS = "sorting_groups" CONF_SORTING_WEIGHT = "sorting_weight" @@ -85,6 +89,19 @@ def validate_version_deprecated(config: ConfigType) -> ConfigType: return config +def validate_auth_type_deprecated(auth: ConfigType) -> ConfigType: + # Remove before 2027.1.0: the default auth scheme changes from basic to digest. + if CONF_TYPE not in auth: + _LOGGER.warning( + "The 'web_server' 'auth' scheme currently defaults to 'basic', which sends the " + "password over the network in an easily reversible form. The default will change " + "to 'digest' in ESPHome 2027.1.0. To keep using basic authentication, set " + "'type: basic' under 'auth:' explicitly; otherwise set 'type: digest' now to " + "adopt the more secure scheme." + ) + return auth + + def validate_local(config: ConfigType) -> ConfigType: if CONF_LOCAL in config and config[CONF_VERSION] == 1: raise cv.Invalid("'local' is not supported in version 1") @@ -242,15 +259,21 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ALLOWED_ORIGINS): cv.All( cv.ensure_list(validate_origin), cv.Length(min=1) ), - cv.Optional(CONF_AUTH): cv.Schema( - { - cv.Required(CONF_USERNAME): cv.All( - cv.string_strict, cv.Length(min=1) - ), - cv.Required(CONF_PASSWORD): cv.sensitive( - cv.All(cv.string_strict, cv.Length(min=1)) - ), - } + cv.Optional(CONF_AUTH): cv.All( + cv.Schema( + { + cv.Required(CONF_USERNAME): cv.All( + cv.string_strict, cv.Length(min=1) + ), + cv.Required(CONF_PASSWORD): cv.sensitive( + cv.All(cv.string_strict, cv.Length(min=1)) + ), + cv.Optional(CONF_TYPE): cv.one_of( + AUTH_TYPE_BASIC, AUTH_TYPE_DIGEST, lower=True + ), + } + ), + validate_auth_type_deprecated, ), cv.GenerateID(CONF_WEB_SERVER_BASE_ID): cv.use_id( web_server_base.WebServerBase @@ -378,10 +401,15 @@ async def to_code(config): if (allowed_origins := config.get(CONF_ALLOWED_ORIGINS)) is not None: cg.add_define("USE_WEBSERVER_ALLOWED_ORIGINS") cg.add(var.set_allowed_origins(allowed_origins)) - if CONF_AUTH in config: + if (auth := config.get(CONF_AUTH)) is not None: cg.add_define("USE_WEBSERVER_AUTH") - cg.add(paren.set_auth_username(config[CONF_AUTH][CONF_USERNAME])) - cg.add(paren.set_auth_password(config[CONF_AUTH][CONF_PASSWORD])) + # The scheme is fixed at build time so the unused Basic/Digest code path is compiled + # out. Basic is the current default (the absence of this define); an explicit + # 'type: digest' opts in early. Default changes to digest in 2027.1.0. + if auth.get(CONF_TYPE) == AUTH_TYPE_DIGEST: + cg.add_define("USE_WEBSERVER_AUTH_DIGEST") + cg.add(paren.set_auth_username(auth[CONF_USERNAME])) + cg.add(paren.set_auth_password(auth[CONF_PASSWORD])) if CONF_CSS_INCLUDE in config: cg.add_define("USE_WEBSERVER_CSS_INCLUDE") path = CORE.relative_config_path(config[CONF_CSS_INCLUDE]) diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 19c2185fb9..9657853a73 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -59,7 +59,15 @@ class AuthMiddlewareHandler : public MiddlewareHandler { bool check_auth(AsyncWebServerRequest *request) { bool success = request->authenticate(credentials_->username.c_str(), credentials_->password.c_str()); if (!success) { + // The scheme is chosen at build time (USE_WEBSERVER_AUTH_DIGEST); the unused path is + // compiled out. On ESP32 our own server picks the scheme internally. +#if USE_ESP32 request->requestAuthentication(); +#elif defined(USE_WEBSERVER_AUTH_DIGEST) + request->requestAuthentication(nullptr, true); +#else + request->requestAuthentication(nullptr, false); +#endif } return success; } diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 46a389f359..bf5a8666dc 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -16,6 +16,11 @@ #include "utils.h" #include "web_server_idf.h" +#ifdef USE_WEBSERVER_AUTH_DIGEST +#include +#include +#endif + #ifdef USE_WEBSERVER_OTA #include #include "multipart.h" // For parse_multipart_boundary and other utils @@ -372,6 +377,135 @@ void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code } #ifdef USE_WEBSERVER_AUTH + +#ifdef USE_WEBSERVER_AUTH_DIGEST +namespace { + +// Hex-encode `len` bytes into `out`, which must hold at least 2 * len + 1 bytes. Null-terminated. +void bytes_to_hex(const uint8_t *data, size_t len, char *out) { + static const char HEX[] = "0123456789abcdef"; + for (size_t i = 0; i < len; i++) { + out[i * 2] = HEX[data[i] >> 4]; + out[i * 2 + 1] = HEX[data[i] & 0x0f]; + } + out[len * 2] = '\0'; +} + +// Extract the value of a Digest auth parameter (e.g. "nonce") from the comma-separated +// parameter list. Values may be quoted or bare. Returns an empty ref when the key is absent. +// Only whole parameter names match, so "nc" does not match inside "cnonce". +StringRef digest_param(StringRef params, const char *key) { + size_t key_len = strlen(key); + const char *base = params.c_str(); + size_t n = params.size(); + size_t i = 0; + while (i < n) { + while (i < n && (base[i] == ' ' || base[i] == ',')) + i++; + size_t name_start = i; + while (i < n && base[i] != '=' && base[i] != ',') + i++; + if (i >= n || base[i] == ',') + continue; // token without a '=', skip it + size_t name_len = i - name_start; + while (name_len > 0 && base[name_start + name_len - 1] == ' ') + name_len--; + i++; // consume '=' + const char *val_start; + size_t val_len; + if (i < n && base[i] == '"') { + i++; + val_start = base + i; + while (i < n && base[i] != '"') + i++; + val_len = (base + i) - val_start; + if (i < n) + i++; // consume closing quote + } else { + val_start = base + i; + while (i < n && base[i] != ',') + i++; + val_len = (base + i) - val_start; + } + if (name_len == key_len && memcmp(base + name_start, key, key_len) == 0) + return StringRef(val_start, val_len); + while (i < n && base[i] != ',') + i++; + } + return StringRef(); +} + +// Verify an RFC 2617 Digest response. Stateless (the nonce we issued is not tracked), which +// matches the ESPAsyncWebServer backend used on the Arduino platforms. +bool check_digest_auth(const char *username, const char *password, const std::string &header, const char *method) { + const size_t prefix_len = sizeof("Digest ") - 1; + StringRef params(header.c_str() + prefix_len, header.size() - prefix_len); + + if (digest_param(params, "username") != username) + return false; + + StringRef realm = digest_param(params, "realm"); + StringRef nonce = digest_param(params, "nonce"); + StringRef uri = digest_param(params, "uri"); + StringRef qop = digest_param(params, "qop"); + StringRef nc = digest_param(params, "nc"); + StringRef cnonce = digest_param(params, "cnonce"); + StringRef response = digest_param(params, "response"); + if (response.size() != 32) + return false; + + // Compute the three MD5 hashes by streaming the pieces straight into the ROM MD5 engine, so + // nothing is concatenated on the heap. Each hash is emitted as 32 lowercase hex characters. + md5_context_t ctx; + uint8_t digest[16]; + + // HA1 = MD5(username:realm:password) -- uses the realm the client echoed back. + char ha1[33]; + esp_rom_md5_init(&ctx); + esp_rom_md5_update(&ctx, username, strlen(username)); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, realm.c_str(), realm.size()); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, password, strlen(password)); + esp_rom_md5_final(digest, &ctx); + bytes_to_hex(digest, sizeof(digest), ha1); + + // HA2 = MD5(method:uri) -- uses the uri the client echoed back. + char ha2[33]; + esp_rom_md5_init(&ctx); + esp_rom_md5_update(&ctx, method, strlen(method)); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, uri.c_str(), uri.size()); + esp_rom_md5_final(digest, &ctx); + bytes_to_hex(digest, sizeof(digest), ha2); + + // expected = MD5(HA1:nonce:nc:cnonce:qop:HA2) + char expected[33]; + esp_rom_md5_init(&ctx); + esp_rom_md5_update(&ctx, ha1, 32); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, nonce.c_str(), nonce.size()); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, nc.c_str(), nc.size()); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, cnonce.c_str(), cnonce.size()); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, qop.c_str(), qop.size()); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, ha2, 32); + esp_rom_md5_final(digest, &ctx); + bytes_to_hex(digest, sizeof(digest), expected); + + // Constant-time comparison of the two 32-char hex digests. + uint8_t result = 0; + for (size_t i = 0; i < 32; i++) + result |= static_cast(expected[i] ^ response[i]); + return result == 0; +} + +} // namespace +#endif // USE_WEBSERVER_AUTH_DIGEST + bool AsyncWebServerRequest::authenticate(const char *username, const char *password) const { if (username == nullptr || password == nullptr || *username == 0) { return true; @@ -383,9 +517,18 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw auto *auth_str = auth.value().c_str(); +#ifdef USE_WEBSERVER_AUTH_DIGEST + // The build fixed the scheme to Digest, so the Basic path is compiled out entirely. + const auto auth_prefix_len = sizeof("Digest ") - 1; + if (strncmp("Digest ", auth_str, auth_prefix_len) != 0) { + ESP_LOGW(TAG, "Only Digest authorization supported"); + return false; + } + return check_digest_auth(username, password, auth.value(), http_method_str(this->method())); +#else const auto auth_prefix_len = sizeof("Basic ") - 1; if (strncmp("Basic ", auth_str, auth_prefix_len) != 0) { - ESP_LOGW(TAG, "Only Basic authorization supported yet"); + ESP_LOGW(TAG, "Only Basic authorization supported"); return false; } @@ -434,16 +577,33 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw result |= static_cast(digest[i] ^ provided_ch); } return result == 0; +#endif // USE_WEBSERVER_AUTH_DIGEST } -void AsyncWebServerRequest::requestAuthentication(const char *realm) const { +void AsyncWebServerRequest::requestAuthentication() const { httpd_resp_set_hdr(*this, "Connection", "keep-alive"); - // Note: realm is never configured in ESPHome, always nullptr -> "Login Required" - (void) realm; // Unused - always use default +#ifdef USE_WEBSERVER_AUTH_DIGEST + // Issue a fresh random nonce and opaque. The nonce is not stored, so this is stateless and + // does not defend against replay -- its purpose is to keep the password off the wire. + // The header value must stay alive until httpd_resp_send_err() below sends it, so the buffer + // lives on this stack frame (httpd_resp_set_hdr stores the pointer, it does not copy). + uint8_t random_bytes[16]; + char nonce[33]; + char opaque[33]; + char header[160]; + esp_fill_random(random_bytes, sizeof(random_bytes)); + bytes_to_hex(random_bytes, sizeof(random_bytes), nonce); + esp_fill_random(random_bytes, sizeof(random_bytes)); + bytes_to_hex(random_bytes, sizeof(random_bytes), opaque); + snprintf(header, sizeof(header), R"(Digest realm="Login Required", qop="auth", nonce="%s", opaque="%s")", nonce, + opaque); + httpd_resp_set_hdr(*this, "WWW-Authenticate", header); +#else httpd_resp_set_hdr(*this, "WWW-Authenticate", "Basic realm=\"Login Required\""); +#endif // USE_WEBSERVER_AUTH_DIGEST httpd_resp_send_err(*this, HTTPD_401_UNAUTHORIZED, nullptr); } -#endif +#endif // USE_WEBSERVER_AUTH AsyncWebParameter *AsyncWebServerRequest::getParam(const char *name) { // Check cache first - only successful lookups are cached diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 8b5fd5b726..baa55898bb 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -129,7 +129,7 @@ class AsyncWebServerRequest { #ifdef USE_WEBSERVER_AUTH bool authenticate(const char *username, const char *password) const; // NOLINTNEXTLINE(readability-identifier-naming) - void requestAuthentication(const char *realm = nullptr) const; + void requestAuthentication() const; #endif void redirect(const std::string &url); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 78f7769cf6..5c5fc5e8b9 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -298,6 +298,7 @@ #define USE_VOICE_ASSISTANT #define USE_WEBSERVER #define USE_WEBSERVER_AUTH +#define USE_WEBSERVER_AUTH_DIGEST #define USE_WEBSERVER_OTA #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_WEBSERVER_GZIP @@ -408,6 +409,7 @@ #define USE_WEBSERVER #define USE_WEBSERVER_AUTH +#define USE_WEBSERVER_AUTH_DIGEST #define USE_WEBSERVER_PORT 80 // NOLINT #endif @@ -438,6 +440,7 @@ #define USE_LWIP_FAST_SELECT #define USE_WEBSERVER #define USE_WEBSERVER_AUTH +#define USE_WEBSERVER_AUTH_DIGEST #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_ESPHOME_TASK_LOG_BUFFER #define ESPHOME_TASK_LOG_BUFFER_SIZE 768 diff --git a/tests/component_tests/web_server/test_web_server_auth.py b/tests/component_tests/web_server/test_web_server_auth.py new file mode 100644 index 0000000000..82635b26da --- /dev/null +++ b/tests/component_tests/web_server/test_web_server_auth.py @@ -0,0 +1,65 @@ +"""Tests for web_server authentication codegen.""" + +from collections.abc import Callable + +import pytest + +from esphome.core import CORE + +_DEFAULT_CHANGE_WARNING = "default will change to 'digest' in ESPHome 2027.1.0" + + +def _has_define(name: str) -> bool: + return any(d.name == name for d in CORE.defines) + + +def test_web_server_auth_default_is_basic_with_deprecation_warning( + generate_main: Callable[[str], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Auth without an explicit type builds Basic and warns about the upcoming default change.""" + main_cpp = generate_main( + "tests/component_tests/web_server/web_server_auth_default.yaml" + ) + + assert '->set_auth_username("admin");' in main_cpp + assert '->set_auth_password("password");' in main_cpp + assert _has_define("USE_WEBSERVER_AUTH") + assert not _has_define("USE_WEBSERVER_AUTH_DIGEST") + assert _DEFAULT_CHANGE_WARNING in caplog.text + + +def test_web_server_auth_explicit_basic_no_warning( + generate_main: Callable[[str], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Auth type basic builds Basic and does not warn.""" + generate_main("tests/component_tests/web_server/web_server_auth_basic.yaml") + + assert _has_define("USE_WEBSERVER_AUTH") + assert not _has_define("USE_WEBSERVER_AUTH_DIGEST") + assert _DEFAULT_CHANGE_WARNING not in caplog.text + + +def test_web_server_auth_explicit_digest( + generate_main: Callable[[str], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Auth type digest builds Digest and does not warn.""" + generate_main("tests/component_tests/web_server/web_server_auth_digest.yaml") + + assert _has_define("USE_WEBSERVER_AUTH") + assert _has_define("USE_WEBSERVER_AUTH_DIGEST") + assert _DEFAULT_CHANGE_WARNING not in caplog.text + + +def test_web_server_without_auth( + generate_main: Callable[[str], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Without an auth block, no auth is compiled in and no warning is emitted.""" + generate_main("tests/component_tests/web_server/web_server_no_auth.yaml") + + assert not _has_define("USE_WEBSERVER_AUTH") + assert not _has_define("USE_WEBSERVER_AUTH_DIGEST") + assert _DEFAULT_CHANGE_WARNING not in caplog.text diff --git a/tests/component_tests/web_server/web_server_auth_basic.yaml b/tests/component_tests/web_server/web_server_auth_basic.yaml new file mode 100644 index 0000000000..70180f9fbc --- /dev/null +++ b/tests/component_tests/web_server/web_server_auth_basic.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + framework: + type: esp-idf + +wifi: + ssid: MySSID + password: password1 + +web_server: + auth: + username: admin + password: password + type: basic diff --git a/tests/component_tests/web_server/web_server_auth_default.yaml b/tests/component_tests/web_server/web_server_auth_default.yaml new file mode 100644 index 0000000000..076180ab79 --- /dev/null +++ b/tests/component_tests/web_server/web_server_auth_default.yaml @@ -0,0 +1,17 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + framework: + type: esp-idf + +wifi: + ssid: MySSID + password: password1 + +web_server: + auth: + username: admin + password: password diff --git a/tests/component_tests/web_server/web_server_auth_digest.yaml b/tests/component_tests/web_server/web_server_auth_digest.yaml new file mode 100644 index 0000000000..f413787601 --- /dev/null +++ b/tests/component_tests/web_server/web_server_auth_digest.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + framework: + type: esp-idf + +wifi: + ssid: MySSID + password: password1 + +web_server: + auth: + username: admin + password: password + type: digest diff --git a/tests/component_tests/web_server/web_server_no_auth.yaml b/tests/component_tests/web_server/web_server_no_auth.yaml new file mode 100644 index 0000000000..1c7823b4ae --- /dev/null +++ b/tests/component_tests/web_server/web_server_no_auth.yaml @@ -0,0 +1,14 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + framework: + type: esp-idf + +wifi: + ssid: MySSID + password: password1 + +web_server: diff --git a/tests/components/web_server/test.esp32-idf.yaml b/tests/components/web_server/test.esp32-idf.yaml index 858e3b0190..2e091b905a 100644 --- a/tests/components/web_server/test.esp32-idf.yaml +++ b/tests/components/web_server/test.esp32-idf.yaml @@ -5,3 +5,4 @@ web_server: auth: username: admin password: password + type: digest diff --git a/tests/components/web_server/test.esp8266-ard.yaml b/tests/components/web_server/test.esp8266-ard.yaml index 11ad5456ef..2e091b905a 100644 --- a/tests/components/web_server/test.esp8266-ard.yaml +++ b/tests/components/web_server/test.esp8266-ard.yaml @@ -1,2 +1,8 @@ packages: web_server: !include common_v2.yaml + +web_server: + auth: + username: admin + password: password + type: digest diff --git a/tests/components/web_server/test.rp2040-ard.yaml b/tests/components/web_server/test.rp2040-ard.yaml index 11ad5456ef..e4d50d7776 100644 --- a/tests/components/web_server/test.rp2040-ard.yaml +++ b/tests/components/web_server/test.rp2040-ard.yaml @@ -1,2 +1,8 @@ packages: web_server: !include common_v2.yaml + +web_server: + auth: + username: admin + password: password + type: basic diff --git a/tests/components/web_server/validate.esp32-idf.yaml b/tests/components/web_server/validate.esp32-idf.yaml new file mode 100644 index 0000000000..e4d50d7776 --- /dev/null +++ b/tests/components/web_server/validate.esp32-idf.yaml @@ -0,0 +1,8 @@ +packages: + web_server: !include common_v2.yaml + +web_server: + auth: + username: admin + password: password + type: basic From 49bbceb1dad7be50364ad5db11d4796df0061d59 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Jul 2026 13:51:54 -1000 Subject: [PATCH 0869/1815] [libretiny] Keep renamed board generic-ln882hki validating against generic-ln882h (#17542) --- esphome/components/libretiny/__init__.py | 15 ++++++ tests/unit_tests/components/test_libretiny.py | 52 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 tests/unit_tests/components/test_libretiny.py diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 3fde11b1eb..62cef331fd 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -76,12 +76,27 @@ _BLE5_BK_SYS_CONFIG_OPTIONS = [ "CFG_SUPPORT_BLE=0", ] +# Board ids upstream LibreTiny renamed; configs written against the old id +# keep validating and building against the new one (with a warning). +# generic-ln882hki -> generic-ln882h: LibreTiny v1.13.0. +_RENAMED_BOARDS = { + "generic-ln882hki": "generic-ln882h", +} + def _detect_variant(value): if KEY_LIBRETINY not in CORE.data: raise cv.Invalid("Family component didn't populate core data properly!") component: LibreTinyComponent = CORE.data[KEY_LIBRETINY][KEY_COMPONENT_DATA] board = value[CONF_BOARD] + if board not in component.boards and (renamed := _RENAMED_BOARDS.get(board)): + _LOGGER.warning( + "Board '%s' was renamed to '%s'; please update your configuration", + board, + renamed, + ) + value = value.copy() + value[CONF_BOARD] = board = renamed # read board-default family if not specified if board not in component.boards: if CONF_FAMILY not in value: diff --git a/tests/unit_tests/components/test_libretiny.py b/tests/unit_tests/components/test_libretiny.py new file mode 100644 index 0000000000..ee00bdc180 --- /dev/null +++ b/tests/unit_tests/components/test_libretiny.py @@ -0,0 +1,52 @@ +"""Tests for LibreTiny board detection, including renamed-board migration.""" + +import pytest + +from esphome.components.libretiny import _detect_variant +from esphome.components.libretiny.const import ( + FAMILY_LN882H, + KEY_COMPONENT_DATA, + KEY_LIBRETINY, +) +from esphome.components.ln882x import COMPONENT_DATA +import esphome.config_validation as cv +from esphome.const import CONF_BOARD, CONF_FAMILY +from esphome.core import CORE + + +@pytest.fixture +def ln882x_core_data() -> None: + """Populate CORE the way the ln882x component schema does.""" + CORE.data[KEY_LIBRETINY] = {KEY_COMPONENT_DATA: COMPONENT_DATA} + + +def test_detect_variant_known_board_passes(ln882x_core_data: None) -> None: + """A current board id resolves its family without warnings.""" + result = _detect_variant({CONF_BOARD: "generic-ln882h"}) + assert result[CONF_BOARD] == "generic-ln882h" + assert result[CONF_FAMILY] == FAMILY_LN882H + + +def test_detect_variant_renamed_board_migrates( + ln882x_core_data: None, caplog: pytest.LogCaptureFixture +) -> None: + """A pre-rename board id validates against the new id, with a warning.""" + result = _detect_variant({CONF_BOARD: "generic-ln882hki"}) + assert result[CONF_BOARD] == "generic-ln882h" + assert result[CONF_FAMILY] == FAMILY_LN882H + assert "renamed to 'generic-ln882h'" in caplog.text + + +def test_detect_variant_renamed_board_does_not_mutate_input( + ln882x_core_data: None, +) -> None: + """Migration copies the config; the caller's dict keeps the old id.""" + value = {CONF_BOARD: "generic-ln882hki"} + _detect_variant(value) + assert value[CONF_BOARD] == "generic-ln882hki" + + +def test_detect_variant_unknown_board_still_raises(ln882x_core_data: None) -> None: + """Ids outside the rename map keep the family-override error.""" + with pytest.raises(cv.Invalid, match="This board is unknown"): + _detect_variant({CONF_BOARD: "not-a-real-board"}) From f1e4726f4e38a464a26cbed4bbdbc95cfe6d11d7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:53:31 -0400 Subject: [PATCH 0870/1815] [veml7700][as7341][ltr501] Fix device class on raw-count sensors (#17549) --- esphome/components/as7341/sensor.py | 4 ++-- esphome/components/ltr501/sensor.py | 10 +++++----- esphome/components/veml7700/sensor.py | 5 +++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/esphome/components/as7341/sensor.py b/esphome/components/as7341/sensor.py index fa51a1cdfa..8b6cf61028 100644 --- a/esphome/components/as7341/sensor.py +++ b/esphome/components/as7341/sensor.py @@ -5,7 +5,7 @@ from esphome.const import ( CONF_CLEAR, CONF_GAIN, CONF_ID, - DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_EMPTY, ICON_BRIGHTNESS_5, STATE_CLASS_MEASUREMENT, ) @@ -54,7 +54,7 @@ SENSOR_SCHEMA = sensor.sensor_schema( unit_of_measurement=UNIT_COUNTS, icon=ICON_BRIGHTNESS_5, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ) diff --git a/esphome/components/ltr501/sensor.py b/esphome/components/ltr501/sensor.py index cca9330e76..c1fa9009b3 100644 --- a/esphome/components/ltr501/sensor.py +++ b/esphome/components/ltr501/sensor.py @@ -15,7 +15,7 @@ from esphome.const import ( CONF_NAME, CONF_REPEAT, CONF_TYPE, - DEVICE_CLASS_DISTANCE, + DEVICE_CLASS_EMPTY, DEVICE_CLASS_ILLUMINANCE, ICON_BRIGHTNESS_5, ICON_BRIGHTNESS_6, @@ -159,7 +159,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_COUNTS, icon=ICON_BRIGHTNESS_5, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, @@ -169,7 +169,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_COUNTS, icon=ICON_BRIGHTNESS_7, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, @@ -179,7 +179,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_COUNTS, icon=ICON_PROXIMITY, accuracy_decimals=0, - device_class=DEVICE_CLASS_DISTANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, @@ -188,7 +188,7 @@ CONFIG_SCHEMA = cv.All( sensor.sensor_schema( icon=ICON_GAIN, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, diff --git a/esphome/components/veml7700/sensor.py b/esphome/components/veml7700/sensor.py index 6ad2eb417f..d0d3584dc2 100644 --- a/esphome/components/veml7700/sensor.py +++ b/esphome/components/veml7700/sensor.py @@ -14,6 +14,7 @@ from esphome.const import ( CONF_INFRARED, CONF_INTEGRATION_TIME, CONF_NAME, + DEVICE_CLASS_EMPTY, DEVICE_CLASS_ILLUMINANCE, ICON_BRIGHTNESS_5, ICON_BRIGHTNESS_6, @@ -91,7 +92,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_COUNTS, icon=ICON_BRIGHTNESS_6, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, @@ -111,7 +112,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_COUNTS, icon=ICON_BRIGHTNESS_7, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, From ca77cc585c6d3a00ddfd1b6eb1405924a13904a8 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 13 Jul 2026 18:56:03 -0500 Subject: [PATCH 0871/1815] [nextion] Fix unbounded queue growth and OOM crash when display sends no data (#17553) --- esphome/components/nextion/nextion.cpp | 32 +++++++++++++++++++------- esphome/components/nextion/nextion.h | 4 ++++ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index 4ebc717552..bdc66adb70 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -1,6 +1,7 @@ #include "nextion.h" #include +#include #include "esphome/core/application.h" #include "esphome/core/helpers.h" @@ -352,8 +353,9 @@ void Nextion::loop() { this->connection_state_.ignore_is_setup_ = false; } - this->process_serial_(); // Receive serial data - this->process_nextion_commands_(); // Process nextion return commands + this->process_serial_(); // Receive serial data + this->process_nextion_commands_(); // Process nextion return commands + this->purge_stale_queue_entries_(); // Drop expired entries even when the display sends no data if (!this->connection_state_.nextion_reports_is_setup_) { if (this->started_ms_ == 0) @@ -902,6 +904,11 @@ void Nextion::process_nextion_commands_() { this->command_data_.erase(0, to_process_length + DELIMITER_SIZE + 1); } + ESP_LOGN(TAG, "Loop end"); + this->process_serial_(); +} // Nextion::process_nextion_commands_() + +void Nextion::purge_stale_queue_entries_() { const uint32_t ms = App.get_loop_component_start_time(); if (this->max_q_age_ms_ > 0 && !this->nextion_queue_.empty() && @@ -927,10 +934,7 @@ void Nextion::process_nextion_commands_() { } } } - ESP_LOGN(TAG, "Loop end"); - // App.feed_wdt(); Remove before master merge - this->process_serial_(); -} // Nextion::process_nextion_commands_() +} void Nextion::set_nextion_sensor_state(int queue_type, const std::string &name, float state) { this->set_nextion_sensor_state(static_cast(queue_type), name, state); @@ -1101,7 +1105,13 @@ void Nextion::add_no_result_to_queue_(const std::string &variable_name) { new (nextion_queue) nextion::NextionQueue(); // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - nextion_queue->component = new nextion::NextionComponentBase; + nextion_queue->component = new (std::nothrow) nextion::NextionComponentBase; + if (nextion_queue->component == nullptr) { + ESP_LOGW(TAG, "Component alloc failed"); + nextion_queue->~NextionQueue(); + allocator.deallocate(nextion_queue, 1); + return; + } nextion_queue->component->set_variable_name(variable_name); nextion_queue->queue_time = App.get_loop_component_start_time(); @@ -1157,7 +1167,13 @@ void Nextion::add_no_result_to_queue_with_pending_command_(const std::string &va } new (nextion_queue) nextion::NextionQueue(); - nextion_queue->component = new nextion::NextionComponentBase; + nextion_queue->component = new (std::nothrow) nextion::NextionComponentBase; + if (nextion_queue->component == nullptr) { + ESP_LOGW(TAG, "Component alloc failed"); + nextion_queue->~NextionQueue(); + allocator.deallocate(nextion_queue, 1); + return; + } nextion_queue->component->set_variable_name(variable_name); nextion_queue->queue_time = App.get_loop_component_start_time(); nextion_queue->pending_command = command; // Store command for retry diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index d361d9725b..7dc5a4fe44 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -1486,6 +1486,10 @@ class Nextion final : public NextionBase, public PollingComponent, public uart:: void process_nextion_commands_(); void process_serial_(); + /// Drop queue entries older than max_q_age_ms_. Called from loop() so it also runs when the + /// display sends no data at all (disconnected or asleep), which would otherwise grow the queue + /// without bound. + void purge_stale_queue_entries_(); uint16_t touch_sleep_timeout_ = 0; uint8_t wake_up_page_ = 255; #ifdef USE_NEXTION_CONF_START_UP_PAGE From 3e75020007e598fdf1794867565825cdf36c97fd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:28:55 +1200 Subject: [PATCH 0872/1815] [gsl3670] Fix i2c package variant in esp32-s3-idf test (#17535) --- tests/components/gsl3670/test.esp32-s3-idf.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/components/gsl3670/test.esp32-s3-idf.yaml b/tests/components/gsl3670/test.esp32-s3-idf.yaml index 48bb9982d9..5c3f4b931c 100644 --- a/tests/components/gsl3670/test.esp32-s3-idf.yaml +++ b/tests/components/gsl3670/test.esp32-s3-idf.yaml @@ -1,5 +1,5 @@ packages: - i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml xl9535: @@ -10,6 +10,9 @@ display: id: gsl3670_display spi_id: spi_bus model: t-display-s3-pro + # The model's default DC pin (GPIO9) clashes with the shared i2c bus SCL + # pin, so override it onto a free pin for this test. + dc_pin: GPIO5 psram: mode: quad From f38e7f2de21b72122d53552966d4ff073265661d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:38:48 +1200 Subject: [PATCH 0873/1815] [web_server] Add CORS origin checking with allowed_origins (#17530) --- THREAT_MODEL.md | 46 ++++++++++ esphome/components/web_server/__init__.py | 46 +++++++++- esphome/components/web_server/web_server.cpp | 71 +++++++++++++-- esphome/components/web_server/web_server.h | 26 ++++++ esphome/core/defines.h | 1 + .../web_server/test_private_network_access.py | 86 +++++++++++++++++++ tests/components/web_server/common_v2.yaml | 3 + tests/components/web_server/common_v3.yaml | 3 + 8 files changed, 274 insertions(+), 8 deletions(-) create mode 100644 tests/component_tests/web_server/test_private_network_access.py diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index a4355a5055..5816f38176 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -79,6 +79,48 @@ These *are* security bugs in this repo, and we want to hear about them privately - Flaws that weaken the device's API encryption (Noise), OTA, or web server auth below their documented guarantees. +## The web server is an open HTTP API by design + +The `web_server` component exposes a plain HTTP interface for viewing and +controlling entities, and, when the `web_server` OTA platform is enabled, for +uploading firmware at `/update`. Its only access controls are the optional +`web_server` `auth:` credentials and the network the device sits on. + +When `auth:` is not configured, every endpoint is reachable by any client that +can reach the device. This is intentional; enabling `web_server` without `auth:` +is choosing an open control surface, in the same way that running native OTA +without a password leaves OTA open. The API is documented and is meant to be +called by other devices, scripts, and pages. + +As defense-in-depth, the web server checks the `Origin` header on browser requests +to its entity control and state endpoints: a request whose `Origin` does not match +the address the device is served on is rejected, and the `allowed_origins` option +widens that list. This blocks the common "confused deputy" (CSRF) case where a page +the operator visits drives the device through their browser. It is **not** an +authentication boundary: it only constrains browsers. Any client that omits the +`Origin` header — `curl`, scripts, or other non-browser callers on the same +network — reaches every endpoint exactly as before. The check also does not cover +the web OTA `/update` endpoint. The device performs no CSRF-token or `Referer` +validation. The following are therefore **not** vulnerabilities in this repository: + +- Requests without an `Origin` header (for example `curl`) reaching the control + endpoints, whether or not `web_server` `auth:` is set. +- Requests from an origin the operator added to `allowed_origins`. +- Cross-origin or CSRF firmware upload through the web OTA endpoint (`/update`) when + web OTA is enabled without `web_server` `auth:`. The `/update` endpoint is not + covered by the `Origin` check; this is the same exposure as running OTA without a + password. + +The supported defenses are `web_server` `auth:`, protecting OTA (a web password or +a native OTA password), and keeping devices on a trusted, segmented network. See +the security best practices guide linked above. + +What remains in scope is bypassing `web_server` `auth:` when it *is* configured, +and any memory-safety or protocol bug in the server reachable without credentials. + +This section documents the current design and scope; it is not a judgment that the +design is optimal or that it will not change. + ## Explicitly out of scope - Local attackers who already have shell access on the host that runs `esphome`. @@ -86,6 +128,10 @@ These *are* security bugs in this repo, and we want to hear about them privately - Operator-supplied hostile YAML (covered above — config authoring is trusted). - Attacks that require an already-authenticated device peer (someone who already holds the API key / OTA / web credentials). +- Access to the device web server or its web OTA endpoint by non-browser clients + (those that send no `Origin` header). The web server is an open HTTP API by + design (see above); browser cross-origin requests are blocked by default, but the + real controls are `web_server` `auth:` and network isolation. - Anything in the dashboard / device-builder — report that in its own repository (linked at the top). - Deployments where the operator removed protections or exposed credentials. See diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index d9fd27dbc2..68f1c18072 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import gzip import logging +import re import esphome.codegen as cg from esphome.components import web_server_base @@ -46,6 +47,7 @@ AUTO_LOAD = ["json", "web_server_base"] CONF_SORTING_GROUP_ID = "sorting_group_id" CONF_SORTING_GROUPS = "sorting_groups" CONF_SORTING_WEIGHT = "sorting_weight" +CONF_ALLOWED_ORIGINS = "allowed_origins" web_server_ns = cg.esphome_ns.namespace("web_server") @@ -104,6 +106,41 @@ def validate_ota(config: ConfigType) -> ConfigType: return config +# An Origin header is always "scheme://host[:port]" with no path or trailing slash. +_ORIGIN_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*://[^/\s]+$") + + +def validate_origin(value: str) -> str: + # "*" is the wildcard that allows any origin. + if value == "*": + return value + value = cv.string_strict(value) + if not _ORIGIN_RE.match(value): + raise cv.Invalid( + f"'{value}' is not a valid origin. An origin must be 'scheme://host[:port]' with no " + f"path or trailing slash (e.g. 'https://example.com'), or '*' to allow any origin." + ) + # Browsers send the scheme and host lowercased in the Origin header, so normalize to match. + return value.lower() + + +def validate_private_network_access(config: ConfigType) -> ConfigType: + # PNA preflights are always cross-origin, so they can only be authorized against the + # allowed_origins list. Enabling PNA without any origins would deny every PNA request. + if ( + config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS] + and config.get(CONF_ALLOWED_ORIGINS) is None + ): + raise cv.Invalid( + f"'{CONF_ALLOWED_ORIGINS}' must be set when " + f"'{CONF_ENABLE_PRIVATE_NETWORK_ACCESS}' is enabled. List each origin that is " + f"allowed to reach the device (e.g. 'https://example.com'). '*' allows any origin " + f"but is not recommended.", + path=[CONF_ENABLE_PRIVATE_NETWORK_ACCESS], + ) + return config + + def validate_sorting_groups(config: ConfigType) -> ConfigType: if CONF_SORTING_GROUPS in config and config[CONF_VERSION] != 3: raise cv.Invalid( @@ -201,7 +238,10 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_CSS_INCLUDE): cv.file_, cv.Optional(CONF_JS_URL): cv.string, cv.Optional(CONF_JS_INCLUDE): cv.file_, - cv.Optional(CONF_ENABLE_PRIVATE_NETWORK_ACCESS, default=True): cv.boolean, + cv.Optional(CONF_ENABLE_PRIVATE_NETWORK_ACCESS, default=False): cv.boolean, + cv.Optional(CONF_ALLOWED_ORIGINS): cv.All( + cv.ensure_list(validate_origin), cv.Length(min=1) + ), cv.Optional(CONF_AUTH): cv.Schema( { cv.Required(CONF_USERNAME): cv.All( @@ -238,6 +278,7 @@ CONFIG_SCHEMA = cv.All( validate_local, validate_sorting_groups, validate_ota, + validate_private_network_access, _consume_web_server_sockets, ) @@ -334,6 +375,9 @@ async def to_code(config): request_log_listener() # Request a log listener slot for web server log streaming if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]: cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS") + if (allowed_origins := config.get(CONF_ALLOWED_ORIGINS)) is not None: + cg.add_define("USE_WEBSERVER_ALLOWED_ORIGINS") + cg.add(var.set_allowed_origins(allowed_origins)) if CONF_AUTH in config: cg.add_define("USE_WEBSERVER_AUTH") cg.add(paren.set_auth_username(config[CONF_AUTH][CONF_USERNAME])) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 3bba879823..1e6c4e8c62 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -456,9 +456,58 @@ void WebServer::handle_index_request(AsyncWebServerRequest *request) { } #endif +// Read a request header value portably across the Arduino and ESP-IDF web servers. +// Returns an empty string when the header is absent (only allocates when a value is present). +static std::string get_request_header(AsyncWebServerRequest *request, const char *name) { +#ifdef USE_ESP32 + // ESP32 (Arduino and ESP-IDF) uses the web_server_idf backend. + optional value = request->get_header(name); + return value.has_value() ? std::move(*value) : std::string(); +#else + // ESP8266, RP2040 and LibreTiny use the Arduino ESPAsyncWebServer backend. + const AsyncWebHeader *header = request->getHeader(name); + return header != nullptr ? std::string(header->value().c_str()) : std::string(); +#endif +} + +bool WebServer::is_request_origin_allowed_(AsyncWebServerRequest *request, const std::string &origin) { + // No Origin header: not a browser cross-origin request (e.g. curl, native API client). Allow. + if (origin.empty()) + return true; + + // Same-origin: the Origin authority (scheme stripped) matches the Host the request was sent to. + // This covers the device's own IP, mDNS name, or DNS name without knowing any at compile time. + const size_t scheme_sep = origin.find("://"); + if (scheme_sep != std::string::npos) { + const std::string host = get_request_header(request, "Host"); + if (!host.empty() && origin.compare(scheme_sep + 3, std::string::npos, host) == 0) + return true; + } + +#ifdef USE_WEBSERVER_ALLOWED_ORIGINS + // Otherwise the origin must be explicitly allowed via configuration. + for (const char *allowed_origin : this->allowed_origins_) { + // A single "*" entry allows any origin. + if (allowed_origin[0] == '*' && allowed_origin[1] == '\0') + return true; + if (origin == allowed_origin) + return true; + } +#endif + return false; +} + #ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS void WebServer::handle_pna_cors_request(AsyncWebServerRequest *request) { + const std::string origin = get_request_header(request, "Origin"); + if (!this->is_request_origin_allowed_(request, origin)) { + request->send(403); + return; + } + AsyncWebServerResponse *response = request->beginResponse(200, ESPHOME_F("")); + // Echo the specific origin back so the response is valid even when auth (credentials) is enabled. + response->addHeader(ESPHOME_F("Access-Control-Allow-Origin"), origin.empty() ? "*" : origin.c_str()); response->addHeader(ESPHOME_F("Access-Control-Allow-Private-Network"), ESPHOME_F("true")); response->addHeader(ESPHOME_F("Private-Network-Access-Name"), App.get_name().c_str()); char mac_s[18]; @@ -2448,6 +2497,21 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { return; } +#ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS + // Private Network Access preflight carries a cross-origin Origin by design; its handler does the + // origin check itself, so let it run before the general enforcement below. + if (request->method() == HTTP_OPTIONS && request->hasHeader(ESPHOME_F("Access-Control-Request-Private-Network"))) { + this->handle_pna_cors_request(request); + return; + } +#endif + + // Reject cross-origin browser requests unless the origin is explicitly allowed. + if (!this->is_request_origin_allowed_(request, get_request_header(request, "Origin"))) { + request->send(403); + return; + } + #if !defined(USE_ESP32) && defined(USE_ARDUINO) if (url == ESPHOME_F("/events")) { this->events_.add_new_client(this, request); @@ -2469,13 +2533,6 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { } #endif -#ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS - if (request->method() == HTTP_OPTIONS && request->hasHeader(ESPHOME_F("Access-Control-Request-Private-Network"))) { - this->handle_pna_cors_request(request); - return; - } -#endif - // Parse URL for component routing // Pass HTTP method to disambiguate 3-segment URLs (GET=sub-device state, POST=main device action) UrlMatch match = match_url(url.c_str(), url.length(), false, request->method() == HTTP_POST); diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 42182fe510..0fbe4ec551 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -242,6 +242,22 @@ class WebServer final : public Controller, public Component, public AsyncWebHand */ void set_expose_log(bool expose_log) { this->expose_log_ = expose_log; } +#ifdef USE_WEBSERVER_ALLOWED_ORIGINS + /** Set the origins that browsers are allowed to make cross-origin requests from. + * + * Requests without an `Origin` header (e.g. non-browser clients like curl or the native API) + * are always allowed. Requests whose `Origin` matches the address the device is served on + * (same-origin) are always allowed. Any other browser origin must appear in this list, or the + * request is rejected. A single "*" entry allows any origin. Each other entry must exactly match + * the requesting page's `Origin` header (e.g. "https://example.com"). + * + * This list is also used to authorize Private Network Access requests when that feature is enabled. + * + * @param origins The list of allowed origins. + */ + void set_allowed_origins(std::initializer_list origins) { this->allowed_origins_ = origins; } +#endif + // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) /// Setup the internal web server and register handlers. @@ -593,6 +609,16 @@ class WebServer final : public Controller, public Component, public AsyncWebHand const char *js_include_{nullptr}; #endif bool expose_log_{true}; +#ifdef USE_WEBSERVER_ALLOWED_ORIGINS + // Extra origins allowed to make cross-origin browser requests ("*" means any origin). + // Only compiled when allowed_origins is configured; same-origin is always allowed regardless. + FixedVector allowed_origins_; +#endif + + /// Check whether the given request Origin is permitted. Same-origin (matching the Host the + /// request was sent to) and requests without an Origin header are always allowed; any other + /// origin must be listed in allowed_origins. The caller passes the already-read Origin header. + bool is_request_origin_allowed_(AsyncWebServerRequest *request, const std::string &origin); private: #ifdef USE_SENSOR diff --git a/esphome/core/defines.h b/esphome/core/defines.h index bdb0f27f45..78f7769cf6 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -302,6 +302,7 @@ #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_WEBSERVER_GZIP #define USE_WEBSERVER_SORTING +#define USE_WEBSERVER_ALLOWED_ORIGINS #define WEB_SERVER_DEFAULT_HEADERS_COUNT 1 #define USE_CAPTIVE_PORTAL_GZIP #define USE_WIFI_11KV_SUPPORT diff --git a/tests/component_tests/web_server/test_private_network_access.py b/tests/component_tests/web_server/test_private_network_access.py new file mode 100644 index 0000000000..87911c5f9b --- /dev/null +++ b/tests/component_tests/web_server/test_private_network_access.py @@ -0,0 +1,86 @@ +"""Tests for web_server Private Network Access / allowed_origins validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.web_server import ( + CONF_ALLOWED_ORIGINS, + validate_origin, + validate_private_network_access, +) +from esphome.const import CONF_ENABLE_PRIVATE_NETWORK_ACCESS +from esphome.types import ConfigType + + +def test_pna_enabled_without_origins_fails() -> None: + """Enabling PNA without allowed_origins must fail validation.""" + config: ConfigType = {CONF_ENABLE_PRIVATE_NETWORK_ACCESS: True} + + with pytest.raises(cv.Invalid) as exc_info: + validate_private_network_access(config) + + error_msg = str(exc_info.value) + assert CONF_ALLOWED_ORIGINS in error_msg + assert "must be set" in error_msg + + +def test_pna_enabled_with_origins_passes() -> None: + """Enabling PNA with at least one allowed origin passes validation.""" + config: ConfigType = { + CONF_ENABLE_PRIVATE_NETWORK_ACCESS: True, + CONF_ALLOWED_ORIGINS: ["https://app.esphome.io"], + } + assert validate_private_network_access(config) == config + + +def test_origins_without_pna_passes() -> None: + """allowed_origins can be set without enabling PNA (they are independent).""" + config: ConfigType = { + CONF_ENABLE_PRIVATE_NETWORK_ACCESS: False, + CONF_ALLOWED_ORIGINS: ["https://app.esphome.io"], + } + assert validate_private_network_access(config) == config + + +def test_pna_disabled_without_origins_passes() -> None: + """PNA disabled and no origins specified passes validation.""" + config: ConfigType = {CONF_ENABLE_PRIVATE_NETWORK_ACCESS: False} + assert validate_private_network_access(config) == config + + +def test_validate_origin_wildcard() -> None: + """The '*' wildcard is accepted as-is.""" + assert validate_origin("*") == "*" + + +@pytest.mark.parametrize( + "value", + [ + "https://example.com", + "http://example.com:8080", + "https://192.168.1.5", + ], +) +def test_validate_origin_valid(value: str) -> None: + """Well-formed origins pass through unchanged.""" + assert validate_origin(value) == value + + +def test_validate_origin_lowercased() -> None: + """Scheme and host are normalized to lowercase to match the browser Origin header.""" + assert validate_origin("HTTPS://App.Example.com") == "https://app.example.com" + + +@pytest.mark.parametrize( + "value", + [ + "https://example.com/", # trailing slash + "https://example.com/path", # path segment + "example.com", # missing scheme + "", # empty + ], +) +def test_validate_origin_invalid(value: str) -> None: + """Malformed origins are rejected at config time instead of silently 403ing.""" + with pytest.raises(cv.Invalid, match="not a valid origin"): + validate_origin(value) diff --git a/tests/components/web_server/common_v2.yaml b/tests/components/web_server/common_v2.yaml index f2b15e484d..b9bc0bbf61 100644 --- a/tests/components/web_server/common_v2.yaml +++ b/tests/components/web_server/common_v2.yaml @@ -5,3 +5,6 @@ web_server: port: 8080 version: 2 compression: br + enable_private_network_access: true + allowed_origins: + - https://app.esphome.io diff --git a/tests/components/web_server/common_v3.yaml b/tests/components/web_server/common_v3.yaml index bdacaaddbe..354d7bb6ac 100644 --- a/tests/components/web_server/common_v3.yaml +++ b/tests/components/web_server/common_v3.yaml @@ -4,6 +4,9 @@ packages: web_server: port: 8080 version: 3 + # allowed_origins can be set independently of Private Network Access + allowed_origins: + - https://app.esphome.io sorting_groups: - id: sorting_group_1 name: "Group 1 Diplayed Last" From af9a0404d9b4e32385ccd8cb412512a352870dc2 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Sun, 12 Jul 2026 23:23:40 -0500 Subject: [PATCH 0874/1815] [esp32] Do not require verification_key with Secure Boot V2 signing schemes (#17497) --- esphome/components/esp32/__init__.py | 99 +++++++++++++++---- tests/component_tests/esp32/test_esp32.py | 75 ++++++++++++++ ...date-signed_ota_external.esp32-s3-idf.yaml | 11 +++ 3 files changed, 167 insertions(+), 18 deletions(-) create mode 100644 tests/components/esp32/validate-signed_ota_external.esp32-s3-idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 7c926fe28e..9b568dd629 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1160,6 +1160,74 @@ def _ota_downgrade_protection_errors( return errs +_SIGNED_OTA_VERIFICATION_SCHEMA = cv.Schema( + { + cv.Optional(CONF_SIGNING_KEY): cv.file_, + cv.Optional(CONF_VERIFICATION_KEY): cv.file_, + cv.Optional(CONF_SIGNING_SCHEME, default="rsa3072"): cv.one_of( + *SIGNING_SCHEMES, lower=True + ), + } +) + + +@schema_extractor("schema") +def _validate_signed_ota_verification(value): + if value is SCHEMA_EXTRACT: + # Expose the inner schema so the language-schema dumper can walk the + # signing_key / verification_key / signing_scheme options. + return _SIGNED_OTA_VERIFICATION_SCHEMA + if value is None: + # A bare `signed_ota_verification:` block is valid: the default V2 + # scheme needs no keys (verify externally-signed binaries). + value = {} + return _validate_signed_ota_keys(_SIGNED_OTA_VERIFICATION_SCHEMA(value)) + + +def _validate_signed_ota_keys(config: ConfigType) -> ConfigType: + """Validate the signing/verification key combination for the selected scheme. + + A verification key is only used by the Secure Boot V1 scheme (ecdsa_v1): + the public key is compiled into the app so it can verify externally-signed + images. ESP-IDF's CONFIG_SECURE_BOOT_VERIFICATION_KEY only takes effect + when the V1 ECDSA scheme is selected and binaries are not signed during + the build (see SECURE_BOOT_VERIFICATION_KEY in the bootloader Kconfig). + + The V2 schemes (rsa3072, ecdsa256) embed the public key in the signature + block appended to each image, so verifying externally-signed binaries + needs no key in the config at all -- omitting both keys selects that + external-signing mode. + """ + has_signing_key = CONF_SIGNING_KEY in config + has_verification_key = CONF_VERIFICATION_KEY in config + scheme = config[CONF_SIGNING_SCHEME] + if has_signing_key and has_verification_key: + raise cv.Invalid( + f"Provide at most one of '{CONF_SIGNING_KEY}' and " + f"'{CONF_VERIFICATION_KEY}', not both.", + path=[CONF_VERIFICATION_KEY], + ) + if scheme == "ecdsa_v1": + if not has_signing_key and not has_verification_key: + raise cv.Invalid( + f"Signing scheme 'ecdsa_v1' requires either '{CONF_SIGNING_KEY}' " + f"(to sign binaries during the build) or '{CONF_VERIFICATION_KEY}' " + f"(to verify binaries signed externally).", + path=[CONF_SIGNING_KEY], + ) + elif has_verification_key: + raise cv.Invalid( + f"'{CONF_VERIFICATION_KEY}' is only used with signing scheme " + f"'ecdsa_v1'. With '{scheme}' the public key is embedded in each " + f"image's signature block, so no key file is needed to verify " + f"externally-signed binaries: remove '{CONF_VERIFICATION_KEY}', and " + f"set '{CONF_SIGNING_KEY}' only if binaries should be signed during " + f"the build.", + path=[CONF_VERIFICATION_KEY], + ) + return config + + def final_validate(config): # Imported locally to avoid circular import issues from esphome.components.psram import DOMAIN as PSRAM_DOMAIN @@ -1361,7 +1429,7 @@ def final_validate(config): ) else: _LOGGER.info( - "Signed OTA verification is configured with a public verification key. " + "Signed OTA verification is enabled without a signing key. " "Binaries will NOT be signed automatically during build. " "You must sign them externally before flashing." ) @@ -1640,18 +1708,9 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional( CONF_ENABLE_OTA_DOWNGRADE_PROTECTION, default=False ): cv.boolean, - cv.Optional(CONF_SIGNED_OTA_VERIFICATION): cv.All( - cv.Schema( - { - cv.Optional(CONF_SIGNING_KEY): cv.file_, - cv.Optional(CONF_VERIFICATION_KEY): cv.file_, - cv.Optional( - CONF_SIGNING_SCHEME, default="rsa3072" - ): cv.one_of(*SIGNING_SCHEMES, lower=True), - } - ), - cv.has_exactly_one_key(CONF_SIGNING_KEY, CONF_VERIFICATION_KEY), - ), + cv.Optional( + CONF_SIGNED_OTA_VERIFICATION + ): _validate_signed_ota_verification, cv.Optional(CONF_NVS_ENCRYPTION): cv.Schema( { # eFuse key block (0-5) that stores the HMAC key from @@ -2498,12 +2557,16 @@ async def to_code(config): signed_ota[CONF_SIGNING_KEY].resolve().as_posix(), ) else: - # Public key mode — verification only, external signing required + # External signing mode — binaries must be signed after the build add_idf_sdkconfig_option("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES", False) - add_idf_sdkconfig_option( - "CONFIG_SECURE_BOOT_VERIFICATION_KEY", - signed_ota[CONF_VERIFICATION_KEY].resolve().as_posix(), - ) + if CONF_VERIFICATION_KEY in signed_ota: + # V1 ECDSA only: the public key is compiled into the app to + # verify externally-signed images. V2 schemes carry the public + # key in each image's signature block and need no key here. + add_idf_sdkconfig_option( + "CONFIG_SECURE_BOOT_VERIFICATION_KEY", + signed_ota[CONF_VERIFICATION_KEY].resolve().as_posix(), + ) cg.add_define("USE_OTA_SIGNED_VERIFICATION") diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index dd8881e46f..fdca70bf2c 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -665,3 +665,78 @@ def test_downgrade_protection_reports_all_unmet_requirements() -> None: # No project version and no signing -> two distinct errors. errs = _ota_downgrade_protection_errors(None, signed_ota_enabled=False) assert len(errs) == 2 + + +@pytest.mark.parametrize( + "config", + [ + # V2 schemes: signing key (sign during build) or no key at all + # (external signing; the public key travels in the signature block). + {"signing_scheme": "rsa3072", "signing_key": "key.pem"}, + {"signing_scheme": "rsa3072"}, + {"signing_scheme": "ecdsa256", "signing_key": "key.pem"}, + {"signing_scheme": "ecdsa256"}, + # V1 ECDSA: exactly one of signing key / verification key. + {"signing_scheme": "ecdsa_v1", "signing_key": "key.pem"}, + {"signing_scheme": "ecdsa_v1", "verification_key": "key.bin"}, + ], +) +def test_signed_ota_keys_valid_combinations(config: dict) -> None: + from esphome.components.esp32 import _validate_signed_ota_keys + + assert _validate_signed_ota_keys(config) is config + + +@pytest.mark.parametrize("value", [None, {}]) +def test_signed_ota_bare_block_selects_v2_external_signing(value: dict | None) -> None: + """A bare `signed_ota_verification:` block is valid: the default V2 + scheme embeds the public key in the signature block, so verifying + externally-signed binaries needs no keys in the config.""" + from esphome.components.esp32 import _validate_signed_ota_verification + + config = _validate_signed_ota_verification(value) + assert config == {"signing_scheme": "rsa3072"} + + +@pytest.mark.parametrize( + ("config", "match"), + [ + # A verification key is meaningless with the V2 schemes -- the public + # key is embedded in each image's signature block. + ( + {"signing_scheme": "rsa3072", "verification_key": "key.bin"}, + "only used with signing scheme 'ecdsa_v1'", + ), + ( + {"signing_scheme": "ecdsa256", "verification_key": "key.bin"}, + "only used with signing scheme 'ecdsa_v1'", + ), + # V1 ECDSA needs a key either way. + ( + {"signing_scheme": "ecdsa_v1"}, + "Signing scheme 'ecdsa_v1' requires either", + ), + # Never both keys at once. + ( + { + "signing_scheme": "rsa3072", + "signing_key": "key.pem", + "verification_key": "key.bin", + }, + "not both", + ), + ( + { + "signing_scheme": "ecdsa_v1", + "signing_key": "key.pem", + "verification_key": "key.bin", + }, + "not both", + ), + ], +) +def test_signed_ota_keys_invalid_combinations(config: dict, match: str) -> None: + from esphome.components.esp32 import _validate_signed_ota_keys + + with pytest.raises(cv.Invalid, match=match): + _validate_signed_ota_keys(config) diff --git a/tests/components/esp32/validate-signed_ota_external.esp32-s3-idf.yaml b/tests/components/esp32/validate-signed_ota_external.esp32-s3-idf.yaml new file mode 100644 index 0000000000..5b57993e87 --- /dev/null +++ b/tests/components/esp32/validate-signed_ota_external.esp32-s3-idf.yaml @@ -0,0 +1,11 @@ +# Secure Boot V2 schemes carry the public key inside each image's signature +# block, so verifying externally-signed binaries needs no key in the config: +# a bare block enables verification with the default rsa3072 scheme. +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + signed_ota_verification: + +<<: !include common.yaml From 583adc9e69a30898556ce68f945fe6718abb7829 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:23:55 +1200 Subject: [PATCH 0875/1815] [web_server] Use dict-style packages in tests so they can be batch-grouped (#17544) --- tests/components/web_server/test.esp32-ard.yaml | 3 ++- tests/components/web_server/test.esp32-idf.yaml | 3 ++- tests/components/web_server/test.esp8266-ard.yaml | 3 ++- tests/components/web_server/test.rp2040-ard.yaml | 3 ++- tests/components/web_server/test_v1.esp32-ard.yaml | 3 ++- tests/components/web_server/test_v1.esp32-idf.yaml | 3 ++- tests/components/web_server/test_v3.esp32-ard.yaml | 3 ++- 7 files changed, 14 insertions(+), 7 deletions(-) diff --git a/tests/components/web_server/test.esp32-ard.yaml b/tests/components/web_server/test.esp32-ard.yaml index 7e6658e20e..11ad5456ef 100644 --- a/tests/components/web_server/test.esp32-ard.yaml +++ b/tests/components/web_server/test.esp32-ard.yaml @@ -1 +1,2 @@ -<<: !include common_v2.yaml +packages: + web_server: !include common_v2.yaml diff --git a/tests/components/web_server/test.esp32-idf.yaml b/tests/components/web_server/test.esp32-idf.yaml index 24b292d0d6..858e3b0190 100644 --- a/tests/components/web_server/test.esp32-idf.yaml +++ b/tests/components/web_server/test.esp32-idf.yaml @@ -1,4 +1,5 @@ -<<: !include common_v2.yaml +packages: + web_server: !include common_v2.yaml web_server: auth: diff --git a/tests/components/web_server/test.esp8266-ard.yaml b/tests/components/web_server/test.esp8266-ard.yaml index 7e6658e20e..11ad5456ef 100644 --- a/tests/components/web_server/test.esp8266-ard.yaml +++ b/tests/components/web_server/test.esp8266-ard.yaml @@ -1 +1,2 @@ -<<: !include common_v2.yaml +packages: + web_server: !include common_v2.yaml diff --git a/tests/components/web_server/test.rp2040-ard.yaml b/tests/components/web_server/test.rp2040-ard.yaml index 7e6658e20e..11ad5456ef 100644 --- a/tests/components/web_server/test.rp2040-ard.yaml +++ b/tests/components/web_server/test.rp2040-ard.yaml @@ -1 +1,2 @@ -<<: !include common_v2.yaml +packages: + web_server: !include common_v2.yaml diff --git a/tests/components/web_server/test_v1.esp32-ard.yaml b/tests/components/web_server/test_v1.esp32-ard.yaml index 389a930284..1d563de834 100644 --- a/tests/components/web_server/test_v1.esp32-ard.yaml +++ b/tests/components/web_server/test_v1.esp32-ard.yaml @@ -1 +1,2 @@ -<<: !include common_v1.yaml +packages: + web_server: !include common_v1.yaml diff --git a/tests/components/web_server/test_v1.esp32-idf.yaml b/tests/components/web_server/test_v1.esp32-idf.yaml index 389a930284..1d563de834 100644 --- a/tests/components/web_server/test_v1.esp32-idf.yaml +++ b/tests/components/web_server/test_v1.esp32-idf.yaml @@ -1 +1,2 @@ -<<: !include common_v1.yaml +packages: + web_server: !include common_v1.yaml diff --git a/tests/components/web_server/test_v3.esp32-ard.yaml b/tests/components/web_server/test_v3.esp32-ard.yaml index 00d05521e4..956a88bc68 100644 --- a/tests/components/web_server/test_v3.esp32-ard.yaml +++ b/tests/components/web_server/test_v3.esp32-ard.yaml @@ -1 +1,2 @@ -<<: !include common_v3.yaml +packages: + web_server: !include common_v3.yaml From 989797be5356506765574b480be4681a96d7b53c Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:33:44 +1200 Subject: [PATCH 0876/1815] [web_server] Add HTTP digest authentication with selectable scheme (#17541) --- esphome/components/web_server/__init__.py | 52 ++++-- .../web_server_base/web_server_base.h | 8 + .../web_server_idf/web_server_idf.cpp | 170 +++++++++++++++++- .../web_server_idf/web_server_idf.h | 2 +- esphome/core/defines.h | 3 + .../web_server/test_web_server_auth.py | 65 +++++++ .../web_server/web_server_auth_basic.yaml | 18 ++ .../web_server/web_server_auth_default.yaml | 17 ++ .../web_server/web_server_auth_digest.yaml | 18 ++ .../web_server/web_server_no_auth.yaml | 14 ++ .../components/web_server/test.esp32-idf.yaml | 1 + .../web_server/test.esp8266-ard.yaml | 6 + .../web_server/test.rp2040-ard.yaml | 6 + .../web_server/validate.esp32-idf.yaml | 8 + 14 files changed, 370 insertions(+), 18 deletions(-) create mode 100644 tests/component_tests/web_server/test_web_server_auth.py create mode 100644 tests/component_tests/web_server/web_server_auth_basic.yaml create mode 100644 tests/component_tests/web_server/web_server_auth_default.yaml create mode 100644 tests/component_tests/web_server/web_server_auth_digest.yaml create mode 100644 tests/component_tests/web_server/web_server_no_auth.yaml create mode 100644 tests/components/web_server/validate.esp32-idf.yaml diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 68f1c18072..2587d13b9e 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -25,6 +25,7 @@ from esphome.const import ( CONF_OTA, CONF_PASSWORD, CONF_PORT, + CONF_TYPE, CONF_USERNAME, CONF_VERSION, CONF_WEB_SERVER, @@ -44,6 +45,9 @@ _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["json", "web_server_base"] +AUTH_TYPE_BASIC = "basic" +AUTH_TYPE_DIGEST = "digest" + CONF_SORTING_GROUP_ID = "sorting_group_id" CONF_SORTING_GROUPS = "sorting_groups" CONF_SORTING_WEIGHT = "sorting_weight" @@ -85,6 +89,19 @@ def validate_version_deprecated(config: ConfigType) -> ConfigType: return config +def validate_auth_type_deprecated(auth: ConfigType) -> ConfigType: + # Remove before 2027.1.0: the default auth scheme changes from basic to digest. + if CONF_TYPE not in auth: + _LOGGER.warning( + "The 'web_server' 'auth' scheme currently defaults to 'basic', which sends the " + "password over the network in an easily reversible form. The default will change " + "to 'digest' in ESPHome 2027.1.0. To keep using basic authentication, set " + "'type: basic' under 'auth:' explicitly; otherwise set 'type: digest' now to " + "adopt the more secure scheme." + ) + return auth + + def validate_local(config: ConfigType) -> ConfigType: if CONF_LOCAL in config and config[CONF_VERSION] == 1: raise cv.Invalid("'local' is not supported in version 1") @@ -242,15 +259,21 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ALLOWED_ORIGINS): cv.All( cv.ensure_list(validate_origin), cv.Length(min=1) ), - cv.Optional(CONF_AUTH): cv.Schema( - { - cv.Required(CONF_USERNAME): cv.All( - cv.string_strict, cv.Length(min=1) - ), - cv.Required(CONF_PASSWORD): cv.sensitive( - cv.All(cv.string_strict, cv.Length(min=1)) - ), - } + cv.Optional(CONF_AUTH): cv.All( + cv.Schema( + { + cv.Required(CONF_USERNAME): cv.All( + cv.string_strict, cv.Length(min=1) + ), + cv.Required(CONF_PASSWORD): cv.sensitive( + cv.All(cv.string_strict, cv.Length(min=1)) + ), + cv.Optional(CONF_TYPE): cv.one_of( + AUTH_TYPE_BASIC, AUTH_TYPE_DIGEST, lower=True + ), + } + ), + validate_auth_type_deprecated, ), cv.GenerateID(CONF_WEB_SERVER_BASE_ID): cv.use_id( web_server_base.WebServerBase @@ -378,10 +401,15 @@ async def to_code(config): if (allowed_origins := config.get(CONF_ALLOWED_ORIGINS)) is not None: cg.add_define("USE_WEBSERVER_ALLOWED_ORIGINS") cg.add(var.set_allowed_origins(allowed_origins)) - if CONF_AUTH in config: + if (auth := config.get(CONF_AUTH)) is not None: cg.add_define("USE_WEBSERVER_AUTH") - cg.add(paren.set_auth_username(config[CONF_AUTH][CONF_USERNAME])) - cg.add(paren.set_auth_password(config[CONF_AUTH][CONF_PASSWORD])) + # The scheme is fixed at build time so the unused Basic/Digest code path is compiled + # out. Basic is the current default (the absence of this define); an explicit + # 'type: digest' opts in early. Default changes to digest in 2027.1.0. + if auth.get(CONF_TYPE) == AUTH_TYPE_DIGEST: + cg.add_define("USE_WEBSERVER_AUTH_DIGEST") + cg.add(paren.set_auth_username(auth[CONF_USERNAME])) + cg.add(paren.set_auth_password(auth[CONF_PASSWORD])) if CONF_CSS_INCLUDE in config: cg.add_define("USE_WEBSERVER_CSS_INCLUDE") path = CORE.relative_config_path(config[CONF_CSS_INCLUDE]) diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 19c2185fb9..9657853a73 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -59,7 +59,15 @@ class AuthMiddlewareHandler : public MiddlewareHandler { bool check_auth(AsyncWebServerRequest *request) { bool success = request->authenticate(credentials_->username.c_str(), credentials_->password.c_str()); if (!success) { + // The scheme is chosen at build time (USE_WEBSERVER_AUTH_DIGEST); the unused path is + // compiled out. On ESP32 our own server picks the scheme internally. +#if USE_ESP32 request->requestAuthentication(); +#elif defined(USE_WEBSERVER_AUTH_DIGEST) + request->requestAuthentication(nullptr, true); +#else + request->requestAuthentication(nullptr, false); +#endif } return success; } diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 46a389f359..bf5a8666dc 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -16,6 +16,11 @@ #include "utils.h" #include "web_server_idf.h" +#ifdef USE_WEBSERVER_AUTH_DIGEST +#include +#include +#endif + #ifdef USE_WEBSERVER_OTA #include #include "multipart.h" // For parse_multipart_boundary and other utils @@ -372,6 +377,135 @@ void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code } #ifdef USE_WEBSERVER_AUTH + +#ifdef USE_WEBSERVER_AUTH_DIGEST +namespace { + +// Hex-encode `len` bytes into `out`, which must hold at least 2 * len + 1 bytes. Null-terminated. +void bytes_to_hex(const uint8_t *data, size_t len, char *out) { + static const char HEX[] = "0123456789abcdef"; + for (size_t i = 0; i < len; i++) { + out[i * 2] = HEX[data[i] >> 4]; + out[i * 2 + 1] = HEX[data[i] & 0x0f]; + } + out[len * 2] = '\0'; +} + +// Extract the value of a Digest auth parameter (e.g. "nonce") from the comma-separated +// parameter list. Values may be quoted or bare. Returns an empty ref when the key is absent. +// Only whole parameter names match, so "nc" does not match inside "cnonce". +StringRef digest_param(StringRef params, const char *key) { + size_t key_len = strlen(key); + const char *base = params.c_str(); + size_t n = params.size(); + size_t i = 0; + while (i < n) { + while (i < n && (base[i] == ' ' || base[i] == ',')) + i++; + size_t name_start = i; + while (i < n && base[i] != '=' && base[i] != ',') + i++; + if (i >= n || base[i] == ',') + continue; // token without a '=', skip it + size_t name_len = i - name_start; + while (name_len > 0 && base[name_start + name_len - 1] == ' ') + name_len--; + i++; // consume '=' + const char *val_start; + size_t val_len; + if (i < n && base[i] == '"') { + i++; + val_start = base + i; + while (i < n && base[i] != '"') + i++; + val_len = (base + i) - val_start; + if (i < n) + i++; // consume closing quote + } else { + val_start = base + i; + while (i < n && base[i] != ',') + i++; + val_len = (base + i) - val_start; + } + if (name_len == key_len && memcmp(base + name_start, key, key_len) == 0) + return StringRef(val_start, val_len); + while (i < n && base[i] != ',') + i++; + } + return StringRef(); +} + +// Verify an RFC 2617 Digest response. Stateless (the nonce we issued is not tracked), which +// matches the ESPAsyncWebServer backend used on the Arduino platforms. +bool check_digest_auth(const char *username, const char *password, const std::string &header, const char *method) { + const size_t prefix_len = sizeof("Digest ") - 1; + StringRef params(header.c_str() + prefix_len, header.size() - prefix_len); + + if (digest_param(params, "username") != username) + return false; + + StringRef realm = digest_param(params, "realm"); + StringRef nonce = digest_param(params, "nonce"); + StringRef uri = digest_param(params, "uri"); + StringRef qop = digest_param(params, "qop"); + StringRef nc = digest_param(params, "nc"); + StringRef cnonce = digest_param(params, "cnonce"); + StringRef response = digest_param(params, "response"); + if (response.size() != 32) + return false; + + // Compute the three MD5 hashes by streaming the pieces straight into the ROM MD5 engine, so + // nothing is concatenated on the heap. Each hash is emitted as 32 lowercase hex characters. + md5_context_t ctx; + uint8_t digest[16]; + + // HA1 = MD5(username:realm:password) -- uses the realm the client echoed back. + char ha1[33]; + esp_rom_md5_init(&ctx); + esp_rom_md5_update(&ctx, username, strlen(username)); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, realm.c_str(), realm.size()); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, password, strlen(password)); + esp_rom_md5_final(digest, &ctx); + bytes_to_hex(digest, sizeof(digest), ha1); + + // HA2 = MD5(method:uri) -- uses the uri the client echoed back. + char ha2[33]; + esp_rom_md5_init(&ctx); + esp_rom_md5_update(&ctx, method, strlen(method)); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, uri.c_str(), uri.size()); + esp_rom_md5_final(digest, &ctx); + bytes_to_hex(digest, sizeof(digest), ha2); + + // expected = MD5(HA1:nonce:nc:cnonce:qop:HA2) + char expected[33]; + esp_rom_md5_init(&ctx); + esp_rom_md5_update(&ctx, ha1, 32); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, nonce.c_str(), nonce.size()); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, nc.c_str(), nc.size()); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, cnonce.c_str(), cnonce.size()); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, qop.c_str(), qop.size()); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, ha2, 32); + esp_rom_md5_final(digest, &ctx); + bytes_to_hex(digest, sizeof(digest), expected); + + // Constant-time comparison of the two 32-char hex digests. + uint8_t result = 0; + for (size_t i = 0; i < 32; i++) + result |= static_cast(expected[i] ^ response[i]); + return result == 0; +} + +} // namespace +#endif // USE_WEBSERVER_AUTH_DIGEST + bool AsyncWebServerRequest::authenticate(const char *username, const char *password) const { if (username == nullptr || password == nullptr || *username == 0) { return true; @@ -383,9 +517,18 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw auto *auth_str = auth.value().c_str(); +#ifdef USE_WEBSERVER_AUTH_DIGEST + // The build fixed the scheme to Digest, so the Basic path is compiled out entirely. + const auto auth_prefix_len = sizeof("Digest ") - 1; + if (strncmp("Digest ", auth_str, auth_prefix_len) != 0) { + ESP_LOGW(TAG, "Only Digest authorization supported"); + return false; + } + return check_digest_auth(username, password, auth.value(), http_method_str(this->method())); +#else const auto auth_prefix_len = sizeof("Basic ") - 1; if (strncmp("Basic ", auth_str, auth_prefix_len) != 0) { - ESP_LOGW(TAG, "Only Basic authorization supported yet"); + ESP_LOGW(TAG, "Only Basic authorization supported"); return false; } @@ -434,16 +577,33 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw result |= static_cast(digest[i] ^ provided_ch); } return result == 0; +#endif // USE_WEBSERVER_AUTH_DIGEST } -void AsyncWebServerRequest::requestAuthentication(const char *realm) const { +void AsyncWebServerRequest::requestAuthentication() const { httpd_resp_set_hdr(*this, "Connection", "keep-alive"); - // Note: realm is never configured in ESPHome, always nullptr -> "Login Required" - (void) realm; // Unused - always use default +#ifdef USE_WEBSERVER_AUTH_DIGEST + // Issue a fresh random nonce and opaque. The nonce is not stored, so this is stateless and + // does not defend against replay -- its purpose is to keep the password off the wire. + // The header value must stay alive until httpd_resp_send_err() below sends it, so the buffer + // lives on this stack frame (httpd_resp_set_hdr stores the pointer, it does not copy). + uint8_t random_bytes[16]; + char nonce[33]; + char opaque[33]; + char header[160]; + esp_fill_random(random_bytes, sizeof(random_bytes)); + bytes_to_hex(random_bytes, sizeof(random_bytes), nonce); + esp_fill_random(random_bytes, sizeof(random_bytes)); + bytes_to_hex(random_bytes, sizeof(random_bytes), opaque); + snprintf(header, sizeof(header), R"(Digest realm="Login Required", qop="auth", nonce="%s", opaque="%s")", nonce, + opaque); + httpd_resp_set_hdr(*this, "WWW-Authenticate", header); +#else httpd_resp_set_hdr(*this, "WWW-Authenticate", "Basic realm=\"Login Required\""); +#endif // USE_WEBSERVER_AUTH_DIGEST httpd_resp_send_err(*this, HTTPD_401_UNAUTHORIZED, nullptr); } -#endif +#endif // USE_WEBSERVER_AUTH AsyncWebParameter *AsyncWebServerRequest::getParam(const char *name) { // Check cache first - only successful lookups are cached diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 8b5fd5b726..baa55898bb 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -129,7 +129,7 @@ class AsyncWebServerRequest { #ifdef USE_WEBSERVER_AUTH bool authenticate(const char *username, const char *password) const; // NOLINTNEXTLINE(readability-identifier-naming) - void requestAuthentication(const char *realm = nullptr) const; + void requestAuthentication() const; #endif void redirect(const std::string &url); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 78f7769cf6..5c5fc5e8b9 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -298,6 +298,7 @@ #define USE_VOICE_ASSISTANT #define USE_WEBSERVER #define USE_WEBSERVER_AUTH +#define USE_WEBSERVER_AUTH_DIGEST #define USE_WEBSERVER_OTA #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_WEBSERVER_GZIP @@ -408,6 +409,7 @@ #define USE_WEBSERVER #define USE_WEBSERVER_AUTH +#define USE_WEBSERVER_AUTH_DIGEST #define USE_WEBSERVER_PORT 80 // NOLINT #endif @@ -438,6 +440,7 @@ #define USE_LWIP_FAST_SELECT #define USE_WEBSERVER #define USE_WEBSERVER_AUTH +#define USE_WEBSERVER_AUTH_DIGEST #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_ESPHOME_TASK_LOG_BUFFER #define ESPHOME_TASK_LOG_BUFFER_SIZE 768 diff --git a/tests/component_tests/web_server/test_web_server_auth.py b/tests/component_tests/web_server/test_web_server_auth.py new file mode 100644 index 0000000000..82635b26da --- /dev/null +++ b/tests/component_tests/web_server/test_web_server_auth.py @@ -0,0 +1,65 @@ +"""Tests for web_server authentication codegen.""" + +from collections.abc import Callable + +import pytest + +from esphome.core import CORE + +_DEFAULT_CHANGE_WARNING = "default will change to 'digest' in ESPHome 2027.1.0" + + +def _has_define(name: str) -> bool: + return any(d.name == name for d in CORE.defines) + + +def test_web_server_auth_default_is_basic_with_deprecation_warning( + generate_main: Callable[[str], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Auth without an explicit type builds Basic and warns about the upcoming default change.""" + main_cpp = generate_main( + "tests/component_tests/web_server/web_server_auth_default.yaml" + ) + + assert '->set_auth_username("admin");' in main_cpp + assert '->set_auth_password("password");' in main_cpp + assert _has_define("USE_WEBSERVER_AUTH") + assert not _has_define("USE_WEBSERVER_AUTH_DIGEST") + assert _DEFAULT_CHANGE_WARNING in caplog.text + + +def test_web_server_auth_explicit_basic_no_warning( + generate_main: Callable[[str], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Auth type basic builds Basic and does not warn.""" + generate_main("tests/component_tests/web_server/web_server_auth_basic.yaml") + + assert _has_define("USE_WEBSERVER_AUTH") + assert not _has_define("USE_WEBSERVER_AUTH_DIGEST") + assert _DEFAULT_CHANGE_WARNING not in caplog.text + + +def test_web_server_auth_explicit_digest( + generate_main: Callable[[str], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Auth type digest builds Digest and does not warn.""" + generate_main("tests/component_tests/web_server/web_server_auth_digest.yaml") + + assert _has_define("USE_WEBSERVER_AUTH") + assert _has_define("USE_WEBSERVER_AUTH_DIGEST") + assert _DEFAULT_CHANGE_WARNING not in caplog.text + + +def test_web_server_without_auth( + generate_main: Callable[[str], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Without an auth block, no auth is compiled in and no warning is emitted.""" + generate_main("tests/component_tests/web_server/web_server_no_auth.yaml") + + assert not _has_define("USE_WEBSERVER_AUTH") + assert not _has_define("USE_WEBSERVER_AUTH_DIGEST") + assert _DEFAULT_CHANGE_WARNING not in caplog.text diff --git a/tests/component_tests/web_server/web_server_auth_basic.yaml b/tests/component_tests/web_server/web_server_auth_basic.yaml new file mode 100644 index 0000000000..70180f9fbc --- /dev/null +++ b/tests/component_tests/web_server/web_server_auth_basic.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + framework: + type: esp-idf + +wifi: + ssid: MySSID + password: password1 + +web_server: + auth: + username: admin + password: password + type: basic diff --git a/tests/component_tests/web_server/web_server_auth_default.yaml b/tests/component_tests/web_server/web_server_auth_default.yaml new file mode 100644 index 0000000000..076180ab79 --- /dev/null +++ b/tests/component_tests/web_server/web_server_auth_default.yaml @@ -0,0 +1,17 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + framework: + type: esp-idf + +wifi: + ssid: MySSID + password: password1 + +web_server: + auth: + username: admin + password: password diff --git a/tests/component_tests/web_server/web_server_auth_digest.yaml b/tests/component_tests/web_server/web_server_auth_digest.yaml new file mode 100644 index 0000000000..f413787601 --- /dev/null +++ b/tests/component_tests/web_server/web_server_auth_digest.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + framework: + type: esp-idf + +wifi: + ssid: MySSID + password: password1 + +web_server: + auth: + username: admin + password: password + type: digest diff --git a/tests/component_tests/web_server/web_server_no_auth.yaml b/tests/component_tests/web_server/web_server_no_auth.yaml new file mode 100644 index 0000000000..1c7823b4ae --- /dev/null +++ b/tests/component_tests/web_server/web_server_no_auth.yaml @@ -0,0 +1,14 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + framework: + type: esp-idf + +wifi: + ssid: MySSID + password: password1 + +web_server: diff --git a/tests/components/web_server/test.esp32-idf.yaml b/tests/components/web_server/test.esp32-idf.yaml index 858e3b0190..2e091b905a 100644 --- a/tests/components/web_server/test.esp32-idf.yaml +++ b/tests/components/web_server/test.esp32-idf.yaml @@ -5,3 +5,4 @@ web_server: auth: username: admin password: password + type: digest diff --git a/tests/components/web_server/test.esp8266-ard.yaml b/tests/components/web_server/test.esp8266-ard.yaml index 11ad5456ef..2e091b905a 100644 --- a/tests/components/web_server/test.esp8266-ard.yaml +++ b/tests/components/web_server/test.esp8266-ard.yaml @@ -1,2 +1,8 @@ packages: web_server: !include common_v2.yaml + +web_server: + auth: + username: admin + password: password + type: digest diff --git a/tests/components/web_server/test.rp2040-ard.yaml b/tests/components/web_server/test.rp2040-ard.yaml index 11ad5456ef..e4d50d7776 100644 --- a/tests/components/web_server/test.rp2040-ard.yaml +++ b/tests/components/web_server/test.rp2040-ard.yaml @@ -1,2 +1,8 @@ packages: web_server: !include common_v2.yaml + +web_server: + auth: + username: admin + password: password + type: basic diff --git a/tests/components/web_server/validate.esp32-idf.yaml b/tests/components/web_server/validate.esp32-idf.yaml new file mode 100644 index 0000000000..e4d50d7776 --- /dev/null +++ b/tests/components/web_server/validate.esp32-idf.yaml @@ -0,0 +1,8 @@ +packages: + web_server: !include common_v2.yaml + +web_server: + auth: + username: admin + password: password + type: basic From 519ce38b7932fd3e4b8b3bbba0b5e709caea13d4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Jul 2026 13:51:54 -1000 Subject: [PATCH 0877/1815] [libretiny] Keep renamed board generic-ln882hki validating against generic-ln882h (#17542) --- esphome/components/libretiny/__init__.py | 15 ++++++ tests/unit_tests/components/test_libretiny.py | 52 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 tests/unit_tests/components/test_libretiny.py diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 3fde11b1eb..62cef331fd 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -76,12 +76,27 @@ _BLE5_BK_SYS_CONFIG_OPTIONS = [ "CFG_SUPPORT_BLE=0", ] +# Board ids upstream LibreTiny renamed; configs written against the old id +# keep validating and building against the new one (with a warning). +# generic-ln882hki -> generic-ln882h: LibreTiny v1.13.0. +_RENAMED_BOARDS = { + "generic-ln882hki": "generic-ln882h", +} + def _detect_variant(value): if KEY_LIBRETINY not in CORE.data: raise cv.Invalid("Family component didn't populate core data properly!") component: LibreTinyComponent = CORE.data[KEY_LIBRETINY][KEY_COMPONENT_DATA] board = value[CONF_BOARD] + if board not in component.boards and (renamed := _RENAMED_BOARDS.get(board)): + _LOGGER.warning( + "Board '%s' was renamed to '%s'; please update your configuration", + board, + renamed, + ) + value = value.copy() + value[CONF_BOARD] = board = renamed # read board-default family if not specified if board not in component.boards: if CONF_FAMILY not in value: diff --git a/tests/unit_tests/components/test_libretiny.py b/tests/unit_tests/components/test_libretiny.py new file mode 100644 index 0000000000..ee00bdc180 --- /dev/null +++ b/tests/unit_tests/components/test_libretiny.py @@ -0,0 +1,52 @@ +"""Tests for LibreTiny board detection, including renamed-board migration.""" + +import pytest + +from esphome.components.libretiny import _detect_variant +from esphome.components.libretiny.const import ( + FAMILY_LN882H, + KEY_COMPONENT_DATA, + KEY_LIBRETINY, +) +from esphome.components.ln882x import COMPONENT_DATA +import esphome.config_validation as cv +from esphome.const import CONF_BOARD, CONF_FAMILY +from esphome.core import CORE + + +@pytest.fixture +def ln882x_core_data() -> None: + """Populate CORE the way the ln882x component schema does.""" + CORE.data[KEY_LIBRETINY] = {KEY_COMPONENT_DATA: COMPONENT_DATA} + + +def test_detect_variant_known_board_passes(ln882x_core_data: None) -> None: + """A current board id resolves its family without warnings.""" + result = _detect_variant({CONF_BOARD: "generic-ln882h"}) + assert result[CONF_BOARD] == "generic-ln882h" + assert result[CONF_FAMILY] == FAMILY_LN882H + + +def test_detect_variant_renamed_board_migrates( + ln882x_core_data: None, caplog: pytest.LogCaptureFixture +) -> None: + """A pre-rename board id validates against the new id, with a warning.""" + result = _detect_variant({CONF_BOARD: "generic-ln882hki"}) + assert result[CONF_BOARD] == "generic-ln882h" + assert result[CONF_FAMILY] == FAMILY_LN882H + assert "renamed to 'generic-ln882h'" in caplog.text + + +def test_detect_variant_renamed_board_does_not_mutate_input( + ln882x_core_data: None, +) -> None: + """Migration copies the config; the caller's dict keeps the old id.""" + value = {CONF_BOARD: "generic-ln882hki"} + _detect_variant(value) + assert value[CONF_BOARD] == "generic-ln882hki" + + +def test_detect_variant_unknown_board_still_raises(ln882x_core_data: None) -> None: + """Ids outside the rename map keep the family-override error.""" + with pytest.raises(cv.Invalid, match="This board is unknown"): + _detect_variant({CONF_BOARD: "not-a-real-board"}) From 1da8900ffc80df4c7220df9a998ee7d419c3a815 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:53:31 -0400 Subject: [PATCH 0878/1815] [veml7700][as7341][ltr501] Fix device class on raw-count sensors (#17549) --- esphome/components/as7341/sensor.py | 4 ++-- esphome/components/ltr501/sensor.py | 10 +++++----- esphome/components/veml7700/sensor.py | 5 +++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/esphome/components/as7341/sensor.py b/esphome/components/as7341/sensor.py index fa51a1cdfa..8b6cf61028 100644 --- a/esphome/components/as7341/sensor.py +++ b/esphome/components/as7341/sensor.py @@ -5,7 +5,7 @@ from esphome.const import ( CONF_CLEAR, CONF_GAIN, CONF_ID, - DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_EMPTY, ICON_BRIGHTNESS_5, STATE_CLASS_MEASUREMENT, ) @@ -54,7 +54,7 @@ SENSOR_SCHEMA = sensor.sensor_schema( unit_of_measurement=UNIT_COUNTS, icon=ICON_BRIGHTNESS_5, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ) diff --git a/esphome/components/ltr501/sensor.py b/esphome/components/ltr501/sensor.py index cca9330e76..c1fa9009b3 100644 --- a/esphome/components/ltr501/sensor.py +++ b/esphome/components/ltr501/sensor.py @@ -15,7 +15,7 @@ from esphome.const import ( CONF_NAME, CONF_REPEAT, CONF_TYPE, - DEVICE_CLASS_DISTANCE, + DEVICE_CLASS_EMPTY, DEVICE_CLASS_ILLUMINANCE, ICON_BRIGHTNESS_5, ICON_BRIGHTNESS_6, @@ -159,7 +159,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_COUNTS, icon=ICON_BRIGHTNESS_5, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, @@ -169,7 +169,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_COUNTS, icon=ICON_BRIGHTNESS_7, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, @@ -179,7 +179,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_COUNTS, icon=ICON_PROXIMITY, accuracy_decimals=0, - device_class=DEVICE_CLASS_DISTANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, @@ -188,7 +188,7 @@ CONFIG_SCHEMA = cv.All( sensor.sensor_schema( icon=ICON_GAIN, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, diff --git a/esphome/components/veml7700/sensor.py b/esphome/components/veml7700/sensor.py index 6ad2eb417f..d0d3584dc2 100644 --- a/esphome/components/veml7700/sensor.py +++ b/esphome/components/veml7700/sensor.py @@ -14,6 +14,7 @@ from esphome.const import ( CONF_INFRARED, CONF_INTEGRATION_TIME, CONF_NAME, + DEVICE_CLASS_EMPTY, DEVICE_CLASS_ILLUMINANCE, ICON_BRIGHTNESS_5, ICON_BRIGHTNESS_6, @@ -91,7 +92,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_COUNTS, icon=ICON_BRIGHTNESS_6, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, @@ -111,7 +112,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_COUNTS, icon=ICON_BRIGHTNESS_7, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, From 8da377ab43922307ff40440b8c5dad4f7f5de72a Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 13 Jul 2026 18:56:03 -0500 Subject: [PATCH 0879/1815] [nextion] Fix unbounded queue growth and OOM crash when display sends no data (#17553) --- esphome/components/nextion/nextion.cpp | 32 +++++++++++++++++++------- esphome/components/nextion/nextion.h | 4 ++++ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index 4ebc717552..bdc66adb70 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -1,6 +1,7 @@ #include "nextion.h" #include +#include #include "esphome/core/application.h" #include "esphome/core/helpers.h" @@ -352,8 +353,9 @@ void Nextion::loop() { this->connection_state_.ignore_is_setup_ = false; } - this->process_serial_(); // Receive serial data - this->process_nextion_commands_(); // Process nextion return commands + this->process_serial_(); // Receive serial data + this->process_nextion_commands_(); // Process nextion return commands + this->purge_stale_queue_entries_(); // Drop expired entries even when the display sends no data if (!this->connection_state_.nextion_reports_is_setup_) { if (this->started_ms_ == 0) @@ -902,6 +904,11 @@ void Nextion::process_nextion_commands_() { this->command_data_.erase(0, to_process_length + DELIMITER_SIZE + 1); } + ESP_LOGN(TAG, "Loop end"); + this->process_serial_(); +} // Nextion::process_nextion_commands_() + +void Nextion::purge_stale_queue_entries_() { const uint32_t ms = App.get_loop_component_start_time(); if (this->max_q_age_ms_ > 0 && !this->nextion_queue_.empty() && @@ -927,10 +934,7 @@ void Nextion::process_nextion_commands_() { } } } - ESP_LOGN(TAG, "Loop end"); - // App.feed_wdt(); Remove before master merge - this->process_serial_(); -} // Nextion::process_nextion_commands_() +} void Nextion::set_nextion_sensor_state(int queue_type, const std::string &name, float state) { this->set_nextion_sensor_state(static_cast(queue_type), name, state); @@ -1101,7 +1105,13 @@ void Nextion::add_no_result_to_queue_(const std::string &variable_name) { new (nextion_queue) nextion::NextionQueue(); // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - nextion_queue->component = new nextion::NextionComponentBase; + nextion_queue->component = new (std::nothrow) nextion::NextionComponentBase; + if (nextion_queue->component == nullptr) { + ESP_LOGW(TAG, "Component alloc failed"); + nextion_queue->~NextionQueue(); + allocator.deallocate(nextion_queue, 1); + return; + } nextion_queue->component->set_variable_name(variable_name); nextion_queue->queue_time = App.get_loop_component_start_time(); @@ -1157,7 +1167,13 @@ void Nextion::add_no_result_to_queue_with_pending_command_(const std::string &va } new (nextion_queue) nextion::NextionQueue(); - nextion_queue->component = new nextion::NextionComponentBase; + nextion_queue->component = new (std::nothrow) nextion::NextionComponentBase; + if (nextion_queue->component == nullptr) { + ESP_LOGW(TAG, "Component alloc failed"); + nextion_queue->~NextionQueue(); + allocator.deallocate(nextion_queue, 1); + return; + } nextion_queue->component->set_variable_name(variable_name); nextion_queue->queue_time = App.get_loop_component_start_time(); nextion_queue->pending_command = command; // Store command for retry diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index d361d9725b..7dc5a4fe44 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -1486,6 +1486,10 @@ class Nextion final : public NextionBase, public PollingComponent, public uart:: void process_nextion_commands_(); void process_serial_(); + /// Drop queue entries older than max_q_age_ms_. Called from loop() so it also runs when the + /// display sends no data at all (disconnected or asleep), which would otherwise grow the queue + /// without bound. + void purge_stale_queue_entries_(); uint16_t touch_sleep_timeout_ = 0; uint8_t wake_up_page_ = 255; #ifdef USE_NEXTION_CONF_START_UP_PAGE From 6fadf353b196dc31c9ffba2a7ff1e8baedae5f2b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:40:45 +1200 Subject: [PATCH 0880/1815] Bump version to 2026.7.0b3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 1bcfded35d..3bd4dc140f 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.7.0b2 +PROJECT_NUMBER = 2026.7.0b3 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index f6014176b8..01ff67e3f2 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.7.0b2" +__version__ = "2026.7.0b3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From db9a09d05a7b2e38f02dfe71aabc61b2ef9af627 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:11:03 +1200 Subject: [PATCH 0881/1815] [script] Recursively expand nested packages when merging component tests (#17557) --- script/merge_component_configs.py | 49 +++++++++++------ tests/script/test_merge_component_configs.py | 56 ++++++++++++++++++++ 2 files changed, 89 insertions(+), 16 deletions(-) diff --git a/script/merge_component_configs.py b/script/merge_component_configs.py index 5eeeafac2a..c2be7be7fd 100755 --- a/script/merge_component_configs.py +++ b/script/merge_component_configs.py @@ -263,22 +263,39 @@ def prepare_component_body(comp_data: dict, comp_name: str, comp_dir: Path) -> d else {} ) - packages_value = comp_data.get("packages") - if isinstance(packages_value, dict): - common_bus_packages = get_common_bus_packages() - for pkg_name, pkg_value in list(packages_value.items()): - if pkg_name in common_bus_packages: - continue - if isinstance(pkg_value, yaml_util.IncludeFile): - pkg_value = pkg_value.load() - if isinstance(pkg_value, dict): - comp_data = merge_config(comp_data, pkg_value) - elif isinstance(packages_value, list): - for pkg_value in packages_value: - if isinstance(pkg_value, yaml_util.IncludeFile): - pkg_value = pkg_value.load() - if isinstance(pkg_value, dict): - comp_data = merge_config(comp_data, pkg_value) + # Expand component-specific package includes inline. A package include may + # itself pull in further component-specific packages (e.g. web_server's test + # includes common_v2, which includes common with the wifi/network config), so + # keep expanding until only common bus packages remain -- otherwise the nested + # includes are silently dropped when the packages key is removed below. + common_bus_packages = get_common_bus_packages() + while True: + packages_value = comp_data.get("packages") + expanded = False + if isinstance(packages_value, dict): + for pkg_name, pkg_value in list(packages_value.items()): + if pkg_name in common_bus_packages: + continue + # Drop before merging so a nested packages dict introduced by the + # include does not re-add this same key on the next iteration. + del packages_value[pkg_name] + if isinstance(pkg_value, yaml_util.IncludeFile): + pkg_value = pkg_value.load() + if isinstance(pkg_value, dict): + comp_data = merge_config(comp_data, pkg_value) + expanded = True + elif isinstance(packages_value, list): + # List-style packages never contain common bus packages, so expand + # them all and drop the key entirely. + comp_data.pop("packages", None) + for pkg_value in packages_value: + if isinstance(pkg_value, yaml_util.IncludeFile): + pkg_value = pkg_value.load() + if isinstance(pkg_value, dict): + comp_data = merge_config(comp_data, pkg_value) + expanded = True + if not expanded: + break # Common bus packages are re-added once by the caller; drop them here. comp_data.pop("packages", None) diff --git a/tests/script/test_merge_component_configs.py b/tests/script/test_merge_component_configs.py index 6ed1bd2c1e..27be3b5628 100644 --- a/tests/script/test_merge_component_configs.py +++ b/tests/script/test_merge_component_configs.py @@ -10,7 +10,10 @@ sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve( import merge_component_configs # noqa: E402 +from esphome import yaml_util # noqa: E402 + deduplicate_by_id = merge_component_configs.deduplicate_by_id +prepare_component_body = merge_component_configs.prepare_component_body def test_identical_duplicate_ids_collapse() -> None: @@ -99,3 +102,56 @@ def test_nested_lists_are_checked() -> None: } with pytest.raises(ValueError, match="dup"): deduplicate_by_id(data) + + +def test_nested_package_includes_are_fully_expanded(tmp_path: Path) -> None: + """A package include that itself pulls in another package expands fully. + + Mirrors web_server's tests, where test.yaml includes common_v2, which + includes common (holding the wifi/network config). Without recursive + expansion the nested include is dropped and network config is lost. + """ + (tmp_path / "common.yaml").write_text("wifi:\n ssid: MySSID\n") + (tmp_path / "common_v2.yaml").write_text( + "packages:\n device_base: !include common.yaml\nweb_server:\n port: 8080\n" + ) + (tmp_path / "test.yaml").write_text( + "packages:\n web_server: !include common_v2.yaml\n" + "web_server:\n auth:\n username: admin\n" + ) + + comp_data = yaml_util.load_yaml(tmp_path / "test.yaml") + result = prepare_component_body(comp_data, "web_server", tmp_path) + + assert "packages" not in result + assert result["wifi"] == {"ssid": "MySSID"} + assert result["web_server"] == {"port": 8080, "auth": {"username": "admin"}} + + +def test_common_bus_package_is_left_for_caller(tmp_path: Path) -> None: + """Common bus packages are not expanded inline; the caller re-adds them.""" + comp_data = { + "packages": { + "i2c": {"sda": 21, "scl": 22}, + "device_base": {"wifi": {"ssid": "MySSID"}}, + }, + } + result = prepare_component_body(comp_data, "mycomp", tmp_path) + + # The bus package's body must not be merged in, and the packages key is + # dropped entirely for the caller to re-add the common bus package. + assert "packages" not in result + assert "sda" not in result + assert result["wifi"] == {"ssid": "MySSID"} + + +def test_list_style_packages_are_expanded(tmp_path: Path) -> None: + """List-style package includes are expanded and the key removed.""" + (tmp_path / "common.yaml").write_text("wifi:\n ssid: MySSID\n") + (tmp_path / "test.yaml").write_text("packages:\n - !include common.yaml\n") + + comp_data = yaml_util.load_yaml(tmp_path / "test.yaml") + result = prepare_component_body(comp_data, "mycomp", tmp_path) + + assert "packages" not in result + assert result["wifi"] == {"ssid": "MySSID"} From 0a1065da75b3b4d6dea3ef9dd73f6789cf9e68ae Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:11:03 +1200 Subject: [PATCH 0882/1815] [script] Recursively expand nested packages when merging component tests (#17557) --- script/merge_component_configs.py | 49 +++++++++++------ tests/script/test_merge_component_configs.py | 56 ++++++++++++++++++++ 2 files changed, 89 insertions(+), 16 deletions(-) diff --git a/script/merge_component_configs.py b/script/merge_component_configs.py index 5eeeafac2a..c2be7be7fd 100755 --- a/script/merge_component_configs.py +++ b/script/merge_component_configs.py @@ -263,22 +263,39 @@ def prepare_component_body(comp_data: dict, comp_name: str, comp_dir: Path) -> d else {} ) - packages_value = comp_data.get("packages") - if isinstance(packages_value, dict): - common_bus_packages = get_common_bus_packages() - for pkg_name, pkg_value in list(packages_value.items()): - if pkg_name in common_bus_packages: - continue - if isinstance(pkg_value, yaml_util.IncludeFile): - pkg_value = pkg_value.load() - if isinstance(pkg_value, dict): - comp_data = merge_config(comp_data, pkg_value) - elif isinstance(packages_value, list): - for pkg_value in packages_value: - if isinstance(pkg_value, yaml_util.IncludeFile): - pkg_value = pkg_value.load() - if isinstance(pkg_value, dict): - comp_data = merge_config(comp_data, pkg_value) + # Expand component-specific package includes inline. A package include may + # itself pull in further component-specific packages (e.g. web_server's test + # includes common_v2, which includes common with the wifi/network config), so + # keep expanding until only common bus packages remain -- otherwise the nested + # includes are silently dropped when the packages key is removed below. + common_bus_packages = get_common_bus_packages() + while True: + packages_value = comp_data.get("packages") + expanded = False + if isinstance(packages_value, dict): + for pkg_name, pkg_value in list(packages_value.items()): + if pkg_name in common_bus_packages: + continue + # Drop before merging so a nested packages dict introduced by the + # include does not re-add this same key on the next iteration. + del packages_value[pkg_name] + if isinstance(pkg_value, yaml_util.IncludeFile): + pkg_value = pkg_value.load() + if isinstance(pkg_value, dict): + comp_data = merge_config(comp_data, pkg_value) + expanded = True + elif isinstance(packages_value, list): + # List-style packages never contain common bus packages, so expand + # them all and drop the key entirely. + comp_data.pop("packages", None) + for pkg_value in packages_value: + if isinstance(pkg_value, yaml_util.IncludeFile): + pkg_value = pkg_value.load() + if isinstance(pkg_value, dict): + comp_data = merge_config(comp_data, pkg_value) + expanded = True + if not expanded: + break # Common bus packages are re-added once by the caller; drop them here. comp_data.pop("packages", None) diff --git a/tests/script/test_merge_component_configs.py b/tests/script/test_merge_component_configs.py index 6ed1bd2c1e..27be3b5628 100644 --- a/tests/script/test_merge_component_configs.py +++ b/tests/script/test_merge_component_configs.py @@ -10,7 +10,10 @@ sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve( import merge_component_configs # noqa: E402 +from esphome import yaml_util # noqa: E402 + deduplicate_by_id = merge_component_configs.deduplicate_by_id +prepare_component_body = merge_component_configs.prepare_component_body def test_identical_duplicate_ids_collapse() -> None: @@ -99,3 +102,56 @@ def test_nested_lists_are_checked() -> None: } with pytest.raises(ValueError, match="dup"): deduplicate_by_id(data) + + +def test_nested_package_includes_are_fully_expanded(tmp_path: Path) -> None: + """A package include that itself pulls in another package expands fully. + + Mirrors web_server's tests, where test.yaml includes common_v2, which + includes common (holding the wifi/network config). Without recursive + expansion the nested include is dropped and network config is lost. + """ + (tmp_path / "common.yaml").write_text("wifi:\n ssid: MySSID\n") + (tmp_path / "common_v2.yaml").write_text( + "packages:\n device_base: !include common.yaml\nweb_server:\n port: 8080\n" + ) + (tmp_path / "test.yaml").write_text( + "packages:\n web_server: !include common_v2.yaml\n" + "web_server:\n auth:\n username: admin\n" + ) + + comp_data = yaml_util.load_yaml(tmp_path / "test.yaml") + result = prepare_component_body(comp_data, "web_server", tmp_path) + + assert "packages" not in result + assert result["wifi"] == {"ssid": "MySSID"} + assert result["web_server"] == {"port": 8080, "auth": {"username": "admin"}} + + +def test_common_bus_package_is_left_for_caller(tmp_path: Path) -> None: + """Common bus packages are not expanded inline; the caller re-adds them.""" + comp_data = { + "packages": { + "i2c": {"sda": 21, "scl": 22}, + "device_base": {"wifi": {"ssid": "MySSID"}}, + }, + } + result = prepare_component_body(comp_data, "mycomp", tmp_path) + + # The bus package's body must not be merged in, and the packages key is + # dropped entirely for the caller to re-add the common bus package. + assert "packages" not in result + assert "sda" not in result + assert result["wifi"] == {"ssid": "MySSID"} + + +def test_list_style_packages_are_expanded(tmp_path: Path) -> None: + """List-style package includes are expanded and the key removed.""" + (tmp_path / "common.yaml").write_text("wifi:\n ssid: MySSID\n") + (tmp_path / "test.yaml").write_text("packages:\n - !include common.yaml\n") + + comp_data = yaml_util.load_yaml(tmp_path / "test.yaml") + result = prepare_component_body(comp_data, "mycomp", tmp_path) + + assert "packages" not in result + assert result["wifi"] == {"ssid": "MySSID"} From b6b5b6164082fcbfa8f9aba1e7bde872af24063d Mon Sep 17 00:00:00 2001 From: Daniele Palumbo Date: Tue, 14 Jul 2026 04:38:07 +0200 Subject: [PATCH 0883/1815] [mcp23017] reset IPOL registers to 0x00 on setup (#17177) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/mcp23017/mcp23017.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/mcp23017/mcp23017.cpp b/esphome/components/mcp23017/mcp23017.cpp index 9e3d75575a..173d117457 100644 --- a/esphome/components/mcp23017/mcp23017.cpp +++ b/esphome/components/mcp23017/mcp23017.cpp @@ -19,6 +19,10 @@ void MCP23017::setup() { this->read_reg(mcp23x17_base::MCP23X17_OLATA, &this->olat_a_); this->read_reg(mcp23x17_base::MCP23X17_OLATB, &this->olat_b_); + // Reset IPOL to 0x00: ESPHome handles 'inverted' in software. + this->write_reg(mcp23x17_base::MCP23X17_IPOLA, 0x00); + this->write_reg(mcp23x17_base::MCP23X17_IPOLB, 0x00); + uint8_t iocon_flags = 0; if (this->open_drain_ints_) { iocon_flags |= IOCON_ODR; From 2753ab1f4570e76129aea55f06e89e8a7c4dcb4a Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:50:08 -1000 Subject: [PATCH 0884/1815] Bump bundled esphome-device-builder to 1.5.0 (#17558) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index f09280a50e..01ff53a463 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.4.3 +RUN uv pip install --no-cache-dir esphome-device-builder==1.5.0 RUN \ platformio settings set enable_telemetry No \ From a833685a730679801e956b9e0b278affbe714a8a Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:50:08 -1000 Subject: [PATCH 0885/1815] Bump bundled esphome-device-builder to 1.5.0 (#17558) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index f09280a50e..01ff53a463 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.4.3 +RUN uv pip install --no-cache-dir esphome-device-builder==1.5.0 RUN \ platformio settings set enable_telemetry No \ From 4ebe49b141f49dae8ca5815111011b80f85b2bd5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:54:02 -1000 Subject: [PATCH 0886/1815] Bump clang-tidy from 22.1.7 to 22.1.8 (#17565) Signed-off-by: dependabot[bot] --- requirements_dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_dev.txt b/requirements_dev.txt index 7e66c7244d..f2cf855d6b 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,4 +1,4 @@ # Useful stuff when working in a development environment clang-format==13.0.1 # also change in .pre-commit-config.yaml and Dockerfile when updating -clang-tidy==22.1.7 +clang-tidy==22.1.8 yamllint==1.38.0 # also change in .pre-commit-config.yaml when updating From 427534323114264b0f89ee3bdf0bbe95b7383c5e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:23:37 -0400 Subject: [PATCH 0887/1815] [atc_mithermometer] Make duplicate-packet counter per-instance (#17496) --- esphome/components/atc_mithermometer/atc_mithermometer.cpp | 7 +++---- esphome/components/atc_mithermometer/atc_mithermometer.h | 2 ++ 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.cpp b/esphome/components/atc_mithermometer/atc_mithermometer.cpp index f8bbd9d55e..7b5cdcfa20 100644 --- a/esphome/components/atc_mithermometer/atc_mithermometer.cpp +++ b/esphome/components/atc_mithermometer/atc_mithermometer.cpp @@ -65,12 +65,11 @@ optional ATCMiThermometer::parse_header_(const esp32_ble_tracker::S return {}; } - static uint8_t last_frame_count = 0; - if (last_frame_count == raw[12]) { - ESP_LOGVV(TAG, "parse_header(): duplicate data packet received (%hhu).", last_frame_count); + if (this->last_frame_count_ == raw[12]) { + ESP_LOGVV(TAG, "parse_header(): duplicate data packet received (%hhu).", this->last_frame_count_); return {}; } - last_frame_count = raw[12]; + this->last_frame_count_ = raw[12]; return result; } diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.h b/esphome/components/atc_mithermometer/atc_mithermometer.h index 3dde5f1868..0f472c11b9 100644 --- a/esphome/components/atc_mithermometer/atc_mithermometer.h +++ b/esphome/components/atc_mithermometer/atc_mithermometer.h @@ -38,6 +38,8 @@ class ATCMiThermometer final : public Component, public esp32_ble_tracker::ESPBT sensor::Sensor *battery_voltage_{nullptr}; sensor::Sensor *signal_strength_{nullptr}; + uint8_t last_frame_count_{0}; + optional parse_header_(const esp32_ble_tracker::ServiceData &service_data); bool parse_message_(const std::vector &message, ParseResult &result); bool report_results_(const optional &result, const char *address); From e2b62bcd00950fbe7b868e1513fdb9376cb5236e Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:27:17 +0200 Subject: [PATCH 0888/1815] [zigbee] bump esp-zigbee-sdk to 2.0.3 (#17564) --- esphome/components/zigbee/zigbee_esp32.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 73dcd07029..116dce8cc5 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -274,7 +274,7 @@ async def attributes_to_code( async def esp32_to_code(config: ConfigType) -> "MockObj": add_idf_component( name="espressif/esp-zigbee-lib", - ref="2.0.2", + ref="2.0.3", ) # add sdkconfigs later so they can overwrite esp32 defaults diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 7ad41fa978..60b00d33c7 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -48,7 +48,7 @@ dependencies: rules: - if: "target in [esp32, esp32p4]" espressif/esp-zigbee-lib: - version: 2.0.2 + version: 2.0.3 rules: - if: "target in [esp32h2, esp32c5, esp32c6]" espressif/lan87xx: From a5583dcba60946492846d0b5c7a64966b2e352aa Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:22:47 +1200 Subject: [PATCH 0889/1815] [tests] Add test_display component to free touchscreen tests from display pins (#17540) --- .../components/gsl3670/test.esp32-s3-idf.yaml | 18 ++-------- tests/components/gt911/common.yaml | 13 +------ tests/components/gt911/test.esp32-idf.yaml | 5 ++- tests/components/gt911/test.esp8266-ard.yaml | 5 ++- tests/components/gt911/test.rp2040-ard.yaml | 5 ++- tests/components/test_display/common.yaml | 13 +++++++ .../components/test_display/__init__.py | 0 .../components/test_display/display.py | 36 +++++++++++++++++++ .../components/test_display/test_display.h | 36 +++++++++++++++++++ .../test_display/test.esp32-idf.yaml | 3 ++ .../test_display/test.esp8266-ard.yaml | 3 ++ .../test_display/test.rp2040-ard.yaml | 3 ++ tests/components/tt21100/common.yaml | 13 +------ tests/components/tt21100/test.esp32-idf.yaml | 5 ++- .../components/tt21100/test.esp8266-ard.yaml | 5 ++- tests/components/tt21100/test.rp2040-ard.yaml | 5 ++- .../common/test_display/test_display.yaml | 26 ++++++++++++++ 17 files changed, 137 insertions(+), 57 deletions(-) create mode 100644 tests/components/test_display/common.yaml create mode 100644 tests/components/test_display/components/test_display/__init__.py create mode 100644 tests/components/test_display/components/test_display/display.py create mode 100644 tests/components/test_display/components/test_display/test_display.h create mode 100644 tests/components/test_display/test.esp32-idf.yaml create mode 100644 tests/components/test_display/test.esp8266-ard.yaml create mode 100644 tests/components/test_display/test.rp2040-ard.yaml create mode 100644 tests/test_build_components/common/test_display/test_display.yaml diff --git a/tests/components/gsl3670/test.esp32-s3-idf.yaml b/tests/components/gsl3670/test.esp32-s3-idf.yaml index 5c3f4b931c..384e12eaba 100644 --- a/tests/components/gsl3670/test.esp32-s3-idf.yaml +++ b/tests/components/gsl3670/test.esp32-s3-idf.yaml @@ -1,32 +1,20 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml - spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml + test_display: !include ../../test_build_components/common/test_display/test_display.yaml xl9535: id: expander -display: - - platform: mipi_spi - id: gsl3670_display - spi_id: spi_bus - model: t-display-s3-pro - # The model's default DC pin (GPIO9) clashes with the shared i2c bus SCL - # pin, so override it onto a free pin for this test. - dc_pin: GPIO5 - -psram: - mode: quad - touchscreen: # Firmware downloaded from the model's default release URL and cached. - platform: gsl3670 model: seeed-reterminal-d1001 - display: gsl3670_display + display: test_display_screen interrupt_pin: 18 # Explicit firmware URL + SHA-256 override. - platform: gsl3670 model: seeed-reterminal-d1001 - display: gsl3670_display + display: test_display_screen reset_pin: 10 interrupt_pin: 11 firmware: diff --git a/tests/components/gt911/common.yaml b/tests/components/gt911/common.yaml index 0fc40737f0..24a67e2e45 100644 --- a/tests/components/gt911/common.yaml +++ b/tests/components/gt911/common.yaml @@ -1,19 +1,8 @@ -display: - - platform: ssd1306_i2c - i2c_id: i2c_bus - id: gt911_ssd1306_i2c_display - model: SSD1306_128X64 - reset_pin: ${display_reset_pin} - pages: - - id: gt911_page1 - lambda: |- - it.rectangle(0, 0, it.get_width(), it.get_height()); - touchscreen: - platform: gt911 i2c_id: i2c_bus id: gt911_touchscreen - display: gt911_ssd1306_i2c_display + display: test_display_screen interrupt_pin: ${interrupt_pin} reset_pin: ${reset_pin} diff --git a/tests/components/gt911/test.esp32-idf.yaml b/tests/components/gt911/test.esp32-idf.yaml index 3bce86d9a3..9c2de1a425 100644 --- a/tests/components/gt911/test.esp32-idf.yaml +++ b/tests/components/gt911/test.esp32-idf.yaml @@ -1,9 +1,8 @@ substitutions: - display_reset_pin: "10" interrupt_pin: "20" reset_pin: "21" packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - -<<: !include common.yaml + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + gt911: !include common.yaml diff --git a/tests/components/gt911/test.esp8266-ard.yaml b/tests/components/gt911/test.esp8266-ard.yaml index c3bc159b5b..59af399be8 100644 --- a/tests/components/gt911/test.esp8266-ard.yaml +++ b/tests/components/gt911/test.esp8266-ard.yaml @@ -1,9 +1,8 @@ substitutions: - display_reset_pin: "10" interrupt_pin: "12" reset_pin: "13" packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml - -<<: !include common.yaml + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + gt911: !include common.yaml diff --git a/tests/components/gt911/test.rp2040-ard.yaml b/tests/components/gt911/test.rp2040-ard.yaml index 0c7f0bc504..efd5d9c2b1 100644 --- a/tests/components/gt911/test.rp2040-ard.yaml +++ b/tests/components/gt911/test.rp2040-ard.yaml @@ -1,9 +1,8 @@ substitutions: - display_reset_pin: "10" interrupt_pin: "20" reset_pin: "21" packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml - -<<: !include common.yaml + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + gt911: !include common.yaml diff --git a/tests/components/test_display/common.yaml b/tests/components/test_display/common.yaml new file mode 100644 index 0000000000..c36cf4b997 --- /dev/null +++ b/tests/components/test_display/common.yaml @@ -0,0 +1,13 @@ +# The test_display platform (and its external_components entry) is provided by +# the shared package included from the test.*.yaml files. These extra instances +# exercise the remaining `dimensions` code paths: the width/height map form and +# the default when omitted. The package's own `test_display_screen` covers the +# "WIDTHxHEIGHT" string form. +display: + - platform: test_display + id: test_display_wh_dimensions + dimensions: + width: 320 + height: 240 + - platform: test_display + id: test_display_default_dimensions diff --git a/tests/components/test_display/components/test_display/__init__.py b/tests/components/test_display/components/test_display/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/components/test_display/components/test_display/display.py b/tests/components/test_display/components/test_display/display.py new file mode 100644 index 0000000000..8503053b46 --- /dev/null +++ b/tests/components/test_display/components/test_display/display.py @@ -0,0 +1,36 @@ +import esphome.codegen as cg +from esphome.components import display +import esphome.config_validation as cv +from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_ID, CONF_WIDTH +from esphome.core import CoroPriority, coroutine_with_priority + +test_display_ns = cg.esphome_ns.namespace("test_display") +TestDisplay = test_display_ns.class_("TestDisplay", display.Display) + +CONFIG_SCHEMA = display.FULL_DISPLAY_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(TestDisplay), + cv.Optional(CONF_DIMENSIONS, default="100x100"): cv.Any( + cv.dimensions, + cv.Schema( + { + cv.Required(CONF_WIDTH): cv.int_, + cv.Required(CONF_HEIGHT): cv.int_, + } + ), + ), + } +) + + +@coroutine_with_priority(CoroPriority.CORE) +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await display.register_display(var, config) + + dimensions = config[CONF_DIMENSIONS] + if isinstance(dimensions, dict): + width, height = dimensions[CONF_WIDTH], dimensions[CONF_HEIGHT] + else: + width, height = dimensions + cg.add(var.set_dimensions(width, height)) diff --git a/tests/components/test_display/components/test_display/test_display.h b/tests/components/test_display/components/test_display/test_display.h new file mode 100644 index 0000000000..3f2b03a773 --- /dev/null +++ b/tests/components/test_display/components/test_display/test_display.h @@ -0,0 +1,36 @@ +#pragma once + +#include "esphome/components/display/display.h" +#include "esphome/core/color.h" + +namespace esphome::test_display { + +/** A no-op display that draws nothing and uses no pins. + * + * It exists purely to satisfy components that require a display (for example + * touchscreens, which read the display dimensions) in configurations - most + * notably YAML build tests - where a real display driver would only get in the + * way by occupying GPIO pins and pulling in bus dependencies. + */ +class TestDisplay : public display::Display { + public: + void update() override { this->do_update_(); } + + void set_dimensions(int width, int height) { + this->width_ = width; + this->height_ = height; + } + + display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } + + void draw_pixel_at(int x, int y, Color color) override {} + + protected: + int get_width_internal() override { return this->width_; } + int get_height_internal() override { return this->height_; } + + int width_{0}; + int height_{0}; +}; + +} // namespace esphome::test_display diff --git a/tests/components/test_display/test.esp32-idf.yaml b/tests/components/test_display/test.esp32-idf.yaml new file mode 100644 index 0000000000..dfc8006c38 --- /dev/null +++ b/tests/components/test_display/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + test_display_extra: !include common.yaml diff --git a/tests/components/test_display/test.esp8266-ard.yaml b/tests/components/test_display/test.esp8266-ard.yaml new file mode 100644 index 0000000000..dfc8006c38 --- /dev/null +++ b/tests/components/test_display/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + test_display_extra: !include common.yaml diff --git a/tests/components/test_display/test.rp2040-ard.yaml b/tests/components/test_display/test.rp2040-ard.yaml new file mode 100644 index 0000000000..dfc8006c38 --- /dev/null +++ b/tests/components/test_display/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + test_display_extra: !include common.yaml diff --git a/tests/components/tt21100/common.yaml b/tests/components/tt21100/common.yaml index 1f9249f1ba..5cb6b99a8e 100644 --- a/tests/components/tt21100/common.yaml +++ b/tests/components/tt21100/common.yaml @@ -1,19 +1,8 @@ -display: - - platform: ssd1306_i2c - i2c_id: i2c_bus - id: tt21100_ssd1306_i2c_display - model: SSD1306_128X64 - reset_pin: ${disp_reset_pin} - pages: - - id: tt21100_page1 - lambda: |- - it.rectangle(0, 0, it.get_width(), it.get_height()); - touchscreen: - platform: tt21100 i2c_id: i2c_bus id: tt21100_touchscreen - display: tt21100_ssd1306_i2c_display + display: test_display_screen interrupt_pin: ${interrupt_pin} reset_pin: ${reset_pin} diff --git a/tests/components/tt21100/test.esp32-idf.yaml b/tests/components/tt21100/test.esp32-idf.yaml index 033aafb73c..a79695d611 100644 --- a/tests/components/tt21100/test.esp32-idf.yaml +++ b/tests/components/tt21100/test.esp32-idf.yaml @@ -1,9 +1,8 @@ substitutions: - disp_reset_pin: GPIO12 interrupt_pin: GPIO15 reset_pin: GPIO4 packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - -<<: !include common.yaml + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + tt21100: !include common.yaml diff --git a/tests/components/tt21100/test.esp8266-ard.yaml b/tests/components/tt21100/test.esp8266-ard.yaml index 25d1ff82e3..ae6977c6ec 100644 --- a/tests/components/tt21100/test.esp8266-ard.yaml +++ b/tests/components/tt21100/test.esp8266-ard.yaml @@ -1,9 +1,8 @@ substitutions: - disp_reset_pin: GPIO0 interrupt_pin: GPIO15 reset_pin: GPIO16 packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml - -<<: !include common.yaml + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + tt21100: !include common.yaml diff --git a/tests/components/tt21100/test.rp2040-ard.yaml b/tests/components/tt21100/test.rp2040-ard.yaml index 0d13628294..98b2ad600c 100644 --- a/tests/components/tt21100/test.rp2040-ard.yaml +++ b/tests/components/tt21100/test.rp2040-ard.yaml @@ -1,9 +1,8 @@ substitutions: - disp_reset_pin: GPIO10 interrupt_pin: GPIO2 reset_pin: GPIO3 packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml - -<<: !include common.yaml + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + tt21100: !include common.yaml diff --git a/tests/test_build_components/common/test_display/test_display.yaml b/tests/test_build_components/common/test_display/test_display.yaml new file mode 100644 index 0000000000..986ab45223 --- /dev/null +++ b/tests/test_build_components/common/test_display/test_display.yaml @@ -0,0 +1,26 @@ +# Shared "test display" package for component tests. +# +# Provides a no-op display (id: test_display_screen) that uses no pins and no +# bus, so tests that only need a display to exist -- touchscreens especially -- +# don't have to instantiate a real driver and fight it over GPIOs. Include it +# like a common bus package; the consuming test does NOT need to declare +# external_components itself: +# +# packages: +# test_display: !include ../../test_build_components/common/test_display/test_display.yaml +# +# then point the touchscreen (or other display consumer) at `test_display_screen`. +# +# The test_display platform lives at tests/components/test_display/components/ and +# is loaded via external_components. The source path is written relative to the +# build directory (tests/test_build_components/build/), which every test -- +# standalone or grouped -- is generated into, so this always resolves to the +# component under tests/components/test_display/. +external_components: + - source: ../../components/test_display/components + components: [test_display] + +display: + - platform: test_display + id: test_display_screen + dimensions: 240x320 From 4d3d06959ba5cbf892006077562212ce38f7cbbb Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:59:19 -1000 Subject: [PATCH 0890/1815] Bump bundled esphome-device-builder to 1.6.0 (#17573) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 01ff53a463..e310d766b5 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.5.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.0 RUN \ platformio settings set enable_telemetry No \ From b295b8d5a2895deda98cabc2373ab8cd52e119eb Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:11:47 +1200 Subject: [PATCH 0891/1815] [api] Warn when Home Assistant actions are sent with no subscribed client (#17560) --- esphome/components/api/api_connection.h | 8 +- esphome/components/api/api_server.cpp | 10 ++- ...pi_homeassistant_action_no_subscriber.yaml | 30 +++++++ ..._api_homeassistant_action_no_subscriber.py | 79 +++++++++++++++++++ 4 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 tests/integration/fixtures/api_homeassistant_action_no_subscriber.yaml create mode 100644 tests/integration/test_api_homeassistant_action_no_subscriber.py diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 144973fa9d..7df7ea1429 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -166,10 +166,14 @@ class APIConnection final : public APIServerConnectionBase { #endif bool try_send_log_message(int level, const char *tag, const char *line, size_t message_len); #ifdef USE_API_HOMEASSISTANT_SERVICES - void send_homeassistant_action(const HomeassistantActionRequest &call) { + // Returns whether this client has subscribed to Home Assistant actions; the message + // is only handed to the send path when subscribed. A true return does not guarantee + // delivery - it lets the caller warn when no connected client has the subscription. + bool send_homeassistant_action(const HomeassistantActionRequest &call) { if (!this->flags_.service_call_subscription) - return; + return false; this->send_message(call); + return true; } #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES void on_homeassistant_action_response(const HomeassistantActionResponse &msg); diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 1062dfeb39..6e3448121c 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -426,8 +426,16 @@ void APIServer::set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = bat #ifdef USE_API_HOMEASSISTANT_SERVICES void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call) { + bool has_subscriber = false; for (auto &client : this->active_clients()) { - client->send_homeassistant_action(call); + has_subscriber |= client->send_homeassistant_action(call); + } + if (!has_subscriber) { + // Home Assistant subscribes to actions shortly *after* authenticating, so actions + // fired right at connection time (on_client_connected, on_time_sync, ...) can + // arrive before the subscription and are lost - warn instead of failing silently. + ESP_LOGW(TAG, "Home Assistant %s '%s' dropped; %s", call.is_event ? "event" : "action", call.service.c_str(), + this->is_connected() ? "client has not subscribed to actions (yet)" : "no client connected"); } } #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES diff --git a/tests/integration/fixtures/api_homeassistant_action_no_subscriber.yaml b/tests/integration/fixtures/api_homeassistant_action_no_subscriber.yaml new file mode 100644 index 0000000000..26791e2cd1 --- /dev/null +++ b/tests/integration/fixtures/api_homeassistant_action_no_subscriber.yaml @@ -0,0 +1,30 @@ +esphome: + name: test-ha-action-no-subscriber + friendly_name: Home Assistant Action No Subscriber Test + on_boot: + # Fires before any client is connected - dropped with a warning. + - homeassistant.action: + action: test.boot_action + +host: + +api: + on_client_connected: + # Fires at authentication time, before the client has subscribed to + # Home Assistant actions - dropped with a warning. + - homeassistant.action: + action: test.connected_action + +logger: + level: DEBUG + +button: + - platform: template + name: Send Action Button + id: send_action_button + on_press: + # Pressed only after the client has subscribed - must be delivered. + - homeassistant.action: + action: test.button_action + data: + value: subscribed diff --git a/tests/integration/test_api_homeassistant_action_no_subscriber.py b/tests/integration/test_api_homeassistant_action_no_subscriber.py new file mode 100644 index 0000000000..9e7594e0ce --- /dev/null +++ b/tests/integration/test_api_homeassistant_action_no_subscriber.py @@ -0,0 +1,79 @@ +"""Integration test for Home Assistant actions fired without a subscriber. + +Home Assistant subscribes to device actions shortly after authenticating, while +on_client_connected (and similar triggers) fire right at authentication. Actions +fired before any client has subscribed cannot be delivered - they must produce a +warning in the log instead of vanishing silently. +""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import ButtonInfo, HomeassistantServiceCall +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_api_homeassistant_action_no_subscriber( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Undeliverable actions warn in the log; actions after subscribing arrive.""" + loop = asyncio.get_running_loop() + + boot_warning_future = loop.create_future() + connected_warning_future = loop.create_future() + button_action_future = loop.create_future() + + def check_output(line: str) -> None: + if ( + not boot_warning_future.done() + and "Home Assistant action 'test.boot_action' dropped; no client connected" + in line + ): + boot_warning_future.set_result(True) + if ( + not connected_warning_future.done() + and "Home Assistant action 'test.connected_action' dropped; " + "client has not subscribed to actions (yet)" + in line + ): + connected_warning_future.set_result(True) + + service_calls: list[HomeassistantServiceCall] = [] + + def on_service_call(service_call: HomeassistantServiceCall) -> None: + service_calls.append(service_call) + if ( + service_call.service == "test.button_action" + and not button_action_future.done() + ): + button_action_future.set_result(service_call) + + async with run_compiled(yaml_config, line_callback=check_output): + # The on_boot action fires with no client connected at all. + await asyncio.wait_for(boot_warning_future, timeout=10.0) + + async with api_client_connected() as client: + device_info = await client.device_info() + assert device_info.name == "test-ha-action-no-subscriber" + + # on_client_connected fired at authentication, before this client + # subscribed to Home Assistant actions. + await asyncio.wait_for(connected_warning_future, timeout=5.0) + + # After subscribing, actions must be delivered normally (and the + # dropped ones must not suddenly show up). + client.subscribe_service_calls(on_service_call) + + entities, _ = await client.list_entities_services() + button = next(e for e in entities if isinstance(e, ButtonInfo)) + client.button_command(button.key) + + button_call = await asyncio.wait_for(button_action_future, timeout=5.0) + assert button_call.data == {"value": "subscribed"} + assert [call.service for call in service_calls] == ["test.button_action"] From 053ce39fc6f0e01d84791027663783c73cf5dab7 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:51:50 -0400 Subject: [PATCH 0892/1815] Bump bundled esphome-device-builder to 1.6.1 (#17575) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index e310d766b5..84fd658594 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.6.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.1 RUN \ platformio settings set enable_telemetry No \ From 54987d7d23ae5ce92b2bd210c5d98241ff1c5bf0 Mon Sep 17 00:00:00 2001 From: Hajo Noerenberg Date: Wed, 15 Jul 2026 18:38:08 +0200 Subject: [PATCH 0893/1815] [cc1101] Export CC1101Listener to Python (#17576) --- esphome/components/cc1101/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index 0feb384ac2..cafd894c54 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -21,6 +21,7 @@ MULTI_CONF = True ns = cg.esphome_ns.namespace("cc1101") CC1101Component = ns.class_("CC1101Component", cg.Component, spi.SPIDevice) +CC1101Listener = ns.class_("CC1101Listener") # Config keys CONF_RX_ATTENUATION = "rx_attenuation" From a3ab7961e062527d24156c89cb67d03782676d3d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 16 Jul 2026 08:14:55 +1200 Subject: [PATCH 0894/1815] [core] Fix wait_until crash when re-entered from its own continuation (#17571) --- esphome/core/base_automation.h | 51 ++++++++--- .../wait_until_reentrant_restart.yaml | 86 ++++++++++++++++++ .../test_wait_until_reentrant_restart.py | 89 +++++++++++++++++++ 3 files changed, 214 insertions(+), 12 deletions(-) create mode 100644 tests/integration/fixtures/wait_until_reentrant_restart.yaml create mode 100644 tests/integration/test_wait_until_reentrant_restart.py diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index cf8b05a300..38e52e44cb 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -502,6 +502,9 @@ template class WaitUntilAction : public Action, public Co void stop() override { this->var_queue_.clear(); + // Tell any process_queue_() call further down the stack that the items it is + // still holding were cancelled + this->stop_generation_++; this->disable_loop(); } @@ -511,33 +514,57 @@ template class WaitUntilAction : public Action, public Co } protected: + using QueueItem = std::tuple, std::tuple>; + // Helper: Process queue, triggering completed items and removing them // Returns true if queue still has pending items bool process_queue_(uint32_t now) { - // Process each queued wait_until and remove completed ones - this->var_queue_.remove_if([&](auto &queued) { - auto start = std::get(queued); - auto timeout = std::get>(queued); - auto &var = std::get>(queued); + // Completed items run the rest of the action chain synchronously, and that chain + // can re-enter this same action (e.g. a script with mode: restart that executes + // itself) and add to or clear var_queue_. Iterating the member list directly would + // then corrupt it, so move it aside and iterate a local list instead. + std::list queue; + queue.swap(this->var_queue_); + std::list pending; + while (!queue.empty()) { + auto it = queue.begin(); + auto start = std::get(*it); + auto timeout = std::get>(*it); // Check if timeout has expired auto expired = timeout && (now - start) >= *timeout; // Keep waiting if not expired and condition not met - if (!expired && !this->condition_->check_tuple(var)) { - return false; + if (!expired && !this->condition_->check_tuple(std::get>(*it))) { + pending.splice(pending.end(), queue, it); + continue; } - // Condition met or timed out - trigger next action - this->play_next_tuple_(var); - return true; - }); + // Condition met or timed out - trigger the next action. Keep the item in a local + // holder so its arguments stay valid while the chain runs, without any nested + // process_queue_() call being able to see (and fire) it again. + std::list completed; + completed.splice(completed.begin(), queue, it); + uint8_t generation = this->stop_generation_; + this->play_next_tuple_(std::get>(completed.front())); + if (generation != this->stop_generation_) { + // stop() ran inside the chain - the items still held locally were cancelled + pending.clear(); + break; + } + } + + // Re-entrant continuations may have enqueued new waits into var_queue_; put the + // older still-waiting items back in front of them to keep FIFO firing order + this->var_queue_.splice(this->var_queue_.begin(), pending); return !this->var_queue_.empty(); } Condition *condition_; - std::list, std::tuple>> var_queue_{}; + std::list var_queue_{}; + // Bumped by stop() so process_queue_() can detect a stop from inside play_next_tuple_() + uint8_t stop_generation_{0}; }; template class UpdateComponentAction : public Action { diff --git a/tests/integration/fixtures/wait_until_reentrant_restart.yaml b/tests/integration/fixtures/wait_until_reentrant_restart.yaml new file mode 100644 index 0000000000..337d0de837 --- /dev/null +++ b/tests/integration/fixtures/wait_until_reentrant_restart.yaml @@ -0,0 +1,86 @@ +esphome: + name: wait-until-reentrant-restart + +host: + +api: + actions: + - action: start_self_restart + then: + - script.execute: retry_script + - action: start_stop_during_wait + then: + - globals.set: + id: gate_open + value: 'false' + # num 0 is a blocker: its condition never becomes true, so it is still + # waiting (already checked and set aside) when num 1 stops the script - + # it must be cancelled, not restored, so its timeout must never fire + - script.execute: + id: waiter + num: 0 + - script.execute: + id: waiter + num: 1 + - script.execute: + id: waiter + num: 2 + - script.execute: + id: waiter + num: 3 + # Give all three instances time to queue in the same wait_until + - delay: 100ms + - globals.set: + id: gate_open + value: 'true' + - delay: 200ms + - logger.log: "stop test complete" + +logger: + level: DEBUG + +globals: + - id: attempt + type: int + initial_value: '0' + - id: gate_open + type: bool + initial_value: 'false' + +script: + # Self-restart retry pattern: when the wait_until times out, the rest of the + # script runs synchronously from inside the wait queue processing and restarts + # this same script - re-entering the same WaitUntilAction while it is still + # processing its queue. This used to corrupt the queue and crash. + - id: retry_script + mode: restart + then: + - wait_until: + condition: + lambda: 'return false;' + timeout: 20ms + - lambda: |- + id(attempt) += 1; + ESP_LOGD("test", "attempt %d done", id(attempt)); + - if: + condition: + lambda: 'return id(attempt) < 5;' + then: + - script.execute: retry_script + else: + - logger.log: "retry test complete" + + # Parallel waiters all queued in the same wait_until; the first one to pass the + # gate stops the script from its continuation, cancelling the other waiters + # while the queue is still being processed. + - id: waiter + mode: parallel + parameters: + num: int + then: + - wait_until: + condition: + lambda: 'return num != 0 && id(gate_open);' + timeout: 1s + - lambda: 'ESP_LOGD("test", "gate passed %d", num);' + - script.stop: waiter diff --git a/tests/integration/test_wait_until_reentrant_restart.py b/tests/integration/test_wait_until_reentrant_restart.py new file mode 100644 index 0000000000..9c73339515 --- /dev/null +++ b/tests/integration/test_wait_until_reentrant_restart.py @@ -0,0 +1,89 @@ +"""Integration test for wait_until queue reentrancy. + +When a wait_until completes, the rest of the action chain runs synchronously +from inside the wait queue processing. That chain can re-enter the very same +WaitUntilAction - for example a script with mode: restart that executes itself +as a retry pattern, or a waiter that stops its own script. Both used to mutate +the std::list while it was being iterated, corrupting it and crashing the +device (Guru Meditation StoreProhibited in _M_transfer). +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_wait_until_reentrant_restart( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that re-entering a wait_until from its own continuation is safe.""" + retry_complete = asyncio.Event() + stop_complete = asyncio.Event() + + attempt_pattern = re.compile(r"attempt (\d+) done") + gate_pattern = re.compile(r"gate passed (\d+)") + + attempts: list[int] = [] + gate_passed: list[int] = [] + + def check_output(line: str) -> None: + """Check log output for expected messages.""" + if mo := attempt_pattern.search(line): + attempts.append(int(mo.group(1))) + elif mo := gate_pattern.search(line): + gate_passed.append(int(mo.group(1))) + elif "retry test complete" in line: + retry_complete.set() + elif "stop test complete" in line: + stop_complete.set() + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "wait-until-reentrant-restart" + + _, services = await client.list_entities_services() + self_restart_service = next( + (s for s in services if s.name == "start_self_restart"), None + ) + assert self_restart_service is not None, "start_self_restart not found" + stop_service = next( + (s for s in services if s.name == "start_stop_during_wait"), None + ) + assert stop_service is not None, "start_stop_during_wait not found" + + # Scenario 1: the wait_until timeout continuation restarts its own + # script five times, re-entering the same wait_until each time. + await client.execute_service(self_restart_service, {}) + try: + await asyncio.wait_for(retry_complete.wait(), timeout=10.0) + except TimeoutError: + pytest.fail(f"Self-restart retry did not finish. Attempts: {attempts}") + assert attempts == [1, 2, 3, 4, 5], attempts + + # Scenario 2: the first waiter through the gate stops the script while + # the other waiters are still queued in the same wait_until; both the + # not-yet-checked waiters (2, 3) and the already-checked still-waiting + # blocker (0) must be cancelled, not fired. + await client.execute_service(stop_service, {}) + try: + await asyncio.wait_for(stop_complete.wait(), timeout=10.0) + except TimeoutError: + pytest.fail(f"Stop-during-wait did not finish. Gate passed: {gate_passed}") + assert gate_passed == [1], gate_passed + + # If the cancelled blocker had been kept, its 1s wait_until timeout + # would still fire - give it the chance and check it stays silent. + await asyncio.sleep(1.5) + assert gate_passed == [1], gate_passed From 26db22546d4daa073ba17b28b144481ffc34025e Mon Sep 17 00:00:00 2001 From: Daniele Palumbo Date: Tue, 14 Jul 2026 04:38:07 +0200 Subject: [PATCH 0895/1815] [mcp23017] reset IPOL registers to 0x00 on setup (#17177) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/mcp23017/mcp23017.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/mcp23017/mcp23017.cpp b/esphome/components/mcp23017/mcp23017.cpp index 9e3d75575a..173d117457 100644 --- a/esphome/components/mcp23017/mcp23017.cpp +++ b/esphome/components/mcp23017/mcp23017.cpp @@ -19,6 +19,10 @@ void MCP23017::setup() { this->read_reg(mcp23x17_base::MCP23X17_OLATA, &this->olat_a_); this->read_reg(mcp23x17_base::MCP23X17_OLATB, &this->olat_b_); + // Reset IPOL to 0x00: ESPHome handles 'inverted' in software. + this->write_reg(mcp23x17_base::MCP23X17_IPOLA, 0x00); + this->write_reg(mcp23x17_base::MCP23X17_IPOLB, 0x00); + uint8_t iocon_flags = 0; if (this->open_drain_ints_) { iocon_flags |= IOCON_ODR; From 724df7b11ec8e86048a2ba68082df6bcd1b8e79e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:23:37 -0400 Subject: [PATCH 0896/1815] [atc_mithermometer] Make duplicate-packet counter per-instance (#17496) --- esphome/components/atc_mithermometer/atc_mithermometer.cpp | 7 +++---- esphome/components/atc_mithermometer/atc_mithermometer.h | 2 ++ 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.cpp b/esphome/components/atc_mithermometer/atc_mithermometer.cpp index f8bbd9d55e..7b5cdcfa20 100644 --- a/esphome/components/atc_mithermometer/atc_mithermometer.cpp +++ b/esphome/components/atc_mithermometer/atc_mithermometer.cpp @@ -65,12 +65,11 @@ optional ATCMiThermometer::parse_header_(const esp32_ble_tracker::S return {}; } - static uint8_t last_frame_count = 0; - if (last_frame_count == raw[12]) { - ESP_LOGVV(TAG, "parse_header(): duplicate data packet received (%hhu).", last_frame_count); + if (this->last_frame_count_ == raw[12]) { + ESP_LOGVV(TAG, "parse_header(): duplicate data packet received (%hhu).", this->last_frame_count_); return {}; } - last_frame_count = raw[12]; + this->last_frame_count_ = raw[12]; return result; } diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.h b/esphome/components/atc_mithermometer/atc_mithermometer.h index 3dde5f1868..0f472c11b9 100644 --- a/esphome/components/atc_mithermometer/atc_mithermometer.h +++ b/esphome/components/atc_mithermometer/atc_mithermometer.h @@ -38,6 +38,8 @@ class ATCMiThermometer final : public Component, public esp32_ble_tracker::ESPBT sensor::Sensor *battery_voltage_{nullptr}; sensor::Sensor *signal_strength_{nullptr}; + uint8_t last_frame_count_{0}; + optional parse_header_(const esp32_ble_tracker::ServiceData &service_data); bool parse_message_(const std::vector &message, ParseResult &result); bool report_results_(const optional &result, const char *address); From b5b426492aee046a93f5bd96853bdd6b66210943 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:27:17 +0200 Subject: [PATCH 0897/1815] [zigbee] bump esp-zigbee-sdk to 2.0.3 (#17564) --- esphome/components/zigbee/zigbee_esp32.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 73dcd07029..116dce8cc5 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -274,7 +274,7 @@ async def attributes_to_code( async def esp32_to_code(config: ConfigType) -> "MockObj": add_idf_component( name="espressif/esp-zigbee-lib", - ref="2.0.2", + ref="2.0.3", ) # add sdkconfigs later so they can overwrite esp32 defaults diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 7ad41fa978..60b00d33c7 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -48,7 +48,7 @@ dependencies: rules: - if: "target in [esp32, esp32p4]" espressif/esp-zigbee-lib: - version: 2.0.2 + version: 2.0.3 rules: - if: "target in [esp32h2, esp32c5, esp32c6]" espressif/lan87xx: From d1150d148d60b841c5676b6f6b09bd8c472f0902 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:59:19 -1000 Subject: [PATCH 0898/1815] Bump bundled esphome-device-builder to 1.6.0 (#17573) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 01ff53a463..e310d766b5 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.5.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.0 RUN \ platformio settings set enable_telemetry No \ From 3b915cf16bb5b721be22096d28b6b0eb5a70d961 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:11:47 +1200 Subject: [PATCH 0899/1815] [api] Warn when Home Assistant actions are sent with no subscribed client (#17560) --- esphome/components/api/api_connection.h | 8 +- esphome/components/api/api_server.cpp | 10 ++- ...pi_homeassistant_action_no_subscriber.yaml | 30 +++++++ ..._api_homeassistant_action_no_subscriber.py | 79 +++++++++++++++++++ 4 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 tests/integration/fixtures/api_homeassistant_action_no_subscriber.yaml create mode 100644 tests/integration/test_api_homeassistant_action_no_subscriber.py diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 144973fa9d..7df7ea1429 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -166,10 +166,14 @@ class APIConnection final : public APIServerConnectionBase { #endif bool try_send_log_message(int level, const char *tag, const char *line, size_t message_len); #ifdef USE_API_HOMEASSISTANT_SERVICES - void send_homeassistant_action(const HomeassistantActionRequest &call) { + // Returns whether this client has subscribed to Home Assistant actions; the message + // is only handed to the send path when subscribed. A true return does not guarantee + // delivery - it lets the caller warn when no connected client has the subscription. + bool send_homeassistant_action(const HomeassistantActionRequest &call) { if (!this->flags_.service_call_subscription) - return; + return false; this->send_message(call); + return true; } #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES void on_homeassistant_action_response(const HomeassistantActionResponse &msg); diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 1062dfeb39..6e3448121c 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -426,8 +426,16 @@ void APIServer::set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = bat #ifdef USE_API_HOMEASSISTANT_SERVICES void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call) { + bool has_subscriber = false; for (auto &client : this->active_clients()) { - client->send_homeassistant_action(call); + has_subscriber |= client->send_homeassistant_action(call); + } + if (!has_subscriber) { + // Home Assistant subscribes to actions shortly *after* authenticating, so actions + // fired right at connection time (on_client_connected, on_time_sync, ...) can + // arrive before the subscription and are lost - warn instead of failing silently. + ESP_LOGW(TAG, "Home Assistant %s '%s' dropped; %s", call.is_event ? "event" : "action", call.service.c_str(), + this->is_connected() ? "client has not subscribed to actions (yet)" : "no client connected"); } } #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES diff --git a/tests/integration/fixtures/api_homeassistant_action_no_subscriber.yaml b/tests/integration/fixtures/api_homeassistant_action_no_subscriber.yaml new file mode 100644 index 0000000000..26791e2cd1 --- /dev/null +++ b/tests/integration/fixtures/api_homeassistant_action_no_subscriber.yaml @@ -0,0 +1,30 @@ +esphome: + name: test-ha-action-no-subscriber + friendly_name: Home Assistant Action No Subscriber Test + on_boot: + # Fires before any client is connected - dropped with a warning. + - homeassistant.action: + action: test.boot_action + +host: + +api: + on_client_connected: + # Fires at authentication time, before the client has subscribed to + # Home Assistant actions - dropped with a warning. + - homeassistant.action: + action: test.connected_action + +logger: + level: DEBUG + +button: + - platform: template + name: Send Action Button + id: send_action_button + on_press: + # Pressed only after the client has subscribed - must be delivered. + - homeassistant.action: + action: test.button_action + data: + value: subscribed diff --git a/tests/integration/test_api_homeassistant_action_no_subscriber.py b/tests/integration/test_api_homeassistant_action_no_subscriber.py new file mode 100644 index 0000000000..9e7594e0ce --- /dev/null +++ b/tests/integration/test_api_homeassistant_action_no_subscriber.py @@ -0,0 +1,79 @@ +"""Integration test for Home Assistant actions fired without a subscriber. + +Home Assistant subscribes to device actions shortly after authenticating, while +on_client_connected (and similar triggers) fire right at authentication. Actions +fired before any client has subscribed cannot be delivered - they must produce a +warning in the log instead of vanishing silently. +""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import ButtonInfo, HomeassistantServiceCall +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_api_homeassistant_action_no_subscriber( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Undeliverable actions warn in the log; actions after subscribing arrive.""" + loop = asyncio.get_running_loop() + + boot_warning_future = loop.create_future() + connected_warning_future = loop.create_future() + button_action_future = loop.create_future() + + def check_output(line: str) -> None: + if ( + not boot_warning_future.done() + and "Home Assistant action 'test.boot_action' dropped; no client connected" + in line + ): + boot_warning_future.set_result(True) + if ( + not connected_warning_future.done() + and "Home Assistant action 'test.connected_action' dropped; " + "client has not subscribed to actions (yet)" + in line + ): + connected_warning_future.set_result(True) + + service_calls: list[HomeassistantServiceCall] = [] + + def on_service_call(service_call: HomeassistantServiceCall) -> None: + service_calls.append(service_call) + if ( + service_call.service == "test.button_action" + and not button_action_future.done() + ): + button_action_future.set_result(service_call) + + async with run_compiled(yaml_config, line_callback=check_output): + # The on_boot action fires with no client connected at all. + await asyncio.wait_for(boot_warning_future, timeout=10.0) + + async with api_client_connected() as client: + device_info = await client.device_info() + assert device_info.name == "test-ha-action-no-subscriber" + + # on_client_connected fired at authentication, before this client + # subscribed to Home Assistant actions. + await asyncio.wait_for(connected_warning_future, timeout=5.0) + + # After subscribing, actions must be delivered normally (and the + # dropped ones must not suddenly show up). + client.subscribe_service_calls(on_service_call) + + entities, _ = await client.list_entities_services() + button = next(e for e in entities if isinstance(e, ButtonInfo)) + client.button_command(button.key) + + button_call = await asyncio.wait_for(button_action_future, timeout=5.0) + assert button_call.data == {"value": "subscribed"} + assert [call.service for call in service_calls] == ["test.button_action"] From e056e99fd459ee56ac645447bec699e6cc18edbe Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:51:50 -0400 Subject: [PATCH 0900/1815] Bump bundled esphome-device-builder to 1.6.1 (#17575) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index e310d766b5..84fd658594 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.6.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.1 RUN \ platformio settings set enable_telemetry No \ From 878d8a2f6a404b81271705616fc1c7e96cce31ec Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 16 Jul 2026 08:14:55 +1200 Subject: [PATCH 0901/1815] [core] Fix wait_until crash when re-entered from its own continuation (#17571) --- esphome/core/base_automation.h | 51 ++++++++--- .../wait_until_reentrant_restart.yaml | 86 ++++++++++++++++++ .../test_wait_until_reentrant_restart.py | 89 +++++++++++++++++++ 3 files changed, 214 insertions(+), 12 deletions(-) create mode 100644 tests/integration/fixtures/wait_until_reentrant_restart.yaml create mode 100644 tests/integration/test_wait_until_reentrant_restart.py diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index cf8b05a300..38e52e44cb 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -502,6 +502,9 @@ template class WaitUntilAction : public Action, public Co void stop() override { this->var_queue_.clear(); + // Tell any process_queue_() call further down the stack that the items it is + // still holding were cancelled + this->stop_generation_++; this->disable_loop(); } @@ -511,33 +514,57 @@ template class WaitUntilAction : public Action, public Co } protected: + using QueueItem = std::tuple, std::tuple>; + // Helper: Process queue, triggering completed items and removing them // Returns true if queue still has pending items bool process_queue_(uint32_t now) { - // Process each queued wait_until and remove completed ones - this->var_queue_.remove_if([&](auto &queued) { - auto start = std::get(queued); - auto timeout = std::get>(queued); - auto &var = std::get>(queued); + // Completed items run the rest of the action chain synchronously, and that chain + // can re-enter this same action (e.g. a script with mode: restart that executes + // itself) and add to or clear var_queue_. Iterating the member list directly would + // then corrupt it, so move it aside and iterate a local list instead. + std::list queue; + queue.swap(this->var_queue_); + std::list pending; + while (!queue.empty()) { + auto it = queue.begin(); + auto start = std::get(*it); + auto timeout = std::get>(*it); // Check if timeout has expired auto expired = timeout && (now - start) >= *timeout; // Keep waiting if not expired and condition not met - if (!expired && !this->condition_->check_tuple(var)) { - return false; + if (!expired && !this->condition_->check_tuple(std::get>(*it))) { + pending.splice(pending.end(), queue, it); + continue; } - // Condition met or timed out - trigger next action - this->play_next_tuple_(var); - return true; - }); + // Condition met or timed out - trigger the next action. Keep the item in a local + // holder so its arguments stay valid while the chain runs, without any nested + // process_queue_() call being able to see (and fire) it again. + std::list completed; + completed.splice(completed.begin(), queue, it); + uint8_t generation = this->stop_generation_; + this->play_next_tuple_(std::get>(completed.front())); + if (generation != this->stop_generation_) { + // stop() ran inside the chain - the items still held locally were cancelled + pending.clear(); + break; + } + } + + // Re-entrant continuations may have enqueued new waits into var_queue_; put the + // older still-waiting items back in front of them to keep FIFO firing order + this->var_queue_.splice(this->var_queue_.begin(), pending); return !this->var_queue_.empty(); } Condition *condition_; - std::list, std::tuple>> var_queue_{}; + std::list var_queue_{}; + // Bumped by stop() so process_queue_() can detect a stop from inside play_next_tuple_() + uint8_t stop_generation_{0}; }; template class UpdateComponentAction : public Action { diff --git a/tests/integration/fixtures/wait_until_reentrant_restart.yaml b/tests/integration/fixtures/wait_until_reentrant_restart.yaml new file mode 100644 index 0000000000..337d0de837 --- /dev/null +++ b/tests/integration/fixtures/wait_until_reentrant_restart.yaml @@ -0,0 +1,86 @@ +esphome: + name: wait-until-reentrant-restart + +host: + +api: + actions: + - action: start_self_restart + then: + - script.execute: retry_script + - action: start_stop_during_wait + then: + - globals.set: + id: gate_open + value: 'false' + # num 0 is a blocker: its condition never becomes true, so it is still + # waiting (already checked and set aside) when num 1 stops the script - + # it must be cancelled, not restored, so its timeout must never fire + - script.execute: + id: waiter + num: 0 + - script.execute: + id: waiter + num: 1 + - script.execute: + id: waiter + num: 2 + - script.execute: + id: waiter + num: 3 + # Give all three instances time to queue in the same wait_until + - delay: 100ms + - globals.set: + id: gate_open + value: 'true' + - delay: 200ms + - logger.log: "stop test complete" + +logger: + level: DEBUG + +globals: + - id: attempt + type: int + initial_value: '0' + - id: gate_open + type: bool + initial_value: 'false' + +script: + # Self-restart retry pattern: when the wait_until times out, the rest of the + # script runs synchronously from inside the wait queue processing and restarts + # this same script - re-entering the same WaitUntilAction while it is still + # processing its queue. This used to corrupt the queue and crash. + - id: retry_script + mode: restart + then: + - wait_until: + condition: + lambda: 'return false;' + timeout: 20ms + - lambda: |- + id(attempt) += 1; + ESP_LOGD("test", "attempt %d done", id(attempt)); + - if: + condition: + lambda: 'return id(attempt) < 5;' + then: + - script.execute: retry_script + else: + - logger.log: "retry test complete" + + # Parallel waiters all queued in the same wait_until; the first one to pass the + # gate stops the script from its continuation, cancelling the other waiters + # while the queue is still being processed. + - id: waiter + mode: parallel + parameters: + num: int + then: + - wait_until: + condition: + lambda: 'return num != 0 && id(gate_open);' + timeout: 1s + - lambda: 'ESP_LOGD("test", "gate passed %d", num);' + - script.stop: waiter diff --git a/tests/integration/test_wait_until_reentrant_restart.py b/tests/integration/test_wait_until_reentrant_restart.py new file mode 100644 index 0000000000..9c73339515 --- /dev/null +++ b/tests/integration/test_wait_until_reentrant_restart.py @@ -0,0 +1,89 @@ +"""Integration test for wait_until queue reentrancy. + +When a wait_until completes, the rest of the action chain runs synchronously +from inside the wait queue processing. That chain can re-enter the very same +WaitUntilAction - for example a script with mode: restart that executes itself +as a retry pattern, or a waiter that stops its own script. Both used to mutate +the std::list while it was being iterated, corrupting it and crashing the +device (Guru Meditation StoreProhibited in _M_transfer). +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_wait_until_reentrant_restart( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that re-entering a wait_until from its own continuation is safe.""" + retry_complete = asyncio.Event() + stop_complete = asyncio.Event() + + attempt_pattern = re.compile(r"attempt (\d+) done") + gate_pattern = re.compile(r"gate passed (\d+)") + + attempts: list[int] = [] + gate_passed: list[int] = [] + + def check_output(line: str) -> None: + """Check log output for expected messages.""" + if mo := attempt_pattern.search(line): + attempts.append(int(mo.group(1))) + elif mo := gate_pattern.search(line): + gate_passed.append(int(mo.group(1))) + elif "retry test complete" in line: + retry_complete.set() + elif "stop test complete" in line: + stop_complete.set() + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "wait-until-reentrant-restart" + + _, services = await client.list_entities_services() + self_restart_service = next( + (s for s in services if s.name == "start_self_restart"), None + ) + assert self_restart_service is not None, "start_self_restart not found" + stop_service = next( + (s for s in services if s.name == "start_stop_during_wait"), None + ) + assert stop_service is not None, "start_stop_during_wait not found" + + # Scenario 1: the wait_until timeout continuation restarts its own + # script five times, re-entering the same wait_until each time. + await client.execute_service(self_restart_service, {}) + try: + await asyncio.wait_for(retry_complete.wait(), timeout=10.0) + except TimeoutError: + pytest.fail(f"Self-restart retry did not finish. Attempts: {attempts}") + assert attempts == [1, 2, 3, 4, 5], attempts + + # Scenario 2: the first waiter through the gate stops the script while + # the other waiters are still queued in the same wait_until; both the + # not-yet-checked waiters (2, 3) and the already-checked still-waiting + # blocker (0) must be cancelled, not fired. + await client.execute_service(stop_service, {}) + try: + await asyncio.wait_for(stop_complete.wait(), timeout=10.0) + except TimeoutError: + pytest.fail(f"Stop-during-wait did not finish. Gate passed: {gate_passed}") + assert gate_passed == [1], gate_passed + + # If the cancelled blocker had been kept, its 1s wait_until timeout + # would still fire - give it the chance and check it stays silent. + await asyncio.sleep(1.5) + assert gate_passed == [1], gate_passed From 63fae2c36a14b5dc042fd1ffa5fc773c52aa5cf2 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 16 Jul 2026 08:23:57 +1200 Subject: [PATCH 0902/1815] Bump version to 2026.7.0b4 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 3bd4dc140f..8896ab5d18 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.7.0b3 +PROJECT_NUMBER = 2026.7.0b4 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 01ff67e3f2..2e41e8b131 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.7.0b3" +__version__ = "2026.7.0b4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 6c401d406fd345260b6620a95011076924579bf7 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:52:34 +1200 Subject: [PATCH 0903/1815] Bump version to 2026.7.0 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 8896ab5d18..46f96b459a 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.7.0b4 +PROJECT_NUMBER = 2026.7.0 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 2e41e8b131..0b0d3c2e4a 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.7.0b4" +__version__ = "2026.7.0" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 5d2372937e87b99264b51f7be2a1aa41cec2fcdf Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:29:23 -0400 Subject: [PATCH 0904/1815] [ci] Stop per-PR cache copies from crowding the 10GB Actions cache quota (#17463) --- .github/actions/restore-python/action.yml | 3 ++ .github/workflows/ci-api-proto.yml | 3 ++ .github/workflows/ci.yml | 65 ++++++++++++++++++++--- 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 9d78b2d843..9d6dc5301c 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -35,6 +35,9 @@ runs: uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true + # Pull request saves land in per-PR scopes nothing else can + # reuse; dev pushes seed the shared copy instead. + save-cache: ${{ github.event_name != 'pull_request' }} # Pin uv version so the action does not have to fetch the # manifest from raw.githubusercontent.com on every cache # miss; that fetch flakes on Windows runners. diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index ebbe720463..58fc83e3f5 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -32,6 +32,9 @@ jobs: uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true + # Pull-request-only workflow: a save could never be shared and + # would only consume quota. + save-cache: "false" # Pin uv version so the action does not have to fetch the # manifest from raw.githubusercontent.com on every cache # miss; that fetch flakes on Windows runners. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e93b6ece8..adf98478fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,9 @@ jobs: uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true + # Pull request saves land in per-PR scopes nothing else can + # reuse; dev pushes seed the shared copy instead. + save-cache: ${{ github.event_name != 'pull_request' }} # Pin uv version so the action does not have to fetch the # manifest from raw.githubusercontent.com on every cache # miss; that fetch flakes on Windows runners. @@ -174,6 +177,9 @@ jobs: uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true + # Pull request saves land in per-PR scopes nothing else can + # reuse; dev pushes seed the shared copy instead. + save-cache: ${{ github.event_name != 'pull_request' }} # Pin uv version so the action does not have to fetch the # manifest from raw.githubusercontent.com on every cache # miss; that fetch flakes on Windows runners. @@ -375,6 +381,9 @@ jobs: uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true + # Pull request saves land in per-PR scopes nothing else can + # reuse; dev pushes seed the shared copy instead. + save-cache: ${{ github.event_name != 'pull_request' }} # Pin uv version so the action does not have to fetch the # manifest from raw.githubusercontent.com on every cache # miss; that fetch flakes on Windows runners. @@ -828,11 +837,12 @@ jobs: - name: List components run: echo ${{ matrix.batch.components }} - - name: Cache apt packages - uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 - with: - packages: libsdl2-dev ccache - version: 1.1 + - name: Install apt packages + # Not cached: this job is pull-request-only, so a cache save could + # never be shared and would only consume quota. + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends libsdl2-dev ccache - name: Check out code from GitHub uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -1006,6 +1016,36 @@ jobs: # Arduino framework via PlatformIO (only components with an esp32-ard test are built): python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio + pre-commit-seed-cache: + name: Seed pre-commit cache + runs-on: ubuntu-latest + needs: + - common + # Saves a dev-scoped pre-commit cache that pull request runs can + # restore, since pre-commit.ci lite itself never runs on dev pushes. + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + steps: + - name: Check out code from GitHub + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + - name: Cache pre-commit environments + id: cache-pre-commit + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/pre-commit + # Must match the restore key in pre-commit-ci-lite + # yamllint disable-line rule:line-length + key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} + - name: Install pre-commit hook environments + if: steps.cache-pre-commit.outputs.cache-hit != 'true' + run: | + python -m pip install pre-commit + pre-commit install-hooks + pre-commit-ci-lite: name: pre-commit.ci lite runs-on: ubuntu-latest @@ -1021,9 +1061,22 @@ jobs: with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - uses: esphome/pre-commit-action@43cd1109c09c544d97196f7730ee5b2e0cc6d81e # v3.0.1 fork with pinned actions/cache + # Inlined from esphome/pre-commit-action with a restore-only cache + # step: the pre-commit-seed-cache job owns saving this cache, so + # pull request runs never write per-PR copies. + - name: Restore pre-commit cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/pre-commit + # Must match the key pre-commit-seed-cache saves + # yamllint disable-line rule:line-length + key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} + - name: Run pre-commit env: SKIP: pylint,ci-custom + run: | + python -m pip install pre-commit + pre-commit run --show-diff-on-failure --color=always --all-files - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 if: always() From d53d4c5b58aa381aa004f80262ab4ff6579e54c1 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:16:24 -0400 Subject: [PATCH 0905/1815] [emc2101] Fix negative external temperatures reported as large positives (#17494) --- esphome/components/emc2101/emc2101.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/emc2101/emc2101.cpp b/esphome/components/emc2101/emc2101.cpp index 464f49fe51..f46082f5e7 100644 --- a/esphome/components/emc2101/emc2101.cpp +++ b/esphome/components/emc2101/emc2101.cpp @@ -145,9 +145,9 @@ float Emc2101Component::get_external_temperature() { return NAN; } - // join msb and lsb (5 least significant bits are not used) - uint16_t raw = (msb << 8 | lsb) >> 5; - return raw * 0.125; + // join msb and lsb (5 least significant bits are not used); msb is signed, so read as int16_t + int16_t raw = static_cast((msb << 8) | lsb) >> 5; + return raw * 0.125f; } float Emc2101Component::get_speed() { From e700b0140601a16ed40d2dcfafc81b068ea403ba Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:16:38 -0400 Subject: [PATCH 0906/1815] [haier] Fix outdoor defrost temperature reporting the coil temperature (#17492) --- esphome/components/haier/hon_climate.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index f68404afd9..88d446829a 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -825,7 +825,7 @@ haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t * #ifdef USE_SENSOR this->update_sub_sensor_(SubSensorType::INDOOR_COIL_TEMPERATURE, bd_packet->indoor_coil_temperature / 2.0 - 20); this->update_sub_sensor_(SubSensorType::OUTDOOR_COIL_TEMPERATURE, bd_packet->outdoor_coil_temperature - 64); - this->update_sub_sensor_(SubSensorType::OUTDOOR_DEFROST_TEMPERATURE, bd_packet->outdoor_coil_temperature - 64); + this->update_sub_sensor_(SubSensorType::OUTDOOR_DEFROST_TEMPERATURE, bd_packet->outdoor_defrost_temperature - 64); this->update_sub_sensor_(SubSensorType::OUTDOOR_IN_AIR_TEMPERATURE, bd_packet->outdoor_in_air_temperature - 64); this->update_sub_sensor_(SubSensorType::OUTDOOR_OUT_AIR_TEMPERATURE, bd_packet->outdoor_out_air_temperature - 64); this->update_sub_sensor_(SubSensorType::POWER, encode_uint16(bd_packet->power[0], bd_packet->power[1])); From 5a3c2f4d11a7be933e24ed67c6a6618578398941 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:17:21 -0400 Subject: [PATCH 0907/1815] [ci] Add rp2 clang-tidy environment (#17486) --- .github/workflows/ci.yml | 4 ++ esphome/components/debug/debug_rp2.cpp | 2 +- .../components/ethernet/ethernet_component.h | 2 + .../ethernet/ethernet_component_rp2.cpp | 13 ++-- .../components/fastled_base/fastled_light.cpp | 2 +- .../components/fastled_base/fastled_light.h | 2 +- esphome/components/midea/ac_adapter.cpp | 2 +- esphome/components/midea/ac_adapter.h | 2 +- esphome/components/midea/ac_automations.h | 2 +- esphome/components/midea/air_conditioner.cpp | 2 +- esphome/components/midea/air_conditioner.h | 2 +- esphome/components/midea/appliance_base.h | 2 +- esphome/components/midea/climate.py | 14 ++++ esphome/components/midea/ir_transmitter.h | 2 +- esphome/components/rp2/core.h | 1 + esphome/components/rp2/crash_handler.cpp | 2 +- esphome/components/rp2/hal.cpp | 3 +- esphome/components/rp2/hal.h | 8 +-- esphome/components/rp2/preferences.cpp | 9 +-- esphome/components/rp2/printf_stubs.cpp | 4 +- esphome/components/rp2040_ble/rp2040_ble.cpp | 6 +- esphome/components/rp2040_ble/rp2040_ble.h | 2 +- .../rp2040_pio_led_strip/led_strip.cpp | 47 +++++------- .../rp2040_pio_led_strip/led_strip.h | 12 ++-- esphome/components/wifi/wifi_component.h | 2 +- .../components/wifi/wifi_component_pico_w.cpp | 22 +++--- esphome/components/wireguard/__init__.py | 71 ++++++++++++------- esphome/components/wled/wled_light_effect.h | 7 ++ esphome/core/defines.h | 9 ++- esphome/core/wake/wake_rp2.cpp | 4 +- platformio.ini | 22 ++++++ script/clang-tidy | 59 ++++++++++----- 32 files changed, 214 insertions(+), 129 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e98999741..6066d0ea03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -504,6 +504,10 @@ jobs: options: --environment nrf52-tidy --grep USE_ZEPHYR --grep USE_NRF52 cache_sdk_nrf: true ignore_errors: false + - id: clang-tidy + name: Run script/clang-tidy for RP2 + options: --environment rp2-tidy --grep USE_RP2 + pio_cache_key: tidyrp2 steps: - name: Check out code from GitHub diff --git a/esphome/components/debug/debug_rp2.cpp b/esphome/components/debug/debug_rp2.cpp index ba6081963f..336e9c7e06 100644 --- a/esphome/components/debug/debug_rp2.cpp +++ b/esphome/components/debug/debug_rp2.cpp @@ -74,7 +74,7 @@ size_t DebugComponent::get_device_info_(std::span constexpr size_t size = DEVICE_INFO_BUFFER_SIZE; char *buf = buffer.data(); - uint32_t cpu_freq = ::rp2040.f_cpu(); + uint32_t cpu_freq = RP2040::f_cpu(); ESP_LOGD(TAG, "CPU Frequency: %" PRIu32, cpu_freq); pos = buf_append_printf(buf, size, pos, "|CPU Frequency: %" PRIu32, cpu_freq); diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 7160351727..9f4398c621 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -112,8 +112,10 @@ enum class EthernetComponentState : uint8_t { // Platform-neutral duplex/speed types #ifndef USE_ESP32 +// NOLINTBEGIN(readability-identifier-naming) enum eth_duplex_t { ETH_DUPLEX_HALF, ETH_DUPLEX_FULL }; enum eth_speed_t { ETH_SPEED_10M, ETH_SPEED_100M }; +// NOLINTEND(readability-identifier-naming) #endif class EthernetComponent final : public Component { diff --git a/esphome/components/ethernet/ethernet_component_rp2.cpp b/esphome/components/ethernet/ethernet_component_rp2.cpp index d2e3f14e02..4d6d6c4f5b 100644 --- a/esphome/components/ethernet/ethernet_component_rp2.cpp +++ b/esphome/components/ethernet/ethernet_component_rp2.cpp @@ -187,17 +187,18 @@ void EthernetComponent::loop() { } void EthernetComponent::dump_config() { - const char *type_str = "Unknown"; #if defined(USE_ETHERNET_W5500) - type_str = "W5500"; + const char *type_str = "W5500"; #elif defined(USE_ETHERNET_W5100) - type_str = "W5100"; + const char *type_str = "W5100"; #elif defined(USE_ETHERNET_W6100) - type_str = "W6100"; + const char *type_str = "W6100"; #elif defined(USE_ETHERNET_W6300) - type_str = "W6300"; + const char *type_str = "W6300"; #elif defined(USE_ETHERNET_ENC28J60) - type_str = "ENC28J60"; + const char *type_str = "ENC28J60"; +#else + const char *type_str = "Unknown"; #endif #if defined(USE_ETHERNET_W6300) // W6300 uses PIO QSPI with hardcoded pins — SPI pin fields are not used diff --git a/esphome/components/fastled_base/fastled_light.cpp b/esphome/components/fastled_base/fastled_light.cpp index 0fa69a23b4..af6e5720ec 100644 --- a/esphome/components/fastled_base/fastled_light.cpp +++ b/esphome/components/fastled_base/fastled_light.cpp @@ -1,4 +1,4 @@ -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_RP2) #include "fastled_light.h" #include "esphome/core/log.h" diff --git a/esphome/components/fastled_base/fastled_light.h b/esphome/components/fastled_base/fastled_light.h index 1261b742a1..0459777f40 100644 --- a/esphome/components/fastled_base/fastled_light.h +++ b/esphome/components/fastled_base/fastled_light.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_RP2) #include "esphome/core/component.h" #include "esphome/core/helpers.h" diff --git a/esphome/components/midea/ac_adapter.cpp b/esphome/components/midea/ac_adapter.cpp index 2f4ef5c948..3611b20715 100644 --- a/esphome/components/midea/ac_adapter.cpp +++ b/esphome/components/midea/ac_adapter.cpp @@ -1,4 +1,4 @@ -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_RP2) #include "esphome/core/log.h" #include "ac_adapter.h" diff --git a/esphome/components/midea/ac_adapter.h b/esphome/components/midea/ac_adapter.h index a7924ae51e..53959efe2a 100644 --- a/esphome/components/midea/ac_adapter.h +++ b/esphome/components/midea/ac_adapter.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_RP2) // MideaUART #include diff --git a/esphome/components/midea/ac_automations.h b/esphome/components/midea/ac_automations.h index acd9191916..9c35e191b5 100644 --- a/esphome/components/midea/ac_automations.h +++ b/esphome/components/midea/ac_automations.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_RP2) #include "esphome/core/automation.h" #include "air_conditioner.h" diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index 7603dd5254..a743e867af 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -1,4 +1,4 @@ -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_RP2) #include "esphome/core/helpers.h" #include "esphome/core/log.h" diff --git a/esphome/components/midea/air_conditioner.h b/esphome/components/midea/air_conditioner.h index bea6c2eadb..cd04c87890 100644 --- a/esphome/components/midea/air_conditioner.h +++ b/esphome/components/midea/air_conditioner.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_RP2) // MideaUART #include diff --git a/esphome/components/midea/appliance_base.h b/esphome/components/midea/appliance_base.h index d36f5a322c..d9486564c0 100644 --- a/esphome/components/midea/appliance_base.h +++ b/esphome/components/midea/appliance_base.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_RP2) // MideaUART #include diff --git a/esphome/components/midea/climate.py b/esphome/components/midea/climate.py index 4a75464b90..aedb517f89 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -25,6 +25,11 @@ from esphome.const import ( ICON_POWER, ICON_THERMOMETER, ICON_WATER_PERCENT, + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_LN882X, + PLATFORM_RTL87XX, STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, UNIT_PERCENT, @@ -152,6 +157,15 @@ CONFIG_SCHEMA = cv.All( .extend(uart.UART_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA), cv.only_with_arduino, + cv.only_on( + [ + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_BK72XX, + PLATFORM_RTL87XX, + PLATFORM_LN882X, + ] + ), ) # Actions diff --git a/esphome/components/midea/ir_transmitter.h b/esphome/components/midea/ir_transmitter.h index f11682230d..43a2e2f261 100644 --- a/esphome/components/midea/ir_transmitter.h +++ b/esphome/components/midea/ir_transmitter.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_RP2) #ifdef USE_REMOTE_TRANSMITTER #include "esphome/components/remote_base/midea_protocol.h" diff --git a/esphome/components/rp2/core.h b/esphome/components/rp2/core.h index c53c3719eb..4ce9151d41 100644 --- a/esphome/components/rp2/core.h +++ b/esphome/components/rp2/core.h @@ -5,6 +5,7 @@ #include #include +// NOLINTNEXTLINE(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) extern "C" unsigned long ulMainGetRunTimeCounterValue(); namespace esphome::rp2 {} // namespace esphome::rp2 diff --git a/esphome/components/rp2/crash_handler.cpp b/esphome/components/rp2/crash_handler.cpp index 5553a24a60..a0fea21637 100644 --- a/esphome/components/rp2/crash_handler.cpp +++ b/esphome/components/rp2/crash_handler.cpp @@ -64,7 +64,7 @@ static struct CrashData { uint32_t sp; uint32_t backtrace[MAX_BACKTRACE]; uint8_t backtrace_count; -} s_crash_data __attribute__((section(".noinit"))); +} s_crash_data __attribute__((section(".noinit"))); // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) bool crash_handler_has_data() { return s_crash_data.valid; } diff --git a/esphome/components/rp2/hal.cpp b/esphome/components/rp2/hal.cpp index 28535cacbb..8eb1b469bc 100644 --- a/esphome/components/rp2/hal.cpp +++ b/esphome/components/rp2/hal.cpp @@ -20,8 +20,7 @@ namespace esphome { // arch_feed_wdt(), arch_get_cpu_cycle_count() inlined in components/rp2/hal.h. void arch_restart() { watchdog_reboot(0, 0, 10); - while (1) { - continue; + while (true) { } } diff --git a/esphome/components/rp2/hal.h b/esphome/components/rp2/hal.h index b16f31d797..ec46937bab 100644 --- a/esphome/components/rp2/hal.h +++ b/esphome/components/rp2/hal.h @@ -17,13 +17,13 @@ extern "C" unsigned long micros(void); extern "C" unsigned long millis(void); // NOLINTEND(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) -// Forward decl from . +// Forward decls from and the pico-sdk / FreeRTOS port for the +// inline arch_* wrappers below. +// NOLINTBEGIN(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) extern "C" uint64_t time_us_64(void); - -// Forward decls from pico-sdk / FreeRTOS port for the inline arch_* -// wrappers below. extern "C" void watchdog_update(void); extern "C" unsigned long ulMainGetRunTimeCounterValue(void); +// NOLINTEND(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) namespace esphome::rp2 {} diff --git a/esphome/components/rp2/preferences.cpp b/esphome/components/rp2/preferences.cpp index 778ce070a9..d1e0bc555f 100644 --- a/esphome/components/rp2/preferences.cpp +++ b/esphome/components/rp2/preferences.cpp @@ -26,6 +26,7 @@ static bool s_flash_dirty = false; // NOLINT(cppcoreguidelines-avo // No preference can exceed the total flash storage, so stack buffer covers all cases. static constexpr size_t PREF_MAX_BUFFER_SIZE = RP2040_FLASH_STORAGE_SIZE; +// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp) extern "C" uint8_t _EEPROM_start; template uint8_t calculate_crc(It first, It last, uint32_t type) { @@ -38,9 +39,9 @@ template uint8_t calculate_crc(It first, It last, uint32_t type) { } bool RP2PreferenceBackend::save(const uint8_t *data, size_t len) { - const size_t buffer_size = len + 1; - if (buffer_size > PREF_MAX_BUFFER_SIZE) + if (len >= PREF_MAX_BUFFER_SIZE) return false; + const size_t buffer_size = len + 1; uint8_t buffer[PREF_MAX_BUFFER_SIZE]; memcpy(buffer, data, len); buffer[len] = calculate_crc(buffer, buffer + len, this->type); @@ -59,9 +60,9 @@ bool RP2PreferenceBackend::save(const uint8_t *data, size_t len) { } bool RP2PreferenceBackend::load(uint8_t *data, size_t len) { - const size_t buffer_size = len + 1; - if (buffer_size > PREF_MAX_BUFFER_SIZE) + if (len >= PREF_MAX_BUFFER_SIZE) return false; + const size_t buffer_size = len + 1; uint8_t buffer[PREF_MAX_BUFFER_SIZE]; for (size_t i = 0; i < buffer_size; i++) { diff --git a/esphome/components/rp2/printf_stubs.cpp b/esphome/components/rp2/printf_stubs.cpp index bf03565f30..47cf30b263 100644 --- a/esphome/components/rp2/printf_stubs.cpp +++ b/esphome/components/rp2/printf_stubs.cpp @@ -33,8 +33,8 @@ static int write_printf_buffer(FILE *stream, char *buf, int len) { if (write_len >= PRINTF_BUFFER_SIZE) { fwrite(buf, 1, PRINTF_BUFFER_SIZE - 1, stream); // Use fwrite for the message to avoid recursive __wrap_printf call - static const char msg[] = "\nprintf buffer overflow\n"; - fwrite(msg, 1, sizeof(msg) - 1, stream); + static const char MSG[] = "\nprintf buffer overflow\n"; + fwrite(MSG, 1, sizeof(MSG) - 1, stream); abort(); } if (fwrite(buf, 1, write_len, stream) < write_len || ferror(stream)) { diff --git a/esphome/components/rp2040_ble/rp2040_ble.cpp b/esphome/components/rp2040_ble/rp2040_ble.cpp index 4125da7ec0..dca0cd4653 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.cpp +++ b/esphome/components/rp2040_ble/rp2040_ble.cpp @@ -35,10 +35,10 @@ void RP2040BLE::enable() { l2cap_init(); sm_init(); - this->hci_event_callback_registration_.callback = &RP2040BLE::packet_handler_; + this->hci_event_callback_registration_.callback = &RP2040BLE::packet_handler; hci_add_event_handler(&this->hci_event_callback_registration_); - this->sm_event_callback_registration_.callback = &RP2040BLE::packet_handler_; + this->sm_event_callback_registration_.callback = &RP2040BLE::packet_handler; sm_add_event_handler(&this->sm_event_callback_registration_); this->btstack_initialized_ = true; @@ -95,7 +95,7 @@ void RP2040BLE::dump_config() { float RP2040BLE::get_setup_priority() const { return setup_priority::BLUETOOTH; } -void RP2040BLE::packet_handler_(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size) { +void RP2040BLE::packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size) { if (global_ble == nullptr) { return; } diff --git a/esphome/components/rp2040_ble/rp2040_ble.h b/esphome/components/rp2040_ble/rp2040_ble.h index 885e49f690..e9df12cfb1 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.h +++ b/esphome/components/rp2040_ble/rp2040_ble.h @@ -32,7 +32,7 @@ class RP2040BLE final : public Component { void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } protected: - static void packet_handler_(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); + static void packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); btstack_packet_callback_registration_t hci_event_callback_registration_{}; btstack_packet_callback_registration_t sm_event_callback_registration_{}; diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.cpp b/esphome/components/rp2040_pio_led_strip/led_strip.cpp index b9c0a9c257..cf7041931e 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.cpp +++ b/esphome/components/rp2040_pio_led_strip/led_strip.cpp @@ -14,26 +14,15 @@ namespace esphome::rp2040_pio_led_strip { -static const char *TAG = "rp2040_pio_led_strip"; - -static uint8_t num_instance_[2] = {0, 0}; -static std::map chipset_offsets_ = { - {CHIPSET_WS2812, 0}, {CHIPSET_WS2812B, 0}, {CHIPSET_SK6812, 0}, {CHIPSET_SM16703, 0}, {CHIPSET_CUSTOM, 0}, -}; -static std::map conf_count_ = { - {CHIPSET_WS2812, false}, {CHIPSET_WS2812B, false}, {CHIPSET_SK6812, false}, - {CHIPSET_SM16703, false}, {CHIPSET_CUSTOM, false}, -}; -static bool dma_chan_active_[12]; -static struct semaphore dma_write_complete_sem_[12]; +static const char *const TAG = "rp2040_pio_led_strip"; // DMA interrupt service routine -void RP2040PIOLEDStripLightOutput::dma_write_complete_handler_() { +void RP2040PIOLEDStripLightOutput::dma_write_complete_handler() { uint32_t channel = dma_hw->ints0; for (uint dma_chan = 0; dma_chan < 12; ++dma_chan) { - if (RP2040PIOLEDStripLightOutput::dma_chan_active_[dma_chan] && (channel & (1u << dma_chan))) { - dma_hw->ints0 = (1u << dma_chan); // Clear the interrupt - sem_release(&RP2040PIOLEDStripLightOutput::dma_write_complete_sem_[dma_chan]); // Handle the interrupt + if (RP2040PIOLEDStripLightOutput::dma_chan_active[dma_chan] && (channel & (1u << dma_chan))) { + dma_hw->ints0 = (1u << dma_chan); // Clear the interrupt + sem_release(&RP2040PIOLEDStripLightOutput::dma_write_complete_sem[dma_chan]); // Handle the interrupt } } } @@ -69,22 +58,22 @@ void RP2040PIOLEDStripLightOutput::setup() { // but there are only 4 state machines on each PIO so we can only have 4 strips per PIO uint offset = 0; - if (RP2040PIOLEDStripLightOutput::num_instance_[this->pio_ == pio0 ? 0 : 1] >= 4) { + if (RP2040PIOLEDStripLightOutput::num_instance[this->pio_ == pio0 ? 0 : 1] >= 4) { ESP_LOGE(TAG, "Too many instances of PIO program"); this->mark_failed(); return; } // keep track of how many instances of the PIO program are running on each PIO - RP2040PIOLEDStripLightOutput::num_instance_[this->pio_ == pio0 ? 0 : 1]++; + RP2040PIOLEDStripLightOutput::num_instance[this->pio_ == pio0 ? 0 : 1]++; // if there are multiple strips of the same chipset, we can reuse the same PIO program and save space - if (this->conf_count_[this->chipset_]) { - offset = RP2040PIOLEDStripLightOutput::chipset_offsets_[this->chipset_]; + if (RP2040PIOLEDStripLightOutput::conf_count[this->chipset_]) { + offset = RP2040PIOLEDStripLightOutput::chipset_offsets[this->chipset_]; } else { // Load the assembled program into the PIO and get its location in the PIO's instruction memory and save it offset = pio_add_program(this->pio_, this->program_); - RP2040PIOLEDStripLightOutput::chipset_offsets_[this->chipset_] = offset; - RP2040PIOLEDStripLightOutput::conf_count_[this->chipset_] = true; + RP2040PIOLEDStripLightOutput::chipset_offsets[this->chipset_] = offset; + RP2040PIOLEDStripLightOutput::conf_count[this->chipset_] = true; } // Configure the state machine's PIO, and start it @@ -106,7 +95,7 @@ void RP2040PIOLEDStripLightOutput::setup() { } // Mark the DMA channel as active - RP2040PIOLEDStripLightOutput::dma_chan_active_[this->dma_chan_] = true; + RP2040PIOLEDStripLightOutput::dma_chan_active[this->dma_chan_] = true; this->dma_config_ = dma_channel_get_default_config(this->dma_chan_); channel_config_set_transfer_data_size( @@ -125,11 +114,11 @@ void RP2040PIOLEDStripLightOutput::setup() { ); // Initialize the semaphore for this DMA channel - sem_init(&RP2040PIOLEDStripLightOutput::dma_write_complete_sem_[this->dma_chan_], 1, 1); + sem_init(&RP2040PIOLEDStripLightOutput::dma_write_complete_sem[this->dma_chan_], 1, 1); - irq_set_exclusive_handler(DMA_IRQ_0, dma_write_complete_handler_); // after DMA all data, raise an interrupt - dma_channel_set_irq0_enabled(this->dma_chan_, true); // map DMA channel to interrupt - irq_set_enabled(DMA_IRQ_0, true); // enable interrupt + irq_set_exclusive_handler(DMA_IRQ_0, dma_write_complete_handler); // after DMA all data, raise an interrupt + dma_channel_set_irq0_enabled(this->dma_chan_, true); // map DMA channel to interrupt + irq_set_enabled(DMA_IRQ_0, true); // enable interrupt this->init_(this->pio_, this->sm_, offset, this->pin_, this->max_refresh_rate_); } @@ -148,12 +137,12 @@ void RP2040PIOLEDStripLightOutput::write_state(light::LightState *state) { } // the bits are already in the correct order for the pio program so we can just copy the buffer using DMA - sem_acquire_blocking(&RP2040PIOLEDStripLightOutput::dma_write_complete_sem_[this->dma_chan_]); + sem_acquire_blocking(&RP2040PIOLEDStripLightOutput::dma_write_complete_sem[this->dma_chan_]); dma_channel_transfer_from_buffer_now(this->dma_chan_, this->buf_, this->get_buffer_size_()); } light::ESPColorView RP2040PIOLEDStripLightOutput::get_view_internal(int32_t index) const { - int32_t r = 0, g = 0, b = 0, w = 0; + int32_t r = 0, g = 0, b = 0; switch (this->rgb_order_) { case ORDER_RGB: r = 0; diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.h b/esphome/components/rp2040_pio_led_strip/led_strip.h index b74dd14108..c499f0a7ca 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.h +++ b/esphome/components/rp2040_pio_led_strip/led_strip.h @@ -95,7 +95,7 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { size_t get_buffer_size_() const { return this->num_leds_ * (3 + this->is_rgbw_); } - static void dma_write_complete_handler_(); + static void dma_write_complete_handler(); uint8_t *buf_{nullptr}; uint8_t *effect_data_{nullptr}; @@ -119,11 +119,11 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { init_fn init_; private: - inline static int num_instance_[2]; - inline static std::map conf_count_; - inline static std::map chipset_offsets_; - inline static bool dma_chan_active_[12]; - inline static struct semaphore dma_write_complete_sem_[12]; + inline static int num_instance[2]; + inline static std::map conf_count; + inline static std::map chipset_offsets; + inline static bool dma_chan_active[12]; + inline static struct semaphore dma_write_complete_sem[12]; }; } // namespace esphome::rp2040_pio_led_strip diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 23b7558564..6faabc223c 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -819,7 +819,7 @@ class WiFiComponent final : public Component { #ifdef USE_RP2 static int s_wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result); - void wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result); + void wifi_scan_result_(void *env, const cyw43_ev_scan_result_t *result); #endif #ifdef USE_LIBRETINY diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 1a70f81a2b..69ac90822f 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -109,10 +109,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { // setup depends on begin() succeeding. beginNoBlock() skips the outer wait loop, saving // up to 20 additional seconds of blocking per attempt. auto ret = WiFi.beginNoBlock(ap.ssid_.c_str(), ap.password_.c_str()); - if (ret == WL_IDLE_STATUS) - return false; - - return true; + return ret != WL_IDLE_STATUS; } bool WiFiComponent::wifi_sta_pre_setup_() { return this->wifi_mode_(true, {}); } @@ -169,11 +166,11 @@ WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() const { } int WiFiComponent::s_wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result) { - global_wifi_component->wifi_scan_result(env, result); + global_wifi_component->wifi_scan_result_(env, result); return 0; } -void WiFiComponent::wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result) { +void WiFiComponent::wifi_scan_result_(void *env, const cyw43_ev_scan_result_t *result) { s_scan_result_count++; // CYW43 scan results have ssid as a 32-byte buffer that is NOT null-terminated. @@ -282,7 +279,7 @@ network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() { // Filter out AP interface addresses — addrList includes all lwIP netifs. // The AP netif IP lingers even after the AP radio is disabled. IPAddress ap_ip = WiFi.softAPIP(); - for (auto addr : addrList) { + for (const auto &addr : addrList) { IPAddress ip(addr.ipFromNetifNum()); if (ip == ap_ip) { continue; @@ -351,12 +348,11 @@ bool WiFiComponent::wifi_loop_() { // Detect IP address changes (only when connected) if (is_connected) { - bool has_ip = false; - // Check for any IP address (IPv4 or IPv6) - for (auto addr : addrList) { - has_ip = true; - break; - } + // Check for any IP address (IPv4 or IPv6). The iterator comparison + // operators take non-const references, so the temporaries need names. + auto addr_it = addrList.begin(); + auto addr_end = addrList.end(); + bool has_ip = addr_it != addr_end; if (has_ip && !s_sta_had_ip) { // Just got IP address diff --git a/esphome/components/wireguard/__init__.py b/esphome/components/wireguard/__init__.py index e128b8476d..ff98cfc966 100644 --- a/esphome/components/wireguard/__init__.py +++ b/esphome/components/wireguard/__init__.py @@ -6,7 +6,17 @@ import esphome.codegen as cg from esphome.components import time from esphome.components.esp32 import CORE, add_idf_sdkconfig_option import esphome.config_validation as cv -from esphome.const import CONF_ADDRESS, CONF_ID, CONF_REBOOT_TIMEOUT, CONF_TIME_ID +from esphome.const import ( + CONF_ADDRESS, + CONF_ID, + CONF_REBOOT_TIMEOUT, + CONF_TIME_ID, + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_LN882X, + PLATFORM_RTL87XX, +) from esphome.core import TimePeriod CONF_NETMASK = "netmask" @@ -57,30 +67,41 @@ def _cidr_network(value): return value -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(Wireguard), - cv.GenerateID(CONF_TIME_ID): cv.use_id(time.RealTimeClock), - cv.Required(CONF_ADDRESS): cv.ipv4address, - cv.Optional(CONF_NETMASK, default="255.255.255.255"): cv.ipv4address, - cv.Required(CONF_PRIVATE_KEY): _wireguard_key, - cv.Required(CONF_PEER_ENDPOINT): cv.string, - cv.Required(CONF_PEER_PUBLIC_KEY): _wireguard_key, - cv.Optional(CONF_PEER_PORT, default=51820): cv.port, - cv.Optional(CONF_PEER_PRESHARED_KEY): _wireguard_key, - cv.Optional(CONF_PEER_ALLOWED_IPS, default=["0.0.0.0/0"]): cv.ensure_list( - _cidr_network - ), - cv.Optional(CONF_PEER_PERSISTENT_KEEPALIVE, default="0s"): cv.All( - cv.positive_time_period_seconds, - cv.Range(max=TimePeriod(seconds=65535)), - ), - cv.Optional( - CONF_REBOOT_TIMEOUT, default="15min" - ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_REQUIRE_CONNECTION_TO_PROCEED, default=False): cv.boolean, - } -).extend(cv.polling_component_schema("10s")) +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(Wireguard), + cv.GenerateID(CONF_TIME_ID): cv.use_id(time.RealTimeClock), + cv.Required(CONF_ADDRESS): cv.ipv4address, + cv.Optional(CONF_NETMASK, default="255.255.255.255"): cv.ipv4address, + cv.Required(CONF_PRIVATE_KEY): _wireguard_key, + cv.Required(CONF_PEER_ENDPOINT): cv.string, + cv.Required(CONF_PEER_PUBLIC_KEY): _wireguard_key, + cv.Optional(CONF_PEER_PORT, default=51820): cv.port, + cv.Optional(CONF_PEER_PRESHARED_KEY): _wireguard_key, + cv.Optional(CONF_PEER_ALLOWED_IPS, default=["0.0.0.0/0"]): cv.ensure_list( + _cidr_network + ), + cv.Optional(CONF_PEER_PERSISTENT_KEEPALIVE, default="0s"): cv.All( + cv.positive_time_period_seconds, + cv.Range(max=TimePeriod(seconds=65535)), + ), + cv.Optional( + CONF_REBOOT_TIMEOUT, default="15min" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_REQUIRE_CONNECTION_TO_PROCEED, default=False): cv.boolean, + } + ).extend(cv.polling_component_schema("10s")), + cv.only_on( + [ + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_BK72XX, + PLATFORM_RTL87XX, + PLATFORM_LN882X, + ] + ), +) async def to_code(config): diff --git a/esphome/components/wled/wled_light_effect.h b/esphome/components/wled/wled_light_effect.h index bed897f5a6..085303e6c0 100644 --- a/esphome/components/wled/wled_light_effect.h +++ b/esphome/components/wled/wled_light_effect.h @@ -8,7 +8,14 @@ #include #include +#ifdef USE_RP2 +namespace arduino { class UDP; +} // namespace arduino +using arduino::UDP; // NOLINT(google-global-names-in-headers) +#else +class UDP; +#endif namespace esphome::wled { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 5c5fc5e8b9..1ecc3dc4a8 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -205,8 +205,11 @@ #define MAX_API_CONNECTIONS 6 #define USE_MD5 #define USE_SHA256 +#ifndef USE_RP2 // no MQTT backend or esp_wireguard library on RP2 #define USE_MQTT #define USE_MQTT_COVER_JSON +#define USE_WIREGUARD +#endif #define USE_RTTTL_FINISHED_PLAYBACK_CALLBACK #define USE_RUNTIME_IMAGE_BMP #define USE_RUNTIME_IMAGE_PNG @@ -219,7 +222,6 @@ #define USE_WIFI #define USE_WIFI_AP #define USE_WIFI_MANUAL_IP -#define USE_WIREGUARD #endif // Arduino-specific feature flags @@ -432,6 +434,11 @@ #ifndef USE_ETHERNET_SPI #define USE_ETHERNET_SPI #endif +#define USE_ETHERNET_W5500 +#define USE_WIFI_IP_STATE_LISTENERS +#define ESPHOME_WIFI_IP_STATE_LISTENERS 2 +#define USE_ETHERNET_IP_STATE_LISTENERS +#define ESPHOME_ETHERNET_IP_STATE_LISTENERS 2 #endif #ifdef USE_LIBRETINY diff --git a/esphome/core/wake/wake_rp2.cpp b/esphome/core/wake/wake_rp2.cpp index 101c87c818..ac1deba726 100644 --- a/esphome/core/wake/wake_rp2.cpp +++ b/esphome/core/wake/wake_rp2.cpp @@ -20,7 +20,7 @@ volatile bool g_main_loop_woke = false; // 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) { +static int64_t alarm_callback(alarm_id_t id, void *user_data) { (void) id; (void) user_data; s_delay_expired = true; @@ -43,7 +43,7 @@ void wakeable_delay(uint32_t ms) { return; } s_delay_expired = false; - alarm_id_t alarm = add_alarm_in_ms(ms, alarm_callback_, nullptr, true); + alarm_id_t alarm = add_alarm_in_ms(ms, alarm_callback, nullptr, true); if (alarm <= 0) { delay(ms); return; diff --git a/platformio.ini b/platformio.ini index 061e92a64a..7e8494aea6 100644 --- a/platformio.ini +++ b/platformio.ini @@ -214,8 +214,19 @@ lib_deps = ${common:idf-component-libs.lib_deps} ayushsharma82/RPAsyncTCP@1.3.2 ; async_tcp ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base + WiFi ; wifi (arduino-pico built-in) + lwIP_CYW43 ; wifi (arduino-pico built-in, WiFi dependency) + HTTPClient ; http_request (arduino-pico built-in) + Updater ; ota (arduino-pico built-in) + MD5Builder ; md5 (arduino-pico built-in) + LEAmDNS ; mdns (arduino-pico built-in) + lwIP_w5500 ; ethernet (arduino-pico built-in) + lwIP-Ethernet ; ethernet (arduino-pico built-in, lwIP_w5500/lwIP_CYW43 dependency) + WebServer ; web_server_base (arduino-pico built-in, ESPAsyncWebServer dependency) + http-parser ; web_server_base (arduino-pico built-in, ESPAsyncWebServer dependency) build_flags = ${common:arduino.build_flags} + -DUSE_RP2 -DUSE_RP2040 -DUSE_RP2040_FRAMEWORK_ARDUINO build_unflags = @@ -510,6 +521,17 @@ build_flags = build_unflags = ${common.build_unflags} +[env:rp2-tidy] +extends = common:rp2040-arduino +; The W variant so the cyw43 / WiFi library paths are part of the idedata. +board = rpipicow +build_flags = + ${common:rp2040-arduino.build_flags} + ${flags:clangtidy.build_flags} + -DPIO_FRAMEWORK_ARDUINO_ENABLE_BLUETOOTH +build_unflags = + ${common.build_unflags} + ;;;;;;;; LibreTiny ;;;;;;;; [env:bk72xx-arduino] diff --git a/script/clang-tidy b/script/clang-tidy index 7df46cb2d2..f463e2455d 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -29,7 +29,7 @@ from helpers import ( ) -def clang_options(idedata): +def clang_options(idedata, environment): cmd = [] # extract target architecture from triplet in g++ filename @@ -95,30 +95,42 @@ def clang_options(idedata): [ # disable built-in include directories from the host "-nostdinc", - # replace pgmspace.h, as it uses GNU extensions clang doesn't support - # https://github.com/earlephilhower/newlib-xtensa/pull/18 - "-D_PGMSPACE_H_", - "-Dpgm_read_byte(s)=(*(const uint8_t *)(s))", - "-Dpgm_read_byte_near(s)=(*(const uint8_t *)(s))", - "-Dpgm_read_word(s)=(*(const uint16_t *)(s))", - "-Dpgm_read_dword(s)=(*(const uint32_t *)(s))", - "-Dpgm_read_ptr(s)=(*(const void *const *)(s))", - "-DPROGMEM=", - "-DPGM_P=const char *", - "-DPSTR(s)=(s)", - # this next one is also needed with upstream pgmspace.h - # suppress warning about identifier naming in expansion of this macro - "-DPSTRN(s, n)=(s)", - # suppress warning about attribute cannot be applied to type - # https://github.com/esp8266/Arduino/pull/8258 - "-Ddeprecated(x)=", # allow to condition code on the presence of clang-tidy "-DCLANG_TIDY", # (esp-idf) Fix __once_callable in some libstdc++ headers "-D_GLIBCXX_HAVE_TLS", + # suppress warning about attribute cannot be applied to type + # https://github.com/esp8266/Arduino/pull/8258 + # also keeps deprecation diagnostics consistent across environments + "-Ddeprecated(x)=", ] ) + if environment.startswith("rp2"): + # clang's ARM backend doesn't know GCC's long_call attribute (IRAM_ATTR) + cmd.append("-Wno-unknown-attributes") + else: + # replace pgmspace.h, as it uses GNU extensions clang doesn't support + # https://github.com/earlephilhower/newlib-xtensa/pull/18 + # arduino-pico ships clang-parseable pgmspace inline functions, so the + # replacements are skipped there (they clash with those definitions). + cmd.extend( + [ + "-D_PGMSPACE_H_", + "-Dpgm_read_byte(s)=(*(const uint8_t *)(s))", + "-Dpgm_read_byte_near(s)=(*(const uint8_t *)(s))", + "-Dpgm_read_word(s)=(*(const uint16_t *)(s))", + "-Dpgm_read_dword(s)=(*(const uint32_t *)(s))", + "-Dpgm_read_ptr(s)=(*(const void *const *)(s))", + "-DPROGMEM=", + "-DPGM_P=const char *", + "-DPSTR(s)=(s)", + # this next one is also needed with upstream pgmspace.h + # suppress warning about identifier naming in expansion of this macro + "-DPSTRN(s, n)=(s)", + ] + ) + # Copy compiler flags, dropping: ones clang doesn't understand; -Werror* # (clang-tidy enforces .clang-tidy's WarningsAsErrors, and a build -Werror # would bypass the -clang-diagnostic-* suppressions); and -std= (the native @@ -207,6 +219,15 @@ def run_tidy(executable, args, options, tmpdir, path_queue, lock, failed_files): if sys.stdout.isatty(): invocation.append("--use-color") + if args.environment.startswith("rp2"): + # MMIO peripheral access on bare-metal RP2 is all fixed-address. + # bugprone-pointer-arithmetic-on-polymorphic-object (and its + # cert-ctr56-cpp alias) crashes clang-tidy 22 with infinite matcher + # recursion on lvgl_esphome.h under the RP2 defines. + invocation.append( + "--checks=-clang-analyzer-core.FixedAddressDereference," + "-bugprone-pointer-arithmetic-on-polymorphic-object,-cert-ctr56-cpp" + ) invocation.append(f"--header-filter={Path(basepath).resolve()}/.*") invocation.append(str(Path(path).resolve())) invocation.append("--") @@ -351,7 +372,7 @@ def main(): # Load idedata and options only if we have files to check idedata = load_idedata(args.environment) - options = clang_options(idedata) + options = clang_options(idedata, args.environment) tmpdir = None if args.fix: From c4975e1870a97dec693f5f398de96b659a25b8b0 Mon Sep 17 00:00:00 2001 From: Hajo Noerenberg Date: Thu, 16 Jul 2026 03:17:39 +0200 Subject: [PATCH 0908/1815] [cc1101] Add FOCCFG and BSCFG config options (#17577) --- esphome/components/cc1101/__init__.py | 76 ++++++++++++++++++++++++++ esphome/components/cc1101/cc1101.cpp | 63 +++++++++++++++++++++ esphome/components/cc1101/cc1101.h | 11 ++++ esphome/components/cc1101/cc1101defs.h | 50 +++++++++++++++++ tests/components/cc1101/common.yaml | 9 +++ 5 files changed, 209 insertions(+) diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index cafd894c54..01e3ed0cd5 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -49,6 +49,15 @@ CONF_FILTER_LENGTH_FSK_MSK = "filter_length_fsk_msk" CONF_FILTER_LENGTH_ASK_OOK = "filter_length_ask_ook" CONF_FREEZE = "freeze" CONF_HYST_LEVEL = "hyst_level" +CONF_FOC_BS_CS_GATE = "foc_bs_cs_gate" +CONF_FOC_LIMIT = "foc_limit" +CONF_FOC_PRE_K = "foc_pre_k" +CONF_FOC_POST_K = "foc_post_k" +CONF_BS_LIMIT = "bs_limit" +CONF_BS_PRE_KI = "bs_pre_ki" +CONF_BS_PRE_KP = "bs_pre_kp" +CONF_BS_POST_KI = "bs_post_ki" +CONF_BS_POST_KP = "bs_post_kp" # Packet mode config keys CONF_PACKET_MODE = "packet_mode" @@ -162,6 +171,64 @@ HYST_LEVEL = { "High": HystLevel.HYST_LEVEL_HIGH, } +FocLimit = ns.enum("FocLimit", True) +FOC_LIMIT = { + "Disabled": FocLimit.FOC_LIMIT_DISABLED, + "BW/8": FocLimit.FOC_LIMIT_BW_8, + "BW/4": FocLimit.FOC_LIMIT_BW_4, + "BW/2": FocLimit.FOC_LIMIT_BW_2, +} + +FocPreK = ns.enum("FocPreK", True) +FOC_PRE_K = { + "K": FocPreK.FOC_PRE_K_K, + "2K": FocPreK.FOC_PRE_K_2K, + "3K": FocPreK.FOC_PRE_K_3K, + "4K": FocPreK.FOC_PRE_K_4K, +} + +FocPostK = ns.enum("FocPostK", True) +FOC_POST_K = { + "Same": FocPostK.FOC_POST_K_SAME, + "K/2": FocPostK.FOC_POST_K_K_2, +} + +BsLimit = ns.enum("BsLimit", True) +BS_LIMIT = { + "Disabled": BsLimit.BS_LIMIT_DISABLED, + "3.125%": BsLimit.BS_LIMIT_3P125_PERCENT, + "6.25%": BsLimit.BS_LIMIT_6P25_PERCENT, + "12.5%": BsLimit.BS_LIMIT_12P5_PERCENT, +} + +BsPreKi = ns.enum("BsPreKi", True) +BS_PRE_KI = { + "KI": BsPreKi.BS_PRE_KI_KI, + "2KI": BsPreKi.BS_PRE_KI_2KI, + "3KI": BsPreKi.BS_PRE_KI_3KI, + "4KI": BsPreKi.BS_PRE_KI_4KI, +} + +BsPreKp = ns.enum("BsPreKp", True) +BS_PRE_KP = { + "KP": BsPreKp.BS_PRE_KP_KP, + "2KP": BsPreKp.BS_PRE_KP_2KP, + "3KP": BsPreKp.BS_PRE_KP_3KP, + "4KP": BsPreKp.BS_PRE_KP_4KP, +} + +BsPostKi = ns.enum("BsPostKi", True) +BS_POST_KI = { + "Same": BsPostKi.BS_POST_KI_SAME, + "KI/2": BsPostKi.BS_POST_KI_KI_2, +} + +BsPostKp = ns.enum("BsPostKp", True) +BS_POST_KP = { + "Same": BsPostKp.BS_POST_KP_SAME, + "KP": BsPostKp.BS_POST_KP_KP, +} + # Optional settings to generate setter calls for CONFIG_MAP = { cv.Optional(CONF_OUTPUT_POWER, default=10): cv.float_range(min=-30.0, max=11.0), @@ -215,6 +282,15 @@ CONFIG_MAP = { cv.Optional(CONF_FREEZE): cv.enum(FREEZE, upper=False), cv.Optional(CONF_WAIT_TIME, default="32"): cv.enum(WAIT_TIME, upper=False), cv.Optional(CONF_HYST_LEVEL): cv.enum(HYST_LEVEL, upper=False), + cv.Optional(CONF_FOC_BS_CS_GATE): cv.boolean, + cv.Optional(CONF_FOC_LIMIT): cv.enum(FOC_LIMIT, upper=False), + cv.Optional(CONF_FOC_PRE_K): cv.enum(FOC_PRE_K, upper=False), + cv.Optional(CONF_FOC_POST_K): cv.enum(FOC_POST_K, upper=False), + cv.Optional(CONF_BS_LIMIT): cv.enum(BS_LIMIT, upper=False), + cv.Optional(CONF_BS_PRE_KI): cv.enum(BS_PRE_KI, upper=False), + cv.Optional(CONF_BS_PRE_KP): cv.enum(BS_PRE_KP, upper=False), + cv.Optional(CONF_BS_POST_KI): cv.enum(BS_POST_KI, upper=False), + cv.Optional(CONF_BS_POST_KP): cv.enum(BS_POST_KP, upper=False), cv.Optional(CONF_PACKET_MODE, default=False): cv.boolean, cv.Optional(CONF_PACKET_LENGTH): cv.uint8_t, cv.Optional(CONF_CRC_ENABLE, default=False): cv.boolean, diff --git a/esphome/components/cc1101/cc1101.cpp b/esphome/components/cc1101/cc1101.cpp index ea0138e1dd..f7b90b91cf 100644 --- a/esphome/components/cc1101/cc1101.cpp +++ b/esphome/components/cc1101/cc1101.cpp @@ -672,6 +672,69 @@ void CC1101Component::set_hyst_level(HystLevel value) { } } +void CC1101Component::set_foc_bs_cs_gate(bool value) { + this->state_.FOC_BS_CS_GATE = value ? 1 : 0; + if (this->initialized_) { + this->write_(Register::FOCCFG); + } +} + +void CC1101Component::set_foc_limit(FocLimit value) { + this->state_.FOC_LIMIT = static_cast(value); + if (this->initialized_) { + this->write_(Register::FOCCFG); + } +} + +void CC1101Component::set_foc_pre_k(FocPreK value) { + this->state_.FOC_PRE_K = static_cast(value); + if (this->initialized_) { + this->write_(Register::FOCCFG); + } +} + +void CC1101Component::set_foc_post_k(FocPostK value) { + this->state_.FOC_POST_K = static_cast(value); + if (this->initialized_) { + this->write_(Register::FOCCFG); + } +} + +void CC1101Component::set_bs_limit(BsLimit value) { + this->state_.BS_LIMIT = static_cast(value); + if (this->initialized_) { + this->write_(Register::BSCFG); + } +} + +void CC1101Component::set_bs_pre_ki(BsPreKi value) { + this->state_.BS_PRE_KI = static_cast(value); + if (this->initialized_) { + this->write_(Register::BSCFG); + } +} + +void CC1101Component::set_bs_pre_kp(BsPreKp value) { + this->state_.BS_PRE_KP = static_cast(value); + if (this->initialized_) { + this->write_(Register::BSCFG); + } +} + +void CC1101Component::set_bs_post_ki(BsPostKi value) { + this->state_.BS_POST_KI = static_cast(value); + if (this->initialized_) { + this->write_(Register::BSCFG); + } +} + +void CC1101Component::set_bs_post_kp(BsPostKp value) { + this->state_.BS_POST_KP = static_cast(value); + if (this->initialized_) { + this->write_(Register::BSCFG); + } +} + void CC1101Component::set_packet_mode(bool value) { this->state_.PKT_FORMAT = static_cast(value ? PacketFormat::PACKET_FORMAT_FIFO : PacketFormat::PACKET_FORMAT_ASYNC_SERIAL); diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h index 065ffd5250..79bfc9cb33 100644 --- a/esphome/components/cc1101/cc1101.h +++ b/esphome/components/cc1101/cc1101.h @@ -71,6 +71,17 @@ class CC1101Component final : public Component, void set_wait_time(WaitTime value); void set_hyst_level(HystLevel value); + // Frequency offset compensation and bit synchronization settings + void set_foc_bs_cs_gate(bool value); + void set_foc_limit(FocLimit value); + void set_foc_pre_k(FocPreK value); + void set_foc_post_k(FocPostK value); + void set_bs_limit(BsLimit value); + void set_bs_pre_ki(BsPreKi value); + void set_bs_pre_kp(BsPreKp value); + void set_bs_post_ki(BsPostKi value); + void set_bs_post_kp(BsPostKp value); + // Packet mode settings void set_packet_mode(bool value); void set_packet_length(uint8_t value); diff --git a/esphome/components/cc1101/cc1101defs.h b/esphome/components/cc1101/cc1101defs.h index 59b29f7478..6748f4369a 100644 --- a/esphome/components/cc1101/cc1101defs.h +++ b/esphome/components/cc1101/cc1101defs.h @@ -231,6 +231,56 @@ enum class HystLevel : uint8_t { HYST_LEVEL_HIGH, }; +enum class FocLimit : uint8_t { + FOC_LIMIT_DISABLED, + FOC_LIMIT_BW_8, + FOC_LIMIT_BW_4, + FOC_LIMIT_BW_2, +}; + +enum class FocPreK : uint8_t { + FOC_PRE_K_K, + FOC_PRE_K_2K, + FOC_PRE_K_3K, + FOC_PRE_K_4K, +}; + +enum class FocPostK : uint8_t { + FOC_POST_K_SAME, + FOC_POST_K_K_2, +}; + +enum class BsLimit : uint8_t { + BS_LIMIT_DISABLED, + BS_LIMIT_3P125_PERCENT, + BS_LIMIT_6P25_PERCENT, + BS_LIMIT_12P5_PERCENT, +}; + +enum class BsPreKi : uint8_t { + BS_PRE_KI_KI, + BS_PRE_KI_2KI, + BS_PRE_KI_3KI, + BS_PRE_KI_4KI, +}; + +enum class BsPreKp : uint8_t { + BS_PRE_KP_KP, + BS_PRE_KP_2KP, + BS_PRE_KP_3KP, + BS_PRE_KP_4KP, +}; + +enum class BsPostKi : uint8_t { + BS_POST_KI_SAME, + BS_POST_KI_KI_2, +}; + +enum class BsPostKp : uint8_t { + BS_POST_KP_SAME, + BS_POST_KP_KP, +}; + enum class PacketFormat : uint8_t { PACKET_FORMAT_FIFO, PACKET_FORMAT_SYNC_SERIAL, diff --git a/tests/components/cc1101/common.yaml b/tests/components/cc1101/common.yaml index 9784bfce8b..4d2411e021 100644 --- a/tests/components/cc1101/common.yaml +++ b/tests/components/cc1101/common.yaml @@ -17,6 +17,15 @@ cc1101: sync0: 0x91 sync1: 0xD3 num_preamble: 2 + foc_bs_cs_gate: true + foc_pre_k: "2K" + foc_post_k: "K/2" + foc_limit: "BW/4" + bs_pre_ki: "3KI" + bs_pre_kp: "4KP" + bs_post_ki: "KI/2" + bs_post_kp: "KP" + bs_limit: "12.5%" on_packet: then: - lambda: |- From dedca344f980d098442a05084773edb9b36d935a Mon Sep 17 00:00:00 2001 From: Jas Strong Date: Wed, 15 Jul 2026 18:17:58 -0700 Subject: [PATCH 0909/1815] [aqi] Add extended_range option for over-range AQI values (#17570) Co-authored-by: jas Co-authored-by: Claude Opus 4.8 (1M context) --- esphome/components/aqi/__init__.py | 1 + .../components/aqi/abstract_aqi_calculator.h | 2 +- esphome/components/aqi/aqi_calculator.h | 30 ++++--- esphome/components/aqi/aqi_sensor.cpp | 3 +- esphome/components/aqi/aqi_sensor.h | 2 + esphome/components/aqi/caqi_calculator.h | 23 ++--- esphome/components/aqi/sensor.py | 20 ++++- esphome/components/hm3301/hm3301.cpp | 2 +- tests/component_tests/aqi/__init__.py | 0 tests/component_tests/aqi/test_aqi.py | 35 ++++++++ tests/components/aqi/benchmark.yaml | 16 ++++ tests/components/aqi/common.yaml | 7 ++ tests/components/aqi/test_aqi_calculator.cpp | 85 +++++++++++++++++++ 13 files changed, 200 insertions(+), 26 deletions(-) create mode 100644 tests/component_tests/aqi/__init__.py create mode 100644 tests/component_tests/aqi/test_aqi.py create mode 100644 tests/components/aqi/benchmark.yaml create mode 100644 tests/components/aqi/test_aqi_calculator.cpp diff --git a/esphome/components/aqi/__init__.py b/esphome/components/aqi/__init__.py index 4b979ab406..17d434294a 100644 --- a/esphome/components/aqi/__init__.py +++ b/esphome/components/aqi/__init__.py @@ -7,6 +7,7 @@ AQICalculatorType = aqi_ns.enum("AQICalculatorType") CONF_AQI = "aqi" CONF_CALCULATION_TYPE = "calculation_type" +CONF_EXTENDED_RANGE = "extended_range" AQI_CALCULATION_TYPE = { "CAQI": AQICalculatorType.CAQI_TYPE, diff --git a/esphome/components/aqi/abstract_aqi_calculator.h b/esphome/components/aqi/abstract_aqi_calculator.h index 299962fa17..6b4c9c5e04 100644 --- a/esphome/components/aqi/abstract_aqi_calculator.h +++ b/esphome/components/aqi/abstract_aqi_calculator.h @@ -6,7 +6,7 @@ namespace esphome::aqi { class AbstractAQICalculator { public: - virtual uint16_t get_aqi(float pm2_5_value, float pm10_0_value) = 0; + virtual uint16_t get_aqi(float pm2_5_value, float pm10_0_value, bool extended_range) = 0; }; } // namespace esphome::aqi diff --git a/esphome/components/aqi/aqi_calculator.h b/esphome/components/aqi/aqi_calculator.h index bb8e402280..56b6069118 100644 --- a/esphome/components/aqi/aqi_calculator.h +++ b/esphome/components/aqi/aqi_calculator.h @@ -11,10 +11,12 @@ namespace esphome::aqi { class AQICalculator : public AbstractAQICalculator { public: - uint16_t get_aqi(float pm2_5_value, float pm10_0_value) override { - float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID); - float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID); + uint16_t get_aqi(float pm2_5_value, float pm10_0_value, bool extended_range) override { + float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID, extended_range); + float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID, extended_range); float aqi = std::max({pm2_5_index, pm10_0_index, 0.0f}); + // extended_range lets the index run past the standard maximum, so clamp to the sensor's range. + aqi = std::min(aqi, static_cast(std::numeric_limits::max())); return static_cast(std::lround(aqi)); } @@ -30,7 +32,7 @@ class AQICalculator : public AbstractAQICalculator { {35.5f, 55.5f}, {55.5f, 125.5f}, {125.5f, 225.5f}, - {225.5f, std::numeric_limits::max()} + {225.5f, 500.4f} // EPA 2024: AQI 301-500 maps to PM2.5 225.5-500.4 ug/m3 // clang-format on }; @@ -41,11 +43,11 @@ class AQICalculator : public AbstractAQICalculator { {155.0f, 255.0f}, {255.0f, 355.0f}, {355.0f, 425.0f}, - {425.0f, std::numeric_limits::max()} + {425.0f, 604.0f} // EPA: AQI 301-500 maps to PM10 425-604 ug/m3 (top of the 401-500 band) // clang-format on }; - static float calculate_index(float value, const float array[NUM_LEVELS][2]) { + static float calculate_index(float value, const float array[NUM_LEVELS][2], bool extended_range) { int grid_index = get_grid_index(value, array); if (grid_index == -1) { return -1.0f; @@ -55,14 +57,22 @@ class AQICalculator : public AbstractAQICalculator { float conc_lo = array[grid_index][0]; float conc_hi = array[grid_index][1]; - return (value - conc_lo) * (aqi_hi - aqi_lo) / (conc_hi - conc_lo) + aqi_lo; + float index = (value - conc_lo) * (aqi_hi - aqi_lo) / (conc_hi - conc_lo) + aqi_lo; + + // Concentrations above the highest breakpoint run the linear fit past aqi_hi. By default we + // clamp to the standard maximum; with extended_range we keep the extrapolated "over-range" + // value so heavy pollution reports numbers beyond what the standard defines. + if (grid_index == NUM_LEVELS - 1 && !extended_range && index > aqi_hi) { + return aqi_hi; + } + return index; } static int get_grid_index(float value, const float array[NUM_LEVELS][2]) { for (int i = 0; i < NUM_LEVELS; i++) { - const bool in_range = - (value >= array[i][0]) && ((i == NUM_LEVELS - 1) ? (value <= array[i][1]) // last bucket inclusive - : (value < array[i][1])); // others exclusive on hi + // The top band is open-ended: any value at or above its lower breakpoint falls into it, + // and calculate_index() decides whether to clamp or extrapolate. + const bool in_range = (value >= array[i][0]) && (i == NUM_LEVELS - 1 || value < array[i][1]); if (in_range) { return i; } diff --git a/esphome/components/aqi/aqi_sensor.cpp b/esphome/components/aqi/aqi_sensor.cpp index 2d8a780cc7..4bb964d5ee 100644 --- a/esphome/components/aqi/aqi_sensor.cpp +++ b/esphome/components/aqi/aqi_sensor.cpp @@ -24,6 +24,7 @@ void AQISensor::setup() { void AQISensor::dump_config() { ESP_LOGCONFIG(TAG, "AQI Sensor:"); ESP_LOGCONFIG(TAG, " Calculation Type: %s", this->aqi_calc_type_ == AQI_TYPE ? "AQI" : "CAQI"); + ESP_LOGCONFIG(TAG, " Extended Range: %s", this->extended_range_ ? "enabled" : "disabled"); if (this->pm_2_5_sensor_ != nullptr) { ESP_LOGCONFIG(TAG, " PM2.5 Sensor: '%s'", this->pm_2_5_sensor_->get_name().c_str()); } @@ -44,7 +45,7 @@ void AQISensor::calculate_aqi_() { return; } - uint16_t aqi = calculator->get_aqi(this->pm_2_5_value_, this->pm_10_0_value_); + uint16_t aqi = calculator->get_aqi(this->pm_2_5_value_, this->pm_10_0_value_, this->extended_range_); this->publish_state(aqi); } diff --git a/esphome/components/aqi/aqi_sensor.h b/esphome/components/aqi/aqi_sensor.h index aa64fa5a4d..464c088188 100644 --- a/esphome/components/aqi/aqi_sensor.h +++ b/esphome/components/aqi/aqi_sensor.h @@ -14,6 +14,7 @@ class AQISensor final : public sensor::Sensor, public Component { void set_pm_2_5_sensor(sensor::Sensor *sensor) { this->pm_2_5_sensor_ = sensor; } void set_pm_10_0_sensor(sensor::Sensor *sensor) { this->pm_10_0_sensor_ = sensor; } void set_aqi_calculation_type(AQICalculatorType type) { this->aqi_calc_type_ = type; } + void set_extended_range(bool extended_range) { this->extended_range_ = extended_range; } protected: void calculate_aqi_(); @@ -21,6 +22,7 @@ class AQISensor final : public sensor::Sensor, public Component { sensor::Sensor *pm_2_5_sensor_{nullptr}; sensor::Sensor *pm_10_0_sensor_{nullptr}; AQICalculatorType aqi_calc_type_{AQI_TYPE}; + bool extended_range_{false}; AQICalculatorFactory aqi_calculator_factory_; float pm_2_5_value_{NAN}; diff --git a/esphome/components/aqi/caqi_calculator.h b/esphome/components/aqi/caqi_calculator.h index 3f6da45aa9..56a98682d9 100644 --- a/esphome/components/aqi/caqi_calculator.h +++ b/esphome/components/aqi/caqi_calculator.h @@ -9,25 +9,28 @@ namespace esphome::aqi { class CAQICalculator : public AbstractAQICalculator { public: - uint16_t get_aqi(float pm2_5_value, float pm10_0_value) override { + // The CAQI (CITEAIR) scale defines no maximum: its top "Very high" class is simply ">100". We + // therefore always extrapolate the top band past 100 without limit, so the extended_range flag + // (which lifts the AQI calculator's fixed 500 cap) has no meaning here and is ignored. + uint16_t get_aqi(float pm2_5_value, float pm10_0_value, bool /*extended_range*/) override { float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID); float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID); float aqi = std::max({pm2_5_index, pm10_0_index, 0.0f}); + aqi = std::min(aqi, static_cast(std::numeric_limits::max())); return static_cast(std::lround(aqi)); } protected: - static constexpr int NUM_LEVELS = 5; + static constexpr int NUM_LEVELS = 4; - static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 25}, {26, 50}, {51, 75}, {76, 100}, {101, 400}}; + static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 25}, {26, 50}, {51, 75}, {76, 100}}; static constexpr float PM2_5_GRID[NUM_LEVELS][2] = { // clang-format off {0.0f, 15.1f}, {15.1f, 30.1f}, {30.1f, 55.1f}, - {55.1f, 110.1f}, - {110.1f, std::numeric_limits::max()} + {55.1f, 110.1f} // clang-format on }; @@ -36,8 +39,7 @@ class CAQICalculator : public AbstractAQICalculator { {0.0f, 25.1f}, {25.1f, 50.1f}, {50.1f, 90.1f}, - {90.1f, 180.1f}, - {180.1f, std::numeric_limits::max()} + {90.1f, 180.1f} // clang-format on }; @@ -52,14 +54,15 @@ class CAQICalculator : public AbstractAQICalculator { float conc_lo = array[grid_index][0]; float conc_hi = array[grid_index][1]; + // The top band is open-ended (see get_grid_index), so for concentrations above the last + // breakpoint this linear fit extrapolates past 100 unbounded, matching CAQI's open ">100" class. return (value - conc_lo) * (aqi_hi - aqi_lo) / (conc_hi - conc_lo) + aqi_lo; } static int get_grid_index(float value, const float array[NUM_LEVELS][2]) { for (int i = 0; i < NUM_LEVELS; i++) { - const bool in_range = - (value >= array[i][0]) && ((i == NUM_LEVELS - 1) ? (value <= array[i][1]) // last bucket inclusive - : (value < array[i][1])); // others exclusive on hi + // The top band is open-ended: any value at or above its lower breakpoint falls into it. + const bool in_range = (value >= array[i][0]) && (i == NUM_LEVELS - 1 || value < array[i][1]); if (in_range) { return i; } diff --git a/esphome/components/aqi/sensor.py b/esphome/components/aqi/sensor.py index 5842aea88c..9c361560df 100644 --- a/esphome/components/aqi/sensor.py +++ b/esphome/components/aqi/sensor.py @@ -8,14 +8,25 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, ) -from . import AQI_CALCULATION_TYPE, CONF_CALCULATION_TYPE, aqi_ns +from . import AQI_CALCULATION_TYPE, CONF_CALCULATION_TYPE, CONF_EXTENDED_RANGE, aqi_ns CODEOWNERS = ["@jasstrong"] DEPENDENCIES = ["sensor"] AQISensor = aqi_ns.class_("AQISensor", sensor.Sensor, cg.Component) -CONFIG_SCHEMA = ( + +def _validate_extended_range(config): + if CONF_EXTENDED_RANGE in config and config[CONF_CALCULATION_TYPE] == "CAQI": + raise cv.Invalid( + f"'{CONF_EXTENDED_RANGE}' is not supported with 'calculation_type: CAQI'. " + "CAQI has no maximum value by specification, so it is always reported unbounded.", + [CONF_EXTENDED_RANGE], + ) + return config + + +CONFIG_SCHEMA = cv.All( sensor.sensor_schema( AQISensor, accuracy_decimals=0, @@ -29,9 +40,11 @@ CONFIG_SCHEMA = ( cv.Required(CONF_CALCULATION_TYPE): cv.enum( AQI_CALCULATION_TYPE, upper=True ), + cv.Optional(CONF_EXTENDED_RANGE): cv.boolean, } ) - .extend(cv.COMPONENT_SCHEMA) + .extend(cv.COMPONENT_SCHEMA), + _validate_extended_range, ) @@ -46,3 +59,4 @@ async def to_code(config): cg.add(var.set_pm_10_0_sensor(pm_10_0_sensor)) cg.add(var.set_aqi_calculation_type(config[CONF_CALCULATION_TYPE])) + cg.add(var.set_extended_range(config.get(CONF_EXTENDED_RANGE, False))) diff --git a/esphome/components/hm3301/hm3301.cpp b/esphome/components/hm3301/hm3301.cpp index f46a6b8580..02c6e75146 100644 --- a/esphome/components/hm3301/hm3301.cpp +++ b/esphome/components/hm3301/hm3301.cpp @@ -61,7 +61,7 @@ void HM3301Component::update() { int16_t aqi_value = -1; if (this->aqi_sensor_ != nullptr && pm_2_5_value != -1 && pm_10_0_value != -1) { aqi::AbstractAQICalculator *calculator = this->aqi_calculator_factory_.get_calculator(this->aqi_calc_type_); - aqi_value = calculator->get_aqi(pm_2_5_value, pm_10_0_value); + aqi_value = calculator->get_aqi(pm_2_5_value, pm_10_0_value, /*extended_range=*/false); } if (pm_1_0_value != -1) { diff --git a/tests/component_tests/aqi/__init__.py b/tests/component_tests/aqi/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/aqi/test_aqi.py b/tests/component_tests/aqi/test_aqi.py new file mode 100644 index 0000000000..712c277508 --- /dev/null +++ b/tests/component_tests/aqi/test_aqi.py @@ -0,0 +1,35 @@ +"""Config-validation tests for the aqi sensor component.""" + +import pytest +from voluptuous import Invalid + +from esphome.components.aqi import CONF_CALCULATION_TYPE, CONF_EXTENDED_RANGE +from esphome.components.aqi.sensor import _validate_extended_range + + +def test_extended_range_rejected_with_caqi(): + """extended_range has no meaning for CAQI (no spec maximum) and must be rejected.""" + with pytest.raises(Invalid, match="CAQI"): + _validate_extended_range( + {CONF_CALCULATION_TYPE: "CAQI", CONF_EXTENDED_RANGE: True} + ) + + +def test_extended_range_rejected_with_caqi_even_when_false(): + """The option is not allowed at all with CAQI, regardless of its value.""" + with pytest.raises(Invalid, match="CAQI"): + _validate_extended_range( + {CONF_CALCULATION_TYPE: "CAQI", CONF_EXTENDED_RANGE: False} + ) + + +def test_extended_range_allowed_with_aqi(): + """extended_range is valid for the US AQI calculation.""" + config = {CONF_CALCULATION_TYPE: "AQI", CONF_EXTENDED_RANGE: True} + assert _validate_extended_range(config) is config + + +def test_caqi_without_extended_range_ok(): + """CAQI is fine as long as extended_range is not set.""" + config = {CONF_CALCULATION_TYPE: "CAQI"} + assert _validate_extended_range(config) is config diff --git a/tests/components/aqi/benchmark.yaml b/tests/components/aqi/benchmark.yaml new file mode 100644 index 0000000000..d0d54c50b0 --- /dev/null +++ b/tests/components/aqi/benchmark.yaml @@ -0,0 +1,16 @@ +# Declares the component graph the C++ unit test build needs so that the aqi +# component's sources (which include sensor.h) compile. to_code is suppressed by +# the test harness; this only pulls the sensor + aqi source/include paths in. +# Loaded with plain yaml.safe_load, so avoid lambdas / ESPHome-tagged values here. +sensor: + - platform: template + id: pm25_sensor + name: "PM2.5" + - platform: template + id: pm10_sensor + name: "PM10" + - platform: aqi + name: "AQI" + pm_2_5: pm25_sensor + pm_10_0: pm10_sensor + calculation_type: AQI diff --git a/tests/components/aqi/common.yaml b/tests/components/aqi/common.yaml index 4c8cbbfa3f..cddc1f77cd 100644 --- a/tests/components/aqi/common.yaml +++ b/tests/components/aqi/common.yaml @@ -20,3 +20,10 @@ sensor: pm_2_5: pm25_sensor pm_10_0: pm10_sensor calculation_type: CAQI + + - platform: aqi + name: "Air Quality Index (AQI, extended)" + pm_2_5: pm25_sensor + pm_10_0: pm10_sensor + calculation_type: AQI + extended_range: true diff --git a/tests/components/aqi/test_aqi_calculator.cpp b/tests/components/aqi/test_aqi_calculator.cpp new file mode 100644 index 0000000000..ab95d3ac92 --- /dev/null +++ b/tests/components/aqi/test_aqi_calculator.cpp @@ -0,0 +1,85 @@ +#include + +#include "esphome/components/aqi/aqi_calculator.h" +#include "esphome/components/aqi/caqi_calculator.h" + +namespace esphome::aqi::testing { + +// US AQI (EPA 2024): PM2.5 225.5-500.4 -> 301-500, PM10 425-604 -> 301-500. + +TEST(USAQI, LowRangeUnaffectedByExtendedFlag) { + AQICalculator calc; + // PM2.5 25 drives over PM10 50; well below the top band, so the flag changes nothing. + EXPECT_EQ(calc.get_aqi(25.0f, 50.0f, false), 81); + EXPECT_EQ(calc.get_aqi(25.0f, 50.0f, true), 81); +} + +TEST(USAQI, HazardousInterpolatesNotPinnedAt301) { + AQICalculator calc; + // Regression guard: the old FLT_MAX top bucket collapsed every hazardous reading to 301. + EXPECT_EQ(calc.get_aqi(225.5f, 0.0f, false), 301); // band start + EXPECT_EQ(calc.get_aqi(250.0f, 0.0f, false), 319); // interpolated, not 301 + EXPECT_EQ(calc.get_aqi(500.4f, 0.0f, false), 500); // band top +} + +TEST(USAQI, DefaultClampsAtStandardMaximum) { + AQICalculator calc; + EXPECT_EQ(calc.get_aqi(600.0f, 0.0f, false), 500); + EXPECT_EQ(calc.get_aqi(1000.0f, 0.0f, false), 500); + EXPECT_EQ(calc.get_aqi(0.0f, 604.0f, false), 500); // PM10 top breakpoint +} + +TEST(USAQI, ExtendedRangeExtrapolatesBeyond500) { + AQICalculator calc; + EXPECT_EQ(calc.get_aqi(600.0f, 0.0f, true), 572); + EXPECT_EQ(calc.get_aqi(1000.0f, 0.0f, true), 862); + EXPECT_EQ(calc.get_aqi(0.0f, 700.0f, true), 607); // PM10 extrapolated past 500 +} + +TEST(USAQI, ExtendedRangeSaturatesUint16NoWraparound) { + AQICalculator calc; + // An absurd concentration would overflow uint16_t; it must saturate, not wrap to a small value. + EXPECT_EQ(calc.get_aqi(100000.0f, 0.0f, true), 65535); +} + +TEST(USAQI, WorseOfTwoPollutantsWins) { + AQICalculator calc; + // PM10 604 -> 500 dominates PM2.5 25 -> 81. + EXPECT_EQ(calc.get_aqi(25.0f, 604.0f, false), 500); +} + +// CAQI (CITEAIR): no maximum by spec -- the top ">100" class is open, so it is always unbounded +// and the extended_range flag does not apply. + +TEST(CAQI, LowRange) { + CAQICalculator calc; + EXPECT_EQ(calc.get_aqi(25.0f, 50.0f, false), 50); +} + +TEST(CAQI, ContinuousAt100NoPinAt101) { + CAQICalculator calc; + // Old code pinned everything above the top breakpoint to 101; now it reaches exactly 100. + EXPECT_EQ(calc.get_aqi(110.1f, 0.0f, false), 100); +} + +TEST(CAQI, UnboundedAboveTopBand) { + CAQICalculator calc; + EXPECT_EQ(calc.get_aqi(200.0f, 0.0f, false), 139); + EXPECT_EQ(calc.get_aqi(2000.0f, 0.0f, false), 925); +} + +TEST(CAQI, ExtendedRangeFlagIsIgnored) { + CAQICalculator calc; + // CAQI is always unbounded, so the flag must make no difference either way. + EXPECT_EQ(calc.get_aqi(200.0f, 0.0f, true), calc.get_aqi(200.0f, 0.0f, false)); + EXPECT_EQ(calc.get_aqi(2000.0f, 0.0f, true), calc.get_aqi(2000.0f, 0.0f, false)); +} + +TEST(CAQI, SaturatesUint16NoWraparound) { + CAQICalculator calc; + // CAQI is unbounded, so an extreme reading can extrapolate past uint16_t; it must saturate, + // not wrap around to a small (falsely "good") value. + EXPECT_EQ(calc.get_aqi(200000.0f, 0.0f, false), 65535); +} + +} // namespace esphome::aqi::testing From 3439a08b68d34c9f3d78bfae04333c68380ad485 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:19:41 +1200 Subject: [PATCH 0910/1815] [deep_sleep] Add on_wake automation triggers (#17569) --- esphome/components/deep_sleep/__init__.py | 56 +++++++++++++++--- .../deep_sleep/deep_sleep_bk72xx.cpp | 15 +++++ .../deep_sleep/deep_sleep_component.h | 59 +++++++++++++++++++ .../deep_sleep/deep_sleep_esp32.cpp | 19 ++++++ .../deep_sleep/deep_sleep_esp8266.cpp | 17 ++++++ esphome/core/defines.h | 1 + .../deep_sleep/test_deep_sleep.py | 37 ++++++++++++ .../deep_sleep/test_deep_sleep3.yaml | 23 ++++++++ .../deep_sleep/common-esp32-all.yaml | 8 ++- .../deep_sleep/common-esp32-ext1.yaml | 9 ++- tests/components/deep_sleep/common-esp32.yaml | 4 ++ .../deep_sleep/test.bk72xx-ard.yaml | 4 ++ .../deep_sleep/test.esp8266-ard.yaml | 4 ++ 13 files changed, 247 insertions(+), 9 deletions(-) create mode 100644 tests/component_tests/deep_sleep/test_deep_sleep3.yaml diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 9666c8e507..83eff496ac 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -30,6 +30,7 @@ from esphome.const import ( CONF_SECOND, CONF_SLEEP_DURATION, CONF_TIME_ID, + CONF_TRIGGER_ID, CONF_WAKEUP_PIN, PLATFORM_BK72XX, PLATFORM_ESP32, @@ -234,6 +235,15 @@ EXT1_WAKEUP_MODES = { } WakeupCauseToRunDuration = deep_sleep_ns.struct("WakeupCauseToRunDuration") +WakeupCause = deep_sleep_ns.enum("WakeupCause") +WakeTrigger = deep_sleep_ns.class_( + "WakeTrigger", automation.Trigger.template(WakeupCause), cg.Component +) +Ext1WakeTrigger = deep_sleep_ns.class_( + "Ext1WakeTrigger", automation.Trigger.template(), cg.Component +) + +CONF_ON_WAKE = "on_wake" CONF_WAKEUP_PIN_MODE = "wakeup_pin_mode" CONF_ESP32_EXT1_WAKEUP = "esp32_ext1_wakeup" CONF_TOUCH_WAKEUP = "touch_wakeup" @@ -256,6 +266,22 @@ WAKEUP_PIN_SCHEMA = cv.Schema( } ) +EXT1_WAKEUP_PIN_SCHEMA = cv.Schema( + { + cv.Required(CONF_PIN): cv.All( + pins.internal_gpio_input_pin_schema, validate_pin_number_esp32 + ), + cv.Optional(CONF_ON_WAKE): automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(Ext1WakeTrigger)} + ), + } +) + +# Entries that are not in the {pin: ..., on_wake: ...} form are treated as a +# bare pin config (the original syntax, e.g. a plain "GPIO5" or {number: 5}). +validate_ext1_wakeup_pin = cv.maybe_simple_value(EXT1_WAKEUP_PIN_SCHEMA, key=CONF_PIN) + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -282,8 +308,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.Required(CONF_PINS): cv.ensure_list( - pins.internal_gpio_input_pin_schema, - validate_pin_number_esp32, + validate_ext1_wakeup_pin, ), cv.Required(CONF_MODE): cv.All( cv.enum(EXT1_WAKEUP_MODES, upper=True), @@ -292,6 +317,12 @@ CONFIG_SCHEMA = cv.All( } ), ), + cv.Optional(CONF_ON_WAKE): cv.All( + cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX]), + automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(WakeTrigger)} + ), + ), cv.Optional(CONF_TOUCH_WAKEUP): cv.All( cv.only_on_esp32, esp32.only_on_variant( @@ -362,16 +393,27 @@ async def to_code(config): ) cg.add(var.set_run_duration(wakeup_cause_to_run_duration)) - if CONF_ESP32_EXT1_WAKEUP in config: - conf = config[CONF_ESP32_EXT1_WAKEUP] + if (ext1_conf := config.get(CONF_ESP32_EXT1_WAKEUP)) is not None: mask = 0 - for pin in conf[CONF_PINS]: - mask |= 1 << pin[CONF_NUMBER] + for pin_conf in ext1_conf[CONF_PINS]: + number = pin_conf[CONF_PIN][CONF_NUMBER] + mask |= 1 << number + for wake_conf in pin_conf.get(CONF_ON_WAKE, []): + trigger = cg.new_Pvariable(wake_conf[CONF_TRIGGER_ID], number) + await cg.register_component(trigger, wake_conf) + await automation.build_automation(trigger, [], wake_conf) + cg.add_define("USE_DEEP_SLEEP_ON_WAKE") struct = cg.StructInitializer( - Ext1Wakeup, ("mask", mask), ("wakeup_mode", conf[CONF_MODE]) + Ext1Wakeup, ("mask", mask), ("wakeup_mode", ext1_conf[CONF_MODE]) ) cg.add(var.set_ext1_wakeup(struct)) + for wake_conf in config.get(CONF_ON_WAKE, []): + trigger = cg.new_Pvariable(wake_conf[CONF_TRIGGER_ID]) + await cg.register_component(trigger, wake_conf) + await automation.build_automation(trigger, [(WakeupCause, "cause")], wake_conf) + cg.add_define("USE_DEEP_SLEEP_ON_WAKE") + if CONF_TOUCH_WAKEUP in config: cg.add(var.set_touch_wakeup(config[CONF_TOUCH_WAKEUP])) if CORE.using_zephyr and "zigbee" not in CORE.loaded_integrations: diff --git a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp index 8dca32689b..5595b0ba89 100644 --- a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp +++ b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp @@ -7,6 +7,21 @@ namespace esphome::deep_sleep { static const char *const TAG = "deep_sleep.bk72xx"; +#ifdef USE_DEEP_SLEEP_ON_WAKE +WakeupCause get_wakeup_cause() { + switch (lt_get_reboot_reason()) { + case REBOOT_REASON_SLEEP_GPIO: + return WAKEUP_CAUSE_GPIO; + case REBOOT_REASON_SLEEP_RTC: + return WAKEUP_CAUSE_TIMER; + case REBOOT_REASON_SLEEP_USB: + return WAKEUP_CAUSE_UNKNOWN; + default: + return WAKEUP_CAUSE_NONE; + } +} +#endif // USE_DEEP_SLEEP_ON_WAKE + optional DeepSleepComponent::get_run_duration_() const { return this->run_duration_; } void DeepSleepComponent::dump_config_platform_() { diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 896ed092aa..05e18f8c38 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -60,6 +60,65 @@ struct WakeupCauseToRunDuration { #endif // USE_ESP32 +#ifdef USE_DEEP_SLEEP_ON_WAKE + +/// Why the device woke from deep sleep. Passed to on_wake automations. +enum WakeupCause : uint8_t { + /// The device did not wake from deep sleep (for example a cold boot, reset or OTA restart). + WAKEUP_CAUSE_NONE = 0, + /// The device woke from deep sleep, but the source could not be identified. + WAKEUP_CAUSE_UNKNOWN, + /// The device was woken by the sleep timer. + WAKEUP_CAUSE_TIMER, + /// The device was woken by a GPIO pin (wakeup_pin or esp32_ext1_wakeup). + WAKEUP_CAUSE_GPIO, + /// The device was woken by a touch pad. + WAKEUP_CAUSE_TOUCH, +}; + +/// Return why the device woke from deep sleep. Implemented per platform. +WakeupCause get_wakeup_cause(); + +/** Setup priority of on_wake triggers. + * + * Between restoring global variables (setup_priority::HARDWARE, 800) and on_boot automations at + * their default priority (600), so on_wake automations can update state (e.g. globals) that + * on_boot automations then use. + */ +inline constexpr float ON_WAKE_TRIGGER_SETUP_PRIORITY = 700.0f; + +/// Fires once on boot when the device woke from deep sleep, with the wakeup cause. +class WakeTrigger : public Trigger, public Component { + public: + void setup() override { + const WakeupCause cause = get_wakeup_cause(); + if (cause != WAKEUP_CAUSE_NONE) { + this->trigger(cause); + } + } + float get_setup_priority() const override { return ON_WAKE_TRIGGER_SETUP_PRIORITY; } +}; + +#if defined(USE_ESP32) && !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) +/// Fires once on boot when the device was woken from deep sleep by the given ext1 pin. +class Ext1WakeTrigger : public Trigger<>, public Component { + public: + explicit Ext1WakeTrigger(uint8_t pin) : pin_(pin) {} + void setup() override { + if (esp_sleep_get_wakeup_cause() == ESP_SLEEP_WAKEUP_EXT1 && + (esp_sleep_get_ext1_wakeup_status() & (1ULL << this->pin_))) { + this->trigger(); + } + } + float get_setup_priority() const override { return ON_WAKE_TRIGGER_SETUP_PRIORITY; } + + protected: + uint8_t pin_; +}; +#endif + +#endif // USE_DEEP_SLEEP_ON_WAKE + template class EnterDeepSleepAction; template class PreventDeepSleepAction; diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index 7cb8e53efd..f64e1f37e1 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -30,6 +30,25 @@ namespace esphome::deep_sleep { static const char *const TAG = "deep_sleep"; +#ifdef USE_DEEP_SLEEP_ON_WAKE +WakeupCause get_wakeup_cause() { + switch (esp_sleep_get_wakeup_cause()) { + case ESP_SLEEP_WAKEUP_EXT0: + case ESP_SLEEP_WAKEUP_EXT1: + case ESP_SLEEP_WAKEUP_GPIO: + return WAKEUP_CAUSE_GPIO; + case ESP_SLEEP_WAKEUP_TIMER: + return WAKEUP_CAUSE_TIMER; + case ESP_SLEEP_WAKEUP_TOUCHPAD: + return WAKEUP_CAUSE_TOUCH; + case ESP_SLEEP_WAKEUP_UNDEFINED: + return WAKEUP_CAUSE_NONE; + default: + return WAKEUP_CAUSE_UNKNOWN; + } +} +#endif // USE_DEEP_SLEEP_ON_WAKE + optional DeepSleepComponent::get_run_duration_() const { if (this->wakeup_cause_to_run_duration_.has_value()) { esp_sleep_wakeup_cause_t wakeup_cause = esp_sleep_get_wakeup_cause(); diff --git a/esphome/components/deep_sleep/deep_sleep_esp8266.cpp b/esphome/components/deep_sleep/deep_sleep_esp8266.cpp index 9239a7fb31..2b98f4b855 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp8266.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp8266.cpp @@ -3,10 +3,27 @@ #include +#ifdef USE_DEEP_SLEEP_ON_WAKE +extern "C" { +#include +} +#endif + namespace esphome::deep_sleep { static const char *const TAG = "deep_sleep"; +#ifdef USE_DEEP_SLEEP_ON_WAKE +WakeupCause get_wakeup_cause() { + // The ESP8266 can only wake from deep sleep through the RTC timer (via GPIO16 -> RST). + // NOLINTNEXTLINE(readability-static-accessed-through-instance) + if (ESP.getResetInfoPtr()->reason == REASON_DEEP_SLEEP_AWAKE) { + return WAKEUP_CAUSE_TIMER; + } + return WAKEUP_CAUSE_NONE; +} +#endif // USE_DEEP_SLEEP_ON_WAKE + optional DeepSleepComponent::get_run_duration_() const { return this->run_duration_; } void DeepSleepComponent::dump_config_platform_() {} diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 1ecc3dc4a8..61de97ca74 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -56,6 +56,7 @@ #define USE_DATETIME_TIME #define USE_DEBUG #define USE_DEEP_SLEEP +#define USE_DEEP_SLEEP_ON_WAKE #define USE_DEVICES #define USE_DISPLAY #define USE_ENTITY_DEVICE_CLASS diff --git a/tests/component_tests/deep_sleep/test_deep_sleep.py b/tests/component_tests/deep_sleep/test_deep_sleep.py index 84128d75d7..f105ed5888 100644 --- a/tests/component_tests/deep_sleep/test_deep_sleep.py +++ b/tests/component_tests/deep_sleep/test_deep_sleep.py @@ -33,6 +33,43 @@ def test_deep_sleep_run_duration_simple(generate_main): assert "deepsleep->set_run_duration(10000);" in main_cpp +def test_deep_sleep_on_wake_trigger(generate_main): + """ + When deep sleep is configured with a component-level on_wake automation, + a WakeTrigger component should be registered with the wakeup cause as + the automation argument. + """ + main_cpp = generate_main("tests/component_tests/deep_sleep/test_deep_sleep3.yaml") + + assert "deep_sleep::WakeTrigger();" in main_cpp + assert "Automation" in main_cpp + + +def test_deep_sleep_ext1_on_wake_triggers(generate_main): + """ + Each esp32_ext1_wakeup pin with an on_wake automation should get its own + Ext1WakeTrigger with the pin number, and all pins (including the legacy + bare-pin shorthand) should contribute to the ext1 wakeup mask. + """ + main_cpp = generate_main("tests/component_tests/deep_sleep/test_deep_sleep3.yaml") + + assert "deep_sleep::Ext1WakeTrigger(2);" in main_cpp + assert "deep_sleep::Ext1WakeTrigger(4);" in main_cpp + # GPIO13 has no on_wake, so no trigger is created for it + assert "deep_sleep::Ext1WakeTrigger(13)" not in main_cpp + # mask covers GPIO2, GPIO4 and GPIO13 + assert ".mask = 8212," in main_cpp + + +def test_deep_sleep_no_on_wake_no_triggers(generate_main): + """ + Without any on_wake automations, no wake trigger code should be generated. + """ + main_cpp = generate_main("tests/component_tests/deep_sleep/test_deep_sleep1.yaml") + + assert "WakeTrigger" not in main_cpp + + def test_deep_sleep_run_duration_dictionary(generate_main): """ When deep sleep is configured with dictionary run duration, it should be set. diff --git a/tests/component_tests/deep_sleep/test_deep_sleep3.yaml b/tests/component_tests/deep_sleep/test_deep_sleep3.yaml new file mode 100644 index 0000000000..71a0340b65 --- /dev/null +++ b/tests/component_tests/deep_sleep/test_deep_sleep3.yaml @@ -0,0 +1,23 @@ +esphome: + name: test + +esp32: + board: nodemcu-32s + +deep_sleep: + id: deepsleep + sleep_duration: 1min + run_duration: 10s + on_wake: + - lambda: 'ESP_LOGD("test", "cause %d", static_cast(cause));' + esp32_ext1_wakeup: + mode: ANY_HIGH + pins: + - pin: GPIO2 + on_wake: + - lambda: 'ESP_LOGD("test", "left");' + - pin: + number: GPIO4 + on_wake: + - lambda: 'ESP_LOGD("test", "right");' + - number: GPIO13 diff --git a/tests/components/deep_sleep/common-esp32-all.yaml b/tests/components/deep_sleep/common-esp32-all.yaml index b97eec76b9..9dc2f87258 100644 --- a/tests/components/deep_sleep/common-esp32-all.yaml +++ b/tests/components/deep_sleep/common-esp32-all.yaml @@ -6,9 +6,15 @@ deep_sleep: sleep_duration: 50s wakeup_pin: ${wakeup_pin} wakeup_pin_mode: INVERT_WAKEUP + on_wake: + - logger.log: + format: "Woke up, cause %d" + args: ["static_cast(cause)"] esp32_ext1_wakeup: pins: - - number: GPIO2 + - pin: GPIO2 + on_wake: + - logger.log: Woken by ext1 pin GPIO2 - number: GPIO13 mode: ANY_HIGH touch_wakeup: true diff --git a/tests/components/deep_sleep/common-esp32-ext1.yaml b/tests/components/deep_sleep/common-esp32-ext1.yaml index 9ed4279a33..c531d44743 100644 --- a/tests/components/deep_sleep/common-esp32-ext1.yaml +++ b/tests/components/deep_sleep/common-esp32-ext1.yaml @@ -5,8 +5,15 @@ deep_sleep: sleep_duration: 50s wakeup_pin: ${wakeup_pin} wakeup_pin_mode: INVERT_WAKEUP + on_wake: + - logger.log: + format: "Woke up, cause %d" + args: ["static_cast(cause)"] esp32_ext1_wakeup: pins: - - number: GPIO2 + - pin: + number: GPIO2 + on_wake: + - logger.log: Woken by ext1 pin GPIO2 - number: GPIO5 mode: ANY_HIGH diff --git a/tests/components/deep_sleep/common-esp32.yaml b/tests/components/deep_sleep/common-esp32.yaml index c20e1a902e..e670787cc0 100644 --- a/tests/components/deep_sleep/common-esp32.yaml +++ b/tests/components/deep_sleep/common-esp32.yaml @@ -5,3 +5,7 @@ deep_sleep: sleep_duration: 50s wakeup_pin: ${wakeup_pin} wakeup_pin_mode: INVERT_WAKEUP + on_wake: + - logger.log: + format: "Woke up, cause %d" + args: ["static_cast(cause)"] diff --git a/tests/components/deep_sleep/test.bk72xx-ard.yaml b/tests/components/deep_sleep/test.bk72xx-ard.yaml index 2385fbb4db..bdbd27c902 100644 --- a/tests/components/deep_sleep/test.bk72xx-ard.yaml +++ b/tests/components/deep_sleep/test.bk72xx-ard.yaml @@ -1,6 +1,10 @@ deep_sleep: run_duration: 30s sleep_duration: 12h + on_wake: + - logger.log: + format: "Woke up, cause %d" + args: ["static_cast(cause)"] wakeup_pin: - pin: number: P6 diff --git a/tests/components/deep_sleep/test.esp8266-ard.yaml b/tests/components/deep_sleep/test.esp8266-ard.yaml index df08ec8a14..e4c592c095 100644 --- a/tests/components/deep_sleep/test.esp8266-ard.yaml +++ b/tests/components/deep_sleep/test.esp8266-ard.yaml @@ -1,5 +1,9 @@ deep_sleep: run_duration: 10s sleep_duration: 50s + on_wake: + - logger.log: + format: "Woke up, cause %d" + args: ["static_cast(cause)"] <<: !include common.yaml From be3b27f37801b118c93ccbbc7330abf7d25d0af7 Mon Sep 17 00:00:00 2001 From: lsellens Date: Wed, 15 Jul 2026 20:20:07 -0500 Subject: [PATCH 0911/1815] [rc522_i2c] Change default address to match whats in the docs (#17566) --- esphome/components/rc522_i2c/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/rc522_i2c/__init__.py b/esphome/components/rc522_i2c/__init__.py index 7c42a12429..c67615e2d8 100644 --- a/esphome/components/rc522_i2c/__init__.py +++ b/esphome/components/rc522_i2c/__init__.py @@ -16,7 +16,7 @@ CONFIG_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(RC522I2C), } - ).extend(i2c.i2c_device_schema(0x2C)) + ).extend(i2c.i2c_device_schema(0x28)) ) From f91aa7aa13d5a5c8fda868371e42069a726301ab Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:26:23 -0400 Subject: [PATCH 0912/1815] [as3935_i2c] Use repeated start when reading registers (#17584) --- esphome/components/as3935_i2c/as3935_i2c.cpp | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/esphome/components/as3935_i2c/as3935_i2c.cpp b/esphome/components/as3935_i2c/as3935_i2c.cpp index 4c1020daa7..b3d015114f 100644 --- a/esphome/components/as3935_i2c/as3935_i2c.cpp +++ b/esphome/components/as3935_i2c/as3935_i2c.cpp @@ -24,11 +24,7 @@ void I2CAS3935Component::write_register(uint8_t reg, uint8_t mask, uint8_t bits, uint8_t I2CAS3935Component::read_register(uint8_t reg) { uint8_t value; - if (write(®, 1) != i2c::ERROR_OK) { - ESP_LOGW(TAG, "Writing register failed!"); - return 0; - } - if (read(&value, 1) != i2c::ERROR_OK) { + if (!this->read_byte(reg, &value)) { ESP_LOGW(TAG, "Reading register failed!"); return 0; } From 90e838d327510e71a2081f5c350bafbaf77e3c5d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:07:34 -0400 Subject: [PATCH 0913/1815] [ci] Extend variant clang-tidy scans to USB logger, tsens and LP peripheral code (#17580) --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6066d0ea03..59e6f006e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -748,7 +748,8 @@ jobs: include: - id: clang-tidy name: Run script/clang-tidy for ESP32 S3 - options: --environment esp32s3-idf-tidy --grep USE_ESP32_VARIANT_ESP32S3 + # yamllint disable-line rule:line-length + options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC - id: clang-tidy name: Run script/clang-tidy for ESP32 P4 # P4 has no native Wi-Fi/BLE; those run over the hosted co-processor, @@ -758,7 +759,7 @@ jobs: - id: clang-tidy name: Run script/clang-tidy for ESP32 C6 # yamllint disable-line rule:line-length - options: --environment esp32c6-idf-tidy --grep USE_ESP32_VARIANT_ESP32C6 --grep USE_OPENTHREAD --grep USE_ZIGBEE + options: --environment esp32c6-idf-tidy --grep SOC_LP_I2C_SUPPORTED --grep USE_ESP32_VARIANT_ESP32C6 --grep USE_OPENTHREAD --grep USE_ZIGBEE steps: - name: Check out code from GitHub From e327a7f52fc9ce21be86f75348ff090f1b599c4e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Jul 2026 17:58:41 -1000 Subject: [PATCH 0914/1815] [scheduler] Remove deprecated set_retry/cancel_retry (#17585) --- esphome/core/base_automation.h | 4 +- esphome/core/component.cpp | 52 ---- esphome/core/component.h | 37 --- esphome/core/scheduler.cpp | 180 +---------- esphome/core/scheduler.h | 90 ++---- .../fixtures/scheduler_numeric_id_test.yaml | 33 +- .../fixtures/scheduler_retry_test.yaml | 287 ------------------ .../test_scheduler_numeric_id_test.py | 45 +-- .../integration/test_scheduler_retry_test.py | 279 ----------------- 9 files changed, 41 insertions(+), 966 deletions(-) delete mode 100644 tests/integration/fixtures/scheduler_retry_test.yaml delete mode 100644 tests/integration/test_scheduler_retry_test.py diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 38e52e44cb..276b8aa972 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -201,7 +201,7 @@ template class DelayAction : public Action { /* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER, /* static_name= */ reinterpret_cast(this), /* hash_or_id= */ 0, this->delay_.value(), [this]() { this->play_next_(); }, - /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1, + /* skip_cancel= */ this->num_running_ > 1, // Record the owning script (if any) so the blocking warning can name it; propagates across // chained delays via the scheduler. /* source= */ App.get_current_source()); @@ -215,7 +215,7 @@ template class DelayAction : public Action { /* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER, /* static_name= */ reinterpret_cast(this), /* hash_or_id= */ 0, this->delay_.value(x...), std::move(f), - /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1, + /* skip_cancel= */ this->num_running_ > 1, // See the no-argument branch above: record the owning script for log attribution. /* source= */ App.get_current_source()); } diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 281d7aaecd..e5fbb8ba07 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -93,36 +93,6 @@ bool Component::cancel_interval(const char *name) { // NOLINT return App.scheduler.cancel_interval(this, name); } -void Component::set_retry(const std::string &name, uint32_t initial_wait_time, uint8_t max_attempts, - std::function &&f, float backoff_increase_factor) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - App.scheduler.set_retry(this, name, initial_wait_time, max_attempts, std::move(f), backoff_increase_factor); -#pragma GCC diagnostic pop -} - -void Component::set_retry(const char *name, uint32_t initial_wait_time, uint8_t max_attempts, - std::function &&f, float backoff_increase_factor) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - App.scheduler.set_retry(this, name, initial_wait_time, max_attempts, std::move(f), backoff_increase_factor); -#pragma GCC diagnostic pop -} - -bool Component::cancel_retry(const std::string &name) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return App.scheduler.cancel_retry(this, name); -#pragma GCC diagnostic pop -} - -bool Component::cancel_retry(const char *name) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return App.scheduler.cancel_retry(this, name); -#pragma GCC diagnostic pop -} - void Component::set_timeout(const char *name, uint32_t timeout, std::function &&f) { // NOLINT App.scheduler.set_timeout(this, name, timeout, std::move(f)); } @@ -156,21 +126,6 @@ void Component::set_interval(InternalSchedulerID id, uint32_t interval, std::fun bool Component::cancel_interval(InternalSchedulerID id) { return App.scheduler.cancel_interval(this, id); } -void Component::set_retry(uint32_t id, uint32_t initial_wait_time, uint8_t max_attempts, - std::function &&f, float backoff_increase_factor) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - App.scheduler.set_retry(this, id, initial_wait_time, max_attempts, std::move(f), backoff_increase_factor); -#pragma GCC diagnostic pop -} - -bool Component::cancel_retry(uint32_t id) { -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return App.scheduler.cancel_retry(this, id); -#pragma GCC diagnostic pop -} - void Component::call_setup() { this->setup(); } void Component::call_dump_config_() { this->dump_config(); @@ -307,13 +262,6 @@ void Component::set_timeout(uint32_t timeout, std::function &&f) { // N void Component::set_interval(uint32_t interval, std::function &&f) { // NOLINT App.scheduler.set_interval(this, static_cast(nullptr), interval, std::move(f)); } -void Component::set_retry(uint32_t initial_wait_time, uint8_t max_attempts, std::function &&f, - float backoff_increase_factor) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - App.scheduler.set_retry(this, "", initial_wait_time, max_attempts, std::move(f), backoff_increase_factor); -#pragma GCC diagnostic pop -} bool Component::is_ready() const { // Bitmask check: valid states are SETUP(1), LOOP(2), LOOP_DONE(4) // (1 << state) & 0b10110 checks membership in one instruction diff --git a/esphome/core/component.h b/esphome/core/component.h index 70a051ca0b..ecaf863ecf 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -96,8 +96,6 @@ inline constexpr uint8_t COMPONENT_HAS_LOOP = 0x20; // decide whether to propagate clears to App.app_state_. Never set on a // Component's component_state_. inline constexpr uint8_t APP_STATE_SETUP_COMPLETE = 0x40; -// Remove before 2026.8.0 -enum class RetryResult { DONE, RETRY }; inline constexpr uint8_t WARN_IF_BLOCKING_OVER_CS = 5U; // 50ms in centiseconds (1cs = 10ms) @@ -410,41 +408,6 @@ class Component { bool cancel_interval(uint32_t id); // NOLINT bool cancel_interval(InternalSchedulerID id); // NOLINT - /// @deprecated set_retry is deprecated. Use set_timeout or set_interval instead. Removed in 2026.8.0. - // Remove before 2026.8.0 - ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", - "2026.2.0") - void set_retry(const std::string &name, uint32_t initial_wait_time, uint8_t max_attempts, // NOLINT - std::function &&f, float backoff_increase_factor = 1.0f); // NOLINT - - // Remove before 2026.8.0 - ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", - "2026.2.0") - void set_retry(const char *name, uint32_t initial_wait_time, uint8_t max_attempts, // NOLINT - std::function &&f, float backoff_increase_factor = 1.0f); // NOLINT - - // Remove before 2026.8.0 - ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", - "2026.2.0") - void set_retry(uint32_t id, uint32_t initial_wait_time, uint8_t max_attempts, // NOLINT - std::function &&f, float backoff_increase_factor = 1.0f); // NOLINT - - // Remove before 2026.8.0 - ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", - "2026.2.0") - void set_retry(uint32_t initial_wait_time, uint8_t max_attempts, std::function &&f, // NOLINT - float backoff_increase_factor = 1.0f); // NOLINT - - // Remove before 2026.8.0 - ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") - bool cancel_retry(const std::string &name); // NOLINT - // Remove before 2026.8.0 - ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") - bool cancel_retry(const char *name); // NOLINT - // Remove before 2026.8.0 - ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") - bool cancel_retry(uint32_t id); // NOLINT - /** Set a timeout function with a const char* name. * * Similar to javascript's setTimeout(). Empty name means no cancelling possible. diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 8449cba5e8..e9c5bf2c04 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -110,35 +110,16 @@ uint32_t Scheduler::calculate_interval_offset_(uint32_t delay) { return static_cast((static_cast(random_uint32()) * max_offset) >> 32); } -// Check if a retry was already cancelled in items_ or to_add_ -// Extracted from set_timer_common_ to reduce code size - retry path is cold and deprecated -// Remove before 2026.8.0 along with all retry code -bool Scheduler::is_retry_cancelled_locked_(Component *component, NameType name_type, const char *static_name, - uint32_t hash_or_id) { - for (auto *container : {&this->items_, &this->to_add_}) { - for (auto *item : *container) { - if (item != nullptr && this->is_item_removed_locked_(item) && - this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, SchedulerItem::TIMEOUT, - /* match_retry= */ true, /* skip_removed= */ false)) { - return true; - } - } - } - return false; -} - // Common implementation for both timeout and interval // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type, const char *static_name, uint32_t hash_or_id, uint32_t delay, - std::function &&func, bool is_retry, bool skip_cancel, - const LogString *source) { + std::function &&func, bool skip_cancel, const LogString *source) { if (delay == SCHEDULER_DONT_RUN) { // Still need to cancel existing timer if we have a name/id if (!skip_cancel) { LockGuard guard{this->lock_}; - this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, /* match_retry= */ false, - /* find_first= */ true); + this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, /* find_first= */ true); } return; } @@ -156,23 +137,9 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type delay = 1; } - // Take lock early to protect scheduler_item_pool_head_ access and retry-cancelled check + // Take lock early to protect scheduler_item_pool_head_ access LockGuard guard{this->lock_}; - // For retries, check if there's a cancelled timeout first - before allocating an item. - // Skip check for anonymous retries (STATIC_STRING with nullptr) - they can't be cancelled by name - // Skip check for defer (delay=0) - deferred retries bypass the cancellation check - if (is_retry && delay != 0 && (name_type != NameType::STATIC_STRING || static_name != nullptr) && - type == SchedulerItem::TIMEOUT && - this->is_retry_cancelled_locked_(component, name_type, static_name, hash_or_id)) { -#ifdef ESPHOME_DEBUG_SCHEDULER - SchedulerNameLog skip_name_log; - ESP_LOGD(TAG, "Skipping retry '%s' - found cancelled item", - skip_name_log.format(name_type, static_name, hash_or_id)); -#endif - return; - } - // Create and populate the scheduler item SchedulerItem *item = this->get_item_from_pool_locked_(); // SELF_POINTER items store the source name (owning script) in the union slot instead of a component. @@ -192,7 +159,6 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type new (&item->callback) std::function(std::move(func)); // Reset remove flag - recycled items may have been cancelled (remove=true) in previous use this->set_item_removed_(item, false); - item->is_retry = is_retry; // Determine target container: defer_queue_ for deferred items, to_add_ for everything else. // Using a pointer lets both paths share the cancel + push_back epilogue. @@ -234,8 +200,7 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type // Common epilogue: atomic cancel-and-add (unless skip_cancel is true or anonymous) // Anonymous items (STATIC_STRING with nullptr) can never match anything, so skip the scan. if (!skip_cancel && (name_type != NameType::STATIC_STRING || static_name != nullptr)) { - this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, /* match_retry= */ false, - /* find_first= */ true); + this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, /* find_first= */ true); } target->push_back(item); if (target == &this->to_add_) { @@ -301,125 +266,6 @@ bool HOT Scheduler::cancel_interval(const void *self) { SchedulerItem::INTERVAL); } -// Suppress deprecation warnings for RetryResult usage in the still-present (but deprecated) retry implementation. -// Remove before 2026.8.0 along with all retry code. -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - -struct RetryArgs { - // Ordered to minimize padding on 32-bit systems - std::function func; - Component *component; - Scheduler *scheduler; - // Union for name storage - only one is used based on name_type - union { - const char *static_name; // For STATIC_STRING - uint32_t hash_or_id; // For HASHED_STRING or NUMERIC_ID - } name_; - uint32_t current_interval; - float backoff_increase_factor; - Scheduler::NameType name_type; // Discriminator for name_ union - uint8_t retry_countdown; -}; - -void retry_handler(const std::shared_ptr &args) { - RetryResult const retry_result = args->func(--args->retry_countdown); - if (retry_result == RetryResult::DONE || args->retry_countdown <= 0) - return; - // second execution of `func` happens after `initial_wait_time` - // args->name_ is owned by the shared_ptr - // which is captured in the lambda and outlives the SchedulerItem - const char *static_name = (args->name_type == Scheduler::NameType::STATIC_STRING) ? args->name_.static_name : nullptr; - uint32_t hash_or_id = (args->name_type != Scheduler::NameType::STATIC_STRING) ? args->name_.hash_or_id : 0; - args->scheduler->set_timer_common_( - args->component, Scheduler::SchedulerItem::TIMEOUT, args->name_type, static_name, hash_or_id, - args->current_interval, [args]() { retry_handler(args); }, - /* is_retry= */ true); - // backoff_increase_factor applied to third & later executions - args->current_interval *= args->backoff_increase_factor; -} - -void HOT Scheduler::set_retry_common_(Component *component, NameType name_type, const char *static_name, - uint32_t hash_or_id, uint32_t initial_wait_time, uint8_t max_attempts, - std::function func, float backoff_increase_factor) { - this->cancel_retry_(component, name_type, static_name, hash_or_id); - - if (initial_wait_time == SCHEDULER_DONT_RUN) - return; - -#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE - { - SchedulerNameLog name_log; - ESP_LOGVV(TAG, "set_retry(name='%s', initial_wait_time=%" PRIu32 ", max_attempts=%u, backoff_factor=%0.1f)", - name_log.format(name_type, static_name, hash_or_id), initial_wait_time, max_attempts, - backoff_increase_factor); - } -#endif - - if (backoff_increase_factor < 0.0001f) { - ESP_LOGE(TAG, "set_retry: backoff_factor %0.1f too small, using 1.0: %s", backoff_increase_factor, - (name_type == NameType::STATIC_STRING && static_name) ? static_name : ""); - backoff_increase_factor = 1; - } - - auto args = std::make_shared(); - args->func = std::move(func); - args->component = component; - args->scheduler = this; - args->name_type = name_type; - if (name_type == NameType::STATIC_STRING) { - args->name_.static_name = static_name; - } else { - args->name_.hash_or_id = hash_or_id; - } - args->current_interval = initial_wait_time; - args->backoff_increase_factor = backoff_increase_factor; - args->retry_countdown = max_attempts; - - // First execution of `func` immediately - use set_timer_common_ with is_retry=true - this->set_timer_common_( - component, SchedulerItem::TIMEOUT, name_type, static_name, hash_or_id, 0, [args]() { retry_handler(args); }, - /* is_retry= */ true); -} - -void HOT Scheduler::set_retry(Component *component, const char *name, uint32_t initial_wait_time, uint8_t max_attempts, - std::function func, float backoff_increase_factor) { - this->set_retry_common_(component, NameType::STATIC_STRING, name, 0, initial_wait_time, max_attempts, std::move(func), - backoff_increase_factor); -} - -bool HOT Scheduler::cancel_retry_(Component *component, NameType name_type, const char *static_name, - uint32_t hash_or_id) { - return this->cancel_item_(component, name_type, static_name, hash_or_id, SchedulerItem::TIMEOUT, - /* match_retry= */ true); -} -bool HOT Scheduler::cancel_retry(Component *component, const char *name) { - return this->cancel_retry_(component, NameType::STATIC_STRING, name, 0); -} - -void HOT Scheduler::set_retry(Component *component, const std::string &name, uint32_t initial_wait_time, - uint8_t max_attempts, std::function func, - float backoff_increase_factor) { - this->set_retry_common_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), initial_wait_time, - max_attempts, std::move(func), backoff_increase_factor); -} - -bool HOT Scheduler::cancel_retry(Component *component, const std::string &name) { - return this->cancel_retry_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name)); -} - -void HOT Scheduler::set_retry(Component *component, uint32_t id, uint32_t initial_wait_time, uint8_t max_attempts, - std::function func, float backoff_increase_factor) { - this->set_retry_common_(component, NameType::NUMERIC_ID, nullptr, id, initial_wait_time, max_attempts, - std::move(func), backoff_increase_factor); -} - -bool HOT Scheduler::cancel_retry(Component *component, uint32_t id) { - return this->cancel_retry_(component, NameType::NUMERIC_ID, nullptr, id); -} - -#pragma GCC diagnostic pop // End suppression of deprecated RetryResult warnings - optional HOT Scheduler::next_schedule_in(uint32_t now) { // IMPORTANT: This method should only be called from the main thread (loop task). // Accesses items_[0] and the fast-path empty checks without holding a lock, which @@ -806,11 +652,11 @@ uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) { // Common implementation for cancel operations - handles locking bool HOT Scheduler::cancel_item_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id, - SchedulerItem::Type type, bool match_retry) { + SchedulerItem::Type type) { LockGuard guard{this->lock_}; // Public cancel path uses default find_first=false to cancel ALL matches because // DelayAction parallel mode (skip_cancel=true) can create multiple items with the same key. - return this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, match_retry); + return this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type); } // Helper to cancel matching items - must be called with lock held. @@ -822,11 +668,10 @@ bool HOT Scheduler::cancel_item_(Component *component, NameType name_type, const size_t Scheduler::mark_matching_items_removed_slow_locked_(std::vector &container, Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id, - SchedulerItem::Type type, bool match_retry, - bool find_first) { + SchedulerItem::Type type, bool find_first) { size_t count = 0; for (auto *item : container) { - if (this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, type, match_retry)) { + if (this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, type)) { this->set_item_removed_(item, true); if (find_first) return 1; @@ -837,8 +682,7 @@ size_t Scheduler::mark_matching_items_removed_slow_locked_(std::vectormark_matching_items_removed_locked_(this->defer_queue_, component, name_type, static_name, - hash_or_id, type, match_retry, find_first); + hash_or_id, type, find_first); if (find_first && total_cancelled > 0) return true; } @@ -863,7 +707,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type // Only the main loop in call() should recycle items after execution completes. { size_t heap_cancelled = this->mark_matching_items_removed_locked_(this->items_, component, name_type, static_name, - hash_or_id, type, match_retry, find_first); + hash_or_id, type, find_first); total_cancelled += heap_cancelled; this->to_remove_add_locked_(heap_cancelled); if (find_first && total_cancelled > 0) @@ -872,7 +716,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type // Cancel items in to_add_ total_cancelled += this->mark_matching_items_removed_locked_(this->to_add_, component, name_type, static_name, - hash_or_id, type, match_retry, find_first); + hash_or_id, type, find_first); return total_cancelled > 0; } diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index c7743e5b2a..8ef3499a11 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -16,14 +16,8 @@ namespace esphome { class Component; -struct RetryArgs; - -// Forward declaration of retry_handler - needs to be non-static for friend declaration -void retry_handler(const std::shared_ptr &args); class Scheduler { - // Allow retry_handler to access protected members for internal retry mechanism - friend void ::esphome::retry_handler(const std::shared_ptr &args); // Allow DelayAction to call set_timer_common_ with skip_cancel=true for parallel script delays. // This is needed to fix issue #10264 where parallel scripts with delays interfere with each other. // We use friend instead of a public API because skip_cancel is dangerous - it can cause delays @@ -79,32 +73,6 @@ class Scheduler { SchedulerItem::INTERVAL); } - // Remove before 2026.8.0 - ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", - "2026.2.0") - void set_retry(Component *component, const std::string &name, uint32_t initial_wait_time, uint8_t max_attempts, - std::function func, float backoff_increase_factor = 1.0f); - // Remove before 2026.8.0 - ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", - "2026.2.0") - void set_retry(Component *component, const char *name, uint32_t initial_wait_time, uint8_t max_attempts, - std::function func, float backoff_increase_factor = 1.0f); - // Remove before 2026.8.0 - ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", - "2026.2.0") - void set_retry(Component *component, uint32_t id, uint32_t initial_wait_time, uint8_t max_attempts, - std::function func, float backoff_increase_factor = 1.0f); - - // Remove before 2026.8.0 - ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") - bool cancel_retry(Component *component, const std::string &name); - // Remove before 2026.8.0 - ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") - bool cancel_retry(Component *component, const char *name); - // Remove before 2026.8.0 - ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") - bool cancel_retry(Component *component, uint32_t id); - /// Get 64-bit millisecond timestamp (handles 32-bit millis() rollover) uint64_t millis_64() { return esphome::millis_64(); } @@ -202,19 +170,17 @@ class Scheduler { // std::atomic inlines correctly on all platforms. std::atomic remove{0}; - // Bit-packed fields (5 bits used, 3 bits padding in 1 byte) + // Bit-packed fields (4 bits used, 4 bits padding in 1 byte) enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1; NameType name_type_ : 3; // Discriminator for name_ union (0–4, see NameType enum) - bool is_retry : 1; // True if this is a retry timeout - // 3 bits padding + // 4 bits padding #else // Single-threaded or multi-threaded without atomics: can pack all fields together - // Bit-packed fields (6 bits used, 2 bits padding in 1 byte) + // Bit-packed fields (5 bits used, 3 bits padding in 1 byte) enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1; bool remove : 1; NameType name_type_ : 3; // Discriminator for name_ union (0–4, see NameType enum) - bool is_retry : 1; // True if this is a retry timeout - // 2 bits padding + // 3 bits padding #endif // Constructor @@ -226,13 +192,11 @@ class Scheduler { #ifdef ESPHOME_THREAD_MULTI_ATOMICS // remove is initialized in the member declaration type(TIMEOUT), - name_type_(NameType::STATIC_STRING), - is_retry(false) { + name_type_(NameType::STATIC_STRING) { #else type(TIMEOUT), remove(false), - name_type_(NameType::STATIC_STRING), - is_retry(false) { + name_type_(NameType::STATIC_STRING) { #endif name_.static_name = nullptr; } @@ -306,19 +270,8 @@ class Scheduler { // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id // `source` is stored (in the union slot) only for SELF_POINTER items; ignored otherwise. void set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type, const char *static_name, - uint32_t hash_or_id, uint32_t delay, std::function &&func, bool is_retry = false, - bool skip_cancel = false, const LogString *source = nullptr); - - // Common implementation for retry - Remove before 2026.8.0 - // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - void set_retry_common_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id, - uint32_t initial_wait_time, uint8_t max_attempts, std::function func, - float backoff_increase_factor); -#pragma GCC diagnostic pop - // Common implementation for cancel_retry - bool cancel_retry_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id); + uint32_t hash_or_id, uint32_t delay, std::function &&func, bool skip_cancel = false, + const LogString *source = nullptr); // Extend a 32-bit millis() value to 64-bit. Use when the caller already has a fresh now. // On platforms with native 64-bit time (ESP32, Host, Zephyr, RP2040 — see @@ -374,11 +327,11 @@ class Scheduler { // mode where skip_cancel=true allows multiple items with the same key). // name_type determines matching: STATIC_STRING uses static_name, others use hash_or_id bool cancel_item_locked_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id, - SchedulerItem::Type type, bool match_retry = false, bool find_first = false); + SchedulerItem::Type type, bool find_first = false); // Common implementation for cancel operations - handles locking bool cancel_item_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id, - SchedulerItem::Type type, bool match_retry = false); + SchedulerItem::Type type); // Helper to check if two static string names match inline bool HOT names_match_static_(const char *name1, const char *name2) const { @@ -394,7 +347,7 @@ class Scheduler { // IMPORTANT: Must be called with scheduler lock held inline bool HOT matches_item_locked_(SchedulerItem *item, Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id, SchedulerItem::Type type, - bool match_retry, bool skip_removed = true) const { + bool skip_removed = true) const { // THREAD SAFETY: Check for nullptr first to prevent LoadProhibited crashes. On multi-threaded // platforms, items can be nulled in defer_queue_ during processing. // Fixes: https://github.com/esphome/esphome/issues/11940 @@ -403,7 +356,7 @@ class Scheduler { // get_component() is nullptr for SELF_POINTER items (their cancels pass nullptr too), so they // match by the `this` key alone. if (item->get_component() != component || item->type != type || - (skip_removed && this->is_item_removed_locked_(item)) || (match_retry && !item->is_retry)) { + (skip_removed && this->is_item_removed_locked_(item))) { return false; } // Name type must match @@ -448,13 +401,6 @@ class Scheduler { // IMPORTANT: Must not be inlined - called only for intervals, keeping it out of the hot path saves flash. uint32_t __attribute__((noinline)) calculate_interval_offset_(uint32_t delay); - // Helper to check if a retry was already cancelled - extracted to reduce code size of set_timer_common_ - // Remove before 2026.8.0 along with all retry code. - // IMPORTANT: Must not be inlined - retry path is cold and deprecated. - // IMPORTANT: Caller must hold the scheduler lock before calling this function. - bool __attribute__((noinline)) - is_retry_cancelled_locked_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id); - #ifdef ESPHOME_DEBUG_SCHEDULER // Helper for debug logging in set_timer_common_ - extracted to reduce code size void debug_log_timer_(const SchedulerItem *item, NameType name_type, const char *static_name, uint32_t hash_or_id, @@ -556,19 +502,21 @@ class Scheduler { // Inlined: the fast path (empty container) avoids calling the out-of-line scan. inline size_t HOT mark_matching_items_removed_locked_(std::vector &container, Component *component, NameType name_type, const char *static_name, - uint32_t hash_or_id, SchedulerItem::Type type, bool match_retry, + uint32_t hash_or_id, SchedulerItem::Type type, bool find_first = false) { if (container.empty()) return 0; return this->mark_matching_items_removed_slow_locked_(container, component, name_type, static_name, hash_or_id, - type, match_retry, find_first); + type, find_first); } // Out-of-line slow path for mark_matching_items_removed_locked_ when container is non-empty. // IMPORTANT: Must be called with scheduler lock held - __attribute__((noinline)) size_t mark_matching_items_removed_slow_locked_( - std::vector &container, Component *component, NameType name_type, const char *static_name, - uint32_t hash_or_id, SchedulerItem::Type type, bool match_retry, bool find_first); + __attribute__((noinline)) size_t mark_matching_items_removed_slow_locked_(std::vector &container, + Component *component, NameType name_type, + const char *static_name, + uint32_t hash_or_id, + SchedulerItem::Type type, bool find_first); Mutex lock_; std::vector items_; diff --git a/tests/integration/fixtures/scheduler_numeric_id_test.yaml b/tests/integration/fixtures/scheduler_numeric_id_test.yaml index 25decf20f5..ae95e095f6 100644 --- a/tests/integration/fixtures/scheduler_numeric_id_test.yaml +++ b/tests/integration/fixtures/scheduler_numeric_id_test.yaml @@ -18,9 +18,6 @@ globals: - id: interval_counter type: int initial_value: '0' - - id: retry_counter - type: int - initial_value: '0' - id: defer_counter type: int initial_value: '0' @@ -118,29 +115,7 @@ script: id(timeout_counter) += 1; }); - // Test 10: set_retry with numeric ID - App.scheduler.set_retry(component1, 6001U, 50, 3, - [](uint8_t retry_countdown) { - id(retry_counter)++; - ESP_LOGI("test", "Numeric retry 6001 attempt %d (countdown=%d)", - id(retry_counter), retry_countdown); - if (id(retry_counter) >= 2) { - ESP_LOGI("test", "Numeric retry 6001 done"); - return RetryResult::DONE; - } - return RetryResult::RETRY; - }); - - // Test 11: cancel_retry with numeric ID - App.scheduler.set_retry(component1, 6002U, 100, 5, - [](uint8_t retry_countdown) { - ESP_LOGE("test", "ERROR: Numeric retry 6002 should have been cancelled"); - return RetryResult::RETRY; - }); - App.scheduler.cancel_retry(component1, 6002U); - ESP_LOGI("test", "Cancelled numeric retry 6002"); - - // Test 12: defer with numeric ID (Component method) + // Test 10: defer with numeric ID (Component method) class TestDeferComponent : public Component { public: void test_defer_methods() { @@ -161,7 +136,7 @@ script: static TestDeferComponent test_defer_component; test_defer_component.test_defer_methods(); - // Test 13: cancel_defer with numeric ID (Component method) + // Test 11: cancel_defer with numeric ID (Component method) class TestCancelDeferComponent : public Component { public: void test_cancel_defer() { @@ -181,8 +156,8 @@ script: - id: report_results then: - lambda: |- - ESP_LOGI("test", "Final results - Timeouts: %d, Intervals: %d, Retries: %d, Defers: %d", - id(timeout_counter), id(interval_counter), id(retry_counter), id(defer_counter)); + ESP_LOGI("test", "Final results - Timeouts: %d, Intervals: %d, Defers: %d", + id(timeout_counter), id(interval_counter), id(defer_counter)); sensor: - platform: template diff --git a/tests/integration/fixtures/scheduler_retry_test.yaml b/tests/integration/fixtures/scheduler_retry_test.yaml deleted file mode 100644 index cdf71152bd..0000000000 --- a/tests/integration/fixtures/scheduler_retry_test.yaml +++ /dev/null @@ -1,287 +0,0 @@ -esphome: - debug_scheduler: true # Enable scheduler leak detection - name: scheduler-retry-test - on_boot: - priority: -100 - then: - - logger.log: "Starting scheduler retry tests" - # Run all tests sequentially with delays - - script.execute: run_all_tests - -host: -api: -logger: - level: VERY_VERBOSE - -globals: - - id: simple_retry_counter - type: int - initial_value: '0' - - id: backoff_retry_counter - type: int - initial_value: '0' - - id: backoff_last_attempt_time - type: uint32_t - initial_value: '0' - - id: immediate_done_counter - type: int - initial_value: '0' - - id: cancel_retry_counter - type: int - initial_value: '0' - - id: empty_name_retry_counter - type: int - initial_value: '0' - - id: script_retry_counter - type: int - initial_value: '0' - - id: multiple_same_name_counter - type: int - initial_value: '0' - - id: const_char_retry_counter - type: int - initial_value: '0' - - id: static_char_retry_counter - type: int - initial_value: '0' - -# Using different component types for each test to ensure isolation -sensor: - - platform: template - name: Simple Retry Test Sensor - id: simple_retry_sensor - lambda: return 1.0; - update_interval: never - - - platform: template - name: Backoff Retry Test Sensor - id: backoff_retry_sensor - lambda: return 2.0; - update_interval: never - - - platform: template - name: Immediate Done Test Sensor - id: immediate_done_sensor - lambda: return 3.0; - update_interval: never - -binary_sensor: - - platform: template - name: Cancel Retry Test Binary Sensor - id: cancel_retry_binary_sensor - lambda: return false; - - - platform: template - name: Empty Name Test Binary Sensor - id: empty_name_binary_sensor - lambda: return true; - -switch: - - platform: template - name: Script Retry Test Switch - id: script_retry_switch - optimistic: true - - - platform: template - name: Multiple Same Name Test Switch - id: multiple_same_name_switch - optimistic: true - -script: - - id: run_all_tests - then: - # Test 1: Simple retry - - logger.log: "=== Test 1: Simple retry ===" - - lambda: |- - auto *component = id(simple_retry_sensor); - App.scheduler.set_retry(component, "simple_retry", 50, 3, - [](uint8_t retry_countdown) { - id(simple_retry_counter)++; - ESP_LOGI("test", "Simple retry attempt %d (countdown=%d)", - id(simple_retry_counter), retry_countdown); - - if (id(simple_retry_counter) >= 2) { - ESP_LOGI("test", "Simple retry succeeded on attempt %d", id(simple_retry_counter)); - return RetryResult::DONE; - } - return RetryResult::RETRY; - }); - - # Test 2: Backoff retry - - logger.log: "=== Test 2: Retry with backoff ===" - - lambda: |- - auto *component = id(backoff_retry_sensor); - - App.scheduler.set_retry(component, "backoff_retry", 50, 4, - [](uint8_t retry_countdown) { - id(backoff_retry_counter)++; - uint32_t now = millis(); - uint32_t interval = 0; - - // Only calculate interval after first attempt - if (id(backoff_retry_counter) > 1) { - interval = now - id(backoff_last_attempt_time); - } - id(backoff_last_attempt_time) = now; - - ESP_LOGI("test", "Backoff retry attempt %d (countdown=%d, interval=%dms)", - id(backoff_retry_counter), retry_countdown, interval); - - if (id(backoff_retry_counter) == 1) { - ESP_LOGI("test", "First call was immediate"); - } else if (id(backoff_retry_counter) == 2) { - ESP_LOGI("test", "Second call interval: %dms (expected ~50ms)", interval); - } else if (id(backoff_retry_counter) == 3) { - ESP_LOGI("test", "Third call interval: %dms (expected ~100ms)", interval); - } else if (id(backoff_retry_counter) == 4) { - ESP_LOGI("test", "Fourth call interval: %dms (expected ~200ms)", interval); - ESP_LOGI("test", "Backoff retry completed"); - return RetryResult::DONE; - } - - return RetryResult::RETRY; - }, 2.0f); - - # Test 3: Immediate done - - logger.log: "=== Test 3: Immediate done ===" - - lambda: |- - auto *component = id(immediate_done_sensor); - App.scheduler.set_retry(component, "immediate_done", 50, 5, - [](uint8_t retry_countdown) { - id(immediate_done_counter)++; - ESP_LOGI("test", "Immediate done retry called (countdown=%d)", retry_countdown); - return RetryResult::DONE; - }); - - # Test 4: Cancel retry - - logger.log: "=== Test 4: Cancel retry ===" - - lambda: |- - auto *component = id(cancel_retry_binary_sensor); - App.scheduler.set_retry(component, "cancel_test", 30, 10, - [](uint8_t retry_countdown) { - id(cancel_retry_counter)++; - ESP_LOGI("test", "Cancel test retry attempt %d", id(cancel_retry_counter)); - return RetryResult::RETRY; - }); - - // Cancel it after 100ms - App.scheduler.set_timeout(component, "cancel_timer", 100, []() { - bool cancelled = App.scheduler.cancel_retry(id(cancel_retry_binary_sensor), "cancel_test"); - ESP_LOGI("test", "Retry cancellation result: %s", cancelled ? "true" : "false"); - ESP_LOGI("test", "Cancel retry ran %d times before cancellation", id(cancel_retry_counter)); - }); - - # Test 5: Empty name retry - - logger.log: "=== Test 5: Empty name retry ===" - - lambda: |- - auto *component = id(empty_name_binary_sensor); - App.scheduler.set_retry(component, "", 100, 5, - [](uint8_t retry_countdown) { - id(empty_name_retry_counter)++; - ESP_LOGI("test", "Empty name retry attempt %d", id(empty_name_retry_counter)); - return RetryResult::RETRY; - }); - - // Try to cancel after 150ms - App.scheduler.set_timeout(component, "empty_cancel_timer", 150, []() { - bool cancelled = App.scheduler.cancel_retry(id(empty_name_binary_sensor), ""); - ESP_LOGI("test", "Empty name retry cancel result: %s", - cancelled ? "true" : "false"); - ESP_LOGI("test", "Empty name retry ran %d times", id(empty_name_retry_counter)); - }); - - # Test 6: Component method - - logger.log: "=== Test 6: Component::set_retry method ===" - - lambda: |- - class TestRetryComponent : public Component { - public: - void test_retry() { - this->set_retry(50, 3, - [](uint8_t retry_countdown) { - id(script_retry_counter)++; - ESP_LOGI("test", "Component retry attempt %d", id(script_retry_counter)); - if (id(script_retry_counter) >= 2) { - return RetryResult::DONE; - } - return RetryResult::RETRY; - }, 1.5f); - } - }; - - static TestRetryComponent test_component; - test_component.test_retry(); - - # Test 7: Multiple same name - - logger.log: "=== Test 7: Multiple retries with same name ===" - - lambda: |- - auto *component = id(multiple_same_name_switch); - - // Set first retry - App.scheduler.set_retry(component, "duplicate_retry", 100, 5, - [](uint8_t retry_countdown) { - id(multiple_same_name_counter) += 1; - ESP_LOGI("test", "First duplicate retry - should not run"); - return RetryResult::RETRY; - }); - - // Set second retry with same name (should cancel first) - App.scheduler.set_retry(component, "duplicate_retry", 50, 3, - [](uint8_t retry_countdown) { - id(multiple_same_name_counter) += 10; - ESP_LOGI("test", "Second duplicate retry attempt (counter=%d)", - id(multiple_same_name_counter)); - if (id(multiple_same_name_counter) >= 20) { - return RetryResult::DONE; - } - return RetryResult::RETRY; - }); - - # Test 8: Const char* overloads - - logger.log: "=== Test 8: Const char* overloads ===" - - lambda: |- - auto *component = id(simple_retry_sensor); - - // Test 8a: Direct string literal - App.scheduler.set_retry(component, "const_char_test", 30, 2, - [](uint8_t retry_countdown) { - id(const_char_retry_counter)++; - ESP_LOGI("test", "Const char retry %d", id(const_char_retry_counter)); - return RetryResult::DONE; - }); - - # Test 9: Static const char* variable - - logger.log: "=== Test 9: Static const char* ===" - - lambda: |- - auto *component = id(backoff_retry_sensor); - - static const char* STATIC_NAME = "static_retry_test"; - App.scheduler.set_retry(component, STATIC_NAME, 20, 1, - [](uint8_t retry_countdown) { - id(static_char_retry_counter)++; - ESP_LOGI("test", "Static const char retry %d", id(static_char_retry_counter)); - return RetryResult::DONE; - }); - - // Cancel with same static const char* - App.scheduler.set_timeout(component, "static_cancel", 10, []() { - static const char* STATIC_NAME = "static_retry_test"; - bool result = App.scheduler.cancel_retry(id(backoff_retry_sensor), STATIC_NAME); - ESP_LOGI("test", "Static cancel result: %s", result ? "true" : "false"); - }); - - # Wait for all tests to complete before reporting - - delay: 500ms - - # Final report - - logger.log: "=== Retry Test Results ===" - - lambda: |- - ESP_LOGI("test", "Simple retry counter: %d (expected 2)", id(simple_retry_counter)); - ESP_LOGI("test", "Backoff retry counter: %d (expected 4)", id(backoff_retry_counter)); - ESP_LOGI("test", "Immediate done counter: %d (expected 1)", id(immediate_done_counter)); - ESP_LOGI("test", "Cancel retry counter: %d (expected 2-4)", id(cancel_retry_counter)); - ESP_LOGI("test", "Empty name retry counter: %d (expected 1-2)", id(empty_name_retry_counter)); - ESP_LOGI("test", "Component retry counter: %d (expected 2)", id(script_retry_counter)); - ESP_LOGI("test", "Multiple same name counter: %d (expected 20+)", id(multiple_same_name_counter)); - ESP_LOGI("test", "Const char retry counter: %d (expected 1)", id(const_char_retry_counter)); - ESP_LOGI("test", "Static char retry counter: %d (expected 1)", id(static_char_retry_counter)); - ESP_LOGI("test", "All retry tests completed"); diff --git a/tests/integration/test_scheduler_numeric_id_test.py b/tests/integration/test_scheduler_numeric_id_test.py index c1958db685..3591e3014e 100644 --- a/tests/integration/test_scheduler_numeric_id_test.py +++ b/tests/integration/test_scheduler_numeric_id_test.py @@ -18,7 +18,6 @@ async def test_scheduler_numeric_id_test( # Track counts timeout_count = 0 interval_count = 0 - retry_count = 0 defer_count = 0 # Events for each test completion @@ -32,8 +31,6 @@ async def test_scheduler_numeric_id_test( component_interval_fired = asyncio.Event() zero_id_timeout_fired = asyncio.Event() max_id_timeout_fired = asyncio.Event() - numeric_retry_done = asyncio.Event() - numeric_retry_cancelled = asyncio.Event() numeric_defer_7001_fired = asyncio.Event() numeric_defer_7002_fired = asyncio.Event() numeric_defer_cancelled = asyncio.Event() @@ -41,11 +38,10 @@ async def test_scheduler_numeric_id_test( # Track interval counts numeric_interval_count = 0 - numeric_retry_count = 0 def on_log_line(line: str) -> None: - nonlocal timeout_count, interval_count, retry_count, defer_count - nonlocal numeric_interval_count, numeric_retry_count + nonlocal timeout_count, interval_count, defer_count + nonlocal numeric_interval_count # Strip ANSI color codes clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) @@ -97,18 +93,6 @@ async def test_scheduler_numeric_id_test( max_id_timeout_fired.set() timeout_count += 1 - # Check for numeric retry tests - elif "Numeric retry 6001 attempt" in clean_line: - match = re.search(r"attempt (\d+)", clean_line) - if match: - numeric_retry_count = int(match.group(1)) - - elif "Numeric retry 6001 done" in clean_line: - numeric_retry_done.set() - - elif "Cancelled numeric retry 6002" in clean_line: - numeric_retry_cancelled.set() - # Check for numeric defer tests elif "Component numeric defer 7001 fired" in clean_line: numeric_defer_7001_fired.set() @@ -122,14 +106,13 @@ async def test_scheduler_numeric_id_test( # Check for final results elif "Final results" in clean_line: match = re.search( - r"Timeouts: (\d+), Intervals: (\d+), Retries: (\d+), Defers: (\d+)", + r"Timeouts: (\d+), Intervals: (\d+), Defers: (\d+)", clean_line, ) if match: timeout_count = int(match.group(1)) interval_count = int(match.group(2)) - retry_count = int(match.group(3)) - defer_count = int(match.group(4)) + defer_count = int(match.group(3)) final_results_logged.set() async with ( @@ -200,23 +183,6 @@ async def test_scheduler_numeric_id_test( except TimeoutError: pytest.fail("Max ID timeout did not fire within 0.5 seconds") - # Wait for numeric retry tests - try: - await asyncio.wait_for(numeric_retry_done.wait(), timeout=1.0) - except TimeoutError: - pytest.fail( - f"Numeric retry 6001 did not complete. Count: {numeric_retry_count}" - ) - - assert numeric_retry_count >= 2, ( - f"Expected at least 2 numeric retry attempts, got {numeric_retry_count}" - ) - - # Verify numeric retry was cancelled - assert numeric_retry_cancelled.is_set(), ( - "Numeric retry 6002 should have been cancelled" - ) - # Wait for numeric defer tests try: await asyncio.wait_for(numeric_defer_7001_fired.wait(), timeout=0.5) @@ -245,7 +211,4 @@ async def test_scheduler_numeric_id_test( assert interval_count >= 3, ( f"Expected at least 3 interval fires, got {interval_count}" ) - assert retry_count >= 2, ( - f"Expected at least 2 retry attempts, got {retry_count}" - ) assert defer_count >= 2, f"Expected at least 2 defer fires, got {defer_count}" diff --git a/tests/integration/test_scheduler_retry_test.py b/tests/integration/test_scheduler_retry_test.py deleted file mode 100644 index 910034e5bb..0000000000 --- a/tests/integration/test_scheduler_retry_test.py +++ /dev/null @@ -1,279 +0,0 @@ -"""Test scheduler retry functionality.""" - -import asyncio -import re - -import pytest - -from .types import APIClientConnectedFactory, RunCompiledFunction - - -@pytest.mark.asyncio -async def test_scheduler_retry_test( - yaml_config: str, - run_compiled: RunCompiledFunction, - api_client_connected: APIClientConnectedFactory, -) -> None: - """Test that scheduler retry functionality works correctly.""" - # Track test progress - simple_retry_done = asyncio.Event() - backoff_retry_done = asyncio.Event() - immediate_done_done = asyncio.Event() - cancel_retry_done = asyncio.Event() - empty_name_retry_done = asyncio.Event() - component_retry_done = asyncio.Event() - multiple_name_done = asyncio.Event() - const_char_done = asyncio.Event() - static_char_done = asyncio.Event() - test_complete = asyncio.Event() - - # Track retry counts - simple_retry_count = 0 - backoff_retry_count = 0 - immediate_done_count = 0 - cancel_retry_count = 0 - empty_name_retry_count = 0 - component_retry_count = 0 - multiple_name_count = 0 - const_char_retry_count = 0 - static_char_retry_count = 0 - - # Track specific test results - cancel_result = None - empty_cancel_result = None - backoff_intervals = [] - - def on_log_line(line: str) -> None: - nonlocal simple_retry_count, backoff_retry_count, immediate_done_count - nonlocal cancel_retry_count, empty_name_retry_count, component_retry_count - nonlocal multiple_name_count, const_char_retry_count, static_char_retry_count - nonlocal cancel_result, empty_cancel_result - - # Strip ANSI color codes - clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) - - # Simple retry test - if "Simple retry attempt" in clean_line: - if match := re.search(r"Simple retry attempt (\d+)", clean_line): - simple_retry_count = int(match.group(1)) - - elif "Simple retry succeeded on attempt" in clean_line: - simple_retry_done.set() - - # Backoff retry test - elif "Backoff retry attempt" in clean_line: - if match := re.search( - r"Backoff retry attempt (\d+).*interval=(\d+)ms", clean_line - ): - backoff_retry_count = int(match.group(1)) - interval = int(match.group(2)) - if backoff_retry_count > 1: # Skip first (immediate) call - backoff_intervals.append(interval) - - elif "Backoff retry completed" in clean_line: - backoff_retry_done.set() - - # Immediate done test - elif "Immediate done retry called" in clean_line: - immediate_done_count += 1 - immediate_done_done.set() - - # Cancel retry test - elif "Cancel test retry attempt" in clean_line: - cancel_retry_count += 1 - - elif "Retry cancellation result:" in clean_line: - cancel_result = "true" in clean_line - cancel_retry_done.set() - - # Empty name retry test - elif "Empty name retry attempt" in clean_line: - if match := re.search(r"Empty name retry attempt (\d+)", clean_line): - empty_name_retry_count = int(match.group(1)) - - elif "Empty name retry cancel result:" in clean_line: - empty_cancel_result = "true" in clean_line - - elif "Empty name retry ran" in clean_line: - empty_name_retry_done.set() - - # Component retry test - elif "Component retry attempt" in clean_line: - if match := re.search(r"Component retry attempt (\d+)", clean_line): - component_retry_count = int(match.group(1)) - if component_retry_count >= 2: - component_retry_done.set() - - # Multiple same name test - elif "Second duplicate retry attempt" in clean_line: - if match := re.search(r"counter=(\d+)", clean_line): - multiple_name_count = int(match.group(1)) - if multiple_name_count >= 20: - multiple_name_done.set() - - # Const char retry test - elif "Const char retry" in clean_line: - if match := re.search(r"Const char retry (\d+)", clean_line): - const_char_retry_count = int(match.group(1)) - const_char_done.set() - - # Static const char retry test - elif "Static const char retry" in clean_line: - if match := re.search(r"Static const char retry (\d+)", clean_line): - static_char_retry_count = int(match.group(1)) - static_char_done.set() - - elif "Static cancel result:" in clean_line: - # This is part of test 9, but we don't track it separately - pass - - # Test completion - elif "All retry tests completed" in clean_line: - test_complete.set() - - async with ( - run_compiled(yaml_config, line_callback=on_log_line), - api_client_connected() as client, - ): - # Verify we can connect - device_info = await client.device_info() - assert device_info is not None - assert device_info.name == "scheduler-retry-test" - - # Wait for simple retry test - try: - await asyncio.wait_for(simple_retry_done.wait(), timeout=1.0) - except TimeoutError: - pytest.fail( - f"Simple retry test did not complete. Count: {simple_retry_count}" - ) - - assert simple_retry_count == 2, ( - f"Expected 2 simple retry attempts, got {simple_retry_count}" - ) - - # Wait for backoff retry test - try: - await asyncio.wait_for(backoff_retry_done.wait(), timeout=3.0) - except TimeoutError: - pytest.fail( - f"Backoff retry test did not complete. Count: {backoff_retry_count}" - ) - - assert backoff_retry_count == 4, ( - f"Expected 4 backoff retry attempts, got {backoff_retry_count}" - ) - - # Verify backoff intervals (allowing for timing variations) - assert len(backoff_intervals) >= 2, ( - f"Expected at least 2 intervals, got {len(backoff_intervals)}" - ) - if len(backoff_intervals) >= 3: - # First interval should be ~50ms (very wide tolerance for heavy system load) - assert 20 <= backoff_intervals[0] <= 150, ( - f"First interval {backoff_intervals[0]}ms not ~50ms" - ) - # Second interval should be ~100ms (50ms * 2.0) - assert 50 <= backoff_intervals[1] <= 250, ( - f"Second interval {backoff_intervals[1]}ms not ~100ms" - ) - # Third interval should be ~200ms (100ms * 2.0) - assert 100 <= backoff_intervals[2] <= 500, ( - f"Third interval {backoff_intervals[2]}ms not ~200ms" - ) - - # Wait for immediate done test - try: - await asyncio.wait_for(immediate_done_done.wait(), timeout=3.0) - except TimeoutError: - pytest.fail( - f"Immediate done test did not complete. Count: {immediate_done_count}" - ) - - assert immediate_done_count == 1, ( - f"Expected 1 immediate done call, got {immediate_done_count}" - ) - - # Wait for cancel retry test - try: - await asyncio.wait_for(cancel_retry_done.wait(), timeout=3.0) - except TimeoutError: - pytest.fail( - f"Cancel retry test did not complete. Count: {cancel_retry_count}" - ) - - assert cancel_result is True, "Retry cancellation should have succeeded" - assert 2 <= cancel_retry_count <= 5, ( - f"Expected 2-5 cancel retry attempts before cancellation, got {cancel_retry_count}" - ) - - # Wait for empty name retry test - try: - await asyncio.wait_for(empty_name_retry_done.wait(), timeout=1.0) - except TimeoutError: - pytest.fail( - f"Empty name retry test did not complete. Count: {empty_name_retry_count}" - ) - - # Empty name retry should run at least once before being cancelled - assert 1 <= empty_name_retry_count <= 3, ( - f"Expected 1-3 empty name retry attempts, got {empty_name_retry_count}" - ) - assert empty_cancel_result is True, ( - "Empty name retry cancel should have succeeded" - ) - - # Wait for component retry test - try: - await asyncio.wait_for(component_retry_done.wait(), timeout=1.0) - except TimeoutError: - pytest.fail( - f"Component retry test did not complete. Count: {component_retry_count}" - ) - - assert component_retry_count >= 2, ( - f"Expected at least 2 component retry attempts, got {component_retry_count}" - ) - - # Wait for multiple same name test - try: - await asyncio.wait_for(multiple_name_done.wait(), timeout=1.0) - except TimeoutError: - pytest.fail( - f"Multiple same name test did not complete. Count: {multiple_name_count}" - ) - - # Should be 20+ (only second retry should run) - assert multiple_name_count >= 20, ( - f"Expected multiple name count >= 20 (second retry only), got {multiple_name_count}" - ) - - # Wait for const char retry test - try: - await asyncio.wait_for(const_char_done.wait(), timeout=1.0) - except TimeoutError: - pytest.fail( - f"Const char retry test did not complete. Count: {const_char_retry_count}" - ) - - assert const_char_retry_count == 1, ( - f"Expected 1 const char retry call, got {const_char_retry_count}" - ) - - # Wait for static char retry test - try: - await asyncio.wait_for(static_char_done.wait(), timeout=1.0) - except TimeoutError: - pytest.fail( - f"Static char retry test did not complete. Count: {static_char_retry_count}" - ) - - assert static_char_retry_count == 1, ( - f"Expected 1 static char retry call, got {static_char_retry_count}" - ) - - # Wait for test completion - try: - await asyncio.wait_for(test_complete.wait(), timeout=1.0) - except TimeoutError: - pytest.fail("Test did not complete within timeout") From 49a1d2bb1b18583c6d75bf8fcdaccca30ebe5889 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 15 Jul 2026 23:08:14 -0500 Subject: [PATCH 0915/1815] [veml3235] Overhaul auto-gain and fix latent config bugs (#17551) Co-authored-by: Claude Fable 5 --- esphome/components/veml3235/sensor.py | 27 ++- esphome/components/veml3235/veml3235.cpp | 283 +++++++++++------------ esphome/components/veml3235/veml3235.h | 42 ++-- 3 files changed, 178 insertions(+), 174 deletions(-) diff --git a/esphome/components/veml3235/sensor.py b/esphome/components/veml3235/sensor.py index 862fac302f..08d3685d1f 100644 --- a/esphome/components/veml3235/sensor.py +++ b/esphome/components/veml3235/sensor.py @@ -22,13 +22,13 @@ veml3235_ns = cg.esphome_ns.namespace("veml3235") VEML3235Sensor = veml3235_ns.class_( "VEML3235Sensor", sensor.Sensor, cg.PollingComponent, i2c.I2CDevice ) -VEML3235IntegrationTime = veml3235_ns.enum("VEML3235IntegrationTime") +VEML3235ComponentIntegrationTime = veml3235_ns.enum("VEML3235ComponentIntegrationTime") VEML3235_INTEGRATION_TIMES = { - "50ms": VEML3235IntegrationTime.VEML3235_INTEGRATION_TIME_50MS, - "100ms": VEML3235IntegrationTime.VEML3235_INTEGRATION_TIME_100MS, - "200ms": VEML3235IntegrationTime.VEML3235_INTEGRATION_TIME_200MS, - "400ms": VEML3235IntegrationTime.VEML3235_INTEGRATION_TIME_400MS, - "800ms": VEML3235IntegrationTime.VEML3235_INTEGRATION_TIME_800MS, + "50ms": VEML3235ComponentIntegrationTime.VEML3235_INTEGRATION_TIME_50MS, + "100ms": VEML3235ComponentIntegrationTime.VEML3235_INTEGRATION_TIME_100MS, + "200ms": VEML3235ComponentIntegrationTime.VEML3235_INTEGRATION_TIME_200MS, + "400ms": VEML3235ComponentIntegrationTime.VEML3235_INTEGRATION_TIME_400MS, + "800ms": VEML3235ComponentIntegrationTime.VEML3235_INTEGRATION_TIME_800MS, } VEML3235ComponentDigitalGain = veml3235_ns.enum("VEML3235ComponentDigitalGain") DIGITAL_GAINS = { @@ -40,10 +40,18 @@ GAINS = { "1X": VEML3235ComponentGain.VEML3235_GAIN_1X, "2X": VEML3235ComponentGain.VEML3235_GAIN_2X, "4X": VEML3235ComponentGain.VEML3235_GAIN_4X, - "AUTO": VEML3235ComponentGain.VEML3235_GAIN_AUTO, } -CONFIG_SCHEMA = ( + +def _validate_auto_gain_thresholds(config): + if config[CONF_AUTO_GAIN_THRESHOLD_LOW] >= config[CONF_AUTO_GAIN_THRESHOLD_HIGH]: + raise cv.Invalid( + f"'{CONF_AUTO_GAIN_THRESHOLD_LOW}' must be less than '{CONF_AUTO_GAIN_THRESHOLD_HIGH}'" + ) + return config + + +CONFIG_SCHEMA = cv.All( sensor.sensor_schema( VEML3235Sensor, unit_of_measurement=UNIT_LUX, @@ -67,7 +75,8 @@ CONFIG_SCHEMA = ( } ) .extend(cv.polling_component_schema("60s")) - .extend(i2c.i2c_device_schema(0x10)) + .extend(i2c.i2c_device_schema(0x10)), + _validate_auto_gain_thresholds, ) diff --git a/esphome/components/veml3235/veml3235.cpp b/esphome/components/veml3235/veml3235.cpp index 59892936b0..b3170469b9 100644 --- a/esphome/components/veml3235/veml3235.cpp +++ b/esphome/components/veml3235/veml3235.cpp @@ -6,6 +6,16 @@ namespace esphome::veml3235 { static const char *const TAG = "veml3235.sensor"; +// ADC counts at or above this value (98% of full scale) are treated as clipped: the true light level cannot +// be estimated from such a reading, so auto-gain restarts from minimum sensitivity instead +static const uint16_t CLIPPED_COUNTS = 64224; + +// Maximum sensitivity multiplier: integration time 800 ms (16x) * gain 4x * digital gain 2x +static const uint16_t MAX_SENSITIVITY_FACTOR = 128; + +// At most one restart from clipping plus one proportional adjustment per update cycle +static const uint8_t MAX_ADJUSTMENTS_PER_UPDATE = 2; + void VEML3235Sensor::setup() { uint8_t device_id[] = {0, 0}; if (!this->refresh_config_reg()) { @@ -22,186 +32,156 @@ void VEML3235Sensor::setup() { } } -bool VEML3235Sensor::refresh_config_reg(bool force_on) { - uint16_t data = this->power_on_ || force_on ? 0 : SHUTDOWN_BITS; +bool VEML3235Sensor::refresh_config_reg() { + uint16_t data = 0x1; // mandatory 1 per RM; shutdown bits cleared (device powered on) - data |= (uint16_t(this->integration_time_ << CONFIG_REG_IT_BIT)); - data |= (uint16_t(this->digital_gain_ << CONFIG_REG_DG_BIT)); - data |= (uint16_t(this->gain_ << CONFIG_REG_G_BIT)); - data |= 0x1; // mandatory 1 here per RM + data |= (uint16_t(this->integration_time_) << CONFIG_REG_IT_BIT); + data |= (uint16_t(this->digital_gain_) << CONFIG_REG_DG_BIT); + data |= (uint16_t(this->gain_) << CONFIG_REG_G_BIT); ESP_LOGVV(TAG, "Writing 0x%.4x to register 0x%.2x", data, CONFIG_REG); return this->write_byte_16(CONFIG_REG, data); } -float VEML3235Sensor::read_lx_() { - if (!this->power_on_) { // if off, turn on - if (!this->refresh_config_reg(true)) { - ESP_LOGW(TAG, "Turning on failed"); - this->status_set_warning(); - return NAN; - } - delay(4); // from RM: a wait time of 4 ms should be observed before the first measurement is picked up, to allow - // for a correct start of the signal processor and oscillator +void VEML3235Sensor::update() { + if (this->measurement_in_progress_) { + ESP_LOGV(TAG, "'%s': Previous measurement still in progress; skipping update", this->get_name().c_str()); + return; } + this->measurement_in_progress_ = true; + this->read_and_publish_(MAX_ADJUSTMENTS_PER_UPDATE); +} +void VEML3235Sensor::read_and_publish_(uint8_t adjustments_left) { uint8_t als_regs[] = {0, 0}; if ((this->read_register(ALS_REG, als_regs, sizeof als_regs) != i2c::ERROR_OK)) { this->status_set_warning(); - return NAN; + this->publish_state(NAN); + this->measurement_in_progress_ = false; + return; } this->status_clear_warning(); - float als_raw_value_multiplier = LUX_MULTIPLIER_BASE; - uint16_t als_raw_value = encode_uint16(als_regs[1], als_regs[0]); - // determine multiplier value based on gains and integration time - if (this->digital_gain_ == VEML3235_DIGITAL_GAIN_1X) { - als_raw_value_multiplier *= 2; - } - switch (this->gain_) { - case VEML3235_GAIN_1X: - als_raw_value_multiplier *= 4; - break; - case VEML3235_GAIN_2X: - als_raw_value_multiplier *= 2; - break; - default: - break; - } - switch (this->integration_time_) { - case VEML3235_INTEGRATION_TIME_50MS: - als_raw_value_multiplier *= 16; - break; - case VEML3235_INTEGRATION_TIME_100MS: - als_raw_value_multiplier *= 8; - break; - case VEML3235_INTEGRATION_TIME_200MS: - als_raw_value_multiplier *= 4; - break; - case VEML3235_INTEGRATION_TIME_400MS: - als_raw_value_multiplier *= 2; - break; - default: - break; - } - // finally, determine and return the actual lux value - float lx = float(als_raw_value) * als_raw_value_multiplier; - ESP_LOGVV(TAG, "'%s': ALS raw = %u, multiplier = %.5f", this->get_name().c_str(), als_raw_value, - als_raw_value_multiplier); - ESP_LOGD(TAG, "'%s': Illuminance = %.4flx", this->get_name().c_str(), lx); + uint16_t als_counts = encode_uint16(als_regs[1], als_regs[0]); - if (!this->power_on_) { // turn off if required - if (!this->refresh_config_reg()) { - ESP_LOGW(TAG, "Turning off failed"); - this->status_set_warning(); + if (this->auto_gain_ && adjustments_left > 0) { + // A sample integrated with the previous settings may still be in the data register after the + // configuration changes, so wait out the old integration period plus two new ones before re-reading + const uint32_t old_integration_time_ms = this->integration_time_ms_(); + if (this->adjust_sensitivity_(als_counts)) { + const uint32_t wait_ms = old_integration_time_ms + 2 * this->integration_time_ms_(); + this->set_timeout("reread", wait_ms, + [this, adjustments_left]() { this->read_and_publish_(adjustments_left - 1); }); + return; } } - if (this->auto_gain_) { - this->adjust_gain_(als_raw_value); - } - - return lx; + float lux = this->counts_to_lux_(als_counts); + ESP_LOGVV(TAG, "'%s': ALS counts = %u, sensitivity = %ux", this->get_name().c_str(), als_counts, + this->sensitivity_factor_()); + ESP_LOGV(TAG, "'%s': Illuminance = %.4flx", this->get_name().c_str(), lux); + this->publish_state(lux); + this->measurement_in_progress_ = false; } -void VEML3235Sensor::adjust_gain_(const uint16_t als_raw_value) { - if ((als_raw_value > UINT16_MAX * this->auto_gain_threshold_low_) && - (als_raw_value < UINT16_MAX * this->auto_gain_threshold_high_)) { - return; +float VEML3235Sensor::counts_to_lux_(uint16_t counts) const { + float resolution = LUX_MULTIPLIER_BASE * (float(MAX_SENSITIVITY_FACTOR) / float(this->sensitivity_factor_())); + return float(counts) * resolution; +} + +uint8_t VEML3235Sensor::gain_factor_() const { + switch (this->gain_) { + case VEML3235_GAIN_4X: + return 4; + case VEML3235_GAIN_2X: + return 2; + default: + return 1; + } +} + +uint16_t VEML3235Sensor::sensitivity_factor_() const { + const uint8_t digital_gain_factor = this->digital_gain_ == VEML3235_DIGITAL_GAIN_2X ? 2 : 1; + return (1 << this->integration_time_) * this->gain_factor_() * digital_gain_factor; +} + +void VEML3235Sensor::set_sensitivity_factor_(uint16_t factor) { + // The factor is a power of two in [1, 128]. Prefer integration time (improves the signal-to-noise ratio), + // then analog gain; digital gain is a plain doubling of the output and is used only as a last resort. + uint8_t it_exponent = 0; // integration time is 2^n * 50 ms + while (it_exponent < VEML3235_INTEGRATION_TIME_800MS && (1u << it_exponent) < factor) { + it_exponent++; + } + this->integration_time_ = static_cast(it_exponent); + factor >>= it_exponent; + + if (factor >= 4) { + this->gain_ = VEML3235_GAIN_4X; + factor >>= 2; + } else if (factor == 2) { + this->gain_ = VEML3235_GAIN_2X; + factor >>= 1; + } else { + this->gain_ = VEML3235_GAIN_1X; } - if (als_raw_value >= UINT16_MAX * 0.9) { // over-saturated, reset all gains and start over - this->digital_gain_ = VEML3235_DIGITAL_GAIN_1X; - this->gain_ = VEML3235_GAIN_1X; - this->integration_time_ = VEML3235_INTEGRATION_TIME_50MS; - this->refresh_config_reg(); - return; + this->digital_gain_ = factor >= 2 ? VEML3235_DIGITAL_GAIN_2X : VEML3235_DIGITAL_GAIN_1X; +} + +bool VEML3235Sensor::adjust_sensitivity_(uint16_t counts) { + // Test for clipping before the window test: with an upper threshold configured at or above the clip + // point, a saturated reading would otherwise count as "in window" and sensitivity would never recover + const bool clipped = counts >= CLIPPED_COUNTS; + const uint16_t low = uint16_t(UINT16_MAX * this->auto_gain_threshold_low_); + const uint16_t high = uint16_t(UINT16_MAX * this->auto_gain_threshold_high_); + if (!clipped && counts >= low && counts <= high) { + return false; } - if (this->gain_ != VEML3235_GAIN_4X) { // increase gain if possible - switch (this->gain_) { - case VEML3235_GAIN_1X: - this->gain_ = VEML3235_GAIN_2X; - break; - case VEML3235_GAIN_2X: - this->gain_ = VEML3235_GAIN_4X; - break; - default: - break; + const uint16_t current_factor = this->sensitivity_factor_(); + uint16_t new_factor; + if (clipped) { + new_factor = 1; + } else if (counts == 0) { + new_factor = MAX_SENSITIVITY_FACTOR; + } else { + // Counts scale linearly with the sensitivity factor: in one step, pick the power of two that puts the + // next reading closest below the middle of the configured window. Rounding down means the target is + // never overshot, which also keeps the sensitivity stable when the window is narrower than one step. + float desired = float(current_factor) * ((float(low) + float(high)) * 0.5f / float(counts)); + desired = clamp(desired, 1.0f, float(MAX_SENSITIVITY_FACTOR)); + new_factor = 1; + while (new_factor * 2 <= uint16_t(desired)) { + new_factor *= 2; } - this->refresh_config_reg(); - return; } - // gain is maxed out; reset it and try to increase digital gain - if (this->digital_gain_ != VEML3235_DIGITAL_GAIN_2X) { // increase digital gain if possible - this->digital_gain_ = VEML3235_DIGITAL_GAIN_2X; - this->gain_ = VEML3235_GAIN_1X; - this->refresh_config_reg(); - return; + + if (new_factor == current_factor) { + return false; } - // digital gain is maxed out; reset it and try to increase integration time - if (this->integration_time_ != VEML3235_INTEGRATION_TIME_800MS) { // increase integration time if possible - switch (this->integration_time_) { - case VEML3235_INTEGRATION_TIME_50MS: - this->integration_time_ = VEML3235_INTEGRATION_TIME_100MS; - break; - case VEML3235_INTEGRATION_TIME_100MS: - this->integration_time_ = VEML3235_INTEGRATION_TIME_200MS; - break; - case VEML3235_INTEGRATION_TIME_200MS: - this->integration_time_ = VEML3235_INTEGRATION_TIME_400MS; - break; - case VEML3235_INTEGRATION_TIME_400MS: - this->integration_time_ = VEML3235_INTEGRATION_TIME_800MS; - break; - default: - break; - } - this->digital_gain_ = VEML3235_DIGITAL_GAIN_1X; - this->gain_ = VEML3235_GAIN_1X; - this->refresh_config_reg(); - return; + + const VEML3235ComponentIntegrationTime old_integration_time = this->integration_time_; + const VEML3235ComponentGain old_gain = this->gain_; + const VEML3235ComponentDigitalGain old_digital_gain = this->digital_gain_; + + this->set_sensitivity_factor_(new_factor); + if (!this->refresh_config_reg()) { + // Keep our state consistent with the device, which still has the old configuration + this->integration_time_ = old_integration_time; + this->gain_ = old_gain; + this->digital_gain_ = old_digital_gain; + this->status_set_warning(); + return false; } + + ESP_LOGV(TAG, "'%s': Sensitivity adjusted from %ux to %ux (ALS counts = %u)", this->get_name().c_str(), + current_factor, new_factor, counts); + return true; } void VEML3235Sensor::dump_config() { - uint8_t digital_gain = 1; - uint8_t gain = 1; - uint16_t integration_time = 0; - - if (this->digital_gain_ == VEML3235_DIGITAL_GAIN_2X) { - digital_gain = 2; - } - switch (this->gain_) { - case VEML3235_GAIN_2X: - gain = 2; - break; - case VEML3235_GAIN_4X: - gain = 4; - break; - default: - break; - } - switch (this->integration_time_) { - case VEML3235_INTEGRATION_TIME_50MS: - integration_time = 50; - break; - case VEML3235_INTEGRATION_TIME_100MS: - integration_time = 100; - break; - case VEML3235_INTEGRATION_TIME_200MS: - integration_time = 200; - break; - case VEML3235_INTEGRATION_TIME_400MS: - integration_time = 400; - break; - case VEML3235_INTEGRATION_TIME_800MS: - integration_time = 800; - break; - default: - break; - } + const uint8_t digital_gain = this->digital_gain_ == VEML3235_DIGITAL_GAIN_2X ? 2 : 1; LOG_SENSOR("", "VEML3235", this); LOG_I2C_DEVICE(this); @@ -212,8 +192,9 @@ void VEML3235Sensor::dump_config() { ESP_LOGCONFIG(TAG, " Auto-gain enabled: %s", YESNO(this->auto_gain_)); if (this->auto_gain_) { ESP_LOGCONFIG(TAG, - " Auto-gain upper threshold: %f%%\n" - " Auto-gain lower threshold: %f%%\n" + " Auto-gain thresholds:\n" + " Upper: %.0f%%\n" + " Lower: %.0f%%\n" " Values below will be used as initial values only", this->auto_gain_threshold_high_ * 100.0f, this->auto_gain_threshold_low_ * 100.0f); } @@ -221,7 +202,7 @@ void VEML3235Sensor::dump_config() { " Digital gain: %uX\n" " Gain: %uX\n" " Integration time: %ums", - digital_gain, gain, integration_time); + digital_gain, this->gain_factor_(), this->integration_time_ms_()); } } // namespace esphome::veml3235 diff --git a/esphome/components/veml3235/veml3235.h b/esphome/components/veml3235/veml3235.h index cda6d177aa..c19fc17b65 100644 --- a/esphome/components/veml3235/veml3235.h +++ b/esphome/components/veml3235/veml3235.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/hal.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" @@ -16,6 +15,10 @@ static const uint8_t ID_REG = 0x09; // Bit offsets within CONFIG_REG // +// The device expects the low data byte first while write_byte_16() sends the high byte first, so the 16-bit +// configuration word used here is byte-swapped relative to the datasheet: datasheet low-byte bits are word +// bits 15:8 here and datasheet high-byte bits are word bits 7:0. +// static const uint8_t CONFIG_REG_IT_BIT = 12; static const uint8_t CONFIG_REG_DG_BIT = 5; static const uint8_t CONFIG_REG_G_BIT = 3; @@ -23,18 +26,17 @@ static const uint8_t CONFIG_REG_G_BIT = 3; // Other important constants // static const uint8_t DEVICE_ID = 0x35; -static const uint16_t SHUTDOWN_BITS = 0x0018; -// Base multiplier value for lux computation +// Resolution (lx/count) at maximum sensitivity (integration time 800 ms, gain 4x, digital gain 2x) // -static const float LUX_MULTIPLIER_BASE = 0.00213; +static const float LUX_MULTIPLIER_BASE = 0.00213f; // Enum for conversion/integration time settings for the VEML3235. // // Specific values of the enum constants are register values taken from the VEML3235 datasheet. // Longer times mean more accurate results, but will take more energy/more time. // -enum VEML3235ComponentIntegrationTime { +enum VEML3235ComponentIntegrationTime : uint8_t { VEML3235_INTEGRATION_TIME_50MS = 0b000, VEML3235_INTEGRATION_TIME_100MS = 0b001, VEML3235_INTEGRATION_TIME_200MS = 0b010, @@ -45,7 +47,7 @@ enum VEML3235ComponentIntegrationTime { // Enum for digital gain settings for the VEML3235. // Higher values are better for low light situations, but can increase noise. // -enum VEML3235ComponentDigitalGain { +enum VEML3235ComponentDigitalGain : uint8_t { VEML3235_DIGITAL_GAIN_1X = 0b0, VEML3235_DIGITAL_GAIN_2X = 0b1, }; @@ -53,7 +55,7 @@ enum VEML3235ComponentDigitalGain { // Enum for gain settings for the VEML3235. // Higher values are better for low light situations, but can increase noise. // -enum VEML3235ComponentGain { +enum VEML3235ComponentGain : uint8_t { VEML3235_GAIN_1X = 0b00, VEML3235_GAIN_2X = 0b01, VEML3235_GAIN_4X = 0b11, @@ -63,7 +65,7 @@ class VEML3235Sensor final : public sensor::Sensor, public PollingComponent, pub public: void setup() override; void dump_config() override; - void update() override { this->publish_state(this->read_lx_()); } + void update() override; // Used by ESPHome framework. Does NOT actually set the value on the device. void set_auto_gain(bool auto_gain) { this->auto_gain_ = auto_gain; } @@ -73,7 +75,6 @@ class VEML3235Sensor final : public sensor::Sensor, public PollingComponent, pub void set_auto_gain_threshold_low(float auto_gain_threshold_low) { this->auto_gain_threshold_low_ = auto_gain_threshold_low; } - void set_power_on(bool power_on) { this->power_on_ = power_on; } void set_digital_gain(VEML3235ComponentDigitalGain digital_gain) { this->digital_gain_ = digital_gain; } void set_gain(VEML3235ComponentGain gain) { this->gain_ = gain; } void set_integration_time(VEML3235ComponentIntegrationTime integration_time) { @@ -88,19 +89,32 @@ class VEML3235Sensor final : public sensor::Sensor, public PollingComponent, pub VEML3235ComponentIntegrationTime integration_time() { return this->integration_time_; } // Updates the configuration register on the device - bool refresh_config_reg(bool force_on = false); + bool refresh_config_reg(); protected: - float read_lx_(); - void adjust_gain_(uint16_t als_raw_value); + // One measurement pass: reads the ALS counts, possibly adjusts the sensitivity and schedules a re-read, + // otherwise publishes the result + void read_and_publish_(uint8_t adjustments_left); + // Chooses a new sensitivity for the given ALS reading and writes it to the device. + // Returns true only if the device configuration was changed. + bool adjust_sensitivity_(uint16_t counts); + float counts_to_lux_(uint16_t counts) const; - bool auto_gain_{true}; - bool power_on_{true}; + // Overall sensitivity multiplier (1x-128x, always a power of two) relative to the least sensitive + // configuration (integration time 50 ms, gain 1x, digital gain 1x). ALS counts scale linearly with it. + uint16_t sensitivity_factor_() const; + void set_sensitivity_factor_(uint16_t factor); + uint8_t gain_factor_() const; + uint16_t integration_time_ms_() const { return 50 << this->integration_time_; } + + // Members are ordered largest to smallest to minimize padding float auto_gain_threshold_high_{0.9}; float auto_gain_threshold_low_{0.2}; VEML3235ComponentDigitalGain digital_gain_{VEML3235_DIGITAL_GAIN_1X}; VEML3235ComponentGain gain_{VEML3235_GAIN_1X}; VEML3235ComponentIntegrationTime integration_time_{VEML3235_INTEGRATION_TIME_50MS}; + bool auto_gain_{true}; + bool measurement_in_progress_{false}; }; } // namespace esphome::veml3235 From b12d392e02fed88fc1df881e460a78d2c8d11820 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Jul 2026 19:06:30 -1000 Subject: [PATCH 0916/1815] [esp32_ble] Remove deprecated ESPBTUUID::to_string() (#17590) --- esphome/components/esp32_ble/ble_uuid.cpp | 6 ------ esphome/components/esp32_ble/ble_uuid.h | 3 --- 2 files changed, 9 deletions(-) diff --git a/esphome/components/esp32_ble/ble_uuid.cpp b/esphome/components/esp32_ble/ble_uuid.cpp index 886f8237ad..3ce05b4310 100644 --- a/esphome/components/esp32_ble/ble_uuid.cpp +++ b/esphome/components/esp32_ble/ble_uuid.cpp @@ -181,12 +181,6 @@ const char *ESPBTUUID::to_str(std::span output) const { return output.data(); } } -std::string ESPBTUUID::to_string() const { - char buf[UUID_STR_LEN]; - this->to_str(buf); - return std::string(buf); -} - } // namespace esphome::esp32_ble #endif // USE_ESP32_BLE_UUID diff --git a/esphome/components/esp32_ble/ble_uuid.h b/esphome/components/esp32_ble/ble_uuid.h index 503fde6945..20b8f4e35a 100644 --- a/esphome/components/esp32_ble/ble_uuid.h +++ b/esphome/components/esp32_ble/ble_uuid.h @@ -46,9 +46,6 @@ class ESPBTUUID { esp_bt_uuid_t get_uuid() const; - // Remove before 2026.8.0 - ESPDEPRECATED("Use to_str() instead. Removed in 2026.8.0", "2026.2.0") - std::string to_string() const; // NOLINT const char *to_str(std::span output) const; protected: From f336c4517714d399af813c3da76984b4360c38d4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Jul 2026 19:06:38 -1000 Subject: [PATCH 0917/1815] [water_heater] Remove deprecated WaterHeaterCall::get_state() (#17592) --- esphome/components/water_heater/water_heater.h | 4 ---- 1 file changed, 4 deletions(-) diff --git a/esphome/components/water_heater/water_heater.h b/esphome/components/water_heater/water_heater.h index a1e1ca10a6..dfec26859f 100644 --- a/esphome/components/water_heater/water_heater.h +++ b/esphome/components/water_heater/water_heater.h @@ -90,10 +90,6 @@ class WaterHeaterCall { float get_target_temperature() const { return this->target_temperature_; } float get_target_temperature_low() const { return this->target_temperature_low_; } float get_target_temperature_high() const { return this->target_temperature_high_; } - /// Get state flags value - ESPDEPRECATED("get_state() is deprecated, use get_away() and get_on() instead. (Removed in 2026.8.0)", "2026.2.0") - uint32_t get_state() const { return this->state_; } - optional get_away() const { if (this->state_mask_ & WATER_HEATER_STATE_AWAY) { return (this->state_ & WATER_HEATER_STATE_AWAY) != 0; From 880cb1db4388e9a15e82789dac3c4f28ec339a4d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Jul 2026 19:06:49 -1000 Subject: [PATCH 0918/1815] [voice_assistant] Remove deprecated Timer::to_string() (#17591) --- esphome/components/voice_assistant/voice_assistant.h | 6 ------ 1 file changed, 6 deletions(-) diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index dd9d205aff..d46b089c2e 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -81,12 +81,6 @@ struct Timer { this->id.c_str(), this->name.c_str(), this->total_seconds, this->seconds_left, YESNO(this->is_active)); return buffer.data(); } - // Remove before 2026.8.0 - ESPDEPRECATED("Use to_str() instead. Removed in 2026.8.0", "2026.2.0") - std::string to_string() const { // NOLINT - char buffer[TO_STR_BUFFER_SIZE]; - return this->to_str(buffer); - } }; struct WakeWord { From ec3f7ec16e1c4c34540879eaa229a614989ca5c5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Jul 2026 19:07:26 -1000 Subject: [PATCH 0919/1815] [api] Remove outdated API version warning (#17593) --- esphome/components/api/api_connection.cpp | 6 ------ 1 file changed, 6 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 880b7cc404..1f7f59128a 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1746,12 +1746,6 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { ESP_LOGV(TAG, "Hello from client: '%s' | %s | API Version %" PRIu16 ".%" PRIu16, this->helper_->get_client_name(), this->helper_->get_peername_to(peername), this->client_api_version_major_, this->client_api_version_minor_); - // TODO: Remove before 2026.8.0 (one version after get_object_id backward compat removal) - if (!this->client_supports_api_version(1, 14)) { - ESP_LOGW(TAG, "'%s' using outdated API %" PRIu16 ".%" PRIu16 ", update to 1.14+", this->helper_->get_client_name(), - this->client_api_version_major_, this->client_api_version_minor_); - } - HelloResponse resp; resp.api_version_major = 1; resp.api_version_minor = 14; From f88621f2e169b2b7d3ea8a0af2ea244d023563cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Jul 2026 19:12:06 -1000 Subject: [PATCH 0920/1815] [network] Remove deprecated IPAddress::str() (#17589) --- esphome/components/network/ip_address.h | 27 ------------------------- 1 file changed, 27 deletions(-) diff --git a/esphome/components/network/ip_address.h b/esphome/components/network/ip_address.h index d8a127f4a0..ec1a8c7a07 100644 --- a/esphome/components/network/ip_address.h +++ b/esphome/components/network/ip_address.h @@ -71,15 +71,6 @@ struct IPAddress { bool is_ip4() const { return false; } bool is_ip6() const { return this->is_set(); } bool is_multicast() const { return net_ipv6_is_addr_mcast(&ip_addr_); } - // Remove before 2026.8.0 - ESPDEPRECATED( - "str() is deprecated: use 'char buf[IP_ADDRESS_BUFFER_SIZE]; ip.str_to(buf);' instead. Removed in 2026.8.0", - "2026.2.0") - std::string str() const { - char buf[IP_ADDRESS_BUFFER_SIZE]; - this->str_to(buf); - return buf; - } char *str_to(char *buf) const { if (inet_ntop(AF_INET6, &ip_addr_, buf, IP_ADDRESS_BUFFER_SIZE) == nullptr) buf[0] = '\0'; @@ -95,15 +86,6 @@ struct IPAddress { } IPAddress(const std::string &in_address) { inet_aton(in_address.c_str(), &ip_addr_); } IPAddress(const ip_addr_t *other_ip) { ip_addr_ = *other_ip; } - // Remove before 2026.8.0 - ESPDEPRECATED( - "str() is deprecated: use 'char buf[IP_ADDRESS_BUFFER_SIZE]; ip.str_to(buf);' instead. Removed in 2026.8.0", - "2026.2.0") - std::string str() const { - char buf[IP_ADDRESS_BUFFER_SIZE]; - this->str_to(buf); - return buf; - } /// Write IP address to buffer. Buffer must be at least IP_ADDRESS_BUFFER_SIZE bytes. char *str_to(char *buf) const { inet_ntop(AF_INET, &ip_addr_, buf, IP_ADDRESS_BUFFER_SIZE); @@ -186,15 +168,6 @@ struct IPAddress { bool is_ip4() const { return IP_IS_V4(&ip_addr_); } bool is_ip6() const { return IP_IS_V6(&ip_addr_); } bool is_multicast() const { return ip_addr_ismulticast(&ip_addr_); } - // Remove before 2026.8.0 - ESPDEPRECATED( - "str() is deprecated: use 'char buf[IP_ADDRESS_BUFFER_SIZE]; ip.str_to(buf);' instead. Removed in 2026.8.0", - "2026.2.0") - std::string str() const { - char buf[IP_ADDRESS_BUFFER_SIZE]; - this->str_to(buf); - return buf; - } /// Write IP address to buffer. Buffer must be at least IP_ADDRESS_BUFFER_SIZE bytes. /// Output is lowercased per RFC 5952 (IPv6 hex digits a-f). char *str_to(char *buf) const { From 0887e01828686eefe8c57194488f8f3edc9232c5 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Thu, 16 Jul 2026 00:17:01 -0500 Subject: [PATCH 0921/1815] [improv_base] Bump Improv library to 1.2.6 (#17599) Co-authored-by: Claude Opus 4.8 (1M context) --- esphome/components/improv_base/__init__.py | 2 +- platformio.ini | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/improv_base/__init__.py b/esphome/components/improv_base/__init__.py index e175aa2220..5929f2b60a 100644 --- a/esphome/components/improv_base/__init__.py +++ b/esphome/components/improv_base/__init__.py @@ -42,4 +42,4 @@ async def setup_improv_core(var: MockObj, config: ConfigType, component: str): cg.add(var.set_next_url(_process_next_url(next_url))) cg.add_define(f"USE_{component.upper()}_NEXT_URL") - cg.add_library("improv/Improv", "1.2.4") + cg.add_library("improv/Improv", "1.2.6") diff --git a/platformio.ini b/platformio.ini index 7e8494aea6..30968e80e8 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} esphome/noise-c@0.1.11 ; api - improv/Improv@1.2.4 ; improv_serial / esp32_improv + improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; online_image From 8d1a0446a2b423ce2788e356253af44091803268 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Jul 2026 07:25:37 -0400 Subject: [PATCH 0922/1815] [haier][teleinfo][hlk_fm22x][rp2040_ble] Rename enum members that collide with vendor SDK macros (#17595) --- esphome/components/haier/haier_base.cpp | 4 ++-- esphome/components/haier/haier_base.h | 8 ++++---- esphome/components/haier/hon_climate.cpp | 12 ++++++------ esphome/components/haier/hon_climate.h | 2 +- esphome/components/haier/smartair2_climate.cpp | 4 ++-- esphome/components/hlk_fm22x/hlk_fm22x.cpp | 2 +- esphome/components/hlk_fm22x/hlk_fm22x.h | 2 +- esphome/components/rp2040_ble/rp2040_ble.cpp | 4 ++-- esphome/components/rp2040_ble/rp2040_ble.h | 4 ++-- esphome/components/teleinfo/teleinfo.cpp | 14 +++++++------- esphome/components/teleinfo/teleinfo.h | 6 +++--- 11 files changed, 31 insertions(+), 31 deletions(-) diff --git a/esphome/components/haier/haier_base.cpp b/esphome/components/haier/haier_base.cpp index 74a218263d..294aa53b03 100644 --- a/esphome/components/haier/haier_base.cpp +++ b/esphome/components/haier/haier_base.cpp @@ -132,7 +132,7 @@ void HaierClimateBase::save_settings() { } bool HaierClimateBase::get_display_state() const { - return (this->display_status_ == SwitchState::ON) || (this->display_status_ == SwitchState::PENDING_ON); + return (this->display_status_ == SwitchState::SWITCH_ON) || (this->display_status_ == SwitchState::PENDING_ON); } void HaierClimateBase::set_display_state(bool state) { @@ -144,7 +144,7 @@ void HaierClimateBase::set_display_state(bool state) { } bool HaierClimateBase::get_health_mode() const { - return (this->health_mode_ == SwitchState::ON) || (this->health_mode_ == SwitchState::PENDING_ON); + return (this->health_mode_ == SwitchState::SWITCH_ON) || (this->health_mode_ == SwitchState::PENDING_ON); } void HaierClimateBase::set_health_mode(bool state) { diff --git a/esphome/components/haier/haier_base.h b/esphome/components/haier/haier_base.h index 13e8d7548d..db4c1abceb 100644 --- a/esphome/components/haier/haier_base.h +++ b/esphome/components/haier/haier_base.h @@ -147,8 +147,8 @@ class HaierClimateBase : public esphome::Component, esphome::optional message; }; enum class SwitchState { - OFF = 0b00, - ON = 0b01, + SWITCH_OFF = 0b00, + SWITCH_ON = 0b01, PENDING_OFF = 0b10, PENDING_ON = 0b11, }; @@ -157,8 +157,8 @@ class HaierClimateBase : public esphome::Component, esphome::optional action_request_; uint8_t fan_mode_speed_; uint8_t other_modes_fan_speed_; - SwitchState display_status_{SwitchState::ON}; - SwitchState health_mode_{SwitchState::OFF}; + SwitchState display_status_{SwitchState::SWITCH_ON}; + SwitchState health_mode_{SwitchState::SWITCH_OFF}; bool force_send_control_; bool forced_request_status_; bool reset_protocol_request_; diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index 88d446829a..881a2328cb 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -50,7 +50,7 @@ void HonClimate::set_quiet_mode_state(bool state) { this->quiet_mode_state_ = state ? SwitchState::PENDING_ON : SwitchState::PENDING_OFF; this->force_send_control_ = true; } else { - this->quiet_mode_state_ = state ? SwitchState::ON : SwitchState::OFF; + this->quiet_mode_state_ = state ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; } this->settings_.quiet_mode_state = state; #ifdef USE_SWITCH @@ -63,7 +63,7 @@ void HonClimate::set_quiet_mode_state(bool state) { } bool HonClimate::get_quiet_mode_state() const { - return (this->quiet_mode_state_ == SwitchState::ON) || (this->quiet_mode_state_ == SwitchState::PENDING_ON); + return (this->quiet_mode_state_ == SwitchState::SWITCH_ON) || (this->quiet_mode_state_ == SwitchState::PENDING_ON); } esphome::optional HonClimate::get_vertical_airflow() const { @@ -513,7 +513,7 @@ void HonClimate::initialization() { } this->current_vertical_swing_ = this->settings_.last_vertiacal_swing; this->current_horizontal_swing_ = this->settings_.last_horizontal_swing; - this->quiet_mode_state_ = this->settings_.quiet_mode_state ? SwitchState::ON : SwitchState::OFF; + this->quiet_mode_state_ = this->settings_.quiet_mode_state ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; } haier_protocol::HaierMessage HonClimate::get_control_message() { @@ -939,14 +939,14 @@ haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t * // AC just turned on from remote need to turn off display this->force_send_control_ = true; } else if ((((uint8_t) this->display_status_) & 0b10) == 0) { - this->display_status_ = disp_status ? SwitchState::ON : SwitchState::OFF; + this->display_status_ = disp_status ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; } } } // Health mode if ((((uint8_t) this->health_mode_) & 0b10) == 0) { bool old_health_mode = this->get_health_mode(); - this->health_mode_ = packet.control.health_mode == 1 ? SwitchState::ON : SwitchState::OFF; + this->health_mode_ = packet.control.health_mode == 1 ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; should_publish = should_publish || (old_health_mode != this->get_health_mode()); } { @@ -1008,7 +1008,7 @@ haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t * // In proper mode and not in pending state bool new_quiet_mode = packet.control.quiet_mode != 0; if (new_quiet_mode != this->get_quiet_mode_state()) { - this->quiet_mode_state_ = new_quiet_mode ? SwitchState::ON : SwitchState::OFF; + this->quiet_mode_state_ = new_quiet_mode ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; this->settings_.quiet_mode_state = new_quiet_mode; #ifdef USE_SWITCH if (this->quiet_mode_switch_ != nullptr) { diff --git a/esphome/components/haier/hon_climate.h b/esphome/components/haier/hon_climate.h index ba36e6a8fb..a34b4422c6 100644 --- a/esphome/components/haier/hon_climate.h +++ b/esphome/components/haier/hon_climate.h @@ -197,7 +197,7 @@ class HonClimate final : public HaierClimateBase { esphome::optional current_horizontal_swing_{}; HonSettings settings_{}; ESPPreferenceObject hon_rtc_; - SwitchState quiet_mode_state_{SwitchState::OFF}; + SwitchState quiet_mode_state_{SwitchState::SWITCH_OFF}; }; } // namespace esphome::haier diff --git a/esphome/components/haier/smartair2_climate.cpp b/esphome/components/haier/smartair2_climate.cpp index a013371649..fdb3b779e2 100644 --- a/esphome/components/haier/smartair2_climate.cpp +++ b/esphome/components/haier/smartair2_climate.cpp @@ -464,14 +464,14 @@ haier_protocol::HandlerError Smartair2Climate::process_status_message_(const uin // AC just turned on from remote need to turn off display this->force_send_control_ = true; } else if ((((uint8_t) this->health_mode_) & 0b10) == 0) { - this->display_status_ = disp_status ? SwitchState::ON : SwitchState::OFF; + this->display_status_ = disp_status ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; } } } // Health mode if ((((uint8_t) this->health_mode_) & 0b10) == 0) { bool old_health_mode = this->get_health_mode(); - this->health_mode_ = packet.control.health_mode == 1 ? SwitchState::ON : SwitchState::OFF; + this->health_mode_ = packet.control.health_mode == 1 ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; should_publish = should_publish || (old_health_mode != this->get_health_mode()); } { diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.cpp b/esphome/components/hlk_fm22x/hlk_fm22x.cpp index 7a0dc0690c..964d26dfbc 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.cpp +++ b/esphome/components/hlk_fm22x/hlk_fm22x.cpp @@ -242,7 +242,7 @@ void HlkFm22xComponent::handle_reply_(const uint8_t *data, size_t length) { return; } - if (data[1] != HlkFm22xResult::SUCCESS) { + if (data[1] != HlkFm22xResult::SUCCEEDED) { ESP_LOGE(TAG, "Command <0x%.2X> failed. Error: 0x%.2X", data[0], data[1]); switch (expected) { case HlkFm22xCommand::ENROLL: diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.h b/esphome/components/hlk_fm22x/hlk_fm22x.h index 34246f52f0..3bdf6e2c71 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.h +++ b/esphome/components/hlk_fm22x/hlk_fm22x.h @@ -41,7 +41,7 @@ enum HlkFm22xNoteType { }; enum HlkFm22xResult { - SUCCESS = 0x00, + SUCCEEDED = 0x00, REJECTED = 0x01, ABORTED = 0x02, FAILED4_CAMERA = 0x04, diff --git a/esphome/components/rp2040_ble/rp2040_ble.cpp b/esphome/components/rp2040_ble/rp2040_ble.cpp index dca0cd4653..f3896f7b9c 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.cpp +++ b/esphome/components/rp2040_ble/rp2040_ble.cpp @@ -48,7 +48,7 @@ void RP2040BLE::enable() { } void RP2040BLE::disable() { - if (this->state_ == BLEComponentState::DISABLED || this->state_ == BLEComponentState::OFF) { + if (this->state_ == BLEComponentState::DISABLED || this->state_ == BLEComponentState::STATE_OFF) { return; } @@ -70,7 +70,7 @@ void RP2040BLE::loop() { static const char *state_to_str(BLEComponentState state) { switch (state) { - case BLEComponentState::OFF: + case BLEComponentState::STATE_OFF: return "OFF"; case BLEComponentState::ENABLING: return "ENABLING"; diff --git a/esphome/components/rp2040_ble/rp2040_ble.h b/esphome/components/rp2040_ble/rp2040_ble.h index e9df12cfb1..a77b5fc26c 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.h +++ b/esphome/components/rp2040_ble/rp2040_ble.h @@ -11,7 +11,7 @@ namespace esphome::rp2040_ble { enum class BLEComponentState : uint8_t { - OFF = 0, + STATE_OFF = 0, ENABLING, ACTIVE, DISABLING, @@ -37,7 +37,7 @@ class RP2040BLE final : public Component { btstack_packet_callback_registration_t hci_event_callback_registration_{}; btstack_packet_callback_registration_t sm_event_callback_registration_{}; - BLEComponentState state_{BLEComponentState::OFF}; + BLEComponentState state_{BLEComponentState::STATE_OFF}; bool enable_on_boot_{true}; bool btstack_initialized_{false}; bool active_logged_{false}; diff --git a/esphome/components/teleinfo/teleinfo.cpp b/esphome/components/teleinfo/teleinfo.cpp index cd2ddbbb38..e00895d162 100644 --- a/esphome/components/teleinfo/teleinfo.cpp +++ b/esphome/components/teleinfo/teleinfo.cpp @@ -57,7 +57,7 @@ bool TeleInfo::read_chars_until_(bool drop, uint8_t c) { */ if (buf_index_ >= (MAX_BUF_SIZE - 1)) { ESP_LOGW(TAG, "Internal buffer full"); - state_ = OFF; + state_ = STATE_OFF; return false; } buf_[buf_index_++] = received; @@ -65,18 +65,18 @@ bool TeleInfo::read_chars_until_(bool drop, uint8_t c) { return false; } -void TeleInfo::setup() { state_ = OFF; } +void TeleInfo::setup() { state_ = STATE_OFF; } void TeleInfo::update() { - if (state_ == OFF) { + if (state_ == STATE_OFF) { buf_index_ = 0; - state_ = ON; + state_ = STATE_ON; } } void TeleInfo::loop() { switch (state_) { - case OFF: + case STATE_OFF: break; - case ON: + case STATE_ON: /* Dequeue chars until start frame (0x2) */ if (read_chars_until_(true, 0x2)) state_ = START_FRAME_RECEIVED; @@ -173,7 +173,7 @@ void TeleInfo::loop() { publish_value_(std::string(tag_), std::string(val_)); } - state_ = OFF; + state_ = STATE_OFF; break; } } diff --git a/esphome/components/teleinfo/teleinfo.h b/esphome/components/teleinfo/teleinfo.h index 83ea1474f2..4aab3bf2cd 100644 --- a/esphome/components/teleinfo/teleinfo.h +++ b/esphome/components/teleinfo/teleinfo.h @@ -40,11 +40,11 @@ class TeleInfo final : public PollingComponent, public uart::UARTDevice { char val_[MAX_VAL_SIZE]; char timestamp_[MAX_TIMESTAMP_SIZE]; enum State { - OFF, - ON, + STATE_OFF, + STATE_ON, START_FRAME_RECEIVED, END_FRAME_RECEIVED, - } state_{OFF}; + } state_{STATE_OFF}; bool read_chars_until_(bool drop, uint8_t c); bool check_crc_(const char *grp, const char *grp_end); void publish_value_(const std::string &tag, const std::string &val); From d748c04b28d9248121ddda6a62a3c096d93ef670 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Jul 2026 07:25:46 -0400 Subject: [PATCH 0923/1815] [libretiny] Code fixes for upcoming LibreTiny clang-tidy scans (#17596) --- .../beken_spi_led_strip/led_strip.cpp | 53 ++++++++++--------- esphome/components/debug/debug_libretiny.cpp | 4 +- esphome/components/deep_sleep/__init__.py | 2 +- .../deep_sleep/deep_sleep_bk72xx.cpp | 12 ++--- .../deep_sleep/deep_sleep_component.h | 4 +- .../components/fastled_base/fastled_light.cpp | 2 +- .../components/fastled_base/fastled_light.h | 2 +- .../http_request/http_request_arduino.cpp | 2 +- .../http_request/http_request_arduino.h | 2 +- esphome/components/i2c/i2c_bus_arduino.cpp | 2 +- esphome/components/i2c/i2c_bus_arduino.h | 2 +- esphome/components/libretiny/hal.cpp | 2 +- esphome/components/libretiny/hal.h | 3 ++ esphome/components/libretiny/lt_component.cpp | 4 +- .../components/libretiny/preference_backend.h | 2 + .../logger/task_log_buffer_libretiny.cpp | 12 ++--- .../logger/task_log_buffer_libretiny.h | 2 +- esphome/components/mdns/mdns_libretiny.cpp | 4 +- esphome/components/nextion/nextion.h | 4 +- esphome/components/spi/spi.h | 2 +- .../uart/uart_component_libretiny.cpp | 33 ++++++------ esphome/components/wled/wled_light_effect.cpp | 2 +- esphome/components/wled/wled_light_effect.h | 2 +- 23 files changed, 85 insertions(+), 74 deletions(-) diff --git a/esphome/components/beken_spi_led_strip/led_strip.cpp b/esphome/components/beken_spi_led_strip/led_strip.cpp index 4e22489844..9e14615d7a 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.cpp +++ b/esphome/components/beken_spi_led_strip/led_strip.cpp @@ -37,15 +37,15 @@ namespace esphome::beken_spi_led_strip { static const char *const TAG = "beken_spi_led_strip"; -struct spi_data_t { +struct SpiData { SemaphoreHandle_t dma_tx_semaphore; volatile bool tx_in_progress; bool first_run; }; -static spi_data_t *spi_data = nullptr; +static SpiData *spi_data = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static void set_spi_ctrl_register(unsigned long bit, bool val) { +static void set_spi_ctrl_register(uint32_t bit, bool val) { uint32_t value = REG_READ(SPI_CTRL); if (val == 0) { value &= ~bit; @@ -55,7 +55,7 @@ static void set_spi_ctrl_register(unsigned long bit, bool val) { REG_WRITE(SPI_CTRL, value); } -static void set_spi_config_register(unsigned long bit, bool val) { +static void set_spi_config_register(uint32_t bit, bool val) { uint32_t value = REG_READ(SPI_CONFIG); if (val == 0) { value &= ~bit; @@ -67,7 +67,7 @@ static void set_spi_config_register(unsigned long bit, bool val) { void spi_dma_tx_enable(bool enable) { GDMA_CFG_ST en_cfg; - set_spi_config_register(SPI_TX_EN, enable ? 1 : 0); + set_spi_config_register(SPI_TX_EN, enable); en_cfg.channel = SPI_TX_DMA_CHANNEL; en_cfg.param = enable ? 1 : 0; sddev_control(GDMA_DEV_NAME, CMD_GDMA_SET_DMA_ENABLE, &en_cfg); @@ -110,13 +110,13 @@ static void spi_set_clock(uint32_t max_hz) { param &= ~(SPI_CKR_MASK << SPI_CKR_POSI); param |= (div << SPI_CKR_POSI); REG_WRITE(SPI_CTRL, param); - ESP_LOGD(TAG, "target frequency: %d, actual frequency: %d", max_hz, source_clk / 2 / div); + ESP_LOGD(TAG, "target frequency: %" PRIu32 ", actual frequency: %d", max_hz, source_clk / 2 / div); } void spi_dma_tx_finish_callback(unsigned int param) { spi_data->tx_in_progress = false; xSemaphoreGive(spi_data->dma_tx_semaphore); - spi_dma_tx_enable(0); + spi_dma_tx_enable(false); } void BekenSPILEDStripLightOutput::setup() { @@ -161,7 +161,7 @@ void BekenSPILEDStripLightOutput::setup() { return; } - spi_data = (spi_data_t *) calloc(1, sizeof(spi_data_t)); + spi_data = (SpiData *) calloc(1, sizeof(SpiData)); // NOLINT(cppcoreguidelines-no-malloc) if (spi_data == nullptr) { ESP_LOGE(TAG, "Cannot allocate spi_data!"); this->mark_failed(); @@ -177,20 +177,20 @@ void BekenSPILEDStripLightOutput::setup() { spi_data->first_run = true; - set_spi_ctrl_register(MSTEN, 0); - set_spi_ctrl_register(BIT_WDTH, 0); + set_spi_ctrl_register(MSTEN, false); + set_spi_ctrl_register(BIT_WDTH, false); spi_set_clock(this->spi_frequency_); - set_spi_ctrl_register(CKPOL, 0); - set_spi_ctrl_register(CKPHA, 0); - set_spi_ctrl_register(MSTEN, 1); - set_spi_ctrl_register(SPIEN, 1); + set_spi_ctrl_register(CKPOL, false); + set_spi_ctrl_register(CKPHA, false); + set_spi_ctrl_register(MSTEN, true); + set_spi_ctrl_register(SPIEN, true); - set_spi_ctrl_register(TXINT_EN, 0); - set_spi_ctrl_register(RXINT_EN, 0); - set_spi_config_register(SPI_TX_FINISH_EN, 1); - set_spi_config_register(SPI_RX_FINISH_EN, 1); - set_spi_ctrl_register(RXOVR_EN, 0); - set_spi_ctrl_register(TXOVR_EN, 0); + set_spi_ctrl_register(TXINT_EN, false); + set_spi_ctrl_register(RXINT_EN, false); + set_spi_config_register(SPI_TX_FINISH_EN, true); + set_spi_config_register(SPI_RX_FINISH_EN, true); + set_spi_ctrl_register(RXOVR_EN, false); + set_spi_ctrl_register(TXOVR_EN, false); value = REG_READ(SPI_CTRL); value &= ~CTRL_NSSMD_3; @@ -199,7 +199,7 @@ void BekenSPILEDStripLightOutput::setup() { value = GFUNC_MODE_SPI_DMA; sddev_control(GPIO_DEV_NAME, CMD_GPIO_ENABLE_SECOND, &value); - set_spi_ctrl_register(SPI_S_CS_UP_INT_EN, 0); + set_spi_ctrl_register(SPI_S_CS_UP_INT_EN, false); GDMA_CFG_ST en_cfg; GDMACFG_TPYES_ST init_cfg; @@ -210,7 +210,7 @@ void BekenSPILEDStripLightOutput::setup() { init_cfg.dstptr_incr = 0; init_cfg.srcptr_incr = 1; init_cfg.src_start_addr = this->dma_buf_; - init_cfg.dst_start_addr = (void *) SPI_DAT; // SPI_DMA_REG4_TXFIFO + init_cfg.dst_start_addr = (void *) SPI_DAT; // NOLINT(performance-no-int-to-ptr) SPI_DMA_REG4_TXFIFO init_cfg.channel = SPI_TX_DMA_CHANNEL; init_cfg.prio = 0; // 10 init_cfg.u.type4.src_loop_start_addr = this->dma_buf_; @@ -230,7 +230,7 @@ void BekenSPILEDStripLightOutput::setup() { en_cfg.param = 0; sddev_control(GDMA_DEV_NAME, CMD_GDMA_CFG_SRCADDR_LOOP, &en_cfg); - spi_dma_tx_enable(0); + spi_dma_tx_enable(false); value = REG_READ(SPI_CONFIG); value &= ~(0xFFF << 8); @@ -247,7 +247,8 @@ void BekenSPILEDStripLightOutput::set_led_params(uint8_t bit0, uint8_t bit1, uin void BekenSPILEDStripLightOutput::write_state(light::LightState *state) { // protect from refreshing too often uint32_t now = micros(); - if (*this->max_refresh_rate_ != 0 && (now - this->last_refresh_) < *this->max_refresh_rate_) { + if (this->max_refresh_rate_.has_value() && *this->max_refresh_rate_ != 0 && + (now - this->last_refresh_) < *this->max_refresh_rate_) { // try again next loop iteration, so that this change won't get lost this->schedule_show(); return; @@ -293,7 +294,7 @@ void BekenSPILEDStripLightOutput::write_state(light::LightState *state) { } spi_data->first_run = false; - spi_dma_tx_enable(1); + spi_dma_tx_enable(true); this->status_clear_warning(); } @@ -376,7 +377,7 @@ void BekenSPILEDStripLightOutput::dump_config() { " RGB Order: %s\n" " Max refresh rate: %" PRIu32 "\n" " Number of LEDs: %u", - rgb_order, *this->max_refresh_rate_, this->num_leds_); + rgb_order, this->max_refresh_rate_.value_or(0), this->num_leds_); } float BekenSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/debug/debug_libretiny.cpp b/esphome/components/debug/debug_libretiny.cpp index 1cc04dcbd8..55b29310a1 100644 --- a/esphome/components/debug/debug_libretiny.cpp +++ b/esphome/components/debug/debug_libretiny.cpp @@ -28,7 +28,7 @@ size_t DebugComponent::get_device_info_(std::span ESP_LOGD(TAG, "LibreTiny debug info:\n" " Version: %s\n" - " Chip: %s (%04x) @ %u MHz\n" + " Chip: %s (%04x) @ %" PRIu32 " MHz\n" " Chip ID: 0x%06" PRIX32 "\n" " Board: %s\n" " Flash: %" PRIu32 " KiB\n" @@ -38,7 +38,7 @@ size_t DebugComponent::get_device_info_(std::span lt_get_board_code(), flash_kib, ram_kib, reset_reason); pos = buf_append_str(buf, size, pos, "|Version: "); - pos = buf_append_str(buf, size, pos, LT_BANNER_STR + 10); + pos = buf_append_str(buf, size, pos, <_BANNER_STR[10]); pos = buf_append_str(buf, size, pos, "|Reset Reason: "); pos = buf_append_str(buf, size, pos, reset_reason); pos = buf_append_str(buf, size, pos, "|Chip Name: "); diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 83eff496ac..3b70f947d2 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -354,7 +354,7 @@ async def to_code(config): if CONF_WAKEUP_PIN in config: pins_as_list = config.get(CONF_WAKEUP_PIN, []) if CORE.is_bk72xx: - cg.add(var.init_wakeup_pins_(len(pins_as_list))) + cg.add(var.init_wakeup_pins(len(pins_as_list))) for item in pins_as_list: cg.add( var.add_wakeup_pin( diff --git a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp index 5595b0ba89..73e0331c76 100644 --- a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp +++ b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp @@ -30,15 +30,15 @@ void DeepSleepComponent::dump_config_platform_() { } } -bool DeepSleepComponent::pin_prevents_sleep_(WakeUpPinItem &pinItem) const { - return (pinItem.wakeup_pin_mode == WAKEUP_PIN_MODE_KEEP_AWAKE && pinItem.wakeup_pin != nullptr && - !this->sleep_duration_.has_value() && (pinItem.wakeup_level == get_real_pin_state_(*pinItem.wakeup_pin))); +bool DeepSleepComponent::pin_prevents_sleep_(WakeUpPinItem &pin_item) const { + return (pin_item.wakeup_pin_mode == WAKEUP_PIN_MODE_KEEP_AWAKE && pin_item.wakeup_pin != nullptr && + !this->sleep_duration_.has_value() && (pin_item.wakeup_level == get_real_pin_state_(*pin_item.wakeup_pin))); } bool DeepSleepComponent::prepare_to_sleep_() { - if (wakeup_pins_.size() > 0) { + if (!this->wakeup_pins_.empty()) { for (WakeUpPinItem &item : this->wakeup_pins_) { - if (pin_prevents_sleep_(item)) { + if (this->pin_prevents_sleep_(item)) { // Defer deep sleep until inactive if (!this->next_enter_deep_sleep_) { this->status_set_warning(); @@ -59,7 +59,7 @@ void DeepSleepComponent::deep_sleep_() { item.wakeup_level = !item.wakeup_level; } } - ESP_LOGI(TAG, "Wake-up on P%u %s (%d)", item.wakeup_pin->get_pin(), item.wakeup_level ? "HIGH" : "LOW", + ESP_LOGI(TAG, "Wake-up on P%u %s (%" PRId32 ")", item.wakeup_pin->get_pin(), item.wakeup_level ? "HIGH" : "LOW", static_cast(item.wakeup_pin_mode)); } diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 05e18f8c38..a620d52a02 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -143,7 +143,7 @@ class DeepSleepComponent final : public Component { #endif // USE_ESP32 #if defined(USE_BK72XX) - void init_wakeup_pins_(size_t capacity) { this->wakeup_pins_.init(capacity); } + void init_wakeup_pins(size_t capacity) { this->wakeup_pins_.init(capacity); } void add_wakeup_pin(InternalGPIOPin *wakeup_pin, WakeupPinMode wakeup_pin_mode) { this->wakeup_pins_.emplace_back(WakeUpPinItem{wakeup_pin, wakeup_pin_mode, !wakeup_pin->is_inverted()}); } @@ -191,7 +191,7 @@ class DeepSleepComponent final : public Component { bool should_teardown_(); #ifdef USE_BK72XX - bool pin_prevents_sleep_(WakeUpPinItem &pinItem) const; + bool pin_prevents_sleep_(WakeUpPinItem &pin_item) const; bool get_real_pin_state_(InternalGPIOPin &pin) const { return (pin.digital_read() ^ pin.is_inverted()); } #endif // USE_BK72XX diff --git a/esphome/components/fastled_base/fastled_light.cpp b/esphome/components/fastled_base/fastled_light.cpp index af6e5720ec..da4dbf2ed7 100644 --- a/esphome/components/fastled_base/fastled_light.cpp +++ b/esphome/components/fastled_base/fastled_light.cpp @@ -1,4 +1,4 @@ -#if defined(USE_ARDUINO) && !defined(USE_RP2) +#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) #include "fastled_light.h" #include "esphome/core/log.h" diff --git a/esphome/components/fastled_base/fastled_light.h b/esphome/components/fastled_base/fastled_light.h index 0459777f40..9f903b4530 100644 --- a/esphome/components/fastled_base/fastled_light.h +++ b/esphome/components/fastled_base/fastled_light.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ARDUINO) && !defined(USE_RP2) +#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) #include "esphome/core/component.h" #include "esphome/core/helpers.h" diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index 1760cb9395..84333e7169 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -1,6 +1,6 @@ #include "http_request_arduino.h" -#if defined(USE_ARDUINO) && !defined(USE_ESP32) +#if defined(USE_ARDUINO) && !defined(USE_ESP32) && !defined(USE_LIBRETINY) #include "esphome/components/network/util.h" #include "esphome/components/watchdog/watchdog.h" diff --git a/esphome/components/http_request/http_request_arduino.h b/esphome/components/http_request/http_request_arduino.h index c109de8a39..028b9f44a1 100644 --- a/esphome/components/http_request/http_request_arduino.h +++ b/esphome/components/http_request/http_request_arduino.h @@ -2,7 +2,7 @@ #include "http_request.h" -#if defined(USE_ARDUINO) && !defined(USE_ESP32) +#if defined(USE_ARDUINO) && !defined(USE_ESP32) && !defined(USE_LIBRETINY) #if defined(USE_RP2) #include diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index 871f67a4c8..cc036b12c3 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -1,4 +1,4 @@ -#if defined(USE_ARDUINO) && !defined(USE_ESP32) +#if defined(USE_ARDUINO) && !defined(USE_ESP32) && !defined(USE_LIBRETINY) #include "i2c_bus_arduino.h" #include diff --git a/esphome/components/i2c/i2c_bus_arduino.h b/esphome/components/i2c/i2c_bus_arduino.h index ded28dd80c..71e91e770b 100644 --- a/esphome/components/i2c/i2c_bus_arduino.h +++ b/esphome/components/i2c/i2c_bus_arduino.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ARDUINO) && !defined(USE_ESP32) +#if defined(USE_ARDUINO) && !defined(USE_ESP32) && !defined(USE_LIBRETINY) #include #include "esphome/core/component.h" diff --git a/esphome/components/libretiny/hal.cpp b/esphome/components/libretiny/hal.cpp index 67e902024d..01b276005d 100644 --- a/esphome/components/libretiny/hal.cpp +++ b/esphome/components/libretiny/hal.cpp @@ -44,7 +44,7 @@ void arch_init() { void arch_restart() { lt_reboot(); - while (1) { + while (true) { } } diff --git a/esphome/components/libretiny/hal.h b/esphome/components/libretiny/hal.h index 01a7b5450b..48b94a5214 100644 --- a/esphome/components/libretiny/hal.h +++ b/esphome/components/libretiny/hal.h @@ -44,6 +44,7 @@ // it is callable from Thumb code via interworking. The MRS CPSR instruction // is ARM-only and user code here may be built in Thumb, so in_isr_context() // defers to this port helper on BK72xx instead of reading CPSR inline. +// NOLINTNEXTLINE(readability-redundant-declaration) extern "C" uint32_t platform_is_in_interrupt_context(void); #endif @@ -59,9 +60,11 @@ extern "C" void delayMicroseconds(unsigned int us); // Forward decls from libretiny's family for the inline arch_* // wrappers below. Pulling the full header would drag in the rest of the // LibreTiny C API. +// NOLINTBEGIN(readability-redundant-declaration) extern "C" void lt_wdt_feed(void); extern "C" uint32_t lt_cpu_get_cycle_count(void); extern "C" uint32_t lt_cpu_get_freq(void); +// NOLINTEND(readability-redundant-declaration) namespace esphome::libretiny {} diff --git a/esphome/components/libretiny/lt_component.cpp b/esphome/components/libretiny/lt_component.cpp index 9bbbd66be4..0ab064e3e1 100644 --- a/esphome/components/libretiny/lt_component.cpp +++ b/esphome/components/libretiny/lt_component.cpp @@ -13,14 +13,14 @@ void LTComponent::dump_config() { "LibreTiny:\n" " Version: %s\n" " Loglevel: %u", - LT_BANNER_STR + 10, LT_LOGLEVEL); + <_BANNER_STR[10], LT_LOGLEVEL); #if defined(__OPTIMIZE_SIZE__) && __OPTIMIZE_LEVEL__ > 0 && __OPTIMIZE_LEVEL__ <= 3 ESP_LOGCONFIG(TAG, " Optimization: -Os, SDK: -O" STRINGIFY_MACRO(__OPTIMIZE_LEVEL__)); #endif #ifdef USE_TEXT_SENSOR if (this->version_ != nullptr) { - this->version_->publish_state(LT_BANNER_STR + 10); + this->version_->publish_state(<_BANNER_STR[10]); } #endif // USE_TEXT_SENSOR } diff --git a/esphome/components/libretiny/preference_backend.h b/esphome/components/libretiny/preference_backend.h index 66b6847bee..f7f8279ac0 100644 --- a/esphome/components/libretiny/preference_backend.h +++ b/esphome/components/libretiny/preference_backend.h @@ -5,8 +5,10 @@ #include // Forward declare FlashDB types to avoid pulling in flashdb.h +// NOLINTBEGIN(readability-identifier-naming) struct fdb_kvdb; struct fdb_blob; +// NOLINTEND(readability-identifier-naming) namespace esphome::libretiny { diff --git a/esphome/components/logger/task_log_buffer_libretiny.cpp b/esphome/components/logger/task_log_buffer_libretiny.cpp index b6d6b22ab5..5cde18d19e 100644 --- a/esphome/components/logger/task_log_buffer_libretiny.cpp +++ b/esphome/components/logger/task_log_buffer_libretiny.cpp @@ -20,7 +20,7 @@ TaskLogBuffer::~TaskLogBuffer() { } } -size_t TaskLogBuffer::available_contiguous_space() const { +size_t TaskLogBuffer::available_contiguous_space_() const { if (this->head_ >= this->tail_) { // head is ahead of or equal to tail // Available space is from head to end, plus from start to tail @@ -81,7 +81,7 @@ void TaskLogBuffer::release_message_main_loop() { this->tail_ = 0; } - this->message_count_--; + this->message_count_ = this->message_count_ - 1; this->current_message_size_ = 0; xSemaphoreGive(this->mutex_); @@ -117,7 +117,7 @@ bool TaskLogBuffer::send_message_thread_safe(uint8_t level, const char *tag, uin } // Check if we have enough contiguous space - size_t contiguous = this->available_contiguous_space(); + size_t contiguous = this->available_contiguous_space_(); if (contiguous < total_size) { // Not enough contiguous space at end @@ -128,9 +128,9 @@ bool TaskLogBuffer::send_message_thread_safe(uint8_t level, const char *tag, uin } // Need at least enough space to safely write padding marker (level field is at end of struct) - constexpr size_t PADDING_MARKER_MIN_SPACE = offsetof(LogMessage, level) + 1; + constexpr size_t padding_marker_min_space = offsetof(LogMessage, level) + 1; - if (space_at_start >= total_size && this->head_ > 0 && contiguous >= PADDING_MARKER_MIN_SPACE) { + if (space_at_start >= total_size && this->head_ > 0 && contiguous >= padding_marker_min_space) { // Add padding marker (set level field to indicate this is padding, not a real message) LogMessage *padding = reinterpret_cast(this->storage_ + this->head_); padding->level = PADDING_MARKER_LEVEL; @@ -180,7 +180,7 @@ bool TaskLogBuffer::send_message_thread_safe(uint8_t level, const char *tag, uin this->head_ = 0; } - this->message_count_++; + this->message_count_ = this->message_count_ + 1; xSemaphoreGive(this->mutex_); return true; diff --git a/esphome/components/logger/task_log_buffer_libretiny.h b/esphome/components/logger/task_log_buffer_libretiny.h index b42894502a..ce469a1a18 100644 --- a/esphome/components/logger/task_log_buffer_libretiny.h +++ b/esphome/components/logger/task_log_buffer_libretiny.h @@ -84,7 +84,7 @@ class TaskLogBuffer { static inline size_t message_total_size(size_t text_length) { return sizeof(LogMessage) + text_length + 1; } // Calculate available contiguous space at write position - size_t available_contiguous_space() const; + size_t available_contiguous_space_() const; uint8_t storage_[ESPHOME_TASK_LOG_BUFFER_SIZE]; // Embedded in Logger (no separate heap allocation) size_t head_{0}; // Write position diff --git a/esphome/components/mdns/mdns_libretiny.cpp b/esphome/components/mdns/mdns_libretiny.cpp index a543a3809a..5354bd241c 100644 --- a/esphome/components/mdns/mdns_libretiny.cpp +++ b/esphome/components/mdns/mdns_libretiny.cpp @@ -27,8 +27,8 @@ static void register_libretiny(MDNSComponent *, StaticVector #include -#endif // USE_ESP32 vs USE_ESP8266 +#elif defined(USE_LIBRETINY) +#include +#endif // USE_ESP32 vs USE_ESP8266 vs USE_LIBRETINY #endif // USE_NEXTION_TFT_UPLOAD namespace esphome::nextion { diff --git a/esphome/components/spi/spi.h b/esphome/components/spi/spi.h index c038426f61..0358ed278f 100644 --- a/esphome/components/spi/spi.h +++ b/esphome/components/spi/spi.h @@ -13,7 +13,7 @@ using SPIInterface = spi_host_device_t; -#elif defined(USE_ARDUINO) +#elif defined(USE_ARDUINO) && !defined(USE_LIBRETINY) #include diff --git a/esphome/components/uart/uart_component_libretiny.cpp b/esphome/components/uart/uart_component_libretiny.cpp index 4172e7c164..fbf0c20ded 100644 --- a/esphome/components/uart/uart_component_libretiny.cpp +++ b/esphome/components/uart/uart_component_libretiny.cpp @@ -18,7 +18,7 @@ namespace esphome::uart { static const char *const TAG = "uart.lt"; -static const char *UART_TYPE[] = { +static const char *const UART_TYPE[] = { "hardware", "software", }; @@ -45,19 +45,19 @@ uint16_t LibreTinyUARTComponent::get_config() { } void LibreTinyUARTComponent::setup() { - int8_t tx_pin = tx_pin_ == nullptr ? -1 : tx_pin_->get_pin(); - int8_t rx_pin = rx_pin_ == nullptr ? -1 : rx_pin_->get_pin(); - bool tx_inverted = tx_pin_ != nullptr && tx_pin_->is_inverted(); - bool rx_inverted = rx_pin_ != nullptr && rx_pin_->is_inverted(); + int16_t tx_pin = tx_pin_ == nullptr ? -1 : tx_pin_->get_pin(); + int16_t rx_pin = rx_pin_ == nullptr ? -1 : rx_pin_->get_pin(); - auto shouldFallbackToSoftwareSerial = [&]() -> bool { - auto hasFlags = [](InternalGPIOPin *pin, const gpio::Flags mask) -> bool { + auto should_fallback_to_software_serial = [&]() -> bool { + auto has_flags = [](InternalGPIOPin *pin, const gpio::Flags mask) -> bool { return pin && (pin->get_flags() & mask) != gpio::Flags::FLAG_NONE; }; - if (hasFlags(this->tx_pin_, gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN) || - hasFlags(this->rx_pin_, gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN)) { + if (has_flags(this->tx_pin_, + gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN) || + has_flags(this->rx_pin_, + gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN)) { #if LT_ARD_HAS_SOFTSERIAL - ESP_LOGI(TAG, "Pins has flags set. Using Software Serial"); + ESP_LOGI(TAG, "Pins have flags set. Using Software Serial"); return true; #else ESP_LOGW(TAG, "Pin flags are set but not supported for hardware serial. Ignoring"); @@ -66,25 +66,26 @@ void LibreTinyUARTComponent::setup() { return false; }; - if (false) + if (false) { // NOLINT(readability-simplify-boolean-expr) return; + } #if LT_HW_UART0 else if ((tx_pin == -1 || tx_pin == PIN_SERIAL0_TX) && (rx_pin == -1 || rx_pin == PIN_SERIAL0_RX) && - !shouldFallbackToSoftwareSerial()) { + !should_fallback_to_software_serial()) { this->serial_ = &Serial0; this->hardware_idx_ = 0; } #endif #if LT_HW_UART1 else if ((tx_pin == -1 || tx_pin == PIN_SERIAL1_TX) && (rx_pin == -1 || rx_pin == PIN_SERIAL1_RX) && - !shouldFallbackToSoftwareSerial()) { + !should_fallback_to_software_serial()) { this->serial_ = &Serial1; this->hardware_idx_ = 1; } #endif #if LT_HW_UART2 else if ((tx_pin == -1 || tx_pin == PIN_SERIAL2_TX) && (rx_pin == -1 || rx_pin == PIN_SERIAL2_RX) && - !shouldFallbackToSoftwareSerial()) { + !should_fallback_to_software_serial()) { this->serial_ = &Serial2; this->hardware_idx_ = 2; } @@ -97,6 +98,8 @@ void LibreTinyUARTComponent::setup() { if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { this->tx_pin_->setup(); } + bool tx_inverted = tx_pin_ != nullptr && tx_pin_->is_inverted(); + bool rx_inverted = rx_pin_ != nullptr && rx_pin_->is_inverted(); this->serial_ = new SoftwareSerial(rx_pin, tx_pin, rx_inverted || tx_inverted); #else this->serial_ = &Serial; @@ -133,7 +136,7 @@ void LibreTinyUARTComponent::dump_config() { ESP_LOGCONFIG(TAG, " RX Buffer Size: %u", this->rx_buffer_size_); } ESP_LOGCONFIG(TAG, - " Baud Rate: %u baud\n" + " Baud Rate: %" PRIu32 " baud\n" " Data Bits: %u\n" " Parity: %s\n" " Stop bits: %u", diff --git a/esphome/components/wled/wled_light_effect.cpp b/esphome/components/wled/wled_light_effect.cpp index e0724aa94a..5150cda2a5 100644 --- a/esphome/components/wled/wled_light_effect.cpp +++ b/esphome/components/wled/wled_light_effect.cpp @@ -13,7 +13,7 @@ #include #endif -#ifdef USE_BK72XX +#ifdef USE_LIBRETINY #include #endif diff --git a/esphome/components/wled/wled_light_effect.h b/esphome/components/wled/wled_light_effect.h index 085303e6c0..07abb7c674 100644 --- a/esphome/components/wled/wled_light_effect.h +++ b/esphome/components/wled/wled_light_effect.h @@ -8,7 +8,7 @@ #include #include -#ifdef USE_RP2 +#if defined(USE_RP2) || defined(USE_LIBRETINY) namespace arduino { class UDP; } // namespace arduino From 2333e6eef51aeb0fcb813c9fc0dcb4b184f7d2d3 Mon Sep 17 00:00:00 2001 From: Guanzhong Chen Date: Thu, 16 Jul 2026 07:57:30 -0400 Subject: [PATCH 0924/1815] [zephyr] implement ISRInternalGPIOPin::digital_write (#17601) --- esphome/components/zephyr/gpio.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/esphome/components/zephyr/gpio.cpp b/esphome/components/zephyr/gpio.cpp index 1e4201d8f5..23da2cafac 100644 --- a/esphome/components/zephyr/gpio.cpp +++ b/esphome/components/zephyr/gpio.cpp @@ -173,6 +173,14 @@ bool IRAM_ATTR ISRInternalGPIOPin::digital_read() { return bool(gpio_pin_get(arg->gpio, arg->pin % arg->gpio_size) != arg->inverted); } +void IRAM_ATTR ISRInternalGPIOPin::digital_write(bool value) { + auto *arg = (zephyr::ISRPinArg *) this->arg_; + if (arg == nullptr || arg->gpio == nullptr) { + return; + } + gpio_pin_set(arg->gpio, arg->pin % arg->gpio_size, value != arg->inverted ? 1 : 0); +} + } // namespace esphome #endif From 14e71e190c3c2a43ce0b5c6156c684fa134595a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Jul 2026 02:00:19 -1000 Subject: [PATCH 0925/1815] [ci] Restore memory impact detail for ESP-IDF builds (#17587) --- esphome/analyze_memory/cli.py | 35 +--- esphome/analyze_memory/toolchain.py | 72 ++++++++ esphome/espidf/idedata.py | 25 ++- esphome/espidf/toolchain.py | 9 +- script/ci_memory_impact_extract.py | 60 ++++--- tests/script/test_determine_jobs.py | 50 ++++++ .../analyze_memory/test_build_artifacts.py | 157 ++++++++++++++++++ .../test_ci_memory_impact_extract.py | 57 +++++++ tests/unit_tests/test_espidf_toolchain.py | 57 ++++++- 9 files changed, 459 insertions(+), 63 deletions(-) create mode 100644 tests/unit_tests/analyze_memory/test_build_artifacts.py create mode 100644 tests/unit_tests/analyze_memory/test_ci_memory_impact_extract.py diff --git a/esphome/analyze_memory/cli.py b/esphome/analyze_memory/cli.py index 4fbceb7e5e..ab20e4d076 100644 --- a/esphome/analyze_memory/cli.py +++ b/esphome/analyze_memory/cli.py @@ -20,6 +20,7 @@ from . import ( RAM_SECTIONS, MemoryAnalyzer, ) +from .toolchain import find_elf_path, find_idedata_path, idedata_candidates if TYPE_CHECKING: from . import ComponentMemory @@ -759,45 +760,25 @@ def main(): print(f"Error: {build_path} is not a directory", file=sys.stderr) sys.exit(1) - # Find firmware.elf - elf_file = None - for elf_candidate in [ - build_path / "firmware.elf", - build_path / ".pioenvs" / build_path.name / "firmware.elf", - ]: - if elf_candidate.exists(): - elf_file = str(elf_candidate) - break - - if not elf_file: - print(f"Error: firmware.elf not found in {build_dir}", file=sys.stderr) + elf_path = find_elf_path(build_path) + if not elf_path: + print(f"Error: no firmware ELF found in {build_dir}", file=sys.stderr) sys.exit(1) - - # Find idedata.json - check current directory first, then home - device_name = build_path.name - idedata_candidates = [ - Path.cwd() / ".esphome" / "idedata" / f"{device_name}.json", - Path.home() / ".esphome" / "idedata" / f"{device_name}.json", - ] + elf_file = str(elf_path) idedata = None - for idedata_path in idedata_candidates: - if not idedata_path.exists(): - continue + if idedata_path := find_idedata_path(build_path): try: with idedata_path.open(encoding="utf-8") as f: raw_data = json.load(f) idedata = IDEData(raw_data) print(f"Loaded idedata from: {idedata_path}", file=sys.stderr) - break except (json.JSONDecodeError, OSError) as e: print(f"Warning: Failed to load idedata: {e}", file=sys.stderr) if not idedata: - print( - f"Warning: idedata not found (searched {idedata_candidates[0]} and {idedata_candidates[1]})", - file=sys.stderr, - ) + searched = "\n ".join(str(p) for p in idedata_candidates(build_path)) + print(f"Warning: idedata not found, searched:\n {searched}", file=sys.stderr) analyzer = MemoryAnalyzerCLI(elf_file, idedata=idedata) analyzer.analyze() diff --git a/esphome/analyze_memory/toolchain.py b/esphome/analyze_memory/toolchain.py index a724d52f25..19041ac807 100644 --- a/esphome/analyze_memory/toolchain.py +++ b/esphome/analyze_memory/toolchain.py @@ -23,6 +23,78 @@ TOOLCHAIN_PREFIXES = [ ] +def find_elf_path(build_path: Path) -> Path | None: + """Locate the firmware ELF inside an ESPHome build directory. + + The layout depends on the toolchain that produced the build, so try each + known one in turn. + + Args: + build_path: Path to an ESPHome build directory + + Returns: + Path to the ELF file, or None if no known layout matches + """ + name = build_path.name + for candidate in ( + # Native ESP-IDF: idf.py writes build/.elf, which ESPHome copies + # to build/firmware.elf (see espidf.toolchain.create_elf_copy) + build_path / "build" / "firmware.elf", + # PlatformIO + build_path / "firmware.elf", + build_path / ".pioenvs" / name / "firmware.elf", + # LibreTiny uses raw_firmware.elf + build_path / "raw_firmware.elf", + build_path / ".pioenvs" / name / "raw_firmware.elf", + # Zephyr (nRF52); the SDK nests the artifacts one level deeper from 2.9.2 + build_path / ".pioenvs" / name / "zephyr" / "zephyr" / "zephyr.elf", + build_path / ".pioenvs" / name / "zephyr" / "zephyr.elf", + ): + if candidate.is_file(): + return candidate + return None + + +def idedata_candidates(build_path: Path) -> list[Path]: + """Return the idedata locations searched for a build directory, in order. + + Exposed so a caller reporting "not found" can name the paths it tried + without keeping its own copy of the list. + + Args: + build_path: Path to an ESPHome build directory + + Returns: + The candidate idedata JSON paths, most specific first + """ + name = build_path.name + return [ + # In .pioenvs for test builds + build_path / ".pioenvs" / name / "idedata.json", + # Both toolchains cache it in the data dir, which holds this build dir: + # /idedata/.json next to /build/ + build_path.parent.parent / "idedata" / f"{name}.json", + # Regular builds, invoked from the config dir or from anywhere + Path.cwd() / ".esphome" / "idedata" / f"{name}.json", + Path.home() / ".esphome" / "idedata" / f"{name}.json", + ] + + +def find_idedata_path(build_path: Path) -> Path | None: + """Locate the idedata JSON belonging to an ESPHome build directory. + + Args: + build_path: Path to an ESPHome build directory + + Returns: + Path to the idedata JSON, or None if it was not found + """ + for candidate in idedata_candidates(build_path): + if candidate.is_file(): + return candidate + return None + + def _find_in_platformio_packages(tool_name: str) -> str | None: """Search for a tool in PlatformIO package directories. diff --git a/esphome/espidf/idedata.py b/esphome/espidf/idedata.py index 0ed357a759..0047d568e2 100644 --- a/esphome/espidf/idedata.py +++ b/esphome/espidf/idedata.py @@ -6,7 +6,7 @@ toolchain has no such command, but its CMake build emits turns that file into the same fields consumers (IDE integration, clang-tidy) expect: - {cxx_path, cxx_flags, defines, includes: {build, toolchain}} + {cc_path, cxx_path, cxx_flags, defines, includes: {build, toolchain}} """ from __future__ import annotations @@ -197,6 +197,28 @@ def _get_toolchain_includes(cxx_path: str) -> list[str]: return includes +def _cc_path_from_cxx(cxx_path: str) -> str: + """Derive the C compiler path from the C++ compiler path. + + compile_commands.json only names the C++ compiler, but consumers reach the + rest of the toolchain (objdump, readelf, addr2line) by rewriting the tail of + ``cc_path``, so they need the ``gcc``-suffixed name. + """ + stem, suffix = ( + (cxx_path[: -len(".exe")], ".exe") + if cxx_path.endswith(".exe") + else (cxx_path, "") + ) + # Rewrite the program name only when it is g++ itself, or a toolchain + # prefixed one such as xtensa-esp32-elf-g++ -> xtensa-esp32-elf-gcc. + # Requiring a separator before the "g++" keeps names that merely end in + # those three characters intact: "clang++" must not become "clangcc". + head = stem[: -len("g++")] + if stem.endswith("g++") and (not head or head.endswith(("-", "/", "\\"))): + stem = f"{head}gcc" + return f"{stem}{suffix}" + + def idedata_from_build(compile_commands: Path) -> dict: """Parse compile_commands.json into the idedata fields consumers expect. @@ -218,6 +240,7 @@ def idedata_from_build(compile_commands: Path) -> dict: build_includes.setdefault(inc, None) return { + "cc_path": _cc_path_from_cxx(cxx_path), "cxx_path": cxx_path, "cxx_flags": cxx_flags, "defines": defines, diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 000ce739db..231763d17d 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -467,9 +467,16 @@ def get_idedata() -> dict | None: cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json") if cache.is_file() and cache.stat().st_mtime >= compile_commands.stat().st_mtime: try: - return json.loads(cache.read_text(encoding="utf-8")) + cached = json.loads(cache.read_text(encoding="utf-8")) except ValueError: pass + else: + # Caches written before cc_path was emitted stay newer than + # compile_commands.json forever, so rebuild them on the field rather + # than on the timestamp. Check the type too: a corrupted cache can + # still be valid JSON, and "in" would match a substring of a string. + if isinstance(cached, dict) and "cc_path" in cached: + return cached data = idedata_from_build(compile_commands) data["prog_path"] = str(get_elf_path()) diff --git a/script/ci_memory_impact_extract.py b/script/ci_memory_impact_extract.py index 20a737cdbf..6e999a29d6 100755 --- a/script/ci_memory_impact_extract.py +++ b/script/ci_memory_impact_extract.py @@ -33,6 +33,11 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) # pylint: disable=wrong-import-position from esphome.analyze_memory import MemoryAnalyzer +from esphome.analyze_memory.toolchain import ( + find_elf_path, + find_idedata_path, + idedata_candidates, +) from esphome.platformio.toolchain import IDEData from script.ci_helpers import write_github_output @@ -130,53 +135,31 @@ def run_detailed_analysis(build_dir: str) -> dict | None: print(f"Build directory not found: {build_dir}", file=sys.stderr) return None - # Find firmware.elf (or raw_firmware.elf for LibreTiny) - elf_path = None - for elf_candidate in [ - build_path / "firmware.elf", - build_path / ".pioenvs" / build_path.name / "firmware.elf", - # LibreTiny uses raw_firmware.elf - build_path / "raw_firmware.elf", - build_path / ".pioenvs" / build_path.name / "raw_firmware.elf", - ]: - if elf_candidate.exists(): - elf_path = str(elf_candidate) - break - + elf_path = find_elf_path(build_path) if not elf_path: - print( - f"firmware.elf/raw_firmware.elf not found in {build_dir}", file=sys.stderr - ) + print(f"No firmware ELF found in {build_dir}", file=sys.stderr) return None - # Find idedata.json - check multiple locations - device_name = build_path.name - idedata_candidates = [ - # In .pioenvs for test builds - build_path / ".pioenvs" / device_name / "idedata.json", - # In .esphome/idedata for regular builds - Path.home() / ".esphome" / "idedata" / f"{device_name}.json", - # Check parent directories for .esphome/idedata (for test_build_components) - build_path.parent.parent.parent / "idedata" / f"{device_name}.json", - ] - idedata = None - for idedata_path in idedata_candidates: - if not idedata_path.exists(): - continue + if idedata_path := find_idedata_path(build_path): try: with idedata_path.open(encoding="utf-8") as f: raw_data = json.load(f) idedata = IDEData(raw_data) print(f"Loaded idedata from: {idedata_path}", file=sys.stderr) - break except (json.JSONDecodeError, OSError) as e: print( f"Warning: Failed to load idedata from {idedata_path}: {e}", file=sys.stderr, ) + else: + # Without idedata the analyzer falls back to whatever binutils are on + # PATH, which are the wrong architecture for a cross build, so say where + # we looked rather than let the results quietly get worse. + searched = "\n ".join(str(p) for p in idedata_candidates(build_path)) + print(f"Warning: idedata not found, searched:\n {searched}", file=sys.stderr) - analyzer = MemoryAnalyzer(elf_path, idedata=idedata) + analyzer = MemoryAnalyzer(str(elf_path), idedata=idedata) components = analyzer.analyze() # Convert to JSON-serializable format @@ -320,6 +303,19 @@ def main() -> int: else: print(f"{ram_bytes},{flash_bytes}") + # The build produced usable totals, so a missing detailed analysis means the + # build layout moved out from under this script rather than a broken build. + # Fail loudly: the comment would otherwise silently drop the component + # breakdown and the symbol tables, which is easy to miss for a long time. + if detailed_analysis is None: + print( + "::error::Detailed memory analysis unavailable even though the build " + f"succeeded (build directory: {build_dir or 'not detected'}). The PR " + "comment would be missing its component breakdown and symbol changes.", + file=sys.stderr, + ) + return 1 + return 0 diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index d018c6dbd0..a05b683a5f 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -2993,3 +2993,53 @@ def test_main_force_all_off_uses_detection( assert output["component_test_count"] == 0 mock_determine_integration_tests.assert_called_once() mock_should_run_clang_tidy.assert_called_once() + + +# Every platform the memory impact analysis can select must produce an ELF that +# find_elf_path knows how to locate. The analysis fails the job when it cannot +# find one, so a platform with an unknown layout would turn a clean build red. +_MEMORY_IMPACT_ELF_LAYOUTS = { + # Native ESP-IDF toolchain (the esp32 default): /build/firmware.elf + "esp32-c6-idf": "build/firmware.elf", + "esp32-idf": "build/firmware.elf", + "esp32-c3-idf": "build/firmware.elf", + "esp32-s2-idf": "build/firmware.elf", + "esp32-s3-idf": "build/firmware.elf", + # PlatformIO: /.pioenvs//firmware.elf + "esp8266-ard": ".pioenvs/{name}/firmware.elf", + "rp2040-ard": ".pioenvs/{name}/firmware.elf", + "rp2350-ard": ".pioenvs/{name}/firmware.elf", + # LibreTiny: /.pioenvs//raw_firmware.elf + "bk72xx-ard": ".pioenvs/{name}/raw_firmware.elf", + "rtl87xx-ard": ".pioenvs/{name}/raw_firmware.elf", + "ln882x-ard": ".pioenvs/{name}/raw_firmware.elf", + # Zephyr: /.pioenvs//zephyr/[zephyr/]zephyr.elf + "nrf52-adafruit": ".pioenvs/{name}/zephyr/zephyr/zephyr.elf", +} + + +def test_memory_impact_platforms_have_known_elf_layout() -> None: + """Every selectable memory impact platform has a documented ELF layout. + + Adding a platform to the preference list without teaching find_elf_path + where its ELF lands would fail the memory impact job on a clean build. + """ + selectable = { + platform.value for platform in determine_jobs.MEMORY_IMPACT_PLATFORM_PREFERENCE + } + selectable.add(determine_jobs.MEMORY_IMPACT_FALLBACK_PLATFORM.value) + + assert selectable == set(_MEMORY_IMPACT_ELF_LAYOUTS) + + +def test_memory_impact_elf_layouts_are_found(tmp_path: Path) -> None: + """find_elf_path locates the ELF each memory impact platform produces.""" + from esphome.analyze_memory.toolchain import find_elf_path + + for platform, layout in _MEMORY_IMPACT_ELF_LAYOUTS.items(): + build_path = tmp_path / platform / ".esphome" / "build" / "mydevice" + elf = build_path / layout.format(name=build_path.name) + elf.parent.mkdir(parents=True) + elf.write_text("") + + assert find_elf_path(build_path) == elf, f"{platform} ELF not found" diff --git a/tests/unit_tests/analyze_memory/test_build_artifacts.py b/tests/unit_tests/analyze_memory/test_build_artifacts.py new file mode 100644 index 0000000000..734f21d852 --- /dev/null +++ b/tests/unit_tests/analyze_memory/test_build_artifacts.py @@ -0,0 +1,157 @@ +"""Tests for locating build artifacts across the supported toolchain layouts.""" + +from pathlib import Path + +import pytest + +from esphome.analyze_memory.toolchain import ( + find_elf_path, + find_idedata_path, + idedata_candidates, +) +from esphome.espidf.idedata import _cc_path_from_cxx +from esphome.platformio.toolchain import IDEData + + +def _make_build_dir(tmp_path: Path, name: str = "mydevice") -> Path: + """Create /.esphome/build/, mirroring a real data dir.""" + build_path = tmp_path / ".esphome" / "build" / name + build_path.mkdir(parents=True) + return build_path + + +def _touch(path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("") + return path + + +def test_find_elf_path_native_esp_idf(tmp_path: Path) -> None: + """The native ESP-IDF toolchain writes the ELF under build/.""" + build_path = _make_build_dir(tmp_path) + elf = _touch(build_path / "build" / "firmware.elf") + + assert find_elf_path(build_path) == elf + + +def test_find_elf_path_platformio(tmp_path: Path) -> None: + """The PlatformIO toolchain writes the ELF under .pioenvs//.""" + build_path = _make_build_dir(tmp_path) + elf = _touch(build_path / ".pioenvs" / build_path.name / "firmware.elf") + + assert find_elf_path(build_path) == elf + + +def test_find_elf_path_libretiny(tmp_path: Path) -> None: + """The LibreTiny toolchain names the unwrapped ELF raw_firmware.elf.""" + build_path = _make_build_dir(tmp_path) + elf = _touch(build_path / ".pioenvs" / build_path.name / "raw_firmware.elf") + + assert find_elf_path(build_path) == elf + + +@pytest.mark.parametrize( + "relative_elf", + [ + # SDK < 2.9.2 + "zephyr/zephyr.elf", + # SDK >= 2.9.2 nests the artifacts one level deeper + "zephyr/zephyr/zephyr.elf", + ], +) +def test_find_elf_path_zephyr(tmp_path: Path, relative_elf: str) -> None: + """Zephyr (nRF52) keeps the ELF under .pioenvs//zephyr/.""" + build_path = _make_build_dir(tmp_path) + elf = _touch(build_path / ".pioenvs" / build_path.name / relative_elf) + + assert find_elf_path(build_path) == elf + + +def test_find_elf_path_missing(tmp_path: Path) -> None: + """An unknown layout resolves to None rather than a bogus path.""" + assert find_elf_path(_make_build_dir(tmp_path)) is None + + +def test_find_idedata_path_in_data_dir(tmp_path: Path) -> None: + """The idedata cache sits in the data dir that holds the build dir.""" + build_path = _make_build_dir(tmp_path) + idedata = _touch(tmp_path / ".esphome" / "idedata" / f"{build_path.name}.json") + + assert find_idedata_path(build_path) == idedata + + +def test_find_idedata_path_in_pioenvs(tmp_path: Path) -> None: + """Test builds may keep idedata alongside the PlatformIO env.""" + build_path = _make_build_dir(tmp_path) + idedata = _touch(build_path / ".pioenvs" / build_path.name / "idedata.json") + + assert find_idedata_path(build_path) == idedata + + +def test_find_idedata_path_missing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A missing idedata resolves to None.""" + # Keep the cwd/home fallbacks from finding an unrelated file on this machine + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + + assert find_idedata_path(_make_build_dir(tmp_path)) is None + + +def test_idedata_candidates_are_what_find_probes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Every advertised candidate is one find_idedata_path actually accepts. + + The candidates are reported to the user when idedata is missing, so a list + that drifts from the lookup would send someone hunting in the wrong place. + """ + # Two candidates are relative to the cwd and to home; keep the test from + # writing into the real ones. + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + + build_path = _make_build_dir(tmp_path) + candidates = idedata_candidates(build_path) + + assert candidates, "no candidates advertised" + for candidate in candidates: + _touch(candidate) + assert find_idedata_path(build_path) == candidate + candidate.unlink() + + +@pytest.mark.parametrize( + ("cxx_path", "expected"), + [ + ("/tools/bin/xtensa-esp32-elf-g++", "/tools/bin/xtensa-esp32-elf-gcc"), + ("/tools/bin/riscv32-esp-elf-g++", "/tools/bin/riscv32-esp-elf-gcc"), + ( + r"C:\tools\bin\xtensa-esp32-elf-g++.exe", + r"C:\tools\bin\xtensa-esp32-elf-gcc.exe", + ), + # Nothing to rewrite; leave the path alone + ("/tools/bin/clang++", "/tools/bin/clang++"), + ], +) +def test_cc_path_from_cxx(cxx_path: str, expected: str) -> None: + """cc_path is derived from the C++ compiler that compile_commands.json names.""" + assert _cc_path_from_cxx(cxx_path) == expected + + +def test_native_idedata_resolves_toolchain_tools() -> None: + """The binutils paths are derived from the native ESP-IDF cc_path. + + Without cc_path, IDEData.objdump_path raises KeyError and the memory + analysis silently degrades to no component or symbol detail. + """ + idedata = IDEData( + { + "cc_path": _cc_path_from_cxx("/tools/bin/xtensa-esp32-elf-g++"), + "cxx_path": "/tools/bin/xtensa-esp32-elf-g++", + } + ) + + assert idedata.objdump_path == "/tools/bin/xtensa-esp32-elf-objdump" + assert idedata.readelf_path == "/tools/bin/xtensa-esp32-elf-readelf" diff --git a/tests/unit_tests/analyze_memory/test_ci_memory_impact_extract.py b/tests/unit_tests/analyze_memory/test_ci_memory_impact_extract.py new file mode 100644 index 0000000000..73a1c63e1a --- /dev/null +++ b/tests/unit_tests/analyze_memory/test_ci_memory_impact_extract.py @@ -0,0 +1,57 @@ +"""Tests for script/ci_memory_impact_extract.py.""" + +import io +from pathlib import Path +import sys + +import pytest + +# Add script directory to path so we can import the module +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "script")) + +from ci_memory_impact_extract import main # noqa: E402 + +_COMPILE_OUTPUT = ( + "RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes)\n" + "Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes)\n" +) + + +@pytest.fixture(autouse=True) +def _no_github_output(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("GITHUB_OUTPUT", raising=False) + + +def _run(monkeypatch: pytest.MonkeyPatch, compile_output: str, argv: list[str]) -> int: + monkeypatch.setattr(sys, "stdin", io.StringIO(compile_output)) + monkeypatch.setattr(sys, "argv", ["ci_memory_impact_extract.py", *argv]) + return main() + + +def test_missing_detailed_analysis_fails( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A build with no usable ELF fails instead of posting a comment without detail.""" + build_dir = tmp_path / ".esphome" / "build" / "mydevice" + build_dir.mkdir(parents=True) + out_json = tmp_path / "analysis.json" + + rc = _run( + monkeypatch, + _COMPILE_OUTPUT, + ["--build-dir", str(build_dir), "--output-json", str(out_json)], + ) + + assert rc == 1 + # The totals are still written so the failure can be diagnosed from the artifact + assert out_json.is_file() + + +def test_undetected_build_dir_fails(monkeypatch: pytest.MonkeyPatch) -> None: + """Compile output without a build path cannot be analyzed, so it fails.""" + assert _run(monkeypatch, _COMPILE_OUTPUT, []) == 1 + + +def test_unparseable_output_fails(monkeypatch: pytest.MonkeyPatch) -> None: + """Output with no memory totals at all is still a failure.""" + assert _run(monkeypatch, "nothing useful here\n", []) == 1 diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 017d8c49b4..e4b8971fb7 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -7,6 +7,8 @@ import os from pathlib import Path from unittest.mock import patch +import pytest + from esphome.const import CONF_FRAMEWORK, CONF_SOURCE from esphome.core import CORE from esphome.espidf import toolchain @@ -100,7 +102,7 @@ def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None: compile_commands.parent.mkdir(parents=True, exist_ok=True) compile_commands.write_text("[]") cache.parent.mkdir(parents=True, exist_ok=True) - cache.write_text('{"cxx_path": "cached"}') + cache.write_text('{"cc_path": "cached-gcc", "cxx_path": "cached"}') cc_mtime = compile_commands.stat().st_mtime os.utime(cache, (cc_mtime + 1, cc_mtime + 1)) @@ -108,7 +110,31 @@ def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None: result = toolchain.get_idedata() mock_transform.assert_not_called() - assert result == {"cxx_path": "cached"} + assert result == {"cc_path": "cached-gcc", "cxx_path": "cached"} + + +def test_get_idedata_regenerates_cache_without_cc_path(setup_core: Path) -> None: + """A cache predating cc_path is rebuilt even though it is newer. + + Such a cache stays newer than the compile DB forever, so consumers that + derive the binutils paths from cc_path would keep failing on it. + """ + compile_commands, cache = _setup_build(setup_core) + compile_commands.parent.mkdir(parents=True, exist_ok=True) + compile_commands.write_text("[]") + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text('{"cxx_path": "cached"}') + cc_mtime = compile_commands.stat().st_mtime + os.utime(cache, (cc_mtime + 1, cc_mtime + 1)) + + with patch( + "esphome.espidf.idedata.idedata_from_build", + return_value={"cc_path": "gcc", "cxx_path": "g++"}, + ) as mock_transform: + result = toolchain.get_idedata() + + mock_transform.assert_called_once() + assert result["cc_path"] == "gcc" def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) -> None: @@ -131,6 +157,33 @@ def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) - assert result == {"cxx_path": "fresh", "prog_path": str(toolchain.get_elf_path())} +@pytest.mark.parametrize("cached", ['"cc_path is a string"', "[]", "42"]) +def test_get_idedata_regenerates_on_non_dict_cache( + setup_core: Path, cached: str +) -> None: + """A newer cache holding valid JSON that is not an object is regenerated. + + A bare string would otherwise pass the cc_path check by substring and be + handed to consumers expecting a dict. + """ + compile_commands, cache = _setup_build(setup_core) + compile_commands.parent.mkdir(parents=True, exist_ok=True) + compile_commands.write_text("[]") + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text(cached) + cc_mtime = compile_commands.stat().st_mtime + os.utime(cache, (cc_mtime + 1, cc_mtime + 1)) + + with patch( + "esphome.espidf.idedata.idedata_from_build", + return_value={"cc_path": "gcc", "cxx_path": "g++"}, + ) as mock_transform: + result = toolchain.get_idedata() + + mock_transform.assert_called_once() + assert isinstance(result, dict) + + def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None: """An unparseable (but newer) cache falls back to regeneration.""" compile_commands, cache = _setup_build(setup_core) From 5b4ae22f581c8f1f83fa39ff2bcf1be0f8ded962 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Jul 2026 02:08:28 -1000 Subject: [PATCH 0926/1815] [api] Don't spam tracebacks when decoding a crash without a local build (#17597) --- esphome/components/api/client.py | 25 ++++--- esphome/components/esp32/__init__.py | 16 ++-- esphome/espidf/toolchain.py | 8 ++ .../unit_tests/components/api/test_client.py | 36 +++++++-- tests/unit_tests/test_espidf_toolchain.py | 74 ++++++++++++++++++- 5 files changed, 135 insertions(+), 24 deletions(-) diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 44edc035f9..98edfef038 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -18,7 +18,7 @@ with warnings.catch_warnings(): import contextlib from esphome.const import CONF_KEY, CONF_PORT, __version__ -from esphome.core import CORE, EsphomeError +from esphome.core import CORE from esphome.util import safe_print from . import CONF_ENCRYPTION @@ -36,15 +36,17 @@ class _LogLineProcessor: """Feeds incoming log lines to the stack-trace decoder. Two responsibilities beyond just calling the decoder: - 1. Catch EsphomeError. on_log runs inside an asyncio protocol - callback; if an exception escapes, the loop tears the transport - down with "Fatal error: protocol.data_received() call failed." - and ReconnectLogic immediately reconnects, the device replays - the same crash trace, and we loop forever. - 2. Disable decoding after the first failure. _decode_pc shells out - to PlatformIO via _run_idedata, which is expensive; a single - crash dump can contain many PC/BT lines and we don't want to - retry the failing subprocess for each one. + 1. Catch everything the decoder can raise. aioesphomeapi isolates + exceptions raised by log handlers, so an escaping one no longer + kills the session, but it does log a full traceback per line. A + crash dump carries a PC line plus one per backtrace frame, so the + tracebacks bury the dump the user is trying to read. Decoding is a + diagnostic nicety; nothing it raises is worth that noise. + 2. Disable decoding after the first failure. _decode_pc shells out to + the toolchain to resolve addr2line, which is expensive; a single + crash dump can contain many PC/BT lines and we don't want to retry + the failing subprocess for each one. This only works if every + failure is caught, which is why 1 is not narrowed to EsphomeError. """ def __init__(self, config: dict[str, Any], platform_handler: Any | None) -> None: @@ -61,12 +63,13 @@ class _LogLineProcessor: self.backtrace_state = self._platform_handler( self._config, raw_line, self.backtrace_state ) - except EsphomeError as exc: + except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except self._decode_enabled = False self.backtrace_state = False # _run_idedata raises EsphomeError with no message; fall back # to a generic explanation when str(exc) is empty. detail = str(exc) or "build artifacts not found locally" + _LOGGER.debug("Stack-trace decoding failed", exc_info=True) _LOGGER.warning( "Crash trace decoding unavailable: %s. " "Run 'esphome compile' for this device to enable PC decoding.", diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 9b568dd629..3c2fb35dde 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -3058,19 +3058,23 @@ def copy_files(): def _decode_pc(config, addr): - # _decode_pc runs from the api log processor's asyncio callback, which - # only catches EsphomeError. Any other exception escaping here tears down - # the protocol and triggers an infinite reconnect/replay loop. Convert - # toolchain-resolution errors (e.g. missing build dir / cmake cache) into - # EsphomeError so the caller can disable decoding cleanly. + # Convert toolchain-resolution errors (e.g. missing build dir / cmake + # cache) into EsphomeError. The api log processor stops decoding on any + # exception, so this is about the message it reports rather than about + # catching it at all: EsphomeError carries an explanation worth showing + # the user, where a raw OSError repr does not. if CORE.using_toolchain_esp_idf: from esphome.espidf import toolchain as idf_toolchain try: addr2line_path = idf_toolchain.get_addr2line_path() firmware_elf_path = idf_toolchain.get_elf_path() - except RuntimeError as err: + except (RuntimeError, OSError) as err: + # OSError covers a missing build directory or a cmake that isn't + # on PATH; both surface from the subprocess call, not as RuntimeError. raise EsphomeError(f"ESP-IDF toolchain not available: {err}") from err + if not firmware_elf_path.is_file(): + raise EsphomeError(f"Firmware ELF not found: {firmware_elf_path}") else: from esphome.platformio import toolchain diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 231763d17d..f2bb99d970 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -94,6 +94,14 @@ def _get_idf_env(version: str | None = None) -> dict[str, str]: def _get_cmake_output(build_dir) -> str: cmake_output_cache = _cache().cmake_output if build_dir not in cmake_output_cache: + # Check the build before resolving the env: _get_idf_env() runs + # check_esp_idf_install(), which can download and install the whole + # framework. Never start that for a build that isn't there. Callers + # such as the log stack-trace decoder run against devices that were + # never compiled on this machine. + if not (Path(build_dir) / "CMakeCache.txt").is_file(): + raise EsphomeError(f"No ESP-IDF build found in {build_dir}") + cmd = ["cmake", "-LA", "-N", "."] env = _get_idf_env() diff --git a/tests/unit_tests/components/api/test_client.py b/tests/unit_tests/components/api/test_client.py index 333ef70b22..cbec406a3a 100644 --- a/tests/unit_tests/components/api/test_client.py +++ b/tests/unit_tests/components/api/test_client.py @@ -12,11 +12,9 @@ from esphome.core import EsphomeError def test_decoder_swallows_esphome_error() -> None: """A failing stack-trace decode must not propagate. - on_log runs inside an asyncio protocol callback; if EsphomeError - escapes, the loop reports "Fatal error: protocol.data_received() - call failed.", tears the connection down, and ReconnectLogic loops - forever as the device replays the same crash trace on every - reconnect. + aioesphomeapi isolates exceptions raised by log handlers, so an + escaping one logs a full traceback for every line it fires on rather + than being reported once as an unavailable decoder. """ config = {"esphome": {"name": "test"}} @@ -43,6 +41,32 @@ def test_decoder_swallows_platform_handler_error() -> None: assert processor.backtrace_state is False +def test_decoder_swallows_non_esphome_error() -> None: + """Decoding failures that aren't EsphomeError must be contained too. + + A missing build directory surfaces as FileNotFoundError from the toolchain + subprocess. aioesphomeapi isolates it, so the session survives, but it logs + a traceback for every PC/BT line and decoding is never disabled, which + buries the crash dump the user is trying to read. + """ + config = {"esphome": {"name": "test"}} + + with patch.object( + esp32, + "process_stacktrace", + side_effect=FileNotFoundError( + 2, "No such file or directory", "/build/ol/build" + ), + ) as mock_process: + processor = api_client._LogLineProcessor(config, esp32.process_stacktrace) + processor.process_line("PC: 0x4010496e") + processor.process_line("BT0: 0x4010496e") + + # Disabled after the first failure rather than retried per backtrace line. + assert mock_process.call_count == 1 + assert processor.backtrace_state is False + + def test_decoder_warning_uses_fallback_for_empty_error(caplog) -> None: """_run_idedata raises EsphomeError with no message; the warning must show a useful explanation rather than empty parens. @@ -61,7 +85,7 @@ def test_decoder_warning_uses_fallback_for_empty_error(caplog) -> None: def test_decoder_short_circuits_after_failure() -> None: """After one failure, subsequent lines must not retry the decoder. - _decode_pc shells out to PlatformIO; a crash dump can contain many + _decode_pc shells out to the toolchain; a crash dump can contain many PC/BT lines and retrying the failing subprocess for each one would stall log streaming. """ diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index e4b8971fb7..8731884ed3 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -5,12 +5,13 @@ import json import os from pathlib import Path +import subprocess from unittest.mock import patch import pytest from esphome.const import CONF_FRAMEWORK, CONF_SOURCE -from esphome.core import CORE +from esphome.core import CORE, EsphomeError from esphome.espidf import toolchain @@ -237,6 +238,77 @@ def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None: assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep) +def test_get_cmake_output_without_build_dir(setup_core: Path) -> None: + """A build dir that was never created raises EsphomeError. + + Without this, subprocess.run(cwd=build_dir) raises FileNotFoundError, which + the log stack-trace decoder doesn't recognise as a decode failure. + """ + _setup_build(setup_core) + build_dir = CORE.relative_build_path("build") + assert not build_dir.exists() + + with pytest.raises(EsphomeError, match="No ESP-IDF build found"): + toolchain._get_cmake_output(build_dir) + + +def test_get_cmake_output_without_cmake_cache(setup_core: Path) -> None: + """A build dir that exists but was never configured raises EsphomeError.""" + _setup_build(setup_core) + build_dir = CORE.relative_build_path("build") + build_dir.mkdir(parents=True) + + with pytest.raises(EsphomeError, match="No ESP-IDF build found"): + toolchain._get_cmake_output(build_dir) + + +def test_get_cmake_output_with_configured_build(setup_core: Path) -> None: + """A configured build still runs cmake and caches the output. + + The missing-build guard must not get in the way of a real build. + """ + _setup_build(setup_core) + build_dir = CORE.relative_build_path("build") + build_dir.mkdir(parents=True) + (build_dir / "CMakeCache.txt").write_text("") + + completed = subprocess.CompletedProcess( + args=[], returncode=0, stdout="CMAKE_ADDR2LINE:FILEPATH=/tool/addr2line\n" + ) + with ( + patch.object(toolchain, "_get_idf_env", return_value={}), + patch.object(toolchain.subprocess, "run", return_value=completed) as mock_run, + ): + assert toolchain._get_cmake_output(build_dir) == completed.stdout + # Second call is served from the cache rather than re-running cmake. + assert toolchain._get_cmake_output(build_dir) == completed.stdout + + mock_run.assert_called_once() + assert toolchain._get_cmake_tool_path("CMAKE_ADDR2LINE") == Path("/tool/addr2line") + + +def test_get_cmake_output_missing_build_does_not_resolve_idf_env( + setup_core: Path, +) -> None: + """The build check runs before the env is resolved. + + Resolving the env calls check_esp_idf_install(), which can download and + extract the whole framework. A doomed call must never start that. + """ + _setup_build(setup_core) + build_dir = CORE.relative_build_path("build") + + with ( + patch.object(toolchain, "_get_idf_env") as mock_env, + patch.object(toolchain.subprocess, "run") as mock_run, + pytest.raises(EsphomeError), + ): + toolchain._get_cmake_output(build_dir) + + mock_env.assert_not_called() + mock_run.assert_not_called() + + def test_get_core_framework_version_from_core_data(): """The version is read from CORE.data when validation populated it.""" from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION From bc8510c6d888c9dbcafae680a44bea21f1831977 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Jul 2026 02:09:30 -1000 Subject: [PATCH 0927/1815] [micro_wake_word] Include the local model file in bundles (#17604) --- esphome/bundle.py | 37 +++++- .../components/micro_wake_word/__init__.py | 45 ++++++- .../micro_wake_word/__init__.py | 0 .../micro_wake_word/test_init.py | 110 ++++++++++++++++++ tests/unit_tests/test_bundle.py | 65 +++++++++++ 5 files changed, 251 insertions(+), 6 deletions(-) create mode 100644 tests/component_tests/micro_wake_word/__init__.py create mode 100644 tests/component_tests/micro_wake_word/test_init.py diff --git a/esphome/bundle.py b/esphome/bundle.py index d38f68ebfd..88df87c3ba 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -7,7 +7,7 @@ and compiled directly: ``esphome compile my_device.esphomebundle.tar.gz`` from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import StrEnum import io import json @@ -32,6 +32,8 @@ from esphome.core import CORE, EsphomeError _LOGGER = logging.getLogger(__name__) +DOMAIN = "bundle" + BUNDLE_EXTENSION = ".esphomebundle.tar.gz" MANIFEST_FILENAME = "manifest.json" CURRENT_MANIFEST_VERSION = 1 @@ -120,6 +122,32 @@ def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]: return keys +@dataclass +class BundleData: + """Files components asked to include, keyed under DOMAIN in CORE.data.""" + + extra_files: list[Path] = field(default_factory=list) + + +def _get_data() -> BundleData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = BundleData() + return CORE.data[DOMAIN] + + +def add_bundle_file(path: Path) -> None: + """Register a file that a bundle must include. + + Bundle discovery walks the validated config, so it only finds files the config + names. Components call this during validation for files it cannot see, such as a + file that is referenced from inside another file. + + A relative path is taken as relative to the config directory. Files outside the + config directory are skipped when the bundle is built. + """ + _get_data().extra_files.append(CORE.relative_config_path(path)) + + @dataclass class BundleFile: """A file to include in the bundle.""" @@ -286,13 +314,18 @@ class ConfigBundleCreator: with known file extensions are also resolved and checked. Core ESPHome concepts that use relative paths or directories - are handled explicitly. + are handled explicitly. Files the config does not name at all are + registered by their component with add_bundle_file(). """ config = self._config # Generic walk: find all file paths in the validated config self._walk_config_for_files(config) + # Files registered by components during validation + for extra_file in _get_data().extra_files: + self._add_file(extra_file) + # --- Core ESPHome concepts needing explicit handling --- # esphome.includes / includes_c - can be relative paths and directories diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index cba6bcfa50..4b309551ba 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -6,6 +6,7 @@ from urllib.parse import urljoin from esphome import automation, external_files, git from esphome.automation import register_action, register_condition +from esphome.bundle import add_bundle_file import esphome.codegen as cg from esphome.components import esp32, microphone, ota, psram import esphome.config_validation as cv @@ -28,6 +29,7 @@ from esphome.const import ( TYPE_LOCAL, ) from esphome.core import CORE, HexInt +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -236,10 +238,45 @@ HTTP_SCHEMA = cv.All( _process_http_source, ) -LOCAL_SCHEMA = cv.Schema( - { - cv.Required(CONF_PATH): cv.All(_validate_json_filename, cv.file_), - } + +def _register_local_model_file(config: ConfigType) -> ConfigType: + """Register the model file that the manifest points to, so bundles include it. + + The manifest names its model file relative to itself, so that path never appears + in the YAML and bundle discovery cannot find it on its own. + + Problems with the manifest are logged and ignored here rather than raised. Loading + the manifest later reports them with better messages, and raising would be + swallowed by the shorthand validator, which then reports a confusing error about a + missing file in a git repository. Logging keeps the skipped registration + diagnosable if the manifest is only briefly unreadable, since the bundle would + then be built without the model file. + """ + manifest_path: Path = config[CONF_PATH] + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + model = manifest[CONF_MODEL] + except (OSError, ValueError, KeyError, TypeError) as err: + _LOGGER.debug("Not registering a model file from %s: %s", manifest_path, err) + return config + if not isinstance(model, str): + _LOGGER.debug( + "Not registering a model file from %s: 'model' is %s, expected a string", + manifest_path, + type(model).__name__, + ) + return config + add_bundle_file(manifest_path.parent / model) + return config + + +LOCAL_SCHEMA = cv.All( + cv.Schema( + { + cv.Required(CONF_PATH): cv.All(_validate_json_filename, cv.file_), + } + ), + _register_local_model_file, ) diff --git a/tests/component_tests/micro_wake_word/__init__.py b/tests/component_tests/micro_wake_word/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/micro_wake_word/test_init.py b/tests/component_tests/micro_wake_word/test_init.py new file mode 100644 index 0000000000..5e57653585 --- /dev/null +++ b/tests/component_tests/micro_wake_word/test_init.py @@ -0,0 +1,110 @@ +"""Tests for micro_wake_word local model validation.""" + +import json +import logging +from pathlib import Path +from typing import Any + +import pytest + +from esphome.components.micro_wake_word import LOCAL_SCHEMA +from esphome.core import CORE + +MANIFEST: dict[str, Any] = { + "type": "micro", + "model": "hey_jarvis.tflite", + "author": "someone", + "version": 2, + "wake_word": "hey jarvis", + "trained_languages": ["en"], + "micro": { + "feature_step_size": 10, + "tensor_arena_size": 30000, + "probability_cutoff": 0.97, + "sliding_window_size": 5, + "minimum_esphome_version": "2024.7.0", + }, +} + + +def _registered_files() -> list[Path]: + """Files components registered for bundling this run.""" + data = CORE.data.get("bundle") + return list(data.extra_files) if data else [] + + +@pytest.fixture +def config_dir(tmp_path: Path) -> Path: + """A config dir holding a manifest and its model file.""" + (tmp_path / "models").mkdir() + (tmp_path / "models" / "hey_jarvis.tflite").write_bytes(b"fake model") + (tmp_path / "models" / "hey_jarvis.json").write_text(json.dumps(MANIFEST)) + CORE.config_path = tmp_path / "test.yaml" + return tmp_path + + +def test_local_schema_registers_model_file(config_dir: Path) -> None: + """The model file named by the manifest is registered so bundles include it.""" + LOCAL_SCHEMA({"path": "models/hey_jarvis.json"}) + + assert _registered_files() == [config_dir / "models" / "hey_jarvis.tflite"] + + +def test_local_schema_registers_model_file_in_subdirectory(config_dir: Path) -> None: + """The model reference is resolved relative to the manifest, not the config dir.""" + nested = config_dir / "models" / "nested" + nested.mkdir() + (nested / "model.tflite").write_bytes(b"fake model") + (config_dir / "models" / "nested.json").write_text( + json.dumps({**MANIFEST, "model": "nested/model.tflite"}) + ) + + LOCAL_SCHEMA({"path": "models/nested.json"}) + + assert _registered_files() == [nested / "model.tflite"] + + +def test_local_schema_leaves_config_untouched(config_dir: Path) -> None: + """Registration is a side effect; the model file is not a config key.""" + config = LOCAL_SCHEMA({"path": "models/hey_jarvis.json"}) + + assert config == {"path": config_dir / "models" / "hey_jarvis.json"} + + +def test_local_schema_missing_model_file_still_validates(config_dir: Path) -> None: + """A model file that does not exist is registered, not rejected. + + Raising here would be swallowed by the shorthand validator, which would then + report a confusing error about a missing file in a git repository. + """ + (config_dir / "models" / "hey_jarvis.tflite").unlink() + + LOCAL_SCHEMA({"path": "models/hey_jarvis.json"}) + + assert _registered_files() == [config_dir / "models" / "hey_jarvis.tflite"] + + +@pytest.mark.parametrize( + "contents", + [ + pytest.param("{not valid json", id="malformed"), + pytest.param(json.dumps({"type": "micro"}), id="no_model_key"), + pytest.param(json.dumps(["a", "list"]), id="not_an_object"), + pytest.param(json.dumps({"model": 42}), id="model_not_a_string"), + ], +) +def test_local_schema_bad_manifest_does_not_raise( + config_dir: Path, contents: str, caplog: pytest.LogCaptureFixture +) -> None: + """Manifest problems are left to later stages, which report them better. + + The skipped registration is logged so a bundle built without the model file can + be diagnosed. + """ + (config_dir / "models" / "hey_jarvis.json").write_text(contents) + + with caplog.at_level(logging.DEBUG): + LOCAL_SCHEMA({"path": "models/hey_jarvis.json"}) + + assert _registered_files() == [] + assert "Not registering a model file" in caplog.text diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index f15bbf2e29..6cecb63c2d 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -22,6 +22,7 @@ from esphome.bundle import ( _add_bytes_to_tar, _default_target_dir, _find_used_secret_keys, + add_bundle_file, extract_bundle, is_bundle_path, prepare_bundle_for_compile, @@ -611,6 +612,70 @@ def test_discover_files_includes_config(tmp_path: Path) -> None: assert "test.yaml" in paths +def test_discover_files_includes_registered_files(tmp_path: Path) -> None: + """Files registered with add_bundle_file() are included. + + The config does not name them, so discovery cannot find them on its own. + """ + config_dir = _setup_config_dir( + tmp_path, + files={"models/model.tflite": "fake model data"}, + ) + add_bundle_file(config_dir / "models" / "model.tflite") + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "models/model.tflite" in paths + + +def test_discover_files_registered_relative_file(tmp_path: Path) -> None: + """A relative registered path is taken as relative to the config directory. + + Not the working directory, which is where Path.resolve() would put it. + """ + _setup_config_dir( + tmp_path, + files={"models/model.tflite": "fake model data"}, + ) + add_bundle_file(Path("models/model.tflite")) + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "models/model.tflite" in paths + + +def test_discover_files_registered_file_outside_config_dir(tmp_path: Path) -> None: + """A registered file outside the config directory is skipped, not bundled.""" + _setup_config_dir(tmp_path) + outside = tmp_path / "outside.tflite" + outside.write_text("fake model data") + add_bundle_file(outside) + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + assert [f.path for f in files] == ["test.yaml"] + + +def test_discover_files_registered_file_deduplicated(tmp_path: Path) -> None: + """Registering the same file twice adds it once.""" + config_dir = _setup_config_dir( + tmp_path, + files={"models/model.tflite": "fake model data"}, + ) + add_bundle_file(config_dir / "models" / "model.tflite") + add_bundle_file(config_dir / "models" / "model.tflite") + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + assert [f.path for f in files].count("models/model.tflite") == 1 + + def test_discover_files_finds_path_objects(tmp_path: Path) -> None: """Path objects in validated config are discovered.""" config_dir = _setup_config_dir( From dcdd044234255e6a10ca59cf91d6d535e9095fc1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Jul 2026 08:48:47 -1000 Subject: [PATCH 0928/1815] [web_server] Use alarm_control_panel as the domain in JSON (#17594) --- esphome/components/web_server/web_server.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 1e6c4e8c62..2faa2ab66b 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1907,7 +1907,7 @@ json::SerializationBuffer<> WebServer::alarm_control_panel_json_(alarm_control_p json::JsonBuilder builder; JsonObject root = builder.root(); - set_json_icon_state_value(root, obj, "alarm-control-panel", + set_json_icon_state_value(root, obj, "alarm_control_panel", json_state_str(alarm_control_panel_state_to_string(value)), value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); From 2218edf20c73b3a30c1120471b8d69ba6ba514c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Jul 2026 08:49:43 -1000 Subject: [PATCH 0929/1815] [web_server] Switch entity id to the new format and drop name_id (#17586) --- esphome/components/web_server/web_server.cpp | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 2faa2ab66b..d06b6d6408 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -558,26 +558,19 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J size_t device_len = device_name ? strlen(device_name) : 0; #endif - // Single stack buffer for both id formats - ArduinoJson copies the string before we overwrite + // Stack buffer for the id - ArduinoJson copies the string before it goes out of scope // Buffer sizes use constants from entity_base.h validated in core/config.py // Note: Device name uses ESPHOME_FRIENDLY_NAME_MAX_LEN (sub-device max 120), not ESPHOME_DEVICE_NAME_MAX_LEN // (hostname) - // Without USE_DEVICES: legacy id ({prefix}-{object_id}) is the largest format - // With USE_DEVICES: name_id ({prefix}/{device}/{name}) is the largest format - static constexpr size_t LEGACY_ID_SIZE = ESPHOME_DOMAIN_MAX_LEN + 1 + OBJECT_ID_MAX_LEN; #ifdef USE_DEVICES static constexpr size_t ID_BUF_SIZE = - std::max(ESPHOME_DOMAIN_MAX_LEN + 1 + ESPHOME_FRIENDLY_NAME_MAX_LEN + 1 + ESPHOME_FRIENDLY_NAME_MAX_LEN + 1, - LEGACY_ID_SIZE); + ESPHOME_DOMAIN_MAX_LEN + 1 + ESPHOME_FRIENDLY_NAME_MAX_LEN + 1 + ESPHOME_FRIENDLY_NAME_MAX_LEN + 1; #else - static constexpr size_t ID_BUF_SIZE = - std::max(ESPHOME_DOMAIN_MAX_LEN + 1 + ESPHOME_FRIENDLY_NAME_MAX_LEN + 1, LEGACY_ID_SIZE); + static constexpr size_t ID_BUF_SIZE = ESPHOME_DOMAIN_MAX_LEN + 1 + ESPHOME_FRIENDLY_NAME_MAX_LEN + 1; #endif char id_buf[ID_BUF_SIZE]; memcpy(id_buf, prefix, prefix_len); // NOLINT(bugprone-not-null-terminated-result) - // name_id: new format {prefix}/{device?}/{name} - frontend should prefer this - // Remove in 2026.8.0 when id switches to new format permanently char *p = id_buf + prefix_len; *p++ = '/'; #ifdef USE_DEVICES @@ -589,12 +582,6 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J #endif memcpy(p, name.c_str(), name_len); p[name_len] = '\0'; - root[ESPHOME_F("name_id")] = id_buf; - - // id: old format {prefix}-{object_id} for backward compatibility - // Will switch to new format in 2026.8.0 - reuses prefix already in id_buf - id_buf[prefix_len] = '-'; - obj->write_object_id_to(id_buf + prefix_len + 1, ID_BUF_SIZE - prefix_len - 1); root[ESPHOME_F("id")] = id_buf; if (start_config == DETAIL_ALL) { From f53394e46ff1bad541189d0ec0f7264143177f0a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:14:18 -0400 Subject: [PATCH 0930/1815] [mlx90393] Compile out on BK72xx where the bundled library cannot build (#17614) --- esphome/components/mlx90393/sensor_mlx90393.cpp | 4 ++++ esphome/components/mlx90393/sensor_mlx90393.h | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/esphome/components/mlx90393/sensor_mlx90393.cpp b/esphome/components/mlx90393/sensor_mlx90393.cpp index 7048302124..2288e8ff84 100644 --- a/esphome/components/mlx90393/sensor_mlx90393.cpp +++ b/esphome/components/mlx90393/sensor_mlx90393.cpp @@ -1,3 +1,5 @@ +#ifndef USE_BK72XX + #include "sensor_mlx90393.h" #include "esphome/core/log.h" @@ -270,3 +272,5 @@ void MLX90393Cls::verify_settings_timeout_(MLX90393Setting stage) { } } // namespace esphome::mlx90393 + +#endif // USE_BK72XX diff --git a/esphome/components/mlx90393/sensor_mlx90393.h b/esphome/components/mlx90393/sensor_mlx90393.h index e3b7ae5d93..03e78f51cc 100644 --- a/esphome/components/mlx90393/sensor_mlx90393.h +++ b/esphome/components/mlx90393/sensor_mlx90393.h @@ -1,5 +1,7 @@ #pragma once +#ifndef USE_BK72XX + #include #include #include "esphome/components/i2c/i2c.h" @@ -76,3 +78,5 @@ class MLX90393Cls final : public PollingComponent, public i2c::I2CDevice, public }; } // namespace esphome::mlx90393 + +#endif // USE_BK72XX From c0e78a5574ac12c8cbefd2f8f46e1c13bd10d392 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:22:19 -0400 Subject: [PATCH 0931/1815] [core] Improve framework mirror selection, download errors, and version parsing (#17615) --- esphome/config_validation.py | 6 +- esphome/espidf/framework.py | 31 ++++-- esphome/framework_helpers.py | 71 +++++++++++-- tests/component_tests/esp32/test_esp32.py | 25 +++++ tests/unit_tests/test_config_validation.py | 36 ++++++- tests/unit_tests/test_espidf_framework.py | 23 +++++ tests/unit_tests/test_framework_helpers.py | 111 ++++++++++++++++++++- 7 files changed, 281 insertions(+), 22 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 16f0a63aa0..3f7c8ff783 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -422,12 +422,14 @@ class Version: @classmethod def parse(cls, value: str) -> Version: - match = re.match(r"^(\d+).(\d+).(\d+)[-.]?(\w*)$", value) + # The patch component is optional and defaults to 0, so "6.0" and + # "6.0-rc1" parse as 6.0.0 and 6.0.0-rc1. + match = re.match(r"^(\d+)\.(\d+)(?:\.(\d+))?[-.]?(\w*)$", value) if match is None: raise ValueError(f"Not a valid version number {value}") major = int(match[1]) minor = int(match[2]) - patch = int(match[3]) + patch = int(match[3] or 0) extra = match[4] or "" return Version(major=major, minor=minor, patch=patch, extra=extra) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 810a63476f..18aa966bff 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -63,7 +63,7 @@ ESPHOME_IDF_FRAMEWORK_MIRRORS = str_to_lst_of_str( os.environ.get("ESPHOME_IDF_FRAMEWORK_MIRRORS") or [ "https://github.com/esphome-libs/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.tar.xz", - "https://github.com/esphome-libs/esp-idf/releases/download/v{MAJOR}.{MINOR}{EXTRA}/esp-idf-v{MAJOR}.{MINOR}{EXTRA}.tar.xz", + "https://github.com/esphome-libs/esp-idf/releases/download/v{SHORT_VERSION}/esp-idf-v{SHORT_VERSION}.tar.xz", ] ) @@ -536,10 +536,14 @@ def _check_esphome_idf_framework_install( env: Optional dictionary of environment variables to set source_url: Optional override URL for the framework tarball. Supports the same ``{VERSION}`` / ``{MAJOR}`` / ``{MINOR}`` / ``{PATCH}`` / - ``{EXTRA}`` substitutions as ESPHOME_IDF_FRAMEWORK_MIRRORS - (``{EXTRA}`` includes its leading ``-``, e.g. ``-rc1``, or is empty). - When set, it replaces the default mirror list — no implicit fallback, - so a misspelled URL fails loudly. + ``{EXTRA}`` / ``{SHORT_VERSION}`` substitutions as + ESPHOME_IDF_FRAMEWORK_MIRRORS (``{EXTRA}`` includes its leading + ``-``, e.g. ``-rc1``, or is empty; ``{SHORT_VERSION}`` is ``x.y`` + plus any extra and only available for x.y.0 versions — a URL + referencing it is skipped for other versions). When set, it + replaces the default mirror list — no implicit fallback, so a + misspelled or skipped URL fails loudly with an EsphomeError naming + the URL. Returns: tuple of (framework_path, install_flag) @@ -588,7 +592,11 @@ def _check_esphome_idf_framework_install( with tempfile.NamedTemporaryFile() as tmp: _LOGGER.info("Downloading ESP-IDF %s framework ...", version) - # Create substitutions for the URLs + # Create substitutions for the URLs. SHORT_VERSION (x.y with + # optional -extra) is only provided for x.y.0 releases, since + # the vX.Y release tags only exist for those; templates that + # reference it are skipped for other versions by + # download_from_mirrors. substitutions = {"VERSION": version} try: ver = Version.parse(version) @@ -596,8 +604,17 @@ def _check_esphome_idf_framework_install( substitutions["MINOR"] = str(ver.minor) substitutions["PATCH"] = str(ver.patch) substitutions["EXTRA"] = f"-{ver.extra}" if ver.extra else "" + if ver.patch == 0: + substitutions["SHORT_VERSION"] = ( + f"{ver.major}.{ver.minor}{substitutions['EXTRA']}" + ) except ValueError: - pass + _LOGGER.warning( + "ESP-IDF version '%s' is not a valid version number; " + "only the {VERSION} substitution is available for " + "mirror URLs", + version, + ) mirrors = [source_url] if source_url else ESPHOME_IDF_FRAMEWORK_MIRRORS download_from_mirrors(mirrors, substitutions, tmp.file) diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 70d440d995..6c055dded3 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -552,6 +552,17 @@ def archive_extract_all( matched_fct(archive_ref, extract_dir, progress_header=progress_header) +def _failure_reason(e: Exception) -> str: + """Format a download exception for the aggregated error message. + + ``requests`` appends " for url: " to HTTP errors; the URL is already + printed on the line above, so strip the suffix to keep lines short. Falls + back to the repr for exceptions with no message (e.g. ``TimeoutError()``) + so the line always names the failure. + """ + return str(e).split(" for url: ", maxsplit=1)[0] or repr(e) + + def download_from_mirrors( mirrors: list[str], substitutions: dict[str, str], @@ -570,14 +581,22 @@ def download_from_mirrors( Returns: The source URL. + Mirror URL templates that reference a substitution not present in + ``substitutions`` are skipped, so callers can offer templates that only + apply to some downloads. + Raises: ValueError: If mirrors list is empty. - Exception: If all download attempts fail. + EsphomeError: If all download attempts fail; the message lists every + attempted URL with its individual failure reason. Also raised if + no template matched the provided substitutions. """ # Imported lazily: requests is a heavy import (~85ms) and is only needed # when actually downloading a toolchain, never during config validation. import requests + from esphome.core import EsphomeError + # 1. Open target file for writing if path given with ExitStack() as stack: if isinstance(target, (str, os.PathLike)): @@ -590,13 +609,31 @@ def download_from_mirrors( ) # 2. Try each mirror in order - last_exception = None + failures: list[tuple[str, Exception]] = [] + skipped: list[tuple[str, str]] = [] for mirror in mirrors: # 3. Apply substitutions to URL - url = mirror.format(**substitutions) + try: + url = mirror.format(**substitutions) + except KeyError as e: + # The template references a substitution not provided for + # this download (e.g. SHORT_VERSION only exists for x.y.0 + # versions) - expected, the template just doesn't apply. + _LOGGER.debug("Skipping mirror %s: %s not available", mirror, e) + skipped.append((mirror, f"not applicable ({e.args[0]} not available)")) + continue + except (IndexError, ValueError) as e: + # A malformed template (unbalanced braces, bad format spec) + # is an authoring error, not an expected fallthrough - warn + # even if a later mirror succeeds. + _LOGGER.warning( + "Skipping malformed mirror URL template %s: %r", mirror, e + ) + skipped.append((mirror, f"skipped ({e!r})")) + continue - _LOGGER.debug("Trying downloading from %s", url) + _LOGGER.debug("Trying to download from %s", url) try: # 4. Reset file pointer and download @@ -631,9 +668,27 @@ def download_from_mirrors( except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught _LOGGER.debug("Failed to download %s: %s", url, str(e)) - last_exception = e + failures.append((url, e)) - # 7. Raise last exception if all mirrors failed - if last_exception: - raise last_exception + # 7. Report every attempted URL if all mirrors failed. Falling back + # past an early mirror is normal (e.g. only one of the framework URL + # templates matches a given version's tag), so raising only the last + # error would hide the failure that actually matters. + if failures: + attempts = "".join( + f"\n {url}\n {_failure_reason(e)}" for url, e in failures + ) + attempts += "".join( + f"\n {mirror}\n {reason}" for mirror, reason in skipped + ) + raise EsphomeError( + f"Failed to download from all mirrors:{attempts}" + ) from failures[0][1] + if skipped: + details = "".join( + f"\n {mirror}\n {reason}" for mirror, reason in skipped + ) + raise EsphomeError( + f"No mirror URL template matched the provided substitutions:{details}" + ) raise ValueError("download_from_mirrors called with an empty mirrors list") diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index fdca70bf2c..8a116ccc27 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -740,3 +740,28 @@ def test_signed_ota_keys_invalid_combinations(config: dict, match: str) -> None: with pytest.raises(cv.Invalid, match=match): _validate_signed_ota_keys(config) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + # Full x.y.z versions are rewritten into pioarduino release URLs + ( + "55.3.30", + "https://github.com/pioarduino/platform-espressif32/releases/download/55.03.30/platform-espressif32.zip", + ), + ( + "55.3.31-2", + "https://github.com/pioarduino/platform-espressif32/releases/download/55.03.31-2/platform-espressif32.zip", + ), + # Non-version values pass through untouched + ( + "https://github.com/pioarduino/platform-espressif32.git#develop", + "https://github.com/pioarduino/platform-espressif32.git#develop", + ), + ], +) +def test_parse_pio_platform_version(value: str, expected: str) -> None: + from esphome.components.esp32 import _parse_pio_platform_version + + assert _parse_pio_platform_version(value) == expected diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 17dfaad9b8..fd21ac92ea 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1436,9 +1436,41 @@ def test_version_parse_with_extra() -> None: assert version.extra == "dev20240101" -def test_version_parse_invalid() -> None: +def test_version_parse_without_patch() -> None: + """A two-part version parses with patch defaulting to 0, so framework + shorthands like '6.0' and '6.0-rc1' are accepted.""" + version = cv.Version.parse("6.0") + assert (version.major, version.minor, version.patch, version.extra) == ( + 6, + 0, + 0, + "", + ) + version = cv.Version.parse("6.0-rc1") + assert (version.major, version.minor, version.patch, version.extra) == ( + 6, + 0, + 0, + "rc1", + ) + + +def test_version_parse_numeric_extra() -> None: + """Four-part versions keep the trailing component as extra (pioarduino + packaging revisions, e.g. 5.5.3.1).""" + version = cv.Version.parse("5.5.3.1") + assert (version.major, version.minor, version.patch, version.extra) == ( + 5, + 5, + 3, + "1", + ) + + +@pytest.mark.parametrize("value", ["not.a.version", "6", "a.b", ""]) +def test_version_parse_invalid(value: str) -> None: with pytest.raises(ValueError, match="Not a valid version number"): - cv.Version.parse("not.a.version") + cv.Version.parse(value) def test_version_is_beta() -> None: diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index c5d9ddbaf1..de02a6b227 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -489,6 +489,29 @@ def test_check_esp_idf_install_unparseable_version( espidf_mocks.extract.assert_called_once() +@pytest.mark.parametrize( + ("version", "short_version"), + [ + ("6.0.0", "6.0"), + ("6.0.0-rc1", "6.0-rc1"), + ("5.5.4", None), # vX.Y tags only exist for X.Y.0 releases + ], +) +def test_check_esp_idf_install_short_version_substitution( + espidf_mocks: SimpleNamespace, version: str, short_version: str | None +) -> None: + """SHORT_VERSION is only offered for x.y.0 releases, so the vX.Y mirror + template is never tried for versions whose tag cannot exist.""" + _get_framework_path(version).mkdir(parents=True, exist_ok=True) + check_esp_idf_install(version, force=True) + + # First call downloads the framework archive; a later call fetches the + # constraints file with its own substitutions. + substitutions = espidf_mocks.download.call_args_list[0][0][1] + assert substitutions.get("SHORT_VERSION") == short_version + assert substitutions["VERSION"] == version + + # --------------------------------------------------------------------------- # _patch_tools_json_for_linux_arm64 (arm64-only ninja backport) # --------------------------------------------------------------------------- diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index 69b9f20eaa..e662d2d015 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -16,6 +16,7 @@ import zipfile import pytest import requests as req +from esphome.core import EsphomeError from esphome.framework_helpers import ( _7z_extract_all, _detect_archive_root, @@ -546,6 +547,99 @@ class TestDownloadFromMirrors: ) assert mock_get.call_args[0][0] == "https://example.com/1.2.3.bin" + def test_template_with_missing_substitution_is_skipped( + self, tmp_path: Path + ) -> None: + """A template referencing an unavailable substitution is skipped, not + formatted into a bogus URL (e.g. SHORT_VERSION only exists for x.y.0 + framework versions).""" + with patch( + "requests.get", + return_value=_mock_response(b"x"), + ) as mock_get: + url = download_from_mirrors( + [ + "https://example.com/{SHORT_VERSION}.bin", + "https://example.com/{VERSION}.bin", + ], + {"VERSION": "1.2.3"}, + tmp_path / "out.bin", + ) + assert url == "https://example.com/1.2.3.bin" + assert mock_get.call_count == 1 + + def test_all_templates_skipped_raises_esphome_error(self, tmp_path: Path) -> None: + with ( + patch("requests.get") as mock_get, + pytest.raises(EsphomeError, match="No mirror URL template matched") as ei, + ): + download_from_mirrors( + ["https://example.com/{MISSING}.bin"], + {"VERSION": "1.2.3"}, + tmp_path / "out.bin", + ) + mock_get.assert_not_called() + # The skipped template and its missing substitution are named + assert "https://example.com/{MISSING}.bin" in str(ei.value) + assert "MISSING" in str(ei.value) + + def test_failure_message_includes_skipped_templates(self, tmp_path: Path) -> None: + """When downloads fail, templates that were skipped for missing + substitutions are also listed so a typo'd custom mirror is + attributable.""" + with ( + patch( + "requests.get", + return_value=_mock_response(b"", ok=False), + ), + pytest.raises(EsphomeError, match="all mirrors") as ei, + ): + download_from_mirrors( + [ + "https://example.com/{TYPO}.bin", + "https://example.com/{VERSION}.bin", + ], + {"VERSION": "1.2.3"}, + tmp_path / "out.bin", + ) + message = str(ei.value) + assert "https://example.com/1.2.3.bin" in message + assert ( + "https://example.com/{TYPO}.bin\n not applicable (TYPO not available)" + in message + ) + + def test_malformed_template_warns_and_is_reported( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + """A structurally malformed template is an authoring error: warned + about even when another mirror succeeds, and named in the aggregate + error when everything fails.""" + with ( + patch("requests.get", return_value=_mock_response(b"x")), + caplog.at_level(logging.WARNING, logger="esphome.framework_helpers"), + ): + url = download_from_mirrors( + ["https://example.com/{oops.bin", "https://example.com/{VERSION}.bin"], + {"VERSION": "1.2.3"}, + tmp_path / "out.bin", + ) + assert url == "https://example.com/1.2.3.bin" + assert "malformed mirror URL template" in caplog.text + + with ( + patch("requests.get", return_value=_mock_response(b"", ok=False)), + pytest.raises(EsphomeError, match="all mirrors") as ei, + ): + download_from_mirrors( + ["https://example.com/{oops.bin", "https://example.com/{VERSION}.bin"], + {"VERSION": "1.2.3"}, + tmp_path / "out.bin", + ) + assert "https://example.com/{oops.bin\n skipped (ValueError(" in str( + ei.value + ) + def test_falls_back_to_second_mirror(self, tmp_path: Path) -> None: with patch( "requests.get", @@ -559,15 +653,26 @@ class TestDownloadFromMirrors: assert url == "https://mirror2.com/f" assert (tmp_path / "out.bin").read_bytes() == b"second" - def test_all_mirrors_fail_reraises_last_exception(self, tmp_path: Path) -> None: + def test_all_mirrors_fail_raises_error_listing_every_attempt( + self, tmp_path: Path + ) -> None: with ( patch( "requests.get", return_value=_mock_response(b"", ok=False), ), - pytest.raises(req.HTTPError), + pytest.raises(EsphomeError, match="all mirrors") as excinfo, ): - download_from_mirrors(["https://example.com/f"], {}, tmp_path / "out.bin") + download_from_mirrors( + ["https://mirror1.com/f", "https://mirror2.com/f"], + {}, + tmp_path / "out.bin", + ) + # Every attempted URL appears in the message, and the first mirror's + # exception (the primary URL, usually the one that matters) is chained. + assert "https://mirror1.com/f" in str(excinfo.value) + assert "https://mirror2.com/f" in str(excinfo.value) + assert isinstance(excinfo.value.__cause__, req.HTTPError) def test_empty_mirrors_raises_value_error(self, tmp_path: Path) -> None: with pytest.raises(ValueError, match="empty mirrors list"): From 5b37049dff3cc101d7e438a442ea19bd767f101e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:23:46 -0400 Subject: [PATCH 0932/1815] [midea] Restrict to platforms where MideaUART actually builds (#17613) --- esphome/components/midea/ac_adapter.cpp | 2 +- esphome/components/midea/ac_adapter.h | 2 +- esphome/components/midea/ac_automations.h | 2 +- esphome/components/midea/air_conditioner.cpp | 2 +- esphome/components/midea/air_conditioner.h | 2 +- esphome/components/midea/appliance_base.h | 2 +- esphome/components/midea/climate.py | 6 ------ esphome/components/midea/ir_transmitter.h | 2 +- 8 files changed, 7 insertions(+), 13 deletions(-) diff --git a/esphome/components/midea/ac_adapter.cpp b/esphome/components/midea/ac_adapter.cpp index 3611b20715..77bb9bbe86 100644 --- a/esphome/components/midea/ac_adapter.cpp +++ b/esphome/components/midea/ac_adapter.cpp @@ -1,4 +1,4 @@ -#if defined(USE_ARDUINO) && !defined(USE_RP2) +#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) #include "esphome/core/log.h" #include "ac_adapter.h" diff --git a/esphome/components/midea/ac_adapter.h b/esphome/components/midea/ac_adapter.h index 53959efe2a..4545743564 100644 --- a/esphome/components/midea/ac_adapter.h +++ b/esphome/components/midea/ac_adapter.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ARDUINO) && !defined(USE_RP2) +#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) // MideaUART #include diff --git a/esphome/components/midea/ac_automations.h b/esphome/components/midea/ac_automations.h index 9c35e191b5..b595a018b3 100644 --- a/esphome/components/midea/ac_automations.h +++ b/esphome/components/midea/ac_automations.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ARDUINO) && !defined(USE_RP2) +#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) #include "esphome/core/automation.h" #include "air_conditioner.h" diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index a743e867af..e55afedd8a 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -1,4 +1,4 @@ -#if defined(USE_ARDUINO) && !defined(USE_RP2) +#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) #include "esphome/core/helpers.h" #include "esphome/core/log.h" diff --git a/esphome/components/midea/air_conditioner.h b/esphome/components/midea/air_conditioner.h index cd04c87890..089928902e 100644 --- a/esphome/components/midea/air_conditioner.h +++ b/esphome/components/midea/air_conditioner.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ARDUINO) && !defined(USE_RP2) +#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) // MideaUART #include diff --git a/esphome/components/midea/appliance_base.h b/esphome/components/midea/appliance_base.h index d9486564c0..1b45561fab 100644 --- a/esphome/components/midea/appliance_base.h +++ b/esphome/components/midea/appliance_base.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ARDUINO) && !defined(USE_RP2) +#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) // MideaUART #include diff --git a/esphome/components/midea/climate.py b/esphome/components/midea/climate.py index aedb517f89..b0c102af6d 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -25,11 +25,8 @@ from esphome.const import ( ICON_POWER, ICON_THERMOMETER, ICON_WATER_PERCENT, - PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, - PLATFORM_LN882X, - PLATFORM_RTL87XX, STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, UNIT_PERCENT, @@ -161,9 +158,6 @@ CONFIG_SCHEMA = cv.All( [ PLATFORM_ESP32, PLATFORM_ESP8266, - PLATFORM_BK72XX, - PLATFORM_RTL87XX, - PLATFORM_LN882X, ] ), ) diff --git a/esphome/components/midea/ir_transmitter.h b/esphome/components/midea/ir_transmitter.h index 43a2e2f261..ecf3fa1c1a 100644 --- a/esphome/components/midea/ir_transmitter.h +++ b/esphome/components/midea/ir_transmitter.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ARDUINO) && !defined(USE_RP2) +#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) #ifdef USE_REMOTE_TRANSMITTER #include "esphome/components/remote_base/midea_protocol.h" From 72bd45538e29e031840b550860d998209938f46b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:25:45 -0400 Subject: [PATCH 0933/1815] [opentherm] Rename OpenthermData accessors that collide with vendor SDK type macros (#17611) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/components/opentherm/hub.cpp | 12 ++++++------ esphome/components/opentherm/opentherm.cpp | 16 ++++++++-------- esphome/components/opentherm/opentherm.h | 14 +++++++------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/esphome/components/opentherm/hub.cpp b/esphome/components/opentherm/hub.cpp index e2828a9e30..f8b515fa05 100644 --- a/esphome/components/opentherm/hub.cpp +++ b/esphome/components/opentherm/hub.cpp @@ -27,11 +27,11 @@ uint8_t parse_u8_lb(OpenthermData &data) { return data.valueLB; } uint8_t parse_u8_hb(OpenthermData &data) { return data.valueHB; } int8_t parse_s8_lb(OpenthermData &data) { return (int8_t) data.valueLB; } int8_t parse_s8_hb(OpenthermData &data) { return (int8_t) data.valueHB; } -uint16_t parse_u16(OpenthermData &data) { return data.u16(); } +uint16_t parse_u16(OpenthermData &data) { return data.get_u16(); } uint16_t parse_u8_lb_60(OpenthermData &data) { return data.valueLB * 60; } uint16_t parse_u8_hb_60(OpenthermData &data) { return data.valueHB * 60; } -int16_t parse_s16(OpenthermData &data) { return data.s16(); } -float parse_f88(OpenthermData &data) { return data.f88(); } +int16_t parse_s16(OpenthermData &data) { return data.get_s16(); } +float parse_f88(OpenthermData &data) { return data.get_f88(); } void write_flag8_lb_0(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 0, value); } void write_flag8_lb_1(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 1, value); } @@ -53,9 +53,9 @@ void write_u8_lb(const uint8_t value, OpenthermData &data) { data.valueLB = valu void write_u8_hb(const uint8_t value, OpenthermData &data) { data.valueHB = value; } void write_s8_lb(const int8_t value, OpenthermData &data) { data.valueLB = (uint8_t) value; } void write_s8_hb(const int8_t value, OpenthermData &data) { data.valueHB = (uint8_t) value; } -void write_u16(const uint16_t value, OpenthermData &data) { data.u16(value); } -void write_s16(const int16_t value, OpenthermData &data) { data.s16(value); } -void write_f88(const float value, OpenthermData &data) { data.f88(value); } +void write_u16(const uint16_t value, OpenthermData &data) { data.set_u16(value); } +void write_s16(const int16_t value, OpenthermData &data) { data.set_s16(value); } +void write_f88(const float value, OpenthermData &data) { data.set_f88(value); } } // namespace message_data diff --git a/esphome/components/opentherm/opentherm.cpp b/esphome/components/opentherm/opentherm.cpp index 5cf7c19880..e05dbf8d82 100644 --- a/esphome/components/opentherm/opentherm.cpp +++ b/esphome/components/opentherm/opentherm.cpp @@ -533,34 +533,34 @@ void OpenTherm::debug_data(OpenthermData &data) { ESP_LOGD(TAG, "%s %s %s %s", format_bin_to(type_buf, data.type), format_bin_to(id_buf, data.id), format_bin_to(hb_buf, data.valueHB), format_bin_to(lb_buf, data.valueLB)); ESP_LOGD(TAG, "type: %s; id: %u; HB: %u; LB: %u; uint_16: %u; float: %f", - this->message_type_to_str((MessageType) data.type), data.id, data.valueHB, data.valueLB, data.u16(), - data.f88()); + this->message_type_to_str((MessageType) data.type), data.id, data.valueHB, data.valueLB, data.get_u16(), + data.get_f88()); } void OpenTherm::debug_error(OpenThermError &error) const { ESP_LOGD(TAG, "data: 0x%08" PRIx32 "; clock: %u; capture: 0x%08" PRIx32 "; bit_pos: %u", error.data, this->clock_, error.capture, error.bit_pos); } -float OpenthermData::f88() { return ((float) this->s16()) / 256.0f; } +float OpenthermData::get_f88() { return ((float) this->get_s16()) / 256.0f; } -void OpenthermData::f88(float value) { this->s16((int16_t) (value * 256)); } +void OpenthermData::set_f88(float value) { this->set_s16((int16_t) (value * 256)); } -uint16_t OpenthermData::u16() { +uint16_t OpenthermData::get_u16() { uint16_t const value = this->valueHB; return (value << 8) | this->valueLB; } -void OpenthermData::u16(uint16_t value) { +void OpenthermData::set_u16(uint16_t value) { this->valueLB = value & 0xFF; this->valueHB = (value >> 8) & 0xFF; } -int16_t OpenthermData::s16() { +int16_t OpenthermData::get_s16() { int16_t const value = this->valueHB; return (value << 8) | this->valueLB; } -void OpenthermData::s16(int16_t value) { +void OpenthermData::set_s16(int16_t value) { this->valueLB = value & 0xFF; this->valueHB = (value >> 8) & 0xFF; } diff --git a/esphome/components/opentherm/opentherm.h b/esphome/components/opentherm/opentherm.h index 3078e92c9d..7aa81cd8a2 100644 --- a/esphome/components/opentherm/opentherm.h +++ b/esphome/components/opentherm/opentherm.h @@ -178,7 +178,7 @@ enum BitPositions { STOP_BIT = 33 }; /** * Structure to hold Opentherm data packet content. - * Use f88(), u16() or s16() functions to get appropriate value of data packet accoridng to id of message. + * Use get_f88(), get_u16() or get_s16() functions to get appropriate value of data packet according to id of message. */ struct OpenthermData { uint8_t type; @@ -191,32 +191,32 @@ struct OpenthermData { /** * @return float representation of data packet value */ - float f88(); + float get_f88(); /** * @param float number to set as value of this data packet */ - void f88(float value); + void set_f88(float value); /** * @return unsigned 16b integer representation of data packet value */ - uint16_t u16(); + uint16_t get_u16(); /** * @param unsigned 16b integer number to set as value of this data packet */ - void u16(uint16_t value); + void set_u16(uint16_t value); /** * @return signed 16b integer representation of data packet value */ - int16_t s16(); + int16_t get_s16(); /** * @param signed 16b integer number to set as value of this data packet */ - void s16(int16_t value); + void set_s16(int16_t value); }; struct OpenThermError { From 09da2766ed9517ce01ad7ae3df24ea4005ce8e47 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:32:09 -0400 Subject: [PATCH 0934/1815] [bluetooth_proxy] Bound GATT characteristic/descriptor enumeration to allocated size (#17625) --- esphome/components/bluetooth_proxy/bluetooth_connection.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 7ba9e61e19..9820977a13 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -245,7 +245,9 @@ void BluetoothConnection::send_service_for_discovery_() { service_resp.characteristics.init(total_char_count); uint16_t char_offset = 0; esp_gattc_char_elem_t char_result; - while (true) { // characteristics + // Bound by total_char_count: the vector is sized for it, and a malicious peripheral + // can make enumeration return more entries than the count query reported + while (char_offset < total_char_count) { // characteristics uint16_t char_count = 1; esp_gatt_status_t char_status = esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, @@ -287,7 +289,7 @@ void BluetoothConnection::send_service_for_discovery_() { characteristic_resp.descriptors.init(total_desc_count); uint16_t desc_offset = 0; esp_gattc_descr_elem_t desc_result; - while (true) { // descriptors + while (desc_offset < total_desc_count) { // descriptors uint16_t desc_count = 1; esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr( this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset); From 6c8d0050abef9d0cfacc3d9a116584f0ea2995aa Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:32:44 -0400 Subject: [PATCH 0935/1815] [esp32_ble_server] Validate descriptor write length before copying (#17626) --- esphome/components/esp32_ble_server/ble_descriptor.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/esp32_ble_server/ble_descriptor.cpp b/esphome/components/esp32_ble_server/ble_descriptor.cpp index 5ca80d6a7a..3dcac3691c 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.cpp +++ b/esphome/components/esp32_ble_server/ble_descriptor.cpp @@ -77,6 +77,10 @@ void BLEDescriptor::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_ case ESP_GATTS_WRITE_EVT: { if (this->handle_ != param->write.handle) break; + if (param->write.len > this->value_.attr_max_len) { + ESP_LOGE(TAG, "Size %d too large, must be no bigger than %d", param->write.len, this->value_.attr_max_len); + break; + } this->value_.attr_len = param->write.len; memcpy(this->value_.attr_value, param->write.value, param->write.len); if (this->on_write_callback_) { From a624659856904b591cf76744ebd43c4b4865f94e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:33:30 -0400 Subject: [PATCH 0936/1815] Bump github/codeql-action/analyze from 4.37.0 to 4.37.1 (#17630) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e718b481e0..4779b8059e 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 with: category: "/language:${{matrix.language}}" From 3caae3031a189ddc00bd29b0b0910792817dbdc9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:33:38 -0400 Subject: [PATCH 0937/1815] Bump github/codeql-action/init from 4.37.0 to 4.37.1 (#17629) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4779b8059e..70527a0fa2 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} From 4507d058221774608d70a2ed1c9dbc7b58e506d3 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:34:49 -0400 Subject: [PATCH 0938/1815] [http_request] Fix use-after-return of header collection state in IDF backend (#17627) --- .../components/http_request/http_request_idf.cpp | 15 +++++---------- .../components/http_request/http_request_idf.h | 2 ++ 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index 3e341395a4..a437540241 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -19,11 +19,6 @@ namespace esphome::http_request { static const char *const TAG = "http_request.idf"; static constexpr uint32_t ERROR_DURATION_MS = 1000; -struct UserData { - const std::vector &lower_case_collect_headers; - std::vector

&response_headers; -}; - void HttpRequestIDF::dump_config() { HttpRequestComponent::dump_config(); ESP_LOGCONFIG(TAG, @@ -34,15 +29,15 @@ void HttpRequestIDF::dump_config() { } esp_err_t HttpRequestIDF::http_event_handler(esp_http_client_event_t *evt) { - UserData *user_data = (UserData *) evt->user_data; + auto *container = (HttpContainerIDF *) evt->user_data; switch (evt->event_id) { case HTTP_EVENT_ON_HEADER: { const std::string header_name = str_lower_case(evt->header_key); // NOLINT - if (should_collect_header(user_data->lower_case_collect_headers, header_name)) { + if (should_collect_header(container->collect_headers_, header_name)) { const std::string header_value = evt->header_value; ESP_LOGD(TAG, "Received response header, name: %s, value: %s", header_name.c_str(), header_value.c_str()); - user_data->response_headers.push_back({header_name, header_value}); + container->response_headers_.push_back({header_name, header_value}); } break; } @@ -124,8 +119,8 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c container->set_secure(secure); - auto user_data = UserData{lower_case_collect_headers, container->response_headers_}; - esp_http_client_set_user_data(client, static_cast(&user_data)); + container->collect_headers_ = lower_case_collect_headers; + esp_http_client_set_user_data(client, static_cast(container.get())); for (const auto &header : request_headers) { esp_http_client_set_header(client, header.name.c_str(), header.value.c_str()); diff --git a/esphome/components/http_request/http_request_idf.h b/esphome/components/http_request/http_request_idf.h index 8a803b5469..16a5b6a161 100644 --- a/esphome/components/http_request/http_request_idf.h +++ b/esphome/components/http_request/http_request_idf.h @@ -24,6 +24,8 @@ class HttpContainerIDF : public HttpContainer { protected: friend class HttpRequestIDF; esp_http_client_handle_t client_; + // Owned copy (not a reference): must outlive perform() for the response-header event handler + std::vector collect_headers_; }; class HttpRequestIDF final : public HttpRequestComponent { From e2df9fb5543746f5c8e15c1d0a4c12a012ce248f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:43:43 -0400 Subject: [PATCH 0939/1815] Bump ruff from 0.15.21 to 0.15.22 (#17628) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 7aa8dab534..9a9b7adff5 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.6 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.21 # also change in .pre-commit-config.yaml when updating +ruff==0.15.22 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From 7a2d13da905b5da6496bda0ae60fdce638d475a8 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:59:53 -0400 Subject: [PATCH 0940/1815] [web_server_idf] Use core format_hex_to helper for digest auth (fixes Arduino build) (#17608) --- .../web_server_idf/web_server_idf.cpp | 20 +++++-------------- .../components/web_server/test.esp32-ard.yaml | 6 ++++++ 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index bf5a8666dc..993fb6c035 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -381,16 +381,6 @@ void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code #ifdef USE_WEBSERVER_AUTH_DIGEST namespace { -// Hex-encode `len` bytes into `out`, which must hold at least 2 * len + 1 bytes. Null-terminated. -void bytes_to_hex(const uint8_t *data, size_t len, char *out) { - static const char HEX[] = "0123456789abcdef"; - for (size_t i = 0; i < len; i++) { - out[i * 2] = HEX[data[i] >> 4]; - out[i * 2 + 1] = HEX[data[i] & 0x0f]; - } - out[len * 2] = '\0'; -} - // Extract the value of a Digest auth parameter (e.g. "nonce") from the comma-separated // parameter list. Values may be quoted or bare. Returns an empty ref when the key is absent. // Only whole parameter names match, so "nc" does not match inside "cnonce". @@ -468,7 +458,7 @@ bool check_digest_auth(const char *username, const char *password, const std::st esp_rom_md5_update(&ctx, ":", 1); esp_rom_md5_update(&ctx, password, strlen(password)); esp_rom_md5_final(digest, &ctx); - bytes_to_hex(digest, sizeof(digest), ha1); + format_hex_to(ha1, digest, sizeof(digest)); // HA2 = MD5(method:uri) -- uses the uri the client echoed back. char ha2[33]; @@ -477,7 +467,7 @@ bool check_digest_auth(const char *username, const char *password, const std::st esp_rom_md5_update(&ctx, ":", 1); esp_rom_md5_update(&ctx, uri.c_str(), uri.size()); esp_rom_md5_final(digest, &ctx); - bytes_to_hex(digest, sizeof(digest), ha2); + format_hex_to(ha2, digest, sizeof(digest)); // expected = MD5(HA1:nonce:nc:cnonce:qop:HA2) char expected[33]; @@ -494,7 +484,7 @@ bool check_digest_auth(const char *username, const char *password, const std::st esp_rom_md5_update(&ctx, ":", 1); esp_rom_md5_update(&ctx, ha2, 32); esp_rom_md5_final(digest, &ctx); - bytes_to_hex(digest, sizeof(digest), expected); + format_hex_to(expected, digest, sizeof(digest)); // Constant-time comparison of the two 32-char hex digests. uint8_t result = 0; @@ -592,9 +582,9 @@ void AsyncWebServerRequest::requestAuthentication() const { char opaque[33]; char header[160]; esp_fill_random(random_bytes, sizeof(random_bytes)); - bytes_to_hex(random_bytes, sizeof(random_bytes), nonce); + format_hex_to(nonce, random_bytes, sizeof(random_bytes)); esp_fill_random(random_bytes, sizeof(random_bytes)); - bytes_to_hex(random_bytes, sizeof(random_bytes), opaque); + format_hex_to(opaque, random_bytes, sizeof(random_bytes)); snprintf(header, sizeof(header), R"(Digest realm="Login Required", qop="auth", nonce="%s", opaque="%s")", nonce, opaque); httpd_resp_set_hdr(*this, "WWW-Authenticate", header); diff --git a/tests/components/web_server/test.esp32-ard.yaml b/tests/components/web_server/test.esp32-ard.yaml index 11ad5456ef..2e091b905a 100644 --- a/tests/components/web_server/test.esp32-ard.yaml +++ b/tests/components/web_server/test.esp32-ard.yaml @@ -1,2 +1,8 @@ packages: web_server: !include common_v2.yaml + +web_server: + auth: + username: admin + password: password + type: digest From cc6392785fda8c6cb12bfefd9773a3fd53117967 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:36:18 -0400 Subject: [PATCH 0941/1815] [runtime_image] Prevent integer overflow in image buffer size calculation (#17624) --- .../runtime_image/image_decoder.cpp | 4 ++++ .../components/runtime_image/image_decoder.h | 1 + .../components/runtime_image/png_decoder.cpp | 2 ++ .../runtime_image/runtime_image.cpp | 22 +++++++++++++++++-- 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/esphome/components/runtime_image/image_decoder.cpp b/esphome/components/runtime_image/image_decoder.cpp index 8d3320b5d1..f2c4f5c8cd 100644 --- a/esphome/components/runtime_image/image_decoder.cpp +++ b/esphome/components/runtime_image/image_decoder.cpp @@ -10,12 +10,16 @@ static const char *const TAG = "image_decoder"; bool ImageDecoder::set_size(int width, int height) { bool success = this->image_->resize(width, height) > 0; + this->size_valid_ = success; this->x_scale_ = static_cast(this->image_->get_buffer_width()) / width; this->y_scale_ = static_cast(this->image_->get_buffer_height()) / height; return success; } void ImageDecoder::draw(int x, int y, int w, int h, const Color &color) { + if (!this->size_valid_) { + return; + } auto width = std::min(this->image_->get_buffer_width(), static_cast(std::ceil((x + w) * this->x_scale_))); auto height = std::min(this->image_->get_buffer_height(), static_cast(std::ceil((y + h) * this->y_scale_))); for (int i = x * this->x_scale_; i < width; i++) { diff --git a/esphome/components/runtime_image/image_decoder.h b/esphome/components/runtime_image/image_decoder.h index c68ea5720b..6d351a10aa 100644 --- a/esphome/components/runtime_image/image_decoder.h +++ b/esphome/components/runtime_image/image_decoder.h @@ -108,6 +108,7 @@ class ImageDecoder { size_t decoded_bytes_ = 0; // Bytes processed so far double x_scale_ = 1.0; double y_scale_ = 1.0; + bool size_valid_ = true; // Last set_size() result; draw() no-ops while false }; } // namespace esphome::runtime_image diff --git a/esphome/components/runtime_image/png_decoder.cpp b/esphome/components/runtime_image/png_decoder.cpp index 12bce0d284..9501702711 100644 --- a/esphome/components/runtime_image/png_decoder.cpp +++ b/esphome/components/runtime_image/png_decoder.cpp @@ -96,6 +96,8 @@ int HOT PngDecoder::decode(uint8_t *buffer, size_t size) { if (fed < 0) { ESP_LOGE(TAG, "Error decoding image: %s", pngle_error(this->pngle_)); return DECODE_ERROR_INTERNAL_DECODER_ERROR; + } else if (!this->size_valid_) { + return DECODE_ERROR_OUT_OF_MEMORY; } else { this->decoded_bytes_ += fed; } diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index 4c7f1bfb6f..4b12478e4f 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -3,7 +3,9 @@ #include "esphome/core/log.h" #include "esphome/core/helpers.h" #include +#include #include +#include #ifdef USE_RUNTIME_IMAGE_BMP #include "bmp_decoder.h" @@ -19,6 +21,13 @@ namespace esphome::runtime_image { static const char *const TAG = "runtime_image"; +// Widest supported format is 4 bytes/pixel, so 32767 * 32767 * 4 still fits a 32-bit size_t +static constexpr int MAX_IMAGE_DIMENSION = 32767; +static constexpr int MAX_IMAGE_BPP = 32; +static_assert((static_cast(MAX_IMAGE_BPP) * MAX_IMAGE_DIMENSION + 7) / 8 * MAX_IMAGE_DIMENSION <= + std::numeric_limits::max(), + "MAX_IMAGE_DIMENSION must keep the worst-case buffer size within size_t"); + inline bool is_color_on(const Color &color) { // This produces the most accurate monochrome conversion, but is slightly slower. // return (0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b) > 127; @@ -257,6 +266,11 @@ void RuntimeImage::release_buffer_() { size_t RuntimeImage::resize_buffer_(int width, int height) { size_t new_size = this->get_buffer_size_(width, height); + if (new_size == 0) { + ESP_LOGE(TAG, "Refusing to allocate buffer for invalid image dimensions %dx%d", width, height); + return 0; + } + if (this->buffer_ && this->buffer_width_ == width && this->buffer_height_ == height) { // Buffer already allocated with correct size return new_size; @@ -287,11 +301,15 @@ size_t RuntimeImage::resize_buffer_(int width, int height) { } size_t RuntimeImage::get_buffer_size_(int width, int height) const { + // Dimensions come from a remote image header; reject absurd values so the size math cannot overflow + if (width <= 0 || height <= 0 || width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION) { + return 0; + } if (this->get_type() == image::IMAGE_TYPE_RGB565 && this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) { // Add extra alpha channel for RGB565 with alpha - return width * height * 3; + return static_cast(width) * height * 3; } - return (this->get_bpp() * width + 7u) / 8u * height; + return (static_cast(this->get_bpp()) * width + 7u) / 8u * height; } int RuntimeImage::get_position_(int x, int y) const { return (x + y * this->buffer_width_) * this->get_bpp() / 8; } From 735f8d607dcb47d7c38f507a52c71c66d6d72250 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:04:35 +1200 Subject: [PATCH 0942/1815] [nrf52] Set ZEPHYR_SDK_INSTALL_DIR for Zephyr SDK discovery (#17633) --- esphome/components/nrf52/framework.py | 10 +++++- tests/unit_tests/test_nrf52_framework.py | 39 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index fa6f7d57ad..623cd4eef3 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -133,7 +133,15 @@ def get_build_env() -> dict: env = os.environ.copy() env["PATH"] = str(venv_bin_dir) + os.pathsep + env.get("PATH", "") env["ZEPHYR_BASE"] = str(_get_framework_path(version) / "zephyr") - env["Zephyr-sdk_DIR"] = str(_get_toolchain_path(TOOLCHAIN_VERSION) / "cmake") + # ZEPHYR_SDK_INSTALL_DIR is the variable Zephyr documents for pointing at + # the SDK: FindZephyr-sdk.cmake reads it (from the environment, via + # zephyr_get) and passes it straight to find_package as a HINT. This + # matters because the SDK lives in the esphome cache dir, which is not on + # the module's static search path (/usr, /opt, $HOME, ...). A generic + # "Zephyr-sdk_DIR" environment hint proved unreliable here: containerized + # non-root builds failed to locate the SDK with it, while + # ZEPHYR_SDK_INSTALL_DIR fixed the same invocation. + env["ZEPHYR_SDK_INSTALL_DIR"] = str(_get_toolchain_path(TOOLCHAIN_VERSION)) return env diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index bb5bc8c064..830e9efba5 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -1,6 +1,7 @@ """Tests for esphome.components.nrf52.framework helpers.""" import hashlib +import os from pathlib import Path from types import SimpleNamespace from unittest.mock import patch @@ -12,11 +13,13 @@ from esphome.components.nrf52.framework import ( TOOLCHAIN_VERSION, _get_toolchain_platform_info, check_and_install, + get_build_env, get_sdk_nrf_tools_path, ) from esphome.config_validation import Version from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, EsphomeError +from esphome.framework_helpers import get_python_env_executable_path @pytest.fixture(autouse=True) @@ -252,6 +255,42 @@ class TestCheckAndInstall: assert substitutions["extension"] == "tar.xz" +# --------------------------------------------------------------------------- +# get_build_env tests +# --------------------------------------------------------------------------- + + +def test_get_build_env( + nrf52_dirs: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + """get_build_env exposes ZEPHYR_SDK_INSTALL_DIR pointing at the toolchain root. + + ZEPHYR_SDK_INSTALL_DIR is the variable Zephyr's FindZephyr-sdk.cmake + explicitly consumes (from the environment) and uses as a find_package + HINT. The old Zephyr-sdk_DIR environment hint proved unreliable in + containerized non-root builds and was removed. + """ + monkeypatch.setenv("SOME_PREEXISTING_VAR", "kept") + + env = get_build_env() + + tools = get_sdk_nrf_tools_path() + venv_bin_dir = get_python_env_executable_path( + tools / "penvs" / f"v{_TEST_SDK_VERSION}", "python" + ).parent + assert env["PATH"].startswith(str(venv_bin_dir) + os.pathsep) + assert env["ZEPHYR_BASE"] == str( + tools / "frameworks" / f"v{_TEST_SDK_VERSION}" / "zephyr" + ) + # Toolchain root, not the cmake/ subdir + assert env["ZEPHYR_SDK_INSTALL_DIR"] == str( + tools / "toolchains" / TOOLCHAIN_VERSION + ) + assert "Zephyr-sdk_DIR" not in env + # The rest of the process environment is inherited + assert env["SOME_PREEXISTING_VAR"] == "kept" + + # --------------------------------------------------------------------------- # get_sdk_nrf_tools_path tests # --------------------------------------------------------------------------- From 2132cae1c9ea2be18da0b263d3c38b588f75f941 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 17 Jul 2026 07:07:38 -0500 Subject: [PATCH 0943/1815] [nextion] Fix RP2040 clang-tidy failure from TFT upload guard mismatch (#17638) --- esphome/components/nextion/__init__.py | 1 - esphome/components/nextion/display.py | 27 +++++++++++++++++-- esphome/components/nextion/nextion.h | 4 +-- .../nextion/nextion_upload_arduino.cpp | 4 +-- 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/esphome/components/nextion/__init__.py b/esphome/components/nextion/__init__.py index d51155b0a4..803d7a0dd2 100644 --- a/esphome/components/nextion/__init__.py +++ b/esphome/components/nextion/__init__.py @@ -19,7 +19,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( }, "nextion_upload_arduino.cpp": { PlatformFramework.ESP8266_ARDUINO, - PlatformFramework.RP2_ARDUINO, PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 89e9b93520..b8971fd06f 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -4,7 +4,17 @@ from esphome import automation import esphome.codegen as cg from esphome.components import display, esp32, uart import esphome.config_validation as cv -from esphome.const import CONF_BRIGHTNESS, CONF_ID, CONF_LAMBDA, CONF_ON_TOUCH +from esphome.const import ( + CONF_BRIGHTNESS, + CONF_ID, + CONF_LAMBDA, + CONF_ON_TOUCH, + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_LN882X, + PLATFORM_RTL87XX, +) from esphome.core import CORE, TimePeriod from . import ( # noqa: F401 pylint: disable=unused-import @@ -135,7 +145,20 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_TFT_URL): cv.url, + # TFT upload needs an HTTP client and runtime UART reconfiguration, + # neither of which is implemented for the RP2 or host platforms. + cv.Optional(CONF_TFT_URL): cv.All( + cv.url, + cv.only_on( + [ + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_BK72XX, + PLATFORM_RTL87XX, + PLATFORM_LN882X, + ] + ), + ), cv.Optional(CONF_TOUCH_SLEEP_TIMEOUT): cv.Any( 0, cv.int_range(min=3, max=65535) ), diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index 29cadf979b..aa9fe8abb3 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -1566,7 +1566,7 @@ class Nextion final : public NextionBase, public PollingComponent, public uart:: * @return position of last byte transferred, -1 for failure. */ int upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &range_start); -#elif defined(USE_ARDUINO) +#elif defined(USE_ESP8266) || defined(USE_LIBRETINY) /** * will request chunk_size chunks from the web server * and send each to the nextion @@ -1575,7 +1575,7 @@ class Nextion final : public NextionBase, public PollingComponent, public uart:: * @return position of last byte transferred, -1 for failure. */ int upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start); -#endif // USE_ESP32 vs USE_ARDUINO +#endif // USE_ESP32 vs USE_ESP8266/USE_LIBRETINY /** * Ends the upload process, restart Nextion and, if successful, diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index 41379c2345..2b1039fef7 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -1,7 +1,7 @@ #include "nextion.h" #ifdef USE_NEXTION_TFT_UPLOAD -#ifndef USE_ESP32 +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) #include #include "esphome/components/network/util.h" @@ -378,5 +378,5 @@ WiFiClient *Nextion::get_wifi_client_() { } // namespace esphome::nextion -#endif // NOT USE_ESP32 +#endif // USE_ESP8266 || USE_LIBRETINY #endif // USE_NEXTION_TFT_UPLOAD From 7aed5fb94bf7bf7ffc516afa03b3ec842a9ba4e5 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 08:08:28 -0400 Subject: [PATCH 0944/1815] Bump bundled esphome-device-builder to 1.6.2 (#17640) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 84fd658594..7710256318 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.6.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.2 RUN \ platformio settings set enable_telemetry No \ From 300ab1be35ab4c10d6c9a0cae5a5160f8c0ea462 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:00:54 -1000 Subject: [PATCH 0945/1815] Bump aioesphomeapi from 45.6.0 to 45.6.1 (#17653) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 5f98111445..4dbf469347 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.6.0 +aioesphomeapi==45.6.1 zeroconf==0.150.0 puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import From 6f27c7f3fee1a2953baae2f181a006edd701926e Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:25:18 -1000 Subject: [PATCH 0946/1815] Bump bundled esphome-device-builder to 1.6.3 (#17651) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 7710256318..b60bfac7a2 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.6.2 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.3 RUN \ platformio settings set enable_telemetry No \ From a4a9feac161f169b9136a07bc38226f0cf0de270 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:26:04 -1000 Subject: [PATCH 0947/1815] Bump esphome/workflows/.github/workflows/lock.yml from 2026.4.1 to 2026.7.0 (#17652) Signed-off-by: dependabot[bot] --- .github/workflows/lock.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 5e70117652..ec736a2002 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -14,4 +14,4 @@ jobs: permissions: issues: write # issues.lock on closed issues pull-requests: write # issues.lock on closed pull requests - uses: esphome/workflows/.github/workflows/lock.yml@025a1e6255610c498ed590403b7e510b69e474df # 2026.4.1 + uses: esphome/workflows/.github/workflows/lock.yml@9f6577fd37b5cf773ab1b9be929714a0dcd15661 # 2026.7.0 From 328a2018d23324619380c90ebf642b1fd6cbb75f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:32:47 -1000 Subject: [PATCH 0948/1815] Bump aioesphomeapi from 45.6.1 to 45.6.2 (#17654) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4dbf469347..2a79a0c433 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.6.1 +aioesphomeapi==45.6.2 zeroconf==0.150.0 puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import From f918c299b1cd53cbdfcaa69b880837a4a0913da4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:46:45 -0400 Subject: [PATCH 0949/1815] [espidf] Suggest installing missing system libraries when the tools install fails (#17619) --- esphome/espidf/framework.py | 9 ++++++++ tests/unit_tests/test_espidf_framework.py | 28 +++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 18aa966bff..b8e0d4cfca 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -1,5 +1,6 @@ """ESP-IDF framework tools for ESPHome.""" +from ctypes.util import find_library import json import logging import os @@ -668,6 +669,14 @@ def _check_esphome_idf_framework_install( env=env, stream_output=True, ): + if platform.system() == "Linux" and find_library("usb-1.0") is None: + _LOGGER.error( + "libusb-1.0.so.0 was not found on this system and the ESP-IDF " + "tools need it (openocd fails its install check without it). " + "Install the libusb 1.0 package, e.g. libusb-1.0-0 " + "(Debian/Ubuntu), libusb1 (Fedora) or libusb (Alpine/Arch), " + "then run the build again." + ) raise RuntimeError(f"ESP-IDF {version} framework installation failure") _write_stamp(env_stamp_file, stamp_info) diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index de02a6b227..a1af5ae54c 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -478,6 +478,34 @@ def test_check_esp_idf_install_python_stamp_mismatch_rebuilds_venv( espidf_mocks.venv.assert_called_once() +@pytest.mark.parametrize( + ("lib", "expect_hint"), + [ + (None, True), + ("libusb-1.0.so.0", False), + ], +) +def test_check_esp_idf_install_failure_libusb_hint( + espidf_mocks: SimpleNamespace, + caplog: pytest.LogCaptureFixture, + lib: str | None, + expect_hint: bool, +) -> None: + """A failed tools install only shows the libusb hint when libusb-1.0 is + actually missing.""" + espidf_mocks.run_ok.return_value = False + # Fake Linux so the gate is exercised on all CI hosts; faking Linux is safe + # everywhere (unlike faking Windows, which pulls in winreg on other hosts) + with ( + patch("esphome.espidf.framework.find_library", return_value=lib), + patch("esphome.espidf.framework.platform.system", return_value="Linux"), + caplog.at_level(logging.ERROR, logger="esphome.espidf.framework"), + pytest.raises(RuntimeError, match="framework installation failure"), + ): + check_esp_idf_install(_IDF_VERSION, force=True) + assert ("libusb-1.0.so.0 was not found" in caplog.text) == expect_hint + + def test_check_esp_idf_install_unparseable_version( espidf_mocks: SimpleNamespace, ) -> None: From ebaf32c82a0706963963be009c4d9e8189427b4d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:38:55 -0400 Subject: [PATCH 0950/1815] [ci] Add LibreTiny clang-tidy environments (#17491) --- .github/workflows/ci.yml | 20 ++++++- esphome/components/nextion/__init__.py | 3 - esphome/components/nextion/display.py | 6 -- .../nextion/nextion_upload_arduino.cpp | 8 +-- platformio.ini | 56 +++++++++++++++++++ script/clang-tidy | 56 +++++++++++++++++-- 6 files changed, 127 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59e6f006e8..17b33520a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -508,6 +508,11 @@ jobs: name: Run script/clang-tidy for RP2 options: --environment rp2-tidy --grep USE_RP2 pio_cache_key: tidyrp2 + - id: clang-tidy + name: Run script/clang-tidy for LibreTiny + environments: bk72xx-tidy ln882h-tidy rtl87xxb-tidy rtl87xxc-tidy + options: --grep USE_LIBRETINY --grep USE_BK72XX --grep USE_RTL87XX --grep USE_LN882X + pio_cache_key: tidylibretiny steps: - name: Check out code from GitHub @@ -571,10 +576,21 @@ jobs: . venv/bin/activate if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})" - script/clang-tidy --all-headers --fix ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }} + changed="" else echo "Running clang-tidy on changed files only" - script/clang-tidy --all-headers --fix --changed ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }} + changed="--changed" + fi + if [ -n "${{ matrix.environments }}" ]; then + rc=0 + for env in ${{ matrix.environments }}; do + echo "::group::clang-tidy $env" + script/clang-tidy --all-headers --fix $changed --environment "$env" ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }} || rc=1 + echo "::endgroup::" + done + exit $rc + else + script/clang-tidy --all-headers --fix $changed ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }} fi env: # Also cache libdeps, store them in a ~/.platformio subfolder diff --git a/esphome/components/nextion/__init__.py b/esphome/components/nextion/__init__.py index 803d7a0dd2..efb6c88d28 100644 --- a/esphome/components/nextion/__init__.py +++ b/esphome/components/nextion/__init__.py @@ -19,9 +19,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( }, "nextion_upload_arduino.cpp": { PlatformFramework.ESP8266_ARDUINO, - PlatformFramework.BK72XX_ARDUINO, - PlatformFramework.RTL87XX_ARDUINO, - PlatformFramework.LN882X_ARDUINO, }, } ) diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index b8971fd06f..4ab123c354 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -9,11 +9,8 @@ from esphome.const import ( CONF_ID, CONF_LAMBDA, CONF_ON_TOUCH, - PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, - PLATFORM_LN882X, - PLATFORM_RTL87XX, ) from esphome.core import CORE, TimePeriod @@ -153,9 +150,6 @@ CONFIG_SCHEMA = cv.All( [ PLATFORM_ESP32, PLATFORM_ESP8266, - PLATFORM_BK72XX, - PLATFORM_RTL87XX, - PLATFORM_LN882X, ] ), ), diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index 2b1039fef7..2f3377d950 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -1,7 +1,7 @@ #include "nextion.h" #ifdef USE_NEXTION_TFT_UPLOAD -#if defined(USE_ESP8266) || defined(USE_LIBRETINY) +#ifdef USE_ESP8266 #include #include "esphome/components/network/util.h" @@ -209,7 +209,6 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { http_client.setTimeout(this->tft_upload_http_timeout_); bool begin_status = false; -#ifdef USE_ESP8266 #if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 7, 0) http_client.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); #elif USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 6, 0) @@ -219,7 +218,6 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { http_client.setRedirectLimit(3); #endif begin_status = http_client.begin(*this->get_wifi_client_(), this->tft_url_.c_str()); -#endif // USE_ESP8266 if (!begin_status) { this->connection_state_.is_updating_ = false; ESP_LOGD(TAG, "Connection failed"); @@ -356,7 +354,6 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { return upload_end_(true); } -#ifdef USE_ESP8266 WiFiClient *Nextion::get_wifi_client_() { if (this->tft_url_.starts_with("https:")) { if (this->wifi_client_secure_ == nullptr) { @@ -374,9 +371,8 @@ WiFiClient *Nextion::get_wifi_client_() { } return this->wifi_client_; } -#endif // USE_ESP8266 } // namespace esphome::nextion -#endif // USE_ESP8266 || USE_LIBRETINY +#endif // USE_ESP8266 #endif // USE_NEXTION_TFT_UPLOAD diff --git a/platformio.ini b/platformio.ini index 30968e80e8..35dec2ff76 100644 --- a/platformio.ini +++ b/platformio.ini @@ -239,9 +239,17 @@ platform = https://github.com/libretiny-eu/libretiny.git#v1.13.0 framework = arduino lib_compat_mode = soft lib_deps = + ${common.lib_deps_base} ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard + esphome/noise-c@0.1.11 ; api + ESP32Async/AsyncTCP@3.4.5 ; async_tcp + DNSServer ; captive_portal + heman/AsyncMqttClient-esphome@2.0.0 ; mqtt + improv/Improv@1.2.6 ; improv_serial + kikuchan98/pngle@1.1.0 ; online_image + https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; online_image build_flags = ${common:arduino.build_flags} -DUSE_LIBRETINY @@ -578,6 +586,54 @@ build_flags = build_unflags = ${common.build_unflags} +[env:bk72xx-tidy] +extends = common:libretiny-arduino +board = generic-bk7231n-qfn32-tuya +build_flags = + ${common:libretiny-arduino.build_flags} + ${flags:clangtidy.build_flags} + -DUSE_BK72XX + -DUSE_LIBRETINY_VARIANT_BK7231N +build_unflags = + ${common.build_unflags} + +[env:ln882h-tidy] +extends = common:libretiny-arduino +board = generic-ln882h +build_flags = + ${common:libretiny-arduino.build_flags} + ${flags:clangtidy.build_flags} + -DUSE_LN882X + -DUSE_LIBRETINY_VARIANT_LN882H + ; the SDK lwip port dir is missing from pio idedata; lwipopts.h include_next needs it + -I${platformio.packages_dir}/framework-lightning-ln882h/components/net/lwip-2.1.3/src/port/ln_osal/include +build_unflags = + ${common.build_unflags} + +[env:rtl87xxb-tidy] +extends = common:libretiny-arduino +board = generic-rtl8710bn-2mb-788k +; mirror the libretiny codegen pin: RTL8710B needs 8.2.3+ for task notifications +custom_versions.freertos = 8.2.3 +build_flags = + ${common:libretiny-arduino.build_flags} + ${flags:clangtidy.build_flags} + -DUSE_RTL87XX + -DUSE_LIBRETINY_VARIANT_RTL8710B +build_unflags = + ${common.build_unflags} + +[env:rtl87xxc-tidy] +extends = common:libretiny-arduino +board = generic-rtl8720cf-2mb-992k +build_flags = + ${common:libretiny-arduino.build_flags} + ${flags:clangtidy.build_flags} + -DUSE_RTL87XX + -DUSE_LIBRETINY_VARIANT_RTL8720C +build_unflags = + ${common.build_unflags} + ;;;;;;;; Host ;;;;;;;; [env:host] diff --git a/script/clang-tidy b/script/clang-tidy index f463e2455d..4f1bc6021c 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -74,6 +74,8 @@ def clang_options(idedata, environment): "-fno-jump-tables", "-fno-shrink-wrap", "-mno-target-align", + # GCC-only flag emitted by the LibreTiny build + "-mthumb-interwork", ) if "zephyr" in triplet: @@ -109,6 +111,23 @@ def clang_options(idedata, environment): if environment.startswith("rp2"): # clang's ARM backend doesn't know GCC's long_call attribute (IRAM_ATTR) cmd.append("-Wno-unknown-attributes") + elif environment.startswith(("bk72xx", "ln882h", "rtl87xx")): + cmd.extend( + [ + # GCC on arm-none-eabi types (u)int32_t as (unsigned) long; clang + # types it as (unsigned) int, clashing with LibreTiny's lwip + # port typedefs. Match the GCC type model. + "-U__UINT32_TYPE__", + "-D__UINT32_TYPE__=long unsigned int", + "-U__INT32_TYPE__", + "-D__INT32_TYPE__=long int", + # newlib's machine/endian.h macroizes __bswap16 into + # __builtin_bswap16; the beken BDK then defines __bswap16 as a + # function, which GCC tolerates as a builtin redeclaration but + # clang rejects + "-D__MACHINE_ENDIAN_H__", + ] + ) else: # replace pgmspace.h, as it uses GNU extensions clang doesn't support # https://github.com/earlephilhower/newlib-xtensa/pull/18 @@ -154,8 +173,32 @@ def clang_options(idedata, environment): ) cmd.append("-std=gnu++20") - # defines - cmd.extend(f"-D{define}" for define in idedata["defines"]) + if environment.startswith(("bk72xx", "ln882h", "rtl87xx")): + # LibreTiny leaves function-like macro values unparenthesized + # (bugprone-macro-parentheses); its SDK-internal FAL_PART_TABLE macro trips the same + # check. Assumes define values are always expressions (never type- or char-literal), + # which holds for the current LibreTiny idedata. + def sanitize_define(define): + name, sep, value = define.partition("=") + if ( + sep + and value + and not value.startswith(("(", '"')) + and ("(" in name or not re.fullmatch(r"[\w.]+", value)) + ): + value = f"({value})" + return f"-D{name}{sep}{value}" + + # FAL_PART_TABLE and the delay() remap are library-scope LibreTiny flags that the real + # build never applies to esphome sources. Strip LibreTiny's shell quoting from define + # names first so the skip list matches regardless of which names it happens to quote. + for define in idedata["defines"]: + define = define.replace("'", "") + if define.startswith(("FAL_PART_TABLE", "delay(")): + continue + cmd.append(sanitize_define(define)) + else: + cmd.extend(f"-D{define}" for define in idedata["defines"]) # toolchain include directories, using -isystem to suppress their errors # idedata contains include directories for all toolchains of this platform, only use those from the one in use @@ -219,8 +262,9 @@ def run_tidy(executable, args, options, tmpdir, path_queue, lock, failed_files): if sys.stdout.isatty(): invocation.append("--use-color") - if args.environment.startswith("rp2"): - # MMIO peripheral access on bare-metal RP2 is all fixed-address. + if args.environment.startswith(("rp2", "bk72xx", "ln882h", "rtl87xx")): + # MMIO peripheral access on these bare-metal platforms is all + # fixed-address. # bugprone-pointer-arithmetic-on-polymorphic-object (and its # cert-ctr56-cpp alias) crashes clang-tidy 22 with infinite matcher # recursion on lvgl_esphome.h under the RP2 defines. @@ -439,7 +483,9 @@ def main(): print("Error applying fixes.\n", file=sys.stderr) raise - return len(failed_files) + # Cap at 255: shells truncate exit codes to one byte, so 256 failures + # would otherwise report success + return min(len(failed_files), 255) if __name__ == "__main__": From cb66dc01ab61e8090f3e936834aca1e51a6bd019 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:45:13 -0500 Subject: [PATCH 0951/1815] [core] Fix srcFilter exclusions being silently ignored on Windows (#17648) --- esphome/platformio/library.py | 6 ++++ tests/unit_tests/test_espidf_component.py | 43 +++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 291bedb5cd..0ffac65e0d 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -292,6 +292,12 @@ def collect_filtered_files(src_dir: PathType, src_filters: list[str]) -> list[st for root, _, files in os.walk(item): matched.extend([str(Path(root) / f) for f in files]) + # glob keeps the pattern's literal separators for non-wildcard path + # components, so on Windows the same file can surface with different + # separators depending on where the wildcards sit; normalize so the + # include/exclude set operations below compare equal paths. + matched = [os.path.normpath(m) for m in matched] + # FILTER_REGEX only ever captures "+" or "-", so the else is the "-" case. if sign == "+": selected.update(matched) diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index a50024b8e9..055e9c8502 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -1,3 +1,4 @@ +import glob import hashlib import json import os @@ -86,6 +87,48 @@ def test_collect_filtered_files_exclude(tmp_path): assert str(f2) not in result +def test_collect_filtered_files_exclude_pattern_in_subdir(tmp_path): + src = tmp_path / "lib" / "src" + src.mkdir(parents=True) + kept = src / "a.c" + excluded = src / "hasty.c" + kept.write_text("int a;") + excluded.write_text("int b;") + + result = collect_filtered_files(tmp_path, ["+", "-"]) + assert str(kept) in result + assert str(excluded) not in result + + +def test_collect_filtered_files_exclude_unnormalized_glob_output(tmp_path, monkeypatch): + # On Windows, glob keeps the pattern's literal separators for non-wildcard + # path components, so the "+" wildcard pattern and the "-" literal pattern + # yield the same file spelled differently and the exclude set difference + # misses it. Backslash is a regular filename character on POSIX (such paths + # fail the final is_file filter), so reproduce the unnormalized-output + # mismatch portably with dot segments, which normpath also collapses. + src = tmp_path / "lib" / "src" + src.mkdir(parents=True) + kept = src / "a.c" + excluded = src / "hasty.c" + kept.write_text("int a;") + excluded.write_text("int b;") + + real_glob = glob.glob + + def unnormalized_glob(pattern, recursive=False): + if "*" in pattern: + base = str(tmp_path) + return [base + "/lib/./src/a.c", base + "/lib/./src/hasty.c"] + return real_glob(pattern, recursive=recursive) + + monkeypatch.setattr(glob, "glob", unnormalized_glob) + + result = collect_filtered_files(tmp_path, ["+", "-"]) + assert [Path(r).name for r in result] == ["a.c"] + assert str(kept) in result + + def test_split_list_by_condition(): items = ["-Iinclude", "-Llib", "-Wall"] From 94be1da8938641c098e6eba900601266c204987d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Jul 2026 15:46:16 -1000 Subject: [PATCH 0952/1815] Pin cryptography to 48.0.1 on Intel macOS (#17658) --- requirements.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 2a79a0c433..fbfc034268 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,7 @@ -cryptography==49.0.0 +# cryptography 49+ ships no Intel macOS wheels (arm64 only); esptool caps <49 there. +# Keep 48.0.1, the last universal2 release, so esphome stays installable on Intel Macs. +cryptography==49.0.0; platform_system != "Darwin" or platform_machine != "x86_64" +cryptography==48.0.1; platform_system == "Darwin" and platform_machine == "x86_64" voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 From bacc223360428182a85ff7e336b84dede08c048c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Jul 2026 15:59:12 -1000 Subject: [PATCH 0953/1815] Ship component requirements.txt files in the sdist and wheel (#17660) --- MANIFEST.in | 1 + 1 file changed, 1 insertion(+) diff --git a/MANIFEST.in b/MANIFEST.in index e426627e8d..1626261fb6 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -6,3 +6,4 @@ recursive-include esphome *.cpp *.h *.tcc *.c recursive-include esphome *.py.script recursive-include esphome *.jinja recursive-include esphome LICENSE.txt +recursive-include esphome requirements.txt From 70421fb14ba328cb7a74e7d3aece82d9d8998478 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:49:50 -0400 Subject: [PATCH 0954/1815] [espidf] Prune tool download cache after install to shrink the ESP-IDF cache (#17661) --- esphome/espidf/framework.py | 10 ++++++++++ tests/unit_tests/test_espidf_framework.py | 24 ++++++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index b8e0d4cfca..a9b3fd9644 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -679,6 +679,16 @@ def _check_esphome_idf_framework_install( ) raise RuntimeError(f"ESP-IDF {version} framework installation failure") + # idf_tools.py extracts tool archives from /dist into tools/; the + # archives are not needed afterward and, already compressed, dominate the cached install. + # Best-effort: a failure to prune must not fail an otherwise successful install. + try: + rmdir( + get_idf_tools_path() / "dist", msg="Remove ESP-IDF tool download cache" + ) + except RuntimeError as err: + _LOGGER.debug("Could not remove ESP-IDF tool download cache: %s", err) + _write_stamp(env_stamp_file, stamp_info) return framework_path, install diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index a1af5ae54c..f18a219878 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -317,7 +317,7 @@ def espidf_mocks(setup_core: Path): # extracted-marker touch writes into. _get_framework_path(_IDF_VERSION).mkdir(parents=True, exist_ok=True) with ( - patch("esphome.espidf.framework.rmdir"), + patch("esphome.espidf.framework.rmdir") as rmdir_mock, patch( "esphome.espidf.framework.download_from_mirrors", return_value="https://example.com/idf.tar.xz", @@ -344,6 +344,7 @@ def espidf_mocks(setup_core: Path): run_ok=run_ok, tool_paths=tool_paths, clone=clone, + rmdir=rmdir_mock, ) @@ -358,6 +359,27 @@ def test_check_esp_idf_install_fresh(espidf_mocks: SimpleNamespace) -> None: espidf_mocks.extract.assert_called_once() espidf_mocks.venv.assert_called_once() espidf_mocks.clone.assert_not_called() + # the tool download cache (/dist) is pruned after install + espidf_mocks.rmdir.assert_any_call( + get_idf_tools_path() / "dist", msg="Remove ESP-IDF tool download cache" + ) + + +def test_check_esp_idf_install_dist_prune_failure_ignored( + espidf_mocks: SimpleNamespace, +) -> None: + """A failure to prune the tool download cache must not fail the install.""" + tools_dist = get_idf_tools_path() / "dist" + + def rmdir_side_effect(directory: Path, msg: str | None = None) -> None: + if directory == tools_dist: + raise RuntimeError("cannot remove dist") + + espidf_mocks.rmdir.side_effect = rmdir_side_effect + + # install still succeeds despite the failed prune + framework_path, _ = check_esp_idf_install(_IDF_VERSION, force=True) + assert framework_path == _get_framework_path(_IDF_VERSION) def test_check_esp_idf_install_git_source(espidf_mocks: SimpleNamespace) -> None: From 2a59c0ad9e85a9377737f2b46856b43dc143a3a7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Jul 2026 17:16:14 -1000 Subject: [PATCH 0955/1815] [logs] Cap the logs reconnect backoff for deep-sleep devices (#17656) --- esphome/components/api/client.py | 3 ++ .../unit_tests/components/api/test_client.py | 34 +++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 98edfef038..7f07146dba 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -155,6 +155,9 @@ async def async_run_logs( name=name, subscribe_states=subscribe_states, allow_plaintext_fallback=True, + # A top-level ``deep_sleep:`` block means the device is only awake + # briefly; cap the reconnect backoff so a wake window is not missed. + deep_sleep="deep_sleep" in config, ) try: await asyncio.Event().wait() diff --git a/tests/unit_tests/components/api/test_client.py b/tests/unit_tests/components/api/test_client.py index cbec406a3a..4ebcecbfff 100644 --- a/tests/unit_tests/components/api/test_client.py +++ b/tests/unit_tests/components/api/test_client.py @@ -2,11 +2,14 @@ from __future__ import annotations -from unittest.mock import patch +from unittest.mock import AsyncMock, patch + +import pytest from esphome.components import esp32 from esphome.components.api import client as api_client -from esphome.core import EsphomeError +from esphome.const import CONF_PORT, KEY_CORE, KEY_TARGET_PLATFORM +from esphome.core import CORE, EsphomeError def test_decoder_swallows_esphome_error() -> None: @@ -136,3 +139,30 @@ def test_decoder_uses_platform_handler_when_provided() -> None: assert calls == [(config, "BT0: 0x4010496e", False)] assert mock_generic.called is False assert processor.backtrace_state is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("extra_config", "expected_deep_sleep"), + [({"deep_sleep": {}}, True), ({}, False)], +) +async def test_async_run_logs_passes_deep_sleep( + extra_config: dict, expected_deep_sleep: bool +) -> None: + """async_run_logs tells async_run whether the device deep sleeps, from the config.""" + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}, **extra_config} + # async_run blocks forever after connecting; raise to unwind async_run_logs + # once we have captured how it was called. + sentinel = RuntimeError("stop the wait") + + with ( + patch.object( + api_client, "async_run", AsyncMock(side_effect=sentinel) + ) as mock_run, + patch.object(api_client, "APIClient"), + pytest.raises(RuntimeError, match="stop the wait"), + ): + await api_client.async_run_logs(config, ["1.2.3.4"]) + + assert mock_run.call_args.kwargs["deep_sleep"] is expected_deep_sleep From 1493a095212ee543f2ea9b53238c872b5543b1a1 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:22:23 +1200 Subject: [PATCH 0956/1815] [nrf52] Install PlatformIO toolchain Python packages into a dedicated venv (#17635) --- esphome/components/nrf52/__init__.py | 12 +- esphome/components/nrf52/framework.py | 82 ++++++++++ tests/unit_tests/test_nrf52_framework.py | 181 +++++++++++++++++++++++ tests/unit_tests/test_nrf52_upload.py | 66 +++++++++ 4 files changed, 340 insertions(+), 1 deletion(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 8d522a8740..5b3c250f34 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -69,7 +69,12 @@ from .const import ( BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, ) -from .framework import check_and_install, get_build_env, get_build_paths +from .framework import ( + check_and_install, + get_build_env, + get_build_paths, + setup_platformio_python_env, +) # force import gpio to register pin schema from .gpio import nrf52_pin_to_code # noqa: F401 @@ -514,6 +519,7 @@ def _upload_using_platformio( ) -> int | str: from esphome.platformio import toolchain + setup_platformio_python_env() if port is not None: upload_args += ["--upload-port", port] return toolchain.run_platformio_cli_run(config, CORE.verbose, *upload_args) @@ -809,6 +815,10 @@ def _copy_if_exists(src: Path, dst: Path) -> None: def run_compile(args, config: ConfigType) -> bool: if CORE.using_toolchain_platformio: + # The actual build is done by PlatformIO (the caller falls through to + # it when this returns False); prepare the Python environment its + # Zephyr build script expects first. + setup_platformio_python_env() return False if not CORE.using_toolchain_sdk_nrf: raise EsphomeError( diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 623cd4eef3..7392ad2d60 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -4,6 +4,7 @@ import os from pathlib import Path import platform import shutil +import sys import tempfile import platformdirs @@ -27,6 +28,11 @@ _LOGGER = logging.getLogger(__name__) _REQUIREMENTS = Path(__file__).parent / "requirements.txt" TOOLCHAIN_VERSION = "0.17.4" +# Packages the PlatformIO toolchain's Zephyr build script needs beyond west +# (which comes from requirements.txt). Keep the pin in sync with +# framework-sdk-nrf scripts/platformio/platformio-build.py. +_PLATFORMIO_PENV_REQUIREMENTS: tuple[str, ...] = ("cbor2==5.6.5",) + SDK_NG_TOOLCHAIN_MIRRORS = str_to_lst_of_str( os.environ.get( "ESPHOME_SDK_NG_TOOLCHAIN_MIRRORS", @@ -145,6 +151,82 @@ def get_build_env() -> dict: return env +def _get_platformio_penv_path() -> Path: + return get_sdk_nrf_tools_path() / "penvs" / "platformio" + + +def _get_penv_site_packages(penv_path: Path) -> Path: + if os.name == "nt": + return penv_path / "Lib" / "site-packages" + python_dir = f"python{sys.version_info.major}.{sys.version_info.minor}" + return penv_path / "lib" / python_dir / "site-packages" + + +def _prepend_env_path(name: str, entry: str) -> None: + """Prepend ``entry`` to the ``os.pathsep``-separated env var ``name``.""" + current = os.environ.get(name, "") + entries = current.split(os.pathsep) if current else [] + if entry not in entries: + os.environ[name] = os.pathsep.join([entry, *entries]) + + +def setup_platformio_python_env() -> None: + """Make the Zephyr build's Python packages available to PlatformIO. + + The PlatformIO toolchain's Zephyr framework build script pip-installs + west and cbor2 (and pyocd on x86_64) into the Python environment running + PlatformIO whenever they are not importable. That environment is not + always writable — for example the docker image run as a non-root user, + where ESPHome lives in the system Python — so the install fails with + "Permission denied". Instead, pre-install those packages into a dedicated + venv under the sdk-nrf tools dir and expose it to the PlatformIO + subprocesses through the environment: + + * PYTHONPATH makes the venv's packages importable from the interpreter + that runs PlatformIO/SCons, so the build script skips its installs. + * VIRTUAL_ENV redirects any install the build script still performs via + uv (pyocd is fetched on demand) into the writable venv. + * PATH exposes console scripts installed into the venv (e.g. pyocd). + """ + penv_path = _get_platformio_penv_path() + env_python_path = get_python_env_executable_path(penv_path, "python") + sentinel = penv_path / ".ready" + # Include the Python version: the venv breaks when the interpreter it + # was created from is upgraded, so it must be rebuilt. + requirements_hash = hashlib.sha256( + _REQUIREMENTS.read_bytes() + + "\n".join(_PLATFORMIO_PENV_REQUIREMENTS).encode() + + f"python{sys.version_info.major}.{sys.version_info.minor}".encode() + ).hexdigest() + if ( + not sentinel.exists() + or sentinel.read_text(encoding="utf-8") != requirements_hash + ): + rmdir(penv_path, msg="Clean up PlatformIO toolchain Python environment") + + create_venv(penv_path, msg="PlatformIO toolchain") + + _LOGGER.info("Installing PlatformIO toolchain requirements ...") + cmd = [ + str(env_python_path), + "-m", + "pip", + "install", + "-r", + str(_REQUIREMENTS), + *_PLATFORMIO_PENV_REQUIREMENTS, + ] + if not run_command_ok(cmd): + raise EsphomeError( + "Install requirements for PlatformIO toolchain Python environment failure" + ) + sentinel.write_text(requirements_hash, encoding="utf-8") + + os.environ["VIRTUAL_ENV"] = str(penv_path) + _prepend_env_path("PYTHONPATH", str(_get_penv_site_packages(penv_path))) + _prepend_env_path("PATH", str(env_python_path.parent)) + + def _patch_uf2conv_escape_sequences(framework_path: Path) -> None: # SDK v2.6.1 ships uf2conv.py with '\s+' — an unrecognised escape that # Python 3.12+ flags with SyntaxWarning (a future version will reject it). diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index 830e9efba5..8a5f4377d3 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -3,18 +3,23 @@ import hashlib import os from pathlib import Path +import sys from types import SimpleNamespace from unittest.mock import patch import pytest from esphome.components.nrf52.framework import ( + _PLATFORMIO_PENV_REQUIREMENTS, _REQUIREMENTS, TOOLCHAIN_VERSION, + _get_penv_site_packages, + _get_platformio_penv_path, _get_toolchain_platform_info, check_and_install, get_build_env, get_sdk_nrf_tools_path, + setup_platformio_python_env, ) from esphome.config_validation import Version from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION @@ -255,6 +260,182 @@ class TestCheckAndInstall: assert substitutions["extension"] == "tar.xz" +# --------------------------------------------------------------------------- +# setup_platformio_python_env tests +# --------------------------------------------------------------------------- + + +def _platformio_requirements_hash() -> str: + return hashlib.sha256( + _REQUIREMENTS.read_bytes() + + "\n".join(_PLATFORMIO_PENV_REQUIREMENTS).encode() + + f"python{sys.version_info.major}.{sys.version_info.minor}".encode() + ).hexdigest() + + +@pytest.fixture +def platformio_penv_dir() -> Path: + """Pre-create the PlatformIO penv dir so sentinel writes succeed. + + create_venv is mocked in these tests, so the directory it would have + created must exist for ``sentinel.write_text`` to work. + """ + penv_path = _get_platformio_penv_path() + penv_path.mkdir(parents=True, exist_ok=True) + return penv_path + + +class TestSetupPlatformioPythonEnv: + def test_fresh_install_creates_venv_and_sets_env( + self, + platformio_penv_dir: Path, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """No sentinel → venv created, requirements installed, env exported.""" + with patch.dict(os.environ): + os.environ.pop("PYTHONPATH", None) + + setup_platformio_python_env() + + mock_nrf52_ops.rmdir.assert_called_once() + mock_nrf52_ops.create_venv.assert_called_once_with( + platformio_penv_dir, msg="PlatformIO toolchain" + ) + mock_nrf52_ops.run_command_ok.assert_called_once() + cmd = mock_nrf52_ops.run_command_ok.call_args[0][0] + assert cmd[1:4] == ["-m", "pip", "install"] + assert "-r" in cmd + assert str(_REQUIREMENTS) in cmd + for requirement in _PLATFORMIO_PENV_REQUIREMENTS: + assert requirement in cmd + sentinel = platformio_penv_dir / ".ready" + assert sentinel.read_text(encoding="utf-8") == ( + _platformio_requirements_hash() + ) + + assert os.environ["VIRTUAL_ENV"] == str(platformio_penv_dir) + site_packages = str(_get_penv_site_packages(platformio_penv_dir)) + assert os.environ["PYTHONPATH"] == site_packages + bin_dir = str( + get_python_env_executable_path(platformio_penv_dir, "python").parent + ) + assert os.environ["PATH"].split(os.pathsep)[0] == bin_dir + + def test_ready_sentinel_skips_install_but_sets_env( + self, + platformio_penv_dir: Path, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Current sentinel → no install work, env vars still exported.""" + (platformio_penv_dir / ".ready").write_text( + _platformio_requirements_hash(), encoding="utf-8" + ) + + with patch.dict(os.environ): + setup_platformio_python_env() + + mock_nrf52_ops.rmdir.assert_not_called() + mock_nrf52_ops.create_venv.assert_not_called() + mock_nrf52_ops.run_command_ok.assert_not_called() + assert os.environ["VIRTUAL_ENV"] == str(platformio_penv_dir) + + def test_stale_sentinel_reinstalls( + self, + platformio_penv_dir: Path, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A sentinel from different requirements → venv rebuilt from scratch.""" + sentinel = platformio_penv_dir / ".ready" + sentinel.write_text("stale-hash", encoding="utf-8") + + with patch.dict(os.environ): + setup_platformio_python_env() + + mock_nrf52_ops.rmdir.assert_called_once() + mock_nrf52_ops.create_venv.assert_called_once() + mock_nrf52_ops.run_command_ok.assert_called_once() + assert sentinel.read_text(encoding="utf-8") == _platformio_requirements_hash() + + def test_install_failure_raises( + self, + platformio_penv_dir: Path, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Failing pip install raises EsphomeError and writes no sentinel.""" + mock_nrf52_ops.run_command_ok.return_value = False + + with ( + patch.dict(os.environ), + pytest.raises( + EsphomeError, match="Install requirements for PlatformIO toolchain" + ), + ): + setup_platformio_python_env() + + assert not (platformio_penv_dir / ".ready").exists() + + def test_repeated_calls_do_not_duplicate_env_entries( + self, + platformio_penv_dir: Path, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Compile then upload in one process must not grow PYTHONPATH/PATH.""" + (platformio_penv_dir / ".ready").write_text( + _platformio_requirements_hash(), encoding="utf-8" + ) + site_packages = str(_get_penv_site_packages(platformio_penv_dir)) + bin_dir = str( + get_python_env_executable_path(platformio_penv_dir, "python").parent + ) + + with patch.dict(os.environ): + setup_platformio_python_env() + setup_platformio_python_env() + + assert os.environ["PYTHONPATH"].split(os.pathsep).count(site_packages) == 1 + assert os.environ["PATH"].split(os.pathsep).count(bin_dir) == 1 + + def test_existing_pythonpath_preserved( + self, + platformio_penv_dir: Path, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A pre-existing PYTHONPATH keeps its entries after the venv entry.""" + (platformio_penv_dir / ".ready").write_text( + _platformio_requirements_hash(), encoding="utf-8" + ) + site_packages = str(_get_penv_site_packages(platformio_penv_dir)) + + with patch.dict(os.environ, {"PYTHONPATH": "/existing/path"}): + setup_platformio_python_env() + + assert os.environ["PYTHONPATH"] == os.pathsep.join( + [site_packages, "/existing/path"] + ) + + +@pytest.mark.parametrize( + ("os_name", "expected_parts"), + [ + ( + "posix", + ( + "lib", + f"python{sys.version_info.major}.{sys.version_info.minor}", + "site-packages", + ), + ), + ("nt", ("Lib", "site-packages")), + ], +) +def test_get_penv_site_packages( + tmp_path: Path, os_name: str, expected_parts: tuple[str, ...] +) -> None: + penv_path = tmp_path / "penv" + with patch("os.name", os_name): + assert _get_penv_site_packages(penv_path) == penv_path.joinpath(*expected_parts) + + # --------------------------------------------------------------------------- # get_build_env tests # --------------------------------------------------------------------------- diff --git a/tests/unit_tests/test_nrf52_upload.py b/tests/unit_tests/test_nrf52_upload.py index a60e23a337..9b738ebc81 100644 --- a/tests/unit_tests/test_nrf52_upload.py +++ b/tests/unit_tests/test_nrf52_upload.py @@ -146,6 +146,72 @@ class TestUploadProgramPyocd: upload_program(config={}, args=None, host="PYOCD") +# --------------------------------------------------------------------------- +# PlatformIO toolchain paths +# --------------------------------------------------------------------------- + + +class TestRunCompilePlatformio: + def test_prepares_python_env_and_delegates_to_platformio( + self, setup_core: Path, tmp_path: Path + ) -> None: + """The PlatformIO toolchain prepares the env, then returns False so PlatformIO builds.""" + from esphome.components.nrf52 import run_compile + + _setup_nrf52_core(toolchain=Toolchain.PLATFORMIO, build_path=tmp_path / "build") + + with patch( + "esphome.components.nrf52.setup_platformio_python_env" + ) as mock_setup: + assert run_compile(args=None, config={}) is False + + mock_setup.assert_called_once_with() + + +class TestUploadProgramSerialPlatformio: + def _upload(self, host: str, tmp_path: Path, run_result: int) -> tuple: + from esphome.components.nrf52 import upload_program + from esphome.upload_targets import PortType + + _setup_nrf52_core(toolchain=Toolchain.PLATFORMIO, build_path=tmp_path / "build") + CORE.config_path = tmp_path / "test.yaml" + + with ( + patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL), + patch("esphome.__main__.check_permissions"), + patch("esphome.components.nrf52.setup_platformio_python_env") as mock_setup, + patch( + "esphome.platformio.toolchain.run_platformio_cli_run", + return_value=run_result, + ) as mock_run, + ): + result = upload_program(config={}, args=None, host=host) + return result, mock_setup, mock_run + + def test_serial_upload_prepares_env_and_runs_platformio( + self, setup_core: Path, tmp_path: Path + ) -> None: + """Serial upload with the PlatformIO toolchain runs pio with -t upload.""" + host = "/dev/ttyACM0" + result, mock_setup, mock_run = self._upload(host, tmp_path, run_result=0) + + assert result is True + mock_setup.assert_called_once_with() + mock_run.assert_called_once() + run_args = mock_run.call_args[0] + assert "-t" in run_args + assert "upload" in run_args + assert "--upload-port" in run_args + assert host in run_args + + def test_serial_upload_failure_raises( + self, setup_core: Path, tmp_path: Path + ) -> None: + """A non-zero PlatformIO result must raise EsphomeError.""" + with pytest.raises(EsphomeError, match="Upload failed"): + self._upload("/dev/ttyACM0", tmp_path, run_result=1) + + # --------------------------------------------------------------------------- # Serial DFU upload path # --------------------------------------------------------------------------- From a7dad14449858386238903c5c6d0234e9041d9b1 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:44:52 -1000 Subject: [PATCH 0957/1815] Bump bundled esphome-device-builder to 1.6.4 (#17662) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index b60bfac7a2..f804ebd148 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.6.3 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.4 RUN \ platformio settings set enable_telemetry No \ From 52fe461e9c92f4e82d65d35a330182476efcf83b Mon Sep 17 00:00:00 2001 From: Tom <81973502+tomwellnitz@users.noreply.github.com> Date: Sat, 18 Jul 2026 07:01:15 +0200 Subject: [PATCH 0958/1815] [ds248x] Add OneWireBus platform for DS248x I2C-to-1Wire bridges (#12717) --- CODEOWNERS | 1 + esphome/components/ds248x/__init__.py | 112 ++++++ esphome/components/ds248x/ds248x.cpp | 320 ++++++++++++++++++ esphome/components/ds248x/ds248x.h | 133 ++++++++ .../components/ds248x/ds248x_one_wire_bus.cpp | 171 ++++++++++ .../components/ds248x/ds248x_one_wire_bus.h | 57 ++++ esphome/components/ds248x/one_wire.py | 56 +++ tests/components/ds248x/common.yaml | 115 +++++++ tests/components/ds248x/test.esp32-ard.yaml | 4 + tests/components/ds248x/test.esp32-idf.yaml | 4 + tests/components/ds248x/test.esp8266-ard.yaml | 4 + tests/components/ds248x/test.rp2040-ard.yaml | 4 + 12 files changed, 981 insertions(+) create mode 100644 esphome/components/ds248x/__init__.py create mode 100644 esphome/components/ds248x/ds248x.cpp create mode 100644 esphome/components/ds248x/ds248x.h create mode 100644 esphome/components/ds248x/ds248x_one_wire_bus.cpp create mode 100644 esphome/components/ds248x/ds248x_one_wire_bus.h create mode 100644 esphome/components/ds248x/one_wire.py create mode 100644 tests/components/ds248x/common.yaml create mode 100644 tests/components/ds248x/test.esp32-ard.yaml create mode 100644 tests/components/ds248x/test.esp32-idf.yaml create mode 100644 tests/components/ds248x/test.esp8266-ard.yaml create mode 100644 tests/components/ds248x/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 0f43cd9749..b752c9c5ce 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -145,6 +145,7 @@ esphome/components/dlms_meter/* @latonita @PolarGoose @SimonFischer04 @Tomer27cz esphome/components/dps310/* @kbx81 esphome/components/ds1307/* @badbadc0ffee esphome/components/ds2484/* @mrk-its +esphome/components/ds248x/* @tomwellnitz esphome/components/dsmr/* @glmnet @PolarGoose esphome/components/duty_time/* @dudanov esphome/components/ee895/* @Stock-M diff --git a/esphome/components/ds248x/__init__.py b/esphome/components/ds248x/__init__.py new file mode 100644 index 0000000000..5a26ceab50 --- /dev/null +++ b/esphome/components/ds248x/__init__.py @@ -0,0 +1,112 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_SLEEP_PIN, CONF_TYPE + +CODEOWNERS = ["@tomwellnitz"] +MULTI_CONF = True +DEPENDENCIES = ["i2c"] + +CONF_DS248X_ID = "ds248x_id" +CONF_BUS_SLEEP = "bus_sleep" +CONF_HUB_SLEEP = "hub_sleep" +CONF_ACTIVE_PULLUP = "active_pullup" + +CONF_RESET_LOW_TIME = "reset_low_time" +CONF_MASTER_SAMPLE_TIME = "master_sample_time" +CONF_WRITE_0_LOW_TIME = "write_0_low_time" +CONF_RECOVERY_TIME = "recovery_time" +CONF_ACTIVE_PULLUP_RESISTANCE = "active_pullup_resistance" + +TYPE_DS2482_100 = "ds2482-100" +TYPE_DS2482_101 = "ds2482-101" +TYPE_DS2482_800 = "ds2482-800" +TYPE_DS2484 = "ds2484" + +CHANNEL_COUNTS = { + TYPE_DS2482_100: 1, + TYPE_DS2482_101: 1, + TYPE_DS2482_800: 8, + TYPE_DS2484: 1, +} + +ds248x_ns = cg.esphome_ns.namespace("ds248x") +DS248xComponent = ds248x_ns.class_("DS248xComponent", cg.Component, i2c.I2CDevice) + + +def _component_schema(*extras): + schema = cv.Schema( + { + cv.GenerateID(): cv.declare_id(DS248xComponent), + cv.Optional(CONF_ACTIVE_PULLUP, default=False): cv.boolean, + } + ) + for extra in extras: + schema = schema.extend(extra) + return schema.extend(cv.COMPONENT_SCHEMA).extend(i2c.i2c_device_schema(0x18)) + + +SLEEP_SCHEMA = { + cv.Optional(CONF_SLEEP_PIN): pins.internal_gpio_output_pin_schema, + cv.Optional(CONF_BUS_SLEEP, default=False): cv.boolean, + cv.Optional(CONF_HUB_SLEEP, default=False): cv.boolean, +} + +DS2484_SCHEMA = { + cv.Optional(CONF_RESET_LOW_TIME): cv.int_range(min=0, max=15), + cv.Optional(CONF_MASTER_SAMPLE_TIME): cv.int_range(min=0, max=15), + cv.Optional(CONF_WRITE_0_LOW_TIME): cv.int_range(min=0, max=15), + cv.Optional(CONF_RECOVERY_TIME): cv.int_range(min=0, max=15), + cv.Optional(CONF_ACTIVE_PULLUP_RESISTANCE): cv.enum( + { + # DS2484 Table 7: value codes 0-5 map to 500 ohm, 6-15 map to 1000 ohm. + "500ohm": 0, + "1000ohm": 6, + } + ), +} + +CONFIG_SCHEMA = cv.typed_schema( + { + TYPE_DS2482_100: _component_schema(), + TYPE_DS2482_101: _component_schema(SLEEP_SCHEMA), + TYPE_DS2482_800: _component_schema(), + TYPE_DS2484: _component_schema(SLEEP_SCHEMA, DS2484_SCHEMA), + }, + key=CONF_TYPE, + lower=True, +) + + +def get_channel_count(config): + return CHANNEL_COUNTS[config[CONF_TYPE]] + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + cg.add(var.set_active_pullup(config[CONF_ACTIVE_PULLUP])) + cg.add(var.set_channel_count(get_channel_count(config))) + + if CONF_BUS_SLEEP in config: + cg.add(var.set_bus_sleep(config[CONF_BUS_SLEEP])) + if CONF_HUB_SLEEP in config: + cg.add(var.set_hub_sleep(config[CONF_HUB_SLEEP])) + + if CONF_RESET_LOW_TIME in config: + cg.add(var.set_val_trstl(config[CONF_RESET_LOW_TIME])) + if CONF_MASTER_SAMPLE_TIME in config: + cg.add(var.set_val_tmsp(config[CONF_MASTER_SAMPLE_TIME])) + if CONF_WRITE_0_LOW_TIME in config: + cg.add(var.set_val_tw0l(config[CONF_WRITE_0_LOW_TIME])) + if CONF_RECOVERY_TIME in config: + cg.add(var.set_val_trec0(config[CONF_RECOVERY_TIME])) + if CONF_ACTIVE_PULLUP_RESISTANCE in config: + cg.add(var.set_val_rwpu(config[CONF_ACTIVE_PULLUP_RESISTANCE])) + + if CONF_SLEEP_PIN in config: + pin = await cg.gpio_pin_expression(config[CONF_SLEEP_PIN]) + cg.add(var.set_sleep_pin(pin)) diff --git a/esphome/components/ds248x/ds248x.cpp b/esphome/components/ds248x/ds248x.cpp new file mode 100644 index 0000000000..c8f7395119 --- /dev/null +++ b/esphome/components/ds248x/ds248x.cpp @@ -0,0 +1,320 @@ +#include "ds248x.h" +#include "esphome/core/log.h" +#include "esphome/core/helpers.h" + +namespace esphome::ds248x { + +static const char *const TAG = "ds248x"; + +void DS248xComponent::setup() { + ESP_LOGCONFIG(TAG, "Setting up DS248x..."); + + // Wake up device if sleep pin is configured + if (this->sleep_pin_) { + this->sleep_pin_->setup(); + this->sleep_pin_->pin_mode(esphome::gpio::FLAG_OUTPUT); + this->sleep_pin_->digital_write(true); // Wake up + delay(1); // DS2482-101 Datasheet: tOSCWUP = 100μs (using 10x margin) + } + + // Probe device + ESP_LOGD(TAG, "Probing DS248x..."); + uint8_t status = 0; + if (this->read(&status, 1) == i2c::ERROR_OK) { + ESP_LOGD(TAG, "Device responded! Status: 0x%02x", status); + } else { + ESP_LOGW(TAG, "Device did not respond. Trying reset anyway..."); + } + + if (!this->device_reset_()) { + ESP_LOGW(TAG, "DS248x reset failed during setup!"); + } + + // Configure device + if (!this->device_configure_()) { + ESP_LOGE(TAG, "DS248x configuration failed!"); + this->mark_failed(); + return; + } + + // Reset to Channel 0 + this->select_channel(0); + + ESP_LOGI(TAG, "DS248x initialized successfully."); +} + +void DS248xComponent::on_shutdown() { + if (this->sleep_pin_ && (this->hub_sleep_ || this->bus_sleep_)) { + this->sleep_pin_->digital_write(false); // Sleep + } +} + +void DS248xComponent::dump_config() { + ESP_LOGCONFIG(TAG, "DS248x:"); + LOG_I2C_DEVICE(this); + ESP_LOGCONFIG(TAG, " Channel Count: %d", this->channel_count_); + ESP_LOGCONFIG(TAG, " Active Pullup: %s", YESNO(this->active_pullup_)); + if (this->ds2484_mode_) { + ESP_LOGCONFIG(TAG, " DS2484 Mode: enabled"); + } +} + +// --- Internal Helpers --- + +// Datasheet command durations are sub-2ms; allow a little margin before forcing recovery. +static constexpr uint32_t BUSY_TIMEOUT_MS = 5; + +bool DS248xComponent::set_read_pointer_(uint8_t ptr) { return this->write_byte(DS248X_COMMAND_SETREADPTR, ptr); } + +bool DS248xComponent::wait_busy_() { + uint32_t start = millis(); + do { + uint8_t status; + if (this->read(&status, 1) == i2c::ERROR_OK && !(status & DS248X_STATUS_BUSY)) + return true; + delayMicroseconds(100); + } while (millis() - start < BUSY_TIMEOUT_MS); + ESP_LOGW(TAG, "DS248x busy timeout"); + bool recovered = this->device_reset_() && this->device_configure_(); + this->current_channel_ = -1; + if (!recovered) { + ESP_LOGE(TAG, "DS248x recovery failed after busy timeout"); + this->mark_failed(); + } + return false; +} + +bool DS248xComponent::device_reset_() { + ESP_LOGD(TAG, "Resetting device..."); + uint8_t cmd = DS248X_COMMAND_RESET; + if (this->write(&cmd, 1) != i2c::ERROR_OK) + return false; + + uint8_t status; + if (this->read(&status, 1) != i2c::ERROR_OK) + return false; + + if (!(status & DS248X_STATUS_RST)) { + ESP_LOGW(TAG, "Device reset failed (RST bit not set)"); + return false; + } + + this->current_channel_ = -1; + return true; +} + +bool DS248xComponent::device_configure_() { + ESP_LOGD(TAG, "Configuring device..."); + + if (!this->write_config_()) { + ESP_LOGW(TAG, "Config write/verify failed"); + return false; + } + + ESP_LOGD(TAG, "Configured successfully"); + + // DS2484 Configuration + if (this->ds2484_mode_) { + if (this->ds2484_trstl_ != DS2484_PARAM_UNSET && + !this->configure_ds2484_port_(DS2484_PORT_PARAM_TRSTL, this->ds2484_trstl_)) + return false; + if (this->ds2484_tmsp_ != DS2484_PARAM_UNSET && + !this->configure_ds2484_port_(DS2484_PORT_PARAM_TMSP, this->ds2484_tmsp_)) + return false; + if (this->ds2484_tw0l_ != DS2484_PARAM_UNSET && + !this->configure_ds2484_port_(DS2484_PORT_PARAM_TW0L, this->ds2484_tw0l_)) + return false; + if (this->ds2484_trec0_ != DS2484_PARAM_UNSET && + !this->configure_ds2484_port_(DS2484_PORT_PARAM_TREC0, this->ds2484_trec0_)) + return false; + if (this->ds2484_rwpu_ != DS2484_PARAM_UNSET && + !this->configure_ds2484_port_(DS2484_PORT_PARAM_RWPU, this->ds2484_rwpu_)) + return false; + } + + return true; +} + +bool DS248xComponent::configure_ds2484_port_(uint8_t param, uint8_t val) { + uint8_t cmd = DS2484_COMMAND_ADJUSTPORT; + // Control Byte format (DS2484 Table 6): P[2:0] in bits 7:5, OD in bit 4, VAL[3:0] in bits 3:0 + uint8_t data = ((param & 0x07) << 5) | (val & 0x0F); + + // The DS2484 always acknowledges the Adjust 1-Wire Port control byte (datasheet "Adjust + // 1-Wire Port"), so a successful write confirms the update. We deliberately do not read + // back to verify: a single read of the Port Configuration register always returns the + // fixed 8-byte report starting at Byte 1 (tRSTL standard speed), not the parameter that + // was just written, so a per-parameter readback comparison would spuriously fail for + // tMSP/tW0L/tREC0/RWPU. + if (!this->write_byte(cmd, data)) { + ESP_LOGW(TAG, "DS2484 port config failed (param %d)", param); + return false; + } + + return this->set_read_pointer_(DS248X_POINTER_STATUS); +} + +bool DS248xComponent::write_config_() { + uint8_t config = 0; + if (this->active_pullup_) + config |= DS248X_CONFIG_ACTIVE_PULLUP; + + // The DS248x only accepts the config byte if the upper nibble is the one's-complement of the lower nibble. + uint8_t config_byte = (config & 0x0F) | ((~config & 0x0F) << 4); + + if (!this->write_byte(DS248X_COMMAND_WRITECONFIG, config_byte)) { + ESP_LOGW(TAG, "Failed to write config byte"); + return false; + } + + if (!this->set_read_pointer_(DS248X_POINTER_CONFIG)) { + return false; + } + + uint8_t read_config; + if (this->read(&read_config, 1) != i2c::ERROR_OK) { + ESP_LOGW(TAG, "Failed to read back config byte"); + return false; + } + + if ((read_config & 0x0F) != (config_byte & 0x0F)) { + ESP_LOGW(TAG, "Config mismatch! Wrote 0x%02x, Read 0x%02x", config_byte, read_config); + return false; + } + + return this->set_read_pointer_(DS248X_POINTER_STATUS); +} + +// --- Channel Selection --- + +// Channel select codes: write code -> expected read code +static constexpr uint8_t CHANNEL_WRITE_CODES[8] = {0xF0, 0xE1, 0xD2, 0xC3, 0xB4, 0xA5, 0x96, 0x87}; +static constexpr uint8_t CHANNEL_READ_CODES[8] = {0xB8, 0xB1, 0xAA, 0xA3, 0x9C, 0x95, 0x8E, 0x87}; + +bool DS248xComponent::select_channel(uint8_t channel) { + if (this->channel_count_ <= 1) + return true; + if (channel >= this->channel_count_) + return false; + + if (this->current_channel_ == channel) + return true; + + if (!this->write_byte(DS248X_COMMAND_CHANNELSELECT, CHANNEL_WRITE_CODES[channel])) { + this->current_channel_ = -1; + return false; + } + + uint8_t read_code; + if (this->read(&read_code, 1) != i2c::ERROR_OK) { + this->current_channel_ = -1; + return false; + } + + if (read_code != CHANNEL_READ_CODES[channel]) { + ESP_LOGW(TAG, "Channel select failed! Expected 0x%02x, got 0x%02x", CHANNEL_READ_CODES[channel], read_code); + this->current_channel_ = -1; + return false; + } + + if (!this->set_read_pointer_(DS248X_POINTER_STATUS)) + return false; + + this->current_channel_ = channel; + return true; +} + +// --- 1-Wire Bus Operations --- + +bool DS248xComponent::ow_reset(bool &presence) { + if (!this->set_read_pointer_(DS248X_POINTER_STATUS)) + return false; + + uint8_t cmd = DS248X_COMMAND_RESETWIRE; + if (this->write(&cmd, 1) != i2c::ERROR_OK) + return false; + + if (!this->wait_busy_()) { + ESP_LOGW(TAG, "ow_reset: wait busy failed"); + return false; + } + + uint8_t status; + if (this->read(&status, 1) != i2c::ERROR_OK) { + ESP_LOGW(TAG, "ow_reset: read status failed"); + return false; + } + + if (status & DS248X_STATUS_SD) { + ESP_LOGW(TAG, "Short detected on 1-Wire bus!"); + return false; + } + + presence = (status & DS248X_STATUS_PPD); + return true; +} + +bool DS248xComponent::ow_write_byte(uint8_t byte) { + if (!this->set_read_pointer_(DS248X_POINTER_STATUS)) + return false; + + if (!this->wait_busy_()) { + ESP_LOGW(TAG, "Device busy before writing byte 0x%02x", byte); + return false; + } + + uint8_t cmd[2] = {DS248X_COMMAND_WRITEBYTE, byte}; + if (this->write(cmd, 2) != i2c::ERROR_OK) { + ESP_LOGW(TAG, "I2C write failed for byte 0x%02x", byte); + return false; + } + + if (!this->wait_busy_()) { + ESP_LOGW(TAG, "Timeout waiting for write byte to complete!"); + return false; + } + + return true; +} + +bool DS248xComponent::ow_read_byte(uint8_t &byte) { + if (!this->set_read_pointer_(DS248X_POINTER_STATUS)) + return false; + + uint8_t cmd = DS248X_COMMAND_READBYTE; + if (this->write(&cmd, 1) != i2c::ERROR_OK) + return false; + + if (!this->wait_busy_()) + return false; + + if (!this->set_read_pointer_(DS248X_POINTER_DATA)) + return false; + + if (this->read(&byte, 1) != i2c::ERROR_OK) + return false; + + return true; +} + +bool DS248xComponent::search_triplet(bool search_direction, uint8_t &status) { + if (!this->set_read_pointer_(DS248X_POINTER_STATUS)) + return false; + + // DS248x Datasheet: 1-Wire Triplet command requires 2 bytes: + // Byte 1: Command code 0x78 + // Byte 2: Direction byte (bit 7 = V, search direction if discrepancy) + uint8_t buffer[2] = {DS248X_COMMAND_TRIPLET, static_cast(search_direction ? 0x80 : 0x00)}; + if (this->write(buffer, 2) != i2c::ERROR_OK) + return false; + + if (!this->wait_busy_()) + return false; + + if (this->read(&status, 1) != i2c::ERROR_OK) + return false; + + return true; +} + +} // namespace esphome::ds248x diff --git a/esphome/components/ds248x/ds248x.h b/esphome/components/ds248x/ds248x.h new file mode 100644 index 0000000000..0873ee0e2a --- /dev/null +++ b/esphome/components/ds248x/ds248x.h @@ -0,0 +1,133 @@ +#pragma once + +// DS248x I2C-to-1-Wire Bridge Family +// Datasheet: https://www.analog.com/media/en/technical-documentation/data-sheets/ds2482-100.pdf +// Datasheet: https://www.analog.com/media/en/technical-documentation/data-sheets/ds2482-800.pdf +// Datasheet: https://www.analog.com/media/en/technical-documentation/data-sheets/ds2484.pdf + +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::ds248x { + +// DS248x I2C Commands +static constexpr uint8_t DS248X_COMMAND_RESET = 0xF0; +static constexpr uint8_t DS248X_COMMAND_SETREADPTR = 0xE1; +static constexpr uint8_t DS248X_COMMAND_WRITECONFIG = 0xD2; +static constexpr uint8_t DS248X_COMMAND_CHANNELSELECT = 0xC3; +static constexpr uint8_t DS248X_COMMAND_RESETWIRE = 0xB4; +static constexpr uint8_t DS248X_COMMAND_WRITEBYTE = 0xA5; +static constexpr uint8_t DS248X_COMMAND_READBYTE = 0x96; +static constexpr uint8_t DS248X_COMMAND_TRIPLET = 0x78; +static constexpr uint8_t DS2484_COMMAND_ADJUSTPORT = 0xC3; + +// DS2484 "Adjust 1-Wire Port" parameter codes (datasheet Table 6, control byte P[2:0]) +static constexpr uint8_t DS2484_PORT_PARAM_TRSTL = 0x0; +static constexpr uint8_t DS2484_PORT_PARAM_TMSP = 0x1; +static constexpr uint8_t DS2484_PORT_PARAM_TW0L = 0x2; +static constexpr uint8_t DS2484_PORT_PARAM_TREC0 = 0x3; +static constexpr uint8_t DS2484_PORT_PARAM_RWPU = 0x4; + +// DS248x Status Register Bits +static constexpr uint8_t DS248X_STATUS_BUSY = 0x01; +static constexpr uint8_t DS248X_STATUS_PPD = 0x02; +static constexpr uint8_t DS248X_STATUS_SD = 0x04; +static constexpr uint8_t DS248X_STATUS_RST = 0x10; +static constexpr uint8_t DS248X_STATUS_SBR = 0x20; +static constexpr uint8_t DS248X_STATUS_TSB = 0x40; +static constexpr uint8_t DS248X_STATUS_DIR = 0x80; + +// DS248x Register Pointers +static constexpr uint8_t DS248X_POINTER_STATUS = 0xF0; +static constexpr uint8_t DS248X_POINTER_DATA = 0xE1; +static constexpr uint8_t DS248X_POINTER_CONFIG = 0xC3; + +// DS248x Configuration Bits +static constexpr uint8_t DS248X_CONFIG_ACTIVE_PULLUP = 0x01; + +/** + * @brief DS248x I2C-to-1-Wire Bridge Component. + * + * This component manages the DS248x chip (DS2482-100, DS2482-800, DS2484). + * It provides low-level 1-Wire bus operations via I2C. + * + * Usage: Configure DS248xOneWireBus instances for each channel. + * These buses implement the one_wire::OneWireBus interface for compatibility + * with all existing 1-Wire device components (dallas_temp, etc.). + */ +class DS248xComponent : public Component, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + void on_shutdown() override; + float get_setup_priority() const override { return setup_priority::BUS; } + + void set_sleep_pin(InternalGPIOPin *pin) { this->sleep_pin_ = pin; } + void set_bus_sleep(bool enabled) { this->bus_sleep_ = enabled; } + void set_hub_sleep(bool enabled) { this->hub_sleep_ = enabled; } + void set_channel_count(uint8_t count) { this->channel_count_ = count; } + void set_active_pullup(bool enabled) { this->active_pullup_ = enabled; } + + // DS2484 Timing Parameters + void set_val_trstl(uint8_t val) { + this->ds2484_trstl_ = val; + this->ds2484_mode_ = true; + } + void set_val_tmsp(uint8_t val) { + this->ds2484_tmsp_ = val; + this->ds2484_mode_ = true; + } + void set_val_tw0l(uint8_t val) { + this->ds2484_tw0l_ = val; + this->ds2484_mode_ = true; + } + void set_val_trec0(uint8_t val) { + this->ds2484_trec0_ = val; + this->ds2484_mode_ = true; + } + void set_val_rwpu(uint8_t val) { + this->ds2484_rwpu_ = val; + this->ds2484_mode_ = true; + } + + /// Get the channel count (1 for DS2482-100/DS2484, 8 for DS2482-800) + uint8_t get_channel_count() const { return this->channel_count_; } + + // --- Core 1-Wire API (used by DS248xOneWireBus) --- + bool select_channel(uint8_t channel); + bool ow_reset(bool &presence); + bool ow_write_byte(uint8_t byte); + bool ow_read_byte(uint8_t &byte); + + // --- Search support (used by DS248xOneWireBus) --- + bool search_triplet(bool search_direction, uint8_t &status); + + protected: + InternalGPIOPin *sleep_pin_{nullptr}; + uint8_t channel_count_ = 1; + bool bus_sleep_{false}; + bool hub_sleep_{false}; + bool active_pullup_ = false; + + // DS2484 Config + bool ds2484_mode_ = false; + static constexpr uint8_t DS2484_PARAM_UNSET = 0xFF; + uint8_t ds2484_trstl_{DS2484_PARAM_UNSET}; + uint8_t ds2484_tmsp_{DS2484_PARAM_UNSET}; + uint8_t ds2484_tw0l_{DS2484_PARAM_UNSET}; + uint8_t ds2484_trec0_{DS2484_PARAM_UNSET}; + uint8_t ds2484_rwpu_{DS2484_PARAM_UNSET}; + + int8_t current_channel_{-1}; + + // Internal helpers + bool set_read_pointer_(uint8_t ptr); + bool wait_busy_(); + bool device_reset_(); + bool device_configure_(); + bool configure_ds2484_port_(uint8_t param, uint8_t val); + bool write_config_(); +}; + +} // namespace esphome::ds248x diff --git a/esphome/components/ds248x/ds248x_one_wire_bus.cpp b/esphome/components/ds248x/ds248x_one_wire_bus.cpp new file mode 100644 index 0000000000..ed5b05bab7 --- /dev/null +++ b/esphome/components/ds248x/ds248x_one_wire_bus.cpp @@ -0,0 +1,171 @@ +#include "ds248x_one_wire_bus.h" +#include "ds248x.h" +#include "esphome/core/log.h" + +namespace esphome::ds248x { + +static const char *const TAG = "ds248x.one_wire"; + +void DS248xOneWireBus::setup() { + ESP_LOGCONFIG(TAG, "Setting up DS248x 1-Wire Bus (Channel %d)...", this->channel_); + + // Parent setup happens in DS248xComponent::setup() + // We just need to scan for devices on this channel + if (!this->ensure_channel_()) { + ESP_LOGE(TAG, "Failed to select channel %d during setup", this->channel_); + this->mark_failed(); + return; + } + + // Perform device search on this channel + this->search(); + + ESP_LOGCONFIG(TAG, "Found %zu devices on channel %d", this->devices_.size(), this->channel_); +} + +void DS248xOneWireBus::dump_config() { + ESP_LOGCONFIG(TAG, "DS248x 1-Wire Bus (Channel %d):", this->channel_); + this->dump_devices_(TAG); +} + +bool DS248xOneWireBus::ensure_channel_() { + if (this->parent_ == nullptr) { + ESP_LOGE(TAG, "Parent not set!"); + return false; + } + return this->parent_->select_channel(this->channel_); +} + +int DS248xOneWireBus::reset_int() { + if (!this->ensure_channel_()) { + return -1; + } + + bool presence = false; + if (!this->parent_->ow_reset(presence)) { + return -1; + } + return presence ? 1 : 0; +} + +void DS248xOneWireBus::write8(uint8_t val) { + if (!this->ensure_channel_()) { + return; + } + if (!this->parent_->ow_write_byte(val)) { + ESP_LOGE(TAG, "Failed to write byte 0x%02X on channel %d", val, this->channel_); + } +} + +void DS248xOneWireBus::write64(uint64_t val) { + if (!this->ensure_channel_()) { + return; + } + for (uint8_t i = 0; i < 8; i++) { + uint8_t byte = static_cast(val >> (i * 8)); + if (!this->parent_->ow_write_byte(byte)) { + ESP_LOGE(TAG, "Failed to write byte %d/8 (0x%02X) on channel %d - aborting write64", i + 1, byte, this->channel_); + return; // Stop writing to prevent sending corrupted data + } + } +} + +uint8_t DS248xOneWireBus::read8() { + if (!this->ensure_channel_()) { + return 0; + } + uint8_t value = 0; + if (!this->parent_->ow_read_byte(value)) { + ESP_LOGE(TAG, "Failed to read byte on channel %d", this->channel_); + } + return value; +} + +uint64_t DS248xOneWireBus::read64() { + if (!this->ensure_channel_()) { + return 0; + } + uint64_t value = 0; + for (uint8_t i = 0; i < 8; i++) { + uint8_t byte = 0; + if (!this->parent_->ow_read_byte(byte)) { + ESP_LOGE(TAG, "Failed to read byte %d/8 on channel %d - returning partial data", i + 1, this->channel_); + return value; // Return partial data to avoid blocking, caller should validate + } + value |= (static_cast(byte) << (i * 8)); + } + return value; +} + +void DS248xOneWireBus::reset_search() { + this->search_last_discrepancy_ = 0; + this->search_last_device_flag_ = false; + this->search_address_ = 0; +} + +uint64_t DS248xOneWireBus::search_int() { + if (!this->ensure_channel_()) { + return 0; + } + + if (this->search_last_device_flag_) { + return 0; + } + + uint8_t last_zero = 0; + uint64_t address = this->search_address_; + + // Iterate through all 64 bits + for (uint8_t bit_number = 1; bit_number <= 64; bit_number++) { + uint64_t bit_mask = 1ULL << (bit_number - 1); + + // Determine search direction + bool search_direction; + if (bit_number < this->search_last_discrepancy_) { + search_direction = (address & bit_mask) != 0; + } else { + search_direction = (bit_number == this->search_last_discrepancy_); + } + + // Perform triplet operation + uint8_t status = 0; + if (!this->parent_->search_triplet(search_direction, status)) { + ESP_LOGW(TAG, "1-Wire triplet failed at bit %d on channel %d - aborting search", bit_number, this->channel_); + this->reset_search(); + return 0; + } + + bool id_bit = (status & DS248X_STATUS_SBR) != 0; + bool cmp_id_bit = (status & DS248X_STATUS_TSB) != 0; + bool dir_taken = (status & DS248X_STATUS_DIR) != 0; + + if (id_bit && cmp_id_bit) { + // No devices participating + this->reset_search(); + return 0; + } + + if (!id_bit && !cmp_id_bit && !dir_taken) { + // Discrepancy, went 0 - record position + last_zero = bit_number; + } + + // Update address based on direction taken + if (dir_taken) { + address |= bit_mask; + } else { + address &= ~bit_mask; + } + } + + // Search successful + this->search_last_discrepancy_ = last_zero; + if (last_zero == 0) { + this->search_last_device_flag_ = true; + } + this->search_address_ = address; + + return address; +} + +} // namespace esphome::ds248x diff --git a/esphome/components/ds248x/ds248x_one_wire_bus.h b/esphome/components/ds248x/ds248x_one_wire_bus.h new file mode 100644 index 0000000000..0591796d60 --- /dev/null +++ b/esphome/components/ds248x/ds248x_one_wire_bus.h @@ -0,0 +1,57 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/one_wire/one_wire_bus.h" + +namespace esphome::ds248x { + +class DS248xComponent; + +/** + * @brief OneWireBus implementation for DS248x I2C-to-1-Wire bridges. + * + * This class wraps the DS248xComponent to provide the one_wire::OneWireBus interface, + * enabling compatibility with all existing 1-Wire device components (dallas_temp, etc.). + * + * For DS2482-800, multiple instances of this class can be created (one per channel). + * For DS2482-100/DS2484, a single instance is used. + */ +class DS248xOneWireBus : public one_wire::OneWireBus, public Component { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::BUS - 1.0f; } + + /// Set the parent DS248x component + void set_parent(DS248xComponent *parent) { this->parent_ = parent; } + + /// Set the 1-Wire channel (0-7, only relevant for DS2482-800) + void set_channel(uint8_t channel) { this->channel_ = channel; } + + /// Get the channel number + uint8_t get_channel() const { return this->channel_; } + + // OneWireBus interface implementation + int reset_int() override; + void write8(uint8_t val) override; + void write64(uint64_t val) override; + uint8_t read8() override; + uint64_t read64() override; + + protected: + void reset_search() override; + uint64_t search_int() override; + + /// Select the channel on the DS248x before any 1-Wire operation + bool ensure_channel_(); + + DS248xComponent *parent_{nullptr}; + uint8_t channel_{0}; + + // Search state + uint64_t search_address_{0}; + uint8_t search_last_discrepancy_{0}; + bool search_last_device_flag_{false}; +}; + +} // namespace esphome::ds248x diff --git a/esphome/components/ds248x/one_wire.py b/esphome/components/ds248x/one_wire.py new file mode 100644 index 0000000000..19861eae36 --- /dev/null +++ b/esphome/components/ds248x/one_wire.py @@ -0,0 +1,56 @@ +"""DS248x 1-Wire Bus Platform. + +This platform creates one_wire bus instances backed by a DS248x I2C-to-1-Wire bridge. +It supports DS2482-100/101 (single channel), DS2482-800 (8 channels), and DS2484 (single channel). + +For multi-channel devices (DS2482-800), create one platform entry per channel. +Each entry becomes a separate one_wire bus that can be used by dallas_temp and other 1-Wire devices. +""" + +from esphome import final_validate as fv +import esphome.codegen as cg +from esphome.components.one_wire import OneWireBus +import esphome.config_validation as cv +from esphome.const import CONF_CHANNEL, CONF_ID + +from . import CONF_DS248X_ID, DS248xComponent, ds248x_ns, get_channel_count + +CODEOWNERS = ["@tomwellnitz"] +DEPENDENCIES = ["ds248x"] + +DS248xOneWireBus = ds248x_ns.class_("DS248xOneWireBus", OneWireBus, cg.Component) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(DS248xOneWireBus), + cv.GenerateID(CONF_DS248X_ID): cv.use_id(DS248xComponent), + cv.Optional(CONF_CHANNEL, default=0): cv.int_range(min=0, max=7), + } +).extend(cv.COMPONENT_SCHEMA) + + +def _final_validate(config): + """Validate that the channel is within the parent's channel count.""" + fconf = fv.full_config.get() + path = fconf.get_path_for_id(config[CONF_DS248X_ID])[:-1] + parent_config = fconf.get_config_for_path(path) + channel_count = get_channel_count(parent_config) + channel = config[CONF_CHANNEL] + + if channel >= channel_count: + raise cv.Invalid( + f"Channel {channel} is invalid for DS248x with {channel_count} channel(s). " + f"Valid range: 0-{channel_count - 1}" + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + parent = await cg.get_variable(config[CONF_DS248X_ID]) + cg.add(var.set_parent(parent)) + cg.add(var.set_channel(config[CONF_CHANNEL])) diff --git a/tests/components/ds248x/common.yaml b/tests/components/ds248x/common.yaml new file mode 100644 index 0000000000..53ae56f20a --- /dev/null +++ b/tests/components/ds248x/common.yaml @@ -0,0 +1,115 @@ +# Combined DS248x test covering all chip variants and options: +# - DS2482-100: active pullup, multiple sensors + index access +# - DS2482-101: sleep pin, bus_sleep / hub_sleep +# - DS2482-800: all 8 channels +# - DS2484: adjustable 1-Wire timing + RWPU pullup resistor selection +ds248x: + - id: ds2482_100 + address: 0x18 + type: ds2482-100 + active_pullup: true + - id: ds2482_101 + address: 0x19 + type: ds2482-101 + active_pullup: true + sleep_pin: + number: GPIO12 + inverted: false + bus_sleep: true + hub_sleep: true + - id: ds2482_800 + address: 0x1a + type: ds2482-800 + active_pullup: true + - id: ds2484_hub + address: 0x1b + type: ds2484 + active_pullup: true + # DS2484-specific 1-Wire timing parameters (optional fine-tuning) + reset_low_time: 8 # tRSTL: Reset low time + master_sample_time: 8 # tMSP: Master sample point + write_0_low_time: 8 # tW0L: Write-0 low time + recovery_time: 8 # tREC0: Recovery time + active_pullup_resistance: 1000ohm # RWPU: weak pullup resistor selection + +one_wire: + - platform: ds248x + ds248x_id: ds2482_100 + channel: 0 + id: ow_100 + - platform: ds248x + ds248x_id: ds2482_101 + channel: 0 + id: ow_101 + - platform: ds248x + ds248x_id: ds2482_800 + channel: 0 + id: ow_800_0 + - platform: ds248x + ds248x_id: ds2482_800 + channel: 1 + id: ow_800_1 + - platform: ds248x + ds248x_id: ds2482_800 + channel: 2 + id: ow_800_2 + - platform: ds248x + ds248x_id: ds2482_800 + channel: 3 + id: ow_800_3 + - platform: ds248x + ds248x_id: ds2482_800 + channel: 4 + id: ow_800_4 + - platform: ds248x + ds248x_id: ds2482_800 + channel: 5 + id: ow_800_5 + - platform: ds248x + ds248x_id: ds2482_800 + channel: 6 + id: ow_800_6 + - platform: ds248x + ds248x_id: ds2482_800 + channel: 7 + id: ow_800_7 + - platform: ds248x + ds248x_id: ds2484_hub + channel: 0 + id: ow_2484 + +sensor: + # DS2482-100: explicit address + index-based access on the same bus + - platform: dallas_temp + one_wire_id: ow_100 + address: 0x1c0000031edd2a28 + name: Temp 100 by address + resolution: 12 + - platform: dallas_temp + one_wire_id: ow_100 + index: 0 + name: Temp 100 by index + # DS2482-101 (sleep variant) + - platform: dallas_temp + one_wire_id: ow_101 + address: 0x578295491f64ff28 + name: Temp 101 + # DS2482-800: sensors on a few of the eight channels + - platform: dallas_temp + one_wire_id: ow_800_0 + address: 0x1c0000031edd2a28 + name: Temp 800 CH0 + - platform: dallas_temp + one_wire_id: ow_800_3 + index: 0 + name: Temp 800 CH3 by index + - platform: dallas_temp + one_wire_id: ow_800_7 + address: 0x2800000123456789 + name: Temp 800 CH7 + # DS2484 (adjustable timing) + - platform: dallas_temp + one_wire_id: ow_2484 + address: 0x1c0000031edd2a28 + name: Temp 2484 + resolution: 12 diff --git a/tests/components/ds248x/test.esp32-ard.yaml b/tests/components/ds248x/test.esp32-ard.yaml new file mode 100644 index 0000000000..7c503b0ccb --- /dev/null +++ b/tests/components/ds248x/test.esp32-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/ds248x/test.esp32-idf.yaml b/tests/components/ds248x/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/ds248x/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/ds248x/test.esp8266-ard.yaml b/tests/components/ds248x/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4a98b9388a --- /dev/null +++ b/tests/components/ds248x/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/ds248x/test.rp2040-ard.yaml b/tests/components/ds248x/test.rp2040-ard.yaml new file mode 100644 index 0000000000..319a7c71a6 --- /dev/null +++ b/tests/components/ds248x/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml From 017040ec8e9111c4542fa44eb1ce4869283205ac Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 18 Jul 2026 09:09:52 +0200 Subject: [PATCH 0959/1815] [zigbee] add radio power off stats (#17521) --- esphome/components/zigbee/zigbee_zephyr.cpp | 45 +++++++++++++++++++-- esphome/components/zigbee/zigbee_zephyr.h | 3 ++ esphome/components/zigbee/zigbee_zephyr.py | 8 ++++ 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/esphome/components/zigbee/zigbee_zephyr.cpp b/esphome/components/zigbee/zigbee_zephyr.cpp index 81aad7dcb1..fedcb4a9c2 100644 --- a/esphome/components/zigbee/zigbee_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_zephyr.cpp @@ -229,6 +229,7 @@ void ZigbeeComponent::dump_config() { " Wipe on boot: %s\n" " Device is joined to the network: %s\n" " Sleep time: %us\n" + " Radio sleep time: %us\n" " RX ON when idle: %s\n" " Current channel: %d\n" " Current page: %d\n" @@ -238,9 +239,10 @@ void ZigbeeComponent::dump_config() { " Short addr: 0x%04X\n" " Long pan id: 0x%s\n" " Short pan id: 0x%04X", - get_wipe_on_boot(), YESNO(zb_zdo_joined()), this->sleep_time_, YESNO(zb_get_rx_on_when_idle()), - zb_get_current_channel(), zb_get_current_page(), zb_get_sleep_threshold(), role(), ieee_addr_buf, - zb_get_short_address(), extended_pan_id_buf, zb_get_pan_id()); + get_wipe_on_boot(), YESNO(zb_zdo_joined()), this->sleep_time_, this->radio_sleep_time_, + YESNO(zb_get_rx_on_when_idle()), zb_get_current_channel(), zb_get_current_page(), + zb_get_sleep_threshold(), role(), ieee_addr_buf, zb_get_short_address(), extended_pan_id_buf, + zb_get_pan_id()); dump_reporting_(); } @@ -251,6 +253,13 @@ static void send_attribute_report(zb_bufid_t bufid, zb_uint16_t cmd_id) { void ZigbeeComponent::force_report() { this->force_report_ = true; } +void ZigbeeComponent::add_radio_sleep_time_ms(uint32_t ms) { + this->radio_sleep_remainder_ += ms; + uint32_t seconds = this->radio_sleep_remainder_ / 1000; + this->radio_sleep_remainder_ -= seconds * 1000; + this->radio_sleep_time_ += seconds; +} + void ZigbeeComponent::loop() { if (this->force_report_) { this->force_report_ = false; @@ -327,6 +336,36 @@ zb_ret_t __wrap_zb_zcl_put_reporting_info_from_req(zb_zcl_configure_reporting_re esphome::zigbee::global_zigbee->after_reporting_info(config_rep_req, attr_addr_info); return ret; } + +extern void __real_zb_trans_enter_sleep(void); +extern void __real_zb_trans_enter_receive(void); +extern zb_bool_t __real_zb_trans_transmit(zb_uint8_t wait_type, zb_time_t tx_at, zb_uint8_t *tx_buf, + zb_uint8_t current_channel); + +static uint32_t radio_sleep_start_ms = 0; + +static void stop_radio_sleep_timer() { + if (radio_sleep_start_ms) { + esphome::zigbee::global_zigbee->add_radio_sleep_time_ms(esphome::millis() - radio_sleep_start_ms); + } + radio_sleep_start_ms = 0; +} + +void __wrap_zb_trans_enter_sleep(void) { + __real_zb_trans_enter_sleep(); + radio_sleep_start_ms = esphome::millis(); +} + +void __wrap_zb_trans_enter_receive(void) { + stop_radio_sleep_timer(); + __real_zb_trans_enter_receive(); +} + +zb_bool_t __wrap_zb_trans_transmit(zb_uint8_t wait_type, zb_time_t tx_at, zb_uint8_t *tx_buf, + zb_uint8_t current_channel) { + stop_radio_sleep_timer(); + return __real_zb_trans_transmit(wait_type, tx_at, tx_buf, current_channel); +} // NOLINTEND(readability-identifier-naming,bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp) } #endif diff --git a/esphome/components/zigbee/zigbee_zephyr.h b/esphome/components/zigbee/zigbee_zephyr.h index 3b4a465361..8528aebff8 100644 --- a/esphome/components/zigbee/zigbee_zephyr.h +++ b/esphome/components/zigbee/zigbee_zephyr.h @@ -81,6 +81,7 @@ class ZigbeeComponent final : public Component { void force_report(); void loop() override; void set_sleepy(bool sleepy) { this->sleepy_ = sleepy; } + void add_radio_sleep_time_ms(uint32_t ms); protected: static void zcl_device_cb(zb_bufid_t bufid); @@ -94,6 +95,8 @@ class ZigbeeComponent final : public Component { bool force_report_{false}; uint32_t sleep_time_{}; uint32_t sleep_remainder_{}; + uint32_t radio_sleep_time_{}; + uint32_t radio_sleep_remainder_{}; bool sleepy_{}; }; diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index 1647fb28ae..f47cf6bd40 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -117,6 +117,14 @@ async def zephyr_to_code(config: ConfigType) -> "MockObj": cg.add_build_flag("-Wl,--wrap=zb_zcl_put_reporting_info_from_req") + # Wrap the transceiver sleep/receive/transmit calls to measure how long the + # radio is powered down. The span between a zb_trans_enter_sleep() and the + # following zb_trans_enter_receive() or zb_trans_transmit() is time the + # radio spent asleep. + cg.add_build_flag("-Wl,--wrap=zb_trans_enter_sleep") + cg.add_build_flag("-Wl,--wrap=zb_trans_enter_receive") + cg.add_build_flag("-Wl,--wrap=zb_trans_transmit") + if CONF_IEEE802154_VENDOR_OUI in config: zephyr_add_prj_conf("IEEE802154_VENDOR_OUI_ENABLE", True) random_number = config[CONF_IEEE802154_VENDOR_OUI] From 0b0e706349731bdebf6e8e95f842276adf1de3a4 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 18 Jul 2026 14:05:19 +0200 Subject: [PATCH 0960/1815] [nrf52] add platform: ultrasonic test (#17665) --- tests/components/ultrasonic/test.nrf52-adafruit.yaml | 1 + 1 file changed, 1 insertion(+) create mode 100644 tests/components/ultrasonic/test.nrf52-adafruit.yaml diff --git a/tests/components/ultrasonic/test.nrf52-adafruit.yaml b/tests/components/ultrasonic/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/ultrasonic/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From 4c9dab9c980a452fca997edca747bbccb3dcca0e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:14:42 -0400 Subject: [PATCH 0961/1815] [esp32] Bump recommended ESP-IDF to 5.5.5 and Arduino to 3.3.10 (#17669) --- esphome/components/esp32/__init__.py | 15 +++++++++------ platformio.ini | 6 +++--- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 3c2fb35dde..7e13156b4d 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -814,14 +814,15 @@ def _is_framework_url(source: str) -> bool: # The default/recommended arduino framework version # - https://github.com/espressif/arduino-esp32/releases ARDUINO_FRAMEWORK_VERSION_LOOKUP = { - "recommended": cv.Version(3, 3, 9), - "latest": cv.Version(3, 3, 9), - "dev": cv.Version(3, 3, 9), + "recommended": cv.Version(3, 3, 10), + "latest": cv.Version(3, 3, 10), + "dev": cv.Version(3, 3, 10), } ARDUINO_PLATFORM_VERSION_LOOKUP = { cv.Version( 4, 0, 0, "alpha1" ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", + cv.Version(3, 3, 10): cv.Version(55, 3, 39), cv.Version(3, 3, 9): cv.Version(55, 3, 39), cv.Version(3, 3, 8): cv.Version(55, 3, 38, "1"), cv.Version(3, 3, 7): cv.Version(55, 3, 37), @@ -844,6 +845,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = { # See: https://github.com/pioarduino/esp-idf/releases ARDUINO_IDF_VERSION_LOOKUP = { cv.Version(4, 0, 0, "alpha1"): cv.Version(6, 0, 1), + cv.Version(3, 3, 10): cv.Version(5, 5, 5), cv.Version(3, 3, 9): cv.Version(5, 5, 4), cv.Version(3, 3, 8): cv.Version(5, 5, 4), cv.Version(3, 3, 7): cv.Version(5, 5, 3, "1"), @@ -865,9 +867,9 @@ ARDUINO_IDF_VERSION_LOOKUP = { # The default/recommended esp-idf framework version # - https://github.com/espressif/esp-idf/releases ESP_IDF_FRAMEWORK_VERSION_LOOKUP = { - "recommended": cv.Version(5, 5, 4), - "latest": cv.Version(5, 5, 4), - "dev": cv.Version(5, 5, 4), + "recommended": cv.Version(5, 5, 5), + "latest": cv.Version(5, 5, 5), + "dev": cv.Version(5, 5, 5), } ESP_IDF_PLATFORM_VERSION_LOOKUP = { @@ -877,6 +879,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { cv.Version( 6, 0, 0 ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", + cv.Version(5, 5, 5): cv.Version(55, 3, 39), cv.Version(5, 5, 4): cv.Version(55, 3, 39), cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37), cv.Version(5, 5, 3): cv.Version(55, 3, 37), diff --git a/platformio.ini b/platformio.ini index 35dec2ff76..255a900367 100644 --- a/platformio.ini +++ b/platformio.ini @@ -143,8 +143,8 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script extends = common:arduino platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip platform_packages = - pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.9/esp32-core-3.3.9.tar.xz - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz + pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.10/esp32-core-3.3.10.tar.xz + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz framework = arduino, espidf ; Arduino as an ESP-IDF component lib_deps = @@ -180,7 +180,7 @@ extra_scripts = extends = common:idf platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip platform_packages = - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz framework = espidf lib_deps = From 3064fb0d48f1542f1c6a7d397914548d9d5cd0e9 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 11:46:14 -1000 Subject: [PATCH 0962/1815] Bump bundled esphome-device-builder to 1.6.5 (#17675) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index f804ebd148..ecf1f0479a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.6.4 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.5 RUN \ platformio settings set enable_telemetry No \ From 96dd2382c14fbf2d6219ef354bc6e40b5ff0f0b4 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:25:56 -1000 Subject: [PATCH 0963/1815] Bump bundled esphome-device-builder to 1.6.6 (#17681) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index ecf1f0479a..5ab0e71008 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.6.5 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.6 RUN \ platformio settings set enable_telemetry No \ From 1f93345af81c6f2fb5b0debf6f2972d45d95323c Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:17:53 -1000 Subject: [PATCH 0964/1815] Bump bundled esphome-device-builder to 1.6.7 (#17696) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 5ab0e71008..331585f123 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.6.6 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.7 RUN \ platformio settings set enable_telemetry No \ From 977376d55c052991d70c37333914b5eea200cd87 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:20:43 -0500 Subject: [PATCH 0965/1815] Split multi-token build.flags entries when generating ESP-IDF component CMakeLists (#17649) --- esphome/espidf/component.py | 21 ++++++++++++ tests/unit_tests/test_espidf_component.py | 41 +++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index e9ec170a5e..51d023099e 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -83,6 +83,10 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: Returns: str: The complete CMakeLists.txt content as a string """ + # Late import: this module loads with the esp32 platform on every + # validate/compile, but shlex is only needed when generating component + # CMakeLists. + import shlex def escape_entry(p: PathType) -> str: # In CMakeLists.txt, backslashes need to be escaped @@ -105,6 +109,23 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: build_flags = ensure_list( component.data.get("build", {}).get("flags", DEFAULT_BUILD_FLAGS) ) + # PlatformIO shell-lexes each build.flags entry, so one entry can carry a + # flag and its argument (e.g. "-include cp_custom_alloc.h"). Split the + # same way; emitting such an entry as a single quoted compile option + # hands the compiler one argv with an embedded space. + build_flags = [token for entry in build_flags for token in shlex.split(entry)] + # Re-glue bare -I/-L/-l tokens to their argument ("-I foo" -> "-Ifoo") so + # the prefix classifiers below still route them to INCLUDE_DIRS and the + # link handling. + tokens, build_flags = build_flags, [] + i = 0 + while i < len(tokens): + if tokens[i] in ("-I", "-L", "-l") and i + 1 < len(tokens): + build_flags.append(tokens[i] + tokens[i + 1]) + i += 2 + else: + build_flags.append(tokens[i]) + i += 1 # List all sources files build_src_files = collect_filtered_files( diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 055e9c8502..89d5ce3cf2 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -193,6 +193,47 @@ target_link_libraries(${{COMPONENT_LIB}} INTERFACE ) +def test_generate_cmakelists_txt_multi_token_flag(tmp_component): + # PlatformIO shell-lexes each build.flags entry, so a single entry can + # carry a flag and its argument. The generated CMakeLists must emit them + # as separate compile options, not one argument with an embedded space. + src_dir = tmp_component.path / "src" + src_dir.mkdir() + (src_dir / "main.c").write_text("int main() {}") + + tmp_component.data = {"build": {"flags": ["-include cp_custom_alloc.h", "-DTEST"]}} + + content = generate_cmakelists_txt(tmp_component) + assert '"-include cp_custom_alloc.h"' not in content + assert ' "-include"\n "cp_custom_alloc.h"\n' in content + + +def test_generate_cmakelists_txt_space_separated_classified_flags(tmp_component): + # Space-separated -I/-L/-l entries routed to INCLUDE_DIRS and the link + # handling before the shlex split was added; splitting must not leak + # them into raw compile options. + src_dir = tmp_component.path / "src" + src_dir.mkdir() + (src_dir / "main.c").write_text("int main() {}") + (tmp_component.path / "extra_inc").mkdir() + + tmp_component.data = { + "build": {"flags": ["-I extra_inc", "-L extra_lib", "-l extralib", "-DTEST"]} + } + + content = generate_cmakelists_txt(tmp_component) + assert 'INCLUDE_DIRS "src" "extra_inc"' in content + assert 'target_link_directories(${COMPONENT_LIB} INTERFACE\n "extra_lib"\n)' in ( + content + ) + assert 'target_link_libraries(${COMPONENT_LIB} INTERFACE\n "extralib"\n)' in ( + content + ) + assert '"-I"' not in content + assert '"-L"' not in content + assert '"-l"' not in content + + def test_generate_cmakelists_txt_references_project_managed_components_variable( tmp_component: IDFComponent, ) -> None: From 0443b4849bd964995f05293cbc9201489f8d1bac Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:51:46 +1200 Subject: [PATCH 0966/1815] [ssd1306] Fix offset_x being ignored on SH1106/SH1107 displays (#17700) --- .../components/ssd1306_i2c/ssd1306_i2c.cpp | 20 +++++++++---------- .../components/ssd1306_spi/ssd1306_spi.cpp | 15 ++++++++------ 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp b/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp index 8ff908fe7a..00c864a217 100644 --- a/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp +++ b/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp @@ -41,17 +41,17 @@ void I2CSSD1306::command(uint8_t value) { this->write_byte(0x00, value); } void HOT I2CSSD1306::write_display_data() { if (this->is_sh1106_() || this->is_sh1107_()) { uint32_t i = 0; + // Some panels wire their visible columns to a window of the controller RAM + // that does not start at column 0 (e.g. SH1107 M5Stack Unit OLED needs offset_x: 32). + // SH1106 keeps its historical 0x02 base column on top of any offset. + uint8_t start_column = this->offset_x_; + if (this->is_sh1106_()) { + start_column += 0x02; + } for (uint8_t page = 0; page < (uint8_t) this->get_height_internal() / 8; page++) { - this->command(0xB0 + page); // row - if (this->is_sh1106_()) { - this->command(0x02); // lower column - 0x02 is historical SH1106 value - } else { - // Other SH1107 drivers use 0x00 - // Column values dont change and it seems they can be set only once, - // but we follow SH1106 implementation and resend them - this->command(0x00); - } - this->command(0x10); // higher column + this->command(0xB0 + page); // row + this->command(start_column & 0x0F); // lower column + this->command(0x10 | (start_column >> 4)); // higher column for (uint8_t x = 0; x < (uint8_t) this->get_width_internal() / 16; x++) { uint8_t data[16]; for (uint8_t &j : data) diff --git a/esphome/components/ssd1306_spi/ssd1306_spi.cpp b/esphome/components/ssd1306_spi/ssd1306_spi.cpp index 5c9369f1a2..0534deeb03 100644 --- a/esphome/components/ssd1306_spi/ssd1306_spi.cpp +++ b/esphome/components/ssd1306_spi/ssd1306_spi.cpp @@ -38,14 +38,17 @@ void SPISSD1306::command(uint8_t value) { } void HOT SPISSD1306::write_display_data() { if (this->is_sh1106_() || this->is_sh1107_()) { + // Some panels wire their visible columns to a window of the controller RAM + // that does not start at column 0 (e.g. SH1107 M5Stack Unit OLED needs offset_x: 32). + // SH1106 keeps its historical 0x02 base column on top of any offset. + uint8_t start_column = this->offset_x_; + if (this->is_sh1106_()) { + start_column += 0x02; + } for (uint8_t y = 0; y < (uint8_t) this->get_height_internal() / 8; y++) { this->command(0xB0 + y); - if (this->is_sh1106_()) { - this->command(0x02); - } else { - this->command(0x00); - } - this->command(0x10); + this->command(start_column & 0x0F); // lower column + this->command(0x10 | (start_column >> 4)); // higher column this->dc_pin_->digital_write(true); for (uint8_t x = 0; x < (uint8_t) this->get_width_internal(); x++) { this->enable(); From dfb988c5630d7a696724333e26dbca6488b25d85 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Jul 2026 18:16:15 -1000 Subject: [PATCH 0967/1815] [micro_wake_word] Download models in parallel (#17701) --- .../components/micro_wake_word/__init__.py | 97 +++++++--- esphome/external_files.py | 12 +- .../components/micro_wake_word/__init__.py | 0 .../components/micro_wake_word/test_init.py | 169 ++++++++++++++++++ 4 files changed, 247 insertions(+), 31 deletions(-) create mode 100644 tests/unit_tests/components/micro_wake_word/__init__.py create mode 100644 tests/unit_tests/components/micro_wake_word/test_init.py diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index 4b309551ba..c427f28028 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -2,6 +2,7 @@ import hashlib import json import logging from pathlib import Path +import re from urllib.parse import urljoin from esphome import automation, external_files, git @@ -9,6 +10,7 @@ from esphome.automation import register_action, register_condition from esphome.bundle import add_bundle_file import esphome.codegen as cg from esphome.components import esp32, microphone, ota, psram +from esphome.components.http_request import validate_url import esphome.config_validation as cv from esphome.const import ( CONF_FILE, @@ -209,33 +211,13 @@ def _validate_manifest_version(manifest_data): raise cv.Invalid("Invalid manifest file, missing 'version' key") -def _process_http_source(config): - url = config[CONF_URL] - path = _compute_local_file_path(config) - - json_path = path / "manifest.json" - - json_contents = external_files.download_content(url, json_path) - - manifest_data = json.loads(json_contents) - if not isinstance(manifest_data, dict): - raise cv.Invalid("Manifest file must contain a JSON object") - - model = manifest_data[CONF_MODEL] - model_url = urljoin(url, model) - - model_path = path / model - - external_files.download_content(str(model_url), model_path) - - return config - - -HTTP_SCHEMA = cv.All( +HTTP_SCHEMA = cv.Schema( { - cv.Required(CONF_URL): cv.url, - }, - _process_http_source, + # validate_url only accepts http(s); the shorthand validator relies + # on this branch rejecting git shorthands ("github://...") so they + # fall through to the git branch. + cv.Required(CONF_URL): validate_url, + } ) @@ -280,6 +262,13 @@ LOCAL_SCHEMA = cv.All( ) +# Bare model names in the official model repository ("okay_nabu"). Must not +# overlap with local paths, http(s) urls, or git shorthands +# ("github://user/repo/file.json@ref"), which the shorthand validator tries +# next; anything containing "/", ":" or "@" is not a model name. +_MODEL_NAME_RE = re.compile(r"[A-Za-z0-9_.-]+") + + def _validate_source_model_name(value): if not isinstance(value, str): raise cv.Invalid("Model name must be a string") @@ -287,6 +276,9 @@ def _validate_source_model_name(value): if value.endswith(".json"): raise cv.Invalid("Model name must not end with .json") + if not _MODEL_NAME_RE.fullmatch(value): + raise cv.Invalid("Model name may only contain letters, numbers, . _ -") + return MODEL_SOURCE_SCHEMA( { CONF_TYPE: TYPE_HTTP, @@ -376,6 +368,58 @@ def _maybe_empty_vad_schema(value): return VAD_MODEL_SCHEMA(value) +def _download_http_models(config: ConfigType) -> ConfigType: + """Download every http-sourced manifest and model file in two concurrent + batches (all manifests, then all model files). + + The model file's URL only becomes known once its manifest has been + fetched and parsed, so the two stages cannot be merged into one batch. + """ + model_parameters = [*config[CONF_MODELS]] + if vad := config.get(CONF_VAD): + model_parameters.append(vad) + # Keyed by cache path so a URL referenced twice is fetched and parsed once + http_models: dict[Path, str] = { + _compute_local_file_path(model_config): model_config[CONF_URL] + for parameters in model_parameters + if (model_config := parameters.get(CONF_MODEL)) is not None + and model_config.get(CONF_TYPE) == TYPE_HTTP + } + if not http_models: + return config + + external_files.download_content_many( + ((url, path / "manifest.json") for path, url in http_models.items()), + description="wake word manifest(s)", + ) + + model_files: list[tuple[str, Path]] = [] + errors: list[cv.Invalid] = [] + for path, url in http_models.items(): + try: + manifest_data = json.loads((path / "manifest.json").read_bytes()) + except (OSError, ValueError) as e: + errors.append(cv.Invalid(f"Invalid manifest file at {url}: {e}")) + continue + if not isinstance(manifest_data, dict): + errors.append( + cv.Invalid(f"Manifest file at {url} must contain a JSON object") + ) + continue + model = manifest_data.get(CONF_MODEL) + if not isinstance(model, str): + errors.append( + cv.Invalid(f"Manifest file at {url} is missing the 'model' key") + ) + continue + model_files.append((urljoin(url, model), path / model)) + if errors: + raise cv.MultipleInvalid(errors) + + external_files.download_content_many(model_files, description="wake word model(s)") + return config + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -409,6 +453,7 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), cv.only_on_esp32, + _download_http_models, ) diff --git a/esphome/external_files.py b/esphome/external_files.py index 4e73c8dc21..69423d3999 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -165,11 +165,8 @@ def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> by _LOGGER.debug("Remote file has not changed %s", url) return path.read_bytes() - _LOGGER.debug( - "Remote file has changed, downloading from %s to %s", - url, - path, - ) + _LOGGER.info("Downloading %s", url) + _LOGGER.debug("Saving to %s", path) try: req = requests.get( @@ -210,9 +207,13 @@ def download_content_many( items: Iterable[tuple[str, Path]], timeout: int = NETWORK_TIMEOUT, max_workers: int = DEFAULT_DOWNLOAD_WORKERS, + description: str = "remote file(s)", ) -> None: """Run `download_content` for each (url, path) pair concurrently. + `description` names the kind of files in the progress log line, e.g. + "wake word manifest(s)". + Wall time drops from `sum(latency)` to roughly `max(latency)` for cached files where the HEAD round-trip dominates. All workers run to completion before this returns; every `cv.Invalid` raised by a worker @@ -230,6 +231,7 @@ def download_content_many( seen: dict[Path, str] = {path: url for url, path in items} if not seen: return + _LOGGER.info("Checking %d %s for updates", len(seen), description) if len(seen) == 1: path, url = next(iter(seen.items())) download_content(url, path, timeout) diff --git a/tests/unit_tests/components/micro_wake_word/__init__.py b/tests/unit_tests/components/micro_wake_word/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/micro_wake_word/test_init.py b/tests/unit_tests/components/micro_wake_word/test_init.py new file mode 100644 index 0000000000..84371ab906 --- /dev/null +++ b/tests/unit_tests/components/micro_wake_word/test_init.py @@ -0,0 +1,169 @@ +"""Tests for the micro_wake_word model source validation and downloads.""" + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from esphome.components import micro_wake_word as mww +import esphome.config_validation as cv +from esphome.const import ( + CONF_FILE, + CONF_MODEL, + CONF_PATH, + CONF_REF, + CONF_TYPE, + CONF_URL, +) + + +@pytest.fixture +def mock_download_content_many() -> MagicMock: + """Patch the concurrent download helper so no network is involved.""" + with patch( + "esphome.components.micro_wake_word.external_files.download_content_many" + ) as m: + yield m + + +def test_shorthand_model_name_resolves_without_network( + mock_download_content_many: MagicMock, +) -> None: + config = mww._validate_source_shorthand("okay_nabu") + assert config[CONF_TYPE] == mww.TYPE_HTTP + assert config[CONF_URL] == ( + "https://github.com/esphome/micro-wake-word-models/raw/main/models/v2/okay_nabu.json" + ) + mock_download_content_many.assert_not_called() + + +def test_shorthand_git_with_ref_not_captured_as_model_name( + setup_core: Path, tmp_path: Path +) -> None: + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + (repo_dir / "model.json").write_text("{}") + with patch( + "esphome.components.micro_wake_word.git.clone_or_update", + return_value=(repo_dir, None), + ): + config = mww._validate_source_shorthand("github://user/repo/model.json@main") + assert config[CONF_TYPE] == "git" + assert config[CONF_URL] == "https://github.com/user/repo.git" + assert config[CONF_FILE] == "model.json" + assert config[CONF_REF] == "main" + + +def test_shorthand_local_path_not_captured_as_model_name( + setup_core: Path, tmp_path: Path +) -> None: + manifest = tmp_path / "model.json" + manifest.write_text("{}") + config = mww.MODEL_SOURCE_SCHEMA(str(manifest)) + assert config[CONF_TYPE] == "local" + assert Path(config[CONF_PATH]) == manifest + + +@pytest.mark.parametrize( + "value", ["some/path/file", "name@ref", "bad:name", "okay_nabu\n", "héllo"] +) +def test_model_name_rejects_non_identifiers(value: str) -> None: + with pytest.raises(cv.Invalid): + mww._validate_source_model_name(value) + + +def _http_model(name: str) -> dict: + return { + CONF_MODEL: { + CONF_TYPE: mww.TYPE_HTTP, + CONF_URL: f"https://example.com/models/{name}.json", + } + } + + +def _write_manifest(model_config: dict, contents: str) -> Path: + path = mww._compute_local_file_path(model_config[CONF_MODEL]) + path.mkdir(parents=True, exist_ok=True) + manifest = path / "manifest.json" + manifest.write_text(contents) + return path + + +def test_download_http_models_batches_manifests_then_models( + setup_core: Path, mock_download_content_many: MagicMock +) -> None: + names = ("okay_nabu", "hey_mycroft", "vad") + models = {name: _http_model(name) for name in names} + paths = { + name: _write_manifest(models[name], json.dumps({"model": f"{name}.tflite"})) + for name in names + } + config = { + mww.CONF_MODELS: [ + models["okay_nabu"], + models["hey_mycroft"], + # non-http sources must be ignored + {CONF_MODEL: {CONF_TYPE: "local", CONF_PATH: "x"}}, + ], + mww.CONF_VAD: models["vad"], + } + + assert mww._download_http_models(config) is config + + assert mock_download_content_many.call_count == 2 + manifest_items = list(mock_download_content_many.call_args_list[0].args[0]) + assert manifest_items == [ + (f"https://example.com/models/{name}.json", paths[name] / "manifest.json") + for name in names + ] + model_items = list(mock_download_content_many.call_args_list[1].args[0]) + assert model_items == [ + (f"https://example.com/models/{name}.tflite", paths[name] / f"{name}.tflite") + for name in names + ] + + +def test_download_http_models_no_http_sources_skips_download( + mock_download_content_many: MagicMock, +) -> None: + config = {mww.CONF_MODELS: [{CONF_MODEL: {CONF_TYPE: "local", CONF_PATH: "x"}}]} + assert mww._download_http_models(config) is config + mock_download_content_many.assert_not_called() + + +@pytest.mark.parametrize( + ("contents", "message"), + [ + ("not json", "Invalid manifest file"), + ("[1, 2]", "must contain a JSON object"), + ("{}", "missing the 'model' key"), + ], +) +def test_download_http_models_bad_manifest_raises( + setup_core: Path, + mock_download_content_many: MagicMock, + contents: str, + message: str, +) -> None: + model = _http_model("okay_nabu") + config = {mww.CONF_MODELS: [model]} + _write_manifest(model, contents) + + with pytest.raises(cv.Invalid, match=message): + mww._download_http_models(config) + # manifests were still fetched in one batch; the model batch never ran + assert mock_download_content_many.call_count == 1 + + +def test_download_http_models_collects_all_manifest_errors( + setup_core: Path, mock_download_content_many: MagicMock +) -> None: + models = {name: _http_model(name) for name in ("one", "two")} + config = {mww.CONF_MODELS: list(models.values())} + _write_manifest(models["one"], "not json") + _write_manifest(models["two"], "[1]") + + with pytest.raises(cv.MultipleInvalid) as excinfo: + mww._download_http_models(config) + assert len(excinfo.value.errors) == 2 From 76655cf95122b3f6926769818255b20049a903d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Jul 2026 18:32:20 -1000 Subject: [PATCH 0968/1815] [platformio] Accept git URLs passed as the library name (#17697) --- esphome/platformio/library.py | 39 ++++++++--- tests/unit_tests/test_espidf_component.py | 78 +++++++++++++++++++++ tests/unit_tests/test_platformio_library.py | 42 +++++++++++ 3 files changed, 150 insertions(+), 9 deletions(-) diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 0ffac65e0d..72a50b795b 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -25,7 +25,7 @@ from pathlib import Path import re import tempfile from typing import Any -from urllib.parse import urlparse, urlsplit, urlunsplit +from urllib.parse import urlsplit, urlunsplit from esphome import git from esphome.core import CORE, Library @@ -523,6 +523,17 @@ class _LibNode: edges: set[str] = field(default_factory=set) +def _url_or_none(value: Any) -> str | None: + """Return ``value`` if it parses as a URL (scheme and host), else None.""" + if not value or not isinstance(value, str): + return None + try: + parsed = urlsplit(value) + except ValueError: + return None + return value if parsed.scheme and parsed.netloc else None + + def _node_key( name: str | None, version: str | None, repository: str | None ) -> tuple[str, bool, tuple[str | None, str | None]]: @@ -533,9 +544,23 @@ def _node_key( inconsistently -- bare ``name`` vs ``owner/name``, or git vs registry -- maps to distinct keys and isn't deduplicated; ``convert_libraries`` warns about that after resolution rather than merging the nodes. + + PlatformIO's Library Manager also accepted a git URL in the *name* + position (``add_library("https://github.com/x/y", None)``), including the + ``git+`` VCS prefix and the ``CustomName=URL`` form; recognize those here + so such specs resolve as git sources instead of failing a registry lookup. """ + if not repository and name and "://" in name: + # Try the whole name first so a bare URL whose query contains ``=`` + # stays intact; fall back to the ``CustomName=URL`` form, where the + # key derives from the URL path and the custom name is irrelevant. + repository = _url_or_none(name) or _url_or_none(name.split("=", 1)[-1]) + if repository is None: + # Anything with ``://`` was meant to be a URL; failing it fast + # beats a confusing registry "package not found" error. + raise RuntimeError(f"Invalid PIO library URL: {name}") if repository: - split_result = urlsplit(repository) + split_result = urlsplit(repository.removeprefix("git+")) key = str(split_result.path).strip("/").removesuffix(".git") ref = split_result.fragment.strip() or None url = urlunsplit(split_result._replace(fragment="")) @@ -687,13 +712,9 @@ def convert_libraries( continue # The version field may actually be a URL (git/archive dependency). dep_version = dependency["version"] - dep_url = None - try: - parsed = urlparse(dep_version) - if all([parsed.scheme, parsed.netloc]): - dep_url, dep_version = dep_version, None - except (TypeError, ValueError): - pass + dep_url = _url_or_none(dep_version) + if dep_url is not None: + dep_version = None dep_key = add_spec(dep_name, dep_version, dep_url) node.edges.add(dep_key) worklist.append(dep_key) diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 89d5ce3cf2..f9ed44b8d2 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -456,6 +456,84 @@ def test_node_key_git_no_ref(): assert locator == ("https://github.com/foo/bar.git", None) +def test_node_key_url_in_name_is_git(): + # add_library("https://github.com/x/y", None): PlatformIO accepted a bare + # git URL as the library name, so the converter must too. + key, is_git, locator = _node_key( + "https://github.com/pstolarz/OneWireNg", None, None + ) + assert key == "pstolarz/OneWireNg" + assert is_git is True + assert locator == ("https://github.com/pstolarz/OneWireNg", None) + + +def test_node_key_url_in_name_with_ref(): + key, is_git, locator = _node_key( + "https://github.com/foo/bar.git#v1.2.3", None, None + ) + assert (key, is_git, locator) == ( + "foo/bar", + True, + ("https://github.com/foo/bar.git", "v1.2.3"), + ) + + +def test_node_key_url_in_name_git_plus_prefix(): + key, is_git, locator = _node_key("git+https://github.com/foo/bar", None, None) + assert (key, is_git, locator) == ( + "foo/bar", + True, + ("https://github.com/foo/bar", None), + ) + + +def test_node_key_git_plus_prefix_in_repository(): + _key, is_git, locator = _node_key("name", None, "git+https://github.com/foo/bar") + assert (is_git, locator) == (True, ("https://github.com/foo/bar", None)) + + +def test_node_key_custom_name_equals_url_is_git(): + key, is_git, locator = _node_key( + "OneWireNg=https://github.com/pstolarz/OneWireNg", None, None + ) + assert (key, is_git, locator) == ( + "pstolarz/OneWireNg", + True, + ("https://github.com/pstolarz/OneWireNg", None), + ) + + +def test_node_key_url_in_name_with_query_containing_equals(): + # A bare URL whose query string contains ``=`` must not be split by the + # CustomName=URL handling. + key, is_git, locator = _node_key("https://host/x/y.git?ref=main", None, None) + assert (key, is_git, locator) == ( + "x/y", + True, + ("https://host/x/y.git?ref=main", None), + ) + + +@pytest.mark.parametrize("name", ["http://[::1", "CustomName=http://[::1"]) +def test_node_key_malformed_url_in_name_raises(name: str) -> None: + # A name that was clearly meant to be a URL but does not parse must fail + # fast instead of degrading to a confusing registry lookup error. + with pytest.raises(RuntimeError, match="Invalid PIO library URL"): + _node_key(name, None, None) + + +def test_node_key_name_with_equals_but_no_url_is_registry(): + key, is_git, locator = _node_key("FOO=BAR", "1.0", None) + assert (key, is_git, locator) == ("FOO=BAR", False, (None, "FOO=BAR")) + + +def test_node_key_version_url_still_ignored_when_name_plain(): + # A version that is a URL is handled by the dependency walk, not here; + # a plain name must stay a registry spec regardless of version shape. + key, is_git, _locator = _node_key("bar", "https://github.com/foo/bar", None) + assert (key, is_git) == ("bar", False) + + def test_node_key_registry_owner_name(): key, is_git, locator = _node_key("foo/bar", "^1.0.0", None) assert (key, is_git, locator) == ("foo/bar", False, ("foo", "bar")) diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index 03360eab37..6a4c057469 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -212,6 +212,48 @@ def test_convert_libraries_handles_unparsable_dependency_version(tmp_path, monke assert [d.name for d in top[0].dependencies] == ["C"] +@pytest.mark.parametrize( + ("value", "expected"), + [ + (None, None), + ("", None), + ("http://[::1", None), # malformed IPv6 makes urlsplit raise ValueError + ("foo/bar", None), + ("file:///no/host", None), + ("https://github.com/x/y", "https://github.com/x/y"), + ], +) +def test_url_or_none(value: str | None, expected: str | None) -> None: + assert lib._url_or_none(value) == expected + + +def test_convert_libraries_url_in_name_resolves_as_git( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # add_library("https://github.com/x/y", None) puts a git URL in the name + # position; it must resolve as a git source and never hit the registry. + _patch_download_with_manifests( + monkeypatch, tmp_path, {"pstolarz/OneWireNg": {"name": "OneWireNg"}} + ) + + def fail_registry(owner: str, pkgname: str, requirements: set[str]) -> None: + raise AssertionError(f"registry consulted for {owner}/{pkgname}") + + # After the helper so this stub wins over the helper's benign one + monkeypatch.setattr(lib, "_resolve_registry_version", fail_registry) + + top = convert_libraries( + [Library("https://github.com/pstolarz/OneWireNg", None, None)], _backend() + ) + + assert [c.name for c in top] == ["pstolarz/OneWireNg"] + assert top[0].data["name"] == "OneWireNg" + source = top[0].source + assert isinstance(source, GitSource) + assert source.url == "https://github.com/pstolarz/OneWireNg" + assert source.ref is None + + def test_convert_libraries_skips_incompatible_dependency(tmp_path, monkeypatch): # A dependency that declares an incompatible platform is skipped (the # top-level library still builds). From 4a1f54ce4b7f12534e1e0369eeffa1fa824d4faf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Jul 2026 18:34:29 -1000 Subject: [PATCH 0969/1815] [git] Detect interrupted clones and re-clone automatically (#17690) --- esphome/git.py | 71 +++++++- tests/unit_tests/test_git.py | 309 ++++++++++++++++++++++++++++++++++- 2 files changed, 373 insertions(+), 7 deletions(-) diff --git a/esphome/git.py b/esphome/git.py index c4a612753b..0c1ad56367 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -10,14 +10,22 @@ import time import urllib.parse import esphome.config_validation as cv -from esphome.core import CORE, TimePeriodSeconds -from esphome.helpers import rmtree +from esphome.core import CORE, EsphomeError, TimePeriodSeconds +from esphome.helpers import rmtree, write_file _LOGGER = logging.getLogger(__name__) # Special value to indicate never refresh NEVER_REFRESH = TimePeriodSeconds(seconds=-1) +# Written inside .git only after every clone step (clone, ref fetch, reset, +# submodule init) has completed. A directory without it is an interrupted +# clone (e.g. the process was killed mid-clone) and must be re-cloned; without +# this check such a directory would be trusted forever when the caller uses +# NEVER_REFRESH. Lives in .git so stash/reset/checkout can never touch it and +# it does not pollute the worktree. +_CLONE_COMPLETE_MARKER = "esphome_clone_complete" + class GitException(cv.Invalid): """Base exception for git-related errors.""" @@ -95,6 +103,26 @@ def _compute_destination_path(key: str, domain: str) -> Path: return base_dir / h.hexdigest()[:8] +def _clone_complete_marker_path(repo_dir: Path) -> Path: + return repo_dir / ".git" / _CLONE_COMPLETE_MARKER + + +def _remove_repo_dir(repo_dir: Path) -> None: + """Remove a repo directory, deleting the completion marker first. + + Marker-first ordering guarantees an interrupted removal can never leave a + marker behind next to a partially deleted worktree. The unlink is best + effort: if it fails (e.g. a file lock on Windows), rmtree below still + gets the chance to remove the directory, marker included. + """ + try: + _clone_complete_marker_path(repo_dir).unlink(missing_ok=True) + except OSError as err: + _LOGGER.debug("Could not delete clone completion marker first: %s", err) + if repo_dir.is_dir(): + rmtree(repo_dir) + + def resolve_symlink_stub(repo_dir: Path, file_path: Path) -> Path | None: """Return the symlink target if ``file_path`` is a Windows-checked-out symlink stub. @@ -201,9 +229,19 @@ def clone_or_update( ) repo_dir = _compute_destination_path(key, domain) + hash_dir_name = repo_dir.name if subpath: repo_dir = repo_dir / subpath + if repo_dir.is_dir() and not _clone_complete_marker_path(repo_dir).is_file(): + # The last clone never finished (killed process, container stop) or + # predates the marker; either way it cannot be trusted, especially + # with NEVER_REFRESH where it would otherwise be reused forever. + _LOGGER.warning( + "Removing incomplete clone of %s at %s, will re-clone", key, repo_dir + ) + _remove_repo_dir(repo_dir) + if not repo_dir.is_dir(): _LOGGER.info("Cloning %s", key) _LOGGER.debug("Location: %s", repo_dir) @@ -233,14 +271,28 @@ def clone_or_update( + submodules, git_dir=repo_dir, ) + except GitException: # Remove incomplete clone to prevent stale state. Without this, # a failed ref fetch leaves a clone on the default branch, and # subsequent calls skip the update due to the refresh window. - if repo_dir.is_dir(): - rmtree(repo_dir) + _remove_repo_dir(repo_dir) raise + # Every git step succeeded; the key and hash dir name are recorded + # purely to make cache debugging easier. The marker is only a + # validity signal, so a failed write must not fail an otherwise + # complete clone: the only cost is a re-clone on the next run. + try: + write_file( + _clone_complete_marker_path(repo_dir), + f"key={key}\nhash={hash_dir_name}\n", + ) + except EsphomeError as err: + _LOGGER.warning( + "Could not write clone completion marker for %s: %s", key, err + ) + else: if refresh == NEVER_REFRESH or CORE.skip_external_update: _LOGGER.debug("Skipping update for %s (refresh disabled)", key) @@ -250,7 +302,13 @@ def clone_or_update( # On first clone, FETCH_HEAD does not exist if not file_timestamp.exists(): file_timestamp = Path(repo_dir / ".git" / "HEAD") - age_seconds = time.time() - file_timestamp.stat().st_mtime + try: + age_seconds = time.time() - file_timestamp.stat().st_mtime + except OSError: + # A .git with neither FETCH_HEAD nor HEAD is corrupt (e.g. a + # partially deleted clone). Force the update path so the + # broken-repository recovery below removes and re-clones it. + age_seconds = float("inf") if refresh is None or age_seconds > refresh.total_seconds: # Try to update the repository, recovering from broken state if needed old_sha: str | None = None @@ -303,7 +361,7 @@ def clone_or_update( err, ) _LOGGER.info("Removing broken repository at %s", repo_dir) - rmtree(repo_dir) + _remove_repo_dir(repo_dir) _LOGGER.info("Successfully removed broken repository, re-cloning...") # Recursively call clone_or_update to re-clone @@ -316,6 +374,7 @@ def clone_or_update( username=username, password=password, submodules=submodules, + subpath=subpath, _recover_broken=False, ) _LOGGER.info("Repository %s successfully recovered", key) diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index 62d2344069..c9e0339ad7 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -1,5 +1,6 @@ """Tests for git.py module.""" +from collections.abc import Callable import os from pathlib import Path import time @@ -9,7 +10,7 @@ from unittest.mock import Mock, patch import pytest from esphome import git -from esphome.core import CORE, TimePeriodSeconds +from esphome.core import CORE, EsphomeError, TimePeriodSeconds from esphome.git import GitCommandError @@ -19,6 +20,15 @@ def _compute_repo_dir(url: str, ref: str | None, domain: str) -> Path: return git._compute_destination_path(key, domain) +# The tests must probe the exact location the implementation uses +_marker_path = git._clone_complete_marker_path + + +def _mark_clone_complete(repo_dir: Path) -> None: + """Write the completion marker so a hand-made repo dir is treated as valid.""" + _marker_path(repo_dir).write_text("test") + + def _setup_old_repo(repo_dir: Path, days_old: int = 2) -> None: """Helper to set up a git repo directory structure with an old timestamp. @@ -30,6 +40,7 @@ def _setup_old_repo(repo_dir: Path, days_old: int = 2) -> None: repo_dir.mkdir(parents=True) git_dir = repo_dir / ".git" git_dir.mkdir() + _mark_clone_complete(repo_dir) # Create FETCH_HEAD file with old timestamp fetch_head = git_dir / "FETCH_HEAD" @@ -54,6 +65,25 @@ def _get_git_command_type(cmd: list[str]) -> str | None: return None +def _simulate_cloned_repo(repo_dir: Path) -> None: + """Create the directory structure a successful git clone would leave.""" + repo_dir.mkdir(parents=True, exist_ok=True) + (repo_dir / ".git").mkdir(exist_ok=True) + + +def _make_clone_side_effect(repo_dir: Path) -> Callable[..., str]: + """Return a run_git_command side effect whose clone creates the repo dir.""" + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + if _get_git_command_type(cmd) == "clone": + _simulate_cloned_repo(repo_dir) + return "" + + return git_command_side_effect + + def test_run_git_command_success(tmp_path: Path) -> None: """Test that run_git_command returns output on success.""" # Create a simple git repo to test with @@ -217,6 +247,7 @@ def test_clone_or_update_with_never_refresh( repo_dir.mkdir(parents=True) git_dir = repo_dir / ".git" git_dir.mkdir() + _mark_clone_complete(repo_dir) # Create FETCH_HEAD file with current timestamp fetch_head = git_dir / "FETCH_HEAD" @@ -250,6 +281,7 @@ def test_clone_or_update_skips_when_core_skip_external_update( repo_dir.mkdir(parents=True) git_dir = repo_dir / ".git" git_dir.mkdir() + _mark_clone_complete(repo_dir) (git_dir / "FETCH_HEAD").write_text("test") CORE.skip_external_update = True @@ -281,6 +313,7 @@ def test_clone_or_update_with_refresh_updates_old_repo( repo_dir.mkdir(parents=True) git_dir = repo_dir / ".git" git_dir.mkdir() + _mark_clone_complete(repo_dir) # Create FETCH_HEAD file with old timestamp (2 days ago) fetch_head = git_dir / "FETCH_HEAD" @@ -329,6 +362,7 @@ def test_clone_or_update_with_refresh_skips_fresh_repo( repo_dir.mkdir(parents=True) git_dir = repo_dir / ".git" git_dir.mkdir() + _mark_clone_complete(repo_dir) # Create FETCH_HEAD file with recent timestamp (1 hour ago) fetch_head = git_dir / "FETCH_HEAD" @@ -371,6 +405,8 @@ def test_clone_or_update_clones_missing_repo( # repo_dir should NOT exist assert not repo_dir.exists() + mock_run_git_command.side_effect = _make_clone_side_effect(repo_dir) + # Test with NEVER_REFRESH - should still clone since repo doesn't exist result_dir, revert = git.clone_or_update( url=url, @@ -405,6 +441,7 @@ def test_clone_or_update_with_none_refresh_always_updates( repo_dir.mkdir(parents=True) git_dir = repo_dir / ".git" git_dir.mkdir() + _mark_clone_complete(repo_dir) # Create FETCH_HEAD file with very recent timestamp (1 second ago) fetch_head = git_dir / "FETCH_HEAD" @@ -486,6 +523,9 @@ def test_clone_or_update_recovers_from_git_failures( # Default successful responses if cmd_type == "rev-parse": return "abc123" + if cmd_type == "clone": + # Simulate the recovery re-clone creating the repo directory + _simulate_cloned_repo(repo_dir) return "" mock_run_git_command.side_effect = git_command_side_effect @@ -813,6 +853,273 @@ def test_clone_or_update_stale_clone_is_retried_after_cleanup( assert call_count["fetch"] == 2 +def test_clone_or_update_recloned_when_marker_missing_with_never_refresh( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A repo dir without the completion marker is an interrupted clone. + + It must be removed and re-cloned even with NEVER_REFRESH, which would + otherwise trust the broken directory forever. + """ + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + ref = "1.8.4" + domain = "test" + repo_dir = _compute_repo_dir(url, ref, domain) + + # Simulate an interrupted clone: directory exists, no marker + repo_dir.mkdir(parents=True) + (repo_dir / ".git").mkdir() + + mock_run_git_command.side_effect = _make_clone_side_effect(repo_dir) + + result_dir, _ = git.clone_or_update( + url=url, ref=ref, refresh=git.NEVER_REFRESH, domain=domain + ) + + clone_calls = [c for c in mock_run_git_command.call_args_list if "clone" in c[0][0]] + assert len(clone_calls) == 1 + assert result_dir == repo_dir + # The fresh clone completed, so the marker must now be present + assert _marker_path(repo_dir).is_file() + + +def test_clone_or_update_recloned_when_marker_missing_with_skip_external_update( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """skip_external_update must not preserve an interrupted clone.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + + repo_dir.mkdir(parents=True) + (repo_dir / ".git").mkdir() + + mock_run_git_command.side_effect = _make_clone_side_effect(repo_dir) + + CORE.skip_external_update = True + result_dir, _ = git.clone_or_update( + url=url, ref=None, refresh=TimePeriodSeconds(days=1), domain=domain + ) + + clone_calls = [c for c in mock_run_git_command.call_args_list if "clone" in c[0][0]] + assert len(clone_calls) == 1 + assert result_dir == repo_dir + assert _marker_path(repo_dir).is_file() + + +def test_fresh_clone_writes_completion_marker_with_debug_info( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """The marker is written after a fresh clone and records key and hash dir.""" + 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) + + mock_run_git_command.side_effect = _make_clone_side_effect(repo_dir) + + git.clone_or_update(url=url, ref=ref, refresh=git.NEVER_REFRESH, domain=domain) + + marker = _marker_path(repo_dir) + assert marker.is_file() + content = marker.read_text() + assert f"{url}@{ref}" in content + assert repo_dir.name in content + + +def test_marker_is_deleted_before_rmtree( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """The marker must be gone even if rmtree fails partway. + + Simulated by an rmtree that does nothing: the directory survives but the + marker must already have been deleted, so the next run still re-clones + instead of trusting a partially deleted worktree. + """ + 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) + assert _marker_path(repo_dir).is_file() + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + if _get_git_command_type(cmd) == "stash": + raise GitCommandError("fatal: unable to write new index file") + return "abc123" + + mock_run_git_command.side_effect = git_command_side_effect + + with ( + patch("esphome.git.rmtree"), + pytest.raises(GitCommandError), + ): + git.clone_or_update( + url=url, ref=ref, refresh=TimePeriodSeconds(days=1), domain=domain + ) + + # rmtree never deleted anything, yet the marker is gone + assert repo_dir.is_dir() + assert not _marker_path(repo_dir).is_file() + + +def test_failed_marker_write_does_not_fail_the_clone( + tmp_path: Path, + mock_run_git_command: Mock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A marker write failure must not fail an otherwise complete clone. + + The clone is valid; the missing marker only costs a re-clone on the next + run, so the error is logged as a warning instead of propagating. + """ + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + + mock_run_git_command.side_effect = _make_clone_side_effect(repo_dir) + + with patch( + "esphome.git.write_file", side_effect=EsphomeError("Could not write file") + ): + result_dir, _ = git.clone_or_update( + url=url, ref=None, refresh=git.NEVER_REFRESH, domain=domain + ) + + assert result_dir == repo_dir + assert not _marker_path(repo_dir).is_file() + assert "Could not write clone completion marker" in caplog.text + + +def test_corrupt_git_dir_without_head_recovers( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A .git with neither FETCH_HEAD nor HEAD must recover, not crash. + + The age check stats FETCH_HEAD falling back to HEAD; if both are gone + (partially deleted clone) the stat raised an unhandled FileNotFoundError + before the broken-repository recovery could run. + """ + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + + # Marker present but .git gutted: no FETCH_HEAD, no HEAD + repo_dir.mkdir(parents=True) + (repo_dir / ".git").mkdir() + _mark_clone_complete(repo_dir) + + 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": + raise GitCommandError("ambiguous argument 'HEAD': unknown revision") + if cmd_type == "clone": + _simulate_cloned_repo(repo_dir) + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + result_dir, _ = git.clone_or_update( + url=url, ref=None, refresh=TimePeriodSeconds(days=1), domain=domain + ) + + assert result_dir == repo_dir + clone_calls = [c for c in mock_run_git_command.call_args_list if "clone" in c[0][0]] + assert len(clone_calls) == 1 + assert _marker_path(repo_dir).is_file() + + +def test_remove_repo_dir_tolerates_marker_unlink_failure(tmp_path: Path) -> None: + """A locked marker file must not abort the directory removal.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + (repo_dir / ".git").mkdir() + _mark_clone_complete(repo_dir) + + with patch.object(Path, "unlink", side_effect=PermissionError("locked")): + git._remove_repo_dir(repo_dir) + + # rmtree still removed the directory, marker included + assert not repo_dir.exists() + + +def test_clone_or_update_recovery_preserves_subpath( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Recovery must re-clone into the same subpath-ed directory. + + Without passing subpath through, the recursive recovery call would + recompute the destination without the subpath and clone (and write the + completion marker) at the wrong location. + """ + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + ref = "main" + domain = "test" + subpath = Path("mylib") + repo_dir = _compute_repo_dir(url, ref, domain) / subpath + + _setup_old_repo(repo_dir) + + call_counts: dict[str, int] = {} + + 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: + call_counts[cmd_type] = call_counts.get(cmd_type, 0) + 1 + # First rev-parse fails (broken repo) to trigger recovery + if cmd_type == "rev-parse" and call_counts[cmd_type] == 1: + raise GitCommandError( + "ambiguous argument 'HEAD': unknown revision or path not in the working tree." + ) + if cmd_type == "clone": + # Create whatever directory the clone was asked to target + target = Path(cmd[-1]) + target.mkdir(parents=True, exist_ok=True) + (target / ".git").mkdir(exist_ok=True) + if cmd_type == "rev-parse": + return "abc123" + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + result_dir, _ = git.clone_or_update( + url=url, + ref=ref, + refresh=TimePeriodSeconds(days=1), + domain=domain, + subpath=subpath, + ) + + # The recovery re-clone must target the subpath-ed directory and the + # completion marker must land there too + clone_calls = [c for c in mock_run_git_command.call_args_list if "clone" in c[0][0]] + assert len(clone_calls) == 1 + assert clone_calls[0][0][0][-1] == str(repo_dir) + assert result_dir == repo_dir + assert _marker_path(repo_dir).is_file() + + def test_clone_with_ref_uses_shallow_fetch( tmp_path: Path, mock_run_git_command: Mock ) -> None: From 5fa93513f2b547a580fc3b19b21684fc5d26aee6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Jul 2026 19:05:50 -1000 Subject: [PATCH 0970/1815] [espidf] Make openocd-esp32 optional so its libusb check cannot break installs (#17686) --- esphome/espidf/framework.py | 130 ++++++++++++++++------ tests/unit_tests/test_espidf_framework.py | 49 ++++++++ 2 files changed, 147 insertions(+), 32 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index a9b3fd9644..66a34ea03f 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -1,5 +1,6 @@ """ESP-IDF framework tools for ESPHome.""" +from collections.abc import Callable from ctypes.util import find_library import json import logging @@ -467,17 +468,21 @@ _NINJA_ARM64_BACKPORT: dict[str, dict[str, str | int]] = { } -def _patch_tools_json_for_linux_arm64(framework_path: Path) -> None: - """Inject ninja linux-arm64 entries into the framework's tools.json on aarch64. +def _patch_tools_json( + framework_path: Path, + apply_patch: Callable[[dict], bool], + patched_log: str, +) -> None: + """Apply an in-place fixup to the framework's tools/tools.json. - Idempotent: a tools.json that already has the entry, or a host that - isn't aarch64, is a no-op. Applied unconditionally on every install - check so a build dir extracted before the backport got fixed up - without forcing a clean. + Shared plumbing for the tools.json patches below: a missing file is a + no-op, an unparseable file logs a warning and skips, and when + ``apply_patch`` reports a change the file is written back atomically. + ``patched_log`` is the info log line, with a single ``%s`` placeholder + for the tools.json path. Patches are idempotent and applied on every + install check, so an already-extracted framework picks them up on the + next build without forcing a clean. """ - if platform.machine() != "aarch64": - return - tools_json = framework_path / "tools" / "tools.json" if not tools_json.is_file(): return @@ -485,37 +490,93 @@ def _patch_tools_json_for_linux_arm64(framework_path: Path) -> None: try: with tools_json.open(encoding="utf-8") as f: data = json.load(f) - except (json.JSONDecodeError, OSError) as e: + # apply_patch also raises inside the guard: a tools.json that is + # valid JSON but not the expected shape (e.g. a top-level list) + # must skip the patch, not crash the install check this patch is + # meant to recover. + changed = apply_patch(data) + except (json.JSONDecodeError, OSError, AttributeError, TypeError, KeyError) as e: _LOGGER.warning( - "Could not parse %s for linux-arm64 backport (%s); " - "skipping. A clean reinstall of the framework directory " - "may be needed.", + "Could not apply tools.json patch to %s (%s); skipping. A clean " + "reinstall of the framework directory may be needed.", tools_json, e, ) return - changed = False - for tool in data.get("tools", []): - if tool.get("name") != "ninja": - continue - for ver in tool.get("versions", []): - entry = _NINJA_ARM64_BACKPORT.get(ver.get("name")) - if entry is None or ver.get("linux-arm64"): - continue - ver["linux-arm64"] = entry - changed = True - if changed: # write_file_if_changed stages a tempfile in the destination dir # and atomically replaces — safe against mid-write interruption # and concurrent invocations. write_file_if_changed(tools_json, json.dumps(data, indent=2) + "\n") - _LOGGER.info( - "Patched %s to add ninja linux-arm64 download " - "(espressif/esp-idf#18272 backport).", - tools_json, - ) + _LOGGER.info(patched_log, tools_json) + + +def _patch_tools_json_for_linux_arm64(framework_path: Path) -> None: + """Inject ninja linux-arm64 entries into the framework's tools.json on aarch64. + + A tools.json that already has the entry, or a host that isn't aarch64, + is a no-op. + """ + if platform.machine() != "aarch64": + return + + def apply_patch(data: dict) -> bool: + changed = False + for tool in data.get("tools", []): + if tool.get("name") != "ninja": + continue + for ver in tool.get("versions", []): + entry = _NINJA_ARM64_BACKPORT.get(ver.get("name")) + if entry is None or ver.get("linux-arm64"): + continue + ver["linux-arm64"] = entry + changed = True + return changed + + _patch_tools_json( + framework_path, + apply_patch, + "Patched %s to add ninja linux-arm64 download " + "(espressif/esp-idf#18272 backport).", + ) + + +def _patch_tools_json_demote_openocd(framework_path: Path) -> None: + """Demote openocd-esp32 from ``install: always`` to ``install: on_request``. + + ``idf_tools.py install required`` installs every tool marked ``always`` in + tools.json and validates each one after extraction by running its version + command. openocd links against libusb-1.0, which minimal systems (bare LXC + containers, slim images) often lack, so that one validation aborted the + whole framework install and left it permanently retrying (#17685) — even + though ESPHome never runs openocd (it is a JTAG debugging tool). Demoting + it drops it from the ``required`` set: it is no longer downloaded or + validated, and the tool-path export treats a missing ``on_request`` tool + as fine. A user who wants it can still name ``openocd-esp32`` explicitly + in ESPHOME_IDF_DEFAULT_TOOLS; explicit names bypass install-type + filtering. + + Because this runs on every install check, an install stuck in the + failing state (which never wrote its stamp file) heals on the next + build without a clean. + """ + + def apply_patch(data: dict) -> bool: + changed = False + for tool in data.get("tools", []): + if tool.get("name") == "openocd-esp32" and tool.get("install") == "always": + tool["install"] = "on_request" + changed = True + return changed + + _patch_tools_json( + framework_path, + apply_patch, + "Patched %s to make openocd-esp32 optional (not needed for " + "building, and its install check fails on systems without " + "libusb-1.0).", + ) def _check_esphome_idf_framework_install( @@ -636,6 +697,11 @@ def _check_esphome_idf_framework_install( # a pre-patch tools.json get fixed up without forcing a clean. _patch_tools_json_for_linux_arm64(framework_path) + # Drop openocd-esp32 from the required tool set on every invocation so + # an install that previously failed on its libusb check recovers on the + # next build. + _patch_tools_json_demote_openocd(framework_path) + # 3. Check if the framework tools are the same and correctly installed if not install: install = True @@ -671,9 +737,9 @@ def _check_esphome_idf_framework_install( ): if platform.system() == "Linux" and find_library("usb-1.0") is None: _LOGGER.error( - "libusb-1.0.so.0 was not found on this system and the ESP-IDF " - "tools need it (openocd fails its install check without it). " - "Install the libusb 1.0 package, e.g. libusb-1.0-0 " + "libusb-1.0.so.0 was not found on this system. If the error " + "above mentions it (openocd fails its install check without " + "it), install the libusb 1.0 package, e.g. libusb-1.0-0 " "(Debian/Ubuntu), libusb1 (Fedora) or libusb (Alpine/Arch), " "then run the build again." ) diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index f18a219878..6127948d8c 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -25,6 +25,7 @@ from esphome.espidf.framework import ( _get_python_env_path, _get_python_version, _parse_git_source, + _patch_tools_json_demote_openocd, _patch_tools_json_for_linux_arm64, _windows_long_paths_enabled, _write_idf_version_txt, @@ -331,6 +332,7 @@ def espidf_mocks(setup_core: Path): patch("esphome.espidf.framework._clone_idf_with_submodules") as clone, patch("esphome.espidf.framework._write_idf_version_txt"), patch("esphome.espidf.framework._patch_tools_json_for_linux_arm64"), + patch("esphome.espidf.framework._patch_tools_json_demote_openocd"), patch("esphome.espidf.framework._write_stamp"), patch("esphome.espidf.framework._check_stamp", return_value=True), patch("esphome.espidf.framework._get_idf_version", return_value=_IDF_VERSION), @@ -634,6 +636,53 @@ def test_patch_tools_json_already_patched_is_noop(tmp_path: Path) -> None: assert tools_json.read_text(encoding="utf-8") == before +# --------------------------------------------------------------------------- +# _patch_tools_json_demote_openocd (openocd-esp32 made optional) +# --------------------------------------------------------------------------- + + +def test_demote_openocd_patches_install_type(tmp_path: Path) -> None: + tools_json = _write_tools_json( + tmp_path, + { + "tools": [ + {"name": "openocd-esp32", "install": "always"}, + {"name": "cmake", "install": "always"}, + ] + }, + ) + _patch_tools_json_demote_openocd(tmp_path) + + data = json.loads(tools_json.read_text(encoding="utf-8")) + openocd = next(t for t in data["tools"] if t["name"] == "openocd-esp32") + cmake = next(t for t in data["tools"] if t["name"] == "cmake") + assert openocd["install"] == "on_request" + # other tools are left untouched + assert cmake["install"] == "always" + + +def test_patch_tools_json_unexpected_structure_warns_and_skips( + tmp_path: Path, +) -> None: + """Valid JSON with an unexpected shape must skip the patch, not raise.""" + tools_dir = tmp_path / "tools" + tools_dir.mkdir() + tools_json = tools_dir / "tools.json" + tools_json.write_text('["not", "a", "dict"]', encoding="utf-8") + before = tools_json.read_text(encoding="utf-8") + _patch_tools_json_demote_openocd(tmp_path) # AttributeError -> skip + assert tools_json.read_text(encoding="utf-8") == before + + +def test_demote_openocd_already_patched_is_noop(tmp_path: Path) -> None: + tools_json = _write_tools_json( + tmp_path, {"tools": [{"name": "openocd-esp32", "install": "on_request"}]} + ) + before = tools_json.read_text(encoding="utf-8") + _patch_tools_json_demote_openocd(tmp_path) + assert tools_json.read_text(encoding="utf-8") == before + + # --------------------------------------------------------------------------- # Subprocess-backed helpers (_exec -> run_command rename) and get_framework_env # --------------------------------------------------------------------------- From 2a96fa44fff2d2882191b0f5ec842327f0ed1162 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:09:02 +1000 Subject: [PATCH 0971/1815] [light] Fix pulse and other effects (#17645) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/light/light_call.cpp | 13 +-- esphome/components/light/light_state.cpp | 8 ++ .../light_effect_zero_brightness.yaml | 35 +++++++ .../fixtures/light_initial_state.yaml | 18 ++++ tests/integration/test_light_calls.py | 10 +- .../test_light_effect_zero_brightness.py | 91 +++++++++++++++++++ tests/integration/test_light_initial_state.py | 15 +++ 7 files changed, 177 insertions(+), 13 deletions(-) create mode 100644 tests/integration/fixtures/light_effect_zero_brightness.yaml create mode 100644 tests/integration/test_light_effect_zero_brightness.py diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 2b13b40a16..67fd175ce6 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -219,14 +219,11 @@ LightColorValues LightCall::validate_() { this->set_flag_(FLAG_HAS_STATE); } - // Make sure a turn-on makes the light visible: if the resulting brightness would be zero - // (e.g. restored from a brightness=0 turn-off), reset it to full brightness. - if (this->has_state() && this->state_ && (color_mode & ColorCapability::BRIGHTNESS)) { - float brightness = this->has_brightness() ? this->brightness_ : this->parent_->remote_values.get_brightness(); - if (brightness == 0.0f) { - this->brightness_ = 1.0f; - this->set_flag_(FLAG_HAS_BRIGHTNESS); - } + // Make sure a simple (no specific brightness) turn-on makes the light visible + if (this->has_state() && this->state_ && (color_mode & ColorCapability::BRIGHTNESS) && !this->has_brightness() && + this->parent_->remote_values.get_brightness() == 0.0f) { + this->brightness_ = 1.0f; + this->set_flag_(FLAG_HAS_BRIGHTNESS); } // Set color brightness to 100% if currently zero and a color is set. diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index bd778926d5..9d0181a05c 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -71,6 +71,14 @@ void LightState::setup() { break; } + // A light coming up on boot must never end up on-but-invisible: if the resolved restore + // state is on but its brightness is zero (e.g. a stale/persisted value from before a + // forced-on restore mode, or an inverted restore flipping a dim-to-0 off state to on), + // reset it to full brightness. + if (recovered.state && recovered.brightness == 0.0f) { + recovered.brightness = 1.0f; + } + call.set_color_mode_if_supported(recovered.color_mode); call.set_state(recovered.state); call.set_brightness_if_supported(recovered.brightness); diff --git a/tests/integration/fixtures/light_effect_zero_brightness.yaml b/tests/integration/fixtures/light_effect_zero_brightness.yaml new file mode 100644 index 0000000000..b98bed84db --- /dev/null +++ b/tests/integration/fixtures/light_effect_zero_brightness.yaml @@ -0,0 +1,35 @@ +esphome: + name: light-effect-zero-bright +host: +api: # Port will be automatically injected +logger: + level: DEBUG + +output: + - platform: template + id: pulse_output + type: float + write_action: + - logger.log: + format: "PULSE_OUTPUT:%.4f" + args: [state] + +light: + - platform: monochromatic + name: "Test Pulse Light" + id: test_pulse_light + output: pulse_output + effects: + - pulse: + name: "Fast Pulse" + transition_length: 20ms + update_interval: 50ms + min_brightness: 0% + max_brightness: 100% + - strobe: + name: "Fast Strobe" + colors: + - state: true + duration: 50ms + - state: false + duration: 50ms diff --git a/tests/integration/fixtures/light_initial_state.yaml b/tests/integration/fixtures/light_initial_state.yaml index 2654c76aa0..052de0a4e5 100644 --- a/tests/integration/fixtures/light_initial_state.yaml +++ b/tests/integration/fixtures/light_initial_state.yaml @@ -21,6 +21,11 @@ output: type: float write_action: - lambda: "" + - platform: template + id: test_restore_and_on_output + type: float + write_action: + - lambda: "" light: - platform: rgb @@ -37,3 +42,16 @@ light: red: 1.0 green: 0.5 blue: 0.0 + + - platform: monochromatic + name: "Test Restore And On Light" + id: test_restore_and_on_light + output: test_restore_and_on_output + restore_mode: RESTORE_AND_ON + # Simulates a stale/persisted zero brightness: RESTORE_AND_ON always forces the light + # on at boot regardless of the recovered state, so a leftover brightness of 0 must not + # leave the light on-but-invisible. + initial_state: + color_mode: BRIGHTNESS + state: false + brightness: 0% diff --git a/tests/integration/test_light_calls.py b/tests/integration/test_light_calls.py index a3a4103f5c..b75e2fac62 100644 --- a/tests/integration/test_light_calls.py +++ b/tests/integration/test_light_calls.py @@ -341,14 +341,14 @@ async def test_light_calls( assert state.state is True assert state.brightness == pytest.approx(1.0) - # Test 31b: An explicit turn-on with brightness 0 still resets to full - # brightness - a turn-on must never leave the light on-but-invisible. This - # is the same path the restore logic exercises (set_state(true) + - # set_brightness(0) from a persisted brightness=0 turn-off). + # Test 31b: An explicit turn-on with brightness 0 respects the explicit value and + # stays dark. Only a turn-on with no brightness specified (Test 31) restores + # visibility -- an explicit brightness request (e.g. from a light effect's dark + # phase) is never overridden. client.light_command(key=rgbcw_light.key, state=True, brightness=0.0) state = await wait_for_state_change(rgbcw_light.key) assert state.state is True - assert state.brightness == pytest.approx(1.0) + assert state.brightness == pytest.approx(0.0) # Test 32: Turning a light on when it already has nonzero brightness leaves # the brightness unchanged (the reset only happens when brightness is 0). diff --git a/tests/integration/test_light_effect_zero_brightness.py b/tests/integration/test_light_effect_zero_brightness.py new file mode 100644 index 0000000000..6c386d4229 --- /dev/null +++ b/tests/integration/test_light_effect_zero_brightness.py @@ -0,0 +1,91 @@ +"""Integration test verifying light effects can dim to 0% brightness while staying on. + +Regression test for https://github.com/esphome/esphome/issues/17639, where PR #17103's +"make turn-on visible" logic in LightCall::validate_() also clobbered brightness set by a +running effect (e.g. pulse, strobe), forcing it back to 100% and breaking the dark phase +of those effects. + +Effect ticks are published with `publish: false` (so Home Assistant isn't spammed with +every frame), so the effect's actual output can't be observed via API state broadcasts. +Instead, this test reads the output component's log lines, which are written on every +update regardless of the publish flag. +""" + +from __future__ import annotations + +import asyncio +import re +from typing import Any + +from aioesphomeapi import EntityState, LightState +import pytest + +from .state_utils import InitialStateHelper +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_light_effect_zero_brightness( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Pulse and strobe effects must be able to reach 0% brightness while the light stays on.""" + output_pattern = re.compile(r"PULSE_OUTPUT:([\d.]+)") + observed: list[float] = [] + + def on_log_line(line: str) -> None: + match = output_pattern.search(line) + if match: + observed.append(float(match.group(1))) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + light = next(e for e in entities if e.object_id == "test_pulse_light") + + state_futures: dict[int, asyncio.Future[LightState]] = {} + + def on_state(state: EntityState) -> None: + if isinstance(state, LightState) and state.key in state_futures: + future = state_futures[state.key] + if not future.done(): + future.set_result(state) + + # ESPHome sends the current state of every entity right after connecting; drain + # that initial burst so it can't be mistaken for the response to a command below. + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + async def send_and_wait(timeout: float = 5.0, **kwargs: Any) -> LightState: + """Send a light command and wait for the matching state response.""" + state_futures[light.key] = asyncio.get_running_loop().create_future() + client.light_command(key=light.key, **kwargs) + return await asyncio.wait_for(state_futures[light.key], timeout=timeout) + + # Turn the light on first so the effect starts from a known, visible state. + state = await send_and_wait(state=True, brightness=1.0) + assert state.state is True + assert state.brightness == pytest.approx(1.0) + + for effect_name in ("Fast Pulse", "Fast Strobe"): + observed.clear() + state = await send_and_wait(effect=effect_name) + assert state.effect == effect_name + # Let several effect cycles run (update_interval/duration is 50ms in the fixture). + await asyncio.sleep(1.0) + + assert observed, f"No output observed while running effect {effect_name!r}" + assert min(observed) == pytest.approx(0.0, abs=0.01), ( + f"Effect {effect_name!r} never dimmed to 0% brightness while the light " + f"stayed on -- got min={min(observed):.4f} (values: {observed})" + ) + assert max(observed) > 0.5, ( + f"Effect {effect_name!r} never reached full brightness -- " + f"got max={max(observed):.4f}" + ) + + client.light_command(key=light.key, effect="None") diff --git a/tests/integration/test_light_initial_state.py b/tests/integration/test_light_initial_state.py index f1cd96dbf0..657e273fe7 100644 --- a/tests/integration/test_light_initial_state.py +++ b/tests/integration/test_light_initial_state.py @@ -11,6 +11,14 @@ from .state_utils import InitialStateHelper, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction +@pytest.fixture(autouse=True) +def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + """Keep host preferences per-test so RESTORE_AND_ON never loads a stale value left + behind by a previous run (host preferences otherwise persist to ~/.esphome/prefs, + keyed only by device name).""" + monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs")) + + @pytest.mark.asyncio async def test_light_initial_state( yaml_config: str, @@ -36,3 +44,10 @@ async def test_light_initial_state( assert state.red == pytest.approx(1.0, abs=0.01) assert state.green == pytest.approx(0.5, abs=0.01) assert state.blue == pytest.approx(0.0, abs=0.01) + + # Regression test: RESTORE_AND_ON always forces the light on at boot, even when + # the recovered/initial brightness was 0 -- it must never come up on-but-invisible. + restore_and_on_light = require_entity(entities, "test_restore_and_on_light") + restore_and_on_state = helper.initial_states[restore_and_on_light.key] + assert restore_and_on_state.state is True + assert restore_and_on_state.brightness == pytest.approx(1.0) From 26fcc068b67a37846da4a59f1730a4fddb605e61 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:32:22 -1000 Subject: [PATCH 0972/1815] Bump bundled esphome-device-builder to 1.6.8 (#17708) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 331585f123..1a34fda520 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.6.7 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.8 RUN \ platformio settings set enable_telemetry No \ From bb1318dce1a2555ab3713313880f4954d998bd37 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Jul 2026 19:32:38 -1000 Subject: [PATCH 0973/1815] [core] Auto-clean the PlatformIO build environment when the Python version changes (#17671) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/platformio/toolchain.py | 187 ++++++++- esphome/writer.py | 15 +- requirements.txt | 1 + tests/unit_tests/test_platformio_toolchain.py | 360 ++++++++++++++++++ 4 files changed, 550 insertions(+), 13 deletions(-) diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index c97df812e3..105d4a8283 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -1,17 +1,35 @@ +from collections.abc import Iterable import json import logging import os from pathlib import Path import re import sys +from typing import TYPE_CHECKING from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE from esphome.core import CORE, EsphomeError -from esphome.helpers import add_git_ceiling_directory +from esphome.helpers import add_git_ceiling_directory, rmtree, write_file from esphome.util import FlashImage, run_external_process +if TYPE_CHECKING: + from platformio.project.config import ProjectConfig + _LOGGER = logging.getLogger(__name__) +# PlatformIO cache subdirs resolved via ProjectConfig. A full ``clean-all`` wipes +# these plus the whole ``core_dir``; a Python-version heal wipes these plus the +# penv while keeping ``core_dir`` (so the sibling stamp/lock survive). +_PIO_CACHE_DIRS = ("cache_dir", "packages_dir", "platforms_dir") + +# Marker recording the Python major.minor the PlatformIO cache was provisioned +# under, plus the lock guarding the check/wipe. Both live in the dir resolved +# by ``_pio_stamp_dir`` (NOT wiped by the heal), so they survive the wipe and +# are rewritten after it. +_PIO_PYTHON_STAMP_FILE = ".esphome.pio.stamp.json" +_PIO_PYTHON_STAMP_LOCK = ".esphome.pio.stamp.lock" +_PIO_PYTHON_STAMP_SCHEMA = "0" + def _strip_win_long_path_prefix(path: str) -> str: r"""Strip the Windows extended-length path prefix from ``path``. @@ -44,7 +62,174 @@ def _strip_win_long_path_prefix(path: str) -> str: return path +def get_platformio_config() -> "ProjectConfig | None": + """Return PlatformIO's ``ProjectConfig``, or None when PlatformIO is absent.""" + try: + from platformio.project.config import ProjectConfig + except ImportError: + return None + return ProjectConfig.get_instance() + + +def _pio_stamp_dir(config: "ProjectConfig") -> Path: + """Return the persistent home for the python-version stamp and lock. + + The parent of ``platforms_dir``, not ``core_dir``: the container/add-on + images relocate the platform/package caches to a persistent volume while + ``core_dir`` stays at the ephemeral default (its ``appstate.json`` must not + move), so a stamp under ``core_dir`` would be wiped on every image update + while the stale cache it guards survives. Everywhere else ``platforms_dir`` + sits inside ``core_dir`` and this resolves to ``core_dir``. + """ + return Path(config.get("platformio", "platforms_dir")).parent + + +def _delete_platformio_dirs(config: "ProjectConfig", pio_dirs: Iterable[str]) -> None: + """Delete each named PlatformIO dir resolved from *config*.""" + for pio_dir in pio_dirs: + path = Path(config.get("platformio", pio_dir)) + if path.is_dir(): + _LOGGER.info("Deleting PlatformIO %s %s", pio_dir, path) + rmtree(path) + + +def clean_platformio_cache() -> None: + """Wipe the whole PlatformIO cache (cache/packages/platforms/core). + + The full set ``clean-all`` (Reset Build Environment) clears. No-op when + PlatformIO is unavailable. + """ + config = get_platformio_config() + if config is None: + return + _delete_platformio_dirs(config, [*_PIO_CACHE_DIRS, "core_dir"]) + + +def _clean_platformio_python_env(config: "ProjectConfig", core_dir: Path) -> None: + """Wipe the cache subdirs + penv for a Python-version change. + + Keeps ``core_dir`` itself (and the stamp/lock siblings under it); otherwise + the same cache set ``clean-all`` clears. + """ + _delete_platformio_dirs(config, _PIO_CACHE_DIRS) + penv = core_dir / "penv" + if penv.is_dir(): + _LOGGER.info("Deleting PlatformIO penv %s", penv) + rmtree(penv) + + +def _current_python_minor() -> str: + """Return the running interpreter's ``major.minor`` (e.g. ``3.13``).""" + return f"{sys.version_info.major}.{sys.version_info.minor}" + + +def _read_pio_stamp_python(stamp_file: Path) -> str | None: + """Return the ``python_version`` recorded in *stamp_file*, or None.""" + try: + with stamp_file.open(encoding="utf-8") as f: + data = json.load(f) + except FileNotFoundError: + return None + except (json.JSONDecodeError, OSError) as err: + # A present-but-unreadable stamp is a distinct signal from an absent + # one, and it drives a cache clean; surface why at normal verbosity. + _LOGGER.warning("Could not read %s: %s", stamp_file, err) + return None + if not isinstance(data, dict): + return None + version = data.get("python_version") + return version if isinstance(version, str) else None + + +def _write_pio_stamp_python(stamp_file: Path, python_version: str) -> None: + """Atomically write the PlatformIO python-version stamp.""" + write_file( + stamp_file, + json.dumps( + { + "schema_version": _PIO_PYTHON_STAMP_SCHEMA, + "python_version": python_version, + } + ), + ) + + +def heal_platformio_python_env() -> None: + """Wipe the PlatformIO cache unless it is stamped for the running Python. + + A PlatformIO platform/tool package pins the Python versions it accepts when + it is provisioned, and ESPHome pins platforms to exact, immutable versions, + so a later interpreter bump (a container upgrading its base Python) leaves + the cached platform rejecting the new interpreter ("Python version must be + between ...") until the cache is wiped. A stamp records the ``major.minor`` + the cache was provisioned for; when it doesn't match the running + interpreter (or has never been written for an existing cache), the same + PlatformIO dirs ``clean-all`` wipes are cleaned so PlatformIO + re-provisions, matching Reset Build Environment automatically. The native + ESP-IDF toolchain already self-heals through its own stamp; this covers the + PlatformIO path. No-op when PlatformIO is unavailable. + """ + config = get_platformio_config() + if config is None: + return + try: + _check_platformio_python_stamp(config) + except (EsphomeError, OSError) as err: + # The check is a best-effort repair; a full or read-only cache volume + # must not abort a build that might otherwise work. The stamp write + # surfaces as EsphomeError (write_file wraps OSError). + _LOGGER.warning("PlatformIO build environment check failed: %s", err) + + +def _check_platformio_python_stamp(config: "ProjectConfig") -> None: + """Compare the stamp to the running interpreter; wipe and restamp on mismatch.""" + current = _current_python_minor() + stamp_dir = _pio_stamp_dir(config) + # Host the stamp/lock even before PlatformIO's first run creates the dir. + stamp_dir.mkdir(parents=True, exist_ok=True) + stamp_file = stamp_dir / _PIO_PYTHON_STAMP_FILE + + from filelock import FileLock + + with FileLock(str(stamp_dir / _PIO_PYTHON_STAMP_LOCK)): + provisioned = _read_pio_stamp_python(stamp_file) + if provisioned == current: + return + core_dir = Path(config.get("platformio", "core_dir")) + has_cache = ( + any( + Path(config.get("platformio", pio_dir)).is_dir() + for pio_dir in _PIO_CACHE_DIRS + ) + or (core_dir / "penv").is_dir() + ) + if has_cache: + if provisioned is None: + # An existing cache with no stamp predates the stamp: its + # provisioning interpreter is unknown, so clean once rather + # than leave a possibly-stale cache failing every build. + _LOGGER.info( + "Cleaning the PlatformIO build environment once so it " + "re-provisions for Python %s", + current, + ) + else: + _LOGGER.info( + "Python version changed (%s -> %s); cleaning PlatformIO " + "build environment so it re-provisions for the new " + "interpreter", + provisioned, + current, + ) + _clean_platformio_python_env(config, core_dir) + _write_pio_stamp_python(stamp_file, current) + + def run_platformio_cli(*args, **kwargs) -> str | int: + # Re-provision the PlatformIO cache if the interpreter's major.minor changed + # since it was last built; a stale platform otherwise rejects the new Python + # with "Python version must be between ..." until Reset Build Environment. + heal_platformio_python_env() os.environ["PLATFORMIO_FORCE_COLOR"] = "true" os.environ["PLATFORMIO_BUILD_DIR"] = str(CORE.relative_pioenvs_path().absolute()) os.environ.setdefault( diff --git a/esphome/writer.py b/esphome/writer.py index b7eeec916d..866377d2f5 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -670,18 +670,9 @@ def clean_all(configuration: list[str]): rmtree(install_path) # Clean PlatformIO project files - try: - from platformio.project.config import ProjectConfig - except ImportError: - # PlatformIO is not available, skip cleaning - pass - else: - config = ProjectConfig.get_instance() - for pio_dir in ["cache_dir", "packages_dir", "platforms_dir", "core_dir"]: - path = Path(config.get("platformio", pio_dir)) - if path.is_dir(): - _LOGGER.info("Deleting PlatformIO %s %s", pio_dir, path) - rmtree(path) + from esphome.platformio.toolchain import clean_platformio_cache + + clean_platformio_cache() GITIGNORE_CONTENT = """# Gitignore settings for ESPHome diff --git a/requirements.txt b/requirements.txt index fbfc034268..9dfff1452c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,6 +27,7 @@ smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 platformdirs==4.10.0 # native esp-idf toolchain global cache dir +filelock==3.29.0 # lock guarding the PlatformIO python-version cache heal # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index 568b43a259..013030d38f 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -2,12 +2,14 @@ # pylint: disable=protected-access +from collections.abc import Generator from contextlib import contextmanager from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json import os from pathlib import Path import shutil +import sys import threading from types import SimpleNamespace from unittest.mock import MagicMock, Mock, call, patch @@ -1093,3 +1095,361 @@ def test_filter_platformio_lines_blocks_noisy_messages(msg: str) -> None: def test_filter_platformio_lines_allows_other_messages(msg: str) -> None: """Test that non-noisy platformio output lines pass through RedirectText.""" assert _filter_through_redirect(msg) == msg + "\n" + + +# --------------------------------------------------------------------------- +# PlatformIO python-version cache heal +# --------------------------------------------------------------------------- + +_CURRENT_MINOR = f"{sys.version_info.major}.{sys.version_info.minor}" +# Captured before the autouse guard patches the name, so tests can exercise the +# real implementation. +_REAL_GET_PLATFORMIO_CONFIG = toolchain.get_platformio_config + + +@pytest.fixture(autouse=True) +def _guard_real_platformio() -> Generator[None, None, None]: + """Default the PlatformIO config lookup to None so no test in this module + touches a real ~/.platformio; the heal tests re-patch it at a temp dir.""" + with patch.object(toolchain, "get_platformio_config", return_value=None): + yield + + +def _pio_layout(core_dir: Path) -> dict[str, Path]: + """Return the PlatformIO dir layout with cache/packages/platforms under core.""" + return { + "core_dir": core_dir, + "packages_dir": core_dir / "packages", + "platforms_dir": core_dir / "platforms", + "cache_dir": core_dir / ".cache", + } + + +def _split_pio_layout(tmp_path: Path) -> dict[str, Path]: + """Container-shape layout: caches on a persistent root, core_dir ephemeral.""" + persistent = tmp_path / "data" / "platformio" + return { + "core_dir": tmp_path / "root" / ".platformio", + "platforms_dir": persistent / "platforms", + "packages_dir": persistent / "packages", + "cache_dir": persistent / "cache", + } + + +def _seed_layout(layout: dict[str, Path]) -> None: + """Populate each cache dir (and the core penv) with a marker file.""" + for key in ("platforms_dir", "packages_dir", "cache_dir"): + layout[key].mkdir(parents=True, exist_ok=True) + (layout[key] / "marker").write_text("x", encoding="utf-8") + penv = layout["core_dir"] / "penv" + penv.mkdir(parents=True, exist_ok=True) + (penv / "marker").write_text("x", encoding="utf-8") + + +def _make_pio_config(layout: dict[str, Path] | Path) -> MagicMock: + """A ProjectConfig stand-in resolving platformio dir options from *layout*.""" + resolved = _pio_layout(layout) if isinstance(layout, Path) else layout + config = MagicMock() + config.get.side_effect = lambda section, option: ( + str(resolved[option]) if section == "platformio" else "" + ) + return config + + +@contextmanager +def _use_pio_config(layout: dict[str, Path] | Path) -> Generator[MagicMock, None, None]: + """Point ``get_platformio_config`` at a temp layout for the block.""" + config = _make_pio_config(layout) + with patch.object(toolchain, "get_platformio_config", return_value=config): + yield config + + +def _stamp_version(core_dir: Path) -> str | None: + """Read the python version recorded in the heal stamp under *core_dir*.""" + return toolchain._read_pio_stamp_python(core_dir / toolchain._PIO_PYTHON_STAMP_FILE) + + +def _cache_wiped(core_dir: Path) -> bool: + """True when the seeded cache subdir markers are gone.""" + return not any( + (core_dir / sub / "marker").exists() + for sub in ("packages", "platforms", ".cache") + ) + + +@pytest.fixture +def pio_core_dir(tmp_path: Path) -> Path: + """A populated PlatformIO core dir (packages/platforms/.cache/penv seeded).""" + core = tmp_path / "dot-platformio" + for sub in ("packages", "platforms", ".cache", "penv"): + seeded = core / sub + seeded.mkdir(parents=True) + (seeded / "marker").write_text("x", encoding="utf-8") + return core + + +def test_current_python_minor_matches_running_interpreter() -> None: + """_current_python_minor returns major.minor of the running interpreter.""" + assert toolchain._current_python_minor() == _CURRENT_MINOR + + +def test_pio_stamp_round_trip(tmp_path: Path) -> None: + """The stamp writer/reader round-trips and records the schema version.""" + stamp = tmp_path / toolchain._PIO_PYTHON_STAMP_FILE + toolchain._write_pio_stamp_python(stamp, "3.13") + assert toolchain._read_pio_stamp_python(stamp) == "3.13" + assert json.loads(stamp.read_text()) == { + "schema_version": toolchain._PIO_PYTHON_STAMP_SCHEMA, + "python_version": "3.13", + } + + +def test_read_pio_stamp_missing(tmp_path: Path) -> None: + """A missing stamp file yields None.""" + assert toolchain._read_pio_stamp_python(tmp_path / "nope.json") is None + + +def test_read_pio_stamp_malformed(tmp_path: Path) -> None: + """A corrupt stamp file yields None instead of raising.""" + stamp = tmp_path / "bad.json" + stamp.write_text("{not json", encoding="utf-8") + assert toolchain._read_pio_stamp_python(stamp) is None + + +def test_read_pio_stamp_unreadable_logs_warning( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A present-but-unreadable stamp yields None and warns.""" + stamp = tmp_path / "stamp.json" + stamp.mkdir() + with caplog.at_level("WARNING"): + assert toolchain._read_pio_stamp_python(stamp) is None + assert "Could not read" in caplog.text + + +def test_read_pio_stamp_without_python_version(tmp_path: Path) -> None: + """A stamp missing python_version yields None.""" + stamp = tmp_path / "s.json" + stamp.write_text(json.dumps({"schema_version": "0"}), encoding="utf-8") + assert toolchain._read_pio_stamp_python(stamp) is None + + +@pytest.mark.parametrize("payload", ["42", '"x"', "[1, 2]", "null"]) +def test_read_pio_stamp_non_object_json(tmp_path: Path, payload: str) -> None: + """Valid-but-non-object JSON in the stamp yields None, not a crash.""" + stamp = tmp_path / "s.json" + stamp.write_text(payload, encoding="utf-8") + assert toolchain._read_pio_stamp_python(stamp) is None + + +def test_clean_platformio_cache_none_config_is_noop() -> None: + """clean_platformio_cache is a no-op when PlatformIO is unavailable.""" + with patch.object(toolchain, "get_platformio_config", return_value=None): + toolchain.clean_platformio_cache() + + +def test_clean_platformio_cache_wipes_everything(pio_core_dir: Path) -> None: + """clean_platformio_cache removes cache/packages/platforms and core_dir.""" + with _use_pio_config(pio_core_dir): + toolchain.clean_platformio_cache() + assert not pio_core_dir.exists() + + +def test_heal_none_config_is_noop() -> None: + """Heal is a no-op (no error) when PlatformIO is unavailable.""" + with patch.object(toolchain, "get_platformio_config", return_value=None): + toolchain.heal_platformio_python_env() + + +def test_heal_fresh_cache_stamps_without_wipe(tmp_path: Path) -> None: + """A fresh core dir (no stamp, no penv) is stamped, not wiped.""" + core = tmp_path / "pio" + with _use_pio_config(core): + toolchain.heal_platformio_python_env() + assert _stamp_version(core) == _CURRENT_MINOR + + +def test_heal_stamp_matches_current_no_wipe(pio_core_dir: Path) -> None: + """A stamp matching the running interpreter leaves the cache untouched.""" + toolchain._write_pio_stamp_python( + pio_core_dir / toolchain._PIO_PYTHON_STAMP_FILE, _CURRENT_MINOR + ) + with _use_pio_config(pio_core_dir): + toolchain.heal_platformio_python_env() + assert not _cache_wiped(pio_core_dir) + assert (pio_core_dir / "penv" / "marker").exists() + + +def test_heal_stale_stamp_wipes_and_restamps(pio_core_dir: Path) -> None: + """A stamp from an older interpreter triggers a wipe + restamp; core_dir stays.""" + toolchain._write_pio_stamp_python( + pio_core_dir / toolchain._PIO_PYTHON_STAMP_FILE, "2.7" + ) + with _use_pio_config(pio_core_dir): + toolchain.heal_platformio_python_env() + assert _cache_wiped(pio_core_dir) + assert not (pio_core_dir / "penv").exists() + assert pio_core_dir.is_dir() + assert _stamp_version(pio_core_dir) == _CURRENT_MINOR + + +def test_heal_no_stamp_existing_cache_wipes_once( + pio_core_dir: Path, caplog: pytest.LogCaptureFixture +) -> None: + """An existing cache with no stamp is cleaned once and stamped.""" + with _use_pio_config(pio_core_dir), caplog.at_level("INFO"): + toolchain.heal_platformio_python_env() + assert _cache_wiped(pio_core_dir) + assert not (pio_core_dir / "penv").exists() + assert _stamp_version(pio_core_dir) == _CURRENT_MINOR + assert "once" in caplog.text + + +def test_heal_no_stamp_penv_only_counts_as_cache(tmp_path: Path) -> None: + """A core dir holding only a penv still triggers the one-time clean.""" + core = tmp_path / "pio" + penv = core / "penv" + penv.mkdir(parents=True) + (penv / "marker").write_text("x", encoding="utf-8") + with _use_pio_config(core): + toolchain.heal_platformio_python_env() + assert not penv.exists() + assert _stamp_version(core) == _CURRENT_MINOR + + +def test_heal_oserror_is_nonfatal( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A filesystem failure during the check warns instead of aborting the build.""" + blocker = tmp_path / "pio" + blocker.write_text("not a directory", encoding="utf-8") + with _use_pio_config(blocker), caplog.at_level("WARNING"): + toolchain.heal_platformio_python_env() + assert "build environment check failed" in caplog.text + + +def test_heal_stamp_write_failure_is_nonfatal( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A failed stamp write (EsphomeError from write_file) warns, not aborts.""" + with ( + _use_pio_config(tmp_path / "pio"), + patch.object( + toolchain, + "_write_pio_stamp_python", + side_effect=EsphomeError("disk full"), + ), + caplog.at_level("WARNING"), + ): + toolchain.heal_platformio_python_env() + assert "build environment check failed" in caplog.text + + +def test_heal_is_idempotent_across_runs(pio_core_dir: Path) -> None: + """After a heal writes the stamp, a re-provisioned cache is not wiped again.""" + with _use_pio_config(pio_core_dir): + toolchain.heal_platformio_python_env() + repop = pio_core_dir / "packages" + repop.mkdir(exist_ok=True) + (repop / "marker").write_text("x", encoding="utf-8") + toolchain.heal_platformio_python_env() + assert (pio_core_dir / "packages" / "marker").exists() + + +def test_pio_stamp_dir_is_platforms_parent(tmp_path: Path) -> None: + """The stamp home is the parent of platforms_dir, not core_dir.""" + layout = _split_pio_layout(tmp_path) + config = _make_pio_config(layout) + assert toolchain._pio_stamp_dir(config) == layout["platforms_dir"].parent + nested = _make_pio_config(tmp_path / "pio") + assert toolchain._pio_stamp_dir(nested) == tmp_path / "pio" + + +def test_heal_container_layout_stamps_persistent_root(tmp_path: Path) -> None: + """Container shape: the stamp lands on the persistent cache root.""" + layout = _split_pio_layout(tmp_path) + with _use_pio_config(layout): + toolchain.heal_platformio_python_env() + persistent = layout["platforms_dir"].parent + assert _stamp_version(persistent) == _CURRENT_MINOR + assert not (layout["core_dir"] / toolchain._PIO_PYTHON_STAMP_FILE).exists() + + +def test_heal_container_layout_stale_stamp_wipes_persistent_cache( + tmp_path: Path, +) -> None: + """Container shape: a stale stamp wipes the relocated persistent caches.""" + layout = _split_pio_layout(tmp_path) + _seed_layout(layout) + persistent = layout["platforms_dir"].parent + toolchain._write_pio_stamp_python( + persistent / toolchain._PIO_PYTHON_STAMP_FILE, "2.7" + ) + with _use_pio_config(layout): + toolchain.heal_platformio_python_env() + for key in ("platforms_dir", "packages_dir", "cache_dir"): + assert not layout[key].exists() + assert not (layout["core_dir"] / "penv").exists() + assert _stamp_version(persistent) == _CURRENT_MINOR + + +def test_heal_container_layout_survives_core_dir_wipe(tmp_path: Path) -> None: + """A python change is still detected after an image update wiped core_dir.""" + layout = _split_pio_layout(tmp_path) + _seed_layout(layout) + shutil.rmtree(layout["core_dir"]) + persistent = layout["platforms_dir"].parent + toolchain._write_pio_stamp_python( + persistent / toolchain._PIO_PYTHON_STAMP_FILE, "2.7" + ) + with _use_pio_config(layout): + toolchain.heal_platformio_python_env() + for key in ("platforms_dir", "packages_dir", "cache_dir"): + assert not layout[key].exists() + assert _stamp_version(persistent) == _CURRENT_MINOR + + +def test_get_platformio_config_returns_project_config() -> None: + """The real lookup returns a usable ProjectConfig when PlatformIO is present.""" + config = _REAL_GET_PLATFORMIO_CONFIG() + assert config is not None + assert hasattr(config, "get") + + +def test_get_platformio_config_none_when_platformio_absent() -> None: + """The lookup returns None when PlatformIO cannot be imported.""" + with patch.dict(sys.modules, {"platformio.project.config": None}): + assert _REAL_GET_PLATFORMIO_CONFIG() is None + + +def test_delete_platformio_dirs_skips_missing(tmp_path: Path) -> None: + """A named dir that does not exist is skipped without error.""" + (tmp_path / "packages").mkdir() + (tmp_path / "packages" / "marker").write_text("x", encoding="utf-8") + config = _make_pio_config(tmp_path) + # platforms_dir does not exist; packages_dir does. + toolchain._delete_platformio_dirs(config, ["packages_dir", "platforms_dir"]) + assert not (tmp_path / "packages").exists() + + +def test_heal_stale_stamp_wipes_when_penv_absent(pio_core_dir: Path) -> None: + """The penv wipe is skipped cleanly when no penv exists.""" + shutil.rmtree(pio_core_dir / "penv") + toolchain._write_pio_stamp_python( + pio_core_dir / toolchain._PIO_PYTHON_STAMP_FILE, "2.7" + ) + with _use_pio_config(pio_core_dir): + toolchain.heal_platformio_python_env() + assert _cache_wiped(pio_core_dir) + assert _stamp_version(pio_core_dir) == _CURRENT_MINOR + + +def test_run_platformio_cli_invokes_heal( + setup_core: Path, mock_run_external_process: Mock +) -> None: + """run_platformio_cli runs the heal before spawning PlatformIO.""" + CORE.build_path = str(setup_core / "build" / "test") + mock_run_external_process.return_value = 0 + with patch.object(toolchain, "heal_platformio_python_env") as mock_heal: + toolchain.run_platformio_cli("test") + mock_heal.assert_called_once() From 3e1a9e8a3aa313e7a44b5c4638a9a47ed7010b0b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Jul 2026 19:34:56 -1000 Subject: [PATCH 0974/1815] [platformio] Re-download library when cached copy is missing its manifest (#17691) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/platformio/library.py | 20 ++++- tests/unit_tests/test_platformio_library.py | 84 ++++++++++++++++++--- 2 files changed, 90 insertions(+), 14 deletions(-) diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 72a50b795b..b3fd24c2b7 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -669,9 +669,25 @@ def convert_libraries( library_json_path = component.path / "library.json" library_properties_path = component.path / "library.properties" - if library_json_path.is_file(): + has_json = library_json_path.is_file() + has_properties = library_properties_path.is_file() + if not has_json and not has_properties: + # The shared cache can hold a broken copy (e.g. a clone or an + # extraction interrupted by a killed process). Force one + # re-download so a bad cache entry self-heals instead of failing + # every build until the user runs a full clean. + _LOGGER.warning( + "Library %s at %s is missing library.json and library.properties; " + "re-downloading", + key, + component.path, + ) + component.download(force=True, salt=salt, namespace=backend.cache_key) + has_json = library_json_path.is_file() + has_properties = library_properties_path.is_file() + if has_json: component.data = _parse_library_json(library_json_path) - elif library_properties_path.is_file(): + elif has_properties: component.data = _parse_library_properties(library_properties_path) else: raise RuntimeError( diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index 6a4c057469..d2ca71bad6 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -133,18 +133,8 @@ def test_resolve_registry_version_raises_without_pkg_file(monkeypatch): _resolve_registry_version("owner", "pkg", set()) -def _patch_download_with_manifests(monkeypatch, tmp_path, manifests, *, properties=()): - """Fake ConvertedLibrary.download to materialize canned manifests on disk.""" - - def fake_download(self, force=False, salt="", namespace=""): - self.path = tmp_path / self.get_sanitized_name().replace("/", "__") - self.path.mkdir(parents=True, exist_ok=True) - if self.name in properties: - (self.path / "library.properties").write_text(manifests[self.name]) - else: - (self.path / "library.json").write_text(json.dumps(manifests[self.name])) - - monkeypatch.setattr(ConvertedLibrary, "download", fake_download) +def _patch_registry_resolve(monkeypatch: pytest.MonkeyPatch) -> None: + """Stub the registry lookup so tests never touch the network.""" monkeypatch.setattr( lib, "_resolve_registry_version", @@ -157,6 +147,21 @@ def _patch_download_with_manifests(monkeypatch, tmp_path, manifests, *, properti ) +def _patch_download_with_manifests(monkeypatch, tmp_path, manifests, *, properties=()): + """Fake ConvertedLibrary.download to materialize canned manifests on disk.""" + + def fake_download(self, force=False, salt="", namespace=""): + self.path = tmp_path / self.get_require_name() + self.path.mkdir(parents=True, exist_ok=True) + if self.name in properties: + (self.path / "library.properties").write_text(manifests[self.name]) + else: + (self.path / "library.json").write_text(json.dumps(manifests[self.name])) + + monkeypatch.setattr(ConvertedLibrary, "download", fake_download) + _patch_registry_resolve(monkeypatch) + + def test_convert_libraries_parses_library_properties(tmp_path, monkeypatch): # A manifest provided as library.properties (Arduino style) instead of # library.json must still be parsed and converted. @@ -212,6 +217,61 @@ def test_convert_libraries_handles_unparsable_dependency_version(tmp_path, monke assert [d.name for d in top[0].dependencies] == ["C"] +def _patch_download_without_manifest( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, *, manifest_on_force: bool +) -> list[bool]: + """Fake ConvertedLibrary.download that leaves the manifest missing. + + When ``manifest_on_force`` is set, a forced re-download writes a valid + library.json, simulating a broken cache entry that heals on retry. + Returns the list of ``force`` values download was called with. + """ + calls: list[bool] = [] + + def fake_download( + self: ConvertedLibrary, force: bool = False, salt: str = "", namespace: str = "" + ) -> None: + calls.append(force) + self.path = tmp_path / self.get_require_name() + self.path.mkdir(parents=True, exist_ok=True) + if force and manifest_on_force: + (self.path / "library.json").write_text(json.dumps({"name": "A"})) + + monkeypatch.setattr(ConvertedLibrary, "download", fake_download) + _patch_registry_resolve(monkeypatch) + return calls + + +def test_convert_libraries_redownloads_when_manifest_missing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # A cached copy without any manifest (e.g. an interrupted clone or + # extraction) triggers exactly one forced re-download and then succeeds. + calls = _patch_download_without_manifest( + monkeypatch, tmp_path, manifest_on_force=True + ) + + top = convert_libraries([Library("esphome/A", "1.0.0", None)], _backend()) + + assert calls == [False, True] + assert top[0].data["name"] == "A" + + +def test_convert_libraries_raises_when_manifest_missing_after_retry( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # If the forced re-download still yields no manifest, the error is raised + # after exactly one retry (no retry loop). + calls = _patch_download_without_manifest( + monkeypatch, tmp_path, manifest_on_force=False + ) + + with pytest.raises(RuntimeError, match="Invalid PIO library"): + convert_libraries([Library("esphome/A", "1.0.0", None)], _backend()) + + assert calls == [False, True] + + @pytest.mark.parametrize( ("value", "expected"), [ From 6f39030b4128ba63a0769ba393f4074ca5de6c78 Mon Sep 17 00:00:00 2001 From: Jeroen Date: Mon, 20 Jul 2026 18:28:56 +0200 Subject: [PATCH 0975/1815] [core] Prevent blocking-warning log time from cascading (#17710) Co-authored-by: Jeroen Jansen --- esphome/core/application.h | 2 + ...og_time_not_charged_to_next_operation.yaml | 66 +++++++++++++++++++ ..._log_time_not_charged_to_next_operation.py | 63 ++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 tests/integration/fixtures/blocking_warning_log_time_not_charged_to_next_operation.yaml create mode 100644 tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py diff --git a/esphome/core/application.h b/esphome/core/application.h index 76af514511..a12cdc4ac8 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -612,6 +612,8 @@ class LoopBlockingGuard { uint32_t blocking_time = curr_time - App.get_loop_component_start_time(); if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] { warn_blocking(blocking_time); + // Exclude synchronous warning-log time from the next operation. + curr_time = MillisInternal::get(); } #endif return curr_time; diff --git a/tests/integration/fixtures/blocking_warning_log_time_not_charged_to_next_operation.yaml b/tests/integration/fixtures/blocking_warning_log_time_not_charged_to_next_operation.yaml new file mode 100644 index 0000000000..2f30a07d0d --- /dev/null +++ b/tests/integration/fixtures/blocking_warning_log_time_not_charged_to_next_operation.yaml @@ -0,0 +1,66 @@ +esphome: + name: blocking-warning-cascade + on_boot: + then: + - script.execute: blocking_60 + - script.execute: blocking_90 + - script.execute: blocking_120 + +host: + +api: + +logger: + level: DEBUG + on_message: + level: WARN + then: + - lambda: |- + uint32_t injected_delay = 0; + if (strstr(message, "blocking_60 took a long time") != nullptr) { + injected_delay = 60; + } else if (strstr(message, "blocking_90 took a long time") != nullptr) { + injected_delay = 90; + } else if (strstr(message, "blocking_120 took a long time") != nullptr) { + injected_delay = 120; + } + if (injected_delay != 0) { + id(injected_delay_total) += injected_delay; + const uint32_t start = millis(); + while (millis() - start < injected_delay) { + } + } + +globals: + - id: injected_delay_total + type: uint32_t + initial_value: "0" + +script: + - id: blocking_60 + then: + - delay: 20ms + - lambda: |- + const uint32_t start = millis(); + while (millis() - start < 80) { + } + + - id: blocking_90 + then: + - delay: 300ms + - lambda: |- + const uint32_t start = millis(); + while (millis() - start < 80) { + } + + - id: blocking_120 + then: + - delay: 600ms + - lambda: |- + const uint32_t start = millis(); + while (millis() - start < 80) { + } + - delay: 200ms + - logger.log: + format: "BLOCKING_WARNING_CASCADE_TEST_COMPLETE total=%u" + args: [id(injected_delay_total)] diff --git a/tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py b/tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py new file mode 100644 index 0000000000..e8b08f2265 --- /dev/null +++ b/tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py @@ -0,0 +1,63 @@ +"""Regression test for blocking-warning log time attribution.""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +WARN_PATTERN = re.compile( + r"(\S+) took a long time for an operation \((\d+) ms\), max is (\d+) ms" +) +COMPLETE_PATTERN = re.compile(r"BLOCKING_WARNING_CASCADE_TEST_COMPLETE total=(\d+)") +PRIMARY_SOURCES = {"blocking_60", "blocking_90", "blocking_120"} + + +@pytest.mark.asyncio +async def test_blocking_warning_log_time_not_charged_to_next_operation( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Synchronous warning-log delays must not be charged to the next operation.""" + loop = asyncio.get_running_loop() + complete = asyncio.Event() + warnings: list[tuple[str, int, int]] = [] + injected_delay_total = 0 + + def check_output(line: str) -> None: + nonlocal injected_delay_total + if match := WARN_PATTERN.search(line): + warnings.append((match.group(1), int(match.group(2)), int(match.group(3)))) + if match := COMPLETE_PATTERN.search(line): + injected_delay_total = int(match.group(1)) + loop.call_soon_threadsafe(complete.set) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + assert await client.device_info() is not None + await asyncio.wait_for(complete.wait(), timeout=10.0) + + assert injected_delay_total == 270, ( + f"Expected 270 ms of injected warning-log delay, got {injected_delay_total} ms" + ) + + primary_warnings = [ + warning for warning in warnings if warning[0] in PRIMARY_SOURCES + ] + assert {warning[0] for warning in primary_warnings} == PRIMARY_SOURCES, ( + f"Expected one real blocking warning from each test script, got: {warnings}" + ) + + secondary_warnings = [ + warning for warning in warnings if warning[0] not in PRIMARY_SOURCES + ] + assert not secondary_warnings, ( + "Warning-handler time was incorrectly charged to the next operation: " + f"{secondary_warnings}" + ) From 4268d91da3b72e67557a50d61c45ab7103275783 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Seux?= Date: Mon, 20 Jul 2026 19:07:28 +0200 Subject: [PATCH 0976/1815] [http_request] Fix usage of http response body (#17713) Co-authored-by: J. Nick Koston --- esphome/components/http_request/http_request.h | 4 ++-- .../components/http_request/http_request.yaml | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index df1bb462ab..4471dffdc2 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -488,10 +488,10 @@ template class HttpRequestSendAction final : public Actionbody_.value(x...); } if (!this->json_.empty()) { - body = json::build_json([this, x...](JsonObject root) { this->encode_json_(x..., root); }); + body = json::build_json([this, x...](JsonObject root) mutable { this->encode_json_(x..., root); }); } if (this->json_func_ != nullptr) { - body = json::build_json([this, x...](JsonObject root) { this->json_func_(x..., root); }); + body = json::build_json([this, x...](JsonObject root) mutable { this->json_func_(x..., root); }); } std::vector
request_headers; request_headers.reserve(this->request_headers_.size()); diff --git a/tests/components/http_request/http_request.yaml b/tests/components/http_request/http_request.yaml index 46d4b88ec5..4b3c2ca36b 100644 --- a/tests/components/http_request/http_request.yaml +++ b/tests/components/http_request/http_request.yaml @@ -59,6 +59,24 @@ esphome: id: test_regression_light brightness: 100% effect: "None" + - http_request.get: + url: https://esphome.io + capture_response: true + on_response: + then: + # Regression test: http_request.post with json: (dict variant) inside + # on_response of a capture_response: true request puts std::string& + # (body) into the nested action's Ts..., which exposes a + # const-correctness bug in HttpRequestSendAction::play() where + # encode_json_ receives const copies of non-const reference args. + - http_request.post: + url: https://esphome.io + json: + status: "ok" + # Same with json: lambda variant, exercises json_func_ path + - http_request.post: + url: https://esphome.io + json: !lambda "root[\"status\"] = \"ok\";" http_request: useragent: esphome/tagreader From 057143838deedcef23e941dede47853b5cc33ee1 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Mon, 20 Jul 2026 14:18:37 -0400 Subject: [PATCH 0977/1815] [network] Fix IPAddress IPv6 support on HOST platform (#17067) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/network/ip_address.h | 105 ++++++++- tests/components/network/__init__.py | 13 ++ .../network/test_ip_address_host.cpp | 208 ++++++++++++++++++ 3 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 tests/components/network/__init__.py create mode 100644 tests/components/network/test_ip_address_host.cpp diff --git a/esphome/components/network/ip_address.h b/esphome/components/network/ip_address.h index ec1a8c7a07..f7aa7daf99 100644 --- a/esphome/components/network/ip_address.h +++ b/esphome/components/network/ip_address.h @@ -19,10 +19,37 @@ #ifdef USE_HOST #include +#if USE_NETWORK_IPV6 +using ip4_addr_t = struct in_addr; +using ip6_addr_t = struct in6_addr; +struct ip_addr_t { + union { + struct in6_addr ip6; + struct in_addr ip4; + } u_addr; + uint8_t type; +}; +enum : uint8_t { IPADDR_TYPE_V4 = 0, IPADDR_TYPE_V6 = 6 }; +static inline int ipaddr_aton(const char *cp, ip_addr_t *addr) { + if (strchr(cp, ':') != nullptr) { + if (inet_pton(AF_INET6, cp, &addr->u_addr.ip6) != 1) { + return 0; + } + addr->type = IPADDR_TYPE_V6; + return 1; + } + if (inet_aton(cp, &addr->u_addr.ip4) != 1) { + return 0; + } + addr->type = IPADDR_TYPE_V4; + return 1; +} +#else using ip_addr_t = in_addr; using ip4_addr_t = in_addr; #define ipaddr_aton(x, y) inet_aton((x), (y)) -#endif +#endif // USE_NETWORK_IPV6 +#endif // USE_HOST #ifdef USE_ZEPHYR #include @@ -80,6 +107,81 @@ struct IPAddress { bool operator!=(const IPAddress &other) const { return !net_ipv6_addr_cmp(&ip_addr_, &other.ip_addr_); } #elif defined(USE_HOST) +#if USE_NETWORK_IPV6 + IPAddress() { memset(&this->ip_addr_, 0, sizeof(this->ip_addr_)); } + IPAddress(uint8_t first, uint8_t second, uint8_t third, uint8_t fourth) { + memset(&this->ip_addr_, 0, sizeof(this->ip_addr_)); + this->ip_addr_.u_addr.ip4.s_addr = + htonl(((uint32_t) first << 24) | ((uint32_t) second << 16) | ((uint32_t) third << 8) | fourth); + this->ip_addr_.type = IPADDR_TYPE_V4; + } + IPAddress(const char *in_address) { + memset(&this->ip_addr_, 0, sizeof(this->ip_addr_)); + ipaddr_aton(in_address, &this->ip_addr_); + } + IPAddress(const std::string &in_address) : IPAddress(in_address.c_str()) {} + IPAddress(const ip_addr_t *other_ip) { memcpy(&this->ip_addr_, other_ip, sizeof(ip_addr_t)); } + IPAddress(ip4_addr_t *other_ip) { + memset(&this->ip_addr_, 0, sizeof(this->ip_addr_)); + this->ip_addr_.u_addr.ip4 = *other_ip; + this->ip_addr_.type = IPADDR_TYPE_V4; + } + IPAddress(ip6_addr_t *other_ip) { + memset(&this->ip_addr_, 0, sizeof(this->ip_addr_)); + this->ip_addr_.u_addr.ip6 = *other_ip; + this->ip_addr_.type = IPADDR_TYPE_V6; + } + operator ip_addr_t() const { return this->ip_addr_; } + bool is_set() const { + if (this->ip_addr_.type == IPADDR_TYPE_V6) { + static constexpr uint8_t zero[sizeof(struct in6_addr)] = {}; + return memcmp(this->ip_addr_.u_addr.ip6.s6_addr, zero, sizeof(zero)) != 0; + } + return this->ip_addr_.u_addr.ip4.s_addr != 0; + } + bool is_ip4() const { return this->ip_addr_.type == IPADDR_TYPE_V4; } + bool is_ip6() const { return this->ip_addr_.type == IPADDR_TYPE_V6; } + bool is_multicast() const { + if (this->ip_addr_.type == IPADDR_TYPE_V6) { + return this->ip_addr_.u_addr.ip6.s6_addr[0] == 0xff; + } + return (ntohl(this->ip_addr_.u_addr.ip4.s_addr) & 0xF0000000UL) == 0xE0000000UL; + } + // Remove before 2026.8.0 + ESPDEPRECATED( + "str() is deprecated: use 'char buf[IP_ADDRESS_BUFFER_SIZE]; ip.str_to(buf);' instead. Removed in 2026.8.0", + "2026.2.0") + std::string str() const { + char buf[IP_ADDRESS_BUFFER_SIZE]; + this->str_to(buf); + return buf; + } + char *str_to(char *buf) const { + if (this->ip_addr_.type == IPADDR_TYPE_V6) { + inet_ntop(AF_INET6, &this->ip_addr_.u_addr.ip6, buf, IP_ADDRESS_BUFFER_SIZE); + } else { + inet_ntop(AF_INET, &this->ip_addr_.u_addr.ip4, buf, IP_ADDRESS_BUFFER_SIZE); + } + lowercase_ip_str(buf); + return buf; + } + bool operator==(const IPAddress &other) const { + if (this->ip_addr_.type != other.ip_addr_.type) { + return false; + } + if (this->ip_addr_.type == IPADDR_TYPE_V6) { + return memcmp(&this->ip_addr_.u_addr.ip6, &other.ip_addr_.u_addr.ip6, sizeof(struct in6_addr)) == 0; + } + return this->ip_addr_.u_addr.ip4.s_addr == other.ip_addr_.u_addr.ip4.s_addr; + } + bool operator!=(const IPAddress &other) const { return !(*this == other); } + IPAddress &operator+=(uint8_t increase) { + if (this->ip_addr_.type == IPADDR_TYPE_V4) { + (((uint8_t *) (&this->ip_addr_.u_addr.ip4))[3]) += increase; + } + return *this; + } +#else IPAddress() { ip_addr_.s_addr = 0; } IPAddress(uint8_t first, uint8_t second, uint8_t third, uint8_t fourth) { this->ip_addr_.s_addr = htonl((first << 24) | (second << 16) | (third << 8) | fourth); @@ -91,6 +193,7 @@ struct IPAddress { inet_ntop(AF_INET, &ip_addr_, buf, IP_ADDRESS_BUFFER_SIZE); return buf; // IPv4 only, no hex letters to lowercase } +#endif // USE_NETWORK_IPV6 #else IPAddress() { ip_addr_set_zero(&ip_addr_); } IPAddress(uint8_t first, uint8_t second, uint8_t third, uint8_t fourth) { diff --git a/tests/components/network/__init__.py b/tests/components/network/__init__.py new file mode 100644 index 0000000000..101f63ac82 --- /dev/null +++ b/tests/components/network/__init__.py @@ -0,0 +1,13 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.enable_codegen() + real_to_code = manifest.to_code + + async def to_code_testing(config): + await real_to_code(config) + cg.add_define("USE_NETWORK_IPV6", True) + + manifest.to_code = to_code_testing diff --git a/tests/components/network/test_ip_address_host.cpp b/tests/components/network/test_ip_address_host.cpp new file mode 100644 index 0000000000..4070f422b1 --- /dev/null +++ b/tests/components/network/test_ip_address_host.cpp @@ -0,0 +1,208 @@ +#include + +#include "esphome/components/network/ip_address.h" + +#ifdef USE_HOST +#if USE_NETWORK_IPV6 + +namespace esphome::network::testing { + +// ========================================================================= +// IPv4 +// ========================================================================= + +TEST(IPAddressHost, IPv4DefaultNotSet) { + IPAddress addr; + EXPECT_FALSE(addr.is_set()); +} + +TEST(IPAddressHost, IPv4DefaultIsIPv4) { + IPAddress addr; + EXPECT_TRUE(addr.is_ip4()); + EXPECT_FALSE(addr.is_ip6()); +} + +TEST(IPAddressHost, IPv4ParseAndSerialize) { + IPAddress addr("192.168.1.1"); + char buf[IP_ADDRESS_BUFFER_SIZE]; + EXPECT_STREQ(addr.str_to(buf), "192.168.1.1"); +} + +TEST(IPAddressHost, IPv4FromOctets) { + IPAddress addr(192, 168, 1, 1); + char buf[IP_ADDRESS_BUFFER_SIZE]; + EXPECT_STREQ(addr.str_to(buf), "192.168.1.1"); +} + +TEST(IPAddressHost, IPv4IsSet) { + IPAddress addr("192.168.1.1"); + EXPECT_TRUE(addr.is_set()); +} + +TEST(IPAddressHost, IPv4IsIp4) { + IPAddress addr("192.168.1.1"); + EXPECT_TRUE(addr.is_ip4()); + EXPECT_FALSE(addr.is_ip6()); +} + +TEST(IPAddressHost, IPv4MulticastDetected) { + IPAddress addr("239.0.60.53"); + EXPECT_TRUE(addr.is_multicast()); +} + +TEST(IPAddressHost, IPv4MulticastBoundaryLow) { + IPAddress addr("224.0.0.0"); + EXPECT_TRUE(addr.is_multicast()); +} + +TEST(IPAddressHost, IPv4MulticastBoundaryHigh) { + IPAddress addr("239.255.255.255"); + EXPECT_TRUE(addr.is_multicast()); +} + +TEST(IPAddressHost, IPv4UnicastNotMulticast) { + IPAddress addr("192.168.1.1"); + EXPECT_FALSE(addr.is_multicast()); +} + +TEST(IPAddressHost, IPv4EqualityMatch) { + IPAddress a("192.168.1.1"); + IPAddress b("192.168.1.1"); + EXPECT_EQ(a, b); +} + +TEST(IPAddressHost, IPv4EqualityMismatch) { + IPAddress a("192.168.1.1"); + IPAddress b("192.168.1.2"); + EXPECT_NE(a, b); +} + +TEST(IPAddressHost, IPv4FromOctetsMatchesParse) { + IPAddress from_octets(192, 168, 1, 1); + IPAddress from_string("192.168.1.1"); + EXPECT_EQ(from_octets, from_string); +} + +TEST(IPAddressHost, IPv4FromIPAddrT) { + ip_addr_t raw; + memset(&raw, 0, sizeof(raw)); + raw.u_addr.ip4.s_addr = htonl((192u << 24) | (168u << 16) | (1u << 8) | 1u); + raw.type = IPADDR_TYPE_V4; + IPAddress addr(&raw); + char buf[IP_ADDRESS_BUFFER_SIZE]; + EXPECT_STREQ(addr.str_to(buf), "192.168.1.1"); + EXPECT_TRUE(addr.is_ip4()); + EXPECT_FALSE(addr.is_ip6()); +} + +// ========================================================================= +// IPv6 +// ========================================================================= + +TEST(IPAddressHost, IPv6ParseAndSerialize) { + IPAddress addr("ff12::cafe"); + char buf[IP_ADDRESS_BUFFER_SIZE]; + EXPECT_STREQ(addr.str_to(buf), "ff12::cafe"); +} + +TEST(IPAddressHost, IPv6Loopback) { + IPAddress addr("::1"); + char buf[IP_ADDRESS_BUFFER_SIZE]; + EXPECT_STREQ(addr.str_to(buf), "::1"); +} + +TEST(IPAddressHost, IPv6IsIp6) { + IPAddress addr("ff12::cafe"); + EXPECT_TRUE(addr.is_ip6()); + EXPECT_FALSE(addr.is_ip4()); +} + +TEST(IPAddressHost, IPv6AllZerosNotSet) { + IPAddress addr("::"); + EXPECT_FALSE(addr.is_set()); +} + +TEST(IPAddressHost, IPv6LoopbackIsSet) { + IPAddress addr("::1"); + EXPECT_TRUE(addr.is_set()); +} + +TEST(IPAddressHost, IPv6MulticastDetected) { + IPAddress addr("ff12::cafe"); + EXPECT_TRUE(addr.is_multicast()); +} + +TEST(IPAddressHost, IPv6MulticastLinkLocal) { + IPAddress addr("ff02::1"); + EXPECT_TRUE(addr.is_multicast()); +} + +TEST(IPAddressHost, IPv6UnicastNotMulticast) { + IPAddress addr("::1"); + EXPECT_FALSE(addr.is_multicast()); +} + +TEST(IPAddressHost, IPv6EqualityMatch) { + IPAddress a("ff12::cafe"); + IPAddress b("ff12::cafe"); + EXPECT_EQ(a, b); +} + +TEST(IPAddressHost, IPv6EqualityMismatch) { + IPAddress a("ff12::cafe"); + IPAddress b("ff02::1"); + EXPECT_NE(a, b); +} + +TEST(IPAddressHost, IPv6OutputIsLowercase) { + // inet_pton is case-insensitive; str_to must lowercase the output + IPAddress addr("FF12::CAFE"); + char buf[IP_ADDRESS_BUFFER_SIZE]; + addr.str_to(buf); + for (const char *p = buf; *p; ++p) { + EXPECT_FALSE(*p >= 'A' && *p <= 'F') << "uppercase letter in: " << buf; + } +} + +TEST(IPAddressHost, IPv6FullAddressRoundTrip) { + // A full 128-bit address with no compression opportunity + const char *input = "fde0:983a:d0d3:a65e:725a:0fff:fe36:9916"; + IPAddress addr(input); + char buf[IP_ADDRESS_BUFFER_SIZE]; + addr.str_to(buf); + EXPECT_NE(buf[0], '\0'); + EXPECT_NE(std::string(buf).find("fde0"), std::string::npos); +} + +// ========================================================================= +// Malformed input +// ========================================================================= + +TEST(IPAddressHost, MalformedIPv4YieldsEmptyAddress) { + IPAddress addr("not-an-ip"); + EXPECT_FALSE(addr.is_set()); + EXPECT_TRUE(addr.is_ip4()); +} + +TEST(IPAddressHost, MalformedIPv6YieldsEmptyAddress) { + // "gg::1" looks like IPv6 (contains ':') but fails inet_pton; addr stays + // zeroed (type=V4 from memset) so is_set() is false and is_ip4() is true. + IPAddress addr("gg::1"); + EXPECT_FALSE(addr.is_set()); + EXPECT_TRUE(addr.is_ip4()); +} + +// ========================================================================= +// Cross-family +// ========================================================================= + +TEST(IPAddressHost, IPv4AndIPv6NotEqual) { + IPAddress v4("192.168.1.1"); + IPAddress v6("::1"); + EXPECT_NE(v4, v6); +} + +} // namespace esphome::network::testing + +#endif // USE_NETWORK_IPV6 +#endif // USE_HOST From d5681ac6a0a8c481a528cbd0caafae58571d8e64 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 20 Jul 2026 20:29:00 +0200 Subject: [PATCH 0978/1815] [nrf52] add platformio deprecation warning (#17705) --- esphome/components/nrf52/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 5b3c250f34..2edc988ff8 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -283,6 +283,13 @@ def _validate_mcumgr(config): def _final_validate(config): + # Remove before 2027.2.0 + if CORE.using_toolchain_platformio: + _LOGGER.warning( + "The 'platformio' toolchain for nRF52 is deprecated and will be removed in ESPHome 2027.2.0. " + "Please use 'toolchain: sdk-nrf' instead." + ) + if CONF_DFU in config: _validate_mcumgr(config) if config[KEY_BOOTLOADER] == BOOTLOADER_ADAFRUIT: From 9a0e8606cd0d29d2293052b2b107647278dc7951 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Jul 2026 10:08:46 -1000 Subject: [PATCH 0979/1815] [platformio] Include cache path in invalid library error (#17692) --- esphome/platformio/library.py | 2 +- tests/unit_tests/test_platformio_library.py | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index b3fd24c2b7..7c8566b77a 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -692,7 +692,7 @@ def convert_libraries( else: raise RuntimeError( f"Invalid PIO library {key}: missing library.json and " - "library.properties" + f"library.properties in {component.path}" ) try: diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index d2ca71bad6..c0a0c678db 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -261,15 +261,18 @@ def test_convert_libraries_raises_when_manifest_missing_after_retry( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: # If the forced re-download still yields no manifest, the error is raised - # after exactly one retry (no retry loop). + # after exactly one retry (no retry loop). The error must name the cache + # directory so users can find the broken entry instead of guessing where + # the library was unpacked. calls = _patch_download_without_manifest( monkeypatch, tmp_path, manifest_on_force=False ) - with pytest.raises(RuntimeError, match="Invalid PIO library"): + with pytest.raises(RuntimeError, match="Invalid PIO library") as excinfo: convert_libraries([Library("esphome/A", "1.0.0", None)], _backend()) assert calls == [False, True] + assert str(tmp_path / "esphome__A") in str(excinfo.value) @pytest.mark.parametrize( From 271c1b409a17203a1dfbc3100a50f211b7a8d5a4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Jul 2026 10:12:17 -1000 Subject: [PATCH 0980/1815] [espidf] Resume interrupted toolchain downloads instead of restarting (#17706) --- esphome/espidf/framework.py | 213 ++++-- esphome/espidf/get_tool_downloads.py | 88 +++ esphome/framework_helpers.py | 547 ++++++++++--- .../fixtures/idf_tools_stub/idf_tools.py | 120 +++ tests/unit_tests/test_espidf_framework.py | 317 +++++++- tests/unit_tests/test_framework_helpers.py | 718 +++++++++++++++++- 6 files changed, 1844 insertions(+), 159 deletions(-) create mode 100644 esphome/espidf/get_tool_downloads.py create mode 100644 tests/unit_tests/fixtures/idf_tools_stub/idf_tools.py diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 66a34ea03f..f1544e9d46 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -9,7 +9,7 @@ from pathlib import Path import platform import re import shutil -import tempfile +from typing import NoReturn import platformdirs @@ -20,6 +20,7 @@ from esphome.framework_helpers import ( archive_extract_all, create_venv, download_from_mirrors, + download_with_resume, get_python_env_executable_path, get_system_python_path, rmdir, @@ -231,6 +232,40 @@ def _write_stamp(file: PathType, data: dict[str, str]): json.dump(data, fp) +def _run_idf_tools_script( + idf_framework_root: PathType, + script_name: str, + msg: str, + args: list[str] | None = None, + env: dict[str, str] | None = None, +) -> tuple[bool, str | None, str | None]: + """Run one of the sibling idf_tools-backed helper scripts. + + The script is executed with the framework's ``tools`` directory on + PYTHONPATH so it imports the framework's own ``idf_tools`` module. + """ + cmd = [ + get_system_python_path(), + str(_SCRIPTS_DIR / script_name), + str(idf_framework_root), + *(args or []), + ] + return run_command( + cmd, + msg=msg, + env=(env or os.environ) + | {"PYTHONPATH": str(Path(idf_framework_root) / "tools")}, + ) + + +def _raise_script_failure(what: str, root: PathType, stderr: str | None) -> NoReturn: + """Raise RuntimeError for a failed helper script, appending stderr detail.""" + detail = (stderr or "").strip() + raise RuntimeError( + f"Can't get {what} of {root}" + (f": {detail}" if detail else "") + ) + + def _get_idf_version( idf_framework_root: PathType, env: dict[str, str] | None = None ) -> str: @@ -248,26 +283,13 @@ def _get_idf_version( RuntimeError: If ESP-IDF version cannot be determined """ - cmd = [ - get_system_python_path(), - str(_SCRIPTS_DIR / "get_idf_version.py"), - str(idf_framework_root), - ] - - success, stdout, stderr = run_command( - cmd, - msg="ESP-IDF version", - env=(env or os.environ) - | {"PYTHONPATH": str(Path(idf_framework_root) / "tools")}, + success, stdout, stderr = _run_idf_tools_script( + idf_framework_root, "get_idf_version.py", "ESP-IDF version", env=env ) if stdout: stdout = stdout.strip() if not success or not stdout: - detail = (stderr or "").strip() - raise RuntimeError( - f"Can't get ESP-IDF version of {idf_framework_root}" - + (f": {detail}" if detail else "") - ) + _raise_script_failure("ESP-IDF version", idf_framework_root, stderr) return stdout @@ -288,24 +310,11 @@ def _get_idf_tool_paths( RuntimeError: If ESP-IDF tool paths cannot be determined """ - cmd = [ - get_system_python_path(), - str(_SCRIPTS_DIR / "get_idf_tool_paths.py"), - str(idf_framework_root), - ] - - success, stdout, stderr = run_command( - cmd, - msg="ESP-IDF tool paths", - env=(env or os.environ) - | {"PYTHONPATH": str(Path(idf_framework_root) / "tools")}, + success, stdout, stderr = _run_idf_tools_script( + idf_framework_root, "get_idf_tool_paths.py", "ESP-IDF tool paths", env=env ) if not success or not stdout: - detail = (stderr or "").strip() - raise RuntimeError( - f"Can't get ESP-IDF tool paths of {idf_framework_root}" - + (f": {detail}" if detail else "") - ) + _raise_script_failure("ESP-IDF tool paths", idf_framework_root, stderr) # Extract json values try: @@ -579,6 +588,69 @@ def _patch_tools_json_demote_openocd(framework_path: Path) -> None: ) +def _prefetch_idf_tool_archives( + framework_path: Path, + targets_str: str, + tools: list[str], + env: dict[str, str] | None, +) -> None: + """Pre-download the tool archives ``idf_tools.py install`` would fetch. + + ``idf_tools.py``'s own downloader restarts from byte zero on every retry, + which makes large archives effectively impossible to fetch on unstable + connections (#17703). This asks the framework's idf_tools (via + ``get_tool_downloads.py``) which archives the coming install needs, then + downloads each into ``/dist`` with + ``download_with_resume``. The installer then finds the verified archives + already in place ("file ... is already downloaded") and never touches the + network. + + Strictly best-effort: any failure here just logs and returns, leaving + ``idf_tools.py install`` to download whatever is missing exactly as + before. Leftover ``.part`` files live in ``dist/`` and are removed by the + post-install cache prune. + """ + try: + success, stdout, stderr = _run_idf_tools_script( + framework_path, + "get_tool_downloads.py", + "ESP-IDF tool download list", + args=[targets_str, *tools], + env=env, + ) + if not success or not stdout: + _LOGGER.warning( + "Could not determine ESP-IDF tool downloads: %s", + (stderr or "").strip(), + ) + return + dist_path = get_idf_tools_path() / "dist" + entries = [ + entry + for entry in json.loads(stdout) + if not (dist_path / entry["dest"]).is_file() + ] + for index, entry in enumerate(entries, start=1): + _LOGGER.info( + "Downloading %s (%d/%d) ...", entry["name"], index, len(entries) + ) + try: + download_with_resume( + entry["url"], + dist_path / entry["dest"], + sha256=entry["sha256"], + size=entry["size"], + ) + except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught + # Keep prefetching the remaining archives; the installer + # will retry this one itself (without resume). + _LOGGER.warning("Could not prefetch %s: %s", entry["name"], e) + except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught + # The installer downloads anything missing itself; never let the + # prefetch become a new way for the install to fail. + _LOGGER.warning("ESP-IDF tool prefetch failed: %s", e) + + def _check_esphome_idf_framework_install( version: str, targets: list[str], @@ -650,41 +722,51 @@ def _check_esphome_idf_framework_install( git_url, ref = git_source _clone_idf_with_submodules(framework_path, git_url, ref) else: - # Download in temporary file - with tempfile.NamedTemporaryFile() as tmp: - _LOGGER.info("Downloading ESP-IDF %s framework ...", version) + _LOGGER.info("Downloading ESP-IDF %s framework ...", version) - # Create substitutions for the URLs. SHORT_VERSION (x.y with - # optional -extra) is only provided for x.y.0 releases, since - # the vX.Y release tags only exist for those; templates that - # reference it are skipped for other versions by - # download_from_mirrors. - substitutions = {"VERSION": version} - try: - ver = Version.parse(version) - substitutions["MAJOR"] = str(ver.major) - substitutions["MINOR"] = str(ver.minor) - substitutions["PATCH"] = str(ver.patch) - substitutions["EXTRA"] = f"-{ver.extra}" if ver.extra else "" - if ver.patch == 0: - substitutions["SHORT_VERSION"] = ( - f"{ver.major}.{ver.minor}{substitutions['EXTRA']}" - ) - except ValueError: - _LOGGER.warning( - "ESP-IDF version '%s' is not a valid version number; " - "only the {VERSION} substitution is available for " - "mirror URLs", - version, + # Create substitutions for the URLs. SHORT_VERSION (x.y with + # optional -extra) is only provided for x.y.0 releases, since + # the vX.Y release tags only exist for those; templates that + # reference it are skipped for other versions by + # download_from_mirrors. + substitutions = {"VERSION": version} + try: + ver = Version.parse(version) + substitutions["MAJOR"] = str(ver.major) + substitutions["MINOR"] = str(ver.minor) + substitutions["PATCH"] = str(ver.patch) + substitutions["EXTRA"] = f"-{ver.extra}" if ver.extra else "" + if ver.patch == 0: + substitutions["SHORT_VERSION"] = ( + f"{ver.major}.{ver.minor}{substitutions['EXTRA']}" ) - - mirrors = [source_url] if source_url else ESPHOME_IDF_FRAMEWORK_MIRRORS - download_from_mirrors(mirrors, substitutions, tmp.file) - - _LOGGER.info("Extracting ESP-IDF %s framework ...", version) - archive_extract_all( - tmp.file, framework_path, progress_header="Extracting" + except ValueError: + _LOGGER.warning( + "ESP-IDF version '%s' is not a valid version number; " + "only the {VERSION} substitution is available for " + "mirror URLs", + version, ) + + mirrors = [source_url] if source_url else ESPHOME_IDF_FRAMEWORK_MIRRORS + # Download to a persistent file in the tool download cache (not + # a temp file) so an interrupted download resumes on the next + # run; the cache is pruned after a successful install anyway. + tarball_path = get_idf_tools_path() / "dist" / f"esp-idf-{version}.tar.xz" + download_from_mirrors(mirrors, substitutions, tarball_path) + + _LOGGER.info("Extracting ESP-IDF %s framework ...", version) + try: + with tarball_path.open("rb") as tarball: + archive_extract_all( + tarball, framework_path, progress_header="Extracting" + ) + finally: + # Success: drop the archive rather than caching ~70MB twice. + # Failure: a corrupt archive (e.g. torn by an unclean + # shutdown) must not be reused — without a checksum only a + # failed extraction can expose it, so force a re-download. + tarball_path.unlink(missing_ok=True) extracted_marker.touch() # Idempotent post-extract patch: written every invocation so a build @@ -722,6 +804,7 @@ def _check_esphome_idf_framework_install( if install: _LOGGER.info("Installing ESP-IDF %s framework ...", version) targets_str = ",".join(targets) + _prefetch_idf_tool_archives(framework_path, targets_str, tools, env) cmd = [ get_system_python_path(), str(idf_tools_path), diff --git a/esphome/espidf/get_tool_downloads.py b/esphome/espidf/get_tool_downloads.py new file mode 100644 index 0000000000..37a6126fae --- /dev/null +++ b/esphome/espidf/get_tool_downloads.py @@ -0,0 +1,88 @@ +"""Print JSON download info for the ESP-IDF tools an install would fetch. + +Run via ``python ...``. +PYTHONPATH must include ``/tools`` so ``idf_tools`` is +importable, and IDF_TOOLS_PATH must be set. Prints a JSON list of +``{name, url, size, sha256, dest}`` for every tool version that is not yet +installed, where ``dest`` is the archive filename ``idf_tools.py install`` +expects to find in ``/dist``. Tools with no download for the +current platform are skipped; already-installed versions are skipped so a +pruned download cache is not re-fetched. + +The target/tool expansion mirrors ``idf_tools.py install`` (targets passed to +``add_and_check_targets`` accumulate with idf-env.json) but nothing is saved +or written — this script only reports what the install would download. +""" + +# pylint: disable=import-error # idf_tools is on PYTHONPATH at runtime only + +from contextlib import redirect_stdout +import json +import os +from pathlib import Path +import sys + +from idf_tools import ( + CURRENT_PLATFORM, + TOOLS_FILE, + IDFEnv, + ToolBinaryError, + add_and_check_targets, + expand_tools_arg, + g, + get_idf_download_url_apply_mirrors, + load_tools_info, +) + + +def collect_downloads() -> list[dict]: + g.idf_path = sys.argv[1] + g.idf_tools_path = os.environ.get("IDF_TOOLS_PATH") + g.tools_json = str(Path(g.idf_path) / TOOLS_FILE) + + targets = add_and_check_targets(IDFEnv.get_idf_env(), sys.argv[2]) + tools_info = load_tools_info() + downloads: list[dict] = [] + + for name in expand_tools_arg(sys.argv[3:], tools_info, targets): + if "@" in name: + name, version = name.split("@", 1) + else: + version = None + tool = tools_info.get(name) + if tool is None or not tool.compatible_with_platform(): + continue + version = version or tool.get_recommended_version() + if version is None: + continue + try: + tool.find_installed_versions() + except ToolBinaryError as e: + # A broken installed binary is idf_tools' problem to repair on + # install; note it and treat the version as not installed. + print(f"tool {name} failed its binary check: {e}", file=sys.stderr) + if version in tool.versions_installed or version not in tool.versions: + continue + download = tool.versions[version].get_download_for_platform(CURRENT_PLATFORM) + if download is None: + continue + downloads.append( + { + "name": f"{name}@{version}", + # Apply the same IDF_MIRROR_PREFIX_MAP / IDF_GITHUB_ASSETS + # rewriting the installer's own downloader applies, so users + # behind a mirror prefetch from the mirror too. + "url": get_idf_download_url_apply_mirrors(None, download.url), + "size": download.size, + "sha256": download.sha256, + "dest": download.rename_dist or Path(download.url).name, + } + ) + return downloads + + +# idf_tools prints informational lines (e.g. mirror URL rewrites) to stdout; +# route them to stderr so stdout carries only the JSON result. +with redirect_stdout(sys.stderr): + result = collect_downloads() +print(json.dumps(result)) diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 6c055dded3..202d4a2bfb 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -2,21 +2,31 @@ from collections.abc import Iterable from contextlib import ExitStack +import hashlib import io +import json import logging import os from pathlib import Path import subprocess import sys import time -from typing import IO +from typing import IO, TYPE_CHECKING from esphome.helpers import ProgressBar, rmtree +if TYPE_CHECKING: + import requests + PathType = str | os.PathLike _LOGGER = logging.getLogger(__name__) +# Attempts per mirror URL before falling through to the next mirror; only +# mid-stream drops retry (resuming when the server gave a validator), +# connect errors move on immediately. +_MIRROR_ATTEMPTS = 3 + def get_project_link_flags() -> list[str]: """Return the sorted -Wl, linker flags from the current build.""" @@ -394,17 +404,23 @@ def _zip_extract_all( progress.update(1) -def _rename_with_retry(src: Path, dst: Path, attempts: int = 5) -> None: +def _rename_with_retry( + src: Path, dst: Path, attempts: int = 5, overwrite: bool = False +) -> None: """Rename ``src`` to ``dst`` with backoff retries on Windows sharing violations. Antivirus/indexer handles on freshly-written files can briefly block ``os.rename`` with ERROR_SHARING_VIOLATION / ERROR_ACCESS_DENIED. The handle is released within tens of ms in practice, so exponential backoff - works. + works. With ``overwrite`` an existing ``dst`` is replaced instead of + failing. """ for i in range(attempts): try: - src.rename(dst) + if overwrite: + src.replace(dst) + else: + src.rename(dst) return except PermissionError: if i == attempts - 1: @@ -525,8 +541,8 @@ def archive_extract_all( ValueError: If archive format is unsupported """ - # 1. Handle different archive input types with ExitStack() as stack: + # 1. Handle different archive input types archive_ref: io.BufferedIOBase if isinstance(archive, (str, os.PathLike)): archive_ref = stack.enter_context(Path(archive).open("rb")) @@ -552,6 +568,311 @@ def archive_extract_all( matched_fct(archive_ref, extract_dir, progress_header=progress_header) +def _open_ranged( + url: str, offset: int, timeout: int, validator: str | None = None +) -> tuple["requests.Response | None", int]: + """Open a streaming GET, asking the server to resume at ``offset``. + + ``validator`` is an ETag or Last-Modified value from the interrupted + response; it is sent as ``If-Range`` so the server only honors the Range + when the content is unchanged, replying 200 (full body, restart) if the + file was replaced between requests — the resumed bytes can then never be + stitched onto a different file's prefix. + + Returns ``(response, effective_offset)``. The response is None when the + server answered 416 Range Not Satisfiable: the file holds every byte the + server has (a previous attempt was interrupted after the last byte), so + there is nothing to stream and the caller's verification decides whether + the file is good. The offset drops to 0 when the server ignored the + ``Range`` header (no 206), meaning the caller must restart the file. + Raises on connect errors and HTTP error statuses; the response is closed + on failure. + """ + import requests + + headers = {"Range": f"bytes={offset}-"} if offset else {} + if offset and validator: + headers["If-Range"] = validator + resp = requests.get(url, stream=True, timeout=timeout, headers=headers) + if offset and resp.status_code == 416: + resp.close() + return None, offset + if offset and resp.status_code != 206: + _LOGGER.debug( + "Server did not resume %s (HTTP %s), restarting", url, resp.status_code + ) + offset = 0 + if not resp.ok: + resp.close() + resp.raise_for_status() + if offset: + _LOGGER.info("Resuming download at %d bytes ...", offset) + return resp, offset + + +def _verify_file(path: Path, sha256: str | None, size: int | None) -> None: + """Raise EsphomeError when ``path`` fails an available sha256/size check.""" + from esphome.core import EsphomeError + + if size is not None and path.stat().st_size != size: + raise EsphomeError(f"size mismatch: expected {size}, got {path.stat().st_size}") + if sha256 is not None: + with path.open("rb") as f: + digest = hashlib.file_digest(f, "sha256").hexdigest() + if digest != sha256: + raise EsphomeError(f"sha256 mismatch: got {digest}") + + +def _load_download_meta(meta: Path, url: str) -> tuple[str | None, int]: + """Return the ``(validator, total)`` a previous run recorded for ``url``. + + ``(None, 0)`` when there is no sidecar, it is unreadable, or it belongs + to a different URL (e.g. a different mirror was tried last time). + """ + try: + with meta.open(encoding="utf-8") as f: + data = json.load(f) + except (OSError, json.JSONDecodeError): + return None, 0 + if not isinstance(data, dict) or data.get("url") != url: + return None, 0 + validator = data.get("validator") + total = data.get("total") + return ( + validator if isinstance(validator, str) else None, + total if isinstance(total, int) else 0, + ) + + +def _write_download_meta( + meta: Path, url: str, validator: str | None, total: int +) -> None: + """Persist resume metadata next to the part file; best-effort. + + Without a validator there is nothing a later run could resume against, + so any stale sidecar is removed instead. + """ + try: + if validator is None: + meta.unlink(missing_ok=True) + else: + meta.write_text( + json.dumps({"url": url, "validator": validator, "total": total}), + encoding="utf-8", + ) + except OSError as e: + _LOGGER.debug("Could not update download metadata %s: %s", meta, e) + + +def _content_length(resp: "requests.Response") -> int: + """Return the response's Content-Length, or 0 when absent or malformed. + + 0 means "unknown", which downstream disables the progress bar and the + resume/completeness logic — a garbage header from a broken proxy must + degrade to a plain single-stream download, not crash the attempt. + """ + try: + return int(resp.headers.get("content-length", 0)) + except ValueError: + return 0 + + +def _response_validator(resp: "requests.Response") -> str | None: + """Return the response's strong validator for ``If-Range`` resumes. + + Weak ETags (``W/...``) are not usable for byte-range conditionals, so + fall back to Last-Modified, or None when the server offers neither. + """ + etag = resp.headers.get("ETag") + if etag and not etag.startswith("W/"): + return etag + return resp.headers.get("Last-Modified") + + +def _stream_response_to_file( + resp: "requests.Response", f: IO[bytes], offset: int, size: int | None = None +) -> None: + """Stream an open ``_open_ranged`` response body into ``f`` at ``offset``. + + Truncates ``f`` to ``offset`` first, so a server-rejected resume + (effective offset 0) discards the stale bytes. ``offset`` also seeds the + progress bar so a resumed download shows overall progress. ``size`` is + the known full file size; when None it is derived from the response's + content-length, and without either there is no progress bar. + """ + f.seek(offset) + f.truncate(offset) + total_size = size or offset + _content_length(resp) + downloaded = offset + progress = ProgressBar("Downloading") if total_size > 0 else None + for chunk in resp.iter_content(chunk_size=256 * 1024): + if chunk: + f.write(chunk) + downloaded += len(chunk) + if progress is not None: + progress.update(downloaded / total_size) + if progress is not None: + progress.update(1) + + +def download_with_resume( + url: str, + dest: PathType, + sha256: str | None = None, + size: int | None = None, + # More attempts than _MIRROR_ATTEMPTS: a single-URL download has no + # mirror fallback, and each retry only re-fetches the remainder. + attempts: int = 5, + timeout: int = 30, + retry_connect_errors: bool = True, +) -> None: + """Download ``url`` to ``dest``, resuming partial downloads. + + The body streams into ``.part``, which persists across attempts and + esphome runs: a mid-stream connection drop only costs one attempt and the + next continues from where it stopped, so an unstable connection converges + on a complete file instead of restarting from zero each retry (#17703). + When ``size`` / ``sha256`` are given the completed file is verified and a + mismatch restarts from scratch; success renames the part file into place. + An already-present ``dest`` that passes verification is kept as-is. + + Resuming a part file from an earlier run needs proof the content is + unchanged: ``sha256`` when the caller has one, or otherwise the server's + If-Range validator recorded in a ``.part.meta`` sidecar by the run + that started the download — a size alone cannot detect a same-length + content change on the server. + + With ``retry_connect_errors`` disabled, a failure before any body bytes + flow (connect error, HTTP error status) propagates immediately instead + of consuming attempts — for callers with their own fallback, like + ``download_from_mirrors``. + + Raises EsphomeError when all attempts are exhausted. + """ + # Imported lazily: requests is a heavy import (~85ms) and is only needed + # when actually downloading a toolchain, never during config validation. + import requests + + from esphome.core import EsphomeError + + dest = Path(dest) + part = dest.with_name(dest.name + ".part") + meta = part.with_name(part.name + ".meta") + dest.parent.mkdir(parents=True, exist_ok=True) + last_error: Exception | None = None + + # An earlier run already completed this download. Only trust it when + # there is something to verify it against; without sha/size the remote + # content may have changed (e.g. a refreshed constraints file), so + # re-download and atomically replace it. + if dest.is_file() and (sha256 is not None or size is not None): + try: + _verify_file(dest, sha256, size) + return + except EsphomeError: + dest.unlink() + + # Adopt the validator/total the run that started this part file recorded, + # so an unfinished download resumes across runs even without a sha256. + validator, expected_total = _load_download_meta(meta, url) + + for _ in range(attempts): + streamed = False + try: + offset = part.stat().st_size if part.is_file() else 0 + # A stitched resume needs two proofs: content identity (the + # bytes being appended belong to the same file as the prefix) + # and completeness. sha256 provides both, across runs. Without + # it, identity needs this run's If-Range validator — a size + # alone cannot detect a same-length content change, so a + # leftover part file from an earlier run must restart — and + # completeness needs a known total length. + if ( + offset + and sha256 is None + and (validator is None or not (size or expected_total)) + ): + _LOGGER.debug( + "Restarting %s from zero: cannot prove a resumed " + "file correct (no sha256, validator=%s, total=%s)", + url, + validator is not None, + size or expected_total, + ) + offset = 0 + if size is None or offset < size: + resp, offset = _open_ranged(url, offset, timeout, validator) + # A None response means HTTP 416: the part file already holds + # every byte the server has; fall through to verification. + if resp is not None: + with resp, part.open("ab") as f: + streamed = True + if offset == 0: + validator = _response_validator(resp) + expected_total = _content_length(resp) + # Recorded so a later run can prove an If-Range + # resume of this part file safe. + _write_download_meta(meta, url, validator, expected_total) + _stream_response_to_file(resp, f, offset, size) + # else: a previous run already wrote every byte (or more) but + # was killed before the rename below. Skip the network entirely + # — a Range request past EOF would draw HTTP 416 — and let + # verification decide whether to promote the file or discard it + # and start over. + + expected_size = size if size is not None else expected_total + _verify_file(part, sha256, expected_size or None) + if not expected_size and sha256 is None: + # No sha, no size, and the server sent no usable + # content-length: nothing can prove the download complete + # (urllib3 still errors on most short bodies, but not on a + # cleanly closed chunked stream). Promote with a debug + # note rather than fail or warn: some servers (e.g. the + # Espressif constraints host) never send a length, the user + # can do nothing about it, and every current caller + # extracts or parses the file afterwards, where corruption + # fails loudly. + _LOGGER.debug( + "Downloaded %s without any way to verify completeness", + dest.name, + ) + # Retry on Windows sharing violations: an antivirus handle on the + # freshly-written file must not get the verified download deleted + # as corrupt by the except clause below. If even the backoff + # retries fail, keep the verified part so the next attempt (or + # run) only has to redo the rename, not the download. + try: + _rename_with_retry(part, dest, overwrite=True) + except PermissionError as e: + _LOGGER.debug("Could not move %s into place: %s", part, e) + last_error = e + continue + meta.unlink(missing_ok=True) + return + except requests.RequestException as e: + # Network failures — including connect errors, since a single + # URL has no mirror-list fallback — keep the part file for the + # next attempt (or the next esphome run) to resume from. Checked + # before OSError: RequestException subclasses IOError. + if not retry_connect_errors and not streamed: + # The caller falls back to another URL on pre-body failures. + raise + _LOGGER.debug("Download of %s interrupted: %s", url, e) + last_error = e + except (OSError, EsphomeError) as e: + # A completed-but-corrupt file (or local disk error) can't be + # trusted for resume; start over. + _LOGGER.debug("Discarding %s: %s", part, e) + part.unlink(missing_ok=True) + meta.unlink(missing_ok=True) + last_error = e + + raise EsphomeError( + f"Failed to download {url} after {attempts} attempts: " + f"{_failure_reason(last_error)}" + ) from last_error + + def _failure_reason(e: Exception) -> str: """Format a download exception for the aggregated error message. @@ -585,110 +906,166 @@ def download_from_mirrors( ``substitutions`` are skipped, so callers can offer templates that only apply to some downloads. + A path target downloads through ``download_with_resume``, so an + interrupted download resumes on the next esphome run; a file-like target + only resumes mid-stream drops within this call. + Raises: ValueError: If mirrors list is empty. EsphomeError: If all download attempts fail; the message lists every attempted URL with its individual failure reason. Also raised if no template matched the provided substitutions. """ - # Imported lazily: requests is a heavy import (~85ms) and is only needed - # when actually downloading a toolchain, never during config validation. + # Imported lazily: requests is a heavy import (~85ms) and is only + # needed when actually downloading, never during config validation. import requests from esphome.core import EsphomeError - # 1. Open target file for writing if path given - with ExitStack() as stack: - if isinstance(target, (str, os.PathLike)): - f = stack.enter_context(Path(target).open("wb")) - elif isinstance(target, (io.RawIOBase, io.IOBase)): - f = target - else: - raise TypeError( - f"target must be str, Path, or file-like object: {type(target)}" - ) + # 1. Classify the target: filesystem path or open file object + path_target: Path | None = None + f: IO[bytes] | None = None + if isinstance(target, (str, os.PathLike)): + path_target = Path(target) + elif isinstance(target, (io.RawIOBase, io.IOBase)): + f = target + else: + raise TypeError( + f"target must be str, Path, or file-like object: {type(target)}" + ) - # 2. Try each mirror in order - failures: list[tuple[str, Exception]] = [] - skipped: list[tuple[str, str]] = [] + # 2. Try each mirror in order + failures: list[tuple[str, Exception]] = [] + skipped: list[tuple[str, str]] = [] - for mirror in mirrors: - # 3. Apply substitutions to URL + for mirror in mirrors: + # 3. Apply substitutions to URL + try: + url = mirror.format(**substitutions) + except KeyError as e: + # The template references a substitution not provided for + # this download (e.g. SHORT_VERSION only exists for x.y.0 + # versions) - expected, the template just doesn't apply. + _LOGGER.debug("Skipping mirror %s: %s not available", mirror, e) + skipped.append((mirror, f"not applicable ({e.args[0]} not available)")) + continue + except (IndexError, ValueError) as e: + # A malformed template (unbalanced braces, bad format spec) + # is an authoring error, not an expected fallthrough - warn + # even if a later mirror succeeds. + _LOGGER.warning("Skipping malformed mirror URL template %s: %r", mirror, e) + skipped.append((mirror, f"skipped ({e!r})")) + continue + + _LOGGER.debug("Trying to download from %s", url) + + # Path targets delegate to download_with_resume so a partial + # download persists (and resumes) across esphome runs. + if path_target is not None: try: - url = mirror.format(**substitutions) - except KeyError as e: - # The template references a substitution not provided for - # this download (e.g. SHORT_VERSION only exists for x.y.0 - # versions) - expected, the template just doesn't apply. - _LOGGER.debug("Skipping mirror %s: %s not available", mirror, e) - skipped.append((mirror, f"not applicable ({e.args[0]} not available)")) - continue - except (IndexError, ValueError) as e: - # A malformed template (unbalanced braces, bad format spec) - # is an authoring error, not an expected fallthrough - warn - # even if a later mirror succeeds. - _LOGGER.warning( - "Skipping malformed mirror URL template %s: %r", mirror, e + download_with_resume( + url, + path_target, + attempts=_MIRROR_ATTEMPTS, + timeout=timeout, + # Pre-body failures (connect/HTTP errors) fall to the + # next mirror immediately; only mid-stream drops + # retry-with-resume on the same URL. + retry_connect_errors=False, ) - skipped.append((mirror, f"skipped ({e!r})")) + return url + except (requests.RequestException, OSError, EsphomeError) as e: + # Everything download_with_resume classifies as a download + # failure; programming errors propagate. + _LOGGER.debug("Failed to download %s: %s", url, str(e)) + failures.append((url, e)) continue - _LOGGER.debug("Trying to download from %s", url) + # 4. Download; mid-stream failures retry the same mirror with + # resume (see download_with_resume) instead of starting over. + # There is no checksum to verify a resumed file against, so a + # stitch is only trusted when the server proves consistency: the + # If-Range validator guarantees 206 only for unchanged content, + # and the expected total length (when the first response carried + # one) guards against short or shifted bodies. Without a + # validator the retry restarts from zero. + offset = 0 + expected_total = 0 + validator = None + for attempt in range(_MIRROR_ATTEMPTS): + try: + resp, offset = _open_ranged(url, offset, timeout, validator) + except (requests.RequestException, OSError) as e: + # Connect/HTTP error, no bytes flowed — next mirror. + _LOGGER.debug("Failed to download %s: %s", url, str(e)) + failures.append((url, e)) + break try: - # 4. Reset file pointer and download - f.seek(0) - f.truncate(0) + # A None response means HTTP 416: the file already holds + # every byte the server has (a drop after the last byte); + # only the length check below remains. + if resp is not None: + with resp: + if offset == 0: + validator = _response_validator(resp) + expected_total = _content_length(resp) + _stream_response_to_file(resp, f, offset) - with requests.get(url, stream=True, timeout=timeout) as r: - r.raise_for_status() - - total_size = int(r.headers.get("content-length", 0)) - downloaded = 0 - - progress = ProgressBar("Downloading") if total_size > 0 else None - - for chunk in r.iter_content(chunk_size=8192): - if chunk: - f.write(chunk) - - downloaded += len(chunk) - - if progress is not None: - progress.update(downloaded / total_size) - - if progress is not None: - progress.update(1) + if expected_total and f.tell() != expected_total: + raise EsphomeError( + f"size mismatch: expected {expected_total}, got {f.tell()}" + ) + if not expected_total: + # Same trust decision as download_with_resume's + # unverifiable promotion; surface it at the same level. + _LOGGER.debug( + "Downloaded %s without any way to verify completeness", + url, + ) _LOGGER.debug("Downloaded successfully from: %s", url) - # 6. Reset file pointer and return + # 5. Reset file pointer and return f.seek(0) return url - except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught + except (requests.RequestException, OSError, EsphomeError) as e: + # Mid-stream drop: keep the received bytes and retry this + # mirror from the current position — but only when the + # server gave a validator to resume against safely AND a + # total length to prove the stitched file complete (the + # length check above is the only verification here). _LOGGER.debug("Failed to download %s: %s", url, str(e)) - failures.append((url, e)) + if validator and expected_total: + offset = f.tell() + else: + _LOGGER.debug( + "Restarting %s from zero: cannot prove a " + "resumed file complete (validator=%s, total=%s)", + url, + validator is not None, + expected_total, + ) + offset = 0 + if attempt == _MIRROR_ATTEMPTS - 1: + failures.append((url, e)) - # 7. Report every attempted URL if all mirrors failed. Falling back - # past an early mirror is normal (e.g. only one of the framework URL - # templates matches a given version's tag), so raising only the last - # error would hide the failure that actually matters. - if failures: - attempts = "".join( - f"\n {url}\n {_failure_reason(e)}" for url, e in failures - ) - attempts += "".join( - f"\n {mirror}\n {reason}" for mirror, reason in skipped - ) - raise EsphomeError( - f"Failed to download from all mirrors:{attempts}" - ) from failures[0][1] - if skipped: - details = "".join( - f"\n {mirror}\n {reason}" for mirror, reason in skipped - ) - raise EsphomeError( - f"No mirror URL template matched the provided substitutions:{details}" - ) - raise ValueError("download_from_mirrors called with an empty mirrors list") + # 6. Report every attempted URL if all mirrors failed. Falling back + # past an early mirror is normal (e.g. only one of the framework URL + # templates matches a given version's tag), so raising only the last + # error would hide the failure that actually matters. + if failures: + attempts = "".join( + f"\n {url}\n {_failure_reason(e)}" for url, e in failures + ) + attempts += "".join(f"\n {mirror}\n {reason}" for mirror, reason in skipped) + raise EsphomeError( + f"Failed to download from all mirrors:{attempts}" + ) from failures[0][1] + if skipped: + details = "".join(f"\n {mirror}\n {reason}" for mirror, reason in skipped) + raise EsphomeError( + f"No mirror URL template matched the provided substitutions:{details}" + ) + raise ValueError("download_from_mirrors called with an empty mirrors list") diff --git a/tests/unit_tests/fixtures/idf_tools_stub/idf_tools.py b/tests/unit_tests/fixtures/idf_tools_stub/idf_tools.py new file mode 100644 index 0000000000..aeff24fb57 --- /dev/null +++ b/tests/unit_tests/fixtures/idf_tools_stub/idf_tools.py @@ -0,0 +1,120 @@ +"""Minimal idf_tools stand-in for get_tool_downloads.py tests.""" + +from collections.abc import Iterable +import os + +CURRENT_PLATFORM = "linux-amd64" +TOOLS_FILE = "tools/tools.json" + + +class ToolBinaryError(RuntimeError): + pass + + +class _G: + idf_path: str | None = None + idf_tools_path: str | None = None + tools_json: str | None = None + + +g = _G() + + +class IDFEnv: + @classmethod + def get_idf_env(cls) -> "IDFEnv": + return cls() + + +def add_and_check_targets(idf_env_obj: IDFEnv, targets_str: str) -> list[str]: + return targets_str.split(",") + + +class _Download: + def __init__(self, url: str, size: int, sha256: str, rename_dist: str = "") -> None: + self.url = url + self.size = size + self.sha256 = sha256 + self.rename_dist = rename_dist + + +class _Version: + def __init__(self, download: _Download | None) -> None: + self._download = download + + def get_download_for_platform(self, platform_name: str) -> _Download | None: + return self._download + + +class _Tool: + def __init__( + self, + versions: dict[str, _Version], + recommended: str | None, + installed: Iterable[str] = (), + broken: bool = False, + ) -> None: + self.versions = versions + self._recommended = recommended + self.versions_installed = list(installed) + self._broken = broken + + def compatible_with_platform(self) -> bool: + return True + + def get_recommended_version(self) -> str | None: + return self._recommended + + def find_installed_versions(self) -> None: + if self._broken: + raise ToolBinaryError("broken binary") + + +_TOOLS = { + "cmake": _Tool( + {"3.30.2": _Version(_Download("https://gh.test/cmake.tar.gz", 11, "aa"))}, + "3.30.2", + ), + "ninja": _Tool( + { + "1.12.1": _Version( + _Download("https://gh.test/ninja-mac.zip", 22, "bb", "ninja-v1.zip") + ) + }, + "1.12.1", + ), + "installed-tool": _Tool( + {"1.0": _Version(_Download("https://gh.test/x.tar.gz", 33, "cc"))}, + "1.0", + installed=["1.0"], + ), + "broken-tool": _Tool( + {"2.0": _Version(_Download("https://gh.test/y.tar.gz", 44, "dd"))}, + "2.0", + broken=True, + ), + "no-recommended-tool": _Tool({"3.0": _Version(None)}, None), + "no-download-tool": _Tool({"4.0": _Version(None)}, "4.0"), +} + + +def load_tools_info() -> dict[str, _Tool]: + return _TOOLS + + +def expand_tools_arg( + tools_spec: list[str], overall_tools: dict[str, _Tool], targets: list[str] +) -> list[str]: + if "required" in tools_spec: + return list(overall_tools) + return [t for t in tools_spec if "@" not in t] + [t for t in tools_spec if "@" in t] + + +def get_idf_download_url_apply_mirrors( + args: object = None, download_url: str = "" +) -> str: + print(f"Changed download URL: {download_url}") # noise on stdout, like idf_tools + prefix = os.environ.get("TEST_MIRROR_PREFIX") + if prefix: + return prefix + download_url + return download_url diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index 6127948d8c..e1408e538a 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -3,10 +3,14 @@ # pylint: disable=protected-access from contextlib import contextmanager +import importlib.util import io import json import logging +import os from pathlib import Path +import runpy +import subprocess import sys import tarfile from types import SimpleNamespace @@ -27,6 +31,7 @@ from esphome.espidf.framework import ( _parse_git_source, _patch_tools_json_demote_openocd, _patch_tools_json_for_linux_arm64, + _prefetch_idf_tool_archives, _windows_long_paths_enabled, _write_idf_version_txt, _write_stamp, @@ -311,6 +316,21 @@ class TestTarExtractHardLinkPrefixStripping: _IDF_VERSION = "5.1.2" +def _fake_download_from_mirrors( + mirrors: list[str], + substitutions: dict[str, str], + target: object, + **kwargs: object, +) -> str: + """Stand-in for download_from_mirrors that creates path targets, since + the framework code opens the downloaded tarball afterwards.""" + if isinstance(target, (str, os.PathLike)): + path = Path(target) + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + return "https://example.com/idf.tar.xz" + + @pytest.fixture def espidf_mocks(setup_core: Path): """Patch the heavy I/O of check_esp_idf_install and pre-create the framework dir.""" @@ -321,7 +341,7 @@ def espidf_mocks(setup_core: Path): patch("esphome.espidf.framework.rmdir") as rmdir_mock, patch( "esphome.espidf.framework.download_from_mirrors", - return_value="https://example.com/idf.tar.xz", + side_effect=_fake_download_from_mirrors, ) as download, patch("esphome.espidf.framework.archive_extract_all") as extract, patch("esphome.espidf.framework.create_venv") as venv, @@ -333,6 +353,7 @@ def espidf_mocks(setup_core: Path): patch("esphome.espidf.framework._write_idf_version_txt"), patch("esphome.espidf.framework._patch_tools_json_for_linux_arm64"), patch("esphome.espidf.framework._patch_tools_json_demote_openocd"), + patch("esphome.espidf.framework._prefetch_idf_tool_archives"), patch("esphome.espidf.framework._write_stamp"), patch("esphome.espidf.framework._check_stamp", return_value=True), patch("esphome.espidf.framework._get_idf_version", return_value=_IDF_VERSION), @@ -413,6 +434,20 @@ def test_check_esp_idf_install_already_installed(espidf_mocks: SimpleNamespace) espidf_mocks.venv.assert_not_called() +def test_corrupt_tarball_removed_when_extraction_fails( + espidf_mocks: SimpleNamespace, +) -> None: + """A tarball that fails to extract (e.g. torn by an unclean shutdown) is + deleted so the next run re-downloads instead of failing forever.""" + espidf_mocks.extract.side_effect = RuntimeError("xz: unexpected end of input") + tarball = get_idf_tools_path() / "dist" / f"esp-idf-{_IDF_VERSION}.tar.xz" + + with pytest.raises(RuntimeError, match="unexpected end of input"): + check_esp_idf_install(_IDF_VERSION, force=True) + + assert not tarball.exists() + + def test_check_esp_idf_install_framework_failure(espidf_mocks: SimpleNamespace) -> None: """A failing idf_tools install raises.""" espidf_mocks.run_ok.side_effect = [False] @@ -636,6 +671,286 @@ def test_patch_tools_json_already_patched_is_noop(tmp_path: Path) -> None: assert tools_json.read_text(encoding="utf-8") == before +# --------------------------------------------------------------------------- +# _prefetch_idf_tool_archives +# --------------------------------------------------------------------------- + + +_PREFETCH_JSON = json.dumps( + [ + { + "name": "cmake@3.30.2", + "url": "https://example.com/cmake.tar.gz", + "size": 123, + "sha256": "ab" * 32, + "dest": "cmake-3.30.2.tar.gz", + }, + { + "name": "ninja@1.12.1", + "url": "https://example.com/ninja.zip", + "size": 45, + "sha256": "cd" * 32, + "dest": "ninja.zip", + }, + ] +) + + +def test_prefetch_downloads_each_archive_with_resume(tmp_path: Path) -> None: + with ( + patch( + "esphome.espidf.framework.run_command", + return_value=(True, _PREFETCH_JSON, ""), + ), + patch("esphome.espidf.framework.download_with_resume") as download, + patch("esphome.espidf.framework.get_system_python_path", return_value="python"), + ): + _prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None) + + dist = get_idf_tools_path() / "dist" + assert download.call_count == 2 + assert download.call_args_list[0][0] == ( + "https://example.com/cmake.tar.gz", + dist / "cmake-3.30.2.tar.gz", + ) + assert download.call_args_list[0][1] == {"sha256": "ab" * 32, "size": 123} + + +def test_prefetch_skips_already_downloaded_archives(tmp_path: Path) -> None: + dist = get_idf_tools_path() / "dist" + dist.mkdir(parents=True) + (dist / "cmake-3.30.2.tar.gz").write_bytes(b"cached") + with ( + patch( + "esphome.espidf.framework.run_command", + return_value=(True, _PREFETCH_JSON, ""), + ), + patch("esphome.espidf.framework.download_with_resume") as download, + patch("esphome.espidf.framework.get_system_python_path", return_value="python"), + ): + _prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None) + + # only the missing archive is downloaded + assert download.call_count == 1 + assert download.call_args[0][1] == dist / "ninja.zip" + + +@pytest.mark.parametrize( + ("run_result", "download_error", "expected_log"), + [ + ((False, "", "script exploded"), None, "tool downloads"), # script failure + ((True, "{ not json", ""), None, "prefetch failed"), # unparsable output + ( + (True, _PREFETCH_JSON, ""), + OSError("network down"), + "Could not prefetch", + ), # download failure + ], +) +def test_prefetch_failures_never_raise( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + run_result: tuple[bool, str, str], + download_error: Exception | None, + expected_log: str, +) -> None: + """The prefetch is best-effort; idf_tools downloads whatever is missing.""" + with ( + patch("esphome.espidf.framework.run_command", return_value=run_result), + patch( + "esphome.espidf.framework.download_with_resume", + side_effect=download_error, + ), + patch("esphome.espidf.framework.get_system_python_path", return_value="python"), + ): + _prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None) + + assert expected_log in caplog.text + + +def test_prefetch_one_failed_archive_does_not_stop_the_rest( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A single archive failing its download must not abort the prefetch of + the remaining archives.""" + with ( + patch( + "esphome.espidf.framework.run_command", + return_value=(True, _PREFETCH_JSON, ""), + ), + patch( + "esphome.espidf.framework.download_with_resume", + side_effect=[OSError("network down"), None], + ) as download, + patch("esphome.espidf.framework.get_system_python_path", return_value="python"), + ): + _prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None) + + assert download.call_count == 2 + assert "Could not prefetch cmake@3.30.2" in caplog.text + + +def test_prefetch_passes_targets_and_tools_to_script(tmp_path: Path) -> None: + with ( + patch( + "esphome.espidf.framework.run_command", return_value=(True, "[]", "") + ) as run, + patch("esphome.espidf.framework.get_system_python_path", return_value="python"), + ): + _prefetch_idf_tool_archives( + tmp_path, "esp32,esp32c3", ["required", "cmake"], {"IDF_TOOLS_PATH": "/x"} + ) + + cmd = run.call_args[0][0] + assert cmd[-3:] == ["esp32,esp32c3", "required", "cmake"] + assert cmd[1].endswith("get_tool_downloads.py") + # the script inherits the caller's env plus the framework tools PYTHONPATH + env = run.call_args[1]["env"] + assert env["IDF_TOOLS_PATH"] == "/x" + assert env["PYTHONPATH"] == str(tmp_path / "tools") + + +def test_framework_install_prefetches_before_installer( + espidf_mocks: SimpleNamespace, +) -> None: + """The prefetch runs before idf_tools.py install so the installer finds + the archives already in dist/.""" + calls: list[str] = [] + with ( + patch( + "esphome.espidf.framework._prefetch_idf_tool_archives", + side_effect=lambda *a, **k: calls.append("prefetch"), + ), + ): + espidf_mocks.run_ok.side_effect = lambda *a, **k: ( + calls.append("install") or True + ) + check_esp_idf_install(_IDF_VERSION, force=True) + + assert calls.index("prefetch") < calls.index("install") + + +# --------------------------------------------------------------------------- +# get_tool_downloads.py (against the stub idf_tools module in fixtures/) +# --------------------------------------------------------------------------- + + +_IDF_TOOLS_STUB_DIR = Path(__file__).parent / "fixtures" / "idf_tools_stub" + + +def _run_downloads_script( + tmp_path: Path, *args: str, env_extra: dict[str, str] | None = None +) -> subprocess.CompletedProcess[str]: + """Run the real get_tool_downloads.py against the stub idf_tools module.""" + script = Path(__file__).parents[2] / "esphome" / "espidf" / "get_tool_downloads.py" + env = os.environ | { + "PYTHONPATH": str(_IDF_TOOLS_STUB_DIR), + "IDF_TOOLS_PATH": str(tmp_path / "tp"), + } + if env_extra: + env |= env_extra + return subprocess.run( + [sys.executable, str(script), str(tmp_path / "fw"), *args], + capture_output=True, + text=True, + env=env, + check=False, + ) + + +def test_get_tool_downloads_lists_missing_tools(tmp_path: Path) -> None: + """Installed versions are skipped, tools that fail their binary check are + still listed, rename_dist decides the dist filename, and idf_tools' stdout + chatter stays off the JSON channel.""" + result = _run_downloads_script(tmp_path, "esp32", "required") + + assert result.returncode == 0, result.stderr + downloads = {d["name"]: d for d in json.loads(result.stdout)} + # installed-tool@1.0 is already installed and must not be listed + assert set(downloads) == {"cmake@3.30.2", "ninja@1.12.1", "broken-tool@2.0"} + assert downloads["cmake@3.30.2"]["dest"] == "cmake.tar.gz" + assert downloads["cmake@3.30.2"]["size"] == 11 + assert downloads["cmake@3.30.2"]["sha256"] == "aa" + # rename_dist overrides the URL basename + assert downloads["ninja@1.12.1"]["dest"] == "ninja-v1.zip" + # the stub prints informational lines; they must be on stderr + assert "Changed download URL" in result.stderr + + +def test_get_tool_downloads_applies_mirror_rewrite(tmp_path: Path) -> None: + result = _run_downloads_script( + tmp_path, + "esp32", + "required", + env_extra={"TEST_MIRROR_PREFIX": "https://mirror.test/"}, + ) + + assert result.returncode == 0, result.stderr + downloads = json.loads(result.stdout) + assert all(d["url"].startswith("https://mirror.test/") for d in downloads) + + +def _run_downloads_inprocess( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + *args: str, +) -> list[dict]: + """Execute get_tool_downloads.py in-process against the stub idf_tools. + + Unlike the subprocess variant this runs under coverage, exercising the + script's own lines. + """ + spec = importlib.util.spec_from_file_location( + "idf_tools", _IDF_TOOLS_STUB_DIR / "idf_tools.py" + ) + stub = importlib.util.module_from_spec(spec) + spec.loader.exec_module(stub) + monkeypatch.setitem(sys.modules, "idf_tools", stub) + monkeypatch.setenv("IDF_TOOLS_PATH", str(tmp_path / "tp")) + script = Path(__file__).parents[2] / "esphome" / "espidf" / "get_tool_downloads.py" + monkeypatch.setattr(sys, "argv", [str(script), str(tmp_path / "fw"), *args]) + runpy.run_path(str(script)) + return json.loads(capsys.readouterr().out) + + +def test_get_tool_downloads_inprocess_full_flow( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """In-process run covering the whole script: required expansion, + installed/broken tools, rename_dist, and version pinning via tool@version.""" + downloads = { + d["name"]: d + for d in _run_downloads_inprocess( + tmp_path, monkeypatch, capsys, "esp32", "required" + ) + } + assert set(downloads) == {"cmake@3.30.2", "ninja@1.12.1", "broken-tool@2.0"} + assert downloads["ninja@1.12.1"]["dest"] == "ninja-v1.zip" + assert downloads["cmake@3.30.2"]["url"] == "https://gh.test/cmake.tar.gz" + + +def test_get_tool_downloads_inprocess_explicit_tool_specs( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Explicit tool names and tool@version specs resolve; unknown tools and + unknown versions are skipped.""" + downloads = _run_downloads_inprocess( + tmp_path, + monkeypatch, + capsys, + "esp32", + "cmake@3.30.2", + "no-such-tool", + "cmake@9.9.9", + ) + assert [d["name"] for d in downloads] == ["cmake@3.30.2"] + + # --------------------------------------------------------------------------- # _patch_tools_json_demote_openocd (openocd-esp32 made optional) # --------------------------------------------------------------------------- diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index e662d2d015..b8aa19d6ae 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -2,8 +2,10 @@ # pylint: disable=protected-access +import hashlib import importlib.util import io +import json import logging import os from pathlib import Path @@ -16,6 +18,7 @@ import zipfile import pytest import requests as req +from esphome import framework_helpers from esphome.core import EsphomeError from esphome.framework_helpers import ( _7z_extract_all, @@ -26,6 +29,7 @@ from esphome.framework_helpers import ( archive_extract_all, create_venv, download_from_mirrors, + download_with_resume, get_project_compile_flags, get_project_cxx_compile_flags, get_project_link_flags, @@ -507,7 +511,7 @@ class TestArchiveExtractAll: # --------------------------------------------------------------------------- -# download_from_mirrors +# download_from_mirrors / download_with_resume # --------------------------------------------------------------------------- @@ -515,6 +519,8 @@ def _mock_response(content: bytes, ok: bool = True) -> MagicMock: r = MagicMock() r.__enter__.return_value = r r.__exit__.return_value = False + r.status_code = 200 + r.ok = ok if ok: r.raise_for_status.return_value = None else: @@ -524,6 +530,563 @@ def _mock_response(content: bytes, ok: bool = True) -> MagicMock: return r +def _interrupted_response(content: bytes, etag: str | None = None) -> MagicMock: + """A response whose body yields ``content`` and then drops mid-stream. + + ``etag`` makes the response resumable: without a validator the retry + logic restarts from zero rather than stitching unverified bytes. + """ + + def body(chunk_size): + yield content + raise req.exceptions.ChunkedEncodingError("connection dropped") + + r = _mock_response(b"") + if etag is not None: + r.headers = {**r.headers, "ETag": etag} + r.iter_content.side_effect = body + return r + + +def _resumed_response(content: bytes) -> MagicMock: + """An HTTP 206 response continuing an interrupted download.""" + r = _mock_response(content) + r.status_code = 206 + return r + + +class TestOpenRanged: + def test_fresh_download_sends_no_range(self) -> None: + with patch("requests.get", return_value=_mock_response(b"x")) as mock_get: + resp, offset = framework_helpers._open_ranged("https://e.com/f", 0, 30) + assert offset == 0 + assert mock_get.call_args[1]["headers"] == {} + assert resp is mock_get.return_value + + def test_resume_kept_on_206(self) -> None: + with patch("requests.get", return_value=_resumed_response(b"x")): + _, offset = framework_helpers._open_ranged("https://e.com/f", 7, 30) + assert offset == 7 + + def test_resume_downgraded_on_200(self) -> None: + """A server that ignores the Range header forces a restart.""" + with patch("requests.get", return_value=_mock_response(b"x")): + _, offset = framework_helpers._open_ranged("https://e.com/f", 7, 30) + assert offset == 0 + + def test_http_error_closes_response_and_raises(self) -> None: + r = _mock_response(b"", ok=False) + with ( + patch("requests.get", return_value=r), + pytest.raises(req.HTTPError), + ): + framework_helpers._open_ranged("https://e.com/f", 0, 30) + r.close.assert_called_once() + + def test_connect_error_propagates(self) -> None: + with ( + patch("requests.get", side_effect=req.ConnectionError("refused")), + pytest.raises(req.ConnectionError), + ): + framework_helpers._open_ranged("https://e.com/f", 0, 30) + + +class TestDownloadWithResume: + def test_downloads_and_renames(self, tmp_path: Path) -> None: + dest = tmp_path / "tool.tar.gz" + with patch("requests.get", return_value=_mock_response(b"data")) as mock_get: + download_with_resume("https://example.com/t", dest) + assert dest.read_bytes() == b"data" + assert not (tmp_path / "tool.tar.gz.part").exists() + # a fresh download must not send a Range header + assert "Range" not in mock_get.call_args[1]["headers"] + + def test_mid_stream_drop_resumes_with_range(self, tmp_path: Path) -> None: + dest = tmp_path / "tool.tar.gz" + first = _interrupted_response(b"1234", etag='"v1"') + first.headers = {**first.headers, "content-length": "8"} + with patch( + "requests.get", + side_effect=[first, _resumed_response(b"5678")], + ) as mock_get: + download_with_resume("https://example.com/t", dest) + # earlier bytes were kept, remainder appended conditionally + assert dest.read_bytes() == b"12345678" + assert mock_get.call_args_list[1][1]["headers"] == { + "Range": "bytes=4-", + "If-Range": '"v1"', + } + + def test_unverifiable_drop_without_length_restarts(self, tmp_path: Path) -> None: + """A validator alone is not enough to stitch when nothing can prove + the stitched file complete (no sha/size and no content-length).""" + dest = tmp_path / "tool.tar.gz" + with patch( + "requests.get", + side_effect=[ + _interrupted_response(b"1234", etag='"v1"'), + _mock_response(b"full"), + ], + ) as mock_get: + download_with_resume("https://example.com/t", dest) + assert dest.read_bytes() == b"full" + assert "Range" not in mock_get.call_args_list[1][1]["headers"] + + def test_resumed_clean_but_short_body_discarded(self, tmp_path: Path) -> None: + """A resumed stream that ends cleanly but short of the advertised + total is rejected and re-downloaded, not promoted.""" + dest = tmp_path / "tool.tar.gz" + first = _interrupted_response(b"abcd", etag='"v1"') + first.headers = {**first.headers, "content-length": "8"} + # resume ends cleanly after only 2 of the 4 missing bytes + short = _resumed_response(b"ef") + full = _mock_response(b"abcdefgh") + full.headers = {**full.headers, "content-length": "8"} + with patch("requests.get", side_effect=[first, short, full]) as mock_get: + download_with_resume("https://example.com/t", dest) + assert dest.read_bytes() == b"abcdefgh" + # the short stitch was discarded; the final attempt started fresh + assert "Range" not in mock_get.call_args_list[2][1]["headers"] + + def test_unverifiable_drop_without_validator_restarts(self, tmp_path: Path) -> None: + """No sha/size and no server validator: the retry must not stitch.""" + dest = tmp_path / "tool.tar.gz" + with patch( + "requests.get", + side_effect=[_interrupted_response(b"1234"), _mock_response(b"full")], + ) as mock_get: + download_with_resume("https://example.com/t", dest) + assert dest.read_bytes() == b"full" + assert "Range" not in mock_get.call_args_list[1][1]["headers"] + + def test_resume_across_invocations_from_part_file(self, tmp_path: Path) -> None: + """A .part file left by a previous run is resumed, not restarted, + when sha/size verification will vouch for the stitched result.""" + dest = tmp_path / "tool.tar.gz" + (tmp_path / "tool.tar.gz.part").write_bytes(b"12345") + good = hashlib.sha256(b"12345678").hexdigest() + with patch("requests.get", return_value=_resumed_response(b"678")) as mock_get: + download_with_resume("https://example.com/t", dest, sha256=good, size=8) + assert dest.read_bytes() == b"12345678" + assert mock_get.call_args[1]["headers"] == {"Range": "bytes=5-"} + + def test_unverifiable_leftover_part_file_ignored(self, tmp_path: Path) -> None: + """Without sha/size there is no way to vouch for a cross-run stitch, + so a leftover part file starts over.""" + dest = tmp_path / "tool.tar.gz" + (tmp_path / "tool.tar.gz.part").write_bytes(b"12345") + with patch("requests.get", return_value=_mock_response(b"fresh")) as mock_get: + download_with_resume("https://example.com/t", dest) + assert dest.read_bytes() == b"fresh" + assert "Range" not in mock_get.call_args[1]["headers"] + + def test_server_without_range_support_restarts(self, tmp_path: Path) -> None: + """HTTP 200 in response to a Range request truncates and restarts.""" + dest = tmp_path / "tool.tar.gz" + (tmp_path / "tool.tar.gz.part").write_bytes(b"sta") + good = hashlib.sha256(b"fresh").hexdigest() + with patch("requests.get", return_value=_mock_response(b"fresh")) as mock_get: + download_with_resume("https://example.com/t", dest, sha256=good, size=5) + # the Range request was sent (verifiable resume) and downgraded + assert mock_get.call_args[1]["headers"] == {"Range": "bytes=3-"} + assert dest.read_bytes() == b"fresh" + + def test_size_only_leftover_part_restarts(self, tmp_path: Path) -> None: + """A size alone cannot detect a same-length content change on the + server, so a cross-run part without sha256 restarts from zero.""" + dest = tmp_path / "tool.tar.gz" + (tmp_path / "tool.tar.gz.part").write_bytes(b"12") + with patch("requests.get", return_value=_mock_response(b"1234")) as mock_get: + download_with_resume("https://example.com/t", dest, size=4) + assert "Range" not in mock_get.call_args[1]["headers"] + assert dest.read_bytes() == b"1234" + + def test_size_only_in_run_drop_resumes_with_validator(self, tmp_path: Path) -> None: + """Within a run the If-Range validator proves identity, so size-only + callers still resume mid-stream drops.""" + dest = tmp_path / "tool.tar.gz" + with patch( + "requests.get", + side_effect=[ + _interrupted_response(b"12", etag='"v1"'), + _resumed_response(b"34"), + ], + ) as mock_get: + download_with_resume("https://example.com/t", dest, size=4) + assert dest.read_bytes() == b"1234" + assert mock_get.call_args_list[1][1]["headers"] == { + "Range": "bytes=2-", + "If-Range": '"v1"', + } + + def test_unverifiable_download_logged( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + """No sha, no size, no content-length: the download is promoted with + a debug note (routine for e.g. the constraints host, so not a + warning) that completeness could not be verified.""" + dest = tmp_path / "tool.tar.gz" + with ( + caplog.at_level(logging.DEBUG, logger="esphome.framework_helpers"), + patch("requests.get", return_value=_mock_response(b"data")), + ): + download_with_resume("https://example.com/t", dest) + assert dest.read_bytes() == b"data" + assert "without any way to verify completeness" in caplog.text + + def test_416_promotes_complete_part_when_size_unknown(self, tmp_path: Path) -> None: + """sha256-only caller with a byte-complete part file: the server's + 416 confirms nothing is missing, verification promotes in place, and + the 416 must not loop as a retryable error.""" + dest = tmp_path / "tool.tar.gz" + (tmp_path / "tool.tar.gz.part").write_bytes(b"data") + good = hashlib.sha256(b"data").hexdigest() + r416 = _mock_response(b"", ok=False) + r416.status_code = 416 + with patch("requests.get", return_value=r416) as mock_get: + download_with_resume("https://example.com/t", dest, sha256=good) + assert mock_get.call_count == 1 + r416.close.assert_called_once() + assert dest.read_bytes() == b"data" + + def test_416_with_corrupt_part_discards_and_redownloads( + self, tmp_path: Path + ) -> None: + dest = tmp_path / "tool.tar.gz" + (tmp_path / "tool.tar.gz.part").write_bytes(b"bad!") + good = hashlib.sha256(b"data").hexdigest() + r416 = _mock_response(b"", ok=False) + r416.status_code = 416 + with patch( + "requests.get", side_effect=[r416, _mock_response(b"data")] + ) as mock_get: + download_with_resume("https://example.com/t", dest, sha256=good) + assert "Range" not in mock_get.call_args_list[1][1]["headers"] + assert dest.read_bytes() == b"data" + + def test_hash_mismatch_discards_and_retries(self, tmp_path: Path) -> None: + dest = tmp_path / "tool.tar.gz" + good = hashlib.sha256(b"good").hexdigest() + with patch( + "requests.get", + side_effect=[_mock_response(b"bad!"), _mock_response(b"good")], + ) as mock_get: + download_with_resume("https://example.com/t", dest, sha256=good, size=4) + assert dest.read_bytes() == b"good" + # the corrupt part file was discarded, so the retry starts fresh + assert "Range" not in mock_get.call_args_list[1][1]["headers"] + + def test_size_mismatch_discards_part(self, tmp_path: Path) -> None: + dest = tmp_path / "tool.tar.gz" + with ( + patch("requests.get", return_value=_mock_response(b"xx")), + pytest.raises(EsphomeError, match="after 2 attempts"), + ): + download_with_resume("https://example.com/t", dest, size=99, attempts=2) + assert not (tmp_path / "tool.tar.gz.part").exists() + assert not dest.exists() + + def test_attempts_exhausted_keeps_part_file(self, tmp_path: Path) -> None: + """Mid-stream failures keep the partial file so a later run resumes.""" + dest = tmp_path / "tool.tar.gz" + first = _interrupted_response(b"12", etag='"v1"') + first.headers = {**first.headers, "content-length": "4"} + second = _interrupted_response(b"34") + second.status_code = 206 + with ( + patch("requests.get", side_effect=[first, second]), + pytest.raises(EsphomeError, match="after 2 attempts"), + ): + download_with_resume("https://example.com/t", dest, attempts=2) + assert (tmp_path / "tool.tar.gz.part").read_bytes() == b"1234" + + def test_multiple_drops_accumulate_across_attempts(self, tmp_path: Path) -> None: + """Each attempt appends its bytes; three partial responses complete + the file.""" + dest = tmp_path / "tool.tar.gz" + first = _interrupted_response(b"ab", etag='"v1"') + first.headers = {**first.headers, "content-length": "6"} + second = _interrupted_response(b"cd") + second.status_code = 206 + third = _resumed_response(b"ef") + with patch( + "requests.get", + side_effect=[first, second, third], + ) as mock_get: + download_with_resume("https://example.com/t", dest) + assert dest.read_bytes() == b"abcdef" + expected = {"Range": "bytes=2-", "If-Range": '"v1"'} + assert mock_get.call_args_list[1][1]["headers"] == expected + expected = {"Range": "bytes=4-", "If-Range": '"v1"'} + assert mock_get.call_args_list[2][1]["headers"] == expected + + def test_connect_error_then_success(self, tmp_path: Path) -> None: + """A connect error (no response at all) consumes an attempt and the + next attempt succeeds.""" + dest = tmp_path / "tool.tar.gz" + with patch( + "requests.get", + side_effect=[req.ConnectionError("refused"), _mock_response(b"data")], + ): + download_with_resume("https://example.com/t", dest) + assert dest.read_bytes() == b"data" + + def test_http_error_keeps_part_file(self, tmp_path: Path) -> None: + """A transient HTTP error (e.g. 503) must not discard resume state.""" + dest = tmp_path / "tool.tar.gz" + (tmp_path / "tool.tar.gz.part").write_bytes(b"keep") + error = _mock_response(b"", ok=False) + error.status_code = 503 + with ( + patch("requests.get", return_value=error), + pytest.raises(EsphomeError, match="after 1 attempts"), + ): + download_with_resume("https://example.com/t", dest, attempts=1) + assert (tmp_path / "tool.tar.gz.part").read_bytes() == b"keep" + + def test_creates_missing_parent_directories(self, tmp_path: Path) -> None: + dest = tmp_path / "dist" / "nested" / "tool.tar.gz" + with patch("requests.get", return_value=_mock_response(b"data")): + download_with_resume("https://example.com/t", dest) + assert dest.read_bytes() == b"data" + + def test_verifies_both_size_and_sha(self, tmp_path: Path) -> None: + dest = tmp_path / "tool.tar.gz" + good = hashlib.sha256(b"data").hexdigest() + with patch("requests.get", return_value=_mock_response(b"data")): + download_with_resume("https://example.com/t", dest, sha256=good, size=4) + assert dest.read_bytes() == b"data" + + def test_corrupt_partial_resumed_then_discarded_then_redownloaded( + self, tmp_path: Path + ) -> None: + """The full recovery cycle for a corrupted partial download: the + resume completes it, verification fails, the poisoned part file is + discarded, and the next attempt re-downloads from scratch.""" + dest = tmp_path / "tool.tar.gz" + # a previous run left a corrupted 4-byte prefix behind + (tmp_path / "tool.tar.gz.part").write_bytes(b"BAD!") + good = hashlib.sha256(b"data66").hexdigest() + with patch( + "requests.get", + side_effect=[ + _resumed_response(b"66"), # resume "completes" the bad part + _mock_response(b"data66"), # clean retry from zero + ], + ) as mock_get: + download_with_resume("https://example.com/t", dest, sha256=good, size=6) + # first attempt resumed at the corrupt offset, failed verification; + # second attempt started fresh (no Range header) and succeeded + assert mock_get.call_args_list[0][1]["headers"] == {"Range": "bytes=4-"} + assert "Range" not in mock_get.call_args_list[1][1]["headers"] + assert dest.read_bytes() == b"data66" + assert not (tmp_path / "tool.tar.gz.part").exists() + + def test_existing_dest_passing_verification_kept(self, tmp_path: Path) -> None: + """A dest completed by an earlier run is reused without any request.""" + dest = tmp_path / "tool.tar.gz" + dest.write_bytes(b"data") + good = hashlib.sha256(b"data").hexdigest() + with patch("requests.get") as mock_get: + download_with_resume("https://example.com/t", dest, sha256=good, size=4) + mock_get.assert_not_called() + assert dest.read_bytes() == b"data" + + @pytest.mark.parametrize( + "stale", + [ + pytest.param(b"corrupt!", id="wrong-size"), + pytest.param(b"bad!", id="right-size-wrong-hash"), + ], + ) + def test_existing_dest_failing_verification_redownloaded( + self, tmp_path: Path, stale: bytes + ) -> None: + dest = tmp_path / "tool.tar.gz" + dest.write_bytes(stale) + good = hashlib.sha256(b"data").hexdigest() + with patch("requests.get", return_value=_mock_response(b"data")): + download_with_resume("https://example.com/t", dest, sha256=good, size=4) + assert dest.read_bytes() == b"data" + + def test_existing_dest_with_size_only_kept(self, tmp_path: Path) -> None: + dest = tmp_path / "tool.tar.gz" + dest.write_bytes(b"data") + with patch("requests.get") as mock_get: + download_with_resume("https://example.com/t", dest, size=4) + mock_get.assert_not_called() + + def test_existing_dest_with_sha_only_kept(self, tmp_path: Path) -> None: + """sha-only verification also authorizes reusing a completed dest.""" + dest = tmp_path / "tool.tar.gz" + dest.write_bytes(b"data") + good = hashlib.sha256(b"data").hexdigest() + with patch("requests.get") as mock_get: + download_with_resume("https://example.com/t", dest, sha256=good) + mock_get.assert_not_called() + + def test_meta_write_failure_is_best_effort(self, tmp_path: Path) -> None: + """A failure to persist the resume sidecar must not fail the + download itself.""" + dest = tmp_path / "f.tar.xz" + first = _mock_response(b"data") + first.headers = {**first.headers, "ETag": '"v1"', "content-length": "4"} + with ( + patch("requests.get", return_value=first), + patch.object(Path, "write_text", side_effect=OSError("read-only")), + ): + download_with_resume("https://example.com/f", dest) + assert dest.read_bytes() == b"data" + + def test_meta_sidecar_written_and_removed(self, tmp_path: Path) -> None: + """The validator sidecar appears while downloading and is cleaned up + with the promotion.""" + dest = tmp_path / "f.tar.xz" + meta = tmp_path / "f.tar.xz.part.meta" + seen: list[bool] = [] + first = _interrupted_response(b"1234", etag='"v1"') + first.headers = {**first.headers, "content-length": "8"} + responses = [first] + + def get(*args: object, **kwargs: object) -> MagicMock: + if responses: + return responses.pop(0) + # the resume request: the sidecar written by the first response + # must already be on disk at this point + seen.append(meta.is_file()) + return _resumed_response(b"5678") + + with patch("requests.get", side_effect=get): + download_with_resume("https://example.com/f", dest) + assert dest.read_bytes() == b"12345678" + assert seen == [True] # sidecar existed during the resume attempt + assert not meta.exists() # cleaned up on success + + def test_locked_promotion_keeps_verified_part(self, tmp_path: Path) -> None: + """A rename that stays blocked (e.g. a long-lived Windows file lock) + must not delete the verified download; the next attempt retries just + the rename without touching the network.""" + dest = tmp_path / "tool.tar.gz" + good = hashlib.sha256(b"data").hexdigest() + with ( + patch("requests.get", return_value=_mock_response(b"data")) as mock_get, + patch( + "esphome.framework_helpers._rename_with_retry", + side_effect=[PermissionError("locked"), None], + ) as rename, + ): + download_with_resume("https://example.com/t", dest, sha256=good, size=4) + # one download; the second attempt only redid the rename + assert mock_get.call_count == 1 + assert rename.call_count == 2 + + def test_locked_promotion_exhausted_keeps_part_for_next_run( + self, tmp_path: Path + ) -> None: + dest = tmp_path / "tool.tar.gz" + good = hashlib.sha256(b"data").hexdigest() + with ( + patch("requests.get", return_value=_mock_response(b"data")), + patch( + "esphome.framework_helpers._rename_with_retry", + side_effect=PermissionError("locked"), + ), + pytest.raises(EsphomeError, match="after 1 attempts"), + ): + download_with_resume( + "https://example.com/t", dest, sha256=good, size=4, attempts=1 + ) + # the verified bytes survive for the next run + assert (tmp_path / "tool.tar.gz.part").read_bytes() == b"data" + + def test_meta_sidecar_resumes_across_runs_without_sha(self, tmp_path: Path) -> None: + """A later run resumes an unfinished download using the validator the + first run stored — the cross-run fix for the framework tarball.""" + dest = tmp_path / "f.tar.xz" + (tmp_path / "f.tar.xz.part").write_bytes(b"1234") + (tmp_path / "f.tar.xz.part.meta").write_text( + json.dumps( + {"url": "https://example.com/f", "validator": '"v1"', "total": 8} + ) + ) + with patch("requests.get", return_value=_resumed_response(b"5678")) as mock_get: + download_with_resume("https://example.com/f", dest) + assert dest.read_bytes() == b"12345678" + assert mock_get.call_args[1]["headers"] == { + "Range": "bytes=4-", + "If-Range": '"v1"', + } + + def test_meta_sidecar_for_other_url_ignored(self, tmp_path: Path) -> None: + """Metadata from a different mirror URL must not authorize a stitch.""" + dest = tmp_path / "f.tar.xz" + (tmp_path / "f.tar.xz.part").write_bytes(b"1234") + (tmp_path / "f.tar.xz.part.meta").write_text( + json.dumps({"url": "https://other.com/f", "validator": '"v1"', "total": 8}) + ) + full = _mock_response(b"12345678") + with patch("requests.get", return_value=full) as mock_get: + download_with_resume("https://example.com/f", dest) + assert "Range" not in mock_get.call_args[1]["headers"] + assert dest.read_bytes() == b"12345678" + + def test_complete_part_file_promoted_without_network(self, tmp_path: Path) -> None: + """A .part holding every byte (killed between write and rename) is + verified in place and promoted; no request is made, so no 416 loop.""" + dest = tmp_path / "tool.tar.gz" + (tmp_path / "tool.tar.gz.part").write_bytes(b"data") + good = hashlib.sha256(b"data").hexdigest() + with patch("requests.get") as mock_get: + download_with_resume("https://example.com/t", dest, sha256=good, size=4) + mock_get.assert_not_called() + assert dest.read_bytes() == b"data" + + def test_complete_but_corrupt_part_file_redownloaded(self, tmp_path: Path) -> None: + """A full-size .part with a wrong hash is discarded and re-downloaded + from scratch.""" + dest = tmp_path / "tool.tar.gz" + (tmp_path / "tool.tar.gz.part").write_bytes(b"bad!") + good = hashlib.sha256(b"data").hexdigest() + with patch("requests.get", return_value=_mock_response(b"data")) as mock_get: + download_with_resume("https://example.com/t", dest, sha256=good, size=4) + assert "Range" not in mock_get.call_args[1]["headers"] + assert dest.read_bytes() == b"data" + + def test_oversized_part_file_discarded(self, tmp_path: Path) -> None: + """A .part larger than the expected size fails verification and is + replaced by a fresh download.""" + dest = tmp_path / "tool.tar.gz" + (tmp_path / "tool.tar.gz.part").write_bytes(b"toolong") + good = hashlib.sha256(b"data").hexdigest() + with patch("requests.get", return_value=_mock_response(b"data")): + download_with_resume("https://example.com/t", dest, sha256=good, size=4) + assert dest.read_bytes() == b"data" + + def test_malformed_content_length_degrades_gracefully(self, tmp_path: Path) -> None: + """A garbage Content-Length must not crash the attempt; it means + "unknown", so a drop restarts instead of stitching and a clean + download still succeeds.""" + dest = tmp_path / "tool.tar.gz" + first = _interrupted_response(b"1234", etag='"v1"') + first.headers = {**first.headers, "content-length": "explode"} + retry = _mock_response(b"full") + retry.headers = {**retry.headers, "content-length": "explode"} + with patch("requests.get", side_effect=[first, retry]) as mock_get: + download_with_resume("https://example.com/t", dest) + assert dest.read_bytes() == b"full" + # unknown length -> completeness unprovable -> no resume attempted + assert "Range" not in mock_get.call_args_list[1][1]["headers"] + + def test_zero_byte_part_file_sends_no_range(self, tmp_path: Path) -> None: + """An empty leftover part file is a fresh download, not a resume.""" + dest = tmp_path / "tool.tar.gz" + (tmp_path / "tool.tar.gz.part").write_bytes(b"") + with patch("requests.get", return_value=_mock_response(b"data")) as mock_get: + download_with_resume("https://example.com/t", dest) + assert mock_get.call_args[1]["headers"] == {} + assert dest.read_bytes() == b"data" + + class TestDownloadFromMirrors: def test_success_returns_url_and_writes_content(self, tmp_path: Path) -> None: target = tmp_path / "out.bin" @@ -640,7 +1203,8 @@ class TestDownloadFromMirrors: ei.value ) - def test_falls_back_to_second_mirror(self, tmp_path: Path) -> None: + def test_falls_back_to_second_mirror(self) -> None: + buf = io.BytesIO() with patch( "requests.get", side_effect=[_mock_response(b"", ok=False), _mock_response(b"second")], @@ -648,14 +1212,152 @@ class TestDownloadFromMirrors: url = download_from_mirrors( ["https://mirror1.com/f", "https://mirror2.com/f"], {}, - tmp_path / "out.bin", + buf, ) assert url == "https://mirror2.com/f" - assert (tmp_path / "out.bin").read_bytes() == b"second" + assert buf.getvalue() == b"second" - def test_all_mirrors_fail_raises_error_listing_every_attempt( - self, tmp_path: Path - ) -> None: + def test_mid_stream_drop_resumes_same_mirror(self) -> None: + """A mid-stream failure retries the same mirror with Range and + If-Range headers, keeping the bytes already received, before falling + to the next.""" + first = _interrupted_response(b"1234", etag='"v1"') + first.headers = {**first.headers, "content-length": "8"} + buf = io.BytesIO() + with patch( + "requests.get", + side_effect=[first, _resumed_response(b"5678")], + ) as mock_get: + url = download_from_mirrors( + ["https://mirror1.com/f", "https://mirror2.com/f"], + {}, + buf, + ) + assert url == "https://mirror1.com/f" + assert buf.getvalue() == b"12345678" + assert mock_get.call_count == 2 + assert mock_get.call_args_list[1][0][0] == "https://mirror1.com/f" + # the resume is conditional on the content being unchanged + assert mock_get.call_args_list[1][1]["headers"] == { + "Range": "bytes=4-", + "If-Range": '"v1"', + } + + def test_mid_stream_drop_without_validator_restarts(self) -> None: + """A server offering no ETag/Last-Modified cannot be resumed safely; + the retry restarts from zero instead of stitching unverified bytes.""" + buf = io.BytesIO() + with patch( + "requests.get", + side_effect=[_interrupted_response(b"1234"), _mock_response(b"full")], + ) as mock_get: + download_from_mirrors(["https://mirror1.com/f"], {}, buf) + assert buf.getvalue() == b"full" + assert "Range" not in mock_get.call_args_list[1][1]["headers"] + + def test_drop_after_last_byte_recovers_via_416(self) -> None: + """A connection drop after the final body byte leaves a complete file; + the retry's 416 answer plus the length check turn it into success + instead of a wasted refetch.""" + first = _interrupted_response(b"1234", etag='"v1"') + first.headers = {**first.headers, "content-length": "4"} + r416 = _mock_response(b"", ok=False) + r416.status_code = 416 + buf = io.BytesIO() + with patch("requests.get", side_effect=[first, r416]) as mock_get: + url = download_from_mirrors(["https://mirror1.com/f"], {}, buf) + assert url == "https://mirror1.com/f" + assert buf.getvalue() == b"1234" + assert mock_get.call_count == 2 + + def test_mirror_drop_without_length_restarts(self) -> None: + """With no content-length there is no way to prove a stitched file + complete, so the retry restarts even though a validator exists.""" + buf = io.BytesIO() + with patch( + "requests.get", + side_effect=[ + _interrupted_response(b"1234", etag='"v1"'), + _mock_response(b"full"), + ], + ) as mock_get: + download_from_mirrors(["https://mirror1.com/f"], {}, buf) + assert buf.getvalue() == b"full" + assert "Range" not in mock_get.call_args_list[1][1]["headers"] + + def test_path_target_resumes_across_runs(self, tmp_path: Path) -> None: + """A path target routes through download_with_resume: a part file and + metadata from a previous run resume instead of restarting.""" + dest = tmp_path / "idf.tar.xz" + (tmp_path / "idf.tar.xz.part").write_bytes(b"1234") + (tmp_path / "idf.tar.xz.part.meta").write_text( + json.dumps( + {"url": "https://mirror1.com/f", "validator": '"v1"', "total": 8} + ) + ) + with patch("requests.get", return_value=_resumed_response(b"5678")) as mock_get: + url = download_from_mirrors(["https://mirror1.com/f"], {}, dest) + assert url == "https://mirror1.com/f" + assert dest.read_bytes() == b"12345678" + assert mock_get.call_args[1]["headers"] == { + "Range": "bytes=4-", + "If-Range": '"v1"', + } + + def test_path_target_falls_back_to_next_mirror(self, tmp_path: Path) -> None: + dest = tmp_path / "idf.tar.xz" + with patch( + "requests.get", + side_effect=[req.ConnectionError("down"), _mock_response(b"data")], + ): + url = download_from_mirrors( + ["https://mirror1.com/f", "https://mirror2.com/f"], {}, dest + ) + assert url == "https://mirror2.com/f" + assert dest.read_bytes() == b"data" + + def test_resumed_short_body_fails_length_check(self) -> None: + """A stitched file whose final length disagrees with the advertised + total is rejected instead of reported as success.""" + first = _interrupted_response(b"1234", etag='"v1"') + first.headers = {**first.headers, "content-length": "8"} + # the resume ends early (5 of 8 bytes); the poisoned part is then + # discarded and the fresh retry also delivers a short body + short_resume = _resumed_response(b"5") + short_fresh = _mock_response(b"56") + short_fresh.headers = {**short_fresh.headers, "content-length": "8"} + buf = io.BytesIO() + with ( + patch("requests.get", side_effect=[first, short_resume, short_fresh]), + pytest.raises(EsphomeError, match="all mirrors"), + ): + download_from_mirrors(["https://mirror1.com/f"], {}, buf) + + def test_failed_mirror_leftovers_not_kept_for_next_mirror(self) -> None: + """Bytes from a mirror that failed all attempts must not leak into the + next mirror's download (no bogus Range request, fresh content).""" + exhausted = [_interrupted_response(b"AAAA", etag='"a1"')] + for _ in range(2): + r = _interrupted_response(b"BB") + r.status_code = 206 + exhausted.append(r) + buf = io.BytesIO() + with patch( + "requests.get", + side_effect=exhausted + [_mock_response(b"clean")], + ) as mock_get: + url = download_from_mirrors( + ["https://mirror1.com/f", "https://mirror2.com/f"], + {}, + buf, + ) + assert url == "https://mirror2.com/f" + assert buf.getvalue() == b"clean" + # the second mirror starts fresh, without a Range header + assert mock_get.call_args_list[3][0][0] == "https://mirror2.com/f" + assert "Range" not in mock_get.call_args_list[3][1]["headers"] + + def test_all_mirrors_fail_raises_error_listing_every_attempt(self) -> None: with ( patch( "requests.get", @@ -666,7 +1368,7 @@ class TestDownloadFromMirrors: download_from_mirrors( ["https://mirror1.com/f", "https://mirror2.com/f"], {}, - tmp_path / "out.bin", + io.BytesIO(), ) # Every attempted URL appears in the message, and the first mirror's # exception (the primary URL, usually the one that matters) is chained. From 500d4aa9e92a286b9c3c4b109df8886e8e01bf4f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Jul 2026 10:13:56 -1000 Subject: [PATCH 0981/1815] [wireguard] Mark private keys sensitive, stop redacting public keys (#17736) --- esphome/__main__.py | 12 +++++- esphome/components/wireguard/__init__.py | 4 +- tests/component_tests/wireguard/__init__.py | 1 + tests/component_tests/wireguard/test_init.py | 44 ++++++++++++++++++++ tests/unit_tests/test_main.py | 40 ++++++++++++++++++ 5 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/wireguard/__init__.py create mode 100644 tests/component_tests/wireguard/test_init.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 4abd18d239..553a8b390f 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1510,10 +1510,18 @@ def _redact_with_legacy_fallback(output: str) -> str: m = _LEGACY_REDACTION_RE.search(line) if m is None: continue + key = m.group("key") if not in_substitutions: - unmarked.add(m.group("key")) + # Public keys (e.g. wireguard's peer_public_key) are not secret; + # redacting them and telling maintainers to mark them cv.sensitive + # would be wrong on both counts. Substitution keys are user-named + # with no schema behind them, so anything secret-shaped there + # (public or not) stays conservatively redacted. + if "public" in key.split("_"): + continue + unmarked.add(key) lines[i] = ( - f"{line[: m.start()]}{m.group('key')}: " + f"{line[: m.start()]}{key}: " f"\\033[8m{m.group('val')}\\033[28m{line[m.end() :]}" ) output = "\n".join(lines) diff --git a/esphome/components/wireguard/__init__.py b/esphome/components/wireguard/__init__.py index ff98cfc966..ea9e5a3b0c 100644 --- a/esphome/components/wireguard/__init__.py +++ b/esphome/components/wireguard/__init__.py @@ -74,11 +74,11 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_TIME_ID): cv.use_id(time.RealTimeClock), cv.Required(CONF_ADDRESS): cv.ipv4address, cv.Optional(CONF_NETMASK, default="255.255.255.255"): cv.ipv4address, - cv.Required(CONF_PRIVATE_KEY): _wireguard_key, + cv.Required(CONF_PRIVATE_KEY): cv.sensitive(_wireguard_key), cv.Required(CONF_PEER_ENDPOINT): cv.string, cv.Required(CONF_PEER_PUBLIC_KEY): _wireguard_key, cv.Optional(CONF_PEER_PORT, default=51820): cv.port, - cv.Optional(CONF_PEER_PRESHARED_KEY): _wireguard_key, + cv.Optional(CONF_PEER_PRESHARED_KEY): cv.sensitive(_wireguard_key), cv.Optional(CONF_PEER_ALLOWED_IPS, default=["0.0.0.0/0"]): cv.ensure_list( _cidr_network ), diff --git a/tests/component_tests/wireguard/__init__.py b/tests/component_tests/wireguard/__init__.py new file mode 100644 index 0000000000..82b57e8fef --- /dev/null +++ b/tests/component_tests/wireguard/__init__.py @@ -0,0 +1 @@ +"""Tests for the wireguard component.""" diff --git a/tests/component_tests/wireguard/test_init.py b/tests/component_tests/wireguard/test_init.py new file mode 100644 index 0000000000..556d14cd00 --- /dev/null +++ b/tests/component_tests/wireguard/test_init.py @@ -0,0 +1,44 @@ +"""Tests for the wireguard component schema.""" + +import pytest + +from esphome.components.wireguard import CONFIG_SCHEMA +from esphome.const import PlatformFramework +from esphome.yaml_util import SensitiveStr +from tests.component_tests.types import SetCoreConfigCallable + +# Any 42 base64 chars plus a valid terminator satisfies _WG_KEY_REGEX. +PRIVATE_KEY = "a" * 42 + "A=" +PEER_PUBLIC_KEY = "b" * 42 + "A=" +PEER_PRESHARED_KEY = "c" * 42 + "A=" + + +@pytest.mark.parametrize( + ("field", "value", "sensitive"), + [ + ("private_key", PRIVATE_KEY, True), + ("peer_preshared_key", PEER_PRESHARED_KEY, True), + ("peer_public_key", PEER_PUBLIC_KEY, False), + ], +) +def test_key_sensitivity( + field: str, + value: str, + sensitive: bool, + set_core_config: SetCoreConfigCallable, +) -> None: + """The private and preshared keys are secrets and must be tagged so dump + tooling redacts them deterministically; the peer's public key is not a + secret and must stay readable in redacted dumps (see issue #17718).""" + set_core_config(PlatformFramework.ESP32_IDF) + config = CONFIG_SCHEMA( + { + "address": "10.0.0.2", + "private_key": PRIVATE_KEY, + "peer_endpoint": "wg.example.com", + "peer_public_key": PEER_PUBLIC_KEY, + "peer_preshared_key": PEER_PRESHARED_KEY, + } + ) + assert isinstance(config[field], SensitiveStr) == sensitive + assert config[field] == value diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 9a9aafec43..a1ed89bf5d 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -442,6 +442,46 @@ def test_redact_with_legacy_fallback__does_not_match_fragment_as_suffix( assert not any("legacy substring" in rec.message for rec in caplog.records) +@pytest.mark.parametrize("field", ["public_key", "peer_public_key"]) +def test_redact_with_legacy_fallback__skips_public_key_fields( + field: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Public keys are not secret; fields with a ``public`` name segment + must pass through unredacted and without the migration warning + (see issue #17718).""" + text = f"{field}: c29tZXB1YmxpY2tleQ==\n" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback(text) + assert out == text + assert not any("legacy substring" in rec.message for rec in caplog.records) + + +def test_redact_with_legacy_fallback__public_substitution_still_redacted( + caplog: pytest.LogCaptureFixture, +) -> None: + """Substitution keys are user-named with no schema behind them, so the + public-key exemption does not apply there; a ``public``-named substitution + keeps the conservative silent redaction.""" + text = "substitutions:\n public_key: something\nesphome:\n name: x\n" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback(text) + assert "public_key: \\033[8msomething\\033[28m" in out + assert not any("legacy substring" in rec.message for rec in caplog.records) + + +def test_redact_with_legacy_fallback__public_must_be_a_whole_segment( + caplog: pytest.LogCaptureFixture, +) -> None: + """The exemption matches ``public`` as an underscore-separated segment, + not a substring; an unrelated name like ``republic_key`` keeps the + conservative redaction.""" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback("republic_key: abc\n") + assert "republic_key: \\033[8mabc\\033[28m" in out + assert any("'republic_key'" in rec.message for rec in caplog.records) + + def test_redact_with_legacy_fallback__substitutions_redacted_without_warning( caplog: pytest.LogCaptureFixture, ) -> None: From cd8d76fa341700af4ae88478e6037e9a54524014 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Jul 2026 10:17:01 -1000 Subject: [PATCH 0982/1815] [esp8266] Fail fast when Rosetta 2 is missing on Apple Silicon Macs (#17737) --- esphome/__main__.py | 7 +++ esphome/components/esp8266/__init__.py | 37 ++++++++++++- tests/unit_tests/components/test_esp8266.py | 61 ++++++++++++++++++++- tests/unit_tests/test_main.py | 37 +++++++++++++ 4 files changed, 138 insertions(+), 4 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 553a8b390f..27bb64a4df 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -776,6 +776,13 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int: check_placeholder_credentials(config) + # Keep this here, NOT in codegen: config-hash and --only-generate must keep + # working on machines that cannot run the toolchain. + if CORE.is_esp8266: + from esphome.components.esp8266 import check_rosetta + + check_rosetta() + # NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py # If you change this format, update the regex in that script as well _LOGGER.info("Compiling app... Build path: %s", CORE.build_path) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 0e0e2f77d7..7ce10d465d 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -1,5 +1,6 @@ import logging from pathlib import Path +import platform import re import subprocess @@ -20,9 +21,15 @@ from esphome.const import ( PLATFORM_ESP8266, ThreadModel, ) -from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority +from esphome.core import ( + CORE, + CoroPriority, + EsphomeError, + Lambda, + coroutine_with_priority, +) from esphome.core.config import BOARD_MAX_LENGTH -from esphome.helpers import copy_file_if_changed +from esphome.helpers import IS_MACOS, copy_file_if_changed from esphome.types import ConfigType from .boards import BOARDS, ESP8266_LD_SCRIPTS @@ -237,6 +244,32 @@ CONFIG_SCHEMA = cv.All( ) +def check_rosetta() -> None: + """Fail fast when the x86_64 ESP8266 toolchain cannot run on this Mac. + + There is no native arm64 build of the xtensa-lx106 toolchain; on Apple + Silicon it runs under Rosetta 2, which macOS updates can remove. + """ + if not IS_MACOS or platform.machine() != "arm64": + return + try: + result = subprocess.run( + ["/usr/bin/arch", "-x86_64", "/usr/bin/true"], + capture_output=True, + close_fds=False, + check=False, + ) + except OSError: + return # arch(1) unavailable; let the build proceed + if result.returncode != 0: + raise EsphomeError( + "ESP8266 builds on Apple Silicon Macs use an Intel (x86_64) " + "compiler that requires Rosetta 2, which is not installed on " + "this system. Install it with:\n" + " softwareupdate --install-rosetta --agree-to-license" + ) + + @coroutine_with_priority(CoroPriority.PLATFORM) async def to_code(config): cg.add(esp8266_ns.setup_preferences()) diff --git a/tests/unit_tests/components/test_esp8266.py b/tests/unit_tests/components/test_esp8266.py index 318fd2d889..fb0e437d24 100644 --- a/tests/unit_tests/components/test_esp8266.py +++ b/tests/unit_tests/components/test_esp8266.py @@ -1,9 +1,15 @@ """Tests for ESP8266 component.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + import pytest -from esphome.components.esp8266 import lambdas_use_scanf_float -from esphome.core import Lambda +from esphome.components import esp8266 +from esphome.components.esp8266 import check_rosetta, lambdas_use_scanf_float +from esphome.core import EsphomeError, Lambda from esphome.types import ConfigType @@ -60,3 +66,54 @@ def test_lambdas_use_scanf_float_nested() -> None: """Test detection in deeply nested config.""" config: ConfigType = {"a": {"b": {"c": [Lambda('sscanf(buf, "%f", &v)')]}}} assert lambdas_use_scanf_float(config) is True + + +@pytest.fixture +def apple_silicon_run(monkeypatch: pytest.MonkeyPatch) -> Generator[MagicMock]: + """Simulate an Apple Silicon Mac and yield the mocked subprocess.run.""" + monkeypatch.setattr(esp8266, "IS_MACOS", True) + with ( + patch("esphome.components.esp8266.platform.machine", return_value="arm64"), + patch("esphome.components.esp8266.subprocess.run") as mock_run, + ): + yield mock_run + + +@pytest.mark.parametrize( + ("is_macos", "machine"), + [ + (False, "arm64"), + (True, "x86_64"), + ], +) +def test_check_rosetta_skips_other_systems( + monkeypatch: pytest.MonkeyPatch, is_macos: bool, machine: str +) -> None: + """The check only probes on Apple Silicon Macs.""" + monkeypatch.setattr(esp8266, "IS_MACOS", is_macos) + with ( + patch("esphome.components.esp8266.platform.machine", return_value=machine), + patch("esphome.components.esp8266.subprocess.run") as mock_run, + ): + check_rosetta() + mock_run.assert_not_called() + + +def test_check_rosetta_installed(apple_silicon_run: MagicMock) -> None: + """No error when the x86_64 probe succeeds (Rosetta present).""" + apple_silicon_run.return_value = MagicMock(returncode=0) + check_rosetta() + apple_silicon_run.assert_called_once() + + +def test_check_rosetta_missing(apple_silicon_run: MagicMock) -> None: + """A failing x86_64 probe raises an actionable error.""" + apple_silicon_run.return_value = MagicMock(returncode=1) + with pytest.raises(EsphomeError, match="softwareupdate --install-rosetta"): + check_rosetta() + + +def test_check_rosetta_arch_unavailable(apple_silicon_run: MagicMock) -> None: + """The build proceeds when arch(1) cannot be executed.""" + apple_silicon_run.side_effect = OSError("no such file") + check_rosetta() diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index a1ed89bf5d..7de11d0568 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -5450,6 +5450,43 @@ def _setup_build_info_test( return build_info_path, firmware_path +def test_compile_program_esp8266_runs_rosetta_check(tmp_path: Path) -> None: + """Test that compile_program runs the Rosetta preflight for ESP8266 targets.""" + setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test_device") + + config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}} + args = MockArgs() + + with ( + patch( + "esphome.components.esp8266.check_rosetta", + side_effect=EsphomeError("Rosetta 2 is not installed"), + ) as mock_check, + pytest.raises(EsphomeError, match="Rosetta 2 is not installed"), + ): + compile_program(args, config) + + mock_check.assert_called_once() + + +def test_compile_program_skips_rosetta_check_on_other_platforms( + tmp_path: Path, + mock_compile_build_info_run_compile: Mock, + mock_compile_build_info_get_idedata: Mock, +) -> None: + """Test that the Rosetta preflight does not run for non-ESP8266 targets.""" + _setup_build_info_test(tmp_path, firmware_first=True) + + config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}} + args = MockArgs() + + with patch("esphome.components.esp8266.check_rosetta") as mock_check: + result = compile_program(args, config) + + assert result == 0 + mock_check.assert_not_called() + + def test_compile_program_emits_build_info_when_firmware_rebuilt( tmp_path: Path, caplog: pytest.LogCaptureFixture, From d3655597eaf18284983cc288b9140908b9cab7eb Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:26:23 -0400 Subject: [PATCH 0983/1815] [as3935_i2c] Use repeated start when reading registers (#17584) --- esphome/components/as3935_i2c/as3935_i2c.cpp | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/esphome/components/as3935_i2c/as3935_i2c.cpp b/esphome/components/as3935_i2c/as3935_i2c.cpp index 4c1020daa7..b3d015114f 100644 --- a/esphome/components/as3935_i2c/as3935_i2c.cpp +++ b/esphome/components/as3935_i2c/as3935_i2c.cpp @@ -24,11 +24,7 @@ void I2CAS3935Component::write_register(uint8_t reg, uint8_t mask, uint8_t bits, uint8_t I2CAS3935Component::read_register(uint8_t reg) { uint8_t value; - if (write(®, 1) != i2c::ERROR_OK) { - ESP_LOGW(TAG, "Writing register failed!"); - return 0; - } - if (read(&value, 1) != i2c::ERROR_OK) { + if (!this->read_byte(reg, &value)) { ESP_LOGW(TAG, "Reading register failed!"); return 0; } From ea01c909b7b500d3b034a2f7ed231cc65a3e97b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Jul 2026 02:09:30 -1000 Subject: [PATCH 0984/1815] [micro_wake_word] Include the local model file in bundles (#17604) --- esphome/bundle.py | 37 +++++- .../components/micro_wake_word/__init__.py | 45 ++++++- .../micro_wake_word/__init__.py | 0 .../micro_wake_word/test_init.py | 110 ++++++++++++++++++ tests/unit_tests/test_bundle.py | 65 +++++++++++ 5 files changed, 251 insertions(+), 6 deletions(-) create mode 100644 tests/component_tests/micro_wake_word/__init__.py create mode 100644 tests/component_tests/micro_wake_word/test_init.py diff --git a/esphome/bundle.py b/esphome/bundle.py index d38f68ebfd..88df87c3ba 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -7,7 +7,7 @@ and compiled directly: ``esphome compile my_device.esphomebundle.tar.gz`` from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import StrEnum import io import json @@ -32,6 +32,8 @@ from esphome.core import CORE, EsphomeError _LOGGER = logging.getLogger(__name__) +DOMAIN = "bundle" + BUNDLE_EXTENSION = ".esphomebundle.tar.gz" MANIFEST_FILENAME = "manifest.json" CURRENT_MANIFEST_VERSION = 1 @@ -120,6 +122,32 @@ def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]: return keys +@dataclass +class BundleData: + """Files components asked to include, keyed under DOMAIN in CORE.data.""" + + extra_files: list[Path] = field(default_factory=list) + + +def _get_data() -> BundleData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = BundleData() + return CORE.data[DOMAIN] + + +def add_bundle_file(path: Path) -> None: + """Register a file that a bundle must include. + + Bundle discovery walks the validated config, so it only finds files the config + names. Components call this during validation for files it cannot see, such as a + file that is referenced from inside another file. + + A relative path is taken as relative to the config directory. Files outside the + config directory are skipped when the bundle is built. + """ + _get_data().extra_files.append(CORE.relative_config_path(path)) + + @dataclass class BundleFile: """A file to include in the bundle.""" @@ -286,13 +314,18 @@ class ConfigBundleCreator: with known file extensions are also resolved and checked. Core ESPHome concepts that use relative paths or directories - are handled explicitly. + are handled explicitly. Files the config does not name at all are + registered by their component with add_bundle_file(). """ config = self._config # Generic walk: find all file paths in the validated config self._walk_config_for_files(config) + # Files registered by components during validation + for extra_file in _get_data().extra_files: + self._add_file(extra_file) + # --- Core ESPHome concepts needing explicit handling --- # esphome.includes / includes_c - can be relative paths and directories diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index cba6bcfa50..4b309551ba 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -6,6 +6,7 @@ from urllib.parse import urljoin from esphome import automation, external_files, git from esphome.automation import register_action, register_condition +from esphome.bundle import add_bundle_file import esphome.codegen as cg from esphome.components import esp32, microphone, ota, psram import esphome.config_validation as cv @@ -28,6 +29,7 @@ from esphome.const import ( TYPE_LOCAL, ) from esphome.core import CORE, HexInt +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -236,10 +238,45 @@ HTTP_SCHEMA = cv.All( _process_http_source, ) -LOCAL_SCHEMA = cv.Schema( - { - cv.Required(CONF_PATH): cv.All(_validate_json_filename, cv.file_), - } + +def _register_local_model_file(config: ConfigType) -> ConfigType: + """Register the model file that the manifest points to, so bundles include it. + + The manifest names its model file relative to itself, so that path never appears + in the YAML and bundle discovery cannot find it on its own. + + Problems with the manifest are logged and ignored here rather than raised. Loading + the manifest later reports them with better messages, and raising would be + swallowed by the shorthand validator, which then reports a confusing error about a + missing file in a git repository. Logging keeps the skipped registration + diagnosable if the manifest is only briefly unreadable, since the bundle would + then be built without the model file. + """ + manifest_path: Path = config[CONF_PATH] + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + model = manifest[CONF_MODEL] + except (OSError, ValueError, KeyError, TypeError) as err: + _LOGGER.debug("Not registering a model file from %s: %s", manifest_path, err) + return config + if not isinstance(model, str): + _LOGGER.debug( + "Not registering a model file from %s: 'model' is %s, expected a string", + manifest_path, + type(model).__name__, + ) + return config + add_bundle_file(manifest_path.parent / model) + return config + + +LOCAL_SCHEMA = cv.All( + cv.Schema( + { + cv.Required(CONF_PATH): cv.All(_validate_json_filename, cv.file_), + } + ), + _register_local_model_file, ) diff --git a/tests/component_tests/micro_wake_word/__init__.py b/tests/component_tests/micro_wake_word/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/micro_wake_word/test_init.py b/tests/component_tests/micro_wake_word/test_init.py new file mode 100644 index 0000000000..5e57653585 --- /dev/null +++ b/tests/component_tests/micro_wake_word/test_init.py @@ -0,0 +1,110 @@ +"""Tests for micro_wake_word local model validation.""" + +import json +import logging +from pathlib import Path +from typing import Any + +import pytest + +from esphome.components.micro_wake_word import LOCAL_SCHEMA +from esphome.core import CORE + +MANIFEST: dict[str, Any] = { + "type": "micro", + "model": "hey_jarvis.tflite", + "author": "someone", + "version": 2, + "wake_word": "hey jarvis", + "trained_languages": ["en"], + "micro": { + "feature_step_size": 10, + "tensor_arena_size": 30000, + "probability_cutoff": 0.97, + "sliding_window_size": 5, + "minimum_esphome_version": "2024.7.0", + }, +} + + +def _registered_files() -> list[Path]: + """Files components registered for bundling this run.""" + data = CORE.data.get("bundle") + return list(data.extra_files) if data else [] + + +@pytest.fixture +def config_dir(tmp_path: Path) -> Path: + """A config dir holding a manifest and its model file.""" + (tmp_path / "models").mkdir() + (tmp_path / "models" / "hey_jarvis.tflite").write_bytes(b"fake model") + (tmp_path / "models" / "hey_jarvis.json").write_text(json.dumps(MANIFEST)) + CORE.config_path = tmp_path / "test.yaml" + return tmp_path + + +def test_local_schema_registers_model_file(config_dir: Path) -> None: + """The model file named by the manifest is registered so bundles include it.""" + LOCAL_SCHEMA({"path": "models/hey_jarvis.json"}) + + assert _registered_files() == [config_dir / "models" / "hey_jarvis.tflite"] + + +def test_local_schema_registers_model_file_in_subdirectory(config_dir: Path) -> None: + """The model reference is resolved relative to the manifest, not the config dir.""" + nested = config_dir / "models" / "nested" + nested.mkdir() + (nested / "model.tflite").write_bytes(b"fake model") + (config_dir / "models" / "nested.json").write_text( + json.dumps({**MANIFEST, "model": "nested/model.tflite"}) + ) + + LOCAL_SCHEMA({"path": "models/nested.json"}) + + assert _registered_files() == [nested / "model.tflite"] + + +def test_local_schema_leaves_config_untouched(config_dir: Path) -> None: + """Registration is a side effect; the model file is not a config key.""" + config = LOCAL_SCHEMA({"path": "models/hey_jarvis.json"}) + + assert config == {"path": config_dir / "models" / "hey_jarvis.json"} + + +def test_local_schema_missing_model_file_still_validates(config_dir: Path) -> None: + """A model file that does not exist is registered, not rejected. + + Raising here would be swallowed by the shorthand validator, which would then + report a confusing error about a missing file in a git repository. + """ + (config_dir / "models" / "hey_jarvis.tflite").unlink() + + LOCAL_SCHEMA({"path": "models/hey_jarvis.json"}) + + assert _registered_files() == [config_dir / "models" / "hey_jarvis.tflite"] + + +@pytest.mark.parametrize( + "contents", + [ + pytest.param("{not valid json", id="malformed"), + pytest.param(json.dumps({"type": "micro"}), id="no_model_key"), + pytest.param(json.dumps(["a", "list"]), id="not_an_object"), + pytest.param(json.dumps({"model": 42}), id="model_not_a_string"), + ], +) +def test_local_schema_bad_manifest_does_not_raise( + config_dir: Path, contents: str, caplog: pytest.LogCaptureFixture +) -> None: + """Manifest problems are left to later stages, which report them better. + + The skipped registration is logged so a bundle built without the model file can + be diagnosed. + """ + (config_dir / "models" / "hey_jarvis.json").write_text(contents) + + with caplog.at_level(logging.DEBUG): + LOCAL_SCHEMA({"path": "models/hey_jarvis.json"}) + + assert _registered_files() == [] + assert "Not registering a model file" in caplog.text diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index f15bbf2e29..6cecb63c2d 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -22,6 +22,7 @@ from esphome.bundle import ( _add_bytes_to_tar, _default_target_dir, _find_used_secret_keys, + add_bundle_file, extract_bundle, is_bundle_path, prepare_bundle_for_compile, @@ -611,6 +612,70 @@ def test_discover_files_includes_config(tmp_path: Path) -> None: assert "test.yaml" in paths +def test_discover_files_includes_registered_files(tmp_path: Path) -> None: + """Files registered with add_bundle_file() are included. + + The config does not name them, so discovery cannot find them on its own. + """ + config_dir = _setup_config_dir( + tmp_path, + files={"models/model.tflite": "fake model data"}, + ) + add_bundle_file(config_dir / "models" / "model.tflite") + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "models/model.tflite" in paths + + +def test_discover_files_registered_relative_file(tmp_path: Path) -> None: + """A relative registered path is taken as relative to the config directory. + + Not the working directory, which is where Path.resolve() would put it. + """ + _setup_config_dir( + tmp_path, + files={"models/model.tflite": "fake model data"}, + ) + add_bundle_file(Path("models/model.tflite")) + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "models/model.tflite" in paths + + +def test_discover_files_registered_file_outside_config_dir(tmp_path: Path) -> None: + """A registered file outside the config directory is skipped, not bundled.""" + _setup_config_dir(tmp_path) + outside = tmp_path / "outside.tflite" + outside.write_text("fake model data") + add_bundle_file(outside) + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + assert [f.path for f in files] == ["test.yaml"] + + +def test_discover_files_registered_file_deduplicated(tmp_path: Path) -> None: + """Registering the same file twice adds it once.""" + config_dir = _setup_config_dir( + tmp_path, + files={"models/model.tflite": "fake model data"}, + ) + add_bundle_file(config_dir / "models" / "model.tflite") + add_bundle_file(config_dir / "models" / "model.tflite") + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + assert [f.path for f in files].count("models/model.tflite") == 1 + + def test_discover_files_finds_path_objects(tmp_path: Path) -> None: """Path objects in validated config are discovered.""" config_dir = _setup_config_dir( From 95a01ac2ab71f21397dc83be5d1c99de5c58e6e7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:22:19 -0400 Subject: [PATCH 0985/1815] [core] Improve framework mirror selection, download errors, and version parsing (#17615) --- esphome/config_validation.py | 6 +- esphome/espidf/framework.py | 31 ++++-- esphome/framework_helpers.py | 71 +++++++++++-- tests/component_tests/esp32/test_esp32.py | 25 +++++ tests/unit_tests/test_config_validation.py | 36 ++++++- tests/unit_tests/test_espidf_framework.py | 23 +++++ tests/unit_tests/test_framework_helpers.py | 111 ++++++++++++++++++++- 7 files changed, 281 insertions(+), 22 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 16f0a63aa0..3f7c8ff783 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -422,12 +422,14 @@ class Version: @classmethod def parse(cls, value: str) -> Version: - match = re.match(r"^(\d+).(\d+).(\d+)[-.]?(\w*)$", value) + # The patch component is optional and defaults to 0, so "6.0" and + # "6.0-rc1" parse as 6.0.0 and 6.0.0-rc1. + match = re.match(r"^(\d+)\.(\d+)(?:\.(\d+))?[-.]?(\w*)$", value) if match is None: raise ValueError(f"Not a valid version number {value}") major = int(match[1]) minor = int(match[2]) - patch = int(match[3]) + patch = int(match[3] or 0) extra = match[4] or "" return Version(major=major, minor=minor, patch=patch, extra=extra) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 810a63476f..18aa966bff 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -63,7 +63,7 @@ ESPHOME_IDF_FRAMEWORK_MIRRORS = str_to_lst_of_str( os.environ.get("ESPHOME_IDF_FRAMEWORK_MIRRORS") or [ "https://github.com/esphome-libs/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.tar.xz", - "https://github.com/esphome-libs/esp-idf/releases/download/v{MAJOR}.{MINOR}{EXTRA}/esp-idf-v{MAJOR}.{MINOR}{EXTRA}.tar.xz", + "https://github.com/esphome-libs/esp-idf/releases/download/v{SHORT_VERSION}/esp-idf-v{SHORT_VERSION}.tar.xz", ] ) @@ -536,10 +536,14 @@ def _check_esphome_idf_framework_install( env: Optional dictionary of environment variables to set source_url: Optional override URL for the framework tarball. Supports the same ``{VERSION}`` / ``{MAJOR}`` / ``{MINOR}`` / ``{PATCH}`` / - ``{EXTRA}`` substitutions as ESPHOME_IDF_FRAMEWORK_MIRRORS - (``{EXTRA}`` includes its leading ``-``, e.g. ``-rc1``, or is empty). - When set, it replaces the default mirror list — no implicit fallback, - so a misspelled URL fails loudly. + ``{EXTRA}`` / ``{SHORT_VERSION}`` substitutions as + ESPHOME_IDF_FRAMEWORK_MIRRORS (``{EXTRA}`` includes its leading + ``-``, e.g. ``-rc1``, or is empty; ``{SHORT_VERSION}`` is ``x.y`` + plus any extra and only available for x.y.0 versions — a URL + referencing it is skipped for other versions). When set, it + replaces the default mirror list — no implicit fallback, so a + misspelled or skipped URL fails loudly with an EsphomeError naming + the URL. Returns: tuple of (framework_path, install_flag) @@ -588,7 +592,11 @@ def _check_esphome_idf_framework_install( with tempfile.NamedTemporaryFile() as tmp: _LOGGER.info("Downloading ESP-IDF %s framework ...", version) - # Create substitutions for the URLs + # Create substitutions for the URLs. SHORT_VERSION (x.y with + # optional -extra) is only provided for x.y.0 releases, since + # the vX.Y release tags only exist for those; templates that + # reference it are skipped for other versions by + # download_from_mirrors. substitutions = {"VERSION": version} try: ver = Version.parse(version) @@ -596,8 +604,17 @@ def _check_esphome_idf_framework_install( substitutions["MINOR"] = str(ver.minor) substitutions["PATCH"] = str(ver.patch) substitutions["EXTRA"] = f"-{ver.extra}" if ver.extra else "" + if ver.patch == 0: + substitutions["SHORT_VERSION"] = ( + f"{ver.major}.{ver.minor}{substitutions['EXTRA']}" + ) except ValueError: - pass + _LOGGER.warning( + "ESP-IDF version '%s' is not a valid version number; " + "only the {VERSION} substitution is available for " + "mirror URLs", + version, + ) mirrors = [source_url] if source_url else ESPHOME_IDF_FRAMEWORK_MIRRORS download_from_mirrors(mirrors, substitutions, tmp.file) diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 70d440d995..6c055dded3 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -552,6 +552,17 @@ def archive_extract_all( matched_fct(archive_ref, extract_dir, progress_header=progress_header) +def _failure_reason(e: Exception) -> str: + """Format a download exception for the aggregated error message. + + ``requests`` appends " for url: " to HTTP errors; the URL is already + printed on the line above, so strip the suffix to keep lines short. Falls + back to the repr for exceptions with no message (e.g. ``TimeoutError()``) + so the line always names the failure. + """ + return str(e).split(" for url: ", maxsplit=1)[0] or repr(e) + + def download_from_mirrors( mirrors: list[str], substitutions: dict[str, str], @@ -570,14 +581,22 @@ def download_from_mirrors( Returns: The source URL. + Mirror URL templates that reference a substitution not present in + ``substitutions`` are skipped, so callers can offer templates that only + apply to some downloads. + Raises: ValueError: If mirrors list is empty. - Exception: If all download attempts fail. + EsphomeError: If all download attempts fail; the message lists every + attempted URL with its individual failure reason. Also raised if + no template matched the provided substitutions. """ # Imported lazily: requests is a heavy import (~85ms) and is only needed # when actually downloading a toolchain, never during config validation. import requests + from esphome.core import EsphomeError + # 1. Open target file for writing if path given with ExitStack() as stack: if isinstance(target, (str, os.PathLike)): @@ -590,13 +609,31 @@ def download_from_mirrors( ) # 2. Try each mirror in order - last_exception = None + failures: list[tuple[str, Exception]] = [] + skipped: list[tuple[str, str]] = [] for mirror in mirrors: # 3. Apply substitutions to URL - url = mirror.format(**substitutions) + try: + url = mirror.format(**substitutions) + except KeyError as e: + # The template references a substitution not provided for + # this download (e.g. SHORT_VERSION only exists for x.y.0 + # versions) - expected, the template just doesn't apply. + _LOGGER.debug("Skipping mirror %s: %s not available", mirror, e) + skipped.append((mirror, f"not applicable ({e.args[0]} not available)")) + continue + except (IndexError, ValueError) as e: + # A malformed template (unbalanced braces, bad format spec) + # is an authoring error, not an expected fallthrough - warn + # even if a later mirror succeeds. + _LOGGER.warning( + "Skipping malformed mirror URL template %s: %r", mirror, e + ) + skipped.append((mirror, f"skipped ({e!r})")) + continue - _LOGGER.debug("Trying downloading from %s", url) + _LOGGER.debug("Trying to download from %s", url) try: # 4. Reset file pointer and download @@ -631,9 +668,27 @@ def download_from_mirrors( except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught _LOGGER.debug("Failed to download %s: %s", url, str(e)) - last_exception = e + failures.append((url, e)) - # 7. Raise last exception if all mirrors failed - if last_exception: - raise last_exception + # 7. Report every attempted URL if all mirrors failed. Falling back + # past an early mirror is normal (e.g. only one of the framework URL + # templates matches a given version's tag), so raising only the last + # error would hide the failure that actually matters. + if failures: + attempts = "".join( + f"\n {url}\n {_failure_reason(e)}" for url, e in failures + ) + attempts += "".join( + f"\n {mirror}\n {reason}" for mirror, reason in skipped + ) + raise EsphomeError( + f"Failed to download from all mirrors:{attempts}" + ) from failures[0][1] + if skipped: + details = "".join( + f"\n {mirror}\n {reason}" for mirror, reason in skipped + ) + raise EsphomeError( + f"No mirror URL template matched the provided substitutions:{details}" + ) raise ValueError("download_from_mirrors called with an empty mirrors list") diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index fdca70bf2c..8a116ccc27 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -740,3 +740,28 @@ def test_signed_ota_keys_invalid_combinations(config: dict, match: str) -> None: with pytest.raises(cv.Invalid, match=match): _validate_signed_ota_keys(config) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + # Full x.y.z versions are rewritten into pioarduino release URLs + ( + "55.3.30", + "https://github.com/pioarduino/platform-espressif32/releases/download/55.03.30/platform-espressif32.zip", + ), + ( + "55.3.31-2", + "https://github.com/pioarduino/platform-espressif32/releases/download/55.03.31-2/platform-espressif32.zip", + ), + # Non-version values pass through untouched + ( + "https://github.com/pioarduino/platform-espressif32.git#develop", + "https://github.com/pioarduino/platform-espressif32.git#develop", + ), + ], +) +def test_parse_pio_platform_version(value: str, expected: str) -> None: + from esphome.components.esp32 import _parse_pio_platform_version + + assert _parse_pio_platform_version(value) == expected diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 17dfaad9b8..fd21ac92ea 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1436,9 +1436,41 @@ def test_version_parse_with_extra() -> None: assert version.extra == "dev20240101" -def test_version_parse_invalid() -> None: +def test_version_parse_without_patch() -> None: + """A two-part version parses with patch defaulting to 0, so framework + shorthands like '6.0' and '6.0-rc1' are accepted.""" + version = cv.Version.parse("6.0") + assert (version.major, version.minor, version.patch, version.extra) == ( + 6, + 0, + 0, + "", + ) + version = cv.Version.parse("6.0-rc1") + assert (version.major, version.minor, version.patch, version.extra) == ( + 6, + 0, + 0, + "rc1", + ) + + +def test_version_parse_numeric_extra() -> None: + """Four-part versions keep the trailing component as extra (pioarduino + packaging revisions, e.g. 5.5.3.1).""" + version = cv.Version.parse("5.5.3.1") + assert (version.major, version.minor, version.patch, version.extra) == ( + 5, + 5, + 3, + "1", + ) + + +@pytest.mark.parametrize("value", ["not.a.version", "6", "a.b", ""]) +def test_version_parse_invalid(value: str) -> None: with pytest.raises(ValueError, match="Not a valid version number"): - cv.Version.parse("not.a.version") + cv.Version.parse(value) def test_version_is_beta() -> None: diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index c5d9ddbaf1..de02a6b227 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -489,6 +489,29 @@ def test_check_esp_idf_install_unparseable_version( espidf_mocks.extract.assert_called_once() +@pytest.mark.parametrize( + ("version", "short_version"), + [ + ("6.0.0", "6.0"), + ("6.0.0-rc1", "6.0-rc1"), + ("5.5.4", None), # vX.Y tags only exist for X.Y.0 releases + ], +) +def test_check_esp_idf_install_short_version_substitution( + espidf_mocks: SimpleNamespace, version: str, short_version: str | None +) -> None: + """SHORT_VERSION is only offered for x.y.0 releases, so the vX.Y mirror + template is never tried for versions whose tag cannot exist.""" + _get_framework_path(version).mkdir(parents=True, exist_ok=True) + check_esp_idf_install(version, force=True) + + # First call downloads the framework archive; a later call fetches the + # constraints file with its own substitutions. + substitutions = espidf_mocks.download.call_args_list[0][0][1] + assert substitutions.get("SHORT_VERSION") == short_version + assert substitutions["VERSION"] == version + + # --------------------------------------------------------------------------- # _patch_tools_json_for_linux_arm64 (arm64-only ninja backport) # --------------------------------------------------------------------------- diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index 69b9f20eaa..e662d2d015 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -16,6 +16,7 @@ import zipfile import pytest import requests as req +from esphome.core import EsphomeError from esphome.framework_helpers import ( _7z_extract_all, _detect_archive_root, @@ -546,6 +547,99 @@ class TestDownloadFromMirrors: ) assert mock_get.call_args[0][0] == "https://example.com/1.2.3.bin" + def test_template_with_missing_substitution_is_skipped( + self, tmp_path: Path + ) -> None: + """A template referencing an unavailable substitution is skipped, not + formatted into a bogus URL (e.g. SHORT_VERSION only exists for x.y.0 + framework versions).""" + with patch( + "requests.get", + return_value=_mock_response(b"x"), + ) as mock_get: + url = download_from_mirrors( + [ + "https://example.com/{SHORT_VERSION}.bin", + "https://example.com/{VERSION}.bin", + ], + {"VERSION": "1.2.3"}, + tmp_path / "out.bin", + ) + assert url == "https://example.com/1.2.3.bin" + assert mock_get.call_count == 1 + + def test_all_templates_skipped_raises_esphome_error(self, tmp_path: Path) -> None: + with ( + patch("requests.get") as mock_get, + pytest.raises(EsphomeError, match="No mirror URL template matched") as ei, + ): + download_from_mirrors( + ["https://example.com/{MISSING}.bin"], + {"VERSION": "1.2.3"}, + tmp_path / "out.bin", + ) + mock_get.assert_not_called() + # The skipped template and its missing substitution are named + assert "https://example.com/{MISSING}.bin" in str(ei.value) + assert "MISSING" in str(ei.value) + + def test_failure_message_includes_skipped_templates(self, tmp_path: Path) -> None: + """When downloads fail, templates that were skipped for missing + substitutions are also listed so a typo'd custom mirror is + attributable.""" + with ( + patch( + "requests.get", + return_value=_mock_response(b"", ok=False), + ), + pytest.raises(EsphomeError, match="all mirrors") as ei, + ): + download_from_mirrors( + [ + "https://example.com/{TYPO}.bin", + "https://example.com/{VERSION}.bin", + ], + {"VERSION": "1.2.3"}, + tmp_path / "out.bin", + ) + message = str(ei.value) + assert "https://example.com/1.2.3.bin" in message + assert ( + "https://example.com/{TYPO}.bin\n not applicable (TYPO not available)" + in message + ) + + def test_malformed_template_warns_and_is_reported( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + """A structurally malformed template is an authoring error: warned + about even when another mirror succeeds, and named in the aggregate + error when everything fails.""" + with ( + patch("requests.get", return_value=_mock_response(b"x")), + caplog.at_level(logging.WARNING, logger="esphome.framework_helpers"), + ): + url = download_from_mirrors( + ["https://example.com/{oops.bin", "https://example.com/{VERSION}.bin"], + {"VERSION": "1.2.3"}, + tmp_path / "out.bin", + ) + assert url == "https://example.com/1.2.3.bin" + assert "malformed mirror URL template" in caplog.text + + with ( + patch("requests.get", return_value=_mock_response(b"", ok=False)), + pytest.raises(EsphomeError, match="all mirrors") as ei, + ): + download_from_mirrors( + ["https://example.com/{oops.bin", "https://example.com/{VERSION}.bin"], + {"VERSION": "1.2.3"}, + tmp_path / "out.bin", + ) + assert "https://example.com/{oops.bin\n skipped (ValueError(" in str( + ei.value + ) + def test_falls_back_to_second_mirror(self, tmp_path: Path) -> None: with patch( "requests.get", @@ -559,15 +653,26 @@ class TestDownloadFromMirrors: assert url == "https://mirror2.com/f" assert (tmp_path / "out.bin").read_bytes() == b"second" - def test_all_mirrors_fail_reraises_last_exception(self, tmp_path: Path) -> None: + def test_all_mirrors_fail_raises_error_listing_every_attempt( + self, tmp_path: Path + ) -> None: with ( patch( "requests.get", return_value=_mock_response(b"", ok=False), ), - pytest.raises(req.HTTPError), + pytest.raises(EsphomeError, match="all mirrors") as excinfo, ): - download_from_mirrors(["https://example.com/f"], {}, tmp_path / "out.bin") + download_from_mirrors( + ["https://mirror1.com/f", "https://mirror2.com/f"], + {}, + tmp_path / "out.bin", + ) + # Every attempted URL appears in the message, and the first mirror's + # exception (the primary URL, usually the one that matters) is chained. + assert "https://mirror1.com/f" in str(excinfo.value) + assert "https://mirror2.com/f" in str(excinfo.value) + assert isinstance(excinfo.value.__cause__, req.HTTPError) def test_empty_mirrors_raises_value_error(self, tmp_path: Path) -> None: with pytest.raises(ValueError, match="empty mirrors list"): From 2797349c7514c2953b259b9cb98b94c3b71eef9e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:34:49 -0400 Subject: [PATCH 0986/1815] [http_request] Fix use-after-return of header collection state in IDF backend (#17627) --- .../components/http_request/http_request_idf.cpp | 15 +++++---------- .../components/http_request/http_request_idf.h | 2 ++ 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index 3e341395a4..a437540241 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -19,11 +19,6 @@ namespace esphome::http_request { static const char *const TAG = "http_request.idf"; static constexpr uint32_t ERROR_DURATION_MS = 1000; -struct UserData { - const std::vector &lower_case_collect_headers; - std::vector
&response_headers; -}; - void HttpRequestIDF::dump_config() { HttpRequestComponent::dump_config(); ESP_LOGCONFIG(TAG, @@ -34,15 +29,15 @@ void HttpRequestIDF::dump_config() { } esp_err_t HttpRequestIDF::http_event_handler(esp_http_client_event_t *evt) { - UserData *user_data = (UserData *) evt->user_data; + auto *container = (HttpContainerIDF *) evt->user_data; switch (evt->event_id) { case HTTP_EVENT_ON_HEADER: { const std::string header_name = str_lower_case(evt->header_key); // NOLINT - if (should_collect_header(user_data->lower_case_collect_headers, header_name)) { + if (should_collect_header(container->collect_headers_, header_name)) { const std::string header_value = evt->header_value; ESP_LOGD(TAG, "Received response header, name: %s, value: %s", header_name.c_str(), header_value.c_str()); - user_data->response_headers.push_back({header_name, header_value}); + container->response_headers_.push_back({header_name, header_value}); } break; } @@ -124,8 +119,8 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c container->set_secure(secure); - auto user_data = UserData{lower_case_collect_headers, container->response_headers_}; - esp_http_client_set_user_data(client, static_cast(&user_data)); + container->collect_headers_ = lower_case_collect_headers; + esp_http_client_set_user_data(client, static_cast(container.get())); for (const auto &header : request_headers) { esp_http_client_set_header(client, header.name.c_str(), header.value.c_str()); diff --git a/esphome/components/http_request/http_request_idf.h b/esphome/components/http_request/http_request_idf.h index 8a803b5469..16a5b6a161 100644 --- a/esphome/components/http_request/http_request_idf.h +++ b/esphome/components/http_request/http_request_idf.h @@ -24,6 +24,8 @@ class HttpContainerIDF : public HttpContainer { protected: friend class HttpRequestIDF; esp_http_client_handle_t client_; + // Owned copy (not a reference): must outlive perform() for the response-header event handler + std::vector collect_headers_; }; class HttpRequestIDF final : public HttpRequestComponent { From 05e2c6b133175a43151ac9f7a5fee770fc30ba05 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:59:53 -0400 Subject: [PATCH 0987/1815] [web_server_idf] Use core format_hex_to helper for digest auth (fixes Arduino build) (#17608) --- .../web_server_idf/web_server_idf.cpp | 20 +++++-------------- .../components/web_server/test.esp32-ard.yaml | 6 ++++++ 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index bf5a8666dc..993fb6c035 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -381,16 +381,6 @@ void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code #ifdef USE_WEBSERVER_AUTH_DIGEST namespace { -// Hex-encode `len` bytes into `out`, which must hold at least 2 * len + 1 bytes. Null-terminated. -void bytes_to_hex(const uint8_t *data, size_t len, char *out) { - static const char HEX[] = "0123456789abcdef"; - for (size_t i = 0; i < len; i++) { - out[i * 2] = HEX[data[i] >> 4]; - out[i * 2 + 1] = HEX[data[i] & 0x0f]; - } - out[len * 2] = '\0'; -} - // Extract the value of a Digest auth parameter (e.g. "nonce") from the comma-separated // parameter list. Values may be quoted or bare. Returns an empty ref when the key is absent. // Only whole parameter names match, so "nc" does not match inside "cnonce". @@ -468,7 +458,7 @@ bool check_digest_auth(const char *username, const char *password, const std::st esp_rom_md5_update(&ctx, ":", 1); esp_rom_md5_update(&ctx, password, strlen(password)); esp_rom_md5_final(digest, &ctx); - bytes_to_hex(digest, sizeof(digest), ha1); + format_hex_to(ha1, digest, sizeof(digest)); // HA2 = MD5(method:uri) -- uses the uri the client echoed back. char ha2[33]; @@ -477,7 +467,7 @@ bool check_digest_auth(const char *username, const char *password, const std::st esp_rom_md5_update(&ctx, ":", 1); esp_rom_md5_update(&ctx, uri.c_str(), uri.size()); esp_rom_md5_final(digest, &ctx); - bytes_to_hex(digest, sizeof(digest), ha2); + format_hex_to(ha2, digest, sizeof(digest)); // expected = MD5(HA1:nonce:nc:cnonce:qop:HA2) char expected[33]; @@ -494,7 +484,7 @@ bool check_digest_auth(const char *username, const char *password, const std::st esp_rom_md5_update(&ctx, ":", 1); esp_rom_md5_update(&ctx, ha2, 32); esp_rom_md5_final(digest, &ctx); - bytes_to_hex(digest, sizeof(digest), expected); + format_hex_to(expected, digest, sizeof(digest)); // Constant-time comparison of the two 32-char hex digests. uint8_t result = 0; @@ -592,9 +582,9 @@ void AsyncWebServerRequest::requestAuthentication() const { char opaque[33]; char header[160]; esp_fill_random(random_bytes, sizeof(random_bytes)); - bytes_to_hex(random_bytes, sizeof(random_bytes), nonce); + format_hex_to(nonce, random_bytes, sizeof(random_bytes)); esp_fill_random(random_bytes, sizeof(random_bytes)); - bytes_to_hex(random_bytes, sizeof(random_bytes), opaque); + format_hex_to(opaque, random_bytes, sizeof(random_bytes)); snprintf(header, sizeof(header), R"(Digest realm="Login Required", qop="auth", nonce="%s", opaque="%s")", nonce, opaque); httpd_resp_set_hdr(*this, "WWW-Authenticate", header); diff --git a/tests/components/web_server/test.esp32-ard.yaml b/tests/components/web_server/test.esp32-ard.yaml index 11ad5456ef..2e091b905a 100644 --- a/tests/components/web_server/test.esp32-ard.yaml +++ b/tests/components/web_server/test.esp32-ard.yaml @@ -1,2 +1,8 @@ packages: web_server: !include common_v2.yaml + +web_server: + auth: + username: admin + password: password + type: digest From 3a10d2c1873daf75e9ce852930b4aa45af95996a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:04:35 +1200 Subject: [PATCH 0988/1815] [nrf52] Set ZEPHYR_SDK_INSTALL_DIR for Zephyr SDK discovery (#17633) --- esphome/components/nrf52/framework.py | 10 +++++- tests/unit_tests/test_nrf52_framework.py | 39 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index fa6f7d57ad..623cd4eef3 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -133,7 +133,15 @@ def get_build_env() -> dict: env = os.environ.copy() env["PATH"] = str(venv_bin_dir) + os.pathsep + env.get("PATH", "") env["ZEPHYR_BASE"] = str(_get_framework_path(version) / "zephyr") - env["Zephyr-sdk_DIR"] = str(_get_toolchain_path(TOOLCHAIN_VERSION) / "cmake") + # ZEPHYR_SDK_INSTALL_DIR is the variable Zephyr documents for pointing at + # the SDK: FindZephyr-sdk.cmake reads it (from the environment, via + # zephyr_get) and passes it straight to find_package as a HINT. This + # matters because the SDK lives in the esphome cache dir, which is not on + # the module's static search path (/usr, /opt, $HOME, ...). A generic + # "Zephyr-sdk_DIR" environment hint proved unreliable here: containerized + # non-root builds failed to locate the SDK with it, while + # ZEPHYR_SDK_INSTALL_DIR fixed the same invocation. + env["ZEPHYR_SDK_INSTALL_DIR"] = str(_get_toolchain_path(TOOLCHAIN_VERSION)) return env diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index bb5bc8c064..830e9efba5 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -1,6 +1,7 @@ """Tests for esphome.components.nrf52.framework helpers.""" import hashlib +import os from pathlib import Path from types import SimpleNamespace from unittest.mock import patch @@ -12,11 +13,13 @@ from esphome.components.nrf52.framework import ( TOOLCHAIN_VERSION, _get_toolchain_platform_info, check_and_install, + get_build_env, get_sdk_nrf_tools_path, ) from esphome.config_validation import Version from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, EsphomeError +from esphome.framework_helpers import get_python_env_executable_path @pytest.fixture(autouse=True) @@ -252,6 +255,42 @@ class TestCheckAndInstall: assert substitutions["extension"] == "tar.xz" +# --------------------------------------------------------------------------- +# get_build_env tests +# --------------------------------------------------------------------------- + + +def test_get_build_env( + nrf52_dirs: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + """get_build_env exposes ZEPHYR_SDK_INSTALL_DIR pointing at the toolchain root. + + ZEPHYR_SDK_INSTALL_DIR is the variable Zephyr's FindZephyr-sdk.cmake + explicitly consumes (from the environment) and uses as a find_package + HINT. The old Zephyr-sdk_DIR environment hint proved unreliable in + containerized non-root builds and was removed. + """ + monkeypatch.setenv("SOME_PREEXISTING_VAR", "kept") + + env = get_build_env() + + tools = get_sdk_nrf_tools_path() + venv_bin_dir = get_python_env_executable_path( + tools / "penvs" / f"v{_TEST_SDK_VERSION}", "python" + ).parent + assert env["PATH"].startswith(str(venv_bin_dir) + os.pathsep) + assert env["ZEPHYR_BASE"] == str( + tools / "frameworks" / f"v{_TEST_SDK_VERSION}" / "zephyr" + ) + # Toolchain root, not the cmake/ subdir + assert env["ZEPHYR_SDK_INSTALL_DIR"] == str( + tools / "toolchains" / TOOLCHAIN_VERSION + ) + assert "Zephyr-sdk_DIR" not in env + # The rest of the process environment is inherited + assert env["SOME_PREEXISTING_VAR"] == "kept" + + # --------------------------------------------------------------------------- # get_sdk_nrf_tools_path tests # --------------------------------------------------------------------------- From 2b4d9c0af9709adb554a4fc60313e5e833d5fdf4 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 08:08:28 -0400 Subject: [PATCH 0989/1815] Bump bundled esphome-device-builder to 1.6.2 (#17640) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 84fd658594..7710256318 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.6.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.2 RUN \ platformio settings set enable_telemetry No \ From b3172ecae8b8a917fc028680cd73a8a2e449efa4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:00:54 -1000 Subject: [PATCH 0990/1815] Bump aioesphomeapi from 45.6.0 to 45.6.1 (#17653) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b36e70ef5d..3c9d71e5ce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.6.0 +aioesphomeapi==45.6.1 zeroconf==0.150.0 puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import From e08bdf8cca312f245f3840400f20ff4fdb3d0251 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:25:18 -1000 Subject: [PATCH 0991/1815] Bump bundled esphome-device-builder to 1.6.3 (#17651) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 7710256318..b60bfac7a2 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.6.2 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.3 RUN \ platformio settings set enable_telemetry No \ From 7afe7750cd26d9ddf197bd8692293a5930c33b1e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:46:45 -0400 Subject: [PATCH 0992/1815] [espidf] Suggest installing missing system libraries when the tools install fails (#17619) --- esphome/espidf/framework.py | 9 ++++++++ tests/unit_tests/test_espidf_framework.py | 28 +++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 18aa966bff..b8e0d4cfca 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -1,5 +1,6 @@ """ESP-IDF framework tools for ESPHome.""" +from ctypes.util import find_library import json import logging import os @@ -668,6 +669,14 @@ def _check_esphome_idf_framework_install( env=env, stream_output=True, ): + if platform.system() == "Linux" and find_library("usb-1.0") is None: + _LOGGER.error( + "libusb-1.0.so.0 was not found on this system and the ESP-IDF " + "tools need it (openocd fails its install check without it). " + "Install the libusb 1.0 package, e.g. libusb-1.0-0 " + "(Debian/Ubuntu), libusb1 (Fedora) or libusb (Alpine/Arch), " + "then run the build again." + ) raise RuntimeError(f"ESP-IDF {version} framework installation failure") _write_stamp(env_stamp_file, stamp_info) diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index de02a6b227..a1af5ae54c 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -478,6 +478,34 @@ def test_check_esp_idf_install_python_stamp_mismatch_rebuilds_venv( espidf_mocks.venv.assert_called_once() +@pytest.mark.parametrize( + ("lib", "expect_hint"), + [ + (None, True), + ("libusb-1.0.so.0", False), + ], +) +def test_check_esp_idf_install_failure_libusb_hint( + espidf_mocks: SimpleNamespace, + caplog: pytest.LogCaptureFixture, + lib: str | None, + expect_hint: bool, +) -> None: + """A failed tools install only shows the libusb hint when libusb-1.0 is + actually missing.""" + espidf_mocks.run_ok.return_value = False + # Fake Linux so the gate is exercised on all CI hosts; faking Linux is safe + # everywhere (unlike faking Windows, which pulls in winreg on other hosts) + with ( + patch("esphome.espidf.framework.find_library", return_value=lib), + patch("esphome.espidf.framework.platform.system", return_value="Linux"), + caplog.at_level(logging.ERROR, logger="esphome.espidf.framework"), + pytest.raises(RuntimeError, match="framework installation failure"), + ): + check_esp_idf_install(_IDF_VERSION, force=True) + assert ("libusb-1.0.so.0 was not found" in caplog.text) == expect_hint + + def test_check_esp_idf_install_unparseable_version( espidf_mocks: SimpleNamespace, ) -> None: From cd40fb1c684ec6bc025be11a9d48553eec7abc0e Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:45:13 -0500 Subject: [PATCH 0993/1815] [core] Fix srcFilter exclusions being silently ignored on Windows (#17648) --- esphome/platformio/library.py | 6 ++++ tests/unit_tests/test_espidf_component.py | 43 +++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 291bedb5cd..0ffac65e0d 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -292,6 +292,12 @@ def collect_filtered_files(src_dir: PathType, src_filters: list[str]) -> list[st for root, _, files in os.walk(item): matched.extend([str(Path(root) / f) for f in files]) + # glob keeps the pattern's literal separators for non-wildcard path + # components, so on Windows the same file can surface with different + # separators depending on where the wildcards sit; normalize so the + # include/exclude set operations below compare equal paths. + matched = [os.path.normpath(m) for m in matched] + # FILTER_REGEX only ever captures "+" or "-", so the else is the "-" case. if sign == "+": selected.update(matched) diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index a50024b8e9..055e9c8502 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -1,3 +1,4 @@ +import glob import hashlib import json import os @@ -86,6 +87,48 @@ def test_collect_filtered_files_exclude(tmp_path): assert str(f2) not in result +def test_collect_filtered_files_exclude_pattern_in_subdir(tmp_path): + src = tmp_path / "lib" / "src" + src.mkdir(parents=True) + kept = src / "a.c" + excluded = src / "hasty.c" + kept.write_text("int a;") + excluded.write_text("int b;") + + result = collect_filtered_files(tmp_path, ["+", "-"]) + assert str(kept) in result + assert str(excluded) not in result + + +def test_collect_filtered_files_exclude_unnormalized_glob_output(tmp_path, monkeypatch): + # On Windows, glob keeps the pattern's literal separators for non-wildcard + # path components, so the "+" wildcard pattern and the "-" literal pattern + # yield the same file spelled differently and the exclude set difference + # misses it. Backslash is a regular filename character on POSIX (such paths + # fail the final is_file filter), so reproduce the unnormalized-output + # mismatch portably with dot segments, which normpath also collapses. + src = tmp_path / "lib" / "src" + src.mkdir(parents=True) + kept = src / "a.c" + excluded = src / "hasty.c" + kept.write_text("int a;") + excluded.write_text("int b;") + + real_glob = glob.glob + + def unnormalized_glob(pattern, recursive=False): + if "*" in pattern: + base = str(tmp_path) + return [base + "/lib/./src/a.c", base + "/lib/./src/hasty.c"] + return real_glob(pattern, recursive=recursive) + + monkeypatch.setattr(glob, "glob", unnormalized_glob) + + result = collect_filtered_files(tmp_path, ["+", "-"]) + assert [Path(r).name for r in result] == ["a.c"] + assert str(kept) in result + + def test_split_list_by_condition(): items = ["-Iinclude", "-Llib", "-Wall"] From 4062f0a3238323c85442d76a29ce74b0e99ca7a5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Jul 2026 15:46:16 -1000 Subject: [PATCH 0994/1815] Pin cryptography to 48.0.1 on Intel macOS (#17658) --- requirements.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3c9d71e5ce..9c78597360 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,7 @@ -cryptography==49.0.0 +# cryptography 49+ ships no Intel macOS wheels (arm64 only); esptool caps <49 there. +# Keep 48.0.1, the last universal2 release, so esphome stays installable on Intel Macs. +cryptography==49.0.0; platform_system != "Darwin" or platform_machine != "x86_64" +cryptography==48.0.1; platform_system == "Darwin" and platform_machine == "x86_64" voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 From 572fc033cd6148146dfe079639a67d3bae581b0d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Jul 2026 15:59:12 -1000 Subject: [PATCH 0995/1815] Ship component requirements.txt files in the sdist and wheel (#17660) --- MANIFEST.in | 1 + 1 file changed, 1 insertion(+) diff --git a/MANIFEST.in b/MANIFEST.in index e426627e8d..1626261fb6 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -6,3 +6,4 @@ recursive-include esphome *.cpp *.h *.tcc *.c recursive-include esphome *.py.script recursive-include esphome *.jinja recursive-include esphome LICENSE.txt +recursive-include esphome requirements.txt From 0dfb573e186fc47a84a20dcc6f0f4f8ca4035f4d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Jul 2026 17:16:14 -1000 Subject: [PATCH 0996/1815] [logs] Cap the logs reconnect backoff for deep-sleep devices (#17656) --- esphome/components/api/client.py | 3 ++ .../unit_tests/components/api/test_client.py | 34 +++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 44edc035f9..3473deec83 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -152,6 +152,9 @@ async def async_run_logs( name=name, subscribe_states=subscribe_states, allow_plaintext_fallback=True, + # A top-level ``deep_sleep:`` block means the device is only awake + # briefly; cap the reconnect backoff so a wake window is not missed. + deep_sleep="deep_sleep" in config, ) try: await asyncio.Event().wait() diff --git a/tests/unit_tests/components/api/test_client.py b/tests/unit_tests/components/api/test_client.py index 333ef70b22..379705f534 100644 --- a/tests/unit_tests/components/api/test_client.py +++ b/tests/unit_tests/components/api/test_client.py @@ -2,11 +2,14 @@ from __future__ import annotations -from unittest.mock import patch +from unittest.mock import AsyncMock, patch + +import pytest from esphome.components import esp32 from esphome.components.api import client as api_client -from esphome.core import EsphomeError +from esphome.const import CONF_PORT, KEY_CORE, KEY_TARGET_PLATFORM +from esphome.core import CORE, EsphomeError def test_decoder_swallows_esphome_error() -> None: @@ -112,3 +115,30 @@ def test_decoder_uses_platform_handler_when_provided() -> None: assert calls == [(config, "BT0: 0x4010496e", False)] assert mock_generic.called is False assert processor.backtrace_state is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("extra_config", "expected_deep_sleep"), + [({"deep_sleep": {}}, True), ({}, False)], +) +async def test_async_run_logs_passes_deep_sleep( + extra_config: dict, expected_deep_sleep: bool +) -> None: + """async_run_logs tells async_run whether the device deep sleeps, from the config.""" + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}, **extra_config} + # async_run blocks forever after connecting; raise to unwind async_run_logs + # once we have captured how it was called. + sentinel = RuntimeError("stop the wait") + + with ( + patch.object( + api_client, "async_run", AsyncMock(side_effect=sentinel) + ) as mock_run, + patch.object(api_client, "APIClient"), + pytest.raises(RuntimeError, match="stop the wait"), + ): + await api_client.async_run_logs(config, ["1.2.3.4"]) + + assert mock_run.call_args.kwargs["deep_sleep"] is expected_deep_sleep From d4443be0c191ce6db74051e6167ce06a65c02ea2 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:22:23 +1200 Subject: [PATCH 0997/1815] [nrf52] Install PlatformIO toolchain Python packages into a dedicated venv (#17635) --- esphome/components/nrf52/__init__.py | 12 +- esphome/components/nrf52/framework.py | 82 ++++++++++ tests/unit_tests/test_nrf52_framework.py | 181 +++++++++++++++++++++++ tests/unit_tests/test_nrf52_upload.py | 66 +++++++++ 4 files changed, 340 insertions(+), 1 deletion(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 8d522a8740..5b3c250f34 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -69,7 +69,12 @@ from .const import ( BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, ) -from .framework import check_and_install, get_build_env, get_build_paths +from .framework import ( + check_and_install, + get_build_env, + get_build_paths, + setup_platformio_python_env, +) # force import gpio to register pin schema from .gpio import nrf52_pin_to_code # noqa: F401 @@ -514,6 +519,7 @@ def _upload_using_platformio( ) -> int | str: from esphome.platformio import toolchain + setup_platformio_python_env() if port is not None: upload_args += ["--upload-port", port] return toolchain.run_platformio_cli_run(config, CORE.verbose, *upload_args) @@ -809,6 +815,10 @@ def _copy_if_exists(src: Path, dst: Path) -> None: def run_compile(args, config: ConfigType) -> bool: if CORE.using_toolchain_platformio: + # The actual build is done by PlatformIO (the caller falls through to + # it when this returns False); prepare the Python environment its + # Zephyr build script expects first. + setup_platformio_python_env() return False if not CORE.using_toolchain_sdk_nrf: raise EsphomeError( diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 623cd4eef3..7392ad2d60 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -4,6 +4,7 @@ import os from pathlib import Path import platform import shutil +import sys import tempfile import platformdirs @@ -27,6 +28,11 @@ _LOGGER = logging.getLogger(__name__) _REQUIREMENTS = Path(__file__).parent / "requirements.txt" TOOLCHAIN_VERSION = "0.17.4" +# Packages the PlatformIO toolchain's Zephyr build script needs beyond west +# (which comes from requirements.txt). Keep the pin in sync with +# framework-sdk-nrf scripts/platformio/platformio-build.py. +_PLATFORMIO_PENV_REQUIREMENTS: tuple[str, ...] = ("cbor2==5.6.5",) + SDK_NG_TOOLCHAIN_MIRRORS = str_to_lst_of_str( os.environ.get( "ESPHOME_SDK_NG_TOOLCHAIN_MIRRORS", @@ -145,6 +151,82 @@ def get_build_env() -> dict: return env +def _get_platformio_penv_path() -> Path: + return get_sdk_nrf_tools_path() / "penvs" / "platformio" + + +def _get_penv_site_packages(penv_path: Path) -> Path: + if os.name == "nt": + return penv_path / "Lib" / "site-packages" + python_dir = f"python{sys.version_info.major}.{sys.version_info.minor}" + return penv_path / "lib" / python_dir / "site-packages" + + +def _prepend_env_path(name: str, entry: str) -> None: + """Prepend ``entry`` to the ``os.pathsep``-separated env var ``name``.""" + current = os.environ.get(name, "") + entries = current.split(os.pathsep) if current else [] + if entry not in entries: + os.environ[name] = os.pathsep.join([entry, *entries]) + + +def setup_platformio_python_env() -> None: + """Make the Zephyr build's Python packages available to PlatformIO. + + The PlatformIO toolchain's Zephyr framework build script pip-installs + west and cbor2 (and pyocd on x86_64) into the Python environment running + PlatformIO whenever they are not importable. That environment is not + always writable — for example the docker image run as a non-root user, + where ESPHome lives in the system Python — so the install fails with + "Permission denied". Instead, pre-install those packages into a dedicated + venv under the sdk-nrf tools dir and expose it to the PlatformIO + subprocesses through the environment: + + * PYTHONPATH makes the venv's packages importable from the interpreter + that runs PlatformIO/SCons, so the build script skips its installs. + * VIRTUAL_ENV redirects any install the build script still performs via + uv (pyocd is fetched on demand) into the writable venv. + * PATH exposes console scripts installed into the venv (e.g. pyocd). + """ + penv_path = _get_platformio_penv_path() + env_python_path = get_python_env_executable_path(penv_path, "python") + sentinel = penv_path / ".ready" + # Include the Python version: the venv breaks when the interpreter it + # was created from is upgraded, so it must be rebuilt. + requirements_hash = hashlib.sha256( + _REQUIREMENTS.read_bytes() + + "\n".join(_PLATFORMIO_PENV_REQUIREMENTS).encode() + + f"python{sys.version_info.major}.{sys.version_info.minor}".encode() + ).hexdigest() + if ( + not sentinel.exists() + or sentinel.read_text(encoding="utf-8") != requirements_hash + ): + rmdir(penv_path, msg="Clean up PlatformIO toolchain Python environment") + + create_venv(penv_path, msg="PlatformIO toolchain") + + _LOGGER.info("Installing PlatformIO toolchain requirements ...") + cmd = [ + str(env_python_path), + "-m", + "pip", + "install", + "-r", + str(_REQUIREMENTS), + *_PLATFORMIO_PENV_REQUIREMENTS, + ] + if not run_command_ok(cmd): + raise EsphomeError( + "Install requirements for PlatformIO toolchain Python environment failure" + ) + sentinel.write_text(requirements_hash, encoding="utf-8") + + os.environ["VIRTUAL_ENV"] = str(penv_path) + _prepend_env_path("PYTHONPATH", str(_get_penv_site_packages(penv_path))) + _prepend_env_path("PATH", str(env_python_path.parent)) + + def _patch_uf2conv_escape_sequences(framework_path: Path) -> None: # SDK v2.6.1 ships uf2conv.py with '\s+' — an unrecognised escape that # Python 3.12+ flags with SyntaxWarning (a future version will reject it). diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index 830e9efba5..8a5f4377d3 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -3,18 +3,23 @@ import hashlib import os from pathlib import Path +import sys from types import SimpleNamespace from unittest.mock import patch import pytest from esphome.components.nrf52.framework import ( + _PLATFORMIO_PENV_REQUIREMENTS, _REQUIREMENTS, TOOLCHAIN_VERSION, + _get_penv_site_packages, + _get_platformio_penv_path, _get_toolchain_platform_info, check_and_install, get_build_env, get_sdk_nrf_tools_path, + setup_platformio_python_env, ) from esphome.config_validation import Version from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION @@ -255,6 +260,182 @@ class TestCheckAndInstall: assert substitutions["extension"] == "tar.xz" +# --------------------------------------------------------------------------- +# setup_platformio_python_env tests +# --------------------------------------------------------------------------- + + +def _platformio_requirements_hash() -> str: + return hashlib.sha256( + _REQUIREMENTS.read_bytes() + + "\n".join(_PLATFORMIO_PENV_REQUIREMENTS).encode() + + f"python{sys.version_info.major}.{sys.version_info.minor}".encode() + ).hexdigest() + + +@pytest.fixture +def platformio_penv_dir() -> Path: + """Pre-create the PlatformIO penv dir so sentinel writes succeed. + + create_venv is mocked in these tests, so the directory it would have + created must exist for ``sentinel.write_text`` to work. + """ + penv_path = _get_platformio_penv_path() + penv_path.mkdir(parents=True, exist_ok=True) + return penv_path + + +class TestSetupPlatformioPythonEnv: + def test_fresh_install_creates_venv_and_sets_env( + self, + platformio_penv_dir: Path, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """No sentinel → venv created, requirements installed, env exported.""" + with patch.dict(os.environ): + os.environ.pop("PYTHONPATH", None) + + setup_platformio_python_env() + + mock_nrf52_ops.rmdir.assert_called_once() + mock_nrf52_ops.create_venv.assert_called_once_with( + platformio_penv_dir, msg="PlatformIO toolchain" + ) + mock_nrf52_ops.run_command_ok.assert_called_once() + cmd = mock_nrf52_ops.run_command_ok.call_args[0][0] + assert cmd[1:4] == ["-m", "pip", "install"] + assert "-r" in cmd + assert str(_REQUIREMENTS) in cmd + for requirement in _PLATFORMIO_PENV_REQUIREMENTS: + assert requirement in cmd + sentinel = platformio_penv_dir / ".ready" + assert sentinel.read_text(encoding="utf-8") == ( + _platformio_requirements_hash() + ) + + assert os.environ["VIRTUAL_ENV"] == str(platformio_penv_dir) + site_packages = str(_get_penv_site_packages(platformio_penv_dir)) + assert os.environ["PYTHONPATH"] == site_packages + bin_dir = str( + get_python_env_executable_path(platformio_penv_dir, "python").parent + ) + assert os.environ["PATH"].split(os.pathsep)[0] == bin_dir + + def test_ready_sentinel_skips_install_but_sets_env( + self, + platformio_penv_dir: Path, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Current sentinel → no install work, env vars still exported.""" + (platformio_penv_dir / ".ready").write_text( + _platformio_requirements_hash(), encoding="utf-8" + ) + + with patch.dict(os.environ): + setup_platformio_python_env() + + mock_nrf52_ops.rmdir.assert_not_called() + mock_nrf52_ops.create_venv.assert_not_called() + mock_nrf52_ops.run_command_ok.assert_not_called() + assert os.environ["VIRTUAL_ENV"] == str(platformio_penv_dir) + + def test_stale_sentinel_reinstalls( + self, + platformio_penv_dir: Path, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A sentinel from different requirements → venv rebuilt from scratch.""" + sentinel = platformio_penv_dir / ".ready" + sentinel.write_text("stale-hash", encoding="utf-8") + + with patch.dict(os.environ): + setup_platformio_python_env() + + mock_nrf52_ops.rmdir.assert_called_once() + mock_nrf52_ops.create_venv.assert_called_once() + mock_nrf52_ops.run_command_ok.assert_called_once() + assert sentinel.read_text(encoding="utf-8") == _platformio_requirements_hash() + + def test_install_failure_raises( + self, + platformio_penv_dir: Path, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Failing pip install raises EsphomeError and writes no sentinel.""" + mock_nrf52_ops.run_command_ok.return_value = False + + with ( + patch.dict(os.environ), + pytest.raises( + EsphomeError, match="Install requirements for PlatformIO toolchain" + ), + ): + setup_platformio_python_env() + + assert not (platformio_penv_dir / ".ready").exists() + + def test_repeated_calls_do_not_duplicate_env_entries( + self, + platformio_penv_dir: Path, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Compile then upload in one process must not grow PYTHONPATH/PATH.""" + (platformio_penv_dir / ".ready").write_text( + _platformio_requirements_hash(), encoding="utf-8" + ) + site_packages = str(_get_penv_site_packages(platformio_penv_dir)) + bin_dir = str( + get_python_env_executable_path(platformio_penv_dir, "python").parent + ) + + with patch.dict(os.environ): + setup_platformio_python_env() + setup_platformio_python_env() + + assert os.environ["PYTHONPATH"].split(os.pathsep).count(site_packages) == 1 + assert os.environ["PATH"].split(os.pathsep).count(bin_dir) == 1 + + def test_existing_pythonpath_preserved( + self, + platformio_penv_dir: Path, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A pre-existing PYTHONPATH keeps its entries after the venv entry.""" + (platformio_penv_dir / ".ready").write_text( + _platformio_requirements_hash(), encoding="utf-8" + ) + site_packages = str(_get_penv_site_packages(platformio_penv_dir)) + + with patch.dict(os.environ, {"PYTHONPATH": "/existing/path"}): + setup_platformio_python_env() + + assert os.environ["PYTHONPATH"] == os.pathsep.join( + [site_packages, "/existing/path"] + ) + + +@pytest.mark.parametrize( + ("os_name", "expected_parts"), + [ + ( + "posix", + ( + "lib", + f"python{sys.version_info.major}.{sys.version_info.minor}", + "site-packages", + ), + ), + ("nt", ("Lib", "site-packages")), + ], +) +def test_get_penv_site_packages( + tmp_path: Path, os_name: str, expected_parts: tuple[str, ...] +) -> None: + penv_path = tmp_path / "penv" + with patch("os.name", os_name): + assert _get_penv_site_packages(penv_path) == penv_path.joinpath(*expected_parts) + + # --------------------------------------------------------------------------- # get_build_env tests # --------------------------------------------------------------------------- diff --git a/tests/unit_tests/test_nrf52_upload.py b/tests/unit_tests/test_nrf52_upload.py index a60e23a337..9b738ebc81 100644 --- a/tests/unit_tests/test_nrf52_upload.py +++ b/tests/unit_tests/test_nrf52_upload.py @@ -146,6 +146,72 @@ class TestUploadProgramPyocd: upload_program(config={}, args=None, host="PYOCD") +# --------------------------------------------------------------------------- +# PlatformIO toolchain paths +# --------------------------------------------------------------------------- + + +class TestRunCompilePlatformio: + def test_prepares_python_env_and_delegates_to_platformio( + self, setup_core: Path, tmp_path: Path + ) -> None: + """The PlatformIO toolchain prepares the env, then returns False so PlatformIO builds.""" + from esphome.components.nrf52 import run_compile + + _setup_nrf52_core(toolchain=Toolchain.PLATFORMIO, build_path=tmp_path / "build") + + with patch( + "esphome.components.nrf52.setup_platformio_python_env" + ) as mock_setup: + assert run_compile(args=None, config={}) is False + + mock_setup.assert_called_once_with() + + +class TestUploadProgramSerialPlatformio: + def _upload(self, host: str, tmp_path: Path, run_result: int) -> tuple: + from esphome.components.nrf52 import upload_program + from esphome.upload_targets import PortType + + _setup_nrf52_core(toolchain=Toolchain.PLATFORMIO, build_path=tmp_path / "build") + CORE.config_path = tmp_path / "test.yaml" + + with ( + patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL), + patch("esphome.__main__.check_permissions"), + patch("esphome.components.nrf52.setup_platformio_python_env") as mock_setup, + patch( + "esphome.platformio.toolchain.run_platformio_cli_run", + return_value=run_result, + ) as mock_run, + ): + result = upload_program(config={}, args=None, host=host) + return result, mock_setup, mock_run + + def test_serial_upload_prepares_env_and_runs_platformio( + self, setup_core: Path, tmp_path: Path + ) -> None: + """Serial upload with the PlatformIO toolchain runs pio with -t upload.""" + host = "/dev/ttyACM0" + result, mock_setup, mock_run = self._upload(host, tmp_path, run_result=0) + + assert result is True + mock_setup.assert_called_once_with() + mock_run.assert_called_once() + run_args = mock_run.call_args[0] + assert "-t" in run_args + assert "upload" in run_args + assert "--upload-port" in run_args + assert host in run_args + + def test_serial_upload_failure_raises( + self, setup_core: Path, tmp_path: Path + ) -> None: + """A non-zero PlatformIO result must raise EsphomeError.""" + with pytest.raises(EsphomeError, match="Upload failed"): + self._upload("/dev/ttyACM0", tmp_path, run_result=1) + + # --------------------------------------------------------------------------- # Serial DFU upload path # --------------------------------------------------------------------------- From a0fb14bf548c3705a5408c7c46a6fc76c0c3faa5 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:44:52 -1000 Subject: [PATCH 0998/1815] Bump bundled esphome-device-builder to 1.6.4 (#17662) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index b60bfac7a2..f804ebd148 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.6.3 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.4 RUN \ platformio settings set enable_telemetry No \ From f37dad683b87c64578b79c883cadfa7a86ebc76e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:14:42 -0400 Subject: [PATCH 0999/1815] [esp32] Bump recommended ESP-IDF to 5.5.5 and Arduino to 3.3.10 (#17669) --- esphome/components/esp32/__init__.py | 15 +++++++++------ platformio.ini | 6 +++--- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 9b568dd629..7911b172d3 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -814,14 +814,15 @@ def _is_framework_url(source: str) -> bool: # The default/recommended arduino framework version # - https://github.com/espressif/arduino-esp32/releases ARDUINO_FRAMEWORK_VERSION_LOOKUP = { - "recommended": cv.Version(3, 3, 9), - "latest": cv.Version(3, 3, 9), - "dev": cv.Version(3, 3, 9), + "recommended": cv.Version(3, 3, 10), + "latest": cv.Version(3, 3, 10), + "dev": cv.Version(3, 3, 10), } ARDUINO_PLATFORM_VERSION_LOOKUP = { cv.Version( 4, 0, 0, "alpha1" ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", + cv.Version(3, 3, 10): cv.Version(55, 3, 39), cv.Version(3, 3, 9): cv.Version(55, 3, 39), cv.Version(3, 3, 8): cv.Version(55, 3, 38, "1"), cv.Version(3, 3, 7): cv.Version(55, 3, 37), @@ -844,6 +845,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = { # See: https://github.com/pioarduino/esp-idf/releases ARDUINO_IDF_VERSION_LOOKUP = { cv.Version(4, 0, 0, "alpha1"): cv.Version(6, 0, 1), + cv.Version(3, 3, 10): cv.Version(5, 5, 5), cv.Version(3, 3, 9): cv.Version(5, 5, 4), cv.Version(3, 3, 8): cv.Version(5, 5, 4), cv.Version(3, 3, 7): cv.Version(5, 5, 3, "1"), @@ -865,9 +867,9 @@ ARDUINO_IDF_VERSION_LOOKUP = { # The default/recommended esp-idf framework version # - https://github.com/espressif/esp-idf/releases ESP_IDF_FRAMEWORK_VERSION_LOOKUP = { - "recommended": cv.Version(5, 5, 4), - "latest": cv.Version(5, 5, 4), - "dev": cv.Version(5, 5, 4), + "recommended": cv.Version(5, 5, 5), + "latest": cv.Version(5, 5, 5), + "dev": cv.Version(5, 5, 5), } ESP_IDF_PLATFORM_VERSION_LOOKUP = { @@ -877,6 +879,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { cv.Version( 6, 0, 0 ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", + cv.Version(5, 5, 5): cv.Version(55, 3, 39), cv.Version(5, 5, 4): cv.Version(55, 3, 39), cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37), cv.Version(5, 5, 3): cv.Version(55, 3, 37), diff --git a/platformio.ini b/platformio.ini index 061e92a64a..2ab90e63ad 100644 --- a/platformio.ini +++ b/platformio.ini @@ -143,8 +143,8 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script extends = common:arduino platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip platform_packages = - pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.9/esp32-core-3.3.9.tar.xz - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz + pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.10/esp32-core-3.3.10.tar.xz + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz framework = arduino, espidf ; Arduino as an ESP-IDF component lib_deps = @@ -180,7 +180,7 @@ extra_scripts = extends = common:idf platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip platform_packages = - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz framework = espidf lib_deps = From f05e15522cd00989aa68bca1f314b7881d140e93 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 11:46:14 -1000 Subject: [PATCH 1000/1815] Bump bundled esphome-device-builder to 1.6.5 (#17675) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index f804ebd148..ecf1f0479a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.6.4 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.5 RUN \ platformio settings set enable_telemetry No \ From 2f99466f3ada990b3e07ade8f93e1e8a1a36dd11 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:25:56 -1000 Subject: [PATCH 1001/1815] Bump bundled esphome-device-builder to 1.6.6 (#17681) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index ecf1f0479a..5ab0e71008 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.6.5 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.6 RUN \ platformio settings set enable_telemetry No \ From 3d340f4d907f8a956dd10b358735a5b932abe06d Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:17:53 -1000 Subject: [PATCH 1002/1815] Bump bundled esphome-device-builder to 1.6.7 (#17696) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 5ab0e71008..331585f123 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.6.6 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.7 RUN \ platformio settings set enable_telemetry No \ From 2223b147947c0b398155c9a979ffee99128983a9 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:20:43 -0500 Subject: [PATCH 1003/1815] Split multi-token build.flags entries when generating ESP-IDF component CMakeLists (#17649) --- esphome/espidf/component.py | 21 ++++++++++++ tests/unit_tests/test_espidf_component.py | 41 +++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index e9ec170a5e..51d023099e 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -83,6 +83,10 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: Returns: str: The complete CMakeLists.txt content as a string """ + # Late import: this module loads with the esp32 platform on every + # validate/compile, but shlex is only needed when generating component + # CMakeLists. + import shlex def escape_entry(p: PathType) -> str: # In CMakeLists.txt, backslashes need to be escaped @@ -105,6 +109,23 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: build_flags = ensure_list( component.data.get("build", {}).get("flags", DEFAULT_BUILD_FLAGS) ) + # PlatformIO shell-lexes each build.flags entry, so one entry can carry a + # flag and its argument (e.g. "-include cp_custom_alloc.h"). Split the + # same way; emitting such an entry as a single quoted compile option + # hands the compiler one argv with an embedded space. + build_flags = [token for entry in build_flags for token in shlex.split(entry)] + # Re-glue bare -I/-L/-l tokens to their argument ("-I foo" -> "-Ifoo") so + # the prefix classifiers below still route them to INCLUDE_DIRS and the + # link handling. + tokens, build_flags = build_flags, [] + i = 0 + while i < len(tokens): + if tokens[i] in ("-I", "-L", "-l") and i + 1 < len(tokens): + build_flags.append(tokens[i] + tokens[i + 1]) + i += 2 + else: + build_flags.append(tokens[i]) + i += 1 # List all sources files build_src_files = collect_filtered_files( diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 055e9c8502..89d5ce3cf2 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -193,6 +193,47 @@ target_link_libraries(${{COMPONENT_LIB}} INTERFACE ) +def test_generate_cmakelists_txt_multi_token_flag(tmp_component): + # PlatformIO shell-lexes each build.flags entry, so a single entry can + # carry a flag and its argument. The generated CMakeLists must emit them + # as separate compile options, not one argument with an embedded space. + src_dir = tmp_component.path / "src" + src_dir.mkdir() + (src_dir / "main.c").write_text("int main() {}") + + tmp_component.data = {"build": {"flags": ["-include cp_custom_alloc.h", "-DTEST"]}} + + content = generate_cmakelists_txt(tmp_component) + assert '"-include cp_custom_alloc.h"' not in content + assert ' "-include"\n "cp_custom_alloc.h"\n' in content + + +def test_generate_cmakelists_txt_space_separated_classified_flags(tmp_component): + # Space-separated -I/-L/-l entries routed to INCLUDE_DIRS and the link + # handling before the shlex split was added; splitting must not leak + # them into raw compile options. + src_dir = tmp_component.path / "src" + src_dir.mkdir() + (src_dir / "main.c").write_text("int main() {}") + (tmp_component.path / "extra_inc").mkdir() + + tmp_component.data = { + "build": {"flags": ["-I extra_inc", "-L extra_lib", "-l extralib", "-DTEST"]} + } + + content = generate_cmakelists_txt(tmp_component) + assert 'INCLUDE_DIRS "src" "extra_inc"' in content + assert 'target_link_directories(${COMPONENT_LIB} INTERFACE\n "extra_lib"\n)' in ( + content + ) + assert 'target_link_libraries(${COMPONENT_LIB} INTERFACE\n "extralib"\n)' in ( + content + ) + assert '"-I"' not in content + assert '"-L"' not in content + assert '"-l"' not in content + + def test_generate_cmakelists_txt_references_project_managed_components_variable( tmp_component: IDFComponent, ) -> None: From 231a2897c060f0edce711cd5c5543862e00e046b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:51:46 +1200 Subject: [PATCH 1004/1815] [ssd1306] Fix offset_x being ignored on SH1106/SH1107 displays (#17700) --- .../components/ssd1306_i2c/ssd1306_i2c.cpp | 20 +++++++++---------- .../components/ssd1306_spi/ssd1306_spi.cpp | 15 ++++++++------ 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp b/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp index 8ff908fe7a..00c864a217 100644 --- a/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp +++ b/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp @@ -41,17 +41,17 @@ void I2CSSD1306::command(uint8_t value) { this->write_byte(0x00, value); } void HOT I2CSSD1306::write_display_data() { if (this->is_sh1106_() || this->is_sh1107_()) { uint32_t i = 0; + // Some panels wire their visible columns to a window of the controller RAM + // that does not start at column 0 (e.g. SH1107 M5Stack Unit OLED needs offset_x: 32). + // SH1106 keeps its historical 0x02 base column on top of any offset. + uint8_t start_column = this->offset_x_; + if (this->is_sh1106_()) { + start_column += 0x02; + } for (uint8_t page = 0; page < (uint8_t) this->get_height_internal() / 8; page++) { - this->command(0xB0 + page); // row - if (this->is_sh1106_()) { - this->command(0x02); // lower column - 0x02 is historical SH1106 value - } else { - // Other SH1107 drivers use 0x00 - // Column values dont change and it seems they can be set only once, - // but we follow SH1106 implementation and resend them - this->command(0x00); - } - this->command(0x10); // higher column + this->command(0xB0 + page); // row + this->command(start_column & 0x0F); // lower column + this->command(0x10 | (start_column >> 4)); // higher column for (uint8_t x = 0; x < (uint8_t) this->get_width_internal() / 16; x++) { uint8_t data[16]; for (uint8_t &j : data) diff --git a/esphome/components/ssd1306_spi/ssd1306_spi.cpp b/esphome/components/ssd1306_spi/ssd1306_spi.cpp index 5c9369f1a2..0534deeb03 100644 --- a/esphome/components/ssd1306_spi/ssd1306_spi.cpp +++ b/esphome/components/ssd1306_spi/ssd1306_spi.cpp @@ -38,14 +38,17 @@ void SPISSD1306::command(uint8_t value) { } void HOT SPISSD1306::write_display_data() { if (this->is_sh1106_() || this->is_sh1107_()) { + // Some panels wire their visible columns to a window of the controller RAM + // that does not start at column 0 (e.g. SH1107 M5Stack Unit OLED needs offset_x: 32). + // SH1106 keeps its historical 0x02 base column on top of any offset. + uint8_t start_column = this->offset_x_; + if (this->is_sh1106_()) { + start_column += 0x02; + } for (uint8_t y = 0; y < (uint8_t) this->get_height_internal() / 8; y++) { this->command(0xB0 + y); - if (this->is_sh1106_()) { - this->command(0x02); - } else { - this->command(0x00); - } - this->command(0x10); + this->command(start_column & 0x0F); // lower column + this->command(0x10 | (start_column >> 4)); // higher column this->dc_pin_->digital_write(true); for (uint8_t x = 0; x < (uint8_t) this->get_width_internal(); x++) { this->enable(); From b571d2a5abfd17101d4b0bfc94fb234797061e23 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Jul 2026 18:16:15 -1000 Subject: [PATCH 1005/1815] [micro_wake_word] Download models in parallel (#17701) --- .../components/micro_wake_word/__init__.py | 97 +++++++--- esphome/external_files.py | 12 +- .../components/micro_wake_word/__init__.py | 0 .../components/micro_wake_word/test_init.py | 169 ++++++++++++++++++ 4 files changed, 247 insertions(+), 31 deletions(-) create mode 100644 tests/unit_tests/components/micro_wake_word/__init__.py create mode 100644 tests/unit_tests/components/micro_wake_word/test_init.py diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index 4b309551ba..c427f28028 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -2,6 +2,7 @@ import hashlib import json import logging from pathlib import Path +import re from urllib.parse import urljoin from esphome import automation, external_files, git @@ -9,6 +10,7 @@ from esphome.automation import register_action, register_condition from esphome.bundle import add_bundle_file import esphome.codegen as cg from esphome.components import esp32, microphone, ota, psram +from esphome.components.http_request import validate_url import esphome.config_validation as cv from esphome.const import ( CONF_FILE, @@ -209,33 +211,13 @@ def _validate_manifest_version(manifest_data): raise cv.Invalid("Invalid manifest file, missing 'version' key") -def _process_http_source(config): - url = config[CONF_URL] - path = _compute_local_file_path(config) - - json_path = path / "manifest.json" - - json_contents = external_files.download_content(url, json_path) - - manifest_data = json.loads(json_contents) - if not isinstance(manifest_data, dict): - raise cv.Invalid("Manifest file must contain a JSON object") - - model = manifest_data[CONF_MODEL] - model_url = urljoin(url, model) - - model_path = path / model - - external_files.download_content(str(model_url), model_path) - - return config - - -HTTP_SCHEMA = cv.All( +HTTP_SCHEMA = cv.Schema( { - cv.Required(CONF_URL): cv.url, - }, - _process_http_source, + # validate_url only accepts http(s); the shorthand validator relies + # on this branch rejecting git shorthands ("github://...") so they + # fall through to the git branch. + cv.Required(CONF_URL): validate_url, + } ) @@ -280,6 +262,13 @@ LOCAL_SCHEMA = cv.All( ) +# Bare model names in the official model repository ("okay_nabu"). Must not +# overlap with local paths, http(s) urls, or git shorthands +# ("github://user/repo/file.json@ref"), which the shorthand validator tries +# next; anything containing "/", ":" or "@" is not a model name. +_MODEL_NAME_RE = re.compile(r"[A-Za-z0-9_.-]+") + + def _validate_source_model_name(value): if not isinstance(value, str): raise cv.Invalid("Model name must be a string") @@ -287,6 +276,9 @@ def _validate_source_model_name(value): if value.endswith(".json"): raise cv.Invalid("Model name must not end with .json") + if not _MODEL_NAME_RE.fullmatch(value): + raise cv.Invalid("Model name may only contain letters, numbers, . _ -") + return MODEL_SOURCE_SCHEMA( { CONF_TYPE: TYPE_HTTP, @@ -376,6 +368,58 @@ def _maybe_empty_vad_schema(value): return VAD_MODEL_SCHEMA(value) +def _download_http_models(config: ConfigType) -> ConfigType: + """Download every http-sourced manifest and model file in two concurrent + batches (all manifests, then all model files). + + The model file's URL only becomes known once its manifest has been + fetched and parsed, so the two stages cannot be merged into one batch. + """ + model_parameters = [*config[CONF_MODELS]] + if vad := config.get(CONF_VAD): + model_parameters.append(vad) + # Keyed by cache path so a URL referenced twice is fetched and parsed once + http_models: dict[Path, str] = { + _compute_local_file_path(model_config): model_config[CONF_URL] + for parameters in model_parameters + if (model_config := parameters.get(CONF_MODEL)) is not None + and model_config.get(CONF_TYPE) == TYPE_HTTP + } + if not http_models: + return config + + external_files.download_content_many( + ((url, path / "manifest.json") for path, url in http_models.items()), + description="wake word manifest(s)", + ) + + model_files: list[tuple[str, Path]] = [] + errors: list[cv.Invalid] = [] + for path, url in http_models.items(): + try: + manifest_data = json.loads((path / "manifest.json").read_bytes()) + except (OSError, ValueError) as e: + errors.append(cv.Invalid(f"Invalid manifest file at {url}: {e}")) + continue + if not isinstance(manifest_data, dict): + errors.append( + cv.Invalid(f"Manifest file at {url} must contain a JSON object") + ) + continue + model = manifest_data.get(CONF_MODEL) + if not isinstance(model, str): + errors.append( + cv.Invalid(f"Manifest file at {url} is missing the 'model' key") + ) + continue + model_files.append((urljoin(url, model), path / model)) + if errors: + raise cv.MultipleInvalid(errors) + + external_files.download_content_many(model_files, description="wake word model(s)") + return config + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -409,6 +453,7 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), cv.only_on_esp32, + _download_http_models, ) diff --git a/esphome/external_files.py b/esphome/external_files.py index 4e73c8dc21..69423d3999 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -165,11 +165,8 @@ def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> by _LOGGER.debug("Remote file has not changed %s", url) return path.read_bytes() - _LOGGER.debug( - "Remote file has changed, downloading from %s to %s", - url, - path, - ) + _LOGGER.info("Downloading %s", url) + _LOGGER.debug("Saving to %s", path) try: req = requests.get( @@ -210,9 +207,13 @@ def download_content_many( items: Iterable[tuple[str, Path]], timeout: int = NETWORK_TIMEOUT, max_workers: int = DEFAULT_DOWNLOAD_WORKERS, + description: str = "remote file(s)", ) -> None: """Run `download_content` for each (url, path) pair concurrently. + `description` names the kind of files in the progress log line, e.g. + "wake word manifest(s)". + Wall time drops from `sum(latency)` to roughly `max(latency)` for cached files where the HEAD round-trip dominates. All workers run to completion before this returns; every `cv.Invalid` raised by a worker @@ -230,6 +231,7 @@ def download_content_many( seen: dict[Path, str] = {path: url for url, path in items} if not seen: return + _LOGGER.info("Checking %d %s for updates", len(seen), description) if len(seen) == 1: path, url = next(iter(seen.items())) download_content(url, path, timeout) diff --git a/tests/unit_tests/components/micro_wake_word/__init__.py b/tests/unit_tests/components/micro_wake_word/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/micro_wake_word/test_init.py b/tests/unit_tests/components/micro_wake_word/test_init.py new file mode 100644 index 0000000000..84371ab906 --- /dev/null +++ b/tests/unit_tests/components/micro_wake_word/test_init.py @@ -0,0 +1,169 @@ +"""Tests for the micro_wake_word model source validation and downloads.""" + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from esphome.components import micro_wake_word as mww +import esphome.config_validation as cv +from esphome.const import ( + CONF_FILE, + CONF_MODEL, + CONF_PATH, + CONF_REF, + CONF_TYPE, + CONF_URL, +) + + +@pytest.fixture +def mock_download_content_many() -> MagicMock: + """Patch the concurrent download helper so no network is involved.""" + with patch( + "esphome.components.micro_wake_word.external_files.download_content_many" + ) as m: + yield m + + +def test_shorthand_model_name_resolves_without_network( + mock_download_content_many: MagicMock, +) -> None: + config = mww._validate_source_shorthand("okay_nabu") + assert config[CONF_TYPE] == mww.TYPE_HTTP + assert config[CONF_URL] == ( + "https://github.com/esphome/micro-wake-word-models/raw/main/models/v2/okay_nabu.json" + ) + mock_download_content_many.assert_not_called() + + +def test_shorthand_git_with_ref_not_captured_as_model_name( + setup_core: Path, tmp_path: Path +) -> None: + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + (repo_dir / "model.json").write_text("{}") + with patch( + "esphome.components.micro_wake_word.git.clone_or_update", + return_value=(repo_dir, None), + ): + config = mww._validate_source_shorthand("github://user/repo/model.json@main") + assert config[CONF_TYPE] == "git" + assert config[CONF_URL] == "https://github.com/user/repo.git" + assert config[CONF_FILE] == "model.json" + assert config[CONF_REF] == "main" + + +def test_shorthand_local_path_not_captured_as_model_name( + setup_core: Path, tmp_path: Path +) -> None: + manifest = tmp_path / "model.json" + manifest.write_text("{}") + config = mww.MODEL_SOURCE_SCHEMA(str(manifest)) + assert config[CONF_TYPE] == "local" + assert Path(config[CONF_PATH]) == manifest + + +@pytest.mark.parametrize( + "value", ["some/path/file", "name@ref", "bad:name", "okay_nabu\n", "héllo"] +) +def test_model_name_rejects_non_identifiers(value: str) -> None: + with pytest.raises(cv.Invalid): + mww._validate_source_model_name(value) + + +def _http_model(name: str) -> dict: + return { + CONF_MODEL: { + CONF_TYPE: mww.TYPE_HTTP, + CONF_URL: f"https://example.com/models/{name}.json", + } + } + + +def _write_manifest(model_config: dict, contents: str) -> Path: + path = mww._compute_local_file_path(model_config[CONF_MODEL]) + path.mkdir(parents=True, exist_ok=True) + manifest = path / "manifest.json" + manifest.write_text(contents) + return path + + +def test_download_http_models_batches_manifests_then_models( + setup_core: Path, mock_download_content_many: MagicMock +) -> None: + names = ("okay_nabu", "hey_mycroft", "vad") + models = {name: _http_model(name) for name in names} + paths = { + name: _write_manifest(models[name], json.dumps({"model": f"{name}.tflite"})) + for name in names + } + config = { + mww.CONF_MODELS: [ + models["okay_nabu"], + models["hey_mycroft"], + # non-http sources must be ignored + {CONF_MODEL: {CONF_TYPE: "local", CONF_PATH: "x"}}, + ], + mww.CONF_VAD: models["vad"], + } + + assert mww._download_http_models(config) is config + + assert mock_download_content_many.call_count == 2 + manifest_items = list(mock_download_content_many.call_args_list[0].args[0]) + assert manifest_items == [ + (f"https://example.com/models/{name}.json", paths[name] / "manifest.json") + for name in names + ] + model_items = list(mock_download_content_many.call_args_list[1].args[0]) + assert model_items == [ + (f"https://example.com/models/{name}.tflite", paths[name] / f"{name}.tflite") + for name in names + ] + + +def test_download_http_models_no_http_sources_skips_download( + mock_download_content_many: MagicMock, +) -> None: + config = {mww.CONF_MODELS: [{CONF_MODEL: {CONF_TYPE: "local", CONF_PATH: "x"}}]} + assert mww._download_http_models(config) is config + mock_download_content_many.assert_not_called() + + +@pytest.mark.parametrize( + ("contents", "message"), + [ + ("not json", "Invalid manifest file"), + ("[1, 2]", "must contain a JSON object"), + ("{}", "missing the 'model' key"), + ], +) +def test_download_http_models_bad_manifest_raises( + setup_core: Path, + mock_download_content_many: MagicMock, + contents: str, + message: str, +) -> None: + model = _http_model("okay_nabu") + config = {mww.CONF_MODELS: [model]} + _write_manifest(model, contents) + + with pytest.raises(cv.Invalid, match=message): + mww._download_http_models(config) + # manifests were still fetched in one batch; the model batch never ran + assert mock_download_content_many.call_count == 1 + + +def test_download_http_models_collects_all_manifest_errors( + setup_core: Path, mock_download_content_many: MagicMock +) -> None: + models = {name: _http_model(name) for name in ("one", "two")} + config = {mww.CONF_MODELS: list(models.values())} + _write_manifest(models["one"], "not json") + _write_manifest(models["two"], "[1]") + + with pytest.raises(cv.MultipleInvalid) as excinfo: + mww._download_http_models(config) + assert len(excinfo.value.errors) == 2 From 5df922e0df505a67a4615c5ecea03dc9479406a7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Jul 2026 18:32:20 -1000 Subject: [PATCH 1006/1815] [platformio] Accept git URLs passed as the library name (#17697) --- esphome/platformio/library.py | 39 ++++++++--- tests/unit_tests/test_espidf_component.py | 78 +++++++++++++++++++++ tests/unit_tests/test_platformio_library.py | 42 +++++++++++ 3 files changed, 150 insertions(+), 9 deletions(-) diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 0ffac65e0d..72a50b795b 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -25,7 +25,7 @@ from pathlib import Path import re import tempfile from typing import Any -from urllib.parse import urlparse, urlsplit, urlunsplit +from urllib.parse import urlsplit, urlunsplit from esphome import git from esphome.core import CORE, Library @@ -523,6 +523,17 @@ class _LibNode: edges: set[str] = field(default_factory=set) +def _url_or_none(value: Any) -> str | None: + """Return ``value`` if it parses as a URL (scheme and host), else None.""" + if not value or not isinstance(value, str): + return None + try: + parsed = urlsplit(value) + except ValueError: + return None + return value if parsed.scheme and parsed.netloc else None + + def _node_key( name: str | None, version: str | None, repository: str | None ) -> tuple[str, bool, tuple[str | None, str | None]]: @@ -533,9 +544,23 @@ def _node_key( inconsistently -- bare ``name`` vs ``owner/name``, or git vs registry -- maps to distinct keys and isn't deduplicated; ``convert_libraries`` warns about that after resolution rather than merging the nodes. + + PlatformIO's Library Manager also accepted a git URL in the *name* + position (``add_library("https://github.com/x/y", None)``), including the + ``git+`` VCS prefix and the ``CustomName=URL`` form; recognize those here + so such specs resolve as git sources instead of failing a registry lookup. """ + if not repository and name and "://" in name: + # Try the whole name first so a bare URL whose query contains ``=`` + # stays intact; fall back to the ``CustomName=URL`` form, where the + # key derives from the URL path and the custom name is irrelevant. + repository = _url_or_none(name) or _url_or_none(name.split("=", 1)[-1]) + if repository is None: + # Anything with ``://`` was meant to be a URL; failing it fast + # beats a confusing registry "package not found" error. + raise RuntimeError(f"Invalid PIO library URL: {name}") if repository: - split_result = urlsplit(repository) + split_result = urlsplit(repository.removeprefix("git+")) key = str(split_result.path).strip("/").removesuffix(".git") ref = split_result.fragment.strip() or None url = urlunsplit(split_result._replace(fragment="")) @@ -687,13 +712,9 @@ def convert_libraries( continue # The version field may actually be a URL (git/archive dependency). dep_version = dependency["version"] - dep_url = None - try: - parsed = urlparse(dep_version) - if all([parsed.scheme, parsed.netloc]): - dep_url, dep_version = dep_version, None - except (TypeError, ValueError): - pass + dep_url = _url_or_none(dep_version) + if dep_url is not None: + dep_version = None dep_key = add_spec(dep_name, dep_version, dep_url) node.edges.add(dep_key) worklist.append(dep_key) diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 89d5ce3cf2..f9ed44b8d2 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -456,6 +456,84 @@ def test_node_key_git_no_ref(): assert locator == ("https://github.com/foo/bar.git", None) +def test_node_key_url_in_name_is_git(): + # add_library("https://github.com/x/y", None): PlatformIO accepted a bare + # git URL as the library name, so the converter must too. + key, is_git, locator = _node_key( + "https://github.com/pstolarz/OneWireNg", None, None + ) + assert key == "pstolarz/OneWireNg" + assert is_git is True + assert locator == ("https://github.com/pstolarz/OneWireNg", None) + + +def test_node_key_url_in_name_with_ref(): + key, is_git, locator = _node_key( + "https://github.com/foo/bar.git#v1.2.3", None, None + ) + assert (key, is_git, locator) == ( + "foo/bar", + True, + ("https://github.com/foo/bar.git", "v1.2.3"), + ) + + +def test_node_key_url_in_name_git_plus_prefix(): + key, is_git, locator = _node_key("git+https://github.com/foo/bar", None, None) + assert (key, is_git, locator) == ( + "foo/bar", + True, + ("https://github.com/foo/bar", None), + ) + + +def test_node_key_git_plus_prefix_in_repository(): + _key, is_git, locator = _node_key("name", None, "git+https://github.com/foo/bar") + assert (is_git, locator) == (True, ("https://github.com/foo/bar", None)) + + +def test_node_key_custom_name_equals_url_is_git(): + key, is_git, locator = _node_key( + "OneWireNg=https://github.com/pstolarz/OneWireNg", None, None + ) + assert (key, is_git, locator) == ( + "pstolarz/OneWireNg", + True, + ("https://github.com/pstolarz/OneWireNg", None), + ) + + +def test_node_key_url_in_name_with_query_containing_equals(): + # A bare URL whose query string contains ``=`` must not be split by the + # CustomName=URL handling. + key, is_git, locator = _node_key("https://host/x/y.git?ref=main", None, None) + assert (key, is_git, locator) == ( + "x/y", + True, + ("https://host/x/y.git?ref=main", None), + ) + + +@pytest.mark.parametrize("name", ["http://[::1", "CustomName=http://[::1"]) +def test_node_key_malformed_url_in_name_raises(name: str) -> None: + # A name that was clearly meant to be a URL but does not parse must fail + # fast instead of degrading to a confusing registry lookup error. + with pytest.raises(RuntimeError, match="Invalid PIO library URL"): + _node_key(name, None, None) + + +def test_node_key_name_with_equals_but_no_url_is_registry(): + key, is_git, locator = _node_key("FOO=BAR", "1.0", None) + assert (key, is_git, locator) == ("FOO=BAR", False, (None, "FOO=BAR")) + + +def test_node_key_version_url_still_ignored_when_name_plain(): + # A version that is a URL is handled by the dependency walk, not here; + # a plain name must stay a registry spec regardless of version shape. + key, is_git, _locator = _node_key("bar", "https://github.com/foo/bar", None) + assert (key, is_git) == ("bar", False) + + def test_node_key_registry_owner_name(): key, is_git, locator = _node_key("foo/bar", "^1.0.0", None) assert (key, is_git, locator) == ("foo/bar", False, ("foo", "bar")) diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index 03360eab37..6a4c057469 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -212,6 +212,48 @@ def test_convert_libraries_handles_unparsable_dependency_version(tmp_path, monke assert [d.name for d in top[0].dependencies] == ["C"] +@pytest.mark.parametrize( + ("value", "expected"), + [ + (None, None), + ("", None), + ("http://[::1", None), # malformed IPv6 makes urlsplit raise ValueError + ("foo/bar", None), + ("file:///no/host", None), + ("https://github.com/x/y", "https://github.com/x/y"), + ], +) +def test_url_or_none(value: str | None, expected: str | None) -> None: + assert lib._url_or_none(value) == expected + + +def test_convert_libraries_url_in_name_resolves_as_git( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # add_library("https://github.com/x/y", None) puts a git URL in the name + # position; it must resolve as a git source and never hit the registry. + _patch_download_with_manifests( + monkeypatch, tmp_path, {"pstolarz/OneWireNg": {"name": "OneWireNg"}} + ) + + def fail_registry(owner: str, pkgname: str, requirements: set[str]) -> None: + raise AssertionError(f"registry consulted for {owner}/{pkgname}") + + # After the helper so this stub wins over the helper's benign one + monkeypatch.setattr(lib, "_resolve_registry_version", fail_registry) + + top = convert_libraries( + [Library("https://github.com/pstolarz/OneWireNg", None, None)], _backend() + ) + + assert [c.name for c in top] == ["pstolarz/OneWireNg"] + assert top[0].data["name"] == "OneWireNg" + source = top[0].source + assert isinstance(source, GitSource) + assert source.url == "https://github.com/pstolarz/OneWireNg" + assert source.ref is None + + def test_convert_libraries_skips_incompatible_dependency(tmp_path, monkeypatch): # A dependency that declares an incompatible platform is skipped (the # top-level library still builds). From 9de7bd74618450860848e299b845b451ee946363 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Jul 2026 18:34:29 -1000 Subject: [PATCH 1007/1815] [git] Detect interrupted clones and re-clone automatically (#17690) --- esphome/git.py | 71 +++++++- tests/unit_tests/test_git.py | 309 ++++++++++++++++++++++++++++++++++- 2 files changed, 373 insertions(+), 7 deletions(-) diff --git a/esphome/git.py b/esphome/git.py index c4a612753b..0c1ad56367 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -10,14 +10,22 @@ import time import urllib.parse import esphome.config_validation as cv -from esphome.core import CORE, TimePeriodSeconds -from esphome.helpers import rmtree +from esphome.core import CORE, EsphomeError, TimePeriodSeconds +from esphome.helpers import rmtree, write_file _LOGGER = logging.getLogger(__name__) # Special value to indicate never refresh NEVER_REFRESH = TimePeriodSeconds(seconds=-1) +# Written inside .git only after every clone step (clone, ref fetch, reset, +# submodule init) has completed. A directory without it is an interrupted +# clone (e.g. the process was killed mid-clone) and must be re-cloned; without +# this check such a directory would be trusted forever when the caller uses +# NEVER_REFRESH. Lives in .git so stash/reset/checkout can never touch it and +# it does not pollute the worktree. +_CLONE_COMPLETE_MARKER = "esphome_clone_complete" + class GitException(cv.Invalid): """Base exception for git-related errors.""" @@ -95,6 +103,26 @@ def _compute_destination_path(key: str, domain: str) -> Path: return base_dir / h.hexdigest()[:8] +def _clone_complete_marker_path(repo_dir: Path) -> Path: + return repo_dir / ".git" / _CLONE_COMPLETE_MARKER + + +def _remove_repo_dir(repo_dir: Path) -> None: + """Remove a repo directory, deleting the completion marker first. + + Marker-first ordering guarantees an interrupted removal can never leave a + marker behind next to a partially deleted worktree. The unlink is best + effort: if it fails (e.g. a file lock on Windows), rmtree below still + gets the chance to remove the directory, marker included. + """ + try: + _clone_complete_marker_path(repo_dir).unlink(missing_ok=True) + except OSError as err: + _LOGGER.debug("Could not delete clone completion marker first: %s", err) + if repo_dir.is_dir(): + rmtree(repo_dir) + + def resolve_symlink_stub(repo_dir: Path, file_path: Path) -> Path | None: """Return the symlink target if ``file_path`` is a Windows-checked-out symlink stub. @@ -201,9 +229,19 @@ def clone_or_update( ) repo_dir = _compute_destination_path(key, domain) + hash_dir_name = repo_dir.name if subpath: repo_dir = repo_dir / subpath + if repo_dir.is_dir() and not _clone_complete_marker_path(repo_dir).is_file(): + # The last clone never finished (killed process, container stop) or + # predates the marker; either way it cannot be trusted, especially + # with NEVER_REFRESH where it would otherwise be reused forever. + _LOGGER.warning( + "Removing incomplete clone of %s at %s, will re-clone", key, repo_dir + ) + _remove_repo_dir(repo_dir) + if not repo_dir.is_dir(): _LOGGER.info("Cloning %s", key) _LOGGER.debug("Location: %s", repo_dir) @@ -233,14 +271,28 @@ def clone_or_update( + submodules, git_dir=repo_dir, ) + except GitException: # Remove incomplete clone to prevent stale state. Without this, # a failed ref fetch leaves a clone on the default branch, and # subsequent calls skip the update due to the refresh window. - if repo_dir.is_dir(): - rmtree(repo_dir) + _remove_repo_dir(repo_dir) raise + # Every git step succeeded; the key and hash dir name are recorded + # purely to make cache debugging easier. The marker is only a + # validity signal, so a failed write must not fail an otherwise + # complete clone: the only cost is a re-clone on the next run. + try: + write_file( + _clone_complete_marker_path(repo_dir), + f"key={key}\nhash={hash_dir_name}\n", + ) + except EsphomeError as err: + _LOGGER.warning( + "Could not write clone completion marker for %s: %s", key, err + ) + else: if refresh == NEVER_REFRESH or CORE.skip_external_update: _LOGGER.debug("Skipping update for %s (refresh disabled)", key) @@ -250,7 +302,13 @@ def clone_or_update( # On first clone, FETCH_HEAD does not exist if not file_timestamp.exists(): file_timestamp = Path(repo_dir / ".git" / "HEAD") - age_seconds = time.time() - file_timestamp.stat().st_mtime + try: + age_seconds = time.time() - file_timestamp.stat().st_mtime + except OSError: + # A .git with neither FETCH_HEAD nor HEAD is corrupt (e.g. a + # partially deleted clone). Force the update path so the + # broken-repository recovery below removes and re-clones it. + age_seconds = float("inf") if refresh is None or age_seconds > refresh.total_seconds: # Try to update the repository, recovering from broken state if needed old_sha: str | None = None @@ -303,7 +361,7 @@ def clone_or_update( err, ) _LOGGER.info("Removing broken repository at %s", repo_dir) - rmtree(repo_dir) + _remove_repo_dir(repo_dir) _LOGGER.info("Successfully removed broken repository, re-cloning...") # Recursively call clone_or_update to re-clone @@ -316,6 +374,7 @@ def clone_or_update( username=username, password=password, submodules=submodules, + subpath=subpath, _recover_broken=False, ) _LOGGER.info("Repository %s successfully recovered", key) diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index 62d2344069..c9e0339ad7 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -1,5 +1,6 @@ """Tests for git.py module.""" +from collections.abc import Callable import os from pathlib import Path import time @@ -9,7 +10,7 @@ from unittest.mock import Mock, patch import pytest from esphome import git -from esphome.core import CORE, TimePeriodSeconds +from esphome.core import CORE, EsphomeError, TimePeriodSeconds from esphome.git import GitCommandError @@ -19,6 +20,15 @@ def _compute_repo_dir(url: str, ref: str | None, domain: str) -> Path: return git._compute_destination_path(key, domain) +# The tests must probe the exact location the implementation uses +_marker_path = git._clone_complete_marker_path + + +def _mark_clone_complete(repo_dir: Path) -> None: + """Write the completion marker so a hand-made repo dir is treated as valid.""" + _marker_path(repo_dir).write_text("test") + + def _setup_old_repo(repo_dir: Path, days_old: int = 2) -> None: """Helper to set up a git repo directory structure with an old timestamp. @@ -30,6 +40,7 @@ def _setup_old_repo(repo_dir: Path, days_old: int = 2) -> None: repo_dir.mkdir(parents=True) git_dir = repo_dir / ".git" git_dir.mkdir() + _mark_clone_complete(repo_dir) # Create FETCH_HEAD file with old timestamp fetch_head = git_dir / "FETCH_HEAD" @@ -54,6 +65,25 @@ def _get_git_command_type(cmd: list[str]) -> str | None: return None +def _simulate_cloned_repo(repo_dir: Path) -> None: + """Create the directory structure a successful git clone would leave.""" + repo_dir.mkdir(parents=True, exist_ok=True) + (repo_dir / ".git").mkdir(exist_ok=True) + + +def _make_clone_side_effect(repo_dir: Path) -> Callable[..., str]: + """Return a run_git_command side effect whose clone creates the repo dir.""" + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + if _get_git_command_type(cmd) == "clone": + _simulate_cloned_repo(repo_dir) + return "" + + return git_command_side_effect + + def test_run_git_command_success(tmp_path: Path) -> None: """Test that run_git_command returns output on success.""" # Create a simple git repo to test with @@ -217,6 +247,7 @@ def test_clone_or_update_with_never_refresh( repo_dir.mkdir(parents=True) git_dir = repo_dir / ".git" git_dir.mkdir() + _mark_clone_complete(repo_dir) # Create FETCH_HEAD file with current timestamp fetch_head = git_dir / "FETCH_HEAD" @@ -250,6 +281,7 @@ def test_clone_or_update_skips_when_core_skip_external_update( repo_dir.mkdir(parents=True) git_dir = repo_dir / ".git" git_dir.mkdir() + _mark_clone_complete(repo_dir) (git_dir / "FETCH_HEAD").write_text("test") CORE.skip_external_update = True @@ -281,6 +313,7 @@ def test_clone_or_update_with_refresh_updates_old_repo( repo_dir.mkdir(parents=True) git_dir = repo_dir / ".git" git_dir.mkdir() + _mark_clone_complete(repo_dir) # Create FETCH_HEAD file with old timestamp (2 days ago) fetch_head = git_dir / "FETCH_HEAD" @@ -329,6 +362,7 @@ def test_clone_or_update_with_refresh_skips_fresh_repo( repo_dir.mkdir(parents=True) git_dir = repo_dir / ".git" git_dir.mkdir() + _mark_clone_complete(repo_dir) # Create FETCH_HEAD file with recent timestamp (1 hour ago) fetch_head = git_dir / "FETCH_HEAD" @@ -371,6 +405,8 @@ def test_clone_or_update_clones_missing_repo( # repo_dir should NOT exist assert not repo_dir.exists() + mock_run_git_command.side_effect = _make_clone_side_effect(repo_dir) + # Test with NEVER_REFRESH - should still clone since repo doesn't exist result_dir, revert = git.clone_or_update( url=url, @@ -405,6 +441,7 @@ def test_clone_or_update_with_none_refresh_always_updates( repo_dir.mkdir(parents=True) git_dir = repo_dir / ".git" git_dir.mkdir() + _mark_clone_complete(repo_dir) # Create FETCH_HEAD file with very recent timestamp (1 second ago) fetch_head = git_dir / "FETCH_HEAD" @@ -486,6 +523,9 @@ def test_clone_or_update_recovers_from_git_failures( # Default successful responses if cmd_type == "rev-parse": return "abc123" + if cmd_type == "clone": + # Simulate the recovery re-clone creating the repo directory + _simulate_cloned_repo(repo_dir) return "" mock_run_git_command.side_effect = git_command_side_effect @@ -813,6 +853,273 @@ def test_clone_or_update_stale_clone_is_retried_after_cleanup( assert call_count["fetch"] == 2 +def test_clone_or_update_recloned_when_marker_missing_with_never_refresh( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A repo dir without the completion marker is an interrupted clone. + + It must be removed and re-cloned even with NEVER_REFRESH, which would + otherwise trust the broken directory forever. + """ + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + ref = "1.8.4" + domain = "test" + repo_dir = _compute_repo_dir(url, ref, domain) + + # Simulate an interrupted clone: directory exists, no marker + repo_dir.mkdir(parents=True) + (repo_dir / ".git").mkdir() + + mock_run_git_command.side_effect = _make_clone_side_effect(repo_dir) + + result_dir, _ = git.clone_or_update( + url=url, ref=ref, refresh=git.NEVER_REFRESH, domain=domain + ) + + clone_calls = [c for c in mock_run_git_command.call_args_list if "clone" in c[0][0]] + assert len(clone_calls) == 1 + assert result_dir == repo_dir + # The fresh clone completed, so the marker must now be present + assert _marker_path(repo_dir).is_file() + + +def test_clone_or_update_recloned_when_marker_missing_with_skip_external_update( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """skip_external_update must not preserve an interrupted clone.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + + repo_dir.mkdir(parents=True) + (repo_dir / ".git").mkdir() + + mock_run_git_command.side_effect = _make_clone_side_effect(repo_dir) + + CORE.skip_external_update = True + result_dir, _ = git.clone_or_update( + url=url, ref=None, refresh=TimePeriodSeconds(days=1), domain=domain + ) + + clone_calls = [c for c in mock_run_git_command.call_args_list if "clone" in c[0][0]] + assert len(clone_calls) == 1 + assert result_dir == repo_dir + assert _marker_path(repo_dir).is_file() + + +def test_fresh_clone_writes_completion_marker_with_debug_info( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """The marker is written after a fresh clone and records key and hash dir.""" + 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) + + mock_run_git_command.side_effect = _make_clone_side_effect(repo_dir) + + git.clone_or_update(url=url, ref=ref, refresh=git.NEVER_REFRESH, domain=domain) + + marker = _marker_path(repo_dir) + assert marker.is_file() + content = marker.read_text() + assert f"{url}@{ref}" in content + assert repo_dir.name in content + + +def test_marker_is_deleted_before_rmtree( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """The marker must be gone even if rmtree fails partway. + + Simulated by an rmtree that does nothing: the directory survives but the + marker must already have been deleted, so the next run still re-clones + instead of trusting a partially deleted worktree. + """ + 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) + assert _marker_path(repo_dir).is_file() + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + if _get_git_command_type(cmd) == "stash": + raise GitCommandError("fatal: unable to write new index file") + return "abc123" + + mock_run_git_command.side_effect = git_command_side_effect + + with ( + patch("esphome.git.rmtree"), + pytest.raises(GitCommandError), + ): + git.clone_or_update( + url=url, ref=ref, refresh=TimePeriodSeconds(days=1), domain=domain + ) + + # rmtree never deleted anything, yet the marker is gone + assert repo_dir.is_dir() + assert not _marker_path(repo_dir).is_file() + + +def test_failed_marker_write_does_not_fail_the_clone( + tmp_path: Path, + mock_run_git_command: Mock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A marker write failure must not fail an otherwise complete clone. + + The clone is valid; the missing marker only costs a re-clone on the next + run, so the error is logged as a warning instead of propagating. + """ + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + + mock_run_git_command.side_effect = _make_clone_side_effect(repo_dir) + + with patch( + "esphome.git.write_file", side_effect=EsphomeError("Could not write file") + ): + result_dir, _ = git.clone_or_update( + url=url, ref=None, refresh=git.NEVER_REFRESH, domain=domain + ) + + assert result_dir == repo_dir + assert not _marker_path(repo_dir).is_file() + assert "Could not write clone completion marker" in caplog.text + + +def test_corrupt_git_dir_without_head_recovers( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A .git with neither FETCH_HEAD nor HEAD must recover, not crash. + + The age check stats FETCH_HEAD falling back to HEAD; if both are gone + (partially deleted clone) the stat raised an unhandled FileNotFoundError + before the broken-repository recovery could run. + """ + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + + # Marker present but .git gutted: no FETCH_HEAD, no HEAD + repo_dir.mkdir(parents=True) + (repo_dir / ".git").mkdir() + _mark_clone_complete(repo_dir) + + 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": + raise GitCommandError("ambiguous argument 'HEAD': unknown revision") + if cmd_type == "clone": + _simulate_cloned_repo(repo_dir) + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + result_dir, _ = git.clone_or_update( + url=url, ref=None, refresh=TimePeriodSeconds(days=1), domain=domain + ) + + assert result_dir == repo_dir + clone_calls = [c for c in mock_run_git_command.call_args_list if "clone" in c[0][0]] + assert len(clone_calls) == 1 + assert _marker_path(repo_dir).is_file() + + +def test_remove_repo_dir_tolerates_marker_unlink_failure(tmp_path: Path) -> None: + """A locked marker file must not abort the directory removal.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + (repo_dir / ".git").mkdir() + _mark_clone_complete(repo_dir) + + with patch.object(Path, "unlink", side_effect=PermissionError("locked")): + git._remove_repo_dir(repo_dir) + + # rmtree still removed the directory, marker included + assert not repo_dir.exists() + + +def test_clone_or_update_recovery_preserves_subpath( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Recovery must re-clone into the same subpath-ed directory. + + Without passing subpath through, the recursive recovery call would + recompute the destination without the subpath and clone (and write the + completion marker) at the wrong location. + """ + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + ref = "main" + domain = "test" + subpath = Path("mylib") + repo_dir = _compute_repo_dir(url, ref, domain) / subpath + + _setup_old_repo(repo_dir) + + call_counts: dict[str, int] = {} + + 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: + call_counts[cmd_type] = call_counts.get(cmd_type, 0) + 1 + # First rev-parse fails (broken repo) to trigger recovery + if cmd_type == "rev-parse" and call_counts[cmd_type] == 1: + raise GitCommandError( + "ambiguous argument 'HEAD': unknown revision or path not in the working tree." + ) + if cmd_type == "clone": + # Create whatever directory the clone was asked to target + target = Path(cmd[-1]) + target.mkdir(parents=True, exist_ok=True) + (target / ".git").mkdir(exist_ok=True) + if cmd_type == "rev-parse": + return "abc123" + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + result_dir, _ = git.clone_or_update( + url=url, + ref=ref, + refresh=TimePeriodSeconds(days=1), + domain=domain, + subpath=subpath, + ) + + # The recovery re-clone must target the subpath-ed directory and the + # completion marker must land there too + clone_calls = [c for c in mock_run_git_command.call_args_list if "clone" in c[0][0]] + assert len(clone_calls) == 1 + assert clone_calls[0][0][0][-1] == str(repo_dir) + assert result_dir == repo_dir + assert _marker_path(repo_dir).is_file() + + def test_clone_with_ref_uses_shallow_fetch( tmp_path: Path, mock_run_git_command: Mock ) -> None: From 3ffc3a961033c3ddedb3dfa2ec47da60291e0621 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Jul 2026 19:05:50 -1000 Subject: [PATCH 1008/1815] [espidf] Make openocd-esp32 optional so its libusb check cannot break installs (#17686) --- esphome/espidf/framework.py | 130 ++++++++++++++++------ tests/unit_tests/test_espidf_framework.py | 49 ++++++++ 2 files changed, 147 insertions(+), 32 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index b8e0d4cfca..fce6a88ccf 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -1,5 +1,6 @@ """ESP-IDF framework tools for ESPHome.""" +from collections.abc import Callable from ctypes.util import find_library import json import logging @@ -467,17 +468,21 @@ _NINJA_ARM64_BACKPORT: dict[str, dict[str, str | int]] = { } -def _patch_tools_json_for_linux_arm64(framework_path: Path) -> None: - """Inject ninja linux-arm64 entries into the framework's tools.json on aarch64. +def _patch_tools_json( + framework_path: Path, + apply_patch: Callable[[dict], bool], + patched_log: str, +) -> None: + """Apply an in-place fixup to the framework's tools/tools.json. - Idempotent: a tools.json that already has the entry, or a host that - isn't aarch64, is a no-op. Applied unconditionally on every install - check so a build dir extracted before the backport got fixed up - without forcing a clean. + Shared plumbing for the tools.json patches below: a missing file is a + no-op, an unparseable file logs a warning and skips, and when + ``apply_patch`` reports a change the file is written back atomically. + ``patched_log`` is the info log line, with a single ``%s`` placeholder + for the tools.json path. Patches are idempotent and applied on every + install check, so an already-extracted framework picks them up on the + next build without forcing a clean. """ - if platform.machine() != "aarch64": - return - tools_json = framework_path / "tools" / "tools.json" if not tools_json.is_file(): return @@ -485,37 +490,93 @@ def _patch_tools_json_for_linux_arm64(framework_path: Path) -> None: try: with tools_json.open(encoding="utf-8") as f: data = json.load(f) - except (json.JSONDecodeError, OSError) as e: + # apply_patch also raises inside the guard: a tools.json that is + # valid JSON but not the expected shape (e.g. a top-level list) + # must skip the patch, not crash the install check this patch is + # meant to recover. + changed = apply_patch(data) + except (json.JSONDecodeError, OSError, AttributeError, TypeError, KeyError) as e: _LOGGER.warning( - "Could not parse %s for linux-arm64 backport (%s); " - "skipping. A clean reinstall of the framework directory " - "may be needed.", + "Could not apply tools.json patch to %s (%s); skipping. A clean " + "reinstall of the framework directory may be needed.", tools_json, e, ) return - changed = False - for tool in data.get("tools", []): - if tool.get("name") != "ninja": - continue - for ver in tool.get("versions", []): - entry = _NINJA_ARM64_BACKPORT.get(ver.get("name")) - if entry is None or ver.get("linux-arm64"): - continue - ver["linux-arm64"] = entry - changed = True - if changed: # write_file_if_changed stages a tempfile in the destination dir # and atomically replaces — safe against mid-write interruption # and concurrent invocations. write_file_if_changed(tools_json, json.dumps(data, indent=2) + "\n") - _LOGGER.info( - "Patched %s to add ninja linux-arm64 download " - "(espressif/esp-idf#18272 backport).", - tools_json, - ) + _LOGGER.info(patched_log, tools_json) + + +def _patch_tools_json_for_linux_arm64(framework_path: Path) -> None: + """Inject ninja linux-arm64 entries into the framework's tools.json on aarch64. + + A tools.json that already has the entry, or a host that isn't aarch64, + is a no-op. + """ + if platform.machine() != "aarch64": + return + + def apply_patch(data: dict) -> bool: + changed = False + for tool in data.get("tools", []): + if tool.get("name") != "ninja": + continue + for ver in tool.get("versions", []): + entry = _NINJA_ARM64_BACKPORT.get(ver.get("name")) + if entry is None or ver.get("linux-arm64"): + continue + ver["linux-arm64"] = entry + changed = True + return changed + + _patch_tools_json( + framework_path, + apply_patch, + "Patched %s to add ninja linux-arm64 download " + "(espressif/esp-idf#18272 backport).", + ) + + +def _patch_tools_json_demote_openocd(framework_path: Path) -> None: + """Demote openocd-esp32 from ``install: always`` to ``install: on_request``. + + ``idf_tools.py install required`` installs every tool marked ``always`` in + tools.json and validates each one after extraction by running its version + command. openocd links against libusb-1.0, which minimal systems (bare LXC + containers, slim images) often lack, so that one validation aborted the + whole framework install and left it permanently retrying (#17685) — even + though ESPHome never runs openocd (it is a JTAG debugging tool). Demoting + it drops it from the ``required`` set: it is no longer downloaded or + validated, and the tool-path export treats a missing ``on_request`` tool + as fine. A user who wants it can still name ``openocd-esp32`` explicitly + in ESPHOME_IDF_DEFAULT_TOOLS; explicit names bypass install-type + filtering. + + Because this runs on every install check, an install stuck in the + failing state (which never wrote its stamp file) heals on the next + build without a clean. + """ + + def apply_patch(data: dict) -> bool: + changed = False + for tool in data.get("tools", []): + if tool.get("name") == "openocd-esp32" and tool.get("install") == "always": + tool["install"] = "on_request" + changed = True + return changed + + _patch_tools_json( + framework_path, + apply_patch, + "Patched %s to make openocd-esp32 optional (not needed for " + "building, and its install check fails on systems without " + "libusb-1.0).", + ) def _check_esphome_idf_framework_install( @@ -636,6 +697,11 @@ def _check_esphome_idf_framework_install( # a pre-patch tools.json get fixed up without forcing a clean. _patch_tools_json_for_linux_arm64(framework_path) + # Drop openocd-esp32 from the required tool set on every invocation so + # an install that previously failed on its libusb check recovers on the + # next build. + _patch_tools_json_demote_openocd(framework_path) + # 3. Check if the framework tools are the same and correctly installed if not install: install = True @@ -671,9 +737,9 @@ def _check_esphome_idf_framework_install( ): if platform.system() == "Linux" and find_library("usb-1.0") is None: _LOGGER.error( - "libusb-1.0.so.0 was not found on this system and the ESP-IDF " - "tools need it (openocd fails its install check without it). " - "Install the libusb 1.0 package, e.g. libusb-1.0-0 " + "libusb-1.0.so.0 was not found on this system. If the error " + "above mentions it (openocd fails its install check without " + "it), install the libusb 1.0 package, e.g. libusb-1.0-0 " "(Debian/Ubuntu), libusb1 (Fedora) or libusb (Alpine/Arch), " "then run the build again." ) diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index a1af5ae54c..79a50059cd 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -25,6 +25,7 @@ from esphome.espidf.framework import ( _get_python_env_path, _get_python_version, _parse_git_source, + _patch_tools_json_demote_openocd, _patch_tools_json_for_linux_arm64, _windows_long_paths_enabled, _write_idf_version_txt, @@ -331,6 +332,7 @@ def espidf_mocks(setup_core: Path): patch("esphome.espidf.framework._clone_idf_with_submodules") as clone, patch("esphome.espidf.framework._write_idf_version_txt"), patch("esphome.espidf.framework._patch_tools_json_for_linux_arm64"), + patch("esphome.espidf.framework._patch_tools_json_demote_openocd"), patch("esphome.espidf.framework._write_stamp"), patch("esphome.espidf.framework._check_stamp", return_value=True), patch("esphome.espidf.framework._get_idf_version", return_value=_IDF_VERSION), @@ -612,6 +614,53 @@ def test_patch_tools_json_already_patched_is_noop(tmp_path: Path) -> None: assert tools_json.read_text(encoding="utf-8") == before +# --------------------------------------------------------------------------- +# _patch_tools_json_demote_openocd (openocd-esp32 made optional) +# --------------------------------------------------------------------------- + + +def test_demote_openocd_patches_install_type(tmp_path: Path) -> None: + tools_json = _write_tools_json( + tmp_path, + { + "tools": [ + {"name": "openocd-esp32", "install": "always"}, + {"name": "cmake", "install": "always"}, + ] + }, + ) + _patch_tools_json_demote_openocd(tmp_path) + + data = json.loads(tools_json.read_text(encoding="utf-8")) + openocd = next(t for t in data["tools"] if t["name"] == "openocd-esp32") + cmake = next(t for t in data["tools"] if t["name"] == "cmake") + assert openocd["install"] == "on_request" + # other tools are left untouched + assert cmake["install"] == "always" + + +def test_patch_tools_json_unexpected_structure_warns_and_skips( + tmp_path: Path, +) -> None: + """Valid JSON with an unexpected shape must skip the patch, not raise.""" + tools_dir = tmp_path / "tools" + tools_dir.mkdir() + tools_json = tools_dir / "tools.json" + tools_json.write_text('["not", "a", "dict"]', encoding="utf-8") + before = tools_json.read_text(encoding="utf-8") + _patch_tools_json_demote_openocd(tmp_path) # AttributeError -> skip + assert tools_json.read_text(encoding="utf-8") == before + + +def test_demote_openocd_already_patched_is_noop(tmp_path: Path) -> None: + tools_json = _write_tools_json( + tmp_path, {"tools": [{"name": "openocd-esp32", "install": "on_request"}]} + ) + before = tools_json.read_text(encoding="utf-8") + _patch_tools_json_demote_openocd(tmp_path) + assert tools_json.read_text(encoding="utf-8") == before + + # --------------------------------------------------------------------------- # Subprocess-backed helpers (_exec -> run_command rename) and get_framework_env # --------------------------------------------------------------------------- From 629afd38f6c8e23a01267663d0532a43bbbc9969 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:09:02 +1000 Subject: [PATCH 1009/1815] [light] Fix pulse and other effects (#17645) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/light/light_call.cpp | 13 +-- esphome/components/light/light_state.cpp | 8 ++ .../light_effect_zero_brightness.yaml | 35 +++++++ .../fixtures/light_initial_state.yaml | 18 ++++ tests/integration/test_light_calls.py | 10 +- .../test_light_effect_zero_brightness.py | 91 +++++++++++++++++++ tests/integration/test_light_initial_state.py | 15 +++ 7 files changed, 177 insertions(+), 13 deletions(-) create mode 100644 tests/integration/fixtures/light_effect_zero_brightness.yaml create mode 100644 tests/integration/test_light_effect_zero_brightness.py diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 2b13b40a16..67fd175ce6 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -219,14 +219,11 @@ LightColorValues LightCall::validate_() { this->set_flag_(FLAG_HAS_STATE); } - // Make sure a turn-on makes the light visible: if the resulting brightness would be zero - // (e.g. restored from a brightness=0 turn-off), reset it to full brightness. - if (this->has_state() && this->state_ && (color_mode & ColorCapability::BRIGHTNESS)) { - float brightness = this->has_brightness() ? this->brightness_ : this->parent_->remote_values.get_brightness(); - if (brightness == 0.0f) { - this->brightness_ = 1.0f; - this->set_flag_(FLAG_HAS_BRIGHTNESS); - } + // Make sure a simple (no specific brightness) turn-on makes the light visible + if (this->has_state() && this->state_ && (color_mode & ColorCapability::BRIGHTNESS) && !this->has_brightness() && + this->parent_->remote_values.get_brightness() == 0.0f) { + this->brightness_ = 1.0f; + this->set_flag_(FLAG_HAS_BRIGHTNESS); } // Set color brightness to 100% if currently zero and a color is set. diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index bd778926d5..9d0181a05c 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -71,6 +71,14 @@ void LightState::setup() { break; } + // A light coming up on boot must never end up on-but-invisible: if the resolved restore + // state is on but its brightness is zero (e.g. a stale/persisted value from before a + // forced-on restore mode, or an inverted restore flipping a dim-to-0 off state to on), + // reset it to full brightness. + if (recovered.state && recovered.brightness == 0.0f) { + recovered.brightness = 1.0f; + } + call.set_color_mode_if_supported(recovered.color_mode); call.set_state(recovered.state); call.set_brightness_if_supported(recovered.brightness); diff --git a/tests/integration/fixtures/light_effect_zero_brightness.yaml b/tests/integration/fixtures/light_effect_zero_brightness.yaml new file mode 100644 index 0000000000..b98bed84db --- /dev/null +++ b/tests/integration/fixtures/light_effect_zero_brightness.yaml @@ -0,0 +1,35 @@ +esphome: + name: light-effect-zero-bright +host: +api: # Port will be automatically injected +logger: + level: DEBUG + +output: + - platform: template + id: pulse_output + type: float + write_action: + - logger.log: + format: "PULSE_OUTPUT:%.4f" + args: [state] + +light: + - platform: monochromatic + name: "Test Pulse Light" + id: test_pulse_light + output: pulse_output + effects: + - pulse: + name: "Fast Pulse" + transition_length: 20ms + update_interval: 50ms + min_brightness: 0% + max_brightness: 100% + - strobe: + name: "Fast Strobe" + colors: + - state: true + duration: 50ms + - state: false + duration: 50ms diff --git a/tests/integration/fixtures/light_initial_state.yaml b/tests/integration/fixtures/light_initial_state.yaml index 2654c76aa0..052de0a4e5 100644 --- a/tests/integration/fixtures/light_initial_state.yaml +++ b/tests/integration/fixtures/light_initial_state.yaml @@ -21,6 +21,11 @@ output: type: float write_action: - lambda: "" + - platform: template + id: test_restore_and_on_output + type: float + write_action: + - lambda: "" light: - platform: rgb @@ -37,3 +42,16 @@ light: red: 1.0 green: 0.5 blue: 0.0 + + - platform: monochromatic + name: "Test Restore And On Light" + id: test_restore_and_on_light + output: test_restore_and_on_output + restore_mode: RESTORE_AND_ON + # Simulates a stale/persisted zero brightness: RESTORE_AND_ON always forces the light + # on at boot regardless of the recovered state, so a leftover brightness of 0 must not + # leave the light on-but-invisible. + initial_state: + color_mode: BRIGHTNESS + state: false + brightness: 0% diff --git a/tests/integration/test_light_calls.py b/tests/integration/test_light_calls.py index a3a4103f5c..b75e2fac62 100644 --- a/tests/integration/test_light_calls.py +++ b/tests/integration/test_light_calls.py @@ -341,14 +341,14 @@ async def test_light_calls( assert state.state is True assert state.brightness == pytest.approx(1.0) - # Test 31b: An explicit turn-on with brightness 0 still resets to full - # brightness - a turn-on must never leave the light on-but-invisible. This - # is the same path the restore logic exercises (set_state(true) + - # set_brightness(0) from a persisted brightness=0 turn-off). + # Test 31b: An explicit turn-on with brightness 0 respects the explicit value and + # stays dark. Only a turn-on with no brightness specified (Test 31) restores + # visibility -- an explicit brightness request (e.g. from a light effect's dark + # phase) is never overridden. client.light_command(key=rgbcw_light.key, state=True, brightness=0.0) state = await wait_for_state_change(rgbcw_light.key) assert state.state is True - assert state.brightness == pytest.approx(1.0) + assert state.brightness == pytest.approx(0.0) # Test 32: Turning a light on when it already has nonzero brightness leaves # the brightness unchanged (the reset only happens when brightness is 0). diff --git a/tests/integration/test_light_effect_zero_brightness.py b/tests/integration/test_light_effect_zero_brightness.py new file mode 100644 index 0000000000..6c386d4229 --- /dev/null +++ b/tests/integration/test_light_effect_zero_brightness.py @@ -0,0 +1,91 @@ +"""Integration test verifying light effects can dim to 0% brightness while staying on. + +Regression test for https://github.com/esphome/esphome/issues/17639, where PR #17103's +"make turn-on visible" logic in LightCall::validate_() also clobbered brightness set by a +running effect (e.g. pulse, strobe), forcing it back to 100% and breaking the dark phase +of those effects. + +Effect ticks are published with `publish: false` (so Home Assistant isn't spammed with +every frame), so the effect's actual output can't be observed via API state broadcasts. +Instead, this test reads the output component's log lines, which are written on every +update regardless of the publish flag. +""" + +from __future__ import annotations + +import asyncio +import re +from typing import Any + +from aioesphomeapi import EntityState, LightState +import pytest + +from .state_utils import InitialStateHelper +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_light_effect_zero_brightness( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Pulse and strobe effects must be able to reach 0% brightness while the light stays on.""" + output_pattern = re.compile(r"PULSE_OUTPUT:([\d.]+)") + observed: list[float] = [] + + def on_log_line(line: str) -> None: + match = output_pattern.search(line) + if match: + observed.append(float(match.group(1))) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + light = next(e for e in entities if e.object_id == "test_pulse_light") + + state_futures: dict[int, asyncio.Future[LightState]] = {} + + def on_state(state: EntityState) -> None: + if isinstance(state, LightState) and state.key in state_futures: + future = state_futures[state.key] + if not future.done(): + future.set_result(state) + + # ESPHome sends the current state of every entity right after connecting; drain + # that initial burst so it can't be mistaken for the response to a command below. + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + async def send_and_wait(timeout: float = 5.0, **kwargs: Any) -> LightState: + """Send a light command and wait for the matching state response.""" + state_futures[light.key] = asyncio.get_running_loop().create_future() + client.light_command(key=light.key, **kwargs) + return await asyncio.wait_for(state_futures[light.key], timeout=timeout) + + # Turn the light on first so the effect starts from a known, visible state. + state = await send_and_wait(state=True, brightness=1.0) + assert state.state is True + assert state.brightness == pytest.approx(1.0) + + for effect_name in ("Fast Pulse", "Fast Strobe"): + observed.clear() + state = await send_and_wait(effect=effect_name) + assert state.effect == effect_name + # Let several effect cycles run (update_interval/duration is 50ms in the fixture). + await asyncio.sleep(1.0) + + assert observed, f"No output observed while running effect {effect_name!r}" + assert min(observed) == pytest.approx(0.0, abs=0.01), ( + f"Effect {effect_name!r} never dimmed to 0% brightness while the light " + f"stayed on -- got min={min(observed):.4f} (values: {observed})" + ) + assert max(observed) > 0.5, ( + f"Effect {effect_name!r} never reached full brightness -- " + f"got max={max(observed):.4f}" + ) + + client.light_command(key=light.key, effect="None") diff --git a/tests/integration/test_light_initial_state.py b/tests/integration/test_light_initial_state.py index f1cd96dbf0..657e273fe7 100644 --- a/tests/integration/test_light_initial_state.py +++ b/tests/integration/test_light_initial_state.py @@ -11,6 +11,14 @@ from .state_utils import InitialStateHelper, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction +@pytest.fixture(autouse=True) +def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + """Keep host preferences per-test so RESTORE_AND_ON never loads a stale value left + behind by a previous run (host preferences otherwise persist to ~/.esphome/prefs, + keyed only by device name).""" + monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs")) + + @pytest.mark.asyncio async def test_light_initial_state( yaml_config: str, @@ -36,3 +44,10 @@ async def test_light_initial_state( assert state.red == pytest.approx(1.0, abs=0.01) assert state.green == pytest.approx(0.5, abs=0.01) assert state.blue == pytest.approx(0.0, abs=0.01) + + # Regression test: RESTORE_AND_ON always forces the light on at boot, even when + # the recovered/initial brightness was 0 -- it must never come up on-but-invisible. + restore_and_on_light = require_entity(entities, "test_restore_and_on_light") + restore_and_on_state = helper.initial_states[restore_and_on_light.key] + assert restore_and_on_state.state is True + assert restore_and_on_state.brightness == pytest.approx(1.0) From 83092ea05ccd2b17a6ed3c897c29600c5ccc5488 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:32:22 -1000 Subject: [PATCH 1010/1815] Bump bundled esphome-device-builder to 1.6.8 (#17708) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 331585f123..1a34fda520 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.6.7 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.8 RUN \ platformio settings set enable_telemetry No \ From 786b47d8c27a580432c2855681696ee4e916f1f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Jul 2026 19:32:38 -1000 Subject: [PATCH 1011/1815] [core] Auto-clean the PlatformIO build environment when the Python version changes (#17671) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/platformio/toolchain.py | 187 ++++++++- esphome/writer.py | 15 +- requirements.txt | 1 + tests/unit_tests/test_platformio_toolchain.py | 360 ++++++++++++++++++ 4 files changed, 550 insertions(+), 13 deletions(-) diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index c97df812e3..105d4a8283 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -1,17 +1,35 @@ +from collections.abc import Iterable import json import logging import os from pathlib import Path import re import sys +from typing import TYPE_CHECKING from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE from esphome.core import CORE, EsphomeError -from esphome.helpers import add_git_ceiling_directory +from esphome.helpers import add_git_ceiling_directory, rmtree, write_file from esphome.util import FlashImage, run_external_process +if TYPE_CHECKING: + from platformio.project.config import ProjectConfig + _LOGGER = logging.getLogger(__name__) +# PlatformIO cache subdirs resolved via ProjectConfig. A full ``clean-all`` wipes +# these plus the whole ``core_dir``; a Python-version heal wipes these plus the +# penv while keeping ``core_dir`` (so the sibling stamp/lock survive). +_PIO_CACHE_DIRS = ("cache_dir", "packages_dir", "platforms_dir") + +# Marker recording the Python major.minor the PlatformIO cache was provisioned +# under, plus the lock guarding the check/wipe. Both live in the dir resolved +# by ``_pio_stamp_dir`` (NOT wiped by the heal), so they survive the wipe and +# are rewritten after it. +_PIO_PYTHON_STAMP_FILE = ".esphome.pio.stamp.json" +_PIO_PYTHON_STAMP_LOCK = ".esphome.pio.stamp.lock" +_PIO_PYTHON_STAMP_SCHEMA = "0" + def _strip_win_long_path_prefix(path: str) -> str: r"""Strip the Windows extended-length path prefix from ``path``. @@ -44,7 +62,174 @@ def _strip_win_long_path_prefix(path: str) -> str: return path +def get_platformio_config() -> "ProjectConfig | None": + """Return PlatformIO's ``ProjectConfig``, or None when PlatformIO is absent.""" + try: + from platformio.project.config import ProjectConfig + except ImportError: + return None + return ProjectConfig.get_instance() + + +def _pio_stamp_dir(config: "ProjectConfig") -> Path: + """Return the persistent home for the python-version stamp and lock. + + The parent of ``platforms_dir``, not ``core_dir``: the container/add-on + images relocate the platform/package caches to a persistent volume while + ``core_dir`` stays at the ephemeral default (its ``appstate.json`` must not + move), so a stamp under ``core_dir`` would be wiped on every image update + while the stale cache it guards survives. Everywhere else ``platforms_dir`` + sits inside ``core_dir`` and this resolves to ``core_dir``. + """ + return Path(config.get("platformio", "platforms_dir")).parent + + +def _delete_platformio_dirs(config: "ProjectConfig", pio_dirs: Iterable[str]) -> None: + """Delete each named PlatformIO dir resolved from *config*.""" + for pio_dir in pio_dirs: + path = Path(config.get("platformio", pio_dir)) + if path.is_dir(): + _LOGGER.info("Deleting PlatformIO %s %s", pio_dir, path) + rmtree(path) + + +def clean_platformio_cache() -> None: + """Wipe the whole PlatformIO cache (cache/packages/platforms/core). + + The full set ``clean-all`` (Reset Build Environment) clears. No-op when + PlatformIO is unavailable. + """ + config = get_platformio_config() + if config is None: + return + _delete_platformio_dirs(config, [*_PIO_CACHE_DIRS, "core_dir"]) + + +def _clean_platformio_python_env(config: "ProjectConfig", core_dir: Path) -> None: + """Wipe the cache subdirs + penv for a Python-version change. + + Keeps ``core_dir`` itself (and the stamp/lock siblings under it); otherwise + the same cache set ``clean-all`` clears. + """ + _delete_platformio_dirs(config, _PIO_CACHE_DIRS) + penv = core_dir / "penv" + if penv.is_dir(): + _LOGGER.info("Deleting PlatformIO penv %s", penv) + rmtree(penv) + + +def _current_python_minor() -> str: + """Return the running interpreter's ``major.minor`` (e.g. ``3.13``).""" + return f"{sys.version_info.major}.{sys.version_info.minor}" + + +def _read_pio_stamp_python(stamp_file: Path) -> str | None: + """Return the ``python_version`` recorded in *stamp_file*, or None.""" + try: + with stamp_file.open(encoding="utf-8") as f: + data = json.load(f) + except FileNotFoundError: + return None + except (json.JSONDecodeError, OSError) as err: + # A present-but-unreadable stamp is a distinct signal from an absent + # one, and it drives a cache clean; surface why at normal verbosity. + _LOGGER.warning("Could not read %s: %s", stamp_file, err) + return None + if not isinstance(data, dict): + return None + version = data.get("python_version") + return version if isinstance(version, str) else None + + +def _write_pio_stamp_python(stamp_file: Path, python_version: str) -> None: + """Atomically write the PlatformIO python-version stamp.""" + write_file( + stamp_file, + json.dumps( + { + "schema_version": _PIO_PYTHON_STAMP_SCHEMA, + "python_version": python_version, + } + ), + ) + + +def heal_platformio_python_env() -> None: + """Wipe the PlatformIO cache unless it is stamped for the running Python. + + A PlatformIO platform/tool package pins the Python versions it accepts when + it is provisioned, and ESPHome pins platforms to exact, immutable versions, + so a later interpreter bump (a container upgrading its base Python) leaves + the cached platform rejecting the new interpreter ("Python version must be + between ...") until the cache is wiped. A stamp records the ``major.minor`` + the cache was provisioned for; when it doesn't match the running + interpreter (or has never been written for an existing cache), the same + PlatformIO dirs ``clean-all`` wipes are cleaned so PlatformIO + re-provisions, matching Reset Build Environment automatically. The native + ESP-IDF toolchain already self-heals through its own stamp; this covers the + PlatformIO path. No-op when PlatformIO is unavailable. + """ + config = get_platformio_config() + if config is None: + return + try: + _check_platformio_python_stamp(config) + except (EsphomeError, OSError) as err: + # The check is a best-effort repair; a full or read-only cache volume + # must not abort a build that might otherwise work. The stamp write + # surfaces as EsphomeError (write_file wraps OSError). + _LOGGER.warning("PlatformIO build environment check failed: %s", err) + + +def _check_platformio_python_stamp(config: "ProjectConfig") -> None: + """Compare the stamp to the running interpreter; wipe and restamp on mismatch.""" + current = _current_python_minor() + stamp_dir = _pio_stamp_dir(config) + # Host the stamp/lock even before PlatformIO's first run creates the dir. + stamp_dir.mkdir(parents=True, exist_ok=True) + stamp_file = stamp_dir / _PIO_PYTHON_STAMP_FILE + + from filelock import FileLock + + with FileLock(str(stamp_dir / _PIO_PYTHON_STAMP_LOCK)): + provisioned = _read_pio_stamp_python(stamp_file) + if provisioned == current: + return + core_dir = Path(config.get("platformio", "core_dir")) + has_cache = ( + any( + Path(config.get("platformio", pio_dir)).is_dir() + for pio_dir in _PIO_CACHE_DIRS + ) + or (core_dir / "penv").is_dir() + ) + if has_cache: + if provisioned is None: + # An existing cache with no stamp predates the stamp: its + # provisioning interpreter is unknown, so clean once rather + # than leave a possibly-stale cache failing every build. + _LOGGER.info( + "Cleaning the PlatformIO build environment once so it " + "re-provisions for Python %s", + current, + ) + else: + _LOGGER.info( + "Python version changed (%s -> %s); cleaning PlatformIO " + "build environment so it re-provisions for the new " + "interpreter", + provisioned, + current, + ) + _clean_platformio_python_env(config, core_dir) + _write_pio_stamp_python(stamp_file, current) + + def run_platformio_cli(*args, **kwargs) -> str | int: + # Re-provision the PlatformIO cache if the interpreter's major.minor changed + # since it was last built; a stale platform otherwise rejects the new Python + # with "Python version must be between ..." until Reset Build Environment. + heal_platformio_python_env() os.environ["PLATFORMIO_FORCE_COLOR"] = "true" os.environ["PLATFORMIO_BUILD_DIR"] = str(CORE.relative_pioenvs_path().absolute()) os.environ.setdefault( diff --git a/esphome/writer.py b/esphome/writer.py index b7eeec916d..866377d2f5 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -670,18 +670,9 @@ def clean_all(configuration: list[str]): rmtree(install_path) # Clean PlatformIO project files - try: - from platformio.project.config import ProjectConfig - except ImportError: - # PlatformIO is not available, skip cleaning - pass - else: - config = ProjectConfig.get_instance() - for pio_dir in ["cache_dir", "packages_dir", "platforms_dir", "core_dir"]: - path = Path(config.get("platformio", pio_dir)) - if path.is_dir(): - _LOGGER.info("Deleting PlatformIO %s %s", pio_dir, path) - rmtree(path) + from esphome.platformio.toolchain import clean_platformio_cache + + clean_platformio_cache() GITIGNORE_CONTENT = """# Gitignore settings for ESPHome diff --git a/requirements.txt b/requirements.txt index 9c78597360..cc081d66f2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,6 +27,7 @@ smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 platformdirs==4.10.0 # native esp-idf toolchain global cache dir +filelock==3.29.0 # lock guarding the PlatformIO python-version cache heal # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index 568b43a259..013030d38f 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -2,12 +2,14 @@ # pylint: disable=protected-access +from collections.abc import Generator from contextlib import contextmanager from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json import os from pathlib import Path import shutil +import sys import threading from types import SimpleNamespace from unittest.mock import MagicMock, Mock, call, patch @@ -1093,3 +1095,361 @@ def test_filter_platformio_lines_blocks_noisy_messages(msg: str) -> None: def test_filter_platformio_lines_allows_other_messages(msg: str) -> None: """Test that non-noisy platformio output lines pass through RedirectText.""" assert _filter_through_redirect(msg) == msg + "\n" + + +# --------------------------------------------------------------------------- +# PlatformIO python-version cache heal +# --------------------------------------------------------------------------- + +_CURRENT_MINOR = f"{sys.version_info.major}.{sys.version_info.minor}" +# Captured before the autouse guard patches the name, so tests can exercise the +# real implementation. +_REAL_GET_PLATFORMIO_CONFIG = toolchain.get_platformio_config + + +@pytest.fixture(autouse=True) +def _guard_real_platformio() -> Generator[None, None, None]: + """Default the PlatformIO config lookup to None so no test in this module + touches a real ~/.platformio; the heal tests re-patch it at a temp dir.""" + with patch.object(toolchain, "get_platformio_config", return_value=None): + yield + + +def _pio_layout(core_dir: Path) -> dict[str, Path]: + """Return the PlatformIO dir layout with cache/packages/platforms under core.""" + return { + "core_dir": core_dir, + "packages_dir": core_dir / "packages", + "platforms_dir": core_dir / "platforms", + "cache_dir": core_dir / ".cache", + } + + +def _split_pio_layout(tmp_path: Path) -> dict[str, Path]: + """Container-shape layout: caches on a persistent root, core_dir ephemeral.""" + persistent = tmp_path / "data" / "platformio" + return { + "core_dir": tmp_path / "root" / ".platformio", + "platforms_dir": persistent / "platforms", + "packages_dir": persistent / "packages", + "cache_dir": persistent / "cache", + } + + +def _seed_layout(layout: dict[str, Path]) -> None: + """Populate each cache dir (and the core penv) with a marker file.""" + for key in ("platforms_dir", "packages_dir", "cache_dir"): + layout[key].mkdir(parents=True, exist_ok=True) + (layout[key] / "marker").write_text("x", encoding="utf-8") + penv = layout["core_dir"] / "penv" + penv.mkdir(parents=True, exist_ok=True) + (penv / "marker").write_text("x", encoding="utf-8") + + +def _make_pio_config(layout: dict[str, Path] | Path) -> MagicMock: + """A ProjectConfig stand-in resolving platformio dir options from *layout*.""" + resolved = _pio_layout(layout) if isinstance(layout, Path) else layout + config = MagicMock() + config.get.side_effect = lambda section, option: ( + str(resolved[option]) if section == "platformio" else "" + ) + return config + + +@contextmanager +def _use_pio_config(layout: dict[str, Path] | Path) -> Generator[MagicMock, None, None]: + """Point ``get_platformio_config`` at a temp layout for the block.""" + config = _make_pio_config(layout) + with patch.object(toolchain, "get_platformio_config", return_value=config): + yield config + + +def _stamp_version(core_dir: Path) -> str | None: + """Read the python version recorded in the heal stamp under *core_dir*.""" + return toolchain._read_pio_stamp_python(core_dir / toolchain._PIO_PYTHON_STAMP_FILE) + + +def _cache_wiped(core_dir: Path) -> bool: + """True when the seeded cache subdir markers are gone.""" + return not any( + (core_dir / sub / "marker").exists() + for sub in ("packages", "platforms", ".cache") + ) + + +@pytest.fixture +def pio_core_dir(tmp_path: Path) -> Path: + """A populated PlatformIO core dir (packages/platforms/.cache/penv seeded).""" + core = tmp_path / "dot-platformio" + for sub in ("packages", "platforms", ".cache", "penv"): + seeded = core / sub + seeded.mkdir(parents=True) + (seeded / "marker").write_text("x", encoding="utf-8") + return core + + +def test_current_python_minor_matches_running_interpreter() -> None: + """_current_python_minor returns major.minor of the running interpreter.""" + assert toolchain._current_python_minor() == _CURRENT_MINOR + + +def test_pio_stamp_round_trip(tmp_path: Path) -> None: + """The stamp writer/reader round-trips and records the schema version.""" + stamp = tmp_path / toolchain._PIO_PYTHON_STAMP_FILE + toolchain._write_pio_stamp_python(stamp, "3.13") + assert toolchain._read_pio_stamp_python(stamp) == "3.13" + assert json.loads(stamp.read_text()) == { + "schema_version": toolchain._PIO_PYTHON_STAMP_SCHEMA, + "python_version": "3.13", + } + + +def test_read_pio_stamp_missing(tmp_path: Path) -> None: + """A missing stamp file yields None.""" + assert toolchain._read_pio_stamp_python(tmp_path / "nope.json") is None + + +def test_read_pio_stamp_malformed(tmp_path: Path) -> None: + """A corrupt stamp file yields None instead of raising.""" + stamp = tmp_path / "bad.json" + stamp.write_text("{not json", encoding="utf-8") + assert toolchain._read_pio_stamp_python(stamp) is None + + +def test_read_pio_stamp_unreadable_logs_warning( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A present-but-unreadable stamp yields None and warns.""" + stamp = tmp_path / "stamp.json" + stamp.mkdir() + with caplog.at_level("WARNING"): + assert toolchain._read_pio_stamp_python(stamp) is None + assert "Could not read" in caplog.text + + +def test_read_pio_stamp_without_python_version(tmp_path: Path) -> None: + """A stamp missing python_version yields None.""" + stamp = tmp_path / "s.json" + stamp.write_text(json.dumps({"schema_version": "0"}), encoding="utf-8") + assert toolchain._read_pio_stamp_python(stamp) is None + + +@pytest.mark.parametrize("payload", ["42", '"x"', "[1, 2]", "null"]) +def test_read_pio_stamp_non_object_json(tmp_path: Path, payload: str) -> None: + """Valid-but-non-object JSON in the stamp yields None, not a crash.""" + stamp = tmp_path / "s.json" + stamp.write_text(payload, encoding="utf-8") + assert toolchain._read_pio_stamp_python(stamp) is None + + +def test_clean_platformio_cache_none_config_is_noop() -> None: + """clean_platformio_cache is a no-op when PlatformIO is unavailable.""" + with patch.object(toolchain, "get_platformio_config", return_value=None): + toolchain.clean_platformio_cache() + + +def test_clean_platformio_cache_wipes_everything(pio_core_dir: Path) -> None: + """clean_platformio_cache removes cache/packages/platforms and core_dir.""" + with _use_pio_config(pio_core_dir): + toolchain.clean_platformio_cache() + assert not pio_core_dir.exists() + + +def test_heal_none_config_is_noop() -> None: + """Heal is a no-op (no error) when PlatformIO is unavailable.""" + with patch.object(toolchain, "get_platformio_config", return_value=None): + toolchain.heal_platformio_python_env() + + +def test_heal_fresh_cache_stamps_without_wipe(tmp_path: Path) -> None: + """A fresh core dir (no stamp, no penv) is stamped, not wiped.""" + core = tmp_path / "pio" + with _use_pio_config(core): + toolchain.heal_platformio_python_env() + assert _stamp_version(core) == _CURRENT_MINOR + + +def test_heal_stamp_matches_current_no_wipe(pio_core_dir: Path) -> None: + """A stamp matching the running interpreter leaves the cache untouched.""" + toolchain._write_pio_stamp_python( + pio_core_dir / toolchain._PIO_PYTHON_STAMP_FILE, _CURRENT_MINOR + ) + with _use_pio_config(pio_core_dir): + toolchain.heal_platformio_python_env() + assert not _cache_wiped(pio_core_dir) + assert (pio_core_dir / "penv" / "marker").exists() + + +def test_heal_stale_stamp_wipes_and_restamps(pio_core_dir: Path) -> None: + """A stamp from an older interpreter triggers a wipe + restamp; core_dir stays.""" + toolchain._write_pio_stamp_python( + pio_core_dir / toolchain._PIO_PYTHON_STAMP_FILE, "2.7" + ) + with _use_pio_config(pio_core_dir): + toolchain.heal_platformio_python_env() + assert _cache_wiped(pio_core_dir) + assert not (pio_core_dir / "penv").exists() + assert pio_core_dir.is_dir() + assert _stamp_version(pio_core_dir) == _CURRENT_MINOR + + +def test_heal_no_stamp_existing_cache_wipes_once( + pio_core_dir: Path, caplog: pytest.LogCaptureFixture +) -> None: + """An existing cache with no stamp is cleaned once and stamped.""" + with _use_pio_config(pio_core_dir), caplog.at_level("INFO"): + toolchain.heal_platformio_python_env() + assert _cache_wiped(pio_core_dir) + assert not (pio_core_dir / "penv").exists() + assert _stamp_version(pio_core_dir) == _CURRENT_MINOR + assert "once" in caplog.text + + +def test_heal_no_stamp_penv_only_counts_as_cache(tmp_path: Path) -> None: + """A core dir holding only a penv still triggers the one-time clean.""" + core = tmp_path / "pio" + penv = core / "penv" + penv.mkdir(parents=True) + (penv / "marker").write_text("x", encoding="utf-8") + with _use_pio_config(core): + toolchain.heal_platformio_python_env() + assert not penv.exists() + assert _stamp_version(core) == _CURRENT_MINOR + + +def test_heal_oserror_is_nonfatal( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A filesystem failure during the check warns instead of aborting the build.""" + blocker = tmp_path / "pio" + blocker.write_text("not a directory", encoding="utf-8") + with _use_pio_config(blocker), caplog.at_level("WARNING"): + toolchain.heal_platformio_python_env() + assert "build environment check failed" in caplog.text + + +def test_heal_stamp_write_failure_is_nonfatal( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A failed stamp write (EsphomeError from write_file) warns, not aborts.""" + with ( + _use_pio_config(tmp_path / "pio"), + patch.object( + toolchain, + "_write_pio_stamp_python", + side_effect=EsphomeError("disk full"), + ), + caplog.at_level("WARNING"), + ): + toolchain.heal_platformio_python_env() + assert "build environment check failed" in caplog.text + + +def test_heal_is_idempotent_across_runs(pio_core_dir: Path) -> None: + """After a heal writes the stamp, a re-provisioned cache is not wiped again.""" + with _use_pio_config(pio_core_dir): + toolchain.heal_platformio_python_env() + repop = pio_core_dir / "packages" + repop.mkdir(exist_ok=True) + (repop / "marker").write_text("x", encoding="utf-8") + toolchain.heal_platformio_python_env() + assert (pio_core_dir / "packages" / "marker").exists() + + +def test_pio_stamp_dir_is_platforms_parent(tmp_path: Path) -> None: + """The stamp home is the parent of platforms_dir, not core_dir.""" + layout = _split_pio_layout(tmp_path) + config = _make_pio_config(layout) + assert toolchain._pio_stamp_dir(config) == layout["platforms_dir"].parent + nested = _make_pio_config(tmp_path / "pio") + assert toolchain._pio_stamp_dir(nested) == tmp_path / "pio" + + +def test_heal_container_layout_stamps_persistent_root(tmp_path: Path) -> None: + """Container shape: the stamp lands on the persistent cache root.""" + layout = _split_pio_layout(tmp_path) + with _use_pio_config(layout): + toolchain.heal_platformio_python_env() + persistent = layout["platforms_dir"].parent + assert _stamp_version(persistent) == _CURRENT_MINOR + assert not (layout["core_dir"] / toolchain._PIO_PYTHON_STAMP_FILE).exists() + + +def test_heal_container_layout_stale_stamp_wipes_persistent_cache( + tmp_path: Path, +) -> None: + """Container shape: a stale stamp wipes the relocated persistent caches.""" + layout = _split_pio_layout(tmp_path) + _seed_layout(layout) + persistent = layout["platforms_dir"].parent + toolchain._write_pio_stamp_python( + persistent / toolchain._PIO_PYTHON_STAMP_FILE, "2.7" + ) + with _use_pio_config(layout): + toolchain.heal_platformio_python_env() + for key in ("platforms_dir", "packages_dir", "cache_dir"): + assert not layout[key].exists() + assert not (layout["core_dir"] / "penv").exists() + assert _stamp_version(persistent) == _CURRENT_MINOR + + +def test_heal_container_layout_survives_core_dir_wipe(tmp_path: Path) -> None: + """A python change is still detected after an image update wiped core_dir.""" + layout = _split_pio_layout(tmp_path) + _seed_layout(layout) + shutil.rmtree(layout["core_dir"]) + persistent = layout["platforms_dir"].parent + toolchain._write_pio_stamp_python( + persistent / toolchain._PIO_PYTHON_STAMP_FILE, "2.7" + ) + with _use_pio_config(layout): + toolchain.heal_platformio_python_env() + for key in ("platforms_dir", "packages_dir", "cache_dir"): + assert not layout[key].exists() + assert _stamp_version(persistent) == _CURRENT_MINOR + + +def test_get_platformio_config_returns_project_config() -> None: + """The real lookup returns a usable ProjectConfig when PlatformIO is present.""" + config = _REAL_GET_PLATFORMIO_CONFIG() + assert config is not None + assert hasattr(config, "get") + + +def test_get_platformio_config_none_when_platformio_absent() -> None: + """The lookup returns None when PlatformIO cannot be imported.""" + with patch.dict(sys.modules, {"platformio.project.config": None}): + assert _REAL_GET_PLATFORMIO_CONFIG() is None + + +def test_delete_platformio_dirs_skips_missing(tmp_path: Path) -> None: + """A named dir that does not exist is skipped without error.""" + (tmp_path / "packages").mkdir() + (tmp_path / "packages" / "marker").write_text("x", encoding="utf-8") + config = _make_pio_config(tmp_path) + # platforms_dir does not exist; packages_dir does. + toolchain._delete_platformio_dirs(config, ["packages_dir", "platforms_dir"]) + assert not (tmp_path / "packages").exists() + + +def test_heal_stale_stamp_wipes_when_penv_absent(pio_core_dir: Path) -> None: + """The penv wipe is skipped cleanly when no penv exists.""" + shutil.rmtree(pio_core_dir / "penv") + toolchain._write_pio_stamp_python( + pio_core_dir / toolchain._PIO_PYTHON_STAMP_FILE, "2.7" + ) + with _use_pio_config(pio_core_dir): + toolchain.heal_platformio_python_env() + assert _cache_wiped(pio_core_dir) + assert _stamp_version(pio_core_dir) == _CURRENT_MINOR + + +def test_run_platformio_cli_invokes_heal( + setup_core: Path, mock_run_external_process: Mock +) -> None: + """run_platformio_cli runs the heal before spawning PlatformIO.""" + CORE.build_path = str(setup_core / "build" / "test") + mock_run_external_process.return_value = 0 + with patch.object(toolchain, "heal_platformio_python_env") as mock_heal: + toolchain.run_platformio_cli("test") + mock_heal.assert_called_once() From 5a86e26f680b2da5581bf4e003372abc68bc3621 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Jul 2026 19:34:56 -1000 Subject: [PATCH 1012/1815] [platformio] Re-download library when cached copy is missing its manifest (#17691) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/platformio/library.py | 20 ++++- tests/unit_tests/test_platformio_library.py | 84 ++++++++++++++++++--- 2 files changed, 90 insertions(+), 14 deletions(-) diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 72a50b795b..b3fd24c2b7 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -669,9 +669,25 @@ def convert_libraries( library_json_path = component.path / "library.json" library_properties_path = component.path / "library.properties" - if library_json_path.is_file(): + has_json = library_json_path.is_file() + has_properties = library_properties_path.is_file() + if not has_json and not has_properties: + # The shared cache can hold a broken copy (e.g. a clone or an + # extraction interrupted by a killed process). Force one + # re-download so a bad cache entry self-heals instead of failing + # every build until the user runs a full clean. + _LOGGER.warning( + "Library %s at %s is missing library.json and library.properties; " + "re-downloading", + key, + component.path, + ) + component.download(force=True, salt=salt, namespace=backend.cache_key) + has_json = library_json_path.is_file() + has_properties = library_properties_path.is_file() + if has_json: component.data = _parse_library_json(library_json_path) - elif library_properties_path.is_file(): + elif has_properties: component.data = _parse_library_properties(library_properties_path) else: raise RuntimeError( diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index 6a4c057469..d2ca71bad6 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -133,18 +133,8 @@ def test_resolve_registry_version_raises_without_pkg_file(monkeypatch): _resolve_registry_version("owner", "pkg", set()) -def _patch_download_with_manifests(monkeypatch, tmp_path, manifests, *, properties=()): - """Fake ConvertedLibrary.download to materialize canned manifests on disk.""" - - def fake_download(self, force=False, salt="", namespace=""): - self.path = tmp_path / self.get_sanitized_name().replace("/", "__") - self.path.mkdir(parents=True, exist_ok=True) - if self.name in properties: - (self.path / "library.properties").write_text(manifests[self.name]) - else: - (self.path / "library.json").write_text(json.dumps(manifests[self.name])) - - monkeypatch.setattr(ConvertedLibrary, "download", fake_download) +def _patch_registry_resolve(monkeypatch: pytest.MonkeyPatch) -> None: + """Stub the registry lookup so tests never touch the network.""" monkeypatch.setattr( lib, "_resolve_registry_version", @@ -157,6 +147,21 @@ def _patch_download_with_manifests(monkeypatch, tmp_path, manifests, *, properti ) +def _patch_download_with_manifests(monkeypatch, tmp_path, manifests, *, properties=()): + """Fake ConvertedLibrary.download to materialize canned manifests on disk.""" + + def fake_download(self, force=False, salt="", namespace=""): + self.path = tmp_path / self.get_require_name() + self.path.mkdir(parents=True, exist_ok=True) + if self.name in properties: + (self.path / "library.properties").write_text(manifests[self.name]) + else: + (self.path / "library.json").write_text(json.dumps(manifests[self.name])) + + monkeypatch.setattr(ConvertedLibrary, "download", fake_download) + _patch_registry_resolve(monkeypatch) + + def test_convert_libraries_parses_library_properties(tmp_path, monkeypatch): # A manifest provided as library.properties (Arduino style) instead of # library.json must still be parsed and converted. @@ -212,6 +217,61 @@ def test_convert_libraries_handles_unparsable_dependency_version(tmp_path, monke assert [d.name for d in top[0].dependencies] == ["C"] +def _patch_download_without_manifest( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, *, manifest_on_force: bool +) -> list[bool]: + """Fake ConvertedLibrary.download that leaves the manifest missing. + + When ``manifest_on_force`` is set, a forced re-download writes a valid + library.json, simulating a broken cache entry that heals on retry. + Returns the list of ``force`` values download was called with. + """ + calls: list[bool] = [] + + def fake_download( + self: ConvertedLibrary, force: bool = False, salt: str = "", namespace: str = "" + ) -> None: + calls.append(force) + self.path = tmp_path / self.get_require_name() + self.path.mkdir(parents=True, exist_ok=True) + if force and manifest_on_force: + (self.path / "library.json").write_text(json.dumps({"name": "A"})) + + monkeypatch.setattr(ConvertedLibrary, "download", fake_download) + _patch_registry_resolve(monkeypatch) + return calls + + +def test_convert_libraries_redownloads_when_manifest_missing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # A cached copy without any manifest (e.g. an interrupted clone or + # extraction) triggers exactly one forced re-download and then succeeds. + calls = _patch_download_without_manifest( + monkeypatch, tmp_path, manifest_on_force=True + ) + + top = convert_libraries([Library("esphome/A", "1.0.0", None)], _backend()) + + assert calls == [False, True] + assert top[0].data["name"] == "A" + + +def test_convert_libraries_raises_when_manifest_missing_after_retry( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # If the forced re-download still yields no manifest, the error is raised + # after exactly one retry (no retry loop). + calls = _patch_download_without_manifest( + monkeypatch, tmp_path, manifest_on_force=False + ) + + with pytest.raises(RuntimeError, match="Invalid PIO library"): + convert_libraries([Library("esphome/A", "1.0.0", None)], _backend()) + + assert calls == [False, True] + + @pytest.mark.parametrize( ("value", "expected"), [ From 7738464f0bef5f278af606d673ec8a99978cd974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Seux?= Date: Mon, 20 Jul 2026 19:07:28 +0200 Subject: [PATCH 1013/1815] [http_request] Fix usage of http response body (#17713) Co-authored-by: J. Nick Koston --- esphome/components/http_request/http_request.h | 4 ++-- .../components/http_request/http_request.yaml | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index df1bb462ab..4471dffdc2 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -488,10 +488,10 @@ template class HttpRequestSendAction final : public Actionbody_.value(x...); } if (!this->json_.empty()) { - body = json::build_json([this, x...](JsonObject root) { this->encode_json_(x..., root); }); + body = json::build_json([this, x...](JsonObject root) mutable { this->encode_json_(x..., root); }); } if (this->json_func_ != nullptr) { - body = json::build_json([this, x...](JsonObject root) { this->json_func_(x..., root); }); + body = json::build_json([this, x...](JsonObject root) mutable { this->json_func_(x..., root); }); } std::vector
request_headers; request_headers.reserve(this->request_headers_.size()); diff --git a/tests/components/http_request/http_request.yaml b/tests/components/http_request/http_request.yaml index 46d4b88ec5..4b3c2ca36b 100644 --- a/tests/components/http_request/http_request.yaml +++ b/tests/components/http_request/http_request.yaml @@ -59,6 +59,24 @@ esphome: id: test_regression_light brightness: 100% effect: "None" + - http_request.get: + url: https://esphome.io + capture_response: true + on_response: + then: + # Regression test: http_request.post with json: (dict variant) inside + # on_response of a capture_response: true request puts std::string& + # (body) into the nested action's Ts..., which exposes a + # const-correctness bug in HttpRequestSendAction::play() where + # encode_json_ receives const copies of non-const reference args. + - http_request.post: + url: https://esphome.io + json: + status: "ok" + # Same with json: lambda variant, exercises json_func_ path + - http_request.post: + url: https://esphome.io + json: !lambda "root[\"status\"] = \"ok\";" http_request: useragent: esphome/tagreader From 5f2adcf9b3020a51788909779423b9d882707138 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Jul 2026 10:08:46 -1000 Subject: [PATCH 1014/1815] [platformio] Include cache path in invalid library error (#17692) --- esphome/platformio/library.py | 2 +- tests/unit_tests/test_platformio_library.py | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index b3fd24c2b7..7c8566b77a 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -692,7 +692,7 @@ def convert_libraries( else: raise RuntimeError( f"Invalid PIO library {key}: missing library.json and " - "library.properties" + f"library.properties in {component.path}" ) try: diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index d2ca71bad6..c0a0c678db 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -261,15 +261,18 @@ def test_convert_libraries_raises_when_manifest_missing_after_retry( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: # If the forced re-download still yields no manifest, the error is raised - # after exactly one retry (no retry loop). + # after exactly one retry (no retry loop). The error must name the cache + # directory so users can find the broken entry instead of guessing where + # the library was unpacked. calls = _patch_download_without_manifest( monkeypatch, tmp_path, manifest_on_force=False ) - with pytest.raises(RuntimeError, match="Invalid PIO library"): + with pytest.raises(RuntimeError, match="Invalid PIO library") as excinfo: convert_libraries([Library("esphome/A", "1.0.0", None)], _backend()) assert calls == [False, True] + assert str(tmp_path / "esphome__A") in str(excinfo.value) @pytest.mark.parametrize( From 307faa6c389bd8265b82305bc7270783c60387b1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Jul 2026 10:12:17 -1000 Subject: [PATCH 1015/1815] [espidf] Resume interrupted toolchain downloads instead of restarting (#17706) --- esphome/espidf/framework.py | 213 ++++-- esphome/espidf/get_tool_downloads.py | 88 +++ esphome/framework_helpers.py | 547 ++++++++++--- .../fixtures/idf_tools_stub/idf_tools.py | 120 +++ tests/unit_tests/test_espidf_framework.py | 317 +++++++- tests/unit_tests/test_framework_helpers.py | 718 +++++++++++++++++- 6 files changed, 1844 insertions(+), 159 deletions(-) create mode 100644 esphome/espidf/get_tool_downloads.py create mode 100644 tests/unit_tests/fixtures/idf_tools_stub/idf_tools.py diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index fce6a88ccf..b54a0c294b 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -9,7 +9,7 @@ from pathlib import Path import platform import re import shutil -import tempfile +from typing import NoReturn import platformdirs @@ -20,6 +20,7 @@ from esphome.framework_helpers import ( archive_extract_all, create_venv, download_from_mirrors, + download_with_resume, get_python_env_executable_path, get_system_python_path, rmdir, @@ -231,6 +232,40 @@ def _write_stamp(file: PathType, data: dict[str, str]): json.dump(data, fp) +def _run_idf_tools_script( + idf_framework_root: PathType, + script_name: str, + msg: str, + args: list[str] | None = None, + env: dict[str, str] | None = None, +) -> tuple[bool, str | None, str | None]: + """Run one of the sibling idf_tools-backed helper scripts. + + The script is executed with the framework's ``tools`` directory on + PYTHONPATH so it imports the framework's own ``idf_tools`` module. + """ + cmd = [ + get_system_python_path(), + str(_SCRIPTS_DIR / script_name), + str(idf_framework_root), + *(args or []), + ] + return run_command( + cmd, + msg=msg, + env=(env or os.environ) + | {"PYTHONPATH": str(Path(idf_framework_root) / "tools")}, + ) + + +def _raise_script_failure(what: str, root: PathType, stderr: str | None) -> NoReturn: + """Raise RuntimeError for a failed helper script, appending stderr detail.""" + detail = (stderr or "").strip() + raise RuntimeError( + f"Can't get {what} of {root}" + (f": {detail}" if detail else "") + ) + + def _get_idf_version( idf_framework_root: PathType, env: dict[str, str] | None = None ) -> str: @@ -248,26 +283,13 @@ def _get_idf_version( RuntimeError: If ESP-IDF version cannot be determined """ - cmd = [ - get_system_python_path(), - str(_SCRIPTS_DIR / "get_idf_version.py"), - str(idf_framework_root), - ] - - success, stdout, stderr = run_command( - cmd, - msg="ESP-IDF version", - env=(env or os.environ) - | {"PYTHONPATH": str(Path(idf_framework_root) / "tools")}, + success, stdout, stderr = _run_idf_tools_script( + idf_framework_root, "get_idf_version.py", "ESP-IDF version", env=env ) if stdout: stdout = stdout.strip() if not success or not stdout: - detail = (stderr or "").strip() - raise RuntimeError( - f"Can't get ESP-IDF version of {idf_framework_root}" - + (f": {detail}" if detail else "") - ) + _raise_script_failure("ESP-IDF version", idf_framework_root, stderr) return stdout @@ -288,24 +310,11 @@ def _get_idf_tool_paths( RuntimeError: If ESP-IDF tool paths cannot be determined """ - cmd = [ - get_system_python_path(), - str(_SCRIPTS_DIR / "get_idf_tool_paths.py"), - str(idf_framework_root), - ] - - success, stdout, stderr = run_command( - cmd, - msg="ESP-IDF tool paths", - env=(env or os.environ) - | {"PYTHONPATH": str(Path(idf_framework_root) / "tools")}, + success, stdout, stderr = _run_idf_tools_script( + idf_framework_root, "get_idf_tool_paths.py", "ESP-IDF tool paths", env=env ) if not success or not stdout: - detail = (stderr or "").strip() - raise RuntimeError( - f"Can't get ESP-IDF tool paths of {idf_framework_root}" - + (f": {detail}" if detail else "") - ) + _raise_script_failure("ESP-IDF tool paths", idf_framework_root, stderr) # Extract json values try: @@ -579,6 +588,69 @@ def _patch_tools_json_demote_openocd(framework_path: Path) -> None: ) +def _prefetch_idf_tool_archives( + framework_path: Path, + targets_str: str, + tools: list[str], + env: dict[str, str] | None, +) -> None: + """Pre-download the tool archives ``idf_tools.py install`` would fetch. + + ``idf_tools.py``'s own downloader restarts from byte zero on every retry, + which makes large archives effectively impossible to fetch on unstable + connections (#17703). This asks the framework's idf_tools (via + ``get_tool_downloads.py``) which archives the coming install needs, then + downloads each into ``/dist`` with + ``download_with_resume``. The installer then finds the verified archives + already in place ("file ... is already downloaded") and never touches the + network. + + Strictly best-effort: any failure here just logs and returns, leaving + ``idf_tools.py install`` to download whatever is missing exactly as + before. Leftover ``.part`` files live in ``dist/`` and are removed by the + post-install cache prune. + """ + try: + success, stdout, stderr = _run_idf_tools_script( + framework_path, + "get_tool_downloads.py", + "ESP-IDF tool download list", + args=[targets_str, *tools], + env=env, + ) + if not success or not stdout: + _LOGGER.warning( + "Could not determine ESP-IDF tool downloads: %s", + (stderr or "").strip(), + ) + return + dist_path = get_idf_tools_path() / "dist" + entries = [ + entry + for entry in json.loads(stdout) + if not (dist_path / entry["dest"]).is_file() + ] + for index, entry in enumerate(entries, start=1): + _LOGGER.info( + "Downloading %s (%d/%d) ...", entry["name"], index, len(entries) + ) + try: + download_with_resume( + entry["url"], + dist_path / entry["dest"], + sha256=entry["sha256"], + size=entry["size"], + ) + except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught + # Keep prefetching the remaining archives; the installer + # will retry this one itself (without resume). + _LOGGER.warning("Could not prefetch %s: %s", entry["name"], e) + except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught + # The installer downloads anything missing itself; never let the + # prefetch become a new way for the install to fail. + _LOGGER.warning("ESP-IDF tool prefetch failed: %s", e) + + def _check_esphome_idf_framework_install( version: str, targets: list[str], @@ -650,41 +722,51 @@ def _check_esphome_idf_framework_install( git_url, ref = git_source _clone_idf_with_submodules(framework_path, git_url, ref) else: - # Download in temporary file - with tempfile.NamedTemporaryFile() as tmp: - _LOGGER.info("Downloading ESP-IDF %s framework ...", version) + _LOGGER.info("Downloading ESP-IDF %s framework ...", version) - # Create substitutions for the URLs. SHORT_VERSION (x.y with - # optional -extra) is only provided for x.y.0 releases, since - # the vX.Y release tags only exist for those; templates that - # reference it are skipped for other versions by - # download_from_mirrors. - substitutions = {"VERSION": version} - try: - ver = Version.parse(version) - substitutions["MAJOR"] = str(ver.major) - substitutions["MINOR"] = str(ver.minor) - substitutions["PATCH"] = str(ver.patch) - substitutions["EXTRA"] = f"-{ver.extra}" if ver.extra else "" - if ver.patch == 0: - substitutions["SHORT_VERSION"] = ( - f"{ver.major}.{ver.minor}{substitutions['EXTRA']}" - ) - except ValueError: - _LOGGER.warning( - "ESP-IDF version '%s' is not a valid version number; " - "only the {VERSION} substitution is available for " - "mirror URLs", - version, + # Create substitutions for the URLs. SHORT_VERSION (x.y with + # optional -extra) is only provided for x.y.0 releases, since + # the vX.Y release tags only exist for those; templates that + # reference it are skipped for other versions by + # download_from_mirrors. + substitutions = {"VERSION": version} + try: + ver = Version.parse(version) + substitutions["MAJOR"] = str(ver.major) + substitutions["MINOR"] = str(ver.minor) + substitutions["PATCH"] = str(ver.patch) + substitutions["EXTRA"] = f"-{ver.extra}" if ver.extra else "" + if ver.patch == 0: + substitutions["SHORT_VERSION"] = ( + f"{ver.major}.{ver.minor}{substitutions['EXTRA']}" ) - - mirrors = [source_url] if source_url else ESPHOME_IDF_FRAMEWORK_MIRRORS - download_from_mirrors(mirrors, substitutions, tmp.file) - - _LOGGER.info("Extracting ESP-IDF %s framework ...", version) - archive_extract_all( - tmp.file, framework_path, progress_header="Extracting" + except ValueError: + _LOGGER.warning( + "ESP-IDF version '%s' is not a valid version number; " + "only the {VERSION} substitution is available for " + "mirror URLs", + version, ) + + mirrors = [source_url] if source_url else ESPHOME_IDF_FRAMEWORK_MIRRORS + # Download to a persistent file in the tool download cache (not + # a temp file) so an interrupted download resumes on the next + # run; the cache is pruned after a successful install anyway. + tarball_path = get_idf_tools_path() / "dist" / f"esp-idf-{version}.tar.xz" + download_from_mirrors(mirrors, substitutions, tarball_path) + + _LOGGER.info("Extracting ESP-IDF %s framework ...", version) + try: + with tarball_path.open("rb") as tarball: + archive_extract_all( + tarball, framework_path, progress_header="Extracting" + ) + finally: + # Success: drop the archive rather than caching ~70MB twice. + # Failure: a corrupt archive (e.g. torn by an unclean + # shutdown) must not be reused — without a checksum only a + # failed extraction can expose it, so force a re-download. + tarball_path.unlink(missing_ok=True) extracted_marker.touch() # Idempotent post-extract patch: written every invocation so a build @@ -722,6 +804,7 @@ def _check_esphome_idf_framework_install( if install: _LOGGER.info("Installing ESP-IDF %s framework ...", version) targets_str = ",".join(targets) + _prefetch_idf_tool_archives(framework_path, targets_str, tools, env) cmd = [ get_system_python_path(), str(idf_tools_path), diff --git a/esphome/espidf/get_tool_downloads.py b/esphome/espidf/get_tool_downloads.py new file mode 100644 index 0000000000..37a6126fae --- /dev/null +++ b/esphome/espidf/get_tool_downloads.py @@ -0,0 +1,88 @@ +"""Print JSON download info for the ESP-IDF tools an install would fetch. + +Run via ``python ...``. +PYTHONPATH must include ``/tools`` so ``idf_tools`` is +importable, and IDF_TOOLS_PATH must be set. Prints a JSON list of +``{name, url, size, sha256, dest}`` for every tool version that is not yet +installed, where ``dest`` is the archive filename ``idf_tools.py install`` +expects to find in ``/dist``. Tools with no download for the +current platform are skipped; already-installed versions are skipped so a +pruned download cache is not re-fetched. + +The target/tool expansion mirrors ``idf_tools.py install`` (targets passed to +``add_and_check_targets`` accumulate with idf-env.json) but nothing is saved +or written — this script only reports what the install would download. +""" + +# pylint: disable=import-error # idf_tools is on PYTHONPATH at runtime only + +from contextlib import redirect_stdout +import json +import os +from pathlib import Path +import sys + +from idf_tools import ( + CURRENT_PLATFORM, + TOOLS_FILE, + IDFEnv, + ToolBinaryError, + add_and_check_targets, + expand_tools_arg, + g, + get_idf_download_url_apply_mirrors, + load_tools_info, +) + + +def collect_downloads() -> list[dict]: + g.idf_path = sys.argv[1] + g.idf_tools_path = os.environ.get("IDF_TOOLS_PATH") + g.tools_json = str(Path(g.idf_path) / TOOLS_FILE) + + targets = add_and_check_targets(IDFEnv.get_idf_env(), sys.argv[2]) + tools_info = load_tools_info() + downloads: list[dict] = [] + + for name in expand_tools_arg(sys.argv[3:], tools_info, targets): + if "@" in name: + name, version = name.split("@", 1) + else: + version = None + tool = tools_info.get(name) + if tool is None or not tool.compatible_with_platform(): + continue + version = version or tool.get_recommended_version() + if version is None: + continue + try: + tool.find_installed_versions() + except ToolBinaryError as e: + # A broken installed binary is idf_tools' problem to repair on + # install; note it and treat the version as not installed. + print(f"tool {name} failed its binary check: {e}", file=sys.stderr) + if version in tool.versions_installed or version not in tool.versions: + continue + download = tool.versions[version].get_download_for_platform(CURRENT_PLATFORM) + if download is None: + continue + downloads.append( + { + "name": f"{name}@{version}", + # Apply the same IDF_MIRROR_PREFIX_MAP / IDF_GITHUB_ASSETS + # rewriting the installer's own downloader applies, so users + # behind a mirror prefetch from the mirror too. + "url": get_idf_download_url_apply_mirrors(None, download.url), + "size": download.size, + "sha256": download.sha256, + "dest": download.rename_dist or Path(download.url).name, + } + ) + return downloads + + +# idf_tools prints informational lines (e.g. mirror URL rewrites) to stdout; +# route them to stderr so stdout carries only the JSON result. +with redirect_stdout(sys.stderr): + result = collect_downloads() +print(json.dumps(result)) diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 6c055dded3..202d4a2bfb 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -2,21 +2,31 @@ from collections.abc import Iterable from contextlib import ExitStack +import hashlib import io +import json import logging import os from pathlib import Path import subprocess import sys import time -from typing import IO +from typing import IO, TYPE_CHECKING from esphome.helpers import ProgressBar, rmtree +if TYPE_CHECKING: + import requests + PathType = str | os.PathLike _LOGGER = logging.getLogger(__name__) +# Attempts per mirror URL before falling through to the next mirror; only +# mid-stream drops retry (resuming when the server gave a validator), +# connect errors move on immediately. +_MIRROR_ATTEMPTS = 3 + def get_project_link_flags() -> list[str]: """Return the sorted -Wl, linker flags from the current build.""" @@ -394,17 +404,23 @@ def _zip_extract_all( progress.update(1) -def _rename_with_retry(src: Path, dst: Path, attempts: int = 5) -> None: +def _rename_with_retry( + src: Path, dst: Path, attempts: int = 5, overwrite: bool = False +) -> None: """Rename ``src`` to ``dst`` with backoff retries on Windows sharing violations. Antivirus/indexer handles on freshly-written files can briefly block ``os.rename`` with ERROR_SHARING_VIOLATION / ERROR_ACCESS_DENIED. The handle is released within tens of ms in practice, so exponential backoff - works. + works. With ``overwrite`` an existing ``dst`` is replaced instead of + failing. """ for i in range(attempts): try: - src.rename(dst) + if overwrite: + src.replace(dst) + else: + src.rename(dst) return except PermissionError: if i == attempts - 1: @@ -525,8 +541,8 @@ def archive_extract_all( ValueError: If archive format is unsupported """ - # 1. Handle different archive input types with ExitStack() as stack: + # 1. Handle different archive input types archive_ref: io.BufferedIOBase if isinstance(archive, (str, os.PathLike)): archive_ref = stack.enter_context(Path(archive).open("rb")) @@ -552,6 +568,311 @@ def archive_extract_all( matched_fct(archive_ref, extract_dir, progress_header=progress_header) +def _open_ranged( + url: str, offset: int, timeout: int, validator: str | None = None +) -> tuple["requests.Response | None", int]: + """Open a streaming GET, asking the server to resume at ``offset``. + + ``validator`` is an ETag or Last-Modified value from the interrupted + response; it is sent as ``If-Range`` so the server only honors the Range + when the content is unchanged, replying 200 (full body, restart) if the + file was replaced between requests — the resumed bytes can then never be + stitched onto a different file's prefix. + + Returns ``(response, effective_offset)``. The response is None when the + server answered 416 Range Not Satisfiable: the file holds every byte the + server has (a previous attempt was interrupted after the last byte), so + there is nothing to stream and the caller's verification decides whether + the file is good. The offset drops to 0 when the server ignored the + ``Range`` header (no 206), meaning the caller must restart the file. + Raises on connect errors and HTTP error statuses; the response is closed + on failure. + """ + import requests + + headers = {"Range": f"bytes={offset}-"} if offset else {} + if offset and validator: + headers["If-Range"] = validator + resp = requests.get(url, stream=True, timeout=timeout, headers=headers) + if offset and resp.status_code == 416: + resp.close() + return None, offset + if offset and resp.status_code != 206: + _LOGGER.debug( + "Server did not resume %s (HTTP %s), restarting", url, resp.status_code + ) + offset = 0 + if not resp.ok: + resp.close() + resp.raise_for_status() + if offset: + _LOGGER.info("Resuming download at %d bytes ...", offset) + return resp, offset + + +def _verify_file(path: Path, sha256: str | None, size: int | None) -> None: + """Raise EsphomeError when ``path`` fails an available sha256/size check.""" + from esphome.core import EsphomeError + + if size is not None and path.stat().st_size != size: + raise EsphomeError(f"size mismatch: expected {size}, got {path.stat().st_size}") + if sha256 is not None: + with path.open("rb") as f: + digest = hashlib.file_digest(f, "sha256").hexdigest() + if digest != sha256: + raise EsphomeError(f"sha256 mismatch: got {digest}") + + +def _load_download_meta(meta: Path, url: str) -> tuple[str | None, int]: + """Return the ``(validator, total)`` a previous run recorded for ``url``. + + ``(None, 0)`` when there is no sidecar, it is unreadable, or it belongs + to a different URL (e.g. a different mirror was tried last time). + """ + try: + with meta.open(encoding="utf-8") as f: + data = json.load(f) + except (OSError, json.JSONDecodeError): + return None, 0 + if not isinstance(data, dict) or data.get("url") != url: + return None, 0 + validator = data.get("validator") + total = data.get("total") + return ( + validator if isinstance(validator, str) else None, + total if isinstance(total, int) else 0, + ) + + +def _write_download_meta( + meta: Path, url: str, validator: str | None, total: int +) -> None: + """Persist resume metadata next to the part file; best-effort. + + Without a validator there is nothing a later run could resume against, + so any stale sidecar is removed instead. + """ + try: + if validator is None: + meta.unlink(missing_ok=True) + else: + meta.write_text( + json.dumps({"url": url, "validator": validator, "total": total}), + encoding="utf-8", + ) + except OSError as e: + _LOGGER.debug("Could not update download metadata %s: %s", meta, e) + + +def _content_length(resp: "requests.Response") -> int: + """Return the response's Content-Length, or 0 when absent or malformed. + + 0 means "unknown", which downstream disables the progress bar and the + resume/completeness logic — a garbage header from a broken proxy must + degrade to a plain single-stream download, not crash the attempt. + """ + try: + return int(resp.headers.get("content-length", 0)) + except ValueError: + return 0 + + +def _response_validator(resp: "requests.Response") -> str | None: + """Return the response's strong validator for ``If-Range`` resumes. + + Weak ETags (``W/...``) are not usable for byte-range conditionals, so + fall back to Last-Modified, or None when the server offers neither. + """ + etag = resp.headers.get("ETag") + if etag and not etag.startswith("W/"): + return etag + return resp.headers.get("Last-Modified") + + +def _stream_response_to_file( + resp: "requests.Response", f: IO[bytes], offset: int, size: int | None = None +) -> None: + """Stream an open ``_open_ranged`` response body into ``f`` at ``offset``. + + Truncates ``f`` to ``offset`` first, so a server-rejected resume + (effective offset 0) discards the stale bytes. ``offset`` also seeds the + progress bar so a resumed download shows overall progress. ``size`` is + the known full file size; when None it is derived from the response's + content-length, and without either there is no progress bar. + """ + f.seek(offset) + f.truncate(offset) + total_size = size or offset + _content_length(resp) + downloaded = offset + progress = ProgressBar("Downloading") if total_size > 0 else None + for chunk in resp.iter_content(chunk_size=256 * 1024): + if chunk: + f.write(chunk) + downloaded += len(chunk) + if progress is not None: + progress.update(downloaded / total_size) + if progress is not None: + progress.update(1) + + +def download_with_resume( + url: str, + dest: PathType, + sha256: str | None = None, + size: int | None = None, + # More attempts than _MIRROR_ATTEMPTS: a single-URL download has no + # mirror fallback, and each retry only re-fetches the remainder. + attempts: int = 5, + timeout: int = 30, + retry_connect_errors: bool = True, +) -> None: + """Download ``url`` to ``dest``, resuming partial downloads. + + The body streams into ``.part``, which persists across attempts and + esphome runs: a mid-stream connection drop only costs one attempt and the + next continues from where it stopped, so an unstable connection converges + on a complete file instead of restarting from zero each retry (#17703). + When ``size`` / ``sha256`` are given the completed file is verified and a + mismatch restarts from scratch; success renames the part file into place. + An already-present ``dest`` that passes verification is kept as-is. + + Resuming a part file from an earlier run needs proof the content is + unchanged: ``sha256`` when the caller has one, or otherwise the server's + If-Range validator recorded in a ``.part.meta`` sidecar by the run + that started the download — a size alone cannot detect a same-length + content change on the server. + + With ``retry_connect_errors`` disabled, a failure before any body bytes + flow (connect error, HTTP error status) propagates immediately instead + of consuming attempts — for callers with their own fallback, like + ``download_from_mirrors``. + + Raises EsphomeError when all attempts are exhausted. + """ + # Imported lazily: requests is a heavy import (~85ms) and is only needed + # when actually downloading a toolchain, never during config validation. + import requests + + from esphome.core import EsphomeError + + dest = Path(dest) + part = dest.with_name(dest.name + ".part") + meta = part.with_name(part.name + ".meta") + dest.parent.mkdir(parents=True, exist_ok=True) + last_error: Exception | None = None + + # An earlier run already completed this download. Only trust it when + # there is something to verify it against; without sha/size the remote + # content may have changed (e.g. a refreshed constraints file), so + # re-download and atomically replace it. + if dest.is_file() and (sha256 is not None or size is not None): + try: + _verify_file(dest, sha256, size) + return + except EsphomeError: + dest.unlink() + + # Adopt the validator/total the run that started this part file recorded, + # so an unfinished download resumes across runs even without a sha256. + validator, expected_total = _load_download_meta(meta, url) + + for _ in range(attempts): + streamed = False + try: + offset = part.stat().st_size if part.is_file() else 0 + # A stitched resume needs two proofs: content identity (the + # bytes being appended belong to the same file as the prefix) + # and completeness. sha256 provides both, across runs. Without + # it, identity needs this run's If-Range validator — a size + # alone cannot detect a same-length content change, so a + # leftover part file from an earlier run must restart — and + # completeness needs a known total length. + if ( + offset + and sha256 is None + and (validator is None or not (size or expected_total)) + ): + _LOGGER.debug( + "Restarting %s from zero: cannot prove a resumed " + "file correct (no sha256, validator=%s, total=%s)", + url, + validator is not None, + size or expected_total, + ) + offset = 0 + if size is None or offset < size: + resp, offset = _open_ranged(url, offset, timeout, validator) + # A None response means HTTP 416: the part file already holds + # every byte the server has; fall through to verification. + if resp is not None: + with resp, part.open("ab") as f: + streamed = True + if offset == 0: + validator = _response_validator(resp) + expected_total = _content_length(resp) + # Recorded so a later run can prove an If-Range + # resume of this part file safe. + _write_download_meta(meta, url, validator, expected_total) + _stream_response_to_file(resp, f, offset, size) + # else: a previous run already wrote every byte (or more) but + # was killed before the rename below. Skip the network entirely + # — a Range request past EOF would draw HTTP 416 — and let + # verification decide whether to promote the file or discard it + # and start over. + + expected_size = size if size is not None else expected_total + _verify_file(part, sha256, expected_size or None) + if not expected_size and sha256 is None: + # No sha, no size, and the server sent no usable + # content-length: nothing can prove the download complete + # (urllib3 still errors on most short bodies, but not on a + # cleanly closed chunked stream). Promote with a debug + # note rather than fail or warn: some servers (e.g. the + # Espressif constraints host) never send a length, the user + # can do nothing about it, and every current caller + # extracts or parses the file afterwards, where corruption + # fails loudly. + _LOGGER.debug( + "Downloaded %s without any way to verify completeness", + dest.name, + ) + # Retry on Windows sharing violations: an antivirus handle on the + # freshly-written file must not get the verified download deleted + # as corrupt by the except clause below. If even the backoff + # retries fail, keep the verified part so the next attempt (or + # run) only has to redo the rename, not the download. + try: + _rename_with_retry(part, dest, overwrite=True) + except PermissionError as e: + _LOGGER.debug("Could not move %s into place: %s", part, e) + last_error = e + continue + meta.unlink(missing_ok=True) + return + except requests.RequestException as e: + # Network failures — including connect errors, since a single + # URL has no mirror-list fallback — keep the part file for the + # next attempt (or the next esphome run) to resume from. Checked + # before OSError: RequestException subclasses IOError. + if not retry_connect_errors and not streamed: + # The caller falls back to another URL on pre-body failures. + raise + _LOGGER.debug("Download of %s interrupted: %s", url, e) + last_error = e + except (OSError, EsphomeError) as e: + # A completed-but-corrupt file (or local disk error) can't be + # trusted for resume; start over. + _LOGGER.debug("Discarding %s: %s", part, e) + part.unlink(missing_ok=True) + meta.unlink(missing_ok=True) + last_error = e + + raise EsphomeError( + f"Failed to download {url} after {attempts} attempts: " + f"{_failure_reason(last_error)}" + ) from last_error + + def _failure_reason(e: Exception) -> str: """Format a download exception for the aggregated error message. @@ -585,110 +906,166 @@ def download_from_mirrors( ``substitutions`` are skipped, so callers can offer templates that only apply to some downloads. + A path target downloads through ``download_with_resume``, so an + interrupted download resumes on the next esphome run; a file-like target + only resumes mid-stream drops within this call. + Raises: ValueError: If mirrors list is empty. EsphomeError: If all download attempts fail; the message lists every attempted URL with its individual failure reason. Also raised if no template matched the provided substitutions. """ - # Imported lazily: requests is a heavy import (~85ms) and is only needed - # when actually downloading a toolchain, never during config validation. + # Imported lazily: requests is a heavy import (~85ms) and is only + # needed when actually downloading, never during config validation. import requests from esphome.core import EsphomeError - # 1. Open target file for writing if path given - with ExitStack() as stack: - if isinstance(target, (str, os.PathLike)): - f = stack.enter_context(Path(target).open("wb")) - elif isinstance(target, (io.RawIOBase, io.IOBase)): - f = target - else: - raise TypeError( - f"target must be str, Path, or file-like object: {type(target)}" - ) + # 1. Classify the target: filesystem path or open file object + path_target: Path | None = None + f: IO[bytes] | None = None + if isinstance(target, (str, os.PathLike)): + path_target = Path(target) + elif isinstance(target, (io.RawIOBase, io.IOBase)): + f = target + else: + raise TypeError( + f"target must be str, Path, or file-like object: {type(target)}" + ) - # 2. Try each mirror in order - failures: list[tuple[str, Exception]] = [] - skipped: list[tuple[str, str]] = [] + # 2. Try each mirror in order + failures: list[tuple[str, Exception]] = [] + skipped: list[tuple[str, str]] = [] - for mirror in mirrors: - # 3. Apply substitutions to URL + for mirror in mirrors: + # 3. Apply substitutions to URL + try: + url = mirror.format(**substitutions) + except KeyError as e: + # The template references a substitution not provided for + # this download (e.g. SHORT_VERSION only exists for x.y.0 + # versions) - expected, the template just doesn't apply. + _LOGGER.debug("Skipping mirror %s: %s not available", mirror, e) + skipped.append((mirror, f"not applicable ({e.args[0]} not available)")) + continue + except (IndexError, ValueError) as e: + # A malformed template (unbalanced braces, bad format spec) + # is an authoring error, not an expected fallthrough - warn + # even if a later mirror succeeds. + _LOGGER.warning("Skipping malformed mirror URL template %s: %r", mirror, e) + skipped.append((mirror, f"skipped ({e!r})")) + continue + + _LOGGER.debug("Trying to download from %s", url) + + # Path targets delegate to download_with_resume so a partial + # download persists (and resumes) across esphome runs. + if path_target is not None: try: - url = mirror.format(**substitutions) - except KeyError as e: - # The template references a substitution not provided for - # this download (e.g. SHORT_VERSION only exists for x.y.0 - # versions) - expected, the template just doesn't apply. - _LOGGER.debug("Skipping mirror %s: %s not available", mirror, e) - skipped.append((mirror, f"not applicable ({e.args[0]} not available)")) - continue - except (IndexError, ValueError) as e: - # A malformed template (unbalanced braces, bad format spec) - # is an authoring error, not an expected fallthrough - warn - # even if a later mirror succeeds. - _LOGGER.warning( - "Skipping malformed mirror URL template %s: %r", mirror, e + download_with_resume( + url, + path_target, + attempts=_MIRROR_ATTEMPTS, + timeout=timeout, + # Pre-body failures (connect/HTTP errors) fall to the + # next mirror immediately; only mid-stream drops + # retry-with-resume on the same URL. + retry_connect_errors=False, ) - skipped.append((mirror, f"skipped ({e!r})")) + return url + except (requests.RequestException, OSError, EsphomeError) as e: + # Everything download_with_resume classifies as a download + # failure; programming errors propagate. + _LOGGER.debug("Failed to download %s: %s", url, str(e)) + failures.append((url, e)) continue - _LOGGER.debug("Trying to download from %s", url) + # 4. Download; mid-stream failures retry the same mirror with + # resume (see download_with_resume) instead of starting over. + # There is no checksum to verify a resumed file against, so a + # stitch is only trusted when the server proves consistency: the + # If-Range validator guarantees 206 only for unchanged content, + # and the expected total length (when the first response carried + # one) guards against short or shifted bodies. Without a + # validator the retry restarts from zero. + offset = 0 + expected_total = 0 + validator = None + for attempt in range(_MIRROR_ATTEMPTS): + try: + resp, offset = _open_ranged(url, offset, timeout, validator) + except (requests.RequestException, OSError) as e: + # Connect/HTTP error, no bytes flowed — next mirror. + _LOGGER.debug("Failed to download %s: %s", url, str(e)) + failures.append((url, e)) + break try: - # 4. Reset file pointer and download - f.seek(0) - f.truncate(0) + # A None response means HTTP 416: the file already holds + # every byte the server has (a drop after the last byte); + # only the length check below remains. + if resp is not None: + with resp: + if offset == 0: + validator = _response_validator(resp) + expected_total = _content_length(resp) + _stream_response_to_file(resp, f, offset) - with requests.get(url, stream=True, timeout=timeout) as r: - r.raise_for_status() - - total_size = int(r.headers.get("content-length", 0)) - downloaded = 0 - - progress = ProgressBar("Downloading") if total_size > 0 else None - - for chunk in r.iter_content(chunk_size=8192): - if chunk: - f.write(chunk) - - downloaded += len(chunk) - - if progress is not None: - progress.update(downloaded / total_size) - - if progress is not None: - progress.update(1) + if expected_total and f.tell() != expected_total: + raise EsphomeError( + f"size mismatch: expected {expected_total}, got {f.tell()}" + ) + if not expected_total: + # Same trust decision as download_with_resume's + # unverifiable promotion; surface it at the same level. + _LOGGER.debug( + "Downloaded %s without any way to verify completeness", + url, + ) _LOGGER.debug("Downloaded successfully from: %s", url) - # 6. Reset file pointer and return + # 5. Reset file pointer and return f.seek(0) return url - except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught + except (requests.RequestException, OSError, EsphomeError) as e: + # Mid-stream drop: keep the received bytes and retry this + # mirror from the current position — but only when the + # server gave a validator to resume against safely AND a + # total length to prove the stitched file complete (the + # length check above is the only verification here). _LOGGER.debug("Failed to download %s: %s", url, str(e)) - failures.append((url, e)) + if validator and expected_total: + offset = f.tell() + else: + _LOGGER.debug( + "Restarting %s from zero: cannot prove a " + "resumed file complete (validator=%s, total=%s)", + url, + validator is not None, + expected_total, + ) + offset = 0 + if attempt == _MIRROR_ATTEMPTS - 1: + failures.append((url, e)) - # 7. Report every attempted URL if all mirrors failed. Falling back - # past an early mirror is normal (e.g. only one of the framework URL - # templates matches a given version's tag), so raising only the last - # error would hide the failure that actually matters. - if failures: - attempts = "".join( - f"\n {url}\n {_failure_reason(e)}" for url, e in failures - ) - attempts += "".join( - f"\n {mirror}\n {reason}" for mirror, reason in skipped - ) - raise EsphomeError( - f"Failed to download from all mirrors:{attempts}" - ) from failures[0][1] - if skipped: - details = "".join( - f"\n {mirror}\n {reason}" for mirror, reason in skipped - ) - raise EsphomeError( - f"No mirror URL template matched the provided substitutions:{details}" - ) - raise ValueError("download_from_mirrors called with an empty mirrors list") + # 6. Report every attempted URL if all mirrors failed. Falling back + # past an early mirror is normal (e.g. only one of the framework URL + # templates matches a given version's tag), so raising only the last + # error would hide the failure that actually matters. + if failures: + attempts = "".join( + f"\n {url}\n {_failure_reason(e)}" for url, e in failures + ) + attempts += "".join(f"\n {mirror}\n {reason}" for mirror, reason in skipped) + raise EsphomeError( + f"Failed to download from all mirrors:{attempts}" + ) from failures[0][1] + if skipped: + details = "".join(f"\n {mirror}\n {reason}" for mirror, reason in skipped) + raise EsphomeError( + f"No mirror URL template matched the provided substitutions:{details}" + ) + raise ValueError("download_from_mirrors called with an empty mirrors list") diff --git a/tests/unit_tests/fixtures/idf_tools_stub/idf_tools.py b/tests/unit_tests/fixtures/idf_tools_stub/idf_tools.py new file mode 100644 index 0000000000..aeff24fb57 --- /dev/null +++ b/tests/unit_tests/fixtures/idf_tools_stub/idf_tools.py @@ -0,0 +1,120 @@ +"""Minimal idf_tools stand-in for get_tool_downloads.py tests.""" + +from collections.abc import Iterable +import os + +CURRENT_PLATFORM = "linux-amd64" +TOOLS_FILE = "tools/tools.json" + + +class ToolBinaryError(RuntimeError): + pass + + +class _G: + idf_path: str | None = None + idf_tools_path: str | None = None + tools_json: str | None = None + + +g = _G() + + +class IDFEnv: + @classmethod + def get_idf_env(cls) -> "IDFEnv": + return cls() + + +def add_and_check_targets(idf_env_obj: IDFEnv, targets_str: str) -> list[str]: + return targets_str.split(",") + + +class _Download: + def __init__(self, url: str, size: int, sha256: str, rename_dist: str = "") -> None: + self.url = url + self.size = size + self.sha256 = sha256 + self.rename_dist = rename_dist + + +class _Version: + def __init__(self, download: _Download | None) -> None: + self._download = download + + def get_download_for_platform(self, platform_name: str) -> _Download | None: + return self._download + + +class _Tool: + def __init__( + self, + versions: dict[str, _Version], + recommended: str | None, + installed: Iterable[str] = (), + broken: bool = False, + ) -> None: + self.versions = versions + self._recommended = recommended + self.versions_installed = list(installed) + self._broken = broken + + def compatible_with_platform(self) -> bool: + return True + + def get_recommended_version(self) -> str | None: + return self._recommended + + def find_installed_versions(self) -> None: + if self._broken: + raise ToolBinaryError("broken binary") + + +_TOOLS = { + "cmake": _Tool( + {"3.30.2": _Version(_Download("https://gh.test/cmake.tar.gz", 11, "aa"))}, + "3.30.2", + ), + "ninja": _Tool( + { + "1.12.1": _Version( + _Download("https://gh.test/ninja-mac.zip", 22, "bb", "ninja-v1.zip") + ) + }, + "1.12.1", + ), + "installed-tool": _Tool( + {"1.0": _Version(_Download("https://gh.test/x.tar.gz", 33, "cc"))}, + "1.0", + installed=["1.0"], + ), + "broken-tool": _Tool( + {"2.0": _Version(_Download("https://gh.test/y.tar.gz", 44, "dd"))}, + "2.0", + broken=True, + ), + "no-recommended-tool": _Tool({"3.0": _Version(None)}, None), + "no-download-tool": _Tool({"4.0": _Version(None)}, "4.0"), +} + + +def load_tools_info() -> dict[str, _Tool]: + return _TOOLS + + +def expand_tools_arg( + tools_spec: list[str], overall_tools: dict[str, _Tool], targets: list[str] +) -> list[str]: + if "required" in tools_spec: + return list(overall_tools) + return [t for t in tools_spec if "@" not in t] + [t for t in tools_spec if "@" in t] + + +def get_idf_download_url_apply_mirrors( + args: object = None, download_url: str = "" +) -> str: + print(f"Changed download URL: {download_url}") # noise on stdout, like idf_tools + prefix = os.environ.get("TEST_MIRROR_PREFIX") + if prefix: + return prefix + download_url + return download_url diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index 79a50059cd..eb68b17572 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -3,10 +3,14 @@ # pylint: disable=protected-access from contextlib import contextmanager +import importlib.util import io import json import logging +import os from pathlib import Path +import runpy +import subprocess import sys import tarfile from types import SimpleNamespace @@ -27,6 +31,7 @@ from esphome.espidf.framework import ( _parse_git_source, _patch_tools_json_demote_openocd, _patch_tools_json_for_linux_arm64, + _prefetch_idf_tool_archives, _windows_long_paths_enabled, _write_idf_version_txt, _write_stamp, @@ -311,6 +316,21 @@ class TestTarExtractHardLinkPrefixStripping: _IDF_VERSION = "5.1.2" +def _fake_download_from_mirrors( + mirrors: list[str], + substitutions: dict[str, str], + target: object, + **kwargs: object, +) -> str: + """Stand-in for download_from_mirrors that creates path targets, since + the framework code opens the downloaded tarball afterwards.""" + if isinstance(target, (str, os.PathLike)): + path = Path(target) + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + return "https://example.com/idf.tar.xz" + + @pytest.fixture def espidf_mocks(setup_core: Path): """Patch the heavy I/O of check_esp_idf_install and pre-create the framework dir.""" @@ -321,7 +341,7 @@ def espidf_mocks(setup_core: Path): patch("esphome.espidf.framework.rmdir"), patch( "esphome.espidf.framework.download_from_mirrors", - return_value="https://example.com/idf.tar.xz", + side_effect=_fake_download_from_mirrors, ) as download, patch("esphome.espidf.framework.archive_extract_all") as extract, patch("esphome.espidf.framework.create_venv") as venv, @@ -333,6 +353,7 @@ def espidf_mocks(setup_core: Path): patch("esphome.espidf.framework._write_idf_version_txt"), patch("esphome.espidf.framework._patch_tools_json_for_linux_arm64"), patch("esphome.espidf.framework._patch_tools_json_demote_openocd"), + patch("esphome.espidf.framework._prefetch_idf_tool_archives"), patch("esphome.espidf.framework._write_stamp"), patch("esphome.espidf.framework._check_stamp", return_value=True), patch("esphome.espidf.framework._get_idf_version", return_value=_IDF_VERSION), @@ -391,6 +412,20 @@ def test_check_esp_idf_install_already_installed(espidf_mocks: SimpleNamespace) espidf_mocks.venv.assert_not_called() +def test_corrupt_tarball_removed_when_extraction_fails( + espidf_mocks: SimpleNamespace, +) -> None: + """A tarball that fails to extract (e.g. torn by an unclean shutdown) is + deleted so the next run re-downloads instead of failing forever.""" + espidf_mocks.extract.side_effect = RuntimeError("xz: unexpected end of input") + tarball = get_idf_tools_path() / "dist" / f"esp-idf-{_IDF_VERSION}.tar.xz" + + with pytest.raises(RuntimeError, match="unexpected end of input"): + check_esp_idf_install(_IDF_VERSION, force=True) + + assert not tarball.exists() + + def test_check_esp_idf_install_framework_failure(espidf_mocks: SimpleNamespace) -> None: """A failing idf_tools install raises.""" espidf_mocks.run_ok.side_effect = [False] @@ -614,6 +649,286 @@ def test_patch_tools_json_already_patched_is_noop(tmp_path: Path) -> None: assert tools_json.read_text(encoding="utf-8") == before +# --------------------------------------------------------------------------- +# _prefetch_idf_tool_archives +# --------------------------------------------------------------------------- + + +_PREFETCH_JSON = json.dumps( + [ + { + "name": "cmake@3.30.2", + "url": "https://example.com/cmake.tar.gz", + "size": 123, + "sha256": "ab" * 32, + "dest": "cmake-3.30.2.tar.gz", + }, + { + "name": "ninja@1.12.1", + "url": "https://example.com/ninja.zip", + "size": 45, + "sha256": "cd" * 32, + "dest": "ninja.zip", + }, + ] +) + + +def test_prefetch_downloads_each_archive_with_resume(tmp_path: Path) -> None: + with ( + patch( + "esphome.espidf.framework.run_command", + return_value=(True, _PREFETCH_JSON, ""), + ), + patch("esphome.espidf.framework.download_with_resume") as download, + patch("esphome.espidf.framework.get_system_python_path", return_value="python"), + ): + _prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None) + + dist = get_idf_tools_path() / "dist" + assert download.call_count == 2 + assert download.call_args_list[0][0] == ( + "https://example.com/cmake.tar.gz", + dist / "cmake-3.30.2.tar.gz", + ) + assert download.call_args_list[0][1] == {"sha256": "ab" * 32, "size": 123} + + +def test_prefetch_skips_already_downloaded_archives(tmp_path: Path) -> None: + dist = get_idf_tools_path() / "dist" + dist.mkdir(parents=True) + (dist / "cmake-3.30.2.tar.gz").write_bytes(b"cached") + with ( + patch( + "esphome.espidf.framework.run_command", + return_value=(True, _PREFETCH_JSON, ""), + ), + patch("esphome.espidf.framework.download_with_resume") as download, + patch("esphome.espidf.framework.get_system_python_path", return_value="python"), + ): + _prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None) + + # only the missing archive is downloaded + assert download.call_count == 1 + assert download.call_args[0][1] == dist / "ninja.zip" + + +@pytest.mark.parametrize( + ("run_result", "download_error", "expected_log"), + [ + ((False, "", "script exploded"), None, "tool downloads"), # script failure + ((True, "{ not json", ""), None, "prefetch failed"), # unparsable output + ( + (True, _PREFETCH_JSON, ""), + OSError("network down"), + "Could not prefetch", + ), # download failure + ], +) +def test_prefetch_failures_never_raise( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + run_result: tuple[bool, str, str], + download_error: Exception | None, + expected_log: str, +) -> None: + """The prefetch is best-effort; idf_tools downloads whatever is missing.""" + with ( + patch("esphome.espidf.framework.run_command", return_value=run_result), + patch( + "esphome.espidf.framework.download_with_resume", + side_effect=download_error, + ), + patch("esphome.espidf.framework.get_system_python_path", return_value="python"), + ): + _prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None) + + assert expected_log in caplog.text + + +def test_prefetch_one_failed_archive_does_not_stop_the_rest( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A single archive failing its download must not abort the prefetch of + the remaining archives.""" + with ( + patch( + "esphome.espidf.framework.run_command", + return_value=(True, _PREFETCH_JSON, ""), + ), + patch( + "esphome.espidf.framework.download_with_resume", + side_effect=[OSError("network down"), None], + ) as download, + patch("esphome.espidf.framework.get_system_python_path", return_value="python"), + ): + _prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None) + + assert download.call_count == 2 + assert "Could not prefetch cmake@3.30.2" in caplog.text + + +def test_prefetch_passes_targets_and_tools_to_script(tmp_path: Path) -> None: + with ( + patch( + "esphome.espidf.framework.run_command", return_value=(True, "[]", "") + ) as run, + patch("esphome.espidf.framework.get_system_python_path", return_value="python"), + ): + _prefetch_idf_tool_archives( + tmp_path, "esp32,esp32c3", ["required", "cmake"], {"IDF_TOOLS_PATH": "/x"} + ) + + cmd = run.call_args[0][0] + assert cmd[-3:] == ["esp32,esp32c3", "required", "cmake"] + assert cmd[1].endswith("get_tool_downloads.py") + # the script inherits the caller's env plus the framework tools PYTHONPATH + env = run.call_args[1]["env"] + assert env["IDF_TOOLS_PATH"] == "/x" + assert env["PYTHONPATH"] == str(tmp_path / "tools") + + +def test_framework_install_prefetches_before_installer( + espidf_mocks: SimpleNamespace, +) -> None: + """The prefetch runs before idf_tools.py install so the installer finds + the archives already in dist/.""" + calls: list[str] = [] + with ( + patch( + "esphome.espidf.framework._prefetch_idf_tool_archives", + side_effect=lambda *a, **k: calls.append("prefetch"), + ), + ): + espidf_mocks.run_ok.side_effect = lambda *a, **k: ( + calls.append("install") or True + ) + check_esp_idf_install(_IDF_VERSION, force=True) + + assert calls.index("prefetch") < calls.index("install") + + +# --------------------------------------------------------------------------- +# get_tool_downloads.py (against the stub idf_tools module in fixtures/) +# --------------------------------------------------------------------------- + + +_IDF_TOOLS_STUB_DIR = Path(__file__).parent / "fixtures" / "idf_tools_stub" + + +def _run_downloads_script( + tmp_path: Path, *args: str, env_extra: dict[str, str] | None = None +) -> subprocess.CompletedProcess[str]: + """Run the real get_tool_downloads.py against the stub idf_tools module.""" + script = Path(__file__).parents[2] / "esphome" / "espidf" / "get_tool_downloads.py" + env = os.environ | { + "PYTHONPATH": str(_IDF_TOOLS_STUB_DIR), + "IDF_TOOLS_PATH": str(tmp_path / "tp"), + } + if env_extra: + env |= env_extra + return subprocess.run( + [sys.executable, str(script), str(tmp_path / "fw"), *args], + capture_output=True, + text=True, + env=env, + check=False, + ) + + +def test_get_tool_downloads_lists_missing_tools(tmp_path: Path) -> None: + """Installed versions are skipped, tools that fail their binary check are + still listed, rename_dist decides the dist filename, and idf_tools' stdout + chatter stays off the JSON channel.""" + result = _run_downloads_script(tmp_path, "esp32", "required") + + assert result.returncode == 0, result.stderr + downloads = {d["name"]: d for d in json.loads(result.stdout)} + # installed-tool@1.0 is already installed and must not be listed + assert set(downloads) == {"cmake@3.30.2", "ninja@1.12.1", "broken-tool@2.0"} + assert downloads["cmake@3.30.2"]["dest"] == "cmake.tar.gz" + assert downloads["cmake@3.30.2"]["size"] == 11 + assert downloads["cmake@3.30.2"]["sha256"] == "aa" + # rename_dist overrides the URL basename + assert downloads["ninja@1.12.1"]["dest"] == "ninja-v1.zip" + # the stub prints informational lines; they must be on stderr + assert "Changed download URL" in result.stderr + + +def test_get_tool_downloads_applies_mirror_rewrite(tmp_path: Path) -> None: + result = _run_downloads_script( + tmp_path, + "esp32", + "required", + env_extra={"TEST_MIRROR_PREFIX": "https://mirror.test/"}, + ) + + assert result.returncode == 0, result.stderr + downloads = json.loads(result.stdout) + assert all(d["url"].startswith("https://mirror.test/") for d in downloads) + + +def _run_downloads_inprocess( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + *args: str, +) -> list[dict]: + """Execute get_tool_downloads.py in-process against the stub idf_tools. + + Unlike the subprocess variant this runs under coverage, exercising the + script's own lines. + """ + spec = importlib.util.spec_from_file_location( + "idf_tools", _IDF_TOOLS_STUB_DIR / "idf_tools.py" + ) + stub = importlib.util.module_from_spec(spec) + spec.loader.exec_module(stub) + monkeypatch.setitem(sys.modules, "idf_tools", stub) + monkeypatch.setenv("IDF_TOOLS_PATH", str(tmp_path / "tp")) + script = Path(__file__).parents[2] / "esphome" / "espidf" / "get_tool_downloads.py" + monkeypatch.setattr(sys, "argv", [str(script), str(tmp_path / "fw"), *args]) + runpy.run_path(str(script)) + return json.loads(capsys.readouterr().out) + + +def test_get_tool_downloads_inprocess_full_flow( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """In-process run covering the whole script: required expansion, + installed/broken tools, rename_dist, and version pinning via tool@version.""" + downloads = { + d["name"]: d + for d in _run_downloads_inprocess( + tmp_path, monkeypatch, capsys, "esp32", "required" + ) + } + assert set(downloads) == {"cmake@3.30.2", "ninja@1.12.1", "broken-tool@2.0"} + assert downloads["ninja@1.12.1"]["dest"] == "ninja-v1.zip" + assert downloads["cmake@3.30.2"]["url"] == "https://gh.test/cmake.tar.gz" + + +def test_get_tool_downloads_inprocess_explicit_tool_specs( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Explicit tool names and tool@version specs resolve; unknown tools and + unknown versions are skipped.""" + downloads = _run_downloads_inprocess( + tmp_path, + monkeypatch, + capsys, + "esp32", + "cmake@3.30.2", + "no-such-tool", + "cmake@9.9.9", + ) + assert [d["name"] for d in downloads] == ["cmake@3.30.2"] + + # --------------------------------------------------------------------------- # _patch_tools_json_demote_openocd (openocd-esp32 made optional) # --------------------------------------------------------------------------- diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index e662d2d015..b8aa19d6ae 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -2,8 +2,10 @@ # pylint: disable=protected-access +import hashlib import importlib.util import io +import json import logging import os from pathlib import Path @@ -16,6 +18,7 @@ import zipfile import pytest import requests as req +from esphome import framework_helpers from esphome.core import EsphomeError from esphome.framework_helpers import ( _7z_extract_all, @@ -26,6 +29,7 @@ from esphome.framework_helpers import ( archive_extract_all, create_venv, download_from_mirrors, + download_with_resume, get_project_compile_flags, get_project_cxx_compile_flags, get_project_link_flags, @@ -507,7 +511,7 @@ class TestArchiveExtractAll: # --------------------------------------------------------------------------- -# download_from_mirrors +# download_from_mirrors / download_with_resume # --------------------------------------------------------------------------- @@ -515,6 +519,8 @@ def _mock_response(content: bytes, ok: bool = True) -> MagicMock: r = MagicMock() r.__enter__.return_value = r r.__exit__.return_value = False + r.status_code = 200 + r.ok = ok if ok: r.raise_for_status.return_value = None else: @@ -524,6 +530,563 @@ def _mock_response(content: bytes, ok: bool = True) -> MagicMock: return r +def _interrupted_response(content: bytes, etag: str | None = None) -> MagicMock: + """A response whose body yields ``content`` and then drops mid-stream. + + ``etag`` makes the response resumable: without a validator the retry + logic restarts from zero rather than stitching unverified bytes. + """ + + def body(chunk_size): + yield content + raise req.exceptions.ChunkedEncodingError("connection dropped") + + r = _mock_response(b"") + if etag is not None: + r.headers = {**r.headers, "ETag": etag} + r.iter_content.side_effect = body + return r + + +def _resumed_response(content: bytes) -> MagicMock: + """An HTTP 206 response continuing an interrupted download.""" + r = _mock_response(content) + r.status_code = 206 + return r + + +class TestOpenRanged: + def test_fresh_download_sends_no_range(self) -> None: + with patch("requests.get", return_value=_mock_response(b"x")) as mock_get: + resp, offset = framework_helpers._open_ranged("https://e.com/f", 0, 30) + assert offset == 0 + assert mock_get.call_args[1]["headers"] == {} + assert resp is mock_get.return_value + + def test_resume_kept_on_206(self) -> None: + with patch("requests.get", return_value=_resumed_response(b"x")): + _, offset = framework_helpers._open_ranged("https://e.com/f", 7, 30) + assert offset == 7 + + def test_resume_downgraded_on_200(self) -> None: + """A server that ignores the Range header forces a restart.""" + with patch("requests.get", return_value=_mock_response(b"x")): + _, offset = framework_helpers._open_ranged("https://e.com/f", 7, 30) + assert offset == 0 + + def test_http_error_closes_response_and_raises(self) -> None: + r = _mock_response(b"", ok=False) + with ( + patch("requests.get", return_value=r), + pytest.raises(req.HTTPError), + ): + framework_helpers._open_ranged("https://e.com/f", 0, 30) + r.close.assert_called_once() + + def test_connect_error_propagates(self) -> None: + with ( + patch("requests.get", side_effect=req.ConnectionError("refused")), + pytest.raises(req.ConnectionError), + ): + framework_helpers._open_ranged("https://e.com/f", 0, 30) + + +class TestDownloadWithResume: + def test_downloads_and_renames(self, tmp_path: Path) -> None: + dest = tmp_path / "tool.tar.gz" + with patch("requests.get", return_value=_mock_response(b"data")) as mock_get: + download_with_resume("https://example.com/t", dest) + assert dest.read_bytes() == b"data" + assert not (tmp_path / "tool.tar.gz.part").exists() + # a fresh download must not send a Range header + assert "Range" not in mock_get.call_args[1]["headers"] + + def test_mid_stream_drop_resumes_with_range(self, tmp_path: Path) -> None: + dest = tmp_path / "tool.tar.gz" + first = _interrupted_response(b"1234", etag='"v1"') + first.headers = {**first.headers, "content-length": "8"} + with patch( + "requests.get", + side_effect=[first, _resumed_response(b"5678")], + ) as mock_get: + download_with_resume("https://example.com/t", dest) + # earlier bytes were kept, remainder appended conditionally + assert dest.read_bytes() == b"12345678" + assert mock_get.call_args_list[1][1]["headers"] == { + "Range": "bytes=4-", + "If-Range": '"v1"', + } + + def test_unverifiable_drop_without_length_restarts(self, tmp_path: Path) -> None: + """A validator alone is not enough to stitch when nothing can prove + the stitched file complete (no sha/size and no content-length).""" + dest = tmp_path / "tool.tar.gz" + with patch( + "requests.get", + side_effect=[ + _interrupted_response(b"1234", etag='"v1"'), + _mock_response(b"full"), + ], + ) as mock_get: + download_with_resume("https://example.com/t", dest) + assert dest.read_bytes() == b"full" + assert "Range" not in mock_get.call_args_list[1][1]["headers"] + + def test_resumed_clean_but_short_body_discarded(self, tmp_path: Path) -> None: + """A resumed stream that ends cleanly but short of the advertised + total is rejected and re-downloaded, not promoted.""" + dest = tmp_path / "tool.tar.gz" + first = _interrupted_response(b"abcd", etag='"v1"') + first.headers = {**first.headers, "content-length": "8"} + # resume ends cleanly after only 2 of the 4 missing bytes + short = _resumed_response(b"ef") + full = _mock_response(b"abcdefgh") + full.headers = {**full.headers, "content-length": "8"} + with patch("requests.get", side_effect=[first, short, full]) as mock_get: + download_with_resume("https://example.com/t", dest) + assert dest.read_bytes() == b"abcdefgh" + # the short stitch was discarded; the final attempt started fresh + assert "Range" not in mock_get.call_args_list[2][1]["headers"] + + def test_unverifiable_drop_without_validator_restarts(self, tmp_path: Path) -> None: + """No sha/size and no server validator: the retry must not stitch.""" + dest = tmp_path / "tool.tar.gz" + with patch( + "requests.get", + side_effect=[_interrupted_response(b"1234"), _mock_response(b"full")], + ) as mock_get: + download_with_resume("https://example.com/t", dest) + assert dest.read_bytes() == b"full" + assert "Range" not in mock_get.call_args_list[1][1]["headers"] + + def test_resume_across_invocations_from_part_file(self, tmp_path: Path) -> None: + """A .part file left by a previous run is resumed, not restarted, + when sha/size verification will vouch for the stitched result.""" + dest = tmp_path / "tool.tar.gz" + (tmp_path / "tool.tar.gz.part").write_bytes(b"12345") + good = hashlib.sha256(b"12345678").hexdigest() + with patch("requests.get", return_value=_resumed_response(b"678")) as mock_get: + download_with_resume("https://example.com/t", dest, sha256=good, size=8) + assert dest.read_bytes() == b"12345678" + assert mock_get.call_args[1]["headers"] == {"Range": "bytes=5-"} + + def test_unverifiable_leftover_part_file_ignored(self, tmp_path: Path) -> None: + """Without sha/size there is no way to vouch for a cross-run stitch, + so a leftover part file starts over.""" + dest = tmp_path / "tool.tar.gz" + (tmp_path / "tool.tar.gz.part").write_bytes(b"12345") + with patch("requests.get", return_value=_mock_response(b"fresh")) as mock_get: + download_with_resume("https://example.com/t", dest) + assert dest.read_bytes() == b"fresh" + assert "Range" not in mock_get.call_args[1]["headers"] + + def test_server_without_range_support_restarts(self, tmp_path: Path) -> None: + """HTTP 200 in response to a Range request truncates and restarts.""" + dest = tmp_path / "tool.tar.gz" + (tmp_path / "tool.tar.gz.part").write_bytes(b"sta") + good = hashlib.sha256(b"fresh").hexdigest() + with patch("requests.get", return_value=_mock_response(b"fresh")) as mock_get: + download_with_resume("https://example.com/t", dest, sha256=good, size=5) + # the Range request was sent (verifiable resume) and downgraded + assert mock_get.call_args[1]["headers"] == {"Range": "bytes=3-"} + assert dest.read_bytes() == b"fresh" + + def test_size_only_leftover_part_restarts(self, tmp_path: Path) -> None: + """A size alone cannot detect a same-length content change on the + server, so a cross-run part without sha256 restarts from zero.""" + dest = tmp_path / "tool.tar.gz" + (tmp_path / "tool.tar.gz.part").write_bytes(b"12") + with patch("requests.get", return_value=_mock_response(b"1234")) as mock_get: + download_with_resume("https://example.com/t", dest, size=4) + assert "Range" not in mock_get.call_args[1]["headers"] + assert dest.read_bytes() == b"1234" + + def test_size_only_in_run_drop_resumes_with_validator(self, tmp_path: Path) -> None: + """Within a run the If-Range validator proves identity, so size-only + callers still resume mid-stream drops.""" + dest = tmp_path / "tool.tar.gz" + with patch( + "requests.get", + side_effect=[ + _interrupted_response(b"12", etag='"v1"'), + _resumed_response(b"34"), + ], + ) as mock_get: + download_with_resume("https://example.com/t", dest, size=4) + assert dest.read_bytes() == b"1234" + assert mock_get.call_args_list[1][1]["headers"] == { + "Range": "bytes=2-", + "If-Range": '"v1"', + } + + def test_unverifiable_download_logged( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + """No sha, no size, no content-length: the download is promoted with + a debug note (routine for e.g. the constraints host, so not a + warning) that completeness could not be verified.""" + dest = tmp_path / "tool.tar.gz" + with ( + caplog.at_level(logging.DEBUG, logger="esphome.framework_helpers"), + patch("requests.get", return_value=_mock_response(b"data")), + ): + download_with_resume("https://example.com/t", dest) + assert dest.read_bytes() == b"data" + assert "without any way to verify completeness" in caplog.text + + def test_416_promotes_complete_part_when_size_unknown(self, tmp_path: Path) -> None: + """sha256-only caller with a byte-complete part file: the server's + 416 confirms nothing is missing, verification promotes in place, and + the 416 must not loop as a retryable error.""" + dest = tmp_path / "tool.tar.gz" + (tmp_path / "tool.tar.gz.part").write_bytes(b"data") + good = hashlib.sha256(b"data").hexdigest() + r416 = _mock_response(b"", ok=False) + r416.status_code = 416 + with patch("requests.get", return_value=r416) as mock_get: + download_with_resume("https://example.com/t", dest, sha256=good) + assert mock_get.call_count == 1 + r416.close.assert_called_once() + assert dest.read_bytes() == b"data" + + def test_416_with_corrupt_part_discards_and_redownloads( + self, tmp_path: Path + ) -> None: + dest = tmp_path / "tool.tar.gz" + (tmp_path / "tool.tar.gz.part").write_bytes(b"bad!") + good = hashlib.sha256(b"data").hexdigest() + r416 = _mock_response(b"", ok=False) + r416.status_code = 416 + with patch( + "requests.get", side_effect=[r416, _mock_response(b"data")] + ) as mock_get: + download_with_resume("https://example.com/t", dest, sha256=good) + assert "Range" not in mock_get.call_args_list[1][1]["headers"] + assert dest.read_bytes() == b"data" + + def test_hash_mismatch_discards_and_retries(self, tmp_path: Path) -> None: + dest = tmp_path / "tool.tar.gz" + good = hashlib.sha256(b"good").hexdigest() + with patch( + "requests.get", + side_effect=[_mock_response(b"bad!"), _mock_response(b"good")], + ) as mock_get: + download_with_resume("https://example.com/t", dest, sha256=good, size=4) + assert dest.read_bytes() == b"good" + # the corrupt part file was discarded, so the retry starts fresh + assert "Range" not in mock_get.call_args_list[1][1]["headers"] + + def test_size_mismatch_discards_part(self, tmp_path: Path) -> None: + dest = tmp_path / "tool.tar.gz" + with ( + patch("requests.get", return_value=_mock_response(b"xx")), + pytest.raises(EsphomeError, match="after 2 attempts"), + ): + download_with_resume("https://example.com/t", dest, size=99, attempts=2) + assert not (tmp_path / "tool.tar.gz.part").exists() + assert not dest.exists() + + def test_attempts_exhausted_keeps_part_file(self, tmp_path: Path) -> None: + """Mid-stream failures keep the partial file so a later run resumes.""" + dest = tmp_path / "tool.tar.gz" + first = _interrupted_response(b"12", etag='"v1"') + first.headers = {**first.headers, "content-length": "4"} + second = _interrupted_response(b"34") + second.status_code = 206 + with ( + patch("requests.get", side_effect=[first, second]), + pytest.raises(EsphomeError, match="after 2 attempts"), + ): + download_with_resume("https://example.com/t", dest, attempts=2) + assert (tmp_path / "tool.tar.gz.part").read_bytes() == b"1234" + + def test_multiple_drops_accumulate_across_attempts(self, tmp_path: Path) -> None: + """Each attempt appends its bytes; three partial responses complete + the file.""" + dest = tmp_path / "tool.tar.gz" + first = _interrupted_response(b"ab", etag='"v1"') + first.headers = {**first.headers, "content-length": "6"} + second = _interrupted_response(b"cd") + second.status_code = 206 + third = _resumed_response(b"ef") + with patch( + "requests.get", + side_effect=[first, second, third], + ) as mock_get: + download_with_resume("https://example.com/t", dest) + assert dest.read_bytes() == b"abcdef" + expected = {"Range": "bytes=2-", "If-Range": '"v1"'} + assert mock_get.call_args_list[1][1]["headers"] == expected + expected = {"Range": "bytes=4-", "If-Range": '"v1"'} + assert mock_get.call_args_list[2][1]["headers"] == expected + + def test_connect_error_then_success(self, tmp_path: Path) -> None: + """A connect error (no response at all) consumes an attempt and the + next attempt succeeds.""" + dest = tmp_path / "tool.tar.gz" + with patch( + "requests.get", + side_effect=[req.ConnectionError("refused"), _mock_response(b"data")], + ): + download_with_resume("https://example.com/t", dest) + assert dest.read_bytes() == b"data" + + def test_http_error_keeps_part_file(self, tmp_path: Path) -> None: + """A transient HTTP error (e.g. 503) must not discard resume state.""" + dest = tmp_path / "tool.tar.gz" + (tmp_path / "tool.tar.gz.part").write_bytes(b"keep") + error = _mock_response(b"", ok=False) + error.status_code = 503 + with ( + patch("requests.get", return_value=error), + pytest.raises(EsphomeError, match="after 1 attempts"), + ): + download_with_resume("https://example.com/t", dest, attempts=1) + assert (tmp_path / "tool.tar.gz.part").read_bytes() == b"keep" + + def test_creates_missing_parent_directories(self, tmp_path: Path) -> None: + dest = tmp_path / "dist" / "nested" / "tool.tar.gz" + with patch("requests.get", return_value=_mock_response(b"data")): + download_with_resume("https://example.com/t", dest) + assert dest.read_bytes() == b"data" + + def test_verifies_both_size_and_sha(self, tmp_path: Path) -> None: + dest = tmp_path / "tool.tar.gz" + good = hashlib.sha256(b"data").hexdigest() + with patch("requests.get", return_value=_mock_response(b"data")): + download_with_resume("https://example.com/t", dest, sha256=good, size=4) + assert dest.read_bytes() == b"data" + + def test_corrupt_partial_resumed_then_discarded_then_redownloaded( + self, tmp_path: Path + ) -> None: + """The full recovery cycle for a corrupted partial download: the + resume completes it, verification fails, the poisoned part file is + discarded, and the next attempt re-downloads from scratch.""" + dest = tmp_path / "tool.tar.gz" + # a previous run left a corrupted 4-byte prefix behind + (tmp_path / "tool.tar.gz.part").write_bytes(b"BAD!") + good = hashlib.sha256(b"data66").hexdigest() + with patch( + "requests.get", + side_effect=[ + _resumed_response(b"66"), # resume "completes" the bad part + _mock_response(b"data66"), # clean retry from zero + ], + ) as mock_get: + download_with_resume("https://example.com/t", dest, sha256=good, size=6) + # first attempt resumed at the corrupt offset, failed verification; + # second attempt started fresh (no Range header) and succeeded + assert mock_get.call_args_list[0][1]["headers"] == {"Range": "bytes=4-"} + assert "Range" not in mock_get.call_args_list[1][1]["headers"] + assert dest.read_bytes() == b"data66" + assert not (tmp_path / "tool.tar.gz.part").exists() + + def test_existing_dest_passing_verification_kept(self, tmp_path: Path) -> None: + """A dest completed by an earlier run is reused without any request.""" + dest = tmp_path / "tool.tar.gz" + dest.write_bytes(b"data") + good = hashlib.sha256(b"data").hexdigest() + with patch("requests.get") as mock_get: + download_with_resume("https://example.com/t", dest, sha256=good, size=4) + mock_get.assert_not_called() + assert dest.read_bytes() == b"data" + + @pytest.mark.parametrize( + "stale", + [ + pytest.param(b"corrupt!", id="wrong-size"), + pytest.param(b"bad!", id="right-size-wrong-hash"), + ], + ) + def test_existing_dest_failing_verification_redownloaded( + self, tmp_path: Path, stale: bytes + ) -> None: + dest = tmp_path / "tool.tar.gz" + dest.write_bytes(stale) + good = hashlib.sha256(b"data").hexdigest() + with patch("requests.get", return_value=_mock_response(b"data")): + download_with_resume("https://example.com/t", dest, sha256=good, size=4) + assert dest.read_bytes() == b"data" + + def test_existing_dest_with_size_only_kept(self, tmp_path: Path) -> None: + dest = tmp_path / "tool.tar.gz" + dest.write_bytes(b"data") + with patch("requests.get") as mock_get: + download_with_resume("https://example.com/t", dest, size=4) + mock_get.assert_not_called() + + def test_existing_dest_with_sha_only_kept(self, tmp_path: Path) -> None: + """sha-only verification also authorizes reusing a completed dest.""" + dest = tmp_path / "tool.tar.gz" + dest.write_bytes(b"data") + good = hashlib.sha256(b"data").hexdigest() + with patch("requests.get") as mock_get: + download_with_resume("https://example.com/t", dest, sha256=good) + mock_get.assert_not_called() + + def test_meta_write_failure_is_best_effort(self, tmp_path: Path) -> None: + """A failure to persist the resume sidecar must not fail the + download itself.""" + dest = tmp_path / "f.tar.xz" + first = _mock_response(b"data") + first.headers = {**first.headers, "ETag": '"v1"', "content-length": "4"} + with ( + patch("requests.get", return_value=first), + patch.object(Path, "write_text", side_effect=OSError("read-only")), + ): + download_with_resume("https://example.com/f", dest) + assert dest.read_bytes() == b"data" + + def test_meta_sidecar_written_and_removed(self, tmp_path: Path) -> None: + """The validator sidecar appears while downloading and is cleaned up + with the promotion.""" + dest = tmp_path / "f.tar.xz" + meta = tmp_path / "f.tar.xz.part.meta" + seen: list[bool] = [] + first = _interrupted_response(b"1234", etag='"v1"') + first.headers = {**first.headers, "content-length": "8"} + responses = [first] + + def get(*args: object, **kwargs: object) -> MagicMock: + if responses: + return responses.pop(0) + # the resume request: the sidecar written by the first response + # must already be on disk at this point + seen.append(meta.is_file()) + return _resumed_response(b"5678") + + with patch("requests.get", side_effect=get): + download_with_resume("https://example.com/f", dest) + assert dest.read_bytes() == b"12345678" + assert seen == [True] # sidecar existed during the resume attempt + assert not meta.exists() # cleaned up on success + + def test_locked_promotion_keeps_verified_part(self, tmp_path: Path) -> None: + """A rename that stays blocked (e.g. a long-lived Windows file lock) + must not delete the verified download; the next attempt retries just + the rename without touching the network.""" + dest = tmp_path / "tool.tar.gz" + good = hashlib.sha256(b"data").hexdigest() + with ( + patch("requests.get", return_value=_mock_response(b"data")) as mock_get, + patch( + "esphome.framework_helpers._rename_with_retry", + side_effect=[PermissionError("locked"), None], + ) as rename, + ): + download_with_resume("https://example.com/t", dest, sha256=good, size=4) + # one download; the second attempt only redid the rename + assert mock_get.call_count == 1 + assert rename.call_count == 2 + + def test_locked_promotion_exhausted_keeps_part_for_next_run( + self, tmp_path: Path + ) -> None: + dest = tmp_path / "tool.tar.gz" + good = hashlib.sha256(b"data").hexdigest() + with ( + patch("requests.get", return_value=_mock_response(b"data")), + patch( + "esphome.framework_helpers._rename_with_retry", + side_effect=PermissionError("locked"), + ), + pytest.raises(EsphomeError, match="after 1 attempts"), + ): + download_with_resume( + "https://example.com/t", dest, sha256=good, size=4, attempts=1 + ) + # the verified bytes survive for the next run + assert (tmp_path / "tool.tar.gz.part").read_bytes() == b"data" + + def test_meta_sidecar_resumes_across_runs_without_sha(self, tmp_path: Path) -> None: + """A later run resumes an unfinished download using the validator the + first run stored — the cross-run fix for the framework tarball.""" + dest = tmp_path / "f.tar.xz" + (tmp_path / "f.tar.xz.part").write_bytes(b"1234") + (tmp_path / "f.tar.xz.part.meta").write_text( + json.dumps( + {"url": "https://example.com/f", "validator": '"v1"', "total": 8} + ) + ) + with patch("requests.get", return_value=_resumed_response(b"5678")) as mock_get: + download_with_resume("https://example.com/f", dest) + assert dest.read_bytes() == b"12345678" + assert mock_get.call_args[1]["headers"] == { + "Range": "bytes=4-", + "If-Range": '"v1"', + } + + def test_meta_sidecar_for_other_url_ignored(self, tmp_path: Path) -> None: + """Metadata from a different mirror URL must not authorize a stitch.""" + dest = tmp_path / "f.tar.xz" + (tmp_path / "f.tar.xz.part").write_bytes(b"1234") + (tmp_path / "f.tar.xz.part.meta").write_text( + json.dumps({"url": "https://other.com/f", "validator": '"v1"', "total": 8}) + ) + full = _mock_response(b"12345678") + with patch("requests.get", return_value=full) as mock_get: + download_with_resume("https://example.com/f", dest) + assert "Range" not in mock_get.call_args[1]["headers"] + assert dest.read_bytes() == b"12345678" + + def test_complete_part_file_promoted_without_network(self, tmp_path: Path) -> None: + """A .part holding every byte (killed between write and rename) is + verified in place and promoted; no request is made, so no 416 loop.""" + dest = tmp_path / "tool.tar.gz" + (tmp_path / "tool.tar.gz.part").write_bytes(b"data") + good = hashlib.sha256(b"data").hexdigest() + with patch("requests.get") as mock_get: + download_with_resume("https://example.com/t", dest, sha256=good, size=4) + mock_get.assert_not_called() + assert dest.read_bytes() == b"data" + + def test_complete_but_corrupt_part_file_redownloaded(self, tmp_path: Path) -> None: + """A full-size .part with a wrong hash is discarded and re-downloaded + from scratch.""" + dest = tmp_path / "tool.tar.gz" + (tmp_path / "tool.tar.gz.part").write_bytes(b"bad!") + good = hashlib.sha256(b"data").hexdigest() + with patch("requests.get", return_value=_mock_response(b"data")) as mock_get: + download_with_resume("https://example.com/t", dest, sha256=good, size=4) + assert "Range" not in mock_get.call_args[1]["headers"] + assert dest.read_bytes() == b"data" + + def test_oversized_part_file_discarded(self, tmp_path: Path) -> None: + """A .part larger than the expected size fails verification and is + replaced by a fresh download.""" + dest = tmp_path / "tool.tar.gz" + (tmp_path / "tool.tar.gz.part").write_bytes(b"toolong") + good = hashlib.sha256(b"data").hexdigest() + with patch("requests.get", return_value=_mock_response(b"data")): + download_with_resume("https://example.com/t", dest, sha256=good, size=4) + assert dest.read_bytes() == b"data" + + def test_malformed_content_length_degrades_gracefully(self, tmp_path: Path) -> None: + """A garbage Content-Length must not crash the attempt; it means + "unknown", so a drop restarts instead of stitching and a clean + download still succeeds.""" + dest = tmp_path / "tool.tar.gz" + first = _interrupted_response(b"1234", etag='"v1"') + first.headers = {**first.headers, "content-length": "explode"} + retry = _mock_response(b"full") + retry.headers = {**retry.headers, "content-length": "explode"} + with patch("requests.get", side_effect=[first, retry]) as mock_get: + download_with_resume("https://example.com/t", dest) + assert dest.read_bytes() == b"full" + # unknown length -> completeness unprovable -> no resume attempted + assert "Range" not in mock_get.call_args_list[1][1]["headers"] + + def test_zero_byte_part_file_sends_no_range(self, tmp_path: Path) -> None: + """An empty leftover part file is a fresh download, not a resume.""" + dest = tmp_path / "tool.tar.gz" + (tmp_path / "tool.tar.gz.part").write_bytes(b"") + with patch("requests.get", return_value=_mock_response(b"data")) as mock_get: + download_with_resume("https://example.com/t", dest) + assert mock_get.call_args[1]["headers"] == {} + assert dest.read_bytes() == b"data" + + class TestDownloadFromMirrors: def test_success_returns_url_and_writes_content(self, tmp_path: Path) -> None: target = tmp_path / "out.bin" @@ -640,7 +1203,8 @@ class TestDownloadFromMirrors: ei.value ) - def test_falls_back_to_second_mirror(self, tmp_path: Path) -> None: + def test_falls_back_to_second_mirror(self) -> None: + buf = io.BytesIO() with patch( "requests.get", side_effect=[_mock_response(b"", ok=False), _mock_response(b"second")], @@ -648,14 +1212,152 @@ class TestDownloadFromMirrors: url = download_from_mirrors( ["https://mirror1.com/f", "https://mirror2.com/f"], {}, - tmp_path / "out.bin", + buf, ) assert url == "https://mirror2.com/f" - assert (tmp_path / "out.bin").read_bytes() == b"second" + assert buf.getvalue() == b"second" - def test_all_mirrors_fail_raises_error_listing_every_attempt( - self, tmp_path: Path - ) -> None: + def test_mid_stream_drop_resumes_same_mirror(self) -> None: + """A mid-stream failure retries the same mirror with Range and + If-Range headers, keeping the bytes already received, before falling + to the next.""" + first = _interrupted_response(b"1234", etag='"v1"') + first.headers = {**first.headers, "content-length": "8"} + buf = io.BytesIO() + with patch( + "requests.get", + side_effect=[first, _resumed_response(b"5678")], + ) as mock_get: + url = download_from_mirrors( + ["https://mirror1.com/f", "https://mirror2.com/f"], + {}, + buf, + ) + assert url == "https://mirror1.com/f" + assert buf.getvalue() == b"12345678" + assert mock_get.call_count == 2 + assert mock_get.call_args_list[1][0][0] == "https://mirror1.com/f" + # the resume is conditional on the content being unchanged + assert mock_get.call_args_list[1][1]["headers"] == { + "Range": "bytes=4-", + "If-Range": '"v1"', + } + + def test_mid_stream_drop_without_validator_restarts(self) -> None: + """A server offering no ETag/Last-Modified cannot be resumed safely; + the retry restarts from zero instead of stitching unverified bytes.""" + buf = io.BytesIO() + with patch( + "requests.get", + side_effect=[_interrupted_response(b"1234"), _mock_response(b"full")], + ) as mock_get: + download_from_mirrors(["https://mirror1.com/f"], {}, buf) + assert buf.getvalue() == b"full" + assert "Range" not in mock_get.call_args_list[1][1]["headers"] + + def test_drop_after_last_byte_recovers_via_416(self) -> None: + """A connection drop after the final body byte leaves a complete file; + the retry's 416 answer plus the length check turn it into success + instead of a wasted refetch.""" + first = _interrupted_response(b"1234", etag='"v1"') + first.headers = {**first.headers, "content-length": "4"} + r416 = _mock_response(b"", ok=False) + r416.status_code = 416 + buf = io.BytesIO() + with patch("requests.get", side_effect=[first, r416]) as mock_get: + url = download_from_mirrors(["https://mirror1.com/f"], {}, buf) + assert url == "https://mirror1.com/f" + assert buf.getvalue() == b"1234" + assert mock_get.call_count == 2 + + def test_mirror_drop_without_length_restarts(self) -> None: + """With no content-length there is no way to prove a stitched file + complete, so the retry restarts even though a validator exists.""" + buf = io.BytesIO() + with patch( + "requests.get", + side_effect=[ + _interrupted_response(b"1234", etag='"v1"'), + _mock_response(b"full"), + ], + ) as mock_get: + download_from_mirrors(["https://mirror1.com/f"], {}, buf) + assert buf.getvalue() == b"full" + assert "Range" not in mock_get.call_args_list[1][1]["headers"] + + def test_path_target_resumes_across_runs(self, tmp_path: Path) -> None: + """A path target routes through download_with_resume: a part file and + metadata from a previous run resume instead of restarting.""" + dest = tmp_path / "idf.tar.xz" + (tmp_path / "idf.tar.xz.part").write_bytes(b"1234") + (tmp_path / "idf.tar.xz.part.meta").write_text( + json.dumps( + {"url": "https://mirror1.com/f", "validator": '"v1"', "total": 8} + ) + ) + with patch("requests.get", return_value=_resumed_response(b"5678")) as mock_get: + url = download_from_mirrors(["https://mirror1.com/f"], {}, dest) + assert url == "https://mirror1.com/f" + assert dest.read_bytes() == b"12345678" + assert mock_get.call_args[1]["headers"] == { + "Range": "bytes=4-", + "If-Range": '"v1"', + } + + def test_path_target_falls_back_to_next_mirror(self, tmp_path: Path) -> None: + dest = tmp_path / "idf.tar.xz" + with patch( + "requests.get", + side_effect=[req.ConnectionError("down"), _mock_response(b"data")], + ): + url = download_from_mirrors( + ["https://mirror1.com/f", "https://mirror2.com/f"], {}, dest + ) + assert url == "https://mirror2.com/f" + assert dest.read_bytes() == b"data" + + def test_resumed_short_body_fails_length_check(self) -> None: + """A stitched file whose final length disagrees with the advertised + total is rejected instead of reported as success.""" + first = _interrupted_response(b"1234", etag='"v1"') + first.headers = {**first.headers, "content-length": "8"} + # the resume ends early (5 of 8 bytes); the poisoned part is then + # discarded and the fresh retry also delivers a short body + short_resume = _resumed_response(b"5") + short_fresh = _mock_response(b"56") + short_fresh.headers = {**short_fresh.headers, "content-length": "8"} + buf = io.BytesIO() + with ( + patch("requests.get", side_effect=[first, short_resume, short_fresh]), + pytest.raises(EsphomeError, match="all mirrors"), + ): + download_from_mirrors(["https://mirror1.com/f"], {}, buf) + + def test_failed_mirror_leftovers_not_kept_for_next_mirror(self) -> None: + """Bytes from a mirror that failed all attempts must not leak into the + next mirror's download (no bogus Range request, fresh content).""" + exhausted = [_interrupted_response(b"AAAA", etag='"a1"')] + for _ in range(2): + r = _interrupted_response(b"BB") + r.status_code = 206 + exhausted.append(r) + buf = io.BytesIO() + with patch( + "requests.get", + side_effect=exhausted + [_mock_response(b"clean")], + ) as mock_get: + url = download_from_mirrors( + ["https://mirror1.com/f", "https://mirror2.com/f"], + {}, + buf, + ) + assert url == "https://mirror2.com/f" + assert buf.getvalue() == b"clean" + # the second mirror starts fresh, without a Range header + assert mock_get.call_args_list[3][0][0] == "https://mirror2.com/f" + assert "Range" not in mock_get.call_args_list[3][1]["headers"] + + def test_all_mirrors_fail_raises_error_listing_every_attempt(self) -> None: with ( patch( "requests.get", @@ -666,7 +1368,7 @@ class TestDownloadFromMirrors: download_from_mirrors( ["https://mirror1.com/f", "https://mirror2.com/f"], {}, - tmp_path / "out.bin", + io.BytesIO(), ) # Every attempted URL appears in the message, and the first mirror's # exception (the primary URL, usually the one that matters) is chained. From 7c55de311f9c7562aa12ce27c5202a6015e4fa66 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Jul 2026 10:13:56 -1000 Subject: [PATCH 1016/1815] [wireguard] Mark private keys sensitive, stop redacting public keys (#17736) --- esphome/__main__.py | 12 +++++- esphome/components/wireguard/__init__.py | 4 +- tests/component_tests/wireguard/__init__.py | 1 + tests/component_tests/wireguard/test_init.py | 44 ++++++++++++++++++++ tests/unit_tests/test_main.py | 40 ++++++++++++++++++ 5 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/wireguard/__init__.py create mode 100644 tests/component_tests/wireguard/test_init.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 4abd18d239..553a8b390f 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1510,10 +1510,18 @@ def _redact_with_legacy_fallback(output: str) -> str: m = _LEGACY_REDACTION_RE.search(line) if m is None: continue + key = m.group("key") if not in_substitutions: - unmarked.add(m.group("key")) + # Public keys (e.g. wireguard's peer_public_key) are not secret; + # redacting them and telling maintainers to mark them cv.sensitive + # would be wrong on both counts. Substitution keys are user-named + # with no schema behind them, so anything secret-shaped there + # (public or not) stays conservatively redacted. + if "public" in key.split("_"): + continue + unmarked.add(key) lines[i] = ( - f"{line[: m.start()]}{m.group('key')}: " + f"{line[: m.start()]}{key}: " f"\\033[8m{m.group('val')}\\033[28m{line[m.end() :]}" ) output = "\n".join(lines) diff --git a/esphome/components/wireguard/__init__.py b/esphome/components/wireguard/__init__.py index e128b8476d..31de6639da 100644 --- a/esphome/components/wireguard/__init__.py +++ b/esphome/components/wireguard/__init__.py @@ -63,11 +63,11 @@ CONFIG_SCHEMA = cv.Schema( cv.GenerateID(CONF_TIME_ID): cv.use_id(time.RealTimeClock), cv.Required(CONF_ADDRESS): cv.ipv4address, cv.Optional(CONF_NETMASK, default="255.255.255.255"): cv.ipv4address, - cv.Required(CONF_PRIVATE_KEY): _wireguard_key, + cv.Required(CONF_PRIVATE_KEY): cv.sensitive(_wireguard_key), cv.Required(CONF_PEER_ENDPOINT): cv.string, cv.Required(CONF_PEER_PUBLIC_KEY): _wireguard_key, cv.Optional(CONF_PEER_PORT, default=51820): cv.port, - cv.Optional(CONF_PEER_PRESHARED_KEY): _wireguard_key, + cv.Optional(CONF_PEER_PRESHARED_KEY): cv.sensitive(_wireguard_key), cv.Optional(CONF_PEER_ALLOWED_IPS, default=["0.0.0.0/0"]): cv.ensure_list( _cidr_network ), diff --git a/tests/component_tests/wireguard/__init__.py b/tests/component_tests/wireguard/__init__.py new file mode 100644 index 0000000000..82b57e8fef --- /dev/null +++ b/tests/component_tests/wireguard/__init__.py @@ -0,0 +1 @@ +"""Tests for the wireguard component.""" diff --git a/tests/component_tests/wireguard/test_init.py b/tests/component_tests/wireguard/test_init.py new file mode 100644 index 0000000000..556d14cd00 --- /dev/null +++ b/tests/component_tests/wireguard/test_init.py @@ -0,0 +1,44 @@ +"""Tests for the wireguard component schema.""" + +import pytest + +from esphome.components.wireguard import CONFIG_SCHEMA +from esphome.const import PlatformFramework +from esphome.yaml_util import SensitiveStr +from tests.component_tests.types import SetCoreConfigCallable + +# Any 42 base64 chars plus a valid terminator satisfies _WG_KEY_REGEX. +PRIVATE_KEY = "a" * 42 + "A=" +PEER_PUBLIC_KEY = "b" * 42 + "A=" +PEER_PRESHARED_KEY = "c" * 42 + "A=" + + +@pytest.mark.parametrize( + ("field", "value", "sensitive"), + [ + ("private_key", PRIVATE_KEY, True), + ("peer_preshared_key", PEER_PRESHARED_KEY, True), + ("peer_public_key", PEER_PUBLIC_KEY, False), + ], +) +def test_key_sensitivity( + field: str, + value: str, + sensitive: bool, + set_core_config: SetCoreConfigCallable, +) -> None: + """The private and preshared keys are secrets and must be tagged so dump + tooling redacts them deterministically; the peer's public key is not a + secret and must stay readable in redacted dumps (see issue #17718).""" + set_core_config(PlatformFramework.ESP32_IDF) + config = CONFIG_SCHEMA( + { + "address": "10.0.0.2", + "private_key": PRIVATE_KEY, + "peer_endpoint": "wg.example.com", + "peer_public_key": PEER_PUBLIC_KEY, + "peer_preshared_key": PEER_PRESHARED_KEY, + } + ) + assert isinstance(config[field], SensitiveStr) == sensitive + assert config[field] == value diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 9a9aafec43..a1ed89bf5d 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -442,6 +442,46 @@ def test_redact_with_legacy_fallback__does_not_match_fragment_as_suffix( assert not any("legacy substring" in rec.message for rec in caplog.records) +@pytest.mark.parametrize("field", ["public_key", "peer_public_key"]) +def test_redact_with_legacy_fallback__skips_public_key_fields( + field: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Public keys are not secret; fields with a ``public`` name segment + must pass through unredacted and without the migration warning + (see issue #17718).""" + text = f"{field}: c29tZXB1YmxpY2tleQ==\n" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback(text) + assert out == text + assert not any("legacy substring" in rec.message for rec in caplog.records) + + +def test_redact_with_legacy_fallback__public_substitution_still_redacted( + caplog: pytest.LogCaptureFixture, +) -> None: + """Substitution keys are user-named with no schema behind them, so the + public-key exemption does not apply there; a ``public``-named substitution + keeps the conservative silent redaction.""" + text = "substitutions:\n public_key: something\nesphome:\n name: x\n" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback(text) + assert "public_key: \\033[8msomething\\033[28m" in out + assert not any("legacy substring" in rec.message for rec in caplog.records) + + +def test_redact_with_legacy_fallback__public_must_be_a_whole_segment( + caplog: pytest.LogCaptureFixture, +) -> None: + """The exemption matches ``public`` as an underscore-separated segment, + not a substring; an unrelated name like ``republic_key`` keeps the + conservative redaction.""" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback("republic_key: abc\n") + assert "republic_key: \\033[8mabc\\033[28m" in out + assert any("'republic_key'" in rec.message for rec in caplog.records) + + def test_redact_with_legacy_fallback__substitutions_redacted_without_warning( caplog: pytest.LogCaptureFixture, ) -> None: From f5f1c48f510873da3bc6f049335b727acec88553 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Jul 2026 10:17:01 -1000 Subject: [PATCH 1017/1815] [esp8266] Fail fast when Rosetta 2 is missing on Apple Silicon Macs (#17737) --- esphome/__main__.py | 7 +++ esphome/components/esp8266/__init__.py | 37 ++++++++++++- tests/unit_tests/components/test_esp8266.py | 61 ++++++++++++++++++++- tests/unit_tests/test_main.py | 37 +++++++++++++ 4 files changed, 138 insertions(+), 4 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 553a8b390f..27bb64a4df 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -776,6 +776,13 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int: check_placeholder_credentials(config) + # Keep this here, NOT in codegen: config-hash and --only-generate must keep + # working on machines that cannot run the toolchain. + if CORE.is_esp8266: + from esphome.components.esp8266 import check_rosetta + + check_rosetta() + # NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py # If you change this format, update the regex in that script as well _LOGGER.info("Compiling app... Build path: %s", CORE.build_path) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 0e0e2f77d7..7ce10d465d 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -1,5 +1,6 @@ import logging from pathlib import Path +import platform import re import subprocess @@ -20,9 +21,15 @@ from esphome.const import ( PLATFORM_ESP8266, ThreadModel, ) -from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority +from esphome.core import ( + CORE, + CoroPriority, + EsphomeError, + Lambda, + coroutine_with_priority, +) from esphome.core.config import BOARD_MAX_LENGTH -from esphome.helpers import copy_file_if_changed +from esphome.helpers import IS_MACOS, copy_file_if_changed from esphome.types import ConfigType from .boards import BOARDS, ESP8266_LD_SCRIPTS @@ -237,6 +244,32 @@ CONFIG_SCHEMA = cv.All( ) +def check_rosetta() -> None: + """Fail fast when the x86_64 ESP8266 toolchain cannot run on this Mac. + + There is no native arm64 build of the xtensa-lx106 toolchain; on Apple + Silicon it runs under Rosetta 2, which macOS updates can remove. + """ + if not IS_MACOS or platform.machine() != "arm64": + return + try: + result = subprocess.run( + ["/usr/bin/arch", "-x86_64", "/usr/bin/true"], + capture_output=True, + close_fds=False, + check=False, + ) + except OSError: + return # arch(1) unavailable; let the build proceed + if result.returncode != 0: + raise EsphomeError( + "ESP8266 builds on Apple Silicon Macs use an Intel (x86_64) " + "compiler that requires Rosetta 2, which is not installed on " + "this system. Install it with:\n" + " softwareupdate --install-rosetta --agree-to-license" + ) + + @coroutine_with_priority(CoroPriority.PLATFORM) async def to_code(config): cg.add(esp8266_ns.setup_preferences()) diff --git a/tests/unit_tests/components/test_esp8266.py b/tests/unit_tests/components/test_esp8266.py index 318fd2d889..fb0e437d24 100644 --- a/tests/unit_tests/components/test_esp8266.py +++ b/tests/unit_tests/components/test_esp8266.py @@ -1,9 +1,15 @@ """Tests for ESP8266 component.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + import pytest -from esphome.components.esp8266 import lambdas_use_scanf_float -from esphome.core import Lambda +from esphome.components import esp8266 +from esphome.components.esp8266 import check_rosetta, lambdas_use_scanf_float +from esphome.core import EsphomeError, Lambda from esphome.types import ConfigType @@ -60,3 +66,54 @@ def test_lambdas_use_scanf_float_nested() -> None: """Test detection in deeply nested config.""" config: ConfigType = {"a": {"b": {"c": [Lambda('sscanf(buf, "%f", &v)')]}}} assert lambdas_use_scanf_float(config) is True + + +@pytest.fixture +def apple_silicon_run(monkeypatch: pytest.MonkeyPatch) -> Generator[MagicMock]: + """Simulate an Apple Silicon Mac and yield the mocked subprocess.run.""" + monkeypatch.setattr(esp8266, "IS_MACOS", True) + with ( + patch("esphome.components.esp8266.platform.machine", return_value="arm64"), + patch("esphome.components.esp8266.subprocess.run") as mock_run, + ): + yield mock_run + + +@pytest.mark.parametrize( + ("is_macos", "machine"), + [ + (False, "arm64"), + (True, "x86_64"), + ], +) +def test_check_rosetta_skips_other_systems( + monkeypatch: pytest.MonkeyPatch, is_macos: bool, machine: str +) -> None: + """The check only probes on Apple Silicon Macs.""" + monkeypatch.setattr(esp8266, "IS_MACOS", is_macos) + with ( + patch("esphome.components.esp8266.platform.machine", return_value=machine), + patch("esphome.components.esp8266.subprocess.run") as mock_run, + ): + check_rosetta() + mock_run.assert_not_called() + + +def test_check_rosetta_installed(apple_silicon_run: MagicMock) -> None: + """No error when the x86_64 probe succeeds (Rosetta present).""" + apple_silicon_run.return_value = MagicMock(returncode=0) + check_rosetta() + apple_silicon_run.assert_called_once() + + +def test_check_rosetta_missing(apple_silicon_run: MagicMock) -> None: + """A failing x86_64 probe raises an actionable error.""" + apple_silicon_run.return_value = MagicMock(returncode=1) + with pytest.raises(EsphomeError, match="softwareupdate --install-rosetta"): + check_rosetta() + + +def test_check_rosetta_arch_unavailable(apple_silicon_run: MagicMock) -> None: + """The build proceeds when arch(1) cannot be executed.""" + apple_silicon_run.side_effect = OSError("no such file") + check_rosetta() diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index a1ed89bf5d..7de11d0568 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -5450,6 +5450,43 @@ def _setup_build_info_test( return build_info_path, firmware_path +def test_compile_program_esp8266_runs_rosetta_check(tmp_path: Path) -> None: + """Test that compile_program runs the Rosetta preflight for ESP8266 targets.""" + setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test_device") + + config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}} + args = MockArgs() + + with ( + patch( + "esphome.components.esp8266.check_rosetta", + side_effect=EsphomeError("Rosetta 2 is not installed"), + ) as mock_check, + pytest.raises(EsphomeError, match="Rosetta 2 is not installed"), + ): + compile_program(args, config) + + mock_check.assert_called_once() + + +def test_compile_program_skips_rosetta_check_on_other_platforms( + tmp_path: Path, + mock_compile_build_info_run_compile: Mock, + mock_compile_build_info_get_idedata: Mock, +) -> None: + """Test that the Rosetta preflight does not run for non-ESP8266 targets.""" + _setup_build_info_test(tmp_path, firmware_first=True) + + config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}} + args = MockArgs() + + with patch("esphome.components.esp8266.check_rosetta") as mock_check: + result = compile_program(args, config) + + assert result == 0 + mock_check.assert_not_called() + + def test_compile_program_emits_build_info_when_firmware_rebuilt( tmp_path: Path, caplog: pytest.LogCaptureFixture, From a8ebdcb8f22276558dc13bdd0bccf80892569e9e Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 21 Jul 2026 08:25:51 +1200 Subject: [PATCH 1018/1815] Bump version to 2026.7.1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 46f96b459a..abaaa7b7aa 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.7.0 +PROJECT_NUMBER = 2026.7.1 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 0b0d3c2e4a..cc44622c86 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.7.0" +__version__ = "2026.7.1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 388e41146957353ab25729890e5f47b50a5abe5f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:32:47 -1000 Subject: [PATCH 1019/1815] Bump aioesphomeapi from 45.6.1 to 45.6.2 (#17654) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index cc081d66f2..7168c8a488 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.6.1 +aioesphomeapi==45.6.2 zeroconf==0.150.0 puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import From fc6664d7376ca9132c94bd18bf523768af7280f5 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:16:24 -0400 Subject: [PATCH 1020/1815] [emc2101] Fix negative external temperatures reported as large positives (#17494) --- esphome/components/emc2101/emc2101.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/emc2101/emc2101.cpp b/esphome/components/emc2101/emc2101.cpp index 464f49fe51..f46082f5e7 100644 --- a/esphome/components/emc2101/emc2101.cpp +++ b/esphome/components/emc2101/emc2101.cpp @@ -145,9 +145,9 @@ float Emc2101Component::get_external_temperature() { return NAN; } - // join msb and lsb (5 least significant bits are not used) - uint16_t raw = (msb << 8 | lsb) >> 5; - return raw * 0.125; + // join msb and lsb (5 least significant bits are not used); msb is signed, so read as int16_t + int16_t raw = static_cast((msb << 8) | lsb) >> 5; + return raw * 0.125f; } float Emc2101Component::get_speed() { From 24a8634a4b00aab122a912bb7f051bbfebd9ffbb Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:16:38 -0400 Subject: [PATCH 1021/1815] [haier] Fix outdoor defrost temperature reporting the coil temperature (#17492) --- esphome/components/haier/hon_climate.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index f68404afd9..88d446829a 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -825,7 +825,7 @@ haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t * #ifdef USE_SENSOR this->update_sub_sensor_(SubSensorType::INDOOR_COIL_TEMPERATURE, bd_packet->indoor_coil_temperature / 2.0 - 20); this->update_sub_sensor_(SubSensorType::OUTDOOR_COIL_TEMPERATURE, bd_packet->outdoor_coil_temperature - 64); - this->update_sub_sensor_(SubSensorType::OUTDOOR_DEFROST_TEMPERATURE, bd_packet->outdoor_coil_temperature - 64); + this->update_sub_sensor_(SubSensorType::OUTDOOR_DEFROST_TEMPERATURE, bd_packet->outdoor_defrost_temperature - 64); this->update_sub_sensor_(SubSensorType::OUTDOOR_IN_AIR_TEMPERATURE, bd_packet->outdoor_in_air_temperature - 64); this->update_sub_sensor_(SubSensorType::OUTDOOR_OUT_AIR_TEMPERATURE, bd_packet->outdoor_out_air_temperature - 64); this->update_sub_sensor_(SubSensorType::POWER, encode_uint16(bd_packet->power[0], bd_packet->power[1])); From 718b04c15a91122a81025a8726d04a3a3dddb582 Mon Sep 17 00:00:00 2001 From: Guanzhong Chen Date: Thu, 16 Jul 2026 07:57:30 -0400 Subject: [PATCH 1022/1815] [zephyr] implement ISRInternalGPIOPin::digital_write (#17601) --- esphome/components/zephyr/gpio.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/esphome/components/zephyr/gpio.cpp b/esphome/components/zephyr/gpio.cpp index 1e4201d8f5..23da2cafac 100644 --- a/esphome/components/zephyr/gpio.cpp +++ b/esphome/components/zephyr/gpio.cpp @@ -173,6 +173,14 @@ bool IRAM_ATTR ISRInternalGPIOPin::digital_read() { return bool(gpio_pin_get(arg->gpio, arg->pin % arg->gpio_size) != arg->inverted); } +void IRAM_ATTR ISRInternalGPIOPin::digital_write(bool value) { + auto *arg = (zephyr::ISRPinArg *) this->arg_; + if (arg == nullptr || arg->gpio == nullptr) { + return; + } + gpio_pin_set(arg->gpio, arg->pin % arg->gpio_size, value != arg->inverted ? 1 : 0); +} + } // namespace esphome #endif From a30f9f7c65f5b6452add0a13f0faff66da9b2764 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:39:43 -1000 Subject: [PATCH 1023/1815] Bump filelock from 3.29.0 to 3.32.0 (#17759) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9dfff1452c..83b551c942 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 platformdirs==4.10.0 # native esp-idf toolchain global cache dir -filelock==3.29.0 # lock guarding the PlatformIO python-version cache heal +filelock==3.32.0 # lock guarding the PlatformIO python-version cache heal # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 From e84880e4ec669ff320922b1cf5298bb511d2a2d6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:33:49 -1000 Subject: [PATCH 1024/1815] Bump platformdirs from 4.10.0 to 4.11.0 (#17756) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 83b551c942..1e36620db0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,7 +26,7 @@ bleak==2.1.1 smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 -platformdirs==4.10.0 # native esp-idf toolchain global cache dir +platformdirs==4.11.0 # native esp-idf toolchain global cache dir filelock==3.32.0 # lock guarding the PlatformIO python-version cache heal # esp-idf >= 5.0 requires this From 008c85c7d51939e456b72155b04b707c40a160bd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:34:03 -1000 Subject: [PATCH 1025/1815] Bump astral-sh/setup-uv from 8.3.2 to 9.0.0 in /.github/actions/restore-python (#17757) Signed-off-by: dependabot[bot] --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 9d6dc5301c..dcd2495809 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -32,7 +32,7 @@ runs: # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # downstream jobs rely on is preserved. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can From 52c6a50b6df7f7d025f5c25831fa2ee7f26b2a7e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:45:27 -1000 Subject: [PATCH 1026/1815] Bump astral-sh/setup-uv from 8.3.2 to 9.0.0 (#17760) Signed-off-by: dependabot[bot] --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci.yml | 6 +++--- .github/workflows/sync-device-classes.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 58fc83e3f5..fb117be7b8 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -29,7 +29,7 @@ jobs: - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true # Pull-request-only workflow: a save could never be shared and diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17b33520a3..07418f8c17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -174,7 +174,7 @@ jobs: # install step (order-of-magnitude faster on cold boots, # with its own wheel cache). actions/setup-python still # provides the interpreter. - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -378,7 +378,7 @@ jobs: - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 2f350d09b3..a1be181d99 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -47,7 +47,7 @@ jobs: # setup-python interpreter so subsequent ``pre-commit`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the From 1e4bf48ea354132c96a3a60692e315f26f5d16a3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:45:39 -1000 Subject: [PATCH 1027/1815] Bump github/codeql-action/init from 4.37.1 to 4.37.2 (#17758) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 70527a0fa2..6083d884c7 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 + uses: github/codeql-action/init@e0647621c2984b5ed2f768cb892365bf2a616ad1 # v4.37.2 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} From 37be04f58e412bac2cc74ad13bf98d93e94508e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Jul 2026 12:21:12 -1000 Subject: [PATCH 1028/1815] [core] Reduce memory footprint of esphome upload (#17684) --- esphome/__main__.py | 17 +++++--- script/import_time_budget.json | 2 +- tests/unit_tests/test_compiled_config.py | 16 +++---- tests/unit_tests/test_lazy_imports.py | 53 ++++++++++++++++++++++++ tests/unit_tests/test_main.py | 25 +++++++++-- 5 files changed, 95 insertions(+), 18 deletions(-) create mode 100644 tests/unit_tests/test_lazy_imports.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 27bb64a4df..e56b504398 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -16,14 +16,10 @@ import sys import time from typing import Protocol -import argcomplete - # Note: Do not import modules from esphome.components here, as this would # cause them to be loaded before external components are processed, resulting # in the built-in version being used instead of the external component one. from esphome import const -import esphome.codegen as cg -from esphome.config import iter_component_configs, read_config, strip_default_ids from esphome.const import ( ALLOWED_NAME_CHARS, ARGUMENT_HELP_DEVICE, @@ -704,6 +700,8 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: def _wrap_to_code(name, comp, yaml_util): + import esphome.codegen as cg + coro = coroutine(comp.to_code) @functools.wraps(comp.to_code) @@ -739,6 +737,7 @@ def write_cpp(config: ConfigType) -> int: def generate_cpp_contents(config: ConfigType) -> None: from esphome import yaml_util + from esphome.config import iter_component_configs _LOGGER.info("Generating C++ source...") @@ -1464,6 +1463,7 @@ def command_wizard(args: ArgsProtocol) -> int | None: def command_config(args: ArgsProtocol, config: ConfigType) -> int | None: from esphome import yaml_util + from esphome.config import strip_default_ids if getattr(args, "no_defaults", False): user_config = getattr(config, "user_config", None) @@ -2498,7 +2498,12 @@ def parse_args(argv): # a deprecation warning). arguments = argv[1:] - argcomplete.autocomplete(parser) + # argcomplete only does anything when the shell-completion machinery + # invokes us with _ARGCOMPLETE set; skip the import otherwise. + if "_ARGCOMPLETE" in os.environ: + import argcomplete + + argcomplete.autocomplete(parser) if len(arguments) > 0 and arguments[0] in SIMPLE_CONFIG_ACTIONS: args, unknown_args = parser.parse_known_args(arguments) @@ -2597,6 +2602,8 @@ def run_esphome(argv): ) if config is None: + from esphome.config import read_config + config = read_config( command_line_substitutions, skip_external_update=skip_external, diff --git a/script/import_time_budget.json b/script/import_time_budget.json index e810817507..cda426bb3e 100644 --- a/script/import_time_budget.json +++ b/script/import_time_budget.json @@ -1,5 +1,5 @@ { "target_module": "esphome.__main__", "margin_pct": 20, - "cumulative_us": 95000 + "cumulative_us": 37200 } diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index e12107152b..f5e045077e 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -220,7 +220,7 @@ def test_run_esphome_upload_and_logs_use_cache_when_fresh( with ( caplog.at_level("INFO", logger="esphome.__main__"), - patch("esphome.__main__.read_config") as mock_read, + patch("esphome.config.read_config") as mock_read, patch.dict("esphome.__main__.POST_CONFIG_ACTIONS", {command: _stub}), ): assert run_esphome(["esphome", command, str(fresh_cache_files)]) == 0 @@ -242,7 +242,7 @@ def test_run_esphome_upload_and_logs_fall_back_when_no_cache( yaml_path.write_text("esphome:\n name: lite_test\n") with ( - patch("esphome.__main__.read_config", return_value=None) as mock_read, + patch("esphome.config.read_config", return_value=None) as mock_read, patch.dict( "esphome.__main__.POST_CONFIG_ACTIONS", {command: lambda args, config: 0}, @@ -266,7 +266,7 @@ def test_run_esphome_upload_does_not_refresh_cache_without_sidecar( with ( patch( - "esphome.__main__.read_config", + "esphome.config.read_config", return_value={"esphome": {"name": "lite_test"}}, ), patch("esphome.compiled_config.save_compiled_config") as mock_save, @@ -299,7 +299,7 @@ def test_run_esphome_upload_and_logs_refresh_cache_on_fallback( fresh_config = {"esphome": {"name": "lite_test"}, "logger": {}} with ( - patch("esphome.__main__.read_config", return_value=fresh_config), + patch("esphome.config.read_config", return_value=fresh_config), patch( "esphome.compiled_config.save_compiled_config", wraps=save_compiled_config ) as mock_save, @@ -322,7 +322,7 @@ def test_run_esphome_upload_with_substitution_does_not_refresh_cache( """`-s` substitutions skip the cache on both read and write -- saving here would clobber the cache with a substitution-specific config.""" with ( - patch("esphome.__main__.read_config", return_value={"esphome": {}}), + patch("esphome.config.read_config", return_value={"esphome": {}}), patch("esphome.compiled_config.save_compiled_config") as mock_save, patch.dict( "esphome.__main__.POST_CONFIG_ACTIONS", @@ -341,7 +341,7 @@ def test_run_esphome_compile_does_not_refresh_cache_via_fallback( upload/logs fallback path -- the fallback save would skip the storage_should_clean check.""" with ( - patch("esphome.__main__.read_config", return_value={"esphome": {}}), + patch("esphome.config.read_config", return_value={"esphome": {}}), patch("esphome.compiled_config.save_compiled_config") as mock_save, patch.dict( "esphome.__main__.POST_CONFIG_ACTIONS", @@ -360,7 +360,7 @@ def test_run_esphome_upload_with_substitution_skips_cache( against the prior substitution set, so reusing it would silently ignore the override.""" with ( - patch("esphome.__main__.read_config", return_value=None) as mock_read, + patch("esphome.config.read_config", return_value=None) as mock_read, patch.dict( "esphome.__main__.POST_CONFIG_ACTIONS", {"upload": lambda args, config: 0}, @@ -374,7 +374,7 @@ def test_run_esphome_upload_with_substitution_skips_cache( def test_run_esphome_compile_does_not_use_cache(fresh_cache_files: Path) -> None: """The compile subcommand always re-validates -- it's what writes the cache.""" with ( - patch("esphome.__main__.read_config", return_value=None) as mock_read, + patch("esphome.config.read_config", return_value=None) as mock_read, patch.dict( "esphome.__main__.POST_CONFIG_ACTIONS", {"compile": lambda args, config: 0}, diff --git a/tests/unit_tests/test_lazy_imports.py b/tests/unit_tests/test_lazy_imports.py new file mode 100644 index 0000000000..ee570a84f6 --- /dev/null +++ b/tests/unit_tests/test_lazy_imports.py @@ -0,0 +1,53 @@ +"""Guard the lazy-import contract of ``esphome.__main__``. + +Every ``esphome`` invocation pays for whatever ``esphome.__main__`` +imports at module level before the requested command runs. The +dashboard and device-builder spawn one ``esphome upload`` subprocess +per device, so keeping validation/codegen machinery out of the +top-level import directly lowers the RAM cost of each concurrent +upload (the upload/logs fast path in ``esphome.compiled_config`` +never needs them). + +``script/check_import_time.py`` budgets import *time* in CI; this +test pins down *which* heavy modules must stay out entirely. +""" + +from __future__ import annotations + +import subprocess +import sys + +# Modules that must only load for the commands that actually use them +# (compile/config validation, shell completion), never from a bare +# ``import esphome.__main__``. +HEAVY_MODULES = ( + "argcomplete", + "esphome.codegen", + "esphome.config", + "esphome.config_validation", + "esphome.cpp_generator", + "esphome.loader", + "voluptuous", +) + + +def test_main_module_does_not_import_heavy_modules() -> None: + """A bare ``import esphome.__main__`` must not drag in validation/codegen.""" + check = ( + "import sys; import esphome.__main__; " + f"leaked = [m for m in {HEAVY_MODULES!r} if m in sys.modules]; " + "print(','.join(leaked))" + ) + result = subprocess.run( + [sys.executable, "-c", check], + capture_output=True, + text=True, + check=True, + ) + leaked = result.stdout.strip() + assert not leaked, ( + f"esphome.__main__ imports heavy modules at top level: {leaked}. " + "Import them lazily inside the command that needs them instead; " + "every esphome invocation (including each parallel dashboard " + "upload subprocess) pays for top-level imports." + ) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 7de11d0568..e575934870 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -618,7 +618,7 @@ def test_command_config__no_defaults_skips_strip_default_ids( validated.user_config = {"sensor": [{"name": "x"}]} with patch( - "esphome.__main__.strip_default_ids", side_effect=AssertionError + "esphome.config.strip_default_ids", side_effect=AssertionError ) as mock_strip: result = command_config(args, validated) @@ -6201,7 +6201,7 @@ def test_run_esphome_bundle_detection(tmp_path: Path) -> None: "esphome.bundle.prepare_bundle_for_compile", return_value=extracted_yaml, ) as mock_prepare, - patch("esphome.__main__.read_config", return_value=None), + patch("esphome.config.read_config", return_value=None), ): result = run_esphome(["esphome", "compile", str(bundle_path)]) @@ -6219,7 +6219,7 @@ def test_run_esphome_non_bundle_skips_extraction(tmp_path: Path) -> None: with ( patch("esphome.bundle.is_bundle_path", return_value=False) as mock_is_bundle, patch("esphome.bundle.prepare_bundle_for_compile") as mock_prepare, - patch("esphome.__main__.read_config", return_value=None), + patch("esphome.config.read_config", return_value=None), ): result = run_esphome(["esphome", "compile", str(yaml_file)]) @@ -6247,7 +6247,7 @@ def test_run_esphome_skip_external_update_per_command( yaml_file = tmp_path / "device.yaml" yaml_file.write_text("esphome:\n name: test\n") - with patch("esphome.__main__.read_config", return_value=None) as mock_read: + with patch("esphome.config.read_config", return_value=None) as mock_read: run_esphome(["esphome", command, str(yaml_file)]) mock_read.assert_called_once() @@ -6405,6 +6405,23 @@ def test_parse_args_logs_states() -> None: assert args.states is True +def test_parse_args_argcomplete_only_runs_when_completing() -> None: + """Only import and invoke argcomplete when _ARGCOMPLETE is set. + + The shell-completion machinery sets _ARGCOMPLETE when it invokes the + CLI; a normal invocation must skip the import entirely so every + esphome subprocess (e.g. parallel dashboard uploads) avoids paying + for it. + """ + fake_argcomplete = MagicMock() + with ( + patch.dict(os.environ, {"_ARGCOMPLETE": "1"}), + patch.dict(sys.modules, {"argcomplete": fake_argcomplete}), + ): + parse_args(["esphome", "version"]) + fake_argcomplete.autocomplete.assert_called_once() + + def test_should_subscribe_states_default() -> None: """Test that states are shown by default when nothing is set.""" from esphome.__main__ import _should_subscribe_states From 4d3b4659d7965d3f200ee2562afdd2c7f454d9dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Wed, 22 Jul 2026 01:32:14 +0300 Subject: [PATCH 1029/1815] [ble_device_base] Platform-neutral BLE layer; esp32_ble_tracker implements BLEHub (#17150) --- CODEOWNERS | 1 + .../components/ble_device_base/__init__.py | 134 +++++ .../ble_device_base/ble_aes_ccm.cpp | 202 +++++++ .../components/ble_device_base/ble_aes_ccm.h | 34 ++ .../components/ble_device_base/ble_device.cpp | 496 ++++++++++++++++++ .../components/ble_device_base/ble_device.h | 239 +++++++++ esphome/components/ble_device_base/ble_hub.h | 63 +++ esphome/components/esp32_ble/__init__.py | 48 +- .../components/esp32_ble/ble_advertising.h | 3 +- esphome/components/esp32_ble/ble_uuid.cpp | 187 ------- esphome/components/esp32_ble/ble_uuid.h | 51 +- .../components/esp32_ble_tracker/__init__.py | 42 +- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 371 ++----------- .../esp32_ble_tracker/esp32_ble_tracker.h | 135 ++--- esphome/core/defines.h | 2 + tests/components/ble_device_base/__init__.py | 12 + .../ble_device_base/test_address.cpp | 31 ++ .../ble_device_base/test_aes_ccm.cpp | 56 ++ .../ble_device_base/test_ble_uuid.cpp | 41 ++ tests/components/ble_device_base/test_irk.cpp | 48 ++ 20 files changed, 1471 insertions(+), 725 deletions(-) create mode 100644 esphome/components/ble_device_base/__init__.py create mode 100644 esphome/components/ble_device_base/ble_aes_ccm.cpp create mode 100644 esphome/components/ble_device_base/ble_aes_ccm.h create mode 100644 esphome/components/ble_device_base/ble_device.cpp create mode 100644 esphome/components/ble_device_base/ble_device.h create mode 100644 esphome/components/ble_device_base/ble_hub.h delete mode 100644 esphome/components/esp32_ble/ble_uuid.cpp create mode 100644 tests/components/ble_device_base/__init__.py create mode 100644 tests/components/ble_device_base/test_address.cpp create mode 100644 tests/components/ble_device_base/test_aes_ccm.cpp create mode 100644 tests/components/ble_device_base/test_ble_uuid.cpp create mode 100644 tests/components/ble_device_base/test_irk.cpp diff --git a/CODEOWNERS b/CODEOWNERS index b752c9c5ce..b73ed319c8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -74,6 +74,7 @@ esphome/components/bl0939/* @ziceva esphome/components/bl0940/* @dan-s-github @tobias- esphome/components/bl0942/* @dbuezas @dwmw2 esphome/components/ble_client/* @buxtronix @clydebarrow +esphome/components/ble_device_base/* @Bl00d-B0b esphome/components/ble_nus/* @tomaszduda23 esphome/components/bluetooth_proxy/* @bdraco @jesserockz esphome/components/bm8563/* @abmantis diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py new file mode 100644 index 0000000000..d9789b0e9f --- /dev/null +++ b/esphome/components/ble_device_base/__init__.py @@ -0,0 +1,134 @@ +""" +ble_device_base — the platform-neutral BLE layer. + +Owns the shared advertisement types (ESPBTUUID / ESPBTDevice / ServiceData / +ESPBLEiBeacon / ESPBTDeviceListener, in ble_device.h) and the tracker contract +(BLEHub, in ble_hub.h) on every platform. + +BLE consumers (sensor components, bluetooth_proxy) bind to whichever tracker the +configuration declares via `cv.use_id(BLEHub)` — ESPHome resolves any declared +subclass, so there is no platform table here and no dependency in either +direction. A sensor appends inject_ble_hub to its CONFIG_SCHEMA (via cv.All) and +calls register_ble_device() in to_code; a tracker component subclasses BLEHub +(C++ and codegen class). Adding a new BLE chip requires only a new tracker +component. + +AES-CCM decryption for encrypted advertisements is provided portably in +ble_aes_ccm.h. +""" + +import re + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.core import CORE +from esphome.types import ConfigType + +CODEOWNERS = ["@Bl00d-B0b"] + +CONF_BLE_HUB_ID = "ble_hub_id" + +# CORE.data key: number of parsed-advertisement listeners registered in this +# build. Trackers whose codegen sizes storage at compile time (esp32's +# StaticVector count define) read it in their final coroutine. +KEY_BLE_LISTENER_COUNT = "ble_device_base_listener_count" + +ble_device_base_ns = cg.esphome_ns.namespace("ble_device_base") + +# The neutral tracker contract. Every tracker's codegen class declares this as a +# parent, which is what lets cv.use_id(BLEHub) resolve any of them. +BLEHub = ble_device_base_ns.class_("BLEHub") + +# The neutral listener base (C++: ble_device_base::ESPBTDeviceListener). +ESPBTDeviceListener = ble_device_base_ns.class_("ESPBTDeviceListener") + + +def inject_ble_hub(config: ConfigType) -> ConfigType: + """Validator: auto-resolve the configured BLE tracker into the config. + + Append via cv.All to a BLE consumer's CONFIG_SCHEMA. Uses cv.GenerateID + + cv.use_id(BLEHub): an omitted id resolves to the single declared tracker on + any platform; multiple trackers can be disambiguated with an explicit + ble_hub_id. + """ + return cv.Schema( + {cv.GenerateID(CONF_BLE_HUB_ID): cv.use_id(BLEHub)}, extra=cv.ALLOW_EXTRA + )(config) + + +def request_irk_support() -> None: + """Compile in resolve_irk()'s software-AES path. Called by sensors with an + irk: option so builds without IRK do not carry the resolution code.""" + cg.add_define("USE_BLE_DEVICE_IRK") + + +def get_listener_count() -> int: + """Number of parsed listeners registered so far (for tracker codegen).""" + return CORE.data.get(KEY_BLE_LISTENER_COUNT, 0) + + +async def register_ble_device(var: cg.MockObj, config: ConfigType) -> cg.MockObj: + """Register `var` as a parsed-advertisement listener on the configured hub.""" + hub = await cg.get_variable(config[CONF_BLE_HUB_ID]) + cg.add(hub.register_listener(var)) + CORE.data[KEY_BLE_LISTENER_COUNT] = CORE.data.get(KEY_BLE_LISTENER_COUNT, 0) + 1 + return var + + +# ---- shared validation / codegen helpers (platform-neutral) ---- +BT_UUID16_FORMAT = "XXXX" +BT_UUID32_FORMAT = "XXXXXXXX" +BT_UUID128_FORMAT = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" + +_BT_UUID16_RE = re.compile("^[A-F0-9]{4,}$") +_BT_UUID32_RE = re.compile("^[A-F0-9]{8,}$") +_BT_UUID128_RE = re.compile( + "^[A-F0-9]{8,}-[A-F0-9]{4,}-[A-F0-9]{4,}-[A-F0-9]{4,}-[A-F0-9]{12,}$" +) + + +# Validator table keyed by input length: (compiled pattern, label used in errors). +_BT_UUID_FORMATS = { + len(BT_UUID16_FORMAT): (_BT_UUID16_RE, "16 bit"), + len(BT_UUID32_FORMAT): (_BT_UUID32_RE, "32 bit"), + len(BT_UUID128_FORMAT): (_BT_UUID128_RE, "128"), +} + + +def bt_uuid(value: str) -> str: + in_value = cv.string_strict(value) + value = in_value.upper() + + fmt = _BT_UUID_FORMATS.get(len(value)) + if fmt is None: + raise cv.Invalid( + f"Bluetooth UUID must be in 16 bit '{BT_UUID16_FORMAT}', 32 bit '{BT_UUID32_FORMAT}', or 128 bit '{BT_UUID128_FORMAT}' format" + ) + pattern, label = fmt + if not pattern.match(value): + raise cv.Invalid( + f"Invalid hexadecimal value for {label} UUID format: '{in_value}'" + ) + return value + + +def as_hex(value: str) -> cg.RawExpression: + return cg.RawExpression(f"0x{value}ULL") + + +def _hex_array_expression(value: str, reverse: bool) -> cg.RawExpression: + value = value.replace("-", "") + cpp_array = [ + f"0x{part}" for part in [value[i : i + 2] for i in range(0, len(value), 2)] + ] + if reverse: + cpp_array.reverse() + return cg.RawExpression(f"(uint8_t*)(const uint8_t[16]){{{','.join(cpp_array)}}}") + + +def as_hex_array(value: str) -> cg.RawExpression: + return _hex_array_expression(value, reverse=False) + + +def as_reversed_hex_array(value: str) -> cg.RawExpression: + return _hex_array_expression(value, reverse=True) diff --git a/esphome/components/ble_device_base/ble_aes_ccm.cpp b/esphome/components/ble_device_base/ble_aes_ccm.cpp new file mode 100644 index 0000000000..3ff34acc34 --- /dev/null +++ b/esphome/components/ble_device_base/ble_aes_ccm.cpp @@ -0,0 +1,202 @@ +#include "ble_aes_ccm.h" + +#include +#include + +namespace esphome::ble_device_base { + +namespace { + +// AES-128 forward cipher only — CCM uses the block cipher in the encrypt +// direction for both the CTR keystream and the CBC-MAC. +const uint8_t SBOX[256] = { + 0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76, // + 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, // + 0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15, // + 0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75, // + 0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84, // + 0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf, // + 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8, // + 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, // + 0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73, // + 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb, // + 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, // + 0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08, // + 0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a, // + 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e, // + 0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, // + 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16, // +}; + +const uint8_t RCON[11] = {0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36}; + +inline uint8_t xtime(uint8_t x) { return static_cast((x << 1) ^ ((x & 0x80) ? 0x1b : 0x00)); } + +// AES-128 forward cipher with on-the-fly key schedule. +class Aes128 { + public: + explicit Aes128(const uint8_t key[16]) { + memcpy(this->rk_, key, 16); + for (size_t i = 16; i < 176; i += 4) { + uint8_t t[4] = {this->rk_[i - 4], this->rk_[i - 3], this->rk_[i - 2], this->rk_[i - 1]}; + if (i % 16 == 0) { + const uint8_t tmp = t[0]; + t[0] = static_cast(SBOX[t[1]] ^ RCON[i / 16]); + t[1] = SBOX[t[2]]; + t[2] = SBOX[t[3]]; + t[3] = SBOX[tmp]; + } + for (size_t j = 0; j < 4; j++) + this->rk_[i + j] = static_cast(this->rk_[i - 16 + j] ^ t[j]); + } + } + + void encrypt(const uint8_t in[16], uint8_t out[16]) const { + uint8_t s[16]; + memcpy(s, in, 16); + for (size_t i = 0; i < 16; i++) + s[i] ^= this->rk_[i]; + + for (size_t round = 1; round < 10; round++) { + for (uint8_t &b : s) + b = SBOX[b]; + shift_rows(s); + for (size_t c = 0; c < 4; c++) { + uint8_t *col = s + c * 4; + const uint8_t a0 = col[0], a1 = col[1], a2 = col[2], a3 = col[3]; + const uint8_t h = static_cast(a0 ^ a1 ^ a2 ^ a3); + col[0] ^= static_cast(h ^ xtime(static_cast(a0 ^ a1))); + col[1] ^= static_cast(h ^ xtime(static_cast(a1 ^ a2))); + col[2] ^= static_cast(h ^ xtime(static_cast(a2 ^ a3))); + col[3] ^= static_cast(h ^ xtime(static_cast(a3 ^ a0))); + } + for (size_t i = 0; i < 16; i++) + s[i] ^= this->rk_[round * 16 + i]; + } + + for (uint8_t &b : s) + b = SBOX[b]; + shift_rows(s); + for (size_t i = 0; i < 16; i++) + s[i] ^= this->rk_[160 + i]; + memcpy(out, s, 16); + } + + protected: + static void shift_rows(uint8_t s[16]) { + uint8_t t = s[1]; + s[1] = s[5]; + s[5] = s[9]; + s[9] = s[13]; + s[13] = t; + t = s[2]; + s[2] = s[10]; + s[10] = t; + t = s[6]; + s[6] = s[14]; + s[14] = t; + t = s[3]; + s[3] = s[15]; + s[15] = s[11]; + s[11] = s[7]; + s[7] = t; + } + + uint8_t rk_[176]; +}; + +} // namespace + +void aes128_encrypt_block(const uint8_t key[16], const uint8_t in[16], uint8_t out[16]) { + Aes128 aes(key); + aes.encrypt(in, out); +} + +bool aes_ccm_auth_decrypt(const uint8_t key[16], const uint8_t *nonce, size_t nonce_len, const uint8_t *aad, + size_t aad_len, const uint8_t *ciphertext, size_t ct_len, uint8_t *plaintext, + const uint8_t *tag, size_t tag_len) { + // CCM length field width L and tag width M (RFC 3610 §2.2). For a 13-byte + // nonce L = 2; BTHome uses M = 4. + if (nonce_len < 7 || nonce_len > 13 || tag_len < 4 || tag_len > 16) + return false; + const size_t l = 15 - nonce_len; + const size_t m = tag_len; + + const Aes128 aes(key); + + // Build CTR block A_i = [L-1] | nonce | counter(L bytes, big-endian). + uint8_t a[16]; + auto build_ctr = [&](uint32_t counter) { + a[0] = static_cast(l - 1); + memcpy(a + 1, nonce, nonce_len); + memset(a + 1 + nonce_len, 0, l); + for (size_t i = 0; i < l; i++) + a[15 - i] = static_cast((counter >> (8 * i)) & 0xff); + }; + + // S_0 = E(A_0); its first m bytes mask the transmitted tag. + uint8_t s0[16]; + build_ctr(0); + aes.encrypt(a, s0); + + // CTR-decrypt ciphertext into plaintext using S_1, S_2, ... + uint8_t ks[16]; + for (size_t off = 0; off < ct_len; off += 16) { + build_ctr(static_cast(off / 16) + 1); + aes.encrypt(a, ks); + const size_t n = std::min(static_cast(16), ct_len - off); + for (size_t i = 0; i < n; i++) + plaintext[off + i] = static_cast(ciphertext[off + i] ^ ks[i]); + } + + // CBC-MAC over B_0 | (formatted AAD) | plaintext. + uint8_t x[16]; + uint8_t b0[16]; + const uint8_t flags = static_cast((aad_len > 0 ? 0x40 : 0x00) | (((m - 2) / 2) << 3) | (l - 1)); + b0[0] = flags; + memcpy(b0 + 1, nonce, nonce_len); + memset(b0 + 1 + nonce_len, 0, l); + for (size_t i = 0; i < l; i++) + b0[15 - i] = static_cast((ct_len >> (8 * i)) & 0xff); + aes.encrypt(b0, x); // X_1 = E(B_0) + + if (aad_len > 0) { + // Only the < 2^16-2^8 encoding is needed for BLE-sized AAD. + uint8_t blk[16] = {0}; + blk[0] = static_cast((aad_len >> 8) & 0xff); + blk[1] = static_cast(aad_len & 0xff); + size_t ai = 0; + size_t pos = 2; + while (pos < 16 && ai < aad_len) + blk[pos++] = aad[ai++]; + for (size_t i = 0; i < 16; i++) + x[i] ^= blk[i]; + aes.encrypt(x, x); + while (ai < aad_len) { + memset(blk, 0, 16); + const size_t n = std::min(static_cast(16), aad_len - ai); + memcpy(blk, aad + ai, n); + ai += n; + for (size_t i = 0; i < 16; i++) + x[i] ^= blk[i]; + aes.encrypt(x, x); + } + } + + for (size_t off = 0; off < ct_len; off += 16) { + uint8_t blk[16] = {0}; + const size_t n = std::min(static_cast(16), ct_len - off); + memcpy(blk, plaintext + off, n); + for (size_t i = 0; i < 16; i++) + x[i] ^= blk[i]; + aes.encrypt(x, x); + } + + // Expected tag U = T XOR S_0[0..m). Constant-time compare with the received tag. + uint8_t diff = 0; + for (size_t i = 0; i < m; i++) + diff |= static_cast((x[i] ^ s0[i]) ^ tag[i]); + return diff == 0; +} + +} // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/ble_aes_ccm.h b/esphome/components/ble_device_base/ble_aes_ccm.h new file mode 100644 index 0000000000..d337ad8ecd --- /dev/null +++ b/esphome/components/ble_device_base/ble_aes_ccm.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include + +namespace esphome::ble_device_base { + +// Self-contained AES-128-CCM authenticated decryption (RFC 3610). +// +// Encrypted BLE advertisements (BTHome, several Xiaomi/ATC variants) use +// AES-128-CCM. The platform crypto that provides it is inconsistent across BLE +// targets: ESP-IDF exposes PSA/mbedtls, but a LibreTiny SDK may keep its mbedtls +// internal (e.g. the beken-72xx SDK ships mbedtls with CCM enabled but does not +// put it on the application include path), so a sensor cannot rely on +// being available. This software implementation makes +// encrypted-advertisement decryption work on every BLE platform without a +// per-chip crypto dependency. Decryption volume is tiny (one short block per +// matching advertisement), so software AES is not a meaningful cost. +// +// Verifies the CCM authentication tag and, on success, writes `ct_len` decrypted +// bytes to `plaintext` and returns true. Returns false when authentication fails +// (the caller must then discard `plaintext`). The CCM parameters follow the +// caller (BTHome: 13-byte nonce, 4-byte tag, no associated data); `aad` may be +// null when `aad_len` is 0. +/// AES-128 single-block encrypt (the same software cipher CCM uses). Used by +/// ESPBTDevice::resolve_irk() for the Bluetooth "ah" RPA hash, so IRK matching +/// works identically on every platform with no chip crypto dependency. +void aes128_encrypt_block(const uint8_t key[16], const uint8_t in[16], uint8_t out[16]); + +bool aes_ccm_auth_decrypt(const uint8_t key[16], const uint8_t *nonce, size_t nonce_len, const uint8_t *aad, + size_t aad_len, const uint8_t *ciphertext, size_t ct_len, uint8_t *plaintext, + const uint8_t *tag, size_t tag_len); + +} // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/ble_device.cpp b/esphome/components/ble_device_base/ble_device.cpp new file mode 100644 index 0000000000..c025af4d28 --- /dev/null +++ b/esphome/components/ble_device_base/ble_device.cpp @@ -0,0 +1,496 @@ +// ble_device.cpp +// +// Platform-neutral implementation of the shared BLE advertisement types. +// Parses raw BLE advertisement data into ESPBTDevice. + +#include "ble_device.h" + +#include "ble_aes_ccm.h" + +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::ble_device_base { + +static const char *const TAG = "ble_device_base"; + +// Longest advertisement payload worth hex-dumping at VERY_VERBOSE +// (legacy advertising: 31-byte adv + 31-byte scan response). +static constexpr size_t BLE_ADV_MAX_LOG_BYTES = 62; + +// --------------------------------------------------------------------------- +// ESPBTUUID +// --------------------------------------------------------------------------- + +ESPBTUUID ESPBTUUID::from_uint16(uint16_t uuid) { + ESPBTUUID ret; + ret.type_ = Type::UUID16; + ret.uuid_.uuid16 = uuid; + return ret; +} + +ESPBTUUID ESPBTUUID::from_uint32(uint32_t uuid) { + ESPBTUUID ret; + ret.type_ = Type::UUID32; + ret.uuid_.uuid32 = uuid; + return ret; +} + +ESPBTUUID ESPBTUUID::from_raw(const uint8_t *data) { + ESPBTUUID ret; + ret.type_ = Type::UUID128; + memcpy(ret.uuid_.uuid128, data, 16); + return ret; +} + +ESPBTUUID ESPBTUUID::from_raw_reversed(const uint8_t *data) { + ESPBTUUID ret; + ret.type_ = Type::UUID128; + for (int i = 0; i < 16; i++) + ret.uuid_.uuid128[i] = data[15 - i]; + return ret; +} + +ESPBTUUID ESPBTUUID::from_raw(const char *data, size_t length) { + // Same text-parsing semantics as the historical esp32_ble::ESPBTUUID::from_raw. + ESPBTUUID ret; + if (length == 4) { + // 16-bit UUID as 4-character hex string + auto parsed = parse_hex(data, length); + if (parsed.has_value()) { + ret.type_ = Type::UUID16; + ret.uuid_.uuid16 = parsed.value(); + } + } else if (length == 8) { + // 32-bit UUID as 8-character hex string + auto parsed = parse_hex(data, length); + if (parsed.has_value()) { + ret.type_ = Type::UUID32; + ret.uuid_.uuid32 = parsed.value(); + } + } else if (length == 16) { + // 16 raw bytes (little-endian 128-bit UUID) + ret.type_ = Type::UUID128; + memcpy(ret.uuid_.uuid128, reinterpret_cast(data), 16); + } else if (length == 36) { + // Dashed text form XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX + ret.type_ = Type::UUID128; + int n = 0; + for (size_t i = 0; i < length; i += 2) { + if (data[i] == '-') + i++; + uint8_t msb = data[i]; + uint8_t lsb = data[i + 1]; + if (msb > '9') + msb -= 7; + if (lsb > '9') + lsb -= 7; + ret.uuid_.uuid128[15 - n++] = ((msb & 0x0F) << 4) | (lsb & 0x0F); + } + } else { + ESP_LOGE(TAG, "ERROR: UUID value not 4, 8, 16 or 36 bytes - %s", data); + } + return ret; +} + +#ifdef USE_ESP32 +ESPBTUUID ESPBTUUID::from_uuid(esp_bt_uuid_t uuid) { + if (uuid.len == ESP_UUID_LEN_16) + return ESPBTUUID::from_uint16(uuid.uuid.uuid16); + if (uuid.len == ESP_UUID_LEN_32) + return ESPBTUUID::from_uint32(uuid.uuid.uuid32); + return ESPBTUUID::from_raw(uuid.uuid.uuid128); +} + +esp_bt_uuid_t ESPBTUUID::get_uuid() const { + esp_bt_uuid_t ret; + switch (this->type_) { + case Type::UUID16: + ret.len = ESP_UUID_LEN_16; + ret.uuid.uuid16 = this->uuid_.uuid16; + break; + case Type::UUID32: + ret.len = ESP_UUID_LEN_32; + ret.uuid.uuid32 = this->uuid_.uuid32; + break; + default: + case Type::UUID128: + ret.len = ESP_UUID_LEN_128; + memcpy(ret.uuid.uuid128, this->uuid_.uuid128, ESP_UUID_LEN_128); + break; + } + return ret; +} + +void ESPBTDevice::parse_scan_rst(const esp32_ble::BLEScanResult &scan_result) { + this->scan_result_ = &scan_result; + // BLEScanResult's bda is most-significant octet first; the neutral ingest + // takes the BLE controller (LSB-first) order, so reverse — address_uint64()/ + // address_str() then produce exactly the historical esp32 values. + uint8_t mac_lsb_first[6]; + for (uint8_t i = 0; i < 6; i++) + mac_lsb_first[i] = scan_result.bda[5 - i]; + this->from_scan_result(mac_lsb_first, scan_result.rssi, scan_result.ble_addr_type, scan_result.ble_adv, + scan_result.adv_data_len + scan_result.scan_rsp_len); +} +#endif // USE_ESP32 + +ESPBTUUID ESPBTUUID::as_128bit() const { + if (this->type_ == Type::UUID128) + return *this; + uint8_t data[16]; + this->to_128bit_(data); + return ESPBTUUID::from_raw(data); +} + +bool ESPBTUUID::contains(uint8_t data1, uint8_t data2) const { + // Adjacent byte-pair search — identical semantics to esp32_ble::ESPBTUUID::contains. + switch (this->type_) { + case Type::UUID16: + return (this->uuid_.uuid16 >> 8) == data2 && (this->uuid_.uuid16 & 0xFF) == data1; + case Type::UUID32: + for (uint8_t i = 0; i < 3; i++) { + bool a = ((this->uuid_.uuid32 >> i * 8) & 0xFF) == data1; + bool b = ((this->uuid_.uuid32 >> (i + 1) * 8) & 0xFF) == data2; + if (a && b) + return true; + } + return false; + case Type::UUID128: + for (uint8_t i = 0; i < 15; i++) { + if (this->uuid_.uuid128[i] == data1 && this->uuid_.uuid128[i + 1] == data2) + return true; + } + return false; + } + return false; +} + +const char *ESPBTUUID::to_str(char *buf) const { + // Identical output format to esp32_ble::ESPBTUUID::to_str. + char *pos = buf; + switch (this->type_) { + case Type::UUID16: + *pos++ = '0'; + *pos++ = 'x'; + *pos++ = format_hex_pretty_char(this->uuid_.uuid16 >> 12); + *pos++ = format_hex_pretty_char((this->uuid_.uuid16 >> 8) & 0x0F); + *pos++ = format_hex_pretty_char((this->uuid_.uuid16 >> 4) & 0x0F); + *pos++ = format_hex_pretty_char(this->uuid_.uuid16 & 0x0F); + *pos = 0; // NUL-terminate + return buf; + case Type::UUID32: + *pos++ = '0'; + *pos++ = 'x'; + for (int shift = 28; shift >= 0; shift -= 4) + *pos++ = format_hex_pretty_char((this->uuid_.uuid32 >> shift) & 0x0F); + *pos = 0; // NUL-terminate + return buf; + default: + case Type::UUID128: + // Format: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX + for (int8_t i = 15; i >= 0; i--) { + uint8_t byte = this->uuid_.uuid128[i]; + *pos++ = format_hex_pretty_char(byte >> 4); + *pos++ = format_hex_pretty_char(byte & 0x0F); + if (i == 12 || i == 10 || i == 8 || i == 6) + *pos++ = '-'; + } + *pos = 0; // NUL-terminate + return buf; + } +} + +void ESPBTUUID::to_128bit_(uint8_t out[16]) const { + // Bluetooth Base UUID 00000000-0000-1000-8000-00805F9B34FB (LSB-first), with the 16/32-bit + // value placed at bytes 12..; identical expansion to esp32_ble::ESPBTUUID::as_128bit(). + static const uint8_t BASE[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80, + 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + if (this->type_ == Type::UUID128) { + memcpy(out, this->uuid_.uuid128, 16); + return; + } + memcpy(out, BASE, 16); + const uint32_t value = (this->type_ == Type::UUID32) ? this->uuid_.uuid32 : this->uuid_.uuid16; + const size_t len = (this->type_ == Type::UUID32) ? 4 : 2; + for (size_t i = 0; i < len; i++) + out[12 + i] = (value >> (i * 8)) & 0xFF; +} + +bool ESPBTUUID::operator==(const ESPBTUUID &other) const { + if (this->type_ == other.type_) { + switch (this->type_) { + case Type::UUID16: + return this->uuid_.uuid16 == other.uuid_.uuid16; + case Type::UUID32: + return this->uuid_.uuid32 == other.uuid_.uuid32; + case Type::UUID128: + return memcmp(this->uuid_.uuid128, other.uuid_.uuid128, 16) == 0; + } + return false; + } + // Different widths: expand both to the 128-bit Bluetooth Base UUID form and compare, so a + // configured 16/32-bit UUID matches the equivalent 128-bit advertisement (esp32 parity). + uint8_t a[16]; + uint8_t b[16]; + this->to_128bit_(a); + other.to_128bit_(b); + return memcmp(a, b, 16) == 0; +} + +// --------------------------------------------------------------------------- +// ESPBLEiBeacon +// --------------------------------------------------------------------------- + +ESPBLEiBeacon::ESPBLEiBeacon(const uint8_t *data) { memcpy(&this->beacon_data_, data, sizeof(this->beacon_data_)); } + +optional ESPBLEiBeacon::from_manufacturer_data(const ServiceData &data) { + // iBeacon manufacturer specific data (after company-ID bytes have been stripped): + // [0x02][0x15][16-byte UUID][2-byte major][2-byte minor][1-byte power] = exactly 23 bytes + // Parity with esp32_ble_tracker: gate on the Apple company ID and length only. + // (Checking the 0x02/0x15 sub-type prefix would be stricter, but is a behavior + // change; it belongs to a follow-up, not this refactor.) + if (!data.uuid.contains(0x4C, 0x00)) // Apple company ID 0x004C + return {}; + if (data.data.size() != 23) + return {}; + return ESPBLEiBeacon(data.data.data()); +} + +// --------------------------------------------------------------------------- +// ESPBTDevice +// --------------------------------------------------------------------------- + +void ESPBTDevice::from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_type, const uint8_t *data, + uint16_t data_len) { + // Ingest is BLE controller order (LSB-first); store in printable (MSB-first) + // order so the raw address() accessor matches the historical esp32 layout. + for (uint8_t i = 0; i < 6; i++) + this->address_[i] = mac[5 - i]; + this->address_type_ = addr_type; + this->rssi_ = rssi; + this->name_.clear(); + this->service_uuids_.clear(); + this->manufacturer_datas_.clear(); + this->service_datas_.clear(); + this->tx_powers_.clear(); + this->appearance_.reset(); + this->ad_flag_.reset(); + this->parse_adv_(data, data_len); + +#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE + ESP_LOGVV(TAG, "Parse Result:"); + const char *address_type; + switch (this->address_type_) { + case BLE_ADDR_TYPE_PUBLIC: + address_type = "PUBLIC"; + break; + case BLE_ADDR_TYPE_RANDOM: + address_type = "RANDOM"; + break; + case BLE_ADDR_TYPE_RPA_PUBLIC: + address_type = "RPA_PUBLIC"; + break; + case BLE_ADDR_TYPE_RPA_RANDOM: + address_type = "RPA_RANDOM"; + break; + default: + address_type = "UNKNOWN"; + break; + } + char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGVV(TAG, " Address: %s (%s)", this->address_str_to(addr_buf), address_type); + ESP_LOGVV(TAG, " RSSI: %d", this->rssi_); + ESP_LOGVV(TAG, " Name: '%s'", this->name_.c_str()); + for (auto &it : this->tx_powers_) { + ESP_LOGVV(TAG, " TX Power: %d", it); + } + if (this->appearance_.has_value()) { + ESP_LOGVV(TAG, " Appearance: %u", *this->appearance_); + } + if (this->ad_flag_.has_value()) { + ESP_LOGVV(TAG, " Ad Flag: %u", *this->ad_flag_); + } + char uuid_buf[UUID_STR_LEN]; + for (auto &uuid : this->service_uuids_) { + ESP_LOGVV(TAG, " Service UUID: %s", uuid.to_str(uuid_buf)); + } + char hex_buf[format_hex_pretty_size(BLE_ADV_MAX_LOG_BYTES)]; + for (auto &mfg_data : this->manufacturer_datas_) { + auto ibeacon = ESPBLEiBeacon::from_manufacturer_data(mfg_data); + if (ibeacon.has_value()) { + ESP_LOGVV(TAG, " Manufacturer iBeacon:"); + ESP_LOGVV(TAG, " UUID: %s", ibeacon.value().get_uuid().to_str(uuid_buf)); + ESP_LOGVV(TAG, " Major: %u", ibeacon.value().get_major()); + ESP_LOGVV(TAG, " Minor: %u", ibeacon.value().get_minor()); + ESP_LOGVV(TAG, " TXPower: %d", ibeacon.value().get_signal_power()); + } else { + ESP_LOGVV(TAG, " Manufacturer ID: %s, data: %s", mfg_data.uuid.to_str(uuid_buf), + format_hex_pretty_to(hex_buf, mfg_data.data.data(), mfg_data.data.size())); + } + } + for (auto &svc_data : this->service_datas_) { + ESP_LOGVV(TAG, " Service data:"); + ESP_LOGVV(TAG, " UUID: %s", svc_data.uuid.to_str(uuid_buf)); + ESP_LOGVV(TAG, " Data: %s", format_hex_pretty_to(hex_buf, svc_data.data.data(), svc_data.data.size())); + } + ESP_LOGVV(TAG, " Adv data: %s", format_hex_pretty_to(hex_buf, data, data_len)); +#endif // ESPHOME_LOG_HAS_VERY_VERBOSE +} + +std::string ESPBTDevice::address_str() const { + char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + return std::string(this->address_str_to(buf)); +} + +const char *ESPBTDevice::address_str_to(char *buf) const { + // address_ is stored in printable (MSB-first) order. + format_mac_addr_upper(this->address_, buf); + return buf; +} + +uint64_t ESPBTDevice::address_uint64() const { + // address_ is MSB-first; byte 0 of the result is the LSB (esp32 semantics). + uint64_t addr = 0; + for (int i = 0; i < 6; i++) + addr |= static_cast(this->address_[i]) << ((5 - i) * 8); + return addr; +} + +bool ESPBTDevice::resolve_irk(const uint8_t *irk) const { +#ifdef USE_BLE_DEVICE_IRK + // Bluetooth Core 5.x "ah" function: hash = e(IRK, padding | prand)[low 24 bits]. + // The resolvable private address is prand (top 3 bytes) | hash (bottom 3 bytes). + // Uses the portable software AES-128 shared with the CCM decryptor, so IRK + // matching behaves identically on every platform (volume is one block per + // advertisement from a matching RPA device — software AES is not a cost). + uint8_t ecb_plaintext[16] = {0}; + uint8_t ecb_ciphertext[16]; + const uint64_t addr64 = this->address_uint64(); + ecb_plaintext[13] = (addr64 >> 40) & 0xff; + ecb_plaintext[14] = (addr64 >> 32) & 0xff; + ecb_plaintext[15] = (addr64 >> 24) & 0xff; + aes128_encrypt_block(irk, ecb_plaintext, ecb_ciphertext); + return ecb_ciphertext[15] == (addr64 & 0xff) && ecb_ciphertext[14] == ((addr64 >> 8) & 0xff) && + ecb_ciphertext[13] == ((addr64 >> 16) & 0xff); +#else + // No sensor configured an irk: in this build; the AES core is compiled out. + (void) irk; + return false; +#endif +} + +void ESPBTDevice::parse_adv_(const uint8_t *payload, uint16_t len) { + // BLE AD structure TLV: [length][type][value...] + // length includes the type byte. + uint16_t offset = 0; + while (offset < len) { + uint8_t ad_len = payload[offset++]; + if (ad_len == 0) + continue; // possible zero-padded advertisement data (esp32_ble_tracker skips these too) + if (offset + ad_len > len) + break; + uint8_t ad_type = payload[offset]; + const uint8_t *ad_data = &payload[offset + 1]; + uint8_t ad_data_len = ad_len - 1; + offset += ad_len; + + switch (ad_type) { + case 0x01: // Flags + if (ad_data_len >= 1) + this->ad_flag_ = ad_data[0]; + break; + + case 0x08: // Shortened Local Name + case 0x09: // Complete Local Name + // Keep the longest name seen — a merged adv + scan-response frame may carry both the + // shortened and the complete name, and the shortened form must never replace the + // complete one (same rule as esp32_ble_tracker's parse_adv_). + if (ad_data_len > this->name_.length()) + this->name_.assign(reinterpret_cast(ad_data), ad_data_len); + break; + + case 0x0A: // TX Power Level + if (ad_data_len >= 1) + this->tx_powers_.push_back(static_cast(ad_data[0])); + break; + + case 0x19: // Appearance + if (ad_data_len >= 2) + this->appearance_ = static_cast(ad_data[0]) | (static_cast(ad_data[1]) << 8); + break; + + case 0x02: // Incomplete List of 16-bit Service UUIDs + case 0x03: // Complete List of 16-bit Service UUIDs + for (uint8_t i = 0; (i + 1) < ad_data_len; i += 2) { + uint16_t uuid = (static_cast(ad_data[i + 1]) << 8) | ad_data[i]; + this->service_uuids_.push_back(ESPBTUUID::from_uint16(uuid)); + } + break; + + case 0x04: // Incomplete List of 32-bit Service UUIDs + case 0x05: // Complete List of 32-bit Service UUIDs + for (uint8_t i = 0; (i + 3) < ad_data_len; i += 4) { + uint32_t uuid = (static_cast(ad_data[i + 3]) << 24) | + (static_cast(ad_data[i + 2]) << 16) | (static_cast(ad_data[i + 1]) << 8) | + ad_data[i]; + this->service_uuids_.push_back(ESPBTUUID::from_uint32(uuid)); + } + break; + + case 0x06: // Incomplete List of 128-bit Service UUIDs + case 0x07: // Complete List of 128-bit Service UUIDs + for (uint8_t i = 0; (i + 15) < ad_data_len; i += 16) + this->service_uuids_.push_back(ESPBTUUID::from_raw(&ad_data[i])); + break; + + case 0xFF: // Manufacturer Specific Data + if (ad_data_len >= 2) { + uint16_t company_id = (static_cast(ad_data[1]) << 8) | ad_data[0]; + ServiceData sd; + sd.uuid = ESPBTUUID::from_uint16(company_id); + sd.data.assign(ad_data + 2, ad_data + ad_data_len); + this->manufacturer_datas_.push_back(std::move(sd)); + } + break; + + case 0x16: // Service Data — 16-bit UUID + if (ad_data_len >= 2) { + uint16_t uuid = (static_cast(ad_data[1]) << 8) | ad_data[0]; + ServiceData sd; + sd.uuid = ESPBTUUID::from_uint16(uuid); + sd.data.assign(ad_data + 2, ad_data + ad_data_len); + this->service_datas_.push_back(std::move(sd)); + } + break; + + case 0x20: // Service Data — 32-bit UUID + if (ad_data_len >= 4) { + uint32_t uuid = (static_cast(ad_data[3]) << 24) | (static_cast(ad_data[2]) << 16) | + (static_cast(ad_data[1]) << 8) | ad_data[0]; + ServiceData sd; + sd.uuid = ESPBTUUID::from_uint32(uuid); + sd.data.assign(ad_data + 4, ad_data + ad_data_len); + this->service_datas_.push_back(std::move(sd)); + } + break; + + case 0x21: // Service Data — 128-bit UUID + if (ad_data_len >= 16) { + ServiceData sd; + sd.uuid = ESPBTUUID::from_raw(ad_data); + sd.data.assign(ad_data + 16, ad_data + ad_data_len); + this->service_datas_.push_back(std::move(sd)); + } + break; + + default: + break; + } + } +} + +} // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/ble_device.h b/esphome/components/ble_device_base/ble_device.h new file mode 100644 index 0000000000..6b52a8f842 --- /dev/null +++ b/esphome/components/ble_device_base/ble_device.h @@ -0,0 +1,239 @@ +// ble_device.h +// +// Platform-neutral BLE advertisement types — the generic base every BLE consumer +// (sensor components, bluetooth_proxy, automation triggers) builds against: +// ESPBTUUID / ServiceData / ESPBLEiBeacon / ESPBTDevice / ESPBTDeviceListener +// +// These types are owned here on EVERY platform, with no chip-SDK types in their +// public surface. Platform trackers produce them: +// - esp32_ble_tracker adapts ESP-IDF scan results into ESPBTDevice and +// re-exports these names (esp32 only) for backward compatibility; +// - the LibreTiny trackers (bk72xx / ln882h) feed from_scan_result() directly. + +#pragma once + +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" + +#include +#include +#include +#include +#include + +#if defined(__cpp_lib_span) +#include +#endif + +#ifdef USE_ESP32 +// Historical esp32_ble API surface (below, under the same define) uses the +// ESP-IDF UUID/address/scan-result types directly; never referenced off-esp32. +#include "esphome/components/esp32_ble/ble_scan_result.h" +#include +#endif + +namespace esphome::ble_device_base { + +using adv_data_t = std::vector; + +// Bluetooth Core address types (spec values; matches ESP-IDF's esp_ble_addr_type_t). +static constexpr uint8_t BLE_ADDR_TYPE_PUBLIC = 0; +static constexpr uint8_t BLE_ADDR_TYPE_RANDOM = 1; +static constexpr uint8_t BLE_ADDR_TYPE_RPA_PUBLIC = 2; +static constexpr uint8_t BLE_ADDR_TYPE_RPA_RANDOM = 3; + +/// Buffer size for UUID string: "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX\0" +static constexpr size_t UUID_STR_LEN = 37; + +// --------------------------------------------------------------------------- +// ESPBTUUID — 16/32/128-bit Bluetooth UUID value type. +// API-compatible with the historical esp32_ble::ESPBTUUID; the esp_bt_uuid_t +// conversions live in esp32_ble (esp32-only adapters), not here. +// --------------------------------------------------------------------------- + +class ESPBTUUID { + public: + ESPBTUUID() = default; + + static ESPBTUUID from_uint16(uint16_t uuid); + static ESPBTUUID from_uint32(uint32_t uuid); + /// Construct from raw 16-byte little-endian UUID. + static ESPBTUUID from_raw(const uint8_t *data); + /// Construct from raw 16-byte big-endian UUID (reversed on store). + static ESPBTUUID from_raw_reversed(const uint8_t *data); + /// Parse from text: 4 hex chars (16-bit), 8 hex chars (32-bit), 16 raw bytes, + /// or the 36-char dashed UUID form. Same semantics as esp32_ble historically. + static ESPBTUUID from_raw(const char *data, size_t length); + static ESPBTUUID from_raw(const char *data) { return from_raw(data, strlen(data)); } + static ESPBTUUID from_raw(const std::string &data) { return from_raw(data.c_str(), data.length()); } + static ESPBTUUID from_raw(std::initializer_list data) { + return from_raw(reinterpret_cast(data.begin()), data.size()); + } + +#ifdef USE_ESP32 + /// Source compatibility with the historical esp32_ble API (esp32 builds only). + static ESPBTUUID from_uuid(esp_bt_uuid_t uuid); + esp_bt_uuid_t get_uuid() const; +#endif + + /// Expand to the 128-bit Bluetooth Base UUID form. + ESPBTUUID as_128bit() const; + + /// True if the UUID value contains the adjacent byte pair (data1, data2). + bool contains(uint8_t data1, uint8_t data2) const; + + bool operator==(const ESPBTUUID &other) const; + bool operator!=(const ESPBTUUID &other) const { return !(*this == other); } + + /// Write "0xABCD" / "0xABCDEF01" / the dashed 128-bit form into buf + /// (>= UUID_STR_LEN bytes) and return buf. + const char *to_str(char *buf) const; +#if defined(__cpp_lib_span) + const char *to_str(std::span output) const { return this->to_str(output.data()); } +#endif + enum class Type : uint8_t { UUID16, UUID32, UUID128 }; + Type type() const { return this->type_; } + uint16_t uuid16() const { return this->uuid_.uuid16; } + uint32_t uuid32() const { return this->uuid_.uuid32; } + const uint8_t *uuid128() const { return this->uuid_.uuid128; } + + protected: + // Expand to the 128-bit Bluetooth Base UUID byte form (out is 16 bytes, little-endian). + void to_128bit_(uint8_t out[16]) const; + + Type type_{Type::UUID16}; + union { + uint16_t uuid16; + uint32_t uuid32; + uint8_t uuid128[16]; + } uuid_{}; +}; + +// --------------------------------------------------------------------------- +// ServiceData — UUID-tagged advertisement payload (0x16 / 0xFF AD types) +// --------------------------------------------------------------------------- + +struct ServiceData { + ESPBTUUID uuid; + adv_data_t data; +}; + +// --------------------------------------------------------------------------- +// ESPBLEiBeacon +// --------------------------------------------------------------------------- + +class ESPBLEiBeacon { + public: + ESPBLEiBeacon() { memset(&this->beacon_data_, 0, sizeof(this->beacon_data_)); } + explicit ESPBLEiBeacon(const uint8_t *data); + static optional from_manufacturer_data(const ServiceData &data); + + uint16_t get_major() const { return byteswap(this->beacon_data_.major); } + uint16_t get_minor() const { return byteswap(this->beacon_data_.minor); } + int8_t get_signal_power() const { return this->beacon_data_.signal_power; } + ESPBTUUID get_uuid() const { return ESPBTUUID::from_raw_reversed(this->beacon_data_.proximity_uuid); } + + protected: + struct PACKED BeaconData { + uint8_t sub_type; + uint8_t length; + uint8_t proximity_uuid[16]; + uint16_t major; + uint16_t minor; + int8_t signal_power; + } beacon_data_; +}; + +// --------------------------------------------------------------------------- +// ESPBTDevice — parsed BLE advertisement +// --------------------------------------------------------------------------- + +class ESPBTDevice { + public: + /// Populate from a raw scan result delivered by a BLE tracker backend. + /// mac is least-significant octet first (BLE controller convention). + void from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_type, const uint8_t *data, uint16_t data_len); + + // Alias the core constant so the two cannot drift apart. + static constexpr size_t MAC_ADDRESS_PRETTY_BUFFER_SIZE = esphome::MAC_ADDRESS_PRETTY_BUFFER_SIZE; + + /// Return MAC as "XX:XX:XX:XX:XX:XX" string. + std::string address_str() const; + /// Buffer overload: writes "XX:XX:XX:XX:XX:XX\0" into buf (>= 18 bytes), returns buf. + const char *address_str_to(char *buf) const; +#if defined(__cpp_lib_span) + const char *address_str_to(std::span buf) const { + return this->address_str_to(buf.data()); + } +#endif + /// Return MAC as packed uint64 (byte 0 in LSB — matches esp32's address_uint64). + uint64_t address_uint64() const; + /// Raw MAC bytes in printable (MSB-first) order — matches the historical + /// esp32 layout (ESP-IDF bda order). + const uint8_t *address() const { return address_; } +#ifdef USE_ESP32 + // Historical esp32 signature: consumers assign the result to esp_ble_addr_type_t. + esp_ble_addr_type_t get_address_type() const { return static_cast(this->address_type_); } + /// Historical esp32 ingest (esp32 builds only): parse an ESP-IDF scan result. + void parse_scan_rst(const esp32_ble::BLEScanResult &scan_result); + // Exposed through a function for use in lambdas + const esp32_ble::BLEScanResult &get_scan_result() const { return *scan_result_; } +#else + uint8_t get_address_type() const { return this->address_type_; } +#endif + + int get_rssi() const { return rssi_; } + const std::string &get_name() const { return name_; } + + const std::vector &get_service_uuids() const { return service_uuids_; } + const std::vector &get_manufacturer_datas() const { return manufacturer_datas_; } + const std::vector &get_service_datas() const { return service_datas_; } + const std::vector &get_tx_powers() const { return tx_powers_; } + const optional &get_appearance() const { return appearance_; } + const optional &get_ad_flag() const { return ad_flag_; } + + /// Resolve a Resolvable Private Address against a 16-byte IRK (Bluetooth "ah" + /// function, AES-128). Uses the portable software AES shared with the CCM + /// decryptor; compiled only when a sensor configures irk: (request_irk_support). + bool resolve_irk(const uint8_t *irk) const; + + optional get_ibeacon() const { + for (const auto &it : this->manufacturer_datas_) { + auto res = ESPBLEiBeacon::from_manufacturer_data(it); + if (res.has_value()) + return res; + } + return {}; + } + + protected: + void parse_adv_(const uint8_t *payload, uint16_t len); + + uint8_t address_[6]{0}; + uint8_t address_type_{0}; + int rssi_{0}; + std::string name_{}; + std::vector service_uuids_{}; + std::vector manufacturer_datas_{}; + std::vector service_datas_{}; +#ifdef USE_ESP32 + const esp32_ble::BLEScanResult *scan_result_{nullptr}; +#endif + std::vector tx_powers_{}; + optional appearance_{}; + optional ad_flag_{}; +}; + +// --------------------------------------------------------------------------- +// ESPBTDeviceListener — base class for BLE consumers (sensors, proxy, triggers) +// --------------------------------------------------------------------------- + +class ESPBTDeviceListener { + public: + virtual ~ESPBTDeviceListener() = default; + /// Called at the end of each scan duration period. + virtual void on_scan_end() {} + virtual bool parse_device(const ESPBTDevice &device) = 0; +}; + +} // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/ble_hub.h b/esphome/components/ble_device_base/ble_hub.h new file mode 100644 index 0000000000..0f3f4de88d --- /dev/null +++ b/esphome/components/ble_device_base/ble_hub.h @@ -0,0 +1,63 @@ +// ble_hub.h +// +// BLEHub — the platform-neutral BLE tracker contract. +// +// Every BLE tracker component (esp32_ble_tracker, bk72xx_ble_tracker, +// ln882h_ble_tracker, future chips) implements this interface; every BLE +// consumer (sensor components, bluetooth_proxy) binds to it — in YAML via +// `cv.use_id(BLEHub)`, which resolves whichever tracker the config declares. +// Adding a new BLE chip therefore requires only a new tracker component that +// implements BLEHub: no consumer, registry, or base changes. +// +// Chip differences are expressed as data (HubCapabilities), never as +// platform conditionals in consumers. + +#pragma once + +#include "ble_device.h" + +#include +#include + +namespace esphome::ble_device_base { + +/// Callback for raw advertisements (the bluetooth_proxy path). +/// mac[] is least-significant octet first (BLE controller convention); +/// the hub delivers on the ESPHome main loop. +using RawAdvertisementCallback = + std::function; + +/// What a tracker's controller/SDK can do — consumers branch on data, not #ifdefs. +struct HubCapabilities { + /// Controller can send scan requests (active scanning). + bool active_scan; + /// Controller (or tracker) delivers advertisement + scan response as one merged + /// frame. When false, consumers relying on scan-response fields (e.g. names) + /// may only see them where the receiver merges per address (Home Assistant does). + bool merges_scan_response; + /// GATT client connections are available (today: esp32 only, but a chip SDK + /// gaining GATT support only has to flip this bit). + bool gatt; +}; + +class BLEHub { + public: + virtual ~BLEHub() = default; + + /// Register a parsed-advertisement consumer (BLE sensors, automation triggers). + virtual void register_listener(ESPBTDeviceListener *listener) = 0; + + /// Wire the raw-advertisement stream (bluetooth_proxy). One consumer at a time. + virtual void set_raw_advertisement_callback(RawAdvertisementCallback cb) = 0; + + virtual HubCapabilities get_capabilities() const = 0; + + /// Adapter MAC in printable (MSB-first) order, out[0] = MSB. + virtual void get_adapter_mac(uint8_t out[6]) = 0; + + virtual bool scan_running() = 0; + /// True when the current/configured scan mode is active (scan requests sent). + virtual bool scan_active() = 0; +}; + +} // namespace esphome::ble_device_base diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index c9fb42fde4..c8613963b9 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -2,11 +2,19 @@ from collections.abc import Callable, MutableMapping from dataclasses import dataclass from enum import Enum import logging -import re from typing import Any from esphome import automation import esphome.codegen as cg + +# bt_uuid validation lives in the platform-neutral ble_device_base; re-exported +# here for backward compatibility. +from esphome.components.ble_device_base import ( # noqa: F401 # pylint: disable=unused-import + BT_UUID16_FORMAT as bt_uuid16_format, + BT_UUID32_FORMAT as bt_uuid32_format, + BT_UUID128_FORMAT as bt_uuid128_format, + bt_uuid, +) from esphome.components.const import CONF_USE_PSRAM from esphome.components.esp32 import ( add_idf_sdkconfig_option, @@ -28,6 +36,7 @@ from esphome.core import CORE, CoroPriority, TimePeriod, coroutine_with_priority import esphome.final_validate as fv from esphome.types import ConfigType +AUTO_LOAD = ["ble_device_base"] # ble_uuid.h builds on the neutral ESPBTUUID DEPENDENCIES = ["esp32"] CODEOWNERS = ["@jesserockz", "@Rapsssito", "@bdraco"] DOMAIN = "esp32_ble" @@ -372,43 +381,6 @@ def _validate_key_sizes(config: ConfigType) -> ConfigType: CONFIG_SCHEMA = cv.All(CONFIG_SCHEMA, _validate_key_sizes) -bt_uuid16_format = "XXXX" -bt_uuid32_format = "XXXXXXXX" -bt_uuid128_format = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" - - -def bt_uuid(value): - in_value = cv.string_strict(value) - value = in_value.upper() - - if len(value) == len(bt_uuid16_format): - pattern = re.compile("^[A-F0-9]{4,}$") - if not pattern.match(value): - raise cv.Invalid( - f"Invalid hexadecimal value for 16 bit UUID format: '{in_value}'" - ) - return value - if len(value) == len(bt_uuid32_format): - pattern = re.compile("^[A-F0-9]{8,}$") - if not pattern.match(value): - raise cv.Invalid( - f"Invalid hexadecimal value for 32 bit UUID format: '{in_value}'" - ) - return value - if len(value) == len(bt_uuid128_format): - pattern = re.compile( - "^[A-F0-9]{8,}-[A-F0-9]{4,}-[A-F0-9]{4,}-[A-F0-9]{4,}-[A-F0-9]{12,}$" - ) - if not pattern.match(value): - raise cv.Invalid( - f"Invalid hexadecimal value for 128 UUID format: '{in_value}'" - ) - return value - raise cv.Invalid( - f"Bluetooth UUID must be in 16 bit '{bt_uuid16_format}', 32 bit '{bt_uuid32_format}', or 128 bit '{bt_uuid128_format}' format" - ) - - def validate_variant(_): variant = get_esp32_variant() if variant in NO_BLUETOOTH_VARIANTS: diff --git a/esphome/components/esp32_ble/ble_advertising.h b/esphome/components/esp32_ble/ble_advertising.h index 3cfa6f548a..6c8a97f453 100644 --- a/esphome/components/esp32_ble/ble_advertising.h +++ b/esphome/components/esp32_ble/ble_advertising.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/defines.h" +#include "ble_uuid.h" #include #include @@ -15,8 +16,6 @@ namespace esphome::esp32_ble { -class ESPBTUUID; - class BLEAdvertising { public: BLEAdvertising(uint32_t advertising_cycle_time); diff --git a/esphome/components/esp32_ble/ble_uuid.cpp b/esphome/components/esp32_ble/ble_uuid.cpp deleted file mode 100644 index 3ce05b4310..0000000000 --- a/esphome/components/esp32_ble/ble_uuid.cpp +++ /dev/null @@ -1,187 +0,0 @@ -#include "ble_uuid.h" - -#ifdef USE_ESP32 -#ifdef USE_ESP32_BLE_UUID - -#include -#include -#include -#include "esphome/core/log.h" -#include "esphome/core/helpers.h" - -namespace esphome::esp32_ble { - -static const char *const TAG = "esp32_ble"; - -ESPBTUUID::ESPBTUUID() : uuid_() {} -ESPBTUUID ESPBTUUID::from_uint16(uint16_t uuid) { - ESPBTUUID ret; - ret.uuid_.len = ESP_UUID_LEN_16; - ret.uuid_.uuid.uuid16 = uuid; - return ret; -} -ESPBTUUID ESPBTUUID::from_uint32(uint32_t uuid) { - ESPBTUUID ret; - ret.uuid_.len = ESP_UUID_LEN_32; - ret.uuid_.uuid.uuid32 = uuid; - return ret; -} -ESPBTUUID ESPBTUUID::from_raw(const uint8_t *data) { - ESPBTUUID ret; - ret.uuid_.len = ESP_UUID_LEN_128; - memcpy(ret.uuid_.uuid.uuid128, data, ESP_UUID_LEN_128); - return ret; -} -ESPBTUUID ESPBTUUID::from_raw_reversed(const uint8_t *data) { - ESPBTUUID ret; - ret.uuid_.len = ESP_UUID_LEN_128; - for (uint8_t i = 0; i < ESP_UUID_LEN_128; i++) - ret.uuid_.uuid.uuid128[ESP_UUID_LEN_128 - 1 - i] = data[i]; - return ret; -} -ESPBTUUID ESPBTUUID::from_raw(const char *data, size_t length) { - ESPBTUUID ret; - if (length == 4) { - // 16-bit UUID as 4-character hex string - auto parsed = parse_hex(data, length); - if (parsed.has_value()) { - ret.uuid_.len = ESP_UUID_LEN_16; - ret.uuid_.uuid.uuid16 = parsed.value(); - } - } else if (length == 8) { - // 32-bit UUID as 8-character hex string - auto parsed = parse_hex(data, length); - if (parsed.has_value()) { - ret.uuid_.len = ESP_UUID_LEN_32; - ret.uuid_.uuid.uuid32 = parsed.value(); - } - } else if (length == 16) { // how we can have 16 byte length string reprezenting 128 bit uuid??? needs to be - // investigated (lack of time) - ret.uuid_.len = ESP_UUID_LEN_128; - memcpy(ret.uuid_.uuid.uuid128, reinterpret_cast(data), 16); - } else if (length == 36) { - // If the length of the string is 36 bytes then we will assume it is a long hex string in - // UUID format. - ret.uuid_.len = ESP_UUID_LEN_128; - int n = 0; - for (size_t i = 0; i < length; i += 2) { - if (data[i] == '-') - i++; - uint8_t msb = data[i]; - uint8_t lsb = data[i + 1]; - - if (msb > '9') - msb -= 7; - if (lsb > '9') - lsb -= 7; - ret.uuid_.uuid.uuid128[15 - n++] = ((msb & 0x0F) << 4) | (lsb & 0x0F); - } - } else { - ESP_LOGE(TAG, "ERROR: UUID value not 2, 4, 16 or 36 bytes - %s", data); - } - return ret; -} -ESPBTUUID ESPBTUUID::from_uuid(esp_bt_uuid_t uuid) { - ESPBTUUID ret; - ret.uuid_.len = uuid.len; - if (uuid.len == ESP_UUID_LEN_16) { - ret.uuid_.uuid.uuid16 = uuid.uuid.uuid16; - } else if (uuid.len == ESP_UUID_LEN_32) { - ret.uuid_.uuid.uuid32 = uuid.uuid.uuid32; - } else if (uuid.len == ESP_UUID_LEN_128) { - memcpy(ret.uuid_.uuid.uuid128, uuid.uuid.uuid128, ESP_UUID_LEN_128); - } - return ret; -} -ESPBTUUID ESPBTUUID::as_128bit() const { - if (this->uuid_.len == ESP_UUID_LEN_128) { - return *this; - } - uint8_t data[] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; - uint32_t uuid32; - if (this->uuid_.len == ESP_UUID_LEN_32) { - uuid32 = this->uuid_.uuid.uuid32; - } else { - uuid32 = this->uuid_.uuid.uuid16; - } - for (uint16_t i = 0; i < this->uuid_.len; i++) { - data[12 + i] = ((uuid32 >> i * 8) & 0xFF); - } - return ESPBTUUID::from_raw(data); -} -bool ESPBTUUID::contains(uint8_t data1, uint8_t data2) const { - if (this->uuid_.len == ESP_UUID_LEN_16) { - return (this->uuid_.uuid.uuid16 >> 8) == data2 && (this->uuid_.uuid.uuid16 & 0xFF) == data1; - } else if (this->uuid_.len == ESP_UUID_LEN_32) { - for (uint8_t i = 0; i < 3; i++) { - bool a = ((this->uuid_.uuid.uuid32 >> i * 8) & 0xFF) == data1; - bool b = ((this->uuid_.uuid.uuid32 >> (i + 1) * 8) & 0xFF) == data2; - if (a && b) - return true; - } - } else { - for (uint8_t i = 0; i < 15; i++) { - if (this->uuid_.uuid.uuid128[i] == data1 && this->uuid_.uuid.uuid128[i + 1] == data2) - return true; - } - } - return false; -} -bool ESPBTUUID::operator==(const ESPBTUUID &uuid) const { - if (this->uuid_.len == uuid.uuid_.len) { - switch (this->uuid_.len) { - case ESP_UUID_LEN_16: - return this->uuid_.uuid.uuid16 == uuid.uuid_.uuid.uuid16; - case ESP_UUID_LEN_32: - return this->uuid_.uuid.uuid32 == uuid.uuid_.uuid.uuid32; - case ESP_UUID_LEN_128: - return memcmp(this->uuid_.uuid.uuid128, uuid.uuid_.uuid.uuid128, ESP_UUID_LEN_128) == 0; - default: - return false; - } - } - return this->as_128bit() == uuid.as_128bit(); -} -esp_bt_uuid_t ESPBTUUID::get_uuid() const { return this->uuid_; } -const char *ESPBTUUID::to_str(std::span output) const { - char *pos = output.data(); - - switch (this->uuid_.len) { - case ESP_UUID_LEN_16: - *pos++ = '0'; - *pos++ = 'x'; - *pos++ = format_hex_pretty_char(this->uuid_.uuid.uuid16 >> 12); - *pos++ = format_hex_pretty_char((this->uuid_.uuid.uuid16 >> 8) & 0x0F); - *pos++ = format_hex_pretty_char((this->uuid_.uuid.uuid16 >> 4) & 0x0F); - *pos++ = format_hex_pretty_char(this->uuid_.uuid.uuid16 & 0x0F); - *pos = '\0'; - return output.data(); - - case ESP_UUID_LEN_32: - *pos++ = '0'; - *pos++ = 'x'; - for (int shift = 28; shift >= 0; shift -= 4) { - *pos++ = format_hex_pretty_char((this->uuid_.uuid.uuid32 >> shift) & 0x0F); - } - *pos = '\0'; - return output.data(); - - default: - case ESP_UUID_LEN_128: - // Format: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX - for (int8_t i = 15; i >= 0; i--) { - uint8_t byte = this->uuid_.uuid.uuid128[i]; - *pos++ = format_hex_pretty_char(byte >> 4); - *pos++ = format_hex_pretty_char(byte & 0x0F); - if (i == 12 || i == 10 || i == 8 || i == 6) { - *pos++ = '-'; - } - } - *pos = '\0'; - return output.data(); - } -} -} // namespace esphome::esp32_ble - -#endif // USE_ESP32_BLE_UUID -#endif // USE_ESP32 diff --git a/esphome/components/esp32_ble/ble_uuid.h b/esphome/components/esp32_ble/ble_uuid.h index 20b8f4e35a..fd8da4baee 100644 --- a/esphome/components/esp32_ble/ble_uuid.h +++ b/esphome/components/esp32_ble/ble_uuid.h @@ -1,56 +1,21 @@ #pragma once #include "esphome/core/defines.h" -#include "esphome/core/hal.h" -#include "esphome/core/helpers.h" #ifdef USE_ESP32 #ifdef USE_ESP32_BLE_UUID -#include -#include -#include -#include +// The BLE UUID type is owned by the platform-neutral ble_device_base layer; +// this header re-exports it under the historical esp32_ble name (esp32 only). +// The full historical API surface — including from_uuid()/get_uuid() with the +// ESP-IDF esp_bt_uuid_t type — is preserved on esp32 builds. + +#include "esphome/components/ble_device_base/ble_device.h" namespace esphome::esp32_ble { -/// Buffer size for UUID string: "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX\0" -static constexpr size_t UUID_STR_LEN = 37; - -class ESPBTUUID { - public: - ESPBTUUID(); - - static ESPBTUUID from_uint16(uint16_t uuid); - - static ESPBTUUID from_uint32(uint32_t uuid); - - static ESPBTUUID from_raw(const uint8_t *data); - static ESPBTUUID from_raw_reversed(const uint8_t *data); - - static ESPBTUUID from_raw(const char *data, size_t length); - static ESPBTUUID from_raw(const char *data) { return from_raw(data, strlen(data)); } - static ESPBTUUID from_raw(const std::string &data) { return from_raw(data.c_str(), data.length()); } - static ESPBTUUID from_raw(std::initializer_list data) { - return from_raw(reinterpret_cast(data.begin()), data.size()); - } - - static ESPBTUUID from_uuid(esp_bt_uuid_t uuid); - - ESPBTUUID as_128bit() const; - - bool contains(uint8_t data1, uint8_t data2) const; - - bool operator==(const ESPBTUUID &uuid) const; - bool operator!=(const ESPBTUUID &uuid) const { return !(*this == uuid); } - - esp_bt_uuid_t get_uuid() const; - - const char *to_str(std::span output) const; - - protected: - esp_bt_uuid_t uuid_; -}; +using ble_device_base::UUID_STR_LEN; +using ESPBTUUID = ble_device_base::ESPBTUUID; } // namespace esphome::esp32_ble diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index e4139bed65..2febb16cf4 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -5,7 +5,7 @@ import logging from esphome import automation import esphome.codegen as cg -from esphome.components import esp32_ble, ota +from esphome.components import ble_device_base, esp32_ble, ota from esphome.components.esp32 import ( add_idf_sdkconfig_option, request_bluetooth, @@ -39,7 +39,7 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.enum import StrEnum from esphome.types import ConfigType -AUTO_LOAD = ["esp32_ble"] +AUTO_LOAD = ["ble_device_base", "esp32_ble"] DEPENDENCIES = ["esp32"] CODEOWNERS = ["@bdraco"] @@ -93,6 +93,7 @@ def register_ble_features(features: set[BLEFeatures]) -> None: esp32_ble_tracker_ns = cg.esphome_ns.namespace("esp32_ble_tracker") ESP32BLETracker = esp32_ble_tracker_ns.class_( "ESP32BLETracker", + ble_device_base.BLEHub, cg.Component, cg.Parented.template(esp32_ble.ESP32BLE), ) @@ -153,26 +154,11 @@ def validate_max_connections_deprecated(config: ConfigType) -> ConfigType: return config -def as_hex(value): - return cg.RawExpression(f"0x{value}ULL") - - -def as_hex_array(value): - value = value.replace("-", "") - cpp_array = [ - f"0x{part}" for part in [value[i : i + 2] for i in range(0, len(value), 2)] - ] - return cg.RawExpression(f"(uint8_t*)(const uint8_t[16]){{{','.join(cpp_array)}}}") - - -def as_reversed_hex_array(value): - value = value.replace("-", "") - cpp_array = [ - f"0x{part}" for part in [value[i : i + 2] for i in range(0, len(value), 2)] - ] - return cg.RawExpression( - f"(uint8_t*)(const uint8_t[16]){{{','.join(reversed(cpp_array))}}}" - ) +# Codegen helpers are owned by ble_device_base; kept under the historical names +# here for the components that import them from this module. +as_hex = ble_device_base.as_hex +as_hex_array = ble_device_base.as_hex_array +as_reversed_hex_array = ble_device_base.as_reversed_hex_array CONFIG_SCHEMA = cv.All( @@ -254,6 +240,10 @@ async def to_code(config): # Register the loggers this component needs esp32_ble.register_bt_logger(BTLoggers.BLE_SCAN) + # Behavior parity with the pre-split tracker: IRK resolution is always + # available on esp32 (sensors with irk: worked without opting in). + ble_device_base.request_irk_support() + var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -346,6 +336,14 @@ async def to_code(config): async def _add_ble_features(): # Add feature-specific defines based on what's needed required_features = _get_required_features() + # Sensors registered through the neutral ble_device_base path (BLEHub) need + # the parsed-device pipeline compiled in, exactly like esp32-path listeners. + neutral_listener_count = ble_device_base.get_listener_count() + if neutral_listener_count > 0: + required_features.add(BLEFeatures.ESP_BT_DEVICE) + # StaticVector sizing for the neutral (BLEHub) listener list — same + # pattern as the esp32-path registration counts below. + cg.add_define("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT", neutral_listener_count) if BLEFeatures.ESP_BT_DEVICE in required_features: cg.add_define("USE_ESP32_BLE_DEVICE") cg.add_define("USE_ESP32_BLE_UUID") diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index f57cb7f5dc..141aa6729d 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -27,15 +27,6 @@ #include #endif -#ifdef USE_ESP32_BLE_DEVICE -#ifdef USE_BLE_TRACKER_PSA_AES -#include -#else -#define MBEDTLS_AES_ALT -#include -#endif -#endif // USE_ESP32_BLE_DEVICE - // bt_trace.h #undef TAG @@ -43,9 +34,6 @@ namespace esphome::esp32_ble_tracker { static const char *const TAG = "esp32_ble_tracker"; -// BLE advertisement max: 31 bytes adv data + 31 bytes scan response -static constexpr size_t BLE_ADV_MAX_LOG_BYTES = 62; - ESP32BLETracker *global_esp32_ble_tracker = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) const char *client_state_to_string(ClientState state) { @@ -263,6 +251,10 @@ void ESP32BLETracker::start_scan_(bool first) { #ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT for (auto *listener : this->listeners_) listener->on_scan_end(); +#endif +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + for (auto *listener : this->neutral_listeners_) + listener->on_scan_end(); #endif } #ifdef USE_ESP32_BLE_DEVICE @@ -304,6 +296,21 @@ void ESP32BLETracker::register_client(ESPBTClient *client) { #endif } +void ESP32BLETracker::register_listener(ble_device_base::ESPBTDeviceListener *listener) { +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + // Neutral BLEHub path (migrated sensors): parsed-advertisement consumers only. + this->neutral_listeners_.push_back(listener); + this->parse_advertisements_ = true; +#endif +} + +void ESP32BLETracker::get_adapter_mac(uint8_t out[6]) { + get_mac_address_raw(out); // WiFi base MAC, MSB-first + // BT MAC = base MAC + 2 on the last octet only, wrapping without carry — + // exactly ESP-IDF's esp_read_mac(ESP_MAC_BT): mac[5] += MAC_ADDR_UNIVERSE_BT_OFFSET. + out[5] += 2; +} + void ESP32BLETracker::register_listener(ESPBTDeviceListener *listener) { #ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT listener->set_parent(this); @@ -315,6 +322,13 @@ void ESP32BLETracker::register_listener(ESPBTDeviceListener *listener) { void ESP32BLETracker::recalculate_advertisement_parser_types() { this->raw_advertisements_ = false; this->parse_advertisements_ = false; +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + // Neutral (BLEHub) listeners are parsed-advertisement consumers and are not in + // listeners_; without this, any later esp32-path registration (e.g. the proxy's + // GATT clients) would recompute the flags and silently drop parsed dispatch. + if (!this->neutral_listeners_.empty()) + this->parse_advertisements_ = true; +#endif #ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT for (auto *listener : this->listeners_) { if (listener->get_advertisement_parser_type() == AdvertisementParserType::PARSED_ADVERTISEMENTS) { @@ -434,270 +448,6 @@ void ESP32BLETracker::set_scanner_state_(ScannerState state) { } } -#ifdef USE_ESP32_BLE_DEVICE -ESPBLEiBeacon::ESPBLEiBeacon(const uint8_t *data) { memcpy(&this->beacon_data_, data, sizeof(beacon_data_)); } -optional ESPBLEiBeacon::from_manufacturer_data(const ServiceData &data) { - if (!data.uuid.contains(0x4C, 0x00)) - return {}; - - if (data.data.size() != 23) - return {}; - return ESPBLEiBeacon(data.data.data()); -} - -void ESPBTDevice::parse_scan_rst(const BLEScanResult &scan_result) { - this->scan_result_ = &scan_result; - for (uint8_t i = 0; i < ESP_BD_ADDR_LEN; i++) - this->address_[i] = scan_result.bda[i]; - this->address_type_ = static_cast(scan_result.ble_addr_type); - this->rssi_ = scan_result.rssi; - - // Parse advertisement data directly - uint8_t total_len = scan_result.adv_data_len + scan_result.scan_rsp_len; - this->parse_adv_(scan_result.ble_adv, total_len); - -#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE - ESP_LOGVV(TAG, "Parse Result:"); - const char *address_type; - switch (this->address_type_) { - case BLE_ADDR_TYPE_PUBLIC: - address_type = "PUBLIC"; - break; - case BLE_ADDR_TYPE_RANDOM: - address_type = "RANDOM"; - break; - case BLE_ADDR_TYPE_RPA_PUBLIC: - address_type = "RPA_PUBLIC"; - break; - case BLE_ADDR_TYPE_RPA_RANDOM: - address_type = "RPA_RANDOM"; - break; - default: - address_type = "UNKNOWN"; - break; - } - ESP_LOGVV(TAG, " Address: %02X:%02X:%02X:%02X:%02X:%02X (%s)", this->address_[0], this->address_[1], - this->address_[2], this->address_[3], this->address_[4], this->address_[5], address_type); - - ESP_LOGVV(TAG, " RSSI: %d", this->rssi_); - ESP_LOGVV(TAG, " Name: '%s'", this->name_.c_str()); - for (auto &it : this->tx_powers_) { - ESP_LOGVV(TAG, " TX Power: %d", it); - } - if (this->appearance_.has_value()) { - ESP_LOGVV(TAG, " Appearance: %u", *this->appearance_); - } - if (this->ad_flag_.has_value()) { - ESP_LOGVV(TAG, " Ad Flag: %u", *this->ad_flag_); - } - for (auto &uuid : this->service_uuids_) { - char uuid_buf[esp32_ble::UUID_STR_LEN]; - uuid.to_str(uuid_buf); - ESP_LOGVV(TAG, " Service UUID: %s", uuid_buf); - } - char hex_buf[format_hex_pretty_size(BLE_ADV_MAX_LOG_BYTES)]; - for (auto &data : this->manufacturer_datas_) { - auto ibeacon = ESPBLEiBeacon::from_manufacturer_data(data); - if (ibeacon.has_value()) { - ESP_LOGVV(TAG, " Manufacturer iBeacon:"); - char uuid_buf[esp32_ble::UUID_STR_LEN]; - ibeacon.value().get_uuid().to_str(uuid_buf); - ESP_LOGVV(TAG, " UUID: %s", uuid_buf); - ESP_LOGVV(TAG, " Major: %u", ibeacon.value().get_major()); - ESP_LOGVV(TAG, " Minor: %u", ibeacon.value().get_minor()); - ESP_LOGVV(TAG, " TXPower: %d", ibeacon.value().get_signal_power()); - } else { - char uuid_buf[esp32_ble::UUID_STR_LEN]; - data.uuid.to_str(uuid_buf); - ESP_LOGVV(TAG, " Manufacturer ID: %s, data: %s", uuid_buf, - format_hex_pretty_to(hex_buf, data.data.data(), data.data.size())); - } - } - for (auto &data : this->service_datas_) { - ESP_LOGVV(TAG, " Service data:"); - char uuid_buf[esp32_ble::UUID_STR_LEN]; - data.uuid.to_str(uuid_buf); - ESP_LOGVV(TAG, " UUID: %s", uuid_buf); - ESP_LOGVV(TAG, " Data: %s", format_hex_pretty_to(hex_buf, data.data.data(), data.data.size())); - } - - ESP_LOGVV(TAG, " Adv data: %s", - format_hex_pretty_to(hex_buf, scan_result.ble_adv, scan_result.adv_data_len + scan_result.scan_rsp_len)); -#endif -} - -void ESPBTDevice::parse_adv_(const uint8_t *payload, uint8_t len) { - size_t offset = 0; - - while (offset + 2 < len) { - const uint8_t field_length = payload[offset++]; // First byte is length of adv record - if (field_length == 0) { - continue; // Possible zero padded advertisement data - } - - // Validate field fits in remaining payload - if (offset + field_length > len) { - break; - } - - // first byte of adv record is adv record type - const uint8_t record_type = payload[offset++]; - const uint8_t *record = &payload[offset]; - const uint8_t record_length = field_length - 1; - offset += record_length; - - // See also Generic Access Profile Assigned Numbers: - // https://www.bluetooth.com/specifications/assigned-numbers/generic-access-profile/ See also ADVERTISING AND SCAN - // RESPONSE DATA FORMAT: https://www.bluetooth.com/specifications/bluetooth-core-specification/ (vol 3, part C, 11) - // See also Core Specification Supplement: https://www.bluetooth.com/specifications/bluetooth-core-specification/ - // (called CSS here) - - switch (record_type) { - case ESP_BLE_AD_TYPE_NAME_SHORT: - case ESP_BLE_AD_TYPE_NAME_CMPL: { - // CSS 1.2 LOCAL NAME - // "The Local Name data type shall be the same as, or a shortened version of, the local name assigned to the - // device." CSS 1: Optional in this context; shall not appear more than once in a block. - // SHORTENED LOCAL NAME - // "The Shortened Local Name data type defines a shortened version of the Local Name data type. The Shortened - // Local Name data type shall not be used to advertise a name that is longer than the Local Name data type." - if (record_length > this->name_.length()) { - this->name_ = std::string(reinterpret_cast(record), record_length); - } - break; - } - case ESP_BLE_AD_TYPE_TX_PWR: { - // CSS 1.5 TX POWER LEVEL - // "The TX Power Level data type indicates the transmitted power level of the packet containing the data type." - // CSS 1: Optional in this context (may appear more than once in a block). - this->tx_powers_.push_back(*record); - break; - } - case ESP_BLE_AD_TYPE_APPEARANCE: { - // CSS 1.12 APPEARANCE - // "The Appearance data type defines the external appearance of the device." - // See also https://www.bluetooth.com/specifications/gatt/characteristics/ - // CSS 1: Optional in this context; shall not appear more than once in a block and shall not appear in both - // the AD and SRD of the same extended advertising interval. - this->appearance_ = *reinterpret_cast(record); - break; - } - case ESP_BLE_AD_TYPE_FLAG: { - // CSS 1.3 FLAGS - // "The Flags data type contains one bit Boolean flags. The Flags data type shall be included when any of the - // Flag bits are non-zero and the advertising packet is connectable, otherwise the Flags data type may be - // omitted." - // CSS 1: Optional in this context; shall not appear more than once in a block. - this->ad_flag_ = *record; - break; - } - // CSS 1.1 SERVICE UUID - // The Service UUID data type is used to include a list of Service or Service Class UUIDs. - // There are six data types defined for the three sizes of Service UUIDs that may be returned: - // CSS 1: Optional in this context (may appear more than once in a block). - case ESP_BLE_AD_TYPE_16SRV_CMPL: - case ESP_BLE_AD_TYPE_16SRV_PART: { - // • 16-bit Bluetooth Service UUIDs - for (uint8_t i = 0; i < record_length / 2; i++) { - this->service_uuids_.push_back(ESPBTUUID::from_uint16(*reinterpret_cast(record + 2 * i))); - } - break; - } - case ESP_BLE_AD_TYPE_32SRV_CMPL: - case ESP_BLE_AD_TYPE_32SRV_PART: { - // • 32-bit Bluetooth Service UUIDs - for (uint8_t i = 0; i < record_length / 4; i++) { - this->service_uuids_.push_back(ESPBTUUID::from_uint32(*reinterpret_cast(record + 4 * i))); - } - break; - } - case ESP_BLE_AD_TYPE_128SRV_CMPL: - case ESP_BLE_AD_TYPE_128SRV_PART: { - // • Global 128-bit Service UUIDs - this->service_uuids_.push_back(ESPBTUUID::from_raw(record)); - break; - } - case ESP_BLE_AD_MANUFACTURER_SPECIFIC_TYPE: { - // CSS 1.4 MANUFACTURER SPECIFIC DATA - // "The Manufacturer Specific data type is used for manufacturer specific data. The first two data octets shall - // contain a company identifier from Assigned Numbers. The interpretation of any other octets within the data - // shall be defined by the manufacturer specified by the company identifier." - // CSS 1: Optional in this context (may appear more than once in a block). - if (record_length < 2) { - ESP_LOGV(TAG, "Record length too small for ESP_BLE_AD_MANUFACTURER_SPECIFIC_TYPE"); - break; - } - ServiceData data{}; - data.uuid = ESPBTUUID::from_uint16(*reinterpret_cast(record)); - data.data.assign(record + 2UL, record + record_length); - this->manufacturer_datas_.push_back(data); - break; - } - - // CSS 1.11 SERVICE DATA - // "The Service Data data type consists of a service UUID with the data associated with that service." - // CSS 1: Optional in this context (may appear more than once in a block). - case ESP_BLE_AD_TYPE_SERVICE_DATA: { - // «Service Data - 16 bit UUID» - // Size: 2 or more octets - // The first 2 octets contain the 16 bit Service UUID fol- lowed by additional service data - if (record_length < 2) { - ESP_LOGV(TAG, "Record length too small for ESP_BLE_AD_TYPE_SERVICE_DATA"); - break; - } - ServiceData data{}; - data.uuid = ESPBTUUID::from_uint16(*reinterpret_cast(record)); - data.data.assign(record + 2UL, record + record_length); - this->service_datas_.push_back(data); - break; - } - case ESP_BLE_AD_TYPE_32SERVICE_DATA: { - // «Service Data - 32 bit UUID» - // Size: 4 or more octets - // The first 4 octets contain the 32 bit Service UUID fol- lowed by additional service data - if (record_length < 4) { - ESP_LOGV(TAG, "Record length too small for ESP_BLE_AD_TYPE_32SERVICE_DATA"); - break; - } - ServiceData data{}; - data.uuid = ESPBTUUID::from_uint32(*reinterpret_cast(record)); - data.data.assign(record + 4UL, record + record_length); - this->service_datas_.push_back(data); - break; - } - case ESP_BLE_AD_TYPE_128SERVICE_DATA: { - // «Service Data - 128 bit UUID» - // Size: 16 or more octets - // The first 16 octets contain the 128 bit Service UUID followed by additional service data - if (record_length < 16) { - ESP_LOGV(TAG, "Record length too small for ESP_BLE_AD_TYPE_128SERVICE_DATA"); - break; - } - ServiceData data{}; - data.uuid = ESPBTUUID::from_raw(record); - data.data.assign(record + 16UL, record + record_length); - this->service_datas_.push_back(data); - break; - } - case ESP_BLE_AD_TYPE_INT_RANGE: - // Avoid logging this as it's very verbose - break; - default: { - ESP_LOGV(TAG, "Unhandled type: advType: 0x%02x", record_type); - break; - } - } - } -} - -std::string ESPBTDevice::address_str() const { - char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - return this->address_str_to(buf); -} - -uint64_t ESPBTDevice::address_uint64() const { return esp32_ble::ble_addr_to_uint64(this->address_); } -#endif // USE_ESP32_BLE_DEVICE - void ESP32BLETracker::dump_config() { ESP_LOGCONFIG(TAG, "BLE Tracker:"); ESP_LOGCONFIG(TAG, @@ -759,64 +509,7 @@ void ESP32BLETracker::print_bt_device_info(const ESPBTDevice &device) { } } -bool ESPBTDevice::resolve_irk(const uint8_t *irk) const { - static constexpr size_t AES_BLOCK_SIZE = 16; - static constexpr size_t AES_KEY_BITS = 128; - - uint8_t ecb_key[AES_BLOCK_SIZE]; - uint8_t ecb_plaintext[AES_BLOCK_SIZE]; - uint8_t ecb_ciphertext[AES_BLOCK_SIZE]; - - uint64_t addr64 = esp32_ble::ble_addr_to_uint64(this->address_); - - memcpy(&ecb_key, irk, AES_BLOCK_SIZE); - memset(&ecb_plaintext, 0, AES_BLOCK_SIZE); - - ecb_plaintext[13] = (addr64 >> 40) & 0xff; - ecb_plaintext[14] = (addr64 >> 32) & 0xff; - ecb_plaintext[15] = (addr64 >> 24) & 0xff; - -#ifdef USE_BLE_TRACKER_PSA_AES - // Use PSA Crypto API (mbedtls 4.0 / IDF 6.0+) - psa_key_attributes_t attributes = PSA_KEY_ATTRIBUTES_INIT; - psa_set_key_type(&attributes, PSA_KEY_TYPE_AES); - psa_set_key_bits(&attributes, AES_KEY_BITS); - psa_set_key_usage_flags(&attributes, PSA_KEY_USAGE_ENCRYPT); - psa_set_key_algorithm(&attributes, PSA_ALG_ECB_NO_PADDING); - - mbedtls_svc_key_id_t key_id; - if (psa_import_key(&attributes, ecb_key, AES_BLOCK_SIZE, &key_id) != PSA_SUCCESS) { - return false; - } - - size_t output_length; - psa_status_t status = psa_cipher_encrypt(key_id, PSA_ALG_ECB_NO_PADDING, ecb_plaintext, AES_BLOCK_SIZE, - ecb_ciphertext, AES_BLOCK_SIZE, &output_length); - psa_destroy_key(key_id); - if (status != PSA_SUCCESS || output_length != AES_BLOCK_SIZE) { - return false; - } -#else - // Use legacy mbedtls AES API (IDF < 6.0) - mbedtls_aes_context ctx = {0, 0, {0}}; - mbedtls_aes_init(&ctx); - - if (mbedtls_aes_setkey_enc(&ctx, ecb_key, AES_KEY_BITS) != 0) { - mbedtls_aes_free(&ctx); - return false; - } - - if (mbedtls_aes_crypt_ecb(&ctx, ESP_AES_ENCRYPT, ecb_plaintext, ecb_ciphertext) != 0) { - mbedtls_aes_free(&ctx); - return false; - } - - mbedtls_aes_free(&ctx); -#endif - - return ecb_ciphertext[15] == (addr64 & 0xff) && ecb_ciphertext[14] == ((addr64 >> 8) & 0xff) && - ecb_ciphertext[13] == ((addr64 >> 16) & 0xff); -} +// resolve_irk() is provided by ble_device_base (portable software AES). #endif // USE_ESP32_BLE_DEVICE @@ -848,6 +541,12 @@ void ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) { found = true; } #endif +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + for (auto *listener : this->neutral_listeners_) { + if (listener->parse_device(device)) + found = true; + } +#endif #ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT for (auto *client : this->clients_) { @@ -876,6 +575,10 @@ void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) { for (auto *listener : this->listeners_) listener->on_scan_end(); #endif +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + for (auto *listener : this->neutral_listeners_) + listener->on_scan_end(); +#endif this->set_scanner_state_(ScannerState::IDLE); } diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 3415196a11..c20962eb25 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -12,13 +12,6 @@ #ifdef USE_ESP32 -#include -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) -// mbedtls 4.0 (IDF 6.0) removed the legacy mbedtls AES API. -// Use the PSA Crypto API instead. -#define USE_BLE_TRACKER_PSA_AES -#endif - #include #include #include @@ -26,6 +19,8 @@ #include #include +#include "esphome/components/ble_device_base/ble_device.h" +#include "esphome/components/ble_device_base/ble_hub.h" #include "esphome/components/esp32_ble/ble.h" #include "esphome/components/esp32_ble/ble_uuid.h" #include "esphome/components/esp32_ble/ble_scan_result.h" @@ -38,7 +33,7 @@ namespace esphome::esp32_ble_tracker { using namespace esp32_ble; -using adv_data_t = std::vector; +using adv_data_t = ble_device_base::adv_data_t; enum AdvertisementParserType { PARSED_ADVERTISEMENTS, @@ -46,105 +41,27 @@ enum AdvertisementParserType { }; #ifdef USE_ESP32_BLE_UUID -struct ServiceData { - ESPBTUUID uuid; - adv_data_t data; -}; +using ServiceData = ble_device_base::ServiceData; #endif #ifdef USE_ESP32_BLE_DEVICE -class ESPBLEiBeacon { - public: - ESPBLEiBeacon() { memset(&this->beacon_data_, 0, sizeof(this->beacon_data_)); } - ESPBLEiBeacon(const uint8_t *data); - static optional from_manufacturer_data(const ServiceData &data); - - uint16_t get_major() { return byteswap(this->beacon_data_.major); } - uint16_t get_minor() { return byteswap(this->beacon_data_.minor); } - int8_t get_signal_power() { return this->beacon_data_.signal_power; } - ESPBTUUID get_uuid() { return ESPBTUUID::from_raw_reversed(this->beacon_data_.proximity_uuid); } - - protected: - struct { - uint8_t sub_type; - uint8_t length; - uint8_t proximity_uuid[16]; - uint16_t major; - uint16_t minor; - int8_t signal_power; - } PACKED beacon_data_; -}; - -class ESPBTDevice { - public: - void parse_scan_rst(const BLEScanResult &scan_result); - - std::string address_str() const; - - /// Format MAC address into provided buffer, returns pointer to buffer for convenience - const char *address_str_to(std::span buf) const { - format_mac_addr_upper(this->address_, buf.data()); - return buf.data(); - } - - uint64_t address_uint64() const; - - const uint8_t *address() const { return address_; } - - esp_ble_addr_type_t get_address_type() const { return this->address_type_; } - int get_rssi() const { return rssi_; } - const std::string &get_name() const { return this->name_; } - - const std::vector &get_tx_powers() const { return tx_powers_; } - - const optional &get_appearance() const { return appearance_; } - const optional &get_ad_flag() const { return ad_flag_; } - const std::vector &get_service_uuids() const { return service_uuids_; } - - const std::vector &get_manufacturer_datas() const { return manufacturer_datas_; } - - const std::vector &get_service_datas() const { return service_datas_; } - - // Exposed through a function for use in lambdas - const BLEScanResult &get_scan_result() const { return *scan_result_; } - - bool resolve_irk(const uint8_t *irk) const; - - optional get_ibeacon() const { - for (auto &it : this->manufacturer_datas_) { - auto res = ESPBLEiBeacon::from_manufacturer_data(it); - if (res.has_value()) - return res; - } - return {}; - } - - protected: - void parse_adv_(const uint8_t *payload, uint8_t len); - - esp_bd_addr_t address_{ - 0, - }; - esp_ble_addr_type_t address_type_{BLE_ADDR_TYPE_PUBLIC}; - int rssi_{0}; - std::string name_{}; - std::vector tx_powers_{}; - optional appearance_{}; - optional ad_flag_{}; - std::vector service_uuids_{}; - std::vector manufacturer_datas_{}; - std::vector service_datas_{}; - const BLEScanResult *scan_result_{nullptr}; -}; +// The advertisement device types are owned by the platform-neutral +// ble_device_base layer; re-exported here (esp32 only) for backward +// compatibility. ESPBTDevice::parse_scan_rst() (esp32-only) adapts BLEScanResult. +using ESPBLEiBeacon = ble_device_base::ESPBLEiBeacon; +using ESPBTDevice = ble_device_base::ESPBTDevice; #endif // USE_ESP32_BLE_DEVICE class ESP32BLETracker; -class ESPBTDeviceListener { +// esp32-flavored listener: the neutral parse_device/on_scan_end come from +// ble_device_base; this subclass adds the esp32-only raw-advertisement path +// (BLEScanResult batches) and the tracker back-pointer. +class ESPBTDeviceListener : public ble_device_base::ESPBTDeviceListener { public: - virtual void on_scan_end() {} -#ifdef USE_ESP32_BLE_DEVICE - virtual bool parse_device(const ESPBTDevice &device) = 0; +#ifndef USE_ESP32_BLE_DEVICE + // Raw-only build: no parsed-device support is compiled in. + bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; } #endif virtual bool parse_devices(const BLEScanResult *scan_results, size_t count) { return false; }; virtual AdvertisementParserType get_advertisement_parser_type() { @@ -295,6 +212,7 @@ class ESPBTClient : public ESPBTDeviceListener { }; class ESP32BLETracker final : public Component, + public ble_device_base::BLEHub, #ifdef USE_OTA_STATE_LISTENER public ota::OTAGlobalStateListener, #endif @@ -314,10 +232,23 @@ class ESP32BLETracker final : public Component, void loop() override; + // esp32-flavored path (unmigrated esp32 sensors; sets the tracker back-pointer). void register_listener(ESPBTDeviceListener *listener); void register_client(ESPBTClient *client); void recalculate_advertisement_parser_types(); + // ---- ble_device_base::BLEHub (the platform-neutral tracker contract) ---- + void register_listener(ble_device_base::ESPBTDeviceListener *listener) override; + void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback cb) override { + this->raw_advertisement_callback_ = std::move(cb); + } + ble_device_base::HubCapabilities get_capabilities() const override { + return {/* active_scan = */ true, /* merges_scan_response = */ true, /* gatt = */ true}; + } + void get_adapter_mac(uint8_t out[6]) override; + bool scan_running() override { return this->scanner_state_ == ScannerState::RUNNING; } + bool scan_active() override { return this->scan_active_; } + #ifdef USE_ESP32_BLE_DEVICE void print_bt_device_info(const ESPBTDevice &device); #endif @@ -405,6 +336,12 @@ class ESP32BLETracker final : public Component, StaticVector clients_; #endif std::vector scanner_state_listeners_; + // Parsed listeners registered through the neutral BLEHub contract (migrated + // sensors); dispatched alongside listeners_. +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + StaticVector neutral_listeners_; +#endif + ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{nullptr}; #ifdef USE_ESP32_BLE_DEVICE /// Vector of addresses that have already been printed in print_bt_device_info std::vector already_discovered_; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 61de97ca74..25f87b90f1 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -44,6 +44,7 @@ #define USE_AREAS #define USE_BINARY_SENSOR #define USE_BINARY_SENSOR_FILTER +#define USE_BLE_DEVICE_IRK #define USE_BUTTON #define USE_CAMERA #define USE_CLIMATE @@ -271,6 +272,7 @@ #define USE_ESP32_BLE_SERVER_ON_DISCONNECT #define ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT 1 #define ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT 1 +#define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 #define ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT 2 #define ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT 1 #define ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT 1 diff --git a/tests/components/ble_device_base/__init__.py b/tests/components/ble_device_base/__init__.py new file mode 100644 index 0000000000..1b041df8df --- /dev/null +++ b/tests/components/ble_device_base/__init__.py @@ -0,0 +1,12 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # resolve_irk() is compiled only when a sensor configures irk: + # (request_irk_support() emits USE_BLE_DEVICE_IRK). The unit-test build has + # no sensors, so emit the define here to put the real IRK path under test. + async def to_code_testing(config): + cg.add_define("USE_BLE_DEVICE_IRK") + + manifest.to_code = to_code_testing diff --git a/tests/components/ble_device_base/test_address.cpp b/tests/components/ble_device_base/test_address.cpp new file mode 100644 index 0000000000..c903003a7c --- /dev/null +++ b/tests/components/ble_device_base/test_address.cpp @@ -0,0 +1,31 @@ +#include + +#include + +#include "esphome/components/ble_device_base/ble_device.h" + +namespace esphome::ble_device_base::testing { + +// from_scan_result() ingests BLE controller order (LSB-first); the public +// accessors must expose the historical esp32 semantics: address() in printable +// (MSB-first) order, address_uint64() with byte 0 in the LSB, address_str() +// printed MSB-first. +namespace { +// Device AA:BB:CC:DD:EE:FF — controller order delivers FF first. +const uint8_t MAC_LSB_FIRST[6] = {0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa}; +} // namespace + +TEST(BleDeviceAddress, AccessorsMatchEsp32Semantics) { + ESPBTDevice device; + device.from_scan_result(MAC_LSB_FIRST, -50, BLE_ADDR_TYPE_PUBLIC, nullptr, 0); + + const uint8_t *raw = device.address(); + EXPECT_EQ(raw[0], 0xaa); // MSB first, like ESP-IDF's bda + EXPECT_EQ(raw[5], 0xff); + + EXPECT_EQ(device.address_uint64(), 0xAABBCCDDEEFFULL); + + EXPECT_EQ(device.address_str(), "AA:BB:CC:DD:EE:FF"); +} + +} // namespace esphome::ble_device_base::testing diff --git a/tests/components/ble_device_base/test_aes_ccm.cpp b/tests/components/ble_device_base/test_aes_ccm.cpp new file mode 100644 index 0000000000..39b2f81dcf --- /dev/null +++ b/tests/components/ble_device_base/test_aes_ccm.cpp @@ -0,0 +1,56 @@ +#include + +#include +#include + +#include "esphome/components/ble_device_base/ble_aes_ccm.h" + +namespace esphome::ble_device_base::testing { + +// Reference vector generated with Python `cryptography` AESCCM(tag_length=4), +// using the same AES-128-CCM parameters BTHome advertisements use: a 16-byte +// key, a 13-byte nonce, a 4-byte authentication tag and no associated data. +namespace { +const uint8_t KEY[16] = {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; +const uint8_t NONCE[13] = {0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c}; +const uint8_t CIPHERTEXT[7] = {0x68, 0xb4, 0xf6, 0xc5, 0x2b, 0xf8, 0xaf}; +const uint8_t TAG[4] = {0x48, 0x4d, 0xaa, 0x56}; +const uint8_t PLAINTEXT[7] = {0x02, 0x01, 0x64, 0x03, 0x10, 0x8a, 0x01}; +} // namespace + +TEST(BleAesCcm, DecryptsAndAuthenticatesKnownVector) { + uint8_t out[sizeof(PLAINTEXT)] = {}; + EXPECT_TRUE(aes_ccm_auth_decrypt(KEY, NONCE, sizeof(NONCE), nullptr, 0, CIPHERTEXT, sizeof(CIPHERTEXT), out, TAG, + sizeof(TAG))); + EXPECT_EQ(0, memcmp(out, PLAINTEXT, sizeof(PLAINTEXT))); +} + +TEST(BleAesCcm, RejectsTamperedTag) { + uint8_t bad_tag[sizeof(TAG)]; + memcpy(bad_tag, TAG, sizeof(TAG)); + bad_tag[0] ^= 0x01; + uint8_t out[sizeof(PLAINTEXT)] = {}; + EXPECT_FALSE(aes_ccm_auth_decrypt(KEY, NONCE, sizeof(NONCE), nullptr, 0, CIPHERTEXT, sizeof(CIPHERTEXT), out, bad_tag, + sizeof(bad_tag))); +} + +TEST(BleAesCcm, RejectsTamperedCiphertext) { + uint8_t bad_ct[sizeof(CIPHERTEXT)]; + memcpy(bad_ct, CIPHERTEXT, sizeof(CIPHERTEXT)); + bad_ct[0] ^= 0x01; + uint8_t out[sizeof(PLAINTEXT)] = {}; + EXPECT_FALSE( + aes_ccm_auth_decrypt(KEY, NONCE, sizeof(NONCE), nullptr, 0, bad_ct, sizeof(bad_ct), out, TAG, sizeof(TAG))); +} + +TEST(BleAesCcm, RejectsWrongKey) { + uint8_t bad_key[sizeof(KEY)]; + memcpy(bad_key, KEY, sizeof(KEY)); + bad_key[0] ^= 0xFF; + uint8_t out[sizeof(PLAINTEXT)] = {}; + EXPECT_FALSE(aes_ccm_auth_decrypt(bad_key, NONCE, sizeof(NONCE), nullptr, 0, CIPHERTEXT, sizeof(CIPHERTEXT), out, TAG, + sizeof(TAG))); +} + +} // namespace esphome::ble_device_base::testing diff --git a/tests/components/ble_device_base/test_ble_uuid.cpp b/tests/components/ble_device_base/test_ble_uuid.cpp new file mode 100644 index 0000000000..45abd48479 --- /dev/null +++ b/tests/components/ble_device_base/test_ble_uuid.cpp @@ -0,0 +1,41 @@ +#include + +#include +#include + +#include "esphome/components/ble_device_base/ble_device.h" + +namespace esphome::ble_device_base::testing { + +// A 16- or 32-bit UUID must compare equal to its 128-bit Bluetooth Base UUID form, matching +// esp32_ble_tracker. The 128-bit raw is the base UUID (LSB-first) with the short value at +// bytes 12.. : here 0x1234 -> bytes [12]=0x34, [13]=0x12. +TEST(BleDeviceUuid, ShortFormMatchesEquivalentLongForm) { + const ESPBTUUID u16 = ESPBTUUID::from_uint16(0x1234); + const uint8_t raw128[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80, + 0x00, 0x10, 0x00, 0x00, 0x34, 0x12, 0x00, 0x00}; + const ESPBTUUID u128 = ESPBTUUID::from_raw(raw128); + EXPECT_TRUE(u16 == u128); + EXPECT_TRUE(u128 == u16); // symmetric +} + +TEST(BleDeviceUuid, ThirtyTwoBitMatchesEquivalentLongForm) { + const ESPBTUUID u32 = ESPBTUUID::from_uint32(0x1122AAFF); + const uint8_t raw128[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80, + 0x00, 0x10, 0x00, 0x00, 0xFF, 0xAA, 0x22, 0x11}; + const ESPBTUUID u128 = ESPBTUUID::from_raw(raw128); + EXPECT_TRUE(u32 == u128); +} + +TEST(BleDeviceUuid, DifferentUuidsDoNotMatch) { + EXPECT_FALSE(ESPBTUUID::from_uint16(0x1234) == ESPBTUUID::from_uint16(0x1235)); + const uint8_t raw128[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80, + 0x00, 0x10, 0x00, 0x00, 0x34, 0x12, 0x00, 0x00}; + // Same low bytes but a non-base prefix is a genuinely different 128-bit UUID. + uint8_t custom[16]; + memcpy(custom, raw128, 16); + custom[0] ^= 0x01; + EXPECT_FALSE(ESPBTUUID::from_uint16(0x1234) == ESPBTUUID::from_raw(custom)); +} + +} // namespace esphome::ble_device_base::testing diff --git a/tests/components/ble_device_base/test_irk.cpp b/tests/components/ble_device_base/test_irk.cpp new file mode 100644 index 0000000000..4f507968c6 --- /dev/null +++ b/tests/components/ble_device_base/test_irk.cpp @@ -0,0 +1,48 @@ +#include + +#include + +#include "esphome/components/ble_device_base/ble_device.h" + +namespace esphome::ble_device_base::testing { + +// Reference vector generated with Python `cryptography` AES-128-ECB following +// the RPA resolution procedure (Bluetooth Core, Vol 3 Part H §2.2.2): +// hash = e(IRK, prand), where prand is the top 3 address bytes and the hash +// must equal the low 3 address bytes. +namespace { +const uint8_t IRK[16] = {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; +// 4A:2B:7C:FB:7B:21 — prand 4A:2B:7C (two MSBs = 01, an RPA), hash FB:7B:21. +const uint8_t RPA_LSB_FIRST[6] = {0x21, 0x7b, 0xfb, 0x7c, 0x2b, 0x4a}; + +ESPBTDevice make_device(const uint8_t mac_lsb_first[6]) { + ESPBTDevice device; + device.from_scan_result(mac_lsb_first, /*rssi=*/-60, /*addr_type=*/BLE_ADDR_TYPE_RPA_RANDOM, nullptr, 0); + return device; +} +} // namespace + +TEST(BleIrk, ResolvesMatchingRpa) { + ESPBTDevice device = make_device(RPA_LSB_FIRST); + EXPECT_TRUE(device.resolve_irk(IRK)); +} + +TEST(BleIrk, RejectsWrongIrk) { + uint8_t wrong_irk[16]; + for (int i = 0; i < 16; i++) + wrong_irk[i] = IRK[i] ^ 0xff; + ESPBTDevice device = make_device(RPA_LSB_FIRST); + EXPECT_FALSE(device.resolve_irk(wrong_irk)); +} + +TEST(BleIrk, RejectsWrongAddress) { + uint8_t other_mac[6]; + for (int i = 0; i < 6; i++) + other_mac[i] = RPA_LSB_FIRST[i]; + other_mac[0] ^= 0x01; // corrupt one hash byte + ESPBTDevice device = make_device(other_mac); + EXPECT_FALSE(device.resolve_irk(IRK)); +} + +} // namespace esphome::ble_device_base::testing From 4afb619672dc48afa8e1daf54f2b49d51ff7dede Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:32:31 -1000 Subject: [PATCH 1030/1815] Bump actions/setup-python from 6.3.0 to 7.0.0 (#17731) Signed-off-by: dependabot[bot] --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci-docker.yml | 4 ++-- .github/workflows/ci.yml | 6 +++--- .github/workflows/release.yml | 4 ++-- .github/workflows/sync-device-classes.yml | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index fb117be7b8..cd6f5ff55a 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -23,7 +23,7 @@ jobs: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - name: Set up uv diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 2740ca76ca..eedcebaca9 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -63,7 +63,7 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - name: Set up Docker Buildx @@ -147,7 +147,7 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - name: Set up Docker Buildx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07418f8c17..20e1dbde8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: 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@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python virtual environment @@ -165,7 +165,7 @@ jobs: ref: main path: device-builder - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" - name: Set up uv @@ -366,7 +366,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python 3.13 id: python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" - name: Restore Python virtual environment diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b63067ab4b..be5a6e9616 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,7 +62,7 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.x" - name: Build @@ -94,7 +94,7 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index a1be181d99..d58a049515 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -37,7 +37,7 @@ jobs: path: lib/home-assistant - name: Setup Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.14" From 9c211abc540dd10f2cde7416767c6b2dd7e78634 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:37:31 -1000 Subject: [PATCH 1031/1815] Bump github/codeql-action/analyze from 4.37.1 to 4.37.2 (#17761) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6083d884c7..c45d7dc57b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 + uses: github/codeql-action/analyze@e0647621c2984b5ed2f768cb892365bf2a616ad1 # v4.37.2 with: category: "/language:${{matrix.language}}" From 1f8fea5379057e8b81bbed8644846404189c2875 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:58:29 -1000 Subject: [PATCH 1032/1815] Bump actions/checkout from 7.0.0 to 7.0.1 (#17733) Signed-off-by: dependabot[bot] --- .github/workflows/auto-label-pr.yml | 2 +- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci-docker.yml | 6 +-- .github/workflows/ci-github-scripts.yml | 2 +- .../workflows/ci-memory-impact-comment.yml | 2 +- .github/workflows/ci.yml | 44 +++++++++---------- .../codeowner-approved-label-update.yml | 2 +- .../workflows/codeowner-review-request.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/pr-title-check.yml | 2 +- .github/workflows/release.yml | 8 ++-- .github/workflows/sync-device-classes.yml | 4 +- 12 files changed, 39 insertions(+), 39 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index d034227ef6..30915b68c7 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -24,7 +24,7 @@ jobs: if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot') steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Generate a token id: generate-token diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index cd6f5ff55a..820081cc46 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index eedcebaca9..926e96078b 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -61,7 +61,7 @@ jobs: tag: ${{ steps.tag.outputs.tag }} push: ${{ steps.tag.outputs.push }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: @@ -145,7 +145,7 @@ jobs: - "ha-addon" - "docker" steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: @@ -202,7 +202,7 @@ jobs: - nrf52 - host steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download image artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: diff --git a/.github/workflows/ci-github-scripts.yml b/.github/workflows/ci-github-scripts.yml index 3313ced690..ea039de9b9 100644 --- a/.github/workflows/ci-github-scripts.yml +++ b/.github/workflows/ci-github-scripts.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Run tests working-directory: .github/scripts/auto-label-pr diff --git a/.github/workflows/ci-memory-impact-comment.yml b/.github/workflows/ci-memory-impact-comment.yml index ac0322e2fa..0dc653bd39 100644 --- a/.github/workflows/ci-memory-impact-comment.yml +++ b/.github/workflows/ci-memory-impact-comment.yml @@ -49,7 +49,7 @@ jobs: - name: Check out code from base repository if: steps.pr.outputs.skip != 'true' - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Always check out from the base repository (esphome/esphome), never from forks # Use the PR's target branch to ensure we run trusted code from the main repo diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20e1dbde8e..72ece5b4fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: cache-key: ${{ steps.cache-key.outputs.key }} steps: - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Generate cache-key id: cache-key run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT @@ -77,7 +77,7 @@ jobs: if: needs.determine-jobs.outputs.python-linters == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -100,7 +100,7 @@ jobs: if: needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -127,7 +127,7 @@ jobs: if: needs.determine-jobs.outputs.import-time == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -155,11 +155,11 @@ jobs: if: needs.determine-jobs.outputs.device-builder == 'true' steps: - name: Check out esphome (this PR) - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: path: esphome - name: Check out esphome/device-builder - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: esphome/device-builder ref: main @@ -231,7 +231,7 @@ jobs: if: needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Restore Python id: restore-python uses: ./.github/actions/restore-python @@ -291,7 +291,7 @@ jobs: benchmarks: ${{ steps.determine.outputs.benchmarks }} steps: - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Fetch enough history to find the merge base fetch-depth: 2 @@ -363,7 +363,7 @@ jobs: bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }} steps: - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python 3.13 id: python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -418,7 +418,7 @@ jobs: if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]') steps: - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Restore Python uses: ./.github/actions/restore-python @@ -447,7 +447,7 @@ jobs: (github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true') steps: - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Restore Python uses: ./.github/actions/restore-python @@ -516,7 +516,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -614,7 +614,7 @@ jobs: ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf steps: - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -694,7 +694,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -779,7 +779,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -866,7 +866,7 @@ jobs: sudo apt-get install -y --no-install-recommends libsdl2-dev ccache - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -1011,7 +1011,7 @@ jobs: TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.esp32-platformio-components }} steps: - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Restore Python uses: ./.github/actions/restore-python @@ -1047,7 +1047,7 @@ jobs: if: github.event_name == 'push' && github.ref == 'refs/heads/dev' steps: - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -1076,7 +1076,7 @@ jobs: if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -1115,7 +1115,7 @@ jobs: skip: ${{ steps.check-script.outputs.skip || steps.check-tests.outputs.skip }} steps: - name: Check out target branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.base_ref }} @@ -1297,7 +1297,7 @@ jobs: flash_usage: ${{ steps.extract.outputs.flash_usage }} steps: - name: Check out PR branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -1366,7 +1366,7 @@ jobs: GH_TOKEN: ${{ github.token }} steps: - name: Check out code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: diff --git a/.github/workflows/codeowner-approved-label-update.yml b/.github/workflows/codeowner-approved-label-update.yml index 9b1333734e..bb1d1e2d7a 100644 --- a/.github/workflows/codeowner-approved-label-update.yml +++ b/.github/workflows/codeowner-approved-label-update.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout base branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.base.sha }} sparse-checkout: | diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml index da9c5f63d6..38a4b8ff0e 100644 --- a/.github/workflows/codeowner-review-request.yml +++ b/.github/workflows/codeowner-review-request.yml @@ -29,7 +29,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout base branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.base.sha }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c45d7dc57b..953f19c0a9 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -52,7 +52,7 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index 2bb6505b74..3a89c26cd3 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -16,7 +16,7 @@ jobs: name: Validate PR title runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index be5a6e9616..f85ad3e8ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: branch_build: ${{ steps.tag.outputs.branch_build }} deploy_env: ${{ steps.tag.outputs.deploy_env }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Get tag id: tag # yamllint disable rule:line-length @@ -60,7 +60,7 @@ jobs: contents: read # actions/checkout to build the sdist/wheel id-token: write # OIDC token for PyPI Trusted Publishing (pypa/gh-action-pypi-publish) steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: @@ -92,7 +92,7 @@ jobs: os: "ubuntu-24.04-arm" steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: @@ -168,7 +168,7 @@ jobs: - ghcr - dockerhub steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index d58a049515..5bec463a33 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -28,10 +28,10 @@ jobs: permission-pull-requests: write # pulls.create / pulls.update to open or refresh the sync PR - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Checkout Home Assistant - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: home-assistant/core path: lib/home-assistant From 078b1aa1d6831dc8033924a80b9312ae6ebeae25 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:02:30 +0000 Subject: [PATCH 1033/1815] Bump aioesphomeapi from 45.6.2 to 45.7.0 (#17768) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1e36620db0..ddec22f080 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.6.2 +aioesphomeapi==45.7.0 zeroconf==0.150.0 puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import From 2edb089f71fbab17b5d3ee5feaef57fd318f8309 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:09:45 -1000 Subject: [PATCH 1034/1815] Bump pypa/gh-action-pypi-publish from 1.14.0 to 1.14.1 (#17734) Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f85ad3e8ed..b8dbb7414d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,7 +70,7 @@ jobs: pip3 install build python3 -m build - name: Publish - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 with: skip-existing: true From 69085a7a426775b5353463b5931cb5e4b25b8448 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:18:21 -0500 Subject: [PATCH 1035/1815] [sen5x] Add model option to override autodetection (#17764) --- esphome/components/sen5x/sen5x.cpp | 40 ++++++++++++++++++------------ esphome/components/sen5x/sen5x.h | 2 ++ esphome/components/sen5x/sensor.py | 15 ++++++++++- tests/components/sen5x/common.yaml | 1 + 4 files changed, 41 insertions(+), 17 deletions(-) diff --git a/esphome/components/sen5x/sen5x.cpp b/esphome/components/sen5x/sen5x.cpp index 588650e630..f8df89ee33 100644 --- a/esphome/components/sen5x/sen5x.cpp +++ b/esphome/components/sen5x/sen5x.cpp @@ -101,25 +101,31 @@ void SEN5XComponent::setup() { ESP_LOGV(TAG, "Serial number %s", this->serial_number_); uint16_t raw_product_name[16]; - if (!this->get_register(SEN5X_CMD_GET_PRODUCT_NAME, raw_product_name, 16, 20)) { - ESP_LOGE(TAG, "Failed to read product name"); - this->error_code_ = PRODUCT_NAME_FAILED; - this->mark_failed(); - return; + Sen5xType detected_type = Sen5xType::UNKNOWN; + if (this->get_register(SEN5X_CMD_GET_PRODUCT_NAME, raw_product_name, 16, 20)) { + const char *product_name = sensirion_convert_to_string_in_place(raw_product_name, 16); + if (strncmp(product_name, "SEN50", 5) == 0) { + detected_type = Sen5xType::SEN50; + } else if (strncmp(product_name, "SEN54", 5) == 0) { + detected_type = Sen5xType::SEN54; + } else if (strncmp(product_name, "SEN55", 5) == 0) { + detected_type = Sen5xType::SEN55; + } } - const char *product_name = sensirion_convert_to_string_in_place(raw_product_name, 16); - if (strncmp(product_name, "SEN50", 5) == 0) { - this->type_ = Sen5xType::SEN50; - } else if (strncmp(product_name, "SEN54", 5) == 0) { - this->type_ = Sen5xType::SEN54; - } else if (strncmp(product_name, "SEN55", 5) == 0) { - this->type_ = Sen5xType::SEN55; - } else { + + if (this->model_override_.has_value()) { + if (detected_type != this->model_override_.value()) { + ESP_LOGW(TAG, "Detected %s, using %s", LOG_STR_ARG(type_to_string(detected_type)), + LOG_STR_ARG(type_to_string(this->model_override_.value()))); + } + this->type_ = this->model_override_.value(); + } else if (detected_type == Sen5xType::UNKNOWN) { this->type_ = Sen5xType::UNKNOWN; - ESP_LOGE(TAG, "Unknown product name: %.32s", product_name); this->error_code_ = PRODUCT_NAME_FAILED; this->mark_failed(); return; + } else { + this->type_ = detected_type; } ESP_LOGD(TAG, "Type: %s", LOG_STR_ARG(type_to_string(this->type_))); @@ -255,10 +261,12 @@ void SEN5XComponent::dump_config() { } } ESP_LOGCONFIG(TAG, - " Type: %s\n" + " Type: %s%s\n" " Firmware version: %d\n" " Serial number: %s", - LOG_STR_ARG(type_to_string(this->type_)), this->firmware_version_, this->serial_number_); + LOG_STR_ARG(type_to_string(this->type_)), + this->model_override_.has_value() ? LOG_STR_LITERAL(" (overridden)") : LOG_STR_LITERAL(""), + this->firmware_version_, this->serial_number_); if (this->auto_cleaning_interval_.has_value()) { ESP_LOGCONFIG(TAG, " Auto cleaning interval: %" PRId32 "s", this->auto_cleaning_interval_.value()); } diff --git a/esphome/components/sen5x/sen5x.h b/esphome/components/sen5x/sen5x.h index 6b5a1f8510..ed7689af52 100644 --- a/esphome/components/sen5x/sen5x.h +++ b/esphome/components/sen5x/sen5x.h @@ -95,6 +95,7 @@ class SEN5XComponent final : public PollingComponent, public sensirion_common::S temp_comp.time_constant = time_constant; this->temperature_compensation_ = temp_comp; } + void set_model(Sen5xType model) { this->model_override_ = model; } bool start_fan_cleaning(); protected: @@ -126,6 +127,7 @@ class SEN5XComponent final : public PollingComponent, public sensirion_common::S optional voc_tuning_params_; optional nox_tuning_params_; optional temperature_compensation_; + optional model_override_; ESPPreferenceObject pref_; }; diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index 480654ee1b..3ea526d931 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -12,6 +12,7 @@ from esphome.const import ( CONF_INDEX_OFFSET, CONF_LEARNING_TIME_GAIN_HOURS, CONF_LEARNING_TIME_OFFSET_HOURS, + CONF_MODEL, CONF_NORMALIZED_OFFSET_SLOPE, CONF_NOX, CONF_OFFSET, @@ -39,6 +40,7 @@ from esphome.const import ( UNIT_MICROGRAMS_PER_CUBIC_METER, UNIT_PERCENT, ) +from esphome.types import ConfigType CODEOWNERS = ["@martgras"] DEPENDENCIES = ["i2c"] @@ -49,6 +51,7 @@ SEN5XComponent = sen5x_ns.class_( "SEN5XComponent", cg.PollingComponent, sensirion_common.SensirionI2CDevice ) RhtAccelerationMode = sen5x_ns.enum("RhtAccelerationMode") +Sen5xType = sen5x_ns.enum("Sen5xType", is_class=True) CONF_ACCELERATION_MODE = "acceleration_mode" CONF_AUTO_CLEANING_INTERVAL = "auto_cleaning_interval" @@ -63,6 +66,12 @@ ACCELERATION_MODES = { "high": RhtAccelerationMode.HIGH_ACCELERATION, } +MODELS = { + "SEN50": Sen5xType.SEN50, + "SEN54": Sen5xType.SEN54, + "SEN55": Sen5xType.SEN55, +} + def _gas_sensor( *, @@ -186,6 +195,7 @@ CONFIG_SCHEMA = ( } ), cv.Optional(CONF_ACCELERATION_MODE): cv.enum(ACCELERATION_MODES), + cv.Optional(CONF_MODEL): cv.enum(MODELS, upper=True), } ) .extend(cv.polling_component_schema("60s")) @@ -210,7 +220,7 @@ SETTING_MAP = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -219,6 +229,9 @@ async def to_code(config): if cfg := config.get(key): cg.add(getattr(var, funcName)(cfg)) + if (model := config.get(CONF_MODEL)) is not None: + cg.add(var.set_model(model)) + for key, funcName in SENSOR_MAP.items(): if cfg := config.get(key): sens = await sensor.new_sensor(cfg) diff --git a/tests/components/sen5x/common.yaml b/tests/components/sen5x/common.yaml index a4462a16ea..20f3a1dfd8 100644 --- a/tests/components/sen5x/common.yaml +++ b/tests/components/sen5x/common.yaml @@ -42,4 +42,5 @@ sensor: auto_cleaning_interval: 604800s acceleration_mode: low store_baseline: true + model: sen55 address: 0x69 From 5dfce9b90c27b3252b0812ee869f86ef39d7c937 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 22 Jul 2026 14:41:33 -0400 Subject: [PATCH 1036/1815] [sendspin] Bump sendspin-cpp to v0.7.0 (#17781) --- esphome/components/sendspin/__init__.py | 9 +++------ esphome/components/sendspin/sendspin_hub.cpp | 7 ++++++- esphome/idf_component.yml | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 97e7f4e22c..e20925f323 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -129,12 +129,12 @@ def _request_high_performance_networking(config: ConfigType) -> ConfigType: """ network.require_high_performance_networking() # Socket consumption varies by mode: - # - Server mode: 1 listening socket + 2 client connections (for handoff) + # - Server mode: 1 listening socket + 4 client connections (established connection, unproven connections, and a spare) # - Client mode: 1 outbound connection socket.consume_sockets( 1, "sendspin_websocket_server", socket.SocketType.TCP_LISTEN )(config) - socket.consume_sockets(2, "sendspin_websocket_server")(config) + socket.consume_sockets(4, "sendspin_websocket_server")(config) socket.consume_sockets(1, "sendspin_websocket_client")(config) wifi.enable_runtime_power_save_control() @@ -198,7 +198,7 @@ async def to_code(config: ConfigType) -> None: psram.request_external_task_stack() # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.1") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.0") cg.add_define("USE_SENDSPIN", True) # for MDNS @@ -255,9 +255,6 @@ async def to_code(config: ConfigType) -> None: if psram_stack: psram.request_external_task_stack() - # Library defaults: priority 18 (one above httpd_priority 17 so the decoder is not - # starved by the HTTP server during the initial encoded-audio burst at stream start), - # decode buffer location PREFER_EXTERNAL. player_struct_fields = [ ("audio_formats", audio_format_structs), ("audio_buffer_capacity", player_cfg[CONF_BUFFER_SIZE]), diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index b95d95b2bc..04dbab0080 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -179,7 +179,12 @@ std::optional SendspinHub::load_last_server_hash() { void SendspinHub::send_client_command(sendspin::SendspinControllerCommand command, std::optional volume, std::optional mute) { if (this->is_ready()) { - this->controller_role_->send_command(command, volume, mute); + sendspin::ClientCommandControllerObject obj = { + .command = command, + .volume = volume, + .muted = mute, + }; + this->controller_role_->send_command(obj); } } diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 60b00d33c7..45efd20bf6 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -98,7 +98,7 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.6.1 + version: 0.7.0 lvgl/lvgl: version: 9.5.0 fastled/FastLED: From 312b0c53c273c4d250e9bbd15737423bffa069fe Mon Sep 17 00:00:00 2001 From: Flautz Date: Wed, 22 Jul 2026 22:50:11 +0200 Subject: [PATCH 1037/1815] [mipi_spi] fix partial update bug (#17747) Co-authored-by: clyde <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/mipi_spi/mipi_spi.h | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 701bcd7169..b269f46dc9 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -385,10 +385,10 @@ class MipiSpi : public display::Display, * @param ptr The pointer to the pixel data * @param w Width of each line in bytes * @param h Height of the buffer in rows - * @param pad Padding in bytes after each line + * @param stride Total length of each line in bytes, including any padding */ - void write_display_data_(const uint8_t *ptr, size_t w, size_t h, size_t pad) { - if (pad == 0) { + void write_display_data_(const uint8_t *ptr, size_t w, size_t h, size_t stride) { + if (stride == w) { if constexpr (BUS_TYPE == BUS_TYPE_SINGLE || BUS_TYPE == BUS_TYPE_SINGLE_16) { this->write_array(ptr, w * h); } else if constexpr (BUS_TYPE == BUS_TYPE_QUAD) { @@ -405,7 +405,7 @@ class MipiSpi : public display::Display, } else if constexpr (BUS_TYPE == BUS_TYPE_OCTAL) { this->write_cmd_addr_data(0, 0, 0, 0, ptr, w, 8); } - ptr += w + pad; + ptr += stride; } } } @@ -423,7 +423,7 @@ class MipiSpi : public display::Display, ptr += y_offset * (x_offset + w + x_pad) + x_offset; if constexpr (BUFFERPIXEL == DISPLAYPIXEL) { this->write_display_data_(reinterpret_cast(ptr), w * sizeof(BUFFERTYPE), h, - x_pad * sizeof(BUFFERTYPE)); + (x_offset + w + x_pad) * sizeof(BUFFERTYPE)); } else { // type conversion required, do it in chunks uint8_t dbuffer[DISPLAYPIXEL * 48]; @@ -459,14 +459,14 @@ class MipiSpi : public display::Display, } // buffer full? Flush. if (dptr == dbuffer + sizeof(dbuffer)) { - this->write_display_data_(dbuffer, sizeof(dbuffer), 1, 0); + this->write_display_data_(dbuffer, sizeof(dbuffer), 1, sizeof(dbuffer)); dptr = dbuffer; } } } // flush any remaining data if (dptr != dbuffer) { - this->write_display_data_(dbuffer, dptr - dbuffer, 1, 0); + this->write_display_data_(dbuffer, dptr - dbuffer, 1, dptr - dbuffer); } } this->disable(); From 98c6fa294ae266ca1d370f19e087bfba77803d06 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Jul 2026 13:28:22 -1000 Subject: [PATCH 1038/1815] [bundle] Remap absolute file paths when compiling an extracted bundle (#17765) --- esphome/bundle.py | 101 +++++++- esphome/config_validation.py | 30 ++- tests/unit_tests/test_bundle.py | 270 ++++++++++++++++++++- tests/unit_tests/test_config_validation.py | 77 ++++++ 4 files changed, 469 insertions(+), 9 deletions(-) diff --git a/esphome/bundle.py b/esphome/bundle.py index 88df87c3ba..dcaea03646 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -12,7 +12,7 @@ from enum import StrEnum import io import json import logging -from pathlib import Path +from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath import re import shutil import tarfile @@ -51,6 +51,7 @@ class ManifestKey(StrEnum): MANIFEST_VERSION = "manifest_version" ESPHOME_VERSION = "esphome_version" CONFIG_FILENAME = "config_filename" + CONFIG_DIR = "config_dir" FILES = "files" HAS_SECRETS = "has_secrets" @@ -127,6 +128,12 @@ class BundleData: """Files components asked to include, keyed under DOMAIN in CORE.data.""" extra_files: list[Path] = field(default_factory=list) + # Original config dir parsed from an extracted bundle's manifest.json, + # kept in the path flavor of the machine the bundle was created on. + # The checked flag makes the manifest lookup happen at most once per run; + # CORE.data is cleared between runs. + original_config_dir: PurePath | None = None + original_config_dir_checked: bool = False def _get_data() -> BundleData: @@ -148,6 +155,94 @@ def add_bundle_file(path: Path) -> None: _get_data().extra_files.append(CORE.relative_config_path(path)) +# Windows paths start with a drive letter or contain backslashes; POSIX +# paths do neither in practice, so this is how the flavor of a recorded +# path string is recognized on any host. +_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:") + + +def _path_flavor(value: str) -> type[PurePath]: + """Pick the pure path class matching the flavor ``value`` was written in.""" + if "\\" in value or _WINDOWS_DRIVE_RE.match(value): + return PureWindowsPath + return PurePosixPath + + +def _load_original_config_dir() -> PurePath | None: + """Read the original config dir from an extracted bundle's manifest. + + Returns None when the current config dir is not an extracted bundle or + the manifest does not record the original config dir. + """ + manifest_path = CORE.config_dir / MANIFEST_FILENAME + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except FileNotFoundError: + # The common case: this config dir is not an extracted bundle. + return None + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as err: + # A manifest.json is present but unreadable or malformed. Say so + # instead of letting it look identical to "not a bundle". + _LOGGER.warning("Bundle: ignoring unreadable %s: %s", manifest_path, err) + return None + if not isinstance(manifest, dict): + return None + # A manifest.json in the config dir does not have to be ours. Only trust + # one that looks like a bundle manifest for exactly this config file. + version = manifest.get(ManifestKey.MANIFEST_VERSION) + if not isinstance(version, int) or version < 1: + return None + if manifest.get(ManifestKey.CONFIG_FILENAME) != CORE.config_path.name: + return None + config_dir = manifest.get(ManifestKey.CONFIG_DIR) + if not isinstance(config_dir, str) or not config_dir: + return None + return _path_flavor(config_dir)(config_dir) + + +def remap_bundle_path(value: str) -> Path | None: + """Remap an absolute path from the machine a bundle was created on. + + A bundled config may reference files by absolute path. The referenced + files ship inside the bundle at their config-relative locations, but the + YAML text is copied verbatim, so after extraction on another machine the + absolute reference points at a path that only existed on the creating + machine. The bundle manifest records that machine's config dir; when + ``value`` names a path that lived under it, return the corresponding + file next to the extracted config. + + ``value`` is the raw path string from the config. It is parsed with the + original machine's path flavor, so a bundle created on Windows remaps on + a POSIX build server and vice versa. + + Returns None when not compiling an extracted bundle, when ``value`` was + not under the original config dir, or when the bundle does not contain + the file. + """ + data = _get_data() + if not data.original_config_dir_checked: + data.original_config_dir_checked = True + data.original_config_dir = _load_original_config_dir() + original_dir = data.original_config_dir + if original_dir is None: + return None + path = type(original_dir)(value) + if not path.is_absolute(): + return None + try: + rel = path.relative_to(original_dir) + except ValueError: + return None + # relative_to is lexical, so ".." segments survive it. Refuse them: the + # remapped file must land strictly inside the extracted config tree. + if ".." in rel.parts: + return None + remapped = CORE.relative_config_path(Path(*rel.parts)) + if not remapped.exists(): + return None + return remapped + + @dataclass class BundleFile: """A file to include in the bundle.""" @@ -174,6 +269,7 @@ class BundleManifest: config_filename: str files: list[str] has_secrets: bool + config_dir: str | None = None class ConfigBundleCreator: @@ -438,6 +534,7 @@ class ConfigBundleCreator: ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION, ManifestKey.ESPHOME_VERSION: const.__version__, ManifestKey.CONFIG_FILENAME: self._config_path.name, + ManifestKey.CONFIG_DIR: str(self._config_dir), ManifestKey.FILES: [f.path for f in files], ManifestKey.HAS_SECRETS: has_secrets, } @@ -522,12 +619,14 @@ def read_bundle_manifest(bundle_path: Path) -> BundleManifest: except tarfile.TarError as err: raise EsphomeError(f"Failed to read bundle: {err}") from err + config_dir = manifest.get(ManifestKey.CONFIG_DIR) return BundleManifest( manifest_version=manifest[ManifestKey.MANIFEST_VERSION], esphome_version=manifest.get(ManifestKey.ESPHOME_VERSION, "unknown"), config_filename=manifest[ManifestKey.CONFIG_FILENAME], files=manifest.get(ManifestKey.FILES, []), has_secrets=manifest.get(ManifestKey.HAS_SECRETS, False), + config_dir=config_dir if isinstance(config_dir, str) else None, ) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 3f7c8ff783..713df5452a 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1938,14 +1938,29 @@ def dimensions(value): return dimensions([match.group(1), match.group(2)]) +def _remap_bundle_path(value: str) -> Path | None: + """Resolve a path from the machine an extracted bundle was created on. + + An absolute path in a config compiled from an extracted bundle may point + at the machine the bundle was created on; the bundle ships the file at + its config-relative location instead. + """ + from esphome.bundle import remap_bundle_path + + return remap_bundle_path(value) + + def directory(value: object) -> Path: value = string(value) path = CORE.relative_config_path(value) if not path.exists(): - raise Invalid( - f"Could not find directory '{path}'. Please make sure it exists (full path: {path.resolve()})." - ) + remapped = _remap_bundle_path(value) + if remapped is None: + raise Invalid( + f"Could not find directory '{path}'. Please make sure it exists (full path: {path.resolve()})." + ) + path = remapped if not path.is_dir(): raise Invalid( f"Path '{path}' is not a directory (full path: {path.resolve()})." @@ -1958,9 +1973,12 @@ def file_(value: object) -> Path: path = CORE.relative_config_path(value) if not path.exists(): - raise Invalid( - f"Could not find file '{path}'. Please make sure it exists (full path: {path.resolve()})." - ) + remapped = _remap_bundle_path(value) + if remapped is None: + raise Invalid( + f"Could not find file '{path}'. Please make sure it exists (full path: {path.resolve()})." + ) + path = remapped if not path.is_file(): raise Invalid(f"Path '{path}' is not a file (full path: {path.resolve()}).") return path diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index 6cecb63c2d..f0abcc74c6 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -27,6 +27,7 @@ from esphome.bundle import ( is_bundle_path, prepare_bundle_for_compile, read_bundle_manifest, + remap_bundle_path, ) from esphome.core import CORE, EsphomeError from esphome.yaml_util import force_load_include_files @@ -478,7 +479,10 @@ def test_read_bundle_manifest_corrupted_tar(tmp_path: Path) -> None: def test_read_bundle_manifest(tmp_path: Path) -> None: bundle_path = _make_bundle( tmp_path, - manifest_overrides={ManifestKey.HAS_SECRETS: True}, + manifest_overrides={ + ManifestKey.HAS_SECRETS: True, + ManifestKey.CONFIG_DIR: "/original/config", + }, extra_files={"secrets.yaml": b"wifi: test\n"}, ) @@ -489,6 +493,7 @@ def test_read_bundle_manifest(tmp_path: Path) -> None: assert manifest.esphome_version == "2026.2.0-test" assert manifest.config_filename == "test.yaml" assert manifest.has_secrets is True + assert manifest.config_dir == "/original/config" def test_read_bundle_manifest_minimal(tmp_path: Path) -> None: @@ -508,6 +513,266 @@ def test_read_bundle_manifest_minimal(tmp_path: Path) -> None: assert result.esphome_version == "unknown" assert not result.files assert result.has_secrets is False + assert result.config_dir is None + + +def test_read_bundle_manifest_non_string_config_dir(tmp_path: Path) -> None: + """A malformed config_dir value is dropped rather than propagated.""" + bundle_path = _make_bundle( + tmp_path, manifest_overrides={ManifestKey.CONFIG_DIR: 42} + ) + + assert read_bundle_manifest(bundle_path).config_dir is None + + +# --------------------------------------------------------------------------- +# remap_bundle_path +# --------------------------------------------------------------------------- + + +ORIGINAL_CONFIG_DIR = "/original/config" + + +def _bundle_manifest_dict(**overrides: Any) -> dict[str, Any]: + """Manifest content an extracted bundle would contain.""" + manifest: dict[str, Any] = { + ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION, + ManifestKey.CONFIG_FILENAME: "test.yaml", + ManifestKey.CONFIG_DIR: ORIGINAL_CONFIG_DIR, + } + manifest.update(overrides) + return manifest + + +def _setup_extracted_dir( + tmp_path: Path, + manifest: dict[str, Any] | str | None, + files: dict[str, str] | None = None, +) -> Path: + """Create a directory shaped like an extracted bundle and point CORE at it.""" + extract_dir = _setup_config_dir(tmp_path, files) + if manifest is not None: + content = manifest if isinstance(manifest, str) else json.dumps(manifest) + (extract_dir / MANIFEST_FILENAME).write_text(content) + return extract_dir + + +def test_remap_bundle_path_success(tmp_path: Path) -> None: + """A stale absolute path resolves to the bundled copy next to the config.""" + extract_dir = _setup_extracted_dir( + tmp_path, _bundle_manifest_dict(), files={"boards/partitions.csv": "csv\n"} + ) + + remapped = remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/boards/partitions.csv") + + assert remapped == extract_dir / "boards" / "partitions.csv" + assert remapped.is_file() + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(r"C:\Users\nick\esphome\boards\partitions.csv", id="backslashes"), + pytest.param("C:/Users/nick/esphome/boards/partitions.csv", id="forward"), + pytest.param(r"c:\users\NICK\esphome\boards\partitions.csv", id="case"), + ], +) +def test_remap_bundle_path_windows_bundle_on_posix(tmp_path: Path, value: str) -> None: + """A bundle created on Windows remaps on a build server with another layout.""" + extract_dir = _setup_extracted_dir( + tmp_path, + _bundle_manifest_dict(**{ManifestKey.CONFIG_DIR: r"C:\Users\nick\esphome"}), + files={"boards/partitions.csv": "csv\n"}, + ) + + remapped = remap_bundle_path(value) + + assert remapped == extract_dir / "boards" / "partitions.csv" + assert remapped.is_file() + + +def test_remap_bundle_path_windows_bundle_path_not_under_config_dir( + tmp_path: Path, +) -> None: + """A Windows path outside the original config dir is left alone.""" + _setup_extracted_dir( + tmp_path, + _bundle_manifest_dict(**{ManifestKey.CONFIG_DIR: r"C:\Users\nick\esphome"}), + files={"partitions.csv": "csv\n"}, + ) + + assert remap_bundle_path(r"D:\other\partitions.csv") is None + + +def test_remap_bundle_path_windows_profile_with_spaces(tmp_path: Path) -> None: + r"""A Windows profile like C:\Users\First Last remaps like any other dir.""" + extract_dir = _setup_extracted_dir( + tmp_path, + _bundle_manifest_dict( + **{ManifestKey.CONFIG_DIR: r"C:\Users\First Last\esphome"} + ), + files={"boards/my partitions.csv": "csv\n"}, + ) + + remapped = remap_bundle_path( + r"C:\Users\First Last\esphome\boards\my partitions.csv" + ) + + assert remapped == extract_dir / "boards" / "my partitions.csv" + assert remapped.is_file() + + +def test_remap_bundle_path_unc_config_dir(tmp_path: Path) -> None: + """A bundle created from a UNC share remaps like any other Windows path.""" + extract_dir = _setup_extracted_dir( + tmp_path, + _bundle_manifest_dict(**{ManifestKey.CONFIG_DIR: r"\\server\share\esphome"}), + files={"partitions.csv": "csv\n"}, + ) + + remapped = remap_bundle_path(r"\\server\share\esphome\partitions.csv") + + assert remapped == extract_dir / "partitions.csv" + + +def test_remap_bundle_path_flavor_mismatch(tmp_path: Path) -> None: + """A POSIX style value cannot come from a Windows config dir; no remap.""" + _setup_extracted_dir( + tmp_path, + _bundle_manifest_dict(**{ManifestKey.CONFIG_DIR: r"C:\Users\nick\esphome"}), + files={"partitions.csv": "csv\n"}, + ) + + assert remap_bundle_path("/original/config/partitions.csv") is None + + +def test_remap_bundle_path_rejects_traversal(tmp_path: Path) -> None: + """A remap may never escape the extracted config tree.""" + extract_dir = _setup_extracted_dir(tmp_path, _bundle_manifest_dict()) + (tmp_path / "outside.csv").write_text("csv\n") + assert (extract_dir / ".." / "outside.csv").resolve().is_file() + + assert remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/../outside.csv") is None + + +def test_remap_bundle_path_relative_value(tmp_path: Path) -> None: + """Relative references resolve normally and are never remapped.""" + _setup_extracted_dir(tmp_path, _bundle_manifest_dict()) + + assert remap_bundle_path("missing.csv") is None + + +def test_remap_bundle_path_no_manifest(tmp_path: Path) -> None: + """A config dir without a manifest is not an extracted bundle.""" + _setup_extracted_dir(tmp_path, None, files={"partitions.csv": "csv\n"}) + + assert remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/partitions.csv") is None + + +@pytest.mark.parametrize( + "manifest", + [ + pytest.param("{not json", id="malformed_json"), + pytest.param("[]", id="not_a_dict"), + pytest.param( + _bundle_manifest_dict(**{ManifestKey.MANIFEST_VERSION: "x"}), + id="version_not_int", + ), + pytest.param( + _bundle_manifest_dict(**{ManifestKey.MANIFEST_VERSION: 0}), + id="version_zero", + ), + pytest.param( + _bundle_manifest_dict(**{ManifestKey.CONFIG_FILENAME: "other.yaml"}), + id="config_filename_mismatch", + ), + pytest.param( + { + ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION, + ManifestKey.CONFIG_FILENAME: "test.yaml", + }, + id="config_dir_missing", + ), + pytest.param( + _bundle_manifest_dict(**{ManifestKey.CONFIG_DIR: ""}), + id="config_dir_empty", + ), + ], +) +def test_remap_bundle_path_untrusted_manifest( + tmp_path: Path, manifest: dict[str, Any] | str +) -> None: + """Manifests that do not look like this bundle's manifest are ignored.""" + _setup_extracted_dir(tmp_path, manifest, files={"partitions.csv": "csv\n"}) + + assert remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/partitions.csv") is None + + +def test_remap_bundle_path_unreadable_manifest_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A present but broken manifest is reported, not silently ignored.""" + _setup_extracted_dir(tmp_path, "{not json", files={"partitions.csv": "csv\n"}) + + assert remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/partitions.csv") is None + assert "ignoring unreadable" in caplog.text + + +def test_remap_bundle_path_outside_original_config_dir(tmp_path: Path) -> None: + """Paths that were not under the original config dir are left alone.""" + _setup_extracted_dir(tmp_path, _bundle_manifest_dict()) + + assert remap_bundle_path("/elsewhere/partitions.csv") is None + + +def test_remap_bundle_path_bundled_copy_missing(tmp_path: Path) -> None: + """No remap when the bundle does not contain the file.""" + _setup_extracted_dir(tmp_path, _bundle_manifest_dict()) + + assert remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/partitions.csv") is None + + +def test_remap_bundle_path_manifest_read_once(tmp_path: Path) -> None: + """The manifest lookup result is cached for the rest of the run.""" + extract_dir = _setup_extracted_dir( + tmp_path, _bundle_manifest_dict(), files={"partitions.csv": "csv\n"} + ) + + first = remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/partitions.csv") + assert first == extract_dir / "partitions.csv" + + (extract_dir / MANIFEST_FILENAME).unlink() + second = remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/partitions.csv") + assert second == first + + +def test_remap_bundle_path_round_trip(tmp_path: Path) -> None: + """A file referenced by absolute path survives bundle create and extract. + + Reproduces https://github.com/esphome/esphome/issues/17755: the config + names its partitions csv by absolute path, the bundle is extracted on a + machine where that path does not exist, and the reference must resolve + to the bundled copy. + """ + config_dir = _setup_config_dir(tmp_path, files={"partitions.csv": "nvs,data\n"}) + abs_path = (config_dir / "partitions.csv").resolve() + + creator = ConfigBundleCreator({"esp32": {"partitions": abs_path}}) + result = creator.create_bundle() + + bundle_path = tmp_path / f"device{BUNDLE_EXTENSION}" + bundle_path.write_bytes(result.data) + target = tmp_path / "build_server" + config_path = extract_bundle(bundle_path, target) + + # Simulate the build server: fresh run, original config dir gone + CORE.reset() + CORE.config_path = config_path + shutil.rmtree(config_dir) + + remapped = remap_bundle_path(str(abs_path)) + assert remapped == target.resolve() / "partitions.csv" + assert remapped.is_file() # --------------------------------------------------------------------------- @@ -1261,7 +1526,7 @@ def test_create_bundle_produces_valid_archive(tmp_path: Path) -> None: def test_create_bundle_manifest_content(tmp_path: Path) -> None: - _setup_config_dir(tmp_path) + config_dir = _setup_config_dir(tmp_path) creator = ConfigBundleCreator({}) result = creator.create_bundle() @@ -1269,6 +1534,7 @@ def test_create_bundle_manifest_content(tmp_path: Path) -> None: manifest = result.manifest assert manifest[ManifestKey.MANIFEST_VERSION] == CURRENT_MANIFEST_VERSION assert manifest[ManifestKey.CONFIG_FILENAME] == "test.yaml" + assert manifest[ManifestKey.CONFIG_DIR] == str(config_dir.resolve()) assert "test.yaml" in manifest[ManifestKey.FILES] diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index fd21ac92ea..1da3d5593a 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1,3 +1,4 @@ +import json from pathlib import Path import string @@ -2912,3 +2913,79 @@ def test_rename_key_present() -> None: def test_rename_key_absent() -> None: assert cv.rename_key("old", "new")({"other": 5}) == {"other": 5} + + +def test_file__existing_relative_path(setup_core: Path) -> None: + (setup_core / "partitions.csv").write_text("csv\n") + + assert cv.file_("partitions.csv") == setup_core / "partitions.csv" + + +def test_file__missing_raises(setup_core: Path) -> None: + with pytest.raises(Invalid, match="Could not find file"): + cv.file_("partitions.csv") + + +def test_file__remaps_bundle_absolute_path(setup_core: Path) -> None: + """A stale absolute path in an extracted bundle resolves to the bundled copy.""" + manifest = { + "manifest_version": 1, + "config_filename": "test.yaml", + "config_dir": "/original/config", + } + (setup_core / "manifest.json").write_text(json.dumps(manifest)) + (setup_core / "partitions.csv").write_text("csv\n") + + assert cv.file_("/original/config/partitions.csv") == setup_core / "partitions.csv" + + +def test_file__missing_absolute_path_without_bundle(setup_core: Path) -> None: + with pytest.raises(Invalid, match="Could not find file"): + cv.file_("/original/config/partitions.csv") + + +def test_file__remaps_windows_bundle_absolute_path(setup_core: Path) -> None: + """A bundle created on Windows resolves on a host with another layout.""" + manifest = { + "manifest_version": 1, + "config_filename": "test.yaml", + "config_dir": "C:\\Users\\nick\\esphome", + } + (setup_core / "manifest.json").write_text(json.dumps(manifest)) + (setup_core / "partitions.csv").write_text("csv\n") + + result = cv.file_("C:\\Users\\nick\\esphome\\partitions.csv") + + assert result == setup_core / "partitions.csv" + + +def test_directory_remaps_bundle_absolute_path(setup_core: Path) -> None: + """A stale absolute directory in an extracted bundle resolves to the bundled copy.""" + manifest = { + "manifest_version": 1, + "config_filename": "test.yaml", + "config_dir": "/original/config", + } + (setup_core / "manifest.json").write_text(json.dumps(manifest)) + (setup_core / "headers").mkdir() + + assert cv.directory("/original/config/headers") == setup_core / "headers" + + +def test_directory_missing_raises(setup_core: Path) -> None: + with pytest.raises(Invalid, match="Could not find directory"): + cv.directory("/original/config/headers") + + +def test_file__remapped_path_is_directory_raises(setup_core: Path) -> None: + """A remapped path that is a directory still fails file validation.""" + manifest = { + "manifest_version": 1, + "config_filename": "test.yaml", + "config_dir": "/original/config", + } + (setup_core / "manifest.json").write_text(json.dumps(manifest)) + (setup_core / "headers").mkdir() + + with pytest.raises(Invalid, match="is not a file"): + cv.file_("/original/config/headers") From 4817904637b6545a0307e7e8fda784759e8f0e26 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:49:38 -1000 Subject: [PATCH 1039/1815] Bump bundled esphome-device-builder to 1.6.9 (#17793) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 1a34fda520..8649dbcd77 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.6.8 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.9 RUN \ platformio settings set enable_telemetry No \ From deba7a1f0c872a1dacfd1b5a786c583eb0cd8083 Mon Sep 17 00:00:00 2001 From: rwalker777 <49888088+rwalker777@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:55:04 -0500 Subject: [PATCH 1040/1815] [ethernet][network][wifi] Add network priority for multi-interface support (#14255) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: kbx81 Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: J. Nick Koston --- esphome/codegen.py | 1 + esphome/components/ethernet/__init__.py | 37 +++- esphome/components/network/__init__.py | 163 +++++++++++++- esphome/components/network/util.cpp | 22 +- esphome/components/network/util.h | 3 +- esphome/components/wifi/__init__.py | 5 + esphome/core/defines.h | 1 + esphome/cpp_helpers.py | 14 +- .../network_wifi_ethernet_priority.yaml | 26 +++ tests/component_tests/esp32/test_esp32.py | 17 ++ tests/component_tests/ethernet/__init__.py | 0 .../component_tests/ethernet/test_ethernet.py | 37 ++++ tests/component_tests/network/__init__.py | 0 .../config/priority_ethernet_first.yaml | 26 +++ .../network/config/priority_wifi_first.yaml | 26 +++ .../network/config/wifi_only.yaml | 11 + .../component_tests/network/test_priority.py | 201 ++++++++++++++++++ .../network/test-priority.esp32-idf.yaml | 23 ++ 18 files changed, 603 insertions(+), 10 deletions(-) create mode 100644 tests/component_tests/esp32/config/network_wifi_ethernet_priority.yaml create mode 100644 tests/component_tests/ethernet/__init__.py create mode 100644 tests/component_tests/ethernet/test_ethernet.py create mode 100644 tests/component_tests/network/__init__.py create mode 100644 tests/component_tests/network/config/priority_ethernet_first.yaml create mode 100644 tests/component_tests/network/config/priority_wifi_first.yaml create mode 100644 tests/component_tests/network/config/wifi_only.yaml create mode 100644 tests/component_tests/network/test_priority.py create mode 100644 tests/components/network/test-priority.esp32-idf.yaml diff --git a/esphome/codegen.py b/esphome/codegen.py index 56a47d146e..0694eb4d84 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -53,6 +53,7 @@ from esphome.cpp_helpers import ( # noqa: F401 past_safe_mode, register_component, register_parented, + set_setup_priority, ) from esphome.cpp_types import ( # noqa: F401 NAN, diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 03fba7164d..ad63c0d13d 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -4,7 +4,12 @@ import logging from esphome import automation, pins from esphome.automation import Condition import esphome.codegen as cg -from esphome.components.network import add_use_address, ip_address_literal +from esphome.components.network import ( + add_use_address, + get_network_priority, + get_priority_interfaces_from_full_config, + ip_address_literal, +) from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -50,7 +55,6 @@ from esphome.core import ( import esphome.final_validate as fv from esphome.types import ConfigType -CONFLICTS_WITH = ["wifi"] AUTO_LOAD = ["network"] LOGGER = logging.getLogger(__name__) @@ -535,6 +539,14 @@ def phy_register(address: int, value: int, page: int): @coroutine_with_priority(CoroPriority.COMMUNICATION) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) + + # Apply network priority before register_component (which emits the user's + # explicit setup_priority: if set) so that, as in wifi, an explicit + # setup_priority: still wins over the network-priority-derived value. + prio = get_network_priority("ethernet") + if prio is not None: + cg.set_setup_priority(var, prio) + await cg.register_component(var, config) if CORE.is_esp32: @@ -644,8 +656,9 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: ) cg.add(var.add_phy_register(reg)) - # Register Ethernet with the esp32 sdkconfig reconciler, which disables the - # WiFi stack and WiFi/BT coexistence when Ethernet is used without WiFi. + # Register Ethernet with the esp32 sdkconfig reconciler. It disables the + # WiFi stack and WiFi/BT coexistence only when Ethernet runs without WiFi, + # so multi-interface configs (network: priority: with both) keep WiFi. request_ethernet() # Re-enable ESP-IDF's Ethernet driver (excluded by default to save compile time) @@ -732,6 +745,22 @@ def _final_validate_rmii_pins(config: ConfigType) -> None: def _final_validate(config: ConfigType) -> ConfigType: """Final validation for Ethernet component.""" + # Allow ethernet + wifi coexistence only when both are declared in network: priority:. + if "wifi" in fv.full_config.get(): + priority_ifaces = get_priority_interfaces_from_full_config(fv.full_config.get()) + missing = [i for i in ("ethernet", "wifi") if i not in priority_ifaces] + if missing and priority_ifaces: + # A priority list exists but is incomplete: point at what to add. + raise cv.Invalid( + "When ethernet and wifi are used together, 'network: priority:' must " + f"list both interfaces; missing: {', '.join(missing)}" + ) + if missing: + raise cv.Invalid( + "Component ethernet cannot be used together with component wifi " + "unless both are listed under 'network: priority:'" + ) + _final_validate_spi(config) _final_validate_rmii_pins(config) return config diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 0f4bcb3e16..24e9aa45e1 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -1,13 +1,20 @@ import ipaddress import logging +from typing import Any import esphome.codegen as cg from esphome.components.esp32 import add_idf_sdkconfig_option from esphome.components.psram import is_guaranteed as psram_is_guaranteed from esphome.components.zephyr import zephyr_add_prj_conf import esphome.config_validation as cv -from esphome.const import CONF_ENABLE_IPV6, CONF_ID, CONF_MIN_IPV6_ADDR_COUNT +from esphome.const import ( + CONF_ENABLE_IPV6, + CONF_ID, + CONF_MIN_IPV6_ADDR_COUNT, + CONF_PRIORITY, +) from esphome.core import CORE, CoroPriority, coroutine_with_priority +import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] @@ -20,6 +27,48 @@ _LOGGER = logging.getLogger(__name__) KEY_HIGH_PERFORMANCE_NETWORKING = "high_performance_networking" CONF_ENABLE_HIGH_PERFORMANCE = "enable_high_performance" +# Network priority tracking infrastructure +# Components can query this to determine their relative setup priority. +# CORE.data[KEY_NETWORK_PRIORITY] is a list of dicts of the form +# {"interface": "ethernet"}, in user-declared order. +KEY_NETWORK_PRIORITY = "network_priority" + +# Only interfaces whose component already calls get_network_priority() are +# accepted in the priority list. openthread and modem will be added here when +# they wire up their setup-priority consumer in their own to_code — see +# NETWORK_PLAN.md for the full multi-interface roadmap. +VALID_NETWORK_TYPES = ["ethernet", "wifi"] + +# Setup priority base values — first in list gets the highest priority. +# +# The base equals the historical setup_priority::WIFI / ::ETHERNET default +# (250.0), so a single-entry priority list yields exactly the same setup order +# as a config with no priority block. Subsequent entries step down by a small +# amount to break ties without crossing other priority bands. +# +# Important: must stay strictly less than setup_priority::AFTER_BLUETOOTH +# (300.0), which NetworkComponent itself uses — otherwise the highest-priority +# interface could tie with NetworkComponent and run before esp_netif_init(). +NETWORK_PRIORITY_BASE = 250.0 +NETWORK_PRIORITY_STEP = 5.0 + +# Lower-bound guard. The lowest-priority entry gets +# NETWORK_PRIORITY_BASE - (len - 1) * NETWORK_PRIORITY_STEP, which must stay +# strictly above setup_priority::AFTER_WIFI (200.0, see esphome/core/component.h) +# so a long priority list never drops an interface into the band used by +# components that expect to run after the network is up. There is ample headroom +# for the two types today; this check raises if a future expansion of +# VALID_NETWORK_TYPES would silently cross that band. Uses an explicit raise +# rather than a bare assert so the guard isn't stripped under python -O/-OO. +_SETUP_PRIORITY_AFTER_WIFI = 200.0 +if ( + NETWORK_PRIORITY_BASE - (len(VALID_NETWORK_TYPES) - 1) * NETWORK_PRIORITY_STEP + <= _SETUP_PRIORITY_AFTER_WIFI +): + raise ValueError( + "network: priority: list is long enough to cross setup_priority::AFTER_WIFI" + ) + network_ns = cg.esphome_ns.namespace("network") NetworkComponent = network_ns.class_("NetworkComponent", cg.Component) IPAddress = network_ns.class_("IPAddress") @@ -142,6 +191,83 @@ def validate_ipv6(value: bool) -> bool: return value +def get_network_priority(iface: str) -> float | None: + """Get the setup priority for the given network interface type. + + Returns the float setup priority for ``iface`` based on the order declared + under ``network: priority:``. Interfaces listed first receive a higher + setup priority so they are initialised before lower-priority ones. + + If no ``network: priority:`` has been configured this returns ``None`` and + the calling component should fall back to its own default setup priority. + + Args: + iface: Interface type string (case-insensitive). Currently ``"ethernet"`` + or ``"wifi"`` — the only types the priority-list validator + accepts; ``"openthread"`` / ``"modem"`` are planned but not yet + supported. An interface not present in the configured list + returns ``None``. + + Returns: + float setup priority, or None if no priority list was configured. + + Example usage inside a component's ``to_code``. Emit the override before + ``register_component`` so an explicit ``setup_priority:`` on the component + still wins:: + + from esphome.components import network + + async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + + prio = network.get_network_priority("ethernet") + if prio is not None: + cg.set_setup_priority(var, prio) + + await cg.register_component(var, config) + ... + """ + priority_list = CORE.data.get(KEY_NETWORK_PRIORITY) + if priority_list is None: + return None + iface_lower = iface.lower() + for idx, entry in enumerate(priority_list): + if entry["interface"] == iface_lower: + return NETWORK_PRIORITY_BASE - (idx * NETWORK_PRIORITY_STEP) + return None + + +def get_priority_interfaces_from_full_config(full_config: ConfigType) -> set[str]: + """Return the set of interface names declared in ``network: priority:``. + + Reads from the full validated config (``fv.full_config.get()``) and is + intended for use inside ``FINAL_VALIDATE_SCHEMA`` hooks, before + ``to_code`` has run and ``CORE.data`` has been populated. Returns an + empty set if no priority list was configured. + """ + return { + entry["interface"] + for entry in full_config.get("network", {}).get(CONF_PRIORITY, []) + } + + +def _validate_priority_list(value: Any) -> list[dict[str, str]]: + """Validate and normalize the priority list, rejecting duplicates. + + Each entry is the name of one network interface (one of + ``VALID_NETWORK_TYPES``). Mixed-case input is accepted and normalized + to lowercase. The normalized list is a list of dicts of the form + ``{"interface": "ethernet"}`` so that future per-entry options can be + added without breaking call sites. + """ + raw = cv.ensure_list(cv.one_of(*VALID_NETWORK_TYPES, lower=True))(value) + entries = [{"interface": iface} for iface in raw] + interfaces = [e["interface"] for e in entries] + if len(interfaces) != len(set(interfaces)): + raise cv.Invalid("Duplicate entries are not allowed in 'priority'") + return entries + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -174,17 +300,52 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ENABLE_HIGH_PERFORMANCE): cv.All( cv.boolean, cv.only_on_esp32 ), + cv.Optional(CONF_PRIORITY): _validate_priority_list, } ), _register_provisioning_source, ) +def _final_validate(config: ConfigType) -> None: + """Check that every interface named in 'priority' has a corresponding component block.""" + full = fv.full_config.get() + for entry in config.get(CONF_PRIORITY, []): + iface = entry["interface"] + if iface not in full: + raise cv.Invalid( + f"'{iface}' is listed in 'network: priority:' but no '{iface}:' " + f"component is configured", + [CONF_PRIORITY], + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + @coroutine_with_priority(CoroPriority.NETWORK) async def to_code(config): cg.add_define("USE_NETWORK") # ESP32 with Arduino uses ESP-IDF network APIs directly, no Arduino Network library needed + # Store the user-declared network priority list in CORE.data so that ethernet, + # wifi and other network components can query it via get_network_priority() + # during their own to_code phase. + if CONF_PRIORITY in config: + priority_list = config[CONF_PRIORITY] + CORE.data[KEY_NETWORK_PRIORITY] = priority_list + # network/util.cpp resolves the reported address (get_use_address_to, + # get_ip_addresses) in a fixed ethernet-first order; a wifi-first priority + # list is the only case that deviates from it, so it is the only case that + # needs a define. Runtime (active-interface) selection is a planned follow-up. + if priority_list[0]["interface"] == "wifi": + cg.add_define("USE_NETWORK_PRIMARY_INTERFACE_WIFI") + + _LOGGER.info( + "Network interface priority: %s", + " > ".join(entry["interface"] for entry in priority_list), + ) + # Apply high performance networking settings # Config can explicitly enable/disable, or default to component-driven behavior enable_high_perf = config.get(CONF_ENABLE_HIGH_PERFORMANCE) diff --git a/esphome/components/network/util.cpp b/esphome/components/network/util.cpp index ae250c6a1f..d90c28801e 100644 --- a/esphome/components/network/util.cpp +++ b/esphome/components/network/util.cpp @@ -23,9 +23,14 @@ bool is_disabled() { } const char *get_use_address_to(std::span buf) { - // Global component pointers are guaranteed to be set by component constructors when USE_* is defined + // Global component pointers are guaranteed to be set by component constructors when USE_* is defined. + // A wifi-first network: priority: list sets USE_NETWORK_PRIMARY_INTERFACE_WIFI to lift + // wifi ahead of the fixed ethernet-first order below; an ethernet-first list already + // matches that order, so no define exists for it. const char *addr = nullptr; -#if defined(USE_ETHERNET) +#if defined(USE_NETWORK_PRIMARY_INTERFACE_WIFI) && defined(USE_WIFI) + addr = wifi::global_wifi_component->get_use_address(); +#elif defined(USE_ETHERNET) addr = ethernet::global_eth_component->get_use_address(); #elif defined(USE_MODEM) addr = modem::global_modem_component->get_use_address(); @@ -44,6 +49,19 @@ const char *get_use_address_to(std::span buf) { } network::IPAddresses get_ip_addresses() { + // With a wifi-first network: priority: list, prefer wifi while it has a valid IP; + // otherwise fall through to the fixed ethernet-first order below. Selection based + // on the runtime-active interface is a planned follow-up. +#if defined(USE_NETWORK_PRIMARY_INTERFACE_WIFI) && defined(USE_WIFI) + if (wifi::global_wifi_component != nullptr) { + auto ips = wifi::global_wifi_component->get_ip_addresses(); + for (const auto &ip : ips) { + if (ip.is_set()) + return ips; + } + } +#endif + #ifdef USE_ETHERNET if (ethernet::global_eth_component != nullptr) return ethernet::global_eth_component->get_ip_addresses(); diff --git a/esphome/components/network/util.h b/esphome/components/network/util.h index 17a2ff0977..df7e164bda 100644 --- a/esphome/components/network/util.h +++ b/esphome/components/network/util.h @@ -57,7 +57,8 @@ bool is_disabled(); /// Buffer size for get_use_address_to(): 63-char DNS label + ".local" + null terminator static constexpr size_t USE_ADDRESS_BUFFER_SIZE = 70; /// Get the active network address for logging. Returns the explicitly configured -/// use_address when one was set, otherwise formats ".local" from the runtime +/// use_address when one was set (from the highest-priority interface when +/// network: priority: is configured), otherwise formats ".local" from the runtime /// device name into buf (so it includes the MAC suffix from name_add_mac_suffix). const char *get_use_address_to(std::span buf); IPAddresses get_ip_addresses(); diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 137304c807..1810a62155 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -15,6 +15,7 @@ from esphome.components.esp32 import ( ) from esphome.components.network import ( add_use_address, + get_network_priority, has_high_performance_networking, ip_address_literal, ) @@ -602,6 +603,10 @@ def wifi_network(config, ap, static_ip): @coroutine_with_priority(CoroPriority.COMMUNICATION) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) + + prio = get_network_priority("wifi") + if prio is not None: + cg.set_setup_priority(var, prio) add_use_address(var, config[CONF_USE_ADDRESS]) # Track if any network uses Enterprise authentication diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 25f87b90f1..ca1d22bf3e 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -138,6 +138,7 @@ #define USE_MEDIA_PLAYER #define USE_MEDIA_SOURCE #define USE_NETWORK +#define USE_NETWORK_PRIMARY_INTERFACE_WIFI #define USE_NEXTION_COMMAND_SPACING #define USE_NEXTION_CONF_START_UP_PAGE #define USE_NEXTION_CONFIG_EXIT_REPARSE_ON_START diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index b035e28a7a..b2338e5bc1 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -151,6 +151,17 @@ async def gpio_pin_expression(conf): return await coroutine(pins.PIN_SCHEMA_REGISTRY[CORE.target_platform][0])(conf) +def set_setup_priority(var, priority: float) -> None: + """Emit a setup-priority override for the given component. + + Pairs the ``set_setup_priority()`` call with the ``USE_SETUP_PRIORITY_OVERRIDE`` + define that compiles in the core override support, so callers cannot emit one + without the other. + """ + add_define("USE_SETUP_PRIORITY_OVERRIDE") + add(var.set_setup_priority(priority)) + + async def register_component(var, config): """Register the given obj as a component. @@ -168,8 +179,7 @@ async def register_component(var, config): ) CORE.component_ids.remove(id_) if CONF_SETUP_PRIORITY in config: - add_define("USE_SETUP_PRIORITY_OVERRIDE") - add(var.set_setup_priority(config[CONF_SETUP_PRIORITY])) + set_setup_priority(var, config[CONF_SETUP_PRIORITY]) if CONF_UPDATE_INTERVAL in config: add(var.set_update_interval(config[CONF_UPDATE_INTERVAL])) diff --git a/tests/component_tests/esp32/config/network_wifi_ethernet_priority.yaml b/tests/component_tests/esp32/config/network_wifi_ethernet_priority.yaml new file mode 100644 index 0000000000..d98d19f545 --- /dev/null +++ b/tests/component_tests/esp32/config/network_wifi_ethernet_priority.yaml @@ -0,0 +1,26 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +ethernet: + type: W5500 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + clock_speed: 10Mhz + +network: + priority: + - ethernet + - wifi diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 8a116ccc27..4d18bbf6e4 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -601,6 +601,23 @@ def test_network_wifi_ble_coexistence_reconciles_end_to_end( assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig +def test_network_wifi_ethernet_priority_keeps_wifi_enabled( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """End-to-end: with both WiFi and Ethernet declared under network: priority:, + the reconciler must NOT disable the WiFi stack or coexistence (the + multi-interface case unlocked by composing network priority with the + sdkconfig reconciler).""" + generate_main(component_config_path("network_wifi_ethernet_priority.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig + assert "CONFIG_SW_COEXIST_ENABLE" not in sdkconfig + # WiFi has no AP here, so SoftAP/DHCP server are still dropped. + assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False + assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False + + def test_esp32_build_internals_are_yaml_only() -> None: """ESP32 raw framework / build inputs are ``YAML_ONLY``. diff --git a/tests/component_tests/ethernet/__init__.py b/tests/component_tests/ethernet/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/ethernet/test_ethernet.py b/tests/component_tests/ethernet/test_ethernet.py new file mode 100644 index 0000000000..b3d37561c7 --- /dev/null +++ b/tests/component_tests/ethernet/test_ethernet.py @@ -0,0 +1,37 @@ +"""Tests for the ethernet final-validation coexistence gate.""" + +import pytest +from voluptuous import Invalid + +from esphome.components.ethernet import _final_validate +from esphome.components.network import _validate_priority_list +from esphome.const import CONF_PRIORITY +import esphome.final_validate as fv + + +@pytest.fixture(autouse=True) +def _reset_full_config(): + """Reset fv.full_config so each test starts with a clean slate.""" + token = fv.full_config.set({}) + yield + fv.full_config.reset(token) + + +def test_rejects_wifi_and_ethernet_without_priority() -> None: + """Wi-Fi + ethernet without a network: priority: list must be rejected.""" + fv.full_config.set({"wifi": {}, "ethernet": {}}) + with pytest.raises(Invalid, match="cannot be used together with component wifi"): + _final_validate({}) + + +def test_rejects_wifi_and_ethernet_with_incomplete_priority() -> None: + """A priority list missing an interface is rejected and names what's missing.""" + fv.full_config.set( + { + "wifi": {}, + "ethernet": {}, + "network": {CONF_PRIORITY: _validate_priority_list(["ethernet"])}, + } + ) + with pytest.raises(Invalid, match=r"must.*list both interfaces; missing: wifi"): + _final_validate({}) diff --git a/tests/component_tests/network/__init__.py b/tests/component_tests/network/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/network/config/priority_ethernet_first.yaml b/tests/component_tests/network/config/priority_ethernet_first.yaml new file mode 100644 index 0000000000..d98d19f545 --- /dev/null +++ b/tests/component_tests/network/config/priority_ethernet_first.yaml @@ -0,0 +1,26 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +ethernet: + type: W5500 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + clock_speed: 10Mhz + +network: + priority: + - ethernet + - wifi diff --git a/tests/component_tests/network/config/priority_wifi_first.yaml b/tests/component_tests/network/config/priority_wifi_first.yaml new file mode 100644 index 0000000000..65247f005f --- /dev/null +++ b/tests/component_tests/network/config/priority_wifi_first.yaml @@ -0,0 +1,26 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +ethernet: + type: W5500 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + clock_speed: 10Mhz + +network: + priority: + - wifi + - ethernet diff --git a/tests/component_tests/network/config/wifi_only.yaml b/tests/component_tests/network/config/wifi_only.yaml new file mode 100644 index 0000000000..61dfde3e03 --- /dev/null +++ b/tests/component_tests/network/config/wifi_only.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" diff --git a/tests/component_tests/network/test_priority.py b/tests/component_tests/network/test_priority.py new file mode 100644 index 0000000000..da1c0a061d --- /dev/null +++ b/tests/component_tests/network/test_priority.py @@ -0,0 +1,201 @@ +"""Tests for the ``network: priority:`` list validator.""" + +from collections.abc import Callable +from pathlib import Path +import re + +import pytest +from voluptuous import Invalid + +from esphome.components.network import ( + _SETUP_PRIORITY_AFTER_WIFI, + KEY_NETWORK_PRIORITY, + NETWORK_PRIORITY_BASE, + NETWORK_PRIORITY_STEP, + _final_validate, + _validate_priority_list, + get_network_priority, +) +from esphome.const import CONF_PRIORITY +from esphome.core import CORE +import esphome.final_validate as fv + + +@pytest.fixture(autouse=True) +def _clear_core_data(): + """Wipe CORE.data and reset fv.full_config so each test starts clean.""" + CORE.data.clear() + token = fv.full_config.set({}) + yield + fv.full_config.reset(token) + CORE.data.clear() + + +def test_validates_plain_string_list() -> None: + result = _validate_priority_list(["ethernet", "wifi"]) + assert result == [{"interface": "ethernet"}, {"interface": "wifi"}] + + +def test_normalizes_mixed_case_to_lowercase() -> None: + # Regression check: mixed-case input must be lowercased so downstream + # callers like get_network_priority("ethernet") find a match. + result = _validate_priority_list(["Ethernet", "WIFI"]) + assert result == [{"interface": "ethernet"}, {"interface": "wifi"}] + + +def test_accepts_all_supported_interface_types() -> None: + # Only ethernet and wifi are currently accepted. Other interface types + # (openthread, modem) will be added when their setup-priority consumers + # land — see NETWORK_PLAN.md. + result = _validate_priority_list(["ethernet", "wifi"]) + assert [e["interface"] for e in result] == ["ethernet", "wifi"] + + +def test_rejects_not_yet_supported_interface() -> None: + # openthread / modem are in the long-term roadmap but no setup-priority + # consumer is wired yet, so VALID_NETWORK_TYPES excludes them today. + with pytest.raises(Invalid): + _validate_priority_list(["ethernet", "openthread"]) + with pytest.raises(Invalid): + _validate_priority_list(["wifi", "modem"]) + + +def test_single_interface_is_valid() -> None: + result = _validate_priority_list(["ethernet"]) + assert result == [{"interface": "ethernet"}] + + +def test_rejects_unknown_interface() -> None: + with pytest.raises(Invalid): + _validate_priority_list(["ethernet", "bluetooth"]) + + +def test_rejects_duplicate_entries() -> None: + with pytest.raises(Invalid, match="Duplicate entries"): + _validate_priority_list(["ethernet", "ethernet"]) + + +def test_rejects_duplicates_regardless_of_case() -> None: + # Same interface in mixed cases should still trip the duplicate check + # after normalization. + with pytest.raises(Invalid, match="Duplicate entries"): + _validate_priority_list(["ethernet", "Ethernet"]) + + +def test_rejects_mapping_form() -> None: + # The mapping form (- ethernet: { timeout: 30s }) was removed when the + # timeout option moved to its consumer PR. Verify we reject it cleanly + # instead of silently accepting a no-op. + with pytest.raises(Invalid): + _validate_priority_list([{"ethernet": {"timeout": "30s"}}]) + + +def test_get_network_priority_returns_none_when_unset() -> None: + assert get_network_priority("ethernet") is None + + +def test_get_network_priority_assigns_base_to_first_entry() -> None: + CORE.data[KEY_NETWORK_PRIORITY] = _validate_priority_list(["ethernet", "wifi"]) + assert get_network_priority("ethernet") == NETWORK_PRIORITY_BASE + + +def test_get_network_priority_steps_down_by_step_per_position() -> None: + CORE.data[KEY_NETWORK_PRIORITY] = _validate_priority_list(["ethernet", "wifi"]) + assert get_network_priority("wifi") == NETWORK_PRIORITY_BASE - NETWORK_PRIORITY_STEP + + +def test_get_network_priority_is_case_insensitive_on_query() -> None: + CORE.data[KEY_NETWORK_PRIORITY] = _validate_priority_list(["ethernet"]) + assert get_network_priority("Ethernet") == NETWORK_PRIORITY_BASE + + +def test_get_network_priority_returns_none_for_unlisted_interface() -> None: + CORE.data[KEY_NETWORK_PRIORITY] = _validate_priority_list(["ethernet"]) + assert get_network_priority("wifi") is None + + +def test_final_validate_rejects_priority_iface_without_component() -> None: + """An interface named in 'priority' with no matching component block is rejected.""" + # priority lists wifi, but only ethernet is present in the full config. + fv.full_config.set({"ethernet": {}}) + config = {CONF_PRIORITY: _validate_priority_list(["ethernet", "wifi"])} + with pytest.raises( + Invalid, match=r"'wifi' is listed in 'network: priority:' but no 'wifi:'" + ): + _final_validate(config) + + +def test_final_validate_accepts_when_all_priority_ifaces_present() -> None: + """No error when every interface in 'priority' has a matching component block.""" + fv.full_config.set({"ethernet": {}, "wifi": {}}) + config = {CONF_PRIORITY: _validate_priority_list(["ethernet", "wifi"])} + _final_validate(config) # must not raise + + +def test_final_validate_noop_without_priority_list() -> None: + """A network config without a 'priority' list imposes no component requirements.""" + fv.full_config.set({}) + _final_validate({}) # must not raise + + +def _cpp_setup_priority(name: str) -> float: + """Read a setup_priority constant straight from esphome/core/component.h.""" + header = Path(__file__).parents[3] / "esphome" / "core" / "component.h" + match = re.search( + rf"inline constexpr float {name} = ([\d.]+)f;", header.read_text() + ) + assert match is not None, f"setup_priority::{name} not found in component.h" + return float(match.group(1)) + + +def test_priority_band_constants_match_cpp_setup_priority() -> None: + """The Python priority-band constants mirror the C++ setup_priority values. + + NETWORK_PRIORITY_BASE must equal the historical setup_priority::WIFI / + ::ETHERNET default so a single-entry priority list reproduces the legacy + setup order, and the band guard must track setup_priority::AFTER_WIFI. + Reading the values from component.h turns a silent desync into a CI + failure if either side is ever rebalanced. + """ + assert _cpp_setup_priority("WIFI") == NETWORK_PRIORITY_BASE + assert _cpp_setup_priority("ETHERNET") == NETWORK_PRIORITY_BASE + assert _cpp_setup_priority("AFTER_WIFI") == _SETUP_PRIORITY_AFTER_WIFI + # Must stay below AFTER_BLUETOOTH (NetworkComponent's own priority) so + # interfaces never set up before esp_netif_init(). + assert _cpp_setup_priority("AFTER_BLUETOOTH") > NETWORK_PRIORITY_BASE + + +def test_wifi_first_priority_emits_primary_interface_define( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A wifi-first priority list emits USE_NETWORK_PRIMARY_INTERFACE_WIFI.""" + generate_main(component_config_path("priority_wifi_first.yaml")) + defines = {d.name for d in CORE.defines} + assert "USE_NETWORK_PRIMARY_INTERFACE_WIFI" in defines + # Emitted by cg.set_setup_priority() at the wifi/ethernet call sites. + assert "USE_SETUP_PRIORITY_OVERRIDE" in defines + + +def test_ethernet_first_priority_emits_no_primary_interface_define( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Ethernet-first matches the built-in preference order, so no define is emitted.""" + generate_main(component_config_path("priority_ethernet_first.yaml")) + assert not any( + d.name.startswith("USE_NETWORK_PRIMARY_INTERFACE_") for d in CORE.defines + ) + # The setup-priority overrides themselves are still emitted. + assert "USE_SETUP_PRIORITY_OVERRIDE" in {d.name for d in CORE.defines} + + +def test_no_primary_interface_define_without_priority( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Without a priority list, no primary-interface define is emitted.""" + generate_main(component_config_path("wifi_only.yaml")) + assert not any( + d.name.startswith("USE_NETWORK_PRIMARY_INTERFACE_") for d in CORE.defines + ) diff --git a/tests/components/network/test-priority.esp32-idf.yaml b/tests/components/network/test-priority.esp32-idf.yaml new file mode 100644 index 0000000000..baa821a234 --- /dev/null +++ b/tests/components/network/test-priority.esp32-idf.yaml @@ -0,0 +1,23 @@ +# Compiled dual-stack test: wifi + ethernet coexisting via network: priority:. +# This is the first build path that keeps both radios' stacks compiled in, so +# it must actually compile (not just validate) to guard the reconciler wiring. +# WiFi is listed first so the build also exercises the wifi-primary branch in +# network/util.cpp (the ethernet-primary branch matches the legacy order). +wifi: + ssid: MySSID + password: password1 + +ethernet: + type: W5500 + clk_pin: GPIO19 + mosi_pin: GPIO21 + miso_pin: GPIO23 + cs_pin: GPIO18 + interrupt_pin: GPIO36 + reset_pin: GPIO22 + clock_speed: 10Mhz + +network: + priority: + - wifi + - ethernet From 18f93c0e410b74836ebf78549716c60cdf137279 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:02:30 +0000 Subject: [PATCH 1041/1815] Bump aioesphomeapi from 45.6.2 to 45.7.0 (#17768) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 7168c8a488..b1f7bc197e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.6.2 +aioesphomeapi==45.7.0 zeroconf==0.150.0 puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import From a08070a836843b5c2fbcc2d513203f79dd01b0de Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:18:21 -0500 Subject: [PATCH 1042/1815] [sen5x] Add model option to override autodetection (#17764) --- esphome/components/sen5x/sen5x.cpp | 40 ++++++++++++++++++------------ esphome/components/sen5x/sen5x.h | 2 ++ esphome/components/sen5x/sensor.py | 15 ++++++++++- tests/components/sen5x/common.yaml | 1 + 4 files changed, 41 insertions(+), 17 deletions(-) diff --git a/esphome/components/sen5x/sen5x.cpp b/esphome/components/sen5x/sen5x.cpp index 588650e630..f8df89ee33 100644 --- a/esphome/components/sen5x/sen5x.cpp +++ b/esphome/components/sen5x/sen5x.cpp @@ -101,25 +101,31 @@ void SEN5XComponent::setup() { ESP_LOGV(TAG, "Serial number %s", this->serial_number_); uint16_t raw_product_name[16]; - if (!this->get_register(SEN5X_CMD_GET_PRODUCT_NAME, raw_product_name, 16, 20)) { - ESP_LOGE(TAG, "Failed to read product name"); - this->error_code_ = PRODUCT_NAME_FAILED; - this->mark_failed(); - return; + Sen5xType detected_type = Sen5xType::UNKNOWN; + if (this->get_register(SEN5X_CMD_GET_PRODUCT_NAME, raw_product_name, 16, 20)) { + const char *product_name = sensirion_convert_to_string_in_place(raw_product_name, 16); + if (strncmp(product_name, "SEN50", 5) == 0) { + detected_type = Sen5xType::SEN50; + } else if (strncmp(product_name, "SEN54", 5) == 0) { + detected_type = Sen5xType::SEN54; + } else if (strncmp(product_name, "SEN55", 5) == 0) { + detected_type = Sen5xType::SEN55; + } } - const char *product_name = sensirion_convert_to_string_in_place(raw_product_name, 16); - if (strncmp(product_name, "SEN50", 5) == 0) { - this->type_ = Sen5xType::SEN50; - } else if (strncmp(product_name, "SEN54", 5) == 0) { - this->type_ = Sen5xType::SEN54; - } else if (strncmp(product_name, "SEN55", 5) == 0) { - this->type_ = Sen5xType::SEN55; - } else { + + if (this->model_override_.has_value()) { + if (detected_type != this->model_override_.value()) { + ESP_LOGW(TAG, "Detected %s, using %s", LOG_STR_ARG(type_to_string(detected_type)), + LOG_STR_ARG(type_to_string(this->model_override_.value()))); + } + this->type_ = this->model_override_.value(); + } else if (detected_type == Sen5xType::UNKNOWN) { this->type_ = Sen5xType::UNKNOWN; - ESP_LOGE(TAG, "Unknown product name: %.32s", product_name); this->error_code_ = PRODUCT_NAME_FAILED; this->mark_failed(); return; + } else { + this->type_ = detected_type; } ESP_LOGD(TAG, "Type: %s", LOG_STR_ARG(type_to_string(this->type_))); @@ -255,10 +261,12 @@ void SEN5XComponent::dump_config() { } } ESP_LOGCONFIG(TAG, - " Type: %s\n" + " Type: %s%s\n" " Firmware version: %d\n" " Serial number: %s", - LOG_STR_ARG(type_to_string(this->type_)), this->firmware_version_, this->serial_number_); + LOG_STR_ARG(type_to_string(this->type_)), + this->model_override_.has_value() ? LOG_STR_LITERAL(" (overridden)") : LOG_STR_LITERAL(""), + this->firmware_version_, this->serial_number_); if (this->auto_cleaning_interval_.has_value()) { ESP_LOGCONFIG(TAG, " Auto cleaning interval: %" PRId32 "s", this->auto_cleaning_interval_.value()); } diff --git a/esphome/components/sen5x/sen5x.h b/esphome/components/sen5x/sen5x.h index 6b5a1f8510..ed7689af52 100644 --- a/esphome/components/sen5x/sen5x.h +++ b/esphome/components/sen5x/sen5x.h @@ -95,6 +95,7 @@ class SEN5XComponent final : public PollingComponent, public sensirion_common::S temp_comp.time_constant = time_constant; this->temperature_compensation_ = temp_comp; } + void set_model(Sen5xType model) { this->model_override_ = model; } bool start_fan_cleaning(); protected: @@ -126,6 +127,7 @@ class SEN5XComponent final : public PollingComponent, public sensirion_common::S optional voc_tuning_params_; optional nox_tuning_params_; optional temperature_compensation_; + optional model_override_; ESPPreferenceObject pref_; }; diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index 480654ee1b..3ea526d931 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -12,6 +12,7 @@ from esphome.const import ( CONF_INDEX_OFFSET, CONF_LEARNING_TIME_GAIN_HOURS, CONF_LEARNING_TIME_OFFSET_HOURS, + CONF_MODEL, CONF_NORMALIZED_OFFSET_SLOPE, CONF_NOX, CONF_OFFSET, @@ -39,6 +40,7 @@ from esphome.const import ( UNIT_MICROGRAMS_PER_CUBIC_METER, UNIT_PERCENT, ) +from esphome.types import ConfigType CODEOWNERS = ["@martgras"] DEPENDENCIES = ["i2c"] @@ -49,6 +51,7 @@ SEN5XComponent = sen5x_ns.class_( "SEN5XComponent", cg.PollingComponent, sensirion_common.SensirionI2CDevice ) RhtAccelerationMode = sen5x_ns.enum("RhtAccelerationMode") +Sen5xType = sen5x_ns.enum("Sen5xType", is_class=True) CONF_ACCELERATION_MODE = "acceleration_mode" CONF_AUTO_CLEANING_INTERVAL = "auto_cleaning_interval" @@ -63,6 +66,12 @@ ACCELERATION_MODES = { "high": RhtAccelerationMode.HIGH_ACCELERATION, } +MODELS = { + "SEN50": Sen5xType.SEN50, + "SEN54": Sen5xType.SEN54, + "SEN55": Sen5xType.SEN55, +} + def _gas_sensor( *, @@ -186,6 +195,7 @@ CONFIG_SCHEMA = ( } ), cv.Optional(CONF_ACCELERATION_MODE): cv.enum(ACCELERATION_MODES), + cv.Optional(CONF_MODEL): cv.enum(MODELS, upper=True), } ) .extend(cv.polling_component_schema("60s")) @@ -210,7 +220,7 @@ SETTING_MAP = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -219,6 +229,9 @@ async def to_code(config): if cfg := config.get(key): cg.add(getattr(var, funcName)(cfg)) + if (model := config.get(CONF_MODEL)) is not None: + cg.add(var.set_model(model)) + for key, funcName in SENSOR_MAP.items(): if cfg := config.get(key): sens = await sensor.new_sensor(cfg) diff --git a/tests/components/sen5x/common.yaml b/tests/components/sen5x/common.yaml index a4462a16ea..20f3a1dfd8 100644 --- a/tests/components/sen5x/common.yaml +++ b/tests/components/sen5x/common.yaml @@ -42,4 +42,5 @@ sensor: auto_cleaning_interval: 604800s acceleration_mode: low store_baseline: true + model: sen55 address: 0x69 From e46bdf9e18ec0377f0a821f98a2bf7be5cf873c0 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 22 Jul 2026 14:41:33 -0400 Subject: [PATCH 1043/1815] [sendspin] Bump sendspin-cpp to v0.7.0 (#17781) --- esphome/components/sendspin/__init__.py | 9 +++------ esphome/components/sendspin/sendspin_hub.cpp | 7 ++++++- esphome/idf_component.yml | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 97e7f4e22c..e20925f323 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -129,12 +129,12 @@ def _request_high_performance_networking(config: ConfigType) -> ConfigType: """ network.require_high_performance_networking() # Socket consumption varies by mode: - # - Server mode: 1 listening socket + 2 client connections (for handoff) + # - Server mode: 1 listening socket + 4 client connections (established connection, unproven connections, and a spare) # - Client mode: 1 outbound connection socket.consume_sockets( 1, "sendspin_websocket_server", socket.SocketType.TCP_LISTEN )(config) - socket.consume_sockets(2, "sendspin_websocket_server")(config) + socket.consume_sockets(4, "sendspin_websocket_server")(config) socket.consume_sockets(1, "sendspin_websocket_client")(config) wifi.enable_runtime_power_save_control() @@ -198,7 +198,7 @@ async def to_code(config: ConfigType) -> None: psram.request_external_task_stack() # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.1") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.0") cg.add_define("USE_SENDSPIN", True) # for MDNS @@ -255,9 +255,6 @@ async def to_code(config: ConfigType) -> None: if psram_stack: psram.request_external_task_stack() - # Library defaults: priority 18 (one above httpd_priority 17 so the decoder is not - # starved by the HTTP server during the initial encoded-audio burst at stream start), - # decode buffer location PREFER_EXTERNAL. player_struct_fields = [ ("audio_formats", audio_format_structs), ("audio_buffer_capacity", player_cfg[CONF_BUFFER_SIZE]), diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index b95d95b2bc..04dbab0080 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -179,7 +179,12 @@ std::optional SendspinHub::load_last_server_hash() { void SendspinHub::send_client_command(sendspin::SendspinControllerCommand command, std::optional volume, std::optional mute) { if (this->is_ready()) { - this->controller_role_->send_command(command, volume, mute); + sendspin::ClientCommandControllerObject obj = { + .command = command, + .volume = volume, + .muted = mute, + }; + this->controller_role_->send_command(obj); } } diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 60b00d33c7..45efd20bf6 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -98,7 +98,7 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.6.1 + version: 0.7.0 lvgl/lvgl: version: 9.5.0 fastled/FastLED: From f7bf4e9727ac0d5d95b280d9fdda5819834235b6 Mon Sep 17 00:00:00 2001 From: Flautz Date: Wed, 22 Jul 2026 22:50:11 +0200 Subject: [PATCH 1044/1815] [mipi_spi] fix partial update bug (#17747) Co-authored-by: clyde <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/mipi_spi/mipi_spi.h | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 701bcd7169..b269f46dc9 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -385,10 +385,10 @@ class MipiSpi : public display::Display, * @param ptr The pointer to the pixel data * @param w Width of each line in bytes * @param h Height of the buffer in rows - * @param pad Padding in bytes after each line + * @param stride Total length of each line in bytes, including any padding */ - void write_display_data_(const uint8_t *ptr, size_t w, size_t h, size_t pad) { - if (pad == 0) { + void write_display_data_(const uint8_t *ptr, size_t w, size_t h, size_t stride) { + if (stride == w) { if constexpr (BUS_TYPE == BUS_TYPE_SINGLE || BUS_TYPE == BUS_TYPE_SINGLE_16) { this->write_array(ptr, w * h); } else if constexpr (BUS_TYPE == BUS_TYPE_QUAD) { @@ -405,7 +405,7 @@ class MipiSpi : public display::Display, } else if constexpr (BUS_TYPE == BUS_TYPE_OCTAL) { this->write_cmd_addr_data(0, 0, 0, 0, ptr, w, 8); } - ptr += w + pad; + ptr += stride; } } } @@ -423,7 +423,7 @@ class MipiSpi : public display::Display, ptr += y_offset * (x_offset + w + x_pad) + x_offset; if constexpr (BUFFERPIXEL == DISPLAYPIXEL) { this->write_display_data_(reinterpret_cast(ptr), w * sizeof(BUFFERTYPE), h, - x_pad * sizeof(BUFFERTYPE)); + (x_offset + w + x_pad) * sizeof(BUFFERTYPE)); } else { // type conversion required, do it in chunks uint8_t dbuffer[DISPLAYPIXEL * 48]; @@ -459,14 +459,14 @@ class MipiSpi : public display::Display, } // buffer full? Flush. if (dptr == dbuffer + sizeof(dbuffer)) { - this->write_display_data_(dbuffer, sizeof(dbuffer), 1, 0); + this->write_display_data_(dbuffer, sizeof(dbuffer), 1, sizeof(dbuffer)); dptr = dbuffer; } } } // flush any remaining data if (dptr != dbuffer) { - this->write_display_data_(dbuffer, dptr - dbuffer, 1, 0); + this->write_display_data_(dbuffer, dptr - dbuffer, 1, dptr - dbuffer); } } this->disable(); From e1cedaba877fcb183a9f8c49b78d4f2464b49f45 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Jul 2026 13:28:22 -1000 Subject: [PATCH 1045/1815] [bundle] Remap absolute file paths when compiling an extracted bundle (#17765) --- esphome/bundle.py | 101 +++++++- esphome/config_validation.py | 30 ++- tests/unit_tests/test_bundle.py | 270 ++++++++++++++++++++- tests/unit_tests/test_config_validation.py | 77 ++++++ 4 files changed, 469 insertions(+), 9 deletions(-) diff --git a/esphome/bundle.py b/esphome/bundle.py index 88df87c3ba..dcaea03646 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -12,7 +12,7 @@ from enum import StrEnum import io import json import logging -from pathlib import Path +from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath import re import shutil import tarfile @@ -51,6 +51,7 @@ class ManifestKey(StrEnum): MANIFEST_VERSION = "manifest_version" ESPHOME_VERSION = "esphome_version" CONFIG_FILENAME = "config_filename" + CONFIG_DIR = "config_dir" FILES = "files" HAS_SECRETS = "has_secrets" @@ -127,6 +128,12 @@ class BundleData: """Files components asked to include, keyed under DOMAIN in CORE.data.""" extra_files: list[Path] = field(default_factory=list) + # Original config dir parsed from an extracted bundle's manifest.json, + # kept in the path flavor of the machine the bundle was created on. + # The checked flag makes the manifest lookup happen at most once per run; + # CORE.data is cleared between runs. + original_config_dir: PurePath | None = None + original_config_dir_checked: bool = False def _get_data() -> BundleData: @@ -148,6 +155,94 @@ def add_bundle_file(path: Path) -> None: _get_data().extra_files.append(CORE.relative_config_path(path)) +# Windows paths start with a drive letter or contain backslashes; POSIX +# paths do neither in practice, so this is how the flavor of a recorded +# path string is recognized on any host. +_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:") + + +def _path_flavor(value: str) -> type[PurePath]: + """Pick the pure path class matching the flavor ``value`` was written in.""" + if "\\" in value or _WINDOWS_DRIVE_RE.match(value): + return PureWindowsPath + return PurePosixPath + + +def _load_original_config_dir() -> PurePath | None: + """Read the original config dir from an extracted bundle's manifest. + + Returns None when the current config dir is not an extracted bundle or + the manifest does not record the original config dir. + """ + manifest_path = CORE.config_dir / MANIFEST_FILENAME + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except FileNotFoundError: + # The common case: this config dir is not an extracted bundle. + return None + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as err: + # A manifest.json is present but unreadable or malformed. Say so + # instead of letting it look identical to "not a bundle". + _LOGGER.warning("Bundle: ignoring unreadable %s: %s", manifest_path, err) + return None + if not isinstance(manifest, dict): + return None + # A manifest.json in the config dir does not have to be ours. Only trust + # one that looks like a bundle manifest for exactly this config file. + version = manifest.get(ManifestKey.MANIFEST_VERSION) + if not isinstance(version, int) or version < 1: + return None + if manifest.get(ManifestKey.CONFIG_FILENAME) != CORE.config_path.name: + return None + config_dir = manifest.get(ManifestKey.CONFIG_DIR) + if not isinstance(config_dir, str) or not config_dir: + return None + return _path_flavor(config_dir)(config_dir) + + +def remap_bundle_path(value: str) -> Path | None: + """Remap an absolute path from the machine a bundle was created on. + + A bundled config may reference files by absolute path. The referenced + files ship inside the bundle at their config-relative locations, but the + YAML text is copied verbatim, so after extraction on another machine the + absolute reference points at a path that only existed on the creating + machine. The bundle manifest records that machine's config dir; when + ``value`` names a path that lived under it, return the corresponding + file next to the extracted config. + + ``value`` is the raw path string from the config. It is parsed with the + original machine's path flavor, so a bundle created on Windows remaps on + a POSIX build server and vice versa. + + Returns None when not compiling an extracted bundle, when ``value`` was + not under the original config dir, or when the bundle does not contain + the file. + """ + data = _get_data() + if not data.original_config_dir_checked: + data.original_config_dir_checked = True + data.original_config_dir = _load_original_config_dir() + original_dir = data.original_config_dir + if original_dir is None: + return None + path = type(original_dir)(value) + if not path.is_absolute(): + return None + try: + rel = path.relative_to(original_dir) + except ValueError: + return None + # relative_to is lexical, so ".." segments survive it. Refuse them: the + # remapped file must land strictly inside the extracted config tree. + if ".." in rel.parts: + return None + remapped = CORE.relative_config_path(Path(*rel.parts)) + if not remapped.exists(): + return None + return remapped + + @dataclass class BundleFile: """A file to include in the bundle.""" @@ -174,6 +269,7 @@ class BundleManifest: config_filename: str files: list[str] has_secrets: bool + config_dir: str | None = None class ConfigBundleCreator: @@ -438,6 +534,7 @@ class ConfigBundleCreator: ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION, ManifestKey.ESPHOME_VERSION: const.__version__, ManifestKey.CONFIG_FILENAME: self._config_path.name, + ManifestKey.CONFIG_DIR: str(self._config_dir), ManifestKey.FILES: [f.path for f in files], ManifestKey.HAS_SECRETS: has_secrets, } @@ -522,12 +619,14 @@ def read_bundle_manifest(bundle_path: Path) -> BundleManifest: except tarfile.TarError as err: raise EsphomeError(f"Failed to read bundle: {err}") from err + config_dir = manifest.get(ManifestKey.CONFIG_DIR) return BundleManifest( manifest_version=manifest[ManifestKey.MANIFEST_VERSION], esphome_version=manifest.get(ManifestKey.ESPHOME_VERSION, "unknown"), config_filename=manifest[ManifestKey.CONFIG_FILENAME], files=manifest.get(ManifestKey.FILES, []), has_secrets=manifest.get(ManifestKey.HAS_SECRETS, False), + config_dir=config_dir if isinstance(config_dir, str) else None, ) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 3f7c8ff783..713df5452a 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1938,14 +1938,29 @@ def dimensions(value): return dimensions([match.group(1), match.group(2)]) +def _remap_bundle_path(value: str) -> Path | None: + """Resolve a path from the machine an extracted bundle was created on. + + An absolute path in a config compiled from an extracted bundle may point + at the machine the bundle was created on; the bundle ships the file at + its config-relative location instead. + """ + from esphome.bundle import remap_bundle_path + + return remap_bundle_path(value) + + def directory(value: object) -> Path: value = string(value) path = CORE.relative_config_path(value) if not path.exists(): - raise Invalid( - f"Could not find directory '{path}'. Please make sure it exists (full path: {path.resolve()})." - ) + remapped = _remap_bundle_path(value) + if remapped is None: + raise Invalid( + f"Could not find directory '{path}'. Please make sure it exists (full path: {path.resolve()})." + ) + path = remapped if not path.is_dir(): raise Invalid( f"Path '{path}' is not a directory (full path: {path.resolve()})." @@ -1958,9 +1973,12 @@ def file_(value: object) -> Path: path = CORE.relative_config_path(value) if not path.exists(): - raise Invalid( - f"Could not find file '{path}'. Please make sure it exists (full path: {path.resolve()})." - ) + remapped = _remap_bundle_path(value) + if remapped is None: + raise Invalid( + f"Could not find file '{path}'. Please make sure it exists (full path: {path.resolve()})." + ) + path = remapped if not path.is_file(): raise Invalid(f"Path '{path}' is not a file (full path: {path.resolve()}).") return path diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index 6cecb63c2d..f0abcc74c6 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -27,6 +27,7 @@ from esphome.bundle import ( is_bundle_path, prepare_bundle_for_compile, read_bundle_manifest, + remap_bundle_path, ) from esphome.core import CORE, EsphomeError from esphome.yaml_util import force_load_include_files @@ -478,7 +479,10 @@ def test_read_bundle_manifest_corrupted_tar(tmp_path: Path) -> None: def test_read_bundle_manifest(tmp_path: Path) -> None: bundle_path = _make_bundle( tmp_path, - manifest_overrides={ManifestKey.HAS_SECRETS: True}, + manifest_overrides={ + ManifestKey.HAS_SECRETS: True, + ManifestKey.CONFIG_DIR: "/original/config", + }, extra_files={"secrets.yaml": b"wifi: test\n"}, ) @@ -489,6 +493,7 @@ def test_read_bundle_manifest(tmp_path: Path) -> None: assert manifest.esphome_version == "2026.2.0-test" assert manifest.config_filename == "test.yaml" assert manifest.has_secrets is True + assert manifest.config_dir == "/original/config" def test_read_bundle_manifest_minimal(tmp_path: Path) -> None: @@ -508,6 +513,266 @@ def test_read_bundle_manifest_minimal(tmp_path: Path) -> None: assert result.esphome_version == "unknown" assert not result.files assert result.has_secrets is False + assert result.config_dir is None + + +def test_read_bundle_manifest_non_string_config_dir(tmp_path: Path) -> None: + """A malformed config_dir value is dropped rather than propagated.""" + bundle_path = _make_bundle( + tmp_path, manifest_overrides={ManifestKey.CONFIG_DIR: 42} + ) + + assert read_bundle_manifest(bundle_path).config_dir is None + + +# --------------------------------------------------------------------------- +# remap_bundle_path +# --------------------------------------------------------------------------- + + +ORIGINAL_CONFIG_DIR = "/original/config" + + +def _bundle_manifest_dict(**overrides: Any) -> dict[str, Any]: + """Manifest content an extracted bundle would contain.""" + manifest: dict[str, Any] = { + ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION, + ManifestKey.CONFIG_FILENAME: "test.yaml", + ManifestKey.CONFIG_DIR: ORIGINAL_CONFIG_DIR, + } + manifest.update(overrides) + return manifest + + +def _setup_extracted_dir( + tmp_path: Path, + manifest: dict[str, Any] | str | None, + files: dict[str, str] | None = None, +) -> Path: + """Create a directory shaped like an extracted bundle and point CORE at it.""" + extract_dir = _setup_config_dir(tmp_path, files) + if manifest is not None: + content = manifest if isinstance(manifest, str) else json.dumps(manifest) + (extract_dir / MANIFEST_FILENAME).write_text(content) + return extract_dir + + +def test_remap_bundle_path_success(tmp_path: Path) -> None: + """A stale absolute path resolves to the bundled copy next to the config.""" + extract_dir = _setup_extracted_dir( + tmp_path, _bundle_manifest_dict(), files={"boards/partitions.csv": "csv\n"} + ) + + remapped = remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/boards/partitions.csv") + + assert remapped == extract_dir / "boards" / "partitions.csv" + assert remapped.is_file() + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(r"C:\Users\nick\esphome\boards\partitions.csv", id="backslashes"), + pytest.param("C:/Users/nick/esphome/boards/partitions.csv", id="forward"), + pytest.param(r"c:\users\NICK\esphome\boards\partitions.csv", id="case"), + ], +) +def test_remap_bundle_path_windows_bundle_on_posix(tmp_path: Path, value: str) -> None: + """A bundle created on Windows remaps on a build server with another layout.""" + extract_dir = _setup_extracted_dir( + tmp_path, + _bundle_manifest_dict(**{ManifestKey.CONFIG_DIR: r"C:\Users\nick\esphome"}), + files={"boards/partitions.csv": "csv\n"}, + ) + + remapped = remap_bundle_path(value) + + assert remapped == extract_dir / "boards" / "partitions.csv" + assert remapped.is_file() + + +def test_remap_bundle_path_windows_bundle_path_not_under_config_dir( + tmp_path: Path, +) -> None: + """A Windows path outside the original config dir is left alone.""" + _setup_extracted_dir( + tmp_path, + _bundle_manifest_dict(**{ManifestKey.CONFIG_DIR: r"C:\Users\nick\esphome"}), + files={"partitions.csv": "csv\n"}, + ) + + assert remap_bundle_path(r"D:\other\partitions.csv") is None + + +def test_remap_bundle_path_windows_profile_with_spaces(tmp_path: Path) -> None: + r"""A Windows profile like C:\Users\First Last remaps like any other dir.""" + extract_dir = _setup_extracted_dir( + tmp_path, + _bundle_manifest_dict( + **{ManifestKey.CONFIG_DIR: r"C:\Users\First Last\esphome"} + ), + files={"boards/my partitions.csv": "csv\n"}, + ) + + remapped = remap_bundle_path( + r"C:\Users\First Last\esphome\boards\my partitions.csv" + ) + + assert remapped == extract_dir / "boards" / "my partitions.csv" + assert remapped.is_file() + + +def test_remap_bundle_path_unc_config_dir(tmp_path: Path) -> None: + """A bundle created from a UNC share remaps like any other Windows path.""" + extract_dir = _setup_extracted_dir( + tmp_path, + _bundle_manifest_dict(**{ManifestKey.CONFIG_DIR: r"\\server\share\esphome"}), + files={"partitions.csv": "csv\n"}, + ) + + remapped = remap_bundle_path(r"\\server\share\esphome\partitions.csv") + + assert remapped == extract_dir / "partitions.csv" + + +def test_remap_bundle_path_flavor_mismatch(tmp_path: Path) -> None: + """A POSIX style value cannot come from a Windows config dir; no remap.""" + _setup_extracted_dir( + tmp_path, + _bundle_manifest_dict(**{ManifestKey.CONFIG_DIR: r"C:\Users\nick\esphome"}), + files={"partitions.csv": "csv\n"}, + ) + + assert remap_bundle_path("/original/config/partitions.csv") is None + + +def test_remap_bundle_path_rejects_traversal(tmp_path: Path) -> None: + """A remap may never escape the extracted config tree.""" + extract_dir = _setup_extracted_dir(tmp_path, _bundle_manifest_dict()) + (tmp_path / "outside.csv").write_text("csv\n") + assert (extract_dir / ".." / "outside.csv").resolve().is_file() + + assert remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/../outside.csv") is None + + +def test_remap_bundle_path_relative_value(tmp_path: Path) -> None: + """Relative references resolve normally and are never remapped.""" + _setup_extracted_dir(tmp_path, _bundle_manifest_dict()) + + assert remap_bundle_path("missing.csv") is None + + +def test_remap_bundle_path_no_manifest(tmp_path: Path) -> None: + """A config dir without a manifest is not an extracted bundle.""" + _setup_extracted_dir(tmp_path, None, files={"partitions.csv": "csv\n"}) + + assert remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/partitions.csv") is None + + +@pytest.mark.parametrize( + "manifest", + [ + pytest.param("{not json", id="malformed_json"), + pytest.param("[]", id="not_a_dict"), + pytest.param( + _bundle_manifest_dict(**{ManifestKey.MANIFEST_VERSION: "x"}), + id="version_not_int", + ), + pytest.param( + _bundle_manifest_dict(**{ManifestKey.MANIFEST_VERSION: 0}), + id="version_zero", + ), + pytest.param( + _bundle_manifest_dict(**{ManifestKey.CONFIG_FILENAME: "other.yaml"}), + id="config_filename_mismatch", + ), + pytest.param( + { + ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION, + ManifestKey.CONFIG_FILENAME: "test.yaml", + }, + id="config_dir_missing", + ), + pytest.param( + _bundle_manifest_dict(**{ManifestKey.CONFIG_DIR: ""}), + id="config_dir_empty", + ), + ], +) +def test_remap_bundle_path_untrusted_manifest( + tmp_path: Path, manifest: dict[str, Any] | str +) -> None: + """Manifests that do not look like this bundle's manifest are ignored.""" + _setup_extracted_dir(tmp_path, manifest, files={"partitions.csv": "csv\n"}) + + assert remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/partitions.csv") is None + + +def test_remap_bundle_path_unreadable_manifest_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A present but broken manifest is reported, not silently ignored.""" + _setup_extracted_dir(tmp_path, "{not json", files={"partitions.csv": "csv\n"}) + + assert remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/partitions.csv") is None + assert "ignoring unreadable" in caplog.text + + +def test_remap_bundle_path_outside_original_config_dir(tmp_path: Path) -> None: + """Paths that were not under the original config dir are left alone.""" + _setup_extracted_dir(tmp_path, _bundle_manifest_dict()) + + assert remap_bundle_path("/elsewhere/partitions.csv") is None + + +def test_remap_bundle_path_bundled_copy_missing(tmp_path: Path) -> None: + """No remap when the bundle does not contain the file.""" + _setup_extracted_dir(tmp_path, _bundle_manifest_dict()) + + assert remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/partitions.csv") is None + + +def test_remap_bundle_path_manifest_read_once(tmp_path: Path) -> None: + """The manifest lookup result is cached for the rest of the run.""" + extract_dir = _setup_extracted_dir( + tmp_path, _bundle_manifest_dict(), files={"partitions.csv": "csv\n"} + ) + + first = remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/partitions.csv") + assert first == extract_dir / "partitions.csv" + + (extract_dir / MANIFEST_FILENAME).unlink() + second = remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/partitions.csv") + assert second == first + + +def test_remap_bundle_path_round_trip(tmp_path: Path) -> None: + """A file referenced by absolute path survives bundle create and extract. + + Reproduces https://github.com/esphome/esphome/issues/17755: the config + names its partitions csv by absolute path, the bundle is extracted on a + machine where that path does not exist, and the reference must resolve + to the bundled copy. + """ + config_dir = _setup_config_dir(tmp_path, files={"partitions.csv": "nvs,data\n"}) + abs_path = (config_dir / "partitions.csv").resolve() + + creator = ConfigBundleCreator({"esp32": {"partitions": abs_path}}) + result = creator.create_bundle() + + bundle_path = tmp_path / f"device{BUNDLE_EXTENSION}" + bundle_path.write_bytes(result.data) + target = tmp_path / "build_server" + config_path = extract_bundle(bundle_path, target) + + # Simulate the build server: fresh run, original config dir gone + CORE.reset() + CORE.config_path = config_path + shutil.rmtree(config_dir) + + remapped = remap_bundle_path(str(abs_path)) + assert remapped == target.resolve() / "partitions.csv" + assert remapped.is_file() # --------------------------------------------------------------------------- @@ -1261,7 +1526,7 @@ def test_create_bundle_produces_valid_archive(tmp_path: Path) -> None: def test_create_bundle_manifest_content(tmp_path: Path) -> None: - _setup_config_dir(tmp_path) + config_dir = _setup_config_dir(tmp_path) creator = ConfigBundleCreator({}) result = creator.create_bundle() @@ -1269,6 +1534,7 @@ def test_create_bundle_manifest_content(tmp_path: Path) -> None: manifest = result.manifest assert manifest[ManifestKey.MANIFEST_VERSION] == CURRENT_MANIFEST_VERSION assert manifest[ManifestKey.CONFIG_FILENAME] == "test.yaml" + assert manifest[ManifestKey.CONFIG_DIR] == str(config_dir.resolve()) assert "test.yaml" in manifest[ManifestKey.FILES] diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index fd21ac92ea..1da3d5593a 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1,3 +1,4 @@ +import json from pathlib import Path import string @@ -2912,3 +2913,79 @@ def test_rename_key_present() -> None: def test_rename_key_absent() -> None: assert cv.rename_key("old", "new")({"other": 5}) == {"other": 5} + + +def test_file__existing_relative_path(setup_core: Path) -> None: + (setup_core / "partitions.csv").write_text("csv\n") + + assert cv.file_("partitions.csv") == setup_core / "partitions.csv" + + +def test_file__missing_raises(setup_core: Path) -> None: + with pytest.raises(Invalid, match="Could not find file"): + cv.file_("partitions.csv") + + +def test_file__remaps_bundle_absolute_path(setup_core: Path) -> None: + """A stale absolute path in an extracted bundle resolves to the bundled copy.""" + manifest = { + "manifest_version": 1, + "config_filename": "test.yaml", + "config_dir": "/original/config", + } + (setup_core / "manifest.json").write_text(json.dumps(manifest)) + (setup_core / "partitions.csv").write_text("csv\n") + + assert cv.file_("/original/config/partitions.csv") == setup_core / "partitions.csv" + + +def test_file__missing_absolute_path_without_bundle(setup_core: Path) -> None: + with pytest.raises(Invalid, match="Could not find file"): + cv.file_("/original/config/partitions.csv") + + +def test_file__remaps_windows_bundle_absolute_path(setup_core: Path) -> None: + """A bundle created on Windows resolves on a host with another layout.""" + manifest = { + "manifest_version": 1, + "config_filename": "test.yaml", + "config_dir": "C:\\Users\\nick\\esphome", + } + (setup_core / "manifest.json").write_text(json.dumps(manifest)) + (setup_core / "partitions.csv").write_text("csv\n") + + result = cv.file_("C:\\Users\\nick\\esphome\\partitions.csv") + + assert result == setup_core / "partitions.csv" + + +def test_directory_remaps_bundle_absolute_path(setup_core: Path) -> None: + """A stale absolute directory in an extracted bundle resolves to the bundled copy.""" + manifest = { + "manifest_version": 1, + "config_filename": "test.yaml", + "config_dir": "/original/config", + } + (setup_core / "manifest.json").write_text(json.dumps(manifest)) + (setup_core / "headers").mkdir() + + assert cv.directory("/original/config/headers") == setup_core / "headers" + + +def test_directory_missing_raises(setup_core: Path) -> None: + with pytest.raises(Invalid, match="Could not find directory"): + cv.directory("/original/config/headers") + + +def test_file__remapped_path_is_directory_raises(setup_core: Path) -> None: + """A remapped path that is a directory still fails file validation.""" + manifest = { + "manifest_version": 1, + "config_filename": "test.yaml", + "config_dir": "/original/config", + } + (setup_core / "manifest.json").write_text(json.dumps(manifest)) + (setup_core / "headers").mkdir() + + with pytest.raises(Invalid, match="is not a file"): + cv.file_("/original/config/headers") From 52ace9c448d7c4485bef99629a1ea60fe544fe92 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:49:38 -1000 Subject: [PATCH 1046/1815] Bump bundled esphome-device-builder to 1.6.9 (#17793) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 1a34fda520..8649dbcd77 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.6.8 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.9 RUN \ platformio settings set enable_telemetry No \ From 66615a24a76e2cff6c723869c1b0c6a86c7c872d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:55:39 +1200 Subject: [PATCH 1047/1815] Bump version to 2026.7.2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index abaaa7b7aa..0dd5e208d4 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.7.1 +PROJECT_NUMBER = 2026.7.2 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index cc44622c86..d351ce286f 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.7.1" +__version__ = "2026.7.2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From dd2fbdd43ac758212d9e8e749b4ff4a04568f8af Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:48:25 +1200 Subject: [PATCH 1048/1815] [github] Add developer-facing feature PR classification (#17795) --- .github/PULL_REQUEST_TEMPLATE.md | 5 + .github/scripts/auto-label-pr/constants.js | 14 ++ .github/scripts/auto-label-pr/detectors.js | 25 +++- .../auto-label-pr/tests/detectors.test.js | 128 +++++++++++++++++- .github/workflows/status-check-labels.yml | 4 +- 5 files changed, 170 insertions(+), 6 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 08def88577..e708ae41b2 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -6,6 +6,7 @@ - [ ] Bugfix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) +- [ ] New developer-facing feature (adds functionality for component developers; no end-user configuration change) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) — [policy](https://developers.esphome.io/contributing/code/#what-constitutes-a-c-breaking-change) - [ ] Developer breaking change (an API change that could break external components) — [policy](https://developers.esphome.io/contributing/code/#what-is-considered-public-c-api) - [ ] Undocumented C++ API change (removal or change of undocumented public methods that lambda users may depend on) — [policy](https://developers.esphome.io/contributing/code/#c-user-expectations) @@ -20,6 +21,10 @@ - esphome/esphome.io# +**Pull request in [developers.esphome.io](https://github.com/esphome/developers.esphome.io) with developer documentation (if applicable):** + +- esphome/developers.esphome.io# + ## Test Environment - [ ] ESP32 diff --git a/.github/scripts/auto-label-pr/constants.js b/.github/scripts/auto-label-pr/constants.js index 2938fd923c..b95bc17518 100644 --- a/.github/scripts/auto-label-pr/constants.js +++ b/.github/scripts/auto-label-pr/constants.js @@ -22,11 +22,13 @@ module.exports = { 'has-tests', 'needs-tests', 'needs-docs', + 'needs-developer-docs', 'needs-codeowners', 'too-big', 'labeller-recheck', 'bugfix', 'new-feature', + 'new-feature-developer', 'breaking-change', 'developer-breaking-change', 'undocumented-api-change', @@ -40,5 +42,17 @@ module.exports = { // Keep matching the old esphome-docs name during the transition period /https:\/\/github\.com\/esphome\/esphome-docs\/pull\/\d+/, /esphome\/esphome-docs#\d+/ + ], + + DEVELOPER_DOCS_PR_PATTERNS: [ + /https:\/\/github\.com\/esphome\/developers\.esphome\.io\/pull\/\d+/, + /esphome\/developers\.esphome\.io#\d+/ + ], + + // Files whose developer-facing changes are documented via Python docstrings + // only - developers.esphome.io has no reference page for them yet, so PRs + // touching nothing but these files (and tests/) skip needs-developer-docs. + DEV_DOCS_EXEMPT_FILES: [ + 'esphome/config_validation.py' ] }; diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index 4406370a27..2478ccf959 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -1,4 +1,4 @@ -const { DOCS_PR_PATTERNS } = require('./constants'); +const { DOCS_PR_PATTERNS, DEVELOPER_DOCS_PR_PATTERNS, DEV_DOCS_EXEMPT_FILES } = require('./constants'); const { COMPONENT_REGEX, detectComponents, @@ -245,6 +245,7 @@ async function detectPRTemplateCheckboxes(context) { const checkboxPatterns = [ { pattern: /- \[x\] Bugfix \(non-breaking change which fixes an issue\)/i, label: 'bugfix' }, { pattern: /- \[x\] New feature \(non-breaking change which adds functionality\)/i, label: 'new-feature' }, + { pattern: /- \[x\] New developer-facing feature \(adds functionality for component developers; no end-user configuration change\)/i, label: 'new-feature-developer' }, { pattern: /- \[x\] Breaking change \(fix or feature that would cause existing functionality to not work as expected\)/i, label: 'breaking-change' }, { pattern: /- \[x\] Developer breaking change \(an API change that could break external components\)/i, label: 'developer-breaking-change' }, { pattern: /- \[x\] Undocumented C\+\+ API change \(removal or change of undocumented public methods that lambda users may depend on\)/i, label: 'undocumented-api-change' }, @@ -355,12 +356,14 @@ async function detectRequirements(allLabels, prFiles, context, hasYamlLoadable) const labels = new Set(); // Check for missing tests - if ((allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature')) && !allLabels.has('has-tests')) { + if ((allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature') || allLabels.has('new-feature-developer')) && !allLabels.has('has-tests')) { labels.add('needs-tests'); } // Check for missing docs. - // `new-feature` (PR-body checkbox) always counts. `new-component` / `new-platform` + // `new-feature` (PR-body checkbox) always counts. `new-feature-developer` is + // deliberately excluded here: its docs live on developers.esphome.io and are + // checked separately below. `new-component` / `new-platform` // only count when at least one newly added file defines a top-level CONFIG_SCHEMA, // i.e. the new component/platform is actually loadable from YAML. const docsEligible = @@ -376,6 +379,22 @@ async function detectRequirements(allLabels, prFiles, context, hasYamlLoadable) } } + // Check for missing developer docs. `new-feature-developer` requires a + // developers.esphome.io PR link, unless every changed file outside tests/ is + // in DEV_DOCS_EXEMPT_FILES (core validators documented via docstrings only). + if (allLabels.has('new-feature-developer')) { + const prBody = context.payload.pull_request.body || ''; + const nonTestFiles = prFiles + .map(file => file.filename) + .filter(file => !file.startsWith('tests/')); + const onlyExemptFiles = nonTestFiles.every(file => DEV_DOCS_EXEMPT_FILES.includes(file)); + const hasDevDocsLink = DEVELOPER_DOCS_PR_PATTERNS.some(pattern => pattern.test(prBody)); + + if (!onlyExemptFiles && !hasDevDocsLink) { + labels.add('needs-developer-docs'); + } + } + // Check for missing CODEOWNERS if (allLabels.has('new-component')) { const codeownersModified = prFiles.some(file => diff --git a/.github/scripts/auto-label-pr/tests/detectors.test.js b/.github/scripts/auto-label-pr/tests/detectors.test.js index aab1827c44..413fdb3f94 100644 --- a/.github/scripts/auto-label-pr/tests/detectors.test.js +++ b/.github/scripts/auto-label-pr/tests/detectors.test.js @@ -1,6 +1,13 @@ const { describe, it } = require('node:test'); const assert = require('node:assert/strict'); -const { detectNewPlatforms, detectNewComponents, detectPRSize } = require('../detectors'); +const { + detectNewPlatforms, + detectNewComponents, + detectPRSize, + detectPRTemplateCheckboxes, + detectRequirements, +} = require('../detectors'); +const { MANAGED_LABELS } = require('../constants'); // Minimal GitHub API mock — only repos.getContent is called by detectNewPlatforms/detectNewComponents // to check for CONFIG_SCHEMA in newly added files. @@ -146,6 +153,125 @@ describe('detectNewComponents', () => { }); }); +// --------------------------------------------------------------------------- +// detectPRTemplateCheckboxes +// --------------------------------------------------------------------------- + +const NEW_FEATURE_LINE = '- [x] New feature (non-breaking change which adds functionality)'; +const DEV_FEATURE_LINE = '- [x] New developer-facing feature (adds functionality for component developers; no end-user configuration change)'; +const DEV_FEATURE_LINE_UNTICKED = '- [ ] New developer-facing feature (adds functionality for component developers; no end-user configuration change)'; + +function makeBodyContext(body) { + return { payload: { pull_request: { body } } }; +} + +describe('detectPRTemplateCheckboxes', () => { + it('ticked developer-facing feature checkbox adds new-feature-developer only', async () => { + const labels = await detectPRTemplateCheckboxes(makeBodyContext(DEV_FEATURE_LINE)); + assert.ok(labels.has('new-feature-developer')); + assert.ok(!labels.has('new-feature')); + }); + + it('unticked developer-facing feature checkbox adds no label', async () => { + const labels = await detectPRTemplateCheckboxes(makeBodyContext(DEV_FEATURE_LINE_UNTICKED)); + assert.ok(!labels.has('new-feature-developer')); + }); + + it('ticked new feature checkbox does not add new-feature-developer', async () => { + const labels = await detectPRTemplateCheckboxes(makeBodyContext(NEW_FEATURE_LINE)); + assert.ok(labels.has('new-feature')); + assert.ok(!labels.has('new-feature-developer')); + }); +}); + +// --------------------------------------------------------------------------- +// detectRequirements +// --------------------------------------------------------------------------- + +describe('detectRequirements', () => { + // PR body without any docs-PR link. + const NO_DOCS_CONTEXT = makeBodyContext('Just a description, no docs link.'); + const USER_DOCS_CONTEXT = makeBodyContext('Docs: esphome/esphome.io#1234'); + const DEV_DOCS_CONTEXT = makeBodyContext('Docs: esphome/developers.esphome.io#1234'); + const DEV_DOCS_URL_CONTEXT = makeBodyContext('Docs: https://github.com/esphome/developers.esphome.io/pull/1234'); + + // File sets: a normal source change vs. one confined to the exempt core validators. + const SOURCE_FILES = [ + { filename: 'esphome/components/foo/foo.py' }, + { filename: 'tests/components/foo/common.yaml' }, + ]; + const VALIDATOR_FILES = [ + { filename: 'esphome/config_validation.py' }, + { filename: 'tests/unit_tests/test_config_validation.py' }, + ]; + + it('new-feature-developer without has-tests adds needs-tests but not needs-docs', async () => { + const labels = await detectRequirements(new Set(['new-feature-developer']), SOURCE_FILES, NO_DOCS_CONTEXT, false); + assert.ok(labels.has('needs-tests')); + assert.ok(!labels.has('needs-docs')); + }); + + it('new-feature-developer with has-tests does not add needs-tests', async () => { + const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, NO_DOCS_CONTEXT, false); + assert.ok(!labels.has('needs-tests')); + }); + + it('new-feature without a docs link still adds needs-docs', async () => { + const labels = await detectRequirements(new Set(['new-feature', 'has-tests']), [], NO_DOCS_CONTEXT, false); + assert.ok(labels.has('needs-docs')); + }); + + it('new-feature-developer without a developer docs link adds needs-developer-docs', async () => { + const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, NO_DOCS_CONTEXT, false); + assert.ok(labels.has('needs-developer-docs')); + }); + + it('a developers.esphome.io shorthand link satisfies needs-developer-docs', async () => { + const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, DEV_DOCS_CONTEXT, false); + assert.ok(!labels.has('needs-developer-docs')); + }); + + it('a developers.esphome.io URL link satisfies needs-developer-docs', async () => { + const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, DEV_DOCS_URL_CONTEXT, false); + assert.ok(!labels.has('needs-developer-docs')); + }); + + it('a user docs (esphome.io) link does not satisfy needs-developer-docs', async () => { + const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, USER_DOCS_CONTEXT, false); + assert.ok(labels.has('needs-developer-docs')); + }); + + it('a developer docs link does not satisfy needs-docs for new-feature', async () => { + const labels = await detectRequirements(new Set(['new-feature', 'has-tests']), [], DEV_DOCS_CONTEXT, false); + assert.ok(labels.has('needs-docs')); + }); + + it('changes confined to core validator files are exempt from needs-developer-docs', async () => { + const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), VALIDATOR_FILES, NO_DOCS_CONTEXT, false); + assert.ok(!labels.has('needs-developer-docs')); + }); + + it('validator changes mixed with other source files are not exempt', async () => { + const prFiles = [...VALIDATOR_FILES, { filename: 'esphome/components/foo/foo.py' }]; + const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), prFiles, NO_DOCS_CONTEXT, false); + assert.ok(labels.has('needs-developer-docs')); + }); +}); + +// --------------------------------------------------------------------------- +// MANAGED_LABELS +// --------------------------------------------------------------------------- + +describe('MANAGED_LABELS', () => { + it('includes new-feature-developer so the workflow syncs it', () => { + assert.ok(MANAGED_LABELS.includes('new-feature-developer')); + }); + + it('includes needs-developer-docs so the workflow syncs it', () => { + assert.ok(MANAGED_LABELS.includes('needs-developer-docs')); + }); +}); + // --------------------------------------------------------------------------- // detectPRSize // --------------------------------------------------------------------------- diff --git a/.github/workflows/status-check-labels.yml b/.github/workflows/status-check-labels.yml index d27cc0cbec..72987c25b1 100644 --- a/.github/workflows/status-check-labels.yml +++ b/.github/workflows/status-check-labels.yml @@ -5,7 +5,7 @@ on: types: [opened, reopened, labeled, unlabeled, synchronize] permissions: - pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, merge-after-release, chained-pr) + pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, needs-developer-docs, merge-after-release, chained-pr) concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} @@ -20,7 +20,7 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | - const blockingLabels = ['needs-docs', 'merge-after-release', 'chained-pr']; + const blockingLabels = ['needs-docs', 'needs-developer-docs', 'merge-after-release', 'chained-pr']; const { data: labels } = await github.rest.issues.listLabelsOnIssue({ owner: context.repo.owner, repo: context.repo.repo, From 427a50430839c42205831acbc3569c1cbdeb7f8d Mon Sep 17 00:00:00 2001 From: Stas Date: Thu, 23 Jul 2026 10:49:38 +0300 Subject: [PATCH 1049/1815] [sgp4x] Fix sgp4x VOC baseline restoration (#15667) Co-authored-by: Keith Burzinski Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/sgp4x/sgp4x.cpp | 60 ++++++++++++++++-------------- esphome/components/sgp4x/sgp4x.h | 12 ++++-- 2 files changed, 40 insertions(+), 32 deletions(-) diff --git a/esphome/components/sgp4x/sgp4x.cpp b/esphome/components/sgp4x/sgp4x.cpp index db56bd13f0..4e14833c16 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -52,29 +52,6 @@ void SGP4xComponent::setup() { ESP_LOGD(TAG, "Version 0x%0X", featureset); - if (this->store_baseline_) { - // Hash with config hash, version, and serial number - // This ensures the baseline storage is cleared after OTA - // Serial numbers are unique to each sensor, so multiple sensors can be used without conflict - uint32_t hash = fnv1a_hash_extend(App.get_config_version_hash(), this->serial_number_); - this->pref_ = global_preferences->make_preference(hash, true); - - if (this->pref_.load(&this->voc_baselines_storage_)) { - this->voc_state0_ = this->voc_baselines_storage_.state0; - this->voc_state1_ = this->voc_baselines_storage_.state1; - ESP_LOGV(TAG, "Loaded VOC baseline state0: 0x%04" PRIX32 ", state1: 0x%04" PRIX32, - this->voc_baselines_storage_.state0, voc_baselines_storage_.state1); - } - - // Initialize storage timestamp - this->seconds_since_last_store_ = 0; - - if (this->voc_baselines_storage_.state0 > 0 && this->voc_baselines_storage_.state1 > 0) { - ESP_LOGV(TAG, "Setting VOC baseline from save state0: 0x%04" PRIX32 ", state1: 0x%04" PRIX32, - this->voc_baselines_storage_.state0, voc_baselines_storage_.state1); - voc_algorithm_.set_states(this->voc_baselines_storage_.state0, this->voc_baselines_storage_.state1); - } - } if (this->voc_sensor_ && this->voc_tuning_params_.has_value()) { voc_algorithm_.set_tuning_parameters( voc_tuning_params_.value().index_offset, voc_tuning_params_.value().learning_time_offset_hours, @@ -89,6 +66,31 @@ void SGP4xComponent::setup() { nox_tuning_params_.value().std_initial, nox_tuning_params_.value().gain_factor); } + if (this->store_baseline_) { + // Initialize storage timestamp + this->seconds_since_last_store_ = 0; + + // Hash with config hash, version, and serial number + // This ensures the baseline storage is cleared after OTA + // Serial numbers are unique to each sensor, so multiple sensors can be used without conflict + uint32_t hash = fnv1a_hash_extend(App.get_config_version_hash(), this->serial_number_); + this->pref_ = global_preferences->make_preference(hash, true); + + if (this->pref_.load(&this->voc_baselines_storage_)) { + this->voc_state0_ = this->voc_baselines_storage_.state0; + this->voc_state1_ = this->voc_baselines_storage_.state1; + + ESP_LOGV(TAG, "Loaded VOC baseline state0: %f, state1: %f", this->voc_baselines_storage_.state0, + this->voc_baselines_storage_.state1); + + if (std::isnormal(this->voc_baselines_storage_.state0) && std::isnormal(this->voc_baselines_storage_.state1)) { + ESP_LOGV(TAG, "Setting VOC baseline from save state0: %f, state1: %f", this->voc_baselines_storage_.state0, + this->voc_baselines_storage_.state1); + voc_algorithm_.set_states(this->voc_baselines_storage_.state0, this->voc_baselines_storage_.state1); + } + } + } + this->self_test_(); /* The official spec for this sensor at @@ -138,15 +140,15 @@ void SGP4xComponent::update_gas_indices_() { // much if (this->store_baseline_ && this->seconds_since_last_store_ > SHORTEST_BASELINE_STORE_INTERVAL) { this->voc_algorithm_.get_states(this->voc_state0_, this->voc_state1_); - if (std::abs(this->voc_baselines_storage_.state0 - this->voc_state0_) > MAXIMUM_STORAGE_DIFF || - std::abs(this->voc_baselines_storage_.state1 - this->voc_state1_) > MAXIMUM_STORAGE_DIFF) { + if (std::abs(this->voc_baselines_storage_.state0 - this->voc_state0_) > MAXIMUM_STORAGE_DIFF_STATE0 || + std::abs(this->voc_baselines_storage_.state1 - this->voc_state1_) > MAXIMUM_STORAGE_DIFF_STATE1) { this->seconds_since_last_store_ = 0; this->voc_baselines_storage_.state0 = this->voc_state0_; this->voc_baselines_storage_.state1 = this->voc_state1_; if (this->pref_.save(&this->voc_baselines_storage_)) { - ESP_LOGV(TAG, "Stored VOC baseline state0: 0x%04" PRIX32 ", state1: 0x%04" PRIX32, - this->voc_baselines_storage_.state0, this->voc_baselines_storage_.state1); + ESP_LOGV(TAG, "Stored VOC baseline state0: %f, state1: %f", this->voc_baselines_storage_.state0, + this->voc_baselines_storage_.state1); } else { ESP_LOGW(TAG, "Storing VOC baselines failed"); } @@ -232,7 +234,9 @@ void SGP4xComponent::measure_raw_() { void SGP4xComponent::take_sample() { if (!this->self_test_complete_) return; - this->seconds_since_last_store_ += 1; + if (this->store_baseline_) { + this->seconds_since_last_store_ += 1; + } this->measure_raw_(); } diff --git a/esphome/components/sgp4x/sgp4x.h b/esphome/components/sgp4x/sgp4x.h index a40188e629..4504c25448 100644 --- a/esphome/components/sgp4x/sgp4x.h +++ b/esphome/components/sgp4x/sgp4x.h @@ -14,9 +14,9 @@ namespace esphome::sgp4x { struct SGP4xBaselines { - int32_t state0; - int32_t state1; -} PACKED; // NOLINT + float state0; + float state1; +}; enum SgpType { SGP40, SGP41 }; @@ -49,7 +49,11 @@ static const uint16_t SPG41_SELFTEST_TIME = 320; // 320 ms for self test static const uint16_t SGP40_MEASURE_TIME = 30; static const uint16_t SGP41_MEASURE_TIME = 55; // Store anyway if the baseline difference exceeds the max storage diff value -const float MAXIMUM_STORAGE_DIFF = 50.0f; +// state0 is mean of variance estimator, hence can have larger absolute values and a larger diff threshold +const float MAXIMUM_STORAGE_DIFF_STATE0 = 50.0f; +// state1 is std of variance estimator, so it typically has smaller absolute values than state0, hence we use a smaller +// diff threshold +const float MAXIMUM_STORAGE_DIFF_STATE1 = 5.0f; class SGP4xComponent; From 33fa9f5069e6b79e0f3f90e69f0f976e9e139d2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Thu, 23 Jul 2026 11:54:30 +0300 Subject: [PATCH 1050/1815] [bk72xx_ble] BLE controller support for BK72xx (BLE 5.x) (#17775) --- CODEOWNERS | 1 + esphome/components/bk72xx_ble/__init__.py | 94 ++++++++++ esphome/components/bk72xx_ble/bk72xx_ble.cpp | 176 ++++++++++++++++++ esphome/components/bk72xx_ble/bk72xx_ble.h | 44 +++++ esphome/components/libretiny/__init__.py | 9 +- esphome/core/defines.h | 1 + tests/components/bk72xx_ble/common.yaml | 2 + .../bk72xx_ble/validate.bk72xx-ard.yaml | 2 + 8 files changed, 327 insertions(+), 2 deletions(-) create mode 100644 esphome/components/bk72xx_ble/__init__.py create mode 100644 esphome/components/bk72xx_ble/bk72xx_ble.cpp create mode 100644 esphome/components/bk72xx_ble/bk72xx_ble.h create mode 100644 tests/components/bk72xx_ble/common.yaml create mode 100644 tests/components/bk72xx_ble/validate.bk72xx-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index b73ed319c8..fe09bd96cf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -69,6 +69,7 @@ esphome/components/bh1750/* @OttoWinter esphome/components/bh1900nux/* @B48D81EFCC esphome/components/binary_sensor/* @esphome/core esphome/components/bk72xx/* @kuba2k2 +esphome/components/bk72xx_ble/* @Bl00d-B0b esphome/components/bl0906/* @athom-tech @jesserockz @tarontop esphome/components/bl0939/* @ziceva esphome/components/bl0940/* @dan-s-github @tobias- diff --git a/esphome/components/bk72xx_ble/__init__.py b/esphome/components/bk72xx_ble/__init__.py new file mode 100644 index 0000000000..29e2a6b13d --- /dev/null +++ b/esphome/components/bk72xx_ble/__init__.py @@ -0,0 +1,94 @@ +"""BK72xx BLE — BLE controller support for the BLE-5.x LibreTiny Beken chips. + +The platform analog of esp32_ble / rp2040_ble: owns the Beken BDK BLE stack +bring-up and the controller BLE address. Consumers (bk72xx_ble_tracker) build +on this component and contain no SDK calls of their own. + +Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7238/BK7252N/BK7253 +(BLE 5.2), and any future BLE-5.x SoC. Capability is detected at compile time, +not by a chip list: the C++ guards on `__has_include("ble_api.h")` — the Beken +BLE 5.x public API header, which the LibreTiny beken-72xx builder ships only +for BLE-5.x SoCs. BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) fail +with a clear #error. + +No framework patch is needed: the LibreTiny beken-72xx builder already compiles +and links the BLE 5.x stack (CFG_SUPPORT_BLE=1 + CFG_BLE_VERSION=BLE_VERSION_5_x; +prebuilt libble_.a per SoC). This component only calls into it via the +public ble_api.h. +""" + +import logging + +import esphome.codegen as cg +from esphome.components import libretiny +from esphome.components.libretiny.const import FAMILY_BK7231N, FAMILY_BK7238 +import esphome.config_validation as cv +from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID +from esphome.types import ConfigType + +DEPENDENCIES = ["bk72xx"] +CODEOWNERS = ["@Bl00d-B0b"] + +_LOGGER = logging.getLogger(__name__) + +bk72xx_ble_ns = cg.esphome_ns.namespace("bk72xx_ble") +BK72xxBLE = bk72xx_ble_ns.class_("BK72xxBLE", cg.Component) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(BK72xxBLE), + # Default off: on the single-core BK72xx, bringing the BLE stack up during + # boot competes with the WiFi connection handshake. Consumers enable the + # stack lazily on first use (e.g. the tracker's first scan start). + cv.Optional(CONF_ENABLE_ON_BOOT, default=False): cv.boolean, + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT])) + + # Enable the BLE stack in the build (the '#h' maps to sys_config.h; the value + # is a list). ESPHome's libretiny platform normally appends CFG_SUPPORT_BLE=0 + # on BK7231N/BK7238 (saves ~21KB RAM/~200KB Flash when BLE is unused) to this + # SAME key — and add_platformio_option appends list values, it never replaces. + # The platform therefore skips its disable when this component is configured, + # so this =1 is the single CFG_SUPPORT_BLE define emitted. + cg.add_platformio_option("custom_options.sys_config#h", ["CFG_SUPPORT_BLE=1"]) + + # Pin the Beken BDK release the BLE 5.x stack is validated against. The + # bundled 3.0.33 has an older BLE header/library layout — and with + # CFG_SUPPORT_BLE=1 the SDK runs its BLE init unconditionally during boot + # (the reason the libretiny platform sets =0 when BLE is unused), so a + # mismatched BDK can crash the device before WiFi comes up regardless of + # enable_on_boot. Pinning here makes a plain config build against the + # validated BDK without any manual platformio_options. + _LOGGER.warning( + "bk72xx_ble builds with beken-bdk 3.0.78 instead of the platform's bundled " + "default: the default's older BLE layout can crash the device at boot when " + "BLE is compiled in" + ) + cg.add_platformio_option("custom_versions.beken-bdk", "3.0.78") + + # The BDK exposes the controller's BLE address as `common_default_bdaddr` on + # BK7231N, but NOT on BK7238 (its BLE stack has no such symbol; the address is + # derived from the WiFi MAC instead — the BDK's own fallback). Tell the C++ + # which path is available so it doesn't reference a missing symbol. + family = libretiny.get_libretiny_family() + if family == FAMILY_BK7231N: + cg.add_define("BK72XX_BLE_HAS_COMMON_BDADDR") + elif family == FAMILY_BK7238: + # ESPHome's LibreTiny disables BLE on BK7238 because the SDK can hang at + # WiFi STA startup when BLE init runs. This component re-enables BLE, so + # warn loudly: BK7238 is accepted but not hardware-verified and may be + # WiFi-unstable with BLE on. + _LOGGER.warning( + "bk72xx_ble on BK7238: enabling BLE is known to risk a WiFi STA startup " + "hang on this family and is not yet hardware-verified. Expect possible " + "instability." + ) + + cg.add_define("USE_BK72XX_BLE") diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.cpp b/esphome/components/bk72xx_ble/bk72xx_ble.cpp new file mode 100644 index 0000000000..db6b665ba6 --- /dev/null +++ b/esphome/components/bk72xx_ble/bk72xx_ble.cpp @@ -0,0 +1,176 @@ +// bk72xx_ble.cpp +// +// BLE controller support for the BK72xx BLE-5.x chips (LibreTiny beken-72xx +// family) — the platform analog of esp32_ble / rp2040_ble. Owns the Beken BDK +// BLE stack bring-up (ble_entry()) and the controller BLE address. Consumers +// (bk72xx_ble_tracker) build on this component and contain no SDK calls of +// their own. +// +// NOTE: the Beken BDK BLE 5.x stack is compiled and linked by the LibreTiny +// beken-72xx builder itself (prebuilt libble_.a + ble_5_x sources, gated +// on CFG_SUPPORT_BLE / CFG_BLE_VERSION in sys_config.h). This component only +// calls into it via its public API — no framework patch is required. + +#include "bk72xx_ble.h" // pulls esphome/core/defines.h for USE_BK72XX_BLE + +#ifdef USE_BK72XX_BLE + +#include + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" // get_mac_address_raw() +#include "esphome/core/log.h" + +// --------------------------------------------------------------------------- +// SDK-capability gate (not a chip allowlist). +// This component drives the Beken BLE *5.x* controller via its public API, +// `ble_api.h`, which the LibreTiny beken-72xx builder ships only for the +// BLE-5.x SoCs (it selects the `ble_pub` 5.x stack from CFG_BLE_VERSION; the +// 4.2 SoCs build a different, older API with no ble_api.h). Gate on the header +// itself so any BLE-5.x Beken chip — present or future — is supported without a +// hard-coded list, and a non-5.x build fails here with a clear message instead +// of a cryptic "ble_api.h: No such file or directory". +// --------------------------------------------------------------------------- +#if defined(CLANG_TIDY) +// The clang-tidy environment does not carry the full Beken BDK BLE 5.x API +// (its ble_api.h variant lacks parts of the 5.x surface), so there is nothing +// accurate to analyze the SDK calls against — skip the file under analysis. +#define BK72XX_BLE_NO_SDK +#elif !__has_include("ble_api.h") +#error \ + "bk72xx_ble requires a BLE 5.x Beken SDK (ble_api.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported." +#endif + +#ifndef BK72XX_BLE_NO_SDK + +// --------------------------------------------------------------------------- +// Beken BDK BLE 5.x SDK surface used here. Wrapped in extern "C" because these +// are C symbols consumed from C++. +// --------------------------------------------------------------------------- +extern "C" { +#ifdef BK72XX_BLE_HAS_COMMON_BDADDR +#include "common_bt_defines.h" // struct bd_addr +// The controller's public BLE address, populated by the BDK during ble_entry(). +// Present on BK7231N; the other BLE-5.x chips' stacks have no such symbol — there the +// address is derived from the WiFi MAC instead (matching the BDK's own fallback). +extern struct bd_addr common_default_bdaddr; +#endif +// ble_entry() brings up the BDK BLE stack; it is not declared in ble_api.h, so +// declare it here. +void ble_entry(void); +} + +namespace esphome::bk72xx_ble { + +static const char *const TAG = "bk72xx_ble"; + +// --------------------------------------------------------------------------- +// Component lifecycle +// --------------------------------------------------------------------------- + +void BK72xxBLE::setup() { + // Resolve the MAC early so get_mac_lsb_first() is valid for consumers before + // the stack is up (it is re-read once ble_entry() has run). + this->resolve_mac_(); + if (this->enable_on_boot_) { + this->enable(); + } +} + +// AFTER_WIFI, not BLUETOOTH: replicates the proven pre-split timing — the BDK +// is first touched only once WiFi is up (single-core WiFi/BLE bring-up order). +float BK72xxBLE::get_setup_priority() const { return setup_priority::AFTER_WIFI; } + +void BK72xxBLE::enable() { + if (this->state_ != BLEComponentState::STATE_OFF) + return; + this->state_ = BLEComponentState::ENABLING; + + // One-time BLE stack init. The BDK has no teardown path — init happens at most once. + ble_entry(); + + delay(100); // NOLINT — one-time BLE stack init; the SDK needs this settle time + + // Re-read the BLE MAC now that the controller is up (common_default_bdaddr is + // populated by ble_entry()); resolve_mac_() may have fallen back earlier. + this->resolve_mac_(); + +#ifdef BK72XX_BLE_HAS_COMMON_BDADDR + // Liveness heuristic (BK7231N): a healthy ble_entry() populates + // common_default_bdaddr during init, so all-zero after the settle delay + // suggests the stack did not come up. The BDK entry point returns void — no + // return code exists — so warn rather than fail: scan starts against a dead + // stack already fail cleanly downstream (no idle activity handle). + bool bdaddr_live = false; + for (uint8_t b : common_default_bdaddr.addr) { + if (b != 0) { + bdaddr_live = true; + break; + } + } + if (!bdaddr_live) + ESP_LOGW(TAG, "Controller address still unset after init; BLE stack may not have started"); +#endif + + this->state_ = BLEComponentState::ACTIVE; + ESP_LOGD(TAG, "BLE stack initialised"); +} + +void BK72xxBLE::get_mac_lsb_first(uint8_t out[6]) const { + for (int i = 0; i < 6; i++) + out[i] = this->ble_mac_[i]; +} + +void BK72xxBLE::dump_config() { + // ble_mac_ is stored LSB-first (BLE convention); print [5..0] for the + // MSB-first order Home Assistant shows. + ESP_LOGCONFIG(TAG, + "BK72xx BLE:\n" + " MAC address: %02X:%02X:%02X:%02X:%02X:%02X\n" + " Active: %s", + this->ble_mac_[5], this->ble_mac_[4], this->ble_mac_[3], this->ble_mac_[2], this->ble_mac_[1], + this->ble_mac_[0], YESNO(this->is_active())); +} + +// --------------------------------------------------------------------------- +// MAC resolution +// --------------------------------------------------------------------------- + +void BK72xxBLE::resolve_mac_() { +#ifdef BK72XX_BLE_HAS_COMMON_BDADDR + // BK7231N: the BDK populates common_default_bdaddr (LSB-first, BLE convention) + // during ble_entry(). It may still be zero before the stack is up; if so, fall + // through to the WiFi-derived MAC below. + bool nonzero = false; + for (uint8_t b : common_default_bdaddr.addr) { + if (b != 0) { + nonzero = true; + break; + } + } + if (nonzero) { + memcpy(this->ble_mac_, common_default_bdaddr.addr, 6); + return; + } +#endif + // Chips whose BLE stack does not export common_default_bdaddr (BK7238 and the other + // BLE-5.x SoCs), or BK7231N before the stack is up: derive the BLE MAC exactly as the + // Beken BDK does in bdaddr_env_init() — the WiFi STA MAC with only its last byte + // incremented (sta_mac[5] += 1, a plain byte increment with no carry into the next + // byte), OUI unchanged. This reproduces the address the controller advertises with + // (verified against the BK7231N BLE-5.1 and BK7252N/BK7238 BLE-5.2 SDK sources), so it + // matches on every device, including the last-byte == 0xFF edge that a 24-bit increment + // would carry differently. + uint8_t wifi_mac[6]; + get_mac_address_raw(wifi_mac); // MSB-first + const uint8_t ble[6] = {wifi_mac[0], wifi_mac[1], wifi_mac[2], + wifi_mac[3], wifi_mac[4], static_cast(wifi_mac[5] + 1)}; + // Store LSB-first to match the BLE controller's address ordering. + for (int i = 0; i < 6; i++) + this->ble_mac_[i] = ble[5 - i]; +} + +} // namespace esphome::bk72xx_ble + +#endif // BK72XX_BLE_NO_SDK +#endif // USE_BK72XX_BLE diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.h b/esphome/components/bk72xx_ble/bk72xx_ble.h new file mode 100644 index 0000000000..a327f7cbd8 --- /dev/null +++ b/esphome/components/bk72xx_ble/bk72xx_ble.h @@ -0,0 +1,44 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_BK72XX_BLE + +#include "esphome/core/component.h" + +#include + +namespace esphome::bk72xx_ble { + +enum class BLEComponentState : uint8_t { + STATE_OFF = 0, + ENABLING, + ACTIVE, +}; + +class BK72xxBLE final : public Component { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override; + + /// Bring up the BDK BLE stack (one-time; the BDK has no teardown path). + void enable(); + bool is_active() const { return this->state_ == BLEComponentState::ACTIVE; } + + void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } + + /// Controller BLE address, least-significant octet first (BLE convention). + void get_mac_lsb_first(uint8_t out[6]) const; + + protected: + void resolve_mac_(); + + uint8_t ble_mac_[6]{0}; // LSB-first (BLE convention) + BLEComponentState state_{BLEComponentState::STATE_OFF}; + bool enable_on_boot_{false}; +}; + +} // namespace esphome::bk72xx_ble + +#endif // USE_BK72XX_BLE diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 62cef331fd..97c0fd455b 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -580,8 +580,13 @@ async def component_to_code(config): cg.add_platformio_option("custom_fw_name", "esphome") cg.add_platformio_option("custom_fw_version", __version__) - # Apply chip-specific SDK options to save RAM/Flash - if config[CONF_FAMILY] in (FAMILY_BK7231N, FAMILY_BK7238): + # Apply chip-specific SDK options to save RAM/Flash. + # Skipped when bk72xx_ble is configured: add_platformio_option APPENDS list + # values (it never replaces), so emitting the disable here as well would put + # both CFG_SUPPORT_BLE=0 and =1 into the generated sys_config.h and rely on + # last-wins emission order. Skipping keeps it a single unambiguous define. + ble_requested = "bk72xx_ble" in CORE.config + if config[CONF_FAMILY] in (FAMILY_BK7231N, FAMILY_BK7238) and not ble_requested: cg.add_platformio_option( "custom_options.sys_config#h", _BLE5_BK_SYS_CONFIG_OPTIONS ) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index ca1d22bf3e..03175bf2cc 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -446,6 +446,7 @@ #endif #ifdef USE_LIBRETINY +#define USE_BK72XX_BLE #define USE_CAPTIVE_PORTAL #define USE_SOCKET_IMPL_LWIP_SOCKETS #define USE_LWIP_FAST_SELECT diff --git a/tests/components/bk72xx_ble/common.yaml b/tests/components/bk72xx_ble/common.yaml new file mode 100644 index 0000000000..5ada71a141 --- /dev/null +++ b/tests/components/bk72xx_ble/common.yaml @@ -0,0 +1,2 @@ +bk72xx_ble: + enable_on_boot: true diff --git a/tests/components/bk72xx_ble/validate.bk72xx-ard.yaml b/tests/components/bk72xx_ble/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..e5009aa940 --- /dev/null +++ b/tests/components/bk72xx_ble/validate.bk72xx-ard.yaml @@ -0,0 +1,2 @@ +packages: + bk72xx_ble: !include common.yaml From f0800336b8e7decf4fecd78e53e034dc97676e64 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:02:31 -1000 Subject: [PATCH 1051/1815] Bump bundled esphome-device-builder to 1.6.10 (#17801) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 8649dbcd77..638bbdbf9e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.6.9 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.10 RUN \ platformio settings set enable_telemetry No \ From a210c27d141ea411e3a6bc2a776cff88799cefc5 Mon Sep 17 00:00:00 2001 From: Johan Henkens Date: Thu, 23 Jul 2026 16:58:39 -0700 Subject: [PATCH 1052/1815] [api][climate][water_heater][core] Add temperature unit support to climate and water heater c++ entities (#16477) Co-authored-by: Claude Sonnet 4.6 --- esphome/components/api/api_connection.cpp | 2 ++ esphome/components/climate/__init__.py | 2 +- esphome/components/climate/climate_traits.h | 4 ++++ esphome/components/water_heater/__init__.py | 2 +- esphome/components/water_heater/water_heater.h | 4 ++++ esphome/core/helpers.h | 6 ++++++ 6 files changed, 18 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 1f7f59128a..c61daf539a 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -794,6 +794,7 @@ uint16_t APIConnection::try_send_climate_info(EntityBase *entity, APIConnection msg.supports_action = traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION); // Current feature flags and other supported parameters msg.feature_flags = traits.get_feature_flags(); + msg.temperature_unit = static_cast(traits.get_temperature_unit()); msg.supported_modes = &traits.get_supported_modes(); msg.visual_min_temperature = traits.get_visual_min_temperature(); msg.visual_max_temperature = traits.get_visual_max_temperature(); @@ -1468,6 +1469,7 @@ uint16_t APIConnection::try_send_water_heater_info(EntityBase *entity, APIConnec msg.target_temperature_step = traits.get_target_temperature_step(); msg.supported_modes = &traits.get_supported_modes(); msg.supported_features = traits.get_feature_flags(); + msg.temperature_unit = static_cast(traits.get_temperature_unit()); return fill_and_encode_entity_info(wh, msg, conn, remaining_size); } diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index fc1b0f368e..fe050fca22 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -274,7 +274,7 @@ def climate_schema( @setup_entity("climate") async def setup_climate_core_(var, config): - visual = config[CONF_VISUAL] + visual = config.get(CONF_VISUAL, {}) if (min_temp := visual.get(CONF_MIN_TEMPERATURE)) is not None: cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES") cg.add(var.set_visual_min_temperature_override(min_temp)) diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index 599894c8a9..6c776e0228 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -205,6 +205,9 @@ class ClimateTraits { float get_visual_max_humidity() const { return this->visual_max_humidity_; } void set_visual_max_humidity(float visual_max_humidity) { this->visual_max_humidity_ = visual_max_humidity; } + TemperatureUnit get_temperature_unit() const { return this->temperature_unit_; } + void set_temperature_unit(TemperatureUnit unit) { this->temperature_unit_ = unit; } + protected: void set_mode_support_(climate::ClimateMode mode, bool supported) { if (supported) { @@ -274,6 +277,7 @@ class ClimateTraits { climate::ClimateFanModeMask supported_fan_modes_; climate::ClimateSwingModeMask supported_swing_modes_; climate::ClimatePresetMask supported_presets_; + TemperatureUnit temperature_unit_{TemperatureUnit::CELSIUS}; /** Custom mode storage - pointers to vectors owned by the Climate base class. * diff --git a/esphome/components/water_heater/__init__.py b/esphome/components/water_heater/__init__.py index f3eec16a40..6bb2b2f8fe 100644 --- a/esphome/components/water_heater/__init__.py +++ b/esphome/components/water_heater/__init__.py @@ -76,7 +76,7 @@ def water_heater_schema( @setup_entity("water_heater") async def setup_water_heater_core_(var: cg.Pvariable, config: ConfigType) -> None: """Set up the core water heater properties in C++.""" - visual = config[CONF_VISUAL] + visual = config.get(CONF_VISUAL, {}) if (min_temp := visual.get(CONF_MIN_TEMPERATURE)) is not None: cg.add_define("USE_WATER_HEATER_VISUAL_OVERRIDES") cg.add(var.set_visual_min_temperature_override(min_temp)) diff --git a/esphome/components/water_heater/water_heater.h b/esphome/components/water_heater/water_heater.h index dfec26859f..995b815440 100644 --- a/esphome/components/water_heater/water_heater.h +++ b/esphome/components/water_heater/water_heater.h @@ -183,6 +183,9 @@ class WaterHeaterTraits { const WaterHeaterModeMask &get_supported_modes() const { return this->supported_modes_; } bool supports_mode(WaterHeaterMode mode) const { return this->supported_modes_.count(mode); } + TemperatureUnit get_temperature_unit() const { return this->temperature_unit_; } + void set_temperature_unit(TemperatureUnit unit) { this->temperature_unit_ = unit; } + protected: // Ordered to minimize padding: 4-byte members first uint32_t feature_flags_{0}; @@ -190,6 +193,7 @@ class WaterHeaterTraits { float max_temperature_{0.0f}; float target_temperature_step_{0.0f}; WaterHeaterModeMask supported_modes_; + TemperatureUnit temperature_unit_{TemperatureUnit::CELSIUS}; }; class WaterHeater : public EntityBase { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index e862d015da..b897b927e2 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1625,6 +1625,12 @@ constexpr float celsius_to_fahrenheit(float value) { return value * 1.8f + 32.0f /// Convert degrees Fahrenheit to degrees Celsius. constexpr float fahrenheit_to_celsius(float value) { return (value - 32.0f) / 1.8f; } +enum class TemperatureUnit : uint8_t { + CELSIUS = 0, + FAHRENHEIT = 1, + KELVIN = 2, +}; + ///@} /// @name Utilities From e224a781ff2e312df55cfc03c78c3ad871b9c62a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcus=20Vo=C3=9F?= <46140304+mvoss96@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:47:27 +0200 Subject: [PATCH 1053/1815] [esp32_ble] Forward ESP_GAP_BLE_SCAN_RSP_DATA_RAW_SET_COMPLETE_EVT to GAP handlers (#17833) --- esphome/components/esp32_ble/ble.cpp | 1 + esphome/components/esp32_ble/ble_event.h | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index a2d19f1042..fb75e8837f 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -58,6 +58,7 @@ static constexpr uint32_t HOSTED_BT_WDT_TIMEOUT_MS = 60000; case ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT: \ case ESP_GAP_BLE_SCAN_RSP_DATA_SET_COMPLETE_EVT: \ case ESP_GAP_BLE_ADV_DATA_RAW_SET_COMPLETE_EVT: \ + case ESP_GAP_BLE_SCAN_RSP_DATA_RAW_SET_COMPLETE_EVT: \ case ESP_GAP_BLE_ADV_START_COMPLETE_EVT: \ case ESP_GAP_BLE_ADV_STOP_COMPLETE_EVT diff --git a/esphome/components/esp32_ble/ble_event.h b/esphome/components/esp32_ble/ble_event.h index ba87fd8805..babfa937c7 100644 --- a/esphome/components/esp32_ble/ble_event.h +++ b/esphome/components/esp32_ble/ble_event.h @@ -207,7 +207,7 @@ class BLEEvent { StatusOnlyData scan_complete; // 1 byte // Advertising complete events all have same structure // Used by: esp32_ble_beacon, esp32_ble server components - // ADV_DATA_SET, SCAN_RSP_DATA_SET, ADV_DATA_RAW_SET, ADV_START, ADV_STOP + // ADV_DATA_SET, SCAN_RSP_DATA_SET, ADV_DATA_RAW_SET, SCAN_RSP_DATA_RAW_SET, ADV_START, ADV_STOP StatusOnlyData adv_complete; // 1 byte // RSSI complete event // Used by: ble_client (ble_rssi_sensor component) @@ -324,6 +324,9 @@ class BLEEvent { case ESP_GAP_BLE_ADV_DATA_RAW_SET_COMPLETE_EVT: // Used by: esp32_ble_beacon this->event_.gap.adv_complete.status = p->adv_data_raw_cmpl.status; break; + case ESP_GAP_BLE_SCAN_RSP_DATA_RAW_SET_COMPLETE_EVT: // Used by: raw advertisers with scan response + this->event_.gap.adv_complete.status = p->scan_rsp_data_raw_cmpl.status; + break; case ESP_GAP_BLE_ADV_START_COMPLETE_EVT: // Used by: esp32_ble_beacon this->event_.gap.adv_complete.status = p->adv_start_cmpl.status; break; From d12300679eb9928b99c717a32f1488e19bafec9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Fri, 24 Jul 2026 13:26:33 +0300 Subject: [PATCH 1054/1815] [bk72xx_ble] Scan primitives and main-task scan report queue (#17802) Co-authored-by: J. Nick Koston --- esphome/components/bk72xx_ble/bk72xx_ble.cpp | 134 ++++++++++++++++-- esphome/components/bk72xx_ble/bk72xx_ble.h | 54 +++++++ esphome/core/event_pool.h | 4 +- esphome/core/lock_free_queue.h | 56 +++++++- .../components/core/test_lock_free_queue.cpp | 96 +++++++++++++ 5 files changed, 330 insertions(+), 14 deletions(-) create mode 100644 tests/components/core/test_lock_free_queue.cpp diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.cpp b/esphome/components/bk72xx_ble/bk72xx_ble.cpp index db6b665ba6..a5ecaf4abb 100644 --- a/esphome/components/bk72xx_ble/bk72xx_ble.cpp +++ b/esphome/components/bk72xx_ble/bk72xx_ble.cpp @@ -1,15 +1,22 @@ // bk72xx_ble.cpp // // BLE controller support for the BK72xx BLE-5.x chips (LibreTiny beken-72xx -// family) — the platform analog of esp32_ble / rp2040_ble. Owns the Beken BDK -// BLE stack bring-up (ble_entry()) and the controller BLE address. Consumers -// (bk72xx_ble_tracker) build on this component and contain no SDK calls of -// their own. +// family) — the platform analog of esp32_ble / rp2040_ble. Owns everything that +// talks to the Beken BDK BLE stack: +// - one-time stack bring-up (ble_set_notice_cb() + ble_entry()), +// - the controller BLE address, +// - the raw controller scan primitives (bk_ble_scan_start/stop), +// - the scan-report ring: the BDK notice callback (BLE task) takes a report +// from a fixed pool and pushes it on a lock-free SPSC queue; loop() drains, +// dispatches on the main task and returns reports to the pool — the same +// EventPool + LockFreeQueue handoff esp32_ble uses, zero allocation at +// steady state. +// Consumers contain no SDK calls of their own. // // NOTE: the Beken BDK BLE 5.x stack is compiled and linked by the LibreTiny // beken-72xx builder itself (prebuilt libble_.a + ble_5_x sources, gated // on CFG_SUPPORT_BLE / CFG_BLE_VERSION in sys_config.h). This component only -// calls into it via its public API — no framework patch is required. +// calls into it via the public ble_api.h — no framework patch is required. #include "bk72xx_ble.h" // pulls esphome/core/defines.h for USE_BK72XX_BLE @@ -44,10 +51,15 @@ #ifndef BK72XX_BLE_NO_SDK // --------------------------------------------------------------------------- -// Beken BDK BLE 5.x SDK surface used here. Wrapped in extern "C" because these -// are C symbols consumed from C++. +// Beken BDK BLE 5.x SDK — public API. +// Exposed on the include path by the LibreTiny beken-72xx builder +// (cores/.../ble_5_x_rw + driver/include). Wrapped in extern "C" because these +// are C headers consumed from C++ (a standard C-header-from-C++ pattern). // --------------------------------------------------------------------------- extern "C" { +#include "ble_api.h" // bk_ble_scan_start/stop, ble_entry, ble_set_notice_cb, + // app_ble_get_idle_actv_idx_handle, struct scan_param, + // recv_adv_t, ble_notice_t, BLE_5_REPORT_ADV, SCAN_ACTV #ifdef BK72XX_BLE_HAS_COMMON_BDADDR #include "common_bt_defines.h" // struct bd_addr // The controller's public BLE address, populated by the BDK during ble_entry(). @@ -64,11 +76,53 @@ namespace esphome::bk72xx_ble { static const char *const TAG = "bk72xx_ble"; +// The BDK notice callback is a plain C function pointer with no user argument, +// so it reaches the (single) component instance through a file-static pointer. +static BK72xxBLE *s_ble = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +// --------------------------------------------------------------------------- +// BLE notice callback — runs in the BDK BLE task context. +// The BK controller reports every advertisement as a BLE_5_REPORT_ADV notice +// carrying a recv_adv_t. Copy it into the queue and return; all dispatch +// happens in loop() on the main task. +// --------------------------------------------------------------------------- +static void ble_notice_callback(ble_notice_t notice, void *param) { + if (s_ble == nullptr || param == nullptr) + return; + if (notice != BLE_5_REPORT_ADV) + return; + + const recv_adv_t *info = reinterpret_cast(param); + // rssi is a signed dBm carried in a uint8_t; cast through int8_t (standard for + // a signed dBm value packed in a uint8_t). + s_ble->enqueue_scan_report(info->adv_addr, static_cast(info->rssi), info->adv_addr_type, info->data, + info->data_len); +} + +void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, + uint16_t data_len) { + BLEScanReport *report = this->report_pool_.allocate(); + if (report == nullptr) { + // Pool exhausted — the queue is full; count and drop. + this->report_queue_.increment_dropped_count(); + return; + } + memcpy(report->mac, mac, 6); + report->rssi = rssi; + report->addr_type = addr_type; + report->data_len = + (data_len <= sizeof(report->data)) ? static_cast(data_len) : static_cast(sizeof(report->data)); + memcpy(report->data, data, report->data_len); + // Cannot fail: the pool is sized to the queue capacity. + this->report_queue_.push(report); +} + // --------------------------------------------------------------------------- // Component lifecycle // --------------------------------------------------------------------------- void BK72xxBLE::setup() { + s_ble = this; // Resolve the MAC early so get_mac_lsb_first() is valid for consumers before // the stack is up (it is re-read once ble_entry() has run). this->resolve_mac_(); @@ -86,7 +140,9 @@ void BK72xxBLE::enable() { return; this->state_ = BLEComponentState::ENABLING; - // One-time BLE stack init. The BDK has no teardown path — init happens at most once. + // One-time BLE stack init: register the notice callback, then bring up the + // BDK BLE stack. The BDK has no teardown path — init happens at most once. + ble_set_notice_cb(ble_notice_callback); ble_entry(); delay(100); // NOLINT — one-time BLE stack init; the SDK needs this settle time @@ -116,6 +172,25 @@ void BK72xxBLE::enable() { ESP_LOGD(TAG, "BLE stack initialised"); } +void BK72xxBLE::loop() { + // Drain the lock-free ring filled by the BLE task; all per-report work runs + // here on the main task, then the report returns to the pool. + BLEScanReport *report = this->report_queue_.pop(); + if (report == nullptr) + return; + do { + for (auto *listener : this->scan_listeners_) + listener->on_scan_report(*report); + this->report_pool_.release(report); + } while ((report = this->report_queue_.pop()) != nullptr); + + // Log dropped reports — only reachable when reports were processed; drops can + // only occur while the queue is full, and only this loop drains it. + uint16_t dropped = this->report_queue_.get_and_reset_dropped_count(); + if (dropped > 0) + ESP_LOGW(TAG, "Dropped %u scan reports due to queue overflow", dropped); +} + void BK72xxBLE::get_mac_lsb_first(uint8_t out[6]) const { for (int i = 0; i < 6; i++) out[i] = this->ble_mac_[i]; @@ -165,11 +240,52 @@ void BK72xxBLE::resolve_mac_() { get_mac_address_raw(wifi_mac); // MSB-first const uint8_t ble[6] = {wifi_mac[0], wifi_mac[1], wifi_mac[2], wifi_mac[3], wifi_mac[4], static_cast(wifi_mac[5] + 1)}; - // Store LSB-first to match the BLE controller's address ordering. + // Store LSB-first to match recv_adv_t adv_addr ordering. for (int i = 0; i < 6; i++) this->ble_mac_[i] = ble[5 - i]; } +// --------------------------------------------------------------------------- +// Controller scan primitives +// --------------------------------------------------------------------------- + +bool BK72xxBLE::scan_start(uint16_t interval, uint16_t window) { + if (!this->is_active()) + this->enable(); + + if (this->scan_actv_idx_ != 0xFF) { + // Already scanning — stop first so this call cleanly restarts with the new + // parameters (the BDK cannot start a second scan on a busy activity). + this->scan_stop(); + } + + struct scan_param sp; + memset(&sp, 0, sizeof(sp)); + sp.channel_map = 7; // advertising channels 37/38/39 + sp.interval = interval; + sp.window = window; + + this->scan_actv_idx_ = app_ble_get_idle_actv_idx_handle(SCAN_ACTV); + if (this->scan_actv_idx_ == 0xFF) { + ESP_LOGE(TAG, "Scan start failed: no idle activity handle"); + return false; + } + ble_err_t ret = bk_ble_scan_start(this->scan_actv_idx_, &sp, nullptr); + if (ret != ERR_SUCCESS) { + ESP_LOGE(TAG, "Scan start failed (err %d)", static_cast(ret)); + this->scan_actv_idx_ = 0xFF; + return false; + } + return true; +} + +void BK72xxBLE::scan_stop() { + if (this->scan_actv_idx_ != 0xFF) { + bk_ble_scan_stop(this->scan_actv_idx_, nullptr); + this->scan_actv_idx_ = 0xFF; + } +} + } // namespace esphome::bk72xx_ble #endif // BK72XX_BLE_NO_SDK diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.h b/esphome/components/bk72xx_ble/bk72xx_ble.h index a327f7cbd8..2654f4e68e 100644 --- a/esphome/components/bk72xx_ble/bk72xx_ble.h +++ b/esphome/components/bk72xx_ble/bk72xx_ble.h @@ -5,8 +5,11 @@ #ifdef USE_BK72XX_BLE #include "esphome/core/component.h" +#include "esphome/core/event_pool.h" +#include "esphome/core/lock_free_queue.h" #include +#include namespace esphome::bk72xx_ble { @@ -16,9 +19,37 @@ enum class BLEComponentState : uint8_t { ACTIVE, }; +/// One advertisement report from the controller. +struct BLEScanReport { + uint8_t mac[6]; // LSB-first, as the controller delivers it + int8_t rssi; // signed dBm + uint8_t addr_type; + uint8_t data_len; // bytes valid in data[] + uint8_t data[62]; // legacy advertisement (31) + scan response (31) + + // EventPool contract: nothing is heap-allocated inside a report. + void release() {} +}; + +/// Consumer interface for controller scan reports. on_scan_report() always runs +/// on the ESPHome main task: reports are queued from the BDK BLE task and +/// drained by the controller's loop(), so consumers never deal with cross-task +/// state (the esp32_ble event-queue pattern). +class BLEScanListener { + public: + virtual void on_scan_report(const BLEScanReport &report) = 0; + + protected: + ~BLEScanListener() = default; // deletion via this interface is not part of the contract +}; + +// Maximum reports buffered between the BLE task and loop(). +static constexpr uint8_t MAX_SCAN_REPORT_QUEUE_SIZE = 64; + class BK72xxBLE final : public Component { public: void setup() override; + void loop() override; void dump_config() override; float get_setup_priority() const override; @@ -31,10 +62,33 @@ class BK72xxBLE final : public Component { /// Controller BLE address, least-significant octet first (BLE convention). void get_mac_lsb_first(uint8_t out[6]) const; + /// Register a consumer for scan reports (delivered on the main task via loop()). + void register_scan_listener(BLEScanListener *listener) { this->scan_listeners_.push_back(listener); } + + /// Start the controller scan. Interval/window are in BLE units (0.625 ms). + /// Enables the stack first if needed. Returns false on controller failure. + bool scan_start(uint16_t interval, uint16_t window); + /// Stop the controller scan (no-op when not scanning). + void scan_stop(); + + /// Internal: buffer one controller report (BDK notice callback, BLE task + /// context — bounded copy under the scheduler lock, nothing else). + void enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint16_t data_len); + protected: void resolve_mac_(); + std::vector scan_listeners_; + // Report ring: the BDK notice callback (BLE task) allocates a report from the + // pool, fills it and pushes the pointer; loop() pops, dispatches and releases. + // Lock-free SPSC, zero allocation at steady state — the esp32_ble pattern. + esphome::LockFreeQueue report_queue_; + // Pool sized to queue capacity (SIZE-1): the ring reserves one slot, so + // allocate() returns nullptr before push() can fail. This prevents leaking a + // pool slot on a failed push and keeps release() off the producer path. + esphome::EventPool report_pool_; uint8_t ble_mac_[6]{0}; // LSB-first (BLE convention) + uint8_t scan_actv_idx_{0xFF}; BLEComponentState state_{BLEComponentState::STATE_OFF}; bool enable_on_boot_{false}; }; diff --git a/esphome/core/event_pool.h b/esphome/core/event_pool.h index ee8e81225a..55c9254327 100644 --- a/esphome/core/event_pool.h +++ b/esphome/core/event_pool.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ESP32) || defined(USE_ZEPHYR) +#if defined(USE_ESP32) || defined(USE_ZEPHYR) || defined(USE_LIBRETINY) #include #include @@ -86,4 +86,4 @@ template class EventPool { } // namespace esphome -#endif // defined(USE_ESP32) +#endif // defined(USE_ESP32) || defined(USE_ZEPHYR) || defined(USE_LIBRETINY) diff --git a/esphome/core/lock_free_queue.h b/esphome/core/lock_free_queue.h index 316186ea54..ce54231137 100644 --- a/esphome/core/lock_free_queue.h +++ b/esphome/core/lock_free_queue.h @@ -1,5 +1,7 @@ #pragma once +#include "esphome/core/defines.h" + #include #include @@ -26,6 +28,54 @@ namespace esphome { +namespace lockfree_internal { +#ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS +// Platforms whose cores lack atomic read-modify-write instructions (currently +// the ARMv5TE BK72xx SoCs — no LDREX/STREX, no libatomic; other LibreTiny +// chips such as LN882x/RTL87xx are ARMv7-M and keep std::atomic). For this +// queue's SPSC contract RMW atomics are not needed: aligned 8/16-bit loads and +// stores are single instructions on these cores, so torn reads cannot occur, +// and on a single in-order core a compiler barrier supplies all the +// acquire/release ordering the algorithm requires. Each index has exactly one +// writer (head_: consumer, tail_: producer). The dropped counter's +// increment/exchange pair is not atomic here — a concurrent reset can lose +// counts — which is acceptable for a diagnostic drop counter. +#define ESPHOME_LFQ_COMPILER_BARRIER() __asm__ __volatile__("" ::: "memory") +template class PlainAtomic { + public: + PlainAtomic() = default; + constexpr PlainAtomic(T value) : value_(value) {} + T load(std::memory_order order = std::memory_order_seq_cst) const { + T value = value_; + if (order != std::memory_order_relaxed) + ESPHOME_LFQ_COMPILER_BARRIER(); // acquire: later reads may not hoist above this load + return value; + } + void store(T value, std::memory_order order = std::memory_order_seq_cst) { + if (order != std::memory_order_relaxed) + ESPHOME_LFQ_COMPILER_BARRIER(); // release: earlier writes may not sink below this store + value_ = value; + } + T fetch_add(T amount, std::memory_order /*order*/ = std::memory_order_seq_cst) { + T value = value_; + value_ = value + amount; + return value; + } + T exchange(T desired, std::memory_order /*order*/ = std::memory_order_seq_cst) { + T value = value_; + value_ = desired; + return value; + } + + private: + volatile T value_{0}; +}; +template using AtomicIndex = PlainAtomic; +#else +template using AtomicIndex = std::atomic; +#endif +} // namespace lockfree_internal + // Base lock-free queue without task notification template class LockFreeQueue { public: @@ -126,13 +176,13 @@ template class LockFreeQueue { protected: T *buffer_[SIZE]{}; // Atomic: written by producer (push/increment), read+reset by consumer (get_and_reset) - std::atomic dropped_count_; // 65535 max - more than enough for drop tracking + lockfree_internal::AtomicIndex dropped_count_; // 65535 max - more than enough for drop tracking // Atomic: written by consumer (pop), read by producer (push) to check if full // Using uint8_t limits queue size to 255 elements but saves memory and ensures // atomic operations are efficient on all platforms - std::atomic head_; + lockfree_internal::AtomicIndex head_; // Atomic: written by producer (push), read by consumer (pop) to check if empty - std::atomic tail_; + lockfree_internal::AtomicIndex tail_; }; #ifdef USE_ESP32 diff --git a/tests/components/core/test_lock_free_queue.cpp b/tests/components/core/test_lock_free_queue.cpp new file mode 100644 index 0000000000..2f74278129 --- /dev/null +++ b/tests/components/core/test_lock_free_queue.cpp @@ -0,0 +1,96 @@ +// Exercises the no-atomics LockFreeQueue implementation (PlainAtomic indices — +// the path used on cores without atomic RMW instructions, currently BK72xx). +// The define is forced before the include so this TU deterministically compiles +// that path regardless of the host's default thread model; no other test TU +// instantiates this template, so the differing definition is confined here. +#define ESPHOME_THREAD_MULTI_NO_ATOMICS +#include "esphome/core/lock_free_queue.h" + +#include + +namespace esphome::core::testing { + +TEST(LockFreeQueueNoAtomics, EmptyPopReturnsNull) { + esphome::LockFreeQueue q; + EXPECT_EQ(q.pop(), nullptr); + EXPECT_TRUE(q.empty()); + EXPECT_FALSE(q.full()); + EXPECT_EQ(q.size(), 0u); +} + +TEST(LockFreeQueueNoAtomics, FifoOrder) { + esphome::LockFreeQueue q; + int a = 1, b = 2, c = 3; + EXPECT_TRUE(q.push(&a)); + EXPECT_TRUE(q.push(&b)); + EXPECT_TRUE(q.push(&c)); + EXPECT_EQ(q.size(), 3u); + EXPECT_EQ(q.pop(), &a); + EXPECT_EQ(q.pop(), &b); + EXPECT_EQ(q.pop(), &c); + EXPECT_EQ(q.pop(), nullptr); +} + +TEST(LockFreeQueueNoAtomics, CapacityIsSizeMinusOne) { + esphome::LockFreeQueue q; + int v[4] = {0, 1, 2, 3}; + EXPECT_TRUE(q.push(&v[0])); + EXPECT_TRUE(q.push(&v[1])); + EXPECT_TRUE(q.push(&v[2])); + EXPECT_TRUE(q.full()); + // Ring reserves one slot: the SIZEth push fails and is counted as dropped. + EXPECT_FALSE(q.push(&v[3])); + EXPECT_EQ(q.get_and_reset_dropped_count(), 1u); + EXPECT_EQ(q.get_and_reset_dropped_count(), 0u); // reset is sticky +} + +TEST(LockFreeQueueNoAtomics, NullPushRejected) { + esphome::LockFreeQueue q; + EXPECT_FALSE(q.push(nullptr)); + EXPECT_TRUE(q.empty()); +} + +TEST(LockFreeQueueNoAtomics, WrapAround) { + esphome::LockFreeQueue q; + int v[3] = {10, 20, 30}; + // Cycle several times the ring size to cross the wrap boundary repeatedly. + for (int cycle = 0; cycle < 10; cycle++) { + for (auto &value : v) + ASSERT_TRUE(q.push(&value)); + EXPECT_TRUE(q.full()); + for (auto &value : v) + ASSERT_EQ(q.pop(), &value); + EXPECT_TRUE(q.empty()); + } + EXPECT_EQ(q.get_and_reset_dropped_count(), 0u); +} + +TEST(LockFreeQueueNoAtomics, IncrementDroppedCount) { + esphome::LockFreeQueue q; + // Producer-side external drop accounting (pool exhausted before push). + q.increment_dropped_count(); + q.increment_dropped_count(); + EXPECT_EQ(q.get_and_reset_dropped_count(), 2u); +} + +TEST(LockFreeQueueNoAtomics, InterleavedPushPop) { + esphome::LockFreeQueue q; + int v[64]; + int popped = 0; + for (int i = 0; i < 64; i++) { + v[i] = i; + ASSERT_TRUE(q.push(&v[i])); + if (i % 2 == 1) { + int *first = q.pop(); + ASSERT_NE(first, nullptr); + EXPECT_EQ(*first, popped++); + int *second = q.pop(); + ASSERT_NE(second, nullptr); + EXPECT_EQ(*second, popped++); + } + } + EXPECT_TRUE(q.empty()); + EXPECT_EQ(popped, 64); +} + +} // namespace esphome::core::testing From 5e2d428e6993cc48f26b57848b53dea783992d19 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Fri, 24 Jul 2026 14:45:59 -0700 Subject: [PATCH 1055/1815] [modbus] Heap-free response path (#17377) Co-authored-by: Claude Fable 5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../growatt_solar/growatt_solar.cpp | 3 +- .../components/growatt_solar/growatt_solar.h | 4 +- .../havells_solar/havells_solar.cpp | 3 +- .../components/havells_solar/havells_solar.h | 4 +- esphome/components/kuntze/kuntze.cpp | 3 +- esphome/components/kuntze/kuntze.h | 4 +- esphome/components/modbus/modbus.cpp | 48 ++++++---- esphome/components/modbus/modbus.h | 42 +++++++-- esphome/components/modbus/modbus_helpers.h | 16 ++++ .../modbus_controller/modbus_controller.cpp | 15 ++- .../modbus_controller/modbus_controller.h | 5 +- esphome/components/pzemac/pzemac.cpp | 3 +- esphome/components/pzemac/pzemac.h | 4 +- esphome/components/pzemdc/pzemdc.cpp | 3 +- esphome/components/pzemdc/pzemdc.h | 4 +- esphome/components/sdm_meter/sdm_meter.cpp | 3 +- esphome/components/sdm_meter/sdm_meter.h | 4 +- .../components/selec_meter/selec_meter.cpp | 3 +- esphome/components/selec_meter/selec_meter.h | 4 +- tests/components/modbus/heap_probe_test.cpp | 91 +++++++++++++++++++ .../modbus/modbus_client_hub_test.cpp | 47 ++++++++-- .../components/modbus/modbus_helpers_test.cpp | 17 ++++ 22 files changed, 267 insertions(+), 63 deletions(-) diff --git a/esphome/components/growatt_solar/growatt_solar.cpp b/esphome/components/growatt_solar/growatt_solar.cpp index fc35271017..6485e90c25 100644 --- a/esphome/components/growatt_solar/growatt_solar.cpp +++ b/esphome/components/growatt_solar/growatt_solar.cpp @@ -35,7 +35,8 @@ void GrowattSolar::update() { this->last_send_ = millis(); } -void GrowattSolar::on_modbus_data(const std::vector &data) { +void GrowattSolar::on_response(std::span request_pdu, std::span response_pdu) { + auto data = modbus::helpers::server_pdu_payload(response_pdu); // Other components might be sending commands to our device. But we don't get called with enough // context to know what is what. So if we didn't do a send, we ignore the data. if (!this->last_send_) diff --git a/esphome/components/growatt_solar/growatt_solar.h b/esphome/components/growatt_solar/growatt_solar.h index 18a7c917d5..a172f49001 100644 --- a/esphome/components/growatt_solar/growatt_solar.h +++ b/esphome/components/growatt_solar/growatt_solar.h @@ -4,7 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/modbus/modbus.h" -#include +#include namespace esphome::growatt_solar { @@ -69,7 +69,7 @@ class GrowattSolar final : public PollingComponent, public modbus::ModbusClientD public: void loop() override; void update() override; - void on_modbus_data(const std::vector &data) override; + void on_response(std::span request_pdu, std::span response_pdu) override; void dump_config() override; void set_protocol_version(GrowattProtocolVersion protocol_version) { this->protocol_version_ = protocol_version; } diff --git a/esphome/components/havells_solar/havells_solar.cpp b/esphome/components/havells_solar/havells_solar.cpp index 9257a37fd9..45e57544db 100644 --- a/esphome/components/havells_solar/havells_solar.cpp +++ b/esphome/components/havells_solar/havells_solar.cpp @@ -10,7 +10,8 @@ static const char *const TAG = "havells_solar"; static const uint8_t MODBUS_CMD_READ_IN_REGISTERS = 0x03; static const uint8_t MODBUS_REGISTER_COUNT = 48; // 48 x 16-bit registers -void HavellsSolar::on_modbus_data(const std::vector &data) { +void HavellsSolar::on_response(std::span request_pdu, std::span response_pdu) { + auto data = modbus::helpers::server_pdu_payload(response_pdu); if (data.size() < MODBUS_REGISTER_COUNT * 2) { ESP_LOGW(TAG, "Invalid size for HavellsSolar!"); return; diff --git a/esphome/components/havells_solar/havells_solar.h b/esphome/components/havells_solar/havells_solar.h index 02e999c56c..ed5d13b8b6 100644 --- a/esphome/components/havells_solar/havells_solar.h +++ b/esphome/components/havells_solar/havells_solar.h @@ -4,7 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/modbus/modbus.h" -#include +#include namespace esphome::havells_solar { @@ -77,7 +77,7 @@ class HavellsSolar final : public PollingComponent, public modbus::ModbusClientD void update() override; - void on_modbus_data(const std::vector &data) override; + void on_response(std::span request_pdu, std::span response_pdu) override; void dump_config() override; diff --git a/esphome/components/kuntze/kuntze.cpp b/esphome/components/kuntze/kuntze.cpp index 6df114e93c..1475ca61ae 100644 --- a/esphome/components/kuntze/kuntze.cpp +++ b/esphome/components/kuntze/kuntze.cpp @@ -13,7 +13,8 @@ static const uint16_t REGISTER[] = {4136, 4160, 4680, 6000, 4688, 4728, 5832}; // Maximum bytes to log for Modbus responses (2 registers = 4, plus count = 5) static constexpr size_t KUNTZE_MAX_LOG_BYTES = 8; -void Kuntze::on_modbus_data(const std::vector &data) { +void Kuntze::on_response(std::span request_pdu, std::span response_pdu) { + auto data = modbus::helpers::server_pdu_payload(response_pdu); auto get_16bit = [&](int i) -> uint16_t { return (uint16_t(data[i * 2]) << 8) | uint16_t(data[i * 2 + 1]); }; this->waiting_ = false; diff --git a/esphome/components/kuntze/kuntze.h b/esphome/components/kuntze/kuntze.h index 46681843d2..28c8089748 100644 --- a/esphome/components/kuntze/kuntze.h +++ b/esphome/components/kuntze/kuntze.h @@ -4,6 +4,8 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/modbus/modbus.h" +#include + namespace esphome::kuntze { class Kuntze final : public PollingComponent, public modbus::ModbusClientDevice { @@ -19,7 +21,7 @@ class Kuntze final : public PollingComponent, public modbus::ModbusClientDevice void loop() override; void update() override; - void on_modbus_data(const std::vector &data) override; + void on_response(std::span request_pdu, std::span response_pdu) override; void dump_config() override; diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index ecb2e4461c..7f90dcd738 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -214,11 +214,10 @@ bool Modbus::parse_modbus_server_frame_() { // Process before clearing: process_modbus_server_frame (receiving a response or peer message) never sends a reply // synchronously. We can safely point directly into rx_buffer_ and avoid a copy. - uint8_t data_offset = helpers::server_frame_data_offset(this->rx_buffer_.data(), this->rx_buffer_.size()); - const uint8_t *data = this->rx_buffer_.data() + data_offset; - uint16_t data_len = frame_length - 2 - data_offset; + // The PDU is the frame without the leading address and the trailing CRC. + std::span pdu(this->rx_buffer_.data() + 1, frame_length - 3); - this->process_modbus_server_frame(address, function_code, data, data_len); + this->process_modbus_server_frame(address, pdu); this->clear_rx_buffer_(LOG_STR("parse succeeded"), false, frame_length); return true; @@ -258,8 +257,16 @@ bool ModbusServerHub::parse_modbus_client_frame_() { return true; } -void ModbusClientHub::process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, - uint16_t len) { +// Bounds contract, enforced by the parser (parse_modbus_server_frame_) rather than locally: +// - pdu is never empty: helpers::server_frame_length() returns at least MIN_FRAME_SIZE (4) on every +// branch, and find_custom_frame_end_() only ever lengthens that, so the PDU (frame minus address +// and CRC) always holds at least the function code. +// - When the exception bit is set, pdu has at least 2 bytes: server_frame_length() checks the +// exception bit before anything else and pins those frames to 5 bytes, so the exception code +// read below is always present. +// Keep those guarantees in mind when changing server_frame_length() or adding callers. +void ModbusClientHub::process_modbus_server_frame(uint8_t address, std::span pdu) { + const uint8_t function_code = pdu[0]; if (!this->waiting_for_response_.has_value()) { ESP_LOGW(TAG, "Received unexpected frame from address %" PRIu8 ", function code 0x%X, %" PRIu32 "ms after last send", @@ -292,20 +299,23 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, uint8_t funct return; } else { // We have a valid device waiting for this response - ModbusClientDevice *device = wfr.device; + // Move the command out of the waiting slot so the request PDU stays alive for the callback. + ModbusDeviceCommand command = std::move(this->waiting_for_response_.value()); this->waiting_for_response_.reset(); + ModbusClientDevice *device = command.device; + // The request PDU is the sent frame without the leading address and the trailing CRC. + std::span request_pdu(command.frame.data.data() + 1, command.frame.size() - 3); // Is it an error response? if (helpers::is_function_code_exception(function_code)) { - uint8_t exception = len > 0 ? data[0] : 0; + uint8_t exception = pdu[1]; // exception frames are fixed-length, so the code is always present ESP_LOGW(TAG, "Error function code: 0x%X exception: %" PRIu8 ", address: %" PRIu8 ", %" PRIu32 "ms after last send", function_code, exception, address, this->last_modbus_byte_ - this->last_send_); if (device) - device->on_modbus_error(function_code & FUNCTION_CODE_MASK, exception); + device->on_error(request_pdu, static_cast(exception)); } else if (device) { // Not an error response - // on_modbus_data is existing public API taking const std::vector& - device->on_modbus_data(std::vector(data, data + len)); + device->on_response(request_pdu, pdu); } else { // Not an error response, but no device to respond to ESP_LOGV(TAG, "Ignoring response from %" PRIu8 " - no callback device set, %" PRIu32 "ms after last send", address, this->last_modbus_byte_ - this->last_send_); @@ -314,7 +324,7 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, uint8_t funct } } -void ModbusServerHub::process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *, uint16_t) { +void ModbusServerHub::process_modbus_server_frame(uint8_t address, std::span) { if (this->find_device_(address) != nullptr) { ESP_LOGE(TAG, "Unexpected response from address %" PRIu8 ", which is mapped to this device.", address); } @@ -503,7 +513,7 @@ void ModbusClientHub::send_next_frame_() { this->waiting_for_response_ = std::move(command); } else { if (command.device) - command.device->on_modbus_not_sent(); + command.device->on_not_sent(); } this->tx_buffer_.pop_front(); @@ -561,11 +571,10 @@ void ModbusServerHub::send_exception_(uint8_t address, uint8_t function_code, Mo this->send_raw_(raw_frame, 3); } -// Raw send for client: pushes to tx queue. Everything except the CRC must be contained in payload. void ModbusClientHub::notify_no_response_(ModbusDeviceCommand &wfr) { if (wfr.device == nullptr) return; - const bool retry = wfr.device->on_modbus_no_response(); + const bool retry = wfr.device->on_no_response(); // The callback may have detached the device (e.g. clear_tx_queue_for_device()); honor the detach // over the retry request rather than re-queueing a frame that can no longer be routed. if (retry && wfr.device != nullptr) @@ -579,17 +588,18 @@ void ModbusClientHub::requeue_waiting_frame_(ModbusDeviceCommand &wfr) { if (this->tx_buffer_.size() >= MODBUS_TX_BUFFER_SIZE) { ESP_LOGE(TAG, "Write buffer full, dropped retry for address %" PRIu8, frame.data.data()[0]); if (wfr.device != nullptr) - wfr.device->on_modbus_not_sent(); + wfr.device->on_not_sent(); return; } // Re-queue a copy (not a move): the waiting entry may have to survive as an interrupted shell. this->tx_buffer_.emplace_back(wfr.device, frame.data.data()[0], frame.data.data() + 1, frame.size() - 3); } +// Raw send for client: pushes to tx queue. Everything except the CRC must be contained in payload. void ModbusClientHub::queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device) { if (pdu_len == 0) { if (device) - device->on_modbus_not_sent(); + device->on_not_sent(); return; } @@ -605,7 +615,7 @@ void ModbusClientHub::queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t p #endif ESP_LOGE(TAG, "Write buffer full, dropped: %" PRIu8 ":%s", address, format_hex_pretty_to(hex_buf, pdu, pdu_len)); if (device) - device->on_modbus_not_sent(); + device->on_not_sent(); } } @@ -644,7 +654,7 @@ void ModbusClientHub::clear_tx_queue_for_device(ModbusClientDevice *device) { void ModbusClientHub::send_raw(const std::vector &payload, ModbusClientDevice *device) { if (payload.size() < 2) { if (device) - device->on_modbus_not_sent(); + device->on_not_sent(); return; } this->queue_raw_(payload[0], payload.data() + 1, static_cast(payload.size() - 1), device); diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index eeba00f6b1..ee448245a4 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -59,8 +60,8 @@ class Modbus : public uart::UARTDevice, public Component { virtual int32_t tx_delay_remaining(); virtual void parse_modbus_frames() = 0; bool parse_modbus_server_frame_(); - virtual void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, - uint16_t len) = 0; + // pdu is the whole PDU (function code + payload, no address/CRC); pdu[0] is the (standard or custom) function code. + virtual void process_modbus_server_frame(uint8_t address, std::span pdu) = 0; void clear_rx_buffer_(const LogString *reason, bool warn = false, size_t bytes_to_clear = 0); bool send_frame_(const ModbusFrame &frame); // Scans forward from min_length to find a frame boundary by CRC match for custom function codes. @@ -118,10 +119,9 @@ class ModbusClientHub : public Modbus { protected: int32_t tx_delay_remaining() override; void parse_modbus_frames() override; - // Parsers need to handle standard (ModbusFunctionCode) and custom (uint8_t) function codes, so we use uint8_t here. - void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) override; + void process_modbus_server_frame(uint8_t address, std::span pdu) override; void send_next_frame_(); - // Notify the waiting device of no response; re-queues the frame if on_modbus_no_response() returns true. + // Notify the waiting device of no response; re-queues the frame if on_no_response() returns true. // wfr is the caller's checked reference to waiting_for_response_. void notify_no_response_(ModbusDeviceCommand &wfr); void requeue_waiting_frame_(ModbusDeviceCommand &wfr); @@ -146,8 +146,7 @@ class ModbusServerHub : public Modbus { protected: void parse_modbus_frames() override; bool parse_modbus_client_frame_(); - // Parsers need to handle standard (ModbusFunctionCode) and custom (uint8_t) function codes, so we use uint8_t here. - void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) override; + void process_modbus_server_frame(uint8_t address, std::span pdu) override; void process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data); ModbusServerDevice *find_device_(uint8_t address); // Returns true if [start_address, start_address + number_of_registers) fits in the 16-bit address space. @@ -180,12 +179,35 @@ class ModbusClientDevice { ModbusClientDevice &operator=(ModbusClientDevice &&) = delete; void set_parent(ModbusClientHub *parent) { this->parent_ = parent; } void set_address(uint8_t address) { this->address_ = address; } - virtual void on_modbus_data(const std::vector &data) {} - virtual void on_modbus_error(uint8_t function_code, uint8_t exception_code) {} - virtual void on_modbus_not_sent() {} + /// Called with the request PDU this device sent and the response PDU received (both: function code + + /// data, no address, no CRC). The spans are only valid for the duration of the call - copy the bytes + /// if they must outlive it. Slice the payload out of the response with helpers::server_pdu_payload(). + virtual void on_response(std::span request_pdu, std::span response_pdu) {} + /// Called with the request PDU and the modbus exception code decoded from the error response. + virtual void on_error(std::span request_pdu, ModbusExceptionCode exception_code) {} + // The on_modbus_* names are signature-identical renames, so the new defaults forward to the old + // virtuals: external devices overriding the old names keep working through the deprecation window. + // Remove the forwards together with the deprecated names. + virtual void on_not_sent() { +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + this->on_modbus_not_sent(); +#pragma GCC diagnostic pop + } /// Called when no (valid) response arrived; return true to have the hub re-queue the frame for a retry. /// The hub does not bound retries: the device is responsible for limiting them (e.g. track a counter and /// return false when exhausted), or an unresponsive peer will starve other traffic on the bus. + virtual bool on_no_response() { +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + return this->on_modbus_no_response(); +#pragma GCC diagnostic pop + } + // Remove before 2027.2.0 + ESPDEPRECATED("Override on_not_sent() instead. Removed in 2027.2.0", "2026.8.0") + virtual void on_modbus_not_sent() {} + // Remove before 2027.2.0 + ESPDEPRECATED("Override on_no_response() instead. Removed in 2027.2.0", "2026.8.0") virtual bool on_modbus_no_response() { return false; } void send(uint8_t function, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0, const uint8_t *payload = nullptr) { diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index 45a13f7582..587f6838ee 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -47,6 +47,8 @@ uint16_t server_frame_length(const uint8_t *frame, size_t size); // If the frame is too short to determine the length, returns the minimum length uint16_t client_frame_length(const uint8_t *frame, size_t size); +// Remove before 2027.2.0 +ESPDEPRECATED("Use server_pdu_payload() on the response PDU instead. Removed in 2027.2.0", "2026.8.0") inline uint8_t server_frame_data_offset(const uint8_t *frame, size_t size) { if (size < 2) return 0; @@ -61,6 +63,20 @@ inline uint8_t server_frame_data_offset(const uint8_t *frame, size_t size) { } } +/** Returns the payload portion of a server response PDU: the bytes after the function code, and for the + * standard read responses (0x01-0x04) also after the byte-count byte. Responses to 0x14/0x17 also carry a + * byte-count byte, but those codes are not implemented and their count byte is left in the payload. For + * an exception PDU the payload is the exception code byte (the read check must not see the masked + * function code, or an exception-of-read would classify as a read and return an empty span). Returns an + * empty span if the PDU is too short. + */ +inline std::span server_pdu_payload(std::span pdu) { + if (pdu.empty()) + return {}; + const size_t offset = (!is_function_code_exception(pdu[0]) && is_function_code_read(pdu[0])) ? 2 : 1; + return pdu.size() > offset ? pdu.subspan(offset) : std::span(); +} + inline uint8_t client_frame_data_offset(const uint8_t *, size_t) { return 2; } enum class SensorValueType : uint8_t { diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 9246239ef9..84f8fc16b8 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -57,7 +57,7 @@ bool ModbusController::send_next_command_() { } // Queue incoming response -void ModbusController::on_modbus_data(const std::vector &data) { +void ModbusController::on_response(std::span request_pdu, std::span response_pdu) { if (this->command_queue_.empty()) { ESP_LOGW(TAG, "Received modbus data but command queue is empty"); return; @@ -78,8 +78,10 @@ void ModbusController::on_modbus_data(const std::vector &data) { this->online_callback_.call((int) current_command->function_code, current_command->register_address); } - // Move the commandItem to the response queue - current_command->payload = data; + // Move the commandItem to the response queue. The span points into the hub's receive buffer, so + // copy the payload into the command for deferred processing in loop(). + auto data = modbus::helpers::server_pdu_payload(response_pdu); + current_command->payload.assign(data.begin(), data.end()); this->incoming_queue_.push(std::move(current_command)); ESP_LOGV(TAG, "Modbus response queued"); this->command_queue_.pop_front(); @@ -93,8 +95,11 @@ void ModbusController::process_modbus_data_(const ModbusCommandItem *response) { response->on_data_func(response->register_type, response->register_address, response->payload); } -void ModbusController::on_modbus_error(uint8_t function_code, uint8_t exception_code) { - ESP_LOGE(TAG, "Modbus error function code: 0x%X exception: %d ", function_code, exception_code); +void ModbusController::on_error(std::span request_pdu, modbus::ModbusExceptionCode exception_code) { + // The request function code (request_pdu[0]) already carries what the log needs; the exception bit only + // ever appears on the response, so no masking is needed here. + const uint8_t function_code = request_pdu.empty() ? 0 : request_pdu[0]; + ESP_LOGE(TAG, "Modbus error function code: 0x%X exception: %d ", function_code, static_cast(exception_code)); if (this->command_queue_.empty()) { return; } diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 484b59ede3..23e93057b8 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -293,9 +294,9 @@ class ModbusController final : public PollingComponent, public modbus::ModbusCli /// Registers a sensor with the controller. Called by esphomes code generator void add_sensor_item(SensorItem *item) { sensorset_.insert(item); } /// called when a modbus response was parsed without errors - void on_modbus_data(const std::vector &data) override; + void on_response(std::span request_pdu, std::span response_pdu) override; /// called when a modbus error response was received - void on_modbus_error(uint8_t function_code, uint8_t exception_code) override; + void on_error(std::span request_pdu, modbus::ModbusExceptionCode exception_code) override; /// 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 diff --git a/esphome/components/pzemac/pzemac.cpp b/esphome/components/pzemac/pzemac.cpp index d36e5d0250..0f22092f34 100644 --- a/esphome/components/pzemac/pzemac.cpp +++ b/esphome/components/pzemac/pzemac.cpp @@ -9,7 +9,8 @@ static const uint8_t PZEM_CMD_READ_IN_REGISTERS = 0x04; static const uint8_t PZEM_CMD_RESET_ENERGY = 0x42; static const uint8_t PZEM_REGISTER_COUNT = 10; // 10x 16-bit registers -void PZEMAC::on_modbus_data(const std::vector &data) { +void PZEMAC::on_response(std::span request_pdu, std::span response_pdu) { + auto data = modbus::helpers::server_pdu_payload(response_pdu); if (data.size() < 20) { ESP_LOGW(TAG, "Invalid size for PZEM AC!"); return; diff --git a/esphome/components/pzemac/pzemac.h b/esphome/components/pzemac/pzemac.h index a3ad7e1167..171212d3ee 100644 --- a/esphome/components/pzemac/pzemac.h +++ b/esphome/components/pzemac/pzemac.h @@ -5,7 +5,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/modbus/modbus.h" -#include +#include namespace esphome::pzemac { @@ -22,7 +22,7 @@ class PZEMAC final : public PollingComponent, public modbus::ModbusClientDevice void update() override; - void on_modbus_data(const std::vector &data) override; + void on_response(std::span request_pdu, std::span response_pdu) override; void dump_config() override; diff --git a/esphome/components/pzemdc/pzemdc.cpp b/esphome/components/pzemdc/pzemdc.cpp index 6ded9b3a34..31d1a7dac1 100644 --- a/esphome/components/pzemdc/pzemdc.cpp +++ b/esphome/components/pzemdc/pzemdc.cpp @@ -9,7 +9,8 @@ static const uint8_t PZEM_CMD_READ_IN_REGISTERS = 0x04; static const uint8_t PZEM_CMD_RESET_ENERGY = 0x42; static const uint8_t PZEM_REGISTER_COUNT = 10; // 10x 16-bit registers -void PZEMDC::on_modbus_data(const std::vector &data) { +void PZEMDC::on_response(std::span request_pdu, std::span response_pdu) { + auto data = modbus::helpers::server_pdu_payload(response_pdu); if (data.size() < 16) { ESP_LOGW(TAG, "Invalid size for PZEM DC!"); return; diff --git a/esphome/components/pzemdc/pzemdc.h b/esphome/components/pzemdc/pzemdc.h index 7d14a5ed4b..b7657608e6 100644 --- a/esphome/components/pzemdc/pzemdc.h +++ b/esphome/components/pzemdc/pzemdc.h @@ -5,7 +5,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/modbus/modbus.h" -#include +#include namespace esphome::pzemdc { @@ -18,7 +18,7 @@ class PZEMDC final : public PollingComponent, public modbus::ModbusClientDevice void update() override; - void on_modbus_data(const std::vector &data) override; + void on_response(std::span request_pdu, std::span response_pdu) override; void dump_config() override; diff --git a/esphome/components/sdm_meter/sdm_meter.cpp b/esphome/components/sdm_meter/sdm_meter.cpp index a4fe6e7d35..989f22dd2a 100644 --- a/esphome/components/sdm_meter/sdm_meter.cpp +++ b/esphome/components/sdm_meter/sdm_meter.cpp @@ -10,7 +10,8 @@ static const char *const TAG = "sdm_meter"; static const uint8_t MODBUS_CMD_READ_IN_REGISTERS = 0x04; static const uint8_t MODBUS_REGISTER_COUNT = 80; // 74 x 16-bit registers -void SDMMeter::on_modbus_data(const std::vector &data) { +void SDMMeter::on_response(std::span request_pdu, std::span response_pdu) { + auto data = modbus::helpers::server_pdu_payload(response_pdu); if (data.size() < MODBUS_REGISTER_COUNT * 2) { ESP_LOGW(TAG, "Invalid size for SDMMeter!"); return; diff --git a/esphome/components/sdm_meter/sdm_meter.h b/esphome/components/sdm_meter/sdm_meter.h index aa71fcaa47..e09b74bbc0 100644 --- a/esphome/components/sdm_meter/sdm_meter.h +++ b/esphome/components/sdm_meter/sdm_meter.h @@ -4,7 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/modbus/modbus.h" -#include +#include namespace esphome::sdm_meter { @@ -55,7 +55,7 @@ class SDMMeter final : public PollingComponent, public modbus::ModbusClientDevic void update() override; - void on_modbus_data(const std::vector &data) override; + void on_response(std::span request_pdu, std::span response_pdu) override; void dump_config() override; diff --git a/esphome/components/selec_meter/selec_meter.cpp b/esphome/components/selec_meter/selec_meter.cpp index f612b89934..f5f0fdf40d 100644 --- a/esphome/components/selec_meter/selec_meter.cpp +++ b/esphome/components/selec_meter/selec_meter.cpp @@ -10,7 +10,8 @@ static const char *const TAG = "selec_meter"; static const uint8_t MODBUS_CMD_READ_IN_REGISTERS = 0x04; static const uint8_t MODBUS_REGISTER_COUNT = 34; // 34 x 16-bit registers -void SelecMeter::on_modbus_data(const std::vector &data) { +void SelecMeter::on_response(std::span request_pdu, std::span response_pdu) { + auto data = modbus::helpers::server_pdu_payload(response_pdu); if (data.size() < MODBUS_REGISTER_COUNT * 2) { ESP_LOGW(TAG, "Invalid size for SelecMeter!"); return; diff --git a/esphome/components/selec_meter/selec_meter.h b/esphome/components/selec_meter/selec_meter.h index c367d1d15d..5ae1f9bf99 100644 --- a/esphome/components/selec_meter/selec_meter.h +++ b/esphome/components/selec_meter/selec_meter.h @@ -4,7 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/modbus/modbus.h" -#include +#include namespace esphome::selec_meter { @@ -37,7 +37,7 @@ class SelecMeter final : public PollingComponent, public modbus::ModbusClientDev void update() override; - void on_modbus_data(const std::vector &data) override; + void on_response(std::span request_pdu, std::span response_pdu) override; void dump_config() override; }; diff --git a/tests/components/modbus/heap_probe_test.cpp b/tests/components/modbus/heap_probe_test.cpp index af43c6e5e3..2c7d9747bd 100644 --- a/tests/components/modbus/heap_probe_test.cpp +++ b/tests/components/modbus/heap_probe_test.cpp @@ -39,6 +39,50 @@ namespace esphome::modbus::testing { namespace { +// A UART the test can inject received bytes into; sent bytes are discarded. +class InjectableUART : public uart::UARTComponent { + public: + void write_array(const uint8_t *data, size_t len) override {} + bool peek_byte(uint8_t *data) override { + if (this->rx_.empty()) + return false; + *data = this->rx_.front(); + return true; + } + bool read_array(uint8_t *data, size_t len) override { + if (len > this->rx_.size()) + return false; + memcpy(data, this->rx_.data(), len); + this->rx_.erase(this->rx_.begin(), this->rx_.begin() + len); + return true; + } + size_t available() override { return this->rx_.size(); } + uart::UARTFlushResult flush() override { return uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS; } + void check_logger_conflict() override {} + + void inject_frame(uint8_t address, std::span pdu) { + // Wire frame: address + PDU + CRC16(low, high) + size_t start = this->rx_.size(); + this->rx_.push_back(address); + this->rx_.insert(this->rx_.end(), pdu.begin(), pdu.end()); + uint16_t crc = crc16(this->rx_.data() + start, this->rx_.size() - start); + this->rx_.push_back(crc & 0xFF); + this->rx_.push_back(crc >> 8); + } + + private: + std::vector rx_; +}; + +class NullDevice : public ModbusClientDevice { + public: + using ModbusClientDevice::ModbusClientDevice; + void on_response(std::span request_pdu, std::span response_pdu) override { + this->responses++; + } + int responses{0}; +}; + struct Sample { size_t count; size_t bytes; @@ -93,14 +137,61 @@ TEST(HeapProbe, QueueingTypicalCommandsIsAllocationFree) { EXPECT_EQ(total, 0u); } +// End to end: bytes injected at the UART travel through receive, frame parsing, response matching and +// device dispatch. The first response may grow the hub's rx buffer once; after that warm-up, handling a +// response performs zero heap allocations all the way to the device callback. +TEST(HeapProbe, ResponseHandlingIsAllocationFreeAfterWarmup) { + InjectableUART uart; + uart.set_baud_rate(115200); // tx timing math divides by the baud rate + ModbusClientHub hub; + hub.set_uart_parent(&uart); + hub.setup(); // computes frame timing from the baud rate + NullDevice device(&hub, 0x02); + + StaticVector req; + const uint8_t read_pdu[] = {0x03, 0x01, 0x00, 0x00, 0x02}; + req.assign(read_pdu, read_pdu + sizeof(read_pdu)); + + // Largest possible read response first, so the rx buffer warm-up covers every later size. + uint8_t large_resp[252] = {0x03, 250}; + const uint8_t small_resp[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + + auto round_trip = [&](std::span response_pdu) { + device.send_pdu(req); + hub.loop(); // transmit; the tx queue is empty during the measured receive below + uart.inject_frame(0x02, response_pdu); + return sample([&] { hub.loop(); }); // receive + parse + match + dispatch + }; + + Sample warmup = round_trip(std::span(large_resp, sizeof(large_resp))); + Sample steady_large = round_trip(std::span(large_resp, sizeof(large_resp))); + Sample steady_small = round_trip(small_resp); + + printf("HEAPPROBE warmup count=%zu bytes=%zu\n", warmup.count, warmup.bytes); + printf("HEAPPROBE steady_large count=%zu bytes=%zu\n", steady_large.count, steady_large.bytes); + printf("HEAPPROBE steady_small count=%zu bytes=%zu\n", steady_small.count, steady_small.bytes); + + EXPECT_EQ(device.responses, 3); + EXPECT_LE(warmup.count, 1u); // at most the one-time rx buffer growth + EXPECT_EQ(steady_large.count, 0u); + EXPECT_EQ(steady_small.count, 0u); +} + } // namespace esphome::modbus::testing #else // !HEAP_PROBE_HAS_ASAN +// Stub every ASan-gated test name, so the suite's test list is identical in every build configuration. namespace esphome::modbus::testing { TEST(HeapProbe, TypicalFrameConstructionIsAllocationFree) { GTEST_SKIP() << "allocation counting requires an AddressSanitizer build"; } +TEST(HeapProbe, QueueingTypicalCommandsIsAllocationFree) { + GTEST_SKIP() << "allocation counting requires an AddressSanitizer build"; +} +TEST(HeapProbe, ResponseHandlingIsAllocationFreeAfterWarmup) { + GTEST_SKIP() << "allocation counting requires an AddressSanitizer build"; +} } // namespace esphome::modbus::testing #endif // HEAP_PROBE_HAS_ASAN diff --git a/tests/components/modbus/modbus_client_hub_test.cpp b/tests/components/modbus/modbus_client_hub_test.cpp index d04c4fe10c..4447ca9344 100644 --- a/tests/components/modbus/modbus_client_hub_test.cpp +++ b/tests/components/modbus/modbus_client_hub_test.cpp @@ -27,8 +27,8 @@ class NoResponseProbeHub : public ModbusClientHub { this->tx_buffer_.pop_front(); } // Drives the real unexpected-frame branch in process_modbus_server_frame(). - void receive_frame_for_test(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) { - this->process_modbus_server_frame(address, function_code, data, len); + void receive_frame_for_test(uint8_t address, std::span pdu) { + this->process_modbus_server_frame(address, pdu); } void timeout_waiting() { if (this->waiting_for_response_.has_value()) @@ -37,11 +37,11 @@ class NoResponseProbeHub : public ModbusClientHub { } }; -// A device with a scripted answer to on_modbus_no_response(). +// A device with a scripted answer to on_no_response(). class RetryingDevice : public ModbusClientDevice { public: RetryingDevice(ModbusClientHub *hub, uint8_t address, bool retry) : ModbusClientDevice(hub, address), retry_(retry) {} - bool on_modbus_no_response() override { + bool on_no_response() override { this->no_response_count_++; return this->retry_; } @@ -55,7 +55,7 @@ class RetryingDevice : public ModbusClientDevice { class ClearingRetryDevice : public ModbusClientDevice { public: ClearingRetryDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} - bool on_modbus_no_response() override { + bool on_no_response() override { this->no_response_count_++; this->clear_tx_queue_for_device(); // detaches this device from the waiting slot mid-callback return true; // and still requests a retry @@ -143,8 +143,8 @@ TEST(ModbusClientHubNoResponse, RetryBehindInterruptedShell) { hub.force_send_front(); // A frame from the wrong address (0x07, expected 0x02) hits the unexpected-frame branch. - const uint8_t stray_payload[] = {0x04, 0x00, 0x2A, 0x01, 0x00}; - hub.receive_frame_for_test(0x07, 0x03, stray_payload, sizeof(stray_payload)); + const uint8_t stray_pdu[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x07, stray_pdu); EXPECT_EQ(device.no_response_count_, 1); ASSERT_EQ(hub.queued_frames(), 1u); // exactly one requeue... @@ -175,4 +175,37 @@ TEST(ModbusClientHubNoResponse, MidCallbackClearCancelsRetry) { EXPECT_FALSE(hub.waiting()); } +namespace { +// Overrides only the DEPRECATED on_modbus_* names: the new-name default implementations must forward, so +// external devices written against the old names keep working through the deprecation window. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +class LegacyNameDevice : public ModbusClientDevice { + public: + LegacyNameDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_modbus_not_sent() override { this->legacy_not_sent_++; } + bool on_modbus_no_response() override { + this->legacy_no_response_++; + return false; + } + int legacy_not_sent_{0}; + int legacy_no_response_{0}; +}; +#pragma GCC diagnostic pop +} // namespace + +TEST(ModbusClientHubCompat, LegacyCallbackNamesStillForward) { + NoResponseProbeHub hub; + LegacyNameDevice device(&hub, 0x02); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; + device.send_pdu(read); + hub.force_send_front(); + hub.timeout_waiting(); // no reply -> on_no_response -> forwards to on_modbus_no_response + EXPECT_EQ(device.legacy_no_response_, 1); + + device.send_pdu(std::span()); // empty PDU refused -> on_not_sent -> forwards + EXPECT_EQ(device.legacy_not_sent_, 1); +} + } // namespace esphome::modbus::testing diff --git a/tests/components/modbus/modbus_helpers_test.cpp b/tests/components/modbus/modbus_helpers_test.cpp index 1c57a81e6f..30ba12b16b 100644 --- a/tests/components/modbus/modbus_helpers_test.cpp +++ b/tests/components/modbus/modbus_helpers_test.cpp @@ -229,4 +229,21 @@ TEST(ModbusHelpersTest, RegistersToNumberRejectsTruncatedMultiRegisterValue) { EXPECT_FALSE(registers_to_number(registers, 1, SensorValueType::U_DWORD).has_value()); } +// server_pdu_payload() must never classify an exception PDU as a read: [fc|0x80, code] is 2 bytes, and a +// read-offset of 2 would return an empty span, losing the exception code. The payload of an exception PDU +// is the exception code byte, for reads and writes alike. +TEST(ModbusServerPduPayload, ExceptionOfReadYieldsExceptionCode) { + const uint8_t pdu[] = {0x83, 0x02}; // exception response to READ_HOLDING_REGISTERS + auto payload = server_pdu_payload(pdu); + ASSERT_EQ(payload.size(), 1u); + EXPECT_EQ(payload[0], 0x02); +} + +TEST(ModbusServerPduPayload, ExceptionOfWriteYieldsExceptionCode) { + const uint8_t pdu[] = {0x86, 0x03}; // exception response to WRITE_SINGLE_REGISTER + auto payload = server_pdu_payload(pdu); + ASSERT_EQ(payload.size(), 1u); + EXPECT_EQ(payload[0], 0x03); +} + } // namespace esphome::modbus::helpers From 9ac4039c01439561d3f5f3f090565befef57a3ac Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Fri, 24 Jul 2026 16:28:22 -0700 Subject: [PATCH 1056/1815] [modbus] Rename core enums to EntityType, FunctionCode and ExceptionCode (#17844) Co-authored-by: Claude Fable 5 --- esphome/components/modbus/helpers.py | 35 ++++---- esphome/components/modbus/modbus.cpp | 28 +++---- esphome/components/modbus/modbus.h | 22 ++--- .../components/modbus/modbus_definitions.h | 34 +++++--- esphome/components/modbus/modbus_helpers.cpp | 81 +++++++++---------- esphome/components/modbus/modbus_helpers.h | 68 ++++++++-------- .../components/modbus_controller/__init__.py | 18 ++--- .../binary_sensor/modbus_binarysensor.cpp | 4 +- .../binary_sensor/modbus_binarysensor.h | 4 +- .../modbus_controller/modbus_controller.cpp | 72 ++++++++--------- .../modbus_controller/modbus_controller.h | 52 ++++++------ .../number/modbus_number.cpp | 4 +- .../modbus_controller/number/modbus_number.h | 2 +- .../output/modbus_output.cpp | 2 +- .../modbus_controller/output/modbus_output.h | 4 +- .../modbus_controller/select/modbus_select.h | 2 +- .../modbus_controller/sensor/modbus_sensor.h | 2 +- .../switch/modbus_switch.cpp | 8 +- .../modbus_controller/switch/modbus_switch.h | 4 +- .../text_sensor/modbus_textsensor.h | 2 +- .../modbus_server/modbus_server.cpp | 16 ++-- .../components/modbus/modbus_helpers_test.cpp | 2 +- .../components/modbus_controller/common.yaml | 2 +- .../modbus_server/modbus_server_test.cpp | 18 ++--- 24 files changed, 250 insertions(+), 236 deletions(-) diff --git a/esphome/components/modbus/helpers.py b/esphome/components/modbus/helpers.py index 9d7dc71547..e3029b2648 100644 --- a/esphome/components/modbus/helpers.py +++ b/esphome/components/modbus/helpers.py @@ -3,33 +3,34 @@ import esphome.codegen as cg modbus_ns = cg.esphome_ns.namespace("modbus") modbus_helpers_ns = modbus_ns.namespace("helpers") -ModbusFunctionCode_ns = modbus_ns.namespace("ModbusFunctionCode") -ModbusFunctionCode = ModbusFunctionCode_ns.enum("ModbusFunctionCode") +FunctionCode_ns = modbus_ns.namespace("FunctionCode") +FunctionCode = FunctionCode_ns.enum("FunctionCode") MODBUS_FUNCTION_CODE = { - "read_coils": ModbusFunctionCode.READ_COILS, - "read_discrete_inputs": ModbusFunctionCode.READ_DISCRETE_INPUTS, - "read_holding_registers": ModbusFunctionCode.READ_HOLDING_REGISTERS, - "read_input_registers": ModbusFunctionCode.READ_INPUT_REGISTERS, - "write_single_coil": ModbusFunctionCode.WRITE_SINGLE_COIL, - "write_single_register": ModbusFunctionCode.WRITE_SINGLE_REGISTER, - "write_multiple_coils": ModbusFunctionCode.WRITE_MULTIPLE_COILS, - "write_multiple_registers": ModbusFunctionCode.WRITE_MULTIPLE_REGISTERS, + "read_coils": FunctionCode.READ_COILS, + "read_discrete_inputs": FunctionCode.READ_DISCRETE_INPUTS, + "read_holding_registers": FunctionCode.READ_HOLDING_REGISTERS, + "read_input_registers": FunctionCode.READ_INPUT_REGISTERS, + "write_single_coil": FunctionCode.WRITE_SINGLE_COIL, + "write_single_register": FunctionCode.WRITE_SINGLE_REGISTER, + "write_multiple_coils": FunctionCode.WRITE_MULTIPLE_COILS, + "write_multiple_registers": FunctionCode.WRITE_MULTIPLE_REGISTERS, } -ModbusRegisterType_ns = modbus_ns.namespace("ModbusRegisterType") -ModbusRegisterType = ModbusRegisterType_ns.enum("ModbusRegisterType") +EntityType_ns = modbus_ns.namespace("EntityType") +EntityType = EntityType_ns.enum("EntityType") MODBUS_WRITE_REGISTER_TYPE = { - "custom": ModbusRegisterType.CUSTOM, - "coil": ModbusRegisterType.COIL, - "holding": ModbusRegisterType.HOLDING, + "custom": EntityType.CUSTOM, + "coil": EntityType.COIL, + "holding": EntityType.HOLDING, } MODBUS_REGISTER_TYPE = { **MODBUS_WRITE_REGISTER_TYPE, - "discrete_input": ModbusRegisterType.DISCRETE_INPUT, - "read": ModbusRegisterType.INPUT_REGISTER, + "discrete_input": EntityType.DISCRETE_INPUT, + "read": EntityType.INPUT_REGISTER, + "input": EntityType.INPUT_REGISTER, } SensorValueType_ns = modbus_helpers_ns.namespace("SensorValueType") diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 7f90dcd738..eaca168ed8 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -312,7 +312,7 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, std::spanlast_modbus_byte_ - this->last_send_); if (device) - device->on_error(request_pdu, static_cast(exception)); + device->on_error(request_pdu, static_cast(exception)); } else if (device) { // Not an error response device->on_response(request_pdu, pdu); @@ -354,7 +354,7 @@ bool ModbusServerHub::check_register_range_(uint8_t address, uint8_t function_co if ((uint32_t) start_address + number_of_registers > 0x10000u) { ESP_LOGW(TAG, "Register address out of range - start: %" PRIu16 " num: %" PRIu16, start_address, number_of_registers); - this->send_exception_(address, function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_ADDRESS); return false; } return true; @@ -373,22 +373,22 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func const uint8_t *response_data = response_buffer; uint16_t response_len = 0; - switch (static_cast(function_code)) { - case ModbusFunctionCode::READ_HOLDING_REGISTERS: - case ModbusFunctionCode::READ_INPUT_REGISTERS: { + switch (static_cast(function_code)) { + case FunctionCode::READ_HOLDING_REGISTERS: + case FunctionCode::READ_INPUT_REGISTERS: { // PDU data: start address(2) + quantity(2). uint16_t start_address = helpers::get_data(data, 0); uint16_t number_of_registers = helpers::get_data(data, 2); if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_READ) { ESP_LOGW(TAG, "Invalid number of registers %" PRIu16, number_of_registers); - this->send_exception_(address, function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); + this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_VALUE); return; } if (!this->check_register_range_(address, function_code, start_address, number_of_registers)) { return; } RegisterValues registers; - if (static_cast(function_code) == ModbusFunctionCode::READ_HOLDING_REGISTERS) { + if (static_cast(function_code) == FunctionCode::READ_HOLDING_REGISTERS) { status = device->on_read_holding_registers(start_address, number_of_registers, registers); } else { status = device->on_read_input_registers(start_address, number_of_registers, registers); @@ -403,7 +403,7 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func if (registers.size() != number_of_registers) { ESP_LOGE(TAG, "Incorrect response %" PRIu16 " requested, %zu returned", number_of_registers, registers.size()); - this->send_exception_(address, function_code, ModbusExceptionCode::SERVICE_DEVICE_FAILURE); + this->send_exception_(address, function_code, ExceptionCode::SERVICE_DEVICE_FAILURE); return; } @@ -415,8 +415,8 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func } break; } - case ModbusFunctionCode::WRITE_SINGLE_REGISTER: - case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS: { + case FunctionCode::WRITE_SINGLE_REGISTER: + case FunctionCode::WRITE_MULTIPLE_REGISTERS: { // PDU data: start address(2) [+ quantity(2) + byte count(1)] + register values. // A single-register write always targets one register; for a multiple-register write the // quantity is in the frame and its byte count must equal quantity * 2. The register values are @@ -424,7 +424,7 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func uint16_t start_address = helpers::get_data(data, 0); uint16_t number_of_registers = 1; uint16_t values_offset = 2; // single write: values follow the 2-byte start address - if (static_cast(function_code) == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { + if (static_cast(function_code) == FunctionCode::WRITE_MULTIPLE_REGISTERS) { number_of_registers = helpers::get_data(data, 2); uint8_t number_of_bytes = helpers::get_data(data, 4); values_offset = 5; // multiple write: values follow start address(2) + quantity(2) + byte count(1) @@ -432,7 +432,7 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func number_of_registers * 2 != number_of_bytes) { ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 " or bytes %" PRIu8, number_of_registers, number_of_bytes); - this->send_exception_(address, function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); + this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_VALUE); return; } if (!this->check_register_range_(address, function_code, start_address, number_of_registers)) { @@ -451,7 +451,7 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func } default: ESP_LOGW(TAG, "Unsupported function code %" PRIu8, function_code); - this->send_exception_(address, function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); + this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_FUNCTION); return; } if (status.has_value()) { @@ -563,7 +563,7 @@ void ModbusServerHub::send_response_(uint8_t address, uint8_t function_code, con this->send_raw_(raw_frame, payload_len + 2); } -void ModbusServerHub::send_exception_(uint8_t address, uint8_t function_code, ModbusExceptionCode exception_code) { +void ModbusServerHub::send_exception_(uint8_t address, uint8_t function_code, ExceptionCode exception_code) { uint8_t raw_frame[3]; raw_frame[0] = address; raw_frame[1] = function_code | FUNCTION_CODE_EXCEPTION_MASK; diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index ee448245a4..61d5f552c1 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -105,8 +105,8 @@ class ModbusClientHub : public Modbus { void send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0, const uint8_t *payload = nullptr, ModbusClientDevice *device = nullptr) { this->send_pdu(address, - helpers::create_client_pdu((ModbusFunctionCode) function_code, start_address, number_of_entities, - payload, payload_len), + helpers::create_client_pdu((FunctionCode) function_code, start_address, number_of_entities, payload, + payload_len), device); }; void send_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device = nullptr) { @@ -154,7 +154,7 @@ class ModbusServerHub : public Modbus { bool check_register_range_(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_registers); void send_raw_(const uint8_t *payload, uint16_t len); - void send_exception_(uint8_t address, uint8_t function_code, ModbusExceptionCode exception_code); + void send_exception_(uint8_t address, uint8_t function_code, ExceptionCode exception_code); void send_response_(uint8_t address, uint8_t function_code, const uint8_t *payload, uint16_t payload_len); uint8_t expecting_peer_response_{0}; std::vector devices_; @@ -184,7 +184,7 @@ class ModbusClientDevice { /// if they must outlive it. Slice the payload out of the response with helpers::server_pdu_payload(). virtual void on_response(std::span request_pdu, std::span response_pdu) {} /// Called with the request PDU and the modbus exception code decoded from the error response. - virtual void on_error(std::span request_pdu, ModbusExceptionCode exception_code) {} + virtual void on_error(std::span request_pdu, ExceptionCode exception_code) {} // The on_modbus_* names are signature-identical renames, so the new defaults forward to the old // virtuals: external devices overriding the old names keep working through the deprecation window. // Remove the forwards together with the deprecated names. @@ -211,10 +211,10 @@ class ModbusClientDevice { virtual bool on_modbus_no_response() { return false; } void send(uint8_t function, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0, const uint8_t *payload = nullptr) { - this->parent_->send_pdu(this->address_, - helpers::create_client_pdu((ModbusFunctionCode) function, start_address, number_of_entities, - payload, payload_len), - this); + this->parent_->send_pdu( + this->address_, + helpers::create_client_pdu((FunctionCode) function, start_address, number_of_entities, payload, payload_len), + this); } void send_pdu(std::span pdu) { this->parent_->send_pdu(this->address_, pdu, this); } void send_raw(const std::vector &payload) { this->parent_->send_raw(payload, this); } @@ -240,7 +240,7 @@ using ModbusDevice ESPDEPRECATED("Use ModbusClientDevice instead. Removed in 202 // Transaction status: std::nullopt on success, otherwise the Modbus exception code. Server handlers return it; // (future) client response callbacks receive it. Named without a side prefix so both directions share it. -using ResponseStatus = std::optional; +using ResponseStatus = std::optional; // Register values exchanged with server handlers, in host byte order. Sized at the larger of the two protocol // maxima (read = 125 / 0x7D, write = 123 / 0x7B); the per-direction count limit is enforced by the hub, not by // the capacity of this type. @@ -259,7 +259,7 @@ class ModbusServerDevice { uint8_t get_address() const { return this->address_; } virtual ResponseStatus on_read_registers(uint16_t start_address, uint16_t number_of_registers, RegisterValues ®isters) { - return ModbusExceptionCode::ILLEGAL_FUNCTION; + return ExceptionCode::ILLEGAL_FUNCTION; }; virtual ResponseStatus on_read_input_registers(uint16_t start_address, uint16_t number_of_registers, RegisterValues ®isters) { @@ -270,7 +270,7 @@ class ModbusServerDevice { return this->on_read_registers(start_address, number_of_registers, registers); }; virtual ResponseStatus on_write_registers(uint16_t start_address, const RegisterValues ®isters) { - return ModbusExceptionCode::ILLEGAL_FUNCTION; + return ExceptionCode::ILLEGAL_FUNCTION; }; protected: diff --git a/esphome/components/modbus/modbus_definitions.h b/esphome/components/modbus/modbus_definitions.h index d11748bcd9..841222ff6a 100644 --- a/esphome/components/modbus/modbus_definitions.h +++ b/esphome/components/modbus/modbus_definitions.h @@ -14,7 +14,7 @@ const uint8_t FUNCTION_CODE_USER_DEFINED_SPACE_1_END = 72; // 0x48 const uint8_t FUNCTION_CODE_USER_DEFINED_SPACE_2_INIT = 100; // 0x64 const uint8_t FUNCTION_CODE_USER_DEFINED_SPACE_2_END = 110; // 0x6E -enum class ModbusFunctionCode : uint8_t { +enum class FunctionCode : uint8_t { INVALID = 0x00, // 0x00 is not a valid function code (even for custom functions). CUSTOM = 0x00, // The CUSTOM alias should be removed in future. READ_COILS = 0x01, @@ -37,14 +37,20 @@ enum class ModbusFunctionCode : uint8_t { READ_FIFO_QUEUE = 0x18, // not implemented }; -/*Allow direct comparison operators between ModbusFunctionCode and uint8_t*/ -inline bool operator==(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast(lhs) == rhs; } -inline bool operator==(uint8_t lhs, ModbusFunctionCode rhs) { return lhs == static_cast(rhs); } -inline bool operator!=(ModbusFunctionCode lhs, uint8_t rhs) { return !(static_cast(lhs) == rhs); } -inline bool operator!=(uint8_t lhs, ModbusFunctionCode rhs) { return !(lhs == static_cast(rhs)); } +// Remove before 2027.2.0 +using ModbusFunctionCode ESPDEPRECATED("Use modbus::FunctionCode instead. Removed in 2027.2.0", + "2026.8.0") = FunctionCode; -// 4.3 MODBUS Data model -enum class ModbusRegisterType : uint8_t { +/*Allow direct comparison operators between FunctionCode and uint8_t*/ +inline bool operator==(FunctionCode lhs, uint8_t rhs) { return static_cast(lhs) == rhs; } +inline bool operator==(uint8_t lhs, FunctionCode rhs) { return lhs == static_cast(rhs); } +inline bool operator!=(FunctionCode lhs, uint8_t rhs) { return !(static_cast(lhs) == rhs); } +inline bool operator!=(uint8_t lhs, FunctionCode rhs) { return !(lhs == static_cast(rhs)); } + +// 4.3 MODBUS Data model. "Entity" is the spec's umbrella for the four primary tables; only the +// 16-bit tables are registers (coils and discrete inputs are bits), so the enum is not named +// RegisterType. +enum class EntityType : uint8_t { CUSTOM = 0x00, COIL = 0x01, DISCRETE_INPUT = 0x02, @@ -52,15 +58,17 @@ enum class ModbusRegisterType : uint8_t { // Named INPUT_REGISTER (not INPUT) because Arduino cores define INPUT as a macro. INPUT_REGISTER = 0x04, // Remove before 2027.2.0 - READ ESPDEPRECATED("Use ModbusRegisterType::INPUT_REGISTER instead. Removed in 2027.2.0", "2026.7.0") = - INPUT_REGISTER, + READ ESPDEPRECATED("Use EntityType::INPUT_REGISTER instead. Removed in 2027.2.0", "2026.7.0") = INPUT_REGISTER, }; +// Remove before 2027.2.0 +using ModbusRegisterType ESPDEPRECATED("Use modbus::EntityType instead. Removed in 2027.2.0", "2026.8.0") = EntityType; + // 7 MODBUS Exception Responses: const uint8_t FUNCTION_CODE_MASK = 0x7F; const uint8_t FUNCTION_CODE_EXCEPTION_MASK = 0x80; -enum class ModbusExceptionCode : uint8_t { +enum class ExceptionCode : uint8_t { ILLEGAL_FUNCTION = 0x01, ILLEGAL_DATA_ADDRESS = 0x02, ILLEGAL_DATA_VALUE = 0x03, @@ -72,6 +80,10 @@ enum class ModbusExceptionCode : uint8_t { GATEWAY_TARGET_DEVICE_FAILED_TO_RESPOND = 0x0B, }; +// Remove before 2027.2.0 +using ModbusExceptionCode ESPDEPRECATED("Use modbus::ExceptionCode instead. Removed in 2027.2.0", + "2026.8.0") = ExceptionCode; + // 6.12 16 (0x10) Write Multiple registers: static constexpr uint16_t MAX_NUM_OF_REGISTERS_TO_WRITE = 123; // 0x7B diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index de109606cb..1ed7912b40 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -13,29 +13,29 @@ uint16_t server_frame_length(const uint8_t *frame, size_t size) { if (is_function_code_exception(frame[1])) { return 5; // address(1) + function(1) + exception(1) + CRC(2) } - switch (static_cast(frame[1])) { - case ModbusFunctionCode::READ_COILS: - case ModbusFunctionCode::READ_DISCRETE_INPUTS: - case ModbusFunctionCode::READ_HOLDING_REGISTERS: - case ModbusFunctionCode::READ_INPUT_REGISTERS: + switch (static_cast(frame[1])) { + case FunctionCode::READ_COILS: + case FunctionCode::READ_DISCRETE_INPUTS: + case FunctionCode::READ_HOLDING_REGISTERS: + case FunctionCode::READ_INPUT_REGISTERS: // address(1) + function(1) + byte count(1) + data + CRC(2) return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0); - case ModbusFunctionCode::WRITE_SINGLE_COIL: - case ModbusFunctionCode::WRITE_SINGLE_REGISTER: - case ModbusFunctionCode::WRITE_MULTIPLE_COILS: - case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS: + case FunctionCode::WRITE_SINGLE_COIL: + case FunctionCode::WRITE_SINGLE_REGISTER: + case FunctionCode::WRITE_MULTIPLE_COILS: + case FunctionCode::WRITE_MULTIPLE_REGISTERS: return 8; // address(1) + function(1) + output/register address(2) + value(2) + CRC(2) // Unsupported function codes. Included here to prevent parser failures. Excluding Serial Line specific functions. - case ModbusFunctionCode::READ_FILE_RECORD: - case ModbusFunctionCode::WRITE_FILE_RECORD: + case FunctionCode::READ_FILE_RECORD: + case FunctionCode::WRITE_FILE_RECORD: // address(1) + function(1) + byte count(1) + data + CRC(2) return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_FRAME_SIZE - 5)) : 0); - case ModbusFunctionCode::MASK_WRITE_REGISTER: + case FunctionCode::MASK_WRITE_REGISTER: return 10; // address(1) + function(1) + reference address(2) + AND mask(2) + OR mask(2) + CRC(2) - case ModbusFunctionCode::READ_WRITE_MULTIPLE_REGISTERS: + case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: // address(1) + function(1) + byte count(1) + data + CRC(2) return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0); - case ModbusFunctionCode::READ_FIFO_QUEUE: + case FunctionCode::READ_FIFO_QUEUE: // address(1) + function(1) + fifo address(2) CRC(2) return 6; default: @@ -46,31 +46,31 @@ uint16_t server_frame_length(const uint8_t *frame, size_t size) { uint16_t client_frame_length(const uint8_t *frame, size_t size) { if (size < 2) return MIN_FRAME_SIZE; - switch (static_cast(frame[1])) { - case ModbusFunctionCode::READ_COILS: - case ModbusFunctionCode::READ_DISCRETE_INPUTS: - case ModbusFunctionCode::READ_HOLDING_REGISTERS: - case ModbusFunctionCode::READ_INPUT_REGISTERS: + switch (static_cast(frame[1])) { + case FunctionCode::READ_COILS: + case FunctionCode::READ_DISCRETE_INPUTS: + case FunctionCode::READ_HOLDING_REGISTERS: + case FunctionCode::READ_INPUT_REGISTERS: // address(1) + function(1) + start address(2) + quantity(2) + CRC(2) - case ModbusFunctionCode::WRITE_SINGLE_COIL: - case ModbusFunctionCode::WRITE_SINGLE_REGISTER: + case FunctionCode::WRITE_SINGLE_COIL: + case FunctionCode::WRITE_SINGLE_REGISTER: return 8; // address(1) + function(1) + output/register address(2) + value(2) + CRC(2) - case ModbusFunctionCode::WRITE_MULTIPLE_COILS: - case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS: + case FunctionCode::WRITE_MULTIPLE_COILS: + case FunctionCode::WRITE_MULTIPLE_REGISTERS: // address(1) + function(1) + start address(2) + quantity(2) + byte count(1) + data + CRC(2) return 9 + (size > 6 ? std::min(frame[6], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE * 2)) : 0); // Unsupported function codes. Included here to prevent parser failures. Excluding Serial Line specific functions. - case ModbusFunctionCode::READ_FILE_RECORD: - case ModbusFunctionCode::WRITE_FILE_RECORD: + case FunctionCode::READ_FILE_RECORD: + case FunctionCode::WRITE_FILE_RECORD: // address(1) + function(1) + byte count(1) + data + CRC(2) return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_FRAME_SIZE - 5)) : 0); - case ModbusFunctionCode::MASK_WRITE_REGISTER: + case FunctionCode::MASK_WRITE_REGISTER: return 10; // address(1) + function(1) + reference address(2) + AND mask(2) + OR mask(2) + CRC(2) - case ModbusFunctionCode::READ_WRITE_MULTIPLE_REGISTERS: + case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: // address(1) + function(1) + read start address(2) + read quantity(2) + write start address(2) + // write quantity(2) + byte count(1) + data + CRC(2) return 13 + (size > 10 ? std::min(frame[10], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE * 2)) : 0); - case ModbusFunctionCode::READ_FIFO_QUEUE: + case FunctionCode::READ_FIFO_QUEUE: // address(1) + function(1) + fifo address(2) CRC(2) return 6; default: @@ -197,7 +197,7 @@ std::optional registers_to_number(const uint16_t *registers, size_t cou return payload_to_number(bytes, required_size, sensor_value_type, 0, 0xFFFFFFFF); } -StaticVector create_client_pdu(ModbusFunctionCode function_code, uint16_t start_address, +StaticVector create_client_pdu(FunctionCode function_code, uint16_t start_address, uint16_t number_of_entities, const uint8_t *values, size_t values_len) { if (is_function_code_read(static_cast(function_code))) { @@ -221,33 +221,33 @@ StaticVector create_client_pdu(ModbusFunctionCode functio } switch (function_code) { - case ModbusFunctionCode::READ_COILS: + case FunctionCode::READ_COILS: if (number_of_entities > MAX_NUM_OF_COILS_TO_READ) { ESP_LOGE(TAG, "number_of_entities %u exceeds maximum coils to read %u for function code %02X", number_of_entities, MAX_NUM_OF_COILS_TO_READ, static_cast(function_code)); return {}; } break; - case ModbusFunctionCode::READ_DISCRETE_INPUTS: + case FunctionCode::READ_DISCRETE_INPUTS: if (number_of_entities > MAX_NUM_OF_DISCRETE_INPUTS_TO_READ) { ESP_LOGE(TAG, "number_of_entities %u exceeds maximum discrete inputs to read %u for function code %02X", number_of_entities, MAX_NUM_OF_DISCRETE_INPUTS_TO_READ, static_cast(function_code)); return {}; } break; - case ModbusFunctionCode::READ_HOLDING_REGISTERS: - case ModbusFunctionCode::READ_INPUT_REGISTERS: + case FunctionCode::READ_HOLDING_REGISTERS: + case FunctionCode::READ_INPUT_REGISTERS: if (number_of_entities > MAX_NUM_OF_REGISTERS_TO_READ) { ESP_LOGE(TAG, "number_of_entities %u exceeds maximum registers to read %u for function code %02X", number_of_entities, MAX_NUM_OF_REGISTERS_TO_READ, static_cast(function_code)); return {}; } break; - case ModbusFunctionCode::WRITE_SINGLE_COIL: - case ModbusFunctionCode::WRITE_SINGLE_REGISTER: + case FunctionCode::WRITE_SINGLE_COIL: + case FunctionCode::WRITE_SINGLE_REGISTER: break; // number_of_entities is ignored for single write, so no need to validate - case ModbusFunctionCode::WRITE_MULTIPLE_COILS: - case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS: + case FunctionCode::WRITE_MULTIPLE_COILS: + case FunctionCode::WRITE_MULTIPLE_REGISTERS: if (number_of_entities > MAX_NUM_OF_REGISTERS_TO_WRITE) { ESP_LOGE(TAG, "number_of_entities %u exceeds maximum registers to write %u for function code %02X", number_of_entities, MAX_NUM_OF_REGISTERS_TO_WRITE, static_cast(function_code)); @@ -263,15 +263,14 @@ StaticVector create_client_pdu(ModbusFunctionCode functio pdu.push_back(static_cast(function_code)); pdu.push_back(start_address >> 8); pdu.push_back(start_address >> 0); - if (function_code != ModbusFunctionCode::WRITE_SINGLE_COIL && - function_code != ModbusFunctionCode::WRITE_SINGLE_REGISTER) { + if (function_code != FunctionCode::WRITE_SINGLE_COIL && function_code != FunctionCode::WRITE_SINGLE_REGISTER) { pdu.push_back(number_of_entities >> 8); pdu.push_back(number_of_entities >> 0); } if (is_function_code_write(static_cast(function_code))) { - if (function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS || - function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { + if (function_code == FunctionCode::WRITE_MULTIPLE_COILS || + function_code == FunctionCode::WRITE_MULTIPLE_REGISTERS) { // 6 bytes of overhead (fc + start_addr×2 + qty×2 + byte_count) leave MAX_PDU_SIZE-6 bytes for values static constexpr size_t MAX_WRITE_MULTIPLE_VALUES_LEN = MAX_PDU_SIZE - 6; if (values_len > MAX_WRITE_MULTIPLE_VALUES_LEN) { diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index 587f6838ee..0fece3be5d 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -12,19 +12,19 @@ namespace esphome::modbus::helpers { inline bool is_function_code_read(uint8_t function_code) { - ModbusFunctionCode masked_function_code = static_cast(function_code & FUNCTION_CODE_MASK); - return masked_function_code == ModbusFunctionCode::READ_COILS || - masked_function_code == ModbusFunctionCode::READ_DISCRETE_INPUTS || - masked_function_code == ModbusFunctionCode::READ_HOLDING_REGISTERS || - masked_function_code == ModbusFunctionCode::READ_INPUT_REGISTERS; + FunctionCode masked_function_code = static_cast(function_code & FUNCTION_CODE_MASK); + return masked_function_code == FunctionCode::READ_COILS || + masked_function_code == FunctionCode::READ_DISCRETE_INPUTS || + masked_function_code == FunctionCode::READ_HOLDING_REGISTERS || + masked_function_code == FunctionCode::READ_INPUT_REGISTERS; } inline bool is_function_code_write(uint8_t function_code) { - ModbusFunctionCode masked_function_code = static_cast(function_code & FUNCTION_CODE_MASK); - return masked_function_code == ModbusFunctionCode::WRITE_SINGLE_COIL || - masked_function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER || - masked_function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS || - masked_function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS; + FunctionCode masked_function_code = static_cast(function_code & FUNCTION_CODE_MASK); + return masked_function_code == FunctionCode::WRITE_SINGLE_COIL || + masked_function_code == FunctionCode::WRITE_SINGLE_REGISTER || + masked_function_code == FunctionCode::WRITE_MULTIPLE_COILS || + masked_function_code == FunctionCode::WRITE_MULTIPLE_REGISTERS; } inline bool is_function_code_exception(uint8_t function_code) { @@ -52,11 +52,11 @@ ESPDEPRECATED("Use server_pdu_payload() on the response PDU instead. Removed in inline uint8_t server_frame_data_offset(const uint8_t *frame, size_t size) { if (size < 2) return 0; - switch (static_cast(frame[1])) { - case ModbusFunctionCode::READ_COILS: - case ModbusFunctionCode::READ_DISCRETE_INPUTS: - case ModbusFunctionCode::READ_HOLDING_REGISTERS: - case ModbusFunctionCode::READ_INPUT_REGISTERS: + switch (static_cast(frame[1])) { + case FunctionCode::READ_COILS: + case FunctionCode::READ_DISCRETE_INPUTS: + case FunctionCode::READ_HOLDING_REGISTERS: + case FunctionCode::READ_INPUT_REGISTERS: return 3; // address(1) + function(1) + byte count(1) + data + CRC(2) default: return 2; @@ -100,32 +100,32 @@ inline bool value_type_is_float(SensorValueType v) { return v == SensorValueType::FP32 || v == SensorValueType::FP32_R; } -inline ModbusFunctionCode modbus_register_read_function(ModbusRegisterType reg_type) { +inline FunctionCode modbus_register_read_function(EntityType reg_type) { switch (reg_type) { - case ModbusRegisterType::COIL: - return ModbusFunctionCode::READ_COILS; - case ModbusRegisterType::DISCRETE_INPUT: - return ModbusFunctionCode::READ_DISCRETE_INPUTS; - case ModbusRegisterType::HOLDING: - return ModbusFunctionCode::READ_HOLDING_REGISTERS; - case ModbusRegisterType::INPUT_REGISTER: - return ModbusFunctionCode::READ_INPUT_REGISTERS; + case EntityType::COIL: + return FunctionCode::READ_COILS; + case EntityType::DISCRETE_INPUT: + return FunctionCode::READ_DISCRETE_INPUTS; + case EntityType::HOLDING: + return FunctionCode::READ_HOLDING_REGISTERS; + case EntityType::INPUT_REGISTER: + return FunctionCode::READ_INPUT_REGISTERS; default: - return ModbusFunctionCode::INVALID; + return FunctionCode::INVALID; } } -inline ModbusFunctionCode modbus_register_write_function(ModbusRegisterType reg_type, bool multiple = false) { +inline FunctionCode modbus_register_write_function(EntityType reg_type, bool multiple = false) { switch (reg_type) { - case ModbusRegisterType::COIL: - return multiple ? ModbusFunctionCode::WRITE_MULTIPLE_COILS : ModbusFunctionCode::WRITE_SINGLE_COIL; - case ModbusRegisterType::HOLDING: - return multiple ? ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS : ModbusFunctionCode::WRITE_SINGLE_REGISTER; + case EntityType::COIL: + return multiple ? FunctionCode::WRITE_MULTIPLE_COILS : FunctionCode::WRITE_SINGLE_COIL; + case EntityType::HOLDING: + return multiple ? FunctionCode::WRITE_MULTIPLE_REGISTERS : FunctionCode::WRITE_SINGLE_REGISTER; // These register types can't be written (per spec) - case ModbusRegisterType::INPUT_REGISTER: - case ModbusRegisterType::DISCRETE_INPUT: + case EntityType::INPUT_REGISTER: + case EntityType::DISCRETE_INPUT: default: - return ModbusFunctionCode::INVALID; + return FunctionCode::INVALID; } } @@ -340,7 +340,7 @@ std::optional registers_to_number(const uint16_t *registers, size_t cou * @param values_len length of values array * @return PDU (function code + data, no address, no CRC) */ -StaticVector create_client_pdu(ModbusFunctionCode function_code, uint16_t start_address, +StaticVector create_client_pdu(FunctionCode function_code, uint16_t start_address, uint16_t number_of_entities, const uint8_t *values = nullptr, size_t values_len = 0); diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index 527e9b047f..35a5479ecb 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -6,7 +6,7 @@ from esphome.components import modbus from esphome.components.modbus.helpers import ( MODBUS_REGISTER_TYPE, TYPE_REGISTER_MAP, - ModbusRegisterType, + EntityType, ) import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_NAME, CONF_OFFSET @@ -217,13 +217,13 @@ async def register_modbus_device(var, config): def function_code_to_register(function_code): FUNCTION_CODE_TYPE_MAP = { - "read_coils": ModbusRegisterType.COIL, - "read_discrete_inputs": ModbusRegisterType.DISCRETE_INPUT, - "read_holding_registers": ModbusRegisterType.HOLDING, - "read_input_registers": ModbusRegisterType.INPUT_REGISTER, - "write_single_coil": ModbusRegisterType.COIL, - "write_single_register": ModbusRegisterType.HOLDING, - "write_multiple_coils": ModbusRegisterType.COIL, - "write_multiple_registers": ModbusRegisterType.HOLDING, + "read_coils": EntityType.COIL, + "read_discrete_inputs": EntityType.DISCRETE_INPUT, + "read_holding_registers": EntityType.HOLDING, + "read_input_registers": EntityType.INPUT_REGISTER, + "write_single_coil": EntityType.COIL, + "write_single_register": EntityType.HOLDING, + "write_multiple_coils": EntityType.COIL, + "write_multiple_registers": EntityType.HOLDING, } return FUNCTION_CODE_TYPE_MAP[function_code] diff --git a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp index 9656013a5f..4175e9e6e4 100644 --- a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp +++ b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp @@ -11,8 +11,8 @@ void ModbusBinarySensor::parse_and_publish(const std::vector &data) { bool value; switch (this->register_type) { - case ModbusRegisterType::DISCRETE_INPUT: - case ModbusRegisterType::COIL: + case EntityType::DISCRETE_INPUT: + case EntityType::COIL: // offset for coil is the actual number of the coil not the byte offset value = modbus::helpers::bit_from_packed(this->offset, data); break; diff --git a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h index 3f7c6b4dd6..518b198eae 100644 --- a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h +++ b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h @@ -10,7 +10,7 @@ namespace esphome::modbus_controller { class ModbusBinarySensor final : public Component, public binary_sensor::BinarySensor, public SensorItem { public: - ModbusBinarySensor(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, + ModbusBinarySensor(EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, uint16_t skip_updates, bool force_new_range) { this->register_type = register_type; this->start_address = start_address; @@ -20,7 +20,7 @@ class ModbusBinarySensor final : public Component, public binary_sensor::BinaryS this->skip_updates = skip_updates; this->force_new_range = force_new_range; - if (register_type == ModbusRegisterType::COIL || register_type == ModbusRegisterType::DISCRETE_INPUT) { + if (register_type == EntityType::COIL || register_type == EntityType::DISCRETE_INPUT) { this->register_count = offset + 1; } else { this->register_count = 1; diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 84f8fc16b8..f8b522b05b 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -95,7 +95,7 @@ void ModbusController::process_modbus_data_(const ModbusCommandItem *response) { response->on_data_func(response->register_type, response->register_address, response->payload); } -void ModbusController::on_error(std::span request_pdu, modbus::ModbusExceptionCode exception_code) { +void ModbusController::on_error(std::span request_pdu, modbus::ExceptionCode exception_code) { // The request function code (request_pdu[0]) already carries what the log needs; the exception bit only // ever appears on the response, so no masking is needed here. const uint8_t function_code = request_pdu.empty() ? 0 : request_pdu[0]; @@ -116,7 +116,7 @@ void ModbusController::on_error(std::span request_pdu, modbus::Mo } } -SensorSet ModbusController::find_sensors_(ModbusRegisterType register_type, uint16_t start_address) const { +SensorSet ModbusController::find_sensors_(EntityType register_type, uint16_t start_address) const { auto reg_it = std::find_if( std::begin(this->register_ranges_), std::end(this->register_ranges_), [=](RegisterRange const &r) { return (r.start_address == start_address && r.register_type == register_type); }); @@ -130,7 +130,7 @@ SensorSet ModbusController::find_sensors_(ModbusRegisterType register_type, uint // not found return {}; } -void ModbusController::on_register_data(ModbusRegisterType register_type, uint16_t start_address, +void ModbusController::on_register_data(EntityType register_type, uint16_t start_address, const std::vector &data) { ESP_LOGV(TAG, "data for register address : 0x%X : ", start_address); @@ -164,18 +164,18 @@ void ModbusController::update_range_(RegisterRange &r) { r.skip_updates_counter); if (r.skip_updates_counter == 0) { // if a custom command is used the user supplied custom_data is only available in the SensorItem. - if (r.register_type == ModbusRegisterType::CUSTOM) { + if (r.register_type == EntityType::CUSTOM) { auto sensors = this->find_sensors_(r.register_type, r.start_address); if (!sensors.empty()) { auto sensor = sensors.cbegin(); auto command_item = ModbusCommandItem::create_custom_command( this, (*sensor)->custom_data, - [this](ModbusRegisterType register_type, uint16_t start_address, const std::vector &data) { - this->on_register_data(ModbusRegisterType::CUSTOM, start_address, data); + [this](EntityType register_type, uint16_t start_address, const std::vector &data) { + this->on_register_data(EntityType::CUSTOM, start_address, data); }); command_item.register_address = (*sensor)->start_address; command_item.register_count = (*sensor)->register_count; - command_item.function_code = ModbusFunctionCode::CUSTOM; + command_item.function_code = FunctionCode::CUSTOM; queue_command(command_item); } } else { @@ -237,7 +237,7 @@ size_t ModbusController::create_register_ranges_() { // this is not the first register in range so it might be possible // to reuse the last register or extend the current range if (!curr->force_new_range && r.register_type == curr->register_type && - curr->register_type != ModbusRegisterType::CUSTOM) { + curr->register_type != EntityType::CUSTOM) { if (curr->start_address == (r.start_address + r.register_count - prev->register_count) && curr->register_count == prev->register_count && curr->get_register_size() == prev->get_register_size()) { // this register can re-use the data from the previous register @@ -347,7 +347,7 @@ void ModbusController::loop() { } } -void ModbusController::on_write_register_response(ModbusRegisterType register_type, uint16_t start_address, +void ModbusController::on_write_register_response(EntityType register_type, uint16_t start_address, const std::vector &data) { ESP_LOGV(TAG, "Command ACK 0x%X %d ", modbus::helpers::get_data(data, 0), modbus::helpers::get_data(data, 1)); @@ -362,9 +362,8 @@ void ModbusController::dump_sensors_() { } ModbusCommandItem ModbusCommandItem::create_read_command( - ModbusController *modbusdevice, ModbusRegisterType register_type, uint16_t start_address, uint16_t register_count, - std::function &data)> - &&handler) { + ModbusController *modbusdevice, EntityType register_type, uint16_t start_address, uint16_t register_count, + std::function &data)> &&handler) { ModbusCommandItem cmd; cmd.modbusdevice = modbusdevice; cmd.register_type = register_type; @@ -375,16 +374,15 @@ ModbusCommandItem ModbusCommandItem::create_read_command( return cmd; } -ModbusCommandItem ModbusCommandItem::create_read_command(ModbusController *modbusdevice, - ModbusRegisterType register_type, uint16_t start_address, - uint16_t register_count) { +ModbusCommandItem ModbusCommandItem::create_read_command(ModbusController *modbusdevice, EntityType register_type, + uint16_t start_address, uint16_t register_count) { ModbusCommandItem cmd; cmd.modbusdevice = modbusdevice; cmd.register_type = register_type; cmd.function_code = modbus::helpers::modbus_register_read_function(register_type); cmd.register_address = start_address; cmd.register_count = register_count; - cmd.on_data_func = [modbusdevice](ModbusRegisterType register_type, uint16_t start_address, + cmd.on_data_func = [modbusdevice](EntityType register_type, uint16_t start_address, const std::vector &data) { modbusdevice->on_register_data(register_type, start_address, data); }; @@ -396,11 +394,11 @@ ModbusCommandItem ModbusCommandItem::create_write_multiple_command(ModbusControl const std::vector &values) { ModbusCommandItem cmd; cmd.modbusdevice = modbusdevice; - cmd.register_type = ModbusRegisterType::HOLDING; - cmd.function_code = ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS; + cmd.register_type = EntityType::HOLDING; + cmd.function_code = FunctionCode::WRITE_MULTIPLE_REGISTERS; cmd.register_address = start_address; cmd.register_count = register_count; - cmd.on_data_func = [modbusdevice, cmd](ModbusRegisterType register_type, uint16_t start_address, + cmd.on_data_func = [modbusdevice, cmd](EntityType register_type, uint16_t start_address, const std::vector &data) { modbusdevice->on_write_register_response(cmd.register_type, start_address, data); }; @@ -416,11 +414,11 @@ ModbusCommandItem ModbusCommandItem::create_write_single_coil(ModbusController * bool value) { ModbusCommandItem cmd; cmd.modbusdevice = modbusdevice; - cmd.register_type = ModbusRegisterType::COIL; - cmd.function_code = ModbusFunctionCode::WRITE_SINGLE_COIL; + cmd.register_type = EntityType::COIL; + cmd.function_code = FunctionCode::WRITE_SINGLE_COIL; cmd.register_address = address; cmd.register_count = 1; - cmd.on_data_func = [modbusdevice, cmd](ModbusRegisterType register_type, uint16_t start_address, + cmd.on_data_func = [modbusdevice, cmd](EntityType register_type, uint16_t start_address, const std::vector &data) { modbusdevice->on_write_register_response(cmd.register_type, start_address, data); }; @@ -433,11 +431,11 @@ ModbusCommandItem ModbusCommandItem::create_write_multiple_coils(ModbusControlle const std::vector &values) { ModbusCommandItem cmd; cmd.modbusdevice = modbusdevice; - cmd.register_type = ModbusRegisterType::COIL; - cmd.function_code = ModbusFunctionCode::WRITE_MULTIPLE_COILS; + cmd.register_type = EntityType::COIL; + cmd.function_code = FunctionCode::WRITE_MULTIPLE_COILS; cmd.register_address = start_address; cmd.register_count = values.size(); - cmd.on_data_func = [modbusdevice, cmd](ModbusRegisterType register_type, uint16_t start_address, + cmd.on_data_func = [modbusdevice, cmd](EntityType register_type, uint16_t start_address, const std::vector &data) { modbusdevice->on_write_register_response(cmd.register_type, start_address, data); }; @@ -465,11 +463,11 @@ ModbusCommandItem ModbusCommandItem::create_write_single_command(ModbusControlle uint16_t value) { ModbusCommandItem cmd; cmd.modbusdevice = modbusdevice; - cmd.register_type = ModbusRegisterType::HOLDING; - cmd.function_code = ModbusFunctionCode::WRITE_SINGLE_REGISTER; + cmd.register_type = EntityType::HOLDING; + cmd.function_code = FunctionCode::WRITE_SINGLE_REGISTER; cmd.register_address = start_address; cmd.register_count = 1; // not used here anyways - cmd.on_data_func = [modbusdevice, cmd](ModbusRegisterType register_type, uint16_t start_address, + cmd.on_data_func = [modbusdevice, cmd](EntityType register_type, uint16_t start_address, const std::vector &data) { modbusdevice->on_write_register_response(cmd.register_type, start_address, data); }; @@ -482,13 +480,12 @@ ModbusCommandItem ModbusCommandItem::create_write_single_command(ModbusControlle ModbusCommandItem ModbusCommandItem::create_custom_command( ModbusController *modbusdevice, const std::vector &values, - std::function &data)> - &&handler) { + std::function &data)> &&handler) { ModbusCommandItem cmd; cmd.modbusdevice = modbusdevice; - cmd.function_code = ModbusFunctionCode::CUSTOM; + cmd.function_code = FunctionCode::CUSTOM; if (handler == nullptr) { - cmd.on_data_func = [](ModbusRegisterType register_type, uint16_t start_address, const std::vector &data) { + cmd.on_data_func = [](EntityType register_type, uint16_t start_address, const std::vector &data) { ESP_LOGI(TAG, "Custom Command sent"); }; } else { @@ -501,13 +498,12 @@ ModbusCommandItem ModbusCommandItem::create_custom_command( ModbusCommandItem ModbusCommandItem::create_custom_command( ModbusController *modbusdevice, const std::vector &values, - std::function &data)> - &&handler) { + std::function &data)> &&handler) { ModbusCommandItem cmd = {}; cmd.modbusdevice = modbusdevice; - cmd.function_code = ModbusFunctionCode::CUSTOM; + cmd.function_code = FunctionCode::CUSTOM; if (handler == nullptr) { - cmd.on_data_func = [](ModbusRegisterType register_type, uint16_t start_address, const std::vector &data) { + cmd.on_data_func = [](EntityType register_type, uint16_t start_address, const std::vector &data) { ESP_LOGI(TAG, "Custom Command sent"); }; } else { @@ -522,7 +518,7 @@ ModbusCommandItem ModbusCommandItem::create_custom_command( } bool ModbusCommandItem::send() { - if (this->function_code != ModbusFunctionCode::CUSTOM) { + if (this->function_code != FunctionCode::CUSTOM) { modbusdevice->send(uint8_t(this->function_code), this->register_address, this->register_count, this->payload.size(), this->payload.empty() ? nullptr : &this->payload[0]); } else { @@ -537,7 +533,7 @@ bool ModbusCommandItem::send() { bool ModbusCommandItem::is_equal(const ModbusCommandItem &other) { // for custom commands we have to check for identical payloads, since // address/count/type fields will be set to zero - return this->function_code == ModbusFunctionCode::CUSTOM + return this->function_code == FunctionCode::CUSTOM ? this->payload == other.payload : other.register_address == this->register_address && other.register_count == this->register_count && other.register_type == this->register_type && other.function_code == this->function_code; diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 23e93057b8..3a9b2f71a9 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -17,22 +17,30 @@ namespace esphome::modbus_controller { class ModbusController; +using modbus::EntityType; +using modbus::ExceptionCode; +using modbus::FunctionCode; +using modbus::helpers::SensorValueType; + +// Remove before 2027.2.0 - deprecated names re-exported so external components keep their warning window +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +using modbus::ModbusExceptionCode; using modbus::ModbusFunctionCode; using modbus::ModbusRegisterType; -using modbus::ModbusExceptionCode; -using modbus::helpers::SensorValueType; +#pragma GCC diagnostic pop // Remove before 2026.10.0 — these helpers have moved to modbus::helpers ESPDEPRECATED("Use modbus::helpers::value_type_is_float() instead. Removed in 2026.10.0", "2026.4.0") inline bool value_type_is_float(SensorValueType v) { return modbus::helpers::value_type_is_float(v); } ESPDEPRECATED("Use modbus::helpers::modbus_register_read_function() instead. Removed in 2026.10.0", "2026.4.0") -inline ModbusFunctionCode modbus_register_read_function(ModbusRegisterType reg_type) { +inline FunctionCode modbus_register_read_function(EntityType reg_type) { return modbus::helpers::modbus_register_read_function(reg_type); } ESPDEPRECATED("Use modbus::helpers::modbus_register_write_function() instead. Removed in 2026.10.0", "2026.4.0") -inline ModbusFunctionCode modbus_register_write_function(ModbusRegisterType reg_type) { +inline FunctionCode modbus_register_write_function(EntityType reg_type) { return modbus::helpers::modbus_register_write_function(reg_type); } @@ -102,7 +110,7 @@ class SensorItem { void set_custom_data(const std::vector &data) { custom_data = data; } size_t virtual get_register_size() const { - if (register_type == ModbusRegisterType::COIL || register_type == ModbusRegisterType::DISCRETE_INPUT) { + if (register_type == EntityType::COIL || register_type == EntityType::DISCRETE_INPUT) { return 1; } else { // if CONF_RESPONSE_BYTES is used override the default return response_bytes > 0 ? response_bytes : register_count * 2; @@ -110,7 +118,7 @@ class SensorItem { } // Override register size for modbus devices not using 1 register for one dword void set_register_size(uint8_t register_size) { response_bytes = register_size; } - ModbusRegisterType register_type{ModbusRegisterType::CUSTOM}; + EntityType register_type{EntityType::CUSTOM}; SensorValueType sensor_value_type{SensorValueType::RAW}; uint16_t start_address{0}; uint32_t bitmask{0}; @@ -157,7 +165,7 @@ using SensorSet = std::set; struct RegisterRange { uint16_t start_address; - ModbusRegisterType register_type; + EntityType register_type; uint8_t register_count; uint16_t skip_updates; // the config value SensorSet sensors; // all sensors of this range @@ -170,10 +178,9 @@ class ModbusCommandItem { ModbusController *modbusdevice{nullptr}; uint16_t register_address{0}; uint16_t register_count{0}; - ModbusFunctionCode function_code{ModbusFunctionCode::CUSTOM}; - ModbusRegisterType register_type{ModbusRegisterType::CUSTOM}; - std::function &data)> - on_data_func; + FunctionCode function_code{FunctionCode::CUSTOM}; + EntityType register_type{EntityType::CUSTOM}; + std::function &data)> on_data_func; std::vector payload = {}; bool send(); /// Check if the command should be retried based on the max_retries parameter @@ -189,10 +196,10 @@ class ModbusCommandItem { * @param handler function called when the response is received * @return ModbusCommandItem with the prepared command */ - static ModbusCommandItem create_read_command( - ModbusController *modbusdevice, ModbusRegisterType register_type, uint16_t start_address, uint16_t register_count, - std::function &data)> - &&handler); + static ModbusCommandItem create_read_command(ModbusController *modbusdevice, EntityType register_type, + uint16_t start_address, uint16_t register_count, + std::function &data)> &&handler); /** Create modbus read command * Function code 02-04 * @param modbusdevice pointer to the device to execute the command @@ -201,7 +208,7 @@ class ModbusCommandItem { * @param register_count number of registers to read * @return ModbusCommandItem with the prepared command */ - static ModbusCommandItem create_read_command(ModbusController *modbusdevice, ModbusRegisterType register_type, + static ModbusCommandItem create_read_command(ModbusController *modbusdevice, EntityType register_type, uint16_t start_address, uint16_t register_count); /** Create modbus read command * Function code 02-04 @@ -251,7 +258,7 @@ class ModbusCommandItem { */ static ModbusCommandItem create_custom_command( ModbusController *modbusdevice, const std::vector &values, - std::function &data)> + std::function &data)> &&handler = nullptr); /** Create custom modbus command @@ -263,7 +270,7 @@ class ModbusCommandItem { */ static ModbusCommandItem create_custom_command( ModbusController *modbusdevice, const std::vector &values, - std::function &data)> + std::function &data)> &&handler = nullptr); bool is_equal(const ModbusCommandItem &other); @@ -296,13 +303,12 @@ class ModbusController final : public PollingComponent, public modbus::ModbusCli /// called when a modbus response was parsed without errors void on_response(std::span request_pdu, std::span response_pdu) override; /// called when a modbus error response was received - void on_error(std::span request_pdu, modbus::ModbusExceptionCode exception_code) override; + void on_error(std::span request_pdu, modbus::ExceptionCode exception_code) override; /// 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); + void on_register_data(EntityType 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 /// incoming queue - void on_write_register_response(ModbusRegisterType register_type, uint16_t start_address, - const std::vector &data); + void on_write_register_response(EntityType register_type, uint16_t start_address, const std::vector &data); /// Allow a duplicate command to be sent void set_allow_duplicate_commands(bool allow_duplicate_commands) { this->allow_duplicate_commands_ = allow_duplicate_commands; @@ -338,7 +344,7 @@ class ModbusController final : public PollingComponent, public modbus::ModbusCli /// parse sensormap_ and create range of sequential addresses size_t create_register_ranges_(); // find register in sensormap. Returns iterator with all registers having the same start address - SensorSet find_sensors_(ModbusRegisterType register_type, uint16_t start_address) const; + SensorSet find_sensors_(EntityType register_type, uint16_t start_address) const; /// submit the read command for the address range to the send queue void update_range_(RegisterRange &r); /// parse incoming modbus data diff --git a/esphome/components/modbus_controller/number/modbus_number.cpp b/esphome/components/modbus_controller/number/modbus_number.cpp index 2c81dd6830..97b490146a 100644 --- a/esphome/components/modbus_controller/number/modbus_number.cpp +++ b/esphome/components/modbus_controller/number/modbus_number.cpp @@ -57,7 +57,7 @@ void ModbusNumber::control(float value) { format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); write_cmd = ModbusCommandItem::create_custom_command( this->parent_, data, - [this, write_cmd](ModbusRegisterType register_type, uint16_t start_address, const std::vector &data) { + [this, write_cmd](EntityType register_type, uint16_t start_address, const std::vector &data) { this->parent_->on_write_register_response(write_cmd.register_type, this->start_address, data); }); } else { @@ -77,7 +77,7 @@ void ModbusNumber::control(float value) { this->parent_, this->start_address + this->offset / 2, this->register_count, data); } // publish new value - write_cmd.on_data_func = [this, write_cmd, value](ModbusRegisterType register_type, uint16_t start_address, + write_cmd.on_data_func = [this, write_cmd, value](EntityType register_type, uint16_t start_address, const std::vector &data) { // gets called when the write command is ack'd from the device this->parent_->on_write_register_response(write_cmd.register_type, start_address, data); diff --git a/esphome/components/modbus_controller/number/modbus_number.h b/esphome/components/modbus_controller/number/modbus_number.h index ce64099170..4bb07f3f39 100644 --- a/esphome/components/modbus_controller/number/modbus_number.h +++ b/esphome/components/modbus_controller/number/modbus_number.h @@ -12,7 +12,7 @@ using value_to_data_t = std::function(float); class ModbusNumber final : public number::Number, public Component, public SensorItem { public: - ModbusNumber(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, + ModbusNumber(EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, SensorValueType value_type, int register_count, uint16_t skip_updates, bool force_new_range) { this->register_type = register_type; this->start_address = start_address; diff --git a/esphome/components/modbus_controller/output/modbus_output.cpp b/esphome/components/modbus_controller/output/modbus_output.cpp index 504e09a093..a3216b3a12 100644 --- a/esphome/components/modbus_controller/output/modbus_output.cpp +++ b/esphome/components/modbus_controller/output/modbus_output.cpp @@ -89,7 +89,7 @@ void ModbusBinaryOutput::write_state(bool state) { format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); cmd = ModbusCommandItem::create_custom_command( this->parent_, data, - [this, cmd](ModbusRegisterType register_type, uint16_t start_address, const std::vector &data) { + [this, cmd](EntityType register_type, uint16_t start_address, const std::vector &data) { this->parent_->on_write_register_response(cmd.register_type, this->start_address, data); }); } else { diff --git a/esphome/components/modbus_controller/output/modbus_output.h b/esphome/components/modbus_controller/output/modbus_output.h index d904e58bd7..f55121a104 100644 --- a/esphome/components/modbus_controller/output/modbus_output.h +++ b/esphome/components/modbus_controller/output/modbus_output.h @@ -11,7 +11,7 @@ namespace esphome::modbus_controller { class ModbusFloatOutput final : public output::FloatOutput, public Component, public SensorItem { public: ModbusFloatOutput(uint16_t start_address, uint8_t offset, SensorValueType value_type, int register_count) { - this->register_type = ModbusRegisterType::HOLDING; + this->register_type = EntityType::HOLDING; this->start_address = start_address; this->offset = offset; this->bitmask = 0xFFFFFFFF; @@ -44,7 +44,7 @@ class ModbusFloatOutput final : public output::FloatOutput, public Component, pu class ModbusBinaryOutput final : public output::BinaryOutput, public Component, public SensorItem { public: ModbusBinaryOutput(uint16_t start_address, uint8_t offset) { - this->register_type = ModbusRegisterType::COIL; + this->register_type = EntityType::COIL; this->start_address = start_address; this->bitmask = 0xFFFFFFFF; this->sensor_value_type = SensorValueType::BIT; diff --git a/esphome/components/modbus_controller/select/modbus_select.h b/esphome/components/modbus_controller/select/modbus_select.h index fb9283305c..9a6f71c64b 100644 --- a/esphome/components/modbus_controller/select/modbus_select.h +++ b/esphome/components/modbus_controller/select/modbus_select.h @@ -13,7 +13,7 @@ class ModbusSelect final : public Component, public select::Select, public Senso public: ModbusSelect(SensorValueType sensor_value_type, uint16_t start_address, uint8_t register_count, uint16_t skip_updates, bool force_new_range, std::vector mapping) { - this->register_type = ModbusRegisterType::HOLDING; // not configurable + this->register_type = EntityType::HOLDING; // not configurable this->sensor_value_type = sensor_value_type; this->start_address = start_address; this->offset = 0; // not configurable diff --git a/esphome/components/modbus_controller/sensor/modbus_sensor.h b/esphome/components/modbus_controller/sensor/modbus_sensor.h index ea4f560b9c..d43746e059 100644 --- a/esphome/components/modbus_controller/sensor/modbus_sensor.h +++ b/esphome/components/modbus_controller/sensor/modbus_sensor.h @@ -10,7 +10,7 @@ namespace esphome::modbus_controller { class ModbusSensor final : public Component, public sensor::Sensor, public SensorItem { public: - ModbusSensor(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, + ModbusSensor(EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, SensorValueType value_type, int register_count, uint16_t skip_updates, bool force_new_range) { this->register_type = register_type; this->start_address = start_address; diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index c8b3868bdc..2a3737889a 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -30,8 +30,8 @@ bool ModbusSwitch::assumed_state() { return this->assumed_state_; } void ModbusSwitch::parse_and_publish(const std::vector &data) { bool value = false; switch (this->register_type) { - case ModbusRegisterType::DISCRETE_INPUT: - case ModbusRegisterType::COIL: + case EntityType::DISCRETE_INPUT: + case EntityType::COIL: // offset for coil is the actual number of the coil not the byte offset value = modbus::helpers::bit_from_packed(this->offset, data); break; @@ -82,13 +82,13 @@ void ModbusSwitch::write_state(bool state) { format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); cmd = ModbusCommandItem::create_custom_command( this->parent_, data, - [this, cmd](ModbusRegisterType register_type, uint16_t start_address, const std::vector &data) { + [this, cmd](EntityType register_type, uint16_t start_address, const std::vector &data) { this->parent_->on_write_register_response(cmd.register_type, this->start_address, data); }); } else { ESP_LOGV(TAG, "write_state '%s': new value = %s type = %d address = %X offset = %x", this->get_name().c_str(), ONOFF(state), (int) this->register_type, this->start_address, this->offset); - if (this->register_type == ModbusRegisterType::COIL) { + if (this->register_type == EntityType::COIL) { // offset for coil and discrete inputs is the coil/register number not bytes if (this->use_write_multiple_) { std::vector states{state}; diff --git a/esphome/components/modbus_controller/switch/modbus_switch.h b/esphome/components/modbus_controller/switch/modbus_switch.h index d6e991582d..82f8fa2a27 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.h +++ b/esphome/components/modbus_controller/switch/modbus_switch.h @@ -10,7 +10,7 @@ namespace esphome::modbus_controller { class ModbusSwitch final : public Component, public switch_::Switch, public SensorItem { public: - ModbusSwitch(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, + ModbusSwitch(EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, uint16_t skip_updates, bool force_new_range) { this->register_type = register_type; this->start_address = start_address; @@ -19,7 +19,7 @@ class ModbusSwitch final : public Component, public switch_::Switch, public Sens this->sensor_value_type = SensorValueType::BIT; this->skip_updates = skip_updates; this->register_count = 1; - if (register_type == ModbusRegisterType::HOLDING || register_type == ModbusRegisterType::COIL) { + if (register_type == EntityType::HOLDING || register_type == EntityType::COIL) { this->start_address += offset; this->offset = 0; } diff --git a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h index e9130c98d4..05b905312a 100644 --- a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h +++ b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h @@ -12,7 +12,7 @@ enum class RawEncoding { NONE = 0, HEXBYTES = 1, COMMA = 2, ANSI = 3 }; class ModbusTextSensor final : public Component, public text_sensor::TextSensor, public SensorItem { public: - ModbusTextSensor(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint8_t register_count, + ModbusTextSensor(EntityType register_type, uint16_t start_address, uint8_t offset, uint8_t register_count, uint16_t response_bytes, RawEncoding encode, uint16_t skip_updates, bool force_new_range) { this->register_type = register_type; this->start_address = start_address; diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp index 4c4e72a086..e649635848 100644 --- a/esphome/components/modbus_server/modbus_server.cpp +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -3,7 +3,7 @@ #include "esphome/core/log.h" namespace esphome::modbus_server { -using modbus::ModbusExceptionCode; +using modbus::ExceptionCode; using modbus::helpers::registers_to_number; static const char *const TAG = "modbus_server"; @@ -50,13 +50,13 @@ modbus::ResponseStatus ModbusServer::on_read_registers(uint16_t start_address, u } ESP_LOGW(TAG, "No register at 0x%04X and courtesy default not allowed. Sending exception response.", static_cast(current_address)); - return ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; + return ExceptionCode::ILLEGAL_DATA_ADDRESS; } if (!server_register->read_lambda) { // Registered but not readable (write-only); don't mask it with the courtesy default. ESP_LOGW(TAG, "Register at 0x%04X is not readable. Sending exception response.", server_register->address); - return ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; + return ExceptionCode::ILLEGAL_DATA_ADDRESS; } // A multi-register value is normally atomic: the request must start at its first register and cover all of @@ -72,7 +72,7 @@ modbus::ResponseStatus ModbusServer::on_read_registers(uint16_t start_address, u "Read clips the multi-register value at 0x%04X, which does not allow partial reads. " "Sending exception response.", server_register->address); - return ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; + return ExceptionCode::ILLEGAL_DATA_ADDRESS; } int64_t value = server_register->read_lambda(); @@ -89,7 +89,7 @@ modbus::ResponseStatus ModbusServer::on_read_registers(uint16_t start_address, u // The value encoded to fewer words than its register span (e.g. a RAW register); treat as a device fault. ESP_LOGE(TAG, "Register at 0x%04X did not encode to %u registers", server_register->address, server_register->register_count); - return ModbusExceptionCode::SERVICE_DEVICE_FAILURE; + return ExceptionCode::SERVICE_DEVICE_FAILURE; } for (uint16_t i = 0; i < take; i++) { registers.push_back(value_words[value_offset + i]); @@ -132,7 +132,7 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address, // so we never apply a partial write before discovering a problem. The commit pass below re-runs // registers_to_number rather than caching the decoded values: using the same function for the check and // the write keeps a single source of truth for the decode bound, independent of how register_count was set. - ModbusExceptionCode precheck = ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; // unmatched or unwritable register + ExceptionCode precheck = ExceptionCode::ILLEGAL_DATA_ADDRESS; // unmatched or unwritable register if (!for_each_register([&precheck, ®isters](ServerRegister *server_register, uint16_t register_offset) -> bool { if (server_register->write_lambda == nullptr) { return false; // unwritable -> ILLEGAL_DATA_ADDRESS @@ -140,7 +140,7 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address, if (!registers_to_number(registers.data() + register_offset, registers.size() - register_offset, server_register->value_type) .has_value()) { - precheck = ModbusExceptionCode::ILLEGAL_DATA_VALUE; // request doesn't supply the full value + precheck = ExceptionCode::ILLEGAL_DATA_VALUE; // request doesn't supply the full value return false; } return true; @@ -158,7 +158,7 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address, return server_register->write_lambda(number); })) { ESP_LOGW(TAG, "A register write callback failed mid-sequence; earlier writes were already applied."); - return ModbusExceptionCode::SERVICE_DEVICE_FAILURE; + return ExceptionCode::SERVICE_DEVICE_FAILURE; } // Success: the caller builds the write response (an echo of the request header). diff --git a/tests/components/modbus/modbus_helpers_test.cpp b/tests/components/modbus/modbus_helpers_test.cpp index 30ba12b16b..0cd6ac6693 100644 --- a/tests/components/modbus/modbus_helpers_test.cpp +++ b/tests/components/modbus/modbus_helpers_test.cpp @@ -4,7 +4,7 @@ namespace esphome::modbus::helpers { -using FC = ModbusFunctionCode; +using FC = FunctionCode; // --- server_frame_length --------------------------------------------------- // Frame layout: address(1) + function(1) + ... + CRC(2). Fixtures borrowed from diff --git a/tests/components/modbus_controller/common.yaml b/tests/components/modbus_controller/common.yaml index 51951a4528..aa2855c2b0 100644 --- a/tests/components/modbus_controller/common.yaml +++ b/tests/components/modbus_controller/common.yaml @@ -18,7 +18,7 @@ binary_sensor: modbus_controller_id: modbus_controller1 id: modbus_binary_sensor2 name: Test Binary Sensor with Lambda - register_type: read + register_type: input address: 0x3201 lambda: |- return x; diff --git a/tests/components/modbus_server/modbus_server_test.cpp b/tests/components/modbus_server/modbus_server_test.cpp index d95bb473c9..8c2e1d16d9 100644 --- a/tests/components/modbus_server/modbus_server_test.cpp +++ b/tests/components/modbus_server/modbus_server_test.cpp @@ -4,7 +4,7 @@ namespace esphome::modbus_server { -using modbus::ModbusExceptionCode; +using modbus::ExceptionCode; using modbus::RegisterValues; namespace { @@ -73,7 +73,7 @@ TEST(ModbusServerWrite, UnderSuppliedValueAppliesNothing) { auto status = server.on_write_registers(0x0000, make_registers({0x1111, 0x2222})); ASSERT_TRUE(status.has_value()); if (status.has_value()) - EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_VALUE); + EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_VALUE); EXPECT_FALSE(word_written); // the writable WORD must NOT have been applied EXPECT_FALSE(dword_written); } @@ -87,7 +87,7 @@ TEST(ModbusServerWrite, UnwritableRegisterRejected) { auto status = server.on_write_registers(0x0000, make_registers({0x1234})); ASSERT_TRUE(status.has_value()); if (status.has_value()) - EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS); } // An address with no registered register yields ILLEGAL_DATA_ADDRESS. @@ -96,7 +96,7 @@ TEST(ModbusServerWrite, UnmatchedAddressRejected) { auto status = server.on_write_registers(0x0005, make_registers({0x1234})); ASSERT_TRUE(status.has_value()); if (status.has_value()) - EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS); } // A write_lambda failing at runtime is the one non-atomic case: the earlier register is already @@ -117,7 +117,7 @@ TEST(ModbusServerWrite, CallbackFailureIsServiceDeviceFailure) { auto status = server.on_write_registers(0x0000, make_registers({0xAAAA, 0xBBBB})); ASSERT_TRUE(status.has_value()); if (status.has_value()) - EXPECT_EQ(status.value(), ModbusExceptionCode::SERVICE_DEVICE_FAILURE); + EXPECT_EQ(status.value(), ExceptionCode::SERVICE_DEVICE_FAILURE); EXPECT_TRUE(first_written); // pre-validation passed, so the first write applied before the failure } @@ -168,7 +168,7 @@ TEST(ModbusServerRead, StartInsideValueRejected) { auto status = server.on_read_registers(0x0011, 1, out); // the second cell of the DWORD ASSERT_TRUE(status.has_value()); if (status.has_value()) - EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS); EXPECT_FALSE(read_called); } @@ -187,7 +187,7 @@ TEST(ModbusServerRead, ClippedTailRejected) { auto status = server.on_read_registers(0x0000, 1, out); // only 1 of the DWORD's 2 registers ASSERT_TRUE(status.has_value()); if (status.has_value()) - EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS); EXPECT_FALSE(read_called); } @@ -203,7 +203,7 @@ TEST(ModbusServerRead, WriteOnlyRegisterRejected) { auto status = server.on_read_registers(0x0000, 1, out); ASSERT_TRUE(status.has_value()); if (status.has_value()) - EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS); } // An unregistered address with courtesy enabled returns the default value for each cell. @@ -227,7 +227,7 @@ TEST(ModbusServerRead, UnregisteredRejectedWithoutCourtesy) { auto status = server.on_read_registers(0x0005, 1, out); ASSERT_TRUE(status.has_value()); if (status.has_value()) - EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS); } // --- partial reads (opt-in) ---------------------------------------------------- From 6648ccc278a29a0d3729be827d71ee8a0296628f Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Fri, 24 Jul 2026 17:01:53 -0700 Subject: [PATCH 1057/1815] [modbus_controller] Qualify modbus::EntityType at use sites (#17845) Co-authored-by: Claude Fable 5 --- .../binary_sensor/modbus_binarysensor.cpp | 4 +- .../binary_sensor/modbus_binarysensor.h | 4 +- .../modbus_controller/modbus_controller.cpp | 52 ++++++++++--------- .../modbus_controller/modbus_controller.h | 37 ++++++------- .../number/modbus_number.cpp | 4 +- .../modbus_controller/number/modbus_number.h | 2 +- .../output/modbus_output.cpp | 2 +- .../modbus_controller/output/modbus_output.h | 4 +- .../modbus_controller/select/modbus_select.h | 2 +- .../modbus_controller/sensor/modbus_sensor.h | 2 +- .../switch/modbus_switch.cpp | 8 +-- .../modbus_controller/switch/modbus_switch.h | 4 +- .../text_sensor/modbus_textsensor.h | 2 +- 13 files changed, 66 insertions(+), 61 deletions(-) diff --git a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp index 4175e9e6e4..d3caaaa3d9 100644 --- a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp +++ b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp @@ -11,8 +11,8 @@ void ModbusBinarySensor::parse_and_publish(const std::vector &data) { bool value; switch (this->register_type) { - case EntityType::DISCRETE_INPUT: - case EntityType::COIL: + case modbus::EntityType::DISCRETE_INPUT: + case modbus::EntityType::COIL: // offset for coil is the actual number of the coil not the byte offset value = modbus::helpers::bit_from_packed(this->offset, data); break; diff --git a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h index 518b198eae..f56a32a5ec 100644 --- a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h +++ b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h @@ -10,7 +10,7 @@ namespace esphome::modbus_controller { class ModbusBinarySensor final : public Component, public binary_sensor::BinarySensor, public SensorItem { public: - ModbusBinarySensor(EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, + ModbusBinarySensor(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, uint16_t skip_updates, bool force_new_range) { this->register_type = register_type; this->start_address = start_address; @@ -20,7 +20,7 @@ class ModbusBinarySensor final : public Component, public binary_sensor::BinaryS this->skip_updates = skip_updates; this->force_new_range = force_new_range; - if (register_type == EntityType::COIL || register_type == EntityType::DISCRETE_INPUT) { + if (register_type == modbus::EntityType::COIL || register_type == modbus::EntityType::DISCRETE_INPUT) { this->register_count = offset + 1; } else { this->register_count = 1; diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index f8b522b05b..8a81acab3a 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -116,7 +116,7 @@ void ModbusController::on_error(std::span request_pdu, modbus::Ex } } -SensorSet ModbusController::find_sensors_(EntityType register_type, uint16_t start_address) const { +SensorSet ModbusController::find_sensors_(modbus::EntityType register_type, uint16_t start_address) const { auto reg_it = std::find_if( std::begin(this->register_ranges_), std::end(this->register_ranges_), [=](RegisterRange const &r) { return (r.start_address == start_address && r.register_type == register_type); }); @@ -130,7 +130,7 @@ SensorSet ModbusController::find_sensors_(EntityType register_type, uint16_t sta // not found return {}; } -void ModbusController::on_register_data(EntityType register_type, uint16_t start_address, +void ModbusController::on_register_data(modbus::EntityType register_type, uint16_t start_address, const std::vector &data) { ESP_LOGV(TAG, "data for register address : 0x%X : ", start_address); @@ -164,14 +164,14 @@ void ModbusController::update_range_(RegisterRange &r) { r.skip_updates_counter); if (r.skip_updates_counter == 0) { // if a custom command is used the user supplied custom_data is only available in the SensorItem. - if (r.register_type == EntityType::CUSTOM) { + if (r.register_type == modbus::EntityType::CUSTOM) { auto sensors = this->find_sensors_(r.register_type, r.start_address); if (!sensors.empty()) { auto sensor = sensors.cbegin(); auto command_item = ModbusCommandItem::create_custom_command( this, (*sensor)->custom_data, - [this](EntityType register_type, uint16_t start_address, const std::vector &data) { - this->on_register_data(EntityType::CUSTOM, start_address, data); + [this](modbus::EntityType register_type, uint16_t start_address, const std::vector &data) { + this->on_register_data(modbus::EntityType::CUSTOM, start_address, data); }); command_item.register_address = (*sensor)->start_address; command_item.register_count = (*sensor)->register_count; @@ -237,7 +237,7 @@ size_t ModbusController::create_register_ranges_() { // this is not the first register in range so it might be possible // to reuse the last register or extend the current range if (!curr->force_new_range && r.register_type == curr->register_type && - curr->register_type != EntityType::CUSTOM) { + curr->register_type != modbus::EntityType::CUSTOM) { if (curr->start_address == (r.start_address + r.register_count - prev->register_count) && curr->register_count == prev->register_count && curr->get_register_size() == prev->get_register_size()) { // this register can re-use the data from the previous register @@ -347,7 +347,7 @@ void ModbusController::loop() { } } -void ModbusController::on_write_register_response(EntityType register_type, uint16_t start_address, +void ModbusController::on_write_register_response(modbus::EntityType register_type, uint16_t start_address, const std::vector &data) { ESP_LOGV(TAG, "Command ACK 0x%X %d ", modbus::helpers::get_data(data, 0), modbus::helpers::get_data(data, 1)); @@ -362,8 +362,9 @@ void ModbusController::dump_sensors_() { } ModbusCommandItem ModbusCommandItem::create_read_command( - ModbusController *modbusdevice, EntityType register_type, uint16_t start_address, uint16_t register_count, - std::function &data)> &&handler) { + ModbusController *modbusdevice, modbus::EntityType register_type, uint16_t start_address, uint16_t register_count, + std::function &data)> + &&handler) { ModbusCommandItem cmd; cmd.modbusdevice = modbusdevice; cmd.register_type = register_type; @@ -374,15 +375,16 @@ ModbusCommandItem ModbusCommandItem::create_read_command( return cmd; } -ModbusCommandItem ModbusCommandItem::create_read_command(ModbusController *modbusdevice, EntityType register_type, - uint16_t start_address, uint16_t register_count) { +ModbusCommandItem ModbusCommandItem::create_read_command(ModbusController *modbusdevice, + modbus::EntityType register_type, uint16_t start_address, + uint16_t register_count) { ModbusCommandItem cmd; cmd.modbusdevice = modbusdevice; cmd.register_type = register_type; cmd.function_code = modbus::helpers::modbus_register_read_function(register_type); cmd.register_address = start_address; cmd.register_count = register_count; - cmd.on_data_func = [modbusdevice](EntityType register_type, uint16_t start_address, + cmd.on_data_func = [modbusdevice](modbus::EntityType register_type, uint16_t start_address, const std::vector &data) { modbusdevice->on_register_data(register_type, start_address, data); }; @@ -394,11 +396,11 @@ ModbusCommandItem ModbusCommandItem::create_write_multiple_command(ModbusControl const std::vector &values) { ModbusCommandItem cmd; cmd.modbusdevice = modbusdevice; - cmd.register_type = EntityType::HOLDING; + cmd.register_type = modbus::EntityType::HOLDING; cmd.function_code = FunctionCode::WRITE_MULTIPLE_REGISTERS; cmd.register_address = start_address; cmd.register_count = register_count; - cmd.on_data_func = [modbusdevice, cmd](EntityType register_type, uint16_t start_address, + cmd.on_data_func = [modbusdevice, cmd](modbus::EntityType register_type, uint16_t start_address, const std::vector &data) { modbusdevice->on_write_register_response(cmd.register_type, start_address, data); }; @@ -414,11 +416,11 @@ ModbusCommandItem ModbusCommandItem::create_write_single_coil(ModbusController * bool value) { ModbusCommandItem cmd; cmd.modbusdevice = modbusdevice; - cmd.register_type = EntityType::COIL; + cmd.register_type = modbus::EntityType::COIL; cmd.function_code = FunctionCode::WRITE_SINGLE_COIL; cmd.register_address = address; cmd.register_count = 1; - cmd.on_data_func = [modbusdevice, cmd](EntityType register_type, uint16_t start_address, + cmd.on_data_func = [modbusdevice, cmd](modbus::EntityType register_type, uint16_t start_address, const std::vector &data) { modbusdevice->on_write_register_response(cmd.register_type, start_address, data); }; @@ -431,11 +433,11 @@ ModbusCommandItem ModbusCommandItem::create_write_multiple_coils(ModbusControlle const std::vector &values) { ModbusCommandItem cmd; cmd.modbusdevice = modbusdevice; - cmd.register_type = EntityType::COIL; + cmd.register_type = modbus::EntityType::COIL; cmd.function_code = FunctionCode::WRITE_MULTIPLE_COILS; cmd.register_address = start_address; cmd.register_count = values.size(); - cmd.on_data_func = [modbusdevice, cmd](EntityType register_type, uint16_t start_address, + cmd.on_data_func = [modbusdevice, cmd](modbus::EntityType register_type, uint16_t start_address, const std::vector &data) { modbusdevice->on_write_register_response(cmd.register_type, start_address, data); }; @@ -463,11 +465,11 @@ ModbusCommandItem ModbusCommandItem::create_write_single_command(ModbusControlle uint16_t value) { ModbusCommandItem cmd; cmd.modbusdevice = modbusdevice; - cmd.register_type = EntityType::HOLDING; + cmd.register_type = modbus::EntityType::HOLDING; cmd.function_code = FunctionCode::WRITE_SINGLE_REGISTER; cmd.register_address = start_address; cmd.register_count = 1; // not used here anyways - cmd.on_data_func = [modbusdevice, cmd](EntityType register_type, uint16_t start_address, + cmd.on_data_func = [modbusdevice, cmd](modbus::EntityType register_type, uint16_t start_address, const std::vector &data) { modbusdevice->on_write_register_response(cmd.register_type, start_address, data); }; @@ -480,12 +482,13 @@ ModbusCommandItem ModbusCommandItem::create_write_single_command(ModbusControlle ModbusCommandItem ModbusCommandItem::create_custom_command( ModbusController *modbusdevice, const std::vector &values, - std::function &data)> &&handler) { + std::function &data)> + &&handler) { ModbusCommandItem cmd; cmd.modbusdevice = modbusdevice; cmd.function_code = FunctionCode::CUSTOM; if (handler == nullptr) { - cmd.on_data_func = [](EntityType register_type, uint16_t start_address, const std::vector &data) { + cmd.on_data_func = [](modbus::EntityType register_type, uint16_t start_address, const std::vector &data) { ESP_LOGI(TAG, "Custom Command sent"); }; } else { @@ -498,12 +501,13 @@ ModbusCommandItem ModbusCommandItem::create_custom_command( ModbusCommandItem ModbusCommandItem::create_custom_command( ModbusController *modbusdevice, const std::vector &values, - std::function &data)> &&handler) { + std::function &data)> + &&handler) { ModbusCommandItem cmd = {}; cmd.modbusdevice = modbusdevice; cmd.function_code = FunctionCode::CUSTOM; if (handler == nullptr) { - cmd.on_data_func = [](EntityType register_type, uint16_t start_address, const std::vector &data) { + cmd.on_data_func = [](modbus::EntityType register_type, uint16_t start_address, const std::vector &data) { ESP_LOGI(TAG, "Custom Command sent"); }; } else { diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 3a9b2f71a9..5315a4f325 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -17,7 +17,6 @@ namespace esphome::modbus_controller { class ModbusController; -using modbus::EntityType; using modbus::ExceptionCode; using modbus::FunctionCode; using modbus::helpers::SensorValueType; @@ -35,12 +34,12 @@ ESPDEPRECATED("Use modbus::helpers::value_type_is_float() instead. Removed in 20 inline bool value_type_is_float(SensorValueType v) { return modbus::helpers::value_type_is_float(v); } ESPDEPRECATED("Use modbus::helpers::modbus_register_read_function() instead. Removed in 2026.10.0", "2026.4.0") -inline FunctionCode modbus_register_read_function(EntityType reg_type) { +inline FunctionCode modbus_register_read_function(modbus::EntityType reg_type) { return modbus::helpers::modbus_register_read_function(reg_type); } ESPDEPRECATED("Use modbus::helpers::modbus_register_write_function() instead. Removed in 2026.10.0", "2026.4.0") -inline FunctionCode modbus_register_write_function(EntityType reg_type) { +inline FunctionCode modbus_register_write_function(modbus::EntityType reg_type) { return modbus::helpers::modbus_register_write_function(reg_type); } @@ -110,7 +109,7 @@ class SensorItem { void set_custom_data(const std::vector &data) { custom_data = data; } size_t virtual get_register_size() const { - if (register_type == EntityType::COIL || register_type == EntityType::DISCRETE_INPUT) { + if (register_type == modbus::EntityType::COIL || register_type == modbus::EntityType::DISCRETE_INPUT) { return 1; } else { // if CONF_RESPONSE_BYTES is used override the default return response_bytes > 0 ? response_bytes : register_count * 2; @@ -118,7 +117,7 @@ class SensorItem { } // Override register size for modbus devices not using 1 register for one dword void set_register_size(uint8_t register_size) { response_bytes = register_size; } - EntityType register_type{EntityType::CUSTOM}; + modbus::EntityType register_type{modbus::EntityType::CUSTOM}; SensorValueType sensor_value_type{SensorValueType::RAW}; uint16_t start_address{0}; uint32_t bitmask{0}; @@ -165,7 +164,7 @@ using SensorSet = std::set; struct RegisterRange { uint16_t start_address; - EntityType register_type; + modbus::EntityType register_type; uint8_t register_count; uint16_t skip_updates; // the config value SensorSet sensors; // all sensors of this range @@ -179,8 +178,9 @@ class ModbusCommandItem { uint16_t register_address{0}; uint16_t register_count{0}; FunctionCode function_code{FunctionCode::CUSTOM}; - EntityType register_type{EntityType::CUSTOM}; - std::function &data)> on_data_func; + modbus::EntityType register_type{modbus::EntityType::CUSTOM}; + std::function &data)> + on_data_func; std::vector payload = {}; bool send(); /// Check if the command should be retried based on the max_retries parameter @@ -196,10 +196,10 @@ class ModbusCommandItem { * @param handler function called when the response is received * @return ModbusCommandItem with the prepared command */ - static ModbusCommandItem create_read_command(ModbusController *modbusdevice, EntityType register_type, - uint16_t start_address, uint16_t register_count, - std::function &data)> &&handler); + static ModbusCommandItem create_read_command( + ModbusController *modbusdevice, modbus::EntityType register_type, uint16_t start_address, uint16_t register_count, + std::function &data)> + &&handler); /** Create modbus read command * Function code 02-04 * @param modbusdevice pointer to the device to execute the command @@ -208,7 +208,7 @@ class ModbusCommandItem { * @param register_count number of registers to read * @return ModbusCommandItem with the prepared command */ - static ModbusCommandItem create_read_command(ModbusController *modbusdevice, EntityType register_type, + static ModbusCommandItem create_read_command(ModbusController *modbusdevice, modbus::EntityType register_type, uint16_t start_address, uint16_t register_count); /** Create modbus read command * Function code 02-04 @@ -258,7 +258,7 @@ class ModbusCommandItem { */ static ModbusCommandItem create_custom_command( ModbusController *modbusdevice, const std::vector &values, - std::function &data)> + std::function &data)> &&handler = nullptr); /** Create custom modbus command @@ -270,7 +270,7 @@ class ModbusCommandItem { */ static ModbusCommandItem create_custom_command( ModbusController *modbusdevice, const std::vector &values, - std::function &data)> + std::function &data)> &&handler = nullptr); bool is_equal(const ModbusCommandItem &other); @@ -305,10 +305,11 @@ class ModbusController final : public PollingComponent, public modbus::ModbusCli /// called when a modbus error response was received void on_error(std::span request_pdu, modbus::ExceptionCode exception_code) override; /// default delegate called by process_modbus_data when a response has retrieved from the incoming queue - void on_register_data(EntityType register_type, uint16_t start_address, const std::vector &data); + void on_register_data(modbus::EntityType 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 /// incoming queue - void on_write_register_response(EntityType register_type, uint16_t start_address, const std::vector &data); + void on_write_register_response(modbus::EntityType register_type, uint16_t start_address, + const std::vector &data); /// Allow a duplicate command to be sent void set_allow_duplicate_commands(bool allow_duplicate_commands) { this->allow_duplicate_commands_ = allow_duplicate_commands; @@ -344,7 +345,7 @@ class ModbusController final : public PollingComponent, public modbus::ModbusCli /// parse sensormap_ and create range of sequential addresses size_t create_register_ranges_(); // find register in sensormap. Returns iterator with all registers having the same start address - SensorSet find_sensors_(EntityType register_type, uint16_t start_address) const; + SensorSet find_sensors_(modbus::EntityType register_type, uint16_t start_address) const; /// submit the read command for the address range to the send queue void update_range_(RegisterRange &r); /// parse incoming modbus data diff --git a/esphome/components/modbus_controller/number/modbus_number.cpp b/esphome/components/modbus_controller/number/modbus_number.cpp index 97b490146a..fdb770fd96 100644 --- a/esphome/components/modbus_controller/number/modbus_number.cpp +++ b/esphome/components/modbus_controller/number/modbus_number.cpp @@ -57,7 +57,7 @@ void ModbusNumber::control(float value) { format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); write_cmd = ModbusCommandItem::create_custom_command( this->parent_, data, - [this, write_cmd](EntityType register_type, uint16_t start_address, const std::vector &data) { + [this, write_cmd](modbus::EntityType register_type, uint16_t start_address, const std::vector &data) { this->parent_->on_write_register_response(write_cmd.register_type, this->start_address, data); }); } else { @@ -77,7 +77,7 @@ void ModbusNumber::control(float value) { this->parent_, this->start_address + this->offset / 2, this->register_count, data); } // publish new value - write_cmd.on_data_func = [this, write_cmd, value](EntityType register_type, uint16_t start_address, + write_cmd.on_data_func = [this, write_cmd, value](modbus::EntityType register_type, uint16_t start_address, const std::vector &data) { // gets called when the write command is ack'd from the device this->parent_->on_write_register_response(write_cmd.register_type, start_address, data); diff --git a/esphome/components/modbus_controller/number/modbus_number.h b/esphome/components/modbus_controller/number/modbus_number.h index 4bb07f3f39..582b042caf 100644 --- a/esphome/components/modbus_controller/number/modbus_number.h +++ b/esphome/components/modbus_controller/number/modbus_number.h @@ -12,7 +12,7 @@ using value_to_data_t = std::function(float); class ModbusNumber final : public number::Number, public Component, public SensorItem { public: - ModbusNumber(EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, + ModbusNumber(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, SensorValueType value_type, int register_count, uint16_t skip_updates, bool force_new_range) { this->register_type = register_type; this->start_address = start_address; diff --git a/esphome/components/modbus_controller/output/modbus_output.cpp b/esphome/components/modbus_controller/output/modbus_output.cpp index a3216b3a12..ffe6f3bdfa 100644 --- a/esphome/components/modbus_controller/output/modbus_output.cpp +++ b/esphome/components/modbus_controller/output/modbus_output.cpp @@ -89,7 +89,7 @@ void ModbusBinaryOutput::write_state(bool state) { format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); cmd = ModbusCommandItem::create_custom_command( this->parent_, data, - [this, cmd](EntityType register_type, uint16_t start_address, const std::vector &data) { + [this, cmd](modbus::EntityType register_type, uint16_t start_address, const std::vector &data) { this->parent_->on_write_register_response(cmd.register_type, this->start_address, data); }); } else { diff --git a/esphome/components/modbus_controller/output/modbus_output.h b/esphome/components/modbus_controller/output/modbus_output.h index f55121a104..c9efd42224 100644 --- a/esphome/components/modbus_controller/output/modbus_output.h +++ b/esphome/components/modbus_controller/output/modbus_output.h @@ -11,7 +11,7 @@ namespace esphome::modbus_controller { class ModbusFloatOutput final : public output::FloatOutput, public Component, public SensorItem { public: ModbusFloatOutput(uint16_t start_address, uint8_t offset, SensorValueType value_type, int register_count) { - this->register_type = EntityType::HOLDING; + this->register_type = modbus::EntityType::HOLDING; this->start_address = start_address; this->offset = offset; this->bitmask = 0xFFFFFFFF; @@ -44,7 +44,7 @@ class ModbusFloatOutput final : public output::FloatOutput, public Component, pu class ModbusBinaryOutput final : public output::BinaryOutput, public Component, public SensorItem { public: ModbusBinaryOutput(uint16_t start_address, uint8_t offset) { - this->register_type = EntityType::COIL; + this->register_type = modbus::EntityType::COIL; this->start_address = start_address; this->bitmask = 0xFFFFFFFF; this->sensor_value_type = SensorValueType::BIT; diff --git a/esphome/components/modbus_controller/select/modbus_select.h b/esphome/components/modbus_controller/select/modbus_select.h index 9a6f71c64b..b4834ba4c6 100644 --- a/esphome/components/modbus_controller/select/modbus_select.h +++ b/esphome/components/modbus_controller/select/modbus_select.h @@ -13,7 +13,7 @@ class ModbusSelect final : public Component, public select::Select, public Senso public: ModbusSelect(SensorValueType sensor_value_type, uint16_t start_address, uint8_t register_count, uint16_t skip_updates, bool force_new_range, std::vector mapping) { - this->register_type = EntityType::HOLDING; // not configurable + this->register_type = modbus::EntityType::HOLDING; // not configurable this->sensor_value_type = sensor_value_type; this->start_address = start_address; this->offset = 0; // not configurable diff --git a/esphome/components/modbus_controller/sensor/modbus_sensor.h b/esphome/components/modbus_controller/sensor/modbus_sensor.h index d43746e059..1d11aa4d66 100644 --- a/esphome/components/modbus_controller/sensor/modbus_sensor.h +++ b/esphome/components/modbus_controller/sensor/modbus_sensor.h @@ -10,7 +10,7 @@ namespace esphome::modbus_controller { class ModbusSensor final : public Component, public sensor::Sensor, public SensorItem { public: - ModbusSensor(EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, + ModbusSensor(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, SensorValueType value_type, int register_count, uint16_t skip_updates, bool force_new_range) { this->register_type = register_type; this->start_address = start_address; diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index 2a3737889a..b8cdbf018d 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -30,8 +30,8 @@ bool ModbusSwitch::assumed_state() { return this->assumed_state_; } void ModbusSwitch::parse_and_publish(const std::vector &data) { bool value = false; switch (this->register_type) { - case EntityType::DISCRETE_INPUT: - case EntityType::COIL: + case modbus::EntityType::DISCRETE_INPUT: + case modbus::EntityType::COIL: // offset for coil is the actual number of the coil not the byte offset value = modbus::helpers::bit_from_packed(this->offset, data); break; @@ -82,13 +82,13 @@ void ModbusSwitch::write_state(bool state) { format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); cmd = ModbusCommandItem::create_custom_command( this->parent_, data, - [this, cmd](EntityType register_type, uint16_t start_address, const std::vector &data) { + [this, cmd](modbus::EntityType register_type, uint16_t start_address, const std::vector &data) { this->parent_->on_write_register_response(cmd.register_type, this->start_address, data); }); } else { ESP_LOGV(TAG, "write_state '%s': new value = %s type = %d address = %X offset = %x", this->get_name().c_str(), ONOFF(state), (int) this->register_type, this->start_address, this->offset); - if (this->register_type == EntityType::COIL) { + if (this->register_type == modbus::EntityType::COIL) { // offset for coil and discrete inputs is the coil/register number not bytes if (this->use_write_multiple_) { std::vector states{state}; diff --git a/esphome/components/modbus_controller/switch/modbus_switch.h b/esphome/components/modbus_controller/switch/modbus_switch.h index 82f8fa2a27..0d5456aa63 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.h +++ b/esphome/components/modbus_controller/switch/modbus_switch.h @@ -10,7 +10,7 @@ namespace esphome::modbus_controller { class ModbusSwitch final : public Component, public switch_::Switch, public SensorItem { public: - ModbusSwitch(EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, + ModbusSwitch(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, uint16_t skip_updates, bool force_new_range) { this->register_type = register_type; this->start_address = start_address; @@ -19,7 +19,7 @@ class ModbusSwitch final : public Component, public switch_::Switch, public Sens this->sensor_value_type = SensorValueType::BIT; this->skip_updates = skip_updates; this->register_count = 1; - if (register_type == EntityType::HOLDING || register_type == EntityType::COIL) { + if (register_type == modbus::EntityType::HOLDING || register_type == modbus::EntityType::COIL) { this->start_address += offset; this->offset = 0; } diff --git a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h index 05b905312a..c7381d7ddd 100644 --- a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h +++ b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h @@ -12,7 +12,7 @@ enum class RawEncoding { NONE = 0, HEXBYTES = 1, COMMA = 2, ANSI = 3 }; class ModbusTextSensor final : public Component, public text_sensor::TextSensor, public SensorItem { public: - ModbusTextSensor(EntityType register_type, uint16_t start_address, uint8_t offset, uint8_t register_count, + ModbusTextSensor(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint8_t register_count, uint16_t response_bytes, RawEncoding encode, uint16_t skip_updates, bool force_new_range) { this->register_type = register_type; this->start_address = start_address; From eb7575f59e3cdc3b54c5d97c2c14c967dec6bbe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20P=C3=B6ttgen?= Date: Sat, 25 Jul 2026 03:33:17 +0200 Subject: [PATCH 1058/1815] [gsl3670] Add a model configuration for the Guition JC8012P4A1 (#17843) --- esphome/components/gsl3670/touchscreen.py | 15 +++++++++++++ tests/component_tests/gsl3670/test_init.py | 25 ++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/esphome/components/gsl3670/touchscreen.py b/esphome/components/gsl3670/touchscreen.py index 11bb24ce44..fc0318f076 100644 --- a/esphome/components/gsl3670/touchscreen.py +++ b/esphome/components/gsl3670/touchscreen.py @@ -68,6 +68,21 @@ MODELS = { CONF_SHA256: "2e50501ad83656fb6fa3d92591f9f31add4d442c8e8a79f29f5c4d335bd127a4", }, }, + "GUITION-JC8012P4A1": { + CONF_SWAP_XY: True, + CONF_MIRROR_X: True, + CONF_MIRROR_Y: False, + CONF_X_MIN: 20, + CONF_Y_MIN: 20, + CONF_X_MAX: 880, + CONF_Y_MAX: 1648, + CONF_RESET_PIN: 22, + CONF_INTERRUPT_PIN: 21, + CONF_FIRMWARE: { + CONF_URL: f"{FIRMWARE_BASE_URL}/seeed-d1001-fw.bin", + CONF_SHA256: "2e50501ad83656fb6fa3d92591f9f31add4d442c8e8a79f29f5c4d335bd127a4", + }, + }, "CUSTOM": {}, } diff --git a/tests/component_tests/gsl3670/test_init.py b/tests/component_tests/gsl3670/test_init.py index 3778cf8aa5..8528cf23ca 100644 --- a/tests/component_tests/gsl3670/test_init.py +++ b/tests/component_tests/gsl3670/test_init.py @@ -254,6 +254,31 @@ def test_config_seeed_model_applies_defaults(tmp_path: Path) -> None: assert CONF_RESET_PIN in result +def test_config_guition_model_applies_defaults(tmp_path: Path) -> None: + """The GUITION model populates transform and calibration defaults.""" + fw = _write_firmware(tmp_path) + result = gsl.CONFIG_SCHEMA( + { + "model": "guition-jc8012p4a1", + "firmware": {"file": str(fw)}, + } + ) + assert result[CONF_MODEL] == "GUITION-JC8012P4A1" + # Transform defaults from the model. + assert result[CONF_TRANSFORM] == { + "swap_xy": True, + "mirror_x": True, + "mirror_y": False, + } + # Calibration defaults from the model. + assert result[CONF_CALIBRATION]["x_min"] == 20 + assert result[CONF_CALIBRATION]["x_max"] == 880 + assert result[CONF_CALIBRATION]["y_min"] == 20 + assert result[CONF_CALIBRATION]["y_max"] == 1648 + assert result[CONF_INTERRUPT_PIN]["number"] == 21 + assert result[CONF_RESET_PIN]["number"] == 22 + + def test_config_rejects_non_dict() -> None: """A non-dict configuration is rejected.""" with pytest.raises(cv.Invalid, match="expected a dictionary"): From 58385bebff34b4f3b2acc3c9ca54b7395499b3ca Mon Sep 17 00:00:00 2001 From: Ryan Gammon Date: Fri, 24 Jul 2026 19:57:17 -0700 Subject: [PATCH 1059/1815] [mipi_spi] Add ST77916 / ESP-VoCat model (#17679) --- esphome/components/mipi_spi/models/st77916.py | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 esphome/components/mipi_spi/models/st77916.py diff --git a/esphome/components/mipi_spi/models/st77916.py b/esphome/components/mipi_spi/models/st77916.py new file mode 100644 index 0000000000..38852f135f --- /dev/null +++ b/esphome/components/mipi_spi/models/st77916.py @@ -0,0 +1,269 @@ +# Init sequence sourced from Espressif's esp-bsp repository: +# https://github.com/espressif/esp-bsp/blob/master/bsp/esp_vocat/priv_include/disp_init_data.h +# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: Apache-2.0 + +from esphome.components.mipi import MODE_RGB, DriverChip +from esphome.components.spi import TYPE_QUAD +from esphome.const import CONF_INVERTED, CONF_NUMBER + +# Init sequence for the ST77916 QSPI display on the ESP-VoCat v1.2 board. +# Source: disp_init_data.h from espressif/esp-bsp (bsp/esp_vocat/priv_include). +# The standard INVON/INVOFF and SLPOUT+DISPON commands (and required delays) are omitted because +# the mipi_spi framework appends them automatically based on the invert_colors and no_slpout settings. +# (So this sequence only contains panel-specific setup commands.) +_ESP_VOCAT_INIT = ( + # Page 1a — startup unlock + (0xF0, 0x28), + (0xF2, 0x28), + (0x73, 0xF0), + (0x7C, 0xD1), + (0x83, 0xE0), + (0x84, 0x61), + (0xF2, 0x82), + # Switch to page 0 → page 1 + (0xF0, 0x00), + (0xF0, 0x01), + (0xF1, 0x01), + # Power settings (Bx) + (0xB0, 0x56), + (0xB1, 0x4D), + (0xB2, 0x24), + (0xB4, 0x87), + (0xB5, 0x44), + (0xB6, 0x8B), + (0xB7, 0x40), + (0xB8, 0x86), + (0xBA, 0x00), + (0xBB, 0x08), + (0xBC, 0x08), + (0xBD, 0x00), + # VCOM / gate settings (Cx) + (0xC0, 0x80), + (0xC1, 0x10), + (0xC2, 0x37), + (0xC3, 0x80), + (0xC4, 0x10), + (0xC5, 0x37), + (0xC6, 0xA9), + (0xC7, 0x41), + (0xC8, 0x01), + (0xC9, 0xA9), + (0xCA, 0x41), + (0xCB, 0x01), + # Source settings (Dx) + (0xD0, 0x91), + (0xD1, 0x68), + (0xD2, 0x68), + # Misc + (0xF5, 0x00, 0xA5), + (0xDD, 0x4F), + (0xDE, 0x4F), + (0xF1, 0x10), + (0xF0, 0x00), + # Switch to page 2 — gamma + (0xF0, 0x02), + ( + 0xE0, + 0xF0, + 0x0A, + 0x10, + 0x09, + 0x09, + 0x36, + 0x35, + 0x33, + 0x4A, + 0x29, + 0x15, + 0x15, + 0x2E, + 0x34, + ), + ( + 0xE1, + 0xF0, + 0x0A, + 0x0F, + 0x08, + 0x08, + 0x05, + 0x34, + 0x33, + 0x4A, + 0x39, + 0x15, + 0x15, + 0x2D, + 0x33, + ), + # Switch to page 10 — GIP / timing + (0xF0, 0x10), + (0xF3, 0x10), + # Page 10: Exxx + (0xE0, 0x07), + (0xE1, 0x00), + (0xE2, 0x00), + (0xE3, 0x00), + (0xE4, 0xE0), + (0xE5, 0x06), + (0xE6, 0x21), + (0xE7, 0x01), + (0xE8, 0x05), + (0xE9, 0x02), + (0xEA, 0xDA), + (0xEB, 0x00), + (0xEC, 0x00), + (0xED, 0x0F), + (0xEE, 0x00), + (0xEF, 0x00), + # Page 10: Fxxx + (0xF8, 0x00), + (0xF9, 0x00), + (0xFA, 0x00), + (0xFB, 0x00), + (0xFC, 0x00), + (0xFD, 0x00), + (0xFE, 0x00), + (0xFF, 0x00), + # GIP section A (0x60–0x6B) + (0x60, 0x40), + (0x61, 0x04), + (0x62, 0x00), + (0x63, 0x42), + (0x64, 0xD9), + (0x65, 0x00), + (0x66, 0x00), + (0x67, 0x00), + (0x68, 0x00), + (0x69, 0x00), + (0x6A, 0x00), + (0x6B, 0x00), + # GIP section B (0x70–0x7B) + (0x70, 0x40), + (0x71, 0x03), + (0x72, 0x00), + (0x73, 0x42), + (0x74, 0xD8), + (0x75, 0x00), + (0x76, 0x00), + (0x77, 0x00), + (0x78, 0x00), + (0x79, 0x00), + (0x7A, 0x00), + (0x7B, 0x00), + # GIP timing (0x80–0x9F) + (0x80, 0x48), + (0x81, 0x00), + (0x82, 0x06), + (0x83, 0x02), + (0x84, 0xD6), + (0x85, 0x04), + (0x86, 0x00), + (0x87, 0x00), + (0x88, 0x48), + (0x89, 0x00), + (0x8A, 0x08), + (0x8B, 0x02), + (0x8C, 0xD8), + (0x8D, 0x04), + (0x8E, 0x00), + (0x8F, 0x00), + (0x90, 0x48), + (0x91, 0x00), + (0x92, 0x0A), + (0x93, 0x02), + (0x94, 0xDA), + (0x95, 0x04), + (0x96, 0x00), + (0x97, 0x00), + (0x98, 0x48), + (0x99, 0x00), + (0x9A, 0x0C), + (0x9B, 0x02), + (0x9C, 0xDC), + (0x9D, 0x04), + (0x9E, 0x00), + (0x9F, 0x00), + # GIP timing (0xA0–0xBF) + (0xA0, 0x48), + (0xA1, 0x00), + (0xA2, 0x05), + (0xA3, 0x02), + (0xA4, 0xD5), + (0xA5, 0x04), + (0xA6, 0x00), + (0xA7, 0x00), + (0xA8, 0x48), + (0xA9, 0x00), + (0xAA, 0x07), + (0xAB, 0x02), + (0xAC, 0xD7), + (0xAD, 0x04), + (0xAE, 0x00), + (0xAF, 0x00), + (0xB0, 0x48), + (0xB1, 0x00), + (0xB2, 0x09), + (0xB3, 0x02), + (0xB4, 0xD9), + (0xB5, 0x04), + (0xB6, 0x00), + (0xB7, 0x00), + (0xB8, 0x48), + (0xB9, 0x00), + (0xBA, 0x0B), + (0xBB, 0x02), + (0xBC, 0xDB), + (0xBD, 0x04), + (0xBE, 0x00), + (0xBF, 0x00), + # Source timing (0xC0–0xC9) + (0xC0, 0x10), + (0xC1, 0x47), + (0xC2, 0x56), + (0xC3, 0x65), + (0xC4, 0x74), + (0xC5, 0x88), + (0xC6, 0x99), + (0xC7, 0x01), + (0xC8, 0xBB), + (0xC9, 0xAA), + # Source timing (0xD0–0xD9) + (0xD0, 0x10), + (0xD1, 0x47), + (0xD2, 0x56), + (0xD3, 0x65), + (0xD4, 0x74), + (0xD5, 0x88), + (0xD6, 0x99), + (0xD7, 0x01), + (0xD8, 0xBB), + (0xD9, 0xAA), + # Finalise page 10, return to page 0 + (0xF3, 0x01), + (0xF0, 0x00), + # INVON (0x21) and SLPOUT (0x11) are appended by the framework. +) + +DriverChip( + "ESP-VOCAT", + width=360, + height=360, + # SPI pins for the ESP-VoCat v1.2 board. + # PCLK (GPIO18) and data pins (GPIO46/13/11/12) are configured on the spi: bus. + cs_pin=14, + # RST is active-HIGH on this panel; invert the ESPHome pin so the framework's + # active-low reset pulse (HIGH→LOW→HIGH) maps to the correct physical sequence + # (LOW→HIGH→LOW) on the wire. + reset_pin={CONF_NUMBER: 47, CONF_INVERTED: True}, + # Note: GPIO9 behaviour varies by board revision (may be POWER_CTRL, not LCD_EN). + # Do not set a default enable_pin — manage LCD power in on_boot if needed. + # GPIO44 is the backlight; manage it separately via an output: or light:. + bus_mode=TYPE_QUAD, + data_rate="80MHz", + invert_colors=True, + color_order=MODE_RGB, + requires={"psram"}, + initsequence=_ESP_VOCAT_INIT, +) From eaa79b36961880a413dd10e75840a2dc630b547d Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Fri, 24 Jul 2026 20:09:05 -0700 Subject: [PATCH 1060/1815] [modbus] Frame accessors, PDU-relative lengths, and span-based send_pdu (#17846) Co-authored-by: Claude Fable 5 --- esphome/components/modbus/modbus.cpp | 55 ++++--- esphome/components/modbus/modbus.h | 32 +++- .../components/modbus/modbus_definitions.h | 7 + esphome/components/modbus/modbus_helpers.cpp | 151 ++++++++++++++---- esphome/components/modbus/modbus_helpers.h | 38 ++++- .../modbus_controller/modbus_controller.h | 11 ++ esphome/components/pzemac/pzemac.cpp | 6 +- esphome/components/pzemdc/pzemdc.cpp | 6 +- .../modbus/modbus_client_hub_test.cpp | 16 +- .../components/modbus/modbus_helpers_test.cpp | 125 +++++++++++++++ 10 files changed, 366 insertions(+), 81 deletions(-) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index eaca168ed8..3f8aef433f 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -51,7 +51,7 @@ void ModbusClientHub::loop() { // If we're past the send_wait_time timeout and response buffer doesn't have the start of the expected response if (this->waiting_for_response_.has_value()) { ModbusDeviceCommand &wfr = this->waiting_for_response_.value(); - uint8_t expected_address = wfr.frame.data.data()[0]; + uint8_t expected_address = wfr.frame.address(); if (this->last_receive_check_ - this->last_send_ > this->last_send_tx_offset_ + this->send_wait_time_ && (this->rx_buffer_.empty() || this->rx_buffer_[0] != expected_address)) { ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send", expected_address, @@ -258,13 +258,13 @@ bool ModbusServerHub::parse_modbus_client_frame_() { } // Bounds contract, enforced by the parser (parse_modbus_server_frame_) rather than locally: -// - pdu is never empty: helpers::server_frame_length() returns at least MIN_FRAME_SIZE (4) on every -// branch, and find_custom_frame_end_() only ever lengthens that, so the PDU (frame minus address -// and CRC) always holds at least the function code. -// - When the exception bit is set, pdu has at least 2 bytes: server_frame_length() checks the -// exception bit before anything else and pins those frames to 5 bytes, so the exception code +// - pdu is never empty: helpers::server_pdu_length() returns at least MIN_PDU_SIZE (1) on every +// branch, and find_custom_frame_end_() only ever lengthens the frame, so the PDU always holds +// at least the function code. +// - When the exception bit is set, pdu has at least 2 bytes: server_pdu_length() checks the +// exception bit before anything else and pins those PDUs to 2 bytes, so the exception code // read below is always present. -// Keep those guarantees in mind when changing server_frame_length() or adding callers. +// Keep those guarantees in mind when changing server_pdu_length() or adding callers. void ModbusClientHub::process_modbus_server_frame(uint8_t address, std::span pdu) { const uint8_t function_code = pdu[0]; if (!this->waiting_for_response_.has_value()) { @@ -276,8 +276,8 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, std::spanwaiting_for_response_.value(); - uint8_t expected_address = wfr.frame.data.data()[0]; - uint8_t expected_function_code = wfr.frame.data.data()[1]; + uint8_t expected_address = wfr.frame.address(); + uint8_t expected_function_code = wfr.frame.pdu()[0]; if (expected_address != address || expected_function_code != (function_code & FUNCTION_CODE_MASK)) { ESP_LOGW(TAG, "Received incorrect frame address %" PRIu8 " <> %" PRIu8 " or function code 0x%X <> 0x%X, %" PRIu32 @@ -304,7 +304,7 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, std::spanwaiting_for_response_.reset(); ModbusClientDevice *device = command.device; // The request PDU is the sent frame without the leading address and the trailing CRC. - std::span request_pdu(command.frame.data.data() + 1, command.frame.size() - 3); + std::span request_pdu = command.frame.pdu(); // Is it an error response? if (helpers::is_function_code_exception(function_code)) { uint8_t exception = pdu[1]; // exception frames are fixed-length, so the code is always present @@ -586,18 +586,26 @@ void ModbusClientHub::notify_no_response_(ModbusDeviceCommand &wfr) { void ModbusClientHub::requeue_waiting_frame_(ModbusDeviceCommand &wfr) { const ModbusFrame &frame = wfr.frame; if (this->tx_buffer_.size() >= MODBUS_TX_BUFFER_SIZE) { - ESP_LOGE(TAG, "Write buffer full, dropped retry for address %" PRIu8, frame.data.data()[0]); + ESP_LOGE(TAG, "Write buffer full, dropped retry for address %" PRIu8, frame.address()); if (wfr.device != nullptr) wfr.device->on_not_sent(); return; } // Re-queue a copy (not a move): the waiting entry may have to survive as an interrupted shell. - this->tx_buffer_.emplace_back(wfr.device, frame.data.data()[0], frame.data.data() + 1, frame.size() - 3); + this->tx_buffer_.emplace_back(wfr.device, frame.address(), frame.pdu()); } // Raw send for client: pushes to tx queue. Everything except the CRC must be contained in payload. -void ModbusClientHub::queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device) { - if (pdu_len == 0) { +void ModbusClientHub::send_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device) { + if (pdu.empty()) { + if (device) + device->on_not_sent(); + return; + } + + // Bound the PDU so the wire frame (address + pdu + CRC) stays within the Modbus RTU 256-byte limit. + if (pdu.size() > MAX_PDU_SIZE) { + ESP_LOGE(TAG, "Frame too large, dropped: %" PRIu8 ":%zu bytes", address, pdu.size()); if (device) device->on_not_sent(); return; @@ -607,13 +615,15 @@ void ModbusClientHub::queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t p #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; #endif - ESP_LOGV(TAG, "Adding frame to tx queue: %" PRIu8 ":%s", address, format_hex_pretty_to(hex_buf, pdu, pdu_len)); - this->tx_buffer_.emplace_back(device, address, pdu, pdu_len); + ESP_LOGV(TAG, "Adding frame to tx queue: %" PRIu8 ":%s", address, + format_hex_pretty_to(hex_buf, pdu.data(), pdu.size())); + this->tx_buffer_.emplace_back(device, address, pdu); } else { #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_ERROR char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; #endif - ESP_LOGE(TAG, "Write buffer full, dropped: %" PRIu8 ":%s", address, format_hex_pretty_to(hex_buf, pdu, pdu_len)); + ESP_LOGE(TAG, "Write buffer full, dropped: %" PRIu8 ":%s", address, + format_hex_pretty_to(hex_buf, pdu.data(), pdu.size())); if (device) device->on_not_sent(); } @@ -622,13 +632,12 @@ void ModbusClientHub::queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t p void ModbusClientHub::clear_tx_queue_for_address(uint8_t address, bool clear_sent) { // Remove any pending commands for this address from the tx buffer auto &tx_buffer = this->tx_buffer_; - tx_buffer.erase( - std::remove_if(tx_buffer.begin(), tx_buffer.end(), - [address](const ModbusDeviceCommand &cmd) { return cmd.frame.data.data()[0] == address; }), - tx_buffer.end()); + tx_buffer.erase(std::remove_if(tx_buffer.begin(), tx_buffer.end(), + [address](const ModbusDeviceCommand &cmd) { return cmd.frame.address() == address; }), + tx_buffer.end()); if (clear_sent && this->waiting_for_response_.has_value() && this->waiting_for_response_.value().device) { - if (this->waiting_for_response_.value().frame.data.data()[0] == address) { + if (this->waiting_for_response_.value().frame.address() == address) { ESP_LOGV(TAG, "Clearing waiting for response for address %" PRIu8, address); // Invalidate the waiting device so it won't process a response. this->waiting_for_response_.value().device = nullptr; @@ -657,7 +666,7 @@ void ModbusClientHub::send_raw(const std::vector &payload, ModbusClient device->on_not_sent(); return; } - this->queue_raw_(payload[0], payload.data() + 1, static_cast(payload.size() - 1), device); + this->send_pdu(payload[0], std::span(payload).subspan(1), device); } // Send raw command for server replies immediately. Except CRC everything must be contained in payload diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 61d5f552c1..a607e75523 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -40,6 +40,13 @@ struct ModbusFrame { } uint16_t size() const { return static_cast(this->data.size()); } + + // A frame is [address][PDU...][CRC lo][CRC hi]. These are the only places that need to know that layout + uint8_t address() const { return this->data.data()[0]; } + /// The PDU: function code + data, without address or CRC. Only valid while the frame is alive. + /// Requires a complete frame (size() >= MIN_FRAME_SIZE, guaranteed by the constructors) - the + /// subtraction would wrap on anything shorter. + std::span pdu() const { return std::span(this->data.data() + 1, this->size() - 3u); } }; class Modbus : public uart::UARTDevice, public Component { @@ -90,6 +97,10 @@ struct ModbusDeviceCommand { ModbusDeviceCommand(ModbusClientDevice *device, uint8_t address, const uint8_t *src, uint16_t len) : device(device), frame(address, src, len) {} + /// Build a command from a PDU span: a caller-supplied PDU, or an existing frame's own pdu() when re-queueing + /// Callers must bound the PDU to MAX_PDU_SIZE + ModbusDeviceCommand(ModbusClientDevice *device, uint8_t address, std::span pdu) + : device(device), frame(address, pdu.data(), static_cast(pdu.size())) {} }; class ModbusClientHub : public Modbus { @@ -109,9 +120,8 @@ class ModbusClientHub : public Modbus { payload_len), device); }; - void send_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device = nullptr) { - this->queue_raw_(address, pdu.data(), pdu.size(), device); - } + void send_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device = nullptr); + ESPDEPRECATED("Use send_pdu(payload[0], , device) instead. Removed in 2027.2.0", "2026.8.0") void send_raw(const std::vector &payload, ModbusClientDevice *device = nullptr); void clear_tx_queue_for_address(uint8_t address, bool clear_sent = true); void clear_tx_queue_for_device(ModbusClientDevice *device); @@ -125,7 +135,6 @@ class ModbusClientHub : public Modbus { // wfr is the caller's checked reference to waiting_for_response_. void notify_no_response_(ModbusDeviceCommand &wfr); void requeue_waiting_frame_(ModbusDeviceCommand &wfr); - void queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device = nullptr); uint16_t send_wait_time_{2000}; uint16_t turnaround_delay_ms_{0}; @@ -165,6 +174,9 @@ class ModbusServerHub : public Modbus { uint16_t deferred_payload_len_{0}; }; +// Transaction status: std::nullopt on success, otherwise a Modbus exception code +using ResponseStatus = std::optional; + class ModbusClientDevice { public: ModbusClientDevice() = default; @@ -217,7 +229,14 @@ class ModbusClientDevice { this); } void send_pdu(std::span pdu) { this->parent_->send_pdu(this->address_, pdu, this); } - void send_raw(const std::vector &payload) { this->parent_->send_raw(payload, this); } + ESPDEPRECATED("Use send_pdu() instead (the device address is prepended for you). Removed in 2027.2.0", "2026.8.0") + void send_raw(const std::vector &payload) { + if (payload.empty()) { + this->on_not_sent(); // match the hub-level send_raw(): a refused send is always signalled + return; + } + this->parent_->send_pdu(payload[0], std::span(payload).subspan(1), this); + } inline void clear_tx_queue_for_address(bool clear_sent = true) { this->parent_->clear_tx_queue_for_address(this->address_, clear_sent); } @@ -238,9 +257,6 @@ class ModbusClientDevice { using ModbusDevice ESPDEPRECATED("Use ModbusClientDevice instead. Removed in 2026.12.0", "2026.6.0") = ModbusClientDevice; -// Transaction status: std::nullopt on success, otherwise the Modbus exception code. Server handlers return it; -// (future) client response callbacks receive it. Named without a side prefix so both directions share it. -using ResponseStatus = std::optional; // Register values exchanged with server handlers, in host byte order. Sized at the larger of the two protocol // maxima (read = 125 / 0x7D, write = 123 / 0x7B); the per-direction count limit is enforced by the hub, not by // the capacity of this type. diff --git a/esphome/components/modbus/modbus_definitions.h b/esphome/components/modbus/modbus_definitions.h index 841222ff6a..f1e6a1f57e 100644 --- a/esphome/components/modbus/modbus_definitions.h +++ b/esphome/components/modbus/modbus_definitions.h @@ -84,9 +84,15 @@ enum class ExceptionCode : uint8_t { using ModbusExceptionCode ESPDEPRECATED("Use modbus::ExceptionCode instead. Removed in 2027.2.0", "2026.8.0") = ExceptionCode; +// 6.11 15 (0x0F) Write Multiple Coils +static constexpr uint16_t MAX_NUM_OF_COILS_TO_WRITE = 1968; // 0x7B0 + // 6.12 16 (0x10) Write Multiple registers: static constexpr uint16_t MAX_NUM_OF_REGISTERS_TO_WRITE = 123; // 0x7B +// 6.17 23 (0x17) Read/Write Multiple Registers: +static constexpr uint16_t MAX_NUM_OF_REGISTERS_TO_WRITE_RW = 121; // 0x79 + // 6.1 01 (0x01) Read Coils // 6.2 02 (0x02) Read Discrete Inputs static constexpr uint16_t MAX_NUM_OF_COILS_TO_READ = 2000; // 0x7D0 @@ -98,6 +104,7 @@ static constexpr uint16_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D // Smallest possible frame is 4 bytes (custom function with no data): address(1) + function(1) + CRC(2) static constexpr uint16_t MIN_FRAME_SIZE = 4; +static constexpr uint16_t MIN_PDU_SIZE = 1; static constexpr uint16_t MAX_PDU_SIZE = 253; // Max PDU size is 256 - address(1) - CRC(2) = 253 static constexpr uint16_t MAX_RAW_SIZE = 254; // Max RAW size is 256 - CRC(2) = 254 static constexpr uint16_t MAX_FRAME_SIZE = 256; diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index 1ed7912b40..900c9e3093 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -7,74 +7,157 @@ namespace esphome::modbus::helpers { static const char *const TAG = "modbus_helpers"; -uint16_t server_frame_length(const uint8_t *frame, size_t size) { - if (size < 2) - return MIN_FRAME_SIZE; - if (is_function_code_exception(frame[1])) { - return 5; // address(1) + function(1) + exception(1) + CRC(2) +uint16_t server_pdu_length(const uint8_t *frame, size_t size) { + if (size < MIN_PDU_SIZE) + return MIN_PDU_SIZE; + if (is_function_code_exception(frame[0])) { + return 2; // function(1) + exception(1) } - switch (static_cast(frame[1])) { + switch (static_cast(frame[0])) { case FunctionCode::READ_COILS: case FunctionCode::READ_DISCRETE_INPUTS: case FunctionCode::READ_HOLDING_REGISTERS: case FunctionCode::READ_INPUT_REGISTERS: - // address(1) + function(1) + byte count(1) + data + CRC(2) - return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0); + // function(1) + byte count(1) + data + return 2 + (size > 1 ? std::min(frame[1], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0); case FunctionCode::WRITE_SINGLE_COIL: case FunctionCode::WRITE_SINGLE_REGISTER: case FunctionCode::WRITE_MULTIPLE_COILS: case FunctionCode::WRITE_MULTIPLE_REGISTERS: - return 8; // address(1) + function(1) + output/register address(2) + value(2) + CRC(2) + return 5; // function(1) + output/register address(2) + value(2) // Unsupported function codes. Included here to prevent parser failures. Excluding Serial Line specific functions. case FunctionCode::READ_FILE_RECORD: case FunctionCode::WRITE_FILE_RECORD: - // address(1) + function(1) + byte count(1) + data + CRC(2) - return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_FRAME_SIZE - 5)) : 0); + // function(1) + byte count(1) + data + return 2 + (size > 1 ? std::min(frame[1], uint8_t(MAX_PDU_SIZE - 2)) : 0); case FunctionCode::MASK_WRITE_REGISTER: - return 10; // address(1) + function(1) + reference address(2) + AND mask(2) + OR mask(2) + CRC(2) + return 7; // function(1) + reference address(2) + AND mask(2) + OR mask(2) case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: - // address(1) + function(1) + byte count(1) + data + CRC(2) - return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0); + // function(1) + byte count(1) + data + return 2 + (size > 1 ? std::min(frame[1], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0); case FunctionCode::READ_FIFO_QUEUE: - // address(1) + function(1) + fifo address(2) CRC(2) - return 6; + // function(1) + fifo address(2) + return 3; default: - return MIN_FRAME_SIZE; // unknown length + return MIN_PDU_SIZE; // unknown length } } -uint16_t client_frame_length(const uint8_t *frame, size_t size) { - if (size < 2) - return MIN_FRAME_SIZE; - switch (static_cast(frame[1])) { +uint16_t client_pdu_length(const uint8_t *frame, size_t size) { + if (size < MIN_PDU_SIZE) + return MIN_PDU_SIZE; + switch (static_cast(frame[0])) { case FunctionCode::READ_COILS: case FunctionCode::READ_DISCRETE_INPUTS: case FunctionCode::READ_HOLDING_REGISTERS: case FunctionCode::READ_INPUT_REGISTERS: - // address(1) + function(1) + start address(2) + quantity(2) + CRC(2) + // function(1) + start address(2) + quantity(2) case FunctionCode::WRITE_SINGLE_COIL: case FunctionCode::WRITE_SINGLE_REGISTER: - return 8; // address(1) + function(1) + output/register address(2) + value(2) + CRC(2) + return 5; // function(1) + output/register address(2) + value(2) case FunctionCode::WRITE_MULTIPLE_COILS: case FunctionCode::WRITE_MULTIPLE_REGISTERS: - // address(1) + function(1) + start address(2) + quantity(2) + byte count(1) + data + CRC(2) - return 9 + (size > 6 ? std::min(frame[6], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE * 2)) : 0); + // function(1) + start address(2) + quantity(2) + byte count(1) + data + return 6 + (size > 5 ? std::min(frame[5], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE * 2)) : 0); // Unsupported function codes. Included here to prevent parser failures. Excluding Serial Line specific functions. case FunctionCode::READ_FILE_RECORD: case FunctionCode::WRITE_FILE_RECORD: - // address(1) + function(1) + byte count(1) + data + CRC(2) - return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_FRAME_SIZE - 5)) : 0); + // function(1) + byte count(1) + data + return 2 + (size > 1 ? std::min(frame[1], uint8_t(MAX_PDU_SIZE - 2)) : 0); case FunctionCode::MASK_WRITE_REGISTER: - return 10; // address(1) + function(1) + reference address(2) + AND mask(2) + OR mask(2) + CRC(2) + return 7; // function(1) + reference address(2) + AND mask(2) + OR mask(2) case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: - // address(1) + function(1) + read start address(2) + read quantity(2) + write start address(2) + - // write quantity(2) + byte count(1) + data + CRC(2) - return 13 + (size > 10 ? std::min(frame[10], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE * 2)) : 0); + // function(1) + read start address(2) + read quantity(2) + write start address(2) + + // write quantity(2) + byte count(1) + data + return 10 + (size > 9 ? std::min(frame[9], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE_RW * 2)) : 0); case FunctionCode::READ_FIFO_QUEUE: - // address(1) + function(1) + fifo address(2) CRC(2) - return 6; + // function(1) + fifo address(2) + return 3; default: - return MIN_FRAME_SIZE; // unknown length + return MIN_PDU_SIZE; // unknown length + } +} + +bool is_server_pdu_standard(const uint8_t *pdu, size_t size) { + if (server_pdu_length(pdu, size) != size) + return false; + + switch (static_cast(pdu[0])) { + case FunctionCode::READ_COILS: + case FunctionCode::READ_DISCRETE_INPUTS: + // A conformant bit-read response carries at least one packed byte (up to 2000 bits = 250 bytes). + return pdu[1] != 0 && pdu[1] <= uint8_t((MAX_NUM_OF_COILS_TO_READ + 7) / 8); + case FunctionCode::READ_HOLDING_REGISTERS: + case FunctionCode::READ_INPUT_REGISTERS: + // Registers are 2 bytes each: the byte count must be a non-zero even count within the read maximum. + return pdu[1] != 0 && pdu[1] % 2 == 0 && pdu[1] <= uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2); + case FunctionCode::READ_FILE_RECORD: + case FunctionCode::WRITE_FILE_RECORD: + return pdu[1] <= uint8_t(MAX_PDU_SIZE - 2); + case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: + return pdu[1] != 0 && pdu[1] % 2 == 0 && pdu[1] <= uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2); + case FunctionCode::WRITE_MULTIPLE_COILS: + case FunctionCode::WRITE_MULTIPLE_REGISTERS: { + // The response echoes start address and quantity: bound them like the request side does. + const bool bits = static_cast(pdu[0]) == FunctionCode::WRITE_MULTIPLE_COILS; + const uint16_t start_address = get_data(pdu, 1); + const uint16_t quantity = get_data(pdu, 3); + const uint16_t max_quantity = bits ? MAX_NUM_OF_COILS_TO_WRITE : MAX_NUM_OF_REGISTERS_TO_WRITE; + return quantity != 0 && quantity <= max_quantity && (uint32_t) start_address + quantity <= 0x10000u; + } + case FunctionCode::WRITE_SINGLE_COIL: + // The response echoes the request, so the same ON/OFF constraint applies. + return (pdu[3] == 0xFF || pdu[3] == 0x00) && pdu[4] == 0x00; + default: + return true; // All other function codes validated by length alone + } +} + +bool is_client_pdu_standard(const uint8_t *pdu, size_t size) { + if (client_pdu_length(pdu, size) != size) + return false; + + switch (static_cast(pdu[0])) { + case FunctionCode::READ_COILS: + case FunctionCode::READ_DISCRETE_INPUTS: + case FunctionCode::READ_HOLDING_REGISTERS: + case FunctionCode::READ_INPUT_REGISTERS: { + const bool bits = static_cast(pdu[0]) == FunctionCode::READ_COILS || + static_cast(pdu[0]) == FunctionCode::READ_DISCRETE_INPUTS; + const uint16_t start_address = get_data(pdu, 1); + const uint16_t quantity = get_data(pdu, 3); + const uint16_t max_quantity = bits ? MAX_NUM_OF_COILS_TO_READ : MAX_NUM_OF_REGISTERS_TO_READ; + return quantity != 0 && quantity <= max_quantity && (uint32_t) start_address + quantity <= 0x10000u; + } + case FunctionCode::WRITE_MULTIPLE_COILS: + case FunctionCode::WRITE_MULTIPLE_REGISTERS: { + const bool bits = static_cast(pdu[0]) == FunctionCode::WRITE_MULTIPLE_COILS; + const uint16_t start_address = get_data(pdu, 1); + const uint16_t quantity = get_data(pdu, 3); + const uint16_t max_quantity = bits ? MAX_NUM_OF_COILS_TO_WRITE : MAX_NUM_OF_REGISTERS_TO_WRITE; + // Coils are packed 8 per data byte; registers are 2 bytes each. + const size_t expected_data_bytes = bits ? (static_cast(quantity) + 7) / 8 : quantity * 2; + return quantity != 0 && quantity <= max_quantity && (uint32_t) start_address + quantity <= 0x10000u && + pdu[5] == expected_data_bytes; + } + case FunctionCode::READ_FILE_RECORD: + case FunctionCode::WRITE_FILE_RECORD: + return pdu[1] <= MAX_PDU_SIZE - 2; + case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: { + const uint16_t start_address_read = get_data(pdu, 1); + const uint16_t quantity_read = get_data(pdu, 3); + const uint16_t start_address_write = get_data(pdu, 5); + const uint16_t quantity_write = get_data(pdu, 7); + return quantity_read != 0 && quantity_read <= MAX_NUM_OF_REGISTERS_TO_READ && quantity_write != 0 && + quantity_write <= MAX_NUM_OF_REGISTERS_TO_WRITE_RW && + (uint32_t) start_address_read + quantity_read <= 0x10000u && + (uint32_t) start_address_write + quantity_write <= 0x10000u && pdu[9] == quantity_write * 2; + } + case FunctionCode::WRITE_SINGLE_COIL: + // The one variable field in an otherwise fixed-shape PDU: the spec allows exactly ON/OFF. + return (pdu[3] == 0xFF || pdu[3] == 0x00) && pdu[4] == 0x00; + default: + return true; // All other function codes validated by length alone } } diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index 0fece3be5d..44c1a01211 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -39,13 +39,39 @@ inline bool is_function_code_custom(uint8_t function_code) { masked_function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_2_END); } -// Returns the expected length of a server response frame based on the function code -// If the frame is too short to determine the length, returns the minimum length -uint16_t server_frame_length(const uint8_t *frame, size_t size); +// Returns the expected length of a server response PDU based on the function code. +// If too few bytes have arrived to determine the length, returns the minimum length. `size` is the +// number of bytes available so far, which may exceed the eventual PDU (e.g. include the frame's CRC +// bytes): only fixed header positions are interpreted, so surplus bytes are never misread. +uint16_t server_pdu_length(const uint8_t *frame, size_t size); +// Frame counterpart: address(1) + PDU + CRC(2). Passes every received byte after the address through, +// so header fields (e.g. a byte count) are interpreted as soon as they arrive. +inline uint16_t server_frame_length(const uint8_t *frame, size_t size) { + if (size < 2) + return MIN_FRAME_SIZE; // function code not received yet + return server_pdu_length(frame + 1, size - 1) + 3; +} -// Returns the expected length of a client request frame based on the function code -// If the frame is too short to determine the length, returns the minimum length -uint16_t client_frame_length(const uint8_t *frame, size_t size); +// Returns the expected length of a client request PDU based on the function code. +// Same contract as server_pdu_length(): `size` is bytes available so far, may exceed the PDU. +uint16_t client_pdu_length(const uint8_t *frame, size_t size); +inline uint16_t client_frame_length(const uint8_t *frame, size_t size) { + if (size < 2) + return MIN_FRAME_SIZE; // function code not received yet + return client_pdu_length(frame + 1, size - 1) + 3; +} + +// Returns true if pdu is a complete transaction whose shape is consistent with its function code. +// Unlike *_pdu_length(), `size` here is the exact PDU length: a size mismatch is non-conformant. +// Function codes with nothing variable to cross-check are validated by their fixed length alone: the +// single writes (except 0x05's value field, which must be 0x0000 or 0xFF00), mask-write and FIFO, and +// - deliberately - custom/unknown codes and exception responses, so a dispatcher can still route +// them by function code rather than reject them outright. Tests pin this contract. +bool is_server_pdu_standard(const uint8_t *pdu, size_t size); + +// Client counterpart: additionally checks quantity bounds and address-range arithmetic per function code. +// The same acceptance rule applies to custom/unknown function codes. +bool is_client_pdu_standard(const uint8_t *pdu, size_t size); // Remove before 2027.2.0 ESPDEPRECATED("Use server_pdu_payload() on the response PDU instead. Removed in 2027.2.0", "2026.8.0") diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 5315a4f325..8be2e333ac 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -298,6 +298,17 @@ class ModbusController final : public PollingComponent, public modbus::ModbusCli /// queues a modbus command in the send queue void queue_command(const ModbusCommandItem &command); + /// Sends a raw payload (address byte + PDU, no CRC) with responses routed back to this controller. + /// The payload carries its own address byte, which may differ from this controller's address. + /// Deliberately shadows the deprecated ModbusClientDevice::send_raw() with identical semantics: + /// controller-level raw sends stay supported until the command machinery is replaced. + void send_raw(const std::vector &payload) { + if (payload.empty()) { + this->on_not_sent(); // match the hub-level send_raw(): a refused send is always signalled + return; + } + this->parent_->send_pdu(payload[0], std::span(payload).subspan(1), this); + } /// Registers a sensor with the controller. Called by esphomes code generator void add_sensor_item(SensorItem *item) { sensorset_.insert(item); } /// called when a modbus response was parsed without errors diff --git a/esphome/components/pzemac/pzemac.cpp b/esphome/components/pzemac/pzemac.cpp index 0f22092f34..233ad6fc53 100644 --- a/esphome/components/pzemac/pzemac.cpp +++ b/esphome/components/pzemac/pzemac.cpp @@ -77,10 +77,8 @@ void PZEMAC::dump_config() { } void PZEMAC::reset_energy_() { - std::vector cmd; - cmd.push_back(this->address_); - cmd.push_back(PZEM_CMD_RESET_ENERGY); - this->send_raw(cmd); + const uint8_t pdu[] = {PZEM_CMD_RESET_ENERGY}; + this->send_pdu(pdu); } } // namespace esphome::pzemac diff --git a/esphome/components/pzemdc/pzemdc.cpp b/esphome/components/pzemdc/pzemdc.cpp index 31d1a7dac1..9de72d51f9 100644 --- a/esphome/components/pzemdc/pzemdc.cpp +++ b/esphome/components/pzemdc/pzemdc.cpp @@ -65,10 +65,8 @@ void PZEMDC::dump_config() { } void PZEMDC::reset_energy() { - std::vector cmd; - cmd.push_back(this->address_); - cmd.push_back(PZEM_CMD_RESET_ENERGY); - this->send_raw(cmd); + const uint8_t pdu[] = {PZEM_CMD_RESET_ENERGY}; + this->send_pdu(pdu); } } // namespace esphome::pzemdc diff --git a/tests/components/modbus/modbus_client_hub_test.cpp b/tests/components/modbus/modbus_client_hub_test.cpp index 4447ca9344..f372af4ab5 100644 --- a/tests/components/modbus/modbus_client_hub_test.cpp +++ b/tests/components/modbus/modbus_client_hub_test.cpp @@ -94,8 +94,9 @@ TEST(ModbusClientHubNoResponse, RetryRequeuesWaitingFrame) { EXPECT_EQ(requeued.device, &device); // address + PDU + CRC ASSERT_EQ(requeued.frame.size(), sizeof(READ_PDU) + 3); - EXPECT_EQ(requeued.frame.data.data()[0], 0x02); - EXPECT_EQ(0, memcmp(requeued.frame.data.data() + 1, READ_PDU, sizeof(READ_PDU))); + EXPECT_EQ(requeued.frame.address(), 0x02); + ASSERT_EQ(requeued.frame.pdu().size(), sizeof(READ_PDU)); + EXPECT_EQ(0, memcmp(requeued.frame.pdu().data(), READ_PDU, sizeof(READ_PDU))); } // A device that declines the retry has the frame dropped. @@ -208,4 +209,15 @@ TEST(ModbusClientHubCompat, LegacyCallbackNamesStillForward) { EXPECT_EQ(device.legacy_not_sent_, 1); } +// The send_pdu() capacity bound: a PDU larger than MAX_PDU_SIZE would build a frame past the RTU +// 256-byte limit, so it is refused up front and signalled like any other failed send. +TEST(ModbusClientHub, OversizedPduIsRefusedWithNotSent) { + NoResponseProbeHub hub; + LegacyNameDevice device(&hub, 0x02); + std::vector big(MAX_PDU_SIZE + 1, 0x41); + device.send_pdu(big); + EXPECT_EQ(device.legacy_not_sent_, 1); // on_not_sent, observed via the legacy forward + EXPECT_TRUE(hub.tx_buffer_empty()); +} + } // namespace esphome::modbus::testing diff --git a/tests/components/modbus/modbus_helpers_test.cpp b/tests/components/modbus/modbus_helpers_test.cpp index 0cd6ac6693..71ea3556b8 100644 --- a/tests/components/modbus/modbus_helpers_test.cpp +++ b/tests/components/modbus/modbus_helpers_test.cpp @@ -83,6 +83,13 @@ TEST(ModbusClientFrameLength, WriteMultipleByteCountCapped) { EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9 + MAX_NUM_OF_REGISTERS_TO_WRITE * 2); } +TEST(ModbusClientFrameLength, ReadWriteMultipleByteCountCappedAtSpecLimit) { + // FC 0x17's write byte count caps at the spec 6.17 limit of 121 registers (242 bytes), deliberately + // tighter than FC 0x10's 123, so a corrupt byte count cannot make the parser wait past the real frame. + const uint8_t pdu[] = {0x17, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0xFF}; // claims 255 bytes + EXPECT_EQ(client_pdu_length(pdu, sizeof(pdu)), 10 + MAX_NUM_OF_REGISTERS_TO_WRITE_RW * 2); +} + TEST(ModbusClientFrameLength, WriteMultipleMissingByteCount) { const uint8_t frame[] = {0x01, 0x10, 0x00, 0x00, 0x00, 0x02}; EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9); @@ -97,6 +104,124 @@ TEST(ModbusClientFrameLength, MiscFixedAndUnknown) { EXPECT_EQ(client_frame_length(unknown, sizeof(unknown)), MIN_FRAME_SIZE); } +// --- file-record length cap -------------------------------------------------- +// FC 0x14/0x15 are parsed only to keep the frame parser in sync; the byte count caps at 251 +// (MAX_PDU_SIZE - 2), reproducing the released frame-relative bound of MAX_FRAME_SIZE - 5. + +TEST(ModbusFileRecordCap, PduLengthCapsByteCountAt251) { + const uint8_t pdu[] = {static_cast(FC::READ_FILE_RECORD), 0xFF}; // claims 255 bytes + EXPECT_EQ(server_pdu_length(pdu, sizeof(pdu)), 2 + (MAX_PDU_SIZE - 2)); + EXPECT_EQ(client_pdu_length(pdu, sizeof(pdu)), 2 + (MAX_PDU_SIZE - 2)); + // Frame wrappers: address(1) + PDU + CRC(2) stays within the RTU 256-byte frame limit. + const uint8_t frame[] = {0x01, static_cast(FC::WRITE_FILE_RECORD), 0xFF}; + EXPECT_EQ(server_frame_length(frame, sizeof(frame)), MAX_FRAME_SIZE); + EXPECT_EQ(client_frame_length(frame, sizeof(frame)), MAX_FRAME_SIZE); +} + +TEST(ModbusFileRecordCap, StandardChecksAcceptUpTo251) { + // A full-length PDU at the cap: function(1) + byte count(1) + 251 data bytes = MAX_PDU_SIZE. + std::vector at_cap(MAX_PDU_SIZE, 0x00); + at_cap[0] = static_cast(FC::READ_FILE_RECORD); + at_cap[1] = MAX_PDU_SIZE - 2; + EXPECT_TRUE(is_server_pdu_standard(at_cap.data(), at_cap.size())); + EXPECT_TRUE(is_client_pdu_standard(at_cap.data(), at_cap.size())); + // Byte count 252 in the same 253-byte buffer: the parsed length still matches (capped), so this + // exercises the byte-count bound itself rather than the length identity. + at_cap[1] = MAX_PDU_SIZE - 1; + EXPECT_FALSE(is_server_pdu_standard(at_cap.data(), at_cap.size())); + EXPECT_FALSE(is_client_pdu_standard(at_cap.data(), at_cap.size())); +} + +// --- is_client_pdu_standard / is_server_pdu_standard ------------------------- +// The gatekeepers for the typed client dispatch: a PDU must be exactly its function code's standard +// shape, with byte count, quantity, and address range all consistent. + +TEST(ModbusPduStandard, ClientReadAndWriteConformant) { + const uint8_t read_regs[] = {0x03, 0x01, 0x00, 0x00, 0x02}; + EXPECT_TRUE(is_client_pdu_standard(read_regs, sizeof(read_regs))); + const uint8_t write_regs[] = {0x10, 0x00, 0x20, 0x00, 0x02, 0x04, 0x00, 0x01, 0x00, 0x02}; + EXPECT_TRUE(is_client_pdu_standard(write_regs, sizeof(write_regs))); + // 10 coils pack into 2 data bytes - the coil formula, not the register one. + const uint8_t write_coils[] = {0x0F, 0x00, 0x30, 0x00, 0x0A, 0x02, 0xFF, 0x03}; + EXPECT_TRUE(is_client_pdu_standard(write_coils, sizeof(write_coils))); +} + +TEST(ModbusPduStandard, ClientRejectsNonConformant) { + // Truncated: header claims 4 data bytes, only 2 present. + const uint8_t truncated[] = {0x10, 0x00, 0x20, 0x00, 0x02, 0x04, 0x00, 0x01}; + EXPECT_FALSE(is_client_pdu_standard(truncated, sizeof(truncated))); + // Byte count disagrees with quantity (2 registers need 4 bytes, header says 2). + const uint8_t inconsistent[] = {0x10, 0x00, 0x20, 0x00, 0x02, 0x02, 0x00, 0x01}; + EXPECT_FALSE(is_client_pdu_standard(inconsistent, sizeof(inconsistent))); + // Coil write using the register byte-count formula (10 coils with 20 data bytes). + const uint8_t coil_as_regs[] = {0x0F, 0x00, 0x30, 0x00, 0x0A, 0x14, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}; + EXPECT_FALSE(is_client_pdu_standard(coil_as_regs, sizeof(coil_as_regs))); + // Quantity zero and quantity beyond the per-function-code maximum. + const uint8_t zero_qty[] = {0x03, 0x01, 0x00, 0x00, 0x00}; + EXPECT_FALSE(is_client_pdu_standard(zero_qty, sizeof(zero_qty))); + const uint8_t too_many[] = {0x03, 0x01, 0x00, 0x00, 0x7E}; // 126 > 125 + EXPECT_FALSE(is_client_pdu_standard(too_many, sizeof(too_many))); + // Address range overflow: 0xFFFF + 2 registers exceeds the 16-bit register space. + const uint8_t wraps[] = {0x03, 0xFF, 0xFF, 0x00, 0x02}; + EXPECT_FALSE(is_client_pdu_standard(wraps, sizeof(wraps))); +} + +TEST(ModbusPduStandard, ServerReadResponses) { + const uint8_t ok[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + EXPECT_TRUE(is_server_pdu_standard(ok, sizeof(ok))); + // Byte-count header disagrees with the actual length. + const uint8_t lying[] = {0x03, 0x06, 0x00, 0x2A, 0x01, 0x00}; + EXPECT_FALSE(is_server_pdu_standard(lying, sizeof(lying))); + // An empty PDU (the on_error path) is not a standard response. + EXPECT_FALSE(is_server_pdu_standard(ok, 0)); +} + +TEST(ModbusPduStandard, ServerResponsesRejectDegenerateShapes) { + // A read response always carries data: byte count zero is non-conformant. + const uint8_t zero_bc[] = {0x03, 0x00}; + EXPECT_FALSE(is_server_pdu_standard(zero_bc, sizeof(zero_bc))); + // Registers are 2 bytes each: an odd byte count would silently truncate a register. + const uint8_t odd_bc[] = {0x03, 0x03, 0x00, 0x01, 0x02}; + EXPECT_FALSE(is_server_pdu_standard(odd_bc, sizeof(odd_bc))); + // Bit reads have no parity requirement: one packed byte is a fine coil response. + const uint8_t coil_one_byte[] = {0x01, 0x01, 0x05}; + EXPECT_TRUE(is_server_pdu_standard(coil_one_byte, sizeof(coil_one_byte))); + // A write-multiple echo claiming 65535 registers written is bounded like the request side. + const uint8_t wild_echo[] = {0x10, 0x00, 0x00, 0xFF, 0xFF}; + EXPECT_FALSE(is_server_pdu_standard(wild_echo, sizeof(wild_echo))); + const uint8_t ok_echo[] = {0x10, 0x00, 0x00, 0x00, 0x02}; + EXPECT_TRUE(is_server_pdu_standard(ok_echo, sizeof(ok_echo))); +} + +TEST(ModbusPduStandard, SingleCoilValueMustBeCanonical) { + // FC 0x05's value field allows exactly 0xFF00 (ON) and 0x0000 (OFF); anything else is non-standard. + const uint8_t on[] = {0x05, 0x00, 0x10, 0xFF, 0x00}; + const uint8_t off[] = {0x05, 0x00, 0x10, 0x00, 0x00}; + const uint8_t junk[] = {0x05, 0x00, 0x10, 0x12, 0x34}; + EXPECT_TRUE(is_client_pdu_standard(on, sizeof(on))); + EXPECT_TRUE(is_client_pdu_standard(off, sizeof(off))); + EXPECT_FALSE(is_client_pdu_standard(junk, sizeof(junk))); + EXPECT_TRUE(is_server_pdu_standard(on, sizeof(on))); // the response echoes the request + EXPECT_FALSE(is_server_pdu_standard(junk, sizeof(junk))); +} + +TEST(ModbusPduStandard, NonStandardFunctionCodesAcceptedOnLengthAlone) { + // Custom, unimplemented, and exception function codes have no standard shape to check: they are + // accepted whenever the parsed length matches, so a dispatcher can still route them by function + // code instead of having them rejected outright. This is the documented contract - see the header. + const uint8_t custom[] = {0x42}; // user-defined space; 1 byte matches the MIN_PDU_SIZE fallback + EXPECT_TRUE(is_client_pdu_standard(custom, sizeof(custom))); + EXPECT_TRUE(is_server_pdu_standard(custom, sizeof(custom))); + const uint8_t unimplemented[] = {0x07}; // READ_EXCEPTION_STATUS + EXPECT_TRUE(is_server_pdu_standard(unimplemented, sizeof(unimplemented))); + const uint8_t exception[] = {0x83, 0x02}; // exception response; length pinned to 2 bytes + EXPECT_TRUE(is_server_pdu_standard(exception, sizeof(exception))); + // The length identity still gates: extra bytes beyond the parsed fallback are non-conformant. + const uint8_t custom_long[] = {0x42, 0x01}; + EXPECT_FALSE(is_client_pdu_standard(custom_long, sizeof(custom_long))); +} + // --- create_client_pdu ----------------------------------------------------- // PDU = function code + data (no address, no CRC). From 0833e91fb5d51d791a93a23d5fc6cae429d0a1ca Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sat, 25 Jul 2026 12:39:07 -0700 Subject: [PATCH 1061/1815] [modbus] Turn the ModbusDevice alias into a working compatibility shim (#17854) Co-authored-by: Claude Fable 5 --- esphome/components/modbus/modbus.h | 25 +++++++-- .../modbus/modbus_client_hub_test.cpp | 52 +++++++++++++++++++ 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index a607e75523..2e2c027dd0 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -252,10 +252,27 @@ class ModbusClientDevice { uint8_t address_{0}; }; -// This is for compatibility with external components using the former class name -// Remove before 2026.12.0 -using ModbusDevice ESPDEPRECATED("Use ModbusClientDevice instead. Removed in 2026.12.0", - "2026.6.0") = ModbusClientDevice; +// Compatibility shim for external components written against the pre-2026.8 API, which subclassed +// ModbusDevice and overrode on_modbus_data()/on_modbus_error(). The name is free (nothing in-tree +// uses it), so instead of a plain alias it adapts the new span-based hooks back to the old +// signatures: on_modbus_data() receives the response payload as an owning vector (the heap copy +// exists only on this deprecated path) and on_modbus_error() the function code and exception code. +// Remove before 2027.2.0 (window restarted when the plain alias became a behavior shim in 2026.8.0) +class ESPDEPRECATED("Subclass ModbusClientDevice and override on_response()/on_error() instead. Removed in 2027.2.0", + "2026.8.0") ModbusDevice : public ModbusClientDevice { + public: + using ModbusClientDevice::ModbusClientDevice; + virtual void on_modbus_data(const std::vector &data) {} + virtual void on_modbus_error(uint8_t function_code, uint8_t exception_code) {} + + void on_response(std::span request_pdu, std::span response_pdu) override { + auto payload = helpers::server_pdu_payload(response_pdu); + this->on_modbus_data(std::vector(payload.begin(), payload.end())); + } + void on_error(std::span request_pdu, ExceptionCode exception_code) override { + this->on_modbus_error(request_pdu.empty() ? 0 : request_pdu[0], static_cast(exception_code)); + } +}; // Register values exchanged with server handlers, in host byte order. Sized at the larger of the two protocol // maxima (read = 125 / 0x7D, write = 123 / 0x7B); the per-direction count limit is enforced by the hub, not by diff --git a/tests/components/modbus/modbus_client_hub_test.cpp b/tests/components/modbus/modbus_client_hub_test.cpp index f372af4ab5..7bdc4ac35b 100644 --- a/tests/components/modbus/modbus_client_hub_test.cpp +++ b/tests/components/modbus/modbus_client_hub_test.cpp @@ -220,4 +220,56 @@ TEST(ModbusClientHub, OversizedPduIsRefusedWithNotSent) { EXPECT_TRUE(hub.tx_buffer_empty()); } +// --- ModbusDevice compatibility shim ------------------------------------------------------------ +// External components written against the pre-2026.8 API subclass ModbusDevice and override the +// old callbacks; the shim adapts the span-based hooks back to those signatures. +namespace { +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +class LegacyApiDevice : public ModbusDevice { + public: + LegacyApiDevice(ModbusClientHub *hub, uint8_t address) : ModbusDevice(hub, address) {} + void on_modbus_data(const std::vector &data) override { this->last_data_ = data; } + void on_modbus_error(uint8_t function_code, uint8_t exception_code) override { + this->last_error_fc_ = function_code; + this->last_error_code_ = exception_code; + } + std::vector last_data_; + int last_error_fc_{-1}; + int last_error_code_{-1}; +}; +#pragma GCC diagnostic pop +} // namespace + +TEST(ModbusDeviceShim, LegacyCallbacksReceiveTheOldShapes) { + NoResponseProbeHub hub; + LegacyApiDevice device(&hub, 0x02); + + // Read response: on_modbus_data() historically received the payload after the function code and + // the byte-count byte, as an owning vector. + const uint8_t read_req[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + device.send_pdu(read_req); + hub.force_send_front(); + const uint8_t response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x02, response); + const std::vector expected{0x00, 0x2A, 0x01, 0x00}; + EXPECT_EQ(device.last_data_, expected); + + // Write echo: no byte-count byte, so the payload is everything after the function code. + const uint8_t write_req[] = {0x06, 0x00, 0x10, 0x00, 0x2A}; + device.send_pdu(write_req); + hub.force_send_front(); + hub.receive_frame_for_test(0x02, write_req); // single-write responses echo the request + const std::vector expected_echo{0x00, 0x10, 0x00, 0x2A}; + EXPECT_EQ(device.last_data_, expected_echo); + + // Exception response: on_modbus_error() received the masked function code and the exception code. + device.send_pdu(read_req); + hub.force_send_front(); + const uint8_t error[] = {0x83, 0x02}; + hub.receive_frame_for_test(0x02, error); + EXPECT_EQ(device.last_error_fc_, 0x03); + EXPECT_EQ(device.last_error_code_, 0x02); +} + } // namespace esphome::modbus::testing From a2c749c2770a7e3b6fa0d8496e06a7606691502d Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sat, 25 Jul 2026 14:18:46 -0700 Subject: [PATCH 1062/1815] [modbus] PackedBits and right-sized typed PDU builders (#17848) Co-authored-by: Claude Fable 5 Co-authored-by: J. Nick Koston --- .../components/modbus/modbus_definitions.h | 60 ++++ esphome/components/modbus/modbus_helpers.cpp | 271 ++++++++++++++---- esphome/components/modbus/modbus_helpers.h | 83 +++++- .../modbus_controller/modbus_controller.h | 4 +- .../number/modbus_number.cpp | 7 +- .../output/modbus_output.cpp | 22 +- .../select/modbus_select.cpp | 17 +- .../components/modbus/modbus_helpers_test.cpp | 180 ++++++++++++ 8 files changed, 567 insertions(+), 77 deletions(-) diff --git a/esphome/components/modbus/modbus_definitions.h b/esphome/components/modbus/modbus_definitions.h index f1e6a1f57e..09f430f703 100644 --- a/esphome/components/modbus/modbus_definitions.h +++ b/esphome/components/modbus/modbus_definitions.h @@ -1,5 +1,9 @@ #pragma once +#include +#include +#include + #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -107,6 +111,62 @@ static constexpr uint16_t MIN_FRAME_SIZE = 4; static constexpr uint16_t MIN_PDU_SIZE = 1; static constexpr uint16_t MAX_PDU_SIZE = 253; // Max PDU size is 256 - address(1) - CRC(2) = 253 static constexpr uint16_t MAX_RAW_SIZE = 254; // Max RAW size is 256 - CRC(2) = 254 +// A read request PDU is always function code(1) + start address(2) + quantity(2) +static constexpr uint16_t READ_PDU_SIZE = 5; +// A single-write PDU is always function code(1) + address(2) + value(2) +static constexpr uint16_t WRITE_SINGLE_PDU_SIZE = 5; static constexpr uint16_t MAX_FRAME_SIZE = 256; +/** Read-only view of Modbus-packed bits: bit 0 of byte 0 is the first bit (LSB first), the layout + * coil/discrete-input values use on the wire. Bundles the bit count with the packed bytes so the + * two cannot desynchronize. The view does not own the bytes - it is only valid while they are. + * Reads (operator[]) are unchecked by design - the caller owns the bit < size() precondition, as + * with any subscript. Writes and forwarding are defensive: set() drops out-of-range bits and + * bytes() clamps to the real span, because those paths touch buffers and the wire directly. + */ +class PackedBits { + public: + PackedBits(std::span data, uint16_t count) : data_(data), count_(count) {} + /// Value of the given bit; bit must be < size(). + bool operator[](size_t bit) const { return (this->data_[bit / 8] & (1 << (bit % 8))) != 0; } + /// Number of bits in the view. + uint16_t size() const { return this->count_; } + /// The underlying packed bytes: exactly ceil(size() / 8) bytes, even when the view was constructed + /// over a larger buffer - forwarding this span onto the wire can never leak trailing buffer content. + /// Clamped to the actual span so a view over a too-short buffer stays detectable instead of UB. + std::span bytes() const { + return this->data_.first(std::min((this->count_ + 7) / 8, this->data_.size())); + } + + private: + std::span data_; // must cover ceil(count_ / 8) bytes + uint16_t count_; +}; + +/** Mutable counterpart of PackedBits: set() writes bits in place (deliberately no proxy operator[]=). + * Converts implicitly to PackedBits for read access. + */ +class MutablePackedBits { + public: + MutablePackedBits(std::span data, uint16_t count) : data_(data), count_(count) {} + bool operator[](size_t bit) const { return (this->data_[bit / 8] & (1 << (bit % 8))) != 0; } + /// Set or clear the given bit. Out-of-range bits are dropped: on the server read path the span wraps a + /// stack response buffer, so a handler looping past size() must not be able to smash the frame. + void set(size_t bit, bool value) { + if (bit >= this->count_ || bit / 8 >= this->data_.size()) + return; + if (value) { + this->data_[bit / 8] |= (1 << (bit % 8)); + } else { + this->data_[bit / 8] &= ~(1 << (bit % 8)); + } + } + uint16_t size() const { return this->count_; } + operator PackedBits() const { return PackedBits(this->data_, this->count_); } + + private: + std::span data_; // must cover ceil(count_ / 8) bytes + uint16_t count_; +}; + /// End of Modbus definitions } // namespace esphome::modbus diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index 900c9e3093..96561cf56f 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -280,27 +280,28 @@ std::optional registers_to_number(const uint16_t *registers, size_t cou return payload_to_number(bytes, required_size, sensor_value_type, 0, 0xFFFFFFFF); } -StaticVector create_client_pdu(FunctionCode function_code, uint16_t start_address, - uint16_t number_of_entities, const uint8_t *values, - size_t values_len) { - if (is_function_code_read(static_cast(function_code))) { - if (values != nullptr || values_len > 0) { - ESP_LOGW(TAG, "Values provided for read function code %02X, but will be ignored", - static_cast(function_code)); - } - } else if (is_function_code_write(static_cast(function_code))) { - if (values == nullptr || values_len == 0) { - ESP_LOGE(TAG, "No values provided for write function code %02X", static_cast(function_code)); - return {}; - } - } else { - ESP_LOGE(TAG, "Unsupported function code %02X for client PDU creation", static_cast(function_code)); - return {}; - } +// Every request PDU opens with the same 5-byte layout: function code, then two big-endian 16-bit +// fields (start address + quantity for reads and multi-writes, address + value for single writes). +template +static void append_pdu_header(StaticVector &pdu, FunctionCode function_code, uint16_t first, + uint16_t second) { + pdu.push_back(static_cast(function_code)); + pdu.push_back(first >> 8); + pdu.push_back(first >> 0); + pdu.push_back(second >> 8); + pdu.push_back(second >> 0); +} +ReadPdu create_read_pdu(FunctionCode function_code, uint16_t start_address, uint16_t number_of_entities) { + ReadPdu pdu; // declared before every return so NRVO fires (all paths return the same object) if (number_of_entities == 0) { ESP_LOGE(TAG, "Number of entities is zero for function code %02X", static_cast(function_code)); - return {}; + return pdu; + } + if (uint32_t(start_address) + number_of_entities > 0x10000u) { + ESP_LOGE(TAG, "Read of %u entities at %u runs past the 16-bit address space, dropping request", number_of_entities, + start_address); + return pdu; } switch (function_code) { @@ -308,14 +309,14 @@ StaticVector create_client_pdu(FunctionCode function_code if (number_of_entities > MAX_NUM_OF_COILS_TO_READ) { ESP_LOGE(TAG, "number_of_entities %u exceeds maximum coils to read %u for function code %02X", number_of_entities, MAX_NUM_OF_COILS_TO_READ, static_cast(function_code)); - return {}; + return pdu; } break; case FunctionCode::READ_DISCRETE_INPUTS: if (number_of_entities > MAX_NUM_OF_DISCRETE_INPUTS_TO_READ) { ESP_LOGE(TAG, "number_of_entities %u exceeds maximum discrete inputs to read %u for function code %02X", number_of_entities, MAX_NUM_OF_DISCRETE_INPUTS_TO_READ, static_cast(function_code)); - return {}; + return pdu; } break; case FunctionCode::READ_HOLDING_REGISTERS: @@ -323,57 +324,201 @@ StaticVector create_client_pdu(FunctionCode function_code if (number_of_entities > MAX_NUM_OF_REGISTERS_TO_READ) { ESP_LOGE(TAG, "number_of_entities %u exceeds maximum registers to read %u for function code %02X", number_of_entities, MAX_NUM_OF_REGISTERS_TO_READ, static_cast(function_code)); - return {}; - } - break; - case FunctionCode::WRITE_SINGLE_COIL: - case FunctionCode::WRITE_SINGLE_REGISTER: - break; // number_of_entities is ignored for single write, so no need to validate - case FunctionCode::WRITE_MULTIPLE_COILS: - case FunctionCode::WRITE_MULTIPLE_REGISTERS: - if (number_of_entities > MAX_NUM_OF_REGISTERS_TO_WRITE) { - ESP_LOGE(TAG, "number_of_entities %u exceeds maximum registers to write %u for function code %02X", - number_of_entities, MAX_NUM_OF_REGISTERS_TO_WRITE, static_cast(function_code)); - return {}; + return pdu; } break; default: - ESP_LOGE(TAG, "Unsupported function code %u for client PDU creation", static_cast(function_code)); - return {}; + ESP_LOGE(TAG, "Unsupported function code %02X for read PDU creation", static_cast(function_code)); + return pdu; } - StaticVector pdu; - pdu.push_back(static_cast(function_code)); - pdu.push_back(start_address >> 8); - pdu.push_back(start_address >> 0); - if (function_code != FunctionCode::WRITE_SINGLE_COIL && function_code != FunctionCode::WRITE_SINGLE_REGISTER) { - pdu.push_back(number_of_entities >> 8); - pdu.push_back(number_of_entities >> 0); - } + append_pdu_header(pdu, function_code, start_address, number_of_entities); + return pdu; +} - if (is_function_code_write(static_cast(function_code))) { - if (function_code == FunctionCode::WRITE_MULTIPLE_COILS || - function_code == FunctionCode::WRITE_MULTIPLE_REGISTERS) { - // 6 bytes of overhead (fc + start_addr×2 + qty×2 + byte_count) leave MAX_PDU_SIZE-6 bytes for values - static constexpr size_t MAX_WRITE_MULTIPLE_VALUES_LEN = MAX_PDU_SIZE - 6; - if (values_len > MAX_WRITE_MULTIPLE_VALUES_LEN) { - ESP_LOGE(TAG, "values_len %zu exceeds PDU capacity %zu, dropping request", values_len, - MAX_WRITE_MULTIPLE_VALUES_LEN); - return {}; - } - pdu.push_back(values_len); // Byte count is required for write multiple - for (size_t i = 0; i < values_len; i++) - pdu.push_back(values[i]); - } else { - // Write single register or coil (2 bytes) - if (values_len < 2) { - ESP_LOGE(TAG, "values_len %zu too small for write-single command (need 2), dropping request", values_len); - return {}; - } - pdu.push_back(values[0]); - pdu.push_back(values[1]); +PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address, uint16_t number_of_entities, + const uint8_t *values, size_t values_len) { + PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object) + // Generic entry point; prefer the direction- and type-specific builders (create_read_pdu(), + // create_write_registers_pdu(), etc.) which bound their inputs per spec. + if (is_function_code_read(static_cast(function_code))) { + if (values != nullptr || values_len > 0) { + ESP_LOGW(TAG, "Values provided for read function code %02X, but will be ignored", + static_cast(function_code)); } + auto read_pdu = create_read_pdu(function_code, start_address, number_of_entities); + pdu.assign(read_pdu.begin(), read_pdu.end()); + return pdu; + } + // Exact codes only: is_function_code_write() masks the exception bit, which would let the + // exception-flagged forms (0x85/0x86/0x8F/0x90) build a request announcing itself as an exception. + const bool is_single = + function_code == FunctionCode::WRITE_SINGLE_COIL || function_code == FunctionCode::WRITE_SINGLE_REGISTER; + const bool is_multi = + function_code == FunctionCode::WRITE_MULTIPLE_COILS || function_code == FunctionCode::WRITE_MULTIPLE_REGISTERS; + if (!is_single && !is_multi) { + ESP_LOGE(TAG, "Unsupported function code %02X for client PDU creation", static_cast(function_code)); + return pdu; + } + + // Generic write builder: raw caller-supplied bytes, so we can only guard against the PDU byte capacity here. + if (values == nullptr || values_len == 0) { + ESP_LOGE(TAG, "No values provided for write function code %02X", static_cast(function_code)); + return pdu; + } + if (number_of_entities == 0) { + ESP_LOGE(TAG, "Number of entities is zero for function code %02X", static_cast(function_code)); + return pdu; + } + // number_of_entities is ignored for single write, so only validate it for the multiple variants. + // The bound is per function code (coils pack 8 per byte, so their quantity limit is far higher) - + // the same limits is_client_pdu_standard() accepts, so builder and validator agree. + const uint16_t max_entities = + function_code == FunctionCode::WRITE_MULTIPLE_COILS ? MAX_NUM_OF_COILS_TO_WRITE : MAX_NUM_OF_REGISTERS_TO_WRITE; + if (!is_single && number_of_entities > max_entities) { + ESP_LOGE(TAG, "number_of_entities %u exceeds maximum %u for function code %02X", number_of_entities, max_entities, + static_cast(function_code)); + return pdu; + } + if (!is_single && uint32_t(start_address) + number_of_entities > 0x10000u) { + ESP_LOGE(TAG, "Write of %u entities at %u runs past the 16-bit address space, dropping request", number_of_entities, + start_address); + return pdu; + } + + if (is_single) { + // Write single register or coil: the two value bytes are the header's second field. + if (values_len < 2) { + ESP_LOGE(TAG, "values_len %zu too small for write-single command (need 2), dropping request", values_len); + return pdu; + } + // The spec allows exactly ON (0xFF00) and OFF (0x0000) for a single-coil write - the same rule + // is_client_pdu_standard() enforces, so a built frame cannot be misclassified on reply. + if (function_code == FunctionCode::WRITE_SINGLE_COIL && + ((values[0] != 0xFF && values[0] != 0x00) || values[1] != 0x00)) { + ESP_LOGE(TAG, "Invalid single-coil value %02X%02X (must be FF00 or 0000), dropping request", values[0], + values[1]); + return pdu; + } + append_pdu_header(pdu, function_code, start_address, uint16_t((values[0] << 8) | values[1])); + return pdu; + } + // The quantity is spec-bounded above, so the data length just has to agree with it exactly + // (registers are 2 bytes each, coils pack 8 per byte). This is the same consistency the response + // dispatch enforces via is_client_pdu_standard(), so a frame built here can never be classified + // non-standard on reply, and the spec bound keeps the PDU within capacity by construction. + // Checked before the header append: a failed check must return an empty PDU, not a 5-byte partial one. + const bool bits = function_code == FunctionCode::WRITE_MULTIPLE_COILS; + const size_t expected_len = + bits ? (static_cast(number_of_entities) + 7) / 8 : static_cast(number_of_entities) * 2; + if (values_len != expected_len) { + ESP_LOGE(TAG, "values_len %zu does not match %u entities (expected %zu) for function code %02X, dropping request", + values_len, number_of_entities, expected_len, static_cast(function_code)); + return pdu; + } + append_pdu_header(pdu, function_code, start_address, number_of_entities); + pdu.push_back(values_len); // Byte count is required for write multiple + for (size_t i = 0; i < values_len; i++) + pdu.push_back(values[i]); + return pdu; +} + +PduBuffer create_write_registers_pdu(uint16_t start_address, std::span values) { + PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object) + if (values.empty()) { + ESP_LOGE(TAG, "No values provided for write multiple registers, dropping request"); + return pdu; + } + // Byte count is registers × 2 (per spec); bounding the register count keeps the PDU within MAX_PDU_SIZE. + if (values.size() > MAX_NUM_OF_REGISTERS_TO_WRITE) { + ESP_LOGE(TAG, "values.size() %zu exceeds maximum registers to write %u, dropping request", values.size(), + MAX_NUM_OF_REGISTERS_TO_WRITE); + return pdu; + } + if (uint32_t(start_address) + values.size() > 0x10000u) { + ESP_LOGE(TAG, "Write of %zu registers at %u runs past the 16-bit address space, dropping request", values.size(), + start_address); + return pdu; + } + append_pdu_header(pdu, FunctionCode::WRITE_MULTIPLE_REGISTERS, start_address, values.size()); + pdu.push_back(static_cast(values.size() * 2)); // byte count + for (auto v : values) { + auto decoded_value = decode_value(v); + pdu.push_back(decoded_value[0]); + pdu.push_back(decoded_value[1]); } return pdu; } + +WriteSinglePdu create_write_single_register_pdu(uint16_t start_address, uint16_t value) { + WriteSinglePdu pdu; + append_pdu_header(pdu, FunctionCode::WRITE_SINGLE_REGISTER, start_address, value); + return pdu; +} + +WriteSinglePdu create_write_single_coil_pdu(uint16_t address, bool value) { + WriteSinglePdu pdu; + append_pdu_header(pdu, FunctionCode::WRITE_SINGLE_COIL, address, value ? 0xFF00 : 0x0000); + return pdu; +} + +// Shared core for the two coil-write overloads: validates, then builds into the caller's named +// pdu (left empty on failure). Each overload's returns all name one local, so NRVO fires. +static void build_write_coils_pdu(PduBuffer &pdu, uint16_t start_address, PackedBits bits) { + const uint16_t count = bits.size(); + const std::span packed_bits = bits.bytes(); + if (count == 0) { + ESP_LOGE(TAG, "No coils requested for write multiple coils, dropping request"); + return; + } + if (count > MAX_NUM_OF_COILS_TO_WRITE) { + ESP_LOGE(TAG, "count %u exceeds maximum coils to write %u, dropping request", count, MAX_NUM_OF_COILS_TO_WRITE); + return; + } + if (uint32_t(start_address) + count > 0x10000u) { + ESP_LOGE(TAG, "Write of %u coils at %u runs past the 16-bit address space, dropping request", count, start_address); + return; + } + const size_t byte_count = (count + 7) / 8; + if (packed_bits.size() < byte_count) { + ESP_LOGE(TAG, "packed_bits (%zu bytes) does not cover %u coils (%zu bytes), dropping request", packed_bits.size(), + count, byte_count); + return; + } + append_pdu_header(pdu, FunctionCode::WRITE_MULTIPLE_COILS, start_address, count); + pdu.push_back(static_cast(byte_count)); + for (size_t i = 0; i != byte_count; i++) { + pdu.push_back(packed_bits[i]); + } + // Zero the unused bits of the final byte, as the spec requires + if (count % 8 != 0) { + pdu[pdu.size() - 1] &= static_cast((1 << (count % 8)) - 1); + } +} + +PduBuffer create_write_coils_pdu(uint16_t start_address, PackedBits bits) { + PduBuffer pdu; + build_write_coils_pdu(pdu, start_address, bits); + return pdu; +} + +PduBuffer create_write_coils_pdu(uint16_t start_address, std::span values) { + PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object) + // Bound before packing so the transient buffer below cannot overflow; the shared core validates the rest. + if (values.size() > MAX_NUM_OF_COILS_TO_WRITE) { + ESP_LOGE(TAG, "values.size() %zu exceeds maximum coils to write %u, dropping request", values.size(), + MAX_NUM_OF_COILS_TO_WRITE); + return pdu; + } + StaticVector packed; + for (size_t i = 0; i != values.size(); i++) { + if (i % 8 == 0) + packed.push_back(0); + if (values[i]) + packed[i / 8] |= (1 << (i % 8)); + } + build_write_coils_pdu(pdu, start_address, + PackedBits(std::span(packed.data(), packed.size()), values.size())); + return pdu; +} } // namespace esphome::modbus::helpers diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index 44c1a01211..3dd933c4d7 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -350,7 +350,24 @@ inline int64_t payload_to_number(const std::vector &data, SensorValueTy */ std::optional registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type); -/** Create a modbus clinet pdu for reading/writing single/multiple coils/register/inputs. +// Named PDU buffer types: the builders' storage strategy (currently stack-allocated StaticVector, +// right-sized per shape) can be swapped in one place without touching every signature. +using PduBuffer = StaticVector; +using ReadPdu = StaticVector; +using WriteSinglePdu = StaticVector; + +/** Create a modbus read request PDU. + * @param function_code one of READ_COILS, READ_DISCRETE_INPUTS, READ_HOLDING_REGISTERS, READ_INPUT_REGISTERS + * @param start_address coil/register/input starting address + * @param number_of_entities number of coils/registers/inputs to read + * @return PDU (function code + data, no address, no CRC); empty on invalid input + */ +ReadPdu create_read_pdu(FunctionCode function_code, uint16_t start_address, uint16_t number_of_entities); + +/** Create a modbus client pdu for reading/writing single/multiple coils/register/inputs. + * Generic entry point; prefer the direction- and type-specific builders (create_read_pdu(), + * create_write_registers_pdu(), create_write_single_register_pdu(), create_write_coils_pdu(), + * create_write_single_coil_pdu()) which bound their inputs per spec. * @param function_code the modbus function code to use. One of: * READ_COILS * READ_DISCRETE_INPUTS @@ -366,11 +383,59 @@ std::optional registers_to_number(const uint16_t *registers, size_t cou * @param values_len length of values array * @return PDU (function code + data, no address, no CRC) */ -StaticVector create_client_pdu(FunctionCode function_code, uint16_t start_address, - uint16_t number_of_entities, const uint8_t *values = nullptr, - size_t values_len = 0); +PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address, uint16_t number_of_entities, + const uint8_t *values = nullptr, size_t values_len = 0); -inline std::vector float_to_payload(float value, SensorValueType value_type) { +/** Create modbus write multiple registers command + * Function 0x10 Write Multiple Registers + * @param start_address modbus address of the first register to write + * @param values register values to write; the register count is values.size() (at most + * MAX_NUM_OF_REGISTERS_TO_WRITE, an over-long set is rejected and an empty PDU is returned). + * Any contiguous uint16_t container converts (std::vector, std::array). + * @return PDU (function code + data, no address, no CRC) + */ +PduBuffer create_write_registers_pdu(uint16_t start_address, std::span values); + +/** Create modbus write single register command + * Function 0x06 Write Single Register + * @param start_address modbus address of the register to write + * @param value uint16_t value to write + * @return PDU (function code + data, no address, no CRC) + */ +WriteSinglePdu create_write_single_register_pdu(uint16_t start_address, uint16_t value); + +/** Create modbus write single coil command + * Function 0x05 Write Single Coil + * @param address modbus address of the coil to write + * @param value coil value to write + * @return PDU (function code + data, no address, no CRC) + */ +WriteSinglePdu create_write_single_coil_pdu(uint16_t address, bool value); + +/** Create modbus write multiple coils command + * Function 0x0F Write Multiple Coils + * @param start_address modbus address of the first coil to write + * @param values coil values to write; the coil count is values.size() (at most MAX_NUM_OF_COILS_TO_WRITE, an + * over-long set is rejected and an empty PDU is returned). Note std::vector is bit-packed and + * does not convert to a span; pass a std::array or other contiguous bool container. + * @return PDU (function code + data, no address, no CRC) + */ +PduBuffer create_write_coils_pdu(uint16_t start_address, std::span values); + +/** Create modbus write multiple coils command (function 0x0F) from bits packed as on the wire. + * @param start_address modbus address of the first coil to write + * @param bits PackedBits view of the coils to write (at most MAX_NUM_OF_COILS_TO_WRITE); invalid + * input returns an empty PDU + * @return PDU (function code + data, no address, no CRC) + */ +PduBuffer create_write_coils_pdu(uint16_t start_address, PackedBits bits); + +/** Append a float converted to register words to any push_back container (heap-free with StaticVector). + * @param data container the register words are appended to + * @param value value to convert + * @param value_type defines if 16/32/64 bits or FP32 is used + */ +template void float_to_payload(Container &data, float value, SensorValueType value_type) { int64_t val; if (value_type_is_float(value_type)) { @@ -379,8 +444,14 @@ inline std::vector float_to_payload(float value, SensorValueType value val = llroundf(value); } - std::vector data; number_to_payload(data, val, value_type); +} + +// Remove before 2027.2.0 +ESPDEPRECATED("Use the container overload of float_to_payload() instead. Removed in 2027.2.0", "2026.8.0") +inline std::vector float_to_payload(float value, SensorValueType value_type) { + std::vector data; + float_to_payload(data, value, value_type); return data; } diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 8be2e333ac..9616c1d935 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -98,7 +98,9 @@ inline int64_t payload_to_number(const std::vector &data, SensorValueTy ESPDEPRECATED("Use modbus::helpers::float_to_payload() instead. Removed in 2026.10.0", "2026.4.0") inline std::vector float_to_payload(float value, SensorValueType value_type) { - return modbus::helpers::float_to_payload(value, value_type); + std::vector data; + modbus::helpers::float_to_payload(data, value, value_type); + return data; } class ModbusController; diff --git a/esphome/components/modbus_controller/number/modbus_number.cpp b/esphome/components/modbus_controller/number/modbus_number.cpp index fdb770fd96..7b18b9e9fc 100644 --- a/esphome/components/modbus_controller/number/modbus_number.cpp +++ b/esphome/components/modbus_controller/number/modbus_number.cpp @@ -61,7 +61,8 @@ void ModbusNumber::control(float value) { this->parent_->on_write_register_response(write_cmd.register_type, this->start_address, data); }); } else { - data = modbus::helpers::float_to_payload(write_value, this->sensor_value_type); + std::vector payload; + modbus::helpers::float_to_payload(payload, write_value, this->sensor_value_type); ESP_LOGD(TAG, "Updating register: connected Sensor=%s start address=0x%X register count=%d new value=%.02f (val=%.02f)", @@ -71,10 +72,10 @@ void ModbusNumber::control(float value) { if (this->register_count == 1 && !this->use_write_multiple_) { // since offset is in bytes and a register is 16 bits we get the start by adding offset/2 write_cmd = ModbusCommandItem::create_write_single_command(this->parent_, this->start_address + this->offset / 2, - data[0]); + payload[0]); } else { write_cmd = ModbusCommandItem::create_write_multiple_command( - this->parent_, this->start_address + this->offset / 2, this->register_count, data); + this->parent_, this->start_address + this->offset / 2, this->register_count, payload); } // publish new value write_cmd.on_data_func = [this, write_cmd, value](modbus::EntityType register_type, uint16_t start_address, diff --git a/esphome/components/modbus_controller/output/modbus_output.cpp b/esphome/components/modbus_controller/output/modbus_output.cpp index ffe6f3bdfa..95618a7505 100644 --- a/esphome/components/modbus_controller/output/modbus_output.cpp +++ b/esphome/components/modbus_controller/output/modbus_output.cpp @@ -33,12 +33,30 @@ void ModbusFloatOutput::write_state(float value) { } // lambda didn't set payload if (data.empty()) { - data = modbus::helpers::float_to_payload(value, this->sensor_value_type); + modbus::helpers::float_to_payload(data, value, this->sensor_value_type); } ESP_LOGD(TAG, "Updating register: start address=0x%X register count=%d new value=%.02f (val=%.02f)", this->start_address, this->register_count, value, original_value); + // The command declares register_count registers, so the payload must be exactly that many words; + // anything else would put a byte count on the wire that disagrees with the quantity field. + // number_to_payload() appends nothing for RAW, so an empty payload must be caught before data[0]. + if (data.empty()) { + ESP_LOGW(TAG, "No payload was created for updating output"); + return; + } + + // register_count declares the READ range width - it may pull neighboring registers into one poll - + // so a write covers exactly the registers the value occupies: the quantity comes from the payload, + // never from register_count (padding to it would zero registers the user only declared for reading). + // A payload wider than the declared range means the config and the lambda disagree - drop it. + if (data.size() > this->register_count) { + ESP_LOGE(TAG, "Payload has %zu registers but register_count is %u; dropping write", data.size(), + this->register_count); + return; + } + // Create and send the write command ModbusCommandItem write_cmd; if (this->register_count == 1 && !this->use_write_multiple_) { @@ -46,7 +64,7 @@ void ModbusFloatOutput::write_state(float value) { ModbusCommandItem::create_write_single_command(this->parent_, this->start_address + this->offset, data[0]); } else { write_cmd = ModbusCommandItem::create_write_multiple_command(this->parent_, this->start_address + this->offset, - this->register_count, data); + data.size(), data); } this->parent_->queue_command(write_cmd); } diff --git a/esphome/components/modbus_controller/select/modbus_select.cpp b/esphome/components/modbus_controller/select/modbus_select.cpp index c650ca7641..daa6b10da4 100644 --- a/esphome/components/modbus_controller/select/modbus_select.cpp +++ b/esphome/components/modbus_controller/select/modbus_select.cpp @@ -72,13 +72,26 @@ void ModbusSelect::control(size_t index) { return; } + // The command declares register_count registers, so the payload must be exactly that many words: + // a value type narrower than the declared width is zero-padded (the config deliberately allows + // register_count larger than the value type). Anything else would put a byte count on the wire + // that disagrees with the quantity field, which conformant devices reject. + // register_count declares the READ range width - it may pull neighboring registers into one poll - + // so a write covers exactly the registers the value occupies: the quantity comes from the payload, + // never from register_count (padding to it would zero registers the user only declared for reading). + // A payload wider than the declared range means the config and the lambda disagree - drop it. + if (data.size() > this->register_count) { + ESP_LOGE(TAG, "Payload has %zu registers but register_count is %u; dropping write", data.size(), + this->register_count); + return; + } + const uint16_t write_address = this->start_address + this->offset / 2; ModbusCommandItem write_cmd; if ((this->register_count == 1) && (!this->use_write_multiple_)) { write_cmd = ModbusCommandItem::create_write_single_command(this->parent_, write_address, data[0]); } else { - write_cmd = - ModbusCommandItem::create_write_multiple_command(this->parent_, write_address, this->register_count, data); + write_cmd = ModbusCommandItem::create_write_multiple_command(this->parent_, write_address, data.size(), data); } this->parent_->queue_command(write_cmd); diff --git a/tests/components/modbus/modbus_helpers_test.cpp b/tests/components/modbus/modbus_helpers_test.cpp index 71ea3556b8..b471644226 100644 --- a/tests/components/modbus/modbus_helpers_test.cpp +++ b/tests/components/modbus/modbus_helpers_test.cpp @@ -1,5 +1,7 @@ #include +#include + #include "esphome/components/modbus/modbus_helpers.h" namespace esphome::modbus::helpers { @@ -304,6 +306,31 @@ TEST(ModbusCreateClientPdu, WriteMultipleOverEntityLimitReturnsEmpty) { EXPECT_TRUE(pdu.empty()); } +// The generic write path requires the data length to agree exactly with the entity count +// (registers: 2 bytes each; coils: 8 packed per byte) - the same rule the response dispatch +// enforces via is_client_pdu_standard(), so a frame built here always passes that gate. +TEST(ModbusCreateClientPdu, WriteMultipleRejectsMismatchedDataLength) { + const uint8_t values[] = {0x00, 0x0B, 0x00, 0x16}; + // 2 registers need exactly 4 data bytes. + EXPECT_TRUE(create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 2, values, 3).empty()); + EXPECT_FALSE(create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 2, values, 4).empty()); + // 10 coils pack into exactly 2 data bytes - the coil formula, not the register one. + EXPECT_FALSE(create_client_pdu(FC::WRITE_MULTIPLE_COILS, 0x0000, 10, values, 2).empty()); + EXPECT_TRUE(create_client_pdu(FC::WRITE_MULTIPLE_COILS, 0x0000, 10, values, 4).empty()); +} + +TEST(ModbusCreateClientPdu, WriteCoilsUseTheCoilLimitNotTheRegisterLimit) { + // 200 coils: above the 123-register write limit but well within the 1968-coil limit; 25 data bytes. + std::vector values(25, 0xAA); + auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_COILS, 0x0000, 200, values.data(), values.size()); + ASSERT_FALSE(pdu.empty()); + EXPECT_EQ(pdu[5], 25); // byte count uses the coil formula + EXPECT_TRUE(is_client_pdu_standard(pdu.data(), pdu.size())); // builder output passes the validator + // Builder and validator agree at the top of the range too: 1969 coils rejected. + std::vector big((1969 + 7) / 8, 0x00); + EXPECT_TRUE(create_client_pdu(FC::WRITE_MULTIPLE_COILS, 0x0000, 1969, big.data(), big.size()).empty()); +} + TEST(ModbusHelpersTest, PayloadToNumberRejectsOffsetAtEndOfBuffer) { const std::vector data{0x12, 0x34}; EXPECT_FALSE(payload_to_number(std::span(data), SensorValueType::U_WORD, 2, 0xFFFFFFFF).has_value()); @@ -354,6 +381,159 @@ TEST(ModbusHelpersTest, RegistersToNumberRejectsTruncatedMultiRegisterValue) { EXPECT_FALSE(registers_to_number(registers, 1, SensorValueType::U_DWORD).has_value()); } +// --- typed builders ---------------------------------------------------------- + +TEST(ModbusTypedBuilders, ReadPduWireBytes) { + auto pdu = create_read_pdu(FC::READ_HOLDING_REGISTERS, 0x0102, 3); + const std::vector expected{0x03, 0x01, 0x02, 0x00, 0x03}; + EXPECT_EQ(std::vector(pdu.begin(), pdu.end()), expected); + EXPECT_TRUE(is_client_pdu_standard(pdu.data(), pdu.size())); + // Reads that run past the 16-bit address space are refused. + EXPECT_TRUE(create_read_pdu(FC::READ_HOLDING_REGISTERS, 0xFFFF, 2).empty()); +} + +TEST(ModbusTypedBuilders, WriteSinglePduWireBytes) { + auto reg = create_write_single_register_pdu(0x0010, 0xABCD); + const std::vector expected_reg{0x06, 0x00, 0x10, 0xAB, 0xCD}; + EXPECT_EQ(std::vector(reg.begin(), reg.end()), expected_reg); + EXPECT_TRUE(is_client_pdu_standard(reg.data(), reg.size())); + auto coil_on = create_write_single_coil_pdu(0x0011, true); + auto coil_off = create_write_single_coil_pdu(0x0011, false); + const std::vector expected_on{0x05, 0x00, 0x11, 0xFF, 0x00}; + const std::vector expected_off{0x05, 0x00, 0x11, 0x00, 0x00}; + EXPECT_EQ(std::vector(coil_on.begin(), coil_on.end()), expected_on); + EXPECT_EQ(std::vector(coil_off.begin(), coil_off.end()), expected_off); + EXPECT_TRUE(is_client_pdu_standard(coil_on.data(), coil_on.size())); + EXPECT_TRUE(is_client_pdu_standard(coil_off.data(), coil_off.size())); +} + +TEST(ModbusTypedBuilders, WriteRegistersPduWireBytes) { + const uint16_t values[] = {0x000B, 0x0016}; + auto pdu = create_write_registers_pdu(0x0000, values); + const std::vector expected{0x10, 0x00, 0x00, 0x00, 0x02, 0x04, 0x00, 0x0B, 0x00, 0x16}; + EXPECT_EQ(std::vector(pdu.begin(), pdu.end()), expected); + EXPECT_TRUE(is_client_pdu_standard(pdu.data(), pdu.size())); + // Writes that run past the 16-bit address space are refused. + EXPECT_TRUE(create_write_registers_pdu(0xFFFF, values).empty()); +} + +TEST(ModbusTypedBuilders, WriteRegistersPduRejectsOverLimit) { + std::vector values(MAX_NUM_OF_REGISTERS_TO_WRITE + 1, 0xAAAA); + EXPECT_TRUE(create_write_registers_pdu(0x0000, values).empty()); + values.pop_back(); + EXPECT_FALSE(create_write_registers_pdu(0x0000, values).empty()); +} + +TEST(ModbusTypedBuilders, FloatToPayloadAppendsToExistingContent) { + // The container overload appends - the semantic every migrated caller relies on when a lambda + // has already put words into the buffer. + std::vector data{0x1234}; + float_to_payload(data, 1.0f, SensorValueType::U_WORD); + ASSERT_EQ(data.size(), 2u); + EXPECT_EQ(data[0], 0x1234); + EXPECT_EQ(data[1], 0x0001); +} + +TEST(ModbusCreateClientPdu, ExceptionFlaggedWriteCodesRejected) { + // is_function_code_write() masks the exception bit; the builder must not. + const uint8_t values[] = {0x00, 0x0B, 0x00, 0x16}; + EXPECT_TRUE(create_client_pdu(FunctionCode(0x90), 0x0000, 2, values, 4).empty()); + EXPECT_TRUE(create_client_pdu(FunctionCode(0x85), 0x0000, 1, values, 2).empty()); +} + +TEST(ModbusTypedBuilders, BoolSpanCoilBuilderRejectsOverLimit) { + // This early guard is what keeps the 246-byte packing buffer from overflowing - the shared core's + // identical check runs after packing, so it cannot protect it. + auto big = std::make_unique(MAX_NUM_OF_COILS_TO_WRITE + 1); + EXPECT_TRUE(create_write_coils_pdu(0, std::span(big.get(), MAX_NUM_OF_COILS_TO_WRITE + 1)).empty()); +} + +TEST(ModbusCreateClientPdu, SingleCoilValueValidated) { + const uint8_t on[] = {0xFF, 0x00}; + const uint8_t junk[] = {0x01, 0x00}; + EXPECT_FALSE(create_client_pdu(FC::WRITE_SINGLE_COIL, 0x0003, 1, on, 2).empty()); + EXPECT_TRUE(create_client_pdu(FC::WRITE_SINGLE_COIL, 0x0003, 1, junk, 2).empty()); +} + +// --- create_write_coils_pdu (packed) --------------------------------------- + +TEST(ModbusWriteCoilsPacked, MatchesBoolBuilder) { + const bool coils[] = {true, false, true, true, false, false, true, false, true, true}; + uint8_t packed[] = {0b01001101, 0b00000011}; + auto from_bools = create_write_coils_pdu(0x13, coils); + auto from_packed = create_write_coils_pdu(0x13, PackedBits(packed, 10)); + ASSERT_EQ(from_packed.size(), from_bools.size()); + EXPECT_EQ(0, memcmp(from_packed.data(), from_bools.data(), from_bools.size())); +} + +TEST(ModbusWriteCoilsPacked, MasksUnusedTrailingBits) { + uint8_t packed[] = {0xFF}; + auto pdu = create_write_coils_pdu(0, PackedBits(packed, 3)); + ASSERT_EQ(pdu.size(), 7u); + EXPECT_EQ(pdu[6], 0x07); +} + +TEST(ModbusWriteCoilsPacked, RejectsShortBufferAndZeroCount) { + uint8_t packed[] = {0xFF}; + EXPECT_TRUE(create_write_coils_pdu(0, PackedBits(packed, 9)).empty()); // needs 2 bytes + EXPECT_TRUE(create_write_coils_pdu(0, PackedBits(packed, 0)).empty()); +} + +TEST(ModbusHelpersTest, PackedBitsReadsLsbFirst) { + const uint8_t packed[] = {0x0D, 0x03}; // bits 0,2,3 and 8,9 + PackedBits bits(packed, 11); + EXPECT_EQ(bits.size(), 11u); + EXPECT_TRUE(bits[0]); + EXPECT_FALSE(bits[1]); + EXPECT_TRUE(bits[2]); + EXPECT_TRUE(bits[3]); + EXPECT_FALSE(bits[7]); + EXPECT_TRUE(bits[8]); + EXPECT_TRUE(bits[9]); + EXPECT_FALSE(bits[10]); + EXPECT_EQ(bits.bytes().size(), 2u); +} + +TEST(ModbusHelpersTest, MutablePackedBitsSetsAndClears) { + uint8_t packed[2] = {0x00, 0xFF}; + MutablePackedBits bits(packed, 16); + bits.set(0, true); + bits.set(3, true); + bits.set(9, false); + EXPECT_EQ(packed[0], 0x09); // bits 0 and 3 + EXPECT_EQ(packed[1], 0xFD); // bit 9 (bit 1 of byte 1) cleared +} + +TEST(ModbusHelpersTest, MutablePackedBitsRoundTripAndConversion) { + const bool original[] = {true, true, false, true, false, false, false, false, true, false, true}; + constexpr uint16_t count = sizeof(original); + uint8_t packed[(count + 7) / 8] = {}; + MutablePackedBits out(packed, count); + for (uint16_t i = 0; i != count; i++) + out.set(i, original[i]); + PackedBits view = out; // implicit conversion to the read-only view + ASSERT_EQ(view.size(), count); + for (uint16_t i = 0; i != count; i++) + EXPECT_EQ(view[i], original[i]) << "bit " << i; +} + +TEST(ModbusHelpersTest, PackedBitsViewContractsEnforced) { + uint8_t buf[8] = {}; + PackedBits view(buf, 10); // 10 bits -> 2 bytes, over an 8-byte buffer + EXPECT_EQ(view.bytes().size(), 2u); + + MutablePackedBits bits(std::span(buf, 2), 10); + bits.set(9, true); // in range: lands in byte 1 + bits.set(10, true); // out of range: dropped + bits.set(300, true); // far out of range: dropped, no write past the span + + MutablePackedBits short_bits(std::span(buf, 1), 10); // contract-violating: 10 bits over 1 byte + short_bits.set(9, false); // within count_ but past the span: dropped (would clear bit 9 set above) + EXPECT_EQ(buf[1], 0x02); + for (size_t i = 2; i < sizeof(buf); i++) + EXPECT_EQ(buf[i], 0) << "byte " << i; +} + // server_pdu_payload() must never classify an exception PDU as a read: [fc|0x80, code] is 2 bytes, and a // read-offset of 2 would return an empty span, losing the exception code. The payload of an exception PDU // is the exception code byte, for reads and writes alike. From 7fbb647c65aefef4f1a1e9d09ca2fa55fe76bb70 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:59:33 -0400 Subject: [PATCH 1063/1815] [espidf] Honor compile_process_limit in native ESP-IDF builds (#17857) --- esphome/espidf/toolchain.py | 16 ++++-- tests/unit_tests/test_espidf_toolchain.py | 59 ++++++++++++++++++++++- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index f2bb99d970..9dd3474910 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -10,7 +10,12 @@ import shutil import subprocess from esphome.components.esp32.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION -from esphome.const import CONF_FRAMEWORK, CONF_SOURCE +from esphome.const import ( + CONF_COMPILE_PROCESS_LIMIT, + CONF_ESPHOME, + CONF_FRAMEWORK, + CONF_SOURCE, +) from esphome.core import CORE, EsphomeError from esphome.espidf.framework import check_esp_idf_install, get_framework_env from esphome.espidf.size_summary import print_summary @@ -155,7 +160,10 @@ def _get_idf_tool(name: str) -> str: def run_idf_py( - *args, cwd: Path | None = None, capture_output: bool = False + *args, + cwd: Path | None = None, + capture_output: bool = False, + jobs: int | None = None, ) -> int | str: """Run idf.py with the given arguments.""" idf_path = _get_idf_path() @@ -163,6 +171,8 @@ def run_idf_py( raise EsphomeError("ESP-IDF not found") env = _get_idf_env() + if jobs is not None: + env = {**env, "IDF_PY_BUILD_JOBS": str(jobs)} python_executable = _get_idf_tool("python") idf_py = idf_path / "tools" / "idf.py" # Dispatch idf.py through esphome.espidf.runner, which wraps @@ -392,7 +402,7 @@ def run_compile(config, verbose: bool) -> int: args.append("build") args.append("size") - rc = run_idf_py(*args) + rc = run_idf_py(*args, jobs=config[CONF_ESPHOME].get(CONF_COMPILE_PROCESS_LIMIT)) if rc == 0: size_json = CORE.relative_build_path("build", "esp_idf_size.json") partitions = CORE.relative_build_path("partitions.csv") diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 8731884ed3..2735746264 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -10,7 +10,12 @@ from unittest.mock import patch import pytest -from esphome.const import CONF_FRAMEWORK, CONF_SOURCE +from esphome.const import ( + CONF_COMPILE_PROCESS_LIMIT, + CONF_ESPHOME, + CONF_FRAMEWORK, + CONF_SOURCE, +) from esphome.core import CORE, EsphomeError from esphome.espidf import toolchain @@ -309,6 +314,58 @@ def test_get_cmake_output_missing_build_does_not_resolve_idf_env( mock_run.assert_not_called() +def test_run_idf_py_jobs_sets_build_jobs_env(setup_core: Path) -> None: + """The jobs argument is exported to idf.py as IDF_PY_BUILD_JOBS.""" + _setup_build(setup_core) + + with ( + patch.object(toolchain, "_get_idf_path", return_value=Path("/idf")), + patch.object(toolchain, "_get_idf_env", return_value={"PATH": "/bin"}), + patch.object(toolchain, "_get_idf_tool", return_value="python"), + patch.object(toolchain.subprocess, "run") as mock_run, + ): + mock_run.return_value.returncode = 0 + + toolchain.run_idf_py("build", jobs=2) + env = mock_run.call_args.kwargs["env"] + assert env["IDF_PY_BUILD_JOBS"] == "2" + assert env["PATH"] == "/bin" + + toolchain.run_idf_py("build") + env = mock_run.call_args.kwargs["env"] + assert "IDF_PY_BUILD_JOBS" not in env + + +def test_run_compile_passes_compile_process_limit(setup_core: Path) -> None: + """compile_process_limit is forwarded to run_idf_py as the job limit.""" + _setup_build(setup_core) + config = {CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 1}} + + with ( + patch.object(toolchain, "need_reconfigure", return_value=False), + patch.object(toolchain, "run_idf_py", return_value=0) as mock_run, + patch.object(toolchain, "print_summary"), + ): + assert toolchain.run_compile(config, verbose=False) == 0 + + mock_run.assert_called_once_with("build", "size", jobs=1) + + +def test_run_compile_without_compile_process_limit(setup_core: Path) -> None: + """When no compile_process_limit is set, no job limit is passed to idf.py.""" + _setup_build(setup_core) + config = {CONF_ESPHOME: {}} + + with ( + patch.object(toolchain, "need_reconfigure", return_value=False), + patch.object(toolchain, "run_idf_py", return_value=0) as mock_run, + patch.object(toolchain, "print_summary"), + ): + assert toolchain.run_compile(config, verbose=False) == 0 + + mock_run.assert_called_once_with("build", "size", jobs=None) + + def test_get_core_framework_version_from_core_data(): """The version is read from CORE.data when validation populated it.""" from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION From fbb1a76e2a988db22b9236e7514696d373ac3c00 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:09:37 -0400 Subject: [PATCH 1064/1815] Bump github/codeql-action/analyze from 4.37.2 to 4.37.3 (#17786) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 953f19c0a9..4b8b754dbb 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e0647621c2984b5ed2f768cb892365bf2a616ad1 # v4.37.2 + uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 with: category: "/language:${{matrix.language}}" From 98e9dd795400f89f81b6502dafe3a9290d25f0cf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:09:46 -0400 Subject: [PATCH 1065/1815] Bump github/codeql-action/init from 4.37.2 to 4.37.3 (#17785) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4b8b754dbb..3ffb4a633f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@e0647621c2984b5ed2f768cb892365bf2a616ad1 # v4.37.2 + uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} From e0644ab20a7a67e76f05186f095cce880f4642aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Jul 2026 18:12:53 -1000 Subject: [PATCH 1066/1815] [esp32] Capture fault address and raw cause in crash handler (#17769) --- esphome/components/esp32/__init__.py | 6 ++- esphome/components/esp32/crash_handler.cpp | 34 +++++++++++++-- .../components/test_esp_stacktrace.py | 42 +++++++++++++++++++ 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 7e13156b4d..9f3d7f1dc9 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -3108,9 +3108,10 @@ def _parse_register(config, regex, line): STACKTRACE_ESP32_PC_RE = re.compile(r".*PC\s*:\s*(?:0x)?(4[0-9a-fA-F]{7}).*") -STACKTRACE_ESP32_EXCVADDR_RE = re.compile(r"EXCVADDR\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})") +STACKTRACE_ESP32_EXCVADDR_RE = re.compile(r".*EXCVADDR\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})") STACKTRACE_ESP32_C3_PC_RE = re.compile(r"MEPC\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})") STACKTRACE_ESP32_C3_RA_RE = re.compile(r"RA\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})") +STACKTRACE_ESP32_C3_MTVAL_RE = re.compile(r".*MTVAL\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})") STACKTRACE_BAD_ALLOC_RE = re.compile( r"^last failed alloc call: (4[0-9a-fA-F]{7})\((\d+)\)$" ) @@ -3128,9 +3129,10 @@ def process_stacktrace(config, line, backtrace_state): # ESP32 PC/EXCVADDR _parse_register(config, STACKTRACE_ESP32_PC_RE, line) _parse_register(config, STACKTRACE_ESP32_EXCVADDR_RE, line) - # ESP32-C3 PC/RA + # ESP32-C3 PC/RA/MTVAL _parse_register(config, STACKTRACE_ESP32_C3_PC_RE, line) _parse_register(config, STACKTRACE_ESP32_C3_RA_RE, line) + _parse_register(config, STACKTRACE_ESP32_C3_MTVAL_RE, line) # bad alloc match = re.match(STACKTRACE_BAD_ALLOC_RE, line) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index a7de48a6ee..4c0f430daf 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -122,7 +122,7 @@ static uint8_t IRAM_ATTR capture_riscv_backtrace(RvExcFrame *frame, uint32_t *ou // Magic is second to validate the data. Remaining fields can change between versions. // Version is uint32_t because it would be padded to 4 bytes anyway before the next // uint32_t field, so we use the full width rather than wasting 3 bytes of padding. -static constexpr uint32_t CRASH_DATA_VERSION = 2; +static constexpr uint32_t CRASH_DATA_VERSION = 3; struct RawCrashData { uint32_t version; uint32_t magic; @@ -132,7 +132,8 @@ struct RawCrashData { uint8_t exception; // panic_exception_t enum (FAULT/ABORT/IWDT/TWDT/DEBUG) uint8_t pseudo_excause; // Whether cause is a pseudo exception (Xtensa SoC-level panic) uint32_t backtrace[MAX_BACKTRACE]; - uint32_t cause; // Architecture-specific: exccause (Xtensa) or mcause (RISC-V) + uint32_t cause; // Architecture-specific: exccause (Xtensa) or mcause (RISC-V) + uint32_t fault_addr; // Faulting memory address: excvaddr (Xtensa) or mtval (RISC-V) uint8_t crashed_core; #if SOC_CPU_CORES_NUM > 1 static_assert(SOC_CPU_CORES_NUM == 2, "Dual-core logic assumes exactly 2 cores"); @@ -240,6 +241,16 @@ static const char *get_exception_reason() { nullptr, "LoadProhibited", "StoreProhibited", + nullptr, + nullptr, + "Cp0Dis", + "Cp1Dis", + "Cp2Dis", + "Cp3Dis", + "Cp4Dis", + "Cp5Dis", + "Cp6Dis", + "Cp7Dis", }; uint32_t cause = s_raw_crash_data.cause; if (cause < sizeof(REASON) / sizeof(REASON[0]) && REASON[cause] != nullptr) @@ -332,12 +343,24 @@ void crash_handler_log() { ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); const char *reason = get_exception_reason(); if (reason != nullptr) { - ESP_LOGE(TAG, " Reason: %s - %s", get_exception_type(), reason); + ESP_LOGE(TAG, " Reason: %s - %s (cause %" PRIu32 ")", get_exception_type(), reason, s_raw_crash_data.cause); } else { ESP_LOGE(TAG, " Reason: %s", get_exception_type()); } ESP_LOGE(TAG, " Crashed core: %d", s_raw_crash_data.crashed_core); ESP_LOGE(TAG, " PC: 0x%08" PRIX32 " (fault location)", s_raw_crash_data.pc); + // Faulting memory address — only meaningful for real CPU faults, not + // aborts/watchdogs or SoC-level pseudo exceptions. Uses the same register + // name as ESP-IDF's live register dump for the architecture (EXCVADDR on + // Xtensa, MTVAL on RISC-V) so the CLI decodes it when it happens to be a + // code address. + if (s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause) { +#if CONFIG_IDF_TARGET_ARCH_XTENSA + ESP_LOGE(TAG, " EXCVADDR: 0x%08" PRIX32 " (faulting address)", s_raw_crash_data.fault_addr); +#elif CONFIG_IDF_TARGET_ARCH_RISCV + ESP_LOGE(TAG, " MTVAL: 0x%08" PRIX32 " (faulting address)", s_raw_crash_data.fault_addr); +#endif + } log_backtrace(s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, s_raw_crash_data.reg_frame_count); #if SOC_CPU_CORES_NUM > 1 @@ -382,6 +405,9 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { s_raw_crash_data.exception = (uint8_t) info->exception; s_raw_crash_data.pseudo_excause = info->pseudo_excause ? 1 : 0; s_raw_crash_data.crashed_core = (uint8_t) info->core; + // Zero unconditionally so a null frame doesn't leave stale .noinit data from a previous boot + s_raw_crash_data.cause = 0; + s_raw_crash_data.fault_addr = 0; #if SOC_CPU_CORES_NUM > 1 s_raw_crash_data.other_backtrace_count = 0; s_raw_crash_data.other_reg_frame_count = 0; @@ -392,6 +418,7 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { if (info->frame != nullptr) { auto *xt_frame = (XtExcFrame *) info->frame; s_raw_crash_data.cause = xt_frame->exccause; + s_raw_crash_data.fault_addr = xt_frame->excvaddr; s_raw_crash_data.backtrace_count = walk_xtensa_backtrace(xt_frame, s_raw_crash_data.backtrace, MAX_BACKTRACE); } @@ -414,6 +441,7 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { if (info->frame != nullptr) { auto *rv_frame = (RvExcFrame *) info->frame; s_raw_crash_data.cause = rv_frame->mcause; + s_raw_crash_data.fault_addr = rv_frame->mtval; s_raw_crash_data.backtrace_count = capture_riscv_backtrace(rv_frame, s_raw_crash_data.backtrace, MAX_BACKTRACE, &s_raw_crash_data.reg_frame_count); } diff --git a/tests/unit_tests/components/test_esp_stacktrace.py b/tests/unit_tests/components/test_esp_stacktrace.py index f231ac5fb7..eb7e63fc4d 100644 --- a/tests/unit_tests/components/test_esp_stacktrace.py +++ b/tests/unit_tests/components/test_esp_stacktrace.py @@ -137,3 +137,45 @@ def test_process_stacktrace_esp32_crash_handler( state = process_stacktrace(config, line_bt1, False) mock_esp32_decode_pc.assert_called_once_with(config, "42005ABC") assert state is False + + mock_esp32_decode_pc.reset_mock() + + # Reason line carries no address, must not trigger a decode + line_reason = "[E][esp32.crash:079]: Reason: Fault - LoadProhibited (cause 28)" + state = process_stacktrace(config, line_reason, False) + mock_esp32_decode_pc.assert_not_called() + assert state is False + + mock_esp32_decode_pc.reset_mock() + + # EXCVADDR pointing at code (e.g. jumping through a corrupted pointer) decodes + line_excvaddr = "[E][esp32.crash:081]: EXCVADDR: 0x400D9ABC (faulting address)" + state = process_stacktrace(config, line_excvaddr, False) + mock_esp32_decode_pc.assert_called_once_with(config, "400D9ABC") + assert state is False + + mock_esp32_decode_pc.reset_mock() + + # EXCVADDR pointing at data (heap/null) is not a code address, must be ignored + line_excvaddr_data = ( + "[E][esp32.crash:081]: EXCVADDR: 0x0000001C (faulting address)" + ) + state = process_stacktrace(config, line_excvaddr_data, False) + mock_esp32_decode_pc.assert_not_called() + assert state is False + + mock_esp32_decode_pc.reset_mock() + + # RISC-V MTVAL pointing at code decodes + line_mtval = "[E][esp32.crash:081]: MTVAL: 0x42001234 (faulting address)" + state = process_stacktrace(config, line_mtval, False) + mock_esp32_decode_pc.assert_called_once_with(config, "42001234") + assert state is False + + mock_esp32_decode_pc.reset_mock() + + # RISC-V MTVAL pointing at data must be ignored + line_mtval_data = "[E][esp32.crash:081]: MTVAL: 0x3FC80123 (faulting address)" + state = process_stacktrace(config, line_mtval_data, False) + mock_esp32_decode_pc.assert_not_called() + assert state is False From b5e18eb101d34fee84d4167d680c9734c29a3b87 Mon Sep 17 00:00:00 2001 From: Thomas Rupprecht Date: Sun, 26 Jul 2026 06:16:07 +0200 Subject: [PATCH 1067/1815] [opentherm] Fix l/min unit capitalization to L/min (#17804) Co-authored-by: Claude Opus 4.8 (1M context) --- esphome/components/opentherm/schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/opentherm/schema.py b/esphome/components/opentherm/schema.py index f70c8e24db..7f3ea2df36 100644 --- a/esphome/components/opentherm/schema.py +++ b/esphome/components/opentherm/schema.py @@ -91,7 +91,7 @@ SENSORS: dict[str, SensorSchema] = { ), "dhw_flow_rate": SensorSchema( description="Water flow rate in DHW circuit", - unit_of_measurement="l/min", + unit_of_measurement="L/min", accuracy_decimals=2, icon="mdi:waves-arrow-right", state_class=STATE_CLASS_MEASUREMENT, From 559614077dfde9d85ca0b78ae696404b3096153f Mon Sep 17 00:00:00 2001 From: Thomas Rupprecht Date: Sun, 26 Jul 2026 06:22:49 +0200 Subject: [PATCH 1068/1815] [ezo_pmp] Fix ml/min and ml unit capitalization (#17803) Co-authored-by: Claude Opus 4.8 (1M context) --- esphome/components/ezo_pmp/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/ezo_pmp/sensor.py b/esphome/components/ezo_pmp/sensor.py index a0473b292c..ed4efeeabc 100644 --- a/esphome/components/ezo_pmp/sensor.py +++ b/esphome/components/ezo_pmp/sensor.py @@ -23,8 +23,8 @@ CONF_PUMP_VOLTAGE = "pump_voltage" CONF_LAST_VOLUME_REQUESTED = "last_volume_requested" CONF_MAX_FLOW_RATE = "max_flow_rate" -UNIT_MILILITER = "ml" -UNIT_MILILITERS_PER_MINUTE = "ml/min" +UNIT_MILILITER = "mL" +UNIT_MILILITERS_PER_MINUTE = "mL/min" CONFIG_SCHEMA = cv.Schema( { From 9dd7dcb06d17575dcedf9f40bcda8a32a14390d3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Jul 2026 20:07:14 -1000 Subject: [PATCH 1069/1815] [espidf] Also skip installing gdb and ULP toolchains ESPHome never runs (#17687) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- esphome/espidf/framework.py | 63 ++++++++++++++--------- tests/unit_tests/test_espidf_framework.py | 47 +++++++++++------ 2 files changed, 71 insertions(+), 39 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index f1544e9d46..aedbeba69e 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -551,30 +551,46 @@ def _patch_tools_json_for_linux_arm64(framework_path: Path) -> None: ) -def _patch_tools_json_demote_openocd(framework_path: Path) -> None: - """Demote openocd-esp32 from ``install: always`` to ``install: on_request``. +# Tools marked ``install: always`` in tools.json that no ESPHome build ever +# runs. openocd-esp32 is a JTAG debug server (its post-install check also +# fails outright on systems without libusb-1.0, #17685). The gdb bundles are +# debuggers used only by ``idf.py gdb``/``idf.py monitor`` flows ESPHome never +# invokes; stack decoding uses addr2line from the compiler toolchains instead. +# esp32ulp-elf is the ULP coprocessor toolchain, and ESPHome excludes the IDF +# ``ulp`` component from every build. esp-rom-elfs stays required: the cmake +# gdbinit generation reads ESP_ROM_ELF_DIR during every configure and warns +# when it is missing. +_UNUSED_IDF_TOOLS: tuple[str, ...] = ( + "esp32ulp-elf", + "openocd-esp32", + "riscv32-esp-elf-gdb", + "xtensa-esp-elf-gdb", +) - ``idf_tools.py install required`` installs every tool marked ``always`` in - tools.json and validates each one after extraction by running its version - command. openocd links against libusb-1.0, which minimal systems (bare LXC - containers, slim images) often lack, so that one validation aborted the - whole framework install and left it permanently retrying (#17685) — even - though ESPHome never runs openocd (it is a JTAG debugging tool). Demoting - it drops it from the ``required`` set: it is no longer downloaded or - validated, and the tool-path export treats a missing ``on_request`` tool - as fine. A user who wants it can still name ``openocd-esp32`` explicitly - in ESPHOME_IDF_DEFAULT_TOOLS; explicit names bypass install-type - filtering. - Because this runs on every install check, an install stuck in the - failing state (which never wrote its stamp file) heals on the next - build without a clean. +def _patch_tools_json_demote_unused_tools(framework_path: Path) -> None: + """Demote tools ESPHome never runs from ``install: always`` to ``on_request``. + + ``idf_tools.py install required`` downloads every tool marked ``always`` + in tools.json and validates each one after extraction by running its + version command. Demoting the tools in ``_UNUSED_IDF_TOOLS`` drops them + from the ``required`` set: they are no longer downloaded or validated, + and the tool-path export treats a missing ``on_request`` tool as fine. + Besides the download and disk savings, this makes the openocd libusb + validation failure (#17685) impossible; because this runs on every + install check, an install stuck in that failing state (which never wrote + its stamp file) heals on the next build without a clean. A user who + wants one of these tools can still name it explicitly in + ESPHOME_IDF_DEFAULT_TOOLS; explicit names bypass install-type filtering. """ def apply_patch(data: dict) -> bool: changed = False for tool in data.get("tools", []): - if tool.get("name") == "openocd-esp32" and tool.get("install") == "always": + if ( + tool.get("name") in _UNUSED_IDF_TOOLS + and tool.get("install") == "always" + ): tool["install"] = "on_request" changed = True return changed @@ -582,9 +598,8 @@ def _patch_tools_json_demote_openocd(framework_path: Path) -> None: _patch_tools_json( framework_path, apply_patch, - "Patched %s to make openocd-esp32 optional (not needed for " - "building, and its install check fails on systems without " - "libusb-1.0).", + "Patched %s to skip installing tools ESPHome does not use " + "(openocd, gdb, ULP toolchain).", ) @@ -779,10 +794,10 @@ def _check_esphome_idf_framework_install( # a pre-patch tools.json get fixed up without forcing a clean. _patch_tools_json_for_linux_arm64(framework_path) - # Drop openocd-esp32 from the required tool set on every invocation so - # an install that previously failed on its libusb check recovers on the - # next build. - _patch_tools_json_demote_openocd(framework_path) + # Drop tools ESPHome never runs from the required tool set on every + # invocation, so an install that previously failed on the openocd libusb + # check recovers on the next build. + _patch_tools_json_demote_unused_tools(framework_path) # 3. Check if the framework tools are the same and correctly installed if not install: diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index e1408e538a..7de1557bd6 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -29,7 +29,7 @@ from esphome.espidf.framework import ( _get_python_env_path, _get_python_version, _parse_git_source, - _patch_tools_json_demote_openocd, + _patch_tools_json_demote_unused_tools, _patch_tools_json_for_linux_arm64, _prefetch_idf_tool_archives, _windows_long_paths_enabled, @@ -352,7 +352,7 @@ def espidf_mocks(setup_core: Path): patch("esphome.espidf.framework._clone_idf_with_submodules") as clone, patch("esphome.espidf.framework._write_idf_version_txt"), patch("esphome.espidf.framework._patch_tools_json_for_linux_arm64"), - patch("esphome.espidf.framework._patch_tools_json_demote_openocd"), + patch("esphome.espidf.framework._patch_tools_json_demote_unused_tools"), patch("esphome.espidf.framework._prefetch_idf_tool_archives"), patch("esphome.espidf.framework._write_stamp"), patch("esphome.espidf.framework._check_stamp", return_value=True), @@ -952,28 +952,37 @@ def test_get_tool_downloads_inprocess_explicit_tool_specs( # --------------------------------------------------------------------------- -# _patch_tools_json_demote_openocd (openocd-esp32 made optional) +# _patch_tools_json_demote_unused_tools (openocd, gdb, ULP toolchain optional) # --------------------------------------------------------------------------- -def test_demote_openocd_patches_install_type(tmp_path: Path) -> None: +def test_demote_unused_tools_patches_install_type(tmp_path: Path) -> None: tools_json = _write_tools_json( tmp_path, { "tools": [ {"name": "openocd-esp32", "install": "always"}, - {"name": "cmake", "install": "always"}, + {"name": "xtensa-esp-elf-gdb", "install": "always"}, + {"name": "riscv32-esp-elf-gdb", "install": "always"}, + {"name": "esp32ulp-elf", "install": "always"}, + {"name": "xtensa-esp-elf", "install": "always"}, + {"name": "esp-rom-elfs", "install": "always"}, ] }, ) - _patch_tools_json_demote_openocd(tmp_path) + _patch_tools_json_demote_unused_tools(tmp_path) data = json.loads(tools_json.read_text(encoding="utf-8")) - openocd = next(t for t in data["tools"] if t["name"] == "openocd-esp32") - cmake = next(t for t in data["tools"] if t["name"] == "cmake") - assert openocd["install"] == "on_request" - # other tools are left untouched - assert cmake["install"] == "always" + install_types = {t["name"]: t["install"] for t in data["tools"]} + assert install_types == { + "openocd-esp32": "on_request", + "xtensa-esp-elf-gdb": "on_request", + "riscv32-esp-elf-gdb": "on_request", + "esp32ulp-elf": "on_request", + # the compiler toolchain and ROM ELFs stay required + "xtensa-esp-elf": "always", + "esp-rom-elfs": "always", + } def test_patch_tools_json_unexpected_structure_warns_and_skips( @@ -985,16 +994,24 @@ def test_patch_tools_json_unexpected_structure_warns_and_skips( tools_json = tools_dir / "tools.json" tools_json.write_text('["not", "a", "dict"]', encoding="utf-8") before = tools_json.read_text(encoding="utf-8") - _patch_tools_json_demote_openocd(tmp_path) # AttributeError -> skip + _patch_tools_json_demote_unused_tools(tmp_path) # AttributeError -> skip assert tools_json.read_text(encoding="utf-8") == before -def test_demote_openocd_already_patched_is_noop(tmp_path: Path) -> None: +def test_demote_unused_tools_already_patched_is_noop(tmp_path: Path) -> None: tools_json = _write_tools_json( - tmp_path, {"tools": [{"name": "openocd-esp32", "install": "on_request"}]} + tmp_path, + { + "tools": [ + {"name": "openocd-esp32", "install": "on_request"}, + {"name": "xtensa-esp-elf-gdb", "install": "on_request"}, + {"name": "riscv32-esp-elf-gdb", "install": "on_request"}, + {"name": "esp32ulp-elf", "install": "on_request"}, + ] + }, ) before = tools_json.read_text(encoding="utf-8") - _patch_tools_json_demote_openocd(tmp_path) + _patch_tools_json_demote_unused_tools(tmp_path) assert tools_json.read_text(encoding="utf-8") == before From 98f2a0b4a440b160e99c5526b353610062c88232 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 26 Jul 2026 00:57:35 -0700 Subject: [PATCH 1070/1815] [modbus] Typed send helpers; migrate in-tree callers off send() (#17859) --- .../growatt_solar/growatt_solar.cpp | 3 +- .../havells_solar/havells_solar.cpp | 3 +- esphome/components/kuntze/kuntze.cpp | 3 +- esphome/components/modbus/modbus.cpp | 21 +++++ esphome/components/modbus/modbus.h | 39 +++++++++- .../modbus_controller/modbus_controller.cpp | 5 +- esphome/components/pzemac/pzemac.cpp | 3 +- esphome/components/pzemdc/pzemdc.cpp | 3 +- esphome/components/sdm_meter/sdm_meter.cpp | 3 +- .../components/selec_meter/selec_meter.cpp | 3 +- .../modbus/modbus_client_hub_test.cpp | 77 +++++++++++++++++++ 11 files changed, 144 insertions(+), 19 deletions(-) diff --git a/esphome/components/growatt_solar/growatt_solar.cpp b/esphome/components/growatt_solar/growatt_solar.cpp index 6485e90c25..d2102496a2 100644 --- a/esphome/components/growatt_solar/growatt_solar.cpp +++ b/esphome/components/growatt_solar/growatt_solar.cpp @@ -7,7 +7,6 @@ namespace esphome::growatt_solar { static const char *const TAG = "growatt_solar"; -static const uint8_t MODBUS_CMD_READ_IN_REGISTERS = 0x04; static const uint8_t MODBUS_REGISTER_COUNT[] = {33, 95}; // indexed with enum GrowattProtocolVersion void GrowattSolar::loop() { @@ -31,7 +30,7 @@ void GrowattSolar::update() { } this->waiting_to_update_ = false; - this->send(MODBUS_CMD_READ_IN_REGISTERS, 0, MODBUS_REGISTER_COUNT[this->protocol_version_]); + this->read_input_registers(0, MODBUS_REGISTER_COUNT[this->protocol_version_]); this->last_send_ = millis(); } diff --git a/esphome/components/havells_solar/havells_solar.cpp b/esphome/components/havells_solar/havells_solar.cpp index 45e57544db..6af72c352b 100644 --- a/esphome/components/havells_solar/havells_solar.cpp +++ b/esphome/components/havells_solar/havells_solar.cpp @@ -7,7 +7,6 @@ namespace esphome::havells_solar { static const char *const TAG = "havells_solar"; -static const uint8_t MODBUS_CMD_READ_IN_REGISTERS = 0x03; static const uint8_t MODBUS_REGISTER_COUNT = 48; // 48 x 16-bit registers void HavellsSolar::on_response(std::span request_pdu, std::span response_pdu) { @@ -122,7 +121,7 @@ void HavellsSolar::on_response(std::span request_pdu, std::spandci_of_t_sensor_->publish_state(dci_of_t); } -void HavellsSolar::update() { this->send(MODBUS_CMD_READ_IN_REGISTERS, 0, MODBUS_REGISTER_COUNT); } +void HavellsSolar::update() { this->read_holding_registers(0, MODBUS_REGISTER_COUNT); } void HavellsSolar::dump_config() { ESP_LOGCONFIG(TAG, "HAVELLS Solar:\n" diff --git a/esphome/components/kuntze/kuntze.cpp b/esphome/components/kuntze/kuntze.cpp index 1475ca61ae..c47a80777c 100644 --- a/esphome/components/kuntze/kuntze.cpp +++ b/esphome/components/kuntze/kuntze.cpp @@ -7,7 +7,6 @@ namespace esphome::kuntze { static const char *const TAG = "kuntze"; -static const uint8_t CMD_READ_REG = 0x03; static const uint16_t REGISTER[] = {4136, 4160, 4680, 6000, 4688, 4728, 5832}; // Maximum bytes to log for Modbus responses (2 registers = 4, plus count = 5) @@ -77,7 +76,7 @@ void Kuntze::loop() { if (this->waiting_ || (this->state_ == 0)) return; this->last_send_ = now; - send(CMD_READ_REG, REGISTER[this->state_ - 1], 2); + this->read_holding_registers(REGISTER[this->state_ - 1], 2); this->waiting_ = true; } diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 3f8aef433f..a507e7e513 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -718,4 +718,25 @@ void Modbus::clear_rx_buffer_(const LogString *reason, bool warn, size_t bytes_t } } +void ModbusClientDevice::read_entities(EntityType entity_type, uint16_t start_address, uint16_t number_of_entities) { + switch (entity_type) { + case EntityType::HOLDING: + this->read_holding_registers(start_address, number_of_entities); + return; + case EntityType::INPUT_REGISTER: + this->read_input_registers(start_address, number_of_entities); + return; + case EntityType::COIL: + this->read_coils(start_address, number_of_entities); + return; + case EntityType::DISCRETE_INPUT: + this->read_discrete_inputs(start_address, number_of_entities); + return; + default: + ESP_LOGW(TAG, "Invalid entity type for read_entities: %d", (int) entity_type); + this->on_not_sent(); // every rejected send is signalled, like send_pdu()'s own refusals + return; + } +} + } // namespace esphome::modbus diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 2e2c027dd0..6afb8584aa 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -206,9 +206,8 @@ class ModbusClientDevice { this->on_modbus_not_sent(); #pragma GCC diagnostic pop } - /// Called when no (valid) response arrived; return true to have the hub re-queue the frame for a retry. - /// The hub does not bound retries: the device is responsible for limiting them (e.g. track a counter and - /// return false when exhausted), or an unresponsive peer will starve other traffic on the bus. + /// Called when no matching, uninterrupted response arrived; return true to have the hub re-queue the frame for a + /// retry. The hub does not bound retries: the device is responsible for limiting them. virtual bool on_no_response() { #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wdeprecated-declarations" @@ -221,6 +220,7 @@ class ModbusClientDevice { // Remove before 2027.2.0 ESPDEPRECATED("Override on_no_response() instead. Removed in 2027.2.0", "2026.8.0") virtual bool on_modbus_no_response() { return false; } + ESPDEPRECATED("Use the typed read_*/write_* helpers or send_pdu() instead. Removed in 2027.2.0", "2026.8.0") void send(uint8_t function, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0, const uint8_t *payload = nullptr) { this->parent_->send_pdu( @@ -237,6 +237,39 @@ class ModbusClientDevice { } this->parent_->send_pdu(payload[0], std::span(payload).subspan(1), this); } + // Dispatches to the matching read_* method; defined in modbus.cpp because it logs on an invalid type. + void read_entities(EntityType entity_type, uint16_t start_address, uint16_t number_of_entities); + void read_input_registers(uint16_t start_address, uint16_t number_of_registers) { + this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_INPUT_REGISTERS, start_address, number_of_registers)); + } + void read_holding_registers(uint16_t start_address, uint16_t number_of_registers) { + this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_HOLDING_REGISTERS, start_address, number_of_registers)); + } + void read_coils(uint16_t start_address, uint16_t number_of_coils) { + this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_COILS, start_address, number_of_coils)); + } + void read_discrete_inputs(uint16_t start_address, uint16_t number_of_inputs) { + this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_DISCRETE_INPUTS, start_address, number_of_inputs)); + } + void write_single_register(uint16_t start_address, uint16_t value) { + this->send_pdu(helpers::create_write_single_register_pdu(start_address, value)); + } + void write_single_coil(uint16_t address, bool value) { + this->send_pdu(helpers::create_write_single_coil_pdu(address, value)); + } + void write_multiple_registers(uint16_t start_address, std::span values) { + this->send_pdu(helpers::create_write_registers_pdu(start_address, values)); + } + /// Note: std::vector cannot bind to std::span; use a contiguous bool container or the packed + /// overload. + void write_multiple_coils(uint16_t start_address, std::span values) { + this->send_pdu(helpers::create_write_coils_pdu(start_address, values)); + } + /// Packed variant: a PackedBits view (the same layout on_read_coils() delivers), so + /// read-modify-write needs no unpack/repack. + void write_multiple_coils(uint16_t start_address, PackedBits bits) { + this->send_pdu(helpers::create_write_coils_pdu(start_address, bits)); + } inline void clear_tx_queue_for_address(bool clear_sent = true) { this->parent_->clear_tx_queue_for_address(this->address_, clear_sent); } diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 8a81acab3a..8822b7b40a 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -523,8 +523,9 @@ ModbusCommandItem ModbusCommandItem::create_custom_command( bool ModbusCommandItem::send() { if (this->function_code != FunctionCode::CUSTOM) { - modbusdevice->send(uint8_t(this->function_code), this->register_address, this->register_count, this->payload.size(), - this->payload.empty() ? nullptr : &this->payload[0]); + modbusdevice->send_pdu( + modbus::helpers::create_client_pdu(this->function_code, this->register_address, this->register_count, + this->payload.empty() ? nullptr : &this->payload[0], this->payload.size())); } else { modbusdevice->send_raw(this->payload); } diff --git a/esphome/components/pzemac/pzemac.cpp b/esphome/components/pzemac/pzemac.cpp index 233ad6fc53..5651e07af0 100644 --- a/esphome/components/pzemac/pzemac.cpp +++ b/esphome/components/pzemac/pzemac.cpp @@ -5,7 +5,6 @@ namespace esphome::pzemac { static const char *const TAG = "pzemac"; -static const uint8_t PZEM_CMD_READ_IN_REGISTERS = 0x04; static const uint8_t PZEM_CMD_RESET_ENERGY = 0x42; static const uint8_t PZEM_REGISTER_COUNT = 10; // 10x 16-bit registers @@ -62,7 +61,7 @@ void PZEMAC::on_response(std::span request_pdu, std::spanpower_factor_sensor_->publish_state(power_factor); } -void PZEMAC::update() { this->send(PZEM_CMD_READ_IN_REGISTERS, 0, PZEM_REGISTER_COUNT); } +void PZEMAC::update() { this->read_input_registers(0, PZEM_REGISTER_COUNT); } void PZEMAC::dump_config() { ESP_LOGCONFIG(TAG, "PZEMAC:\n" diff --git a/esphome/components/pzemdc/pzemdc.cpp b/esphome/components/pzemdc/pzemdc.cpp index 9de72d51f9..5e505cde0c 100644 --- a/esphome/components/pzemdc/pzemdc.cpp +++ b/esphome/components/pzemdc/pzemdc.cpp @@ -5,7 +5,6 @@ namespace esphome::pzemdc { static const char *const TAG = "pzemdc"; -static const uint8_t PZEM_CMD_READ_IN_REGISTERS = 0x04; static const uint8_t PZEM_CMD_RESET_ENERGY = 0x42; static const uint8_t PZEM_REGISTER_COUNT = 10; // 10x 16-bit registers @@ -52,7 +51,7 @@ void PZEMDC::on_response(std::span request_pdu, std::spanenergy_sensor_->publish_state(energy); } -void PZEMDC::update() { this->send(PZEM_CMD_READ_IN_REGISTERS, 0, 8); } +void PZEMDC::update() { this->read_input_registers(0, 8); } void PZEMDC::dump_config() { ESP_LOGCONFIG(TAG, "PZEMDC:\n" diff --git a/esphome/components/sdm_meter/sdm_meter.cpp b/esphome/components/sdm_meter/sdm_meter.cpp index 989f22dd2a..1ebc7fa3d8 100644 --- a/esphome/components/sdm_meter/sdm_meter.cpp +++ b/esphome/components/sdm_meter/sdm_meter.cpp @@ -7,7 +7,6 @@ namespace esphome::sdm_meter { static const char *const TAG = "sdm_meter"; -static const uint8_t MODBUS_CMD_READ_IN_REGISTERS = 0x04; static const uint8_t MODBUS_REGISTER_COUNT = 80; // 74 x 16-bit registers void SDMMeter::on_response(std::span request_pdu, std::span response_pdu) { @@ -83,7 +82,7 @@ void SDMMeter::on_response(std::span request_pdu, std::spanexport_reactive_energy_sensor_->publish_state(export_reactive_energy); } -void SDMMeter::update() { this->send(MODBUS_CMD_READ_IN_REGISTERS, 0, MODBUS_REGISTER_COUNT); } +void SDMMeter::update() { this->read_input_registers(0, MODBUS_REGISTER_COUNT); } void SDMMeter::dump_config() { ESP_LOGCONFIG(TAG, "SDM Meter:\n" diff --git a/esphome/components/selec_meter/selec_meter.cpp b/esphome/components/selec_meter/selec_meter.cpp index f5f0fdf40d..688923d8e6 100644 --- a/esphome/components/selec_meter/selec_meter.cpp +++ b/esphome/components/selec_meter/selec_meter.cpp @@ -7,7 +7,6 @@ namespace esphome::selec_meter { static const char *const TAG = "selec_meter"; -static const uint8_t MODBUS_CMD_READ_IN_REGISTERS = 0x04; static const uint8_t MODBUS_REGISTER_COUNT = 34; // 34 x 16-bit registers void SelecMeter::on_response(std::span request_pdu, std::span response_pdu) { @@ -82,7 +81,7 @@ void SelecMeter::on_response(std::span request_pdu, std::spanmaximum_demand_apparent_power_sensor_->publish_state(maximum_demand_apparent_power); } -void SelecMeter::update() { this->send(MODBUS_CMD_READ_IN_REGISTERS, 0, MODBUS_REGISTER_COUNT); } +void SelecMeter::update() { this->read_input_registers(0, MODBUS_REGISTER_COUNT); } void SelecMeter::dump_config() { ESP_LOGCONFIG(TAG, "SELEC Meter:\n" diff --git a/tests/components/modbus/modbus_client_hub_test.cpp b/tests/components/modbus/modbus_client_hub_test.cpp index 7bdc4ac35b..6335a75775 100644 --- a/tests/components/modbus/modbus_client_hub_test.cpp +++ b/tests/components/modbus/modbus_client_hub_test.cpp @@ -272,4 +272,81 @@ TEST(ModbusDeviceShim, LegacyCallbacksReceiveTheOldShapes) { EXPECT_EQ(device.last_error_code_, 0x02); } +// --- typed send helpers -------------------------------------------------------------------------- +// Each helper is a one-line forward onto a merged builder; these pin the function code and wire +// bytes each one queues, so a swapped code or transposed field cannot survive review silently. +TEST(ModbusTypedSendHelpers, HelpersQueueExpectedPdus) { + NoResponseProbeHub hub; + ModbusClientDevice device(&hub, 0x02); + auto check = [&](const std::vector &expected) { + ASSERT_EQ(hub.queued_frames(), 1u); + auto pdu = hub.front().frame.pdu(); + EXPECT_EQ(std::vector(pdu.begin(), pdu.end()), expected); + hub.force_send_front(); + hub.timeout_waiting(); // default on_no_response() declines the retry, dropping the frame + }; + + device.read_holding_registers(0x0102, 3); + check({0x03, 0x01, 0x02, 0x00, 0x03}); + device.read_input_registers(0x0010, 2); + check({0x04, 0x00, 0x10, 0x00, 0x02}); + device.read_coils(0x0020, 10); + check({0x01, 0x00, 0x20, 0x00, 0x0A}); + device.read_discrete_inputs(0x0030, 1); + check({0x02, 0x00, 0x30, 0x00, 0x01}); + device.write_single_register(0x0040, 0xABCD); + check({0x06, 0x00, 0x40, 0xAB, 0xCD}); + device.write_single_coil(0x0041, true); + check({0x05, 0x00, 0x41, 0xFF, 0x00}); + device.write_single_coil(0x0041, false); + check({0x05, 0x00, 0x41, 0x00, 0x00}); + const uint16_t regs[] = {0x000B, 0x0016}; + device.write_multiple_registers(0x0050, regs); + check({0x10, 0x00, 0x50, 0x00, 0x02, 0x04, 0x00, 0x0B, 0x00, 0x16}); + const bool coils[] = {true, false, true}; + device.write_multiple_coils(0x0060, coils); + check({0x0F, 0x00, 0x60, 0x00, 0x03, 0x01, 0x05}); + const uint8_t packed[] = {0x05}; + device.write_multiple_coils(0x0060, PackedBits(packed, 3)); // packed overload, same wire bytes + check({0x0F, 0x00, 0x60, 0x00, 0x03, 0x01, 0x05}); +} + +TEST(ModbusTypedSendHelpers, ReadEntitiesDispatchesByTypeAndRejectsInvalid) { + NoResponseProbeHub hub; + ModbusClientDevice device(&hub, 0x02); + + device.read_entities(EntityType::HOLDING, 0x0001, 1); + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_EQ(hub.front().frame.pdu()[0], 0x03); + hub.force_send_front(); + hub.timeout_waiting(); + + device.read_entities(EntityType::DISCRETE_INPUT, 0x0001, 1); + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_EQ(hub.front().frame.pdu()[0], 0x02); + hub.force_send_front(); + hub.timeout_waiting(); + + device.read_entities(EntityType::CUSTOM, 0x0001, 1); // no read function: logged and not queued + EXPECT_EQ(hub.queued_frames(), 0u); +} + +// A rejected read_entities() signals on_not_sent() like every other refused send. +namespace { +class NotSentCountingDevice : public ModbusClientDevice { + public: + NotSentCountingDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_not_sent() override { this->not_sent_++; } + int not_sent_{0}; +}; +} // namespace + +TEST(ModbusTypedSendHelpers, InvalidReadEntitiesSignalsNotSent) { + NoResponseProbeHub hub; + NotSentCountingDevice device(&hub, 0x02); + device.read_entities(EntityType::CUSTOM, 0x0001, 1); + EXPECT_EQ(device.not_sent_, 1); + EXPECT_EQ(hub.queued_frames(), 0u); +} + } // namespace esphome::modbus::testing From cf17749307655ada438b53b77e453d85365b25fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Sun, 26 Jul 2026 13:49:29 +0300 Subject: [PATCH 1071/1815] [ble_device_base] Shared address-type string and discovered-device log (#17861) --- .../components/ble_device_base/ble_device.cpp | 94 +++++++++++++------ .../components/ble_device_base/ble_device.h | 21 +++++ .../esp32_ble_tracker/esp32_ble_tracker.cpp | 43 +-------- .../esp32_ble_tracker/esp32_ble_tracker.h | 4 +- 4 files changed, 92 insertions(+), 70 deletions(-) diff --git a/esphome/components/ble_device_base/ble_device.cpp b/esphome/components/ble_device_base/ble_device.cpp index c025af4d28..9c3e1d4397 100644 --- a/esphome/components/ble_device_base/ble_device.cpp +++ b/esphome/components/ble_device_base/ble_device.cpp @@ -264,6 +264,21 @@ optional ESPBLEiBeacon::from_manufacturer_data(const ServiceData // ESPBTDevice // --------------------------------------------------------------------------- +const char *ESPBTDevice::address_type_str() const { + switch (this->address_type_) { + case BLE_ADDR_TYPE_PUBLIC: + return "PUBLIC"; + case BLE_ADDR_TYPE_RANDOM: + return "RANDOM"; + case BLE_ADDR_TYPE_RPA_PUBLIC: + return "RPA_PUBLIC"; + case BLE_ADDR_TYPE_RPA_RANDOM: + return "RPA_RANDOM"; + default: + return "UNKNOWN"; + } +} + void ESPBTDevice::from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_type, const uint8_t *data, uint16_t data_len) { // Ingest is BLE controller order (LSB-first); store in printable (MSB-first) @@ -282,29 +297,13 @@ void ESPBTDevice::from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_ty this->parse_adv_(data, data_len); #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE - ESP_LOGVV(TAG, "Parse Result:"); - const char *address_type; - switch (this->address_type_) { - case BLE_ADDR_TYPE_PUBLIC: - address_type = "PUBLIC"; - break; - case BLE_ADDR_TYPE_RANDOM: - address_type = "RANDOM"; - break; - case BLE_ADDR_TYPE_RPA_PUBLIC: - address_type = "RPA_PUBLIC"; - break; - case BLE_ADDR_TYPE_RPA_RANDOM: - address_type = "RPA_RANDOM"; - break; - default: - address_type = "UNKNOWN"; - break; - } char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - ESP_LOGVV(TAG, " Address: %s (%s)", this->address_str_to(addr_buf), address_type); - ESP_LOGVV(TAG, " RSSI: %d", this->rssi_); - ESP_LOGVV(TAG, " Name: '%s'", this->name_.c_str()); + ESP_LOGVV(TAG, + "Parse Result:\n" + " Address: %s (%s)\n" + " RSSI: %d\n" + " Name: '%s'", + this->address_str_to(addr_buf), this->address_type_str(), this->rssi_, this->name_.c_str()); for (auto &it : this->tx_powers_) { ESP_LOGVV(TAG, " TX Power: %d", it); } @@ -322,20 +321,26 @@ void ESPBTDevice::from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_ty for (auto &mfg_data : this->manufacturer_datas_) { auto ibeacon = ESPBLEiBeacon::from_manufacturer_data(mfg_data); if (ibeacon.has_value()) { - ESP_LOGVV(TAG, " Manufacturer iBeacon:"); - ESP_LOGVV(TAG, " UUID: %s", ibeacon.value().get_uuid().to_str(uuid_buf)); - ESP_LOGVV(TAG, " Major: %u", ibeacon.value().get_major()); - ESP_LOGVV(TAG, " Minor: %u", ibeacon.value().get_minor()); - ESP_LOGVV(TAG, " TXPower: %d", ibeacon.value().get_signal_power()); + ESP_LOGVV(TAG, + " Manufacturer iBeacon:\n" + " UUID: %s\n" + " Major: %u\n" + " Minor: %u\n" + " TXPower: %d", + ibeacon.value().get_uuid().to_str(uuid_buf), ibeacon.value().get_major(), ibeacon.value().get_minor(), + ibeacon.value().get_signal_power()); } else { ESP_LOGVV(TAG, " Manufacturer ID: %s, data: %s", mfg_data.uuid.to_str(uuid_buf), format_hex_pretty_to(hex_buf, mfg_data.data.data(), mfg_data.data.size())); } } for (auto &svc_data : this->service_datas_) { - ESP_LOGVV(TAG, " Service data:"); - ESP_LOGVV(TAG, " UUID: %s", svc_data.uuid.to_str(uuid_buf)); - ESP_LOGVV(TAG, " Data: %s", format_hex_pretty_to(hex_buf, svc_data.data.data(), svc_data.data.size())); + ESP_LOGVV(TAG, + " Service data:\n" + " UUID: %s\n" + " Data: %s", + svc_data.uuid.to_str(uuid_buf), + format_hex_pretty_to(hex_buf, svc_data.data.data(), svc_data.data.size())); } ESP_LOGVV(TAG, " Adv data: %s", format_hex_pretty_to(hex_buf, data, data_len)); #endif // ESPHOME_LOG_HAS_VERY_VERBOSE @@ -493,4 +498,33 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint16_t len) { } } +// --------------------------------------------------------------------------- +// DiscoveredDeviceLog +// --------------------------------------------------------------------------- + +void DiscoveredDeviceLog::log_device(const char *tag, const ESPBTDevice &device) { +#ifdef ESPHOME_LOG_HAS_DEBUG + // Everything here feeds ESP_LOGD: below DEBUG the whole body (including the + // dedup vector growth) would be pure overhead, so compile it out entirely. + const uint64_t address = device.address_uint64(); + for (auto &disc : this->already_discovered_) { + if (disc == address) + return; + } + this->already_discovered_.push_back(address); + + char addr_buf[ESPBTDevice::MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGD(tag, + "Found device %s RSSI=%d\n" + " Address Type: %s", + device.address_str_to(addr_buf), device.get_rssi(), device.address_type_str()); + if (!device.get_name().empty()) { + ESP_LOGD(tag, " Name: '%s'", device.get_name().c_str()); + } + for (auto &tx_power : device.get_tx_powers()) { + ESP_LOGD(tag, " TX Power: %d", tx_power); + } +#endif // ESPHOME_LOG_HAS_DEBUG +} + } // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/ble_device.h b/esphome/components/ble_device_base/ble_device.h index 6b52a8f842..94678091cc 100644 --- a/esphome/components/ble_device_base/ble_device.h +++ b/esphome/components/ble_device_base/ble_device.h @@ -181,6 +181,9 @@ class ESPBTDevice { #else uint8_t get_address_type() const { return this->address_type_; } #endif + /// Human-readable address type ("PUBLIC", "RANDOM", "RPA_PUBLIC", "RPA_RANDOM" or + /// "UNKNOWN"), backed by the shared BLE_ADDR_TYPE_* constants above. + const char *address_type_str() const; int get_rssi() const { return rssi_; } const std::string &get_name() const { return name_; } @@ -224,6 +227,24 @@ class ESPBTDevice { optional ad_flag_{}; }; +// --------------------------------------------------------------------------- +// DiscoveredDeviceLog — shared per-scan-period "Found device" DEBUG logger +// --------------------------------------------------------------------------- + +/// Per-scan-period "Found device" DEBUG logger, deduplicated by MAC address. +/// Shared by all tracker backends so the output format and dedup behaviour stay +/// identical by construction (single implementation instead of per-chip copies). +class DiscoveredDeviceLog { + public: + /// Log the device at DEBUG the first time its MAC is seen this scan period. + void log_device(const char *tag, const ESPBTDevice &device); + /// Reset the per-period dedup list (call when a scan period ends). + void clear() { this->already_discovered_.clear(); } + + protected: + std::vector already_discovered_; +}; + // --------------------------------------------------------------------------- // ESPBTDeviceListener — base class for BLE consumers (sensors, proxy, triggers) // --------------------------------------------------------------------------- diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 141aa6729d..0c1a98c4be 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -258,7 +258,7 @@ void ESP32BLETracker::start_scan_(bool first) { #endif } #ifdef USE_ESP32_BLE_DEVICE - this->already_discovered_.clear(); + this->discovered_log_.clear(); #endif this->scan_params_.scan_type = this->scan_active_ ? BLE_SCAN_TYPE_ACTIVE : BLE_SCAN_TYPE_PASSIVE; this->scan_params_.own_addr_type = BLE_ADDR_TYPE_PUBLIC; @@ -471,42 +471,9 @@ void ESP32BLETracker::dump_config() { #ifdef USE_ESP32_BLE_DEVICE void ESP32BLETracker::print_bt_device_info(const ESPBTDevice &device) { - const uint64_t address = device.address_uint64(); - for (auto &disc : this->already_discovered_) { - if (disc == address) - return; - } - this->already_discovered_.push_back(address); - - char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - ESP_LOGD(TAG, "Found device %s RSSI=%d", device.address_str_to(addr_buf), device.get_rssi()); - - const char *address_type_s; - switch (device.get_address_type()) { - case BLE_ADDR_TYPE_PUBLIC: - address_type_s = "PUBLIC"; - break; - case BLE_ADDR_TYPE_RANDOM: - address_type_s = "RANDOM"; - break; - case BLE_ADDR_TYPE_RPA_PUBLIC: - address_type_s = "RPA_PUBLIC"; - break; - case BLE_ADDR_TYPE_RPA_RANDOM: - address_type_s = "RPA_RANDOM"; - break; - default: - address_type_s = "UNKNOWN"; - break; - } - - ESP_LOGD(TAG, " Address Type: %s", address_type_s); - if (!device.get_name().empty()) { - ESP_LOGD(TAG, " Name: '%s'", device.get_name().c_str()); - } - for (auto &tx_power : device.get_tx_powers()) { - ESP_LOGD(TAG, " TX Power: %d", tx_power); - } + // Shared implementation in ble_device_base — identical output and per-period + // MAC dedup on every tracker backend. + this->discovered_log_.log_device(TAG, device); } // resolve_irk() is provided by ble_device_base (portable software AES). @@ -566,7 +533,7 @@ void ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) { void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) { ESP_LOGV(TAG, "Scan %scomplete, set scanner state to IDLE.", is_stop_complete ? "stop " : ""); #ifdef USE_ESP32_BLE_DEVICE - this->already_discovered_.clear(); + this->discovered_log_.clear(); #endif // Reset timeout state machine instead of cancelling scheduler timeout this->scan_timeout_state_ = ScanTimeoutState::INACTIVE; diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index c20962eb25..01ae22e710 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -343,8 +343,8 @@ class ESP32BLETracker final : public Component, #endif ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{nullptr}; #ifdef USE_ESP32_BLE_DEVICE - /// Vector of addresses that have already been printed in print_bt_device_info - std::vector already_discovered_; + /// Per-period "Found device" DEBUG log with MAC dedup (shared ble_device_base impl) + ble_device_base::DiscoveredDeviceLog discovered_log_; #endif // Group 2: Structs (aligned to 4 bytes) From 741fbbff5ac3c26116435dbad78fe0f02cf2b829 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:49:57 -0400 Subject: [PATCH 1072/1815] [dallas_temp][ds2484] Fix one_wire test configs for grouped CI batches (#17868) --- tests/components/dallas_temp/common.yaml | 4 ++++ tests/components/ds2484/common.yaml | 11 ++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/components/dallas_temp/common.yaml b/tests/components/dallas_temp/common.yaml index abd8e0cfa3..7f03ffa326 100644 --- a/tests/components/dallas_temp/common.yaml +++ b/tests/components/dallas_temp/common.yaml @@ -1,14 +1,18 @@ one_wire: - platform: gpio + id: ow_dallas_temp pin: ${one_wire_pin} sensor: - platform: dallas_temp + one_wire_id: ow_dallas_temp address: 0x1C0000031EDD2A28 name: Dallas Temperature 1 resolution: 9 - platform: dallas_temp + one_wire_id: ow_dallas_temp name: Dallas Temperature 2 - platform: dallas_temp + one_wire_id: ow_dallas_temp name: Dallas Temperature 3 index: 2 diff --git a/tests/components/ds2484/common.yaml b/tests/components/ds2484/common.yaml index 1e5dcd7dba..b6abc5bd76 100644 --- a/tests/components/ds2484/common.yaml +++ b/tests/components/ds2484/common.yaml @@ -1,6 +1,7 @@ one_wire: - platform: ds2484 - i2c_id: i2c_bus - address: 0x18 - active_pullup: true - strong_pullup: false + - platform: ds2484 + id: ow_ds2484 + i2c_id: i2c_bus + address: 0x18 + active_pullup: true + strong_pullup: false From ed066cf0fe7d12dbdbddeda9446df698161d2e8d Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Sun, 26 Jul 2026 23:34:29 +0100 Subject: [PATCH 1073/1815] [espidf] Include .cc, .cxx and .c++ sources in the app source glob (#17754) --- esphome/build_gen/espidf.py | 12 ++++++++++++ tests/unit_tests/build_gen/test_espidf.py | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index cc2fc5c4cd..cf476555e7 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -210,15 +210,27 @@ def get_component_cmakelists() -> str: if(CMAKE_SCRIPT_MODE_FILE) file(GLOB_RECURSE app_sources "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp" + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cc" + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cxx" + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c++" "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c" "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cc" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cxx" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c++" "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c" ) else() file(GLOB_RECURSE app_sources CONFIGURE_DEPENDS "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp" + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cc" + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cxx" + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c++" "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c" "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cc" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cxx" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c++" "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c" ) endif() diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index bcd9fa655a..f21549b48c 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -184,6 +184,18 @@ def test_get_component_cmakelists_compile_flags_excluded_from_link_opts() -> Non assert "-Wl,--gc-sections" in content +def test_get_component_cmakelists_globs_alternate_cpp_extensions() -> None: + """Both app_sources glob variants include .cc/.cxx/.c++ so vendored sources + are compiled, matching the extensions PlatformIO's builder globs by default.""" + CORE.build_flags = set() + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + for ext in ("cc", "cxx", "c++"): + assert content.count(f'"${{CMAKE_CURRENT_SOURCE_DIR}}/*.{ext}"') == 2 + assert content.count(f'"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.{ext}"') == 2 + + def test_get_project_cmakelists_emits_managed_components_property( tmp_path: Path, ) -> None: From 8a7d2d0ca036f9a78a066136a6babc9674d75229 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:01:15 -0400 Subject: [PATCH 1074/1815] [ci] Enforce list form for platform domains in test fixtures (#17869) --- .github/workflows/ci.yml | 1 + script/ci_check_test_fixture_list_form.py | 103 ++++++++++++++++++ tests/components/esp32/test.esp32-p4-idf.yaml | 2 +- tests/components/espnow/common.yaml | 1 + tests/components/packet_transport/common.yaml | 36 +++--- .../packet_transport/test.host.yaml | 36 +++--- tests/components/syslog/test.host.yaml | 2 +- 7 files changed, 147 insertions(+), 34 deletions(-) create mode 100755 script/ci_check_test_fixture_list_form.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72ece5b4fd..b7e5f31cd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,6 +117,7 @@ jobs: script/generate-esp32-boards.py --check script/generate-rp2-boards.py --check script/ci_check_duplicate_test_ids.py + script/ci_check_test_fixture_list_form.py import-time: name: Check import esphome.__main__ time diff --git a/script/ci_check_test_fixture_list_form.py b/script/ci_check_test_fixture_list_form.py new file mode 100755 index 0000000000..6da1f8337d --- /dev/null +++ b/script/ci_check_test_fixture_list_form.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Fail when a test fixture writes a platform-list domain as a single dict. + +Component tests are merged and built in groups in CI (see +``script/merge_component_configs.py``). ESPHome's ``merge_config`` concatenates +two lists, but when one side is a dict it replaces the other side wholesale +(``esphome/config_helpers.py``). A domain such as ``one_wire:`` or ``ota:`` +written in single-dict form therefore deletes every entry other components +contributed to that domain before it in the merge, and is itself deleted by any +list that merges after it. The resulting failure only appears when the affected +components land in the same group -- usually a full component matrix run on an +unrelated PR long after the fixture was written (this is what broke the +dallas_temp tests when ds2484 was added, see #17868). + +This guard scans every fixture under ``tests/components/`` and rejects any +top-level domain written as a dict with a ``platform`` key. Such a domain is by +definition a platform list (single-dict form is only user-config sugar), so the +fix is always to write it as a one-element list: + + one_wire: + - platform: gpio + pin: 4 +""" + +from __future__ import annotations + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from esphome.core import EsphomeError # noqa: E402 +from script.analyze_component_buses import ISOLATED_COMPONENTS # noqa: E402 +from script.merge_component_configs import load_yaml_file # noqa: E402 + +# Resolved relative to this file (not the CWD) so the scan cannot silently cover +# nothing when run from a different directory. +ROOT_DIR = Path(__file__).resolve().parent.parent +TESTS_DIR = ROOT_DIR / "tests" / "components" + + +def main() -> int: + offenders: list[str] = [] + parse_errors: list[str] = [] + fixtures_scanned = 0 + + for fixture in sorted(TESTS_DIR.glob("*/*.yaml")): + # Isolated components are never merged with others, so dict form + # cannot clobber anyone there. + if fixture.parent.name in ISOLATED_COMPONENTS: + continue + try: + data = load_yaml_file(fixture) + except EsphomeError as err: + parse_errors.append(f"{fixture.relative_to(ROOT_DIR)}: {err}") + continue + fixtures_scanned += 1 + if not isinstance(data, dict): + continue + for key, value in data.items(): + if isinstance(value, dict) and "platform" in value: + offenders.append(f"{fixture.relative_to(ROOT_DIR)}: '{key}:'") + + if offenders: + print("Test fixtures with platform domains in single-dict form:\n") + for line in offenders: + print(f" - {line}") + print( + "\nWrite the domain as a one-element list ('- platform: ...') so " + "grouped CI builds can merge it with other components' entries; " + "in dict form it replaces or is replaced by their lists wholesale." + ) + + if parse_errors: + # A fixture we could not parse was never scanned, so the run is not a + # clean pass even if no offenders were found among the rest. + print( + f"\n{len(parse_errors)} test fixture(s) could not be parsed and " + "were not checked:" + ) + for line in parse_errors: + print(f" - {line}") + + if fixtures_scanned == 0: + # A scan that covered nothing is a false green -- the whole point of the + # guard is defeated. Fail loudly (wrong working directory or layout change). + print( + f"\nERROR: scanned 0 test fixtures under {TESTS_DIR}; " + "the guard covered nothing.", + file=sys.stderr, + ) + + if offenders or parse_errors or fixtures_scanned == 0: + return 1 + + print( + f"No single-dict platform domains found ({fixtures_scanned} fixtures scanned)." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/components/esp32/test.esp32-p4-idf.yaml b/tests/components/esp32/test.esp32-p4-idf.yaml index fd42fac5a3..c16869d06b 100644 --- a/tests/components/esp32/test.esp32-p4-idf.yaml +++ b/tests/components/esp32/test.esp32-p4-idf.yaml @@ -21,7 +21,7 @@ esp32: disable_fatfs: true ota: - platform: esphome + - platform: esphome wifi: ssid: MySSID diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml index ae43baa41a..2f82e794c4 100644 --- a/tests/components/espnow/common.yaml +++ b/tests/components/espnow/common.yaml @@ -74,6 +74,7 @@ sensor: id: espnow_temp_sensor - platform: packet_transport + transport_id: transport1 provider: test-provider remote_id: espnow_temp_sensor id: remote_temp diff --git a/tests/components/packet_transport/common.yaml b/tests/components/packet_transport/common.yaml index 9151cf27dc..5c6c8dd636 100644 --- a/tests/components/packet_transport/common.yaml +++ b/tests/components/packet_transport/common.yaml @@ -7,36 +7,40 @@ udp: addresses: ["239.0.60.53"] packet_transport: - platform: udp - update_interval: 5s - encryption: "our key goes here" - rolling_code_enable: true - ping_pong_enable: true - binary_sensors: - - binary_sensor_id1 - - id: binary_sensor_id1 - broadcast_id: other_id - sensors: - - sensor_id1 - - id: sensor_id1 - broadcast_id: other_id - providers: - - name: some-device-name - encryption: "their key goes here" + - platform: udp + id: transport_udp + update_interval: 5s + encryption: "our key goes here" + rolling_code_enable: true + ping_pong_enable: true + binary_sensors: + - binary_sensor_id1 + - id: binary_sensor_id1 + broadcast_id: other_id + sensors: + - sensor_id1 + - id: sensor_id1 + broadcast_id: other_id + providers: + - name: some-device-name + encryption: "their key goes here" sensor: - platform: template id: sensor_id1 - platform: packet_transport + transport_id: transport_udp provider: some-device-name id: our_id remote_id: some_sensor_id binary_sensor: - platform: packet_transport + transport_id: transport_udp provider: unencrypted-device id: other_binary_sensor_id - platform: packet_transport + transport_id: transport_udp provider: some-device-name type: status name: Some-Device Status diff --git a/tests/components/packet_transport/test.host.yaml b/tests/components/packet_transport/test.host.yaml index 49fdbbc9b2..f67b561226 100644 --- a/tests/components/packet_transport/test.host.yaml +++ b/tests/components/packet_transport/test.host.yaml @@ -3,36 +3,40 @@ udp: addresses: ["239.0.60.53"] packet_transport: - platform: udp - update_interval: 5s - encryption: "our key goes here" - rolling_code_enable: true - ping_pong_enable: true - binary_sensors: - - binary_sensor_id1 - - id: binary_sensor_id1 - broadcast_id: other_id - sensors: - - sensor_id1 - - id: sensor_id1 - broadcast_id: other_id - providers: - - name: some-device-name - encryption: "their key goes here" + - platform: udp + id: transport_udp + update_interval: 5s + encryption: "our key goes here" + rolling_code_enable: true + ping_pong_enable: true + binary_sensors: + - binary_sensor_id1 + - id: binary_sensor_id1 + broadcast_id: other_id + sensors: + - sensor_id1 + - id: sensor_id1 + broadcast_id: other_id + providers: + - name: some-device-name + encryption: "their key goes here" sensor: - platform: template id: sensor_id1 - platform: packet_transport + transport_id: transport_udp provider: some-device-name id: our_id remote_id: some_sensor_id binary_sensor: - platform: packet_transport + transport_id: transport_udp provider: unencrypted-device id: other_binary_sensor_id - platform: packet_transport + transport_id: transport_udp provider: some-device-name type: status name: Some-Device Status diff --git a/tests/components/syslog/test.host.yaml b/tests/components/syslog/test.host.yaml index 31122437d5..d9aa8e529b 100644 --- a/tests/components/syslog/test.host.yaml +++ b/tests/components/syslog/test.host.yaml @@ -2,7 +2,7 @@ udp: addresses: ["239.0.60.53"] time: - platform: host + - platform: host syslog: port: 514 From b395a87cad672114da65d4b8b9067b4fdf004640 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:13:55 -1000 Subject: [PATCH 1075/1815] Bump bundled esphome-device-builder to 1.7.0 (#17871) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 638bbdbf9e..4a9edb1b64 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.6.10 +RUN uv pip install --no-cache-dir esphome-device-builder==1.7.0 RUN \ platformio settings set enable_telemetry No \ From 67e0058e7290c4a2ca8eb90d35d2371a711d3f2e Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 26 Jul 2026 16:14:54 -0700 Subject: [PATCH 1076/1815] [modbus] Typed client send helpers and response callbacks (#17435) Co-authored-by: Claude Fable 5 --- esphome/components/modbus/modbus.cpp | 159 +++++++- esphome/components/modbus/modbus.h | 86 ++++- esphome/components/modbus/modbus_helpers.cpp | 5 + .../modbus/modbus_client_device_test.cpp | 344 ++++++++++++++++++ .../components/modbus/modbus_helpers_test.cpp | 9 + 5 files changed, 575 insertions(+), 28 deletions(-) create mode 100644 tests/components/modbus/modbus_client_device_test.cpp diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index a507e7e513..92b4dc25ac 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -718,24 +718,149 @@ void Modbus::clear_rx_buffer_(const LogString *reason, bool warn, size_t bytes_t } } -void ModbusClientDevice::read_entities(EntityType entity_type, uint16_t start_address, uint16_t number_of_entities) { - switch (entity_type) { - case EntityType::HOLDING: - this->read_holding_registers(start_address, number_of_entities); - return; - case EntityType::INPUT_REGISTER: - this->read_input_registers(start_address, number_of_entities); - return; - case EntityType::COIL: - this->read_coils(start_address, number_of_entities); - return; - case EntityType::DISCRETE_INPUT: - this->read_discrete_inputs(start_address, number_of_entities); - return; +void ModbusClientDevice::dispatch_response_(std::span request_pdu, std::span response_pdu, + ResponseStatus status) { + if (request_pdu.empty()) + return; + auto function_code = static_cast(request_pdu[0]); + // All standard requests handled below are function code + start address + count/value (5 bytes); + // anything shorter cannot be parsed and is handed to the catch-all. + if (request_pdu.size() < READ_PDU_SIZE) { + this->on_custom_response(request_pdu, response_pdu, status); + return; + } + const uint16_t start_address = helpers::get_data(request_pdu.data(), 1); + // count for reads/multi-writes, value for single writes + const uint16_t count_or_value = helpers::get_data(request_pdu.data(), 3); + + // Gatekeeper for the typed dispatch below: anything that is not a standard-conformant transaction is + // handed to on_custom_response() with the raw PDUs, so the decode cases can trust every length, byte + // count, and quantity field without re-clamping. + // - The REQUEST must be standard: nothing upstream validates a caller-built request PDU, so its + // internal byte count, quantity, and address range are checked here (is_client_pdu_standard()). + // - On success, the RESPONSE must be standard (self-consistent; the frame parser already guarantees + // most of this, but the check keeps the safety proof local), and a read response's length must also + // match the REQUESTED count - the per-PDU checks cannot see that relationship, and a short but + // self-consistent response must be diverted, never silently clamped and delivered as complete. + // - On failure (status engaged) the response is empty by design (see on_error()), so only the request + // is validated. + bool custom = !helpers::is_client_pdu_standard(request_pdu.data(), request_pdu.size()); + if (!custom && !status.has_value()) { + custom = !helpers::is_server_pdu_standard(response_pdu.data(), response_pdu.size()); + if (!custom && helpers::is_function_code_read(static_cast(function_code))) { + const bool bits = + function_code == FunctionCode::READ_COILS || function_code == FunctionCode::READ_DISCRETE_INPUTS; + const size_t expected_data_size = + bits ? (static_cast(count_or_value) + 7) / 8 : static_cast(count_or_value) * 2; + if (response_pdu.size() != expected_data_size + 2) { + ESP_LOGD(TAG, "Response length %zu does not match request (expected %zu) for function code 0x%X", + response_pdu.size(), expected_data_size + 2, static_cast(function_code)); + custom = true; + } + } + } + if (custom) { + this->on_custom_response(request_pdu, response_pdu, status); + return; + } + + switch (function_code) { + case FunctionCode::READ_HOLDING_REGISTERS: + case FunctionCode::READ_INPUT_REGISTERS: { + // Decode the big-endian register words into host byte order. The gate guarantees a success response + // carries exactly count_or_value registers (and count_or_value <= MAX_NUM_OF_REGISTERS_TO_READ, the + // capacity of RegisterValues); a mismatch was diverted to on_custom_response(), never clamped. On + // failure the registers span is empty. + RegisterValues registers; + if (!status.has_value()) { + for (size_t i = 0; i != count_or_value; i++) { + registers.push_back(helpers::get_data(response_pdu.data(), 2 + 2 * i)); + } + } + std::span register_span(registers.data(), registers.size()); + if (function_code == FunctionCode::READ_HOLDING_REGISTERS) { + this->on_read_holding_registers(start_address, register_span, status); + } else { + this->on_read_input_registers(start_address, register_span, status); + } + break; + } + case FunctionCode::READ_COILS: + case FunctionCode::READ_DISCRETE_INPUTS: { + // Deliver the bits packed as on the wire; the gate guarantees a success response carries exactly + // (count_or_value + 7) / 8 data bytes. On failure the view is empty AND the count is zero - + // PackedBits::operator[] is unchecked, so size() must never promise bits with no bytes behind them. + std::span packed_bytes; + uint16_t count = 0; + if (!status.has_value()) { + packed_bytes = response_pdu.subspan(2); + count = count_or_value; + } + PackedBits bits(packed_bytes, count); + if (function_code == FunctionCode::READ_COILS) { + this->on_read_coils(start_address, bits, status); + } else { + this->on_read_discrete_inputs(start_address, bits, status); + } + break; + } + // Single-write acks echo the value: on success that echo is device-confirmed state - the one + // write whose acknowledgement carries a real read-back - so it is preferred over the request + // copy. On an exception the response has no value and the request copy is the only one. + case FunctionCode::WRITE_SINGLE_REGISTER: + case FunctionCode::WRITE_SINGLE_COIL: { + const uint16_t value = (!status.has_value() && response_pdu.size() >= WRITE_SINGLE_PDU_SIZE) + ? helpers::get_data(response_pdu.data(), 3) + : count_or_value; + if (function_code == FunctionCode::WRITE_SINGLE_REGISTER) { + this->on_write_single_register(start_address, value, status); + } else { + this->on_write_single_coil(start_address, value == 0xFF00, status); + } + break; + } + case FunctionCode::WRITE_MULTIPLE_REGISTERS: { + // Request layout: [0] function code, [1..2] start address, [3..4] register count, [5] byte count, + // [6..] register data. The gate guarantees the request carries exactly count_or_value registers + // (<= MAX_NUM_OF_REGISTERS_TO_WRITE, within RegisterValues capacity). Decoded from the request and + // delivered regardless of status - see the write-acknowledgement note in modbus.h. + RegisterValues registers; + for (size_t i = 0; i != count_or_value; i++) { + registers.push_back(helpers::get_data(request_pdu.data(), 6 + 2 * i)); + } + std::span register_span(registers.data(), registers.size()); + this->on_write_multiple_registers(start_address, register_span, status); + break; + } + case FunctionCode::WRITE_MULTIPLE_COILS: { + // Request layout: [0] function code, [1..2] start address, [3..4] coil count, [5] byte count, + // [6..] packed bits. The gate guarantees the request carries exactly (count_or_value + 7) / 8 packed + // bytes. Decoded from the request and delivered regardless of status - see the write-acknowledgement + // note in modbus.h. + std::span packed_bytes = request_pdu.subspan(6); + PackedBits bits(packed_bytes, count_or_value); + this->on_write_multiple_coils(start_address, bits, status); + break; + } default: - ESP_LOGW(TAG, "Invalid entity type for read_entities: %d", (int) entity_type); - this->on_not_sent(); // every rejected send is signalled, like send_pdu()'s own refusals - return; + this->on_custom_response(request_pdu, response_pdu, status); + break; + } +} + +// Default on_custom_response handler to warn when responses unexpectedly trigger on_custom_response +void ModbusClientDevice::on_custom_response(std::span request_pdu, std::span response_pdu, + ResponseStatus status) { + // The dispatcher never calls this with an empty request, but this is a public virtual - stay safe. + const uint8_t function_code = request_pdu.empty() ? 0 : request_pdu[0]; + // Warn once per device, then drop to VERBOSE: a mildly non-conformant peer answers every poll, + // and an unhandled-response warning per transaction would flood the log permanently. + if (!this->custom_response_warned_) { + this->custom_response_warned_ = true; + ESP_LOGW(TAG, "Non-standard request or response for function code 0x%X. No on_custom_response handler declared", + function_code); + } else { + ESP_LOGV(TAG, "Non-standard request or response for function code 0x%X (unhandled)", function_code); } } diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 6afb8584aa..d83075eda1 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -191,15 +191,25 @@ class ModbusClientDevice { ModbusClientDevice &operator=(ModbusClientDevice &&) = delete; void set_parent(ModbusClientHub *parent) { this->parent_ = parent; } void set_address(uint8_t address) { this->address_ = address; } - /// Called with the request PDU this device sent and the response PDU received (both: function code + - /// data, no address, no CRC). The spans are only valid for the duration of the call - copy the bytes - /// if they must outlive it. Slice the payload out of the response with helpers::server_pdu_payload(). - virtual void on_response(std::span request_pdu, std::span response_pdu) {} - /// Called with the request PDU and the modbus exception code decoded from the error response. - virtual void on_error(std::span request_pdu, ExceptionCode exception_code) {} - // The on_modbus_* names are signature-identical renames, so the new defaults forward to the old - // virtuals: external devices overriding the old names keep working through the deprecation window. - // Remove the forwards together with the deprecated names. + /// Low-level response hook: called with the request PDU this device sent and the response PDU received + /// The spans are only valid for the duration of the call - copy the bytes if they must outlive it. + /// The default implementation decodes standard responses and dispatches to on_read_* / on_write_* callbacks below. + /// Override it to handle raw PDUs directly. + virtual void on_response(std::span request_pdu, std::span response_pdu) { + this->dispatch_response_(request_pdu, response_pdu, std::nullopt); + } + /// Low-level error hook: called with the request PDU and the modbus exception code from the error response. + /// The default implementation dispatches to the same typed callbacks with the exception code as status. + /// Devices implementing the High-level typed callbacks see success and failure through one interface. + virtual void on_error(std::span request_pdu, ExceptionCode exception_code) { + this->dispatch_response_(request_pdu, {}, exception_code); + } + + /// Called when no request could be sent (e.g. queue full, transmission blocked) + /// Do not attempt to queue a command in this callback. + /// (The on_modbus_* names are signature-identical renames, so the new defaults forward to the old + /// virtuals: external devices overriding the old names keep working through the deprecation window. + /// Remove the forwards together with the deprecated names.) virtual void on_not_sent() { #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wdeprecated-declarations" @@ -220,6 +230,51 @@ class ModbusClientDevice { // Remove before 2027.2.0 ESPDEPRECATED("Override on_no_response() instead. Removed in 2027.2.0", "2026.8.0") virtual bool on_modbus_no_response() { return false; } + + /// High-level typed response callbacks, fired by the default on_response()/on_error() with arguments + /// parsed from the request and response PDUs. + /// Status is std::nullopt on success; holds the exception code on failure. + /// Register values are in host byte order; spans are only valid for the duration of the call. + virtual void on_read_registers(EntityType entity_type, uint16_t start_address, std::span registers, + ResponseStatus status) {} + virtual void on_read_holding_registers(uint16_t start_address, std::span registers, + ResponseStatus status) { + this->on_read_registers(EntityType::HOLDING, start_address, registers, status); + } + virtual void on_read_input_registers(uint16_t start_address, std::span registers, + ResponseStatus status) { + this->on_read_registers(EntityType::INPUT_REGISTER, start_address, registers, status); + } + /// Coil/discrete-input reads are delivered as a PackedBits view (bit 0 = the bit at start_address, + /// bits.size() = the count requested). The view points into the hub's receive buffer and is only + /// valid during the call. + virtual void on_read_bits(EntityType entity_type, uint16_t start_address, PackedBits bits, ResponseStatus status) {} + virtual void on_read_coils(uint16_t start_address, PackedBits bits, ResponseStatus status) { + this->on_read_bits(EntityType::COIL, start_address, bits, status); + } + virtual void on_read_discrete_inputs(uint16_t start_address, PackedBits bits, ResponseStatus status) { + this->on_read_bits(EntityType::DISCRETE_INPUT, start_address, bits, status); + } + /// Write acknowledgements. These deliberately mirror the read callbacks' shapes, so a write ack can be fed + /// through the same handler as a read (registers.size() / bits.size() gives the count) + /// + /// IMPORTANT - for the multi-writes these are the values that were REQUESTED, not device-confirmed + /// state: a multi-write ack only echoes the start address and count, so the values are decoded from + /// the request PDU, and they are delivered even when status holds an exception code. Always check + /// status, and treat publishing them as an optimistic update rather than a read-back. The single + /// writes are the exception: their successful ack echoes the value, so on success the delivered + /// value is the device's echo (on an exception it falls back to the request copy). + virtual void on_write_single_register(uint16_t address, uint16_t value, ResponseStatus status) {} + virtual void on_write_single_coil(uint16_t address, bool value, ResponseStatus status) {} + virtual void on_write_multiple_registers(uint16_t start_address, std::span registers, + ResponseStatus status) {} + virtual void on_write_multiple_coils(uint16_t start_address, PackedBits bits, ResponseStatus status) {} + /// Catch-all for custom function codes and anything that is not a standard-conformant transaction + /// (see dispatch_response_()); on failure the response is empty and the exception code is in status. + /// The default implementation only logs a warning that the response is going unhandled - override it + /// to handle custom traffic (which also silences the warning). + virtual void on_custom_response(std::span request_pdu, std::span response_pdu, + ResponseStatus status); ESPDEPRECATED("Use the typed read_*/write_* helpers or send_pdu() instead. Removed in 2027.2.0", "2026.8.0") void send(uint8_t function, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0, const uint8_t *payload = nullptr) { @@ -237,8 +292,12 @@ class ModbusClientDevice { } this->parent_->send_pdu(payload[0], std::span(payload).subspan(1), this); } - // Dispatches to the matching read_* method; defined in modbus.cpp because it logs on an invalid type. - void read_entities(EntityType entity_type, uint16_t start_address, uint16_t number_of_entities); + // Reads via the table-appropriate function code; an unreadable entity type maps to INVALID, which + // create_read_pdu() rejects into an empty PDU and send_pdu() signals via on_not_sent(). + void read_entities(EntityType entity_type, uint16_t start_address, uint16_t number_of_entities) { + this->send_pdu(helpers::create_read_pdu(helpers::modbus_register_read_function(entity_type), start_address, + number_of_entities)); + } void read_input_registers(uint16_t start_address, uint16_t number_of_registers) { this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_INPUT_REGISTERS, start_address, number_of_registers)); } @@ -281,8 +340,13 @@ class ModbusClientDevice { bool ready_for_immediate_send() { return this->parent_->tx_buffer_empty() && !this->parent_->tx_blocked(); } protected: + /// Parses the request/response PDU pair and dispatches to the matching high-level typed callback + void dispatch_response_(std::span request_pdu, std::span response_pdu, + ResponseStatus status); + ModbusClientHub *parent_{nullptr}; uint8_t address_{0}; + bool custom_response_warned_{false}; // first unhandled custom response warns; repeats log at VERBOSE }; // Compatibility shim for external components written against the pre-2026.8 API, which subclassed diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index 96561cf56f..88e10287a2 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -420,6 +420,11 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address, pdu.push_back(values_len); // Byte count is required for write multiple for (size_t i = 0; i < values_len; i++) pdu.push_back(values[i]); + // Zero the unused bits of the final byte as the spec requires, matching the typed coil builder + // so both produce identical wire bytes for the same write. + if (bits && number_of_entities % 8 != 0) { + pdu[pdu.size() - 1] &= static_cast((1 << (number_of_entities % 8)) - 1); + } return pdu; } diff --git a/tests/components/modbus/modbus_client_device_test.cpp b/tests/components/modbus/modbus_client_device_test.cpp new file mode 100644 index 0000000000..8638c37688 --- /dev/null +++ b/tests/components/modbus/modbus_client_device_test.cpp @@ -0,0 +1,344 @@ +#include + +#include +#include +#include +#include + +#include "esphome/components/modbus/modbus.h" + +namespace esphome::modbus::testing { + +namespace { + +// Records every typed callback so tests can assert on the dispatch performed by the default +// on_response()/on_error() implementations. +class RecordingDevice : public ModbusClientDevice { + public: + struct ReadRegistersCall { + uint16_t start_address; + std::vector registers; + ResponseStatus status; + }; + struct ReadBitsCall { + uint16_t start_address; + uint16_t count; + std::vector packed; + ResponseStatus status; + }; + struct WriteCall { + uint16_t address; + uint16_t value; + ResponseStatus status; + }; + + void on_read_holding_registers(uint16_t start_address, std::span registers, + ResponseStatus status) override { + this->holding_calls.push_back({start_address, {registers.begin(), registers.end()}, status}); + } + void on_read_input_registers(uint16_t start_address, std::span registers, + ResponseStatus status) override { + this->input_calls.push_back({start_address, {registers.begin(), registers.end()}, status}); + } + void on_read_coils(uint16_t start_address, PackedBits bits, ResponseStatus status) override { + this->coil_calls.push_back({start_address, bits.size(), {bits.bytes().begin(), bits.bytes().end()}, status}); + } + void on_read_discrete_inputs(uint16_t start_address, PackedBits bits, ResponseStatus status) override { + this->discrete_calls.push_back({start_address, bits.size(), {bits.bytes().begin(), bits.bytes().end()}, status}); + } + void on_write_single_register(uint16_t address, uint16_t value, ResponseStatus status) override { + this->write_single_register_calls.push_back({address, value, status}); + } + void on_write_single_coil(uint16_t address, bool value, ResponseStatus status) override { + this->write_single_coil_calls.push_back({address, static_cast(value), status}); + } + void on_write_multiple_registers(uint16_t start_address, std::span registers, + ResponseStatus status) override { + this->write_multiple_registers_calls.push_back({start_address, {registers.begin(), registers.end()}, status}); + } + void on_write_multiple_coils(uint16_t start_address, PackedBits bits, ResponseStatus status) override { + this->write_multiple_coils_calls.push_back( + {start_address, bits.size(), {bits.bytes().begin(), bits.bytes().end()}, status}); + } + void on_custom_response(std::span request_pdu, std::span response_pdu, + ResponseStatus status) override { + this->custom_requests.emplace_back(request_pdu.begin(), request_pdu.end()); + this->custom_responses.emplace_back(response_pdu.begin(), response_pdu.end()); + this->custom_statuses.push_back(status); + } + + std::vector holding_calls; + std::vector input_calls; + std::vector coil_calls; + std::vector discrete_calls; + std::vector write_single_register_calls; + std::vector write_single_coil_calls; + std::vector write_multiple_registers_calls; + std::vector write_multiple_coils_calls; + std::vector> custom_requests; + std::vector> custom_responses; + std::vector custom_statuses; +}; + +// Overrides only the generic callbacks to verify the typed defaults delegate to them. +class GenericDevice : public ModbusClientDevice { + public: + void on_read_registers(EntityType register_type, uint16_t start_address, std::span registers, + ResponseStatus status) override { + this->register_type = register_type; + this->start_address = start_address; + this->registers.assign(registers.begin(), registers.end()); + this->calls++; + } + void on_read_bits(EntityType register_type, uint16_t start_address, PackedBits bits, ResponseStatus status) override { + this->register_type = register_type; + this->start_address = start_address; + this->bit_count = bits.size(); + this->calls++; + } + EntityType register_type{EntityType::CUSTOM}; + uint16_t start_address{0}; + uint16_t bit_count{0}; + std::vector registers; + int calls{0}; +}; + +} // namespace + +TEST(ModbusClientDeviceFanOut, ReadHoldingRegistersSuccess) { + RecordingDevice device; + const uint8_t request[] = {0x03, 0x01, 0x00, 0x00, 0x02}; // read 2 regs at 0x100 + const uint8_t response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; // 0x002A, 0x0100 + device.on_response(request, response); + + ASSERT_EQ(device.holding_calls.size(), 1u); + const auto &call = device.holding_calls.front(); + EXPECT_EQ(call.start_address, 0x100); + EXPECT_EQ(call.registers, (std::vector{0x002A, 0x0100})); + EXPECT_FALSE(call.status.has_value()); +} + +TEST(ModbusClientDeviceFanOut, ReadInputRegistersDelegateToGeneric) { + GenericDevice device; + const uint8_t request[] = {0x04, 0x00, 0x10, 0x00, 0x01}; + const uint8_t response[] = {0x04, 0x02, 0x12, 0x34}; + device.on_response(request, response); + + EXPECT_EQ(device.calls, 1); + EXPECT_EQ(device.register_type, EntityType::INPUT_REGISTER); + EXPECT_EQ(device.start_address, 0x10); + EXPECT_EQ(device.registers, (std::vector{0x1234})); +} + +TEST(ModbusClientDeviceFanOut, ReadDiscreteInputsDelegateToGenericBits) { + GenericDevice device; + const uint8_t request[] = {0x02, 0x00, 0x20, 0x00, 0x05}; // 5 inputs at 0x20 + const uint8_t response[] = {0x02, 0x01, 0x15}; + device.on_response(request, response); + + EXPECT_EQ(device.calls, 1); + EXPECT_EQ(device.register_type, EntityType::DISCRETE_INPUT); + EXPECT_EQ(device.start_address, 0x20); + EXPECT_EQ(device.bit_count, 5); +} + +// A CRC-valid response whose length does not match its request cannot be decoded per the +// function-code contract: it goes to the catch-all with the raw PDUs, not to the typed callback. +TEST(ModbusClientDeviceFanOut, ReadRegistersMismatchedLengthGoesToCatchAll) { + RecordingDevice device; + // Request asks for 4 registers but the response only carries 1. + const uint8_t request[] = {0x03, 0x00, 0x00, 0x00, 0x04}; + const uint8_t response[] = {0x03, 0x02, 0xBE, 0xEF}; + device.on_response(request, response); + + EXPECT_TRUE(device.holding_calls.empty()); + ASSERT_EQ(device.custom_responses.size(), 1u); + EXPECT_EQ(device.custom_responses.front(), (std::vector(response, response + sizeof(response)))); +} + +// Coil responses are validated the same way: byte count must be ceil(count / 8). +TEST(ModbusClientDeviceFanOut, ReadCoilsMismatchedLengthGoesToCatchAll) { + RecordingDevice device; + const uint8_t request[] = {0x01, 0x00, 0x13, 0x00, 0x13}; // 19 coils -> 3 packed bytes + const uint8_t response[] = {0x01, 0x02, 0xCD, 0x6B}; // only 2 + device.on_response(request, response); + + EXPECT_TRUE(device.coil_calls.empty()); + EXPECT_EQ(device.custom_responses.size(), 1u); +} + +TEST(ModbusClientDeviceFanOut, ReadCoilsSuccess) { + RecordingDevice device; + const uint8_t request[] = {0x01, 0x00, 0x13, 0x00, 0x13}; // 19 coils at 0x13 + const uint8_t response[] = {0x01, 0x03, 0xCD, 0x6B, 0x05}; + device.on_response(request, response); + + ASSERT_EQ(device.coil_calls.size(), 1u); + const auto &call = device.coil_calls.front(); + EXPECT_EQ(call.start_address, 0x13); + EXPECT_EQ(call.count, 19); + EXPECT_EQ(call.packed, (std::vector{0xCD, 0x6B, 0x05})); + EXPECT_FALSE(call.status.has_value()); + // first coil = bit 0 of byte 0 + EXPECT_TRUE(helpers::bit_from_packed(0, call.packed)); + EXPECT_FALSE(helpers::bit_from_packed(1, call.packed)); +} + +TEST(ModbusClientDeviceFanOut, WriteSingleRegisterSuccess) { + RecordingDevice device; + const uint8_t request[] = {0x06, 0x00, 0x01, 0x00, 0x03}; + device.on_response(request, request); // echo + + ASSERT_EQ(device.write_single_register_calls.size(), 1u); + const auto &call = device.write_single_register_calls.front(); + EXPECT_EQ(call.address, 1); + EXPECT_EQ(call.value, 3); + EXPECT_FALSE(call.status.has_value()); +} + +TEST(ModbusClientDeviceFanOut, WriteSingleCoilSuccess) { + RecordingDevice device; + const uint8_t request[] = {0x05, 0x00, 0xAC, 0xFF, 0x00}; + device.on_response(request, request); + + ASSERT_EQ(device.write_single_coil_calls.size(), 1u); + EXPECT_EQ(device.write_single_coil_calls.front().address, 0xAC); + EXPECT_EQ(device.write_single_coil_calls.front().value, 1u); +} + +TEST(ModbusClientDeviceFanOut, WriteErrorReportsRequestArgumentsAndStatus) { + RecordingDevice device; + const uint8_t request[] = {0x06, 0x00, 0x01, 0x00, 0x03}; + const uint8_t exception[] = {0x86, 0x02}; // ILLEGAL_DATA_ADDRESS + device.on_error(request, static_cast(exception[1])); + + ASSERT_EQ(device.write_single_register_calls.size(), 1u); + const auto &call = device.write_single_register_calls.front(); + EXPECT_EQ(call.address, 1); + EXPECT_EQ(call.value, 3); + EXPECT_EQ(call.status, ExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +TEST(ModbusClientDeviceFanOut, ReadErrorReportsEmptyDataAndStatus) { + RecordingDevice device; + const uint8_t request[] = {0x03, 0x01, 0x00, 0x00, 0x02}; + const uint8_t exception[] = {0x83, 0x02}; + device.on_error(request, static_cast(exception[1])); + + ASSERT_EQ(device.holding_calls.size(), 1u); + const auto &call = device.holding_calls.front(); + EXPECT_EQ(call.start_address, 0x100); + EXPECT_TRUE(call.registers.empty()); + EXPECT_EQ(call.status, ExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +TEST(ModbusClientDeviceFanOut, CustomFunctionCodeGoesToCatchAll) { + RecordingDevice device; + const uint8_t request[] = {0x47, 0x01, 0x02, 0x03, 0x04}; + const uint8_t response[] = {0x47, 0xAA, 0xBB}; + device.on_response(request, response); + + ASSERT_EQ(device.custom_requests.size(), 1u); + EXPECT_EQ(device.custom_requests.front(), (std::vector{0x47, 0x01, 0x02, 0x03, 0x04})); + EXPECT_EQ(device.custom_responses.front(), (std::vector{0x47, 0xAA, 0xBB})); + EXPECT_FALSE(device.custom_statuses.front().has_value()); + EXPECT_TRUE(device.holding_calls.empty()); + + // On failure the catch-all receives an empty response and the status (the exception code). + const uint8_t exception[] = {0xC7, 0x02}; + device.on_error(request, static_cast(exception[1])); + ASSERT_EQ(device.custom_statuses.size(), 2u); + EXPECT_EQ(device.custom_statuses.back(), ExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_TRUE(device.custom_responses.back().empty()); +} + +// A write ack only echoes the start address and count, so the data that was written is decoded from the +// request PDU: [0] function code, [1..2] start address, [3..4] count, [5] byte count, [6..] data. +TEST(ModbusClientDeviceFanOut, WriteMultipleAcksReportStartAndData) { + RecordingDevice device; + // Write 2 registers (0x0001, 0x0002) at 0x0020: byte count 4, data from offset 6. + const uint8_t reg_request[] = {0x10, 0x00, 0x20, 0x00, 0x02, 0x04, 0x00, 0x01, 0x00, 0x02}; + const uint8_t reg_ack[] = {0x10, 0x00, 0x20, 0x00, 0x02}; + device.on_response(reg_request, reg_ack); + // Write 10 coils at 0x0030: byte count 2, packed bits 0xFF 0x03 from offset 6. + const uint8_t coil_request[] = {0x0F, 0x00, 0x30, 0x00, 0x0A, 0x02, 0xFF, 0x03}; + const uint8_t coil_ack[] = {0x0F, 0x00, 0x30, 0x00, 0x0A}; + device.on_response(coil_request, coil_ack); + + ASSERT_EQ(device.write_multiple_registers_calls.size(), 1u); + EXPECT_EQ(device.write_multiple_registers_calls.front().start_address, 0x20); + EXPECT_EQ(device.write_multiple_registers_calls.front().registers, (std::vector{0x0001, 0x0002})); + ASSERT_EQ(device.write_multiple_coils_calls.size(), 1u); + EXPECT_EQ(device.write_multiple_coils_calls.front().start_address, 0x30); + EXPECT_EQ(device.write_multiple_coils_calls.front().count, 10); + EXPECT_EQ(device.write_multiple_coils_calls.front().packed, (std::vector{0xFF, 0x03})); +} + +// A truncated request (byte-count header promises more data than the PDU carries) is not a standard +// write-multiple, so it is diverted to on_custom_response() - never clamped and delivered as if complete. +TEST(ModbusClientDeviceFanOut, WriteMultipleTruncatedRequestDispatchesAsCustom) { + RecordingDevice device; + // Header claims 2 registers / 4 data bytes, but only one register's worth is present. + const uint8_t reg_request[] = {0x10, 0x00, 0x20, 0x00, 0x02, 0x04, 0x00, 0x01}; + const uint8_t reg_ack[] = {0x10, 0x00, 0x20, 0x00, 0x02}; + device.on_response(reg_request, reg_ack); + + EXPECT_TRUE(device.write_multiple_registers_calls.empty()); + ASSERT_EQ(device.custom_requests.size(), 1u); + EXPECT_EQ(device.custom_requests.front(), (std::vector{0x10, 0x00, 0x20, 0x00, 0x02, 0x04, 0x00, 0x01})); +} + +// A request whose byte-count header disagrees with its own quantity field (here: 2 registers but a +// byte count of 2 instead of 4, with matching data) is non-standard and diverted to the catch-all. +TEST(ModbusClientDeviceFanOut, WriteMultipleInconsistentByteCountDispatchesAsCustom) { + RecordingDevice device; + const uint8_t reg_request[] = {0x10, 0x00, 0x20, 0x00, 0x02, 0x02, 0x00, 0x01}; + const uint8_t reg_ack[] = {0x10, 0x00, 0x20, 0x00, 0x02}; + device.on_response(reg_request, reg_ack); + + EXPECT_TRUE(device.write_multiple_registers_calls.empty()); + EXPECT_EQ(device.custom_requests.size(), 1u); +} + +// An exception on a read still dispatches to the typed callback (empty data, status set): the gate must +// not require a standard response on the failure path, because on_error() delivers an empty response by +// design. +TEST(ModbusClientDeviceFanOut, ReadErrorWithEmptyResponseStillDispatchesTyped) { + RecordingDevice device; + const uint8_t read_request[] = {0x03, 0x01, 0x00, 0x00, 0x02}; + device.on_error(read_request, ExceptionCode::ILLEGAL_DATA_ADDRESS); + + ASSERT_EQ(device.holding_calls.size(), 1u); + EXPECT_TRUE(device.holding_calls.front().registers.empty()); + EXPECT_EQ(device.holding_calls.front().status, ExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_TRUE(device.custom_requests.empty()); +} + +// An error on a coil read must deliver a PackedBits view whose size() is zero - the count must never +// promise bits that have no bytes behind them (operator[] is unchecked). +TEST(ModbusClientDeviceFanOut, ReadCoilsErrorDeliversZeroCountBits) { + RecordingDevice device; + const uint8_t read_request[] = {0x01, 0x01, 0x00, 0x00, 0x0A}; + device.on_error(read_request, ExceptionCode::SERVICE_DEVICE_FAILURE); + + ASSERT_EQ(device.coil_calls.size(), 1u); + EXPECT_EQ(device.coil_calls.front().count, 0); + EXPECT_TRUE(device.coil_calls.front().packed.empty()); +} + +// Single-write acks: on success the delivered value is the device's echo (real read-back); +// on an exception it falls back to the request copy. +TEST(ModbusTypedDispatch, SingleWriteAckPrefersTheResponseEcho) { + RecordingDevice device; + const uint8_t request[] = {0x06, 0x00, 0x10, 0x00, 0x2A}; + const uint8_t echo_clamped[] = {0x06, 0x00, 0x10, 0x00, 0x28}; // device clamped 42 -> 40 + device.on_response(request, echo_clamped); + ASSERT_EQ(device.write_single_register_calls.size(), 1u); + EXPECT_EQ(device.write_single_register_calls.front().value, 0x0028); // the echo, not the request + + device.on_error(request, ExceptionCode::ILLEGAL_DATA_VALUE); + ASSERT_EQ(device.write_single_register_calls.size(), 2u); + EXPECT_EQ(device.write_single_register_calls.back().value, 0x002A); // exception: request copy +} + +} // namespace esphome::modbus::testing diff --git a/tests/components/modbus/modbus_helpers_test.cpp b/tests/components/modbus/modbus_helpers_test.cpp index b471644226..49de4f9d14 100644 --- a/tests/components/modbus/modbus_helpers_test.cpp +++ b/tests/components/modbus/modbus_helpers_test.cpp @@ -448,6 +448,15 @@ TEST(ModbusTypedBuilders, BoolSpanCoilBuilderRejectsOverLimit) { EXPECT_TRUE(create_write_coils_pdu(0, std::span(big.get(), MAX_NUM_OF_COILS_TO_WRITE + 1)).empty()); } +TEST(ModbusCreateClientPdu, GenericCoilWriteMasksTrailingPadBits) { + // 10 coils with junk in the pad bits of the last data byte: the generic path masks them like the + // typed builder, so both produce identical wire bytes. + const uint8_t values[] = {0xFF, 0xFF}; + auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_COILS, 0x0000, 10, values, 2); + ASSERT_FALSE(pdu.empty()); + EXPECT_EQ(pdu[pdu.size() - 1], 0x03); // bits 8-9 kept, pad bits 10-15 zeroed +} + TEST(ModbusCreateClientPdu, SingleCoilValueValidated) { const uint8_t on[] = {0xFF, 0x00}; const uint8_t junk[] = {0x01, 0x00}; From 91cbcab25ef452277dba084e4bc3aa950921c780 Mon Sep 17 00:00:00 2001 From: Daniele Palumbo Date: Mon, 27 Jul 2026 04:01:45 +0200 Subject: [PATCH 1077/1815] [web_server] Add assumed_state to cover JSON detail (#16800) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/web_server/web_server.cpp | 1 + tests/components/web_server/common.yaml | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index d06b6d6408..3fe3979a8b 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1115,6 +1115,7 @@ json::SerializationBuffer<> WebServer::cover_json_(cover::Cover *obj, JsonDetail if (obj->get_traits().get_supports_tilt()) root[ESPHOME_F("tilt")] = obj->tilt; if (start_config == DETAIL_ALL) { + root[ESPHOME_F("assumed_state")] = obj->get_traits().get_is_assumed_state(); this->add_sorting_info_(root, obj); } diff --git a/tests/components/web_server/common.yaml b/tests/components/web_server/common.yaml index 5a05a58c2d..d7b880efe6 100644 --- a/tests/components/web_server/common.yaml +++ b/tests/components/web_server/common.yaml @@ -4,6 +4,17 @@ wifi: binary_sensor: cover: + - platform: template + name: "Template Cover Assumed" + # assumed_state must be reflected in the web_server JSON (detail=all) + assumed_state: true + lambda: 'return COVER_OPEN;' + open_action: + - logger.log: open_action + close_action: + - logger.log: close_action + stop_action: + - logger.log: stop_action fan: light: sensor: From 8f321bf346b71ebc2ee26ac4d3bac56080388ad0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Jul 2026 19:31:24 -1000 Subject: [PATCH 1078/1815] [wifi] Lock scan results shared with the captive portal web task (#17850) --- esphome/components/captive_portal/__init__.py | 5 +- .../captive_portal/captive_portal.cpp | 31 +++--- esphome/components/wifi/__init__.py | 16 +++ esphome/components/wifi/wifi_component.cpp | 34 ++++--- esphome/components/wifi/wifi_component.h | 36 +++++++ .../wifi/wifi_component_esp8266.cpp | 2 + .../wifi/wifi_component_esp_idf.cpp | 98 +++++++++---------- .../wifi/wifi_component_libretiny.cpp | 66 +++++++------ .../components/wifi/wifi_component_pico_w.cpp | 4 + esphome/core/defines.h | 3 + 10 files changed, 187 insertions(+), 108 deletions(-) diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index 703ae98392..d62c718097 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -1,7 +1,7 @@ import logging import esphome.codegen as cg -from esphome.components import web_server_base +from esphome.components import web_server_base, wifi from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv @@ -101,6 +101,9 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID], paren) await cg.register_component(var, config) cg.add_define("USE_CAPTIVE_PORTAL") + # The portal reads wifi scan results from the web server task; this makes the + # wifi component guard them with a lock on multi-threaded platforms. + wifi.request_wifi_scan_results_lock() if config[CONF_COMPRESSION] == "gzip": cg.add_define("USE_CAPTIVE_PORTAL_GZIP") diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 365e5f64db..228fdf7934 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -24,23 +24,28 @@ void CaptivePortal::handle_config(AsyncWebServerRequest *request) { stream->printf(R"({"mac":"%s","name":"%s","aps":[{})", mac_str, App.get_name().c_str()); #endif - for (auto &scan : wifi::global_wifi_component->get_scan_result()) { - if (scan.get_is_hidden()) - continue; + { + // Invariant: only bounded in-memory work under the lock; the network send + // happens later in request->send() + wifi::ScanResultsLock lock(wifi::global_wifi_component); + for (const auto &scan : wifi::global_wifi_component->get_scan_result()) { + if (scan.get_is_hidden()) + continue; - // Assumes no " in ssid, possible unicode isses? + // Assumes no " in ssid, possible unicode issues? #ifdef USE_ESP8266 - stream->print(ESPHOME_F(",{\"ssid\":\"")); - stream->print(scan.get_ssid().c_str()); - stream->print(ESPHOME_F("\",\"rssi\":")); - stream->print(scan.get_rssi()); - stream->print(ESPHOME_F(",\"lock\":")); - stream->print(scan.get_with_auth()); - stream->print(ESPHOME_F("}")); + stream->print(ESPHOME_F(",{\"ssid\":\"")); + stream->print(scan.get_ssid().c_str()); + stream->print(ESPHOME_F("\",\"rssi\":")); + stream->print(scan.get_rssi()); + stream->print(ESPHOME_F(",\"lock\":")); + stream->print(scan.get_with_auth()); + stream->print(ESPHOME_F("}")); #else - stream->printf(R"(,{"ssid":"%s","rssi":%d,"lock":%d})", scan.get_ssid().c_str(), scan.get_rssi(), - scan.get_with_auth()); + stream->printf(R"(,{"ssid":"%s","rssi":%d,"lock":%d})", scan.get_ssid().c_str(), scan.get_rssi(), + scan.get_with_auth()); #endif + } } stream->print(ESPHOME_F("]}")); request->send(stream); diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 1810a62155..4bb6629da1 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -823,6 +823,7 @@ IP_STATE_LISTENERS_KEY = "wifi_ip_state_listeners" SCAN_RESULTS_LISTENERS_KEY = "wifi_scan_results_listeners" CONNECT_STATE_LISTENERS_KEY = "wifi_connect_state_listeners" POWER_SAVE_LISTENERS_KEY = "wifi_power_save_listeners" +SCAN_RESULTS_LOCK_KEY = "wifi_scan_results_lock" def request_wifi_scan_results(): @@ -835,6 +836,19 @@ def request_wifi_scan_results(): CORE.data[KEEP_SCAN_RESULTS_KEY] = True +def request_wifi_scan_results_lock() -> None: + """Request that scan results be guarded by a lock for cross-task readers. + + Components that read WiFi scan results from a task other than the main loop + (for example a web server handler) must call this function during their code + generation, and their C++ code must hold a wifi::ScanResultsLock while + iterating get_scan_result(). On multi-threaded platforms this compiles in a + lock that scan result writers hold; on single-threaded platforms it compiles + to nothing. + """ + CORE.data[SCAN_RESULTS_LOCK_KEY] = True + + def enable_runtime_power_save_control(): """Enable runtime WiFi power save control. @@ -896,6 +910,8 @@ async def final_step(): cg.add_define("USE_WIFI_RUNTIME_POWER_SAVE") if CORE.data.get(RUNTIME_ROAMING_SUPPRESSION_KEY, False): cg.add_define("USE_WIFI_RUNTIME_ROAMING_SUPPRESSION") + if CORE.data.get(SCAN_RESULTS_LOCK_KEY): + cg.add_define("USE_WIFI_SCAN_RESULTS_LOCK") # Generate listener defines - each listener type has its own #ifdef ip_state_count = CORE.data.get(IP_STATE_LISTENERS_KEY, 0) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 44e3cb6af9..650b06cae1 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1483,23 +1483,26 @@ void WiFiComponent::check_scanning_finished() { } ESP_LOGD(TAG, "Found networks:"); - for (auto &res : this->scan_result_) { - for (auto &ap : this->sta_) { - if (res.matches(ap)) { - res.set_matches(true); - // Cache priority lookup - do single search instead of 2 separate searches - const bssid_t &bssid = res.get_bssid(); - if (!this->has_sta_priority(bssid)) { - this->set_sta_priority(bssid, ap.get_priority()); + { + ScanResultsLock lock(this); + for (auto &res : this->scan_result_) { + for (auto &ap : this->sta_) { + if (res.matches(ap)) { + res.set_matches(true); + // Cache priority lookup - do single search instead of 2 separate searches + const bssid_t &bssid = res.get_bssid(); + if (!this->has_sta_priority(bssid)) { + this->set_sta_priority(bssid, ap.get_priority()); + } + res.set_priority(this->get_sta_priority(bssid)); + break; } - res.set_priority(this->get_sta_priority(bssid)); - break; } } - } - // Sort scan results using insertion sort for better memory efficiency - insertion_sort_scan_results(this->scan_result_); + // Sort scan results using insertion sort for better memory efficiency + insertion_sort_scan_results(this->scan_result_); + } // Log matching networks (non-matching already logged at VERBOSE in scan callback) for (auto &res : this->scan_result_) { @@ -1885,11 +1888,13 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { // Phase-specific setup switch (new_phase) { #ifdef USE_WIFI_FAST_CONNECT - case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS: + case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS: { // Move to next configured AP - clear old scan data so new AP is tried with config only this->selected_sta_index_++; + ScanResultsLock lock(this); this->scan_result_.clear(); break; + } #endif case WiFiRetryPhase::EXPLICIT_HIDDEN: @@ -2404,6 +2409,7 @@ void WiFiComponent::clear_roaming_state_() { void WiFiComponent::release_scan_results_() { if (!this->keep_scan_results_) { + ScanResultsLock lock(this); #if defined(USE_RP2) || defined(USE_ESP32) // std::vector - use swap trick since shrink_to_fit is non-binding decltype(this->scan_result_)().swap(this->scan_result_); diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 6faabc223c..43e44a135f 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -187,6 +187,13 @@ template using wifi_scan_vector_t = std::vector; template using wifi_scan_vector_t = FixedVector; #endif +// A consumer component (e.g. the captive portal) reads scan results from another +// task; guard them with a real lock only on platforms that actually run multiple +// threads. See ScanResultsLock below the WiFiComponent class. +#if defined(USE_WIFI_SCAN_RESULTS_LOCK) && !defined(ESPHOME_THREAD_SINGLE) +#define WIFI_SCAN_RESULTS_LOCK_ENABLED +#endif + /// 20-byte string: 18 chars inline + null, heap for longer. Always null-terminated. /// Used internally for WiFi SSID/password storage to reduce heap fragmentation. class CompactString { @@ -506,6 +513,9 @@ class WiFiComponent final : public Component { const char *get_use_address() const { return this->use_address_; } void set_use_address(const char *use_address) { this->use_address_ = use_address; } + /// Main-loop callers may read this directly. Callers on any other task must + /// hold a ScanResultsLock for the whole iteration and must call + /// wifi.request_wifi_scan_results_lock() from their code generation. const wifi_scan_vector_t &get_scan_result() const { return scan_result_; } network::IPAddress wifi_soft_ap_ip(); @@ -817,6 +827,8 @@ class WiFiComponent final : public Component { friend void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data); #endif + friend class ScanResultsLock; + #ifdef USE_RP2 static int s_wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result); void wifi_scan_result_(void *env, const cyw43_ev_scan_result_t *result); @@ -831,7 +843,11 @@ class WiFiComponent final : public Component { // Large/pointer-aligned members first FixedVector sta_; std::vector sta_priorities_; + // Guarded by ScanResultsLock (see below this class) wifi_scan_vector_t scan_result_; +#ifdef WIFI_SCAN_RESULTS_LOCK_ENABLED + Mutex scan_result_lock_; +#endif #ifdef USE_WIFI_AP WiFiAP ap_; #endif @@ -1003,5 +1019,25 @@ class WiFiComponent final : public Component { extern WiFiComponent *global_wifi_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +/// Guards WiFiComponent::scan_result_. Invariant: every mutation and every read +/// from outside the main loop holds this lock, and holders only do bounded work +/// (never unbounded waits or network sends). On every platform where the lock is +/// enabled (ESP32, LibreTiny) scan-done events are drained from the event queue +/// on the main loop, so all writers are main-loop there and main-loop reads take +/// no lock. Single-threaded platforms write from driver context and the lock is +/// a no-op. Compiles to nothing unless a cross-task reader is in the build and +/// the platform is multi-threaded (WIFI_SCAN_RESULTS_LOCK_ENABLED). +class ScanResultsLock { + public: +#ifdef WIFI_SCAN_RESULTS_LOCK_ENABLED + ScanResultsLock(WiFiComponent *parent) : guard_(parent->scan_result_lock_) {} + + private: + LockGuard guard_; +#else + ScanResultsLock(WiFiComponent *) {} +#endif +}; + } // namespace esphome::wifi #endif diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 84b864c0c5..e082b2c8c1 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -733,6 +733,8 @@ void WiFiComponent::s_wifi_scan_done_callback(void *arg, STATUS status) { } void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) { + // Compiles to nothing here; kept so every scan_result_ mutation holds the lock + ScanResultsLock lock(this); this->scan_result_.clear(); if (status != OK) { diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 2ade015a25..d78cd21380 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -891,65 +891,65 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { const auto &it = data->data.sta_scan_done; ESP_LOGV(TAG, "Scan done: status=%" PRIu32 " number=%u scan_id=%u", it.status, it.number, it.scan_id); - scan_result_.clear(); - this->scan_done_ = true; - if (it.status != 0) { - // scan error - return; - } - - if (it.number == 0) { - // no results - return; - } - uint16_t number = it.number; bool needs_full = this->needs_full_scan_results_(); + { + // Mutate in place under the lock; blocking a portal request is fine and + // avoids scratch buffers + ScanResultsLock lock(this); + this->scan_result_.clear(); + this->scan_done_ = true; + if (it.status != 0) { + // scan error + return; + } - // Smart reserve: full capacity if needed, small reserve otherwise - if (needs_full) { - this->scan_result_.reserve(number); - } else { - this->scan_result_.reserve(WIFI_SCAN_RESULT_FILTERED_RESERVE); - } + if (number == 0) { + // no results + return; + } + + // Smart reserve: full capacity if needed, small reserve otherwise + this->scan_result_.reserve(needs_full ? number : WIFI_SCAN_RESULT_FILTERED_RESERVE); #ifdef USE_ESP32_HOSTED - // getting records one at a time fails on P4 with hosted esp32 WiFi coprocessor - // Presumably an upstream bug, work-around by getting all records at once - // Use stack buffer (3904 bytes / ~80 bytes per record = ~48 records) with heap fallback - static constexpr size_t SCAN_RECORD_STACK_COUNT = 3904 / sizeof(wifi_ap_record_t); - SmallBufferWithHeapFallback records(number); - err = esp_wifi_scan_get_ap_records(&number, records.get()); - if (err != ESP_OK) { - esp_wifi_clear_ap_list(); - ESP_LOGW(TAG, "esp_wifi_scan_get_ap_records failed: %s", esp_err_to_name(err)); - return; - } - for (uint16_t i = 0; i < number; i++) { - wifi_ap_record_t &record = records.get()[i]; -#else - // Process one record at a time to avoid large buffer allocation - for (uint16_t i = 0; i < number; i++) { - wifi_ap_record_t record; - err = esp_wifi_scan_get_ap_record(&record); + // getting records one at a time fails on P4 with hosted esp32 WiFi coprocessor + // Presumably an upstream bug, work-around by getting all records at once + // Use stack buffer (3904 bytes / ~80 bytes per record = ~48 records) with heap fallback + static constexpr size_t SCAN_RECORD_STACK_COUNT = 3904 / sizeof(wifi_ap_record_t); + SmallBufferWithHeapFallback records(number); + err = esp_wifi_scan_get_ap_records(&number, records.get()); if (err != ESP_OK) { - ESP_LOGW(TAG, "esp_wifi_scan_get_ap_record failed: %s", esp_err_to_name(err)); - esp_wifi_clear_ap_list(); // Free remaining records not yet retrieved - break; + esp_wifi_clear_ap_list(); + ESP_LOGW(TAG, "esp_wifi_scan_get_ap_records failed: %s", esp_err_to_name(err)); + return; } + for (uint16_t i = 0; i < number; i++) { + wifi_ap_record_t &record = records.get()[i]; +#else + // Process one record at a time to avoid large buffer allocation + for (uint16_t i = 0; i < number; i++) { + wifi_ap_record_t record; + err = esp_wifi_scan_get_ap_record(&record); + if (err != ESP_OK) { + ESP_LOGW(TAG, "esp_wifi_scan_get_ap_record failed: %s", esp_err_to_name(err)); + esp_wifi_clear_ap_list(); // Free remaining records not yet retrieved + break; + } #endif // USE_ESP32_HOSTED - // Check C string first - avoid std::string construction for non-matching networks - const char *ssid_cstr = reinterpret_cast(record.ssid); + // Check C string first - avoid std::string construction for non-matching networks + const char *ssid_cstr = reinterpret_cast(record.ssid); - // Only construct std::string and store if needed - if (needs_full || this->matches_configured_network_(ssid_cstr, record.bssid)) { - bssid_t bssid; - std::copy(record.bssid, record.bssid + 6, bssid.begin()); - this->scan_result_.emplace_back(bssid, ssid_cstr, strlen(ssid_cstr), record.primary, record.rssi, - record.authmode != WIFI_AUTH_OPEN, ssid_cstr[0] == '\0'); - } else { - this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary); + // Only construct std::string and store if needed + if (needs_full || this->matches_configured_network_(ssid_cstr, record.bssid)) { + bssid_t bssid; + std::copy(record.bssid, record.bssid + 6, bssid.begin()); + this->scan_result_.emplace_back(bssid, ssid_cstr, strlen(ssid_cstr), record.primary, record.rssi, + record.authmode != WIFI_AUTH_OPEN, ssid_cstr[0] == '\0'); + } else { + this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary); + } } } ESP_LOGV(TAG, "Scan complete: %u found, %zu stored%s", number, this->scan_result_.size(), diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 59efa4f842..ce9c4eb6ce 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -657,44 +657,48 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { return true; } void WiFiComponent::wifi_scan_done_callback_() { - this->scan_result_.clear(); - this->scan_done_ = true; - int16_t num = WiFi.scanComplete(); - if (num < 0) - return; - bool needs_full = this->needs_full_scan_results_(); + { + // Mutate in place under the lock; blocking a portal request is fine and + // avoids scratch buffers + ScanResultsLock lock(this); + this->scan_result_.clear(); + this->scan_done_ = true; - // Access scan results directly via WiFi.scan struct to avoid Arduino String allocations - // WiFi.scan is public in LibreTiny for WiFiEvents & WiFiScan static handlers - auto *scan = WiFi.scan; + if (num < 0) + return; - // First pass: count matching networks - size_t count = 0; - for (int i = 0; i < num; i++) { - const char *ssid_cstr = scan->ap[i].ssid; - if (needs_full || this->matches_configured_network_(ssid_cstr, scan->ap[i].bssid.addr)) { - count++; + // Access scan results directly via WiFi.scan struct to avoid Arduino String allocations + // WiFi.scan is public in LibreTiny for WiFiEvents & WiFiScan static handlers + auto *scan = WiFi.scan; + + // First pass: count matching networks + size_t count = 0; + for (int i = 0; i < num; i++) { + const char *ssid_cstr = scan->ap[i].ssid; + if (needs_full || this->matches_configured_network_(ssid_cstr, scan->ap[i].bssid.addr)) { + count++; + } + } + + this->scan_result_.init(count); // Exact allocation + + // Second pass: store matching networks + for (int i = 0; i < num; i++) { + const char *ssid_cstr = scan->ap[i].ssid; + auto &ap = scan->ap[i]; + if (needs_full || this->matches_configured_network_(ssid_cstr, ap.bssid.addr)) { + this->scan_result_.emplace_back(bssid_t{ap.bssid.addr[0], ap.bssid.addr[1], ap.bssid.addr[2], ap.bssid.addr[3], + ap.bssid.addr[4], ap.bssid.addr[5]}, + ssid_cstr, strlen(ssid_cstr), ap.channel, ap.rssi, ap.auth != WIFI_AUTH_OPEN, + ssid_cstr[0] == '\0'); + } else { + this->log_discarded_scan_result_(ssid_cstr, ap.bssid.addr, ap.rssi, ap.channel); + } } } - this->scan_result_.init(count); // Exact allocation - - // Second pass: store matching networks - for (int i = 0; i < num; i++) { - const char *ssid_cstr = scan->ap[i].ssid; - if (needs_full || this->matches_configured_network_(ssid_cstr, scan->ap[i].bssid.addr)) { - auto &ap = scan->ap[i]; - this->scan_result_.emplace_back(bssid_t{ap.bssid.addr[0], ap.bssid.addr[1], ap.bssid.addr[2], ap.bssid.addr[3], - ap.bssid.addr[4], ap.bssid.addr[5]}, - ssid_cstr, strlen(ssid_cstr), ap.channel, ap.rssi, ap.auth != WIFI_AUTH_OPEN, - ssid_cstr[0] == '\0'); - } else { - auto &ap = scan->ap[i]; - this->log_discarded_scan_result_(ssid_cstr, ap.bssid.addr, ap.rssi, ap.channel); - } - } ESP_LOGV(TAG, "Scan complete: %d found, %zu stored%s", num, this->scan_result_.size(), needs_full ? "" : " (filtered)"); WiFi.scanDelete(); diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 69ac90822f..69af9e9a4e 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -190,12 +190,16 @@ void WiFiComponent::wifi_scan_result_(void *env, const cyw43_ev_scan_result_t *r std::copy(result->bssid, result->bssid + 6, bssid.begin()); WiFiScanResult res(bssid, ssid_buf, len, result->channel, result->rssi, result->auth_mode != CYW43_AUTH_OPEN, len == 0); + // Compiles to nothing here; kept so every scan_result_ mutation holds the lock + ScanResultsLock lock(this); if (std::find(this->scan_result_.begin(), this->scan_result_.end(), res) == this->scan_result_.end()) { this->scan_result_.push_back(res); } } bool WiFiComponent::wifi_scan_start_(bool passive) { + // Compiles to nothing here; kept so every scan_result_ mutation holds the lock + ScanResultsLock lock(this); this->scan_result_.clear(); this->scan_done_ = false; s_scan_result_count = 0; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 03175bf2cc..b794254e12 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -257,6 +257,7 @@ #define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 #define BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE 16 #define USE_CAPTIVE_PORTAL +#define USE_WIFI_SCAN_RESULTS_LOCK #define USE_ESP32_BLE #define USE_ESP32_BLE_MAX_CONNECTIONS 3 #define USE_ESP32_BLE_CLIENT @@ -393,6 +394,7 @@ #define USE_ESP8266_CRASH_HANDLER #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 1, 2) #define USE_CAPTIVE_PORTAL +#define USE_WIFI_SCAN_RESULTS_LOCK #define USE_ESP8266_LOGGER_SERIAL #define USE_ESP8266_LOGGER_SERIAL1 #define USE_ESP8266_PREFERENCES_FLASH @@ -448,6 +450,7 @@ #ifdef USE_LIBRETINY #define USE_BK72XX_BLE #define USE_CAPTIVE_PORTAL +#define USE_WIFI_SCAN_RESULTS_LOCK #define USE_SOCKET_IMPL_LWIP_SOCKETS #define USE_LWIP_FAST_SELECT #define USE_WEBSERVER From 43526a815e65c9e2b915515581044615127bcd15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Jul 2026 21:42:21 -1000 Subject: [PATCH 1079/1815] [git] Fix submodule update failure when cloning libraries on the esp-idf toolchain (#17862) --- esphome/espidf/framework.py | 26 +- esphome/git.py | 194 ++++++--- esphome/platformio/library.py | 2 +- tests/unit_tests/test_espidf_framework.py | 33 +- tests/unit_tests/test_git.py | 500 +++++++++++++++++++--- 5 files changed, 614 insertions(+), 141 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index aedbeba69e..3a594c738c 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -397,9 +397,10 @@ def _clone_idf_with_submodules( handles branches, tags, and SHAs uniformly (mirrors the approach in ``esphome.git.clone_or_update``). """ - from esphome.git import run_git_command + from esphome.git import run_git_command, update_submodules - _LOGGER.info("Cloning ESP-IDF from %s%s", git_url, f"@{ref}" if ref else "") + key = f"{git_url}@{ref}" if ref else git_url + _LOGGER.info("Cloning ESP-IDF from %s", key) run_git_command(["git", "clone", "--depth=1", "--", git_url, str(framework_path)]) if ref: run_git_command( @@ -410,25 +411,14 @@ def _clone_idf_with_submodules( ["git", "reset", "--hard", "FETCH_HEAD"], git_dir=framework_path, ) - run_git_command( - [ - "git", - "submodule", - "update", - "--init", - "--recursive", - "--depth=1", - ], - git_dir=framework_path, - ) + update_submodules(framework_path, key) - # Sanity-check the resulting tree. run_git_command only raises when - # stderr is non-empty, so a clone that silently produces no working - # tree would otherwise be marked extracted and stuck until - # ``esphome clean``. + # Sanity-check the resulting tree: a clone can exit 0 yet produce no + # usable ESP-IDF checkout, which would otherwise be marked extracted and + # stuck until ``esphome clean``. if not (framework_path / "tools" / "idf_tools.py").is_file(): raise RuntimeError( - f"Clone of {git_url} produced no usable ESP-IDF tree at {framework_path}" + f"Clone of {key} produced no usable ESP-IDF tree at {framework_path}" ) diff --git a/esphome/git.py b/esphome/git.py index 0c1ad56367..46cce50d9d 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -2,6 +2,7 @@ from collections.abc import Callable from dataclasses import dataclass import hashlib import logging +import os from pathlib import Path import re import subprocess @@ -11,7 +12,7 @@ import urllib.parse import esphome.config_validation as cv from esphome.core import CORE, EsphomeError, TimePeriodSeconds -from esphome.helpers import rmtree, write_file +from esphome.helpers import add_git_ceiling_directory, rmtree, write_file _LOGGER = logging.getLogger(__name__) @@ -26,6 +27,24 @@ NEVER_REFRESH = TimePeriodSeconds(seconds=-1) # it does not pollute the worktree. _CLONE_COMPLETE_MARKER = "esphome_clone_complete" +# Environment variables that scope git to a specific repository. Git hooks and +# some CI wrappers export these; if they leak into the git commands run here, +# git binds to the caller's repository instead of the one being managed. The +# effects range from loud (`git clone` producing a bare-style directory with +# no working tree) to silent (an ambient GIT_INDEX_FILE makes +# `git submodule update --init` exit 0 without initializing anything). +_GIT_REPO_SCOPING_ENV = frozenset( + { + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_COMMON_DIR", + "GIT_NAMESPACE", + } +) + class GitException(cv.Invalid): """Base exception for git-related errors.""" @@ -43,32 +62,61 @@ class GitRepositoryError(GitException): """Exception raised when a git repository is in an invalid state.""" -def run_git_command(cmd: list[str], git_dir: Path | None = None) -> str: - if git_dir is not None: - _LOGGER.debug( - "Running git command with repository isolation: %s (git_dir=%s)", - " ".join(cmd), - git_dir, - ) - else: - _LOGGER.debug("Running git command: %s", " ".join(cmd)) +def _redact_url_credentials(text: str) -> str: + """Mask userinfo in any URLs embedded in ``text``. - # Set up environment for repository isolation if git_dir is provided - # Force git to only operate on this specific repository by setting - # GIT_DIR and GIT_WORK_TREE. This prevents git from walking up the - # directory tree to find parent repositories when the target repo's - # .git directory is corrupt. Without this, commands like 'git stash' - # could accidentally operate on parent repositories (e.g., the main - # ESPHome repo) instead of failing, causing data loss. - env: dict[str, str] | None = None - cwd: str | None = None + Users can put credentials directly in a git URL, and log output is + routinely pasted into public issues. + """ + return re.sub(r"://[^/@\s]+@", "://***@", text) + + +def run_git_command( + cmd: list[str], git_dir: Path | None = None, *, cwd: Path | None = None +) -> str: + """Run a git command and return its stdout. + + The repository-scoping environment variables in ``_GIT_REPO_SCOPING_ENV`` + are always stripped. ``git_dir`` additionally pins GIT_DIR/GIT_WORK_TREE + to that repository and runs the command there; ``cwd`` alone runs the + command in that directory with GIT_CEILING_DIRECTORIES capping repository + discovery at its parent. + """ + # Every invocation starts from an environment with the repository-scoping + # variables stripped (see _GIT_REPO_SCOPING_ENV) so a git hook or CI + # wrapper invoking ESPHome can never redirect these commands to its own + # repository or index. + # + # ``git_dir`` then re-adds GIT_DIR and GIT_WORK_TREE pointing at the + # managed repository. This prevents git from walking up the directory + # tree to find parent repositories when the target repo's .git directory + # is corrupt. Without this, commands like 'git stash' could accidentally + # operate on parent repositories (e.g., the main ESPHome repo) instead of + # failing, causing data loss. + # + # ``cwd`` (without ``git_dir``) runs the command in that directory + # without GIT_DIR/GIT_WORK_TREE. The ``git submodule`` porcelain needs + # this: on some installations (e.g. Windows setups where a shim hands + # git untranslated paths) it refuses to run when GIT_DIR/GIT_WORK_TREE + # are set, failing with "cannot be used without a working tree". + # GIT_CEILING_DIRECTORIES (which git only honors as an absolute path) + # keeps the parent-repo-walk protection instead: if the repo's .git is + # missing or corrupt, git fails rather than discovering an enclosing + # repository. + env = {k: v for k, v in os.environ.items() if k not in _GIT_REPO_SCOPING_ENV} if git_dir is not None: - env = { - **subprocess.os.environ, - "GIT_DIR": str(Path(git_dir) / ".git"), - "GIT_WORK_TREE": str(git_dir), - } - cwd = str(git_dir) + env["GIT_DIR"] = str(Path(git_dir) / ".git") + env["GIT_WORK_TREE"] = str(git_dir) + cwd = git_dir + elif cwd is not None: + add_git_ceiling_directory(env, Path(cwd).absolute().parent) + + _LOGGER.debug( + "Running git command: %s (cwd=%s, isolated=%s)", + _redact_url_credentials(" ".join(cmd)), + cwd, + git_dir is not None, + ) try: ret = subprocess.run( @@ -86,12 +134,17 @@ def run_git_command(cmd: list[str], git_dir: Path | None = None) -> str: "for installation instructions." ) from err - if ret.returncode != 0 and ret.stderr: - err_str = ret.stderr.decode("utf-8") - lines = [x.strip() for x in err_str.splitlines()] - if lines[-1].startswith("fatal:"): - raise GitCommandError(lines[-1][len("fatal: ") :]) - raise GitCommandError(err_str) + if ret.returncode != 0: + if ret.stderr: + err_str = ret.stderr.decode("utf-8") + lines = [x.strip() for x in err_str.splitlines()] + if lines[-1].startswith("fatal:"): + raise GitCommandError(lines[-1][len("fatal: ") :]) + raise GitCommandError(err_str) + raise GitCommandError( + f"git exited with code {ret.returncode}: " + f"{_redact_url_credentials(' '.join(cmd))}" + ) return ret.stdout.decode("utf-8").strip() @@ -123,6 +176,27 @@ def _remove_repo_dir(repo_dir: Path) -> None: rmtree(repo_dir) +def update_submodules(repo_dir: Path, key: str) -> None: + """Initialize/update every submodule the repository declares, recursively, + matching how PlatformIO clones libraries. + + Most repositories declare no submodules, so this does nothing when there + is no ``.gitmodules`` file. Which submodules get populated is git's own + policy (``update = none``, ``submodule.active``, sparse checkouts); + git's exit code is the error signal. + + Runs with plain ``cwd`` rather than ``git_dir`` isolation, which the + ``git submodule`` porcelain does not tolerate (see ``run_git_command``). + """ + if not (repo_dir / ".gitmodules").is_file(): + return + _LOGGER.info("Updating submodules for %s", _redact_url_credentials(key)) + run_git_command( + ["git", "submodule", "update", "--init", "--recursive", "--depth=1"], + cwd=repo_dir, + ) + + def resolve_symlink_stub(repo_dir: Path, file_path: Path) -> Path | None: """Return the symlink target if ``file_path`` is a Windows-checked-out symlink stub. @@ -217,12 +291,19 @@ def clone_or_update( domain: str, username: str = None, password: str = None, - submodules: list[str] | None = None, + init_submodules: bool = False, subpath: Path | None = None, _recover_broken: bool = True, ) -> tuple[Path, Callable[[], None] | None]: key = f"{url}@{ref}" + # The user may have embedded credentials in the URL itself; log this + # instead of key. + safe_key = _redact_url_credentials(key) + # Keep the caller's URL for the recovery re-clone below: rewriting the + # rewritten URL would double the userinfo, and the recursive call must + # compute the same cache key as this one. + original_url = url if username is not None and password is not None: url = url.replace( "://", f"://{urllib.parse.quote(username)}:{urllib.parse.quote(password)}@" @@ -238,12 +319,12 @@ def clone_or_update( # predates the marker; either way it cannot be trusted, especially # with NEVER_REFRESH where it would otherwise be reused forever. _LOGGER.warning( - "Removing incomplete clone of %s at %s, will re-clone", key, repo_dir + "Removing incomplete clone of %s at %s, will re-clone", safe_key, repo_dir ) _remove_repo_dir(repo_dir) if not repo_dir.is_dir(): - _LOGGER.info("Cloning %s", key) + _LOGGER.info("Cloning %s", safe_key) _LOGGER.debug("Location: %s", repo_dir) try: cmd = ["git", "clone", "--depth=1"] @@ -262,15 +343,8 @@ def clone_or_update( ["git", "reset", "--hard", "FETCH_HEAD"], git_dir=repo_dir ) - if submodules is not None: - _LOGGER.info( - "Initializing submodules (%s) for %s", ", ".join(submodules), key - ) - run_git_command( - ["git", "submodule", "update", "--init", "--depth=1", "--"] - + submodules, - git_dir=repo_dir, - ) + if init_submodules: + update_submodules(repo_dir, key) except GitException: # Remove incomplete clone to prevent stale state. Without this, @@ -290,12 +364,12 @@ def clone_or_update( ) except EsphomeError as err: _LOGGER.warning( - "Could not write clone completion marker for %s: %s", key, err + "Could not write clone completion marker for %s: %s", safe_key, err ) else: if refresh == NEVER_REFRESH or CORE.skip_external_update: - _LOGGER.debug("Skipping update for %s (refresh disabled)", key) + _LOGGER.debug("Skipping update for %s (refresh disabled)", safe_key) return repo_dir, None file_timestamp = Path(repo_dir / ".git" / "FETCH_HEAD") @@ -319,7 +393,7 @@ def clone_or_update( ["git", "rev-parse", "HEAD"], git_dir=repo_dir ) - _LOGGER.info("Updating %s", key) + _LOGGER.info("Updating %s", safe_key) _LOGGER.debug("Location: %s", repo_dir) # Stash local changes (if any) @@ -345,19 +419,25 @@ def clone_or_update( ["git", "reset", "--hard", "FETCH_HEAD"], git_dir=repo_dir, ) + + # Inside the try so a submodule failure routes through the + # recovery re-clone below instead of leaving a repo that the + # refresh window would silently accept on the next run. + if init_submodules: + update_submodules(repo_dir, key) except GitException as err: # Repository is in a broken state or update failed # Only attempt recovery once to prevent infinite recursion if not _recover_broken: _LOGGER.error( "Repository %s recovery failed, cannot retry (already attempted once)", - key, + safe_key, ) raise _LOGGER.warning( "Repository %s has issues (%s), attempting recovery", - key, + safe_key, err, ) _LOGGER.info("Removing broken repository at %s", repo_dir) @@ -367,31 +447,21 @@ def clone_or_update( # Recursively call clone_or_update to re-clone # Set _recover_broken=False to prevent infinite recursion result = clone_or_update( - url=url, + url=original_url, ref=ref, refresh=refresh, domain=domain, username=username, password=password, - submodules=submodules, + init_submodules=init_submodules, subpath=subpath, _recover_broken=False, ) - _LOGGER.info("Repository %s successfully recovered", key) + _LOGGER.info("Repository %s successfully recovered", safe_key) return result - if submodules is not None: - _LOGGER.info( - "Updating submodules (%s) for %s", ", ".join(submodules), key - ) - run_git_command( - ["git", "submodule", "update", "--init", "--depth=1", "--"] - + submodules, - git_dir=repo_dir, - ) - def revert(): - _LOGGER.info("Reverting changes to %s -> %s", key, old_sha) + _LOGGER.info("Reverting changes to %s -> %s", safe_key, old_sha) run_git_command(["git", "reset", "--hard", old_sha], git_dir=repo_dir) return repo_dir, revert diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 7c8566b77a..1a523ce0ab 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -134,7 +134,7 @@ class GitSource(Source): ref=self.ref, refresh=git.NEVER_REFRESH if not force else None, domain=domain, - submodules=[], + init_submodules=True, subpath=Path(dir_suffix), ) return path diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index 7de1557bd6..59872bf233 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -137,10 +137,17 @@ def test_parse_git_source_rejected(source: str) -> None: assert _parse_git_source(source) is None -def _make_idf_tree(framework_path: Path) -> None: - """Create the minimum tree _clone_idf_with_submodules sanity-checks for.""" +def _make_idf_tree(framework_path: Path, *, gitmodules: bool = True) -> None: + """Create the minimum tree _clone_idf_with_submodules sanity-checks for. + + ``gitmodules=False`` simulates a fork that vendors components in-tree + instead of declaring submodules; update_submodules skips the git call + when that file is missing. + """ (framework_path / "tools").mkdir(parents=True) (framework_path / "tools" / "idf_tools.py").write_text("# stub\n") + if gitmodules: + (framework_path / ".gitmodules").write_text("# stub\n") def test_clone_idf_with_submodules_without_ref(tmp_path: Path) -> None: @@ -214,6 +221,28 @@ def test_clone_idf_with_submodules_raises_when_tree_missing( ) +def test_clone_idf_accepts_flattened_fork_without_gitmodules( + tmp_path: Path, +) -> None: + """A fork that vendors components in-tree instead of as submodules is valid. + + No .gitmodules means the submodule step is skipped entirely. + """ + framework_path = tmp_path / "idf" + framework_path.mkdir() + _make_idf_tree(framework_path, gitmodules=False) + + with patch("esphome.git.run_git_command", return_value="") as run_git_command_mock: + _clone_idf_with_submodules( + framework_path, + "https://github.com/example/flattened-esp-idf.git", + None, + ) + + calls = [c.args[0] for c in run_git_command_mock.call_args_list] + assert not any(c[1] == "submodule" for c in calls) + + # --------------------------------------------------------------------------- # Helpers for _tar_extract_all hard-link prefix-stripping tests # --------------------------------------------------------------------------- diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index c9e0339ad7..858eee5e9f 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -1,8 +1,10 @@ """Tests for git.py module.""" from collections.abc import Callable +import logging import os from pathlib import Path +import subprocess import time from typing import Any from unittest.mock import Mock, patch @@ -71,19 +73,40 @@ def _simulate_cloned_repo(repo_dir: Path) -> None: (repo_dir / ".git").mkdir(exist_ok=True) -def _make_clone_side_effect(repo_dir: Path) -> Callable[..., str]: - """Return a run_git_command side effect whose clone creates the repo dir.""" +def _make_clone_side_effect( + repo_dir: Path, gitmodules: bool = False +) -> Callable[..., str]: + """Return a run_git_command side effect whose clone creates the repo dir. + + With ``gitmodules`` the cloned repo also declares submodules. + """ def git_command_side_effect( cmd: list[str], cwd: str | None = None, **kwargs: Any ) -> str: if _get_git_command_type(cmd) == "clone": _simulate_cloned_repo(repo_dir) + if gitmodules: + (repo_dir / ".gitmodules").write_text("test") return "" return git_command_side_effect +def _submodule_calls(mock: Mock) -> list[Any]: + """Return the mock's `git submodule` calls.""" + return [ + c for c in mock.call_args_list if _get_git_command_type(c[0][0]) == "submodule" + ] + + +def _assert_submodule_runs_without_isolation(call: Any, repo_dir: Path) -> None: + """Assert a git submodule call ran with plain cwd, not GIT_DIR/GIT_WORK_TREE + isolation, which breaks the submodule porcelain on some installations.""" + assert call.kwargs.get("git_dir") is None + assert call.kwargs.get("cwd") == repo_dir + + def test_run_git_command_success(tmp_path: Path) -> None: """Test that run_git_command returns output on success.""" # Create a simple git repo to test with @@ -100,6 +123,22 @@ def test_run_git_command_success(tmp_path: Path) -> None: assert isinstance(result, str) +def test_run_git_command_debug_log_redacts_credentials( + tmp_path: Path, mock_subprocess_run: Mock, caplog: pytest.LogCaptureFixture +) -> None: + """Embedded URL credentials never reach the debug log; -v output is + routinely pasted into public issues. subprocess is mocked so no real + git ever sees the URL (the path is not creatable on Windows).""" + mock_subprocess_run.return_value = Mock(returncode=0, stdout=b"", stderr=b"") + with caplog.at_level(logging.DEBUG, logger="esphome.git"): + git.run_git_command( + ["git", "clone", "https://user:hunter2@github.com/test/repo"], + cwd=tmp_path, + ) + assert "hunter2" not in caplog.text + assert "://***@github.com/test/repo" in caplog.text + + def test_run_git_command_with_git_dir_isolation( tmp_path: Path, mock_subprocess_run: Mock ) -> None: @@ -116,10 +155,17 @@ def test_run_git_command_with_git_dir_isolation( stderr=b"", ) - result = git.run_git_command( - ["git", "rev-parse", "HEAD"], - git_dir=repo_dir, - ) + # Ambient repo-scoping vars simulate a git hook invoking ESPHome; an + # ambient GIT_INDEX_FILE surviving into a git_dir invocation fails + # silently (git operates on the caller's index and exits 0). + with patch.dict( + os.environ, + {"GIT_INDEX_FILE": "/caller/index", "GIT_OBJECT_DIRECTORY": "/caller/objects"}, + ): + result = git.run_git_command( + ["git", "rev-parse", "HEAD"], + git_dir=repo_dir, + ) # Verify subprocess.run was called assert mock_subprocess_run.called @@ -131,6 +177,9 @@ def test_run_git_command_with_git_dir_isolation( assert "GIT_WORK_TREE" in env assert env["GIT_DIR"] == str(repo_dir / ".git") assert env["GIT_WORK_TREE"] == str(repo_dir) + # The ambient scoping vars must be stripped, not passed through. + assert "GIT_INDEX_FILE" not in env + assert "GIT_OBJECT_DIRECTORY" not in env assert result == "test output" @@ -216,6 +265,89 @@ def test_run_git_command_without_git_dir(mock_subprocess_run: Mock) -> None: assert result == "Cloning into 'test_repo'..." +@pytest.mark.parametrize("relative", [False, True], ids=["absolute", "relative"]) +def test_run_git_command_with_cwd_runs_in_dir_without_isolation( + tmp_path: Path, + mock_subprocess_run: Mock, + monkeypatch: pytest.MonkeyPatch, + relative: bool, +) -> None: + """The cwd parameter sets the working directory without GIT_DIR/GIT_WORK_TREE. + + Ambient GIT_DIR/GIT_WORK_TREE (e.g. from a git hook or CI wrapper) must be + stripped too, and GIT_CEILING_DIRECTORIES must stop git from walking up to + an enclosing repository if the target repo's .git is missing or corrupt. + Git silently ignores a relative ceiling entry, so the variable must come + out absolute even when the given cwd is relative. + """ + repo_dir = tmp_path / "test_repo" + repo_dir.mkdir() + if relative: + monkeypatch.chdir(tmp_path) + cwd_arg = Path("test_repo") + else: + cwd_arg = repo_dir + + mock_subprocess_run.return_value = Mock( + returncode=0, + stdout=b"test output", + stderr=b"", + ) + + with patch.dict( + os.environ, + { + "GIT_DIR": "/ambient/.git", + "GIT_WORK_TREE": "/ambient", + "GIT_INDEX_FILE": "/ambient/.git/index", + }, + ): + result = git.run_git_command(["git", "submodule", "update"], cwd=cwd_arg) + + call_args = mock_subprocess_run.call_args + env = call_args[1]["env"] + assert "GIT_DIR" not in env + assert "GIT_WORK_TREE" not in env + assert "GIT_INDEX_FILE" not in env + ceiling = Path(env["GIT_CEILING_DIRECTORIES"]) + assert ceiling.is_absolute() + assert ceiling.samefile(tmp_path) + assert call_args[1]["cwd"] == cwd_arg + assert result == "test output" + + +def test_run_git_command_raises_on_nonfatal_stderr( + tmp_path: Path, mock_subprocess_run: Mock +) -> None: + """Nonzero exit with stderr lacking a fatal: prefix raises with full stderr.""" + mock_subprocess_run.return_value = Mock( + returncode=1, + stdout=b"", + stderr=b"error: pathspec 'nope' did not match any file(s)\n", + ) + + with pytest.raises(GitCommandError, match="did not match"): + git.run_git_command(["git", "checkout", "nope"], git_dir=tmp_path) + + +def test_run_git_command_raises_on_nonzero_exit_without_stderr( + tmp_path: Path, mock_subprocess_run: Mock +) -> None: + """A nonzero exit must raise even when git printed nothing to stderr. + + Silent nonzero exits were previously treated as success, which is how + broken checkouts could be cached as complete. + """ + mock_subprocess_run.return_value = Mock( + returncode=1, + stdout=b"", + stderr=b"", + ) + + with pytest.raises(GitCommandError, match="exited with code 1"): + git.run_git_command(["git", "submodule", "update"], cwd=tmp_path) + + def test_run_git_command_without_git_dir_raises_error( mock_subprocess_run: Mock, ) -> None: @@ -1156,46 +1288,6 @@ def test_clone_with_ref_uses_shallow_fetch( 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" @@ -1220,10 +1312,91 @@ def test_refresh_fetch_is_shallow(tmp_path: Path, mock_run_git_command: Mock) -> assert cmd[-1] == ref -def test_refresh_submodule_update_is_shallow( +@pytest.mark.parametrize( + "refresh", [None, TimePeriodSeconds(days=1)], ids=["clone", "refresh"] +) +def test_all_submodules_skipped_without_gitmodules( + tmp_path: Path, mock_run_git_command: Mock, refresh: TimePeriodSeconds | None +) -> None: + """init_submodules is a no-op for repos with no .gitmodules. + + This is the esp-idf toolchain library scenario from issue #17860: the + PlatformIO library converter requests "all submodules" for every git + library, and most libraries declare none. The git submodule porcelain + must not run at all in that case — it fails outright on some git + installations. + """ + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + + if refresh is None: + mock_run_git_command.side_effect = _make_clone_side_effect(repo_dir) + else: + _setup_old_repo(repo_dir) + mock_run_git_command.return_value = "abc123" + + git.clone_or_update( + url=url, + ref=None, + refresh=refresh, + domain=domain, + init_submodules=True, + ) + + assert not _submodule_calls(mock_run_git_command) + + +@pytest.mark.parametrize( + "refresh", [None, TimePeriodSeconds(days=1)], ids=["clone", "refresh"] +) +def test_all_submodules_updated_with_gitmodules( + tmp_path: Path, mock_run_git_command: Mock, refresh: TimePeriodSeconds | None +) -> None: + """init_submodules initializes all submodules when .gitmodules exists.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + + if refresh is None: + mock_run_git_command.side_effect = _make_clone_side_effect( + repo_dir, gitmodules=True + ) + else: + _setup_old_repo(repo_dir) + (repo_dir / ".gitmodules").write_text("test") + mock_run_git_command.return_value = "abc123" + + git.clone_or_update( + url=url, + ref=None, + refresh=refresh, + domain=domain, + init_submodules=True, + ) + + submodule_calls = _submodule_calls(mock_run_git_command) + # Which submodules get populated is git's own policy, so no status + # verification follows the update. + assert len(submodule_calls) == 1 + cmd = submodule_calls[0][0][0] + assert cmd[2] == "update" + assert "--depth=1" in cmd + # Recursive, mirroring PlatformIO's recursive library clones. + assert "--recursive" in cmd + _assert_submodule_runs_without_isolation(submodule_calls[0], repo_dir) + + +def test_recovery_reclone_keeps_credentials_and_cache_key( tmp_path: Path, mock_run_git_command: Mock ) -> None: - """The refresh-path submodule update should use --depth=1.""" + """The recovery re-clone must not re-apply credentials to the already + rewritten URL (no doubled userinfo) and must land in the same cache + directory, or a credentialed private repo re-clones on every run.""" CORE.config_path = tmp_path / "test.yaml" url = "https://github.com/test/repo" @@ -1231,24 +1404,235 @@ def test_refresh_submodule_update_is_shallow( repo_dir = _compute_repo_dir(url, None, domain) _setup_old_repo(repo_dir) - mock_run_git_command.return_value = "abc123" + (repo_dir / ".gitmodules").write_text("test") - git.clone_or_update( + calls = {"submodule": 0} + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + if _get_git_command_type(cmd) == "clone": + _simulate_cloned_repo(repo_dir) + if _get_git_command_type(cmd) == "submodule": + calls["submodule"] += 1 + if calls["submodule"] == 1: + raise git.GitCommandError("git submodule update exited with code 1") + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + recovered_dir, _ = git.clone_or_update( url=url, ref=None, refresh=TimePeriodSeconds(days=1), domain=domain, - submodules=["components/foo"], + username="user", + password="hunter2", + init_submodules=True, ) - submodule_calls = [ - c for c in mock_run_git_command.call_args_list if "submodule" in c[0][0] + assert recovered_dir == repo_dir + clone_cmds = [ + c[0][0] + for c in mock_run_git_command.call_args_list + if _get_git_command_type(c[0][0]) == "clone" ] - 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") + assert clone_cmds + clone_url = clone_cmds[0][-2] + assert clone_url == "https://user:hunter2@github.com/test/repo" + assert clone_url.count("@") == 1 + + +def test_refresh_submodule_failure_recovers_then_raises( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A refresh-path submodule failure routes through the recovery re-clone. + + The broken repo is removed and re-cloned; when the submodule update fails + again on the fresh clone the cache entry is removed and the error + propagates, instead of leaving behind a repo the refresh window would + silently accept on the next run. + """ + 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) + (repo_dir / ".gitmodules").write_text("test") + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + if _get_git_command_type(cmd) == "clone": + _simulate_cloned_repo(repo_dir) + (repo_dir / ".gitmodules").write_text("test") + if _get_git_command_type(cmd) == "submodule": + raise git.GitCommandError("git submodule update exited with code 1") + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + with pytest.raises(git.GitCommandError, match="exited with code 1"): + git.clone_or_update( + url=url, + ref=None, + refresh=TimePeriodSeconds(days=1), + domain=domain, + init_submodules=True, + ) + + assert not repo_dir.is_dir() + # Recovery removed the repo and re-cloned before failing again. + assert any( + _get_git_command_type(c[0][0]) == "clone" + for c in mock_run_git_command.call_args_list + ) + + +def _real_git(*args: str, cwd: Path) -> None: + """Run real git to build a test fixture repository.""" + subprocess.run( + [ + "git", + "-c", + "user.email=test@test.invalid", + "-c", + "user.name=test", + "-c", + "commit.gpgsign=false", + "-c", + "protocol.file.allow=always", + *args, + ], + cwd=cwd, + check=True, + capture_output=True, + ) + + +# Git blocks file-protocol submodules by default (CVE-2022-39253); the e2e +# tests allow them via GIT_CONFIG_* environment variables, which reach the +# child git processes through run_git_command's filtered environment. +_ALLOW_FILE_PROTOCOL_ENV = { + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "protocol.file.allow", + "GIT_CONFIG_VALUE_0": "always", +} + + +def _make_real_repo(path: Path, filename: str) -> None: + """Create a real git repository containing one committed file.""" + path.mkdir() + _real_git("init", "-q", cwd=path) + (path / filename).write_text("content") + _real_git("add", filename, cwd=path) + _real_git("commit", "-q", "-m", "init", cwd=path) + + +def _add_submodule( + repo: Path, url: Path, path: str, *, update_none: bool = False +) -> None: + """Add ``url`` as a submodule of ``repo`` at ``path`` and commit it.""" + _real_git("submodule", "add", str(url), path, cwd=repo) + if update_none: + _real_git( + "config", "-f", ".gitmodules", f"submodule.{path}.update", "none", cwd=repo + ) + _real_git("add", ".gitmodules", cwd=repo) + _real_git("commit", "-q", "-m", f"add submodule {path}", cwd=repo) + + +def test_clone_or_update_real_git_without_submodules(tmp_path: Path) -> None: + """End-to-end with real git: a repo with no .gitmodules clones cleanly. + + This is the issue #17860 scenario: requesting "all submodules" on a + submodule-less repository must not invoke the git submodule porcelain + and must produce a usable checkout. + """ + CORE.config_path = tmp_path / "test.yaml" + + upstream = tmp_path / "upstream" + _make_real_repo(upstream, "README.md") + + repo_dir, _ = git.clone_or_update( + url=str(upstream), + ref=None, + refresh=None, + domain="test_e2e", + init_submodules=True, + ) + + assert (repo_dir / "README.md").is_file() + + +def test_clone_or_update_real_git_initializes_submodules(tmp_path: Path) -> None: + """End-to-end with real git: submodules are actually checked out. + + Exercises the real `git submodule update` invocation, including the + env handling in run_git_command that the mocked tests cannot cover. + """ + CORE.config_path = tmp_path / "test.yaml" + + sub_repo = tmp_path / "sub" + _make_real_repo(sub_repo, "sub_file.txt") + + upstream = tmp_path / "upstream" + _make_real_repo(upstream, "README.md") + _add_submodule(upstream, sub_repo, "vendor/sub") + + with patch.dict(os.environ, _ALLOW_FILE_PROTOCOL_ENV): + repo_dir, _ = git.clone_or_update( + url=str(upstream), + ref=None, + refresh=None, + domain="test_e2e", + init_submodules=True, + ) + + assert (repo_dir / "vendor" / "sub" / "sub_file.txt").is_file() + + +def test_clone_or_update_real_git_honors_update_none_submodule( + tmp_path: Path, +) -> None: + """End-to-end with real git: submodules declared `update = none` stay skipped. + + Shows git itself skipping the declared paths at both nesting levels + (and exiting 0) while the regular submodules check out. + """ + CORE.config_path = tmp_path / "test.yaml" + + sub_repo = tmp_path / "sub" + _make_real_repo(sub_repo, "sub_file.txt") + + # Intermediate submodule that itself declares a skipped nested submodule. + mid_repo = tmp_path / "mid" + _make_real_repo(mid_repo, "mid_file.txt") + _add_submodule(mid_repo, sub_repo, "vendor/leaf", update_none=True) + + upstream = tmp_path / "upstream" + _make_real_repo(upstream, "README.md") + _add_submodule(upstream, sub_repo, "vendor/sub") + _add_submodule(upstream, sub_repo, "vendor/skipped", update_none=True) + _add_submodule(upstream, mid_repo, "vendor/mid") + + with patch.dict(os.environ, _ALLOW_FILE_PROTOCOL_ENV): + repo_dir, _ = git.clone_or_update( + url=str(upstream), + ref=None, + refresh=None, + domain="test_e2e", + init_submodules=True, + ) + + assert (repo_dir / "vendor" / "sub" / "sub_file.txt").is_file() + assert not (repo_dir / "vendor" / "skipped" / "sub_file.txt").exists() + assert (repo_dir / "vendor" / "mid" / "mid_file.txt").is_file() + assert not ( + repo_dir / "vendor" / "mid" / "vendor" / "leaf" / "sub_file.txt" + ).exists() def test_refresh_picks_up_new_remote_commits( From a930baab7c88f2c8ab5c2bbdae917ee071c3c7e0 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:42:44 +1200 Subject: [PATCH 1080/1815] [captive_portal] Escape SSID when building config JSON (#17872) --- .../captive_portal/captive_portal.cpp | 11 +- .../components/captive_portal/json_escape.h | 85 ++++++++++++++ tests/components/captive_portal/__init__.py | 23 ++++ .../captive_portal/json_escape_test.cpp | 107 ++++++++++++++++++ 4 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 esphome/components/captive_portal/json_escape.h create mode 100644 tests/components/captive_portal/__init__.py create mode 100644 tests/components/captive_portal/json_escape_test.cpp diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 228fdf7934..e6a63b8275 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -4,6 +4,7 @@ #include "esphome/core/application.h" #include "esphome/components/wifi/wifi_component.h" #include "captive_index.h" +#include "json_escape.h" namespace esphome::captive_portal { @@ -24,6 +25,9 @@ void CaptivePortal::handle_config(AsyncWebServerRequest *request) { stream->printf(R"({"mac":"%s","name":"%s","aps":[{})", mac_str, App.get_name().c_str()); #endif + // An SSID can contain a " or \ that would break the JSON, so escape it before writing it out. An SSID is at most + // 32 bytes (IEEE 802.11), so this is large enough that nothing is ever dropped. Reused for every scan result. + char escaped_ssid[32 * JSON_ESCAPE_MAX_EXPANSION + 1]; { // Invariant: only bounded in-memory work under the lock; the network send // happens later in request->send() @@ -32,18 +36,17 @@ void CaptivePortal::handle_config(AsyncWebServerRequest *request) { if (scan.get_is_hidden()) continue; - // Assumes no " in ssid, possible unicode issues? + json_escape_into_buffer(escaped_ssid, scan.get_ssid()); #ifdef USE_ESP8266 stream->print(ESPHOME_F(",{\"ssid\":\"")); - stream->print(scan.get_ssid().c_str()); + stream->print(escaped_ssid); stream->print(ESPHOME_F("\",\"rssi\":")); stream->print(scan.get_rssi()); stream->print(ESPHOME_F(",\"lock\":")); stream->print(scan.get_with_auth()); stream->print(ESPHOME_F("}")); #else - stream->printf(R"(,{"ssid":"%s","rssi":%d,"lock":%d})", scan.get_ssid().c_str(), scan.get_rssi(), - scan.get_with_auth()); + stream->printf(R"(,{"ssid":"%s","rssi":%d,"lock":%d})", escaped_ssid, scan.get_rssi(), scan.get_with_auth()); #endif } } diff --git a/esphome/components/captive_portal/json_escape.h b/esphome/components/captive_portal/json_escape.h new file mode 100644 index 0000000000..0b3c71cd74 --- /dev/null +++ b/esphome/components/captive_portal/json_escape.h @@ -0,0 +1,85 @@ +#pragma once +#include +#include +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" + +namespace esphome::captive_portal { + +/// Largest number of output bytes a single input byte can expand to (a \u00XX sequence). +static constexpr size_t JSON_ESCAPE_MAX_EXPANSION = 6; + +/// Copy value into buf, escaping the characters that cannot appear raw inside a JSON string literal. +/// +/// Escapes " and \ along with the control characters below 0x20, using the short forms where JSON defines one and +/// \u00XX otherwise. Bytes >= 0x20 are copied verbatim, so text containing valid UTF-8 survives intact. The result is +/// always null terminated; anything that would not fit is dropped rather than written partially. Returns buf so the +/// call can be used directly as an argument. +/// +/// To size buf so that no input is ever dropped, allow JSON_ESCAPE_MAX_EXPANSION bytes per input byte plus one for +/// the null terminator. +inline const char *json_escape_into_buffer(std::span buf, StringRef value) { + if (buf.empty()) + return ""; + // Reserve one byte for the null terminator. + const size_t limit = buf.size() - 1; + size_t pos = 0; + for (char ch : value) { + auto c = static_cast(ch); + // Every short form is a backslash followed by a single character, so only that character is needed here. Keeping + // it a char rather than a string avoids putting the sequences in read only data, which is RAM on the ESP8266. + char escape = '\0'; + switch (c) { + case '"': + escape = '"'; + break; + case '\\': + escape = '\\'; + break; + case '\n': + escape = 'n'; + break; + case '\r': + escape = 'r'; + break; + case '\t': + escape = 't'; + break; + case '\b': + escape = 'b'; + break; + case '\f': + escape = 'f'; + break; + default: + break; + } + if (escape != '\0') { + if (pos + 2 > limit) + break; + buf[pos++] = '\\'; + buf[pos++] = escape; + } else if (c < 0x20) { + // Remaining control characters have no short form and must be written as \u00XX. The value is below 0x20, so + // the two high hex digits are always zero. + if (pos + JSON_ESCAPE_MAX_EXPANSION > limit) + break; + buf[pos++] = '\\'; + buf[pos++] = 'u'; + buf[pos++] = '0'; + buf[pos++] = '0'; + buf[pos++] = format_hex_char(static_cast(c >> 4)); + buf[pos++] = format_hex_char(static_cast(c & 0x0F)); + } else { + if (pos + 1 > limit) + break; + buf[pos++] = static_cast(c); + } + } + buf[pos] = '\0'; + return buf.data(); +} + +} // namespace esphome::captive_portal diff --git a/tests/components/captive_portal/__init__.py b/tests/components/captive_portal/__init__.py new file mode 100644 index 0000000000..b13c81912c --- /dev/null +++ b/tests/components/captive_portal/__init__.py @@ -0,0 +1,23 @@ +"""Test-manifest overrides for the captive_portal C++ unit tests. + +``json_escape`` lives in a standalone, dependency-free header +(``esphome/components/captive_portal/json_escape.h``). The rest of the +captive_portal component and its auto-loaded dependencies (``web_server_base``, +``ota.web_server``) do not build for the ``host`` platform that the C++ unit +test harness targets. Strip those away and replace the real schema -- which is +restricted to non-host platforms via ``cv.only_on`` and requires a +``web_server_base`` instance via ``use_id`` -- with an empty one so the host +test config validates. ``to_code`` stays suppressed (the default), so +``USE_CAPTIVE_PORTAL`` is never defined and ``captive_portal.cpp`` compiles to an +empty translation unit; only ``json_escape.h`` is exercised by the test. +""" + +import esphome.config_validation as cv +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.auto_load = [] + manifest.dependencies = [] + manifest.config_schema = cv.Schema({}) + manifest.final_validate_schema = None diff --git a/tests/components/captive_portal/json_escape_test.cpp b/tests/components/captive_portal/json_escape_test.cpp new file mode 100644 index 0000000000..98b5ce4ff7 --- /dev/null +++ b/tests/components/captive_portal/json_escape_test.cpp @@ -0,0 +1,107 @@ +#include + +#include + +#include "esphome/components/captive_portal/json_escape.h" + +namespace esphome::captive_portal::testing { + +namespace { + +// Large enough that none of the inputs below are ever dropped. +constexpr size_t TEST_BUFFER_SIZE = 64 * JSON_ESCAPE_MAX_EXPANSION + 1; + +// Escape into a stack buffer and return the result as a string so the expectations stay readable. +std::string escape(const std::string &value) { + char buf[TEST_BUFFER_SIZE]; + return json_escape_into_buffer(buf, StringRef(value.c_str(), value.size())); +} + +} // namespace + +// Plain ASCII with no special characters is passed through unchanged. +TEST(CaptivePortalJsonEscape, PlainStringUnchanged) { + EXPECT_EQ(escape("MyNetwork"), "MyNetwork"); + EXPECT_EQ(escape(""), ""); +} + +// A double quote is escaped so it does not terminate the surrounding JSON string. +TEST(CaptivePortalJsonEscape, EscapesDoubleQuote) { + EXPECT_EQ(escape("a\"b"), "a\\\"b"); + // A double quote followed by other characters stays inside the JSON string. + EXPECT_EQ(escape("\">end"), "\\\">end"); +} + +// A backslash is doubled so it does not start an escape sequence in the output. +TEST(CaptivePortalJsonEscape, EscapesBackslash) { + EXPECT_EQ(escape("a\\b"), "a\\\\b"); + // A trailing backslash must not escape the closing quote of the JSON string. + EXPECT_EQ(escape("net\\"), "net\\\\"); +} + +// The control characters with short JSON forms use those forms. +TEST(CaptivePortalJsonEscape, EscapesShortFormControls) { + EXPECT_EQ(escape("\n"), "\\n"); + EXPECT_EQ(escape("\r"), "\\r"); + EXPECT_EQ(escape("\t"), "\\t"); + EXPECT_EQ(escape("\b"), "\\b"); + EXPECT_EQ(escape("\f"), "\\f"); +} + +// Other control characters (< 0x20) without a short form become \u00XX with lowercase hex. +TEST(CaptivePortalJsonEscape, EscapesOtherControlsAsUnicode) { + EXPECT_EQ(escape(std::string("\x00", 1)), "\\u0000"); + EXPECT_EQ(escape("\x01"), "\\u0001"); + EXPECT_EQ(escape("\x10"), "\\u0010"); + EXPECT_EQ(escape("\x1f"), "\\u001f"); + // 0x7f (DEL) is >= 0x20, so it is NOT escaped by this helper. + EXPECT_EQ(escape("\x7f"), "\x7f"); +} + +// Bytes >= 0x20, including multi-byte UTF-8 sequences, are passed through verbatim. +TEST(CaptivePortalJsonEscape, PassesThroughUtf8) { + // "café" in UTF-8 (é == 0xC3 0xA9). + EXPECT_EQ(escape("caf\xc3\xa9"), "caf\xc3\xa9"); + // Emoji (📶, 4-byte UTF-8) survives unchanged. + EXPECT_EQ(escape("\xf0\x9f\x93\xb6"), "\xf0\x9f\x93\xb6"); +} + +// A mix of special and normal characters is escaped in place without disturbing the rest. +TEST(CaptivePortalJsonEscape, MixedContent) { EXPECT_EQ(escape("a\"b\\c\nd"), "a\\\"b\\\\c\\nd"); } + +// A buffer sized at JSON_ESCAPE_MAX_EXPANSION bytes per input byte holds the worst case exactly. +TEST(CaptivePortalJsonEscape, WorstCaseInputFitsExactly) { + constexpr size_t input_len = 8; + char buf[input_len * JSON_ESCAPE_MAX_EXPANSION + 1]; + const std::string input(input_len, '\x01'); + std::string expected; + for (size_t i = 0; i < input_len; i++) + expected += "\\u0001"; + EXPECT_EQ(json_escape_into_buffer(buf, StringRef(input.c_str(), input.size())), expected); +} + +// An escape sequence that would not fit is dropped whole rather than written partially, and the result stays null +// terminated. +TEST(CaptivePortalJsonEscape, DropsEscapeThatWouldNotFit) { + // Room for one \u00XX sequence plus the null terminator, but two are requested. + char buf[JSON_ESCAPE_MAX_EXPANSION + 1]; + const std::string input(2, '\x01'); + const std::string result = json_escape_into_buffer(buf, StringRef(input.c_str(), input.size())); + EXPECT_EQ(result, "\\u0001"); + EXPECT_EQ(buf[JSON_ESCAPE_MAX_EXPANSION], '\0'); +} + +// Plain characters are truncated at the buffer size, leaving room for the null terminator. +TEST(CaptivePortalJsonEscape, TruncatesPlainInput) { + char buf[5]; + const std::string input(20, 'a'); + EXPECT_STREQ(json_escape_into_buffer(buf, StringRef(input.c_str(), input.size())), "aaaa"); +} + +// A zero length buffer cannot even hold a null terminator, so an empty string is returned instead of writing. +TEST(CaptivePortalJsonEscape, EmptyBufferIsSafe) { + const std::string input("test"); + EXPECT_STREQ(json_escape_into_buffer(std::span(), StringRef(input.c_str(), input.size())), ""); +} + +} // namespace esphome::captive_portal::testing From 899ed02ef2b57be8b4139cd53ebacc71e0aaa52e Mon Sep 17 00:00:00 2001 From: Samuel Sieb Date: Mon, 27 Jul 2026 06:52:15 -0700 Subject: [PATCH 1081/1815] [remote_base] support haier short IR message (#17826) Co-authored-by: Samuel Sieb --- esphome/components/remote_base/__init__.py | 9 ++++++++- .../components/remote_base/haier_protocol.cpp | 18 +++++++++++++----- .../remote_transmitter/common-buttons.yaml | 17 ++++++++++++++++- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index cbf82e6f44..19b8549f75 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -2012,7 +2012,14 @@ HaierData, HaierBinarySensor, HaierTrigger, HaierAction, HaierDumper = declare_p HaierAction = ns.class_("HaierAction", RemoteTransmitterActionBase) HAIER_SCHEMA = cv.Schema( { - cv.Required(CONF_CODE): cv.All([cv.hex_uint8_t], cv.Length(min=13, max=13)), + cv.Required(CONF_CODE): cv.All( + [cv.hex_uint8_t], + cv.Any( + cv.Length(min=8, max=8), + cv.Length(min=13, max=13), + msg="must be a list of length 8 or 13", + ), + ), } ) diff --git a/esphome/components/remote_base/haier_protocol.cpp b/esphome/components/remote_base/haier_protocol.cpp index fa4cec773f..8801f1049a 100644 --- a/esphome/components/remote_base/haier_protocol.cpp +++ b/esphome/components/remote_base/haier_protocol.cpp @@ -11,9 +11,12 @@ constexpr uint32_t HEADER_HIGH_US = 4400; constexpr uint32_t BIT_MARK_US = 540; constexpr uint32_t BIT_ONE_SPACE_US = 1650; constexpr uint32_t BIT_ZERO_SPACE_US = 580; -constexpr unsigned int HAIER_IR_PACKET_BIT_SIZE = 112; +// 8 bytes + checksum +constexpr unsigned int HAIER_IR_PACKET_BIT_SIZE_SHORT = 72; +// 13 bytes + checksum +constexpr unsigned int HAIER_IR_PACKET_BIT_SIZE_LONG = 112; // Max data bytes in packet (excluding checksum) -constexpr size_t HAIER_MAX_DATA_BYTES = (HAIER_IR_PACKET_BIT_SIZE / 8); +constexpr size_t HAIER_MAX_DATA_BYTES = HAIER_IR_PACKET_BIT_SIZE_LONG / 8 - 1; void HaierProtocol::encode_byte_(RemoteTransmitData *dst, uint8_t item) { for (uint8_t mask = 1 << 7; mask != 0; mask >>= 1) { @@ -28,7 +31,7 @@ void HaierProtocol::encode_byte_(RemoteTransmitData *dst, uint8_t item) { void HaierProtocol::encode(RemoteTransmitData *dst, const HaierData &data) { dst->set_carrier_frequency(38000); - dst->reserve(5 + ((data.data.size() + 1) * 2)); + dst->reserve(5 + ((data.data.size() + 1) * 16)); dst->mark(HEADER_LOW_US); dst->space(HEADER_LOW_US); dst->mark(HEADER_LOW_US); @@ -50,11 +53,16 @@ optional HaierProtocol::decode(RemoteReceiveData src) { return {}; } size_t size = src.size() - src.get_index() - 1; - if (size < HAIER_IR_PACKET_BIT_SIZE * 2) + if (size >= HAIER_IR_PACKET_BIT_SIZE_LONG * 2) { + size = HAIER_IR_PACKET_BIT_SIZE_LONG * 2; + } else if (size >= HAIER_IR_PACKET_BIT_SIZE_SHORT * 2) { + size = HAIER_IR_PACKET_BIT_SIZE_SHORT * 2; + } else { return {}; - size = HAIER_IR_PACKET_BIT_SIZE * 2; + } uint8_t checksum = 0; HaierData out; + out.data.reserve(size / 16 - 1); while (size > 0) { uint8_t data = 0; for (uint8_t mask = 0x80; mask != 0; mask >>= 1) { diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 5631c48f95..981946a9a4 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -198,7 +198,7 @@ button: 0xFF, ] - platform: template - name: Haier + name: Haier Long on_press: remote_transmitter.transmit_haier: code: @@ -217,6 +217,21 @@ button: 0x00, 0x05, ] + - platform: template + name: Haier Short + on_press: + remote_transmitter.transmit_haier: + code: + [ + 0xA6, + 0xDA, + 0x00, + 0x00, + 0x40, + 0x40, + 0x00, + 0x80, + ] - platform: template name: Mirage on_press: From fdc2974c0d05bd15f5e1ee64d8a0dc34dca016fa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:47:31 -0400 Subject: [PATCH 1082/1815] Bump docker/login-action from 4.4.0 to 4.5.1 in the docker-actions group across 1 directory (#17817) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-docker.yml | 4 ++-- .github/workflows/release.yml | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 926e96078b..da2d86f041 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -96,7 +96,7 @@ jobs: - name: Log in to the GitHub container registry if: steps.tag.outputs.push == 'true' - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 with: registry: ghcr.io username: ${{ github.actor }} @@ -154,7 +154,7 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to the GitHub container registry - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b8dbb7414d..5d7326588f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,12 +102,12 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to docker hub - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 with: registry: ghcr.io username: ${{ github.actor }} @@ -182,13 +182,13 @@ jobs: - name: Log in to docker hub if: matrix.registry == 'dockerhub' - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry if: matrix.registry == 'ghcr' - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 with: registry: ghcr.io username: ${{ github.actor }} From 071ef5016d14e04460971df9ae1c50014c6fbb0e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:47:45 -0400 Subject: [PATCH 1083/1815] Bump actions/setup-python from 6.3.0 to 7.0.0 in /.github/actions/restore-python (#17732) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index dcd2495809..daf041819c 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -17,7 +17,7 @@ runs: steps: - name: Set up Python ${{ inputs.python-version }} id: python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ inputs.python-version }} - name: Restore Python virtual environment From 345bd11a2c21e89ff01d957abe20cc7df3f0694c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:08:51 -0400 Subject: [PATCH 1084/1815] Bump ruff from 0.15.22 to 0.16.0 (#17818) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- AGENTS.md | 63 +++++++++++++++------- pyproject.toml | 1 + requirements_test.txt | 2 +- script/ci-custom.py | 32 ++++++----- tests/components/README.md | 3 ++ tests/integration/README.md | 8 +++ tests/script/test_docker_build.py | 10 ++-- tests/unit_tests/test_framework_helpers.py | 6 ++- 9 files changed, 88 insertions(+), 39 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index da424f516f..99a4f40201 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.15 + rev: v0.16.0 hooks: # Run the linter. - id: ruff diff --git a/AGENTS.md b/AGENTS.md index 75a9cdb2bf..0c98e5fe8e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -191,11 +191,14 @@ This document provides essential context for AI models interacting with this pro my_component_ns = cg.esphome_ns.namespace("my_component") MyComponent = my_component_ns.class_("MyComponent", cg.Component) - CONFIG_SCHEMA = cv.Schema({ - cv.GenerateID(): cv.declare_id(MyComponent), - cv.Required(CONF_KEY): cv.string, - cv.Optional(CONF_PARAM, default=42): cv.int_, - }).extend(cv.COMPONENT_SCHEMA) + CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(MyComponent), + cv.Required(CONF_KEY): cv.string, + cv.Optional(CONF_PARAM, default=42): cv.int_, + } + ).extend(cv.COMPONENT_SCHEMA) + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) @@ -229,7 +232,12 @@ This document provides essential context for AI models interacting with this pro - **Sensor:** ```python from esphome.components import sensor - CONFIG_SCHEMA = sensor.sensor_schema(MySensor).extend(cv.polling_component_schema("60s")) + + CONFIG_SCHEMA = sensor.sensor_schema(MySensor).extend( + cv.polling_component_schema("60s") + ) + + async def to_code(config): var = await sensor.new_sensor(config) await cg.register_component(var, config) @@ -238,7 +246,10 @@ This document provides essential context for AI models interacting with this pro - **Binary Sensor:** ```python from esphome.components import binary_sensor - CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend({ ... }) + + CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend({...}) + + async def to_code(config): var = await binary_sensor.new_binary_sensor(config) ``` @@ -246,7 +257,10 @@ This document provides essential context for AI models interacting with this pro - **Switch:** ```python from esphome.components import switch - CONFIG_SCHEMA = switch.switch_schema().extend({ ... }) + + CONFIG_SCHEMA = switch.switch_schema().extend({...}) + + async def to_code(config): var = await switch.new_switch(config) ``` @@ -263,10 +277,13 @@ This document provides essential context for AI models interacting with this pro ```python from esphome import automation - CONFIG_SCHEMA = cv.Schema({ - cv.GenerateID(): cv.declare_id(MyComponent), - cv.Optional(CONF_ON_STATE): automation.validate_automation({}), - }).extend(cv.COMPONENT_SCHEMA) + CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(MyComponent), + cv.Optional(CONF_ON_STATE): automation.validate_automation({}), + } + ).extend(cv.COMPONENT_SCHEMA) + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) @@ -316,11 +333,14 @@ This document provides essential context for AI models interacting with this pro ```python TurnOnTrigger = my_ns.class_("TurnOnTrigger", automation.Trigger.template()) - CONFIG_SCHEMA = cv.Schema({ - cv.Optional(CONF_ON_TURN_ON): automation.validate_automation( - {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TurnOnTrigger)} - ), - }) + CONFIG_SCHEMA = cv.Schema( + { + cv.Optional(CONF_ON_TURN_ON): automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TurnOnTrigger)} + ), + } + ) + async def to_code(config): for conf in config.get(CONF_ON_TURN_ON, []): @@ -617,6 +637,7 @@ This document provides essential context for AI models interacting with this pro _component_state = [] _use_feature = None + def enable_feature(): global _use_feature _use_feature = True @@ -636,20 +657,24 @@ This document provides essential context for AI models interacting with this pro DOMAIN = "my_component" + @dataclass class MyComponentData: feature_enabled: bool = False item_count: int = 0 items: list[str] = field(default_factory=list) + def _get_data() -> MyComponentData: if DOMAIN not in CORE.data: CORE.data[DOMAIN] = MyComponentData() return CORE.data[DOMAIN] + def request_feature() -> None: _get_data().feature_enabled = True + def add_item(item: str) -> None: _get_data().items.append(item) ``` @@ -707,7 +732,9 @@ This document provides essential context for AI models interacting with this pro ```python # Remove before 2026.6.0 if CONF_OLD_KEY in config: - _LOGGER.warning(f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0") + _LOGGER.warning( + f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0" + ) config[CONF_NEW_KEY] = config.pop(CONF_OLD_KEY) # Auto-migrate ``` ## 9. English Language diff --git a/pyproject.toml b/pyproject.toml index f38633b4ae..d38918a0a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -151,6 +151,7 @@ ignore = [ "PLR0912", # Too many branches ({branches} > {max_branches}) "PLR0913", # Too many arguments to function call ({c_args} > {max_args}) "PLR0915", # Too many statements ({statements} > {max_statements}) + "PLR0917", # Too many positional arguments ({c_pos} > {max_pos}) "PLW1641", # Object does not implement `__hash__` method "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target diff --git a/requirements_test.txt b/requirements_test.txt index 9a9b7adff5..e7de8eb5e7 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.6 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.22 # also change in .pre-commit-config.yaml when updating +ruff==0.16.0 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit diff --git a/script/ci-custom.py b/script/ci-custom.py index 4b16734ebe..90748a13b9 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -345,9 +345,11 @@ def lint_const_ordered(fname, content): ( mi, 1, - f"Constant {highlight(mline)} is not ordered, please make sure all " - f"constants are ordered. See line {mi} (should go to line {target}, " - f"{target_text})", + ( + f"Constant {highlight(mline)} is not ordered, please make sure all " + f"constants are ordered. See line {mi} (should go to line {target}, " + f"{target_text})" + ), ) ) return errs @@ -990,12 +992,14 @@ def lint_log_multiline_continuation(fname, content): ( lineno, col, - "Multi-line log message has a continuation line that does " - "not start with a space. The log viewer uses leading " - "whitespace to detect continuation lines and re-add the " - f"log tag prefix (e.g. {highlight('[C][component:042]:')}).\n" - "Either start the continuation with a space/indent, or " - "split into separate ESP_LOG* calls.", + ( + "Multi-line log message has a continuation line that does " + "not start with a space. The log viewer uses leading " + "whitespace to detect continuation lines and re-add the " + f"log tag prefix (e.g. {highlight('[C][component:042]:')}).\n" + "Either start the continuation with a space/indent, or " + "split into separate ESP_LOG* calls." + ), ) ) return errs @@ -1073,10 +1077,12 @@ def lint_test_package_key_matches_bus(fname, content): ( lineno, 1, - f"Package key {highlight(pkg_key)} does not match bus directory " - f"{highlight(bus_dir)}. The package key must match the directory " - f"name under tests/test_build_components/common/. " - f"Change {highlight(pkg_key)} to {highlight(bus_dir)}.", + ( + f"Package key {highlight(pkg_key)} does not match bus directory " + f"{highlight(bus_dir)}. The package key must match the directory " + f"name under tests/test_build_components/common/. " + f"Change {highlight(pkg_key)} to {highlight(bus_dir)}." + ), ) ) return errs diff --git a/tests/components/README.md b/tests/components/README.md index 145a3440d2..be5e887767 100644 --- a/tests/components/README.md +++ b/tests/components/README.md @@ -28,6 +28,7 @@ create an `__init__.py` in your component's test directory and define `override_ ```python from tests.testing_helpers import ComponentManifestOverride + def override_manifest(manifest: ComponentManifestOverride) -> None: # Re-enable the component's own to_code (needed when the component must # emit C++ setup code that the test binary depends on at link time). @@ -39,6 +40,7 @@ Or supply a lightweight stub instead of the real `to_code`: ```python from tests.testing_helpers import ComponentManifestOverride + def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code_testing(config): # Only emit what the C++ tests actually need @@ -54,6 +56,7 @@ e.g. `tests/components/my_sensor/sensor/__init__.py`): ```python from tests.testing_helpers import ComponentManifestOverride + def override_manifest(manifest: ComponentManifestOverride) -> None: manifest.enable_codegen() ``` diff --git a/tests/integration/README.md b/tests/integration/README.md index 4de08777b0..44d9e0d644 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -187,6 +187,7 @@ loop = asyncio.get_running_loop() states: dict[int, EntityState] = {} state_future: asyncio.Future[EntityState] = loop.create_future() + def on_state(state: EntityState) -> None: """This callback only receives NEW state changes, not initial states.""" states[state.key] = state @@ -195,6 +196,7 @@ def on_state(state: EntityState) -> None: if not state_future.done(): state_future.set_result(state) + # Get entities and set up state synchronization entities, services = await client.list_entities_services() initial_state_helper = InitialStateHelper(entities) @@ -228,6 +230,7 @@ loop = asyncio.get_running_loop() states: dict[int, EntityState] = {} state_future: asyncio.Future[EntityState] = loop.create_future() + def on_state(state: EntityState) -> None: states[state.key] = state # Check for specific condition using isinstance @@ -235,6 +238,7 @@ def on_state(state: EntityState) -> None: if not state_future.done(): state_future.set_result(state) + client.subscribe_states(on_state) # Wait for state with timeout @@ -263,11 +267,13 @@ entity_count = 50 received_states: set[int] = set() all_states_future: asyncio.Future[bool] = loop.create_future() + def on_state(state: EntityState) -> None: received_states.add(state.key) if len(received_states) >= entity_count and not all_states_future.done(): all_states_future.set_result(True) + client.subscribe_states(on_state) await asyncio.wait_for(all_states_future, timeout=10.0) ``` @@ -367,6 +373,7 @@ service_future = loop.create_future() connected_pattern = re.compile(r"Client .* connected from") service_pattern = re.compile(r"Service called") + def check_output(line: str) -> None: """Check log output for expected messages.""" if not connected_future.done() and connected_pattern.search(line): @@ -374,6 +381,7 @@ def check_output(line: str) -> None: elif not service_future.done() and service_pattern.search(line): service_future.set_result(True) + async with run_compiled(yaml_config, line_callback=check_output): async with api_client_connected() as client: # Wait for specific log message diff --git a/tests/script/test_docker_build.py b/tests/script/test_docker_build.py index 34bcc4e714..06dc21a92e 100644 --- a/tests/script/test_docker_build.py +++ b/tests/script/test_docker_build.py @@ -71,10 +71,12 @@ def test_branch_manifest_targets_ghcr_only( ) assert commands == [ - "docker buildx imagetools create " - "--tag ghcr.io/esphome/esphome-hassio:my-branch " - "ghcr.io/esphome/esphome-hassio-amd64:my-branch " - "ghcr.io/esphome/esphome-hassio-aarch64:my-branch" + ( + "docker buildx imagetools create " + "--tag ghcr.io/esphome/esphome-hassio:my-branch " + "ghcr.io/esphome/esphome-hassio-amd64:my-branch " + "ghcr.io/esphome/esphome-hassio-aarch64:my-branch" + ) ] diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index b8aa19d6ae..08751879c2 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -1433,8 +1433,10 @@ def test_importing_framework_helpers_does_not_import_requests() -> None: [ sys.executable, "-c", - "import sys\nimport esphome.framework_helpers\n" - "print('\\n'.join(sys.modules))", + ( + "import sys\nimport esphome.framework_helpers\n" + "print('\\n'.join(sys.modules))" + ), ], capture_output=True, text=True, From c0aa121c3d0a9ae8ba515e81d313fe62756a3afb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Jul 2026 08:39:03 -1000 Subject: [PATCH 1085/1815] [espidf] Install only the toolchains for the variants being built (#17688) --- .github/actions/cache-esp-idf/action.yml | 6 +- esphome/components/esp32/const.py | 6 + esphome/espidf/component.py | 3 +- esphome/espidf/framework.py | 160 +++++++++++-- esphome/espidf/toolchain.py | 33 ++- tests/unit_tests/test_espidf_framework.py | 275 +++++++++++++++++++++- tests/unit_tests/test_espidf_toolchain.py | 26 +- 7 files changed, 476 insertions(+), 33 deletions(-) diff --git a/.github/actions/cache-esp-idf/action.yml b/.github/actions/cache-esp-idf/action.yml index f566ba4c43..b884e1e4c6 100644 --- a/.github/actions/cache-esp-idf/action.yml +++ b/.github/actions/cache-esp-idf/action.yml @@ -3,8 +3,10 @@ description: > Resolve the pinned ESP-IDF version and cache the native ESP-IDF install (toolchains + source) at ~/.esphome-idf. Every job that installs ESP-IDF natively (clang-tidy for IDF/Arduino and the component test batches) shares - one cache, since the install is identical (ESPHOME_IDF_DEFAULT_TARGETS - defaults to "all", so all toolchains are present regardless of the chip). + one cache, since the install is identical: ESPHOME_IDF_DEFAULT_TARGETS + defaults to "all", and _get_configured_targets() in espidf/toolchain.py + skips per-variant narrowing whenever CI is set, so all toolchains are + present regardless of the chip a job builds. Callers must set env ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf and have the Python venv already restored. inputs: diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index 83fcfd233e..248f84c6bc 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -63,4 +63,10 @@ VARIANT_FRIENDLY = { VARIANT_ESP32S31: "ESP32-S31", } + +def variant_to_idf_target(variant: str) -> str: + """Map an esp32 variant name (e.g. "ESP32S3") to its ESP-IDF target name.""" + return variant.lower().replace("-", "") + + esp32_ns = cg.esphome_ns.namespace("esp32") diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 51d023099e..182f29c92d 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -53,9 +53,10 @@ def _apply_extra_script(component: IDFComponent) -> None: if not script_path.is_relative_to(library_root) or not script_path.is_file(): return from esphome.components.esp32 import get_esp32_variant + from esphome.components.esp32.const import variant_to_idf_target from esphome.espidf.extra_script import captured_as_build_flags, run_extra_script - idf_target = get_esp32_variant().lower().replace("-", "") + idf_target = variant_to_idf_target(get_esp32_variant()) result = run_extra_script( script_path, library_dir=component.path, idf_target=idf_target ) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 3a594c738c..0ca7a9d14b 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -9,7 +9,7 @@ from pathlib import Path import platform import re import shutil -from typing import NoReturn +from typing import Any, NoReturn import platformdirs @@ -49,6 +49,10 @@ STAMP_SCHEMA_VERSION = "0" ESPHOME_IDF_DEFAULT_TARGETS = str_to_lst_of_str( os.environ.get("ESPHOME_IDF_DEFAULT_TARGETS", "all") ) +# An explicitly set ESPHOME_IDF_DEFAULT_TARGETS overrides the per-variant +# targets a caller requests, so a builder image can still pre-warm every +# target with one env var. +_IDF_DEFAULT_TARGETS_EXPLICIT = bool(os.environ.get("ESPHOME_IDF_DEFAULT_TARGETS")) ESPHOME_IDF_DEFAULT_TOOLS = str_to_lst_of_str( os.environ.get("ESPHOME_IDF_DEFAULT_TOOLS", "cmake;ninja") @@ -199,7 +203,35 @@ def _get_python_env_path(version: str) -> Path: return get_idf_tools_path() / "penvs" / f"{version}" -def _check_stamp(file: PathType, data: dict[str, str]) -> bool: +def _read_stamp(file: PathType) -> dict | None: + """Return a stamp file's dict contents, or None if missing or invalid. + + A missing stamp is the normal first-install case and stays silent; the + other branches indicate a real fault that forces a full reinstall on + every build, so they warn. + """ + try: + with Path(file).open(encoding="utf-8") as f: + data = json.load(f) + except FileNotFoundError: + return None + except json.JSONDecodeError as e: + _LOGGER.warning("Ignoring corrupt stamp file %s: %s", file, e) + return None + except OSError as e: + _LOGGER.warning("Could not read stamp file %s: %s", file, e) + return None + if not isinstance(data, dict): + _LOGGER.warning( + "Ignoring stamp file %s with unexpected type %s", + file, + type(data).__name__, + ) + return None + return data + + +def _check_stamp(file: PathType, data: dict[str, Any]) -> bool: """ Check if a stamp file contains the expected data. @@ -210,17 +242,43 @@ def _check_stamp(file: PathType, data: dict[str, str]) -> bool: Returns: True if file exists and contains expected data, False otherwise """ - if not Path(file).is_file(): + return _read_stamp(file) == data + + +def _stamps_match_except_targets(stored: dict, requested: dict) -> bool: + """Whether two stamps agree on every field other than ``targets``. + + Compares whole dicts (minus ``targets``) rather than named keys so any + stamp field added later participates in invalidation by default instead + of being silently ignored. + """ + + def _strip(stamp: dict) -> dict: + return {k: v for k, v in stamp.items() if k != "targets"} + + return _strip(stored) == _strip(requested) + + +def _stamp_covers(stored: dict | None, requested: dict) -> bool: + """Return True if a stored framework stamp already covers this request. + + Every field except ``targets`` must match exactly. ``targets`` may be a + superset of the requested ones: ``idf_tools.py install`` accumulates + targets in idf-env.json across runs, so a framework installed for more + targets than this build needs is still valid. A stored ``all`` covers + every target. + """ + if stored is None: return False - - try: - with Path(file).open(encoding="utf-8") as f: - return json.load(f) == data - except (json.JSONDecodeError, OSError): + if not _stamps_match_except_targets(stored, requested): return False + stored_targets = stored.get("targets") + if not isinstance(stored_targets, list): + return False + return "all" in stored_targets or set(requested["targets"]) <= set(stored_targets) -def _write_stamp(file: PathType, data: dict[str, str]): +def _write_stamp(file: PathType, data: dict[str, Any]): """ Write data to a stamp file in JSON format. @@ -557,6 +615,19 @@ _UNUSED_IDF_TOOLS: tuple[str, ...] = ( "xtensa-esp-elf-gdb", ) +# tools.json also lists riscv32-esp-elf as supported on the xtensa chips +# because the S2/S3 ULP coprocessor is a RISC-V core, so installing for an +# S2/S3 target pulls in the whole riscv compiler (~290MB download, 2GB disk) +# just for ULP programs — which ESPHome never builds (the IDF ``ulp`` +# component is excluded by default; a user who re-enables it via +# ``include_builtin_idf_components: [ulp]`` on an S2/S3 and hits a missing +# riscv compiler can set ESPHOME_IDF_DEFAULT_TARGETS=all to install it). +# Removing the xtensa chips from its supported targets keeps it out of +# xtensa-only installs; building a RISC-V variant still installs it. Add any +# future Xtensa chip here; a missing entry only costs the download, while a +# wrongly listed RISC-V chip would strip its own compiler. +_XTENSA_TARGETS: tuple[str, ...] = ("esp32", "esp32s2", "esp32s3") + def _patch_tools_json_demote_unused_tools(framework_path: Path) -> None: """Demote tools ESPHome never runs from ``install: always`` to ``on_request``. @@ -572,6 +643,10 @@ def _patch_tools_json_demote_unused_tools(framework_path: Path) -> None: its stamp file) heals on the next build without a clean. A user who wants one of these tools can still name it explicitly in ESPHOME_IDF_DEFAULT_TOOLS; explicit names bypass install-type filtering. + + Also removes the xtensa chips from riscv32-esp-elf's supported targets + (see ``_XTENSA_TARGETS``) so xtensa-only installs don't pull in the + RISC-V compiler for ULP programs ESPHome never builds. """ def apply_patch(data: dict) -> bool: @@ -583,13 +658,31 @@ def _patch_tools_json_demote_unused_tools(framework_path: Path) -> None: ): tool["install"] = "on_request" changed = True + if tool.get("name") == "riscv32-esp-elf": + targets = tool.get("supported_targets") + # Guard the type so unexpected JSON here cannot abort the + # other demotions; this patch is best-effort. Log it so a + # silently resumed riscv download is diagnosable. + if not isinstance(targets, list): + _LOGGER.warning( + "Unexpected supported_targets for riscv32-esp-elf " + "in tools.json (%s); not excluding it from xtensa " + "installs", + type(targets).__name__, + ) + continue + if any(t in targets for t in _XTENSA_TARGETS): + tool["supported_targets"] = [ + t for t in targets if t not in _XTENSA_TARGETS + ] + changed = True return changed _patch_tools_json( framework_path, apply_patch, "Patched %s to skip installing tools ESPHome does not use " - "(openocd, gdb, ULP toolchain).", + "(openocd, gdb, ULP toolchains).", ) @@ -685,7 +778,9 @@ def _check_esphome_idf_framework_install( the URL. Returns: - tuple of (framework_path, install_flag) + tuple of (framework_path, fresh_extract_flag). The flag is True only + when the framework tree was downloaded and extracted this run, not + when tools were installed into an existing tree. """ # Sanitize inputs @@ -718,8 +813,8 @@ def _check_esphome_idf_framework_install( # avoids post-extraction renames that race with antivirus on Windows. # Tool install state is tracked separately by the stamp file in step 3, # so we only re-extract when extraction itself is missing or incomplete. - install = force or not extracted_marker.is_file() - if install: + fresh_extract = force or not extracted_marker.is_file() + if fresh_extract: rmdir(framework_path, msg=f"Clean up ESP-IDF {version} framework") git_source = _parse_git_source(source_url) if source_url else None @@ -790,9 +885,11 @@ def _check_esphome_idf_framework_install( _patch_tools_json_demote_unused_tools(framework_path) # 3. Check if the framework tools are the same and correctly installed + stored_stamp = None if fresh_extract else _read_stamp(env_stamp_file) + install = fresh_extract if not install: install = True - if _check_stamp(env_stamp_file, stamp_info): + if _stamp_covers(stored_stamp, stamp_info): _LOGGER.info("Checking ESP-IDF %s framework installation ...", version) # Validate via the managed tool-path resolution, not ``idf_tools.py check``: # ``check`` probes tools on the system PATH and aborts if any fail to run (e.g. a @@ -843,9 +940,25 @@ def _check_esphome_idf_framework_install( except RuntimeError as err: _LOGGER.debug("Could not remove ESP-IDF tool download cache: %s", err) + # Record the union of every target installed so far, not just this + # build's. idf_tools.py accumulates targets in idf-env.json and the + # ``required`` metapackage installs tools for all of them, so the + # union is what is actually on disk — and it keeps two variants + # alternating between builds from re-running the installer each time. + # Merge only when everything except targets matches: a reinstall + # triggered by a schema or tools change ran the installer for this + # build's targets alone, so carrying the old targets forward would + # let later builds of those variants skip the reinstall they need. + if ( + stored_stamp + and isinstance(stored_stamp.get("targets"), list) + and _stamps_match_except_targets(stored_stamp, stamp_info) + ): + merged = set(stamp_info["targets"]) | set(stored_stamp["targets"]) + stamp_info["targets"] = ["all"] if "all" in merged else sorted(merged) _write_stamp(env_stamp_file, stamp_info) - return framework_path, install + return framework_path, fresh_extract def _check_esp_idf_python_env_install( @@ -991,7 +1104,11 @@ def check_esp_idf_install( env["IDF_TOOLS_PATH"] = str(get_idf_tools_path()) env["IDF_PATH"] = "" - targets = targets or ESPHOME_IDF_DEFAULT_TARGETS + # An explicit ESPHOME_IDF_DEFAULT_TARGETS wins over the caller's + # per-variant request (builder-image pre-warm); otherwise the caller's + # targets are used, falling back to the default when none were given. + if _IDF_DEFAULT_TARGETS_EXPLICIT or not targets: + targets = ESPHOME_IDF_DEFAULT_TARGETS # Determine which tools need to be installed if not provided if tools is None: @@ -1004,15 +1121,18 @@ def check_esp_idf_install( tools.append(tool) # 1) Framework - framework_path, installed = _check_esphome_idf_framework_install( + framework_path, fresh_extract = _check_esphome_idf_framework_install( version, targets, tools, force=force, env=env, source_url=source_url ) features = features or ESPHOME_IDF_DEFAULT_FEATURES - # 2) Python env - python_env_path, installed = _check_esp_idf_python_env_install( - version, features, force=force or installed, env=env + # 2) Python env. Only a freshly extracted framework forces a rebuild — + # the venv depends on the framework version and features, not on which + # toolchains are installed, so adding a target to an existing tree must + # not wipe it. It still self-validates against its own stamp. + python_env_path, _ = _check_esp_idf_python_env_install( + version, features, force=force or fresh_extract, env=env ) return framework_path, python_env_path diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 9dd3474910..fd95805c6c 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -9,7 +9,13 @@ import re import shutil import subprocess -from esphome.components.esp32.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION +from esphome.components.esp32.const import ( + KEY_ESP32, + KEY_FLASH_SIZE, + KEY_IDF_VERSION, + KEY_VARIANT, + variant_to_idf_target, +) from esphome.const import ( CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, @@ -56,6 +62,27 @@ def _get_framework_source_override() -> str | None: return CORE.config.get(KEY_ESP32, {}).get(CONF_FRAMEWORK, {}).get(CONF_SOURCE) +def _get_configured_targets() -> list[str] | None: + """Return the IDF install target for the configured variant, if known. + + Limiting the toolchain install to the variant being built skips the other + architecture's compiler entirely (several hundred MB of download and 1-2GB + of disk). idf_tools.py accumulates targets across runs, so building a + second variant later installs just its toolchain incrementally. None (no + variant stored, e.g. tooling outside a build) falls back to the default + inside check_esp_idf_install. + + CI always installs every target (None falls through to the "all" + default): runners share one toolchain cache across jobs that build + different variants, so a full install keeps the cached tree identical + everywhere instead of per-variant supersets invalidating each other. + """ + if os.environ.get("CI"): + return None + variant = CORE.data.get(KEY_ESP32, {}).get(KEY_VARIANT) + return [variant_to_idf_target(variant)] if variant else None + + def _get_esphome_esp_idf_paths( version: str | None = None, ) -> tuple[os.PathLike, os.PathLike]: @@ -63,7 +90,9 @@ def _get_esphome_esp_idf_paths( paths = _cache().paths if version not in paths: paths[version] = check_esp_idf_install( - version, source_url=_get_framework_source_override() + version, + targets=_get_configured_targets(), + source_url=_get_framework_source_override(), ) return paths[version] diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index 59872bf233..5912facbb3 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -19,7 +19,10 @@ from unittest.mock import patch import pytest from esphome.espidf.framework import ( + ESPHOME_STAMP_FILE, + STAMP_SCHEMA_VERSION, _ccache_env, + _check_esphome_idf_framework_install, _check_stamp, _check_windows_path_length, _clone_idf_with_submodules, @@ -32,6 +35,8 @@ from esphome.espidf.framework import ( _patch_tools_json_demote_unused_tools, _patch_tools_json_for_linux_arm64, _prefetch_idf_tool_archives, + _read_stamp, + _stamp_covers, _windows_long_paths_enabled, _write_idf_version_txt, _write_stamp, @@ -385,6 +390,7 @@ def espidf_mocks(setup_core: Path): patch("esphome.espidf.framework._prefetch_idf_tool_archives"), patch("esphome.espidf.framework._write_stamp"), patch("esphome.espidf.framework._check_stamp", return_value=True), + patch("esphome.espidf.framework._stamp_covers", return_value=True), patch("esphome.espidf.framework._get_idf_version", return_value=_IDF_VERSION), patch("esphome.espidf.framework._get_python_version", return_value="3.11.0"), patch("esphome.espidf.framework.get_system_python_path", return_value="python"), @@ -514,13 +520,17 @@ def _mark_installed() -> None: def test_check_esp_idf_install_stamp_mismatch_reinstalls( espidf_mocks: SimpleNamespace, ) -> None: - """A stamp mismatch reinstalls tools (marker present, so no re-extract).""" + """A stamp mismatch reinstalls tools (marker present, so no re-extract). + + The python env is left alone: it depends on the framework version and + features, not on which toolchains are installed. + """ _mark_installed() - with patch("esphome.espidf.framework._check_stamp", return_value=False): + with patch("esphome.espidf.framework._stamp_covers", return_value=False): check_esp_idf_install(_IDF_VERSION) espidf_mocks.extract.assert_not_called() # marker present -> no re-extract - espidf_mocks.venv.assert_called_once() # tools reinstall -> venv rebuilt + espidf_mocks.venv.assert_not_called() # tools-only install -> venv kept def test_check_esp_idf_install_check_command_failure_reinstalls( @@ -533,7 +543,7 @@ def test_check_esp_idf_install_check_command_failure_reinstalls( check_esp_idf_install(_IDF_VERSION, features=["fb"]) espidf_mocks.extract.assert_not_called() - espidf_mocks.venv.assert_called_once() + espidf_mocks.venv.assert_not_called() # tools-only install -> venv kept def test_check_esp_idf_install_unknown_python_version_reinstalls( @@ -553,8 +563,8 @@ def test_check_esp_idf_install_python_stamp_mismatch_rebuilds_venv( ) -> None: """Framework stamp matches but the python-env stamp does not -> venv rebuilt.""" - # _check_stamp passes for the framework (no python_version key) and fails - # for the python env (carries python_version), so only the venv rebuilds. + # _check_stamp only guards the python env now (the framework uses + # _stamp_covers, patched True by the fixture); failing it rebuilds the venv. def stamp_ok(_stamp_file, info: dict) -> bool: return "python_version" not in info @@ -566,6 +576,146 @@ def test_check_esp_idf_install_python_stamp_mismatch_rebuilds_venv( espidf_mocks.venv.assert_called_once() +def _requested_stamp(targets: list[str], tools: list[str] | None = None) -> dict: + return { + "schema_version": STAMP_SCHEMA_VERSION, + "targets": targets, + "tools": tools or ["required"], + } + + +@pytest.mark.parametrize( + ("stored", "targets", "expected"), + [ + # a stored "all" covers any target + (_requested_stamp(["all"]), ["esp32"], True), + # exact match and superset both cover + (_requested_stamp(["esp32"]), ["esp32"], True), + (_requested_stamp(["esp32", "esp32c3"]), ["esp32"], True), + # a new target is not covered + (_requested_stamp(["esp32"]), ["esp32c3"], False), + # tools and schema_version must match exactly + (_requested_stamp(["all"], tools=["cmake", "required"]), ["esp32"], False), + (_requested_stamp(["all"]) | {"schema_version": "no"}, ["esp32"], False), + # an unknown extra field participates in invalidation by default + (_requested_stamp(["all"]) | {"module_version": 1}, ["esp32"], False), + # missing/corrupt stamps never cover + (None, ["esp32"], False), + ( + {"schema_version": STAMP_SCHEMA_VERSION, "tools": ["required"]}, + ["esp32"], + False, + ), + ], +) +def test_stamp_covers(stored: dict | None, targets: list[str], expected: bool) -> None: + assert _stamp_covers(stored, _requested_stamp(targets)) is expected + + +@contextmanager +def _framework_install_patches(): + """Patches for calling _check_esphome_idf_framework_install directly with + real stamp files (unlike espidf_mocks, which stubs the stamp layer).""" + with ( + patch("esphome.espidf.framework.run_command_ok", return_value=True) as run_ok, + patch("esphome.espidf.framework._get_idf_tool_paths", return_value=([], {})), + patch("esphome.espidf.framework.get_system_python_path", return_value="python"), + patch("esphome.espidf.framework.rmdir"), + ): + yield run_ok + + +def _extracted_framework_with_stamp(stamp: dict) -> Path: + framework_path = _get_framework_path(_IDF_VERSION) + framework_path.mkdir(parents=True, exist_ok=True) + (framework_path / ".esphome_extracted").touch() + _write_stamp(framework_path / ESPHOME_STAMP_FILE, stamp) + return framework_path + + +def test_framework_install_target_subset_skips_install() -> None: + """A stamp holding a superset of the requested targets skips the installer.""" + framework_path = _extracted_framework_with_stamp(_requested_stamp(["all"])) + + with _framework_install_patches() as run_ok: + _, fresh_extract = _check_esphome_idf_framework_install( + _IDF_VERSION, ["esp32"], ["required"] + ) + + run_ok.assert_not_called() + assert fresh_extract is False + # the stamp is untouched + stamp = json.loads((framework_path / ESPHOME_STAMP_FILE).read_text()) + assert stamp["targets"] == ["all"] + + +def test_framework_install_new_target_installs_and_merges_stamp() -> None: + """A new target runs the installer for just that target and the stamp + records the union of everything installed so far.""" + framework_path = _extracted_framework_with_stamp(_requested_stamp(["esp32"])) + + with _framework_install_patches() as run_ok: + _, fresh_extract = _check_esphome_idf_framework_install( + _IDF_VERSION, ["esp32c3"], ["required"] + ) + + assert fresh_extract is False + assert "--targets=esp32c3" in run_ok.call_args[0][0] + stamp = json.loads((framework_path / ESPHOME_STAMP_FILE).read_text()) + assert stamp["targets"] == ["esp32", "esp32c3"] + + +def test_check_esp_idf_install_env_targets_override_wins( + espidf_mocks: SimpleNamespace, +) -> None: + """An explicitly set ESPHOME_IDF_DEFAULT_TARGETS overrides per-variant targets.""" + with patch("esphome.espidf.framework._IDF_DEFAULT_TARGETS_EXPLICIT", True): + check_esp_idf_install(_IDF_VERSION, force=True, targets=["esp32"]) + + install_cmd = espidf_mocks.run_ok.call_args_list[0][0][0] + assert "--targets=all" in install_cmd + + +def test_check_esp_idf_install_uses_requested_targets( + espidf_mocks: SimpleNamespace, +) -> None: + """Without the env override, the caller's per-variant targets are installed.""" + check_esp_idf_install(_IDF_VERSION, force=True, targets=["esp32"]) + + install_cmd = espidf_mocks.run_ok.call_args_list[0][0][0] + assert "--targets=esp32" in install_cmd + + +def test_framework_install_all_request_collapses_merged_stamp_to_all() -> None: + """Requesting "all" over a per-variant stamp merges and collapses to + ["all"], not ["all", "esp32"], so the stamp shape stays canonical.""" + framework_path = _extracted_framework_with_stamp(_requested_stamp(["esp32"])) + + with _framework_install_patches() as run_ok: + _check_esphome_idf_framework_install(_IDF_VERSION, ["all"], ["required"]) + + run_ok.assert_called_once() + stamp = json.loads((framework_path / ESPHOME_STAMP_FILE).read_text()) + assert stamp["targets"] == ["all"] + + +def test_framework_install_tools_change_resets_stamp_targets() -> None: + """A reinstall triggered by a tools change must not carry the old stamp's + targets forward: the installer only ran for this build's targets, so a + merged stamp would let other variants skip the reinstall they need.""" + framework_path = _extracted_framework_with_stamp( + _requested_stamp(["all"], tools=["cmake", "required"]) + ) + + with _framework_install_patches() as run_ok: + _check_esphome_idf_framework_install(_IDF_VERSION, ["esp32"], ["required"]) + + run_ok.assert_called_once() + stamp = json.loads((framework_path / ESPHOME_STAMP_FILE).read_text()) + assert stamp["targets"] == ["esp32"] + assert stamp["tools"] == ["required"] + + @pytest.mark.parametrize( ("lib", "expect_hint"), [ @@ -1014,6 +1164,66 @@ def test_demote_unused_tools_patches_install_type(tmp_path: Path) -> None: } +def test_demote_unused_tools_drops_xtensa_from_riscv_targets(tmp_path: Path) -> None: + """riscv32-esp-elf loses the xtensa chips (ULP-RISC-V only, which ESPHome + never builds) but keeps its RISC-V targets; other tools are untouched.""" + tools_json = _write_tools_json( + tmp_path, + { + "tools": [ + { + "name": "riscv32-esp-elf", + "install": "always", + "supported_targets": ["esp32s2", "esp32s3", "esp32c3", "esp32p4"], + }, + { + "name": "xtensa-esp-elf", + "install": "always", + "supported_targets": ["esp32", "esp32s2", "esp32s3"], + }, + ] + }, + ) + _patch_tools_json_demote_unused_tools(tmp_path) + + data = json.loads(tools_json.read_text(encoding="utf-8")) + riscv = next(t for t in data["tools"] if t["name"] == "riscv32-esp-elf") + xtensa = next(t for t in data["tools"] if t["name"] == "xtensa-esp-elf") + assert riscv["supported_targets"] == ["esp32c3", "esp32p4"] + assert riscv["install"] == "always" + assert xtensa["supported_targets"] == ["esp32", "esp32s2", "esp32s3"] + + +def test_demote_unused_tools_bad_supported_targets_type_still_demotes( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A non-list supported_targets on riscv32-esp-elf must not abort the + other demotions; the targets patch is best-effort and logs the skip so a + silently resumed riscv download is diagnosable.""" + tools_json = _write_tools_json( + tmp_path, + { + "tools": [ + { + "name": "riscv32-esp-elf", + "install": "always", + "supported_targets": None, + }, + {"name": "openocd-esp32", "install": "always"}, + ] + }, + ) + with caplog.at_level(logging.WARNING, logger="esphome.espidf.framework"): + _patch_tools_json_demote_unused_tools(tmp_path) + + data = json.loads(tools_json.read_text(encoding="utf-8")) + openocd = next(t for t in data["tools"] if t["name"] == "openocd-esp32") + riscv = next(t for t in data["tools"] if t["name"] == "riscv32-esp-elf") + assert openocd["install"] == "on_request" + assert riscv["supported_targets"] is None + assert "Unexpected supported_targets" in caplog.text + + def test_patch_tools_json_unexpected_structure_warns_and_skips( tmp_path: Path, ) -> None: @@ -1036,6 +1246,11 @@ def test_demote_unused_tools_already_patched_is_noop(tmp_path: Path) -> None: {"name": "xtensa-esp-elf-gdb", "install": "on_request"}, {"name": "riscv32-esp-elf-gdb", "install": "on_request"}, {"name": "esp32ulp-elf", "install": "on_request"}, + { + "name": "riscv32-esp-elf", + "install": "always", + "supported_targets": ["esp32c3", "esp32p4"], + }, ] }, ) @@ -1270,6 +1485,54 @@ def test_check_stamp_corrupt_file(tmp_path: Path) -> None: assert _check_stamp(f, {"a": "1"}) is False +def test_read_stamp_corrupt_file_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + # A corrupt stamp forces a full reinstall on every build, so it warns + # where the normal missing-file case stays silent. + f = tmp_path / "s.json" + f.write_text("{ not json", encoding="utf-8") + with caplog.at_level(logging.WARNING, logger="esphome.espidf.framework"): + assert _read_stamp(f) is None + assert "Ignoring corrupt stamp file" in caplog.text + + +def test_read_stamp_unreadable_file_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + # An I/O fault (permissions, disk error) is distinguished from a simply + # missing stamp with a warning before falling back to reinstall. + f = tmp_path / "s.json" + f.write_text(json.dumps({"a": "1"}), encoding="utf-8") + with ( + patch.object(Path, "open", side_effect=PermissionError("denied")), + caplog.at_level(logging.WARNING, logger="esphome.espidf.framework"), + ): + assert _read_stamp(f) is None + assert "Could not read stamp file" in caplog.text + + +def test_read_stamp_non_dict_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + # Well-formed JSON that is not an object is a fault, not a first install; + # it must leave a trace before forcing reinstalls. + f = tmp_path / "s.json" + f.write_text("null", encoding="utf-8") + with caplog.at_level(logging.WARNING, logger="esphome.espidf.framework"): + assert _read_stamp(f) is None + assert "unexpected type NoneType" in caplog.text + + +def test_read_stamp_missing_file_is_silent( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + # Missing stamps are the normal first-install case and must not log. + with caplog.at_level(logging.DEBUG, logger="esphome.espidf.framework"): + assert _read_stamp(tmp_path / "nope.json") is None + assert "stamp file" not in caplog.text + + def test_write_idf_version_txt_writes_when_missing(tmp_path: Path) -> None: _write_idf_version_txt(tmp_path, "5.1.2") assert (tmp_path / "version.txt").read_text(encoding="utf-8") == "v5.1.2\n" diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 2735746264..56f358a24c 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -10,6 +10,7 @@ from unittest.mock import patch import pytest +from esphome.components.esp32.const import KEY_ESP32, KEY_VARIANT from esphome.const import ( CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, @@ -55,7 +56,7 @@ def test_get_esphome_esp_idf_paths_forwards_source_override(): toolchain, "check_esp_idf_install", return_value=("/fw", "/penv") ) as mock_install: toolchain._get_esphome_esp_idf_paths("5.5.4") - mock_install.assert_called_once_with("5.5.4", source_url=url) + mock_install.assert_called_once_with("5.5.4", targets=None, source_url=url) def test_get_esphome_esp_idf_paths_no_override(): @@ -66,7 +67,28 @@ def test_get_esphome_esp_idf_paths_no_override(): toolchain, "check_esp_idf_install", return_value=("/fw", "/penv") ) as mock_install: toolchain._get_esphome_esp_idf_paths("5.5.4") - mock_install.assert_called_once_with("5.5.4", source_url=None) + mock_install.assert_called_once_with("5.5.4", targets=None, source_url=None) + + +def test_get_configured_targets_from_variant(monkeypatch: pytest.MonkeyPatch): + """The configured variant restricts the toolchain install to its target.""" + monkeypatch.delenv("CI", raising=False) + CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32S3"} + assert toolchain._get_configured_targets() == ["esp32s3"] + + +def test_get_configured_targets_without_variant(monkeypatch: pytest.MonkeyPatch): + """No stored variant (e.g. tooling outside a build) keeps the default.""" + monkeypatch.delenv("CI", raising=False) + CORE.data.pop(KEY_ESP32, None) + assert toolchain._get_configured_targets() is None + + +def test_get_configured_targets_ci_installs_all(monkeypatch: pytest.MonkeyPatch): + """CI installs every target so the shared cache covers all variants.""" + monkeypatch.setenv("CI", "true") + CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32S3"} + assert toolchain._get_configured_targets() is None def _setup_build(setup_core: Path) -> tuple[Path, Path]: From 700c0b046098fbbad3ba3bc24bf2da32c6d5e836 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Jul 2026 08:42:52 -1000 Subject: [PATCH 1086/1815] [esp8266] Speed up builds with ccache when available (#17722) --- esphome/components/esp8266/__init__.py | 39 ++--- esphome/platformio/ccache.py.script | 34 ++++ esphome/platformio/toolchain.py | 91 ++++++++++- esphome/util.py | 1 + tests/unit_tests/test_platformio_toolchain.py | 148 +++++++++++++++++- 5 files changed, 285 insertions(+), 28 deletions(-) create mode 100644 esphome/platformio/ccache.py.script diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 7ce10d465d..618bf775a0 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -30,6 +30,7 @@ from esphome.core import ( ) from esphome.core.config import BOARD_MAX_LENGTH from esphome.helpers import IS_MACOS, copy_file_if_changed +from esphome.platformio.toolchain import copy_ccache_script from esphome.types import ConfigType from .boards import BOARDS, ESP8266_LD_SCRIPTS @@ -294,6 +295,7 @@ async def to_code(config): ) extra_scripts = [ + "pre:ccache.py", "pre:testing_mode.py", "pre:exclude_updater.py", "pre:exclude_waveform.py", @@ -443,31 +445,18 @@ async def finalize_serial_config() -> None: # Called by writer.py def copy_files() -> None: dir = Path(__file__).parent - post_build_file = dir / "post_build.py.script" - copy_file_if_changed( - post_build_file, - CORE.relative_build_path("post_build.py"), - ) - testing_mode_file = dir / "testing_mode.py.script" - copy_file_if_changed( - testing_mode_file, - CORE.relative_build_path("testing_mode.py"), - ) - exclude_updater_file = dir / "exclude_updater.py.script" - copy_file_if_changed( - exclude_updater_file, - CORE.relative_build_path("exclude_updater.py"), - ) - exclude_waveform_file = dir / "exclude_waveform.py.script" - copy_file_if_changed( - exclude_waveform_file, - CORE.relative_build_path("exclude_waveform.py"), - ) - remove_float_scanf_file = dir / "remove_float_scanf.py.script" - copy_file_if_changed( - remove_float_scanf_file, - CORE.relative_build_path("remove_float_scanf.py"), - ) + for script in ( + "post_build", + "testing_mode", + "exclude_updater", + "exclude_waveform", + "remove_float_scanf", + ): + copy_file_if_changed( + dir / f"{script}.py.script", + CORE.relative_build_path(f"{script}.py"), + ) + copy_ccache_script() # ESP logs stack trace decoder, based on https://github.com/me-no-dev/EspExceptionDecoder diff --git a/esphome/platformio/ccache.py.script b/esphome/platformio/ccache.py.script new file mode 100644 index 0000000000..cc08a8c044 --- /dev/null +++ b/esphome/platformio/ccache.py.script @@ -0,0 +1,34 @@ +import os +import shutil + +# pylint: disable=E0602 +Import("env") # noqa + +# ESPHome decides whether ccache is used and exports the CCACHE_* settings +# into the environment before PlatformIO starts (_ccache_env() in +# esphome/platformio/toolchain.py); this script only supplies the SCons-level +# mechanism. +# +# This is a "pre" script, so the platform's builder (which sets CC/CXX and +# clones the construction environment for framework and library builds) runs +# after it. Replacing CC/CXX here would be overwritten, and replacing them in +# a "post" script would miss the already-cloned library environments. Wrapping +# SPAWN instead is ordering-proof: clones copy the wrapper, and every compiler +# invocation from every environment funnels through it at execution time. +if ( + os.environ.get("ESPHOME_CCACHE_ENABLE") == "1" + and (ccache_path := shutil.which("ccache")) is not None +): + original_spawn = env["SPAWN"] + + def ccache_spawn(sh, escape, cmd, args, child_env): + # Only wrap compile steps (gcc/g++ with -c); linking, archiving and + # the other tools gain nothing from ccache. + prog = os.path.basename(cmd).removesuffix(".exe") + if prog.endswith(("gcc", "g++")) and "-c" in args: + cmd = ccache_path + args = [escape(ccache_path), *args] + return original_spawn(sh, escape, cmd, args, child_env) + + env.Replace(SPAWN=ccache_spawn) + print("ESPHome: Compiling with ccache") diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index 105d4a8283..0959cbfffb 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -4,12 +4,21 @@ import logging import os from pathlib import Path import re +import shutil import sys from typing import TYPE_CHECKING +import platformdirs + from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE from esphome.core import CORE, EsphomeError -from esphome.helpers import add_git_ceiling_directory, rmtree, write_file +from esphome.helpers import ( + add_git_ceiling_directory, + copy_file_if_changed, + get_bool_env, + rmtree, + write_file, +) from esphome.util import FlashImage, run_external_process if TYPE_CHECKING: @@ -225,6 +234,77 @@ def _check_platformio_python_stamp(config: "ProjectConfig") -> None: _write_pio_stamp_python(stamp_file, current) +def _ccache_env() -> dict[str, str]: + """Return ccache settings for PlatformIO builds. + + Enabled by default whenever the ``ccache`` binary is on PATH; set + ``ESPHOME_CCACHE_ENABLE=0`` in the environment to opt out (or ``1`` to + force it on). The decision is normalized into ``ESPHOME_CCACHE_ENABLE`` + so platform build scripts (e.g. the esp8266 ``ccache.py`` extra script, + which wraps compiler invocations inside SCons) only have to check for + ``"1"`` instead of re-implementing the policy. + + The returned values are merged into the environment of the PlatformIO + subprocess only, never into ``os.environ``: a long-running process + (e.g. the dashboard) also runs ESP-IDF builds, whose own ccache setup + skips defaults for ``CCACHE_*`` keys it finds already set, so leaking + these values would hand it the wrong cache dir and a stale basedir. + + This mirrors ``_ccache_env()`` in ``esphome/espidf/framework.py``. The + cache lives under the machine-global ESPHome cache dir, so it is shared + across all projects and removed by ``esphome clean-all``. Unlike the + ESP-IDF path, ``CCACHE_DEPEND`` is not set: SCons compiles don't emit + the depfiles depend mode needs, so ccache's default preprocessor mode + is used. + + ``CCACHE_BASEDIR`` rewrites the per-device absolute paths (the generated + sources under src/, the .pioenvs build dir) so different devices with + identical source share cache entries; it is always set to the current + build dir. The other ``CCACHE_*`` values the user already set in the + environment are respected. + """ + if "ESPHOME_CCACHE_ENABLE" in os.environ: + enabled = get_bool_env("ESPHOME_CCACHE_ENABLE") + else: + enabled = shutil.which("ccache") is not None + env = {"ESPHOME_CCACHE_ENABLE": "1" if enabled else "0"} + if not enabled: + return env + # build_path is set during preload for every config-loading command, so it + # being unset means a caller built the environment too early; fail loudly + # rather than with an opaque TypeError from Path(None). + if CORE.build_path is None: + raise ValueError( + "CORE.build_path must be set before constructing the PlatformIO " + "build environment" + ) + env["CCACHE_BASEDIR"] = str(Path(CORE.build_path).resolve()) + defaults = { + "CCACHE_DIR": str( + Path(platformdirs.user_cache_dir("esphome", appauthor=False)) + / "platformio-ccache" + ), + "CCACHE_NOHASHDIR": "true", + } + env.update({k: v for k, v in defaults.items() if k not in os.environ}) + return env + + +def copy_ccache_script() -> None: + """Copy the shared ccache SCons pre-script into the build dir. + + Platform components call this from their ``copy_files()`` and add + ``pre:ccache.py`` to their ``extra_scripts``. The script wraps compiler + invocations inside SCons with ccache; it is platform-agnostic, so it + lives here next to ``_ccache_env()`` rather than being duplicated per + component. + """ + copy_file_if_changed( + Path(__file__).parent / "ccache.py.script", + CORE.relative_build_path("ccache.py"), + ) + + def run_platformio_cli(*args, **kwargs) -> str | int: # Re-provision the PlatformIO cache if the interpreter's major.minor changed # since it was last built; a stale platform otherwise rejects the new Python @@ -256,7 +336,14 @@ def run_platformio_cli(*args, **kwargs) -> str | int: os.environ["PYTHONEXEPATH"] = python_exe cmd = [python_exe, "-m", "esphome.platformio.runner"] + list(args) - return run_external_process(*cmd, **kwargs) + # ccache settings go into the subprocess environment only (see + # _ccache_env() for why they must not leak into os.environ). A caller + # supplied env is used as the base when present. + base_env = kwargs.pop("env", None) + env = dict(os.environ if base_env is None else base_env) + env.update(_ccache_env()) + + return run_external_process(*cmd, env=env, **kwargs) def run_platformio_cli_run(config, verbose, *args, **kwargs) -> str | int: diff --git a/esphome/util.py b/esphome/util.py index b597b4b42e..f7d33bd2a9 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -314,6 +314,7 @@ def run_external_process(*cmd: str, **kwargs: Any) -> int | str: encoding="utf-8", check=False, close_fds=False, + env=kwargs.get("env"), ) return proc.stdout if capture_stdout else proc.returncode except KeyboardInterrupt: # pylint: disable=try-except-raise diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index 013030d38f..723646fbd6 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -322,6 +322,149 @@ def test_run_platformio_cli_sets_environment_variables( assert "arg" in args +def test_ccache_env_enabled_by_default(setup_core: Path) -> None: + """Ccache is enabled when the binary is on PATH and no override is set.""" + CORE.build_path = setup_core / "build" / "test" + + with ( + patch.dict(os.environ, {}, clear=True), + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + ): + env = toolchain._ccache_env() + + assert env["ESPHOME_CCACHE_ENABLE"] == "1" + assert env["CCACHE_BASEDIR"] == str((setup_core / "build" / "test").resolve()) + assert env["CCACHE_DIR"].endswith("platformio-ccache") + assert env["CCACHE_NOHASHDIR"] == "true" + # Nothing may leak into os.environ: a later ESP-IDF build in the same + # process would otherwise skip its own ccache defaults. + assert "CCACHE_BASEDIR" not in os.environ + assert "ESPHOME_CCACHE_ENABLE" not in os.environ + + +def test_ccache_env_disabled_without_binary(setup_core: Path) -> None: + """Ccache stays off when the binary is not on PATH.""" + CORE.build_path = setup_core / "build" / "test" + + with ( + patch.dict(os.environ, {}, clear=True), + patch.object(toolchain.shutil, "which", return_value=None), + ): + env = toolchain._ccache_env() + + assert env == {"ESPHOME_CCACHE_ENABLE": "0"} + + +def test_ccache_env_opt_out(setup_core: Path) -> None: + """ESPHOME_CCACHE_ENABLE=0 disables ccache even with the binary present.""" + CORE.build_path = setup_core / "build" / "test" + + with ( + patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "0"}, clear=True), + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + ): + env = toolchain._ccache_env() + + assert env == {"ESPHOME_CCACHE_ENABLE": "0"} + + +def test_ccache_env_normalizes_enable_value(setup_core: Path) -> None: + """A truthy override value is normalized to "1" for the build scripts.""" + CORE.build_path = setup_core / "build" / "test" + + with ( + patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "yes"}, clear=True), + patch.object(toolchain.shutil, "which", return_value=None), + ): + env = toolchain._ccache_env() + + assert env["ESPHOME_CCACHE_ENABLE"] == "1" + + +def test_ccache_env_respects_user_values_and_refreshes_basedir( + setup_core: Path, +) -> None: + """User CCACHE_* values win, but CCACHE_BASEDIR follows the build dir.""" + user_env = { + "CCACHE_DIR": "/custom/cache", + "CCACHE_BASEDIR": "/stale/other-device", + } + CORE.build_path = setup_core / "build" / "test" + + with ( + patch.dict(os.environ, user_env, clear=True), + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + ): + env = toolchain._ccache_env() + + # CCACHE_DIR is not returned, so the user's os.environ value applies in + # the subprocess; CCACHE_BASEDIR is always refreshed to the build dir. + assert "CCACHE_DIR" not in env + assert env["CCACHE_BASEDIR"] == str((setup_core / "build" / "test").resolve()) + + +def test_run_platformio_cli_passes_ccache_env_to_subprocess_only( + setup_core: Path, mock_run_external_process: Mock +) -> None: + """The ccache settings reach the subprocess env without touching os.environ.""" + CORE.build_path = str(setup_core / "build" / "test") + + with ( + patch.dict(os.environ, {}, clear=False), + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + ): + os.environ.pop("ESPHOME_CCACHE_ENABLE", None) + mock_run_external_process.return_value = 0 + toolchain.run_platformio_cli("test", "arg") + + env = mock_run_external_process.call_args[1]["env"] + assert env["ESPHOME_CCACHE_ENABLE"] == "1" + assert env["CCACHE_BASEDIR"] == str((setup_core / "build" / "test").resolve()) + assert "ESPHOME_CCACHE_ENABLE" not in os.environ + assert "CCACHE_BASEDIR" not in os.environ + + +def test_ccache_env_requires_build_path(setup_core: Path) -> None: + """Enabling ccache without a build path fails loudly.""" + CORE.build_path = None + + with ( + patch.dict(os.environ, {}, clear=True), + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + pytest.raises(ValueError, match="CORE.build_path must be set"), + ): + toolchain._ccache_env() + + +def test_run_platformio_cli_merges_caller_env( + setup_core: Path, mock_run_external_process: Mock +) -> None: + """A caller-supplied env is the base and gains the ccache settings.""" + CORE.build_path = str(setup_core / "build" / "test") + + with patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"): + mock_run_external_process.return_value = 0 + toolchain.run_platformio_cli( + "test", env={"CUSTOM_VAR": "1", "ESPHOME_CCACHE_ENABLE": "0"} + ) + + env = mock_run_external_process.call_args[1]["env"] + assert env["CUSTOM_VAR"] == "1" + # The normalized enable flag still lands in the subprocess env. + assert "ESPHOME_CCACHE_ENABLE" in env + + +def test_copy_ccache_script(setup_core: Path) -> None: + """The shared ccache pre-script is copied into the build dir.""" + CORE.build_path = setup_core / "build" / "test" + + toolchain.copy_ccache_script() + + dest = setup_core / "build" / "test" / "ccache.py" + source = Path(toolchain.__file__).parent / "ccache.py.script" + assert dest.read_text() == source.read_text() + + @pytest.mark.parametrize( ("platform", "input_path", "expected"), [ @@ -375,7 +518,10 @@ def test_run_platformio_cli_strips_win_long_path_prefix( ) with ( - patch.dict(os.environ, {}, clear=False), + # Pin ccache off: patching sys.platform to win32 (sys is a singleton, + # so the stdlib sees it too) would send shutil.which down the Windows + # code path, which crashes on a POSIX host. + patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "0"}, clear=False), patch("esphome.platformio.toolchain.sys.platform", "win32"), patch("esphome.platformio.toolchain.sys.executable", prefixed_exe), ): From 89654fea66c328dda9982ee75ed44d940408b465 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Jul 2026 08:52:29 -1000 Subject: [PATCH 1087/1815] [rp2] Speed up builds with ccache when available (#17727) --- esphome/components/rp2/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index fad9d3d25b..8bba6bc27d 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -33,6 +33,7 @@ from esphome.core import ( ) from esphome.core.config import BOARD_MAX_LENGTH from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed +from esphome.platformio.toolchain import copy_ccache_script from esphome.types import ConfigType from . import boards @@ -338,7 +339,7 @@ async def to_code(config): cg.add_define("ESPHOME_VARIANT", VARIANT_FRIENDLY[variant]) cg.add_define(ThreadModel.SINGLE) - cg.add_platformio_option("extra_scripts", ["post:post_build.py"]) + cg.add_platformio_option("extra_scripts", ["pre:ccache.py", "post:post_build.py"]) conf = config[CONF_FRAMEWORK] cg.add_platformio_option("framework", "arduino") @@ -594,6 +595,7 @@ def copy_files(): inject_lwip_file, CORE.relative_build_path("inject_lwip_include.py"), ) + copy_ccache_script() _generate_lwipopts_h() if generate_pio_files(): path = CORE.relative_src_path("esphome.h") From c395ea00cc74ab6f1c9991953316433a960b4716 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Jul 2026 08:54:32 -1000 Subject: [PATCH 1088/1815] [libretiny] Speed up builds with ccache when available (#17726) --- esphome/components/libretiny/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 97c0fd455b..7dbce7a07c 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -26,6 +26,7 @@ from esphome.const import ( from esphome.core import CORE from esphome.core.config import BOARD_MAX_LENGTH from esphome.helpers import copy_file_if_changed +from esphome.platformio.toolchain import copy_ccache_script from esphome.storage_json import StorageJSON from . import gpio # noqa: F401 @@ -506,6 +507,7 @@ async def component_to_code(config): # it for project source files only. GCC uses the last -O flag. build_src_flags += " -Os" cg.add_platformio_option("build_src_flags", build_src_flags) + cg.add_platformio_option("extra_scripts", ["pre:ccache.py"]) # IRAM_ATTR is a no-op on BK72xx (SDK masks FIQ+IRQ around flash ops). # On other families, patch_linker.py routes .sram.text into the right # RAM-executable output section and prints a post-link placement summary. @@ -610,3 +612,4 @@ def copy_files() -> None: patch_linker_file, CORE.relative_build_path("patch_linker.py"), ) + copy_ccache_script() From 580b3a1a696b3870266d5d6076c9206890dad065 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:35:53 -0400 Subject: [PATCH 1089/1815] Bump CodSpeedHQ/action from 4.18.5 to 4.19.1 (#17890) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7e5f31cd8..f73739b5d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -466,7 +466,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@f99becdce5e5d51fd556489ebef684f4ecfd6286 # v4.18.5 + uses: CodSpeedHQ/action@f22792bfac16f3e14eb9fbea76f4a48e9cc22b93 # v4.19.1 with: run: | . venv/bin/activate From e209bbb5aec9c24218d6c65fb5df5000a0a12792 Mon Sep 17 00:00:00 2001 From: Samuel Sieb Date: Mon, 27 Jul 2026 12:48:29 -0700 Subject: [PATCH 1090/1815] [logger] flush lines for host logger (#17878) Co-authored-by: Samuel Sieb --- esphome/components/logger/logger_host.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/logger/logger_host.cpp b/esphome/components/logger/logger_host.cpp index fe094f6e9e..708ab883e7 100644 --- a/esphome/components/logger/logger_host.cpp +++ b/esphome/components/logger/logger_host.cpp @@ -21,6 +21,7 @@ void HOT Logger::write_msg_(const char *msg, uint16_t len) { // Single write for everything fwrite(buffer, 1, pos, stdout); + fflush(stdout); } void Logger::pre_setup() { global_logger = this; } From ca08803425d5c1baeb0642f2dd3aa9502a35e315 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 27 Jul 2026 22:03:22 +0200 Subject: [PATCH 1091/1815] [nrf52] add OTA for Adafruit_nRF52_Bootloader (#17381) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/nrf52/__init__.py | 54 +++++++++-- esphome/components/zephyr/__init__.py | 27 +++++- .../components/zephyr_mcumgr/ota/__init__.py | 90 +++++++++++++++++-- tests/components/ota/test.nrf52-adafruit.yaml | 4 + 4 files changed, 157 insertions(+), 18 deletions(-) create mode 100644 tests/components/ota/test.nrf52-adafruit.yaml diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 2edc988ff8..4002d1cc04 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -877,6 +877,21 @@ def run_compile(args, config: ConfigType) -> bool: zephyr_dir = build_dir / "zephyr" framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] + bootloader = zephyr_data()[KEY_BOOTLOADER] + + # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes + _GENPKG_PARAMS = { + BOOTLOADER_ADAFRUIT_NRF52_SD132: ("0x0051", "0x009D"), + BOOTLOADER_ADAFRUIT_NRF52_SD140_V6: ("0x0052", "0x00B6"), + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA"), + } + # UF2 family IDs — nRF52832 vs nRF52840 per SoftDevice variant + _UF2_FAMILY_IDS = { + BOOTLOADER_ADAFRUIT_NRF52_SD132: "0x7EAED30A", + BOOTLOADER_ADAFRUIT_NRF52_SD140_V6: "0xADA52840", + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: "0xADA52840", + } + # SDK < 2.9.2 places artifacts directly in build_dir/zephyr/. # SDK >= 2.9.2 nests them one level deeper (build_dir/zephyr/zephyr/); # copy files to match get_download_types layout. @@ -888,20 +903,43 @@ def run_compile(args, config: ConfigType) -> bool: _copy_if_exists(west_out / "zephyr.signed.bin", zephyr_dir / "app_update.bin") _copy_if_exists(build_dir / "merged.hex", zephyr_dir / "merged.hex") - # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes - _GENPKG_PARAMS = { - BOOTLOADER_ADAFRUIT_NRF52_SD132: ("0x0051", "0x009D"), - BOOTLOADER_ADAFRUIT_NRF52_SD140_V6: ("0x0052", "0x00B6"), - BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA"), - } - bootloader = zephyr_data()[KEY_BOOTLOADER] + # For Adafruit bootloader builds, regenerate the UF2 from merged.hex, + # whose records carry the correct flash addresses. The build's own + # zephyr.uf2 uses the board's default offset, which is wrong in some cases. + merged_hex = zephyr_dir / "merged.hex" + if bootloader in _UF2_FAMILY_IDS and merged_hex.is_file(): + # Drop the build's own wrong-offset UF2 so it isn't shipped alongside. + app_uf2 = west_out / "zephyr.uf2" + if app_uf2.is_file(): + app_uf2.unlink() + uf2conv = ( + paths["framework_path"] / "zephyr" / "scripts" / "build" / "uf2conv.py" + ) + if not run_command_ok( + [ + str(paths["python_executable"]), + str(uf2conv), + "-f", + _UF2_FAMILY_IDS[bootloader], + "-c", + "-o", + str(zephyr_dir / "zephyr.uf2"), + str(merged_hex), + ], + env=env, + stream_output=True, + ): + raise EsphomeError("Failed to generate UF2 from merged hex") + if bootloader in ( BOOTLOADER_ADAFRUIT, BOOTLOADER_ADAFRUIT_NRF52_SD132, BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, ): - hex_file = west_out / "zephyr.hex" + # no fallback is needed for adafruit case. merged merged.hex is always generated. + # get_download_types needs fallback for mcuboot (non adafruit) + hex_file = zephyr_dir / "merged.hex" dfu_package = build_dir / "firmware.zip" genpkg_cmd = [ str(paths["python_executable"]), diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index b98f94d37a..524dc55a13 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -26,7 +26,26 @@ from .const import ( CODEOWNERS = ["@tomaszduda23"] -PrjConfValueType = bool | str | int + +class HexValue: + """Wrap an integer so it is written as 0x... in prj.conf (required for hex Kconfig types).""" + + def __init__(self, value: int) -> None: + self.value = value + + def __eq__(self, other: object) -> bool: + if isinstance(other, HexValue): + return self.value == other.value + return NotImplemented + + def __repr__(self) -> str: + return f"HexValue(0x{self.value:X})" + + def __str__(self) -> str: + return f"0x{self.value:X}" + + +PrjConfValueType = bool | str | int | HexValue class Section: @@ -164,6 +183,8 @@ def zephyr_setup_preferences(): def _format_prj_conf_val(value: PrjConfValueType) -> str: if isinstance(value, bool): return "y" if value else "n" + if isinstance(value, HexValue): + return hex(value.value) if isinstance(value, int): return str(value) if isinstance(value, str): @@ -249,7 +270,7 @@ def copy_files() -> None: ) if image: - path = CORE.relative_build_path(f"sysbuild/{image}.conf") + path = CORE.relative_build_path(f"zephyr/sysbuild/{image}.conf") else: path = CORE.relative_build_path("zephyr/prj.conf") @@ -257,7 +278,7 @@ def copy_files() -> None: for image, content in zephyr_data()[KEY_OVERLAY].items(): if image: - path = CORE.relative_build_path(f"sysbuild/{image}.overlay") + path = CORE.relative_build_path(f"zephyr/sysbuild/{image}.overlay") else: path = CORE.relative_build_path("zephyr/app.overlay") changed |= write_file_if_changed(path, content) diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index 0ff1825bd1..1503c94274 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -1,6 +1,8 @@ import esphome.codegen as cg +from esphome.components.nrf52.boards import BOOTLOADER_CONFIG from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code from esphome.components.zephyr import ( + HexValue, zephyr_add_cdc_acm, zephyr_add_overlay, zephyr_add_prj_conf, @@ -72,12 +74,6 @@ CONFIG_SCHEMA = cv.All( ) -def _validate_mcumgr_bootloader(config: ConfigType) -> None: - bootloader = zephyr_data()[KEY_BOOTLOADER] - if bootloader != BOOTLOADER_MCUBOOT: - raise cv.Invalid(f"'{bootloader}' bootloader does not support OTA") - - KEY_ZEPHYR_BLE_SERVER = "zephyr_ble_server" @@ -89,9 +85,22 @@ def _validate_ble_server(config: ConfigType) -> None: raise cv.Invalid(f"'{KEY_ZEPHYR_BLE_SERVER}' component is required for BLE OTA") +def _validate_bootloader(config: ConfigType) -> None: + bootloader = zephyr_data()[KEY_BOOTLOADER] + if bootloader == BOOTLOADER_MCUBOOT: + return + if bootloader not in BOOTLOADER_CONFIG: + raise cv.Invalid(f"{bootloader} does not support OTA") + framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] + if framework_ver < cv.Version(2, 9, 2): + raise cv.Invalid( + "OTA with Adafruit_nRF52_Bootloader requires at least SDK 2.9.2" + ) + + def _final_validate(config: ConfigType) -> None: - _validate_mcumgr_bootloader(config) _validate_ble_server(config) + _validate_bootloader(config) FINAL_VALIDATE_SCHEMA = _final_validate @@ -152,3 +161,70 @@ async def to_code(config: ConfigType) -> None: framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] if framework_ver >= cv.Version(2, 9, 2): zephyr_data()[KEY_SYSBUILD] = True + + bootloader = zephyr_data()[KEY_BOOTLOADER] + if bootloader != BOOTLOADER_MCUBOOT: + sections = BOOTLOADER_CONFIG[bootloader] + # Derive partition addresses from the SoftDevice and bootloader sections so + # that the DTS flash map matches what the Partition Manager produces: + # MCUboot sits immediately after the SoftDevice, then slot0, then slot1. + mcuboot_size = 0x9000 + sd_end = next(s.address + s.size for s in sections if "SoftDevice" in s.name) + bl_start = next(s.address for s in sections if "Adafruit" in s.name) + slot0_start = sd_end + mcuboot_size + # Align slot size down to a 4 KB sector boundary + slot_size = ((bl_start - slot0_start) // 2 // 0x1000) * 0x1000 + slot1_start = slot0_start + slot_size + + def _mcuboot_partition_overlay() -> str: + def part(name, start, size): + return f""" + {name}: partition@{start:x} {{ + reg = <0x{start:x} 0x{size:x}>; + }};""" + + return f""" + /delete-node/ &boot_partition; + /delete-node/ &storage_partition; + /delete-node/ &code_partition; + /delete-node/ &reserved_partition_0; + + &flash0 {{ + partitions {{ + compatible = "fixed-partitions"; + #address-cells = <1>; + #size-cells = <1>; + {part("slot0_partition", slot0_start, slot_size)} + {part("slot1_partition", slot1_start, slot_size)} + }}; + }}; + """ + + def _code_partition_overlay() -> str: + return """ + / { + chosen { + zephyr,code-partition = &slot0_partition; + }; + }; + """ + + zephyr_add_overlay(_mcuboot_partition_overlay()) + zephyr_add_overlay(_mcuboot_partition_overlay(), "mcuboot") + zephyr_add_overlay(_code_partition_overlay()) + zephyr_add_overlay(_code_partition_overlay(), "mcuboot") + # mcuboot is second bootloader. It's only task is to swap partitions. + # recovery can be done by first bootloader. Keep it small. + zephyr_add_overlay( + """ + &zephyr_udc0 { + status = "disabled"; + }; + """, + "mcuboot", + ) + zephyr_add_prj_conf("USB_DEVICE_STACK", False, image="mcuboot") + zephyr_add_prj_conf("CONSOLE", False, image="mcuboot") + zephyr_add_prj_conf( + "PM_PARTITION_SIZE_MCUBOOT", HexValue(mcuboot_size), image="mcuboot" + ) diff --git a/tests/components/ota/test.nrf52-adafruit.yaml b/tests/components/ota/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..e8ac96f051 --- /dev/null +++ b/tests/components/ota/test.nrf52-adafruit.yaml @@ -0,0 +1,4 @@ +zephyr_ble_server: + +ota: + - platform: zephyr_mcumgr From 56028d093268db5590c46847b9f55409f20818e8 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:30:52 +1200 Subject: [PATCH 1092/1815] [safe_mode] Prevent OTA rollback when entering deep sleep (#17699) --- esphome/components/safe_mode/__init__.py | 5 +++ esphome/components/safe_mode/safe_mode.cpp | 35 ++++++++++++--- esphome/components/safe_mode/safe_mode.h | 3 ++ esphome/core/defines.h | 1 + tests/component_tests/safe_mode/__init__.py | 0 .../safe_mode/test_safe_mode.py | 45 +++++++++++++++++++ .../safe_mode/test_safe_mode_default.yaml | 8 ++++ .../safe_mode/test_safe_mode_disabled.yaml | 9 ++++ .../test_safe_mode_no_shutdown_confirm.yaml | 9 ++++ .../test-ota-rollback.esp32-idf.yaml | 19 ++++++++ .../test-ota-rollback.nrf52-mcumgr.yaml | 16 +++++++ .../test-no-shutdown-confirm.esp32-idf.yaml | 11 +++++ 12 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 tests/component_tests/safe_mode/__init__.py create mode 100644 tests/component_tests/safe_mode/test_safe_mode.py create mode 100644 tests/component_tests/safe_mode/test_safe_mode_default.yaml create mode 100644 tests/component_tests/safe_mode/test_safe_mode_disabled.yaml create mode 100644 tests/component_tests/safe_mode/test_safe_mode_no_shutdown_confirm.yaml create mode 100644 tests/components/deep_sleep/test-ota-rollback.esp32-idf.yaml create mode 100644 tests/components/deep_sleep/test-ota-rollback.nrf52-mcumgr.yaml create mode 100644 tests/components/safe_mode/test-no-shutdown-confirm.esp32-idf.yaml diff --git a/esphome/components/safe_mode/__init__.py b/esphome/components/safe_mode/__init__.py index c11447e604..70096a56bc 100644 --- a/esphome/components/safe_mode/__init__.py +++ b/esphome/components/safe_mode/__init__.py @@ -16,6 +16,7 @@ from esphome.cpp_generator import RawExpression CODEOWNERS = ["@paulmonigatti", "@jsuanet", "@kbx81"] CONF_BOOT_IS_GOOD_AFTER = "boot_is_good_after" +CONF_BOOT_IS_GOOD_ON_SHUTDOWN = "boot_is_good_on_shutdown" CONF_ON_SAFE_MODE = "on_safe_mode" safe_mode_ns = cg.esphome_ns.namespace("safe_mode") @@ -37,6 +38,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_BOOT_IS_GOOD_AFTER, default="1min" ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_BOOT_IS_GOOD_ON_SHUTDOWN, default=True): cv.boolean, cv.Optional(CONF_DISABLED, default=False): cv.boolean, cv.Optional(CONF_NUM_ATTEMPTS, default="10"): cv.positive_not_null_int, cv.Optional( @@ -78,6 +80,9 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) + if config[CONF_BOOT_IS_GOOD_ON_SHUTDOWN]: + cg.add_define("USE_SAFE_MODE_BOOT_IS_GOOD_ON_SHUTDOWN") + 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)) diff --git a/esphome/components/safe_mode/safe_mode.cpp b/esphome/components/safe_mode/safe_mode.cpp index 2eb1085ee5..ce029b4f55 100644 --- a/esphome/components/safe_mode/safe_mode.cpp +++ b/esphome/components/safe_mode/safe_mode.cpp @@ -117,19 +117,31 @@ void SafeModeComponent::dump_config() { float SafeModeComponent::get_setup_priority() const { return setup_priority::AFTER_WIFI; } -void SafeModeComponent::mark_successful() { - this->clean_rtc(); - this->boot_successful_ = true; -#if defined(USE_OTA_ROLLBACK) -// Mark OTA partition as valid to prevent rollback +#ifdef USE_OTA_ROLLBACK +void SafeModeComponent::confirm_app_image_() { + // Mark the running app image as valid so the bootloader will not roll back + // to the previously flashed image #if defined(USE_ZEPHYR) if (!boot_is_img_confirmed()) { boot_write_img_confirmed(); } #elif defined(USE_ESP32) - // Mark OTA partition as valid to prevent rollback - esp_ota_mark_app_valid_cancel_rollback(); + // esp_ota_mark_app_valid_cancel_rollback() acts on the partition selected + // for the next boot, not the running one. After an OTA update those differ: + // the new image is already selected, and marking it valid before it has ever + // booted would disable rollback protection for that update. + if (esp_ota_get_running_partition() == esp_ota_get_boot_partition()) { + esp_ota_mark_app_valid_cancel_rollback(); + } #endif +} +#endif + +void SafeModeComponent::mark_successful() { + this->clean_rtc(); + this->boot_successful_ = true; +#ifdef USE_OTA_ROLLBACK + this->confirm_app_image_(); #endif // Disable loop since we no longer need to check this->disable_loop(); @@ -266,6 +278,15 @@ void SafeModeComponent::clean_rtc() { void SafeModeComponent::on_safe_shutdown() { if (this->read_rtc_() != SafeModeComponent::ENTER_SAFE_MODE_MAGIC) this->clean_rtc(); +#if defined(USE_OTA_ROLLBACK) && defined(USE_SAFE_MODE_BOOT_IS_GOOD_ON_SHUTDOWN) + // An orderly shutdown (deep sleep, restart, power off) means the firmware is + // functional, so confirm the running app image even if boot_is_good_after has + // not elapsed yet. Without this, a device that enters deep sleep shortly + // after waking would have every OTA update rolled back by the bootloader on + // the next wake. Can be turned off with boot_is_good_on_shutdown: false for + // strict rollback semantics. + this->confirm_app_image_(); +#endif } } // namespace esphome::safe_mode diff --git a/esphome/components/safe_mode/safe_mode.h b/esphome/components/safe_mode/safe_mode.h index d81b8a42d1..0633c92a78 100644 --- a/esphome/components/safe_mode/safe_mode.h +++ b/esphome/components/safe_mode/safe_mode.h @@ -42,6 +42,9 @@ class SafeModeComponent final : public Component { protected: void write_rtc_(uint32_t val); uint32_t read_rtc_(); +#ifdef USE_OTA_ROLLBACK + void confirm_app_image_(); +#endif // Group all 4-byte aligned members together to avoid padding uint32_t safe_mode_boot_is_good_after_{60000}; ///< The amount of time after which the boot is considered successful diff --git a/esphome/core/defines.h b/esphome/core/defines.h index b794254e12..2b84c72a3c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -159,6 +159,7 @@ #define USE_PREFERENCES_SYNC_EVERY_LOOP #define USE_PROVISIONING #define USE_QR_CODE +#define USE_SAFE_MODE_BOOT_IS_GOOD_ON_SHUTDOWN #define USE_SAFE_MODE_CALLBACK #define ESPHOME_SAFE_MODE_CALLBACK_COUNT 1 #define USE_SELECT diff --git a/tests/component_tests/safe_mode/__init__.py b/tests/component_tests/safe_mode/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/safe_mode/test_safe_mode.py b/tests/component_tests/safe_mode/test_safe_mode.py new file mode 100644 index 0000000000..617a3b4855 --- /dev/null +++ b/tests/component_tests/safe_mode/test_safe_mode.py @@ -0,0 +1,45 @@ +"""Tests for the safe_mode component.""" + +from collections.abc import Callable + +from esphome.core import CORE + +SHUTDOWN_DEFINE = "USE_SAFE_MODE_BOOT_IS_GOOD_ON_SHUTDOWN" + + +def _has_define(name: str) -> bool: + return any(define.name == name for define in CORE.defines) + + +def test_boot_is_good_on_shutdown_default( + generate_main: Callable[[str], str], +) -> None: + """By default, an orderly shutdown confirms the app image.""" + main_cpp = generate_main( + "tests/component_tests/safe_mode/test_safe_mode_default.yaml" + ) + + assert "safe_mode::SafeModeComponent" in main_cpp + assert _has_define(SHUTDOWN_DEFINE) + + +def test_boot_is_good_on_shutdown_disabled( + generate_main: Callable[[str], str], +) -> None: + """With boot_is_good_on_shutdown: false, the define is not added.""" + main_cpp = generate_main( + "tests/component_tests/safe_mode/test_safe_mode_no_shutdown_confirm.yaml" + ) + + assert "safe_mode::SafeModeComponent" in main_cpp + assert not _has_define(SHUTDOWN_DEFINE) + + +def test_safe_mode_disabled(generate_main: Callable[[str], str]) -> None: + """With safe_mode disabled, no component and no define are generated.""" + main_cpp = generate_main( + "tests/component_tests/safe_mode/test_safe_mode_disabled.yaml" + ) + + assert "safe_mode::SafeModeComponent" not in main_cpp + assert not _has_define(SHUTDOWN_DEFINE) diff --git a/tests/component_tests/safe_mode/test_safe_mode_default.yaml b/tests/component_tests/safe_mode/test_safe_mode_default.yaml new file mode 100644 index 0000000000..07c38250aa --- /dev/null +++ b/tests/component_tests/safe_mode/test_safe_mode_default.yaml @@ -0,0 +1,8 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + +safe_mode: diff --git a/tests/component_tests/safe_mode/test_safe_mode_disabled.yaml b/tests/component_tests/safe_mode/test_safe_mode_disabled.yaml new file mode 100644 index 0000000000..ac9b224191 --- /dev/null +++ b/tests/component_tests/safe_mode/test_safe_mode_disabled.yaml @@ -0,0 +1,9 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + +safe_mode: + disabled: true diff --git a/tests/component_tests/safe_mode/test_safe_mode_no_shutdown_confirm.yaml b/tests/component_tests/safe_mode/test_safe_mode_no_shutdown_confirm.yaml new file mode 100644 index 0000000000..64df87b381 --- /dev/null +++ b/tests/component_tests/safe_mode/test_safe_mode_no_shutdown_confirm.yaml @@ -0,0 +1,9 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + +safe_mode: + boot_is_good_on_shutdown: false diff --git a/tests/components/deep_sleep/test-ota-rollback.esp32-idf.yaml b/tests/components/deep_sleep/test-ota-rollback.esp32-idf.yaml new file mode 100644 index 0000000000..bb24377675 --- /dev/null +++ b/tests/components/deep_sleep/test-ota-rollback.esp32-idf.yaml @@ -0,0 +1,19 @@ +# Deep sleep combined with OTA while bootloader rollback support is enabled +# (the default on ESP-IDF). Entering deep sleep runs the safe shutdown hooks, +# where safe_mode confirms the running app image so the bootloader does not +# roll back a fresh OTA update when the device goes to sleep before +# boot_is_good_after has elapsed. +substitutions: + wakeup_pin: GPIO4 + +packages: + deep_sleep: !include common.yaml + deep_sleep_esp32: !include common-esp32.yaml + +wifi: + ssid: MySSID + password: password1 + +ota: + - platform: esphome + password: "superlongpasswordthatnoonewillknow" diff --git a/tests/components/deep_sleep/test-ota-rollback.nrf52-mcumgr.yaml b/tests/components/deep_sleep/test-ota-rollback.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..485490576d --- /dev/null +++ b/tests/components/deep_sleep/test-ota-rollback.nrf52-mcumgr.yaml @@ -0,0 +1,16 @@ +# Deep sleep combined with mcumgr OTA while MCUboot image rollback is enabled +# (the default on nRF52). Entering system-off deep sleep runs the safe +# shutdown hooks, where safe_mode confirms the running image so MCUboot does +# not revert a fresh OTA update on the next wake. +packages: + deep_sleep: !include common.yaml + +deep_sleep: + run_duration: 10s + +zephyr_ble_server: + +ota: + - platform: zephyr_mcumgr + transport: + ble: true diff --git a/tests/components/safe_mode/test-no-shutdown-confirm.esp32-idf.yaml b/tests/components/safe_mode/test-no-shutdown-confirm.esp32-idf.yaml new file mode 100644 index 0000000000..5ee1f308a3 --- /dev/null +++ b/tests/components/safe_mode/test-no-shutdown-confirm.esp32-idf.yaml @@ -0,0 +1,11 @@ +# Compile with OTA rollback support active (ota + safe_mode on ESP-IDF, the +# default) but boot_is_good_on_shutdown disabled, so an orderly shutdown does +# not confirm the app image; only boot_is_good_after / mark_successful do. +packages: + safe_mode: !include common-enabled.yaml + +safe_mode: + boot_is_good_on_shutdown: false + +ota: + - platform: esphome From 19511f5787e1d70e6044abcb02a06c866bb235a2 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Mon, 27 Jul 2026 13:35:51 -0700 Subject: [PATCH 1093/1815] [modbus] Command lifecycle: PDU-carrying callbacks, on_sent(), notified queue clearing (#17886) Co-authored-by: Claude Fable 5 --- esphome/components/modbus/modbus.cpp | 77 ++- esphome/components/modbus/modbus.h | 58 +- .../modbus_controller/modbus_controller.h | 3 +- tests/components/modbus/common.h | 22 + .../modbus/modbus_client_hub_test.cpp | 571 +++++++++++++++++- 5 files changed, 694 insertions(+), 37 deletions(-) create mode 100644 tests/components/modbus/common.h diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 92b4dc25ac..2561ee9069 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -313,7 +313,6 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, std::spanlast_modbus_byte_ - this->last_send_); if (device) device->on_error(request_pdu, static_cast(exception)); - } else if (device) { // Not an error response device->on_response(request_pdu, pdu); } else { // Not an error response, but no device to respond to @@ -507,16 +506,25 @@ void ModbusClientHub::send_next_frame_() { return; } - ModbusDeviceCommand &command = this->tx_buffer_.front(); - - if (this->send_frame_(command.frame)) { - this->waiting_for_response_ = std::move(command); - } else { - if (command.device) - command.device->on_not_sent(); - } - + // Move the command out and pop BEFORE attempting the send: no callback may run while the frame still + // sits in the queue (the same principle as the clear sweep). A failure callback that sends would + // otherwise queue a new frame and pop_front() could discard the wrong one - and the deque + // reference / PDU span could be invalidated mid-callback. + ModbusDeviceCommand command = std::move(this->tx_buffer_.front()); this->tx_buffer_.pop_front(); + ModbusClientDevice *device = command.device; + const bool sent = this->send_frame_(command.frame); + + if (sent) { + // The frame now lives in the waiting slot; its PDU is the frame without the leading address and + // trailing CRC. + ModbusDeviceCommand &wfr = this->waiting_for_response_.emplace(std::move(command)); + if (device != nullptr) + device->on_sent(wfr.frame.pdu()); + } else { + if (device != nullptr) + device->trigger_not_sent(command.frame.pdu()); + } if (!this->tx_buffer_.empty()) { ESP_LOGV(TAG, "Write queue contains %zu items.", this->tx_buffer_.size()); @@ -574,7 +582,7 @@ void ModbusServerHub::send_exception_(uint8_t address, uint8_t function_code, Ex void ModbusClientHub::notify_no_response_(ModbusDeviceCommand &wfr) { if (wfr.device == nullptr) return; - const bool retry = wfr.device->on_no_response(); + const bool retry = wfr.device->on_no_response(wfr.frame.pdu()); // The callback may have detached the device (e.g. clear_tx_queue_for_device()); honor the detach // over the retry request rather than re-queueing a frame that can no longer be routed. if (retry && wfr.device != nullptr) @@ -588,7 +596,7 @@ void ModbusClientHub::requeue_waiting_frame_(ModbusDeviceCommand &wfr) { if (this->tx_buffer_.size() >= MODBUS_TX_BUFFER_SIZE) { ESP_LOGE(TAG, "Write buffer full, dropped retry for address %" PRIu8, frame.address()); if (wfr.device != nullptr) - wfr.device->on_not_sent(); + wfr.device->trigger_not_sent(frame.pdu()); return; } // Re-queue a copy (not a move): the waiting entry may have to survive as an interrupted shell. @@ -598,16 +606,16 @@ void ModbusClientHub::requeue_waiting_frame_(ModbusDeviceCommand &wfr) { // Raw send for client: pushes to tx queue. Everything except the CRC must be contained in payload. void ModbusClientHub::send_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device) { if (pdu.empty()) { - if (device) - device->on_not_sent(); + if (device != nullptr) + device->trigger_not_sent(pdu); return; } // Bound the PDU so the wire frame (address + pdu + CRC) stays within the Modbus RTU 256-byte limit. if (pdu.size() > MAX_PDU_SIZE) { ESP_LOGE(TAG, "Frame too large, dropped: %" PRIu8 ":%zu bytes", address, pdu.size()); - if (device) - device->on_not_sent(); + if (device != nullptr) + device->trigger_not_sent(pdu); return; } @@ -624,17 +632,36 @@ void ModbusClientHub::send_pdu(uint8_t address, std::span pdu, Mo #endif ESP_LOGE(TAG, "Write buffer full, dropped: %" PRIu8 ":%s", address, format_hex_pretty_to(hex_buf, pdu.data(), pdu.size())); - if (device) - device->on_not_sent(); + if (device != nullptr) + device->trigger_not_sent(pdu); } } void ModbusClientHub::clear_tx_queue_for_address(uint8_t address, bool clear_sent) { - // Remove any pending commands for this address from the tx buffer - auto &tx_buffer = this->tx_buffer_; - tx_buffer.erase(std::remove_if(tx_buffer.begin(), tx_buffer.end(), - [address](const ModbusDeviceCommand &cmd) { return cmd.frame.address() == address; }), - tx_buffer.end()); + // Drop the queued frames for this address, delivering on_not_sent() to each frame's owner: other + // devices talking to the same physical device (e.g. a modbus_client action alongside a controller that + // just went offline) must observe the drop, or their command never resolves. Mark first, then sweep + // only marked frames: anything a callback re-queues is unmarked, so it + // is never swept - or re-notified - by the clear that triggered it. Each marked frame is moved out and erased BEFORE + // its callback runs, so handlers see a consistent queue; termination is guaranteed because only the initially-marked + // frames are ever swept. + for (auto &cmd : this->tx_buffer_) { + if (cmd.frame.address() == address) + cmd.marked_for_deletion = true; + } + for (;;) { + auto it = std::find_if(this->tx_buffer_.begin(), this->tx_buffer_.end(), + [](const ModbusDeviceCommand &cmd) { return cmd.marked_for_deletion; }); + if (it == this->tx_buffer_.end()) + break; + ModbusDeviceCommand dropped = std::move(*it); + this->tx_buffer_.erase(it); + // The sweep delivers through the same per-device guard as refusals: a device clearing from inside + // its own on_not_sent() gets its remaining frames resolved silently (documented in the lifecycle + // contract), other owners are notified normally, and every nested clear stays bounded. + if (dropped.device != nullptr) + dropped.device->trigger_not_sent(dropped.frame.pdu()); + } if (clear_sent && this->waiting_for_response_.has_value() && this->waiting_for_response_.value().device) { if (this->waiting_for_response_.value().frame.address() == address) { @@ -662,8 +689,8 @@ void ModbusClientHub::clear_tx_queue_for_device(ModbusClientDevice *device) { void ModbusClientHub::send_raw(const std::vector &payload, ModbusClientDevice *device) { if (payload.size() < 2) { - if (device) - device->on_not_sent(); + if (device != nullptr) + device->trigger_not_sent({}); // too short to contain a PDU return; } this->send_pdu(payload[0], std::span(payload).subspan(1), device); diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index d83075eda1..2204c0f82b 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -94,6 +94,9 @@ struct ModbusDeviceCommand { ModbusClientDevice *device; ModbusFrame frame; bool interrupted{false}; + /// Marked by clear_tx_queue_for_address() before it starts notifying, so frames re-queued by an + /// on_not_sent() callback (which are unmarked) are never swept by the clear that triggered them. + bool marked_for_deletion{false}; ModbusDeviceCommand(ModbusClientDevice *device, uint8_t address, const uint8_t *src, uint16_t len) : device(device), frame(address, src, len) {} @@ -123,6 +126,10 @@ class ModbusClientHub : public Modbus { void send_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device = nullptr); ESPDEPRECATED("Use send_pdu(payload[0], , device) instead. Removed in 2027.2.0", "2026.8.0") void send_raw(const std::vector &payload, ModbusClientDevice *device = nullptr); + // Drop the queued commands for an address; every dropped frame resolves via its owner's on_not_sent(), + // so other devices sharing the address observe the drop. The in-flight frame is only detached (silently) + // when clear_sent is set. clear_tx_queue_for_device() SILENTLY discards the caller's own frames + // (supersede/teardown semantics); see the lifecycle note on ModbusClientDevice. void clear_tx_queue_for_address(uint8_t address, bool clear_sent = true); void clear_tx_queue_for_device(ModbusClientDevice *device); @@ -177,6 +184,25 @@ class ModbusServerHub : public Modbus { // Transaction status: std::nullopt on success, otherwise a Modbus exception code using ResponseStatus = std::optional; +/// Command lifecycle: each accepted command (a send_pdu()/typed-helper call, or a hub re-queue from +/// a retry) ends in exactly ONE terminal callback: on_response() (valid response), on_error() +/// (exception response), on_no_response() (timeout or interrupted transaction), or on_not_sent() +/// (never transmitted: send failure or full queue). on_sent() is additional, not +/// terminal: it fires once per wire transmission, before whichever of data/error/no_response follows, +/// and never for a command that ends in on_not_sent(). +/// The exceptions to "exactly one terminal": +/// - clear_tx_queue_for_device() drops the caller's OWN queued commands SILENTLY (supersede/teardown +/// semantics), and both clear variants detach the in-flight frame silently. +/// clear_tx_queue_for_address() DOES resolve every queued frame it drops via the owner's +/// on_not_sent() (delivered one at a time, after that frame leaves the queue). +/// - while a device's own on_not_sent() is on the stack, further on_not_sent() deliveries to THAT +/// device are dropped (see trigger_not_sent()). In particular, a clear issued from inside your own +/// on_not_sent() resolves your remaining frames silently - treat it like +/// clear_tx_queue_for_device(): you cleared them, you know. Other owners are still notified. +/// Sending from inside on_not_sent() is hazardous: the notification may itself mean the queue is full +/// or refusing, and this device's retry that is refused again is dropped WITHOUT a callback (the +/// guard above, which bounds what would otherwise be unbounded re-entry) - prefer re-sending from a +/// later trigger or the component's update()/loop(). class ModbusClientDevice { public: ModbusClientDevice() = default; @@ -204,21 +230,34 @@ class ModbusClientDevice { virtual void on_error(std::span request_pdu, ExceptionCode exception_code) { this->dispatch_response_(request_pdu, {}, exception_code); } - - /// Called when no request could be sent (e.g. queue full, transmission blocked) + /// Called when no request could be sent (e.g. queue full, transmission blocked). /// Do not attempt to queue a command in this callback. - /// (The on_modbus_* names are signature-identical renames, so the new defaults forward to the old - /// virtuals: external devices overriding the old names keep working through the deprecation window. - /// Remove the forwards together with the deprecated names.) - virtual void on_not_sent() { + /// (The on_modbus_* names below are deprecated pre-rename spellings; the defaults forward so + /// external devices overriding them keep working through the deprecation window.) + virtual void on_not_sent(std::span request_pdu) { #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wdeprecated-declarations" this->on_modbus_not_sent(); #pragma GCC diagnostic pop } + /// Non-virtual entry point the hub uses for EVERY on_not_sent() delivery (refusals and clear-queue + /// sweeps alike). While this device's on_not_sent() is on the stack, further deliveries to it are + /// dropped: this bounds every send->refuse and clear->sweep recursion, including cycles through + /// multiple devices (each device can appear on the stack at most once). The documented cost: a clear + /// issued from inside your own on_not_sent() resolves your remaining frames SILENTLY, while other + /// owners are still notified (their guards are not set) - see the lifecycle contract above. + void trigger_not_sent(std::span request_pdu) { + if (this->notifying_not_sent_) + return; + this->notifying_not_sent_ = true; + this->on_not_sent(request_pdu); + this->notifying_not_sent_ = false; + } + /// Called when this device's frame is actually written to the wire + virtual void on_sent(std::span request_pdu) {} /// Called when no matching, uninterrupted response arrived; return true to have the hub re-queue the frame for a /// retry. The hub does not bound retries: the device is responsible for limiting them. - virtual bool on_no_response() { + virtual bool on_no_response(std::span request_pdu) { #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wdeprecated-declarations" return this->on_modbus_no_response(); @@ -287,7 +326,8 @@ class ModbusClientDevice { ESPDEPRECATED("Use send_pdu() instead (the device address is prepended for you). Removed in 2027.2.0", "2026.8.0") void send_raw(const std::vector &payload) { if (payload.empty()) { - this->on_not_sent(); // match the hub-level send_raw(): a refused send is always signalled + // Through the guard like every other delivery, so a handler calling send_raw({}) cannot recurse. + this->trigger_not_sent({}); return; } this->parent_->send_pdu(payload[0], std::span(payload).subspan(1), this); @@ -345,6 +385,8 @@ class ModbusClientDevice { ResponseStatus status); ModbusClientHub *parent_{nullptr}; + /// True while this device's on_not_sent() is on the stack (see trigger_not_sent()). + bool notifying_not_sent_{false}; uint8_t address_{0}; bool custom_response_warned_{false}; // first unhandled custom response warns; repeats log at VERBOSE }; diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 9616c1d935..928054f1ab 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -306,7 +306,8 @@ class ModbusController final : public PollingComponent, public modbus::ModbusCli /// controller-level raw sends stay supported until the command machinery is replaced. void send_raw(const std::vector &payload) { if (payload.empty()) { - this->on_not_sent(); // match the hub-level send_raw(): a refused send is always signalled + // Through the guard like every other delivery, so a handler calling send_raw({}) cannot recurse. + this->trigger_not_sent({}); return; } this->parent_->send_pdu(payload[0], std::span(payload).subspan(1), this); diff --git a/tests/components/modbus/common.h b/tests/components/modbus/common.h new file mode 100644 index 0000000000..659b72014c --- /dev/null +++ b/tests/components/modbus/common.h @@ -0,0 +1,22 @@ +#pragma once +#include +#include "esphome/components/uart/uart_component.h" + +namespace esphome::modbus::testing { + +// A UART that discards all writes, for tests that never inspect the wire. +class NullUART : public uart::UARTComponent { + public: + NullUART() { this->set_baud_rate(115200); } + void write_array(const uint8_t *data, size_t len) override {} + bool peek_byte(uint8_t *data) override { return false; } + bool read_array(uint8_t *data, size_t len) override { return false; } + size_t available() override { return 0; } + uart::UARTFlushResult flush() override { return uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS; } +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif + void check_logger_conflict() override {} +}; + +} // namespace esphome::modbus::testing diff --git a/tests/components/modbus/modbus_client_hub_test.cpp b/tests/components/modbus/modbus_client_hub_test.cpp index 6335a75775..11bc10200d 100644 --- a/tests/components/modbus/modbus_client_hub_test.cpp +++ b/tests/components/modbus/modbus_client_hub_test.cpp @@ -1,9 +1,13 @@ #include #include +#include #include +#include +#include "common.h" #include "esphome/components/modbus/modbus.h" +#include "esphome/core/hal.h" namespace esphome::modbus::testing { @@ -16,12 +20,14 @@ class NoResponseProbeHub : public ModbusClientHub { public: size_t queued_frames() const { return this->tx_buffer_.size(); } const ModbusDeviceCommand &front() const { return this->tx_buffer_.front(); } + const ModbusDeviceCommand &queued(size_t i) const { return this->tx_buffer_[i]; } bool waiting() const { return this->waiting_for_response_.has_value(); } const ModbusDeviceCommand &waiting_command() const { EXPECT_TRUE(this->waiting_for_response_.has_value()); return *this->waiting_for_response_; // NOLINT(bugprone-unchecked-optional-access) } + void send_next_for_test() { this->send_next_frame_(); } void force_send_front() { this->waiting_for_response_ = std::move(this->tx_buffer_.front()); this->tx_buffer_.pop_front(); @@ -41,7 +47,7 @@ class NoResponseProbeHub : public ModbusClientHub { class RetryingDevice : public ModbusClientDevice { public: RetryingDevice(ModbusClientHub *hub, uint8_t address, bool retry) : ModbusClientDevice(hub, address), retry_(retry) {} - bool on_no_response() override { + bool on_no_response(std::span request_pdu) override { this->no_response_count_++; return this->retry_; } @@ -55,7 +61,7 @@ class RetryingDevice : public ModbusClientDevice { class ClearingRetryDevice : public ModbusClientDevice { public: ClearingRetryDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} - bool on_no_response() override { + bool on_no_response(std::span request_pdu) override { this->no_response_count_++; this->clear_tx_queue_for_device(); // detaches this device from the waiting slot mid-callback return true; // and still requests a retry @@ -176,6 +182,565 @@ TEST(ModbusClientHubNoResponse, MidCallbackClearCancelsRetry) { EXPECT_FALSE(hub.waiting()); } +// A device whose sent/not-sent callbacks are counted. +namespace { +class SentCountingDevice : public ModbusClientDevice { + public: + SentCountingDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_sent(std::span request_pdu) override { + this->sent_count_++; + this->last_sent_pdu_.assign(request_pdu.begin(), request_pdu.end()); + } + void on_not_sent(std::span request_pdu) override { + this->not_sent_count_++; + this->last_not_sent_pdu_.assign(request_pdu.begin(), request_pdu.end()); + } + int sent_count_{0}; + int not_sent_count_{0}; + std::vector last_sent_pdu_; + std::vector last_not_sent_pdu_; +}; +} // namespace + +// on_sent() fires when the frame goes onto the wire, not when it is queued. +TEST(ModbusClientHubSent, FiresOnWireNotOnQueue) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); // frame timing derives from the baud rate + SentCountingDevice device(&hub, 0x02); + + device.send_pdu(read_pdu()); + EXPECT_EQ(device.sent_count_, 0); // queued only - nothing on the wire yet + + hub.send_next_for_test(); + EXPECT_EQ(device.sent_count_, 1); + EXPECT_EQ(device.not_sent_count_, 0); + // The callback identifies which command transmitted: it carries the request PDU. + EXPECT_EQ(device.last_sent_pdu_, (std::vector(READ_PDU, READ_PDU + sizeof(READ_PDU)))); + EXPECT_TRUE(hub.waiting()); +} + +// Counts response deliveries so requeue semantics can be pinned end to end. +namespace { +class DataCountingDevice : public ModbusClientDevice { + public: + DataCountingDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_response(std::span request_pdu, std::span response_pdu) override { + this->data_count_++; + } + void on_error(std::span request_pdu, ExceptionCode exception_code) override { this->error_count_++; } + bool on_no_response(std::span request_pdu) override { + this->no_response_count_++; + this->last_no_response_pdu_.assign(request_pdu.begin(), request_pdu.end()); + if (this->retries_ == 0) + return false; + this->retries_--; + return true; + } + void on_not_sent(std::span request_pdu) override { + this->not_sent_count_++; + this->last_not_sent_pdu_.assign(request_pdu.begin(), request_pdu.end()); + } + void on_sent(std::span request_pdu) override { this->sent_count_++; } + int terminals() const { + return this->data_count_ + this->error_count_ + this->no_response_count_ + this->not_sent_count_; + } + int data_count_{0}; + int error_count_{0}; + int no_response_count_{0}; + int not_sent_count_{0}; + int sent_count_{0}; + int retries_{0}; + std::vector last_not_sent_pdu_; + std::vector last_no_response_pdu_; +}; + +// Runs full send/respond cycles until the queue drains; returns the number of cycles executed. +int drain_with_responses(NoResponseProbeHub &hub, std::span response_pdu, int max_cycles = 10) { + int cycles = 0; + while (hub.queued_frames() != 0 && cycles < max_cycles) { + hub.force_send_front(); + hub.receive_frame_for_test(0x02, response_pdu); + cycles++; + } + return cycles; +} +} // namespace + +constexpr uint8_t OK_RESPONSE[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + +// One request produces exactly one data callback. +TEST(ModbusClientHubCallbackCount, SingleReadSingleCallback) { + NoResponseProbeHub hub; + DataCountingDevice device(&hub, 0x02); + + device.send_pdu(read_pdu()); + drain_with_responses(hub, OK_RESPONSE); + + EXPECT_EQ(device.data_count_, 1); + EXPECT_EQ(device.not_sent_count_, 0); + EXPECT_EQ(hub.queued_frames(), 0u); + EXPECT_FALSE(hub.waiting()); +} + +// An exception response is a terminal on its own: exactly one on_error(), no others, +// preceded by exactly one on_sent(). +TEST(ModbusClientHubCallbackCount, ErrorResponseIsSoleTerminal) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + DataCountingDevice device(&hub, 0x02); + + device.send_pdu(read_pdu()); + hub.send_next_for_test(); + const uint8_t exception_response[] = {0x83, 0x02}; + hub.receive_frame_for_test(0x02, exception_response); + + EXPECT_EQ(device.error_count_, 1); + EXPECT_EQ(device.terminals(), 1); + EXPECT_EQ(device.sent_count_, 1); +} + +// A timeout is a terminal on its own: exactly one on_no_response(), preceded by one +// on_sent(); a refused duplicate ends in on_not_sent() with NO on_sent(). +TEST(ModbusClientHubCallbackCount, NoResponseIsSoleTerminalAndNotSentHasNoSent) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + DataCountingDevice device(&hub, 0x02); + + device.send_pdu(read_pdu()); + hub.send_next_for_test(); + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_EQ(device.terminals(), 1); + EXPECT_EQ(device.sent_count_, 1); + + // A refused send (empty PDU) is a not_sent terminal, never sent. + const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; + device.send_pdu(write_pdu); + device.send_pdu(std::span{}); + EXPECT_EQ(device.not_sent_count_, 1); + EXPECT_EQ(device.terminals(), 2); // the accepted write is still queued - no terminal for it yet + EXPECT_EQ(device.sent_count_, 1); // and it has not transmitted yet + + // Drain it: the write echo response is its data terminal, and the books balance. + hub.send_next_for_test(); + hub.receive_frame_for_test(0x02, write_pdu); + EXPECT_EQ(device.data_count_, 1); + EXPECT_EQ(device.terminals(), 3); // 3 accepted lifecycles, 3 terminals + EXPECT_EQ(device.sent_count_, 2); // 2 transmissions (read + write); the refused send never sent +} + +// A device-requested retry starts a new lifecycle: each transmission gets its own sent + terminal. +TEST(ModbusClientHubCallbackCount, RetryLifecyclesEachGetSentAndTerminal) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + DataCountingDevice device(&hub, 0x02); + device.retries_ = 1; // ask for exactly one retry + + device.send_pdu(read_pdu()); + hub.send_next_for_test(); + hub.timeout_waiting(); // lifecycle 1: sent + no_response (retry requested -> re-queued) + ASSERT_EQ(hub.queued_frames(), 1u); + hub.send_next_for_test(); + hub.timeout_waiting(); // lifecycle 2: sent + no_response (retry declined -> done) + + EXPECT_EQ(device.no_response_count_, 2); + EXPECT_EQ(device.terminals(), 2); + EXPECT_EQ(device.sent_count_, 2); + EXPECT_EQ(hub.queued_frames(), 0u); + // The retried lifecycle's timeout carries the SAME request PDU as the first attempt. + EXPECT_EQ(device.last_no_response_pdu_, std::vector(READ_PDU, READ_PDU + sizeof(READ_PDU))); +} + +// A retry re-queue that finds the buffer full is refused like any other send: the device gets +// on_not_sent() carrying the request PDU (the previously uncovered requeue_waiting_frame_ branch). +TEST(ModbusClientHubCallbackCount, FullQueueRetryRefusalDeliversNotSentWithPdu) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + DataCountingDevice device(&hub, 0x02); + device.retries_ = 1; + SentCountingDevice filler(&hub, 0x05); + + device.send_pdu(read_pdu()); + hub.force_send_front(); // in flight + // Fill the queue with distinct frames. + for (uint16_t i = 0; i < MODBUS_TX_BUFFER_SIZE; i++) { + const uint8_t fill[] = {0x03, static_cast(i >> 8), static_cast(i & 0xFF), 0x00, 0x01}; + filler.send_pdu(fill); + } + ASSERT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE); + + hub.timeout_waiting(); // retry requested, but the re-queue is refused: not_sent terminal instead + + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_EQ(device.not_sent_count_, 1); + EXPECT_EQ(device.last_not_sent_pdu_, std::vector(READ_PDU, READ_PDU + sizeof(READ_PDU))); + EXPECT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE); +} + +// The deprecated device-side send_raw() refusal delivers through the same guard as every other +// path: a handler that reacts to its own refusal with another empty send_raw() stays bounded. +namespace { +class SendRawOnNotSentDevice : public ModbusClientDevice { + public: + SendRawOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_not_sent(std::span request_pdu) override { + this->not_sent_count_++; +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + this->send_raw({}); // refused again; the guard must suppress the nested delivery +#pragma GCC diagnostic pop + } + int not_sent_count_{0}; +}; +} // namespace + +TEST(ModbusClientHubQueue, SendRawRefusalIsGuardedAgainstRecursion) { + NoResponseProbeHub hub; + SendRawOnNotSentDevice device(&hub, 0x02); +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + device.send_raw({}); // empty payload refused -> on_not_sent -> nested send_raw({}) suppressed +#pragma GCC diagnostic pop + EXPECT_EQ(device.not_sent_count_, 1); +} + +namespace { +// A device that chains a follow-up send from inside on_sent(). +class ChainOnSentDevice : public ModbusClientDevice { + public: + ChainOnSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_sent(std::span request_pdu) override { + if (!this->chained_) { + this->chained_ = true; + const uint8_t follow[] = {0x03, 0x00, 0x09, 0x00, 0x01}; // read holding 0x0009 x1 + this->send_pdu(follow); + } + } + bool chained_{false}; +}; +} // namespace + +// clear_tx_queue_for_address() resolves every dropped frame via its owner's on_not_sent(), so a device +// sharing the address with the clearer (e.g. a modbus_client action alongside an offline controller) +// observes the drop; frames for other addresses are untouched. +TEST(ModbusClientHubQueue, ClearAddressQueueNotifiesEveryOwner) { + NoResponseProbeHub hub; + SentCountingDevice controller_like(&hub, 0x02); + SentCountingDevice bystander_same(&hub, 0x02); + SentCountingDevice bystander_other(&hub, 0x03); + + const uint8_t read_a[] = {0x03, 0x01, 0x00, 0x00, 0x02}; + const uint8_t read_b[] = {0x03, 0x02, 0x00, 0x00, 0x02}; + const uint8_t read_c[] = {0x03, 0x03, 0x00, 0x00, 0x02}; + controller_like.send_pdu(read_a); + bystander_same.send_pdu(read_b); + bystander_other.send_pdu(read_c); + ASSERT_EQ(hub.queued_frames(), 3u); + + controller_like.clear_tx_queue_for_address(false); + + ASSERT_EQ(hub.queued_frames(), 1u); // only the other-address frame remains + EXPECT_EQ(hub.front().frame.address(), 0x03); + EXPECT_EQ(controller_like.not_sent_count_, 1); + EXPECT_EQ(bystander_same.not_sent_count_, 1); + EXPECT_EQ(bystander_other.not_sent_count_, 0); + // each owner saw its own request PDU + EXPECT_EQ(bystander_same.last_not_sent_pdu_, std::vector(std::begin(read_b), std::end(read_b))); +} + +namespace { +// Re-sends its frame once from inside on_not_sent - the re-queued frame must survive the sweep. +class ResendOnNotSentDevice : public ModbusClientDevice { + public: + ResendOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_not_sent(std::span request_pdu) override { + this->not_sent_count_++; + if (this->not_sent_count_ == 1) { + const uint8_t again[] = {0x06, 0x00, 0x40, 0x00, 0x01}; + this->send_pdu(again); + } + } + int not_sent_count_{0}; +}; +} // namespace + +// A handler that re-sends to the same address from inside on_not_sent() neither corrupts the sweep nor +// loops it: only initially-marked frames are swept, so the re-queued frame stays queued. +TEST(ModbusClientHubQueue, ClearAddressReentrantResendSurvives) { + NoResponseProbeHub hub; + ResendOnNotSentDevice device(&hub, 0x02); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; + device.send_pdu(read); + ASSERT_EQ(hub.queued_frames(), 1u); + + hub.clear_tx_queue_for_address(0x02, false); + + // The original frame resolved via on_not_sent; the re-send from inside that callback remains queued. + EXPECT_EQ(device.not_sent_count_, 1); + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_EQ(hub.front().frame.address(), 0x02); +} + +namespace { +// Retries from EVERY on_not_sent - against a full queue this recursed without bound before the guard. +class AlwaysRetryDevice : public ModbusClientDevice { + public: + AlwaysRetryDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_not_sent(std::span request_pdu) override { + this->not_sent_count_++; + const uint8_t again[] = {0x03, 0x00, 0x50, 0x00, 0x01}; + this->send_pdu(again); + } + int not_sent_count_{0}; +}; + +// From inside on_not_sent, clears ANOTHER address - those victims must still be notified (the per-device +// guard suppresses deliveries only to a device already inside its own on_not_sent()). +class ClearOtherOnNotSentDevice : public ModbusClientDevice { + public: + ClearOtherOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_not_sent(std::span request_pdu) override { + this->not_sent_count_++; + this->parent_->clear_tx_queue_for_address(0x03, false); + } + int not_sent_count_{0}; +}; +} // namespace + +// A handler that retries from every on_not_sent() against a FULL queue must not recurse: the first +// refusal notifies once, the nested refusal is dropped without a callback (the documented guard). +TEST(ModbusClientHubQueue, FullQueueRetryFromNotSentDoesNotRecurse) { + NoResponseProbeHub hub; + SentCountingDevice filler(&hub, 0x05); + AlwaysRetryDevice retrier(&hub, 0x02); + + // Fill the queue with distinct frames. + for (uint16_t i = 0; i < MODBUS_TX_BUFFER_SIZE; i++) { + const uint8_t fill[] = {0x03, static_cast(i >> 8), static_cast(i & 0xFF), 0x00, 0x01}; + filler.send_pdu(fill); + } + ASSERT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; + retrier.send_pdu(read); // refused (full) -> on_not_sent -> retry -> refused under the guard, silently + + EXPECT_EQ(retrier.not_sent_count_, 1); + EXPECT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE); +} + +namespace { +// From inside on_not_sent, triggers ANOTHER device's send (which will be refused too). +class SendOtherOnNotSentDevice : public ModbusClientDevice { + public: + SendOtherOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_not_sent(std::span request_pdu) override { + this->not_sent_count_++; + if (this->other_ != nullptr) { + const uint8_t read[] = {0x03, 0x00, 0x60, 0x00, 0x01}; + this->other_->send_pdu(read); + } + } + ModbusClientDevice *other_{nullptr}; + int not_sent_count_{0}; +}; +} // namespace + +// The refusal recursion guard is per-device: a refusal that lands on a DIFFERENT device while one +// device's notification is on the stack must still deliver - that device did not cause the recursion +// and would otherwise silently lose its terminal callback. +TEST(ModbusClientHubQueue, RefusalForOtherDeviceDeliversDuringNotification) { + NoResponseProbeHub hub; + SentCountingDevice filler(&hub, 0x05); + SendOtherOnNotSentDevice first(&hub, 0x02); + SentCountingDevice second(&hub, 0x03); + first.other_ = &second; + + // Fill the queue with distinct frames. + for (uint16_t i = 0; i < MODBUS_TX_BUFFER_SIZE; i++) { + const uint8_t fill[] = {0x03, static_cast(i >> 8), static_cast(i & 0xFF), 0x00, 0x01}; + filler.send_pdu(fill); + } + ASSERT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; + first.send_pdu(read); // refused -> first.on_not_sent -> second's send refused -> second notified + + EXPECT_EQ(first.not_sent_count_, 1); + EXPECT_EQ(second.not_sent_count_, 1); +} + +// Two devices whose handlers each trigger the other's send cannot recurse without bound: each device +// can be on the notification stack at most once, so the cycle dies as soon as it returns to a device +// whose own on_not_sent() is still running. +TEST(ModbusClientHubQueue, TwoDeviceRefusalCycleTerminates) { + NoResponseProbeHub hub; + SentCountingDevice filler(&hub, 0x05); + SendOtherOnNotSentDevice first(&hub, 0x02); + SendOtherOnNotSentDevice second(&hub, 0x03); + first.other_ = &second; + second.other_ = &first; + + // Fill the queue with distinct frames. + for (uint16_t i = 0; i < MODBUS_TX_BUFFER_SIZE; i++) { + const uint8_t fill[] = {0x03, static_cast(i >> 8), static_cast(i & 0xFF), 0x00, 0x01}; + filler.send_pdu(fill); + } + ASSERT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; + first.send_pdu(read); // refuse -> first -> second refused -> second -> first suppressed -> unwind + + EXPECT_EQ(first.not_sent_count_, 1); + EXPECT_EQ(second.not_sent_count_, 1); +} + +namespace { +// From inside on_not_sent, clears its OWN address - its remaining queued frames resolve silently +// (the guard suppresses self-deliveries), while other owners on the address are still notified. +class ClearOwnAddressOnNotSentDevice : public ModbusClientDevice { + public: + ClearOwnAddressOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_not_sent(std::span request_pdu) override { + this->not_sent_count_++; + this->clear_tx_queue_for_address(/*clear_sent=*/false); + } + int not_sent_count_{0}; +}; +} // namespace + +// The documented cost of the per-device guard: a clear issued from inside your own on_not_sent() +// resolves your remaining frames silently (like clear_tx_queue_for_device() - you cleared them, you +// know), while other owners sharing the address are still notified. +TEST(ModbusClientHubQueue, SelfClearFromNotSentSilentForClearerNotifiesOthers) { + NoResponseProbeHub hub; + ClearOwnAddressOnNotSentDevice clearer(&hub, 0x02); + SentCountingDevice bystander(&hub, 0x02); + + const uint8_t read_a[] = {0x03, 0x00, 0x10, 0x00, 0x01}; + const uint8_t read_b[] = {0x03, 0x00, 0x20, 0x00, 0x01}; + const uint8_t read_c[] = {0x03, 0x00, 0x30, 0x00, 0x01}; + clearer.send_pdu(read_a); + clearer.send_pdu(read_b); + bystander.send_pdu(read_c); + ASSERT_EQ(hub.queued_frames(), 3u); + + clearer.send_pdu(std::span{}); // refused (empty) -> the handler clears the shared address + + EXPECT_EQ(clearer.not_sent_count_, 1); // only the refusal; the two swept frames resolve silently + EXPECT_EQ(bystander.not_sent_count_, 1); // the bystander's swept frame is still notified + EXPECT_EQ(hub.queued_frames(), 0u); +} + +// The guard must not over-suppress: a sweep started from inside on_not_sent() still delivers its +// victims' notifications (only nested refusals are silenced). +TEST(ModbusClientHubQueue, NestedClearFromNotSentStillNotifiesVictims) { + NoResponseProbeHub hub; + ClearOtherOnNotSentDevice clearer(&hub, 0x02); + SentCountingDevice victim(&hub, 0x03); + + const uint8_t read_a[] = {0x03, 0x00, 0x10, 0x00, 0x01}; + const uint8_t read_b[] = {0x03, 0x00, 0x20, 0x00, 0x01}; + clearer.send_pdu(read_a); + victim.send_pdu(read_b); + ASSERT_EQ(hub.queued_frames(), 2u); + + hub.clear_tx_queue_for_address(0x02, false); // clearer's on_not_sent clears address 0x03 in turn + + EXPECT_EQ(clearer.not_sent_count_, 1); + EXPECT_EQ(victim.not_sent_count_, 1); // delivered despite arriving from a nested sweep + EXPECT_EQ(hub.queued_frames(), 0u); +} + +namespace { +// tx_blocked() flips to blocked after the first check, so send_next_frame_() passes its own gate but +// send_frame_() refuses - a deterministic transmit failure. +class FlakyBlockHub : public NoResponseProbeHub { + public: + bool tx_blocked() override { + this->tx_blocked_calls_++; + return this->tx_blocked_calls_ > 1; + } + int tx_blocked_calls_{0}; +}; + +// Reacts to a transmit failure by sending another frame from inside the failure callback. +class WriteOnNotSentDevice : public ModbusClientDevice { + public: + WriteOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_not_sent(std::span request_pdu) override { + this->not_sent_count_++; + const uint8_t write[] = {0x06, 0x00, 0x40, 0x01, 0x02}; + this->send_pdu(write); + } + int not_sent_count_{0}; +}; + +} // namespace + +// A transmit failure must resolve with the failed frame OUT of the queue before its on_not_sent runs: a +// handler that reacts by sending a new frame must not have that frame discarded by the pop that +// follows - the failed frame is popped first, the new frame survives. +TEST(ModbusClientHubQueue, TransmitFailurePopsBeforeNotify) { + FlakyBlockHub hub; + WriteOnNotSentDevice device(&hub, 0x02); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; + device.send_pdu(read); + ASSERT_EQ(hub.queued_frames(), 1u); + + hub.send_next_for_test(); // tx_blocked gate passes, send_frame_ refuses -> failure path + + EXPECT_EQ(device.not_sent_count_, 1); + ASSERT_EQ(hub.queued_frames(), 1u); // the handler's write survives... + EXPECT_EQ(hub.front().frame.pdu()[0], 0x06); // ...and it is the write, not the failed read +} + +// clear_tx_queue_for_device() drops queued frames SILENTLY - no terminal callback (the documented +// exception to the exactly-one-terminal contract; used during teardown/offline handling). +TEST(ModbusClientHubQueue, ClearDeviceQueueDropsSilently) { + NoResponseProbeHub hub; + SentCountingDevice device(&hub, 0x02); + + const uint8_t read_a[] = {0x03, 0x01, 0x00, 0x00, 0x02}; + const uint8_t read_b[] = {0x03, 0x02, 0x00, 0x00, 0x02}; + device.send_pdu(read_a); + device.send_pdu(read_b); + ASSERT_EQ(hub.queued_frames(), 2u); + + device.clear_tx_queue_for_device(); + + EXPECT_EQ(hub.queued_frames(), 0u); + EXPECT_EQ(device.not_sent_count_, 0); // silent drop: no terminal callback +} + +// A send_pdu() from inside on_sent() enqueues behind the in-flight frame rather than sending +// immediately or corrupting the in-flight transaction. +TEST(ModbusClientHubSent, ReentrantSendFromOnSentQueues) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + ChainOnSentDevice device(&hub, 0x02); + + device.send_pdu(read_pdu()); + hub.send_next_for_test(); // first frame goes on the wire -> on_sent chains a follow-up + + EXPECT_TRUE(hub.waiting()); // first frame is in flight + ASSERT_EQ(hub.queued_frames(), 1u); // the follow-up queued behind it, not sent + EXPECT_EQ(hub.queued(0).frame.pdu()[2], 0x09); // it is the chained read (start address 0x0009) +} + namespace { // Overrides only the DEPRECATED on_modbus_* names: the new-name default implementations must forward, so // external devices written against the old names keep working through the deprecation window. @@ -336,7 +901,7 @@ namespace { class NotSentCountingDevice : public ModbusClientDevice { public: NotSentCountingDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} - void on_not_sent() override { this->not_sent_++; } + void on_not_sent(std::span request_pdu) override { this->not_sent_++; } int not_sent_{0}; }; } // namespace From 5eeb780538124c8087e28039a9f1e48e06ede10b Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Mon, 27 Jul 2026 13:44:15 -0700 Subject: [PATCH 1094/1815] [modbus] Fold the repeated PDU validation idioms into shared helpers (#17888) Co-authored-by: Claude Fable 5 --- .../components/modbus/modbus_definitions.h | 5 +- esphome/components/modbus/modbus_helpers.cpp | 87 ++++++++++--------- 2 files changed, 50 insertions(+), 42 deletions(-) diff --git a/esphome/components/modbus/modbus_definitions.h b/esphome/components/modbus/modbus_definitions.h index 09f430f703..fd99055ca2 100644 --- a/esphome/components/modbus/modbus_definitions.h +++ b/esphome/components/modbus/modbus_definitions.h @@ -123,6 +123,9 @@ static constexpr uint16_t MAX_FRAME_SIZE = 256; * with any subscript. Writes and forwarding are defensive: set() drops out-of-range bits and * bytes() clamps to the real span, because those paths touch buffers and the wire directly. */ +/// Bits pack 8 per data byte, rounded up to whole bytes. +constexpr size_t packed_bit_bytes(size_t bits) { return (bits + 7) / 8; } + class PackedBits { public: PackedBits(std::span data, uint16_t count) : data_(data), count_(count) {} @@ -134,7 +137,7 @@ class PackedBits { /// over a larger buffer - forwarding this span onto the wire can never leak trailing buffer content. /// Clamped to the actual span so a view over a too-short buffer stays detectable instead of UB. std::span bytes() const { - return this->data_.first(std::min((this->count_ + 7) / 8, this->data_.size())); + return this->data_.first(std::min(packed_bit_bytes(this->count_), this->data_.size())); } private: diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index 88e10287a2..85c5d3e882 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -7,6 +7,19 @@ namespace esphome::modbus::helpers { static const char *const TAG = "modbus_helpers"; +// A quantity/address pair is standard when the quantity is non-zero, within the per-table maximum, +// and the range [start_address, start_address + quantity) stays inside the 16-bit address space +// (the 32-bit promotion is the overflow guard - a 16-bit sum could wrap and pass). +static bool quantity_in_range(uint16_t start_address, uint16_t quantity, uint16_t max_quantity) { + return quantity != 0 && quantity <= max_quantity && uint32_t(start_address) + quantity <= 0x10000u; +} + +// The spec allows exactly ON (0xFF00) and OFF (0x0000) for a single-coil value, on the request and +// on its echoed response alike. +static bool is_canonical_coil_value(uint8_t high_byte, uint8_t low_byte) { + return (high_byte == 0xFF || high_byte == 0x00) && low_byte == 0x00; +} + uint16_t server_pdu_length(const uint8_t *frame, size_t size) { if (size < MIN_PDU_SIZE) return MIN_PDU_SIZE; @@ -82,11 +95,12 @@ bool is_server_pdu_standard(const uint8_t *pdu, size_t size) { if (server_pdu_length(pdu, size) != size) return false; - switch (static_cast(pdu[0])) { + const auto function_code = static_cast(pdu[0]); + switch (function_code) { case FunctionCode::READ_COILS: case FunctionCode::READ_DISCRETE_INPUTS: // A conformant bit-read response carries at least one packed byte (up to 2000 bits = 250 bytes). - return pdu[1] != 0 && pdu[1] <= uint8_t((MAX_NUM_OF_COILS_TO_READ + 7) / 8); + return pdu[1] != 0 && pdu[1] <= uint8_t(packed_bit_bytes(MAX_NUM_OF_COILS_TO_READ)); case FunctionCode::READ_HOLDING_REGISTERS: case FunctionCode::READ_INPUT_REGISTERS: // Registers are 2 bytes each: the byte count must be a non-zero even count within the read maximum. @@ -99,15 +113,13 @@ bool is_server_pdu_standard(const uint8_t *pdu, size_t size) { case FunctionCode::WRITE_MULTIPLE_COILS: case FunctionCode::WRITE_MULTIPLE_REGISTERS: { // The response echoes start address and quantity: bound them like the request side does. - const bool bits = static_cast(pdu[0]) == FunctionCode::WRITE_MULTIPLE_COILS; - const uint16_t start_address = get_data(pdu, 1); - const uint16_t quantity = get_data(pdu, 3); + const bool bits = function_code == FunctionCode::WRITE_MULTIPLE_COILS; const uint16_t max_quantity = bits ? MAX_NUM_OF_COILS_TO_WRITE : MAX_NUM_OF_REGISTERS_TO_WRITE; - return quantity != 0 && quantity <= max_quantity && (uint32_t) start_address + quantity <= 0x10000u; + return quantity_in_range(get_data(pdu, 1), get_data(pdu, 3), max_quantity); } case FunctionCode::WRITE_SINGLE_COIL: // The response echoes the request, so the same ON/OFF constraint applies. - return (pdu[3] == 0xFF || pdu[3] == 0x00) && pdu[4] == 0x00; + return is_canonical_coil_value(pdu[3], pdu[4]); default: return true; // All other function codes validated by length alone } @@ -117,45 +129,38 @@ bool is_client_pdu_standard(const uint8_t *pdu, size_t size) { if (client_pdu_length(pdu, size) != size) return false; - switch (static_cast(pdu[0])) { + const auto function_code = static_cast(pdu[0]); + switch (function_code) { case FunctionCode::READ_COILS: case FunctionCode::READ_DISCRETE_INPUTS: case FunctionCode::READ_HOLDING_REGISTERS: case FunctionCode::READ_INPUT_REGISTERS: { - const bool bits = static_cast(pdu[0]) == FunctionCode::READ_COILS || - static_cast(pdu[0]) == FunctionCode::READ_DISCRETE_INPUTS; - const uint16_t start_address = get_data(pdu, 1); - const uint16_t quantity = get_data(pdu, 3); + const bool bits = + function_code == FunctionCode::READ_COILS || function_code == FunctionCode::READ_DISCRETE_INPUTS; const uint16_t max_quantity = bits ? MAX_NUM_OF_COILS_TO_READ : MAX_NUM_OF_REGISTERS_TO_READ; - return quantity != 0 && quantity <= max_quantity && (uint32_t) start_address + quantity <= 0x10000u; + return quantity_in_range(get_data(pdu, 1), get_data(pdu, 3), max_quantity); } case FunctionCode::WRITE_MULTIPLE_COILS: case FunctionCode::WRITE_MULTIPLE_REGISTERS: { - const bool bits = static_cast(pdu[0]) == FunctionCode::WRITE_MULTIPLE_COILS; - const uint16_t start_address = get_data(pdu, 1); + const bool bits = function_code == FunctionCode::WRITE_MULTIPLE_COILS; const uint16_t quantity = get_data(pdu, 3); const uint16_t max_quantity = bits ? MAX_NUM_OF_COILS_TO_WRITE : MAX_NUM_OF_REGISTERS_TO_WRITE; // Coils are packed 8 per data byte; registers are 2 bytes each. - const size_t expected_data_bytes = bits ? (static_cast(quantity) + 7) / 8 : quantity * 2; - return quantity != 0 && quantity <= max_quantity && (uint32_t) start_address + quantity <= 0x10000u && - pdu[5] == expected_data_bytes; + const size_t expected_data_bytes = bits ? packed_bit_bytes(quantity) : quantity * 2; + return quantity_in_range(get_data(pdu, 1), quantity, max_quantity) && pdu[5] == expected_data_bytes; } case FunctionCode::READ_FILE_RECORD: case FunctionCode::WRITE_FILE_RECORD: return pdu[1] <= MAX_PDU_SIZE - 2; case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: { - const uint16_t start_address_read = get_data(pdu, 1); - const uint16_t quantity_read = get_data(pdu, 3); - const uint16_t start_address_write = get_data(pdu, 5); const uint16_t quantity_write = get_data(pdu, 7); - return quantity_read != 0 && quantity_read <= MAX_NUM_OF_REGISTERS_TO_READ && quantity_write != 0 && - quantity_write <= MAX_NUM_OF_REGISTERS_TO_WRITE_RW && - (uint32_t) start_address_read + quantity_read <= 0x10000u && - (uint32_t) start_address_write + quantity_write <= 0x10000u && pdu[9] == quantity_write * 2; + return quantity_in_range(get_data(pdu, 1), get_data(pdu, 3), MAX_NUM_OF_REGISTERS_TO_READ) && + quantity_in_range(get_data(pdu, 5), quantity_write, MAX_NUM_OF_REGISTERS_TO_WRITE_RW) && + pdu[9] == quantity_write * 2; } case FunctionCode::WRITE_SINGLE_COIL: // The one variable field in an otherwise fixed-shape PDU: the spec allows exactly ON/OFF. - return (pdu[3] == 0xFF || pdu[3] == 0x00) && pdu[4] == 0x00; + return is_canonical_coil_value(pdu[3], pdu[4]); default: return true; // All other function codes validated by length alone } @@ -292,6 +297,14 @@ static void append_pdu_header(StaticVector &pdu, FunctionCode func pdu.push_back(second >> 0); } +// Zero the unused bits of a multi-coil write's final data byte, as the spec requires. Kept in one +// place so the generic and typed coil builders produce identical wire bytes for the same write. +static void mask_trailing_pad_bits(std::span data, uint16_t bit_count) { + if (data.empty() || bit_count % 8 == 0) + return; + data.back() &= static_cast((1 << (bit_count % 8)) - 1); +} + ReadPdu create_read_pdu(FunctionCode function_code, uint16_t start_address, uint16_t number_of_entities) { ReadPdu pdu; // declared before every return so NRVO fires (all paths return the same object) if (number_of_entities == 0) { @@ -394,8 +407,7 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address, } // The spec allows exactly ON (0xFF00) and OFF (0x0000) for a single-coil write - the same rule // is_client_pdu_standard() enforces, so a built frame cannot be misclassified on reply. - if (function_code == FunctionCode::WRITE_SINGLE_COIL && - ((values[0] != 0xFF && values[0] != 0x00) || values[1] != 0x00)) { + if (function_code == FunctionCode::WRITE_SINGLE_COIL && !is_canonical_coil_value(values[0], values[1])) { ESP_LOGE(TAG, "Invalid single-coil value %02X%02X (must be FF00 or 0000), dropping request", values[0], values[1]); return pdu; @@ -409,8 +421,7 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address, // non-standard on reply, and the spec bound keeps the PDU within capacity by construction. // Checked before the header append: a failed check must return an empty PDU, not a 5-byte partial one. const bool bits = function_code == FunctionCode::WRITE_MULTIPLE_COILS; - const size_t expected_len = - bits ? (static_cast(number_of_entities) + 7) / 8 : static_cast(number_of_entities) * 2; + const size_t expected_len = bits ? packed_bit_bytes(number_of_entities) : static_cast(number_of_entities) * 2; if (values_len != expected_len) { ESP_LOGE(TAG, "values_len %zu does not match %u entities (expected %zu) for function code %02X, dropping request", values_len, number_of_entities, expected_len, static_cast(function_code)); @@ -420,11 +431,8 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address, pdu.push_back(values_len); // Byte count is required for write multiple for (size_t i = 0; i < values_len; i++) pdu.push_back(values[i]); - // Zero the unused bits of the final byte as the spec requires, matching the typed coil builder - // so both produce identical wire bytes for the same write. - if (bits && number_of_entities % 8 != 0) { - pdu[pdu.size() - 1] &= static_cast((1 << (number_of_entities % 8)) - 1); - } + if (bits) + mask_trailing_pad_bits(pdu, number_of_entities); return pdu; } @@ -484,7 +492,7 @@ static void build_write_coils_pdu(PduBuffer &pdu, uint16_t start_address, Packed ESP_LOGE(TAG, "Write of %u coils at %u runs past the 16-bit address space, dropping request", count, start_address); return; } - const size_t byte_count = (count + 7) / 8; + const size_t byte_count = packed_bit_bytes(count); if (packed_bits.size() < byte_count) { ESP_LOGE(TAG, "packed_bits (%zu bytes) does not cover %u coils (%zu bytes), dropping request", packed_bits.size(), count, byte_count); @@ -495,10 +503,7 @@ static void build_write_coils_pdu(PduBuffer &pdu, uint16_t start_address, Packed for (size_t i = 0; i != byte_count; i++) { pdu.push_back(packed_bits[i]); } - // Zero the unused bits of the final byte, as the spec requires - if (count % 8 != 0) { - pdu[pdu.size() - 1] &= static_cast((1 << (count % 8)) - 1); - } + mask_trailing_pad_bits(pdu, count); } PduBuffer create_write_coils_pdu(uint16_t start_address, PackedBits bits) { @@ -515,7 +520,7 @@ PduBuffer create_write_coils_pdu(uint16_t start_address, std::span v MAX_NUM_OF_COILS_TO_WRITE); return pdu; } - StaticVector packed; + StaticVector packed; for (size_t i = 0; i != values.size(); i++) { if (i % 8 == 0) packed.push_back(0); From fa8c7e60da7ac624bbefee996e8d9fe021ee8afc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Jul 2026 10:47:12 -1000 Subject: [PATCH 1095/1815] [host] Speed up builds with ccache when available (#17728) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 10 ++++++++++ esphome/components/host/__init__.py | 7 +++++++ 2 files changed, 17 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f73739b5d5..6c42018a80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -365,6 +365,12 @@ jobs: steps: - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install ccache + # Speeds up the host compiles: tests in a bucket compile overlapping + # component sets, so later tests reuse earlier tests' objects. + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends ccache - name: Set up Python 3.13 id: python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -409,6 +415,10 @@ jobs: mapfile -t test_files < <(echo "$BUCKET_TESTS" | jq -r '.[]') echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests" pytest -vv --no-cov --tb=native --durations=30 -n auto "${test_files[@]}" + - name: Print ccache statistics + # esphome stores the PlatformIO ccache under the machine-global cache + # dir (see _ccache_env() in esphome/platformio/toolchain.py). + run: CCACHE_DIR="$HOME/.cache/esphome/platformio-ccache" ccache -s cpp-unit-tests: name: Run C++ unit tests diff --git a/esphome/components/host/__init__.py b/esphome/components/host/__init__.py index 50deb1acf6..795c1a556d 100644 --- a/esphome/components/host/__init__.py +++ b/esphome/components/host/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( ThreadModel, ) from esphome.core import CORE +from esphome.platformio.toolchain import copy_ccache_script from .const import KEY_HOST @@ -49,3 +50,9 @@ async def to_code(config): cg.add_platformio_option("platform", "platformio/native") cg.add_platformio_option("lib_ldf_mode", "off") cg.add_platformio_option("lib_compat_mode", "strict") + cg.add_platformio_option("extra_scripts", ["pre:ccache.py"]) + + +# Called by writer.py +def copy_files() -> None: + copy_ccache_script() From 6420de53f0ab55be5730039e3907753c9a5e8468 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:24:53 -0400 Subject: [PATCH 1096/1815] [ethernet] Set SPI CS hold time for ENC28J60 (#17885) --- esphome/components/ethernet/__init__.py | 73 +++++++++++-------- .../ethernet/ethernet_component_esp32.cpp | 4 +- 2 files changed, 45 insertions(+), 32 deletions(-) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index ad63c0d13d..d1d5e45c6b 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -437,37 +437,48 @@ GENERIC_SCHEMA = cv.All( cv.only_on([Platform.ESP32]), ) -SPI_SCHEMA = cv.All( - BASE_SCHEMA.extend( - cv.Schema( - { - cv.Required(CONF_CLK_PIN): pins.internal_gpio_output_pin_number, - cv.Required(CONF_MISO_PIN): pins.internal_gpio_input_pin_number, - cv.Required(CONF_MOSI_PIN): pins.internal_gpio_output_pin_number, - cv.Required(CONF_CS_PIN): pins.internal_gpio_output_pin_number, - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_number, - cv.Optional(CONF_RESET_PIN): pins.internal_gpio_output_pin_number, - cv.SplitDefault(CONF_CLOCK_SPEED, esp32="26.67MHz"): cv.All( - cv.only_on_esp32, - cv.frequency, - cv.int_range(int(8e6), int(80e6)), - ), - cv.Optional(CONF_INTERFACE): cv.All( - cv.only_on_esp32, - cv.one_of(*SPI_INTERFACE_MAP.keys(), lower=True), - ), - # Set default value (SPI_ETHERNET_DEFAULT_POLLING_INTERVAL) at _validate() - cv.Optional(CONF_POLLING_INTERVAL): cv.All( - cv.only_on_esp32, - cv.positive_time_period_milliseconds, - cv.Range(min=TimePeriodMilliseconds(milliseconds=1)), - ), - } + +def _spi_schema(default_clock: str = "26.67MHz", max_clock: int = int(80e6)): + return cv.All( + BASE_SCHEMA.extend( + cv.Schema( + { + cv.Required(CONF_CLK_PIN): pins.internal_gpio_output_pin_number, + cv.Required(CONF_MISO_PIN): pins.internal_gpio_input_pin_number, + cv.Required(CONF_MOSI_PIN): pins.internal_gpio_output_pin_number, + cv.Required(CONF_CS_PIN): pins.internal_gpio_output_pin_number, + cv.Optional( + CONF_INTERRUPT_PIN + ): pins.internal_gpio_input_pin_number, + cv.Optional(CONF_RESET_PIN): pins.internal_gpio_output_pin_number, + cv.SplitDefault(CONF_CLOCK_SPEED, esp32=default_clock): cv.All( + cv.only_on_esp32, + cv.frequency, + cv.int_range(int(8e6), max_clock), + ), + cv.Optional(CONF_INTERFACE): cv.All( + cv.only_on_esp32, + cv.one_of(*SPI_INTERFACE_MAP.keys(), lower=True), + ), + # Set default value (SPI_ETHERNET_DEFAULT_POLLING_INTERVAL) at _validate() + cv.Optional(CONF_POLLING_INTERVAL): cv.All( + cv.only_on_esp32, + cv.positive_time_period_milliseconds, + cv.Range(min=TimePeriodMilliseconds(milliseconds=1)), + ), + } + ), ), - ), - cv.only_on([Platform.ESP32, Platform.RP2]), - _validate_spi_interface, -) + cv.only_on([Platform.ESP32, Platform.RP2]), + _validate_spi_interface, + ) + + +SPI_SCHEMA = _spi_schema() + +# The ENC28J60's SCK maximum is 20 MHz, so the shared 26.67 MHz default is out +# of spec for it and makes the driver's CS hold time helper compute no hold +SPI_SCHEMA_ENC28J60 = _spi_schema(default_clock="20MHz", max_clock=int(20e6)) CONFIG_SCHEMA = cv.All( cv.typed_schema( @@ -483,7 +494,7 @@ CONFIG_SCHEMA = cv.All( "W5500": SPI_SCHEMA, "OPENETH": cv.All(BASE_SCHEMA, cv.only_on([Platform.ESP32])), "DM9051": SPI_SCHEMA, - "ENC28J60": SPI_SCHEMA, + "ENC28J60": SPI_SCHEMA_ENC28J60, "W6100": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])), "W6300": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])), "LAN8670": RMII_SCHEMA, diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 5ad1e7d483..94f4c23479 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -232,8 +232,10 @@ void EthernetComponent::ethernet_lazy_init_() { dm9051_config.poll_period_ms = this->polling_interval_; #endif #elif defined(USE_ETHERNET_ENC28J60) + // ENC28J60 does not support poll_period_ms. CS must stay asserted for the chip's CS hold + // time (t10, 210 ns) after the last clock or MAC/MII register reads fail ("wrong chip ID") + enc28j60_config.spi_devcfg->cs_ena_posttrans = enc28j60_cal_spi_cs_hold_time((this->clock_speed_ + 999999) / 1000000); enc28j60_config.int_gpio_num = this->interrupt_pin_; - // ENC28J60 does not support poll_period_ms #endif phy_config.phy_addr = this->phy_addr_spi_; From 7c89e81449ef3780106fb48d0c7e90240baae6e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Jul 2026 11:26:49 -1000 Subject: [PATCH 1097/1815] [ci] Persist the integration test ccache across runs (#17735) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c42018a80..d8b39182f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -371,6 +371,18 @@ jobs: run: | sudo apt-get update -qq sudo apt-get install -y --no-install-recommends ccache + - name: Restore ccache (restore-only) + # esphome stores the PlatformIO ccache under the machine-global cache + # dir (see _ccache_env() in esphome/platformio/toolchain.py). The + # bucket-name prefix prefers a same-bucket seed; the bare prefix falls + # back to any seed when the bucket layout differs from dev. + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/esphome/platformio-ccache + key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }} + restore-keys: | + integration-ccache-${{ matrix.bucket.name }}- + integration-ccache- - name: Set up Python 3.13 id: python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -419,6 +431,14 @@ jobs: # esphome stores the PlatformIO ccache under the machine-global cache # dir (see _ccache_env() in esphome/platformio/toolchain.py). run: CCACHE_DIR="$HOME/.cache/esphome/platformio-ccache" ccache -s + - name: Save ccache + # Pull request saves land in per-PR scopes nothing else can reuse; + # dev pushes seed the shared copy instead. + if: github.event_name != 'pull_request' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/esphome/platformio-ccache + key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }} cpp-unit-tests: name: Run C++ unit tests From 01616c4f309d473a1bd31555f1b7b316430d7c3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Tue, 28 Jul 2026 00:58:29 +0300 Subject: [PATCH 1098/1815] [bk72xx_ble_tracker] BLE 5.x scanner for BK72xx (#17135) --- CODEOWNERS | 1 + .../components/bk72xx_ble_tracker/__init__.py | 152 +++++++++++++ .../bk72xx_ble_tracker/bk72xx_ble_tracker.cpp | 205 ++++++++++++++++++ .../bk72xx_ble_tracker/bk72xx_ble_tracker.h | 151 +++++++++++++ .../test_scan_parameter_validation.py | 114 ++++++++++ .../bk72xx_ble_tracker/common-boundary.yaml | 11 + .../components/bk72xx_ble_tracker/common.yaml | 7 + .../validate-boundary.bk72xx-ard.yaml | 2 + .../validate.bk72xx-ard.yaml | 2 + 9 files changed, 645 insertions(+) create mode 100644 esphome/components/bk72xx_ble_tracker/__init__.py create mode 100644 esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp create mode 100644 esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h create mode 100644 tests/component_tests/bk72xx_ble_tracker/test_scan_parameter_validation.py create mode 100644 tests/components/bk72xx_ble_tracker/common-boundary.yaml create mode 100644 tests/components/bk72xx_ble_tracker/common.yaml create mode 100644 tests/components/bk72xx_ble_tracker/validate-boundary.bk72xx-ard.yaml create mode 100644 tests/components/bk72xx_ble_tracker/validate.bk72xx-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index fe09bd96cf..1a963d8ea6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -70,6 +70,7 @@ esphome/components/bh1900nux/* @B48D81EFCC esphome/components/binary_sensor/* @esphome/core esphome/components/bk72xx/* @kuba2k2 esphome/components/bk72xx_ble/* @Bl00d-B0b +esphome/components/bk72xx_ble_tracker/* @Bl00d-B0b esphome/components/bl0906/* @athom-tech @jesserockz @tarontop esphome/components/bl0939/* @ziceva esphome/components/bl0940/* @dan-s-github @tobias- diff --git a/esphome/components/bk72xx_ble_tracker/__init__.py b/esphome/components/bk72xx_ble_tracker/__init__.py new file mode 100644 index 0000000000..5cf1fabbbc --- /dev/null +++ b/esphome/components/bk72xx_ble_tracker/__init__.py @@ -0,0 +1,152 @@ +"""BK72xx BLE Tracker — ESPHome BLE 5.x scanner for the BLE-5.x-capable +LibreTiny Beken chips (beken-72xx family). + +Builds on the bk72xx_ble controller component (stack bring-up, BLE address, +scan primitives) and implements the platform-neutral ble_device_base BLEHub +contract: the shared BLE sensors (ble_presence, ble_rssi, ble_scanner, +bthome_mithermometer, xiaomi_*, …) bind to this tracker through +cv.use_id(BLEHub) with no BK-specific code. + +Scan modes: + continuous: true — scan runs forever; never stops automatically. + Use this when the radio is dedicated to BLE. + continuous: false — a started scan runs for `duration` ms, then stops. The + FIRST start is external too: nothing in this component + starts a non-continuous scan on boot, so until the + automation actions land (follow-up PR) the radio stays + idle. start_scan() is called from code (e.g. an api + client-connected automation) so the single-core radio + can service WiFi in between scans. +""" + +import esphome.codegen as cg +from esphome.components import bk72xx_ble, ble_device_base, ota +import esphome.config_validation as cv +from esphome.const import CONF_CONTINUOUS, CONF_DURATION, CONF_ID, CONF_INTERVAL +from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.types import ConfigType + +CONF_WINDOW = "window" +CONF_SCAN_PARAMETERS = "scan_parameters" +CONF_BK72XX_BLE_ID = "bk72xx_ble_id" + +DEPENDENCIES = ["bk72xx"] +AUTO_LOAD = ["ble_device_base", "bk72xx_ble"] +CODEOWNERS = ["@Bl00d-B0b"] + +bk72xx_ble_tracker_ns = cg.esphome_ns.namespace("bk72xx_ble_tracker") +BK72xxBLETracker = bk72xx_ble_tracker_ns.class_( + "BK72xxBLETracker", ble_device_base.BLEHub, cg.Component +) + + +def to_ble_units(value: cv.TimePeriod) -> int: + """Convert a scan time to the controller's 0.625 ms units. + + Used by both validation and codegen so what is validated is exactly what is + programmed — the truncation here is what makes the duty-cycle check below + meaningful. + """ + return value.total_microseconds // 625 + + +def validate_scan_parameters(config: ConfigType) -> ConfigType: + """Reject impossible window/interval/duration combinations at config time. + + Mirrors esp32_ble_tracker: the controller cannot scan for longer than the + interval, and a too-short duration would end the scan period almost + immediately. Catching it here gives a clear error instead of a runtime + controller failure and the 1/sec retry loop. + """ + duration = config[CONF_DURATION] + interval = config[CONF_INTERVAL] + window = config[CONF_WINDOW] + + if window > interval: + raise cv.Invalid( + f"Scan window ({window}) needs to be smaller than scan interval ({interval})" + ) + + # BLE scan interval/window are programmed in 0.625 ms units as a 16-bit value; the + # controller only accepts 2.5 ms .. 10240 ms (0x0004 .. 0x4000). Reject out-of-range + # values here instead of letting the unit conversion silently overflow. + for name, value in (("interval", interval), ("window", window)): + if value.total_microseconds < 2500 or value.total_microseconds > 10_240_000: + raise cv.Invalid( + f"Scan {name} ({value}) must be between 2.5 ms and 10240 ms" + ) + + # Validate what actually reaches the controller: both values are truncated to + # whole 0.625 ms units, so a window/interval pair that differs by less than one + # unit collapses to the same value — silently programming a 100 % duty cycle + # (radio permanently on) from a config that asked for less. + interval_units = to_ble_units(interval) + window_units = to_ble_units(window) + if window_units == interval_units and window < interval: + raise cv.Invalid( + f"Scan window ({window}) and interval ({interval}) both round to " + f"{interval_units} x 0.625 ms, which the controller scans at a 100 % duty " + f"cycle. Separate them by at least 0.625 ms." + ) + + if interval.total_microseconds * 3 > duration.total_microseconds: + raise cv.Invalid( + f"Scan duration ({duration}) must cover at least three scan intervals " + f"({interval}): the scanner listens on one of the three BLE advertising " + f"channels per interval, so a shorter duration can miss devices entirely." + ) + + return config + + +SCAN_PARAMETERS_SCHEMA = cv.All( + cv.Schema( + { + cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds, + # interval/window default to the BK reference scan rate — 100 ms / 30 ms, + # a 30 % duty cycle. Converted to the controller's 0.625 ms BLE units in + # to_code(). (LN882H's SDK recommends a different 100 / 50 ms = 50 %.) + cv.Optional(CONF_INTERVAL, default="100ms"): cv.positive_time_period, + cv.Optional(CONF_WINDOW, default="30ms"): cv.positive_time_period, + cv.Optional(CONF_CONTINUOUS, default=True): cv.boolean, + } + ), + validate_scan_parameters, +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(BK72xxBLETracker), + cv.GenerateID(CONF_BK72XX_BLE_ID): cv.use_id(bk72xx_ble.BK72xxBLE), + cv.Optional(CONF_SCAN_PARAMETERS, default={}): SCAN_PARAMETERS_SCHEMA, + } +).extend(cv.COMPONENT_SCHEMA) + + +# Runs at FINAL priority so every BLE sensor has registered through +# ble_device_base (and any tracker-owned listeners have been counted) before +# the StaticVector size is emitted. Same pattern as esp32_ble_tracker. +@coroutine_with_priority(CoroPriority.FINAL) +async def _emit_listener_count() -> None: + count = ble_device_base.get_listener_count() + if count > 0: + cg.add_define("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT", count) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + parent = await cg.get_variable(config[CONF_BK72XX_BLE_ID]) + cg.add(var.set_parent(parent)) + + # Get notified when an OTA update starts, to pause scanning (esp32_ble_tracker parity) + ota.request_ota_state_listeners() + + scan = config[CONF_SCAN_PARAMETERS] + cg.add(var.set_scan_interval(to_ble_units(scan[CONF_INTERVAL]))) + cg.add(var.set_scan_window(to_ble_units(scan[CONF_WINDOW]))) + cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds)) + cg.add(var.set_scan_continuous(scan[CONF_CONTINUOUS])) + + CORE.add_job(_emit_listener_count) diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp new file mode 100644 index 0000000000..8d7199bd0a --- /dev/null +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp @@ -0,0 +1,205 @@ +// bk72xx_ble_tracker.cpp +// +// BLE scan policy for the BK72xx BLE-5.x chips: parameters, duration/period +// timers and the rate-limited start retry. All controller access (stack +// bring-up, scan primitives, the BLE-task → main-task report queue) goes +// through the bk72xx_ble component — no SDK calls and no cross-task state here. + +#ifdef USE_LIBRETINY + +#include "bk72xx_ble_tracker.h" + +#include +#include + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::bk72xx_ble_tracker { + +static const char *const TAG = "bk72xx_ble_tracker"; + +// Minimum interval between scan (re)start attempts, so a failing controller start +// cannot be retried every main-loop iteration (single-core CPU starvation). The +// interval doubles with consecutive failed starts (1 s up to 64 s) so a controller +// that never comes up — the controller logs each failure at ERROR — settles into a +// slow, quiet poll instead of an error line every second for the rest of uptime; +// a single WARN is emitted when the retry interval first saturates. +static constexpr uint32_t SCAN_START_RETRY_MS = 1000; +static constexpr uint8_t SCAN_START_RETRY_MAX_DOUBLINGS = 6; // 1 s << 6 = 64 s + +// --------------------------------------------------------------------------- +// Component lifecycle +// --------------------------------------------------------------------------- + +void BK72xxBLETracker::setup() { + // Receive the controller's scan reports; the controller queues them from the + // BLE task and delivers here on the main task. + this->parent_->register_scan_listener(this); +#ifdef USE_OTA_STATE_LISTENER + // Pause scanning while an OTA update is in flight — on the single-core BK72xx the + // BLE scan competes with the OTA flash writes. Mirrors esp32_ble_tracker. + ota::get_global_ota_callback()->add_global_state_listener(this); +#endif +} + +#ifdef USE_OTA_STATE_LISTENER +void BK72xxBLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, + ota::OTAComponent *comp) { + if (state == ota::OTA_STARTED) { + this->scan_continuous_before_ota_ = this->scan_continuous_; + this->stop_scan(); + } else if ((state == ota::OTA_ERROR || state == ota::OTA_ABORT) && this->scan_continuous_before_ota_) { + // On success the device reboots, so restore only on a failed/aborted update; + // loop() restarts the scan on its next iteration (continuous idle branch). + this->scan_continuous_before_ota_ = false; + this->scan_continuous_ = true; + } +} +#endif // USE_OTA_STATE_LISTENER + +void BK72xxBLETracker::loop() { + const uint32_t now = millis(); + if (this->scan_continuous_) { + if (!this->scan_running_) { + // Rate-limit (re)start attempts. The controller start can fail (no idle activity + // handle, WiFi/BLE coexistence) and leave scan_running_ false; retrying every + // main-loop iteration would spin the single-core CPU and starve WiFi (device + // becomes unresponsive). The interval backs off with consecutive failures so a + // controller that never comes up polls slowly and quietly. + const uint8_t doublings = std::min(this->failed_start_count_, SCAN_START_RETRY_MAX_DOUBLINGS); + if (now - this->last_scan_start_attempt_ >= (SCAN_START_RETRY_MS << doublings)) { + this->last_scan_start_attempt_ = now; + this->start_scan_(); + if (this->scan_running_) { + this->failed_start_count_ = 0; + } else if (this->failed_start_count_ < SCAN_START_RETRY_MAX_DOUBLINGS) { + ++this->failed_start_count_; + if (this->failed_start_count_ == SCAN_START_RETRY_MAX_DOUBLINGS) { + ESP_LOGW(TAG, "Scan start keeps failing; retrying every %" PRIu32 " s", + (SCAN_START_RETRY_MS << SCAN_START_RETRY_MAX_DOUBLINGS) / 1000); + } + } + } + } + // Period timer: fire on_scan_end() once per scan_duration_ window, mirroring + // esp32_ble_tracker::cleanup_scan_state_(). Gated on scan_started_once_ so a scan + // that never came up (start kept failing) does not fire spurious on_scan_end events. + if (this->scan_started_once_ && now - this->scan_period_start_ >= this->scan_duration_) { +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + for (auto *listener : this->listeners_) + listener->on_scan_end(); + this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity) +#endif + this->scan_period_start_ = now; + } + return; + } + + // Non-continuous mode: run for scan_duration_ ms, then stop and fire on_scan_end. + // Restart is driven externally (e.g. api: on_client_connected:). + if (this->scan_running_ && now - this->scan_start_time_ >= this->scan_duration_) { + this->stop_scan_(); + } +} + +void BK72xxBLETracker::dump_config() { + ESP_LOGCONFIG(TAG, + "BK72xx BLE Tracker:\n" + " Scan Duration: %" PRIu32 " s\n" + " Scan Interval: %.0f ms (%" PRIu32 " BLE units)\n" + " Scan Window: %.0f ms (%" PRIu32 " BLE units)\n" + " Scan Type: PASSIVE\n" + " Continuous Scanning: %s", + this->scan_duration_ / 1000, this->scan_interval_ * 0.625f, this->scan_interval_, + this->scan_window_ * 0.625f, this->scan_window_, YESNO(this->scan_continuous_)); +} + +// --------------------------------------------------------------------------- +// Scan report — delivered by the controller's loop() on the ESPHome main task +// (the controller queues reports from the BLE task), so publish_state() and +// listener dispatch run in main-loop context with no cross-task handling here. +// --------------------------------------------------------------------------- + +void BK72xxBLETracker::on_scan_report(const bk72xx_ble::BLEScanReport &report) { + // Raw callback (the raw-advertisement path). + if (this->raw_advertisement_callback_) + this->raw_advertisement_callback_(report.mac, report.rssi, report.addr_type, report.data, report.data_len); + +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + ble_device_base::ESPBTDevice device; + device.from_scan_result(report.mac, report.rssi, report.addr_type, report.data, report.data_len); + bool found = false; + for (auto *listener : this->listeners_) + if (listener->parse_device(device)) + found = true; + // Mirror esp32_ble_tracker: log a newly-seen device only when nothing claimed + // it and the scan is one-shot (continuous scans would spam). + if (!found && !this->scan_continuous_) + this->discovered_log_.log_device(TAG, device); +#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT +} + +// --------------------------------------------------------------------------- +// Public scan control +// --------------------------------------------------------------------------- + +void BK72xxBLETracker::start_scan() { + // Mirrors esp32_ble_tracker::start_scan(): caller sets scan_continuous_ via + // set_scan_continuous() first, then calls start_scan() to begin scanning. + if (!this->scan_running_) { + this->start_scan_(); + } +} + +void BK72xxBLETracker::stop_scan() { + this->scan_continuous_ = false; + this->stop_scan_(); +} + +// --------------------------------------------------------------------------- +// Internal scan start / stop +// --------------------------------------------------------------------------- + +void BK72xxBLETracker::start_scan_() { + if (this->scan_running_) + return; + + if (!this->parent_->scan_start(static_cast(this->scan_interval_), + static_cast(this->scan_window_))) + return; + + const uint32_t now = millis(); + this->scan_running_ = true; + this->scan_start_time_ = now; + // Log every explicit start at DEBUG — stop_scan_() logs every stop at DEBUG, and + // in non-continuous mode each period is an explicit start, so asymmetric logging + // would read as the scanner failing to come back up. + ESP_LOGD(TAG, "Scan started (passive, window=%.0fms, interval=%.0fms)", this->scan_window_ * 0.625f, + this->scan_interval_ * 0.625f); + // Re-anchor the on_scan_end period to every successful start — first start (so the + // period counts from the scan, not from boot) and every restart after a stop (so + // resuming after longer than scan_duration, e.g. a failed OTA restoring continuous + // mode 10 minutes later, does not fire on_scan_end before an advertisement can + // arrive). scan_started_once_ purely gates the period timer. + this->scan_period_start_ = now; + this->scan_started_once_ = true; +} + +void BK72xxBLETracker::stop_scan_() { + if (!this->scan_running_) + return; + this->parent_->scan_stop(); + this->scan_running_ = false; + ESP_LOGD(TAG, "Scan stopped"); +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + for (auto *listener : this->listeners_) + listener->on_scan_end(); + this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity) +#endif + this->scan_period_start_ = millis(); // reset period clock so on_scan_end does not double-fire +} + +} // namespace esphome::bk72xx_ble_tracker + +#endif // USE_LIBRETINY diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h new file mode 100644 index 0000000000..b8d0b31e6a --- /dev/null +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h @@ -0,0 +1,151 @@ +// bk72xx_ble_tracker.h +// +// ESPHome BLE scanner for the BK72xx BLE-5.x chips (LibreTiny beken-72xx family). +// Implements the platform-neutral ble_device_base::BLEHub contract on top of the +// bk72xx_ble controller component: parsed ESPBTDevice objects go to registered +// listeners (bthome_mithermometer, ble_presence, …) and every raw frame to the +// hub's raw-advertisement callback. +// +// This component contains no Beken SDK calls and no cross-task state: the +// controller (stack bring-up, BLE address, scan primitives, and the BLE-task → +// main-task report queue) is owned by bk72xx_ble, which delivers every scan +// report on the ESPHome main task. The tracker owns scan policy — parameters, +// duration/period timers and the rate-limited start retry. +// +// YAML config (values shown are the defaults; interval/window are a 30 % duty +// cycle, the BK reference scan rate): +// +// bk72xx_ble_tracker: +// scan_parameters: +// interval: 100ms +// window: 30ms +// duration: 5min +// continuous: true + +#pragma once + +#ifdef USE_LIBRETINY + +#include "esphome/components/bk72xx_ble/bk72xx_ble.h" +#include "esphome/components/ble_device_base/ble_device.h" +#include "esphome/components/ble_device_base/ble_hub.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include +#include + +#ifdef USE_OTA_STATE_LISTENER +#include "esphome/components/ota/ota_backend.h" +#endif + +namespace esphome::bk72xx_ble_tracker { + +// --------------------------------------------------------------------------- +// BK72xxBLETracker +// --------------------------------------------------------------------------- + +class BK72xxBLETracker : public Component, + public ble_device_base::BLEHub, + public bk72xx_ble::BLEScanListener, + public Parented +#ifdef USE_OTA_STATE_LISTENER + , + public ota::OTAGlobalStateListener +#endif +{ + public: + // ---- ESPHome Component ---- + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + +#ifdef USE_OTA_STATE_LISTENER + // Pause scanning while an OTA update runs (single-core WiFi/BLE/flash contention); + // mirrors esp32_ble_tracker. + void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override; +#endif + + // ---- YAML configuration setters ---- + void set_scan_interval(uint32_t scan_interval) { this->scan_interval_ = scan_interval; } + void set_scan_window(uint32_t scan_window) { this->scan_window_ = scan_window; } + void set_scan_duration(uint32_t scan_duration) { this->scan_duration_ = scan_duration; } + void set_scan_continuous(bool scan_continuous) { this->scan_continuous_ = scan_continuous; } + + // ---- Public scan control ---- + // Mirrors esp32_ble_tracker: set_scan_continuous() + start_scan() / stop_scan(). + void start_scan(); + void stop_scan(); + + // ---- ble_device_base::BLEHub contract ---- + void register_listener(ble_device_base::ESPBTDeviceListener *listener) override { +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + this->listeners_.push_back(listener); +#endif + } + void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback cb) override { + this->raw_advertisement_callback_ = std::move(cb); + } + ble_device_base::HubCapabilities get_capabilities() const override { + // The Beken BDK exposes no active-scan path (passive scanning only), so the + // controller never solicits scan responses and never merges them; consumers + // relying on scan-response fields (device names) get them only where the + // receiver merges per address (Home Assistant does). No GATT client either. + return {.active_scan = false, .merges_scan_response = false, .gatt = false}; + } + // The controller stores the address LSB-first (BLE convention); the contract + // wants printable (MSB-first) order. + void get_adapter_mac(uint8_t out[6]) override { + uint8_t mac[6]; + this->parent_->get_mac_lsb_first(mac); + for (int i = 0; i < 6; i++) + out[i] = mac[5 - i]; + } + bool scan_running() override { return this->scan_running_; } + bool scan_active() override { return false; } // BK72xx scan is passive-only + + // ---- bk72xx_ble::BLEScanListener ---- + // Delivered by the controller's loop() on the ESPHome main task — the + // BLE-task → main-task handoff already happened in the controller's queue. + void on_scan_report(const bk72xx_ble::BLEScanReport &report) override; + + protected: + void start_scan_(); + void stop_scan_(); + + bool scan_running_{false}; + // Defaults: the BK reference — 30 % duty cycle + // (interval 100 ms / window 30 ms), in 0.625 ms BLE units. + uint32_t scan_interval_{160}; // 160 × 0.625 ms = 100 ms + uint32_t scan_window_{48}; // 48 × 0.625 ms = 30 ms (30/100 = 30 %) + uint32_t scan_duration_{300000}; + bool scan_continuous_{true}; +#ifdef USE_OTA_STATE_LISTENER + bool scan_continuous_before_ota_{false}; // continuous mode saved at OTA start, restored on OTA failure +#endif + uint32_t scan_start_time_{0}; + + uint32_t last_scan_start_attempt_{0}; // millis() of last start_scan_() attempt; rate-limits retries + uint8_t failed_start_count_{0}; // consecutive failed starts; drives the retry backoff (reset on success) + uint32_t scan_period_start_{0}; // millis() at start of current scan period; used to rate-limit on_scan_end() + bool scan_started_once_{false}; // true after first successful scan start; gates the period timer + + ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{nullptr}; +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + // Parsed-advertisement consumers registered through ble_device_base. + // Codegen-sized: no heap allocation, no std::vector template instantiations. + StaticVector listeners_; +#endif + +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + // Per-period "Found device" DEBUG log with MAC dedup — shared implementation + // in ble_device_base, identical output on every tracker backend. Guarded like + // its only writer so a no-listener build does not carry an unused vector. + ble_device_base::DiscoveredDeviceLog discovered_log_{}; +#endif +}; + +} // namespace esphome::bk72xx_ble_tracker + +#endif // USE_LIBRETINY diff --git a/tests/component_tests/bk72xx_ble_tracker/test_scan_parameter_validation.py b/tests/component_tests/bk72xx_ble_tracker/test_scan_parameter_validation.py new file mode 100644 index 0000000000..968a24dad5 --- /dev/null +++ b/tests/component_tests/bk72xx_ble_tracker/test_scan_parameter_validation.py @@ -0,0 +1,114 @@ +"""Tests for bk72xx_ble_tracker scan parameter validation.""" + +from __future__ import annotations + +import pytest + +from esphome import config_validation as cv +from esphome.components.bk72xx_ble_tracker import SCAN_PARAMETERS_SCHEMA, to_ble_units + + +def _validate(**kwargs: str) -> dict: + """Run a scan_parameters config through the schema, applying defaults.""" + return SCAN_PARAMETERS_SCHEMA(dict(kwargs)) + + +# --- to_ble_units --- + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("2500us", 4), # controller minimum, 2.5 ms + ("30ms", 48), + ("100ms", 160), + ("10240ms", 16384), # controller maximum, 0x4000 + ], +) +def test_to_ble_units_converts_to_controller_units(value: str, expected: int) -> None: + """A time is converted to whole 0.625 ms units.""" + assert to_ble_units(cv.positive_time_period(value)) == expected + + +def test_to_ble_units_truncates() -> None: + """Sub-unit remainders are dropped, which is what makes collapse possible.""" + assert to_ble_units(cv.positive_time_period("3000us")) == 4 + assert to_ble_units(cv.positive_time_period("2500us")) == 4 + + +# --- accepted configurations --- + + +def test_defaults_are_valid() -> None: + """The documented default 100 ms / 30 ms pair validates.""" + config = _validate() + assert to_ble_units(config["interval"]) == 160 + assert to_ble_units(config["window"]) == 48 + + +def test_minimum_separation_accepted() -> None: + """Values one unit apart at the 2.5 ms floor are honest, not collapsed.""" + config = _validate(interval="5000us", window="2500us") + assert to_ble_units(config["interval"]) == 8 + assert to_ble_units(config["window"]) == 4 + + +def test_maximum_interval_accepted() -> None: + """The documented 10240 ms ceiling is inclusive, and maps to 0x4000. + + Pins the ceiling from the accept side, mirroring the 2.5 ms floor above: the + reject cases alone would let the bound silently become exclusive. + """ + config = _validate(interval="10240ms", window="30ms") + assert to_ble_units(config["interval"]) == 16384 + + +def test_maximum_window_accepted() -> None: + """The ceiling applies to the window too, and is likewise inclusive.""" + config = _validate(interval="10240ms", window="10240ms") + assert to_ble_units(config["window"]) == 16384 + + +def test_window_equal_to_interval_accepted() -> None: + """A deliberate 100 % duty cycle is allowed; only an accidental one is not.""" + config = _validate(interval="100ms", window="100ms") + assert to_ble_units(config["interval"]) == to_ble_units(config["window"]) + + +# --- rejected configurations --- + + +def test_window_larger_than_interval_rejected() -> None: + with pytest.raises(cv.Invalid, match="needs to be smaller than scan interval"): + _validate(interval="30ms", window="100ms") + + +@pytest.mark.parametrize( + ("interval", "window", "offender"), + [ + ("2ms", "1ms", "interval"), # below the 2.5 ms controller floor + ("20s", "1s", "interval"), # above the 10240 ms controller ceiling + ("100ms", "1ms", "window"), # window below the floor + ], +) +def test_out_of_range_rejected(interval: str, window: str, offender: str) -> None: + """Values the controller cannot represent are rejected, not silently wrapped.""" + with pytest.raises( + cv.Invalid, match=f"Scan {offender} .* must be between 2.5 ms and 10240 ms" + ): + _validate(interval=interval, window=window) + + +def test_unit_collapse_rejected() -> None: + """Regression: 3000us/2500us both floor to 4 units — a hidden 100 % duty cycle. + + This is the configuration that previously validated and programmed the radio + permanently on despite asking for roughly 83 %. + """ + with pytest.raises(cv.Invalid, match="both round to 4 x 0.625 ms"): + _validate(interval="3000us", window="2500us") + + +def test_duration_shorter_than_three_intervals_rejected() -> None: + with pytest.raises(cv.Invalid, match="must cover at least three scan intervals"): + _validate(duration="1s", interval="500ms", window="100ms") diff --git a/tests/components/bk72xx_ble_tracker/common-boundary.yaml b/tests/components/bk72xx_ble_tracker/common-boundary.yaml new file mode 100644 index 0000000000..24844e18a5 --- /dev/null +++ b/tests/components/bk72xx_ble_tracker/common-boundary.yaml @@ -0,0 +1,11 @@ +bk72xx_ble_tracker: + id: ble_tracker + scan_parameters: + # Boundary coverage: the documented 2.5 ms floor on window (expressible only + # via the microsecond-accurate validation), a non-round interval exercising the + # 0.625 ms unit conversion without collapsing onto the window's unit count, + # and the non-continuous config path. + interval: 5000us + window: 2500us + duration: 5min + continuous: false diff --git a/tests/components/bk72xx_ble_tracker/common.yaml b/tests/components/bk72xx_ble_tracker/common.yaml new file mode 100644 index 0000000000..d8787a9347 --- /dev/null +++ b/tests/components/bk72xx_ble_tracker/common.yaml @@ -0,0 +1,7 @@ +bk72xx_ble_tracker: + id: ble_tracker + scan_parameters: + interval: 100ms + window: 30ms + duration: 5min + continuous: true diff --git a/tests/components/bk72xx_ble_tracker/validate-boundary.bk72xx-ard.yaml b/tests/components/bk72xx_ble_tracker/validate-boundary.bk72xx-ard.yaml new file mode 100644 index 0000000000..932eb4fb55 --- /dev/null +++ b/tests/components/bk72xx_ble_tracker/validate-boundary.bk72xx-ard.yaml @@ -0,0 +1,2 @@ +packages: + bk72xx_ble_tracker: !include common-boundary.yaml diff --git a/tests/components/bk72xx_ble_tracker/validate.bk72xx-ard.yaml b/tests/components/bk72xx_ble_tracker/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..fc89479d06 --- /dev/null +++ b/tests/components/bk72xx_ble_tracker/validate.bk72xx-ard.yaml @@ -0,0 +1,2 @@ +packages: + bk72xx_ble_tracker: !include common.yaml From 0959141f883e6ff443c0cc8a2cad1e5ec9aefacc Mon Sep 17 00:00:00 2001 From: ESPHome Bot Date: Mon, 27 Jul 2026 18:58:50 -0500 Subject: [PATCH 1099/1815] [nrf52] Clone the sdk-nrf manifest repository shallow (#17892) Co-authored-by: esphbot Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/nrf52/framework.py | 1 + tests/unit_tests/test_nrf52_framework.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 7392ad2d60..6b32fe1fea 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -289,6 +289,7 @@ def check_and_install() -> None: "init", "-m", "https://github.com/nrfconnect/sdk-nrf", + "-o=--depth=1", "--mr", version, str(framework_path), diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index 8a5f4377d3..0a6bddc280 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -201,6 +201,24 @@ class TestCheckAndInstall: assert mock_nrf52_ops.download_from_mirrors.call_count == 2 assert mock_nrf52_ops.archive_extract_all.call_count == 2 + def test_framework_clone_is_shallow( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Both the manifest repository and every project are fetched at depth 1.""" + _mark_venv_ready(nrf52_dirs.python_env) + + check_and_install() + + init_cmd, update_cmd = ( + call.args[0] for call in mock_nrf52_ops.run_command_ok.call_args_list[:2] + ) + assert "init" in init_cmd + assert "-o=--depth=1" in init_cmd + assert "update" in update_cmd + assert "--fetch-opt=--depth=1" in update_cmd + def test_requirements_install_failure_raises( self, nrf52_dirs: SimpleNamespace, From e1ab5a85bb5371130e7882b65e15e992836d7f43 Mon Sep 17 00:00:00 2001 From: Egor Vorontsov Date: Tue, 28 Jul 2026 03:28:40 +0300 Subject: [PATCH 1100/1815] [i2s_audio] Eliminated a double unit conversion in `read_()` (#17752) --- .../i2s_audio/microphone/i2s_audio_microphone.cpp | 13 ++++++------- .../i2s_audio/microphone/i2s_audio_microphone.h | 2 +- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp index 66ca32b830..7b074b2e8f 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp @@ -245,7 +245,7 @@ void I2SAudioMicrophone::mic_task(void *params) { while (!(xEventGroupGetBits(this_microphone->event_group_) & MicrophoneEventGroupBits::COMMAND_STOP)) { if (this_microphone->data_callbacks_.size() > 0) { samples.resize(bytes_to_read); - size_t bytes_read = this_microphone->read_(samples.data(), bytes_to_read, 2 * pdMS_TO_TICKS(READ_DURATION_MS)); + size_t bytes_read = this_microphone->read_(samples.data(), bytes_to_read, 2 * READ_DURATION_MS); samples.resize(bytes_read); if (this_microphone->correct_dc_offset_) { this_microphone->fix_dc_offset_(samples); @@ -318,12 +318,11 @@ void I2SAudioMicrophone::fix_dc_offset_(std::vector &data) { } } -size_t I2SAudioMicrophone::read_(uint8_t *buf, size_t len, TickType_t ticks_to_wait) { +size_t I2SAudioMicrophone::read_(uint8_t *buf, size_t len, uint32_t timeout_ms) { size_t bytes_read = 0; - // i2s_channel_read expects the timeout value in ms, not ticks - esp_err_t err = i2s_channel_read(this->rx_handle_, buf, len, &bytes_read, pdTICKS_TO_MS(ticks_to_wait)); - if ((err != ESP_OK) && ((err != ESP_ERR_TIMEOUT) || (ticks_to_wait != 0))) { - // Ignore ESP_ERR_TIMEOUT if ticks_to_wait = 0, as it will read the data on the next call + esp_err_t err = i2s_channel_read(this->rx_handle_, buf, len, &bytes_read, timeout_ms); + if ((err != ESP_OK) && ((err != ESP_ERR_TIMEOUT) || (timeout_ms != 0))) { + // Ignore ESP_ERR_TIMEOUT if timeout_ms = 0, as it will read the data on the next call if (!this->status_has_warning()) { // Avoid spamming the logs with the error message if its repeated ESP_LOGW(TAG, "Read error: %s", esp_err_to_name(err)); @@ -331,7 +330,7 @@ size_t I2SAudioMicrophone::read_(uint8_t *buf, size_t len, TickType_t ticks_to_w this->status_set_warning(); return 0; } - if ((bytes_read == 0) && (ticks_to_wait > 0)) { + if ((bytes_read == 0) && (timeout_ms > 0)) { this->status_set_warning(); return 0; } diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h index 65ad7df1af..2c6528d8bf 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h @@ -42,7 +42,7 @@ class I2SAudioMicrophone final : public I2SAudioIn, public microphone::Microphon /// @param data void fix_dc_offset_(std::vector &data); - size_t read_(uint8_t *buf, size_t len, TickType_t ticks_to_wait); + size_t read_(uint8_t *buf, size_t len, uint32_t timeout_ms); /// @brief Sets the Microphone ``audio_stream_info_`` member variable to the configured I2S settings. void configure_stream_settings_(); From f1f8b102e26b1b4dc639af8880f442593f173019 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:10:34 -0500 Subject: [PATCH 1101/1815] [core] Add optional deprecation warning to cv.rename_key (#17740) --- esphome/config_validation.py | 20 ++++++++++- tests/unit_tests/test_config_validation.py | 41 ++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 713df5452a..ef250927f3 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -2718,10 +2718,28 @@ SOURCE_SCHEMA = Any( ) -def rename_key(old_key, new_key): +def rename_key( + old_key, new_key, *, removed_in: str | None = None, component: str | None = None +): + """Rename a config key from ``old_key`` to ``new_key``. + + When ``removed_in`` is set, a deprecation warning is logged if the old key is + present. Pass ``component`` (the platform/component name) alongside + ``removed_in`` so the warning identifies where it originates. + """ + def validator(config: dict) -> dict: config = config.copy() if old_key in config: + if removed_in is not None: + prefix = f"[{component}] " if component else "" + _LOGGER.warning( + "%s'%s' is deprecated, use '%s'. Will be removed in %s", + prefix, + old_key, + new_key, + removed_in, + ) config[new_key] = config.pop(old_key) return config diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 1da3d5593a..2ad22bc59c 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1,4 +1,5 @@ import json +import logging from pathlib import Path import string @@ -2915,6 +2916,46 @@ def test_rename_key_absent() -> None: assert cv.rename_key("old", "new")({"other": 5}) == {"other": 5} +def test_rename_key_no_removed_in_is_silent( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING, logger="esphome.config_validation"): + assert cv.rename_key("old", "new")({"old": 5}) == {"new": 5} + assert not caplog.records + + +def test_rename_key_removed_in_renames_and_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING, logger="esphome.config_validation"): + result = cv.rename_key("old", "new", removed_in="2026.8.0")({"old": 5}) + assert result == {"new": 5} + assert "'old' is deprecated, use 'new'. Will be removed in 2026.8.0" in caplog.text + + +def test_rename_key_removed_in_absent_key_no_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING, logger="esphome.config_validation"): + result = cv.rename_key("old", "new", removed_in="2026.8.0")({"other": 5}) + assert result == {"other": 5} + assert not caplog.records + + +def test_rename_key_removed_in_with_component_prefixes_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING, logger="esphome.config_validation"): + result = cv.rename_key( + "old", "new", removed_in="2026.8.0", component="my_component" + )({"old": 5}) + assert result == {"new": 5} + assert ( + "[my_component] 'old' is deprecated, use 'new'. Will be removed in 2026.8.0" + in caplog.text + ) + + def test_file__existing_relative_path(setup_core: Path) -> None: (setup_core / "partitions.csv").write_text("csv\n") From ce6c122449c2aff762caa140bbc251aaca84b6dc Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:16:57 +1200 Subject: [PATCH 1102/1815] [core] Move JSON string escaping into helpers (#17879) --- esphome/components/ble_scanner/ble_scanner.h | 20 ++--- .../captive_portal/captive_portal.cpp | 3 +- .../components/captive_portal/json_escape.h | 85 ------------------- esphome/core/helpers.cpp | 66 ++++++++++++++ esphome/core/helpers.h | 16 ++++ tests/components/captive_portal/__init__.py | 23 ----- .../json_escape_test.cpp | 55 +++++++++--- 7 files changed, 130 insertions(+), 138 deletions(-) delete mode 100644 esphome/components/captive_portal/json_escape.h delete mode 100644 tests/components/captive_portal/__init__.py rename tests/components/{captive_portal => core}/json_escape_test.cpp (66%) diff --git a/esphome/components/ble_scanner/ble_scanner.h b/esphome/components/ble_scanner/ble_scanner.h index c70ee637ef..106171d38f 100644 --- a/esphome/components/ble_scanner/ble_scanner.h +++ b/esphome/components/ble_scanner/ble_scanner.h @@ -5,6 +5,8 @@ #include #include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" #include "esphome/components/text_sensor/text_sensor.h" @@ -18,22 +20,10 @@ class BLEScanner final : public text_sensor::TextSensor, public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override { char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - // Escape special characters in the device name for valid JSON - const char *name = device.get_name().c_str(); + // Escape special characters in the device name for valid JSON. Control characters stay in the \u00XX form this + // sensor has always published. char escaped_name[128]; - size_t pos = 0; - for (; *name != '\0' && pos < sizeof(escaped_name) - 7; name++) { - uint8_t c = static_cast(*name); - if (c == '"' || c == '\\') { - escaped_name[pos++] = '\\'; - escaped_name[pos++] = c; - } else if (c < 0x20) { - pos += snprintf(escaped_name + pos, sizeof(escaped_name) - pos, "\\u%04x", c); - } else { - escaped_name[pos++] = c; - } - } - escaped_name[pos] = '\0'; + json_escape_into_buffer(escaped_name, StringRef(device.get_name()), /*short_control_escapes=*/false); char buf[256]; snprintf(buf, sizeof(buf), "{\"timestamp\":%" PRId64 ",\"address\":\"%s\",\"rssi\":%d,\"name\":\"%s\"}", diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index e6a63b8275..8094903008 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -2,9 +2,10 @@ #ifdef USE_CAPTIVE_PORTAL #include "esphome/core/log.h" #include "esphome/core/application.h" +#include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" #include "esphome/components/wifi/wifi_component.h" #include "captive_index.h" -#include "json_escape.h" namespace esphome::captive_portal { diff --git a/esphome/components/captive_portal/json_escape.h b/esphome/components/captive_portal/json_escape.h deleted file mode 100644 index 0b3c71cd74..0000000000 --- a/esphome/components/captive_portal/json_escape.h +++ /dev/null @@ -1,85 +0,0 @@ -#pragma once -#include -#include -#include - -#include "esphome/core/helpers.h" -#include "esphome/core/string_ref.h" - -namespace esphome::captive_portal { - -/// Largest number of output bytes a single input byte can expand to (a \u00XX sequence). -static constexpr size_t JSON_ESCAPE_MAX_EXPANSION = 6; - -/// Copy value into buf, escaping the characters that cannot appear raw inside a JSON string literal. -/// -/// Escapes " and \ along with the control characters below 0x20, using the short forms where JSON defines one and -/// \u00XX otherwise. Bytes >= 0x20 are copied verbatim, so text containing valid UTF-8 survives intact. The result is -/// always null terminated; anything that would not fit is dropped rather than written partially. Returns buf so the -/// call can be used directly as an argument. -/// -/// To size buf so that no input is ever dropped, allow JSON_ESCAPE_MAX_EXPANSION bytes per input byte plus one for -/// the null terminator. -inline const char *json_escape_into_buffer(std::span buf, StringRef value) { - if (buf.empty()) - return ""; - // Reserve one byte for the null terminator. - const size_t limit = buf.size() - 1; - size_t pos = 0; - for (char ch : value) { - auto c = static_cast(ch); - // Every short form is a backslash followed by a single character, so only that character is needed here. Keeping - // it a char rather than a string avoids putting the sequences in read only data, which is RAM on the ESP8266. - char escape = '\0'; - switch (c) { - case '"': - escape = '"'; - break; - case '\\': - escape = '\\'; - break; - case '\n': - escape = 'n'; - break; - case '\r': - escape = 'r'; - break; - case '\t': - escape = 't'; - break; - case '\b': - escape = 'b'; - break; - case '\f': - escape = 'f'; - break; - default: - break; - } - if (escape != '\0') { - if (pos + 2 > limit) - break; - buf[pos++] = '\\'; - buf[pos++] = escape; - } else if (c < 0x20) { - // Remaining control characters have no short form and must be written as \u00XX. The value is below 0x20, so - // the two high hex digits are always zero. - if (pos + JSON_ESCAPE_MAX_EXPANSION > limit) - break; - buf[pos++] = '\\'; - buf[pos++] = 'u'; - buf[pos++] = '0'; - buf[pos++] = '0'; - buf[pos++] = format_hex_char(static_cast(c >> 4)); - buf[pos++] = format_hex_char(static_cast(c & 0x0F)); - } else { - if (pos + 1 > limit) - break; - buf[pos++] = static_cast(c); - } - } - buf[pos] = '\0'; - return buf.data(); -} - -} // namespace esphome::captive_portal diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index a7b63643a4..c8cf85d7d6 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -335,6 +335,72 @@ char *format_hex_to(char *buffer, size_t buffer_size, const uint8_t *data, size_ return format_hex_internal(buffer, buffer_size, data, length, 0, 'a'); } +const char *json_escape_into_buffer(std::span buf, StringRef value, bool short_control_escapes) { + if (buf.empty()) + return ""; + // Reserve one byte for the null terminator. + const size_t limit = buf.size() - 1; + size_t pos = 0; + for (char ch : value) { + auto c = static_cast(ch); + // Every short form is a backslash followed by a single character, so only that character is needed here. Keeping + // it a char rather than a string avoids putting the sequences in read only data, which is RAM on the ESP8266. + char escape = '\0'; + switch (c) { + case '"': + escape = '"'; + break; + case '\\': + escape = '\\'; + break; + case '\n': + escape = 'n'; + break; + case '\r': + escape = 'r'; + break; + case '\t': + escape = 't'; + break; + case '\b': + escape = 'b'; + break; + case '\f': + escape = 'f'; + break; + default: + break; + } + // " and \ are always written as two characters, but the control characters fall through to \u00XX when the + // caller did not ask for the short forms. + if (!short_control_escapes && c < 0x20) + escape = '\0'; + if (escape != '\0') { + if (pos + 2 > limit) + break; + buf[pos++] = '\\'; + buf[pos++] = escape; + } else if (c < 0x20) { + // Remaining control characters have no short form and must be written as \u00XX. The value is below 0x20, so + // the two high hex digits are always zero. + if (pos + JSON_ESCAPE_MAX_EXPANSION > limit) + break; + buf[pos++] = '\\'; + buf[pos++] = 'u'; + buf[pos++] = '0'; + buf[pos++] = '0'; + buf[pos++] = format_hex_char(static_cast(c >> 4)); + buf[pos++] = format_hex_char(static_cast(c & 0x0F)); + } else { + if (pos + 1 > limit) + break; + buf[pos++] = static_cast(c); + } + } + buf[pos] = '\0'; + return buf.data(); +} + // format_hex (std::string returning overloads) moved to alloc_helpers.cpp char *format_hex_pretty_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length, char separator) { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index b897b927e2..7940df8780 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1268,6 +1268,22 @@ ESPHOME_ALWAYS_INLINE inline char format_hex_char(uint8_t v) { return format_hex /// Convert a nibble (0-15) to uppercase hex char (used for pretty printing) ESPHOME_ALWAYS_INLINE inline char format_hex_pretty_char(uint8_t v) { return format_hex_char(v, 'A'); } +/// Largest number of output bytes a single input byte can expand to when JSON escaped (a \u00XX sequence). +static constexpr size_t JSON_ESCAPE_MAX_EXPANSION = 6; + +/// Copy value into buf, escaping the characters that cannot appear raw inside a JSON string literal. +/// +/// Escapes " and \ along with the control characters below 0x20. Bytes >= 0x20 are copied verbatim, so text +/// containing valid UTF-8 survives intact. The result is always null terminated; anything that would not fit is +/// dropped rather than written partially. Returns buf so the call can be used directly as an argument. +/// +/// With short_control_escapes the five control characters JSON gives a short form get it (\n \r \t \b \f) and the +/// rest become \u00XX. Pass false to write every control character as \u00XX, which some consumers expect. +/// +/// To size buf so that no input is ever dropped, allow JSON_ESCAPE_MAX_EXPANSION bytes per input byte plus one for +/// the null terminator. +const char *json_escape_into_buffer(std::span buf, StringRef value, bool short_control_escapes = true); + /// Write int8 value to buffer without modulo operations. /// Buffer must have at least 4 bytes free. Returns pointer past last char written. inline char *int8_to_str(char *buf, int8_t val) { diff --git a/tests/components/captive_portal/__init__.py b/tests/components/captive_portal/__init__.py deleted file mode 100644 index b13c81912c..0000000000 --- a/tests/components/captive_portal/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Test-manifest overrides for the captive_portal C++ unit tests. - -``json_escape`` lives in a standalone, dependency-free header -(``esphome/components/captive_portal/json_escape.h``). The rest of the -captive_portal component and its auto-loaded dependencies (``web_server_base``, -``ota.web_server``) do not build for the ``host`` platform that the C++ unit -test harness targets. Strip those away and replace the real schema -- which is -restricted to non-host platforms via ``cv.only_on`` and requires a -``web_server_base`` instance via ``use_id`` -- with an empty one so the host -test config validates. ``to_code`` stays suppressed (the default), so -``USE_CAPTIVE_PORTAL`` is never defined and ``captive_portal.cpp`` compiles to an -empty translation unit; only ``json_escape.h`` is exercised by the test. -""" - -import esphome.config_validation as cv -from tests.testing_helpers import ComponentManifestOverride - - -def override_manifest(manifest: ComponentManifestOverride) -> None: - manifest.auto_load = [] - manifest.dependencies = [] - manifest.config_schema = cv.Schema({}) - manifest.final_validate_schema = None diff --git a/tests/components/captive_portal/json_escape_test.cpp b/tests/components/core/json_escape_test.cpp similarity index 66% rename from tests/components/captive_portal/json_escape_test.cpp rename to tests/components/core/json_escape_test.cpp index 98b5ce4ff7..4db6dce2ea 100644 --- a/tests/components/captive_portal/json_escape_test.cpp +++ b/tests/components/core/json_escape_test.cpp @@ -2,9 +2,10 @@ #include -#include "esphome/components/captive_portal/json_escape.h" +#include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" -namespace esphome::captive_portal::testing { +namespace esphome::testing { namespace { @@ -17,30 +18,36 @@ std::string escape(const std::string &value) { return json_escape_into_buffer(buf, StringRef(value.c_str(), value.size())); } +// Same, but with the short control forms turned off. +std::string escape_long(const std::string &value) { + char buf[TEST_BUFFER_SIZE]; + return json_escape_into_buffer(buf, StringRef(value.c_str(), value.size()), false); +} + } // namespace // Plain ASCII with no special characters is passed through unchanged. -TEST(CaptivePortalJsonEscape, PlainStringUnchanged) { +TEST(JsonEscape, PlainStringUnchanged) { EXPECT_EQ(escape("MyNetwork"), "MyNetwork"); EXPECT_EQ(escape(""), ""); } // A double quote is escaped so it does not terminate the surrounding JSON string. -TEST(CaptivePortalJsonEscape, EscapesDoubleQuote) { +TEST(JsonEscape, EscapesDoubleQuote) { EXPECT_EQ(escape("a\"b"), "a\\\"b"); // A double quote followed by other characters stays inside the JSON string. EXPECT_EQ(escape("\">end"), "\\\">end"); } // A backslash is doubled so it does not start an escape sequence in the output. -TEST(CaptivePortalJsonEscape, EscapesBackslash) { +TEST(JsonEscape, EscapesBackslash) { EXPECT_EQ(escape("a\\b"), "a\\\\b"); // A trailing backslash must not escape the closing quote of the JSON string. EXPECT_EQ(escape("net\\"), "net\\\\"); } // The control characters with short JSON forms use those forms. -TEST(CaptivePortalJsonEscape, EscapesShortFormControls) { +TEST(JsonEscape, EscapesShortFormControls) { EXPECT_EQ(escape("\n"), "\\n"); EXPECT_EQ(escape("\r"), "\\r"); EXPECT_EQ(escape("\t"), "\\t"); @@ -49,7 +56,7 @@ TEST(CaptivePortalJsonEscape, EscapesShortFormControls) { } // Other control characters (< 0x20) without a short form become \u00XX with lowercase hex. -TEST(CaptivePortalJsonEscape, EscapesOtherControlsAsUnicode) { +TEST(JsonEscape, EscapesOtherControlsAsUnicode) { EXPECT_EQ(escape(std::string("\x00", 1)), "\\u0000"); EXPECT_EQ(escape("\x01"), "\\u0001"); EXPECT_EQ(escape("\x10"), "\\u0010"); @@ -58,8 +65,28 @@ TEST(CaptivePortalJsonEscape, EscapesOtherControlsAsUnicode) { EXPECT_EQ(escape("\x7f"), "\x7f"); } +// With the short forms turned off, every control character is written as \u00XX instead. +TEST(JsonEscape, LongControlEscapes) { + EXPECT_EQ(escape_long("\n"), "\\u000a"); + EXPECT_EQ(escape_long("\r"), "\\u000d"); + EXPECT_EQ(escape_long("\t"), "\\u0009"); + EXPECT_EQ(escape_long("\b"), "\\u0008"); + EXPECT_EQ(escape_long("\f"), "\\u000c"); + // Controls without a short form are unaffected by the flag. + EXPECT_EQ(escape_long("\x01"), "\\u0001"); +} + +// The flag only affects control characters. A quote or backslash is never written as \u00XX, because that form is +// no shorter and both modes have always emitted the two character escape. +TEST(JsonEscape, LongModeStillUsesTwoCharQuoteAndBackslash) { + EXPECT_EQ(escape_long("a\"b"), "a\\\"b"); + EXPECT_EQ(escape_long("a\\b"), "a\\\\b"); + // Ordinary text is untouched in either mode. + EXPECT_EQ(escape_long("MyDevice"), "MyDevice"); +} + // Bytes >= 0x20, including multi-byte UTF-8 sequences, are passed through verbatim. -TEST(CaptivePortalJsonEscape, PassesThroughUtf8) { +TEST(JsonEscape, PassesThroughUtf8) { // "café" in UTF-8 (é == 0xC3 0xA9). EXPECT_EQ(escape("caf\xc3\xa9"), "caf\xc3\xa9"); // Emoji (📶, 4-byte UTF-8) survives unchanged. @@ -67,10 +94,10 @@ TEST(CaptivePortalJsonEscape, PassesThroughUtf8) { } // A mix of special and normal characters is escaped in place without disturbing the rest. -TEST(CaptivePortalJsonEscape, MixedContent) { EXPECT_EQ(escape("a\"b\\c\nd"), "a\\\"b\\\\c\\nd"); } +TEST(JsonEscape, MixedContent) { EXPECT_EQ(escape("a\"b\\c\nd"), "a\\\"b\\\\c\\nd"); } // A buffer sized at JSON_ESCAPE_MAX_EXPANSION bytes per input byte holds the worst case exactly. -TEST(CaptivePortalJsonEscape, WorstCaseInputFitsExactly) { +TEST(JsonEscape, WorstCaseInputFitsExactly) { constexpr size_t input_len = 8; char buf[input_len * JSON_ESCAPE_MAX_EXPANSION + 1]; const std::string input(input_len, '\x01'); @@ -82,7 +109,7 @@ TEST(CaptivePortalJsonEscape, WorstCaseInputFitsExactly) { // An escape sequence that would not fit is dropped whole rather than written partially, and the result stays null // terminated. -TEST(CaptivePortalJsonEscape, DropsEscapeThatWouldNotFit) { +TEST(JsonEscape, DropsEscapeThatWouldNotFit) { // Room for one \u00XX sequence plus the null terminator, but two are requested. char buf[JSON_ESCAPE_MAX_EXPANSION + 1]; const std::string input(2, '\x01'); @@ -92,16 +119,16 @@ TEST(CaptivePortalJsonEscape, DropsEscapeThatWouldNotFit) { } // Plain characters are truncated at the buffer size, leaving room for the null terminator. -TEST(CaptivePortalJsonEscape, TruncatesPlainInput) { +TEST(JsonEscape, TruncatesPlainInput) { char buf[5]; const std::string input(20, 'a'); EXPECT_STREQ(json_escape_into_buffer(buf, StringRef(input.c_str(), input.size())), "aaaa"); } // A zero length buffer cannot even hold a null terminator, so an empty string is returned instead of writing. -TEST(CaptivePortalJsonEscape, EmptyBufferIsSafe) { +TEST(JsonEscape, EmptyBufferIsSafe) { const std::string input("test"); EXPECT_STREQ(json_escape_into_buffer(std::span(), StringRef(input.c_str(), input.size())), ""); } -} // namespace esphome::captive_portal::testing +} // namespace esphome::testing From 98d99fb9fd90c2bb7413219d52ea6165972e248e Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:19:44 -0500 Subject: [PATCH 1103/1815] [light] Ensure binary light is off with brightness 0 (#17893) --- esphome/components/light/light_call.cpp | 12 + .../light/test_light_call_brightness.cpp | 120 ++++++++++ .../light_binary_effect_off_phase.yaml | 29 +++ ...binary_zero_brightness_is_recoverable.yaml | 21 ++ .../test_light_binary_effect_off_phase.py | 207 ++++++++++++++++++ 5 files changed, 389 insertions(+) create mode 100644 tests/components/light/test_light_call_brightness.cpp create mode 100644 tests/integration/fixtures/light_binary_effect_off_phase.yaml create mode 100644 tests/integration/fixtures/light_binary_zero_brightness_is_recoverable.yaml create mode 100644 tests/integration/test_light_binary_effect_off_phase.py diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 67fd175ce6..4251565e85 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -219,6 +219,18 @@ LightColorValues LightCall::validate_() { this->set_flag_(FLAG_HAS_STATE); } + // A light without brightness control has no way to represent "on but dark", so zero + // brightness -- how effects encode their dark phase -- means the light is off. Clear the + // brightness as well, so a zero can't linger in remote_values and leave the light stuck + // off: a later turn-on can't heal it, because the capability check below drops any + // brightness this mode doesn't support. explicit_turn_off_request was captured above, so + // a running effect is not stopped by this. + if (this->has_brightness() && this->brightness_ == 0.0f && !(color_mode & ColorCapability::BRIGHTNESS)) { + this->state_ = false; + this->set_flag_(FLAG_HAS_STATE); + this->clear_flag_(FLAG_HAS_BRIGHTNESS); + } + // Make sure a simple (no specific brightness) turn-on makes the light visible if (this->has_state() && this->state_ && (color_mode & ColorCapability::BRIGHTNESS) && !this->has_brightness() && this->parent_->remote_values.get_brightness() == 0.0f) { diff --git a/tests/components/light/test_light_call_brightness.cpp b/tests/components/light/test_light_call_brightness.cpp new file mode 100644 index 0000000000..3c5dffd2f1 --- /dev/null +++ b/tests/components/light/test_light_call_brightness.cpp @@ -0,0 +1,120 @@ +#include + +#include "esphome/components/light/light_call.h" +#include "esphome/components/light/light_output.h" +#include "esphome/components/light/light_state.h" + +namespace esphome::light::testing { + +namespace { + +// A light that only supports ON_OFF, like the `binary` platform and `status_led`. +class OnOffOutput : public LightOutput { + public: + LightTraits get_traits() override { + LightTraits traits; + traits.set_supported_color_modes({ColorMode::ON_OFF}); + return traits; + } + void write_state(LightState *state) override {} +}; + +// A dimmable light, like the `monochromatic` platform. +class BrightnessOutput : public LightOutput { + public: + LightTraits get_traits() override { + LightTraits traits; + traits.set_supported_color_modes({ColorMode::BRIGHTNESS}); + return traits; + } + void write_state(LightState *state) override {} +}; + +// validate_() is where zero brightness is resolved against the light's capabilities. +class TestableLightCall : public LightCall { + public: + using LightCall::LightCall; + using LightCall::validate_; +}; + +bool as_binary(const LightColorValues &values) { + bool binary; + values.as_binary(&binary); + return binary; +} + +} // namespace + +// An ON/OFF light has no "on but dark" state, so a zero brightness -- how effects encode +// their dark phase -- must turn the light off. Regression test for +// https://github.com/esphome/esphome/issues/17873. +TEST(LightCallOnOff, ZeroBrightnessTurnsOutputOff) { + OnOffOutput output; + LightState state(&output); + TestableLightCall call(&state); + + call.set_state(true).set_brightness(0.0f); + auto values = call.validate_(); + + EXPECT_FALSE(as_binary(values)); +} + +// The zero must not be stored, or no later turn-on could clear it: the capability check in +// validate_() drops any brightness an ON/OFF light doesn't support, so a stored zero would +// leave the light permanently off. +TEST(LightCallOnOff, ZeroBrightnessIsNotStored) { + OnOffOutput output; + LightState state(&output); + + TestableLightCall dark_call(&state); + dark_call.set_state(true).set_brightness(0.0f); + state.remote_values = dark_call.validate_(); + + EXPECT_FLOAT_EQ(state.remote_values.get_brightness(), 1.0f); + + // A plain turn-on afterwards must switch the light back on. + TestableLightCall on_call(&state); + on_call.set_state(true); + auto values = on_call.validate_(); + + EXPECT_TRUE(as_binary(values)); +} + +// A plain turn-on with no brightness must still light up. +TEST(LightCallOnOff, PlainTurnOnIsVisible) { + OnOffOutput output; + LightState state(&output); + TestableLightCall call(&state); + + call.set_state(true); + auto values = call.validate_(); + + EXPECT_TRUE(as_binary(values)); +} + +TEST(LightCallOnOff, TurnOffTurnsOutputOff) { + OnOffOutput output; + LightState state(&output); + TestableLightCall call(&state); + + call.set_state(false); + auto values = call.validate_(); + + EXPECT_FALSE(as_binary(values)); +} + +// A dimmable light can represent "on but dark", so zero brightness must be kept as-is and +// must not be rewritten into a turn-off. +TEST(LightCallBrightness, ZeroBrightnessStaysOnButDark) { + BrightnessOutput output; + LightState state(&output); + TestableLightCall call(&state); + + call.set_state(true).set_brightness(0.0f); + auto values = call.validate_(); + + EXPECT_TRUE(values.is_on()); + EXPECT_FLOAT_EQ(values.get_brightness(), 0.0f); +} + +} // namespace esphome::light::testing diff --git a/tests/integration/fixtures/light_binary_effect_off_phase.yaml b/tests/integration/fixtures/light_binary_effect_off_phase.yaml new file mode 100644 index 0000000000..c5c74001c3 --- /dev/null +++ b/tests/integration/fixtures/light_binary_effect_off_phase.yaml @@ -0,0 +1,29 @@ +esphome: + name: light-binary-effect-off +host: +api: # Port will be automatically injected +logger: + level: DEBUG + +output: + - platform: template + id: binary_output + type: binary + write_action: + - logger.log: + format: "BINARY_OUTPUT:%s" + args: [YESNO(state)] + +light: + - platform: binary + name: "Test Binary Light" + id: test_binary_light + output: binary_output + effects: + - strobe: + name: "Fast Strobe" + colors: + - state: true + duration: 50ms + - state: false + duration: 50ms diff --git a/tests/integration/fixtures/light_binary_zero_brightness_is_recoverable.yaml b/tests/integration/fixtures/light_binary_zero_brightness_is_recoverable.yaml new file mode 100644 index 0000000000..1db8b44fd1 --- /dev/null +++ b/tests/integration/fixtures/light_binary_zero_brightness_is_recoverable.yaml @@ -0,0 +1,21 @@ +esphome: + name: light-binary-zero-bright +host: +api: # Port will be automatically injected +logger: + level: DEBUG + +output: + - platform: template + id: binary_output + type: binary + write_action: + - logger.log: + format: "BINARY_OUTPUT:%s" + args: [YESNO(state)] + +light: + - platform: binary + name: "Test Binary Light" + id: test_binary_light + output: binary_output diff --git a/tests/integration/test_light_binary_effect_off_phase.py b/tests/integration/test_light_binary_effect_off_phase.py new file mode 100644 index 0000000000..571fca19f6 --- /dev/null +++ b/tests/integration/test_light_binary_effect_off_phase.py @@ -0,0 +1,207 @@ +"""Integration test verifying the off phase of an effect reaches an ON/OFF-only light. + +Regression test for https://github.com/esphome/esphome/issues/17873. A strobe effect +encodes its dark phase as `brightness = 0` while keeping `state = true`, so that the +effect keeps running instead of being stopped by an explicit turn-off. On a dimmable +light that works, because the output is driven by `state * brightness`. On a binary +light the dark phase used to be dropped, so the output stayed on forever. + +Effect ticks are published with `publish: false` (so Home Assistant isn't spammed with +every frame), so the effect's actual output can't be observed via API state broadcasts. +Instead, this test reads the output component's log lines, which are written on every +update regardless of the publish flag. + +The output log line is emitted strictly after the API state response: `perform()` +publishes inline, but the write is deferred to the next `LightState::loop()` iteration +and then has to cross the subprocess stdout pipe. So a future is armed *before* each +command and awaited afterwards, rather than reading the last observed value. +""" + +from __future__ import annotations + +import asyncio +import re +from typing import Any + +from aioesphomeapi import EntityState, LightState +import pytest + +from .state_utils import InitialStateHelper +from .types import APIClientConnectedFactory, RunCompiledFunction + +OUTPUT_PATTERN = re.compile(r"BINARY_OUTPUT:(YES|NO)") + + +@pytest.mark.asyncio +async def test_light_binary_effect_off_phase( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A strobe effect must drive a binary light's output both on and off.""" + loop = asyncio.get_running_loop() + observed: list[bool] = [] + pending: list[asyncio.Future[bool]] = [] + + def on_log_line(line: str) -> None: + if match := OUTPUT_PATTERN.search(line): + value = match.group(1) == "YES" + observed.append(value) + while pending: + future = pending.pop(0) + if not future.done(): + future.set_result(value) + break + + def arm_output() -> asyncio.Future[bool]: + """Arm a future for the next output write, before sending the command.""" + future: asyncio.Future[bool] = loop.create_future() + pending.append(future) + return future + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + light = next(e for e in entities if e.object_id == "test_binary_light") + + state_futures: dict[int, asyncio.Future[LightState]] = {} + + def on_state(state: EntityState) -> None: + if isinstance(state, LightState) and state.key in state_futures: + future = state_futures[state.key] + if not future.done(): + future.set_result(state) + + # ESPHome sends the current state of every entity right after connecting; drain + # that initial burst so it can't be mistaken for the response to a command below. + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + async def send_and_wait(timeout: float = 5.0, **kwargs: Any) -> LightState: + """Send a light command and wait for the matching state response.""" + state_futures[light.key] = loop.create_future() + client.light_command(key=light.key, **kwargs) + return await asyncio.wait_for(state_futures[light.key], timeout=timeout) + + # A plain turn-on must drive the output on -- brightness defaults to 100% and + # must not be mistaken for a dark phase. + output = arm_output() + state = await send_and_wait(state=True) + assert state.state is True + assert await asyncio.wait_for(output, timeout=5.0) is True, ( + "Plain turn-on did not switch the output on" + ) + + # Run the strobe effect; both phases must reach the output. + observed.clear() + state = await send_and_wait(effect="Fast Strobe") + assert state.effect == "Fast Strobe" + # Let several effect cycles run (each phase is 50ms in the fixture). + await asyncio.sleep(1.0) + + assert True in observed, ( + f"Strobe effect never switched the output on -- got {observed}" + ) + assert False in observed, ( + f"Strobe effect never switched the output off; its dark phase was lost -- " + f"got {observed}" + ) + + # Stopping the effect must leave the light usable. + state = await send_and_wait(effect="None") + assert state.effect == "None" + output = arm_output() + state = await send_and_wait(state=True) + assert state.state is True + assert await asyncio.wait_for(output, timeout=5.0) is True, ( + "Light stayed off after the effect stopped" + ) + + # An explicit turn-off still switches the output off. + output = arm_output() + state = await send_and_wait(state=False) + assert state.state is False + assert await asyncio.wait_for(output, timeout=5.0) is False, ( + "Turn-off did not switch the output off" + ) + + +@pytest.mark.asyncio +async def test_light_binary_zero_brightness_is_recoverable( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Zero brightness on an ON/OFF light must not leave it permanently stuck off. + + An ON/OFF light has no brightness capability, so `turn_on` with 0% brightness has + no representable "on but dark" state. It must switch the output off and report the + light as off, and a later plain turn-on must bring it back. + """ + loop = asyncio.get_running_loop() + pending: list[asyncio.Future[bool]] = [] + + def on_log_line(line: str) -> None: + if match := OUTPUT_PATTERN.search(line): + value = match.group(1) == "YES" + while pending: + future = pending.pop(0) + if not future.done(): + future.set_result(value) + break + + def arm_output() -> asyncio.Future[bool]: + future: asyncio.Future[bool] = loop.create_future() + pending.append(future) + return future + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + light = next(e for e in entities if e.object_id == "test_binary_light") + + state_futures: dict[int, asyncio.Future[LightState]] = {} + + def on_state(state: EntityState) -> None: + if isinstance(state, LightState) and state.key in state_futures: + future = state_futures[state.key] + if not future.done(): + future.set_result(state) + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + async def send_and_wait(timeout: float = 5.0, **kwargs: Any) -> LightState: + state_futures[light.key] = loop.create_future() + client.light_command(key=light.key, **kwargs) + return await asyncio.wait_for(state_futures[light.key], timeout=timeout) + + output = arm_output() + state = await send_and_wait(state=True) + assert state.state is True + assert await asyncio.wait_for(output, timeout=5.0) is True + + # Turning on at 0% brightness has no representable "on but dark" state here, + # so the light must switch off and report itself as off. + output = arm_output() + state = await send_and_wait(state=True, brightness=0.0) + assert await asyncio.wait_for(output, timeout=5.0) is False, ( + "Zero brightness did not switch the output off" + ) + assert state.state is False, ( + "Light reported itself as on while its output was off" + ) + + # A plain turn-on must recover -- the stored zero brightness must not persist. + output = arm_output() + state = await send_and_wait(state=True) + assert state.state is True + assert await asyncio.wait_for(output, timeout=5.0) is True, ( + "Light was left permanently off by a zero-brightness turn-on" + ) From c2476911a3d92fd294e6075e9d883df49d8ed202 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Tue, 28 Jul 2026 06:14:37 +0300 Subject: [PATCH 1104/1815] [bluetooth_proxy] Answer UNPAIR with BluetoothDeviceUnpairingResponse (#17895) --- esphome/components/bluetooth_proxy/bluetooth_proxy.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 37ebcad8b4..f1a30cdfa2 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -247,7 +247,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest esp_bd_addr_t address; uint64_to_bd_addr(msg.address, address); esp_err_t ret = esp_ble_remove_bond_device(address); - this->send_device_pairing(msg.address, ret == ESP_OK, ret); + this->send_device_unpairing(msg.address, ret == ESP_OK, ret); break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE: { From 16930c4e2c4685e4b1ec3bfcea32eebcc50f3465 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Jul 2026 17:33:08 -1000 Subject: [PATCH 1105/1815] [ci] Read the GitHub event file as UTF-8 (#17896) --- script/helpers.py | 5 ++++- tests/script/test_helpers.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/script/helpers.py b/script/helpers.py index 0086a00e85..6ba093b413 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -421,7 +421,10 @@ def _get_github_event_data() -> dict | None: """ github_event_path = os.environ.get("GITHUB_EVENT_PATH") if github_event_path and Path(github_event_path).exists(): - with Path(github_event_path).open() as f: + # The event payload is UTF-8 JSON; without an explicit encoding + # Windows decodes it as cp1252 and any non ASCII byte (an ellipsis in + # a commit title is enough) raises UnicodeDecodeError. + with Path(github_event_path).open(encoding="utf-8") as f: return json.load(f) return None diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 886d413ccf..43c4445dcf 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -79,6 +79,22 @@ def test_get_pr_number_from_github_env_event_file( assert result == "5678" +def test_get_github_event_data_decodes_utf8_regardless_of_locale( + monkeypatch: MonkeyPatch, tmp_path: Path +) -> None: + """The event payload is UTF-8; parsing must not depend on the platform + default encoding. On Windows the default is cp1252, which raised + UnicodeDecodeError as soon as a commit title carried non ASCII text.""" + event_file = tmp_path / "event.json" + event_data = {"head_commit": {"message": "Answer UNPAIR with Response… é"}} + event_file.write_bytes(json.dumps(event_data, ensure_ascii=False).encode("utf-8")) + monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_file)) + + result = helpers._get_github_event_data() + + assert result == event_data + + def test_get_pr_number_from_github_env_no_pr( monkeypatch: MonkeyPatch, tmp_path: Path ) -> None: From 93eec5c9619599e8eea8933bde224625281c77f0 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:19:00 +1200 Subject: [PATCH 1106/1815] [ci] Attribute device class sync commits to the esphome[bot] account (#17898) --- .github/workflows/sync-device-classes.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 5bec463a33..5d250b97eb 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -96,8 +96,8 @@ jobs: uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: commit-message: "Synchronise Device Classes from Home Assistant" - committer: esphomebot - author: esphomebot + committer: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> + author: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> branch: sync/device-classes delete-branch: true title: "Synchronise Device Classes from Home Assistant" From 10303b3fa71286b65779ba3e4534695e0250a2fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Tue, 28 Jul 2026 10:17:40 +0300 Subject: [PATCH 1107/1815] [ble_device_base] Add mac_lsb_first_to_uint64 address-packing helper (#17901) --- esphome/components/ble_device_base/ble_device.h | 14 ++++++++++++++ tests/components/ble_device_base/test_address.cpp | 15 +++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/esphome/components/ble_device_base/ble_device.h b/esphome/components/ble_device_base/ble_device.h index 94678091cc..2d2cb5796b 100644 --- a/esphome/components/ble_device_base/ble_device.h +++ b/esphome/components/ble_device_base/ble_device.h @@ -144,6 +144,20 @@ class ESPBLEiBeacon { } beacon_data_; }; +/// Pack a controller-order (LSB-first) MAC into the uint64 the API speaks. +/// +/// The result is the printable-order value esp32 has always sent +/// (esp32_ble::ble_addr_to_uint64), so both proxy paths agree on the wire. +/// This takes the raw controller order delivered by BLEHub's raw-advertisement +/// callback; ESPBTDevice::address_uint64() is the equivalent for an already +/// parsed device, whose address is stored MSB-first. +inline uint64_t mac_lsb_first_to_uint64(const uint8_t *mac) { + uint64_t addr = 0; + for (int i = 0; i < 6; i++) + addr |= static_cast(mac[i]) << (i * 8); + return addr; +} + // --------------------------------------------------------------------------- // ESPBTDevice — parsed BLE advertisement // --------------------------------------------------------------------------- diff --git a/tests/components/ble_device_base/test_address.cpp b/tests/components/ble_device_base/test_address.cpp index c903003a7c..9e4bca4c57 100644 --- a/tests/components/ble_device_base/test_address.cpp +++ b/tests/components/ble_device_base/test_address.cpp @@ -28,4 +28,19 @@ TEST(BleDeviceAddress, AccessorsMatchEsp32Semantics) { EXPECT_EQ(device.address_str(), "AA:BB:CC:DD:EE:FF"); } +// mac_lsb_first_to_uint64() packs the controller-order bytes a raw-advertisement +// callback delivers into the printable-order uint64 the native API speaks — the +// value esp32_ble::ble_addr_to_uint64() has always produced for that address. +TEST(BleDeviceAddress, MacLsbFirstToUint64MatchesWireValue) { + EXPECT_EQ(mac_lsb_first_to_uint64(MAC_LSB_FIRST), 0xAABBCCDDEEFFULL); +} + +// The helper and the parsed-device accessor are two routes to the same wire +// value: byte order must agree no matter which path an advertisement takes. +TEST(BleDeviceAddress, MacLsbFirstToUint64AgreesWithParsedDevice) { + ESPBTDevice device; + device.from_scan_result(MAC_LSB_FIRST, -50, BLE_ADDR_TYPE_PUBLIC, nullptr, 0); + EXPECT_EQ(mac_lsb_first_to_uint64(MAC_LSB_FIRST), device.address_uint64()); +} + } // namespace esphome::ble_device_base::testing From bb8ffac269e04a5afc2190386a60def27face9ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Tue, 28 Jul 2026 10:19:17 +0300 Subject: [PATCH 1108/1815] [const] Move CONF_SCAN_PARAMETERS and CONF_WINDOW to components/const (#17900) --- esphome/components/bk72xx_ble_tracker/__init__.py | 3 +-- esphome/components/const/__init__.py | 2 ++ esphome/components/esp32_ble_tracker/__init__.py | 3 +-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/bk72xx_ble_tracker/__init__.py b/esphome/components/bk72xx_ble_tracker/__init__.py index 5cf1fabbbc..39c9ada731 100644 --- a/esphome/components/bk72xx_ble_tracker/__init__.py +++ b/esphome/components/bk72xx_ble_tracker/__init__.py @@ -21,13 +21,12 @@ Scan modes: import esphome.codegen as cg from esphome.components import bk72xx_ble, ble_device_base, ota +from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW import esphome.config_validation as cv from esphome.const import CONF_CONTINUOUS, CONF_DURATION, CONF_ID, CONF_INTERVAL from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType -CONF_WINDOW = "window" -CONF_SCAN_PARAMETERS = "scan_parameters" CONF_BK72XX_BLE_ID = "bk72xx_ble_id" DEPENDENCIES = ["bk72xx"] diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 6f4fa9aaa7..7e46e81c69 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -31,6 +31,7 @@ CONF_PARITY = "parity" CONF_RECEIVER_FREQUENCY = "receiver_frequency" CONF_REQUEST_HEADERS = "request_headers" CONF_ROWS = "rows" +CONF_SCAN_PARAMETERS = "scan_parameters" CONF_SHA256 = "sha256" CONF_STATE_SAVE_INTERVAL = "state_save_interval" CONF_STOP_BITS = "stop_bits" @@ -39,6 +40,7 @@ CONF_VOLUME_INCREMENT = "volume_increment" CONF_VOLUME_INITIAL = "volume_initial" CONF_VOLUME_MAX = "volume_max" CONF_VOLUME_MIN = "volume_min" +CONF_WINDOW = "window" ICON_CURRENT_DC = "mdi:current-dc" ICON_SOLAR_PANEL = "mdi:solar-panel" diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 2febb16cf4..e462da1a49 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -6,6 +6,7 @@ import logging from esphome import automation import esphome.codegen as cg from esphome.components import ble_device_base, esp32_ble, ota +from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW from esphome.components.esp32 import ( add_idf_sdkconfig_option, request_bluetooth, @@ -44,8 +45,6 @@ DEPENDENCIES = ["esp32"] CODEOWNERS = ["@bdraco"] CONF_ESP32_BLE_ID = "esp32_ble_id" -CONF_SCAN_PARAMETERS = "scan_parameters" -CONF_WINDOW = "window" CONF_ON_SCAN_END = "on_scan_end" CONF_SOFTWARE_COEXISTENCE = "software_coexistence" From 333ad42e2969198f62dcf41392fe1ced50d6b8c8 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:28:23 +1200 Subject: [PATCH 1109/1815] Update webserver local assets to 20260728-053845 (#17899) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- .../components/captive_portal/captive_index.h | 273 +- .../components/web_server/server_index_v2.h | 2600 +++--- .../components/web_server/server_index_v3.h | 8096 +++++++++-------- 3 files changed, 5487 insertions(+), 5482 deletions(-) diff --git a/esphome/components/captive_portal/captive_index.h b/esphome/components/captive_portal/captive_index.h index a81edc1900..a25ac8d010 100644 --- a/esphome/components/captive_portal/captive_index.h +++ b/esphome/components/captive_portal/captive_index.h @@ -7,145 +7,146 @@ namespace esphome::captive_portal { #ifdef USE_CAPTIVE_PORTAL_GZIP constexpr uint8_t INDEX_GZ[] PROGMEM = { - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x95, 0x16, 0x6b, 0x8f, 0xdb, 0x36, 0xf2, 0x7b, 0x7e, - 0x05, 0x8f, 0x49, 0xbb, 0x52, 0xb3, 0x7a, 0x7a, 0xed, 0x6c, 0x24, 0x51, 0x45, 0x9a, 0xbb, 0xa2, 0x05, 0x9a, 0x36, - 0xc0, 0x6e, 0x73, 0x1f, 0x82, 0x00, 0x4b, 0x53, 0x23, 0x8b, 0x31, 0x45, 0xea, 0x48, 0xca, 0x8f, 0x18, 0xbe, 0xdf, - 0x7e, 0xa0, 0x24, 0x7b, 0x9d, 0x45, 0x73, 0xb8, 0xb3, 0x60, 0x61, 0x38, 0xef, 0x19, 0xcd, 0x83, 0xc5, 0xdf, 0x2a, - 0xc5, 0xec, 0xbe, 0x03, 0xd4, 0xd8, 0x56, 0x94, 0x85, 0x7b, 0x23, 0x41, 0xe5, 0x8a, 0x80, 0x2c, 0x8b, 0x06, 0x68, - 0x55, 0x16, 0x2d, 0x58, 0x8a, 0x58, 0x43, 0xb5, 0x01, 0x4b, 0xfe, 0xbc, 0xff, 0x39, 0xb8, 0x2d, 0x0b, 0xc1, 0xe5, - 0x1a, 0x69, 0x10, 0x84, 0x33, 0x25, 0x51, 0xa3, 0xa1, 0x26, 0x15, 0xb5, 0x34, 0xe3, 0x2d, 0x5d, 0xc1, 0x24, 0x22, - 0x69, 0x0b, 0x64, 0xc3, 0x61, 0xdb, 0x29, 0x6d, 0x11, 0x53, 0xd2, 0x82, 0xb4, 0x04, 0x6f, 0x79, 0x65, 0x1b, 0x52, - 0xc1, 0x86, 0x33, 0x08, 0x86, 0xc3, 0x35, 0x97, 0xdc, 0x72, 0x2a, 0x02, 0xc3, 0xa8, 0x00, 0x92, 0x5c, 0xf7, 0x06, - 0xf4, 0x70, 0xa0, 0x4b, 0x01, 0x44, 0x2a, 0x5c, 0x16, 0x86, 0x69, 0xde, 0x59, 0xe4, 0x5c, 0x25, 0xad, 0xaa, 0x7a, - 0x01, 0x65, 0x14, 0x51, 0x63, 0xc0, 0x9a, 0x88, 0xcb, 0x0a, 0x76, 0xe1, 0x32, 0x66, 0x2c, 0x86, 0xdb, 0xdb, 0xf0, - 0xb3, 0x79, 0x56, 0x29, 0xd6, 0xb7, 0x20, 0x6d, 0x28, 0x14, 0xa3, 0x96, 0x2b, 0x19, 0x1a, 0xa0, 0x9a, 0x35, 0x84, - 0x10, 0xfc, 0xa3, 0xa1, 0x1b, 0xc0, 0xdf, 0x7f, 0xef, 0x9d, 0x99, 0x56, 0x60, 0xff, 0x21, 0xc0, 0x81, 0xe6, 0xa7, - 0xfd, 0x3d, 0x5d, 0xfd, 0x4e, 0x5b, 0xf0, 0x30, 0x35, 0xbc, 0x02, 0xec, 0x7f, 0x8c, 0x3f, 0x85, 0xc6, 0xee, 0x05, - 0x84, 0x15, 0x37, 0x9d, 0xa0, 0x7b, 0x82, 0x97, 0x42, 0xb1, 0x35, 0xf6, 0xf3, 0xba, 0x97, 0xcc, 0x29, 0x47, 0xc6, - 0x03, 0xff, 0x20, 0xc0, 0x22, 0x4b, 0xde, 0x51, 0xdb, 0x84, 0x2d, 0xdd, 0x79, 0x23, 0xc0, 0xa5, 0x97, 0xfe, 0xe0, - 0xc1, 0xcb, 0x24, 0x8e, 0xfd, 0xeb, 0xe1, 0x15, 0xfb, 0x51, 0x12, 0xc7, 0xb9, 0x06, 0xdb, 0x6b, 0x89, 0xa8, 0xf7, - 0x50, 0x74, 0xd4, 0x36, 0xa8, 0x22, 0xf8, 0x5d, 0x92, 0xa2, 0xe4, 0x75, 0x98, 0xce, 0x7f, 0x0b, 0x5f, 0xa1, 0x9b, - 0x30, 0x9d, 0xb3, 0x57, 0xc1, 0x1c, 0x25, 0x37, 0xc1, 0x1c, 0xa5, 0x69, 0x38, 0x47, 0xf1, 0x17, 0x8c, 0x6a, 0x2e, - 0x04, 0xc1, 0x52, 0x49, 0xc0, 0xc8, 0x58, 0xad, 0xd6, 0x40, 0x30, 0xeb, 0xb5, 0x06, 0x69, 0xdf, 0x2a, 0xa1, 0x34, - 0x8e, 0xca, 0x67, 0xff, 0x97, 0x42, 0xab, 0xa9, 0x34, 0xb5, 0xd2, 0x2d, 0xc1, 0x43, 0xf6, 0xbd, 0x17, 0x07, 0x7b, - 0x44, 0xee, 0xe5, 0x5f, 0x10, 0x03, 0xa5, 0xf9, 0x8a, 0x4b, 0x82, 0x9d, 0xc6, 0x5b, 0x1c, 0x95, 0x0f, 0xfe, 0xf1, - 0x1c, 0x3d, 0x75, 0xd1, 0x4f, 0xf1, 0x28, 0xef, 0xe3, 0x43, 0x61, 0x36, 0x2b, 0xb4, 0x6b, 0x85, 0x34, 0x04, 0x37, - 0xd6, 0x76, 0x59, 0x14, 0x6d, 0xb7, 0xdb, 0x70, 0x3b, 0x0b, 0x95, 0x5e, 0x45, 0x69, 0x1c, 0xc7, 0x91, 0xd9, 0xac, - 0x30, 0x1a, 0x0b, 0x01, 0xa7, 0x37, 0x18, 0x35, 0xc0, 0x57, 0x8d, 0x1d, 0xe0, 0xf2, 0xc5, 0x01, 0x8e, 0x85, 0xe3, - 0x28, 0x1f, 0x3e, 0x5d, 0x58, 0xe1, 0x17, 0x56, 0xe0, 0x47, 0xea, 0xe1, 0x53, 0x98, 0x57, 0x43, 0x98, 0xaf, 0x68, - 0x8a, 0x52, 0x14, 0x0f, 0x4f, 0x1a, 0x38, 0x78, 0x3a, 0x05, 0x4f, 0x4e, 0xe8, 0xe2, 0xe4, 0xa0, 0x76, 0x11, 0xbc, - 0x3e, 0xcb, 0x26, 0x0e, 0xb3, 0x49, 0xe2, 0x47, 0x84, 0x13, 0xf8, 0x65, 0x71, 0x79, 0x0e, 0xd2, 0x0f, 0x97, 0x0c, - 0xce, 0x5a, 0x93, 0x7c, 0x58, 0xd0, 0x39, 0x9a, 0x4f, 0x98, 0x79, 0xe0, 0xe0, 0xf3, 0x09, 0xcd, 0x37, 0x69, 0x93, - 0xb4, 0xc1, 0x22, 0x98, 0xd3, 0x19, 0x9a, 0x4d, 0x8e, 0xcc, 0xd0, 0x6c, 0x93, 0x36, 0x8b, 0x0f, 0x8b, 0x4b, 0x5c, - 0x30, 0xfb, 0x72, 0x15, 0x95, 0xd8, 0xcf, 0x30, 0x7e, 0x8c, 0x5c, 0x5d, 0x46, 0x1e, 0x7e, 0x56, 0x5c, 0x7a, 0x18, - 0xfb, 0xc7, 0x1a, 0x2c, 0x6b, 0x3c, 0x1c, 0x31, 0x25, 0x6b, 0xbe, 0x0a, 0x3f, 0x1b, 0x25, 0xb1, 0x1f, 0xda, 0x06, - 0xa4, 0x77, 0x12, 0x75, 0x82, 0x30, 0x50, 0xbc, 0xa7, 0x14, 0xeb, 0x1f, 0xce, 0xf5, 0x6f, 0xb9, 0x15, 0x40, 0x6c, - 0xe8, 0x1a, 0xf6, 0xfa, 0x8c, 0x5d, 0xaa, 0x6a, 0xff, 0x8d, 0xd6, 0x68, 0x92, 0xb1, 0x2f, 0xb8, 0x94, 0xa0, 0xef, - 0x61, 0x67, 0x09, 0x7e, 0xf7, 0xe6, 0x2d, 0x7a, 0x53, 0x55, 0x1a, 0x8c, 0xc9, 0x10, 0x7e, 0x69, 0xc3, 0x96, 0xb2, - 0xff, 0x5d, 0x57, 0xf2, 0x95, 0xae, 0x7f, 0xf2, 0x9f, 0x39, 0xfa, 0x1d, 0xec, 0x56, 0xe9, 0xf5, 0xa4, 0xcd, 0xb9, - 0x96, 0xbb, 0x0e, 0xd3, 0xc4, 0x86, 0xb4, 0x33, 0xa1, 0x11, 0x9c, 0x81, 0x97, 0xf8, 0x61, 0x4b, 0xbb, 0xc7, 0xa8, - 0xe4, 0x29, 0x51, 0x0f, 0x45, 0xc5, 0x37, 0x88, 0x09, 0x6a, 0x0c, 0xc1, 0x72, 0x54, 0x85, 0xd1, 0x33, 0x34, 0xfc, - 0x94, 0x64, 0x82, 0xb3, 0x35, 0xc1, 0x7f, 0x31, 0x01, 0x7e, 0xda, 0xff, 0x5a, 0x79, 0x57, 0xc6, 0xf0, 0xea, 0xca, - 0x0f, 0x37, 0x54, 0xf4, 0x80, 0x08, 0xb2, 0x0d, 0x37, 0x8f, 0x0e, 0xe6, 0xdf, 0x14, 0xeb, 0xcc, 0xfa, 0xca, 0x0f, - 0x6b, 0xc5, 0x7a, 0xe3, 0xf9, 0xb8, 0x9c, 0xcc, 0x15, 0x74, 0x1c, 0x90, 0xf8, 0x39, 0x7e, 0xe2, 0x51, 0x20, 0xa0, - 0xb6, 0x67, 0x3e, 0x84, 0x5e, 0x1c, 0x8c, 0x27, 0x43, 0x6d, 0x0c, 0xf7, 0x8f, 0x67, 0x64, 0x61, 0x3a, 0x2a, 0x9f, - 0x0a, 0x3a, 0x07, 0x5d, 0xab, 0xc8, 0xd0, 0x41, 0xae, 0x5f, 0x3a, 0x2a, 0xcf, 0x06, 0x23, 0x7a, 0x02, 0x5f, 0x1c, - 0xb8, 0x27, 0xdd, 0x14, 0x5c, 0x9f, 0x35, 0x16, 0x51, 0xc5, 0x37, 0xe5, 0xc3, 0xd1, 0x7f, 0x8c, 0xe3, 0x5f, 0x3d, - 0xe8, 0xfd, 0x1d, 0x08, 0x60, 0x56, 0x69, 0x0f, 0x3f, 0x97, 0x60, 0xb1, 0x3f, 0x06, 0xfc, 0xcb, 0xfd, 0xbb, 0xdf, - 0x88, 0xf2, 0xb4, 0x7f, 0xfd, 0x2d, 0x6e, 0xb7, 0x0a, 0x3e, 0x6a, 0x10, 0xff, 0x26, 0x57, 0x6e, 0x19, 0x5c, 0x7d, - 0xc2, 0x7e, 0x38, 0xc4, 0xfb, 0xf0, 0xb8, 0x11, 0x5c, 0x3b, 0xbf, 0xdc, 0xb5, 0xe2, 0xda, 0x45, 0x18, 0x2c, 0xe6, - 0xfe, 0xf1, 0xe1, 0xe8, 0x1f, 0xfd, 0xbc, 0x88, 0xc6, 0xb9, 0x5e, 0x16, 0xc3, 0x88, 0x2d, 0x7f, 0x38, 0x2c, 0xd5, - 0x2e, 0x30, 0xfc, 0x0b, 0x97, 0xab, 0x8c, 0xcb, 0x06, 0x34, 0xb7, 0xc7, 0x8a, 0x6f, 0xae, 0xb9, 0xec, 0x7a, 0x7b, - 0xe8, 0x68, 0x55, 0x39, 0xca, 0xbc, 0xdb, 0xe5, 0xb5, 0x92, 0xd6, 0x71, 0x42, 0x96, 0x40, 0x7b, 0x1c, 0xe9, 0xc3, - 0x44, 0xc9, 0x5e, 0xcf, 0xbf, 0x3b, 0xba, 0x82, 0x3b, 0x58, 0xd8, 0xd9, 0x80, 0x0a, 0xbe, 0x92, 0x19, 0x03, 0x69, - 0x41, 0x8f, 0x42, 0x35, 0x6d, 0xb9, 0xd8, 0x67, 0x86, 0x4a, 0x13, 0x18, 0xd0, 0xbc, 0x3e, 0x2e, 0x7b, 0x6b, 0x95, - 0x3c, 0x2c, 0x95, 0xae, 0x40, 0x67, 0x71, 0x3e, 0x02, 0x81, 0xa6, 0x15, 0xef, 0x4d, 0x16, 0xce, 0x34, 0xb4, 0xf9, - 0x92, 0xb2, 0xf5, 0x4a, 0xab, 0x5e, 0x56, 0x01, 0x73, 0x93, 0x36, 0x7b, 0x9e, 0xd4, 0x74, 0x06, 0x2c, 0x9f, 0x4e, - 0x75, 0x5d, 0xe7, 0x82, 0x4b, 0x08, 0xc6, 0x59, 0x96, 0xa5, 0xe1, 0x8d, 0x13, 0xbb, 0x70, 0x33, 0x4c, 0x1d, 0x62, - 0xf4, 0x31, 0x89, 0xe3, 0xef, 0xf2, 0x53, 0x38, 0x71, 0xce, 0x7a, 0x6d, 0x94, 0xce, 0x3a, 0xc5, 0x9d, 0x9b, 0xc7, - 0x96, 0x72, 0x79, 0xe9, 0xbd, 0x2b, 0x93, 0x7c, 0x5a, 0x3f, 0x19, 0x97, 0x83, 0x99, 0x61, 0x09, 0xe5, 0x2d, 0x97, - 0xe3, 0x0e, 0xcd, 0xd2, 0x45, 0xdc, 0xed, 0x8e, 0xe1, 0x54, 0x20, 0x87, 0x13, 0x77, 0x2d, 0x60, 0x97, 0x7f, 0xee, - 0x8d, 0xe5, 0xf5, 0x3e, 0x98, 0x76, 0x70, 0x66, 0x3a, 0xca, 0x20, 0x58, 0x82, 0xdd, 0x02, 0xc8, 0x7c, 0xb0, 0x11, - 0x70, 0x0b, 0xad, 0x99, 0xf2, 0x74, 0x56, 0x33, 0x14, 0xe8, 0xd7, 0xba, 0xfe, 0x1b, 0xb7, 0xab, 0xc5, 0x43, 0x4b, - 0xf5, 0x8a, 0xcb, 0x60, 0xa9, 0xac, 0x55, 0x6d, 0x16, 0xbc, 0xea, 0x76, 0xf9, 0x84, 0x72, 0xca, 0xb2, 0xc4, 0xb9, - 0x39, 0xec, 0xd6, 0x53, 0xbe, 0x93, 0x6e, 0x87, 0x8c, 0x12, 0xbc, 0x9a, 0xf8, 0x06, 0x16, 0x14, 0x9f, 0xd3, 0x93, - 0xcc, 0xbb, 0x1d, 0x72, 0xb8, 0x53, 0xaa, 0x6f, 0xea, 0x5b, 0x9a, 0xc4, 0x7f, 0xf1, 0x45, 0xaa, 0xba, 0x4e, 0x97, - 0xf5, 0x39, 0x53, 0x6e, 0x4d, 0xba, 0xd6, 0x18, 0x4a, 0xab, 0x88, 0xc6, 0xdb, 0x8c, 0xab, 0x8c, 0xb2, 0x70, 0x19, - 0x2e, 0x8b, 0x26, 0x41, 0xbc, 0x22, 0x2d, 0x65, 0xe5, 0xc5, 0xf8, 0x2a, 0xa2, 0x26, 0x39, 0x91, 0x9a, 0xa4, 0xfc, - 0x6a, 0x18, 0x8d, 0xb4, 0xc1, 0xfb, 0xf2, 0xad, 0x92, 0x12, 0x98, 0xe5, 0x72, 0x85, 0xac, 0x42, 0x53, 0x0a, 0xc2, - 0x30, 0x2c, 0x96, 0xba, 0x7c, 0x2f, 0x80, 0x1a, 0x40, 0x5b, 0xca, 0x6d, 0x58, 0x44, 0x23, 0xff, 0xd8, 0xc7, 0xbc, - 0x22, 0x12, 0x6c, 0x39, 0x35, 0x6c, 0xd1, 0xcc, 0x46, 0x03, 0x77, 0x60, 0x9d, 0x26, 0x67, 0x60, 0x56, 0x16, 0x6e, - 0xe5, 0x22, 0x3a, 0x8c, 0x34, 0x12, 0x6d, 0x79, 0xcd, 0xdd, 0x95, 0xa5, 0x2c, 0x86, 0x22, 0x77, 0x1a, 0x5c, 0x9e, - 0xc7, 0xeb, 0xd5, 0x00, 0x09, 0x90, 0x2b, 0xdb, 0x90, 0x59, 0x8a, 0x3a, 0x41, 0x19, 0x34, 0x4a, 0x54, 0xa0, 0xc9, - 0xdd, 0xdd, 0xaf, 0x7f, 0x2f, 0x9d, 0x33, 0x8f, 0x72, 0x9d, 0x59, 0x8f, 0x62, 0x0e, 0x98, 0xa4, 0x16, 0x37, 0xe3, - 0xa5, 0xaa, 0xa3, 0xc6, 0x6c, 0x95, 0xae, 0xbe, 0xd2, 0xf1, 0x7e, 0x42, 0x8e, 0x7a, 0x86, 0xff, 0xd0, 0x2a, 0xe5, - 0x1d, 0xdd, 0x40, 0x11, 0x4d, 0x87, 0x22, 0x72, 0x0e, 0x8f, 0xf4, 0x66, 0xe2, 0x6b, 0x92, 0xf2, 0x8f, 0xfb, 0x37, - 0xe8, 0xcf, 0xae, 0xa2, 0x16, 0xc6, 0xb4, 0x0d, 0x51, 0xb5, 0x60, 0x1b, 0x55, 0x91, 0xf7, 0x7f, 0xdc, 0xdd, 0x9f, - 0x23, 0xec, 0x07, 0x26, 0x04, 0x92, 0x8d, 0xd7, 0xbb, 0x5e, 0x58, 0xde, 0x51, 0x6d, 0x07, 0xb5, 0x81, 0x9b, 0x22, - 0xa7, 0x18, 0x06, 0x7a, 0xcd, 0x05, 0x8c, 0x61, 0x8c, 0x82, 0x25, 0x3a, 0x79, 0x75, 0xb2, 0xf6, 0xc4, 0xaf, 0x68, - 0xfc, 0xda, 0xd1, 0xf8, 0xe9, 0xa3, 0xe1, 0xa6, 0xfb, 0x1f, 0x53, 0x58, 0x46, 0xb2, 0xf9, 0x0a, 0x00, 0x00}; + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x95, 0x16, 0x6b, 0x8f, 0xdb, 0x36, 0xf2, 0x7b, 0x7f, + 0x05, 0x8f, 0x4d, 0x1b, 0xa9, 0xb1, 0xa8, 0x87, 0xd7, 0xde, 0x44, 0x96, 0x54, 0xa4, 0x7b, 0x2d, 0x5a, 0xa0, 0x69, + 0x03, 0xec, 0x36, 0xf7, 0x21, 0x08, 0xb0, 0x34, 0x39, 0xb2, 0x98, 0xa5, 0x48, 0x1d, 0x49, 0xbf, 0x62, 0xf8, 0x7e, + 0xfb, 0x81, 0x92, 0xec, 0xf5, 0x2e, 0x9a, 0x03, 0x0e, 0x86, 0x85, 0x19, 0xce, 0x7b, 0x38, 0x0f, 0x16, 0xff, 0xe0, + 0x9a, 0xb9, 0x7d, 0x07, 0xa8, 0x71, 0xad, 0xac, 0x0a, 0xff, 0x45, 0x92, 0xaa, 0x55, 0x09, 0xaa, 0x2a, 0x1a, 0xa0, + 0xbc, 0x2a, 0x5a, 0x70, 0x14, 0xb1, 0x86, 0x1a, 0x0b, 0xae, 0xfc, 0xeb, 0xee, 0x97, 0xe8, 0x75, 0x55, 0x48, 0xa1, + 0x1e, 0x90, 0x01, 0x59, 0x0a, 0xa6, 0x15, 0x6a, 0x0c, 0xd4, 0x25, 0xa7, 0x8e, 0xe6, 0xa2, 0xa5, 0x2b, 0x18, 0x45, + 0x14, 0x6d, 0xa1, 0xdc, 0x08, 0xd8, 0x76, 0xda, 0x38, 0xc4, 0xb4, 0x72, 0xa0, 0x5c, 0x89, 0xb7, 0x82, 0xbb, 0xa6, + 0xe4, 0xb0, 0x11, 0x0c, 0xa2, 0x1e, 0x99, 0x08, 0x25, 0x9c, 0xa0, 0x32, 0xb2, 0x8c, 0x4a, 0x28, 0xd3, 0xc9, 0xda, + 0x82, 0xe9, 0x11, 0xba, 0x94, 0x50, 0x2a, 0x8d, 0xab, 0xc2, 0x32, 0x23, 0x3a, 0x87, 0xbc, 0xab, 0x65, 0xab, 0xf9, + 0x5a, 0x42, 0x15, 0xc7, 0xd4, 0x5a, 0x70, 0x36, 0x16, 0x8a, 0xc3, 0x8e, 0xd0, 0x6b, 0xb8, 0xa6, 0x2c, 0x4d, 0xc8, + 0x67, 0xfb, 0x0d, 0xd7, 0x6c, 0xdd, 0x82, 0x72, 0x44, 0x6a, 0x46, 0x9d, 0xd0, 0x8a, 0x58, 0xa0, 0x86, 0x35, 0x65, + 0x59, 0xe2, 0x1f, 0x2d, 0xdd, 0x00, 0xfe, 0xfe, 0xfb, 0xe0, 0xcc, 0xb4, 0x02, 0xf7, 0xb3, 0x04, 0x0f, 0xda, 0x9f, + 0xf6, 0x77, 0x74, 0xf5, 0x07, 0x6d, 0x21, 0xc0, 0xd4, 0x0a, 0x0e, 0x38, 0xfc, 0x98, 0x7c, 0x22, 0xd6, 0xed, 0x25, + 0x10, 0x2e, 0x6c, 0x27, 0xe9, 0xbe, 0xc4, 0x4b, 0xa9, 0xd9, 0x03, 0x0e, 0x17, 0xf5, 0x5a, 0x31, 0xaf, 0x1c, 0xe9, + 0x00, 0xc2, 0x83, 0x04, 0x87, 0x5c, 0xf9, 0x8e, 0xba, 0x86, 0xb4, 0x74, 0x17, 0x0c, 0x80, 0x50, 0x41, 0xf6, 0x43, + 0x00, 0xaf, 0xd2, 0x24, 0x09, 0x27, 0xfd, 0x27, 0x09, 0xe3, 0x34, 0x49, 0x16, 0x06, 0xdc, 0xda, 0x28, 0x44, 0x83, + 0xfb, 0xa2, 0xa3, 0xae, 0x41, 0xbc, 0xc4, 0xef, 0xd2, 0x0c, 0xa5, 0x6f, 0x48, 0x36, 0xfb, 0x9d, 0x5c, 0xa3, 0x2b, + 0x92, 0xcd, 0xd8, 0x75, 0x34, 0x43, 0xe9, 0x55, 0x34, 0x43, 0x59, 0x46, 0x66, 0x28, 0xf9, 0x82, 0x51, 0x2d, 0xa4, + 0x2c, 0xb1, 0xd2, 0x0a, 0x30, 0xb2, 0xce, 0xe8, 0x07, 0x28, 0x31, 0x5b, 0x1b, 0x03, 0xca, 0xdd, 0x68, 0xa9, 0x0d, + 0x8e, 0xab, 0x6f, 0xfe, 0x2f, 0x85, 0xce, 0x50, 0x65, 0x6b, 0x6d, 0xda, 0x12, 0xf7, 0xd9, 0x0f, 0x5e, 0x1c, 0xdc, + 0x11, 0xf9, 0x4f, 0x78, 0x41, 0x8c, 0xb4, 0x11, 0x2b, 0xa1, 0x4a, 0xec, 0x35, 0xbe, 0xc6, 0x71, 0x75, 0x1f, 0x1e, + 0xcf, 0xd1, 0x53, 0x1f, 0xfd, 0x18, 0x0f, 0x0f, 0x3e, 0xde, 0x17, 0x76, 0xb3, 0x42, 0xbb, 0x56, 0x2a, 0x5b, 0xe2, + 0xc6, 0xb9, 0x2e, 0x8f, 0xe3, 0xed, 0x76, 0x4b, 0xb6, 0x53, 0xa2, 0xcd, 0x2a, 0xce, 0x92, 0x24, 0x89, 0xed, 0x66, + 0x85, 0xd1, 0x50, 0x08, 0x38, 0xbb, 0xc2, 0xa8, 0x01, 0xb1, 0x6a, 0x5c, 0x0f, 0x57, 0x2f, 0x0e, 0x70, 0x2c, 0x3c, + 0x47, 0x75, 0xff, 0xe9, 0xc2, 0x8a, 0xb9, 0xb0, 0x02, 0x3f, 0xd2, 0x00, 0x9f, 0xc2, 0x7c, 0xd9, 0x87, 0x79, 0x4d, + 0x33, 0x94, 0xa1, 0xa4, 0xff, 0x65, 0x91, 0x87, 0x47, 0x2c, 0x7a, 0x86, 0xa1, 0x0b, 0xcc, 0x43, 0xed, 0x3c, 0x7a, + 0x73, 0x96, 0x4d, 0xfd, 0xc9, 0x26, 0x4d, 0x1e, 0x0f, 0xbc, 0xc0, 0xaf, 0xf3, 0x4b, 0x3c, 0xca, 0x3e, 0x5c, 0x32, + 0x78, 0x6b, 0x4d, 0xfa, 0x61, 0x4e, 0x67, 0x68, 0x36, 0x9e, 0xcc, 0x22, 0x0f, 0x9f, 0x31, 0x34, 0xdb, 0x64, 0x4d, + 0xda, 0x46, 0xf3, 0x68, 0x46, 0xa7, 0x68, 0x3a, 0x3a, 0x32, 0x45, 0xd3, 0x4d, 0xd6, 0xcc, 0x3f, 0xcc, 0x2f, 0xcf, + 0xa2, 0xe9, 0x97, 0x97, 0x71, 0x85, 0xc3, 0x1c, 0xe3, 0xc7, 0xc8, 0xf9, 0x65, 0xe4, 0xe4, 0xb3, 0x16, 0x2a, 0xc0, + 0x38, 0x3c, 0xd6, 0xe0, 0x58, 0x13, 0xe0, 0x98, 0x69, 0x55, 0x8b, 0x15, 0xf9, 0x6c, 0xb5, 0xc2, 0x21, 0x71, 0x0d, + 0xa8, 0xe0, 0x24, 0xea, 0x05, 0xa1, 0xa7, 0x04, 0xcf, 0x29, 0x2e, 0x3c, 0x9c, 0xeb, 0xdf, 0x09, 0x27, 0xa1, 0x74, + 0xc4, 0x37, 0xec, 0xe4, 0x6f, 0xba, 0xe2, 0xa7, 0xfd, 0x6f, 0x3c, 0xc0, 0x2d, 0x65, 0x38, 0x24, 0x42, 0x29, 0x30, + 0x77, 0xb0, 0x73, 0x25, 0x7e, 0xf7, 0xf6, 0x06, 0xbd, 0xe5, 0xdc, 0x80, 0xb5, 0x39, 0xc2, 0xaf, 0x1c, 0x69, 0x29, + 0xfb, 0xba, 0x78, 0x93, 0x3e, 0x95, 0xfe, 0x97, 0xf8, 0x45, 0xa0, 0x3f, 0xc0, 0x6d, 0xb5, 0x79, 0x18, 0xe5, 0xbd, + 0xfd, 0x85, 0x6f, 0x23, 0x56, 0x7e, 0x55, 0x8d, 0x02, 0x87, 0xc3, 0x89, 0xf8, 0x3a, 0x83, 0xb5, 0x82, 0xe3, 0x70, + 0x22, 0xbf, 0xce, 0xd1, 0x59, 0xdf, 0xbc, 0x8e, 0xd0, 0xce, 0x12, 0x2b, 0x05, 0x83, 0x20, 0x0d, 0x49, 0xad, 0xcd, + 0xcf, 0x94, 0x35, 0x8f, 0x09, 0xb2, 0x43, 0x47, 0xab, 0x47, 0x3d, 0xcc, 0x00, 0x75, 0x30, 0xaa, 0x0a, 0x30, 0x17, + 0x1b, 0x1c, 0x2e, 0x14, 0x61, 0x92, 0x5a, 0xeb, 0x47, 0x46, 0xe9, 0x9d, 0xf3, 0xe1, 0xe0, 0x89, 0x1a, 0x22, 0xfd, + 0xf5, 0xee, 0xdd, 0xef, 0xe5, 0x7d, 0x41, 0x87, 0x01, 0x89, 0xbf, 0xc5, 0xa8, 0x67, 0x3e, 0x33, 0x46, 0x12, 0x6a, + 0xe7, 0x2b, 0x5e, 0x07, 0x96, 0x18, 0x6b, 0x45, 0x78, 0x2c, 0x6c, 0x47, 0xd5, 0x73, 0xb6, 0x3e, 0xa6, 0xaa, 0x88, + 0x3d, 0xad, 0x2a, 0x62, 0x5a, 0xbd, 0x38, 0x98, 0xc0, 0xfa, 0xe1, 0xf6, 0x10, 0x1e, 0xef, 0x27, 0x8a, 0xfc, 0x7b, + 0x0d, 0x66, 0x7f, 0x0b, 0x12, 0x98, 0xd3, 0x26, 0xc0, 0xe4, 0x89, 0x60, 0x48, 0x1c, 0xec, 0xdc, 0xcd, 0x38, 0x7f, + 0x2d, 0xf1, 0x87, 0x13, 0x45, 0xb4, 0x62, 0x52, 0xb0, 0x87, 0xf2, 0x1c, 0x71, 0x78, 0x10, 0x64, 0x43, 0xe5, 0x1a, + 0x4e, 0x3c, 0x92, 0xd4, 0x9a, 0xad, 0x6d, 0x10, 0x1e, 0x27, 0x8c, 0xd0, 0xae, 0x03, 0xc5, 0x6f, 0x1a, 0x21, 0x79, + 0xa0, 0xc2, 0x63, 0xf8, 0x78, 0xd3, 0xcf, 0x8c, 0xfb, 0xd5, 0xf0, 0xd1, 0x80, 0xfc, 0x4f, 0xf9, 0xd2, 0x2f, 0x87, + 0x97, 0x9f, 0x70, 0x48, 0xfa, 0xf8, 0xef, 0x1f, 0x37, 0x84, 0x6f, 0xef, 0x57, 0xbb, 0x56, 0x4e, 0x7c, 0xe8, 0xd1, + 0x7c, 0x16, 0x1e, 0xef, 0x8f, 0xe1, 0x31, 0x5c, 0x14, 0xf1, 0x30, 0xe7, 0xab, 0xa2, 0x1f, 0xb9, 0xd5, 0x0f, 0x87, + 0xa5, 0xde, 0x45, 0x56, 0x7c, 0x11, 0x6a, 0x95, 0x0b, 0xd5, 0x80, 0x11, 0xee, 0xc8, 0xc5, 0x66, 0x22, 0x54, 0xb7, + 0x76, 0x87, 0x8e, 0x72, 0xee, 0x29, 0xb3, 0x6e, 0xb7, 0xa8, 0xb5, 0x72, 0x9e, 0x13, 0xf2, 0x14, 0xda, 0xe3, 0x40, + 0xef, 0x27, 0x4c, 0xfe, 0x66, 0xf6, 0xdd, 0x71, 0xa9, 0xf9, 0xfe, 0xe0, 0xd3, 0x10, 0x51, 0x29, 0x56, 0x2a, 0x67, + 0xa0, 0x1c, 0x98, 0x41, 0xa8, 0xa6, 0xad, 0x90, 0xfb, 0xdc, 0x52, 0x65, 0x23, 0x0b, 0x46, 0xd4, 0xc7, 0xe5, 0xda, + 0x39, 0xad, 0x0e, 0x4b, 0x6d, 0x38, 0x98, 0x3c, 0x59, 0x0c, 0x40, 0x64, 0x28, 0x17, 0x6b, 0x9b, 0x93, 0xa9, 0x81, + 0x76, 0xb1, 0xa4, 0xec, 0x61, 0x65, 0xf4, 0x5a, 0xf1, 0x88, 0xf9, 0xc9, 0x9b, 0x7f, 0x9b, 0xd6, 0x74, 0x0a, 0x6c, + 0x31, 0x62, 0x75, 0x5d, 0x2f, 0xa4, 0x50, 0x10, 0x0d, 0xb3, 0x2d, 0xcf, 0xc8, 0x95, 0x17, 0xbb, 0x70, 0x93, 0x64, + 0xfe, 0x60, 0xf0, 0x31, 0x4d, 0x92, 0xef, 0x16, 0xa7, 0x70, 0x92, 0x05, 0x5b, 0x1b, 0xab, 0x4d, 0xde, 0x69, 0xe1, + 0xdd, 0x3c, 0xb6, 0x54, 0xa8, 0x4b, 0xef, 0x7d, 0xd9, 0x2c, 0xc6, 0x75, 0x94, 0x0b, 0xd5, 0x9b, 0xe9, 0x97, 0xd2, + 0xa2, 0x15, 0x6a, 0xd8, 0xa9, 0x79, 0x36, 0x4f, 0xba, 0xdd, 0xf1, 0x54, 0x09, 0x87, 0x13, 0x77, 0x2d, 0x61, 0xb7, + 0xf8, 0xbc, 0xb6, 0x4e, 0xd4, 0xfb, 0x68, 0xdc, 0xc9, 0xb9, 0xed, 0x28, 0x83, 0x68, 0x09, 0x6e, 0x0b, 0xa0, 0x16, + 0xbd, 0x8d, 0x48, 0x38, 0x68, 0xed, 0x98, 0xa7, 0xb3, 0x9a, 0xbe, 0x60, 0x9f, 0xea, 0xfa, 0x5f, 0xdc, 0xbe, 0x8a, + 0x0e, 0x2d, 0x35, 0x2b, 0xa1, 0xa2, 0xa5, 0x76, 0x4e, 0xb7, 0x79, 0x74, 0xdd, 0xed, 0x16, 0xe3, 0x91, 0x57, 0x96, + 0xa7, 0xde, 0xcd, 0x7e, 0xd7, 0x9e, 0xf2, 0x9d, 0x76, 0x3b, 0x64, 0xb5, 0x14, 0x7c, 0xe4, 0xeb, 0x59, 0x50, 0x72, + 0x4e, 0x4f, 0x3a, 0xeb, 0x76, 0xc8, 0x9f, 0x9d, 0x52, 0x7d, 0x55, 0xbf, 0xa6, 0x69, 0xf2, 0x37, 0x37, 0xc2, 0xeb, + 0x3a, 0x5b, 0xd6, 0xe7, 0x4c, 0xf9, 0xb5, 0xe9, 0x57, 0x4b, 0x5f, 0x5a, 0x45, 0x3c, 0xbc, 0x6e, 0x7c, 0x65, 0x54, + 0x85, 0xcf, 0x70, 0x55, 0x34, 0x29, 0x12, 0xbc, 0x6c, 0x29, 0xab, 0x2e, 0x66, 0x5b, 0x11, 0x37, 0xe9, 0x89, 0xd4, + 0xa4, 0xd5, 0x93, 0xb9, 0x35, 0xd0, 0x7a, 0xef, 0xab, 0x1b, 0xad, 0x14, 0x30, 0x27, 0xd4, 0x0a, 0x39, 0x8d, 0xc6, + 0x14, 0x10, 0x42, 0x8a, 0xa5, 0xa9, 0xde, 0x4b, 0xa0, 0x16, 0xd0, 0x96, 0x0a, 0x47, 0x8a, 0x78, 0xe0, 0x1f, 0x3a, + 0x5d, 0xf0, 0x52, 0x81, 0x3b, 0xf7, 0x76, 0x33, 0x1d, 0x0c, 0xdc, 0x82, 0xf3, 0x9a, 0xbc, 0x81, 0x69, 0x55, 0xf8, + 0x15, 0x8c, 0x68, 0xdf, 0xa5, 0x65, 0xbc, 0x15, 0xb5, 0xf0, 0x4f, 0x98, 0xaa, 0xe8, 0x8b, 0xdc, 0x6b, 0xf0, 0x79, + 0x1e, 0x9e, 0x5b, 0x3d, 0x24, 0x41, 0xad, 0x5c, 0x53, 0x4e, 0x33, 0xd4, 0x49, 0xca, 0xa0, 0xd1, 0x92, 0x83, 0x29, + 0x6f, 0x6f, 0x7f, 0xfb, 0x67, 0xe5, 0x9d, 0x79, 0x94, 0xeb, 0xec, 0xc3, 0x20, 0xe6, 0x81, 0x51, 0x6a, 0x7e, 0x35, + 0x3c, 0xb2, 0x3a, 0x6a, 0xed, 0x56, 0x1b, 0xfe, 0x44, 0xc7, 0xfb, 0xf1, 0x70, 0xd0, 0xd3, 0xff, 0xfb, 0x56, 0xa9, + 0x6e, 0xe9, 0x06, 0x8a, 0x78, 0x44, 0x8a, 0xd8, 0x3b, 0x3c, 0xd0, 0x9b, 0x91, 0xaf, 0x49, 0xab, 0x3f, 0xef, 0xde, + 0xa2, 0xbf, 0x3a, 0x4e, 0x1d, 0x0c, 0x69, 0xeb, 0xa3, 0x6a, 0xc1, 0x35, 0x9a, 0x97, 0xef, 0xff, 0xbc, 0xbd, 0x3b, + 0x47, 0xb8, 0xee, 0x99, 0x10, 0x28, 0x36, 0x3c, 0xf7, 0xd6, 0xd2, 0x89, 0x8e, 0x1a, 0xd7, 0xab, 0x8d, 0xfc, 0x14, + 0x39, 0xc5, 0xd0, 0xd3, 0x6b, 0x21, 0x61, 0x08, 0x63, 0x10, 0xac, 0xd0, 0xc9, 0xab, 0x93, 0xb5, 0x67, 0x7e, 0xc5, + 0xc3, 0x6d, 0xc7, 0xc3, 0xd5, 0xc7, 0xfd, 0xcb, 0xf7, 0xbf, 0x81, 0xdb, 0x13, 0xb5, 0x09, 0x0b, 0x00, 0x00}; #else // Brotli (default, smaller) constexpr uint8_t INDEX_BR[] PROGMEM = { - 0x1b, 0xf8, 0x0a, 0x00, 0x64, 0x5a, 0xd3, 0xfa, 0xe7, 0xf3, 0x62, 0xd8, 0x06, 0x1b, 0xe9, 0x6a, 0x8a, 0x81, 0x2b, - 0xb5, 0x49, 0x14, 0x37, 0xdc, 0x9e, 0x1a, 0xcb, 0x56, 0x87, 0xfb, 0xff, 0xf7, 0x73, 0x75, 0x12, 0x0a, 0xd6, 0x48, - 0x84, 0xc6, 0x21, 0xa4, 0x6d, 0xb5, 0x71, 0xef, 0x13, 0xbe, 0x4e, 0x54, 0xf1, 0x64, 0x8f, 0x3f, 0xcc, 0x9a, 0x78, - 0xa5, 0x89, 0x25, 0xb3, 0xda, 0x2c, 0xa2, 0x32, 0x9c, 0x57, 0x07, 0x56, 0xbc, 0x34, 0x13, 0xff, 0x5c, 0x0a, 0xa1, - 0x67, 0x82, 0xb8, 0x6b, 0x4c, 0x76, 0x31, 0x6c, 0xe3, 0x40, 0x46, 0xea, 0xb0, 0xd4, 0xf4, 0x3b, 0x02, 0x65, 0x18, - 0xa4, 0xaf, 0xac, 0x6d, 0x55, 0xd6, 0xbe, 0x59, 0x66, 0x7a, 0x7c, 0x60, 0xb2, 0x83, 0x33, 0x23, 0xc9, 0x79, 0x82, - 0x47, 0xb4, 0x28, 0xf4, 0x24, 0xb5, 0x23, 0x5a, 0x44, 0xe1, 0xc3, 0x27, 0x04, 0xe8, 0x0c, 0xdd, 0xb4, 0xd0, 0x8c, - 0xfb, 0x10, 0x39, 0x93, 0x04, 0x2a, 0x66, 0x18, 0x4b, 0x74, 0xca, 0x31, 0x7f, 0xb2, 0xe5, 0x45, 0xc1, 0xdd, 0x72, - 0x49, 0xff, 0x0e, 0xb3, 0xf0, 0x93, 0x18, 0xab, 0x68, 0xad, 0xe1, 0x9d, 0xe4, 0x29, 0xc0, 0xe3, 0x63, 0x54, 0x61, - 0x1b, 0x45, 0xb9, 0x6c, 0x23, 0x0f, 0x99, 0x7f, 0x8e, 0x69, 0xaa, 0xc1, 0xb8, 0x4e, 0x42, 0x9c, 0xc5, 0x6e, 0x69, - 0x40, 0x0e, 0x4f, 0x97, 0xd3, 0x23, 0x18, 0xf5, 0xc8, 0x75, 0x73, 0xb5, 0xbd, 0x46, 0x8a, 0x97, 0x7d, 0x83, 0xe4, - 0x29, 0x72, 0x73, 0xc1, 0x39, 0x8e, 0x7e, 0x84, 0x39, 0x66, 0x57, 0xc6, 0x85, 0x19, 0x8b, 0xf2, 0x4d, 0xd9, 0xfe, - 0x75, 0xa9, 0xe1, 0x2b, 0x21, 0x81, 0x58, 0x51, 0x99, 0xbc, 0xa4, 0x0b, 0x10, 0x6f, 0x86, 0x17, 0x0b, 0x92, 0x00, - 0x11, 0x6f, 0x3b, 0xa4, 0xa4, 0x11, 0x7e, 0x0b, 0x97, 0x85, 0x23, 0x0c, 0x01, 0x6f, 0x2a, 0x18, 0xc6, 0xbe, 0x3d, - 0x77, 0x1a, 0xe6, 0x00, 0x5c, 0x1a, 0x14, 0x47, 0xc6, 0xcc, 0xcc, 0x52, 0xbe, 0x04, 0x19, 0x31, 0x05, 0x46, 0xa0, - 0xc3, 0x69, 0x0c, 0x60, 0xb7, 0x14, 0x57, 0xa0, 0x92, 0xbf, 0xb7, 0x0c, 0xd8, 0x3a, 0x79, 0x09, 0x99, 0xc9, 0x71, - 0x88, 0x01, 0x8b, 0xa5, 0x61, 0x0a, 0xb5, 0xe8, 0xc7, 0x71, 0xe7, 0x70, 0x79, 0xb6, 0xe4, 0x01, 0xfc, 0x1a, 0x4a, - 0x7b, 0x60, 0x6e, 0xef, 0x95, 0x62, 0x59, 0x28, 0xb5, 0x25, 0x56, 0x15, 0xe7, 0xca, 0xad, 0x32, 0xe6, 0xf7, 0x01, - 0x31, 0x34, 0x87, 0x93, 0x0b, 0x9b, 0x9d, 0x26, 0xff, 0xe5, 0x92, 0xad, 0x6f, 0xb8, 0x3b, 0x16, 0xc1, 0xa0, 0x5a, - 0x4f, 0x52, 0x0b, 0x2b, 0xc1, 0xa7, 0x95, 0x7b, 0x24, 0x51, 0xd3, 0xb3, 0x23, 0x62, 0x0b, 0xcc, 0xa0, 0x58, 0xa7, - 0x64, 0x45, 0x2f, 0x0b, 0xdd, 0x1d, 0x97, 0x82, 0x1f, 0xcc, 0x64, 0xdb, 0xd3, 0xf4, 0xb0, 0x8b, 0xc8, 0xcf, 0x15, - 0x81, 0x8b, 0xa1, 0x9d, 0xf8, 0xfc, 0xec, 0x49, 0x40, 0x12, 0x01, 0x09, 0x51, 0xf3, 0x73, 0x18, 0x24, 0x97, 0x55, - 0x85, 0x6a, 0x92, 0x1a, 0xf5, 0x5a, 0x05, 0x54, 0x1f, 0x27, 0x0a, 0xa8, 0xa1, 0x94, 0x58, 0x78, 0x7d, 0x87, 0xa8, - 0xdb, 0x13, 0x66, 0x20, 0x5e, 0x43, 0x18, 0x7a, 0xbb, 0x16, 0x16, 0x07, 0xc8, 0xab, 0x10, 0xe2, 0x50, 0xb9, 0xb1, - 0xd8, 0x21, 0xc8, 0x4a, 0x2e, 0x99, 0x0e, 0x23, 0x52, 0xc6, 0xcb, 0x29, 0x84, 0x91, 0x03, 0xb1, 0xe2, 0x4c, 0x1d, - 0x22, 0xd3, 0xc8, 0x79, 0x00, 0x8b, 0x8b, 0x88, 0x1e, 0x29, 0xd3, 0xae, 0x10, 0x15, 0x22, 0x6d, 0xb0, 0x87, 0x6f, - 0x27, 0x2e, 0x7c, 0xc2, 0x7a, 0x61, 0xbd, 0x22, 0xe5, 0x5f, 0xdd, 0x7b, 0x00, 0x04, 0xf2, 0x7d, 0x5a, 0x03, 0x38, - 0x1f, 0x69, 0x6d, 0x0b, 0xfb, 0xec, 0x45, 0xfe, 0x8b, 0x7f, 0xec, 0x7b, 0xad, 0xc2, 0x33, 0xf1, 0x9e, 0x9c, 0x71, - 0xd9, 0xe8, 0x5e, 0x8f, 0xd4, 0xee, 0x87, 0x45, 0x6c, 0xe2, 0x12, 0xf8, 0xb8, 0xc5, 0xee, 0x43, 0xa6, 0x37, 0x91, - 0xb5, 0x2c, 0x2f, 0xe9, 0xe8, 0x24, 0xd0, 0x45, 0xc1, 0x0c, 0x7c, 0xf0, 0xb2, 0xb5, 0x2d, 0x10, 0x36, 0x7e, 0x18, - 0x7c, 0x79, 0x82, 0x69, 0x3d, 0x35, 0xca, 0x52, 0xee, 0xc9, 0xb5, 0x65, 0xa4, 0xa1, 0xfd, 0x70, 0x7e, 0xe0, 0x7d, - 0x67, 0xf9, 0xa1, 0x71, 0xd2, 0x08, 0x74, 0x33, 0x5f, 0x69, 0xa4, 0x59, 0x03, 0xfd, 0xf8, 0xf0, 0x70, 0x1a, 0x50, - 0x43, 0xfb, 0x61, 0xf0, 0x38, 0x18, 0x88, 0x85, 0x36, 0x23, 0x06, 0x4f, 0x02, 0xbb, 0x78, 0x1a, 0xaa, 0xd2, 0x02, - 0x5e, 0xa0, 0x74, 0x30, 0xc8, 0x7a, 0x66, 0xab, 0xd9, 0x43, 0x99, 0x45, 0xb7, 0x0c, 0x5c, 0xec, 0xc8, 0x03, 0x0e, - 0x0b, 0xca, 0x4a, 0x22, 0x48, 0xfb, 0xb7, 0x3d, 0x82, 0x07, 0x8d, 0x1b, 0x21, 0x87, 0x4d, 0x57, 0xa4, 0x5b, 0xd4, - 0xe3, 0x88, 0x02, 0xc4, 0x81, 0xf9, 0x47, 0xe4, 0xbf, 0x3e, 0x39, 0xbb, 0x4f, 0x7e, 0x91, 0x63, 0x98, 0x97, 0xe4, - 0x52, 0x01, 0x58, 0xba, 0x32, 0xbf, 0xae, 0xff, 0x45, 0xa1, 0xbc, 0x9b, 0xa4, 0x09, 0x0e, 0x79, 0xc0, 0x41, 0x86, - 0x52, 0x88, 0x55, 0x39, 0x9d, 0xb6, 0xed, 0x35, 0x68, 0x29, 0xfa, 0xe6, 0x6c, 0x3d, 0x0a, 0xcd, 0x6a, 0x28, 0xfd, - 0x65, 0x24, 0xce, 0x38, 0x98, 0x01, 0xd9, 0x3f, 0x1b, 0x4c, 0xc4, 0x5c, 0x1d, 0xaa, 0x21, 0x78, 0x67, 0xaf, 0x55, - 0x72, 0x34, 0xf8, 0x1b, 0x03, 0x21, 0x27, 0x08, 0xbd, 0x59, 0x60, 0x48, 0x0d, 0xe2, 0x56, 0x9b, 0x30, 0x92, 0x8f, - 0x67, 0x8a, 0x7f, 0x20, 0xbd, 0x2d, 0xfd, 0xc5, 0xb0, 0xa6, 0xaa, 0x77, 0x75, 0x26, 0x33, 0x2f, 0x20, 0x2a, 0xab, - 0x5c, 0xd1, 0x3b, 0xda, 0xb2, 0x4c, 0xa4, 0x86, 0x25, 0x8d, 0x49, 0x05, 0xaf, 0x7a, 0xa8, 0xd4, 0x9c, 0x0d, 0xd3, - 0x38, 0xa6, 0x5c, 0x29, 0x6b, 0x16, 0x27, 0x07, 0xf1, 0xbe, 0xe2, 0x24, 0xc1, 0x8d, 0x25, 0x76, 0xbc, 0xf6, 0x0d, - 0xc2, 0x94, 0x25, 0xb8, 0xf3, 0x07, 0x9a, 0x49, 0xf4, 0x89, 0x82, 0x4d, 0x51, 0xb1, 0x96, 0x61, 0x62, 0x8d, 0xc8, - 0x61, 0x65, 0x0d, 0x14, 0x34, 0x02, 0x65, 0x94, 0xcc, 0x1d, 0x85, 0x00, 0x0f, 0x1a, 0x57, 0x68, 0x15, 0xcf, 0xa4, - 0xa2, 0x7d, 0x6d, 0x53, 0x60, 0xce, 0x5c, 0x61, 0x82, 0x17, 0x32, 0xc1, 0x87, 0x02, 0x0c, 0x91, 0x85, 0x57, 0x51, - 0xbe, 0xb2, 0x38, 0x9f, 0x3d, 0x2a, 0x52, 0x5a, 0xad, 0xba, 0x46, 0x9e, 0x3c, 0x8a, 0xa0, 0x46, 0x15, 0xf4, 0x59, - 0x74, 0x5f, 0x2a, 0xae, 0x96, 0x56, 0xf0, 0x54, 0x39, 0xaf, 0xac, 0x2a, 0xb9, 0xad, 0x32, 0x50, 0xc9, 0xc1, 0xee, - 0xd2, 0x0d, 0x34, 0xaa, 0x98, 0x4d, 0x6d, 0x3d, 0xc6, 0xb9, 0x5b, 0x00, 0x5f, 0xea, 0xda, 0x16, 0xa6, 0x08, 0x43, - 0x58, 0x4d, 0x8d, 0x07, 0x55, 0x62, 0x81, 0x44, 0xcc, 0x31, 0x04, 0x4b, 0x4c, 0x8b, 0x3e, 0xff, 0xd8, 0xf6, 0x65, - 0x19, 0xa1, 0x94, 0x62, 0x65, 0x0a, 0xdd, 0x60, 0x38, 0xd3, 0xbe, 0x0d, 0xa3, 0x99, 0xd5, 0x37, 0x68, 0xa1, 0x71, - 0xa3, 0x41, 0xe7, 0xbe, 0x9d, 0x72, 0x84, 0x75, 0xb6, 0x8d, 0x98, 0xd6, 0xb8, 0x2d, 0x43, 0x85, 0x5d, 0xf9, 0xca, - 0xc3, 0x96, 0xa5, 0xa6, 0xe7, 0x50, 0x88, 0x6b, 0x84, 0x58, 0x44, 0x45, 0x20, 0xdf, 0x1e, 0x5a, 0xc9, 0xce, 0x42, - 0x2a, 0x1f, 0x3e, 0x3c, 0x7b, 0x68, 0x3c, 0x34, 0x8b, 0x36, 0xba, 0x1f, 0xce, 0x0f, 0xa0, 0x60, 0x37, 0x5f, 0x1a, - 0x03, 0x2b, 0x86, 0x29, 0x45, 0x7b, 0xb4, 0xb7, 0x06, 0x68, 0x17, 0x7e, 0x13, 0x76, 0x91, 0x4d, 0x27, 0xee, 0xbc, - 0x7e, 0x80, 0xc2, 0x66, 0xac, 0xc6, 0xbf, 0xeb, 0x7f, 0xd7, 0x84, 0x79, 0xf3, 0xf1, 0xde, 0xec, 0xa6, 0x93, 0xa8, - 0x13, 0x3b, 0x4a, 0x81, 0xfa, 0x11, 0x1e, 0x4a, 0xd2, 0x50, 0x2a, 0xea, 0x9a, 0xc2, 0x37, 0x08, 0xed, 0x01, 0xf5, - 0xa2, 0xd5, 0x32, 0x29, 0x49, 0xc4, 0x1a, 0x11, 0xc0, 0xda, 0x24, 0x28, 0x84, 0x38, 0x60, 0x80, 0xcf, 0xd0, 0x45, - 0x83, 0xa7, 0xca, 0x52, 0x5c, 0xac, 0x23, 0x01}; + 0x1b, 0x08, 0x0b, 0x00, 0xe4, 0x7f, 0x9b, 0xad, 0xbb, 0x97, 0x53, 0xde, 0xb7, 0x25, 0x0e, 0x69, 0xd4, 0x69, 0x89, + 0xba, 0xa5, 0x55, 0x22, 0x04, 0x27, 0xeb, 0x00, 0x52, 0xac, 0xbc, 0xec, 0x5f, 0xfb, 0xb5, 0x7a, 0x12, 0x0a, 0xd6, + 0x48, 0x84, 0x4a, 0x48, 0x5a, 0xcb, 0xbd, 0xb7, 0x72, 0x66, 0x4e, 0x62, 0xd8, 0xbd, 0x7f, 0x88, 0x68, 0x86, 0x28, + 0xd6, 0xf1, 0xda, 0x2c, 0xa2, 0x32, 0x5c, 0xdd, 0xab, 0xb2, 0x15, 0xbc, 0x24, 0x13, 0xe6, 0xbd, 0x0c, 0x52, 0x63, + 0x82, 0x88, 0x6d, 0x74, 0x71, 0x51, 0x9c, 0xe2, 0x40, 0x56, 0xea, 0xb0, 0x5c, 0xb7, 0x1d, 0x81, 0x3a, 0x0c, 0xf2, + 0xd7, 0xa8, 0x5c, 0x35, 0x2d, 0x43, 0xb3, 0x42, 0x37, 0x7c, 0x60, 0xd8, 0xc1, 0x95, 0x91, 0x94, 0x3c, 0x29, 0x20, + 0x96, 0x20, 0xf6, 0x24, 0xb7, 0x83, 0x4a, 0x06, 0xf1, 0xc3, 0x2f, 0x88, 0x50, 0x55, 0x35, 0x2d, 0xe8, 0xb6, 0x21, + 0x38, 0x61, 0x8e, 0x44, 0x2a, 0xac, 0x39, 0xcf, 0x74, 0xaa, 0x31, 0x7f, 0x62, 0x32, 0x9b, 0x99, 0x42, 0x0a, 0xf6, + 0x6f, 0xd8, 0x89, 0x3f, 0x49, 0xb1, 0xa2, 0x52, 0x0a, 0x8e, 0xb3, 0x27, 0x0b, 0x07, 0x07, 0x58, 0xb0, 0x8f, 0xaa, + 0xdc, 0x68, 0x12, 0x0f, 0x95, 0xbf, 0xc7, 0x36, 0xd5, 0x60, 0x5a, 0xc7, 0x80, 0xac, 0x52, 0xf7, 0xa7, 0x2d, 0xb6, + 0x64, 0xda, 0xda, 0x11, 0x8d, 0x6a, 0xe5, 0xba, 0xe9, 0xda, 0xdc, 0x62, 0xc3, 0xcb, 0xae, 0xc1, 0xe1, 0x11, 0xb6, + 0x33, 0x29, 0x04, 0x09, 0xfe, 0x09, 0x08, 0xc2, 0x1f, 0x98, 0x16, 0x26, 0x0d, 0xce, 0xd7, 0x75, 0xfb, 0xd7, 0xa5, + 0x82, 0xd7, 0x32, 0x44, 0x72, 0xc1, 0xc2, 0xe4, 0x15, 0xcb, 0x50, 0xfc, 0xd3, 0x58, 0x64, 0x34, 0x41, 0x32, 0xbe, + 0x1f, 0x08, 0x43, 0x16, 0x14, 0xf7, 0x70, 0x2c, 0x1c, 0x61, 0x09, 0x78, 0x73, 0xd1, 0x30, 0xf6, 0xed, 0x85, 0x55, + 0x50, 0x02, 0xf0, 0x68, 0x50, 0x5c, 0x1a, 0xd7, 0x3b, 0x8e, 0xf2, 0x23, 0xc8, 0x88, 0x39, 0xd0, 0x84, 0xf7, 0xa6, + 0xd1, 0xa3, 0xfb, 0xe5, 0x44, 0xc0, 0x4c, 0x2f, 0x6f, 0x19, 0x70, 0x75, 0xf6, 0x1c, 0xb8, 0xce, 0x89, 0x4f, 0x01, + 0x8d, 0xa1, 0x71, 0xf2, 0x97, 0xf8, 0xe7, 0xd3, 0xf1, 0xe1, 0xfa, 0xfc, 0xc8, 0x03, 0x78, 0x14, 0xa0, 0x3d, 0x28, + 0xb7, 0xeb, 0x26, 0x52, 0x59, 0xa8, 0xb5, 0x0d, 0x5c, 0x8a, 0x6b, 0xe5, 0xf6, 0x30, 0xd6, 0xf7, 0x71, 0x31, 0xe8, + 0xbd, 0xc9, 0xfa, 0xf5, 0x71, 0x9d, 0xff, 0xf6, 0x69, 0x62, 0x5f, 0xb5, 0xc7, 0x06, 0x43, 0x54, 0xb5, 0x87, 0xf1, + 0xcc, 0x84, 0xe8, 0xb7, 0x56, 0x38, 0x43, 0x6a, 0xa6, 0x2f, 0x53, 0xd1, 0x89, 0x48, 0x8d, 0x72, 0x75, 0x4a, 0x17, + 0xf6, 0x8d, 0xb2, 0xeb, 0xb1, 0x6b, 0x29, 0x1e, 0xd5, 0x74, 0xc7, 0xb3, 0xf4, 0xf1, 0x04, 0x0d, 0xbf, 0x08, 0x81, + 0x8f, 0xfe, 0x8d, 0xfc, 0xf2, 0x35, 0x92, 0xa0, 0x24, 0x88, 0x12, 0x6a, 0xe6, 0x2f, 0x01, 0x94, 0x5c, 0x4b, 0xf9, + 0x6b, 0x9a, 0xf6, 0x1a, 0x35, 0x11, 0x8a, 0xc6, 0x04, 0x8d, 0x50, 0x64, 0x48, 0x2d, 0x8b, 0xdf, 0xdf, 0xa1, 0xd1, + 0xfd, 0x21, 0xd7, 0x40, 0x96, 0x00, 0xb1, 0x9f, 0x54, 0xc2, 0xe1, 0x00, 0x79, 0x15, 0x80, 0xf8, 0xca, 0x8e, 0xc5, + 0x06, 0x03, 0xdf, 0x72, 0x49, 0xc0, 0x08, 0x27, 0x90, 0xe3, 0x14, 0xc2, 0xac, 0xc3, 0x83, 0xc9, 0xf6, 0x9d, 0x12, + 0xab, 0x46, 0xae, 0x03, 0x74, 0x1d, 0x27, 0xce, 0x91, 0x1d, 0x4e, 0xb4, 0xbe, 0x80, 0xe7, 0x6a, 0x6d, 0x0a, 0x20, + 0x47, 0x6b, 0x00, 0x4e, 0x25, 0x52, 0xe6, 0xf5, 0xe9, 0x43, 0x84, 0xc1, 0x0d, 0x4b, 0x04, 0xb3, 0x91, 0x49, 0xf5, + 0xe9, 0xc4, 0x2e, 0x1b, 0xf9, 0x98, 0xf9, 0xea, 0x9e, 0xb8, 0xf6, 0x53, 0xf8, 0x42, 0xc2, 0x60, 0x5c, 0xa9, 0xd2, + 0xfe, 0x0a, 0xe5, 0xd4, 0x7d, 0x36, 0x76, 0x04, 0x12, 0x38, 0xa1, 0xc4, 0x30, 0xb8, 0xf2, 0x69, 0x86, 0x5b, 0xe7, + 0xe5, 0xa0, 0xc0, 0xfe, 0x91, 0x19, 0x03, 0x1e, 0xa9, 0x93, 0x26, 0x49, 0x58, 0xd5, 0xf6, 0x33, 0x4e, 0x0a, 0x89, + 0x64, 0x1e, 0xb4, 0x7a, 0x5d, 0x0d, 0x12, 0xcf, 0x2b, 0xdd, 0x35, 0x90, 0x55, 0xc3, 0x0e, 0xcd, 0x0e, 0xad, 0x6b, + 0x8f, 0x43, 0xd0, 0xb4, 0x6a, 0xdb, 0x4b, 0xe5, 0xa4, 0x9b, 0x09, 0x23, 0x1a, 0x43, 0xa9, 0xff, 0x61, 0x8b, 0x07, + 0xd6, 0x0f, 0x83, 0x23, 0xbe, 0x8a, 0x66, 0xa2, 0x10, 0x2f, 0x11, 0x01, 0x0d, 0xc6, 0x96, 0xba, 0x87, 0xaa, 0xa8, + 0x44, 0x7c, 0x1e, 0x34, 0xdb, 0xba, 0x41, 0x90, 0xf0, 0x7a, 0xdb, 0x63, 0x60, 0x96, 0x8d, 0x64, 0xcb, 0x2f, 0x28, + 0x96, 0x04, 0xd5, 0xc0, 0x7a, 0xe8, 0xec, 0xd1, 0x61, 0x1b, 0x09, 0x4b, 0x4c, 0x6e, 0x1b, 0x31, 0x98, 0x9c, 0x6d, + 0xdb, 0xd0, 0xec, 0xa4, 0x0f, 0x0a, 0xe0, 0xc8, 0x35, 0xc4, 0x93, 0xdc, 0xee, 0x19, 0x00, 0x80, 0x07, 0xf5, 0xcf, + 0xe0, 0x7f, 0x75, 0xf8, 0x52, 0x3a, 0xfc, 0x0d, 0x84, 0xa5, 0xc1, 0xb2, 0x1c, 0x25, 0x40, 0xc5, 0x8b, 0xb3, 0xdb, + 0x7a, 0x1b, 0x44, 0xff, 0x79, 0x9a, 0x26, 0xc4, 0xe7, 0x9e, 0x78, 0x4c, 0xa5, 0x94, 0xab, 0x78, 0x34, 0x9d, 0xb5, + 0xb7, 0x24, 0x26, 0xe7, 0x9a, 0xf3, 0xe5, 0x2a, 0x35, 0x4b, 0xbe, 0x74, 0xd7, 0x01, 0xbc, 0x71, 0x72, 0x03, 0x5c, + 0x40, 0x24, 0x17, 0x61, 0x5b, 0x7b, 0x19, 0xc2, 0x7f, 0x4e, 0x5b, 0x24, 0xfb, 0x8b, 0xc3, 0x51, 0x00, 0x3d, 0x09, + 0x04, 0x05, 0x72, 0x64, 0xf6, 0xf0, 0x6b, 0x99, 0x38, 0x93, 0x5b, 0xac, 0x0c, 0xff, 0x40, 0x7b, 0x53, 0xba, 0xab, + 0x61, 0xc9, 0xa2, 0xde, 0xd6, 0x2b, 0x0c, 0xbd, 0x85, 0xac, 0x4c, 0x64, 0x8b, 0xe6, 0x69, 0x2b, 0x56, 0x10, 0x1b, + 0x08, 0x59, 0x6c, 0x55, 0x0a, 0xaa, 0x93, 0x85, 0x1d, 0x45, 0xda, 0xc6, 0x39, 0xe6, 0x6a, 0xab, 0x71, 0x73, 0x46, + 0x0f, 0xf7, 0xab, 0x4e, 0x88, 0xae, 0x8c, 0xe0, 0x91, 0xda, 0x35, 0x8c, 0xd3, 0x18, 0x92, 0x3d, 0xab, 0x2f, 0x0d, + 0xd6, 0xc9, 0x06, 0xdb, 0xc2, 0x62, 0xcb, 0x8a, 0x23, 0x5b, 0x28, 0x2e, 0x9b, 0x96, 0xc4, 0xc1, 0x42, 0xa9, 0x8d, + 0x69, 0xe5, 0x8f, 0x89, 0x12, 0x11, 0x9a, 0x56, 0x3b, 0x3c, 0x2b, 0xb4, 0xb2, 0x7b, 0xfd, 0xdb, 0x80, 0x92, 0xb4, + 0xca, 0x44, 0x37, 0x8c, 0x94, 0x2f, 0x4a, 0xb4, 0xc4, 0x28, 0x83, 0x8a, 0x78, 0xcb, 0xd2, 0x5c, 0x5c, 0x35, 0x29, + 0x95, 0x35, 0x2f, 0x99, 0x28, 0x4f, 0x22, 0x18, 0x70, 0x85, 0xee, 0x2c, 0xb9, 0xef, 0x14, 0x57, 0x73, 0x23, 0x45, + 0xae, 0xdc, 0x54, 0x56, 0x55, 0x78, 0x56, 0xad, 0x48, 0x26, 0x27, 0xbf, 0xcb, 0xd7, 0x54, 0xa9, 0xa8, 0xd7, 0xb5, + 0x71, 0x9c, 0xe7, 0x79, 0x89, 0x5c, 0xa9, 0x6a, 0x53, 0xe8, 0xfa, 0x0d, 0x69, 0x36, 0xed, 0xad, 0x33, 0xc9, 0x75, + 0x17, 0xe9, 0x8f, 0x31, 0x58, 0xa6, 0x27, 0xfc, 0x79, 0xc6, 0xb6, 0xd5, 0x65, 0x90, 0x31, 0xc6, 0x9d, 0x29, 0x95, + 0x83, 0xe5, 0x4e, 0xbb, 0xd7, 0xdc, 0x8e, 0xd0, 0x3a, 0x54, 0x27, 0xce, 0xf5, 0xdb, 0xbd, 0x89, 0x3c, 0x61, 0xb3, + 0x71, 0x23, 0xc7, 0x55, 0x9e, 0xea, 0x50, 0xe4, 0x37, 0xae, 0x72, 0x34, 0x66, 0xb9, 0x6e, 0x2c, 0x0a, 0x69, 0x8d, + 0x94, 0x8b, 0x98, 0x08, 0x05, 0x3c, 0x45, 0x45, 0xe1, 0x6c, 0x22, 0xc5, 0x8f, 0x1f, 0x9f, 0x3f, 0xd2, 0x01, 0x12, + 0xec, 0x86, 0x2f, 0x87, 0x8b, 0x47, 0x30, 0xd0, 0x77, 0x77, 0x1a, 0x13, 0x2d, 0xc6, 0x31, 0x65, 0x77, 0xd8, 0x8c, + 0xe7, 0xe0, 0x16, 0xf9, 0x4b, 0xd4, 0xc5, 0xa8, 0x67, 0x79, 0xe7, 0xc3, 0x07, 0x94, 0x46, 0xa3, 0x8c, 0x67, 0xd3, + 0xff, 0x5f, 0x96, 0xfa, 0xed, 0xa7, 0xd3, 0xdd, 0x4f, 0x27, 0x49, 0x27, 0xcf, 0xa4, 0x02, 0xc3, 0x27, 0x3c, 0x96, + 0x64, 0x24, 0x55, 0xc8, 0x36, 0x45, 0x68, 0x90, 0xea, 0x03, 0x0b, 0x46, 0xa7, 0x8d, 0xb4, 0x26, 0x91, 0x07, 0x4c, + 0x80, 0x7b, 0x93, 0xa8, 0x10, 0xcb, 0x5e, 0x8d, 0x42, 0x86, 0x3e, 0x2a, 0x7c, 0x99, 0x79, 0x8e, 0xcb, 0x43, 0x28}; // Backwards compatibility alias #define INDEX_GZ INDEX_BR diff --git a/esphome/components/web_server/server_index_v2.h b/esphome/components/web_server/server_index_v2.h index ac2195f387..0c1a6c7f79 100644 --- a/esphome/components/web_server/server_index_v2.h +++ b/esphome/components/web_server/server_index_v2.h @@ -10,1308 +10,1310 @@ namespace esphome::web_server { #ifdef USE_WEBSERVER_GZIP constexpr uint8_t INDEX_GZ[] PROGMEM = { - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xed, 0x7d, 0xd9, 0x72, 0xdb, 0x48, 0xb6, 0xe0, 0xf3, - 0xd4, 0x57, 0x40, 0x28, 0xb5, 0x8c, 0x2c, 0x26, 0xc1, 0x45, 0x92, 0x2d, 0x83, 0x4a, 0xb2, 0x65, 0xd9, 0xd5, 0x76, - 0x97, 0xb7, 0xb6, 0xec, 0xda, 0x58, 0x6c, 0x09, 0x02, 0x92, 0x44, 0x96, 0x41, 0x80, 0x05, 0x24, 0xb5, 0x14, 0x89, - 0x1b, 0xf3, 0x01, 0x13, 0x31, 0x11, 0xf3, 0x34, 0x2f, 0x13, 0x73, 0x1f, 0xe6, 0x23, 0xe6, 0xf9, 0x7e, 0xca, 0xfd, - 0x81, 0x99, 0x4f, 0x98, 0x38, 0xb9, 0x00, 0x09, 0x2e, 0xb2, 0x5c, 0x55, 0x7d, 0xef, 0x7d, 0x98, 0xa8, 0x28, 0x99, - 0x48, 0xe4, 0x72, 0xf2, 0xe4, 0xc9, 0xb3, 0x67, 0xe2, 0x78, 0x27, 0x4c, 0x03, 0x7e, 0x3b, 0xa3, 0x56, 0xc4, 0xa7, - 0x71, 0xff, 0x58, 0xfd, 0xa5, 0x7e, 0xd8, 0x3f, 0x8e, 0x59, 0xf2, 0xd1, 0xca, 0x68, 0x4c, 0x58, 0x90, 0x26, 0x56, - 0x94, 0xd1, 0x31, 0x09, 0x7d, 0xee, 0x7b, 0x6c, 0xea, 0x4f, 0xa8, 0xd5, 0xea, 0x1f, 0x4f, 0x29, 0xf7, 0xad, 0x20, - 0xf2, 0xb3, 0x9c, 0x72, 0xf2, 0xe1, 0xfd, 0xd7, 0xcd, 0xa3, 0xfe, 0x71, 0x1e, 0x64, 0x6c, 0xc6, 0x2d, 0xe8, 0x92, - 0x4c, 0xd3, 0x70, 0x1e, 0xd3, 0x7e, 0xab, 0x75, 0x7d, 0x7d, 0xed, 0xfe, 0x9c, 0x7f, 0x11, 0xa4, 0x49, 0xce, 0xad, - 0xa7, 0xe4, 0x9a, 0x25, 0x61, 0x7a, 0x8d, 0x73, 0x4e, 0x9e, 0xba, 0x67, 0x91, 0x1f, 0xa6, 0xd7, 0xef, 0xd2, 0x94, - 0xef, 0xed, 0x39, 0xf2, 0xf1, 0xf6, 0xf4, 0xec, 0x8c, 0x10, 0x72, 0x95, 0xb2, 0xd0, 0x6a, 0x2f, 0x97, 0x55, 0xa1, - 0x9b, 0xf8, 0x9c, 0x5d, 0x51, 0xd9, 0x04, 0xed, 0xed, 0xd9, 0x7e, 0x98, 0xce, 0x38, 0x0d, 0xcf, 0xf8, 0x6d, 0x4c, - 0xcf, 0x22, 0x4a, 0x79, 0x6e, 0xb3, 0xc4, 0x7a, 0x9a, 0x06, 0xf3, 0x29, 0x4d, 0xb8, 0x3b, 0xcb, 0x52, 0x9e, 0x02, - 0x24, 0x7b, 0x7b, 0x76, 0x46, 0x67, 0xb1, 0x1f, 0x50, 0x78, 0x7f, 0x7a, 0x76, 0x56, 0xb5, 0xa8, 0x2a, 0xe1, 0x84, - 0x93, 0xb3, 0xdb, 0xe9, 0x65, 0x1a, 0x3b, 0x08, 0x47, 0x9c, 0x24, 0xf4, 0xda, 0xfa, 0x8e, 0xfa, 0x1f, 0x5f, 0xf9, - 0xb3, 0x5e, 0x10, 0xfb, 0x79, 0x6e, 0x9d, 0xf0, 0x85, 0x98, 0x42, 0x36, 0x0f, 0x78, 0x9a, 0x39, 0x1c, 0x53, 0xcc, - 0xd0, 0x82, 0x8d, 0x1d, 0x1e, 0xb1, 0xdc, 0x3d, 0xdf, 0x0d, 0xf2, 0xfc, 0x1d, 0xcd, 0xe7, 0x31, 0xdf, 0x25, 0x3b, - 0x6d, 0xcc, 0x76, 0x08, 0x49, 0x38, 0xe2, 0x51, 0x96, 0x5e, 0x5b, 0xcf, 0xb2, 0x2c, 0xcd, 0x1c, 0xfb, 0xf4, 0xec, - 0x4c, 0xd6, 0xb0, 0x58, 0x6e, 0x25, 0x29, 0xb7, 0xca, 0xfe, 0xfc, 0xcb, 0x98, 0xba, 0xd6, 0x87, 0x9c, 0x5a, 0x17, - 0xf3, 0x24, 0xf7, 0xc7, 0xf4, 0xf4, 0xec, 0xec, 0xc2, 0x4a, 0x33, 0xeb, 0x22, 0xc8, 0xf3, 0x0b, 0x8b, 0x25, 0x39, - 0xa7, 0x7e, 0xe8, 0xda, 0xa8, 0x27, 0x06, 0x0b, 0xf2, 0xfc, 0x3d, 0xbd, 0xe1, 0x84, 0x63, 0xf1, 0xc8, 0x09, 0x2d, - 0x26, 0x94, 0x5b, 0x79, 0x39, 0x2f, 0x07, 0x2d, 0x62, 0xca, 0x2d, 0x4e, 0xc4, 0xfb, 0xb4, 0x27, 0x71, 0x4f, 0xe5, - 0x23, 0xef, 0xb1, 0xb1, 0x93, 0xf3, 0xbd, 0x3d, 0x5e, 0xe2, 0x19, 0xc9, 0xa9, 0x59, 0x8c, 0xd0, 0x1d, 0x5d, 0xb6, - 0xb7, 0x47, 0xdd, 0x98, 0x26, 0x13, 0x1e, 0x11, 0x42, 0x3a, 0x3d, 0xb6, 0xb7, 0xe7, 0x70, 0x12, 0x71, 0x77, 0x42, - 0xb9, 0x43, 0x11, 0xc2, 0x55, 0xeb, 0xbd, 0x3d, 0x47, 0x22, 0x21, 0x25, 0x12, 0x71, 0x35, 0x1c, 0x23, 0x57, 0x61, - 0xff, 0xec, 0x36, 0x09, 0x1c, 0x13, 0x7e, 0x84, 0xd9, 0xde, 0x5e, 0xc4, 0xdd, 0x1c, 0x7a, 0xc4, 0x1c, 0xa1, 0x22, - 0xa3, 0x7c, 0x9e, 0x25, 0x16, 0x2f, 0x78, 0x7a, 0xc6, 0x33, 0x96, 0x4c, 0x1c, 0xb4, 0xd0, 0x65, 0x46, 0xc3, 0xa2, - 0x90, 0xe0, 0xbe, 0xe3, 0x24, 0x21, 0x7d, 0x18, 0xf1, 0x84, 0x3b, 0xb0, 0x8a, 0xe9, 0xd8, 0x4a, 0x08, 0xb1, 0x73, - 0xd1, 0xd6, 0x1e, 0x24, 0x5e, 0xd2, 0xb0, 0x6d, 0x2c, 0xa1, 0xc4, 0x09, 0x47, 0xf8, 0x23, 0x71, 0x12, 0xec, 0xba, - 0x2e, 0x47, 0xa4, 0xbf, 0xd0, 0x58, 0x49, 0x8c, 0x79, 0x0e, 0x92, 0x61, 0x7b, 0xe4, 0x71, 0x37, 0xa3, 0xe1, 0x3c, - 0xa0, 0x8e, 0xc3, 0x70, 0x8e, 0x33, 0x44, 0xfa, 0xac, 0xe1, 0xa4, 0xa4, 0x0f, 0xcb, 0x9d, 0xd6, 0xd7, 0x9a, 0x90, - 0x9d, 0x36, 0x52, 0x30, 0xa6, 0x1a, 0x40, 0xc0, 0xb0, 0x82, 0x27, 0x25, 0xc4, 0x4e, 0xe6, 0xd3, 0x4b, 0x9a, 0xd9, - 0x65, 0xb5, 0x5e, 0x8d, 0x2c, 0xe6, 0x39, 0xb5, 0x82, 0x3c, 0xb7, 0xc6, 0xf3, 0x24, 0xe0, 0x2c, 0x4d, 0x2c, 0xbb, - 0x91, 0x36, 0x6c, 0x49, 0x0e, 0x25, 0x35, 0xd8, 0xa8, 0x40, 0x4e, 0x8e, 0x1a, 0xc9, 0x30, 0x6b, 0x74, 0x46, 0x18, - 0xa0, 0x44, 0x3d, 0xd5, 0x9f, 0x42, 0x00, 0xc5, 0x09, 0xcc, 0xb1, 0xc0, 0x4f, 0x38, 0xcc, 0x52, 0x4c, 0x31, 0xe7, - 0x83, 0xc4, 0x5d, 0xdf, 0x28, 0x84, 0xbb, 0x53, 0x7f, 0xe6, 0x50, 0xd2, 0xa7, 0x82, 0xb8, 0xfc, 0x24, 0x00, 0x58, - 0x6b, 0xeb, 0x36, 0xa0, 0x1e, 0x75, 0x2b, 0x92, 0x42, 0x1e, 0x77, 0xc7, 0x69, 0xf6, 0xcc, 0x0f, 0x22, 0x68, 0x57, - 0x12, 0x4c, 0xa8, 0xf7, 0x5b, 0x90, 0x51, 0x9f, 0xd3, 0x67, 0x31, 0x85, 0x27, 0xc7, 0x16, 0x2d, 0x6d, 0x84, 0x73, - 0xf2, 0xd4, 0x8d, 0x19, 0x7f, 0x9d, 0x26, 0x01, 0xed, 0xe5, 0x06, 0x75, 0x31, 0x58, 0xf7, 0x13, 0xce, 0x33, 0x76, - 0x39, 0xe7, 0xd4, 0xb1, 0x13, 0xa8, 0x61, 0xe3, 0x1c, 0x61, 0xe6, 0x72, 0x7a, 0xc3, 0x4f, 0xd3, 0x84, 0xd3, 0x84, - 0x13, 0xaa, 0x91, 0x8a, 0x13, 0xd7, 0x9f, 0xcd, 0x68, 0x12, 0x9e, 0x46, 0x2c, 0x0e, 0x1d, 0x86, 0x0a, 0x54, 0xe0, - 0x80, 0x13, 0x98, 0x23, 0xe9, 0x27, 0x1e, 0xfc, 0xd9, 0x3e, 0x1b, 0x87, 0x93, 0xbe, 0xd8, 0x14, 0x94, 0xd8, 0x76, - 0x6f, 0x9c, 0x66, 0x8e, 0x9a, 0x81, 0x95, 0x8e, 0x2d, 0x0e, 0x63, 0xbc, 0x9b, 0xc7, 0x34, 0x47, 0xb4, 0x41, 0x58, - 0xb9, 0x8c, 0x0a, 0xc1, 0xef, 0x80, 0xe2, 0x0b, 0xe4, 0x24, 0xc8, 0x4b, 0x7a, 0x57, 0x7e, 0x66, 0xfd, 0xa8, 0x76, - 0xd4, 0xcf, 0x9a, 0x9b, 0x85, 0x9c, 0xfc, 0xec, 0xf2, 0x6c, 0x9e, 0x73, 0x1a, 0xbe, 0xbf, 0x9d, 0xd1, 0x1c, 0x3f, - 0xe7, 0x24, 0xe4, 0x83, 0x90, 0xbb, 0x74, 0x3a, 0xe3, 0xb7, 0x67, 0x82, 0x31, 0x7a, 0xb6, 0x8d, 0xe7, 0x50, 0x33, - 0xa3, 0x7e, 0x00, 0xcc, 0x4c, 0x61, 0xeb, 0x6d, 0x1a, 0xdf, 0x8e, 0x59, 0x1c, 0x9f, 0xcd, 0x67, 0xb3, 0x34, 0xe3, - 0x98, 0x73, 0xb2, 0xe0, 0x69, 0x85, 0x1b, 0x58, 0xcc, 0x45, 0x7e, 0xcd, 0x78, 0x10, 0x39, 0x1c, 0x2d, 0x02, 0x3f, - 0xa7, 0xd6, 0x93, 0x34, 0x8d, 0xa9, 0x0f, 0xb3, 0x4e, 0x06, 0xcf, 0xb9, 0x97, 0xcc, 0xe3, 0xb8, 0x77, 0x99, 0x51, - 0xff, 0x63, 0x4f, 0xbc, 0x7e, 0x73, 0xf9, 0x33, 0x0d, 0xb8, 0x27, 0x7e, 0x9f, 0x64, 0x99, 0x7f, 0x0b, 0x15, 0x09, - 0x81, 0x6a, 0x83, 0xc4, 0xfb, 0xeb, 0xd9, 0x9b, 0xd7, 0xae, 0xdc, 0x25, 0x6c, 0x7c, 0xeb, 0x24, 0xe5, 0xce, 0x4b, - 0x0a, 0x3c, 0xce, 0xd2, 0xe9, 0xca, 0xd0, 0x12, 0x6d, 0x49, 0x6f, 0x0b, 0x08, 0x94, 0x24, 0x3b, 0xb2, 0x6b, 0x13, - 0x82, 0xd7, 0x82, 0xe8, 0xe1, 0x25, 0xd1, 0xe3, 0xce, 0xe3, 0xd8, 0x93, 0xc5, 0x4e, 0x82, 0xee, 0x86, 0x96, 0x67, - 0xb7, 0x0b, 0x4a, 0x04, 0x9c, 0x33, 0x10, 0x31, 0x00, 0x63, 0xe0, 0xf3, 0x20, 0x5a, 0x50, 0xd1, 0x59, 0xa1, 0x21, - 0xa6, 0x45, 0x81, 0x9f, 0x95, 0x04, 0xcf, 0x01, 0x10, 0xc1, 0xa9, 0x08, 0x5f, 0x2e, 0x61, 0xc2, 0x08, 0xff, 0x95, - 0x2c, 0x7c, 0x3d, 0x1f, 0x6f, 0xa7, 0x8d, 0x61, 0x63, 0x7a, 0x92, 0xbd, 0xe0, 0x20, 0x4d, 0xae, 0x68, 0xc6, 0x69, - 0xe6, 0x71, 0x8e, 0x33, 0x3a, 0x8e, 0x01, 0x8c, 0x9d, 0x0e, 0x8e, 0xfc, 0xfc, 0x34, 0xf2, 0x93, 0x09, 0x0d, 0xbd, - 0x67, 0xbc, 0xc0, 0x94, 0x13, 0x7b, 0xcc, 0x12, 0x3f, 0x66, 0xbf, 0xd2, 0xd0, 0x56, 0x02, 0xe1, 0x99, 0x45, 0x6f, - 0x38, 0x4d, 0xc2, 0xdc, 0x7a, 0xfe, 0xfe, 0xd5, 0x4b, 0xb5, 0x94, 0x35, 0x19, 0x81, 0x16, 0xf9, 0x7c, 0x46, 0x33, - 0x07, 0x61, 0x25, 0x23, 0x9e, 0x31, 0xc1, 0x1f, 0x5f, 0xf9, 0x33, 0x59, 0xc2, 0xf2, 0x0f, 0xb3, 0xd0, 0xe7, 0xf4, - 0x2d, 0x4d, 0x42, 0x96, 0x4c, 0xc8, 0x4e, 0x47, 0x96, 0x47, 0xbe, 0x7a, 0x11, 0x96, 0x45, 0xe7, 0xbb, 0xcf, 0x62, - 0x31, 0xf3, 0xf2, 0x71, 0xee, 0xa0, 0x22, 0xe7, 0x3e, 0x67, 0x81, 0xe5, 0x87, 0xe1, 0x8b, 0x84, 0x71, 0x26, 0x00, - 0xcc, 0x60, 0x81, 0x80, 0x4a, 0xa9, 0x94, 0x16, 0x1a, 0x70, 0x07, 0x61, 0xc7, 0x51, 0x32, 0x20, 0x42, 0x6a, 0xc5, - 0xf6, 0xf6, 0x2a, 0x8e, 0x3f, 0xa0, 0x9e, 0x7c, 0x49, 0x86, 0x23, 0xe4, 0xce, 0xe6, 0x39, 0x2c, 0xb5, 0x1e, 0x02, - 0x04, 0x4c, 0x7a, 0x99, 0xd3, 0xec, 0x8a, 0x86, 0x25, 0x79, 0xe4, 0x0e, 0x5a, 0xac, 0x8c, 0xa1, 0x76, 0x06, 0x27, - 0xc3, 0x51, 0xcf, 0x64, 0xdd, 0x54, 0x91, 0x7a, 0x96, 0xce, 0x68, 0xc6, 0x19, 0xcd, 0x4b, 0x6e, 0xe2, 0x80, 0x20, - 0x2d, 0x39, 0x4a, 0x4e, 0xf4, 0xfc, 0x66, 0x0e, 0xc3, 0x14, 0xd5, 0x78, 0x86, 0x96, 0xb5, 0xcf, 0xae, 0x84, 0xd0, - 0xc8, 0x31, 0x43, 0x98, 0x4b, 0x48, 0x73, 0x84, 0x0a, 0x84, 0xb9, 0x06, 0x57, 0x72, 0x23, 0x35, 0xda, 0x2d, 0x48, - 0x6b, 0xf2, 0x57, 0x21, 0xad, 0x81, 0xa7, 0xf9, 0x9c, 0xee, 0xed, 0x39, 0xd4, 0x2d, 0xc9, 0x82, 0xec, 0x74, 0xd4, - 0x1a, 0x19, 0xc8, 0xda, 0x02, 0x36, 0x0c, 0xcc, 0x31, 0x45, 0x78, 0x87, 0xba, 0x49, 0x7a, 0x12, 0x04, 0x34, 0xcf, - 0xd3, 0x6c, 0x6f, 0x6f, 0x47, 0xd4, 0x2f, 0x15, 0x0a, 0x58, 0xc3, 0x37, 0xd7, 0x49, 0x05, 0x01, 0xaa, 0x84, 0xac, - 0x12, 0x0d, 0x1c, 0x44, 0x95, 0xd0, 0x39, 0xec, 0x81, 0xd6, 0x3d, 0x3c, 0xfb, 0xfc, 0xdc, 0x6e, 0x70, 0xac, 0xd0, - 0x30, 0xa1, 0x7a, 0xe8, 0xdb, 0xa7, 0x54, 0x6a, 0x57, 0x42, 0xf7, 0x58, 0xc3, 0x8c, 0xdc, 0x41, 0x6e, 0x48, 0xc7, - 0x2c, 0x31, 0xa6, 0x5d, 0x03, 0x09, 0x73, 0x9c, 0xa3, 0xc2, 0x58, 0xd0, 0x8d, 0x5d, 0x0b, 0xb5, 0x46, 0xae, 0xdc, - 0x62, 0x22, 0x54, 0x09, 0x63, 0x19, 0x87, 0x74, 0x54, 0x60, 0x81, 0x7a, 0x3d, 0x9b, 0x4c, 0x00, 0x3a, 0xe4, 0xa3, - 0x9e, 0x7a, 0x4f, 0x72, 0x89, 0xb9, 0x8c, 0xfe, 0x32, 0xa7, 0x39, 0x97, 0x74, 0xec, 0x70, 0x9c, 0x61, 0x06, 0xfc, - 0x3a, 0x4d, 0xc6, 0x6c, 0x32, 0xcf, 0x40, 0xe3, 0x81, 0xcd, 0x48, 0x93, 0xf9, 0x94, 0xea, 0xa7, 0x4d, 0xb0, 0xbd, - 0x99, 0x81, 0x4c, 0xcc, 0x81, 0xa6, 0xef, 0x26, 0x27, 0x80, 0x95, 0xa3, 0xe5, 0xf2, 0xaf, 0xba, 0x93, 0x6a, 0x29, - 0x4b, 0x2d, 0x6d, 0x65, 0x4d, 0x28, 0x47, 0x4a, 0x26, 0xef, 0x74, 0x14, 0xf8, 0x7c, 0x44, 0x76, 0xda, 0x25, 0x0d, - 0x2b, 0xac, 0x4a, 0x70, 0x24, 0x12, 0xdf, 0xc8, 0xae, 0x90, 0x10, 0xf1, 0x35, 0x72, 0x71, 0xa3, 0x35, 0x4a, 0x8d, - 0xc8, 0x10, 0x94, 0x0d, 0x37, 0x1a, 0x6d, 0x23, 0x27, 0xcd, 0x0f, 0x1c, 0xbe, 0xfe, 0xae, 0x62, 0x1b, 0x57, 0x75, - 0xb6, 0xb1, 0x32, 0x0d, 0x7b, 0x56, 0x36, 0xb1, 0x4b, 0x2a, 0x53, 0x1b, 0xbd, 0x7a, 0x85, 0x99, 0x00, 0xa6, 0x9a, - 0x92, 0xd1, 0xc5, 0x6b, 0x7f, 0x4a, 0x73, 0x87, 0x22, 0xbc, 0xad, 0x82, 0x24, 0x4f, 0xa8, 0x32, 0x32, 0x64, 0x67, - 0x0e, 0xb2, 0x93, 0x21, 0xa9, 0x9a, 0xd5, 0x37, 0x5c, 0x8e, 0xe9, 0x30, 0x1f, 0x55, 0x1a, 0x9d, 0x31, 0x79, 0x21, - 0x94, 0x15, 0x7d, 0x6b, 0xfc, 0xc9, 0x32, 0x89, 0x34, 0xa1, 0x39, 0xe4, 0x08, 0xef, 0xb4, 0x57, 0x57, 0x52, 0xd7, - 0xaa, 0xe6, 0x38, 0x1c, 0xc1, 0x3a, 0x08, 0x91, 0xe1, 0xb2, 0x5c, 0xfc, 0x5b, 0xdb, 0x69, 0x80, 0xb6, 0x33, 0x20, - 0x0c, 0x77, 0x1c, 0xfb, 0xdc, 0xe9, 0xb4, 0xda, 0xa0, 0x8e, 0x5e, 0x51, 0x90, 0x28, 0x08, 0xad, 0x4f, 0x85, 0xba, - 0xf3, 0x24, 0x8f, 0xd8, 0x98, 0x3b, 0x01, 0x17, 0x2c, 0x85, 0xc6, 0x39, 0xb5, 0x78, 0x4d, 0x29, 0x16, 0xec, 0x26, - 0x00, 0x62, 0x2b, 0x35, 0x30, 0xaa, 0x21, 0x15, 0x6c, 0x0b, 0xb8, 0x43, 0xa5, 0x50, 0x57, 0x5c, 0x46, 0xd7, 0x66, - 0xa0, 0x34, 0x76, 0x06, 0xb2, 0x47, 0x4f, 0x31, 0x03, 0x66, 0xe8, 0xad, 0xcc, 0x33, 0x39, 0x84, 0x2a, 0xe4, 0x2e, - 0x4f, 0x5f, 0xa6, 0xd7, 0x34, 0x3b, 0xf5, 0x01, 0x78, 0x4f, 0x36, 0x2f, 0xa4, 0x20, 0x10, 0xfc, 0x9e, 0xf7, 0x34, - 0xbd, 0x9c, 0x8b, 0x89, 0xbf, 0xcd, 0xd2, 0x29, 0xcb, 0x29, 0xa8, 0x6b, 0x12, 0xff, 0x09, 0xec, 0x33, 0xb1, 0x21, - 0x41, 0xd8, 0xd0, 0x92, 0xbe, 0x4e, 0x5e, 0xd6, 0xe9, 0xeb, 0x7c, 0xf7, 0xd9, 0x44, 0x33, 0xc0, 0xfa, 0x36, 0x46, - 0xd8, 0x51, 0x46, 0x85, 0x21, 0xe7, 0xdc, 0x08, 0x29, 0x11, 0xbf, 0x5c, 0x72, 0xc3, 0x76, 0xab, 0x29, 0x8c, 0x54, - 0x6e, 0x1b, 0x54, 0xf8, 0x61, 0x08, 0xaa, 0x5d, 0x96, 0xc6, 0xb1, 0x21, 0xaa, 0x30, 0xeb, 0x95, 0xc2, 0xe9, 0x7c, - 0xf7, 0xd9, 0xd9, 0x5d, 0xf2, 0x09, 0xde, 0x9b, 0x22, 0x4a, 0x03, 0x9a, 0x84, 0x34, 0x03, 0x5b, 0xd2, 0x58, 0x2d, - 0x25, 0x65, 0x4f, 0xd3, 0x24, 0xa1, 0x01, 0xa7, 0x21, 0x98, 0x2a, 0x8c, 0x70, 0x37, 0x4a, 0x73, 0x5e, 0x16, 0x56, - 0xd0, 0x33, 0x03, 0x7a, 0xe6, 0x06, 0x7e, 0x1c, 0x3b, 0xd2, 0x2c, 0x99, 0xa6, 0x57, 0x74, 0x03, 0xd4, 0xbd, 0x1a, - 0xc8, 0x65, 0x37, 0xd4, 0xe8, 0x86, 0xba, 0xf9, 0x2c, 0x66, 0x01, 0x2d, 0x45, 0xd7, 0x99, 0xcb, 0x92, 0x90, 0xde, - 0x00, 0x1f, 0x41, 0xfd, 0x7e, 0xbf, 0x8d, 0x3b, 0xa8, 0x90, 0x08, 0x5f, 0xac, 0x21, 0xf6, 0x0e, 0xa1, 0x09, 0x44, - 0x46, 0xfa, 0x8b, 0x8d, 0x6c, 0x0d, 0x19, 0x92, 0x92, 0x69, 0xf3, 0x4a, 0x72, 0x67, 0x84, 0x43, 0x1a, 0x53, 0x4e, - 0x35, 0x37, 0x07, 0x25, 0x5a, 0x6e, 0xdd, 0x77, 0x25, 0xfe, 0x4a, 0x72, 0xd2, 0xbb, 0x4c, 0xaf, 0x79, 0x5e, 0x9a, - 0xeb, 0xd5, 0xf2, 0x54, 0xd8, 0x1e, 0x70, 0xb9, 0x3c, 0x3e, 0xe7, 0x7e, 0x10, 0x49, 0x3b, 0xdd, 0x59, 0x9b, 0x52, - 0xd5, 0x87, 0xe2, 0xec, 0xe5, 0x26, 0x7a, 0xa2, 0xc1, 0xdc, 0x84, 0x82, 0x33, 0xc5, 0x14, 0x28, 0x98, 0x7e, 0x72, - 0xd9, 0x4e, 0xfd, 0x38, 0xbe, 0xf4, 0x83, 0x8f, 0x75, 0xea, 0xaf, 0xc8, 0x80, 0xac, 0x72, 0x63, 0xe3, 0x95, 0xc1, - 0xb2, 0xcc, 0x79, 0x6b, 0x2e, 0x5d, 0xdb, 0x28, 0xce, 0x4e, 0xbb, 0x22, 0xfb, 0xfa, 0x42, 0x6f, 0xa5, 0x76, 0x01, - 0x11, 0x53, 0x33, 0x73, 0x80, 0x0b, 0x7c, 0x92, 0xe2, 0x34, 0x3f, 0x50, 0x74, 0x07, 0x06, 0x47, 0xb1, 0x02, 0x08, - 0x47, 0x8b, 0x22, 0x64, 0xf9, 0x76, 0x0c, 0xfc, 0x21, 0x50, 0x3e, 0x35, 0x46, 0xb8, 0x2f, 0xa0, 0x25, 0x8f, 0x53, - 0x5a, 0x73, 0x09, 0x99, 0xd2, 0x27, 0x34, 0xa3, 0xf9, 0x06, 0x74, 0x17, 0x41, 0xef, 0x6f, 0xe4, 0x2b, 0xd0, 0xca, - 0x00, 0x8a, 0xbc, 0x67, 0xaa, 0x13, 0x35, 0x0a, 0x50, 0x3c, 0x95, 0x09, 0x91, 0x9b, 0xd5, 0x2c, 0x48, 0xa5, 0xb1, - 0x4b, 0x23, 0x5c, 0xb1, 0xdc, 0x94, 0x38, 0x8e, 0x93, 0x83, 0x11, 0xa7, 0x75, 0xfb, 0x6a, 0x12, 0xf9, 0xda, 0x24, - 0x72, 0xd7, 0x30, 0xb4, 0x50, 0x45, 0xcb, 0x46, 0x73, 0x8f, 0x73, 0x64, 0xd6, 0x02, 0x7d, 0xd5, 0x05, 0x06, 0x8d, - 0x4a, 0x7e, 0x1b, 0x13, 0x8e, 0x53, 0x65, 0xe5, 0x28, 0x52, 0x03, 0x8e, 0x51, 0x35, 0xc9, 0x90, 0xdc, 0x1b, 0x35, - 0x93, 0x37, 0xc3, 0x29, 0x5a, 0x51, 0xee, 0x8b, 0x42, 0x21, 0x89, 0x22, 0xb5, 0x38, 0x35, 0xad, 0xd8, 0x40, 0x0b, - 0xce, 0x88, 0xd2, 0x84, 0xa5, 0xe2, 0xb3, 0x8a, 0x9c, 0xb2, 0xdf, 0x1d, 0x42, 0xb2, 0x0a, 0x37, 0x35, 0x95, 0x52, - 0xeb, 0x56, 0x19, 0xc2, 0x91, 0x56, 0x4a, 0xd3, 0x6a, 0xe2, 0x84, 0xd8, 0xda, 0x27, 0x61, 0x0f, 0x16, 0x35, 0xbb, - 0xd0, 0x33, 0xaa, 0x15, 0x1e, 0xf0, 0xd4, 0x74, 0x13, 0xbe, 0x37, 0x11, 0x4d, 0xad, 0x1f, 0x03, 0xe3, 0x69, 0x0d, - 0xe3, 0x06, 0x6a, 0x33, 0xc9, 0xbb, 0xb2, 0x11, 0x89, 0xea, 0x8d, 0x1d, 0x8a, 0x53, 0xb9, 0x10, 0x6b, 0x58, 0x5c, - 0x55, 0x3e, 0x05, 0x11, 0x82, 0x19, 0x9b, 0x83, 0x7a, 0x67, 0x4a, 0x08, 0x07, 0x80, 0x67, 0xcb, 0xe5, 0x1a, 0xd9, - 0x6d, 0xd4, 0x41, 0x91, 0x5b, 0x59, 0x86, 0xcb, 0xe5, 0x33, 0x8e, 0x1c, 0xa5, 0xfd, 0x62, 0x8a, 0x06, 0x9a, 0xe7, - 0x9e, 0xbc, 0x84, 0x5a, 0x42, 0x19, 0xad, 0x4a, 0x4a, 0xb3, 0xa1, 0x4e, 0xb5, 0xf5, 0x85, 0xe2, 0x06, 0xe3, 0x3e, - 0x5d, 0xe3, 0x5f, 0xa2, 0x50, 0x09, 0xea, 0x6a, 0xca, 0xa7, 0xaa, 0x6b, 0x86, 0x10, 0xf2, 0x72, 0x61, 0xc9, 0xec, - 0x6c, 0x32, 0x2e, 0xf7, 0xf6, 0x72, 0xa3, 0xa3, 0xf3, 0x92, 0x51, 0xfc, 0xec, 0x80, 0x50, 0xce, 0x6f, 0x13, 0xa1, - 0xbd, 0xfc, 0xac, 0xc5, 0xd0, 0x9a, 0x69, 0xda, 0xee, 0x81, 0x4d, 0xee, 0x5f, 0xfb, 0x8c, 0x5b, 0x65, 0x2f, 0xd2, - 0x26, 0x77, 0x28, 0x5a, 0x28, 0x65, 0xc3, 0xcd, 0x28, 0xa8, 0x8f, 0xc0, 0x15, 0xb4, 0x12, 0x2d, 0x09, 0x3f, 0x88, - 0x28, 0xf8, 0x83, 0xb5, 0x1e, 0x51, 0xda, 0x86, 0x3b, 0x4a, 0x8e, 0xa8, 0x8e, 0x37, 0xc3, 0x5e, 0xac, 0x36, 0xaf, - 0xd9, 0x02, 0x33, 0x9a, 0x8d, 0xd3, 0x6c, 0xaa, 0xdf, 0x15, 0x2b, 0xcf, 0x8a, 0x37, 0xb2, 0xb1, 0xb3, 0xb1, 0x6f, - 0x65, 0x01, 0xf4, 0x56, 0x0c, 0xef, 0xca, 0x64, 0xaf, 0x09, 0xd3, 0x52, 0xfe, 0x4a, 0xb7, 0xa0, 0xa6, 0xcc, 0xdc, - 0x34, 0xf1, 0x95, 0x4f, 0xb5, 0x27, 0xdd, 0x26, 0x3b, 0x9d, 0x5e, 0x69, 0xf7, 0x69, 0x6a, 0xe8, 0x49, 0xf7, 0x86, - 0x12, 0xaa, 0xe9, 0x3c, 0x0e, 0x15, 0xb0, 0x0c, 0x61, 0xaa, 0xe8, 0xe8, 0x9a, 0xc5, 0x71, 0x55, 0xfa, 0x39, 0x9c, - 0x3d, 0x57, 0x9c, 0x3d, 0xd3, 0x9c, 0x1d, 0x58, 0x05, 0x70, 0x76, 0xd9, 0x5d, 0xd5, 0x3c, 0x5b, 0xdb, 0x9e, 0x99, - 0xe4, 0xe9, 0xb9, 0xb0, 0xa5, 0x61, 0xbc, 0xb9, 0x86, 0x00, 0x95, 0xba, 0xd7, 0x47, 0x47, 0xb9, 0x62, 0xc0, 0x08, - 0x94, 0x9e, 0x4c, 0x6a, 0xba, 0x29, 0x3e, 0x3a, 0x08, 0xe7, 0x05, 0x2d, 0x29, 0xfb, 0xe4, 0x19, 0xf8, 0xea, 0x8c, - 0xe9, 0x80, 0x18, 0x13, 0xc5, 0x9f, 0xa5, 0x46, 0xe9, 0xd9, 0x31, 0x35, 0xbb, 0x5c, 0xcf, 0x0e, 0x78, 0x7d, 0x35, - 0xbb, 0xf0, 0x6e, 0x6e, 0x2f, 0xa6, 0xc7, 0xca, 0xe9, 0x55, 0xeb, 0xbd, 0x5c, 0x3a, 0x2b, 0x25, 0xe0, 0xc6, 0x57, - 0x46, 0x4a, 0x56, 0xf6, 0x0e, 0x3c, 0xc0, 0xc4, 0x0c, 0x14, 0x14, 0x72, 0xd2, 0xa5, 0x90, 0x7b, 0xf9, 0x29, 0x27, - 0x8f, 0xf0, 0xd6, 0xcb, 0xf6, 0xa7, 0xe9, 0x74, 0x06, 0xfa, 0xd8, 0x0a, 0x49, 0x4f, 0xa8, 0x1a, 0xb0, 0x7a, 0x5f, - 0x6c, 0x28, 0xab, 0xb5, 0x11, 0xfb, 0xb1, 0x46, 0x4d, 0xa5, 0xcd, 0xbc, 0xd3, 0x2e, 0xe6, 0x65, 0x51, 0xc9, 0x38, - 0x36, 0x39, 0x56, 0x4e, 0x57, 0xdd, 0x32, 0xfa, 0xc5, 0x1b, 0x87, 0x49, 0x3e, 0xcc, 0x80, 0xd7, 0x19, 0xec, 0x47, - 0x93, 0xbb, 0xb9, 0xfe, 0x45, 0x85, 0x9c, 0x45, 0xb1, 0x82, 0xbe, 0x45, 0x51, 0x3c, 0x53, 0x76, 0x36, 0x7e, 0xb6, - 0xdd, 0x20, 0xae, 0xde, 0x29, 0x7b, 0x71, 0x38, 0xc2, 0xcf, 0xd6, 0xb5, 0x47, 0xb2, 0x98, 0xa6, 0x21, 0xf5, 0xec, - 0x74, 0x46, 0x13, 0xbb, 0x00, 0xef, 0xaa, 0x5a, 0xfc, 0x39, 0x77, 0x16, 0xef, 0xea, 0x6e, 0x56, 0xef, 0x59, 0x01, - 0x2e, 0xb0, 0x1f, 0xd7, 0x1d, 0xb0, 0xdf, 0xd2, 0x2c, 0x17, 0xba, 0x68, 0xa9, 0xd6, 0xfe, 0x58, 0x09, 0xa6, 0x1f, - 0xbd, 0xad, 0xf5, 0x2b, 0x2b, 0xc4, 0xee, 0xb8, 0x0f, 0xdd, 0x7d, 0x1b, 0x09, 0xf7, 0xf0, 0x37, 0x6a, 0xc7, 0xff, - 0xa2, 0xdd, 0xc3, 0x67, 0xe4, 0x97, 0xba, 0x77, 0x78, 0xc6, 0xc9, 0xd9, 0xe0, 0x4c, 0x1b, 0xcd, 0x69, 0xcc, 0x82, - 0x5b, 0xc7, 0x8e, 0x19, 0x6f, 0x42, 0x08, 0xce, 0xc6, 0x0b, 0xf9, 0x02, 0xfc, 0x8a, 0xc2, 0xad, 0x5d, 0x68, 0x73, - 0x0f, 0x33, 0x4e, 0xec, 0xdd, 0x98, 0xf1, 0x5d, 0x1b, 0xdf, 0x92, 0x0b, 0xf8, 0xb1, 0xbb, 0x70, 0x5e, 0xf9, 0x3c, - 0x72, 0x33, 0x3f, 0x09, 0xd3, 0xa9, 0x83, 0x1a, 0xb6, 0x8d, 0xdc, 0x5c, 0x98, 0x1c, 0x8f, 0x51, 0xb1, 0x7b, 0x81, - 0xcf, 0x38, 0xb1, 0x07, 0x76, 0xe3, 0x16, 0xbf, 0xe2, 0xe4, 0xe2, 0x78, 0x77, 0x71, 0xc6, 0x8b, 0xfe, 0x05, 0x3e, - 0x29, 0x3d, 0xf7, 0xf8, 0x35, 0x71, 0x10, 0xe9, 0x9f, 0x28, 0x68, 0x4e, 0xd3, 0xa9, 0xf4, 0xe0, 0xdb, 0x08, 0xbf, - 0x13, 0xf1, 0x95, 0x8a, 0xdd, 0xa8, 0x10, 0xcb, 0x0e, 0xb1, 0x53, 0xe1, 0x25, 0xb0, 0xf7, 0xf6, 0x8c, 0xb2, 0x52, - 0x59, 0xc0, 0xa7, 0x9c, 0xd4, 0x6c, 0x72, 0xfc, 0x52, 0x44, 0x6a, 0x4e, 0xb9, 0x93, 0x20, 0xdd, 0x8d, 0xa3, 0xdd, - 0xd1, 0x6a, 0x6f, 0x26, 0x43, 0xe9, 0x64, 0x70, 0x19, 0xa7, 0x99, 0xcf, 0xd3, 0x6c, 0x84, 0x4c, 0x05, 0x04, 0xff, - 0x8d, 0x5c, 0x0c, 0xad, 0xff, 0xf4, 0xc5, 0x4f, 0xe3, 0x9f, 0xb2, 0xd1, 0x05, 0xfe, 0x40, 0x5a, 0xc7, 0xce, 0xc0, - 0x73, 0x76, 0x9a, 0xcd, 0xe5, 0x4f, 0xad, 0xe1, 0xdf, 0xfd, 0xe6, 0xaf, 0x27, 0xcd, 0x1f, 0x47, 0x68, 0xe9, 0xfc, - 0xd4, 0x1a, 0x0c, 0xd5, 0xd3, 0xf0, 0xef, 0xfd, 0x9f, 0xf2, 0xd1, 0x57, 0xb2, 0x70, 0x17, 0xa1, 0xd6, 0x04, 0x4f, - 0x38, 0x69, 0x35, 0x9b, 0xfd, 0xd6, 0x04, 0x4f, 0x39, 0x69, 0xc1, 0xbf, 0xd7, 0xe4, 0x1d, 0x9d, 0x3c, 0xbb, 0x99, - 0x39, 0x17, 0xfd, 0xe5, 0xee, 0xe2, 0x6f, 0x05, 0xf4, 0x3a, 0xfc, 0xfb, 0x4f, 0x3f, 0xe5, 0xf6, 0x83, 0x3e, 0x69, - 0x8d, 0x1a, 0xc8, 0x81, 0xd2, 0xaf, 0x88, 0xf8, 0xeb, 0x0c, 0xbc, 0xe1, 0xdf, 0x15, 0x14, 0xf6, 0x83, 0x9f, 0x2e, - 0x8e, 0xfb, 0x64, 0xb4, 0x74, 0xec, 0xe5, 0x03, 0xb4, 0x44, 0x68, 0xb9, 0x8b, 0x2e, 0xb0, 0x3d, 0xb1, 0x11, 0x1e, - 0x73, 0xd2, 0x7a, 0xd0, 0x9a, 0xe0, 0x73, 0x4e, 0x5a, 0x76, 0x6b, 0x82, 0xdf, 0x70, 0xd2, 0xfa, 0xbb, 0x33, 0xf0, - 0xa4, 0x9b, 0x6d, 0x29, 0x3c, 0x1c, 0x4b, 0x08, 0x72, 0xf8, 0x19, 0xf5, 0x97, 0x9c, 0xf1, 0x98, 0xa2, 0xdd, 0x16, - 0xc3, 0x1f, 0x05, 0x9a, 0x1c, 0x0e, 0x7e, 0x18, 0x30, 0xef, 0x9c, 0xc5, 0x39, 0x2c, 0x36, 0xd0, 0xcc, 0xae, 0x97, - 0x60, 0xe9, 0x0a, 0xc8, 0x3d, 0x8e, 0xaf, 0xfc, 0x78, 0x4e, 0x73, 0x8f, 0x16, 0x08, 0xc7, 0xe4, 0x23, 0x77, 0x3a, - 0x08, 0xbf, 0xe0, 0xf0, 0xa3, 0x8b, 0xf0, 0xa9, 0x0a, 0x64, 0xc2, 0x4e, 0x96, 0x44, 0x95, 0xa4, 0x52, 0x65, 0xb1, - 0x11, 0x9e, 0x6c, 0x78, 0xc9, 0x23, 0x70, 0x30, 0x20, 0x7c, 0x55, 0x0b, 0x7b, 0xe2, 0x1b, 0xa2, 0x49, 0xe2, 0x7d, - 0x46, 0xe9, 0x77, 0x7e, 0xfc, 0x91, 0x66, 0xce, 0x09, 0xee, 0x74, 0x1f, 0x63, 0xe1, 0x87, 0xde, 0xe9, 0xa0, 0x5e, - 0x19, 0xb3, 0x7a, 0xcb, 0x65, 0xa8, 0x00, 0xa4, 0x6c, 0xdd, 0x1d, 0x03, 0x2b, 0xbe, 0x93, 0xac, 0xf9, 0xac, 0x32, - 0xff, 0xda, 0x46, 0xf5, 0xf8, 0x28, 0x4b, 0xae, 0xfc, 0x98, 0x85, 0x16, 0xa7, 0xd3, 0x59, 0xec, 0x73, 0x6a, 0xa9, - 0xf9, 0x5a, 0x3e, 0x74, 0x64, 0x97, 0x3a, 0xc3, 0xcc, 0xb0, 0x39, 0x67, 0x3a, 0xf0, 0x04, 0x7b, 0xc5, 0x81, 0x28, - 0x95, 0xd2, 0x3b, 0x9e, 0x56, 0x41, 0xb0, 0xd5, 0x38, 0x5f, 0xb3, 0x03, 0xbe, 0xb0, 0x91, 0x90, 0xcf, 0x39, 0xce, - 0x08, 0x48, 0xd1, 0xee, 0xc0, 0x3e, 0xce, 0xaf, 0x26, 0x7d, 0x1b, 0x62, 0x34, 0x29, 0xf9, 0x20, 0x5c, 0x43, 0x50, - 0x21, 0x22, 0xed, 0x5e, 0x74, 0x4c, 0x7b, 0x51, 0xa3, 0xa1, 0xb5, 0x68, 0x9f, 0x24, 0xc3, 0x48, 0x36, 0x0f, 0x70, - 0x88, 0xe7, 0xa4, 0xd9, 0xc1, 0x33, 0xd2, 0x16, 0x4d, 0x7a, 0xb3, 0x63, 0x5f, 0x0d, 0xb3, 0xb7, 0xe7, 0xa4, 0x6e, - 0xec, 0xe7, 0xfc, 0x05, 0xd8, 0xfb, 0x64, 0x86, 0x43, 0x92, 0xba, 0xf4, 0x86, 0x06, 0x8e, 0x8f, 0x70, 0xa8, 0x38, - 0x0d, 0xea, 0xa1, 0x19, 0x31, 0xaa, 0x81, 0x19, 0x41, 0x3e, 0x0c, 0xc2, 0x61, 0x67, 0x44, 0x08, 0xb1, 0x77, 0x9a, - 0x4d, 0x7b, 0x90, 0x92, 0x09, 0xf7, 0xa0, 0xc4, 0x50, 0x96, 0xc9, 0x14, 0x8a, 0xba, 0x46, 0x91, 0xf3, 0x86, 0xbb, - 0x9c, 0xe6, 0xdc, 0x81, 0x62, 0xf0, 0x00, 0xe4, 0x9a, 0xb0, 0xed, 0xe3, 0x96, 0xdd, 0x80, 0x52, 0x41, 0x9c, 0x08, - 0xa7, 0xe4, 0x1a, 0x79, 0xe1, 0x70, 0x7f, 0x64, 0x0a, 0x00, 0x51, 0x08, 0x83, 0x5f, 0x0f, 0xc2, 0x61, 0x5b, 0x0c, - 0xde, 0xb7, 0x07, 0x4e, 0x4a, 0x72, 0xa9, 0xa1, 0x0d, 0x72, 0xef, 0x83, 0x98, 0x2a, 0xf2, 0x14, 0x70, 0x6a, 0xdc, - 0x39, 0x69, 0x76, 0x3d, 0x67, 0x6e, 0x4e, 0xa2, 0x09, 0x83, 0x29, 0x2c, 0xe0, 0x80, 0x40, 0x7d, 0x9c, 0x12, 0x18, - 0xb1, 0x6a, 0x76, 0xed, 0xa9, 0xe7, 0x07, 0xf6, 0x83, 0xc1, 0x39, 0xf7, 0xc6, 0x5c, 0x0e, 0x7f, 0xce, 0x97, 0x4b, - 0xf8, 0x77, 0xcc, 0x07, 0x29, 0xb9, 0x16, 0x45, 0x13, 0x55, 0x34, 0x85, 0xa2, 0x0f, 0x1e, 0x80, 0x8a, 0xf3, 0x52, - 0xcb, 0x92, 0x6b, 0x32, 0x25, 0x02, 0xf6, 0xbd, 0xbd, 0x64, 0x18, 0x35, 0x3a, 0x23, 0x70, 0xf2, 0x67, 0x3c, 0xff, - 0x8e, 0xf1, 0xc8, 0xb1, 0x5b, 0x7d, 0x1b, 0x0d, 0x6c, 0x0b, 0x96, 0xb6, 0x97, 0x35, 0x88, 0xc4, 0xb0, 0xdf, 0x78, - 0xc5, 0xbd, 0x79, 0x9f, 0xb4, 0x07, 0x0e, 0x53, 0x2e, 0x3d, 0x84, 0x7d, 0xc5, 0x38, 0xdb, 0x78, 0x8e, 0x1a, 0x8c, - 0x37, 0xf4, 0xf3, 0x1c, 0x35, 0x6e, 0x1b, 0x53, 0xe4, 0xf9, 0x8d, 0xdb, 0x86, 0x33, 0x27, 0x84, 0x34, 0xbb, 0x65, - 0x33, 0x2d, 0xfe, 0x22, 0xe4, 0x4d, 0xb5, 0xbf, 0x73, 0x28, 0xb6, 0x43, 0xd6, 0x70, 0x92, 0x21, 0x1d, 0x2d, 0x97, - 0xf6, 0xf1, 0xa0, 0x6f, 0xa3, 0x86, 0xa3, 0x09, 0xad, 0xa5, 0x29, 0x0d, 0x21, 0xcc, 0x46, 0x85, 0x8a, 0x27, 0x3d, - 0xa9, 0xc5, 0x8e, 0x16, 0xd5, 0x66, 0x37, 0x78, 0x00, 0x2d, 0x4a, 0x43, 0x46, 0x2a, 0xac, 0x33, 0x98, 0xa6, 0x26, - 0xe6, 0x8c, 0xb4, 0x71, 0x4a, 0xb4, 0xfb, 0x3a, 0x22, 0xbc, 0x22, 0x78, 0x9f, 0x54, 0xd5, 0xf1, 0x30, 0xc0, 0xe1, - 0x88, 0x3c, 0x95, 0x06, 0x49, 0x4f, 0x3b, 0xc7, 0x69, 0x4c, 0x9e, 0xac, 0x44, 0x71, 0x03, 0x08, 0xb0, 0xdc, 0xb8, - 0xc1, 0x3c, 0xcb, 0x68, 0xc2, 0x5f, 0xa7, 0xa1, 0xd2, 0xd3, 0x68, 0x0c, 0xa6, 0x12, 0x84, 0x67, 0x31, 0x28, 0x69, - 0x5d, 0xbd, 0x33, 0xe6, 0x6b, 0xaf, 0x67, 0x64, 0x2e, 0xf5, 0x27, 0x11, 0xb4, 0xed, 0xcd, 0x94, 0x65, 0xec, 0x20, - 0x3c, 0x57, 0xd1, 0x5c, 0xc7, 0x75, 0xdd, 0x99, 0x1b, 0xc0, 0x6b, 0x18, 0x20, 0x47, 0x85, 0xd8, 0x47, 0x4e, 0x4e, - 0x6e, 0xdc, 0x84, 0xde, 0x88, 0x51, 0x1d, 0x54, 0x49, 0x66, 0xbd, 0xbd, 0x8e, 0xa3, 0x9e, 0x60, 0x37, 0xb9, 0x9b, - 0xa4, 0x21, 0x05, 0xf4, 0x40, 0xfc, 0x5e, 0x15, 0x45, 0x7e, 0x6e, 0x06, 0xa9, 0x2a, 0xf8, 0x86, 0xa6, 0xff, 0x7a, - 0x06, 0x4e, 0x5f, 0xa1, 0x6c, 0x95, 0x95, 0xa5, 0x27, 0x1c, 0x21, 0x36, 0x76, 0x66, 0x2e, 0x04, 0xf7, 0x04, 0x09, - 0x31, 0xb0, 0xe5, 0x66, 0x26, 0x51, 0xdd, 0x96, 0x7d, 0x4e, 0x49, 0x38, 0x4c, 0x1b, 0x0d, 0xe1, 0x88, 0x9e, 0x4b, - 0x92, 0x98, 0x21, 0x3c, 0x2d, 0xf7, 0x96, 0xae, 0xf7, 0x96, 0xd4, 0x47, 0x72, 0xa6, 0x75, 0x87, 0x6e, 0x83, 0x71, - 0x24, 0x7c, 0x85, 0xdc, 0xb9, 0x45, 0x78, 0x4c, 0x5a, 0xce, 0xd0, 0x1d, 0xfc, 0x79, 0x84, 0x06, 0x8e, 0xfb, 0x15, - 0x6a, 0x49, 0xc6, 0x31, 0x45, 0x3d, 0x5f, 0x0e, 0xb1, 0x10, 0x51, 0xcc, 0x0e, 0x16, 0xbe, 0x44, 0x2f, 0xc3, 0x89, - 0x3f, 0xa5, 0xde, 0x18, 0xf6, 0xb8, 0xa6, 0x9b, 0xb7, 0x18, 0xe8, 0xc8, 0x1b, 0x2b, 0x4e, 0xe2, 0xda, 0x83, 0x5f, - 0x78, 0xf9, 0x34, 0xb0, 0x07, 0x5f, 0x57, 0x4f, 0x7f, 0xb6, 0x07, 0xdf, 0x72, 0xef, 0xdb, 0x42, 0xb9, 0xbb, 0x6b, - 0x43, 0x3c, 0xd4, 0x43, 0x14, 0x72, 0x61, 0x0c, 0xcc, 0xcd, 0xd1, 0xba, 0xa3, 0x63, 0x86, 0x0a, 0x36, 0x2e, 0x59, - 0x51, 0xee, 0x72, 0x7f, 0x02, 0x28, 0x35, 0x56, 0x20, 0x37, 0xa3, 0xfb, 0xd5, 0x84, 0x81, 0x50, 0x34, 0xb5, 0x02, - 0x2a, 0x67, 0xfd, 0x36, 0x5a, 0xd4, 0xea, 0x0a, 0x8d, 0xa9, 0x1e, 0x4d, 0x2f, 0xb9, 0xf4, 0x94, 0xb4, 0x7b, 0xd3, - 0xe3, 0x59, 0x6f, 0xda, 0x68, 0xa0, 0x5c, 0x13, 0xd6, 0x7c, 0x38, 0x1d, 0xe1, 0xd7, 0xe0, 0xd5, 0x33, 0x29, 0x09, - 0xd7, 0xa6, 0xd7, 0x55, 0xd3, 0x6b, 0x34, 0xb2, 0x02, 0xf5, 0x8c, 0xa6, 0x33, 0xd9, 0xb4, 0x28, 0x24, 0x4e, 0x56, - 0x09, 0xed, 0x08, 0x89, 0x12, 0x48, 0x89, 0x22, 0x84, 0x9c, 0x71, 0xb4, 0xb1, 0x57, 0xe8, 0x13, 0x9a, 0x8b, 0x1d, - 0x0b, 0xcc, 0x53, 0xca, 0x08, 0x07, 0xb0, 0x00, 0x4d, 0x4b, 0x57, 0xf0, 0x2d, 0x9e, 0x37, 0x3a, 0x82, 0xc8, 0x9b, - 0x9d, 0x5e, 0xbd, 0xaf, 0x47, 0x55, 0x5f, 0x78, 0xde, 0x20, 0xb7, 0x25, 0x96, 0x8a, 0xac, 0xd1, 0x28, 0xea, 0xf1, - 0x4e, 0xbd, 0x6f, 0x6b, 0x11, 0x88, 0x93, 0xd5, 0xd4, 0x0c, 0x2d, 0x5f, 0x2b, 0x89, 0xca, 0x5c, 0x96, 0x24, 0x34, - 0x03, 0x19, 0x4a, 0x38, 0x66, 0x45, 0x51, 0xca, 0xf5, 0x37, 0x20, 0x44, 0x31, 0x25, 0x09, 0xf0, 0x1d, 0x61, 0x76, - 0xe1, 0x0c, 0xa7, 0x38, 0x12, 0x5c, 0x83, 0x10, 0x72, 0xaa, 0x93, 0x5a, 0xb8, 0xe0, 0x40, 0x3e, 0x61, 0x86, 0x44, - 0xca, 0x09, 0x75, 0xcf, 0x77, 0x4f, 0xd3, 0x3b, 0x4d, 0xb2, 0x21, 0x1b, 0x79, 0xa2, 0x5a, 0xac, 0xf8, 0x56, 0x40, - 0xde, 0x39, 0x1c, 0x95, 0xe1, 0x11, 0x57, 0xb0, 0xbf, 0xa7, 0x2c, 0xa3, 0x42, 0x03, 0xdf, 0xd5, 0x66, 0x9f, 0x5f, - 0x57, 0x1f, 0x7d, 0xd3, 0x79, 0x03, 0x88, 0x0c, 0xc0, 0xb7, 0x93, 0x91, 0xb5, 0x6a, 0xe7, 0xbb, 0x27, 0x6f, 0x36, - 0x99, 0xc0, 0xcb, 0xa5, 0x32, 0x7e, 0x7d, 0xd0, 0x6c, 0x70, 0x50, 0x41, 0xea, 0xab, 0x1f, 0x9e, 0xe3, 0x0b, 0x05, - 0x29, 0x70, 0x12, 0xa0, 0xa2, 0xf3, 0xdd, 0x93, 0xf7, 0x4e, 0x22, 0x5c, 0x4b, 0x08, 0x9b, 0xd3, 0x76, 0x52, 0xe2, - 0x44, 0x84, 0x22, 0x39, 0xf7, 0x92, 0x71, 0xa5, 0x86, 0xf8, 0xf6, 0x22, 0xf1, 0x12, 0xec, 0x87, 0x21, 0x1b, 0x11, - 0x5f, 0x61, 0x80, 0xf8, 0x08, 0xfb, 0x35, 0xb3, 0x8c, 0xc0, 0x02, 0x88, 0xb1, 0xce, 0x60, 0x25, 0x5c, 0xa9, 0xf8, - 0x21, 0xec, 0x8b, 0x51, 0x79, 0x21, 0x45, 0xc7, 0xcf, 0x6b, 0xb9, 0x69, 0x95, 0x35, 0xfa, 0x2d, 0x58, 0x4e, 0xfa, - 0xe1, 0xb5, 0xea, 0xba, 0x2c, 0x78, 0xaa, 0x93, 0xc8, 0xce, 0x77, 0x4f, 0x5e, 0xa9, 0x3c, 0xb2, 0x99, 0xaf, 0xb9, - 0xfd, 0x9a, 0x85, 0x79, 0xf2, 0xca, 0xad, 0xde, 0x8a, 0xca, 0xe7, 0xbb, 0x27, 0x1f, 0x36, 0x55, 0x83, 0xf2, 0x62, - 0x5e, 0x99, 0xf8, 0x02, 0xbe, 0x05, 0x8d, 0xbd, 0x85, 0x12, 0x0d, 0x1e, 0x2b, 0xb0, 0x10, 0x47, 0x5e, 0x5e, 0x94, - 0x9e, 0x91, 0xa7, 0x38, 0x23, 0x22, 0x0e, 0x54, 0x5f, 0x35, 0xa5, 0xe4, 0xb1, 0x34, 0x39, 0x0b, 0xd2, 0x19, 0xdd, - 0x12, 0x1c, 0x3a, 0x41, 0x2e, 0x9b, 0x42, 0x02, 0x8d, 0x00, 0x9d, 0xe1, 0x9d, 0x36, 0xea, 0xd5, 0x85, 0x57, 0x26, - 0x88, 0x34, 0xad, 0x49, 0x16, 0x1c, 0x91, 0x36, 0xf6, 0x49, 0x1b, 0x07, 0x24, 0x1f, 0xb6, 0xa5, 0x78, 0xe8, 0x05, - 0x65, 0xbf, 0x52, 0xc8, 0x40, 0x6e, 0x58, 0x20, 0x77, 0xab, 0x14, 0xbf, 0x61, 0x2f, 0x10, 0xae, 0x47, 0x21, 0xd1, - 0x43, 0x69, 0xb4, 0x3a, 0x29, 0x4e, 0x45, 0xc7, 0x67, 0xec, 0x32, 0x86, 0xec, 0x12, 0x98, 0x15, 0xe6, 0xc8, 0x2b, - 0xab, 0x76, 0x54, 0xd5, 0xc0, 0x15, 0xeb, 0x94, 0xe2, 0xc0, 0x05, 0xc6, 0x8d, 0x03, 0x95, 0x8c, 0x93, 0xaf, 0x37, - 0x79, 0xb8, 0xb7, 0xe7, 0xc8, 0x46, 0xdf, 0x71, 0x27, 0xd5, 0xef, 0xab, 0xd0, 0xdd, 0xb7, 0x92, 0x57, 0x84, 0x48, - 0xc0, 0xdf, 0x68, 0xf8, 0xa3, 0x02, 0xe2, 0xd0, 0x4e, 0x50, 0xc7, 0xa0, 0x06, 0x5e, 0x68, 0x7a, 0xf5, 0xe9, 0x37, - 0x1a, 0x65, 0x98, 0xb6, 0x8e, 0xad, 0x13, 0x9c, 0x15, 0x57, 0x4e, 0x99, 0xff, 0xd3, 0x5e, 0xcb, 0x9a, 0xd2, 0x20, - 0x20, 0x66, 0xd2, 0x2c, 0xd3, 0x93, 0x31, 0xb6, 0x04, 0x83, 0x7a, 0x2f, 0x54, 0xe2, 0x02, 0x16, 0x39, 0x56, 0xaa, - 0x92, 0x66, 0x67, 0x5d, 0xe4, 0xe9, 0x4a, 0x10, 0x96, 0x82, 0x4a, 0x8d, 0x42, 0x91, 0xf7, 0xab, 0xf5, 0xcc, 0x4b, - 0x9c, 0x23, 0xe5, 0xe3, 0x12, 0x50, 0x08, 0x64, 0x75, 0x4b, 0xa4, 0x3c, 0x27, 0x93, 0xed, 0x24, 0x7f, 0x62, 0x90, - 0xfc, 0x13, 0x42, 0x0d, 0xf2, 0x97, 0x1e, 0x0e, 0x37, 0x55, 0xae, 0x85, 0x5c, 0xbf, 0x3a, 0x9d, 0x11, 0xf0, 0xa1, - 0xd5, 0x31, 0x5a, 0x8b, 0x2b, 0x6e, 0x61, 0x28, 0xe6, 0x0e, 0x11, 0x5e, 0x48, 0xac, 0x83, 0xc0, 0x4e, 0x15, 0x55, - 0x83, 0xa1, 0x37, 0xb9, 0xf4, 0x4c, 0x0e, 0x78, 0xf2, 0xe1, 0xee, 0x80, 0xe8, 0xe9, 0x6c, 0x7d, 0xe7, 0x1a, 0x19, - 0xa0, 0x30, 0x6b, 0x63, 0xe3, 0xd6, 0xf3, 0x41, 0x61, 0xfc, 0x32, 0x90, 0x5d, 0x67, 0x3e, 0x2b, 0x9b, 0x50, 0xcb, - 0x3f, 0x80, 0xb6, 0xd3, 0x11, 0x35, 0xa8, 0xd1, 0x2d, 0xf0, 0x23, 0x99, 0x87, 0xea, 0x67, 0x5b, 0xd8, 0xc7, 0x89, - 0xa8, 0x40, 0x93, 0x70, 0xf3, 0xeb, 0x27, 0x85, 0x22, 0x13, 0x09, 0x1a, 0x5a, 0x00, 0xff, 0x93, 0x24, 0x0f, 0x74, - 0x23, 0xe4, 0x02, 0x20, 0x68, 0x22, 0xf0, 0x54, 0x21, 0xcc, 0xb6, 0x2b, 0xe7, 0xfb, 0xf3, 0x1d, 0x42, 0x26, 0x95, - 0xf3, 0xf1, 0x5d, 0x95, 0x7d, 0x05, 0x64, 0x81, 0x3c, 0x30, 0x1e, 0xcb, 0x02, 0x19, 0xbf, 0x3c, 0xd5, 0xd5, 0x85, - 0x01, 0xe9, 0x56, 0xfa, 0xb6, 0x11, 0xdb, 0x14, 0x5e, 0x39, 0xf9, 0x5e, 0xa3, 0x61, 0xe5, 0xed, 0x2e, 0xbc, 0x7d, - 0xc9, 0x05, 0x8c, 0xf0, 0xfc, 0x5e, 0xd4, 0xd6, 0xfd, 0x16, 0x1f, 0x57, 0x53, 0x58, 0x56, 0x16, 0xc5, 0x65, 0x49, - 0x4e, 0x33, 0xfe, 0x84, 0x8e, 0xd3, 0x0c, 0x42, 0x16, 0x25, 0x4e, 0x50, 0xb1, 0x6b, 0xb8, 0xed, 0xc4, 0xfc, 0x8c, - 0x38, 0xc1, 0xca, 0x04, 0xc5, 0xaf, 0x8f, 0x22, 0x6a, 0x7d, 0xbe, 0xda, 0x6a, 0xb2, 0xb7, 0xf7, 0xae, 0x42, 0x93, - 0x82, 0x52, 0x40, 0x61, 0x30, 0x2d, 0xa9, 0xd2, 0xa8, 0x50, 0xee, 0xae, 0x53, 0xba, 0x00, 0x34, 0xc3, 0x30, 0x79, - 0xcf, 0x73, 0xc2, 0x8b, 0xc9, 0x2a, 0x8b, 0x57, 0xae, 0x09, 0x66, 0x9a, 0x2d, 0xc0, 0xe1, 0xc1, 0xd0, 0x96, 0xbe, - 0xa2, 0xbc, 0x4a, 0x89, 0x2d, 0x61, 0x38, 0x05, 0x64, 0x39, 0xc2, 0x08, 0x31, 0x28, 0x70, 0xa3, 0x51, 0xf2, 0x16, - 0xf4, 0xca, 0x08, 0xe7, 0x6e, 0x04, 0x49, 0xb0, 0xb5, 0x2d, 0x8b, 0x10, 0x96, 0x99, 0x39, 0x46, 0x2e, 0xc1, 0xc9, - 0xf3, 0x4d, 0x1e, 0x65, 0x4d, 0xd4, 0x54, 0x48, 0x1d, 0xa8, 0x91, 0xa1, 0xb2, 0x81, 0x7b, 0xe5, 0x30, 0xa5, 0xb8, - 0xe9, 0xb8, 0x19, 0x30, 0xe0, 0x9f, 0xb9, 0x23, 0x63, 0x51, 0x20, 0x33, 0x52, 0x77, 0xee, 0xd4, 0x86, 0xee, 0xa5, - 0xa2, 0x19, 0x56, 0x88, 0x8b, 0x4c, 0x34, 0xa5, 0x22, 0xae, 0x77, 0x5a, 0xf1, 0xd2, 0x2b, 0x99, 0x47, 0xcd, 0x35, - 0x17, 0xac, 0x32, 0x49, 0x8c, 0xe9, 0x5f, 0xc9, 0xd4, 0xe8, 0xb2, 0x12, 0xa8, 0x61, 0xf4, 0xda, 0x7a, 0x22, 0xd6, - 0x80, 0x16, 0x40, 0x5f, 0x8b, 0x53, 0x6e, 0xac, 0xa8, 0xf6, 0x61, 0x8b, 0x31, 0x0d, 0xa9, 0xff, 0x0e, 0x72, 0x5d, - 0x56, 0xf7, 0xfc, 0x73, 0x21, 0x0b, 0x19, 0xce, 0x6b, 0x8c, 0x3d, 0x13, 0x8c, 0x1d, 0x81, 0x9e, 0xa6, 0xd3, 0xbf, - 0x07, 0x2a, 0xe5, 0x45, 0xe5, 0x2e, 0x3a, 0x8a, 0xc4, 0x5e, 0x97, 0xe1, 0x72, 0xe3, 0xf7, 0xca, 0x6a, 0x78, 0x8c, - 0x40, 0x1a, 0x10, 0x56, 0x9c, 0x3d, 0x43, 0x38, 0x6f, 0x34, 0x7a, 0xf9, 0x31, 0xad, 0x5c, 0x24, 0x15, 0x8c, 0x0c, - 0x22, 0xba, 0x40, 0xf0, 0x35, 0x19, 0x0a, 0x31, 0x7f, 0x9d, 0x9f, 0x9d, 0x83, 0xab, 0xfd, 0xe4, 0x9d, 0x63, 0x72, - 0x35, 0xb3, 0x6e, 0x19, 0x34, 0x85, 0xf9, 0x38, 0x55, 0xbc, 0xe5, 0xed, 0xdd, 0x19, 0x1e, 0x00, 0xf7, 0x4e, 0x07, - 0x43, 0x36, 0x1a, 0xea, 0x71, 0xc9, 0x12, 0xca, 0xdd, 0xd7, 0x43, 0x55, 0x62, 0xa2, 0x39, 0x58, 0x8f, 0x57, 0xa6, - 0x2c, 0x27, 0x79, 0x51, 0xe4, 0xb4, 0x8a, 0xef, 0xaf, 0x64, 0x60, 0x0a, 0xe1, 0xb2, 0xee, 0x6c, 0x3f, 0x9d, 0x11, - 0x8e, 0x0d, 0x42, 0x7d, 0xbb, 0x2d, 0xf4, 0x51, 0x81, 0x09, 0xfb, 0x5a, 0x09, 0xc5, 0x6f, 0x37, 0x09, 0x45, 0x9c, - 0xa9, 0x2d, 0x2f, 0x04, 0x62, 0xe7, 0x1e, 0x02, 0x51, 0x39, 0xd9, 0xb5, 0x4c, 0x04, 0x75, 0xa4, 0x26, 0x13, 0xeb, - 0x4b, 0x4a, 0x32, 0xcc, 0xd4, 0x6a, 0xf4, 0xbb, 0xcb, 0x25, 0x1b, 0xb6, 0xc1, 0x89, 0x64, 0xdb, 0xf0, 0xb3, 0x23, - 0x7f, 0x1a, 0x9c, 0x58, 0x3a, 0x81, 0x1d, 0x56, 0x9a, 0x2c, 0xc8, 0x85, 0x34, 0x67, 0x47, 0x64, 0x65, 0x09, 0x9a, - 0x56, 0x14, 0xa4, 0x08, 0x9c, 0xb0, 0x32, 0xca, 0x04, 0x10, 0x0b, 0x59, 0xa1, 0x0c, 0x48, 0x67, 0x63, 0xfa, 0x9f, - 0x36, 0x2f, 0x3f, 0xad, 0x89, 0xd6, 0xe4, 0x8a, 0x54, 0x1f, 0x6a, 0x09, 0x07, 0x0a, 0x02, 0xa5, 0x1f, 0xee, 0x08, - 0x13, 0xb4, 0x12, 0xe5, 0xc8, 0x94, 0x43, 0xb8, 0x0d, 0x2e, 0xb4, 0x9d, 0x77, 0x32, 0xc0, 0xbb, 0x41, 0x9a, 0xe0, - 0xd4, 0xa0, 0xeb, 0xe7, 0x84, 0xd7, 0x58, 0x49, 0x44, 0x94, 0xa5, 0x84, 0x03, 0x41, 0xa6, 0x9c, 0x64, 0xc3, 0xf6, - 0x08, 0x14, 0xd0, 0x9e, 0x7f, 0x9c, 0x55, 0x26, 0xb0, 0xdf, 0x68, 0xa0, 0x40, 0x8f, 0x1a, 0x0d, 0x59, 0xc3, 0x1f, - 0x61, 0x8a, 0x7d, 0x69, 0x98, 0x9c, 0xee, 0xed, 0x39, 0x41, 0x35, 0xee, 0xd0, 0x1f, 0x21, 0x9c, 0x2e, 0x97, 0x8e, - 0x00, 0x2b, 0x40, 0xcb, 0x65, 0x60, 0x82, 0x25, 0x5e, 0x43, 0xb3, 0xc9, 0x80, 0x93, 0x89, 0x10, 0x80, 0x13, 0x80, - 0xb0, 0x41, 0x9c, 0x40, 0x39, 0xf7, 0x02, 0x70, 0x46, 0x35, 0xb2, 0xa1, 0xdf, 0xe8, 0x8c, 0x0c, 0xc6, 0x35, 0xf4, - 0x47, 0x24, 0x28, 0xd2, 0xbd, 0xbd, 0x9d, 0x5c, 0x89, 0xc8, 0x9f, 0x41, 0x94, 0xfd, 0x2c, 0x24, 0x8b, 0xec, 0xd0, - 0x5c, 0x8d, 0x55, 0x67, 0x40, 0x49, 0x51, 0x6a, 0x59, 0x75, 0xbd, 0x5a, 0x16, 0x44, 0x59, 0x09, 0xab, 0x58, 0xf0, - 0x00, 0x2c, 0xfb, 0x92, 0xcc, 0x7f, 0xe1, 0x65, 0x9a, 0xf5, 0xb7, 0x1b, 0x93, 0xab, 0x5d, 0xd7, 0xf5, 0xb3, 0x89, - 0x88, 0x64, 0xe8, 0x28, 0xac, 0x20, 0xfe, 0x7d, 0x05, 0xa6, 0x31, 0xf0, 0xb0, 0x1c, 0x6b, 0x44, 0x24, 0xf8, 0x5a, - 0xb5, 0xd1, 0x27, 0x4a, 0x7e, 0xdd, 0xe8, 0x65, 0x90, 0x90, 0x7c, 0xfd, 0x5b, 0x21, 0x39, 0x50, 0x90, 0x48, 0xf2, - 0x58, 0xc1, 0xd9, 0x16, 0x5c, 0xfc, 0xca, 0x57, 0x70, 0xb6, 0x1d, 0xb7, 0x25, 0x43, 0xd8, 0x06, 0x9f, 0xc1, 0x1b, - 0x24, 0xa0, 0x55, 0x81, 0x01, 0xe5, 0xe1, 0xaa, 0xee, 0x25, 0x59, 0x29, 0x08, 0x53, 0x4e, 0x1c, 0x56, 0xdf, 0x00, - 0x95, 0x36, 0x6a, 0x18, 0xbe, 0xcc, 0x9b, 0x20, 0xc3, 0x25, 0x50, 0x4f, 0x5d, 0x01, 0x72, 0x52, 0xbe, 0x76, 0x48, - 0x45, 0xd8, 0x91, 0x4a, 0x9c, 0x1b, 0xf8, 0x33, 0x3e, 0xcf, 0x40, 0x95, 0xca, 0xf5, 0x6f, 0x28, 0x86, 0xb3, 0x20, - 0xa2, 0x0c, 0x7e, 0x40, 0xc1, 0xcc, 0xcf, 0x73, 0x76, 0x25, 0xcb, 0xd4, 0x6f, 0x9c, 0x12, 0x4d, 0xca, 0xb9, 0xd4, - 0x09, 0x33, 0xd4, 0xcb, 0x14, 0x9d, 0xd6, 0xd1, 0xf6, 0xec, 0x8a, 0x26, 0xfc, 0x25, 0xcb, 0x39, 0x4d, 0x60, 0xfa, - 0x15, 0xc5, 0xc1, 0x8c, 0x72, 0x04, 0x1b, 0xb6, 0xd6, 0xca, 0x0f, 0xc3, 0x3b, 0x9b, 0xf0, 0xba, 0x0e, 0x14, 0xf9, - 0x49, 0x18, 0xcb, 0x41, 0xcc, 0x84, 0x46, 0x9d, 0xc4, 0x59, 0xd6, 0x34, 0xf3, 0x69, 0x2a, 0x65, 0x43, 0x70, 0x77, - 0x87, 0x11, 0x2d, 0x09, 0xb4, 0xf4, 0xbc, 0x53, 0x6b, 0x81, 0x80, 0xf7, 0x96, 0x45, 0x30, 0x67, 0x82, 0xb9, 0xc1, - 0x51, 0xdd, 0x3a, 0x9c, 0x9a, 0x6e, 0xbe, 0xdb, 0x78, 0xb0, 0x6d, 0x93, 0x70, 0x10, 0x74, 0xf2, 0x70, 0xbb, 0x65, - 0xf5, 0x4a, 0x4b, 0x0e, 0x2d, 0x2d, 0xd8, 0x7d, 0x19, 0x33, 0x5a, 0x68, 0xf2, 0x42, 0x7a, 0x2b, 0xee, 0x72, 0xf2, - 0x0b, 0x9c, 0x1c, 0x7a, 0xce, 0xa7, 0xf1, 0xca, 0x01, 0x99, 0xde, 0x6e, 0xa9, 0xfd, 0xef, 0x72, 0xe7, 0x09, 0x7e, - 0x05, 0x61, 0xdd, 0x6f, 0xaa, 0xea, 0xeb, 0xe1, 0xdc, 0x6f, 0x2a, 0x04, 0x7d, 0xe3, 0xad, 0xd5, 0x33, 0xc2, 0xb8, - 0x5d, 0xf7, 0xc8, 0x6d, 0xdb, 0x5a, 0x5b, 0xfa, 0x51, 0x06, 0x91, 0x64, 0xaa, 0xa5, 0xd8, 0x0f, 0xb8, 0x4a, 0x54, - 0x83, 0x84, 0xb9, 0xba, 0x85, 0x44, 0x55, 0x8a, 0xa1, 0xd4, 0xe1, 0xb7, 0x2d, 0x8f, 0x92, 0x31, 0x99, 0xb4, 0x33, - 0xde, 0xfa, 0x19, 0xdf, 0x85, 0x5d, 0x96, 0xae, 0x9d, 0xc6, 0x8b, 0x08, 0x78, 0xd0, 0xee, 0x37, 0x84, 0x61, 0x6c, - 0xe7, 0xf2, 0x30, 0x90, 0xd9, 0x3f, 0x49, 0xb5, 0xee, 0x56, 0xb7, 0x32, 0x5e, 0x83, 0xfd, 0x8f, 0x70, 0xa4, 0x8f, - 0xc8, 0x51, 0xc5, 0x81, 0xa9, 0xb7, 0x28, 0x4a, 0xa7, 0x40, 0x2a, 0x95, 0xb7, 0x04, 0xe1, 0xb4, 0x10, 0xe1, 0xed, - 0xef, 0xf1, 0x0f, 0x8a, 0x25, 0x9e, 0x97, 0x1c, 0xe7, 0xd9, 0x7d, 0x39, 0xa2, 0x04, 0xbf, 0x8c, 0xde, 0x03, 0x1d, - 0x0b, 0x0a, 0x2d, 0x34, 0x15, 0x3d, 0x4d, 0xd5, 0x44, 0xb6, 0xe6, 0xa5, 0x62, 0x5a, 0x66, 0xd4, 0x88, 0x61, 0x36, - 0x24, 0x72, 0x6a, 0x2b, 0x9b, 0x97, 0xbb, 0xaa, 0x36, 0x2e, 0xda, 0x82, 0xc5, 0x2a, 0xb0, 0xb8, 0x5c, 0x3a, 0x75, - 0x54, 0x13, 0x66, 0xc4, 0x31, 0x10, 0x66, 0x46, 0x42, 0x45, 0x4d, 0xb3, 0x96, 0x6d, 0x1c, 0xb4, 0x9a, 0x4f, 0xa4, - 0x75, 0xf3, 0x1a, 0x1c, 0xa6, 0x0b, 0x41, 0x36, 0x37, 0x7d, 0x0a, 0x58, 0xce, 0xae, 0x1c, 0xc8, 0xc0, 0xd0, 0x8f, - 0x65, 0xae, 0x6c, 0x95, 0xd4, 0xba, 0x01, 0xbf, 0xe8, 0x8e, 0x6c, 0x59, 0x85, 0xba, 0xf5, 0xf7, 0x46, 0xae, 0xd1, - 0xd3, 0x74, 0x5b, 0xae, 0x51, 0x4d, 0xdb, 0xdd, 0x69, 0xa3, 0xbb, 0xf3, 0x52, 0xe5, 0x58, 0x9b, 0xab, 0xfc, 0x86, - 0xe1, 0x3a, 0x40, 0x9b, 0x12, 0xcd, 0x9a, 0xab, 0x9c, 0x16, 0xc5, 0x79, 0x79, 0x9a, 0x40, 0xa4, 0xee, 0x9c, 0x4b, - 0xfa, 0x57, 0x56, 0xa3, 0x38, 0x94, 0xeb, 0x7c, 0x4f, 0x26, 0x71, 0x7a, 0xe9, 0xc7, 0xef, 0x61, 0xbc, 0xea, 0xe5, - 0xf3, 0xdb, 0x30, 0xf3, 0x39, 0x55, 0xdc, 0xa5, 0x82, 0xe1, 0x7b, 0x03, 0x86, 0xef, 0x25, 0x9f, 0xae, 0xda, 0xe3, - 0xc5, 0xcb, 0xb2, 0x03, 0xef, 0xbc, 0xd0, 0x2c, 0xe3, 0x96, 0x6f, 0x1e, 0x63, 0x95, 0x85, 0xdd, 0x96, 0x2c, 0xec, - 0x96, 0x3b, 0xab, 0x5d, 0x39, 0xce, 0x0f, 0x9b, 0x7b, 0x59, 0xe7, 0x6c, 0x3f, 0x54, 0x1b, 0xff, 0x07, 0xef, 0xce, - 0x36, 0x06, 0x97, 0xdb, 0x77, 0xf7, 0x45, 0xb2, 0x8a, 0x04, 0xf9, 0x25, 0x24, 0x1d, 0x70, 0xd2, 0x37, 0x0e, 0x1d, - 0x54, 0x72, 0x4a, 0xe7, 0x01, 0x39, 0xc1, 0x3c, 0xe7, 0xe9, 0x54, 0xf5, 0x99, 0xab, 0x93, 0x46, 0xe2, 0x25, 0xb8, - 0xa2, 0x45, 0xac, 0xdd, 0xab, 0x9f, 0xe5, 0x5a, 0x7c, 0x64, 0x49, 0xe8, 0xe5, 0x58, 0x49, 0x91, 0xdc, 0xcb, 0x0a, - 0xa2, 0xb3, 0x8d, 0xd7, 0xdf, 0xe1, 0x31, 0x4b, 0x58, 0x1e, 0xd1, 0xcc, 0x49, 0xd1, 0x62, 0xdb, 0x60, 0x29, 0x04, - 0x64, 0xe4, 0x60, 0xf8, 0xaf, 0xd5, 0xa9, 0x3f, 0x17, 0x7a, 0x03, 0x3f, 0xd0, 0x94, 0xf2, 0x28, 0x0d, 0x21, 0x2d, - 0xc5, 0x0d, 0xcb, 0x43, 0x4d, 0x7b, 0x7b, 0x3b, 0x8e, 0x2d, 0xdc, 0x12, 0x70, 0x00, 0xdc, 0x7c, 0x83, 0x06, 0x0b, - 0x38, 0x9f, 0x53, 0x0d, 0x4d, 0xd1, 0x82, 0xae, 0x1e, 0x65, 0xe1, 0xee, 0x47, 0x7a, 0x8b, 0x13, 0x54, 0x14, 0x9e, - 0x84, 0xda, 0x1e, 0x33, 0x1a, 0x87, 0x36, 0xfe, 0x48, 0x6f, 0xbd, 0xf2, 0xcc, 0xb8, 0x38, 0xe2, 0x2c, 0x16, 0xd0, - 0x4e, 0xaf, 0x13, 0x1b, 0x57, 0x83, 0x78, 0x8b, 0x02, 0xa7, 0x19, 0x9b, 0x00, 0x71, 0x7e, 0x43, 0x6f, 0x3d, 0xd9, - 0x1f, 0x33, 0xce, 0xeb, 0xa1, 0x85, 0x46, 0xbd, 0x6b, 0x14, 0x9b, 0xcb, 0xa0, 0x0c, 0x8a, 0xa1, 0x68, 0x3b, 0x22, - 0xb5, 0x7a, 0x95, 0x79, 0x88, 0x50, 0x71, 0xdf, 0xa9, 0xe0, 0x6f, 0x4c, 0xd1, 0xc6, 0x6b, 0x99, 0xaf, 0x2b, 0x8d, - 0x28, 0x34, 0xa8, 0x32, 0x3d, 0x76, 0x9d, 0x44, 0xef, 0x3a, 0x75, 0x08, 0xc1, 0x70, 0x84, 0x7d, 0xc3, 0x55, 0xa7, - 0xde, 0x5f, 0x65, 0x42, 0x48, 0x15, 0x49, 0x7a, 0x51, 0xb5, 0xb3, 0x76, 0x1d, 0xc0, 0x3b, 0x24, 0xb4, 0xf8, 0xe2, - 0x4c, 0x66, 0xa1, 0xb3, 0x45, 0xff, 0xc6, 0x89, 0xb3, 0xd0, 0x53, 0xf0, 0x12, 0x13, 0x8b, 0xbc, 0x00, 0x2a, 0x54, - 0xf4, 0x25, 0x13, 0x00, 0xd9, 0xd8, 0x61, 0x6b, 0x52, 0x33, 0x13, 0x52, 0xd3, 0x35, 0x30, 0xbe, 0x45, 0x4a, 0x52, - 0x81, 0x0c, 0xa1, 0x44, 0x0a, 0xa1, 0xa7, 0x16, 0x57, 0x91, 0x90, 0xb9, 0xa0, 0xe5, 0x09, 0x3a, 0xb9, 0xe6, 0x59, - 0x0d, 0x2c, 0x47, 0xf4, 0x83, 0x0a, 0x0f, 0xa6, 0x44, 0x65, 0x85, 0xa2, 0x3c, 0x9a, 0xad, 0xd3, 0x5b, 0x9d, 0xd4, - 0xd5, 0xd3, 0x22, 0x1a, 0x25, 0x4e, 0x84, 0x16, 0x89, 0x13, 0xe1, 0x0c, 0xd2, 0x11, 0xd3, 0xa2, 0x84, 0x9f, 0x9a, - 0xab, 0x51, 0x4b, 0x56, 0xde, 0x7c, 0xca, 0x0f, 0x94, 0x79, 0x0e, 0x29, 0x9a, 0x38, 0xd1, 0x3c, 0x25, 0x71, 0xc4, - 0x71, 0x3b, 0x63, 0xd9, 0xbe, 0x57, 0x09, 0x3a, 0x0a, 0xb0, 0xbf, 0x71, 0x67, 0x61, 0xcc, 0xc2, 0x3c, 0xd1, 0xad, - 0x4e, 0xfd, 0xa9, 0x60, 0x5f, 0x95, 0x43, 0xea, 0xe4, 0x64, 0x45, 0xe2, 0xdc, 0x9d, 0x6a, 0xf9, 0xcb, 0x9c, 0x66, - 0xb7, 0x67, 0x14, 0x52, 0x9d, 0x53, 0x38, 0xf0, 0x5b, 0x2d, 0x43, 0x95, 0xa7, 0x3e, 0xc8, 0x84, 0xb2, 0x52, 0xd4, - 0xcf, 0x01, 0xae, 0x9e, 0x12, 0x2c, 0x44, 0xb4, 0xd1, 0x70, 0xc4, 0xc8, 0xdd, 0x42, 0xb7, 0x9e, 0x9f, 0xa4, 0x3d, - 0x06, 0xfe, 0xb5, 0x0a, 0xd3, 0x2a, 0x58, 0x80, 0x53, 0xf3, 0x4c, 0xea, 0x30, 0x1f, 0xad, 0x7a, 0x65, 0xa0, 0x08, - 0xc2, 0x77, 0xd9, 0xf6, 0xa9, 0x6e, 0x4a, 0x9a, 0xdd, 0x3e, 0xd5, 0x5a, 0xd0, 0x4f, 0x24, 0xfc, 0x60, 0x35, 0x4e, - 0x79, 0x82, 0x99, 0x15, 0x05, 0x2a, 0x00, 0xbc, 0xbf, 0xf4, 0x1c, 0xe7, 0x2f, 0x2a, 0x65, 0xd0, 0x85, 0x58, 0xec, - 0x59, 0x9c, 0x6a, 0x26, 0x5e, 0x8d, 0xff, 0x97, 0xb5, 0xf1, 0xff, 0x62, 0x9c, 0x3a, 0x05, 0xd3, 0x68, 0x92, 0xd0, - 0x50, 0xb3, 0x4e, 0x24, 0x09, 0x50, 0xe8, 0x6d, 0x19, 0x27, 0x1f, 0x2f, 0x3c, 0xd0, 0xb8, 0x16, 0xe3, 0x34, 0xe1, - 0xcd, 0xb1, 0x3f, 0x65, 0xf1, 0xad, 0x37, 0x67, 0xcd, 0x69, 0x9a, 0xa4, 0xf9, 0xcc, 0x0f, 0x28, 0xce, 0x6f, 0x73, - 0x4e, 0xa7, 0xcd, 0x39, 0xc3, 0xcf, 0x69, 0x7c, 0x45, 0x39, 0x0b, 0x7c, 0x6c, 0x9f, 0x64, 0xcc, 0x8f, 0xad, 0xd7, - 0x7e, 0x96, 0xa5, 0xd7, 0x36, 0x7e, 0x97, 0x5e, 0xa6, 0x3c, 0xc5, 0x6f, 0x6e, 0x6e, 0x27, 0x34, 0xc1, 0x1f, 0x2e, - 0xe7, 0x09, 0x9f, 0xe3, 0xdc, 0x4f, 0xf2, 0x66, 0x4e, 0x33, 0x36, 0xee, 0x05, 0x69, 0x9c, 0x66, 0x4d, 0xc8, 0xd8, - 0x9e, 0x52, 0x2f, 0x66, 0x93, 0x88, 0x5b, 0xa1, 0x9f, 0x7d, 0xec, 0x35, 0x9b, 0xb3, 0x8c, 0x4d, 0xfd, 0xec, 0xb6, - 0x29, 0x6a, 0x78, 0x5f, 0xb6, 0xf7, 0xfd, 0xc7, 0xe3, 0x83, 0x1e, 0xcf, 0xfc, 0x24, 0x67, 0xb0, 0x4c, 0x9e, 0x1f, - 0xc7, 0xd6, 0xfe, 0x61, 0x7b, 0x9a, 0xef, 0xc8, 0x40, 0x9e, 0x9f, 0xf0, 0xe2, 0x02, 0xbf, 0x07, 0xb8, 0xdd, 0x4b, - 0x9e, 0xe0, 0xcb, 0x39, 0xe7, 0x69, 0xb2, 0x08, 0xe6, 0x59, 0x9e, 0x66, 0xde, 0x2c, 0x65, 0x09, 0xa7, 0x59, 0xef, - 0x32, 0xcd, 0x42, 0x9a, 0x35, 0x33, 0x3f, 0x64, 0xf3, 0xdc, 0x3b, 0x98, 0xdd, 0xf4, 0x40, 0xb3, 0x98, 0x64, 0xe9, - 0x3c, 0x09, 0xd5, 0x58, 0x2c, 0x89, 0x68, 0xc6, 0xb8, 0xf9, 0x42, 0x5c, 0x64, 0xe2, 0xc5, 0x2c, 0xa1, 0x7e, 0xd6, - 0x9c, 0x40, 0x63, 0x30, 0x8b, 0xda, 0x21, 0x9d, 0xe0, 0x6c, 0x72, 0xe9, 0x3b, 0x9d, 0xee, 0x23, 0xac, 0xff, 0x77, - 0x0f, 0x91, 0xd5, 0xde, 0x5c, 0xdc, 0x69, 0xb7, 0xff, 0x84, 0x7a, 0x2b, 0xa3, 0x08, 0x80, 0xbc, 0xce, 0xec, 0xc6, - 0xca, 0x53, 0xc8, 0x68, 0xdb, 0xd4, 0xb2, 0x37, 0xf3, 0x43, 0xc8, 0x07, 0xf6, 0xba, 0xb3, 0x9b, 0x02, 0x66, 0xe7, - 0xc9, 0x14, 0x53, 0x35, 0x49, 0xf5, 0xb4, 0xf8, 0xad, 0x10, 0x1f, 0x6d, 0x86, 0xb8, 0xab, 0x21, 0xae, 0xb0, 0xde, - 0x0c, 0xe7, 0x99, 0x88, 0xad, 0x7a, 0x9d, 0x5c, 0x02, 0x12, 0xa5, 0x57, 0x34, 0xd3, 0x70, 0x88, 0x87, 0xdf, 0x0c, - 0x46, 0x77, 0x33, 0x18, 0x47, 0x9f, 0x02, 0x23, 0x4b, 0xc2, 0x45, 0x7d, 0x5d, 0x3b, 0x19, 0x9d, 0xf6, 0x22, 0x0a, - 0xf4, 0xe4, 0x75, 0xe1, 0xf7, 0x35, 0x0b, 0x79, 0x24, 0x7f, 0x0a, 0x72, 0xbe, 0x96, 0xef, 0x0e, 0xdb, 0x6d, 0xf9, - 0x9c, 0xb3, 0x5f, 0xa9, 0xd7, 0x71, 0xa1, 0x42, 0x71, 0x81, 0x7f, 0x28, 0x4f, 0xf3, 0xd6, 0xb9, 0x27, 0xfe, 0x8b, - 0x79, 0xcc, 0xd7, 0x48, 0x51, 0xac, 0x0e, 0x45, 0xe3, 0x54, 0xcb, 0x4a, 0x29, 0x7c, 0xc0, 0x6d, 0x27, 0xb8, 0x23, - 0x61, 0xfd, 0xf2, 0x18, 0x27, 0x1b, 0xfc, 0x45, 0xe6, 0x5d, 0x78, 0x10, 0xe9, 0x30, 0x52, 0x0d, 0xd3, 0x5e, 0xd6, - 0x27, 0xed, 0x5e, 0xd6, 0x6c, 0x22, 0x27, 0x25, 0xc9, 0x30, 0x53, 0xc9, 0x79, 0x0e, 0x1b, 0xa4, 0xc2, 0xd8, 0xce, - 0x91, 0x97, 0xc2, 0x59, 0xd3, 0xe5, 0xb2, 0x0a, 0x03, 0x30, 0x71, 0x5a, 0xe3, 0x07, 0xae, 0x2a, 0xe0, 0xdc, 0xe0, - 0xe4, 0xbe, 0xbe, 0xde, 0x25, 0xd1, 0xbc, 0x22, 0x4e, 0x03, 0x81, 0x39, 0x77, 0xe6, 0xf3, 0x08, 0xbc, 0x14, 0xa5, - 0xf8, 0xa9, 0x52, 0x98, 0xec, 0x96, 0x8d, 0x06, 0x49, 0x99, 0xdf, 0x06, 0x79, 0x7c, 0x49, 0x01, 0xbd, 0x5c, 0x72, - 0x02, 0x3d, 0x56, 0xfd, 0x7f, 0xe0, 0x86, 0xa4, 0x4e, 0x5c, 0x96, 0x04, 0xf1, 0x3c, 0xa4, 0xb9, 0xe8, 0xa1, 0x12, - 0xe7, 0x70, 0x37, 0x44, 0x59, 0x4b, 0x34, 0x81, 0xde, 0x45, 0x36, 0x0f, 0x54, 0x84, 0x5b, 0x54, 0xca, 0xe7, 0xa6, - 0x78, 0xae, 0xda, 0xbe, 0xae, 0x92, 0x45, 0xa1, 0xa5, 0x3b, 0x4f, 0xd8, 0x2f, 0x73, 0x7a, 0xce, 0x42, 0xe3, 0xe4, - 0x2e, 0x4d, 0x82, 0x34, 0xa4, 0x1f, 0xde, 0xbd, 0x80, 0x6c, 0xf7, 0x34, 0x01, 0x12, 0x4b, 0xa4, 0xbf, 0x0b, 0xe7, - 0x24, 0x71, 0x43, 0x7a, 0xc5, 0x02, 0x3a, 0xb8, 0xd8, 0x5d, 0x6c, 0xac, 0x28, 0x5f, 0xa3, 0xa2, 0x75, 0x21, 0x92, - 0xfe, 0x04, 0x94, 0x17, 0xbb, 0x8b, 0x4b, 0x5e, 0xb4, 0x76, 0x17, 0x89, 0x1b, 0xa6, 0x53, 0x9f, 0x25, 0xf0, 0x3b, - 0x2f, 0x76, 0x17, 0x0c, 0x7e, 0xf0, 0xe2, 0xa2, 0xa8, 0x12, 0x45, 0x4b, 0x88, 0x8c, 0x29, 0x28, 0xdc, 0x75, 0x90, - 0xfb, 0x73, 0xca, 0x12, 0x51, 0x74, 0x57, 0xcf, 0x54, 0xf7, 0x0a, 0x48, 0xfe, 0x95, 0x48, 0x83, 0x59, 0x9b, 0xcb, - 0xe7, 0xf7, 0x35, 0x97, 0x69, 0xc2, 0x99, 0x48, 0x8b, 0xd7, 0xe1, 0x9c, 0xc8, 0xcf, 0xcf, 0x03, 0x79, 0x12, 0x35, - 0xaf, 0x4e, 0x5d, 0xf8, 0x02, 0xb1, 0xd2, 0x02, 0xa6, 0x99, 0x30, 0xf6, 0xe9, 0xf6, 0xa3, 0x92, 0xc9, 0x5d, 0xc6, - 0x5f, 0x49, 0x55, 0x79, 0x3a, 0xcf, 0x02, 0x88, 0xf5, 0x2a, 0x95, 0x62, 0xdd, 0x2b, 0x66, 0x0b, 0xfd, 0xcd, 0xc6, - 0xdc, 0x48, 0xb2, 0xe5, 0x70, 0xa6, 0xaf, 0xba, 0xb6, 0x83, 0x8a, 0x78, 0x22, 0xac, 0x19, 0x13, 0xab, 0x77, 0xce, - 0x42, 0x08, 0xbc, 0xb0, 0x50, 0x25, 0x2c, 0xd6, 0x26, 0x09, 0x2a, 0x52, 0x28, 0x32, 0x48, 0xe1, 0xb2, 0x9d, 0xb4, - 0x5a, 0x05, 0x42, 0x88, 0x8c, 0xeb, 0x81, 0xf0, 0x6d, 0x76, 0xf6, 0xf6, 0xf2, 0xea, 0x44, 0x1b, 0x53, 0x38, 0x5f, - 0x2e, 0x39, 0x75, 0x72, 0x79, 0xea, 0x26, 0x22, 0xa0, 0x8c, 0x31, 0x2c, 0xdf, 0x78, 0x29, 0x2e, 0x7b, 0xf2, 0xf2, - 0xa2, 0x17, 0x09, 0x24, 0x4a, 0x94, 0x11, 0x8d, 0xd4, 0x13, 0xad, 0x92, 0x61, 0xf3, 0x75, 0x79, 0x90, 0xbf, 0x86, - 0xf5, 0xf6, 0xca, 0xe2, 0x48, 0xab, 0x2a, 0x5a, 0x2d, 0xcd, 0xd3, 0x8c, 0x3b, 0x8e, 0x8f, 0x03, 0x44, 0xfa, 0xbe, - 0x98, 0xfd, 0xb1, 0xcc, 0xf7, 0x18, 0x34, 0x3b, 0x5e, 0xa7, 0xf4, 0x87, 0xd4, 0xce, 0x57, 0xcb, 0x6c, 0x33, 0x75, - 0x46, 0x17, 0xf0, 0x84, 0xcb, 0xdf, 0x0a, 0x7d, 0x55, 0x81, 0x9c, 0x5d, 0xf5, 0x5c, 0x4e, 0x12, 0x2b, 0x86, 0x26, - 0x95, 0x01, 0xa7, 0x06, 0xd5, 0x30, 0x1b, 0x61, 0xb6, 0x65, 0x6c, 0x54, 0x54, 0x88, 0x28, 0x37, 0xf7, 0x85, 0x54, - 0x82, 0xce, 0x0d, 0xea, 0xbe, 0x60, 0xda, 0x8d, 0x57, 0xa7, 0xbb, 0x42, 0xa1, 0xc8, 0xe0, 0x0c, 0x9b, 0xaa, 0x49, - 0x58, 0x6e, 0x49, 0xb2, 0x91, 0x78, 0x5d, 0xf9, 0x48, 0x25, 0x6d, 0x6c, 0xae, 0x22, 0x92, 0x21, 0x37, 0x01, 0x06, - 0x8e, 0x81, 0x9c, 0xeb, 0x29, 0x00, 0x8f, 0x19, 0x53, 0x38, 0xa9, 0xa4, 0x38, 0x0e, 0x5e, 0x48, 0xed, 0xde, 0xb3, - 0xdf, 0xbe, 0x39, 0x7b, 0x6f, 0x63, 0xb8, 0xea, 0x8c, 0x66, 0xb9, 0xb7, 0xb0, 0x55, 0x8e, 0x61, 0x13, 0xe2, 0xd5, - 0xb6, 0x67, 0xfb, 0x33, 0x38, 0xb4, 0x2d, 0x98, 0x6a, 0xeb, 0xa6, 0x79, 0x7d, 0x7d, 0xdd, 0x84, 0x13, 0x65, 0xcd, - 0x79, 0x16, 0x4b, 0x76, 0x13, 0xda, 0x45, 0x81, 0x5c, 0x1e, 0xd1, 0xa4, 0xbc, 0x0c, 0x29, 0x8d, 0xa9, 0x1b, 0xa7, - 0x13, 0x79, 0x1e, 0x76, 0xd5, 0x3d, 0x11, 0x5f, 0x1c, 0x8b, 0x4b, 0xbe, 0xfa, 0xc7, 0x5c, 0x5e, 0xaf, 0xc6, 0x33, - 0xf8, 0xd9, 0x87, 0xe0, 0xd5, 0x71, 0x8b, 0x47, 0xe2, 0xe1, 0x0c, 0x76, 0x93, 0x78, 0xda, 0x5d, 0xac, 0x51, 0xdd, - 0x00, 0xba, 0x88, 0xfa, 0x72, 0x6a, 0xb9, 0xa8, 0x75, 0xe1, 0xc5, 0x17, 0x17, 0xc5, 0x71, 0x0b, 0xfa, 0x6a, 0xe9, - 0x7e, 0x2f, 0xd3, 0xf0, 0x56, 0xb7, 0x2f, 0x29, 0x11, 0x2e, 0x7b, 0x4a, 0x48, 0x1f, 0xba, 0x80, 0x71, 0xc3, 0xbe, - 0xc0, 0x99, 0x62, 0xa1, 0xc3, 0xea, 0xa1, 0x18, 0x59, 0xc0, 0x30, 0x0b, 0x28, 0x01, 0x72, 0x83, 0xce, 0xc3, 0xb2, - 0x81, 0xd8, 0xed, 0xb2, 0x68, 0x1b, 0x80, 0xb2, 0x62, 0xb5, 0x7f, 0xa4, 0x9b, 0xbb, 0x22, 0x0b, 0x0d, 0x71, 0x68, - 0x02, 0x7f, 0x81, 0xe0, 0x5f, 0x01, 0xf8, 0x71, 0x4b, 0xa2, 0xe9, 0xc2, 0xbc, 0x76, 0x46, 0x5e, 0x08, 0x51, 0x22, - 0x73, 0x98, 0x71, 0xfc, 0x9e, 0xe3, 0x8f, 0x17, 0xa2, 0xaa, 0xd6, 0x12, 0x40, 0x7d, 0x05, 0x6d, 0xaa, 0xad, 0xd5, - 0xc1, 0x20, 0x8d, 0x63, 0x7f, 0x96, 0x53, 0x4f, 0xff, 0x50, 0x0a, 0x03, 0xe8, 0x1d, 0xeb, 0x1a, 0x9a, 0xca, 0x7b, - 0x3a, 0x05, 0x3d, 0x6e, 0x5d, 0x7d, 0xbc, 0xf2, 0x33, 0xa7, 0xd9, 0x0c, 0x9a, 0x97, 0x13, 0x54, 0xf0, 0x68, 0x61, - 0xaa, 0x1b, 0x0f, 0xdb, 0xed, 0x1e, 0x24, 0xa9, 0x36, 0xfd, 0x98, 0x4d, 0x12, 0x2f, 0xa6, 0x63, 0x5e, 0x70, 0x38, - 0x3d, 0xb8, 0xd0, 0xfa, 0x9d, 0xdb, 0x3d, 0xcc, 0xe8, 0xd4, 0x72, 0xe1, 0xef, 0xdd, 0x03, 0x17, 0x3c, 0xf4, 0x12, - 0x1e, 0x35, 0x45, 0x32, 0x34, 0x1c, 0xe5, 0xe0, 0x51, 0xed, 0x79, 0x61, 0x0c, 0x14, 0x50, 0xd0, 0x7d, 0x0b, 0x9e, - 0x59, 0x3c, 0xc2, 0x3c, 0x33, 0xeb, 0x25, 0x68, 0xb1, 0x36, 0x83, 0x75, 0x15, 0x6c, 0x1f, 0x15, 0xb9, 0xb0, 0x58, - 0x16, 0x6b, 0x78, 0x31, 0x54, 0xe9, 0x82, 0x25, 0xb3, 0x39, 0x1f, 0x0a, 0xcf, 0x7f, 0x06, 0x67, 0x48, 0x46, 0xd8, - 0x28, 0x01, 0x78, 0x46, 0xaa, 0x7d, 0xe0, 0xc7, 0x81, 0x03, 0x9d, 0x58, 0x4d, 0xeb, 0x28, 0xa3, 0x53, 0xd4, 0x9b, - 0xb2, 0xa4, 0x29, 0xdf, 0x1d, 0x1a, 0xba, 0x9b, 0xfb, 0x08, 0x9e, 0x0a, 0x57, 0xf4, 0x86, 0x45, 0x82, 0xef, 0x86, - 0x79, 0x5d, 0x8c, 0x8a, 0xa2, 0x97, 0x72, 0x67, 0xf8, 0xc2, 0x41, 0x23, 0xfc, 0xab, 0x71, 0x89, 0x8d, 0xad, 0xa9, - 0xda, 0xc6, 0x5d, 0xb4, 0xa5, 0x8a, 0x49, 0x97, 0xa2, 0xda, 0xaf, 0x04, 0x2a, 0xbe, 0x74, 0x6c, 0x9a, 0xcf, 0x9a, - 0x92, 0xfd, 0x34, 0x05, 0xf9, 0xd8, 0xd0, 0x14, 0x29, 0x77, 0x36, 0xa5, 0x0b, 0xc1, 0x59, 0xd4, 0x39, 0x16, 0xe9, - 0x71, 0x19, 0x95, 0xe7, 0x9e, 0xd4, 0xb3, 0x79, 0xd2, 0x09, 0xd5, 0xb6, 0xfe, 0xc5, 0x49, 0x9d, 0x4d, 0x81, 0xfc, - 0x2f, 0xef, 0xfa, 0xf3, 0xe3, 0x18, 0x06, 0xbc, 0xd0, 0x4a, 0x83, 0x79, 0x35, 0xca, 0x90, 0x8f, 0x1c, 0x54, 0xa8, - 0x3d, 0xf3, 0x44, 0xe8, 0xdd, 0xc6, 0x05, 0x83, 0x3b, 0x5c, 0x47, 0xd4, 0xe4, 0x09, 0x66, 0x06, 0x39, 0x01, 0xb5, - 0xdc, 0xf1, 0x5e, 0xc5, 0x66, 0xa4, 0xd6, 0x6e, 0x89, 0x09, 0x11, 0x3b, 0x4b, 0x42, 0xdb, 0xfa, 0x73, 0x10, 0xb3, - 0xe0, 0x23, 0xb1, 0x77, 0x17, 0x0e, 0x5a, 0x3f, 0x1a, 0x2a, 0x76, 0xa8, 0xe6, 0xb9, 0xa8, 0x1e, 0x6d, 0xc8, 0x5c, - 0x83, 0x9d, 0xca, 0xdb, 0x83, 0xec, 0x3e, 0xa8, 0x36, 0xc7, 0x2d, 0x39, 0x4e, 0xff, 0xa2, 0x38, 0xaf, 0x6e, 0x05, - 0xab, 0xa0, 0x00, 0x34, 0xcb, 0x72, 0x4b, 0xd0, 0x1f, 0xb1, 0xe5, 0x16, 0xaa, 0x59, 0x80, 0xd8, 0xa4, 0x7d, 0x64, - 0x5b, 0x92, 0xc1, 0x00, 0x9c, 0x5c, 0xf1, 0x1a, 0xdb, 0xfa, 0x73, 0x59, 0x46, 0x4b, 0xb7, 0x8f, 0xc8, 0x5b, 0x21, - 0x36, 0x8c, 0x05, 0xb6, 0xbe, 0x1b, 0x52, 0xee, 0xb3, 0x58, 0x36, 0xe9, 0x69, 0x2f, 0xc5, 0xca, 0x8c, 0x96, 0xcb, - 0xbc, 0x3e, 0x17, 0x56, 0xc7, 0xa0, 0x98, 0xd9, 0x71, 0xab, 0x82, 0x5b, 0xcc, 0x4c, 0xec, 0x0f, 0x33, 0x7e, 0x5a, - 0xcd, 0x50, 0xbe, 0xb3, 0xfe, 0x1c, 0x88, 0x93, 0x55, 0x00, 0x60, 0xaa, 0x00, 0x84, 0xc8, 0xbe, 0x54, 0x42, 0x1c, - 0x9f, 0xa4, 0x2e, 0xf7, 0xb3, 0x09, 0xe5, 0x2b, 0x88, 0xf5, 0x65, 0x22, 0x6f, 0x4f, 0x47, 0xf1, 0xd7, 0xa0, 0x0d, - 0xea, 0xd0, 0x82, 0x9e, 0x5b, 0x0c, 0x40, 0x55, 0x25, 0x1b, 0x35, 0xde, 0x08, 0x81, 0xec, 0x13, 0x8b, 0x23, 0xb9, - 0x7d, 0x2a, 0xb8, 0xbd, 0x8c, 0xc3, 0x59, 0x62, 0x2c, 0x01, 0x62, 0x61, 0x5b, 0x03, 0x09, 0x39, 0x0d, 0x25, 0xcc, - 0x24, 0x13, 0xad, 0xd2, 0xe2, 0xb8, 0x25, 0x6b, 0x4b, 0x76, 0x2c, 0x2b, 0x01, 0x12, 0xc4, 0x3e, 0xad, 0x70, 0x00, - 0xc9, 0xdf, 0x26, 0x1e, 0x42, 0x76, 0x55, 0x12, 0x9b, 0x38, 0x63, 0xd6, 0x3f, 0x8e, 0xfd, 0x4b, 0x1a, 0xf7, 0x77, - 0x17, 0xd9, 0x72, 0xd9, 0x2e, 0x8e, 0x5b, 0xf2, 0xd1, 0x3a, 0x16, 0x7c, 0x43, 0xde, 0x0d, 0x2a, 0x96, 0x18, 0x0e, - 0x6e, 0x42, 0x4a, 0xac, 0xce, 0x05, 0xf3, 0x54, 0x07, 0x85, 0x6d, 0x89, 0x2c, 0x14, 0x51, 0xa9, 0xd4, 0x69, 0x0a, - 0xdb, 0x62, 0xe1, 0x7a, 0x59, 0xce, 0xe9, 0x0c, 0x4a, 0xa3, 0xe5, 0xb2, 0x53, 0xd8, 0xd6, 0x94, 0x25, 0xf0, 0x94, - 0x2d, 0x97, 0xe2, 0x4c, 0xe4, 0x94, 0x25, 0x4e, 0x1b, 0xc8, 0xd6, 0xb6, 0xa6, 0xfe, 0x8d, 0x98, 0xb0, 0x7e, 0xe3, - 0xdf, 0x38, 0x1d, 0xf5, 0xca, 0x2d, 0xf1, 0x93, 0x03, 0xc5, 0x55, 0x2b, 0xea, 0xab, 0x15, 0x0d, 0xf1, 0x5c, 0x9e, - 0xf6, 0x22, 0x4e, 0x48, 0xfc, 0xcd, 0x2b, 0x1a, 0xea, 0x15, 0x9d, 0x6f, 0x59, 0xd1, 0xf9, 0x1d, 0x2b, 0x1a, 0xa8, - 0xd5, 0xb3, 0x4a, 0xdc, 0xa5, 0xcb, 0x65, 0xa7, 0x5d, 0x61, 0xef, 0xb8, 0x15, 0xb2, 0x2b, 0x58, 0x0d, 0xd0, 0xd4, - 0x38, 0x9b, 0xd2, 0xcd, 0x44, 0x59, 0x47, 0x31, 0xfd, 0x2c, 0x4c, 0x56, 0x58, 0xc8, 0xea, 0x58, 0x30, 0xe9, 0xba, - 0x0c, 0x4c, 0xfe, 0x91, 0x94, 0xcd, 0x00, 0x0f, 0x39, 0xe0, 0x21, 0xd2, 0x77, 0x85, 0x3a, 0xf6, 0x7b, 0x1b, 0xdb, - 0x96, 0xad, 0xc9, 0xfa, 0xa2, 0x38, 0x07, 0x19, 0x21, 0xe6, 0x77, 0x2f, 0x5a, 0x84, 0xda, 0x76, 0x7f, 0x3b, 0xcd, - 0x41, 0x0e, 0xc1, 0x75, 0x9a, 0x85, 0xb6, 0x27, 0xab, 0x7e, 0x16, 0xaa, 0xa6, 0x2c, 0x51, 0x19, 0x69, 0x5b, 0x69, - 0xad, 0x7a, 0x6f, 0x52, 0x5c, 0xf7, 0xf0, 0x50, 0xd6, 0x98, 0xf9, 0x9c, 0xd3, 0x2c, 0x51, 0x94, 0x6b, 0xdb, 0xff, - 0x21, 0xa8, 0x70, 0x03, 0x5f, 0x09, 0xf4, 0x02, 0x68, 0x02, 0x54, 0x3a, 0xb7, 0xe2, 0xf9, 0x52, 0x3c, 0xed, 0x54, - 0xca, 0xe6, 0x2d, 0x32, 0xf5, 0x7e, 0x59, 0x04, 0x66, 0xc8, 0x7c, 0x4a, 0xc3, 0x73, 0xc1, 0xa0, 0x07, 0xf1, 0x85, - 0x52, 0x1e, 0x57, 0xc4, 0x5d, 0xd5, 0x00, 0xdb, 0x3f, 0xcd, 0xbb, 0x8f, 0x0e, 0x4e, 0x6d, 0x2c, 0x79, 0x7c, 0x3a, - 0x1e, 0xdb, 0xa8, 0xb0, 0xee, 0xd7, 0xac, 0x73, 0xf0, 0xd3, 0xfc, 0xeb, 0x67, 0xed, 0xaf, 0xcb, 0xc6, 0x09, 0x10, - 0x91, 0x4a, 0x82, 0xd0, 0xa2, 0xca, 0x80, 0x57, 0xcf, 0x68, 0xec, 0x27, 0xdb, 0xa7, 0x33, 0x34, 0xa7, 0x93, 0xcf, - 0x28, 0x0d, 0x81, 0x38, 0xf1, 0x5a, 0xe9, 0x79, 0x4c, 0xaf, 0xa8, 0xbe, 0xa1, 0x71, 0xc3, 0x60, 0x1b, 0x5a, 0x04, - 0xe9, 0x3c, 0xe1, 0x2a, 0x1b, 0x44, 0xb1, 0x5a, 0x63, 0x4a, 0x17, 0x62, 0x0e, 0xa6, 0x3a, 0x7f, 0x2b, 0xe5, 0x5c, - 0x5d, 0x7a, 0x15, 0x17, 0xd8, 0x36, 0x00, 0xd8, 0x0a, 0xd9, 0x60, 0x4b, 0xb9, 0xd7, 0xc6, 0xed, 0x6d, 0xb0, 0xe1, - 0x0e, 0xf2, 0x6c, 0x7b, 0xa4, 0xf1, 0x24, 0x1c, 0xba, 0xb5, 0x4b, 0x35, 0xb6, 0xe2, 0xeb, 0x93, 0x18, 0xb8, 0xcc, - 0xa0, 0xb3, 0x84, 0xe6, 0xf9, 0x56, 0x04, 0x94, 0x8b, 0x88, 0xed, 0xaa, 0xb6, 0xbd, 0xa5, 0x17, 0xdc, 0xc6, 0xb0, - 0xc3, 0x04, 0xc0, 0x65, 0x58, 0x59, 0xd5, 0xa2, 0xe3, 0x31, 0x0d, 0x4a, 0x7f, 0x38, 0x04, 0x08, 0xc7, 0x2c, 0xe6, - 0x10, 0x27, 0x13, 0x01, 0x2c, 0xfb, 0x75, 0x9a, 0x50, 0x1b, 0xe9, 0x94, 0x57, 0x05, 0xbf, 0x92, 0xff, 0x9b, 0xe1, - 0x91, 0x3d, 0xd6, 0x61, 0x51, 0xa3, 0x2c, 0x97, 0xda, 0x5d, 0x53, 0x2b, 0xaf, 0x23, 0x32, 0x15, 0xfe, 0x98, 0x6d, - 0x1b, 0xe8, 0x7e, 0xdb, 0x64, 0xd1, 0xf9, 0xfa, 0xb0, 0xd3, 0x2e, 0x6c, 0x6c, 0x43, 0x77, 0xf7, 0xdd, 0x25, 0xa2, - 0xd5, 0x3e, 0xb4, 0x9a, 0x27, 0x9f, 0xd3, 0xae, 0xdb, 0x79, 0xdc, 0xb1, 0xb1, 0xbc, 0x6b, 0x01, 0x15, 0x25, 0x33, - 0x08, 0xc0, 0x43, 0xfc, 0xbb, 0xa7, 0x52, 0xef, 0xfc, 0x7e, 0xf0, 0x3c, 0xec, 0xb4, 0x6d, 0x6c, 0xe7, 0x3c, 0x9d, - 0x7d, 0xc6, 0x14, 0xf6, 0x6d, 0x6c, 0x07, 0x71, 0x9a, 0x53, 0x73, 0x0e, 0x52, 0x9d, 0xfd, 0xfd, 0x93, 0x90, 0x10, - 0xcd, 0x32, 0x9a, 0xe7, 0x96, 0xd9, 0xbf, 0x22, 0xa5, 0x4f, 0x30, 0xcc, 0x8d, 0x14, 0x97, 0x53, 0x2e, 0xf0, 0x22, - 0xaf, 0x41, 0x30, 0xa9, 0x4a, 0x96, 0xad, 0x11, 0x9b, 0x10, 0x01, 0x25, 0x63, 0x93, 0xda, 0xd5, 0x27, 0x47, 0xde, - 0xb0, 0xf5, 0xe4, 0xc0, 0x32, 0x70, 0xbe, 0x3e, 0x40, 0xad, 0x64, 0xca, 0x92, 0xf3, 0x0d, 0xa5, 0xfe, 0xcd, 0x86, - 0x52, 0x50, 0xd9, 0x4a, 0xe8, 0xd4, 0x15, 0x3d, 0x9f, 0xc6, 0x7a, 0xa5, 0xf8, 0x98, 0x20, 0x86, 0xc2, 0xff, 0xf8, - 0x09, 0x48, 0x8d, 0x65, 0x10, 0x3d, 0xfc, 0xf6, 0xe1, 0xa0, 0xe4, 0x73, 0x86, 0x2b, 0x7b, 0xf9, 0x7d, 0x33, 0x84, - 0xd2, 0x26, 0x38, 0xf9, 0xe3, 0xcf, 0x9a, 0x2b, 0xbd, 0xf9, 0x34, 0xc1, 0x19, 0x5a, 0xd5, 0xef, 0x58, 0x7a, 0x75, - 0xd4, 0x7f, 0x75, 0xed, 0x37, 0x14, 0x2b, 0xc5, 0xa7, 0x5c, 0xff, 0x20, 0x66, 0xd3, 0x8a, 0x04, 0xd6, 0xc1, 0x14, - 0x1a, 0x0f, 0x64, 0x7c, 0x99, 0x9d, 0x48, 0xd5, 0xe7, 0x1c, 0xce, 0xb1, 0xc2, 0x55, 0x21, 0xf3, 0x8c, 0x9e, 0xc7, - 0xe9, 0xf5, 0xea, 0xe5, 0x67, 0xdb, 0x2b, 0x47, 0x6c, 0x12, 0x19, 0x87, 0xd3, 0x28, 0x29, 0x17, 0xe1, 0xce, 0x01, - 0x8a, 0x7f, 0xf9, 0x67, 0xd7, 0xfd, 0x97, 0x7f, 0xfe, 0x64, 0x55, 0xe8, 0xbe, 0xb8, 0xc0, 0xbc, 0xea, 0x76, 0xfb, - 0xee, 0xda, 0x3c, 0x52, 0x1d, 0xe7, 0x9b, 0xeb, 0xac, 0x2d, 0x02, 0xbc, 0x5f, 0x5b, 0x82, 0xb5, 0x42, 0xb9, 0xfb, - 0xac, 0xdf, 0x02, 0x18, 0xcc, 0xeb, 0x93, 0x90, 0x41, 0xa5, 0xdf, 0x05, 0xda, 0x05, 0xf2, 0xee, 0xb5, 0x22, 0xbf, - 0x1d, 0xc3, 0x9f, 0x9a, 0xc3, 0xef, 0x04, 0x5f, 0xf9, 0x27, 0xe2, 0x8b, 0x8b, 0x32, 0x0b, 0xd1, 0x6c, 0x0a, 0x77, - 0x1c, 0x0c, 0xd6, 0x4a, 0x94, 0xe2, 0xe1, 0xb5, 0x51, 0x5f, 0x9c, 0xa1, 0x24, 0xf1, 0xc5, 0x2b, 0xb8, 0xd8, 0xe8, - 0xf8, 0x32, 0xd3, 0xce, 0xd6, 0x3b, 0x84, 0x03, 0x74, 0x51, 0x9f, 0x95, 0xe8, 0x74, 0x4d, 0x32, 0x40, 0x29, 0x98, - 0x1b, 0x00, 0x26, 0x8e, 0x2f, 0x94, 0xb5, 0x79, 0x2a, 0xdd, 0x30, 0xde, 0x2a, 0x69, 0x2b, 0xf7, 0x4c, 0x0d, 0xe9, - 0xd8, 0x7a, 0x2f, 0xf0, 0x25, 0x2a, 0xd3, 0xca, 0xba, 0x17, 0xae, 0x2e, 0xb0, 0x23, 0x4a, 0xf6, 0x73, 0xe5, 0xc7, - 0x57, 0xf7, 0x63, 0x7c, 0xdb, 0x05, 0xea, 0xd2, 0x5a, 0xfe, 0xa3, 0x55, 0x82, 0x65, 0x73, 0xb9, 0x49, 0x1f, 0xb8, - 0xf6, 0x39, 0xcd, 0xce, 0x23, 0x48, 0x84, 0xca, 0x3e, 0xc1, 0x9c, 0x60, 0xa5, 0x31, 0x15, 0x7f, 0x19, 0x51, 0x17, - 0x49, 0xff, 0x83, 0x38, 0x15, 0x83, 0x2c, 0x46, 0x18, 0xca, 0x58, 0x84, 0xff, 0xcf, 0xb7, 0xfe, 0xc3, 0xf0, 0xad, - 0xbb, 0x87, 0xa8, 0x9d, 0x91, 0xfe, 0xec, 0x85, 0xfc, 0x8f, 0xcd, 0xee, 0x72, 0xc1, 0xee, 0x7e, 0x03, 0xa3, 0xcb, - 0xff, 0x31, 0x8c, 0x4e, 0xd8, 0xc8, 0x9a, 0xd3, 0xad, 0x85, 0x9a, 0x6f, 0x5d, 0xff, 0xda, 0xbf, 0xad, 0xf6, 0x55, - 0x7c, 0x71, 0x72, 0xed, 0xdf, 0x56, 0x8b, 0xb0, 0x9d, 0x5d, 0xac, 0xf6, 0x31, 0xb0, 0xdf, 0xbc, 0xb6, 0x3d, 0xfb, - 0xcd, 0xd7, 0x5f, 0xdb, 0xf8, 0x22, 0xa7, 0x7c, 0x00, 0x85, 0x64, 0x77, 0xb1, 0xb3, 0x5a, 0x11, 0xdc, 0x28, 0x30, - 0x45, 0x11, 0xf6, 0x82, 0xa4, 0x43, 0xe3, 0x3d, 0xcb, 0xcf, 0xd3, 0xc4, 0x84, 0xe6, 0x2d, 0x58, 0xf6, 0x9f, 0x0b, - 0x8e, 0xe8, 0x65, 0x0d, 0x1e, 0x51, 0xba, 0x0a, 0x90, 0x28, 0xac, 0x41, 0x54, 0x5d, 0x19, 0x74, 0x37, 0xff, 0xaf, - 0xae, 0x45, 0x90, 0xb7, 0x7d, 0x44, 0x83, 0xf8, 0xe2, 0x73, 0xc4, 0x87, 0x1c, 0xac, 0xf2, 0xd8, 0x69, 0x77, 0xa7, - 0x5f, 0xec, 0x2e, 0xa2, 0xbd, 0x3d, 0x36, 0xb0, 0xb1, 0xb8, 0xa7, 0xa9, 0xd8, 0x24, 0x5c, 0x72, 0xf8, 0x93, 0xc1, - 0x9f, 0xb4, 0x62, 0xd4, 0x2c, 0x19, 0x67, 0x7e, 0x46, 0xc3, 0xed, 0x4c, 0xba, 0xbc, 0xdf, 0x48, 0x91, 0x86, 0x4c, - 0xc0, 0xce, 0xcf, 0x45, 0xea, 0xd1, 0x94, 0x81, 0x3e, 0xba, 0x63, 0x7e, 0xc5, 0x47, 0x5d, 0x88, 0x56, 0x7e, 0x04, - 0xc0, 0x44, 0x38, 0x25, 0x79, 0x99, 0xeb, 0x00, 0xb7, 0x6a, 0xaa, 0xec, 0x10, 0x6c, 0x23, 0xe1, 0x75, 0x0f, 0x49, - 0x5f, 0xa4, 0x3d, 0xbc, 0x48, 0xb8, 0x13, 0xba, 0x3c, 0x63, 0x53, 0x07, 0xe1, 0x4e, 0x1b, 0x21, 0xed, 0x6c, 0x08, - 0x49, 0x7f, 0x87, 0xe5, 0xaf, 0xfd, 0xd7, 0x4e, 0x28, 0x2e, 0xe2, 0x12, 0x9f, 0xee, 0x81, 0x43, 0x92, 0x4f, 0xe6, - 0xe3, 0x31, 0xcd, 0x1c, 0x7d, 0x00, 0xf0, 0xab, 0x03, 0x38, 0x63, 0x0c, 0x6f, 0x9f, 0xfa, 0xdc, 0xff, 0x96, 0xd1, - 0x6b, 0x27, 0x45, 0xbd, 0xac, 0xba, 0x9c, 0x31, 0xc4, 0x73, 0x44, 0xfa, 0x11, 0x24, 0xc6, 0xbf, 0x48, 0xf8, 0x7e, - 0xd7, 0x99, 0x7f, 0x75, 0x80, 0x43, 0xb8, 0xf2, 0x42, 0x67, 0x75, 0xcb, 0xbb, 0x4a, 0x3e, 0xb0, 0x84, 0x1f, 0xc9, - 0x63, 0x98, 0x29, 0x52, 0xee, 0xc3, 0x32, 0x23, 0xc6, 0xf2, 0xcb, 0x0e, 0x43, 0xd2, 0x0f, 0x1a, 0x44, 0x1e, 0xca, - 0x14, 0xb7, 0xec, 0x9e, 0x46, 0x7e, 0x76, 0x0a, 0x07, 0xbe, 0x01, 0xd0, 0x4b, 0x9e, 0xfa, 0x4e, 0x50, 0x7e, 0xc9, - 0xc9, 0x69, 0xfd, 0xd4, 0x68, 0x4d, 0xb0, 0x48, 0x8a, 0xa9, 0x8a, 0x5a, 0x50, 0x74, 0x6e, 0x16, 0x91, 0xc6, 0x6e, - 0x0b, 0xc3, 0x1e, 0xec, 0x6d, 0xf4, 0xd1, 0xea, 0xa5, 0x6b, 0x5e, 0x67, 0xfe, 0xac, 0x8c, 0x1b, 0x9c, 0xfa, 0x59, - 0xc6, 0x68, 0x66, 0x39, 0xcf, 0x7f, 0x45, 0xde, 0xbf, 0xfc, 0xf3, 0xe6, 0xf8, 0x81, 0x0a, 0x19, 0x58, 0x90, 0x5c, - 0xd2, 0x14, 0xe9, 0xd8, 0xc4, 0x0e, 0x64, 0x43, 0x5b, 0x87, 0x3b, 0xf6, 0x8f, 0xda, 0xed, 0xb6, 0x0a, 0x09, 0x74, - 0xe4, 0x4f, 0x88, 0x01, 0xc0, 0x4f, 0x78, 0x10, 0x51, 0x65, 0x62, 0xcb, 0x00, 0xe5, 0x51, 0x7b, 0x76, 0x63, 0xf7, - 0x61, 0x3b, 0x28, 0x28, 0xde, 0xd1, 0x19, 0xf5, 0xf9, 0x67, 0x8d, 0x9f, 0x89, 0x26, 0xe5, 0xf0, 0x1d, 0x3d, 0x74, - 0x35, 0xee, 0xca, 0xa0, 0x87, 0xab, 0x83, 0xbe, 0x67, 0x53, 0x71, 0x75, 0xd3, 0xb6, 0x51, 0x85, 0xa7, 0xba, 0x36, - 0x26, 0x97, 0x2d, 0x6c, 0x4b, 0x60, 0x3c, 0x4a, 0xe3, 0x90, 0x66, 0xc4, 0xa6, 0xee, 0xc4, 0xb5, 0x1e, 0xb7, 0xdb, - 0x6d, 0xdc, 0x3c, 0x38, 0x6c, 0xb7, 0xf1, 0xe1, 0xc3, 0x36, 0x6e, 0xc2, 0x1f, 0xd7, 0x75, 0x57, 0x60, 0xb8, 0x2b, - 0x6a, 0xdb, 0x69, 0x67, 0x74, 0xaa, 0x00, 0xbc, 0x33, 0xac, 0x58, 0xed, 0x09, 0xb8, 0x60, 0x5a, 0xed, 0x7b, 0x29, - 0xd9, 0xd4, 0x05, 0x07, 0x2a, 0x1d, 0x55, 0xf8, 0x0b, 0xd3, 0x2a, 0x68, 0x4a, 0xe5, 0xc5, 0x7f, 0x2f, 0x14, 0x21, - 0x78, 0xd6, 0x29, 0xdc, 0x5e, 0x2a, 0xe2, 0xa5, 0x90, 0x0a, 0x04, 0x1f, 0x48, 0xe3, 0x3e, 0x4b, 0xe0, 0xdb, 0x59, - 0x3a, 0x6a, 0xaa, 0x19, 0x55, 0xba, 0x92, 0x74, 0xfb, 0x40, 0x86, 0xa5, 0x37, 0x11, 0xc4, 0xe8, 0x01, 0xc2, 0xfe, - 0x7d, 0x1a, 0xa8, 0x15, 0x84, 0xfa, 0xc1, 0x7d, 0xea, 0x6b, 0xec, 0x8f, 0x1e, 0x88, 0xe4, 0xa4, 0x9d, 0x68, 0xb9, - 0xdc, 0xf1, 0x97, 0xcb, 0x9d, 0xe0, 0xfe, 0x33, 0x94, 0xcb, 0xab, 0x4f, 0x41, 0xc0, 0xcd, 0x9f, 0x12, 0xe8, 0x17, - 0x50, 0xee, 0x45, 0x58, 0x82, 0x24, 0x9f, 0x7c, 0xac, 0x06, 0x94, 0x8f, 0x41, 0xb1, 0x82, 0x94, 0x90, 0x44, 0xd2, - 0x3e, 0x5f, 0x2e, 0x15, 0xf1, 0xe3, 0x39, 0xf1, 0xcb, 0xa2, 0x8e, 0x8d, 0x67, 0x24, 0x28, 0x1f, 0x6d, 0x01, 0xf2, - 0x4c, 0x71, 0xa9, 0x0a, 0xe2, 0x6b, 0x3f, 0x4b, 0x4c, 0x80, 0x5f, 0xa7, 0x96, 0x1a, 0xd6, 0x9a, 0x65, 0xe9, 0x15, - 0x83, 0xe4, 0x97, 0x95, 0x81, 0xa7, 0x04, 0x2e, 0xfe, 0xea, 0x99, 0xa1, 0x70, 0xa3, 0x83, 0xf7, 0x9a, 0xcf, 0xc2, - 0x2d, 0x93, 0xe5, 0x04, 0xbd, 0x50, 0xcd, 0xcd, 0x9b, 0xeb, 0x69, 0xbd, 0xf3, 0xaf, 0xbd, 0x99, 0x7e, 0x78, 0x26, - 0xf3, 0x6c, 0xbc, 0x69, 0x79, 0xb2, 0xe6, 0x2d, 0x79, 0x0d, 0xb1, 0x1f, 0x5b, 0xf3, 0x6d, 0xb8, 0x67, 0x53, 0xf2, - 0xb8, 0x77, 0x2f, 0xcf, 0xa8, 0x9f, 0x05, 0xd1, 0x5b, 0x3f, 0xf3, 0xa7, 0x79, 0x6f, 0xac, 0x6f, 0xf1, 0xd2, 0x14, - 0x70, 0x3e, 0x16, 0x99, 0x4e, 0x49, 0x70, 0x6b, 0xe3, 0x10, 0xe1, 0xea, 0xbd, 0x84, 0x40, 0xfa, 0xb9, 0x6d, 0x3c, - 0x37, 0x5f, 0xc1, 0x3a, 0xdb, 0x78, 0x8a, 0xb0, 0x4c, 0x20, 0x7a, 0xfb, 0x47, 0xa6, 0x0e, 0x61, 0xc8, 0x75, 0xf1, - 0xc6, 0x6e, 0xf5, 0x95, 0x3b, 0x9d, 0x4c, 0xf4, 0x7e, 0x25, 0x99, 0x68, 0x03, 0x1a, 0xad, 0x8c, 0xe6, 0xb3, 0x34, - 0xc9, 0xa9, 0x8d, 0xdf, 0x43, 0x3b, 0x79, 0x15, 0xb3, 0xd9, 0x70, 0x8d, 0xe6, 0xca, 0xa6, 0xe2, 0x8d, 0x6c, 0x07, - 0x41, 0x9d, 0xf7, 0xdf, 0x97, 0x71, 0x7c, 0x1d, 0xdf, 0x11, 0x89, 0xe8, 0x8c, 0x6e, 0xc9, 0x95, 0xcd, 0xe9, 0x27, - 0x73, 0x65, 0xe3, 0x7b, 0xe5, 0xca, 0xe6, 0xf4, 0x8f, 0xce, 0x95, 0x65, 0xd4, 0xc8, 0x95, 0x05, 0x39, 0xf7, 0xf5, - 0xbd, 0x52, 0x2e, 0x75, 0x26, 0x5c, 0x7a, 0x9d, 0x93, 0x8e, 0x8a, 0x81, 0xc4, 0xe9, 0x04, 0xf2, 0x2d, 0xff, 0xf1, - 0xe9, 0x93, 0x71, 0x3a, 0x31, 0x93, 0x27, 0xe1, 0xc3, 0x24, 0x40, 0x76, 0x38, 0x23, 0x0b, 0xfb, 0xa7, 0x9b, 0xce, - 0x93, 0x61, 0xa7, 0xb7, 0xdf, 0x99, 0xda, 0x9e, 0x0d, 0x4e, 0x47, 0x51, 0xd0, 0xee, 0xed, 0xef, 0x43, 0xc1, 0xb5, - 0x51, 0xd0, 0x85, 0x02, 0x66, 0x14, 0x1c, 0x42, 0x41, 0x60, 0x14, 0x3c, 0x84, 0x82, 0xd0, 0x28, 0x78, 0x04, 0x05, - 0x57, 0x76, 0x31, 0x64, 0x65, 0x42, 0xf0, 0x23, 0x24, 0x6e, 0x30, 0xdc, 0xc9, 0xea, 0xa7, 0xb7, 0x23, 0xa2, 0xab, - 0x3c, 0x2a, 0x6f, 0x7e, 0x68, 0x1e, 0xe8, 0x8b, 0x0a, 0x2f, 0xbe, 0xb8, 0x00, 0xd6, 0x0a, 0x17, 0xb1, 0x60, 0x88, - 0x49, 0xca, 0x9a, 0xfb, 0xfa, 0xb5, 0xed, 0x95, 0x59, 0xb3, 0x6d, 0xdc, 0xd5, 0x79, 0xb3, 0x9e, 0x8d, 0x04, 0x5f, - 0x92, 0x2f, 0x0e, 0x1b, 0xa1, 0xea, 0x16, 0xee, 0x00, 0xac, 0x2e, 0xe0, 0xdc, 0x47, 0x78, 0xaa, 0x15, 0x20, 0xea, - 0xc0, 0x07, 0x18, 0xde, 0xb3, 0x29, 0xd5, 0xfb, 0x45, 0x0f, 0x60, 0x89, 0xcc, 0xe2, 0x5e, 0x54, 0x29, 0x46, 0x6f, - 0xf1, 0xb8, 0xba, 0xf3, 0xf5, 0x3d, 0x91, 0x77, 0xe8, 0x65, 0x58, 0x86, 0xb9, 0x66, 0x98, 0xfb, 0x13, 0x0f, 0x52, - 0x28, 0x21, 0x63, 0xc4, 0x1b, 0x13, 0x42, 0xda, 0x83, 0xb9, 0xf7, 0x16, 0x5f, 0x47, 0x34, 0xf1, 0xa6, 0x45, 0xaf, - 0x5c, 0x7f, 0x99, 0xd2, 0xf9, 0xbe, 0xbc, 0x28, 0x5c, 0xd0, 0x44, 0xf5, 0x56, 0x42, 0xd9, 0x2c, 0x69, 0x67, 0x4b, - 0xce, 0x9f, 0xa1, 0xec, 0x8c, 0xe3, 0xf4, 0xba, 0x09, 0xe2, 0x7e, 0x63, 0x1e, 0x20, 0xcc, 0xad, 0xcc, 0x03, 0x7c, - 0x09, 0xb0, 0x96, 0x4f, 0xef, 0xfd, 0x49, 0xf9, 0xfb, 0x15, 0xcd, 0x73, 0x7f, 0xa2, 0x6a, 0x6e, 0xcf, 0xfb, 0x13, - 0x20, 0x9a, 0x39, 0x7f, 0x1a, 0x08, 0x48, 0xce, 0x03, 0x84, 0x40, 0x40, 0x57, 0xe5, 0xea, 0xc1, 0xcc, 0xeb, 0x69, - 0x7e, 0x02, 0x55, 0xf5, 0x22, 0xee, 0x4f, 0xaa, 0x82, 0xe3, 0x59, 0x46, 0x55, 0x02, 0x21, 0x60, 0xb1, 0x38, 0x6e, - 0x41, 0x81, 0x7c, 0xbd, 0x25, 0x9d, 0x4f, 0x73, 0x97, 0xed, 0x49, 0x7d, 0x96, 0x4e, 0xe7, 0x33, 0x4f, 0xa6, 0x94, - 0xc7, 0x52, 0xd6, 0x33, 0xf2, 0xbe, 0xec, 0x04, 0xf0, 0x9f, 0x3a, 0x78, 0xf1, 0xe5, 0x78, 0x3c, 0xbe, 0x33, 0xbd, - 0xef, 0xcb, 0x70, 0x4c, 0xbb, 0xf4, 0xb0, 0x07, 0xa7, 0x16, 0x9a, 0x2a, 0x11, 0xad, 0x53, 0x08, 0xdc, 0x2d, 0xee, - 0x57, 0x19, 0x72, 0xd6, 0x78, 0xb4, 0xb8, 0x7f, 0xaa, 0x5f, 0x31, 0xcb, 0xe8, 0x62, 0xea, 0x67, 0x13, 0x96, 0x78, - 0xed, 0xc2, 0xbd, 0x5a, 0x28, 0x50, 0x8f, 0x8e, 0x8e, 0x0a, 0x37, 0xd4, 0x4f, 0xed, 0x30, 0x2c, 0xdc, 0x60, 0x51, - 0x4e, 0xa3, 0xdd, 0x1e, 0x8f, 0x0b, 0x97, 0xe9, 0x82, 0xfd, 0x6e, 0x10, 0xee, 0x77, 0x0b, 0xf7, 0xda, 0xa8, 0x51, - 0xb8, 0x54, 0x3d, 0x65, 0x34, 0xac, 0x1d, 0x7d, 0x78, 0xd4, 0x6e, 0x17, 0xae, 0x24, 0xb4, 0x05, 0xc4, 0xe4, 0xe4, - 0x4f, 0xcf, 0x9f, 0x73, 0x30, 0x98, 0x8a, 0x5e, 0xcc, 0x9d, 0xe1, 0xae, 0xba, 0x56, 0x52, 0x7e, 0x87, 0xb1, 0x40, - 0x23, 0xfc, 0xb5, 0x99, 0x39, 0x07, 0xc4, 0x2c, 0x32, 0xe6, 0x62, 0x9d, 0x58, 0x57, 0x7b, 0x0d, 0x94, 0x25, 0x5e, - 0x7f, 0x4d, 0xe2, 0x2a, 0xa1, 0x0e, 0xf8, 0x18, 0xd4, 0x94, 0xb7, 0x9f, 0x27, 0xdb, 0xa4, 0x47, 0xf6, 0x69, 0xe9, - 0x71, 0x79, 0x1f, 0xe1, 0x91, 0xfd, 0xe1, 0xc2, 0x23, 0x31, 0x85, 0x87, 0x64, 0x1d, 0xd7, 0x9c, 0xd8, 0x41, 0x44, - 0x83, 0x8f, 0x97, 0xe9, 0x4d, 0x13, 0xb6, 0x44, 0x66, 0x0b, 0xb1, 0x72, 0xf5, 0x5b, 0x33, 0xf9, 0x75, 0x67, 0xc6, - 0x47, 0x1c, 0x85, 0x8e, 0xff, 0x26, 0x21, 0xf6, 0x1b, 0x1d, 0xd8, 0x93, 0x25, 0xe3, 0x31, 0xb1, 0xdf, 0x8c, 0xc7, - 0xb6, 0xbe, 0x1c, 0xc7, 0xe7, 0x54, 0xd4, 0x7a, 0x5d, 0x2b, 0x11, 0xb5, 0xc0, 0xd0, 0xaf, 0xca, 0xcc, 0x02, 0x95, - 0x77, 0x67, 0xe6, 0xd8, 0xa9, 0x37, 0x21, 0xcb, 0x61, 0xab, 0xc1, 0xb7, 0x25, 0xeb, 0x97, 0xf3, 0x27, 0xb5, 0x2f, - 0x29, 0x95, 0x00, 0x6f, 0xf8, 0xfc, 0xd3, 0xea, 0xcd, 0x70, 0x13, 0xaa, 0x55, 0xfc, 0x27, 0xb7, 0x2f, 0x42, 0xe7, - 0x9a, 0xa3, 0x82, 0xe5, 0x6f, 0x92, 0x95, 0x5b, 0x1f, 0x24, 0x8c, 0x84, 0x98, 0xd3, 0x2a, 0x78, 0x3a, 0x99, 0xc4, - 0xe2, 0x30, 0x49, 0xcd, 0xe0, 0x96, 0xcd, 0x07, 0xb5, 0xf9, 0x7a, 0x66, 0x43, 0xf5, 0x79, 0x0d, 0xf1, 0xbd, 0x61, - 0x79, 0x5a, 0xf8, 0x4a, 0x7d, 0x78, 0x56, 0xc4, 0x04, 0x17, 0x8a, 0xc7, 0x2f, 0xe4, 0x19, 0x53, 0x8e, 0x59, 0x28, - 0x9b, 0xb3, 0xb0, 0x28, 0xd4, 0xe9, 0xfc, 0x90, 0xe5, 0x33, 0xd0, 0x9e, 0x64, 0x4b, 0xfa, 0x29, 0x16, 0x9e, 0x5f, - 0x1b, 0xc9, 0x6d, 0xb5, 0xe5, 0x2a, 0xb4, 0x9d, 0x26, 0xb3, 0x85, 0xae, 0x79, 0x61, 0x2b, 0x93, 0x4d, 0x23, 0xd1, - 0xb6, 0x24, 0x3e, 0x65, 0xda, 0x9d, 0x31, 0x43, 0xc8, 0xfc, 0x29, 0x17, 0x44, 0xbf, 0xd2, 0x05, 0x85, 0x69, 0x65, - 0x89, 0x37, 0x12, 0x5b, 0x22, 0x55, 0x2c, 0x9f, 0xf9, 0x89, 0x36, 0xe6, 0x24, 0x3f, 0xd8, 0x5d, 0x54, 0x2b, 0x5f, - 0xd8, 0x1a, 0x6c, 0x49, 0xbc, 0xfd, 0xe3, 0x16, 0x34, 0xe8, 0x5b, 0x35, 0xd0, 0x93, 0xb5, 0x0c, 0xb3, 0xbb, 0xf3, - 0xae, 0x3f, 0x5e, 0xb8, 0xf9, 0x35, 0x76, 0xf3, 0x6b, 0xeb, 0xab, 0x45, 0xf3, 0x9a, 0x5e, 0x7e, 0x64, 0xbc, 0xc9, - 0xfd, 0x59, 0x13, 0xbc, 0xa7, 0x22, 0x33, 0x44, 0xb1, 0x67, 0xa1, 0xa3, 0x4b, 0xd3, 0xaf, 0x37, 0xcf, 0x21, 0x3d, - 0x5b, 0x98, 0x51, 0x5e, 0x92, 0x26, 0xb4, 0x57, 0x3f, 0xbe, 0x67, 0x66, 0x18, 0x6b, 0x6c, 0x8d, 0x16, 0x29, 0xa4, - 0x73, 0xf3, 0x5b, 0xaf, 0xad, 0xd8, 0x7a, 0x5b, 0xa7, 0x0f, 0xb7, 0x37, 0xd6, 0xf7, 0x14, 0x72, 0x1b, 0x42, 0x7a, - 0x65, 0xeb, 0xf9, 0xcf, 0xdb, 0xf2, 0xbb, 0x3f, 0x75, 0x98, 0x0d, 0xf2, 0x49, 0xf4, 0xff, 0xc6, 0x29, 0xc0, 0xd5, - 0x62, 0x71, 0x98, 0xed, 0x3e, 0x90, 0x79, 0xfe, 0x98, 0xd3, 0x0c, 0xdf, 0xa7, 0xe6, 0xa5, 0xb8, 0x77, 0x62, 0x01, - 0x62, 0xc6, 0xeb, 0x1c, 0xd5, 0x53, 0xb1, 0xef, 0xee, 0xfe, 0xee, 0xe9, 0x17, 0x0a, 0x47, 0xfa, 0x1e, 0x56, 0xdb, - 0xee, 0xc1, 0x46, 0x88, 0xfd, 0x5b, 0x8f, 0x25, 0x42, 0xe6, 0x5d, 0x42, 0x52, 0x48, 0x6f, 0x96, 0xaa, 0x53, 0x99, - 0x19, 0x8d, 0xc5, 0xa7, 0xd7, 0xd5, 0x52, 0xec, 0x3f, 0x9c, 0xdd, 0xe8, 0xd5, 0xe8, 0xac, 0x9c, 0xb6, 0xfc, 0x43, - 0x0f, 0x55, 0x6e, 0x3f, 0xc5, 0x59, 0x3f, 0x18, 0x78, 0x38, 0xbb, 0xe9, 0x49, 0x41, 0xdb, 0xcc, 0x24, 0x54, 0xed, - 0xd9, 0x8d, 0x79, 0xac, 0xb4, 0xea, 0xc8, 0x72, 0xf7, 0x73, 0x8b, 0xfa, 0x39, 0xed, 0xc1, 0x97, 0xa6, 0x58, 0xe0, - 0xc7, 0x4a, 0x98, 0x4f, 0x59, 0x18, 0xc6, 0xb4, 0xa7, 0xe5, 0xb5, 0xd5, 0x79, 0x08, 0xa7, 0x32, 0xcd, 0x25, 0xab, - 0xaf, 0x8a, 0x81, 0xbc, 0x12, 0x4f, 0xfe, 0x65, 0x9e, 0xc6, 0xf0, 0x9d, 0xc7, 0x8d, 0xe8, 0x54, 0xc7, 0x15, 0xdb, - 0x15, 0xf2, 0xc4, 0xef, 0xfa, 0x5c, 0x0e, 0xdb, 0x7f, 0xea, 0x89, 0x05, 0x6f, 0xf7, 0x78, 0x3a, 0xf3, 0x9a, 0xfb, - 0xf5, 0x89, 0xc0, 0xab, 0x72, 0x0a, 0x78, 0xc3, 0xb4, 0x30, 0x48, 0x2b, 0xc9, 0xa7, 0x2d, 0xb7, 0xa3, 0xca, 0x44, - 0x07, 0x60, 0x84, 0x96, 0x45, 0x45, 0x7d, 0x32, 0xff, 0x98, 0xdd, 0xf2, 0x78, 0xf3, 0x6e, 0x79, 0xac, 0x77, 0xcb, - 0xdd, 0x14, 0xfb, 0xe5, 0xb8, 0x03, 0xff, 0xf5, 0xaa, 0x09, 0x79, 0x6d, 0x6b, 0x7f, 0x76, 0x63, 0x81, 0x9e, 0xd6, - 0xec, 0xce, 0x6e, 0xe4, 0xa1, 0x5a, 0x48, 0x5c, 0x6b, 0xc3, 0x31, 0x53, 0xdc, 0xb6, 0xa0, 0x10, 0xfe, 0x6f, 0xd7, - 0x5e, 0x75, 0x0e, 0xe0, 0x1d, 0xb4, 0x3a, 0x5c, 0x7f, 0xd7, 0xbd, 0x7b, 0xd3, 0x7a, 0x49, 0xca, 0x1d, 0x4f, 0x73, - 0x63, 0xe4, 0x72, 0xff, 0xf2, 0x92, 0x86, 0xde, 0x38, 0x0d, 0xe6, 0xf9, 0x3f, 0x29, 0xf8, 0x15, 0x12, 0xef, 0xdc, - 0xd2, 0x2b, 0xfd, 0xe8, 0xa6, 0xf2, 0x88, 0xaf, 0xee, 0x61, 0x51, 0xae, 0x93, 0x97, 0x07, 0x7e, 0x4c, 0x9d, 0xae, - 0x7b, 0xb0, 0x61, 0x13, 0xfc, 0x9b, 0xac, 0xcd, 0xc6, 0xc9, 0xfc, 0x5e, 0x64, 0xdc, 0x89, 0x84, 0xcf, 0xc2, 0x81, - 0xb9, 0x86, 0xed, 0xa3, 0xcd, 0xe0, 0x0e, 0xf5, 0x48, 0x23, 0x2d, 0x14, 0x94, 0xdc, 0x09, 0xe9, 0xd8, 0x9f, 0xc7, - 0xfc, 0xee, 0x5e, 0xb7, 0x51, 0xc6, 0x5a, 0xaf, 0x77, 0x30, 0xf4, 0xaa, 0xee, 0x3d, 0xb9, 0xf4, 0x97, 0x8f, 0x0f, - 0xe0, 0x3f, 0x79, 0xf8, 0xe5, 0xb2, 0xd2, 0xd5, 0xa5, 0xd5, 0x0b, 0xba, 0xfa, 0x55, 0x4d, 0x19, 0x97, 0x22, 0x5c, - 0xe8, 0xe3, 0xf7, 0xad, 0x0d, 0x5a, 0xe5, 0xbd, 0xaa, 0x2b, 0x2d, 0xeb, 0xb3, 0x6a, 0x7f, 0x5e, 0xe7, 0xf7, 0xac, - 0x1b, 0x48, 0xcd, 0xb5, 0x5e, 0x57, 0x7d, 0x7a, 0x7e, 0xad, 0xb2, 0xc6, 0xb8, 0xa8, 0x7f, 0x45, 0x2e, 0x4b, 0x13, - 0x45, 0xa6, 0xa2, 0x82, 0x95, 0x72, 0x25, 0xad, 0x94, 0x94, 0x92, 0x8b, 0xe3, 0xc1, 0xcd, 0x34, 0xb6, 0xae, 0xe4, - 0xfd, 0x38, 0xc4, 0xee, 0xb8, 0x6d, 0xdb, 0x12, 0x4e, 0x3a, 0xf8, 0x4c, 0x97, 0xfd, 0xe1, 0xfd, 0xd7, 0xcd, 0x23, - 0x7b, 0x00, 0x9a, 0xd6, 0xd5, 0x44, 0x68, 0x76, 0x2f, 0xfd, 0x5b, 0x9a, 0x9d, 0x77, 0x95, 0x0b, 0x5e, 0xe6, 0x8b, - 0x8b, 0x32, 0xab, 0x6b, 0x5b, 0x37, 0xd3, 0x38, 0xc9, 0x89, 0x1d, 0x71, 0x3e, 0xf3, 0x5a, 0xad, 0xeb, 0xeb, 0x6b, - 0xf7, 0x7a, 0xdf, 0x4d, 0xb3, 0x49, 0xab, 0xdb, 0x6e, 0xb7, 0xe1, 0x8b, 0x1f, 0xb6, 0x75, 0xc5, 0xe8, 0xf5, 0x93, - 0xf4, 0x86, 0xd8, 0x6d, 0xab, 0x6d, 0x75, 0xba, 0x47, 0x56, 0xa7, 0x7b, 0xe0, 0x3e, 0x3c, 0xb2, 0xfb, 0x5f, 0x58, - 0xd6, 0x71, 0x48, 0xc7, 0x39, 0xfc, 0xb0, 0xac, 0x63, 0xa1, 0x78, 0xc9, 0xdf, 0x96, 0xe5, 0x06, 0x71, 0xde, 0xec, - 0x58, 0x0b, 0xf5, 0x68, 0x59, 0x70, 0x8b, 0x90, 0x67, 0x7d, 0x39, 0xee, 0x8e, 0x0f, 0xc6, 0x8f, 0x7b, 0xaa, 0xb8, - 0xf8, 0xa2, 0x56, 0x1d, 0xcb, 0x7f, 0xbb, 0x46, 0xb3, 0x9c, 0x67, 0xe9, 0x47, 0xaa, 0x5c, 0xfb, 0x16, 0x88, 0x9e, - 0x8d, 0x4d, 0xbb, 0xeb, 0x23, 0x75, 0x8e, 0x2e, 0x83, 0x71, 0xb7, 0xaa, 0x2e, 0x60, 0x6c, 0x95, 0x40, 0x1e, 0xb7, - 0x34, 0xe8, 0xc7, 0x26, 0x9a, 0x3a, 0xcd, 0x4d, 0x88, 0xea, 0xd8, 0x6a, 0x8e, 0x13, 0x3d, 0xbf, 0x63, 0x38, 0xb4, - 0xae, 0x75, 0x55, 0x01, 0x81, 0x6d, 0x85, 0xc4, 0x7e, 0xd5, 0xe9, 0x1e, 0xe1, 0x4e, 0xe7, 0xa1, 0xfb, 0xf0, 0x28, - 0x68, 0xe3, 0x03, 0xf7, 0xa0, 0xb9, 0xef, 0x3e, 0xc4, 0x47, 0xcd, 0x23, 0x7c, 0xf4, 0xfc, 0x28, 0x68, 0x1e, 0xb8, - 0x07, 0xb8, 0xdd, 0x3c, 0x82, 0xc2, 0xe6, 0x51, 0xf3, 0xe8, 0xaa, 0x79, 0x70, 0x14, 0xb4, 0x45, 0x69, 0xd7, 0x3d, - 0x3c, 0x6c, 0x76, 0xda, 0xee, 0xe1, 0x21, 0x3e, 0x74, 0x1f, 0x3e, 0x6c, 0x76, 0xf6, 0xdd, 0x87, 0x0f, 0x5f, 0x1e, - 0x1e, 0xb9, 0xfb, 0xf0, 0x6e, 0x7f, 0x3f, 0xd8, 0x77, 0x3b, 0x9d, 0x26, 0xfc, 0xc1, 0x47, 0x6e, 0x57, 0xfe, 0xe8, - 0x74, 0xdc, 0xfd, 0x0e, 0x6e, 0xc7, 0x87, 0x5d, 0xf7, 0xe1, 0x63, 0x2c, 0xfe, 0x8a, 0x6a, 0x58, 0xfc, 0x81, 0x6e, - 0xf0, 0x63, 0xb7, 0xfb, 0x50, 0xfe, 0x12, 0x1d, 0x5e, 0x1d, 0x1c, 0xfd, 0x68, 0xb7, 0xb6, 0xce, 0xa1, 0x23, 0xe7, - 0x70, 0x74, 0xe8, 0xee, 0xef, 0xe3, 0x83, 0x8e, 0x7b, 0xb4, 0x1f, 0x35, 0x0f, 0xba, 0xee, 0xc3, 0x47, 0x41, 0xb3, - 0xe3, 0x3e, 0x7a, 0x84, 0xdb, 0xcd, 0x7d, 0xb7, 0x8b, 0x3b, 0xee, 0xc1, 0xbe, 0xf8, 0xb1, 0xef, 0x76, 0xaf, 0x1e, - 0x3d, 0x76, 0x1f, 0x1e, 0x46, 0x0f, 0xdd, 0x83, 0x6f, 0x0f, 0x8e, 0xdc, 0xee, 0x7e, 0xb4, 0xff, 0xd0, 0xed, 0x3e, - 0xba, 0x7a, 0xe8, 0x1e, 0x44, 0xcd, 0xee, 0xc3, 0x3b, 0x5b, 0x76, 0xba, 0x2e, 0xe0, 0x48, 0xbc, 0x86, 0x17, 0x58, - 0xbd, 0x80, 0xff, 0x23, 0xd1, 0xf6, 0xdf, 0xb0, 0x9b, 0x7c, 0xbd, 0xe9, 0x63, 0xf7, 0xe8, 0x51, 0x20, 0xab, 0x43, - 0x41, 0x53, 0xd7, 0x80, 0x26, 0x57, 0x4d, 0x39, 0xac, 0xe8, 0xae, 0xa9, 0x3b, 0xd2, 0xff, 0xab, 0xc1, 0xae, 0x9a, - 0x30, 0xb0, 0x1c, 0xf7, 0xdf, 0xb5, 0x9f, 0x72, 0xc9, 0x8f, 0x5b, 0x13, 0x49, 0xfa, 0x93, 0xfe, 0x17, 0xf2, 0x73, - 0x3e, 0x5f, 0x5c, 0x60, 0x7f, 0x9b, 0xe3, 0x23, 0xfe, 0xb4, 0xe3, 0x23, 0xa2, 0xf7, 0xf1, 0x7c, 0xc4, 0x7f, 0xb8, - 0xe7, 0xc3, 0x5f, 0x75, 0x9b, 0xdf, 0xf0, 0x35, 0x07, 0xc7, 0xaa, 0x55, 0xfc, 0x82, 0x3b, 0xc3, 0x14, 0x3e, 0x1d, - 0x5d, 0xf4, 0x6e, 0x38, 0x89, 0xa8, 0xe9, 0x07, 0x4a, 0x81, 0xc5, 0xde, 0x70, 0xc9, 0x63, 0x83, 0x6d, 0x08, 0x09, - 0x3f, 0x8d, 0x90, 0xef, 0xee, 0x83, 0x8f, 0xf0, 0x0f, 0xc7, 0x47, 0x60, 0xe2, 0xa3, 0xe6, 0xc9, 0x17, 0x9e, 0x06, - 0xe1, 0x29, 0x38, 0x13, 0xcf, 0x0e, 0xdc, 0x9a, 0xd1, 0xb0, 0x5b, 0xf4, 0x4a, 0x44, 0xee, 0x64, 0x70, 0xfd, 0xf9, - 0xe7, 0x04, 0x1d, 0xe4, 0x15, 0x39, 0xc4, 0x56, 0x6e, 0x99, 0x99, 0x90, 0x3a, 0xea, 0xa1, 0x14, 0x4a, 0x5d, 0xb7, - 0xed, 0xb6, 0x4b, 0x97, 0x0e, 0x5c, 0x8b, 0x44, 0x16, 0x29, 0xf7, 0xbd, 0x9d, 0x0e, 0x8e, 0xd3, 0x09, 0x5c, 0x96, - 0x24, 0x3e, 0x1f, 0x07, 0x27, 0x1e, 0x02, 0xf9, 0xe5, 0x3e, 0x48, 0x9f, 0x50, 0x8e, 0x1e, 0x3f, 0xfb, 0xf8, 0x37, - 0x08, 0x62, 0xea, 0x98, 0xc4, 0x14, 0xbc, 0x1d, 0xaf, 0x68, 0xc8, 0x7c, 0xc7, 0x76, 0x66, 0x19, 0x1d, 0xd3, 0x2c, - 0x6f, 0xd6, 0xee, 0xeb, 0x11, 0x57, 0xf5, 0x20, 0x5b, 0x41, 0x38, 0xce, 0xe0, 0x73, 0x48, 0x64, 0xa8, 0xfc, 0x8d, - 0xb6, 0x32, 0xc0, 0xec, 0x02, 0xeb, 0x92, 0x0c, 0x64, 0x6d, 0xa5, 0xb4, 0xd9, 0x52, 0x6b, 0xeb, 0xb8, 0xdd, 0x43, - 0x64, 0x89, 0x62, 0xf8, 0xd0, 0xcc, 0x0f, 0x4e, 0x73, 0xbf, 0xfd, 0x27, 0x64, 0x34, 0x2b, 0x3b, 0x1a, 0x29, 0x77, - 0x5b, 0x52, 0x7e, 0x8e, 0x70, 0x25, 0xec, 0x6a, 0x4b, 0x8a, 0xf8, 0x52, 0xce, 0xdd, 0x46, 0xbd, 0x44, 0x25, 0xcd, - 0xc9, 0x2b, 0x01, 0xc7, 0x6c, 0xe2, 0x18, 0xd7, 0x4d, 0x24, 0xf2, 0x43, 0x36, 0x70, 0x5b, 0x3d, 0x42, 0x45, 0x55, - 0x25, 0x41, 0x0b, 0x11, 0x6d, 0x61, 0x89, 0x95, 0x2c, 0x97, 0x4e, 0x02, 0x2e, 0x72, 0x62, 0xe0, 0x14, 0x9e, 0x51, - 0x0d, 0xc9, 0x09, 0x2e, 0x01, 0x12, 0x08, 0x26, 0x89, 0xfc, 0xb7, 0x2a, 0xd6, 0x3f, 0x94, 0xe3, 0xcb, 0x8d, 0xfd, - 0x64, 0x02, 0x54, 0xe8, 0x27, 0x93, 0x35, 0xb7, 0x9a, 0x0c, 0x18, 0xad, 0x94, 0x56, 0x5d, 0x55, 0xee, 0xb3, 0xfc, - 0xc9, 0xed, 0x7b, 0x75, 0xe3, 0xb5, 0x0d, 0xde, 0x69, 0x11, 0xdf, 0xa8, 0xbe, 0xce, 0xd3, 0x20, 0x0f, 0x8e, 0xa7, - 0x94, 0xfb, 0xf2, 0xb0, 0x1a, 0xe8, 0x13, 0x90, 0xcb, 0x62, 0x29, 0x6b, 0x54, 0x05, 0xf5, 0x89, 0x3c, 0xcc, 0x2f, - 0x45, 0x3d, 0xb6, 0xd4, 0x55, 0x71, 0x4d, 0xb1, 0x34, 0xa4, 0x83, 0xa5, 0x3f, 0x26, 0xf0, 0xc5, 0x71, 0x64, 0x92, - 0xa4, 0x76, 0xff, 0x41, 0x99, 0xeb, 0xb2, 0x6d, 0x11, 0x62, 0x96, 0x7c, 0x1c, 0x66, 0x34, 0xfe, 0x27, 0xf2, 0x80, - 0x05, 0x69, 0xf2, 0x60, 0x64, 0xa3, 0x1e, 0x77, 0xa3, 0x8c, 0x8e, 0xc9, 0x03, 0x90, 0xf1, 0x9e, 0xb0, 0x3e, 0x80, - 0x11, 0x36, 0x6e, 0xa6, 0x31, 0x16, 0x1a, 0xd3, 0x3d, 0x14, 0x22, 0x09, 0xae, 0xdd, 0x3d, 0xb4, 0x2d, 0x69, 0x13, - 0x8b, 0xdf, 0x7d, 0x29, 0x4e, 0x85, 0x12, 0x60, 0x75, 0xba, 0xee, 0x61, 0xd4, 0x75, 0x1f, 0x5f, 0x3d, 0x72, 0x8f, - 0xa2, 0xce, 0xa3, 0xab, 0x26, 0xfc, 0xdb, 0x75, 0x1f, 0xc7, 0xcd, 0xae, 0xfb, 0x18, 0xfe, 0xff, 0xf6, 0xc0, 0x3d, - 0x8c, 0x9a, 0x1d, 0xf7, 0xe8, 0x6a, 0xdf, 0xdd, 0x7f, 0xd9, 0xe9, 0xba, 0xfb, 0x56, 0xc7, 0x92, 0xed, 0x80, 0x5d, - 0x4b, 0xee, 0xfc, 0x60, 0x65, 0x43, 0x6c, 0x08, 0xc6, 0xc9, 0x03, 0x77, 0x36, 0x16, 0x67, 0xa4, 0xcd, 0xfd, 0xa9, - 0x9c, 0x75, 0x4f, 0xfd, 0x0c, 0xbe, 0x6c, 0x5a, 0xdf, 0xbb, 0xb5, 0x77, 0xb8, 0xc6, 0x2f, 0x36, 0x0c, 0x31, 0x13, - 0x11, 0x70, 0xf3, 0xae, 0x35, 0x2a, 0xee, 0xb0, 0x93, 0xdf, 0x82, 0x52, 0x51, 0xb0, 0x32, 0xbb, 0xc8, 0x20, 0x6b, - 0x59, 0x03, 0x12, 0x80, 0x04, 0x0d, 0xae, 0xe6, 0x8f, 0x56, 0x74, 0x9e, 0xc1, 0xfd, 0x04, 0x9a, 0x97, 0x30, 0xf1, - 0x45, 0x3e, 0x01, 0xc3, 0x8b, 0xb0, 0x58, 0x05, 0x0f, 0x8e, 0x05, 0x66, 0xa9, 0x71, 0x1b, 0x1d, 0xad, 0x72, 0x00, - 0x42, 0x06, 0xf7, 0x07, 0x16, 0x85, 0x9e, 0x59, 0xcd, 0x8b, 0x5b, 0x21, 0x51, 0xb0, 0x13, 0x9a, 0x0f, 0x6c, 0x28, - 0xb2, 0x3d, 0x5b, 0x78, 0x00, 0xed, 0xf2, 0xe3, 0xaf, 0x25, 0xdd, 0x57, 0x05, 0x58, 0x5c, 0x0e, 0x01, 0x9b, 0x1a, - 0xd0, 0x67, 0xa3, 0xbd, 0xbd, 0xad, 0xdb, 0x49, 0xe8, 0x97, 0x30, 0xb5, 0xea, 0x9b, 0x91, 0x26, 0xa7, 0xb2, 0xcd, - 0x75, 0x28, 0xfb, 0x15, 0x18, 0x46, 0x0a, 0x2d, 0x97, 0xd4, 0xe7, 0xae, 0x9f, 0xc8, 0x03, 0x06, 0x06, 0x3f, 0xc3, - 0x1d, 0xba, 0x8f, 0x8a, 0x94, 0xfb, 0x32, 0x67, 0xcc, 0x64, 0x03, 0x29, 0xf7, 0xf5, 0xdd, 0x4a, 0x3e, 0xaf, 0x9d, - 0xab, 0x8f, 0xba, 0xfd, 0x37, 0xef, 0x4f, 0x2c, 0xb9, 0x7b, 0x8f, 0x5b, 0x51, 0xb7, 0x7f, 0x2c, 0x5c, 0x2a, 0x32, - 0x2b, 0x80, 0xc8, 0xac, 0x00, 0x4b, 0x5d, 0x2a, 0x03, 0x81, 0xb6, 0xa2, 0x25, 0xa7, 0x2d, 0x4c, 0x0a, 0xe9, 0x0c, - 0x9e, 0xce, 0x63, 0xce, 0xe0, 0x9b, 0x47, 0x2d, 0x91, 0x12, 0x20, 0x52, 0x0c, 0xf4, 0x19, 0x55, 0xa5, 0x3c, 0x5e, - 0xf2, 0x44, 0xbb, 0x8e, 0xc7, 0x2c, 0xa6, 0xfa, 0x54, 0xaa, 0xea, 0xaa, 0xcc, 0x07, 0x5a, 0xaf, 0x9d, 0xcf, 0x2f, - 0x21, 0x27, 0x42, 0x67, 0x1f, 0x7d, 0x50, 0x0d, 0x8e, 0xc5, 0x50, 0x10, 0xd8, 0x97, 0x52, 0x5c, 0x7f, 0xdd, 0xb5, - 0xbe, 0xa4, 0x6a, 0xf6, 0x4a, 0x80, 0xc0, 0x4d, 0x1e, 0xd1, 0x7e, 0xbf, 0xf4, 0x26, 0x9b, 0xef, 0x8a, 0xe3, 0x56, - 0xb4, 0xdf, 0xbf, 0xf0, 0x26, 0xaa, 0xbf, 0x97, 0xe9, 0x64, 0x73, 0x5f, 0x71, 0x3a, 0x19, 0x88, 0x63, 0xf2, 0xf2, - 0xca, 0x27, 0xad, 0x1b, 0xa7, 0xb1, 0xdd, 0x3f, 0x56, 0xba, 0x82, 0x25, 0xa2, 0xee, 0xf6, 0x61, 0x5b, 0x9f, 0xbc, - 0x8f, 0xd3, 0x09, 0xec, 0x57, 0xd9, 0xc4, 0x18, 0xa4, 0xe6, 0x90, 0x8f, 0x3a, 0xfd, 0x63, 0xdf, 0x12, 0xac, 0x47, - 0xf0, 0x96, 0xdc, 0x6b, 0x41, 0xe3, 0x28, 0x9d, 0x52, 0x97, 0xa5, 0xad, 0x6b, 0x7a, 0xd9, 0xf4, 0x67, 0xac, 0xf2, - 0x7e, 0x83, 0x4e, 0x52, 0x0e, 0x99, 0xae, 0x64, 0x60, 0x75, 0x2b, 0x6f, 0xdc, 0x01, 0x98, 0x44, 0xda, 0x73, 0x27, - 0x5c, 0x76, 0x06, 0x58, 0x69, 0xff, 0xb8, 0xe5, 0xaf, 0x60, 0x44, 0x6c, 0xc5, 0x42, 0xf9, 0xe1, 0xc1, 0xee, 0xb9, - 0x14, 0xe9, 0x5f, 0x52, 0x5a, 0x68, 0x7f, 0xbd, 0x92, 0xe3, 0x85, 0xdd, 0xff, 0xd7, 0xff, 0xf1, 0xbf, 0x94, 0x0b, - 0xfe, 0xb8, 0x15, 0x75, 0x74, 0x5f, 0x2b, 0xab, 0x52, 0x1c, 0xc3, 0x3d, 0x36, 0x55, 0xcc, 0x98, 0xde, 0x34, 0x27, - 0x19, 0x0b, 0x9b, 0x91, 0x1f, 0x8f, 0xed, 0xfe, 0x76, 0x6c, 0xca, 0xf4, 0xc4, 0xa6, 0x8e, 0xb6, 0xae, 0x17, 0x01, - 0xbd, 0xfe, 0xa6, 0x4b, 0x19, 0x74, 0xc6, 0x97, 0xd8, 0xda, 0xe6, 0x15, 0x0d, 0xd5, 0xee, 0xab, 0x5d, 0xd3, 0x90, - 0xa8, 0x4f, 0x46, 0x2b, 0x06, 0x99, 0xd4, 0x6e, 0x67, 0x28, 0x6c, 0xab, 0x8c, 0x79, 0xfd, 0xdf, 0xff, 0xf9, 0x5f, - 0xfe, 0x9b, 0x7e, 0x84, 0x50, 0xd6, 0xbf, 0xfe, 0xf7, 0xff, 0xfc, 0x7f, 0xfe, 0xf7, 0x7f, 0x85, 0xf4, 0x34, 0x15, - 0xee, 0x12, 0x4c, 0xc5, 0xaa, 0x62, 0x5d, 0x92, 0xbb, 0x58, 0x70, 0xe8, 0x6d, 0xca, 0x72, 0xce, 0x82, 0xfa, 0x7d, - 0x0d, 0x67, 0x62, 0x40, 0xb1, 0x33, 0x15, 0x74, 0x62, 0x87, 0x17, 0x15, 0x41, 0xd5, 0x50, 0x2e, 0x08, 0xb7, 0x38, - 0x6e, 0x01, 0xbe, 0xef, 0x77, 0xdd, 0x8c, 0x5b, 0x2e, 0xc7, 0x42, 0x93, 0x09, 0x94, 0x14, 0x55, 0xb9, 0x05, 0xa1, - 0x97, 0x05, 0x3c, 0x7a, 0x5d, 0xa3, 0x58, 0xac, 0x5e, 0xad, 0x4d, 0xef, 0xe7, 0x79, 0xce, 0xd9, 0x18, 0x50, 0x2e, - 0xdd, 0xc8, 0x22, 0xca, 0xdd, 0x04, 0x55, 0x32, 0xbe, 0x2d, 0x44, 0x2f, 0x92, 0x40, 0x0f, 0x8e, 0xfe, 0x54, 0xfc, - 0x79, 0x0a, 0x0a, 0x9b, 0xe5, 0x4c, 0xfd, 0x1b, 0x65, 0xbd, 0x3f, 0x6c, 0xb7, 0x67, 0x37, 0x68, 0x51, 0x8d, 0x80, - 0xb7, 0x0d, 0x26, 0xe8, 0xd8, 0xec, 0x50, 0x84, 0xc7, 0x4b, 0x2f, 0x77, 0xdb, 0x02, 0x57, 0xb9, 0xd5, 0x2e, 0x8a, - 0xaf, 0x16, 0xc2, 0xd1, 0xca, 0x7e, 0x85, 0x30, 0xb6, 0xf2, 0x49, 0x5f, 0xa6, 0xe6, 0xe4, 0x16, 0x46, 0xab, 0xae, - 0x6c, 0x15, 0x75, 0xd6, 0x6f, 0x6e, 0x31, 0xc3, 0xf0, 0x66, 0x00, 0xfd, 0x00, 0x42, 0xe2, 0x51, 0x07, 0x47, 0xdd, - 0x45, 0xd9, 0x3d, 0xe7, 0xe9, 0xd4, 0x8c, 0xbb, 0x53, 0x9f, 0x06, 0x74, 0xac, 0x7d, 0xf9, 0xea, 0xbd, 0x8c, 0xa9, - 0x17, 0xd1, 0xfe, 0x86, 0xb1, 0x14, 0x48, 0x22, 0xde, 0x6e, 0xb5, 0x8b, 0x2f, 0x61, 0x07, 0x2e, 0xc6, 0x71, 0xea, - 0x73, 0x4f, 0x10, 0x6c, 0xcf, 0x8c, 0xde, 0xfb, 0xc0, 0x93, 0xd2, 0x85, 0x01, 0x4f, 0x4f, 0x56, 0x05, 0xaf, 0x7a, - 0xfd, 0x06, 0xc7, 0xc2, 0x15, 0xcd, 0xcd, 0xae, 0xa4, 0x53, 0xee, 0x3b, 0x15, 0x14, 0x7f, 0x5e, 0xf3, 0x66, 0x29, - 0x81, 0xd4, 0x45, 0x9b, 0xdf, 0x4b, 0xb1, 0x2f, 0xdf, 0x7e, 0xcf, 0x1d, 0x5b, 0x80, 0x69, 0xaf, 0xd6, 0x12, 0x85, - 0x50, 0xeb, 0x39, 0xf9, 0xae, 0xb4, 0xa8, 0xfc, 0xd9, 0x4c, 0x54, 0x44, 0xbd, 0xe3, 0x96, 0x54, 0x84, 0x81, 0x7b, - 0x88, 0x8c, 0x0f, 0x99, 0x60, 0xa1, 0x2a, 0xa9, 0xad, 0x20, 0x7f, 0xa9, 0xd4, 0x0b, 0xf8, 0x94, 0x78, 0xff, 0xff, - 0x01, 0x65, 0x21, 0x07, 0x4b, 0xe3, 0x97, 0x00, 0x00}; + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xed, 0x7d, 0xdb, 0x72, 0xdb, 0xc6, 0xb6, 0xe0, 0xf3, + 0xe4, 0x2b, 0x20, 0x44, 0x5b, 0x46, 0x87, 0x4d, 0xf0, 0x22, 0xc9, 0x96, 0x41, 0x35, 0xb9, 0x65, 0xd9, 0xd9, 0xf6, + 0x8e, 0x6f, 0xdb, 0xb2, 0x73, 0x53, 0xb8, 0x25, 0x08, 0x68, 0x12, 0x1d, 0x83, 0x00, 0x03, 0x34, 0x45, 0x29, 0x24, + 0x4e, 0xcd, 0x07, 0x4c, 0xd5, 0x54, 0xcd, 0xd3, 0xbc, 0x4c, 0xcd, 0x79, 0x98, 0x8f, 0x98, 0xe7, 0xf3, 0x29, 0xe7, + 0x07, 0x66, 0x3e, 0x61, 0x6a, 0xf5, 0x05, 0x68, 0xf0, 0x22, 0xcb, 0x49, 0xf6, 0x39, 0xe7, 0x61, 0x2a, 0x15, 0x99, + 0x68, 0xf4, 0x65, 0xf5, 0xea, 0xd5, 0xeb, 0xde, 0x8d, 0xe3, 0x9d, 0x30, 0x0d, 0xf8, 0xed, 0x94, 0x5a, 0x11, 0x9f, + 0xc4, 0xfd, 0x63, 0xf5, 0x97, 0xfa, 0x61, 0xff, 0x38, 0x66, 0xc9, 0x47, 0x2b, 0xa3, 0x31, 0x61, 0x41, 0x9a, 0x58, + 0x51, 0x46, 0x47, 0x24, 0xf4, 0xb9, 0xef, 0xb1, 0x89, 0x3f, 0xa6, 0x56, 0xab, 0x7f, 0x3c, 0xa1, 0xdc, 0xb7, 0x82, + 0xc8, 0xcf, 0x72, 0xca, 0xc9, 0x87, 0xf7, 0x5f, 0x37, 0x8f, 0xfa, 0xc7, 0x79, 0x90, 0xb1, 0x29, 0xb7, 0xa0, 0x4b, + 0x32, 0x49, 0xc3, 0x59, 0x4c, 0xfb, 0xad, 0xd6, 0x7c, 0x3e, 0x77, 0x7f, 0xce, 0xbf, 0x08, 0xd2, 0x24, 0xe7, 0xd6, + 0x53, 0x32, 0x67, 0x49, 0x98, 0xce, 0x71, 0xc2, 0xc9, 0x53, 0xf7, 0x2c, 0xf2, 0xc3, 0x74, 0xfe, 0x2e, 0x4d, 0xf9, + 0xde, 0x9e, 0x23, 0x1f, 0x6f, 0x4f, 0xcf, 0xce, 0x08, 0x21, 0xd7, 0x29, 0x0b, 0xad, 0xf6, 0x72, 0x59, 0x15, 0xba, + 0x89, 0xcf, 0xd9, 0x35, 0x95, 0x4d, 0xd0, 0xde, 0x9e, 0xed, 0x87, 0xe9, 0x94, 0xd3, 0xf0, 0x8c, 0xdf, 0xc6, 0xf4, + 0x2c, 0xa2, 0x94, 0xe7, 0x36, 0x4b, 0xac, 0xa7, 0x69, 0x30, 0x9b, 0xd0, 0x84, 0xbb, 0xd3, 0x2c, 0xe5, 0x29, 0x40, + 0xb2, 0xb7, 0x67, 0x67, 0x74, 0x1a, 0xfb, 0x01, 0x85, 0xf7, 0xa7, 0x67, 0x67, 0x55, 0x8b, 0xaa, 0x12, 0xce, 0x39, + 0x39, 0xbb, 0x9d, 0x5c, 0xa5, 0xb1, 0x83, 0x70, 0xc4, 0x49, 0x42, 0xe7, 0xd6, 0x77, 0xd4, 0xff, 0xf8, 0xca, 0x9f, + 0xf6, 0x82, 0xd8, 0xcf, 0x73, 0xeb, 0x84, 0x2f, 0xc4, 0x14, 0xb2, 0x59, 0xc0, 0xd3, 0xcc, 0xe1, 0x98, 0x62, 0x86, + 0x16, 0x6c, 0xe4, 0xf0, 0x88, 0xe5, 0xee, 0xc5, 0x6e, 0x90, 0xe7, 0xef, 0x68, 0x3e, 0x8b, 0xf9, 0x2e, 0xd9, 0x69, + 0x63, 0xb6, 0x43, 0x48, 0xce, 0x11, 0x8f, 0xb2, 0x74, 0x6e, 0x3d, 0xcb, 0xb2, 0x34, 0x73, 0xec, 0xd3, 0xb3, 0x33, + 0x59, 0xc3, 0x62, 0xb9, 0x95, 0xa4, 0xdc, 0x2a, 0xfb, 0xf3, 0xaf, 0x62, 0xea, 0x5a, 0x1f, 0x72, 0x6a, 0x5d, 0xce, + 0x92, 0xdc, 0x1f, 0xd1, 0xd3, 0xb3, 0xb3, 0x4b, 0x2b, 0xcd, 0xac, 0xcb, 0x20, 0xcf, 0x2f, 0x2d, 0x96, 0xe4, 0x9c, + 0xfa, 0xa1, 0x6b, 0xa3, 0x9e, 0x18, 0x2c, 0xc8, 0xf3, 0xf7, 0xf4, 0x86, 0x13, 0x8e, 0xc5, 0x23, 0x27, 0xb4, 0x18, + 0x53, 0x6e, 0xe5, 0xe5, 0xbc, 0x1c, 0xb4, 0x88, 0x29, 0xb7, 0x38, 0x11, 0xef, 0xd3, 0x9e, 0xc4, 0x3d, 0x95, 0x8f, + 0xbc, 0xc7, 0x46, 0x4e, 0xc2, 0xf7, 0xf6, 0x78, 0x89, 0x67, 0x24, 0xa7, 0x66, 0x31, 0x42, 0x77, 0x74, 0xd9, 0xde, + 0x1e, 0x75, 0x63, 0x9a, 0x8c, 0x79, 0x44, 0x08, 0xe9, 0xf4, 0xd8, 0xde, 0x9e, 0xc3, 0x49, 0xc4, 0xdd, 0x31, 0xe5, + 0x0e, 0x45, 0x08, 0x57, 0xad, 0xf7, 0xf6, 0x1c, 0x89, 0x84, 0x94, 0x48, 0xc4, 0xd5, 0x70, 0x8c, 0x5c, 0x85, 0xfd, + 0xb3, 0xdb, 0x24, 0x70, 0x4c, 0xf8, 0x11, 0x66, 0x7b, 0x7b, 0x11, 0x77, 0x73, 0xe8, 0x11, 0x73, 0x84, 0x8a, 0x8c, + 0xf2, 0x59, 0x96, 0x58, 0xbc, 0xe0, 0xe9, 0x19, 0xcf, 0x58, 0x32, 0x76, 0xd0, 0x42, 0x97, 0x19, 0x0d, 0x8b, 0x42, + 0x82, 0xfb, 0x8e, 0x93, 0x9c, 0xf4, 0x61, 0xc4, 0x13, 0xee, 0xc0, 0x2a, 0xa6, 0x23, 0x2b, 0x27, 0xc4, 0xce, 0x45, + 0x5b, 0x7b, 0x90, 0x7b, 0x79, 0xc3, 0xb6, 0xb1, 0x84, 0x12, 0xe7, 0x1c, 0xe1, 0x8f, 0xc4, 0xc9, 0xb1, 0xeb, 0xba, + 0x1c, 0x91, 0xfe, 0x42, 0x63, 0x25, 0x37, 0xe6, 0x39, 0xc8, 0xcf, 0xdb, 0x43, 0x8f, 0xbb, 0x19, 0x0d, 0x67, 0x01, + 0x75, 0x1c, 0x86, 0x13, 0x9c, 0x21, 0xd2, 0x67, 0x0d, 0x27, 0x25, 0x7d, 0x58, 0xee, 0xb4, 0xbe, 0xd6, 0x84, 0xec, + 0xb4, 0x91, 0x82, 0x31, 0xd5, 0x00, 0x02, 0x86, 0x15, 0x3c, 0x29, 0x21, 0x76, 0x32, 0x9b, 0x5c, 0xd1, 0xcc, 0x2e, + 0xab, 0xf5, 0x6a, 0x64, 0x31, 0xcb, 0xa9, 0x15, 0xe4, 0xb9, 0x35, 0x9a, 0x25, 0x01, 0x67, 0x69, 0x62, 0xd9, 0x8d, + 0xb4, 0x61, 0x4b, 0x72, 0x28, 0xa9, 0xc1, 0x46, 0x05, 0x72, 0x12, 0xd4, 0xc8, 0xcf, 0xb3, 0x46, 0x67, 0x88, 0x01, + 0x4a, 0xd4, 0x53, 0xfd, 0x29, 0x04, 0x50, 0x9c, 0xc3, 0x1c, 0x0b, 0xfc, 0x84, 0xc3, 0x2c, 0xc5, 0x14, 0x13, 0x3e, + 0xc8, 0xdd, 0xf5, 0x8d, 0x42, 0xb8, 0x3b, 0xf1, 0xa7, 0x0e, 0x25, 0x7d, 0x2a, 0x88, 0xcb, 0x4f, 0x02, 0x80, 0xb5, + 0xb6, 0x6e, 0x03, 0xea, 0x51, 0xb7, 0x22, 0x29, 0xe4, 0x71, 0x77, 0x94, 0x66, 0xcf, 0xfc, 0x20, 0x82, 0x76, 0x25, + 0xc1, 0x84, 0x7a, 0xbf, 0x05, 0x19, 0xf5, 0x39, 0x7d, 0x16, 0x53, 0x78, 0x72, 0x6c, 0xd1, 0xd2, 0x46, 0x38, 0x21, + 0x4f, 0xdd, 0x98, 0xf1, 0xd7, 0x69, 0x12, 0xd0, 0x5e, 0x62, 0x50, 0x17, 0x83, 0x75, 0x3f, 0xe1, 0x3c, 0x63, 0x57, + 0x33, 0x4e, 0x1d, 0x3b, 0x81, 0x1a, 0x36, 0x4e, 0x10, 0x66, 0x2e, 0xa7, 0x37, 0xfc, 0x34, 0x4d, 0x38, 0x4d, 0x38, + 0xa1, 0x1a, 0xa9, 0x38, 0x77, 0xfd, 0xe9, 0x94, 0x26, 0xe1, 0x69, 0xc4, 0xe2, 0xd0, 0x61, 0xa8, 0x40, 0x05, 0x0e, + 0x38, 0x81, 0x39, 0x92, 0x7e, 0xee, 0xc1, 0x9f, 0xed, 0xb3, 0x71, 0x38, 0xe9, 0x8b, 0x4d, 0x41, 0x89, 0x6d, 0xf7, + 0x46, 0x69, 0xe6, 0xa8, 0x19, 0x58, 0xe9, 0xc8, 0xe2, 0x30, 0xc6, 0xbb, 0x59, 0x4c, 0x73, 0x44, 0x1b, 0x84, 0x95, + 0xcb, 0xa8, 0x10, 0xfc, 0x0e, 0x28, 0xbe, 0x40, 0x4e, 0x8e, 0xbc, 0xbc, 0x77, 0xed, 0x67, 0xd6, 0x8f, 0x6a, 0x47, + 0xfd, 0xac, 0xb9, 0x59, 0xc8, 0xc9, 0xcf, 0x2e, 0xcf, 0x66, 0x39, 0xa7, 0xe1, 0xfb, 0xdb, 0x29, 0xcd, 0xf1, 0x73, + 0x4e, 0x42, 0x3e, 0x08, 0xb9, 0x4b, 0x27, 0x53, 0x7e, 0x7b, 0x26, 0x18, 0xa3, 0x67, 0xdb, 0x78, 0x06, 0x35, 0x33, + 0xea, 0x07, 0xc0, 0xcc, 0x14, 0xb6, 0xde, 0xa6, 0xf1, 0xed, 0x88, 0xc5, 0xf1, 0xd9, 0x6c, 0x3a, 0x4d, 0x33, 0x8e, + 0x39, 0x27, 0x0b, 0x9e, 0x56, 0xb8, 0x81, 0xc5, 0x5c, 0xe4, 0x73, 0xc6, 0x83, 0xc8, 0xe1, 0x68, 0x11, 0xf8, 0x39, + 0xb5, 0x9e, 0xa4, 0x69, 0x4c, 0xfd, 0xc4, 0xcb, 0x49, 0x3e, 0x78, 0xce, 0xbd, 0x64, 0x16, 0xc7, 0xbd, 0xab, 0x8c, + 0xfa, 0x1f, 0x7b, 0xe2, 0xf5, 0x9b, 0xab, 0x9f, 0x69, 0xc0, 0x3d, 0xf1, 0xfb, 0x24, 0xcb, 0xfc, 0x5b, 0xa8, 0x48, + 0x08, 0x54, 0x1b, 0xe4, 0xde, 0x5f, 0xcf, 0xde, 0xbc, 0x76, 0xe5, 0x2e, 0x61, 0xa3, 0x5b, 0x27, 0x2f, 0x77, 0x5e, + 0x5e, 0xe0, 0x51, 0x96, 0x4e, 0x56, 0x86, 0x96, 0x68, 0xcb, 0x7b, 0x5b, 0x40, 0xa0, 0x24, 0xdf, 0x91, 0x5d, 0x9b, + 0x10, 0xbc, 0x16, 0x44, 0x0f, 0x2f, 0x89, 0x1a, 0x17, 0xfe, 0x78, 0xb2, 0xd8, 0xc9, 0xd1, 0xdd, 0xd0, 0xf2, 0xec, + 0x76, 0x41, 0x89, 0x80, 0x73, 0x0a, 0x22, 0x06, 0x60, 0x0c, 0x7c, 0x1e, 0x44, 0x0b, 0x2a, 0x3a, 0x2b, 0x34, 0xc4, + 0xb4, 0x28, 0xf0, 0xb3, 0x92, 0xe0, 0x39, 0xb0, 0x5d, 0xc1, 0xa9, 0x08, 0x5f, 0x2e, 0x73, 0x42, 0x72, 0x84, 0xff, + 0x4a, 0x16, 0xbe, 0x9e, 0x8f, 0xb7, 0xd3, 0xc6, 0xb0, 0x31, 0x3d, 0xc9, 0x5e, 0x70, 0x90, 0x26, 0xd7, 0x34, 0xe3, + 0x34, 0xf3, 0x38, 0xc7, 0x19, 0x1d, 0xc5, 0x00, 0xc6, 0x4e, 0x07, 0x47, 0x7e, 0x7e, 0x1a, 0xf9, 0xc9, 0x98, 0x86, + 0xde, 0x33, 0x5e, 0x60, 0xca, 0x89, 0x3d, 0x62, 0x89, 0x1f, 0xb3, 0x5f, 0x69, 0x68, 0x2b, 0x81, 0xf0, 0xcc, 0xa2, + 0x37, 0x9c, 0x26, 0x61, 0x6e, 0x3d, 0x7f, 0xff, 0xea, 0xa5, 0x5a, 0xca, 0x9a, 0x8c, 0x40, 0x8b, 0x7c, 0x36, 0xa5, + 0x99, 0x83, 0xb0, 0x92, 0x11, 0xcf, 0x98, 0xe0, 0x8f, 0xaf, 0xfc, 0xa9, 0x2c, 0x61, 0xf9, 0x87, 0x69, 0xe8, 0x73, + 0xfa, 0x96, 0x26, 0x21, 0x4b, 0xc6, 0x64, 0xa7, 0x23, 0xcb, 0x23, 0x5f, 0xbd, 0x08, 0xcb, 0xa2, 0x8b, 0xdd, 0x67, + 0xb1, 0x98, 0x79, 0xf9, 0x38, 0x73, 0x50, 0x91, 0x73, 0x9f, 0xb3, 0xc0, 0xf2, 0xc3, 0xf0, 0x45, 0xc2, 0x38, 0x13, + 0x00, 0x66, 0xb0, 0x40, 0x40, 0xa5, 0x54, 0x4a, 0x0b, 0x0d, 0xb8, 0x83, 0xb0, 0xe3, 0x28, 0x19, 0x10, 0x21, 0xb5, + 0x62, 0x7b, 0x7b, 0x15, 0xc7, 0x1f, 0x50, 0x4f, 0xbe, 0x24, 0xe7, 0x43, 0xe4, 0x4e, 0x67, 0x39, 0x2c, 0xb5, 0x1e, + 0x02, 0x04, 0x4c, 0x7a, 0x95, 0xd3, 0xec, 0x9a, 0x86, 0x25, 0x79, 0xe4, 0x0e, 0x5a, 0xac, 0x8c, 0xa1, 0x76, 0x06, + 0x27, 0xe7, 0xc3, 0x9e, 0xc9, 0xba, 0xa9, 0x22, 0xf5, 0x2c, 0x9d, 0xd2, 0x8c, 0x33, 0x9a, 0x97, 0xdc, 0xc4, 0x01, + 0x41, 0x5a, 0x72, 0x94, 0x84, 0xe8, 0xf9, 0x4d, 0x1d, 0x86, 0x29, 0xaa, 0xf1, 0x0c, 0x2d, 0x6b, 0x9f, 0x5d, 0x0b, + 0xa1, 0x91, 0x60, 0x86, 0x30, 0x97, 0x90, 0x26, 0x08, 0x15, 0x08, 0x73, 0x0d, 0xae, 0xe4, 0x46, 0x6a, 0xb4, 0x5b, + 0x90, 0xd6, 0xe4, 0xaf, 0x42, 0x5a, 0x03, 0x4f, 0xf3, 0x39, 0xdd, 0xdb, 0x73, 0xa8, 0x5b, 0x92, 0x05, 0xd9, 0xe9, + 0xa8, 0x35, 0x32, 0x90, 0xb5, 0x05, 0x6c, 0x18, 0x98, 0x63, 0x8a, 0xf0, 0x0e, 0x75, 0x93, 0xf4, 0x24, 0x08, 0x68, + 0x9e, 0xa7, 0xd9, 0xde, 0xde, 0x8e, 0xa8, 0x5f, 0x2a, 0x14, 0xb0, 0x86, 0x6f, 0xe6, 0x49, 0x05, 0x01, 0xaa, 0x84, + 0xac, 0x12, 0x0d, 0x1c, 0x44, 0x95, 0xd0, 0x39, 0xec, 0x81, 0xd6, 0x3d, 0x3c, 0xfb, 0xe2, 0xc2, 0x6e, 0x70, 0xac, + 0xd0, 0x30, 0xa6, 0x7a, 0xe8, 0xdb, 0xa7, 0x54, 0x6a, 0x57, 0x42, 0xf7, 0x58, 0xc3, 0x8c, 0xdc, 0x41, 0x6e, 0x48, + 0x47, 0x2c, 0x31, 0xa6, 0x5d, 0x03, 0x09, 0x73, 0x9c, 0xa0, 0xc2, 0x58, 0xd0, 0x8d, 0x5d, 0x0b, 0xb5, 0x46, 0xae, + 0xdc, 0x62, 0x2c, 0x54, 0x09, 0x63, 0x19, 0xcf, 0xe9, 0xb0, 0xc0, 0x02, 0xf5, 0x7a, 0x36, 0x99, 0x00, 0xf4, 0x9c, + 0x0f, 0x7b, 0xea, 0x3d, 0x49, 0x24, 0xe6, 0x32, 0xfa, 0xcb, 0x8c, 0xe6, 0x5c, 0xd2, 0xb1, 0xc3, 0x71, 0x86, 0x19, + 0xf0, 0xeb, 0x34, 0x19, 0xb1, 0xf1, 0x2c, 0x03, 0x8d, 0x07, 0x36, 0x23, 0x4d, 0x66, 0x13, 0xaa, 0x9f, 0x36, 0xc1, + 0xf6, 0x66, 0x0a, 0x32, 0x31, 0x07, 0x9a, 0xbe, 0x9b, 0x9c, 0x00, 0x56, 0x8e, 0x96, 0xcb, 0xbf, 0xea, 0x4e, 0xaa, + 0xa5, 0x2c, 0xb5, 0xb4, 0x95, 0x35, 0xa1, 0x1c, 0x29, 0x99, 0xbc, 0xd3, 0x51, 0xe0, 0xf3, 0x21, 0xd9, 0x69, 0x97, + 0x34, 0xac, 0xb0, 0x2a, 0xc1, 0x91, 0x48, 0x7c, 0x23, 0xbb, 0x42, 0x42, 0xc4, 0xd7, 0xc8, 0xc5, 0x8d, 0xd6, 0x28, + 0x35, 0x22, 0xe7, 0xa0, 0x6c, 0xb8, 0xd1, 0x70, 0x1b, 0x39, 0x69, 0x7e, 0xe0, 0xf0, 0xf5, 0x77, 0x15, 0xdb, 0xb8, + 0xae, 0xb3, 0x8d, 0x95, 0x69, 0xd8, 0xd3, 0xb2, 0x89, 0x5d, 0x52, 0x99, 0xda, 0xe8, 0xd5, 0x2b, 0xcc, 0x04, 0x30, + 0xd5, 0x94, 0x8c, 0x2e, 0x5e, 0xfb, 0x13, 0x9a, 0x3b, 0x14, 0xe1, 0x6d, 0x15, 0x24, 0x79, 0x42, 0x95, 0xa1, 0x21, + 0x3b, 0x13, 0x90, 0x9d, 0x0c, 0x49, 0xd5, 0xac, 0xbe, 0xe1, 0x12, 0x4c, 0xcf, 0x93, 0x61, 0xa5, 0xd1, 0x19, 0x93, + 0x17, 0x42, 0x39, 0x27, 0xb5, 0xed, 0x26, 0xcb, 0x24, 0xd2, 0x84, 0xe6, 0x90, 0x23, 0xbc, 0xd3, 0x5e, 0x5d, 0x49, + 0x5d, 0xab, 0x9a, 0xe3, 0xf9, 0x10, 0xd6, 0x41, 0x88, 0x0c, 0x97, 0xe5, 0xe2, 0xdf, 0xda, 0x4e, 0x03, 0xb4, 0x9d, + 0x01, 0x61, 0xb8, 0xa3, 0xd8, 0xe7, 0x4e, 0xa7, 0xd5, 0x06, 0x75, 0xf4, 0x9a, 0x82, 0x44, 0x41, 0x68, 0x7d, 0x2a, + 0xd4, 0x9d, 0x25, 0x79, 0xc4, 0x46, 0xdc, 0x09, 0xb8, 0x60, 0x29, 0x34, 0xce, 0xa9, 0xc5, 0x6b, 0x4a, 0xb1, 0x60, + 0x37, 0x01, 0x10, 0x5b, 0xa9, 0x81, 0x51, 0x0d, 0xa9, 0x60, 0x5b, 0xc0, 0x1d, 0x2a, 0x85, 0xba, 0xe2, 0x32, 0xba, + 0x36, 0x03, 0xa5, 0xb1, 0x33, 0x90, 0x3d, 0x7a, 0x8a, 0x19, 0x30, 0x43, 0x6f, 0x65, 0x9e, 0xc9, 0x21, 0x54, 0x21, + 0x77, 0x79, 0xfa, 0x32, 0x9d, 0xd3, 0xec, 0xd4, 0x07, 0xe0, 0x3d, 0xd9, 0xbc, 0x90, 0x82, 0x40, 0xf0, 0x7b, 0xde, + 0xd3, 0xf4, 0x72, 0x21, 0x26, 0xfe, 0x36, 0x4b, 0x27, 0x2c, 0xa7, 0xa0, 0xae, 0x49, 0xfc, 0x27, 0xb0, 0xcf, 0xc4, + 0x86, 0x04, 0x61, 0x43, 0x4b, 0xfa, 0x3a, 0x79, 0x59, 0xa7, 0xaf, 0x8b, 0xdd, 0x67, 0x63, 0xcd, 0x00, 0xeb, 0xdb, + 0x18, 0x61, 0x47, 0x19, 0x15, 0x86, 0x9c, 0x73, 0x23, 0xa4, 0x44, 0xfc, 0x72, 0xc9, 0x0d, 0xdb, 0xad, 0xa6, 0x30, + 0x52, 0xb9, 0x6d, 0x50, 0xe1, 0x87, 0x21, 0xa8, 0x76, 0x59, 0x1a, 0xc7, 0x86, 0xa8, 0xc2, 0xac, 0x57, 0x0a, 0xa7, + 0x8b, 0xdd, 0x67, 0x67, 0x77, 0xc9, 0x27, 0x78, 0x6f, 0x8a, 0x28, 0x0d, 0x68, 0x12, 0xd2, 0x0c, 0x6c, 0x49, 0x63, + 0xb5, 0x94, 0x94, 0x3d, 0x4d, 0x93, 0x84, 0x06, 0x9c, 0x86, 0x60, 0xaa, 0x30, 0xc2, 0xdd, 0x28, 0xcd, 0x79, 0x59, + 0x58, 0x41, 0xcf, 0x0c, 0xe8, 0x99, 0x1b, 0xf8, 0x71, 0xec, 0x48, 0xb3, 0x64, 0x92, 0x5e, 0xd3, 0x0d, 0x50, 0xf7, + 0x6a, 0x20, 0x97, 0xdd, 0x50, 0xa3, 0x1b, 0xea, 0xe6, 0xd3, 0x98, 0x05, 0xb4, 0x14, 0x5d, 0x67, 0x2e, 0x4b, 0x42, + 0x7a, 0x03, 0x7c, 0x04, 0xf5, 0xfb, 0xfd, 0x36, 0xee, 0xa0, 0x42, 0x22, 0x7c, 0xb1, 0x86, 0xd8, 0x3b, 0x84, 0x26, + 0x10, 0x19, 0xe9, 0x2f, 0x36, 0xb2, 0x35, 0x64, 0x48, 0x4a, 0xa6, 0xcd, 0x2b, 0xc9, 0x9d, 0x11, 0x0e, 0x69, 0x4c, + 0x39, 0xd5, 0xdc, 0x1c, 0x94, 0x68, 0xb9, 0x75, 0xdf, 0x95, 0xf8, 0x2b, 0xc9, 0x49, 0xef, 0x32, 0xbd, 0xe6, 0x79, + 0x69, 0xae, 0x57, 0xcb, 0x53, 0x61, 0x7b, 0xc0, 0xe5, 0xf2, 0xf8, 0x9c, 0xfb, 0x41, 0x24, 0xed, 0x74, 0x67, 0x6d, + 0x4a, 0x55, 0x1f, 0x8a, 0xb3, 0x97, 0x9b, 0xe8, 0x89, 0x06, 0x73, 0x13, 0x0a, 0xce, 0x14, 0x53, 0xa0, 0x60, 0xfa, + 0xc9, 0x65, 0x3b, 0xf5, 0xe3, 0xf8, 0xca, 0x0f, 0x3e, 0xd6, 0xa9, 0xbf, 0x22, 0x03, 0xb2, 0xca, 0x8d, 0x8d, 0x57, + 0x06, 0xcb, 0x32, 0xe7, 0xad, 0xb9, 0x74, 0x6d, 0xa3, 0x38, 0x3b, 0xed, 0x8a, 0xec, 0xeb, 0x0b, 0xbd, 0x95, 0xda, + 0x05, 0x44, 0x4c, 0xcd, 0xcc, 0x01, 0x2e, 0xf0, 0x49, 0x8a, 0xd3, 0xfc, 0x40, 0xd1, 0x1d, 0x18, 0x1c, 0xc5, 0x0a, + 0x20, 0x1c, 0x2d, 0x8a, 0x90, 0xe5, 0xdb, 0x31, 0xf0, 0x87, 0x40, 0xf9, 0xd4, 0x18, 0xe1, 0xbe, 0x80, 0x96, 0x3c, + 0x4e, 0x69, 0xcd, 0x25, 0x64, 0x4a, 0x9f, 0xd0, 0x8c, 0xe6, 0x1b, 0xd0, 0x5d, 0x04, 0xbd, 0xbf, 0x91, 0xaf, 0x40, + 0x2b, 0x03, 0x28, 0x92, 0x9e, 0xa9, 0x4e, 0xd4, 0x28, 0x40, 0xf1, 0x54, 0x26, 0x44, 0x6e, 0x56, 0xb3, 0x20, 0x95, + 0xc6, 0x2e, 0x8d, 0x70, 0xc5, 0x72, 0x53, 0xe2, 0x38, 0x4e, 0x02, 0x46, 0x9c, 0xd6, 0xed, 0xab, 0x49, 0x24, 0x6b, + 0x93, 0x48, 0x5c, 0xc3, 0xd0, 0x42, 0x15, 0x2d, 0x1b, 0xcd, 0x3d, 0xce, 0x91, 0x59, 0x0b, 0xf4, 0x55, 0x17, 0x18, + 0x34, 0x2a, 0xf9, 0x6d, 0x4c, 0x38, 0x4e, 0x95, 0x95, 0xa3, 0x48, 0x0d, 0x38, 0x46, 0xd5, 0x24, 0x43, 0x72, 0x6f, + 0xd4, 0x4c, 0xde, 0x0c, 0xa7, 0x68, 0x45, 0xb9, 0x2f, 0x0a, 0x85, 0x24, 0x8a, 0xd4, 0xe2, 0xd4, 0xb4, 0x62, 0x03, + 0x2d, 0x38, 0x23, 0x89, 0xd4, 0x84, 0xa5, 0xe2, 0xb3, 0x8a, 0x9c, 0xb2, 0xdf, 0x1d, 0x42, 0xb2, 0x0a, 0x37, 0x89, + 0xbb, 0x41, 0xb7, 0xca, 0x10, 0x8e, 0xb4, 0x52, 0x9a, 0x56, 0x13, 0x27, 0xc4, 0xd6, 0x3e, 0x09, 0x7b, 0xb0, 0xa8, + 0xd9, 0x85, 0x9e, 0x51, 0xad, 0xf0, 0x80, 0xa7, 0xa6, 0x9b, 0xf0, 0xbd, 0x89, 0x68, 0x6a, 0xfd, 0x18, 0x18, 0x4f, + 0x6b, 0x18, 0x37, 0x50, 0x9b, 0x49, 0xde, 0x95, 0x0d, 0x49, 0x54, 0x6f, 0xec, 0x50, 0x9c, 0xca, 0x85, 0x58, 0xc3, + 0xe2, 0xaa, 0xf2, 0x29, 0x88, 0x10, 0xcc, 0xd8, 0x04, 0xd4, 0x3b, 0x53, 0x42, 0x38, 0x00, 0x3c, 0x5b, 0x2e, 0xd7, + 0xc8, 0x6e, 0xa3, 0x0e, 0x8a, 0xdc, 0xca, 0x32, 0x5c, 0x2e, 0x9f, 0x71, 0xe4, 0x28, 0xed, 0x17, 0x53, 0x34, 0xd0, + 0x3c, 0xf7, 0xe4, 0x25, 0xd4, 0x12, 0xca, 0x68, 0x55, 0x52, 0x9a, 0x0d, 0x75, 0xaa, 0xad, 0x2f, 0x14, 0x37, 0x18, + 0xf7, 0xe9, 0x1a, 0xff, 0x12, 0x85, 0x4a, 0x50, 0x57, 0x53, 0x3e, 0x55, 0x5d, 0x33, 0x84, 0x90, 0x97, 0x08, 0x4b, + 0x66, 0x67, 0x93, 0x71, 0xb9, 0xb7, 0x97, 0x18, 0x1d, 0x5d, 0x94, 0x8c, 0xe2, 0x67, 0x07, 0x84, 0x72, 0x7e, 0x9b, + 0x08, 0xed, 0xe5, 0x67, 0x2d, 0x86, 0xd6, 0x4c, 0xd3, 0x76, 0x0f, 0x6c, 0x72, 0x7f, 0xee, 0x33, 0x6e, 0x95, 0xbd, + 0x48, 0x9b, 0xdc, 0xa1, 0x68, 0xa1, 0x94, 0x0d, 0x37, 0xa3, 0xa0, 0x3e, 0x02, 0x57, 0xd0, 0x4a, 0xb4, 0x24, 0xfc, + 0x20, 0xa2, 0xe0, 0x0f, 0xd6, 0x7a, 0x44, 0x69, 0x1b, 0xee, 0x28, 0x39, 0xa2, 0x3a, 0xde, 0x0c, 0x7b, 0xb1, 0xda, + 0xbc, 0x66, 0x0b, 0x4c, 0x69, 0x36, 0x4a, 0xb3, 0x89, 0x7e, 0x57, 0xac, 0x3c, 0x2b, 0xde, 0xc8, 0x46, 0xce, 0xc6, + 0xbe, 0x95, 0x05, 0xd0, 0x5b, 0x31, 0xbc, 0x2b, 0x93, 0xbd, 0x26, 0x4c, 0x4b, 0xf9, 0x2b, 0xdd, 0x82, 0x9a, 0x32, + 0x13, 0xd3, 0xc4, 0x57, 0x3e, 0xd5, 0x9e, 0x74, 0x9b, 0xec, 0x74, 0x7a, 0xa5, 0xdd, 0xa7, 0xa9, 0xa1, 0x27, 0xdd, + 0x1b, 0x4a, 0xa8, 0xa6, 0xb3, 0x38, 0x54, 0xc0, 0x32, 0x84, 0xa9, 0xa2, 0xa3, 0x39, 0x8b, 0xe3, 0xaa, 0xf4, 0x73, + 0x38, 0x7b, 0xa2, 0x38, 0x7b, 0xa6, 0x39, 0x3b, 0xb0, 0x0a, 0xe0, 0xec, 0xb2, 0xbb, 0xaa, 0x79, 0xb6, 0xb6, 0x3d, + 0x33, 0xc9, 0xd3, 0x13, 0x61, 0x4b, 0xc3, 0x78, 0x33, 0x0d, 0x01, 0x2a, 0x75, 0xaf, 0x8f, 0x8e, 0x72, 0xc5, 0x80, + 0x11, 0x28, 0x3d, 0x99, 0xd4, 0x74, 0x53, 0x7c, 0x74, 0x10, 0x4e, 0x0a, 0x5a, 0x52, 0xf6, 0xc9, 0x33, 0xf0, 0xd5, + 0x19, 0xd3, 0x01, 0x31, 0x26, 0x8a, 0x3f, 0x4b, 0x8d, 0xd2, 0xb3, 0x63, 0x6a, 0x76, 0x89, 0x9e, 0x1d, 0xf0, 0xfa, + 0x6a, 0x76, 0xe1, 0xdd, 0xdc, 0x5e, 0x4c, 0x8f, 0x95, 0xd3, 0xab, 0xd6, 0x7b, 0xb9, 0x74, 0x56, 0x4a, 0xc0, 0x8d, + 0xaf, 0x8c, 0x94, 0xac, 0xec, 0x1d, 0x78, 0x80, 0x89, 0x19, 0x28, 0x28, 0xe4, 0xa4, 0x4b, 0x21, 0xf7, 0xf2, 0x53, + 0x4e, 0x1e, 0xe1, 0xad, 0x97, 0xed, 0x4f, 0xd3, 0xc9, 0x14, 0xf4, 0xb1, 0x15, 0x92, 0x1e, 0x53, 0x35, 0x60, 0xf5, + 0xbe, 0xd8, 0x50, 0x56, 0x6b, 0x23, 0xf6, 0x63, 0x8d, 0x9a, 0x4a, 0x9b, 0x79, 0xa7, 0x5d, 0xcc, 0xca, 0xa2, 0x92, + 0x71, 0x6c, 0x72, 0xac, 0x9c, 0xae, 0xba, 0x65, 0xf4, 0x8b, 0x37, 0x0e, 0x93, 0x7c, 0x98, 0x01, 0xaf, 0x33, 0xd8, + 0x8f, 0x26, 0x77, 0x73, 0xfd, 0x8b, 0x0a, 0x39, 0x8b, 0x62, 0x05, 0x7d, 0x8b, 0xa2, 0x78, 0xa6, 0xec, 0x6c, 0xfc, + 0x6c, 0xbb, 0x41, 0x5c, 0xbd, 0x53, 0xf6, 0xe2, 0xf9, 0x10, 0x3f, 0x5b, 0xd7, 0x1e, 0xc9, 0x62, 0x92, 0x86, 0xd4, + 0xb3, 0xd3, 0x29, 0x4d, 0xec, 0x02, 0xbc, 0xab, 0x6a, 0xf1, 0x67, 0xdc, 0x59, 0xbc, 0xab, 0xbb, 0x59, 0xbd, 0x67, + 0x05, 0xb8, 0xc0, 0x7e, 0x5c, 0x77, 0xc0, 0x7e, 0x4b, 0xb3, 0x5c, 0xe8, 0xa2, 0xa5, 0x5a, 0xfb, 0x63, 0x25, 0x98, + 0x7e, 0xf4, 0xb6, 0xd6, 0xaf, 0xac, 0x10, 0xbb, 0xe3, 0x3e, 0x74, 0xf7, 0x6d, 0x24, 0xdc, 0xc3, 0xdf, 0xa8, 0x1d, + 0xff, 0x8b, 0x76, 0x0f, 0x9f, 0x91, 0x5f, 0xea, 0xde, 0xe1, 0x29, 0x27, 0x67, 0x83, 0x33, 0x6d, 0x34, 0xa7, 0x31, + 0x0b, 0x6e, 0x1d, 0x3b, 0x66, 0xbc, 0x09, 0x21, 0x38, 0x1b, 0x2f, 0xe4, 0x0b, 0xf0, 0x2b, 0x0a, 0xb7, 0x76, 0xa1, + 0xcd, 0x3d, 0xcc, 0x38, 0xb1, 0x77, 0x63, 0xc6, 0x77, 0x6d, 0xbc, 0x4b, 0x2e, 0xe1, 0xc7, 0xee, 0xc2, 0x79, 0xe5, + 0xf3, 0xc8, 0xcd, 0xfc, 0x24, 0x4c, 0x27, 0x0e, 0x6a, 0xd8, 0x36, 0x72, 0x73, 0x61, 0x72, 0x3c, 0x46, 0xc5, 0xee, + 0x25, 0x3e, 0xe3, 0xc4, 0x1e, 0xd8, 0x8d, 0x5d, 0xfc, 0x8a, 0x93, 0xcb, 0xe3, 0xdd, 0xc5, 0x19, 0x2f, 0xfa, 0x97, + 0xf8, 0xa4, 0xf4, 0xdc, 0xe3, 0xd7, 0xc4, 0x41, 0xa4, 0x7f, 0xa2, 0xa0, 0x39, 0x4d, 0x27, 0xd2, 0x83, 0x6f, 0x23, + 0xfc, 0x0e, 0xe2, 0x2b, 0x79, 0xc5, 0x6e, 0x54, 0x88, 0x65, 0x87, 0xd8, 0xa9, 0xf0, 0x12, 0xd8, 0x7b, 0x7b, 0x46, + 0x59, 0xa9, 0x2c, 0xe0, 0x53, 0x4e, 0x6a, 0x36, 0x39, 0x7e, 0x29, 0x22, 0x35, 0xa7, 0xdc, 0xc9, 0x91, 0xee, 0xc6, + 0xd1, 0xee, 0x68, 0xb5, 0x37, 0xf3, 0x73, 0xe9, 0x64, 0x70, 0x19, 0xa7, 0x99, 0xcf, 0xd3, 0x6c, 0x88, 0x4c, 0x05, + 0x04, 0xff, 0x8d, 0x5c, 0x9e, 0x5b, 0xff, 0xe9, 0x8b, 0x9f, 0x46, 0x3f, 0x65, 0xc3, 0x4b, 0xfc, 0x81, 0xb4, 0x8e, + 0x9d, 0x81, 0xe7, 0xec, 0x34, 0x9b, 0xcb, 0x9f, 0x5a, 0xe7, 0x7f, 0xf7, 0x9b, 0xbf, 0x9e, 0x34, 0x7f, 0x1c, 0xa2, + 0xa5, 0xf3, 0x53, 0x6b, 0x70, 0xae, 0x9e, 0xce, 0xff, 0xde, 0xff, 0x29, 0x1f, 0x7e, 0x25, 0x0b, 0x77, 0x11, 0x6a, + 0x8d, 0xf1, 0x98, 0x93, 0x56, 0xb3, 0xd9, 0x6f, 0x8d, 0xf1, 0x84, 0x93, 0x16, 0xfc, 0x3b, 0x27, 0xef, 0xe8, 0xf8, + 0xd9, 0xcd, 0xd4, 0xb9, 0xec, 0x2f, 0x77, 0x17, 0x7f, 0x2b, 0xa0, 0xd7, 0xf3, 0xbf, 0xff, 0xf4, 0x53, 0x6e, 0x3f, + 0xe8, 0x93, 0xd6, 0xb0, 0x81, 0x1c, 0x28, 0xfd, 0x8a, 0x88, 0xbf, 0xce, 0xc0, 0x3b, 0xff, 0xbb, 0x82, 0xc2, 0x7e, + 0xf0, 0xd3, 0xe5, 0x71, 0x9f, 0x0c, 0x97, 0x8e, 0xbd, 0x7c, 0x80, 0x96, 0x08, 0x2d, 0x77, 0xd1, 0x25, 0xb6, 0xc7, + 0x36, 0xc2, 0x17, 0x9c, 0xb4, 0x1e, 0xb4, 0xc6, 0x78, 0xc4, 0x49, 0xcb, 0x6e, 0x8d, 0xf1, 0x1b, 0x4e, 0x5a, 0x7f, + 0x77, 0x06, 0x9e, 0x74, 0xb3, 0x2d, 0x85, 0x87, 0x63, 0x09, 0x41, 0x0e, 0x3f, 0xa3, 0xfe, 0x92, 0x33, 0x1e, 0x53, + 0xb4, 0xdb, 0x62, 0xf8, 0xa3, 0x40, 0x93, 0xc3, 0xc1, 0x0f, 0x03, 0xe6, 0x9d, 0xb3, 0xb8, 0x80, 0xc5, 0x06, 0x9a, + 0xd9, 0xf5, 0x20, 0xba, 0x03, 0xae, 0x80, 0xdc, 0xe3, 0xf8, 0xda, 0x8f, 0x67, 0x34, 0xf7, 0x68, 0x81, 0x70, 0x4c, + 0x3e, 0x72, 0xa7, 0x83, 0xf0, 0x0b, 0x0e, 0x3f, 0xba, 0x08, 0x9f, 0xaa, 0x40, 0x26, 0xec, 0x64, 0x49, 0x54, 0x49, + 0x2a, 0x55, 0x16, 0x1b, 0xe1, 0xf1, 0x86, 0x97, 0x3c, 0x02, 0x07, 0x03, 0xc2, 0xd7, 0xb5, 0xb0, 0x27, 0xbe, 0x21, + 0x9a, 0x24, 0xde, 0x67, 0x94, 0x7e, 0xe7, 0xc7, 0x1f, 0x69, 0xe6, 0x9c, 0xe0, 0x4e, 0xf7, 0x31, 0x16, 0x7e, 0xe8, + 0x9d, 0x0e, 0xea, 0x95, 0x31, 0xab, 0xb7, 0x5c, 0x86, 0x0a, 0x40, 0xca, 0xd6, 0xdd, 0x31, 0xb0, 0xe2, 0x3b, 0xeb, + 0x3e, 0xab, 0xcc, 0x9f, 0xdb, 0xa8, 0x1e, 0x1f, 0x65, 0xc9, 0xb5, 0x1f, 0xb3, 0xd0, 0xe2, 0x74, 0x32, 0x8d, 0x7d, + 0x4e, 0x2d, 0x35, 0x5f, 0xcb, 0x87, 0x8e, 0xec, 0x52, 0x67, 0x98, 0x1a, 0x36, 0xe7, 0x54, 0x07, 0x9e, 0x60, 0xaf, + 0x38, 0x10, 0xa5, 0x52, 0x7a, 0xc7, 0xd3, 0x2a, 0x08, 0xb6, 0x1a, 0xe7, 0x6b, 0x76, 0xc0, 0x17, 0x36, 0x14, 0xf2, + 0x39, 0xc1, 0x19, 0x01, 0x29, 0xda, 0x1d, 0xd8, 0xc7, 0xf9, 0xf5, 0xb8, 0x6f, 0x43, 0x8c, 0x26, 0x25, 0x1f, 0x84, + 0x6b, 0x08, 0x2a, 0x44, 0xa4, 0xdd, 0x8b, 0x8e, 0x69, 0x2f, 0x6a, 0x34, 0xb4, 0x16, 0xed, 0x93, 0xfc, 0x3c, 0x92, + 0xcd, 0x03, 0x1c, 0xe2, 0x19, 0x69, 0x76, 0xf0, 0x94, 0xb4, 0x45, 0x93, 0xde, 0xf4, 0xd8, 0x57, 0xc3, 0xec, 0xed, + 0x39, 0xa9, 0x1b, 0xfb, 0x39, 0x7f, 0x01, 0xf6, 0x3e, 0x99, 0xe2, 0x90, 0xa4, 0x2e, 0xbd, 0xa1, 0x81, 0xe3, 0x23, + 0x1c, 0x2a, 0x4e, 0x83, 0x7a, 0x68, 0x4a, 0x8c, 0x6a, 0x60, 0x46, 0x90, 0x0f, 0x83, 0xf0, 0xbc, 0x33, 0x24, 0x84, + 0xd8, 0x3b, 0xcd, 0xa6, 0x3d, 0x48, 0xc9, 0x98, 0x7b, 0x50, 0x62, 0x28, 0xcb, 0x64, 0x02, 0x45, 0x5d, 0xa3, 0xc8, + 0x79, 0xc3, 0x5d, 0x4e, 0x73, 0xee, 0x40, 0x31, 0x78, 0x00, 0x12, 0x4d, 0xd8, 0xf6, 0x71, 0xcb, 0x6e, 0x40, 0xa9, + 0x20, 0x4e, 0x84, 0x53, 0x32, 0x47, 0x5e, 0x78, 0xbe, 0x3f, 0x34, 0x05, 0x80, 0x28, 0x84, 0xc1, 0xe7, 0x83, 0xf0, + 0xbc, 0x2d, 0x06, 0xef, 0xdb, 0x03, 0x27, 0x25, 0xc9, 0x8e, 0x8a, 0xde, 0x78, 0x1f, 0xc4, 0x54, 0x91, 0xa7, 0x80, + 0x53, 0xe3, 0xce, 0x48, 0xb3, 0xeb, 0x39, 0x33, 0x73, 0x12, 0x4d, 0x18, 0x4c, 0x61, 0x01, 0x07, 0x04, 0xea, 0xe3, + 0x94, 0xc0, 0x88, 0x55, 0xb3, 0xb9, 0xa7, 0x9e, 0x1f, 0xd8, 0x0f, 0x06, 0x23, 0xee, 0x5d, 0x70, 0x39, 0xfc, 0x88, + 0x2f, 0x97, 0xf0, 0xef, 0x05, 0x1f, 0xa4, 0x64, 0x2e, 0x8a, 0xc6, 0xaa, 0x68, 0x02, 0x45, 0x1f, 0x3c, 0x00, 0x15, + 0x27, 0xa5, 0x96, 0x25, 0xd7, 0x64, 0x42, 0x04, 0xec, 0x7b, 0x7b, 0xf9, 0x79, 0xd4, 0xe8, 0x0c, 0xc1, 0xc9, 0x9f, + 0xf1, 0xfc, 0x3b, 0xc6, 0x23, 0xc7, 0x6e, 0xf5, 0x6d, 0x34, 0xb0, 0x2d, 0x58, 0xda, 0x5e, 0xd6, 0x20, 0x12, 0xc3, + 0x7e, 0xe3, 0x15, 0xf7, 0x66, 0x7d, 0xd2, 0x1e, 0x38, 0x4c, 0xb9, 0xf4, 0x10, 0xf6, 0x15, 0xe3, 0x6c, 0xe3, 0x19, + 0x6a, 0x30, 0xde, 0xd0, 0xcf, 0x33, 0xd4, 0xd8, 0x6d, 0x4c, 0x90, 0xe7, 0x37, 0x76, 0x1b, 0xce, 0x8c, 0x10, 0xd2, + 0xec, 0x96, 0xcd, 0xb4, 0xf8, 0x8b, 0x90, 0x37, 0xd1, 0xfe, 0xce, 0x73, 0xb1, 0x1d, 0xb2, 0x86, 0x03, 0x2e, 0x96, + 0xe5, 0xd2, 0x3e, 0x1e, 0xf4, 0x6d, 0xd4, 0x70, 0x34, 0xa1, 0xb5, 0x34, 0xa5, 0x21, 0x84, 0xd9, 0xb0, 0x50, 0xf1, + 0xa4, 0x27, 0xb5, 0xd8, 0xd1, 0xa2, 0xda, 0xec, 0x06, 0x0f, 0xa0, 0x45, 0x69, 0xc8, 0x48, 0x85, 0x75, 0x0a, 0xd3, + 0xd4, 0xc4, 0x9c, 0x91, 0x36, 0x4e, 0x89, 0x76, 0x5f, 0x47, 0x84, 0x57, 0x04, 0xef, 0x93, 0xaa, 0x3a, 0x3e, 0x0f, + 0x70, 0x38, 0x24, 0x4f, 0xa5, 0x41, 0xd2, 0xd3, 0xce, 0x71, 0x1a, 0x93, 0x27, 0x2b, 0x51, 0xdc, 0x00, 0x02, 0x2c, + 0x37, 0x6e, 0x30, 0xcb, 0x32, 0x9a, 0xf0, 0xd7, 0x69, 0xa8, 0xf4, 0x34, 0x1a, 0x83, 0xa9, 0x04, 0xe1, 0x59, 0x0c, + 0x4a, 0x5a, 0x57, 0xef, 0x8c, 0xd9, 0xda, 0xeb, 0x29, 0x99, 0x49, 0xfd, 0x49, 0x04, 0x6d, 0x7b, 0x53, 0x65, 0x19, + 0x3b, 0x08, 0xcf, 0x54, 0x34, 0xd7, 0x71, 0x5d, 0x77, 0xea, 0x06, 0xf0, 0x1a, 0x06, 0xc8, 0x51, 0x21, 0xf6, 0x91, + 0x93, 0x90, 0x1b, 0x37, 0xa1, 0x37, 0x62, 0x54, 0x07, 0x55, 0x92, 0x59, 0x6f, 0xaf, 0xe3, 0xa8, 0x27, 0xd8, 0x4d, + 0xe2, 0x26, 0x69, 0x48, 0x01, 0x3d, 0x10, 0xbf, 0x57, 0x45, 0x91, 0x9f, 0x9b, 0x41, 0xaa, 0x0a, 0xbe, 0x73, 0xd3, + 0x7f, 0x3d, 0x05, 0xa7, 0xaf, 0xb0, 0x88, 0xcb, 0xca, 0xd2, 0x13, 0x8e, 0x10, 0x1b, 0x39, 0x53, 0x17, 0x82, 0x7b, + 0x82, 0x84, 0x18, 0xd8, 0x72, 0x53, 0x93, 0xa8, 0x76, 0xcb, 0x3e, 0x27, 0x24, 0x3c, 0x4f, 0x1b, 0x0d, 0xe1, 0x88, + 0x9e, 0x49, 0x92, 0x98, 0x22, 0x3c, 0x29, 0xf7, 0x96, 0xae, 0xf7, 0x96, 0xd4, 0x47, 0x72, 0x26, 0x75, 0x87, 0x6e, + 0x83, 0x71, 0x24, 0x7c, 0x85, 0xdc, 0xd9, 0x45, 0xf8, 0x82, 0xb4, 0x9c, 0x73, 0x77, 0xf0, 0xe7, 0x21, 0x1a, 0x38, + 0xee, 0x57, 0xa8, 0x25, 0x19, 0xc7, 0x04, 0xf5, 0x7c, 0x39, 0xc4, 0x42, 0x44, 0x31, 0x3b, 0x58, 0xf8, 0x12, 0xbd, + 0x0c, 0x27, 0xfe, 0x84, 0x7a, 0x17, 0xb0, 0xc7, 0x35, 0xdd, 0xbc, 0xc5, 0x40, 0x47, 0xde, 0x85, 0xe2, 0x24, 0xae, + 0x3d, 0xf8, 0x85, 0x97, 0x4f, 0x03, 0x7b, 0xf0, 0x75, 0xf5, 0xf4, 0x67, 0x7b, 0xf0, 0x2d, 0xf7, 0xbe, 0x2d, 0x94, + 0xbb, 0xbb, 0x36, 0xc4, 0x43, 0x3d, 0x44, 0x21, 0x17, 0xc6, 0xc0, 0xdc, 0x0c, 0x25, 0x6b, 0x8e, 0x8e, 0x29, 0x2a, + 0xd8, 0xa8, 0x64, 0x45, 0x89, 0xcb, 0xfd, 0x31, 0xa0, 0xd4, 0x58, 0x81, 0xc4, 0x8c, 0xee, 0x57, 0x13, 0x06, 0x42, + 0xd1, 0xd4, 0x0a, 0xa8, 0x9c, 0xf6, 0xdb, 0x68, 0x51, 0xab, 0x2b, 0x34, 0xa6, 0x7a, 0x34, 0xbd, 0xe4, 0xd2, 0x13, + 0xd2, 0xee, 0x4d, 0x8e, 0xa7, 0xbd, 0x49, 0xa3, 0x81, 0x12, 0x4d, 0x58, 0xb3, 0xf3, 0xc9, 0x10, 0xbf, 0x06, 0xaf, + 0x9e, 0x49, 0x49, 0xb8, 0x36, 0xbd, 0xae, 0x9a, 0x5e, 0xa3, 0x91, 0x15, 0xa8, 0x67, 0x34, 0x9d, 0xca, 0xa6, 0x45, + 0x21, 0x71, 0xb2, 0x4a, 0x68, 0x47, 0x48, 0x94, 0x40, 0x4a, 0x14, 0x21, 0xe4, 0x8c, 0xa3, 0x8d, 0xbd, 0x42, 0x9f, + 0xd0, 0x5c, 0xec, 0x58, 0x60, 0x9e, 0x52, 0x46, 0x38, 0x80, 0x05, 0x68, 0x5a, 0xba, 0x82, 0x77, 0xf1, 0xac, 0xd1, + 0x11, 0x44, 0xde, 0xec, 0xf4, 0xea, 0x7d, 0x3d, 0xaa, 0xfa, 0xc2, 0xb3, 0x06, 0xd9, 0x2d, 0xb1, 0x54, 0x64, 0x8d, + 0x46, 0x51, 0x8f, 0x77, 0xea, 0x7d, 0x5b, 0x8b, 0x40, 0x9c, 0xac, 0xa6, 0x66, 0x68, 0xf9, 0x5a, 0x49, 0x54, 0xe6, + 0xb2, 0x24, 0xa1, 0x19, 0xc8, 0x50, 0xc2, 0x31, 0x2b, 0x8a, 0x52, 0xae, 0xbf, 0x01, 0x21, 0x8a, 0x29, 0xc9, 0x81, + 0xef, 0x08, 0xb3, 0x0b, 0x67, 0x38, 0xc5, 0x91, 0xe0, 0x1a, 0x84, 0x90, 0x53, 0x9d, 0xd4, 0xc2, 0x05, 0x07, 0xf2, + 0x09, 0x33, 0x24, 0x52, 0x42, 0xa8, 0x7b, 0xb1, 0x7b, 0x9a, 0xde, 0x69, 0x92, 0x9d, 0xb3, 0xa1, 0x27, 0xaa, 0xc5, + 0x8a, 0x6f, 0x05, 0xe4, 0x9d, 0xc3, 0x51, 0x19, 0x1e, 0x71, 0x05, 0xfb, 0x7b, 0xca, 0x32, 0x2a, 0x34, 0xf0, 0x5d, + 0x6d, 0xf6, 0xf9, 0x75, 0xf5, 0xd1, 0x37, 0x9d, 0x37, 0x80, 0xc8, 0x00, 0x7c, 0x3b, 0x19, 0x59, 0xab, 0x76, 0xb1, + 0x7b, 0xf2, 0x66, 0x93, 0x09, 0xbc, 0x5c, 0x2a, 0xe3, 0xd7, 0x07, 0xcd, 0x06, 0x07, 0x15, 0xa4, 0xbe, 0xfa, 0xe1, + 0x39, 0xbe, 0x50, 0x90, 0x02, 0x27, 0x07, 0x2a, 0xba, 0xd8, 0x3d, 0x79, 0xef, 0xe4, 0xc2, 0xb5, 0x84, 0xb0, 0x39, + 0x6d, 0x27, 0x25, 0x4e, 0x44, 0x28, 0x92, 0x73, 0x2f, 0x19, 0x57, 0x6a, 0x88, 0x6f, 0x2f, 0x12, 0x2f, 0xc1, 0x7e, + 0x38, 0x67, 0x43, 0xe2, 0x2b, 0x0c, 0x10, 0x1f, 0x61, 0xbf, 0x66, 0x96, 0x11, 0x58, 0x00, 0x31, 0xd6, 0x19, 0xac, + 0x84, 0x2b, 0x15, 0x3f, 0x84, 0x7d, 0x31, 0x2a, 0x2f, 0xa4, 0xe8, 0xf8, 0x79, 0x2d, 0x37, 0xad, 0xb2, 0x46, 0xbf, + 0x05, 0xcb, 0x49, 0x3f, 0xbc, 0x56, 0x5d, 0x97, 0x05, 0x4f, 0x75, 0x12, 0xd9, 0xc5, 0xee, 0xc9, 0x2b, 0x95, 0x47, + 0x36, 0xf5, 0x35, 0xb7, 0x5f, 0xb3, 0x30, 0x4f, 0x5e, 0xb9, 0xd5, 0x5b, 0x51, 0xf9, 0x62, 0xf7, 0xe4, 0xc3, 0xa6, + 0x6a, 0x50, 0x5e, 0xcc, 0x2a, 0x13, 0x5f, 0xc0, 0xb7, 0xa0, 0xb1, 0xb7, 0x50, 0xa2, 0xc1, 0x63, 0x05, 0x16, 0xe2, + 0xc8, 0x4b, 0x8a, 0xd2, 0x33, 0xf2, 0x14, 0x67, 0x44, 0xc4, 0x81, 0xea, 0xab, 0xa6, 0x94, 0x3c, 0x96, 0x26, 0x67, + 0x41, 0x3a, 0xa5, 0x5b, 0x82, 0x43, 0x27, 0xc8, 0x65, 0x13, 0x48, 0xa0, 0x11, 0xa0, 0x33, 0xbc, 0xd3, 0x46, 0xbd, + 0xba, 0xf0, 0xca, 0x04, 0x91, 0xa6, 0x35, 0xc9, 0x82, 0x23, 0xd2, 0xc6, 0x3e, 0x69, 0xe3, 0x80, 0x24, 0xe7, 0x6d, + 0x29, 0x1e, 0x7a, 0x41, 0xd9, 0xaf, 0x14, 0x32, 0x90, 0x1b, 0x16, 0xc8, 0xdd, 0x2a, 0xc5, 0x6f, 0xd8, 0x0b, 0x84, + 0xeb, 0x51, 0x48, 0xf4, 0x50, 0x1a, 0xad, 0x4e, 0x8a, 0x53, 0xd1, 0xf1, 0x19, 0xbb, 0x8a, 0x21, 0xbb, 0x04, 0x66, + 0x85, 0x39, 0xf2, 0xca, 0xaa, 0x1d, 0x55, 0x35, 0x70, 0xc5, 0x3a, 0xa5, 0x38, 0x70, 0x81, 0x71, 0xe3, 0x40, 0x25, + 0xe3, 0xe4, 0xeb, 0x4d, 0x1e, 0xee, 0xed, 0x39, 0xb2, 0xd1, 0x77, 0xdc, 0x49, 0xf5, 0xfb, 0x2a, 0x74, 0xf7, 0xad, + 0xe4, 0x15, 0x21, 0x12, 0xf0, 0x37, 0x1a, 0xfe, 0xb0, 0x80, 0x38, 0xb4, 0x13, 0xd4, 0x31, 0xa8, 0x81, 0x17, 0x9a, + 0x5e, 0x7d, 0xfa, 0x8d, 0x46, 0x19, 0xa6, 0xad, 0x63, 0xeb, 0x04, 0x67, 0xc5, 0xb5, 0x53, 0xe6, 0xff, 0xb4, 0xd7, + 0xb2, 0xa6, 0x34, 0x08, 0x88, 0x99, 0x34, 0xcb, 0xf4, 0x64, 0x8c, 0x2d, 0xc1, 0xa0, 0xde, 0x0b, 0x95, 0xb8, 0x80, + 0x45, 0x8e, 0x95, 0xaa, 0xa4, 0xd9, 0x59, 0x17, 0x79, 0xba, 0x12, 0x84, 0xa5, 0xa0, 0x52, 0xa3, 0x50, 0xe4, 0xfd, + 0x6a, 0x3d, 0xf3, 0x12, 0x27, 0x48, 0xf9, 0xb8, 0x04, 0x14, 0x02, 0x59, 0xdd, 0x12, 0x29, 0xcf, 0xc9, 0x78, 0x3b, + 0xc9, 0x9f, 0x18, 0x24, 0xff, 0x84, 0x50, 0x83, 0xfc, 0xa5, 0x87, 0xc3, 0x4d, 0x95, 0x6b, 0x21, 0xd1, 0xaf, 0x4e, + 0xa7, 0x04, 0x7c, 0x68, 0x75, 0x8c, 0x26, 0x66, 0x5c, 0x71, 0x0b, 0x43, 0x31, 0x77, 0x88, 0xf0, 0x42, 0x62, 0x1d, + 0x04, 0x76, 0xaa, 0xa8, 0x1a, 0x0c, 0xbd, 0xc9, 0xa5, 0x67, 0x72, 0xc0, 0x93, 0x0f, 0x77, 0x07, 0x44, 0x4f, 0xa7, + 0xeb, 0x3b, 0xd7, 0xc8, 0x00, 0x85, 0x59, 0x1b, 0x1b, 0xb7, 0x9e, 0x0f, 0x0a, 0xe3, 0x97, 0x81, 0xec, 0x3a, 0xf3, + 0x59, 0xd9, 0x84, 0x5a, 0xfe, 0x01, 0xb4, 0x9d, 0x8e, 0xa8, 0x41, 0x8d, 0x6e, 0x81, 0x1f, 0xc9, 0x3c, 0x54, 0x3f, + 0xdb, 0xc2, 0x3e, 0x4e, 0x44, 0x05, 0x9a, 0x84, 0x9b, 0x5f, 0x3f, 0x29, 0x14, 0x99, 0x48, 0xd0, 0xd0, 0x02, 0xf8, + 0x9f, 0x24, 0x79, 0xa0, 0x1b, 0x21, 0x17, 0x00, 0x41, 0x63, 0x81, 0xa7, 0x0a, 0x61, 0xb6, 0x5d, 0x39, 0xdf, 0x9f, + 0xef, 0x10, 0x32, 0xae, 0x9c, 0x8f, 0xef, 0xaa, 0xec, 0x2b, 0x20, 0x0b, 0xe4, 0x81, 0xf1, 0x58, 0x16, 0xc8, 0xf8, + 0xe5, 0xa9, 0xae, 0x2e, 0x0c, 0x48, 0xb7, 0xd2, 0xb7, 0x8d, 0xd8, 0xa6, 0xf0, 0xca, 0xc9, 0xf7, 0x1a, 0x0d, 0x2b, + 0x6f, 0x77, 0xe1, 0xed, 0x4b, 0x2e, 0x60, 0x84, 0xe7, 0xf7, 0xa2, 0xb6, 0xee, 0xb7, 0xf8, 0xb8, 0x9a, 0xc2, 0xb2, + 0xb2, 0x28, 0x2e, 0x4b, 0x72, 0x9a, 0xf1, 0x27, 0x74, 0x94, 0x66, 0x10, 0xb2, 0x28, 0x71, 0x82, 0x8a, 0x5d, 0xc3, + 0x6d, 0x27, 0xe6, 0x67, 0xc4, 0x09, 0x56, 0x26, 0x28, 0x7e, 0x7d, 0x14, 0x51, 0xeb, 0x8b, 0xd5, 0x56, 0xe3, 0xbd, + 0xbd, 0x77, 0x15, 0x9a, 0x14, 0x94, 0x02, 0x0a, 0x83, 0x69, 0x49, 0x95, 0x46, 0x85, 0x72, 0x77, 0x9d, 0xd2, 0x05, + 0xa0, 0x19, 0x86, 0xc9, 0x7b, 0x9e, 0x13, 0x5e, 0x8c, 0x57, 0x59, 0xbc, 0x72, 0x4d, 0x30, 0xd3, 0x6c, 0x01, 0x0e, + 0x0f, 0x86, 0xb6, 0xf4, 0x15, 0x25, 0x55, 0x4a, 0x6c, 0x09, 0xc3, 0x29, 0x20, 0xcb, 0x49, 0xc0, 0x08, 0x31, 0x28, + 0x30, 0xd9, 0x64, 0x94, 0xbc, 0x05, 0xbd, 0x32, 0xc2, 0x89, 0x1b, 0x41, 0x12, 0x6c, 0x6d, 0xcb, 0x22, 0x84, 0x13, + 0x61, 0xd0, 0x18, 0xb9, 0x04, 0x27, 0xcf, 0x37, 0x79, 0x94, 0x35, 0x51, 0x53, 0x21, 0x75, 0xa0, 0x46, 0x86, 0xca, + 0x06, 0xee, 0xb5, 0xc3, 0x94, 0xe2, 0x56, 0xc6, 0xcd, 0xe8, 0xdc, 0xfa, 0x99, 0x3b, 0x32, 0x16, 0x05, 0x32, 0x23, + 0x75, 0x67, 0x4e, 0x6d, 0xe8, 0x5e, 0x2a, 0x9a, 0x61, 0x85, 0xb8, 0xc8, 0x44, 0x53, 0x2a, 0xe2, 0x7a, 0xa7, 0x15, + 0x2f, 0xbd, 0x96, 0x79, 0xd4, 0x5c, 0x73, 0xc1, 0x2a, 0x93, 0xc4, 0x98, 0xfe, 0xb5, 0x4c, 0x8d, 0x2e, 0x2b, 0x61, + 0x2a, 0xc0, 0x78, 0x22, 0xd6, 0x80, 0x16, 0x40, 0x5f, 0x8b, 0x53, 0x6e, 0xac, 0xa8, 0xf6, 0x61, 0x8b, 0x31, 0x0d, + 0xa9, 0xff, 0x0e, 0x72, 0x5d, 0x56, 0xf7, 0xfc, 0x73, 0x21, 0x0b, 0x19, 0x4e, 0x6a, 0x8c, 0x3d, 0x13, 0x8c, 0x1d, + 0x81, 0x9e, 0xa6, 0xd3, 0xbf, 0x07, 0x2a, 0xe5, 0x45, 0xe5, 0x2e, 0x3a, 0x8a, 0xc4, 0x5e, 0x97, 0xe1, 0x72, 0xe3, + 0xf7, 0xca, 0x6a, 0x78, 0x8c, 0x40, 0x1a, 0x10, 0x56, 0x9c, 0x3d, 0x43, 0x38, 0x69, 0x34, 0x7a, 0xc9, 0x31, 0xad, + 0x5c, 0x24, 0x15, 0x8c, 0x0c, 0x22, 0xba, 0x40, 0xf0, 0x35, 0x19, 0x9a, 0x20, 0x5c, 0xe6, 0xa1, 0x27, 0xe0, 0x6a, + 0x3f, 0x79, 0xe7, 0x98, 0x5c, 0xcd, 0xac, 0x5b, 0x06, 0x4d, 0x61, 0x3e, 0x4e, 0x15, 0x6f, 0x79, 0x7b, 0x77, 0x86, + 0x07, 0xc0, 0xbd, 0xd3, 0xc1, 0x90, 0x8d, 0x86, 0x7a, 0x5c, 0xb2, 0x84, 0x72, 0xf7, 0xf5, 0x50, 0x95, 0x98, 0x68, + 0x0e, 0xd6, 0xe3, 0x95, 0x29, 0xcb, 0x49, 0x52, 0x14, 0x39, 0xad, 0xe2, 0xfb, 0x2b, 0x19, 0x98, 0x42, 0xb8, 0xac, + 0x3b, 0xdb, 0x4f, 0xa7, 0x84, 0x63, 0x83, 0x50, 0xdf, 0x6e, 0x0b, 0x7d, 0x54, 0x60, 0xc2, 0xbe, 0x56, 0x42, 0xf1, + 0xdb, 0x4d, 0x42, 0x11, 0x67, 0x6a, 0xcb, 0x0b, 0x81, 0xd8, 0xb9, 0x87, 0x40, 0x54, 0x4e, 0x76, 0x2d, 0x13, 0x41, + 0x1d, 0xa9, 0xc9, 0xc4, 0xa4, 0x2e, 0x13, 0x33, 0xcc, 0xd4, 0x6a, 0xf4, 0xbb, 0xcb, 0x25, 0x3b, 0x6f, 0x83, 0x13, + 0xc9, 0xb6, 0xe1, 0x67, 0x47, 0xfe, 0x34, 0x38, 0xb1, 0x74, 0x02, 0x3b, 0xac, 0x34, 0x59, 0x90, 0x0b, 0x69, 0xce, + 0x8e, 0xc8, 0xca, 0x12, 0x34, 0xad, 0x28, 0x48, 0x11, 0x38, 0x61, 0x65, 0x94, 0x09, 0x20, 0x16, 0xb2, 0x42, 0x19, + 0x90, 0xce, 0xc6, 0xf4, 0x3f, 0x6d, 0x5e, 0x7e, 0x5a, 0x13, 0xad, 0xc9, 0x15, 0xa9, 0x3e, 0xd4, 0x12, 0x0e, 0x14, + 0x04, 0x4a, 0x3f, 0xdc, 0x11, 0x26, 0x68, 0x25, 0xca, 0x91, 0x29, 0x87, 0x70, 0x1b, 0x5c, 0x68, 0x3b, 0xef, 0x64, + 0x80, 0x77, 0x83, 0x34, 0xc1, 0xa9, 0x41, 0xd7, 0xcf, 0x09, 0xaf, 0xb1, 0x92, 0x88, 0x28, 0x4b, 0x09, 0x07, 0x82, + 0x4c, 0x39, 0xc9, 0xce, 0xdb, 0x43, 0x50, 0x40, 0x7b, 0xfe, 0x71, 0x56, 0x99, 0xc0, 0x7e, 0xa3, 0x81, 0x02, 0x3d, + 0x6a, 0x74, 0xce, 0x1a, 0xfe, 0x10, 0x53, 0xec, 0x4b, 0xc3, 0xe4, 0x74, 0x6f, 0xcf, 0x09, 0xaa, 0x71, 0xcf, 0xfd, + 0x21, 0xc2, 0xe9, 0x72, 0xe9, 0x08, 0xb0, 0x02, 0xb4, 0x5c, 0x06, 0x26, 0x58, 0xe2, 0x35, 0x34, 0x1b, 0x0f, 0x38, + 0x19, 0x0b, 0x01, 0x38, 0x06, 0x08, 0x1b, 0xc4, 0x09, 0x94, 0x73, 0x2f, 0x00, 0x67, 0x54, 0x23, 0x3b, 0xf7, 0x1b, + 0x9d, 0xa1, 0xc1, 0xb8, 0xce, 0xfd, 0x21, 0x09, 0x8a, 0x74, 0x6f, 0x6f, 0x27, 0x51, 0x22, 0xf2, 0x67, 0x10, 0x65, + 0x3f, 0x0b, 0xc9, 0x22, 0x3b, 0x34, 0x57, 0x63, 0xd5, 0x19, 0x50, 0x52, 0x94, 0x5a, 0x56, 0x5d, 0xaf, 0x96, 0x05, + 0x51, 0x56, 0xc2, 0x2a, 0x16, 0x3c, 0x00, 0xcb, 0xbe, 0x24, 0xf3, 0x5f, 0x78, 0x99, 0x66, 0xfd, 0xed, 0xc6, 0xe4, + 0x6a, 0xd7, 0x75, 0xfd, 0x6c, 0x2c, 0x22, 0x19, 0x3a, 0x63, 0x52, 0x10, 0xff, 0xbe, 0x02, 0xd3, 0x18, 0xf8, 0xbc, + 0x1c, 0x6b, 0x48, 0x24, 0xf8, 0x5a, 0xb5, 0xd1, 0x27, 0x4a, 0x7e, 0xdd, 0xe8, 0x65, 0x90, 0x90, 0x7c, 0xfd, 0x5b, + 0x21, 0x39, 0x50, 0x90, 0x48, 0xf2, 0x58, 0xc1, 0xd9, 0x16, 0x5c, 0xfc, 0xca, 0x57, 0x70, 0xb6, 0x1d, 0xb7, 0x25, + 0x43, 0xd8, 0x06, 0x9f, 0xc1, 0x1b, 0x24, 0xa0, 0x55, 0x81, 0x01, 0xe5, 0xe1, 0xaa, 0xee, 0x25, 0x59, 0x29, 0x08, + 0x53, 0x4e, 0x1c, 0x56, 0xdf, 0x00, 0x95, 0x36, 0x6a, 0x18, 0xbe, 0xcc, 0x1b, 0x23, 0xc3, 0x25, 0x50, 0x4f, 0x5d, + 0x01, 0x72, 0x52, 0xbe, 0x76, 0x48, 0x45, 0xd8, 0x91, 0x4a, 0x9c, 0x1b, 0xf8, 0x53, 0x3e, 0xcb, 0x40, 0x95, 0x4a, + 0xf4, 0x6f, 0x28, 0x86, 0xb3, 0x20, 0xa2, 0x0c, 0x7e, 0x40, 0xc1, 0xd4, 0xcf, 0x73, 0x76, 0x2d, 0xcb, 0xd4, 0x6f, + 0x9c, 0x12, 0x4d, 0xca, 0x89, 0xd4, 0x09, 0x33, 0xd4, 0xcb, 0x14, 0x9d, 0xd6, 0xd1, 0xf6, 0xec, 0x9a, 0x26, 0xfc, + 0x25, 0xcb, 0x39, 0x4d, 0x60, 0xfa, 0x15, 0xc5, 0xc1, 0x8c, 0x12, 0x04, 0x1b, 0xb6, 0xd6, 0xca, 0x0f, 0xc3, 0x3b, + 0x9b, 0xf0, 0xba, 0x0e, 0x14, 0xf9, 0x49, 0x18, 0xcb, 0x41, 0xcc, 0x84, 0x46, 0x9d, 0xc4, 0x59, 0xd6, 0x34, 0xf3, + 0x69, 0x2a, 0x65, 0x43, 0x70, 0x77, 0x87, 0x11, 0x2d, 0x09, 0xb4, 0xf4, 0xbc, 0x53, 0x6b, 0x81, 0x80, 0xf7, 0x96, + 0x45, 0x30, 0x67, 0x82, 0xb9, 0xc1, 0x51, 0xdd, 0x3a, 0x9c, 0x9a, 0x6e, 0xbe, 0xdb, 0x78, 0xb0, 0x6d, 0x93, 0x70, + 0x10, 0x74, 0xf2, 0x70, 0xbb, 0x65, 0xf5, 0x4a, 0x4b, 0x0e, 0x2d, 0x2d, 0xd8, 0x7d, 0x19, 0x33, 0x5a, 0x68, 0xf2, + 0x42, 0x7a, 0x2b, 0xde, 0x72, 0xf2, 0x0b, 0x9c, 0x1c, 0x7a, 0xce, 0x27, 0xf1, 0xca, 0x01, 0x99, 0xde, 0x6d, 0xa9, + 0xfd, 0xdf, 0x72, 0xe7, 0x09, 0x7e, 0x05, 0x61, 0xdd, 0x6f, 0xaa, 0xea, 0xeb, 0xe1, 0xdc, 0x6f, 0x2a, 0x04, 0x7d, + 0xe3, 0xad, 0xd5, 0x33, 0xc2, 0xb8, 0x5d, 0xf7, 0xc8, 0x6d, 0xdb, 0x5a, 0x5b, 0xfa, 0x51, 0x06, 0x91, 0x64, 0xaa, + 0xa5, 0xd8, 0x0f, 0xb8, 0x4a, 0x54, 0x83, 0x84, 0xb9, 0xba, 0x85, 0x44, 0x55, 0x8a, 0xa1, 0xd4, 0xe1, 0xb7, 0x2d, + 0x8f, 0x92, 0x31, 0x99, 0xb4, 0x33, 0xde, 0xfa, 0x19, 0xdf, 0x85, 0x5d, 0x96, 0xae, 0x9d, 0xc6, 0x8b, 0x08, 0x78, + 0xd0, 0xee, 0x37, 0x44, 0x75, 0x16, 0x60, 0x90, 0xc8, 0xc3, 0x40, 0x66, 0xff, 0x24, 0xd5, 0xba, 0x5b, 0xdd, 0xca, + 0x78, 0x0d, 0xf6, 0x3f, 0xc2, 0x91, 0x3e, 0x22, 0x47, 0x15, 0x07, 0xa6, 0xde, 0xa2, 0x28, 0x9d, 0x02, 0xa9, 0x54, + 0xde, 0x72, 0x84, 0xd3, 0x42, 0x84, 0xb7, 0xbf, 0xc7, 0x3f, 0x28, 0x96, 0x38, 0x2a, 0x39, 0xce, 0xb3, 0xfb, 0x72, + 0x44, 0x09, 0x7e, 0x19, 0xbd, 0x07, 0x3a, 0x16, 0x14, 0x5a, 0x68, 0x2a, 0x7a, 0x9a, 0xaa, 0x89, 0x6c, 0xcd, 0x4b, + 0xc5, 0xb4, 0xcc, 0xa8, 0x11, 0xc3, 0x6c, 0x48, 0xe4, 0xd4, 0x56, 0x36, 0x2f, 0x77, 0x55, 0x6d, 0x5c, 0xb4, 0x05, + 0x8b, 0x55, 0x60, 0x71, 0xb9, 0x74, 0xea, 0xa8, 0x26, 0xcc, 0x88, 0x63, 0x20, 0xcc, 0x8c, 0x84, 0x8a, 0x9a, 0x66, + 0x2d, 0xdb, 0x38, 0x68, 0x35, 0x9f, 0x48, 0xeb, 0xe6, 0x35, 0x38, 0x4c, 0x17, 0x82, 0x6c, 0x6e, 0xfa, 0x14, 0xb0, + 0x9c, 0x5d, 0x39, 0x90, 0x81, 0xa1, 0x1f, 0xcb, 0x5c, 0xd9, 0x2a, 0xa9, 0x75, 0x03, 0x7e, 0xd1, 0x1d, 0xd9, 0xb2, + 0x0a, 0x75, 0xeb, 0xef, 0x8d, 0x5c, 0xa3, 0xa7, 0xe9, 0xb6, 0x5c, 0xa3, 0x9a, 0xb6, 0xbb, 0xd3, 0x46, 0x77, 0xe7, + 0xa5, 0xca, 0xb1, 0x36, 0x57, 0xf9, 0x0d, 0xc3, 0x75, 0x80, 0x36, 0x25, 0x9a, 0x35, 0x57, 0x39, 0x2d, 0x8a, 0x51, + 0x79, 0x9a, 0x40, 0xa4, 0xee, 0x8c, 0x24, 0xfd, 0x2b, 0xab, 0x51, 0x1c, 0xca, 0x75, 0xbe, 0x27, 0xe3, 0x38, 0xbd, + 0xf2, 0xe3, 0xf7, 0x30, 0x5e, 0xf5, 0xf2, 0xf9, 0x6d, 0x98, 0xf9, 0x9c, 0x2a, 0xee, 0x52, 0xc1, 0xf0, 0xbd, 0x01, + 0xc3, 0xf7, 0x92, 0x4f, 0x57, 0xed, 0xf1, 0xe2, 0x65, 0xd9, 0x81, 0x37, 0x2a, 0x34, 0xcb, 0xd8, 0xe5, 0x9b, 0xc7, + 0x58, 0x65, 0x61, 0xbb, 0x25, 0x0b, 0xdb, 0xe5, 0xce, 0x6a, 0x57, 0x8e, 0xf3, 0xc3, 0xe6, 0x5e, 0xd6, 0x39, 0xdb, + 0x0f, 0xd5, 0xc6, 0xff, 0xc1, 0xbb, 0xb3, 0x8d, 0xc1, 0xe5, 0xf6, 0xdd, 0x7d, 0x91, 0xac, 0x22, 0x41, 0x7e, 0x09, + 0x49, 0x07, 0x9c, 0xf4, 0x8d, 0x43, 0x07, 0x95, 0x9c, 0xd2, 0x79, 0x40, 0x4e, 0x30, 0xcb, 0x79, 0x3a, 0x51, 0x7d, + 0xe6, 0xea, 0xa4, 0x91, 0x78, 0x09, 0xae, 0x68, 0x11, 0x6b, 0xf7, 0xea, 0x67, 0xb9, 0x16, 0x1f, 0x59, 0x12, 0x7a, + 0x09, 0x56, 0x52, 0x24, 0xf7, 0xb2, 0x82, 0xe8, 0x6c, 0xe3, 0xf5, 0x77, 0x78, 0xc4, 0x12, 0x96, 0x47, 0x34, 0x73, + 0x52, 0xb4, 0xd8, 0x36, 0x58, 0x0a, 0x01, 0x19, 0x39, 0x18, 0xfe, 0x6b, 0x75, 0xea, 0xcf, 0x85, 0xde, 0xc0, 0x0f, + 0x34, 0xa1, 0x3c, 0x4a, 0x43, 0x48, 0x4b, 0x71, 0xc3, 0xf2, 0x50, 0xd3, 0xde, 0xde, 0x8e, 0x63, 0x0b, 0xb7, 0x04, + 0x1c, 0x00, 0x37, 0xdf, 0xa0, 0xc1, 0x02, 0xce, 0xe7, 0x54, 0x43, 0x53, 0xb4, 0xa0, 0xab, 0x47, 0x59, 0xb8, 0xfb, + 0x91, 0xde, 0xe2, 0x1c, 0x15, 0x85, 0x27, 0xa1, 0xb6, 0x47, 0x8c, 0xc6, 0xa1, 0x8d, 0x3f, 0xd2, 0x5b, 0xaf, 0x3c, + 0x33, 0x2e, 0x8e, 0x38, 0x8b, 0x05, 0xb4, 0xd3, 0x79, 0x62, 0xe3, 0x6a, 0x10, 0x6f, 0x51, 0xe0, 0x34, 0x63, 0x63, + 0x20, 0xce, 0x6f, 0xe8, 0xad, 0x27, 0xfb, 0x63, 0xc6, 0x79, 0x3d, 0xb4, 0xd0, 0xa8, 0x77, 0x8d, 0x62, 0x73, 0x19, + 0x94, 0x41, 0x71, 0x2e, 0xda, 0x0e, 0x49, 0xad, 0x5e, 0x65, 0x1e, 0x22, 0x54, 0xdc, 0x77, 0x2a, 0xf8, 0x1b, 0x53, + 0xb4, 0xf1, 0x5a, 0xe6, 0xeb, 0x4a, 0x23, 0x0a, 0x0d, 0xaa, 0x4c, 0x0f, 0xc8, 0xe8, 0x58, 0x68, 0xf6, 0x2a, 0x9a, + 0x1b, 0x8e, 0xb0, 0x6f, 0xb8, 0xea, 0xd4, 0xfb, 0xab, 0x4c, 0x08, 0xa9, 0x22, 0x49, 0x2f, 0xaa, 0x76, 0xd6, 0xad, + 0x03, 0x78, 0x87, 0x84, 0x16, 0x5f, 0x9c, 0xc9, 0x2c, 0x74, 0xb6, 0xe8, 0xdf, 0x38, 0x71, 0x16, 0x7a, 0x0a, 0x5e, + 0x6e, 0x62, 0x91, 0x17, 0x40, 0x85, 0x8a, 0xbe, 0x64, 0x02, 0x20, 0x1b, 0x39, 0x6c, 0x4d, 0x6a, 0x66, 0x42, 0x6a, + 0xba, 0x06, 0xc6, 0xb7, 0x48, 0x49, 0x2a, 0x90, 0x21, 0x94, 0x48, 0x21, 0xf4, 0xd4, 0xe2, 0x2a, 0x12, 0x32, 0x17, + 0xb4, 0x3c, 0x41, 0x27, 0xd7, 0x3c, 0xab, 0x81, 0xe5, 0x88, 0x7e, 0x50, 0xe1, 0xc1, 0x94, 0xa8, 0xac, 0x50, 0x68, + 0x77, 0x4e, 0xae, 0xd3, 0x5b, 0x9d, 0xd4, 0xd5, 0xd3, 0x22, 0x1a, 0x25, 0x4e, 0x84, 0x16, 0xb9, 0x13, 0xe1, 0x0c, + 0xd2, 0x11, 0xd3, 0xa2, 0x84, 0x9f, 0x9a, 0xab, 0x51, 0x4b, 0x56, 0xde, 0x7c, 0xca, 0x0f, 0x94, 0x79, 0x0e, 0x29, + 0x9a, 0x38, 0xd7, 0x3c, 0x25, 0x77, 0xc4, 0x71, 0x3b, 0x63, 0xd9, 0xbe, 0x57, 0x09, 0x3a, 0x0a, 0xb0, 0xbf, 0x71, + 0x67, 0x61, 0xcc, 0xc2, 0x3c, 0xd1, 0xad, 0x4e, 0xfd, 0xa9, 0x60, 0x5f, 0x95, 0x43, 0xea, 0x24, 0x64, 0x45, 0xe2, + 0xdc, 0x9d, 0x6a, 0xf9, 0xcb, 0x8c, 0x66, 0xb7, 0x67, 0x14, 0x52, 0x9d, 0x53, 0x38, 0xf0, 0x5b, 0x2d, 0x43, 0x95, + 0xa7, 0x3e, 0xc8, 0x84, 0xb2, 0x52, 0xd4, 0xcf, 0x01, 0xae, 0x9e, 0x12, 0x2c, 0x44, 0xb4, 0xd1, 0x70, 0xc4, 0xc8, + 0xdd, 0x42, 0xb7, 0x9e, 0x9f, 0xa4, 0x3d, 0x06, 0xfe, 0xb5, 0x0a, 0xd3, 0x2a, 0x58, 0x80, 0x53, 0xf3, 0x4c, 0xea, + 0x79, 0x32, 0x5c, 0xf5, 0xca, 0x40, 0x11, 0x84, 0xef, 0xb2, 0xed, 0x53, 0xdd, 0x94, 0x34, 0xbb, 0x7d, 0xaa, 0xb5, + 0xa0, 0x9f, 0x48, 0xf8, 0xc1, 0x6a, 0x9c, 0xf2, 0x04, 0x33, 0x2b, 0x0a, 0x54, 0x00, 0x78, 0x7f, 0xe9, 0x39, 0xce, + 0x5f, 0x54, 0xca, 0xa0, 0x0b, 0xb1, 0xd8, 0xb3, 0x38, 0xd5, 0x4c, 0xbc, 0x1a, 0xff, 0x2f, 0x6b, 0xe3, 0xff, 0xc5, + 0x38, 0x75, 0x0a, 0xa6, 0xd1, 0x38, 0xa1, 0xa1, 0x66, 0x9d, 0x48, 0x12, 0xa0, 0xd0, 0xdb, 0x32, 0x4e, 0x3e, 0x5e, + 0x7a, 0xa0, 0x71, 0x2d, 0x46, 0x69, 0xc2, 0x9b, 0x23, 0x7f, 0xc2, 0xe2, 0x5b, 0x6f, 0xc6, 0x9a, 0x93, 0x34, 0x49, + 0xf3, 0xa9, 0x1f, 0x50, 0x9c, 0xdf, 0xe6, 0x9c, 0x4e, 0x9a, 0x33, 0x86, 0x9f, 0xd3, 0xf8, 0x9a, 0x72, 0x16, 0xf8, + 0xd8, 0x3e, 0xc9, 0x98, 0x1f, 0x5b, 0xaf, 0xfd, 0x2c, 0x4b, 0xe7, 0x36, 0x7e, 0x97, 0x5e, 0xa5, 0x3c, 0xc5, 0x6f, + 0x6e, 0x6e, 0xc7, 0x34, 0xc1, 0x1f, 0xae, 0x66, 0x09, 0x9f, 0xe1, 0xdc, 0x4f, 0xf2, 0x66, 0x4e, 0x33, 0x36, 0xea, + 0x05, 0x69, 0x9c, 0x66, 0x4d, 0xc8, 0xd8, 0x9e, 0x50, 0x2f, 0x66, 0xe3, 0x88, 0x5b, 0xa1, 0x9f, 0x7d, 0xec, 0x35, + 0x9b, 0xd3, 0x8c, 0x4d, 0xfc, 0xec, 0xb6, 0x29, 0x6a, 0x78, 0x5f, 0xb6, 0xf7, 0xfd, 0xc7, 0xa3, 0x83, 0x1e, 0xcf, + 0xfc, 0x24, 0x67, 0xb0, 0x4c, 0x9e, 0x1f, 0xc7, 0xd6, 0xfe, 0x61, 0x7b, 0x92, 0xef, 0xc8, 0x40, 0x9e, 0x9f, 0xf0, + 0xe2, 0x12, 0xbf, 0x07, 0xb8, 0xdd, 0x2b, 0x9e, 0xe0, 0xab, 0x19, 0xe7, 0x69, 0xb2, 0x08, 0x66, 0x59, 0x9e, 0x66, + 0xde, 0x34, 0x65, 0x09, 0xa7, 0x59, 0xef, 0x2a, 0xcd, 0x42, 0x9a, 0x35, 0x33, 0x3f, 0x64, 0xb3, 0xdc, 0x3b, 0x98, + 0xde, 0xf4, 0x40, 0xb3, 0x18, 0x67, 0xe9, 0x2c, 0x09, 0xd5, 0x58, 0x2c, 0x89, 0x68, 0xc6, 0xb8, 0xf9, 0x42, 0x5c, + 0x64, 0xe2, 0xc5, 0x2c, 0xa1, 0x7e, 0xd6, 0x1c, 0x43, 0x63, 0x30, 0x8b, 0xda, 0x21, 0x1d, 0xe3, 0x6c, 0x7c, 0xe5, + 0x3b, 0x9d, 0xee, 0x23, 0xac, 0xff, 0x77, 0x0f, 0x91, 0xd5, 0xde, 0x5c, 0xdc, 0x69, 0xb7, 0xff, 0x84, 0x7a, 0x2b, + 0xa3, 0x08, 0x80, 0xbc, 0xce, 0xf4, 0xc6, 0xca, 0x53, 0xc8, 0x68, 0xdb, 0xd4, 0xb2, 0x37, 0xf5, 0x43, 0xc8, 0x07, + 0xf6, 0xba, 0xd3, 0x9b, 0x02, 0x66, 0xe7, 0xc9, 0x14, 0x53, 0x35, 0x49, 0xf5, 0xb4, 0xf8, 0xad, 0x10, 0x1f, 0x6d, + 0x86, 0xb8, 0xab, 0x21, 0xae, 0xb0, 0xde, 0x0c, 0x67, 0x99, 0x88, 0xad, 0x7a, 0x9d, 0x5c, 0x02, 0x12, 0xa5, 0xd7, + 0x34, 0xd3, 0x70, 0x88, 0x87, 0xdf, 0x0c, 0x46, 0x77, 0x33, 0x18, 0x47, 0x9f, 0x02, 0x23, 0x4b, 0xc2, 0x45, 0x7d, + 0x5d, 0x3b, 0x19, 0x9d, 0xf4, 0x22, 0x0a, 0xf4, 0xe4, 0x75, 0xe1, 0xf7, 0x9c, 0x85, 0x3c, 0x92, 0x3f, 0x05, 0x39, + 0xcf, 0xe5, 0xbb, 0xc3, 0x76, 0x5b, 0x3e, 0xe7, 0xec, 0x57, 0xea, 0x75, 0x5c, 0xa8, 0x50, 0x5c, 0xe2, 0x1f, 0xca, + 0xd3, 0xbc, 0x75, 0xee, 0x89, 0xff, 0x62, 0x1e, 0xf3, 0x35, 0x52, 0x14, 0xab, 0x43, 0xd1, 0x38, 0xd5, 0xb2, 0x52, + 0x0a, 0x1f, 0x70, 0xdb, 0x09, 0xee, 0x48, 0x58, 0xbf, 0x3c, 0xc6, 0xc9, 0x06, 0x7f, 0x91, 0x79, 0x17, 0x1e, 0x44, + 0x3a, 0x8c, 0x54, 0xc3, 0xb4, 0x97, 0xf5, 0x49, 0xbb, 0x97, 0x35, 0x9b, 0xc8, 0x49, 0x09, 0x9c, 0x16, 0x90, 0xc9, + 0x79, 0x0e, 0x1b, 0xa4, 0xc2, 0xd8, 0x4e, 0x90, 0x97, 0xc2, 0x59, 0xd3, 0xe5, 0x32, 0xa9, 0x12, 0x32, 0xc4, 0x69, + 0x8d, 0x1f, 0xb8, 0xaa, 0x80, 0x13, 0x83, 0x93, 0xfb, 0xfa, 0x7a, 0x97, 0x5c, 0xf3, 0x8a, 0x38, 0x0d, 0x04, 0xe6, + 0xdc, 0xa9, 0xcf, 0x23, 0xf0, 0x52, 0x94, 0xe2, 0xa7, 0x4a, 0x61, 0xb2, 0x5b, 0x36, 0x1a, 0xe4, 0x65, 0x7e, 0x1b, + 0xe4, 0xf1, 0xe5, 0x05, 0xf4, 0x72, 0xc5, 0x09, 0xf4, 0x58, 0xf5, 0xff, 0x81, 0x1b, 0x92, 0x3a, 0x77, 0x59, 0x12, + 0xc4, 0xb3, 0x90, 0xe6, 0xa2, 0x87, 0x4a, 0x9c, 0xc3, 0xdd, 0x10, 0x65, 0x2d, 0xd1, 0x04, 0x7a, 0x17, 0xd9, 0x3c, + 0x50, 0x11, 0x6e, 0x51, 0x29, 0x9f, 0x9b, 0xe2, 0xb9, 0x6a, 0xfb, 0xba, 0x4a, 0x16, 0x85, 0x96, 0xee, 0x2c, 0x61, + 0xbf, 0xcc, 0xe8, 0x05, 0x0b, 0x8d, 0x93, 0xbb, 0x34, 0x09, 0xd2, 0x90, 0x7e, 0x78, 0xf7, 0x02, 0xb2, 0xdd, 0xd3, + 0x04, 0x48, 0x4c, 0xf9, 0xbb, 0x70, 0x42, 0x40, 0x23, 0xbc, 0x66, 0x01, 0x1d, 0x5c, 0xee, 0x2e, 0x36, 0x56, 0x94, + 0xaf, 0x51, 0xd1, 0xba, 0x14, 0x49, 0x7f, 0x02, 0xca, 0xcb, 0xdd, 0xc5, 0x15, 0x2f, 0x5a, 0xbb, 0x8b, 0xdc, 0x0d, + 0xd3, 0x89, 0xcf, 0x12, 0xf8, 0x9d, 0x14, 0xbb, 0x0b, 0x06, 0x3f, 0x78, 0x71, 0x59, 0x54, 0x89, 0xa2, 0x25, 0x44, + 0xc6, 0x14, 0x14, 0xee, 0x3a, 0xc8, 0xfd, 0x39, 0x65, 0x89, 0x28, 0xba, 0xab, 0x67, 0xaa, 0x7b, 0x05, 0x24, 0xff, + 0x4a, 0xa4, 0xc1, 0xac, 0xcd, 0xe5, 0xd1, 0x7d, 0xcd, 0x65, 0x9a, 0x70, 0x26, 0xd2, 0xe2, 0x75, 0x38, 0x27, 0xf2, + 0xf3, 0x8b, 0x40, 0x9e, 0x44, 0xcd, 0xab, 0x53, 0x17, 0xbe, 0x40, 0xac, 0xb4, 0x80, 0x69, 0x26, 0x8c, 0x7d, 0xba, + 0xfd, 0xa8, 0x64, 0x7e, 0x97, 0xf1, 0x57, 0x52, 0x55, 0x9e, 0xce, 0xb2, 0x00, 0x62, 0xbd, 0x4a, 0xa5, 0x58, 0xf7, + 0x8a, 0xd9, 0x42, 0x7f, 0xb3, 0x31, 0x37, 0x92, 0x6c, 0x39, 0x9c, 0xe9, 0xab, 0xae, 0xed, 0xa0, 0x22, 0x9e, 0x08, + 0x6b, 0xc6, 0xc4, 0xea, 0x5d, 0xb0, 0x10, 0x02, 0x2f, 0x2c, 0x54, 0x09, 0x8b, 0xb5, 0x49, 0x82, 0x8a, 0x14, 0x8a, + 0x0c, 0x52, 0xb8, 0x6c, 0x27, 0xad, 0x56, 0x01, 0x84, 0x1f, 0xd2, 0x2e, 0xf9, 0x66, 0x67, 0x6f, 0x2f, 0xa9, 0x4e, + 0xb4, 0x31, 0x85, 0xf3, 0xe5, 0x92, 0x53, 0x27, 0x91, 0xa7, 0x6e, 0x22, 0x02, 0xca, 0x18, 0xc3, 0xf2, 0x8d, 0x97, + 0xe2, 0xb2, 0x27, 0x2f, 0x29, 0x7a, 0x91, 0x40, 0xa2, 0x44, 0x19, 0xd1, 0x48, 0x3d, 0xd1, 0x2a, 0x19, 0x36, 0x5f, + 0x97, 0x07, 0xf9, 0x6b, 0x58, 0x6f, 0xaf, 0x2c, 0x8e, 0xb4, 0xaa, 0xa2, 0xd5, 0xd2, 0x3c, 0xcd, 0xb8, 0xe3, 0xf8, + 0x38, 0x40, 0xa4, 0xef, 0x8b, 0xd9, 0x1f, 0xcb, 0x7c, 0x8f, 0x41, 0xb3, 0xe3, 0x75, 0x4a, 0x7f, 0x48, 0xed, 0x7c, + 0xb5, 0xcc, 0x36, 0x53, 0x67, 0x74, 0x01, 0x4f, 0xb8, 0xfc, 0xad, 0xd0, 0x57, 0x15, 0xc8, 0xd9, 0x55, 0xcf, 0xe5, + 0x24, 0xb1, 0x62, 0x68, 0x52, 0x19, 0x70, 0x6a, 0x50, 0x9d, 0x67, 0x43, 0xcc, 0xb6, 0x8c, 0x8d, 0x8a, 0x0a, 0x11, + 0xe5, 0xe6, 0xbe, 0x94, 0x4a, 0xd0, 0x85, 0x41, 0xdd, 0x97, 0x4c, 0xbb, 0xf1, 0xea, 0x74, 0x57, 0x28, 0x14, 0x19, + 0x9c, 0x61, 0x53, 0x35, 0x09, 0xcb, 0x2d, 0xc9, 0x37, 0x12, 0xaf, 0x2b, 0x1f, 0xa9, 0xa4, 0x8d, 0xcd, 0x55, 0x44, + 0x32, 0xe4, 0x26, 0xc0, 0xc0, 0x31, 0x90, 0x73, 0x3d, 0x05, 0xe0, 0x31, 0x23, 0x0a, 0x27, 0x95, 0x14, 0xc7, 0xc1, + 0x0b, 0xa9, 0xdd, 0x7b, 0xf6, 0xdb, 0x37, 0x67, 0xef, 0x6d, 0x0c, 0x57, 0x9d, 0xd1, 0x2c, 0xf7, 0x16, 0xb6, 0xca, + 0x31, 0x6c, 0x42, 0xbc, 0xda, 0xf6, 0x6c, 0x7f, 0x0a, 0x87, 0xb6, 0x05, 0x53, 0x6d, 0xdd, 0x34, 0xe7, 0xf3, 0x79, + 0x13, 0x4e, 0x94, 0x35, 0x67, 0x59, 0x2c, 0xd9, 0x4d, 0x68, 0x17, 0x05, 0x72, 0x79, 0x44, 0x93, 0xf2, 0x32, 0xa4, + 0x34, 0xa6, 0x6e, 0x9c, 0x8e, 0xe5, 0x79, 0xd8, 0x55, 0xf7, 0x44, 0x7c, 0x79, 0x2c, 0x2e, 0xf9, 0xea, 0x1f, 0x73, + 0x79, 0xbd, 0x1a, 0xcf, 0xe0, 0x67, 0x1f, 0x82, 0x57, 0xc7, 0x2d, 0x1e, 0x89, 0x87, 0x33, 0xd8, 0x4d, 0xe2, 0x69, + 0x77, 0xb1, 0x46, 0x75, 0x03, 0xe8, 0x22, 0xea, 0xcb, 0xa9, 0xe5, 0xa2, 0xd6, 0xa5, 0x17, 0x5f, 0x5e, 0x16, 0xc7, + 0x2d, 0xe8, 0xab, 0xa5, 0xfb, 0xbd, 0x4a, 0xc3, 0x5b, 0xdd, 0xbe, 0xa4, 0x44, 0xb8, 0xec, 0x29, 0x27, 0x7d, 0xe8, + 0x02, 0xc6, 0x0d, 0xfb, 0x02, 0x67, 0x8a, 0x85, 0x9e, 0x57, 0x0f, 0xc5, 0xd0, 0x02, 0x86, 0x59, 0x40, 0x09, 0x90, + 0x1b, 0x74, 0x1e, 0x96, 0x0d, 0xc4, 0x6e, 0x97, 0x45, 0xdb, 0x00, 0x94, 0x15, 0xab, 0xfd, 0x23, 0xdd, 0xdc, 0x15, + 0x59, 0x68, 0x88, 0x43, 0x13, 0xf8, 0x4b, 0x04, 0xff, 0x0a, 0xc0, 0x8f, 0x5b, 0x12, 0x4d, 0x97, 0xe6, 0xb5, 0x33, + 0xf2, 0x42, 0x88, 0x12, 0x99, 0xe7, 0x19, 0xc7, 0xef, 0x39, 0xfe, 0x78, 0x29, 0xaa, 0x6a, 0x2d, 0x01, 0xd4, 0x57, + 0xd0, 0xa6, 0xda, 0x5a, 0x1d, 0x0c, 0xd2, 0x38, 0xf6, 0xa7, 0x39, 0xf5, 0xf4, 0x0f, 0xa5, 0x30, 0x80, 0xde, 0xb1, + 0xae, 0xa1, 0xa9, 0xbc, 0xa7, 0x53, 0xd0, 0xe3, 0xd6, 0xd5, 0xc7, 0x6b, 0x3f, 0x73, 0x9a, 0xcd, 0xa0, 0x79, 0x35, + 0x46, 0x05, 0x8f, 0x16, 0xa6, 0xba, 0xf1, 0xb0, 0xdd, 0xee, 0x41, 0x92, 0x6a, 0xd3, 0x8f, 0xd9, 0x38, 0xf1, 0x62, + 0x3a, 0xe2, 0x05, 0x87, 0xd3, 0x83, 0x0b, 0xad, 0xdf, 0xb9, 0xdd, 0xc3, 0x8c, 0x4e, 0x2c, 0x17, 0xfe, 0xde, 0x3d, + 0x70, 0xc1, 0x43, 0x2f, 0xe1, 0x51, 0x53, 0x24, 0x43, 0xc3, 0x51, 0x0e, 0x1e, 0xd5, 0x9e, 0x17, 0xc6, 0x40, 0x01, + 0x05, 0xdd, 0xb7, 0xe0, 0x99, 0xc5, 0x23, 0xcc, 0x33, 0xb3, 0x5e, 0x82, 0x16, 0x6b, 0x33, 0x58, 0x57, 0xc1, 0xf6, + 0x51, 0x91, 0x0b, 0x8b, 0x65, 0xb1, 0x86, 0x17, 0x43, 0x95, 0x2e, 0x58, 0x32, 0x9d, 0xf1, 0x73, 0xe1, 0xf9, 0xcf, + 0xe0, 0x0c, 0xc9, 0x10, 0x1b, 0x25, 0x00, 0xcf, 0x50, 0xb5, 0x0f, 0xfc, 0x38, 0x70, 0xa0, 0x13, 0xab, 0x69, 0x1d, + 0x65, 0x74, 0x82, 0x7a, 0x13, 0x96, 0x34, 0xe5, 0xbb, 0x43, 0x43, 0x77, 0x73, 0x1f, 0xc1, 0x53, 0xe1, 0x8a, 0xde, + 0xb0, 0x48, 0xf0, 0xdd, 0x30, 0xaf, 0xcb, 0x61, 0x51, 0xf4, 0x52, 0xee, 0x9c, 0xbf, 0x70, 0xd0, 0x10, 0xff, 0x6a, + 0x5c, 0x62, 0x63, 0x6b, 0xaa, 0xb6, 0x71, 0x17, 0x6d, 0xa9, 0x62, 0xd2, 0xa5, 0xa8, 0xf6, 0x2b, 0x81, 0x8a, 0x2f, + 0x1d, 0x9b, 0xe6, 0xd3, 0xa6, 0x64, 0x3f, 0x4d, 0x41, 0x3e, 0x36, 0x34, 0x45, 0xca, 0x9d, 0x4d, 0xe9, 0x42, 0x70, + 0x16, 0x75, 0x8e, 0x45, 0x7a, 0x5c, 0x86, 0xe5, 0xb9, 0x27, 0xf5, 0x6c, 0x9e, 0x74, 0x42, 0xb5, 0xad, 0x7f, 0x79, + 0x52, 0x67, 0x53, 0x20, 0xff, 0xcb, 0xbb, 0xfe, 0xfc, 0x38, 0x86, 0x01, 0x2f, 0xb5, 0xd2, 0x60, 0x5e, 0x8d, 0x72, + 0xce, 0x87, 0x0e, 0x2a, 0xd4, 0x9e, 0x79, 0x22, 0xf4, 0x6e, 0xe3, 0x82, 0xc1, 0x1d, 0xae, 0x23, 0x6a, 0xf2, 0x04, + 0x33, 0x83, 0x9c, 0x80, 0x5a, 0xee, 0x78, 0xaf, 0x62, 0x33, 0x52, 0x6b, 0xb7, 0xc4, 0x84, 0x88, 0x9d, 0x25, 0xa1, + 0x6d, 0xfd, 0x39, 0x88, 0x59, 0xf0, 0x91, 0xd8, 0xbb, 0x0b, 0x07, 0xad, 0x1f, 0x0d, 0x15, 0x3b, 0x54, 0xf3, 0x5c, + 0x54, 0x8f, 0x36, 0x64, 0xae, 0xc1, 0x4e, 0xe5, 0xed, 0x41, 0x76, 0x1f, 0x54, 0x9b, 0xe3, 0x96, 0x1c, 0xa7, 0x7f, + 0x59, 0x5c, 0x54, 0xb7, 0x82, 0x55, 0x50, 0x00, 0x9a, 0x65, 0xb9, 0x25, 0xe8, 0x8f, 0xd8, 0x72, 0x0b, 0xd5, 0x2c, + 0x40, 0x6c, 0xd2, 0x3e, 0xb2, 0x2d, 0xc9, 0x60, 0x00, 0x4e, 0xae, 0x78, 0x8d, 0x6d, 0xfd, 0xb9, 0x2c, 0xa3, 0xa5, + 0xdb, 0x47, 0xe4, 0xad, 0x10, 0x1b, 0xc6, 0x02, 0x5b, 0xdf, 0x0d, 0x29, 0xf7, 0x59, 0x2c, 0x9b, 0xf4, 0xb4, 0x97, + 0x62, 0x65, 0x46, 0xcb, 0x65, 0x52, 0x9f, 0x0b, 0xab, 0x63, 0x50, 0xcc, 0xec, 0xb8, 0x55, 0xc1, 0x2d, 0x66, 0x26, + 0xf6, 0x87, 0x19, 0x3f, 0xad, 0x66, 0x28, 0xdf, 0x59, 0x7f, 0x0e, 0xc4, 0xc9, 0x2a, 0x00, 0x30, 0x55, 0x00, 0x42, + 0x64, 0x5f, 0x2a, 0x21, 0x8e, 0x4f, 0x52, 0x97, 0xfb, 0xd9, 0x98, 0xf2, 0x15, 0xc4, 0xfa, 0x32, 0x91, 0xb7, 0xa7, + 0xa3, 0xf8, 0x6b, 0xd0, 0x06, 0x75, 0x68, 0x41, 0xcf, 0x2d, 0x06, 0xa0, 0xaa, 0x92, 0x8d, 0x1a, 0x6f, 0x84, 0x40, + 0xf6, 0x89, 0xc5, 0x49, 0x04, 0xb7, 0x4f, 0x05, 0xb7, 0x97, 0x71, 0x38, 0x4b, 0x8c, 0x25, 0x40, 0x2c, 0x6c, 0x6b, + 0x20, 0x21, 0xa7, 0xa1, 0x84, 0x99, 0x64, 0xa2, 0x55, 0x5a, 0x1c, 0xb7, 0x64, 0x6d, 0xc9, 0x8e, 0x65, 0x25, 0x40, + 0x82, 0xd8, 0xa7, 0x15, 0x0e, 0x20, 0xf9, 0xdb, 0xc4, 0x43, 0xc8, 0xae, 0x4b, 0x62, 0x13, 0x67, 0xcc, 0xfa, 0xc7, + 0xb1, 0x7f, 0x45, 0xe3, 0xfe, 0xee, 0x22, 0x5b, 0x2e, 0xdb, 0xc5, 0x71, 0x4b, 0x3e, 0x5a, 0xc7, 0x82, 0x6f, 0xc8, + 0xbb, 0x41, 0xc5, 0x12, 0xc3, 0xc1, 0x4d, 0x48, 0x89, 0xd5, 0xb9, 0x60, 0x9e, 0xea, 0xa0, 0xb0, 0x2d, 0x91, 0x85, + 0x22, 0x2a, 0x95, 0x3a, 0x4d, 0x61, 0x5b, 0x2c, 0x5c, 0x2f, 0xcb, 0x39, 0x9d, 0x42, 0x69, 0xb4, 0x5c, 0x76, 0x0a, + 0xdb, 0x9a, 0xb0, 0x04, 0x9e, 0xb2, 0xe5, 0x52, 0x9c, 0x89, 0x9c, 0xb0, 0xc4, 0x69, 0x03, 0xd9, 0xda, 0xd6, 0xc4, + 0xbf, 0x11, 0x13, 0xd6, 0x6f, 0xfc, 0x1b, 0xa7, 0xa3, 0x5e, 0xb9, 0x25, 0x7e, 0x12, 0xa0, 0xb8, 0x6a, 0x45, 0x7d, + 0xb5, 0xa2, 0x21, 0x9e, 0xc9, 0xd3, 0x5e, 0xc4, 0x09, 0x89, 0xbf, 0x79, 0x45, 0x43, 0xbd, 0xa2, 0xb3, 0x2d, 0x2b, + 0x3a, 0xbb, 0x63, 0x45, 0x03, 0xb5, 0x7a, 0x56, 0x89, 0xbb, 0x74, 0xb9, 0xec, 0xb4, 0x2b, 0xec, 0x1d, 0xb7, 0x42, + 0x76, 0x0d, 0xab, 0x01, 0x9a, 0x1a, 0x67, 0x13, 0xba, 0x99, 0x28, 0xeb, 0x28, 0xa6, 0x9f, 0x85, 0xc9, 0x0a, 0x0b, + 0x59, 0x1d, 0x0b, 0x26, 0x5d, 0x97, 0x81, 0xc9, 0x3f, 0x92, 0xb2, 0x19, 0xe0, 0x21, 0x01, 0x3c, 0x44, 0xfa, 0xae, + 0x50, 0xc7, 0x7e, 0x6f, 0x63, 0xdb, 0xb2, 0x35, 0x59, 0x5f, 0x16, 0x17, 0x20, 0x23, 0xc4, 0xfc, 0xee, 0x45, 0x8b, + 0x50, 0xdb, 0xee, 0x6f, 0xa7, 0x39, 0xc8, 0x21, 0x98, 0xa7, 0x59, 0x68, 0x7b, 0xb2, 0xea, 0x67, 0xa1, 0x6a, 0xc2, + 0x12, 0x95, 0x91, 0xb6, 0x95, 0xd6, 0xaa, 0xf7, 0x26, 0xc5, 0x75, 0x0f, 0x0f, 0x65, 0x8d, 0xa9, 0xcf, 0x39, 0xcd, + 0x12, 0x45, 0xb9, 0xb6, 0xfd, 0x1f, 0x82, 0x0a, 0x37, 0xf0, 0x95, 0x40, 0x2f, 0x80, 0x26, 0x40, 0xa5, 0x73, 0x2b, + 0x9e, 0x2f, 0xc5, 0xd3, 0x4e, 0xa5, 0x6c, 0xde, 0x22, 0x53, 0xef, 0x97, 0x45, 0x60, 0x86, 0xcc, 0x26, 0x34, 0xbc, + 0x10, 0x0c, 0x7a, 0x10, 0x5f, 0x2a, 0xe5, 0x71, 0x45, 0xdc, 0x55, 0x0d, 0xb0, 0xfd, 0xd3, 0xac, 0xfb, 0xe8, 0xe0, + 0xd4, 0xc6, 0x92, 0xc7, 0xa7, 0xa3, 0x91, 0x8d, 0x0a, 0xeb, 0x7e, 0xcd, 0x3a, 0x07, 0x3f, 0xcd, 0xbe, 0x7e, 0xd6, + 0xfe, 0xba, 0x6c, 0x9c, 0x00, 0x11, 0xa9, 0x24, 0x08, 0x2d, 0xaa, 0x0c, 0x78, 0xf5, 0x8c, 0x46, 0x7e, 0xb2, 0x7d, + 0x3a, 0xe7, 0xe6, 0x74, 0xf2, 0x29, 0xa5, 0x21, 0x10, 0x27, 0x5e, 0x2b, 0xbd, 0x88, 0xe9, 0x35, 0xd5, 0x37, 0x34, + 0x6e, 0x18, 0x6c, 0x43, 0x8b, 0x20, 0x9d, 0x25, 0x5c, 0x65, 0x83, 0x28, 0x56, 0x6b, 0x4c, 0xe9, 0x52, 0xcc, 0xc1, + 0x54, 0xe7, 0x6f, 0xa5, 0x9c, 0xab, 0x4b, 0xaf, 0xe2, 0x12, 0xdb, 0x06, 0x00, 0x5b, 0x21, 0x1b, 0x6c, 0x29, 0xf7, + 0xda, 0xb8, 0xbd, 0x0d, 0x36, 0xdc, 0x41, 0x9e, 0x6d, 0x0f, 0x35, 0x9e, 0x84, 0x43, 0xb7, 0x76, 0xa9, 0xc6, 0x56, + 0x7c, 0x7d, 0x12, 0x03, 0x57, 0x19, 0x74, 0x96, 0xd0, 0x3c, 0xdf, 0x8a, 0x80, 0x72, 0x11, 0xb1, 0x5d, 0xd5, 0xb6, + 0xb7, 0xf4, 0x82, 0xdb, 0x18, 0x76, 0x98, 0x00, 0xb8, 0x56, 0x45, 0xa8, 0x1b, 0x17, 0x70, 0xee, 0xe9, 0x3e, 0x03, + 0x55, 0xb5, 0xb7, 0xf5, 0x82, 0x3b, 0x87, 0x07, 0x78, 0xff, 0x51, 0x5b, 0x0d, 0xa5, 0x23, 0xd8, 0xaa, 0x1e, 0x1d, + 0x8d, 0x68, 0x50, 0xba, 0xde, 0x21, 0x16, 0x39, 0x62, 0x31, 0x87, 0x90, 0x9c, 0x88, 0x95, 0xd9, 0xaf, 0xd3, 0x84, + 0xda, 0x48, 0x67, 0xd7, 0x2a, 0x54, 0x29, 0x55, 0x63, 0x33, 0x44, 0xb2, 0xc7, 0x3a, 0x34, 0x6a, 0x94, 0xe5, 0x52, + 0x7b, 0x86, 0x6a, 0xe5, 0xf5, 0x35, 0x4b, 0x85, 0xeb, 0x67, 0xdb, 0x5e, 0xbd, 0xdf, 0x8e, 0x5c, 0x74, 0xbe, 0x3e, + 0xec, 0xb4, 0x0b, 0x1b, 0xdb, 0xd0, 0xdd, 0x7d, 0x37, 0xa4, 0x68, 0xb5, 0x0f, 0xad, 0x66, 0xc9, 0xe7, 0xb4, 0xeb, + 0x76, 0x1e, 0x77, 0x6c, 0x2c, 0xaf, 0x75, 0x40, 0x45, 0xc9, 0x77, 0x02, 0x70, 0x46, 0xff, 0xee, 0xa9, 0xd4, 0x3b, + 0xbf, 0x1f, 0x3c, 0x0f, 0x3b, 0x6d, 0x1b, 0xdb, 0x39, 0x4f, 0xa7, 0x9f, 0x31, 0x85, 0x7d, 0xa0, 0xa6, 0x38, 0xcd, + 0xa9, 0x39, 0x07, 0xa9, 0x39, 0xff, 0xfe, 0x49, 0x48, 0x88, 0xa6, 0x19, 0xcd, 0x73, 0xcb, 0xec, 0x5f, 0x91, 0xd2, + 0x27, 0x78, 0xf3, 0x46, 0x8a, 0xcb, 0x29, 0x17, 0x78, 0x91, 0x37, 0x2e, 0x98, 0x54, 0x25, 0xcb, 0xd6, 0x88, 0x4d, + 0x48, 0x9b, 0x92, 0x87, 0x4a, 0x45, 0xee, 0x93, 0x23, 0x6f, 0xd8, 0x7c, 0x72, 0x60, 0x19, 0xa3, 0x5f, 0x1f, 0xa0, + 0x56, 0x32, 0x61, 0xc9, 0xc5, 0x86, 0x52, 0xff, 0x66, 0x43, 0x29, 0x68, 0x87, 0x25, 0x74, 0xea, 0x36, 0xa0, 0x4f, + 0x63, 0xbd, 0xd2, 0xb1, 0x4c, 0x10, 0x43, 0xe1, 0xea, 0xfc, 0x04, 0xa4, 0xc6, 0x32, 0x88, 0x1e, 0x7e, 0xfb, 0x70, + 0x50, 0xf2, 0x39, 0xc3, 0x95, 0xbd, 0xfc, 0xbe, 0x19, 0x42, 0x69, 0x13, 0xe2, 0x09, 0xf1, 0x67, 0xcd, 0x95, 0xde, + 0x7c, 0x9a, 0xe0, 0x0c, 0x05, 0xee, 0x77, 0x2c, 0xbd, 0xba, 0x55, 0x60, 0x75, 0xed, 0x37, 0x14, 0x2b, 0x1d, 0xab, + 0x5c, 0xff, 0x20, 0x66, 0x93, 0x8a, 0x04, 0xd6, 0xc1, 0x14, 0xca, 0x15, 0x24, 0x97, 0x99, 0x9d, 0x48, 0x2d, 0x4b, + 0x30, 0x7d, 0xb8, 0x95, 0x64, 0x96, 0xd1, 0x8b, 0x38, 0x9d, 0xaf, 0xde, 0xb3, 0xb6, 0xbd, 0x72, 0xc4, 0xc6, 0x91, + 0x71, 0x0e, 0x8e, 0x92, 0x72, 0x11, 0xee, 0x1c, 0xa0, 0xf8, 0x97, 0x7f, 0x76, 0xdd, 0x7f, 0xf9, 0xe7, 0x4f, 0x56, + 0x85, 0xee, 0x8b, 0x4b, 0xcc, 0xab, 0x6e, 0xb7, 0xef, 0xae, 0xcd, 0x23, 0xd5, 0x71, 0xbe, 0xb9, 0xce, 0xda, 0x22, + 0x08, 0x19, 0xb8, 0xba, 0x04, 0x6b, 0x85, 0x72, 0xf7, 0x59, 0xbf, 0x05, 0x30, 0x98, 0xd7, 0x27, 0x21, 0x83, 0x4a, + 0xbf, 0x0b, 0xb4, 0x4b, 0xe4, 0xdd, 0x6b, 0x45, 0x7e, 0x3b, 0x86, 0x3f, 0x35, 0x87, 0xdf, 0x09, 0xbe, 0x72, 0x85, + 0xc4, 0x97, 0x97, 0x65, 0xc2, 0xa3, 0xd9, 0x14, 0xae, 0x53, 0x18, 0xac, 0x95, 0x28, 0xc5, 0xc3, 0x6b, 0xa3, 0xbe, + 0x38, 0xae, 0x49, 0xe2, 0xcb, 0x57, 0x70, 0x87, 0xd2, 0xf1, 0x55, 0xa6, 0xfd, 0xba, 0x77, 0x08, 0x07, 0xe8, 0xa2, + 0x3e, 0x2b, 0xd1, 0xe9, 0x9a, 0x64, 0x80, 0x52, 0xb0, 0x6c, 0x00, 0x4c, 0x1c, 0x5f, 0x2a, 0xc3, 0xf6, 0x54, 0x7a, + 0x7c, 0xbc, 0x55, 0xd2, 0x56, 0x9e, 0xa0, 0x1a, 0xd2, 0xb1, 0xf5, 0x5e, 0xe0, 0x4b, 0x54, 0xa6, 0x95, 0x23, 0x41, + 0x78, 0xd5, 0xc0, 0x64, 0x29, 0xd9, 0xcf, 0xb5, 0x1f, 0x5f, 0xdf, 0x8f, 0xf1, 0x6d, 0x17, 0xa8, 0x4b, 0x6b, 0xf9, + 0x8f, 0x56, 0x09, 0x96, 0xcd, 0xe5, 0x26, 0x7d, 0x60, 0xee, 0x73, 0x9a, 0x5d, 0x44, 0x90, 0x73, 0x95, 0x7d, 0x82, + 0x39, 0xc1, 0x4a, 0x63, 0x2a, 0xfe, 0x32, 0xa2, 0xee, 0xac, 0xfe, 0x07, 0x71, 0x2a, 0x06, 0x09, 0x93, 0x30, 0x94, + 0xb1, 0x08, 0xff, 0x9f, 0x6f, 0xfd, 0x87, 0xe1, 0x5b, 0x77, 0x0f, 0x51, 0x3b, 0x8e, 0xfd, 0xd9, 0x0b, 0xf9, 0x1f, + 0x9b, 0xdd, 0x25, 0x82, 0xdd, 0xfd, 0x06, 0x46, 0x97, 0xfc, 0x63, 0x18, 0x9d, 0x30, 0xc7, 0x35, 0xa7, 0x5b, 0x8b, + 0x6a, 0xdf, 0xba, 0xfe, 0xdc, 0xbf, 0xad, 0xf6, 0x55, 0x7c, 0x79, 0x32, 0xf7, 0x6f, 0xab, 0x45, 0xd8, 0xce, 0x2e, + 0x56, 0xfb, 0x18, 0xd8, 0x6f, 0x5e, 0xdb, 0x9e, 0xfd, 0xe6, 0xeb, 0xaf, 0x6d, 0x7c, 0x99, 0x53, 0x3e, 0x80, 0x42, + 0xb2, 0xbb, 0xd8, 0x59, 0xad, 0x08, 0x1e, 0x1b, 0x98, 0xa2, 0x88, 0xb0, 0x41, 0x7e, 0xa3, 0xf1, 0x9e, 0xe5, 0x17, + 0x69, 0x62, 0x42, 0xf3, 0x16, 0x9c, 0x08, 0x9f, 0x0b, 0x8e, 0xe8, 0x65, 0x0d, 0x1e, 0x51, 0xba, 0x0a, 0x90, 0x28, + 0xac, 0x41, 0x54, 0xdd, 0x4e, 0x74, 0x37, 0xff, 0xaf, 0x6e, 0x60, 0x90, 0x17, 0x8b, 0x44, 0x83, 0xf8, 0xf2, 0x73, + 0xc4, 0x87, 0x1c, 0xac, 0x72, 0x0e, 0x6a, 0xcf, 0xaa, 0x5f, 0xec, 0x2e, 0xa2, 0xbd, 0x3d, 0x36, 0xb0, 0xb1, 0xb8, + 0x12, 0xaa, 0xd8, 0x24, 0x5c, 0x12, 0xf8, 0x93, 0xc1, 0x9f, 0xb4, 0x62, 0xd4, 0x2c, 0x19, 0x65, 0x7e, 0x46, 0xc3, + 0xed, 0x4c, 0xba, 0xbc, 0x4a, 0x49, 0x91, 0x86, 0xcc, 0xf5, 0xce, 0x2f, 0x44, 0x96, 0xd3, 0x84, 0x81, 0x3e, 0xba, + 0x63, 0x7e, 0x30, 0x48, 0xdd, 0xbd, 0x56, 0x7e, 0x6f, 0xc0, 0x44, 0x38, 0x25, 0x49, 0x99, 0x56, 0x01, 0x17, 0x78, + 0xaa, 0x44, 0x14, 0x6c, 0x23, 0xe1, 0xe0, 0x0f, 0x49, 0x5f, 0x64, 0x58, 0xbc, 0x48, 0xb8, 0x13, 0xba, 0x3c, 0x63, + 0x13, 0x07, 0xe1, 0x4e, 0x1b, 0x21, 0xed, 0x6c, 0x08, 0x49, 0x7f, 0x87, 0xe5, 0xaf, 0xfd, 0xd7, 0x4e, 0x28, 0xee, + 0xfc, 0x12, 0x5f, 0x09, 0x82, 0xf3, 0x98, 0x4f, 0x66, 0xa3, 0x11, 0xcd, 0x1c, 0x7d, 0xd6, 0xf0, 0xab, 0x03, 0x38, + 0xce, 0x0c, 0x6f, 0x9f, 0xfa, 0xdc, 0xff, 0x96, 0xd1, 0xb9, 0x93, 0xa2, 0x5e, 0x56, 0xdd, 0x03, 0x19, 0xe2, 0x19, + 0x22, 0xfd, 0x08, 0x72, 0xf0, 0x5f, 0x24, 0x7c, 0xbf, 0xeb, 0xcc, 0xbe, 0x3a, 0xc0, 0x21, 0xdc, 0xae, 0xa1, 0x13, + 0xc8, 0xe5, 0xb5, 0x28, 0x1f, 0x58, 0xc2, 0x8f, 0xe4, 0x89, 0xcf, 0x14, 0x29, 0x4f, 0x65, 0x99, 0x7c, 0x63, 0xf9, + 0x65, 0x87, 0x21, 0xe9, 0x07, 0x0d, 0x22, 0xcf, 0x7f, 0x8a, 0x0b, 0x7d, 0x4f, 0x23, 0x3f, 0x3b, 0x85, 0xb3, 0xe5, + 0x00, 0xe8, 0x15, 0x4f, 0x7d, 0x27, 0x28, 0x3f, 0x1a, 0xe5, 0xb4, 0x7e, 0x6a, 0xb4, 0xc6, 0x58, 0xe4, 0xdf, 0x54, + 0x45, 0x2d, 0x28, 0xba, 0x30, 0x8b, 0x48, 0x63, 0xb7, 0x85, 0x61, 0x0f, 0xf6, 0x36, 0xba, 0x83, 0xf5, 0xd2, 0x35, + 0xe7, 0x99, 0x3f, 0x2d, 0x43, 0x14, 0xa7, 0x7e, 0x96, 0x31, 0x9a, 0x59, 0xce, 0xf3, 0x5f, 0x91, 0xf7, 0x2f, 0xff, + 0xbc, 0x39, 0x54, 0xa1, 0xa2, 0x13, 0x16, 0xe4, 0xb1, 0x34, 0x45, 0xe6, 0x37, 0xb1, 0x03, 0xd9, 0xd0, 0xd6, 0x91, + 0x95, 0xfd, 0xa3, 0x76, 0xbb, 0xad, 0xa2, 0x0f, 0x1d, 0xf9, 0x13, 0xc2, 0x0d, 0xf0, 0x13, 0x1e, 0x44, 0x00, 0x9b, + 0xd8, 0x32, 0x16, 0x7a, 0xd4, 0x9e, 0xde, 0xd8, 0x7d, 0xd8, 0x0e, 0x0a, 0x8a, 0x77, 0x74, 0x4a, 0x7d, 0xfe, 0x59, + 0xe3, 0x67, 0xa2, 0x49, 0x39, 0x7c, 0x47, 0x0f, 0x5d, 0x8d, 0xbb, 0x32, 0xe8, 0xe1, 0xea, 0xa0, 0xef, 0xd9, 0x44, + 0xdc, 0x12, 0xb5, 0x6d, 0x54, 0xe1, 0x14, 0xaf, 0x8d, 0xc9, 0x65, 0x0b, 0xdb, 0x12, 0x18, 0x8f, 0xd2, 0x38, 0xa4, + 0x19, 0xb1, 0xa9, 0x3b, 0x76, 0xad, 0xc7, 0xed, 0x76, 0x1b, 0x37, 0x0f, 0x0e, 0xdb, 0x6d, 0x7c, 0xf8, 0xb0, 0x8d, + 0x9b, 0xf0, 0xc7, 0x75, 0xdd, 0x15, 0x18, 0xee, 0x0a, 0x10, 0x77, 0xda, 0x19, 0x9d, 0x28, 0x00, 0xef, 0x8c, 0x60, + 0x56, 0x7b, 0x02, 0xee, 0xb2, 0x56, 0xfb, 0x5e, 0x4a, 0x36, 0x75, 0x97, 0x82, 0xca, 0x7c, 0x15, 0xae, 0xc9, 0xb4, + 0x8a, 0xcf, 0x52, 0x79, 0xc7, 0xe0, 0x0b, 0x45, 0x08, 0x9e, 0x75, 0x0a, 0x17, 0xa5, 0x8a, 0xd0, 0x2c, 0x64, 0x1d, + 0xc1, 0xb7, 0xd8, 0xb8, 0xcf, 0x12, 0xf8, 0x4c, 0x97, 0x0e, 0xd0, 0x6a, 0x46, 0x95, 0xae, 0xe4, 0xf7, 0x3e, 0x90, + 0x11, 0xf0, 0x4d, 0x04, 0x31, 0x7c, 0x80, 0xb0, 0x7f, 0x9f, 0x06, 0x6a, 0x05, 0xa1, 0x7e, 0x70, 0x9f, 0xfa, 0x1a, + 0xfb, 0xc3, 0x07, 0x22, 0x0f, 0x6a, 0x27, 0x5a, 0x2e, 0x77, 0xfc, 0xe5, 0x72, 0x27, 0xb8, 0xff, 0x0c, 0xe5, 0xf2, + 0xea, 0x03, 0x17, 0x70, 0xc9, 0xa8, 0x04, 0xfa, 0x05, 0x94, 0x7b, 0x11, 0x96, 0x20, 0xc9, 0x27, 0x1f, 0xab, 0x01, + 0xe5, 0x63, 0x50, 0xac, 0x20, 0x25, 0x24, 0x91, 0xb4, 0xcf, 0x97, 0x4b, 0x45, 0xfc, 0x78, 0x46, 0xfc, 0xb2, 0xa8, + 0x63, 0xe3, 0x29, 0x09, 0xca, 0x47, 0x5b, 0x80, 0x3c, 0x55, 0x5c, 0xaa, 0x82, 0x78, 0xee, 0x67, 0x89, 0x09, 0xf0, + 0xeb, 0xd4, 0x52, 0xc3, 0x5a, 0xd3, 0x2c, 0xbd, 0x66, 0x90, 0x67, 0xb3, 0x32, 0xf0, 0x84, 0xc0, 0x1d, 0x63, 0x3d, + 0x33, 0xea, 0x6e, 0x74, 0xf0, 0x5e, 0xf3, 0x59, 0xb8, 0xd0, 0xb2, 0x9c, 0xa0, 0x17, 0xaa, 0xb9, 0x79, 0x33, 0x3d, + 0xad, 0x77, 0xfe, 0xdc, 0x9b, 0xea, 0x87, 0x67, 0x32, 0xa5, 0xc7, 0x9b, 0x94, 0x87, 0x78, 0xde, 0x92, 0xd7, 0x10, + 0x66, 0xb2, 0x35, 0xdf, 0x86, 0x2b, 0x3d, 0x25, 0x8f, 0x7b, 0xf7, 0xf2, 0x8c, 0xfa, 0x59, 0x10, 0xbd, 0xf5, 0x33, + 0x7f, 0x92, 0xf7, 0x2e, 0xf4, 0x85, 0x61, 0x9a, 0x02, 0x2e, 0x46, 0x22, 0xa9, 0x2a, 0x09, 0x6e, 0x6d, 0x1c, 0x22, + 0x5c, 0xbd, 0x97, 0x10, 0x48, 0x97, 0xba, 0x8d, 0x67, 0xe6, 0x2b, 0x58, 0x67, 0x1b, 0x4f, 0x10, 0x96, 0xb9, 0x4a, + 0x6f, 0xff, 0xc8, 0x2c, 0x25, 0x0c, 0x69, 0x35, 0xde, 0x85, 0x5b, 0x7d, 0x50, 0x4f, 0xe7, 0x2d, 0xbd, 0x5f, 0xc9, + 0x5b, 0xda, 0x80, 0x46, 0x2b, 0xa3, 0xf9, 0x34, 0x4d, 0x72, 0x6a, 0xe3, 0xf7, 0xd0, 0x4e, 0xde, 0xfa, 0x6c, 0x36, + 0x5c, 0xa3, 0xb9, 0xb2, 0xa9, 0x78, 0x23, 0xdb, 0x41, 0xfc, 0xe8, 0xfd, 0xf7, 0x65, 0xca, 0x80, 0x0e, 0x25, 0x89, + 0x9c, 0x77, 0x46, 0xb7, 0xa4, 0xe5, 0x26, 0xf4, 0x93, 0x69, 0xb9, 0xf1, 0xbd, 0xd2, 0x72, 0x13, 0xfa, 0x47, 0xa7, + 0xe5, 0x32, 0x6a, 0xa4, 0xe5, 0x82, 0x9c, 0xfb, 0xfa, 0x5e, 0xd9, 0x9d, 0x3a, 0xe9, 0x2e, 0x9d, 0xe7, 0xa4, 0xa3, + 0xc2, 0x2d, 0x71, 0x3a, 0x86, 0xd4, 0xce, 0x7f, 0x7c, 0xa6, 0x66, 0x9c, 0x8e, 0xcd, 0x3c, 0x4d, 0xf8, 0x06, 0x0a, + 0x90, 0x1d, 0xce, 0xc8, 0xc2, 0xfe, 0xe9, 0xa6, 0xf3, 0xe4, 0xbc, 0xd3, 0xdb, 0xef, 0x4c, 0x6c, 0xcf, 0x06, 0xa7, + 0xa3, 0x28, 0x68, 0xf7, 0xf6, 0xf7, 0xa1, 0x60, 0x6e, 0x14, 0x74, 0xa1, 0x80, 0x19, 0x05, 0x87, 0x50, 0x10, 0x18, + 0x05, 0x0f, 0xa1, 0x20, 0x34, 0x0a, 0x1e, 0x41, 0xc1, 0xb5, 0x5d, 0x9c, 0xb3, 0x32, 0xf7, 0xf8, 0x11, 0x12, 0x97, + 0x25, 0xee, 0x64, 0xf5, 0x83, 0xe2, 0x11, 0xd1, 0x55, 0x1e, 0x95, 0x97, 0x4c, 0x34, 0x0f, 0xf4, 0x9d, 0x88, 0x97, + 0x5f, 0x5c, 0x02, 0x6b, 0x85, 0x3b, 0x5f, 0x30, 0x84, 0x3f, 0x65, 0xcd, 0x7d, 0xfd, 0xda, 0xf6, 0xca, 0x04, 0xdd, + 0x36, 0xee, 0xea, 0x14, 0x5d, 0xcf, 0x46, 0x82, 0x2f, 0xc9, 0x17, 0x87, 0x8d, 0x50, 0x75, 0x0b, 0xd7, 0x0d, 0x56, + 0x77, 0x7d, 0xee, 0x23, 0x3c, 0xd1, 0x0a, 0x10, 0x75, 0xe0, 0x5b, 0x0f, 0xef, 0xd9, 0x84, 0xea, 0xfd, 0xa2, 0x07, + 0xb0, 0x44, 0x12, 0x73, 0x2f, 0xaa, 0x14, 0xa3, 0xb7, 0xf8, 0xa2, 0xba, 0x5e, 0xf6, 0x3d, 0x91, 0xd7, 0xf5, 0x65, + 0x58, 0x46, 0xd4, 0xa6, 0x98, 0xfb, 0x63, 0x0f, 0xb2, 0x35, 0x21, 0x39, 0xc5, 0xbb, 0x20, 0x84, 0xb4, 0x07, 0x33, + 0xef, 0x2d, 0x9e, 0x47, 0x34, 0xf1, 0x26, 0x45, 0xaf, 0x5c, 0x7f, 0x99, 0x3d, 0xfa, 0xbe, 0xbc, 0x93, 0x5c, 0xd0, + 0x44, 0xf5, 0x56, 0x42, 0xd9, 0x2c, 0x69, 0x67, 0x4b, 0x7a, 0xa1, 0xa1, 0xec, 0x8c, 0xe2, 0x74, 0xde, 0x04, 0x71, + 0xbf, 0x31, 0xe5, 0x10, 0xe6, 0x56, 0xa6, 0x1c, 0xbe, 0x04, 0x58, 0xcb, 0xa7, 0xf7, 0xfe, 0xb8, 0xfc, 0xfd, 0x8a, + 0xe6, 0xb9, 0x3f, 0x56, 0x35, 0xb7, 0xa7, 0x18, 0x0a, 0x10, 0xcd, 0xf4, 0x42, 0x0d, 0x04, 0xe4, 0x01, 0x02, 0x42, + 0x20, 0x76, 0xac, 0xd2, 0x02, 0x61, 0xe6, 0xf5, 0x8c, 0x42, 0x81, 0xaa, 0x7a, 0x11, 0xf7, 0xc7, 0x55, 0xc1, 0xf1, + 0x34, 0xa3, 0x2a, 0x57, 0x11, 0xb0, 0x58, 0x1c, 0xb7, 0xa0, 0x40, 0xbe, 0xde, 0x92, 0x39, 0xa8, 0xb9, 0xcb, 0xf6, + 0xfc, 0x41, 0x4b, 0x67, 0x0e, 0x9a, 0x87, 0x60, 0xca, 0x13, 0x30, 0xeb, 0xc9, 0x7f, 0x5f, 0x76, 0x02, 0xf8, 0x4f, + 0x9d, 0xf1, 0xf8, 0x72, 0x34, 0x1a, 0xdd, 0x99, 0x49, 0xf8, 0x65, 0x38, 0xa2, 0x5d, 0x7a, 0xd8, 0x83, 0x03, 0x12, + 0x4d, 0x95, 0xf3, 0xd6, 0x29, 0x04, 0xee, 0x16, 0xf7, 0xab, 0x0c, 0xe9, 0x71, 0x3c, 0x5a, 0xdc, 0x3f, 0xab, 0xb0, + 0x98, 0x66, 0x74, 0x31, 0xf1, 0xb3, 0x31, 0x4b, 0xbc, 0x76, 0xe1, 0x5e, 0x2f, 0x14, 0xa8, 0x47, 0x47, 0x47, 0x85, + 0x1b, 0xea, 0xa7, 0x76, 0x18, 0x16, 0x6e, 0xb0, 0x28, 0xa7, 0xd1, 0x6e, 0x8f, 0x46, 0x85, 0xcb, 0x74, 0xc1, 0x7e, + 0x37, 0x08, 0xf7, 0xbb, 0x85, 0x3b, 0x37, 0x6a, 0x14, 0x2e, 0x55, 0x4f, 0x19, 0x0d, 0x6b, 0xa7, 0x2c, 0x1e, 0xb5, + 0xdb, 0x85, 0x2b, 0x09, 0x6d, 0x01, 0x31, 0x39, 0xf9, 0xd3, 0xf3, 0x67, 0x1c, 0x0c, 0xa6, 0xa2, 0x17, 0x73, 0xe7, + 0xfc, 0x56, 0xdd, 0x60, 0x29, 0x3f, 0xf9, 0x58, 0xa0, 0x21, 0xfe, 0xda, 0x4c, 0xd2, 0x03, 0x62, 0x16, 0xc9, 0x79, + 0xb1, 0xce, 0xe1, 0xab, 0xbd, 0x06, 0xca, 0x12, 0xaf, 0xbf, 0x26, 0x71, 0x95, 0xbb, 0x07, 0x7c, 0x0c, 0x6a, 0xca, + 0x8b, 0xd6, 0xf3, 0x6d, 0xd2, 0x23, 0xfb, 0xb4, 0xf4, 0xb8, 0xba, 0x8f, 0xf0, 0xc8, 0xfe, 0x70, 0xe1, 0x91, 0x9b, + 0xc2, 0x43, 0xb2, 0x8e, 0x39, 0x27, 0x76, 0x10, 0xd1, 0xe0, 0xe3, 0x55, 0x7a, 0xd3, 0x84, 0x2d, 0x91, 0xd9, 0x42, + 0xac, 0x5c, 0xff, 0xd6, 0x43, 0x03, 0xba, 0x33, 0xe3, 0x7b, 0x91, 0x42, 0xc7, 0x7f, 0x93, 0x10, 0xfb, 0x8d, 0x0e, + 0xec, 0xc9, 0x92, 0xd1, 0x88, 0xd8, 0x6f, 0x46, 0x23, 0x5b, 0xdf, 0xc3, 0xe3, 0x73, 0x2a, 0x6a, 0xbd, 0xae, 0x95, + 0x88, 0x5a, 0x60, 0xe8, 0x57, 0x65, 0x66, 0x81, 0x4a, 0xf1, 0x33, 0xd3, 0xf9, 0xd4, 0x9b, 0x90, 0xe5, 0xb0, 0xd5, + 0xe0, 0x33, 0x96, 0xf5, 0xef, 0x00, 0xe4, 0xb5, 0x8f, 0x36, 0x95, 0x00, 0x6f, 0xf8, 0xd2, 0xd4, 0xea, 0x25, 0x74, + 0x63, 0xaa, 0x55, 0xfc, 0x27, 0xb7, 0x2f, 0x42, 0x67, 0xce, 0x51, 0xc1, 0xf2, 0x37, 0xc9, 0xca, 0x05, 0x13, 0x12, + 0x46, 0x42, 0xcc, 0x69, 0x15, 0x3c, 0x1d, 0x8f, 0x63, 0x71, 0x6e, 0xa5, 0x66, 0x70, 0xcb, 0xe6, 0x83, 0xda, 0x7c, + 0x3d, 0xb3, 0xa1, 0xfa, 0x92, 0x87, 0xf8, 0xb4, 0xb1, 0x3c, 0x98, 0x7c, 0xad, 0xbe, 0x71, 0x2b, 0x62, 0x82, 0x0b, + 0xc5, 0xe3, 0x17, 0xf2, 0x38, 0x2b, 0xc7, 0x2c, 0x94, 0xcd, 0x59, 0x58, 0x14, 0xea, 0x22, 0x80, 0x90, 0xe5, 0x53, + 0xd0, 0x9e, 0x64, 0x4b, 0xfa, 0x29, 0x16, 0x9e, 0xcf, 0x8d, 0x3c, 0xba, 0xda, 0x72, 0x15, 0xda, 0x4e, 0x93, 0x89, + 0x49, 0x73, 0x5e, 0xd8, 0xca, 0x64, 0xd3, 0x48, 0xb4, 0x2d, 0x89, 0x4f, 0x99, 0xe1, 0x67, 0xcc, 0x10, 0x92, 0x8c, + 0xca, 0x05, 0xd1, 0xaf, 0x74, 0x41, 0x61, 0x5a, 0x59, 0xe2, 0x8d, 0xc4, 0x96, 0xc8, 0x4a, 0xcb, 0xa7, 0x7e, 0xa2, + 0x8d, 0x39, 0xc9, 0x0f, 0x76, 0x17, 0xd5, 0xca, 0x17, 0xb6, 0x06, 0x5b, 0x12, 0x6f, 0xff, 0xb8, 0x05, 0x0d, 0xfa, + 0x56, 0x0d, 0xf4, 0x64, 0x2d, 0x99, 0xed, 0xee, 0x14, 0xef, 0x8f, 0x97, 0x6e, 0x3e, 0xc7, 0x6e, 0x3e, 0xb7, 0xbe, + 0x5a, 0x34, 0xe7, 0xf4, 0xea, 0x23, 0xe3, 0x4d, 0xee, 0x4f, 0x9b, 0xe0, 0x3d, 0x15, 0x49, 0x28, 0x8a, 0x3d, 0x0b, + 0x1d, 0x5d, 0x9a, 0x7e, 0xbd, 0x59, 0x0e, 0x99, 0xe0, 0xc2, 0x8c, 0xf2, 0x92, 0x34, 0xa1, 0xbd, 0xfa, 0x49, 0x41, + 0x33, 0x99, 0x59, 0x63, 0x6b, 0xb8, 0x48, 0x21, 0x73, 0x9c, 0xdf, 0x7a, 0x6d, 0xc5, 0xd6, 0xdb, 0x3a, 0x53, 0xb9, + 0xbd, 0xb1, 0xbe, 0xa7, 0x90, 0xdb, 0x10, 0xd2, 0x2b, 0x5b, 0x4f, 0xb5, 0xde, 0x96, 0x4a, 0xfe, 0xa9, 0x73, 0x73, + 0x90, 0xba, 0xa2, 0xff, 0x37, 0x0e, 0x1c, 0xae, 0x16, 0x8b, 0x73, 0x73, 0xf7, 0x81, 0xcc, 0xf3, 0x47, 0x9c, 0x66, + 0xf8, 0x3e, 0x35, 0xaf, 0xc4, 0x15, 0x17, 0x0b, 0x10, 0x33, 0x5e, 0xe7, 0xa8, 0x9e, 0xf5, 0x7d, 0x77, 0xf7, 0x77, + 0x4f, 0xbf, 0x50, 0x38, 0xd2, 0x57, 0xbe, 0xda, 0x76, 0x0f, 0x36, 0x42, 0xec, 0xdf, 0x7a, 0x2c, 0x11, 0x32, 0xef, + 0x0a, 0x92, 0x42, 0x7a, 0xd3, 0x54, 0x1d, 0x00, 0xcd, 0x68, 0x2c, 0xbe, 0xf2, 0xae, 0x96, 0x62, 0xff, 0xe1, 0xf4, + 0x46, 0xaf, 0x46, 0x67, 0xe5, 0x60, 0xe7, 0x1f, 0x7a, 0x7e, 0x73, 0xfb, 0x81, 0xd1, 0xfa, 0x19, 0xc4, 0xc3, 0xe9, + 0x4d, 0x4f, 0x0a, 0xda, 0x66, 0x26, 0xa1, 0x6a, 0x4f, 0x6f, 0xcc, 0x13, 0xac, 0x55, 0x47, 0x96, 0xbb, 0x9f, 0x5b, + 0xd4, 0xcf, 0x69, 0x0f, 0x3e, 0x6a, 0xc5, 0x02, 0x3f, 0x56, 0xc2, 0x7c, 0xc2, 0xc2, 0x30, 0xa6, 0x3d, 0x2d, 0xaf, + 0xad, 0xce, 0x43, 0x38, 0x00, 0x6a, 0x2e, 0x59, 0x7d, 0x55, 0x0c, 0xe4, 0x95, 0x78, 0xf2, 0xaf, 0xf2, 0x34, 0x86, + 0x4f, 0x4a, 0x6e, 0x44, 0xa7, 0x3a, 0x19, 0xd9, 0xae, 0x90, 0x27, 0x7e, 0xd7, 0xe7, 0x72, 0xd8, 0xfe, 0x53, 0x4f, + 0x2c, 0x78, 0xbb, 0xc7, 0xd3, 0xa9, 0xd7, 0xdc, 0xaf, 0x4f, 0x04, 0x5e, 0x95, 0x53, 0xc0, 0x1b, 0xa6, 0x85, 0x41, + 0x5a, 0x49, 0x3e, 0x6d, 0xb9, 0x1d, 0x55, 0x26, 0x3a, 0x00, 0x23, 0xb4, 0x2c, 0x2a, 0xea, 0x93, 0xf9, 0xc7, 0xec, + 0x96, 0xc7, 0x9b, 0x77, 0xcb, 0x63, 0xbd, 0x5b, 0xee, 0xa6, 0xd8, 0x2f, 0x47, 0x1d, 0xf8, 0xaf, 0x57, 0x4d, 0xc8, + 0x6b, 0x5b, 0xfb, 0xd3, 0x1b, 0x0b, 0xf4, 0xb4, 0x66, 0x77, 0x7a, 0x23, 0xcf, 0xef, 0x42, 0x8e, 0x5c, 0x1b, 0x4e, + 0xb4, 0xe2, 0xb6, 0x05, 0x85, 0xf0, 0x7f, 0xbb, 0xf6, 0xaa, 0x73, 0x00, 0xef, 0xa0, 0xd5, 0xe1, 0xfa, 0xbb, 0xee, + 0xdd, 0x9b, 0xd6, 0x4b, 0x52, 0xee, 0x78, 0x9a, 0x1b, 0x23, 0x97, 0xfb, 0x57, 0x57, 0x34, 0xf4, 0x46, 0x69, 0x30, + 0xcb, 0xff, 0x49, 0xc1, 0xaf, 0x90, 0x78, 0xe7, 0x96, 0x5e, 0xe9, 0x47, 0x37, 0x95, 0xa7, 0x89, 0x75, 0x0f, 0x8b, + 0x72, 0x9d, 0xbc, 0x3c, 0xf0, 0x63, 0xea, 0x74, 0xdd, 0x83, 0x0d, 0x9b, 0xe0, 0xdf, 0x64, 0x6d, 0x36, 0x4e, 0xe6, + 0xf7, 0x22, 0xe3, 0x4e, 0x24, 0x7c, 0x16, 0x0e, 0xcc, 0x35, 0x6c, 0x1f, 0x6d, 0x06, 0xf7, 0x5c, 0x8f, 0x34, 0xd4, + 0x42, 0x41, 0xc9, 0x9d, 0x90, 0x8e, 0xfc, 0x59, 0xcc, 0xef, 0xee, 0x75, 0x1b, 0x65, 0xac, 0xf5, 0x7a, 0x07, 0x43, + 0xaf, 0xea, 0xde, 0x93, 0x4b, 0x7f, 0xf9, 0xf8, 0x00, 0xfe, 0x93, 0xe7, 0x6c, 0xae, 0x2a, 0x5d, 0x5d, 0x5a, 0xbd, + 0xa0, 0xab, 0x5f, 0xd7, 0x94, 0x71, 0x29, 0xc2, 0x85, 0x3e, 0x7e, 0xdf, 0xda, 0xa0, 0x55, 0xde, 0xab, 0xba, 0xd2, + 0xb2, 0x3e, 0xab, 0xf6, 0xe7, 0x75, 0x7e, 0xcf, 0xba, 0x81, 0xd4, 0x5c, 0xeb, 0x75, 0xd5, 0x57, 0xee, 0xd7, 0x2a, + 0x6b, 0x8c, 0x8b, 0xfa, 0xd7, 0xe4, 0xaa, 0x34, 0x51, 0x64, 0xd6, 0x2b, 0x58, 0x29, 0xd7, 0xd2, 0x4a, 0x49, 0x29, + 0xb9, 0x3c, 0x1e, 0xdc, 0x4c, 0x62, 0xeb, 0x5a, 0x5e, 0xc5, 0x43, 0xec, 0x8e, 0xdb, 0xb6, 0x2d, 0xe1, 0xa4, 0x83, + 0x2f, 0x82, 0xd9, 0x1f, 0xde, 0x7f, 0xdd, 0x3c, 0xb2, 0x07, 0xa0, 0x69, 0x5d, 0x8f, 0x85, 0x66, 0xf7, 0xd2, 0xbf, + 0xa5, 0xd9, 0x45, 0x57, 0xb9, 0xe0, 0x65, 0x6a, 0xba, 0x28, 0xb3, 0xba, 0xb6, 0x75, 0x33, 0x89, 0x93, 0x9c, 0xd8, + 0x11, 0xe7, 0x53, 0xaf, 0xd5, 0x9a, 0xcf, 0xe7, 0xee, 0x7c, 0xdf, 0x4d, 0xb3, 0x71, 0xab, 0xdb, 0x6e, 0xb7, 0xe1, + 0xe3, 0x22, 0xb6, 0x75, 0xcd, 0xe8, 0xfc, 0x49, 0x7a, 0x43, 0xec, 0xb6, 0xd5, 0xb6, 0x3a, 0xdd, 0x23, 0xab, 0xd3, + 0x3d, 0x70, 0x1f, 0x1e, 0xd9, 0xfd, 0x2f, 0x2c, 0xeb, 0x38, 0xa4, 0xa3, 0x1c, 0x7e, 0x58, 0xd6, 0xb1, 0x50, 0xbc, + 0xe4, 0x6f, 0xcb, 0x72, 0x83, 0x38, 0x6f, 0x76, 0xac, 0x85, 0x7a, 0xb4, 0x2c, 0xb8, 0xb0, 0xc8, 0xb3, 0xbe, 0x1c, + 0x75, 0x47, 0x07, 0xa3, 0xc7, 0x3d, 0x55, 0x5c, 0x7c, 0x51, 0xab, 0x8e, 0xe5, 0xbf, 0x5d, 0xa3, 0x59, 0xce, 0xb3, + 0xf4, 0x23, 0x55, 0xae, 0x7d, 0x0b, 0x44, 0xcf, 0xc6, 0xa6, 0xdd, 0xf5, 0x91, 0x3a, 0x47, 0x57, 0xc1, 0xa8, 0x5b, + 0x55, 0x17, 0x30, 0xb6, 0x4a, 0x20, 0x8f, 0x5b, 0x1a, 0xf4, 0x63, 0x13, 0x4d, 0x9d, 0xe6, 0x26, 0x44, 0x75, 0x6c, + 0x35, 0xc7, 0xb1, 0x9e, 0xdf, 0x31, 0x9c, 0x8f, 0xd7, 0xba, 0xaa, 0x80, 0xc0, 0xb6, 0x42, 0x62, 0xbf, 0xea, 0x74, + 0x8f, 0x70, 0xa7, 0xf3, 0xd0, 0x7d, 0x78, 0x14, 0xb4, 0xf1, 0x81, 0x7b, 0xd0, 0xdc, 0x77, 0x1f, 0xe2, 0xa3, 0xe6, + 0x11, 0x3e, 0x7a, 0x7e, 0x14, 0x34, 0x0f, 0xdc, 0x03, 0xdc, 0x6e, 0x1e, 0x41, 0x61, 0xf3, 0xa8, 0x79, 0x74, 0xdd, + 0x3c, 0x38, 0x0a, 0xda, 0xa2, 0xb4, 0xeb, 0x1e, 0x1e, 0x36, 0x3b, 0x6d, 0xf7, 0xf0, 0x10, 0x1f, 0xba, 0x0f, 0x1f, + 0x36, 0x3b, 0xfb, 0xee, 0xc3, 0x87, 0x2f, 0x0f, 0x8f, 0xdc, 0x7d, 0x78, 0xb7, 0xbf, 0x1f, 0xec, 0xbb, 0x9d, 0x4e, + 0x13, 0xfe, 0xe0, 0x23, 0xb7, 0x2b, 0x7f, 0x74, 0x3a, 0xee, 0x7e, 0x07, 0xb7, 0xe3, 0xc3, 0xae, 0xfb, 0xf0, 0x31, + 0x16, 0x7f, 0x45, 0x35, 0x2c, 0xfe, 0x40, 0x37, 0xf8, 0xb1, 0xdb, 0x7d, 0x28, 0x7f, 0x89, 0x0e, 0xaf, 0x0f, 0x8e, + 0x7e, 0xb4, 0x5b, 0x5b, 0xe7, 0xd0, 0x91, 0x73, 0x38, 0x3a, 0x74, 0xf7, 0xf7, 0xf1, 0x41, 0xc7, 0x3d, 0xda, 0x8f, + 0x9a, 0x07, 0x5d, 0xf7, 0xe1, 0xa3, 0xa0, 0xd9, 0x71, 0x1f, 0x3d, 0xc2, 0xed, 0xe6, 0xbe, 0xdb, 0xc5, 0x1d, 0xf7, + 0x60, 0x5f, 0xfc, 0xd8, 0x77, 0xbb, 0xd7, 0x8f, 0x1e, 0xbb, 0x0f, 0x0f, 0xa3, 0x87, 0xee, 0xc1, 0xb7, 0x07, 0x47, + 0x6e, 0x77, 0x3f, 0xda, 0x7f, 0xe8, 0x76, 0x1f, 0x5d, 0x3f, 0x74, 0x0f, 0xa2, 0x66, 0xf7, 0xe1, 0x9d, 0x2d, 0x3b, + 0x5d, 0x17, 0x70, 0x24, 0x5e, 0xc3, 0x0b, 0xac, 0x5e, 0xc0, 0xff, 0x91, 0x68, 0xfb, 0x6f, 0xd8, 0x4d, 0xbe, 0xde, + 0xf4, 0xb1, 0x7b, 0xf4, 0x28, 0x90, 0xd5, 0xa1, 0xa0, 0xa9, 0x6b, 0x40, 0x93, 0xeb, 0xa6, 0x1c, 0x56, 0x74, 0xd7, + 0xd4, 0x1d, 0xe9, 0xff, 0xd5, 0x60, 0xd7, 0x4d, 0x18, 0x58, 0x8e, 0xfb, 0xef, 0xda, 0x4f, 0xb9, 0xe4, 0xc7, 0xad, + 0xb1, 0x24, 0xfd, 0x71, 0xff, 0x0b, 0xf9, 0xe5, 0xa0, 0x2f, 0x2e, 0xb1, 0xbf, 0xcd, 0xf1, 0x11, 0x7f, 0xda, 0xf1, + 0x11, 0xd1, 0xfb, 0x78, 0x3e, 0xe2, 0x3f, 0xdc, 0xf3, 0xe1, 0xaf, 0xba, 0xcd, 0x6f, 0xf8, 0x9a, 0x83, 0x63, 0xd5, + 0x2a, 0x7e, 0xc1, 0x9d, 0xf3, 0x14, 0xbe, 0x52, 0x5d, 0xf4, 0x6e, 0x38, 0x89, 0xa8, 0xe9, 0x07, 0x4a, 0x81, 0xc5, + 0xde, 0x70, 0xc9, 0x63, 0x83, 0x6d, 0x08, 0x09, 0x3f, 0x8d, 0x90, 0xef, 0xee, 0x83, 0x8f, 0xf0, 0x0f, 0xc7, 0x47, + 0x60, 0xe2, 0xa3, 0xe6, 0xc9, 0x17, 0x9e, 0x06, 0xe1, 0x29, 0x38, 0x13, 0xcf, 0x0e, 0x5c, 0xd0, 0xd1, 0xb0, 0x5b, + 0xf4, 0x5a, 0x44, 0xee, 0x64, 0x70, 0xfd, 0xf9, 0xe7, 0x04, 0x1d, 0xe4, 0x6d, 0x3c, 0x44, 0x1f, 0x8b, 0x98, 0x0a, + 0xa9, 0xa3, 0x1e, 0x4a, 0xa1, 0xd4, 0x75, 0xdb, 0x6e, 0xbb, 0x74, 0xe9, 0xc0, 0x0d, 0x4c, 0x64, 0x91, 0x72, 0xdf, + 0xdb, 0xe9, 0xe0, 0x38, 0x1d, 0xc3, 0xbd, 0x4c, 0xe2, 0x4b, 0x75, 0x70, 0xe2, 0x21, 0x90, 0x1f, 0x09, 0x84, 0xf4, + 0x09, 0xe5, 0xe8, 0xf1, 0xb3, 0x8f, 0x7f, 0x83, 0x20, 0xa6, 0x8e, 0x49, 0x4c, 0xc0, 0xdb, 0xf1, 0x8a, 0x86, 0xcc, + 0x77, 0x6c, 0x67, 0x9a, 0xd1, 0x11, 0xcd, 0xf2, 0x66, 0xed, 0x6a, 0x20, 0x71, 0x2b, 0x10, 0xb2, 0x15, 0x84, 0xa3, + 0x0c, 0xbe, 0xbc, 0x44, 0xce, 0x95, 0xbf, 0xd1, 0x56, 0x06, 0x98, 0x5d, 0x60, 0x5d, 0x92, 0x81, 0xac, 0xad, 0x94, + 0x36, 0x5b, 0x6a, 0x6d, 0x1d, 0xb7, 0x7b, 0x88, 0x2c, 0x51, 0x0c, 0xdf, 0xb4, 0xf9, 0xc1, 0x69, 0xee, 0xb7, 0xff, + 0x84, 0x8c, 0x66, 0x65, 0x47, 0x43, 0xe5, 0x6e, 0xcb, 0xcb, 0x2f, 0x1f, 0xae, 0x84, 0x5d, 0x6d, 0x49, 0x11, 0x5f, + 0xca, 0xb9, 0xdb, 0xa8, 0x97, 0xab, 0xa4, 0x39, 0x79, 0xfb, 0xe0, 0x88, 0x8d, 0x1d, 0xe3, 0x66, 0x8b, 0x5c, 0x7e, + 0x33, 0x07, 0x2e, 0xc6, 0x47, 0xa8, 0xa8, 0xaa, 0xe4, 0x68, 0x21, 0xa2, 0x2d, 0x2c, 0xb1, 0xf2, 0xe5, 0xd2, 0x11, + 0x2e, 0x72, 0x62, 0xe0, 0x14, 0x9e, 0x51, 0x0d, 0xc9, 0x39, 0x2e, 0x01, 0x12, 0x08, 0x26, 0xb9, 0xfc, 0xb7, 0x2a, + 0xd6, 0x3f, 0x94, 0xe3, 0xcb, 0x8d, 0xfd, 0x64, 0x0c, 0x54, 0xe8, 0x27, 0xe3, 0x35, 0xb7, 0x9a, 0x0c, 0x18, 0xad, + 0x94, 0x56, 0x5d, 0x55, 0xee, 0xb3, 0xfc, 0xc9, 0xed, 0x7b, 0x75, 0xb9, 0xb6, 0x0d, 0xde, 0x69, 0x11, 0xdf, 0xa8, + 0x3e, 0x04, 0xd4, 0x20, 0x0f, 0x8e, 0x27, 0x94, 0xfb, 0xf2, 0x5c, 0x1c, 0xe8, 0x13, 0x90, 0xcb, 0x62, 0x29, 0x6b, + 0x54, 0x05, 0xf5, 0x89, 0xbc, 0x37, 0x40, 0x8a, 0x7a, 0x6c, 0xa9, 0x5b, 0xe9, 0x9a, 0x62, 0x69, 0x48, 0x07, 0x4b, + 0x7f, 0x4c, 0xe0, 0x8b, 0x93, 0xcf, 0x24, 0x49, 0xed, 0xfe, 0x83, 0x32, 0xd7, 0x65, 0xdb, 0x22, 0xc4, 0x2c, 0xf9, + 0x78, 0x9e, 0xd1, 0xf8, 0x9f, 0xc8, 0x03, 0x16, 0xa4, 0xc9, 0x83, 0xa1, 0x8d, 0x7a, 0xdc, 0x8d, 0x32, 0x3a, 0x22, + 0x0f, 0x40, 0xc6, 0x7b, 0xc2, 0xfa, 0x00, 0x46, 0xd8, 0xb8, 0x99, 0xc4, 0x58, 0x68, 0x4c, 0xf7, 0x50, 0x88, 0x24, + 0xb8, 0x76, 0xf7, 0xd0, 0xb6, 0xa4, 0x4d, 0x2c, 0x7e, 0xf7, 0xa5, 0x38, 0x15, 0x4a, 0x80, 0xd5, 0xe9, 0xba, 0x87, + 0x51, 0xd7, 0x7d, 0x7c, 0xfd, 0xc8, 0x3d, 0x8a, 0x3a, 0x8f, 0xae, 0x9b, 0xf0, 0x6f, 0xd7, 0x7d, 0x1c, 0x37, 0xbb, + 0xee, 0x63, 0xf8, 0xff, 0xdb, 0x03, 0xf7, 0x30, 0x6a, 0x76, 0xdc, 0xa3, 0xeb, 0x7d, 0x77, 0xff, 0x65, 0xa7, 0xeb, + 0xee, 0x5b, 0x1d, 0x4b, 0xb6, 0x03, 0x76, 0x2d, 0xb9, 0xf3, 0x83, 0x95, 0x0d, 0xb1, 0x21, 0x18, 0x27, 0xcf, 0xf6, + 0xd9, 0x58, 0x1c, 0xc7, 0x36, 0xf7, 0xa7, 0x72, 0xd6, 0x3d, 0xf5, 0x33, 0xf8, 0x88, 0x6a, 0x7d, 0xef, 0xd6, 0xde, + 0xe1, 0x1a, 0xbf, 0xd8, 0x30, 0xc4, 0x54, 0x44, 0xc0, 0xcd, 0x6b, 0xdd, 0xa8, 0xb8, 0x2e, 0x4f, 0x7e, 0x76, 0x4a, + 0x45, 0xc1, 0xca, 0xec, 0x22, 0x83, 0xac, 0x65, 0x0d, 0x48, 0x00, 0x12, 0x34, 0xb8, 0x9a, 0x3f, 0x5a, 0xd1, 0x79, + 0x06, 0x57, 0x21, 0x68, 0x5e, 0xc2, 0xc4, 0xc7, 0xff, 0x04, 0x0c, 0x2f, 0xc2, 0x62, 0x15, 0x3c, 0x38, 0x81, 0x98, + 0xa5, 0xc6, 0xc5, 0x77, 0xb4, 0xca, 0x01, 0x08, 0x19, 0x5c, 0x55, 0x58, 0x14, 0x7a, 0x66, 0x35, 0x2f, 0x6e, 0x85, + 0x44, 0xc1, 0x4e, 0x68, 0x3e, 0xb0, 0xa1, 0xc8, 0xf6, 0x6c, 0xe1, 0x01, 0xb4, 0xcb, 0xef, 0xcc, 0x96, 0x74, 0x5f, + 0x15, 0x60, 0x71, 0x0f, 0x05, 0x6c, 0x6a, 0x40, 0x9f, 0x8d, 0xf6, 0xf6, 0xb6, 0x6e, 0x27, 0xa1, 0x5f, 0xc2, 0xd4, + 0xaa, 0xcf, 0x53, 0x9a, 0x9c, 0xca, 0x36, 0xd7, 0xa1, 0xec, 0x57, 0x60, 0x18, 0x29, 0xb4, 0x5c, 0x51, 0x9f, 0xbb, + 0x7e, 0x22, 0x0f, 0x18, 0x18, 0xfc, 0x0c, 0x77, 0xe8, 0x3e, 0x2a, 0x52, 0xee, 0xcb, 0x9c, 0x31, 0x93, 0x0d, 0xa4, + 0xdc, 0xd7, 0xd7, 0x38, 0xf9, 0xbc, 0x76, 0x84, 0x3f, 0xea, 0xf6, 0xdf, 0xbc, 0x3f, 0xb1, 0xe4, 0xee, 0x3d, 0x6e, + 0x45, 0xdd, 0xfe, 0xb1, 0x70, 0xa9, 0xc8, 0xac, 0x00, 0x22, 0xb3, 0x02, 0x2c, 0x75, 0x7f, 0x0d, 0x04, 0xda, 0x8a, + 0x96, 0x9c, 0xb6, 0x30, 0x29, 0xa4, 0x33, 0x78, 0x32, 0x8b, 0x39, 0x83, 0xcf, 0x2b, 0xb5, 0x44, 0x4a, 0x80, 0x48, + 0x31, 0xd0, 0xc7, 0x61, 0x95, 0xf2, 0x78, 0xc5, 0x13, 0xed, 0x3a, 0x1e, 0xb1, 0x98, 0xea, 0x03, 0xb0, 0xaa, 0xab, + 0x32, 0x1f, 0x68, 0xbd, 0x76, 0x3e, 0xbb, 0x82, 0x9c, 0x08, 0x9d, 0x7d, 0xf4, 0x41, 0x35, 0x38, 0x16, 0x43, 0x41, + 0x60, 0x5f, 0x4a, 0x71, 0xfd, 0x21, 0xd9, 0xfa, 0x92, 0xaa, 0xd9, 0x2b, 0x01, 0x02, 0x97, 0x86, 0x44, 0xfb, 0xfd, + 0xd2, 0x9b, 0x6c, 0xbe, 0x2b, 0x8e, 0x5b, 0xd1, 0x7e, 0xff, 0xd2, 0x1b, 0xab, 0xfe, 0x5e, 0xa6, 0xe3, 0xcd, 0x7d, + 0xc5, 0xe9, 0x78, 0x20, 0x4e, 0xe4, 0xcb, 0xdb, 0xa5, 0xb4, 0x6e, 0x9c, 0xc6, 0x76, 0xff, 0x58, 0xe9, 0x0a, 0x96, + 0x88, 0xba, 0xdb, 0x87, 0x6d, 0x7d, 0xc8, 0x3f, 0x4e, 0xc7, 0xb0, 0x5f, 0x65, 0x13, 0x63, 0x90, 0x9a, 0x43, 0x3e, + 0xea, 0xf4, 0x8f, 0x7d, 0x4b, 0xb0, 0x1e, 0xc1, 0x5b, 0x72, 0xaf, 0x05, 0x8d, 0xa3, 0x74, 0x42, 0x5d, 0x96, 0xb6, + 0xe6, 0xf4, 0xaa, 0xe9, 0x4f, 0x59, 0xe5, 0xfd, 0x06, 0x9d, 0xa4, 0x1c, 0x32, 0x5d, 0xc9, 0xc0, 0xea, 0x56, 0xde, + 0xb8, 0x03, 0x30, 0x89, 0xb4, 0xe7, 0x4e, 0xb8, 0xec, 0x0c, 0xb0, 0xd2, 0xfe, 0x71, 0xcb, 0x5f, 0xc1, 0x88, 0xd8, + 0x8a, 0x85, 0xf2, 0xc3, 0x83, 0xdd, 0x73, 0x25, 0xd2, 0xbf, 0xa4, 0xb4, 0xd0, 0xfe, 0x7a, 0x25, 0xc7, 0x0b, 0xbb, + 0xff, 0xaf, 0xff, 0xe3, 0x7f, 0x29, 0x17, 0xfc, 0x71, 0x2b, 0xea, 0xe8, 0xbe, 0x56, 0x56, 0xa5, 0x38, 0x86, 0x2b, + 0x73, 0xaa, 0x98, 0x31, 0xbd, 0x69, 0x8e, 0x33, 0x16, 0x36, 0x23, 0x3f, 0x1e, 0xd9, 0xfd, 0xed, 0xd8, 0x94, 0xe9, + 0x89, 0x4d, 0x1d, 0x6d, 0x5d, 0x2f, 0x02, 0x7a, 0xfd, 0x4d, 0xf7, 0x3f, 0xe8, 0x8c, 0x2f, 0xb1, 0xb5, 0xcd, 0xdb, + 0x20, 0xaa, 0xdd, 0x57, 0xbb, 0x11, 0x22, 0x57, 0x5f, 0xa7, 0x56, 0x0c, 0x32, 0xaf, 0x5d, 0x04, 0x51, 0xd8, 0x56, + 0x19, 0xf3, 0xfa, 0xbf, 0xff, 0xf3, 0xbf, 0xfc, 0x37, 0xfd, 0x08, 0xa1, 0xac, 0x7f, 0xfd, 0xef, 0xff, 0xf9, 0xff, + 0xfc, 0xef, 0xff, 0x0a, 0xe9, 0x69, 0x2a, 0xdc, 0x25, 0x98, 0x8a, 0x55, 0xc5, 0xba, 0x24, 0x77, 0xb1, 0xe0, 0xd0, + 0xdb, 0x84, 0xe5, 0x9c, 0x05, 0xf5, 0xab, 0x21, 0xce, 0xc4, 0x80, 0x62, 0x67, 0x2a, 0xe8, 0xc4, 0x0e, 0x2f, 0x2a, + 0x82, 0xaa, 0xa1, 0x5c, 0x10, 0x6e, 0x71, 0xdc, 0x02, 0x7c, 0xdf, 0xef, 0x66, 0x1b, 0xb7, 0x5c, 0x8e, 0x85, 0x26, + 0x13, 0x28, 0x29, 0xaa, 0x72, 0x0b, 0x42, 0x2f, 0x0b, 0x78, 0xf4, 0xba, 0x46, 0xb1, 0x58, 0xbd, 0x5a, 0x9b, 0xde, + 0xcf, 0xb3, 0x9c, 0xb3, 0x11, 0xa0, 0x5c, 0xba, 0x91, 0x45, 0x94, 0xbb, 0x09, 0xaa, 0x64, 0x7c, 0x5b, 0x88, 0x5e, + 0x24, 0x81, 0x1e, 0x1c, 0xfd, 0xa9, 0xf8, 0xf3, 0x04, 0x14, 0x36, 0xcb, 0x99, 0xf8, 0x37, 0xca, 0x7a, 0x7f, 0xd8, + 0x6e, 0x4f, 0x6f, 0xd0, 0xa2, 0x1a, 0x01, 0x6f, 0x1b, 0x4c, 0xd0, 0xb1, 0xd9, 0xa1, 0x08, 0x8f, 0x97, 0x5e, 0xee, + 0xb6, 0x05, 0xae, 0x72, 0xab, 0x5d, 0x14, 0x5f, 0x2d, 0x84, 0xa3, 0x95, 0xfd, 0x0a, 0x61, 0x6c, 0xe5, 0x93, 0xbe, + 0x4a, 0xcd, 0xc9, 0x2d, 0x8c, 0x56, 0x5d, 0xd9, 0x2a, 0xea, 0xac, 0x5f, 0x12, 0x63, 0x86, 0xe1, 0xcd, 0x00, 0xfa, + 0x01, 0x84, 0xc4, 0xa3, 0x0e, 0x8e, 0xba, 0x8b, 0xb2, 0x7b, 0xce, 0xd3, 0x89, 0x19, 0x77, 0xa7, 0x3e, 0x0d, 0xe8, + 0x48, 0xfb, 0xf2, 0xd5, 0x7b, 0x19, 0x53, 0x2f, 0xa2, 0xfd, 0x0d, 0x63, 0x29, 0x90, 0x44, 0xbc, 0xdd, 0x6a, 0x17, + 0x5f, 0xc2, 0x0e, 0x5c, 0x8c, 0xe2, 0xd4, 0xe7, 0x9e, 0x20, 0xd8, 0x9e, 0x19, 0xbd, 0xf7, 0x81, 0x27, 0xa5, 0x0b, + 0x03, 0x9e, 0x9e, 0xac, 0x0a, 0x5e, 0xf5, 0xfa, 0x65, 0x91, 0x85, 0x2b, 0x9a, 0x9b, 0x5d, 0x49, 0xa7, 0xdc, 0x77, + 0x2a, 0x28, 0xfe, 0xbc, 0xe6, 0xcd, 0x52, 0x02, 0xa9, 0x8b, 0x36, 0xbf, 0x97, 0x62, 0x5f, 0xbe, 0xfd, 0x9e, 0x3b, + 0xb6, 0x00, 0xd3, 0x5e, 0xad, 0x25, 0x0a, 0xa1, 0xd6, 0x73, 0xf2, 0x5d, 0x69, 0x51, 0xf9, 0xd3, 0xa9, 0xa8, 0x88, + 0x7a, 0xc7, 0x2d, 0xa9, 0x08, 0x03, 0xf7, 0x10, 0x19, 0x1f, 0x32, 0xc1, 0x42, 0x55, 0x52, 0x5b, 0x41, 0xfe, 0x52, + 0xa9, 0x17, 0xf0, 0xd5, 0xf2, 0xfe, 0xff, 0x03, 0x0c, 0xbd, 0x72, 0x0a, 0x4e, 0x98, 0x00, 0x00}; #else // Brotli (default, smaller) constexpr uint8_t INDEX_BR[] PROGMEM = { - 0x1b, 0xe2, 0x97, 0xa3, 0x90, 0xa2, 0x95, 0x55, 0x51, 0x04, 0x1b, 0x07, 0x80, 0x20, 0x79, 0x0e, 0x50, 0xab, 0x02, - 0xdb, 0x98, 0x16, 0xf4, 0x7b, 0x22, 0xa3, 0x4d, 0xd3, 0x86, 0xc1, 0x26, 0x48, 0x49, 0x60, 0xbe, 0xb3, 0xc9, 0xa1, - 0x8c, 0x96, 0x10, 0x1b, 0x21, 0xcf, 0x48, 0x68, 0xce, 0x10, 0x34, 0x32, 0x7c, 0xbf, 0x71, 0x7b, 0x03, 0x8f, 0xdd, - 0x37, 0x06, 0x9a, 0x30, 0x50, 0xe4, 0x08, 0x47, 0x68, 0xec, 0x93, 0xdc, 0x7d, 0x53, 0xf5, 0x4f, 0xd7, 0x8a, 0xcf, - 0x2f, 0x85, 0x3a, 0x6c, 0xa9, 0x63, 0xcb, 0xf5, 0xc8, 0x18, 0xe3, 0xf5, 0xdb, 0x0c, 0x05, 0x9b, 0x48, 0x2c, 0x42, - 0x21, 0xa0, 0x2c, 0x25, 0xf7, 0xcb, 0xb7, 0xaa, 0x65, 0xd5, 0x7f, 0x3e, 0x2f, 0x94, 0x2b, 0x53, 0xa7, 0x0f, 0x4e, - 0x56, 0xa9, 0xf7, 0xce, 0x54, 0x88, 0x39, 0xef, 0xea, 0x3d, 0x69, 0x56, 0xd0, 0x52, 0x96, 0x0a, 0x5b, 0x35, 0xd4, - 0x42, 0xd6, 0x35, 0x10, 0xf3, 0x7f, 0xec, 0x95, 0xd3, 0x2a, 0xfe, 0x4d, 0x22, 0x6a, 0xd6, 0x69, 0x6e, 0x7b, 0x5a, - 0xdd, 0x33, 0x58, 0x21, 0xa4, 0x33, 0xd6, 0x61, 0x05, 0xf5, 0x08, 0xa9, 0x10, 0x32, 0xf5, 0xdc, 0x04, 0x59, 0x72, - 0x41, 0xf4, 0x09, 0xfa, 0x08, 0x08, 0x9b, 0xcc, 0xf3, 0x2d, 0x71, 0xb4, 0x1c, 0xe3, 0x04, 0x64, 0x9a, 0x96, 0x5b, - 0x16, 0x58, 0xed, 0xbf, 0x37, 0xd5, 0x2a, 0x6d, 0x80, 0x66, 0xcf, 0xba, 0xd4, 0xb8, 0xd4, 0x38, 0x65, 0x10, 0x57, - 0x3a, 0xe7, 0x93, 0xe0, 0x82, 0x90, 0x78, 0xef, 0xfd, 0xff, 0x96, 0xed, 0x30, 0x44, 0x77, 0x13, 0x3b, 0x70, 0x92, - 0xe8, 0xb0, 0xf4, 0x25, 0x5a, 0xc9, 0xff, 0xff, 0xbb, 0x01, 0x76, 0x03, 0x94, 0x16, 0x20, 0xa5, 0x2d, 0x8a, 0xe2, - 0x56, 0x51, 0xd4, 0xdc, 0x18, 0xcb, 0x99, 0xb3, 0x4e, 0x3b, 0x55, 0x67, 0x5d, 0x64, 0xa3, 0xc4, 0xf8, 0xec, 0x2a, - 0xb7, 0x51, 0x68, 0x6c, 0x7a, 0xd9, 0x85, 0x97, 0x9e, 0xcb, 0x61, 0x26, 0xbe, 0xeb, 0x3a, 0x6b, 0xe5, 0x38, 0xd8, - 0x63, 0xa8, 0xb5, 0xba, 0xbe, 0xbd, 0x1d, 0xe3, 0xc4, 0x11, 0xa3, 0x08, 0xc4, 0xfe, 0x26, 0x3a, 0xfb, 0x01, 0x5d, - 0xb4, 0xfc, 0x1d, 0x78, 0xca, 0xb2, 0xac, 0x61, 0x39, 0x21, 0xe5, 0x6b, 0x5a, 0x70, 0x76, 0x57, 0x91, 0x4c, 0x8c, - 0x3d, 0x0e, 0x50, 0x4e, 0x41, 0x1c, 0xda, 0x62, 0xd2, 0xf1, 0x25, 0xce, 0x03, 0xf4, 0xfa, 0x3b, 0xc1, 0xc4, 0xed, - 0xc1, 0xdf, 0x8f, 0xe1, 0xc0, 0x0e, 0x34, 0x72, 0x3c, 0xb3, 0x3f, 0xfa, 0xc0, 0xe6, 0xcd, 0xf4, 0x01, 0x19, 0xf4, - 0x28, 0x5b, 0xde, 0x02, 0x6e, 0xa2, 0x24, 0x59, 0x76, 0x94, 0x8d, 0x00, 0x35, 0xab, 0xbe, 0xd9, 0xc0, 0xfb, 0xfa, - 0x97, 0x4f, 0x8f, 0x6e, 0xa4, 0x28, 0x0a, 0xfd, 0x43, 0xd9, 0xf2, 0xb2, 0xc2, 0x75, 0x49, 0x97, 0x8c, 0xcd, 0x61, - 0xb3, 0x64, 0x52, 0x1e, 0x46, 0x1e, 0xa2, 0xe0, 0xb5, 0x26, 0xd3, 0xf5, 0x3c, 0x9e, 0x19, 0x32, 0x45, 0xb9, 0x28, - 0xf5, 0x40, 0xcf, 0xa5, 0xf1, 0xcd, 0x8d, 0x29, 0x55, 0xe3, 0x2c, 0x43, 0x12, 0xa2, 0x4d, 0x37, 0xda, 0x94, 0x3e, - 0x49, 0x88, 0x4f, 0x2c, 0xa5, 0x67, 0xbe, 0xb7, 0x75, 0x28, 0xb8, 0x2f, 0x0d, 0x75, 0xce, 0x87, 0x3f, 0xe3, 0x30, - 0x5a, 0x25, 0x92, 0x30, 0xd3, 0x2d, 0x45, 0xca, 0xe5, 0x49, 0xc7, 0x4d, 0x13, 0x95, 0xa5, 0x9f, 0x7f, 0x2d, 0x49, - 0x46, 0x5a, 0x49, 0x11, 0x12, 0xd2, 0xdd, 0x38, 0x3c, 0x31, 0x63, 0x5e, 0xb6, 0xe6, 0xde, 0xcd, 0xcd, 0x6d, 0x67, - 0x46, 0x53, 0x16, 0x86, 0xd4, 0xad, 0x07, 0xac, 0xdb, 0xd7, 0x9f, 0x48, 0xcc, 0xa6, 0x4d, 0x9f, 0x6c, 0x8b, 0xf2, - 0xcb, 0xcd, 0x1b, 0x1e, 0xa9, 0x39, 0x37, 0x4d, 0xcd, 0x80, 0x9b, 0x19, 0x67, 0x64, 0x70, 0xb0, 0x38, 0x00, 0x9f, - 0xab, 0x26, 0xca, 0xb7, 0xbb, 0x55, 0x50, 0xcf, 0x31, 0x65, 0x12, 0xb6, 0x2b, 0x36, 0x9d, 0x17, 0x2b, 0x50, 0xd1, - 0x13, 0x72, 0x80, 0x7f, 0x88, 0x62, 0xe4, 0xee, 0x40, 0xb7, 0x56, 0xd2, 0x66, 0xa3, 0xb0, 0x0e, 0x31, 0xeb, 0x9a, - 0x27, 0xc1, 0xd5, 0x7b, 0x63, 0xb3, 0x84, 0x1b, 0xc8, 0xbf, 0x35, 0x29, 0x8a, 0x3c, 0xd0, 0x66, 0x03, 0x2a, 0x3e, - 0xb9, 0x79, 0x4c, 0x16, 0x01, 0xaa, 0xe8, 0x0e, 0x01, 0x1c, 0x73, 0x05, 0x78, 0x3a, 0x9c, 0x23, 0xb8, 0x18, 0x78, - 0xc5, 0xcd, 0x53, 0x7f, 0xb4, 0x61, 0xb8, 0x11, 0xa4, 0xcd, 0xc6, 0x27, 0x27, 0xb6, 0xae, 0x51, 0x01, 0x1d, 0xec, - 0xe4, 0x6b, 0x99, 0xe4, 0xdb, 0x2d, 0xbb, 0x66, 0x38, 0x54, 0xf5, 0xf2, 0xba, 0xc3, 0x24, 0x41, 0xfa, 0xae, 0x46, - 0x43, 0xdd, 0xfd, 0x9d, 0x0d, 0x52, 0x98, 0x1c, 0x7f, 0xab, 0x29, 0x83, 0x0f, 0xb9, 0x11, 0xe0, 0xd3, 0x49, 0x86, - 0xef, 0xbb, 0xdf, 0x0a, 0x04, 0x76, 0x11, 0x07, 0x48, 0x7f, 0x86, 0x4c, 0x3a, 0x84, 0xf5, 0xb6, 0x32, 0x54, 0x59, - 0xed, 0xd5, 0xf1, 0xf0, 0xf8, 0x39, 0x2d, 0x10, 0x85, 0x11, 0xd2, 0xef, 0x72, 0xcb, 0xd2, 0x8f, 0xf2, 0x2e, 0x7c, - 0x9b, 0x28, 0xa6, 0x07, 0x7f, 0x7a, 0x7c, 0x43, 0x28, 0x0b, 0x3f, 0xe5, 0x98, 0x64, 0x6f, 0x63, 0xad, 0xd9, 0x90, - 0x34, 0x84, 0x30, 0xf9, 0x53, 0x6e, 0xea, 0xe3, 0x5f, 0x36, 0x39, 0xe7, 0x26, 0x49, 0xf0, 0xe9, 0xe7, 0x32, 0x50, - 0x58, 0x41, 0x1e, 0xaa, 0x98, 0x6f, 0x6b, 0xfa, 0x94, 0x4b, 0x20, 0x01, 0x84, 0x2a, 0x32, 0x84, 0x73, 0x5a, 0x39, - 0x4a, 0x57, 0xbc, 0xbd, 0x86, 0xa4, 0xb2, 0x77, 0x99, 0xe5, 0xdd, 0x44, 0x5d, 0xd5, 0xde, 0x5b, 0x94, 0x7e, 0xec, - 0x53, 0xdd, 0x67, 0xb8, 0x8d, 0xbb, 0x1d, 0x65, 0xf4, 0xe8, 0xe4, 0x73, 0x3d, 0xbc, 0xba, 0xd9, 0x30, 0xbe, 0x1f, - 0xeb, 0x0b, 0x21, 0xaf, 0x24, 0x9a, 0x44, 0xa2, 0x0a, 0xbf, 0xfe, 0xfa, 0x06, 0x14, 0x59, 0x76, 0x6d, 0x97, 0x7e, - 0xe0, 0x70, 0x8c, 0x41, 0x28, 0xac, 0x0b, 0xad, 0x4b, 0xb5, 0xbb, 0x54, 0xeb, 0x0d, 0x05, 0x24, 0xc7, 0xad, 0x04, - 0xfb, 0x9b, 0x12, 0x44, 0xec, 0x20, 0x03, 0xff, 0xba, 0x91, 0xa0, 0x50, 0xba, 0x24, 0xed, 0x9c, 0x96, 0x7e, 0xef, - 0x6f, 0x24, 0x6c, 0xd1, 0x8c, 0x55, 0xe9, 0x0f, 0x8c, 0x2f, 0x8b, 0x62, 0x44, 0x3c, 0x1b, 0x46, 0x3b, 0x49, 0x99, - 0xdc, 0xd6, 0x7a, 0x70, 0x5d, 0xe5, 0x2a, 0x62, 0x2e, 0x54, 0xab, 0x44, 0xf2, 0xf4, 0x61, 0xb2, 0xd8, 0x07, 0x8b, - 0x01, 0x3e, 0x04, 0x19, 0xe1, 0x5d, 0x8e, 0x2a, 0xff, 0x86, 0xa3, 0x59, 0xe5, 0xcc, 0x8d, 0xd3, 0x51, 0x6f, 0xc1, - 0x15, 0x9f, 0x37, 0x73, 0x3d, 0x49, 0x99, 0xca, 0x53, 0x3a, 0x96, 0x0c, 0x92, 0x2b, 0x8b, 0xde, 0x08, 0x68, 0x52, - 0x87, 0x31, 0xb2, 0x68, 0x81, 0xb1, 0xe9, 0x9f, 0x78, 0xf1, 0x22, 0xe8, 0x84, 0x48, 0xdb, 0x49, 0x4d, 0xd2, 0xea, - 0x80, 0x1f, 0xec, 0x50, 0x77, 0x66, 0xe7, 0x13, 0x36, 0x02, 0x85, 0x6f, 0xdd, 0x68, 0xe0, 0x4b, 0x6c, 0x5b, 0xbe, - 0x18, 0xca, 0xaf, 0x92, 0x97, 0xdd, 0x4e, 0x90, 0x28, 0x4e, 0x48, 0x42, 0x62, 0xc3, 0xf1, 0xf7, 0x71, 0x59, 0x2b, - 0x24, 0x2e, 0x4b, 0xf1, 0x52, 0x2d, 0x7b, 0xbf, 0x8f, 0x5d, 0x1a, 0x29, 0x6b, 0xdd, 0xed, 0x8b, 0x0d, 0xa3, 0xaf, - 0x1a, 0x94, 0x32, 0xc4, 0x54, 0x3d, 0xa1, 0xee, 0x41, 0x42, 0x00, 0xc3, 0xc2, 0x23, 0x57, 0x52, 0x9c, 0x48, 0x54, - 0x42, 0x82, 0x61, 0xb1, 0xcb, 0x1b, 0xae, 0x8f, 0xfa, 0x30, 0x6c, 0x00, 0xc4, 0x1b, 0x74, 0x7c, 0x99, 0x51, 0x60, - 0x45, 0x6d, 0x05, 0xe0, 0x44, 0x15, 0x24, 0x98, 0xb1, 0x40, 0x5f, 0xa1, 0x5e, 0x43, 0x55, 0xae, 0x10, 0xbd, 0x9d, - 0x80, 0x41, 0x6e, 0x35, 0x5d, 0xe8, 0xb2, 0x34, 0x7a, 0x1b, 0xb8, 0x29, 0xad, 0x6d, 0xd3, 0xb4, 0x4f, 0x32, 0x0e, - 0x4e, 0xd7, 0xb3, 0x98, 0x12, 0x37, 0xd4, 0x5c, 0x19, 0xbd, 0x26, 0xaa, 0xbb, 0x5b, 0x7d, 0x92, 0xd3, 0xb7, 0xd3, - 0x2e, 0xfa, 0x6e, 0xf6, 0x2b, 0xaa, 0xc4, 0x24, 0x46, 0x6d, 0x58, 0xe9, 0x7e, 0x8d, 0x27, 0x23, 0x14, 0xbc, 0x15, - 0xaf, 0x1b, 0x88, 0x7b, 0xd1, 0x9d, 0xba, 0x9c, 0x08, 0xd2, 0xf9, 0x9b, 0x81, 0xfd, 0xf8, 0x94, 0xc1, 0x0a, 0xf1, - 0xc8, 0xfa, 0x4e, 0x5b, 0x87, 0x86, 0x34, 0xed, 0x92, 0xcf, 0xfd, 0x49, 0xda, 0xd7, 0x25, 0xc9, 0xe3, 0x22, 0xdf, - 0x9e, 0xdd, 0x53, 0x30, 0x15, 0xe0, 0x2c, 0x5a, 0xcf, 0x41, 0xb3, 0x0d, 0xa4, 0x3a, 0x7b, 0x70, 0xc8, 0x9e, 0x4e, - 0x6b, 0xa7, 0xeb, 0xa3, 0x55, 0xd5, 0xc6, 0x45, 0x89, 0x21, 0x81, 0x5f, 0xb1, 0x29, 0x01, 0xe4, 0x40, 0xe4, 0xd1, - 0x6b, 0xe3, 0x4b, 0xe1, 0xfa, 0xf5, 0x12, 0x7d, 0x82, 0x59, 0xb9, 0xfa, 0x07, 0x0d, 0xa9, 0xa4, 0xd5, 0x80, 0x90, - 0x91, 0xfa, 0x8c, 0xf2, 0xcc, 0x0a, 0xee, 0x97, 0xce, 0x17, 0x31, 0x3a, 0x3c, 0xfd, 0x6e, 0x3f, 0x34, 0xf6, 0x2d, - 0x94, 0x17, 0x65, 0xa5, 0x32, 0x73, 0x94, 0x13, 0x80, 0x24, 0x96, 0x3c, 0x25, 0xd2, 0xc6, 0xb7, 0xad, 0x2d, 0x11, - 0xc1, 0x37, 0x7c, 0x88, 0x77, 0xee, 0x05, 0xc7, 0x26, 0x21, 0x81, 0x0a, 0xed, 0x76, 0x01, 0x14, 0x54, 0x90, 0x89, - 0x23, 0xc9, 0xd5, 0xd1, 0x20, 0xb1, 0x3f, 0x56, 0x36, 0x1d, 0x3c, 0x22, 0x92, 0xb5, 0xcd, 0x06, 0xd0, 0x91, 0xc6, - 0xab, 0x4a, 0x92, 0x83, 0x08, 0x4b, 0x00, 0x3a, 0x56, 0xfe, 0x49, 0x4a, 0x5c, 0x4e, 0xd0, 0x85, 0x41, 0xc1, 0x5d, - 0x1a, 0xc6, 0xcd, 0x26, 0xb9, 0xb0, 0x52, 0x01, 0xfd, 0x78, 0xe8, 0xc7, 0x63, 0x0f, 0x45, 0x0a, 0x82, 0x56, 0x88, - 0x87, 0x9c, 0xd2, 0x81, 0x22, 0xfa, 0xa5, 0xfe, 0x71, 0x9b, 0x37, 0xbf, 0x26, 0xe6, 0x46, 0x89, 0x8a, 0xe6, 0x3c, - 0xa6, 0x52, 0xd4, 0xc7, 0x88, 0xc1, 0x3f, 0x66, 0xec, 0xd0, 0x61, 0xa2, 0x92, 0x5e, 0xaa, 0x54, 0xac, 0x83, 0x75, - 0x26, 0x95, 0x02, 0xed, 0xd4, 0xf8, 0xe2, 0x9b, 0x48, 0x12, 0xbc, 0x13, 0xb3, 0xce, 0x20, 0x85, 0x97, 0x2a, 0xac, - 0x95, 0xe8, 0x97, 0x2d, 0x0a, 0xa2, 0xc4, 0x35, 0xb4, 0x0e, 0x69, 0x42, 0x11, 0xec, 0x09, 0x1d, 0x94, 0x68, 0xf9, - 0x87, 0xb6, 0xca, 0x48, 0x82, 0x72, 0xdf, 0xf3, 0xc1, 0xbb, 0xcb, 0x80, 0xf4, 0xf0, 0x51, 0x0f, 0x29, 0x24, 0x16, - 0x3e, 0x61, 0xcb, 0x01, 0x5d, 0xb7, 0x41, 0x52, 0x00, 0xef, 0xaa, 0x62, 0x79, 0xd9, 0x2c, 0x88, 0xbb, 0x93, 0x35, - 0x35, 0x63, 0xbf, 0x4c, 0x60, 0xa7, 0x82, 0xa3, 0xd5, 0xb6, 0x09, 0x6b, 0xa9, 0x96, 0x24, 0xa3, 0x63, 0x81, 0x59, - 0x02, 0x89, 0x10, 0xe9, 0xfe, 0x58, 0x9c, 0x03, 0x31, 0xaf, 0x93, 0xcc, 0x80, 0xf3, 0xd4, 0x2a, 0x47, 0x13, 0x28, - 0x1c, 0xc7, 0x72, 0xbe, 0x26, 0x29, 0xc9, 0x13, 0x0e, 0xb0, 0x1a, 0xaf, 0xb0, 0x8e, 0x82, 0xfb, 0xb8, 0xa6, 0xa4, - 0xcc, 0xee, 0x7f, 0x99, 0xd2, 0xc4, 0x60, 0x57, 0xa2, 0x03, 0x12, 0x40, 0x4a, 0xb3, 0xd4, 0x62, 0xf0, 0x79, 0x44, - 0x3c, 0x16, 0x82, 0x89, 0x88, 0x44, 0xe1, 0x2b, 0x5d, 0xcb, 0xcf, 0xbc, 0x04, 0x84, 0xca, 0x4c, 0x83, 0xce, 0x92, - 0xd7, 0xaa, 0xa4, 0x86, 0xf6, 0x1b, 0xed, 0x46, 0x35, 0x2b, 0x9f, 0x14, 0x3e, 0x64, 0x1d, 0xb9, 0x7f, 0x1a, 0x98, - 0x64, 0xbd, 0xc9, 0x29, 0x95, 0x76, 0x96, 0xaf, 0xfe, 0xf5, 0x05, 0x8a, 0x8d, 0xaa, 0xa3, 0xe9, 0xb6, 0x3e, 0xda, - 0x10, 0x75, 0xf6, 0x11, 0x71, 0xc0, 0x13, 0x56, 0x33, 0x97, 0x5f, 0x65, 0xf8, 0xe1, 0x32, 0x39, 0x20, 0x45, 0x73, - 0x66, 0xa2, 0x6b, 0xfa, 0xef, 0x22, 0x39, 0x70, 0x89, 0xad, 0xc0, 0x14, 0x50, 0x46, 0x15, 0x63, 0x64, 0x39, 0x90, - 0xc4, 0x52, 0xc9, 0xe5, 0x7c, 0x84, 0x16, 0x59, 0x57, 0x4e, 0x19, 0x0a, 0x95, 0xd3, 0xc8, 0x1c, 0x36, 0x29, 0x8e, - 0x61, 0x5e, 0x96, 0xea, 0x79, 0x86, 0x90, 0x26, 0xdd, 0xd5, 0xa7, 0x88, 0x42, 0xcd, 0xaa, 0x7d, 0x17, 0xa6, 0xbe, - 0x08, 0x57, 0x85, 0x01, 0xf2, 0xfc, 0x61, 0x2d, 0xb2, 0xce, 0xa4, 0xf1, 0x62, 0x67, 0xbc, 0xa0, 0xb2, 0x61, 0x24, - 0x59, 0x96, 0x38, 0x28, 0x41, 0xe0, 0x94, 0x90, 0xc6, 0x3e, 0x71, 0xb8, 0x2d, 0x3f, 0x1e, 0x33, 0xb7, 0xe9, 0x50, - 0x46, 0x31, 0xe2, 0xea, 0x49, 0x95, 0x75, 0x0d, 0xe7, 0x21, 0xe6, 0x0f, 0x2f, 0x8b, 0xda, 0x6f, 0xba, 0xd2, 0xe8, - 0xc6, 0xa1, 0xb3, 0x02, 0x6d, 0x4f, 0x27, 0x73, 0x3a, 0x7d, 0x11, 0x57, 0x75, 0x52, 0x10, 0x50, 0x04, 0xc2, 0x1e, - 0x8f, 0xfe, 0x51, 0x1a, 0xed, 0x1f, 0x01, 0x4b, 0xd6, 0x31, 0xd8, 0x93, 0x6a, 0x8f, 0x09, 0x49, 0xcb, 0xdb, 0x1f, - 0x81, 0xb9, 0x52, 0x25, 0xd1, 0x43, 0xf0, 0xe1, 0x08, 0xa5, 0x05, 0x85, 0x64, 0xd3, 0x93, 0x6e, 0x43, 0xa6, 0x09, - 0x98, 0xe8, 0x71, 0x90, 0x67, 0xc3, 0x1b, 0x17, 0x55, 0x88, 0x3e, 0x3e, 0x30, 0xd9, 0xa5, 0x63, 0xb4, 0x69, 0x95, - 0x6d, 0xf6, 0x9f, 0xa1, 0xd8, 0xef, 0xf7, 0xd7, 0xcc, 0xa1, 0x48, 0xef, 0x3a, 0x23, 0x37, 0xb1, 0xe0, 0xfc, 0x14, - 0x25, 0xc6, 0xb3, 0xb6, 0x34, 0xa4, 0xe5, 0x10, 0x45, 0x48, 0x0e, 0x1d, 0x82, 0xbe, 0x0c, 0x19, 0x56, 0x57, 0xe8, - 0xf0, 0x2d, 0xfd, 0x82, 0x43, 0x26, 0x29, 0x39, 0xd2, 0x64, 0xbf, 0x97, 0xc4, 0x64, 0x57, 0xba, 0xa8, 0x40, 0x87, - 0xd5, 0xb4, 0x13, 0x43, 0xb2, 0xd5, 0xbb, 0xda, 0x66, 0xa9, 0xe5, 0x08, 0xee, 0xce, 0x03, 0xc9, 0x1f, 0x81, 0xaa, - 0xe7, 0xd1, 0x19, 0x47, 0x0b, 0x44, 0x9d, 0x4b, 0x92, 0xdb, 0x49, 0x31, 0xc8, 0x26, 0x52, 0x28, 0x90, 0xae, 0x10, - 0x8d, 0x61, 0x31, 0x6d, 0x3f, 0x08, 0x1c, 0x2c, 0x75, 0x9b, 0x64, 0xa4, 0xcf, 0x9d, 0xdd, 0x26, 0xc5, 0x23, 0x54, - 0x1e, 0xb5, 0xee, 0xbb, 0x69, 0x49, 0x90, 0xea, 0x24, 0x4f, 0x10, 0xb4, 0x67, 0x63, 0xef, 0x98, 0x80, 0xf9, 0xde, - 0x54, 0xcc, 0xaf, 0xa7, 0x6e, 0xc2, 0xc2, 0xee, 0x43, 0x8a, 0x5b, 0x66, 0x76, 0xf2, 0x9d, 0xf9, 0x1c, 0x69, 0xce, - 0x0c, 0x9d, 0xd4, 0x29, 0x24, 0xb3, 0xb1, 0xb7, 0xf4, 0x17, 0xa4, 0x79, 0x77, 0x2f, 0x3a, 0x94, 0x4d, 0xf8, 0x3d, - 0x21, 0xb8, 0x1e, 0x91, 0xc3, 0x08, 0xbe, 0xea, 0x90, 0xd8, 0xcd, 0x46, 0x2b, 0x52, 0x68, 0xed, 0x68, 0x88, 0x4b, - 0xb6, 0x7b, 0x37, 0x0b, 0x00, 0x88, 0x90, 0xd3, 0xef, 0x95, 0x86, 0x8c, 0x2d, 0xfd, 0xe2, 0x8c, 0xad, 0x14, 0xe8, - 0x59, 0x2d, 0xe2, 0x09, 0xaf, 0x09, 0x29, 0x41, 0x67, 0x85, 0x63, 0x86, 0xea, 0x43, 0xd0, 0xce, 0x1b, 0x4a, 0xb6, - 0x74, 0x30, 0x9c, 0xb8, 0x86, 0x92, 0x2d, 0x8c, 0x18, 0x1f, 0xba, 0xd9, 0x7b, 0x5a, 0x24, 0x43, 0xc1, 0x8f, 0x54, - 0x11, 0xe5, 0x22, 0x6a, 0x46, 0x68, 0x6c, 0x69, 0x30, 0x8a, 0x36, 0x9c, 0x9b, 0x77, 0x57, 0x04, 0x71, 0xd9, 0x27, - 0x56, 0x52, 0xc4, 0x8f, 0x83, 0xc4, 0xe9, 0x57, 0xab, 0x11, 0xf4, 0x32, 0x67, 0xa1, 0x34, 0xbe, 0x29, 0x85, 0x79, - 0xe4, 0x81, 0xc1, 0xb2, 0xb5, 0x0d, 0xd3, 0x3e, 0x69, 0x59, 0x3d, 0x5f, 0x55, 0x03, 0xdb, 0x20, 0x1c, 0xb5, 0x2c, - 0x1d, 0xeb, 0xe7, 0xb3, 0x8a, 0x5e, 0x37, 0xf2, 0xaf, 0x56, 0xac, 0xc5, 0x17, 0x20, 0x3b, 0x63, 0x98, 0xcd, 0x98, - 0x34, 0x2a, 0xa0, 0x16, 0x92, 0x29, 0x6b, 0x8b, 0x8a, 0xa7, 0x49, 0x09, 0x1b, 0x1a, 0x70, 0x34, 0x2d, 0x0b, 0xe9, - 0xc5, 0xeb, 0xa1, 0x7d, 0x70, 0xd6, 0xe1, 0x73, 0xcb, 0xd2, 0x23, 0x58, 0x0d, 0x78, 0x8d, 0x88, 0x12, 0x44, 0x2a, - 0xa4, 0x44, 0x85, 0x94, 0x43, 0x15, 0xd3, 0x41, 0xa7, 0x5c, 0x53, 0x67, 0xa5, 0x95, 0x79, 0x97, 0xc6, 0xf8, 0xd3, - 0x22, 0xa4, 0xb0, 0xae, 0x80, 0xc1, 0xa2, 0xf8, 0x0d, 0x04, 0xc0, 0x8b, 0x35, 0xd3, 0x33, 0x31, 0x30, 0xc7, 0x4b, - 0x5a, 0xde, 0x4b, 0x13, 0x66, 0xb1, 0x74, 0x63, 0x53, 0xa8, 0x8f, 0x8c, 0x42, 0x7a, 0xce, 0x25, 0x20, 0xea, 0xa6, - 0xc7, 0x97, 0xd9, 0x7a, 0xcf, 0xb8, 0x24, 0xff, 0x75, 0xbe, 0xdd, 0x9b, 0x15, 0x0e, 0xcf, 0x3d, 0x72, 0x38, 0x70, - 0x06, 0xa9, 0x48, 0x63, 0x06, 0x39, 0x05, 0x2f, 0x7a, 0x85, 0x19, 0x7f, 0xa4, 0x2b, 0x59, 0x22, 0x0a, 0x4f, 0x00, - 0x7f, 0x57, 0x2d, 0x42, 0xb7, 0x07, 0x84, 0xef, 0x42, 0xc6, 0x67, 0x35, 0x4c, 0xf2, 0x47, 0x18, 0x23, 0xf1, 0xe5, - 0x7b, 0x70, 0x53, 0x99, 0x8c, 0x6f, 0x7e, 0xcb, 0x92, 0x40, 0x65, 0x19, 0x4c, 0x53, 0x83, 0x92, 0x3a, 0xfb, 0x04, - 0x79, 0xe4, 0xbc, 0xaa, 0x1b, 0xa6, 0x4e, 0x9a, 0x49, 0x1e, 0xf4, 0x41, 0xa6, 0x08, 0x44, 0xa7, 0x8b, 0x61, 0xe4, - 0x81, 0x10, 0x00, 0xcf, 0x11, 0x88, 0xb4, 0x04, 0xce, 0x00, 0x8e, 0xe9, 0x9c, 0x0c, 0x1a, 0x91, 0xd1, 0xf8, 0xa9, - 0x51, 0x84, 0x8a, 0x54, 0xae, 0x63, 0xc7, 0xe1, 0x68, 0x89, 0x68, 0x94, 0xdf, 0x40, 0x31, 0x05, 0xff, 0xd2, 0xb8, - 0xb5, 0x69, 0xd7, 0x7b, 0xe2, 0x19, 0xc6, 0x96, 0xa6, 0x99, 0xa6, 0x45, 0xd1, 0x48, 0xdd, 0x67, 0x0c, 0x57, 0x2c, - 0x41, 0x9b, 0x84, 0xa2, 0x0c, 0xa3, 0x3a, 0xa6, 0x4a, 0x71, 0x0b, 0x47, 0x68, 0x54, 0xbe, 0xb5, 0x08, 0xed, 0xfd, - 0xc4, 0xf1, 0xe9, 0x32, 0x42, 0x5a, 0x9f, 0x1f, 0xbd, 0x2c, 0x30, 0xfd, 0x32, 0x9c, 0xa1, 0xaf, 0x44, 0x44, 0x34, - 0xad, 0x02, 0x3b, 0x1c, 0xe8, 0x6a, 0xc3, 0x4b, 0x73, 0x17, 0xb7, 0x35, 0xd1, 0x83, 0x33, 0xf6, 0x54, 0x86, 0xf4, - 0xed, 0x99, 0xc8, 0xba, 0x28, 0xea, 0xf6, 0xb7, 0x93, 0xaf, 0xe1, 0xb1, 0xf9, 0x78, 0x4c, 0xea, 0x14, 0xe5, 0x6b, - 0xa2, 0xd6, 0xea, 0x5a, 0x1f, 0x82, 0x99, 0x79, 0xf4, 0x5c, 0x31, 0x19, 0xe3, 0xd4, 0x8c, 0x8c, 0xac, 0xef, 0x59, - 0xe2, 0xc5, 0x36, 0xf1, 0x3b, 0x85, 0xe4, 0x47, 0xc7, 0x19, 0xd2, 0x88, 0x82, 0xa0, 0xca, 0xfc, 0x8a, 0x42, 0x19, - 0x18, 0xe9, 0xe7, 0xb6, 0xf6, 0x03, 0x72, 0xc5, 0x28, 0x96, 0xf1, 0x6c, 0x33, 0x3e, 0xe5, 0xea, 0x1f, 0x57, 0x34, - 0xc8, 0xb2, 0xb4, 0xdf, 0x89, 0xa7, 0x6d, 0x1e, 0xda, 0x66, 0x5e, 0xd9, 0x24, 0x02, 0x78, 0x95, 0x26, 0xd9, 0xf6, - 0x70, 0xaa, 0xf5, 0x47, 0xe0, 0x57, 0x5e, 0x41, 0x80, 0xcb, 0x49, 0x58, 0xb9, 0x8b, 0x02, 0x45, 0xb5, 0x2d, 0xb8, - 0x7c, 0xb0, 0x4b, 0x9f, 0x47, 0xb1, 0x44, 0x36, 0xf7, 0xc0, 0x6c, 0x51, 0x44, 0x78, 0x4a, 0xbd, 0xad, 0x51, 0xef, - 0xdf, 0x4d, 0x11, 0x1f, 0x71, 0x24, 0x77, 0x27, 0xab, 0x6e, 0x1c, 0x95, 0x47, 0x5a, 0x28, 0xfd, 0x00, 0x2f, 0x2e, - 0x9a, 0x4b, 0x97, 0x8a, 0xc7, 0x5e, 0x0a, 0xd9, 0x46, 0xc2, 0xdc, 0x22, 0x4e, 0x6d, 0xfb, 0x6a, 0xf2, 0xfd, 0x5c, - 0xd0, 0x24, 0x31, 0xeb, 0x4b, 0x97, 0xd6, 0x86, 0x4f, 0x32, 0xbd, 0xb3, 0xcf, 0x7a, 0xf6, 0x64, 0xce, 0xe4, 0xc6, - 0xe0, 0x39, 0xa8, 0xfa, 0xbd, 0xfd, 0x94, 0xba, 0x6e, 0x78, 0x94, 0xc4, 0x94, 0x26, 0x7f, 0xe1, 0x4e, 0x92, 0xe9, - 0xae, 0x33, 0x1f, 0x25, 0xdd, 0x37, 0x1c, 0xce, 0xde, 0xdf, 0xc6, 0x5d, 0x81, 0x54, 0x16, 0x1f, 0x43, 0x24, 0x3c, - 0xf1, 0xeb, 0xad, 0x31, 0xe0, 0xa1, 0x40, 0xc0, 0x83, 0x4a, 0xba, 0x59, 0xac, 0x15, 0x1d, 0xe7, 0x74, 0xff, 0x66, - 0x13, 0xce, 0x0a, 0xc3, 0x93, 0x1c, 0x27, 0xb1, 0xcb, 0xab, 0xdc, 0x4e, 0xa5, 0xad, 0x7e, 0x9a, 0x6c, 0xc0, 0x5b, - 0x68, 0x43, 0xd1, 0x72, 0x7c, 0x86, 0x5d, 0xf5, 0x43, 0x53, 0xf9, 0x27, 0xa1, 0x14, 0xc7, 0x36, 0x0d, 0x63, 0x0d, - 0xb9, 0xfe, 0xbe, 0x1d, 0xc8, 0xb4, 0x7a, 0xf3, 0xcf, 0xd9, 0xf7, 0xea, 0xed, 0x58, 0x37, 0x3c, 0x91, 0xde, 0x0e, - 0xfe, 0x7a, 0x48, 0x8a, 0x62, 0x79, 0x7b, 0x55, 0xfd, 0xd7, 0x16, 0xbf, 0x7e, 0xac, 0x2e, 0x6b, 0x2c, 0x96, 0xf9, - 0xf8, 0xb2, 0x1a, 0x4f, 0x2d, 0xef, 0xdf, 0x4e, 0xf5, 0x87, 0x2f, 0x3f, 0xf5, 0x1a, 0xb8, 0x3a, 0x73, 0x9e, 0xa4, - 0x57, 0x14, 0xfb, 0x28, 0x57, 0xc1, 0x4b, 0xf8, 0x20, 0x3f, 0x6d, 0x8f, 0xeb, 0xa7, 0xfb, 0x65, 0x3d, 0x1f, 0x68, - 0xc9, 0xe3, 0x66, 0xeb, 0xed, 0x8d, 0xe6, 0xd5, 0x5e, 0xa6, 0x75, 0x0e, 0x1b, 0x13, 0x7c, 0x28, 0xb7, 0x8a, 0x82, - 0xf1, 0x26, 0x20, 0xf9, 0x03, 0x31, 0xaf, 0xde, 0x36, 0xbb, 0xbe, 0xfc, 0x58, 0xac, 0x59, 0x5e, 0x61, 0x01, 0x96, - 0x35, 0x1a, 0x9a, 0xb3, 0x01, 0x67, 0x49, 0x7a, 0xaf, 0xae, 0xce, 0x9c, 0xe0, 0x9c, 0xc9, 0xed, 0x4d, 0xfc, 0xc7, - 0x4f, 0x53, 0x6d, 0xce, 0x32, 0xcb, 0xe1, 0x2f, 0x8e, 0x62, 0x67, 0x71, 0xd8, 0x6e, 0xc0, 0xfa, 0xaa, 0xe3, 0x2d, - 0xaa, 0xca, 0x56, 0xe7, 0x62, 0x26, 0x4b, 0x44, 0xe5, 0x76, 0xd2, 0xe1, 0x40, 0x37, 0x73, 0x6b, 0x1f, 0xf8, 0xdf, - 0x63, 0x17, 0x2a, 0x9d, 0xc2, 0x3f, 0x97, 0x47, 0x05, 0x17, 0x72, 0x9b, 0x6c, 0x2e, 0xb9, 0x91, 0xee, 0x58, 0x9f, - 0xbc, 0xb1, 0x33, 0x13, 0x46, 0x33, 0x11, 0x56, 0xd8, 0x0f, 0x47, 0xc0, 0x3d, 0x2e, 0x18, 0x7b, 0x2e, 0xfc, 0xd6, - 0xc5, 0x96, 0xbd, 0x77, 0x7d, 0x36, 0xf9, 0x28, 0x64, 0x01, 0xfb, 0x0d, 0x81, 0x1d, 0x68, 0xdc, 0x1c, 0x47, 0x3b, - 0x24, 0xeb, 0x08, 0xe6, 0xa2, 0x5b, 0xc9, 0x56, 0x06, 0xbf, 0x55, 0xb8, 0x9f, 0xdc, 0x05, 0x20, 0x69, 0xf5, 0xee, - 0xc7, 0x5e, 0xdf, 0x7f, 0xd5, 0xcf, 0x5b, 0xbd, 0xca, 0xd8, 0x3e, 0x19, 0xf8, 0x48, 0x83, 0xae, 0x77, 0xc3, 0xcb, - 0x95, 0x6a, 0xa2, 0xcd, 0xb8, 0x59, 0x5e, 0xc9, 0xe8, 0x0d, 0xe9, 0xda, 0xee, 0x3c, 0x54, 0x27, 0x37, 0x5e, 0xb6, - 0x4c, 0x06, 0x78, 0x55, 0x67, 0x33, 0xf9, 0x05, 0x62, 0x7d, 0x7d, 0xd3, 0xc5, 0x16, 0x9a, 0xd9, 0x23, 0xd4, 0x09, - 0x7f, 0xbd, 0x8c, 0xa2, 0x52, 0x24, 0x7a, 0x69, 0x76, 0xf2, 0x78, 0xde, 0x1b, 0x6f, 0x0c, 0x59, 0x4e, 0xa6, 0x87, - 0x20, 0xd1, 0x47, 0xf4, 0x92, 0xc5, 0xf5, 0x0e, 0x83, 0xcf, 0x73, 0x94, 0x66, 0xd9, 0xb0, 0xf2, 0x45, 0xfc, 0x8c, - 0x8e, 0x25, 0x1b, 0xf9, 0xb8, 0x85, 0xad, 0x64, 0xd9, 0xe6, 0x7e, 0xd7, 0x3b, 0x5b, 0xd5, 0xcb, 0x37, 0x1b, 0xcb, - 0xee, 0x58, 0x79, 0x7e, 0x51, 0xa9, 0x59, 0x0a, 0x3b, 0x7c, 0x8e, 0x47, 0x6b, 0xc1, 0x92, 0xdb, 0xbc, 0x1c, 0x21, - 0xbd, 0x97, 0xa4, 0xbf, 0x76, 0xb2, 0x14, 0xc9, 0x87, 0xb2, 0xac, 0xf2, 0x1f, 0x92, 0x24, 0x62, 0x55, 0x14, 0xa6, - 0x1c, 0x64, 0x9e, 0x7c, 0x28, 0x37, 0xbd, 0x78, 0xe7, 0xab, 0xc2, 0x4f, 0x75, 0xd8, 0x4b, 0xeb, 0x37, 0x20, 0x0a, - 0x66, 0x01, 0x7c, 0x21, 0xee, 0x45, 0xb3, 0xeb, 0x99, 0x3c, 0x06, 0x09, 0xf4, 0x39, 0xf0, 0x0f, 0x3e, 0xfa, 0x01, - 0x05, 0x9f, 0x8b, 0x0e, 0x8c, 0x00, 0x00, 0x72, 0xc7, 0xa1, 0xec, 0xf9, 0xbd, 0x29, 0x57, 0x28, 0xab, 0xa1, 0x5a, - 0x9b, 0xfc, 0x49, 0x73, 0x1d, 0x09, 0xce, 0x7b, 0xa4, 0xc8, 0x3f, 0xf1, 0x7a, 0x36, 0x69, 0x1a, 0x62, 0x17, 0x15, - 0x0a, 0x4c, 0x54, 0xe9, 0x24, 0x4f, 0x95, 0x2c, 0xb5, 0x2a, 0x59, 0xa3, 0x07, 0x1f, 0x76, 0xeb, 0xb5, 0x79, 0x55, - 0x1e, 0x92, 0x9f, 0x25, 0x10, 0xa6, 0x7f, 0xa5, 0x65, 0xb9, 0xbd, 0xa1, 0x4a, 0xe5, 0x20, 0x0f, 0xc1, 0x23, 0xab, - 0xfc, 0xba, 0x7c, 0xe2, 0xc1, 0x8d, 0x28, 0x7f, 0xa5, 0xcf, 0x21, 0xa8, 0x04, 0xfe, 0x5b, 0x36, 0x9b, 0xbe, 0xf2, - 0xf9, 0xf6, 0xc7, 0x73, 0x41, 0x04, 0x4f, 0x97, 0xec, 0x0d, 0xe6, 0x6a, 0x6f, 0x50, 0x9a, 0xac, 0x7b, 0x67, 0x43, - 0xcc, 0x05, 0xcb, 0x1f, 0xd1, 0xe6, 0xdf, 0x27, 0xdf, 0xec, 0x85, 0x96, 0x40, 0xd3, 0x7a, 0x0d, 0xd0, 0x32, 0xcf, - 0x3b, 0x32, 0xd8, 0x23, 0xef, 0x52, 0x1e, 0x56, 0x1c, 0x57, 0xbd, 0xe4, 0xef, 0xe1, 0x2d, 0xdc, 0x69, 0x1b, 0x29, - 0x12, 0xf5, 0x3a, 0xab, 0x14, 0x81, 0xf3, 0x1e, 0xbe, 0x9a, 0xf3, 0x74, 0xaa, 0x21, 0xf1, 0x4b, 0xc7, 0xdc, 0x86, - 0x0a, 0xeb, 0x65, 0x31, 0xbb, 0xbf, 0x7b, 0x83, 0xdc, 0x12, 0x9b, 0xdf, 0x3b, 0x6f, 0x01, 0x48, 0xb5, 0xfa, 0x94, - 0xb2, 0x23, 0xff, 0x51, 0xaa, 0x1d, 0xf0, 0x2a, 0x1f, 0xa8, 0xa0, 0x1a, 0x33, 0xa4, 0xb4, 0xe2, 0xaa, 0x13, 0x49, - 0xd0, 0xdb, 0xa2, 0x64, 0xb0, 0x91, 0x29, 0xec, 0x43, 0x5e, 0x94, 0xe8, 0xfb, 0x52, 0xc9, 0x72, 0x0b, 0xaf, 0x1c, - 0xcb, 0x5a, 0xee, 0x1b, 0x25, 0x7e, 0x98, 0x20, 0x3f, 0x0d, 0x2f, 0x32, 0xf2, 0xe4, 0x6e, 0x71, 0x24, 0xf0, 0xf1, - 0x49, 0x26, 0x82, 0x7d, 0x03, 0x79, 0x92, 0x5c, 0x3c, 0x89, 0x44, 0x65, 0x62, 0xbb, 0xe4, 0xe8, 0xe8, 0xde, 0x24, - 0xa9, 0xd7, 0xd2, 0xe8, 0x50, 0xe8, 0xb8, 0x8d, 0x42, 0x6d, 0x1d, 0xcf, 0xd9, 0x94, 0x8d, 0x47, 0x77, 0xc9, 0x76, - 0x11, 0xb7, 0x87, 0xd2, 0x08, 0x95, 0xd4, 0x26, 0xe8, 0x58, 0x9a, 0x06, 0x91, 0xc7, 0x03, 0x5b, 0x84, 0x88, 0x3e, - 0x9b, 0x4a, 0x67, 0x39, 0xc4, 0x6a, 0x3b, 0x1f, 0x58, 0x8e, 0x2d, 0x87, 0x2c, 0x09, 0x28, 0x9a, 0x95, 0x22, 0xe1, - 0x60, 0xe0, 0x38, 0x9a, 0xa3, 0x4a, 0x81, 0x31, 0x73, 0x35, 0x87, 0x9d, 0xaf, 0x33, 0x72, 0x2c, 0x8d, 0x34, 0x1b, - 0xbe, 0x2e, 0x45, 0x77, 0x6b, 0xeb, 0x63, 0x6d, 0x44, 0x32, 0xb2, 0xc9, 0x9e, 0xcb, 0xdc, 0x13, 0x56, 0xe9, 0xa9, - 0xdc, 0x8d, 0x95, 0x94, 0x15, 0xe7, 0xf9, 0x64, 0xb4, 0xdb, 0x90, 0x45, 0xab, 0x06, 0xab, 0xf1, 0x25, 0xd3, 0xee, - 0xb3, 0x2f, 0xb5, 0x0d, 0xda, 0x6a, 0x52, 0x17, 0x68, 0xce, 0xc3, 0xba, 0xc2, 0xdf, 0xc8, 0x13, 0x38, 0x93, 0x9e, - 0xbd, 0xea, 0x2e, 0x3f, 0xaa, 0x51, 0xda, 0xee, 0x2d, 0x5c, 0x31, 0x8f, 0xb9, 0x0a, 0xc1, 0xae, 0xd5, 0x44, 0x4f, - 0x66, 0x90, 0xc3, 0xf7, 0x04, 0x5c, 0x8e, 0xfc, 0x66, 0x80, 0xed, 0xd9, 0x38, 0x97, 0xb4, 0x53, 0xde, 0x27, 0x2d, - 0x45, 0x7e, 0xc6, 0x4d, 0xd6, 0x9c, 0x28, 0xff, 0x97, 0x42, 0xac, 0xb2, 0xe0, 0x0b, 0xeb, 0x23, 0xeb, 0xef, 0xd1, - 0xa9, 0x4e, 0x79, 0xeb, 0xd5, 0x8c, 0x7e, 0x2b, 0x2a, 0x4c, 0xd8, 0x3b, 0xa0, 0xc1, 0x64, 0xc7, 0x5a, 0x0d, 0xed, - 0x6e, 0xd9, 0xd1, 0xa2, 0x38, 0x6d, 0xcf, 0x68, 0x55, 0x7b, 0x21, 0x23, 0x1e, 0x7e, 0x6e, 0x34, 0x12, 0x8b, 0xa4, - 0x58, 0x40, 0xe7, 0x2b, 0xea, 0xfd, 0xb5, 0x9c, 0xd3, 0x93, 0xd6, 0x64, 0xf0, 0xa8, 0x33, 0xa7, 0xce, 0x55, 0x9f, - 0xbd, 0xde, 0xd5, 0xa1, 0xf2, 0x27, 0xe2, 0xfa, 0x13, 0x34, 0x55, 0x55, 0x73, 0xd7, 0x4a, 0x90, 0x9a, 0xb2, 0x6c, - 0xe2, 0xc8, 0xa7, 0xc9, 0xf7, 0xf9, 0x4c, 0x87, 0xec, 0xc3, 0x75, 0xa8, 0x8a, 0x17, 0x23, 0xb1, 0x05, 0x21, 0xb9, - 0x70, 0x82, 0x65, 0x51, 0xdf, 0x63, 0x29, 0x8a, 0xa3, 0x84, 0x92, 0xe1, 0x05, 0x8a, 0xc0, 0x51, 0x0b, 0x0c, 0x94, - 0xe4, 0x44, 0x1d, 0x1a, 0xc9, 0xce, 0xd3, 0xc1, 0x8b, 0x4f, 0x6c, 0xf2, 0x2d, 0xa1, 0x43, 0x3a, 0x43, 0x79, 0x05, - 0xdf, 0x8b, 0x77, 0x45, 0x9e, 0x30, 0xd5, 0xd2, 0x31, 0xcf, 0xbd, 0x66, 0xfa, 0xd8, 0x35, 0x78, 0x21, 0xba, 0x0e, - 0x97, 0x67, 0x48, 0x19, 0xab, 0x48, 0xd5, 0x34, 0xed, 0x87, 0x77, 0x87, 0x28, 0x49, 0x55, 0xb6, 0xdb, 0x7b, 0xa7, - 0x2d, 0x44, 0x09, 0xa4, 0xc9, 0xba, 0x0d, 0x8c, 0x64, 0x7a, 0xce, 0xb1, 0x2a, 0x45, 0x31, 0x86, 0x19, 0x82, 0x5c, - 0xe8, 0xb0, 0x15, 0x92, 0x4a, 0x3f, 0xca, 0x22, 0xe8, 0x26, 0x9d, 0xf1, 0x60, 0x96, 0x81, 0x51, 0xe1, 0xf8, 0xa4, - 0xde, 0x85, 0x77, 0x6d, 0x73, 0x72, 0x68, 0x15, 0x69, 0x02, 0x75, 0x2c, 0x90, 0x1e, 0xe5, 0x6f, 0xbe, 0x7b, 0x8c, - 0x6b, 0xd3, 0xaf, 0x8b, 0x55, 0x21, 0x33, 0x76, 0x02, 0x07, 0x90, 0x69, 0xbb, 0xf9, 0x9d, 0x7d, 0xa3, 0x24, 0x05, - 0x23, 0xad, 0xe7, 0x9e, 0x19, 0x5c, 0x8c, 0xc9, 0x42, 0xb4, 0xad, 0x76, 0xd3, 0x47, 0x07, 0x6d, 0x64, 0xe5, 0x35, - 0x00, 0xab, 0x24, 0x2d, 0x39, 0x1b, 0xc0, 0xc2, 0x6a, 0xc7, 0x36, 0x4c, 0x2f, 0x0d, 0x80, 0x4d, 0xbf, 0x05, 0x58, - 0xc0, 0x0b, 0xa2, 0xfd, 0xcc, 0xbc, 0xd2, 0x2c, 0xc2, 0x03, 0xef, 0x13, 0x80, 0x0d, 0xb1, 0x52, 0x51, 0x2d, 0x16, - 0x0b, 0xaf, 0x14, 0x0b, 0x5a, 0xd3, 0xcc, 0x96, 0x4e, 0xc0, 0xfa, 0x72, 0x47, 0xa1, 0x3a, 0xe5, 0xaf, 0xa8, 0xc4, - 0x9a, 0x43, 0x55, 0xf1, 0xd1, 0xfd, 0x66, 0x7d, 0x9a, 0x62, 0x91, 0xda, 0xa7, 0xd6, 0x93, 0x0c, 0xf0, 0x76, 0x8b, - 0xe7, 0xc0, 0x4b, 0xcb, 0xa2, 0xf7, 0xbd, 0x9d, 0xb5, 0x64, 0x51, 0xdd, 0x72, 0x7c, 0xd5, 0xca, 0xe4, 0x74, 0x25, - 0x97, 0x82, 0x32, 0x14, 0xf9, 0x9e, 0x27, 0x49, 0x21, 0xae, 0x4f, 0x1f, 0xcc, 0x7c, 0x84, 0x71, 0x65, 0xc6, 0x8b, - 0x6f, 0xc5, 0xc3, 0x8c, 0x27, 0xa5, 0x48, 0x6a, 0x79, 0x51, 0x01, 0xa9, 0xc6, 0x28, 0xbe, 0x20, 0x43, 0xcf, 0xf9, - 0x7a, 0xd4, 0xa7, 0xb8, 0x73, 0xb6, 0x24, 0x0e, 0xa2, 0x70, 0x98, 0x3e, 0x2b, 0x54, 0x08, 0xfe, 0x3b, 0x20, 0x26, - 0x19, 0x82, 0x00, 0x73, 0x22, 0xa1, 0x0e, 0xb2, 0xf0, 0x84, 0x67, 0x7b, 0xc9, 0x68, 0x25, 0xa3, 0x13, 0xa9, 0x32, - 0x11, 0x51, 0xf9, 0xed, 0xca, 0x26, 0x41, 0xb3, 0xec, 0xa5, 0x28, 0xdc, 0x88, 0x25, 0x11, 0x5c, 0x2b, 0x83, 0x9b, - 0x7e, 0x44, 0xa9, 0xee, 0x96, 0x86, 0xeb, 0x8c, 0x65, 0x72, 0xe1, 0x1b, 0x6f, 0xe8, 0xfa, 0xd2, 0xf5, 0x67, 0xe4, - 0xb6, 0xa8, 0xf0, 0xc9, 0x47, 0xf2, 0xc0, 0xb9, 0xbe, 0x9a, 0x66, 0x3a, 0xc7, 0xbc, 0x8b, 0x50, 0x5c, 0x63, 0xb2, - 0xcd, 0x7e, 0xdb, 0x2c, 0xc1, 0xeb, 0xfc, 0x59, 0xb2, 0x9c, 0xa6, 0x02, 0x76, 0x31, 0xf1, 0x8b, 0xa0, 0x44, 0x1b, - 0xae, 0x7a, 0xff, 0xde, 0xd6, 0x7a, 0xf7, 0xd9, 0x07, 0x1c, 0x16, 0xe5, 0x6f, 0xd4, 0xf6, 0x0c, 0x28, 0xa8, 0xe4, - 0xb9, 0xab, 0xd6, 0x80, 0xb1, 0x1d, 0xc3, 0x0f, 0x51, 0x1f, 0xed, 0x1a, 0xa0, 0xae, 0xd9, 0xca, 0x29, 0x06, 0x63, - 0x00, 0x1d, 0xdd, 0xfa, 0xd4, 0x20, 0x75, 0x58, 0xd8, 0x94, 0xd6, 0xdb, 0x58, 0xbc, 0xd0, 0x22, 0xe6, 0x72, 0x95, - 0x2c, 0xad, 0xf9, 0x1a, 0xfe, 0x37, 0xb8, 0xa6, 0xe0, 0x77, 0x94, 0xbf, 0x92, 0x78, 0xd7, 0x9d, 0xd3, 0x0a, 0x89, - 0x82, 0x79, 0x2e, 0xbc, 0x22, 0xc2, 0x4a, 0x9f, 0x10, 0x73, 0xcc, 0x75, 0x99, 0x93, 0x7d, 0xe1, 0xd0, 0x1a, 0xa9, - 0x43, 0xc0, 0xa5, 0xfb, 0x1e, 0x4f, 0x2f, 0xf8, 0x12, 0x5d, 0x1d, 0xdf, 0xcb, 0x3c, 0x9a, 0x01, 0xab, 0xba, 0x6f, - 0x31, 0x09, 0x53, 0x51, 0x46, 0x09, 0x41, 0xdc, 0x54, 0x22, 0x0b, 0x43, 0xcf, 0x1a, 0x57, 0x1f, 0x3b, 0xad, 0xa7, - 0x0c, 0x00, 0x28, 0x25, 0x09, 0xdd, 0x33, 0x94, 0x31, 0xa3, 0x97, 0x56, 0x81, 0x72, 0xcb, 0xd5, 0xc1, 0xcb, 0xce, - 0x3d, 0x86, 0x81, 0x9d, 0xd9, 0x5a, 0x67, 0x1a, 0x07, 0x22, 0xcb, 0x40, 0x80, 0x38, 0xc8, 0xb7, 0xa9, 0xd2, 0x58, - 0x74, 0x03, 0xd4, 0xb5, 0xa8, 0x0f, 0xd2, 0x8e, 0x2c, 0xc4, 0x19, 0x26, 0x3a, 0x06, 0xf6, 0xa7, 0x97, 0x68, 0xa4, - 0xa4, 0x42, 0xe0, 0x95, 0x31, 0xf3, 0x40, 0x22, 0x7b, 0xa0, 0x1d, 0x94, 0x0d, 0x80, 0x24, 0x67, 0x8e, 0x2b, 0x05, - 0x69, 0x2d, 0x23, 0x96, 0xd0, 0xff, 0x13, 0x2b, 0x8d, 0x32, 0x01, 0xf9, 0xc8, 0xa1, 0x4d, 0x49, 0xe3, 0x79, 0x78, - 0x2d, 0x1c, 0x48, 0x3e, 0x4c, 0x7f, 0x9c, 0x05, 0x8d, 0xc8, 0x94, 0xd3, 0xb9, 0x15, 0x6c, 0xe3, 0x8b, 0x3b, 0x4f, - 0x23, 0x51, 0x61, 0xfa, 0xcc, 0x77, 0x96, 0xb7, 0x93, 0x55, 0x84, 0xe5, 0x8f, 0xb9, 0xec, 0xa7, 0xe8, 0x7f, 0xad, - 0xa2, 0x24, 0xc9, 0x06, 0x5f, 0x2a, 0x99, 0x8e, 0xdb, 0xf3, 0x6b, 0xad, 0x8d, 0x96, 0xee, 0x01, 0xce, 0x78, 0x0f, - 0xaa, 0x3b, 0x12, 0x7a, 0xc8, 0x69, 0xcd, 0x53, 0x87, 0xa8, 0xaa, 0xa0, 0x84, 0x44, 0x63, 0x5c, 0xa4, 0xd6, 0x44, - 0xea, 0x9b, 0xc5, 0xd3, 0x00, 0x92, 0x69, 0x0c, 0x2a, 0xaa, 0xdc, 0x57, 0xcf, 0x6c, 0x5e, 0x4c, 0x4f, 0xa4, 0xc9, - 0x74, 0x41, 0x2e, 0x3f, 0xc5, 0xe8, 0x66, 0x4a, 0xc9, 0xb2, 0x58, 0x86, 0xc3, 0x1e, 0x23, 0xcc, 0x01, 0x43, 0x44, - 0xa5, 0xc2, 0x78, 0xc3, 0xa4, 0xbc, 0x9f, 0x94, 0xfc, 0x69, 0x8a, 0xf3, 0x0b, 0x61, 0xf5, 0x61, 0x5b, 0x07, 0xb8, - 0x3a, 0x07, 0xe5, 0x08, 0xb7, 0x96, 0x07, 0xd8, 0xd8, 0x98, 0x47, 0x7b, 0x39, 0x55, 0x25, 0x22, 0xd3, 0xac, 0x3b, - 0x58, 0x52, 0xde, 0xa4, 0xef, 0x0c, 0x99, 0xb0, 0x75, 0x33, 0x14, 0x41, 0x6e, 0x3c, 0xc9, 0xae, 0xb1, 0xd5, 0x07, - 0x81, 0xb3, 0x7a, 0x44, 0x5e, 0xc6, 0xa3, 0xaa, 0xce, 0xaa, 0xed, 0x94, 0xfc, 0x34, 0xbc, 0x4f, 0xae, 0x3b, 0xc9, - 0x09, 0x95, 0xd5, 0x24, 0x2a, 0x0a, 0x8a, 0xc2, 0xf3, 0x24, 0x20, 0x82, 0xe2, 0x8c, 0xfa, 0xa1, 0x8a, 0xfa, 0xa6, - 0xc8, 0xfe, 0x71, 0xea, 0xd5, 0xbe, 0xe5, 0x25, 0x80, 0x24, 0x3f, 0x93, 0x49, 0x77, 0x8c, 0x7b, 0x67, 0x5d, 0x1e, - 0x3e, 0x4a, 0x14, 0xe6, 0x3e, 0x14, 0x57, 0x31, 0x49, 0x51, 0x2c, 0x01, 0xf7, 0xca, 0x65, 0x2f, 0x1e, 0x49, 0x3a, - 0x41, 0x66, 0xa8, 0x5c, 0x1b, 0xef, 0x9b, 0x7a, 0xa4, 0xea, 0x49, 0x96, 0x02, 0x79, 0xe4, 0xee, 0x77, 0x46, 0x50, - 0xf2, 0xfc, 0xb7, 0xec, 0x8a, 0xd7, 0x49, 0x79, 0x86, 0x36, 0x1b, 0x22, 0x7a, 0x5d, 0x8e, 0x36, 0x05, 0xcc, 0x31, - 0x23, 0x02, 0xfd, 0x73, 0xb0, 0x0a, 0xf9, 0xb2, 0xe3, 0x14, 0xdb, 0x54, 0x01, 0x4a, 0xb9, 0xf7, 0xfd, 0x55, 0x1e, - 0x08, 0xfb, 0x33, 0x92, 0x9c, 0x49, 0x46, 0x44, 0x50, 0xb6, 0xc0, 0x23, 0xb0, 0x2f, 0xa4, 0x8b, 0x13, 0xb5, 0x0a, - 0xfb, 0x82, 0x9a, 0xb3, 0x67, 0xa9, 0x88, 0xea, 0x14, 0x7d, 0xfc, 0xca, 0xb8, 0x60, 0x2e, 0xab, 0x91, 0xb6, 0x22, - 0x5a, 0x9e, 0xaa, 0x04, 0x04, 0xb5, 0x10, 0x0b, 0xb1, 0xa9, 0x80, 0x60, 0x7c, 0xaf, 0xa7, 0x27, 0x18, 0x31, 0x0b, - 0xc5, 0x8b, 0xcc, 0xe5, 0x44, 0xbb, 0xfc, 0x87, 0x9b, 0x30, 0x9d, 0x33, 0x86, 0x34, 0x22, 0xa9, 0x47, 0xd6, 0xef, - 0x5f, 0x2f, 0x2f, 0x54, 0x65, 0x13, 0x49, 0x65, 0x23, 0xee, 0xe6, 0x73, 0x9d, 0x1b, 0xd3, 0x44, 0x70, 0xc5, 0x92, - 0xd9, 0x62, 0xe3, 0xe9, 0xfc, 0xc3, 0x95, 0x59, 0x48, 0xd7, 0x44, 0x39, 0x92, 0xc8, 0x4f, 0x0a, 0xc1, 0x43, 0x8d, - 0xf2, 0x42, 0x18, 0x91, 0xfa, 0x6f, 0x86, 0xdc, 0x75, 0x29, 0xda, 0xd5, 0x46, 0x75, 0xd9, 0x02, 0xd8, 0xd2, 0xd7, - 0x30, 0x32, 0x14, 0x42, 0x47, 0x0c, 0x92, 0xdc, 0xa5, 0x3e, 0x2a, 0x19, 0xc8, 0xa2, 0x2b, 0xcc, 0x40, 0x99, 0x7b, - 0xe8, 0xe4, 0x8d, 0x93, 0x28, 0x01, 0xb9, 0x9f, 0x99, 0x4f, 0xea, 0xec, 0x24, 0xf2, 0x62, 0x2d, 0x05, 0x74, 0xa4, - 0xba, 0x4e, 0x25, 0x56, 0x59, 0xad, 0x84, 0x9e, 0x08, 0x76, 0x17, 0xcd, 0xe0, 0x55, 0x9b, 0xe7, 0xe9, 0xb1, 0xe6, - 0x9f, 0xc7, 0x57, 0x94, 0xb7, 0x35, 0x91, 0x16, 0x74, 0x22, 0xb4, 0xfa, 0xc0, 0x7d, 0xdd, 0x5c, 0xd9, 0x1a, 0xe4, - 0x65, 0x01, 0xd0, 0x72, 0xce, 0x72, 0x7a, 0x12, 0xca, 0xba, 0x79, 0x5e, 0x26, 0x99, 0x7b, 0x15, 0x07, 0x5b, 0x40, - 0x73, 0x01, 0x37, 0xc1, 0x67, 0x1f, 0x27, 0xa4, 0x7e, 0xa8, 0x3c, 0x56, 0x36, 0xdf, 0xd6, 0x60, 0xee, 0xdb, 0xc4, - 0xe9, 0xb0, 0xd9, 0x24, 0x22, 0x26, 0x73, 0x37, 0xb6, 0xde, 0x08, 0x67, 0x2d, 0x54, 0xed, 0x11, 0xf3, 0x84, 0x00, - 0x53, 0xd5, 0x20, 0x7c, 0xda, 0xc7, 0x49, 0x4c, 0x6f, 0x11, 0x15, 0xa0, 0x5c, 0x62, 0x52, 0xaf, 0xdc, 0xa5, 0xa5, - 0xd6, 0xbd, 0x4f, 0x17, 0x58, 0x57, 0xba, 0x78, 0xbc, 0xd3, 0x7d, 0xe0, 0x00, 0x70, 0x3f, 0x83, 0xaa, 0x55, 0x5e, - 0xaa, 0xea, 0x0b, 0x6a, 0x69, 0x82, 0x94, 0x04, 0xbc, 0x55, 0x49, 0xef, 0xe7, 0x99, 0x06, 0x82, 0xe6, 0x6b, 0x64, - 0x75, 0xe4, 0x0b, 0x91, 0xc8, 0x43, 0xcf, 0x4b, 0x7c, 0xbc, 0x08, 0xcf, 0x09, 0x1e, 0xbf, 0x8c, 0xad, 0x0b, 0x3a, - 0x65, 0xfe, 0x20, 0x81, 0xe5, 0x40, 0xed, 0xda, 0xe5, 0xeb, 0x38, 0x11, 0xec, 0x14, 0x05, 0xea, 0x29, 0x2a, 0x40, - 0x83, 0x40, 0xd1, 0x48, 0x0b, 0xe8, 0x24, 0xf9, 0x39, 0xa6, 0x05, 0x84, 0xd4, 0x29, 0x10, 0x31, 0xdf, 0x0e, 0xcb, - 0x11, 0xdc, 0x95, 0x22, 0x27, 0x9e, 0x38, 0x37, 0x6b, 0xe5, 0xcb, 0x7d, 0x88, 0xaa, 0x73, 0x7f, 0x7c, 0x83, 0x3b, - 0x70, 0x15, 0xdb, 0x8d, 0xe3, 0x1f, 0x71, 0xbf, 0x49, 0x16, 0x72, 0x0e, 0x44, 0x8a, 0xbc, 0x1c, 0x11, 0x22, 0x13, - 0x87, 0x3a, 0xdc, 0x84, 0x90, 0x8e, 0x2f, 0xa0, 0x3f, 0x8e, 0x98, 0xc6, 0x56, 0x9d, 0x26, 0x20, 0xe7, 0x3c, 0xbe, - 0x3d, 0x9d, 0xde, 0xba, 0xa8, 0x1e, 0x44, 0xdb, 0x22, 0xe2, 0x87, 0xb6, 0xa8, 0x51, 0xa8, 0x3c, 0x9c, 0x5a, 0x5f, - 0x53, 0xc3, 0x31, 0xc4, 0xe1, 0xdf, 0x06, 0x48, 0x00, 0x85, 0xdd, 0x26, 0xd7, 0x5c, 0xd0, 0xe9, 0x9d, 0xa4, 0x23, - 0xb4, 0xd6, 0xf4, 0x53, 0xb9, 0x6a, 0xd6, 0xc1, 0xca, 0xb4, 0xd3, 0xfb, 0x6c, 0xe3, 0xb6, 0x38, 0x01, 0x41, 0xb4, - 0xd2, 0xeb, 0x9b, 0x30, 0x61, 0x89, 0x31, 0x06, 0xde, 0x17, 0x62, 0xce, 0x53, 0x98, 0x49, 0xcc, 0xc7, 0x70, 0xb4, - 0x3a, 0x8b, 0x77, 0x6e, 0xd1, 0xa5, 0xbd, 0xd1, 0x9b, 0x36, 0x92, 0xa9, 0x84, 0x8e, 0x05, 0xf0, 0xc7, 0xe9, 0xa8, - 0x1d, 0x71, 0x07, 0x04, 0xd8, 0xca, 0x12, 0xe3, 0xd2, 0x2d, 0xd8, 0xaa, 0x6c, 0xf9, 0xb4, 0x71, 0xae, 0xdc, 0xcf, - 0xd6, 0x2e, 0x74, 0x44, 0x70, 0x58, 0x97, 0x34, 0x07, 0xe6, 0x63, 0xc1, 0x5c, 0x8a, 0x8b, 0xd5, 0x4e, 0x01, 0x12, - 0xb4, 0x92, 0x3c, 0x5c, 0x66, 0x48, 0x7a, 0x7c, 0xa2, 0x2e, 0x12, 0x72, 0xc6, 0x8d, 0xb6, 0x06, 0xec, 0xe0, 0xdd, - 0x5e, 0x8f, 0xb4, 0xee, 0xbc, 0x45, 0xde, 0x8b, 0xe2, 0x05, 0xa4, 0x9a, 0x02, 0x71, 0x65, 0x83, 0x20, 0xed, 0x3a, - 0x25, 0xac, 0xbf, 0x19, 0x2c, 0x8d, 0xdb, 0x77, 0x6d, 0x4a, 0x0f, 0x7a, 0x76, 0xa6, 0x87, 0x5c, 0xf8, 0xb3, 0xa2, - 0x6f, 0x5f, 0x79, 0xcb, 0x36, 0xec, 0xa7, 0xa5, 0x00, 0xb2, 0xba, 0xb8, 0x1b, 0xe7, 0xbc, 0x60, 0x8b, 0xa5, 0xc9, - 0xe9, 0xab, 0x65, 0x85, 0x9a, 0xc0, 0x1e, 0x7c, 0xa0, 0x65, 0xa4, 0x52, 0x5f, 0x29, 0x29, 0x5a, 0x1e, 0x1a, 0x93, - 0x6c, 0x6d, 0x6a, 0x85, 0xb8, 0xaa, 0x06, 0xab, 0xea, 0xe1, 0x12, 0x4b, 0x4b, 0xdb, 0x52, 0x0b, 0xcd, 0x75, 0xef, - 0x05, 0x98, 0x7c, 0x8f, 0x1e, 0xb1, 0xbc, 0x00, 0xba, 0xfb, 0x85, 0x7c, 0x19, 0x87, 0x41, 0x5a, 0x54, 0x41, 0x00, - 0xe9, 0x75, 0x1d, 0xc3, 0xa6, 0x61, 0x8d, 0x89, 0x0e, 0x8b, 0x3e, 0x8d, 0x40, 0x45, 0xa8, 0x81, 0x21, 0xd8, 0x42, - 0xae, 0x4c, 0xc5, 0xd2, 0xa9, 0x97, 0xc9, 0xe2, 0xd2, 0xe7, 0x5e, 0x6c, 0x6b, 0xd2, 0x15, 0xb3, 0x54, 0x41, 0x5c, - 0x1a, 0x75, 0xbd, 0xd1, 0x37, 0x6a, 0x79, 0xd0, 0x35, 0xde, 0xe3, 0x26, 0x19, 0xd6, 0xa6, 0x72, 0x7d, 0x94, 0x6c, - 0xb7, 0xff, 0x59, 0xb9, 0x44, 0xb5, 0xe4, 0x2c, 0xad, 0xb1, 0xea, 0x61, 0x8b, 0x02, 0x5c, 0xbe, 0xe3, 0x4e, 0xc6, - 0x00, 0x59, 0x8e, 0xb4, 0x61, 0x6e, 0x1d, 0xce, 0x65, 0x1b, 0x68, 0xfb, 0xcd, 0xa7, 0x92, 0x60, 0xeb, 0x57, 0x4f, - 0xa7, 0xb1, 0x4d, 0x91, 0xc3, 0x28, 0x70, 0x14, 0x9e, 0xbb, 0xe7, 0xbc, 0x5a, 0x29, 0xe3, 0x12, 0xdb, 0xed, 0x73, - 0xd3, 0x4f, 0x5e, 0xd9, 0x86, 0xf5, 0x14, 0x5f, 0x8f, 0x91, 0x8d, 0xbd, 0xe7, 0xc9, 0x7a, 0x32, 0x16, 0x77, 0x0c, - 0x80, 0x8b, 0x92, 0x62, 0xb8, 0x5b, 0xc1, 0xc5, 0x83, 0x5f, 0xf8, 0x7c, 0x2a, 0xa7, 0x9b, 0x34, 0xee, 0xcd, 0xbf, - 0xed, 0xed, 0x85, 0x07, 0xcd, 0x24, 0x7d, 0x99, 0xa5, 0xcb, 0x2a, 0xb9, 0x16, 0xc8, 0xb5, 0x1b, 0x9e, 0x8b, 0x72, - 0xbd, 0x69, 0x6b, 0x23, 0x4c, 0x50, 0xbc, 0x1c, 0xf8, 0xdb, 0xbb, 0xf8, 0xed, 0xb9, 0xec, 0xf9, 0xb9, 0xb7, 0x68, - 0x08, 0x31, 0xdf, 0xbc, 0x8f, 0x2a, 0x2b, 0x8e, 0x63, 0xe2, 0x7d, 0x3e, 0x34, 0xde, 0xeb, 0x1a, 0x2d, 0x63, 0xae, - 0xf0, 0xf3, 0x18, 0xaa, 0xda, 0xc7, 0xcd, 0x2d, 0xff, 0x6c, 0x77, 0x9d, 0x95, 0xa2, 0x30, 0x9b, 0xc9, 0xc3, 0xd2, - 0x94, 0xb4, 0x3b, 0x0e, 0x36, 0xdc, 0x3e, 0x2b, 0x00, 0x73, 0x00, 0x2c, 0x8f, 0x74, 0x7d, 0x16, 0x7b, 0x16, 0xca, - 0xb6, 0x8b, 0x38, 0x54, 0x6f, 0x61, 0x57, 0xdc, 0x9c, 0xe5, 0x59, 0xea, 0x6e, 0xe3, 0x0b, 0x03, 0x54, 0x3d, 0xe4, - 0x8e, 0x39, 0x92, 0x96, 0x09, 0xa6, 0x4a, 0x7e, 0xbb, 0x71, 0xdc, 0xcc, 0x19, 0x3b, 0xf1, 0x0c, 0xb3, 0x79, 0xea, - 0x0d, 0x2b, 0x9a, 0xf7, 0xed, 0x2b, 0xf7, 0x34, 0x30, 0xf1, 0xad, 0x8d, 0xca, 0x54, 0xf6, 0x75, 0x00, 0x94, 0x2c, - 0xd1, 0x9f, 0x76, 0x51, 0x5a, 0x57, 0x08, 0xa3, 0xc2, 0xa9, 0xf2, 0x0f, 0xd6, 0x92, 0x56, 0x31, 0x11, 0x8b, 0xa3, - 0x23, 0xcd, 0x19, 0xe0, 0x96, 0x78, 0xcb, 0xa8, 0x03, 0xc5, 0x98, 0xd1, 0xc6, 0x4c, 0xca, 0x6a, 0x8f, 0x66, 0x07, - 0xc2, 0xc8, 0x73, 0x6d, 0x11, 0xe9, 0x28, 0x60, 0xbd, 0x54, 0x70, 0xe0, 0x37, 0xef, 0x55, 0xa0, 0x79, 0xdf, 0xb3, - 0x01, 0xe5, 0x00, 0xee, 0x37, 0x74, 0x94, 0xd4, 0xa6, 0x8d, 0xbf, 0xe4, 0x8a, 0xd1, 0xd5, 0x83, 0x24, 0xd0, 0x36, - 0x63, 0xc0, 0x07, 0x4c, 0xae, 0xa8, 0x42, 0xfa, 0x34, 0x46, 0xde, 0x28, 0x90, 0x9c, 0x63, 0xd3, 0x50, 0x4c, 0x3b, - 0xac, 0x27, 0x91, 0x94, 0x0e, 0x22, 0x64, 0x8a, 0xc5, 0xf4, 0xa0, 0x0e, 0x96, 0x64, 0xa4, 0x75, 0x2a, 0x6f, 0x45, - 0x47, 0xfd, 0x9e, 0x8d, 0xa0, 0x39, 0xb6, 0xac, 0x2a, 0xd4, 0x37, 0xcb, 0x2d, 0x13, 0x95, 0x74, 0xf3, 0x6c, 0x2a, - 0x1f, 0x97, 0x83, 0xc8, 0xa6, 0x69, 0xc7, 0x6f, 0xfb, 0xbc, 0xc7, 0x0c, 0xee, 0x62, 0x84, 0x82, 0xac, 0x6d, 0xc8, - 0x60, 0x8f, 0x3c, 0x5c, 0xd0, 0x2d, 0xfd, 0x40, 0xa1, 0xdf, 0xae, 0x96, 0x00, 0x7e, 0x4a, 0xe0, 0x2b, 0x41, 0x6f, - 0x37, 0xb9, 0x53, 0xbb, 0xce, 0x3d, 0xef, 0x13, 0xd9, 0x0b, 0x27, 0x0f, 0x92, 0x6d, 0x5b, 0xa2, 0x6d, 0xd5, 0x8d, - 0x5b, 0xfe, 0xb1, 0xc3, 0x4f, 0x4a, 0x53, 0x44, 0xad, 0x49, 0xea, 0xb4, 0xb1, 0xdc, 0x12, 0xb5, 0xa3, 0xc1, 0x51, - 0xba, 0x11, 0x5e, 0xb8, 0xdf, 0x86, 0xfd, 0x86, 0x82, 0xb1, 0x1c, 0xbd, 0x72, 0x17, 0x1d, 0x0b, 0x68, 0x1c, 0x29, - 0xe8, 0xd8, 0x1e, 0x47, 0xb5, 0x31, 0x86, 0x72, 0xcc, 0xde, 0x70, 0x0c, 0x65, 0x35, 0x06, 0x6a, 0x63, 0xeb, 0x26, - 0x74, 0x37, 0x9e, 0x88, 0xe4, 0x30, 0xa0, 0x71, 0x40, 0xea, 0x96, 0x19, 0xa9, 0xfc, 0x3a, 0x27, 0x2c, 0x10, 0x83, - 0x3b, 0xf6, 0x78, 0xa1, 0x7d, 0xc1, 0x30, 0xc4, 0x11, 0xe8, 0x16, 0x8f, 0x62, 0xb6, 0xa8, 0x0c, 0xf5, 0xe2, 0xca, - 0x5a, 0x98, 0xc0, 0xda, 0x11, 0xa2, 0x42, 0x7f, 0x6c, 0xf3, 0x5d, 0x3b, 0x14, 0xe4, 0x8a, 0x1f, 0xc6, 0xfe, 0x32, - 0xfd, 0x68, 0xe4, 0xa9, 0xa4, 0xff, 0x22, 0x8c, 0x7e, 0xea, 0x84, 0x95, 0x13, 0x40, 0xf0, 0x27, 0x48, 0x72, 0xdb, - 0x78, 0x3f, 0x4c, 0x69, 0xa6, 0xff, 0xb1, 0xb1, 0xe9, 0x8a, 0xf7, 0x43, 0x3f, 0xcc, 0x1f, 0x3a, 0x51, 0x07, 0xf9, - 0xa7, 0x5f, 0x3c, 0x74, 0x5c, 0x8f, 0xed, 0x63, 0x4c, 0xdd, 0xb1, 0xa5, 0xf9, 0x78, 0xec, 0xda, 0x4b, 0xb6, 0xdb, - 0xf6, 0xe3, 0xf0, 0x64, 0x78, 0x38, 0x64, 0x43, 0x1a, 0xb8, 0xf7, 0xfc, 0x72, 0x8e, 0x3d, 0x4f, 0xde, 0x3d, 0xf4, - 0xe9, 0x81, 0x9c, 0x8b, 0x94, 0x31, 0xd9, 0x2d, 0x9e, 0xb6, 0x5d, 0xa4, 0x34, 0x02, 0xd4, 0xd1, 0x1b, 0xe1, 0x63, - 0x41, 0xd7, 0x24, 0x55, 0xc8, 0xa0, 0x7c, 0x86, 0x49, 0xa3, 0xea, 0x0f, 0xf1, 0x0c, 0x85, 0x88, 0x83, 0xc0, 0x7f, - 0xf9, 0x67, 0x8f, 0xd6, 0x13, 0xa7, 0xd5, 0x69, 0x6d, 0x78, 0xec, 0xf7, 0x65, 0x97, 0xa5, 0x9e, 0x94, 0x51, 0xba, - 0xcd, 0xc4, 0x4b, 0x8c, 0xcc, 0x4d, 0x7e, 0xc8, 0xb1, 0x3d, 0x75, 0x0f, 0x93, 0xff, 0x42, 0x04, 0x45, 0xb8, 0xc7, - 0x02, 0x19, 0xef, 0x21, 0x50, 0x39, 0x15, 0xa2, 0x98, 0x96, 0x8b, 0xb3, 0x05, 0xb0, 0x6b, 0xf4, 0x4b, 0x64, 0x45, - 0x3c, 0x33, 0x9e, 0xdf, 0xb5, 0x36, 0xd7, 0x01, 0xfc, 0x7e, 0x6d, 0xf4, 0x64, 0x46, 0xab, 0x80, 0xac, 0xfb, 0xa0, - 0x0c, 0x2e, 0x09, 0x4f, 0xa5, 0x3d, 0x97, 0xd5, 0x58, 0xd3, 0x7e, 0xa0, 0x57, 0x33, 0xfd, 0x69, 0xfb, 0xac, 0x21, - 0x74, 0x3d, 0x9a, 0x29, 0x05, 0x54, 0xaa, 0x7c, 0x50, 0x66, 0x5f, 0x5f, 0x40, 0x38, 0xa2, 0x55, 0xc8, 0x2f, 0x15, - 0xa7, 0x87, 0xb1, 0x8d, 0x82, 0x20, 0xdf, 0x79, 0x86, 0xc8, 0x0f, 0xc9, 0x13, 0x2a, 0xec, 0xce, 0xfd, 0x02, 0xf4, - 0x45, 0x85, 0xa7, 0xf4, 0xfd, 0x69, 0x8e, 0xdb, 0xd5, 0xbc, 0x8f, 0xef, 0x03, 0x19, 0x25, 0x58, 0x46, 0xba, 0x39, - 0x74, 0xd2, 0xa8, 0x1d, 0x3d, 0xf2, 0x95, 0x48, 0x8e, 0x2e, 0xd0, 0xf4, 0x3d, 0xd6, 0x86, 0x17, 0x49, 0x4a, 0xd0, - 0xa7, 0x72, 0x2d, 0xc9, 0xb0, 0x57, 0x75, 0x60, 0x74, 0x44, 0xde, 0x5e, 0x8a, 0x0d, 0x90, 0x24, 0xd5, 0xd3, 0x12, - 0xa1, 0xfd, 0x50, 0xce, 0x7a, 0x53, 0x7e, 0x89, 0x7b, 0xf1, 0x84, 0x57, 0x46, 0x74, 0xc3, 0x5f, 0x7c, 0x13, 0xe2, - 0x5e, 0x28, 0xee, 0x8b, 0x02, 0x96, 0x25, 0x54, 0x11, 0x41, 0x6f, 0x1a, 0xa8, 0x1c, 0x0c, 0xfd, 0xb1, 0x28, 0xf0, - 0x6c, 0x05, 0x58, 0x96, 0x09, 0x29, 0x03, 0x47, 0x6c, 0x44, 0xff, 0x4a, 0x9a, 0xfa, 0x29, 0xa5, 0xb9, 0x6f, 0x49, - 0xbc, 0xec, 0x17, 0x84, 0x94, 0x37, 0x10, 0x0a, 0x82, 0x96, 0x0a, 0xde, 0x04, 0x29, 0x68, 0x4c, 0x3b, 0xcc, 0x95, - 0x41, 0xd9, 0xe3, 0xb8, 0x01, 0x2e, 0x5f, 0x39, 0xa8, 0x4d, 0xd5, 0xeb, 0x24, 0xb6, 0x2a, 0x6e, 0xf4, 0x9f, 0xe8, - 0xd6, 0xda, 0x0f, 0x07, 0x28, 0x82, 0xb6, 0x28, 0x9b, 0xf4, 0x8a, 0xc6, 0xb3, 0x30, 0x16, 0x96, 0x3d, 0x46, 0x0f, - 0x6a, 0x06, 0x4a, 0x2a, 0xac, 0x36, 0x54, 0x28, 0xe6, 0x53, 0xbb, 0x08, 0xa3, 0xf0, 0x41, 0x53, 0x19, 0x79, 0xf8, - 0xd0, 0x9d, 0x46, 0xef, 0xc6, 0x51, 0x2c, 0x72, 0x43, 0x9b, 0xd7, 0x2c, 0x45, 0xc2, 0xa4, 0x49, 0x3e, 0xbd, 0x6c, - 0xd6, 0xb3, 0x66, 0xd2, 0xb2, 0x15, 0x7a, 0xd5, 0x78, 0x63, 0x20, 0x52, 0xd4, 0x6f, 0xbe, 0x4e, 0x7a, 0xb5, 0x9e, - 0xc3, 0xec, 0x47, 0xc2, 0xf2, 0xa2, 0xe8, 0x7a, 0xa6, 0xdb, 0xbc, 0x6a, 0xa3, 0x3b, 0x73, 0xaa, 0xaf, 0xd4, 0x60, - 0x08, 0xf8, 0x95, 0x73, 0x79, 0x50, 0x26, 0xa8, 0x9c, 0xd8, 0x76, 0x0f, 0x6d, 0x46, 0x40, 0x07, 0xcf, 0xb2, 0xd3, - 0xcc, 0x97, 0xaf, 0x96, 0x49, 0x31, 0xac, 0x77, 0xa9, 0x43, 0x81, 0x97, 0x7b, 0x95, 0xfe, 0x81, 0x46, 0x95, 0x32, - 0xf2, 0x82, 0xa8, 0x3a, 0xd1, 0x5e, 0x70, 0x10, 0xc7, 0x1d, 0xfe, 0x3d, 0xe2, 0x70, 0xc9, 0x3d, 0x87, 0x1d, 0x40, - 0x4e, 0x59, 0x44, 0x3a, 0xca, 0xc7, 0x77, 0x8f, 0xbe, 0x65, 0xcc, 0x31, 0xd2, 0x65, 0xf5, 0x53, 0x11, 0x6d, 0x1f, - 0x51, 0x12, 0xe9, 0x0e, 0x07, 0xfb, 0x14, 0x21, 0xde, 0x6c, 0x8a, 0x41, 0x00, 0x2b, 0x74, 0xbe, 0x44, 0x74, 0x42, - 0x5a, 0xd4, 0x03, 0x0a, 0x87, 0xad, 0x82, 0xcf, 0x72, 0xc1, 0x09, 0x96, 0xfe, 0x10, 0x13, 0xab, 0x52, 0x24, 0x3b, - 0x34, 0xcb, 0xbf, 0x4c, 0x6d, 0xaf, 0x96, 0xa6, 0x51, 0x6d, 0x1e, 0xc1, 0x7d, 0xe3, 0xb2, 0xa4, 0x68, 0x05, 0x76, - 0x97, 0xbd, 0x54, 0xc8, 0xc2, 0x86, 0x6b, 0x2f, 0x79, 0xa6, 0x6d, 0x4b, 0x5e, 0x34, 0x78, 0x40, 0x12, 0xd8, 0x7c, - 0x01, 0xac, 0xff, 0x71, 0xb5, 0x2c, 0x43, 0x2d, 0x54, 0x35, 0x30, 0x42, 0xbe, 0xdb, 0x75, 0x04, 0xd1, 0x9e, 0x55, - 0x37, 0xbf, 0x06, 0x26, 0x5a, 0xf6, 0x26, 0xb0, 0x74, 0x90, 0x45, 0x0b, 0x81, 0x60, 0xe7, 0xfe, 0x7c, 0xed, 0xb2, - 0xd8, 0xce, 0x78, 0x8c, 0x35, 0x61, 0xe1, 0x11, 0xb9, 0x71, 0x80, 0x95, 0xc7, 0x65, 0x09, 0x42, 0x56, 0x94, 0x61, - 0x57, 0xee, 0x1c, 0x50, 0x8f, 0x85, 0x1a, 0x55, 0x08, 0xb2, 0xd6, 0x67, 0xaf, 0xa7, 0x8a, 0x35, 0xc9, 0xfd, 0x3e, - 0x28, 0x30, 0x38, 0x83, 0xbb, 0x4d, 0x45, 0x28, 0x7d, 0x48, 0xe1, 0x4f, 0x6d, 0xba, 0x3e, 0x4b, 0x7b, 0x9e, 0x82, - 0x49, 0xb1, 0x20, 0x5e, 0x2b, 0xf9, 0xe7, 0xe9, 0x2f, 0x12, 0xa8, 0x83, 0x94, 0xdc, 0x98, 0x3e, 0xe2, 0xb5, 0x11, - 0x42, 0x64, 0xac, 0xe7, 0xa0, 0x71, 0x20, 0x9c, 0x52, 0x30, 0xa8, 0x9c, 0xd9, 0x32, 0x8b, 0xe9, 0x78, 0x67, 0x4b, - 0x9d, 0x90, 0x6d, 0x0d, 0x3f, 0xf0, 0x66, 0x1a, 0xfb, 0x89, 0x70, 0xdd, 0xdc, 0xe4, 0x5b, 0x83, 0x67, 0xe8, 0x14, - 0x33, 0x7e, 0x93, 0x31, 0x14, 0xd3, 0xd6, 0x3d, 0x17, 0x4f, 0x4f, 0x4f, 0xc5, 0xa8, 0xb2, 0xb9, 0xe2, 0x61, 0xbc, - 0x1c, 0xab, 0x6a, 0x55, 0x15, 0xd3, 0x42, 0x2b, 0xab, 0xcf, 0x7f, 0x16, 0xc3, 0x25, 0xba, 0xc5, 0x70, 0xb6, 0x08, - 0x6d, 0xa2, 0x88, 0x16, 0x8d, 0x74, 0xcd, 0xd5, 0xfd, 0x4e, 0xdd, 0x95, 0xec, 0xe3, 0xab, 0x77, 0xfb, 0x1f, 0x12, - 0x46, 0xad, 0x97, 0xee, 0x14, 0x90, 0x57, 0x23, 0x9e, 0xf7, 0x5f, 0xcf, 0x29, 0xaf, 0x5a, 0x5e, 0x1a, 0x7d, 0x14, - 0x3c, 0x67, 0xfa, 0xdc, 0xd0, 0xf8, 0x45, 0xd3, 0x28, 0xcd, 0x3e, 0x50, 0x23, 0xbb, 0x81, 0xd6, 0x9b, 0xb4, 0x43, - 0xc6, 0x3b, 0x12, 0x7c, 0xb2, 0x42, 0x78, 0x69, 0xdc, 0x9e, 0x38, 0x89, 0x94, 0x62, 0x34, 0x55, 0x29, 0x54, 0xb5, - 0xce, 0x0a, 0x4d, 0x5b, 0x55, 0x21, 0xc9, 0x81, 0x03, 0xa5, 0x93, 0x21, 0xcc, 0xf1, 0xa4, 0x9c, 0xc4, 0x93, 0xa4, - 0x59, 0xcd, 0x43, 0x4e, 0x79, 0x51, 0x92, 0x86, 0xf4, 0x75, 0xe6, 0x14, 0x80, 0x66, 0x03, 0x25, 0x70, 0x28, 0x49, - 0x01, 0x66, 0x1a, 0xd2, 0x33, 0x44, 0x14, 0x82, 0x01, 0x7a, 0x73, 0x15, 0x13, 0x8f, 0x13, 0x6f, 0x1b, 0xed, 0xb2, - 0xa6, 0x20, 0x9e, 0x7c, 0xec, 0x7d, 0xb3, 0x98, 0xd6, 0x9d, 0x5c, 0x50, 0xc9, 0xf3, 0xc5, 0xd4, 0xd2, 0x04, 0xee, - 0x13, 0x32, 0xd5, 0x8c, 0xa9, 0x42, 0xfe, 0x4d, 0xee, 0xdb, 0xd1, 0x7e, 0x2c, 0x8e, 0xc5, 0xbb, 0x33, 0x34, 0xdd, - 0xcd, 0x55, 0x8e, 0xdc, 0x37, 0x23, 0xb9, 0xd5, 0xb2, 0xa6, 0x11, 0x84, 0x2c, 0x7c, 0xe1, 0x7a, 0xed, 0xf5, 0xf1, - 0x7d, 0xd6, 0xfd, 0xab, 0x0d, 0xc7, 0x8b, 0xe6, 0x25, 0x1f, 0xd2, 0x5d, 0x31, 0xb1, 0x68, 0xaf, 0xfc, 0x24, 0xa9, - 0x77, 0x6a, 0x3d, 0x66, 0xc2, 0xdd, 0x3f, 0x94, 0xa6, 0x31, 0xd3, 0x3b, 0xea, 0x78, 0x3f, 0xba, 0xc3, 0x1c, 0x8a, - 0x98, 0x6a, 0x58, 0xdd, 0x48, 0xa5, 0x5c, 0x98, 0x9e, 0x61, 0x63, 0xae, 0x4e, 0x3b, 0x4a, 0x4a, 0xd0, 0xa9, 0x5a, - 0xff, 0x51, 0x1e, 0xe1, 0x34, 0x55, 0xc1, 0x4f, 0x5e, 0x6d, 0xc7, 0xa2, 0x6b, 0x2f, 0x47, 0x6b, 0xd1, 0xb3, 0x1d, - 0xe5, 0x84, 0x7d, 0x7c, 0x8f, 0x50, 0x75, 0x7d, 0xb1, 0x3e, 0xfd, 0xb5, 0xfe, 0x56, 0xee, 0x06, 0x2a, 0x81, 0x3a, - 0x1b, 0xcb, 0xec, 0x5a, 0x13, 0x17, 0xb6, 0xbf, 0x6e, 0x53, 0xab, 0x06, 0x4e, 0xf6, 0x6a, 0xc3, 0xca, 0x9a, 0xcf, - 0x84, 0x6c, 0x7c, 0x93, 0xb2, 0x5f, 0x88, 0xe1, 0x27, 0xa9, 0x4d, 0x4d, 0x9b, 0xa4, 0xb5, 0xfc, 0x2c, 0xd7, 0xcd, - 0xdb, 0x56, 0xc4, 0xe9, 0xbe, 0x28, 0x82, 0x9c, 0x22, 0x09, 0xd9, 0xc6, 0x78, 0x84, 0xb0, 0x85, 0x0e, 0xe2, 0x5c, - 0xba, 0x88, 0xb1, 0x2c, 0x62, 0x78, 0x2f, 0x8f, 0x7d, 0x12, 0x6a, 0xda, 0x68, 0xa7, 0x2c, 0xb2, 0xff, 0x3e, 0xd3, - 0x8f, 0x8b, 0x2a, 0xa8, 0x03, 0x30, 0xbd, 0xbf, 0x6a, 0x7b, 0xb9, 0x38, 0xea, 0x37, 0x15, 0x07, 0x57, 0xff, 0x94, - 0x36, 0x37, 0x6c, 0xaa, 0xf9, 0x86, 0xa8, 0x54, 0xca, 0xbe, 0x18, 0xf4, 0x8c, 0xec, 0x55, 0xa3, 0x51, 0xcc, 0xa7, - 0xd0, 0xb2, 0x44, 0xfc, 0xf1, 0x54, 0x28, 0x6a, 0xa8, 0xe6, 0x2e, 0xe4, 0xe4, 0xd8, 0x30, 0xf6, 0x27, 0x93, 0xdd, - 0x9e, 0xb6, 0xea, 0xa7, 0xac, 0x67, 0x48, 0x87, 0x87, 0x82, 0x1f, 0xb8, 0xdc, 0x75, 0xf1, 0xa6, 0xec, 0xdd, 0xaa, - 0x45, 0x2a, 0x51, 0x10, 0x2a, 0x9b, 0x7d, 0xf5, 0x86, 0xa9, 0x81, 0x1e, 0x6a, 0xf4, 0x40, 0x19, 0x4c, 0xf1, 0x09, - 0x80, 0x9a, 0xd6, 0xe1, 0xd3, 0xd4, 0x42, 0xd9, 0x48, 0xdf, 0x0b, 0xcc, 0x30, 0xfd, 0xd7, 0x61, 0xb2, 0x42, 0x06, - 0xfc, 0xea, 0x69, 0x79, 0x33, 0xce, 0xbf, 0xe7, 0xb6, 0x87, 0xde, 0xa7, 0x7e, 0xfa, 0x2a, 0x89, 0x71, 0x0f, 0xf6, - 0xf7, 0x69, 0xe6, 0x4c, 0xc9, 0xd8, 0x51, 0x01, 0x24, 0x54, 0xdc, 0x4c, 0x61, 0x08, 0x4f, 0x17, 0x82, 0x22, 0x86, - 0xae, 0x6f, 0xd7, 0xf3, 0x3b, 0xbe, 0x62, 0x1e, 0x51, 0xbb, 0x4c, 0xd5, 0x50, 0xd2, 0xfa, 0x30, 0x1b, 0x10, 0xd6, - 0x04, 0x4f, 0x8e, 0x70, 0xc3, 0xd2, 0x55, 0x44, 0x66, 0xc1, 0x0a, 0xcf, 0xc0, 0xa9, 0x09, 0xb8, 0x6e, 0x8a, 0xcc, - 0x7b, 0x9c, 0x00, 0xce, 0xc7, 0x63, 0x1c, 0xed, 0x29, 0xe0, 0xed, 0xb2, 0xba, 0xda, 0x5b, 0x6a, 0xbd, 0x73, 0x1e, - 0xda, 0x44, 0x10, 0x95, 0xf8, 0x79, 0x36, 0x91, 0xfb, 0x07, 0x6f, 0xce, 0xab, 0xc9, 0x96, 0xa4, 0x43, 0xc9, 0xdf, - 0x41, 0xd1, 0x9b, 0xac, 0xb0, 0x92, 0x1b, 0xc5, 0x22, 0x99, 0x34, 0x02, 0x20, 0x30, 0xaf, 0xf2, 0x1d, 0x11, 0xc0, - 0x55, 0x58, 0x68, 0x34, 0x45, 0x51, 0x5e, 0x51, 0x6d, 0x9e, 0xd1, 0xee, 0xd8, 0xaf, 0xe7, 0xb8, 0x2c, 0xd7, 0x96, - 0xd4, 0x6a, 0x2c, 0xeb, 0x48, 0x8a, 0x66, 0x18, 0xbc, 0x39, 0x2f, 0x05, 0x2f, 0xf1, 0xc1, 0x3c, 0x6f, 0x89, 0xaf, - 0x54, 0x5a, 0x41, 0x23, 0xd7, 0x6b, 0x8a, 0x99, 0x03, 0x9a, 0xd3, 0x65, 0x7a, 0x97, 0xe2, 0xfd, 0xeb, 0x15, 0xbf, - 0x2c, 0x5b, 0xaa, 0xba, 0xee, 0xfa, 0x93, 0x15, 0x71, 0x5c, 0x64, 0xb1, 0x6f, 0x59, 0xb4, 0x19, 0xec, 0x10, 0xfb, - 0x31, 0xed, 0xf3, 0x28, 0xcf, 0xb5, 0xcf, 0x36, 0x3f, 0x97, 0x10, 0x47, 0x96, 0x68, 0xbd, 0x3a, 0x62, 0x3f, 0xb5, - 0x64, 0x63, 0xb9, 0xef, 0x44, 0x29, 0x76, 0xb4, 0xb8, 0x90, 0xe6, 0x42, 0x1f, 0x3c, 0xd7, 0x83, 0xa5, 0x0c, 0x7f, - 0x16, 0x57, 0xb6, 0xf4, 0xaa, 0x1c, 0xad, 0xf4, 0x9f, 0x75, 0xa3, 0x87, 0x53, 0x9b, 0x62, 0xea, 0xde, 0x47, 0xc2, - 0x34, 0xa1, 0xf9, 0xbe, 0x21, 0x36, 0x55, 0x4c, 0x14, 0x44, 0x23, 0x6d, 0x03, 0xc7, 0xfb, 0xe7, 0xf5, 0x95, 0xa7, - 0xbc, 0x94, 0xfc, 0xe1, 0x3a, 0x6e, 0x79, 0x63, 0x68, 0x32, 0xf1, 0x06, 0xad, 0x07, 0x39, 0x81, 0x6d, 0x6c, 0x9f, - 0x1e, 0x69, 0x8f, 0xc2, 0x09, 0xe9, 0x4e, 0x39, 0xb4, 0x0e, 0xd7, 0x27, 0xef, 0xd0, 0x85, 0x28, 0x8d, 0x4c, 0xfc, - 0x84, 0xf4, 0xc6, 0x69, 0x74, 0xaa, 0xab, 0x7f, 0xf2, 0xbc, 0xb3, 0xd8, 0x37, 0xb0, 0xa0, 0xde, 0xff, 0xe9, 0xc6, - 0x50, 0x62, 0x3c, 0x6f, 0x19, 0x71, 0x4c, 0x84, 0xa4, 0xdc, 0x4a, 0xbe, 0x4f, 0x22, 0x2a, 0xb5, 0x52, 0x38, 0xa3, - 0x17, 0xf4, 0x88, 0x1a, 0x2c, 0x9e, 0x9f, 0x5a, 0xe7, 0xc0, 0xa4, 0x1b, 0xe5, 0xa5, 0x51, 0x20, 0x0d, 0x22, 0x4f, - 0xcd, 0xf4, 0x0c, 0x9a, 0xb7, 0x0f, 0xaf, 0x03, 0xf7, 0x9e, 0x20, 0x9f, 0xff, 0xfe, 0x30, 0xdc, 0xde, 0x1a, 0x68, - 0x96, 0xf5, 0x39, 0x76, 0x51, 0xeb, 0x8b, 0x15, 0x7a, 0x58, 0x80, 0xdd, 0x13, 0x92, 0xeb, 0x3f, 0x05, 0xe8, 0x1a, - 0xcc, 0xb2, 0x55, 0xc7, 0xbc, 0x6d, 0xfb, 0xb7, 0xf3, 0x2a, 0xdc, 0x1d, 0x33, 0x10, 0x68, 0x77, 0xc6, 0x38, 0x87, - 0xff, 0x67, 0x89, 0x64, 0x15, 0xc6, 0xe4, 0xa2, 0xbd, 0x6e, 0x0f, 0x97, 0xc4, 0x6e, 0xb5, 0x66, 0x39, 0xd3, 0x76, - 0x60, 0xeb, 0x39, 0x2f, 0xa2, 0xd2, 0x20, 0xc1, 0x4e, 0x6a, 0x43, 0x03, 0x44, 0x32, 0xe8, 0xf6, 0x52, 0xc6, 0xbd, - 0x20, 0x9f, 0x01, 0x7d, 0x6d, 0x67, 0x2e, 0xbd, 0x31, 0x35, 0xae, 0x70, 0x52, 0x97, 0x9d, 0xbb, 0xc9, 0x70, 0xd6, - 0x3e, 0x16, 0xca, 0xd7, 0x63, 0x81, 0x2f, 0xac, 0x8f, 0xd3, 0xf4, 0xc1, 0x1d, 0xd9, 0x47, 0x93, 0x63, 0x2f, 0xa6, - 0xa4, 0x2a, 0x33, 0x18, 0x65, 0x08, 0xb4, 0x74, 0x2d, 0xcb, 0x94, 0x62, 0x8f, 0xde, 0x3e, 0x9c, 0x32, 0x6e, 0xfa, - 0x79, 0x98, 0x73, 0xd0, 0x89, 0x65, 0x8b, 0xe7, 0x15, 0xd9, 0xc3, 0xd4, 0x9d, 0x00, 0x89, 0x04, 0x61, 0xa2, 0x0b, - 0x95, 0x7a, 0x90, 0x61, 0x4d, 0x78, 0x84, 0x34, 0x71, 0x71, 0x3a, 0x32, 0x61, 0x77, 0xe4, 0x49, 0x07, 0x51, 0x07, - 0x86, 0xca, 0xd5, 0x73, 0xfe, 0xd0, 0x63, 0xb2, 0x17, 0x14, 0xd9, 0xf6, 0x48, 0xe1, 0x9c, 0x79, 0xf3, 0x21, 0x7b, - 0xe8, 0x5f, 0x37, 0xbd, 0xe6, 0x88, 0x05, 0xf7, 0xb7, 0x50, 0x81, 0x32, 0x04, 0xdc, 0x1f, 0xfa, 0xee, 0x36, 0x47, - 0xad, 0xa0, 0x33, 0x30, 0x7d, 0xb2, 0xcf, 0xf4, 0x62, 0x4d, 0x69, 0xb8, 0x6f, 0x46, 0xce, 0xe0, 0x4e, 0xd0, 0xb5, - 0x33, 0xa9, 0xb4, 0xbb, 0x7c, 0x21, 0xa8, 0xf0, 0xe1, 0x1a, 0xb4, 0x3a, 0x88, 0x9c, 0x92, 0xfe, 0x4e, 0x48, 0x75, - 0xb5, 0x29, 0x26, 0xdc, 0x40, 0xcd, 0x06, 0x8a, 0xa3, 0x70, 0xe3, 0x07, 0x89, 0x01, 0x66, 0x6e, 0xa4, 0x61, 0x25, - 0xaf, 0x9d, 0x87, 0x5f, 0xec, 0x07, 0x39, 0xcf, 0x63, 0x2a, 0xd1, 0x43, 0x9f, 0x56, 0x75, 0xfd, 0x21, 0xe6, 0x1b, - 0x6a, 0x9f, 0x41, 0x6d, 0x93, 0x10, 0xa2, 0x4e, 0xd3, 0x3e, 0xe6, 0x59, 0xf9, 0xd1, 0xc1, 0x84, 0x98, 0x7b, 0x32, - 0xd0, 0xaa, 0x5d, 0x81, 0xa5, 0xec, 0x52, 0x95, 0x70, 0xed, 0xd4, 0x6f, 0x2a, 0x69, 0x17, 0xab, 0x95, 0x57, 0xa7, - 0xd8, 0xb3, 0x7f, 0xe7, 0xda, 0xfb, 0x90, 0xf1, 0x99, 0xe8, 0x58, 0xb3, 0xda, 0xbd, 0xee, 0x27, 0xce, 0x69, 0xbc, - 0xc4, 0x46, 0x09, 0xe5, 0x87, 0x69, 0x40, 0x3c, 0x78, 0x83, 0x78, 0xd7, 0x4f, 0x6c, 0xf6, 0xe2, 0xaa, 0x2f, 0x35, - 0x5a, 0xa8, 0x3f, 0xe9, 0xc3, 0xa3, 0x1a, 0x9c, 0x3c, 0x5c, 0x86, 0x27, 0x5f, 0x79, 0x3b, 0x19, 0xe0, 0xb1, 0x12, - 0xf8, 0xdc, 0x5a, 0x02, 0x4a, 0x47, 0x24, 0xaf, 0xe4, 0x03, 0xfa, 0x7f, 0x0e, 0xcf, 0x87, 0x5d, 0x8f, 0x9f, 0x2d, - 0x6d, 0xa8, 0x45, 0x27, 0x1d, 0x61, 0x09, 0x6a, 0x7b, 0x48, 0x43, 0x88, 0x8c, 0x1d, 0x81, 0x69, 0xcc, 0x9f, 0x14, - 0x61, 0x1e, 0x81, 0xf7, 0x39, 0x03, 0x8e, 0xda, 0x96, 0xf8, 0xc2, 0x09, 0x77, 0xef, 0xf2, 0xe1, 0x37, 0xf0, 0xbd, - 0xb2, 0x4b, 0x58, 0x6e, 0xab, 0x1d, 0xbb, 0xd9, 0x04, 0x9a, 0xa3, 0x28, 0x6e, 0xbf, 0x99, 0x68, 0xd1, 0xb3, 0xc3, - 0x7e, 0x0e, 0xba, 0x97, 0xa1, 0x42, 0xf9, 0x98, 0xf6, 0x99, 0xdc, 0xaf, 0x47, 0x80, 0x22, 0xe0, 0x10, 0x43, 0x6c, - 0xff, 0xd8, 0x2b, 0x0f, 0xb5, 0x9e, 0x05, 0x04, 0x14, 0xc3, 0x9f, 0x5c, 0x70, 0x66, 0xfa, 0xe0, 0x18, 0x30, 0x39, - 0x00, 0xd4, 0x06, 0x17, 0x8d, 0xc5, 0x29, 0xfe, 0xbf, 0xf3, 0x8d, 0xe4, 0xed, 0xba, 0x38, 0x1d, 0xf1, 0x2e, 0x9f, - 0x51, 0x54, 0xcc, 0x90, 0x42, 0x0b, 0xbf, 0xe8, 0x06, 0xc2, 0x4a, 0x11, 0x0b, 0x7a, 0x2b, 0x1f, 0xdb, 0xcb, 0x63, - 0x14, 0xaa, 0xff, 0xab, 0x97, 0xec, 0x8f, 0x5a, 0xf0, 0xd8, 0xa5, 0x58, 0xde, 0xf0, 0x91, 0x53, 0xaa, 0x87, 0xbb, - 0x78, 0xb3, 0x1d, 0x06, 0x05, 0xbd, 0x1d, 0x10, 0x6f, 0xfd, 0x9f, 0x25, 0x49, 0xb6, 0xdc, 0x6a, 0x86, 0x24, 0xb9, - 0xae, 0x8e, 0x3b, 0xe2, 0xdf, 0x8f, 0x78, 0x57, 0x1b, 0x1d, 0xaa, 0xf6, 0x7c, 0x5c, 0x67, 0xfe, 0x2b, 0xce, 0xf2, - 0x86, 0xa4, 0xd3, 0xcc, 0xee, 0x6b, 0x5c, 0xce, 0x65, 0x3b, 0x99, 0x2f, 0x66, 0x77, 0xb3, 0xfd, 0xf2, 0xfd, 0x96, - 0x2a, 0x63, 0xeb, 0xf9, 0x45, 0xf3, 0x31, 0xc7, 0x1d, 0x91, 0x94, 0x65, 0x18, 0xcb, 0xf9, 0xb9, 0x4b, 0xf3, 0xe3, - 0x0f, 0xc2, 0x9b, 0x1f, 0xbf, 0x78, 0x28, 0x38, 0x9d, 0x62, 0x2a, 0x23, 0x4e, 0x95, 0xce, 0x9c, 0x24, 0x86, 0xa9, - 0x14, 0x68, 0x26, 0xba, 0xbe, 0x06, 0xc9, 0x00, 0xbd, 0x82, 0xa6, 0xc3, 0xd0, 0x9f, 0xf1, 0x01, 0xae, 0x3a, 0x79, - 0xa6, 0x92, 0xcc, 0x17, 0x8c, 0x31, 0x5e, 0xf0, 0x43, 0xbf, 0xf0, 0xe4, 0x5e, 0x3b, 0x32, 0x80, 0x21, 0x15, 0x7b, - 0xfc, 0x78, 0xd1, 0x7c, 0x79, 0x69, 0x44, 0x08, 0x55, 0xc8, 0x52, 0x80, 0xa7, 0x3c, 0x7f, 0x26, 0xab, 0xeb, 0xd9, - 0x6f, 0x36, 0x5d, 0x69, 0xb8, 0xaf, 0xa6, 0x9e, 0x2a, 0x60, 0x6c, 0xb9, 0x91, 0x8f, 0x29, 0x66, 0xd6, 0x06, 0xeb, - 0x74, 0x50, 0xab, 0xc7, 0x1c, 0xe3, 0xa9, 0xa0, 0x2e, 0xa6, 0xd4, 0x93, 0x3c, 0xd6, 0xd9, 0xf4, 0x41, 0x36, 0xb8, - 0x81, 0x71, 0xc5, 0xc9, 0x47, 0x10, 0x45, 0x13, 0x60, 0x39, 0x4f, 0x5b, 0x44, 0x11, 0x7c, 0x87, 0x66, 0x14, 0xc1, - 0x10, 0xb1, 0x88, 0x2d, 0xef, 0x56, 0xc9, 0xbc, 0xbd, 0xec, 0x72, 0x92, 0xe9, 0xb7, 0xa5, 0xcc, 0x49, 0xa2, 0xc1, - 0xc1, 0x2a, 0x9f, 0xb5, 0xea, 0xa6, 0x1f, 0xec, 0x4b, 0x28, 0x00, 0x8e, 0xcc, 0xc0, 0x81, 0x92, 0x62, 0x56, 0xaa, - 0x8a, 0x1a, 0x39, 0x08, 0x70, 0xf2, 0xc3, 0x3f, 0x54, 0x5f, 0x84, 0xa5, 0xb3, 0xdb, 0x29, 0x08, 0x3d, 0xc1, 0x08, - 0x91, 0x40, 0xe3, 0x27, 0x97, 0x6c, 0xfa, 0xef, 0xdc, 0xcc, 0x48, 0x5f, 0xfe, 0xbd, 0x9e, 0xec, 0x6d, 0x6b, 0x50, - 0x30, 0xb9, 0x1e, 0xed, 0xeb, 0x58, 0x2b, 0x96, 0x4e, 0xa8, 0x4b, 0x7f, 0x71, 0x05, 0x3e, 0xa9, 0x09, 0x91, 0xb1, - 0x62, 0xa6, 0x32, 0x6b, 0x29, 0x78, 0xae, 0x7e, 0xcc, 0x65, 0x60, 0x26, 0x52, 0xda, 0x15, 0x93, 0xa6, 0x34, 0xf3, - 0x29, 0x17, 0xd1, 0xb3, 0x67, 0x5d, 0xa7, 0xa1, 0xb5, 0x0e, 0xac, 0xcb, 0x7e, 0x88, 0xb7, 0xf9, 0xd5, 0x99, 0xa6, - 0x30, 0xca, 0xf9, 0xab, 0xf3, 0x0e, 0x8b, 0x72, 0xb3, 0xbe, 0x62, 0x3e, 0xec, 0x1d, 0xda, 0x69, 0x65, 0xf4, 0xf1, - 0x5c, 0xad, 0x70, 0xdf, 0x81, 0x90, 0xf3, 0xe8, 0x7b, 0x03, 0x1e, 0xff, 0x0a, 0xff, 0xbf, 0x3e, 0x04, 0xda, 0xb1, - 0x15, 0x0c, 0xdd, 0xf0, 0x89, 0x4d, 0x70, 0x8f, 0x86, 0x99, 0xd3, 0xd9, 0xca, 0xef, 0x43, 0x22, 0xea, 0x16, 0x70, - 0xb7, 0x8b, 0x1f, 0xd7, 0x3e, 0xc3, 0xd5, 0xc8, 0xc6, 0x18, 0x0e, 0xb9, 0x01, 0xb2, 0x84, 0xf0, 0x09, 0x09, 0x63, - 0xdd, 0x39, 0x3f, 0x38, 0xa3, 0x31, 0xbe, 0xfb, 0x5b, 0xe7, 0xf9, 0x66, 0xbc, 0x8d, 0xf9, 0x75, 0xf2, 0x4d, 0xe7, - 0x7a, 0xa0, 0xf3, 0xf4, 0xa0, 0xd6, 0x6a, 0xfd, 0xc3, 0x4d, 0xef, 0x5d, 0x0c, 0x4b, 0xb8, 0x9f, 0x3a, 0xba, 0xb9, - 0x7b, 0x13, 0x11, 0x11, 0xa8, 0x3f, 0x78, 0x68, 0xd1, 0xf3, 0x09, 0xd4, 0xe9, 0x12, 0x22, 0xfa, 0xa3, 0xcd, 0x9e, - 0xdb, 0xc9, 0x9c, 0x3a, 0x79, 0xb2, 0x8d, 0xae, 0x45, 0x25, 0x5f, 0x58, 0x2c, 0xf3, 0x3e, 0x6d, 0xdd, 0x88, 0xc8, - 0x81, 0xc4, 0x64, 0xc5, 0x36, 0xc3, 0xd4, 0xd0, 0x71, 0xea, 0x22, 0xf1, 0x3f, 0xef, 0xeb, 0xc4, 0x50, 0xf2, 0xb2, - 0xd4, 0x02, 0x0b, 0x4b, 0x55, 0xd8, 0x3e, 0xee, 0x39, 0x95, 0x85, 0x55, 0x37, 0x46, 0xbc, 0x75, 0xdf, 0x76, 0x4d, - 0xc7, 0x26, 0x8a, 0xd7, 0x5f, 0xbf, 0x02, 0xad, 0x21, 0x3d, 0x16, 0xf1, 0x7e, 0x91, 0x8e, 0x63, 0x00, 0xde, 0x31, - 0x74, 0x0b, 0x77, 0xcb, 0xb2, 0x6a, 0xcf, 0xfb, 0x74, 0x0c, 0x25, 0x45, 0xb1, 0x94, 0xdc, 0x3d, 0x62, 0xeb, 0x71, - 0x94, 0xe0, 0xa9, 0xee, 0x3d, 0xbd, 0x45, 0x2a, 0x91, 0xa5, 0xa3, 0xf4, 0xd8, 0xcf, 0x29, 0x60, 0xea, 0xa5, 0xf8, - 0x7d, 0xf4, 0x68, 0x59, 0x32, 0x40, 0x8b, 0x8d, 0x58, 0xe5, 0x1d, 0x5b, 0xc1, 0x5a, 0x9c, 0x92, 0x63, 0xbc, 0xed, - 0xdd, 0x97, 0x54, 0xca, 0x5d, 0xcc, 0x6c, 0x94, 0x76, 0x6a, 0xbc, 0x1c, 0x1c, 0xa7, 0xa5, 0xb0, 0x22, 0xc6, 0x18, - 0x39, 0xbb, 0x12, 0xb4, 0x35, 0x42, 0x77, 0xb8, 0x66, 0x89, 0xff, 0xbe, 0xac, 0x2d, 0x6e, 0x25, 0x90, 0x91, 0x2f, - 0xc3, 0x37, 0xe5, 0x9b, 0xa0, 0xad, 0xfe, 0x62, 0x8f, 0xbe, 0x56, 0x10, 0x32, 0xe1, 0x57, 0x7c, 0x35, 0xba, 0xe6, - 0xf6, 0x7d, 0xd9, 0x4d, 0x56, 0x69, 0x92, 0x9d, 0x40, 0x6b, 0x93, 0xca, 0xb9, 0xf0, 0xf0, 0x39, 0x77, 0x47, 0x92, - 0x3e, 0x7d, 0x2a, 0xcc, 0x28, 0x79, 0xc9, 0x54, 0x50, 0x3a, 0xc8, 0x66, 0x7f, 0x82, 0x25, 0xa8, 0x87, 0x7c, 0x41, - 0x6d, 0xdd, 0xe3, 0xe9, 0xf3, 0x1a, 0x88, 0xeb, 0x65, 0xc3, 0x0a, 0x44, 0x22, 0xfa, 0x6f, 0xb3, 0x8f, 0x3e, 0x64, - 0x73, 0x42, 0xf6, 0xfa, 0x66, 0x8e, 0xd3, 0x9d, 0x44, 0x28, 0xca, 0x1d, 0xb7, 0x03, 0x4a, 0x29, 0x0e, 0x4a, 0xd5, - 0xf0, 0xd8, 0x2c, 0x91, 0x63, 0xe6, 0x07, 0xa7, 0xbb, 0xd8, 0x4f, 0x5c, 0x8b, 0x5f, 0xd8, 0xb1, 0x53, 0x79, 0xf3, - 0xcf, 0xbe, 0x7c, 0xd9, 0xc7, 0x83, 0xc8, 0xe8, 0x0f, 0x42, 0x11, 0x5f, 0xf6, 0x9b, 0x26, 0xf1, 0xe2, 0x17, 0xdf, - 0xd2, 0x53, 0x3c, 0xf7, 0x6b, 0x75, 0x11, 0xb7, 0x75, 0xf7, 0xbe, 0x8a, 0x76, 0x29, 0xb1, 0xe1, 0x36, 0x0c, 0x4f, - 0x93, 0xe4, 0xf4, 0x00, 0xe0, 0x03, 0xce, 0xe5, 0x3f, 0x73, 0x14, 0xca, 0x47, 0x2e, 0xc3, 0xf9, 0x62, 0x11, 0x62, - 0x4c, 0xfe, 0xc6, 0x18, 0xa5, 0x35, 0x6f, 0x9f, 0xb7, 0x77, 0xbf, 0x71, 0x6c, 0x78, 0x6d, 0xbc, 0x89, 0x86, 0x8a, - 0x16, 0xe5, 0x4d, 0xe1, 0x53, 0x5e, 0x17, 0x76, 0x79, 0xaf, 0xf0, 0x98, 0xf7, 0x0b, 0x4f, 0xf9, 0x60, 0xed, 0xd1, - 0x68, 0x45, 0x48, 0xc1, 0xb5, 0x40, 0xd6, 0x85, 0x42, 0x97, 0x71, 0x04, 0xf7, 0x94, 0x17, 0x6d, 0xcd, 0xef, 0xd0, - 0x44, 0x96, 0xff, 0x07, 0x62, 0x85, 0xd5, 0xe9, 0x07, 0x4d, 0xf1, 0x0a, 0xc4, 0x58, 0xe6, 0x58, 0x8a, 0xd5, 0xed, - 0x7f, 0xd6, 0x52, 0x31, 0x1e, 0x73, 0xb6, 0x99, 0x81, 0xbe, 0x5a, 0xbe, 0xc2, 0xc6, 0x40, 0xe3, 0xeb, 0x4d, 0x69, - 0xf5, 0x1a, 0x58, 0x8b, 0xfd, 0x7c, 0x4d, 0x23, 0x59, 0x89, 0xb0, 0x52, 0xe5, 0x61, 0x60, 0xa2, 0x2a, 0xf3, 0x8c, - 0x74, 0x04, 0xc5, 0xf3, 0xe9, 0x0b, 0xbe, 0x72, 0xd4, 0xda, 0x67, 0x05, 0xa8, 0x86, 0xc7, 0x42, 0x47, 0x2f, 0x8c, - 0xec, 0xea, 0xba, 0xa5, 0xa6, 0xb6, 0x67, 0x5f, 0x12, 0x6b, 0xe4, 0xb7, 0xe3, 0x67, 0x52, 0x24, 0xb4, 0x6c, 0xfc, - 0x3e, 0x8f, 0x77, 0xb1, 0xf7, 0x95, 0x86, 0x34, 0x40, 0x68, 0x9d, 0x90, 0x59, 0xd4, 0x74, 0xc1, 0x4b, 0xc2, 0xa7, - 0xa5, 0x8f, 0xe9, 0x47, 0xc7, 0xfb, 0x8b, 0xaf, 0xf0, 0x00, 0x47, 0x5a, 0xbb, 0xd8, 0xe4, 0xc7, 0xe3, 0x02, 0x7e, - 0xed, 0x37, 0x1d, 0x0a, 0x6b, 0xc6, 0x2a, 0x97, 0xde, 0xb4, 0xab, 0x8b, 0xe0, 0x6b, 0x4b, 0x9f, 0xf1, 0xb8, 0x7f, - 0xec, 0x4d, 0x1d, 0xef, 0x4f, 0x7a, 0x04, 0xbe, 0x01, 0x28, 0x15, 0x35, 0x88, 0x7d, 0x10, 0x7a, 0xbc, 0xb3, 0x2a, - 0x82, 0xcb, 0xf0, 0x38, 0xa4, 0xed, 0xf9, 0x32, 0xb3, 0xab, 0xc7, 0xf8, 0x8d, 0x90, 0x04, 0xdd, 0xf0, 0x4e, 0x5a, - 0x12, 0xa0, 0xf4, 0x51, 0x09, 0x93, 0x1c, 0xb1, 0xcf, 0x2f, 0x5a, 0xf6, 0xa6, 0x8d, 0x4e, 0xe1, 0x5b, 0x8f, 0x98, - 0x67, 0x6d, 0x99, 0xf3, 0x9f, 0x06, 0x71, 0x30, 0x93, 0xa3, 0xf8, 0xfd, 0x10, 0xe7, 0x45, 0x15, 0x75, 0xe9, 0xc5, - 0x6c, 0x6f, 0x03, 0xb6, 0xf0, 0xbb, 0x0f, 0xb3, 0x81, 0xef, 0x4f, 0x7d, 0xb9, 0xd6, 0xa1, 0x9e, 0xd1, 0xfd, 0x56, - 0x75, 0xdb, 0xc7, 0x91, 0x75, 0xf2, 0x9c, 0xc5, 0xc3, 0xe8, 0xdd, 0xf7, 0x85, 0xaf, 0x71, 0x66, 0xb4, 0xf8, 0x24, - 0x2a, 0x0a, 0x2b, 0x97, 0x41, 0xb9, 0x7c, 0x4d, 0x55, 0xb5, 0x47, 0x9b, 0x2f, 0x62, 0x74, 0x5e, 0xfc, 0x5e, 0xa7, - 0x8f, 0xba, 0xc6, 0xeb, 0x48, 0xf9, 0x68, 0x5f, 0x16, 0xc3, 0x1f, 0xac, 0x20, 0xb4, 0x98, 0xd8, 0xec, 0xb1, 0x5f, - 0x8e, 0x16, 0xa7, 0x67, 0x69, 0x33, 0xec, 0x34, 0x6d, 0xb5, 0x71, 0x3b, 0xd8, 0x6f, 0x1d, 0xd2, 0x92, 0xc4, 0x8b, - 0xf1, 0x15, 0x2a, 0x7f, 0xc0, 0x43, 0xec, 0x39, 0x48, 0xd0, 0x88, 0x35, 0xe7, 0xb7, 0xc8, 0x75, 0xba, 0x16, 0x48, - 0x5d, 0xf8, 0x7a, 0xe8, 0x61, 0xd2, 0x22, 0xd5, 0x41, 0x59, 0x06, 0xba, 0x89, 0x02, 0xfa, 0x9e, 0xba, 0x2d, 0xc8, - 0x45, 0xf6, 0xf7, 0x9c, 0x9d, 0xbe, 0xc6, 0xfb, 0x73, 0x0b, 0x3b, 0x51, 0xf8, 0xcd, 0x1f, 0x93, 0x18, 0xd6, 0xdc, - 0x76, 0x91, 0x2d, 0x82, 0xde, 0x6c, 0x5a, 0x3e, 0x28, 0x07, 0x6c, 0x7e, 0x69, 0xa1, 0xca, 0xc8, 0x11, 0xeb, 0xf9, - 0x6f, 0xf7, 0x63, 0x97, 0x98, 0x57, 0x41, 0xa8, 0x5e, 0xa9, 0x2a, 0x31, 0x80, 0x3e, 0xa9, 0x3d, 0x03, 0x75, 0x66, - 0x76, 0x55, 0xe9, 0xf5, 0xeb, 0xac, 0x3e, 0xd4, 0xee, 0x02, 0xf7, 0x4e, 0xc3, 0xb3, 0x13, 0x6b, 0x25, 0x8b, 0xe8, - 0x23, 0x24, 0x61, 0x02, 0xfd, 0x7e, 0xd7, 0xb5, 0xaf, 0x7b, 0x3a, 0x96, 0x05, 0x94, 0x89, 0x3a, 0x5c, 0x9c, 0x20, - 0x18, 0x3f, 0xc8, 0x71, 0x80, 0x6d, 0xe4, 0xc7, 0x2e, 0x8b, 0xab, 0xfe, 0x1c, 0x28, 0x92, 0xa0, 0xb9, 0x96, 0xfb, - 0x35, 0xb8, 0xaf, 0xef, 0x74, 0x93, 0x15, 0xd9, 0x65, 0x98, 0x33, 0xde, 0x30, 0xc6, 0x08, 0x51, 0xc5, 0x22, 0x9e, - 0xe7, 0xb8, 0x81, 0xe5, 0x71, 0x09, 0xde, 0x58, 0xce, 0x3b, 0xa3, 0xda, 0xf2, 0x6c, 0x80, 0xa6, 0xb4, 0x62, 0x1b, - 0x95, 0x6a, 0x65, 0x0c, 0x0c, 0x64, 0xcb, 0x4e, 0xa6, 0xef, 0xa9, 0x2c, 0xc6, 0xfb, 0x77, 0x47, 0x04, 0x37, 0x3d, - 0xca, 0x7c, 0x7d, 0x10, 0xc6, 0xd0, 0xdc, 0xc3, 0xa0, 0x62, 0xb7, 0x4d, 0x39, 0x06, 0x17, 0x5c, 0x74, 0xa2, 0x26, - 0x35, 0x94, 0x45, 0xb5, 0x8c, 0x14, 0x5e, 0xcd, 0x8a, 0xbe, 0xee, 0x69, 0xf1, 0x5a, 0x84, 0x18, 0x94, 0xe1, 0xba, - 0x24, 0x21, 0x54, 0x26, 0x08, 0x7d, 0xa8, 0x30, 0xa5, 0xc2, 0xeb, 0x94, 0x80, 0xfd, 0x3d, 0xcf, 0x79, 0xdd, 0xfb, - 0x5d, 0x3b, 0x2c, 0xb3, 0xe4, 0xb8, 0xd7, 0x70, 0xbb, 0x82, 0xbb, 0x23, 0xcf, 0x46, 0x76, 0x6b, 0x64, 0xf2, 0xbe, - 0x56, 0x0c, 0xe9, 0xb6, 0x60, 0x2a, 0x2e, 0x8a, 0x68, 0x95, 0xc5, 0xb8, 0x1d, 0xf8, 0x95, 0xbb, 0x45, 0xb3, 0x9e, - 0x3a, 0x93, 0xf5, 0x86, 0x21, 0x7c, 0x1a, 0x96, 0xb1, 0x84, 0x58, 0xbd, 0x1e, 0xf9, 0x7f, 0x97, 0x85, 0x47, 0x45, - 0xbb, 0x4f, 0x28, 0xc4, 0xbd, 0xc9, 0x8c, 0x37, 0x03, 0x70, 0x90, 0x63, 0x88, 0x63, 0x70, 0xa0, 0xb5, 0xac, 0xd0, - 0xa9, 0x91, 0x80, 0x88, 0xb5, 0x25, 0x7f, 0xd3, 0x5b, 0xec, 0x2a, 0x7a, 0x6d, 0xdb, 0x77, 0x8e, 0x7f, 0xfe, 0xb6, - 0xda, 0xd6, 0x4d, 0x2c, 0xe4, 0x9d, 0x91, 0x41, 0x3d, 0xb0, 0xbf, 0xef, 0x88, 0x13, 0x6d, 0x81, 0xc0, 0xd5, 0x07, - 0xd3, 0x62, 0x7d, 0xbc, 0x10, 0x31, 0x3f, 0xf8, 0x18, 0x26, 0xf1, 0x14, 0x1d, 0x7d, 0xc6, 0xe7, 0x86, 0x8f, 0xc2, - 0x0f, 0xff, 0xb3, 0x1c, 0x58, 0x99, 0x74, 0x24, 0xa7, 0x8e, 0xa9, 0x8e, 0x02, 0x02, 0xe8, 0x4c, 0xee, 0x91, 0xef, - 0xbf, 0x3a, 0xb4, 0x54, 0xb1, 0x6c, 0x3a, 0x43, 0xb3, 0x93, 0x4e, 0xac, 0x5b, 0xcc, 0x06, 0x9f, 0x38, 0xf7, 0x8b, - 0xcb, 0x0f, 0xe9, 0xc9, 0x61, 0x7f, 0x7b, 0xd2, 0x68, 0xd3, 0x63, 0x46, 0x03, 0x60, 0x0c, 0x2b, 0xfd, 0x78, 0x90, - 0xd2, 0xeb, 0x27, 0x6a, 0xa2, 0x65, 0x43, 0x78, 0x66, 0x3c, 0xba, 0x0c, 0x91, 0xfe, 0xc3, 0xa0, 0x78, 0xd8, 0x6c, - 0xbd, 0x32, 0x5f, 0xb0, 0x9a, 0x83, 0xd1, 0x0b, 0x82, 0x66, 0xc3, 0x16, 0x8b, 0xca, 0xea, 0x71, 0x7e, 0x84, 0x59, - 0x50, 0x00, 0x3e, 0x65, 0x6d, 0x80, 0xfe, 0x39, 0xe6, 0x98, 0x0b, 0x88, 0x46, 0xa3, 0x36, 0x52, 0x6d, 0xf5, 0xbc, - 0xe2, 0x9f, 0xa9, 0x38, 0x50, 0xeb, 0x3d, 0x39, 0x66, 0x7b, 0xca, 0xea, 0x6a, 0x93, 0x4a, 0x03, 0xb4, 0xbe, 0x4c, - 0xf0, 0xb5, 0x0e, 0xb5, 0x04, 0x72, 0x56, 0xc0, 0x67, 0x96, 0x56, 0x97, 0xd9, 0x3d, 0xe7, 0xf8, 0xbd, 0x78, 0xf7, - 0xa0, 0x33, 0xee, 0x36, 0xdf, 0x6d, 0x06, 0x3b, 0x2b, 0x91, 0xdf, 0x0f, 0x1c, 0xb0, 0xf5, 0xce, 0xf1, 0xb2, 0x16, - 0x78, 0xbf, 0x85, 0x41, 0x00, 0xf2, 0x7e, 0x81, 0x5d, 0xd2, 0x38, 0x0d, 0xf3, 0x95, 0xb6, 0x94, 0xc6, 0xb8, 0x72, - 0xfc, 0x94, 0x33, 0xff, 0x3f, 0xd4, 0x58, 0x19, 0xc7, 0x4f, 0x6c, 0x80, 0x76, 0x15, 0x20, 0xc9, 0x01, 0xd1, 0xc1, - 0x93, 0x16, 0x8f, 0xdf, 0x08, 0x0a, 0xfd, 0x6f, 0xae, 0xf9, 0xf5, 0x86, 0x41, 0x6c, 0x7b, 0x84, 0xf0, 0x0b, 0x6d, - 0xd8, 0xfc, 0x4d, 0x67, 0xcd, 0x25, 0x44, 0x72, 0xfd, 0x1d, 0x29, 0xa9, 0xab, 0xe7, 0x91, 0xfb, 0x93, 0x06, 0xc0, - 0xa4, 0xb2, 0xfa, 0x3a, 0xed, 0xf9, 0xc2, 0xeb, 0x79, 0x07, 0xb1, 0x19, 0xc7, 0xef, 0x8e, 0x98, 0xf8, 0x50, 0x54, - 0xd5, 0x59, 0xd4, 0xb4, 0x3a, 0xf6, 0xd6, 0x49, 0x07, 0x3a, 0x71, 0x41, 0xf0, 0x18, 0xbf, 0x04, 0xfb, 0x79, 0xf3, - 0x43, 0x42, 0x1d, 0xbf, 0xeb, 0x87, 0xe4, 0x7a, 0x37, 0x85, 0x07, 0x76, 0xc0, 0xf7, 0xf0, 0xc1, 0xda, 0x44, 0xd3, - 0xb9, 0x10, 0x1f, 0x42, 0x52, 0x11, 0x90, 0xf5, 0x24, 0x4e, 0x6e, 0x4a, 0x92, 0x60, 0xc3, 0x5e, 0xd6, 0xb6, 0x82, - 0xc3, 0xb9, 0x76, 0x87, 0x22, 0x9c, 0x46, 0x07, 0xdd, 0x0c, 0x8f, 0x38, 0xe3, 0xa4, 0x6e, 0x65, 0xea, 0xb3, 0x6d, - 0x10, 0x89, 0x91, 0x70, 0x05, 0x04, 0x9f, 0x08, 0x1e, 0x8c, 0x98, 0x1a, 0x20, 0xa9, 0x08, 0x70, 0xfd, 0xb0, 0x8d, - 0x50, 0x76, 0x3f, 0xe5, 0x27, 0x7c, 0x12, 0x43, 0x0e, 0x39, 0xac, 0xc3, 0xf3, 0xe7, 0x70, 0xd1, 0x50, 0x2c, 0xce, - 0x1c, 0x67, 0x5e, 0x94, 0xd5, 0xb4, 0x50, 0x9c, 0x58, 0xf9, 0x82, 0x07, 0x5c, 0x6f, 0xc0, 0xbc, 0x9d, 0x0a, 0x76, - 0xc6, 0x33, 0x5e, 0x61, 0x4a, 0x4c, 0x6f, 0x77, 0xce, 0x2b, 0x5d, 0xb9, 0x55, 0x14, 0xaf, 0x1a, 0xb4, 0x67, 0x46, - 0x5c, 0xf8, 0x3b, 0xad, 0x8d, 0x6e, 0xd9, 0xa5, 0x71, 0xf8, 0x37, 0x4a, 0x24, 0x04, 0x9b, 0x9f, 0x78, 0xe3, 0x3d, - 0xb4, 0x6b, 0xdf, 0x05, 0x87, 0x59, 0x7e, 0xfb, 0x1a, 0xfd, 0xe9, 0x4d, 0xcf, 0xb0, 0x28, 0xbd, 0x9f, 0x99, 0x83, - 0xea, 0x40, 0x56, 0x57, 0x87, 0x03, 0x0c, 0xda, 0xe1, 0x8e, 0x57, 0x90, 0x6e, 0xc5, 0x2c, 0x43, 0xa4, 0x33, 0x19, - 0xfd, 0xdd, 0x8b, 0x79, 0xc1, 0x3a, 0x04, 0x66, 0x1f, 0x0d, 0x73, 0x02, 0x17, 0xab, 0x0c, 0x0a, 0xa1, 0x0a, 0x21, - 0x7c, 0x1c, 0xe6, 0x8a, 0x9c, 0x06, 0x52, 0xe1, 0x8a, 0x9c, 0xfa, 0xa4, 0x83, 0x72, 0x1d, 0x3a, 0x5f, 0xad, 0x71, - 0x3c, 0xc5, 0x84, 0xbe, 0x18, 0x78, 0xa8, 0xaf, 0xd8, 0x2c, 0x3e, 0xf7, 0x42, 0x64, 0xfd, 0x0d, 0x98, 0xdc, 0xe0, - 0x65, 0x75, 0x9f, 0x85, 0x10, 0xb3, 0x70, 0x99, 0x19, 0xa9, 0x5f, 0x8a, 0x5a, 0x4f, 0xa3, 0x11, 0xa0, 0xd6, 0x3c, - 0xa0, 0x55, 0xcb, 0x10, 0x61, 0xfc, 0x25, 0xb4, 0xf4, 0x7b, 0xed, 0xe0, 0x86, 0x5f, 0xc5, 0x34, 0x1c, 0xc3, 0xfc, - 0x47, 0x11, 0x7a, 0x88, 0x01, 0x97, 0x71, 0x4d, 0xad, 0x5c, 0x8d, 0x06, 0xb9, 0x62, 0x7c, 0x01, 0x90, 0x32, 0x18, - 0x60, 0xac, 0x59, 0x28, 0x9e, 0x7f, 0xc7, 0x1f, 0x82, 0x08, 0xf5, 0x6a, 0x1f, 0xfb, 0xd1, 0x0d, 0x31, 0xa6, 0x36, - 0x3e, 0x26, 0x38, 0xf8, 0xd8, 0x5a, 0x69, 0xdf, 0x74, 0x95, 0x35, 0xc2, 0x09, 0xb4, 0xe0, 0xca, 0x3c, 0x88, 0x0f, - 0xa7, 0x36, 0xff, 0x2f, 0xc5, 0xaa, 0x1e, 0xbb, 0xfb, 0xfb, 0x23, 0x5c, 0x0f, 0x9d, 0x72, 0x90, 0x57, 0xb8, 0x00, - 0x2e, 0xbb, 0xea, 0x9c, 0x57, 0xbe, 0xb2, 0x4c, 0xfe, 0x16, 0x0e, 0x96, 0x0f, 0xca, 0x71, 0x3a, 0xfd, 0xcb, 0xb5, - 0x8b, 0xa3, 0x3d, 0x98, 0x4f, 0xd3, 0x30, 0xfe, 0x49, 0x2c, 0x7d, 0x5e, 0xd0, 0xd9, 0x6f, 0x48, 0x1b, 0x3f, 0x2e, - 0xb2, 0x7d, 0xe8, 0xba, 0x3c, 0x7f, 0x8d, 0xb7, 0xe7, 0x76, 0x4d, 0x9b, 0xce, 0xf7, 0x3f, 0xa5, 0xb3, 0x71, 0xcf, - 0xf8, 0x6f, 0xf4, 0x44, 0x27, 0xdf, 0x18, 0x7f, 0x48, 0x6b, 0xe3, 0xd3, 0x20, 0xbe, 0x6c, 0x0b, 0xb2, 0x87, 0x73, - 0x78, 0x1a, 0xce, 0x17, 0x94, 0x5f, 0x64, 0x71, 0xd1, 0x9f, 0xbe, 0xc6, 0x8b, 0x73, 0xcf, 0xcb, 0xb5, 0xd6, 0x7c, - 0x6a, 0x6d, 0xc0, 0xd6, 0x02, 0xe7, 0x46, 0xed, 0x96, 0x49, 0xaa, 0x56, 0xde, 0x88, 0xe9, 0x6c, 0x1a, 0x51, 0x07, - 0xfb, 0x7d, 0x7b, 0xdc, 0xf1, 0x40, 0xff, 0xb3, 0x79, 0x5d, 0x71, 0x6d, 0xd5, 0x4d, 0x77, 0x56, 0xe0, 0x0d, 0x93, - 0xa5, 0x23, 0x3c, 0x2b, 0x88, 0x34, 0xd2, 0x07, 0xa4, 0x65, 0x6d, 0xdb, 0x12, 0x43, 0xbb, 0x59, 0xc9, 0x34, 0x71, - 0x5b, 0x33, 0x5c, 0xe2, 0x4c, 0x08, 0x10, 0x49, 0xa6, 0x18, 0xba, 0xd6, 0x0c, 0x90, 0xde, 0x41, 0x49, 0x88, 0x65, - 0xbf, 0x04, 0x8a, 0x25, 0x83, 0x4f, 0xff, 0x61, 0x45, 0x4c, 0x8e, 0x37, 0x74, 0x70, 0x2a, 0x68, 0xf6, 0xd8, 0x8e, - 0xb9, 0x08, 0xc2, 0x97, 0x28, 0xf4, 0x4c, 0x63, 0x27, 0x57, 0x6d, 0x8e, 0x9e, 0xd8, 0x09, 0x6b, 0x1a, 0x05, 0x55, - 0xbb, 0xdf, 0xde, 0x2a, 0x15, 0x37, 0x57, 0x9c, 0xcf, 0x60, 0x8c, 0x27, 0x1d, 0x41, 0xe4, 0xcf, 0xfe, 0x02, 0xca, - 0xd0, 0x25, 0x8c, 0xb2, 0x65, 0xde, 0x8f, 0x26, 0xb7, 0x52, 0xc7, 0x92, 0xd0, 0xd4, 0xf5, 0xea, 0x8a, 0x54, 0xe1, - 0xfe, 0x2e, 0xfc, 0xb3, 0x06, 0x71, 0x87, 0x38, 0x87, 0x64, 0x01, 0x51, 0x3d, 0x63, 0x25, 0xc5, 0x20, 0x66, 0x36, - 0x28, 0x61, 0x4a, 0x9f, 0xb4, 0xda, 0x6a, 0x9d, 0x1c, 0x7b, 0x5c, 0xae, 0xea, 0x42, 0xd6, 0x2d, 0x7f, 0xa4, 0x45, - 0x22, 0x2d, 0x70, 0x85, 0xef, 0x2c, 0x00, 0x5d, 0x09, 0xe0, 0x29, 0x04, 0x72, 0x98, 0x84, 0xbf, 0x95, 0x55, 0xf4, - 0xe0, 0xfe, 0x6d, 0x98, 0x5b, 0x8e, 0x40, 0xc2, 0x87, 0xb9, 0x69, 0x8d, 0x3a, 0x8d, 0x4c, 0x6b, 0xd8, 0xba, 0x04, - 0xe2, 0x24, 0x41, 0x0b, 0x35, 0xf6, 0x71, 0x28, 0x1c, 0x7a, 0x1e, 0xb9, 0x49, 0xae, 0xe5, 0xca, 0x97, 0xa2, 0x39, - 0x89, 0x3d, 0x52, 0xd1, 0xb1, 0x9f, 0x91, 0xe3, 0xbc, 0x10, 0xe4, 0xe2, 0x48, 0x9a, 0x9e, 0x6a, 0x92, 0x43, 0x9b, - 0x0c, 0x2a, 0x94, 0xdb, 0x2c, 0x68, 0x73, 0x1b, 0xb1, 0xbf, 0x8e, 0x88, 0x0b, 0x1b, 0x40, 0x22, 0x9c, 0x5c, 0x55, - 0xfd, 0x2d, 0xb9, 0xbe, 0x6e, 0x7c, 0x55, 0x0b, 0x19, 0x0f, 0x28, 0x19, 0x4e, 0xea, 0xed, 0x19, 0x0a, 0xc3, 0xc5, - 0xfc, 0xb4, 0xbe, 0xb0, 0xd6, 0xd4, 0x6e, 0xa5, 0x48, 0x0a, 0x43, 0x9a, 0xf2, 0x44, 0xe2, 0x87, 0x65, 0x77, 0xb1, - 0x49, 0xc5, 0x8a, 0xc0, 0xfb, 0x9c, 0xf9, 0x73, 0xe1, 0xd4, 0x1a, 0xff, 0x21, 0xc0, 0xad, 0x39, 0x38, 0xa8, 0xbf, - 0x8b, 0xdc, 0x64, 0xab, 0x1e, 0x38, 0x4d, 0x7e, 0x74, 0x45, 0x3f, 0x8b, 0x62, 0xdc, 0x83, 0x41, 0x9e, 0xb3, 0x46, - 0x1c, 0x27, 0x5e, 0xa1, 0xc8, 0xa6, 0x12, 0xba, 0xdb, 0x75, 0xa6, 0x88, 0xeb, 0x90, 0xa3, 0x19, 0x72, 0x72, 0x38, - 0x4e, 0x5a, 0xcd, 0xa3, 0xb2, 0x49, 0x12, 0x9e, 0xe2, 0x47, 0xee, 0x13, 0x8a, 0x5d, 0x9f, 0x85, 0x32, 0x23, 0xce, - 0x19, 0x67, 0xdb, 0x0b, 0xae, 0xd1, 0x5b, 0x73, 0x90, 0x8e, 0x1d, 0xf6, 0xfc, 0x89, 0x22, 0x4c, 0x21, 0x65, 0xa7, - 0x26, 0x6d, 0xd2, 0x55, 0x97, 0x71, 0x9f, 0x0e, 0x75, 0x1c, 0x52, 0x3d, 0x3b, 0x1c, 0xea, 0xa5, 0x2d, 0x4f, 0x1c, - 0xe2, 0xca, 0x87, 0xfe, 0x38, 0xf2, 0xeb, 0xc2, 0x7a, 0x51, 0xc8, 0xf8, 0xa4, 0xd0, 0x49, 0x4b, 0x95, 0x78, 0x00, - 0xb7, 0x95, 0x4d, 0x6f, 0xcb, 0xd4, 0xda, 0xd0, 0x71, 0xe9, 0x6f, 0x02, 0xa4, 0x90, 0xc5, 0xa9, 0x5c, 0x0a, 0xe5, - 0x9a, 0xf1, 0xe2, 0xb0, 0xe2, 0xf6, 0xd5, 0x7d, 0xda, 0x57, 0x14, 0x1d, 0x20, 0x10, 0x11, 0x5a, 0x01, 0xc2, 0x17, - 0x26, 0x70, 0x75, 0x95, 0xa5, 0xb0, 0x8e, 0x09, 0xc1, 0x53, 0xf8, 0x46, 0x6a, 0xa5, 0x55, 0x46, 0xc4, 0x05, 0xdb, - 0x8d, 0x50, 0xf6, 0x00, 0x1a, 0x10, 0xc3, 0x49, 0xfc, 0x2f, 0x4f, 0x55, 0xcb, 0xb4, 0x5b, 0xc9, 0xa5, 0x91, 0x76, - 0xa3, 0x2d, 0xde, 0x98, 0x56, 0x14, 0x14, 0x13, 0x92, 0xbe, 0xd2, 0xa0, 0xd5, 0xb1, 0xf5, 0x9b, 0xbd, 0x5e, 0xbc, - 0x3a, 0xbe, 0xe3, 0xe4, 0x60, 0x94, 0x63, 0xc9, 0x20, 0x53, 0x11, 0xca, 0xc5, 0x45, 0xd8, 0x7a, 0xd8, 0xd9, 0x16, - 0xda, 0x69, 0xd0, 0x71, 0xb7, 0x82, 0x1a, 0x84, 0xf9, 0xd0, 0x73, 0xa7, 0xdb, 0x3e, 0x5d, 0x19, 0xb7, 0x8b, 0x78, - 0x95, 0xe3, 0x54, 0x55, 0x09, 0xa4, 0x64, 0xf3, 0x31, 0x48, 0x95, 0x24, 0x47, 0xa6, 0x0a, 0xeb, 0x1e, 0x6c, 0xef, - 0x98, 0x30, 0x09, 0x79, 0xe4, 0x7d, 0xf8, 0x27, 0x84, 0x5a, 0x8a, 0x7e, 0xdb, 0xf6, 0x6d, 0xc9, 0xe1, 0x95, 0xa3, - 0x55, 0x83, 0x80, 0xd8, 0x88, 0x00, 0x35, 0x8f, 0x8f, 0xf6, 0x26, 0x6e, 0xbd, 0xa3, 0x72, 0x37, 0x35, 0x7e, 0xcf, - 0x56, 0x76, 0x1e, 0xf9, 0x1d, 0xaf, 0xec, 0xe3, 0x42, 0x15, 0xec, 0x92, 0x12, 0x3d, 0xc9, 0xfa, 0xf1, 0xca, 0xa6, - 0x35, 0xfb, 0x79, 0x7d, 0x41, 0xc8, 0xe6, 0x55, 0xf6, 0xc8, 0xab, 0x42, 0xbd, 0x18, 0x09, 0x63, 0xaa, 0x43, 0x78, - 0xe3, 0xc8, 0xd8, 0x9f, 0x17, 0x32, 0x8d, 0x81, 0x05, 0x28, 0xb4, 0xd4, 0xbb, 0x11, 0x4f, 0x8f, 0x65, 0x56, 0xa4, - 0x75, 0x27, 0x5c, 0xc5, 0x7a, 0x09, 0x3f, 0xba, 0x0d, 0x58, 0x58, 0x29, 0xdd, 0x22, 0x97, 0x77, 0x75, 0x91, 0xf5, - 0xd9, 0x6b, 0x13, 0x43, 0xef, 0x92, 0x42, 0x85, 0xb2, 0x63, 0xca, 0x8a, 0xf9, 0x0a, 0x69, 0x8e, 0x05, 0x6f, 0x42, - 0xfd, 0xbc, 0x2d, 0x7f, 0x87, 0x2a, 0x16, 0x7f, 0x5d, 0xd1, 0x5b, 0xa7, 0x6a, 0xb6, 0xcf, 0x14, 0x33, 0x65, 0x3b, - 0x17, 0xee, 0x8b, 0xfb, 0x8d, 0x6f, 0x88, 0xa7, 0x62, 0xd5, 0x77, 0x45, 0x71, 0xe4, 0xa0, 0xc9, 0x20, 0xaa, 0x93, - 0xb5, 0x10, 0x77, 0x5d, 0x19, 0x92, 0x70, 0xe7, 0x09, 0x33, 0x48, 0xe7, 0xb0, 0x71, 0x55, 0x23, 0xd3, 0xa0, 0xe6, - 0x40, 0x9d, 0x54, 0x83, 0x15, 0xb4, 0x42, 0x52, 0xf6, 0x14, 0x33, 0x51, 0x07, 0xee, 0xf7, 0x9a, 0xfd, 0x3f, 0xa0, - 0x12, 0x7d, 0xdd, 0xf5, 0x57, 0x7d, 0x0b, 0xe7, 0x82, 0x05, 0x4b, 0x2a, 0xfb, 0x72, 0x5b, 0x6f, 0xfc, 0x29, 0x6c, - 0xea, 0xd4, 0xad, 0xbb, 0x5d, 0xea, 0x72, 0x9a, 0x0d, 0xce, 0x3b, 0x47, 0x31, 0x77, 0x0a, 0x1f, 0x62, 0x2e, 0x2f, - 0xd9, 0x44, 0x25, 0x57, 0x71, 0xe2, 0x45, 0x0d, 0x00, 0xf3, 0x0e, 0x90, 0x9c, 0x29, 0x61, 0x94, 0xf8, 0x73, 0x52, - 0x01, 0xd5, 0x94, 0xae, 0xb3, 0xb3, 0xee, 0x17, 0x7b, 0xfe, 0x8a, 0xbc, 0xbe, 0x72, 0x0c, 0xea, 0xe6, 0xbc, 0x20, - 0xa7, 0x98, 0x5f, 0x34, 0x25, 0x63, 0x4f, 0xb7, 0xad, 0xaa, 0x93, 0xb5, 0xcb, 0x8b, 0xda, 0x44, 0x89, 0x74, 0xc9, - 0x0d, 0x2f, 0xf5, 0xb6, 0xbc, 0x66, 0xcb, 0x93, 0x75, 0x7a, 0x2a, 0xd6, 0xd8, 0xbe, 0x08, 0x63, 0x7d, 0x18, 0x5d, - 0xe9, 0x49, 0x07, 0x39, 0x2d, 0x4b, 0x4b, 0xb9, 0x8b, 0x9c, 0x5b, 0xba, 0x5d, 0x3a, 0xcc, 0x8f, 0x19, 0x6b, 0x6f, - 0x8d, 0x8d, 0xad, 0xe5, 0xe6, 0xbf, 0xae, 0x6c, 0xc3, 0x54, 0xa1, 0x68, 0x01, 0x4c, 0xcf, 0x26, 0x87, 0xf5, 0x01, - 0x35, 0x53, 0x6f, 0x51, 0xbb, 0xe2, 0xf5, 0x4e, 0xf4, 0xbc, 0xfb, 0x0e, 0x6a, 0x86, 0x5a, 0x8f, 0x92, 0x68, 0xa9, - 0x7d, 0xef, 0x5b, 0x4a, 0x5b, 0xe6, 0xb1, 0xf2, 0xa2, 0xd4, 0x43, 0xfd, 0xea, 0x8f, 0xd3, 0xda, 0xb8, 0x27, 0xbc, - 0x65, 0xa3, 0xae, 0xe2, 0x63, 0x9f, 0xe7, 0xc2, 0xcc, 0x2c, 0x3e, 0x97, 0xd6, 0x83, 0x5f, 0x4e, 0xbb, 0x99, 0x39, - 0x3d, 0xbe, 0xa7, 0x83, 0xc4, 0x5c, 0x7a, 0x2f, 0x43, 0xa0, 0x68, 0x85, 0x66, 0x1d, 0x35, 0xcc, 0x79, 0x9f, 0x3a, - 0xc6, 0xcf, 0x7b, 0x4c, 0xc9, 0x1d, 0x3f, 0xe3, 0xf5, 0xd0, 0xa6, 0x9f, 0x3e, 0x66, 0xce, 0x87, 0x89, 0xf0, 0x6a, - 0x57, 0xa3, 0x13, 0x56, 0xe0, 0xeb, 0xa5, 0xc7, 0xc9, 0xa7, 0xbd, 0xaa, 0x5a, 0x5a, 0xdf, 0x7f, 0x6b, 0x62, 0x80, - 0xa9, 0x52, 0x7e, 0x45, 0xfb, 0xb9, 0xc6, 0x62, 0x86, 0x97, 0x74, 0xd9, 0xcb, 0x00}; + 0x1b, 0x4d, 0x98, 0xa3, 0x10, 0xd8, 0x38, 0x00, 0x40, 0x74, 0x87, 0x5d, 0x14, 0x95, 0xac, 0x9e, 0x80, 0x5a, 0x16, + 0xd8, 0x44, 0x44, 0xed, 0xc1, 0xec, 0xbf, 0x23, 0x3c, 0x6d, 0x9a, 0x66, 0x3e, 0x6c, 0xc2, 0x28, 0x09, 0x3c, 0x6e, + 0x5f, 0x78, 0xfd, 0x3f, 0x03, 0xf2, 0xf9, 0xd8, 0x63, 0x23, 0xd6, 0x47, 0x4b, 0xb8, 0x52, 0xbd, 0x19, 0x5a, 0x7e, + 0xdb, 0x62, 0xfe, 0x45, 0xae, 0x2e, 0x1e, 0x2d, 0x75, 0x91, 0x2c, 0x66, 0xae, 0xa8, 0x95, 0x97, 0xe5, 0x64, 0x1c, + 0xa1, 0xb1, 0x4f, 0x72, 0x37, 0x53, 0xad, 0xd7, 0x77, 0xc5, 0xf3, 0xe4, 0xa0, 0x14, 0x9b, 0xb4, 0xb7, 0x34, 0x79, + 0x53, 0x5a, 0x99, 0x3d, 0x9b, 0xa1, 0x10, 0x13, 0x89, 0x05, 0x2a, 0x24, 0x94, 0xa6, 0xe4, 0xff, 0xb9, 0xff, 0x75, + 0xd9, 0xfb, 0x75, 0xa6, 0xa9, 0x98, 0x1b, 0x1f, 0x5b, 0x7a, 0x5c, 0x81, 0xb1, 0xb3, 0x0a, 0xae, 0xc9, 0xb2, 0xec, + 0x1e, 0x07, 0x18, 0x23, 0x63, 0x4e, 0x30, 0xf8, 0x20, 0x79, 0x99, 0x27, 0x6e, 0xfa, 0xe2, 0x57, 0x65, 0x8a, 0xea, + 0x5b, 0x96, 0x99, 0xfe, 0xf3, 0x79, 0x49, 0xca, 0x42, 0xe8, 0x32, 0x2b, 0xe3, 0xe3, 0xd9, 0xb3, 0x25, 0xe6, 0xbc, + 0x95, 0xbc, 0x08, 0x22, 0xcb, 0xac, 0xb8, 0x36, 0x11, 0x41, 0x34, 0xc4, 0xb6, 0x9d, 0x84, 0x6a, 0x33, 0x6d, 0x59, + 0xb5, 0x0e, 0xb0, 0xb4, 0x63, 0x81, 0x75, 0xba, 0x40, 0x8f, 0x35, 0x96, 0x4f, 0x21, 0x76, 0xb0, 0x49, 0x5c, 0x24, + 0xdf, 0x39, 0xe5, 0x80, 0xb5, 0x79, 0x5b, 0x9a, 0xb4, 0x82, 0x0b, 0x5c, 0xab, 0x35, 0x95, 0x7d, 0xa0, 0x4c, 0x23, + 0x72, 0x77, 0x0f, 0x25, 0x36, 0x0e, 0xd8, 0x95, 0x65, 0x67, 0xff, 0xde, 0x54, 0xab, 0xb4, 0x01, 0x9a, 0xb3, 0x36, + 0x35, 0x2e, 0x35, 0x4e, 0x19, 0xc4, 0x95, 0xce, 0xf9, 0x24, 0xb8, 0x20, 0x64, 0xbf, 0xf7, 0xfe, 0x7f, 0xcb, 0x76, + 0x18, 0xa2, 0xbb, 0x89, 0x1d, 0x38, 0x8d, 0xe8, 0xb0, 0xf4, 0x35, 0xb4, 0x72, 0x9c, 0xf9, 0xff, 0x77, 0x03, 0xec, + 0x06, 0x28, 0x2d, 0x40, 0x51, 0x5b, 0x24, 0x87, 0x5b, 0x25, 0xb7, 0xc6, 0x6b, 0xe6, 0xac, 0xd3, 0x4c, 0xd5, 0x69, + 0xce, 0xd9, 0xc8, 0x46, 0x89, 0xf1, 0xd9, 0x55, 0x6e, 0xa3, 0xd0, 0xd8, 0xf4, 0xb2, 0x0b, 0x2f, 0x3d, 0x9d, 0x63, + 0x9b, 0xa9, 0x66, 0x65, 0x06, 0x9c, 0xbf, 0x4d, 0x72, 0xd6, 0x90, 0x03, 0xc2, 0x41, 0xca, 0x7d, 0xd8, 0x75, 0x91, + 0x65, 0x59, 0x72, 0x91, 0x0b, 0x9b, 0x37, 0x91, 0xcd, 0x94, 0x37, 0xa3, 0x12, 0x2a, 0xa9, 0x25, 0xac, 0xdc, 0xc4, + 0xf4, 0xc4, 0xbd, 0x7f, 0x17, 0x64, 0x62, 0x6c, 0x71, 0x00, 0x3e, 0x05, 0x71, 0xe8, 0xc8, 0xa6, 0xeb, 0x1c, 0xe7, + 0x18, 0xbd, 0x61, 0x21, 0x98, 0x66, 0x7b, 0x08, 0x0e, 0x7d, 0x38, 0xb0, 0x01, 0x8d, 0x3c, 0xcf, 0x1d, 0x5e, 0x7d, + 0x60, 0xeb, 0x7e, 0xf9, 0x68, 0x18, 0xf4, 0x78, 0xb3, 0x2a, 0x01, 0xb7, 0x91, 0x43, 0xab, 0x65, 0x64, 0x23, 0x50, + 0xcd, 0x6a, 0xec, 0xe7, 0xf0, 0x67, 0xf3, 0x7f, 0x5f, 0x9c, 0x1c, 0x55, 0x14, 0x4c, 0xff, 0x84, 0xbe, 0xbd, 0xef, + 0xf0, 0x0a, 0xe9, 0x92, 0xb5, 0x05, 0xec, 0x67, 0x14, 0xe5, 0x61, 0xe4, 0xa1, 0x0a, 0x5e, 0x7b, 0xd9, 0x4d, 0x97, + 0x39, 0x9e, 0x19, 0x33, 0x36, 0x5d, 0x70, 0x3d, 0x30, 0x73, 0x65, 0xe5, 0x78, 0xb4, 0xa5, 0x1a, 0x9c, 0x67, 0x0a, + 0x0d, 0xda, 0x74, 0xa3, 0xe5, 0xf4, 0x21, 0x1e, 0x3f, 0x4c, 0x29, 0x3d, 0xf7, 0x93, 0xad, 0x2b, 0xe3, 0xbe, 0xb2, + 0x24, 0x6b, 0x3a, 0xfc, 0x39, 0xe7, 0xab, 0x09, 0x91, 0x84, 0x95, 0x9e, 0x46, 0xa4, 0x5c, 0x1d, 0x75, 0xdc, 0xb2, + 0x8c, 0xb2, 0xf4, 0xe6, 0x77, 0x94, 0x69, 0xd2, 0x96, 0x8a, 0x14, 0x88, 0x37, 0xd7, 0xf9, 0xc3, 0x64, 0xcc, 0xab, + 0xa6, 0x3e, 0x3e, 0x1e, 0x6f, 0x7b, 0x37, 0xb4, 0x6c, 0x78, 0x8e, 0x66, 0x3d, 0x98, 0xba, 0x7d, 0xf3, 0x59, 0x1a, + 0x37, 0xe3, 0xe6, 0x42, 0xb6, 0x36, 0xfd, 0x75, 0x1e, 0x3e, 0x85, 0xeb, 0x66, 0xec, 0x96, 0xe5, 0xa1, 0xc3, 0xcd, + 0x5c, 0x51, 0xb2, 0x7a, 0x68, 0x77, 0xd0, 0xe7, 0xaa, 0x4b, 0xb8, 0xe9, 0xe5, 0x22, 0xcc, 0xd7, 0x63, 0x6c, 0x52, + 0xd8, 0xae, 0x5a, 0xbe, 0x4e, 0x57, 0x28, 0x32, 0x13, 0x85, 0x8a, 0xff, 0x08, 0x65, 0xe5, 0xe5, 0x95, 0x9e, 0x8c, + 0x63, 0x3f, 0x17, 0x5c, 0x47, 0x58, 0x4d, 0x2d, 0x92, 0x70, 0xfb, 0xa7, 0xb9, 0x9f, 0x41, 0x01, 0xf9, 0xb7, 0xa6, + 0xa2, 0x30, 0x95, 0xf6, 0x73, 0x10, 0xf9, 0x28, 0x93, 0x31, 0x0c, 0x01, 0x8a, 0x4c, 0x47, 0x00, 0x9e, 0x79, 0x42, + 0x9e, 0x8e, 0xe7, 0x18, 0x25, 0x06, 0xde, 0xef, 0xf8, 0x7a, 0x79, 0xb4, 0x30, 0xdc, 0x08, 0xd2, 0x7e, 0xee, 0xa3, + 0x24, 0x57, 0x37, 0x28, 0x40, 0x76, 0x76, 0x0a, 0x92, 0x27, 0x05, 0x26, 0xd9, 0x35, 0xcb, 0x51, 0x26, 0xc8, 0x9b, + 0x8e, 0x43, 0x49, 0x43, 0x5f, 0xe3, 0x25, 0x29, 0xfe, 0xce, 0x27, 0x15, 0x2a, 0xc5, 0xdf, 0xba, 0x6b, 0x93, 0xa7, + 0x06, 0x00, 0x21, 0x9d, 0x66, 0xba, 0xde, 0xf8, 0x41, 0x90, 0xd8, 0x2d, 0x25, 0xa0, 0xe1, 0x8a, 0x99, 0x6c, 0x0a, + 0xeb, 0xbe, 0x36, 0x75, 0xba, 0x77, 0x29, 0x73, 0x78, 0x3c, 0xd3, 0x80, 0xc8, 0x8c, 0xd0, 0x70, 0x6a, 0x44, 0xd0, + 0x30, 0xca, 0x7b, 0x84, 0xdb, 0x54, 0x31, 0x7c, 0xc0, 0xe9, 0x87, 0x1b, 0x62, 0x59, 0xc0, 0x54, 0x08, 0xb4, 0xf6, + 0x36, 0x36, 0x9a, 0xaf, 0xa4, 0x21, 0x16, 0x93, 0x3f, 0xe3, 0x96, 0x36, 0xfe, 0x55, 0xd5, 0x84, 0x06, 0x48, 0x82, + 0xcf, 0xcf, 0xda, 0x21, 0x61, 0x8c, 0x26, 0x75, 0xb1, 0x49, 0x7a, 0x42, 0xca, 0x1c, 0x48, 0x20, 0xa1, 0x86, 0x4c, + 0xe1, 0x9c, 0x4d, 0x2e, 0xc7, 0x3b, 0xde, 0x3e, 0x08, 0x47, 0x6b, 0x4b, 0x62, 0x79, 0x23, 0x49, 0xa9, 0x86, 0xb7, + 0x86, 0x1a, 0x8e, 0x6d, 0xaa, 0x47, 0xcc, 0x4f, 0x71, 0xb7, 0xa7, 0x35, 0x8e, 0x25, 0x7d, 0x6e, 0x86, 0x4b, 0xb9, + 0x9b, 0xaf, 0xfa, 0x85, 0x60, 0x57, 0x12, 0x4d, 0x2a, 0x51, 0x85, 0x9f, 0x7f, 0xfd, 0x1d, 0xc8, 0x5a, 0x2d, 0x0f, + 0x53, 0xfc, 0x1c, 0xf8, 0x71, 0xac, 0x4c, 0x61, 0x3d, 0x48, 0x2f, 0xd5, 0xe9, 0x4d, 0x6d, 0xde, 0x91, 0x41, 0x2a, + 0xdc, 0x4a, 0xb0, 0xbf, 0x19, 0x21, 0x62, 0x87, 0x99, 0xf8, 0xd7, 0x8d, 0x84, 0x44, 0xd2, 0x85, 0xc2, 0x39, 0xab, + 0xe1, 0xe2, 0x3f, 0xa4, 0x0c, 0xd1, 0x9c, 0x10, 0x0d, 0x13, 0xc6, 0x57, 0x46, 0x29, 0x48, 0xef, 0xe6, 0xab, 0x4d, + 0xd3, 0x86, 0xee, 0x88, 0x3f, 0xa8, 0x57, 0xb9, 0x8e, 0xb1, 0x21, 0xf2, 0x55, 0x22, 0x79, 0xf6, 0x38, 0x0c, 0xe3, + 0x60, 0x39, 0xc1, 0x53, 0x95, 0x11, 0xfe, 0x75, 0xaa, 0x0a, 0xee, 0x39, 0x9a, 0x55, 0xce, 0xdd, 0x17, 0xb9, 0xe6, + 0x5b, 0x50, 0xe3, 0xf3, 0x66, 0x6e, 0x86, 0xca, 0x68, 0xeb, 0x58, 0x4b, 0x06, 0xc9, 0x95, 0x65, 0x57, 0x80, 0x8d, + 0xe9, 0x28, 0x8e, 0x2c, 0x5a, 0x60, 0x6c, 0xf6, 0xd7, 0x30, 0xfd, 0x4f, 0xd0, 0x09, 0x91, 0xb6, 0x97, 0x12, 0x6a, + 0x65, 0xc6, 0x0f, 0x46, 0xa8, 0xb7, 0xb2, 0xf3, 0x29, 0x8b, 0x20, 0xc3, 0xf7, 0xac, 0xe5, 0x39, 0x9c, 0xc7, 0xe1, + 0xe2, 0x49, 0xa9, 0xfd, 0x22, 0x5c, 0x76, 0xbb, 0x55, 0xa2, 0xb8, 0xd5, 0x08, 0x89, 0x0d, 0xd7, 0x3f, 0x51, 0xad, + 0x15, 0x0c, 0x57, 0x54, 0x5c, 0x6b, 0xad, 0xf5, 0x31, 0x76, 0x29, 0xa5, 0x6c, 0x4c, 0x4f, 0xff, 0x59, 0x4a, 0x7d, + 0xb5, 0x94, 0x94, 0x21, 0x26, 0xef, 0x09, 0x75, 0xc7, 0x49, 0x15, 0x0c, 0x0b, 0xcf, 0xdc, 0x52, 0x71, 0x26, 0x51, + 0x09, 0x06, 0x46, 0xe5, 0xb4, 0x3f, 0x78, 0xbe, 0xeb, 0x5d, 0xb0, 0x04, 0x88, 0xb7, 0xc8, 0xf5, 0xbf, 0x15, 0x05, + 0x2b, 0xe6, 0x56, 0x00, 0x4e, 0xcc, 0x82, 0x84, 0x2b, 0x3a, 0x18, 0x24, 0xd4, 0x1b, 0x98, 0xc9, 0x35, 0xa2, 0x77, + 0x82, 0xf5, 0x22, 0xb7, 0xfa, 0x25, 0x0a, 0xb9, 0x4d, 0x49, 0x45, 0x02, 0xb7, 0xd5, 0xda, 0x30, 0xcd, 0x7a, 0xa6, + 0x99, 0x38, 0x3d, 0x67, 0x31, 0x65, 0x76, 0xd4, 0x5c, 0xbb, 0xba, 0x04, 0xb3, 0xbb, 0x3b, 0x3d, 0x33, 0xf4, 0xc3, + 0x32, 0x44, 0xdf, 0xcd, 0xbe, 0x26, 0x49, 0x0c, 0xa9, 0x6c, 0xc3, 0xda, 0xf4, 0xff, 0x78, 0xda, 0x09, 0x05, 0x7f, + 0xad, 0xd5, 0x0d, 0xc4, 0xbd, 0x88, 0x4e, 0x5d, 0x4e, 0x84, 0xf9, 0xfa, 0x62, 0x60, 0x3f, 0x29, 0x67, 0xb8, 0x46, + 0x3c, 0xb2, 0xb1, 0x37, 0xd4, 0x5b, 0x23, 0x5a, 0x86, 0xe4, 0xf3, 0x7e, 0x95, 0xf6, 0x0d, 0x65, 0x66, 0x5f, 0xec, + 0x87, 0x8b, 0x77, 0xda, 0x4c, 0x0e, 0xb8, 0xd3, 0xd0, 0xf3, 0xa6, 0xf9, 0x06, 0xf2, 0xac, 0x3d, 0x38, 0x61, 0x4f, + 0x27, 0xdd, 0xe9, 0xc6, 0xd5, 0x24, 0x6b, 0xe3, 0xa1, 0xc4, 0x90, 0xc0, 0xaf, 0x59, 0x4e, 0x00, 0x39, 0x10, 0x7b, + 0xc4, 0xda, 0xe4, 0x52, 0xb8, 0x7e, 0xa3, 0x45, 0x37, 0x30, 0xaf, 0x9b, 0xbf, 0xc8, 0x21, 0x95, 0xc5, 0x1b, 0x10, + 0x32, 0x32, 0x3f, 0xa3, 0x1c, 0x59, 0xc1, 0xa3, 0xf2, 0xf5, 0x21, 0x52, 0x87, 0x2f, 0xaf, 0xf6, 0x43, 0x63, 0xdf, + 0x22, 0xf3, 0xa2, 0x68, 0x2a, 0x33, 0x47, 0xb9, 0x0f, 0x90, 0xc4, 0x92, 0x67, 0x58, 0x61, 0x7c, 0xd5, 0xda, 0x88, + 0x08, 0xbe, 0x11, 0xc0, 0x7e, 0xf7, 0x49, 0x70, 0x6c, 0x63, 0x12, 0x28, 0xd0, 0xee, 0x66, 0x20, 0x41, 0x01, 0x99, + 0x38, 0x92, 0xd4, 0x8e, 0x06, 0x89, 0xfd, 0x09, 0xda, 0x76, 0x71, 0x45, 0x24, 0x1b, 0xfb, 0x39, 0x60, 0x21, 0x8d, + 0x0f, 0xa8, 0xcc, 0x80, 0x08, 0x4b, 0x01, 0x3a, 0x7a, 0xfe, 0xa9, 0x42, 0x5c, 0xcd, 0xb0, 0xf0, 0x9c, 0xc1, 0x5d, + 0x99, 0xaf, 0xfb, 0x79, 0xf6, 0xe0, 0x4c, 0x05, 0xc4, 0xe3, 0x89, 0x5f, 0x6e, 0x17, 0x28, 0x32, 0x10, 0xb4, 0x42, + 0x3c, 0x14, 0x84, 0x16, 0x8a, 0x18, 0xb4, 0xf9, 0x8f, 0x7d, 0xae, 0x8a, 0x91, 0x0a, 0x85, 0xa8, 0x68, 0x4d, 0xc6, + 0x70, 0x45, 0x9d, 0x23, 0x06, 0xdf, 0xcc, 0xd8, 0xa1, 0x65, 0xa2, 0x52, 0xbe, 0x54, 0xf1, 0x58, 0x07, 0xeb, 0x89, + 0x14, 0x32, 0x32, 0x52, 0x93, 0x9d, 0x6f, 0x21, 0x49, 0xf0, 0x4e, 0xad, 0x3c, 0x83, 0x14, 0x5e, 0xe9, 0xb0, 0x4f, + 0xa2, 0x5f, 0x86, 0x28, 0x8c, 0xda, 0xd7, 0x94, 0xbe, 0x9a, 0x89, 0xc4, 0xd8, 0x13, 0x79, 0x50, 0xa2, 0xe5, 0x1f, + 0xd9, 0x84, 0x91, 0x84, 0xe4, 0xd8, 0xf3, 0xe1, 0xdf, 0xe7, 0x04, 0xe9, 0xe3, 0xac, 0x87, 0xb4, 0x25, 0x11, 0x3e, + 0x51, 0x96, 0x03, 0xba, 0xee, 0x80, 0xa4, 0x00, 0xde, 0x75, 0xc1, 0xed, 0x7d, 0xdb, 0x21, 0x8e, 0x4e, 0xce, 0xa9, + 0x19, 0xe3, 0x65, 0x0a, 0x1b, 0x39, 0x1c, 0x6f, 0x93, 0x20, 0x6c, 0x44, 0xaf, 0x4c, 0xd3, 0xb1, 0xc0, 0x2c, 0x81, + 0x44, 0x88, 0xf4, 0x7e, 0x71, 0xce, 0x85, 0x98, 0xd7, 0x49, 0x66, 0xa8, 0x78, 0x6a, 0x95, 0xa9, 0x09, 0x32, 0x1c, + 0xe7, 0x2a, 0xbe, 0x27, 0x29, 0xc9, 0x13, 0xee, 0x62, 0xb2, 0x5f, 0x61, 0x1d, 0x25, 0x4f, 0x49, 0x41, 0xc9, 0xa8, + 0xe1, 0x7f, 0x99, 0xd2, 0x44, 0x62, 0x57, 0x76, 0x87, 0x24, 0x80, 0x94, 0x60, 0xa9, 0xce, 0xe0, 0x71, 0x44, 0x3c, + 0x17, 0x82, 0x86, 0x88, 0x44, 0xe1, 0x33, 0xdb, 0xcb, 0xcf, 0x22, 0x87, 0x04, 0xcf, 0x4c, 0x89, 0xce, 0xe2, 0x0f, + 0xd6, 0x71, 0x8f, 0x8c, 0x37, 0x1a, 0x46, 0x35, 0xaf, 0x0f, 0xda, 0x3e, 0x62, 0x6e, 0x7a, 0xfe, 0x30, 0x30, 0xd3, + 0xb1, 0xc9, 0x26, 0x95, 0x70, 0x56, 0x6e, 0xfe, 0xc6, 0x05, 0x8a, 0x8d, 0x5a, 0xa1, 0xe5, 0x67, 0x3d, 0xb5, 0x21, + 0xea, 0x9c, 0x13, 0xe2, 0x80, 0x03, 0x56, 0xb3, 0x60, 0x9e, 0x6b, 0xfc, 0xcf, 0x65, 0x72, 0x97, 0x1c, 0xc1, 0x99, + 0x1b, 0x4b, 0x73, 0x79, 0x15, 0xc9, 0xa1, 0x0b, 0xb6, 0x02, 0x55, 0x40, 0x39, 0xc9, 0x18, 0x23, 0xcb, 0x01, 0x23, + 0x96, 0x48, 0x2e, 0x17, 0x20, 0xb4, 0xc8, 0xba, 0x0a, 0xc2, 0x50, 0xa8, 0x9c, 0x46, 0xda, 0x70, 0x28, 0xe3, 0x18, + 0x99, 0xd6, 0x55, 0xdf, 0x19, 0x42, 0x96, 0xf2, 0xae, 0x01, 0xed, 0x28, 0x95, 0xbc, 0x94, 0xef, 0xa2, 0xdc, 0x9d, + 0xf0, 0x52, 0x18, 0x20, 0xcf, 0x1f, 0x15, 0x1b, 0x75, 0x47, 0x81, 0x17, 0x83, 0xf1, 0x42, 0x96, 0x0d, 0x77, 0x52, + 0xc9, 0x12, 0x13, 0x25, 0x08, 0x9c, 0x32, 0xd2, 0xd8, 0xa7, 0x2c, 0xed, 0xca, 0xfb, 0x2b, 0x4c, 0x2c, 0x4f, 0xca, + 0x28, 0x46, 0x3c, 0x39, 0xab, 0xb2, 0xae, 0x59, 0x3c, 0xc4, 0xfc, 0xc9, 0xdb, 0x24, 0xe5, 0x37, 0x3d, 0xd3, 0xe8, + 0x8d, 0x49, 0x67, 0x0d, 0x39, 0x9c, 0x4e, 0xc5, 0xe9, 0xec, 0x59, 0x5c, 0x35, 0x48, 0x55, 0x40, 0x11, 0x08, 0x87, + 0x3c, 0xf9, 0x26, 0x33, 0xda, 0x37, 0x01, 0x4b, 0xa5, 0x63, 0x28, 0x4f, 0xaa, 0x31, 0x26, 0x24, 0x2d, 0xf7, 0x3f, + 0x82, 0xe2, 0x4a, 0x8d, 0x24, 0x4b, 0xf0, 0xe1, 0x1d, 0x4a, 0x08, 0x4a, 0xc9, 0xa1, 0x83, 0x6e, 0x43, 0x45, 0x13, + 0x28, 0xa2, 0x27, 0x41, 0x9e, 0xaf, 0x37, 0x76, 0xaa, 0x14, 0x03, 0x9c, 0x98, 0xec, 0xca, 0xe3, 0x68, 0x66, 0x95, + 0x3e, 0xfb, 0x4f, 0x11, 0x1c, 0x0e, 0x87, 0x17, 0x34, 0x48, 0xa4, 0xf7, 0x5c, 0x91, 0x9b, 0x5a, 0x70, 0x7e, 0xba, + 0x10, 0x93, 0x59, 0x5b, 0x16, 0xd2, 0x72, 0x84, 0x62, 0x24, 0x87, 0x8e, 0xc0, 0xb6, 0x0c, 0xb9, 0xad, 0x91, 0xc8, + 0xe4, 0x5b, 0xfe, 0x1d, 0x87, 0x4c, 0x52, 0x32, 0xa5, 0xc9, 0x78, 0x2f, 0xa7, 0x22, 0xbb, 0x12, 0x45, 0x25, 0x32, + 0x2a, 0xa6, 0x41, 0x0c, 0xa9, 0xac, 0xde, 0xd3, 0x82, 0xa5, 0xba, 0x23, 0xb8, 0x3b, 0x27, 0xa4, 0x60, 0x19, 0x54, + 0xdd, 0x8e, 0xce, 0x38, 0xda, 0x20, 0x66, 0x5d, 0x92, 0xec, 0x27, 0xc5, 0x20, 0x9b, 0x48, 0xa1, 0x44, 0x3d, 0x61, + 0x37, 0x6e, 0x4b, 0x08, 0xfb, 0xdd, 0xc0, 0xc4, 0xd2, 0xb2, 0x4c, 0x93, 0x3e, 0x45, 0x62, 0xa7, 0x14, 0x8f, 0x50, + 0xf9, 0x14, 0xba, 0x77, 0xd3, 0x48, 0x48, 0x75, 0x92, 0x27, 0x08, 0xda, 0x73, 0x30, 0x76, 0x4c, 0xc0, 0x7c, 0x7f, + 0x0a, 0xd6, 0x8f, 0xd3, 0xb4, 0x60, 0xe1, 0xe0, 0x21, 0xc5, 0x9e, 0x99, 0xdd, 0xfc, 0xcb, 0x7c, 0x8e, 0x72, 0xce, + 0x0c, 0x9d, 0xcc, 0x53, 0x48, 0x66, 0xe3, 0xec, 0xe4, 0x5f, 0x90, 0xe6, 0xbd, 0x83, 0xdd, 0x91, 0xb6, 0xe1, 0xf7, + 0x99, 0xe0, 0xfa, 0x44, 0x0e, 0x23, 0xf8, 0xaa, 0x4b, 0x62, 0x37, 0x1f, 0x23, 0x8c, 0x22, 0x45, 0xaf, 0x1d, 0x07, + 0xe2, 0xb2, 0xda, 0x7d, 0x79, 0x10, 0x00, 0xb0, 0xa8, 0xf4, 0xef, 0x95, 0x88, 0x4c, 0xcc, 0x83, 0x5c, 0x06, 0x5b, + 0x19, 0xf0, 0xb3, 0x4a, 0xe2, 0x01, 0x97, 0x80, 0x4b, 0xe8, 0xb3, 0x02, 0x66, 0xa8, 0x01, 0xd4, 0xde, 0x79, 0x53, + 0x18, 0x46, 0x3a, 0x68, 0x4e, 0xb5, 0x86, 0xe2, 0x2d, 0x8a, 0x28, 0x1f, 0xfa, 0xb0, 0xf7, 0x61, 0x91, 0x01, 0x1d, + 0xfc, 0x38, 0x33, 0xa1, 0x3c, 0x4c, 0x9a, 0x31, 0x9a, 0x98, 0xe7, 0x19, 0xc5, 0xbd, 0xe1, 0xc2, 0xa4, 0xb7, 0x24, + 0x10, 0xd3, 0xbe, 0x6f, 0x4b, 0x45, 0x7c, 0xbf, 0x1b, 0x97, 0xfe, 0xd5, 0x7a, 0x04, 0xbd, 0x64, 0x16, 0x4a, 0xe4, + 0x5b, 0x2a, 0xd4, 0x91, 0x07, 0x86, 0xdb, 0x76, 0x6c, 0x98, 0x75, 0xa7, 0x95, 0xf4, 0x7a, 0x55, 0x35, 0xec, 0x80, + 0x71, 0x54, 0x5a, 0x7a, 0xaa, 0x5f, 0x1c, 0xd4, 0xe4, 0xf5, 0x62, 0xfd, 0xd5, 0x8e, 0xbd, 0x3c, 0x01, 0x99, 0x19, + 0xa3, 0xc1, 0x9c, 0x92, 0xc6, 0x0e, 0xa8, 0x85, 0x34, 0x94, 0x75, 0xb8, 0x8b, 0xa7, 0xb5, 0x12, 0x0e, 0x44, 0xe0, + 0x6c, 0xba, 0x4d, 0xac, 0x97, 0xdc, 0x0f, 0x1d, 0x40, 0x19, 0x1d, 0x3e, 0x77, 0x9b, 0x5a, 0x0c, 0xeb, 0x01, 0x6f, + 0x10, 0xd1, 0x42, 0x93, 0x0a, 0x2e, 0xb1, 0x43, 0xca, 0xa6, 0xca, 0xd0, 0x41, 0xe7, 0x5c, 0x53, 0x66, 0x65, 0xa5, + 0xf2, 0x2e, 0xaf, 0xa4, 0x9f, 0x66, 0x21, 0x1b, 0xeb, 0x2a, 0x68, 0x2c, 0xc8, 0x6f, 0x21, 0x00, 0xce, 0xa3, 0x99, + 0xbe, 0xd9, 0x00, 0x73, 0xb2, 0x64, 0xf9, 0xad, 0x3c, 0xaa, 0x2c, 0x56, 0xee, 0x2d, 0x47, 0xea, 0xc8, 0xc8, 0xa4, + 0xef, 0x4a, 0x01, 0x92, 0x0e, 0xc6, 0xe5, 0x8e, 0xd5, 0x9e, 0x31, 0x25, 0xba, 0x5f, 0x30, 0xc4, 0xda, 0xe1, 0xf0, + 0xcb, 0x91, 0xc3, 0xa1, 0x66, 0x90, 0x1d, 0x69, 0xf4, 0x20, 0x45, 0xf0, 0x22, 0x57, 0xb8, 0xe2, 0x8f, 0x65, 0xdb, + 0x96, 0x08, 0xe2, 0x29, 0xc2, 0xdf, 0x33, 0x49, 0xe8, 0xe3, 0x01, 0xa1, 0xbb, 0x90, 0xf6, 0xf9, 0x34, 0x93, 0xf5, + 0x23, 0x94, 0x91, 0x64, 0xfa, 0x3e, 0xd4, 0x54, 0xa6, 0xc1, 0x37, 0xbf, 0xe6, 0xa9, 0x41, 0xe5, 0x36, 0x98, 0x44, + 0x83, 0x92, 0x3b, 0x07, 0x18, 0x7e, 0xa4, 0x5c, 0xd5, 0xab, 0xa2, 0x93, 0x56, 0x66, 0x6a, 0x7f, 0x90, 0x39, 0x02, + 0x93, 0xd3, 0x43, 0x33, 0xd2, 0x40, 0x88, 0x00, 0x2f, 0x10, 0x88, 0xbc, 0x04, 0xca, 0x00, 0xb6, 0xe9, 0x5e, 0x1b, + 0x34, 0xc6, 0xe3, 0xf1, 0x33, 0xa2, 0x98, 0x48, 0x2a, 0xdf, 0x13, 0xc7, 0xd1, 0x68, 0xb1, 0x88, 0x54, 0xd0, 0x84, + 0x62, 0x06, 0xfe, 0xdc, 0x7c, 0xb0, 0x3c, 0xeb, 0x7d, 0xd6, 0x0c, 0x63, 0x4c, 0xb3, 0x74, 0xd3, 0x26, 0xe7, 0xc8, + 0xdd, 0x4f, 0x58, 0x5a, 0x33, 0x42, 0x46, 0x09, 0x9b, 0x32, 0xb4, 0xea, 0x5a, 0x57, 0x8a, 0x63, 0x38, 0x46, 0xe3, + 0xfc, 0x1d, 0x59, 0x74, 0xf8, 0x53, 0x8d, 0x4f, 0x1f, 0x63, 0xa4, 0xe5, 0xf9, 0xd9, 0xb7, 0x09, 0xc4, 0x2f, 0xa3, + 0x1a, 0x75, 0x25, 0xc2, 0xa2, 0x65, 0x82, 0xd4, 0x61, 0x43, 0x2f, 0x23, 0x5e, 0x5e, 0xb3, 0xb8, 0x23, 0x41, 0x0f, + 0x4a, 0xec, 0x89, 0x86, 0xd4, 0xed, 0x99, 0xd8, 0xda, 0x26, 0xf5, 0xfa, 0xf3, 0xc9, 0x4f, 0xf3, 0x64, 0x7f, 0x5c, + 0x26, 0x75, 0x8e, 0x0a, 0xc4, 0x51, 0x7b, 0xbb, 0xcc, 0x77, 0xc6, 0x5c, 0x79, 0xf4, 0xdd, 0x56, 0x32, 0x46, 0xd1, + 0x8c, 0xb4, 0x6c, 0x1c, 0x18, 0xd5, 0xc5, 0x0e, 0xd5, 0x77, 0x0a, 0xcb, 0x8f, 0xaf, 0xe4, 0xc8, 0x23, 0x4a, 0x02, + 0x55, 0xd7, 0x8f, 0x24, 0x94, 0x86, 0x71, 0x7e, 0x35, 0xd6, 0x3e, 0x26, 0xd7, 0x06, 0xc5, 0xd2, 0x9e, 0xc7, 0x8c, + 0x8f, 0xb8, 0xf9, 0xcb, 0xb5, 0x1e, 0x64, 0x45, 0xed, 0x39, 0xf1, 0x74, 0xd4, 0xa1, 0x6d, 0x16, 0x93, 0x4d, 0x30, + 0xc0, 0x07, 0x68, 0xc2, 0xda, 0x63, 0x51, 0xeb, 0x8f, 0xc1, 0xd7, 0x3e, 0x40, 0x80, 0x6b, 0x21, 0xac, 0x9c, 0xa2, + 0x40, 0xe9, 0xda, 0x96, 0x5c, 0x1f, 0xef, 0xda, 0xfd, 0x28, 0x23, 0x91, 0xed, 0x2a, 0x29, 0x51, 0x6c, 0xa7, 0x29, + 0xf5, 0x77, 0x4a, 0x7d, 0xf0, 0x28, 0x22, 0x3e, 0xe3, 0x44, 0x8f, 0x4f, 0x56, 0xdd, 0x3c, 0x69, 0x4f, 0x7a, 0xa1, + 0x74, 0x03, 0x5e, 0x5c, 0x56, 0xdd, 0x14, 0x9f, 0x1c, 0x7b, 0x29, 0x62, 0x6b, 0x09, 0xb2, 0x45, 0x14, 0x6d, 0x07, + 0x39, 0xe4, 0x7b, 0x16, 0x29, 0xa4, 0x66, 0xd3, 0x29, 0xee, 0x00, 0x3b, 0xc5, 0x78, 0xe5, 0x88, 0x59, 0xab, 0xc9, + 0x9c, 0x6b, 0x14, 0xc8, 0xcb, 0xa0, 0xea, 0xf7, 0xf6, 0x03, 0x79, 0x37, 0x9e, 0x3f, 0x09, 0xa9, 0x5c, 0x85, 0x9d, + 0xf9, 0xbd, 0xd0, 0xf8, 0x77, 0xa7, 0x3d, 0x89, 0x3c, 0x3c, 0xcc, 0x2f, 0x49, 0xef, 0xf7, 0x71, 0x5f, 0x90, 0x6b, + 0xf8, 0x59, 0x88, 0x84, 0x26, 0x7e, 0xb3, 0x29, 0x90, 0x3c, 0x56, 0x08, 0xb8, 0x50, 0x49, 0x35, 0x8b, 0xb5, 0x25, + 0x9c, 0xd3, 0x83, 0xfb, 0x19, 0x73, 0xee, 0x30, 0x3c, 0xc8, 0x95, 0xd0, 0xb8, 0xbc, 0xc6, 0xdd, 0xa0, 0xb6, 0xfe, + 0x45, 0x58, 0xc2, 0x6b, 0x64, 0x89, 0xb4, 0x2c, 0x9f, 0x51, 0xea, 0x04, 0x0d, 0x5f, 0xba, 0x50, 0x8c, 0xd7, 0x21, + 0x4e, 0xf5, 0x10, 0xdd, 0xdf, 0xb7, 0x23, 0xb5, 0x32, 0xde, 0x7e, 0x7a, 0xe3, 0xe1, 0xe9, 0xf0, 0x34, 0xee, 0x4a, + 0x3c, 0xa3, 0x5e, 0x06, 0x7f, 0x34, 0x64, 0x4a, 0x4d, 0x4f, 0xf1, 0xf6, 0xbf, 0x4a, 0xbd, 0xfe, 0x70, 0xe1, 0x7a, + 0x87, 0x49, 0x20, 0x9f, 0x94, 0x6f, 0x27, 0x53, 0xab, 0x9b, 0x27, 0xbb, 0x7b, 0xf5, 0xfc, 0x33, 0xcf, 0xa4, 0x8c, + 0x1b, 0x9c, 0x38, 0xea, 0x29, 0xb5, 0x89, 0x0a, 0x15, 0x3c, 0x47, 0xcf, 0x74, 0x6b, 0x7b, 0xdc, 0x3c, 0xde, 0x4c, + 0x33, 0x7f, 0xc4, 0x94, 0x27, 0xc5, 0xd6, 0xd3, 0x8d, 0x50, 0x6e, 0x28, 0xde, 0x85, 0x52, 0xd8, 0x84, 0xcf, 0xe8, + 0x3f, 0x9b, 0x30, 0x59, 0x45, 0x48, 0xfe, 0x40, 0xa0, 0x7c, 0x2a, 0xb3, 0x21, 0xed, 0x26, 0xa1, 0xa6, 0x85, 0x9c, + 0xa4, 0x9c, 0x66, 0xb2, 0x44, 0xd5, 0x00, 0x70, 0xe4, 0xa8, 0xb7, 0x88, 0x1b, 0xbc, 0xf3, 0x0b, 0x50, 0x38, 0x98, + 0xfa, 0x5b, 0x4f, 0xa2, 0x36, 0x77, 0x92, 0x72, 0x04, 0x93, 0xa2, 0xd8, 0x9d, 0x14, 0xb6, 0x5b, 0xe4, 0x2c, 0x6e, + 0xf1, 0x21, 0xa9, 0x2a, 0x42, 0x64, 0x31, 0x30, 0xc4, 0xab, 0x89, 0x76, 0x94, 0xe1, 0xc0, 0x37, 0x0b, 0x33, 0x9d, + 0xf0, 0xea, 0xb1, 0x8b, 0x04, 0x95, 0xc2, 0xcf, 0xd2, 0xc8, 0x12, 0xa7, 0xf4, 0xe0, 0x84, 0x01, 0xb7, 0xdc, 0x8a, + 0xd5, 0xf7, 0x57, 0x94, 0x99, 0x50, 0x9a, 0x89, 0xb1, 0xa2, 0x7e, 0x40, 0xc0, 0x3d, 0x49, 0x98, 0x78, 0x22, 0xf4, + 0xd6, 0x76, 0xcd, 0x3f, 0xa9, 0x3e, 0x5b, 0xf8, 0x42, 0x6c, 0x01, 0xf3, 0x86, 0xc0, 0x04, 0x1a, 0x37, 0x9b, 0x51, + 0x2c, 0xa1, 0xf1, 0x03, 0xca, 0xa2, 0xdb, 0x59, 0x82, 0xaa, 0xb7, 0x8a, 0x0e, 0x43, 0x5d, 0x00, 0x2d, 0xad, 0x9e, + 0xfd, 0x98, 0xeb, 0x7d, 0x1e, 0xe5, 0x56, 0x1f, 0x60, 0xac, 0x6e, 0x00, 0x1d, 0x69, 0xd8, 0xf6, 0x6a, 0x78, 0xb9, + 0xa7, 0x9a, 0x88, 0x33, 0x9e, 0x2c, 0xaf, 0x0c, 0xfd, 0x86, 0x6c, 0x3d, 0xee, 0x3c, 0x51, 0xbb, 0xa8, 0xbc, 0xec, + 0x00, 0x91, 0x5a, 0x58, 0xd9, 0x8c, 0x7a, 0x81, 0x64, 0x5d, 0xdf, 0xac, 0x4a, 0x48, 0xd2, 0x23, 0xec, 0x13, 0xfe, + 0x7a, 0x19, 0x49, 0xa8, 0xa0, 0xd5, 0x4c, 0x65, 0xe9, 0xda, 0x6c, 0x40, 0x2b, 0xc0, 0x40, 0x67, 0xe2, 0x21, 0x70, + 0xf4, 0xba, 0x5e, 0x7a, 0xe4, 0x33, 0x4c, 0x7d, 0x18, 0x4a, 0x6a, 0x96, 0x8d, 0xb6, 0x9e, 0xc4, 0xcf, 0xe9, 0x58, + 0x62, 0x43, 0x0b, 0x09, 0x6b, 0xd2, 0xde, 0x16, 0x7e, 0xd5, 0x99, 0xdd, 0xd4, 0xfb, 0xce, 0xe7, 0x22, 0x44, 0x58, + 0x79, 0x7e, 0x51, 0xaa, 0xb1, 0xa4, 0x10, 0xe1, 0xdd, 0xec, 0x85, 0x95, 0x58, 0xd6, 0x36, 0xef, 0x2b, 0xd3, 0xfc, + 0x4c, 0x4e, 0x7f, 0xed, 0x18, 0xa8, 0xa0, 0x5f, 0xf3, 0x72, 0x6b, 0x76, 0x22, 0x82, 0x47, 0xa5, 0x20, 0x1f, 0x68, + 0xe2, 0xb4, 0x29, 0x47, 0xdd, 0xbe, 0x8b, 0x55, 0x69, 0xbf, 0x01, 0x07, 0x6e, 0xff, 0x0d, 0xb0, 0x02, 0x29, 0x40, + 0xc0, 0xcc, 0xbd, 0xac, 0xb2, 0x1e, 0x84, 0x36, 0xc8, 0xa0, 0xcf, 0x49, 0xfc, 0xc1, 0xc7, 0x3d, 0xcb, 0x92, 0x81, + 0xad, 0x40, 0x0b, 0x08, 0x40, 0xe1, 0x36, 0xa2, 0x9f, 0xdf, 0x40, 0xbe, 0x62, 0x7e, 0xd4, 0xe0, 0x84, 0xfa, 0x2c, + 0xba, 0x2e, 0x82, 0xf3, 0x31, 0xb2, 0xf1, 0x07, 0x56, 0x43, 0x68, 0x22, 0xe2, 0xa8, 0x0d, 0x8a, 0x94, 0xa8, 0xa1, + 0x23, 0x3f, 0x35, 0x06, 0xda, 0xaa, 0xe2, 0x35, 0x7e, 0xd6, 0x66, 0xb7, 0x2e, 0x60, 0x91, 0x1f, 0x9c, 0x1e, 0xb9, + 0x20, 0xcc, 0x1e, 0xdc, 0x34, 0xfd, 0xbf, 0xa5, 0x70, 0xf9, 0x40, 0xcf, 0xc6, 0x63, 0x4d, 0xf1, 0x54, 0x39, 0xd3, + 0xc1, 0x8d, 0x91, 0x1f, 0xa5, 0xce, 0x21, 0xac, 0x14, 0xfe, 0x5b, 0xe6, 0x73, 0xbb, 0xf5, 0x61, 0xb2, 0xbb, 0x2c, + 0x88, 0xe0, 0xe2, 0x92, 0xbd, 0x41, 0xc5, 0x1b, 0x90, 0x39, 0x04, 0xd9, 0x3b, 0x9f, 0x6a, 0x7f, 0x6c, 0x28, 0x95, + 0x5f, 0xd7, 0x36, 0xdf, 0x86, 0x37, 0x07, 0xe9, 0x16, 0x48, 0xac, 0xd7, 0x04, 0x6d, 0xe5, 0xf9, 0x12, 0xcd, 0x06, + 0x0d, 0x45, 0x63, 0x66, 0xf7, 0x17, 0x75, 0xe6, 0x2a, 0xb8, 0x75, 0xf7, 0x42, 0xa3, 0xa2, 0x58, 0xe8, 0x7b, 0x95, + 0x4d, 0xe0, 0xa2, 0x87, 0x57, 0x32, 0x4f, 0xb7, 0x2b, 0x12, 0xb5, 0xd8, 0x08, 0x31, 0xcb, 0x1b, 0xdc, 0xde, 0x55, + 0xf6, 0x67, 0xb8, 0x93, 0x0d, 0x30, 0x5b, 0xd0, 0x5b, 0x76, 0x48, 0x90, 0xfa, 0xd4, 0x29, 0xe5, 0x97, 0xf5, 0x47, + 0x99, 0xb6, 0xc0, 0xab, 0xf5, 0x40, 0x15, 0x73, 0x30, 0x43, 0x9d, 0x56, 0xdc, 0xeb, 0x44, 0x32, 0xf4, 0xae, 0x28, + 0xcd, 0x20, 0x11, 0xf6, 0x09, 0x2f, 0x61, 0xfa, 0x01, 0x2b, 0x2f, 0xb7, 0xf0, 0xc6, 0xb1, 0xec, 0xb5, 0x3a, 0x28, + 0x09, 0xaa, 0x80, 0xfc, 0x61, 0x78, 0xd6, 0xb2, 0x26, 0x77, 0x87, 0x23, 0x81, 0x2f, 0x17, 0x32, 0x11, 0xcc, 0x0d, + 0xe4, 0xcb, 0xb9, 0xb8, 0x10, 0x89, 0x2a, 0xc4, 0x78, 0xc9, 0xd2, 0xd1, 0xbb, 0x71, 0xd2, 0xa8, 0xd5, 0xf4, 0xa1, + 0x50, 0x71, 0x1b, 0xd7, 0x7a, 0x74, 0xbc, 0x60, 0x39, 0x1b, 0x8d, 0xee, 0x8a, 0x75, 0x4b, 0x79, 0x0b, 0xa5, 0x11, + 0x36, 0x52, 0x5f, 0x90, 0x65, 0x69, 0x16, 0x58, 0x2f, 0xc0, 0x16, 0xc1, 0x62, 0xc0, 0xf2, 0xd6, 0x59, 0x16, 0xb1, + 0xfa, 0xbd, 0xaf, 0x55, 0x8e, 0xc3, 0x90, 0x25, 0x21, 0x89, 0xe6, 0x55, 0x14, 0xc6, 0x18, 0x6a, 0x1c, 0x4d, 0x51, + 0xa5, 0x84, 0x31, 0x77, 0x23, 0xc3, 0x2e, 0xd6, 0x39, 0xc6, 0xd2, 0x48, 0xd2, 0xf0, 0x4d, 0x39, 0xa6, 0x27, 0xab, + 0xb1, 0x36, 0x22, 0x1b, 0x39, 0x34, 0x9e, 0xcb, 0xd5, 0x8c, 0x55, 0xee, 0xd0, 0xdd, 0x5a, 0xa9, 0xec, 0x42, 0x13, + 0x0a, 0xa3, 0xbd, 0xc6, 0x35, 0xc9, 0xa2, 0x5d, 0x83, 0x55, 0xfa, 0x92, 0x66, 0x8f, 0x38, 0x94, 0x6f, 0xc3, 0x56, + 0x55, 0xea, 0x02, 0xcd, 0xf9, 0xd0, 0x2b, 0xfc, 0x8d, 0x74, 0x72, 0x8e, 0x8a, 0x1e, 0xdc, 0x74, 0xdb, 0xc5, 0xbf, + 0x68, 0xa1, 0xfb, 0x2c, 0x7f, 0xce, 0x3c, 0x16, 0x2a, 0x54, 0xab, 0xab, 0x89, 0x2d, 0x99, 0xa1, 0xe1, 0x6b, 0x02, + 0xae, 0x44, 0xbe, 0x18, 0x60, 0x67, 0x94, 0xce, 0x25, 0xed, 0x54, 0x0e, 0x49, 0x4b, 0x36, 0x4e, 0xdc, 0x64, 0x23, + 0xda, 0xe5, 0x8f, 0xb1, 0xc5, 0xca, 0x4b, 0xd6, 0xad, 0x0f, 0xac, 0xf3, 0xf8, 0x3c, 0xab, 0xbc, 0x75, 0x6f, 0xc6, + 0xbf, 0xda, 0x0c, 0x13, 0xf6, 0xce, 0x6e, 0x70, 0xa9, 0xec, 0xd8, 0xa8, 0x91, 0xd3, 0x13, 0x3b, 0x5a, 0xe6, 0x22, + 0xc3, 0x6b, 0xb4, 0xaa, 0xb1, 0x90, 0xe3, 0x16, 0x7e, 0x0e, 0x34, 0x12, 0x8b, 0xa4, 0x58, 0x40, 0xe7, 0xfb, 0xd5, + 0x87, 0x17, 0x58, 0xcd, 0x63, 0xae, 0xc9, 0xd4, 0xa2, 0xce, 0x9c, 0xba, 0x50, 0x7d, 0x5e, 0x75, 0x5f, 0xd7, 0x2a, + 0xb8, 0x10, 0xd7, 0x9f, 0xa0, 0xe9, 0xaa, 0x9e, 0xfb, 0x96, 0x83, 0xd4, 0x94, 0x67, 0x10, 0xc7, 0xfa, 0xd3, 0x73, + 0x73, 0x23, 0x5b, 0xad, 0x8f, 0xd6, 0x51, 0x26, 0x5e, 0x8c, 0xc4, 0x16, 0x7e, 0xc7, 0x19, 0xd4, 0xa2, 0xbe, 0xcf, + 0x2a, 0x8a, 0x93, 0x80, 0xcb, 0x70, 0x05, 0x27, 0x30, 0xd5, 0x02, 0x03, 0x25, 0x39, 0xd1, 0x80, 0x46, 0xd6, 0xb9, + 0x3a, 0x78, 0xb9, 0x33, 0xdf, 0x34, 0x09, 0xa1, 0x83, 0x39, 0x83, 0x7b, 0x25, 0xdf, 0xec, 0xbb, 0x4a, 0x1d, 0x4c, + 0xb5, 0xf3, 0xda, 0x84, 0xad, 0x66, 0x7a, 0xda, 0x35, 0xb4, 0x42, 0xf4, 0x5c, 0x52, 0xcf, 0x90, 0x32, 0x56, 0x91, + 0xaa, 0x59, 0x1a, 0x87, 0x77, 0x8f, 0x84, 0x94, 0x29, 0xdb, 0x9d, 0x83, 0xf3, 0x0e, 0xa2, 0x12, 0xa9, 0xb2, 0x6e, + 0x0b, 0x23, 0x03, 0x3d, 0xe7, 0x58, 0x57, 0x51, 0xac, 0xa0, 0x18, 0x82, 0x5c, 0xe8, 0xa4, 0x15, 0x49, 0xa5, 0x1f, + 0x77, 0x16, 0x96, 0x51, 0x67, 0x65, 0x2e, 0x96, 0xcd, 0x75, 0xd4, 0xbb, 0x51, 0xff, 0xcc, 0xbb, 0x76, 0x39, 0x1d, + 0x9b, 0xc0, 0x4c, 0x28, 0x85, 0x05, 0xd2, 0x2c, 0x7f, 0x8b, 0xd3, 0xfb, 0xf1, 0xae, 0xe8, 0xd7, 0xc3, 0x66, 0x21, + 0x73, 0xb6, 0x02, 0x07, 0x90, 0xe9, 0xb8, 0xfa, 0x9d, 0x23, 0xa3, 0x8c, 0x42, 0x21, 0xad, 0xef, 0x41, 0x31, 0xd8, + 0x8e, 0xa9, 0x84, 0xe8, 0xd8, 0xdc, 0xcd, 0x00, 0x1d, 0xb4, 0xb1, 0xd5, 0x7b, 0x08, 0x36, 0x93, 0xb4, 0xe2, 0x2c, + 0x81, 0x8e, 0xd5, 0x4f, 0x2d, 0x55, 0x2f, 0x0d, 0x81, 0x41, 0xbf, 0x05, 0x82, 0xc0, 0x0b, 0x11, 0x7e, 0x66, 0x5e, + 0xd9, 0x20, 0xc2, 0x43, 0xf7, 0x06, 0xa0, 0x0c, 0xb1, 0xd6, 0x51, 0x2f, 0x8b, 0x85, 0xf7, 0x97, 0x05, 0x6d, 0xd1, + 0xcc, 0x51, 0x24, 0xa0, 0x7f, 0x85, 0x13, 0x57, 0x96, 0xf1, 0x09, 0x20, 0xa0, 0xcf, 0x91, 0xa4, 0xf8, 0xe8, 0x7d, + 0xaf, 0x9f, 0xa6, 0x94, 0x48, 0x9d, 0xf3, 0xd2, 0x93, 0xdc, 0xe0, 0xef, 0x3b, 0xcf, 0x1b, 0xaf, 0xac, 0x4a, 0x9e, + 0xfb, 0x7b, 0xba, 0x64, 0x71, 0x3d, 0x70, 0x7c, 0xb5, 0x94, 0xc9, 0xe6, 0xca, 0xc5, 0x04, 0x59, 0xb0, 0xf1, 0xbe, + 0x67, 0x46, 0x61, 0xdf, 0x40, 0xbe, 0x2b, 0xe6, 0x23, 0x8c, 0x6b, 0x2b, 0x9e, 0xbd, 0x15, 0x0f, 0x73, 0x4e, 0x49, + 0x91, 0xd4, 0x76, 0x4e, 0x81, 0x54, 0x67, 0x54, 0x5b, 0x90, 0x21, 0xe6, 0x02, 0x59, 0xf5, 0x29, 0x0e, 0xce, 0x96, + 0xa6, 0x81, 0x28, 0x5a, 0xca, 0x8f, 0x0a, 0x15, 0x82, 0xff, 0x1a, 0x88, 0x99, 0x46, 0x15, 0x60, 0x6e, 0x24, 0xd4, + 0xe1, 0x20, 0x9e, 0xf0, 0x74, 0x2f, 0x4d, 0x2b, 0x4d, 0x27, 0xee, 0xb4, 0x88, 0xa8, 0xfe, 0x72, 0x6e, 0x93, 0xa0, + 0x59, 0xf5, 0x2a, 0x0a, 0x97, 0x62, 0x49, 0x04, 0xd7, 0xcb, 0xea, 0xaa, 0x1f, 0x51, 0xaa, 0x7b, 0x65, 0xc1, 0x75, + 0xce, 0x02, 0x83, 0xe3, 0x5b, 0x8f, 0x74, 0x7b, 0x9e, 0x2e, 0xaf, 0x91, 0xdb, 0xa6, 0xc0, 0x8d, 0x8f, 0x99, 0xd0, + 0x95, 0xb8, 0x9a, 0x0d, 0x74, 0x85, 0x79, 0xdb, 0xae, 0xf8, 0x4a, 0xb0, 0x36, 0xff, 0x75, 0x3f, 0x03, 0xef, 0x8b, + 0x17, 0x61, 0xc1, 0x4c, 0x15, 0x8c, 0x62, 0xe2, 0x17, 0x61, 0x89, 0x30, 0xbc, 0x68, 0x6e, 0xce, 0xf6, 0xf9, 0xe6, + 0x3c, 0x02, 0x1c, 0x16, 0xe5, 0x09, 0x73, 0x7b, 0x06, 0x14, 0x54, 0x9b, 0xb0, 0xa9, 0xd6, 0x80, 0xb1, 0x3d, 0x4b, + 0xf3, 0x31, 0xdf, 0x9b, 0x0e, 0x50, 0x4f, 0xad, 0x39, 0xc5, 0x60, 0x0c, 0x61, 0xa2, 0xdb, 0x80, 0x02, 0xa4, 0x26, + 0x0b, 0x87, 0xcc, 0xfa, 0x5b, 0xca, 0x0b, 0x6d, 0x62, 0x43, 0x5f, 0x92, 0xa5, 0xb5, 0x56, 0xf0, 0x13, 0x34, 0x4d, + 0xc1, 0x29, 0x0e, 0x3f, 0x48, 0xbc, 0xe7, 0xde, 0x79, 0x8d, 0x44, 0x46, 0x3d, 0x17, 0x7e, 0x21, 0xc2, 0xca, 0x7d, + 0xc4, 0x9c, 0x73, 0x53, 0x13, 0xb2, 0x2f, 0x5d, 0xb2, 0x96, 0xd5, 0x24, 0xe0, 0xd1, 0x73, 0xa1, 0x42, 0x3b, 0x23, + 0xde, 0x5d, 0x5b, 0x79, 0xab, 0x7a, 0x34, 0x03, 0x56, 0x73, 0xdc, 0xb6, 0x98, 0x86, 0xa9, 0x28, 0xa9, 0x84, 0x20, + 0x6e, 0x09, 0x91, 0x85, 0x61, 0xcb, 0x1a, 0x7b, 0x9f, 0x58, 0xad, 0xa7, 0x24, 0x00, 0x70, 0x25, 0x0d, 0xdd, 0x33, + 0x94, 0x09, 0xa9, 0x97, 0xb4, 0x40, 0x39, 0xe4, 0x6a, 0xe2, 0xe5, 0xc6, 0x3d, 0x86, 0x81, 0x1b, 0xb3, 0xb5, 0xc8, + 0x34, 0x26, 0x44, 0x96, 0x81, 0x00, 0x71, 0x68, 0x5e, 0x9a, 0xca, 0xa2, 0xd3, 0x4d, 0x50, 0x74, 0x51, 0x8f, 0x33, + 0x5c, 0x59, 0x88, 0xbb, 0x64, 0xe8, 0x1c, 0x78, 0x39, 0x5d, 0xe3, 0xe5, 0x24, 0x15, 0x02, 0xaf, 0x82, 0x95, 0x07, + 0x12, 0xd9, 0x03, 0xed, 0xa0, 0x6c, 0x00, 0x24, 0xb9, 0x13, 0x5c, 0x29, 0x48, 0x6b, 0x2b, 0xc8, 0x21, 0xfe, 0xa7, + 0xb6, 0x1c, 0xa5, 0x02, 0xf2, 0xd4, 0xb1, 0xe5, 0xa4, 0xf1, 0x3c, 0x5c, 0x0a, 0x6f, 0xa4, 0x36, 0xcc, 0x60, 0xc5, + 0x0a, 0x16, 0x22, 0x33, 0x25, 0xcf, 0xad, 0x60, 0x1b, 0xaf, 0xde, 0xc4, 0x8c, 0x44, 0x85, 0xe9, 0xa3, 0xd8, 0x59, + 0xdd, 0x0d, 0x13, 0x6c, 0x2b, 0x9e, 0xb2, 0xdb, 0x8f, 0xc8, 0x7f, 0x4c, 0x50, 0x92, 0xa6, 0xc3, 0x97, 0x4a, 0xa6, + 0x93, 0xf2, 0xe2, 0x9d, 0x16, 0x46, 0x4b, 0x0e, 0x01, 0x17, 0x7c, 0x06, 0xde, 0x9d, 0x89, 0xfc, 0xcb, 0xa6, 0x35, + 0xc9, 0x1c, 0xa3, 0xaa, 0x8a, 0x16, 0x12, 0x8d, 0x71, 0x51, 0xb6, 0x26, 0x16, 0x0f, 0x16, 0x57, 0x03, 0x48, 0xa6, + 0x31, 0x2c, 0xf0, 0xf2, 0xc8, 0x7c, 0xcd, 0xe6, 0x45, 0xf5, 0x44, 0x96, 0x8a, 0x2e, 0xc8, 0xe5, 0x67, 0x18, 0x9b, + 0x99, 0x32, 0xac, 0x16, 0xcb, 0x70, 0x38, 0x2b, 0x08, 0x7b, 0x3f, 0xe0, 0x82, 0xe7, 0xa6, 0x8f, 0xbe, 0x5c, 0x30, + 0xa9, 0x1c, 0x46, 0x26, 0x7f, 0x96, 0x5c, 0xbc, 0x22, 0xac, 0x9e, 0x6c, 0xe7, 0x00, 0x72, 0xa1, 0x06, 0xe5, 0x08, + 0x87, 0x96, 0x13, 0xd8, 0xc4, 0x58, 0x44, 0x67, 0xd5, 0x54, 0x35, 0xc2, 0xd2, 0x7c, 0xe9, 0x46, 0x99, 0x37, 0xd9, + 0x76, 0x86, 0x4c, 0xd8, 0xba, 0x1f, 0x89, 0x20, 0x37, 0x1e, 0x0c, 0xa5, 0x31, 0xef, 0xc3, 0x1a, 0xac, 0xfa, 0x44, + 0x5e, 0xce, 0xa3, 0xaa, 0x11, 0x32, 0xc3, 0x29, 0xf9, 0x69, 0xf4, 0x14, 0x4d, 0x77, 0x92, 0x13, 0x2a, 0xda, 0x24, + 0x2a, 0x0a, 0x6c, 0xe2, 0x45, 0x29, 0x24, 0x82, 0xe2, 0x2e, 0xc7, 0x43, 0x0d, 0xf3, 0x0f, 0x27, 0x07, 0x57, 0x22, + 0x56, 0x07, 0x6e, 0xef, 0x1b, 0x48, 0xf2, 0x33, 0x99, 0xf4, 0x46, 0xba, 0x77, 0x37, 0xe5, 0xe1, 0x69, 0x22, 0x33, + 0xf7, 0x91, 0xb8, 0x8e, 0x8b, 0x8a, 0x42, 0x05, 0xdc, 0x6f, 0xd5, 0x5e, 0x7c, 0x92, 0x74, 0x82, 0xcc, 0x30, 0x73, + 0x6d, 0x7c, 0x6e, 0x8a, 0x91, 0x9a, 0x93, 0x54, 0x81, 0x7c, 0x72, 0xf7, 0x97, 0x46, 0x90, 0x9b, 0xf0, 0x17, 0xdf, + 0x3b, 0xaf, 0x93, 0xf2, 0x1c, 0xed, 0xe7, 0x44, 0xf4, 0xba, 0x1c, 0x6d, 0x31, 0x68, 0x63, 0x4e, 0x8c, 0x7c, 0xbb, + 0xbb, 0x88, 0x7c, 0xb9, 0xe1, 0x14, 0xc3, 0x54, 0x05, 0x32, 0x79, 0xf8, 0x43, 0x2d, 0x0f, 0x84, 0xfd, 0x29, 0x99, + 0x61, 0xa6, 0x89, 0xa8, 0xc2, 0x16, 0x38, 0x05, 0x0e, 0xd4, 0x5c, 0x39, 0x51, 0xf3, 0x70, 0xa0, 0x4a, 0x71, 0xf6, + 0x49, 0x22, 0xce, 0x5c, 0xc6, 0x36, 0x7e, 0x25, 0x5d, 0x30, 0x97, 0xd5, 0x48, 0x5b, 0x11, 0x2d, 0x8f, 0x14, 0x02, + 0x82, 0x5a, 0x8a, 0xa5, 0xd8, 0x12, 0x40, 0x30, 0xbe, 0xc5, 0xf3, 0xfb, 0x18, 0xb1, 0x0a, 0xc5, 0xcb, 0x34, 0xb2, + 0xa2, 0x5d, 0x7e, 0x63, 0x17, 0xa6, 0x0b, 0x26, 0xe0, 0x66, 0x24, 0xc5, 0xc8, 0x73, 0x87, 0x57, 0xe5, 0x46, 0xea, + 0x74, 0xbf, 0x2d, 0x0a, 0x05, 0x4f, 0x8b, 0x46, 0xe7, 0xc6, 0x54, 0x11, 0x5c, 0x35, 0x2a, 0xb6, 0x38, 0x38, 0x9c, + 0x7f, 0xa8, 0x99, 0x85, 0x74, 0x4d, 0x94, 0x23, 0x89, 0xfc, 0x7e, 0x11, 0x1c, 0x6a, 0x94, 0x17, 0xa2, 0x10, 0xa9, + 0x9f, 0x18, 0x72, 0x59, 0xc4, 0xec, 0x30, 0x37, 0xaa, 0xcb, 0x16, 0xc0, 0x96, 0xae, 0xc3, 0xc8, 0x50, 0x88, 0x3c, + 0x62, 0x98, 0x99, 0x26, 0xf5, 0x71, 0xe5, 0x20, 0x8b, 0xae, 0x52, 0x83, 0x34, 0xef, 0xb8, 0x91, 0x37, 0x49, 0xa2, + 0x84, 0x0c, 0xf1, 0xcc, 0x7c, 0x52, 0x67, 0x27, 0xb1, 0x57, 0x69, 0x29, 0xa4, 0x23, 0xd5, 0x4d, 0xa2, 0xd8, 0xe9, + 0x3e, 0x13, 0x7a, 0x5f, 0xb5, 0xf7, 0xb1, 0x18, 0xbc, 0x6e, 0x9b, 0x30, 0x7f, 0xf4, 0xf9, 0x4d, 0x7c, 0x47, 0x4d, + 0xd5, 0x13, 0x69, 0x41, 0x27, 0xa1, 0x35, 0x00, 0xee, 0xf3, 0xe6, 0xce, 0xf6, 0x60, 0xb8, 0x4d, 0x00, 0x5a, 0xc1, + 0x59, 0x4e, 0x37, 0x42, 0x56, 0xb7, 0x4f, 0x5a, 0xa7, 0x89, 0x8b, 0x38, 0xd8, 0x01, 0xd2, 0x10, 0xb8, 0x0a, 0x3e, + 0x67, 0x5f, 0x21, 0xf5, 0x23, 0x35, 0xb1, 0xb3, 0x4d, 0xd2, 0x83, 0xb6, 0xf7, 0xc9, 0xe5, 0xbc, 0x9f, 0x67, 0x2c, + 0x26, 0x0b, 0xf7, 0x4e, 0xfe, 0x10, 0xae, 0xe2, 0xa8, 0x16, 0x23, 0xe6, 0x0a, 0x01, 0xa6, 0xaa, 0x61, 0xb8, 0xd9, + 0x57, 0x4a, 0x63, 0xdc, 0x62, 0x0a, 0x40, 0x05, 0xc7, 0xa4, 0x5e, 0x7d, 0x0c, 0x55, 0xeb, 0xfe, 0xe7, 0x0a, 0xd6, + 0xd5, 0x6e, 0x59, 0xef, 0xf4, 0x00, 0x98, 0x00, 0xfc, 0x01, 0xa8, 0xaa, 0xe7, 0xe5, 0xce, 0xbf, 0xb0, 0x57, 0x10, + 0xa4, 0x24, 0xe0, 0x5e, 0x25, 0xfd, 0xdf, 0x6a, 0x1a, 0x08, 0x9a, 0xaf, 0x97, 0xf5, 0xb1, 0xcf, 0x44, 0x22, 0xf7, + 0x3c, 0x69, 0xf1, 0xf1, 0x1e, 0x78, 0x0b, 0x38, 0x7e, 0x19, 0x5b, 0x97, 0x74, 0xce, 0xfc, 0x41, 0x02, 0xcb, 0x1b, + 0xb5, 0xaf, 0x1e, 0x5f, 0xd2, 0x89, 0x60, 0xa7, 0x28, 0x50, 0x1f, 0x22, 0x02, 0x4a, 0x04, 0x4a, 0x8e, 0xb4, 0x84, + 0xee, 0x27, 0x9f, 0xa0, 0x5a, 0x40, 0x48, 0x9d, 0x12, 0x16, 0xf5, 0xed, 0xa0, 0x8e, 0xe0, 0x6d, 0x33, 0x72, 0xe2, + 0xc0, 0xb9, 0x81, 0x93, 0xf2, 0x39, 0xec, 0x6a, 0x84, 0xcb, 0xe3, 0x0d, 0x9e, 0xc0, 0x97, 0xe8, 0x37, 0x8e, 0x6f, + 0xe2, 0x79, 0x8b, 0x41, 0xe4, 0x1c, 0xb2, 0x9c, 0x7c, 0x21, 0xa2, 0x46, 0x24, 0x09, 0x75, 0xd8, 0x85, 0x90, 0xd6, + 0x17, 0x30, 0x38, 0x5e, 0x31, 0x8d, 0xa1, 0x7a, 0x18, 0x83, 0xc1, 0xe6, 0xf9, 0xed, 0xe9, 0x74, 0xeb, 0x21, 0xf9, + 0x20, 0xea, 0x8b, 0x88, 0x77, 0x4d, 0xa9, 0x51, 0x64, 0x79, 0xd8, 0xb4, 0xae, 0x53, 0xc3, 0x7b, 0x88, 0xc3, 0xbf, + 0x0a, 0x90, 0x00, 0xc5, 0x6e, 0xd3, 0xe7, 0x5c, 0xb0, 0xd1, 0x3b, 0x4d, 0x44, 0x68, 0xa1, 0x19, 0xa4, 0x70, 0xd5, + 0x7c, 0x81, 0x95, 0x69, 0xa7, 0xff, 0x45, 0xe7, 0xb6, 0x24, 0x01, 0x41, 0xb4, 0xd2, 0xef, 0xab, 0x30, 0x61, 0x89, + 0x31, 0x01, 0xde, 0x11, 0x62, 0xce, 0x33, 0x58, 0x49, 0x2c, 0x40, 0x72, 0xb4, 0x5e, 0x97, 0x1f, 0xcb, 0x74, 0x8a, + 0xd1, 0xe8, 0x4d, 0x9d, 0x64, 0xaa, 0xf5, 0xb5, 0x04, 0xf0, 0xc7, 0x79, 0x0d, 0x5b, 0xe6, 0x1e, 0x08, 0xb0, 0x63, + 0x25, 0xa1, 0x49, 0xb7, 0x64, 0xa7, 0xba, 0xe3, 0x66, 0x93, 0x9a, 0x72, 0x3f, 0x6f, 0x55, 0xb2, 0x54, 0x82, 0xc3, + 0xba, 0xf6, 0xa0, 0xfc, 0x21, 0x15, 0xe6, 0x32, 0x54, 0x56, 0x7b, 0x08, 0x90, 0xb0, 0x94, 0xe4, 0xa3, 0x9a, 0x21, + 0xe5, 0xe3, 0x53, 0x45, 0x91, 0x90, 0x33, 0x5e, 0x2c, 0x6b, 0xc0, 0x00, 0xef, 0xce, 0x5d, 0x4a, 0xeb, 0x1d, 0x7a, + 0xe4, 0xbd, 0x47, 0xbc, 0x80, 0xbd, 0x29, 0x61, 0x8f, 0x3b, 0x04, 0x69, 0x5f, 0x33, 0x14, 0xf2, 0x6f, 0x86, 0x92, + 0xc6, 0xfe, 0x7d, 0xcb, 0xe9, 0x41, 0xcf, 0xc8, 0xf4, 0x91, 0x0b, 0x7f, 0xae, 0xf1, 0xf6, 0x83, 0x7b, 0xb6, 0x61, + 0x3c, 0xad, 0x24, 0x30, 0x64, 0x13, 0x77, 0xf3, 0x92, 0x57, 0x6c, 0xb1, 0x7c, 0x77, 0xfe, 0x3a, 0x59, 0xa3, 0x20, + 0x70, 0x0b, 0x3e, 0xd0, 0x32, 0x52, 0x69, 0x90, 0x94, 0x14, 0xaf, 0xce, 0x81, 0x49, 0xa7, 0x9b, 0x5a, 0x25, 0x6a, + 0xd5, 0xa0, 0x57, 0x7d, 0x8a, 0x61, 0x59, 0xd2, 0xb6, 0xc4, 0x42, 0xb3, 0xdf, 0x87, 0x01, 0x26, 0x3f, 0x46, 0xce, + 0xb8, 0xbd, 0x03, 0xba, 0x07, 0x45, 0x6d, 0x19, 0x27, 0x41, 0x52, 0xaa, 0x20, 0x80, 0x74, 0xbf, 0xce, 0x63, 0x79, + 0xd5, 0x31, 0xd1, 0x61, 0xd1, 0xaa, 0x11, 0xc8, 0x09, 0x75, 0x63, 0x04, 0x86, 0x90, 0x3d, 0x53, 0xb1, 0xf4, 0xd0, + 0xeb, 0x30, 0x54, 0x7d, 0xee, 0xc7, 0xb0, 0xa6, 0xd5, 0x98, 0x25, 0x0f, 0x92, 0xcc, 0xa8, 0xfa, 0x46, 0xdf, 0xa2, + 0xd5, 0x59, 0xcf, 0xf1, 0x9e, 0x37, 0xd3, 0xd0, 0x4d, 0x65, 0xff, 0xd0, 0xd8, 0x81, 0x7f, 0x5b, 0x46, 0xa2, 0x5a, + 0x72, 0x96, 0xf6, 0x4a, 0xe6, 0x53, 0x8f, 0x02, 0x54, 0xdf, 0xf1, 0xee, 0xd2, 0x80, 0x28, 0x39, 0x3a, 0x77, 0x9b, + 0x1b, 0x70, 0xa9, 0x3e, 0xd0, 0x38, 0x3d, 0x86, 0x62, 0x60, 0xe7, 0xb7, 0xaf, 0xa7, 0xeb, 0x10, 0x23, 0x87, 0x51, + 0xe0, 0x28, 0xbd, 0xf4, 0x2e, 0x79, 0xb5, 0xe2, 0xc6, 0x15, 0xb6, 0xbb, 0x97, 0x96, 0xdf, 0x25, 0xdb, 0xb0, 0x3a, + 0xc9, 0xfe, 0x18, 0xd9, 0xd8, 0xc7, 0x74, 0xac, 0x8e, 0xd1, 0xb9, 0x73, 0x00, 0x5c, 0xb9, 0x94, 0xc0, 0xdd, 0x4a, + 0xae, 0x8e, 0x7f, 0xe5, 0xf6, 0x54, 0x4e, 0x37, 0xbd, 0x2e, 0x5f, 0x3e, 0x39, 0xbb, 0x8a, 0x07, 0xad, 0xd0, 0x50, + 0x66, 0xe9, 0xb2, 0x4a, 0xea, 0x02, 0x79, 0xd6, 0xf1, 0x5c, 0xb8, 0xeb, 0x2f, 0xbd, 0x8d, 0xd0, 0x80, 0x3d, 0x43, + 0x58, 0xcd, 0xa5, 0xa1, 0x3f, 0x97, 0xb3, 0x1e, 0x7b, 0x8b, 0x26, 0x13, 0xed, 0x2d, 0x7a, 0x4c, 0x69, 0x1c, 0x27, + 0xec, 0x0f, 0x38, 0x35, 0xde, 0x87, 0x74, 0xb5, 0x80, 0xd5, 0xc3, 0x2f, 0x0c, 0xc8, 0xcc, 0x01, 0x6e, 0xf7, 0xfc, + 0x73, 0xca, 0xd7, 0xbc, 0x8a, 0x42, 0x75, 0x93, 0x07, 0xd5, 0x94, 0x6c, 0x59, 0x07, 0x1b, 0xf6, 0xcf, 0x0a, 0x41, + 0x2d, 0x80, 0xe5, 0xd4, 0x74, 0xd9, 0xec, 0x7d, 0x12, 0xda, 0xb6, 0x5b, 0x4a, 0x78, 0x6f, 0x61, 0x4f, 0xec, 0xce, + 0xf2, 0x34, 0x29, 0x0f, 0xe3, 0x7f, 0x4c, 0xc8, 0x74, 0xc8, 0x5d, 0xb5, 0x92, 0x96, 0x29, 0xa6, 0xca, 0xde, 0x6f, + 0x1c, 0xbb, 0x39, 0x63, 0x24, 0x3e, 0x41, 0x0d, 0x1f, 0x2e, 0x3b, 0x7a, 0xb4, 0xe8, 0xed, 0x07, 0x47, 0x1a, 0x98, + 0xfa, 0x41, 0x46, 0x6e, 0x2a, 0x63, 0x1d, 0x00, 0x25, 0x4b, 0xf4, 0x67, 0xcb, 0x2e, 0x2d, 0x2a, 0x44, 0xa1, 0xc2, + 0xed, 0xec, 0x0f, 0xf7, 0x32, 0xab, 0x14, 0x11, 0xed, 0xde, 0x95, 0xe0, 0x0c, 0x71, 0x47, 0xbc, 0xe5, 0xa4, 0x01, + 0xc5, 0x68, 0xd1, 0x41, 0x4b, 0x8a, 0xb6, 0x47, 0xeb, 0xd5, 0x52, 0xca, 0xf3, 0xcc, 0x89, 0xec, 0x28, 0x60, 0xfd, + 0x70, 0x38, 0xf4, 0xed, 0x67, 0x55, 0xa4, 0xdd, 0x8f, 0xd9, 0x02, 0x77, 0x00, 0xf7, 0x5b, 0x16, 0xa6, 0x18, 0xa2, + 0xf3, 0x97, 0xd4, 0x18, 0x5d, 0x3f, 0x0a, 0x41, 0x1b, 0x8c, 0x21, 0x4f, 0x98, 0x5c, 0x93, 0x84, 0x86, 0x34, 0x46, + 0xad, 0x51, 0x20, 0x39, 0x27, 0xa6, 0x91, 0x98, 0x2d, 0x58, 0x4f, 0x23, 0x29, 0x5d, 0x44, 0xc8, 0x4c, 0x50, 0xd1, + 0x83, 0x22, 0x58, 0x92, 0x91, 0x16, 0xa9, 0xdc, 0x8b, 0x8e, 0xe2, 0x3d, 0x1f, 0x41, 0x73, 0xcd, 0xad, 0x1a, 0xd2, + 0x83, 0xe5, 0x8d, 0x86, 0x82, 0xac, 0xd2, 0xf1, 0x92, 0xfb, 0xa8, 0x0e, 0x22, 0x83, 0xa6, 0xad, 0xdf, 0xf6, 0x97, + 0xf1, 0x58, 0x93, 0x79, 0x46, 0x24, 0x18, 0x32, 0x0c, 0x39, 0x8c, 0x91, 0x7b, 0xab, 0xd2, 0xd3, 0x0f, 0x32, 0xf4, + 0xbb, 0xc5, 0x08, 0x60, 0xe2, 0x2b, 0x61, 0xb2, 0x2e, 0x77, 0x6a, 0xd4, 0x79, 0x97, 0x71, 0x22, 0x63, 0xe1, 0xfe, + 0xa3, 0xb0, 0x36, 0x24, 0x5a, 0xaf, 0x6e, 0xec, 0xf9, 0xc7, 0x0d, 0x7e, 0x52, 0x9a, 0x22, 0x6a, 0x4d, 0x52, 0xa7, + 0x03, 0x75, 0x4b, 0x1c, 0x83, 0xa3, 0x7c, 0x5c, 0xbc, 0xf0, 0xa0, 0xa5, 0x72, 0x43, 0x49, 0xac, 0x44, 0xdf, 0xdc, + 0x23, 0xfb, 0x02, 0x1a, 0x7b, 0x0a, 0xba, 0xd9, 0xe2, 0xa8, 0x56, 0xc6, 0x50, 0x8a, 0x39, 0x1c, 0xf6, 0xa1, 0xac, + 0x61, 0xa5, 0x3a, 0xb6, 0x5e, 0x1a, 0x77, 0xe3, 0x81, 0xc8, 0x50, 0x3b, 0x34, 0x0e, 0x71, 0x5f, 0x33, 0x23, 0x37, + 0x43, 0x13, 0xde, 0x21, 0x63, 0x70, 0x27, 0x8e, 0x97, 0x1a, 0x4b, 0xc2, 0x48, 0x88, 0x41, 0xbf, 0xb8, 0x17, 0xb3, + 0x45, 0x15, 0x24, 0x88, 0x6b, 0x1b, 0x15, 0x60, 0xe3, 0x15, 0xa2, 0x42, 0x7b, 0x6c, 0xeb, 0x78, 0x9e, 0x19, 0xb9, + 0x02, 0xc3, 0xc4, 0x1b, 0xd9, 0x8d, 0x9e, 0xa7, 0x72, 0xfc, 0x17, 0x61, 0xf5, 0x33, 0x16, 0x6c, 0xdd, 0x8a, 0x82, + 0x3f, 0x41, 0xe8, 0xe1, 0x41, 0xfb, 0x79, 0x89, 0x75, 0xfc, 0x8f, 0xad, 0xdf, 0x50, 0xd3, 0xaa, 0xd3, 0xd0, 0x0f, + 0xc7, 0x0f, 0x9d, 0x46, 0x07, 0xf9, 0xa7, 0xaf, 0x2e, 0x2d, 0x6e, 0x9a, 0xee, 0x6a, 0x5c, 0xbb, 0xaf, 0x50, 0x7d, + 0x38, 0xb6, 0x55, 0x17, 0xec, 0x0f, 0xe3, 0x38, 0xdc, 0x80, 0xc7, 0xc3, 0xf3, 0xe0, 0x06, 0x3c, 0xb8, 0xbf, 0x34, + 0xa6, 0xc7, 0xb3, 0xe7, 0x4b, 0xef, 0x2e, 0xc3, 0xb9, 0xc8, 0x35, 0x26, 0x7b, 0xea, 0xd7, 0xb6, 0x8b, 0x23, 0x8d, + 0xc0, 0xe8, 0xe8, 0xcd, 0x74, 0x41, 0x8d, 0x6b, 0x92, 0x51, 0x6b, 0x50, 0x7e, 0x42, 0x38, 0xbd, 0x7f, 0x7f, 0x6b, + 0x74, 0x84, 0x42, 0xc4, 0x8b, 0xc0, 0x7f, 0xdf, 0xc1, 0xdb, 0x7a, 0xd8, 0x99, 0x56, 0x67, 0xb9, 0xc4, 0x53, 0xd8, + 0x57, 0xa3, 0x5b, 0xd7, 0xe3, 0xc8, 0x28, 0xbd, 0xfc, 0xe0, 0x25, 0xc6, 0xc9, 0x4d, 0x7e, 0xc4, 0xb1, 0xaa, 0xdb, + 0x8b, 0xd5, 0x9f, 0x07, 0x41, 0x11, 0xfe, 0xf1, 0x82, 0x8c, 0x0f, 0x91, 0x8e, 0x72, 0x2a, 0x96, 0x62, 0x5a, 0x51, + 0x8d, 0x03, 0x50, 0x34, 0xfa, 0x25, 0xf4, 0xd5, 0x34, 0x18, 0x9b, 0xe7, 0x4a, 0x18, 0xdf, 0xf1, 0xbf, 0x1f, 0xbc, + 0xfb, 0x05, 0x9b, 0xe5, 0x2e, 0x18, 0xd6, 0x7d, 0x18, 0xa9, 0x4f, 0x02, 0xa8, 0xac, 0x9e, 0x65, 0x35, 0xd1, 0x76, + 0x50, 0xc7, 0xab, 0x99, 0xed, 0xbf, 0xef, 0x1c, 0x42, 0x4f, 0xab, 0x99, 0x52, 0x40, 0xe5, 0x96, 0x77, 0x88, 0x87, + 0xfa, 0x12, 0xbe, 0x8f, 0xf5, 0x55, 0xcc, 0xaf, 0xa8, 0xfa, 0x32, 0x56, 0x51, 0x10, 0x9a, 0x1f, 0x30, 0x34, 0xfc, + 0x90, 0x3c, 0xe3, 0x86, 0x83, 0xb9, 0x5f, 0x42, 0xff, 0xb2, 0xbe, 0x3f, 0x24, 0xf6, 0xb5, 0x8f, 0xdb, 0x75, 0xf3, + 0x35, 0xa7, 0x74, 0x18, 0x25, 0x78, 0x8e, 0xe3, 0xe6, 0xd0, 0x59, 0x1b, 0xed, 0xe8, 0xd4, 0x17, 0x69, 0x1d, 0x5d, + 0x60, 0xe8, 0xfb, 0xcc, 0x25, 0x5e, 0x39, 0xe2, 0xa0, 0x8f, 0xc4, 0x0d, 0x47, 0xdd, 0x5e, 0xd5, 0x8e, 0xd1, 0x31, + 0x06, 0x79, 0x29, 0x04, 0x90, 0x1c, 0xaa, 0xa7, 0xcd, 0xa2, 0x4d, 0x57, 0xce, 0x06, 0xe5, 0x9f, 0xeb, 0x5e, 0x3c, + 0xa0, 0x05, 0xa3, 0xba, 0xe1, 0x2f, 0x1e, 0xd2, 0xb8, 0xa1, 0xe5, 0x28, 0x2a, 0x25, 0x45, 0xa0, 0xb4, 0x8d, 0x0a, + 0x7a, 0xb3, 0x40, 0xf9, 0x60, 0xe9, 0x8f, 0x85, 0x2c, 0x75, 0x10, 0x2c, 0xe5, 0x34, 0xf5, 0x4a, 0x19, 0xd8, 0x63, + 0x23, 0xfe, 0xd3, 0x19, 0x1a, 0x44, 0xe6, 0xe6, 0x81, 0x1d, 0xe2, 0xe5, 0xa8, 0xa4, 0xa1, 0xbc, 0x61, 0xa0, 0x20, + 0xa8, 0xa9, 0x60, 0x11, 0xa4, 0xa8, 0x31, 0xed, 0x51, 0x31, 0xc8, 0xdc, 0xea, 0xb8, 0x81, 0x2e, 0x5f, 0x25, 0xb1, + 0x4b, 0xb5, 0xdb, 0x20, 0x57, 0x15, 0x3f, 0x06, 0xcf, 0x44, 0x5a, 0x07, 0xe9, 0x05, 0x8a, 0xa0, 0x2b, 0x8a, 0x48, + 0xaf, 0xca, 0x78, 0x11, 0xd6, 0xa2, 0xdc, 0x6a, 0xf4, 0xa0, 0x61, 0x18, 0x49, 0x85, 0xb7, 0x8d, 0x28, 0xc5, 0x7e, + 0x66, 0x5f, 0x61, 0x14, 0x3e, 0xe8, 0x50, 0x46, 0x9e, 0x2c, 0xda, 0xba, 0xf7, 0x6e, 0xd2, 0x88, 0x45, 0xa2, 0xce, + 0x6b, 0x1e, 0x99, 0xd2, 0x41, 0x93, 0x7c, 0x74, 0x5e, 0xce, 0xbc, 0x61, 0x32, 0xb2, 0x53, 0x72, 0x5c, 0x6a, 0x05, + 0x18, 0xb1, 0xf9, 0xdb, 0x6f, 0x1d, 0xc7, 0x33, 0x9f, 0x8e, 0x7e, 0x24, 0x3c, 0x5f, 0x66, 0x9e, 0x79, 0xba, 0x2d, + 0x0a, 0x97, 0x5c, 0x98, 0x53, 0xa1, 0x52, 0x83, 0x21, 0xf0, 0x57, 0x31, 0x78, 0x51, 0x26, 0xb8, 0x39, 0xb5, 0xeb, + 0x3e, 0xba, 0x8c, 0x88, 0x0e, 0xdf, 0x54, 0x68, 0xe6, 0xeb, 0xd7, 0xc9, 0x9d, 0x5c, 0x28, 0xa7, 0xd7, 0xaa, 0xc0, + 0xcb, 0x52, 0x65, 0x50, 0x8c, 0x51, 0xa5, 0xf4, 0xbc, 0xa0, 0x51, 0x9d, 0xa8, 0x14, 0x1c, 0x9a, 0xb1, 0xc0, 0x7f, + 0x48, 0xec, 0x2e, 0x79, 0xe8, 0x54, 0x00, 0x64, 0xca, 0xa2, 0xa1, 0xa3, 0x02, 0xf9, 0xdd, 0xc7, 0xd6, 0x8c, 0xb9, + 0x6a, 0x75, 0x59, 0x83, 0x14, 0x45, 0xdb, 0x53, 0x82, 0x34, 0x74, 0x87, 0x8b, 0x6d, 0x8a, 0x10, 0x6f, 0x0e, 0xc5, + 0x20, 0xa0, 0x15, 0x1a, 0x5f, 0x62, 0xaa, 0x95, 0x16, 0xf5, 0x80, 0xc2, 0xcb, 0x56, 0xc1, 0xdf, 0x72, 0xc1, 0x7d, + 0x81, 0x86, 0x43, 0x4c, 0x80, 0x00, 0x0c, 0x64, 0xb5, 0xfc, 0xfb, 0xa8, 0xa4, 0x98, 0xe9, 0x7b, 0xb5, 0xf9, 0x84, + 0xf7, 0xa5, 0x69, 0x72, 0x46, 0x30, 0x49, 0x71, 0x17, 0x32, 0x64, 0x11, 0xe1, 0xde, 0x2b, 0x3a, 0xa0, 0x6b, 0x2b, + 0x9a, 0x39, 0xf5, 0x88, 0x24, 0xb4, 0x05, 0x84, 0xd8, 0xe0, 0xc3, 0x6c, 0x59, 0x0e, 0x8d, 0x60, 0xd6, 0xc0, 0x8c, + 0xf9, 0x5e, 0xcb, 0x08, 0xa2, 0x92, 0x55, 0x2f, 0xbf, 0x07, 0x0e, 0xb4, 0xec, 0x4d, 0x60, 0xd1, 0x49, 0xda, 0x54, + 0x18, 0x08, 0x33, 0xf7, 0xe3, 0x07, 0xcf, 0x55, 0x32, 0x34, 0x7d, 0xac, 0x49, 0x0b, 0x8f, 0x86, 0x1b, 0x07, 0x5c, + 0xf9, 0xf8, 0x5c, 0xa2, 0x90, 0x37, 0xca, 0xb0, 0x2b, 0x77, 0x0e, 0xa8, 0x8f, 0x4c, 0x8d, 0x32, 0x04, 0x39, 0x01, + 0x19, 0xf0, 0xa0, 0xe3, 0x20, 0xf9, 0x3f, 0x20, 0x19, 0x19, 0x9c, 0xc0, 0xbd, 0x32, 0x23, 0x94, 0x2d, 0x28, 0xfc, + 0x91, 0x65, 0xdb, 0x07, 0xb4, 0xe7, 0x33, 0x9a, 0x14, 0x07, 0x92, 0x8d, 0x12, 0x3c, 0x8f, 0x7e, 0xa1, 0x84, 0x26, + 0x68, 0x93, 0x67, 0xe8, 0x23, 0xd9, 0x18, 0x29, 0x44, 0x26, 0x02, 0x07, 0x95, 0x03, 0xb1, 0x75, 0xc1, 0x40, 0x3e, + 0xb3, 0xe3, 0xce, 0xb8, 0xfd, 0x51, 0x70, 0x9d, 0x08, 0xdb, 0x1c, 0x7e, 0xa8, 0xd5, 0x61, 0xec, 0xa7, 0x81, 0xeb, + 0x16, 0xac, 0x6e, 0x95, 0x9e, 0xa1, 0xab, 0x8e, 0xf8, 0x4d, 0x4e, 0x8d, 0x98, 0xb6, 0xe9, 0xae, 0x6e, 0xb7, 0x9b, + 0xea, 0x55, 0xb6, 0xa0, 0x2e, 0x63, 0xf7, 0x5a, 0x55, 0x6b, 0xc6, 0xf2, 0xb0, 0xd0, 0xca, 0xec, 0xf3, 0x9f, 0xc5, + 0xd0, 0x99, 0x68, 0x3a, 0x34, 0x02, 0x25, 0x57, 0x51, 0xc4, 0xd3, 0x87, 0xd5, 0x35, 0xd7, 0x36, 0x99, 0xf8, 0x2b, + 0xa7, 0x8f, 0xaf, 0x1d, 0x37, 0xdf, 0x11, 0x46, 0xbd, 0xe7, 0x8e, 0x1b, 0x70, 0xae, 0x46, 0xbc, 0x1c, 0x3d, 0xf3, + 0x94, 0x57, 0xcb, 0xbb, 0xd2, 0x1c, 0x05, 0xcf, 0xb5, 0x9f, 0x5b, 0x4a, 0x3d, 0x2d, 0x4b, 0x1e, 0xb3, 0x0f, 0xb6, + 0x91, 0xdb, 0x30, 0xd6, 0x9b, 0x74, 0x43, 0xc6, 0x3b, 0x0e, 0xf8, 0x64, 0xa5, 0xa8, 0x2b, 0xfd, 0x9e, 0xaa, 0x49, + 0x0a, 0x1b, 0xcd, 0x6c, 0x37, 0xd4, 0x78, 0x17, 0x30, 0x4d, 0x87, 0xb7, 0x02, 0xc9, 0x81, 0x07, 0xe5, 0xda, 0x12, + 0xa6, 0x78, 0xdc, 0x9c, 0x0a, 0x48, 0x32, 0xac, 0xa6, 0x21, 0x37, 0xbf, 0x2a, 0xa4, 0x21, 0xa1, 0xce, 0xd5, 0x01, + 0x68, 0x95, 0x92, 0x07, 0x38, 0x94, 0x43, 0x01, 0xe6, 0xca, 0xa1, 0x67, 0x68, 0x50, 0x08, 0x46, 0xe8, 0xcd, 0xdb, + 0xe8, 0xf0, 0xd4, 0xe1, 0x43, 0x69, 0x5c, 0xe6, 0x14, 0xc4, 0x2f, 0x1f, 0xfb, 0x48, 0x3d, 0x1a, 0xeb, 0x4e, 0x3e, + 0x51, 0x87, 0xe7, 0x4b, 0xc8, 0xa5, 0x09, 0xdd, 0x27, 0x9c, 0x54, 0x33, 0x21, 0x0b, 0xf9, 0x37, 0x79, 0xaa, 0x46, + 0xb1, 0xa0, 0xf6, 0xea, 0xb9, 0x91, 0xec, 0x8e, 0x3e, 0xcb, 0x51, 0xf8, 0x6a, 0x1c, 0x6e, 0xb5, 0xc2, 0xae, 0x07, + 0x21, 0x2f, 0xbe, 0x70, 0x73, 0xbf, 0xf9, 0x9a, 0x53, 0xd0, 0xfd, 0xa9, 0x03, 0xcf, 0x6d, 0xf1, 0x8a, 0x66, 0x77, + 0x54, 0x07, 0x16, 0xed, 0xfd, 0xfb, 0xa0, 0x1c, 0xb7, 0xf5, 0x59, 0x07, 0xee, 0xfe, 0x91, 0xd8, 0x8d, 0x81, 0xbc, + 0x41, 0x19, 0xef, 0x67, 0x3f, 0xa5, 0x0f, 0x45, 0x42, 0x36, 0xac, 0x31, 0x40, 0x8e, 0x5c, 0x98, 0xf5, 0xb8, 0x31, + 0x67, 0xa7, 0x5d, 0x1e, 0x4a, 0xd0, 0xdd, 0xd6, 0xfe, 0xe3, 0x7a, 0x84, 0xb3, 0xb8, 0x15, 0x60, 0xf2, 0x77, 0x6e, + 0x2c, 0xbb, 0xaa, 0xdb, 0x0b, 0x87, 0x9e, 0x1e, 0xa9, 0xe0, 0xbd, 0xd1, 0x9c, 0x64, 0x5a, 0xb5, 0xbd, 0xda, 0x9f, + 0xfd, 0x92, 0x7f, 0xab, 0x74, 0xbf, 0x6d, 0x09, 0x39, 0x72, 0xb1, 0x82, 0x5d, 0x67, 0x92, 0xc2, 0xf6, 0xd7, 0x2d, + 0x77, 0xcc, 0x69, 0x70, 0xe2, 0x66, 0x4b, 0xe4, 0x3b, 0x7c, 0x1b, 0xc8, 0x26, 0x50, 0x94, 0xfd, 0x38, 0xc0, 0x3e, + 0x8c, 0xa9, 0xb4, 0x49, 0x46, 0x2b, 0x6f, 0xf4, 0xbe, 0x7d, 0x57, 0x28, 0x63, 0xcf, 0x8a, 0x05, 0xb9, 0x8a, 0x84, + 0x1c, 0xb0, 0x1e, 0xcb, 0x54, 0x42, 0x87, 0xc6, 0x73, 0x17, 0xd1, 0x97, 0x45, 0x74, 0xef, 0xe5, 0xbe, 0x4f, 0x62, + 0x9b, 0xd6, 0xdb, 0x29, 0x8f, 0xe4, 0x7f, 0xc4, 0xf8, 0x43, 0x56, 0x05, 0x79, 0x00, 0x1e, 0xef, 0xaf, 0x36, 0x74, + 0x9d, 0xa7, 0x41, 0x99, 0x71, 0x10, 0x45, 0x40, 0xe9, 0x72, 0x53, 0xe4, 0x9c, 0x6e, 0x68, 0x94, 0x4a, 0xd9, 0x16, + 0x83, 0xc0, 0xc8, 0x56, 0x35, 0xea, 0xc5, 0xfc, 0x10, 0x9a, 0x26, 0xa3, 0x3f, 0x9e, 0x49, 0x59, 0x0d, 0xe5, 0xdc, + 0xc5, 0x3a, 0x39, 0xb6, 0x8c, 0x7d, 0x0d, 0xd1, 0x07, 0x87, 0xad, 0xfa, 0x11, 0x33, 0x0f, 0x79, 0xf7, 0x50, 0x80, + 0x81, 0xf9, 0xae, 0x27, 0xdf, 0x94, 0xd2, 0xad, 0xca, 0x52, 0x69, 0x04, 0xa1, 0x0a, 0x6c, 0xb2, 0x37, 0x3c, 0x1a, + 0xe8, 0x89, 0x92, 0x8b, 0x91, 0xc1, 0x14, 0x48, 0x00, 0xd5, 0xb4, 0x0f, 0x7f, 0x4d, 0x2d, 0x94, 0x8c, 0xf4, 0x52, + 0x60, 0x0e, 0xe9, 0xbf, 0x21, 0x21, 0x60, 0x32, 0x00, 0xab, 0x2f, 0xfc, 0x66, 0x12, 0xff, 0x98, 0x0f, 0x7c, 0x04, + 0x9f, 0x30, 0x51, 0x23, 0x52, 0xfe, 0x41, 0x79, 0x9f, 0x8e, 0x9c, 0x29, 0x59, 0x3b, 0x2b, 0x05, 0x0e, 0x15, 0x57, + 0x53, 0x18, 0xc2, 0xd3, 0x83, 0xb0, 0x88, 0xa1, 0x1b, 0xc8, 0x7a, 0xb0, 0xe3, 0x09, 0xd3, 0x88, 0xda, 0x64, 0xaa, + 0x86, 0x92, 0xf6, 0x47, 0xc1, 0xe2, 0xc0, 0x9a, 0x00, 0xe4, 0x58, 0x68, 0x5a, 0x74, 0x19, 0x91, 0x79, 0xb0, 0x14, + 0x8e, 0xc0, 0xa9, 0x09, 0xb9, 0x9e, 0x55, 0xe6, 0x3d, 0x4f, 0x0a, 0x0e, 0xe2, 0x09, 0xf6, 0xce, 0x18, 0xf1, 0x4e, + 0x9e, 0x5d, 0xed, 0x4f, 0xb9, 0xde, 0x05, 0x2f, 0xb9, 0x8c, 0x20, 0x97, 0x39, 0x7e, 0x31, 0x98, 0x86, 0xfb, 0x07, + 0x30, 0x17, 0x19, 0x82, 0x7c, 0xe8, 0x50, 0x82, 0x3b, 0x2c, 0x46, 0x9b, 0xd5, 0xc0, 0xc3, 0x8d, 0x22, 0x4b, 0x26, + 0x83, 0x80, 0x08, 0x4c, 0xab, 0x7c, 0x47, 0x05, 0x70, 0x15, 0x17, 0xda, 0x98, 0xa2, 0xb8, 0x5e, 0x51, 0xed, 0x38, + 0xa3, 0xbd, 0x64, 0x33, 0xf3, 0x71, 0x9a, 0x96, 0x36, 0xd4, 0x6a, 0xe2, 0xd4, 0x91, 0x14, 0xcd, 0xd0, 0x79, 0x73, + 0x91, 0x8a, 0x64, 0xa6, 0x0f, 0xe6, 0x0f, 0x1d, 0x09, 0x6c, 0x94, 0x56, 0x30, 0xc8, 0xf9, 0x1a, 0x3b, 0x73, 0x97, + 0xb6, 0xbe, 0xce, 0xda, 0x30, 0xe7, 0xd3, 0x55, 0x3f, 0x4d, 0x09, 0x54, 0x3b, 0x4d, 0xfd, 0xd9, 0x8a, 0xd8, 0x2f, + 0xd2, 0x2e, 0xcb, 0x42, 0x93, 0xe5, 0xde, 0x8f, 0x1f, 0xee, 0xe3, 0x61, 0xa1, 0xba, 0x0b, 0x73, 0x29, 0x47, 0x38, + 0xb2, 0x58, 0x8b, 0xd5, 0x31, 0xfb, 0x19, 0x25, 0x1b, 0xcb, 0x7d, 0x0f, 0x4a, 0xb2, 0xe3, 0xe5, 0xa5, 0x34, 0x97, + 0x7a, 0xf1, 0x5d, 0x0c, 0x96, 0x03, 0xfc, 0x59, 0xa1, 0x9a, 0xe8, 0x5d, 0x59, 0xad, 0xf4, 0x9f, 0x75, 0xc9, 0x45, + 0x5d, 0x39, 0xe3, 0xda, 0x93, 0x21, 0x4c, 0x13, 0x9a, 0xef, 0x18, 0x62, 0x53, 0xc5, 0x44, 0x49, 0x34, 0xd2, 0x36, + 0x70, 0xbc, 0x7f, 0x5e, 0x9f, 0x45, 0x2a, 0x72, 0xd9, 0x2f, 0xd7, 0x71, 0xc7, 0x2f, 0x40, 0x15, 0xc0, 0x0d, 0x42, + 0x0f, 0x72, 0x02, 0xc3, 0xd8, 0x39, 0x3d, 0xd2, 0x88, 0xc2, 0x29, 0xe9, 0x4e, 0x59, 0x5a, 0x87, 0x37, 0x34, 0xde, + 0xa5, 0x07, 0x51, 0x1a, 0x15, 0xf1, 0x53, 0xd2, 0x1b, 0x9b, 0xd1, 0xa9, 0xae, 0xd1, 0x6f, 0x9a, 0x8b, 0x18, 0x1b, + 0x58, 0x50, 0xef, 0xff, 0x74, 0x00, 0x4a, 0x4c, 0xe6, 0x2d, 0x63, 0x8e, 0x89, 0x90, 0x32, 0xb7, 0x92, 0xef, 0x93, + 0x88, 0xca, 0x3c, 0x66, 0x38, 0xe3, 0x17, 0x19, 0x23, 0xea, 0x66, 0x71, 0x7c, 0x6a, 0xdd, 0x82, 0x49, 0x37, 0xf3, + 0xae, 0xcc, 0x40, 0x1a, 0x44, 0x9e, 0x6a, 0xe9, 0x29, 0xa8, 0x9e, 0x2e, 0xab, 0xae, 0x5e, 0x29, 0xf2, 0xf9, 0x1f, + 0x0c, 0xc3, 0xe1, 0x00, 0xe0, 0xc0, 0xea, 0x73, 0xae, 0xf6, 0xda, 0x9f, 0xad, 0x69, 0xeb, 0x80, 0xd3, 0x13, 0x92, + 0xa7, 0x3f, 0x04, 0xe8, 0x1a, 0xcc, 0x32, 0x54, 0xe7, 0x3c, 0x54, 0xfd, 0xed, 0xa2, 0x2d, 0x0e, 0xc7, 0x0c, 0x04, + 0xda, 0x9b, 0x7b, 0xdc, 0xe2, 0xf7, 0x2c, 0x91, 0xce, 0xc3, 0x04, 0x5b, 0x34, 0xea, 0xf6, 0x48, 0x4e, 0xec, 0x56, + 0x0f, 0x96, 0x3b, 0x0e, 0x07, 0x86, 0x9e, 0xed, 0x22, 0x2a, 0x0d, 0x12, 0xec, 0x7e, 0x2e, 0x51, 0x01, 0x91, 0x0e, + 0xba, 0xc3, 0xe4, 0xfb, 0x1e, 0x4b, 0x6b, 0xea, 0xcf, 0xdd, 0x68, 0xe0, 0x0a, 0x76, 0xb8, 0xc2, 0x4a, 0x5d, 0x6e, + 0xdc, 0x4d, 0x87, 0xb3, 0xce, 0xb1, 0x50, 0xb9, 0x1d, 0x1d, 0x7c, 0xbc, 0xde, 0x58, 0x7b, 0xe7, 0x88, 0x1c, 0xa0, + 0xb2, 0x71, 0x18, 0x70, 0xa9, 0x86, 0x9a, 0xa9, 0x0c, 0x81, 0xd6, 0x8d, 0x61, 0x96, 0xc3, 0x29, 0xfa, 0x3e, 0x75, + 0xec, 0x99, 0x62, 0x23, 0x95, 0x4b, 0xfb, 0xd0, 0x34, 0x35, 0x07, 0x9d, 0xbc, 0x3b, 0x05, 0x42, 0x7f, 0xc5, 0xe0, + 0xe1, 0x81, 0x6c, 0xff, 0xf1, 0xdc, 0xff, 0x7a, 0x73, 0x2e, 0xb6, 0x97, 0x39, 0x70, 0x08, 0x93, 0x7d, 0xd4, 0x12, + 0x0a, 0xa0, 0x48, 0xe6, 0xa6, 0x7a, 0x90, 0xab, 0x77, 0x03, 0xfa, 0x84, 0x8b, 0xb1, 0x49, 0xda, 0xa7, 0xc7, 0x1b, + 0x8c, 0x7c, 0x9e, 0x36, 0xbd, 0x76, 0x21, 0x55, 0xbe, 0xd8, 0x9b, 0xed, 0x17, 0x27, 0x9b, 0xe0, 0x24, 0x27, 0xca, + 0x8e, 0x6d, 0x16, 0xc3, 0x3b, 0xed, 0xd2, 0xbf, 0x6f, 0x5b, 0xb9, 0x81, 0x4b, 0x38, 0xb4, 0x43, 0x15, 0xcc, 0x7b, + 0x70, 0xe8, 0xf5, 0x83, 0x0d, 0x8e, 0xea, 0x41, 0x77, 0x60, 0xfa, 0xb4, 0xd6, 0x09, 0x06, 0x84, 0xef, 0x56, 0x30, + 0x0a, 0x01, 0xc7, 0x5b, 0xd7, 0x2e, 0x94, 0x7b, 0x3e, 0xe0, 0x07, 0x41, 0x85, 0x33, 0x43, 0x68, 0x7e, 0x10, 0x39, + 0xa5, 0x3d, 0xa5, 0xa4, 0xba, 0xba, 0x35, 0x0e, 0x37, 0xe0, 0xb3, 0x81, 0xe2, 0x68, 0xbb, 0xf1, 0x4e, 0x12, 0x87, + 0xf9, 0x28, 0x65, 0xe6, 0xd2, 0xfb, 0xce, 0x09, 0x30, 0xf1, 0x4e, 0xed, 0xf4, 0x09, 0x9e, 0xe8, 0x5b, 0x1f, 0x55, + 0xb5, 0x7d, 0xb1, 0xe7, 0x9b, 0xab, 0xee, 0x90, 0x43, 0x94, 0x10, 0x62, 0xf6, 0xa9, 0x73, 0xcc, 0x73, 0x3e, 0x4b, + 0x07, 0x13, 0xf6, 0xdc, 0x16, 0x40, 0xab, 0x46, 0x05, 0xba, 0x72, 0x40, 0x5e, 0xc2, 0x57, 0xb7, 0x4e, 0xe8, 0xd2, + 0x41, 0x7a, 0x2b, 0xbf, 0x5c, 0x35, 0x89, 0x40, 0xf7, 0xc2, 0x7b, 0x8f, 0xe6, 0x4e, 0x74, 0x9c, 0x89, 0x3b, 0xb8, + 0xe8, 0x27, 0xce, 0x69, 0x7c, 0x24, 0xee, 0x12, 0xf9, 0x2c, 0xa6, 0x01, 0x31, 0x4f, 0x84, 0xf8, 0xab, 0x9f, 0xb9, + 0x84, 0x8d, 0x0a, 0x66, 0xea, 0x6e, 0x91, 0xd3, 0xca, 0x16, 0x13, 0x28, 0xdc, 0x5f, 0x74, 0xc3, 0xad, 0x59, 0xbe, + 0x13, 0x0b, 0x30, 0x2d, 0x03, 0x5f, 0xda, 0x39, 0x20, 0x45, 0x44, 0x7a, 0x4f, 0xde, 0xce, 0xff, 0x9b, 0xda, 0x7b, + 0xc5, 0x7f, 0xb4, 0xb9, 0x44, 0x71, 0x3a, 0x6d, 0x0a, 0x4b, 0xe1, 0xdb, 0x3d, 0x02, 0x21, 0x32, 0x46, 0x04, 0x9a, + 0x31, 0x7f, 0xd0, 0x0e, 0x73, 0x0a, 0xbc, 0xc3, 0x01, 0x70, 0x14, 0xb6, 0xd4, 0x0f, 0x36, 0x78, 0x70, 0x8f, 0x77, + 0xbd, 0x94, 0x3a, 0x56, 0x0e, 0x08, 0xcb, 0x1d, 0x85, 0xe3, 0x20, 0x83, 0x40, 0xd5, 0x21, 0xf6, 0x0e, 0xca, 0x3a, + 0x1d, 0xdd, 0x3a, 0x0c, 0xa9, 0xd0, 0x3b, 0xdf, 0x2a, 0x32, 0x1f, 0xb3, 0x5a, 0xe3, 0xa0, 0xfa, 0x00, 0x4e, 0xc0, + 0x6a, 0x46, 0x1c, 0x3d, 0xcd, 0xcb, 0x3d, 0xcd, 0xbc, 0x80, 0x00, 0x67, 0xf8, 0x83, 0x1d, 0xce, 0xd9, 0x3b, 0xef, + 0x81, 0xd2, 0x0d, 0x80, 0xda, 0xc4, 0x69, 0x59, 0xb8, 0x15, 0xbf, 0x5a, 0x7d, 0x23, 0x79, 0x7b, 0x6e, 0x9f, 0x8e, + 0x78, 0x0f, 0x0f, 0x5e, 0x2a, 0x5a, 0xc8, 0x60, 0x65, 0x82, 0xad, 0x06, 0xc2, 0xca, 0x10, 0x0b, 0xfa, 0x68, 0x5e, + 0xab, 0xee, 0x6a, 0x84, 0xea, 0xff, 0xea, 0x29, 0x98, 0xb2, 0x05, 0xaf, 0x38, 0xa9, 0xe9, 0x86, 0x93, 0xb4, 0xd4, + 0x8a, 0xa3, 0xf9, 0xb1, 0x13, 0x06, 0x05, 0xb1, 0x1d, 0x22, 0xfe, 0xf4, 0x7f, 0x96, 0x28, 0x4b, 0xb8, 0xd5, 0x1c, + 0x51, 0xb6, 0xf4, 0x8e, 0x23, 0xe2, 0xdf, 0x8f, 0x78, 0x57, 0x07, 0x11, 0xaa, 0xc6, 0x7c, 0x52, 0x64, 0xfe, 0x2b, + 0xce, 0xf2, 0x46, 0xb8, 0xdb, 0xcc, 0xee, 0xeb, 0x9d, 0x2f, 0xe4, 0x22, 0x39, 0x3f, 0xcc, 0x2d, 0xdb, 0xce, 0xcb, + 0x4b, 0x3b, 0x55, 0xd2, 0xd6, 0xf3, 0xd3, 0xf2, 0x43, 0x8c, 0x23, 0x22, 0x2d, 0xcb, 0x30, 0xba, 0xf3, 0xec, 0x1c, + 0xbe, 0xff, 0x4e, 0xb9, 0xfa, 0xfe, 0xb3, 0x75, 0xc5, 0xb1, 0x35, 0x2e, 0xdf, 0xf1, 0x50, 0xea, 0xf8, 0xcc, 0x30, + 0xd4, 0xda, 0x40, 0x30, 0xb1, 0x95, 0x6d, 0x18, 0x03, 0x40, 0xef, 0x47, 0xb6, 0x18, 0xfa, 0x0b, 0xce, 0xa5, 0xd5, + 0xcd, 0x0b, 0xb9, 0x64, 0x7e, 0xe0, 0x1e, 0xe3, 0x03, 0xef, 0xfa, 0x83, 0xeb, 0x11, 0x3b, 0x91, 0x01, 0x0c, 0xa9, + 0x18, 0x5c, 0xe4, 0x3d, 0xe6, 0xf3, 0xae, 0x14, 0x21, 0xe4, 0x21, 0x4b, 0x01, 0xae, 0x5d, 0xfd, 0xb9, 0x2c, 0xcf, + 0xbc, 0x9f, 0xcf, 0xdb, 0x1c, 0x70, 0x58, 0xa8, 0xbe, 0x2c, 0x60, 0x1c, 0xfe, 0xa1, 0x18, 0x33, 0x81, 0x59, 0x1b, + 0x5e, 0x3f, 0xeb, 0x39, 0x47, 0x53, 0x33, 0x6a, 0x3b, 0xa5, 0x9e, 0xe5, 0xcb, 0xaa, 0xcd, 0x16, 0xcb, 0x90, 0x1b, + 0x1a, 0x1f, 0x27, 0x0d, 0x12, 0xe3, 0xaf, 0x09, 0xb4, 0x5c, 0x24, 0x6d, 0x44, 0x62, 0xd5, 0x8a, 0x56, 0x14, 0x2b, + 0xa3, 0x58, 0x88, 0x95, 0xfc, 0x56, 0xc9, 0xd3, 0x88, 0x39, 0xe5, 0xf1, 0xac, 0xdf, 0x95, 0xa3, 0x11, 0x50, 0xe1, + 0x60, 0x95, 0x4f, 0x7b, 0x6c, 0xed, 0x77, 0xf6, 0x3b, 0x28, 0xa5, 0xc4, 0x4e, 0x20, 0xd8, 0x27, 0x53, 0x1c, 0x00, + 0x2b, 0x9b, 0x23, 0xfb, 0x1b, 0x4e, 0xbf, 0x7a, 0xeb, 0x08, 0x68, 0x5d, 0x76, 0x4e, 0x75, 0xb4, 0x43, 0xef, 0x0b, + 0x32, 0x8d, 0x63, 0x41, 0x7e, 0x5a, 0x89, 0xcd, 0xe0, 0x31, 0x3a, 0x08, 0xd3, 0x2f, 0xac, 0x7d, 0xd5, 0xc8, 0x36, + 0x58, 0x62, 0xb6, 0xec, 0x58, 0xec, 0x5a, 0x27, 0x56, 0xce, 0xa8, 0x77, 0xef, 0xf9, 0x02, 0x9f, 0x54, 0x5b, 0xc9, + 0x0a, 0x38, 0x33, 0x81, 0x75, 0x14, 0x80, 0x6b, 0xec, 0x43, 0xea, 0x03, 0x8a, 0x83, 0xfa, 0x8a, 0xe3, 0xb3, 0x04, + 0x8b, 0x12, 0x42, 0x60, 0x9f, 0x74, 0xeb, 0x86, 0x4a, 0x38, 0x39, 0x4b, 0xda, 0x8f, 0xf0, 0x14, 0xc6, 0x0d, 0x2a, + 0x05, 0x9b, 0x8b, 0xf1, 0x45, 0xc5, 0x32, 0x85, 0xb3, 0x18, 0xd3, 0x61, 0xff, 0xb4, 0x4a, 0x58, 0x46, 0x1f, 0x1f, + 0x16, 0x16, 0x6e, 0xa6, 0x10, 0x4b, 0x4a, 0xfa, 0xfe, 0x80, 0xcd, 0xd7, 0x52, 0xff, 0xbf, 0xe6, 0x0a, 0x2a, 0xd8, + 0x8a, 0xb9, 0xe3, 0xf0, 0x77, 0xa8, 0xe0, 0xed, 0x2d, 0xf3, 0xa4, 0x6a, 0x6b, 0xeb, 0xbe, 0xa4, 0x16, 0x08, 0x2f, + 0x79, 0xfa, 0x89, 0xc3, 0x1d, 0xde, 0x8d, 0x33, 0x26, 0xc3, 0xab, 0x7b, 0x80, 0x24, 0x21, 0x20, 0xa1, 0x75, 0x5f, + 0x77, 0x0f, 0x06, 0x77, 0xb4, 0xc6, 0xf7, 0x20, 0xf1, 0x9e, 0x6f, 0xc6, 0xdb, 0x84, 0x5f, 0xa6, 0x7f, 0x69, 0x55, + 0xc7, 0x6a, 0xec, 0x83, 0x2a, 0xc6, 0xf5, 0x0f, 0xcf, 0xfd, 0xe9, 0x01, 0xb3, 0xcf, 0xfd, 0xcc, 0x26, 0x9a, 0x44, + 0x9f, 0xde, 0x5f, 0x4a, 0x5c, 0x78, 0xab, 0xf1, 0x96, 0xbf, 0xb4, 0xe2, 0xc2, 0xf9, 0x4a, 0xb7, 0xdb, 0x85, 0xa3, + 0xc3, 0x46, 0xe2, 0x69, 0xa3, 0x26, 0x80, 0x4c, 0xdf, 0x8a, 0xa9, 0xa4, 0x0b, 0x2b, 0xc8, 0xb4, 0x4f, 0xd2, 0x8d, + 0xc6, 0x53, 0x90, 0x4a, 0xb3, 0x58, 0x3d, 0x99, 0x19, 0xda, 0x68, 0x3d, 0x1c, 0x67, 0xd0, 0xff, 0x92, 0x18, 0xca, + 0x7a, 0xd9, 0xb6, 0x30, 0x5b, 0xa6, 0xba, 0xae, 0x3f, 0x6e, 0xa4, 0x95, 0xcc, 0xaa, 0x57, 0xd0, 0xf1, 0xf5, 0xfe, + 0x6d, 0x05, 0x4f, 0x24, 0x8a, 0x0f, 0x7b, 0xb7, 0x90, 0x68, 0x2d, 0x51, 0x2c, 0xe2, 0xfd, 0x32, 0x1d, 0xc7, 0x00, + 0xfc, 0xc1, 0xd0, 0x2d, 0x1d, 0xa7, 0xe9, 0xb1, 0xb2, 0x77, 0x68, 0x1f, 0x4a, 0x8a, 0x62, 0xb9, 0x48, 0xf8, 0x98, + 0x2d, 0xe0, 0xb8, 0x58, 0xa8, 0x86, 0xf6, 0x08, 0x16, 0x6d, 0x89, 0x2d, 0x7a, 0x4a, 0x8f, 0xa3, 0x1c, 0x23, 0xa6, + 0x51, 0xca, 0x97, 0xd1, 0xe3, 0x69, 0x4a, 0x00, 0x6d, 0x36, 0x64, 0x37, 0xef, 0x49, 0x12, 0xd6, 0xe4, 0x36, 0xb9, + 0x00, 0xb6, 0x7f, 0xe6, 0x54, 0xca, 0x5d, 0x1c, 0x44, 0x29, 0xed, 0xdc, 0xfc, 0x6e, 0x0e, 0xbc, 0x96, 0xeb, 0x22, + 0x31, 0xc6, 0xc8, 0x93, 0x2b, 0xa1, 0xa8, 0x65, 0xda, 0xf2, 0xae, 0x39, 0x12, 0xfc, 0xc2, 0x6b, 0xed, 0xad, 0x04, + 0x32, 0xd6, 0x65, 0xf8, 0x66, 0x74, 0x13, 0xb4, 0xf5, 0x9f, 0xec, 0x4d, 0xd7, 0x1b, 0xc4, 0xf2, 0xf3, 0xab, 0xba, + 0xea, 0xc8, 0xb3, 0xff, 0x50, 0xfb, 0x4e, 0x08, 0x2a, 0xe7, 0x26, 0x0c, 0xeb, 0x49, 0x7c, 0x2e, 0x3a, 0xde, 0x9d, + 0x48, 0x92, 0x16, 0xba, 0x3e, 0x93, 0x8e, 0x06, 0x0d, 0x93, 0x54, 0x50, 0x2e, 0x96, 0x81, 0xdf, 0x66, 0x29, 0xd1, + 0x8c, 0x88, 0x74, 0xaa, 0x56, 0x9f, 0x4c, 0x5f, 0xd4, 0x40, 0x5c, 0xaf, 0x02, 0x2b, 0x89, 0xfa, 0x4a, 0xff, 0x6d, + 0x0e, 0x35, 0x95, 0x1c, 0x3c, 0xf6, 0x7b, 0x03, 0xa3, 0x68, 0x52, 0x3d, 0xa9, 0xbb, 0x54, 0x38, 0xed, 0x04, 0x95, + 0x72, 0xe5, 0x29, 0x35, 0xe0, 0xa9, 0x5d, 0x1c, 0xf9, 0x99, 0x1f, 0x4c, 0x77, 0xc9, 0x9f, 0xb8, 0x17, 0xbf, 0xb0, + 0x0d, 0xa9, 0xfa, 0xcb, 0x1f, 0x6a, 0x03, 0xb2, 0x39, 0x09, 0xf5, 0xde, 0x8f, 0x43, 0x46, 0x35, 0xf7, 0x5b, 0xc7, + 0xe6, 0xf2, 0xa7, 0xdf, 0xde, 0xbd, 0xb9, 0xf0, 0x1b, 0xb4, 0x06, 0x65, 0xdd, 0xed, 0xaf, 0x6b, 0x78, 0x4a, 0xc5, + 0xbb, 0x2d, 0xc3, 0xcd, 0x92, 0xd1, 0x03, 0x90, 0x0f, 0xea, 0x9d, 0xff, 0xcc, 0xb5, 0x35, 0x4f, 0x75, 0x43, 0x73, + 0xb5, 0x08, 0x95, 0x33, 0x7f, 0x63, 0x18, 0xa9, 0x55, 0x4f, 0xf7, 0x64, 0x79, 0x63, 0x46, 0x03, 0xf7, 0x80, 0xa1, + 0x32, 0xc0, 0x8d, 0x96, 0x2e, 0x86, 0xd2, 0x5b, 0xd1, 0x97, 0xb6, 0xc5, 0xa6, 0x74, 0x5f, 0x6c, 0x4b, 0xeb, 0x62, + 0xb7, 0x71, 0x05, 0xdb, 0x92, 0xfe, 0x71, 0xfd, 0x1b, 0x7a, 0xa6, 0xd0, 0x63, 0xec, 0x2c, 0x3e, 0xe3, 0x65, 0xac, + 0xf5, 0x8d, 0x14, 0x59, 0xc1, 0x5b, 0xe3, 0x22, 0xd6, 0xc6, 0x0f, 0x25, 0x7b, 0x85, 0x71, 0x5f, 0x16, 0x58, 0x92, + 0x35, 0x1d, 0x0c, 0x8c, 0x54, 0x95, 0x56, 0xd2, 0x6d, 0x69, 0xf6, 0xdf, 0x2d, 0x5d, 0xc5, 0xc6, 0x42, 0xf3, 0x4b, + 0xaf, 0x74, 0x7a, 0x43, 0x62, 0x4d, 0xf6, 0xf3, 0x83, 0x0e, 0xc0, 0x8a, 0xd6, 0x45, 0x55, 0x91, 0x61, 0xbe, 0x56, + 0x91, 0x66, 0xd8, 0x13, 0x5c, 0x09, 0xd0, 0x40, 0xf5, 0x99, 0xa3, 0xf6, 0x21, 0x8e, 0x24, 0x56, 0xa3, 0x53, 0x0d, + 0xd9, 0x17, 0x45, 0x6c, 0x55, 0xbb, 0xa5, 0x46, 0xa9, 0x1a, 0x5d, 0xa2, 0x82, 0xca, 0x6f, 0x47, 0x8f, 0x88, 0x68, + 0x39, 0x6b, 0xf4, 0x21, 0x3e, 0x1f, 0x4d, 0xaf, 0x2b, 0x4e, 0x69, 0x80, 0x34, 0x48, 0x21, 0xb1, 0xa8, 0x74, 0xc1, + 0xcf, 0x04, 0xa4, 0xe5, 0x05, 0xfa, 0xd1, 0x55, 0x0c, 0x93, 0x33, 0x3c, 0x30, 0xf9, 0xad, 0xa3, 0x44, 0x7e, 0xb2, + 0xda, 0xe1, 0x37, 0xfc, 0xa5, 0xc5, 0x75, 0xa1, 0xf1, 0x96, 0x2b, 0xbf, 0x54, 0xcd, 0x55, 0x4c, 0xa1, 0x4b, 0x9f, + 0xc9, 0x6a, 0x86, 0x0c, 0xa6, 0xae, 0x62, 0x28, 0x01, 0x81, 0x6f, 0x40, 0x4a, 0x95, 0x41, 0x87, 0x10, 0x42, 0x6f, + 0xd0, 0x2a, 0x44, 0x74, 0x19, 0x36, 0x9f, 0x90, 0xaa, 0xb9, 0xce, 0x65, 0xf5, 0x68, 0xfe, 0x20, 0x26, 0xc1, 0x34, + 0xfc, 0x41, 0x23, 0x69, 0xb4, 0x07, 0x89, 0xc3, 0xa4, 0xd7, 0x21, 0xf4, 0x83, 0x37, 0xbd, 0x23, 0x0c, 0xdf, 0xfa, + 0xa4, 0xd3, 0xe3, 0x56, 0x92, 0xcb, 0xbf, 0x86, 0x95, 0x67, 0xa6, 0xd7, 0x26, 0xfc, 0x11, 0xfe, 0xa9, 0x0f, 0x66, + 0x75, 0x7b, 0x7b, 0x34, 0xc3, 0x6e, 0x68, 0x0c, 0x7f, 0x77, 0xc0, 0x5b, 0xf8, 0xc1, 0xf4, 0x35, 0x27, 0x76, 0x47, + 0xaf, 0x59, 0xb8, 0xce, 0xe7, 0xd1, 0xf8, 0x59, 0x9a, 0x17, 0xac, 0x3c, 0x79, 0x70, 0xdf, 0xfa, 0xde, 0x67, 0x12, + 0x19, 0x2f, 0x3f, 0x75, 0x79, 0xad, 0x2d, 0x27, 0x83, 0xf2, 0xc2, 0x52, 0xf7, 0xc3, 0x1e, 0xa7, 0x2f, 0xb5, 0xf6, + 0x97, 0x7a, 0xed, 0xb3, 0xcf, 0xa6, 0x26, 0x8f, 0x31, 0x3c, 0x1d, 0x4d, 0xdd, 0xd3, 0xc2, 0xfa, 0x16, 0x59, 0x21, + 0x36, 0xc7, 0xb7, 0xcb, 0xd1, 0xd3, 0x59, 0x6d, 0x2f, 0xae, 0xd0, 0xb4, 0x93, 0xd3, 0xa9, 0x13, 0x37, 0xaf, 0xa3, + 0x58, 0xd2, 0xb4, 0x8f, 0xef, 0xc7, 0x72, 0x87, 0xeb, 0x8a, 0x7a, 0x40, 0xd0, 0xa8, 0xa0, 0x17, 0x4c, 0xf5, 0xf8, + 0x74, 0x23, 0x40, 0x5d, 0x78, 0x3a, 0xb1, 0x4e, 0x53, 0xfd, 0x3d, 0xe0, 0x65, 0x60, 0x9a, 0x06, 0x5b, 0x3f, 0x54, + 0x92, 0x20, 0x97, 0x19, 0x6f, 0xfb, 0xf6, 0xfc, 0xf5, 0x3e, 0x5e, 0x58, 0x6a, 0x05, 0xf3, 0x5b, 0x7c, 0x0e, 0x52, + 0xb3, 0x80, 0x3b, 0x2a, 0x59, 0x84, 0x23, 0x88, 0x96, 0x77, 0xc8, 0x53, 0xc7, 0x01, 0xe9, 0xa0, 0x3a, 0x67, 0x24, + 0xe6, 0xf3, 0x5f, 0xed, 0x7b, 0x26, 0xf5, 0x7d, 0x0f, 0x5b, 0xaf, 0xde, 0x1d, 0x48, 0x39, 0xf4, 0x49, 0xf5, 0x19, + 0x68, 0x32, 0xf7, 0xdd, 0x56, 0x3a, 0x7d, 0xa3, 0xcf, 0xd6, 0xb5, 0xbb, 0x50, 0xf3, 0xd3, 0x54, 0xfa, 0xc4, 0x5e, + 0x39, 0x1b, 0xf5, 0x19, 0x94, 0x25, 0x73, 0x01, 0x04, 0x49, 0x8b, 0x40, 0x07, 0x3a, 0x71, 0xb6, 0x29, 0xd3, 0x40, + 0x74, 0x49, 0x6b, 0xce, 0xf8, 0x61, 0x9e, 0x9d, 0xe4, 0xd6, 0x7e, 0xcf, 0x49, 0x5c, 0x85, 0x73, 0xa8, 0xa0, 0xa0, + 0x79, 0x3c, 0xd1, 0x36, 0xf8, 0xaf, 0x17, 0xba, 0xc9, 0x89, 0x7c, 0x1e, 0xe6, 0x9c, 0xb6, 0x8c, 0x31, 0x42, 0x03, + 0x70, 0xd1, 0xf4, 0xea, 0x28, 0x60, 0xb9, 0x0b, 0x84, 0xdf, 0xf2, 0x79, 0xb7, 0xdd, 0xb6, 0xaa, 0x05, 0xa9, 0x76, + 0x62, 0x17, 0xd5, 0xcc, 0x32, 0x45, 0x06, 0xce, 0x00, 0x4f, 0xb6, 0x6f, 0x0b, 0xd9, 0xf8, 0xa0, 0xbd, 0xe9, 0xd2, + 0xe9, 0x51, 0x16, 0xf0, 0x83, 0x94, 0x93, 0x16, 0x9e, 0x1d, 0x43, 0xb1, 0x4d, 0x79, 0xb9, 0x2f, 0xf8, 0xd4, 0x35, + 0x86, 0xd4, 0x50, 0xda, 0x6c, 0x19, 0x29, 0xbc, 0x9b, 0x37, 0x06, 0x5c, 0xd2, 0xe2, 0xbd, 0x88, 0x31, 0xe0, 0xe1, + 0xfa, 0xa2, 0x45, 0x88, 0x27, 0x08, 0x73, 0xb8, 0x61, 0x86, 0x01, 0x74, 0x22, 0xe0, 0x60, 0x3a, 0xbd, 0xbd, 0x0e, + 0x7e, 0x4f, 0x56, 0x68, 0x4b, 0xaf, 0xe6, 0x0d, 0xb7, 0xab, 0x51, 0xba, 0xa1, 0x6d, 0x06, 0xb3, 0x7e, 0x3e, 0xf9, + 0x0d, 0xe5, 0xaa, 0xb3, 0x9a, 0xbf, 0x5c, 0xb0, 0x68, 0x75, 0x36, 0x73, 0x27, 0x9d, 0x1a, 0xdd, 0x53, 0xd5, 0x7a, + 0xea, 0x41, 0xb3, 0x37, 0xf4, 0x16, 0xd4, 0x14, 0x9a, 0x25, 0xc6, 0x1a, 0x3b, 0x1f, 0xfe, 0x47, 0xb6, 0xf0, 0x35, + 0x6b, 0x0f, 0xb4, 0xb6, 0x72, 0x7f, 0x6d, 0xc7, 0xd7, 0x08, 0x0e, 0xc3, 0x28, 0xc4, 0x09, 0xea, 0xd6, 0x5a, 0x52, + 0xe8, 0x56, 0xa7, 0x43, 0x54, 0x10, 0x93, 0xff, 0xa5, 0x37, 0xf3, 0x2e, 0x3e, 0x75, 0x0c, 0x9d, 0xab, 0x7f, 0x55, + 0x5c, 0x1d, 0x9b, 0xa6, 0xd9, 0xea, 0x5d, 0x3f, 0x17, 0x3e, 0xcc, 0xb4, 0xdf, 0x15, 0x2f, 0x3a, 0x42, 0x81, 0xc7, + 0x0f, 0x1e, 0xf6, 0xf5, 0x95, 0x15, 0xa4, 0x53, 0xcf, 0x27, 0xcc, 0x47, 0x4f, 0xd1, 0x31, 0x70, 0x43, 0x16, 0x13, + 0xef, 0xe3, 0x3a, 0x8b, 0xff, 0x59, 0xf6, 0xe1, 0x4c, 0xdb, 0x69, 0x54, 0x57, 0x8a, 0xc7, 0xb5, 0x08, 0xe8, 0xf3, + 0xe9, 0xe3, 0x12, 0x03, 0xd4, 0x5e, 0xac, 0x8a, 0x63, 0xb3, 0x41, 0x37, 0xbc, 0x2f, 0x84, 0xac, 0x57, 0x3a, 0xe3, + 0x3e, 0x2d, 0x12, 0x40, 0x5c, 0x7f, 0x44, 0x5d, 0x8b, 0xf9, 0xfa, 0xf2, 0xcd, 0xd1, 0xa6, 0xc7, 0x8c, 0x86, 0xc0, + 0x84, 0x59, 0xfb, 0x93, 0x51, 0x4a, 0xa7, 0x4f, 0xd1, 0x8a, 0xcf, 0x4d, 0xe1, 0x99, 0x6b, 0x75, 0x6d, 0x14, 0xe9, + 0x3f, 0x8a, 0xba, 0xf7, 0x31, 0x9c, 0x35, 0xaf, 0xbf, 0x60, 0x37, 0x07, 0xa3, 0x1f, 0x06, 0xcd, 0x41, 0x89, 0x45, + 0xbc, 0x7a, 0x12, 0x1f, 0x73, 0xbc, 0x26, 0x01, 0x3e, 0xe7, 0x39, 0x40, 0xff, 0x1c, 0x53, 0xcc, 0x25, 0x8c, 0xe3, + 0x63, 0x07, 0x54, 0x5b, 0x5b, 0x39, 0x24, 0xff, 0x66, 0xf6, 0x02, 0xb5, 0x59, 0xd7, 0x32, 0xa8, 0xbf, 0x83, 0xbc, + 0xda, 0xf4, 0xc2, 0xca, 0x41, 0xe7, 0x2b, 0x4b, 0xfa, 0xda, 0x04, 0xdd, 0xe2, 0xb2, 0xec, 0x80, 0xcf, 0xbc, 0xde, + 0x5d, 0x11, 0xbf, 0x14, 0xcc, 0x5b, 0xf8, 0x72, 0x1b, 0x9a, 0x70, 0x77, 0xe9, 0xa7, 0xc1, 0x09, 0xcd, 0x91, 0xdf, + 0x26, 0xa3, 0x0f, 0xdf, 0x7a, 0x76, 0xf5, 0xb2, 0x0e, 0xfc, 0x7f, 0xc3, 0x20, 0x10, 0x79, 0xa7, 0xd0, 0x2d, 0x69, + 0x9d, 0x7a, 0x14, 0x4b, 0x57, 0xca, 0x3e, 0xae, 0x5c, 0x7d, 0x74, 0x9b, 0xff, 0x1f, 0xae, 0xe0, 0x5b, 0xa3, 0xf8, + 0x49, 0x0c, 0xd0, 0x81, 0x22, 0x24, 0x3d, 0x22, 0xba, 0x78, 0xd6, 0xe2, 0xf1, 0x5b, 0x50, 0x33, 0xd8, 0xfa, 0x16, + 0xec, 0x04, 0x83, 0x90, 0x3d, 0x62, 0x9d, 0x0d, 0x1d, 0xb8, 0xfc, 0xad, 0x17, 0x65, 0x0e, 0x91, 0xde, 0x7c, 0x57, + 0x38, 0x75, 0x6d, 0xe5, 0x7d, 0xff, 0x97, 0xfa, 0xda, 0x64, 0x9e, 0xf3, 0xeb, 0x54, 0xf2, 0x85, 0xd3, 0x45, 0x57, + 0x21, 0xc6, 0xf1, 0xbb, 0x2b, 0x36, 0xde, 0x19, 0xf7, 0xc5, 0x45, 0xe4, 0xb4, 0xba, 0xf6, 0xd6, 0x4d, 0x0f, 0xba, + 0x71, 0x45, 0xf4, 0x18, 0xbf, 0xc4, 0x4c, 0xf7, 0xe6, 0x87, 0xc4, 0x3a, 0x7e, 0x37, 0xae, 0xf4, 0x5c, 0x4c, 0xe1, + 0x3e, 0x24, 0xf0, 0x3d, 0x7a, 0xb5, 0x42, 0x5c, 0x66, 0xdd, 0xf0, 0x82, 0x08, 0x50, 0x24, 0x00, 0x2b, 0x25, 0x09, + 0xa2, 0x25, 0x81, 0xe5, 0x70, 0xf2, 0xde, 0x56, 0x78, 0x6d, 0x7a, 0x77, 0x88, 0x16, 0x35, 0x2e, 0x54, 0x0c, 0x8f, + 0xbb, 0xa7, 0x93, 0xb9, 0x15, 0xa8, 0x57, 0xec, 0x41, 0x4c, 0x00, 0xa6, 0x05, 0x30, 0x56, 0x84, 0xcf, 0x6b, 0x44, + 0x1c, 0x00, 0x8a, 0x04, 0x0e, 0x30, 0xe2, 0x00, 0xfe, 0xbb, 0x9f, 0xf1, 0xa3, 0xf9, 0x85, 0x60, 0x59, 0xf4, 0x27, + 0xd3, 0x4f, 0xfb, 0x0c, 0x47, 0xe4, 0xf2, 0xe6, 0x21, 0x08, 0xb2, 0xda, 0x1c, 0xec, 0x8a, 0x1f, 0x60, 0x1b, 0xb7, + 0x27, 0xbc, 0xdc, 0x10, 0x5d, 0x3a, 0xab, 0xa4, 0xb4, 0x0b, 0xbe, 0xc0, 0xa5, 0x6f, 0xba, 0xbf, 0xa4, 0x87, 0xd5, + 0xc2, 0x17, 0xe3, 0x9e, 0xd5, 0x30, 0x3f, 0x78, 0xf1, 0xe8, 0xff, 0xac, 0x7a, 0xdd, 0x61, 0x63, 0x1c, 0xfe, 0x31, + 0xe0, 0x87, 0xa0, 0xf9, 0x49, 0xf6, 0xde, 0x47, 0xb7, 0xf6, 0xbd, 0x24, 0x39, 0x99, 0x1e, 0x56, 0x18, 0x4e, 0x3f, + 0x5e, 0x60, 0x55, 0x06, 0x3f, 0x97, 0x25, 0xd5, 0x5d, 0x85, 0x5f, 0x5c, 0x13, 0x61, 0x70, 0x0e, 0xef, 0xf8, 0x02, + 0x40, 0x5a, 0xcc, 0x70, 0x25, 0x5d, 0xeb, 0xf5, 0x77, 0x2f, 0xf8, 0xd6, 0x69, 0x92, 0x48, 0x20, 0x72, 0x5a, 0xc9, + 0xe1, 0x6c, 0x08, 0x4a, 0x4e, 0xca, 0xc3, 0x9c, 0x32, 0x38, 0x4b, 0x95, 0xd3, 0xa2, 0xc0, 0x9f, 0xda, 0xd9, 0xdd, + 0xba, 0xbc, 0x58, 0xd1, 0x1a, 0x4b, 0xf5, 0xbe, 0x0c, 0x35, 0x44, 0xb0, 0xd8, 0xf2, 0x69, 0x4b, 0x98, 0xfd, 0x0d, + 0x66, 0x53, 0x83, 0x08, 0xbf, 0xcf, 0x53, 0x42, 0x57, 0xde, 0x44, 0x04, 0x26, 0x54, 0x1f, 0x9a, 0x22, 0x46, 0x7a, + 0x44, 0xa7, 0x45, 0x42, 0x52, 0xab, 0x34, 0x42, 0x63, 0x0d, 0x89, 0x7e, 0xbf, 0x75, 0xcf, 0xab, 0xe5, 0x38, 0x1e, + 0xa3, 0xf2, 0x47, 0xd1, 0x6f, 0x30, 0x23, 0x17, 0xa4, 0xdd, 0xb0, 0x2b, 0x62, 0x98, 0xb2, 0x60, 0x18, 0xa8, 0xb2, + 0x41, 0x49, 0xe0, 0xb6, 0x62, 0xdb, 0xbf, 0xe3, 0xfb, 0x30, 0x22, 0xda, 0x4d, 0xc0, 0xcf, 0x3c, 0xa1, 0x76, 0x63, + 0x01, 0x1d, 0x7a, 0xc0, 0x6f, 0x58, 0xc3, 0x77, 0x4d, 0x14, 0xe9, 0x04, 0x4e, 0xd0, 0xb2, 0x48, 0xe2, 0xd3, 0xbd, + 0xf1, 0xff, 0x2f, 0x85, 0x54, 0x9f, 0xf7, 0xf7, 0xb7, 0x8d, 0x48, 0x0d, 0x3d, 0x15, 0xa8, 0xc8, 0xb8, 0x02, 0x5b, + 0xf6, 0x78, 0x29, 0x72, 0xc0, 0xc4, 0xe4, 0x5f, 0xb1, 0xc1, 0x4a, 0xe7, 0x8d, 0xe3, 0xd3, 0xbf, 0x60, 0x5a, 0x9c, + 0xed, 0x61, 0x16, 0xf3, 0x30, 0xfe, 0x4b, 0x47, 0x0f, 0x7a, 0xac, 0x87, 0x12, 0x2b, 0xe1, 0xc7, 0x65, 0x3e, 0xdc, + 0xf3, 0x8d, 0x59, 0xbe, 0xde, 0x1f, 0x2e, 0xec, 0x59, 0x89, 0xce, 0x8f, 0x7e, 0x89, 0xc5, 0x38, 0x32, 0xfe, 0x1b, + 0x6d, 0xc9, 0xe6, 0x36, 0xe0, 0x4e, 0x32, 0xa7, 0x77, 0x47, 0x47, 0x23, 0x0b, 0x72, 0x86, 0x25, 0xba, 0xbb, 0xe5, + 0x92, 0xdc, 0x65, 0xce, 0x2e, 0xfb, 0xfc, 0xeb, 0x7d, 0x76, 0xe1, 0x45, 0x7b, 0x4d, 0x9a, 0x4f, 0xd2, 0x06, 0x94, + 0x16, 0xb8, 0x3f, 0x9b, 0xdd, 0x22, 0x2a, 0x11, 0x32, 0x84, 0xf8, 0x82, 0x3b, 0x22, 0x05, 0xfb, 0x1d, 0xdb, 0x54, + 0x3c, 0xd0, 0x8d, 0xa8, 0xd7, 0x83, 0x97, 0x76, 0xdd, 0xf6, 0x8d, 0x01, 0x37, 0x4c, 0xd6, 0x2a, 0x46, 0xb5, 0xa0, + 0x59, 0x98, 0xde, 0x4e, 0x3e, 0x48, 0x55, 0x57, 0x12, 0x7a, 0x18, 0x1a, 0xf8, 0x14, 0xfb, 0x5a, 0xd7, 0x19, 0xbd, + 0x0c, 0x88, 0x7e, 0xc6, 0x0e, 0x3d, 0xf6, 0x03, 0xb3, 0xfc, 0x20, 0xe8, 0x62, 0xa9, 0x97, 0x40, 0x04, 0x34, 0x78, + 0x4d, 0x23, 0x56, 0x41, 0x9c, 0xb5, 0xd1, 0x61, 0xab, 0xa6, 0x07, 0x72, 0x8a, 0xbf, 0x58, 0x42, 0x28, 0x11, 0x5f, + 0x4d, 0xd3, 0xd2, 0x56, 0xe6, 0xe8, 0x2f, 0x0f, 0xc2, 0x5a, 0x90, 0x68, 0xea, 0x8c, 0xed, 0xad, 0xd2, 0x71, 0xf3, + 0x96, 0x97, 0x27, 0x24, 0xd0, 0xb6, 0x15, 0x61, 0x9e, 0x7f, 0xf2, 0x9f, 0xa6, 0xd6, 0x75, 0x0d, 0x5e, 0x99, 0x98, + 0xbf, 0x13, 0xb9, 0x95, 0xd3, 0xd1, 0x0f, 0x4d, 0xaa, 0x57, 0x0f, 0xb8, 0xc2, 0x7b, 0x33, 0xfe, 0xf3, 0x80, 0xd4, + 0xee, 0x38, 0x87, 0x33, 0x10, 0xa2, 0x79, 0x4e, 0x80, 0xd2, 0xa0, 0xe3, 0xe6, 0x20, 0x98, 0x95, 0x01, 0xc9, 0xce, + 0xea, 0x56, 0x7a, 0x8d, 0xcb, 0xd6, 0x89, 0x83, 0x74, 0xfb, 0x17, 0x62, 0xf2, 0x2c, 0xa5, 0x2b, 0x98, 0xe5, 0x65, + 0x42, 0x57, 0x2d, 0x06, 0x0a, 0x13, 0x39, 0x22, 0xfb, 0xbf, 0x62, 0x45, 0x1f, 0xac, 0xdf, 0x86, 0x8b, 0xb1, 0x23, + 0x24, 0xfb, 0x69, 0x16, 0xad, 0x91, 0xd2, 0xc8, 0x64, 0xc3, 0xe4, 0x52, 0x20, 0x57, 0x02, 0x09, 0x35, 0xea, 0x38, + 0x94, 0x83, 0x01, 0x9d, 0xda, 0x39, 0x28, 0x21, 0xec, 0x4b, 0x14, 0x50, 0x62, 0x44, 0x2a, 0x14, 0xfb, 0x39, 0x3a, + 0x4b, 0x19, 0x62, 0x66, 0x3a, 0x02, 0xee, 0x53, 0xa3, 0x84, 0x64, 0x32, 0x68, 0x00, 0xbd, 0xa5, 0x1d, 0xd4, 0x0f, + 0x70, 0x58, 0x64, 0xc4, 0xa5, 0x09, 0x80, 0xcf, 0x29, 0x6c, 0x6b, 0xff, 0x1e, 0x94, 0x2f, 0x5b, 0x17, 0x3d, 0xc8, + 0xd4, 0x45, 0x20, 0x74, 0x32, 0x8b, 0x05, 0x2a, 0xc3, 0xe5, 0xf0, 0xfb, 0xd4, 0x61, 0xaf, 0xa9, 0xd3, 0x4e, 0x91, + 0xc4, 0x5d, 0x9a, 0x69, 0xe8, 0xfb, 0x61, 0xdd, 0xdb, 0x34, 0xa9, 0xd8, 0x11, 0x78, 0x6b, 0x19, 0xcf, 0x42, 0xbb, + 0x51, 0x8c, 0x7d, 0x40, 0xde, 0xc8, 0xd0, 0xf9, 0x7f, 0x1b, 0x9b, 0x7e, 0xe0, 0x17, 0x9e, 0xa6, 0xcf, 0x9c, 0xf4, + 0xf3, 0xb0, 0x20, 0xbb, 0xc1, 0x4e, 0x45, 0x61, 0x45, 0x89, 0x2f, 0x50, 0x65, 0x53, 0x0d, 0xdd, 0x6b, 0x51, 0x28, + 0x92, 0x14, 0x72, 0x74, 0x61, 0x3c, 0xb9, 0x3c, 0x49, 0xb6, 0x5a, 0x46, 0xa5, 0x48, 0x12, 0xae, 0x4d, 0x48, 0xd6, + 0x09, 0x25, 0xda, 0xe7, 0xb1, 0xce, 0x48, 0xda, 0x8c, 0x0b, 0x76, 0xd6, 0x82, 0x6b, 0x53, 0xbb, 0xb9, 0x38, 0x65, + 0x1e, 0x6a, 0xfe, 0x44, 0x15, 0xa6, 0xcc, 0x9a, 0xa7, 0xb2, 0x36, 0xb9, 0x6a, 0xc8, 0x34, 0xf2, 0xa1, 0xbe, 0x0f, + 0xa9, 0x5e, 0x1c, 0x4e, 0x44, 0xc9, 0xf5, 0x89, 0x4b, 0x07, 0x00, 0xc4, 0x70, 0x9c, 0xf9, 0x65, 0xc9, 0x41, 0x14, + 0x70, 0xa2, 0x94, 0x29, 0xd9, 0x32, 0xb8, 0x1f, 0xc0, 0xbe, 0xb2, 0x1d, 0x05, 0x4a, 0xe6, 0xd8, 0x71, 0xed, 0x6f, + 0x4a, 0x48, 0x01, 0xfb, 0xa9, 0x92, 0x83, 0x71, 0x1d, 0x86, 0xea, 0x1c, 0xcc, 0x1d, 0x69, 0x17, 0xde, 0x57, 0x0c, + 0x5d, 0x9c, 0x8b, 0x22, 0x12, 0x2a, 0x09, 0x1f, 0x0f, 0x30, 0x9f, 0x17, 0x29, 0xec, 0x63, 0x42, 0xf4, 0x94, 0x43, + 0x54, 0x6a, 0xb5, 0x55, 0x0e, 0xf2, 0x82, 0x69, 0x63, 0x2a, 0xfb, 0x48, 0x1a, 0x40, 0xfc, 0x24, 0x6e, 0x50, 0xaa, + 0x6a, 0x9d, 0x76, 0x2b, 0x9a, 0xdb, 0x1a, 0x39, 0xb8, 0x6a, 0xbf, 0x31, 0xad, 0x2a, 0xb0, 0x13, 0x72, 0x2a, 0xa7, + 0x61, 0xeb, 0x4e, 0xc6, 0xbf, 0xdd, 0xaf, 0xa6, 0xbf, 0xfc, 0xb2, 0x14, 0x95, 0x60, 0x84, 0xcc, 0x64, 0x80, 0x6f, + 0x84, 0x10, 0xbc, 0x68, 0x6f, 0x3d, 0x54, 0xb6, 0x45, 0x1c, 0x47, 0x1d, 0x87, 0x15, 0x24, 0x10, 0xe6, 0x73, 0xb9, + 0x3b, 0x5b, 0x8d, 0x2e, 0xf6, 0xee, 0xa8, 0x7c, 0x95, 0x27, 0x89, 0x55, 0xc1, 0x4e, 0x49, 0xf1, 0x31, 0x00, 0x58, + 0x92, 0x27, 0x82, 0x15, 0xe4, 0x8e, 0x37, 0x0d, 0x7c, 0x98, 0xf0, 0x24, 0xf9, 0xbf, 0xbe, 0x09, 0xfc, 0x4c, 0x71, + 0xc9, 0xf2, 0x6d, 0x79, 0x30, 0x59, 0xce, 0x56, 0x2d, 0x05, 0x44, 0x23, 0x02, 0x13, 0x87, 0x7c, 0x9c, 0x5f, 0x27, + 0xd9, 0xbb, 0x0c, 0xf1, 0xa9, 0xf9, 0xa0, 0x27, 0x34, 0xcf, 0xfc, 0x26, 0x34, 0xf4, 0xb8, 0x52, 0x05, 0x5a, 0x02, + 0x42, 0x13, 0xf7, 0x8f, 0xd7, 0x86, 0x0e, 0xa6, 0x59, 0x7f, 0x41, 0xc0, 0x00, 0xab, 0xbc, 0xad, 0xa0, 0x0a, 0x73, + 0x3b, 0x12, 0xde, 0xd4, 0x0d, 0xe1, 0x2b, 0x67, 0xc6, 0xd1, 0xc9, 0x14, 0x3e, 0x19, 0x10, 0x40, 0x7c, 0x54, 0x6f, + 0x44, 0x43, 0x7c, 0x33, 0xcf, 0xaa, 0x3a, 0xb7, 0xb0, 0x55, 0xec, 0x97, 0xf0, 0x47, 0xaf, 0x61, 0x2f, 0xac, 0x8c, + 0x97, 0xc8, 0x15, 0x3f, 0xeb, 0xe8, 0xf8, 0x39, 0x68, 0x53, 0x43, 0xeb, 0x51, 0xa5, 0x0a, 0x15, 0xc7, 0x0c, 0x23, + 0x8a, 0x05, 0x9e, 0x63, 0x8c, 0x4f, 0xe8, 0x9e, 0xdb, 0xf2, 0xd7, 0xc8, 0xb0, 0xf9, 0x2f, 0x87, 0xf2, 0x75, 0xe6, + 0x98, 0xd0, 0x33, 0xe5, 0x4c, 0x85, 0x33, 0x1c, 0x61, 0xac, 0x37, 0xbe, 0xc1, 0xdc, 0x55, 0x33, 0xb6, 0xb5, 0x3a, + 0x93, 0xa2, 0xe9, 0x52, 0x54, 0x9f, 0x41, 0x43, 0xbc, 0xeb, 0xc6, 0xc0, 0xc2, 0xdd, 0x9f, 0x03, 0x42, 0x6e, 0x0e, + 0x85, 0xab, 0xda, 0x8c, 0x10, 0x6a, 0x09, 0xd4, 0x67, 0x85, 0xb0, 0x92, 0x56, 0x49, 0x4a, 0x4d, 0x31, 0xcf, 0x1f, + 0xc1, 0x7a, 0xaf, 0xf9, 0xff, 0x97, 0x19, 0xd1, 0xf7, 0xcb, 0xfe, 0x33, 0x7e, 0x41, 0xf4, 0x8c, 0x15, 0x4b, 0x26, + 0xfa, 0xf6, 0xba, 0x60, 0xc0, 0x09, 0xdf, 0x5e, 0xc3, 0xa9, 0xb5, 0xae, 0xdd, 0x4f, 0x0f, 0xe1, 0xfe, 0xbc, 0x51, + 0x2c, 0x9d, 0x22, 0x84, 0x58, 0xca, 0xcb, 0xcc, 0x54, 0xd2, 0x8a, 0x99, 0x17, 0x1d, 0x40, 0x9a, 0x77, 0x61, 0x76, + 0x9b, 0x72, 0x94, 0x25, 0x81, 0x67, 0x15, 0x30, 0xcd, 0xb0, 0x9d, 0x13, 0xa8, 0x5f, 0x1c, 0xff, 0x1d, 0xeb, 0xfe, + 0x0b, 0xe7, 0xa0, 0xee, 0xcf, 0x4f, 0x21, 0x91, 0x05, 0x4a, 0x94, 0x8c, 0x9a, 0x6e, 0x47, 0x75, 0x27, 0xeb, 0xdd, + 0x0b, 0x53, 0x22, 0x26, 0x5d, 0xf9, 0xdc, 0xcf, 0xed, 0x03, 0x68, 0x68, 0xab, 0x50, 0x55, 0x77, 0x65, 0xe3, 0x7c, + 0x45, 0x4b, 0x36, 0x20, 0xfd, 0x56, 0xfa, 0xe2, 0x06, 0x99, 0x97, 0x25, 0x51, 0x56, 0x91, 0xb3, 0xa4, 0xdb, 0xd3, + 0x39, 0x0a, 0x99, 0xe3, 0x7c, 0xe5, 0x85, 0xad, 0x95, 0xf6, 0xb5, 0x2a, 0xdb, 0x70, 0xa9, 0xa4, 0x68, 0x11, 0xcc, + 0x7a, 0x9f, 0xa3, 0xfe, 0x2e, 0x6f, 0x92, 0x89, 0x62, 0x54, 0x55, 0xbc, 0xae, 0x44, 0x2f, 0x7e, 0x7e, 0x0d, 0xc7, + 0x84, 0x7e, 0xf5, 0x07, 0xbd, 0xa5, 0xea, 0xde, 0x77, 0x98, 0xca, 0xec, 0xcd, 0x21, 0x88, 0xd2, 0x0d, 0xe9, 0xd5, + 0x5f, 0x89, 0x8f, 0xeb, 0xed, 0x89, 0x60, 0x39, 0x5d, 0x57, 0xf6, 0xeb, 0x7c, 0x5c, 0x0a, 0x73, 0x1e, 0xa9, 0x97, + 0xa6, 0xc1, 0xaf, 0x54, 0x51, 0x61, 0xce, 0xfa, 0xc7, 0x6c, 0x0a, 0xce, 0x4b, 0xd7, 0x32, 0x84, 0x1c, 0x91, 0xd0, + 0xc8, 0x91, 0x60, 0xce, 0xbf, 0x50, 0x8c, 0x5f, 0xb4, 0x49, 0xec, 0x8e, 0x5f, 0xc9, 0x6e, 0xa8, 0xe9, 0xa7, 0xcf, + 0xb9, 0x4b, 0x27, 0x54, 0x50, 0x7b, 0x82, 0x4b, 0xb0, 0xc0, 0xfb, 0x2b, 0x9b, 0x74, 0x31, 0xaa, 0xaa, 0x57, 0xe7, + 0xf3, 0x8f, 0x86, 0x38, 0x4c, 0x05, 0x14, 0x16, 0x6f, 0x32, 0x87, 0x76, 0x86, 0xd7, 0x74, 0x98, 0x67}; // Backwards compatibility alias #define INDEX_GZ INDEX_BR diff --git a/esphome/components/web_server/server_index_v3.h b/esphome/components/web_server/server_index_v3.h index a1cafe8707..37c80ddf96 100644 --- a/esphome/components/web_server/server_index_v3.h +++ b/esphome/components/web_server/server_index_v3.h @@ -3634,4058 +3634,4060 @@ constexpr uint8_t INDEX_GZ[] PROGMEM = { 0x36, 0x16, 0x43, 0x14, 0xa2, 0x85, 0xb1, 0x9f, 0x44, 0x7a, 0x6a, 0xf7, 0x8b, 0xa8, 0x63, 0x84, 0xe7, 0x2e, 0x8e, 0x40, 0xbb, 0x60, 0xb2, 0xd8, 0xed, 0x3a, 0x06, 0xb0, 0x93, 0x12, 0x46, 0xf3, 0x4c, 0x91, 0xc8, 0x45, 0x3d, 0x95, 0x78, 0xf0, 0xa9, 0x53, 0x8d, 0x99, 0x83, 0xf0, 0x54, 0x31, 0xd4, 0xc0, 0xc7, 0x7a, 0xaf, 0x4d, 0xc8, 0x99, 0xff, - 0xaf, 0xbd, 0xa7, 0xdd, 0x6e, 0x13, 0x49, 0xf6, 0xff, 0x3c, 0x05, 0x26, 0xd9, 0x04, 0x12, 0xc0, 0x20, 0xf9, 0x43, - 0x91, 0x8c, 0x3c, 0x93, 0xc4, 0x99, 0x8f, 0xf5, 0x4c, 0xe6, 0x24, 0x9e, 0xec, 0xbd, 0xeb, 0xf5, 0xb1, 0x90, 0xd4, - 0x92, 0xd8, 0x20, 0xd0, 0x01, 0x64, 0xd9, 0xa3, 0xb0, 0xcf, 0xb2, 0x8f, 0x70, 0x9f, 0x61, 0x9f, 0xec, 0x9e, 0xaa, - 0xea, 0x86, 0x06, 0x81, 0x2c, 0x4f, 0x32, 0xb3, 0x7b, 0xcf, 0xb9, 0x67, 0x26, 0x89, 0x68, 0xba, 0x9b, 0xea, 0xaf, + 0xaf, 0xbd, 0xa7, 0xdd, 0x6e, 0x13, 0x49, 0xf6, 0xff, 0x3c, 0x05, 0x26, 0xd9, 0x04, 0x12, 0xc0, 0x20, 0x59, 0xb6, + 0x22, 0x19, 0x79, 0x26, 0x89, 0x33, 0x1f, 0xeb, 0x99, 0xcc, 0x49, 0x3c, 0xd9, 0x7b, 0xd7, 0xeb, 0x63, 0x21, 0xa9, + 0x25, 0xb1, 0x41, 0xa0, 0x03, 0xc8, 0x1f, 0xa3, 0xb0, 0xcf, 0xb2, 0x8f, 0x70, 0x9f, 0x61, 0x9f, 0xec, 0x9e, 0xaa, + 0xea, 0x86, 0x06, 0x81, 0x2c, 0x4f, 0x32, 0xb3, 0x7b, 0xcf, 0xb9, 0x67, 0x26, 0x89, 0x68, 0x9a, 0xee, 0xea, 0xaf, 0xaa, 0xea, 0xfa, 0x2c, 0x53, 0x4a, 0xbb, 0x82, 0x7f, 0x85, 0x15, 0x72, 0xa5, 0x94, 0xb6, 0x1c, 0x88, 0x39, 0xdd, - 0x3e, 0xae, 0x1a, 0x58, 0x15, 0x8a, 0x7b, 0x32, 0x98, 0x09, 0x56, 0x76, 0x9d, 0x98, 0x87, 0x59, 0x97, 0x36, 0xbc, - 0x11, 0xb8, 0x60, 0x7a, 0xd8, 0x50, 0x6b, 0xdc, 0xf5, 0x4a, 0xa1, 0x30, 0xe3, 0xf2, 0xa4, 0x9e, 0x78, 0xe5, 0x67, - 0xd8, 0xd2, 0x95, 0x2a, 0xe0, 0x1b, 0x53, 0xa9, 0x04, 0x52, 0xb0, 0xe0, 0x94, 0x3e, 0x6f, 0xa5, 0xd1, 0x79, 0xb4, - 0x12, 0x82, 0xe3, 0x13, 0xaf, 0xa6, 0x10, 0xcf, 0x49, 0x77, 0x74, 0x12, 0xd0, 0x0f, 0x27, 0x6b, 0xa0, 0x00, 0x59, - 0x31, 0xc1, 0x39, 0xdb, 0x3a, 0xa0, 0xcb, 0xd4, 0xf5, 0xe3, 0xb5, 0xd8, 0x72, 0xd9, 0xc0, 0xcf, 0xd3, 0xc2, 0x96, + 0x01, 0xae, 0x1a, 0x58, 0x15, 0x8a, 0x7b, 0x32, 0x98, 0x09, 0x56, 0x76, 0x9d, 0x98, 0x87, 0x79, 0x8f, 0x36, 0xbc, + 0x11, 0xb8, 0x60, 0x7a, 0xd8, 0x50, 0x6b, 0xd2, 0xf3, 0x4a, 0xa1, 0x30, 0xe3, 0xf2, 0xa4, 0x1e, 0x7b, 0xe5, 0x67, + 0xd8, 0xd2, 0x95, 0x2a, 0xe0, 0x1b, 0x53, 0xa9, 0x04, 0x52, 0xb0, 0xe0, 0x84, 0xba, 0xb7, 0xd2, 0xe8, 0x2c, 0xba, + 0x11, 0x82, 0xe3, 0x63, 0xaf, 0xa6, 0x10, 0xcf, 0x49, 0x6f, 0x7c, 0x1c, 0xd0, 0x0f, 0x27, 0x6b, 0xa0, 0x00, 0x59, + 0x31, 0xc1, 0x39, 0xdb, 0x3a, 0xa4, 0xcb, 0xd4, 0xd5, 0xe3, 0xb5, 0xd8, 0x72, 0xd9, 0xd0, 0xcf, 0xd3, 0xc2, 0x96, 0x58, 0x8e, 0x8c, 0x4f, 0xbd, 0x1c, 0x85, 0xb4, 0xa6, 0x1a, 0xdf, 0x1f, 0xae, 0x5f, 0xcb, 0xb7, 0x58, 0xf4, 0xc8, - 0x88, 0xa6, 0xd7, 0x57, 0x61, 0xb7, 0x6c, 0xa4, 0xd5, 0x01, 0x46, 0x82, 0x27, 0x31, 0x04, 0x0c, 0xcc, 0x8c, 0xa8, - 0xff, 0xdb, 0xb8, 0x8a, 0xfa, 0xd1, 0xfe, 0x2e, 0x47, 0xfe, 0x3f, 0xbf, 0x7d, 0x7f, 0x01, 0x7a, 0x2d, 0x0f, 0x15, - 0xd1, 0x6b, 0x95, 0xdb, 0xb0, 0x98, 0xa0, 0x29, 0x52, 0xbb, 0xaa, 0xb7, 0x00, 0xea, 0x8c, 0x37, 0x86, 0xfd, 0x5b, - 0x73, 0xb5, 0x5a, 0x99, 0x60, 0xd1, 0x6a, 0x2e, 0xe3, 0x80, 0xb8, 0xc3, 0xb1, 0x9a, 0x09, 0xa4, 0xce, 0x2a, 0x48, - 0x1d, 0xc2, 0xe1, 0xf2, 0x7c, 0x2a, 0xef, 0x67, 0xd1, 0xea, 0x9b, 0x20, 0x90, 0xc5, 0x36, 0x82, 0x89, 0xe3, 0x92, - 0x8c, 0x12, 0x32, 0xd0, 0x40, 0xfb, 0x64, 0xf9, 0xc9, 0x35, 0xb7, 0x17, 0x18, 0x5f, 0x0f, 0xef, 0xae, 0xb9, 0x4e, - 0x22, 0x8f, 0x47, 0xfc, 0x7e, 0x70, 0x32, 0xf6, 0x6f, 0x14, 0xe4, 0x34, 0x5d, 0x15, 0x9c, 0xb9, 0x02, 0x36, 0x5c, - 0xa6, 0x69, 0x14, 0x9a, 0x71, 0xb4, 0x52, 0xfb, 0x27, 0xf4, 0x20, 0x2a, 0x78, 0xf4, 0xa8, 0x2a, 0x5f, 0x8f, 0x02, - 0x7f, 0xf4, 0xd1, 0x55, 0x1f, 0xaf, 0x7d, 0xb7, 0x5f, 0xe1, 0x27, 0xed, 0x4c, 0xed, 0x03, 0xac, 0xca, 0x37, 0x41, - 0x70, 0xb2, 0x4f, 0x2d, 0xfa, 0x27, 0xfb, 0x63, 0xff, 0xa6, 0x2f, 0xa5, 0x86, 0xe1, 0x7a, 0x53, 0x97, 0x87, 0xe0, - 0xcc, 0x2d, 0xcd, 0x12, 0x8c, 0xe9, 0x30, 0x62, 0x5a, 0x71, 0xf9, 0x85, 0x58, 0x33, 0x04, 0xaf, 0x36, 0x42, 0x71, - 0x7a, 0x00, 0x57, 0xbd, 0x4f, 0x9f, 0xb4, 0xdc, 0x0e, 0x75, 0x26, 0x05, 0x69, 0x43, 0x35, 0x1f, 0x56, 0x31, 0x30, - 0xd2, 0x8c, 0xae, 0x89, 0x50, 0x72, 0x81, 0x6e, 0x8c, 0x32, 0x03, 0x33, 0xec, 0x78, 0x0b, 0xd0, 0x38, 0xf2, 0x9f, - 0xd2, 0x8d, 0x78, 0x04, 0x59, 0xb5, 0x25, 0x24, 0xae, 0x4b, 0x3a, 0x17, 0x3a, 0x85, 0x3c, 0x4e, 0x20, 0x28, 0x4b, - 0xf0, 0x3b, 0xa4, 0x07, 0xd1, 0x02, 0x1d, 0xb2, 0xba, 0xe5, 0xc1, 0x79, 0xbc, 0x4c, 0xe4, 0x51, 0x13, 0xf3, 0x72, - 0x5a, 0x5a, 0xa1, 0x6e, 0x75, 0xbd, 0x44, 0xd4, 0xc8, 0xbd, 0xa4, 0x69, 0xc9, 0x40, 0x87, 0xa7, 0xa5, 0x46, 0x85, - 0xe6, 0x82, 0x57, 0x9f, 0xa4, 0x38, 0x62, 0x86, 0x76, 0x99, 0x18, 0xd1, 0x55, 0x41, 0xa7, 0x12, 0x42, 0x94, 0xdd, - 0x28, 0x2b, 0x02, 0x38, 0xd3, 0xaa, 0xf7, 0x1f, 0xaf, 0x43, 0x24, 0x6c, 0x89, 0xdb, 0x2f, 0xef, 0x83, 0xd4, 0x1b, - 0x9a, 0xb4, 0x99, 0x55, 0xe5, 0xeb, 0xf1, 0x30, 0xc8, 0x17, 0x9b, 0x0e, 0xc1, 0xcc, 0x0b, 0xc7, 0x01, 0xbb, 0xf0, - 0x86, 0xdf, 0x61, 0x9d, 0xd7, 0xc3, 0xe0, 0x15, 0x54, 0xc8, 0xd4, 0xfe, 0xe3, 0x35, 0x91, 0xee, 0x3a, 0x84, 0x9d, - 0xd1, 0x16, 0xa8, 0x7e, 0x87, 0xa7, 0x5c, 0x62, 0x31, 0xb5, 0x46, 0x60, 0x89, 0xdc, 0x52, 0x1c, 0xdb, 0x32, 0x64, - 0x3c, 0xe5, 0x0f, 0xec, 0x4d, 0x85, 0x9f, 0x5a, 0x80, 0x2b, 0x12, 0x27, 0x58, 0xde, 0x99, 0x32, 0xb0, 0x44, 0x56, - 0xdf, 0x45, 0x2b, 0x01, 0x29, 0x9f, 0x00, 0x0a, 0x51, 0x79, 0xfa, 0x7e, 0x70, 0x22, 0xab, 0x85, 0x50, 0x76, 0x4e, - 0xfd, 0xc2, 0xaf, 0x4c, 0x55, 0x8a, 0x04, 0x50, 0x8b, 0x5b, 0xb5, 0x7f, 0xb2, 0x2f, 0xd7, 0xee, 0x0f, 0xba, 0x67, - 0xd2, 0xe0, 0xb0, 0x57, 0x71, 0x6f, 0xbe, 0x2c, 0x1e, 0xb2, 0x2b, 0x05, 0x6e, 0xc9, 0x19, 0x94, 0xc0, 0x1c, 0x95, - 0x9b, 0x6c, 0x90, 0x1f, 0x48, 0x99, 0x58, 0x10, 0x28, 0xda, 0x3d, 0x02, 0x3f, 0x46, 0x7a, 0x37, 0x5f, 0x42, 0xb2, - 0xcc, 0x14, 0xbd, 0x0d, 0xf8, 0xbf, 0xc5, 0x94, 0xa0, 0xa4, 0x9b, 0x85, 0x49, 0x14, 0xab, 0x30, 0xcc, 0x6a, 0xde, - 0x24, 0x45, 0xca, 0xd7, 0x86, 0x03, 0xae, 0x25, 0xab, 0x30, 0x61, 0xfb, 0xd5, 0xa6, 0xd2, 0xb8, 0x07, 0x7a, 0xf1, - 0x43, 0xe1, 0x83, 0xa9, 0x20, 0xad, 0x1c, 0xc0, 0xe6, 0x7c, 0x54, 0x97, 0x8f, 0x7d, 0xe3, 0x2f, 0x91, 0x31, 0xf4, - 0x8c, 0x6b, 0xcf, 0xf8, 0x31, 0xbc, 0xca, 0x6a, 0x17, 0x2f, 0xcf, 0x25, 0x67, 0xb0, 0x9e, 0x06, 0x11, 0x98, 0xca, - 0x97, 0x0a, 0xdf, 0xe2, 0x36, 0x23, 0x17, 0x5e, 0x3c, 0x65, 0x22, 0x85, 0x9b, 0x78, 0x2b, 0x64, 0x07, 0xba, 0x34, - 0x2d, 0x10, 0x9e, 0x6c, 0x8f, 0x9b, 0xd6, 0xf9, 0xd6, 0x28, 0x8d, 0x83, 0x3f, 0xb3, 0x3b, 0x60, 0xb3, 0x92, 0x34, - 0x5a, 0x80, 0xcc, 0xca, 0x9b, 0x72, 0x1d, 0x84, 0xa1, 0xb1, 0xdd, 0x3e, 0xf7, 0xe9, 0x13, 0x93, 0xb2, 0x8a, 0xa5, - 0xd1, 0x74, 0x1a, 0x30, 0x4d, 0xca, 0x3e, 0x96, 0x7f, 0xe6, 0x74, 0xcf, 0x16, 0x91, 0xab, 0xf5, 0xac, 0xe9, 0x60, - 0x89, 0x11, 0xb3, 0x9c, 0x1b, 0x04, 0xc4, 0x45, 0xc6, 0x55, 0xc8, 0x90, 0x6b, 0xe2, 0x5c, 0x14, 0x07, 0xd7, 0x1c, - 0x47, 0xcb, 0x61, 0xc0, 0x4c, 0x3c, 0x0d, 0xf0, 0xc9, 0xf5, 0x70, 0x39, 0x1c, 0x06, 0x94, 0x2e, 0x0c, 0xe2, 0xaf, - 0x45, 0x09, 0xca, 0x45, 0x33, 0xbd, 0x07, 0x83, 0xb2, 0xd2, 0x2a, 0xf8, 0x60, 0x33, 0x09, 0x37, 0x07, 0xfa, 0x40, - 0x0a, 0x32, 0xd0, 0xcd, 0x33, 0xed, 0xaa, 0x70, 0x63, 0x61, 0x89, 0xda, 0xab, 0x61, 0xe9, 0xdc, 0x4b, 0xf5, 0x3d, - 0xce, 0xb0, 0xe2, 0x85, 0x63, 0xe5, 0x15, 0xed, 0x5d, 0xd5, 0x50, 0xc9, 0xf4, 0x8b, 0x67, 0x97, 0x53, 0x0d, 0xf5, - 0xb5, 0xef, 0x4d, 0xc3, 0x28, 0x49, 0xfd, 0x91, 0x7a, 0xd5, 0x7b, 0xed, 0x6b, 0x97, 0xf3, 0x54, 0xd3, 0xaf, 0x8c, - 0x6f, 0xe5, 0x3c, 0x60, 0x02, 0x53, 0x62, 0x1a, 0xb0, 0x86, 0x3a, 0xf2, 0xe9, 0xd9, 0x56, 0x4f, 0x60, 0x64, 0xac, - 0xf3, 0xad, 0x0b, 0xb5, 0x2a, 0x19, 0xc5, 0x30, 0x55, 0x24, 0x64, 0x14, 0xfb, 0x56, 0xef, 0x91, 0x10, 0xe6, 0x9b, - 0xe5, 0x1a, 0x99, 0x86, 0xb4, 0x20, 0xbe, 0x18, 0x04, 0x5f, 0x78, 0x8e, 0xd2, 0xf3, 0x9e, 0xec, 0xf5, 0x50, 0x22, - 0xe3, 0x83, 0x6f, 0xca, 0x1c, 0xc8, 0xe3, 0x75, 0x9a, 0x81, 0xc9, 0x61, 0x18, 0xa5, 0x0a, 0x44, 0x76, 0x83, 0x0f, - 0x0e, 0xaa, 0x56, 0xd2, 0xbc, 0x57, 0x4d, 0xcf, 0x38, 0x16, 0x78, 0x89, 0xb4, 0x14, 0x25, 0x97, 0x10, 0x88, 0x02, - 0x82, 0x94, 0x96, 0xe2, 0x38, 0x71, 0xdf, 0x3c, 0x58, 0xbe, 0x12, 0xff, 0x26, 0xe1, 0xfd, 0x32, 0x3d, 0x7f, 0xbc, - 0x4e, 0x4e, 0x05, 0x51, 0xff, 0x3e, 0xc1, 0xb5, 0x04, 0x76, 0x85, 0x53, 0xf9, 0x4c, 0x55, 0x4e, 0x05, 0x25, 0xc2, - 0xba, 0x25, 0xf4, 0xaa, 0x09, 0x76, 0x37, 0x16, 0x31, 0xf3, 0xb9, 0x18, 0x45, 0x30, 0x60, 0x95, 0xa3, 0x07, 0xc1, - 0x9a, 0x72, 0xde, 0x2a, 0x05, 0x8b, 0x6b, 0x24, 0x18, 0x80, 0xb9, 0x38, 0x8f, 0x30, 0xc8, 0xae, 0x81, 0x91, 0x84, - 0x08, 0x66, 0x62, 0x8c, 0x46, 0x24, 0x27, 0x91, 0xf3, 0xc3, 0xc5, 0x32, 0xc5, 0xc8, 0xf4, 0x00, 0x00, 0xcb, 0x54, - 0x05, 0x2f, 0x8c, 0x80, 0xeb, 0x8b, 0x0b, 0x4f, 0xa6, 0x2a, 0xfe, 0x78, 0xb3, 0x8c, 0x4b, 0x67, 0x00, 0xc7, 0xe1, - 0x30, 0x50, 0xaf, 0x03, 0x8f, 0x31, 0x1f, 0xc6, 0xc8, 0x28, 0xd2, 0xba, 0x68, 0x23, 0xb4, 0x7f, 0xa8, 0x41, 0x20, - 0x23, 0xea, 0xa7, 0xa7, 0x05, 0xb5, 0x83, 0x85, 0x68, 0xd5, 0xa5, 0x61, 0x0e, 0x40, 0x46, 0x79, 0x0a, 0x73, 0xe7, - 0xc2, 0xa5, 0x5e, 0x98, 0xd6, 0xa9, 0x17, 0x2a, 0xd9, 0xd5, 0x0d, 0x70, 0x1a, 0x06, 0xd9, 0x75, 0xe1, 0xe8, 0x5a, - 0x8c, 0x17, 0xb6, 0x24, 0x95, 0x2b, 0x68, 0xe9, 0xe6, 0x72, 0x7b, 0xb6, 0x45, 0xec, 0xcf, 0xbd, 0xf8, 0x8e, 0xcc, - 0xdf, 0x0c, 0xd9, 0x46, 0x4e, 0x57, 0x15, 0xa2, 0x07, 0x34, 0x01, 0x44, 0x1a, 0x54, 0xe5, 0xeb, 0xbc, 0x8c, 0xf1, - 0xd1, 0xe6, 0x36, 0x40, 0xf0, 0xad, 0x6b, 0xf5, 0x39, 0xb3, 0x48, 0xfe, 0x48, 0x4d, 0x7a, 0x5a, 0xd2, 0x30, 0xbc, - 0xa4, 0x3c, 0xbc, 0xb0, 0xbc, 0xd1, 0x70, 0x30, 0x44, 0x29, 0x08, 0x6e, 0x1c, 0x19, 0x26, 0xc1, 0xac, 0x5f, 0x51, - 0x7a, 0xf7, 0x87, 0x2e, 0x07, 0x83, 0xe5, 0x08, 0x61, 0x39, 0x6a, 0x44, 0xb3, 0x9e, 0x58, 0x11, 0xe0, 0x45, 0x80, - 0x0b, 0x89, 0x91, 0x03, 0xa1, 0xfc, 0x98, 0x4a, 0xbe, 0x85, 0x62, 0x38, 0x1a, 0x04, 0x3b, 0x1d, 0x8d, 0xd8, 0x75, - 0x23, 0x6c, 0x15, 0x67, 0x27, 0xfb, 0x54, 0x9b, 0x88, 0x22, 0x55, 0x82, 0x69, 0x88, 0x61, 0x84, 0xc5, 0x2c, 0x40, - 0x82, 0x70, 0xd7, 0x29, 0x2e, 0x3a, 0xd6, 0x1c, 0xd5, 0xd2, 0xce, 0x69, 0x99, 0xe1, 0xc1, 0x56, 0x6a, 0xff, 0x04, - 0x53, 0x7e, 0x02, 0x59, 0x87, 0xa0, 0x58, 0x27, 0xfb, 0xf4, 0xa8, 0x54, 0x4e, 0x44, 0xd1, 0x89, 0x90, 0x41, 0x76, - 0x79, 0x07, 0x0f, 0x3a, 0x2a, 0x49, 0xca, 0x16, 0x50, 0xea, 0x65, 0xaa, 0x32, 0xe7, 0x0c, 0x16, 0x8f, 0xbe, 0x07, - 0xa1, 0x79, 0x6c, 0x70, 0x89, 0x50, 0x95, 0xb9, 0x77, 0x8b, 0x23, 0x17, 0x6f, 0xbc, 0x5b, 0xcd, 0xe1, 0xaf, 0x8a, - 0xb3, 0x96, 0x94, 0xcf, 0xda, 0x68, 0xe3, 0x86, 0x1c, 0xc0, 0x0d, 0x79, 0x54, 0xbf, 0xb8, 0x33, 0xb1, 0xb8, 0xe3, - 0x86, 0xc5, 0x1d, 0x6f, 0x59, 0xdc, 0x80, 0x2f, 0xa4, 0x92, 0x4f, 0x5d, 0x8c, 0xbe, 0xd4, 0xf9, 0xe4, 0x71, 0x7e, - 0xa4, 0xcb, 0xcf, 0x19, 0xce, 0x93, 0x99, 0x04, 0x60, 0x4b, 0xdc, 0x30, 0x57, 0x75, 0xf3, 0x22, 0x4d, 0xc4, 0xe6, - 0xc0, 0xf3, 0x53, 0x27, 0xc6, 0x0d, 0x29, 0xbc, 0xb5, 0xa0, 0x3a, 0x5e, 0xd8, 0xa5, 0xd8, 0xd0, 0xd0, 0x66, 0x0d, - 0x23, 0x9d, 0x6d, 0x19, 0xe9, 0xa8, 0x74, 0x74, 0xf9, 0xb0, 0xe9, 0x10, 0xca, 0x83, 0x82, 0x3d, 0x08, 0xfe, 0x15, - 0xb8, 0x65, 0xca, 0xfb, 0xb0, 0x19, 0xc7, 0x4a, 0x3b, 0x6a, 0xe1, 0x25, 0xc9, 0x2a, 0x8a, 0xc1, 0x40, 0x01, 0xba, - 0x79, 0xd8, 0x96, 0x9a, 0xfb, 0x21, 0x8f, 0x7d, 0xd6, 0xb8, 0x99, 0x8a, 0xf7, 0xf2, 0x96, 0x6a, 0x1d, 0x1e, 0x52, - 0x8d, 0x85, 0x97, 0xa6, 0x2c, 0xc6, 0x49, 0xf7, 0x20, 0x49, 0xc6, 0x7f, 0xc8, 0x36, 0xab, 0xc1, 0x21, 0x81, 0x84, - 0xd5, 0x11, 0x43, 0x2f, 0x80, 0x05, 0x23, 0x8d, 0x64, 0xa8, 0xaf, 0xa5, 0x38, 0xaa, 0x71, 0x3e, 0xf1, 0x3f, 0xe1, - 0x71, 0xd5, 0x62, 0xc9, 0xd3, 0xd7, 0x39, 0xd2, 0xad, 0x85, 0x37, 0x7e, 0x0f, 0x76, 0x30, 0x5a, 0xcb, 0x00, 0x9f, - 0x16, 0x39, 0x6a, 0x6a, 0x4c, 0x3c, 0xe1, 0xa8, 0x40, 0x92, 0x88, 0x25, 0xb9, 0xc5, 0x30, 0x04, 0x1b, 0xf0, 0xcc, - 0xc9, 0xd5, 0xba, 0x95, 0xed, 0x4f, 0x7d, 0x7d, 0x03, 0x6b, 0x02, 0x6a, 0x0b, 0xdc, 0x7e, 0x2e, 0x74, 0x0b, 0x0c, - 0xe7, 0x48, 0x07, 0x45, 0xe9, 0x25, 0xa4, 0x43, 0xb7, 0xc5, 0x65, 0x7a, 0x10, 0x03, 0xd5, 0x02, 0xb5, 0xe2, 0x93, - 0x29, 0xfe, 0x72, 0xae, 0xb2, 0x27, 0x43, 0xfc, 0xd5, 0xba, 0xca, 0x95, 0x58, 0x15, 0x29, 0x82, 0x34, 0x66, 0xb5, - 0x5f, 0xda, 0x4f, 0x64, 0xae, 0xfd, 0x80, 0x6d, 0xc3, 0x17, 0xf8, 0xd1, 0xe3, 0x75, 0x02, 0x01, 0x0a, 0xe4, 0x31, - 0x84, 0x56, 0xac, 0x67, 0xb5, 0xe5, 0xd3, 0x86, 0xf2, 0xa1, 0xfe, 0x07, 0x13, 0x7e, 0xdc, 0x25, 0x51, 0x41, 0x53, - 0xca, 0x32, 0x90, 0xeb, 0xa1, 0x1f, 0x7a, 0xf1, 0xdd, 0x35, 0xdd, 0x42, 0x34, 0xc1, 0xe2, 0xe7, 0xb2, 0x1d, 0xe2, - 0x45, 0xcb, 0xd6, 0x21, 0xa9, 0xa4, 0xa8, 0xba, 0xe3, 0x84, 0xde, 0xfd, 0x73, 0x2c, 0xf1, 0x77, 0xa5, 0x6b, 0x2c, - 0x5f, 0x90, 0xd2, 0x87, 0xae, 0x1f, 0xaf, 0x35, 0xb6, 0xd9, 0x4d, 0x65, 0xb4, 0x15, 0x06, 0x12, 0x96, 0x07, 0xaf, - 0xc4, 0xf3, 0xb1, 0xdf, 0x45, 0xf3, 0x8f, 0x61, 0x74, 0x6b, 0x3e, 0x5e, 0xa7, 0xa7, 0xea, 0xdc, 0x8b, 0x3f, 0xb2, - 0xb1, 0x39, 0xf2, 0xe3, 0x51, 0x00, 0xcc, 0xe3, 0x30, 0xf0, 0xc2, 0x8f, 0xfc, 0xd1, 0x8c, 0x96, 0x29, 0x1a, 0x74, - 0xdd, 0x7b, 0x83, 0x16, 0x73, 0x42, 0x82, 0x44, 0xe4, 0x6a, 0x6b, 0x66, 0x41, 0x79, 0x3f, 0x10, 0xd7, 0xfa, 0x82, - 0x51, 0x2c, 0x6a, 0x19, 0xe0, 0x8f, 0x00, 0x36, 0x66, 0x10, 0xe0, 0xc1, 0x50, 0x71, 0xbd, 0x54, 0x43, 0x1e, 0x2a, - 0x69, 0xd5, 0xf2, 0x0c, 0xc5, 0xd7, 0xd8, 0xc3, 0x6f, 0xff, 0x1c, 0x94, 0x3c, 0xe4, 0x73, 0x79, 0x2f, 0x9f, 0x37, - 0x42, 0x28, 0x35, 0xc9, 0xb1, 0xf0, 0x01, 0x1f, 0xe7, 0x0c, 0x66, 0xf3, 0xa7, 0xe5, 0xc6, 0x5e, 0x92, 0x2c, 0xe7, - 0x6c, 0x4c, 0x2a, 0xb1, 0xd3, 0x02, 0xa8, 0xf2, 0x3d, 0x44, 0x06, 0xec, 0x6f, 0xcb, 0xd6, 0xf1, 0xc1, 0x2b, 0x30, - 0xf0, 0x03, 0x86, 0x32, 0x9a, 0x4c, 0xd4, 0x42, 0x14, 0x70, 0x4f, 0x33, 0xe7, 0xe0, 0x6f, 0xcb, 0x37, 0x67, 0xf6, - 0x9b, 0xbc, 0x71, 0x08, 0x8c, 0xb1, 0xb0, 0x56, 0xe2, 0x7c, 0xb1, 0x04, 0xaf, 0x18, 0xd1, 0xc4, 0x0b, 0x9b, 0x87, - 0x73, 0x59, 0xda, 0xe2, 0x0b, 0xc6, 0xc6, 0xc0, 0x70, 0x1b, 0x1b, 0xa5, 0xd7, 0x01, 0xbb, 0x61, 0xb9, 0x25, 0xd4, - 0xe6, 0xc7, 0x6a, 0x5a, 0x60, 0xa8, 0x56, 0xae, 0x7b, 0xe4, 0x5c, 0x9d, 0x34, 0xa4, 0x01, 0x8e, 0x81, 0x8f, 0x5c, - 0x3e, 0x62, 0x95, 0x23, 0x35, 0x30, 0x54, 0x09, 0x80, 0x46, 0xc8, 0x4e, 0x1b, 0xca, 0xbb, 0x80, 0xa8, 0x1b, 0x60, - 0x33, 0x1c, 0xbd, 0x0b, 0xa9, 0x2d, 0xf8, 0x3c, 0x05, 0x70, 0xf2, 0xb4, 0x42, 0x6a, 0xd2, 0x34, 0x63, 0x75, 0xa2, - 0x36, 0x95, 0x84, 0x34, 0xc2, 0x39, 0x00, 0xbd, 0x64, 0x84, 0xb8, 0xaa, 0x76, 0x6d, 0x94, 0xf2, 0xc8, 0x87, 0x98, - 0xf8, 0x3d, 0x64, 0x49, 0xd2, 0x38, 0x61, 0xf9, 0xa2, 0x1b, 0x6a, 0x51, 0xbb, 0x3c, 0x1f, 0x45, 0xb9, 0x61, 0x1b, - 0xc0, 0x12, 0xe0, 0x00, 0xab, 0xdf, 0x42, 0xf2, 0x72, 0x3d, 0xe7, 0xe6, 0x9d, 0xf1, 0x74, 0xa8, 0x72, 0xd3, 0xbb, - 0xa6, 0xf7, 0x2b, 0x95, 0x03, 0x55, 0x22, 0xd3, 0xb5, 0xa0, 0x69, 0x25, 0xd4, 0xbb, 0x21, 0x55, 0xc2, 0x0e, 0x04, - 0x4c, 0x15, 0xfc, 0xca, 0x26, 0x13, 0x36, 0x4a, 0x13, 0x5d, 0xc8, 0x98, 0xf2, 0x60, 0xeb, 0xe0, 0x64, 0xbb, 0xe7, - 0xaa, 0x3f, 0x41, 0xc8, 0x19, 0x11, 0x93, 0x90, 0x03, 0x24, 0xee, 0x4c, 0xf5, 0xd3, 0x44, 0x3d, 0x96, 0xa7, 0x88, - 0x7f, 0x05, 0xa4, 0xd0, 0x35, 0xe5, 0x08, 0x1a, 0xa7, 0x3f, 0xc5, 0xbe, 0x88, 0x72, 0x23, 0xd0, 0xed, 0xa8, 0x68, - 0xdb, 0xf1, 0x5d, 0x3b, 0x6f, 0x0e, 0x1d, 0x3b, 0x53, 0x0d, 0x70, 0x75, 0xfe, 0x58, 0xd9, 0xc6, 0x44, 0xa0, 0x5c, - 0xf5, 0xfc, 0xed, 0xab, 0x3f, 0x9f, 0xbd, 0xde, 0x15, 0x23, 0x60, 0x97, 0x6d, 0xe8, 0x72, 0x19, 0x6e, 0xe9, 0xf4, - 0x97, 0x9f, 0x1e, 0xd6, 0x6d, 0xcb, 0x79, 0xe1, 0xa8, 0x06, 0x59, 0xa7, 0x4b, 0x78, 0x71, 0x14, 0xdd, 0xb0, 0xf8, - 0xb3, 0xa7, 0x41, 0xee, 0xbc, 0x1e, 0xdc, 0xb7, 0x3f, 0x9f, 0xfd, 0xb4, 0x33, 0xa8, 0x47, 0x8e, 0x0d, 0xb8, 0x3d, - 0x8d, 0x16, 0x0f, 0x18, 0x5d, 0x5b, 0x35, 0xd4, 0x51, 0x10, 0x25, 0xac, 0x01, 0x82, 0x57, 0xe7, 0x6f, 0xdf, 0xe3, - 0x74, 0x15, 0x2c, 0x08, 0x75, 0xf5, 0x79, 0x83, 0xff, 0xf9, 0xdd, 0xd9, 0xfb, 0xf7, 0xaa, 0x81, 0xc9, 0xba, 0x13, - 0xb9, 0x77, 0xbe, 0x89, 0xef, 0xa1, 0x38, 0xb5, 0x7b, 0x9d, 0xa8, 0x1a, 0x5d, 0xa4, 0xcb, 0xa3, 0xa1, 0xb2, 0x8d, - 0x6d, 0xce, 0xa9, 0x1d, 0xff, 0x32, 0xdd, 0x7e, 0x77, 0x1a, 0x57, 0x0d, 0x3e, 0xda, 0x4e, 0x52, 0x4b, 0x25, 0x73, - 0x3f, 0xbc, 0xae, 0x29, 0xf5, 0x6e, 0x6b, 0x4a, 0xe1, 0xfa, 0xb8, 0x81, 0x1f, 0x97, 0xd1, 0x5c, 0x62, 0x47, 0xd8, - 0xed, 0xfd, 0xd3, 0x25, 0xdd, 0xe1, 0x3e, 0x03, 0x68, 0x9e, 0x6c, 0xa5, 0x0a, 0x75, 0x4d, 0x31, 0xbf, 0x78, 0xe5, - 0x73, 0x3b, 0x0a, 0xc0, 0x26, 0x9f, 0xc9, 0x6a, 0xc8, 0x32, 0xab, 0xca, 0x3d, 0x6a, 0xdc, 0xca, 0xad, 0x80, 0x9a, - 0x91, 0xea, 0x86, 0xd3, 0x94, 0x85, 0x37, 0x06, 0x43, 0x77, 0x73, 0x18, 0xa5, 0x69, 0x34, 0xef, 0x3a, 0xf6, 0xe2, - 0x56, 0x55, 0x7a, 0x42, 0xd8, 0xc1, 0xed, 0xf0, 0xbb, 0xff, 0xfa, 0x67, 0x05, 0xcd, 0x53, 0xf9, 0x75, 0xca, 0xe6, - 0x0b, 0x16, 0x7b, 0xe9, 0x32, 0x66, 0x99, 0xf2, 0xaf, 0xff, 0x79, 0x55, 0xb9, 0xd8, 0xf7, 0xe4, 0x36, 0xc4, 0xd2, - 0xcb, 0x4d, 0xae, 0x83, 0x68, 0xb5, 0x57, 0x78, 0xdc, 0xdd, 0x53, 0x79, 0xe6, 0x4f, 0x67, 0x79, 0xed, 0xd3, 0x74, - 0xcb, 0xd8, 0x04, 0xf4, 0xa4, 0x0f, 0x50, 0xce, 0xa3, 0x55, 0xf7, 0x5f, 0xff, 0xcc, 0x05, 0x36, 0xf7, 0xee, 0xba, - 0x7a, 0x40, 0xcb, 0x2b, 0x5a, 0x5f, 0x67, 0x63, 0x89, 0xe1, 0xfd, 0xc6, 0x02, 0x6f, 0x14, 0xd2, 0xae, 0xdc, 0xd4, - 0xcd, 0x6d, 0x19, 0xd3, 0x77, 0xfe, 0x74, 0xf6, 0xb9, 0x83, 0x82, 0x09, 0xbd, 0x77, 0x54, 0x50, 0xe9, 0x0b, 0x0c, - 0x6b, 0xd0, 0xdd, 0x7d, 0xc1, 0x3e, 0x73, 0x5c, 0xf7, 0x0d, 0xe9, 0x4b, 0x8c, 0x86, 0x4b, 0x6e, 0xdf, 0x0f, 0x06, - 0x79, 0xb2, 0x5a, 0xb9, 0x3d, 0xf8, 0x0c, 0x9e, 0x6e, 0x94, 0x70, 0xf6, 0xa2, 0x6b, 0xeb, 0x14, 0xcc, 0x67, 0x87, - 0x09, 0x41, 0xeb, 0xf7, 0x9a, 0xe9, 0x68, 0xc6, 0xd7, 0xe4, 0xc4, 0xb6, 0xf1, 0xed, 0x0d, 0x64, 0x0d, 0xa5, 0x98, - 0xe8, 0x34, 0xd7, 0x1a, 0x1a, 0xf5, 0xe0, 0xac, 0x62, 0x6f, 0x41, 0x4a, 0x02, 0x05, 0x35, 0x26, 0x20, 0x74, 0xa9, - 0xdc, 0xa2, 0x6f, 0xbc, 0xe0, 0x66, 0xb7, 0x0b, 0x55, 0x33, 0x05, 0x43, 0xd2, 0xfc, 0xef, 0x23, 0xde, 0x48, 0x97, - 0x1f, 0x4c, 0xbb, 0x57, 0x5e, 0xca, 0xe2, 0xeb, 0x19, 0x78, 0xfb, 0x0a, 0xe9, 0x01, 0xc4, 0xd1, 0xdd, 0x86, 0x94, - 0x4b, 0x6c, 0x69, 0x0d, 0x1a, 0x2d, 0x30, 0xdc, 0x6f, 0xc3, 0xdd, 0x5f, 0x08, 0x73, 0x77, 0xcf, 0xc0, 0x1f, 0xf3, - 0x77, 0xc3, 0xde, 0xdb, 0x28, 0xd3, 0xff, 0x63, 0xef, 0xff, 0x44, 0xec, 0xbd, 0xf5, 0x3b, 0xbf, 0x65, 0x61, 0xff, - 0x0f, 0x60, 0xf9, 0x2e, 0x73, 0xcf, 0x38, 0xa6, 0xd7, 0x34, 0xcf, 0xd5, 0xe2, 0xd2, 0xe1, 0x45, 0xbc, 0xba, 0xa1, - 0x60, 0xe5, 0xc1, 0xd6, 0xb8, 0xe5, 0xa0, 0x87, 0xc8, 0x7e, 0xcb, 0x51, 0xfe, 0xfd, 0x11, 0x7d, 0x42, 0x19, 0xaa, - 0x24, 0x4c, 0xdf, 0x3d, 0x33, 0x92, 0xd2, 0x48, 0xbc, 0x95, 0x77, 0xb7, 0x0b, 0xde, 0x11, 0xc0, 0x7e, 0xb3, 0xf2, - 0xee, 0xea, 0x80, 0x6d, 0x44, 0xaf, 0xd5, 0x8f, 0x9d, 0x82, 0x97, 0x4f, 0x17, 0x5d, 0x7c, 0x8c, 0x41, 0xc2, 0xd2, - 0x53, 0x28, 0x74, 0x1f, 0xaf, 0xf7, 0xaa, 0x15, 0xb3, 0x01, 0xf8, 0x3f, 0x4b, 0x80, 0x47, 0x25, 0xc0, 0xfd, 0xe4, - 0x3a, 0x0a, 0x1f, 0x02, 0xf9, 0xcf, 0x20, 0xfc, 0xf9, 0xcd, 0xa0, 0xe3, 0xe7, 0x36, 0x60, 0xc7, 0xd2, 0x2a, 0xf0, - 0x58, 0x58, 0x85, 0xbe, 0x57, 0x2f, 0xab, 0xaf, 0x10, 0x5a, 0xa4, 0xb1, 0x8c, 0x08, 0xad, 0x02, 0x7a, 0x15, 0x05, - 0x74, 0x5c, 0x15, 0x92, 0xeb, 0x87, 0x93, 0xd8, 0x8b, 0xd9, 0xb8, 0xf9, 0x0a, 0x50, 0xb2, 0x4e, 0xbe, 0xb3, 0x92, - 0xe5, 0x62, 0x11, 0xc5, 0x69, 0x72, 0x8d, 0x71, 0x5a, 0xe6, 0x3e, 0x5c, 0x28, 0x20, 0xa3, 0x58, 0x1e, 0xb5, 0xf7, - 0xac, 0x4e, 0xbe, 0x6d, 0x30, 0xb7, 0x9c, 0x6c, 0x83, 0x7b, 0xdf, 0x18, 0xdc, 0x7f, 0x67, 0x26, 0xe9, 0x2f, 0x66, - 0x56, 0x1a, 0xfb, 0x73, 0x4d, 0x37, 0x1c, 0x5b, 0xd7, 0x85, 0x7c, 0x65, 0xe6, 0xf6, 0xf7, 0x28, 0xda, 0xf0, 0x4c, - 0x87, 0xa8, 0x85, 0xe8, 0xd1, 0x02, 0xb6, 0x72, 0x2f, 0x97, 0x93, 0x09, 0x8b, 0x35, 0x11, 0x96, 0x11, 0xe2, 0xc2, - 0x92, 0x31, 0x20, 0xf8, 0x39, 0x7e, 0xf0, 0xd9, 0x0a, 0xf2, 0x3f, 0x15, 0x61, 0xd5, 0xc1, 0xd7, 0x93, 0xcc, 0xc9, - 0x21, 0xb7, 0x5c, 0xda, 0x6e, 0x69, 0xe3, 0x67, 0x07, 0xc6, 0x0c, 0x82, 0x31, 0x15, 0xee, 0xf1, 0x18, 0xe7, 0xcf, - 0x0f, 0xd3, 0x0e, 0x7e, 0x01, 0x3a, 0x80, 0xc3, 0x1b, 0xb8, 0xb9, 0x5f, 0x94, 0x32, 0xca, 0x3b, 0x9c, 0xb9, 0xfd, - 0xe0, 0xb9, 0x4b, 0x7a, 0x1e, 0xb4, 0xdb, 0x7b, 0x35, 0xf3, 0xe2, 0x57, 0xd1, 0x98, 0x21, 0xa0, 0xc3, 0x34, 0x02, - 0x6f, 0x4d, 0x29, 0x0c, 0x0f, 0x46, 0xe1, 0x31, 0x4b, 0x91, 0x79, 0xf6, 0xa1, 0xe8, 0x5a, 0x2e, 0x72, 0x9f, 0x3f, - 0xde, 0x37, 0xe0, 0xa4, 0xd5, 0xaf, 0xb4, 0x58, 0x34, 0xbe, 0xd4, 0xb5, 0xaf, 0xe4, 0xdd, 0xfa, 0xca, 0x8b, 0x63, - 0x9f, 0xc5, 0x8a, 0xf6, 0xdd, 0xaf, 0xba, 0xbc, 0x69, 0x4b, 0x0a, 0x1d, 0xae, 0x65, 0x56, 0x30, 0x1a, 0xdd, 0xc4, - 0x67, 0xc1, 0xd8, 0x55, 0x47, 0xd4, 0x30, 0x57, 0xde, 0xb4, 0x3b, 0xb6, 0x6d, 0x73, 0x85, 0xa9, 0x43, 0x3f, 0x41, - 0x61, 0x0a, 0x3f, 0xe1, 0xa1, 0x24, 0x5e, 0xec, 0x10, 0x17, 0xb1, 0x41, 0xce, 0x6a, 0x21, 0x7c, 0x47, 0xf1, 0x7c, - 0x1e, 0x02, 0x1b, 0x8f, 0xfb, 0x23, 0x40, 0x73, 0x04, 0x58, 0x05, 0x4c, 0x15, 0x80, 0x0e, 0x1f, 0x02, 0xd0, 0x85, - 0x3f, 0xf7, 0xc3, 0x69, 0xd2, 0x08, 0x11, 0xaa, 0x4d, 0x4b, 0xf0, 0xa4, 0xd4, 0x42, 0x55, 0x70, 0x0d, 0x67, 0x51, - 0x00, 0x79, 0x88, 0x54, 0x66, 0x4d, 0x2d, 0xe5, 0x85, 0x6d, 0xdb, 0x86, 0x79, 0x00, 0x19, 0xff, 0x0e, 0x8f, 0x6c, - 0xc3, 0x84, 0xbf, 0x2c, 0xcb, 0xaa, 0x91, 0xc7, 0xf6, 0xe6, 0x7e, 0x68, 0xd2, 0x63, 0xcb, 0xde, 0x0d, 0xde, 0x7b, - 0xad, 0x7a, 0x13, 0xae, 0x1b, 0x1b, 0xe6, 0xae, 0xa3, 0xda, 0xd0, 0x4d, 0xca, 0xb6, 0x6e, 0x16, 0x05, 0x5c, 0xe2, - 0xf1, 0x30, 0x2a, 0xc4, 0x68, 0x58, 0x7e, 0x8b, 0x6c, 0x69, 0x5c, 0xcd, 0x63, 0xa1, 0x7e, 0xcf, 0xc1, 0xea, 0x2a, - 0xaf, 0xa2, 0x65, 0x30, 0x46, 0x73, 0x28, 0xb0, 0x5d, 0x56, 0x0a, 0xab, 0xd0, 0x4a, 0xb2, 0x29, 0xc8, 0x25, 0x8e, - 0x89, 0xd6, 0xde, 0x23, 0x71, 0x8a, 0x62, 0xed, 0x29, 0x4e, 0xf1, 0x65, 0xdd, 0x16, 0xbc, 0x7a, 0x0a, 0xf1, 0x84, - 0x76, 0x68, 0xc0, 0xf7, 0x05, 0xd4, 0x0f, 0x76, 0xa9, 0x2f, 0xd6, 0xed, 0xea, 0x29, 0x05, 0x9d, 0xf5, 0x3e, 0x7d, - 0xda, 0x1b, 0x7d, 0xfa, 0xb4, 0xb7, 0x91, 0xa9, 0xa3, 0x79, 0x84, 0xb4, 0x31, 0x18, 0x0f, 0x31, 0x02, 0x71, 0x83, - 0x08, 0xe8, 0xef, 0xa1, 0xbc, 0xeb, 0xf1, 0x68, 0x55, 0xf4, 0x34, 0x32, 0xf8, 0x07, 0xe9, 0x31, 0xc8, 0x2a, 0x93, - 0x32, 0x73, 0x3d, 0x12, 0xf3, 0x7c, 0xfa, 0xc4, 0x8f, 0x9b, 0x31, 0x76, 0x47, 0x79, 0x91, 0xa3, 0x1a, 0x4b, 0x37, - 0xc8, 0x1f, 0x55, 0x04, 0x79, 0xc9, 0x31, 0x66, 0x01, 0xf1, 0xca, 0x8b, 0x43, 0x19, 0xe0, 0x9f, 0x22, 0x85, 0x7f, - 0x56, 0xe1, 0x11, 0x51, 0xc7, 0xd5, 0xd5, 0x98, 0xb8, 0x4c, 0x5b, 0x12, 0x0e, 0x14, 0x96, 0x6e, 0x52, 0x07, 0x17, - 0x02, 0xdb, 0x63, 0x5a, 0x54, 0x31, 0x40, 0xf4, 0xaf, 0xc6, 0x93, 0x3b, 0x16, 0xc3, 0x7a, 0xe7, 0xad, 0xba, 0x4b, - 0xf1, 0x70, 0x46, 0x26, 0xf1, 0xdd, 0x49, 0xee, 0xb7, 0xbc, 0x20, 0xaf, 0xc3, 0xa9, 0xfb, 0x6d, 0xac, 0x2d, 0x8c, - 0xd4, 0x50, 0x05, 0x19, 0x51, 0x75, 0x63, 0x5e, 0x17, 0x60, 0xb5, 0x37, 0xe7, 0xe1, 0x66, 0x34, 0xb1, 0x15, 0xae, - 0x27, 0xe8, 0xab, 0x10, 0x8e, 0xee, 0x30, 0x80, 0x72, 0xf1, 0x9e, 0x40, 0xb9, 0xe6, 0xd9, 0xf7, 0xc6, 0xf2, 0x2b, - 0x58, 0x70, 0xd5, 0x98, 0xe8, 0x06, 0xf9, 0x00, 0x4c, 0xbf, 0xa4, 0xb9, 0x3f, 0xc5, 0x54, 0x9e, 0x4b, 0xe1, 0x5e, - 0x85, 0x03, 0xc0, 0x75, 0xc5, 0x01, 0xa0, 0x66, 0x3e, 0x95, 0x98, 0x25, 0x8b, 0x28, 0x84, 0xbb, 0xe2, 0x75, 0xe1, - 0xe1, 0x75, 0xbd, 0xe9, 0xe1, 0x55, 0xd3, 0x14, 0xdf, 0x50, 0x3b, 0x50, 0x49, 0x5f, 0xfc, 0x57, 0xc5, 0x42, 0x5f, - 0x90, 0x7a, 0xcc, 0x52, 0x7e, 0xd6, 0xe4, 0xd9, 0xfd, 0xfd, 0xfd, 0x9e, 0xdd, 0xe7, 0x3b, 0x79, 0x76, 0x7f, 0xff, - 0xc5, 0x3d, 0xbb, 0xcf, 0x64, 0xcf, 0x6e, 0x20, 0xc1, 0x67, 0x6c, 0x27, 0x47, 0x5a, 0xe1, 0xd2, 0x12, 0xad, 0x12, - 0xd7, 0xe1, 0x9a, 0xb5, 0x64, 0x34, 0x63, 0x60, 0xaa, 0xc0, 0x59, 0xdd, 0x20, 0x9a, 0x82, 0xbf, 0x6b, 0xb3, 0x47, - 0xeb, 0x97, 0xf2, 0x67, 0x0d, 0xa2, 0xa9, 0x2a, 0xe5, 0x69, 0x0b, 0x45, 0x9e, 0x36, 0x88, 0x4d, 0xf7, 0xb7, 0x5b, - 0xe7, 0xe5, 0xa5, 0xd3, 0x6b, 0x3b, 0x10, 0xe7, 0x14, 0xb4, 0xcf, 0x58, 0x60, 0xf7, 0xda, 0x6d, 0x28, 0x58, 0x49, - 0x05, 0x2d, 0x28, 0xf0, 0xa5, 0x82, 0x43, 0x28, 0x18, 0x49, 0x05, 0x47, 0x50, 0x30, 0x96, 0x0a, 0x8e, 0xa1, 0xe0, - 0x46, 0xcd, 0x2e, 0xc3, 0xdc, 0x6f, 0xfd, 0x58, 0xbf, 0x2a, 0xa5, 0xe8, 0xcc, 0x4d, 0x25, 0x44, 0x95, 0x63, 0x43, - 0xe4, 0x8b, 0x30, 0x0f, 0x74, 0xce, 0xa3, 0x0d, 0xbe, 0x1a, 0x00, 0xe6, 0x05, 0xcb, 0x11, 0x03, 0xec, 0x6e, 0xa8, - 0x66, 0x5b, 0xbc, 0x56, 0xbb, 0xb9, 0x9f, 0xb7, 0x6d, 0xb4, 0x84, 0xdf, 0x74, 0x17, 0xa3, 0x78, 0x88, 0xca, 0x87, - 0xcf, 0x67, 0x79, 0xf0, 0xe8, 0xa5, 0x5b, 0x04, 0xc3, 0x69, 0x43, 0x0a, 0x1d, 0xce, 0xab, 0x31, 0x0d, 0xec, 0x65, - 0x20, 0xd6, 0x89, 0x38, 0x45, 0xe2, 0x03, 0x0a, 0x3a, 0xc3, 0xf7, 0xbc, 0x82, 0x87, 0xe3, 0xa1, 0xd6, 0x09, 0xfa, - 0x79, 0x1e, 0xc1, 0x9a, 0x74, 0xa9, 0x4b, 0x23, 0xf5, 0xa6, 0xdd, 0x99, 0x41, 0x86, 0x54, 0xdd, 0x29, 0xa4, 0x24, - 0x39, 0x1d, 0x77, 0x17, 0xc6, 0x6a, 0xc6, 0xc2, 0xee, 0x84, 0xbb, 0x1d, 0xc2, 0xfa, 0x93, 0x27, 0xc9, 0x5c, 0x17, - 0x2e, 0x50, 0xb8, 0x27, 0x8a, 0xb7, 0x04, 0xa5, 0x99, 0x6f, 0xa5, 0xc2, 0x7b, 0x47, 0x93, 0x8d, 0xac, 0xbe, 0x84, - 0xaf, 0xc5, 0x6b, 0x36, 0x5c, 0x4e, 0x95, 0xf3, 0x68, 0x7a, 0xaf, 0x5f, 0x85, 0xfc, 0x0a, 0xa0, 0x54, 0xc9, 0x9a, - 0xd4, 0x14, 0xdb, 0x9b, 0x7f, 0x8b, 0x1e, 0xb3, 0x72, 0xfd, 0x14, 0x60, 0x53, 0x52, 0x62, 0x1b, 0xe0, 0x3b, 0x30, - 0xdb, 0x92, 0xe7, 0xc2, 0x39, 0xcc, 0x9f, 0xf4, 0x7c, 0xe1, 0x49, 0xf0, 0xf4, 0x7f, 0x64, 0x49, 0xe2, 0x4d, 0x99, - 0x8c, 0x5a, 0x4a, 0x9d, 0x03, 0x16, 0xcc, 0xd5, 0xc9, 0x38, 0x81, 0xc0, 0xd8, 0xfb, 0x1b, 0xfe, 0x28, 0xe0, 0x32, - 0x0b, 0x7e, 0x5a, 0xb0, 0x68, 0x85, 0xf3, 0x86, 0x6f, 0xc1, 0xf2, 0x94, 0xfd, 0x28, 0x00, 0x89, 0xdc, 0xb0, 0xa0, - 0x5a, 0x98, 0x7a, 0xd3, 0x6a, 0x11, 0xad, 0x75, 0x56, 0x42, 0x7b, 0x7a, 0xe9, 0x51, 0xe0, 0xc2, 0xcf, 0xb0, 0xcb, - 0x0f, 0xa2, 0xe9, 0xef, 0x6a, 0x94, 0xbf, 0xc5, 0x99, 0xe2, 0xc7, 0xd0, 0x08, 0xd3, 0x81, 0x85, 0x73, 0xac, 0x58, - 0x30, 0x85, 0xdd, 0x30, 0x9d, 0x99, 0x18, 0x58, 0x4e, 0x6b, 0x85, 0xba, 0x61, 0xe1, 0xda, 0xae, 0xab, 0xe1, 0x34, - 0xbb, 0xf1, 0x74, 0xe8, 0x69, 0x4e, 0xeb, 0xd8, 0x10, 0x7f, 0x2c, 0xfb, 0x50, 0xcf, 0xb0, 0x07, 0x65, 0xec, 0xdf, - 0xac, 0x27, 0x51, 0x98, 0x9a, 0x13, 0x6f, 0xee, 0x07, 0x77, 0xdd, 0x79, 0x14, 0x46, 0xc9, 0xc2, 0x1b, 0xb1, 0x9e, - 0xc4, 0x8f, 0x62, 0xa0, 0x66, 0x1e, 0x2b, 0xd0, 0xb1, 0x5a, 0x31, 0x9b, 0x53, 0xeb, 0x3c, 0x0e, 0xf3, 0x24, 0x60, - 0xb7, 0x19, 0xff, 0x7c, 0xa9, 0x32, 0x55, 0xc5, 0x2d, 0x47, 0x2d, 0x80, 0x65, 0xe6, 0x41, 0x9e, 0x21, 0xb5, 0x41, - 0x8f, 0x4b, 0x1d, 0xbb, 0x56, 0xeb, 0x30, 0x66, 0x73, 0xc5, 0x3a, 0x6c, 0xec, 0x3c, 0x8e, 0x56, 0x7d, 0x80, 0x16, - 0x1b, 0x9b, 0x09, 0x0b, 0x26, 0xf8, 0xc6, 0xc4, 0xb8, 0x52, 0xa2, 0x1f, 0x13, 0xed, 0x0a, 0xa0, 0x37, 0x36, 0xef, - 0xc1, 0xeb, 0x6e, 0x4b, 0xb1, 0x25, 0x7e, 0xfa, 0xd8, 0x5e, 0x48, 0x7d, 0xc9, 0xf3, 0xa7, 0xaf, 0xb1, 0xba, 0xa3, - 0xd8, 0x3d, 0xd0, 0x1f, 0x4f, 0x82, 0x68, 0xd5, 0x9d, 0xf9, 0xe3, 0x31, 0x0b, 0x7b, 0x08, 0x73, 0x5e, 0xc8, 0x82, - 0xc0, 0x5f, 0x24, 0x7e, 0xd2, 0x9b, 0x7b, 0xb7, 0xbc, 0xd7, 0x83, 0xa6, 0x5e, 0xdb, 0xbc, 0xd7, 0xf6, 0xce, 0xbd, - 0x4a, 0xdd, 0x40, 0x0c, 0x2b, 0xea, 0x87, 0x83, 0x76, 0xa8, 0xd8, 0x95, 0x71, 0xee, 0xdc, 0xeb, 0x22, 0x66, 0xeb, - 0xb9, 0x17, 0x4f, 0xfd, 0xb0, 0x6b, 0x67, 0xd6, 0xcd, 0x9a, 0x36, 0xc6, 0xa3, 0x4e, 0xa7, 0x93, 0x59, 0x63, 0xf1, - 0x64, 0x8f, 0xc7, 0x99, 0x35, 0x12, 0x4f, 0x93, 0x89, 0x6d, 0x4f, 0x26, 0x99, 0xe5, 0x8b, 0x82, 0x76, 0x6b, 0x34, - 0x6e, 0xb7, 0x32, 0x6b, 0x25, 0xd5, 0xc8, 0x2c, 0xc6, 0x9f, 0x62, 0x36, 0xee, 0xe1, 0x46, 0xe2, 0xfe, 0xcf, 0xc7, - 0xb6, 0x9d, 0x21, 0x06, 0xb8, 0x2c, 0xe1, 0x26, 0x34, 0x5d, 0xb9, 0x5a, 0xef, 0x5c, 0x53, 0x29, 0x3e, 0x37, 0x1a, - 0xd5, 0xd6, 0x1b, 0x7b, 0xf1, 0xc7, 0x2b, 0x45, 0x1a, 0x85, 0xe7, 0x51, 0xb5, 0xb5, 0x98, 0x06, 0xf3, 0xb6, 0x0b, - 0x09, 0x3b, 0x7a, 0xc3, 0x28, 0x86, 0x33, 0x1b, 0x7b, 0x63, 0x7f, 0x99, 0x74, 0x9d, 0xd6, 0xe2, 0x56, 0x14, 0xf1, - 0xbd, 0x5e, 0x14, 0xe0, 0xd9, 0xeb, 0x26, 0x51, 0xe0, 0x8f, 0x45, 0x51, 0xd3, 0x59, 0x72, 0x5a, 0x7a, 0x0f, 0xf9, - 0x57, 0x1f, 0x83, 0x2e, 0x7b, 0x41, 0xa0, 0x58, 0xed, 0x44, 0x61, 0x5e, 0x82, 0xe6, 0x72, 0x8a, 0x9d, 0xd0, 0xbc, - 0x60, 0x68, 0x5a, 0xe7, 0x60, 0x71, 0x9b, 0xef, 0x79, 0xe7, 0x68, 0x71, 0x9b, 0x7d, 0x3d, 0x67, 0x63, 0xdf, 0x53, - 0xb4, 0x62, 0x37, 0x39, 0x36, 0x98, 0xd4, 0xe9, 0xeb, 0x86, 0x6d, 0x2a, 0x8e, 0x05, 0x24, 0x36, 0xda, 0xf3, 0xe7, - 0x20, 0x87, 0xf1, 0xc2, 0x34, 0xcb, 0x06, 0x57, 0x59, 0xd6, 0x3b, 0xf7, 0xb5, 0xcb, 0xff, 0xd6, 0x88, 0x16, 0x92, - 0x09, 0x6a, 0xa6, 0x5f, 0x19, 0x67, 0x4c, 0x76, 0x97, 0x01, 0x32, 0x86, 0xae, 0x32, 0x72, 0x65, 0xa2, 0xb7, 0x9b, - 0x95, 0x69, 0x92, 0xf3, 0xea, 0xe4, 0x7d, 0x53, 0xae, 0x82, 0x14, 0x08, 0x2a, 0x9c, 0x31, 0xf7, 0x5c, 0xf2, 0xbd, - 0x01, 0xa6, 0x07, 0x2b, 0x53, 0x54, 0xa1, 0xd7, 0x4d, 0xbc, 0xe7, 0xc5, 0xfd, 0xbc, 0xe7, 0x5f, 0xd3, 0x5d, 0x78, - 0xcf, 0x8b, 0x2f, 0xce, 0x7b, 0xbe, 0xde, 0x8c, 0x2a, 0x74, 0x11, 0xb9, 0x6a, 0x6e, 0x30, 0x09, 0xa4, 0x29, 0xa6, - 0x78, 0xfd, 0xaf, 0xd3, 0xdf, 0x1a, 0xde, 0x45, 0xf4, 0x86, 0x44, 0x81, 0xf3, 0xa9, 0x20, 0x66, 0x7d, 0x1b, 0xba, - 0x7f, 0x8e, 0xe5, 0xe7, 0xc9, 0xc4, 0x7d, 0x1d, 0x49, 0x05, 0xf9, 0x13, 0xf7, 0x25, 0x29, 0xc5, 0x56, 0xa6, 0x37, - 0xb9, 0xb7, 0x0f, 0x64, 0x9f, 0x86, 0xd0, 0xac, 0xe4, 0xda, 0x3d, 0xce, 0x7d, 0xee, 0x7a, 0x65, 0x10, 0xb4, 0xdc, - 0xc9, 0x55, 0x04, 0xe0, 0xda, 0xb0, 0x8c, 0x9a, 0x32, 0x21, 0x03, 0x78, 0x79, 0xf7, 0xfd, 0x58, 0xbb, 0x88, 0xf4, - 0xcc, 0x4f, 0xde, 0x56, 0xc3, 0x5f, 0x09, 0x3d, 0x97, 0x3c, 0x9c, 0x8c, 0xfb, 0xcd, 0x49, 0x51, 0x6e, 0xf1, 0x35, - 0x35, 0x3f, 0x2d, 0x8d, 0xb4, 0x2b, 0x37, 0xec, 0x51, 0xcc, 0xef, 0x0d, 0x62, 0xcc, 0xc3, 0xc4, 0xac, 0x39, 0x97, - 0xb7, 0xc6, 0x67, 0x88, 0x1a, 0x3a, 0xa6, 0xe6, 0xfe, 0x38, 0xcb, 0xf4, 0x9e, 0x98, 0x08, 0x89, 0xd0, 0xb2, 0xfb, - 0x98, 0xb8, 0xa4, 0x10, 0x02, 0x71, 0x89, 0x0f, 0x59, 0x33, 0x5f, 0x80, 0x7f, 0x00, 0xb7, 0x7d, 0xe6, 0x73, 0xa6, - 0x2a, 0x34, 0x7d, 0xe4, 0x37, 0x22, 0x0d, 0x08, 0x0c, 0xda, 0x65, 0x6f, 0xab, 0xd2, 0x82, 0x6c, 0x3a, 0xb6, 0xd2, - 0xe4, 0xa0, 0x83, 0x03, 0xc4, 0xf8, 0x15, 0x62, 0x21, 0x42, 0x3b, 0xbc, 0x0e, 0x3e, 0x64, 0x6a, 0xce, 0xfb, 0xe1, - 0xf6, 0xeb, 0x9f, 0xec, 0x43, 0x83, 0x7e, 0x45, 0xe9, 0x76, 0x8f, 0x5f, 0x26, 0xb0, 0x12, 0xc9, 0xca, 0xb0, 0x92, - 0x95, 0xf2, 0x6c, 0x2d, 0xe2, 0x63, 0xa7, 0xde, 0xc2, 0x04, 0x2d, 0x0f, 0xe2, 0x5e, 0x8e, 0xf1, 0xa4, 0x50, 0xdc, - 0xbd, 0x65, 0x02, 0xb8, 0x11, 0xe5, 0x28, 0x88, 0x7f, 0x7a, 0xa3, 0x65, 0x9c, 0x44, 0x71, 0x77, 0x11, 0xf9, 0x61, - 0xca, 0xe2, 0x8c, 0x04, 0x2b, 0x38, 0x3f, 0x62, 0x7a, 0xae, 0xd6, 0xd1, 0xc2, 0x1b, 0xf9, 0xe9, 0x5d, 0xd7, 0xe6, - 0x2c, 0x85, 0xdd, 0xe3, 0xdc, 0x81, 0x5d, 0x5b, 0xbf, 0xcb, 0x67, 0xf3, 0x39, 0x32, 0x7e, 0xf1, 0x26, 0x3b, 0x23, - 0x6f, 0xf3, 0x9e, 0xf4, 0x96, 0x22, 0x84, 0x03, 0xfb, 0xe1, 0xc5, 0xe6, 0x14, 0xb0, 0x3c, 0x2c, 0xb5, 0x3d, 0x66, - 0x53, 0x03, 0xb1, 0x36, 0x98, 0x19, 0x8a, 0x3f, 0xd6, 0xa1, 0xae, 0xd8, 0xf5, 0xc5, 0xc0, 0xf1, 0xe8, 0xbb, 0x40, - 0xd6, 0xf5, 0x26, 0x29, 0x8b, 0x8d, 0x5d, 0x6a, 0x0e, 0xd9, 0x24, 0x8a, 0x19, 0x65, 0x93, 0x73, 0x3a, 0x8b, 0xdb, - 0xdd, 0xbb, 0xdf, 0x3e, 0xfc, 0xfa, 0x7e, 0xc2, 0x28, 0xd5, 0x44, 0x67, 0xfa, 0x3d, 0xbd, 0x6d, 0xd2, 0x33, 0x60, - 0x0d, 0x69, 0xe6, 0x47, 0x24, 0x05, 0x81, 0x48, 0x60, 0xb5, 0x49, 0x3b, 0x16, 0x11, 0xa7, 0x79, 0x31, 0x0b, 0xbc, - 0xd4, 0xbf, 0x11, 0x3c, 0x63, 0xfb, 0x68, 0x71, 0x2b, 0xd6, 0x18, 0x09, 0xde, 0x03, 0x16, 0xa9, 0x02, 0x8a, 0x58, - 0xa4, 0x6a, 0x31, 0x2e, 0x52, 0x6f, 0x63, 0x34, 0x22, 0x8e, 0x75, 0x85, 0xd2, 0x1f, 0x2e, 0x6e, 0x65, 0x12, 0x5d, - 0x34, 0xcb, 0x29, 0x75, 0x35, 0x01, 0xc9, 0xdc, 0x1f, 0x8f, 0x03, 0x96, 0x95, 0x16, 0xba, 0xbc, 0x96, 0xd2, 0xe4, - 0xe4, 0xf3, 0xe0, 0x0d, 0x93, 0x28, 0x58, 0xa6, 0xac, 0x7e, 0xba, 0x84, 0x44, 0xb7, 0x98, 0x1c, 0xfc, 0x5d, 0x86, - 0xf5, 0x10, 0xd8, 0x6d, 0xd8, 0x26, 0x76, 0x0f, 0xf2, 0x0d, 0x9a, 0xed, 0x32, 0xe8, 0xf0, 0x2a, 0x07, 0xda, 0xa8, - 0x19, 0x88, 0x01, 0x64, 0x89, 0xb0, 0xb7, 0x62, 0x39, 0xbc, 0x2c, 0xcf, 0xb9, 0x96, 0x17, 0x65, 0xe5, 0xc1, 0xfc, - 0x3e, 0x67, 0xec, 0x45, 0xfd, 0x19, 0x7b, 0x21, 0xce, 0xd8, 0xf6, 0x9d, 0xf9, 0x68, 0xe2, 0xc0, 0x7f, 0xbd, 0x62, - 0x40, 0x5d, 0x5b, 0x69, 0x2f, 0x6e, 0x15, 0x67, 0x71, 0xab, 0x98, 0xad, 0xc5, 0xad, 0x82, 0x5d, 0xa3, 0x7b, 0x8b, - 0x61, 0xb5, 0x74, 0xc3, 0x56, 0xa0, 0x10, 0xfe, 0xd8, 0xa5, 0x57, 0xce, 0x01, 0xbc, 0x83, 0x56, 0x87, 0x9b, 0xef, - 0x5a, 0xdb, 0x8f, 0x3a, 0x9d, 0x25, 0x81, 0xb4, 0x75, 0x2b, 0xf5, 0x86, 0x43, 0x10, 0x65, 0x46, 0xa3, 0x65, 0xf2, - 0x0f, 0x0e, 0x3f, 0x9f, 0xc4, 0xad, 0x88, 0xa0, 0xd2, 0x8f, 0x68, 0x0a, 0x8a, 0xc2, 0x1b, 0x26, 0x7a, 0x58, 0xe7, - 0xeb, 0xd4, 0xa5, 0xe4, 0x88, 0x2d, 0xeb, 0xa0, 0x66, 0x93, 0xd7, 0x4f, 0xf4, 0xef, 0xb6, 0x4a, 0xcd, 0x28, 0xe6, - 0x33, 0xa6, 0x65, 0xeb, 0x74, 0x3c, 0x7c, 0x36, 0xf8, 0x6a, 0xda, 0x9d, 0x7a, 0x70, 0x2f, 0xc5, 0x97, 0xae, 0x04, - 0x51, 0xe1, 0x74, 0x8b, 0x87, 0xe2, 0xd8, 0xde, 0x6b, 0xd3, 0x1e, 0xd9, 0xe8, 0x75, 0x0b, 0x41, 0x28, 0xea, 0xee, - 0x88, 0xe5, 0x1f, 0xbd, 0x38, 0x80, 0xff, 0x88, 0xab, 0xff, 0x6b, 0x5a, 0xc7, 0xa8, 0xbf, 0x4e, 0x4b, 0x8c, 0x3a, - 0xb1, 0x4a, 0xc8, 0x88, 0xef, 0x5e, 0x7f, 0x32, 0x79, 0x58, 0x83, 0x9d, 0x6b, 0x93, 0x67, 0x58, 0xb5, 0xf6, 0xcb, - 0x28, 0x0a, 0x98, 0x17, 0x6e, 0x56, 0x17, 0xd3, 0x43, 0x6e, 0xfe, 0xa9, 0x0b, 0x8d, 0xc4, 0x3d, 0x82, 0x9c, 0x12, - 0x54, 0x6c, 0x43, 0x57, 0x89, 0xf3, 0xa6, 0xab, 0xc4, 0xbb, 0xfb, 0xaf, 0x12, 0x3f, 0xec, 0x74, 0x95, 0x78, 0xf7, - 0xc5, 0xaf, 0x12, 0xe7, 0x9b, 0x57, 0x89, 0xf3, 0x48, 0xb8, 0x03, 0x1b, 0x6f, 0x96, 0xfc, 0xe7, 0x07, 0xb2, 0xf7, - 0x7d, 0x17, 0xb9, 0x87, 0x36, 0x25, 0x3c, 0xbc, 0xf8, 0xcd, 0x17, 0x0b, 0xdc, 0x88, 0xef, 0xd0, 0x3b, 0xae, 0xb8, - 0x5a, 0x70, 0xcc, 0x8e, 0xdf, 0x91, 0x8a, 0x83, 0x28, 0x9c, 0xfe, 0x0c, 0xf6, 0xde, 0x20, 0x0e, 0x8c, 0xa5, 0x17, - 0x7e, 0xf2, 0x73, 0xb4, 0x58, 0x2e, 0x50, 0x51, 0xf5, 0xc1, 0x4f, 0xfc, 0x61, 0xc0, 0xf2, 0x08, 0x93, 0xa4, 0x75, - 0xe5, 0xb2, 0x75, 0x50, 0xbc, 0x8a, 0x9f, 0xde, 0xad, 0xf8, 0x89, 0x2e, 0xb6, 0xfc, 0x37, 0xb9, 0x09, 0xaa, 0xf5, - 0x17, 0x11, 0x61, 0x21, 0x26, 0x01, 0xfd, 0xf0, 0xcb, 0xc8, 0xb9, 0x88, 0xe5, 0x55, 0x1a, 0xa5, 0x70, 0xdf, 0x68, - 0xec, 0x87, 0x55, 0xfb, 0x79, 0xb3, 0xd4, 0x8d, 0x3c, 0x01, 0xc7, 0xa6, 0x38, 0x7f, 0x1e, 0x2d, 0x13, 0x36, 0x8e, - 0x56, 0xa1, 0x6a, 0x84, 0x5c, 0xaf, 0x1a, 0xa1, 0x4c, 0x3d, 0x6f, 0x53, 0x56, 0x38, 0xaa, 0xd6, 0x02, 0xe6, 0xd0, - 0x24, 0x0d, 0xb6, 0x89, 0x43, 0x54, 0x45, 0xc8, 0xa6, 0xde, 0x9e, 0xa6, 0x45, 0xee, 0xc3, 0x5a, 0x0a, 0xcf, 0x93, - 0xc8, 0xe2, 0x52, 0xe1, 0x44, 0x0b, 0x85, 0x70, 0x51, 0x44, 0xc1, 0xae, 0x59, 0x38, 0xfe, 0x86, 0x22, 0x44, 0x16, - 0x6f, 0x41, 0x57, 0x95, 0x2d, 0xf9, 0x7a, 0xf0, 0x98, 0xd0, 0xf4, 0xf8, 0x4a, 0x9a, 0xc6, 0xb7, 0x37, 0x2c, 0x0e, - 0xbc, 0x3b, 0x4d, 0xcf, 0xa2, 0xf0, 0x47, 0x98, 0x80, 0xd7, 0xd1, 0x2a, 0x94, 0x2b, 0x60, 0xaa, 0xf6, 0x9a, 0xbd, - 0x54, 0x1b, 0xbd, 0x1c, 0x62, 0x76, 0x48, 0x10, 0xf8, 0xd6, 0xc2, 0x9b, 0xb2, 0xff, 0x32, 0xe8, 0xdf, 0xff, 0xd6, - 0x33, 0xe3, 0x5d, 0x94, 0x7f, 0xe8, 0x97, 0xc5, 0x0e, 0x9f, 0x79, 0xf2, 0x64, 0xaf, 0x79, 0xd8, 0xda, 0x28, 0x60, - 0x5e, 0x2c, 0xa0, 0xa8, 0x69, 0xad, 0x37, 0x9e, 0x02, 0x80, 0xe2, 0x22, 0x5a, 0x8e, 0x66, 0xe8, 0xb7, 0xfb, 0xe5, - 0xc6, 0x9b, 0x42, 0x9f, 0x2c, 0xb9, 0xb4, 0xaf, 0xf2, 0xa1, 0x57, 0x8a, 0x8a, 0x59, 0xc0, 0xef, 0x9f, 0x41, 0xfa, - 0xad, 0x7f, 0xe3, 0x34, 0x6c, 0xee, 0x9a, 0x3c, 0xe4, 0xd7, 0x83, 0x36, 0x6f, 0xcf, 0x87, 0xa8, 0x3c, 0x14, 0xd8, - 0x5a, 0x28, 0xe9, 0xea, 0x91, 0x4c, 0x56, 0x9d, 0x34, 0x39, 0x89, 0x4c, 0x53, 0x7e, 0x1c, 0xf1, 0x15, 0x66, 0x95, - 0xac, 0x46, 0x0c, 0xc6, 0xb1, 0x55, 0x05, 0xc9, 0x70, 0x6f, 0x0a, 0x86, 0xe8, 0xab, 0xfa, 0x6e, 0xee, 0x87, 0x06, - 0xe6, 0x80, 0xdd, 0x7c, 0xe3, 0xdd, 0x42, 0x16, 0x44, 0x40, 0x6e, 0xd5, 0x57, 0x50, 0x68, 0xc8, 0xd1, 0x82, 0xbc, - 0xf1, 0x58, 0x53, 0x6b, 0x67, 0x42, 0x68, 0x03, 0x07, 0x5f, 0x29, 0x8a, 0xa2, 0xe4, 0xd7, 0x08, 0x25, 0xbf, 0x47, - 0x60, 0x39, 0x5e, 0x07, 0x40, 0x5b, 0x92, 0x2d, 0x6e, 0xa9, 0x04, 0x6e, 0x06, 0x68, 0x3f, 0x2d, 0x0a, 0x78, 0xa2, - 0x1f, 0x30, 0x6e, 0xa1, 0x02, 0x71, 0xa1, 0x07, 0xd5, 0xb7, 0x17, 0x43, 0x3e, 0xc0, 0xae, 0x82, 0x17, 0x76, 0x7c, - 0xcb, 0x25, 0xc1, 0x8a, 0x4d, 0x8f, 0x83, 0x1e, 0xab, 0xcf, 0x08, 0x13, 0x4a, 0x58, 0x10, 0xb4, 0x0e, 0x95, 0x04, - 0x8f, 0x06, 0xab, 0xc1, 0x8d, 0x78, 0x2f, 0xba, 0x4d, 0xe7, 0x2c, 0x5c, 0xaa, 0x06, 0x58, 0x9d, 0x60, 0x86, 0x1e, - 0xa8, 0xf3, 0x9a, 0x98, 0x2d, 0xc0, 0x36, 0xf5, 0x2d, 0x67, 0x44, 0x0b, 0x85, 0xa9, 0x8a, 0x67, 0x8c, 0x78, 0x00, - 0x9c, 0x84, 0xe3, 0xb6, 0x2a, 0x85, 0xe0, 0x4b, 0x1a, 0x95, 0xb1, 0x39, 0x0f, 0x79, 0x85, 0x9c, 0x02, 0xd9, 0x88, - 0x71, 0x71, 0x91, 0x98, 0x76, 0xcd, 0xab, 0x2e, 0x5a, 0xae, 0x91, 0xf1, 0x2a, 0x82, 0xa2, 0x58, 0xdf, 0xec, 0x86, - 0xc3, 0x09, 0x69, 0x09, 0x1a, 0xfb, 0x19, 0x6d, 0xf4, 0xd3, 0x30, 0xe8, 0x8f, 0xec, 0x8e, 0x08, 0x09, 0x4d, 0xd5, - 0x47, 0x76, 0x07, 0xc6, 0xe1, 0x67, 0x20, 0x4d, 0x51, 0xb7, 0xa0, 0x6b, 0x03, 0x12, 0xfd, 0x8e, 0x20, 0x55, 0xc5, - 0x96, 0x03, 0x64, 0x67, 0x5b, 0xb0, 0x38, 0x85, 0x23, 0x35, 0x92, 0x9e, 0x38, 0xc4, 0x3c, 0x62, 0x81, 0x56, 0x3b, - 0xc7, 0x66, 0xcd, 0xd1, 0xd0, 0x9f, 0x39, 0xb6, 0xbd, 0xbf, 0x51, 0x1f, 0x04, 0xd9, 0x75, 0xb5, 0x75, 0x23, 0x75, - 0x1d, 0xdb, 0xf4, 0x9f, 0x59, 0xad, 0xde, 0x06, 0x8d, 0x96, 0x32, 0x49, 0x0d, 0x50, 0xfc, 0xd5, 0x7f, 0xbc, 0xd6, - 0x36, 0x0e, 0xa4, 0x5e, 0x8d, 0x00, 0x80, 0xb0, 0x65, 0x5c, 0xfe, 0x35, 0xd8, 0x24, 0xfd, 0x94, 0xc7, 0x8a, 0xb2, - 0x9a, 0x0f, 0x20, 0x17, 0xa2, 0x06, 0xc7, 0xe8, 0x4f, 0xca, 0x73, 0x45, 0xa3, 0xe3, 0xa3, 0xeb, 0x83, 0x9e, 0xc0, - 0x28, 0x22, 0x44, 0x8e, 0xdc, 0x41, 0xe5, 0x8b, 0x49, 0x15, 0xc3, 0xf1, 0xac, 0x6b, 0xac, 0xd0, 0xe8, 0x6d, 0xe5, - 0x16, 0xb0, 0xff, 0x06, 0xf2, 0x69, 0x0d, 0x21, 0xc6, 0x23, 0xd4, 0x80, 0xcc, 0xa9, 0xf7, 0x76, 0x08, 0xe1, 0x79, - 0xe5, 0xee, 0xca, 0x44, 0x72, 0xf7, 0xce, 0x90, 0xe8, 0xa0, 0x0e, 0x2d, 0xef, 0xaf, 0x9e, 0xdc, 0x3d, 0xb0, 0x4b, - 0x16, 0x8e, 0xcb, 0x1d, 0x56, 0xe8, 0xd7, 0xee, 0xdd, 0x95, 0x30, 0x0a, 0xa4, 0x14, 0x8e, 0x6a, 0x30, 0x4a, 0x16, - 0x85, 0xb8, 0xf9, 0xe9, 0xb8, 0xf9, 0x3b, 0x71, 0x31, 0xd8, 0x80, 0xf2, 0x81, 0xe4, 0xcd, 0x24, 0xa1, 0x38, 0xe4, - 0xad, 0xc4, 0x08, 0x5a, 0x9a, 0x60, 0x44, 0x1b, 0x77, 0x62, 0x2a, 0xdc, 0x15, 0x8b, 0x36, 0x3e, 0xcf, 0x44, 0xb5, - 0xab, 0xd4, 0xda, 0xbf, 0x5f, 0x6a, 0x9d, 0xde, 0x27, 0xb5, 0xa6, 0xe8, 0x30, 0xdc, 0x1e, 0x54, 0x44, 0xc9, 0x11, - 0xcc, 0xb9, 0x1c, 0x67, 0xa8, 0x24, 0xea, 0xc6, 0x60, 0x32, 0x35, 0x56, 0xa4, 0xd4, 0x1b, 0x39, 0x20, 0xa2, 0xf8, - 0x5b, 0xba, 0xa0, 0x08, 0x85, 0xba, 0x2c, 0x1b, 0x3f, 0x2f, 0x64, 0xe3, 0x74, 0xab, 0x29, 0xe2, 0x82, 0x08, 0xee, - 0x5f, 0x8a, 0xb9, 0x93, 0xdf, 0x0e, 0x8a, 0xd8, 0x3b, 0x05, 0xa4, 0x52, 0x34, 0x99, 0xe2, 0xa2, 0x21, 0xc5, 0x28, - 0x12, 0xb7, 0x8c, 0x72, 0xa8, 0xa2, 0x72, 0xd5, 0x22, 0x98, 0x4c, 0x51, 0x0e, 0x52, 0x77, 0x04, 0x39, 0x2f, 0x96, - 0xb7, 0x4d, 0x39, 0x9a, 0x88, 0xfc, 0x5a, 0xda, 0x24, 0x79, 0xd8, 0x0f, 0x9a, 0x60, 0x21, 0xa6, 0xaf, 0xe8, 0xb5, - 0x73, 0x1b, 0x08, 0x04, 0xb2, 0x26, 0x4a, 0xd1, 0xfd, 0xd2, 0x79, 0xca, 0x96, 0x5c, 0xa8, 0xae, 0x1d, 0xa4, 0xee, - 0xa4, 0x09, 0x96, 0xe5, 0x11, 0x38, 0xd7, 0x57, 0x92, 0x04, 0xa1, 0x6b, 0x2b, 0x76, 0xaf, 0x86, 0x01, 0x40, 0xfa, - 0x5f, 0x7d, 0xe6, 0xac, 0x00, 0x48, 0x22, 0x15, 0x5b, 0xd6, 0xf9, 0xe3, 0x21, 0x36, 0xc9, 0x92, 0x1d, 0xab, 0x6e, - 0x7e, 0x93, 0xe4, 0x3d, 0x6b, 0x1e, 0x13, 0xa4, 0x2c, 0xce, 0xe7, 0x35, 0xba, 0x02, 0x0e, 0xbe, 0xcb, 0xe2, 0x65, - 0x88, 0x49, 0x70, 0xcd, 0x34, 0xf6, 0x46, 0x1f, 0xd7, 0xd2, 0xf7, 0xb8, 0x48, 0x14, 0xc4, 0xc5, 0x65, 0xa5, 0x42, - 0xcf, 0xc3, 0x9c, 0x51, 0xac, 0x6b, 0xb5, 0x12, 0x49, 0x50, 0xd3, 0x7d, 0x64, 0xb7, 0xbd, 0x17, 0x93, 0x83, 0x8a, - 0xfc, 0xb4, 0x75, 0x58, 0x96, 0xae, 0xe7, 0x70, 0xcc, 0xa3, 0x5f, 0x79, 0xf4, 0xa4, 0x3f, 0xfe, 0xd3, 0x09, 0xff, - 0x66, 0x65, 0x8d, 0x3e, 0x07, 0x04, 0x68, 0x5f, 0x52, 0x4c, 0xcb, 0x6a, 0x9a, 0x8d, 0x92, 0x26, 0xb0, 0x26, 0x7e, - 0x10, 0x98, 0x01, 0xb8, 0x31, 0xac, 0x3f, 0x6b, 0x78, 0xd8, 0xcf, 0x12, 0xb2, 0x15, 0x7e, 0x46, 0x3f, 0xe5, 0x9d, - 0x92, 0xce, 0x96, 0xf3, 0xe1, 0x5a, 0x16, 0x94, 0x4b, 0xf2, 0xf3, 0x4d, 0x99, 0xb9, 0xfc, 0xd9, 0xc9, 0x64, 0x52, - 0x96, 0x1a, 0xdb, 0xca, 0x01, 0x4a, 0x7e, 0x1f, 0xd9, 0xb6, 0x5d, 0x9d, 0xdf, 0xa6, 0x83, 0x42, 0x07, 0xc3, 0x44, - 0x21, 0x7c, 0xe7, 0xfe, 0x3d, 0xf5, 0x07, 0x41, 0x4b, 0x5d, 0x35, 0x9d, 0x47, 0xda, 0x6a, 0xff, 0x11, 0xa0, 0x20, - 0x6a, 0xb8, 0xef, 0xf8, 0x6f, 0xee, 0x95, 0x2d, 0x3d, 0x55, 0x0f, 0xf0, 0xc3, 0x1a, 0xdf, 0xb3, 0xd7, 0x77, 0x68, - 0xda, 0xb4, 0xbd, 0x33, 0xab, 0x20, 0xbb, 0x25, 0x9b, 0xa5, 0x1e, 0x59, 0x2a, 0xf9, 0x29, 0x9b, 0x27, 0xdd, 0x11, - 0x43, 0x05, 0xa9, 0x25, 0x51, 0x5b, 0xb4, 0xea, 0x31, 0xa7, 0x60, 0xc7, 0xe5, 0x08, 0x3c, 0x6c, 0x2b, 0xa8, 0xac, - 0xda, 0xd0, 0xac, 0x89, 0x8f, 0x20, 0x15, 0x5b, 0x6f, 0x2a, 0x9c, 0x70, 0x9b, 0x1e, 0xda, 0x7f, 0x2a, 0xd5, 0x53, - 0x80, 0x3b, 0x5d, 0x0b, 0x6b, 0x13, 0x52, 0x9e, 0xe0, 0xdf, 0xb9, 0x72, 0xee, 0xc5, 0xe2, 0xb6, 0x6c, 0xdc, 0xd5, - 0x01, 0x75, 0x53, 0x41, 0xca, 0x08, 0xea, 0x3a, 0xd4, 0x97, 0x9b, 0x00, 0x4d, 0x64, 0xeb, 0x16, 0xb0, 0xa0, 0x11, - 0x53, 0x50, 0xd1, 0x11, 0xe6, 0xa0, 0xe2, 0x75, 0x16, 0x76, 0x5e, 0x21, 0xdf, 0xc7, 0x5f, 0x90, 0xa5, 0x1c, 0xd2, - 0x9d, 0xfc, 0xc9, 0x78, 0xde, 0x41, 0xe5, 0x5e, 0x69, 0xab, 0xa2, 0xa9, 0x0c, 0xee, 0x01, 0x71, 0x23, 0x55, 0x96, - 0x71, 0x60, 0x52, 0xe2, 0x7a, 0x4d, 0x5f, 0x6f, 0x8e, 0xbb, 0xb9, 0x7b, 0xe7, 0x10, 0xf4, 0x1a, 0x9b, 0x53, 0xb5, - 0x93, 0x6a, 0xaf, 0xaa, 0xc3, 0x16, 0x70, 0xc2, 0x0a, 0x80, 0xcf, 0xac, 0x82, 0x46, 0x43, 0x4a, 0x05, 0xf7, 0xd1, - 0xa0, 0xf3, 0xb7, 0x32, 0xb2, 0x16, 0xe3, 0xc4, 0xee, 0xea, 0xab, 0x50, 0xdf, 0x42, 0x33, 0x08, 0x73, 0xc7, 0xb1, - 0x13, 0x3e, 0x9b, 0xb0, 0x63, 0x64, 0x74, 0xe5, 0xe0, 0x0e, 0xc2, 0x53, 0x6a, 0x52, 0xf2, 0x13, 0x3a, 0xa5, 0xa8, - 0x4b, 0xf8, 0xa1, 0x56, 0x78, 0x7f, 0x51, 0x92, 0xc6, 0xf3, 0xa0, 0x13, 0x2d, 0x7d, 0xa7, 0xda, 0x73, 0x3f, 0xdc, - 0xbd, 0xae, 0x77, 0xbb, 0x73, 0x5d, 0x60, 0x0e, 0x77, 0xae, 0x0c, 0xdc, 0x25, 0x56, 0xbe, 0x48, 0xdd, 0x1f, 0x24, - 0xe5, 0x81, 0x1c, 0x30, 0x51, 0xc5, 0x56, 0x74, 0xa3, 0xff, 0x69, 0xe9, 0x0e, 0x4e, 0x4e, 0x6f, 0xe7, 0x81, 0x72, - 0xc3, 0xe2, 0x04, 0x12, 0x4a, 0xa8, 0x8e, 0x65, 0xab, 0x0a, 0x1a, 0xf4, 0xfb, 0xe1, 0xd4, 0x55, 0x7f, 0xb9, 0x78, - 0x63, 0x76, 0xd4, 0x53, 0x30, 0xc7, 0xb8, 0x99, 0x22, 0x8b, 0x7b, 0xee, 0xdd, 0xb1, 0xf8, 0xba, 0xc5, 0x3d, 0x7e, - 0x88, 0xb9, 0xc5, 0x32, 0xa5, 0xa5, 0xee, 0x90, 0x12, 0x5e, 0xb9, 0xf1, 0xd9, 0xea, 0x65, 0x74, 0xeb, 0xaa, 0x80, - 0x58, 0x9d, 0x56, 0x47, 0x71, 0x5a, 0x07, 0xd6, 0x51, 0x47, 0xed, 0x7f, 0xa5, 0x28, 0x27, 0x63, 0x36, 0x49, 0xfa, - 0x28, 0x8e, 0x39, 0x41, 0x7e, 0x90, 0x7e, 0x2b, 0x8a, 0x35, 0x0a, 0x12, 0xd3, 0x51, 0xd6, 0xfc, 0x51, 0x51, 0x00, - 0x19, 0x75, 0x95, 0x47, 0x93, 0xd6, 0xe4, 0x60, 0xf2, 0xa2, 0xc7, 0x8b, 0xb3, 0xaf, 0x4a, 0xd5, 0x0d, 0xfa, 0xb7, - 0x25, 0x35, 0x4b, 0xd2, 0x38, 0xfa, 0xc8, 0x38, 0x2f, 0xa9, 0xe4, 0x82, 0xa2, 0x6a, 0xd3, 0xd6, 0xe6, 0x97, 0x9c, - 0xce, 0x70, 0x34, 0x69, 0x15, 0xd5, 0x11, 0xc6, 0xfd, 0x1c, 0xc8, 0x93, 0x7d, 0x01, 0xfa, 0x89, 0x3c, 0x4d, 0x8e, - 0x59, 0x37, 0x51, 0x8e, 0xca, 0xc7, 0x38, 0x15, 0xe3, 0x3b, 0x81, 0x8c, 0x6b, 0x85, 0xf7, 0x62, 0x82, 0xcd, 0x5c, - 0xf5, 0x47, 0xa7, 0xd5, 0x31, 0x1c, 0xe7, 0xc8, 0x3a, 0xea, 0x8c, 0x6c, 0xe3, 0xc0, 0x3a, 0x30, 0xdb, 0xd6, 0x91, - 0xd1, 0x31, 0x3b, 0x46, 0xe7, 0xbb, 0xce, 0xc8, 0x3c, 0xb0, 0x0e, 0x0c, 0xdb, 0xec, 0x40, 0xa1, 0xd9, 0x31, 0x3b, - 0x37, 0xe6, 0x41, 0x67, 0x64, 0x63, 0x69, 0xcb, 0x3a, 0x3c, 0x34, 0x1d, 0xdb, 0x3a, 0x3c, 0x34, 0x0e, 0xad, 0xa3, - 0x23, 0xd3, 0x69, 0x5b, 0x47, 0x47, 0xe7, 0x87, 0x1d, 0xab, 0x0d, 0xef, 0xda, 0xed, 0x51, 0xdb, 0x72, 0x1c, 0x13, - 0xfe, 0x32, 0x3a, 0x56, 0x8b, 0x7e, 0x38, 0x8e, 0xd5, 0x76, 0x0c, 0x3b, 0x38, 0x6c, 0x59, 0x47, 0x2f, 0x0c, 0xfc, - 0x1b, 0xab, 0x19, 0xf8, 0x17, 0x74, 0x63, 0xbc, 0xb0, 0x5a, 0x47, 0xf4, 0x0b, 0x3b, 0xbc, 0x39, 0xe8, 0xfc, 0x55, - 0xdd, 0x6f, 0x1c, 0x83, 0x43, 0x63, 0xe8, 0x1c, 0x5a, 0xed, 0xb6, 0x71, 0xe0, 0x58, 0x9d, 0xf6, 0xcc, 0x3c, 0x68, - 0x59, 0x47, 0xc7, 0x23, 0xd3, 0xb1, 0x8e, 0x8f, 0x0d, 0xdb, 0x6c, 0x5b, 0x2d, 0xc3, 0xb1, 0x0e, 0xda, 0xf8, 0xa3, - 0x6d, 0xb5, 0x6e, 0x8e, 0x5f, 0x58, 0x47, 0x87, 0xb3, 0x23, 0xeb, 0xe0, 0xc3, 0x41, 0xc7, 0x6a, 0xb5, 0x67, 0xed, - 0x23, 0xab, 0x75, 0x7c, 0x73, 0x64, 0x1d, 0xcc, 0xcc, 0xd6, 0xd1, 0xd6, 0x96, 0x4e, 0xcb, 0x82, 0x39, 0xc2, 0xd7, - 0xf0, 0xc2, 0xe0, 0x2f, 0xe0, 0xcf, 0x0c, 0xdb, 0xfe, 0x81, 0xdd, 0x24, 0x9b, 0x4d, 0x5f, 0x58, 0x9d, 0xe3, 0x11, - 0x55, 0x87, 0x02, 0x53, 0xd4, 0x80, 0x26, 0x37, 0x26, 0x7d, 0x16, 0xbb, 0x33, 0x45, 0x47, 0xe2, 0x0f, 0xff, 0xd8, - 0x8d, 0x09, 0x1f, 0xa6, 0xef, 0xfe, 0x5b, 0xfb, 0xc9, 0x97, 0xfc, 0x64, 0x7f, 0x4a, 0x5b, 0x7f, 0xda, 0xff, 0xea, - 0x04, 0x0e, 0x77, 0x7f, 0x60, 0xfc, 0xda, 0xa4, 0x94, 0xfc, 0xfb, 0xfd, 0x4a, 0xc9, 0x97, 0xcb, 0x5d, 0x94, 0x92, - 0x7f, 0xff, 0xe2, 0x4a, 0xc9, 0x5f, 0xab, 0xbe, 0x35, 0x6f, 0xaa, 0x59, 0xa8, 0x7f, 0x58, 0x57, 0x45, 0x0e, 0x89, - 0xa7, 0x5d, 0xfe, 0xb4, 0xbc, 0x82, 0xf8, 0xf1, 0x6f, 0x22, 0xf7, 0xe5, 0xb2, 0x64, 0xf0, 0x19, 0x01, 0x8e, 0x7d, - 0x13, 0x11, 0x8e, 0xfd, 0xb0, 0x74, 0xc1, 0xca, 0x8c, 0xb3, 0x39, 0xfe, 0xd8, 0x9c, 0x79, 0xc1, 0x24, 0x67, 0x91, - 0xa0, 0xa4, 0x87, 0xc5, 0xe0, 0x37, 0x0f, 0xe4, 0x19, 0x6e, 0x32, 0xcb, 0x79, 0x98, 0x80, 0x45, 0x30, 0x58, 0x72, - 0x4c, 0xe2, 0xac, 0xd2, 0xd8, 0x12, 0x11, 0xf7, 0xaf, 0xb9, 0x47, 0x71, 0xe3, 0x7b, 0x34, 0x00, 0xae, 0xef, 0xdd, - 0xd9, 0xec, 0x57, 0x01, 0xcb, 0x3a, 0x61, 0x20, 0x0d, 0xdc, 0x7e, 0xdd, 0xfb, 0xb2, 0x19, 0x6e, 0xc5, 0xf0, 0xba, - 0x19, 0x52, 0x80, 0xa4, 0xda, 0xde, 0x29, 0x9b, 0xf1, 0xde, 0x37, 0xcc, 0x9a, 0xcf, 0x97, 0x9a, 0x6f, 0xb1, 0x21, - 0xce, 0x3b, 0xae, 0x4e, 0xd5, 0xba, 0xc4, 0xa7, 0xd5, 0x4f, 0x48, 0x71, 0x41, 0x2d, 0x0c, 0x8d, 0x0b, 0x4e, 0xd5, - 0x56, 0x90, 0xdf, 0xb1, 0xa5, 0x77, 0xa5, 0x3e, 0x65, 0xe3, 0xe4, 0x67, 0x6b, 0xbc, 0x57, 0xf8, 0xbf, 0x02, 0x27, - 0xca, 0x39, 0x9e, 0x61, 0x24, 0xcf, 0xf3, 0x5a, 0xea, 0x97, 0xa4, 0x11, 0xd9, 0xcc, 0x59, 0x6f, 0xf2, 0xa2, 0x8d, - 0x6e, 0x09, 0x0e, 0x9b, 0x0b, 0x2e, 0x08, 0x3f, 0x4f, 0x4e, 0x00, 0x19, 0x39, 0x6a, 0xa0, 0x9f, 0xc3, 0xb6, 0xce, - 0x44, 0xbd, 0x47, 0xb0, 0x89, 0xb9, 0x27, 0xa0, 0x22, 0x87, 0x34, 0x5d, 0x4f, 0x82, 0xc8, 0x4b, 0xbb, 0xc8, 0xa6, - 0x49, 0x2c, 0x6f, 0x0b, 0x3d, 0x16, 0x7a, 0x5b, 0x8c, 0xe9, 0xe4, 0x8e, 0x79, 0x27, 0xe8, 0xf9, 0xb0, 0xcd, 0xfe, - 0x2e, 0x77, 0x38, 0x5b, 0x97, 0xcc, 0x51, 0x9c, 0xc3, 0x63, 0xc3, 0x39, 0x32, 0xac, 0xe3, 0x43, 0x3d, 0x13, 0x07, - 0x4e, 0xee, 0xb2, 0x34, 0x21, 0xe0, 0x00, 0x91, 0x83, 0xe9, 0x87, 0x7e, 0xea, 0x7b, 0x41, 0x06, 0xfc, 0x70, 0xf9, - 0x92, 0xf2, 0xf7, 0x65, 0x92, 0xc2, 0x18, 0x05, 0xd3, 0x8b, 0xce, 0x1f, 0xe6, 0x90, 0xa5, 0x2b, 0xc6, 0xc2, 0x06, - 0xc3, 0x98, 0xaa, 0x2f, 0xc9, 0xef, 0x67, 0x59, 0x9f, 0x91, 0xd5, 0xda, 0x30, 0x0d, 0xf9, 0xfe, 0x10, 0x8e, 0x0f, - 0xd9, 0xc0, 0xf8, 0xae, 0x09, 0xe1, 0xfe, 0x72, 0x3f, 0xc2, 0x4d, 0xd9, 0x2e, 0x08, 0xf7, 0x97, 0x2f, 0x8e, 0x70, - 0xbf, 0x93, 0x11, 0x6e, 0xc9, 0x7f, 0xb0, 0xd0, 0x30, 0xbd, 0xc7, 0x67, 0x0d, 0x5c, 0x64, 0x9f, 0xab, 0xfb, 0xc4, - 0xc0, 0xab, 0x7a, 0x91, 0xbd, 0xf6, 0x2f, 0x4b, 0xd9, 0x82, 0x1a, 0x05, 0xa0, 0x98, 0xd7, 0xd1, 0x47, 0xd7, 0x65, - 0x1f, 0x5c, 0xdd, 0x44, 0x18, 0x06, 0xe8, 0xf3, 0xfb, 0x30, 0x0d, 0xac, 0x77, 0xfc, 0x1e, 0x09, 0x0a, 0xdd, 0x37, - 0x51, 0x3c, 0xf7, 0x30, 0xc5, 0x88, 0xaa, 0x83, 0x3b, 0x1d, 0x3c, 0xd8, 0x10, 0x08, 0x64, 0x14, 0x85, 0xe3, 0x5c, - 0x2b, 0xc9, 0xdc, 0x4b, 0xe2, 0xb8, 0xd5, 0x3b, 0xe6, 0xc5, 0xaa, 0x41, 0xaf, 0x61, 0x71, 0x9f, 0xb5, 0xed, 0x67, - 0xad, 0x83, 0x67, 0x47, 0x36, 0xfc, 0xef, 0xb0, 0x76, 0x66, 0xf0, 0x8a, 0xf3, 0x28, 0x4c, 0x67, 0x45, 0xcd, 0xa6, - 0x6a, 0x2b, 0xc6, 0x3e, 0x16, 0xb5, 0x8e, 0xeb, 0x2b, 0x8d, 0xbd, 0xbb, 0xa2, 0x4e, 0x6d, 0x8d, 0x59, 0xb4, 0x94, - 0xc0, 0xaa, 0x81, 0xc6, 0x0f, 0x97, 0x20, 0x67, 0x97, 0x6a, 0xc8, 0xaf, 0xf9, 0x70, 0x8b, 0x71, 0xb1, 0x76, 0x76, - 0x25, 0x72, 0x28, 0xa8, 0x3d, 0x91, 0x56, 0xef, 0xde, 0x19, 0xe4, 0x2a, 0x4a, 0x1b, 0x73, 0x4e, 0x61, 0x66, 0x43, - 0xc8, 0x38, 0xc5, 0xc4, 0x02, 0x79, 0xb4, 0x40, 0x69, 0xbc, 0x0c, 0x47, 0x1a, 0xfe, 0xf4, 0x86, 0x89, 0xe6, 0xef, - 0xc7, 0x16, 0xff, 0xb0, 0x8e, 0xab, 0xe6, 0xf5, 0xed, 0x22, 0xe9, 0x7c, 0x22, 0x56, 0xc5, 0x7b, 0x96, 0x1a, 0x31, - 0xea, 0xb1, 0x69, 0x69, 0x4d, 0xd7, 0x7b, 0x96, 0x37, 0x7c, 0x96, 0x1a, 0xe1, 0x73, 0xd0, 0x7d, 0xba, 0xf6, 0x93, - 0x27, 0x54, 0x6b, 0xcf, 0x15, 0xc3, 0x3a, 0x1d, 0x15, 0x99, 0x29, 0x14, 0x6f, 0x1a, 0x51, 0x72, 0x8a, 0xee, 0xc8, - 0x88, 0x9e, 0x3f, 0xef, 0xbb, 0x8e, 0x3e, 0x8c, 0x99, 0xf7, 0x31, 0x13, 0xe1, 0xbe, 0x43, 0xcc, 0x4f, 0x7b, 0xbe, - 0x9b, 0xa1, 0x91, 0x5e, 0xeb, 0x4a, 0xbb, 0x80, 0x3b, 0x93, 0x2d, 0xdc, 0x11, 0x38, 0xf6, 0x82, 0xdc, 0xf5, 0x64, - 0x50, 0xe0, 0x09, 0x83, 0x1f, 0x51, 0xe7, 0x7a, 0xe6, 0x25, 0x3f, 0x24, 0x51, 0xf8, 0xcb, 0x02, 0x82, 0x1f, 0x17, - 0x16, 0x45, 0xe2, 0x32, 0xd6, 0xb6, 0x6c, 0xcb, 0x56, 0xf3, 0xfe, 0x26, 0xfe, 0xd4, 0x5d, 0x47, 0xa9, 0xd7, 0xdd, - 0x73, 0x8c, 0x20, 0x9a, 0x82, 0x7b, 0x5d, 0xea, 0xa7, 0x01, 0xeb, 0xaa, 0x2a, 0xf8, 0xd9, 0xcd, 0xe9, 0xba, 0x9e, - 0x71, 0xa7, 0x07, 0x2f, 0x86, 0x6c, 0xe6, 0xf1, 0x9d, 0xf0, 0xd0, 0xc5, 0x18, 0xea, 0x3f, 0x02, 0x8d, 0xd4, 0x54, - 0x0d, 0x44, 0x06, 0x2c, 0x4e, 0x4c, 0xd9, 0x89, 0xa8, 0xab, 0x40, 0x1b, 0x5d, 0xe5, 0x63, 0x9b, 0xc4, 0xde, 0x1c, - 0xd2, 0xed, 0xae, 0x33, 0x83, 0x23, 0x60, 0x95, 0x63, 0x60, 0xc5, 0x79, 0x71, 0x64, 0x28, 0x2d, 0xc7, 0x50, 0x6c, - 0xc0, 0xc2, 0x6a, 0x66, 0xac, 0xb3, 0xab, 0xde, 0x7d, 0x76, 0x10, 0x84, 0x76, 0x1e, 0xd1, 0x38, 0xc8, 0x02, 0x82, - 0x6b, 0x98, 0x52, 0xca, 0x9d, 0xa3, 0x49, 0x89, 0x35, 0x7d, 0xd2, 0x85, 0x5e, 0xb0, 0xdb, 0x54, 0x07, 0x85, 0x92, - 0xa8, 0xe2, 0xeb, 0x6b, 0xf4, 0x23, 0xf6, 0x43, 0xc5, 0xff, 0xf4, 0x49, 0xf3, 0xc1, 0xc7, 0xc9, 0x95, 0xe6, 0x07, - 0x9e, 0xf5, 0xd2, 0x84, 0xf9, 0x85, 0xf6, 0x1e, 0x27, 0x0b, 0x1c, 0x10, 0xe1, 0xdf, 0xa2, 0x58, 0xfc, 0xe0, 0xd6, - 0x13, 0x56, 0xe0, 0x85, 0x53, 0xc0, 0x74, 0x5e, 0x38, 0xdd, 0xb0, 0xd2, 0x22, 0x57, 0xe8, 0x4a, 0x69, 0xd1, 0x55, - 0x61, 0x41, 0x95, 0xbc, 0xbc, 0xbb, 0xf0, 0xa6, 0x3f, 0x79, 0x73, 0xa6, 0xa9, 0x40, 0xfc, 0xd0, 0x73, 0xb7, 0x50, - 0xf0, 0x3e, 0x77, 0x9f, 0x9e, 0xcc, 0x59, 0xea, 0x91, 0x76, 0x08, 0xee, 0xc4, 0xc0, 0x25, 0x28, 0x9c, 0xfe, 0xf0, - 0x38, 0x18, 0x2e, 0xa5, 0xd8, 0x22, 0xf2, 0x61, 0x28, 0x9c, 0x7c, 0x99, 0x68, 0x08, 0xea, 0x3a, 0x06, 0xf9, 0x21, - 0x8c, 0x3c, 0x4c, 0xb3, 0xe3, 0x86, 0x91, 0xda, 0x7f, 0x9a, 0xbb, 0x6c, 0x36, 0x2d, 0x42, 0xe0, 0x87, 0x1f, 0x2f, - 0x63, 0x16, 0xfc, 0xc3, 0x7d, 0x0a, 0xf4, 0xfc, 0xe9, 0x95, 0xaa, 0xf7, 0x52, 0x6b, 0x16, 0xb3, 0x89, 0xfb, 0x14, - 0xee, 0xa9, 0x5d, 0xb4, 0x9a, 0x05, 0x66, 0xfe, 0xf9, 0xed, 0x3c, 0x30, 0xf0, 0xd6, 0x4f, 0xb0, 0xa8, 0xed, 0x56, - 0x11, 0xee, 0xbc, 0xbd, 0xd3, 0x5d, 0xbf, 0xcf, 0x2f, 0xf1, 0x70, 0x31, 0x5c, 0x97, 0xae, 0xde, 0x4e, 0x0f, 0xaf, - 0xd5, 0xc3, 0xc0, 0x1b, 0x7d, 0xec, 0xd1, 0x9b, 0xd2, 0x83, 0x09, 0x44, 0x7c, 0xe4, 0x2d, 0xba, 0x48, 0x75, 0xe5, - 0x42, 0x70, 0xaa, 0xa6, 0xd2, 0x9c, 0xe1, 0xab, 0xdd, 0xcb, 0xb8, 0x95, 0xd7, 0xf8, 0x65, 0xfc, 0xd4, 0x6a, 0xe6, - 0xa7, 0x4c, 0x7c, 0x0a, 0x1f, 0xb2, 0x4c, 0xdc, 0xdf, 0xe9, 0xe6, 0x8a, 0xf7, 0x6d, 0xab, 0xad, 0x38, 0x9d, 0xef, - 0x0e, 0x6f, 0x1c, 0x7b, 0xd6, 0x72, 0xac, 0xce, 0x07, 0xa7, 0x33, 0x6b, 0x5b, 0xc7, 0x81, 0xd9, 0xb6, 0x8e, 0xe1, - 0xcf, 0x87, 0x63, 0xab, 0x33, 0x33, 0x5b, 0xd6, 0xc1, 0x07, 0xa7, 0x15, 0x98, 0x1d, 0xeb, 0x18, 0xfe, 0x9c, 0x53, - 0x2b, 0xb8, 0x17, 0xd1, 0x35, 0xe8, 0x69, 0x09, 0x39, 0x48, 0xbf, 0x73, 0x55, 0xad, 0x51, 0xa2, 0x7a, 0x35, 0xea, - 0xde, 0x05, 0x06, 0x97, 0x10, 0x69, 0x6d, 0x30, 0xf4, 0x90, 0x16, 0xba, 0x8c, 0xd2, 0xcd, 0x0a, 0xc3, 0x37, 0xe1, - 0xa1, 0x5e, 0xe4, 0x3f, 0x95, 0x4e, 0x10, 0xaf, 0xdb, 0x4b, 0x68, 0xbb, 0x4b, 0x61, 0xe5, 0xb4, 0xca, 0xb1, 0x6b, - 0xc8, 0x7c, 0xac, 0x1b, 0xa0, 0x3a, 0x06, 0xc4, 0x54, 0x84, 0x83, 0xd2, 0x6a, 0xd1, 0x96, 0xc0, 0x66, 0x09, 0x4b, - 0xa9, 0x48, 0x13, 0x2d, 0x81, 0xd8, 0xe8, 0xba, 0x48, 0x58, 0x8c, 0x1d, 0xf3, 0x1a, 0x8c, 0x67, 0x56, 0xae, 0x7d, - 0xd5, 0xab, 0xe2, 0x4b, 0xf0, 0x8a, 0xb7, 0xc2, 0x68, 0x85, 0x56, 0x1f, 0xf7, 0xcd, 0x1d, 0xc6, 0x19, 0x60, 0xc2, - 0xe6, 0xac, 0x0c, 0x2c, 0x0f, 0x9d, 0x5d, 0xfd, 0xe0, 0x06, 0x82, 0x7e, 0xd0, 0x07, 0xa5, 0x44, 0xdd, 0x9f, 0xd5, - 0x0f, 0x8f, 0x62, 0x21, 0x27, 0xcb, 0x1c, 0xfb, 0x71, 0x0e, 0x9e, 0x44, 0x51, 0x9c, 0xfa, 0x4c, 0xa5, 0xba, 0x41, - 0xb1, 0x9c, 0x58, 0x7c, 0xe3, 0x05, 0x92, 0xdd, 0x9d, 0xd4, 0x72, 0x2f, 0x27, 0x54, 0x4f, 0x9e, 0x14, 0xc0, 0x99, - 0x15, 0xb8, 0x4f, 0x9c, 0x43, 0xe0, 0x12, 0x0e, 0x59, 0x7b, 0xab, 0x09, 0x28, 0x5d, 0xcc, 0xb6, 0xb9, 0x82, 0x17, - 0x89, 0x99, 0x84, 0x99, 0x97, 0x30, 0x30, 0x69, 0xb4, 0x43, 0xdd, 0x30, 0x2f, 0x81, 0xcc, 0x76, 0x95, 0x9b, 0x99, - 0xaa, 0xf7, 0x42, 0x61, 0x2d, 0x11, 0x6e, 0xc9, 0x49, 0xc7, 0xaf, 0x8e, 0x2a, 0x4c, 0xcd, 0x96, 0x71, 0xdc, 0xe3, - 0xcf, 0xfe, 0xef, 0x1e, 0x04, 0xfa, 0x96, 0x82, 0x79, 0x47, 0x05, 0x8b, 0x94, 0x7c, 0x0d, 0x73, 0x7a, 0x4f, 0x84, - 0x9e, 0x25, 0xa7, 0x2a, 0x14, 0xa9, 0x5d, 0x15, 0xfd, 0xd8, 0xd4, 0xdc, 0xb6, 0x35, 0xa7, 0x62, 0x45, 0x81, 0xe1, - 0x63, 0xfe, 0x4f, 0xe1, 0xe7, 0xaa, 0x3f, 0x79, 0xd2, 0x48, 0x1c, 0xc9, 0x96, 0x28, 0x61, 0xa9, 0xb8, 0x4f, 0x68, - 0xaa, 0x8c, 0x77, 0x55, 0x19, 0xf5, 0xe5, 0xfd, 0x22, 0x36, 0x13, 0x26, 0xb9, 0xb4, 0xf7, 0xf0, 0xe7, 0x90, 0x79, - 0xa9, 0xc5, 0x75, 0xbb, 0x9a, 0xc4, 0x74, 0x18, 0x80, 0x36, 0x32, 0x42, 0x21, 0xf9, 0x30, 0x07, 0x8f, 0xd7, 0x7f, - 0x59, 0xf2, 0x20, 0x14, 0xd0, 0xc7, 0xa7, 0x4f, 0x76, 0x11, 0x37, 0xf4, 0x6d, 0xea, 0x51, 0xdc, 0x36, 0x99, 0x17, - 0x88, 0x52, 0x8f, 0xec, 0x4f, 0x7c, 0x0c, 0xb5, 0x53, 0x1f, 0x41, 0x4c, 0x8a, 0x54, 0xd1, 0x7f, 0x7b, 0xf1, 0x8d, - 0xc2, 0x0f, 0x00, 0x59, 0x37, 0xe0, 0xc5, 0x8b, 0xe2, 0xe3, 0xb8, 0x14, 0x1f, 0x47, 0xe1, 0x99, 0x97, 0x21, 0x47, - 0x6c, 0xb6, 0x4f, 0x53, 0x88, 0x02, 0x73, 0xb2, 0xf9, 0x98, 0x2f, 0x83, 0xd4, 0x5f, 0x78, 0x71, 0xba, 0x8f, 0xc1, - 0x71, 0x30, 0xd8, 0x4e, 0x53, 0xfc, 0x0a, 0x32, 0x1b, 0x11, 0xd9, 0x4c, 0xd2, 0x50, 0xd8, 0x8d, 0x4c, 0xfc, 0x20, - 0x37, 0x1b, 0x11, 0x1f, 0xf0, 0x46, 0x23, 0xb6, 0x48, 0xdd, 0x52, 0x10, 0x9e, 0x68, 0x94, 0xb2, 0xd4, 0x4c, 0xd2, - 0x98, 0x79, 0x73, 0x35, 0x0f, 0xca, 0xb5, 0xd9, 0x5f, 0xb2, 0x1c, 0x42, 0x54, 0x21, 0x11, 0x1e, 0x8c, 0x06, 0x08, - 0x06, 0x1c, 0x00, 0x22, 0x04, 0xc5, 0xa1, 0x29, 0x3c, 0x8f, 0xa6, 0x95, 0x2d, 0x55, 0xb0, 0x54, 0xa7, 0x98, 0xd4, - 0x8c, 0x6e, 0x5e, 0x20, 0xdd, 0x1e, 0x45, 0xc1, 0x35, 0x8f, 0xb9, 0x91, 0x67, 0xc7, 0x51, 0xfb, 0x27, 0xfc, 0x3a, - 0xae, 0x60, 0xb8, 0x19, 0xf5, 0xd0, 0x86, 0xb4, 0x6d, 0x4d, 0xd1, 0x38, 0xf6, 0x79, 0x65, 0xa0, 0x99, 0xd4, 0x33, - 0x66, 0xde, 0x24, 0x58, 0x2e, 0x80, 0x64, 0x95, 0x0c, 0x7c, 0x66, 0x4e, 0x3f, 0x77, 0xff, 0x44, 0xa8, 0x90, 0xaa, - 0x7d, 0xfa, 0xf4, 0x7e, 0xf0, 0xaf, 0x7f, 0x42, 0x7a, 0xd0, 0x99, 0x23, 0x62, 0x60, 0x5c, 0xca, 0xb5, 0x38, 0x5b, - 0x6c, 0x0c, 0xd0, 0xb8, 0x8b, 0x8d, 0x45, 0x74, 0x42, 0xb1, 0xb7, 0xb2, 0xc1, 0x95, 0x88, 0xab, 0x07, 0x89, 0x85, - 0x75, 0x11, 0xa9, 0x63, 0x00, 0xcb, 0x3b, 0x10, 0x31, 0x5c, 0x94, 0xbf, 0xdd, 0xbe, 0x3c, 0x56, 0x8a, 0x70, 0x8f, - 0x75, 0x16, 0x48, 0xb4, 0x87, 0xfa, 0x27, 0x9e, 0x82, 0xdc, 0x14, 0xf2, 0x45, 0x49, 0x77, 0x1f, 0x86, 0x39, 0x8b, - 0xe6, 0xcc, 0xf2, 0xa3, 0xfd, 0x15, 0x1b, 0x9a, 0xde, 0xc2, 0x27, 0x3b, 0x22, 0x94, 0x13, 0x2a, 0xc4, 0x92, 0xe6, - 0xe6, 0x39, 0xc4, 0xf8, 0x67, 0xc5, 0x54, 0x46, 0x95, 0xc0, 0x6d, 0xad, 0x42, 0x6f, 0x79, 0xc0, 0x83, 0xa2, 0x89, - 0x9a, 0xfd, 0x93, 0x7d, 0xaf, 0x5f, 0xce, 0x94, 0x63, 0x89, 0x8c, 0xaf, 0x65, 0x2a, 0x70, 0x4a, 0x09, 0x6f, 0x44, - 0x6e, 0x9b, 0xe2, 0xc1, 0x8c, 0x26, 0x13, 0x39, 0xbb, 0x8d, 0x55, 0x06, 0x2f, 0x9f, 0xb4, 0x62, 0x4b, 0x47, 0x0b, - 0xfa, 0xd2, 0xe6, 0x27, 0xf2, 0x9f, 0x6a, 0x17, 0xd3, 0x5a, 0xc1, 0x98, 0xe1, 0xbc, 0x6f, 0x64, 0xc9, 0xc9, 0x67, - 0xec, 0x11, 0x55, 0xe2, 0x88, 0xa4, 0x9a, 0x93, 0xb1, 0x81, 0xa5, 0xda, 0x73, 0x5d, 0xc2, 0x73, 0x55, 0x74, 0x07, - 0x93, 0x58, 0x93, 0xfd, 0x16, 0x06, 0x9b, 0x42, 0x43, 0x93, 0xdc, 0x7b, 0xb1, 0x51, 0x75, 0x38, 0x9b, 0x30, 0xee, - 0x7b, 0x62, 0xfb, 0x95, 0x36, 0x28, 0x6c, 0x3c, 0xbe, 0xee, 0x80, 0xe0, 0x45, 0x3f, 0x15, 0x3c, 0xaf, 0x7c, 0x4d, - 0x28, 0xdd, 0x0c, 0xbc, 0xbb, 0x48, 0x32, 0xbb, 0xe2, 0x11, 0x58, 0xce, 0xb1, 0xf4, 0x42, 0x78, 0x3e, 0x6f, 0x1c, - 0x34, 0xa4, 0x61, 0x90, 0x25, 0x74, 0xf3, 0xb0, 0x15, 0x04, 0x38, 0x60, 0xf7, 0x9d, 0x35, 0xb9, 0x6e, 0x79, 0x30, - 0x88, 0x3c, 0xb3, 0xe2, 0x1c, 0x96, 0x5e, 0x22, 0x5a, 0xc8, 0x4e, 0xf6, 0x61, 0x7c, 0x94, 0xf7, 0x50, 0x30, 0x79, - 0xc2, 0xbe, 0x10, 0x6f, 0xbd, 0x7e, 0xd3, 0xad, 0xb7, 0xca, 0xa3, 0x94, 0x59, 0x2f, 0x5f, 0x87, 0xd8, 0x36, 0x5e, - 0x42, 0xb6, 0x67, 0xdf, 0x8f, 0x39, 0x61, 0x90, 0xbe, 0x92, 0x07, 0xc3, 0x2c, 0xd5, 0xd3, 0xb7, 0x06, 0x89, 0xa1, - 0x8c, 0xbb, 0x1f, 0x96, 0x98, 0x6e, 0x37, 0xeb, 0xa5, 0x4c, 0xc4, 0x6c, 0x38, 0x4f, 0x1b, 0xc2, 0x3a, 0x34, 0x55, - 0x21, 0x3e, 0x7c, 0x4b, 0x85, 0x62, 0x9b, 0x6f, 0xab, 0x55, 0x70, 0x56, 0x45, 0x35, 0x4f, 0x53, 0x1f, 0xe1, 0x81, - 0xd8, 0xa8, 0x8d, 0xa5, 0x18, 0x6c, 0x22, 0x75, 0xa1, 0xaa, 0x50, 0x2d, 0x78, 0x8b, 0x05, 0x55, 0xd6, 0x7b, 0x27, - 0xfb, 0x74, 0x9d, 0xee, 0xd3, 0x06, 0xec, 0x9f, 0x80, 0x65, 0x3a, 0xed, 0x09, 0x6f, 0xb1, 0xe0, 0x2b, 0x4e, 0xbf, - 0xe8, 0xcd, 0xfe, 0x2c, 0x9d, 0x07, 0xfd, 0xff, 0x05, 0x64, 0x23, 0xa6, 0xdb, 0x06, 0x7b, 0x03, 0x00}; + 0x88, 0xa6, 0xd7, 0x57, 0x61, 0xb7, 0x6c, 0xac, 0xd5, 0x01, 0x46, 0x82, 0x27, 0x31, 0x04, 0x0c, 0xcc, 0x8c, 0xa8, + 0xff, 0xdb, 0xb8, 0x8a, 0xfa, 0xd1, 0xfe, 0x2e, 0x47, 0xfe, 0x3f, 0xbf, 0x7d, 0x7f, 0x0e, 0x7a, 0x2d, 0x0f, 0x15, + 0xd1, 0x6b, 0x95, 0xdb, 0xb0, 0x98, 0xa0, 0x29, 0x52, 0x7b, 0xaa, 0xb7, 0x04, 0xea, 0x8c, 0x37, 0x86, 0xfd, 0x5b, + 0xf3, 0xe6, 0xe6, 0xc6, 0x04, 0x8b, 0x56, 0x73, 0x15, 0x07, 0xc4, 0x1d, 0x4e, 0xd4, 0x4c, 0x20, 0x75, 0x56, 0x41, + 0xea, 0x10, 0x0e, 0x97, 0xe7, 0x53, 0x79, 0x3f, 0x8f, 0x6e, 0xbe, 0x09, 0x02, 0x59, 0x6c, 0x23, 0x98, 0x38, 0x2e, + 0xc9, 0x28, 0x21, 0x03, 0x0d, 0xb4, 0x4f, 0x96, 0x9f, 0x5c, 0x71, 0x7b, 0x81, 0xc9, 0xd5, 0xe8, 0xee, 0x8a, 0xeb, + 0x24, 0xf2, 0x78, 0xc4, 0xef, 0x87, 0xc7, 0x13, 0xff, 0x5a, 0x41, 0x4e, 0xd3, 0x55, 0xc1, 0x99, 0x2b, 0x60, 0xa3, + 0x55, 0x9a, 0x46, 0xa1, 0x19, 0x47, 0x37, 0xea, 0xe0, 0x98, 0x1e, 0x44, 0x05, 0x8f, 0x1e, 0x55, 0xe5, 0xeb, 0x71, + 0xe0, 0x8f, 0x3f, 0xba, 0xea, 0xe3, 0xb5, 0xef, 0x0e, 0x2a, 0xfc, 0xa4, 0x9d, 0xa9, 0x03, 0x80, 0x55, 0xf9, 0x26, + 0x08, 0x8e, 0xf7, 0xe9, 0x8b, 0xc1, 0xf1, 0xfe, 0xc4, 0xbf, 0x1e, 0x48, 0xa9, 0x61, 0xb8, 0xde, 0xd4, 0xe5, 0x21, + 0x38, 0x73, 0x4b, 0xb3, 0x04, 0x63, 0x3a, 0x8c, 0x99, 0x56, 0x5c, 0x7e, 0x21, 0xd6, 0x0c, 0xc1, 0xab, 0x8d, 0x51, + 0x9c, 0x1e, 0xc0, 0x55, 0xef, 0xd3, 0x27, 0x2d, 0xb7, 0x43, 0x9d, 0x4b, 0x41, 0xda, 0x50, 0xcd, 0x87, 0x55, 0x0c, + 0x8c, 0x34, 0xa3, 0x6b, 0x22, 0x94, 0x5c, 0xa0, 0x1b, 0xe3, 0xcc, 0xc0, 0x0c, 0x3b, 0xde, 0x12, 0x34, 0x8e, 0xfc, + 0xa7, 0x74, 0x23, 0x1e, 0x43, 0x56, 0x6d, 0x09, 0x89, 0xeb, 0x92, 0xce, 0x85, 0x4e, 0x21, 0x8f, 0x13, 0x08, 0xca, + 0x12, 0xec, 0x87, 0xf4, 0x20, 0x5a, 0xa0, 0x43, 0x56, 0xb7, 0x3c, 0x38, 0x8f, 0x97, 0x89, 0x3c, 0x6a, 0x62, 0x5e, + 0x4e, 0x4a, 0x2b, 0xd4, 0xab, 0xae, 0x97, 0x88, 0x1a, 0xb9, 0x97, 0x34, 0x2d, 0x19, 0xe8, 0xf0, 0xb4, 0xd4, 0xa8, + 0xd0, 0x5c, 0xf0, 0xea, 0x93, 0x14, 0x47, 0xcc, 0xd0, 0x2e, 0x12, 0x23, 0xba, 0x2c, 0xe8, 0x54, 0x42, 0x88, 0xb2, + 0x17, 0x65, 0x45, 0x00, 0x67, 0x5a, 0xf5, 0xc1, 0xe3, 0x75, 0x88, 0x84, 0x2d, 0x71, 0x07, 0xe5, 0x7d, 0x90, 0x7a, + 0x23, 0x93, 0x36, 0xb3, 0xaa, 0x7c, 0x3d, 0x19, 0x05, 0xf9, 0x62, 0xd3, 0x21, 0x98, 0x7b, 0xe1, 0x24, 0x60, 0xe7, + 0xde, 0xe8, 0x3b, 0xac, 0xf3, 0x7a, 0x14, 0xbc, 0x82, 0x0a, 0x99, 0x3a, 0x78, 0xbc, 0x26, 0xd2, 0x5d, 0x87, 0xb0, + 0x33, 0xda, 0x02, 0xd5, 0x7e, 0x78, 0xca, 0x25, 0x16, 0xd3, 0xd7, 0x08, 0x2c, 0x91, 0x5b, 0x8a, 0x63, 0x5b, 0x86, + 0x8c, 0xa7, 0xfc, 0x81, 0xbd, 0xa9, 0xf0, 0x53, 0x0b, 0x70, 0x45, 0xe2, 0x04, 0xcb, 0x3b, 0x53, 0x06, 0x96, 0xc8, + 0xea, 0xbb, 0xe8, 0x46, 0x40, 0xca, 0x27, 0x80, 0x42, 0x54, 0x9e, 0xbc, 0x1f, 0x1e, 0xcb, 0x6a, 0x21, 0x94, 0x9d, + 0x53, 0xbb, 0xf0, 0x2b, 0x53, 0x95, 0x22, 0x01, 0xd4, 0xf2, 0x56, 0x1d, 0x1c, 0xef, 0xcb, 0xb5, 0x07, 0xc3, 0xde, + 0xa9, 0x34, 0x38, 0x6c, 0x55, 0xdc, 0x9b, 0x2f, 0x8a, 0x87, 0xec, 0x52, 0x81, 0x5b, 0x72, 0x06, 0x25, 0x30, 0x47, + 0xe5, 0x4f, 0x36, 0xc8, 0x0f, 0xa4, 0x4c, 0x2c, 0x08, 0x14, 0xed, 0x1e, 0x81, 0x1f, 0x23, 0xbd, 0x97, 0x2f, 0x21, + 0x59, 0x66, 0x8a, 0xd6, 0x86, 0xfc, 0xdf, 0x62, 0x4a, 0x50, 0xd2, 0xcd, 0xc2, 0x24, 0x8a, 0x55, 0x18, 0x66, 0x35, + 0x6f, 0x92, 0x22, 0xe5, 0x6b, 0xc3, 0x01, 0xd7, 0x92, 0x55, 0x98, 0xb0, 0xfd, 0xea, 0xa7, 0xd2, 0xb8, 0x87, 0x7a, + 0xf1, 0x43, 0xe1, 0x83, 0xa9, 0x20, 0xad, 0x1c, 0xc0, 0xe6, 0x7c, 0x54, 0x17, 0x8f, 0x7d, 0xe3, 0x2f, 0x91, 0x31, + 0xf2, 0x8c, 0x2b, 0xcf, 0xf8, 0x31, 0xbc, 0xcc, 0x6a, 0x17, 0x2f, 0xcf, 0x25, 0x67, 0xb0, 0xbe, 0x06, 0x11, 0x98, + 0xca, 0x97, 0x0a, 0xdf, 0xe2, 0x36, 0x23, 0xe7, 0x5e, 0x3c, 0x63, 0x22, 0x85, 0x9b, 0x78, 0x2b, 0x64, 0x07, 0xba, + 0x34, 0x2d, 0x10, 0x9e, 0x6c, 0x8f, 0x9b, 0xd6, 0xf9, 0xd6, 0x38, 0x8d, 0x83, 0x3f, 0xb3, 0x3b, 0x60, 0xb3, 0x92, + 0x34, 0x5a, 0x82, 0xcc, 0xca, 0x9b, 0x71, 0x1d, 0x84, 0xa1, 0xb1, 0xdd, 0xba, 0xfb, 0xf4, 0x89, 0x49, 0x59, 0xc5, + 0xd2, 0x68, 0x36, 0x0b, 0x98, 0x26, 0x65, 0x1f, 0xcb, 0xbb, 0x39, 0xd9, 0xb3, 0x45, 0xe4, 0x6a, 0x3d, 0x6b, 0x3a, + 0x58, 0x62, 0xc4, 0x2c, 0xe7, 0x06, 0x01, 0x71, 0x91, 0x71, 0x15, 0x32, 0xe4, 0x9a, 0x38, 0x17, 0xc5, 0xc1, 0x35, + 0x27, 0xd1, 0x6a, 0x14, 0x30, 0x13, 0x4f, 0x03, 0x74, 0xb9, 0x1e, 0xad, 0x46, 0xa3, 0x80, 0xd2, 0x85, 0x41, 0xfc, + 0xb5, 0x28, 0x41, 0xb9, 0x68, 0xa6, 0xf7, 0x61, 0x50, 0x56, 0x5a, 0x05, 0x1f, 0x6c, 0x26, 0xe1, 0xe6, 0x40, 0x1d, + 0xa4, 0x20, 0x03, 0xdd, 0x3c, 0xd3, 0xae, 0x0a, 0x37, 0x16, 0x96, 0xa8, 0xfd, 0x1a, 0x96, 0xce, 0xbd, 0x50, 0xdf, + 0xe3, 0x0c, 0x2b, 0x5e, 0x38, 0x51, 0x5e, 0xd1, 0xde, 0x55, 0x0d, 0x95, 0x4c, 0xbf, 0x78, 0x76, 0x39, 0xd5, 0x50, + 0x5f, 0xfb, 0xde, 0x2c, 0x8c, 0x92, 0xd4, 0x1f, 0xab, 0x97, 0xfd, 0xd7, 0xbe, 0x76, 0xb1, 0x48, 0x35, 0xfd, 0xd2, + 0xf8, 0x56, 0xce, 0x03, 0x26, 0x30, 0x25, 0xa6, 0x01, 0x6b, 0xa8, 0x23, 0x9f, 0x9e, 0x6d, 0xf5, 0x04, 0x46, 0xc6, + 0x3a, 0xdf, 0xba, 0x50, 0xab, 0x92, 0x51, 0x0c, 0x53, 0x45, 0x42, 0x46, 0xb1, 0x6f, 0xf5, 0x3e, 0x09, 0x61, 0xbe, + 0x59, 0xad, 0x91, 0x69, 0x48, 0x0b, 0xe2, 0x8b, 0x41, 0xf0, 0x85, 0xe7, 0x28, 0x3d, 0xef, 0xc9, 0x5e, 0x0f, 0x25, + 0x32, 0x3e, 0xfc, 0xa6, 0xcc, 0x81, 0x3c, 0x5e, 0xa7, 0x19, 0x98, 0x1c, 0x86, 0x51, 0xaa, 0x40, 0x64, 0x37, 0xe8, + 0x70, 0x58, 0xb5, 0x92, 0xe6, 0xad, 0x6a, 0x7a, 0xc6, 0xb1, 0xc0, 0x4b, 0xa4, 0xa5, 0x28, 0xb9, 0x84, 0x40, 0x14, + 0x10, 0xa4, 0xb4, 0x14, 0xc7, 0x89, 0xfb, 0xe6, 0xc1, 0xf2, 0x95, 0xf8, 0x37, 0x09, 0xef, 0x97, 0xe9, 0xf9, 0xe3, + 0x75, 0x72, 0x22, 0x88, 0xfa, 0xf7, 0x09, 0xae, 0x25, 0xb0, 0x2b, 0x9c, 0xca, 0x67, 0xaa, 0x72, 0x22, 0x28, 0x11, + 0xd6, 0x2d, 0xa1, 0x57, 0x4d, 0xb0, 0xbb, 0xb1, 0x88, 0x99, 0xcf, 0xc5, 0x28, 0x82, 0x01, 0xab, 0x1c, 0x3d, 0x08, + 0xd6, 0x94, 0xf3, 0x56, 0x29, 0x58, 0x5c, 0x23, 0xc1, 0x00, 0xcc, 0xc5, 0x79, 0x84, 0x61, 0x76, 0x05, 0x8c, 0x24, + 0x44, 0x30, 0x13, 0x63, 0x34, 0x22, 0x39, 0x89, 0x9c, 0x1f, 0x2e, 0x57, 0x29, 0x46, 0xa6, 0x07, 0x00, 0x58, 0xa6, + 0x2a, 0x78, 0x61, 0x04, 0x5c, 0x5f, 0x5c, 0x78, 0x32, 0x55, 0xf1, 0x27, 0x9b, 0x65, 0x5c, 0x3a, 0x03, 0x38, 0x0e, + 0x87, 0x81, 0x7a, 0x1d, 0x78, 0x8c, 0xf9, 0x30, 0xc6, 0x46, 0x91, 0xd6, 0x45, 0x1b, 0xa3, 0xfd, 0x43, 0x0d, 0x02, + 0x19, 0x53, 0x3b, 0x7d, 0x2d, 0xa8, 0x1d, 0x2c, 0x44, 0xab, 0x2e, 0x0d, 0x73, 0x08, 0x32, 0xca, 0x13, 0x98, 0x3b, + 0x17, 0x2e, 0xf5, 0xc2, 0xb4, 0x4e, 0x3d, 0x57, 0xc9, 0xae, 0x6e, 0x88, 0xd3, 0x30, 0xcc, 0xae, 0x0a, 0x47, 0xd7, + 0x62, 0xbc, 0xb0, 0x25, 0xa9, 0x5c, 0x41, 0x4b, 0x37, 0x97, 0xdb, 0xb3, 0x2d, 0x63, 0x7f, 0xe1, 0xc5, 0x77, 0x64, + 0xfe, 0x66, 0xc8, 0x36, 0x72, 0xba, 0xaa, 0x10, 0x3d, 0xa0, 0x09, 0x20, 0xd2, 0xa0, 0x2a, 0x5f, 0xe7, 0x65, 0x8c, + 0x8f, 0x36, 0xb7, 0x01, 0x82, 0xbe, 0xae, 0xd4, 0xe7, 0xcc, 0x22, 0xf9, 0x23, 0x7d, 0xd2, 0xd7, 0x92, 0x86, 0xe1, + 0x25, 0xe5, 0xe1, 0x85, 0xe5, 0x8d, 0x86, 0x83, 0x21, 0x4a, 0x41, 0x70, 0xe3, 0xc8, 0x30, 0x09, 0x66, 0xfd, 0x8a, + 0xd2, 0xbb, 0x3f, 0x74, 0x39, 0x18, 0x2c, 0x47, 0x08, 0xcb, 0x51, 0x23, 0x9a, 0xf5, 0xc4, 0x8a, 0x00, 0x2f, 0x02, + 0x5c, 0x48, 0x8c, 0x1c, 0x08, 0xe5, 0xc7, 0x54, 0xf2, 0x2d, 0x14, 0xc3, 0xd1, 0x20, 0xd8, 0xe9, 0x68, 0xc4, 0xae, + 0x1b, 0xe1, 0x57, 0x71, 0x76, 0xbc, 0x4f, 0xb5, 0x89, 0x28, 0x52, 0x25, 0x98, 0x86, 0x18, 0x46, 0x58, 0xcc, 0x02, + 0x24, 0x08, 0x77, 0x9d, 0xe2, 0xa2, 0x63, 0x2d, 0x50, 0x2d, 0xed, 0x9c, 0x94, 0x19, 0x1e, 0xfc, 0x4a, 0x1d, 0x1c, + 0x63, 0xca, 0x4f, 0x20, 0xeb, 0x10, 0x14, 0xeb, 0x78, 0x9f, 0x1e, 0x95, 0xca, 0x89, 0x28, 0x1a, 0x11, 0x32, 0xc8, + 0x1e, 0x6f, 0xe0, 0x41, 0x47, 0x25, 0x49, 0xd9, 0x12, 0x4a, 0xbd, 0x4c, 0x55, 0x16, 0x9c, 0xc1, 0xe2, 0xd1, 0xf7, + 0x20, 0x34, 0x8f, 0x0d, 0x2e, 0x11, 0xaa, 0xb2, 0xf0, 0x6e, 0x71, 0xe4, 0xe2, 0x8d, 0x77, 0xab, 0x39, 0xfc, 0x55, + 0x71, 0xd6, 0x92, 0xf2, 0x59, 0x1b, 0x6f, 0xdc, 0x90, 0x03, 0xb8, 0x21, 0x8f, 0xeb, 0x17, 0x77, 0x2e, 0x16, 0x77, + 0xd2, 0xb0, 0xb8, 0x93, 0x2d, 0x8b, 0x1b, 0xf0, 0x85, 0x54, 0xf2, 0xa9, 0x8b, 0xd1, 0x97, 0x3a, 0x9f, 0x3c, 0xce, + 0x8f, 0xf4, 0xf8, 0x39, 0xc3, 0x79, 0x32, 0x93, 0x00, 0x6c, 0x89, 0x1b, 0xe6, 0xaa, 0x6e, 0x5e, 0xa4, 0x89, 0xd8, + 0x1c, 0x78, 0x7e, 0xea, 0xc4, 0xb8, 0x21, 0x85, 0xb7, 0x16, 0x54, 0xc7, 0x0b, 0xbb, 0x14, 0x3f, 0x34, 0xb4, 0x79, + 0xc3, 0x48, 0xe7, 0x5b, 0x46, 0x3a, 0x2e, 0x1d, 0x5d, 0x3e, 0x6c, 0x3a, 0x84, 0xf2, 0xa0, 0x60, 0x0f, 0x82, 0x7f, + 0x05, 0x6e, 0x99, 0xf2, 0x3e, 0x6c, 0xc6, 0xb1, 0xd2, 0x8e, 0x5a, 0x7a, 0x49, 0x72, 0x13, 0xc5, 0x60, 0xa0, 0x00, + 0xcd, 0x3c, 0x6c, 0x4b, 0x2d, 0xfc, 0x90, 0xc7, 0x3e, 0x6b, 0xdc, 0x4c, 0xc5, 0x7b, 0x79, 0x4b, 0xb5, 0x3a, 0x1d, + 0xaa, 0xb1, 0xf4, 0xd2, 0x94, 0xc5, 0x38, 0xe9, 0x1e, 0x24, 0xc9, 0xf8, 0x0f, 0xd9, 0x66, 0x35, 0x38, 0x24, 0x90, + 0xb0, 0x3a, 0x62, 0xe8, 0x25, 0xb0, 0x60, 0xa4, 0x91, 0x0c, 0xf5, 0xb5, 0x14, 0x47, 0x35, 0xce, 0x27, 0xfe, 0x27, + 0x3c, 0xae, 0x5a, 0x2c, 0x79, 0xfa, 0x3a, 0x87, 0xba, 0xb5, 0xf4, 0x26, 0xef, 0xc1, 0x0e, 0x46, 0x6b, 0x19, 0xe0, + 0xd3, 0x22, 0x47, 0x4d, 0x8d, 0x89, 0x27, 0x1c, 0x17, 0x48, 0x12, 0xb1, 0x24, 0xb7, 0x18, 0x86, 0x60, 0x03, 0x9e, + 0x39, 0xbd, 0x5c, 0xb7, 0xb2, 0xfd, 0x99, 0xaf, 0x6f, 0x60, 0x4d, 0x40, 0x6d, 0x81, 0x3b, 0xc8, 0x85, 0x6e, 0x81, + 0xe1, 0x1c, 0xea, 0xa0, 0x28, 0xbd, 0x80, 0x74, 0xe8, 0xb6, 0xb8, 0x4c, 0x0f, 0x63, 0xa0, 0x5a, 0xa0, 0x56, 0x7c, + 0x32, 0xc3, 0x5f, 0xce, 0x65, 0xf6, 0x64, 0x84, 0xbf, 0x5a, 0x97, 0xb9, 0x12, 0xab, 0x22, 0x45, 0x90, 0xc6, 0xac, + 0x0e, 0x4a, 0xfb, 0x89, 0xcc, 0xb5, 0x1f, 0xb0, 0x6d, 0xf8, 0x02, 0x3f, 0x7a, 0xbc, 0x4e, 0x20, 0x40, 0x81, 0x3c, + 0x86, 0xd0, 0x8a, 0xf5, 0xac, 0xb6, 0x7c, 0xd6, 0x50, 0x3e, 0xd2, 0xff, 0x60, 0xc2, 0x8f, 0xbb, 0x24, 0x2a, 0x68, + 0x4a, 0x59, 0x06, 0x72, 0x35, 0xf2, 0x43, 0x2f, 0xbe, 0xbb, 0xa2, 0x5b, 0x88, 0x26, 0x58, 0xfc, 0x5c, 0xb6, 0x43, + 0xbc, 0x68, 0xd9, 0x3a, 0x24, 0x95, 0x14, 0x55, 0x77, 0x9c, 0xd0, 0xbb, 0x7f, 0x8e, 0x25, 0xfe, 0xae, 0x74, 0x8d, + 0xe5, 0x0b, 0x52, 0xea, 0xe8, 0xea, 0xf1, 0x5a, 0x63, 0x9b, 0xcd, 0x54, 0x46, 0x5b, 0x61, 0x20, 0x61, 0x79, 0xf0, + 0x4a, 0xbc, 0x98, 0xf8, 0x3d, 0x34, 0xff, 0x18, 0x45, 0xb7, 0xe6, 0xe3, 0x75, 0x7a, 0xa2, 0x2e, 0xbc, 0xf8, 0x23, + 0x9b, 0x98, 0x63, 0x3f, 0x1e, 0x07, 0xc0, 0x3c, 0x8e, 0x02, 0x2f, 0xfc, 0xc8, 0x1f, 0xcd, 0x68, 0x95, 0xa2, 0x41, + 0xd7, 0xbd, 0x37, 0x68, 0x31, 0x27, 0x24, 0x48, 0x44, 0xae, 0xb6, 0x66, 0x16, 0x94, 0xf7, 0x43, 0x71, 0xad, 0x2f, + 0x18, 0xc5, 0xa2, 0x96, 0x01, 0xfe, 0x08, 0x60, 0x63, 0x06, 0x01, 0x1e, 0x0c, 0x15, 0xd7, 0x4b, 0x35, 0xe4, 0xa1, + 0x92, 0x56, 0x2d, 0xcf, 0x50, 0x7c, 0x85, 0x2d, 0xfc, 0xf6, 0xee, 0xa0, 0xe4, 0x21, 0xdd, 0xe5, 0xad, 0x7c, 0xde, + 0x08, 0xa1, 0xd4, 0x24, 0xc7, 0xc2, 0x07, 0x74, 0xce, 0x19, 0xcc, 0xe6, 0xae, 0xe5, 0x8f, 0xbd, 0x24, 0x59, 0x2d, + 0xd8, 0x84, 0x54, 0x62, 0x27, 0x05, 0x50, 0xe5, 0x7b, 0x88, 0x0c, 0xd8, 0xdf, 0x56, 0xad, 0xa3, 0x83, 0x57, 0x60, + 0xe0, 0x07, 0x0c, 0x65, 0x34, 0x9d, 0xaa, 0x85, 0x28, 0xe0, 0x9e, 0xcf, 0x9c, 0x83, 0xbf, 0xad, 0xde, 0x9c, 0xda, + 0x6f, 0xf2, 0x8f, 0x43, 0x60, 0x8c, 0x85, 0xb5, 0x12, 0xe7, 0x8b, 0x25, 0x78, 0xc5, 0x88, 0xa6, 0x5e, 0xd8, 0x3c, + 0x9c, 0x8b, 0xd2, 0x16, 0x5f, 0x32, 0x36, 0x01, 0x86, 0xdb, 0xd8, 0x28, 0xbd, 0x0a, 0xd8, 0x35, 0xcb, 0x2d, 0xa1, + 0x36, 0x3b, 0xab, 0xf9, 0x02, 0x43, 0xb5, 0x72, 0xdd, 0x23, 0xe7, 0xea, 0xa4, 0x21, 0x0d, 0x71, 0x0c, 0x7c, 0xe4, + 0xf2, 0x11, 0xab, 0x1c, 0xa9, 0xa1, 0xa1, 0x4a, 0x00, 0x34, 0x42, 0x76, 0xd2, 0x50, 0xde, 0x03, 0x44, 0xdd, 0x00, + 0x9b, 0xe1, 0xe8, 0x3d, 0x48, 0x6d, 0xc1, 0xe7, 0x29, 0x80, 0x93, 0xa7, 0x15, 0x52, 0x93, 0xa6, 0x19, 0xab, 0x13, + 0xb5, 0xa9, 0x24, 0xa4, 0x11, 0xce, 0x01, 0xe8, 0x25, 0x23, 0xc4, 0x55, 0xb5, 0x6b, 0xa3, 0x94, 0x47, 0x3e, 0xc2, + 0xc4, 0xef, 0x21, 0x4b, 0x92, 0xc6, 0x09, 0xcb, 0x17, 0xdd, 0x50, 0x8b, 0xda, 0xe5, 0xf9, 0x28, 0xca, 0x81, 0x0e, + 0x1a, 0x6a, 0xab, 0xd3, 0x51, 0x69, 0x90, 0xd5, 0xfe, 0x90, 0xc4, 0x5c, 0xa5, 0x6c, 0xb1, 0xdc, 0xa5, 0xbf, 0xa2, + 0x76, 0xb9, 0xbf, 0xa2, 0xdc, 0x50, 0x9d, 0xce, 0x81, 0x6a, 0xa8, 0xed, 0x23, 0x7b, 0x6b, 0x8f, 0x0b, 0x6e, 0x54, + 0x1a, 0xcf, 0x46, 0x2a, 0x37, 0xf8, 0x6b, 0x7a, 0x7f, 0xa3, 0x72, 0xd0, 0x4a, 0xcc, 0x41, 0x2d, 0x80, 0x5a, 0x09, + 0xe1, 0x6f, 0xc8, 0xb2, 0xb0, 0x01, 0x01, 0x53, 0x05, 0xab, 0xb3, 0xe9, 0x94, 0x8d, 0xd3, 0x44, 0x17, 0x92, 0xad, + 0x3c, 0xc4, 0x3b, 0xb8, 0xf6, 0xee, 0xb9, 0xea, 0x4f, 0x10, 0xe8, 0x46, 0x44, 0x42, 0xe4, 0x00, 0x89, 0x9b, 0x5a, + 0xfd, 0x64, 0x51, 0x8b, 0xe5, 0x89, 0xe2, 0xbd, 0x80, 0xec, 0xbb, 0xa6, 0x1c, 0x41, 0xe3, 0x54, 0xaf, 0xd8, 0x8d, + 0x51, 0x6e, 0x7a, 0xba, 0x1d, 0x01, 0x6e, 0x43, 0x1a, 0x6b, 0xe7, 0x4d, 0xc7, 0xb1, 0x33, 0xd5, 0x00, 0x07, 0xeb, + 0x8f, 0x95, 0xc3, 0x43, 0x64, 0xd1, 0x55, 0xcf, 0xde, 0xbe, 0xfa, 0xf3, 0xe9, 0xeb, 0x5d, 0xf1, 0x10, 0x36, 0xd9, + 0x86, 0x26, 0x57, 0xe1, 0x96, 0x46, 0x7f, 0xf9, 0xe9, 0x61, 0xcd, 0xb6, 0x9c, 0x17, 0x8e, 0x6a, 0x90, 0x4d, 0xbc, + 0x84, 0x8d, 0xc7, 0xd1, 0x35, 0x8b, 0x3f, 0x7b, 0x1a, 0xe4, 0xc6, 0xeb, 0xc1, 0x7d, 0xfb, 0xf3, 0xe9, 0x4f, 0x3b, + 0x83, 0x7a, 0xe8, 0xc0, 0xe1, 0x02, 0xb1, 0xe7, 0x03, 0x46, 0xd7, 0x86, 0x73, 0x14, 0x44, 0x09, 0x6b, 0x80, 0xe0, + 0xd5, 0xd9, 0xdb, 0xf7, 0x38, 0x5d, 0x05, 0xe3, 0x43, 0x4d, 0x7d, 0xde, 0xe0, 0x7f, 0x7e, 0x77, 0xfa, 0xfe, 0xbd, + 0x6a, 0x60, 0x8a, 0xf0, 0x44, 0x6e, 0x9d, 0x6f, 0xe2, 0x7b, 0xe8, 0x5c, 0xed, 0x5e, 0x27, 0x5a, 0x4a, 0xd7, 0xf7, + 0xf2, 0x68, 0xa8, 0x6c, 0x63, 0x9b, 0x73, 0x1a, 0xcb, 0x7b, 0xa6, 0x3b, 0xf7, 0x4e, 0xe3, 0xaa, 0xc1, 0x4a, 0xdb, + 0x09, 0x79, 0xa9, 0x64, 0xe1, 0x87, 0x57, 0x35, 0xa5, 0xde, 0x6d, 0x4d, 0x29, 0x5c, 0x5a, 0x37, 0xb0, 0xf2, 0x2a, + 0x5a, 0x48, 0x4c, 0x10, 0xbb, 0xbd, 0x7f, 0xba, 0xa4, 0x9b, 0xe3, 0x67, 0x00, 0xcd, 0x53, 0xbc, 0x54, 0xa1, 0xae, + 0x29, 0xe6, 0xd7, 0xbd, 0x7c, 0x6e, 0xc7, 0x01, 0x78, 0x02, 0x30, 0x59, 0xf9, 0x59, 0x66, 0x90, 0xb9, 0x1f, 0x8f, + 0x5b, 0xb9, 0x8b, 0xd0, 0x67, 0xa4, 0x30, 0xe2, 0x94, 0x6c, 0xe9, 0x4d, 0xc0, 0xbc, 0xde, 0x1c, 0x45, 0x69, 0x1a, + 0x2d, 0x7a, 0x8e, 0xbd, 0xbc, 0x55, 0x95, 0xbe, 0x10, 0xb1, 0x70, 0xeb, 0xff, 0xde, 0xbf, 0xfe, 0x59, 0x41, 0xf3, + 0x54, 0x8e, 0x44, 0x81, 0xc5, 0x5e, 0xba, 0x8a, 0x59, 0xa6, 0xfc, 0xeb, 0x7f, 0x5e, 0x55, 0xc4, 0x09, 0x7d, 0xf9, + 0x1b, 0xba, 0x48, 0xc8, 0x9f, 0x5c, 0x05, 0xd1, 0xcd, 0x5e, 0xe1, 0xe7, 0x77, 0x4f, 0xe5, 0xb9, 0x3f, 0x9b, 0xe7, + 0xb5, 0x4f, 0xd2, 0x2d, 0x63, 0x13, 0xd0, 0x93, 0x16, 0x42, 0x39, 0x8b, 0x6e, 0x7a, 0xff, 0xfa, 0x67, 0x2e, 0x26, + 0xba, 0x77, 0xd7, 0xd5, 0x03, 0x5a, 0x5e, 0xd1, 0xfa, 0x3a, 0x1b, 0x4b, 0x8c, 0x44, 0xb3, 0xba, 0xc0, 0x1b, 0x85, + 0xb4, 0x2b, 0x37, 0x35, 0x82, 0x5b, 0xc6, 0xf4, 0x9d, 0x3f, 0x9b, 0x7f, 0xee, 0xa0, 0x60, 0x42, 0xef, 0x1d, 0x15, + 0x54, 0xfa, 0x02, 0xc3, 0x1a, 0xf6, 0x76, 0x5f, 0xb0, 0xcf, 0x1c, 0xd7, 0x7d, 0x43, 0xfa, 0x12, 0xa3, 0xe1, 0xf2, + 0xe2, 0xf7, 0xc3, 0x61, 0x9e, 0x22, 0x57, 0xfe, 0x1e, 0x3c, 0x15, 0x4f, 0x36, 0x4a, 0x38, 0x7b, 0xd1, 0xb3, 0x75, + 0x0a, 0x21, 0xb4, 0xc3, 0x84, 0xa0, 0xcd, 0x7d, 0xcd, 0x74, 0x34, 0xe3, 0x6b, 0x72, 0x9d, 0xdb, 0xe8, 0x7b, 0x03, + 0x59, 0x43, 0x29, 0xa6, 0x57, 0xcd, 0x75, 0x95, 0x46, 0x3d, 0x38, 0x37, 0xb1, 0xb7, 0x24, 0xd5, 0x84, 0x82, 0x7a, + 0x1a, 0x10, 0xf5, 0x54, 0xee, 0xee, 0xd7, 0x5e, 0x70, 0xbd, 0xdb, 0x35, 0xae, 0x99, 0x82, 0x21, 0x69, 0xfe, 0xf7, + 0x11, 0x6f, 0xa4, 0xcb, 0x0f, 0xa6, 0xdd, 0x37, 0x5e, 0xca, 0xe2, 0xab, 0x39, 0xf8, 0x18, 0x0b, 0x99, 0x05, 0x44, + 0xef, 0xdd, 0x86, 0x94, 0x4b, 0x6c, 0x69, 0x0d, 0x1a, 0x2d, 0x30, 0xdc, 0x6f, 0xc3, 0xdd, 0x5f, 0x08, 0x73, 0xf7, + 0x4e, 0xc1, 0x0b, 0xf4, 0x77, 0xc3, 0xde, 0xdb, 0x28, 0xd3, 0xff, 0x63, 0xef, 0xff, 0x44, 0xec, 0xbd, 0xb5, 0x9f, + 0xdf, 0xb2, 0xb0, 0xff, 0x07, 0xb0, 0x7c, 0x8f, 0xb9, 0xa7, 0x1c, 0xd3, 0x6b, 0x9a, 0xe7, 0x6a, 0x71, 0xe9, 0xf0, + 0x22, 0x5e, 0xdd, 0x50, 0xeb, 0xf2, 0x10, 0x6f, 0xdc, 0x5e, 0xd1, 0x43, 0x64, 0xbf, 0xe5, 0x28, 0xff, 0xfe, 0x88, + 0x3e, 0xa1, 0xbc, 0x58, 0x12, 0xa6, 0xef, 0x9d, 0x1a, 0x49, 0x69, 0x24, 0xde, 0x8d, 0x77, 0xb7, 0x0b, 0xde, 0x11, + 0xc0, 0x7e, 0x73, 0xe3, 0xdd, 0xd5, 0x01, 0xdb, 0x88, 0x5e, 0xab, 0x9d, 0x9d, 0x80, 0x6f, 0x51, 0x0f, 0x1d, 0x8b, + 0x8c, 0x61, 0xc2, 0xd2, 0x13, 0x28, 0x74, 0x1f, 0xaf, 0xf7, 0xaa, 0x15, 0xb3, 0x21, 0x78, 0x5d, 0x4b, 0x80, 0x47, + 0x25, 0xc0, 0xfd, 0xe4, 0x2a, 0x0a, 0x1f, 0x02, 0xf9, 0xcf, 0x20, 0x72, 0xfa, 0xcd, 0xa0, 0x63, 0x77, 0x1b, 0xb0, + 0x63, 0x69, 0x15, 0x78, 0x2c, 0xac, 0x42, 0xdf, 0xaf, 0xd7, 0x10, 0x54, 0x08, 0x2d, 0xd2, 0x58, 0x46, 0x84, 0x56, + 0x01, 0x6d, 0x8e, 0x02, 0x9a, 0xb5, 0x0a, 0xc9, 0xf5, 0xc3, 0x69, 0xec, 0xc5, 0x6c, 0xd2, 0x7c, 0x05, 0x28, 0xd9, + 0x44, 0xdf, 0x59, 0xc9, 0x6a, 0xb9, 0x8c, 0xe2, 0x34, 0xb9, 0xc2, 0xe8, 0x30, 0x0b, 0x1f, 0x2e, 0x14, 0x90, 0xc7, + 0x2c, 0x8f, 0x15, 0x7c, 0x5a, 0x27, 0x55, 0x37, 0x98, 0x5b, 0x4e, 0xf1, 0xc1, 0x7d, 0x7e, 0x0c, 0xee, 0x35, 0x34, + 0x97, 0xb4, 0x26, 0x73, 0x2b, 0x8d, 0xfd, 0x85, 0xa6, 0x1b, 0x8e, 0xad, 0xeb, 0x42, 0xbe, 0x32, 0x77, 0x07, 0x7b, + 0x14, 0xe3, 0x78, 0xae, 0x43, 0xac, 0x44, 0xf4, 0xa3, 0x01, 0x0b, 0xbd, 0x97, 0xab, 0xe9, 0x94, 0xc5, 0x9a, 0x08, + 0x06, 0x09, 0xd1, 0x68, 0xc9, 0x04, 0x11, 0xbc, 0x2b, 0x3f, 0xf8, 0xec, 0x06, 0xb2, 0x4e, 0x15, 0xc1, 0xdc, 0xc1, + 0xc3, 0x94, 0x8c, 0xd8, 0x21, 0xa3, 0x5d, 0xda, 0x6e, 0x69, 0x93, 0x67, 0x07, 0xc6, 0x1c, 0x42, 0x40, 0x15, 0x4e, + 0xf9, 0x18, 0x5d, 0xd0, 0x0f, 0xd3, 0x2e, 0xf6, 0x00, 0x0d, 0xc0, 0xe1, 0x0d, 0xdc, 0xdc, 0x1b, 0x4b, 0x19, 0xe7, + 0x0d, 0xce, 0xdd, 0x41, 0xf0, 0xdc, 0x25, 0xed, 0x12, 0x5a, 0x0b, 0xbe, 0x9a, 0x7b, 0xf1, 0xab, 0x68, 0xc2, 0x10, + 0xd0, 0x51, 0x1a, 0x81, 0x8f, 0xa8, 0x14, 0xfc, 0x07, 0x63, 0xff, 0x98, 0xa5, 0x78, 0x40, 0xfb, 0x50, 0x74, 0x25, + 0x17, 0xb9, 0xcf, 0x1f, 0xef, 0x1b, 0x70, 0xd2, 0xea, 0x57, 0x5a, 0x2c, 0x1a, 0x5f, 0xea, 0xda, 0x57, 0xf2, 0x6e, + 0x7d, 0xe5, 0xc5, 0xb1, 0xcf, 0x62, 0x45, 0xfb, 0xee, 0x57, 0x5d, 0xde, 0xb4, 0x25, 0x35, 0x12, 0xd7, 0x6d, 0x2b, + 0x18, 0x03, 0x6f, 0xea, 0xb3, 0x60, 0xe2, 0xaa, 0x63, 0xfa, 0x30, 0x57, 0x19, 0xb5, 0xbb, 0xb6, 0x6d, 0x73, 0x35, + 0xad, 0x43, 0x3f, 0x41, 0x4d, 0x0b, 0x3f, 0xe1, 0xa1, 0x24, 0xd4, 0xec, 0x12, 0x17, 0xb1, 0x41, 0xce, 0x6a, 0x21, + 0x7c, 0x47, 0x51, 0x84, 0x1e, 0x02, 0x1b, 0x8f, 0x36, 0x24, 0x40, 0x73, 0x04, 0x58, 0x05, 0x4c, 0x15, 0x80, 0x3a, + 0x0f, 0x01, 0xe8, 0xdc, 0x5f, 0xf8, 0xe1, 0x2c, 0x69, 0x84, 0x08, 0x95, 0xb5, 0x25, 0x78, 0x52, 0xfa, 0x42, 0x55, + 0x70, 0x0d, 0xe7, 0x51, 0x00, 0xd9, 0x8f, 0x54, 0x66, 0xcd, 0x2c, 0xe5, 0x85, 0x6d, 0xdb, 0x86, 0x79, 0x00, 0x79, + 0x06, 0x3b, 0x87, 0xb6, 0x61, 0xc2, 0x5f, 0x96, 0x65, 0xd5, 0x48, 0x81, 0xfb, 0x0b, 0x3f, 0x34, 0xe9, 0xb1, 0x65, + 0xef, 0x06, 0xef, 0xbd, 0xb6, 0xc4, 0x09, 0xd7, 0xc8, 0x8d, 0x72, 0x87, 0x55, 0x6d, 0xe4, 0x26, 0x65, 0x0b, 0x3b, + 0x8b, 0xc2, 0x3c, 0xf1, 0x28, 0x1c, 0x15, 0x62, 0x34, 0x2a, 0xbf, 0x45, 0xb6, 0x34, 0xae, 0x66, 0xcf, 0x50, 0xbf, + 0xe7, 0x60, 0xf5, 0x94, 0x57, 0xd1, 0x2a, 0x98, 0xa0, 0x11, 0x16, 0x58, 0x4c, 0x2b, 0x85, 0x2d, 0x6a, 0x25, 0xc5, + 0x15, 0x64, 0x30, 0xc7, 0xf4, 0x6e, 0xef, 0x91, 0x38, 0x45, 0xb1, 0xf6, 0x14, 0xa7, 0xf8, 0xa2, 0x6e, 0x0b, 0x5e, + 0x3e, 0x85, 0x28, 0x46, 0x3b, 0x7c, 0xc0, 0xf7, 0x05, 0xd4, 0x0f, 0x76, 0xa9, 0x2f, 0xd6, 0xed, 0xf2, 0x29, 0x85, + 0xba, 0xf5, 0x3e, 0x7d, 0xda, 0x1b, 0x7f, 0xfa, 0xb4, 0xb7, 0x91, 0x1f, 0xa4, 0x79, 0x84, 0xb4, 0x31, 0x18, 0x0f, + 0x6c, 0x02, 0xd1, 0x8a, 0x08, 0xe8, 0xef, 0xa1, 0xbc, 0xe7, 0xf1, 0x18, 0x59, 0xf4, 0x34, 0x36, 0x78, 0x87, 0xf4, + 0x18, 0x64, 0x95, 0x49, 0x99, 0xbb, 0x1e, 0x89, 0x79, 0x3e, 0x7d, 0xe2, 0xc7, 0xcd, 0x98, 0xb8, 0xe3, 0xbc, 0xc8, + 0x51, 0x8d, 0x95, 0x1b, 0xe4, 0x8f, 0x2a, 0x82, 0xbc, 0xe2, 0x18, 0xb3, 0x80, 0xf8, 0xc6, 0x8b, 0x43, 0x19, 0xe0, + 0x9f, 0x22, 0x85, 0x77, 0xab, 0xf0, 0x38, 0xac, 0x93, 0xea, 0x6a, 0x4c, 0x5d, 0xa6, 0xad, 0x08, 0x07, 0x0a, 0xfb, + 0x3a, 0xa9, 0x81, 0x73, 0x81, 0xed, 0x31, 0x19, 0xab, 0x18, 0x20, 0x7a, 0x75, 0xe3, 0xc9, 0x9d, 0x88, 0x61, 0xbd, + 0xf3, 0x6e, 0x7a, 0x2b, 0xf1, 0x70, 0x4a, 0x86, 0xf8, 0xbd, 0x69, 0xee, 0x2d, 0xbd, 0x24, 0x5f, 0xc7, 0x99, 0xfb, + 0x6d, 0xac, 0x2d, 0x8d, 0xd4, 0x50, 0x05, 0x19, 0x51, 0x75, 0x63, 0x51, 0x17, 0xd6, 0xb5, 0xbf, 0xe0, 0x41, 0x6e, + 0x34, 0xb1, 0x15, 0xae, 0xa6, 0xe8, 0x21, 0x11, 0x8e, 0xef, 0x30, 0x6c, 0x73, 0xf1, 0x9e, 0x40, 0xb9, 0xe2, 0x39, + 0xff, 0x26, 0xf2, 0x2b, 0x58, 0x70, 0xd5, 0x98, 0xea, 0x06, 0x79, 0x1e, 0xcc, 0xbe, 0xa4, 0x93, 0x01, 0x45, 0x72, + 0x5e, 0x48, 0x41, 0x66, 0x85, 0xdb, 0xc1, 0x55, 0xc5, 0xed, 0xa0, 0x66, 0x3e, 0x95, 0x98, 0x25, 0xcb, 0x28, 0x84, + 0xbb, 0xe2, 0x55, 0xe1, 0x57, 0x76, 0xb5, 0xe9, 0x57, 0x56, 0xf3, 0x29, 0xbe, 0xa1, 0xef, 0x40, 0x11, 0x7e, 0xfe, + 0x5f, 0x15, 0xbf, 0x00, 0x41, 0xea, 0x31, 0x37, 0xfa, 0x69, 0x93, 0x3f, 0xf9, 0xf7, 0xf7, 0xfb, 0x93, 0x9f, 0xed, + 0xe4, 0x4f, 0xfe, 0xfd, 0x17, 0xf7, 0x27, 0x3f, 0x95, 0xfd, 0xc9, 0x81, 0x04, 0x9f, 0xb2, 0x9d, 0xdc, 0x77, 0x85, + 0x23, 0x4d, 0x74, 0x93, 0xb8, 0x0e, 0xd7, 0xe7, 0x25, 0xe3, 0x39, 0x03, 0x03, 0x09, 0xce, 0xea, 0x06, 0xd1, 0x0c, + 0xbc, 0x6c, 0x9b, 0xfd, 0x68, 0xbf, 0x94, 0x17, 0x6d, 0x10, 0xcd, 0x54, 0x29, 0x3b, 0x5c, 0x28, 0xb2, 0xc3, 0x41, + 0x44, 0xbc, 0xbf, 0xdd, 0x3a, 0x2f, 0x2f, 0x9c, 0x7e, 0xdb, 0x81, 0xe8, 0xaa, 0xa0, 0xf3, 0xc6, 0x02, 0xbb, 0xdf, + 0x6e, 0x43, 0xc1, 0x8d, 0x54, 0xd0, 0x82, 0x02, 0x5f, 0x2a, 0xe8, 0x40, 0xc1, 0x58, 0x2a, 0x38, 0x84, 0x82, 0x89, + 0x54, 0x70, 0x04, 0x05, 0xd7, 0x6a, 0x76, 0x11, 0xe6, 0xde, 0xf2, 0x47, 0xfa, 0x65, 0x29, 0x31, 0x68, 0x6e, 0xa0, + 0x21, 0xaa, 0x1c, 0x19, 0x22, 0x4b, 0x85, 0x79, 0xa0, 0x73, 0x1e, 0x6d, 0xf8, 0xd5, 0x10, 0x30, 0x2f, 0xd8, 0xab, + 0x18, 0x60, 0xed, 0x43, 0x35, 0xdb, 0xe2, 0xb5, 0xda, 0xcb, 0xbd, 0xcb, 0x6d, 0xa3, 0x25, 0xbc, 0xb5, 0x7b, 0x18, + 0x3b, 0x44, 0x54, 0xee, 0x3c, 0x9f, 0xe7, 0x21, 0xab, 0x57, 0x6e, 0x11, 0x82, 0xa7, 0x0d, 0x89, 0x7b, 0x38, 0xaf, + 0xc6, 0x34, 0xb0, 0xd2, 0x81, 0x08, 0x2b, 0xe2, 0x14, 0x89, 0x0e, 0x14, 0x74, 0xc1, 0xef, 0x7b, 0x05, 0x0f, 0xc7, + 0x03, 0xbc, 0x13, 0xf4, 0x8b, 0x3c, 0x6e, 0x36, 0x69, 0x70, 0x57, 0x46, 0xea, 0xcd, 0x7a, 0x73, 0x83, 0xcc, 0xb7, + 0x7a, 0x33, 0x48, 0x84, 0x72, 0x32, 0xe9, 0x2d, 0x8d, 0x9b, 0x39, 0x0b, 0x7b, 0x53, 0xee, 0xec, 0x08, 0xeb, 0x4f, + 0xfe, 0x2b, 0x0b, 0x5d, 0x38, 0x5e, 0xe1, 0x9e, 0x28, 0xde, 0x12, 0x94, 0x66, 0xbe, 0x95, 0x0a, 0x9f, 0x21, 0x4d, + 0x36, 0xed, 0xfa, 0x12, 0x1e, 0x1e, 0xaf, 0xd9, 0x68, 0x35, 0x53, 0xce, 0xa2, 0xd9, 0xbd, 0xde, 0x1c, 0xf2, 0x2b, + 0x80, 0x52, 0x25, 0x1b, 0x56, 0x53, 0x6c, 0x6f, 0xde, 0x17, 0x3d, 0x66, 0xe5, 0xfa, 0x29, 0xc0, 0xa6, 0xa4, 0xc4, + 0x36, 0x40, 0x3f, 0x30, 0xdb, 0x92, 0xbf, 0xc4, 0x19, 0xcc, 0x9f, 0xf4, 0x7c, 0xee, 0x49, 0xf0, 0x0c, 0x7e, 0x64, + 0x49, 0xe2, 0xcd, 0x98, 0x8c, 0x5a, 0x4a, 0x8d, 0x03, 0x16, 0xcc, 0x95, 0xd8, 0x38, 0x81, 0xc0, 0xd8, 0xfb, 0x1b, + 0x5e, 0x30, 0xe0, 0xa8, 0x0b, 0xde, 0x61, 0xb0, 0x68, 0x85, 0xcb, 0x88, 0x6f, 0xc1, 0xf2, 0x94, 0xbd, 0x37, 0x00, + 0x89, 0x5c, 0xb3, 0xa0, 0x5a, 0x98, 0x7a, 0xb3, 0x6a, 0x11, 0xad, 0x75, 0x56, 0x42, 0x7b, 0x7a, 0xe9, 0x51, 0xe0, + 0xc2, 0xcf, 0xf0, 0x06, 0x08, 0xa2, 0xd9, 0xef, 0xea, 0x0a, 0xb0, 0xc5, 0x85, 0xe3, 0xc7, 0xd0, 0x08, 0xd3, 0xa1, + 0x85, 0x73, 0xac, 0x58, 0x30, 0x85, 0xbd, 0x30, 0x9d, 0x9b, 0x18, 0xce, 0x4e, 0x6b, 0x85, 0xba, 0x61, 0xe1, 0xda, + 0xae, 0xab, 0x41, 0x3c, 0x7b, 0xf1, 0x6c, 0xe4, 0x69, 0x4e, 0xeb, 0xc8, 0x10, 0x7f, 0x2c, 0xbb, 0xa3, 0x67, 0xd8, + 0x82, 0x32, 0xf1, 0xaf, 0xd7, 0xd3, 0x28, 0x4c, 0xcd, 0xa9, 0xb7, 0xf0, 0x83, 0xbb, 0xde, 0x22, 0x0a, 0xa3, 0x64, + 0xe9, 0x8d, 0x59, 0x5f, 0xe2, 0x47, 0x31, 0x3c, 0x34, 0x8f, 0x50, 0xe8, 0x58, 0xad, 0x98, 0x2d, 0xe8, 0xeb, 0x3c, + 0xfa, 0xf3, 0x34, 0x60, 0xb7, 0x19, 0xef, 0xbe, 0x54, 0x99, 0xaa, 0xe2, 0x96, 0xa3, 0x2f, 0x80, 0x65, 0xe6, 0xa1, + 0xa5, 0x21, 0xa1, 0x42, 0x9f, 0x4b, 0x1d, 0x7b, 0x56, 0xab, 0x13, 0xb3, 0x85, 0x62, 0x75, 0x1a, 0x1b, 0x8f, 0xa3, + 0x9b, 0x01, 0x40, 0x8b, 0x1f, 0x9b, 0x09, 0x0b, 0xa6, 0xf8, 0xc6, 0xc4, 0x68, 0x56, 0xa2, 0x1d, 0x13, 0xad, 0x19, + 0xa0, 0x35, 0xb6, 0xe8, 0xc3, 0xeb, 0x5e, 0x4b, 0xb1, 0x25, 0x7e, 0xfa, 0xc8, 0x5e, 0x4a, 0x6d, 0xc9, 0xf3, 0xa7, + 0xaf, 0xb1, 0xba, 0xa3, 0xd8, 0x7d, 0xd0, 0x1f, 0x4f, 0x83, 0xe8, 0xa6, 0x37, 0xf7, 0x27, 0x13, 0x16, 0xf6, 0x11, + 0xe6, 0xbc, 0x90, 0x05, 0x81, 0xbf, 0x4c, 0xfc, 0xa4, 0xbf, 0xf0, 0x6e, 0x79, 0xab, 0x07, 0x4d, 0xad, 0xb6, 0x79, + 0xab, 0xed, 0x9d, 0x5b, 0x95, 0x9a, 0x81, 0xc8, 0x59, 0xd4, 0x0e, 0x07, 0xad, 0xa3, 0xd8, 0x95, 0x71, 0xee, 0xdc, + 0xea, 0x32, 0x66, 0xeb, 0x85, 0x17, 0xcf, 0xfc, 0xb0, 0x67, 0x67, 0xd6, 0xf5, 0x9a, 0x36, 0xc6, 0xa3, 0x6e, 0xb7, + 0x9b, 0x59, 0x13, 0xf1, 0x64, 0x4f, 0x26, 0x99, 0x35, 0x16, 0x4f, 0xd3, 0xa9, 0x6d, 0x4f, 0xa7, 0x99, 0xe5, 0x8b, + 0x82, 0x76, 0x6b, 0x3c, 0x69, 0xb7, 0x32, 0xeb, 0x46, 0xaa, 0x91, 0x59, 0x8c, 0x3f, 0xc5, 0x6c, 0xd2, 0xc7, 0x8d, + 0xc4, 0xbd, 0xae, 0x8f, 0x6c, 0x3b, 0x43, 0x0c, 0x70, 0x51, 0xc2, 0x4d, 0x68, 0x30, 0x73, 0xb9, 0xde, 0xb9, 0xa6, + 0x52, 0x74, 0x37, 0x1e, 0xd7, 0xd6, 0x9b, 0x78, 0xf1, 0xc7, 0x4b, 0x45, 0x1a, 0x85, 0xe7, 0x51, 0xb5, 0xb5, 0x98, + 0x06, 0xf3, 0xb6, 0x07, 0x69, 0x42, 0xfa, 0xa3, 0x28, 0x86, 0x33, 0x1b, 0x7b, 0x13, 0x7f, 0x95, 0xf4, 0x9c, 0xd6, + 0xf2, 0x56, 0x14, 0xf1, 0xbd, 0x5e, 0x14, 0xe0, 0xd9, 0xeb, 0x25, 0x51, 0xe0, 0x4f, 0x44, 0x51, 0xd3, 0x59, 0x72, + 0x5a, 0x7a, 0x1f, 0xf9, 0x57, 0x1f, 0x43, 0x3d, 0x7b, 0x41, 0xa0, 0x58, 0xed, 0x44, 0x61, 0x5e, 0x82, 0x46, 0x7a, + 0x8a, 0x9d, 0xd0, 0xbc, 0x60, 0x40, 0x5c, 0xe7, 0x60, 0x79, 0x9b, 0xef, 0x79, 0xe7, 0x70, 0x79, 0x9b, 0x7d, 0xbd, + 0x60, 0x13, 0xdf, 0x53, 0xb4, 0x62, 0x37, 0x39, 0x36, 0x18, 0xf2, 0xe9, 0xeb, 0x86, 0x6d, 0x2a, 0x8e, 0x05, 0xa4, + 0x53, 0xda, 0xf3, 0x17, 0x20, 0x87, 0xf1, 0xc2, 0x34, 0xcb, 0x86, 0x97, 0x59, 0xd6, 0x3f, 0xf3, 0xb5, 0x8b, 0xff, + 0xd6, 0x88, 0x16, 0x92, 0xe1, 0x6b, 0xa6, 0x5f, 0x1a, 0xa7, 0x4c, 0x76, 0xd2, 0x01, 0x32, 0x86, 0x0e, 0x3a, 0x72, + 0x65, 0xa2, 0xb7, 0x9b, 0x95, 0x69, 0x92, 0xf3, 0xea, 0xe4, 0xf3, 0x53, 0xae, 0x82, 0x14, 0x08, 0x2a, 0x9c, 0x32, + 0xf7, 0x4c, 0xf2, 0xf8, 0x01, 0xa6, 0x07, 0x2b, 0x53, 0x2c, 0xa3, 0xd7, 0x4d, 0xbc, 0xe7, 0xf9, 0xfd, 0xbc, 0xe7, + 0x5f, 0xd3, 0x5d, 0x78, 0xcf, 0xf3, 0x2f, 0xce, 0x7b, 0xbe, 0xde, 0x8c, 0x65, 0x74, 0x1e, 0xb9, 0x6a, 0x6e, 0xa6, + 0x09, 0xa4, 0x29, 0xa6, 0x2c, 0x01, 0xaf, 0xd3, 0xdf, 0x1a, 0x54, 0x46, 0xb4, 0x86, 0x44, 0x81, 0xf3, 0xa9, 0x20, + 0x66, 0x7d, 0x1b, 0xba, 0x7f, 0x8e, 0xe5, 0xe7, 0xe9, 0xd4, 0x7d, 0x1d, 0x49, 0x05, 0xf9, 0x13, 0xf7, 0x60, 0x29, + 0x45, 0x74, 0xa6, 0x37, 0xb9, 0x8f, 0x11, 0xe4, 0xbc, 0x86, 0x80, 0xb0, 0xe4, 0x50, 0x3e, 0xc9, 0x3d, 0xfd, 0xfa, + 0x65, 0x10, 0xb4, 0xdc, 0xb5, 0x56, 0x84, 0xfd, 0xda, 0xb0, 0x8c, 0x9a, 0x31, 0x21, 0x03, 0x78, 0x79, 0xf7, 0xfd, + 0x44, 0x3b, 0x8f, 0xf4, 0xcc, 0x4f, 0xde, 0x56, 0x83, 0x6e, 0x09, 0x3d, 0x97, 0x3c, 0x9c, 0x8c, 0x7b, 0xeb, 0x49, + 0xb1, 0x75, 0xf1, 0x35, 0x7d, 0x7e, 0x52, 0x1a, 0x69, 0x4f, 0xfe, 0xb0, 0x4f, 0x91, 0xc6, 0x37, 0x88, 0x31, 0x0f, + 0x4e, 0xb3, 0xe6, 0x5c, 0xde, 0x1a, 0x9f, 0x21, 0x56, 0xe9, 0x84, 0x3e, 0xf7, 0x27, 0x59, 0xa6, 0xf7, 0xc5, 0x44, + 0x48, 0x84, 0x96, 0xdd, 0xc7, 0xc4, 0x25, 0x85, 0x10, 0x88, 0x4b, 0x7c, 0xc8, 0x86, 0xfa, 0x1c, 0xbc, 0x12, 0xb8, + 0xc5, 0x35, 0x9f, 0x33, 0x55, 0xa1, 0xe9, 0x23, 0x6f, 0x15, 0x69, 0x40, 0x60, 0x46, 0x2f, 0xfb, 0x78, 0x95, 0x16, + 0x64, 0xd3, 0x9d, 0x96, 0x26, 0x07, 0xdd, 0x2a, 0x20, 0xb2, 0xb0, 0x10, 0x0b, 0x11, 0xda, 0xe1, 0x75, 0xf0, 0x21, + 0x53, 0x73, 0xde, 0x0f, 0xb7, 0xdf, 0xe0, 0x78, 0x1f, 0x3e, 0x18, 0x54, 0x94, 0x6e, 0xf7, 0x78, 0x83, 0x02, 0x2b, + 0x91, 0xdc, 0x18, 0x56, 0x72, 0xa3, 0x3c, 0x5b, 0x8b, 0xa8, 0xdc, 0xa9, 0xb7, 0x34, 0x41, 0xcb, 0x83, 0xb8, 0x97, + 0x63, 0x3c, 0x29, 0x00, 0x78, 0x7f, 0x95, 0x00, 0x6e, 0x44, 0x39, 0x0a, 0xe2, 0x9f, 0xfe, 0x78, 0x15, 0x27, 0x51, + 0xdc, 0x5b, 0x46, 0x7e, 0x98, 0xb2, 0x38, 0x23, 0xc1, 0x0a, 0xce, 0x8f, 0x98, 0x9e, 0xcb, 0x75, 0xb4, 0xf4, 0xc6, + 0x7e, 0x7a, 0xd7, 0xb3, 0x39, 0x4b, 0x61, 0xf7, 0x39, 0x77, 0x60, 0xd7, 0xd6, 0xef, 0xf1, 0xd9, 0x7c, 0x8e, 0x8c, + 0x5f, 0xbc, 0xc9, 0xce, 0xc8, 0xdb, 0xbc, 0x2f, 0xbd, 0xa5, 0xb8, 0xe4, 0xc0, 0x7e, 0x78, 0xb1, 0x39, 0x03, 0x2c, + 0x0f, 0x4b, 0x6d, 0x4f, 0xd8, 0xcc, 0x40, 0xac, 0x0d, 0xfe, 0x0e, 0xe2, 0x8f, 0xd5, 0xd1, 0x15, 0xbb, 0xbe, 0x18, + 0x38, 0x1e, 0x7d, 0x17, 0xc8, 0x7a, 0xde, 0x34, 0x65, 0xb1, 0xb1, 0x4b, 0xcd, 0x11, 0x9b, 0x46, 0x31, 0xa3, 0x1c, + 0x76, 0x4e, 0x77, 0x79, 0xbb, 0x7b, 0xf3, 0xdb, 0x87, 0x5f, 0xdf, 0x4e, 0x18, 0xa5, 0x9a, 0x68, 0x4c, 0xbf, 0xa7, + 0xb5, 0x4d, 0x7a, 0x06, 0xac, 0x21, 0xcd, 0xfc, 0x98, 0xa4, 0x20, 0x10, 0x7f, 0xac, 0x36, 0x55, 0xc8, 0x32, 0xe2, + 0x34, 0x2f, 0x66, 0x81, 0x97, 0xfa, 0xd7, 0x82, 0x67, 0x6c, 0x1f, 0x2e, 0x6f, 0xc5, 0x1a, 0x23, 0xc1, 0x7b, 0xc0, + 0x22, 0x55, 0x40, 0x11, 0x8b, 0x54, 0x2d, 0xc6, 0x45, 0xea, 0x6f, 0x8c, 0x46, 0x44, 0xcf, 0xae, 0x50, 0xfa, 0xce, + 0xf2, 0x56, 0x26, 0xd1, 0xc5, 0x67, 0x39, 0xa5, 0xae, 0xa6, 0x3d, 0x59, 0xf8, 0x93, 0x49, 0xc0, 0xb2, 0xd2, 0x42, + 0x97, 0xd7, 0x52, 0x9a, 0x9c, 0x7c, 0x1e, 0xbc, 0x51, 0x12, 0x05, 0xab, 0x94, 0xd5, 0x4f, 0x97, 0x90, 0xe8, 0x16, + 0x93, 0x83, 0xbf, 0xcb, 0xb0, 0x76, 0x80, 0xdd, 0x86, 0x6d, 0x62, 0xf7, 0x21, 0xcb, 0xa1, 0xd9, 0x2e, 0x83, 0x0e, + 0xaf, 0x72, 0xa0, 0x8d, 0x9a, 0x81, 0x18, 0x40, 0x96, 0x08, 0x7b, 0x2b, 0x96, 0xc3, 0xcb, 0xf2, 0x4c, 0x6f, 0x79, + 0x51, 0x56, 0x1e, 0xcc, 0xef, 0x73, 0xc6, 0x5e, 0xd4, 0x9f, 0xb1, 0x17, 0xe2, 0x8c, 0x6d, 0xdf, 0x99, 0x8f, 0xa6, + 0x0e, 0xfc, 0xd7, 0x2f, 0x06, 0xd4, 0xb3, 0x95, 0xf6, 0xf2, 0x56, 0x71, 0x96, 0xb7, 0x8a, 0xd9, 0x5a, 0xde, 0x2a, + 0xd8, 0x34, 0x3a, 0xd5, 0x18, 0x56, 0x4b, 0x37, 0x6c, 0x05, 0x0a, 0xe1, 0x8f, 0x5d, 0x7a, 0xe5, 0x1c, 0xc0, 0x3b, + 0xf8, 0xaa, 0xb3, 0xf9, 0xae, 0xb5, 0xfd, 0xa8, 0xd3, 0x59, 0x12, 0x48, 0x5b, 0xb7, 0x52, 0x6f, 0x34, 0x02, 0x51, + 0x66, 0x34, 0x5e, 0x25, 0xff, 0xe0, 0xf0, 0xf3, 0x49, 0xdc, 0x8a, 0x08, 0x2a, 0xed, 0x88, 0x4f, 0x41, 0x51, 0x78, + 0xcd, 0x44, 0x0b, 0xeb, 0x7c, 0x9d, 0x7a, 0x94, 0x92, 0xb1, 0x65, 0x1d, 0xd4, 0x6c, 0xf2, 0xfa, 0x89, 0xfe, 0xdd, + 0x56, 0xa9, 0x19, 0xc5, 0x7c, 0xc6, 0xb4, 0x6c, 0x9d, 0x8e, 0x87, 0xcf, 0x06, 0x5f, 0x4d, 0xbb, 0x5b, 0x0f, 0xee, + 0x85, 0xe8, 0xe9, 0x52, 0x10, 0x15, 0x4e, 0xb7, 0x78, 0x00, 0x90, 0xed, 0xad, 0x36, 0xed, 0x91, 0x8d, 0x56, 0xb7, + 0x10, 0x84, 0xa2, 0xee, 0x8e, 0x58, 0xfe, 0xd1, 0x8b, 0x03, 0xf8, 0x8f, 0xb8, 0xfa, 0xbf, 0xa6, 0x75, 0x8c, 0xfa, + 0xeb, 0xb4, 0xc4, 0xa8, 0x13, 0xab, 0x84, 0x8c, 0xf8, 0xee, 0xf5, 0xa7, 0xd3, 0x87, 0x7d, 0xb0, 0x73, 0x6d, 0xf2, + 0x47, 0xab, 0xd6, 0x7e, 0x19, 0x45, 0x01, 0xf3, 0xc2, 0xcd, 0xea, 0x62, 0x7a, 0x28, 0xb8, 0x40, 0xea, 0xc2, 0x47, + 0xe2, 0x1e, 0x41, 0xae, 0x10, 0x2a, 0x7e, 0x43, 0x57, 0x89, 0xb3, 0xa6, 0xab, 0xc4, 0xbb, 0xfb, 0xaf, 0x12, 0x3f, + 0xec, 0x74, 0x95, 0x78, 0xf7, 0xc5, 0xaf, 0x12, 0x67, 0x9b, 0x57, 0x89, 0xb3, 0x48, 0x38, 0x21, 0x1b, 0x6f, 0x56, + 0xfc, 0xe7, 0x07, 0xb2, 0xf7, 0x7d, 0x17, 0xb9, 0x1d, 0x9b, 0xd2, 0x2c, 0x9e, 0xff, 0xe6, 0x8b, 0x05, 0x6e, 0xc4, + 0x77, 0xe8, 0x93, 0x57, 0x5c, 0x2d, 0x38, 0x66, 0xc7, 0x7e, 0xa4, 0xe2, 0x20, 0x0a, 0x67, 0x3f, 0x83, 0xbd, 0x37, + 0x88, 0x03, 0x63, 0xe9, 0x85, 0x9f, 0xfc, 0x1c, 0x2d, 0x57, 0x4b, 0x54, 0x54, 0x7d, 0xf0, 0x13, 0x7f, 0x14, 0xb0, + 0x3c, 0xae, 0x25, 0x69, 0x5d, 0xb9, 0x6c, 0x1d, 0x14, 0xaf, 0xe2, 0xa7, 0x77, 0x2b, 0x7e, 0xa2, 0x63, 0x2f, 0xff, + 0x4d, 0xce, 0x89, 0x6a, 0xfd, 0x45, 0x44, 0x58, 0x88, 0x49, 0x40, 0x3f, 0xfc, 0x32, 0x72, 0x26, 0x22, 0x88, 0x95, + 0x46, 0x29, 0xdc, 0x37, 0x1a, 0xdb, 0x61, 0xd5, 0x76, 0xde, 0xac, 0x74, 0x23, 0x4f, 0xfb, 0xb1, 0x29, 0xce, 0x5f, + 0x44, 0xab, 0x84, 0x4d, 0xa2, 0x9b, 0x50, 0x35, 0x42, 0xae, 0x57, 0x8d, 0x50, 0xa6, 0x9e, 0x7f, 0x53, 0x56, 0x38, + 0xaa, 0xd6, 0x12, 0xe6, 0xd0, 0x24, 0x0d, 0xb6, 0x89, 0x43, 0x54, 0x45, 0xa0, 0xa8, 0xfe, 0x9e, 0xa6, 0x45, 0xee, + 0xc3, 0xbe, 0x14, 0x9e, 0x27, 0x91, 0xc5, 0xa5, 0xc2, 0x89, 0x16, 0x0a, 0xe1, 0xa2, 0x88, 0xbd, 0x5d, 0xb3, 0x70, + 0xfc, 0x0d, 0xc5, 0xa5, 0x2c, 0xde, 0x82, 0xae, 0x2a, 0x5b, 0xf1, 0xf5, 0xe0, 0x91, 0xa8, 0xe9, 0xf1, 0x95, 0x34, + 0x8d, 0x6f, 0xaf, 0x59, 0x1c, 0x78, 0x77, 0x9a, 0x9e, 0x45, 0xe1, 0x8f, 0x30, 0x01, 0xaf, 0xa3, 0x9b, 0x50, 0xae, + 0x80, 0x09, 0xe2, 0x6b, 0xf6, 0x52, 0x6d, 0xcc, 0x74, 0x88, 0x14, 0x22, 0x41, 0xe0, 0x5b, 0x4b, 0x6f, 0xc6, 0xfe, + 0xcb, 0xa0, 0x7f, 0xff, 0x5b, 0xcf, 0x8c, 0x77, 0x51, 0xde, 0xd1, 0x2f, 0xcb, 0x1d, 0xba, 0x79, 0xf2, 0x64, 0xaf, + 0x79, 0xd8, 0xda, 0x38, 0x60, 0x5e, 0x2c, 0xa0, 0xa8, 0xf9, 0x5a, 0x6f, 0x3c, 0x05, 0x00, 0xc5, 0x79, 0xb4, 0x1a, + 0xcf, 0xd1, 0x5b, 0xf8, 0xcb, 0x8d, 0x37, 0x85, 0x36, 0x59, 0x72, 0x61, 0x5f, 0xe6, 0x43, 0xaf, 0x14, 0x15, 0xb3, + 0x80, 0xfd, 0x9f, 0x42, 0xd2, 0xaf, 0x7f, 0xe3, 0x34, 0x6c, 0xee, 0x9a, 0x3c, 0xd0, 0xd8, 0x83, 0x36, 0x6f, 0xdf, + 0x87, 0x58, 0x40, 0x14, 0x4e, 0x5b, 0x28, 0xe9, 0xea, 0x91, 0x4c, 0x56, 0x9d, 0x34, 0x39, 0x75, 0x4d, 0x53, 0x56, + 0x1e, 0xd1, 0x0b, 0xb3, 0x4a, 0x56, 0x23, 0x06, 0xe3, 0xd8, 0xaa, 0x82, 0x64, 0xb8, 0x37, 0x05, 0x43, 0xf4, 0x55, + 0x7d, 0xb7, 0xf0, 0x43, 0x03, 0x33, 0xcf, 0x6e, 0xbe, 0xf1, 0x6e, 0x21, 0xf7, 0x22, 0x20, 0xb7, 0xea, 0x2b, 0x28, + 0x34, 0xe4, 0x18, 0x45, 0xde, 0x64, 0xa2, 0xa9, 0xb5, 0x33, 0x21, 0xb4, 0x81, 0xc3, 0xaf, 0x14, 0x45, 0x51, 0xf2, + 0x6b, 0x84, 0x92, 0xdf, 0x23, 0xb0, 0x1c, 0xaf, 0x03, 0xa0, 0x2d, 0xc9, 0x96, 0xb7, 0x54, 0x02, 0x37, 0x03, 0xb4, + 0x9f, 0x16, 0x05, 0x3c, 0xbd, 0x10, 0x18, 0xb7, 0x50, 0x81, 0xb8, 0xd0, 0x83, 0xea, 0xdb, 0x8b, 0x21, 0x0b, 0x61, + 0x4f, 0xc1, 0x0b, 0x3b, 0xbe, 0xe5, 0x92, 0x60, 0xc5, 0xa6, 0xc7, 0x61, 0x9f, 0xd5, 0xe7, 0xa1, 0x09, 0x25, 0x2c, + 0x08, 0x5a, 0x87, 0x4a, 0x5a, 0x49, 0x83, 0xd5, 0xe0, 0x46, 0xbc, 0x17, 0xdd, 0xa6, 0x0b, 0x16, 0xae, 0x54, 0x03, + 0xac, 0x4e, 0x30, 0x2f, 0x10, 0xd4, 0x79, 0x4d, 0xcc, 0x16, 0x60, 0x9b, 0xfa, 0x2f, 0xe7, 0x44, 0x0b, 0x85, 0xa9, + 0x8a, 0x67, 0x8c, 0x79, 0xd8, 0x9d, 0x84, 0xe3, 0xb6, 0x2a, 0x85, 0xe0, 0x4b, 0x1a, 0x95, 0xb1, 0x39, 0x0f, 0xb4, + 0x85, 0x9c, 0x02, 0xd9, 0x88, 0x71, 0x71, 0x91, 0x98, 0x76, 0xcd, 0xab, 0x2e, 0x5a, 0xae, 0x91, 0xf1, 0x2a, 0x82, + 0xa2, 0x58, 0xdf, 0x6c, 0x86, 0xc3, 0x09, 0xc9, 0x10, 0x1a, 0xdb, 0x19, 0x6f, 0xb4, 0xd3, 0x30, 0xe8, 0x8f, 0xec, + 0x8e, 0x08, 0x09, 0x4d, 0xd5, 0x47, 0x76, 0x07, 0xc6, 0xe1, 0xa7, 0x20, 0x4d, 0x51, 0xb7, 0xa0, 0x6b, 0x03, 0xd2, + 0x0b, 0x8f, 0x21, 0x41, 0xc6, 0x96, 0x03, 0x64, 0x67, 0x5b, 0xb0, 0x38, 0x05, 0x41, 0x35, 0x92, 0xbe, 0x38, 0xc4, + 0x3c, 0x4e, 0x82, 0x56, 0x3b, 0xc7, 0x66, 0xcd, 0xd1, 0xd0, 0x9f, 0x39, 0xb6, 0xbd, 0xbf, 0x51, 0x1f, 0x04, 0xd9, + 0x75, 0xb5, 0x75, 0x23, 0x75, 0x1d, 0xdb, 0xf4, 0x9f, 0x59, 0xad, 0xfe, 0x06, 0x8d, 0x96, 0xf2, 0x57, 0x0d, 0x51, + 0xfc, 0x35, 0x78, 0xbc, 0xd6, 0x36, 0x0e, 0xa4, 0x5e, 0x8d, 0x3b, 0x80, 0xb0, 0x65, 0x5c, 0xfe, 0x35, 0xdc, 0x24, + 0xfd, 0x94, 0x3d, 0x8b, 0x72, 0xa9, 0x0f, 0x21, 0x03, 0xa3, 0x06, 0xc7, 0xe8, 0x4f, 0xca, 0x73, 0x45, 0xa3, 0xe3, + 0xa3, 0xeb, 0xc3, 0xbe, 0xc0, 0x28, 0x22, 0x30, 0x8f, 0xdc, 0x40, 0xa5, 0xc7, 0xa4, 0x8a, 0xe1, 0x78, 0xae, 0x37, + 0x56, 0x68, 0xf4, 0xb6, 0x72, 0x0b, 0xd8, 0x7e, 0x03, 0xf9, 0xb4, 0x46, 0x10, 0x59, 0x12, 0x6a, 0x40, 0xbe, 0xd6, + 0x7b, 0x1b, 0x5c, 0x2d, 0xcb, 0xcd, 0x95, 0x89, 0xe4, 0xee, 0x8d, 0x21, 0xd1, 0x41, 0x1d, 0x5a, 0xde, 0x5e, 0x3d, + 0xb9, 0x7b, 0x60, 0x93, 0x2c, 0x9c, 0x94, 0x1b, 0xac, 0xd0, 0xaf, 0xdd, 0x9b, 0x2b, 0x61, 0x14, 0x48, 0x64, 0x1c, + 0xd5, 0x60, 0x94, 0x2c, 0x0a, 0x71, 0xf3, 0xd3, 0x71, 0xf3, 0x77, 0xe2, 0x62, 0xf0, 0x03, 0xca, 0x42, 0x92, 0x7f, + 0x26, 0x09, 0xc5, 0x21, 0x5b, 0x26, 0xc6, 0xed, 0xd2, 0x04, 0x23, 0xda, 0xb8, 0x13, 0x53, 0xe1, 0xae, 0x58, 0x7c, + 0xe3, 0xf3, 0xfc, 0x57, 0xbb, 0x4a, 0xad, 0xfd, 0xfb, 0xa5, 0xd6, 0xe9, 0x7d, 0x52, 0x6b, 0x8a, 0x49, 0xc3, 0xed, + 0x41, 0x45, 0x6c, 0x1e, 0xc1, 0x9c, 0xcb, 0xd1, 0x8d, 0x4a, 0xa2, 0x6e, 0x0c, 0x61, 0x53, 0x63, 0x45, 0x4a, 0xad, + 0x91, 0x03, 0x22, 0x8a, 0xbf, 0xa5, 0x0b, 0x8a, 0x50, 0xa8, 0xcb, 0xb2, 0xf1, 0xb3, 0x42, 0x36, 0x4e, 0xb7, 0x9a, + 0x22, 0x1a, 0x89, 0xe0, 0xfe, 0xa5, 0x48, 0x3f, 0xf9, 0xed, 0xa0, 0x88, 0xf8, 0x53, 0x40, 0x2a, 0xc5, 0xb0, 0x29, + 0x2e, 0x1a, 0x52, 0x64, 0x24, 0x71, 0xcb, 0x28, 0x07, 0x48, 0x2a, 0x57, 0x2d, 0x42, 0xd8, 0x14, 0xe5, 0x20, 0x75, + 0x47, 0x90, 0xf3, 0x62, 0x79, 0xdb, 0x94, 0x63, 0x98, 0xc8, 0xaf, 0xa5, 0x4d, 0x92, 0x07, 0x1b, 0xa1, 0x09, 0x16, + 0x62, 0xfa, 0x8a, 0x5e, 0x3b, 0xb7, 0x81, 0x40, 0x20, 0x6b, 0x62, 0x23, 0xdd, 0x2f, 0x9d, 0xa7, 0x1c, 0xcd, 0x85, + 0xea, 0xda, 0x41, 0xea, 0x4e, 0x9a, 0x60, 0x59, 0x1e, 0x81, 0x73, 0x7d, 0x29, 0x49, 0x10, 0x7a, 0xb6, 0x62, 0xf7, + 0x6b, 0x18, 0x00, 0xa4, 0xff, 0xd5, 0x67, 0xce, 0x0a, 0x80, 0x24, 0x52, 0xb1, 0x65, 0x9d, 0x3f, 0x1e, 0x62, 0x93, + 0x2c, 0xd9, 0xb1, 0xea, 0x66, 0x9f, 0x24, 0xef, 0x59, 0xf3, 0x48, 0x24, 0x65, 0x71, 0x3e, 0xaf, 0xd1, 0x13, 0x70, + 0xf0, 0x5d, 0x16, 0xaf, 0x42, 0x4c, 0xbd, 0x6b, 0xa6, 0xb1, 0x37, 0xfe, 0xb8, 0x96, 0xfa, 0xe3, 0x22, 0x51, 0x10, + 0x17, 0x97, 0x95, 0x0a, 0x7d, 0x0f, 0x33, 0x55, 0xb1, 0x9e, 0xd5, 0x4a, 0x24, 0x41, 0x4d, 0xef, 0x91, 0xdd, 0xf6, + 0x5e, 0x4c, 0x0f, 0x2a, 0xf2, 0xd3, 0x56, 0xa7, 0x2c, 0x5d, 0xcf, 0xe1, 0x58, 0x44, 0xbf, 0xf2, 0x98, 0x4d, 0x7f, + 0x7c, 0xd7, 0x09, 0xef, 0xb3, 0xb2, 0x46, 0x9f, 0x03, 0x02, 0x7c, 0x5f, 0x52, 0x4c, 0xcb, 0x6a, 0x9a, 0x8d, 0x92, + 0x26, 0xb0, 0xa6, 0x7e, 0x10, 0x98, 0x01, 0xb8, 0x31, 0xac, 0x3f, 0x6b, 0x78, 0xd8, 0xce, 0x0a, 0x72, 0x24, 0x7e, + 0x46, 0x3b, 0xe5, 0x9d, 0x92, 0xce, 0x57, 0x8b, 0xd1, 0x5a, 0x16, 0x94, 0x4b, 0xf2, 0xf3, 0x4d, 0x99, 0xb9, 0xdc, + 0xed, 0x74, 0x3a, 0x2d, 0x4b, 0x8d, 0x6d, 0xe5, 0x00, 0x25, 0xbf, 0x8f, 0x6c, 0xdb, 0xae, 0xce, 0x6f, 0xd3, 0x41, + 0xa1, 0x83, 0x61, 0xa2, 0x10, 0xbe, 0x7b, 0xff, 0x9e, 0xfa, 0x83, 0xa0, 0xa5, 0xa6, 0x9a, 0xce, 0x23, 0x6d, 0xb5, + 0xff, 0x08, 0x50, 0x10, 0x35, 0xdc, 0x77, 0xfc, 0x37, 0xf7, 0xca, 0x96, 0x96, 0xaa, 0x07, 0xf8, 0x61, 0x1f, 0xdf, + 0xb3, 0xd7, 0x77, 0xf8, 0xb4, 0x69, 0x7b, 0x67, 0x56, 0x41, 0x76, 0x4b, 0x36, 0x4b, 0x7d, 0xb2, 0x54, 0xf2, 0x53, + 0xb6, 0x48, 0x7a, 0x63, 0x86, 0x0a, 0x52, 0x4b, 0xa2, 0xb6, 0x68, 0xd5, 0x63, 0xce, 0xc0, 0x8e, 0xcb, 0x11, 0x78, + 0xd8, 0x56, 0x50, 0x59, 0xb5, 0xa1, 0x59, 0x13, 0x9d, 0x20, 0x15, 0x5b, 0x6f, 0x2a, 0x9c, 0x70, 0x9b, 0x76, 0xec, + 0x3f, 0x95, 0xea, 0x29, 0xc0, 0x9d, 0xae, 0x85, 0xb5, 0x09, 0x29, 0x4f, 0xf0, 0xef, 0x5c, 0x39, 0xf7, 0x62, 0x79, + 0x5b, 0x36, 0xee, 0xea, 0x82, 0xba, 0xa9, 0x20, 0x65, 0x04, 0x75, 0x1d, 0xea, 0xcb, 0x4d, 0x80, 0xa6, 0xb2, 0x75, + 0x0b, 0x58, 0xd0, 0x88, 0x29, 0xa8, 0xe8, 0x08, 0x73, 0x50, 0xf1, 0x3a, 0x0b, 0x3b, 0xaf, 0x90, 0xef, 0xe3, 0x2f, + 0xc8, 0x8d, 0x0e, 0x49, 0x56, 0xfe, 0x64, 0x3c, 0xef, 0xa2, 0x72, 0xaf, 0xb4, 0x55, 0xd1, 0x54, 0x06, 0xf7, 0x80, + 0xb8, 0x91, 0x2a, 0xab, 0x38, 0x30, 0x97, 0x31, 0x9b, 0xfa, 0xb7, 0x9a, 0xbe, 0xde, 0x1c, 0x77, 0x73, 0xf3, 0x4e, + 0x07, 0xf4, 0x1a, 0x9b, 0x53, 0xb5, 0x93, 0x6a, 0xaf, 0xaa, 0xc3, 0x16, 0x70, 0xc2, 0x0a, 0x80, 0xcf, 0xac, 0x82, + 0x46, 0x43, 0x4a, 0x05, 0xf7, 0xd1, 0xa0, 0xf3, 0xb7, 0x32, 0xb2, 0x16, 0xe3, 0xc4, 0xe6, 0xea, 0xab, 0x50, 0xdb, + 0x42, 0x33, 0x08, 0x73, 0xc7, 0xb1, 0x13, 0x3e, 0x9b, 0xb0, 0x63, 0x64, 0x74, 0xe5, 0xe0, 0x0e, 0xc2, 0x53, 0x6a, + 0x52, 0xca, 0x15, 0x3a, 0xa5, 0xa8, 0x4b, 0xf8, 0xa1, 0x56, 0x78, 0x7f, 0x5e, 0x92, 0xc6, 0xf3, 0xa0, 0x13, 0x2d, + 0x7d, 0xa7, 0xda, 0x0b, 0x3f, 0xdc, 0xbd, 0xae, 0x77, 0xbb, 0x73, 0x5d, 0x60, 0x0e, 0x77, 0xae, 0x0c, 0xdc, 0x25, + 0x56, 0x3e, 0x4f, 0xdd, 0x1f, 0x24, 0xe5, 0x81, 0x1c, 0xa6, 0x51, 0xc5, 0xaf, 0xe8, 0x46, 0xff, 0xd3, 0xca, 0x1d, + 0x1e, 0x9f, 0xdc, 0x2e, 0x02, 0xe5, 0x9a, 0xc5, 0x09, 0xa4, 0xb1, 0x50, 0x1d, 0xcb, 0x56, 0x15, 0x34, 0xe8, 0xf7, + 0xc3, 0x99, 0xab, 0xfe, 0x72, 0xfe, 0xc6, 0xec, 0xaa, 0x27, 0x60, 0x8e, 0x71, 0x3d, 0x43, 0x16, 0xf7, 0xcc, 0xbb, + 0x63, 0xf1, 0x55, 0x8b, 0x7b, 0xfc, 0x10, 0x73, 0x8b, 0x65, 0x4a, 0x4b, 0xdd, 0x21, 0x11, 0xbd, 0x72, 0xed, 0xb3, + 0x9b, 0x97, 0xd1, 0xad, 0xab, 0x02, 0x62, 0x75, 0x5a, 0x5d, 0xc5, 0x69, 0x1d, 0x58, 0x87, 0x5d, 0x75, 0xf0, 0x95, + 0xa2, 0x1c, 0x4f, 0xd8, 0x34, 0x19, 0xa0, 0x38, 0xe6, 0x18, 0xf9, 0x41, 0xfa, 0xad, 0x28, 0xd6, 0x38, 0x48, 0x4c, + 0x47, 0x59, 0xf3, 0x47, 0x45, 0x01, 0x64, 0xd4, 0x53, 0x1e, 0x4d, 0x5b, 0xd3, 0x83, 0xe9, 0x8b, 0x3e, 0x2f, 0xce, + 0xbe, 0x2a, 0x55, 0x37, 0xe8, 0xdf, 0x96, 0xf4, 0x59, 0x92, 0xc6, 0xd1, 0x47, 0xc6, 0x79, 0x49, 0x25, 0x17, 0x14, + 0x55, 0x3f, 0x6d, 0x6d, 0xf6, 0xe4, 0x74, 0x47, 0xe3, 0x69, 0xab, 0xa8, 0x8e, 0x30, 0xee, 0xe7, 0x40, 0x1e, 0xef, + 0x0b, 0xd0, 0x8f, 0xe5, 0x69, 0x72, 0xcc, 0xba, 0x89, 0x72, 0x54, 0x3e, 0xc6, 0x99, 0x18, 0xdf, 0x31, 0xe4, 0x79, + 0x2b, 0xbc, 0x17, 0x13, 0xfc, 0xcc, 0x55, 0x7f, 0x74, 0x5a, 0x5d, 0xc3, 0x71, 0x0e, 0xad, 0xc3, 0xee, 0xd8, 0x36, + 0x0e, 0xac, 0x03, 0xb3, 0x6d, 0x1d, 0x1a, 0x5d, 0xb3, 0x6b, 0x74, 0xbf, 0xeb, 0x8e, 0xcd, 0x03, 0xeb, 0xc0, 0xb0, + 0xcd, 0x2e, 0x14, 0x9a, 0x5d, 0xb3, 0x7b, 0x6d, 0x1e, 0x74, 0xc7, 0x36, 0x96, 0xb6, 0xac, 0x4e, 0xc7, 0x74, 0x6c, + 0xab, 0xd3, 0x31, 0x3a, 0xd6, 0xe1, 0xa1, 0xe9, 0xb4, 0xad, 0xc3, 0xc3, 0xb3, 0x4e, 0xd7, 0x6a, 0xc3, 0xbb, 0x76, + 0x7b, 0xdc, 0xb6, 0x1c, 0xc7, 0x84, 0xbf, 0x8c, 0xae, 0xd5, 0xa2, 0x1f, 0x8e, 0x63, 0xb5, 0x1d, 0xc3, 0x0e, 0x3a, + 0x2d, 0xeb, 0xf0, 0x85, 0x81, 0x7f, 0x63, 0x35, 0x03, 0xff, 0x82, 0x66, 0x8c, 0x17, 0x56, 0xeb, 0x90, 0x7e, 0x61, + 0x83, 0xd7, 0x07, 0xdd, 0xbf, 0xaa, 0xfb, 0x8d, 0x63, 0x70, 0x68, 0x0c, 0xdd, 0x8e, 0xd5, 0x6e, 0x1b, 0x07, 0x8e, + 0xd5, 0x6d, 0xcf, 0xcd, 0x83, 0x96, 0x75, 0x78, 0x34, 0x36, 0x1d, 0xeb, 0xe8, 0xc8, 0xb0, 0xcd, 0xb6, 0xd5, 0x32, + 0x1c, 0xeb, 0xa0, 0x8d, 0x3f, 0xda, 0x56, 0xeb, 0xfa, 0xe8, 0x85, 0x75, 0xd8, 0x99, 0x1f, 0x5a, 0x07, 0x1f, 0x0e, + 0xba, 0x56, 0xab, 0x3d, 0x6f, 0x1f, 0x5a, 0xad, 0xa3, 0xeb, 0x43, 0xeb, 0x60, 0x6e, 0xb6, 0x0e, 0xb7, 0x7e, 0xe9, + 0xb4, 0x2c, 0x98, 0x23, 0x7c, 0x0d, 0x2f, 0x0c, 0xfe, 0x02, 0xfe, 0xcc, 0xf1, 0xdb, 0x3f, 0xb0, 0x99, 0x64, 0xf3, + 0xd3, 0x17, 0x56, 0xf7, 0x68, 0x4c, 0xd5, 0xa1, 0xc0, 0x14, 0x35, 0xe0, 0x93, 0x6b, 0x93, 0xba, 0xc5, 0xe6, 0x4c, + 0xd1, 0x90, 0xf8, 0xc3, 0x3b, 0xbb, 0x36, 0xa1, 0x63, 0xea, 0xf7, 0xdf, 0xda, 0x4e, 0xbe, 0xe4, 0xc7, 0xfb, 0x33, + 0xda, 0xfa, 0xb3, 0xc1, 0x57, 0xc7, 0x70, 0xb8, 0x07, 0x43, 0xe3, 0xd7, 0x26, 0xa5, 0xe4, 0xdf, 0xef, 0x57, 0x4a, + 0xbe, 0x5c, 0xed, 0xa2, 0x94, 0xfc, 0xfb, 0x17, 0x57, 0x4a, 0xfe, 0x5a, 0xf5, 0xad, 0x79, 0x53, 0xcd, 0x7d, 0xfd, + 0xc3, 0xba, 0x2a, 0x72, 0x48, 0x3c, 0xed, 0xe2, 0xa7, 0xd5, 0x25, 0x44, 0xad, 0x7f, 0x13, 0xb9, 0x2f, 0x57, 0x25, + 0x83, 0xcf, 0x08, 0x70, 0xec, 0x9b, 0x88, 0x70, 0xec, 0x87, 0x95, 0x0b, 0x56, 0x66, 0x9c, 0xcd, 0xf1, 0x27, 0xe6, + 0xdc, 0x0b, 0xa6, 0x39, 0x8b, 0x04, 0x25, 0x7d, 0x2c, 0x06, 0xbf, 0x79, 0x20, 0xcf, 0x70, 0x93, 0x59, 0x2d, 0xc2, + 0x04, 0x2c, 0x82, 0xc1, 0x92, 0x63, 0x1a, 0x67, 0x95, 0x8f, 0x2d, 0x11, 0xe7, 0xff, 0x8a, 0x7b, 0x14, 0x37, 0xbe, + 0x47, 0x03, 0xe0, 0xfa, 0xd6, 0x9d, 0xcd, 0x76, 0x15, 0xb0, 0xac, 0x13, 0x06, 0xd2, 0xc0, 0xed, 0xd7, 0xbd, 0x2f, + 0x9b, 0xe1, 0x56, 0x0c, 0xaf, 0x9b, 0x21, 0x05, 0x48, 0xaa, 0xdf, 0x3b, 0x65, 0x33, 0xde, 0xfb, 0x86, 0x59, 0xd3, + 0x7d, 0xe9, 0xf3, 0x2d, 0x36, 0xc4, 0x79, 0xc3, 0xd5, 0xa9, 0x5a, 0x97, 0xf8, 0xb4, 0xfa, 0x09, 0x29, 0x2e, 0xa8, + 0x85, 0xa1, 0x71, 0xc1, 0xa9, 0xda, 0x0a, 0xf2, 0x3b, 0xb6, 0xf4, 0xae, 0xd4, 0xa6, 0x6c, 0x9c, 0xfc, 0x6c, 0x8d, + 0xf7, 0x0a, 0xff, 0x57, 0xe0, 0x44, 0x39, 0xc7, 0x33, 0x8a, 0xe4, 0x79, 0x5e, 0x4b, 0xed, 0x92, 0x34, 0x22, 0x9b, + 0x3b, 0xeb, 0x4d, 0x5e, 0xb4, 0xd1, 0x2d, 0xc1, 0x61, 0x0b, 0xc1, 0x05, 0x61, 0xf7, 0xe4, 0x04, 0x90, 0x91, 0xa3, + 0x06, 0xfa, 0x39, 0x6c, 0x6b, 0x4c, 0xd4, 0x7b, 0x04, 0x9b, 0x98, 0x7b, 0x02, 0x2a, 0x72, 0x20, 0xd5, 0xf5, 0x34, + 0x88, 0xbc, 0xb4, 0x87, 0x6c, 0x9a, 0xc4, 0xf2, 0xb6, 0xd0, 0x63, 0xa1, 0xbf, 0xc5, 0x98, 0x4e, 0x6e, 0x98, 0x37, + 0x82, 0x9e, 0x0f, 0xdb, 0xec, 0xef, 0x72, 0x87, 0xb3, 0x75, 0xc9, 0x1c, 0xc5, 0xe9, 0x1c, 0x19, 0xce, 0xa1, 0x61, + 0x1d, 0x75, 0xf4, 0x4c, 0x1c, 0x38, 0xb9, 0xc9, 0xd2, 0x84, 0x80, 0x03, 0x44, 0x0e, 0xa6, 0x1f, 0xfa, 0xa9, 0xef, + 0x05, 0x19, 0xf0, 0xc3, 0xe5, 0x4b, 0xca, 0xdf, 0x57, 0x49, 0x0a, 0x63, 0x14, 0x4c, 0x2f, 0x3a, 0x7f, 0x98, 0x23, + 0x96, 0xde, 0x30, 0x16, 0x36, 0x18, 0xc6, 0x54, 0x7d, 0x49, 0x7e, 0x3f, 0xcb, 0xfa, 0x8c, 0xac, 0xd6, 0x46, 0x69, + 0xc8, 0xf7, 0x87, 0x70, 0x7c, 0xc8, 0x86, 0xc6, 0x77, 0x4d, 0x08, 0xf7, 0x97, 0xfb, 0x11, 0x6e, 0xca, 0x76, 0x41, + 0xb8, 0xbf, 0x7c, 0x71, 0x84, 0xfb, 0x9d, 0x8c, 0x70, 0x4b, 0xfe, 0x83, 0x85, 0x86, 0xe9, 0x3d, 0x3e, 0x6b, 0xe0, + 0x22, 0xfb, 0x5c, 0xdd, 0x27, 0x06, 0x5e, 0xd5, 0x8b, 0x9c, 0xb9, 0x7f, 0x59, 0xc9, 0x16, 0xd4, 0x28, 0x00, 0xc5, + 0x6c, 0x92, 0x3e, 0xba, 0x2e, 0xfb, 0xe0, 0xea, 0x26, 0xc2, 0x30, 0x40, 0x9b, 0xdf, 0x87, 0x69, 0x60, 0xbd, 0xe3, + 0xf7, 0x48, 0x50, 0xe8, 0xbe, 0x89, 0xe2, 0x85, 0x87, 0x89, 0x4d, 0x54, 0x1d, 0xdc, 0xe9, 0xe0, 0xc1, 0x86, 0x40, + 0x20, 0xe3, 0x28, 0x9c, 0xe4, 0x5a, 0x49, 0xe6, 0x5e, 0x10, 0xc7, 0xad, 0xde, 0x31, 0x2f, 0x56, 0x0d, 0x7a, 0x0d, + 0x8b, 0xfb, 0xac, 0x6d, 0x3f, 0x6b, 0x1d, 0x3c, 0x3b, 0xb4, 0xe1, 0x7f, 0x87, 0xb5, 0x33, 0x83, 0x57, 0x5c, 0x44, + 0x61, 0x3a, 0x2f, 0x6a, 0x36, 0x55, 0xbb, 0x61, 0xec, 0x63, 0x51, 0xeb, 0xa8, 0xbe, 0xd2, 0xc4, 0xbb, 0x2b, 0xea, + 0xd4, 0xd6, 0x98, 0x47, 0x2b, 0x09, 0xac, 0x1a, 0x68, 0xfc, 0x70, 0x05, 0x72, 0x76, 0xa9, 0x86, 0xfc, 0x9a, 0x0f, + 0xb7, 0x18, 0x17, 0x6b, 0x67, 0x97, 0x22, 0x73, 0x83, 0xda, 0x17, 0xc9, 0xfc, 0xee, 0x9d, 0x41, 0xae, 0xa2, 0xb4, + 0x31, 0xd3, 0x15, 0xe6, 0x53, 0x84, 0x3c, 0x57, 0x4c, 0x2c, 0x90, 0x47, 0x0b, 0x94, 0xc6, 0xab, 0x70, 0xac, 0xe1, + 0x4f, 0x6f, 0x94, 0x68, 0xfe, 0x7e, 0x6c, 0xf1, 0x8e, 0x75, 0x5c, 0x35, 0x6f, 0x60, 0x17, 0xa9, 0xee, 0x13, 0xb1, + 0x2a, 0xde, 0xb3, 0xd4, 0x88, 0x51, 0x8f, 0x4d, 0x4b, 0x6b, 0xba, 0xde, 0xb3, 0xfc, 0xc3, 0x67, 0xa9, 0x11, 0x3e, + 0x07, 0xdd, 0xa7, 0x6b, 0x3f, 0x79, 0x42, 0xb5, 0xf6, 0x5c, 0x31, 0xac, 0x93, 0x71, 0x91, 0x0f, 0x43, 0xf1, 0x66, + 0x11, 0xa5, 0xc4, 0xe8, 0x8d, 0x8d, 0xe8, 0xf9, 0xf3, 0x81, 0xeb, 0xe8, 0xa3, 0x98, 0x79, 0x1f, 0x33, 0x11, 0x64, + 0x3c, 0xc4, 0xac, 0xb8, 0x67, 0xbb, 0x19, 0x1a, 0xe9, 0xb5, 0xae, 0xb4, 0x4b, 0xb8, 0x33, 0xd9, 0xc2, 0x1d, 0x81, + 0x63, 0x2f, 0x77, 0x8f, 0x97, 0x80, 0x2b, 0x13, 0x19, 0xfc, 0x88, 0x3a, 0x57, 0x73, 0x2f, 0xf9, 0x21, 0x89, 0xc2, + 0x5f, 0x96, 0x10, 0x72, 0xb9, 0xb0, 0x28, 0x12, 0x97, 0xb1, 0xb6, 0x65, 0x5b, 0xb6, 0x9a, 0xb7, 0x37, 0xf5, 0x67, + 0xee, 0x3a, 0x4a, 0xbd, 0xde, 0x9e, 0x63, 0x04, 0xd1, 0x0c, 0xdc, 0xeb, 0x52, 0x3f, 0x0d, 0x58, 0x4f, 0x55, 0xc1, + 0xcf, 0x6e, 0x41, 0xd7, 0xf5, 0x8c, 0x3b, 0x3d, 0x78, 0x31, 0xe4, 0x50, 0x8f, 0xef, 0x84, 0x87, 0x2e, 0x46, 0x6e, + 0xff, 0x11, 0x68, 0xa4, 0xa6, 0x6a, 0x20, 0x32, 0x60, 0x71, 0x62, 0xca, 0x4e, 0x44, 0x3d, 0x05, 0xbe, 0xd1, 0x55, + 0x3e, 0xb6, 0x69, 0xec, 0x2d, 0x20, 0xc9, 0xef, 0x3a, 0x33, 0x38, 0x02, 0x56, 0x39, 0x06, 0x56, 0x9c, 0x17, 0x87, + 0x86, 0xd2, 0x72, 0x0c, 0xc5, 0x06, 0x2c, 0xac, 0x66, 0xc6, 0x3a, 0xbb, 0xec, 0xdf, 0x67, 0x07, 0x41, 0x68, 0xe7, + 0x11, 0x8d, 0x83, 0x2c, 0x20, 0xb8, 0x86, 0x29, 0xa5, 0x8c, 0x3d, 0x9a, 0x94, 0xce, 0xd3, 0x27, 0x5d, 0xe8, 0x39, + 0xbb, 0x4d, 0x75, 0x50, 0x28, 0x89, 0x2a, 0xbe, 0xbe, 0x46, 0x3f, 0x62, 0x3f, 0x54, 0xfc, 0x4f, 0x9f, 0x34, 0x1f, + 0x7c, 0x9c, 0x5c, 0x69, 0x7e, 0xe0, 0x59, 0x2f, 0x4d, 0x98, 0x5f, 0x68, 0xef, 0x71, 0xb2, 0xc0, 0x01, 0x11, 0xfe, + 0x2d, 0x8a, 0xc5, 0x0f, 0x6e, 0x3d, 0x61, 0x05, 0x5e, 0x38, 0x03, 0x4c, 0xe7, 0x85, 0xb3, 0x0d, 0x2b, 0x2d, 0x72, + 0x85, 0xae, 0x94, 0x16, 0x4d, 0x15, 0x16, 0x54, 0xc9, 0xcb, 0xbb, 0x73, 0x6f, 0xf6, 0x93, 0xb7, 0x60, 0x9a, 0x0a, + 0xc4, 0x0f, 0x3d, 0x77, 0x0b, 0x05, 0xef, 0x73, 0xf7, 0xe9, 0xf1, 0x82, 0xa5, 0x1e, 0x69, 0x87, 0xe0, 0x4e, 0x0c, + 0x5c, 0x82, 0xc2, 0xe9, 0x0f, 0x8f, 0x83, 0xe1, 0x52, 0x62, 0x2f, 0x22, 0x1f, 0x86, 0xc2, 0xc9, 0x97, 0x89, 0x86, + 0xa0, 0xae, 0x63, 0x90, 0x1f, 0xc2, 0xd8, 0xc3, 0xe4, 0x3e, 0x6e, 0x18, 0xa9, 0x83, 0xa7, 0xb9, 0xcb, 0x66, 0xd3, + 0x22, 0x04, 0x7e, 0xf8, 0xf1, 0x22, 0x66, 0xc1, 0x3f, 0xdc, 0xa7, 0x40, 0xcf, 0x9f, 0x5e, 0xaa, 0x7a, 0x3f, 0xb5, + 0xe6, 0x31, 0x9b, 0xba, 0x4f, 0xe1, 0x9e, 0xda, 0x43, 0xab, 0x59, 0x60, 0xe6, 0x9f, 0xdf, 0x2e, 0x02, 0x03, 0x6f, + 0xfd, 0x04, 0x8b, 0xda, 0x6e, 0x15, 0x41, 0xd6, 0xdb, 0x3b, 0xdd, 0xf5, 0x07, 0xfc, 0x12, 0x0f, 0x17, 0xc3, 0x75, + 0xe9, 0xea, 0xed, 0xf4, 0xf1, 0x5a, 0x3d, 0x0a, 0xbc, 0xf1, 0xc7, 0x3e, 0xbd, 0x29, 0x3d, 0x98, 0x40, 0xc4, 0xc7, + 0xde, 0xb2, 0x87, 0x54, 0x57, 0x2e, 0x04, 0xa7, 0x6a, 0x2a, 0xcd, 0x19, 0xbe, 0xda, 0xbd, 0x8c, 0x5b, 0x79, 0x8d, + 0x3d, 0x63, 0x57, 0x37, 0x73, 0x3f, 0x65, 0xa2, 0x2b, 0x7c, 0xc8, 0x32, 0x71, 0x7f, 0xa7, 0x9b, 0x2b, 0xde, 0xb7, + 0xad, 0xb6, 0xe2, 0x74, 0xbf, 0xeb, 0x5c, 0x3b, 0xf6, 0xbc, 0xe5, 0x58, 0xdd, 0x0f, 0x4e, 0x77, 0xde, 0xb6, 0x8e, + 0x02, 0xb3, 0x6d, 0x1d, 0xc1, 0x9f, 0x0f, 0x47, 0x56, 0x77, 0x6e, 0xb6, 0xac, 0x83, 0x0f, 0x4e, 0x2b, 0x30, 0xbb, + 0xd6, 0x11, 0xfc, 0x39, 0xa3, 0xaf, 0xe0, 0x5e, 0x44, 0xd7, 0xa0, 0xa7, 0x25, 0xe4, 0x20, 0xfd, 0xce, 0x55, 0xb5, + 0x46, 0x89, 0xea, 0xd5, 0xa8, 0x7b, 0x97, 0x18, 0x5c, 0x42, 0x24, 0xd3, 0xc1, 0xd0, 0x43, 0x5a, 0xe8, 0x32, 0x4a, + 0x72, 0x2b, 0x0c, 0xdf, 0x84, 0x87, 0x7a, 0x91, 0x75, 0x55, 0x3a, 0x41, 0xbc, 0x6e, 0x3f, 0xa1, 0xed, 0x2e, 0x85, + 0x95, 0xd3, 0x2a, 0xc7, 0xae, 0x21, 0xdf, 0xb2, 0x6e, 0x80, 0xea, 0x18, 0x10, 0x53, 0x11, 0x0e, 0x4a, 0xab, 0x45, + 0x5b, 0x02, 0x9b, 0x25, 0x2c, 0xa5, 0x22, 0x4d, 0x7c, 0x09, 0xc4, 0x46, 0xd7, 0x45, 0x9a, 0x64, 0x6c, 0x98, 0xd7, + 0x60, 0x3c, 0x9f, 0x73, 0xed, 0xab, 0x7e, 0x15, 0x5f, 0x82, 0x57, 0xbc, 0x15, 0x46, 0x37, 0x68, 0xf5, 0x71, 0xdf, + 0xdc, 0x61, 0x9c, 0x01, 0x26, 0x6c, 0xce, 0xca, 0xc0, 0xf2, 0xd0, 0xd9, 0xd5, 0x0e, 0x37, 0x10, 0xf4, 0x83, 0x3a, + 0x94, 0xd2, 0x83, 0x7f, 0x56, 0x3b, 0x3c, 0x8a, 0x85, 0x9c, 0xa2, 0x73, 0xe2, 0xc7, 0x39, 0x78, 0x12, 0x45, 0x71, + 0xea, 0xf3, 0xa3, 0xea, 0x06, 0xc5, 0x72, 0x62, 0xf1, 0xb5, 0x17, 0x48, 0x76, 0x77, 0xd2, 0x97, 0x7b, 0x39, 0xa1, + 0x7a, 0xf2, 0xa4, 0x00, 0xce, 0xac, 0xc0, 0x7d, 0xec, 0x74, 0x80, 0x4b, 0xe8, 0xb0, 0xf6, 0x56, 0x13, 0x50, 0xba, + 0x98, 0x6d, 0x73, 0x05, 0x2f, 0xd2, 0x41, 0x09, 0x33, 0x2f, 0x61, 0x60, 0xd2, 0x68, 0x87, 0xba, 0x61, 0x5e, 0x02, + 0xf9, 0xf4, 0x2a, 0x37, 0x33, 0x55, 0xef, 0x87, 0xc2, 0x5a, 0x22, 0xdc, 0x92, 0x09, 0x8f, 0x5f, 0x1d, 0x55, 0x98, + 0x9a, 0x2d, 0xe3, 0xb8, 0xc7, 0x9f, 0xfd, 0xdf, 0x3d, 0x08, 0xf4, 0x2d, 0x05, 0xf3, 0x8e, 0x0a, 0x16, 0x29, 0xf9, + 0x1a, 0xe6, 0xf4, 0x9e, 0x08, 0x3d, 0x4b, 0x4e, 0x54, 0x28, 0x52, 0x7b, 0x2a, 0xfa, 0xb1, 0xa9, 0xb9, 0x6d, 0x6b, + 0x4e, 0xc5, 0x8a, 0x02, 0xc3, 0xc7, 0xac, 0xa3, 0xc2, 0xcf, 0x55, 0x7f, 0xf2, 0xa4, 0x91, 0x38, 0x92, 0x2d, 0x51, + 0xc2, 0x52, 0x71, 0x9f, 0xd0, 0x54, 0x19, 0xef, 0xaa, 0x32, 0xea, 0xcb, 0xdb, 0x45, 0x6c, 0x26, 0x4c, 0x72, 0x69, + 0xef, 0xe1, 0xcf, 0x11, 0xf3, 0x52, 0x8b, 0xeb, 0x76, 0x35, 0x89, 0xe9, 0x30, 0x00, 0x6d, 0x64, 0x84, 0x42, 0xf2, + 0x61, 0x0e, 0x1f, 0xaf, 0xff, 0xb2, 0xe2, 0x41, 0x28, 0xa0, 0x8d, 0x4f, 0x9f, 0xec, 0x22, 0x6e, 0xe8, 0xdb, 0xd4, + 0xa3, 0xb8, 0x6d, 0x32, 0x2f, 0x10, 0xa5, 0x1e, 0xd9, 0x9f, 0xf8, 0x18, 0x6a, 0xa7, 0x3e, 0x82, 0x98, 0x14, 0xa9, + 0x62, 0xf0, 0xf6, 0xfc, 0x1b, 0x85, 0x1f, 0x00, 0xb2, 0x6e, 0xc0, 0x8b, 0x17, 0xc5, 0xc7, 0x71, 0x29, 0x3e, 0x8e, + 0xc2, 0xf3, 0x3d, 0x43, 0x66, 0xda, 0x6c, 0x9f, 0xa6, 0x10, 0x05, 0xe6, 0x64, 0xf3, 0xb1, 0x58, 0x05, 0xa9, 0xbf, + 0xf4, 0xe2, 0x74, 0x1f, 0x83, 0xe3, 0x60, 0xb0, 0x9d, 0xa6, 0xf8, 0x15, 0x64, 0x36, 0x22, 0x72, 0xa8, 0xa4, 0xa1, + 0xb0, 0x1b, 0x99, 0xfa, 0x41, 0x6e, 0x36, 0x22, 0x3a, 0xf0, 0xc6, 0x63, 0xb6, 0x4c, 0xdd, 0x52, 0x10, 0x9e, 0x68, + 0x9c, 0xb2, 0xd4, 0x4c, 0xd2, 0x98, 0x79, 0x0b, 0x35, 0x0f, 0xca, 0xb5, 0xd9, 0x5e, 0xb2, 0x1a, 0x41, 0x54, 0x21, + 0x11, 0x1e, 0x8c, 0x06, 0x08, 0x06, 0x1c, 0x00, 0x22, 0x04, 0xc5, 0xa1, 0x29, 0x3c, 0x8b, 0x66, 0x95, 0x2d, 0x55, + 0xb0, 0x54, 0x27, 0x98, 0x4a, 0x8d, 0x6e, 0x5e, 0x20, 0xdd, 0x1e, 0x47, 0xc1, 0x15, 0x8f, 0xb9, 0x91, 0xe7, 0xe4, + 0x51, 0x07, 0xc7, 0xfc, 0x3a, 0xae, 0x60, 0xb8, 0x19, 0xb5, 0x63, 0x43, 0xb2, 0xb8, 0xa6, 0x68, 0x1c, 0xfb, 0xbc, + 0x32, 0xd0, 0x4c, 0x6a, 0x19, 0xf3, 0x7d, 0x12, 0x2c, 0xe7, 0x40, 0xb2, 0x4a, 0x06, 0x3e, 0x73, 0x67, 0x90, 0xbb, + 0x7f, 0x22, 0x54, 0x48, 0xd5, 0x3e, 0x7d, 0x7a, 0x3f, 0xfc, 0xd7, 0x3f, 0x21, 0x29, 0xe9, 0xdc, 0x11, 0x31, 0x30, + 0x2e, 0xe4, 0x5a, 0x9c, 0x2d, 0x36, 0x86, 0x68, 0xdc, 0xc5, 0x26, 0x22, 0x3a, 0xa1, 0xd8, 0x5b, 0xd9, 0xf0, 0x52, + 0xc4, 0xd5, 0x83, 0x74, 0xc6, 0xba, 0x88, 0xd4, 0x31, 0x84, 0xe5, 0x1d, 0x8a, 0x18, 0x2e, 0xca, 0xdf, 0x6e, 0x5f, + 0x1e, 0x29, 0x45, 0xb8, 0xc7, 0x3a, 0x0b, 0x24, 0xda, 0x43, 0x83, 0x63, 0x4f, 0x41, 0x6e, 0x0a, 0xf9, 0xa2, 0xa4, + 0xb7, 0x0f, 0xc3, 0x9c, 0x47, 0x0b, 0x66, 0xf9, 0xd1, 0xfe, 0x0d, 0x1b, 0x99, 0xde, 0xd2, 0x27, 0x3b, 0x22, 0x94, + 0x13, 0x2a, 0xc4, 0x92, 0xe6, 0xe6, 0x39, 0xc4, 0xf8, 0x67, 0xc5, 0x54, 0x46, 0x95, 0xc0, 0x6d, 0xad, 0x42, 0x6f, + 0x79, 0xc0, 0x83, 0xa2, 0x89, 0x9a, 0x83, 0xe3, 0x7d, 0x6f, 0x50, 0xce, 0xcf, 0x63, 0x89, 0x3c, 0xb3, 0x65, 0x2a, + 0x70, 0x42, 0x69, 0x76, 0x44, 0x46, 0x9d, 0xe2, 0xc1, 0x8c, 0xa6, 0x53, 0x39, 0xa7, 0x8e, 0x55, 0x06, 0x2f, 0x9f, + 0xb4, 0x62, 0x4b, 0x47, 0x4b, 0xea, 0x69, 0xb3, 0x8b, 0xfc, 0xa7, 0xda, 0xc3, 0x64, 0x5a, 0x30, 0x66, 0x38, 0xef, + 0x1b, 0xb9, 0x79, 0xf2, 0x19, 0x7b, 0x44, 0x95, 0x38, 0x22, 0xa9, 0x66, 0x82, 0x6c, 0x60, 0xa9, 0xf6, 0x5c, 0x97, + 0xf0, 0x5c, 0x15, 0xdd, 0xc1, 0x24, 0xd6, 0xe4, 0xdc, 0x85, 0xc1, 0xa6, 0xf0, 0xa1, 0x49, 0xee, 0xbd, 0xf8, 0x51, + 0x75, 0x38, 0x9b, 0x30, 0xee, 0x7b, 0x62, 0xfb, 0x95, 0x36, 0x28, 0x6c, 0x3c, 0xbe, 0xee, 0x80, 0xe0, 0x45, 0x3b, + 0x15, 0x3c, 0xaf, 0x7c, 0x4d, 0x28, 0xdd, 0x0c, 0xbc, 0xbb, 0x48, 0x32, 0xbb, 0xe2, 0x11, 0x58, 0xce, 0xb0, 0xf4, + 0x5c, 0x78, 0x3e, 0x6f, 0x1c, 0x34, 0xa4, 0x61, 0x90, 0x9b, 0x74, 0xf3, 0xb0, 0x15, 0x04, 0x38, 0x60, 0xf7, 0x9d, + 0x35, 0xb9, 0x6e, 0x79, 0x30, 0x88, 0x3c, 0xb3, 0xe2, 0x1c, 0x96, 0x5e, 0x22, 0x5a, 0xc8, 0x8e, 0xf7, 0x61, 0x7c, + 0x94, 0x6d, 0x51, 0x30, 0x79, 0xc2, 0xbe, 0x10, 0x6f, 0xbd, 0x7e, 0xd3, 0xad, 0xb7, 0xca, 0xa3, 0x94, 0x59, 0x2f, + 0x5f, 0x87, 0xd8, 0x36, 0x5e, 0x42, 0xb6, 0x67, 0xdf, 0x4f, 0x38, 0x61, 0x90, 0x7a, 0xc9, 0x83, 0x61, 0x96, 0xea, + 0xe9, 0x5b, 0x83, 0xc4, 0x50, 0x9e, 0xdf, 0x0f, 0x2b, 0x4c, 0xf2, 0x9b, 0xf5, 0x53, 0x26, 0x62, 0x36, 0x9c, 0xa5, + 0x0d, 0x61, 0x1d, 0x9a, 0xaa, 0x10, 0x1f, 0xbe, 0xa5, 0x42, 0xb1, 0xcd, 0xb7, 0xd5, 0x2a, 0x38, 0xab, 0xa2, 0x9a, + 0xa7, 0xa9, 0x8f, 0xf0, 0x40, 0x6c, 0xd4, 0xc6, 0x52, 0x0c, 0x36, 0x91, 0xba, 0x50, 0x55, 0xa8, 0x16, 0xbc, 0xe5, + 0x92, 0x2a, 0xeb, 0xfd, 0xe3, 0x7d, 0xba, 0x4e, 0x0f, 0x68, 0x03, 0x0e, 0x8e, 0xc1, 0x32, 0x9d, 0xf6, 0x84, 0xb7, + 0x5c, 0xf2, 0x15, 0xa7, 0x5f, 0xf4, 0x66, 0x7f, 0x9e, 0x2e, 0x82, 0xc1, 0xff, 0x02, 0xf1, 0xc9, 0x8b, 0x98, 0x7c, + 0x7b, 0x03, 0x00}; #else // Brotli (default, smaller) constexpr uint8_t INDEX_BR[] PROGMEM = { - 0x5b, 0x05, 0x7b, 0x53, 0xc1, 0xb6, 0x69, 0x3d, 0x41, 0xeb, 0x04, 0x30, 0xf6, 0xd6, 0x77, 0x35, 0xdb, 0xa3, 0x08, - 0x36, 0x0e, 0x04, 0x80, 0x90, 0x4f, 0xf1, 0xb2, 0x21, 0xa4, 0x82, 0xee, 0x00, 0xaa, 0x20, 0x7f, 0x3b, 0xff, 0x00, - 0xaa, 0x9a, 0x73, 0x74, 0x8c, 0xe1, 0xa6, 0x1f, 0xa0, 0xa2, 0x59, 0xf5, 0xaa, 0x92, 0x79, 0x50, 0x43, 0x1f, 0xe8, - 0x3c, 0x52, 0x68, 0x2c, 0xf6, 0x36, 0x31, 0x90, 0x4e, 0x2b, 0x91, 0x69, 0x38, 0xb4, 0x61, 0xa7, 0xbb, 0x57, 0x79, - 0x3b, 0x6d, 0x62, 0x85, 0x9f, 0x24, 0x24, 0xda, 0x45, 0xc1, 0xe2, 0x85, 0x44, 0x50, 0x6c, 0x8c, 0x1e, 0xab, 0xbb, - 0x7b, 0x1e, 0x30, 0x98, 0x59, 0xe5, 0xb9, 0xd1, 0x34, 0x03, 0x0b, 0x79, 0x37, 0x8c, 0x4b, 0x68, 0x2c, 0xbe, 0x21, - 0xcc, 0x30, 0xc4, 0x9c, 0x4d, 0x34, 0xd6, 0x01, 0xc1, 0xe1, 0x66, 0x48, 0x3c, 0x94, 0xf4, 0x3c, 0x5b, 0x12, 0xfd, - 0x15, 0xb4, 0x05, 0xdb, 0xaf, 0xc1, 0x59, 0x5d, 0x9f, 0xc5, 0x8d, 0xaf, 0xf7, 0xc6, 0x5f, 0x58, 0xfb, 0xc3, 0x74, - 0x45, 0x97, 0x23, 0xac, 0x2e, 0x4a, 0xb9, 0xab, 0xd7, 0x5b, 0xe5, 0x85, 0x6f, 0x21, 0xba, 0x7e, 0xa3, 0x57, 0xa5, - 0xb3, 0x24, 0x7e, 0x60, 0x30, 0x0e, 0x15, 0x1f, 0x04, 0x5e, 0xba, 0x06, 0x04, 0xff, 0x02, 0x26, 0x2d, 0x03, 0x6c, - 0x97, 0x1b, 0x23, 0x84, 0x28, 0x25, 0xb4, 0xe2, 0xca, 0xbd, 0xc8, 0xc3, 0x96, 0x4a, 0xef, 0xed, 0xe7, 0x65, 0x4f, - 0xb2, 0x2d, 0x6c, 0x02, 0x91, 0x04, 0x76, 0xae, 0xba, 0x67, 0x9c, 0xe3, 0x38, 0xd6, 0x96, 0x61, 0x0c, 0xd3, 0x80, - 0xe4, 0x4a, 0x43, 0x2e, 0x92, 0xff, 0xa7, 0xdb, 0xd2, 0xec, 0xf5, 0xf5, 0x90, 0x4a, 0x22, 0x74, 0x92, 0x40, 0x80, - 0xed, 0x2b, 0xb5, 0xbc, 0xb6, 0xe0, 0x71, 0xda, 0x35, 0x3b, 0xe9, 0xec, 0xeb, 0xac, 0xbe, 0x6a, 0x2f, 0xf0, 0xde, - 0x96, 0xf7, 0x43, 0x51, 0x4d, 0xce, 0xbb, 0xd3, 0x70, 0xc2, 0x08, 0x2c, 0xf0, 0xc8, 0xac, 0x25, 0x82, 0x67, 0xeb, - 0xcd, 0xf5, 0x7d, 0x7d, 0xb7, 0x8b, 0xca, 0x2a, 0x15, 0x66, 0x68, 0xf3, 0x6e, 0xb6, 0xda, 0x5b, 0x81, 0x7b, 0x2e, - 0xe6, 0xc1, 0xdc, 0x5e, 0x0a, 0x7a, 0xda, 0x4a, 0x2c, 0x68, 0xa4, 0xd0, 0x92, 0xe7, 0xb2, 0xb3, 0xef, 0xab, 0xda, - 0xd7, 0xef, 0x89, 0xe7, 0x22, 0xd0, 0x93, 0xe5, 0x08, 0xcc, 0xea, 0x82, 0xdb, 0xde, 0x1a, 0xdd, 0x49, 0x47, 0x26, - 0x23, 0xc1, 0x16, 0x7b, 0x2a, 0x99, 0x23, 0xe9, 0x4c, 0xf8, 0x65, 0xaa, 0xfe, 0xe9, 0x6a, 0x41, 0x0d, 0x8f, 0xfb, - 0x48, 0x9a, 0x49, 0x4e, 0x0b, 0x2e, 0x91, 0xfc, 0x76, 0x6e, 0x55, 0xee, 0xb4, 0xbb, 0x3c, 0x63, 0xc1, 0x95, 0x5a, - 0xf8, 0x37, 0x35, 0xb1, 0xaa, 0xcd, 0xa9, 0x99, 0x2f, 0x3d, 0x52, 0xd0, 0x01, 0xd9, 0x06, 0x27, 0x41, 0x92, 0xdd, - 0x6d, 0x4a, 0xee, 0xad, 0xa6, 0x33, 0xe1, 0xff, 0xeb, 0x6b, 0x5a, 0xff, 0xfd, 0xf3, 0x05, 0x0a, 0xbb, 0xc4, 0x55, - 0x1d, 0x28, 0x69, 0x66, 0x7f, 0x4f, 0x49, 0xce, 0xb2, 0xec, 0x8a, 0x0a, 0x15, 0x2d, 0x63, 0x1b, 0x6f, 0x28, 0x00, - 0xf5, 0x44, 0x47, 0xd6, 0xf5, 0xb5, 0xd7, 0x6f, 0xff, 0xf5, 0x0b, 0x3d, 0xd9, 0x1d, 0x42, 0xe9, 0xb3, 0x32, 0xa5, - 0xf6, 0xa3, 0x1b, 0xc3, 0x58, 0x8a, 0x7c, 0x24, 0xc7, 0x03, 0xbc, 0x92, 0xd5, 0x4f, 0xbf, 0xfc, 0xfa, 0x3d, 0x3b, - 0x6d, 0x8c, 0xc5, 0xdb, 0x9d, 0x77, 0x97, 0xd2, 0x5e, 0x82, 0x85, 0x53, 0x9a, 0xaf, 0xb6, 0x09, 0x45, 0x51, 0x25, - 0x90, 0x44, 0x85, 0xa4, 0xc6, 0x23, 0x18, 0xfb, 0xbb, 0x56, 0xef, 0xe9, 0x3c, 0x13, 0x62, 0xf0, 0xaf, 0x6b, 0xe9, - 0xa2, 0xa7, 0x26, 0x32, 0xa1, 0x8d, 0x5d, 0x56, 0x22, 0xa0, 0xe8, 0xb8, 0x06, 0xc1, 0x07, 0xb4, 0x7e, 0xf5, 0xbe, - 0xae, 0xff, 0xfa, 0x95, 0xa7, 0xb8, 0xe2, 0x34, 0x6a, 0xc9, 0x3c, 0xee, 0xb3, 0x45, 0x67, 0xae, 0x93, 0x6c, 0x20, - 0xbc, 0x42, 0xc5, 0xe1, 0x91, 0x52, 0xb9, 0x8c, 0x62, 0x0d, 0x46, 0xbb, 0x46, 0x72, 0x69, 0x26, 0xe0, 0xac, 0x67, - 0xec, 0xb5, 0x51, 0x5f, 0x9d, 0xae, 0x5d, 0x25, 0x15, 0x72, 0xf0, 0xc2, 0x2f, 0x67, 0xe6, 0x68, 0x08, 0xac, 0xdd, - 0xcb, 0xd5, 0xe3, 0x03, 0x9d, 0x49, 0x4d, 0xa1, 0xcd, 0x81, 0xbf, 0x09, 0xd9, 0xdd, 0x2d, 0xe1, 0x7b, 0x7f, 0x9a, - 0x7d, 0xfd, 0x92, 0xd9, 0x26, 0x19, 0x8d, 0x9d, 0x2b, 0x6d, 0x38, 0x99, 0x2b, 0x2d, 0xb5, 0xfa, 0xed, 0xe3, 0xb0, - 0x11, 0x2c, 0x17, 0x79, 0xe0, 0x24, 0xb1, 0x86, 0x68, 0x15, 0xb3, 0x7f, 0x4b, 0xf3, 0xeb, 0xd7, 0xc3, 0xdd, 0x24, - 0x64, 0x69, 0x0f, 0x4e, 0xae, 0xdb, 0xc7, 0x71, 0x4e, 0xaa, 0x64, 0x18, 0xec, 0xd1, 0x7b, 0x59, 0xa8, 0x31, 0x05, - 0x80, 0x2b, 0xcb, 0x0c, 0x91, 0x02, 0x85, 0x0d, 0x16, 0x1d, 0x1b, 0xa6, 0x6e, 0x48, 0x13, 0x04, 0xa1, 0xc5, 0xff, - 0x33, 0xa7, 0xdf, 0xb4, 0x67, 0x98, 0xb0, 0x60, 0xdb, 0x52, 0xfa, 0xaf, 0x26, 0xbf, 0x98, 0xed, 0xc1, 0x7b, 0x18, - 0x2e, 0x02, 0x9e, 0x60, 0x3c, 0xef, 0xff, 0xef, 0xad, 0xb4, 0xdc, 0xfe, 0x88, 0x74, 0x45, 0x88, 0xec, 0x01, 0xc8, - 0x71, 0x86, 0x23, 0xeb, 0xf7, 0x9d, 0x99, 0x55, 0xe0, 0x34, 0x40, 0xb2, 0xc7, 0x99, 0x95, 0xb4, 0xd6, 0x62, 0x53, - 0x71, 0xef, 0x7d, 0xef, 0x32, 0xbf, 0x8b, 0xce, 0xf8, 0x61, 0x58, 0x61, 0x32, 0x1b, 0x69, 0x87, 0x65, 0xbb, 0xb2, - 0x9c, 0x06, 0x00, 0xc1, 0xfb, 0xde, 0xfb, 0x51, 0xf8, 0xff, 0x47, 0x16, 0xe7, 0x47, 0x64, 0x81, 0x8a, 0xcc, 0x2a, - 0xce, 0xc9, 0x2c, 0x60, 0x46, 0x55, 0x00, 0x67, 0x54, 0x00, 0xfb, 0xe8, 0x80, 0x1c, 0x0b, 0x82, 0x46, 0xd3, 0x64, - 0xb3, 0x8f, 0x86, 0x6c, 0x79, 0xbf, 0xd2, 0x62, 0x05, 0x51, 0xae, 0x67, 0x64, 0x5b, 0xf2, 0xbb, 0x1d, 0x28, 0x6b, - 0x96, 0xd2, 0x4a, 0x47, 0xff, 0xeb, 0x96, 0xeb, 0x89, 0x6e, 0x15, 0x9f, 0x68, 0xa7, 0xdc, 0x30, 0x8c, 0xfc, 0x9f, - 0xc0, 0x23, 0xe1, 0x0c, 0x4e, 0xc3, 0x69, 0xa8, 0xc2, 0xd5, 0xa0, 0xa6, 0x5b, 0xc7, 0x8e, 0xb3, 0xe9, 0x34, 0x54, - 0xfd, 0x31, 0xfc, 0x1e, 0x4a, 0x0b, 0x83, 0xa9, 0x33, 0x4d, 0xd3, 0x7f, 0x77, 0x8d, 0x72, 0x55, 0x71, 0x4d, 0x74, - 0xe3, 0xaa, 0x55, 0x19, 0xf8, 0x9b, 0xa6, 0x69, 0xf8, 0x87, 0xe4, 0x6e, 0x5f, 0xac, 0xf9, 0x3d, 0xda, 0x61, 0xa0, - 0x50, 0xca, 0x2e, 0x93, 0x38, 0x3e, 0xe4, 0x5b, 0x96, 0x25, 0xd9, 0xf0, 0x75, 0x2c, 0xad, 0x37, 0x37, 0x2a, 0x15, - 0x48, 0xe8, 0xbd, 0xf6, 0x19, 0xb5, 0x55, 0xf0, 0x10, 0x5d, 0x14, 0x49, 0xa4, 0xa7, 0xff, 0xa7, 0xaa, 0x7a, 0xbc, - 0x43, 0xa6, 0x66, 0xdd, 0xd8, 0xc4, 0x05, 0xf2, 0xe9, 0x1b, 0x34, 0x4f, 0x12, 0x1a, 0x1b, 0xac, 0xf3, 0x32, 0x32, - 0xad, 0x2b, 0xeb, 0xd8, 0x29, 0x4e, 0xfe, 0x6f, 0x80, 0xa1, 0x0d, 0xad, 0x4a, 0xc2, 0xb6, 0x83, 0xa0, 0xff, 0x50, - 0x68, 0x62, 0xad, 0x71, 0x00, 0x77, 0x5a, 0x66, 0xb4, 0x05, 0x9d, 0x01, 0x51, 0x9c, 0xbb, 0xf8, 0xd3, 0x64, 0x82, - 0x69, 0xb6, 0x87, 0x82, 0xef, 0x73, 0x14, 0x62, 0x0c, 0x22, 0xcb, 0x73, 0xdb, 0xb3, 0x0f, 0x4c, 0xfe, 0x77, 0x60, - 0x45, 0xff, 0x14, 0xea, 0x9f, 0xb0, 0x02, 0xa0, 0x6e, 0xbd, 0xe4, 0xd7, 0x0a, 0xbc, 0xe7, 0xf7, 0x00, 0x44, 0x26, - 0xc2, 0x96, 0xe5, 0x37, 0xda, 0x32, 0xca, 0xef, 0x42, 0xb4, 0xdd, 0xe2, 0x70, 0xf0, 0xe0, 0xc1, 0x93, 0xd4, 0xed, - 0x18, 0xbf, 0x54, 0xdd, 0xb0, 0x5b, 0x86, 0x3e, 0x46, 0x7f, 0xbe, 0x1b, 0x61, 0x83, 0x52, 0xe1, 0x6a, 0x62, 0x7a, - 0xa6, 0x7e, 0x6f, 0xc4, 0x5b, 0x63, 0x55, 0x1a, 0xb8, 0xa5, 0x87, 0xd9, 0x84, 0xd4, 0xcf, 0xd7, 0xd4, 0x62, 0x26, - 0x28, 0x13, 0x41, 0x44, 0xca, 0x76, 0xf6, 0x21, 0x63, 0x4b, 0xae, 0xaf, 0xe7, 0xc7, 0x4d, 0x79, 0x5d, 0x8f, 0xc3, - 0x04, 0x0d, 0xc9, 0x06, 0x39, 0xa0, 0xd8, 0xde, 0x87, 0xb3, 0xd7, 0x4b, 0x65, 0x4e, 0xa3, 0xa6, 0xff, 0x79, 0xab, - 0x0c, 0xed, 0xb4, 0x01, 0x69, 0xdc, 0xa6, 0x15, 0x98, 0x98, 0x82, 0xc8, 0x86, 0x4d, 0x06, 0x77, 0xbd, 0x39, 0x6c, - 0x73, 0x52, 0x73, 0x48, 0xd6, 0xe4, 0x8a, 0x4a, 0x83, 0x9a, 0xf5, 0xc9, 0xeb, 0xa5, 0x2e, 0xa9, 0x70, 0x4b, 0x9d, - 0xb9, 0xbe, 0xc8, 0xe4, 0x9e, 0x17, 0xf2, 0xca, 0x95, 0x97, 0xd3, 0x14, 0xd8, 0x9b, 0xef, 0xb1, 0xaf, 0x13, 0x9a, - 0xf5, 0x3d, 0x8f, 0x74, 0xe3, 0x5d, 0x47, 0x07, 0x26, 0xd2, 0x60, 0xa2, 0xef, 0x11, 0x34, 0x2f, 0x6e, 0xb3, 0xfe, - 0xf0, 0xa3, 0x42, 0x7d, 0xfb, 0x47, 0x5c, 0x75, 0x98, 0x0f, 0xe6, 0x0f, 0x4e, 0xe5, 0x67, 0x1a, 0x4f, 0x93, 0x47, - 0x5f, 0x04, 0x7c, 0xff, 0x7a, 0xf9, 0x79, 0x6b, 0x46, 0x47, 0xc6, 0x0c, 0xd5, 0x90, 0x30, 0xd5, 0xfc, 0xde, 0x8b, - 0xd5, 0x65, 0x7f, 0x87, 0x2d, 0x9a, 0xd5, 0xe4, 0x3f, 0xf9, 0xed, 0x07, 0xfb, 0xa2, 0xb6, 0xd3, 0xe1, 0xbf, 0xbd, - 0x40, 0x78, 0x7a, 0x67, 0x24, 0x0b, 0x6b, 0x0e, 0xed, 0xcf, 0x7a, 0x6a, 0x7c, 0xdb, 0x36, 0x61, 0x5b, 0xc3, 0x7c, - 0x5d, 0xfc, 0xf6, 0x1c, 0xc2, 0xa9, 0xba, 0x12, 0x15, 0x35, 0x11, 0x87, 0x41, 0x13, 0xa5, 0xe5, 0x73, 0x07, 0xfa, - 0xc6, 0xe3, 0x96, 0x43, 0x01, 0x76, 0x4b, 0x53, 0x28, 0xad, 0x09, 0x7e, 0x88, 0x0f, 0x26, 0x90, 0x04, 0xfd, 0x2a, - 0x0d, 0x4c, 0xcd, 0x5c, 0xbf, 0x10, 0xd6, 0x47, 0xaf, 0xe2, 0x12, 0x80, 0x00, 0x59, 0xaa, 0x9b, 0x18, 0x58, 0x96, - 0xc8, 0x80, 0x67, 0xc2, 0xb9, 0x4e, 0x5d, 0x86, 0x1e, 0x79, 0xf5, 0xcf, 0xb0, 0x81, 0x1f, 0x9e, 0x4f, 0x34, 0x1d, - 0x7c, 0xf2, 0x4a, 0x4d, 0xbd, 0x42, 0x06, 0xbc, 0x73, 0x56, 0xbc, 0x9d, 0x43, 0xa9, 0xe6, 0x44, 0x0c, 0xcd, 0xcd, - 0xe4, 0x4e, 0xde, 0xb3, 0x0e, 0x25, 0x35, 0xb6, 0xb6, 0xf6, 0xcc, 0xae, 0x6f, 0x91, 0x82, 0x59, 0xa1, 0xdc, 0x8b, - 0xaa, 0x4f, 0x64, 0x26, 0xd0, 0xa5, 0xe7, 0x38, 0xf3, 0xf5, 0xcd, 0x4f, 0x05, 0x62, 0x8c, 0x38, 0xc3, 0x96, 0x13, - 0x68, 0xb2, 0xe4, 0xd9, 0xcf, 0x4a, 0x5f, 0x44, 0x57, 0xf6, 0x49, 0x47, 0xae, 0x16, 0x81, 0xa1, 0xa7, 0x2d, 0xd8, - 0xb3, 0x35, 0x74, 0x6a, 0xc2, 0xbc, 0xc0, 0x7d, 0xae, 0xf0, 0x88, 0xe4, 0xd0, 0x28, 0x7c, 0x22, 0x98, 0x95, 0xa3, - 0x2a, 0x81, 0x16, 0x0b, 0xc7, 0x4a, 0xf3, 0x07, 0xb8, 0xa1, 0x56, 0xbf, 0xdf, 0x36, 0x6b, 0xa3, 0x84, 0x8b, 0xbf, - 0x24, 0x99, 0xc1, 0x09, 0x7e, 0xff, 0x99, 0x8c, 0x1c, 0xd1, 0x43, 0x7c, 0xb1, 0x46, 0x9d, 0x2e, 0x65, 0x92, 0xa9, - 0xa0, 0xd0, 0x45, 0x92, 0x47, 0x37, 0x9c, 0x3c, 0x5f, 0xf1, 0xf3, 0x0d, 0x1e, 0x37, 0xeb, 0x3d, 0xb6, 0x7c, 0x33, - 0x35, 0xaf, 0xf3, 0x08, 0x54, 0x33, 0x56, 0x02, 0x4f, 0x18, 0xda, 0xc0, 0xbb, 0xc5, 0x4a, 0x42, 0xd7, 0xef, 0xbd, - 0xa4, 0xec, 0x61, 0x77, 0x1b, 0xa2, 0x57, 0x47, 0x00, 0xee, 0x8b, 0xd3, 0x56, 0xd4, 0xbd, 0x01, 0xa2, 0x8f, 0xee, - 0xef, 0xb1, 0xac, 0xe1, 0x03, 0x87, 0x8d, 0x2b, 0x3c, 0x8e, 0x15, 0x84, 0x96, 0xb4, 0xfe, 0x56, 0xd5, 0x1e, 0xc0, - 0x83, 0x66, 0x79, 0x15, 0x8a, 0x60, 0xb7, 0x15, 0x21, 0x3b, 0xce, 0x44, 0x71, 0x9f, 0x6f, 0xe0, 0x70, 0xde, 0xb8, - 0x7a, 0x74, 0x43, 0xcd, 0x4d, 0x7a, 0x90, 0xd2, 0x4b, 0x9b, 0xc8, 0x6a, 0xa2, 0xc6, 0xdf, 0x1a, 0x55, 0x85, 0x34, - 0xdd, 0x1d, 0x1a, 0x7c, 0x88, 0x7c, 0x81, 0x3d, 0xd8, 0x12, 0xf4, 0x32, 0x8d, 0xc6, 0xc1, 0x56, 0x0d, 0xe5, 0x8d, - 0x75, 0x00, 0x03, 0x61, 0x93, 0xa0, 0x44, 0x06, 0x5b, 0x67, 0x8b, 0x58, 0xf5, 0x9c, 0xb0, 0x79, 0x7f, 0xbd, 0x2e, - 0xb1, 0x17, 0xb3, 0x85, 0xcd, 0x54, 0x5f, 0xc8, 0x38, 0xeb, 0xa7, 0xcd, 0xfe, 0xbf, 0x2d, 0x80, 0x83, 0x12, 0xc3, - 0x0b, 0x02, 0x41, 0x44, 0xd5, 0x07, 0xe5, 0xcd, 0xb0, 0x24, 0x2c, 0x0a, 0x6c, 0x1b, 0x1f, 0xb9, 0x7b, 0x48, 0x9e, - 0x55, 0x42, 0x7c, 0x2b, 0x63, 0xd3, 0xd1, 0x76, 0x18, 0x61, 0xa8, 0x86, 0x2d, 0x11, 0x5a, 0x41, 0x04, 0x6c, 0xea, - 0xcf, 0x34, 0xf6, 0x71, 0xe7, 0xda, 0x41, 0xba, 0x28, 0xcb, 0x2c, 0x1c, 0x47, 0x50, 0xa7, 0x83, 0x41, 0xad, 0x84, - 0x9e, 0xec, 0x1e, 0xfc, 0xc6, 0xc6, 0xb8, 0xa0, 0xb8, 0xa1, 0x70, 0xeb, 0x5a, 0x9f, 0x46, 0x06, 0xa6, 0xf8, 0x72, - 0xa5, 0xff, 0xfe, 0x80, 0x1e, 0x07, 0xbb, 0xd4, 0x48, 0xf9, 0x6c, 0xd6, 0x13, 0xf8, 0xee, 0x86, 0x06, 0x67, 0x78, - 0x38, 0xf2, 0x83, 0xc3, 0x3b, 0x25, 0xf0, 0xa0, 0x60, 0x56, 0xbe, 0x7d, 0x10, 0x8a, 0xd4, 0x17, 0x81, 0x0e, 0x17, - 0x5f, 0x53, 0xaf, 0x87, 0x23, 0xe4, 0x56, 0xe4, 0xb1, 0xc0, 0x9d, 0xc8, 0x38, 0x25, 0x47, 0x18, 0x18, 0x27, 0x57, - 0xdf, 0x84, 0xcd, 0x7c, 0xdb, 0x31, 0xff, 0xc2, 0xe5, 0x83, 0x03, 0xd1, 0xac, 0x9f, 0x2d, 0xd8, 0xa5, 0xa4, 0x40, - 0xe0, 0x1e, 0xe9, 0x18, 0x3d, 0x85, 0xa9, 0x1b, 0x9c, 0xa6, 0x14, 0x51, 0x9a, 0x88, 0xd9, 0x42, 0x70, 0x6c, 0x6b, - 0x63, 0x2e, 0xfd, 0x46, 0xd9, 0xd5, 0xb4, 0xd9, 0x8f, 0x2c, 0xf8, 0x42, 0xf9, 0xb6, 0x27, 0xb8, 0x69, 0xc5, 0xed, - 0x5c, 0xea, 0xff, 0x76, 0x9d, 0x49, 0x1b, 0x5a, 0xf7, 0xaa, 0x2d, 0x04, 0x36, 0x45, 0x05, 0xa8, 0x99, 0x5e, 0x24, - 0x53, 0x3b, 0x89, 0xd9, 0x0f, 0x4d, 0x24, 0x27, 0x44, 0x2b, 0xfb, 0x3b, 0xd9, 0x8b, 0x36, 0xe9, 0x18, 0x4a, 0x30, - 0xfc, 0xc8, 0xa5, 0xf4, 0xd5, 0xd5, 0x72, 0x2d, 0x3b, 0x5f, 0xc3, 0x4e, 0x98, 0x0c, 0x08, 0xb2, 0xff, 0x59, 0x68, - 0x6b, 0xc0, 0xe4, 0x52, 0x8f, 0x29, 0x78, 0x74, 0x6d, 0xbe, 0xfb, 0x13, 0x8a, 0x25, 0x0b, 0x31, 0xe5, 0xd0, 0xae, - 0xbf, 0x1e, 0x13, 0x23, 0xa0, 0x2c, 0x89, 0x10, 0x6e, 0xa5, 0x1c, 0xa8, 0x7f, 0xbf, 0x62, 0x52, 0xb0, 0xa5, 0xf6, - 0x46, 0x9c, 0x5d, 0xbd, 0xac, 0x81, 0xc4, 0x46, 0xf3, 0x61, 0x62, 0x47, 0x08, 0x27, 0x4d, 0xed, 0x4b, 0x45, 0x91, - 0x48, 0xcf, 0x52, 0xc4, 0x20, 0xe3, 0x8a, 0xe9, 0x12, 0x2d, 0xac, 0x99, 0xb0, 0x1c, 0x1b, 0x91, 0xa4, 0xcc, 0x6d, - 0x11, 0x3f, 0xbe, 0xe9, 0x06, 0x24, 0x40, 0x3d, 0x62, 0x90, 0x0f, 0xbe, 0x25, 0x20, 0xd7, 0x25, 0x09, 0xca, 0xb5, - 0xcf, 0x25, 0x64, 0x42, 0x3b, 0x19, 0x09, 0x13, 0xf3, 0x46, 0x90, 0x72, 0xf7, 0x74, 0x4a, 0xd7, 0x00, 0x4b, 0x39, - 0x59, 0xcd, 0x21, 0x62, 0xe4, 0x78, 0x5d, 0x75, 0xb5, 0x80, 0x58, 0x0a, 0xb7, 0xa3, 0xed, 0xc8, 0xe4, 0x5c, 0xdc, - 0xa1, 0xf3, 0xce, 0x99, 0x5f, 0x18, 0xa7, 0x1c, 0x9c, 0x1e, 0xe6, 0x2e, 0x20, 0x20, 0xa7, 0xae, 0xfa, 0xc1, 0x19, - 0x19, 0xa4, 0xb8, 0x9a, 0x77, 0x5a, 0x24, 0x9a, 0x11, 0xf9, 0xac, 0x18, 0xaa, 0xdb, 0x2a, 0x37, 0xc2, 0x62, 0xad, - 0x5c, 0x82, 0x29, 0x72, 0x72, 0x1b, 0x7c, 0xb3, 0x83, 0xc7, 0xcd, 0x13, 0x16, 0xc0, 0x59, 0x8f, 0xe5, 0x62, 0xc2, - 0xa1, 0xea, 0x36, 0x7e, 0x0d, 0x64, 0x0a, 0xbc, 0x72, 0xd4, 0x59, 0x92, 0xe3, 0x0b, 0x0d, 0xaa, 0x81, 0xbf, 0xf6, - 0x91, 0xe7, 0x41, 0x6e, 0x50, 0x35, 0xd5, 0x34, 0x8b, 0x42, 0x4f, 0x31, 0xcf, 0x84, 0xcc, 0x5f, 0x35, 0x8a, 0x4e, - 0xc2, 0x8c, 0xa7, 0xc9, 0x96, 0x3a, 0xdd, 0xa7, 0x32, 0xa1, 0x80, 0xd8, 0x43, 0xe0, 0x14, 0xb8, 0xf7, 0xa6, 0xc2, - 0x3c, 0x9d, 0x92, 0x49, 0x1c, 0x9e, 0xcc, 0xb3, 0x59, 0x03, 0x66, 0xc0, 0x8d, 0x12, 0xe8, 0xe6, 0x8c, 0xfc, 0x70, - 0x0b, 0xb7, 0x55, 0x71, 0x1e, 0x93, 0x15, 0x68, 0xc9, 0x4d, 0x04, 0xc9, 0xf0, 0xca, 0xb8, 0x80, 0xd2, 0x7b, 0x13, - 0x67, 0xc6, 0xdd, 0xe2, 0xab, 0x8a, 0x4f, 0xc0, 0x79, 0xdc, 0x97, 0xdb, 0x8a, 0x53, 0x9a, 0x2a, 0x1c, 0x80, 0x92, - 0x17, 0xc4, 0x63, 0xe1, 0x9b, 0xd3, 0x2b, 0x99, 0x61, 0xe0, 0x62, 0x46, 0x35, 0x15, 0xdd, 0x85, 0x74, 0xc2, 0x74, - 0x90, 0xf0, 0x96, 0x34, 0x06, 0x77, 0x40, 0xf1, 0xbe, 0x00, 0x14, 0x11, 0x8e, 0xc2, 0x77, 0x76, 0x4c, 0x47, 0xab, - 0x92, 0xf0, 0x68, 0x99, 0x2d, 0xda, 0x79, 0xf9, 0x46, 0x25, 0xab, 0x1c, 0x70, 0x34, 0x00, 0x6c, 0x5e, 0x7f, 0x48, - 0x7c, 0x06, 0x81, 0x1c, 0x1f, 0x27, 0x76, 0x3e, 0x34, 0x4d, 0x95, 0xc2, 0x9f, 0x8d, 0xf6, 0x26, 0x2c, 0x70, 0xc7, - 0x29, 0x13, 0x3a, 0x1e, 0x1b, 0xd1, 0x0d, 0x41, 0xe7, 0x5f, 0xb0, 0x03, 0xb6, 0xda, 0x66, 0x7b, 0xbf, 0x7a, 0xbd, - 0x2c, 0x4e, 0x0e, 0x98, 0xe4, 0x9d, 0xcd, 0xbd, 0xb7, 0xdb, 0xf9, 0x2f, 0x27, 0x1f, 0x99, 0xb0, 0x40, 0xe1, 0x55, - 0x4e, 0x59, 0x64, 0x24, 0x3a, 0xa0, 0xc4, 0x2b, 0x4d, 0xe7, 0x62, 0x74, 0x2d, 0x12, 0xaf, 0x4a, 0xb1, 0x2b, 0x24, - 0xa9, 0x61, 0xe6, 0x0d, 0x38, 0xc8, 0x66, 0x9d, 0xa6, 0x46, 0x41, 0x11, 0xb2, 0xcc, 0xc5, 0xc6, 0x2c, 0xb1, 0x58, - 0xf3, 0x96, 0x33, 0x6d, 0x4e, 0x61, 0x44, 0xe0, 0xe4, 0x80, 0xa8, 0xfe, 0xac, 0xd6, 0xd8, 0xe0, 0xd6, 0xf3, 0x6a, - 0x18, 0x61, 0xe0, 0xdd, 0x50, 0x92, 0xf2, 0xc4, 0x18, 0x2b, 0x21, 0xc9, 0xa9, 0x23, 0x8e, 0xfd, 0xc8, 0xf2, 0x15, - 0xdf, 0xef, 0xb9, 0xc6, 0x14, 0x97, 0x07, 0x13, 0x63, 0x16, 0x43, 0xa6, 0x76, 0x83, 0xca, 0x22, 0xa9, 0x9f, 0x8e, - 0x6a, 0x59, 0x39, 0x93, 0x7b, 0x0c, 0xf7, 0x51, 0xe7, 0x92, 0xbc, 0x7b, 0x74, 0x05, 0x01, 0xd9, 0x5d, 0x82, 0xd5, - 0x27, 0x87, 0x24, 0x8a, 0x9c, 0xb0, 0x9f, 0xeb, 0x5f, 0x56, 0x23, 0x7f, 0x95, 0x63, 0x29, 0x5f, 0x0f, 0x79, 0x4b, - 0x19, 0x62, 0x2a, 0xad, 0xe5, 0x9e, 0x53, 0x90, 0x71, 0xe9, 0xb2, 0x6c, 0xf1, 0x40, 0x2c, 0x21, 0x7c, 0xb0, 0x98, - 0x7d, 0xde, 0x2e, 0x1c, 0xc8, 0xa4, 0x50, 0x7e, 0xdf, 0xe5, 0xf2, 0xfc, 0xf0, 0xc9, 0x46, 0x2d, 0xc6, 0x18, 0xa7, - 0x54, 0x5b, 0xdf, 0x38, 0xa8, 0x15, 0xa3, 0x0d, 0x3c, 0xbf, 0xb0, 0xdd, 0x6e, 0x6f, 0xd9, 0x2b, 0x20, 0x2b, 0x6b, - 0x2b, 0x70, 0x53, 0x27, 0x9d, 0x1f, 0x36, 0xc2, 0x49, 0x13, 0xca, 0x40, 0x7a, 0x39, 0x43, 0x2d, 0x90, 0x44, 0x37, - 0x45, 0x2d, 0x8c, 0x16, 0xeb, 0x5b, 0x8e, 0xbe, 0xf5, 0x6b, 0x18, 0x32, 0x4a, 0xe9, 0x98, 0xa6, 0xc3, 0xbd, 0x2e, - 0xd9, 0x77, 0x11, 0x44, 0x8b, 0x6c, 0xd6, 0x6c, 0xc3, 0x2f, 0x52, 0xae, 0xbd, 0x10, 0xde, 0x92, 0xe6, 0x28, 0xf2, - 0xce, 0x11, 0xa9, 0xa5, 0x70, 0xaa, 0xa9, 0xc7, 0x24, 0x1c, 0xbb, 0x04, 0x5a, 0x29, 0x98, 0xee, 0xe1, 0xc5, 0x4e, - 0xb6, 0xa1, 0xc2, 0x22, 0x2d, 0x04, 0x69, 0x14, 0x73, 0xf8, 0xfd, 0x20, 0x11, 0x59, 0x06, 0xd8, 0x79, 0xd4, 0xe7, - 0xc0, 0x1e, 0x0e, 0xe3, 0xd1, 0x55, 0x11, 0xfb, 0xee, 0x0f, 0x13, 0x14, 0x62, 0x9d, 0xa6, 0x09, 0x0a, 0xf7, 0x7c, - 0x84, 0x8e, 0xcd, 0x31, 0x3f, 0x9d, 0x85, 0xb6, 0x7d, 0xb3, 0x7a, 0x28, 0xbc, 0xfc, 0x2e, 0xc5, 0x3e, 0xa5, 0xb7, - 0xf3, 0x2f, 0xd3, 0x39, 0xc5, 0x3c, 0xb3, 0xeb, 0x14, 0x53, 0xa1, 0x89, 0xcd, 0x85, 0x97, 0x28, 0x12, 0xe7, 0xc3, - 0x4b, 0x69, 0xe0, 0xcd, 0xd0, 0x29, 0x91, 0x60, 0xdc, 0x08, 0x4f, 0x62, 0x14, 0x61, 0x0d, 0x98, 0xee, 0xe6, 0x3d, - 0x3b, 0xa9, 0x38, 0x77, 0xca, 0x92, 0x2b, 0xba, 0xfb, 0xb9, 0x41, 0x00, 0x40, 0xc5, 0xce, 0xe3, 0x0b, 0x9f, 0x2c, - 0xaf, 0xe5, 0xf4, 0x1c, 0x57, 0x86, 0x10, 0x5a, 0x1a, 0x71, 0x42, 0xb0, 0x91, 0x4a, 0x60, 0x5b, 0x61, 0xb9, 0x53, - 0x25, 0x4a, 0x06, 0x7d, 0x60, 0x8c, 0xec, 0x0e, 0x8a, 0x13, 0xd9, 0x10, 0xda, 0x9a, 0x8e, 0x08, 0x62, 0xe6, 0x7f, - 0x1f, 0xe4, 0x0c, 0xf0, 0xf8, 0xad, 0x64, 0x48, 0x85, 0x00, 0xcd, 0x1c, 0x2f, 0x2f, 0x03, 0x76, 0x31, 0xc4, 0x95, - 0xb2, 0xb8, 0x00, 0xc4, 0x66, 0x1f, 0x9b, 0x71, 0x90, 0xfb, 0x41, 0xea, 0x1c, 0xd6, 0x65, 0x07, 0xa2, 0xd2, 0x0b, - 0xe1, 0xf9, 0x35, 0x0d, 0xd5, 0xa4, 0x09, 0xe3, 0x75, 0xcc, 0x20, 0x66, 0x45, 0x69, 0x23, 0x05, 0x61, 0x95, 0x82, - 0x43, 0x60, 0x8e, 0x34, 0xcb, 0x84, 0xfa, 0xd2, 0x22, 0xc1, 0x1b, 0x0a, 0x01, 0xdb, 0xf1, 0x5a, 0xa2, 0x01, 0x1c, - 0x18, 0xb3, 0x61, 0x87, 0xa4, 0x95, 0x05, 0xf5, 0x40, 0xf9, 0x59, 0x5f, 0x78, 0x3c, 0x23, 0x99, 0xf0, 0xc1, 0x03, - 0x47, 0xf3, 0xa5, 0xa8, 0xe7, 0xe6, 0x44, 0x4b, 0xda, 0xe4, 0x10, 0x7f, 0x22, 0xaa, 0xb5, 0x68, 0xc5, 0x03, 0x5f, - 0x01, 0xa8, 0x90, 0xa6, 0x82, 0x4a, 0x64, 0x49, 0x59, 0x54, 0x64, 0x1e, 0x4c, 0xcc, 0xb5, 0x05, 0x56, 0xd6, 0xa8, - 0x77, 0xfd, 0x08, 0x7e, 0xa6, 0x84, 0x4a, 0xad, 0x3b, 0xc4, 0x3f, 0x65, 0xfc, 0x35, 0x84, 0x14, 0x99, 0x9f, 0x19, - 0xb9, 0xcb, 0x63, 0x9e, 0x3d, 0xb2, 0xfa, 0xb7, 0x7d, 0x1b, 0x8e, 0x2e, 0xdc, 0x63, 0x92, 0xab, 0xf7, 0x48, 0x64, - 0x64, 0x33, 0x01, 0x41, 0xb7, 0xf1, 0x7a, 0x38, 0x4a, 0xc5, 0x1f, 0x9b, 0x14, 0x6f, 0xab, 0x0a, 0xa2, 0xf6, 0xa2, - 0x85, 0x54, 0x93, 0x9e, 0x03, 0x69, 0xd0, 0x9c, 0x34, 0x6a, 0xd0, 0xb5, 0x07, 0x2a, 0x54, 0x04, 0xe5, 0x8f, 0x0e, - 0x27, 0x26, 0xca, 0xf0, 0x14, 0xee, 0x2f, 0x4c, 0x46, 0x98, 0x39, 0x02, 0x55, 0xed, 0xa2, 0xe5, 0xf3, 0x16, 0x63, - 0x02, 0x79, 0xef, 0xfe, 0x0d, 0xf3, 0x23, 0xaa, 0x61, 0xb3, 0xe5, 0xbf, 0xf9, 0x33, 0x4f, 0x29, 0x2a, 0x27, 0xc2, - 0x54, 0x48, 0xa8, 0xb1, 0xb3, 0xa4, 0xd0, 0xa3, 0x8b, 0x58, 0xc3, 0x2c, 0x3f, 0x59, 0x73, 0x75, 0x63, 0x08, 0x19, - 0x23, 0x10, 0x9c, 0x21, 0xc4, 0xa8, 0xf2, 0x44, 0x39, 0x78, 0x9b, 0x00, 0xb8, 0x04, 0xf5, 0x18, 0x2c, 0x73, 0xfb, - 0x12, 0xa1, 0xb9, 0x9c, 0xf7, 0x1f, 0x71, 0x29, 0x19, 0x29, 0x7e, 0x96, 0x97, 0x59, 0xf7, 0x52, 0x79, 0x2a, 0x6e, - 0x5d, 0xd1, 0x56, 0xdc, 0x06, 0x0f, 0x98, 0x82, 0x3e, 0xca, 0x4a, 0x6d, 0xf4, 0x69, 0x16, 0x35, 0xbb, 0x64, 0xdb, - 0x63, 0x77, 0x63, 0x79, 0x52, 0x70, 0x27, 0xbc, 0x84, 0x69, 0x19, 0x4a, 0xdd, 0x36, 0x5e, 0x8a, 0x7d, 0x38, 0xc7, - 0x79, 0xf9, 0x2d, 0x53, 0xbc, 0xff, 0x8e, 0xe2, 0xd3, 0xd7, 0x2c, 0xcd, 0x30, 0x3f, 0x3a, 0x2a, 0x94, 0xc0, 0xcc, - 0x7a, 0xac, 0xe6, 0x51, 0x12, 0x6b, 0x28, 0x40, 0x87, 0x05, 0x43, 0x7b, 0xbc, 0xde, 0x82, 0x78, 0xa8, 0xb2, 0x1e, - 0x2c, 0xbc, 0x27, 0x33, 0x74, 0xb5, 0xa4, 0x0d, 0xad, 0x6b, 0xa2, 0xa2, 0x4f, 0xef, 0x91, 0xc5, 0xdd, 0xfa, 0x38, - 0x4e, 0x9a, 0x18, 0x15, 0x97, 0xa2, 0xdd, 0x59, 0x37, 0x5e, 0x84, 0x99, 0x78, 0x8c, 0x9c, 0x11, 0x19, 0x6b, 0xe9, - 0x8c, 0x96, 0xdc, 0xba, 0x04, 0x99, 0x4f, 0x06, 0x7a, 0x27, 0x50, 0x7e, 0xfe, 0x28, 0x07, 0x8e, 0x08, 0x00, 0xb5, - 0xdb, 0x16, 0x84, 0x2c, 0x38, 0xf0, 0xbf, 0x2c, 0xb7, 0x7d, 0x9f, 0xbc, 0xb3, 0x7c, 0x31, 0x2b, 0xe0, 0x7c, 0x63, - 0xa3, 0xbd, 0x11, 0xb7, 0xb3, 0x91, 0x0d, 0x1d, 0x4f, 0x24, 0xd4, 0x1f, 0x66, 0xe6, 0xd9, 0x31, 0x1f, 0x18, 0x09, - 0xce, 0x46, 0x47, 0x60, 0xbb, 0x2a, 0x73, 0xfd, 0x37, 0x49, 0xde, 0x59, 0x12, 0x23, 0x5c, 0x51, 0x81, 0xac, 0x1e, - 0x64, 0x41, 0xb0, 0xb8, 0xa5, 0x2b, 0xba, 0xde, 0xe7, 0x15, 0x89, 0x36, 0x91, 0x29, 0xb9, 0x1e, 0x20, 0xa0, 0xab, - 0x57, 0x3d, 0x3e, 0x55, 0xf7, 0xc6, 0xfb, 0x52, 0xe3, 0x85, 0xf6, 0xf7, 0xb5, 0x9d, 0x3a, 0xdc, 0xbc, 0xe7, 0x7a, - 0x9e, 0xf6, 0x8f, 0x12, 0x75, 0x7a, 0x2d, 0x32, 0x9e, 0x87, 0xfb, 0x35, 0x94, 0xd3, 0x48, 0x35, 0x69, 0x25, 0xa1, - 0xb8, 0x11, 0xcb, 0xbc, 0xe3, 0x96, 0x07, 0xbc, 0x0a, 0xbf, 0x69, 0xb5, 0x10, 0xef, 0x7d, 0x64, 0x58, 0x9e, 0x7a, - 0x77, 0x34, 0x61, 0xf6, 0x50, 0x2b, 0xbb, 0x29, 0x26, 0x60, 0x3f, 0xad, 0xfe, 0xc9, 0xae, 0xca, 0x17, 0x17, 0xf3, - 0x3f, 0xbb, 0xae, 0xb2, 0x91, 0xf1, 0x64, 0x9a, 0xf5, 0xdd, 0xf3, 0xf9, 0x87, 0x3f, 0x7b, 0x1a, 0x4e, 0xe6, 0xc3, - 0x2c, 0xda, 0x7f, 0x29, 0xbf, 0x2c, 0x1a, 0x42, 0x5b, 0xfe, 0xe8, 0x2c, 0xf5, 0xcc, 0x42, 0xef, 0x85, 0x00, 0x3e, - 0x2d, 0xda, 0x1d, 0x1c, 0xd5, 0x74, 0x3d, 0x8c, 0xf7, 0x41, 0x0b, 0xb7, 0x2e, 0x77, 0x20, 0xce, 0x6c, 0xc4, 0x49, - 0x7e, 0x51, 0xb3, 0xeb, 0x5d, 0x36, 0xb3, 0x69, 0x97, 0xbf, 0x23, 0x08, 0x9f, 0x4d, 0x90, 0xd1, 0x3a, 0xd6, 0x36, - 0xa9, 0xbc, 0x0a, 0x2c, 0xff, 0x8d, 0xe4, 0xd3, 0xb9, 0x36, 0x8c, 0x6e, 0x05, 0xfb, 0xf9, 0xa7, 0xf0, 0x6d, 0xd5, - 0x77, 0xc7, 0x9d, 0xff, 0x7c, 0xba, 0x5f, 0xfd, 0x3b, 0xd5, 0x93, 0xb9, 0x59, 0xf4, 0xde, 0xf3, 0xe0, 0xfb, 0xd3, - 0xa0, 0xdb, 0xb6, 0x7a, 0xb2, 0x7d, 0xfc, 0x7f, 0x17, 0xef, 0xff, 0x27, 0xfa, 0xfa, 0x15, 0x7b, 0x64, 0x3e, 0xf4, - 0xe2, 0xf8, 0x6c, 0xea, 0xe2, 0xf2, 0xff, 0x5c, 0xab, 0xdb, 0x3b, 0xcf, 0x6a, 0xb4, 0x6b, 0x6e, 0xb9, 0xee, 0xb5, - 0xed, 0x32, 0xaa, 0x9c, 0xc0, 0xad, 0xff, 0x7a, 0xea, 0x2b, 0x08, 0xf2, 0x79, 0xe3, 0xe5, 0x7e, 0xf6, 0xf0, 0xb6, - 0x26, 0xb3, 0xcf, 0x96, 0x7b, 0x27, 0x2f, 0xfc, 0xbc, 0x1e, 0x6c, 0xfb, 0x54, 0xea, 0x28, 0xd5, 0xac, 0xf8, 0x61, - 0x4d, 0x72, 0xb6, 0x2b, 0xe7, 0xfc, 0xd3, 0xf2, 0x79, 0xfd, 0xff, 0xc5, 0xf3, 0x6f, 0x76, 0xba, 0x47, 0xd8, 0x6f, - 0x61, 0x5f, 0x63, 0x3f, 0xab, 0x4f, 0xe6, 0x70, 0xf5, 0x29, 0xde, 0xba, 0xee, 0x6d, 0xb5, 0x6b, 0xee, 0xe2, 0x8a, - 0x39, 0x75, 0xc9, 0x95, 0xb3, 0x7a, 0x2a, 0x88, 0x0b, 0x62, 0x11, 0x48, 0x7c, 0x57, 0xff, 0x1d, 0xdc, 0xd5, 0x43, - 0xef, 0x95, 0x8b, 0x6d, 0xcf, 0x6d, 0x02, 0xec, 0x9a, 0xc8, 0x8c, 0x11, 0x2b, 0x62, 0x1b, 0xed, 0x28, 0xf0, 0x01, - 0x8b, 0xb6, 0xcf, 0x9f, 0x12, 0x9f, 0xf5, 0xbb, 0x90, 0x6b, 0x70, 0x7d, 0x7f, 0xfd, 0x5a, 0x3c, 0x93, 0x3f, 0x08, - 0x29, 0xfa, 0xf9, 0xc0, 0x53, 0xfd, 0x8b, 0x54, 0x98, 0x42, 0x54, 0x27, 0x2c, 0x6b, 0x86, 0x7d, 0x65, 0xdf, 0x47, - 0x8b, 0x43, 0x05, 0x6a, 0x3d, 0xde, 0x27, 0x44, 0x3e, 0xaa, 0x48, 0x77, 0xf9, 0x77, 0x2a, 0x82, 0xc0, 0x88, 0x28, - 0x30, 0x34, 0xc8, 0xa7, 0x9d, 0x03, 0x7f, 0x81, 0xe5, 0xfe, 0xa2, 0xb8, 0x7a, 0xb7, 0x4b, 0x4a, 0x35, 0x5c, 0xcd, - 0x1b, 0xeb, 0x14, 0x20, 0x25, 0x36, 0x11, 0x01, 0xc3, 0x7f, 0x72, 0xe5, 0x0b, 0xb6, 0x5e, 0xe7, 0x69, 0x3e, 0x19, - 0x55, 0xa7, 0x26, 0x37, 0x5b, 0x0b, 0x23, 0x5d, 0x0b, 0xaf, 0x3a, 0x3a, 0xe1, 0x94, 0x63, 0xcc, 0x57, 0x94, 0x36, - 0x1e, 0x71, 0xa7, 0xfe, 0xf6, 0x2a, 0xfe, 0xdc, 0x80, 0x84, 0x72, 0xcb, 0x6c, 0x70, 0x95, 0x99, 0xbd, 0x56, 0x70, - 0xa3, 0x24, 0x4c, 0xf1, 0x12, 0xf2, 0x8c, 0xdf, 0x73, 0xb1, 0x32, 0x05, 0xb6, 0x69, 0x7a, 0xff, 0xc7, 0xf6, 0x24, - 0x28, 0x04, 0x3a, 0xf1, 0x52, 0x80, 0x04, 0xaa, 0xbe, 0x21, 0xc8, 0x37, 0x3d, 0xe2, 0xa0, 0xad, 0xa9, 0x8d, 0xf3, - 0x4c, 0xb3, 0x68, 0x33, 0xde, 0xd5, 0x95, 0x92, 0xb9, 0x9e, 0x11, 0x8d, 0xbc, 0xb6, 0xed, 0xf5, 0x66, 0xcc, 0xec, - 0x7a, 0x38, 0x07, 0x74, 0xbe, 0x0e, 0xa0, 0x9f, 0x55, 0x07, 0x96, 0x2a, 0x4e, 0x7f, 0xb9, 0x03, 0x32, 0xb0, 0xa4, - 0x43, 0x0f, 0x53, 0x0a, 0x9f, 0xa5, 0xf4, 0x1f, 0x01, 0x3b, 0x05, 0x0e, 0x4b, 0x39, 0x16, 0xd9, 0x8d, 0xd9, 0xcc, - 0xda, 0xd6, 0xe4, 0xa4, 0x33, 0x17, 0x51, 0xf6, 0x7c, 0x6d, 0x57, 0xff, 0x5d, 0xac, 0xfa, 0x78, 0xc9, 0xc6, 0x29, - 0x20, 0x05, 0x05, 0x05, 0xb1, 0xc7, 0xf2, 0xf3, 0xd0, 0x2f, 0x19, 0x91, 0x41, 0x34, 0xbd, 0x1a, 0x74, 0xfa, 0x79, - 0xd2, 0x5f, 0x58, 0x94, 0xd4, 0x4a, 0xe8, 0x0f, 0xb8, 0xe1, 0x03, 0x77, 0xe7, 0x8c, 0x38, 0x37, 0x84, 0xfc, 0x34, - 0xf5, 0x23, 0xe6, 0x6e, 0xe6, 0x64, 0x16, 0x3f, 0x07, 0x72, 0xd2, 0x82, 0xd5, 0xf8, 0x13, 0x36, 0x82, 0xdc, 0x37, - 0x7e, 0x69, 0xa9, 0x02, 0x47, 0x97, 0x2b, 0xe9, 0xe9, 0xd2, 0xc9, 0x62, 0xa1, 0x75, 0x70, 0x77, 0x8a, 0xe0, 0x8b, - 0xb7, 0xc2, 0x3a, 0xff, 0xe5, 0x72, 0xeb, 0xae, 0x38, 0x1b, 0x36, 0xa2, 0x7e, 0x76, 0xbf, 0xba, 0x75, 0x1a, 0xbe, - 0x47, 0xbf, 0x8b, 0x57, 0xdf, 0xf5, 0x78, 0x21, 0x47, 0xb7, 0x6e, 0x0d, 0x56, 0xf3, 0x74, 0x05, 0x45, 0x33, 0xb0, - 0x2f, 0x5e, 0x94, 0x83, 0x2f, 0x60, 0x34, 0xee, 0x78, 0x11, 0x6c, 0x0a, 0xed, 0xf3, 0xce, 0xf3, 0xe5, 0x15, 0x55, - 0x93, 0x85, 0xf4, 0x76, 0xcd, 0xc6, 0xf0, 0xfe, 0xba, 0xbd, 0xf9, 0x59, 0xfc, 0x54, 0x7c, 0x85, 0x46, 0xe8, 0xb7, - 0x0b, 0x40, 0xba, 0xfa, 0x92, 0x15, 0x8f, 0x3e, 0x6f, 0x85, 0x58, 0xcd, 0xf7, 0x8e, 0x67, 0xb1, 0x42, 0xe0, 0xe3, - 0x5b, 0xd1, 0xeb, 0x25, 0x3c, 0xb3, 0x9a, 0x75, 0x82, 0xb9, 0x03, 0x88, 0x50, 0x44, 0x1a, 0x34, 0x11, 0xe4, 0xbb, - 0xb3, 0x61, 0x2e, 0x54, 0x67, 0x35, 0x2f, 0xff, 0xbc, 0xbc, 0xa2, 0x1e, 0x51, 0x2f, 0x7e, 0x73, 0xbd, 0x81, 0x44, - 0xab, 0xae, 0x85, 0x1a, 0xbf, 0x4b, 0x8d, 0x53, 0xe6, 0x41, 0x03, 0x24, 0x69, 0xe8, 0x50, 0xcb, 0xfb, 0x10, 0x8f, - 0x8b, 0xed, 0x6b, 0x34, 0x7e, 0xdf, 0xc4, 0x5a, 0x11, 0x15, 0x79, 0x4f, 0x81, 0x2c, 0x0e, 0x94, 0x50, 0x5a, 0x5e, - 0xc8, 0xdf, 0x15, 0xa6, 0x5a, 0x64, 0xce, 0xc3, 0xc4, 0x59, 0xa0, 0xfe, 0x7a, 0xf4, 0xd4, 0xfb, 0x52, 0x07, 0x7c, - 0x63, 0x61, 0x03, 0xd7, 0x73, 0x43, 0x8d, 0x60, 0x1c, 0xa4, 0x48, 0x35, 0xa8, 0x0d, 0x33, 0x6a, 0x30, 0x79, 0x4d, - 0xc7, 0x96, 0x62, 0xaf, 0xa4, 0x3f, 0xe4, 0x99, 0x2e, 0xac, 0xf2, 0x6d, 0xd5, 0x23, 0x3b, 0xbd, 0x8d, 0x0b, 0xfe, - 0x59, 0xf3, 0xde, 0x7c, 0x8d, 0x78, 0xba, 0x6c, 0x48, 0xb0, 0xa4, 0xe7, 0xc9, 0x71, 0xeb, 0xfc, 0xa2, 0xba, 0x9e, - 0xab, 0x78, 0x04, 0x99, 0x12, 0x2e, 0x09, 0xb9, 0x8e, 0x73, 0xbd, 0x97, 0x30, 0x10, 0x21, 0x5f, 0xd7, 0x67, 0x09, - 0x50, 0xda, 0xd6, 0x97, 0x50, 0x0b, 0x1f, 0x4a, 0xb3, 0xaf, 0xdd, 0x12, 0x8e, 0xcc, 0x7d, 0x57, 0xec, 0x5d, 0x1d, - 0x39, 0x5d, 0x38, 0x24, 0x15, 0xa0, 0xef, 0xb9, 0xe8, 0xf0, 0x8d, 0xb1, 0xb0, 0x48, 0xc4, 0xa6, 0x37, 0xd1, 0x8d, - 0x66, 0xdd, 0xe0, 0x57, 0x27, 0x9d, 0x06, 0x5f, 0x1b, 0x38, 0x15, 0xb1, 0xa6, 0x00, 0x3b, 0xf4, 0xb8, 0xb1, 0x3b, - 0x40, 0x88, 0x6f, 0x5a, 0xed, 0x84, 0xce, 0xd6, 0x8d, 0x23, 0x14, 0x04, 0x03, 0xea, 0x45, 0xd4, 0x8a, 0xf2, 0xfb, - 0x4a, 0x27, 0x11, 0xf5, 0x06, 0xbf, 0x7b, 0x7d, 0xef, 0xe5, 0xa9, 0x2a, 0xbe, 0xde, 0xab, 0x07, 0x7a, 0xb2, 0x1d, - 0x90, 0xd8, 0x34, 0x15, 0x6b, 0x40, 0x93, 0x67, 0x4e, 0x81, 0xb8, 0xc1, 0x3c, 0xf7, 0x61, 0x3f, 0xc6, 0x7c, 0x8d, - 0x40, 0x8d, 0x4e, 0x84, 0x6a, 0x49, 0xa4, 0x2f, 0x57, 0x2a, 0x63, 0xdd, 0x2c, 0xe2, 0xd9, 0x45, 0x93, 0xf7, 0x7f, - 0x6f, 0x1c, 0x5c, 0xd7, 0x54, 0x50, 0x6e, 0xda, 0x33, 0xff, 0xeb, 0x9a, 0xc2, 0x06, 0xc0, 0x03, 0x7c, 0x5d, 0x42, - 0x72, 0x65, 0xc0, 0x87, 0x6f, 0x0a, 0x75, 0x5e, 0xdd, 0xe6, 0x2d, 0x64, 0x65, 0x40, 0xe4, 0xb4, 0x7d, 0x80, 0xf0, - 0x36, 0x60, 0x0c, 0x23, 0x2e, 0x40, 0xf4, 0xd1, 0x31, 0xdd, 0xaa, 0x69, 0x20, 0xee, 0xca, 0x56, 0x1f, 0xcf, 0x04, - 0xbc, 0x12, 0x7e, 0x63, 0x18, 0xc7, 0x10, 0xe2, 0x33, 0x8e, 0xed, 0xf1, 0x25, 0x54, 0x0e, 0x34, 0xca, 0x83, 0x55, - 0x79, 0xfc, 0x39, 0xf7, 0xfe, 0xea, 0xab, 0x66, 0x64, 0xd3, 0x80, 0xb9, 0xd1, 0xb4, 0xa1, 0x63, 0xc5, 0xba, 0xe4, - 0x6f, 0xbd, 0x43, 0x63, 0xb0, 0x2f, 0x5b, 0x5f, 0xc2, 0xfd, 0x4e, 0x46, 0xc3, 0x0d, 0x8c, 0xc0, 0x18, 0x0c, 0x54, - 0x95, 0x52, 0x90, 0xfe, 0xe2, 0xa5, 0x9d, 0x8b, 0x92, 0xf7, 0xa4, 0x93, 0xbd, 0x11, 0xca, 0x83, 0x42, 0x8b, 0x81, - 0x8b, 0x7e, 0x53, 0x6b, 0x25, 0xee, 0x63, 0xf9, 0xae, 0x8f, 0xd6, 0x54, 0xba, 0x99, 0x11, 0xd9, 0x52, 0x87, 0x51, - 0xdc, 0x9c, 0x3b, 0x69, 0xe6, 0xa1, 0x53, 0x60, 0x91, 0xe6, 0x66, 0x79, 0x00, 0xe2, 0x5b, 0xd4, 0xc8, 0xe6, 0x94, - 0xff, 0x89, 0x47, 0xdd, 0x40, 0x88, 0xc8, 0xb2, 0xbe, 0x6b, 0x8a, 0x33, 0x28, 0x94, 0xe4, 0x06, 0x85, 0xf0, 0xde, - 0xc8, 0xa0, 0x40, 0xc9, 0x52, 0xd9, 0x48, 0xfa, 0xfd, 0x27, 0x1e, 0x54, 0xe8, 0xe9, 0xce, 0x91, 0x64, 0xeb, 0x36, - 0x0b, 0x6b, 0x28, 0x8d, 0x32, 0x31, 0xbb, 0xd9, 0xc9, 0xb7, 0x05, 0x05, 0x45, 0x49, 0x39, 0x51, 0xa4, 0x19, 0x0e, - 0x77, 0xfa, 0x5f, 0xee, 0x51, 0xde, 0xb1, 0x40, 0xb9, 0xcd, 0x9c, 0x96, 0x00, 0x01, 0x44, 0xfd, 0x5c, 0x40, 0x34, - 0x51, 0xa4, 0x14, 0x72, 0x79, 0x23, 0x2f, 0xf3, 0xd1, 0xad, 0x79, 0xca, 0x41, 0xfb, 0xca, 0xfe, 0x94, 0x30, 0xe7, - 0xb6, 0x92, 0x3e, 0x92, 0x31, 0x31, 0x52, 0x17, 0xdc, 0xd0, 0x81, 0x61, 0xbd, 0x77, 0xe8, 0xe5, 0x53, 0x63, 0xc2, - 0xef, 0x2f, 0x82, 0x22, 0x88, 0x42, 0x00, 0x80, 0x69, 0x59, 0xb6, 0xa4, 0xf0, 0x49, 0x12, 0x05, 0x90, 0xf5, 0xb8, - 0xf4, 0xc0, 0xb5, 0x24, 0x30, 0x3c, 0xaa, 0x09, 0x68, 0xde, 0x2e, 0x50, 0x38, 0xa0, 0x85, 0x95, 0xeb, 0xb0, 0x76, - 0x42, 0xaa, 0x26, 0x45, 0xab, 0x9b, 0xd5, 0x92, 0x94, 0x67, 0x06, 0x4c, 0x15, 0x91, 0xa7, 0xf5, 0x3f, 0x64, 0xbe, - 0xb4, 0x40, 0xf4, 0xc6, 0x7c, 0x16, 0x5c, 0x3f, 0x56, 0x3b, 0x8e, 0x5e, 0x37, 0x4c, 0x6b, 0x37, 0x48, 0x02, 0x44, - 0x3e, 0x95, 0xd5, 0xd5, 0x2b, 0x15, 0xa4, 0xa1, 0xc6, 0x8f, 0x7c, 0xaf, 0x14, 0xe4, 0x4a, 0xe9, 0xa5, 0xa0, 0x00, - 0xdd, 0x78, 0xe9, 0x88, 0xa7, 0x6c, 0xe9, 0xc5, 0xa6, 0xb0, 0x71, 0xc2, 0xd8, 0xeb, 0xd9, 0x8a, 0x93, 0x7a, 0xec, - 0xaa, 0x4e, 0xb2, 0x04, 0x5d, 0x5c, 0x4b, 0x5e, 0xc5, 0x91, 0xe9, 0xd2, 0xd4, 0x31, 0xf5, 0xef, 0x1a, 0xed, 0x89, - 0x15, 0xba, 0xff, 0x2d, 0x91, 0x3b, 0xaf, 0x4c, 0xd3, 0x02, 0x41, 0xd6, 0x82, 0x90, 0xe0, 0x7c, 0x27, 0x44, 0x1e, - 0x95, 0xc7, 0xa4, 0x65, 0xee, 0xf1, 0xb5, 0x6e, 0xc6, 0x53, 0xda, 0x03, 0x51, 0x3e, 0xcc, 0x71, 0x97, 0x12, 0xe6, - 0x9e, 0x3d, 0xb0, 0x32, 0x4c, 0x4c, 0xec, 0x83, 0x1e, 0x3d, 0xae, 0x59, 0x01, 0xc1, 0x30, 0xfd, 0xda, 0xa5, 0xdd, - 0xed, 0xfa, 0x61, 0x0b, 0xe0, 0x5d, 0x2e, 0x84, 0xfa, 0xb9, 0x3a, 0x71, 0x53, 0x78, 0x75, 0x83, 0xb6, 0x8a, 0xd5, - 0x1a, 0x54, 0xb4, 0xdc, 0xa1, 0x6d, 0xeb, 0xcf, 0x69, 0x06, 0x1f, 0x3b, 0x39, 0x10, 0x6a, 0x3a, 0x22, 0x98, 0x89, - 0x72, 0x24, 0x0d, 0x9e, 0xb8, 0xea, 0x6c, 0x91, 0xaa, 0x77, 0x73, 0x02, 0x64, 0x48, 0xea, 0x0b, 0x32, 0x84, 0x36, - 0x20, 0x74, 0xac, 0xa9, 0xf2, 0xb5, 0x41, 0xed, 0xd6, 0x13, 0x63, 0x6f, 0xdf, 0x84, 0x16, 0x45, 0x85, 0xbe, 0x2d, - 0x16, 0xbb, 0x64, 0x8c, 0xbe, 0xe0, 0x6f, 0xcc, 0x7e, 0x92, 0xd1, 0xc3, 0x67, 0xb5, 0xd1, 0x45, 0x3b, 0x88, 0x5d, - 0x60, 0xfc, 0xa3, 0xc9, 0xdb, 0x9a, 0x55, 0x5c, 0x7e, 0x75, 0x41, 0x55, 0xab, 0xd9, 0x62, 0xfa, 0xb3, 0xae, 0x70, - 0x89, 0x64, 0xa2, 0xc4, 0x0c, 0x7f, 0x10, 0x68, 0xf5, 0x7d, 0xe0, 0xdc, 0xe7, 0xb9, 0x9e, 0xa0, 0xff, 0xe2, 0xa5, - 0x77, 0x28, 0xa3, 0x42, 0xc8, 0xc7, 0x91, 0x2f, 0xa4, 0x88, 0x55, 0xba, 0x7b, 0xb4, 0xd1, 0x12, 0x39, 0x0b, 0x64, - 0xcd, 0x6a, 0xca, 0x34, 0xd4, 0x85, 0x63, 0x8b, 0x2e, 0xd3, 0x6c, 0x17, 0xd0, 0x32, 0xac, 0xa4, 0x63, 0xeb, 0x9d, - 0x05, 0x84, 0x22, 0x22, 0x80, 0x29, 0x69, 0xf0, 0x9f, 0x5d, 0x69, 0x8b, 0xc5, 0xdc, 0xb4, 0x94, 0x7d, 0x2e, 0x23, - 0x31, 0xd7, 0x13, 0xb2, 0x1b, 0xb8, 0x5e, 0xdc, 0x08, 0x4d, 0x6b, 0x84, 0xe4, 0x24, 0xd1, 0xa3, 0x5e, 0xa8, 0x37, - 0x55, 0x59, 0x34, 0x7a, 0x40, 0x54, 0x03, 0xd7, 0xbb, 0x69, 0x27, 0x52, 0x12, 0x2c, 0x15, 0xdd, 0x07, 0x6e, 0xdd, - 0xad, 0x75, 0x98, 0x70, 0x85, 0x9c, 0x97, 0x50, 0x23, 0x86, 0x84, 0xfb, 0x80, 0x3d, 0x94, 0x4c, 0x00, 0x98, 0x82, - 0x13, 0x68, 0x09, 0xb0, 0xed, 0x56, 0x50, 0x02, 0x06, 0xac, 0xcc, 0x34, 0xa2, 0x30, 0xf3, 0xd0, 0x15, 0x26, 0xe4, - 0x38, 0x37, 0x8f, 0x3a, 0x58, 0x90, 0x2a, 0x44, 0xdb, 0xef, 0x4d, 0x0f, 0xe6, 0x38, 0x33, 0xce, 0x91, 0x0b, 0x80, - 0xe3, 0x2d, 0x28, 0xd5, 0x30, 0x34, 0x6c, 0xff, 0xaa, 0xc9, 0x2a, 0x67, 0x44, 0x62, 0xd5, 0x2b, 0x9b, 0xfd, 0x2a, - 0xfe, 0x18, 0x0a, 0x2a, 0x69, 0x3a, 0xbe, 0x49, 0x4c, 0x3d, 0x5b, 0x5e, 0x7d, 0x65, 0x78, 0xd2, 0xb3, 0x7d, 0xc0, - 0x15, 0x8f, 0xc0, 0xba, 0x29, 0xf9, 0x51, 0x27, 0x83, 0x06, 0xe0, 0xa8, 0x45, 0x3b, 0x54, 0x9d, 0x62, 0x10, 0x30, - 0xe2, 0x74, 0x5a, 0x96, 0xfc, 0x25, 0x8a, 0x0d, 0x34, 0xf1, 0x18, 0x2f, 0x58, 0x3a, 0xb1, 0xbd, 0xa3, 0xf9, 0xaa, - 0x44, 0x23, 0xcb, 0xac, 0x0d, 0x92, 0xfc, 0x36, 0xbd, 0xd6, 0x2a, 0x23, 0xed, 0x6d, 0xd9, 0x21, 0xfe, 0x11, 0xc8, - 0x82, 0x31, 0xa3, 0x22, 0x51, 0x31, 0x45, 0xc6, 0xa5, 0xf1, 0x56, 0xb2, 0x00, 0x5d, 0xa6, 0x67, 0x6b, 0xf3, 0x8a, - 0xbc, 0x7d, 0x12, 0xcd, 0x7d, 0x30, 0x55, 0x61, 0xff, 0x72, 0x34, 0x5b, 0x1e, 0xab, 0xf0, 0x8f, 0x55, 0x75, 0x04, - 0x9a, 0xb6, 0xab, 0xa7, 0x40, 0x8e, 0x4e, 0xd5, 0xc5, 0x21, 0x39, 0xf6, 0xc2, 0x8c, 0x43, 0x12, 0x72, 0xb2, 0x78, - 0x1b, 0xac, 0x4f, 0x32, 0xb4, 0x46, 0x80, 0x2f, 0x17, 0x61, 0xd5, 0x2b, 0xcd, 0x1e, 0x65, 0xb2, 0x1a, 0x59, 0x2b, - 0x28, 0x4d, 0x10, 0x45, 0xf3, 0x14, 0x09, 0x03, 0xcf, 0x72, 0xa2, 0x30, 0x61, 0x38, 0x25, 0xec, 0x43, 0xa2, 0x8b, - 0xf6, 0x0f, 0x33, 0xcb, 0x87, 0x12, 0xb0, 0xa5, 0x79, 0x12, 0x20, 0x46, 0x80, 0x51, 0xa5, 0x58, 0xd1, 0x3f, 0x38, - 0x4f, 0x1c, 0x0f, 0x73, 0x49, 0x22, 0x3f, 0xe3, 0xfd, 0x91, 0x79, 0xd3, 0xcd, 0xcb, 0x23, 0xdb, 0x90, 0x26, 0x66, - 0xaa, 0xa7, 0x70, 0x8d, 0xd8, 0x6e, 0xbb, 0x80, 0x2d, 0x54, 0xba, 0x41, 0xb5, 0x2f, 0x8a, 0x20, 0xf4, 0x2f, 0x75, - 0x90, 0xd6, 0xfc, 0x37, 0x47, 0x1b, 0x4c, 0x8c, 0xde, 0x64, 0x07, 0x8c, 0xfb, 0x66, 0xaa, 0xba, 0x96, 0x40, 0xc7, - 0xa6, 0x2a, 0xfc, 0x76, 0x70, 0x09, 0x89, 0xb9, 0x32, 0x16, 0xbd, 0xd5, 0x19, 0x59, 0xe5, 0xfe, 0xdf, 0x36, 0x1d, - 0x41, 0xb7, 0x7f, 0x9d, 0x5d, 0xcd, 0xce, 0x03, 0x64, 0x91, 0x07, 0x8e, 0x88, 0xa5, 0x7a, 0x6a, 0xf3, 0x68, 0x58, - 0x58, 0xaa, 0x2b, 0xc7, 0xfb, 0xb8, 0x92, 0x36, 0x9f, 0x97, 0x86, 0x03, 0x22, 0x72, 0x30, 0xbd, 0x35, 0xf0, 0x5b, - 0x24, 0x32, 0xaf, 0x6a, 0x1c, 0xd1, 0xa9, 0x8b, 0x71, 0x31, 0xae, 0x15, 0x94, 0x46, 0x7e, 0xdc, 0x49, 0x3f, 0x46, - 0x47, 0x4b, 0x1f, 0x9f, 0x6e, 0xad, 0x8a, 0xee, 0xd5, 0x2f, 0x72, 0x28, 0xe6, 0x65, 0x19, 0x1d, 0x08, 0x19, 0x24, - 0x7b, 0x4f, 0xbe, 0xf3, 0x9e, 0xb8, 0xcc, 0x45, 0x4f, 0x8d, 0x0a, 0x0e, 0xbd, 0xbd, 0x8d, 0x2c, 0x53, 0x39, 0x72, - 0x07, 0xcc, 0xce, 0xf8, 0xda, 0xde, 0x40, 0x6c, 0xef, 0x85, 0xc8, 0xad, 0xf0, 0x48, 0x61, 0xfa, 0x71, 0x65, 0x84, - 0xab, 0x31, 0xe9, 0x50, 0x99, 0x4c, 0xf3, 0xc2, 0x2e, 0x57, 0x59, 0xd0, 0x61, 0x19, 0x54, 0x33, 0x99, 0x99, 0x66, - 0xb2, 0x69, 0xa4, 0xe1, 0x0a, 0xc5, 0x34, 0x06, 0x2e, 0x97, 0x2a, 0x52, 0xf6, 0xbc, 0x92, 0xa5, 0xe7, 0x38, 0x0b, - 0x1d, 0xa6, 0x4d, 0x07, 0xcf, 0x53, 0xe2, 0x92, 0x70, 0x84, 0x35, 0x13, 0x4c, 0x93, 0xac, 0xb4, 0x40, 0xb9, 0xa8, - 0xa4, 0x18, 0xba, 0x3e, 0xaf, 0x24, 0x65, 0xee, 0x68, 0x19, 0x4f, 0x69, 0xf4, 0x8c, 0xf2, 0x15, 0xb5, 0x66, 0xfe, - 0xc9, 0xf2, 0xef, 0x20, 0x85, 0xd6, 0x57, 0x40, 0x05, 0xa6, 0x14, 0xac, 0x04, 0xf9, 0xfb, 0xc5, 0x8d, 0x56, 0x11, - 0x97, 0x82, 0xf3, 0x2a, 0xe6, 0x65, 0x53, 0x0d, 0x69, 0xbe, 0xfe, 0xe4, 0x7f, 0xa6, 0x93, 0x83, 0x4a, 0x1c, 0x6e, - 0x00, 0x33, 0x86, 0x5c, 0x2c, 0xe8, 0x4f, 0xa5, 0x57, 0x5f, 0xa9, 0x97, 0xa2, 0x46, 0x5d, 0xe8, 0xee, 0x96, 0xdc, - 0x5a, 0xcf, 0x46, 0x9a, 0x68, 0x56, 0x2a, 0xdf, 0x0f, 0x92, 0x66, 0x86, 0x1a, 0xe1, 0x62, 0x2f, 0x36, 0x60, 0xdc, - 0x1a, 0xa7, 0x50, 0x7b, 0x2f, 0x59, 0xc2, 0x67, 0x8b, 0xcb, 0x41, 0x95, 0xc2, 0x18, 0xdf, 0x81, 0xb9, 0x21, 0xf7, - 0xc1, 0x93, 0xde, 0x7e, 0xbb, 0xf3, 0x53, 0xbc, 0x0c, 0xec, 0x12, 0x11, 0x0f, 0xa2, 0xdf, 0xdc, 0x2a, 0x6d, 0xaf, - 0x37, 0x16, 0x36, 0x57, 0xc5, 0x0f, 0x2a, 0x55, 0xe0, 0xce, 0x2b, 0x77, 0x61, 0x50, 0x1e, 0x41, 0x0e, 0xfa, 0x4d, - 0xe3, 0xe6, 0x7e, 0x27, 0x54, 0x61, 0xd8, 0xa5, 0x87, 0x49, 0x59, 0xe7, 0x4b, 0x7a, 0x18, 0x33, 0xc4, 0xce, 0xcc, - 0x32, 0xa9, 0xd0, 0xae, 0x65, 0x41, 0xe3, 0xa7, 0xe0, 0x8f, 0x28, 0xa3, 0x48, 0x2b, 0x26, 0xb0, 0x0f, 0x32, 0x01, - 0xc7, 0x07, 0xc1, 0xa8, 0x2e, 0xe2, 0x13, 0x9c, 0xee, 0xce, 0x0b, 0x0e, 0x54, 0x32, 0xb4, 0x48, 0xb0, 0xc4, 0x1e, - 0xf1, 0xb0, 0xa9, 0x1f, 0xec, 0x9d, 0xda, 0x55, 0x38, 0x6f, 0x16, 0xeb, 0x31, 0x48, 0xf5, 0xfc, 0xb6, 0xf9, 0x84, - 0x03, 0xfc, 0x51, 0x9d, 0xea, 0xf1, 0x4d, 0x1d, 0xaf, 0x71, 0x08, 0xab, 0x43, 0xe5, 0x16, 0x7f, 0x52, 0x90, 0xce, - 0xb8, 0xa0, 0x87, 0xfd, 0x2b, 0x69, 0xf1, 0x05, 0x65, 0x37, 0x01, 0x1b, 0xbd, 0xf5, 0xa0, 0x04, 0xa1, 0xf3, 0xfe, - 0xe1, 0xd1, 0x7d, 0x16, 0x14, 0x6b, 0x44, 0x1d, 0x35, 0xf1, 0x6e, 0xb4, 0x9b, 0x54, 0x5c, 0x10, 0xab, 0x36, 0x5b, - 0xed, 0xb0, 0x0c, 0xd1, 0xfb, 0x37, 0x19, 0x59, 0x80, 0xa2, 0xbd, 0xe9, 0x79, 0x19, 0xac, 0x56, 0x4f, 0x13, 0x12, - 0x86, 0x6f, 0x20, 0xab, 0x29, 0x6c, 0x33, 0xdd, 0xca, 0xe8, 0x73, 0x60, 0x8e, 0x9e, 0x74, 0xd6, 0xd4, 0x82, 0xb1, - 0x65, 0xd4, 0x9f, 0x29, 0x0b, 0x27, 0x1f, 0xcb, 0xe0, 0xe7, 0x85, 0x29, 0x75, 0x07, 0x0d, 0xc9, 0x62, 0xc4, 0xca, - 0x4d, 0x3c, 0x74, 0xe8, 0xaa, 0x04, 0x83, 0xf5, 0xdb, 0x7a, 0xe3, 0xac, 0xd7, 0x38, 0x20, 0xf4, 0xde, 0x0f, 0x5c, - 0x2d, 0xfc, 0x10, 0x89, 0x11, 0xde, 0x90, 0x36, 0x47, 0x9d, 0xf1, 0xe2, 0x37, 0xde, 0x1b, 0x43, 0xb9, 0xbd, 0xae, - 0xf8, 0xa3, 0x5f, 0xd7, 0x95, 0x2a, 0x74, 0x25, 0x71, 0x66, 0xee, 0x63, 0x49, 0xd1, 0x23, 0x53, 0xda, 0xc5, 0x3d, - 0x00, 0x58, 0x98, 0x8d, 0x8a, 0xd0, 0xa4, 0x91, 0xb8, 0xfc, 0x54, 0x61, 0xa5, 0x52, 0x9f, 0x50, 0x72, 0x22, 0x30, - 0x0c, 0xbe, 0xff, 0x28, 0xd2, 0x15, 0x47, 0x3f, 0xc0, 0x3f, 0x22, 0x20, 0x50, 0x6b, 0x16, 0x69, 0xa8, 0x1d, 0x90, - 0x8c, 0x9f, 0x0e, 0x17, 0xce, 0xce, 0xcc, 0x88, 0x20, 0x53, 0x77, 0x03, 0x02, 0x84, 0xe1, 0x1a, 0x81, 0x2e, 0xff, - 0x4a, 0x49, 0xdb, 0x96, 0x3b, 0xd4, 0x61, 0x90, 0x5d, 0xe8, 0x20, 0x5a, 0x2d, 0xfa, 0xa5, 0xca, 0xf8, 0x16, 0xd1, - 0xc9, 0xfa, 0xfa, 0xfd, 0xc7, 0xe5, 0x5e, 0xe4, 0x0f, 0x6e, 0x2d, 0x00, 0x98, 0x8d, 0x38, 0x1b, 0x27, 0xbc, 0x6a, - 0x1d, 0x8b, 0x8f, 0xd2, 0x35, 0x86, 0xed, 0x14, 0xb4, 0xe2, 0x41, 0xab, 0x46, 0x54, 0x8a, 0x75, 0x7e, 0xdc, 0x2b, - 0x0f, 0xed, 0x76, 0xef, 0x87, 0xc0, 0xb9, 0x60, 0x47, 0xcc, 0x13, 0xc0, 0xbc, 0xac, 0x5c, 0x15, 0x32, 0x4d, 0xb0, - 0x11, 0x07, 0x39, 0xc8, 0xb4, 0xeb, 0x1e, 0x98, 0xb2, 0x4d, 0x8b, 0xdd, 0x2d, 0x66, 0x61, 0x03, 0x19, 0x21, 0x05, - 0x9b, 0x84, 0x0f, 0xd9, 0x32, 0x09, 0xa5, 0x07, 0x0e, 0x33, 0xd0, 0xd6, 0x7a, 0x14, 0xfb, 0x35, 0x6d, 0x13, 0x5d, - 0xb2, 0xc0, 0xa5, 0x46, 0xb6, 0x6f, 0xfa, 0x84, 0x8e, 0xc5, 0xe4, 0x86, 0xef, 0x77, 0xe7, 0xd5, 0x18, 0x96, 0xed, - 0x63, 0x65, 0x2f, 0xeb, 0xaf, 0x57, 0x2b, 0x50, 0x33, 0x9d, 0xb9, 0x7c, 0xab, 0xe4, 0xdf, 0xf5, 0x61, 0xa0, 0xf6, - 0x42, 0xe1, 0xa3, 0x98, 0x40, 0x59, 0x4b, 0xea, 0x14, 0xbc, 0x35, 0xde, 0xaf, 0xda, 0x61, 0xdc, 0xbf, 0xb9, 0x0b, - 0x15, 0x37, 0xbe, 0xfe, 0xa7, 0x9b, 0xda, 0xe0, 0xe8, 0x8d, 0x70, 0x49, 0xe7, 0x7e, 0xbd, 0x82, 0x00, 0x51, 0x6f, - 0x61, 0xca, 0x3a, 0x6f, 0xaf, 0xae, 0x6b, 0xf2, 0x68, 0x4b, 0x3f, 0xee, 0x82, 0x0e, 0x9b, 0xac, 0x64, 0x6d, 0xa1, - 0x7b, 0x64, 0x35, 0xee, 0x7e, 0x46, 0x38, 0x74, 0xb0, 0x83, 0xd4, 0x2d, 0x3e, 0xf4, 0x0e, 0xd3, 0xeb, 0x94, 0x54, - 0x6f, 0xf5, 0x9b, 0xfa, 0xcb, 0x97, 0xc6, 0xb9, 0x1a, 0x35, 0xac, 0x5d, 0xd4, 0xa4, 0x64, 0x66, 0x0a, 0xa6, 0xdb, - 0x20, 0x85, 0xab, 0xbe, 0xfa, 0xca, 0xe0, 0xc8, 0xf7, 0x73, 0x42, 0x05, 0x9b, 0x10, 0xaa, 0xc7, 0x2f, 0x88, 0xae, - 0x64, 0xfe, 0x71, 0xbb, 0x32, 0x06, 0x49, 0xe8, 0xdb, 0x11, 0x6f, 0xa5, 0xa9, 0xb3, 0x43, 0x3e, 0xe6, 0x6c, 0x82, - 0x5f, 0xd2, 0x84, 0x40, 0xb3, 0xf0, 0x2f, 0x0d, 0xd8, 0xee, 0x70, 0x6c, 0x3d, 0xd0, 0xb8, 0xf8, 0x0f, 0x94, 0x1b, - 0xd1, 0x99, 0x85, 0x1d, 0xef, 0x66, 0xe6, 0x4b, 0x87, 0xc3, 0x9e, 0x61, 0x09, 0x54, 0x65, 0x18, 0xd0, 0x0f, 0xdd, - 0x90, 0xed, 0x52, 0x1d, 0x3b, 0x07, 0x09, 0xeb, 0x3d, 0x14, 0x62, 0x3f, 0x9a, 0xab, 0xcb, 0xeb, 0xe5, 0x81, 0xfb, - 0x0a, 0xc3, 0xe5, 0xc1, 0xb0, 0xf8, 0x98, 0x15, 0x52, 0x75, 0xe9, 0xba, 0x8e, 0x3d, 0xad, 0x31, 0x20, 0x1f, 0x33, - 0x0c, 0x7f, 0x0e, 0x06, 0x8b, 0x76, 0x64, 0xdb, 0x32, 0x38, 0xc3, 0xca, 0xdb, 0x32, 0x65, 0xa6, 0xec, 0x2e, 0xd8, - 0x9e, 0x1a, 0xf8, 0xb3, 0x93, 0x94, 0x11, 0x14, 0x2a, 0xd3, 0x11, 0x34, 0xfa, 0xc7, 0x57, 0x45, 0xad, 0xc8, 0x46, - 0xd6, 0xfc, 0xb6, 0x78, 0x67, 0x8c, 0x53, 0x5a, 0x97, 0xb3, 0x7a, 0x17, 0xab, 0x46, 0x1f, 0x9a, 0x44, 0x2b, 0x92, - 0xb5, 0xaf, 0xb1, 0xc7, 0xc0, 0x10, 0x46, 0xc8, 0x72, 0xb3, 0xd6, 0x66, 0x36, 0x38, 0x89, 0xe3, 0x51, 0x07, 0xd6, - 0xdb, 0x79, 0xe5, 0x15, 0x0c, 0x02, 0xe0, 0x5f, 0x43, 0xcc, 0xb3, 0x0d, 0xfd, 0xce, 0x74, 0x53, 0xd5, 0xcb, 0x25, - 0x14, 0x46, 0x7f, 0x8c, 0x49, 0x07, 0xe5, 0xa5, 0x6a, 0x2a, 0x0d, 0x92, 0x51, 0x3d, 0x16, 0xa4, 0xa3, 0xcb, 0x73, - 0xc6, 0x67, 0x1d, 0xec, 0x69, 0xd2, 0xcd, 0x00, 0x10, 0x69, 0x87, 0x32, 0x26, 0x2a, 0x84, 0x15, 0x1e, 0x19, 0xa1, - 0xca, 0x1d, 0xf8, 0x28, 0xe0, 0xf3, 0xee, 0xb4, 0x20, 0x98, 0xd5, 0x25, 0x87, 0xb7, 0x42, 0x54, 0x14, 0xb7, 0xb2, - 0x9f, 0x90, 0xcc, 0xc7, 0x66, 0x26, 0xda, 0x6b, 0xc6, 0x9a, 0x77, 0x7f, 0x0e, 0x41, 0xa3, 0x20, 0x74, 0x58, 0x44, - 0xf3, 0x43, 0x0e, 0x83, 0xe4, 0x95, 0xd5, 0xd5, 0xc9, 0xf0, 0x5b, 0x81, 0x64, 0x05, 0x42, 0x74, 0xe2, 0x12, 0x84, - 0xde, 0x7e, 0x32, 0xec, 0x82, 0x57, 0xa0, 0x70, 0x28, 0x1c, 0x2f, 0x81, 0xcd, 0x27, 0x46, 0xc7, 0x72, 0xec, 0x74, - 0xc8, 0x55, 0x85, 0x3a, 0x59, 0x45, 0xd0, 0xda, 0x92, 0x9f, 0xf4, 0x95, 0x82, 0x98, 0x64, 0xcb, 0x96, 0x20, 0xa6, - 0x26, 0xc7, 0xc7, 0x09, 0xbd, 0x3f, 0xb1, 0x48, 0x1a, 0x92, 0x84, 0xef, 0x3e, 0xc1, 0x19, 0x23, 0x18, 0x63, 0x95, - 0x12, 0x63, 0x43, 0x59, 0x66, 0x7f, 0x38, 0x7d, 0x33, 0xc1, 0x81, 0x5f, 0x42, 0x91, 0xf2, 0x2a, 0x39, 0xe1, 0x19, - 0xc3, 0x5c, 0xca, 0xf1, 0xac, 0xe8, 0x1b, 0x75, 0xf8, 0x4b, 0x84, 0x22, 0x90, 0xe0, 0x2e, 0x2f, 0x67, 0xea, 0x8b, - 0xca, 0x4c, 0x69, 0x11, 0x6e, 0xf1, 0x1c, 0x6a, 0x8f, 0x1b, 0xb2, 0x13, 0x6f, 0xba, 0xf7, 0x7a, 0x84, 0x0f, 0x54, - 0x8a, 0x70, 0xde, 0x15, 0x83, 0xe5, 0x6e, 0x2c, 0xcc, 0xa5, 0x2f, 0x26, 0x7f, 0xa0, 0xe7, 0xb3, 0xb4, 0x9c, 0xf8, - 0xaa, 0xf9, 0xe6, 0xa8, 0x8b, 0x3f, 0xe2, 0x69, 0x89, 0x6d, 0xb9, 0xbd, 0x49, 0x2f, 0x3e, 0xdf, 0xe7, 0x7f, 0xd7, - 0x78, 0xb0, 0x50, 0xfd, 0x6c, 0x96, 0xe2, 0x06, 0xab, 0x07, 0x3e, 0x04, 0xb9, 0xc8, 0x4c, 0xe5, 0xda, 0xa6, 0xc7, - 0x9a, 0x3c, 0x6c, 0xc6, 0x3a, 0xb1, 0x5a, 0xf9, 0x36, 0xd8, 0xf4, 0x6a, 0x5b, 0xab, 0x5b, 0x13, 0xfa, 0x43, 0xad, - 0x64, 0x5b, 0xfd, 0x82, 0x07, 0x75, 0x34, 0x5f, 0x76, 0x86, 0xd5, 0xc5, 0xfe, 0x94, 0xc3, 0xa4, 0xf8, 0xbb, 0xb4, - 0xa2, 0x88, 0xfb, 0x4b, 0x06, 0x35, 0x75, 0x7e, 0x8c, 0x5f, 0xac, 0x0e, 0xa1, 0xdf, 0xc7, 0x11, 0xb5, 0x54, 0xfe, - 0x87, 0x13, 0xa5, 0x93, 0x49, 0x1e, 0xed, 0xf9, 0x34, 0x2d, 0x5e, 0xd1, 0xa5, 0xdb, 0xf1, 0x32, 0xf9, 0x56, 0x14, - 0x79, 0xbc, 0x58, 0x65, 0xc0, 0xec, 0x75, 0x68, 0xfa, 0xc9, 0xd3, 0x28, 0x39, 0x16, 0x57, 0x0c, 0x6e, 0x3d, 0x0e, - 0x1e, 0x91, 0x37, 0x35, 0xec, 0x4d, 0x28, 0x58, 0xec, 0xc0, 0x20, 0x3f, 0x06, 0x87, 0xa1, 0x43, 0x3d, 0x22, 0xde, - 0x35, 0xbe, 0x9c, 0xd4, 0x47, 0x58, 0x30, 0xab, 0x89, 0xf0, 0xdb, 0x82, 0xbc, 0x47, 0x22, 0x5a, 0x9f, 0x24, 0x8d, - 0xad, 0x74, 0xe0, 0xed, 0x2b, 0x41, 0x36, 0x39, 0xd0, 0x93, 0xde, 0xc2, 0x6c, 0xe7, 0x7c, 0xb4, 0xf2, 0xf7, 0x22, - 0xf9, 0x29, 0x24, 0xd2, 0x55, 0x15, 0x34, 0x75, 0x64, 0x1f, 0x91, 0x61, 0xed, 0x4b, 0xdd, 0xaf, 0x7d, 0x2d, 0xa4, - 0x24, 0xf8, 0xff, 0x69, 0x14, 0x0b, 0xba, 0x0b, 0x98, 0x77, 0x57, 0xc3, 0x30, 0x91, 0x93, 0xd5, 0xc4, 0x65, 0x89, - 0x6e, 0xea, 0x40, 0x61, 0x0c, 0xfb, 0x6d, 0xb4, 0x38, 0x5c, 0xd8, 0x97, 0x3c, 0x50, 0xa9, 0x5b, 0x8e, 0xe7, 0xb7, - 0x32, 0xa7, 0xf2, 0x62, 0x39, 0xa8, 0x0c, 0x5b, 0x03, 0xf0, 0x0c, 0x61, 0x18, 0x10, 0x0d, 0x39, 0x2e, 0x49, 0xb8, - 0xc2, 0xb0, 0x46, 0xf6, 0x58, 0x34, 0x52, 0x86, 0xd5, 0xc5, 0x8d, 0x2c, 0x4e, 0xa6, 0x89, 0x18, 0xce, 0xa7, 0x69, - 0x71, 0x02, 0xfc, 0xc0, 0x84, 0x1b, 0xec, 0xfa, 0xaa, 0xb0, 0x1c, 0xd0, 0x6c, 0x13, 0x1a, 0x2d, 0x52, 0x93, 0x66, - 0x95, 0x74, 0x52, 0xfe, 0x2f, 0xc7, 0x34, 0xd6, 0x19, 0xe9, 0x9c, 0x30, 0x22, 0xf2, 0x0f, 0x8d, 0x52, 0x76, 0x10, - 0xb6, 0x3a, 0x3c, 0x9c, 0x4d, 0x7e, 0x40, 0xd5, 0x46, 0xf7, 0x83, 0xaf, 0x2e, 0x64, 0xb0, 0x07, 0xc1, 0x91, 0xeb, - 0xe6, 0xa8, 0x49, 0xfe, 0x97, 0xa3, 0xfc, 0x73, 0x2e, 0x4e, 0x3a, 0x92, 0xb4, 0xb1, 0x62, 0x44, 0x64, 0x63, 0xfa, - 0x83, 0x4d, 0x9b, 0xc2, 0xa1, 0x0f, 0x0b, 0x3a, 0x9e, 0xa2, 0x40, 0x1a, 0xbd, 0xaa, 0xe4, 0xa0, 0x30, 0x19, 0x20, - 0x7b, 0x25, 0x65, 0xde, 0x2f, 0xe4, 0x32, 0xc8, 0x7c, 0xab, 0x56, 0x66, 0xcb, 0x67, 0x0c, 0xc4, 0x08, 0x37, 0xc2, - 0xe0, 0x57, 0x8a, 0xa9, 0xaf, 0x59, 0xa6, 0xb8, 0x9f, 0x86, 0x90, 0x1d, 0x12, 0x86, 0x1f, 0x68, 0x53, 0x26, 0xa7, - 0x4d, 0x13, 0x2f, 0xf5, 0xd5, 0xd5, 0x30, 0x79, 0x81, 0x4c, 0xde, 0xe0, 0x3e, 0x72, 0xdd, 0x8f, 0xfa, 0x6d, 0x93, - 0x60, 0x7f, 0x95, 0xe0, 0xff, 0x0e, 0x21, 0x5b, 0x0b, 0x60, 0x06, 0x89, 0x8d, 0xbe, 0x5d, 0xf0, 0x71, 0x51, 0xe4, - 0xeb, 0x44, 0xac, 0xa8, 0x8c, 0x4b, 0xb2, 0x5b, 0x07, 0xbb, 0xd2, 0x36, 0x18, 0x6c, 0x90, 0x68, 0x68, 0x32, 0x0d, - 0x9d, 0x2c, 0xdf, 0x2c, 0x0d, 0xc9, 0xb7, 0x65, 0x9d, 0x3b, 0x14, 0x1a, 0x49, 0x0d, 0x6e, 0xd3, 0xf2, 0x39, 0xf5, - 0x94, 0x6c, 0x78, 0x6c, 0x2b, 0x79, 0x50, 0x61, 0x3a, 0x36, 0xb3, 0x79, 0xb4, 0x62, 0x3d, 0xaf, 0xd6, 0x39, 0x24, - 0x76, 0x5b, 0x56, 0x0e, 0x56, 0x64, 0x88, 0x4f, 0x5c, 0x8f, 0xd6, 0xf6, 0xa3, 0xef, 0x63, 0x44, 0x25, 0x39, 0x18, - 0x96, 0x2d, 0x95, 0xa7, 0x16, 0x27, 0x9e, 0xda, 0xfd, 0x60, 0xfa, 0xb2, 0x48, 0x85, 0x17, 0x4d, 0xe6, 0x8b, 0xd4, - 0xa4, 0x38, 0xcc, 0xc5, 0x48, 0xcc, 0x81, 0xa5, 0x7d, 0x84, 0x8c, 0xc1, 0x52, 0x7a, 0xa4, 0xb1, 0x8d, 0x91, 0xb7, - 0xe8, 0x50, 0x2e, 0x3a, 0xfb, 0x9f, 0x59, 0x7a, 0x0a, 0x43, 0xda, 0x2c, 0x8d, 0x6b, 0xb7, 0x54, 0x5e, 0xd9, 0x0f, - 0xae, 0xb2, 0x6d, 0x1b, 0x8b, 0xa0, 0x7e, 0xb2, 0xde, 0x92, 0x4c, 0x2a, 0x70, 0x30, 0x31, 0xd0, 0x43, 0x65, 0x37, - 0x5a, 0x6b, 0x7a, 0x3c, 0x4a, 0x5b, 0xa4, 0x89, 0xb0, 0x36, 0x32, 0xaa, 0xba, 0x8a, 0xb8, 0xbd, 0xd2, 0x97, 0x7c, - 0x05, 0x18, 0x6f, 0xbc, 0xb9, 0xb9, 0x46, 0xc3, 0x1d, 0x73, 0x12, 0xc5, 0x20, 0xde, 0xad, 0x8c, 0x27, 0x4d, 0xeb, - 0xf2, 0xdb, 0x72, 0xe2, 0x53, 0xbd, 0x6e, 0x31, 0x81, 0x0c, 0xce, 0x82, 0x78, 0x3d, 0x03, 0x58, 0x65, 0x57, 0x11, - 0xd5, 0x66, 0x41, 0x82, 0x60, 0x1a, 0x5c, 0xc7, 0x1a, 0xb7, 0x39, 0xca, 0x36, 0x09, 0x7a, 0xe6, 0x68, 0x2c, 0xff, - 0x9d, 0x59, 0xe0, 0xe5, 0x45, 0x81, 0xf6, 0xc4, 0x81, 0xef, 0xc6, 0x33, 0xf6, 0xfa, 0x9f, 0x8f, 0x8c, 0x01, 0x1c, - 0xd4, 0x98, 0x93, 0xd9, 0xe1, 0x55, 0xda, 0xaa, 0xf4, 0x2a, 0xa9, 0xb2, 0x3d, 0xc4, 0xab, 0xde, 0x8c, 0x24, 0x4a, - 0xa9, 0x0b, 0xb6, 0x6c, 0xe6, 0x41, 0xe2, 0x35, 0x47, 0x7f, 0xac, 0x25, 0x92, 0xbd, 0x82, 0x86, 0x91, 0x33, 0xf1, - 0x8b, 0x4f, 0x89, 0x79, 0xa7, 0x7d, 0xc5, 0xe4, 0x79, 0x83, 0x8f, 0xe0, 0x8b, 0x4a, 0x5d, 0x34, 0xde, 0x38, 0x84, - 0x3a, 0xd6, 0x30, 0x41, 0x0a, 0x30, 0x73, 0x80, 0x8b, 0x62, 0xe5, 0x93, 0x51, 0x52, 0xec, 0x9d, 0x16, 0xdb, 0xbc, - 0x16, 0x8a, 0x57, 0xfe, 0xa2, 0x94, 0xa6, 0xf6, 0xf2, 0xa9, 0x0d, 0xe5, 0xfb, 0x0d, 0x74, 0x1a, 0xeb, 0x8d, 0xe8, - 0xa3, 0x94, 0xf2, 0xec, 0x3a, 0xe1, 0x0c, 0x8f, 0xad, 0x8a, 0x59, 0x3c, 0x58, 0x35, 0xce, 0x3e, 0x84, 0x12, 0x1e, - 0x2d, 0x5b, 0x52, 0x76, 0xf3, 0x14, 0x86, 0xbf, 0xc3, 0x4a, 0xa1, 0xa8, 0x45, 0x80, 0xc0, 0xba, 0x8d, 0x7f, 0xdc, - 0x69, 0x3a, 0x6f, 0xa7, 0xb3, 0xc1, 0xc6, 0xe2, 0x68, 0xd8, 0x1e, 0xa9, 0x32, 0xf0, 0xcb, 0xc7, 0x33, 0x6b, 0x4d, - 0x0a, 0x17, 0x78, 0xa8, 0xc2, 0xf6, 0xaf, 0xa3, 0x3d, 0x69, 0xbe, 0x23, 0xe0, 0x8e, 0x41, 0x3a, 0x01, 0xdf, 0x79, - 0x08, 0x5d, 0x00, 0xed, 0x14, 0x62, 0x17, 0x21, 0x75, 0x09, 0x72, 0x97, 0xa1, 0xed, 0x5a, 0x78, 0x40, 0x4c, 0xf0, - 0xb0, 0x32, 0xc3, 0xa3, 0xca, 0x02, 0x8f, 0x2b, 0x7b, 0x78, 0x42, 0x1c, 0xe0, 0x69, 0x65, 0x85, 0x85, 0x8a, 0xea, - 0xb2, 0xba, 0xaa, 0xae, 0xab, 0xa5, 0x32, 0xf4, 0x2b, 0x96, 0x05, 0xc6, 0xbf, 0x20, 0x46, 0xb0, 0xf8, 0xa0, 0x31, - 0xe5, 0xf6, 0xc1, 0xc3, 0x47, 0x8f, 0x9f, 0x3c, 0x0d, 0x55, 0xa6, 0xf3, 0x75, 0xd6, 0xa4, 0xe6, 0x71, 0x6b, 0xa8, - 0x23, 0xef, 0x7c, 0x39, 0xf4, 0x2d, 0x03, 0xea, 0xec, 0x69, 0x1a, 0x86, 0x2f, 0x5d, 0x7e, 0x3d, 0xf1, 0x4b, 0xc5, - 0xeb, 0xc6, 0x37, 0x66, 0x7c, 0xef, 0x8b, 0x4f, 0x13, 0x5a, 0x3c, 0xbd, 0xe0, 0x82, 0x15, 0x3c, 0x98, 0x8f, 0xf3, - 0x2e, 0x0b, 0x11, 0x75, 0xaa, 0x87, 0xc2, 0x5a, 0xf1, 0xfc, 0x83, 0x0e, 0x63, 0x2f, 0x4e, 0x11, 0xd9, 0x37, 0xe7, - 0x86, 0x88, 0xa6, 0xc9, 0xbb, 0x88, 0xaa, 0x7f, 0xb0, 0x7b, 0x29, 0x1a, 0x5d, 0x52, 0xee, 0xd3, 0xdf, 0x9f, 0xad, - 0xc8, 0xbe, 0xa9, 0xe8, 0x41, 0x71, 0x70, 0x56, 0x2d, 0x4c, 0x66, 0x93, 0xa6, 0x4e, 0x36, 0xc0, 0xf7, 0x4c, 0x0a, - 0xaa, 0x92, 0xa1, 0xf6, 0xcf, 0x1e, 0xf8, 0x4f, 0x5a, 0x77, 0xe1, 0xa7, 0xb2, 0x7e, 0xbb, 0xff, 0x9a, 0xbe, 0x2d, - 0x4e, 0x58, 0x9c, 0x58, 0x94, 0x54, 0xd2, 0x99, 0xc9, 0xa2, 0x9e, 0xd7, 0x57, 0xce, 0xcd, 0x3c, 0xc9, 0x2c, 0x9f, - 0xf6, 0x56, 0xd0, 0xd9, 0x6d, 0x80, 0x98, 0x04, 0xea, 0x35, 0x3e, 0x65, 0xf5, 0x66, 0x3c, 0xfd, 0xc4, 0xb2, 0x29, - 0x85, 0x00, 0xa7, 0xc5, 0x17, 0xff, 0xdb, 0xf1, 0x2d, 0xf9, 0x6e, 0xa6, 0x97, 0xf9, 0xfd, 0x9a, 0x6c, 0xe6, 0x46, - 0x0a, 0xdc, 0xf1, 0xcb, 0xd9, 0xe8, 0xb9, 0xc6, 0x7f, 0x33, 0xc8, 0xdb, 0xe4, 0xe9, 0x52, 0xca, 0xd5, 0xc6, 0x0e, - 0x5d, 0x6e, 0xfe, 0xa9, 0x2a, 0x8f, 0x5d, 0x5b, 0xea, 0xf8, 0xe7, 0x5d, 0x1c, 0x8f, 0x5f, 0xf4, 0x3f, 0xde, 0xe7, - 0x1e, 0xef, 0xa2, 0x66, 0x51, 0xfe, 0x3d, 0x54, 0xa9, 0xe3, 0xf7, 0xa7, 0xf9, 0xd2, 0x51, 0x3e, 0x4e, 0x4d, 0xf3, - 0x41, 0x5e, 0x46, 0x2c, 0x8f, 0xd6, 0xf4, 0xf6, 0xcf, 0x9f, 0x12, 0xff, 0x6b, 0x73, 0x9d, 0xed, 0xe1, 0xfe, 0xa4, - 0x5e, 0x6c, 0x63, 0x31, 0x22, 0x66, 0x63, 0xc1, 0x0e, 0xa8, 0xde, 0x3f, 0x3d, 0x87, 0x94, 0x65, 0xfc, 0xa7, 0xc7, - 0x0b, 0x2c, 0x9d, 0xc0, 0x9a, 0xbf, 0xed, 0x98, 0x96, 0xbc, 0xcb, 0x96, 0x63, 0x0c, 0x7d, 0xc6, 0xff, 0xe6, 0x2e, - 0xee, 0xb0, 0x5a, 0x12, 0xa5, 0x18, 0x11, 0x16, 0xfa, 0xf9, 0x8d, 0x0f, 0x42, 0xf4, 0x61, 0xe5, 0xc1, 0x54, 0xaa, - 0x4d, 0xa6, 0x9c, 0x38, 0x81, 0x0f, 0xbe, 0x1d, 0x78, 0x5a, 0x81, 0x37, 0x7f, 0xdb, 0x37, 0x2d, 0xa3, 0x63, 0xf7, - 0x92, 0xbe, 0xbc, 0x9c, 0xb7, 0x4c, 0x4b, 0xbc, 0xd4, 0xc5, 0xed, 0x49, 0x13, 0x7a, 0x67, 0x41, 0x35, 0xc9, 0xc9, - 0xa7, 0xe5, 0x13, 0x0c, 0xfb, 0x45, 0x49, 0xac, 0x9a, 0x56, 0x43, 0x63, 0xd4, 0x9b, 0x26, 0x53, 0x37, 0xb8, 0xd6, - 0xa8, 0x57, 0x81, 0x7f, 0x66, 0x40, 0xee, 0x39, 0x13, 0x7b, 0xce, 0xa0, 0x97, 0x6f, 0x7e, 0x4c, 0x9a, 0xb7, 0xa6, - 0xde, 0x16, 0x5f, 0x2b, 0xa6, 0x52, 0xa2, 0xf0, 0x7d, 0x5d, 0xb8, 0x10, 0x1f, 0x0b, 0x97, 0xab, 0x07, 0xa6, 0xe9, - 0xd3, 0x29, 0xe4, 0xfb, 0x56, 0xe0, 0x9c, 0x91, 0xa3, 0xa0, 0x0d, 0x86, 0x3c, 0x62, 0xde, 0x0f, 0x21, 0xe5, 0x73, - 0x1f, 0xc6, 0xe8, 0xf7, 0xd8, 0x08, 0x28, 0x96, 0xa3, 0xec, 0x2e, 0xc4, 0x3c, 0xcf, 0x62, 0x76, 0xd0, 0xd5, 0x72, - 0xb3, 0xee, 0x98, 0x63, 0x26, 0x56, 0xfc, 0x8c, 0xeb, 0x58, 0xc1, 0x8c, 0x7c, 0xd0, 0xa2, 0x3b, 0xfa, 0x23, 0x2b, - 0xfb, 0x3a, 0x50, 0x77, 0x12, 0x5a, 0x3b, 0x4c, 0xdf, 0x66, 0x19, 0x0e, 0x28, 0x96, 0x08, 0x12, 0x85, 0xe6, 0xf7, - 0x2a, 0x31, 0xb1, 0x52, 0x9c, 0x1a, 0xcd, 0xf1, 0x86, 0xaa, 0xc4, 0x98, 0xa0, 0xe5, 0xed, 0xea, 0x8b, 0xcc, 0x74, - 0x09, 0x1b, 0x37, 0x58, 0xd2, 0x8a, 0xfd, 0xa2, 0xa4, 0x7e, 0xdf, 0x96, 0x85, 0x8a, 0x77, 0xd9, 0xd5, 0x51, 0x5d, - 0xda, 0xa1, 0xa3, 0x22, 0x66, 0xa0, 0xe8, 0xbb, 0x9d, 0x57, 0xce, 0x46, 0xf6, 0x81, 0xdb, 0x09, 0x9c, 0x05, 0x55, - 0x79, 0x9d, 0xea, 0x50, 0x0a, 0x97, 0x05, 0xe0, 0x16, 0x38, 0x3d, 0x3d, 0x09, 0xca, 0xb3, 0xae, 0xac, 0x44, 0x57, - 0x7a, 0x37, 0xe4, 0x3f, 0xd2, 0xe8, 0xc7, 0x39, 0xe2, 0xd9, 0xe7, 0xdd, 0xe9, 0x46, 0xa7, 0xf9, 0x5d, 0xfe, 0x26, - 0xe6, 0xef, 0x2a, 0xfa, 0xf2, 0x97, 0x2d, 0x6f, 0x5f, 0x07, 0xc2, 0x60, 0x69, 0x86, 0xf8, 0xda, 0x52, 0x79, 0x8d, - 0xb9, 0x0f, 0x31, 0x4d, 0xc2, 0xc6, 0xc8, 0xa3, 0x29, 0xe0, 0x3c, 0xdf, 0xbb, 0x51, 0x7a, 0x6d, 0x4b, 0x82, 0x50, - 0xe2, 0x3d, 0xdf, 0x4a, 0xbf, 0xe7, 0x71, 0x11, 0x0b, 0x32, 0xdb, 0xd0, 0xe9, 0xf9, 0xa8, 0x8e, 0x21, 0xe6, 0xc6, - 0xd3, 0x2e, 0xec, 0xa9, 0x5a, 0xab, 0x05, 0x49, 0xd2, 0xeb, 0x3c, 0xdf, 0xfd, 0xce, 0xa4, 0x8c, 0xe0, 0x51, 0x13, - 0x24, 0x7e, 0xee, 0xeb, 0x3f, 0xef, 0x4c, 0x0d, 0x7a, 0x0b, 0x78, 0x5a, 0x3a, 0xe8, 0xc9, 0x27, 0x8e, 0x5f, 0x0b, - 0xcb, 0x6d, 0xa3, 0x4b, 0xff, 0x7d, 0x9e, 0xad, 0x4b, 0x26, 0x4c, 0xba, 0x45, 0x32, 0x1f, 0x2b, 0x13, 0xe8, 0xa9, - 0x69, 0x9d, 0x90, 0xaa, 0xfe, 0x89, 0x15, 0xd4, 0x28, 0x70, 0xa0, 0x94, 0xba, 0x9a, 0xb0, 0x10, 0xdc, 0x09, 0x8f, - 0xcf, 0x4b, 0x01, 0xd1, 0xdc, 0xbd, 0x08, 0x92, 0x8b, 0xc4, 0x6b, 0x41, 0x25, 0x56, 0x65, 0xc5, 0x65, 0x55, 0x52, - 0x09, 0xa6, 0xf0, 0x03, 0x00, 0xcd, 0x7b, 0x05, 0x4c, 0x72, 0xa0, 0x47, 0x54, 0x3a, 0xf9, 0xa8, 0x11, 0x1f, 0xf0, - 0x64, 0x93, 0xfe, 0xa1, 0xa7, 0xb8, 0x24, 0x4e, 0x9c, 0xe9, 0x50, 0x82, 0xfb, 0x63, 0xd2, 0xfa, 0x53, 0xc5, 0x7c, - 0x8b, 0x89, 0x0e, 0xea, 0xf2, 0xf6, 0xfa, 0x3a, 0xab, 0x89, 0x84, 0x1a, 0x70, 0xc3, 0x9a, 0xd6, 0x94, 0xba, 0x6e, - 0x03, 0x4d, 0x1f, 0x40, 0xdf, 0xb3, 0xea, 0xf6, 0x47, 0x85, 0xe5, 0x40, 0x6e, 0x72, 0x5b, 0x8d, 0xa2, 0x8d, 0xff, - 0x38, 0xb8, 0xa9, 0xc6, 0x29, 0x70, 0x5d, 0xcd, 0x64, 0x99, 0x80, 0xab, 0x6a, 0x8a, 0x00, 0x2e, 0xab, 0xf9, 0x09, - 0xf5, 0xbd, 0xfa, 0x82, 0x8c, 0x5f, 0xea, 0xf0, 0xf5, 0x7e, 0xae, 0x20, 0xf4, 0xce, 0x4f, 0x59, 0x5b, 0x39, 0xf3, - 0x9c, 0xf7, 0xeb, 0x94, 0x83, 0xf7, 0x1b, 0xba, 0xee, 0xf0, 0xa9, 0xce, 0xbc, 0x0d, 0xd0, 0xfe, 0x29, 0x6f, 0xde, - 0xe9, 0x29, 0x62, 0xef, 0xe4, 0x6a, 0xee, 0xee, 0xff, 0x69, 0xe8, 0x79, 0x6f, 0x78, 0xaa, 0xb4, 0x95, 0xe3, 0x4a, - 0x8f, 0xde, 0xd1, 0xbf, 0x96, 0xda, 0x02, 0x87, 0xd5, 0x54, 0x0a, 0x1c, 0x54, 0x93, 0x2b, 0xb0, 0x5f, 0xcd, 0xab, - 0x5a, 0x3b, 0x00, 0x9b, 0xd5, 0x20, 0x03, 0x7b, 0xd5, 0x64, 0x0d, 0xd8, 0xaa, 0x46, 0xbb, 0x4f, 0x1e, 0xd8, 0xae, - 0x06, 0x6b, 0x60, 0x67, 0x64, 0x5e, 0xe7, 0xed, 0xfe, 0x33, 0xf1, 0xdc, 0xc0, 0x58, 0xaf, 0xdb, 0xcb, 0x3e, 0x33, - 0xef, 0x75, 0x0d, 0x4c, 0x97, 0x65, 0x5b, 0xac, 0x5a, 0x80, 0x0d, 0xfa, 0xef, 0x48, 0xd3, 0xd6, 0x0a, 0x7e, 0x23, - 0x90, 0xc0, 0xfc, 0xec, 0x2c, 0x60, 0x01, 0xa3, 0xff, 0xd0, 0xe3, 0xd8, 0xc0, 0x50, 0x4b, 0xac, 0x4f, 0xd6, 0x31, - 0x6d, 0xd3, 0xff, 0xc7, 0xc6, 0x66, 0x1b, 0x25, 0x48, 0xb7, 0xed, 0x35, 0xbe, 0xbc, 0x65, 0x47, 0x68, 0x3f, 0x52, - 0xd4, 0xa5, 0xb4, 0x0a, 0xf0, 0x57, 0x4b, 0x72, 0xf1, 0xe8, 0xb2, 0x3d, 0x13, 0xbe, 0x57, 0x67, 0xb1, 0xce, 0x94, - 0x90, 0x88, 0x1d, 0xe2, 0xea, 0xfe, 0xbf, 0x3c, 0xf5, 0x95, 0x42, 0x65, 0x11, 0x27, 0xc5, 0x47, 0x7c, 0x4c, 0xb6, - 0x0a, 0x4e, 0x11, 0xc7, 0x42, 0x8c, 0x43, 0x37, 0x4f, 0xce, 0x61, 0xd5, 0x24, 0x0a, 0xfb, 0x47, 0xc2, 0x6b, 0xd0, - 0xf0, 0x9b, 0x6c, 0x39, 0xc0, 0xce, 0x19, 0x86, 0xd0, 0x8c, 0xbe, 0xb3, 0x9c, 0x49, 0x95, 0x93, 0x57, 0x30, 0x8e, - 0xe8, 0x58, 0xfd, 0x8e, 0x94, 0x97, 0xf7, 0xa5, 0xca, 0x97, 0x43, 0x50, 0xb6, 0x1f, 0xe0, 0x1d, 0x14, 0xc5, 0xf4, - 0xb9, 0x0c, 0x16, 0xf8, 0x5e, 0x77, 0x0f, 0x0f, 0xf0, 0xc2, 0x26, 0xfc, 0x58, 0x2b, 0x4f, 0x78, 0x67, 0xab, 0x96, - 0xd1, 0x63, 0xe1, 0x60, 0x06, 0xeb, 0x20, 0xfe, 0xaf, 0x79, 0xdd, 0x32, 0x80, 0x12, 0x38, 0xe1, 0xe6, 0x4c, 0x7b, - 0x7d, 0x9d, 0x3e, 0xb0, 0x32, 0x69, 0x8a, 0x5d, 0xf4, 0x07, 0xe6, 0x2a, 0xca, 0x6b, 0xa8, 0xe2, 0x34, 0xbb, 0x91, - 0xba, 0xc0, 0xe3, 0x7b, 0xa5, 0x3b, 0x8f, 0x3b, 0x3c, 0x74, 0xed, 0x0a, 0x8a, 0x5c, 0x23, 0x06, 0xda, 0x5d, 0x00, - 0xdd, 0x2e, 0xc3, 0xc6, 0xbf, 0xea, 0x27, 0xa9, 0x08, 0x44, 0x34, 0xe3, 0x5e, 0xf8, 0x17, 0xb0, 0xad, 0x1b, 0xdd, - 0xa2, 0x94, 0x8e, 0x2e, 0x72, 0xeb, 0xad, 0xc4, 0x9d, 0xa9, 0x64, 0xa3, 0x41, 0x0d, 0x58, 0xce, 0xbd, 0xff, 0x3b, - 0xd5, 0x5c, 0x52, 0x7f, 0xc8, 0xc4, 0x41, 0xdf, 0x9a, 0xad, 0x90, 0x05, 0xff, 0x32, 0xc9, 0x7f, 0xc6, 0x3d, 0x3e, - 0xf1, 0xd5, 0xf3, 0x90, 0x36, 0xdd, 0xb4, 0x02, 0x76, 0xd7, 0x14, 0x59, 0x9f, 0xf2, 0x71, 0x11, 0x46, 0xce, 0x82, - 0x47, 0xf8, 0x03, 0x3a, 0x09, 0x71, 0xd9, 0xfb, 0xda, 0x8b, 0xbd, 0xe8, 0xb9, 0x4c, 0xfa, 0xc7, 0xac, 0x5e, 0xa4, - 0xe3, 0x3c, 0xd4, 0x66, 0xef, 0xa8, 0x5f, 0xa3, 0x0c, 0xd5, 0x9b, 0xb3, 0x9e, 0xc3, 0x36, 0x30, 0xea, 0x4e, 0x89, - 0x56, 0x11, 0xa5, 0x1b, 0x31, 0x68, 0x28, 0x39, 0x19, 0x5f, 0xe9, 0x6c, 0xbf, 0xbb, 0xbc, 0xe3, 0x56, 0x53, 0x0a, - 0x14, 0xfb, 0x46, 0xb9, 0xc6, 0x87, 0x9f, 0x25, 0x2b, 0x22, 0xcb, 0x7a, 0xc1, 0x78, 0xf6, 0x1a, 0xd6, 0x82, 0x69, - 0x8a, 0xb6, 0x50, 0x7b, 0x71, 0x71, 0xd1, 0x84, 0x6b, 0x9d, 0xdc, 0xb8, 0x3b, 0x1f, 0x16, 0x76, 0x41, 0x09, 0xb7, - 0xbd, 0xc2, 0xd3, 0xc1, 0xdc, 0x67, 0x46, 0x28, 0x89, 0x06, 0xe0, 0x04, 0x5c, 0x3a, 0x86, 0xeb, 0xc6, 0xf1, 0x5e, - 0x03, 0x78, 0x58, 0x2f, 0xe0, 0x0c, 0x50, 0xa9, 0x78, 0x45, 0x77, 0xab, 0x1e, 0xbf, 0x3b, 0x4b, 0xf3, 0xeb, 0x44, - 0x19, 0x14, 0x96, 0x83, 0x87, 0x96, 0x32, 0xcf, 0x34, 0xa8, 0x51, 0x83, 0x8d, 0x54, 0xac, 0x90, 0x17, 0xaf, 0x79, - 0xd0, 0xba, 0xea, 0x85, 0x3d, 0x41, 0x4a, 0x3f, 0x2f, 0x73, 0x0b, 0xaa, 0x81, 0x81, 0x84, 0x58, 0x36, 0x99, 0xd5, - 0x1f, 0x1d, 0x3c, 0x77, 0xaf, 0xa6, 0x9a, 0x6c, 0xb2, 0x05, 0x99, 0xf7, 0xeb, 0xef, 0xad, 0x24, 0x5b, 0x7e, 0x71, - 0x27, 0xdb, 0x5f, 0xef, 0x97, 0x42, 0xc9, 0xe8, 0xcd, 0x38, 0x3b, 0xd6, 0x83, 0x72, 0xd6, 0x81, 0x40, 0x44, 0x7e, - 0x5b, 0x1d, 0x0a, 0xc4, 0x62, 0x32, 0x82, 0x32, 0x85, 0x19, 0x61, 0xdf, 0xd0, 0x5d, 0x1e, 0xc6, 0xdb, 0xb7, 0x13, - 0xd8, 0x4c, 0x2c, 0x28, 0xc0, 0x58, 0xdc, 0x4e, 0x4e, 0x27, 0xd7, 0x50, 0x09, 0xe6, 0x31, 0xc7, 0x50, 0x09, 0xc9, - 0xbd, 0x72, 0x66, 0xfd, 0x58, 0x64, 0x15, 0x35, 0x06, 0xc9, 0x95, 0x55, 0xc0, 0xb2, 0xb7, 0x90, 0x90, 0x71, 0xc8, - 0x28, 0x72, 0x02, 0xfb, 0x45, 0x6a, 0x46, 0x41, 0x49, 0xfc, 0x6f, 0xe6, 0x9e, 0xb9, 0x99, 0x7b, 0xe5, 0xc3, 0xb2, - 0x34, 0x29, 0x49, 0x56, 0x69, 0x13, 0xf4, 0x13, 0x3a, 0x7e, 0x89, 0xd8, 0xe9, 0xb4, 0x21, 0x34, 0xb0, 0xc7, 0x38, - 0x32, 0x82, 0xa9, 0x30, 0xa1, 0x7a, 0x57, 0x6f, 0x40, 0x12, 0x78, 0x80, 0xd1, 0xce, 0x44, 0x4e, 0x68, 0xe0, 0xc3, - 0xd9, 0x68, 0xe3, 0xe6, 0xe1, 0x77, 0x4e, 0x54, 0xaa, 0x85, 0x9d, 0xe5, 0xdf, 0x5b, 0x70, 0xa5, 0xfd, 0x4d, 0x40, - 0x95, 0xc0, 0xc5, 0xe4, 0xdf, 0xbf, 0xfb, 0x71, 0xe8, 0xdf, 0x2a, 0xe7, 0x8f, 0x16, 0x8b, 0x6f, 0x23, 0x3b, 0x5c, - 0x82, 0xb5, 0xae, 0x23, 0xe7, 0x22, 0x3f, 0x33, 0x1b, 0xaa, 0xe1, 0xf7, 0x43, 0xdd, 0xee, 0xe4, 0x42, 0xa9, 0x5a, - 0x7c, 0x32, 0x72, 0xdc, 0x51, 0xc9, 0x13, 0x89, 0x86, 0x58, 0xd1, 0xf2, 0x2b, 0x26, 0x9e, 0xd1, 0xf8, 0x52, 0xf1, - 0x48, 0x16, 0xee, 0x1f, 0xb9, 0xf6, 0x92, 0xa9, 0x83, 0x6c, 0xc0, 0x64, 0x64, 0xae, 0xfc, 0x76, 0xdc, 0x68, 0x1f, - 0xe6, 0x0f, 0x7f, 0xaa, 0x4f, 0xc4, 0xde, 0xff, 0x9a, 0x51, 0x76, 0xbd, 0x2c, 0x24, 0x3f, 0x61, 0xe1, 0x09, 0x7f, - 0x1c, 0xa1, 0xe0, 0xe5, 0xf4, 0xf1, 0x82, 0x38, 0x8e, 0x9e, 0x2b, 0x76, 0x20, 0x4b, 0x25, 0x2a, 0xee, 0x22, 0x48, - 0x42, 0x14, 0xe1, 0x09, 0xa0, 0xe4, 0x7d, 0x5c, 0x89, 0x4a, 0xb3, 0x98, 0x98, 0x4f, 0x46, 0x2a, 0x00, 0x67, 0x07, - 0xd1, 0xd0, 0x4a, 0xa4, 0x27, 0xea, 0x24, 0xa2, 0x21, 0x47, 0x67, 0x83, 0xfe, 0x9d, 0x78, 0x16, 0x1b, 0xa2, 0x22, - 0x40, 0x4c, 0x41, 0xd4, 0xb1, 0x15, 0x6f, 0x94, 0x81, 0x2b, 0xea, 0xa1, 0x44, 0x82, 0xd6, 0xd4, 0x19, 0xa0, 0x29, - 0x21, 0x20, 0xc7, 0x5c, 0xee, 0xa1, 0x87, 0x19, 0xa6, 0x6d, 0xb7, 0xab, 0xa8, 0x30, 0xde, 0x1f, 0xce, 0xcb, 0xa8, - 0xd4, 0x76, 0x0a, 0x44, 0xa9, 0x7a, 0x1a, 0xc6, 0x38, 0x1d, 0x2b, 0x84, 0x77, 0x01, 0xc5, 0x25, 0xc9, 0x4a, 0x8c, - 0xfa, 0x6e, 0x34, 0x6d, 0xaa, 0x11, 0x12, 0x38, 0xe9, 0xdd, 0xf4, 0x3a, 0x40, 0x49, 0x0c, 0x26, 0x58, 0x1c, 0xc1, - 0x66, 0xf0, 0xc9, 0xb1, 0x46, 0x90, 0x94, 0x50, 0xa0, 0x54, 0x99, 0xf1, 0x47, 0xad, 0xa4, 0x20, 0xf1, 0x3e, 0xfc, - 0xfc, 0x53, 0x65, 0xf1, 0xc4, 0x0a, 0x1b, 0xef, 0x63, 0x7e, 0x5f, 0xc9, 0xab, 0x1e, 0x84, 0x89, 0x07, 0xc4, 0xb7, - 0x09, 0x1c, 0x61, 0x99, 0xca, 0x65, 0x83, 0x0c, 0x05, 0x28, 0x38, 0xd5, 0x9a, 0xb3, 0x38, 0x13, 0x9b, 0x46, 0xaa, - 0x30, 0xe8, 0x11, 0x4c, 0x91, 0xfc, 0x8b, 0xb8, 0x7f, 0xdf, 0x06, 0xc4, 0xf8, 0x14, 0x1f, 0xfa, 0xc9, 0xeb, 0x9d, - 0x85, 0x2a, 0x98, 0x10, 0xf5, 0xe2, 0x3f, 0x8f, 0x97, 0xc5, 0xae, 0x1f, 0x1f, 0x36, 0x15, 0x48, 0x20, 0xf6, 0x3c, - 0x0d, 0xce, 0x7f, 0xc6, 0x2c, 0x09, 0x03, 0x69, 0x71, 0x6f, 0x4a, 0xe0, 0x4d, 0x2f, 0x58, 0x9a, 0x4d, 0x80, 0x09, - 0x52, 0x9c, 0x8c, 0xb6, 0x16, 0xac, 0x53, 0x4f, 0x8d, 0xd2, 0x99, 0x13, 0x60, 0x52, 0xa9, 0x87, 0x90, 0x9e, 0x7a, - 0x81, 0xde, 0xb1, 0xa2, 0x1f, 0xe3, 0xad, 0x86, 0xb7, 0xec, 0x6a, 0x91, 0x0a, 0x8b, 0xd0, 0x19, 0x08, 0x2e, 0x0b, - 0x4e, 0xf1, 0xfb, 0x08, 0x75, 0x0d, 0xc3, 0x9a, 0xb1, 0xc9, 0x89, 0x1a, 0x2a, 0xc8, 0xb4, 0xee, 0xe9, 0x60, 0xef, - 0x65, 0xde, 0x24, 0xcc, 0x9c, 0x0f, 0x47, 0x94, 0x6c, 0x78, 0xe3, 0x1e, 0x4f, 0x3f, 0x20, 0xdd, 0x19, 0xa4, 0x4f, - 0x30, 0x3d, 0xa6, 0x50, 0x12, 0x41, 0x73, 0x5f, 0x5e, 0x92, 0xab, 0x47, 0x87, 0xbd, 0x52, 0x37, 0xdd, 0x2f, 0xf7, - 0x72, 0xd2, 0xb8, 0x85, 0xe9, 0x2a, 0xec, 0x76, 0xf7, 0xa3, 0x7e, 0x10, 0x38, 0xea, 0xa5, 0x9a, 0x66, 0xa0, 0x66, - 0x92, 0x62, 0x67, 0xf2, 0x56, 0xca, 0x37, 0x8e, 0xa5, 0x17, 0xd4, 0x79, 0x5a, 0x33, 0x6f, 0x72, 0x9f, 0x15, 0x8a, - 0xb2, 0xc2, 0xda, 0xa1, 0x8c, 0x61, 0xa8, 0x92, 0xe1, 0x71, 0x1a, 0xc1, 0xba, 0x28, 0x8a, 0xb6, 0xe8, 0x3a, 0x73, - 0x34, 0xa7, 0x5d, 0xa5, 0x4b, 0xb2, 0xc4, 0xf7, 0xe8, 0x47, 0x13, 0x95, 0xfd, 0x25, 0x7a, 0x91, 0x5a, 0x5a, 0x99, - 0xb6, 0x77, 0xd7, 0xd4, 0xf1, 0x3b, 0x1b, 0x85, 0x3f, 0xba, 0xc1, 0x67, 0xa6, 0xf3, 0xd1, 0x06, 0x37, 0x96, 0xee, - 0x08, 0x67, 0xb0, 0x6d, 0x63, 0x3f, 0xef, 0x03, 0x46, 0x85, 0x0f, 0xa8, 0xbe, 0x9e, 0xe6, 0x1d, 0xae, 0x1d, 0x71, - 0x53, 0x5b, 0xcf, 0xff, 0x66, 0x3a, 0x07, 0xe5, 0x07, 0x7c, 0xc0, 0xa6, 0xcf, 0xf6, 0x16, 0x2c, 0x95, 0xaf, 0x3b, - 0x01, 0x63, 0x49, 0xd5, 0x55, 0x98, 0xad, 0xd9, 0xd2, 0x60, 0x80, 0xe2, 0x22, 0x3f, 0x73, 0x2a, 0x88, 0x70, 0x89, - 0x4c, 0x70, 0x03, 0x45, 0x1f, 0x50, 0x42, 0x99, 0xb1, 0x08, 0x0f, 0xc6, 0x57, 0x98, 0x48, 0x8c, 0xf4, 0x27, 0x14, - 0xc6, 0xe3, 0x8e, 0x7f, 0x7e, 0xce, 0xcf, 0xbb, 0x66, 0xa7, 0xbd, 0xc2, 0xd8, 0xc4, 0xee, 0x59, 0xe8, 0xf8, 0x83, - 0x9c, 0xfd, 0xce, 0xfc, 0x45, 0x30, 0xad, 0xf3, 0x17, 0xc2, 0x7e, 0xe6, 0x44, 0x82, 0xbf, 0xf6, 0xd4, 0xf6, 0x06, - 0x62, 0x39, 0x11, 0xa5, 0x94, 0xfa, 0xc6, 0xbc, 0x55, 0x83, 0x05, 0x50, 0xc2, 0xec, 0xa7, 0x51, 0x7f, 0x6d, 0xe3, - 0x4f, 0x1d, 0x5a, 0x17, 0x4c, 0xbd, 0x16, 0x30, 0x64, 0x86, 0x11, 0x2d, 0x58, 0x13, 0x69, 0x64, 0xe6, 0x8f, 0x33, - 0xb9, 0x90, 0xf3, 0x3e, 0xf3, 0xe7, 0x19, 0x9f, 0x73, 0xea, 0xb7, 0xd5, 0xe6, 0x4a, 0xce, 0xd0, 0x5f, 0xc5, 0xb3, - 0xb7, 0x97, 0x18, 0xdd, 0xf5, 0x0e, 0x62, 0x24, 0xf6, 0x95, 0x3e, 0x9c, 0xfe, 0x73, 0x77, 0x8e, 0x5c, 0x06, 0x3c, - 0x9e, 0x8b, 0x15, 0x67, 0x7e, 0x80, 0xf2, 0x34, 0xb7, 0x36, 0x4e, 0x10, 0xa9, 0xc9, 0xd2, 0xb8, 0xf2, 0x7c, 0x2f, - 0xe3, 0x60, 0x41, 0x76, 0x6c, 0x87, 0x3d, 0xd3, 0xf7, 0xb4, 0xd7, 0xd7, 0x1e, 0xc8, 0x88, 0x3f, 0x97, 0xbe, 0x9f, - 0x40, 0x94, 0xab, 0xad, 0xa7, 0x2a, 0xaa, 0xe6, 0x5e, 0x1e, 0xac, 0xf3, 0x2b, 0x2f, 0xfd, 0xc9, 0xd3, 0x4e, 0xf7, - 0xe5, 0x6e, 0xdd, 0x80, 0x4c, 0x71, 0xa2, 0xf6, 0xc9, 0xc7, 0x0c, 0x11, 0xc5, 0xc5, 0x6f, 0x5c, 0x4f, 0xef, 0x30, - 0xe1, 0x7b, 0x5f, 0x20, 0xcb, 0x47, 0x2a, 0x71, 0xc1, 0x7a, 0x4e, 0x53, 0x63, 0xf9, 0x2e, 0xbb, 0xfd, 0xa2, 0x6d, - 0x83, 0x31, 0xe9, 0xd1, 0x8c, 0xcf, 0xfa, 0x6f, 0xac, 0x03, 0x00, 0x16, 0x7f, 0x97, 0xf8, 0x4a, 0xac, 0xe6, 0xb9, - 0xc9, 0x12, 0x5b, 0x71, 0xd5, 0x78, 0x5e, 0x27, 0x72, 0x90, 0xdd, 0x64, 0x87, 0x08, 0x4d, 0x11, 0x31, 0xed, 0xa5, - 0x7a, 0xbd, 0x41, 0xdf, 0x41, 0x84, 0x85, 0x8a, 0xea, 0x4c, 0x3f, 0x69, 0x0a, 0x33, 0x46, 0xa7, 0x23, 0x40, 0xe5, - 0x80, 0xa4, 0x2f, 0x0f, 0xbe, 0x7d, 0x25, 0x64, 0x05, 0xee, 0x72, 0xe7, 0xb2, 0x90, 0x02, 0xfb, 0xf0, 0xa1, 0xe9, - 0x6f, 0xcf, 0xf3, 0x3c, 0x57, 0x59, 0x3c, 0x8f, 0x7f, 0x92, 0x1c, 0x26, 0x26, 0xda, 0x1a, 0x45, 0x3c, 0xd1, 0x02, - 0x6f, 0x7f, 0xd1, 0x14, 0x09, 0x57, 0x95, 0xc2, 0x8a, 0x02, 0xbd, 0x6a, 0x74, 0x49, 0x50, 0xbc, 0x2b, 0x93, 0x40, - 0x67, 0x70, 0x5d, 0x32, 0x58, 0xb0, 0x3b, 0x15, 0xd2, 0x73, 0x6a, 0x2e, 0xd8, 0xce, 0x04, 0x78, 0x7d, 0x48, 0xe5, - 0xc6, 0x2c, 0x55, 0x48, 0x59, 0x54, 0xe7, 0x05, 0x78, 0xdb, 0xe3, 0xa0, 0xd5, 0x89, 0xc2, 0xde, 0x6b, 0x01, 0x68, - 0xc0, 0xf2, 0xb9, 0xcd, 0x03, 0x5b, 0x72, 0x80, 0xa6, 0x41, 0xd3, 0x31, 0x80, 0xec, 0xef, 0x7c, 0x95, 0xf4, 0xbf, - 0xbd, 0xe3, 0x4f, 0xeb, 0x16, 0x0c, 0x21, 0x63, 0x36, 0x4f, 0x9f, 0xb5, 0x68, 0x08, 0x14, 0x6f, 0xa3, 0x48, 0xc4, - 0x9f, 0x59, 0xab, 0x2a, 0x0d, 0x0c, 0xb9, 0x0e, 0x83, 0x5a, 0xa5, 0x9d, 0xcc, 0x99, 0x96, 0x59, 0xa9, 0x53, 0xb5, - 0xb0, 0x7b, 0x80, 0x0a, 0x3c, 0xa8, 0xde, 0x4b, 0x0d, 0x2a, 0x07, 0x18, 0x8a, 0xe2, 0xa2, 0x0c, 0x9d, 0x8a, 0x7b, - 0xfa, 0x3e, 0x4f, 0xb1, 0x09, 0xff, 0xf9, 0xb2, 0xf2, 0x8d, 0xe7, 0x20, 0x5e, 0x4a, 0xff, 0xb9, 0x53, 0x7e, 0x8c, - 0xbc, 0x86, 0xfa, 0xea, 0x2a, 0x0a, 0x73, 0x3c, 0x62, 0xbc, 0xb3, 0x31, 0x34, 0x02, 0xb1, 0x94, 0xe2, 0x09, 0xe1, - 0xc5, 0x36, 0x0a, 0x3e, 0x87, 0x0a, 0xb4, 0x19, 0x01, 0x58, 0xb8, 0xbb, 0x16, 0xfc, 0xcb, 0xd7, 0x90, 0xbf, 0x57, - 0x3c, 0xa2, 0xa9, 0x4c, 0x6e, 0x57, 0xa7, 0xec, 0x6b, 0xb8, 0xc2, 0xe8, 0xed, 0xfb, 0xbe, 0x5e, 0xe6, 0xeb, 0x4e, - 0xff, 0xe1, 0x13, 0x3e, 0x76, 0xa7, 0xfd, 0x65, 0xe7, 0x61, 0x9d, 0x91, 0x7f, 0x3c, 0x36, 0x79, 0x1b, 0xd6, 0xb4, - 0x1b, 0xec, 0x65, 0x8f, 0x89, 0xc3, 0x4c, 0x3c, 0x14, 0xe3, 0xdf, 0x16, 0x15, 0x38, 0xe6, 0x85, 0x06, 0x52, 0x6b, - 0x7f, 0x4e, 0x1f, 0xff, 0xaa, 0xb0, 0x43, 0xcc, 0x0e, 0xac, 0x04, 0x81, 0xee, 0x7b, 0x05, 0x6e, 0x29, 0x5d, 0x0a, - 0xb4, 0xf5, 0x68, 0x51, 0x2e, 0xae, 0xef, 0x01, 0x21, 0xb5, 0xb6, 0x59, 0xd5, 0x65, 0xa2, 0x71, 0x29, 0x8e, 0x57, - 0xa7, 0x3e, 0xfa, 0x03, 0x2d, 0xf6, 0xd9, 0xc5, 0x16, 0x9a, 0x5a, 0x88, 0x33, 0x71, 0x36, 0x2e, 0xb8, 0x19, 0x37, - 0xeb, 0xce, 0x81, 0x0a, 0x7d, 0x35, 0x18, 0xca, 0x92, 0xd0, 0xda, 0xc0, 0x10, 0x4c, 0x2b, 0x37, 0x58, 0x14, 0x25, - 0xdd, 0x51, 0x2f, 0x8e, 0xb6, 0x0d, 0x2e, 0xdc, 0x46, 0x8c, 0x72, 0xbc, 0x65, 0x7f, 0x81, 0x2a, 0x61, 0xfe, 0x92, - 0x59, 0xe4, 0x5d, 0x93, 0xce, 0x9f, 0x23, 0x3b, 0x10, 0xb3, 0xd4, 0x96, 0xb5, 0x5a, 0x85, 0x9b, 0x09, 0xf9, 0xa6, - 0x7b, 0x8a, 0x42, 0x81, 0x6d, 0x53, 0xb9, 0x8b, 0xff, 0x3d, 0xe2, 0x6a, 0xe7, 0x1c, 0xf9, 0x37, 0x9b, 0xe6, 0xb9, - 0x9e, 0x89, 0x03, 0xa2, 0x9c, 0xae, 0x82, 0x99, 0xc3, 0x70, 0xc7, 0x3d, 0xf5, 0xf3, 0x22, 0x3d, 0xcf, 0xa8, 0x63, - 0x25, 0xb7, 0xef, 0xd4, 0x2f, 0xfc, 0x52, 0x3c, 0xf6, 0x0b, 0x7d, 0x61, 0xcf, 0x95, 0x78, 0x4b, 0xb7, 0x4f, 0x4e, - 0xa2, 0xd5, 0xa8, 0x9c, 0x9a, 0x76, 0xe1, 0x25, 0xd4, 0x6c, 0x6f, 0x68, 0xaf, 0xcc, 0x2d, 0x7a, 0xd9, 0x1d, 0xee, - 0x57, 0x76, 0x8a, 0x22, 0x76, 0xa5, 0x60, 0x9f, 0x56, 0xd2, 0xe3, 0x4a, 0x9c, 0x73, 0xa1, 0xb3, 0x4e, 0x2d, 0xa0, - 0x28, 0xcb, 0x00, 0x2b, 0x2f, 0x15, 0xfa, 0x6d, 0xc5, 0x13, 0x94, 0x1c, 0xa6, 0x36, 0x1b, 0x47, 0xcf, 0x7a, 0x49, - 0x25, 0xf7, 0xd5, 0x06, 0xf7, 0x92, 0x91, 0xd6, 0xde, 0xe1, 0xf2, 0xce, 0x56, 0x1f, 0xf1, 0xb4, 0x87, 0xfb, 0x8f, - 0x59, 0x81, 0x7f, 0xab, 0xca, 0xfb, 0x86, 0x31, 0x14, 0x02, 0xb5, 0xce, 0xa5, 0xd4, 0xb4, 0x49, 0xb8, 0x64, 0x10, - 0xee, 0x1d, 0xac, 0x11, 0x51, 0x27, 0xb0, 0xbf, 0x46, 0xc9, 0x6a, 0x67, 0xc0, 0xe7, 0x49, 0x88, 0x98, 0x13, 0xe5, - 0xb1, 0x7f, 0x7e, 0xb7, 0xb2, 0xc9, 0x9f, 0x98, 0x43, 0x9d, 0xa1, 0x4a, 0x58, 0x87, 0xf8, 0x83, 0xb8, 0x79, 0xd5, - 0xeb, 0xdb, 0x25, 0xca, 0xdf, 0x4c, 0x4f, 0x2c, 0xcc, 0x0a, 0x2b, 0xf8, 0x4b, 0x29, 0x5f, 0xdf, 0xf1, 0x01, 0xd4, - 0x67, 0x93, 0x1f, 0xff, 0xbc, 0xa1, 0x7b, 0xd9, 0x95, 0xff, 0x72, 0x87, 0x40, 0x31, 0xed, 0xe2, 0x70, 0x8e, 0x4b, - 0x87, 0xc2, 0xec, 0x02, 0xc3, 0xe2, 0x45, 0x55, 0x1d, 0xf0, 0xe1, 0x39, 0xe2, 0xe3, 0xf3, 0x24, 0x2e, 0x88, 0x84, - 0xd2, 0xfc, 0x79, 0x50, 0x37, 0xa3, 0xe3, 0xc2, 0x86, 0x3e, 0x38, 0x9c, 0xd1, 0x00, 0x84, 0xac, 0xb1, 0xc9, 0xf6, - 0x63, 0x95, 0x52, 0x99, 0x59, 0x3a, 0x72, 0xc7, 0xc6, 0x76, 0xb5, 0xd4, 0xe3, 0xbb, 0x7e, 0x1f, 0xee, 0x64, 0xd3, - 0xb2, 0x7e, 0xca, 0x10, 0xfb, 0xa8, 0x0b, 0xc3, 0x05, 0xc8, 0xda, 0x0f, 0xb9, 0xf6, 0x62, 0x19, 0xdb, 0x80, 0x3e, - 0xc4, 0xfe, 0xa7, 0x5d, 0x9c, 0xd1, 0xad, 0xf0, 0xb1, 0x58, 0xb4, 0x3a, 0xdb, 0x90, 0x24, 0x07, 0x46, 0x73, 0x48, - 0x30, 0x6e, 0x44, 0x68, 0x1b, 0x94, 0xe0, 0xb1, 0x62, 0x0a, 0x37, 0xe2, 0xfe, 0x38, 0x58, 0x68, 0x55, 0xd1, 0x8d, - 0xb9, 0x8d, 0x6d, 0x14, 0x67, 0xf0, 0xf5, 0x80, 0x7f, 0x15, 0x65, 0x7c, 0x80, 0xbb, 0x41, 0xe4, 0xce, 0x9e, 0x97, - 0x92, 0x48, 0x2d, 0xa7, 0xdb, 0x56, 0x6c, 0xe7, 0x97, 0xd8, 0xed, 0x82, 0x3d, 0x5f, 0x16, 0xe6, 0xcc, 0xd6, 0x20, - 0x33, 0x8c, 0xbd, 0xb7, 0xc0, 0xbb, 0x6d, 0x29, 0x4c, 0x76, 0xe9, 0x1b, 0xa8, 0x84, 0x56, 0xe7, 0xa3, 0xc3, 0x78, - 0x53, 0x07, 0xae, 0xe2, 0x78, 0x1a, 0xdb, 0x56, 0x62, 0x34, 0x46, 0x1e, 0xc9, 0x30, 0x95, 0xe1, 0x10, 0x7f, 0x24, - 0xbb, 0x29, 0x37, 0xd3, 0xa5, 0xce, 0x2e, 0x0d, 0x10, 0x74, 0x85, 0x55, 0x0f, 0x85, 0x24, 0x11, 0x70, 0xcb, 0x15, - 0xc8, 0xc3, 0x70, 0x3f, 0xaf, 0xc6, 0xc8, 0xe1, 0x6c, 0x81, 0xd0, 0xf0, 0xb2, 0x51, 0x90, 0x39, 0xf1, 0xed, 0x21, - 0xf1, 0x72, 0x6a, 0x7a, 0xc7, 0x11, 0x0f, 0x86, 0xea, 0x36, 0x0f, 0x5e, 0x8e, 0xaa, 0x4e, 0x67, 0xe9, 0x91, 0x70, - 0x4b, 0xe7, 0x59, 0x49, 0xca, 0x51, 0x35, 0x05, 0x7a, 0x8e, 0x34, 0x1a, 0xca, 0x5b, 0x5b, 0x29, 0x81, 0x55, 0xd0, - 0x32, 0x39, 0xfd, 0xbc, 0x23, 0x12, 0x91, 0x70, 0x21, 0xa0, 0xf8, 0xcb, 0x28, 0xa4, 0xd1, 0x5b, 0xcd, 0xc7, 0x30, - 0xc8, 0x70, 0x9d, 0x57, 0x5c, 0x0a, 0xfe, 0xf8, 0xc5, 0x01, 0xf4, 0x50, 0x94, 0x0d, 0xdc, 0x2f, 0x16, 0xfe, 0xcc, - 0x2e, 0x46, 0xf4, 0x36, 0x9b, 0xa1, 0xba, 0xcd, 0xe8, 0x27, 0xd6, 0xe7, 0x95, 0x22, 0x76, 0xcf, 0x0e, 0xed, 0xc1, - 0x8e, 0x19, 0x59, 0x5d, 0x25, 0x1d, 0xcd, 0x04, 0x99, 0xf4, 0x32, 0xf2, 0x0c, 0x41, 0x84, 0x0e, 0xd8, 0x27, 0x87, - 0x36, 0x4a, 0x87, 0xf9, 0x0a, 0xc2, 0x3f, 0xc7, 0x7c, 0x0e, 0x89, 0x6e, 0x76, 0x09, 0x8e, 0x7a, 0x8d, 0x48, 0x3a, - 0x89, 0x50, 0x04, 0x5e, 0xc8, 0x7a, 0x22, 0x98, 0x78, 0x41, 0xef, 0x26, 0xb8, 0xe2, 0xc5, 0xd1, 0x4d, 0x07, 0xcb, - 0x61, 0x46, 0xfc, 0x1b, 0x13, 0x46, 0xee, 0x12, 0xe2, 0xdb, 0x03, 0x84, 0x3b, 0xd8, 0x29, 0x88, 0xea, 0xe5, 0x56, - 0x9b, 0x5e, 0x9c, 0xa2, 0x9e, 0xc7, 0xf9, 0x78, 0x36, 0xd6, 0x91, 0x17, 0x72, 0x38, 0x5b, 0xc4, 0x30, 0x40, 0x16, - 0xc2, 0x93, 0x9b, 0x76, 0x97, 0x10, 0x90, 0x9c, 0x4c, 0x65, 0x59, 0xde, 0xc4, 0x4d, 0x27, 0x60, 0x91, 0xe7, 0x76, - 0x69, 0x4a, 0xf1, 0xf4, 0x9f, 0xaa, 0xb1, 0x0d, 0x67, 0x8b, 0x8c, 0x63, 0xd0, 0x62, 0x95, 0xce, 0x20, 0x10, 0x17, - 0xe5, 0x46, 0x4b, 0x2f, 0x0e, 0x73, 0xb8, 0xf9, 0xf7, 0xb8, 0xbc, 0x8d, 0x09, 0x20, 0x1f, 0xbc, 0x49, 0x3a, 0x40, - 0xcf, 0xf2, 0x7c, 0xce, 0xd0, 0x4b, 0x6f, 0x73, 0x91, 0x4c, 0x84, 0xff, 0xd3, 0xc9, 0x47, 0xa2, 0x1c, 0xe9, 0x15, - 0x72, 0x9c, 0x50, 0x51, 0xb2, 0x9d, 0x10, 0xd5, 0xcb, 0xc3, 0x7f, 0x61, 0xd5, 0x11, 0x02, 0xe7, 0xdb, 0x84, 0x2f, - 0x5f, 0x6e, 0xf9, 0xc1, 0xd7, 0x97, 0xec, 0x44, 0x28, 0xe5, 0x1f, 0x18, 0x87, 0x98, 0x56, 0x32, 0xb1, 0x63, 0x40, - 0x64, 0x7a, 0x58, 0xc0, 0x72, 0xe0, 0x66, 0xe4, 0xf1, 0xe3, 0xd6, 0x38, 0xd3, 0x14, 0x9f, 0xab, 0xff, 0x9f, 0xd8, - 0x7a, 0x10, 0xd7, 0x6e, 0x2f, 0x8d, 0x48, 0x62, 0x1a, 0xa3, 0x01, 0xf3, 0x8a, 0x06, 0x68, 0x9c, 0x94, 0x01, 0xc3, - 0x5e, 0x59, 0xfa, 0x85, 0x1e, 0x63, 0x93, 0x47, 0xaa, 0x99, 0x98, 0x1f, 0x21, 0x64, 0xbb, 0x46, 0xc1, 0x44, 0x12, - 0x8c, 0xf6, 0x2d, 0x50, 0xd8, 0x81, 0x14, 0x53, 0xdd, 0x01, 0xf9, 0x9c, 0xcb, 0xc8, 0x6b, 0x20, 0x1b, 0x7d, 0xbe, - 0xb9, 0xd7, 0xaf, 0x13, 0xfb, 0xd2, 0xa3, 0x39, 0x84, 0x48, 0x23, 0xf2, 0xfb, 0xf0, 0xfe, 0x58, 0xaf, 0x99, 0x77, - 0xbd, 0xca, 0x14, 0x2f, 0xc1, 0xc8, 0x07, 0x37, 0x96, 0x90, 0x0f, 0x1a, 0xac, 0x02, 0x73, 0xf2, 0x35, 0xaa, 0xb5, - 0x43, 0xcc, 0xce, 0xf3, 0x26, 0x47, 0xde, 0x76, 0x75, 0x54, 0x51, 0x58, 0xad, 0xc0, 0xf9, 0x55, 0x03, 0xad, 0xc4, - 0x07, 0xf2, 0x2f, 0x43, 0xa2, 0x8a, 0x09, 0x61, 0x80, 0x1e, 0x19, 0xe7, 0x1f, 0x84, 0x28, 0xe8, 0x32, 0xa9, 0x5a, - 0x36, 0xfb, 0x97, 0x9a, 0xc3, 0x55, 0x60, 0x04, 0xec, 0x36, 0xa6, 0x31, 0x8d, 0xe7, 0xe3, 0x28, 0x66, 0xd6, 0xbc, - 0x2b, 0x89, 0xaf, 0x70, 0x2e, 0x08, 0x2a, 0xac, 0xe1, 0xbe, 0xcb, 0xff, 0xfd, 0x7c, 0xfc, 0x90, 0x97, 0x62, 0xe7, - 0xd7, 0xe5, 0x1a, 0xfa, 0x61, 0xff, 0x75, 0x29, 0x56, 0xbd, 0x49, 0x2d, 0x7a, 0x37, 0x9a, 0x36, 0x8e, 0xff, 0x7c, - 0x76, 0xb1, 0x91, 0x4e, 0xef, 0x78, 0xcb, 0x7b, 0xd0, 0x37, 0xa7, 0xe9, 0x69, 0x5c, 0xe0, 0xe7, 0x2c, 0x2f, 0x67, - 0xff, 0x95, 0xbb, 0x94, 0xc7, 0xf5, 0x7b, 0x76, 0xdd, 0xa1, 0x39, 0xad, 0xbd, 0xb1, 0xec, 0xdd, 0xb3, 0x2b, 0xfe, - 0x1e, 0x81, 0x2c, 0xbe, 0x08, 0xc9, 0xa4, 0x52, 0x09, 0x20, 0xd0, 0x5c, 0x0f, 0x7e, 0xf7, 0xc4, 0x28, 0xa5, 0x1e, - 0xef, 0x3f, 0x26, 0x5f, 0x95, 0x75, 0xb8, 0x3b, 0xb7, 0x40, 0xd6, 0x23, 0xfd, 0x3b, 0x4f, 0x37, 0xba, 0x5f, 0xd0, - 0xa8, 0x3a, 0x75, 0x90, 0x19, 0x8d, 0x33, 0x2d, 0x0d, 0xf9, 0xb7, 0x8d, 0xe6, 0x8c, 0xc2, 0xb7, 0x82, 0x46, 0x74, - 0x13, 0xe1, 0x1f, 0x57, 0x8d, 0x03, 0x4a, 0x0a, 0xf8, 0x61, 0x9b, 0xf6, 0x6d, 0xf7, 0x72, 0x2f, 0xa4, 0xa9, 0xf2, - 0xcb, 0x33, 0x16, 0x18, 0xb4, 0x0f, 0x74, 0x66, 0x47, 0xff, 0x7f, 0x0a, 0x68, 0xbd, 0x88, 0x51, 0xb2, 0x95, 0x3a, - 0x40, 0x5c, 0x6c, 0xe3, 0xe6, 0x0b, 0xbd, 0x71, 0x9a, 0x0b, 0x67, 0x1e, 0xf5, 0xe8, 0x24, 0xdd, 0x02, 0x18, 0xd5, - 0xfc, 0x7e, 0xc4, 0xab, 0x53, 0x57, 0x46, 0x7c, 0x54, 0xbc, 0xa3, 0xbb, 0x0b, 0xcc, 0xf6, 0xbf, 0xf2, 0x2e, 0x46, - 0x34, 0x7f, 0xf7, 0x11, 0xe8, 0x86, 0x1f, 0xb3, 0xd3, 0x37, 0x9f, 0xf9, 0xe3, 0x03, 0x3e, 0x0c, 0xed, 0x1e, 0xa3, - 0x79, 0x67, 0xdc, 0x9a, 0x27, 0x3c, 0x31, 0xc8, 0x0c, 0xe0, 0xb2, 0xcf, 0xde, 0x7b, 0x2c, 0xe3, 0xc0, 0x77, 0x20, - 0x56, 0x26, 0xf3, 0x16, 0x30, 0x29, 0x17, 0x23, 0xa4, 0x35, 0x32, 0xfa, 0x37, 0xe0, 0x45, 0xc9, 0xe8, 0x9f, 0xce, - 0x3d, 0x8a, 0x6e, 0x48, 0xf4, 0xc9, 0x93, 0x01, 0xcb, 0x3a, 0x28, 0x5a, 0x62, 0x52, 0x21, 0x3a, 0x84, 0x2c, 0x13, - 0xa0, 0xf4, 0x49, 0xa0, 0xa1, 0xf0, 0x77, 0x2d, 0x27, 0xbd, 0x9f, 0x7b, 0x66, 0x82, 0xa4, 0xc7, 0xe4, 0x28, 0x8d, - 0x4c, 0x18, 0xf9, 0x73, 0xcd, 0xcb, 0xeb, 0xeb, 0xa7, 0x76, 0x7b, 0xd0, 0x7c, 0x64, 0xbf, 0x95, 0xe6, 0xc4, 0xe4, - 0x6b, 0xad, 0x06, 0x2b, 0x79, 0x03, 0x28, 0x9b, 0x7d, 0x41, 0x2b, 0x60, 0xf1, 0x5b, 0x0d, 0x61, 0xe9, 0x99, 0x0c, - 0xb4, 0x06, 0x4e, 0xd2, 0x73, 0x36, 0xb8, 0x6e, 0x98, 0x1f, 0x91, 0x5e, 0xaf, 0x98, 0xa8, 0x32, 0xa7, 0x27, 0x7d, - 0xba, 0xb9, 0x1e, 0x7b, 0xb1, 0xd0, 0x87, 0xd4, 0x13, 0xfa, 0x93, 0x17, 0xe1, 0x6c, 0xf9, 0xb9, 0xec, 0x3f, 0x4d, - 0x20, 0x75, 0xd5, 0x18, 0x2d, 0x74, 0x7e, 0x3d, 0xbe, 0x9b, 0x35, 0x3e, 0x1a, 0xd9, 0xea, 0x6d, 0xbb, 0x73, 0x64, - 0xb9, 0x77, 0x8b, 0x59, 0x5f, 0x42, 0x3e, 0xa3, 0x58, 0x33, 0x99, 0x83, 0x9c, 0x23, 0xb4, 0xbf, 0xd6, 0x95, 0xe4, - 0xb8, 0xf6, 0x61, 0x4e, 0x41, 0x7a, 0x6c, 0x0d, 0xeb, 0x20, 0x6a, 0xbe, 0xad, 0x7d, 0x06, 0x2d, 0xbf, 0x9e, 0x7a, - 0x9d, 0x16, 0x4c, 0xf2, 0xa4, 0x73, 0x5f, 0xf7, 0x8f, 0x34, 0xe2, 0x5e, 0x7a, 0x59, 0x13, 0x45, 0xb7, 0x48, 0x40, - 0xd7, 0x2a, 0x2d, 0xf4, 0xb2, 0xe2, 0x3c, 0xad, 0xe8, 0x4f, 0x33, 0xe6, 0x51, 0xc9, 0xaa, 0x51, 0xa9, 0x9e, 0x5c, - 0x63, 0x9c, 0x29, 0xeb, 0x09, 0x20, 0x17, 0x45, 0x02, 0xc7, 0x59, 0x6f, 0xd7, 0xa7, 0x4b, 0x43, 0x07, 0xf1, 0xd1, - 0xdb, 0xb8, 0xe9, 0xbc, 0x83, 0x69, 0x2c, 0xdd, 0x9f, 0x48, 0x67, 0x19, 0xc3, 0x89, 0x2a, 0x4b, 0xf2, 0xb4, 0x1c, - 0x85, 0xba, 0xa3, 0xbb, 0x20, 0x29, 0x4b, 0xf6, 0x46, 0x3b, 0xfb, 0xe3, 0x7a, 0xf2, 0x28, 0xfb, 0x30, 0xec, 0xa1, - 0x0a, 0xdc, 0x43, 0xaa, 0xef, 0x72, 0xff, 0xba, 0xcc, 0x94, 0xa6, 0xc1, 0xfe, 0xc7, 0xd7, 0xa1, 0x03, 0x3f, 0x0e, - 0x6e, 0xc7, 0x11, 0x12, 0x28, 0xb7, 0x98, 0xa6, 0x0c, 0x5b, 0x4e, 0x30, 0xd9, 0xee, 0x0d, 0x37, 0xc5, 0xd5, 0x9e, - 0x4b, 0x14, 0x83, 0x25, 0xf7, 0xc0, 0xcb, 0x67, 0xb4, 0x7f, 0x62, 0xeb, 0x26, 0xe6, 0xa9, 0x6b, 0xe1, 0xa3, 0xd4, - 0xc2, 0x34, 0x74, 0x60, 0xb0, 0xc8, 0x59, 0x92, 0x8c, 0x04, 0xbb, 0xfc, 0xd2, 0x6a, 0x27, 0xcf, 0x72, 0x25, 0xd3, - 0xd7, 0x5e, 0x4f, 0x4c, 0x31, 0xd2, 0xb0, 0xa5, 0xed, 0x70, 0xd8, 0xc9, 0x79, 0x02, 0x22, 0x44, 0x54, 0xcf, 0x97, - 0xb8, 0xa6, 0xbe, 0x10, 0x67, 0xdd, 0xf9, 0x32, 0x56, 0xb4, 0xe7, 0x41, 0x01, 0x08, 0xad, 0x36, 0xad, 0x54, 0x17, - 0xdc, 0xd0, 0x23, 0x48, 0x77, 0xeb, 0xe5, 0x1d, 0x14, 0x55, 0xcd, 0xf4, 0x60, 0xd2, 0x8b, 0x1f, 0xe7, 0x5d, 0xe1, - 0x61, 0x16, 0x19, 0x2a, 0x80, 0x1b, 0xa3, 0xef, 0xe0, 0x72, 0x7d, 0xcf, 0x43, 0xb8, 0xb5, 0xe6, 0x4c, 0xcf, 0x4f, - 0x5b, 0x8f, 0x78, 0xf1, 0xe6, 0x61, 0x1c, 0xc2, 0x5d, 0x6c, 0x7d, 0xfa, 0x24, 0x5f, 0x3b, 0x6c, 0xe7, 0xd1, 0xa2, - 0xd0, 0xd6, 0xe5, 0xd4, 0xf6, 0xe2, 0xce, 0xe7, 0xf9, 0x27, 0xb5, 0x05, 0xca, 0x0a, 0xbc, 0xf6, 0xea, 0x3e, 0x22, - 0x14, 0x73, 0x07, 0xdf, 0xfe, 0x2f, 0x1d, 0xb5, 0xdd, 0x7c, 0xde, 0x0e, 0x67, 0x46, 0x0f, 0xcf, 0x48, 0x88, 0xba, - 0x3c, 0xd8, 0x24, 0xd7, 0xaf, 0xfe, 0xe9, 0x29, 0x7e, 0xa5, 0x9d, 0xe6, 0x5f, 0x73, 0xce, 0x0b, 0x63, 0x53, 0x3e, - 0xdb, 0x47, 0x9a, 0x30, 0xba, 0x46, 0x84, 0xcb, 0xef, 0xdb, 0xd0, 0x4a, 0x83, 0x8c, 0x48, 0x08, 0x79, 0xbd, 0x75, - 0x05, 0xb8, 0xef, 0x2f, 0xdb, 0x1d, 0xbc, 0xa5, 0x44, 0xe2, 0x8d, 0xea, 0x38, 0x6e, 0xcf, 0xc8, 0xc2, 0xf5, 0xfd, - 0x5b, 0x07, 0x82, 0x7d, 0xad, 0x7d, 0x25, 0xbf, 0xdc, 0x39, 0x7a, 0x01, 0x06, 0x94, 0x30, 0x84, 0x27, 0x51, 0xff, - 0x97, 0xd8, 0x88, 0xd4, 0x6d, 0xc6, 0x74, 0xc2, 0x84, 0xfd, 0x59, 0xd1, 0xaa, 0xad, 0xf4, 0x00, 0x28, 0xa6, 0x4e, - 0xae, 0x06, 0x51, 0x74, 0x87, 0x26, 0xe2, 0x8e, 0x39, 0x5a, 0xde, 0x13, 0x9a, 0xb5, 0x40, 0x15, 0x4e, 0x61, 0xcf, - 0xa3, 0x50, 0x9a, 0xe1, 0x19, 0xf4, 0x01, 0xf6, 0x52, 0x84, 0x9c, 0xb9, 0x24, 0x79, 0xe6, 0xc0, 0x6b, 0x13, 0x04, - 0x69, 0x74, 0xb7, 0x7a, 0x4a, 0xb1, 0x8b, 0x6c, 0xb7, 0x20, 0xe9, 0xcd, 0x22, 0x74, 0x2b, 0x56, 0x49, 0x8a, 0xbb, - 0x99, 0x8a, 0xad, 0x0e, 0x1e, 0x61, 0x8f, 0x48, 0xdf, 0x96, 0xfd, 0xbd, 0x75, 0xc0, 0x42, 0x17, 0x45, 0x4d, 0x4a, - 0xed, 0xbf, 0x29, 0x1d, 0x38, 0xa1, 0x86, 0x09, 0x05, 0x05, 0xfb, 0x6c, 0xdc, 0x62, 0xbc, 0x7b, 0x6b, 0x6d, 0x6f, - 0x21, 0xf0, 0x2a, 0x34, 0x37, 0xd5, 0x82, 0x5c, 0xe1, 0x0b, 0x64, 0xc9, 0xb5, 0x15, 0x42, 0xd7, 0x37, 0x2d, 0xbb, - 0xf0, 0xfc, 0xc2, 0xf4, 0xc7, 0x56, 0x29, 0xea, 0x52, 0x90, 0x4b, 0x38, 0xb5, 0xb2, 0x46, 0x57, 0x1f, 0xd8, 0x9a, - 0x8e, 0x51, 0xbb, 0x33, 0xce, 0x5e, 0x21, 0x90, 0xfc, 0x89, 0x4a, 0x9d, 0x53, 0x9a, 0x11, 0x18, 0x5e, 0x0f, 0x8a, - 0xd5, 0x2f, 0xb9, 0x16, 0x30, 0x0e, 0x0f, 0xf4, 0xc7, 0xa0, 0x48, 0x9e, 0x64, 0x62, 0x0e, 0x03, 0x4f, 0xe5, 0xb0, - 0x73, 0xcf, 0xe9, 0x4e, 0xe6, 0xf7, 0xbe, 0xb1, 0xb7, 0xc7, 0xae, 0xe3, 0x96, 0x31, 0x3f, 0x8c, 0x20, 0x6a, 0x25, - 0xc2, 0x48, 0x45, 0x1e, 0x31, 0x80, 0x12, 0x4e, 0xae, 0x1b, 0x70, 0xa8, 0xa9, 0x36, 0xdc, 0xa7, 0xe8, 0x08, 0xcc, - 0xa9, 0xcb, 0x34, 0xaa, 0x39, 0x55, 0x99, 0x20, 0x84, 0xcf, 0x8d, 0x5b, 0xe7, 0x78, 0x02, 0x33, 0xed, 0x80, 0xd5, - 0x26, 0xaf, 0x53, 0x1c, 0x84, 0xcc, 0xd4, 0x9d, 0x2d, 0x1a, 0x13, 0x49, 0x4d, 0xb5, 0x4b, 0xad, 0x05, 0xe3, 0x64, - 0xb3, 0x6b, 0xd4, 0x6e, 0x2b, 0x32, 0xb8, 0x88, 0x15, 0x0f, 0x64, 0x04, 0x38, 0xba, 0x96, 0x6b, 0x94, 0x27, 0x47, - 0x5a, 0x10, 0xe6, 0x26, 0x39, 0x8e, 0x98, 0xb6, 0x7f, 0xdc, 0x8d, 0xe8, 0x66, 0x9e, 0x99, 0x8a, 0xc3, 0x5f, 0xbd, - 0xe7, 0xb6, 0x5e, 0x59, 0x2a, 0xd6, 0xf3, 0x2c, 0x25, 0xeb, 0x95, 0xcf, 0x2c, 0xa5, 0x21, 0xb9, 0xb0, 0x16, 0xd8, - 0x6c, 0x9a, 0xa5, 0xd9, 0x72, 0x7a, 0xde, 0xb9, 0x45, 0x66, 0x5e, 0xf0, 0x08, 0x53, 0xde, 0xae, 0xbc, 0x44, 0x67, - 0x03, 0xf6, 0x3f, 0xfb, 0x7c, 0x09, 0x9a, 0x19, 0x2b, 0x34, 0xc7, 0xbb, 0xc2, 0x1c, 0x12, 0x59, 0x61, 0xd4, 0x8f, - 0x4b, 0xf9, 0xec, 0x5d, 0x70, 0xda, 0x6a, 0xe7, 0x46, 0x05, 0x85, 0xef, 0x4d, 0x52, 0x60, 0x22, 0x09, 0x6c, 0x72, - 0x34, 0xee, 0x83, 0xf3, 0xac, 0x9c, 0xe9, 0x97, 0x03, 0x04, 0xff, 0x89, 0x6d, 0xc6, 0x35, 0x27, 0x30, 0x77, 0x06, - 0x77, 0x4a, 0xa8, 0x6e, 0x88, 0xe1, 0xf5, 0xd9, 0x75, 0x4e, 0x56, 0x1c, 0x73, 0x4b, 0xb2, 0x10, 0xe0, 0xb5, 0x07, - 0xb7, 0xcf, 0x33, 0x6b, 0x71, 0xa7, 0xe2, 0x34, 0xd4, 0x66, 0x5f, 0xfa, 0xcc, 0xd7, 0x83, 0x5f, 0x8d, 0x1c, 0x65, - 0x5c, 0xe0, 0x66, 0xd7, 0x8b, 0x81, 0x21, 0x34, 0x9e, 0x05, 0xe8, 0x11, 0x4f, 0xe9, 0xbf, 0x80, 0x10, 0xbf, 0x1b, - 0xfc, 0x2a, 0x33, 0x83, 0xd5, 0xd7, 0x2a, 0x06, 0x89, 0x9e, 0x64, 0x42, 0x81, 0x91, 0x61, 0xe8, 0xba, 0x2a, 0x8b, - 0x84, 0x37, 0xbc, 0xd8, 0xcd, 0xee, 0xcd, 0x98, 0x3f, 0x60, 0xa8, 0x43, 0xf8, 0x25, 0xb1, 0x27, 0xe6, 0x39, 0x9c, - 0x6a, 0xe6, 0x65, 0x76, 0x56, 0x45, 0x63, 0xbd, 0x59, 0xe3, 0x89, 0x09, 0xd5, 0x87, 0x68, 0xdb, 0x37, 0xc5, 0xdc, - 0x6e, 0xf7, 0xd6, 0x87, 0xd3, 0x44, 0x8d, 0x98, 0x99, 0x9a, 0x8f, 0xfb, 0xc6, 0x0a, 0x69, 0x33, 0x52, 0x64, 0x12, - 0xaa, 0x0c, 0x56, 0xc2, 0xc8, 0x3d, 0xbd, 0x6d, 0x75, 0x74, 0x5a, 0x00, 0x4e, 0x34, 0xcb, 0xdb, 0x4a, 0x64, 0xa3, - 0xbd, 0xb6, 0x1b, 0x85, 0xa8, 0x17, 0x3d, 0x9e, 0x51, 0x28, 0x15, 0x37, 0x34, 0x70, 0x6e, 0x06, 0x02, 0x4b, 0x3f, - 0xc5, 0x4b, 0xd8, 0x8b, 0xae, 0x3d, 0x6b, 0xc2, 0xb5, 0x51, 0x7b, 0x87, 0xb4, 0xac, 0x54, 0x4b, 0xd9, 0x77, 0x8e, - 0x74, 0xe3, 0x85, 0xaa, 0x97, 0xb9, 0xd0, 0xb9, 0xda, 0x4f, 0x7c, 0x6c, 0x1b, 0x23, 0x4d, 0xed, 0x9a, 0xfe, 0x66, - 0xce, 0x36, 0xd7, 0x99, 0xac, 0x90, 0x1f, 0x2c, 0x43, 0xfe, 0x04, 0xe9, 0xb6, 0x91, 0x4d, 0xac, 0xc4, 0xfa, 0x85, - 0x1f, 0xf0, 0x0e, 0x3a, 0x67, 0x2d, 0x3b, 0xb0, 0x36, 0xdb, 0x2e, 0x58, 0x26, 0x3f, 0x58, 0xae, 0x5d, 0xe3, 0x37, - 0x7c, 0x08, 0x57, 0xb2, 0x3a, 0x97, 0x9d, 0xec, 0x3d, 0xfe, 0x45, 0xfd, 0xf2, 0xfb, 0x19, 0x3d, 0x8b, 0x0f, 0x96, - 0x35, 0xde, 0x4c, 0x9f, 0xb2, 0x32, 0xfb, 0xc5, 0xed, 0x5b, 0x8b, 0x8f, 0x37, 0x97, 0x36, 0x38, 0x8f, 0x61, 0x68, - 0xef, 0xc5, 0xdd, 0x83, 0xfa, 0xc3, 0x70, 0x56, 0x4e, 0xd0, 0x6a, 0x18, 0x19, 0xe0, 0xce, 0xd6, 0xf3, 0x05, 0xbd, - 0xc7, 0xc6, 0x4c, 0x1f, 0xee, 0xf9, 0xd0, 0xbb, 0xfc, 0xc7, 0xcb, 0x7e, 0x24, 0x9c, 0x3d, 0x3a, 0xbb, 0x40, 0xd0, - 0x5a, 0xd7, 0x56, 0x4a, 0xf5, 0x98, 0xd7, 0x2e, 0x8e, 0xd0, 0x92, 0x3d, 0x2f, 0x75, 0x34, 0xff, 0xd0, 0x2a, 0x87, - 0x0d, 0x1a, 0x63, 0xf5, 0xbe, 0xd5, 0x96, 0x46, 0x6f, 0x3f, 0x10, 0x16, 0xa6, 0xa1, 0x52, 0x81, 0x80, 0x4a, 0xff, - 0xcc, 0x26, 0x5c, 0x7b, 0x9b, 0xc9, 0x28, 0x7d, 0x8a, 0x30, 0x7b, 0xd4, 0x93, 0xc5, 0xfb, 0x8e, 0x9d, 0xac, 0xd5, - 0x1b, 0xca, 0x74, 0x58, 0x69, 0x33, 0x59, 0xa9, 0x11, 0x46, 0x0c, 0x33, 0x9b, 0xe1, 0x85, 0x2a, 0xa7, 0xc3, 0xae, - 0x04, 0x81, 0xa9, 0xda, 0x78, 0xe2, 0xdc, 0xc1, 0xf3, 0xdf, 0xb2, 0x3e, 0x40, 0x8c, 0xe9, 0xe1, 0xc4, 0xde, 0x81, - 0x2e, 0xb5, 0x8b, 0x27, 0xfc, 0xcb, 0xdf, 0x92, 0x43, 0xb0, 0x42, 0xb5, 0xf1, 0xfd, 0x00, 0xd6, 0xd7, 0x20, 0xe6, - 0x6d, 0x77, 0xe2, 0xe0, 0x8e, 0x5d, 0x59, 0x3e, 0xcd, 0xcb, 0x03, 0x88, 0x52, 0x36, 0x72, 0xb3, 0x61, 0xcd, 0xaf, - 0x66, 0x16, 0xbb, 0x36, 0x9e, 0x1f, 0xc8, 0xfa, 0xb0, 0xcd, 0x9f, 0x0b, 0x90, 0xa2, 0x28, 0xd0, 0x96, 0xbb, 0xda, - 0xa2, 0x8d, 0x80, 0x69, 0xd7, 0x3a, 0x6f, 0x6d, 0x21, 0xeb, 0xa4, 0x76, 0xca, 0xa0, 0x2b, 0x65, 0x8a, 0x9c, 0x9a, - 0x51, 0x23, 0x44, 0xc7, 0xf8, 0x41, 0x0e, 0xfd, 0x62, 0xf5, 0xdd, 0xf5, 0x3b, 0x5d, 0x80, 0xb8, 0xe2, 0x54, 0xe6, - 0x59, 0x49, 0xac, 0x0f, 0x37, 0x79, 0xcf, 0x1b, 0xf4, 0xbf, 0xd4, 0x95, 0xef, 0xcb, 0xda, 0x13, 0x24, 0x03, 0x41, - 0x3a, 0x0e, 0xfe, 0x18, 0xc0, 0xf0, 0xc7, 0x06, 0x46, 0x2f, 0x7a, 0x78, 0x1e, 0x54, 0xbf, 0x76, 0xc2, 0x77, 0x96, - 0x5f, 0xaa, 0xd0, 0xfb, 0x49, 0xf5, 0x0b, 0x58, 0x5f, 0x83, 0xa0, 0x8e, 0x44, 0xcd, 0xef, 0x69, 0x5b, 0xf7, 0x2b, - 0x8c, 0x78, 0x91, 0x0f, 0x15, 0xf9, 0xeb, 0xba, 0xfa, 0x3c, 0x87, 0x01, 0x39, 0xf6, 0x09, 0x06, 0x36, 0xfd, 0xb2, - 0x0f, 0x21, 0x78, 0x5f, 0x5f, 0xd5, 0x42, 0xe3, 0x97, 0x22, 0x4e, 0x50, 0xe1, 0x81, 0x2c, 0x74, 0x3c, 0xb5, 0x72, - 0x6b, 0x1d, 0x99, 0x68, 0x6c, 0x62, 0x14, 0x3a, 0x8b, 0x15, 0x6c, 0xcc, 0x27, 0xa3, 0xba, 0xf2, 0x86, 0x09, 0x86, - 0x5f, 0xad, 0x3f, 0x9d, 0xa5, 0x57, 0x5b, 0x85, 0xbd, 0xaa, 0xf0, 0x5f, 0x75, 0x13, 0xbe, 0xc9, 0x70, 0x58, 0x05, - 0x2f, 0x08, 0x15, 0xfc, 0x40, 0x27, 0x55, 0xa8, 0xa3, 0xd3, 0x10, 0xa1, 0x55, 0xb3, 0x82, 0x1c, 0x15, 0xda, 0xef, - 0xdb, 0xd4, 0xd6, 0x9b, 0xea, 0xec, 0xed, 0x58, 0xd5, 0x54, 0x98, 0x1f, 0x8f, 0x59, 0x4d, 0x33, 0x12, 0x95, 0x2c, - 0xbf, 0x83, 0xdd, 0x69, 0x0b, 0x6f, 0x9f, 0xc0, 0xfb, 0x9b, 0xfa, 0x31, 0xe3, 0xb3, 0x6c, 0xd2, 0x04, 0xba, 0x33, - 0xd7, 0x02, 0xb5, 0x4f, 0x4d, 0xdd, 0x91, 0xb9, 0x0e, 0xec, 0x5d, 0xcd, 0x97, 0xf8, 0x4c, 0x84, 0xbb, 0x5f, 0x93, - 0xa8, 0xcc, 0x69, 0x06, 0x6d, 0x2c, 0xa5, 0x89, 0xaa, 0xdb, 0x70, 0xca, 0xb0, 0xf7, 0x0c, 0xed, 0x02, 0x6a, 0xf4, - 0x44, 0x77, 0x62, 0x8c, 0x90, 0xc6, 0xfd, 0x22, 0xb4, 0x1f, 0xe9, 0x79, 0x2b, 0x90, 0x8e, 0xed, 0x18, 0xa6, 0x9b, - 0x06, 0xc8, 0x5a, 0xe8, 0xe3, 0x5f, 0x5f, 0xed, 0xc3, 0xd8, 0xe6, 0xfd, 0x06, 0x61, 0xa9, 0xde, 0x1e, 0x1d, 0x20, - 0xf9, 0x9e, 0x52, 0x58, 0x5c, 0xd1, 0x1a, 0xad, 0x86, 0x8d, 0x83, 0x5c, 0x61, 0x30, 0xca, 0x54, 0xe9, 0x3c, 0x62, - 0x38, 0x1a, 0xc2, 0x08, 0x85, 0x42, 0x5e, 0x7d, 0xc4, 0x9a, 0x79, 0xdc, 0x9e, 0x3d, 0x94, 0x56, 0x07, 0xbf, 0x7a, - 0xb2, 0x46, 0x7d, 0xe9, 0x5d, 0x6e, 0xc6, 0x52, 0x8b, 0x8f, 0x57, 0xbc, 0xd1, 0xeb, 0xcb, 0x84, 0x66, 0x6e, 0xd1, - 0xa0, 0x14, 0x1b, 0x12, 0xbb, 0x95, 0xdf, 0x13, 0xeb, 0xb1, 0x59, 0x21, 0x09, 0x99, 0x5f, 0x5e, 0x99, 0xca, 0x53, - 0x79, 0x7f, 0x65, 0x39, 0xc3, 0x51, 0x3c, 0x78, 0x07, 0x7e, 0xd1, 0xcb, 0x9f, 0xa4, 0xde, 0xaa, 0x6e, 0x4b, 0x1b, - 0x14, 0xb5, 0x73, 0xcb, 0x86, 0x73, 0xe1, 0x3a, 0x29, 0x54, 0xc1, 0x0d, 0x16, 0x49, 0x23, 0x6f, 0x1d, 0x2f, 0x3e, - 0xc5, 0x60, 0xca, 0xc2, 0x19, 0x94, 0xb5, 0xcc, 0x05, 0xd6, 0x68, 0x1f, 0x86, 0x67, 0x8b, 0xcc, 0x18, 0x33, 0x18, - 0xdb, 0x70, 0x6e, 0xf9, 0xac, 0xfb, 0xfa, 0x85, 0xe0, 0xfd, 0xc6, 0x48, 0x44, 0x2c, 0x1f, 0xa0, 0x0f, 0x06, 0xa4, - 0x7f, 0x59, 0x62, 0xe4, 0xc3, 0x73, 0x05, 0x7e, 0xd2, 0xb2, 0x70, 0x00, 0x36, 0x6b, 0xef, 0x30, 0x2e, 0x92, 0x79, - 0xab, 0xdb, 0x31, 0x3b, 0x04, 0x37, 0x6c, 0x8d, 0x22, 0x18, 0x15, 0xa3, 0x25, 0x18, 0xac, 0xa0, 0x21, 0xb8, 0x80, - 0xf3, 0x75, 0xc4, 0xaa, 0xc7, 0x29, 0x2e, 0x33, 0x75, 0x86, 0x7f, 0x76, 0x37, 0xcd, 0xb2, 0x1a, 0xc4, 0x07, 0xa1, - 0xc8, 0x16, 0xec, 0xc1, 0xc5, 0x63, 0xe1, 0xcf, 0x21, 0xdf, 0x45, 0x61, 0xe9, 0x1a, 0xff, 0xaf, 0x43, 0xaa, 0xf7, - 0x3d, 0xec, 0x9e, 0x60, 0x0f, 0x3a, 0xa9, 0x2d, 0x34, 0x7f, 0x85, 0x55, 0x15, 0x55, 0xf3, 0xcd, 0x08, 0x8f, 0x16, - 0x5c, 0xab, 0x23, 0xd0, 0x41, 0x20, 0xd4, 0x6a, 0x06, 0x03, 0xb4, 0xe3, 0x07, 0xf8, 0xd2, 0xf1, 0xf8, 0x25, 0x89, - 0x09, 0xcf, 0xef, 0x9b, 0x10, 0xc4, 0xe3, 0xe8, 0x71, 0xe7, 0xfa, 0x43, 0x95, 0x21, 0xb2, 0x48, 0xea, 0x7e, 0x84, - 0xb9, 0xfd, 0x34, 0x17, 0x2e, 0x16, 0x27, 0xe8, 0xb1, 0x5c, 0x71, 0xc7, 0x3d, 0xea, 0x6e, 0xda, 0x3d, 0x9f, 0xb2, - 0x27, 0x31, 0x96, 0x52, 0xc4, 0x1d, 0xad, 0xcd, 0xb8, 0x22, 0x45, 0xae, 0x36, 0x81, 0x5e, 0x8e, 0xf4, 0x1c, 0x8f, - 0x64, 0x29, 0x51, 0xc7, 0x12, 0x44, 0xad, 0xe2, 0x3b, 0x23, 0x05, 0xd5, 0x28, 0xef, 0x72, 0xf7, 0xad, 0xd3, 0xd4, - 0xdd, 0xcf, 0xee, 0xa7, 0xc1, 0xcb, 0x54, 0xe7, 0x8c, 0x77, 0x5e, 0xb4, 0x5a, 0xfb, 0x22, 0x46, 0xaf, 0x1f, 0x0b, - 0x32, 0x9c, 0xf6, 0x5d, 0x67, 0x01, 0x6a, 0x95, 0xe5, 0xbf, 0x41, 0x20, 0x53, 0x74, 0x97, 0x9e, 0x8e, 0x68, 0xae, - 0x74, 0xf9, 0x8e, 0x0e, 0x54, 0x26, 0x0a, 0x31, 0xd3, 0x68, 0xf6, 0x80, 0xce, 0x2d, 0xcf, 0x75, 0x19, 0xf5, 0x2e, - 0xa2, 0x0d, 0x0a, 0xb5, 0xcf, 0xd1, 0x5d, 0x2f, 0x3a, 0x87, 0xeb, 0x94, 0xdb, 0x47, 0xcb, 0x45, 0xe5, 0xb3, 0xf1, - 0x70, 0x61, 0x97, 0x48, 0x22, 0x1f, 0x78, 0x09, 0x31, 0x74, 0xdf, 0xce, 0x30, 0x83, 0xb3, 0xda, 0xbb, 0x5d, 0xaa, - 0x1b, 0x3e, 0x84, 0x1e, 0xc5, 0xc2, 0xb5, 0x59, 0xce, 0xff, 0x97, 0xde, 0x45, 0xf5, 0xb7, 0x3a, 0x25, 0xee, 0x17, - 0xfe, 0x5d, 0x24, 0x8a, 0x84, 0x1e, 0xd2, 0x90, 0xde, 0x9f, 0x95, 0x1d, 0x98, 0x0f, 0xed, 0xa1, 0x32, 0x35, 0x79, - 0x9e, 0x05, 0xa0, 0xf5, 0xaa, 0x50, 0x46, 0x0e, 0x46, 0x4f, 0xce, 0x3b, 0xa4, 0x10, 0x86, 0x90, 0xc3, 0x20, 0x11, - 0x73, 0x1d, 0x70, 0x73, 0xd5, 0xed, 0x2c, 0x45, 0x85, 0xee, 0x1a, 0x96, 0x12, 0xd0, 0x11, 0x1d, 0x92, 0xcc, 0x9c, - 0xd0, 0x10, 0x14, 0x28, 0xf2, 0x1e, 0x31, 0x18, 0x4d, 0xe0, 0x3f, 0x98, 0x7d, 0x14, 0xd2, 0x08, 0x08, 0xe3, 0x14, - 0xc5, 0x7b, 0x20, 0x0e, 0x94, 0xd6, 0x3d, 0x98, 0x56, 0xe1, 0xaa, 0x57, 0xda, 0xca, 0x18, 0xbe, 0xc6, 0xb9, 0x33, - 0xc8, 0x05, 0x9e, 0xea, 0x5e, 0xcc, 0x80, 0x28, 0x40, 0x29, 0x68, 0xc1, 0x49, 0x90, 0x7c, 0xa8, 0x15, 0x48, 0xc0, - 0x21, 0xae, 0x41, 0xa9, 0xb1, 0xe0, 0xd5, 0x78, 0xa3, 0x10, 0x96, 0x62, 0x24, 0x02, 0x21, 0xd9, 0x30, 0xac, 0x98, - 0x0a, 0xb4, 0xfb, 0xc5, 0xbe, 0xf7, 0xc2, 0xe3, 0x43, 0x7d, 0x23, 0xe6, 0x02, 0x09, 0xa3, 0xb3, 0x93, 0x7b, 0x81, - 0x24, 0x7f, 0xb5, 0xa7, 0x2b, 0xb3, 0xbc, 0xf0, 0x8d, 0x85, 0x73, 0xb5, 0x12, 0x10, 0xf6, 0x6f, 0x8c, 0x03, 0x01, - 0x30, 0x97, 0xce, 0x6a, 0x2d, 0x91, 0x95, 0x0b, 0x69, 0xd6, 0x63, 0x29, 0xd6, 0xdd, 0x3c, 0x54, 0x80, 0x29, 0xb5, - 0xb8, 0x20, 0x95, 0x15, 0xde, 0x68, 0x0e, 0xa6, 0xf0, 0xa6, 0x83, 0xae, 0xcd, 0x67, 0xff, 0x43, 0xee, 0x1e, 0x1e, - 0x87, 0x57, 0xaa, 0x5b, 0x82, 0x51, 0x67, 0x92, 0xc1, 0x89, 0x4c, 0xa5, 0x9e, 0x06, 0xb1, 0x93, 0xbe, 0x13, 0x20, - 0x90, 0xd0, 0x38, 0x25, 0x9d, 0x8d, 0x74, 0xe8, 0x03, 0xf7, 0x43, 0x59, 0x50, 0x7c, 0x1d, 0x75, 0x7c, 0x11, 0x45, - 0x58, 0x64, 0xa5, 0x67, 0x97, 0x57, 0x37, 0x8d, 0xce, 0xcc, 0x4b, 0xcb, 0x9c, 0xc6, 0x4f, 0x60, 0xc9, 0x0a, 0x51, - 0xf2, 0x92, 0xb4, 0xb0, 0x9c, 0xe0, 0x7a, 0xa0, 0xe9, 0xb0, 0x20, 0x73, 0xe3, 0xb8, 0xfe, 0x51, 0x31, 0x8e, 0xa9, - 0xc3, 0x9e, 0xd2, 0x9b, 0x0a, 0x3c, 0x75, 0x64, 0x15, 0x3a, 0x10, 0x9e, 0x61, 0xbc, 0xa6, 0x81, 0x37, 0xfb, 0xf5, - 0xfc, 0xdf, 0x01, 0x8d, 0xe3, 0xc3, 0x25, 0x6d, 0xb8, 0x0e, 0xab, 0x70, 0x21, 0x8e, 0xc9, 0x0f, 0x26, 0x93, 0xb8, - 0x26, 0x71, 0xe0, 0xf7, 0x61, 0x89, 0x54, 0x88, 0x0c, 0xea, 0x58, 0xb9, 0x1d, 0xfb, 0x0b, 0x40, 0x8f, 0x87, 0x4c, - 0xe7, 0x81, 0x2f, 0x58, 0xe0, 0x38, 0xa8, 0x66, 0x37, 0x87, 0x8a, 0x05, 0xc0, 0x85, 0x59, 0x29, 0x5c, 0x8c, 0xd6, - 0x28, 0xc5, 0xec, 0x19, 0x1f, 0xda, 0x55, 0x03, 0x96, 0x99, 0x77, 0x66, 0xe5, 0xdc, 0x5a, 0x48, 0xc1, 0xed, 0xfa, - 0x46, 0x4c, 0x70, 0xbb, 0x46, 0xb2, 0x2d, 0xea, 0xd7, 0xe0, 0x58, 0x5c, 0x5c, 0xd7, 0xf8, 0xac, 0xcc, 0xdc, 0x49, - 0xfb, 0xc4, 0x75, 0x94, 0x56, 0x20, 0x89, 0xe7, 0x79, 0x18, 0x89, 0x05, 0xd3, 0xe7, 0x84, 0xa8, 0xc4, 0xb0, 0xf4, - 0xb1, 0xec, 0x0c, 0x83, 0xc7, 0x1c, 0x1d, 0x79, 0x66, 0xe7, 0x1c, 0xfe, 0xc7, 0x05, 0x60, 0x59, 0x7c, 0x2a, 0xe3, - 0x5f, 0x1c, 0x8f, 0xb2, 0x27, 0xf2, 0xfe, 0x4a, 0xe2, 0x4e, 0xc5, 0x1c, 0x48, 0x23, 0x5b, 0xc6, 0xd2, 0x16, 0xc8, - 0x45, 0xc6, 0x33, 0xec, 0xfc, 0xd4, 0xfa, 0x98, 0xfd, 0xd8, 0xc7, 0xaa, 0xe1, 0xd7, 0x81, 0x6e, 0x93, 0x12, 0xf4, - 0xad, 0x94, 0xe9, 0xec, 0xbd, 0x99, 0xd2, 0xdc, 0x89, 0xab, 0x7a, 0x65, 0x6b, 0x1b, 0x6a, 0x9b, 0xc4, 0xf5, 0x5b, - 0xf3, 0x18, 0x98, 0xb6, 0x4e, 0x5c, 0x19, 0x0a, 0x6d, 0xb2, 0x3c, 0xd3, 0x20, 0x55, 0x31, 0x74, 0xf7, 0x8a, 0x0f, - 0x9d, 0xee, 0x70, 0x36, 0x5f, 0x9a, 0xf4, 0x30, 0x9e, 0xc5, 0xb5, 0x5c, 0x92, 0xc1, 0x07, 0x85, 0xc3, 0x21, 0x49, - 0xd1, 0x22, 0x97, 0x21, 0x80, 0xdc, 0xed, 0xe0, 0x6e, 0xb2, 0xdd, 0x94, 0x77, 0xcc, 0x5e, 0x9a, 0xa3, 0xcf, 0xdb, - 0x72, 0x31, 0xa1, 0x46, 0x4c, 0xd5, 0x79, 0x6b, 0xbb, 0x6e, 0x0a, 0x4a, 0x39, 0x0a, 0xa4, 0x53, 0x16, 0xa2, 0x82, - 0x9f, 0x98, 0xef, 0xff, 0xa0, 0x28, 0x37, 0x04, 0xdc, 0xf2, 0x3a, 0x7e, 0xdc, 0x69, 0x2d, 0x63, 0x58, 0x8e, 0x8c, - 0x0b, 0xd3, 0xbf, 0xa4, 0x59, 0xcd, 0x96, 0x65, 0xe2, 0x75, 0x9d, 0x3d, 0x28, 0x2e, 0xe1, 0x5c, 0xad, 0x65, 0xe1, - 0x3a, 0xd2, 0xd0, 0x84, 0xfe, 0x10, 0x0a, 0xdb, 0xa6, 0x32, 0x70, 0xa2, 0x94, 0x21, 0x3f, 0x97, 0x86, 0x29, 0x18, - 0x7e, 0x13, 0x60, 0x9d, 0x66, 0x18, 0x85, 0xb4, 0x00, 0xaa, 0x0f, 0x47, 0x93, 0x6e, 0x08, 0x3b, 0x07, 0x1d, 0x47, - 0xe9, 0xec, 0xc0, 0x7a, 0x40, 0xce, 0xf3, 0xd9, 0x5e, 0xef, 0xcd, 0xfa, 0xda, 0xf8, 0x07, 0x04, 0x3e, 0xf3, 0x2d, - 0xfa, 0xda, 0x06, 0xe2, 0x7c, 0x39, 0x23, 0xc6, 0xb6, 0x0c, 0xd8, 0x52, 0xe5, 0x10, 0xb6, 0x54, 0x0c, 0x13, 0x33, - 0x75, 0x62, 0x8a, 0x17, 0x65, 0xdd, 0x79, 0x3e, 0x04, 0x2c, 0x50, 0x7e, 0xc0, 0x91, 0x25, 0xc7, 0x74, 0x14, 0x29, - 0x3a, 0x0d, 0x14, 0x2c, 0x50, 0x7e, 0x7d, 0x5b, 0xfe, 0x61, 0x08, 0xb0, 0x1c, 0x69, 0x95, 0x81, 0x64, 0x6a, 0x63, - 0x39, 0xa9, 0xc5, 0xa9, 0x38, 0x8b, 0xca, 0x30, 0xfa, 0xdd, 0xb8, 0x78, 0xe9, 0xbd, 0x56, 0x6f, 0xe1, 0x29, 0x57, - 0xb0, 0x46, 0x13, 0xd3, 0x13, 0xb1, 0xbf, 0xe0, 0x7c, 0x30, 0xc8, 0x6f, 0x78, 0x77, 0x08, 0xa9, 0x8d, 0x62, 0x8f, - 0xda, 0x0f, 0x4c, 0x46, 0xa5, 0xd5, 0x25, 0x2f, 0xea, 0x45, 0xb6, 0x65, 0x17, 0xbb, 0x72, 0x8f, 0x81, 0xcb, 0x8b, - 0x11, 0xe8, 0xf1, 0xf6, 0x1a, 0x1c, 0x00, 0x1f, 0x2d, 0x8a, 0xab, 0x61, 0x5b, 0xa4, 0x40, 0xd8, 0xd6, 0x7b, 0xde, - 0xea, 0x53, 0x2b, 0xc8, 0x63, 0x10, 0x5a, 0x97, 0x13, 0xde, 0xb9, 0x75, 0xca, 0x90, 0x16, 0x31, 0xce, 0xb3, 0xa8, - 0xd0, 0x87, 0x49, 0x55, 0xc9, 0x86, 0x7f, 0xc0, 0x60, 0xe4, 0x16, 0x53, 0xe1, 0xdf, 0xe2, 0xaf, 0xcc, 0x0d, 0xf7, - 0x6a, 0x98, 0xce, 0xa9, 0x36, 0xef, 0xba, 0xed, 0xf0, 0xc3, 0xf0, 0xdd, 0x12, 0x7a, 0x54, 0x60, 0x9c, 0xe6, 0x89, - 0xd9, 0x1a, 0x7e, 0xa5, 0x80, 0x6f, 0x1f, 0xca, 0xb4, 0x0d, 0x37, 0xd3, 0xaa, 0xbd, 0xe9, 0xb6, 0x1b, 0x40, 0xe6, - 0xac, 0x66, 0xf9, 0xe6, 0x83, 0x3b, 0x09, 0x69, 0x11, 0xfe, 0x58, 0x26, 0xea, 0x11, 0xb6, 0x74, 0xe8, 0x04, 0x3c, - 0xd3, 0xd3, 0xaa, 0xc6, 0xf3, 0x75, 0x56, 0x22, 0x7f, 0xb4, 0x37, 0xfe, 0xe4, 0x83, 0xb7, 0xbe, 0x83, 0x1a, 0x79, - 0xa2, 0x47, 0x84, 0x0b, 0xd5, 0x25, 0xb4, 0xad, 0x1a, 0xb2, 0x28, 0x96, 0xdc, 0x06, 0xde, 0x13, 0x53, 0x84, 0xc3, - 0x4f, 0xed, 0xe9, 0x52, 0xd4, 0xfe, 0x98, 0x19, 0xfc, 0x07, 0x80, 0x44, 0xe5, 0xf2, 0xbf, 0xc3, 0xe3, 0x1d, 0x85, - 0x88, 0x78, 0x0b, 0xc9, 0x82, 0x05, 0x18, 0x79, 0xa8, 0xcc, 0x48, 0x4a, 0xca, 0xb5, 0x12, 0x80, 0xef, 0xc3, 0xd0, - 0x56, 0x5d, 0x83, 0x1c, 0x6c, 0xf0, 0xb7, 0x0c, 0xe2, 0x61, 0xd7, 0x23, 0xad, 0xf1, 0xf2, 0xf8, 0xd2, 0xa7, 0x9a, - 0xd0, 0xe2, 0xdb, 0x48, 0x59, 0xbc, 0x5c, 0x3d, 0x10, 0x1d, 0x49, 0x0c, 0x71, 0x23, 0x27, 0xc9, 0x9b, 0xc4, 0xfb, - 0x69, 0x63, 0x44, 0x72, 0x62, 0x9d, 0xbd, 0x20, 0xe5, 0x17, 0x62, 0xf3, 0xdd, 0xb8, 0x73, 0xb8, 0x73, 0xbd, 0xaf, - 0x94, 0x45, 0x5d, 0x8b, 0x7a, 0x68, 0x76, 0x1d, 0xfd, 0xd9, 0x94, 0x30, 0xa4, 0x43, 0xa2, 0x41, 0x21, 0x2d, 0x2a, - 0x0b, 0xa4, 0x81, 0x9e, 0x44, 0xf6, 0x71, 0x58, 0xcc, 0xde, 0xbd, 0x4a, 0x7d, 0x92, 0x48, 0x49, 0x6c, 0x0f, 0x58, - 0x9a, 0x4c, 0xbc, 0xb9, 0x30, 0xfb, 0xbf, 0xb2, 0xf3, 0xf2, 0x21, 0xd2, 0x98, 0xaa, 0x63, 0x64, 0xa1, 0x06, 0x4a, - 0x59, 0x0b, 0xa7, 0x2d, 0xbe, 0x14, 0x45, 0x5b, 0x85, 0x9e, 0x6a, 0x1e, 0x78, 0x5a, 0x58, 0x13, 0xc5, 0x16, 0xf4, - 0x74, 0x98, 0x96, 0x25, 0xb5, 0x09, 0x4f, 0x5f, 0x7a, 0x9e, 0xe5, 0x39, 0xdb, 0x5d, 0x9a, 0x7d, 0xeb, 0xa0, 0x5e, - 0x53, 0xcb, 0xf6, 0x53, 0x95, 0x69, 0xd0, 0x12, 0x04, 0xf5, 0x10, 0xe4, 0x56, 0x61, 0xe2, 0xc6, 0x38, 0x4f, 0x77, - 0xed, 0xd6, 0x9d, 0xf9, 0xa7, 0x5d, 0x10, 0x17, 0x12, 0x18, 0x34, 0x12, 0xad, 0x26, 0xf4, 0x63, 0xc3, 0x52, 0x18, - 0x72, 0xb6, 0x64, 0x96, 0xf3, 0x6a, 0x20, 0x3f, 0xd3, 0x56, 0x70, 0x40, 0xc2, 0xe8, 0x1c, 0x63, 0x66, 0xf0, 0x39, - 0x12, 0xc3, 0x57, 0x6d, 0xd2, 0x73, 0x24, 0xf7, 0x34, 0xc1, 0x54, 0x00, 0xf3, 0x4a, 0xc1, 0x74, 0xd6, 0x37, 0x8b, - 0x0a, 0x56, 0xfc, 0xf0, 0xe3, 0x2f, 0xa8, 0xde, 0x07, 0x05, 0x9d, 0x04, 0x57, 0xea, 0xb6, 0x9d, 0xf1, 0x6d, 0xf7, - 0x41, 0x01, 0x5e, 0xa8, 0x21, 0xf3, 0x12, 0xff, 0x57, 0x2f, 0xd6, 0xd4, 0x2f, 0xf2, 0xd9, 0x61, 0xa2, 0xef, 0x32, - 0x69, 0xe6, 0xf7, 0xa5, 0x01, 0x65, 0x7e, 0xc9, 0xe3, 0x8a, 0x69, 0xde, 0x23, 0xfe, 0xd3, 0x98, 0xdb, 0xc2, 0x84, - 0x76, 0x98, 0x3e, 0x4a, 0xd4, 0xdc, 0x3e, 0x13, 0x54, 0xfb, 0x86, 0x97, 0xea, 0x31, 0x17, 0xac, 0x63, 0x72, 0x4b, - 0x89, 0xf5, 0x95, 0xc0, 0x83, 0x2c, 0x92, 0x89, 0x7b, 0xa9, 0xf6, 0x8e, 0xf2, 0x7c, 0xa7, 0xf6, 0x34, 0x39, 0x61, - 0x5d, 0x5c, 0x5d, 0xc9, 0xd7, 0x31, 0xc2, 0x6e, 0xbd, 0x59, 0x5e, 0xab, 0x62, 0xcc, 0x28, 0xd9, 0xd4, 0x6e, 0xef, - 0x62, 0x31, 0xe3, 0x26, 0x0c, 0x45, 0xb6, 0x28, 0x97, 0x8f, 0x5c, 0x3c, 0xe4, 0xfb, 0x94, 0x5f, 0xfd, 0x67, 0x0b, - 0x71, 0xf3, 0xf9, 0xf9, 0x1b, 0x23, 0x2c, 0x08, 0x03, 0xdb, 0xad, 0x22, 0x3e, 0x9d, 0x09, 0x14, 0xc6, 0xc6, 0x04, - 0x9b, 0xd7, 0xba, 0x09, 0xbc, 0x48, 0x94, 0x91, 0x34, 0xcc, 0xcf, 0xf2, 0x10, 0xa8, 0x62, 0xe8, 0x49, 0x6b, 0x25, - 0x8a, 0xd6, 0xf7, 0x63, 0x9f, 0x01, 0x21, 0x55, 0xb2, 0xac, 0x88, 0x2b, 0x57, 0x28, 0x04, 0x22, 0x09, 0x07, 0x47, - 0x60, 0x9b, 0x26, 0x84, 0x4f, 0x0f, 0xe9, 0xa5, 0x2e, 0x73, 0xc9, 0xc5, 0x35, 0x38, 0x0a, 0x60, 0x69, 0x32, 0xe2, - 0xd7, 0xbb, 0x55, 0x5e, 0xfa, 0xa5, 0x9d, 0x6e, 0xfe, 0x9e, 0x03, 0x8e, 0x0b, 0xdd, 0x17, 0x05, 0x68, 0x0d, 0x58, - 0x56, 0x28, 0x6f, 0x1f, 0x83, 0x8b, 0xd2, 0x61, 0xf4, 0x72, 0x5c, 0x2d, 0xa2, 0xba, 0x42, 0x59, 0xbb, 0x5d, 0x11, - 0x95, 0xb7, 0xf3, 0xd7, 0x34, 0xa9, 0x45, 0x04, 0x71, 0xde, 0x47, 0x34, 0xcb, 0x44, 0x98, 0x5d, 0xdc, 0x75, 0xa8, - 0xc7, 0x90, 0xf4, 0xa1, 0x15, 0x17, 0x11, 0xf8, 0xb4, 0x02, 0x69, 0x63, 0x6e, 0x0f, 0xe9, 0xb7, 0xb6, 0xa3, 0x00, - 0xe8, 0x85, 0xb0, 0x90, 0xb9, 0x91, 0x14, 0x3c, 0x7b, 0x0f, 0x54, 0x92, 0xf4, 0xb9, 0x1a, 0xb3, 0xae, 0xc7, 0x17, - 0xaf, 0x95, 0xbe, 0x05, 0x79, 0x6f, 0x8a, 0xe0, 0xe9, 0xaa, 0xbd, 0x74, 0x21, 0xa0, 0xbd, 0xce, 0x74, 0xb6, 0x34, - 0x7b, 0x1f, 0xbc, 0x17, 0x1d, 0x78, 0x0d, 0xf5, 0x66, 0x89, 0x3c, 0x66, 0xf0, 0x65, 0x93, 0x90, 0xe4, 0xb5, 0x91, - 0x0a, 0xa2, 0xa0, 0x07, 0xae, 0x51, 0x91, 0x8c, 0x92, 0x8b, 0x6e, 0xfb, 0xb3, 0x19, 0xa4, 0x5c, 0x5e, 0x7d, 0xcd, - 0xdb, 0x9d, 0x83, 0x28, 0xa5, 0xf9, 0xeb, 0x85, 0x4f, 0xbb, 0x67, 0x74, 0xe5, 0x35, 0x81, 0x56, 0x33, 0x7a, 0x4b, - 0x8d, 0x6a, 0xa4, 0xa9, 0x48, 0x05, 0xb1, 0x77, 0x59, 0x83, 0xb5, 0xf1, 0x78, 0x30, 0x95, 0x1a, 0xbc, 0xcf, 0xf4, - 0xa4, 0x75, 0xfa, 0xf6, 0x69, 0x39, 0x84, 0x57, 0xdf, 0x6d, 0x37, 0x2a, 0xf5, 0x5c, 0x7c, 0x2f, 0xdb, 0x45, 0xa6, - 0x75, 0x9c, 0xe8, 0x64, 0xd2, 0x57, 0x69, 0xb7, 0x27, 0x55, 0x3d, 0x73, 0xe1, 0x00, 0xfd, 0xbb, 0x51, 0xa6, 0x94, - 0xa5, 0xf1, 0xda, 0x25, 0xac, 0xa7, 0xde, 0x08, 0xbb, 0x2e, 0x0a, 0xc0, 0x3f, 0x67, 0x1c, 0x68, 0xa2, 0x63, 0xc5, - 0x3a, 0xbe, 0x2e, 0x75, 0x3c, 0x94, 0xfc, 0x80, 0x6d, 0x66, 0x92, 0x77, 0x28, 0x6e, 0xde, 0x10, 0xd8, 0x9f, 0x29, - 0xd6, 0x76, 0xab, 0xc4, 0x19, 0xab, 0xd8, 0x2b, 0xb1, 0xd7, 0x1e, 0x6f, 0x98, 0x40, 0x99, 0xad, 0x2b, 0x4c, 0x99, - 0x33, 0xbf, 0xc9, 0x67, 0x2f, 0xaf, 0x6f, 0x9e, 0xfd, 0x65, 0xe7, 0x35, 0xc3, 0xb0, 0x92, 0x3d, 0x27, 0xcb, 0x21, - 0xac, 0xca, 0xf8, 0x99, 0x9e, 0x6a, 0x1e, 0xdf, 0x51, 0x6f, 0xdc, 0x9b, 0xa4, 0x07, 0xe3, 0xdb, 0x13, 0x92, 0x87, - 0x82, 0x09, 0x18, 0xe9, 0xcf, 0x47, 0xa3, 0x00, 0x9d, 0x5c, 0x76, 0x0d, 0x2e, 0x12, 0x93, 0x3f, 0xa6, 0x5e, 0xc6, - 0x22, 0x05, 0xa9, 0x3a, 0xc2, 0x5d, 0x83, 0x48, 0x8a, 0x2a, 0xe8, 0xc8, 0x8b, 0x02, 0x4c, 0x5a, 0x70, 0x60, 0x01, - 0x0a, 0x3a, 0x5f, 0x79, 0x89, 0x25, 0x7e, 0x88, 0xfe, 0xf1, 0x1f, 0x56, 0x27, 0xbd, 0x68, 0xfe, 0x31, 0xbd, 0xf0, - 0xff, 0x4f, 0xe7, 0xb3, 0x75, 0x8e, 0x0d, 0x08, 0xd6, 0xff, 0x05, 0x62, 0x17, 0x9a, 0xa0, 0x04, 0xe9, 0x01, 0x84, - 0x43, 0xfe, 0xf4, 0x4e, 0x33, 0x9c, 0xb0, 0x7c, 0xaa, 0x26, 0xc3, 0x78, 0x2a, 0xce, 0xc9, 0x84, 0xc6, 0x71, 0x1a, - 0x4d, 0x29, 0xc0, 0x33, 0x6e, 0xcc, 0x5e, 0xc3, 0xd0, 0x7b, 0xdd, 0xa3, 0x40, 0xe8, 0x24, 0xdd, 0xcc, 0x58, 0x8a, - 0x49, 0xb4, 0xac, 0x57, 0x6d, 0x9c, 0x1c, 0xf6, 0xe0, 0x0c, 0xb4, 0xcc, 0x32, 0x27, 0x5a, 0x0f, 0x16, 0x42, 0x73, - 0x6e, 0x6c, 0x30, 0xdd, 0x5b, 0x28, 0xdd, 0x11, 0x41, 0x63, 0xf7, 0x98, 0xca, 0x56, 0x44, 0x25, 0xa5, 0x88, 0x78, - 0x63, 0x20, 0x4c, 0x2f, 0x7e, 0x9d, 0x9a, 0x53, 0xdf, 0xf6, 0x51, 0xbe, 0x32, 0xa9, 0x94, 0xb5, 0x5c, 0x48, 0x83, - 0xf1, 0xea, 0xcb, 0x48, 0x08, 0xcd, 0x44, 0x48, 0x91, 0x17, 0xb2, 0x27, 0x60, 0x13, 0x23, 0xbe, 0xc7, 0xa5, 0x84, - 0x32, 0x39, 0x28, 0x55, 0x50, 0x3c, 0x3e, 0xd4, 0x8f, 0x18, 0x10, 0xba, 0x1a, 0x23, 0x89, 0xe5, 0x58, 0xe8, 0x27, - 0xd2, 0x17, 0x34, 0x71, 0x08, 0x5c, 0xe3, 0x07, 0xd1, 0x67, 0x4f, 0xc6, 0xcb, 0x5e, 0x81, 0xcd, 0x39, 0xb0, 0x89, - 0xdc, 0x1c, 0xb4, 0xee, 0x3f, 0x65, 0x02, 0x4d, 0x14, 0x59, 0xeb, 0x39, 0x4b, 0xf6, 0x2c, 0xc5, 0x3c, 0x54, 0x4b, - 0x96, 0x40, 0xa3, 0xa8, 0x88, 0xd0, 0x5f, 0x0e, 0xf6, 0x50, 0xcf, 0x2a, 0x23, 0xb6, 0x30, 0xaf, 0x4e, 0xa8, 0xb2, - 0xd5, 0x51, 0xd2, 0x2b, 0x0b, 0xf5, 0x70, 0x37, 0x80, 0xf1, 0xb5, 0x9f, 0x7b, 0xe3, 0xce, 0xfa, 0x84, 0x6d, 0xcf, - 0x37, 0xb9, 0x38, 0xfe, 0x7a, 0xe6, 0xe5, 0xe1, 0xe0, 0xb9, 0x67, 0xb1, 0xd9, 0x50, 0x09, 0x81, 0x48, 0x26, 0x82, - 0x4a, 0xf5, 0xdb, 0x6c, 0x38, 0x20, 0xb0, 0x83, 0x62, 0x2b, 0xe1, 0xa5, 0x52, 0x51, 0xee, 0xad, 0xd5, 0x58, 0x0e, - 0x1c, 0x8e, 0x21, 0x8d, 0x63, 0x97, 0x98, 0x82, 0x38, 0xd6, 0x67, 0xe9, 0x21, 0xf0, 0x6b, 0x6b, 0x34, 0x4f, 0xec, - 0x90, 0x21, 0xd3, 0xa4, 0x4a, 0x45, 0xfa, 0x39, 0x00, 0x6e, 0x23, 0xa7, 0x17, 0xe9, 0x1f, 0x50, 0x02, 0xfc, 0x2a, - 0x16, 0x7b, 0xa3, 0x32, 0x16, 0xc9, 0xaa, 0xac, 0x56, 0x0a, 0xa7, 0xe3, 0x03, 0xf0, 0xd2, 0xbf, 0x2c, 0x58, 0xa0, - 0x6a, 0xa4, 0x67, 0x0b, 0x77, 0x08, 0xd4, 0x4a, 0xdf, 0x8d, 0x10, 0xb5, 0xee, 0xd7, 0xf5, 0xa8, 0xba, 0xb9, 0x58, - 0x8c, 0x31, 0xb6, 0xdc, 0x9b, 0x4f, 0x2a, 0xb7, 0x6d, 0x31, 0xfa, 0x36, 0x77, 0x60, 0x6b, 0xd2, 0x3d, 0xfd, 0xb0, - 0x3d, 0xe5, 0xe1, 0x25, 0x7e, 0xf3, 0x6a, 0x22, 0x33, 0x79, 0x7c, 0x26, 0x74, 0xef, 0xa9, 0x42, 0xcd, 0x8d, 0x85, - 0x3c, 0xf5, 0xbb, 0xf7, 0x34, 0xe1, 0x87, 0xfa, 0xaf, 0xd4, 0x78, 0x52, 0x93, 0x93, 0xbf, 0x99, 0xc5, 0x64, 0x13, - 0x86, 0xfd, 0xb0, 0x81, 0x20, 0x10, 0x50, 0x25, 0x80, 0xed, 0x51, 0x94, 0xeb, 0x6f, 0x4a, 0xaa, 0x5b, 0xc0, 0x85, - 0xa2, 0x53, 0x51, 0xf7, 0x29, 0xe1, 0x59, 0xcf, 0xb6, 0x5b, 0x28, 0x22, 0x2e, 0xaa, 0x33, 0x71, 0xa2, 0xcd, 0xcf, - 0xd9, 0xfe, 0x51, 0x75, 0x40, 0x87, 0x2c, 0x3e, 0xea, 0x9a, 0x50, 0x10, 0x37, 0xbc, 0x50, 0x86, 0x09, 0x92, 0x08, - 0x65, 0xd3, 0x6c, 0xf7, 0x65, 0xba, 0xc6, 0xad, 0xfd, 0xb9, 0xd0, 0xf7, 0x76, 0x20, 0x12, 0x17, 0x33, 0xd1, 0xac, - 0x08, 0x57, 0xf2, 0xe0, 0x54, 0xd1, 0xe6, 0x2c, 0xc8, 0xd6, 0xec, 0xad, 0x9e, 0x93, 0x39, 0xe0, 0x28, 0xd2, 0x72, - 0xcc, 0x8f, 0x3c, 0x17, 0xcf, 0xd5, 0x67, 0x8b, 0xe4, 0x7e, 0xc9, 0xc6, 0xb6, 0xdd, 0x4b, 0xd2, 0x93, 0x1c, 0x60, - 0x22, 0x04, 0xdf, 0x94, 0x90, 0x0e, 0xc0, 0xfa, 0xda, 0xb2, 0x04, 0x99, 0xa9, 0x02, 0x22, 0x93, 0xe9, 0xfc, 0xda, - 0x93, 0x83, 0xa0, 0x33, 0x15, 0x70, 0xc0, 0x10, 0x7e, 0x06, 0x06, 0x1a, 0xdf, 0x73, 0x90, 0xa4, 0x44, 0x43, 0x32, - 0xc5, 0x3d, 0x20, 0x52, 0xc0, 0xcd, 0x37, 0x4f, 0xb6, 0x68, 0x5d, 0x19, 0xa7, 0x90, 0x5e, 0xd2, 0x7a, 0x30, 0x25, - 0x31, 0x9e, 0x49, 0xb8, 0xc5, 0xf1, 0xcf, 0x96, 0xc0, 0xeb, 0x44, 0x5e, 0xa5, 0x64, 0x4b, 0xce, 0x09, 0x0c, 0x2e, - 0x47, 0x2b, 0x36, 0xb0, 0xb8, 0x7e, 0x0a, 0x6b, 0x8c, 0x27, 0x59, 0x1e, 0x8b, 0x9b, 0xbf, 0xef, 0xbf, 0xf5, 0xc8, - 0xee, 0xb3, 0xdd, 0x9d, 0x4f, 0x4d, 0x6b, 0x05, 0x83, 0x18, 0x53, 0xb2, 0x01, 0x6a, 0x05, 0xcf, 0x6c, 0xc8, 0xd8, - 0x95, 0xe6, 0x59, 0xe0, 0xa8, 0x67, 0xbb, 0x6f, 0xcd, 0xee, 0x58, 0xbe, 0x41, 0xfc, 0x44, 0x43, 0xd8, 0x78, 0xa7, - 0x02, 0x9b, 0xa8, 0xe5, 0xf7, 0xe8, 0xb7, 0xbb, 0xb3, 0x1d, 0xda, 0xab, 0x78, 0x37, 0x8d, 0x67, 0x19, 0x53, 0x59, - 0x96, 0xbf, 0x8f, 0x9e, 0xf9, 0xd1, 0xd1, 0x12, 0x86, 0xfa, 0xc8, 0x39, 0x4e, 0xd3, 0x38, 0xe8, 0x08, 0xf5, 0x84, - 0x1d, 0x7f, 0x87, 0x88, 0xff, 0xef, 0x0c, 0xff, 0x77, 0xf0, 0xbf, 0xe7, 0xf8, 0x17, 0xa1, 0xae, 0xf3, 0xd5, 0xe6, - 0xaf, 0x28, 0xff, 0x8d, 0x8f, 0x64, 0x9d, 0xbf, 0xda, 0xdb, 0x7d, 0x61, 0x5a, 0x6f, 0x92, 0x47, 0x6b, 0x13, 0x05, - 0xf4, 0xd5, 0xe3, 0xce, 0xe9, 0x04, 0x51, 0xe6, 0xd4, 0xf9, 0x88, 0xf2, 0xfb, 0x6e, 0x70, 0xe1, 0x17, 0xe3, 0x06, - 0x96, 0xad, 0x2f, 0xff, 0xf4, 0xaf, 0x90, 0xcb, 0x7d, 0x7e, 0xce, 0xb5, 0xad, 0x68, 0x94, 0xfd, 0x7d, 0x68, 0xdc, - 0x11, 0x41, 0x0d, 0xe7, 0xfe, 0xf3, 0x44, 0xf3, 0x4f, 0x06, 0x97, 0x22, 0x47, 0x6b, 0x85, 0xc5, 0x67, 0xfe, 0x4c, - 0x2b, 0x7b, 0xdf, 0xb8, 0x75, 0x65, 0x1b, 0x5a, 0x8c, 0x36, 0xdd, 0x00, 0x83, 0x49, 0x05, 0xad, 0x95, 0xb5, 0xfb, - 0xfc, 0x97, 0x89, 0x31, 0x24, 0x3c, 0xfa, 0xd9, 0xaf, 0x81, 0xfa, 0x8d, 0x6b, 0xb3, 0x06, 0x12, 0x5b, 0xa6, 0xe7, - 0x2f, 0x05, 0xb3, 0x09, 0x0d, 0x0f, 0xf5, 0x74, 0xa9, 0xf7, 0xea, 0xb3, 0x48, 0x34, 0xc6, 0xed, 0x50, 0x5c, 0x5f, - 0x01, 0x0a, 0xa4, 0x38, 0x4f, 0x97, 0xff, 0xc7, 0x5f, 0x88, 0xa2, 0xeb, 0x12, 0x3a, 0x2a, 0xe7, 0xa4, 0xe6, 0xa7, - 0xd0, 0x4b, 0x54, 0x7a, 0xa9, 0x12, 0x75, 0x54, 0x4c, 0x3c, 0x0b, 0x82, 0xeb, 0x8a, 0x9c, 0x9b, 0x5c, 0x0d, 0xe7, - 0x64, 0xdc, 0xe7, 0xff, 0x38, 0xcb, 0x49, 0x82, 0x87, 0xc0, 0x8c, 0xef, 0xec, 0x0a, 0x61, 0x49, 0x5e, 0xc3, 0xc1, - 0xec, 0xc1, 0xb0, 0x7c, 0x52, 0x03, 0x0d, 0x86, 0xf9, 0xf3, 0x05, 0x24, 0xe0, 0x1b, 0xdf, 0x0c, 0x52, 0xa3, 0xe2, - 0xa9, 0x3d, 0xad, 0x43, 0x95, 0x36, 0x06, 0x86, 0x21, 0x11, 0xc1, 0x21, 0xe7, 0x00, 0xf1, 0x41, 0xa7, 0xb3, 0xa3, - 0x8d, 0x63, 0x26, 0xa7, 0xe4, 0xa0, 0x1f, 0xe7, 0x52, 0x15, 0xca, 0xa1, 0x96, 0xd4, 0x51, 0xd1, 0x90, 0xe3, 0x32, - 0x3a, 0xd0, 0xa2, 0xdb, 0x2b, 0xc8, 0xfa, 0x32, 0x17, 0xef, 0xb2, 0xd9, 0xea, 0x91, 0x08, 0x33, 0x89, 0x18, 0xa9, - 0xe0, 0xb4, 0x64, 0x55, 0x0c, 0xfd, 0xa2, 0x25, 0xa6, 0x36, 0xe2, 0xe9, 0xde, 0x5a, 0x3a, 0x75, 0x1e, 0x79, 0x70, - 0x65, 0x90, 0xbd, 0xe2, 0x35, 0xe0, 0xde, 0x24, 0x1b, 0x66, 0x19, 0x2b, 0xb8, 0xb0, 0xbe, 0xf6, 0x69, 0xf5, 0xc1, - 0xbe, 0x43, 0xc1, 0x79, 0xf6, 0x5e, 0x0c, 0xba, 0x19, 0xec, 0x82, 0xea, 0x8d, 0xfd, 0x3c, 0x0d, 0xf8, 0xdd, 0x03, - 0xa5, 0xa0, 0xc0, 0x31, 0xa8, 0xa7, 0x3b, 0x7f, 0x43, 0x04, 0x7e, 0x60, 0x37, 0x60, 0x9d, 0xb3, 0x6d, 0xc1, 0x95, - 0x84, 0x6a, 0x20, 0xd5, 0x65, 0x2c, 0x0b, 0x6c, 0xa7, 0x87, 0x2d, 0xae, 0x7b, 0x8e, 0x06, 0xa5, 0xba, 0x37, 0x13, - 0x94, 0x89, 0xa3, 0x65, 0xae, 0x03, 0x5b, 0x04, 0x30, 0xb2, 0x15, 0xe1, 0x36, 0x20, 0xaf, 0x2e, 0x66, 0x34, 0xf4, - 0x87, 0xb8, 0x9a, 0x04, 0x34, 0xa0, 0x91, 0x8b, 0x8e, 0x17, 0x5d, 0x2f, 0x9d, 0xd2, 0xdb, 0xe3, 0x09, 0x72, 0xb0, - 0x73, 0x16, 0x86, 0xf2, 0x7a, 0x92, 0xf3, 0xa5, 0xe7, 0xbf, 0x9e, 0x10, 0xed, 0xf5, 0x31, 0x86, 0xb3, 0x85, 0x94, - 0xc4, 0x06, 0x32, 0xb4, 0x3a, 0x8e, 0xe8, 0xe0, 0x21, 0x0f, 0xe8, 0x49, 0x69, 0x96, 0xd7, 0x4d, 0x88, 0x8a, 0xb9, - 0x08, 0x95, 0xc8, 0xcb, 0xe2, 0x9a, 0xad, 0x2b, 0x10, 0x98, 0xd9, 0x2e, 0xf8, 0x82, 0xa3, 0xd1, 0xca, 0xf0, 0x82, - 0x30, 0x27, 0x8c, 0x90, 0xc5, 0xfa, 0x27, 0xc0, 0x1b, 0xbe, 0x06, 0x45, 0xfe, 0x64, 0x5c, 0x12, 0x9e, 0x59, 0xe6, - 0x0e, 0xe1, 0x84, 0x9f, 0x2a, 0x61, 0x75, 0x17, 0x15, 0xfa, 0xc7, 0xf3, 0x52, 0x50, 0x1c, 0x35, 0x06, 0x0c, 0xfb, - 0x22, 0x04, 0x91, 0x8a, 0xd2, 0xec, 0x3b, 0x88, 0x54, 0xf7, 0x03, 0xb1, 0x1e, 0x8e, 0x68, 0xc3, 0x23, 0x11, 0xc8, - 0x1f, 0x49, 0xb5, 0xd0, 0xc6, 0x47, 0x22, 0x24, 0xbd, 0x7d, 0xf3, 0xc1, 0x79, 0xb9, 0x29, 0xaa, 0x2c, 0xa3, 0xd0, - 0x27, 0x3a, 0xa8, 0x9c, 0xea, 0xf1, 0x7a, 0x96, 0xda, 0x0a, 0xae, 0x5b, 0x2b, 0x81, 0x8a, 0x8d, 0xe9, 0x32, 0xf7, - 0x91, 0xed, 0xb5, 0x4b, 0x29, 0xf1, 0xe7, 0x79, 0xc6, 0x1a, 0x8e, 0x04, 0xec, 0xa6, 0x17, 0x86, 0xb1, 0x93, 0x1d, - 0x43, 0x47, 0x92, 0xf4, 0x82, 0xd2, 0x74, 0x8c, 0x91, 0x9b, 0xd7, 0xdd, 0x60, 0xbb, 0x8a, 0xc1, 0x57, 0x03, 0xc3, - 0x39, 0x34, 0x69, 0x38, 0x29, 0x38, 0xd7, 0xad, 0xfb, 0xeb, 0x78, 0xe9, 0x3c, 0x2f, 0xfb, 0x18, 0x96, 0x47, 0x3c, - 0xd7, 0xf6, 0x5f, 0x2f, 0xe4, 0xf9, 0x7a, 0x09, 0xcb, 0xd6, 0x5f, 0x93, 0xe4, 0x08, 0xd9, 0xcf, 0x2a, 0xa2, 0xe6, - 0x17, 0x8c, 0x43, 0xe4, 0x4f, 0x71, 0x4c, 0x15, 0xcd, 0x5c, 0x75, 0x7a, 0x54, 0x7a, 0x83, 0x5e, 0x3c, 0x3c, 0x20, - 0x38, 0x09, 0x90, 0x27, 0x4e, 0x42, 0x18, 0x30, 0xe4, 0x14, 0xd6, 0x7c, 0x05, 0x3c, 0xe6, 0x71, 0xc3, 0x53, 0x9a, - 0xab, 0x3f, 0x01, 0x34, 0xa0, 0x02, 0xea, 0xc3, 0x0e, 0x5f, 0x2c, 0xc1, 0x86, 0x46, 0x08, 0x21, 0x9f, 0x10, 0xfc, - 0x2e, 0xff, 0x4a, 0x50, 0x16, 0x3b, 0x05, 0x32, 0x5a, 0xa7, 0xda, 0x15, 0x93, 0x95, 0x36, 0xa8, 0xff, 0x96, 0x76, - 0x53, 0x5e, 0x10, 0x39, 0x88, 0x50, 0x01, 0x06, 0xb2, 0xda, 0x50, 0xe0, 0xd5, 0x65, 0x9b, 0x69, 0x06, 0x63, 0xf5, - 0x51, 0xd3, 0x66, 0xef, 0xf6, 0x4a, 0x4f, 0x65, 0x40, 0xd4, 0x06, 0x86, 0x77, 0xfd, 0xcf, 0xe1, 0x69, 0x19, 0xcf, - 0x8b, 0x41, 0x35, 0x4e, 0x87, 0xc8, 0x70, 0x9d, 0x3a, 0x56, 0xad, 0xfc, 0xcf, 0x94, 0x12, 0xe3, 0x53, 0x51, 0xe1, - 0xc0, 0xba, 0x6a, 0xa8, 0x5a, 0x10, 0xa6, 0x8d, 0xd2, 0x3f, 0x28, 0xca, 0xa0, 0x05, 0x58, 0x1a, 0xb5, 0xc6, 0x01, - 0x9b, 0x9a, 0x5e, 0x9e, 0x1b, 0xd3, 0x89, 0x57, 0xfb, 0x9b, 0xc0, 0x84, 0xbb, 0xfa, 0x31, 0x87, 0xba, 0xb6, 0x78, - 0xae, 0xef, 0xbb, 0x8f, 0xa7, 0x3c, 0x24, 0x7a, 0x26, 0x16, 0x9a, 0xb2, 0xb9, 0xce, 0xe7, 0x1d, 0x14, 0x1b, 0x5e, - 0xd8, 0xe7, 0x2b, 0x24, 0xc8, 0x01, 0x87, 0x4d, 0x71, 0x35, 0x0e, 0xcf, 0x38, 0x27, 0x2a, 0x7d, 0x2e, 0xf6, 0x83, - 0x03, 0x38, 0x3c, 0xe7, 0x4a, 0x59, 0xcb, 0xe7, 0x6f, 0xd3, 0x19, 0x4f, 0xfa, 0x12, 0x9c, 0x94, 0x32, 0xf1, 0x8a, - 0xc5, 0x9f, 0xf1, 0xf1, 0x11, 0x67, 0x78, 0x7f, 0xa3, 0xf3, 0x69, 0x7f, 0x9b, 0xae, 0x8d, 0x79, 0x0f, 0xf2, 0x46, - 0x05, 0xcf, 0x5d, 0x58, 0xc6, 0x36, 0x51, 0x40, 0xf0, 0x37, 0x3a, 0xa7, 0x69, 0x1e, 0x42, 0x3c, 0xac, 0xa2, 0x22, - 0xbf, 0xa3, 0x48, 0x96, 0x68, 0xe1, 0x6d, 0x4e, 0x8b, 0xf4, 0x6a, 0x7a, 0xde, 0xe6, 0x4b, 0x99, 0x0e, 0xed, 0xc6, - 0xd4, 0x49, 0xb4, 0xaa, 0x30, 0x21, 0x88, 0xc0, 0x9d, 0x2e, 0x91, 0x60, 0xb7, 0x26, 0xb3, 0x27, 0xfc, 0x6b, 0x4c, - 0xe3, 0x20, 0x6e, 0xd9, 0x73, 0x8b, 0x7d, 0x4f, 0xcd, 0x1c, 0xe8, 0x85, 0x9c, 0xa1, 0x38, 0x64, 0x0c, 0xfa, 0x42, - 0xae, 0x1f, 0x8f, 0xd0, 0x49, 0x05, 0xf8, 0x55, 0xc9, 0xaf, 0x7a, 0xbc, 0x96, 0x0a, 0x95, 0xea, 0x6c, 0x22, 0x47, - 0xfb, 0xb3, 0xb4, 0xf1, 0x18, 0xfb, 0xb1, 0x3c, 0x7d, 0xed, 0xe9, 0x04, 0x50, 0xb1, 0x25, 0xb7, 0xb2, 0x6f, 0xc8, - 0x43, 0x4b, 0xbf, 0x79, 0x9c, 0x61, 0xe6, 0x08, 0xd0, 0xd9, 0xaa, 0xe0, 0xa9, 0x8b, 0xd9, 0xdb, 0x5b, 0x6e, 0x2a, - 0xb6, 0xdf, 0xf0, 0x19, 0x60, 0x27, 0x89, 0x40, 0x34, 0xaa, 0xf6, 0x59, 0xa7, 0x31, 0x79, 0x2a, 0x9e, 0x42, 0x23, - 0x60, 0x29, 0x22, 0xd7, 0x94, 0x68, 0x5d, 0xcb, 0xd0, 0x45, 0x72, 0x7c, 0xbb, 0x1c, 0x82, 0x04, 0x77, 0x68, 0xd6, - 0x48, 0xe8, 0xa9, 0xba, 0x62, 0xae, 0x54, 0x95, 0x50, 0x77, 0x3a, 0x4c, 0x79, 0x6e, 0xac, 0xf0, 0x28, 0x73, 0xd1, - 0xb9, 0x8e, 0x55, 0x25, 0xc2, 0x22, 0x2e, 0xce, 0x02, 0x2f, 0x02, 0xfc, 0x08, 0x0c, 0xe2, 0x52, 0x95, 0x65, 0xa6, - 0x08, 0x49, 0x93, 0x6d, 0x81, 0xb1, 0xe2, 0xd0, 0x6f, 0xa2, 0x9a, 0x07, 0x5f, 0xff, 0x34, 0xa1, 0x28, 0xec, 0x04, - 0x44, 0xa3, 0x41, 0xb7, 0x16, 0xd0, 0xcc, 0xfc, 0x9b, 0x72, 0x78, 0x0c, 0x9d, 0x27, 0xf1, 0x86, 0x25, 0xc9, 0xa2, - 0x3f, 0xff, 0x97, 0x06, 0x61, 0xd2, 0x3b, 0x90, 0x52, 0x95, 0x40, 0xbb, 0x61, 0x6e, 0x3e, 0x89, 0x0c, 0xd9, 0xa2, - 0x5c, 0xec, 0x71, 0x94, 0x73, 0x1b, 0xd2, 0x0a, 0x66, 0x5e, 0x42, 0xc9, 0x3b, 0x5a, 0xaf, 0xbc, 0x0e, 0xab, 0x6e, - 0x79, 0x8d, 0x97, 0x38, 0xd1, 0x2f, 0x58, 0x74, 0x4d, 0xae, 0xc1, 0x6d, 0x42, 0x03, 0x32, 0x2b, 0x13, 0xd5, 0x1b, - 0x82, 0xa7, 0x90, 0xb2, 0x31, 0xe0, 0xe2, 0xdc, 0xe0, 0xd1, 0xf9, 0x9c, 0x90, 0xb2, 0x90, 0x0b, 0xcd, 0x00, 0x27, - 0x31, 0x92, 0xd1, 0xa6, 0x2e, 0xe5, 0x42, 0x9d, 0x7e, 0xe0, 0xad, 0x83, 0x1e, 0xd6, 0x66, 0x67, 0x2b, 0xf6, 0xb2, - 0x5e, 0x31, 0xb2, 0xa6, 0xea, 0x72, 0x52, 0x6e, 0x4a, 0x68, 0x68, 0x3f, 0xc6, 0x7b, 0x19, 0x87, 0x1c, 0x66, 0xd5, - 0x18, 0x89, 0x3d, 0x23, 0x39, 0xb3, 0x49, 0x92, 0x65, 0xd6, 0x65, 0x2d, 0xfd, 0x6c, 0xae, 0xfd, 0x8f, 0x6c, 0xe1, - 0x11, 0xcb, 0x38, 0xbf, 0xd2, 0x43, 0x49, 0xb8, 0xb2, 0x4e, 0x93, 0xe7, 0xe9, 0x07, 0xe1, 0xba, 0xdb, 0xd4, 0x8c, - 0x86, 0xf7, 0x80, 0x84, 0x2c, 0x6d, 0xd9, 0xaa, 0x36, 0x36, 0x36, 0xf8, 0xb9, 0xcf, 0x03, 0x0b, 0xf1, 0xa0, 0x21, - 0x19, 0xfd, 0x7c, 0x9b, 0xc7, 0x86, 0x85, 0xce, 0xe8, 0xa0, 0x94, 0x5e, 0x4b, 0x71, 0xee, 0x05, 0x4a, 0x2c, 0xf8, - 0x72, 0xaf, 0xa4, 0xc9, 0x99, 0x07, 0x7e, 0x10, 0x67, 0x46, 0x17, 0x99, 0x77, 0xbe, 0x46, 0xdd, 0x4a, 0xef, 0x95, - 0x96, 0xf4, 0x83, 0x5f, 0xfd, 0x6c, 0x95, 0x5e, 0xa7, 0x5f, 0x22, 0x73, 0xe6, 0x2b, 0x5c, 0xa2, 0x5d, 0x81, 0x1d, - 0xc6, 0x49, 0x5d, 0xf3, 0xeb, 0x0e, 0xd0, 0xfb, 0xc2, 0x9b, 0x81, 0x46, 0x89, 0xc0, 0x0b, 0x4d, 0x2e, 0x71, 0x25, - 0xbe, 0x10, 0xde, 0x14, 0x7b, 0x98, 0xc1, 0x44, 0x6c, 0x14, 0x41, 0x3c, 0x00, 0xff, 0x1c, 0xe2, 0x97, 0x31, 0xbf, - 0xd7, 0x41, 0x6d, 0xb4, 0x20, 0x34, 0x45, 0xe6, 0x26, 0x7b, 0x11, 0x5b, 0x90, 0xc3, 0x2b, 0x81, 0x04, 0xb9, 0x66, - 0x8d, 0x1d, 0xb0, 0x65, 0x5b, 0xa1, 0x6c, 0xde, 0x70, 0x6c, 0xb0, 0x3b, 0x7b, 0x69, 0x6d, 0x12, 0x84, 0xb8, 0x44, - 0xd4, 0x9a, 0x1e, 0x19, 0xe5, 0xab, 0x96, 0x82, 0x4a, 0xf4, 0xf3, 0xf4, 0xbf, 0xb1, 0x49, 0x6f, 0x17, 0x04, 0xfd, - 0x52, 0x64, 0xda, 0x2f, 0x34, 0x0c, 0x4a, 0x8b, 0xbc, 0xff, 0x43, 0x09, 0x7c, 0x1d, 0xac, 0xe9, 0x3a, 0xd5, 0x6e, - 0x7d, 0xf1, 0x4f, 0x50, 0x3d, 0x4f, 0xc6, 0x1d, 0x46, 0x2e, 0x0c, 0x68, 0x7e, 0x6f, 0x24, 0x9d, 0xb7, 0xfe, 0xcf, - 0x12, 0x02, 0x67, 0x90, 0x25, 0x14, 0xae, 0x11, 0xf9, 0x26, 0xff, 0xc0, 0x30, 0xed, 0x15, 0xc1, 0xce, 0x8e, 0xbc, - 0x37, 0xb6, 0x93, 0x75, 0x88, 0x36, 0xf4, 0x0d, 0x98, 0xb2, 0x7e, 0xa3, 0x59, 0x36, 0x40, 0x6d, 0x3b, 0xe3, 0xb6, - 0x2b, 0x69, 0x16, 0x92, 0xe1, 0x99, 0xc5, 0x20, 0xd5, 0x46, 0x7e, 0xe6, 0x95, 0x85, 0x33, 0x73, 0x77, 0xa1, 0x91, - 0x9f, 0x3d, 0x9d, 0xe1, 0xf0, 0xc6, 0x46, 0x7a, 0xe1, 0x09, 0x4b, 0x73, 0x43, 0x07, 0x00, 0x20, 0x5c, 0x2a, 0x3a, - 0xde, 0xb1, 0x54, 0xd9, 0x4a, 0xd4, 0x57, 0x82, 0xe6, 0xe0, 0x38, 0x1b, 0x5d, 0xc8, 0x27, 0x4c, 0x18, 0xe9, 0x79, - 0x0e, 0x11, 0x8f, 0x63, 0xe5, 0x32, 0x18, 0x32, 0x07, 0x3f, 0x91, 0x60, 0x47, 0xb3, 0xc3, 0x59, 0x0e, 0x57, 0xa9, - 0x5f, 0x27, 0xa8, 0x86, 0xd4, 0x58, 0x65, 0x6c, 0xc3, 0xfa, 0xff, 0x0a, 0x27, 0x2e, 0xeb, 0x79, 0x42, 0x2f, 0x3a, - 0x2c, 0xdc, 0xc2, 0x47, 0x2e, 0xf9, 0x87, 0x38, 0x38, 0xc4, 0xd9, 0x18, 0xe7, 0x19, 0x48, 0x9e, 0x36, 0x50, 0x18, - 0x79, 0x39, 0xbe, 0x54, 0x72, 0xb6, 0x1b, 0xaa, 0x4a, 0x4a, 0x97, 0x74, 0xab, 0xbd, 0x67, 0xb2, 0x1f, 0x91, 0x13, - 0x27, 0x45, 0x24, 0x99, 0x4c, 0x2a, 0xa8, 0x53, 0x1a, 0x6c, 0xfa, 0x15, 0xc0, 0x08, 0xe6, 0x5a, 0xd7, 0x34, 0x45, - 0x69, 0x99, 0x70, 0xf8, 0x1c, 0x2d, 0xd6, 0x99, 0x43, 0xd1, 0xc1, 0x60, 0x50, 0x48, 0x33, 0xb4, 0x0b, 0x3c, 0xc8, - 0xa8, 0xcc, 0x1e, 0x7f, 0x9e, 0x9f, 0x14, 0x4d, 0xd8, 0x64, 0xc5, 0x2a, 0x51, 0xa4, 0x96, 0xb1, 0xa4, 0x54, 0x75, - 0xfe, 0xed, 0x3e, 0x1a, 0x1a, 0x1d, 0x58, 0x03, 0x3a, 0x0a, 0x25, 0x9c, 0xf7, 0x50, 0x3c, 0xe9, 0x4e, 0xcd, 0xde, - 0x7e, 0xeb, 0x77, 0x57, 0x1f, 0xe2, 0x54, 0xdf, 0x25, 0x9d, 0xdc, 0x37, 0x9e, 0xb0, 0x9d, 0xb1, 0xd7, 0xba, 0xbe, - 0x63, 0x0b, 0x60, 0x97, 0x19, 0xcf, 0xc6, 0xf0, 0xbe, 0x8e, 0xbd, 0xf8, 0x82, 0x5c, 0x3d, 0x7f, 0xd6, 0x6a, 0xf9, - 0x8c, 0x5b, 0x0a, 0xa7, 0x1c, 0xa1, 0x85, 0x45, 0x40, 0x76, 0xc0, 0x28, 0x20, 0x2f, 0xa1, 0xcb, 0x18, 0xc2, 0x83, - 0x5f, 0x1b, 0x14, 0xad, 0x0e, 0x38, 0x13, 0xe8, 0x51, 0x3f, 0xe8, 0xc1, 0xdf, 0x74, 0x12, 0x6f, 0x4c, 0xbc, 0xb7, - 0x68, 0x4a, 0xbf, 0x5a, 0x69, 0x53, 0xa6, 0x3c, 0xbb, 0xe4, 0xc3, 0xd8, 0x0e, 0x55, 0x5b, 0xbb, 0x0d, 0x35, 0xc4, - 0x67, 0xb8, 0x9f, 0xb8, 0xa2, 0x78, 0x6c, 0x06, 0xea, 0x6f, 0xfc, 0xc8, 0x3b, 0x6d, 0x3f, 0x92, 0xfb, 0x10, 0x87, - 0x1e, 0xcb, 0xbe, 0x2a, 0xfb, 0x00, 0xae, 0xd9, 0xd9, 0x58, 0x89, 0x9f, 0x15, 0x61, 0xb8, 0x1c, 0x82, 0xf1, 0x67, - 0xb5, 0x0d, 0x3d, 0xa9, 0xa7, 0xac, 0x79, 0xfc, 0x3a, 0x32, 0xbf, 0xc9, 0xc2, 0xa4, 0x5e, 0xc8, 0x9a, 0x1e, 0xa7, - 0x33, 0x43, 0xe7, 0x4c, 0xe4, 0xee, 0xd9, 0xaf, 0xd1, 0xda, 0xc8, 0x48, 0xed, 0x6c, 0xd1, 0xc7, 0xdf, 0x35, 0x55, - 0x36, 0x9a, 0x34, 0xe3, 0xb1, 0x73, 0xad, 0x4b, 0x97, 0xba, 0x8c, 0x4c, 0xf7, 0x67, 0xbd, 0x58, 0x50, 0xa5, 0x55, - 0x66, 0xde, 0x27, 0x52, 0xc3, 0xb8, 0xd6, 0x6a, 0xe2, 0x9d, 0x5e, 0x7c, 0xcb, 0x8e, 0xdd, 0x74, 0x96, 0x64, 0x30, - 0x98, 0x8e, 0xdc, 0x0c, 0x1d, 0xc9, 0x08, 0x53, 0xbf, 0x7c, 0x32, 0x30, 0xeb, 0x3a, 0x7f, 0x6f, 0x5f, 0x33, 0x8e, - 0xe2, 0x5b, 0x8f, 0x15, 0xfd, 0xb6, 0x4e, 0xb7, 0xef, 0x09, 0x70, 0x6d, 0x53, 0xfb, 0xd4, 0x5b, 0xa5, 0x9c, 0x8d, - 0xe4, 0x58, 0x4e, 0xe2, 0x09, 0x14, 0x3c, 0x00, 0x49, 0x04, 0x4c, 0x9a, 0xf9, 0xc5, 0x52, 0xc9, 0x84, 0x8d, 0xba, - 0xdb, 0xef, 0x15, 0x17, 0x18, 0xb5, 0x7f, 0x94, 0xb9, 0x9a, 0x5e, 0x8e, 0x14, 0x25, 0xd3, 0x81, 0x81, 0xa6, 0x30, - 0x96, 0x3e, 0xff, 0x62, 0x5b, 0x89, 0x05, 0x95, 0x45, 0xb1, 0xb0, 0x66, 0x74, 0x00, 0x22, 0xe8, 0x63, 0x2a, 0xa8, - 0x1a, 0x7e, 0x43, 0xca, 0xe7, 0xf4, 0xeb, 0xd9, 0xf9, 0x88, 0x6d, 0xba, 0x29, 0x7d, 0x8c, 0x07, 0xab, 0xec, 0x75, - 0x7e, 0x1f, 0x9f, 0xfa, 0x42, 0x2f, 0x5e, 0x49, 0xde, 0x59, 0xf7, 0x4f, 0xf3, 0xcf, 0xe7, 0x6c, 0xfe, 0xf9, 0xac, - 0x19, 0x60, 0x98, 0xd9, 0x18, 0xf7, 0x2a, 0x7a, 0xb5, 0xc0, 0x35, 0x4e, 0x65, 0x78, 0x1a, 0xcb, 0x7f, 0x74, 0x16, - 0xae, 0x32, 0xc0, 0x99, 0x6c, 0xa0, 0x4d, 0x65, 0x10, 0x7c, 0x73, 0xc3, 0x82, 0xa6, 0x2b, 0x27, 0x26, 0x9a, 0x99, - 0x48, 0x5c, 0x82, 0x9c, 0xc4, 0x1c, 0xec, 0xc9, 0xa5, 0xea, 0x3a, 0x25, 0x33, 0x7e, 0x66, 0xda, 0x82, 0x9e, 0xa0, - 0xf0, 0x53, 0x90, 0xa9, 0x00, 0x41, 0x74, 0xbe, 0x4f, 0xf3, 0x38, 0x4a, 0x7f, 0xe7, 0x98, 0xa7, 0xfc, 0xff, 0x89, - 0x2c, 0x11, 0xa9, 0x6b, 0x19, 0xc2, 0x01, 0xbf, 0x46, 0x34, 0x75, 0xc6, 0xef, 0xc5, 0xa0, 0x7e, 0x91, 0xde, 0xaa, - 0x11, 0x38, 0x17, 0xa0, 0xae, 0x46, 0x98, 0x06, 0xf2, 0x8e, 0xf6, 0x1a, 0xf9, 0xc5, 0xa9, 0xd2, 0x2d, 0x7b, 0x5a, - 0xf2, 0x8a, 0x2c, 0x54, 0xfe, 0xc9, 0x98, 0x62, 0x56, 0x15, 0x1d, 0x47, 0x9a, 0xf7, 0x0d, 0x36, 0xc9, 0x17, 0x4e, - 0x12, 0x7a, 0xca, 0xa9, 0xb1, 0x30, 0x6e, 0x15, 0x5e, 0xda, 0x85, 0xd1, 0x07, 0x64, 0x0e, 0x34, 0x17, 0x65, 0x3f, - 0x0e, 0x71, 0x44, 0x7c, 0xa0, 0x9f, 0xf1, 0x2d, 0x64, 0xdc, 0xf6, 0x1c, 0x27, 0xc4, 0xa9, 0xfa, 0x62, 0x28, 0x5e, - 0xc6, 0x26, 0x15, 0x92, 0x1b, 0xb2, 0x18, 0xca, 0xaa, 0x85, 0xe7, 0x67, 0xf0, 0x7c, 0xe1, 0xf9, 0x69, 0x9e, 0x1b, - 0xbc, 0x25, 0x53, 0x51, 0x46, 0x62, 0xed, 0x0a, 0x3b, 0x6a, 0xf9, 0x4d, 0x5e, 0x61, 0xef, 0x73, 0x00, 0x12, 0xd5, - 0x5e, 0x15, 0x0b, 0x72, 0x01, 0x1b, 0xd0, 0xa0, 0x3a, 0x0c, 0xf2, 0x12, 0x5f, 0x0a, 0x20, 0x00, 0xa3, 0x07, 0xef, - 0xd9, 0x71, 0x3a, 0x6d, 0x0c, 0xe3, 0x2e, 0xc7, 0x04, 0x4a, 0xae, 0xd9, 0x54, 0x12, 0xf2, 0x26, 0x27, 0x9c, 0x7d, - 0x01, 0x2a, 0x84, 0x31, 0x80, 0x3c, 0x35, 0xb6, 0x58, 0xbd, 0x7e, 0x2b, 0xd5, 0xe7, 0x04, 0xa3, 0xb0, 0x97, 0x98, - 0x7d, 0xa1, 0x1b, 0x11, 0x11, 0x39, 0x8b, 0x39, 0xb9, 0xf4, 0xda, 0x55, 0xea, 0xfb, 0xd1, 0xd9, 0x27, 0x04, 0xf4, - 0xed, 0xe4, 0x5b, 0x73, 0x13, 0x0e, 0xc7, 0x97, 0x2c, 0x33, 0xd1, 0x7f, 0xae, 0x16, 0xc8, 0xb3, 0x32, 0xe7, 0x3d, - 0x57, 0xc7, 0xae, 0x89, 0x34, 0xc5, 0xa6, 0xa0, 0x53, 0x70, 0x4a, 0x10, 0x41, 0x5b, 0x70, 0x92, 0xe4, 0x90, 0x88, - 0xca, 0x40, 0x7f, 0x36, 0x15, 0x4b, 0x30, 0x5b, 0xc3, 0x52, 0x9d, 0xd7, 0x5a, 0xec, 0x9a, 0x1f, 0xb2, 0x27, 0x99, - 0x65, 0xc3, 0x45, 0xea, 0x4c, 0x27, 0xc5, 0x75, 0x64, 0x8d, 0xc2, 0xd4, 0xb8, 0x8b, 0xc5, 0xab, 0x84, 0x2b, 0xa9, - 0xdc, 0xa4, 0x4a, 0xbc, 0xd9, 0xa8, 0x19, 0x8f, 0xc6, 0xe5, 0x8f, 0x6d, 0x76, 0xf4, 0x46, 0x4a, 0xc0, 0xe3, 0x21, - 0xd5, 0xde, 0xa6, 0x33, 0x6e, 0x43, 0xfe, 0xed, 0xea, 0x69, 0x0b, 0x58, 0xfc, 0x4f, 0x7a, 0x18, 0xe2, 0xff, 0x8d, - 0x0a, 0x8a, 0xc8, 0x36, 0x42, 0x78, 0x14, 0x02, 0x84, 0xfb, 0xc2, 0xc9, 0x0b, 0x62, 0x51, 0xc4, 0xa3, 0xb0, 0xf7, - 0x3a, 0x6b, 0xe5, 0x12, 0x16, 0x7f, 0x1f, 0x74, 0xff, 0x8e, 0x42, 0xe7, 0x5c, 0xaf, 0xf1, 0xa7, 0xe2, 0xc7, 0x1f, - 0x39, 0x76, 0x74, 0x28, 0xc2, 0x4d, 0x0c, 0xad, 0x04, 0xcf, 0xb6, 0x44, 0x75, 0xac, 0x0a, 0x46, 0x84, 0x30, 0x07, - 0xa9, 0x6a, 0xc1, 0x04, 0xbb, 0xf0, 0x13, 0x2c, 0x3e, 0x10, 0x4e, 0xff, 0x1b, 0x1a, 0xb7, 0x09, 0x61, 0x36, 0x58, - 0xe5, 0xa8, 0x93, 0x27, 0xf1, 0x6a, 0x9f, 0xf9, 0x23, 0xe3, 0xd6, 0x57, 0x5f, 0xa4, 0xd5, 0x48, 0xf6, 0x14, 0x33, - 0x38, 0xbc, 0x76, 0x4b, 0x99, 0x19, 0x9f, 0x23, 0x26, 0x55, 0xf3, 0xe4, 0x45, 0x41, 0xa9, 0xa1, 0xae, 0x59, 0x9e, - 0x0b, 0xf3, 0x9c, 0xe1, 0xb9, 0xc0, 0x60, 0x2c, 0xeb, 0xb2, 0xc6, 0x19, 0x9f, 0xc6, 0x56, 0x0c, 0x8c, 0x3b, 0x1e, - 0x0e, 0x7a, 0x8e, 0x39, 0x54, 0x80, 0x5e, 0x04, 0x6e, 0x22, 0x3e, 0xe9, 0x25, 0x70, 0xea, 0xd4, 0x86, 0xf3, 0xcc, - 0x5c, 0x8e, 0x98, 0xf2, 0x0c, 0xa7, 0x98, 0xd2, 0xef, 0xdc, 0x26, 0xd2, 0x6e, 0x7d, 0xad, 0xc9, 0x47, 0xc1, 0xad, - 0x7a, 0x3a, 0xac, 0x23, 0x1a, 0x97, 0x16, 0xc1, 0x93, 0x45, 0x79, 0xd8, 0xbf, 0xf1, 0x9c, 0x5f, 0x89, 0xb4, 0x93, - 0x52, 0x25, 0x3a, 0x9f, 0x79, 0x0e, 0x42, 0x48, 0x93, 0x96, 0xd0, 0xf6, 0xb9, 0x20, 0x25, 0xc6, 0xb5, 0x53, 0x8a, - 0xd8, 0xcd, 0xc1, 0xfe, 0xd7, 0x53, 0xb9, 0x48, 0xab, 0xeb, 0x87, 0x77, 0x8b, 0x85, 0xd8, 0x4e, 0xc8, 0x79, 0x2e, - 0x64, 0xef, 0xfd, 0x6b, 0x12, 0x5e, 0x27, 0xe5, 0x97, 0xfd, 0x20, 0x71, 0xef, 0x5c, 0x6a, 0xfe, 0x5f, 0x38, 0x6c, - 0x7a, 0xf4, 0xca, 0xc1, 0x6c, 0x33, 0x01, 0x93, 0xa3, 0x52, 0x55, 0xdf, 0x8f, 0x80, 0xd4, 0xf0, 0x30, 0x40, 0x59, - 0xc5, 0xfe, 0x41, 0xbd, 0x25, 0x00, 0xb8, 0xd4, 0xb3, 0xfd, 0xb0, 0xb4, 0xbf, 0xfb, 0x61, 0xab, 0x13, 0x2e, 0x57, - 0x4a, 0xfe, 0x1b, 0x18, 0x42, 0x97, 0x86, 0xe4, 0xb0, 0x75, 0xd6, 0x03, 0x21, 0x77, 0x9e, 0x73, 0x8a, 0x51, 0x5c, - 0x4b, 0xbe, 0x60, 0xfb, 0x91, 0x94, 0xd7, 0xc9, 0x7c, 0x7e, 0xb1, 0xb5, 0x82, 0xcf, 0x3a, 0xa9, 0xbf, 0xa8, 0x44, - 0xff, 0x05, 0xec, 0x3b, 0x88, 0x0f, 0xcf, 0x13, 0xab, 0xca, 0x0f, 0x1d, 0x87, 0x23, 0xac, 0xb0, 0x95, 0x2a, 0x0d, - 0xcd, 0x23, 0xd3, 0xc7, 0x94, 0x9c, 0x9a, 0x80, 0x71, 0x48, 0x72, 0xb6, 0x56, 0x91, 0x4b, 0x92, 0x6a, 0x16, 0xee, - 0xeb, 0x06, 0x9f, 0x84, 0xe9, 0x92, 0x56, 0xe1, 0x1e, 0xa0, 0x7f, 0x0c, 0x7a, 0xfe, 0xcf, 0x4a, 0x71, 0xc0, 0xb0, - 0xea, 0x85, 0x4c, 0xfc, 0x64, 0x00, 0xd7, 0x66, 0x9e, 0xcc, 0x71, 0xe3, 0x6d, 0xd4, 0x47, 0x6d, 0x4b, 0xc0, 0x4f, - 0xa2, 0x27, 0x8f, 0x61, 0x4c, 0x16, 0x86, 0x02, 0x77, 0x2e, 0xf5, 0x73, 0xb0, 0x70, 0xc3, 0x31, 0xfa, 0x86, 0xe0, - 0x5b, 0xfe, 0xfc, 0x0e, 0xd4, 0x71, 0x8c, 0x86, 0x2e, 0x8d, 0xa4, 0xf4, 0x3b, 0x14, 0x44, 0x1a, 0x48, 0xa0, 0x79, - 0xee, 0x6b, 0x28, 0x9c, 0x4e, 0x22, 0x3b, 0xc9, 0x11, 0xd6, 0xce, 0x82, 0x59, 0xab, 0x9c, 0xbe, 0x9e, 0xed, 0x3b, - 0x0c, 0xd0, 0xb5, 0xcb, 0xe9, 0x7d, 0xae, 0xbf, 0x95, 0xa9, 0x9f, 0x2b, 0x84, 0x96, 0x65, 0x9b, 0x9d, 0x43, 0x20, - 0x94, 0x6b, 0xf5, 0x34, 0x44, 0xc1, 0x10, 0xef, 0x74, 0xac, 0x6e, 0xd0, 0x47, 0x2a, 0x5a, 0xe7, 0xab, 0x0d, 0xda, - 0x7c, 0xab, 0xf0, 0xd2, 0xf5, 0xca, 0xf6, 0x34, 0x24, 0xc5, 0x34, 0x62, 0x38, 0xf8, 0x66, 0x42, 0x67, 0xf2, 0x3e, - 0x6e, 0x90, 0x4d, 0x9c, 0x21, 0x79, 0xb8, 0x8e, 0x58, 0xba, 0x4a, 0x58, 0x54, 0xb6, 0xf0, 0x74, 0x4a, 0x3b, 0x5c, - 0xdd, 0x15, 0xe1, 0xe6, 0x4c, 0x06, 0x6a, 0xb9, 0x8e, 0xbe, 0x89, 0xc4, 0x20, 0x07, 0x6c, 0xb9, 0x4e, 0x1f, 0x1d, - 0x55, 0xaa, 0x90, 0xe9, 0x76, 0xde, 0xbc, 0x86, 0x05, 0x87, 0x2d, 0x63, 0x29, 0x0d, 0x30, 0xf0, 0x5d, 0x7b, 0x79, - 0x5f, 0x6d, 0x76, 0x2a, 0x3c, 0xe6, 0xbc, 0x4b, 0x69, 0x91, 0x57, 0xa9, 0x7f, 0x6e, 0xe3, 0xb5, 0xf9, 0xd5, 0xdc, - 0x46, 0x17, 0xbc, 0x39, 0x27, 0x3a, 0xad, 0x6f, 0x31, 0x5e, 0x76, 0x88, 0x68, 0xe7, 0x3e, 0x97, 0x79, 0xba, 0x85, - 0xd4, 0x62, 0xcc, 0x3a, 0xc7, 0x4c, 0x4f, 0x4b, 0x8b, 0xf2, 0x11, 0x63, 0x50, 0xc9, 0x9d, 0xf2, 0xcd, 0xc8, 0x53, - 0x8d, 0xc2, 0x10, 0xab, 0x6d, 0xe8, 0x19, 0xb4, 0xd2, 0x4f, 0x34, 0xfb, 0xf6, 0x76, 0xb3, 0x06, 0xa8, 0xc4, 0x62, - 0x1f, 0x99, 0x65, 0xe1, 0xe3, 0x72, 0x07, 0xa1, 0x83, 0x34, 0xb7, 0x93, 0xc3, 0x38, 0x3d, 0x46, 0x2b, 0xf4, 0xbb, - 0xf6, 0x14, 0xb3, 0x80, 0x48, 0x52, 0x2f, 0x90, 0xce, 0x01, 0x77, 0x49, 0xfd, 0x59, 0x9c, 0x19, 0x61, 0x3e, 0x7a, - 0x29, 0xa1, 0xdc, 0x25, 0x94, 0x1b, 0xaf, 0xf3, 0x24, 0x00, 0x3e, 0x89, 0xb6, 0xd7, 0x56, 0x9c, 0xe6, 0x9c, 0xd7, - 0xb6, 0x08, 0xc3, 0xae, 0xeb, 0xc5, 0x48, 0x72, 0x33, 0x7b, 0x61, 0xcc, 0x18, 0x04, 0x22, 0xa8, 0xe8, 0xe6, 0x80, - 0xc9, 0x98, 0x3a, 0xc2, 0x41, 0xe7, 0x4f, 0xb2, 0xa9, 0xa6, 0xb4, 0x98, 0xda, 0xd1, 0xff, 0xce, 0x1e, 0xfc, 0xf0, - 0x68, 0x7a, 0xfe, 0xeb, 0xdb, 0xfc, 0x1a, 0x34, 0x41, 0x0f, 0xe1, 0x7e, 0x77, 0x35, 0x53, 0xa1, 0xa0, 0xb0, 0xb2, - 0x7d, 0x69, 0x01, 0x50, 0x63, 0x2a, 0x4a, 0x57, 0xd7, 0xd6, 0xfd, 0x49, 0xc6, 0xa7, 0x35, 0x6d, 0x7c, 0x1d, 0xa8, - 0xf2, 0xb5, 0xa1, 0x6c, 0xdd, 0xe1, 0xf3, 0x50, 0xcd, 0x78, 0x0a, 0xda, 0x5c, 0x18, 0xfc, 0x0a, 0x39, 0x6e, 0x42, - 0x27, 0x43, 0x95, 0x4d, 0xb3, 0x13, 0x6f, 0x59, 0x25, 0x6b, 0x29, 0x39, 0x91, 0x12, 0xf6, 0xaa, 0xf2, 0x47, 0xdd, - 0x93, 0xd4, 0x96, 0x6a, 0x70, 0x83, 0x13, 0x94, 0x36, 0x3a, 0xfa, 0x2a, 0x6e, 0xe4, 0xa7, 0x1b, 0x40, 0x44, 0x4d, - 0x4f, 0x87, 0xf6, 0x76, 0xfe, 0x79, 0x6f, 0x8c, 0xb0, 0x49, 0x05, 0x3f, 0xca, 0xa8, 0xa9, 0x1a, 0x9e, 0xfb, 0x53, - 0xaf, 0x6c, 0x87, 0x67, 0xba, 0x9b, 0x2c, 0x6a, 0x8b, 0x3e, 0xe3, 0x02, 0xac, 0x9a, 0x68, 0xaa, 0x71, 0xa1, 0x08, - 0x63, 0x1a, 0x6f, 0xce, 0xda, 0x89, 0xb5, 0xea, 0xd5, 0xed, 0xac, 0x97, 0x1e, 0x6d, 0xb3, 0x05, 0x6a, 0xbc, 0x68, - 0xda, 0xf2, 0x2a, 0x7c, 0xb7, 0xa4, 0x9b, 0x95, 0x23, 0xc8, 0xdc, 0x04, 0xe8, 0x67, 0x3f, 0x64, 0x26, 0x7a, 0x07, - 0xe1, 0x4e, 0x2a, 0xd9, 0x93, 0x8a, 0xcd, 0x78, 0xbe, 0xbd, 0x72, 0x7e, 0x5a, 0x92, 0x6d, 0xc4, 0xda, 0x8d, 0x2a, - 0xe3, 0xb1, 0x35, 0xc8, 0xdb, 0x35, 0xd3, 0x59, 0xa2, 0xbf, 0x86, 0x7b, 0xfd, 0xf9, 0x76, 0x0d, 0x4d, 0xb7, 0xd5, - 0xdc, 0x79, 0xe9, 0xe4, 0x28, 0x29, 0x79, 0xf1, 0x03, 0x7b, 0x6c, 0xe1, 0x7c, 0xb0, 0xf1, 0x90, 0x60, 0x99, 0x8a, - 0x55, 0x9f, 0x55, 0xac, 0xf2, 0x2c, 0x11, 0x85, 0xd9, 0xd3, 0x2e, 0x80, 0xe3, 0x0f, 0x2b, 0xb9, 0x8a, 0x1f, 0x66, - 0x5a, 0xb5, 0x7c, 0x58, 0x08, 0xd5, 0xf4, 0x24, 0x10, 0x19, 0x98, 0x35, 0xf2, 0x70, 0x61, 0xea, 0x98, 0x4c, 0x4a, - 0x49, 0x20, 0x57, 0x58, 0x4d, 0xab, 0x6a, 0xd5, 0x87, 0x1f, 0x6e, 0xb8, 0x41, 0x5d, 0xf9, 0x67, 0x33, 0xae, 0xff, - 0xaf, 0x99, 0x89, 0xe5, 0xa0, 0xb1, 0xd0, 0xfa, 0x27, 0x2d, 0xcc, 0xfd, 0x08, 0xdd, 0x35, 0xc3, 0x95, 0xe9, 0x4b, - 0x14, 0xf3, 0x2b, 0x46, 0x66, 0x5b, 0xe4, 0xb5, 0x32, 0xd8, 0xc5, 0xde, 0x98, 0x49, 0x5b, 0x27, 0xc6, 0x34, 0x36, - 0x24, 0x56, 0x67, 0x51, 0x23, 0x43, 0x6a, 0x6b, 0xf3, 0x9d, 0xbe, 0xa2, 0xf9, 0x25, 0x2f, 0xf1, 0x97, 0x69, 0xc8, - 0xc2, 0x7c, 0xab, 0xe8, 0x8d, 0xf4, 0x1a, 0x7a, 0x09, 0xfe, 0x62, 0xe1, 0xde, 0x04, 0x78, 0x8e, 0x22, 0x2a, 0x46, - 0x63, 0xf0, 0x4d, 0x16, 0x07, 0x7a, 0xbf, 0xc2, 0xad, 0x20, 0xc3, 0x94, 0x15, 0xff, 0x5f, 0x03, 0xad, 0xf4, 0x2f, - 0x20, 0xf6, 0x0d, 0xbe, 0xc0, 0xca, 0x5b, 0x41, 0xb9, 0x87, 0xe9, 0xfe, 0x02, 0xdf, 0x0a, 0x71, 0x30, 0x68, 0x44, - 0x4d, 0x58, 0xeb, 0x3d, 0xc5, 0x38, 0x31, 0x3d, 0xf8, 0x67, 0x7a, 0x68, 0x25, 0x08, 0x87, 0x31, 0x86, 0x0e, 0x52, - 0x80, 0x60, 0xa5, 0x7c, 0xf5, 0xf5, 0xa1, 0x55, 0xa1, 0x64, 0xe4, 0xab, 0x66, 0x83, 0x4f, 0xb1, 0x9d, 0xa7, 0xd8, - 0x3e, 0x2b, 0x5f, 0x5a, 0x6b, 0x4d, 0x67, 0xab, 0x46, 0x7a, 0xbe, 0x0a, 0x2e, 0x30, 0xec, 0x60, 0xe0, 0xbc, 0x0a, - 0xbe, 0x22, 0x41, 0x93, 0x40, 0x58, 0x40, 0x83, 0xe7, 0x36, 0x94, 0x93, 0x0f, 0x09, 0x94, 0xba, 0x04, 0xbb, 0xcd, - 0x8f, 0x48, 0x6e, 0x03, 0x21, 0xb5, 0x18, 0x67, 0x0b, 0x7b, 0x70, 0x26, 0xcd, 0xfa, 0xb9, 0xb1, 0x1e, 0x5a, 0x89, - 0x92, 0x36, 0x5d, 0x9e, 0x0d, 0xae, 0x19, 0x29, 0xac, 0x93, 0xff, 0x2f, 0x39, 0x6e, 0xf3, 0xbd, 0x2a, 0x08, 0xae, - 0xc4, 0x49, 0x5f, 0xeb, 0x29, 0x28, 0x77, 0x3a, 0x74, 0xe0, 0x8e, 0x20, 0x4b, 0xd9, 0xf3, 0xcc, 0xd3, 0xe7, 0x76, - 0x81, 0x9d, 0x98, 0xed, 0x9e, 0x95, 0xce, 0x70, 0xc2, 0xf1, 0xfb, 0x05, 0xef, 0x2e, 0xfd, 0x39, 0xa4, 0xdc, 0xdf, - 0x57, 0x96, 0x8c, 0x77, 0xc7, 0xdd, 0xb3, 0x3f, 0xc7, 0x1b, 0xb7, 0xfc, 0x29, 0xf7, 0xc3, 0x6d, 0x24, 0xdd, 0xf0, - 0xaa, 0xf9, 0x17, 0x8f, 0x6c, 0xe5, 0x56, 0x42, 0xd0, 0x3a, 0x9d, 0xe3, 0x9b, 0xaf, 0x41, 0xa7, 0x12, 0xb6, 0xf8, - 0xf1, 0x5d, 0xf2, 0x5b, 0x63, 0x2f, 0x46, 0x61, 0x70, 0x68, 0xa6, 0x76, 0x95, 0x9c, 0xb7, 0xe0, 0xb6, 0x4c, 0xed, - 0x56, 0x81, 0x34, 0x7a, 0xe7, 0xb9, 0xfa, 0x3a, 0xfd, 0x54, 0x35, 0xff, 0x31, 0x73, 0x13, 0xf6, 0xb1, 0x42, 0x91, - 0xf8, 0x67, 0x6a, 0x74, 0x83, 0x91, 0xca, 0x03, 0x75, 0x81, 0x55, 0x4b, 0x82, 0xca, 0xdb, 0x11, 0x3f, 0xe5, 0xd7, - 0xdc, 0xe5, 0x48, 0x6c, 0x84, 0xfd, 0x69, 0x6c, 0x3e, 0x53, 0x9f, 0xed, 0x6c, 0x99, 0x65, 0xb7, 0x8f, 0x99, 0x87, - 0x47, 0xf9, 0x5b, 0xaa, 0x5b, 0xe4, 0x7b, 0xb3, 0x2c, 0x2f, 0xca, 0xec, 0xd4, 0x3e, 0x87, 0x4f, 0xb4, 0xd1, 0x24, - 0x7f, 0xe3, 0x71, 0x2f, 0xb1, 0x21, 0xd9, 0x34, 0x2f, 0x33, 0x87, 0xd8, 0xc3, 0xf3, 0x1b, 0x3f, 0x65, 0x53, 0x99, - 0x28, 0x38, 0x6d, 0x87, 0xa6, 0x03, 0xf7, 0x0d, 0xd4, 0x41, 0x15, 0x20, 0xa7, 0x2b, 0xb0, 0xd1, 0x80, 0x59, 0x6d, - 0xd4, 0x97, 0xa5, 0xb2, 0x8a, 0xb3, 0xae, 0xdb, 0x75, 0xfa, 0xc5, 0x46, 0x12, 0xb8, 0xb1, 0x47, 0x91, 0x3c, 0x9d, - 0x95, 0xae, 0xf1, 0x57, 0x79, 0xc9, 0x1c, 0xa5, 0x0f, 0xf8, 0xfc, 0x5b, 0xf0, 0x48, 0xfe, 0x52, 0x74, 0x8d, 0xab, - 0xdc, 0x66, 0x34, 0x6a, 0xcc, 0xc9, 0x78, 0x43, 0xd8, 0x58, 0x14, 0x55, 0x31, 0x35, 0xa7, 0xfc, 0x9c, 0x19, 0x8d, - 0xe4, 0x05, 0x09, 0xf2, 0xb9, 0xb7, 0xfb, 0x99, 0x5c, 0xf0, 0x2b, 0xd7, 0xa1, 0x57, 0x56, 0xa3, 0xe2, 0x4b, 0xe7, - 0xb8, 0xb7, 0xee, 0x50, 0x80, 0x0c, 0xd8, 0x43, 0x86, 0x9c, 0xf2, 0x56, 0xd3, 0xbe, 0x5e, 0x7e, 0xff, 0x82, 0x91, - 0x14, 0xab, 0xb2, 0xef, 0xc6, 0x26, 0x4c, 0x22, 0x3b, 0xc2, 0x5e, 0x35, 0xb2, 0x9b, 0x63, 0x11, 0x21, 0x21, 0x19, - 0xf7, 0x4c, 0xc8, 0xe8, 0xad, 0x5e, 0x26, 0x80, 0x73, 0xe2, 0x49, 0xe1, 0x4c, 0x4e, 0x9c, 0xc8, 0xb2, 0xb4, 0x15, - 0x5b, 0x62, 0xe1, 0x08, 0x5f, 0x6b, 0xca, 0x6c, 0xdb, 0x18, 0x2b, 0x68, 0x70, 0xcc, 0x01, 0xa2, 0x33, 0x9f, 0xf1, - 0x86, 0x09, 0xe7, 0xfc, 0xa9, 0xf2, 0xbe, 0x59, 0xa6, 0x41, 0x5f, 0x15, 0x2c, 0x6c, 0xb7, 0x9e, 0x20, 0xca, 0x34, - 0x03, 0x03, 0xf2, 0x6d, 0x85, 0x6a, 0xfe, 0x95, 0x97, 0x28, 0xf8, 0xee, 0x32, 0xf2, 0xf3, 0xe7, 0x70, 0x1b, 0x21, - 0x1a, 0x04, 0x8d, 0xed, 0x85, 0x51, 0xb2, 0xb3, 0xac, 0x86, 0x5c, 0x84, 0x93, 0xe0, 0x83, 0xdd, 0x53, 0xb8, 0x3e, - 0x87, 0xa4, 0x97, 0xe0, 0x29, 0x30, 0x4f, 0x68, 0xa4, 0xb4, 0xdf, 0xf7, 0x2e, 0x08, 0x84, 0xe6, 0x2d, 0x8f, 0x70, - 0x40, 0x32, 0x62, 0x36, 0x16, 0x1e, 0x37, 0x0b, 0x97, 0x56, 0xc1, 0xa9, 0xcb, 0x6a, 0xd4, 0x15, 0xc9, 0x25, 0x85, - 0x34, 0x63, 0xf0, 0x60, 0x74, 0x24, 0x24, 0x96, 0x85, 0x60, 0xcc, 0x86, 0xbf, 0xf5, 0x02, 0xeb, 0xa7, 0x39, 0x00, - 0xf6, 0x04, 0xf1, 0xd8, 0x84, 0xe9, 0x0d, 0x44, 0x78, 0xb6, 0x2d, 0x0f, 0x98, 0xf7, 0x05, 0x1a, 0xcf, 0xf9, 0x98, - 0x52, 0xe6, 0x7c, 0x01, 0x2e, 0x33, 0xf1, 0x62, 0x20, 0x87, 0x80, 0x7b, 0x88, 0x87, 0xfc, 0xe0, 0xb1, 0x97, 0x60, - 0x67, 0x64, 0xa5, 0xbb, 0x5d, 0xbb, 0x71, 0xcc, 0x2d, 0x45, 0x0e, 0xbc, 0xd8, 0x3f, 0x38, 0xac, 0x6b, 0xb1, 0x4a, - 0xbf, 0x69, 0xa0, 0xd2, 0xbf, 0xfa, 0xf7, 0xcd, 0xe7, 0xc8, 0xb7, 0xcf, 0xb5, 0xd4, 0xa2, 0xf8, 0x81, 0x77, 0x56, - 0x93, 0x82, 0x76, 0xff, 0xab, 0x26, 0x23, 0x5f, 0x52, 0x5a, 0x2d, 0x8b, 0x4f, 0xb5, 0x8b, 0x9e, 0xa2, 0x5a, 0x36, - 0x79, 0x6e, 0x0f, 0x49, 0x8e, 0x5e, 0x6b, 0x19, 0x56, 0xb5, 0x3d, 0xea, 0x83, 0xbd, 0xd7, 0x7b, 0x41, 0x1e, 0x52, - 0x0f, 0x89, 0xaa, 0x37, 0x5e, 0x40, 0xc5, 0x7f, 0xf5, 0x95, 0xea, 0x99, 0x5f, 0xd0, 0x90, 0x37, 0xca, 0x31, 0xbe, - 0x6c, 0x9c, 0xb6, 0xae, 0x1e, 0xc5, 0x14, 0xac, 0x14, 0x65, 0x65, 0x88, 0x1b, 0x59, 0xa1, 0x6b, 0x44, 0x5a, 0x03, - 0x7f, 0x63, 0xa3, 0x14, 0x5b, 0x4d, 0xb5, 0x91, 0xf4, 0xdf, 0x99, 0xfe, 0x0f, 0x89, 0xec, 0xff, 0x14, 0xc7, 0xfe, - 0x8f, 0x73, 0x84, 0x16, 0xf6, 0x8d, 0x65, 0x44, 0xc0, 0x15, 0x4d, 0x8a, 0xe3, 0x2b, 0x83, 0xb3, 0x44, 0x50, 0xa3, - 0x89, 0xc8, 0x6e, 0x3c, 0x51, 0x99, 0x11, 0x79, 0x6e, 0x15, 0x3c, 0x4b, 0x7b, 0x74, 0x4f, 0xee, 0xef, 0x9c, 0x40, - 0x94, 0x63, 0x52, 0x6d, 0x6f, 0xc6, 0x9c, 0xc3, 0x10, 0x17, 0x93, 0x8b, 0x0a, 0x60, 0x04, 0x37, 0x84, 0x6c, 0x2c, - 0x81, 0x8e, 0x92, 0xec, 0x47, 0x23, 0xe6, 0x00, 0x34, 0xc0, 0x7e, 0xd9, 0x20, 0xb0, 0x1c, 0xcc, 0x30, 0x43, 0x30, - 0x3a, 0xaf, 0x0e, 0xf0, 0x39, 0x66, 0x7b, 0xc7, 0x26, 0xf8, 0xb0, 0x32, 0x07, 0x3b, 0xd0, 0x20, 0x1c, 0x30, 0x8f, - 0x66, 0x79, 0x26, 0x28, 0x9a, 0xe0, 0x23, 0xea, 0x2c, 0xfb, 0x5c, 0xfc, 0x92, 0xa5, 0xad, 0xd7, 0x25, 0x87, 0x2e, - 0x16, 0x9f, 0x66, 0xd6, 0x50, 0xfa, 0x13, 0xf8, 0x5f, 0x83, 0x3b, 0xb0, 0xc7, 0x1d, 0x24, 0xc2, 0x56, 0x14, 0x4e, - 0xa5, 0xea, 0x9f, 0x5d, 0x06, 0x84, 0x92, 0x9e, 0x66, 0x48, 0xb9, 0x40, 0xeb, 0x1a, 0xe2, 0x1a, 0x6c, 0x0c, 0x86, - 0xed, 0x8c, 0x67, 0x9a, 0xdb, 0xd6, 0x33, 0xe3, 0xa4, 0x5e, 0xa3, 0x4d, 0x46, 0x87, 0x64, 0x5e, 0x44, 0x99, 0xbb, - 0xc8, 0x2f, 0x47, 0x4a, 0xad, 0xb9, 0x51, 0xac, 0x31, 0xe5, 0xa5, 0xd3, 0xec, 0xc3, 0x39, 0x82, 0xd3, 0x45, 0x50, - 0xf5, 0xc3, 0x1e, 0x9c, 0xb5, 0x31, 0xf8, 0x91, 0x62, 0x51, 0x20, 0xbd, 0x5d, 0x11, 0x52, 0xf3, 0x93, 0x1d, 0xab, - 0x59, 0x4c, 0x4b, 0x6f, 0x17, 0x1e, 0xce, 0xb7, 0xc5, 0x14, 0x64, 0x1c, 0x08, 0xf9, 0x23, 0x18, 0xd8, 0x14, 0x77, - 0x66, 0xa2, 0x2d, 0x82, 0xe0, 0x04, 0xa1, 0x8c, 0x48, 0x87, 0x6f, 0x45, 0x29, 0x2a, 0x02, 0x91, 0xfb, 0xd1, 0x7b, - 0x4c, 0xcb, 0x6a, 0x28, 0xad, 0x81, 0x3d, 0x2a, 0x2a, 0x06, 0xca, 0xeb, 0x4c, 0x68, 0x38, 0x45, 0x8b, 0x90, 0xa9, - 0x78, 0xb3, 0x00, 0x2b, 0x37, 0xf8, 0xa4, 0xc5, 0x4a, 0xcf, 0x58, 0xf6, 0x07, 0xf9, 0x4a, 0x59, 0xd1, 0x30, 0x81, - 0xee, 0x31, 0x55, 0xdb, 0x7f, 0xe3, 0xa2, 0x8d, 0xb9, 0x01, 0x7e, 0x06, 0x8c, 0xe2, 0x7a, 0x85, 0x09, 0xab, 0x15, - 0xdc, 0x01, 0x2c, 0xbd, 0x71, 0x6f, 0xd5, 0x4c, 0xc9, 0xa7, 0x5c, 0x49, 0x29, 0x13, 0xac, 0x77, 0x2a, 0x4b, 0x70, - 0xf2, 0x21, 0x04, 0x43, 0x7c, 0xf7, 0x65, 0xe6, 0xd7, 0x6b, 0x9e, 0xda, 0x84, 0x27, 0xd1, 0x9e, 0xf6, 0xd1, 0x37, - 0x6e, 0xc3, 0xab, 0x16, 0x43, 0x5c, 0x9d, 0x65, 0xe7, 0xe4, 0x4b, 0x64, 0x4d, 0xe5, 0x80, 0x1f, 0xb1, 0x1a, 0xea, - 0x12, 0xf8, 0x8c, 0x98, 0x37, 0x0c, 0xe6, 0x6f, 0x74, 0x8a, 0xc5, 0xbc, 0xa9, 0x22, 0xc5, 0xe1, 0xfe, 0x08, 0x8b, - 0x8c, 0x4b, 0x94, 0x23, 0x7d, 0xc8, 0xbf, 0x83, 0xc1, 0xa8, 0xd2, 0xcd, 0x8a, 0xfa, 0x9a, 0xf1, 0xbc, 0xed, 0x63, - 0x6b, 0x12, 0xb6, 0x00, 0x6b, 0x91, 0xf1, 0x04, 0xe8, 0xbe, 0x56, 0x6f, 0x0b, 0xd9, 0x9a, 0x68, 0x89, 0x86, 0xa6, - 0x50, 0xd4, 0x0d, 0x04, 0x13, 0x93, 0xd2, 0xee, 0xc0, 0x48, 0x82, 0xf1, 0xac, 0xa9, 0xfc, 0x82, 0xfc, 0xb8, 0x5e, - 0xc4, 0xd6, 0x98, 0x0b, 0x1d, 0x33, 0xa2, 0x49, 0x4d, 0x9a, 0x09, 0x88, 0x05, 0xf0, 0x72, 0x16, 0x3d, 0xac, 0xf3, - 0x84, 0x53, 0x73, 0xef, 0xd4, 0x11, 0x33, 0x30, 0x40, 0xa7, 0x10, 0xa9, 0x14, 0x3f, 0xbc, 0x0f, 0x52, 0x0a, 0x04, - 0xa0, 0xec, 0x98, 0x0d, 0x2d, 0x29, 0xa8, 0x4f, 0x6b, 0x97, 0x99, 0x5a, 0xdb, 0x1a, 0xfe, 0x94, 0xd9, 0xac, 0x45, - 0x55, 0xcc, 0x1f, 0x2e, 0xf3, 0x8b, 0x98, 0x71, 0xd1, 0xf0, 0x09, 0x43, 0xd5, 0x61, 0x05, 0x7a, 0x8f, 0x9b, 0xbc, - 0x5e, 0x99, 0xf0, 0x7e, 0x5e, 0xef, 0x9b, 0xfb, 0x22, 0x16, 0x5e, 0x14, 0x38, 0xf7, 0xa5, 0x82, 0x97, 0x86, 0x13, - 0xb8, 0xc4, 0x43, 0x99, 0xf9, 0x54, 0xb6, 0x95, 0x99, 0xa2, 0x04, 0x25, 0xb5, 0x88, 0x5c, 0x92, 0x0b, 0x82, 0x94, - 0x8a, 0x97, 0x81, 0x50, 0xdb, 0xb7, 0x0b, 0x90, 0xbd, 0xaf, 0x2d, 0xe3, 0xb5, 0x64, 0xe7, 0x22, 0x94, 0xcd, 0x66, - 0x5c, 0xdb, 0xcb, 0x69, 0xb7, 0xbf, 0x95, 0x41, 0x35, 0x00, 0x25, 0xb3, 0xe1, 0x32, 0xe2, 0x9b, 0x9b, 0x1d, 0x0a, - 0x08, 0xed, 0xfc, 0x6d, 0x57, 0xca, 0x2c, 0xcc, 0x41, 0xd7, 0xec, 0xa8, 0xf8, 0x17, 0xe5, 0xdd, 0x59, 0xed, 0x66, - 0x7c, 0xc9, 0x3b, 0x18, 0xdf, 0x14, 0xca, 0x01, 0x2e, 0x79, 0x21, 0xeb, 0x07, 0x6e, 0x94, 0xc8, 0x12, 0xc2, 0x32, - 0xbb, 0xa8, 0x15, 0x95, 0xc9, 0x98, 0x36, 0xbd, 0xad, 0xc8, 0x66, 0x23, 0x63, 0x5d, 0x96, 0x68, 0x79, 0x6e, 0xc5, - 0xc9, 0xab, 0x87, 0x3f, 0x52, 0xe5, 0xf4, 0x7d, 0x89, 0xfa, 0xd4, 0x07, 0x77, 0x6f, 0x2b, 0xb3, 0x84, 0x4f, 0x4d, - 0x13, 0x45, 0x70, 0x07, 0xcc, 0x55, 0xb6, 0x22, 0x6a, 0x61, 0x48, 0xfd, 0x17, 0x5e, 0xfa, 0xc6, 0x13, 0xaa, 0xb6, - 0x73, 0xd5, 0xab, 0x39, 0x24, 0x66, 0x8c, 0xe7, 0x68, 0xf5, 0x01, 0xb2, 0x81, 0xae, 0xcd, 0xbe, 0x02, 0x02, 0xaf, - 0x99, 0xfc, 0xf2, 0xdb, 0x61, 0xcc, 0xd9, 0x6d, 0x5e, 0x68, 0x64, 0x2b, 0x67, 0xd9, 0xc1, 0x8d, 0xb4, 0x55, 0x7b, - 0x3c, 0xd3, 0x4d, 0x5c, 0x69, 0x99, 0x8c, 0x31, 0xbf, 0xad, 0xea, 0xab, 0x05, 0xdf, 0x99, 0xdb, 0xce, 0x4a, 0xa6, - 0x91, 0xab, 0xd5, 0x57, 0x78, 0x5e, 0x34, 0x41, 0x27, 0x2e, 0x31, 0x53, 0xab, 0xea, 0xb7, 0xaa, 0x1c, 0x15, 0x15, - 0xcb, 0xb9, 0xd5, 0xa6, 0xca, 0x6b, 0xb7, 0xa8, 0xdf, 0x9f, 0x8d, 0x09, 0x41, 0x65, 0x8a, 0xcc, 0x58, 0xa2, 0x8b, - 0x78, 0xa1, 0x9f, 0xd3, 0xba, 0xa8, 0xe8, 0xf1, 0xca, 0xaa, 0x86, 0x18, 0x02, 0xb7, 0xda, 0xc9, 0x20, 0x65, 0x16, - 0x89, 0x79, 0x16, 0x31, 0xdb, 0xeb, 0xd1, 0xb6, 0x39, 0x1b, 0x06, 0x6e, 0xd2, 0x39, 0x81, 0x73, 0x12, 0xfe, 0xa6, - 0x32, 0xdb, 0xb0, 0xa2, 0x6e, 0x79, 0x8d, 0xae, 0xa2, 0x6e, 0xcd, 0x79, 0x3d, 0x55, 0xc5, 0x0f, 0xd4, 0x71, 0xb5, - 0x5e, 0xdd, 0x88, 0x0e, 0x41, 0x81, 0x0b, 0xeb, 0xe7, 0x00, 0xfe, 0x6f, 0xab, 0x18, 0x1e, 0xec, 0xaa, 0x0f, 0xd5, - 0xaa, 0x69, 0x63, 0xdf, 0x38, 0x20, 0xb0, 0x58, 0x15, 0x5c, 0xa0, 0x33, 0xac, 0x50, 0x2f, 0x33, 0x6d, 0x30, 0x4c, - 0x8c, 0x53, 0x4b, 0xcf, 0xa5, 0x13, 0x11, 0x7d, 0x1a, 0x5e, 0xcc, 0x34, 0x77, 0x68, 0xb3, 0x55, 0x6e, 0x11, 0x6a, - 0x47, 0xb0, 0x08, 0x26, 0xd3, 0x65, 0x1c, 0x8c, 0xfc, 0x36, 0xb4, 0xd1, 0x00, 0x13, 0x97, 0x36, 0x65, 0x21, 0xc4, - 0xca, 0x02, 0xaa, 0xf7, 0x5d, 0xb0, 0x40, 0x30, 0xb3, 0x27, 0xa4, 0x5f, 0x41, 0x54, 0xf9, 0x69, 0x7c, 0x3e, 0xa9, - 0xa4, 0xb6, 0x9d, 0xaf, 0x73, 0x0d, 0x1c, 0xaa, 0xa6, 0xa2, 0x2a, 0x57, 0xb6, 0x21, 0x72, 0x18, 0xe4, 0x3a, 0x52, - 0x42, 0x2d, 0x91, 0x2f, 0x33, 0x4a, 0x27, 0x13, 0x46, 0xcb, 0xb5, 0x29, 0x56, 0x81, 0xb4, 0xd6, 0x85, 0x77, 0xf9, - 0x1f, 0x86, 0xb2, 0x0d, 0x7a, 0x21, 0x4a, 0xe4, 0xa2, 0x67, 0xf1, 0xe7, 0x6b, 0x9d, 0x03, 0xa4, 0xfa, 0x5f, 0xad, - 0x5c, 0x2a, 0x63, 0x03, 0xa7, 0xd8, 0x95, 0x91, 0xf8, 0x20, 0xad, 0x25, 0xfa, 0x3e, 0xd7, 0x31, 0xea, 0xba, 0x07, - 0x8d, 0xeb, 0x55, 0xb1, 0x18, 0xfa, 0xc5, 0x7b, 0x12, 0xdd, 0xc2, 0x97, 0x05, 0x25, 0x1d, 0xf4, 0xd3, 0x5d, 0xdd, - 0x5f, 0xa7, 0x84, 0x44, 0x65, 0x31, 0x31, 0x84, 0x55, 0x7e, 0x66, 0x38, 0x6c, 0x15, 0xd1, 0xac, 0xb6, 0xeb, 0xec, - 0xfb, 0x03, 0x05, 0x13, 0x88, 0xa0, 0xb0, 0xf8, 0xff, 0x73, 0x1f, 0x14, 0x68, 0xe0, 0xa4, 0xce, 0x8d, 0x4a, 0x4e, - 0xfb, 0xb9, 0x76, 0x7d, 0xa8, 0xbc, 0x04, 0x40, 0x56, 0x8f, 0x37, 0xb2, 0xbe, 0xe5, 0x77, 0x2b, 0x8b, 0x17, 0x30, - 0x96, 0x3f, 0x85, 0x4d, 0x56, 0x23, 0xda, 0x8c, 0xae, 0xd5, 0x6c, 0x94, 0x4f, 0x99, 0x18, 0x8e, 0x36, 0xd0, 0xf5, - 0xbb, 0x6d, 0x6f, 0x50, 0x5d, 0x58, 0x4b, 0xec, 0x3e, 0x60, 0x5d, 0xa9, 0x80, 0xfd, 0xac, 0xa9, 0xa5, 0x3b, 0x15, - 0xfc, 0xfc, 0x56, 0x4f, 0xa5, 0x59, 0xd8, 0x3a, 0x02, 0x7a, 0xf6, 0xd5, 0x15, 0x07, 0xc0, 0x07, 0x74, 0x0d, 0x0b, - 0x3d, 0x76, 0x2c, 0xf5, 0x99, 0x45, 0x94, 0x7d, 0xe6, 0x1e, 0x5f, 0xdf, 0x0c, 0x85, 0x87, 0x9d, 0xdb, 0x6f, 0xbd, - 0x2a, 0xc6, 0xf1, 0xc2, 0xba, 0xba, 0xc8, 0x05, 0xc5, 0x13, 0x92, 0x9c, 0x5f, 0xce, 0x61, 0xa8, 0x5a, 0x49, 0xc4, - 0x5c, 0x85, 0x04, 0x41, 0xed, 0xbb, 0x22, 0xe0, 0x31, 0x39, 0x5e, 0x81, 0xbb, 0x07, 0xa6, 0xa8, 0x9b, 0x1d, 0x42, - 0xe8, 0x96, 0xb4, 0xba, 0x5b, 0x91, 0x00, 0xd0, 0x2e, 0xd8, 0xfe, 0xc6, 0x79, 0x89, 0x2d, 0x68, 0x19, 0xad, 0x17, - 0x21, 0x0c, 0x44, 0x22, 0x85, 0x31, 0x72, 0x7a, 0x38, 0x5b, 0xd7, 0xa0, 0x18, 0xfa, 0x53, 0x17, 0x38, 0xf5, 0xe3, - 0xd9, 0xb6, 0x4b, 0x85, 0x64, 0x22, 0xe8, 0xd0, 0x14, 0x58, 0x96, 0x9d, 0x38, 0xed, 0x91, 0xe4, 0xfd, 0x7d, 0x9e, - 0x92, 0x15, 0x15, 0x3f, 0x94, 0xc3, 0xcf, 0x27, 0x24, 0x38, 0xd4, 0x13, 0x30, 0x83, 0x0e, 0x78, 0xa6, 0xf7, 0xa9, - 0x13, 0x23, 0x95, 0xf5, 0x0e, 0x38, 0x8a, 0x88, 0x32, 0xd3, 0x25, 0xbb, 0xc7, 0xed, 0xf1, 0x14, 0x70, 0x23, 0x63, - 0xda, 0x65, 0x8e, 0x61, 0x26, 0x30, 0x8e, 0xf9, 0x6a, 0x7c, 0x3e, 0xa2, 0x1f, 0xc7, 0x7d, 0x44, 0xc9, 0x45, 0xa5, - 0x86, 0xc2, 0x36, 0x66, 0x8b, 0x5a, 0xf4, 0xd4, 0xbe, 0x91, 0x48, 0x47, 0xaf, 0x60, 0x2c, 0x17, 0x98, 0x06, 0x2b, - 0x9d, 0xf3, 0x8a, 0x82, 0x15, 0x4d, 0x80, 0xb8, 0x0a, 0xe3, 0x94, 0x51, 0x6b, 0xcc, 0x52, 0x18, 0x5c, 0x51, 0x93, - 0x11, 0xc1, 0xaf, 0x26, 0xf4, 0xec, 0x63, 0x31, 0xdc, 0x93, 0x97, 0xc3, 0xa1, 0x1e, 0xad, 0xa7, 0x75, 0xf7, 0x06, - 0x16, 0x63, 0xc1, 0xb2, 0xc0, 0x9c, 0x9d, 0x0e, 0x3a, 0xaf, 0xd8, 0xd6, 0xd4, 0x3a, 0x5d, 0xad, 0x1f, 0x5a, 0xd9, - 0x28, 0x96, 0xd3, 0x24, 0x92, 0x38, 0x6f, 0xa6, 0x51, 0x8c, 0x3f, 0x34, 0x5c, 0xea, 0x86, 0xfa, 0xc4, 0x6f, 0xcd, - 0x5f, 0x4b, 0xa5, 0xbf, 0xce, 0x3f, 0x8a, 0x85, 0x1d, 0x9b, 0xd8, 0x6f, 0xb4, 0x60, 0x65, 0xd1, 0xd8, 0x40, 0xa8, - 0xea, 0x0b, 0x9e, 0x25, 0x2b, 0x95, 0x27, 0xdf, 0x8d, 0xd8, 0xd2, 0x02, 0x3f, 0x8f, 0xf2, 0x6a, 0xea, 0xcd, 0x88, - 0x41, 0xb5, 0x7c, 0x8a, 0x6a, 0x77, 0x72, 0x20, 0x5c, 0x26, 0x37, 0x56, 0x95, 0x07, 0x88, 0x9e, 0x5f, 0x96, 0x1e, - 0x09, 0x73, 0xa9, 0x98, 0x92, 0x06, 0xcf, 0x89, 0xa0, 0xb7, 0x30, 0x85, 0x18, 0x1e, 0x49, 0xdf, 0xa0, 0xf2, 0xee, - 0x8f, 0xeb, 0x7d, 0xaf, 0x0b, 0xdc, 0x19, 0x3b, 0xdf, 0x74, 0x2f, 0x9d, 0x19, 0x34, 0x7a, 0xfd, 0x73, 0xa8, 0x5a, - 0x84, 0xc1, 0x4e, 0xd3, 0x85, 0xa0, 0x09, 0xea, 0x5f, 0x8c, 0x06, 0xd6, 0x32, 0x5d, 0xeb, 0xed, 0x20, 0x53, 0x21, - 0x31, 0xfe, 0x5f, 0x64, 0xbc, 0x0c, 0x98, 0x9c, 0x8c, 0xe2, 0x16, 0x3c, 0x00, 0x37, 0xd3, 0x90, 0x0b, 0x94, 0xd9, - 0xc3, 0x13, 0xa8, 0xc9, 0x58, 0x84, 0x67, 0x39, 0xe6, 0x3a, 0x75, 0x60, 0x3d, 0xb2, 0x79, 0x58, 0xa3, 0x70, 0xb5, - 0x9c, 0x4d, 0x4e, 0xc9, 0x8e, 0x59, 0x5d, 0xed, 0x63, 0x77, 0xc6, 0x25, 0x9e, 0x39, 0x1f, 0xf2, 0x6d, 0xec, 0x71, - 0xc0, 0x1c, 0x87, 0x07, 0x0e, 0x33, 0xe7, 0x21, 0x82, 0x5c, 0x37, 0xc0, 0x16, 0x60, 0xb2, 0x93, 0xb5, 0x6b, 0x94, - 0xb0, 0x78, 0x73, 0x03, 0x20, 0x8f, 0x64, 0x12, 0x42, 0x2e, 0x1b, 0x7e, 0x96, 0x5a, 0xaa, 0x8f, 0x80, 0x8f, 0xd5, - 0x87, 0x9a, 0x06, 0x42, 0xdc, 0x36, 0x42, 0x1e, 0x30, 0x26, 0xae, 0xcc, 0x2f, 0xaa, 0x09, 0x2e, 0xf8, 0x2f, 0x7b, - 0x1d, 0x7f, 0xdd, 0xac, 0xfb, 0x9e, 0x21, 0x22, 0x1d, 0xac, 0x45, 0x64, 0xbd, 0x22, 0x85, 0xff, 0x86, 0xdf, 0x03, - 0x45, 0x09, 0xc5, 0x52, 0xb1, 0xfc, 0x88, 0xea, 0x1e, 0xe3, 0x1e, 0xf2, 0xde, 0x4e, 0x7e, 0x1f, 0x09, 0x83, 0x1e, - 0x50, 0x63, 0x94, 0xa4, 0x38, 0x52, 0xab, 0x9e, 0x7b, 0x14, 0x2c, 0x85, 0xa5, 0x86, 0xe7, 0x88, 0xd2, 0xd5, 0xf7, - 0x0a, 0xb5, 0xf0, 0x1f, 0x2d, 0x6d, 0x9e, 0x86, 0x7d, 0x44, 0xf2, 0x4d, 0x46, 0xd7, 0xc8, 0x42, 0x25, 0x51, 0x78, - 0x23, 0x04, 0x9e, 0x73, 0xc6, 0x53, 0x7d, 0x84, 0x98, 0x07, 0xa2, 0xc9, 0xc8, 0xf5, 0x80, 0xde, 0xd1, 0xe6, 0xe8, - 0x45, 0x72, 0x4c, 0xdf, 0xb4, 0x0f, 0x83, 0xb0, 0x1d, 0x5b, 0x5c, 0x6a, 0x4c, 0x44, 0x6b, 0x5a, 0x75, 0xd9, 0x23, - 0x52, 0xef, 0x3c, 0x15, 0xa3, 0x04, 0x25, 0x72, 0x13, 0x15, 0xdd, 0x39, 0x4e, 0xed, 0xa2, 0xe8, 0xf6, 0x19, 0x4b, - 0xb8, 0x18, 0x55, 0x7c, 0x5f, 0x06, 0x9f, 0x44, 0x52, 0x34, 0x60, 0xd8, 0x80, 0xaf, 0xf7, 0xff, 0x60, 0xe8, 0x66, - 0x20, 0x97, 0xda, 0x30, 0x65, 0xf3, 0xe9, 0x9c, 0x66, 0x5b, 0xc3, 0x7d, 0x83, 0xd6, 0x97, 0x50, 0xcf, 0xb9, 0xcf, - 0x88, 0xdf, 0x4a, 0xcd, 0x2d, 0x26, 0xab, 0x36, 0x1b, 0x59, 0x4c, 0xd6, 0x61, 0xd5, 0x3d, 0x46, 0xa6, 0x10, 0xff, - 0x42, 0x93, 0x5c, 0x10, 0x1e, 0x55, 0xc9, 0x82, 0x7f, 0xd0, 0xcc, 0x60, 0xc3, 0x79, 0x9d, 0xfe, 0x8d, 0x32, 0x80, - 0xf7, 0x3b, 0xcb, 0x5a, 0x41, 0x35, 0x25, 0xb5, 0xe3, 0xba, 0x4b, 0xc7, 0x2f, 0x5d, 0xa0, 0x07, 0x99, 0x89, 0x67, - 0x47, 0x25, 0x21, 0x66, 0x81, 0x75, 0x2f, 0x91, 0xfa, 0xe6, 0x27, 0xe9, 0x91, 0x36, 0x78, 0xce, 0x42, 0xb4, 0xa0, - 0x17, 0x15, 0xfb, 0x7b, 0xa5, 0x34, 0xbd, 0x57, 0x76, 0x06, 0xaf, 0x8d, 0x99, 0xca, 0x41, 0xb0, 0xee, 0x12, 0x7a, - 0xb9, 0x3c, 0xc9, 0x8f, 0x6d, 0xe6, 0x92, 0xe6, 0x23, 0xe9, 0xef, 0xaa, 0x14, 0xeb, 0xc7, 0x80, 0xd6, 0xbf, 0xa6, - 0xcc, 0x92, 0x14, 0x68, 0x30, 0x58, 0x8d, 0x15, 0xdb, 0x04, 0x1c, 0x52, 0x68, 0x22, 0xa2, 0x89, 0x76, 0xdc, 0xee, - 0x68, 0x7c, 0x86, 0xd4, 0x47, 0xb8, 0x40, 0x32, 0xe0, 0x91, 0x43, 0x4c, 0x56, 0xc5, 0x2e, 0xc0, 0x17, 0xb8, 0x7d, - 0x3c, 0x83, 0x7e, 0xd8, 0x6e, 0xdd, 0x20, 0xe5, 0xa6, 0x1c, 0x17, 0x01, 0x6b, 0x08, 0x80, 0xa7, 0x5c, 0x13, 0xad, - 0x06, 0x52, 0x7d, 0x69, 0x04, 0xec, 0xbb, 0x83, 0xfa, 0x38, 0x9a, 0xa6, 0x8c, 0x65, 0xd3, 0xc4, 0x4b, 0xc9, 0x23, - 0xc4, 0x88, 0x7d, 0x85, 0x53, 0x8e, 0xc0, 0xbc, 0xc3, 0xef, 0xac, 0xd7, 0x42, 0x7a, 0x9b, 0xe8, 0x73, 0x93, 0x81, - 0x87, 0xe1, 0xc7, 0xd8, 0x7e, 0xd1, 0xd3, 0xce, 0xd6, 0x9c, 0xbf, 0x0a, 0x48, 0x46, 0x47, 0xe1, 0x5f, 0x85, 0x67, - 0xa5, 0x6d, 0x12, 0x42, 0xfc, 0x03, 0xd1, 0x75, 0x86, 0x33, 0x48, 0xb0, 0x48, 0x5f, 0x2e, 0x6a, 0x17, 0x39, 0x05, - 0x95, 0xf6, 0x99, 0xd5, 0xca, 0xb2, 0x7c, 0x7b, 0xfb, 0x8f, 0x73, 0x6b, 0x53, 0x8d, 0x0b, 0x1e, 0x72, 0x8d, 0xcb, - 0x56, 0x36, 0xfc, 0xa2, 0x8d, 0xf7, 0x96, 0x6c, 0x36, 0x72, 0xd5, 0x57, 0x2e, 0xe1, 0x9f, 0xf8, 0x51, 0x46, 0xb2, - 0xf1, 0x02, 0xac, 0x45, 0x6a, 0x79, 0xe4, 0xea, 0xa9, 0xed, 0x7b, 0xbd, 0x50, 0xac, 0x0c, 0xee, 0xfc, 0xe2, 0x38, - 0x41, 0x92, 0xca, 0x43, 0xfe, 0x9c, 0xaf, 0xe3, 0x1c, 0x3b, 0xab, 0xe9, 0x68, 0x45, 0xef, 0x48, 0x5d, 0x0e, 0x16, - 0x5b, 0x8e, 0x92, 0xf3, 0xc1, 0xb9, 0x6b, 0x86, 0x1e, 0x1c, 0x45, 0x3d, 0x9f, 0x29, 0x89, 0x05, 0x5c, 0x9a, 0xbb, - 0xa7, 0x08, 0x7a, 0x84, 0x48, 0x8c, 0xd0, 0xbb, 0xa0, 0x21, 0x18, 0xb6, 0x39, 0xdf, 0x64, 0x82, 0x36, 0x6b, 0xd1, - 0x2e, 0xe2, 0x17, 0xc3, 0xa2, 0xf0, 0xda, 0xbb, 0x7a, 0xe4, 0x8a, 0xe5, 0x12, 0x1a, 0x03, 0x59, 0x83, 0x62, 0xff, - 0x9a, 0x04, 0x3f, 0xe8, 0x9f, 0xad, 0x16, 0x1a, 0x2b, 0x53, 0xe6, 0xc7, 0x8c, 0x55, 0xea, 0x9c, 0xb5, 0xf4, 0x6e, - 0x6a, 0x17, 0x94, 0x9c, 0x6c, 0xe5, 0xfc, 0x5a, 0xcc, 0xbf, 0x1f, 0xaa, 0x9f, 0x66, 0xca, 0x3b, 0x84, 0x27, 0xcc, - 0xd7, 0x89, 0xa2, 0x80, 0xac, 0x9b, 0x25, 0xce, 0x52, 0x52, 0xc7, 0x2a, 0x89, 0x12, 0x63, 0x3b, 0x87, 0x47, 0x08, - 0x42, 0xd2, 0xd9, 0xac, 0xce, 0x8c, 0xc9, 0x95, 0x14, 0x6f, 0x87, 0x72, 0x25, 0x9c, 0xc5, 0x22, 0x4d, 0x50, 0x74, - 0xf9, 0x86, 0x5c, 0x2a, 0xf4, 0x54, 0x97, 0x76, 0x74, 0xa8, 0xf4, 0x80, 0x7f, 0x74, 0x73, 0x89, 0x99, 0x54, 0xa0, - 0x11, 0x9f, 0xc7, 0x96, 0x54, 0x22, 0x59, 0x15, 0x39, 0x0c, 0xd4, 0xca, 0xe4, 0x27, 0xdb, 0xe7, 0x32, 0x5a, 0x37, - 0x21, 0x70, 0xdd, 0xe6, 0x4a, 0xe2, 0xee, 0x5f, 0x26, 0xf3, 0x74, 0x00, 0xf6, 0xcb, 0x72, 0x9d, 0x37, 0x3a, 0xe1, - 0xf2, 0xe8, 0xec, 0x53, 0x13, 0xec, 0x78, 0x07, 0x2f, 0x26, 0x12, 0x04, 0x07, 0x89, 0x44, 0xa4, 0x82, 0x33, 0x90, - 0x78, 0x02, 0x03, 0x70, 0x72, 0xfe, 0x88, 0x9f, 0x17, 0x64, 0x79, 0x01, 0x5c, 0xe1, 0xa8, 0x02, 0x44, 0x82, 0x04, - 0x8d, 0x2e, 0xbc, 0x9b, 0x63, 0xf5, 0x9a, 0x2d, 0xb7, 0xab, 0xd2, 0x79, 0x50, 0x73, 0x24, 0x85, 0x92, 0x30, 0xe2, - 0x0c, 0x8b, 0x1f, 0x6c, 0x4a, 0x94, 0xaf, 0x1a, 0x81, 0x30, 0xb2, 0x58, 0xe2, 0x85, 0x46, 0x83, 0x00, 0x8f, 0x8f, - 0x90, 0x32, 0xd9, 0x36, 0xe3, 0x98, 0x7d, 0x4d, 0x89, 0x73, 0x86, 0xcc, 0x10, 0x4a, 0x06, 0xe6, 0x68, 0x09, 0x60, - 0x9d, 0xc5, 0x18, 0x4d, 0xa5, 0x29, 0x3e, 0x3f, 0x47, 0xad, 0xd6, 0x91, 0x57, 0x36, 0x43, 0xbd, 0x0d, 0x56, 0x4c, - 0x89, 0x00, 0xc7, 0x21, 0xa4, 0x97, 0xc0, 0x82, 0x32, 0xe6, 0xb6, 0x24, 0x97, 0x22, 0x3f, 0x24, 0x6b, 0x49, 0xd3, - 0x66, 0x60, 0x70, 0xae, 0x9b, 0x7c, 0x31, 0x1f, 0x8e, 0x92, 0x69, 0x15, 0x6c, 0x1a, 0xeb, 0xfe, 0x3d, 0x6c, 0x9a, - 0x6e, 0x72, 0xe5, 0xb6, 0x0a, 0xc6, 0x6a, 0xe6, 0x78, 0xc4, 0xe6, 0x6c, 0xc0, 0xcb, 0xef, 0x41, 0x1a, 0x2e, 0x1e, - 0x42, 0x64, 0xda, 0x4f, 0xfb, 0xa7, 0xd8, 0xbb, 0xe5, 0xf2, 0x64, 0x06, 0xce, 0xda, 0xd8, 0x1d, 0xa7, 0xa8, 0xae, - 0x25, 0x51, 0x2e, 0x09, 0x9b, 0x0f, 0x80, 0xa1, 0xd6, 0xf7, 0xa2, 0xec, 0xff, 0x2e, 0xe9, 0x89, 0xa2, 0xc2, 0x73, - 0x9d, 0xd7, 0x67, 0xa9, 0x3f, 0x80, 0x76, 0x1f, 0xc7, 0xe4, 0xce, 0x38, 0xcc, 0x11, 0x50, 0x99, 0xbd, 0x5f, 0xbf, - 0xa2, 0x83, 0xb6, 0x95, 0xea, 0x4f, 0x28, 0xce, 0x1f, 0x94, 0xd1, 0x3a, 0x5b, 0xe6, 0xfc, 0x6c, 0xc1, 0x40, 0x67, - 0x92, 0x96, 0x92, 0xca, 0x27, 0xfe, 0x87, 0xea, 0xf0, 0x31, 0xb5, 0x47, 0x8c, 0x4d, 0x24, 0x09, 0x7e, 0x92, 0x27, - 0x7c, 0x4c, 0x35, 0x13, 0xb5, 0x3d, 0x43, 0x8a, 0x7a, 0x63, 0x44, 0xa6, 0x52, 0x4d, 0x96, 0x15, 0x9b, 0x58, 0xe4, - 0x04, 0xbb, 0xba, 0xb0, 0x2e, 0x7d, 0xa2, 0x3e, 0xa5, 0xa6, 0xe6, 0x20, 0x5c, 0x18, 0x48, 0x77, 0xdb, 0x55, 0x8f, - 0x16, 0x4b, 0x5a, 0x28, 0x52, 0x12, 0x39, 0x89, 0xa4, 0x69, 0x1c, 0xa9, 0x0b, 0x60, 0x9e, 0xa3, 0xec, 0x56, 0xb2, - 0x06, 0x6b, 0x6b, 0x3c, 0x51, 0x27, 0xd5, 0x91, 0x9b, 0x3a, 0xcc, 0xac, 0xa7, 0x9a, 0xf9, 0x3f, 0x3b, 0x26, 0x52, - 0xd0, 0x71, 0xe5, 0x91, 0x27, 0x94, 0xe6, 0xcd, 0x54, 0xed, 0x64, 0x48, 0x8f, 0x3c, 0xd7, 0xdb, 0xa4, 0x63, 0x9f, - 0x0b, 0x25, 0x0e, 0xdd, 0x74, 0x39, 0xd5, 0x25, 0xf0, 0xf1, 0x55, 0xfc, 0x91, 0x50, 0x2c, 0x49, 0xa4, 0x61, 0xee, - 0x6c, 0x34, 0xb6, 0x34, 0x56, 0x97, 0x8a, 0x2c, 0x0e, 0x4b, 0x43, 0xd5, 0x55, 0x94, 0xc5, 0x1b, 0x35, 0xd8, 0x44, - 0xd4, 0x45, 0x7d, 0x0d, 0x3a, 0xa5, 0xf3, 0x1c, 0x68, 0xa9, 0xc5, 0xdd, 0x53, 0x41, 0xef, 0x27, 0x5c, 0x53, 0x01, - 0x0e, 0x26, 0x1e, 0xb9, 0x78, 0xc1, 0xe9, 0x32, 0xa7, 0x2b, 0xd8, 0x5f, 0x34, 0x52, 0x8d, 0x33, 0x0b, 0x55, 0x39, - 0x33, 0xaa, 0xda, 0x99, 0x75, 0xd3, 0x0d, 0x86, 0x39, 0x57, 0xbb, 0x1c, 0x59, 0x94, 0x25, 0x59, 0x1c, 0x57, 0xa6, - 0x89, 0xe7, 0xf6, 0xca, 0x65, 0xa4, 0xf3, 0x4a, 0xb6, 0x98, 0x8c, 0x89, 0x4b, 0x3d, 0x32, 0x3d, 0x31, 0xb2, 0x6c, - 0xa0, 0xb6, 0x4b, 0xaf, 0xa9, 0x3a, 0xf6, 0x8b, 0x3b, 0x16, 0x85, 0x97, 0xb9, 0xc6, 0xf4, 0x38, 0x09, 0x19, 0xf2, - 0xa5, 0x35, 0x50, 0x62, 0x1b, 0xfc, 0x58, 0x8e, 0xf6, 0xd3, 0x69, 0x09, 0xac, 0x49, 0x44, 0xa0, 0xea, 0xb5, 0x81, - 0xfc, 0xb8, 0x4d, 0x0f, 0xe9, 0xcb, 0x16, 0x2e, 0xca, 0x1f, 0xca, 0x61, 0x73, 0xe0, 0x10, 0x66, 0x02, 0xa3, 0x60, - 0xa1, 0xbc, 0x92, 0xc0, 0x26, 0xf0, 0x3b, 0x46, 0xcd, 0x76, 0xbb, 0xd2, 0xfb, 0x00, 0x32, 0x19, 0x37, 0x21, 0x3c, - 0x80, 0xc2, 0xeb, 0x29, 0x28, 0x57, 0x88, 0x03, 0xcd, 0x14, 0xa0, 0xc3, 0x0f, 0xe9, 0xc3, 0x13, 0x90, 0x1f, 0xd3, - 0xe1, 0x47, 0xb7, 0x72, 0x1b, 0x6d, 0x73, 0x2c, 0x4f, 0x95, 0x87, 0x6a, 0x1c, 0x21, 0x4a, 0x72, 0x61, 0xb1, 0xa8, - 0xdb, 0x2b, 0x57, 0xb4, 0xbd, 0xf1, 0x5e, 0xdf, 0xb0, 0x4d, 0x3a, 0xfe, 0x18, 0xe6, 0xb8, 0xc2, 0xa8, 0x46, 0x15, - 0x6c, 0xe9, 0x1d, 0xb0, 0xd5, 0x5d, 0x25, 0xb0, 0xc7, 0xa6, 0xb1, 0xb9, 0x00, 0x1d, 0x1a, 0xa2, 0x0c, 0xa4, 0x54, - 0x35, 0x0b, 0x64, 0x72, 0xf5, 0x29, 0xec, 0xb6, 0xa6, 0x31, 0x9b, 0x90, 0xf7, 0xbf, 0xa1, 0x79, 0x15, 0x96, 0x7c, - 0xc2, 0xfe, 0x10, 0xc9, 0x67, 0xf8, 0xd2, 0x47, 0x8d, 0xf8, 0x1e, 0xe0, 0x6a, 0x5f, 0x0a, 0x45, 0xa6, 0x38, 0xb6, - 0xc7, 0x6b, 0xd4, 0x26, 0xf3, 0xf0, 0x50, 0x47, 0x17, 0x36, 0xe4, 0x47, 0x38, 0x61, 0xfb, 0x31, 0xce, 0x93, 0x0b, - 0x8c, 0xe8, 0xbb, 0x18, 0x37, 0x07, 0xe8, 0xca, 0x00, 0xbc, 0x2d, 0xa3, 0x5e, 0x2a, 0x1f, 0xec, 0xf0, 0x16, 0x75, - 0xb3, 0xe3, 0x34, 0xd8, 0x66, 0xc4, 0xa1, 0x1c, 0x0a, 0x70, 0x97, 0xbd, 0xaa, 0x52, 0xe4, 0xf4, 0xd6, 0xf4, 0x8e, - 0xeb, 0x0d, 0xf2, 0x45, 0x13, 0x4d, 0x1d, 0x4c, 0x7a, 0x00, 0x13, 0x6a, 0x39, 0xa0, 0x31, 0x7a, 0xb5, 0x25, 0x5b, - 0x5c, 0x0b, 0x9e, 0xd9, 0x02, 0xd2, 0xbc, 0x22, 0xd5, 0x6e, 0x94, 0x46, 0x53, 0x32, 0x34, 0x69, 0x13, 0x8b, 0xd4, - 0x40, 0x32, 0xab, 0x57, 0x75, 0x22, 0x55, 0x83, 0x2d, 0x70, 0x60, 0xb3, 0x20, 0xc3, 0x37, 0xfb, 0x93, 0x01, 0x63, - 0x4b, 0xaf, 0x7d, 0x4f, 0x76, 0x1f, 0x35, 0xe4, 0x1a, 0x12, 0x19, 0xc7, 0x39, 0xd1, 0x6c, 0xaa, 0x88, 0xf8, 0x64, - 0x1d, 0x15, 0xb0, 0x36, 0x97, 0x6d, 0xe5, 0x83, 0x0a, 0x7a, 0x6c, 0xb0, 0xc9, 0x06, 0xd7, 0x8e, 0x19, 0xd6, 0x17, - 0x9b, 0x8a, 0xd4, 0x65, 0xfa, 0x40, 0xc4, 0x34, 0x83, 0x44, 0xa8, 0x3b, 0x36, 0xbe, 0xae, 0x4a, 0xbb, 0x20, 0xec, - 0xfb, 0xab, 0x74, 0xae, 0x61, 0xe3, 0x15, 0x90, 0xb9, 0xbe, 0xea, 0x00, 0x03, 0xbc, 0x02, 0x71, 0x31, 0xe0, 0xe3, - 0x2d, 0x90, 0x29, 0xf9, 0x77, 0xd4, 0x73, 0xa3, 0x94, 0x47, 0x2d, 0xef, 0x86, 0x19, 0xde, 0x6a, 0x2f, 0xf3, 0x0f, - 0x4b, 0x1f, 0xf2, 0x21, 0x41, 0x85, 0x2c, 0xa4, 0xe6, 0x49, 0xd4, 0xcd, 0x1d, 0x88, 0x6d, 0xdd, 0xe7, 0x22, 0xa3, - 0x58, 0xc4, 0xca, 0x23, 0xc0, 0x5d, 0x04, 0xbc, 0xdb, 0xac, 0x94, 0x88, 0x6f, 0x0e, 0xa5, 0x55, 0xde, 0x12, 0xcd, - 0x55, 0x60, 0xde, 0x45, 0x2b, 0x3b, 0x9c, 0xea, 0x90, 0x81, 0x9d, 0x4a, 0xed, 0x94, 0x44, 0xef, 0xb1, 0xc2, 0x5e, - 0xb3, 0x8d, 0xf5, 0x5b, 0x3b, 0xfa, 0x10, 0x56, 0x0b, 0x56, 0xdb, 0x73, 0x85, 0xe6, 0x66, 0xa2, 0x80, 0x58, 0x60, - 0xcd, 0xde, 0xbe, 0x49, 0x14, 0x42, 0xe1, 0x82, 0xcb, 0x52, 0x2a, 0x91, 0x62, 0xdb, 0x01, 0x83, 0x44, 0x13, 0x26, - 0xaa, 0x8a, 0x73, 0x23, 0xf6, 0x3c, 0x6b, 0xb0, 0xc0, 0xb3, 0x92, 0x0c, 0x8a, 0xd0, 0xba, 0xad, 0x76, 0xa9, 0x40, - 0xef, 0x67, 0x81, 0x95, 0x53, 0xe5, 0x04, 0x0e, 0xa9, 0x46, 0x1c, 0x9f, 0x05, 0x23, 0xb7, 0x4a, 0xf9, 0x16, 0x61, - 0xce, 0xe0, 0xcc, 0x7f, 0x5d, 0x7a, 0x6d, 0x7a, 0x52, 0x0a, 0x0e, 0xd3, 0xf7, 0xe7, 0x30, 0xe9, 0x15, 0x18, 0x9d, - 0xf7, 0x9e, 0xf7, 0x4a, 0x16, 0xf8, 0xeb, 0xb5, 0xbe, 0x14, 0x45, 0xb3, 0x29, 0x4f, 0x3d, 0xb9, 0x94, 0xf9, 0x96, - 0x06, 0xae, 0x53, 0x1c, 0xad, 0xee, 0xd9, 0x9b, 0x15, 0x30, 0x13, 0xcb, 0xf0, 0xef, 0xc1, 0xd6, 0xbe, 0x81, 0xf3, - 0x62, 0x09, 0x44, 0x7e, 0x63, 0x7c, 0x7d, 0xc8, 0xd3, 0xe2, 0x85, 0xcf, 0xcf, 0x08, 0x0b, 0x15, 0xe6, 0x8a, 0x84, - 0xc7, 0xa7, 0x4a, 0xab, 0x2c, 0x41, 0xc3, 0xb4, 0x7c, 0xa6, 0xc7, 0x8b, 0xfa, 0x56, 0x39, 0x7a, 0xeb, 0x2f, 0xb3, - 0xd2, 0x98, 0xcf, 0xcf, 0x93, 0x47, 0x4a, 0xbc, 0x7e, 0xf2, 0x89, 0xaa, 0xf2, 0x67, 0xc3, 0x6d, 0xbd, 0xef, 0xe1, - 0xd7, 0xde, 0x7e, 0x90, 0x65, 0x81, 0xc8, 0xaa, 0x71, 0x6d, 0xe3, 0xa8, 0xf2, 0x34, 0xed, 0x85, 0x58, 0x28, 0xc2, - 0x0f, 0x8a, 0x0e, 0x2c, 0x2f, 0x73, 0xa9, 0xe6, 0x5c, 0x85, 0x8a, 0x46, 0xec, 0x69, 0xfc, 0x7c, 0x08, 0x4b, 0x61, - 0x2a, 0x32, 0x08, 0xe3, 0x0e, 0xed, 0x92, 0x64, 0xec, 0x96, 0x32, 0x6d, 0xeb, 0x77, 0x0b, 0xe5, 0x35, 0x15, 0x13, - 0x30, 0x85, 0x77, 0x20, 0xb9, 0x99, 0x2d, 0xb6, 0xd6, 0x39, 0xa9, 0xa3, 0x02, 0xfb, 0x31, 0xa6, 0xc0, 0x61, 0xa7, - 0x9b, 0xe9, 0x73, 0x81, 0x1b, 0x9a, 0xf2, 0x50, 0x6f, 0x3a, 0xc3, 0x95, 0xd7, 0xf1, 0x43, 0x75, 0xe6, 0x6c, 0x81, - 0x96, 0x6b, 0xe4, 0x2b, 0xbe, 0xaa, 0x96, 0x20, 0xf2, 0x40, 0xf2, 0xb7, 0xaf, 0xbe, 0x7b, 0xab, 0x4b, 0x45, 0xd2, - 0x19, 0x6e, 0xd5, 0xb0, 0x3b, 0x58, 0xe8, 0x6e, 0x75, 0x26, 0x91, 0x04, 0x1a, 0x90, 0x5d, 0x1b, 0xde, 0x0b, 0x1b, - 0xe8, 0x4e, 0x3b, 0x5c, 0x3b, 0xa9, 0x22, 0x68, 0x9d, 0x5d, 0x65, 0x0c, 0x6d, 0xd9, 0x45, 0xc4, 0x2d, 0xbb, 0x0e, - 0x47, 0xd1, 0xcd, 0xb4, 0x10, 0xd6, 0xc6, 0xe3, 0x1e, 0x54, 0x6f, 0x33, 0x20, 0x25, 0x22, 0x12, 0x28, 0x17, 0xe2, - 0x6f, 0x5d, 0xa8, 0x59, 0xc6, 0xdd, 0xa6, 0x43, 0xec, 0x26, 0x89, 0xeb, 0x83, 0x66, 0xf0, 0xd6, 0xa5, 0x95, 0xd7, - 0x19, 0x52, 0xf8, 0x48, 0x45, 0x06, 0xce, 0x0d, 0x53, 0x7b, 0xda, 0x65, 0x1d, 0xc6, 0xbc, 0x54, 0xca, 0xaa, 0x08, - 0xb8, 0xd5, 0x00, 0xcf, 0xda, 0xb7, 0x70, 0x4c, 0x13, 0x1b, 0x9a, 0xa7, 0xbe, 0xcf, 0xd1, 0x76, 0x37, 0x5e, 0xb4, - 0xe2, 0xab, 0xd7, 0xd6, 0x71, 0xd9, 0x3c, 0xeb, 0x6e, 0x13, 0x56, 0xb1, 0x9f, 0x22, 0x29, 0x6c, 0x1a, 0x8b, 0xb9, - 0x26, 0x71, 0x4c, 0x02, 0xa3, 0x05, 0xb0, 0x37, 0xd1, 0x2c, 0xbb, 0x58, 0x22, 0xb5, 0x75, 0x58, 0x77, 0x73, 0xc0, - 0xe1, 0xdb, 0xce, 0x57, 0xaa, 0x76, 0x53, 0x83, 0x12, 0x39, 0xe7, 0xc3, 0xfe, 0xc2, 0xed, 0xfe, 0x50, 0xe2, 0x4d, - 0xdd, 0xc6, 0xe2, 0x48, 0x34, 0x16, 0x10, 0x5c, 0x66, 0x8c, 0xda, 0x2c, 0x8b, 0x10, 0x9d, 0x5a, 0x59, 0xff, 0x40, - 0x05, 0x48, 0x25, 0xd4, 0x6a, 0x71, 0x33, 0x81, 0x05, 0xc7, 0xa4, 0xd4, 0xc6, 0xe1, 0xc7, 0x3f, 0x89, 0xa7, 0x54, - 0xb4, 0x69, 0xd4, 0x13, 0xcd, 0x05, 0xfb, 0x72, 0x88, 0x46, 0x20, 0x77, 0x1b, 0xd6, 0x38, 0xf5, 0xa2, 0xb3, 0xb9, - 0x51, 0xe8, 0xb0, 0x32, 0x55, 0x60, 0xfc, 0xad, 0xc2, 0x6c, 0x20, 0xe7, 0x2a, 0x89, 0x95, 0xdb, 0x19, 0x78, 0x61, - 0x84, 0x0e, 0x62, 0x00, 0xbd, 0x9d, 0xfc, 0x54, 0x7f, 0x5a, 0x5d, 0x94, 0x71, 0x22, 0x4c, 0x4e, 0xdf, 0xdb, 0xc1, - 0x83, 0xda, 0x6c, 0xe7, 0x52, 0xbc, 0xe6, 0x39, 0x81, 0xf6, 0x95, 0x9f, 0xfd, 0x5e, 0xbf, 0x3f, 0x71, 0x13, 0x5a, - 0x56, 0x20, 0x75, 0x8a, 0x7f, 0xd5, 0x9d, 0x51, 0xee, 0x76, 0xce, 0xb3, 0x09, 0xcb, 0x63, 0x92, 0xec, 0x1b, 0x7f, - 0x5b, 0xb8, 0xe4, 0x68, 0xc9, 0x9f, 0x38, 0x0f, 0x8c, 0x62, 0x31, 0x4d, 0x16, 0xa6, 0x7e, 0x4d, 0xd2, 0xde, 0xc4, - 0x75, 0x6c, 0xc4, 0xf1, 0x9f, 0xe3, 0x10, 0x3d, 0x49, 0x84, 0xd4, 0x7a, 0x4b, 0x51, 0x3d, 0xaa, 0x7b, 0x27, 0xea, - 0x42, 0x36, 0x0f, 0x39, 0x5a, 0x93, 0x31, 0x9d, 0xd4, 0x5d, 0xaa, 0x91, 0x68, 0x04, 0x4b, 0xb7, 0x76, 0x3b, 0x39, - 0x3c, 0xc4, 0x4b, 0xfb, 0x28, 0x12, 0x15, 0xbd, 0x0b, 0x4d, 0x71, 0x68, 0xa3, 0x58, 0x5f, 0x59, 0x89, 0x05, 0xd6, - 0x5e, 0x2a, 0xad, 0xe6, 0x82, 0xae, 0xbc, 0x1c, 0x15, 0x3a, 0x27, 0x00, 0x5b, 0xcb, 0x39, 0x91, 0x01, 0x74, 0x62, - 0xb1, 0x70, 0x1d, 0x72, 0x03, 0xca, 0x10, 0xa1, 0x72, 0x4b, 0x8f, 0x53, 0x69, 0xd5, 0x28, 0x96, 0x80, 0xc4, 0x70, - 0xc6, 0x7c, 0xeb, 0x63, 0x36, 0x6e, 0x64, 0x0c, 0xae, 0x5a, 0x76, 0x6d, 0x19, 0x6b, 0x6b, 0x85, 0xb4, 0x0e, 0x98, - 0x5a, 0x65, 0x3f, 0x35, 0xbe, 0xf3, 0xe7, 0xbd, 0x23, 0x4d, 0x6f, 0x71, 0x24, 0x11, 0x06, 0x6d, 0xaf, 0x18, 0x0b, - 0x53, 0x84, 0xdb, 0xec, 0xf6, 0x8a, 0xd0, 0xdd, 0x5f, 0x0a, 0x7c, 0x5b, 0xb8, 0x31, 0x15, 0x37, 0x8e, 0x1e, 0x5f, - 0x14, 0x4c, 0x04, 0xe3, 0xd0, 0x54, 0x95, 0xf0, 0x6e, 0xba, 0x0a, 0x0a, 0x72, 0x2a, 0x2a, 0x1c, 0x78, 0xb0, 0x5e, - 0x66, 0xf4, 0x28, 0x12, 0x8a, 0x2d, 0xae, 0x6a, 0x4d, 0x14, 0x77, 0x19, 0x17, 0xa4, 0x2f, 0x87, 0xf9, 0xb7, 0xaa, - 0x1b, 0xba, 0x66, 0x55, 0xba, 0x45, 0xe2, 0x6b, 0x53, 0x8d, 0x46, 0x44, 0xe5, 0x7b, 0xe9, 0x03, 0xf3, 0x58, 0x4b, - 0x77, 0x3e, 0xed, 0x13, 0xae, 0x0c, 0x1c, 0x18, 0xfa, 0x48, 0xf7, 0x57, 0xeb, 0xea, 0x24, 0x1f, 0x57, 0x9f, 0x7c, - 0x0d, 0xcf, 0x1f, 0x8c, 0x9d, 0x76, 0x70, 0xcb, 0x21, 0x7d, 0xcc, 0xf9, 0x35, 0x33, 0xbd, 0x45, 0xab, 0xbd, 0x51, - 0xa3, 0x2e, 0xb0, 0x99, 0x4c, 0xd3, 0x63, 0xfe, 0xe9, 0xad, 0xe8, 0xc1, 0x05, 0x27, 0x89, 0x2f, 0x20, 0xe1, 0x86, - 0xed, 0xd5, 0xc7, 0x47, 0xaa, 0xeb, 0xd6, 0x09, 0x25, 0x76, 0x23, 0x95, 0xed, 0xa0, 0x42, 0xc8, 0x0e, 0xf7, 0xc4, - 0xd5, 0x1b, 0xec, 0x73, 0x88, 0xd3, 0xd1, 0x00, 0x29, 0x93, 0x26, 0xf6, 0x25, 0x8c, 0xf7, 0xc5, 0x1c, 0x36, 0x2b, - 0x48, 0xbc, 0xea, 0xc2, 0x29, 0x94, 0x58, 0xd9, 0xf3, 0xea, 0x78, 0x1d, 0xdd, 0xe4, 0x23, 0x9a, 0x32, 0xd0, 0xdc, - 0xbd, 0xe5, 0x2e, 0x17, 0x18, 0xdd, 0x6b, 0xf3, 0xf1, 0xdd, 0xce, 0x68, 0x4d, 0x12, 0xf9, 0xc6, 0xb7, 0xf9, 0x70, - 0xf3, 0xf8, 0x85, 0x86, 0xe2, 0x00, 0xd7, 0x71, 0xb8, 0xfd, 0xe1, 0xb2, 0x2a, 0xf7, 0xaa, 0x1f, 0xd4, 0xc0, 0x91, - 0x52, 0x4f, 0x0d, 0x67, 0x61, 0x4f, 0x30, 0xe1, 0xd4, 0x81, 0xb3, 0xe6, 0x83, 0x90, 0x73, 0xf9, 0xd7, 0x2e, 0x94, - 0x73, 0x37, 0x6e, 0x16, 0x9e, 0x06, 0x36, 0x76, 0x86, 0x3a, 0x5c, 0xea, 0xce, 0x6c, 0x49, 0x3c, 0xc3, 0x8f, 0xbb, - 0x9a, 0x08, 0x4b, 0xed, 0x81, 0xaf, 0x57, 0xbc, 0x9c, 0xfb, 0xdb, 0xe1, 0x8d, 0xe2, 0x82, 0x30, 0x33, 0xbe, 0x88, - 0x4a, 0x93, 0x2c, 0x69, 0xf8, 0x36, 0xb2, 0x19, 0x75, 0xed, 0xbb, 0x68, 0x45, 0x50, 0x32, 0x22, 0x54, 0x71, 0x68, - 0xc6, 0x50, 0x06, 0xe8, 0x58, 0x45, 0x69, 0xcf, 0xd7, 0x06, 0xb3, 0x4e, 0x36, 0x73, 0x04, 0x74, 0x44, 0xdf, 0x2f, - 0x5f, 0xd4, 0xb3, 0x7f, 0xdd, 0x1f, 0x1e, 0xbe, 0xc8, 0x55, 0x7d, 0xd4, 0x00, 0xfc, 0x8e, 0x54, 0xf5, 0xe8, 0x8d, - 0xe5, 0x57, 0x5a, 0x82, 0xad, 0x66, 0x07, 0x46, 0x9d, 0xa4, 0x8d, 0xd4, 0x86, 0xcc, 0x32, 0x67, 0x52, 0x28, 0x04, - 0x2d, 0x3d, 0x9e, 0x63, 0x31, 0x05, 0x50, 0xd2, 0xe5, 0x8a, 0x88, 0x0b, 0x06, 0x61, 0x15, 0x87, 0x31, 0x2c, 0xa4, - 0x69, 0x3d, 0xdb, 0xce, 0xa2, 0x51, 0x83, 0xd0, 0x35, 0x86, 0x44, 0x85, 0x99, 0xf5, 0x8c, 0x83, 0x5c, 0x6a, 0xbb, - 0x20, 0x4f, 0x7e, 0x73, 0x15, 0x03, 0xd0, 0x63, 0x22, 0x79, 0x5a, 0xd5, 0x44, 0x96, 0x90, 0xcf, 0xa4, 0x61, 0xd3, - 0xfb, 0xc3, 0x37, 0x31, 0x3d, 0xfa, 0xd8, 0xd5, 0xd6, 0x1f, 0xa2, 0xe4, 0xb9, 0x17, 0x29, 0x5f, 0xeb, 0xb4, 0x65, - 0x77, 0xa2, 0x4d, 0xd0, 0x44, 0xdb, 0x82, 0xb0, 0x05, 0x3a, 0xd0, 0x67, 0x3c, 0x1a, 0x2e, 0x1b, 0x51, 0x16, 0x8b, - 0x94, 0x28, 0x87, 0xfc, 0xe6, 0xec, 0x11, 0xe2, 0xb4, 0x16, 0x46, 0x03, 0xcb, 0xbd, 0x60, 0x18, 0x45, 0x17, 0xec, - 0x81, 0x4f, 0x2b, 0x52, 0x5c, 0x7d, 0xbb, 0x58, 0xf3, 0xba, 0x32, 0xd1, 0x36, 0x98, 0xf0, 0x0e, 0x1a, 0x1e, 0x61, - 0xaf, 0x71, 0x4e, 0x83, 0xae, 0xeb, 0xc9, 0xd3, 0x8a, 0x8c, 0x4d, 0x65, 0xa5, 0x1e, 0x01, 0xb7, 0xd8, 0xdb, 0x7e, - 0xd4, 0x36, 0x07, 0x7b, 0x36, 0xb6, 0xea, 0xc6, 0xb6, 0xc7, 0x10, 0xdc, 0x3a, 0x41, 0x3e, 0xdd, 0x29, 0x7d, 0x9e, - 0xe7, 0x4b, 0x6b, 0x13, 0xe8, 0xf0, 0xba, 0x35, 0xed, 0x71, 0x84, 0x11, 0x11, 0xb7, 0x99, 0x2e, 0x58, 0x58, 0x4a, - 0x6f, 0xa9, 0xae, 0x88, 0xe1, 0xd9, 0x0e, 0x59, 0x0d, 0x40, 0x2f, 0xb0, 0x3f, 0x94, 0xe7, 0x25, 0x5c, 0xe8, 0xf9, - 0xf0, 0xd1, 0x45, 0x95, 0x97, 0xa5, 0x1d, 0x66, 0x7b, 0xd6, 0xdd, 0xa0, 0xc2, 0xf5, 0xa9, 0x5a, 0x9d, 0x50, 0x20, - 0x96, 0x9e, 0xa3, 0xbf, 0xef, 0x53, 0x47, 0x3c, 0xcf, 0x08, 0x61, 0x27, 0x36, 0x9b, 0x07, 0x20, 0xf6, 0x41, 0xc7, - 0x04, 0x01, 0x42, 0xd0, 0x10, 0xab, 0x3d, 0xa0, 0x1e, 0xbf, 0x33, 0xf4, 0x7d, 0x44, 0x7a, 0x13, 0xa0, 0x32, 0x05, - 0xc5, 0x89, 0xda, 0xa7, 0x24, 0x22, 0x27, 0x3f, 0xc9, 0x2e, 0x9b, 0xb1, 0xa8, 0x93, 0xc0, 0xf9, 0x88, 0x53, 0xb0, - 0x14, 0x0a, 0xe7, 0xc5, 0x33, 0x01, 0x7c, 0x3a, 0x67, 0x8b, 0x69, 0xe1, 0x8b, 0x1c, 0x94, 0xcd, 0xa4, 0xc7, 0xf5, - 0x38, 0xb7, 0x7d, 0x4c, 0x38, 0x2a, 0xca, 0xd8, 0xd8, 0x5b, 0x75, 0x66, 0x8c, 0xf0, 0xd5, 0x44, 0xa8, 0xf7, 0x63, - 0x9c, 0xb7, 0xf7, 0x3d, 0x3e, 0xe2, 0x52, 0x6c, 0x2a, 0x84, 0xc2, 0x82, 0x9a, 0xa7, 0xf4, 0x47, 0x59, 0xe7, 0xd4, - 0xc8, 0x82, 0xf2, 0xb8, 0x82, 0x91, 0xa2, 0x4c, 0xfb, 0xec, 0xc9, 0x9e, 0x32, 0x48, 0x6c, 0xe7, 0x65, 0xa2, 0x2b, - 0x05, 0x0c, 0xa2, 0x54, 0x0a, 0x76, 0xb5, 0x2d, 0x14, 0xc9, 0x20, 0x1c, 0x43, 0xbb, 0x11, 0x47, 0x55, 0xe6, 0x90, - 0x84, 0x7c, 0xcd, 0xd7, 0x38, 0xb3, 0xdd, 0x1c, 0x52, 0x18, 0x6c, 0x51, 0x4d, 0x46, 0x81, 0xd0, 0x6e, 0x41, 0x40, - 0xe8, 0xd2, 0x85, 0xdf, 0xe0, 0x97, 0x47, 0xa9, 0x6c, 0x26, 0x38, 0x4f, 0x17, 0x6e, 0xe1, 0x97, 0x1e, 0xb5, 0x62, - 0xc7, 0x5b, 0x6b, 0xe3, 0x12, 0xe5, 0xa2, 0x65, 0xfe, 0x23, 0xf6, 0xb8, 0x80, 0x03, 0x5b, 0x60, 0x6d, 0xe8, 0x0e, - 0x95, 0x61, 0x34, 0x70, 0xe2, 0x01, 0x24, 0xb5, 0xbb, 0x61, 0x49, 0x5b, 0xd4, 0x7f, 0x32, 0xd7, 0xea, 0x1a, 0x34, - 0x81, 0x59, 0xab, 0xc5, 0x36, 0x4d, 0x85, 0x1c, 0x32, 0xaa, 0x1a, 0xb0, 0x52, 0x6d, 0x87, 0x34, 0x59, 0x22, 0x87, - 0x24, 0x71, 0x27, 0x73, 0x06, 0x55, 0x56, 0x7a, 0xd1, 0xff, 0xa8, 0x44, 0xe4, 0x43, 0x5e, 0xff, 0xa4, 0x8a, 0x67, - 0x99, 0xd4, 0x8f, 0xc2, 0xa1, 0x4a, 0x63, 0x93, 0x0d, 0x6d, 0x00, 0xa3, 0x0f, 0x73, 0xa8, 0x2c, 0x74, 0x6c, 0x95, - 0xfb, 0x6e, 0x5a, 0xc9, 0xb1, 0x21, 0x9f, 0xcc, 0x18, 0x30, 0xdf, 0x7e, 0x0b, 0x62, 0x8f, 0x5b, 0xcc, 0x38, 0xdc, - 0x4b, 0x7e, 0xbe, 0x4c, 0x44, 0xc1, 0x1f, 0x4e, 0xc3, 0x0e, 0x7c, 0xd3, 0x21, 0xa6, 0xcd, 0x15, 0x33, 0x64, 0x06, - 0xa5, 0x6d, 0x09, 0x31, 0x2d, 0x78, 0x4a, 0xa4, 0xfb, 0xf7, 0xfe, 0xc4, 0xde, 0xd3, 0x7c, 0x21, 0x3f, 0x59, 0x9d, - 0x83, 0xbe, 0x55, 0x97, 0x3e, 0x86, 0x17, 0xc6, 0x7d, 0x00, 0x50, 0xb9, 0xbd, 0x6d, 0xc5, 0x71, 0x7b, 0x5f, 0x85, - 0xf8, 0x83, 0x19, 0x66, 0x1c, 0xaf, 0x52, 0x64, 0x63, 0x81, 0xe9, 0x94, 0x59, 0xa9, 0x5b, 0x35, 0x2b, 0xfb, 0xc7, - 0xf4, 0x5f, 0x1a, 0x0d, 0xb0, 0x2f, 0x17, 0xf9, 0xf9, 0x76, 0x99, 0x28, 0xb0, 0xc2, 0x22, 0xd1, 0x7b, 0x17, 0x40, - 0xba, 0x83, 0x48, 0xc6, 0x9f, 0xf7, 0x70, 0xd1, 0xf0, 0xf0, 0x17, 0x39, 0x68, 0xd9, 0x79, 0xe5, 0x44, 0x69, 0x3e, - 0xaf, 0xd8, 0x09, 0x44, 0x9e, 0x3a, 0x45, 0x98, 0xfe, 0x7d, 0x72, 0xe5, 0xb5, 0x47, 0x4e, 0xce, 0x5e, 0x40, 0xbf, - 0x26, 0x6e, 0x9f, 0x9f, 0x65, 0x1d, 0xfe, 0xb1, 0x44, 0x85, 0xb0, 0x52, 0xe8, 0x41, 0x55, 0x08, 0x5f, 0x70, 0xe2, - 0x00, 0x4e, 0x3d, 0x7c, 0x78, 0xc6, 0xdf, 0x0e, 0x43, 0xfb, 0xf8, 0x99, 0xb3, 0x69, 0x89, 0xf1, 0x12, 0x83, 0x45, - 0xb5, 0xd4, 0x78, 0x7e, 0xff, 0x34, 0xeb, 0xe9, 0x9e, 0xb1, 0x4f, 0x8b, 0x9e, 0xac, 0x6a, 0x9a, 0x37, 0x24, 0xce, - 0x7f, 0xd8, 0xfc, 0x5a, 0x1b, 0x1f, 0xec, 0xdc, 0x56, 0x25, 0x47, 0xd6, 0x05, 0xae, 0xcb, 0xaa, 0x55, 0xd5, 0x37, - 0x03, 0xce, 0x49, 0x8f, 0xc5, 0x4b, 0x9d, 0xdd, 0x2f, 0xe8, 0x8f, 0x66, 0x3a, 0x5a, 0x1f, 0x7d, 0x70, 0x2d, 0x42, - 0xd5, 0xa4, 0x33, 0xba, 0x37, 0xbf, 0xc3, 0x39, 0xe5, 0x33, 0xd7, 0xf1, 0xb9, 0x5b, 0x0b, 0xe5, 0x09, 0x8f, 0x16, - 0x1a, 0x85, 0xa1, 0x3b, 0x77, 0x8f, 0xe0, 0x5a, 0x24, 0xcd, 0xc8, 0xde, 0xc2, 0x39, 0xd3, 0xf8, 0x4c, 0x7f, 0x36, - 0x0b, 0xf5, 0xa7, 0x3e, 0x14, 0x14, 0x11, 0xf3, 0x2b, 0xa6, 0x62, 0x28, 0x25, 0xc1, 0x43, 0x44, 0x04, 0x5a, 0x47, - 0x51, 0x3e, 0x55, 0x57, 0x57, 0xca, 0xea, 0x97, 0xb3, 0x2c, 0x28, 0x92, 0xd9, 0x14, 0x59, 0xb9, 0xe2, 0x8f, 0x4e, - 0x72, 0x96, 0xeb, 0x42, 0x40, 0xce, 0x3e, 0xc0, 0x89, 0xfd, 0x9b, 0x41, 0xe0, 0xb6, 0xb6, 0xd6, 0xfe, 0x48, 0x50, - 0x63, 0x14, 0x7c, 0x8b, 0x00, 0x8c, 0xc4, 0xd0, 0x46, 0xf9, 0xe4, 0x56, 0xba, 0x50, 0x51, 0xbd, 0x3f, 0x71, 0xf7, - 0x3f, 0xbf, 0xbb, 0xc9, 0x0d, 0x1b, 0x77, 0x69, 0x0e, 0x4d, 0xe6, 0xc9, 0x39, 0xda, 0xc8, 0xee, 0xba, 0x5f, 0x06, - 0xf9, 0x2d, 0x5f, 0x92, 0xf4, 0x74, 0x44, 0x60, 0x4b, 0xcb, 0x8f, 0x48, 0x45, 0x49, 0x22, 0x90, 0x63, 0xad, 0x00, - 0x50, 0x33, 0x21, 0x95, 0x8a, 0x1c, 0x45, 0x9e, 0x8c, 0x7a, 0x33, 0xa7, 0x24, 0x2d, 0x69, 0x37, 0xa8, 0x31, 0x2c, - 0x87, 0xaf, 0xb9, 0x36, 0x4b, 0x7d, 0xad, 0xa4, 0xec, 0xc4, 0xf6, 0x82, 0x05, 0x94, 0x38, 0xa6, 0xe0, 0x82, 0xd5, - 0x58, 0x9a, 0x36, 0xaf, 0x27, 0x18, 0xd0, 0x32, 0x97, 0x76, 0xd9, 0x12, 0xf2, 0x95, 0xfa, 0x7d, 0x58, 0x8c, 0x90, - 0x7c, 0x63, 0xa1, 0x58, 0xda, 0xaa, 0x55, 0xb9, 0xf3, 0x1c, 0x3f, 0xd0, 0xa4, 0x48, 0x1d, 0xed, 0x61, 0xfa, 0x16, - 0x8e, 0xc4, 0xe0, 0x66, 0x4e, 0xb9, 0xa4, 0x4c, 0xe3, 0xd2, 0x9f, 0xa4, 0xff, 0xaa, 0x2f, 0x43, 0x3e, 0xc1, 0x51, - 0xac, 0xfe, 0x83, 0x6a, 0xcc, 0x40, 0x40, 0xea, 0x4b, 0x10, 0x15, 0xc3, 0x68, 0xe6, 0x10, 0xdd, 0xa0, 0xf5, 0x99, - 0x3a, 0x91, 0xce, 0x5e, 0x6c, 0x70, 0xd2, 0x97, 0x73, 0xa2, 0x79, 0xe1, 0x3b, 0x8c, 0xf7, 0x81, 0x01, 0x0c, 0x0a, - 0xf3, 0x60, 0x0c, 0xec, 0xb2, 0x26, 0x6d, 0x29, 0xb8, 0x41, 0x0d, 0x34, 0x81, 0x07, 0x78, 0x3a, 0x89, 0x90, 0x8b, - 0x7c, 0x66, 0x71, 0x27, 0xbb, 0x98, 0x52, 0x6b, 0x7e, 0x2c, 0x84, 0x85, 0xfe, 0xdd, 0x62, 0x2b, 0xcb, 0x1d, 0x3c, - 0x13, 0x11, 0x54, 0x05, 0x0a, 0xbc, 0x72, 0x79, 0x43, 0xa7, 0x25, 0x70, 0xf0, 0x3e, 0xb5, 0xe2, 0x06, 0x07, 0xbe, - 0x0d, 0x2a, 0x5d, 0xb0, 0x1f, 0xb4, 0xeb, 0xdf, 0x7b, 0xae, 0xc2, 0x22, 0xea, 0x21, 0xde, 0x6a, 0xae, 0x57, 0x77, - 0xf7, 0xbe, 0xd7, 0xf1, 0x59, 0x53, 0xcb, 0x1e, 0x7f, 0xc6, 0x10, 0x0a, 0x4e, 0xd0, 0x2a, 0x15, 0x12, 0x30, 0xf0, - 0xc7, 0x2d, 0x6c, 0xfc, 0x92, 0xa5, 0xdb, 0x11, 0x4b, 0x7f, 0xfd, 0xba, 0xa2, 0xc9, 0xae, 0xba, 0xa9, 0x27, 0xa0, - 0x88, 0xbd, 0xa3, 0x55, 0x76, 0xb8, 0x4a, 0xcd, 0x7b, 0xc5, 0xbb, 0x1e, 0xf8, 0x94, 0x0e, 0xcc, 0x28, 0xb0, 0x17, - 0xc4, 0x1c, 0x18, 0xeb, 0xc7, 0x46, 0x79, 0xd7, 0x4f, 0xbe, 0x4b, 0xd1, 0x46, 0xad, 0xaf, 0xfc, 0x41, 0x10, 0xdf, - 0x67, 0x46, 0xac, 0xbd, 0x04, 0x66, 0x30, 0xba, 0xd3, 0x36, 0x1d, 0x76, 0xe5, 0x3e, 0x9e, 0x1f, 0xb2, 0xde, 0x41, - 0x40, 0xa5, 0xe8, 0x47, 0x81, 0x4b, 0x26, 0x30, 0x98, 0x83, 0x23, 0xdb, 0x8b, 0x3d, 0xf9, 0x44, 0xcc, 0x85, 0x28, - 0x45, 0x33, 0x46, 0x01, 0xc1, 0xc8, 0x61, 0x85, 0xed, 0x3f, 0xc2, 0x76, 0x01, 0x70, 0x8b, 0x87, 0x0c, 0x7b, 0x5e, - 0xe3, 0x4d, 0xbc, 0x1d, 0x35, 0xcc, 0x99, 0xd4, 0x5b, 0xd0, 0x4e, 0x8f, 0x21, 0xf9, 0x7d, 0x1a, 0x24, 0xa3, 0x22, - 0xf7, 0x28, 0x12, 0x84, 0xd7, 0x45, 0x4e, 0x5a, 0x80, 0x75, 0x77, 0xe8, 0xa6, 0x5f, 0x01, 0x62, 0xfa, 0x5e, 0x02, - 0xfe, 0x44, 0x6e, 0x22, 0x16, 0xbc, 0xdd, 0x34, 0xc4, 0x1d, 0x4c, 0x80, 0xa1, 0x11, 0x9e, 0x41, 0xd0, 0x08, 0x92, - 0x11, 0xdd, 0x6d, 0xee, 0xa7, 0xcc, 0x7f, 0x56, 0xe4, 0xc7, 0xb2, 0x31, 0xae, 0x79, 0xd3, 0xde, 0xc5, 0x6f, 0x11, - 0xa9, 0x00, 0x62, 0x67, 0xca, 0x2c, 0x54, 0x89, 0xc9, 0xd7, 0x85, 0x8d, 0x7d, 0x6e, 0x94, 0x25, 0xdb, 0xe7, 0xf5, - 0xd7, 0x66, 0xd8, 0x92, 0x66, 0xb6, 0xb7, 0x39, 0xe3, 0xb3, 0x8a, 0x89, 0x85, 0x17, 0x05, 0xce, 0xfd, 0xed, 0xd7, - 0xfd, 0xf9, 0x70, 0x95, 0x2d, 0xdb, 0x29, 0x53, 0x8f, 0x23, 0x25, 0xcb, 0x5a, 0x7f, 0xbb, 0x32, 0x93, 0xb7, 0x6e, - 0xd1, 0x13, 0xec, 0xa8, 0x35, 0xf3, 0x25, 0x47, 0xda, 0xba, 0x87, 0x93, 0xec, 0xba, 0xc0, 0x2e, 0xef, 0x04, 0xd0, - 0xb4, 0x74, 0x42, 0xf1, 0x73, 0x25, 0xb4, 0xac, 0x1d, 0xe0, 0x24, 0x7e, 0xfa, 0x62, 0xe2, 0xa5, 0x98, 0xad, 0xc1, - 0x36, 0xbf, 0x62, 0x5e, 0xc4, 0x60, 0xcf, 0x8d, 0x0a, 0xe1, 0x8c, 0xf3, 0xbe, 0x05, 0xb3, 0xf4, 0x1b, 0xaf, 0xdc, - 0xe6, 0x73, 0x82, 0xfd, 0x96, 0x16, 0xc1, 0xc0, 0xc4, 0x5d, 0xf5, 0x5a, 0xe3, 0x2c, 0x84, 0xa8, 0x6b, 0xb9, 0x2f, - 0x62, 0xe6, 0x36, 0xa7, 0xe9, 0x5d, 0xad, 0xc9, 0x8c, 0xfd, 0xe2, 0x4a, 0x33, 0xeb, 0xbb, 0xef, 0x20, 0x6b, 0xad, - 0x2a, 0xf4, 0x2b, 0x52, 0xcf, 0x64, 0xfd, 0x27, 0xb0, 0x19, 0x8b, 0x1d, 0x16, 0x4b, 0x2b, 0x75, 0xe7, 0xaa, 0xf4, - 0x03, 0x9e, 0x54, 0x00, 0x72, 0x11, 0xd0, 0x99, 0x6e, 0x3d, 0x77, 0x8b, 0x45, 0x3d, 0xea, 0xc1, 0xad, 0xbf, 0xb7, - 0x1a, 0x06, 0x41, 0xac, 0x63, 0xbf, 0x22, 0x78, 0x3c, 0x5e, 0x89, 0xdf, 0x0b, 0xaf, 0xc8, 0x0f, 0x5b, 0x1e, 0xff, - 0x7c, 0x01, 0x65, 0xfa, 0x49, 0x34, 0xed, 0xfc, 0x6c, 0xc3, 0xc2, 0xa4, 0x7c, 0x3a, 0x8f, 0xfc, 0x1e, 0xcd, 0xcd, - 0x15, 0xb4, 0xdc, 0xf3, 0x03, 0x97, 0xf2, 0x7f, 0x16, 0x29, 0x4b, 0x6a, 0x85, 0x66, 0xd9, 0x36, 0xc1, 0xd1, 0xdd, - 0x9e, 0xe2, 0xc1, 0x73, 0x9c, 0x50, 0x68, 0x6f, 0x4a, 0xbd, 0x55, 0x85, 0x9a, 0xa8, 0xb5, 0x85, 0x02, 0x65, 0xfd, - 0x88, 0xf6, 0x51, 0x71, 0xc4, 0x0d, 0x23, 0x3d, 0xea, 0x6f, 0x6a, 0x6d, 0x91, 0x5d, 0x47, 0xed, 0x97, 0xb5, 0xfb, - 0x7d, 0x12, 0x24, 0xff, 0x6d, 0x05, 0xc8, 0xac, 0x0d, 0xd5, 0x9b, 0x80, 0x69, 0x44, 0x31, 0x47, 0xc1, 0x8f, 0xd1, - 0x92, 0x42, 0xa3, 0x0c, 0x2e, 0x1c, 0x11, 0x66, 0x2d, 0xb5, 0xe4, 0x19, 0x43, 0xf0, 0xbc, 0xd1, 0xd0, 0x91, 0xf0, - 0xb5, 0xe9, 0x5d, 0x76, 0x66, 0x36, 0x4c, 0xce, 0x3d, 0xa2, 0x21, 0x9b, 0x7a, 0xaa, 0x28, 0x01, 0xf7, 0xcd, 0x72, - 0x7c, 0x75, 0x50, 0xb2, 0x26, 0xb5, 0x57, 0xc1, 0x6e, 0x1f, 0x72, 0x73, 0x19, 0xbd, 0x35, 0x54, 0x6b, 0xf8, 0xde, - 0x48, 0xd6, 0xb0, 0xca, 0x35, 0x90, 0xd8, 0xce, 0x8f, 0x30, 0x49, 0x45, 0x77, 0xf9, 0x16, 0x34, 0xde, 0x51, 0x95, - 0xcb, 0x4e, 0xeb, 0xda, 0xbb, 0x03, 0x37, 0x61, 0xd8, 0xfa, 0xd4, 0x8d, 0x8e, 0xf4, 0xfd, 0x80, 0x0d, 0x9a, 0x95, - 0x4a, 0x03, 0x4e, 0xf9, 0x05, 0x15, 0xad, 0xf3, 0xbb, 0x25, 0x5f, 0xec, 0x19, 0xee, 0x83, 0x11, 0x32, 0x26, 0x8e, - 0xc0, 0x8e, 0x1a, 0xe0, 0x29, 0x61, 0xc6, 0xe1, 0xc7, 0x9e, 0xdb, 0xd7, 0xc6, 0xa0, 0x7f, 0xa5, 0xd9, 0x50, 0x40, - 0x8d, 0xf6, 0xb8, 0x92, 0x54, 0x3a, 0x86, 0x19, 0x93, 0xc2, 0x87, 0x54, 0x28, 0x73, 0xfc, 0xbb, 0x73, 0x4d, 0xb1, - 0x66, 0x38, 0x57, 0x23, 0xd3, 0x86, 0xf3, 0xbf, 0x1a, 0xf3, 0x5b, 0x8e, 0xef, 0x20, 0xaa, 0x9e, 0x8e, 0x41, 0x87, - 0x50, 0x4a, 0x50, 0x76, 0x65, 0x42, 0x55, 0x03, 0xfd, 0xa2, 0x19, 0x6d, 0x9a, 0xd6, 0x8f, 0x91, 0xf3, 0xbf, 0x6e, - 0xbf, 0xb6, 0x93, 0x8b, 0xd6, 0x0a, 0xeb, 0xe2, 0x07, 0x3f, 0x30, 0xe4, 0xb5, 0x7b, 0x7e, 0x76, 0xab, 0x5c, 0xd9, - 0x53, 0x5b, 0x3c, 0x75, 0xc1, 0x97, 0xe9, 0xfa, 0x18, 0xbc, 0x2c, 0x20, 0x35, 0x8d, 0xaa, 0x75, 0xec, 0x13, 0x16, - 0x5a, 0xec, 0x3a, 0x6f, 0xd7, 0x2f, 0x4f, 0xaa, 0x89, 0x57, 0x2c, 0x03, 0x3a, 0x3f, 0xb3, 0x29, 0xf6, 0x91, 0x16, - 0x97, 0x0d, 0xff, 0x32, 0xa0, 0xe7, 0xd9, 0x40, 0x9e, 0xf9, 0x59, 0x7f, 0xae, 0x3f, 0xbe, 0xe5, 0x21, 0xa1, 0x14, - 0xb7, 0x35, 0x4e, 0xef, 0x1a, 0xdb, 0xcc, 0x3b, 0xb3, 0xb4, 0x8f, 0x9d, 0x66, 0x3e, 0xa2, 0x22, 0x5d, 0x70, 0x12, - 0xb6, 0xa7, 0x43, 0xba, 0x92, 0x6d, 0x16, 0x9a, 0x39, 0xf5, 0xa5, 0x71, 0x59, 0x9c, 0xd7, 0x69, 0x73, 0x31, 0xf7, - 0x82, 0xae, 0x03, 0x38, 0xd7, 0x29, 0x47, 0x70, 0x55, 0x11, 0x28, 0x9a, 0x9a, 0xb9, 0xa2, 0x78, 0x68, 0x2d, 0x76, - 0x73, 0x6b, 0xf7, 0x53, 0x8c, 0xb8, 0xd4, 0xa5, 0x2a, 0x51, 0x92, 0x6c, 0x59, 0x29, 0x90, 0xc9, 0x82, 0xac, 0x39, - 0x49, 0x15, 0x0e, 0xfa, 0x37, 0x87, 0x74, 0xf6, 0x62, 0xd8, 0x87, 0x70, 0xe6, 0x6b, 0xa9, 0x0a, 0xa3, 0x59, 0xac, - 0xe6, 0x39, 0x88, 0x54, 0x6d, 0x1f, 0x28, 0xa7, 0xca, 0x7d, 0x5b, 0x40, 0xe6, 0x46, 0xca, 0x4b, 0x51, 0x47, 0x6e, - 0x78, 0x4a, 0xbf, 0x36, 0x3d, 0x10, 0xa3, 0xd5, 0xb0, 0xa3, 0x8d, 0x66, 0xb3, 0x59, 0x14, 0x53, 0x8f, 0x43, 0x9b, - 0xd4, 0x7c, 0x1b, 0x51, 0xaf, 0x50, 0x35, 0xb3, 0x6f, 0x4c, 0x3d, 0x62, 0xc9, 0x9c, 0xe2, 0x35, 0x14, 0x26, 0xc9, - 0x3d, 0x8b, 0x2d, 0xea, 0x16, 0x6d, 0x6e, 0xce, 0x1c, 0x1b, 0x92, 0xb8, 0x8a, 0x4b, 0x99, 0xae, 0x34, 0x1e, 0x05, - 0xc2, 0x61, 0x25, 0xda, 0x4e, 0x30, 0x1b, 0xf3, 0xf4, 0x83, 0x9f, 0x92, 0x9f, 0x7b, 0x04, 0x4c, 0xb3, 0x2f, 0x60, - 0x2b, 0xe9, 0xce, 0x8c, 0xf8, 0x48, 0x41, 0xce, 0xe1, 0x2b, 0x86, 0xe9, 0x7b, 0x9b, 0xc8, 0x72, 0x1f, 0xe7, 0x53, - 0x82, 0x32, 0x39, 0xa9, 0x76, 0x68, 0xbc, 0x81, 0xd8, 0x1b, 0x20, 0x9e, 0x86, 0xb0, 0x04, 0x4f, 0x23, 0x60, 0x90, - 0xf8, 0xbc, 0x5c, 0xc5, 0x43, 0x2d, 0x6e, 0x7c, 0x97, 0x79, 0x08, 0x70, 0xb6, 0x0c, 0x43, 0x6d, 0x62, 0x92, 0xfb, - 0xb3, 0x06, 0x74, 0x27, 0xa2, 0x62, 0x49, 0x66, 0x97, 0x75, 0x15, 0x85, 0xf9, 0x77, 0x5e, 0x2f, 0x53, 0x27, 0x9c, - 0x2d, 0xde, 0xb8, 0x0d, 0x80, 0xe9, 0x42, 0x7b, 0xaa, 0x93, 0x13, 0x93, 0xe2, 0xd7, 0x50, 0x1a, 0xd6, 0x09, 0x0d, - 0x14, 0x89, 0xfa, 0x79, 0xb4, 0x9e, 0x98, 0xa2, 0x38, 0xff, 0x11, 0x91, 0x0c, 0x4c, 0x12, 0xc8, 0x60, 0xb4, 0x7b, - 0xc5, 0x9a, 0x50, 0xac, 0xfd, 0xa4, 0x65, 0xd3, 0x99, 0xfb, 0x36, 0x83, 0x98, 0xbd, 0x1f, 0x04, 0x0f, 0x04, 0xff, - 0xe5, 0x56, 0x27, 0x8c, 0xa0, 0x04, 0xc3, 0xec, 0x30, 0xff, 0x89, 0xac, 0xba, 0x2d, 0xe8, 0xa8, 0x57, 0xe4, 0xaa, - 0x77, 0xe2, 0x52, 0x67, 0x12, 0x55, 0x4f, 0x7e, 0x9e, 0xb8, 0xfb, 0x56, 0x36, 0x46, 0x0b, 0xdc, 0xe7, 0xc8, 0x27, - 0x57, 0x6e, 0x66, 0xdb, 0xc8, 0xa8, 0xa6, 0x28, 0x12, 0x8b, 0x98, 0xca, 0xbc, 0x79, 0x8e, 0xfb, 0xf0, 0xaa, 0xb9, - 0x83, 0xef, 0xb7, 0x39, 0xd8, 0xca, 0xac, 0xdc, 0xe5, 0xf2, 0x6d, 0x7a, 0x68, 0xd0, 0xfd, 0xae, 0x73, 0xa4, 0x4b, - 0xef, 0xa6, 0x72, 0x5b, 0xb7, 0x3b, 0x53, 0xe7, 0x23, 0xf5, 0xd4, 0xf1, 0xf9, 0x99, 0x74, 0x63, 0xb7, 0xfe, 0x53, - 0x35, 0xc1, 0x4f, 0xf9, 0x02, 0xb4, 0x34, 0x55, 0x7c, 0x2c, 0x28, 0xa3, 0x16, 0xdd, 0xc1, 0x57, 0x6e, 0xb9, 0x15, - 0xf4, 0x2b, 0x9f, 0xab, 0xbc, 0x70, 0x8d, 0x5c, 0x3a, 0x7b, 0xe1, 0x04, 0x36, 0xf5, 0xe0, 0x9d, 0xf1, 0x57, 0xc1, - 0x25, 0xe0, 0xda, 0x10, 0x07, 0x23, 0x25, 0x89, 0xf7, 0xd5, 0xc0, 0x1b, 0x11, 0xf1, 0x8f, 0x82, 0xa1, 0x51, 0xfa, - 0x96, 0xfa, 0x18, 0x5b, 0x0c, 0xb3, 0x3e, 0xaa, 0x94, 0xab, 0xb3, 0xb9, 0xe0, 0xd4, 0x39, 0xfa, 0x13, 0x46, 0xdc, - 0xf3, 0x00, 0xe7, 0xac, 0xae, 0x7f, 0x06, 0xe7, 0xb5, 0xfd, 0xcc, 0x38, 0x1f, 0x8a, 0xa6, 0x44, 0xeb, 0xdd, 0x78, - 0xa7, 0x3c, 0x9b, 0x2d, 0xe7, 0x67, 0x15, 0x5e, 0x0d, 0xf7, 0x19, 0x9f, 0x5e, 0xfa, 0x1d, 0x98, 0x3c, 0x0e, 0xba, - 0xf4, 0xbe, 0x72, 0x78, 0xef, 0x4e, 0xc5, 0x4a, 0x15, 0x35, 0xe2, 0xd8, 0xa1, 0x7b, 0x8d, 0xc7, 0xbd, 0x0b, 0xcc, - 0x1a, 0xab, 0x93, 0xc3, 0x43, 0x6b, 0xab, 0x7c, 0x5d, 0x65, 0x84, 0x9d, 0xc4, 0x37, 0xcb, 0xc6, 0x94, 0x48, 0xb0, - 0xbf, 0x0d, 0x94, 0x63, 0x38, 0x10, 0xe1, 0x61, 0xc2, 0x9b, 0xac, 0xc2, 0xbc, 0x96, 0x8a, 0x32, 0xab, 0x1d, 0xfe, - 0x5a, 0x71, 0x8d, 0x68, 0x07, 0x4b, 0xae, 0xa4, 0x0d, 0x66, 0xd1, 0xa5, 0xa4, 0x61, 0x75, 0xc3, 0xa1, 0x5e, 0xdf, - 0x15, 0x5c, 0xd5, 0xb6, 0x66, 0x91, 0xfc, 0x45, 0xd9, 0x8e, 0x95, 0x08, 0x9b, 0x29, 0xf3, 0x3c, 0xfb, 0x3f, 0xa2, - 0x4a, 0x87, 0xdc, 0x02, 0xa6, 0xf6, 0x43, 0xba, 0x42, 0x52, 0x8c, 0x0d, 0xda, 0x4f, 0x4a, 0x57, 0x32, 0xef, 0xf8, - 0x8d, 0xc5, 0x75, 0xcb, 0x50, 0xe4, 0x62, 0x33, 0x56, 0x17, 0x1b, 0xc0, 0xc2, 0x2a, 0x07, 0xcc, 0x46, 0x14, 0xcd, - 0xa2, 0x6c, 0xca, 0xa3, 0xed, 0x16, 0xaf, 0x5b, 0xb4, 0xfa, 0xfb, 0x33, 0xd1, 0x33, 0x5b, 0x27, 0x55, 0x9d, 0x65, - 0xbd, 0x7f, 0x65, 0xc5, 0x5c, 0xe1, 0xe1, 0x47, 0x7b, 0x6e, 0xe7, 0x88, 0xce, 0xfb, 0xbb, 0x9b, 0xfe, 0x85, 0xdd, - 0xfc, 0x7f, 0x49, 0x37, 0x61, 0x86, 0xf5, 0xe4, 0xf6, 0xd3, 0x4b, 0xac, 0x09, 0xe7, 0x3f, 0xb2, 0x89, 0x61, 0xdb, - 0x15, 0xd4, 0x16, 0x35, 0x66, 0x9c, 0x12, 0x3c, 0xf6, 0x81, 0x0a, 0xed, 0x61, 0xe2, 0x0a, 0x61, 0x54, 0x79, 0xaa, - 0x44, 0xfa, 0x5c, 0xfc, 0xb2, 0x4d, 0x64, 0xd0, 0x19, 0x87, 0xb2, 0x81, 0x9d, 0xdb, 0xb5, 0xca, 0xcc, 0xd6, 0xd2, - 0xfa, 0x8f, 0x99, 0x62, 0xf3, 0x7f, 0xc0, 0x12, 0xf5, 0x90, 0x47, 0x7e, 0x59, 0xb5, 0x08, 0xef, 0x0d, 0xe5, 0xe6, - 0x21, 0xc8, 0x2d, 0x8b, 0x0e, 0x7f, 0x60, 0x3e, 0x40, 0x8e, 0x60, 0x8c, 0x1a, 0xb0, 0x52, 0x4e, 0x21, 0x97, 0xf9, - 0x71, 0xaa, 0xc9, 0x50, 0xcb, 0x72, 0x9d, 0xb1, 0x4a, 0x23, 0xaf, 0x59, 0x99, 0xa7, 0x59, 0x91, 0x6b, 0x94, 0x0d, - 0x15, 0xd7, 0x9f, 0x91, 0xa3, 0x51, 0x1b, 0xd0, 0x10, 0xbb, 0xe3, 0x9c, 0xd8, 0x28, 0x73, 0xd4, 0x71, 0x72, 0x4b, - 0x9e, 0x59, 0x57, 0x33, 0x5b, 0x89, 0x93, 0x8b, 0x77, 0x9b, 0xb1, 0x6d, 0x77, 0x34, 0x2e, 0x99, 0x27, 0x8e, 0x73, - 0x74, 0x7d, 0xa3, 0xcd, 0x9e, 0x97, 0xec, 0xb8, 0xf8, 0x3f, 0x48, 0x0e, 0xdd, 0x3c, 0x1a, 0x11, 0xcc, 0xc5, 0x25, - 0x45, 0xa9, 0xe9, 0xe6, 0x48, 0x02, 0x1b, 0x1e, 0xff, 0xb9, 0x89, 0xae, 0xf8, 0x78, 0x6e, 0x56, 0x46, 0x14, 0x5b, - 0x9c, 0xd8, 0x9f, 0xed, 0x61, 0xd5, 0x7a, 0x44, 0xc2, 0x81, 0xb3, 0xce, 0xfa, 0x60, 0x9f, 0xeb, 0xd2, 0xff, 0xe0, - 0x07, 0x36, 0x12, 0x82, 0x8d, 0x61, 0xf5, 0xce, 0xfe, 0xa7, 0x66, 0xc5, 0x85, 0xae, 0x35, 0x3b, 0x5e, 0xf8, 0x57, - 0x5c, 0xe1, 0x2d, 0x49, 0x65, 0x25, 0x37, 0x2e, 0x77, 0x2a, 0xe3, 0x05, 0x55, 0x3a, 0x66, 0x61, 0xe8, 0x58, 0x4c, - 0xaf, 0x0e, 0x4a, 0xaf, 0x08, 0x68, 0xa8, 0xce, 0xb9, 0xab, 0x95, 0xd9, 0x04, 0x97, 0x11, 0x92, 0x4a, 0x81, 0xbb, - 0xc2, 0x90, 0xe9, 0x9d, 0x6f, 0x86, 0x7e, 0x30, 0x14, 0x66, 0x6e, 0x40, 0xd8, 0x32, 0x41, 0xa5, 0xc3, 0x9a, 0x15, - 0x7b, 0x41, 0x9b, 0x0c, 0xe6, 0x3c, 0xa2, 0xde, 0x6b, 0xa4, 0xbf, 0x73, 0xc2, 0x05, 0x38, 0x4a, 0x81, 0xc2, 0x80, - 0x2e, 0x6f, 0x3c, 0x40, 0x72, 0x89, 0x10, 0x63, 0x0d, 0x85, 0xd4, 0x26, 0x7e, 0x39, 0xbf, 0xe2, 0x9e, 0xf7, 0xb3, - 0xe3, 0xac, 0xeb, 0x5b, 0x03, 0x79, 0x98, 0x5f, 0xbf, 0xbd, 0xce, 0x7a, 0x90, 0xb3, 0x21, 0x71, 0xb1, 0xb2, 0xf3, - 0x8a, 0x76, 0x76, 0x45, 0x5b, 0xea, 0x6a, 0x54, 0xe1, 0xb6, 0x86, 0x29, 0x52, 0x54, 0xb1, 0xe1, 0x7a, 0x1b, 0xba, - 0x20, 0xe9, 0x8b, 0x35, 0x85, 0x84, 0x19, 0xbb, 0xa6, 0x30, 0x95, 0x3b, 0xa1, 0x47, 0x67, 0xc3, 0x40, 0x5f, 0x6c, - 0xfd, 0x02, 0xf4, 0xa7, 0x8d, 0x8d, 0x36, 0x7d, 0x4f, 0x54, 0x46, 0xcc, 0x29, 0xfa, 0xbc, 0xc3, 0xec, 0xd3, 0xfe, - 0x44, 0x77, 0xb0, 0x5a, 0x5f, 0xc6, 0x5f, 0x56, 0x6c, 0xd4, 0xc7, 0xd6, 0x33, 0x26, 0x89, 0x53, 0xc9, 0xed, 0x41, - 0x49, 0x41, 0x66, 0xde, 0x44, 0x0d, 0x19, 0x29, 0xad, 0x39, 0x8f, 0x20, 0xfe, 0x77, 0xae, 0x98, 0x99, 0x98, 0xf6, - 0x63, 0x5c, 0x52, 0x1f, 0x7f, 0xf7, 0xc4, 0x5b, 0xbb, 0x77, 0x9a, 0xa1, 0x63, 0xf6, 0x00, 0x81, 0x9c, 0x57, 0x5e, - 0xba, 0x60, 0x68, 0x6e, 0xad, 0x54, 0xb3, 0xa6, 0x51, 0xfe, 0xb3, 0xbb, 0x32, 0x05, 0x03, 0xfb, 0x44, 0xad, 0x3f, - 0xdb, 0xe5, 0x66, 0xea, 0x1b, 0xb3, 0x57, 0x03, 0x4e, 0x04, 0x66, 0x36, 0xdd, 0x54, 0xfa, 0xaf, 0xfb, 0xfe, 0x3b, - 0x16, 0xa0, 0xd8, 0xd9, 0xc8, 0x1f, 0x9a, 0x8a, 0xe0, 0xc6, 0x77, 0x67, 0x2f, 0x86, 0x2d, 0x0a, 0x05, 0x5f, 0x46, - 0x99, 0xee, 0x32, 0xf2, 0x07, 0x0d, 0x6d, 0xf0, 0x4b, 0x7a, 0x63, 0x1b, 0x97, 0x61, 0x1f, 0xed, 0x61, 0x12, 0xbb, - 0x60, 0x68, 0x6b, 0x62, 0x41, 0x50, 0x35, 0x75, 0xde, 0x30, 0x22, 0xa1, 0x6f, 0xad, 0x95, 0xcf, 0xeb, 0xd8, 0x33, - 0xde, 0x71, 0x3e, 0x64, 0x62, 0x04, 0x7e, 0x8b, 0xb6, 0x5b, 0x12, 0xca, 0xb8, 0x74, 0x0c, 0x32, 0xb5, 0x47, 0x6d, - 0xc7, 0xc9, 0xb4, 0xed, 0x76, 0xd4, 0xee, 0xd1, 0xdd, 0xcd, 0x6f, 0x06, 0xa5, 0xed, 0x8e, 0xf0, 0x2d, 0xbc, 0x3a, - 0x73, 0xe4, 0x7e, 0xeb, 0xee, 0x24, 0x5b, 0xa0, 0x37, 0x33, 0x15, 0x14, 0x75, 0xc2, 0xc9, 0x33, 0xd6, 0xf8, 0xbf, - 0xd0, 0x54, 0xc1, 0x10, 0x98, 0xcc, 0x44, 0xb2, 0xdb, 0x82, 0x7c, 0x16, 0xfa, 0xfb, 0x14, 0x6e, 0x15, 0xb2, 0xb4, - 0x2d, 0x66, 0x08, 0xa7, 0x7a, 0xd0, 0x0c, 0x5e, 0x42, 0x81, 0x28, 0xed, 0x9d, 0xa1, 0x32, 0xe8, 0x41, 0xa5, 0x03, - 0x99, 0x28, 0x06, 0x35, 0x4b, 0x61, 0xca, 0x9b, 0x90, 0x7a, 0xf7, 0x7b, 0xbd, 0xf5, 0x77, 0xf9, 0xde, 0x8c, 0x22, - 0x1e, 0xf5, 0xd6, 0x49, 0x02, 0x82, 0x5f, 0x71, 0x20, 0x13, 0xe5, 0xf5, 0x92, 0x18, 0xb1, 0x8e, 0xc7, 0x49, 0xae, - 0x16, 0x1d, 0xaf, 0xc4, 0x39, 0x25, 0x15, 0x42, 0xce, 0x01, 0x0c, 0x13, 0x05, 0xee, 0xe5, 0x38, 0x82, 0xf5, 0x80, - 0x67, 0x72, 0x45, 0x3d, 0x1b, 0x8b, 0xbb, 0xfd, 0xef, 0xe5, 0xd5, 0xed, 0x9a, 0xf6, 0x36, 0x49, 0x01, 0x56, 0x5d, - 0x54, 0x82, 0xef, 0xfe, 0xfc, 0x29, 0xe4, 0xb1, 0x64, 0x87, 0x5a, 0x2a, 0x73, 0x30, 0x5b, 0x74, 0x1d, 0x72, 0xd6, - 0xa7, 0xaa, 0x3a, 0x36, 0x39, 0xa0, 0x86, 0xd3, 0xb4, 0x73, 0xc1, 0x78, 0x9c, 0xb0, 0x86, 0x73, 0xc2, 0x1a, 0x76, - 0xa8, 0x68, 0x23, 0x8c, 0x6e, 0x68, 0x31, 0x96, 0xb4, 0xc6, 0x7c, 0x3b, 0x20, 0x24, 0xf8, 0x7a, 0xa1, 0x95, 0x8b, - 0x8c, 0xe3, 0x8f, 0x2d, 0x06, 0x13, 0xec, 0x12, 0x2b, 0xdd, 0x84, 0x7f, 0x0d, 0xcf, 0x95, 0xbe, 0x95, 0x27, 0x71, - 0x73, 0x6f, 0xce, 0xe1, 0x44, 0xe3, 0x51, 0x93, 0x8c, 0xfc, 0x94, 0xf5, 0xa8, 0x94, 0xe4, 0x3f, 0x37, 0x8f, 0x81, - 0x33, 0x73, 0x8b, 0x7d, 0x25, 0x30, 0x26, 0x54, 0x3a, 0x96, 0xf1, 0x2f, 0x11, 0xf5, 0xd9, 0x68, 0xc4, 0x0c, 0x0a, - 0xe3, 0x5c, 0x25, 0x56, 0xe2, 0x3e, 0xdb, 0xa2, 0x97, 0xf2, 0xae, 0x31, 0x46, 0x25, 0x4c, 0xc5, 0x2f, 0x46, 0xf6, - 0x18, 0xa9, 0xb7, 0x73, 0xb6, 0xfd, 0x5c, 0x13, 0xdd, 0x73, 0x3a, 0x90, 0x04, 0x8d, 0x4b, 0x66, 0x0a, 0x90, 0xc4, - 0x04, 0x63, 0x72, 0x07, 0x2c, 0xda, 0xa6, 0x75, 0x9e, 0xc2, 0xab, 0x56, 0xe3, 0x49, 0x65, 0x7b, 0xdf, 0x65, 0x65, - 0x2e, 0xdb, 0x8e, 0x4e, 0x5b, 0x12, 0x24, 0x8d, 0x1a, 0xa7, 0x48, 0x48, 0xd5, 0xd3, 0xac, 0x0c, 0x0b, 0x84, 0xb5, - 0xe2, 0x9c, 0xbe, 0xb9, 0x35, 0x99, 0x9d, 0x17, 0xb1, 0x57, 0x78, 0x15, 0x85, 0x08, 0x6e, 0x67, 0x13, 0x89, 0x0f, - 0x63, 0xcb, 0x3a, 0x59, 0xc8, 0xd2, 0xb7, 0x6e, 0xad, 0x4b, 0xc0, 0x0f, 0xde, 0xea, 0xb7, 0xfb, 0xf1, 0x38, 0xb4, - 0x30, 0xd6, 0x47, 0xb8, 0xf8, 0xa8, 0x17, 0x2c, 0xad, 0x7c, 0x89, 0x08, 0x4a, 0x9b, 0xa5, 0xd7, 0xbf, 0x60, 0xb1, - 0x29, 0x2f, 0x57, 0x2c, 0x34, 0x36, 0x74, 0x33, 0x0d, 0xd5, 0x32, 0x31, 0x27, 0x15, 0x55, 0x31, 0xc7, 0x00, 0x3d, - 0xee, 0x20, 0x73, 0xcb, 0x22, 0x6b, 0xd2, 0xc3, 0x59, 0x09, 0xcc, 0xd7, 0x60, 0xe7, 0x38, 0x03, 0xea, 0xd8, 0xa4, - 0xea, 0x17, 0x0b, 0xa0, 0x24, 0x6e, 0xe0, 0x5b, 0x21, 0x77, 0xa1, 0xca, 0x1e, 0x29, 0xa4, 0xb0, 0x0e, 0x2c, 0xe1, - 0xac, 0x60, 0xc5, 0xd8, 0x3e, 0x6c, 0xe6, 0x8f, 0x51, 0x6f, 0x01, 0xd3, 0x43, 0x08, 0xf3, 0xdd, 0x1d, 0xb8, 0x11, - 0x1d, 0xad, 0xc9, 0xe4, 0x1e, 0x27, 0xc8, 0xa2, 0x9f, 0xfb, 0x25, 0x31, 0x14, 0x4f, 0xc8, 0xcb, 0x51, 0x33, 0x16, - 0xb5, 0x60, 0x5a, 0xa6, 0xcd, 0x2d, 0xdf, 0x7d, 0x6d, 0x23, 0xaa, 0x47, 0xc4, 0xa5, 0x42, 0x48, 0x1d, 0x14, 0xe8, - 0x0e, 0x73, 0xa9, 0xeb, 0xc9, 0xb3, 0x45, 0xf1, 0x2c, 0x9b, 0xae, 0x12, 0xfc, 0xe9, 0xe3, 0x0d, 0xb5, 0xbd, 0x09, - 0xa8, 0xf4, 0x5e, 0x77, 0x9c, 0x93, 0xde, 0x51, 0x89, 0x88, 0x26, 0x19, 0x7f, 0xfb, 0xc8, 0xbc, 0x05, 0x91, 0x58, - 0xeb, 0xe1, 0xd2, 0xeb, 0xb7, 0xaf, 0x51, 0xb0, 0x6a, 0x22, 0x9c, 0xbd, 0xa5, 0x49, 0x1c, 0xbc, 0x14, 0x21, 0x19, - 0x8a, 0x60, 0xe4, 0xa3, 0x82, 0xd8, 0x8a, 0xad, 0x12, 0x75, 0xb5, 0x86, 0x40, 0xc4, 0x39, 0xd8, 0x20, 0xb3, 0x8c, - 0xce, 0x99, 0xd7, 0xbe, 0x3c, 0x44, 0xf1, 0xd2, 0x14, 0xf5, 0xbf, 0x5a, 0x16, 0x7e, 0xf4, 0x70, 0xe0, 0x75, 0x64, - 0xe5, 0xac, 0x77, 0xbd, 0x54, 0x6e, 0xcb, 0x3a, 0x6e, 0xad, 0x7a, 0x4f, 0x9e, 0x20, 0xa7, 0xd1, 0xa6, 0x97, 0xe2, - 0xd6, 0x21, 0xa9, 0x31, 0xbc, 0x56, 0xb5, 0xa8, 0x8f, 0x0b, 0x77, 0xd8, 0x8b, 0x5a, 0xa9, 0x77, 0x30, 0x11, 0x5d, - 0xf7, 0xed, 0x9f, 0x88, 0x6a, 0xc8, 0x98, 0x8e, 0x35, 0xe4, 0x0e, 0x6c, 0xc1, 0xf4, 0x54, 0xd2, 0x77, 0x02, 0xf1, - 0xf8, 0x48, 0xb2, 0xab, 0xff, 0x94, 0xd1, 0xfd, 0x85, 0x8c, 0x81, 0x91, 0xd1, 0x1d, 0x61, 0x2d, 0xc2, 0xbd, 0x34, - 0xe8, 0x18, 0x23, 0x94, 0x4f, 0x89, 0x66, 0x66, 0xd9, 0x6d, 0x5e, 0x90, 0xd8, 0xe7, 0x5a, 0xcd, 0xde, 0x72, 0x9d, - 0x48, 0xd0, 0xa2, 0x04, 0xe2, 0xe5, 0x96, 0x19, 0x17, 0x80, 0xae, 0x8d, 0x9b, 0x14, 0x71, 0xb8, 0xb1, 0xd9, 0xdb, - 0x00, 0xa0, 0x7d, 0xfe, 0xfd, 0x4c, 0xe9, 0xe2, 0x76, 0x41, 0x09, 0x9b, 0x1f, 0x2c, 0x26, 0x8b, 0x5b, 0x19, 0x14, - 0x62, 0x23, 0x04, 0x0f, 0x64, 0x13, 0x8d, 0xdd, 0x7a, 0x8a, 0xd8, 0x3c, 0x5f, 0x20, 0x6d, 0x51, 0x78, 0x26, 0x67, - 0x93, 0xfd, 0x8b, 0x76, 0xb0, 0x81, 0xb1, 0x6e, 0x52, 0x94, 0xdf, 0x95, 0xa6, 0xa3, 0x8c, 0xda, 0xc7, 0x2f, 0x37, - 0x5c, 0x94, 0xa5, 0x26, 0x30, 0x9a, 0x46, 0xdd, 0xf2, 0xf7, 0x89, 0x13, 0x0c, 0x5d, 0x19, 0x01, 0xca, 0xb9, 0x94, - 0x09, 0x9f, 0xb3, 0x6f, 0x90, 0x16, 0x00, 0xf2, 0x9b, 0x1f, 0xb5, 0xe3, 0x63, 0x73, 0xbd, 0xfc, 0xd2, 0xb6, 0xa5, - 0x44, 0xf4, 0x5f, 0xda, 0x2a, 0xdb, 0xb1, 0x0f, 0x54, 0xf1, 0x30, 0x6a, 0x44, 0xcb, 0x9a, 0x0f, 0x59, 0xfb, 0x14, - 0x0f, 0x9b, 0x7b, 0x6f, 0x76, 0xa6, 0xc8, 0x86, 0xda, 0x25, 0xfb, 0xcb, 0x4b, 0x3a, 0x2f, 0xaf, 0xd6, 0x0c, 0x5e, - 0xed, 0x11, 0xea, 0x2a, 0x02, 0x05, 0x8f, 0xc1, 0x01, 0xbe, 0x36, 0xfb, 0x9e, 0x2d, 0x28, 0xf0, 0xcf, 0x8e, 0x9d, - 0xbf, 0x3c, 0x9f, 0x43, 0x02, 0x59, 0x9f, 0x35, 0x49, 0x04, 0x44, 0x24, 0x74, 0x3a, 0xdb, 0x1a, 0x82, 0x3c, 0x8c, - 0x2c, 0x1e, 0xb1, 0x59, 0xc6, 0x7f, 0xb1, 0x98, 0x8b, 0xcb, 0x7b, 0x36, 0xb9, 0x9f, 0x9b, 0xb7, 0xce, 0x00, 0xa9, - 0x6d, 0x9a, 0xc9, 0x48, 0x75, 0x64, 0x1a, 0x40, 0x05, 0xed, 0x85, 0x52, 0x4a, 0x46, 0xa9, 0x1c, 0x23, 0xb6, 0x6b, - 0x23, 0xe3, 0xe2, 0x64, 0x49, 0xc3, 0xb0, 0x24, 0xf8, 0x35, 0x11, 0x04, 0xbd, 0x54, 0x44, 0xf5, 0x70, 0x51, 0xca, - 0xdb, 0x21, 0x8f, 0x06, 0xd0, 0x52, 0xe3, 0x6d, 0x92, 0xa7, 0xdd, 0x8b, 0x73, 0x17, 0x59, 0x71, 0xf3, 0xa7, 0xc4, - 0x0f, 0x95, 0x63, 0x3c, 0x29, 0x90, 0x18, 0xe7, 0x5d, 0xb9, 0xf3, 0xa0, 0x0e, 0xc4, 0x1c, 0x13, 0x3c, 0xd2, 0xb3, - 0xaa, 0x3d, 0x98, 0x19, 0x68, 0x53, 0x1a, 0x4d, 0x15, 0xb5, 0x01, 0xe5, 0xff, 0x80, 0xbe, 0xca, 0xa7, 0xe5, 0x91, - 0x6b, 0x10, 0x86, 0xd2, 0x7a, 0x4b, 0xc3, 0x4b, 0x42, 0x68, 0x71, 0xae, 0x4c, 0x32, 0x08, 0xbc, 0xf1, 0xa1, 0xd7, - 0x35, 0x7e, 0x10, 0x25, 0x40, 0x73, 0xe6, 0x27, 0x1f, 0x3e, 0x9e, 0x03, 0x14, 0xce, 0x5a, 0x32, 0xfa, 0xb3, 0xab, - 0x09, 0x4b, 0xba, 0x5d, 0x34, 0xbb, 0x11, 0xca, 0x57, 0x29, 0x58, 0x5a, 0x58, 0x8a, 0xde, 0xa2, 0x3c, 0x30, 0x6c, - 0xb7, 0xb2, 0x7d, 0xfb, 0x5f, 0x1e, 0xde, 0x2b, 0x74, 0x91, 0xb0, 0x1d, 0xe2, 0xa7, 0xa8, 0xe9, 0x2f, 0x3e, 0x9c, - 0x9e, 0x8c, 0x61, 0xbb, 0x2b, 0x61, 0xee, 0x30, 0xcf, 0xb1, 0xbf, 0x74, 0xe4, 0x86, 0xb6, 0x12, 0x31, 0xf9, 0x5a, - 0x36, 0x61, 0x11, 0x07, 0x0c, 0x64, 0xae, 0x06, 0xb9, 0x83, 0x23, 0x04, 0xa6, 0xd6, 0x7c, 0xf2, 0xff, 0x54, 0x2d, - 0x1e, 0x9f, 0x2d, 0x8b, 0x4a, 0x82, 0x7c, 0x2b, 0xed, 0xf3, 0xd8, 0x87, 0xa4, 0x1d, 0xd8, 0xf7, 0x08, 0x16, 0xbd, - 0xdd, 0x61, 0x51, 0x68, 0xa1, 0x83, 0xb8, 0xa4, 0xce, 0xa7, 0xf0, 0xea, 0xe5, 0x32, 0x85, 0xd0, 0x29, 0x0b, 0x3c, - 0x5f, 0x45, 0x38, 0xa6, 0xf7, 0xc7, 0x03, 0x95, 0x05, 0xa5, 0x5c, 0x4e, 0xf0, 0x29, 0x6f, 0xea, 0x70, 0x06, 0xd4, - 0x90, 0xf6, 0xa9, 0x70, 0xc5, 0x3f, 0x4a, 0x59, 0x17, 0x3a, 0xb3, 0x90, 0xaa, 0x30, 0xd9, 0x91, 0xf0, 0xbf, 0x54, - 0xcc, 0x90, 0xe1, 0x85, 0x50, 0xa5, 0x0d, 0x7c, 0x6d, 0x8b, 0xae, 0x94, 0x17, 0x6d, 0x0b, 0x7d, 0x2c, 0x76, 0x65, - 0x4e, 0x00, 0xba, 0x01, 0x5a, 0x7b, 0xed, 0x82, 0xbb, 0x1b, 0xee, 0x65, 0x9f, 0x15, 0xf7, 0x6e, 0xda, 0x00, 0x07, - 0x5f, 0x20, 0xa7, 0xbe, 0x7f, 0x45, 0x71, 0xfe, 0x69, 0x2b, 0x1e, 0x2d, 0xc4, 0x94, 0x80, 0x09, 0x24, 0xe4, 0x1b, - 0x3e, 0xb6, 0x66, 0xc4, 0x3e, 0x7e, 0x08, 0x37, 0x4a, 0x09, 0x2b, 0x8d, 0x3c, 0x38, 0xca, 0xed, 0x37, 0x55, 0x86, - 0xe4, 0xb6, 0x9c, 0x83, 0xc2, 0x10, 0x0b, 0x07, 0xdc, 0x65, 0xae, 0x6c, 0x7f, 0xbc, 0x4a, 0x8f, 0xc2, 0x9e, 0xb8, - 0x50, 0xb1, 0x18, 0x6a, 0x64, 0xc4, 0x2b, 0x1e, 0xaa, 0xb3, 0xd2, 0xc4, 0x00, 0x19, 0x61, 0x80, 0x8e, 0x29, 0x6d, - 0x84, 0x40, 0x09, 0x01, 0x5b, 0x7e, 0xa8, 0xa3, 0x42, 0x13, 0xa1, 0x08, 0xa1, 0x25, 0xd2, 0x1c, 0x1d, 0x64, 0x65, - 0x86, 0xa4, 0xd2, 0x63, 0x76, 0x4c, 0x07, 0x96, 0x05, 0x58, 0x52, 0x29, 0x0a, 0x20, 0x9f, 0x8c, 0x51, 0xab, 0x88, - 0x50, 0xe2, 0xae, 0xbc, 0x4c, 0x1a, 0x0e, 0x58, 0xc3, 0x5c, 0x34, 0x17, 0x4b, 0xd6, 0x75, 0x38, 0x94, 0x21, 0x4d, - 0xae, 0x5a, 0x05, 0x79, 0xa7, 0x3f, 0x4f, 0x63, 0xce, 0x57, 0x04, 0x42, 0x9b, 0xfb, 0x91, 0xcb, 0x05, 0xc2, 0x8f, - 0x74, 0x6c, 0x8c, 0x91, 0x91, 0xb4, 0x76, 0x20, 0x75, 0x51, 0x22, 0x24, 0xc4, 0x95, 0x74, 0x41, 0x73, 0x3e, 0x14, - 0x22, 0x3e, 0x3b, 0x61, 0xae, 0x0f, 0x12, 0xb3, 0x44, 0xe5, 0xdf, 0x37, 0xcb, 0x61, 0xf5, 0x42, 0xf0, 0xb0, 0xd8, - 0xae, 0xaa, 0x1c, 0x28, 0x24, 0x12, 0xd6, 0xa8, 0x13, 0xe6, 0xce, 0x1b, 0xcb, 0xdf, 0x14, 0xc1, 0x9e, 0x27, 0x64, - 0x26, 0x18, 0xa5, 0x57, 0x51, 0xae, 0x54, 0xef, 0x94, 0x39, 0x8c, 0xdc, 0xf0, 0xee, 0xa6, 0xf8, 0xc1, 0x81, 0xbc, - 0x67, 0x53, 0x7a, 0xc4, 0xdb, 0xfd, 0x50, 0x4b, 0x9c, 0x53, 0x24, 0x39, 0x41, 0x29, 0xe8, 0xfe, 0xc3, 0x6b, 0x47, - 0x25, 0x31, 0xfe, 0xd0, 0xa2, 0xf4, 0x5b, 0x8b, 0xa7, 0xb9, 0x96, 0x33, 0x6d, 0xd2, 0xcc, 0x9c, 0x6f, 0x46, 0x15, - 0x9b, 0x2b, 0x63, 0x68, 0x5d, 0x70, 0x20, 0x00, 0x37, 0x83, 0x75, 0x2a, 0xad, 0xcf, 0xf5, 0x07, 0x08, 0x7d, 0xe3, - 0x3e, 0x28, 0xb3, 0x1d, 0x3c, 0x1a, 0x63, 0xc8, 0xeb, 0x67, 0x57, 0x75, 0xd0, 0x65, 0x44, 0x82, 0x00, 0x16, 0x7a, - 0xc8, 0xe1, 0x95, 0xba, 0x9c, 0xd9, 0xca, 0xec, 0xd1, 0xe6, 0xb5, 0x1c, 0x6f, 0x1d, 0x69, 0x38, 0x2e, 0x8e, 0x67, - 0x1f, 0x2c, 0x9d, 0x47, 0xe8, 0x48, 0xca, 0x8d, 0xf7, 0x4a, 0x20, 0xdf, 0x10, 0x19, 0xa8, 0xe7, 0xa2, 0x02, 0xb0, - 0x2b, 0x8b, 0xaa, 0xe4, 0x75, 0x78, 0xe8, 0xf9, 0x38, 0x32, 0x8f, 0x18, 0xe3, 0x10, 0x55, 0x46, 0x1e, 0x9d, 0xdc, - 0x2e, 0x2d, 0x32, 0x6a, 0x2e, 0x98, 0x5a, 0xcd, 0xbb, 0xea, 0x94, 0x07, 0xb2, 0xc9, 0xd7, 0x2b, 0x2d, 0xb4, 0x1e, - 0x89, 0x15, 0xdd, 0xac, 0x8a, 0x7a, 0x58, 0x20, 0x62, 0xbd, 0xff, 0x04, 0x91, 0x47, 0x2c, 0x1f, 0x64, 0xd4, 0x22, - 0x6d, 0xae, 0xc4, 0x4a, 0x29, 0x60, 0x76, 0x81, 0x42, 0x2b, 0xef, 0x10, 0x5c, 0xf9, 0x4d, 0x85, 0x44, 0xda, 0xc5, - 0x5d, 0x07, 0xea, 0x11, 0xbf, 0x35, 0xb2, 0x59, 0x1f, 0xa8, 0xe5, 0x7c, 0x2b, 0x2a, 0x1a, 0x22, 0x23, 0xd2, 0xd1, - 0x6f, 0x38, 0x01, 0xc3, 0x82, 0x0c, 0xe9, 0xf4, 0x3c, 0xf5, 0x58, 0xa0, 0xc5, 0x50, 0xe5, 0x54, 0x8c, 0x65, 0x52, - 0xdd, 0x0a, 0x96, 0x29, 0xb3, 0x50, 0x12, 0x5d, 0x41, 0xcb, 0xec, 0x35, 0xd8, 0x7c, 0xcf, 0x6a, 0x5b, 0x64, 0x44, - 0xc2, 0x35, 0xc2, 0x1f, 0x86, 0x31, 0x00, 0xaf, 0x12, 0xa5, 0xf3, 0xc0, 0x68, 0xc5, 0x24, 0xe6, 0x71, 0x0a, 0xaf, - 0x9b, 0x2a, 0x79, 0x81, 0x5b, 0xf3, 0xd4, 0xd4, 0x58, 0x7e, 0xff, 0xfa, 0xfb, 0x41, 0x53, 0x65, 0xad, 0x40, 0x7e, - 0xb2, 0x6e, 0xfd, 0xfb, 0x2e, 0xf7, 0x20, 0x6f, 0xd3, 0xfb, 0x7e, 0x1c, 0xf2, 0x0d, 0x04, 0x82, 0x51, 0x0a, 0xd3, - 0xc5, 0xfa, 0xb4, 0xc2, 0xe8, 0x7a, 0x49, 0xbb, 0x32, 0x7d, 0x40, 0xc2, 0xfb, 0x7a, 0xfb, 0x19, 0xa1, 0xcc, 0x12, - 0xfb, 0x50, 0x10, 0xc5, 0x4a, 0x94, 0x47, 0xe6, 0x67, 0x73, 0x6f, 0x45, 0x5c, 0x30, 0x53, 0xfd, 0x62, 0xf2, 0x30, - 0x24, 0x1c, 0x98, 0x99, 0x08, 0x07, 0xd6, 0xb4, 0xf0, 0xec, 0x6a, 0xc1, 0x9f, 0x96, 0x12, 0xe0, 0x11, 0xeb, 0x2a, - 0xfd, 0xbd, 0x8c, 0xa5, 0x18, 0xb1, 0xbd, 0x9d, 0xa1, 0xf4, 0x9c, 0x59, 0x07, 0x1d, 0x5e, 0x89, 0x82, 0x2d, 0xce, - 0xf4, 0x03, 0x33, 0x39, 0x8a, 0x0b, 0xaa, 0x25, 0xfc, 0xed, 0xad, 0xe2, 0xbe, 0x54, 0x3c, 0xa7, 0xb0, 0xef, 0x49, - 0xbe, 0xf8, 0x20, 0xed, 0xbd, 0xa8, 0x82, 0x56, 0x38, 0xb5, 0xc1, 0x0d, 0xf1, 0xd1, 0x9d, 0xf2, 0x50, 0x29, 0x42, - 0x08, 0xa3, 0xe8, 0x17, 0xf5, 0x08, 0x79, 0x81, 0xd7, 0xcb, 0xb7, 0x75, 0x7d, 0x48, 0x98, 0x82, 0xb8, 0xbe, 0xdb, - 0xc2, 0x19, 0x7d, 0x6a, 0x46, 0xcd, 0xdd, 0x71, 0xf5, 0x17, 0xea, 0x16, 0xef, 0x40, 0xb4, 0xc3, 0x74, 0x0f, 0x8f, - 0xeb, 0xfa, 0x68, 0x72, 0xd4, 0x85, 0x26, 0x6e, 0x35, 0xdb, 0xaf, 0xb4, 0x64, 0x9e, 0x06, 0x30, 0x68, 0x8c, 0xfa, - 0x7d, 0xc9, 0x1e, 0x8d, 0xef, 0x78, 0x4b, 0x64, 0x43, 0xb8, 0x0d, 0xe8, 0x70, 0x70, 0xa1, 0xa7, 0xfe, 0x46, 0xf6, - 0x6b, 0xb9, 0x04, 0xc7, 0x4b, 0xf1, 0x63, 0xdd, 0xf0, 0x47, 0x99, 0xd0, 0xec, 0x24, 0xa6, 0xc1, 0xfd, 0xb6, 0xb8, - 0xb2, 0xc2, 0x65, 0x12, 0x0a, 0x0b, 0x68, 0x40, 0x60, 0x01, 0x45, 0xd0, 0xe7, 0x30, 0x49, 0x94, 0x3d, 0x9c, 0xb3, - 0xdd, 0xdb, 0x7b, 0x71, 0x4c, 0x24, 0x9d, 0xd2, 0xe4, 0xe8, 0x07, 0x5e, 0x4c, 0x67, 0x51, 0x93, 0x68, 0xa4, 0x5f, - 0x31, 0x27, 0x2f, 0x1b, 0xe9, 0xc8, 0x00, 0x31, 0x67, 0x15, 0xa2, 0x0b, 0x69, 0xdf, 0x3f, 0x23, 0x32, 0xa0, 0x62, - 0x50, 0x37, 0xc3, 0x0e, 0xb1, 0x29, 0xe7, 0xb5, 0xeb, 0x07, 0x46, 0x4b, 0x7c, 0xb1, 0x3c, 0xca, 0xb0, 0xfc, 0x31, - 0x1f, 0xa3, 0xef, 0xbc, 0x95, 0x65, 0xe8, 0xc2, 0x72, 0x7e, 0x17, 0x93, 0xa5, 0x3a, 0x5c, 0x3d, 0x09, 0xe9, 0x7e, - 0x6a, 0xcd, 0x4b, 0xff, 0xb3, 0xe9, 0x82, 0xb4, 0xcf, 0x3c, 0xa4, 0x7e, 0x92, 0xc7, 0x68, 0xf7, 0x55, 0x2f, 0xac, - 0xd3, 0x85, 0x11, 0x66, 0xfa, 0xa8, 0x9a, 0x85, 0x0f, 0x55, 0x66, 0xcb, 0xc6, 0xd3, 0x52, 0xcc, 0x1f, 0x1d, 0xc1, - 0x1a, 0x82, 0x26, 0x24, 0x1b, 0xf7, 0x25, 0xf1, 0x83, 0xea, 0x82, 0xf1, 0x60, 0x87, 0xe5, 0xf5, 0xfc, 0x66, 0xd7, - 0xbe, 0xd3, 0xf2, 0xa9, 0xe0, 0x1f, 0x3c, 0xc4, 0xca, 0x9f, 0x0a, 0x87, 0x25, 0x2e, 0xbe, 0xb0, 0x52, 0x67, 0xbd, - 0x28, 0xa4, 0xad, 0xd5, 0xac, 0xa8, 0x65, 0x37, 0x64, 0xe5, 0x55, 0xde, 0xf2, 0x52, 0x0a, 0x7e, 0x4d, 0x45, 0x4e, - 0x72, 0x9d, 0x72, 0x31, 0x18, 0x78, 0x33, 0x27, 0xfd, 0x7a, 0x42, 0x1b, 0xb9, 0x81, 0x71, 0xfa, 0x3a, 0xb6, 0x2e, - 0x90, 0x04, 0x76, 0xf5, 0x91, 0x0b, 0x4f, 0x20, 0x91, 0xd5, 0xc7, 0x73, 0x36, 0xd6, 0xcd, 0x4e, 0xbe, 0x97, 0x77, - 0x19, 0x47, 0xf0, 0x4c, 0x21, 0xde, 0x9c, 0xd7, 0x06, 0xfc, 0x23, 0xef, 0x70, 0x6e, 0xe5, 0x7d, 0x50, 0x8d, 0xa1, - 0x35, 0x6c, 0x69, 0xe0, 0xab, 0x0b, 0x8c, 0x61, 0x02, 0xd5, 0x24, 0x08, 0x8e, 0xd6, 0x62, 0xbb, 0x20, 0x38, 0x96, - 0x51, 0x78, 0xb1, 0x3a, 0xe5, 0x17, 0x66, 0x53, 0x11, 0x45, 0x26, 0xfc, 0xc2, 0x0e, 0xd5, 0x66, 0x84, 0x43, 0xfc, - 0x58, 0x11, 0xe5, 0x43, 0x8d, 0x07, 0x20, 0x0e, 0x60, 0x7a, 0xd3, 0x88, 0x68, 0x7f, 0x8d, 0x1a, 0x05, 0x35, 0x3c, - 0x73, 0x97, 0xde, 0x59, 0x23, 0x2e, 0xeb, 0x6f, 0x0a, 0xcc, 0x2b, 0xb1, 0x6c, 0xaf, 0xac, 0xcb, 0x92, 0xf3, 0x3d, - 0x68, 0xe2, 0xb8, 0xd3, 0xce, 0x92, 0xb3, 0xe4, 0x00, 0x6d, 0xbb, 0xa7, 0xcd, 0x7c, 0x42, 0x72, 0x71, 0xb5, 0xeb, - 0x14, 0xa4, 0x32, 0xaf, 0x62, 0x23, 0x95, 0xe2, 0xbc, 0x73, 0x09, 0x38, 0xdc, 0x4f, 0xf1, 0xff, 0x55, 0xa2, 0xbe, - 0x9b, 0x5f, 0x10, 0xc6, 0x76, 0x52, 0xbf, 0x1c, 0x36, 0x6d, 0x61, 0x76, 0x70, 0xdd, 0xe2, 0xa6, 0xdd, 0x10, 0x55, - 0xd9, 0x5b, 0x6b, 0xbe, 0x34, 0x0f, 0x79, 0x61, 0x39, 0xb3, 0xd0, 0xea, 0xf3, 0xb8, 0x65, 0x44, 0xf9, 0xd4, 0x85, - 0xc3, 0xb1, 0xd0, 0x90, 0xcb, 0x9b, 0x43, 0x5d, 0xe4, 0xc7, 0xa4, 0xed, 0x01, 0x03, 0x49, 0x3d, 0xd1, 0xc6, 0x87, - 0x6e, 0xe6, 0xb6, 0x3b, 0x03, 0xe9, 0x7a, 0x39, 0x0d, 0x25, 0xb3, 0x18, 0xb8, 0x70, 0x34, 0xe6, 0xa9, 0x43, 0xa7, - 0x5d, 0xb1, 0x11, 0xd6, 0x1d, 0x0c, 0x57, 0x62, 0x54, 0x75, 0x18, 0xbb, 0xa6, 0xd5, 0x39, 0x56, 0xaa, 0xc7, 0xde, - 0x67, 0x1d, 0x91, 0xe0, 0x09, 0x05, 0x47, 0x1e, 0x78, 0x86, 0xcf, 0xea, 0xa0, 0xc3, 0xa3, 0x8e, 0xc0, 0xa1, 0xba, - 0x40, 0x5f, 0x1d, 0xc6, 0x40, 0x39, 0x82, 0x50, 0x44, 0xbe, 0x7b, 0xa0, 0x0e, 0xe1, 0x6b, 0x7e, 0x82, 0x99, 0x52, - 0x7a, 0x3e, 0x66, 0x7b, 0xf0, 0xe5, 0x80, 0xfb, 0xfd, 0x17, 0x9e, 0xd2, 0x35, 0x8c, 0xc3, 0x0f, 0xbf, 0xd5, 0x62, - 0xf9, 0xfd, 0x00, 0xf3, 0xed, 0x20, 0xd5, 0x25, 0x1c, 0xe5, 0x2a, 0xc0, 0x1f, 0x6d, 0x19, 0x77, 0x0d, 0x86, 0xf5, - 0x11, 0x14, 0x11, 0x1e, 0x71, 0x30, 0xdc, 0x2c, 0x05, 0x80, 0xe2, 0x3c, 0xac, 0x80, 0xc8, 0x42, 0x34, 0x3f, 0x2f, - 0x97, 0x58, 0x57, 0x65, 0x68, 0x4b, 0x4b, 0x36, 0x8f, 0x13, 0x31, 0x6c, 0x26, 0x49, 0x25, 0x44, 0xaf, 0x88, 0x18, - 0x11, 0x33, 0x43, 0xeb, 0xa5, 0xfd, 0x9e, 0xba, 0x2a, 0x08, 0xa3, 0xd6, 0x6d, 0xb8, 0xd7, 0xf5, 0x55, 0x2f, 0x6a, - 0xb5, 0x5f, 0x6b, 0x65, 0x00, 0xfb, 0x96, 0x7c, 0x80, 0x22, 0x09, 0x5b, 0xda, 0xf1, 0x6f, 0x07, 0x72, 0xd1, 0x3f, - 0x84, 0xb0, 0x89, 0x4d, 0x90, 0x73, 0x78, 0xa9, 0x75, 0xf6, 0x36, 0x10, 0xc2, 0x24, 0xd6, 0x6a, 0x3d, 0x82, 0x17, - 0x4d, 0x00, 0xa9, 0xd0, 0x3e, 0x63, 0xf9, 0x88, 0x54, 0x9c, 0x3f, 0x1f, 0x5a, 0x36, 0xb7, 0x3f, 0xe5, 0x13, 0x2b, - 0x47, 0x9c, 0xad, 0x5f, 0x2c, 0x49, 0x56, 0xf0, 0x5d, 0x22, 0xc1, 0x37, 0x96, 0xa1, 0xfa, 0xb4, 0x0f, 0xa0, 0x49, - 0x21, 0xd0, 0xc1, 0xe5, 0x5d, 0x8d, 0x0c, 0xb5, 0x58, 0x46, 0x75, 0xb4, 0xc7, 0x22, 0xd3, 0x17, 0x2f, 0xca, 0xea, - 0xb3, 0x08, 0x0d, 0x27, 0x16, 0xc3, 0x28, 0x95, 0x5e, 0x6c, 0xd1, 0xc6, 0x9f, 0xf4, 0x3f, 0xe6, 0x06, 0xa5, 0xea, - 0x78, 0x85, 0x5b, 0x35, 0x54, 0x87, 0xae, 0xd0, 0x1b, 0xd9, 0xca, 0xb1, 0x7f, 0x79, 0x67, 0x51, 0xc7, 0x9a, 0x36, - 0x08, 0x5e, 0x07, 0xfd, 0xcd, 0x14, 0x9c, 0xec, 0xfc, 0x9a, 0xe8, 0x14, 0x06, 0x08, 0x0a, 0x66, 0x08, 0xf6, 0x19, - 0xcd, 0xa6, 0xa5, 0x74, 0x67, 0xcd, 0x89, 0x3a, 0x36, 0xce, 0x8c, 0xb2, 0x76, 0x29, 0x9e, 0xda, 0x78, 0xeb, 0x05, - 0x3d, 0xf8, 0x5a, 0xbc, 0x58, 0x71, 0x52, 0x5b, 0x46, 0xc4, 0x0b, 0x8e, 0x87, 0xeb, 0x98, 0x43, 0xb5, 0x71, 0x6b, - 0xc1, 0x84, 0x09, 0xad, 0x86, 0xcd, 0xce, 0x5a, 0x4e, 0xf9, 0x5a, 0x1e, 0x27, 0x95, 0x4b, 0x7f, 0xac, 0x90, 0x00, - 0x08, 0x1d, 0x2d, 0x22, 0x09, 0x7c, 0x56, 0x18, 0xc6, 0x1c, 0x0f, 0x92, 0x25, 0xf3, 0x63, 0x25, 0x8f, 0x00, 0x33, - 0x31, 0x5c, 0xbc, 0x0d, 0xbd, 0x7a, 0x82, 0x2e, 0xd9, 0xc1, 0x46, 0xdd, 0x20, 0x08, 0x12, 0xec, 0x00, 0x7f, 0xe1, - 0x7d, 0x17, 0x26, 0xe7, 0xfc, 0x66, 0xeb, 0xf0, 0xff, 0x04, 0x4f, 0xe6, 0x61, 0x6d, 0xbb, 0x1f, 0x6c, 0xd4, 0x97, - 0xff, 0x3f, 0xd5, 0x35, 0xb4, 0x0e, 0x7c, 0xf8, 0xc0, 0x85, 0xc7, 0xab, 0x55, 0x3d, 0x5a, 0x6d, 0xed, 0x80, 0x21, - 0x99, 0x38, 0x51, 0x56, 0xec, 0xa8, 0xde, 0xa1, 0xee, 0x1f, 0x1c, 0xee, 0x8f, 0x1c, 0xa0, 0xfe, 0xc1, 0xc4, 0xdb, - 0x48, 0x23, 0xdd, 0xfd, 0x22, 0x64, 0x62, 0x3d, 0xea, 0x20, 0x57, 0x29, 0xfd, 0xfc, 0xdc, 0xb3, 0x75, 0x84, 0xa5, - 0xab, 0x74, 0x70, 0x7f, 0xd1, 0x59, 0x7b, 0xb0, 0xc1, 0xe5, 0x8b, 0xec, 0x56, 0xad, 0x7d, 0x52, 0xba, 0xca, 0x1a, - 0x6f, 0x02, 0x10, 0x60, 0xab, 0xcc, 0x64, 0xe5, 0xe9, 0x9e, 0x52, 0xc2, 0xbb, 0xd6, 0xe6, 0xec, 0xaf, 0xd7, 0xc1, - 0x29, 0x63, 0x6d, 0x77, 0x71, 0x6b, 0xbd, 0x00, 0x41, 0x39, 0xf7, 0x1a, 0xca, 0x29, 0x84, 0x78, 0x49, 0x0d, 0x2e, - 0x87, 0xb1, 0xf1, 0x10, 0x39, 0x72, 0x88, 0x6e, 0x23, 0x82, 0x75, 0x95, 0xb6, 0x2a, 0x8e, 0xbd, 0x96, 0x27, 0x66, - 0x0b, 0xe3, 0x26, 0xa6, 0x14, 0x16, 0x15, 0x18, 0x79, 0x1a, 0x76, 0x38, 0xdb, 0x11, 0x7a, 0x34, 0x6b, 0x5b, 0x90, - 0x26, 0xec, 0x97, 0xfa, 0x7d, 0x58, 0x82, 0xb1, 0xf9, 0xaa, 0x85, 0xd0, 0x4b, 0xe0, 0x34, 0x79, 0x6f, 0xc8, 0xaf, - 0x2e, 0xf4, 0x8c, 0x70, 0x59, 0x24, 0xf7, 0x58, 0x08, 0x42, 0x65, 0x6b, 0xbb, 0x4c, 0xba, 0x99, 0x63, 0x88, 0xe7, - 0x19, 0x63, 0x68, 0xe1, 0x05, 0x81, 0x4c, 0x13, 0x94, 0x32, 0xfc, 0x16, 0xe1, 0x71, 0x86, 0xb3, 0x43, 0x6e, 0xa6, - 0xc3, 0x48, 0xb8, 0xc2, 0xed, 0x04, 0x99, 0xa5, 0xf9, 0x44, 0xe9, 0xc7, 0x54, 0x75, 0xd8, 0x67, 0x26, 0x21, 0x6a, - 0x8f, 0x58, 0x8f, 0xa7, 0x34, 0x6c, 0x67, 0xfc, 0x9c, 0xe7, 0x52, 0x6c, 0x20, 0xee, 0xae, 0xdc, 0x25, 0xd7, 0xc4, - 0x29, 0xb1, 0xb4, 0xca, 0x38, 0xa4, 0xd0, 0x8e, 0x85, 0xb6, 0xf1, 0x90, 0x1e, 0x44, 0xda, 0xae, 0x0f, 0x49, 0x95, - 0x4e, 0x1e, 0xf3, 0x23, 0x62, 0xc8, 0x4c, 0xbf, 0xc0, 0xda, 0xfe, 0x72, 0xf3, 0x21, 0x04, 0x2a, 0x12, 0x3b, 0x77, - 0x04, 0x3e, 0x0d, 0xb0, 0x79, 0x29, 0x2d, 0x85, 0x56, 0x85, 0xce, 0x55, 0x5b, 0xbd, 0x34, 0x94, 0x0d, 0x51, 0x04, - 0x92, 0x59, 0x96, 0xf0, 0x51, 0xd6, 0x30, 0xc8, 0xa9, 0xdf, 0x35, 0x20, 0xdb, 0x1e, 0x06, 0xeb, 0x7b, 0xaa, 0x2c, - 0xf5, 0xfd, 0xd9, 0x4f, 0x9f, 0xf0, 0xb1, 0x0e, 0x61, 0x95, 0x01, 0xd7, 0x6c, 0x5e, 0xe3, 0xa1, 0x77, 0x9f, 0xcc, - 0xa0, 0x7e, 0xc2, 0x91, 0xbe, 0xc1, 0xd7, 0xc8, 0xfd, 0xcc, 0xcb, 0xf2, 0xca, 0x7b, 0x49, 0x9e, 0x6d, 0x53, 0xea, - 0x27, 0x2d, 0x56, 0xbc, 0x81, 0x3f, 0x75, 0x7e, 0x68, 0xc5, 0xf7, 0x65, 0x75, 0x67, 0xdb, 0x99, 0x33, 0x2c, 0x33, - 0xd8, 0x83, 0x19, 0xba, 0xeb, 0xa3, 0x56, 0x2a, 0xa4, 0x5e, 0xe9, 0xcb, 0x07, 0xef, 0x53, 0xef, 0x53, 0x26, 0x4d, - 0x74, 0x13, 0x98, 0xa2, 0xd2, 0xd7, 0x21, 0xca, 0x0e, 0x69, 0x62, 0xda, 0xa1, 0x4a, 0x14, 0x1d, 0x5e, 0x98, 0x65, - 0x29, 0xc0, 0xf0, 0x8d, 0xe5, 0x53, 0x86, 0x6b, 0x25, 0xa9, 0x20, 0xd4, 0x1a, 0xc4, 0x67, 0x93, 0xe9, 0x7d, 0x99, - 0x1b, 0x0a, 0x58, 0xb0, 0xf8, 0x3a, 0x86, 0x5d, 0xa4, 0x7f, 0x38, 0x7e, 0x27, 0xc1, 0x39, 0xe1, 0x70, 0x64, 0x03, - 0x01, 0x94, 0x69, 0xbb, 0xe0, 0xe2, 0x7e, 0x83, 0x3d, 0xcc, 0xd2, 0x7f, 0x42, 0x6a, 0xc1, 0x69, 0xa0, 0x97, 0xe8, - 0xff, 0xba, 0x33, 0x7f, 0x2a, 0x5f, 0x2f, 0x2c, 0xe6, 0x44, 0xb8, 0xc5, 0xd9, 0x57, 0x96, 0x59, 0xe5, 0x8a, 0xfb, - 0x03, 0x23, 0x13, 0xad, 0x5d, 0x9f, 0x1f, 0xac, 0x56, 0xd4, 0x2a, 0xd4, 0xd0, 0x57, 0xee, 0x7f, 0xa6, 0x7b, 0xb9, - 0x67, 0xc6, 0x3c, 0x14, 0x73, 0x87, 0x75, 0xd1, 0xd0, 0xf8, 0x0c, 0xd1, 0x10, 0xa5, 0xc6, 0x6a, 0xc0, 0x66, 0x4c, - 0xea, 0xd7, 0x83, 0x08, 0x4b, 0xe9, 0x9c, 0x18, 0x55, 0x6a, 0x91, 0x41, 0x82, 0xc9, 0xf1, 0x5c, 0xda, 0x1c, 0x0a, - 0x44, 0xd0, 0xcc, 0x6b, 0x68, 0xf4, 0x35, 0x1e, 0x56, 0xb8, 0xd1, 0xcd, 0x1e, 0x31, 0x64, 0x04, 0x41, 0x65, 0xd9, - 0x18, 0xd9, 0x2e, 0x46, 0x51, 0x38, 0xf5, 0xb3, 0x43, 0x41, 0xf1, 0xcb, 0x99, 0x2f, 0x4d, 0x76, 0xdc, 0x3d, 0x1a, - 0x80, 0xa2, 0x58, 0x97, 0x78, 0xd9, 0x66, 0x22, 0x37, 0xb9, 0xc1, 0x94, 0x20, 0x88, 0x39, 0xfc, 0x09, 0xb2, 0xa4, - 0x88, 0xe9, 0x22, 0x6e, 0x2e, 0xcd, 0xc5, 0xa8, 0x4c, 0x76, 0xf5, 0xc0, 0x6d, 0x68, 0x54, 0xab, 0x89, 0x5e, 0x6b, - 0xdd, 0x0f, 0x0a, 0xd1, 0x09, 0x8b, 0x27, 0xf2, 0x8a, 0x85, 0x48, 0x82, 0x81, 0x00, 0x45, 0xdb, 0xc2, 0x28, 0x0a, - 0xbd, 0x16, 0xd3, 0xd9, 0x72, 0x7e, 0x2e, 0xd3, 0x50, 0x34, 0x3a, 0xe3, 0x96, 0x81, 0x55, 0x3f, 0x6d, 0x96, 0x1f, - 0x78, 0xfc, 0x4f, 0x62, 0xc2, 0xdb, 0x1e, 0x7a, 0x06, 0xe2, 0x53, 0x0f, 0x28, 0xd3, 0x5d, 0x02, 0x85, 0xe9, 0xb9, - 0x8b, 0x50, 0x22, 0x29, 0xea, 0xc6, 0x9c, 0x58, 0x72, 0x1f, 0x95, 0xf8, 0xbe, 0x1a, 0x1f, 0xc2, 0x11, 0xd5, 0x87, - 0xc4, 0x15, 0x05, 0x2c, 0xf2, 0xac, 0x64, 0xf3, 0x39, 0xcb, 0xb8, 0x0e, 0x8b, 0x35, 0x73, 0xce, 0x1b, 0x5e, 0x96, - 0xfa, 0xa0, 0x64, 0x04, 0xef, 0x07, 0x73, 0x08, 0x55, 0xae, 0xc8, 0x0c, 0xf9, 0x55, 0x89, 0xda, 0x0a, 0x58, 0xe3, - 0x0a, 0xc2, 0x6c, 0xed, 0x15, 0xaf, 0x6f, 0x8b, 0x95, 0xfe, 0x40, 0xae, 0x11, 0xf7, 0x70, 0x08, 0x00, 0x0c, 0xfb, - 0xdd, 0x09, 0x8a, 0x91, 0x0a, 0x17, 0xe6, 0x62, 0x68, 0x40, 0xc2, 0x36, 0x48, 0x99, 0xed, 0xc7, 0xf9, 0xf0, 0xee, - 0x9f, 0xd6, 0x9c, 0x1d, 0xac, 0x95, 0x70, 0xed, 0xe8, 0x2a, 0x13, 0xe4, 0xe5, 0x43, 0x94, 0x9d, 0xb9, 0x6d, 0xae, - 0x96, 0x05, 0x51, 0x69, 0x31, 0x9e, 0xad, 0xc4, 0xfd, 0x32, 0x85, 0xc7, 0xce, 0x22, 0xd8, 0x99, 0x97, 0xe0, 0x12, - 0x10, 0x7d, 0x90, 0xf1, 0x91, 0x0d, 0x12, 0xbd, 0xf2, 0x6c, 0xfc, 0x59, 0x75, 0xef, 0x51, 0x9b, 0x2a, 0x52, 0xbb, - 0x1e, 0xb8, 0x3b, 0x94, 0xa4, 0x82, 0x69, 0x77, 0x03, 0xd6, 0xcc, 0xeb, 0x89, 0xc9, 0x37, 0x0a, 0xe2, 0x06, 0x38, - 0xfb, 0x6e, 0x1c, 0x68, 0x1a, 0x58, 0x6f, 0x3e, 0xa2, 0x68, 0x10, 0x6a, 0x44, 0x9c, 0x9b, 0xf5, 0xfa, 0xa5, 0xdf, - 0x81, 0x00, 0xa4, 0x60, 0x56, 0x12, 0xbc, 0x77, 0xe5, 0xa4, 0x10, 0x84, 0xd8, 0x02, 0x88, 0xe9, 0x06, 0x12, 0xc7, - 0x11, 0xe5, 0x1a, 0xcf, 0xbe, 0x59, 0x7a, 0xf4, 0xa2, 0x23, 0x76, 0x7f, 0x09, 0xac, 0xe9, 0x65, 0x07, 0xdb, 0xb9, - 0x09, 0x49, 0x85, 0x32, 0xf4, 0xaa, 0x9e, 0xdd, 0xb0, 0xb9, 0x75, 0x2c, 0x0b, 0x3d, 0x7a, 0x08, 0x72, 0xc9, 0xbc, - 0xb7, 0xd5, 0x18, 0x08, 0xa5, 0x9b, 0x5f, 0x08, 0x14, 0x47, 0xeb, 0x5f, 0x68, 0x93, 0x0c, 0x6d, 0x9c, 0xdb, 0xb4, - 0xb3, 0x66, 0xb7, 0x29, 0xac, 0x5c, 0x72, 0x73, 0xbd, 0x38, 0xa6, 0xa8, 0xa7, 0xf2, 0xbd, 0xd6, 0xb2, 0x67, 0x63, - 0xa0, 0x86, 0xf6, 0xc8, 0x27, 0x85, 0xd0, 0x1b, 0xb6, 0x62, 0x69, 0x24, 0x93, 0xe6, 0xce, 0x3b, 0x27, 0xd4, 0xe6, - 0x61, 0x87, 0xc4, 0x09, 0x73, 0xeb, 0xbf, 0xcf, 0x23, 0x29, 0x8b, 0xf7, 0x29, 0x14, 0x6e, 0x86, 0xea, 0x86, 0xb1, - 0xe8, 0x38, 0x01, 0xb7, 0x95, 0xf5, 0xd3, 0x8c, 0x44, 0xac, 0x16, 0x16, 0xc6, 0x33, 0x80, 0xa9, 0x98, 0x22, 0x6e, - 0x55, 0x30, 0xd4, 0x20, 0x39, 0x57, 0x83, 0x60, 0xa6, 0xd7, 0x8c, 0x9d, 0x79, 0x99, 0xb7, 0xd0, 0xd6, 0xc6, 0x2c, - 0x2c, 0xd4, 0x6c, 0x4c, 0xcd, 0x93, 0x49, 0x01, 0x4b, 0x23, 0xe8, 0xf6, 0x98, 0x1e, 0xee, 0xae, 0x91, 0xef, 0x96, - 0x23, 0x67, 0x17, 0x83, 0xf9, 0xd8, 0xcb, 0xec, 0x71, 0xea, 0xc1, 0xcb, 0x04, 0x33, 0x42, 0x85, 0xad, 0xe2, 0x02, - 0xda, 0xb3, 0xa6, 0xff, 0xc0, 0x37, 0xf1, 0x31, 0x07, 0x37, 0x66, 0xec, 0xad, 0x59, 0xba, 0xe2, 0x3d, 0x1d, 0x23, - 0x64, 0x11, 0x23, 0xf2, 0x9c, 0x35, 0xc5, 0xdc, 0x4a, 0x15, 0xe3, 0x1e, 0x14, 0x82, 0xe5, 0x2b, 0x4c, 0x05, 0x10, - 0x0e, 0x66, 0x37, 0x1a, 0x1c, 0x62, 0x7d, 0xdc, 0xba, 0x5b, 0x20, 0x04, 0x06, 0x50, 0x5d, 0x9c, 0x73, 0x34, 0xd1, - 0x01, 0x90, 0xfc, 0x3e, 0x12, 0x00, 0x49, 0x60, 0x86, 0x22, 0x01, 0x46, 0xaf, 0x5a, 0xfa, 0x9a, 0x17, 0x6b, 0x8c, - 0x8c, 0xd8, 0x23, 0x08, 0xb6, 0x72, 0x8f, 0x2c, 0x90, 0x66, 0x73, 0xaf, 0x75, 0xc2, 0xb7, 0x67, 0x45, 0x25, 0x0e, - 0x2e, 0xbf, 0x2a, 0x23, 0xe9, 0x9f, 0x0c, 0x6a, 0xac, 0x63, 0xe5, 0x29, 0xfd, 0x98, 0xa9, 0xa3, 0x47, 0x77, 0x69, - 0x9a, 0x4e, 0xe6, 0xa0, 0xd8, 0x43, 0x36, 0x28, 0xab, 0x64, 0xec, 0xc4, 0x39, 0x74, 0x22, 0xa9, 0x7f, 0x1c, 0xbd, - 0xbc, 0x53, 0x8f, 0xa2, 0x34, 0xb7, 0xeb, 0x09, 0xb5, 0x72, 0xaa, 0xdd, 0x08, 0xbc, 0x49, 0x79, 0x26, 0x75, 0xc6, - 0x96, 0xfa, 0xa5, 0x42, 0x2a, 0x3b, 0x35, 0x26, 0xb1, 0x93, 0xf3, 0x32, 0xe7, 0xe8, 0x29, 0xbf, 0x10, 0xc6, 0x81, - 0xb1, 0x3f, 0x9d, 0xb6, 0xde, 0xaf, 0xd9, 0x19, 0xe2, 0xf1, 0x6a, 0xaa, 0xf6, 0x21, 0x5d, 0xab, 0x26, 0xa6, 0x40, - 0xd3, 0x9e, 0xa6, 0xff, 0xc9, 0x80, 0x3e, 0x0f, 0xc1, 0x9e, 0xe9, 0x27, 0x21, 0xd5, 0x0e, 0xa2, 0xfd, 0x41, 0x0b, - 0xaf, 0xf0, 0x35, 0x4a, 0xa8, 0xfe, 0xce, 0x09, 0xd0, 0xf1, 0xbd, 0x6e, 0x10, 0x5b, 0x92, 0xb8, 0x98, 0x8b, 0x54, - 0x76, 0x8e, 0x19, 0xd5, 0x40, 0x2e, 0x88, 0x14, 0xcf, 0x75, 0x1a, 0x95, 0x85, 0x2c, 0x79, 0x83, 0x1b, 0x3f, 0xfb, - 0x35, 0x53, 0x28, 0xfc, 0x6a, 0x38, 0x08, 0x58, 0x06, 0x90, 0x30, 0xef, 0x5e, 0x69, 0xce, 0x99, 0x9d, 0x8d, 0x18, - 0xb2, 0x00, 0x2e, 0x75, 0xec, 0x23, 0x74, 0x12, 0x00, 0x10, 0x1d, 0x13, 0x63, 0x20, 0xaf, 0x76, 0x54, 0xff, 0x03, - 0x1c, 0x7a, 0x27, 0xbd, 0x5a, 0x73, 0x37, 0x81, 0x28, 0x42, 0x40, 0x80, 0xc4, 0xde, 0x50, 0x10, 0x45, 0xcb, 0x41, - 0x24, 0x55, 0x62, 0x27, 0xb4, 0x15, 0x9a, 0x05, 0x37, 0xb2, 0x11, 0x69, 0x04, 0xd0, 0x2b, 0xb8, 0x10, 0x33, 0x02, - 0x65, 0x1e, 0x47, 0x1a, 0xbf, 0xa0, 0xc4, 0xcc, 0x4b, 0xc5, 0xe8, 0x73, 0x8a, 0x7a, 0xef, 0x41, 0x74, 0xcf, 0xcd, - 0xa3, 0xd6, 0xc7, 0x84, 0x10, 0x3d, 0x01, 0x6b, 0x28, 0xab, 0x9f, 0xa2, 0x0c, 0x30, 0x1a, 0xa0, 0x6c, 0xef, 0x70, - 0x1e, 0xb0, 0x7c, 0xa9, 0x79, 0x52, 0x0f, 0x1d, 0xf3, 0x88, 0x5c, 0x3a, 0x9f, 0xf7, 0xeb, 0xb8, 0x5e, 0xd4, 0x0e, - 0x2a, 0x04, 0x3c, 0x56, 0x2f, 0xd5, 0x8d, 0x20, 0x37, 0x14, 0xff, 0x45, 0xc5, 0xd4, 0x18, 0xf6, 0x95, 0x5f, 0x4c, - 0x27, 0x1d, 0x6a, 0x87, 0xf5, 0x2e, 0x72, 0x75, 0x2a, 0x4a, 0x00, 0x4e, 0xbb, 0x1d, 0xda, 0x39, 0xb3, 0xe3, 0x6f, - 0x77, 0xfd, 0x68, 0x95, 0x95, 0x6a, 0x51, 0xe7, 0x59, 0x43, 0x41, 0x79, 0x39, 0x1d, 0xff, 0x0b, 0x4f, 0xf6, 0xf2, - 0x64, 0x30, 0xa7, 0x16, 0x61, 0x9c, 0xba, 0xf3, 0xec, 0xfd, 0xc1, 0xdb, 0x61, 0x4b, 0x88, 0x9d, 0xea, 0xe6, 0xd7, - 0x7a, 0xe4, 0x8b, 0xa9, 0xdb, 0x7a, 0x82, 0x1b, 0x37, 0xb7, 0xce, 0xd8, 0xab, 0xc7, 0xd0, 0x31, 0x01, 0xe0, 0xad, - 0x25, 0x8a, 0xa2, 0x22, 0xfc, 0xfb, 0xe3, 0xe9, 0xa1, 0xe6, 0xf4, 0xa0, 0x6f, 0xe3, 0x9d, 0x18, 0x34, 0x05, 0x26, - 0x58, 0x07, 0x0c, 0xf3, 0x01, 0xfd, 0xae, 0xa4, 0x1a, 0xdf, 0xf7, 0xa2, 0xc8, 0x41, 0x4c, 0x66, 0xf0, 0xa5, 0xf1, - 0x4d, 0x56, 0x64, 0x7c, 0x51, 0x01, 0xda, 0xbc, 0x4c, 0xb6, 0x0b, 0xc7, 0x30, 0x85, 0x69, 0xb7, 0xee, 0x7b, 0xec, - 0xa0, 0x5c, 0xdc, 0x1a, 0xc6, 0x6f, 0x24, 0x82, 0xec, 0x8d, 0x65, 0xe6, 0x03, 0xc5, 0xaf, 0x9f, 0x3a, 0x26, 0xb9, - 0xe7, 0x0d, 0xf7, 0x87, 0xa8, 0xa9, 0xd0, 0xf9, 0x5c, 0x79, 0xb7, 0x9c, 0xdf, 0x85, 0xd7, 0xaa, 0x50, 0x77, 0x64, - 0xc9, 0x48, 0xd3, 0x69, 0xe8, 0xe9, 0x5a, 0x2d, 0xda, 0x9c, 0xb8, 0x0e, 0xda, 0xb6, 0xd8, 0x04, 0x12, 0x4b, 0xce, - 0x60, 0xa6, 0xe4, 0x4d, 0x2c, 0x08, 0x0e, 0x1d, 0x41, 0xa1, 0xbb, 0x28, 0x0e, 0x91, 0x30, 0x60, 0xb3, 0x43, 0x85, - 0xdd, 0x47, 0xb0, 0xf1, 0xd5, 0xbc, 0x50, 0xe4, 0x19, 0xab, 0xc2, 0x9c, 0xa9, 0x66, 0x56, 0xb5, 0x5f, 0x0c, 0x70, - 0xf6, 0x4f, 0xb8, 0x97, 0x4e, 0x0c, 0xd6, 0x83, 0x4c, 0x49, 0x0d, 0x9d, 0x72, 0x8a, 0x6a, 0x1e, 0xb1, 0x8d, 0x35, - 0x14, 0x50, 0x3b, 0x3c, 0x32, 0x1c, 0x6e, 0x7a, 0x6f, 0x7c, 0x3e, 0xf0, 0x2c, 0x36, 0xf0, 0xce, 0x21, 0xb0, 0x10, - 0xfb, 0x51, 0xbb, 0x38, 0xb4, 0x35, 0x9b, 0xa1, 0xb0, 0x8d, 0x86, 0x42, 0x3a, 0xb3, 0x17, 0x24, 0xd3, 0x41, 0x1a, - 0x48, 0x5b, 0x87, 0x89, 0xc6, 0xde, 0x57, 0x07, 0xba, 0x03, 0x8d, 0x37, 0x3d, 0xa2, 0xd0, 0xc6, 0xae, 0x1a, 0xc0, - 0x2b, 0x3a, 0x97, 0xe8, 0x66, 0xdf, 0x12, 0xd8, 0xaf, 0x36, 0x83, 0x1b, 0xcb, 0x97, 0x00, 0xa6, 0x14, 0x90, 0x6d, - 0xd9, 0xf8, 0xdc, 0x73, 0x3e, 0x90, 0x4d, 0x98, 0x1c, 0x8d, 0xa3, 0x76, 0x11, 0x76, 0x69, 0x8d, 0xb7, 0x1c, 0x4d, - 0xf5, 0xcf, 0xa5, 0x08, 0x0b, 0xac, 0x2e, 0x98, 0xb6, 0xc5, 0x47, 0x23, 0xac, 0x49, 0x51, 0xb7, 0x87, 0x05, 0xd4, - 0xd8, 0x61, 0x21, 0x95, 0xd6, 0x6b, 0x89, 0x8b, 0x95, 0x18, 0x5f, 0xd3, 0x53, 0x28, 0x0e, 0x2c, 0x3b, 0x47, 0x40, - 0xe3, 0xc1, 0x3a, 0xdf, 0x0a, 0x5f, 0x2a, 0xdc, 0x2f, 0xe5, 0xa8, 0xe4, 0x99, 0x26, 0xce, 0xf5, 0x02, 0xce, 0x08, - 0x89, 0xcb, 0x0f, 0xd8, 0x38, 0x96, 0x00, 0x5b, 0xa6, 0xf7, 0xff, 0x50, 0x87, 0x05, 0xdb, 0x21, 0xc0, 0x2b, 0xee, - 0xa2, 0x7d, 0xa0, 0x5c, 0x01, 0xa4, 0xc9, 0x51, 0x86, 0x5c, 0x7e, 0xd6, 0xbd, 0x42, 0xc6, 0xb4, 0x4f, 0xa2, 0x63, - 0xcb, 0x76, 0x76, 0x20, 0x99, 0x23, 0x43, 0xc2, 0xb8, 0xf5, 0x2a, 0x65, 0xe1, 0x6e, 0x80, 0x63, 0x44, 0xb1, 0xb4, - 0x62, 0x7e, 0x99, 0x29, 0xf2, 0x0a, 0xd8, 0x8d, 0x93, 0xa6, 0xbd, 0x36, 0xa6, 0x4b, 0x64, 0x63, 0x30, 0x76, 0xaa, - 0x08, 0x2c, 0x8c, 0x41, 0x55, 0xb2, 0x20, 0xfc, 0x63, 0x4f, 0x38, 0xe0, 0x7f, 0x72, 0x26, 0x28, 0x3f, 0x1e, 0xcb, - 0x79, 0x52, 0x61, 0x1e, 0xc8, 0x14, 0xf6, 0x70, 0xfa, 0xd2, 0xbf, 0xa2, 0x4f, 0xa9, 0xc0, 0x9a, 0x24, 0xc2, 0xb8, - 0x01, 0x06, 0x55, 0x1b, 0x00, 0x74, 0x83, 0x14, 0x6f, 0x12, 0xd0, 0xe4, 0x26, 0xb4, 0x0a, 0xe5, 0x28, 0x1d, 0xb0, - 0x52, 0xe4, 0x66, 0xd4, 0x14, 0xc1, 0x46, 0xda, 0x41, 0x8a, 0x12, 0x0c, 0x3b, 0xa9, 0x06, 0x69, 0x95, 0x7a, 0x38, - 0x08, 0x7f, 0x4d, 0x80, 0x0e, 0x40, 0x1e, 0x96, 0xff, 0x65, 0x32, 0xc2, 0xcb, 0x23, 0x2b, 0xb9, 0x47, 0xcd, 0x51, - 0x63, 0x62, 0x1a, 0x3a, 0xb9, 0xa5, 0x13, 0xde, 0xd5, 0x1c, 0x71, 0x36, 0x0e, 0x6a, 0xab, 0x9a, 0x0f, 0x06, 0x43, - 0xa7, 0x4e, 0x3a, 0x82, 0xc2, 0x47, 0x39, 0x06, 0x13, 0xda, 0xcd, 0x14, 0x3d, 0x0f, 0x7b, 0x99, 0x97, 0x93, 0x6e, - 0x36, 0x53, 0x00, 0xb6, 0x5a, 0x0a, 0x5b, 0x86, 0x81, 0x31, 0x8c, 0x3f, 0x02, 0x72, 0xc3, 0xa7, 0xcf, 0x4b, 0x23, - 0x8f, 0x4a, 0x2f, 0x6f, 0x7e, 0xf8, 0xf8, 0x31, 0x80, 0xc1, 0x50, 0xd1, 0xe0, 0xc3, 0x67, 0x7d, 0x35, 0xbe, 0x93, - 0xc9, 0xc6, 0x1a, 0xe3, 0x1c, 0x44, 0x79, 0x16, 0xda, 0x91, 0x9f, 0x95, 0x75, 0x51, 0x0c, 0xdb, 0xd7, 0x17, 0x95, - 0xd7, 0x97, 0x22, 0xa4, 0x6a, 0x41, 0x2d, 0x6f, 0x71, 0x8a, 0x8d, 0x43, 0x28, 0x34, 0xe6, 0xa0, 0x08, 0x19, 0x8e, - 0x22, 0x6e, 0xee, 0x34, 0x14, 0x03, 0x52, 0x22, 0x12, 0xe6, 0xd4, 0x69, 0xed, 0x6d, 0x4e, 0xb0, 0xd9, 0x3b, 0x53, - 0x4b, 0x0d, 0xaf, 0x31, 0x61, 0x65, 0xe7, 0x48, 0x91, 0xc0, 0xa5, 0x2d, 0xbb, 0xb5, 0xc8, 0x54, 0xdd, 0x8d, 0x86, - 0xcc, 0x99, 0x15, 0x14, 0xab, 0x97, 0xcf, 0x0a, 0x87, 0x56, 0x50, 0xfc, 0xa6, 0xc1, 0x06, 0xc4, 0x38, 0x76, 0x7f, - 0x7c, 0xae, 0x82, 0x7f, 0xf8, 0x39, 0xdc, 0x93, 0x3f, 0xa6, 0x17, 0xac, 0x52, 0xcc, 0x06, 0x35, 0xdf, 0x25, 0xca, - 0xf4, 0xda, 0x9c, 0x09, 0x4c, 0x1c, 0xf3, 0x82, 0x1e, 0x3e, 0x4b, 0x5f, 0x14, 0xa3, 0xcd, 0xa0, 0x22, 0x65, 0x52, - 0x01, 0x44, 0x34, 0xe9, 0x0e, 0xa9, 0xd7, 0x58, 0xa4, 0x2c, 0x9b, 0x62, 0x9b, 0xe6, 0x4a, 0x6d, 0x1f, 0x3b, 0x6a, - 0x6a, 0xdd, 0x90, 0x48, 0x3c, 0xa4, 0xf9, 0x0f, 0xc5, 0xd7, 0x31, 0x16, 0x84, 0x48, 0x21, 0xad, 0x2f, 0xce, 0x74, - 0x7a, 0x7c, 0x36, 0x8a, 0x3b, 0x1a, 0xc7, 0xa3, 0xfc, 0x6b, 0x8d, 0xe9, 0x54, 0xb0, 0x1f, 0xd2, 0x19, 0x12, 0x47, - 0x9e, 0x1b, 0x17, 0xdd, 0xad, 0xcf, 0x3a, 0x7c, 0x10, 0xb5, 0xbc, 0xe4, 0x1d, 0xbb, 0x21, 0x54, 0x32, 0x98, 0xeb, - 0x94, 0x40, 0x6b, 0xea, 0x0f, 0xfc, 0x0d, 0x5f, 0xb8, 0x51, 0x5d, 0x3a, 0x24, 0x5a, 0xd8, 0x90, 0x60, 0x3a, 0x49, - 0x8a, 0xb4, 0xe0, 0x12, 0x26, 0x9b, 0xa0, 0x58, 0xbb, 0xb0, 0xe0, 0xb3, 0xb0, 0x38, 0x64, 0xf3, 0x8b, 0x2e, 0xc4, - 0x78, 0x07, 0xd6, 0x19, 0xaa, 0x3c, 0x73, 0x8e, 0x41, 0x33, 0x78, 0x61, 0x61, 0xaf, 0x0a, 0x72, 0x88, 0x0b, 0xeb, - 0x80, 0xca, 0xc7, 0xa8, 0x91, 0xf1, 0xe8, 0xad, 0x4d, 0x21, 0x3d, 0xd0, 0x7d, 0xff, 0xce, 0x6f, 0xaf, 0x22, 0x67, - 0x60, 0x88, 0x0b, 0x91, 0x17, 0x1e, 0xcd, 0x6c, 0x70, 0x81, 0x71, 0xe1, 0x6d, 0x8e, 0x7a, 0xf1, 0x1c, 0xce, 0xdb, - 0x37, 0x54, 0x6a, 0x78, 0xc5, 0x94, 0x8e, 0x13, 0x14, 0xdc, 0xa4, 0x97, 0x15, 0xc8, 0xbb, 0x93, 0xb4, 0xd8, 0x35, - 0xbb, 0x14, 0xb2, 0x21, 0x45, 0x67, 0xed, 0x6e, 0x70, 0xca, 0xdc, 0x9e, 0x49, 0x87, 0x65, 0x4e, 0xd1, 0xcc, 0x24, - 0x0a, 0x3d, 0x87, 0x28, 0xc6, 0x8c, 0xa1, 0x49, 0xb5, 0x65, 0x5e, 0xd4, 0x92, 0x0d, 0x95, 0xba, 0x46, 0x50, 0xd6, - 0xcd, 0x91, 0xfd, 0x73, 0xe6, 0xe3, 0xdc, 0xb9, 0xc1, 0xd0, 0x03, 0x79, 0x18, 0x90, 0xb1, 0x3a, 0xc7, 0xfd, 0x85, - 0x8f, 0x69, 0xde, 0xed, 0x5e, 0x73, 0xfc, 0x6b, 0x04, 0x6d, 0x99, 0x7c, 0xd3, 0xfa, 0x07, 0xd5, 0xff, 0x65, 0x03, - 0x46, 0xd6, 0x3a, 0x3e, 0x2c, 0x36, 0xad, 0x37, 0x55, 0x4d, 0x76, 0xd0, 0xd8, 0x99, 0x26, 0x6d, 0x2c, 0x1c, 0xd4, - 0xdd, 0xdb, 0xc8, 0x24, 0x38, 0x6c, 0xde, 0x1c, 0xc2, 0x40, 0x56, 0xc6, 0x77, 0x1b, 0xa1, 0x75, 0xeb, 0xb4, 0xa9, - 0xc3, 0x2f, 0x43, 0x13, 0x0f, 0x7b, 0x8d, 0x56, 0x0c, 0x61, 0x9e, 0x4d, 0x19, 0xbb, 0x02, 0xde, 0x14, 0x41, 0x11, - 0x4f, 0xe3, 0x9a, 0x23, 0x98, 0xd2, 0x6a, 0x60, 0xc8, 0xaa, 0x11, 0xcf, 0x51, 0xa5, 0x6b, 0xd4, 0x73, 0xfb, 0xa6, - 0x07, 0x0c, 0xb9, 0x90, 0xb3, 0x5f, 0x4e, 0x6b, 0x42, 0x67, 0xeb, 0x76, 0xf4, 0x18, 0xf0, 0x0a, 0x91, 0xe8, 0xf3, - 0x41, 0x96, 0x01, 0xb1, 0xc5, 0x64, 0xa5, 0x43, 0x21, 0xc1, 0xe3, 0x76, 0xf8, 0x8c, 0xd5, 0xa7, 0xbc, 0xa7, 0x4f, - 0x59, 0xec, 0x86, 0xa6, 0xd6, 0xc1, 0xdf, 0xa9, 0x12, 0x22, 0x05, 0xae, 0xa5, 0xba, 0xcb, 0x90, 0x55, 0xa5, 0x1e, - 0xfd, 0x41, 0x91, 0x96, 0x46, 0xac, 0xc4, 0x52, 0xa9, 0x1a, 0xd3, 0xfa, 0xef, 0xb4, 0x15, 0x9d, 0xa8, 0xff, 0xb4, - 0xb0, 0x5a, 0x7d, 0x45, 0xac, 0xab, 0x84, 0xe3, 0x45, 0xaf, 0xb6, 0xe8, 0xc8, 0x10, 0x41, 0x02, 0x9f, 0x75, 0xad, - 0x37, 0x1b, 0x3a, 0x0d, 0x68, 0xbc, 0xcd, 0xe3, 0xbf, 0xea, 0xf9, 0x8c, 0xec, 0x05, 0x9a, 0xae, 0x52, 0x9b, 0x02, - 0x5d, 0x41, 0xe0, 0x9c, 0x25, 0xd6, 0x5c, 0xa5, 0x81, 0x09, 0xa6, 0x79, 0xb1, 0xe5, 0x0b, 0xa8, 0x4e, 0xb7, 0x40, - 0x1a, 0x08, 0xd2, 0x50, 0x7a, 0xa9, 0x55, 0xea, 0x36, 0xa6, 0xd3, 0x41, 0x79, 0xd6, 0x06, 0xa5, 0xf8, 0x01, 0xe5, - 0xc0, 0x95, 0x5b, 0xbd, 0x44, 0x09, 0xb2, 0xea, 0xd0, 0xbc, 0x95, 0xbd, 0x66, 0x1e, 0x52, 0xb8, 0x61, 0x4d, 0xc6, - 0x05, 0x6f, 0xfb, 0xdb, 0xdd, 0x40, 0xe6, 0x28, 0x5a, 0x47, 0x4d, 0xc9, 0x42, 0xf7, 0x88, 0xab, 0x08, 0xe9, 0xe7, - 0x85, 0xd2, 0x2d, 0xb0, 0xd3, 0xed, 0xef, 0xd0, 0xba, 0x5b, 0xd4, 0xc5, 0xf2, 0xd0, 0xc3, 0xce, 0x91, 0x7f, 0x04, - 0xef, 0x8f, 0x02, 0x16, 0xc3, 0x4f, 0x13, 0x65, 0x07, 0x6d, 0xf6, 0xfa, 0x7e, 0xb0, 0x4d, 0xbf, 0xa9, 0xb1, 0x1b, - 0xbc, 0x1d, 0xf0, 0x57, 0x1d, 0xac, 0xc2, 0x61, 0x0f, 0xcc, 0x27, 0x16, 0xbf, 0x3f, 0xbf, 0xd8, 0xd7, 0xe0, 0xf8, - 0x44, 0xb8, 0xcd, 0x54, 0x3e, 0xc0, 0xdb, 0x24, 0xb7, 0x74, 0xbd, 0x30, 0xf2, 0xea, 0x02, 0x52, 0x56, 0xce, 0x89, - 0xeb, 0x8b, 0x02, 0x57, 0xa0, 0x85, 0x12, 0x8f, 0x6e, 0x0b, 0xde, 0xb3, 0x86, 0xb4, 0x77, 0x1b, 0x63, 0xd3, 0x69, - 0xef, 0x38, 0x15, 0x87, 0x99, 0x2c, 0xcd, 0x26, 0xe4, 0xdf, 0xae, 0xa8, 0x53, 0x35, 0x70, 0x9f, 0x5f, 0xd7, 0x57, - 0xb3, 0x74, 0x37, 0xee, 0xe7, 0x4f, 0xd9, 0x9e, 0xb0, 0x95, 0x0d, 0x63, 0x76, 0xc8, 0xef, 0x0b, 0x0d, 0xe8, 0x7a, - 0x34, 0x8b, 0x90, 0xfd, 0x40, 0x80, 0xc1, 0x57, 0xe7, 0x8c, 0xa5, 0x59, 0xd9, 0x37, 0xfd, 0xc2, 0x43, 0x49, 0x41, - 0x8c, 0xca, 0x5e, 0x03, 0x64, 0xb4, 0x24, 0x5b, 0xa7, 0xc5, 0x7b, 0x61, 0x01, 0xa3, 0xef, 0x53, 0xe8, 0x54, 0x9f, - 0x64, 0x08, 0xab, 0x2e, 0xd1, 0x78, 0x4e, 0x7b, 0xc4, 0xd7, 0x79, 0x3e, 0x7c, 0x1d, 0x8b, 0x3d, 0x57, 0x58, 0x66, - 0xd8, 0x4c, 0x8c, 0x43, 0x03, 0xf1, 0xc4, 0xa1, 0xfb, 0x91, 0xd1, 0x62, 0xdc, 0x5a, 0x50, 0xc3, 0xe3, 0x2f, 0xe7, - 0x89, 0xa5, 0xeb, 0xdb, 0x80, 0x0c, 0x53, 0x44, 0x9c, 0x59, 0xd4, 0xeb, 0x8b, 0x6a, 0xd8, 0xbd, 0x2e, 0x2a, 0x08, - 0x9a, 0xcd, 0xb7, 0xf5, 0xe0, 0x06, 0xdb, 0x45, 0xed, 0x87, 0x2c, 0xd1, 0xda, 0xba, 0xdf, 0xb4, 0x1b, 0x64, 0x9f, - 0x33, 0x0e, 0xda, 0x40, 0xc0, 0xf8, 0xde, 0xbf, 0xb4, 0xd5, 0xd9, 0xde, 0x3a, 0xcd, 0x5f, 0x88, 0x8b, 0xbf, 0x93, - 0xb0, 0xbc, 0x9b, 0xe1, 0xa0, 0x94, 0x90, 0xe1, 0x04, 0x11, 0xa6, 0xc2, 0xba, 0xb8, 0xe4, 0xb3, 0x0b, 0x13, 0x2b, - 0x23, 0xac, 0x88, 0x76, 0xc4, 0x37, 0x7c, 0x9f, 0xb7, 0x05, 0x04, 0xb1, 0xb3, 0x65, 0xcc, 0x78, 0x46, 0x44, 0x89, - 0x8c, 0xec, 0x30, 0xb1, 0x69, 0x36, 0x61, 0xea, 0xd8, 0x6d, 0x32, 0x68, 0xea, 0x60, 0x9c, 0xc2, 0x06, 0xba, 0x1b, - 0xaa, 0xad, 0xb6, 0x92, 0x8c, 0xf9, 0x85, 0xac, 0x9d, 0xed, 0x8f, 0xea, 0x65, 0x5e, 0x6c, 0x3f, 0xdb, 0x86, 0xe6, - 0x55, 0x31, 0x86, 0x76, 0x20, 0xb3, 0x23, 0xdc, 0x65, 0xea, 0x1e, 0xd2, 0x78, 0xa2, 0x50, 0x6d, 0x25, 0x08, 0x07, - 0xa0, 0x90, 0xa6, 0x39, 0xe3, 0x02, 0xb3, 0xe8, 0xa3, 0x2a, 0xbc, 0xd3, 0x41, 0x59, 0x2d, 0x0d, 0xa0, 0x04, 0x88, - 0xe3, 0x4e, 0x3a, 0x8c, 0xe4, 0xc1, 0x5e, 0xa9, 0x3b, 0xb5, 0x6a, 0x9d, 0xb9, 0x58, 0xec, 0x47, 0x97, 0x5a, 0xec, - 0x11, 0xa6, 0x59, 0x52, 0xeb, 0x07, 0x5a, 0x68, 0xcf, 0x37, 0x5b, 0x13, 0xf3, 0x94, 0x71, 0x48, 0x7b, 0x68, 0x3f, - 0x14, 0xd4, 0x7a, 0x09, 0x8f, 0x95, 0x41, 0x89, 0x64, 0xa7, 0xa6, 0x9d, 0x95, 0x69, 0x0a, 0xde, 0x65, 0x99, 0x78, - 0x15, 0xfa, 0x1d, 0xe1, 0xdd, 0x96, 0x59, 0xdb, 0xc8, 0xc4, 0xcd, 0x49, 0xa4, 0x58, 0x0e, 0xce, 0x35, 0xbc, 0x2d, - 0x3a, 0x76, 0xf1, 0xdb, 0x4c, 0xad, 0x5f, 0xb0, 0x8c, 0x38, 0x5a, 0xc2, 0xcd, 0x0f, 0xe9, 0x91, 0x88, 0x02, 0xa3, - 0xfe, 0x4d, 0x5e, 0xc5, 0x89, 0x1b, 0x66, 0xfc, 0x8e, 0x86, 0x38, 0x54, 0x87, 0xba, 0x67, 0x89, 0x15, 0x88, 0x85, - 0xf3, 0xf7, 0xd5, 0x4d, 0x20, 0x97, 0xde, 0x56, 0xab, 0xe6, 0x61, 0xd4, 0x65, 0xdf, 0x83, 0x3f, 0x85, 0x31, 0x35, - 0x9f, 0xbc, 0x2a, 0xdf, 0x70, 0xcf, 0x64, 0x29, 0x97, 0xf0, 0xba, 0xde, 0x52, 0x73, 0x9c, 0xcb, 0x37, 0x5c, 0x56, - 0x59, 0x6a, 0x33, 0x82, 0x49, 0xdf, 0x5a, 0xe1, 0x38, 0x82, 0x35, 0x14, 0x91, 0xb8, 0x39, 0xa8, 0x83, 0xcd, 0xb1, - 0x0e, 0xe5, 0x36, 0x13, 0xac, 0x43, 0x36, 0xd8, 0x03, 0xc2, 0xa9, 0xc5, 0x66, 0x8e, 0x7d, 0x6c, 0x08, 0x07, 0x74, - 0xdc, 0x9a, 0x22, 0x4a, 0x4e, 0xdd, 0x89, 0xa7, 0x96, 0xe5, 0xbb, 0x19, 0xd3, 0x74, 0x7c, 0x87, 0x18, 0x59, 0x12, - 0x8b, 0xdc, 0xcb, 0x10, 0x97, 0x43, 0x8b, 0xf6, 0x2c, 0x55, 0x21, 0x37, 0xe8, 0xd6, 0x2d, 0x37, 0x1c, 0x3e, 0x7d, - 0x38, 0x2e, 0x7a, 0x22, 0xda, 0x4a, 0x7c, 0x39, 0x85, 0x54, 0x4a, 0xf6, 0x93, 0x0a, 0x2f, 0x54, 0x48, 0xa7, 0xcf, - 0x8a, 0x04, 0xdc, 0xdc, 0x99, 0x0b, 0x57, 0x53, 0xae, 0x65, 0x8d, 0x76, 0x93, 0x9c, 0x08, 0x15, 0x96, 0xcc, 0x18, - 0xbd, 0x78, 0x3f, 0x8b, 0x16, 0xdb, 0x39, 0xc5, 0x76, 0xd8, 0xd4, 0xd4, 0x79, 0x87, 0x22, 0xa7, 0xa1, 0xb2, 0x8c, - 0xc4, 0x2c, 0xca, 0x21, 0x3e, 0x3a, 0x75, 0xbe, 0xc7, 0x28, 0x75, 0x05, 0x73, 0xaa, 0x4d, 0xc9, 0x83, 0xd3, 0xc5, - 0xf9, 0x17, 0x6e, 0x37, 0xfd, 0xe3, 0x28, 0xe8, 0xb7, 0x5a, 0x35, 0x51, 0xad, 0x1c, 0x9a, 0x5d, 0xd7, 0x6e, 0x34, - 0x26, 0xc7, 0x0e, 0x70, 0x67, 0x13, 0xc4, 0x18, 0x3e, 0x2e, 0xc3, 0x2c, 0x9a, 0xe7, 0x53, 0x7d, 0xfd, 0xf3, 0x20, - 0xca, 0xb6, 0x4c, 0xdd, 0x0a, 0xa3, 0xc0, 0xa5, 0x81, 0x07, 0xe3, 0xa8, 0x21, 0x86, 0xcd, 0xa3, 0xa3, 0x6d, 0x22, - 0x93, 0x74, 0xcf, 0x6a, 0xae, 0x00, 0x4d, 0xa7, 0x20, 0xf2, 0xef, 0x71, 0x9b, 0x2f, 0x38, 0x8e, 0x4e, 0xe9, 0xf9, - 0x17, 0xa5, 0x1f, 0x69, 0xf0, 0xc6, 0x78, 0x75, 0xc1, 0xaa, 0x27, 0x23, 0xef, 0x88, 0x87, 0x39, 0x0a, 0xe4, 0x07, - 0xf0, 0x4d, 0xe9, 0x82, 0x73, 0x63, 0xf7, 0x3d, 0xc8, 0x96, 0xed, 0x68, 0x55, 0xc4, 0x1a, 0xf9, 0x1a, 0x27, 0x2e, - 0xb5, 0xfe, 0x92, 0xcc, 0x3a, 0x09, 0xf6, 0x35, 0xf2, 0x78, 0x47, 0xbc, 0xcf, 0xf6, 0x76, 0x68, 0xe3, 0x35, 0x92, - 0xb0, 0xef, 0xb6, 0xeb, 0xaa, 0x2b, 0x4e, 0x7f, 0x62, 0x01, 0xe1, 0x02, 0x36, 0x16, 0xe8, 0x9b, 0xdb, 0x86, 0xd2, - 0xb9, 0xee, 0x1a, 0x7c, 0xaf, 0xf1, 0xce, 0x3b, 0x3b, 0xb8, 0x8a, 0x93, 0x44, 0x60, 0x76, 0xa1, 0x34, 0x26, 0xea, - 0x17, 0x86, 0xe7, 0x6a, 0xcf, 0xb7, 0xea, 0x65, 0x54, 0xb3, 0x67, 0x02, 0xfe, 0x3a, 0x1a, 0x1f, 0x95, 0x79, 0x83, - 0x85, 0xdb, 0x3a, 0x85, 0xee, 0xfc, 0x7d, 0x00, 0xaf, 0xbc, 0x6b, 0xb6, 0x2c, 0xe6, 0x3b, 0xce, 0x82, 0xa5, 0xcd, - 0xdb, 0x5d, 0x99, 0xe0, 0x97, 0x1f, 0x70, 0xaf, 0xf8, 0xd2, 0xc1, 0x93, 0x7e, 0xdd, 0x52, 0xc0, 0x5d, 0xc0, 0xe4, - 0xa5, 0x1b, 0x6e, 0x42, 0xdc, 0xac, 0x72, 0xd3, 0xb7, 0x48, 0xf9, 0xe9, 0xe9, 0xac, 0x79, 0xea, 0x10, 0xde, 0x78, - 0x54, 0x87, 0xca, 0x68, 0x6e, 0xdd, 0xc2, 0x95, 0xfe, 0x05, 0xb7, 0x35, 0x77, 0x30, 0xc3, 0x89, 0x2c, 0x89, 0xc7, - 0x93, 0xee, 0x1e, 0xa0, 0xcc, 0x03, 0x2f, 0x22, 0x29, 0xa2, 0x6d, 0x60, 0x07, 0x2d, 0x28, 0x6b, 0x0d, 0xa2, 0xd6, - 0xde, 0x23, 0x66, 0x7b, 0x69, 0xf7, 0x43, 0xf7, 0xe1, 0x03, 0x0f, 0xd6, 0x44, 0xc8, 0x19, 0x4c, 0x28, 0x7e, 0x97, - 0xf6, 0x73, 0xd8, 0x33, 0xc2, 0x95, 0x56, 0x28, 0x78, 0x8e, 0x1b, 0x54, 0x7d, 0x9f, 0x2d, 0x20, 0x5a, 0x24, 0x20, - 0x67, 0xbb, 0x16, 0xd6, 0xa8, 0x18, 0xf9, 0x49, 0x0c, 0x95, 0xd4, 0xb6, 0x7c, 0x83, 0xa5, 0xbf, 0xae, 0x02, 0x49, - 0x20, 0x31, 0x27, 0xf5, 0xbc, 0xb6, 0x2d, 0x16, 0x37, 0x4b, 0x36, 0x0f, 0x15, 0xa5, 0xe7, 0xe5, 0xd8, 0x79, 0x07, - 0xf5, 0x28, 0xb8, 0x2c, 0xdb, 0xeb, 0xdc, 0x2e, 0xed, 0xae, 0x75, 0x18, 0x4e, 0x52, 0x4e, 0x31, 0xe0, 0xa1, 0x6d, - 0x3d, 0x72, 0xb1, 0xf8, 0xf7, 0x40, 0x1a, 0xde, 0x5d, 0x7e, 0x78, 0x73, 0x4b, 0x6b, 0x4c, 0xd4, 0x66, 0x2a, 0x12, - 0xe1, 0xe4, 0x86, 0x15, 0xc9, 0x90, 0x26, 0x12, 0x3d, 0x7c, 0x91, 0x6f, 0xae, 0x35, 0xac, 0x12, 0xd9, 0x90, 0x61, - 0xb3, 0xd9, 0xcd, 0x64, 0xdc, 0xca, 0x76, 0x7f, 0x3a, 0xf4, 0x26, 0xc8, 0xd6, 0x89, 0xd2, 0x3c, 0x77, 0xd8, 0x62, - 0xed, 0x9e, 0xb1, 0xa8, 0x9f, 0x1c, 0x65, 0x8e, 0x78, 0x43, 0x4c, 0xb4, 0x4d, 0xb9, 0xb6, 0x66, 0x1e, 0x21, 0x70, - 0x6f, 0xed, 0x3f, 0x2b, 0xf6, 0xf1, 0x1a, 0xd3, 0xc7, 0xf4, 0x0b, 0xee, 0xf9, 0xc4, 0xc3, 0xcf, 0x34, 0xc9, 0x0d, - 0xb1, 0xdd, 0x45, 0x3c, 0xe4, 0xa3, 0xbb, 0x61, 0xca, 0x84, 0x65, 0xda, 0x5c, 0xb5, 0x9c, 0x1b, 0x83, 0x54, 0xa1, - 0x48, 0xe5, 0x3e, 0xa2, 0xeb, 0xb0, 0x1f, 0xce, 0xf2, 0x3d, 0x48, 0x64, 0xbe, 0x4f, 0x22, 0x10, 0xeb, 0x1f, 0x05, - 0x71, 0x8e, 0x25, 0x2f, 0x8d, 0x22, 0x8d, 0xfe, 0x84, 0x52, 0x96, 0x72, 0x08, 0xf8, 0xe6, 0x80, 0x73, 0x15, 0x5b, - 0xff, 0x7d, 0xf6, 0x7d, 0x98, 0x76, 0x7d, 0xce, 0xee, 0x16, 0x12, 0xde, 0x72, 0xe2, 0x4e, 0xe5, 0xf1, 0xa9, 0x90, - 0x7b, 0x0f, 0x72, 0x2d, 0xbf, 0xed, 0x86, 0x86, 0xce, 0xf1, 0xb2, 0x45, 0x71, 0x55, 0xa7, 0xf2, 0x47, 0x51, 0xab, - 0xaa, 0xa4, 0x2a, 0x7b, 0x1e, 0x39, 0x93, 0x93, 0xb3, 0xdc, 0xab, 0x0a, 0x43, 0x03, 0x8f, 0x38, 0x5b, 0x62, 0x9d, - 0xbd, 0xc7, 0xcc, 0xe2, 0x84, 0x8f, 0x42, 0x03, 0xc1, 0x2a, 0xe9, 0x13, 0x8e, 0xe8, 0x0b, 0xb4, 0xd3, 0xfa, 0xd2, - 0xcd, 0xed, 0x47, 0x96, 0xb2, 0x83, 0xa3, 0xf1, 0xca, 0x8a, 0x7c, 0x9d, 0x02, 0x31, 0x53, 0xac, 0x62, 0xe4, 0xf3, - 0x1b, 0x84, 0x44, 0xf3, 0x8b, 0xe9, 0x82, 0xf6, 0x2e, 0x6b, 0x51, 0x1d, 0x3e, 0x6b, 0xf6, 0xca, 0x0b, 0x40, 0x1e, - 0x57, 0x15, 0x87, 0x48, 0x7b, 0x83, 0x38, 0x4d, 0x88, 0x7a, 0x76, 0xdb, 0xb3, 0x85, 0x38, 0x31, 0xcb, 0xd1, 0x62, - 0xd2, 0xab, 0x12, 0xd9, 0x6f, 0x4f, 0x4f, 0x20, 0x25, 0x3a, 0x63, 0x2c, 0x19, 0x69, 0x4b, 0xf9, 0x86, 0xe6, 0xf4, - 0x1e, 0xd6, 0x31, 0x11, 0xb1, 0x5a, 0x00, 0x52, 0x0d, 0x29, 0xf4, 0x35, 0x6a, 0x47, 0x44, 0xf8, 0xf9, 0xc8, 0x52, - 0x29, 0x32, 0xc6, 0x37, 0xf6, 0x23, 0x6d, 0x31, 0xab, 0x88, 0x53, 0xc4, 0xac, 0x61, 0x18, 0xd9, 0xb8, 0xc4, 0x28, - 0xa8, 0x56, 0x8f, 0x37, 0xf8, 0x04, 0x83, 0x94, 0x62, 0xab, 0xeb, 0x71, 0xdc, 0xc4, 0x4f, 0x47, 0x22, 0xc2, 0x7d, - 0x82, 0x38, 0x29, 0x81, 0x8d, 0xe7, 0x6f, 0xce, 0x54, 0xe7, 0x7c, 0x69, 0xb0, 0xe7, 0x48, 0xf5, 0x38, 0xc3, 0x40, - 0x13, 0x7c, 0xf5, 0xa3, 0xd9, 0x1f, 0xcb, 0x37, 0x32, 0x8b, 0x3b, 0x09, 0xa9, 0x95, 0xd3, 0xad, 0x36, 0x5b, 0x22, - 0x7e, 0x80, 0x0b, 0x67, 0xbc, 0x47, 0x35, 0x77, 0xf9, 0xa5, 0x26, 0x06, 0x56, 0x98, 0x13, 0x72, 0x37, 0x47, 0x92, - 0xbf, 0x00, 0xe3, 0x66, 0x0d, 0x6d, 0x2e, 0xc7, 0x2e, 0x38, 0x09, 0xe4, 0xea, 0xa6, 0xf4, 0x9b, 0xcf, 0x9e, 0x81, - 0xe9, 0x61, 0xf9, 0xd1, 0xef, 0x32, 0xff, 0x65, 0xac, 0x53, 0x13, 0xfc, 0xc8, 0x51, 0x59, 0x9f, 0xcb, 0xe3, 0xdf, - 0x01, 0xb7, 0x67, 0x7d, 0xe3, 0xcf, 0x93, 0x20, 0xce, 0x35, 0x7f, 0x66, 0xc1, 0x44, 0x36, 0xbb, 0xd6, 0xde, 0xcf, - 0x9f, 0xc1, 0x9b, 0xfb, 0x3d, 0x42, 0xe5, 0x6b, 0xe7, 0x14, 0xea, 0xa6, 0x01, 0x16, 0x7a, 0xf5, 0x60, 0x1f, 0x90, - 0x9b, 0x7d, 0x88, 0x0a, 0x14, 0x39, 0xa2, 0xd2, 0x35, 0xe3, 0x1a, 0xb6, 0x55, 0x3b, 0x94, 0x3d, 0xc3, 0x79, 0x4f, - 0x9b, 0x5d, 0xb5, 0xad, 0x57, 0x69, 0x13, 0x52, 0x98, 0xc2, 0x1a, 0xe8, 0x56, 0x37, 0xec, 0xdc, 0x1c, 0x2c, 0xdf, - 0x5a, 0x06, 0x1e, 0x16, 0xae, 0x98, 0xbd, 0x98, 0xf9, 0x8e, 0x0e, 0xfc, 0x59, 0xca, 0x2b, 0x0a, 0xb5, 0xfa, 0x8d, - 0xe3, 0xa8, 0x87, 0x36, 0xe0, 0x0d, 0x7f, 0x9d, 0x50, 0x9d, 0x3d, 0x67, 0x0b, 0x23, 0x17, 0xe2, 0xd7, 0xba, 0xc1, - 0x27, 0x74, 0xa9, 0x0a, 0x26, 0xe0, 0x4b, 0x9b, 0x1d, 0x0f, 0x5e, 0x87, 0x96, 0xa4, 0xf2, 0xb8, 0x79, 0x8c, 0xe5, - 0xdc, 0x4e, 0xb9, 0x54, 0x2b, 0xf3, 0xa3, 0x1b, 0x25, 0xd8, 0x20, 0x90, 0x24, 0x58, 0x84, 0xf0, 0x4f, 0x30, 0x67, - 0x1c, 0x89, 0x21, 0x7b, 0xa3, 0x62, 0x8a, 0x16, 0x14, 0x0c, 0x91, 0x52, 0xb6, 0x58, 0x60, 0xb3, 0xf7, 0x08, 0x7f, - 0x7f, 0xdf, 0x3a, 0xf5, 0xb4, 0xef, 0x9d, 0x02, 0xed, 0xdb, 0x27, 0xa5, 0xb4, 0xef, 0x9f, 0x14, 0x9d, 0xa5, 0x56, - 0x19, 0xfb, 0x6a, 0xc9, 0xbe, 0x1a, 0xd9, 0x63, 0x3b, 0xdb, 0x43, 0x2b, 0x8e, 0x6b, 0xd0, 0x8e, 0xc5, 0x82, 0x6f, - 0xc9, 0x01, 0xc7, 0x11, 0xcb, 0x92, 0xf5, 0x63, 0xa1, 0xb9, 0x77, 0xb5, 0x1e, 0xf9, 0xf6, 0x00, 0x15, 0x85, 0xb7, - 0xc3, 0xda, 0x8d, 0xac, 0x2c, 0xcf, 0x34, 0xb7, 0x4e, 0xe2, 0xd4, 0xd9, 0xde, 0x42, 0xf1, 0x74, 0xea, 0x08, 0x7e, - 0xd6, 0xe4, 0x70, 0x86, 0x4a, 0x13, 0xf8, 0x8f, 0x1c, 0x3f, 0x36, 0x5a, 0x5b, 0xd1, 0x78, 0xf3, 0xbd, 0x27, 0x1a, - 0x9a, 0xbf, 0xb8, 0x8a, 0x58, 0x98, 0x96, 0x93, 0x7b, 0x07, 0x53, 0x1f, 0x4a, 0xd6, 0xe2, 0x76, 0x07, 0x64, 0xb6, - 0x94, 0x97, 0x39, 0x41, 0x08, 0xa7, 0xba, 0x85, 0xff, 0x2c, 0xa0, 0x31, 0x27, 0x2d, 0x17, 0x53, 0xf9, 0xbc, 0xd5, - 0x86, 0xda, 0xce, 0x59, 0x83, 0x78, 0xec, 0xca, 0x74, 0x57, 0x82, 0x29, 0x3c, 0x9f, 0xc5, 0x15, 0x90, 0xfa, 0x85, - 0x35, 0xff, 0x46, 0xd9, 0xc3, 0xe5, 0x4e, 0x4c, 0x83, 0xff, 0xf7, 0x64, 0x3b, 0x08, 0x27, 0x74, 0x35, 0x4e, 0xb9, - 0x24, 0xe2, 0x7e, 0x6e, 0xa5, 0xed, 0x99, 0x37, 0x72, 0x7d, 0xfb, 0x8c, 0x61, 0x7a, 0xae, 0x42, 0x20, 0x53, 0x68, - 0x3f, 0xdd, 0x3f, 0x8f, 0x71, 0x48, 0xb0, 0x62, 0xcf, 0x09, 0x56, 0x19, 0x98, 0x92, 0x77, 0x33, 0x99, 0x9e, 0xbf, - 0xfc, 0x5f, 0xe6, 0xf7, 0x92, 0xf5, 0xa0, 0x37, 0x7b, 0xcb, 0x36, 0xb7, 0x85, 0x75, 0x69, 0x31, 0xb3, 0x7e, 0x34, - 0xd3, 0xfa, 0x5f, 0x71, 0x80, 0xb7, 0xdb, 0xe9, 0x8a, 0x3f, 0xaa, 0x0c, 0xd2, 0x38, 0x64, 0xdd, 0x6f, 0x4b, 0xd6, - 0x03, 0xde, 0xed, 0xed, 0xf3, 0x5b, 0x1c, 0xfe, 0xb1, 0x09, 0x7c, 0x4b, 0x17, 0x00, 0x02, 0xf8, 0x91, 0xbb, 0x1c, - 0xbb, 0x9c, 0xc9, 0x9d, 0xeb, 0x0a, 0x0b, 0xaa, 0x96, 0x43, 0x16, 0x2e, 0x96, 0x6c, 0x41, 0x3e, 0x5e, 0x13, 0xd9, - 0xe8, 0xc0, 0xec, 0x12, 0xb1, 0xf7, 0x44, 0x09, 0xde, 0x6c, 0xf3, 0x29, 0xd1, 0xf3, 0xe7, 0x04, 0xda, 0x66, 0xd7, - 0x89, 0x0d, 0xb9, 0xb6, 0x38, 0x15, 0x27, 0x00, 0xec, 0x7b, 0xe6, 0xc2, 0xe0, 0x6c, 0xa4, 0xf6, 0x41, 0x0b, 0xa7, - 0xb7, 0x04, 0xfa, 0xd9, 0x38, 0x45, 0xde, 0xef, 0x00, 0x95, 0x52, 0x78, 0xe2, 0x10, 0x13, 0x2a, 0x76, 0xf8, 0x9c, - 0x52, 0x9f, 0x43, 0x8e, 0x9c, 0x77, 0xc0, 0x89, 0x5b, 0xda, 0x74, 0x63, 0x79, 0xbb, 0xe1, 0xbb, 0x8a, 0x74, 0x65, - 0xf5, 0xb0, 0x84, 0xb6, 0xcd, 0xd1, 0x19, 0x67, 0x94, 0xa0, 0xc4, 0x08, 0x29, 0xc3, 0xf3, 0x63, 0x30, 0x48, 0x26, - 0xe3, 0x30, 0xd1, 0x6b, 0xce, 0x0a, 0xa3, 0xff, 0x44, 0x08, 0x23, 0x84, 0x9f, 0x5f, 0x67, 0x6c, 0xdf, 0xa3, 0xbb, - 0xb6, 0xe9, 0x5e, 0x6f, 0x15, 0xb1, 0xcf, 0x2b, 0x0d, 0x4a, 0xa5, 0xd2, 0x58, 0xdb, 0x8c, 0x7f, 0x75, 0x52, 0x9d, - 0x46, 0x1d, 0x8e, 0x70, 0x76, 0xa6, 0xcd, 0xf9, 0xbf, 0x9e, 0x99, 0xb9, 0xd7, 0xce, 0xcb, 0x9e, 0x19, 0xeb, 0xc6, - 0x25, 0x91, 0x16, 0xe4, 0x26, 0x0d, 0x25, 0xeb, 0xb1, 0xd1, 0xde, 0x94, 0x4c, 0xfd, 0xc8, 0xa4, 0x80, 0x8d, 0xb1, - 0xda, 0xe1, 0x32, 0x3e, 0xcd, 0xfb, 0xa8, 0x24, 0x0b, 0x2e, 0xc4, 0xf9, 0x70, 0xbb, 0xb1, 0x22, 0x85, 0x1d, 0x68, - 0xf2, 0xeb, 0x4f, 0xc9, 0xae, 0xf0, 0x9d, 0xdf, 0x81, 0x64, 0xd6, 0x17, 0x2b, 0xc5, 0x06, 0x75, 0x05, 0x7a, 0x03, - 0x2e, 0xdf, 0xd1, 0x1b, 0x65, 0x34, 0x7c, 0x5d, 0x4a, 0x54, 0x92, 0x50, 0x63, 0xa5, 0x60, 0xb7, 0x58, 0x97, 0x51, - 0x14, 0x17, 0x6b, 0xa2, 0x5f, 0x15, 0x4c, 0x16, 0xdb, 0x6e, 0xe0, 0xdc, 0x04, 0xea, 0x51, 0x07, 0x27, 0xfa, 0x3b, - 0x44, 0xde, 0x16, 0xc5, 0x86, 0x6c, 0x1b, 0x88, 0xa1, 0x15, 0xd7, 0x75, 0xaa, 0xb1, 0x3e, 0xf2, 0x80, 0x1e, 0xf7, - 0xaf, 0x8e, 0x21, 0x78, 0xf8, 0x69, 0xc0, 0x52, 0x57, 0x9d, 0x3a, 0xbc, 0x7d, 0x74, 0x63, 0x49, 0xea, 0x71, 0x7e, - 0xf3, 0x4f, 0xc5, 0x36, 0x2d, 0x4f, 0xb7, 0xc8, 0x0d, 0x89, 0xe0, 0x5d, 0x41, 0xf6, 0xd3, 0xc1, 0x6d, 0x7b, 0xa6, - 0x28, 0x0a, 0x2f, 0x94, 0xb4, 0xbc, 0x29, 0x3c, 0x8c, 0xe3, 0x46, 0x8a, 0x1a, 0x2a, 0x32, 0xfe, 0x31, 0x36, 0x2c, - 0x92, 0x9d, 0xad, 0x0f, 0x63, 0xe7, 0x95, 0x90, 0x8f, 0x2b, 0xd7, 0xea, 0x46, 0xa6, 0x63, 0x5b, 0x14, 0x39, 0x7a, - 0x9d, 0x07, 0xa0, 0xf2, 0x47, 0x61, 0x12, 0x50, 0x1c, 0x89, 0x00, 0xa2, 0x3a, 0xf1, 0xa2, 0xe8, 0x81, 0xcd, 0xa1, - 0x19, 0x56, 0x83, 0x7c, 0x2a, 0xfa, 0x1b, 0x15, 0x9d, 0xd9, 0xf1, 0x50, 0xd8, 0x8b, 0x4c, 0xac, 0x3e, 0xe6, 0xae, - 0x43, 0x91, 0x36, 0x72, 0xea, 0x3b, 0xd7, 0x98, 0x37, 0xd0, 0xc3, 0x22, 0x14, 0x7f, 0x61, 0x83, 0x98, 0xb2, 0x01, - 0x2b, 0x64, 0xec, 0x79, 0x04, 0x30, 0x4b, 0xf2, 0x45, 0x12, 0x78, 0xd3, 0xaf, 0x40, 0xbf, 0xc1, 0x7d, 0xb5, 0x6f, - 0x26, 0x4d, 0xb3, 0x70, 0x77, 0x63, 0xbf, 0x2f, 0x36, 0x72, 0x4f, 0x08, 0x22, 0x58, 0x92, 0xd9, 0xb2, 0x96, 0xde, - 0x03, 0x7e, 0xa5, 0xe0, 0x19, 0x78, 0xc8, 0x00, 0x8e, 0x98, 0x96, 0xb8, 0xae, 0xd6, 0x93, 0xab, 0xc3, 0x56, 0x6e, - 0x0b, 0x25, 0x38, 0x44, 0xd2, 0xe8, 0x96, 0x4a, 0xb3, 0x94, 0xee, 0x99, 0xad, 0xbb, 0x91, 0x71, 0xfc, 0x65, 0x50, - 0x9e, 0x58, 0x8f, 0x5f, 0x93, 0xc8, 0x96, 0x44, 0x14, 0x97, 0x5f, 0xe7, 0x90, 0xdd, 0x58, 0xa9, 0x98, 0x0c, 0x54, - 0x3d, 0x79, 0xaa, 0x09, 0xde, 0x60, 0x71, 0x17, 0x1c, 0xe9, 0x03, 0xed, 0x09, 0xc5, 0x49, 0xaa, 0x4a, 0xa9, 0x87, - 0x7e, 0xc2, 0x57, 0xd8, 0xe7, 0xa2, 0xb7, 0xd9, 0x31, 0xcd, 0xd8, 0x67, 0x34, 0xc1, 0x80, 0x3a, 0x09, 0x4e, 0xbb, - 0x66, 0x78, 0xe4, 0x48, 0x6c, 0x3a, 0x90, 0x8f, 0xf1, 0x54, 0xe8, 0x36, 0x6e, 0x56, 0x21, 0x8e, 0x86, 0xd0, 0xfd, - 0x30, 0x14, 0x46, 0x3f, 0xa3, 0x74, 0xac, 0x3e, 0xed, 0xd7, 0x5c, 0xd4, 0xce, 0x98, 0xa2, 0x29, 0x2f, 0xbb, 0xa6, - 0x00, 0x6f, 0xa4, 0xbc, 0xc1, 0x0a, 0xf8, 0xfe, 0xb2, 0x59, 0x57, 0x8f, 0x17, 0x36, 0xf9, 0x0f, 0xae, 0x83, 0x8d, - 0x84, 0x09, 0xfc, 0x11, 0x92, 0x99, 0x2d, 0x82, 0x35, 0x8c, 0xf3, 0x92, 0x58, 0x38, 0x7a, 0x9c, 0xef, 0x07, 0xd9, - 0x1f, 0x57, 0x8d, 0x07, 0x61, 0x0b, 0x0f, 0xad, 0xe4, 0x9c, 0xa8, 0xd7, 0xd4, 0xa9, 0x91, 0x0f, 0x22, 0x93, 0xc0, - 0x84, 0xf2, 0x3c, 0xc1, 0x34, 0xab, 0xb3, 0x59, 0x50, 0xdb, 0x44, 0xc5, 0xa0, 0xd0, 0x8d, 0xdb, 0x99, 0x20, 0xc9, - 0x86, 0xe0, 0x94, 0x97, 0x65, 0xc3, 0xed, 0x75, 0x6b, 0xe6, 0x45, 0xf3, 0xd7, 0x64, 0x87, 0xa5, 0xdf, 0x05, 0x1d, - 0x6b, 0xe3, 0xf5, 0x60, 0x7b, 0xd0, 0x79, 0x58, 0xbc, 0x50, 0x3a, 0x8d, 0xaa, 0x9b, 0x7a, 0x11, 0x37, 0xfb, 0x39, - 0x75, 0x35, 0xd1, 0x6c, 0x09, 0x48, 0x67, 0xa3, 0x7c, 0x8f, 0x9d, 0xb8, 0x46, 0x51, 0x5a, 0x4b, 0xab, 0x5b, 0xe6, - 0x1d, 0x8b, 0x91, 0xbb, 0x81, 0x51, 0x62, 0xed, 0x22, 0x86, 0x9a, 0x9f, 0xc3, 0xdc, 0x9e, 0x98, 0x40, 0x45, 0xff, - 0x3a, 0x9f, 0xcc, 0xec, 0x62, 0x9a, 0x4a, 0x32, 0xcc, 0x07, 0xa5, 0x6f, 0x89, 0xe6, 0xee, 0xf1, 0x9c, 0x93, 0xd2, - 0xb6, 0xad, 0x62, 0x1d, 0xba, 0x85, 0x81, 0x0f, 0x5c, 0x4d, 0x03, 0xc1, 0x15, 0xbd, 0xc5, 0x98, 0x67, 0xf0, 0x7c, - 0xc0, 0xec, 0x1b, 0x79, 0x3e, 0x2f, 0x45, 0xde, 0x3e, 0x91, 0x19, 0xbe, 0x50, 0xa0, 0x98, 0xde, 0xe9, 0x7c, 0x6f, - 0x9f, 0x87, 0x1b, 0x77, 0x59, 0xe0, 0xbe, 0x24, 0x0e, 0x19, 0xfe, 0x75, 0x17, 0x5b, 0xc6, 0x1a, 0xcf, 0x9c, 0xfb, - 0x2d, 0x89, 0x09, 0xa5, 0xda, 0xae, 0x1f, 0xa2, 0xbc, 0x16, 0x61, 0x52, 0x85, 0xa5, 0xdb, 0x2a, 0xa4, 0x32, 0xf4, - 0x45, 0xa4, 0x8a, 0xc7, 0x99, 0x9b, 0x9d, 0xa1, 0x34, 0x82, 0x0c, 0x05, 0x13, 0x64, 0xb5, 0x4f, 0xa2, 0x79, 0x56, - 0xf2, 0xa0, 0x4d, 0x13, 0xf9, 0xf0, 0xba, 0x2a, 0x63, 0xe1, 0x71, 0xe6, 0xde, 0x76, 0xc4, 0xdc, 0xba, 0x8e, 0xf3, - 0xea, 0x03, 0x75, 0x2b, 0x47, 0xe6, 0xb9, 0x62, 0x6c, 0xc5, 0xd8, 0x3d, 0xa8, 0x45, 0xa0, 0x0c, 0x45, 0x12, 0x0e, - 0x6c, 0x31, 0x7a, 0x7b, 0xa1, 0xce, 0x06, 0xc2, 0xad, 0xb2, 0x3e, 0xaa, 0xc5, 0x6b, 0xda, 0xb6, 0x52, 0x0a, 0x4e, - 0xa0, 0x10, 0x4e, 0x34, 0xf6, 0x9c, 0x0f, 0xff, 0xfc, 0x5c, 0xa7, 0x1e, 0xff, 0x99, 0x10, 0x9b, 0xfd, 0x97, 0xf7, - 0xaf, 0x63, 0x0a, 0xd0, 0xeb, 0x9e, 0x75, 0x45, 0x7a, 0xad, 0xeb, 0x35, 0xd2, 0xab, 0xaf, 0x57, 0xb5, 0x39, 0xe1, - 0x59, 0x2d, 0xb5, 0x51, 0x1b, 0x77, 0x6e, 0x14, 0xeb, 0x30, 0x94, 0x94, 0xd4, 0x7e, 0x4f, 0x4f, 0x3f, 0x8d, 0x55, - 0x99, 0x6f, 0xcd, 0xa4, 0x98, 0x4d, 0x5f, 0x1c, 0xab, 0xf5, 0xb8, 0x8c, 0x10, 0xbb, 0x17, 0x43, 0x6d, 0xa5, 0x3a, - 0x35, 0x75, 0x9b, 0xcf, 0x2f, 0xc6, 0xc5, 0xe5, 0xcb, 0xbf, 0x22, 0xc4, 0xf3, 0x11, 0xe3, 0xa1, 0x8d, 0x76, 0xde, - 0x37, 0x48, 0x8d, 0xcb, 0xcd, 0x11, 0xe7, 0x68, 0x56, 0x15, 0xc6, 0x88, 0xa7, 0x55, 0xe7, 0x2e, 0xb8, 0xf6, 0x20, - 0xf0, 0x73, 0x71, 0x55, 0xa9, 0x48, 0x52, 0xdf, 0x36, 0xb0, 0x2e, 0x37, 0x4b, 0x93, 0xc3, 0xdf, 0x0b, 0x2c, 0xe8, - 0x63, 0x53, 0x95, 0xeb, 0x07, 0x25, 0xc4, 0xd8, 0x29, 0x62, 0xca, 0xa9, 0xf4, 0x2e, 0xac, 0x7c, 0x51, 0x5f, 0x4f, - 0x99, 0xfa, 0x36, 0x88, 0x31, 0x8b, 0x31, 0x27, 0x4b, 0x31, 0x27, 0x79, 0x60, 0xfb, 0x3c, 0x06, 0xc6, 0xc5, 0x24, - 0x10, 0xf9, 0x70, 0xe3, 0xca, 0x58, 0xbe, 0x08, 0x18, 0xac, 0xa2, 0x0d, 0x04, 0xd3, 0x3b, 0x33, 0xed, 0xe2, 0x1f, - 0xf3, 0xcb, 0x41, 0x64, 0x5c, 0x89, 0x21, 0xac, 0x8e, 0xf8, 0xad, 0xd3, 0x0b, 0x64, 0x62, 0xe5, 0x8c, 0x66, 0x09, - 0x98, 0x75, 0xd3, 0x34, 0x38, 0x56, 0x4d, 0x83, 0x4a, 0x33, 0xaf, 0xb0, 0x0c, 0x24, 0x31, 0x30, 0x95, 0x6a, 0xf8, - 0x95, 0x16, 0x09, 0xce, 0xf9, 0xfb, 0xae, 0xcf, 0x29, 0x90, 0xc6, 0x99, 0x46, 0xa5, 0xc0, 0xe7, 0x0e, 0xf8, 0x05, - 0xfa, 0x15, 0x9f, 0x88, 0xe3, 0x34, 0xe9, 0x91, 0xc9, 0xe8, 0x81, 0xda, 0x81, 0x90, 0x59, 0x4b, 0x46, 0x61, 0x1a, - 0x42, 0x28, 0x05, 0x44, 0x7c, 0x3f, 0xcc, 0x45, 0x95, 0x35, 0xaf, 0xc6, 0x02, 0xb7, 0x10, 0x31, 0x23, 0xaa, 0x06, - 0x22, 0xc9, 0x48, 0xae, 0x1b, 0x32, 0x2c, 0x96, 0x26, 0x2d, 0xc6, 0xe0, 0x04, 0xc9, 0x3c, 0x9d, 0x08, 0xfe, 0x65, - 0x48, 0xc6, 0x05, 0x6f, 0x7a, 0xaf, 0x80, 0xbe, 0xc6, 0xc5, 0x64, 0xe7, 0xcd, 0xbc, 0xe8, 0x89, 0xb4, 0x5c, 0xe5, - 0x43, 0xa2, 0xd0, 0xdf, 0xd7, 0x7d, 0xef, 0x58, 0x3d, 0x48, 0xc1, 0xbc, 0x2d, 0x6a, 0x8b, 0x96, 0xed, 0xb5, 0x95, - 0xc7, 0x72, 0xdc, 0x3d, 0x4a, 0x50, 0x00, 0xdd, 0x66, 0xd1, 0x0a, 0x3c, 0x49, 0xd6, 0xd8, 0xa9, 0x4f, 0x44, 0x74, - 0x74, 0x1b, 0x25, 0xb3, 0x23, 0x5b, 0x17, 0x3f, 0x90, 0x91, 0xe4, 0xcc, 0x5c, 0x89, 0xce, 0xff, 0x59, 0xf2, 0x26, - 0x17, 0x33, 0x5b, 0x75, 0xc8, 0x01, 0x6e, 0x3a, 0x13, 0x61, 0x8a, 0xf6, 0x56, 0x66, 0x23, 0x44, 0x86, 0x93, 0x49, - 0x16, 0x64, 0xea, 0xc5, 0x5f, 0x8c, 0x14, 0xfc, 0x47, 0xc4, 0x86, 0x96, 0x3c, 0xd2, 0xff, 0x70, 0x0d, 0xe1, 0x5b, - 0x39, 0x1c, 0x24, 0xd5, 0x7b, 0x2d, 0xb8, 0x2d, 0x2d, 0x1b, 0x66, 0x83, 0x24, 0x3c, 0x3e, 0xbb, 0x7c, 0xe6, 0xdb, - 0x83, 0xfc, 0x43, 0x44, 0x08, 0x84, 0xfa, 0xdf, 0xf4, 0xaa, 0x76, 0xf1, 0x32, 0x2a, 0x4e, 0x83, 0xf2, 0xf5, 0xf8, - 0x8c, 0xc3, 0x1b, 0x2a, 0x2f, 0xe0, 0xb7, 0x6f, 0xe7, 0x1c, 0x98, 0x81, 0x2f, 0x63, 0xab, 0xb1, 0x80, 0xbd, 0x70, - 0xd8, 0x63, 0x28, 0x59, 0xc4, 0xa1, 0xed, 0x6c, 0x84, 0xdb, 0xd0, 0xf5, 0x36, 0xdb, 0xaf, 0x94, 0x72, 0x75, 0xc6, - 0xf9, 0x3b, 0xdb, 0xaa, 0xe6, 0x66, 0xd7, 0x0d, 0x5b, 0x2a, 0xe9, 0x69, 0x5f, 0x6e, 0x30, 0x75, 0x43, 0xf6, 0x36, - 0xd4, 0x5a, 0xbe, 0x19, 0xd6, 0x95, 0x37, 0x0b, 0x83, 0x42, 0xc0, 0x98, 0x61, 0xcd, 0x15, 0xb9, 0xd6, 0xca, 0x7e, - 0x30, 0xc5, 0xfe, 0x30, 0x08, 0x89, 0xa8, 0x8a, 0x24, 0x67, 0x83, 0x1e, 0xe7, 0x6a, 0xed, 0x59, 0x3d, 0x02, 0x4b, - 0x27, 0x96, 0x63, 0xcd, 0x0a, 0x06, 0x43, 0xa9, 0xaa, 0xd5, 0x52, 0x77, 0xb8, 0x4a, 0x9f, 0x6a, 0x79, 0xc5, 0x0b, - 0x12, 0xf6, 0x0b, 0x88, 0x4e, 0x7c, 0x77, 0xf7, 0x24, 0xf2, 0x9d, 0x59, 0x7d, 0x63, 0x2a, 0x4d, 0x94, 0x67, 0xc5, - 0x0a, 0x4a, 0xd6, 0x3b, 0xc0, 0x50, 0x51, 0x63, 0x6c, 0xe8, 0x0e, 0x0d, 0xd6, 0xe6, 0x38, 0xdc, 0x17, 0xf6, 0xdb, - 0x82, 0xec, 0x47, 0xfd, 0x9c, 0x93, 0xfb, 0x68, 0xb3, 0xa8, 0x97, 0xf5, 0x56, 0x63, 0xe4, 0x08, 0xaf, 0x37, 0x27, - 0x55, 0xb6, 0xa0, 0xc9, 0x5e, 0x83, 0xd3, 0x4b, 0x33, 0x75, 0xa1, 0xec, 0xc4, 0x8c, 0x28, 0xd3, 0x41, 0x24, 0x09, - 0xba, 0xb3, 0x1e, 0x04, 0xd7, 0x2c, 0x0b, 0x6b, 0x93, 0x91, 0x7b, 0x30, 0x9c, 0x23, 0x15, 0xd1, 0x25, 0x14, 0xc5, - 0x39, 0x9b, 0xd7, 0x3f, 0x30, 0xe4, 0x28, 0x8f, 0xc5, 0xb2, 0x64, 0x41, 0xbd, 0x6f, 0x61, 0xa4, 0x26, 0xfb, 0x74, - 0x2c, 0xa5, 0x90, 0x1d, 0xc0, 0xc6, 0x8e, 0xb6, 0x73, 0xc1, 0x9c, 0xda, 0xba, 0x04, 0x3b, 0xd9, 0xa9, 0xb9, 0x5b, - 0x91, 0x01, 0x91, 0x07, 0x42, 0x14, 0x06, 0x7c, 0x7f, 0x5e, 0x11, 0xc0, 0x9a, 0xe3, 0x14, 0x89, 0x3f, 0x08, 0xe3, - 0x97, 0x1f, 0x14, 0x83, 0x84, 0xe5, 0xae, 0xe7, 0x70, 0xfa, 0x3a, 0x80, 0x56, 0xea, 0xc5, 0xe6, 0x7b, 0x26, 0xca, - 0x46, 0xfe, 0x2a, 0xd6, 0x3a, 0x62, 0x88, 0x70, 0xe0, 0xcb, 0x66, 0x43, 0xd2, 0x78, 0xb3, 0x5c, 0x9c, 0x8d, 0x9a, - 0xae, 0xab, 0x23, 0xee, 0x23, 0x15, 0x84, 0xb1, 0xd9, 0xf0, 0xd0, 0x8d, 0xf3, 0x43, 0xd6, 0x6e, 0x06, 0x87, 0x41, - 0x04, 0x7e, 0x6d, 0xba, 0x56, 0x97, 0x70, 0xb5, 0xa6, 0x99, 0x78, 0x2f, 0xce, 0xa6, 0xfb, 0xba, 0xd7, 0x35, 0xc2, - 0xbf, 0x5c, 0xe2, 0x80, 0xf9, 0x37, 0x52, 0xc5, 0x41, 0x8b, 0x39, 0x7a, 0x8d, 0x4b, 0x9a, 0xe9, 0xa9, 0x21, 0x77, - 0x57, 0xca, 0x7b, 0x28, 0x07, 0xaa, 0x63, 0x3c, 0x3d, 0x64, 0x37, 0x87, 0x5b, 0x80, 0xda, 0x8e, 0x10, 0x57, 0x06, - 0xea, 0x09, 0x80, 0x2b, 0x09, 0x84, 0x65, 0x1e, 0xcf, 0x90, 0xbe, 0x67, 0xd2, 0x09, 0x68, 0xe8, 0x40, 0xb9, 0xe9, - 0x49, 0x99, 0x43, 0xea, 0xa1, 0x0e, 0x52, 0x4c, 0x78, 0xd0, 0xcb, 0xae, 0x96, 0xea, 0x3a, 0x1a, 0x21, 0x69, 0x42, - 0x41, 0xfc, 0x82, 0xa0, 0xe8, 0xab, 0x21, 0xf2, 0x97, 0x89, 0xca, 0xba, 0xaa, 0xf0, 0x06, 0xff, 0x12, 0x2d, 0xb2, - 0xfa, 0xce, 0xcc, 0xec, 0x48, 0x5d, 0x56, 0xa2, 0xf6, 0x02, 0xb0, 0x0e, 0x87, 0xe0, 0x40, 0x22, 0x62, 0x9e, 0x44, - 0x13, 0xd9, 0x54, 0x28, 0x7f, 0xe6, 0xd0, 0x28, 0x80, 0xcb, 0x79, 0x24, 0x68, 0x22, 0xf0, 0xb1, 0x13, 0xe0, 0xcc, - 0x0c, 0x3c, 0x9c, 0xad, 0x26, 0x8d, 0xc0, 0x98, 0x6b, 0xe5, 0xa5, 0x66, 0x1f, 0x33, 0xa2, 0x1c, 0x17, 0x73, 0x23, - 0xbb, 0x6b, 0xf2, 0x70, 0x88, 0x79, 0x62, 0x63, 0x0e, 0xdf, 0xd7, 0x9e, 0x19, 0xd3, 0xbf, 0xcc, 0xc0, 0x27, 0x25, - 0xea, 0x4e, 0x0d, 0x8a, 0xd7, 0xed, 0x9d, 0xd7, 0x56, 0xbb, 0x86, 0x5c, 0x16, 0x1d, 0x06, 0xab, 0xb5, 0xff, 0xd7, - 0x7f, 0x8a, 0xe3, 0xbe, 0x72, 0x3e, 0x06, 0x57, 0x3c, 0x04, 0x87, 0x35, 0x43, 0xcd, 0xaf, 0xeb, 0xe2, 0x39, 0x3e, - 0x6d, 0x1f, 0xe6, 0xc6, 0xd3, 0xdd, 0x81, 0x97, 0xb9, 0x90, 0xfa, 0xcc, 0x12, 0xa2, 0x0f, 0x43, 0x8b, 0x67, 0x63, - 0x54, 0x89, 0xc6, 0x97, 0x0e, 0x29, 0x96, 0x2d, 0x9e, 0xee, 0x04, 0xe2, 0xe5, 0x70, 0x77, 0xb6, 0x40, 0xac, 0x28, - 0x11, 0xe6, 0x74, 0x22, 0xd2, 0x38, 0x02, 0xc6, 0x2b, 0xf1, 0xc0, 0x10, 0x18, 0x69, 0x94, 0x59, 0xd3, 0xfe, 0xb0, - 0x11, 0xd9, 0xe7, 0x90, 0x68, 0x32, 0x6c, 0xca, 0x3b, 0x9b, 0x51, 0x7b, 0x25, 0x12, 0x8a, 0x86, 0x75, 0xdf, 0x4d, - 0x33, 0x2a, 0xef, 0xc5, 0x38, 0x24, 0x0e, 0xe1, 0xa4, 0x77, 0x3f, 0x6d, 0x1f, 0x4a, 0x1e, 0x7e, 0x0e, 0xfb, 0xc3, - 0x0f, 0xfe, 0xe1, 0xe7, 0x70, 0x77, 0x7e, 0xf0, 0x9d, 0x9f, 0x43, 0xde, 0xf9, 0x41, 0xbc, 0x54, 0x9a, 0xbe, 0xd2, - 0x9e, 0x07, 0x63, 0xc5, 0x50, 0x2e, 0xcb, 0xc8, 0x56, 0xaa, 0xe0, 0x17, 0x3f, 0x24, 0xdc, 0xe7, 0x12, 0x29, 0x39, - 0x25, 0x2e, 0x58, 0x89, 0x4a, 0x56, 0x86, 0x4e, 0x81, 0x7d, 0x1a, 0xd0, 0xc3, 0xea, 0xed, 0xe7, 0xfc, 0xcb, 0x6d, - 0x50, 0x74, 0x26, 0xe2, 0x01, 0x24, 0x43, 0xb9, 0x33, 0x07, 0x2f, 0x4c, 0x49, 0x18, 0x65, 0x39, 0x62, 0xb4, 0xa2, - 0xd2, 0x8e, 0xb3, 0x44, 0xef, 0xdc, 0x01, 0x16, 0x82, 0xbe, 0x5d, 0xe8, 0xb5, 0x72, 0x58, 0x7f, 0xff, 0x05, 0x28, - 0x55, 0x57, 0x0c, 0x78, 0x60, 0x1f, 0x7b, 0x71, 0x1f, 0x69, 0xe5, 0xd5, 0xa4, 0x8a, 0x1a, 0x5c, 0x93, 0x83, 0x31, - 0x46, 0x48, 0xdc, 0xd3, 0xbf, 0x64, 0x4d, 0x72, 0xe6, 0xe6, 0xad, 0x66, 0xe1, 0x1e, 0xa3, 0xe7, 0x80, 0xe6, 0xc4, - 0xa8, 0x9a, 0x19, 0xb6, 0x88, 0x5a, 0xb3, 0x9a, 0x33, 0x8b, 0x38, 0x59, 0x8a, 0xad, 0xab, 0xb0, 0xe7, 0x3d, 0x7e, - 0xca, 0xef, 0xe0, 0x2a, 0x37, 0x43, 0x1a, 0xec, 0x8b, 0x0c, 0xec, 0x83, 0x2b, 0x6c, 0x6b, 0x0d, 0xa6, 0x27, 0x9c, - 0xad, 0xc5, 0xf5, 0xd5, 0x14, 0xbe, 0x20, 0xad, 0xa1, 0x2d, 0x45, 0x34, 0xba, 0x4b, 0x26, 0x36, 0x52, 0xda, 0xfa, - 0xe1, 0x6b, 0x0b, 0x8d, 0x36, 0x2b, 0x96, 0x60, 0xc9, 0xee, 0x37, 0x2f, 0xb9, 0x0f, 0x4d, 0xe6, 0x2c, 0xc8, 0x44, - 0xd5, 0x4d, 0x90, 0x36, 0x05, 0xbe, 0x38, 0x59, 0x61, 0x3c, 0x02, 0x59, 0xe4, 0x36, 0x17, 0x87, 0x53, 0x47, 0x2d, - 0xa3, 0xaa, 0x84, 0x48, 0x7d, 0x56, 0xae, 0x93, 0x4b, 0xd0, 0xf1, 0xe2, 0x40, 0x04, 0x97, 0xc3, 0x84, 0x54, 0x6a, - 0x3a, 0x6d, 0xd7, 0x68, 0x6f, 0x21, 0xcf, 0xa1, 0x4e, 0x3f, 0x0d, 0x36, 0x84, 0x21, 0xaa, 0x31, 0xf8, 0x32, 0xf3, - 0xf4, 0x9a, 0x2e, 0x4d, 0xdb, 0xc7, 0x01, 0x04, 0x7a, 0xb1, 0x3d, 0x95, 0xce, 0x5d, 0x9f, 0x92, 0x48, 0x20, 0x91, - 0xf8, 0x02, 0xe0, 0x03, 0x80, 0xaf, 0x7a, 0x89, 0xaa, 0x45, 0x26, 0xbd, 0x54, 0x81, 0x9e, 0x29, 0xb8, 0x03, 0x32, - 0x43, 0x2b, 0x40, 0xe5, 0x8f, 0x48, 0xf1, 0xb5, 0x43, 0xb2, 0x98, 0xf0, 0xd2, 0x50, 0xbc, 0x8e, 0x09, 0xed, 0x7c, - 0x98, 0x9a, 0x5e, 0x22, 0x77, 0x81, 0x94, 0x8e, 0xd8, 0xa2, 0x9f, 0xbe, 0x3c, 0xbb, 0x69, 0xe1, 0x24, 0x8f, 0x2c, - 0xbf, 0xd6, 0xfe, 0x2d, 0x6b, 0xdb, 0x55, 0xf5, 0x47, 0xa6, 0xa4, 0x0e, 0xb4, 0x21, 0x94, 0xeb, 0x99, 0xb2, 0xa7, - 0xf4, 0x15, 0xec, 0x2c, 0x86, 0x45, 0xaf, 0x2d, 0xb3, 0xdd, 0x1c, 0x3e, 0x74, 0xd1, 0x03, 0xd1, 0x84, 0xdb, 0xd7, - 0x48, 0xa0, 0xb9, 0x44, 0xb0, 0x18, 0x9e, 0xe1, 0xd2, 0x6e, 0xfc, 0x92, 0x53, 0x14, 0xc4, 0x2a, 0xf0, 0x21, 0x7d, - 0xff, 0x01, 0x43, 0x86, 0xb2, 0xdd, 0x86, 0xc3, 0x55, 0x0d, 0x34, 0x5f, 0xf7, 0x71, 0xd8, 0xab, 0x13, 0xb0, 0xb6, - 0x64, 0xbe, 0xda, 0xb4, 0x51, 0xec, 0x35, 0x97, 0xa7, 0xbd, 0xb6, 0x52, 0xe0, 0xcf, 0xc5, 0x47, 0x7f, 0x7b, 0x5e, - 0xd4, 0x2c, 0xcb, 0x8b, 0xd2, 0x7b, 0x5b, 0xd5, 0xec, 0xb4, 0x06, 0xa3, 0x3f, 0x4e, 0x85, 0x90, 0x58, 0x0e, 0x93, - 0xd2, 0xf3, 0xd1, 0xa8, 0x16, 0xbb, 0xd7, 0x64, 0x1e, 0x1f, 0x26, 0xa1, 0x9a, 0x4d, 0x8d, 0x3c, 0xb8, 0xd7, 0x9b, - 0x0b, 0x7d, 0x8f, 0x02, 0xd5, 0xbd, 0x16, 0x4e, 0xd5, 0x55, 0x29, 0x41, 0x4c, 0x46, 0x46, 0x33, 0xcd, 0xc6, 0xbc, - 0x0c, 0xdc, 0x9a, 0xa9, 0x7e, 0x41, 0x9f, 0x48, 0xc9, 0x61, 0xd8, 0x59, 0x59, 0x94, 0x8a, 0x49, 0x4a, 0x00, 0x8b, - 0xed, 0x67, 0x71, 0x72, 0x60, 0x50, 0xb5, 0xea, 0x3c, 0x60, 0x24, 0x8e, 0xc5, 0xe2, 0x23, 0x50, 0xf1, 0x5b, 0x07, - 0xa8, 0x12, 0x4e, 0x8f, 0x55, 0x71, 0x1e, 0x7e, 0x10, 0xa5, 0x52, 0x4f, 0x40, 0xa0, 0xa6, 0x4e, 0x5e, 0xe6, 0x5e, - 0xb0, 0x7c, 0x33, 0xa7, 0x8d, 0xbd, 0x30, 0x2b, 0x1d, 0x90, 0x6b, 0xd3, 0x48, 0x0c, 0x45, 0xfc, 0x93, 0x63, 0xe3, - 0x36, 0xba, 0xb0, 0xea, 0x85, 0xe5, 0x5e, 0x54, 0x07, 0xa1, 0x41, 0xe8, 0x90, 0xa7, 0xca, 0x6d, 0x19, 0xd6, 0xe7, - 0x81, 0x97, 0x27, 0xfd, 0x0b, 0x4f, 0x0f, 0x36, 0x3d, 0xfc, 0x80, 0x45, 0x2b, 0x89, 0x34, 0x54, 0xb1, 0x49, 0xe1, - 0x8e, 0x48, 0x95, 0xe5, 0xce, 0xd3, 0x1e, 0xdd, 0x6b, 0x33, 0x0f, 0xd2, 0xd5, 0x47, 0x05, 0x45, 0x6b, 0x68, 0x09, - 0xa5, 0x2e, 0x60, 0x0a, 0xa3, 0x2c, 0xde, 0xe9, 0xab, 0xf5, 0x93, 0x5d, 0x4a, 0xc2, 0x01, 0x1f, 0xc3, 0x60, 0x26, - 0xf0, 0xef, 0x87, 0x48, 0x07, 0x37, 0xb5, 0x6e, 0x85, 0x32, 0x86, 0xb4, 0x42, 0x30, 0x1f, 0x49, 0x74, 0x98, 0xe0, - 0xfb, 0xc1, 0xa4, 0xc8, 0x49, 0xc1, 0x46, 0xe3, 0x37, 0xe3, 0x1a, 0x43, 0xc7, 0x99, 0xf1, 0x9d, 0x9f, 0xae, 0xd8, - 0xdb, 0x72, 0x5c, 0x1d, 0x42, 0xc0, 0xe5, 0x58, 0xee, 0x75, 0x5d, 0x90, 0x75, 0x8c, 0x76, 0x14, 0x3e, 0x23, 0x71, - 0xa9, 0x0b, 0x3d, 0xa5, 0xda, 0x91, 0x33, 0x86, 0x25, 0x38, 0x5d, 0xcd, 0x9f, 0xd8, 0xc6, 0x15, 0xb4, 0xed, 0xec, - 0x34, 0x50, 0xb7, 0x57, 0xc0, 0x83, 0x5d, 0x63, 0x4a, 0x94, 0x25, 0x56, 0x05, 0x34, 0x18, 0x01, 0x6d, 0x59, 0x60, - 0x54, 0x13, 0x31, 0xd1, 0x28, 0x8c, 0x12, 0xa9, 0xa5, 0x94, 0x1d, 0xcb, 0x1f, 0x75, 0x92, 0x4c, 0x92, 0x75, 0x28, - 0x4e, 0x7a, 0x62, 0x92, 0xd4, 0x6a, 0x5d, 0xb6, 0x78, 0x79, 0x21, 0xf6, 0x8b, 0x54, 0x7a, 0x62, 0xef, 0xa0, 0x05, - 0x72, 0xb3, 0xef, 0x69, 0x48, 0x0d, 0x8d, 0xce, 0xf6, 0x46, 0xe7, 0xe5, 0xa9, 0x6c, 0xbe, 0xd5, 0x51, 0xcb, 0xf8, - 0xc6, 0x98, 0xa2, 0x0a, 0xa8, 0x3f, 0xd6, 0x82, 0xf4, 0xfd, 0x4b, 0xb1, 0xce, 0x50, 0x34, 0x4c, 0x5d, 0xf6, 0x58, - 0x8c, 0x74, 0x9d, 0xe6, 0x89, 0x90, 0xe0, 0xde, 0x1d, 0x18, 0x78, 0x44, 0x99, 0x3b, 0x19, 0xd3, 0x09, 0xc2, 0x10, - 0x91, 0x75, 0xb2, 0xe6, 0x7d, 0x6e, 0xfd, 0x7c, 0x14, 0x87, 0x61, 0x0c, 0x1b, 0x4c, 0xae, 0xf6, 0x53, 0x7a, 0xef, - 0xc7, 0x62, 0xa4, 0x76, 0x9d, 0xd9, 0xcd, 0x78, 0x61, 0xa9, 0x3d, 0x16, 0xf6, 0x3f, 0x64, 0x3e, 0xf5, 0x58, 0xe9, - 0xbd, 0xb4, 0x86, 0x34, 0x9e, 0x59, 0x63, 0xd5, 0x5f, 0x82, 0x76, 0xe4, 0x12, 0xed, 0xc4, 0x4e, 0xa9, 0x2a, 0x48, - 0x28, 0x48, 0x8c, 0xa9, 0xed, 0x1c, 0x0c, 0x34, 0x63, 0x9d, 0xb9, 0x63, 0x8b, 0xbe, 0x3d, 0xe5, 0xa4, 0x1c, 0xa0, - 0xbc, 0x14, 0xfe, 0xd9, 0x76, 0x50, 0x62, 0x1f, 0xc7, 0x18, 0x5b, 0x81, 0x7d, 0x48, 0x20, 0x55, 0xc1, 0x84, 0x56, - 0x93, 0x07, 0x74, 0x71, 0x4a, 0xc7, 0x9f, 0x19, 0xe6, 0x4f, 0xb0, 0xfa, 0x9a, 0x27, 0xb6, 0xd9, 0x85, 0x63, 0x4c, - 0xa9, 0xd7, 0xd9, 0x11, 0xeb, 0xa7, 0x74, 0x61, 0x8b, 0xb5, 0x31, 0xa4, 0x6c, 0xc9, 0xd6, 0xb5, 0x45, 0xc8, 0x84, - 0x21, 0xeb, 0x3a, 0x52, 0x71, 0x03, 0xe7, 0x37, 0xe4, 0x02, 0x5e, 0xef, 0xe7, 0x5c, 0xa9, 0x67, 0x11, 0xcd, 0x32, - 0x41, 0xbb, 0x04, 0x72, 0xa4, 0xf3, 0xa2, 0xfe, 0xbf, 0x95, 0x10, 0xa2, 0x4b, 0x6b, 0xba, 0x2d, 0xa1, 0x4e, 0xf2, - 0xd9, 0x59, 0xb4, 0x80, 0xc7, 0x6e, 0x94, 0x1b, 0xe7, 0xb1, 0xb4, 0x09, 0x9e, 0x0d, 0x22, 0x81, 0x0d, 0xcb, 0x29, - 0x51, 0x0d, 0xab, 0xad, 0xee, 0x7a, 0x18, 0x9f, 0xdd, 0xde, 0x28, 0xc4, 0x50, 0x61, 0xf6, 0x77, 0xa0, 0xa4, 0xe2, - 0x5e, 0x97, 0xd4, 0x3a, 0x2a, 0xff, 0x1b, 0xa5, 0x0d, 0x41, 0xe1, 0x8b, 0x9b, 0x82, 0x1d, 0xdc, 0xeb, 0x9e, 0x1a, - 0x8a, 0xfd, 0xfd, 0x42, 0x85, 0x69, 0xa7, 0x0f, 0xca, 0x04, 0x4d, 0x78, 0x0b, 0x72, 0x39, 0xf2, 0xfd, 0x6c, 0x2a, - 0xbf, 0xc8, 0x2f, 0x7d, 0x7b, 0x6d, 0x08, 0x5b, 0xd1, 0x4a, 0x2b, 0x56, 0x47, 0xf9, 0x61, 0x78, 0x13, 0xb7, 0x45, - 0x06, 0x45, 0x7d, 0x5e, 0x63, 0xef, 0x90, 0xaa, 0xc4, 0x6e, 0x7b, 0xe2, 0x06, 0x61, 0x39, 0xe9, 0x12, 0x5c, 0x58, - 0x23, 0x11, 0xa3, 0xd4, 0x9c, 0xe1, 0x54, 0x8b, 0xda, 0xc2, 0x72, 0xae, 0xd5, 0x11, 0x15, 0x10, 0xaa, 0xef, 0xa9, - 0x52, 0xd6, 0xc0, 0xb0, 0x77, 0x9e, 0x86, 0xc1, 0xcb, 0xb1, 0xab, 0x6b, 0xe5, 0xe8, 0x34, 0x5d, 0xf7, 0xb4, 0x40, - 0x81, 0x36, 0xa5, 0xb7, 0x76, 0x59, 0x8e, 0xb7, 0xea, 0x02, 0x17, 0x43, 0x0b, 0x9e, 0x3b, 0xef, 0x00, 0xbe, 0x4a, - 0x1e, 0x29, 0x3c, 0x58, 0xba, 0x76, 0x05, 0xb4, 0x30, 0x99, 0x04, 0x1e, 0x9c, 0xc5, 0x5a, 0x25, 0x6b, 0x51, 0xe1, - 0x35, 0x21, 0x0c, 0xc8, 0x59, 0x1f, 0x6c, 0xbb, 0x31, 0x72, 0x89, 0xda, 0xeb, 0x47, 0x1a, 0x5a, 0x64, 0xfd, 0xa0, - 0x49, 0xcf, 0x03, 0x45, 0xe5, 0xa8, 0x7a, 0x77, 0xa7, 0x8c, 0xbe, 0xc4, 0x3c, 0x61, 0xd4, 0x27, 0x06, 0x8d, 0xf4, - 0x85, 0x3a, 0x22, 0xe4, 0xfc, 0xc4, 0x66, 0xcd, 0x57, 0xfb, 0xf0, 0x9e, 0x10, 0xc6, 0x6a, 0xd3, 0x91, 0xcf, 0x13, - 0x68, 0xcf, 0x96, 0xae, 0x5f, 0xd4, 0x90, 0xe1, 0xb5, 0xe9, 0x72, 0x48, 0xc6, 0x82, 0xa7, 0x66, 0x08, 0x83, 0x5a, - 0xc9, 0x38, 0x4d, 0xec, 0x73, 0x16, 0x26, 0xd2, 0x55, 0xb9, 0x86, 0x00, 0xd7, 0x2f, 0x9c, 0x49, 0xb3, 0xd8, 0x72, - 0x8b, 0x92, 0xd1, 0xa5, 0x26, 0xc4, 0x16, 0x4d, 0x44, 0x06, 0x00, 0xbd, 0x1c, 0xf6, 0x11, 0x90, 0xf0, 0x6d, 0xc2, - 0xb9, 0x79, 0x62, 0x4b, 0x1b, 0xd7, 0x5c, 0x50, 0x18, 0xee, 0xe8, 0xc9, 0x5e, 0x6c, 0x2a, 0x62, 0xcf, 0x60, 0x1e, - 0x9a, 0x8d, 0x65, 0x36, 0x7f, 0xe4, 0xa7, 0xe3, 0x50, 0x0c, 0xa4, 0xff, 0xc0, 0x82, 0xf8, 0x9f, 0xa1, 0x42, 0x5c, - 0x71, 0x41, 0xfe, 0x80, 0x2b, 0x69, 0xf8, 0x82, 0x74, 0x3b, 0x9d, 0xf9, 0xd9, 0xf4, 0xa9, 0x5a, 0x40, 0x50, 0x1e, - 0x08, 0x85, 0x34, 0x17, 0x90, 0xc6, 0x0b, 0x1c, 0x58, 0x2f, 0xec, 0x90, 0x04, 0xb6, 0x9e, 0x8e, 0x64, 0xd2, 0x48, - 0xa7, 0x78, 0xe0, 0x53, 0xbd, 0xb6, 0x3f, 0xd5, 0x31, 0xa5, 0x37, 0xe5, 0x69, 0xd3, 0x3c, 0x15, 0x0f, 0x3d, 0x6b, - 0xab, 0x08, 0x13, 0x06, 0x4f, 0x85, 0x13, 0x5e, 0xef, 0xe9, 0x5a, 0xbb, 0x86, 0xaf, 0xe0, 0x8b, 0x9e, 0x0d, 0xe6, - 0xc2, 0xe6, 0x5a, 0x24, 0xe8, 0x20, 0x4c, 0x17, 0x3e, 0x3e, 0xc2, 0xc8, 0x74, 0x29, 0xbd, 0xa2, 0x1f, 0x0d, 0x0a, - 0xc5, 0xdb, 0xf5, 0x87, 0xf6, 0x2e, 0x82, 0x83, 0xb3, 0x05, 0xd9, 0x98, 0x76, 0x07, 0x20, 0x0f, 0x69, 0x51, 0xd5, - 0x18, 0x23, 0xa4, 0x42, 0x1c, 0x43, 0xc4, 0xe9, 0xf6, 0x55, 0x5b, 0x1e, 0xba, 0xe5, 0x97, 0x3c, 0x23, 0xff, 0x5e, - 0xfc, 0x99, 0xf9, 0xae, 0x6f, 0xd0, 0x15, 0xd7, 0x79, 0x0e, 0xf1, 0xbd, 0xdf, 0xb5, 0x46, 0x42, 0x94, 0x84, 0x7f, - 0x0c, 0x1e, 0x20, 0x66, 0x3c, 0x58, 0x03, 0xf6, 0xbc, 0xba, 0x91, 0x93, 0xe0, 0xbe, 0x60, 0xe8, 0x6d, 0xf3, 0xb5, - 0x7e, 0x3c, 0x26, 0xf1, 0x16, 0x6d, 0x11, 0xbb, 0x52, 0x07, 0x33, 0x76, 0xe2, 0x9c, 0x0f, 0x93, 0xd9, 0x7f, 0x8c, - 0xb0, 0xc0, 0x11, 0x0a, 0x6a, 0x2d, 0xfc, 0xb2, 0x15, 0xc0, 0xad, 0xfe, 0x83, 0x91, 0x02, 0x37, 0xd1, 0x13, 0x3f, - 0xdb, 0x3d, 0xc5, 0x26, 0x38, 0x11, 0x7b, 0x45, 0x6c, 0xcf, 0x81, 0x5a, 0xad, 0x6a, 0x0a, 0xd5, 0xad, 0xd3, 0x41, - 0xe8, 0x62, 0x51, 0x98, 0xeb, 0x75, 0x14, 0xf8, 0xac, 0x5a, 0x56, 0x1d, 0x86, 0x6c, 0x57, 0xa1, 0xf6, 0x24, 0x1b, - 0x16, 0x25, 0x2a, 0x72, 0xe3, 0x78, 0x53, 0xac, 0x03, 0xea, 0xb7, 0x7a, 0x6d, 0x82, 0x5b, 0x2f, 0x78, 0x74, 0x2c, - 0xc8, 0xb5, 0x14, 0x31, 0x78, 0x82, 0xc8, 0xe0, 0x55, 0xb9, 0x40, 0x07, 0xbd, 0x74, 0x5f, 0x37, 0x1f, 0x5a, 0xe3, - 0xe9, 0x6e, 0x1a, 0x3e, 0xfb, 0xb9, 0xf7, 0xd6, 0x88, 0xed, 0x9a, 0x31, 0x32, 0x2e, 0x92, 0x16, 0x3d, 0x75, 0x8d, - 0xcb, 0x35, 0x98, 0x3d, 0xb4, 0x3a, 0x66, 0x98, 0xbf, 0x5c, 0x69, 0x31, 0xc6, 0xef, 0x44, 0x31, 0xed, 0x41, 0x37, - 0x2b, 0xc4, 0x3d, 0xbd, 0x60, 0xc0, 0x5a, 0x4b, 0xbc, 0x69, 0xf5, 0x56, 0x5b, 0x9f, 0x2d, 0xcb, 0x20, 0xfa, 0x46, - 0x53, 0xbe, 0x9b, 0x85, 0x2c, 0x97, 0x29, 0xd6, 0x68, 0x13, 0xf6, 0xe5, 0x72, 0x6f, 0x37, 0xb6, 0x95, 0xf1, 0x6f, - 0x51, 0xf5, 0x64, 0x48, 0x24, 0x2d, 0x51, 0x2a, 0x15, 0x38, 0xe9, 0xc2, 0x10, 0x6b, 0x3a, 0x6a, 0xb9, 0x4e, 0x82, - 0xf9, 0xbe, 0x3b, 0x75, 0x58, 0xfe, 0xf8, 0x9c, 0x17, 0x69, 0xe5, 0x93, 0x22, 0xf6, 0xd9, 0xe1, 0x62, 0x42, 0x39, - 0x85, 0x33, 0xb2, 0xfb, 0x6f, 0x78, 0xb5, 0x2b, 0x80, 0x9a, 0x60, 0xf4, 0x72, 0xc9, 0xd5, 0x50, 0x94, 0x7e, 0x3a, - 0x19, 0xa6, 0x20, 0xac, 0xaf, 0xd6, 0xc2, 0x6b, 0xaf, 0x48, 0x74, 0x89, 0xbf, 0x92, 0x5e, 0x1a, 0x82, 0xa4, 0xed, - 0x50, 0x5f, 0xd5, 0x25, 0x08, 0x74, 0x88, 0x57, 0x12, 0xe0, 0x66, 0xde, 0x82, 0x26, 0x13, 0x19, 0x17, 0x6f, 0x5c, - 0x00, 0x17, 0xc6, 0xdb, 0xa7, 0x1b, 0x48, 0xd6, 0x5a, 0x62, 0x27, 0xa1, 0x9b, 0x5e, 0x1a, 0x9c, 0x00, 0x09, 0x76, - 0x3c, 0x81, 0x26, 0xef, 0x84, 0xcf, 0x5c, 0xaf, 0x26, 0xa6, 0x20, 0x88, 0xe8, 0xde, 0x73, 0xb0, 0x9b, 0xeb, 0x59, - 0x56, 0xd8, 0x84, 0xd8, 0xec, 0xa8, 0xfa, 0x7e, 0xaa, 0xc0, 0xeb, 0xa5, 0x49, 0xc5, 0x46, 0xa1, 0xeb, 0xe4, 0x0e, - 0xc7, 0x01, 0xa6, 0xb3, 0xe4, 0x50, 0xc3, 0x95, 0x8f, 0x65, 0x39, 0x49, 0x09, 0x2d, 0x85, 0x03, 0xce, 0x40, 0x72, - 0xf0, 0x3f, 0x96, 0x74, 0x90, 0x75, 0xf8, 0x89, 0x69, 0x0b, 0xfe, 0x4c, 0x5a, 0xd3, 0xb4, 0x88, 0x56, 0x7b, 0x1d, - 0x6b, 0xd0, 0xbc, 0x4a, 0x9e, 0x4f, 0x0c, 0x60, 0xb3, 0x5a, 0xc8, 0xea, 0xc7, 0x5e, 0x5b, 0xfe, 0x48, 0xf9, 0x29, - 0x0b, 0xb5, 0xa7, 0x7a, 0x6c, 0x85, 0x64, 0xa7, 0x69, 0x51, 0x11, 0xc5, 0xf5, 0x64, 0xbb, 0x21, 0x7e, 0xf8, 0x22, - 0x11, 0x94, 0x4b, 0x05, 0xc4, 0x90, 0x00, 0x04, 0x83, 0x19, 0xd4, 0x90, 0xd0, 0x51, 0x5f, 0x6f, 0x9e, 0x8e, 0x7b, - 0x08, 0x34, 0x4f, 0x85, 0x02, 0x62, 0xba, 0x62, 0x76, 0xbe, 0x0b, 0xa8, 0xe2, 0xfd, 0x1b, 0x6c, 0x9b, 0x56, 0xdf, - 0xd6, 0xb4, 0xca, 0x4f, 0xd6, 0x7f, 0xd4, 0xb9, 0x29, 0xb0, 0x21, 0x36, 0xa8, 0x52, 0x24, 0xac, 0x32, 0x06, 0x88, - 0x46, 0xcf, 0xdc, 0x64, 0x9a, 0xc2, 0xfe, 0xee, 0x3c, 0x5d, 0xf5, 0x75, 0x6a, 0xf3, 0x5d, 0xcf, 0xa5, 0xc4, 0x12, - 0x2e, 0xb3, 0xd0, 0xc7, 0x72, 0x00, 0x64, 0xa6, 0x87, 0xa5, 0x83, 0x06, 0x5f, 0x83, 0x57, 0x57, 0x2c, 0x55, 0xd7, - 0xec, 0x7e, 0xc8, 0xf8, 0xeb, 0x9b, 0xf4, 0x8a, 0xde, 0xc9, 0xc8, 0x7c, 0x73, 0xaf, 0x77, 0xd7, 0xea, 0xfa, 0x85, - 0xf5, 0x8c, 0xba, 0x54, 0x2d, 0x4f, 0x7f, 0x6f, 0xf7, 0x7d, 0x71, 0x67, 0xed, 0x4f, 0x41, 0x19, 0xdb, 0x93, 0x7c, - 0xa0, 0x9a, 0x1b, 0xff, 0x02, 0xcd, 0x9b, 0x82, 0x5a, 0x46, 0xa6, 0xbc, 0xad, 0xfd, 0x92, 0x1b, 0xf2, 0xf6, 0x44, - 0xc6, 0x11, 0xe7, 0x8e, 0x21, 0xef, 0x4b, 0xdb, 0xf8, 0xdc, 0xeb, 0x08, 0x14, 0x7e, 0x79, 0x3a, 0xa5, 0x80, 0xd6, - 0x84, 0x4b, 0xc4, 0x11, 0x5a, 0x5e, 0x97, 0x2e, 0x8a, 0x41, 0xe4, 0xe8, 0x03, 0xd8, 0xd2, 0x86, 0xe0, 0xd3, 0x22, - 0xfc, 0x6c, 0x26, 0xd4, 0x93, 0xad, 0x40, 0xad, 0x88, 0x2a, 0x7b, 0x48, 0x56, 0x02, 0xcb, 0x89, 0xe4, 0xa4, 0x27, - 0x75, 0x26, 0x90, 0x60, 0xea, 0x15, 0xef, 0xbb, 0x60, 0xc8, 0x62, 0x97, 0x2b, 0x0c, 0x2c, 0xe2, 0x64, 0xa1, 0x7e, - 0xbd, 0x3c, 0x95, 0x46, 0x0b, 0x0c, 0x01, 0x4c, 0x73, 0x2f, 0x2f, 0x1b, 0x23, 0x9e, 0xfe, 0xee, 0x86, 0x4c, 0x17, - 0x78, 0xf0, 0xcd, 0x8b, 0x9b, 0xd4, 0x52, 0x80, 0x9e, 0x9b, 0xfc, 0x6e, 0xa4, 0x9d, 0xc8, 0x09, 0xa9, 0xcd, 0x19, - 0x0e, 0x01, 0xaa, 0x9a, 0x3d, 0xc4, 0x5c, 0x2a, 0x65, 0x27, 0xae, 0x81, 0x2c, 0xbf, 0x89, 0xc0, 0x97, 0x6f, 0xe7, - 0xd8, 0x3b, 0x15, 0x95, 0xad, 0xd0, 0x0e, 0xa1, 0xa2, 0x36, 0xac, 0xee, 0xe6, 0xe1, 0x31, 0x47, 0xb0, 0xf3, 0x87, - 0x79, 0xdc, 0xd7, 0x0d, 0x8f, 0x10, 0x60, 0x05, 0xc2, 0x27, 0x04, 0x1f, 0x60, 0x88, 0x66, 0xba, 0xb5, 0xef, 0xef, - 0x55, 0x52, 0x55, 0x3c, 0x05, 0x38, 0x3e, 0xc0, 0xf0, 0xce, 0xd4, 0x63, 0xb3, 0x04, 0x9b, 0x79, 0x04, 0x86, 0x90, - 0x9b, 0xe6, 0x54, 0x53, 0x6e, 0x80, 0xf9, 0x2e, 0x62, 0x98, 0xe2, 0x91, 0xee, 0xd1, 0xf0, 0x01, 0xed, 0xc6, 0x9b, - 0x3b, 0x2f, 0xf0, 0xd3, 0x2c, 0x62, 0xd9, 0xf3, 0x64, 0x94, 0xc1, 0x27, 0x22, 0xdf, 0x22, 0x85, 0xcc, 0xfd, 0xc4, - 0x29, 0xac, 0xb6, 0x69, 0x7d, 0x51, 0x88, 0xdc, 0x5c, 0xdd, 0x98, 0x68, 0x0d, 0x5c, 0xa8, 0x4d, 0x54, 0x27, 0xd0, - 0xda, 0x66, 0x7b, 0xb8, 0xea, 0x4c, 0x24, 0x83, 0x27, 0xc2, 0xfc, 0x1b, 0xaf, 0xee, 0x64, 0xeb, 0x90, 0x8b, 0xd3, - 0xa3, 0x30, 0x57, 0x7b, 0x6b, 0xcf, 0x5b, 0xf7, 0x2d, 0x77, 0xd5, 0x9a, 0x3c, 0xa7, 0x45, 0x28, 0xb1, 0x93, 0x0c, - 0xa0, 0x08, 0xee, 0x9b, 0x41, 0xef, 0x3d, 0xd4, 0x89, 0x0c, 0x2e, 0x54, 0x31, 0xe3, 0xcc, 0x38, 0xca, 0xf3, 0x2b, - 0xae, 0x39, 0xb8, 0xfd, 0xbc, 0x71, 0x31, 0x10, 0xa0, 0xd0, 0x01, 0x99, 0xfa, 0x51, 0x99, 0xda, 0x9a, 0x26, 0xc7, - 0x7c, 0x05, 0x0b, 0x44, 0x86, 0x20, 0x00, 0x59, 0x78, 0xda, 0x56, 0xe9, 0x3e, 0x9e, 0x0c, 0x07, 0xca, 0x1b, 0x81, - 0x19, 0x19, 0x74, 0x10, 0xcd, 0x58, 0xdb, 0x99, 0x44, 0x44, 0x98, 0x84, 0x1b, 0x8b, 0x1a, 0xfe, 0xc5, 0x53, 0x52, - 0x3e, 0xe6, 0xa1, 0x87, 0x11, 0xd3, 0x62, 0x5e, 0x51, 0x7c, 0x49, 0x41, 0x3a, 0x97, 0x56, 0xdf, 0xb2, 0x4c, 0xce, - 0xa9, 0x97, 0xa1, 0xd0, 0x45, 0xc2, 0xa8, 0xb0, 0x49, 0x3d, 0x91, 0x01, 0x24, 0x63, 0x95, 0x19, 0xca, 0x15, 0x5e, - 0x8f, 0x2a, 0x79, 0x5c, 0xf2, 0x6f, 0xcc, 0xca, 0xb8, 0x1c, 0x5b, 0xd6, 0x0d, 0xeb, 0x1c, 0x1c, 0xaf, 0x54, 0xcb, - 0xe4, 0x9b, 0xa2, 0x38, 0xf1, 0xe2, 0x23, 0x06, 0xe2, 0xfd, 0xac, 0xde, 0x66, 0x9e, 0x7d, 0x58, 0xee, 0xda, 0xc2, - 0x95, 0x49, 0xc5, 0x20, 0x96, 0x30, 0x11, 0xb4, 0x28, 0x8d, 0xdf, 0x71, 0x30, 0xc5, 0x29, 0x40, 0x1b, 0x0b, 0xdf, - 0x1b, 0x49, 0x55, 0xe5, 0xb0, 0x5c, 0x46, 0x6f, 0xa5, 0xa8, 0xb3, 0x59, 0x5e, 0x46, 0x9b, 0x79, 0x12, 0x10, 0xe0, - 0xea, 0x4c, 0x59, 0xcd, 0x6e, 0x0e, 0x1d, 0x86, 0x33, 0xac, 0x2c, 0xe5, 0x84, 0x29, 0x9a, 0x35, 0x96, 0x12, 0x61, - 0xdc, 0x66, 0xb7, 0x2f, 0x8e, 0xdf, 0xd5, 0x72, 0x67, 0xfa, 0x0d, 0xdc, 0xe5, 0xae, 0x59, 0x40, 0x78, 0xe0, 0x11, - 0x9d, 0x93, 0xcb, 0x80, 0xaf, 0x8c, 0xea, 0x0d, 0x1a, 0xb0, 0x25, 0xeb, 0xa5, 0xf9, 0x58, 0x95, 0x87, 0xbe, 0x8a, - 0x5d, 0xbc, 0xd4, 0x25, 0xb4, 0x3a, 0xd4, 0xfa, 0xb0, 0xb7, 0xff, 0xb4, 0x57, 0xed, 0x34, 0xa0, 0x03, 0x62, 0x5f, - 0xeb, 0xf1, 0x65, 0x97, 0xff, 0xd5, 0x1f, 0xb7, 0x45, 0xa2, 0xed, 0x94, 0xba, 0x81, 0x0a, 0x41, 0xee, 0x40, 0xb0, - 0x95, 0xce, 0x67, 0xe5, 0x38, 0xe8, 0x85, 0x25, 0xa1, 0x16, 0x5e, 0x97, 0x97, 0x4a, 0xf0, 0x60, 0x4a, 0x49, 0xac, - 0x71, 0xaf, 0x37, 0x87, 0x01, 0x7d, 0xb8, 0xc5, 0x5a, 0x4d, 0x4c, 0x7f, 0x42, 0x54, 0x99, 0x48, 0x0f, 0x6c, 0x2f, - 0x9a, 0x98, 0xf0, 0xb0, 0x1f, 0x54, 0xa4, 0x84, 0xea, 0x40, 0xd0, 0x06, 0xca, 0xc4, 0x1c, 0x5f, 0x76, 0x28, 0x79, - 0x2e, 0xb4, 0xc0, 0x27, 0x06, 0xfb, 0x8e, 0xab, 0xb1, 0x50, 0xb1, 0x03, 0xc9, 0x31, 0x65, 0x0e, 0x37, 0xd8, 0x22, - 0xf6, 0x27, 0xd5, 0x40, 0xe9, 0xaf, 0xc6, 0x75, 0xdf, 0x56, 0x01, 0x94, 0xba, 0xe6, 0xc7, 0x7d, 0x8d, 0x42, 0x0f, - 0x16, 0xf1, 0x76, 0x08, 0xcf, 0x64, 0xbb, 0xa6, 0x22, 0xd6, 0x7c, 0x96, 0xec, 0xb9, 0x61, 0xc3, 0xdf, 0x57, 0x04, - 0x32, 0x46, 0x9a, 0x0e, 0x65, 0x6c, 0xc6, 0x2f, 0x65, 0x14, 0x53, 0x84, 0x7d, 0xe1, 0x77, 0x92, 0x10, 0x21, 0x42, - 0xc6, 0x30, 0xcd, 0x11, 0xb4, 0x33, 0x9f, 0x27, 0xb5, 0x40, 0x75, 0x4d, 0x42, 0xdf, 0xd3, 0xc3, 0x8a, 0x78, 0x90, - 0xa3, 0x47, 0x25, 0x00, 0xea, 0xbf, 0xc5, 0xbd, 0x27, 0x59, 0x31, 0x82, 0xb4, 0xe2, 0x44, 0x1a, 0x57, 0xe0, 0x38, - 0xc7, 0x27, 0x2d, 0x24, 0x88, 0x97, 0xea, 0x4e, 0x42, 0x5f, 0xb4, 0x71, 0x6a, 0xf0, 0x02, 0xb9, 0x28, 0x56, 0x2a, - 0x00, 0xb5, 0x5b, 0xf0, 0x66, 0x09, 0x33, 0x66, 0x48, 0x8f, 0xbc, 0x07, 0x6b, 0x1e, 0xf2, 0x52, 0x2e, 0x8f, 0x39, - 0x39, 0x87, 0xa8, 0xb9, 0x28, 0x92, 0x1a, 0x73, 0x05, 0x7d, 0x0d, 0x8a, 0x53, 0xe8, 0x63, 0x4c, 0xac, 0x36, 0x4f, - 0x7d, 0xaa, 0x86, 0xa2, 0xf4, 0x6c, 0x56, 0x17, 0xeb, 0x88, 0x2d, 0xb0, 0x0b, 0xcd, 0x18, 0x82, 0x5f, 0xc9, 0x24, - 0x87, 0x83, 0xb4, 0x4c, 0x04, 0x1d, 0x95, 0x17, 0x43, 0x27, 0x33, 0xda, 0xbb, 0xf4, 0x84, 0x3b, 0x7a, 0x28, 0x39, - 0x7d, 0x81, 0xd2, 0x43, 0x08, 0xd0, 0x5f, 0x8d, 0x68, 0xdc, 0xfe, 0x0a, 0x27, 0xc5, 0x8b, 0x09, 0x1f, 0x24, 0x51, - 0x84, 0x87, 0x70, 0x46, 0x14, 0x32, 0x12, 0xed, 0x43, 0xc1, 0xcc, 0x3b, 0xdb, 0xd6, 0x94, 0xf7, 0x45, 0x9d, 0x3a, - 0xcd, 0xc1, 0xcb, 0xf7, 0xe2, 0xb5, 0x5c, 0x4e, 0x3d, 0x7a, 0xec, 0xcb, 0x96, 0x90, 0x9d, 0x07, 0x00, 0x02, 0xe4, - 0x8b, 0x1d, 0x32, 0x26, 0x68, 0xc3, 0x9a, 0x96, 0x64, 0x4d, 0x3f, 0x5a, 0x84, 0x7e, 0x54, 0x7d, 0x9c, 0x66, 0x99, - 0x90, 0x6a, 0x0b, 0x63, 0x40, 0x84, 0x9e, 0x2a, 0x94, 0x60, 0x45, 0xee, 0x83, 0x97, 0xb8, 0x9a, 0x00, 0xdb, 0xb6, - 0x18, 0x9e, 0xb4, 0x37, 0x43, 0x60, 0x3b, 0x22, 0xa0, 0xd3, 0x0c, 0x89, 0x42, 0x6c, 0xb8, 0x8f, 0xd1, 0x4c, 0x52, - 0xc1, 0x98, 0x26, 0x2a, 0x1f, 0xfc, 0x07, 0xb5, 0x11, 0x37, 0x69, 0xaf, 0xe2, 0x79, 0x84, 0x3d, 0xc7, 0xa1, 0xeb, - 0xc2, 0x65, 0x40, 0x54, 0xd9, 0x72, 0x59, 0x73, 0x3d, 0x5a, 0x9e, 0x91, 0x41, 0x95, 0x48, 0xfd, 0x85, 0x5b, 0x07, - 0x95, 0x06, 0xd4, 0xb3, 0xf8, 0x64, 0xe0, 0xb9, 0x25, 0xb4, 0xdc, 0x9f, 0x23, 0x89, 0x07, 0xe0, 0xd4, 0xa3, 0x39, - 0xc2, 0x4b, 0x77, 0x87, 0x00, 0xf7, 0x56, 0x75, 0xbb, 0x69, 0x09, 0x28, 0x63, 0x27, 0xe1, 0xaa, 0xad, 0x52, 0x92, - 0x5a, 0x83, 0x12, 0xf3, 0xef, 0xf2, 0x4b, 0x3d, 0x76, 0x15, 0x1b, 0x96, 0x21, 0xd0, 0xb5, 0x42, 0xfd, 0xe5, 0x13, - 0xda, 0x49, 0xe1, 0xc6, 0xe1, 0x0d, 0xb2, 0x68, 0xf3, 0x11, 0xb5, 0x60, 0x2e, 0x50, 0x77, 0x5c, 0xd4, 0xbd, 0xf9, - 0x1b, 0xc1, 0x4d, 0x51, 0x53, 0xe8, 0x42, 0xc9, 0x46, 0x8f, 0x37, 0x12, 0x33, 0x40, 0x73, 0xb9, 0xd2, 0x0a, 0xcf, - 0xaa, 0x07, 0x6a, 0xbf, 0x21, 0x71, 0x6b, 0xbd, 0xbe, 0x0d, 0x1b, 0x3d, 0x44, 0xab, 0xc9, 0x82, 0x36, 0x46, 0x92, - 0xc7, 0xcc, 0xa1, 0xb5, 0x22, 0xd3, 0x35, 0x49, 0xb0, 0x2c, 0xa9, 0xf5, 0x6a, 0xd7, 0xf0, 0xf3, 0xb7, 0x3e, 0x40, - 0x58, 0x30, 0xb0, 0x5a, 0x49, 0xef, 0xb0, 0xdd, 0xca, 0xa5, 0x85, 0xab, 0x4d, 0xfe, 0x2c, 0x95, 0x43, 0x40, 0x9b, - 0x2c, 0xbf, 0xc4, 0xa5, 0xa7, 0x28, 0x88, 0xd4, 0x69, 0xab, 0xab, 0x84, 0x84, 0x60, 0xa5, 0x52, 0x3f, 0x1d, 0x98, - 0x90, 0x23, 0x2a, 0x47, 0x64, 0xf7, 0xba, 0x9c, 0xf3, 0x53, 0x03, 0xd2, 0xdd, 0x88, 0x48, 0xc8, 0xe9, 0x8d, 0x01, - 0x5c, 0x16, 0x1a, 0xfb, 0xdb, 0x80, 0x2b, 0x7c, 0x88, 0xe0, 0xb4, 0xef, 0x4a, 0xb9, 0x2e, 0x82, 0xfb, 0xbe, 0x40, - 0x8a, 0xaa, 0x22, 0x82, 0x05, 0xd5, 0x8e, 0x6c, 0xce, 0x8e, 0xfc, 0xc6, 0x8c, 0x02, 0xe7, 0xe6, 0x78, 0xd7, 0x28, - 0x42, 0xe9, 0x62, 0xe7, 0xbe, 0x62, 0x20, 0x4a, 0x12, 0x3e, 0x3b, 0x46, 0x68, 0xad, 0x75, 0x3e, 0xf1, 0x7e, 0xc0, - 0xb3, 0x24, 0x9c, 0x7f, 0x60, 0x93, 0xf7, 0xa5, 0x38, 0x2f, 0xaf, 0x36, 0x75, 0x5b, 0x30, 0x02, 0x50, 0x5f, 0x78, - 0xde, 0x56, 0x1e, 0xdc, 0x60, 0x64, 0x90, 0x27, 0x73, 0x81, 0xf1, 0xcc, 0xd5, 0x60, 0x9e, 0x1f, 0x3b, 0x2a, 0x04, - 0x2c, 0x04, 0xf2, 0x54, 0x53, 0x9b, 0xd6, 0x4a, 0x6c, 0xd1, 0x8e, 0xd9, 0x6f, 0xd9, 0x00, 0x27, 0xc0, 0xe9, 0x70, - 0xbc, 0xb4, 0x0d, 0xde, 0x90, 0x4b, 0x7a, 0x6b, 0x19, 0x05, 0xd9, 0x85, 0x7f, 0x1b, 0xb4, 0x06, 0xe5, 0x15, 0x08, - 0x15, 0x49, 0x1d, 0x1b, 0x25, 0xa5, 0x48, 0x1a, 0xa1, 0x65, 0xb6, 0x05, 0x59, 0x71, 0xb6, 0x47, 0x7c, 0xd5, 0xcc, - 0xe1, 0xa6, 0xc8, 0x6d, 0x91, 0xce, 0x1a, 0xee, 0x8b, 0x40, 0xc5, 0xa6, 0x90, 0x66, 0x5a, 0x23, 0xdb, 0xb8, 0x27, - 0xab, 0xb4, 0x77, 0x1b, 0x51, 0x33, 0x68, 0x44, 0xdf, 0xd2, 0x54, 0xf9, 0x7d, 0x2d, 0xaa, 0x97, 0x62, 0xa0, 0xcc, - 0x21, 0xa6, 0x6b, 0x5a, 0xc1, 0xa4, 0x4a, 0x2d, 0x8e, 0xf3, 0x36, 0x9f, 0x3e, 0x5c, 0x28, 0x87, 0xe4, 0xc0, 0x09, - 0x25, 0x47, 0x0c, 0xd9, 0x0a, 0x43, 0x70, 0x2b, 0x67, 0x13, 0xc9, 0x72, 0x23, 0x72, 0x99, 0x35, 0x46, 0x77, 0xfc, - 0x83, 0x05, 0xa0, 0xd0, 0x17, 0x1b, 0x14, 0xf4, 0x63, 0xad, 0xf5, 0x89, 0x3a, 0x52, 0x6a, 0x52, 0x7c, 0xba, 0x70, - 0x13, 0x95, 0x43, 0xcd, 0xd5, 0xab, 0xa2, 0x01, 0xb5, 0x26, 0x74, 0xc0, 0xf5, 0x08, 0x83, 0x0d, 0x84, 0xd1, 0x1f, - 0x4d, 0x21, 0x2c, 0xf7, 0x55, 0xdc, 0xb4, 0x9b, 0xbc, 0x7b, 0x3a, 0xdb, 0x63, 0xa4, 0x06, 0x15, 0x69, 0x59, 0x71, - 0x0c, 0xa7, 0x07, 0x9c, 0x83, 0xc7, 0x8e, 0x19, 0x36, 0x1b, 0xa7, 0xc7, 0x18, 0x03, 0x2c, 0x59, 0x61, 0xb1, 0x4d, - 0xa5, 0xb5, 0x22, 0x42, 0x6a, 0x9b, 0xd5, 0x4b, 0x9b, 0x3b, 0x45, 0x7e, 0xfb, 0x33, 0x00, 0xcc, 0xab, 0x26, 0xd3, - 0x3a, 0x8a, 0x29, 0x62, 0x94, 0xb4, 0x59, 0x1c, 0x2f, 0xc4, 0xca, 0x8b, 0x8f, 0x05, 0xee, 0x8f, 0x54, 0xb9, 0xb2, - 0xec, 0xb8, 0x3a, 0x93, 0xfb, 0xe1, 0xe6, 0x87, 0xcc, 0x49, 0xc4, 0x03, 0x16, 0xfa, 0x8c, 0xd9, 0x70, 0x75, 0xe2, - 0x1d, 0xa9, 0xc3, 0x2c, 0x26, 0xf7, 0xba, 0x78, 0xcb, 0xc7, 0xb9, 0x0b, 0xa8, 0xec, 0x41, 0xec, 0xb6, 0x2a, 0x63, - 0xbd, 0xce, 0xc8, 0x20, 0xe1, 0x5b, 0x2a, 0xf6, 0x4a, 0xc6, 0x4e, 0x7c, 0x06, 0x99, 0x1e, 0x2c, 0xc3, 0xc2, 0x53, - 0x46, 0x72, 0xfb, 0x4c, 0x15, 0xb5, 0xeb, 0x29, 0x95, 0xeb, 0xa2, 0x3b, 0xaf, 0xb9, 0xb7, 0x15, 0xee, 0xd4, 0xcc, - 0xa4, 0x13, 0xaf, 0x0b, 0x50, 0xe7, 0x83, 0x97, 0x16, 0xe9, 0x9c, 0x37, 0xb0, 0x6a, 0x85, 0xc2, 0x75, 0xa9, 0x46, - 0x9f, 0x5d, 0xee, 0xa3, 0x2d, 0x8e, 0x4d, 0x77, 0x7e, 0x5d, 0xf6, 0x68, 0xf2, 0x59, 0x87, 0x40, 0xec, 0x29, 0x22, - 0x3e, 0xa5, 0xc1, 0xad, 0x75, 0x98, 0x69, 0xab, 0xad, 0x0c, 0x54, 0x9b, 0xa4, 0x16, 0xf8, 0x49, 0x9b, 0xd2, 0xec, - 0x70, 0x6a, 0x79, 0xd7, 0x20, 0x96, 0xf8, 0x05, 0x0e, 0xab, 0x62, 0xf5, 0xec, 0xf1, 0x2d, 0xae, 0xac, 0x0c, 0x73, - 0xbf, 0x1e, 0x55, 0x0e, 0xb3, 0xb9, 0xe2, 0x78, 0x53, 0x1d, 0x91, 0x48, 0x6d, 0x3f, 0xf7, 0xf3, 0x27, 0x43, 0x45, - 0x8f, 0x83, 0x81, 0x38, 0x50, 0x55, 0xe4, 0x4c, 0x89, 0xb0, 0x0a, 0xa7, 0x25, 0x9a, 0x86, 0xc6, 0x3a, 0x14, 0x04, - 0x64, 0xd4, 0xff, 0x81, 0x70, 0x10, 0x99, 0xb7, 0x4e, 0x48, 0xaa, 0x2a, 0x35, 0x2c, 0xd1, 0x5e, 0xec, 0x7b, 0x48, - 0xe1, 0x21, 0x4f, 0xb6, 0x3e, 0x6f, 0xbf, 0xce, 0x91, 0x05, 0x0f, 0x04, 0xa3, 0x4c, 0x12, 0x03, 0x5b, 0x47, 0x97, - 0x7a, 0xd9, 0x8b, 0xbb, 0x4c, 0x40, 0x4f, 0x77, 0x1e, 0x7f, 0x84, 0x43, 0x51, 0xda, 0x9c, 0xbf, 0x6a, 0x49, 0x36, - 0xf3, 0xe8, 0xb6, 0x6a, 0xac, 0x43, 0x24, 0x36, 0x97, 0x1c, 0x2d, 0xe7, 0x45, 0x9e, 0x72, 0x74, 0xf9, 0x00, 0x8c, - 0x85, 0x77, 0xe7, 0x5c, 0x35, 0x17, 0x52, 0x4d, 0x5f, 0x1c, 0x13, 0xb8, 0x0e, 0x8f, 0xd8, 0x4a, 0xdb, 0x06, 0xeb, - 0xc1, 0x72, 0x88, 0xe7, 0xdc, 0x50, 0xae, 0x3f, 0xd4, 0x92, 0x6a, 0x52, 0xcf, 0x60, 0x1a, 0x2b, 0x75, 0x82, 0x26, - 0x65, 0xce, 0x2b, 0x9e, 0x3a, 0x98, 0x3a, 0x74, 0x93, 0x44, 0xf4, 0xd7, 0x91, 0x39, 0x91, 0xa4, 0x49, 0x3f, 0xb6, - 0x8d, 0x0a, 0x08, 0x80, 0x8e, 0x56, 0x08, 0x68, 0xf7, 0xbd, 0x5b, 0x7d, 0x26, 0xc9, 0x87, 0x67, 0x3d, 0x8a, 0xb9, - 0xd6, 0xd1, 0x56, 0xd7, 0xb0, 0x7c, 0x7b, 0x45, 0x18, 0xcd, 0xdb, 0x03, 0xb3, 0xc2, 0xd9, 0x88, 0x14, 0x63, 0xe7, - 0x2d, 0x20, 0x61, 0x1e, 0x22, 0xc7, 0xbb, 0x2e, 0x6a, 0xdc, 0x4a, 0xe7, 0xe8, 0xbc, 0x08, 0x4f, 0x9b, 0x2b, 0x16, - 0x4a, 0xd1, 0x4b, 0xe5, 0xd8, 0x6f, 0xde, 0x99, 0x51, 0x43, 0x5e, 0xf2, 0xb0, 0xf1, 0x7e, 0x94, 0xa7, 0xa7, 0x30, - 0x3a, 0x3f, 0xc4, 0x61, 0xee, 0x48, 0x5f, 0xa9, 0x03, 0xf4, 0x7a, 0x4f, 0x0e, 0xdf, 0xae, 0xef, 0x65, 0x27, 0x38, - 0x5c, 0x18, 0x2e, 0x8a, 0xf3, 0x05, 0xa9, 0x24, 0xe6, 0x28, 0xf5, 0x78, 0x51, 0x4f, 0xf1, 0x81, 0x78, 0xe5, 0x04, - 0xdb, 0x5e, 0xf6, 0xfd, 0xdf, 0x85, 0x33, 0x29, 0xbf, 0xef, 0x2e, 0x81, 0xaf, 0x07, 0x7f, 0xe8, 0xf6, 0x0b, 0x1c, - 0x89, 0xc8, 0x61, 0x1c, 0xee, 0xd8, 0x56, 0x71, 0xbd, 0x6f, 0xc3, 0x16, 0xa9, 0xd7, 0x1f, 0x27, 0x84, 0x5c, 0x37, - 0xe4, 0xa8, 0x3b, 0x28, 0xe2, 0x65, 0x09, 0x4c, 0xdc, 0x14, 0x42, 0x14, 0xe3, 0xbf, 0x5c, 0xcd, 0x53, 0x84, 0x5f, - 0x35, 0xa2, 0xb0, 0x55, 0x53, 0x53, 0x70, 0x57, 0x60, 0x00, 0x56, 0xf2, 0x04, 0x77, 0xa0, 0xe5, 0x43, 0x59, 0x78, - 0x85, 0x8e, 0xd5, 0xa2, 0xac, 0x04, 0x6a, 0x99, 0x21, 0x8f, 0x08, 0x4e, 0xd0, 0x5e, 0x84, 0x59, 0xd7, 0x30, 0x29, - 0xf7, 0x60, 0xf2, 0xb6, 0x6e, 0xe1, 0x75, 0xb7, 0xa9, 0xd3, 0xc3, 0xfb, 0x55, 0x69, 0xb1, 0xab, 0xb2, 0xc7, 0x03, - 0xe4, 0x28, 0x39, 0xbd, 0x03, 0x30, 0xe7, 0x61, 0x12, 0xd8, 0xea, 0xd2, 0x1c, 0xb6, 0x76, 0x97, 0xd0, 0x6f, 0x33, - 0x7c, 0xba, 0x43, 0x66, 0xa3, 0xa4, 0x9d, 0x7d, 0xfe, 0x53, 0x05, 0x8b, 0xa1, 0x37, 0x00, 0x9e, 0xb0, 0xee, 0x64, - 0xb5, 0xb0, 0x81, 0x7b, 0xfc, 0xd3, 0x87, 0xa6, 0x28, 0xa4, 0x25, 0xa6, 0xb1, 0x8b, 0xa3, 0x9a, 0x6c, 0xad, 0xf6, - 0x1a, 0x39, 0xbb, 0x21, 0x71, 0x55, 0x4a, 0x88, 0x2e, 0x47, 0xb4, 0x42, 0xb2, 0x47, 0x14, 0xc1, 0x6a, 0xef, 0x2c, - 0xdd, 0x46, 0x5f, 0xc3, 0x74, 0x05, 0x18, 0x4d, 0xc0, 0xb0, 0x41, 0xa5, 0xbd, 0x13, 0x00, 0x18, 0xa5, 0x55, 0x53, - 0xa7, 0xf4, 0x2e, 0x76, 0xd9, 0xe5, 0xe6, 0x41, 0xa6, 0xd4, 0x13, 0x35, 0x93, 0xdb, 0x03, 0x2a, 0x6b, 0x2d, 0x54, - 0xb2, 0x5f, 0x72, 0xc5, 0xa7, 0x51, 0x89, 0x56, 0xe8, 0x6a, 0x46, 0x07, 0xdd, 0x4c, 0xd1, 0x51, 0x22, 0xb6, 0x4c, - 0x4c, 0xbb, 0xf2, 0x66, 0x98, 0x78, 0xa9, 0xd8, 0x2a, 0x33, 0x22, 0x4d, 0xd9, 0xa2, 0x96, 0x23, 0xe2, 0xfc, 0xa8, - 0xbd, 0x66, 0x55, 0xa7, 0x36, 0xd6, 0x5a, 0x78, 0xba, 0x38, 0xc4, 0xe4, 0xea, 0x43, 0xb4, 0xdd, 0x07, 0x29, 0x38, - 0xd3, 0xa6, 0x8d, 0x2b, 0xb5, 0xcd, 0xbe, 0x88, 0x32, 0x5e, 0x91, 0x71, 0x11, 0xb3, 0xd9, 0xed, 0x93, 0xa5, 0x1d, - 0x26, 0xca, 0xe3, 0x98, 0x4c, 0x46, 0x0e, 0x54, 0xd2, 0x06, 0xaa, 0x25, 0xf7, 0x92, 0x15, 0x17, 0x71, 0xf1, 0xdf, - 0x68, 0xd9, 0xe6, 0xd9, 0xc2, 0xc0, 0x82, 0x16, 0x66, 0x89, 0x02, 0xb3, 0x54, 0x4a, 0x07, 0x25, 0x1c, 0x45, 0x64, - 0x27, 0x09, 0xd3, 0xcb, 0x92, 0x36, 0xf8, 0xa0, 0x91, 0xee, 0x4e, 0x26, 0x0d, 0x09, 0x97, 0x6b, 0x9c, 0xb5, 0x2d, - 0x26, 0x32, 0xe2, 0xa9, 0x6f, 0x4a, 0x26, 0xc2, 0x48, 0x3c, 0xc8, 0x94, 0x98, 0x0b, 0xcf, 0x06, 0x52, 0xe2, 0x8b, - 0x9c, 0x7e, 0xae, 0x17, 0xb3, 0xd1, 0x22, 0x8d, 0xfe, 0xa1, 0xe7, 0x97, 0xc5, 0xce, 0x6e, 0x47, 0x8c, 0x7a, 0x7b, - 0x5c, 0x79, 0x56, 0x53, 0x6b, 0xd7, 0x8c, 0x1c, 0x33, 0x06, 0x34, 0x52, 0x08, 0x14, 0xd2, 0x27, 0x23, 0x9c, 0x16, - 0x97, 0x03, 0x1b, 0x36, 0xbe, 0x53, 0x8e, 0x67, 0x0a, 0xb7, 0x17, 0x43, 0xc3, 0x73, 0x87, 0x44, 0x10, 0xa1, 0xf1, - 0x06, 0x67, 0xce, 0x50, 0xff, 0xe1, 0xe9, 0xbc, 0x35, 0xd7, 0xfd, 0x0f, 0x8a, 0x9e, 0xa5, 0x45, 0x44, 0x80, 0xf3, - 0x45, 0x45, 0x9a, 0xdb, 0x7b, 0x27, 0x8b, 0xac, 0xc7, 0x37, 0xcd, 0xfa, 0x15, 0x01, 0xdc, 0x49, 0x02, 0x42, 0x80, - 0x86, 0xd7, 0xf5, 0x7c, 0x38, 0x4b, 0x58, 0x1e, 0x60, 0xba, 0xab, 0xe0, 0xef, 0xc4, 0x4d, 0xce, 0x4b, 0x13, 0xfa, - 0xb1, 0xa8, 0xe0, 0x83, 0x9d, 0x2c, 0x10, 0x6e, 0x01, 0x96, 0x10, 0x04, 0x82, 0x92, 0x99, 0x29, 0xa6, 0x12, 0xfa, - 0x0b, 0x29, 0x21, 0x43, 0x02, 0x4c, 0x47, 0xe3, 0x82, 0x1b, 0x24, 0xd5, 0x46, 0x3c, 0xad, 0x62, 0x36, 0x9c, 0x34, - 0x0c, 0x88, 0xf5, 0xc7, 0x30, 0xd8, 0x2a, 0x26, 0xc9, 0xb0, 0xbf, 0xb3, 0x37, 0x9e, 0x0c, 0xa7, 0x4b, 0x14, 0x72, - 0xb1, 0xcf, 0x98, 0x3c, 0xa1, 0xe9, 0x17, 0x85, 0xa8, 0x2f, 0xeb, 0x82, 0xd3, 0x7b, 0x76, 0x74, 0x07, 0x4f, 0xae, - 0x32, 0xd2, 0xdb, 0x38, 0xb0, 0xdc, 0x42, 0x22, 0xc0, 0xbc, 0xdf, 0x03, 0xcd, 0x48, 0x32, 0x64, 0x28, 0x03, 0xcc, - 0x35, 0x66, 0x4f, 0x0d, 0x4d, 0x0f, 0x65, 0x47, 0x72, 0x6d, 0x12, 0xac, 0x1e, 0xe6, 0xbe, 0xbc, 0xb2, 0x6e, 0x73, - 0xbd, 0x03, 0xb9, 0x6e, 0x6b, 0x08, 0xd8, 0xe5, 0x88, 0x34, 0xa7, 0x26, 0xb7, 0x09, 0xd5, 0x03, 0x14, 0x48, 0x35, - 0xd5, 0xb4, 0x0e, 0x0e, 0x37, 0x7c, 0xd0, 0x01, 0xe1, 0x26, 0xc4, 0x46, 0xe5, 0x11, 0x7a, 0xad, 0xc6, 0x3e, 0xd1, - 0xd7, 0x92, 0x6b, 0x9a, 0x6f, 0x90, 0x3a, 0x72, 0xa9, 0xea, 0x3c, 0x4e, 0xd4, 0xb5, 0xb6, 0xda, 0x82, 0x2d, 0xc2, - 0x00, 0x8b, 0x55, 0x0c, 0x87, 0xe8, 0x54, 0xa8, 0x68, 0x89, 0x7b, 0x1b, 0x73, 0xd5, 0xcb, 0x9d, 0xb7, 0x55, 0x97, - 0x7a, 0xa7, 0x06, 0x8d, 0xc8, 0xf4, 0x50, 0x01, 0x0e, 0x84, 0x8c, 0xb5, 0x7d, 0xb0, 0x8c, 0xe3, 0x8c, 0x54, 0x65, - 0xd8, 0x08, 0x46, 0xd3, 0x01, 0xca, 0x5a, 0xf5, 0x38, 0x9c, 0x03, 0x62, 0x79, 0x48, 0x6e, 0x9a, 0xcc, 0x10, 0xd9, - 0x22, 0x9b, 0x5f, 0x6a, 0xf2, 0xe4, 0x0a, 0x1d, 0x0d, 0xfb, 0x1e, 0xd0, 0xae, 0xee, 0xc0, 0x40, 0x76, 0xf8, 0xaa, - 0x93, 0xce, 0x72, 0x09, 0xb4, 0x39, 0x86, 0xce, 0x85, 0xc5, 0x29, 0x9f, 0xa7, 0x23, 0x1b, 0xee, 0x1d, 0xe0, 0x45, - 0x47, 0xd7, 0x0b, 0xf0, 0xdb, 0xc1, 0xc5, 0x1d, 0x63, 0x0f, 0x6e, 0xca, 0xa3, 0x2c, 0x3b, 0x95, 0x30, 0x95, 0x47, - 0x13, 0x17, 0xeb, 0x9c, 0x0b, 0x5d, 0xce, 0xe6, 0x75, 0xba, 0xf5, 0x27, 0x2a, 0x86, 0x9b, 0xb5, 0x73, 0x06, 0xcf, - 0x55, 0x4e, 0x87, 0x24, 0x62, 0x49, 0x8b, 0x73, 0xf4, 0x85, 0x44, 0x9e, 0xd6, 0xf9, 0xfd, 0x42, 0x81, 0xce, 0xa9, - 0x83, 0x6a, 0x1d, 0xe3, 0xcc, 0x4e, 0x0f, 0x3a, 0xef, 0x95, 0xc6, 0xa2, 0xb1, 0x4a, 0x59, 0xf1, 0x1f, 0x38, 0xb7, - 0xf4, 0xf6, 0x84, 0xb0, 0x49, 0x2a, 0xa4, 0x50, 0x96, 0x09, 0xb7, 0x3d, 0x0e, 0x34, 0x6d, 0xe7, 0x44, 0x76, 0x5b, - 0xdf, 0xbe, 0x93, 0x24, 0x22, 0x71, 0xdb, 0x0b, 0xa2, 0xf0, 0x0c, 0xd0, 0x18, 0x92, 0xb3, 0xe7, 0x9d, 0x75, 0xf9, - 0xd2, 0xcb, 0x72, 0xbc, 0xc2, 0xde, 0x15, 0x83, 0xb1, 0xb0, 0x42, 0x0b, 0x0b, 0x37, 0x0d, 0xd4, 0xb1, 0x93, 0x24, - 0x76, 0x59, 0x12, 0x3f, 0xb6, 0xfc, 0x33, 0x69, 0x6e, 0x44, 0x9e, 0x8a, 0x8e, 0x75, 0xc8, 0x3e, 0x73, 0xaa, 0x54, - 0xf7, 0x5a, 0xe5, 0x41, 0x39, 0xe6, 0xa9, 0x1a, 0x31, 0x67, 0x6e, 0x33, 0x45, 0x3e, 0x92, 0x3e, 0x6f, 0xae, 0x67, - 0x94, 0x28, 0x10, 0xa9, 0x0b, 0xbd, 0xca, 0x9c, 0xab, 0xd0, 0x91, 0x42, 0x4a, 0xb7, 0x46, 0xb3, 0x89, 0x39, 0x0e, - 0x67, 0x3f, 0x55, 0xd9, 0x13, 0x7c, 0xed, 0x3d, 0x6f, 0xed, 0xc3, 0x66, 0x83, 0xeb, 0x50, 0xf3, 0x21, 0x3d, 0x60, - 0xa6, 0x99, 0x3b, 0x53, 0x20, 0x0b, 0xdb, 0xaf, 0xec, 0x48, 0x94, 0x32, 0xfd, 0x63, 0xa3, 0x75, 0x7d, 0xd9, 0x47, - 0x75, 0x4c, 0xfe, 0xfd, 0x2d, 0x5d, 0xc3, 0x55, 0x07, 0x45, 0x8e, 0xe1, 0x58, 0xd1, 0x6e, 0xa5, 0x3b, 0x00, 0xe1, - 0x35, 0x3b, 0x8c, 0xdc, 0x72, 0x36, 0x45, 0xbd, 0x55, 0x57, 0xc1, 0x02, 0x6a, 0xd4, 0x91, 0x94, 0xbd, 0x51, 0x58, - 0x44, 0xfd, 0x9a, 0x5d, 0x8b, 0x2b, 0x8a, 0x6e, 0x59, 0xe3, 0x7e, 0xc8, 0xec, 0xa8, 0x3f, 0xe2, 0x5a, 0xb9, 0xc3, - 0x0c, 0xd9, 0xe1, 0x1a, 0x53, 0x48, 0xea, 0x8d, 0xc6, 0xcd, 0xb6, 0xd5, 0xf3, 0x4c, 0xc3, 0xb8, 0x6d, 0xcd, 0xd2, - 0x26, 0x76, 0x50, 0x0d, 0xb7, 0x75, 0xc1, 0x54, 0xb5, 0x5d, 0xf8, 0xfa, 0xd5, 0x6e, 0x25, 0xb2, 0x26, 0xb4, 0xe1, - 0x68, 0x6b, 0x60, 0x9a, 0x16, 0xf9, 0x5c, 0xf4, 0xec, 0x6a, 0xb0, 0xc5, 0xbe, 0x0b, 0xd9, 0xbc, 0xfb, 0x6b, 0x95, - 0x84, 0x2a, 0xb9, 0x72, 0x1f, 0x97, 0xe4, 0x27, 0x9d, 0xac, 0xc2, 0x33, 0xb5, 0x8d, 0xfc, 0x0e, 0x27, 0xda, 0x87, - 0x95, 0xe6, 0x69, 0x25, 0xb3, 0x10, 0x10, 0x85, 0xae, 0xf0, 0x2a, 0x04, 0xba, 0x05, 0x0b, 0xff, 0x07, 0x3a, 0x76, - 0x65, 0x5c, 0x0a, 0xe9, 0x8d, 0xca, 0x39, 0x74, 0x43, 0x42, 0x3e, 0xb4, 0xb0, 0x9c, 0x9c, 0x97, 0x1a, 0x74, 0xb5, - 0x35, 0x74, 0x64, 0x79, 0x20, 0x02, 0xfc, 0x44, 0x0e, 0x79, 0xa6, 0x26, 0xd8, 0xfd, 0x24, 0x70, 0x96, 0x66, 0xb3, - 0x08, 0xbf, 0x18, 0x70, 0x86, 0xa4, 0xf6, 0x9e, 0x7e, 0xf9, 0x14, 0x68, 0x0f, 0xbf, 0x5c, 0x68, 0x7d, 0x72, 0x66, - 0xce, 0x51, 0x4b, 0xc7, 0x0d, 0x1c, 0xc2, 0x45, 0x69, 0xc0, 0xf7, 0x48, 0x35, 0x86, 0x29, 0x22, 0x4c, 0x0e, 0xe0, - 0x5c, 0xb9, 0x6d, 0x78, 0x56, 0x6e, 0x02, 0x33, 0x1d, 0x29, 0xa5, 0x15, 0xc7, 0xa8, 0xfb, 0xb6, 0xf7, 0xa3, 0x24, - 0xe9, 0xcd, 0xc7, 0xcb, 0xac, 0x50, 0xfa, 0x9e, 0x99, 0x85, 0xae, 0xe2, 0x77, 0x26, 0xb9, 0xab, 0x4b, 0xe8, 0xa4, - 0x5a, 0xce, 0x80, 0x51, 0xae, 0x56, 0x58, 0xee, 0x84, 0x40, 0x0e, 0x9b, 0xfb, 0xe9, 0x66, 0x90, 0x26, 0x5b, 0x51, - 0x95, 0x18, 0x23, 0x52, 0x68, 0xbf, 0xd9, 0x9d, 0xfb, 0xa3, 0xd5, 0x0c, 0x3a, 0xea, 0x3b, 0x66, 0x5c, 0xcd, 0xb7, - 0x62, 0xbb, 0xd8, 0xb0, 0x83, 0x69, 0x14, 0x75, 0x98, 0xe6, 0x01, 0x42, 0xf7, 0x2c, 0x1d, 0xa8, 0x5f, 0x10, 0x9f, - 0xf2, 0x76, 0x55, 0x6d, 0x1d, 0xe4, 0x62, 0xa6, 0xa2, 0x7c, 0x8a, 0x1a, 0x14, 0xb0, 0x68, 0xdd, 0x2e, 0x4d, 0xc0, - 0x14, 0x59, 0x48, 0xb7, 0x90, 0x82, 0x28, 0x59, 0x08, 0x66, 0x50, 0xf1, 0x99, 0xbf, 0x4c, 0x7c, 0xad, 0x8f, 0x16, - 0x3c, 0xa5, 0x27, 0x6c, 0x15, 0x72, 0x75, 0xc7, 0x68, 0x31, 0xab, 0x4e, 0x3b, 0x4e, 0x13, 0x87, 0x0e, 0x35, 0xea, - 0x88, 0xd8, 0x75, 0x7c, 0xf0, 0x54, 0x32, 0x79, 0x83, 0xec, 0x2f, 0x27, 0x01, 0x3f, 0xd6, 0xb3, 0x5f, 0x32, 0x7b, - 0x88, 0x55, 0x69, 0xc6, 0xe3, 0x85, 0xb2, 0x47, 0xe5, 0xa8, 0xa8, 0x35, 0xf6, 0x73, 0x17, 0xa7, 0xb5, 0x51, 0x49, - 0x21, 0x77, 0x1e, 0x2e, 0xe4, 0x2b, 0xa7, 0x70, 0xee, 0x46, 0x25, 0xa2, 0x3c, 0x80, 0x99, 0xb0, 0x39, 0x71, 0xa3, - 0xe2, 0x16, 0x50, 0x39, 0xd3, 0x93, 0x26, 0x31, 0x9d, 0x95, 0x88, 0x31, 0xa3, 0x4b, 0xb8, 0x1e, 0x87, 0x68, 0x0c, - 0xcd, 0x30, 0xa7, 0xf7, 0x31, 0x7a, 0x82, 0x1c, 0x50, 0x0f, 0xed, 0x5a, 0x43, 0x88, 0x99, 0x54, 0xf8, 0x56, 0xad, - 0x88, 0x2d, 0xb3, 0x4f, 0x04, 0xb5, 0x6d, 0x2e, 0xf3, 0x88, 0x28, 0x6f, 0x29, 0x7c, 0x9f, 0xfb, 0xcb, 0x77, 0x8c, - 0x57, 0x72, 0xe8, 0x9d, 0x8e, 0x92, 0x9f, 0xc3, 0xfc, 0xec, 0x37, 0x0e, 0x60, 0x01, 0x11, 0xe7, 0x92, 0x9c, 0x7a, - 0x4a, 0x96, 0xe6, 0x3a, 0xeb, 0x75, 0x13, 0xc1, 0x2c, 0x99, 0x06, 0x4c, 0xac, 0x65, 0x16, 0x40, 0x07, 0x52, 0x09, - 0x9c, 0x15, 0x95, 0x75, 0x34, 0x93, 0x47, 0x0b, 0xbd, 0x37, 0xf1, 0xf0, 0x45, 0x29, 0xc6, 0x02, 0xfc, 0xb1, 0xa5, - 0xc6, 0xa2, 0x4c, 0xdf, 0xbc, 0x08, 0x54, 0xcd, 0x5a, 0x1e, 0x87, 0x74, 0xe9, 0xf5, 0x8a, 0x9a, 0x55, 0x49, 0x6b, - 0xa9, 0x2e, 0xd0, 0x76, 0x40, 0x8e, 0x51, 0x8b, 0xea, 0x0c, 0xd2, 0x50, 0xb4, 0x07, 0x4a, 0x5f, 0xc3, 0x84, 0x1e, - 0xf0, 0x4b, 0x35, 0x90, 0xd9, 0xe0, 0x9d, 0x4d, 0xb7, 0xb8, 0x98, 0x1c, 0x39, 0xeb, 0x06, 0x10, 0x70, 0xbb, 0xde, - 0x96, 0x9a, 0x08, 0xa9, 0x70, 0x83, 0x71, 0x59, 0x24, 0xea, 0x2f, 0x9a, 0xc3, 0xda, 0x15, 0x92, 0x3a, 0xc4, 0x3a, - 0xb4, 0x30, 0x01, 0xad, 0x19, 0x17, 0x1b, 0x5a, 0x94, 0x9d, 0xc8, 0x81, 0xb5, 0x59, 0x24, 0x19, 0x87, 0x3d, 0x9a, - 0x69, 0x33, 0x91, 0x6b, 0x09, 0x9e, 0x4a, 0x44, 0x6f, 0xd1, 0xf4, 0xeb, 0x07, 0x15, 0x36, 0x37, 0x99, 0x54, 0xca, - 0x4c, 0x8f, 0x86, 0x40, 0xbb, 0x76, 0x07, 0x7c, 0x87, 0x0a, 0xfe, 0x12, 0x3e, 0x18, 0x45, 0xf7, 0xfb, 0xec, 0x69, - 0x07, 0x7c, 0x08, 0xa7, 0x4e, 0xfb, 0x45, 0x80, 0x75, 0x0e, 0x94, 0x62, 0x5d, 0x98, 0xe3, 0x8c, 0xa3, 0x76, 0x35, - 0xa3, 0x8d, 0xfd, 0xc4, 0x18, 0x02, 0x85, 0xc3, 0xb7, 0x3d, 0x5a, 0x79, 0xd5, 0xcc, 0xd6, 0x4c, 0x2f, 0x69, 0x47, - 0x3e, 0xa2, 0x46, 0x30, 0x09, 0x22, 0x69, 0x99, 0x40, 0x68, 0xc6, 0xe8, 0x2d, 0x5c, 0xc1, 0xda, 0x9c, 0x01, 0x2d, - 0x75, 0xbd, 0x50, 0xe8, 0x81, 0xa7, 0xe7, 0x4c, 0x4c, 0x0a, 0xf3, 0x01, 0x2e, 0x69, 0xff, 0xde, 0x1c, 0x66, 0x0d, - 0xd5, 0x6a, 0x6d, 0xb7, 0x65, 0x7d, 0x97, 0x28, 0x10, 0xb6, 0x1f, 0xda, 0x45, 0xf7, 0x23, 0x3f, 0xbb, 0x16, 0xa0, - 0xce, 0x62, 0xdb, 0x35, 0x9e, 0xf4, 0xd7, 0x5e, 0xb7, 0x04, 0x1f, 0xfb, 0x2b, 0x0d, 0x9f, 0x54, 0x0c, 0xcb, 0x92, - 0x09, 0xd3, 0x95, 0xe5, 0x18, 0x67, 0xa5, 0xb8, 0xcf, 0xcb, 0x98, 0x74, 0x77, 0x28, 0x31, 0x89, 0xaf, 0x3b, 0x1b, - 0xd0, 0xb7, 0x8c, 0xe8, 0x65, 0xfd, 0x56, 0xcf, 0xb0, 0xd7, 0x25, 0x80, 0x98, 0x7a, 0x45, 0xc5, 0x78, 0x98, 0xe8, - 0x8b, 0x87, 0xa0, 0x30, 0x7e, 0x94, 0xb9, 0x18, 0x7c, 0x52, 0x6f, 0x5b, 0x48, 0x84, 0x9f, 0xc6, 0xa3, 0xb8, 0x98, - 0xb5, 0x68, 0xd8, 0x76, 0x3d, 0x29, 0x0e, 0x84, 0x84, 0xd6, 0xcc, 0xa7, 0x49, 0x5a, 0x73, 0x29, 0x0c, 0xbf, 0x59, - 0x88, 0x8d, 0x66, 0xe3, 0x28, 0x5a, 0x0b, 0x60, 0x74, 0x55, 0x73, 0xc5, 0x62, 0xe0, 0x61, 0xc1, 0x43, 0xf9, 0xd2, - 0x12, 0x96, 0x3d, 0x7f, 0x9f, 0x4e, 0xe4, 0x9b, 0xbb, 0x9c, 0x6e, 0xbf, 0x77, 0x9d, 0xbd, 0xb9, 0x4b, 0x27, 0xca, - 0xea, 0x17, 0x1d, 0x95, 0xa8, 0xc6, 0xfa, 0xd8, 0xfa, 0x70, 0x97, 0x5b, 0xfd, 0x44, 0x72, 0xda, 0xd9, 0x0e, 0x18, - 0xb7, 0x14, 0xb0, 0x65, 0xda, 0x1e, 0x36, 0xe5, 0x3f, 0xde, 0xba, 0x38, 0xd2, 0x28, 0x48, 0x7c, 0xc2, 0x9c, 0x21, - 0x49, 0xf1, 0xd8, 0x64, 0x00, 0xa3, 0x96, 0x01, 0xf5, 0x08, 0xf6, 0x75, 0x63, 0x47, 0xbe, 0xb9, 0x8c, 0x71, 0xa9, - 0x4e, 0xbb, 0x0e, 0x64, 0xda, 0xe5, 0x21, 0xb0, 0x71, 0x9b, 0xbb, 0x1c, 0x28, 0x12, 0x07, 0x2a, 0x62, 0xa6, 0xfd, - 0x22, 0xf5, 0xa7, 0x1b, 0xc4, 0x46, 0xed, 0xc0, 0xf5, 0x39, 0xd8, 0x14, 0xfb, 0x64, 0xb1, 0xdf, 0xca, 0x3b, 0x3b, - 0xec, 0x8d, 0xf4, 0x47, 0x9c, 0x9b, 0xcf, 0x38, 0x30, 0xa2, 0x4a, 0x73, 0x31, 0x2b, 0x91, 0x2a, 0xb2, 0xa7, 0x95, - 0xef, 0x2f, 0xa4, 0x49, 0xe0, 0xdc, 0xad, 0x0f, 0x3d, 0x9c, 0xcc, 0x9e, 0x08, 0x93, 0x39, 0xe4, 0x3b, 0x78, 0x49, - 0x89, 0xa6, 0x0b, 0x8d, 0xb6, 0x5b, 0x07, 0x04, 0x76, 0x02, 0xe6, 0x69, 0x89, 0xbc, 0x4e, 0xc9, 0x4b, 0x7e, 0xff, - 0xf6, 0xcf, 0xd2, 0xb2, 0x80, 0xe1, 0xc8, 0x53, 0x5c, 0xa5, 0x00, 0x11, 0xc7, 0x71, 0xfe, 0x6a, 0xdd, 0x67, 0x24, - 0xc6, 0xfa, 0xf3, 0xbb, 0x1f, 0xec, 0x3e, 0x41, 0xae, 0xa4, 0xa1, 0xf0, 0xcc, 0xcd, 0x91, 0x9d, 0x83, 0xec, 0xca, - 0xb8, 0x62, 0xb7, 0x41, 0x3f, 0x89, 0x2c, 0x2a, 0xd1, 0x4c, 0xeb, 0xcd, 0x29, 0x16, 0x49, 0x49, 0x2b, 0x2c, 0x6a, - 0xc9, 0x17, 0x0c, 0xe5, 0x30, 0x59, 0x96, 0xb6, 0x9d, 0x39, 0x0e, 0xc5, 0x5a, 0x96, 0x00, 0xd9, 0xc5, 0x12, 0x9c, - 0x2b, 0x8a, 0x5c, 0x86, 0x95, 0x35, 0xb7, 0x02, 0xe3, 0xc0, 0x14, 0x7e, 0xf2, 0x8f, 0x12, 0xed, 0xef, 0x64, 0x58, - 0xb2, 0x8b, 0x3f, 0xa7, 0x2b, 0xf4, 0xda, 0xb9, 0x17, 0xcc, 0x60, 0x32, 0x44, 0xef, 0xb1, 0x84, 0x79, 0xb9, 0x13, - 0xaf, 0x4a, 0x96, 0xa5, 0x71, 0xe0, 0xa0, 0x59, 0x37, 0x6b, 0x75, 0xdf, 0x22, 0x48, 0xcb, 0x86, 0xab, 0x46, 0xac, - 0xb4, 0x12, 0x2a, 0xa1, 0x29, 0xe8, 0x88, 0x92, 0xbc, 0x44, 0x98, 0x19, 0x80, 0xb2, 0x93, 0x88, 0xca, 0x08, 0x82, - 0x63, 0x58, 0xb1, 0x98, 0x69, 0x5c, 0xd9, 0x80, 0xd5, 0xa9, 0xf1, 0x51, 0x1e, 0xba, 0x5e, 0xc0, 0x50, 0x7d, 0xed, - 0x4d, 0xc6, 0x39, 0xc6, 0xbc, 0xd6, 0x4c, 0x73, 0x72, 0xec, 0x7f, 0xdc, 0x55, 0x13, 0x24, 0x2d, 0xbe, 0x1f, 0xed, - 0x67, 0x0c, 0x4d, 0x33, 0x20, 0x96, 0x3d, 0x7c, 0xc2, 0x56, 0x07, 0x6c, 0xd2, 0x75, 0xd8, 0x48, 0x12, 0x25, 0xe8, - 0x4d, 0x9c, 0x66, 0xfb, 0x26, 0x80, 0xa1, 0xba, 0x34, 0xc8, 0x9e, 0x47, 0x46, 0xbc, 0x35, 0x96, 0x03, 0x4b, 0xbe, - 0x02, 0xba, 0xa0, 0x3c, 0xf3, 0x08, 0xce, 0xb6, 0x73, 0x20, 0x0a, 0x63, 0x2d, 0x8a, 0x4b, 0x9c, 0xf0, 0x3b, 0x92, - 0x43, 0x59, 0x32, 0x43, 0x61, 0xca, 0xe7, 0xe0, 0x5c, 0x99, 0x0f, 0x1f, 0xfe, 0x90, 0x7f, 0xfc, 0x4c, 0x57, 0x97, - 0x22, 0xf6, 0xf9, 0x71, 0x8e, 0xcf, 0xbf, 0x4d, 0x7a, 0xca, 0xed, 0x2c, 0xfc, 0x06, 0xde, 0x59, 0x42, 0xce, 0xbb, - 0x1f, 0x7e, 0xd4, 0x2d, 0x0e, 0x8a, 0x85, 0xce, 0x62, 0x8b, 0x5a, 0x70, 0xfe, 0xf9, 0x79, 0x31, 0x17, 0x55, 0x1e, - 0x13, 0x38, 0x53, 0x49, 0x59, 0xfd, 0xa6, 0x48, 0x81, 0xb4, 0x8d, 0x4a, 0xc2, 0xc6, 0xff, 0x18, 0x45, 0xf1, 0xff, - 0xa3, 0x0c, 0x85, 0x86, 0xac, 0xfd, 0xf1, 0x96, 0x45, 0x65, 0x83, 0xcd, 0xff, 0x98, 0x68, 0xad, 0x56, 0x9f, 0x09, - 0x50, 0x49, 0x5b, 0x49, 0xa5, 0x0f, 0x0a, 0x3c, 0xd3, 0xd1, 0xe4, 0x0c, 0x35, 0xc8, 0x88, 0x27, 0x8c, 0x33, 0x60, - 0x68, 0x9b, 0x75, 0xc9, 0xbb, 0x6d, 0x13, 0x7f, 0x8d, 0xc2, 0x9b, 0x32, 0xb5, 0xd1, 0x18, 0x24, 0xa7, 0x0a, 0x90, - 0xe6, 0x38, 0x5b, 0x85, 0xae, 0x68, 0xc3, 0x39, 0x37, 0x6b, 0x2d, 0x38, 0x1b, 0xc6, 0x56, 0xc3, 0x97, 0xbf, 0x20, - 0x41, 0x60, 0xd7, 0xa4, 0x0e, 0xaa, 0xb2, 0xe6, 0xc5, 0x4d, 0xf8, 0x27, 0x6c, 0x2f, 0x31, 0x98, 0xc9, 0x4b, 0x9a, - 0x77, 0xa6, 0x23, 0xa4, 0x79, 0x84, 0x9c, 0xd9, 0xfc, 0x8f, 0x62, 0x26, 0xcb, 0x43, 0x19, 0xcd, 0x7c, 0x98, 0x18, - 0xff, 0xe6, 0x49, 0x02, 0xfb, 0x99, 0xf3, 0x61, 0x14, 0x99, 0x58, 0x1e, 0xdb, 0xc6, 0x0b, 0x72, 0x1f, 0x43, 0x37, - 0x5a, 0xac, 0xb2, 0x2c, 0x63, 0x5f, 0x29, 0xb3, 0xb4, 0xb4, 0xe0, 0xf0, 0x74, 0x03, 0xa2, 0x0a, 0x9d, 0x0d, 0x21, - 0xcf, 0xa5, 0x7f, 0x59, 0xa5, 0xc2, 0xf4, 0xa1, 0xcc, 0x58, 0xeb, 0x2d, 0x10, 0x7b, 0x3d, 0x51, 0x7f, 0x72, 0x87, - 0x44, 0x9b, 0xdc, 0x68, 0xf9, 0xe0, 0x14, 0x56, 0x93, 0x74, 0x1e, 0x99, 0x88, 0x47, 0xf8, 0xce, 0xb6, 0x9f, 0xb7, - 0x4a, 0x7a, 0x7e, 0xf0, 0x11, 0x76, 0xbb, 0x34, 0xf6, 0x5e, 0xf2, 0x3b, 0xf9, 0x39, 0xfa, 0x30, 0xb8, 0x23, 0x27, - 0x25, 0xb5, 0xfd, 0xa1, 0x8f, 0x71, 0x1d, 0x28, 0xbb, 0xff, 0x41, 0xe3, 0x39, 0x64, 0x51, 0xf1, 0x68, 0x92, 0xce, - 0x30, 0x07, 0x4b, 0xfd, 0x30, 0x73, 0xe1, 0x6f, 0xd2, 0x04, 0x67, 0xd1, 0x8d, 0x5e, 0x1e, 0x4c, 0xeb, 0xc9, 0x3f, - 0x22, 0x2b, 0x7f, 0x9a, 0x65, 0x93, 0xc3, 0x69, 0xb8, 0xe0, 0x47, 0x32, 0xfa, 0xed, 0xac, 0x6e, 0x4f, 0x96, 0xad, - 0x5b, 0xed, 0x21, 0x60, 0xfa, 0x91, 0x86, 0x48, 0xde, 0x2c, 0x53, 0x85, 0x81, 0xa8, 0x18, 0x5c, 0xd0, 0x1a, 0x74, - 0x29, 0x35, 0xb5, 0x55, 0xe0, 0x8c, 0x4e, 0x04, 0x1d, 0x54, 0x70, 0xb4, 0x5c, 0xf9, 0xea, 0x07, 0x0d, 0x8b, 0x93, - 0x8a, 0xed, 0xb6, 0x28, 0x92, 0x3d, 0x83, 0xe3, 0x68, 0x11, 0x15, 0x99, 0xf1, 0xef, 0x32, 0x5a, 0x29, 0xa5, 0x54, - 0x82, 0xe0, 0x8e, 0xbe, 0xd0, 0xc5, 0x65, 0x94, 0x86, 0xfc, 0x90, 0x4a, 0xb6, 0xd0, 0x80, 0x4a, 0x29, 0x0e, 0x54, - 0x8d, 0xcb, 0x44, 0xb8, 0x32, 0x36, 0x17, 0x8d, 0x2b, 0x5a, 0x15, 0xaf, 0x62, 0x87, 0xf4, 0xfa, 0x3a, 0x51, 0x85, - 0x21, 0xcb, 0xc0, 0xe1, 0xe1, 0x1c, 0x65, 0xcd, 0x93, 0x6d, 0x28, 0xc9, 0x33, 0x15, 0x73, 0x30, 0xe3, 0x5c, 0x3d, - 0xa9, 0xc6, 0x06, 0x34, 0x54, 0x54, 0x67, 0x31, 0x9d, 0xad, 0x0e, 0xa8, 0x53, 0x02, 0x02, 0xb3, 0xf0, 0x18, 0x8f, - 0xa3, 0x10, 0x77, 0xa5, 0x0c, 0xbb, 0x70, 0x9b, 0x25, 0x58, 0x8f, 0x93, 0xe1, 0x70, 0x47, 0x1b, 0x3b, 0x17, 0x75, - 0xf8, 0x51, 0xb0, 0x4b, 0x31, 0x68, 0x48, 0x23, 0x24, 0xb9, 0xd8, 0x39, 0x75, 0x2c, 0x9a, 0x70, 0x27, 0x0b, 0x02, - 0x20, 0xf2, 0x30, 0xf5, 0xc1, 0xe2, 0xf2, 0xa8, 0xb3, 0x80, 0x89, 0x79, 0xae, 0xec, 0xa2, 0xbc, 0x81, 0xaf, 0xd6, - 0xa1, 0x1c, 0x62, 0x99, 0xc4, 0x58, 0x69, 0x33, 0xfe, 0x77, 0x59, 0x1e, 0xa5, 0x37, 0x96, 0xd5, 0xb4, 0x45, 0xf5, - 0xa0, 0xd1, 0x1d, 0xae, 0x1d, 0x31, 0x36, 0x96, 0x59, 0x27, 0x86, 0x05, 0xf3, 0xdf, 0x67, 0x86, 0xc5, 0x46, 0x55, - 0xcb, 0x37, 0x39, 0xdf, 0xbd, 0xe3, 0x53, 0x08, 0x66, 0x51, 0xc3, 0x03, 0xee, 0x1e, 0x96, 0x31, 0x2c, 0x16, 0x04, - 0xb3, 0xec, 0xc1, 0xc3, 0xba, 0x1a, 0x82, 0x34, 0xe3, 0x51, 0x92, 0x40, 0x37, 0x62, 0x28, 0x46, 0x72, 0x46, 0xc0, - 0x26, 0x29, 0xc4, 0xe0, 0x15, 0xb0, 0x3f, 0xd6, 0x25, 0xa4, 0x82, 0x23, 0x3c, 0x20, 0x1c, 0xaa, 0xb8, 0xfc, 0xb0, - 0x88, 0x61, 0x20, 0x86, 0x54, 0xbc, 0x98, 0x95, 0x4f, 0x0b, 0x80, 0x91, 0x35, 0xaa, 0x78, 0x48, 0x86, 0xc8, 0xc8, - 0x9b, 0x16, 0x19, 0x75, 0xf0, 0xc6, 0xf0, 0x1b, 0x11, 0x03, 0x5e, 0xc9, 0x19, 0xe4, 0x31, 0x27, 0x2b, 0x73, 0xf9, - 0x32, 0x77, 0xe9, 0xb7, 0xe3, 0xa9, 0x1c, 0xb7, 0xcd, 0x3c, 0xb4, 0xe9, 0x65, 0xac, 0x73, 0x51, 0x71, 0x70, 0xbd, - 0xcc, 0x31, 0xa2, 0xa7, 0xf9, 0xc2, 0xb5, 0x45, 0xf6, 0xb4, 0x86, 0xeb, 0xd7, 0x2a, 0xfb, 0xf0, 0x09, 0x19, 0x5b, - 0x40, 0x86, 0x87, 0x9d, 0xba, 0x6d, 0x64, 0x0c, 0x23, 0xf0, 0xdf, 0xc6, 0xf7, 0x13, 0xe7, 0x98, 0x2e, 0x73, 0x41, - 0x72, 0x98, 0x17, 0xf8, 0xb6, 0x30, 0xfe, 0x92, 0x73, 0x1c, 0x8d, 0xc9, 0xba, 0x87, 0x1a, 0xdd, 0xbd, 0xb4, 0xe1, - 0x0b, 0x26, 0xe8, 0xfc, 0x12, 0x1d, 0x5f, 0x93, 0x06, 0xcb, 0x7d, 0xde, 0xd7, 0x33, 0x64, 0x1a, 0x0f, 0x63, 0x4c, - 0xc8, 0x35, 0x9e, 0x33, 0xd1, 0x8d, 0x7a, 0xcf, 0x96, 0xb1, 0x96, 0xd8, 0x2a, 0xa2, 0xcd, 0x36, 0x58, 0x39, 0xaa, - 0xfe, 0xf5, 0x5d, 0x24, 0x82, 0x11, 0x35, 0xed, 0xd3, 0x5a, 0xdf, 0xa8, 0x3c, 0xf3, 0xdb, 0x99, 0x77, 0x1b, 0x56, - 0x86, 0x19, 0x04, 0x33, 0xbe, 0x62, 0xce, 0xd0, 0xcf, 0x23, 0x73, 0x0f, 0xbc, 0xdd, 0x4b, 0xef, 0xc6, 0x9a, 0x35, - 0xfa, 0x61, 0xba, 0x53, 0x92, 0x59, 0x60, 0x3b, 0xfe, 0x4d, 0xd0, 0x53, 0x21, 0xf5, 0xa3, 0x3a, 0xb0, 0xf8, 0x9a, - 0x93, 0x98, 0x90, 0x0c, 0x39, 0x58, 0x90, 0xab, 0xe6, 0xbd, 0xa7, 0xdb, 0xb6, 0x8c, 0x0a, 0x71, 0xe9, 0x74, 0xf5, - 0xe5, 0xf5, 0xda, 0x0b, 0xb4, 0xa3, 0xfa, 0xd1, 0xc6, 0xcb, 0x78, 0xf1, 0x78, 0x03, 0x77, 0x22, 0x7e, 0x43, 0x6e, - 0x68, 0x8c, 0xaf, 0xc2, 0xa3, 0xb5, 0xda, 0x2b, 0xae, 0xbd, 0x69, 0xee, 0xf1, 0x8b, 0xb9, 0x56, 0x67, 0x4e, 0xb5, - 0x57, 0x66, 0x5c, 0x99, 0xb8, 0x58, 0x51, 0x92, 0x0f, 0x5f, 0x10, 0x5c, 0xc7, 0xcf, 0xd6, 0x41, 0xb8, 0xeb, 0xf1, - 0x9d, 0x1c, 0x2c, 0xc5, 0xc0, 0x74, 0x03, 0xd7, 0x81, 0x18, 0xc3, 0xd8, 0x22, 0x01, 0x92, 0xfa, 0xa1, 0x6c, 0xc5, - 0x28, 0x18, 0xbf, 0x3e, 0x5e, 0xb6, 0xea, 0x1d, 0xff, 0x61, 0x09, 0xe0, 0xd8, 0x46, 0x38, 0x02, 0xcd, 0xac, 0x38, - 0xe5, 0x52, 0x5c, 0xe8, 0x23, 0x38, 0xb3, 0x29, 0xfb, 0x80, 0xe3, 0x90, 0x4d, 0x30, 0xed, 0x8f, 0x86, 0xca, 0xef, - 0x9f, 0xc8, 0x8f, 0x6b, 0x77, 0xbf, 0x57, 0xd7, 0x16, 0x9e, 0xfe, 0x73, 0x87, 0xde, 0x49, 0x47, 0x5e, 0x3b, 0x1d, - 0x75, 0xb8, 0x02, 0xd9, 0x71, 0x53, 0x7b, 0x56, 0x57, 0x5f, 0xc9, 0xf1, 0x63, 0x7f, 0x5b, 0x95, 0x6e, 0xe3, 0xfd, - 0xf3, 0xb2, 0xaa, 0xec, 0x6c, 0xaf, 0x5c, 0x17, 0x7f, 0x59, 0x69, 0xf2, 0xda, 0x7f, 0xd1, 0x6f, 0xe7, 0xa4, 0x1f, - 0xe6, 0x9b, 0x45, 0x8b, 0xbc, 0x61, 0x9b, 0x01, 0xfe, 0xf1, 0xa0, 0xf1, 0x50, 0x44, 0xd8, 0xdc, 0x75, 0xbf, 0xb5, - 0xa1, 0x41, 0x31, 0x27, 0xef, 0x04, 0x29, 0x0a, 0x20, 0x71, 0xc7, 0x5a, 0x01, 0x38, 0x06, 0x86, 0xc1, 0x73, 0xef, - 0x53, 0xeb, 0xc6, 0x14, 0x75, 0xb9, 0x7a, 0xa2, 0xb1, 0x9b, 0xad, 0x37, 0xf4, 0x35, 0x6e, 0xf4, 0x1f, 0x91, 0x0b, - 0x11, 0x18, 0x3c, 0x3f, 0x80, 0xfb, 0xc7, 0x29, 0x7b, 0xd1, 0x62, 0x52, 0x79, 0xc3, 0xe7, 0xf6, 0xf5, 0xe1, 0xb5, - 0x7c, 0x9a, 0xcd, 0x05, 0x12, 0xbd, 0x3e, 0x36, 0xb5, 0xd0, 0x14, 0xb9, 0x96, 0x3b, 0xd9, 0xc5, 0xd1, 0x34, 0xc4, - 0x68, 0x01, 0x50, 0x28, 0x03, 0xc6, 0x4f, 0xb0, 0x86, 0x3a, 0xe3, 0x9f, 0xcf, 0xa7, 0x3c, 0xa7, 0xfb, 0xcd, 0x5b, - 0x33, 0xbd, 0xa5, 0x39, 0xe0, 0xdb, 0x90, 0xff, 0xdb, 0x3f, 0xd1, 0xad, 0x63, 0xac, 0xf6, 0x98, 0x1d, 0x5c, 0x9b, - 0x6b, 0x59, 0xf4, 0x6f, 0x6b, 0xe2, 0xca, 0xeb, 0xd1, 0x0f, 0xf8, 0x75, 0xee, 0x0b, 0x81, 0xd1, 0x14, 0x9e, 0xb1, - 0x98, 0xb4, 0x55, 0xae, 0xef, 0x7a, 0xc2, 0x6c, 0x1b, 0x9d, 0x22, 0x35, 0x04, 0xd7, 0xfb, 0x18, 0x57, 0x1b, 0x4f, - 0xa2, 0xb2, 0xda, 0xbe, 0x79, 0x2a, 0xc0, 0x85, 0xc6, 0xf2, 0x4f, 0xd4, 0x79, 0xbb, 0x47, 0x6d, 0x72, 0xda, 0x3f, - 0x69, 0xed, 0x9e, 0x4b, 0x0f, 0x1d, 0xe9, 0xb1, 0xe9, 0x53, 0x6b, 0xde, 0x10, 0xec, 0x5b, 0xd2, 0x62, 0x2f, 0x00, - 0xdc, 0x01, 0x9e, 0xa8, 0x36, 0xd1, 0xb3, 0xaa, 0x7f, 0xec, 0x01, 0x69, 0x8c, 0xef, 0x31, 0x49, 0x95, 0x1b, 0xb9, - 0x50, 0xb3, 0x48, 0x50, 0x74, 0x1c, 0x1f, 0xdf, 0x31, 0xad, 0xd6, 0xc3, 0xf3, 0x62, 0x55, 0x0a, 0x63, 0xcb, 0xdc, - 0x9b, 0x79, 0x90, 0xd3, 0x54, 0x1f, 0xbc, 0x16, 0xee, 0x1b, 0xba, 0x14, 0x3e, 0x16, 0x8f, 0x5a, 0xed, 0x80, 0x9c, - 0x6c, 0x41, 0x08, 0x47, 0x74, 0xfe, 0x52, 0x32, 0x53, 0x80, 0xd7, 0x81, 0xbb, 0xe2, 0x18, 0x3d, 0xb6, 0xe3, 0x6e, - 0x54, 0xdc, 0xc2, 0x9f, 0x1d, 0x44, 0x11, 0xd6, 0x55, 0xbb, 0x35, 0x61, 0xce, 0xcb, 0x14, 0x46, 0xa9, 0x90, 0x80, - 0x70, 0xb8, 0xcc, 0x0d, 0x50, 0x42, 0x49, 0x40, 0x5b, 0x15, 0xd5, 0x1f, 0xca, 0xdc, 0x76, 0xbb, 0x51, 0x73, 0x1e, - 0x89, 0x87, 0x81, 0x8a, 0xf5, 0x98, 0xd6, 0x5a, 0x92, 0x03, 0x0a, 0x51, 0xb3, 0xc9, 0xf3, 0xf2, 0x8f, 0xf5, 0x48, - 0x2e, 0x05, 0x8f, 0x44, 0x2c, 0xde, 0x96, 0xe4, 0x9b, 0xfc, 0xf1, 0x0c, 0x99, 0xbd, 0xe5, 0xe4, 0x87, 0x39, 0x4c, - 0x27, 0x76, 0x19, 0xf0, 0x04, 0x05, 0xac, 0x51, 0x8f, 0xb6, 0xa2, 0xa7, 0x80, 0x74, 0x98, 0x15, 0x0c, 0x08, 0x4e, - 0xa9, 0x5f, 0xe6, 0x47, 0xbe, 0xd9, 0x96, 0x42, 0x55, 0x89, 0x68, 0x29, 0x0b, 0xbe, 0xdc, 0x9e, 0x6f, 0x26, 0x94, - 0xac, 0xb8, 0xa6, 0xb6, 0x99, 0xad, 0xa2, 0x45, 0x2b, 0x08, 0x7f, 0x5c, 0xcd, 0x8c, 0xa8, 0xbf, 0x90, 0x6e, 0xd6, - 0xb4, 0x7d, 0x80, 0xb4, 0x9a, 0x53, 0x3b, 0x3b, 0x47, 0x73, 0x41, 0x03, 0xf5, 0x18, 0xc1, 0xc6, 0xe2, 0x52, 0x93, - 0x72, 0xd6, 0x39, 0xaf, 0xc6, 0x1b, 0x86, 0xdb, 0x4d, 0x52, 0x2f, 0x8a, 0x1b, 0x57, 0x37, 0x3a, 0xe9, 0x4b, 0xd0, - 0xc1, 0xa0, 0x03, 0x86, 0x94, 0x5a, 0x85, 0x8a, 0xec, 0xce, 0x62, 0x5d, 0x38, 0x4d, 0x48, 0x3a, 0x5d, 0xf1, 0x72, - 0x52, 0xbc, 0x67, 0x84, 0x38, 0xfa, 0x01, 0x29, 0x93, 0x47, 0xa8, 0x49, 0x5e, 0xfb, 0x80, 0x21, 0xf3, 0x67, 0xd4, - 0xe2, 0xb0, 0xa1, 0x0d, 0xa2, 0x7f, 0x10, 0x38, 0x1e, 0x47, 0x90, 0x0a, 0xd6, 0x53, 0x32, 0xba, 0x04, 0x48, 0x7a, - 0x09, 0x9f, 0x1e, 0xb1, 0x60, 0x6a, 0xee, 0x94, 0x82, 0xe2, 0xc9, 0x00, 0x43, 0x5b, 0x69, 0x54, 0x96, 0x54, 0x4e, - 0xf4, 0x40, 0x03, 0xef, 0x29, 0x14, 0x10, 0x46, 0x9c, 0x3d, 0xf6, 0x39, 0x2f, 0x62, 0x50, 0xec, 0xad, 0x41, 0xe8, - 0x3e, 0x03, 0xd8, 0xc8, 0x33, 0x0c, 0x16, 0x79, 0x5e, 0x21, 0x47, 0x65, 0x2f, 0xab, 0xb9, 0xff, 0x72, 0x46, 0xd9, - 0xc0, 0xe0, 0x51, 0x3d, 0xe9, 0xe4, 0x5a, 0xbf, 0x0e, 0x27, 0xc8, 0x59, 0xfa, 0x94, 0xd5, 0xa3, 0x76, 0x6e, 0xca, - 0x98, 0xac, 0x2b, 0xf5, 0x67, 0xee, 0x61, 0x24, 0xdf, 0xca, 0x99, 0x51, 0x16, 0xa9, 0x88, 0x17, 0x7e, 0x00, 0xa5, - 0x9f, 0x67, 0x1d, 0x83, 0xc2, 0x13, 0x0b, 0x0d, 0x81, 0x38, 0xc4, 0x35, 0x36, 0xb8, 0x71, 0x20, 0x18, 0x35, 0x68, - 0x4c, 0x6e, 0x51, 0xad, 0x29, 0x72, 0x2d, 0xd4, 0xa7, 0x06, 0x43, 0x6d, 0x9c, 0x79, 0x66, 0x25, 0x98, 0xd0, 0xf0, - 0x92, 0x4f, 0x95, 0xac, 0xa3, 0xb8, 0xc2, 0x2f, 0x57, 0x80, 0xd9, 0xc0, 0x34, 0x77, 0x1d, 0x60, 0xb0, 0xd2, 0x9c, - 0x9a, 0x91, 0x67, 0xe7, 0x0e, 0xa1, 0xd4, 0x8d, 0x5e, 0xc0, 0x04, 0x30, 0x1c, 0x02, 0xda, 0xa0, 0x97, 0x17, 0x3e, - 0x5c, 0x90, 0xaa, 0x1d, 0x19, 0x70, 0xb4, 0xc8, 0x89, 0xb2, 0x75, 0x88, 0xff, 0x99, 0x48, 0x48, 0xda, 0xec, 0x40, - 0xbc, 0x39, 0x76, 0x53, 0xc7, 0xaa, 0xe7, 0x20, 0xbf, 0xba, 0xc1, 0x5e, 0x2b, 0xae, 0x4c, 0x93, 0x1a, 0x7a, 0x35, - 0x1a, 0x87, 0x82, 0xb4, 0xbc, 0x98, 0xdd, 0x78, 0xd2, 0x24, 0xba, 0x2c, 0xdd, 0x34, 0xe8, 0x21, 0xbc, 0x33, 0x0f, - 0xf9, 0x1d, 0xef, 0xeb, 0xc9, 0xfe, 0x81, 0xa2, 0x43, 0x60, 0xb7, 0x21, 0xd7, 0x15, 0x5f, 0x3f, 0x21, 0xc5, 0x32, - 0xda, 0xa7, 0x5d, 0x5b, 0xf3, 0xd4, 0xe2, 0x04, 0x86, 0xbd, 0x9c, 0x8d, 0x0b, 0x6e, 0x55, 0x84, 0x61, 0x6a, 0x28, - 0x25, 0x97, 0x9d, 0x6e, 0x49, 0x4e, 0xae, 0x05, 0x1a, 0x83, 0x40, 0x71, 0x1e, 0xf7, 0x9f, 0xd3, 0x97, 0x12, 0x6c, - 0xc7, 0x0e, 0x46, 0x27, 0xe9, 0x3d, 0xad, 0x93, 0xa3, 0xa2, 0xb0, 0xdd, 0x29, 0xdd, 0x38, 0xf0, 0xc7, 0x89, 0x2a, - 0xb5, 0x25, 0x06, 0x9e, 0xef, 0xae, 0x4d, 0x42, 0x5b, 0x73, 0x0e, 0xb0, 0x12, 0x00, 0x28, 0x2f, 0x06, 0x55, 0xbb, - 0x78, 0xe0, 0xa6, 0xa5, 0x6d, 0x70, 0xd3, 0x40, 0x8d, 0x44, 0x04, 0x51, 0x40, 0xc2, 0xd4, 0x3f, 0x37, 0x25, 0x3b, - 0xf1, 0x8e, 0x79, 0x27, 0x0a, 0x15, 0x92, 0x06, 0xda, 0x79, 0xf5, 0xf0, 0xe8, 0x63, 0x42, 0x58, 0x63, 0x9c, 0x18, - 0xdb, 0x80, 0x7d, 0xd7, 0x5a, 0xd1, 0x5c, 0x17, 0xf4, 0xb6, 0xee, 0x14, 0xd5, 0x1c, 0xa0, 0x93, 0x70, 0x3b, 0x0f, - 0x3e, 0x42, 0x46, 0x95, 0xbc, 0xdf, 0xe5, 0xc8, 0xe3, 0x12, 0x04, 0x39, 0xdf, 0x36, 0x54, 0x1e, 0x83, 0x07, 0x51, - 0xe0, 0x83, 0x2a, 0x06, 0x53, 0xe5, 0xc4, 0x4d, 0x20, 0xd5, 0x08, 0x32, 0xaf, 0x22, 0xc4, 0x2b, 0x3a, 0xf9, 0x7d, - 0x8f, 0x40, 0xac, 0x12, 0x9e, 0x24, 0xf3, 0x2a, 0xf9, 0x34, 0x23, 0xae, 0x6a, 0x83, 0x61, 0x66, 0xd8, 0x6e, 0xc9, - 0x69, 0x8c, 0x88, 0xa7, 0xe3, 0x66, 0x6f, 0xe2, 0xb9, 0x11, 0xd8, 0xc2, 0x51, 0xc4, 0xf2, 0x35, 0xd1, 0x19, 0x98, - 0x21, 0x55, 0x85, 0xd8, 0x5c, 0xf2, 0x19, 0x91, 0x8d, 0xc2, 0xf5, 0xb5, 0xf8, 0xbb, 0x4a, 0x29, 0x11, 0x19, 0xea, - 0x6b, 0xf5, 0x4f, 0xd1, 0x08, 0xdb, 0xa9, 0x62, 0xf4, 0xfa, 0xf1, 0x81, 0x7a, 0x04, 0x3a, 0x53, 0xaa, 0x8d, 0x52, - 0x2d, 0x98, 0xd7, 0x5a, 0xa1, 0x61, 0xa1, 0x75, 0xd4, 0xa7, 0x26, 0xc3, 0xe2, 0xc7, 0xab, 0xdc, 0x2e, 0x06, 0x80, - 0x4e, 0x02, 0x94, 0xec, 0x0f, 0x2d, 0xf5, 0xcd, 0x2a, 0x4b, 0x12, 0x80, 0xcc, 0x01, 0xd8, 0xe3, 0x38, 0xa9, 0x59, - 0x91, 0x1d, 0xfd, 0x42, 0x54, 0x4e, 0xd8, 0xe1, 0x8b, 0xa5, 0x69, 0xfe, 0x00, 0x57, 0x09, 0xcc, 0x08, 0x31, 0x19, - 0xd7, 0x51, 0x67, 0x83, 0xd0, 0x02, 0xa0, 0x5b, 0xda, 0xa9, 0x87, 0xe4, 0xed, 0x7a, 0x0c, 0x52, 0xf6, 0x01, 0xea, - 0xbc, 0xab, 0xe1, 0xfa, 0x08, 0xc3, 0x9c, 0x1d, 0x24, 0xa0, 0x9d, 0xaa, 0xd0, 0x9f, 0x9a, 0xa6, 0x72, 0x44, 0xaf, - 0xa7, 0x4d, 0x07, 0x95, 0xbb, 0x89, 0x2a, 0x13, 0xe0, 0xe0, 0x4d, 0x3c, 0x49, 0xe2, 0xb0, 0xdb, 0x4b, 0x4d, 0x53, - 0x3f, 0x99, 0xb8, 0xb2, 0x5a, 0x4d, 0xf9, 0x76, 0x6e, 0x95, 0xd0, 0xd2, 0xe3, 0x42, 0x88, 0x79, 0xbc, 0xe7, 0x81, - 0xeb, 0x65, 0xdf, 0xc8, 0x1a, 0x2c, 0xee, 0x9b, 0x95, 0x51, 0x95, 0xd3, 0x11, 0x1a, 0x93, 0x62, 0x9e, 0xfc, 0x05, - 0x88, 0xd1, 0xdd, 0x4e, 0xd3, 0xeb, 0x10, 0x42, 0x44, 0x37, 0xfb, 0xf6, 0x9e, 0x1a, 0xeb, 0x88, 0x3d, 0x21, 0x2c, - 0x73, 0xc3, 0x4b, 0x74, 0x0c, 0xdc, 0xf6, 0xae, 0x2c, 0xc9, 0x74, 0xf9, 0xdc, 0x17, 0x20, 0x7c, 0x1d, 0x30, 0x43, - 0x0a, 0x54, 0x4a, 0xec, 0x83, 0xcd, 0xf7, 0x91, 0xd0, 0x3c, 0x3d, 0x17, 0xb6, 0x11, 0x7a, 0xbe, 0xec, 0xb3, 0xf5, - 0x5b, 0x38, 0x62, 0x6b, 0xab, 0x60, 0x0f, 0x7b, 0xb9, 0x6e, 0x91, 0xd1, 0x3c, 0xf8, 0x85, 0xe9, 0x2c, 0x0b, 0x89, - 0x57, 0x1b, 0xf5, 0x0d, 0xeb, 0x1d, 0x5b, 0xfa, 0x4c, 0x66, 0x4d, 0x3c, 0x4c, 0xd6, 0xd3, 0xc8, 0xc3, 0xc9, 0xa9, - 0x3c, 0xc7, 0xe6, 0xa9, 0xb0, 0xc0, 0x1b, 0xba, 0x7a, 0x7a, 0xcb, 0xb8, 0xf7, 0xa6, 0x21, 0x79, 0x89, 0xcf, 0xce, - 0xa2, 0x05, 0xa0, 0x98, 0xa8, 0x9c, 0x5e, 0xbb, 0xc0, 0x09, 0xf6, 0x7a, 0x51, 0x41, 0x83, 0x63, 0xe4, 0xd8, 0x96, - 0xe0, 0xe9, 0x70, 0x26, 0x67, 0x9d, 0x0b, 0x48, 0x5f, 0x33, 0xa9, 0x39, 0x0b, 0x73, 0x4e, 0x4a, 0x11, 0xf8, 0xe8, - 0x51, 0x9c, 0xa3, 0x79, 0xba, 0x01, 0x04, 0x86, 0x8a, 0xf7, 0x5d, 0x60, 0x8f, 0x37, 0x1c, 0xa9, 0x8b, 0x1c, 0xac, - 0xe4, 0x3d, 0x31, 0xcc, 0x0a, 0xfd, 0xeb, 0xe7, 0x07, 0x2b, 0x85, 0x8a, 0x5c, 0x8e, 0x51, 0x88, 0x62, 0xf7, 0x8c, - 0x08, 0xcc, 0x4d, 0xa5, 0x3a, 0x08, 0xd4, 0xf2, 0x0f, 0xb6, 0x5f, 0x08, 0x57, 0x4a, 0x70, 0xeb, 0x41, 0x5d, 0x5a, - 0x42, 0xc6, 0x1e, 0xce, 0xea, 0x2d, 0xd2, 0x58, 0x40, 0xb0, 0xc7, 0x5c, 0x6b, 0x7a, 0x98, 0x03, 0xc9, 0xac, 0x06, - 0x18, 0x6d, 0x89, 0x20, 0xf5, 0x82, 0xc1, 0x2e, 0x15, 0xdd, 0xd7, 0x05, 0x45, 0xba, 0xcb, 0xa8, 0x31, 0x95, 0x56, - 0x72, 0x7c, 0x1e, 0x62, 0x7f, 0xad, 0xa9, 0x5a, 0xea, 0xab, 0xec, 0x6b, 0x72, 0xba, 0xbb, 0x5f, 0x6c, 0xfc, 0x48, - 0xf8, 0x79, 0xae, 0x98, 0x41, 0x95, 0x8c, 0xa3, 0x5d, 0xc2, 0xa4, 0xa1, 0x7a, 0xa5, 0x38, 0x6e, 0x2c, 0x37, 0x9e, - 0x6e, 0x5f, 0x74, 0xc6, 0x56, 0xe9, 0xbf, 0xbb, 0x05, 0x3e, 0x27, 0xdd, 0x6b, 0x32, 0x4f, 0x49, 0x6c, 0xf0, 0x43, - 0xf7, 0x20, 0x9d, 0x28, 0xcf, 0xfd, 0xcb, 0xf3, 0xe3, 0x39, 0x29, 0x62, 0xdb, 0x56, 0xe4, 0x95, 0x15, 0xa0, 0x1c, - 0xd2, 0x6e, 0x02, 0xea, 0x4b, 0x37, 0xea, 0x4d, 0xe4, 0x8d, 0x0d, 0xbc, 0x84, 0xd4, 0x1a, 0x28, 0x76, 0x61, 0xec, - 0xab, 0xd3, 0x51, 0x48, 0x93, 0x33, 0xd9, 0x43, 0x42, 0x31, 0x61, 0x80, 0xfe, 0x69, 0x71, 0x34, 0xa3, 0x82, 0xd6, - 0xfb, 0xbd, 0xa8, 0x8e, 0x65, 0xe7, 0x1a, 0x08, 0x99, 0xd9, 0x68, 0x96, 0xbf, 0xc8, 0xf0, 0xc6, 0x21, 0xf2, 0x55, - 0x66, 0x3a, 0x3a, 0xf0, 0xd7, 0x94, 0x3b, 0xa9, 0xc3, 0xc6, 0x55, 0x76, 0x24, 0x81, 0xff, 0x2e, 0x73, 0x22, 0x14, - 0xbe, 0x99, 0x2d, 0x0f, 0xe4, 0x6b, 0x5d, 0xf9, 0x5f, 0x33, 0xea, 0xb3, 0xc2, 0x1d, 0x6d, 0xcb, 0xd5, 0x8c, 0xc3, - 0xd9, 0x70, 0x20, 0xf3, 0xf1, 0x81, 0x0b, 0x5e, 0x79, 0xaa, 0xca, 0x7e, 0x13, 0x0e, 0xc9, 0x03, 0x7b, 0x36, 0x39, - 0x4a, 0x4b, 0x47, 0xed, 0x7f, 0xe5, 0xb4, 0xe8, 0x50, 0x34, 0x2c, 0x5a, 0x17, 0x05, 0xa2, 0x56, 0x1b, 0xcb, 0xcb, - 0x3c, 0x22, 0x41, 0xed, 0x8b, 0xc5, 0x43, 0x7b, 0xe0, 0xa3, 0x29, 0x46, 0xbe, 0xcf, 0x58, 0x07, 0x12, 0x7d, 0x7f, - 0x44, 0x90, 0x32, 0x50, 0x3a, 0x74, 0x06, 0xa5, 0x89, 0x29, 0x1e, 0x93, 0x3c, 0x67, 0xb1, 0xc2, 0x5e, 0xf2, 0x3a, - 0x2a, 0x07, 0x2b, 0x92, 0x7f, 0x8e, 0x08, 0x70, 0x14, 0x0c, 0x1e, 0x45, 0x9e, 0xfa, 0x25, 0xb8, 0xe5, 0xbe, 0x3f, - 0x60, 0x44, 0x56, 0xd2, 0x58, 0x5b, 0x8c, 0x5e, 0x88, 0x91, 0xf9, 0x08, 0x8e, 0xc7, 0xef, 0x9b, 0xa3, 0x14, 0x94, - 0xbe, 0xb4, 0x2b, 0x50, 0xdc, 0x04, 0xba, 0xb4, 0x9b, 0x1a, 0xa7, 0x81, 0x9c, 0xc8, 0xb4, 0xb5, 0x1d, 0xf7, 0xdd, - 0xd9, 0xb1, 0xa0, 0x2d, 0x41, 0xc6, 0x74, 0x17, 0x9a, 0x39, 0x0a, 0x0c, 0xef, 0xb7, 0x1a, 0x47, 0xc0, 0x80, 0x5d, - 0x63, 0x3d, 0xfc, 0x52, 0x4c, 0xfb, 0x54, 0xe9, 0x87, 0x2b, 0x9c, 0xb3, 0x4b, 0x3a, 0xbd, 0xf9, 0xfd, 0x40, 0x06, - 0xc4, 0xc5, 0x1b, 0x31, 0xf5, 0x05, 0xcc, 0x2f, 0x83, 0x02, 0x10, 0xa6, 0x12, 0x58, 0xfa, 0xbf, 0x98, 0x0b, 0xbc, - 0x13, 0x87, 0x35, 0x83, 0x03, 0x83, 0x88, 0x8f, 0x3b, 0xb8, 0xc5, 0x5f, 0x87, 0xff, 0x68, 0x80, 0xba, 0x72, 0xf7, - 0x19, 0x65, 0xcd, 0xf7, 0x49, 0x29, 0x32, 0x7d, 0xf9, 0xee, 0x65, 0x2b, 0xd4, 0x41, 0x8e, 0x6d, 0x6e, 0x55, 0xf3, - 0xda, 0xe2, 0xf7, 0xd3, 0x58, 0xcd, 0x4d, 0x7e, 0xd3, 0xdb, 0x55, 0x57, 0x4f, 0x8d, 0x1a, 0xf5, 0x84, 0x60, 0xf4, - 0xe6, 0x66, 0xd8, 0xad, 0xf1, 0xcb, 0x59, 0x09, 0x68, 0x64, 0xb3, 0x57, 0xbf, 0x47, 0x41, 0xae, 0xaf, 0xf5, 0xf3, - 0xbc, 0xac, 0x32, 0x2e, 0xbe, 0x09, 0xc0, 0x53, 0xe3, 0x43, 0xa2, 0x4a, 0xb5, 0x2c, 0x0d, 0x51, 0x93, 0x00, 0x82, - 0xc3, 0x1f, 0x74, 0x0b, 0x2e, 0xed, 0x57, 0x72, 0x9b, 0x55, 0x79, 0x6d, 0x45, 0xd0, 0x81, 0x45, 0x9f, 0xae, 0x0c, - 0x76, 0x30, 0xe0, 0xe1, 0x14, 0xfd, 0x43, 0xf1, 0x87, 0x89, 0xed, 0x9d, 0x6d, 0x4a, 0x28, 0x1f, 0x9a, 0xb9, 0x17, - 0x77, 0xf6, 0x4c, 0x11, 0xcd, 0x22, 0xd4, 0xac, 0x82, 0x19, 0x2c, 0x1b, 0x6a, 0xe7, 0x1a, 0x12, 0xf6, 0x08, 0x52, - 0x4c, 0xc1, 0xb8, 0xd1, 0xde, 0x90, 0xee, 0x88, 0x6b, 0x06, 0xe5, 0xb0, 0x50, 0x94, 0xf9, 0xcd, 0x08, 0xc9, 0x38, - 0xa7, 0xe9, 0x0d, 0x0a, 0xfc, 0x30, 0xe0, 0x73, 0x79, 0xb0, 0x20, 0xcf, 0x1f, 0x55, 0xe9, 0x74, 0x14, 0xfb, 0x56, - 0x12, 0x31, 0x63, 0xda, 0x81, 0x2d, 0xaf, 0xf7, 0xca, 0x74, 0x61, 0xb3, 0x4f, 0x3a, 0xd6, 0x1d, 0xe2, 0xed, 0x29, - 0x71, 0x1d, 0xc4, 0x9e, 0xa6, 0x1c, 0x36, 0x79, 0x3d, 0x99, 0xe3, 0x68, 0x51, 0x76, 0x5d, 0xac, 0xa6, 0x33, 0x14, - 0x7a, 0x0b, 0xc9, 0x46, 0x1b, 0x7a, 0xfe, 0xa4, 0x72, 0x8c, 0xf3, 0xc3, 0xe5, 0x24, 0x86, 0xe9, 0x4b, 0xa9, 0x21, - 0x5a, 0xb7, 0x94, 0xee, 0xb1, 0xbe, 0x63, 0x05, 0x5b, 0xb3, 0xf7, 0x8f, 0x44, 0x96, 0x26, 0x96, 0xa9, 0xd4, 0x96, - 0x9d, 0xba, 0x71, 0xef, 0x59, 0x7b, 0xfc, 0xde, 0x62, 0xb1, 0x46, 0xea, 0x6c, 0xb4, 0x31, 0xcd, 0x40, 0x3e, 0x1a, - 0xda, 0x07, 0x5f, 0x30, 0x65, 0x0b, 0xfd, 0x70, 0xde, 0x6d, 0xd0, 0x16, 0xe3, 0x33, 0x86, 0xa6, 0xd9, 0x9d, 0x0f, - 0xbc, 0xfa, 0x2c, 0x8b, 0x2e, 0x17, 0x1d, 0x4f, 0x73, 0x8c, 0x18, 0x75, 0xff, 0x5f, 0x1e, 0xf6, 0x52, 0x86, 0xbb, - 0x3c, 0x21, 0xc3, 0x4e, 0xee, 0xdb, 0x29, 0xab, 0x80, 0x7c, 0x8c, 0xad, 0xf4, 0xbc, 0x72, 0x30, 0xa2, 0xd2, 0x51, - 0x9c, 0xe9, 0x3f, 0x7c, 0xe5, 0xd7, 0x32, 0x8d, 0xda, 0xf4, 0xa3, 0xcb, 0x92, 0xbf, 0xb2, 0x1a, 0x88, 0x36, 0x4f, - 0x88, 0x4c, 0xfe, 0x4f, 0x24, 0x25, 0x47, 0x06, 0xe2, 0xd1, 0x01, 0x14, 0x30, 0x53, 0x27, 0x93, 0xd3, 0x62, 0x70, - 0x02, 0x22, 0x4b, 0x34, 0x87, 0x73, 0x00, 0x93, 0xb4, 0x04, 0x13, 0x1e, 0xd7, 0x6a, 0xdf, 0x63, 0xc6, 0x01, 0x7f, - 0x99, 0x47, 0x73, 0x70, 0xf7, 0x01, 0x2d, 0x9a, 0x80, 0x64, 0x24, 0x61, 0x58, 0x6b, 0xdb, 0x79, 0x38, 0xd9, 0x4e, - 0xf0, 0xac, 0x7a, 0x7d, 0xc0, 0x8f, 0xb5, 0x82, 0xcb, 0x9d, 0x28, 0x45, 0x75, 0x1f, 0x7c, 0xd9, 0xea, 0xcd, 0x21, - 0xd4, 0x59, 0x0f, 0xf5, 0xcc, 0x40, 0x71, 0xdb, 0xce, 0x66, 0x54, 0xf3, 0x05, 0xff, 0xf8, 0xcb, 0xc2, 0x50, 0x2c, - 0x9a, 0x35, 0x64, 0xc0, 0x00, 0xdc, 0xc6, 0x9c, 0xef, 0x75, 0xfc, 0x97, 0x4f, 0x90, 0xb0, 0x17, 0x11, 0xf6, 0x26, - 0xc5, 0x28, 0xe1, 0x97, 0x13, 0x06, 0x04, 0xf1, 0xda, 0x13, 0x25, 0x88, 0xf4, 0xa0, 0x3e, 0x99, 0x66, 0x5c, 0x66, - 0x33, 0x48, 0xd6, 0xb0, 0x08, 0xba, 0xdd, 0x35, 0xeb, 0x32, 0xe3, 0x4f, 0x7e, 0xc8, 0x70, 0x0d, 0xf4, 0x4f, 0x26, - 0x4a, 0x3a, 0x37, 0x24, 0xa8, 0xf8, 0x20, 0x5e, 0xe6, 0x50, 0x79, 0xde, 0x33, 0xe4, 0xe9, 0xf9, 0x47, 0x7f, 0xdf, - 0xcc, 0x1c, 0xca, 0x53, 0xd6, 0xe4, 0xef, 0x9e, 0xea, 0xfe, 0xa7, 0xc8, 0x2b, 0x3a, 0xf3, 0xd5, 0xac, 0xb3, 0xe2, - 0x3a, 0xe3, 0xec, 0x88, 0x54, 0x70, 0x6a, 0x45, 0xeb, 0x1d, 0x0f, 0xb1, 0x69, 0xfc, 0xb5, 0x40, 0xea, 0xec, 0x91, - 0xb9, 0x67, 0x07, 0x15, 0xa3, 0x25, 0x14, 0x58, 0x2f, 0xa2, 0x06, 0xbe, 0x1d, 0xb5, 0x19, 0x33, 0x7d, 0x4e, 0x0a, - 0xb4, 0x68, 0x09, 0x36, 0x6d, 0x17, 0xa3, 0x26, 0x5e, 0x96, 0xcc, 0x15, 0x27, 0xfc, 0xe9, 0x32, 0x53, 0xec, 0x87, - 0x8c, 0xd4, 0xc1, 0x9e, 0x17, 0x2b, 0x96, 0x2c, 0x97, 0x4f, 0xd7, 0x0f, 0xc9, 0x2e, 0xf7, 0x1e, 0x11, 0x33, 0x5e, - 0x3f, 0x5e, 0xb2, 0x4b, 0x09, 0x28, 0x91, 0x91, 0x0d, 0xe3, 0x36, 0x12, 0x6a, 0x14, 0x15, 0xa3, 0x2b, 0x50, 0x72, - 0xac, 0x53, 0x11, 0x00, 0xf0, 0xc7, 0xf4, 0x52, 0xd8, 0xc0, 0x83, 0xd3, 0x89, 0x02, 0x94, 0x91, 0xa7, 0xef, 0x4c, - 0xc6, 0x82, 0xe8, 0xa8, 0x99, 0xc3, 0xef, 0x84, 0xb1, 0x7a, 0xe6, 0xde, 0xeb, 0xa3, 0x48, 0xb0, 0x7b, 0xd9, 0x08, - 0x03, 0x89, 0x65, 0xd9, 0x64, 0x1c, 0xb6, 0x6e, 0x2b, 0xfc, 0xb4, 0x58, 0x81, 0x34, 0x05, 0x68, 0xde, 0xd3, 0x46, - 0xc0, 0x69, 0x18, 0xb3, 0x2f, 0x13, 0x48, 0xa9, 0x82, 0xb1, 0xfc, 0xa4, 0x64, 0xc3, 0xb3, 0x49, 0xde, 0xfd, 0xc4, - 0xd3, 0x5c, 0x20, 0xe4, 0xc5, 0x02, 0xdb, 0x9a, 0xa9, 0x13, 0xbf, 0x19, 0xe5, 0x66, 0x3f, 0x56, 0xcd, 0xa2, 0x0d, - 0x47, 0x1e, 0x95, 0xe5, 0xa6, 0x1b, 0xdd, 0xda, 0x2d, 0x58, 0xb5, 0x10, 0xa9, 0xe6, 0x78, 0x19, 0x80, 0x8d, 0xe8, - 0x97, 0x94, 0x61, 0xf5, 0x83, 0x4e, 0x81, 0x64, 0x61, 0xc8, 0xb6, 0xcd, 0x92, 0x32, 0x18, 0x82, 0xf2, 0xa8, 0x9a, - 0x02, 0xac, 0x91, 0xf2, 0x4d, 0x0a, 0xa3, 0xc9, 0xbf, 0x6a, 0x8b, 0xfe, 0x93, 0xff, 0x29, 0xd6, 0x7b, 0x26, 0x88, - 0x64, 0x7b, 0x38, 0x9f, 0x9d, 0xa6, 0x05, 0x33, 0x68, 0x14, 0x84, 0xf6, 0x60, 0x4a, 0xcd, 0x49, 0x24, 0x06, 0x25, - 0x17, 0x22, 0xfb, 0x93, 0xea, 0x2d, 0xc7, 0x47, 0x1e, 0xb2, 0xaf, 0x6f, 0x92, 0x26, 0x9d, 0x56, 0xa7, 0xca, 0x08, - 0xee, 0x0a, 0x9c, 0xa0, 0x04, 0xb3, 0x01, 0xfd, 0x93, 0x9f, 0x3f, 0x85, 0x24, 0xfa, 0xd0, 0x05, 0x84, 0x52, 0x67, - 0xcf, 0x88, 0xdc, 0x2c, 0x3c, 0xa2, 0x55, 0x88, 0x62, 0x5c, 0x20, 0x07, 0xc8, 0xfc, 0xb7, 0x91, 0x05, 0xbd, 0x86, - 0xfd, 0x42, 0x37, 0xa2, 0x7d, 0x08, 0x8b, 0x11, 0x9b, 0x2b, 0xde, 0xe8, 0x3d, 0x90, 0x67, 0x88, 0x1b, 0xf7, 0x34, - 0x2e, 0x68, 0xe9, 0x2a, 0x9b, 0x95, 0x02, 0xdd, 0xc4, 0xa3, 0x3e, 0x09, 0x1d, 0xb5, 0x5a, 0xde, 0x0c, 0xd1, 0x3b, - 0xd0, 0xf3, 0x7a, 0xff, 0x04, 0xdf, 0x0e, 0x08, 0x10, 0x51, 0xb8, 0xa3, 0x33, 0xf9, 0xc1, 0xe1, 0x77, 0xae, 0x3c, - 0xff, 0xc8, 0x44, 0x3d, 0x52, 0x99, 0xef, 0x96, 0xf8, 0xbb, 0x5b, 0xde, 0xff, 0xa1, 0x29, 0x53, 0x82, 0xf2, 0x83, - 0x60, 0x60, 0x64, 0x85, 0x0f, 0xae, 0x9d, 0x0e, 0xbf, 0x91, 0x79, 0x89, 0xe2, 0x85, 0xe4, 0xb2, 0x85, 0xdb, 0x2b, - 0xc6, 0x55, 0xac, 0xe2, 0xca, 0x0e, 0xda, 0x67, 0xdc, 0x7a, 0xfc, 0x10, 0x35, 0xc6, 0x1a, 0x47, 0xe7, 0x1c, 0x94, - 0x06, 0x84, 0x04, 0xd3, 0xc0, 0x26, 0x3d, 0x5a, 0x60, 0x99, 0x16, 0x48, 0x09, 0x42, 0x48, 0x2a, 0xba, 0x1f, 0x43, - 0x53, 0x89, 0xcd, 0x8c, 0x20, 0xad, 0x2a, 0x76, 0xa8, 0xc4, 0x29, 0x67, 0x1f, 0xa6, 0x58, 0x23, 0x7c, 0xaa, 0xe9, - 0x3b, 0x88, 0x92, 0xc8, 0x7b, 0x4e, 0x2e, 0x2e, 0x1d, 0x68, 0x45, 0xa6, 0x4a, 0x49, 0xdf, 0x79, 0xc1, 0xad, 0xbf, - 0xd6, 0x3e, 0x20, 0xd6, 0x41, 0x15, 0xf4, 0xac, 0x8a, 0xbf, 0xdc, 0x62, 0xae, 0xa4, 0x25, 0x56, 0xb1, 0xa7, 0x2c, - 0xf6, 0x73, 0x5a, 0x71, 0x1e, 0xce, 0x69, 0xe8, 0x2e, 0x39, 0x77, 0xa9, 0xb8, 0x27, 0x9d, 0xf5, 0x12, 0xe1, 0xbe, - 0x65, 0x07, 0xd3, 0x67, 0x25, 0xfc, 0xf8, 0xbb, 0x39, 0x29, 0x29, 0xd7, 0x81, 0x46, 0xcf, 0x61, 0xe0, 0x65, 0xd0, - 0xa2, 0xee, 0xd4, 0xc0, 0x3d, 0x91, 0xe8, 0x5b, 0x7f, 0x60, 0xc6, 0x66, 0xd9, 0x12, 0x19, 0x14, 0xcf, 0xd4, 0xff, - 0x24, 0x48, 0xe2, 0xb1, 0xfc, 0x23, 0x2f, 0x0e, 0x49, 0x22, 0xa9, 0x3e, 0x80, 0x3e, 0x09, 0x9e, 0x58, 0x80, 0x57, - 0x7f, 0xa0, 0x80, 0xa1, 0x28, 0x57, 0x39, 0x32, 0x77, 0xc2, 0x1c, 0xf2, 0x74, 0x57, 0xbd, 0x53, 0x07, 0x38, 0x7d, - 0xb5, 0x9e, 0x4d, 0x40, 0xa7, 0x85, 0x1e, 0xa0, 0xc4, 0x99, 0x11, 0xa5, 0x19, 0x07, 0xa7, 0x86, 0x39, 0xfc, 0xaf, - 0x57, 0x12, 0x61, 0xec, 0xc1, 0xc3, 0x41, 0xe3, 0x41, 0x05, 0xf9, 0xd9, 0x8e, 0xa6, 0x34, 0x0c, 0x48, 0xc2, 0xb9, - 0x16, 0xab, 0x64, 0x19, 0x5e, 0x3c, 0xf2, 0xca, 0x0c, 0xe1, 0x04, 0xd6, 0x9d, 0x3e, 0x95, 0x0e, 0x82, 0x71, 0x09, - 0x17, 0x2a, 0xaf, 0x39, 0x35, 0x1c, 0x69, 0xb9, 0x40, 0xf1, 0x57, 0x9a, 0xa8, 0x6b, 0x11, 0x4f, 0xe6, 0x47, 0x5c, - 0x35, 0x10, 0x61, 0xda, 0x05, 0x01, 0x96, 0x97, 0xc8, 0xad, 0x85, 0x72, 0xed, 0xb7, 0x1e, 0x36, 0x30, 0x06, 0xeb, - 0xe6, 0xd7, 0x4b, 0x7e, 0x7d, 0xd3, 0xb4, 0xf6, 0xe2, 0x2d, 0x2a, 0x34, 0x9d, 0xe8, 0xe9, 0x90, 0x22, 0x3c, 0x1d, - 0x77, 0x11, 0x19, 0x46, 0x03, 0x4c, 0xdf, 0x56, 0xd5, 0x62, 0x26, 0xed, 0x00, 0xfa, 0xb9, 0x20, 0xcd, 0x01, 0xa0, - 0x29, 0x42, 0xd9, 0x01, 0x70, 0x15, 0xaa, 0xf5, 0xba, 0x5f, 0x69, 0x63, 0x63, 0x3c, 0xe0, 0x11, 0x81, 0x59, 0xf1, - 0x94, 0x42, 0xc9, 0x79, 0x02, 0x79, 0xb1, 0x4d, 0x55, 0xba, 0x99, 0x96, 0xcd, 0xfa, 0xdd, 0xfa, 0x47, 0x96, 0x00, - 0xa2, 0x26, 0x79, 0x64, 0x32, 0x81, 0x0d, 0x15, 0xd2, 0x14, 0xc7, 0xa4, 0x56, 0x02, 0xae, 0xf9, 0xb0, 0x8f, 0x6c, - 0x09, 0x38, 0x3b, 0x70, 0x2d, 0x88, 0xc3, 0x59, 0x33, 0x64, 0xb2, 0x3c, 0xa7, 0xad, 0xd1, 0x3f, 0x5b, 0xad, 0xb1, - 0xb5, 0xff, 0x43, 0x4b, 0x71, 0x3f, 0x19, 0x0b, 0x4d, 0x0c, 0x48, 0x6d, 0x8f, 0xbf, 0xbb, 0x95, 0x74, 0xe6, 0x6d, - 0xc1, 0x49, 0xff, 0x37, 0xd3, 0xe6, 0x74, 0x9e, 0x3d, 0x39, 0x8c, 0x7c, 0xc0, 0x98, 0x0a, 0x61, 0x8c, 0x93, 0xf0, - 0x62, 0x3b, 0xbc, 0x68, 0x0c, 0x6a, 0xff, 0xe5, 0x0e, 0x86, 0x9c, 0xea, 0xd8, 0x7b, 0x1f, 0x44, 0xc9, 0xbe, 0x98, - 0x5b, 0x34, 0x56, 0x87, 0xb4, 0x28, 0x6e, 0xfb, 0x00, 0x32, 0xf0, 0xd2, 0xfd, 0xff, 0xb8, 0x75, 0x88, 0x63, 0xb0, - 0x09, 0x79, 0x89, 0x4b, 0x12, 0xb3, 0x4d, 0x1f, 0x05, 0xf5, 0xfa, 0xb4, 0x11, 0x2e, 0xd1, 0x5c, 0xe9, 0xfe, 0x07, - 0x2f, 0x5b, 0x54, 0x77, 0x29, 0x0f, 0xf7, 0x0e, 0x8c, 0x69, 0x7c, 0x73, 0xf3, 0x3d, 0x0d, 0xa6, 0x14, 0xba, 0x19, - 0xef, 0x60, 0x13, 0xbb, 0xde, 0x56, 0x56, 0x6c, 0x17, 0x99, 0xa2, 0xa2, 0xa9, 0xd1, 0x47, 0x33, 0xd8, 0xec, 0xd0, - 0x80, 0xf6, 0x6f, 0x31, 0xc9, 0x60, 0xf1, 0x70, 0x6b, 0x2e, 0x44, 0xcb, 0xeb, 0x9c, 0xed, 0x28, 0x38, 0x27, 0x23, - 0x8e, 0x24, 0x48, 0x93, 0xee, 0x3b, 0x8e, 0x1e, 0xd4, 0x41, 0xd5, 0x88, 0x3b, 0x6d, 0xc9, 0x7e, 0x45, 0x7d, 0x97, - 0x3e, 0xae, 0x0b, 0x79, 0xe5, 0x1c, 0x48, 0xc4, 0x67, 0x85, 0x37, 0x27, 0x44, 0x46, 0x6d, 0x1b, 0xa9, 0x15, 0x59, - 0x91, 0x5f, 0x21, 0x25, 0xea, 0x5f, 0x51, 0x2b, 0xc8, 0x62, 0x0e, 0xc0, 0xc0, 0x36, 0x00, 0xab, 0xdf, 0xac, 0x18, - 0xb2, 0xa5, 0x80, 0xc6, 0x2f, 0x67, 0xdb, 0x7c, 0xe2, 0x96, 0xec, 0xe8, 0x17, 0x44, 0x6d, 0x6b, 0x45, 0x13, 0x9c, - 0x77, 0x2f, 0xac, 0x9e, 0x89, 0xdf, 0x53, 0xcf, 0xb7, 0xc0, 0x36, 0x90, 0x4f, 0xd2, 0xfd, 0xce, 0x99, 0x3e, 0x60, - 0x0f, 0xc6, 0x58, 0xc7, 0x60, 0x57, 0xd8, 0x63, 0xa3, 0x37, 0x55, 0xe5, 0x39, 0x68, 0x57, 0xb7, 0x1c, 0x15, 0xf1, - 0xf8, 0x2d, 0xcb, 0x3a, 0x18, 0x66, 0x18, 0x3d, 0xf3, 0x05, 0x94, 0x2d, 0xda, 0x11, 0x99, 0x93, 0x5c, 0x46, 0xdb, - 0x54, 0x0e, 0x28, 0x81, 0x05, 0x31, 0xa9, 0x71, 0x4a, 0xdd, 0x2d, 0x9b, 0x97, 0xae, 0xa3, 0x09, 0xf1, 0xd6, 0x5f, - 0x67, 0x3e, 0xd7, 0x83, 0xa3, 0xf2, 0x3c, 0x44, 0x60, 0x1a, 0xc8, 0xc3, 0x02, 0x0e, 0x23, 0x79, 0x5e, 0x8a, 0x40, - 0x01, 0xef, 0x06, 0x7d, 0xb6, 0x19, 0x28, 0x72, 0x0a, 0x91, 0x77, 0x9e, 0x83, 0x05, 0xba, 0xc1, 0x53, 0x44, 0x19, - 0x87, 0x87, 0xff, 0x2e, 0x70, 0x19, 0x1e, 0x92, 0x25, 0x8c, 0xef, 0x1d, 0x4e, 0x24, 0x27, 0xa9, 0x8b, 0xa4, 0xf5, - 0x4b, 0x78, 0xa6, 0xb6, 0x71, 0x6b, 0xfe, 0x22, 0xfb, 0x24, 0x76, 0xaf, 0xbc, 0x80, 0xf9, 0x18, 0x35, 0xd9, 0x65, - 0xfe, 0xc2, 0x3c, 0x26, 0x3d, 0x33, 0xaf, 0xd1, 0x6a, 0x0d, 0x78, 0x20, 0x69, 0x45, 0x58, 0xca, 0x2c, 0x99, 0x73, - 0x19, 0x00, 0xe8, 0xda, 0x78, 0xd8, 0x1a, 0x42, 0x7c, 0x22, 0xd7, 0x77, 0x45, 0x42, 0x65, 0xaa, 0x59, 0x96, 0x23, - 0xf7, 0xc9, 0x4d, 0x08, 0x4b, 0xb5, 0xcb, 0x12, 0xb7, 0x99, 0xe6, 0xb6, 0x36, 0x3c, 0xf7, 0xca, 0xf2, 0xbd, 0xc0, - 0x14, 0xf5, 0xa0, 0xbf, 0xb3, 0x8d, 0x38, 0x45, 0x10, 0x22, 0x66, 0x70, 0x87, 0xa3, 0x11, 0x64, 0x53, 0x4e, 0xf4, - 0x67, 0xbb, 0xc4, 0xe6, 0xa7, 0x97, 0xa9, 0xaa, 0x70, 0x39, 0x62, 0x32, 0xb1, 0x39, 0x1b, 0xb0, 0x98, 0x83, 0x57, - 0x7b, 0x72, 0x9b, 0xdb, 0xb2, 0xec, 0x8d, 0x08, 0x56, 0x83, 0x16, 0xce, 0x1d, 0x2c, 0x15, 0xfa, 0x4e, 0x66, 0xbd, - 0xab, 0x83, 0x9b, 0xd9, 0x6f, 0xd2, 0xee, 0x8f, 0x1c, 0x7d, 0x55, 0x69, 0xdc, 0x81, 0x6d, 0x2c, 0x81, 0x0d, 0x8f, - 0x11, 0x29, 0x87, 0x44, 0xf5, 0xa9, 0x4f, 0x6c, 0x1e, 0xd5, 0x98, 0xe4, 0x38, 0xc8, 0x1d, 0x26, 0xae, 0x68, 0x3a, - 0x9b, 0xb4, 0x10, 0xbb, 0x52, 0x21, 0x3d, 0x9d, 0x85, 0xfc, 0x16, 0x73, 0xd3, 0x75, 0x92, 0xc8, 0xd6, 0xb5, 0x0f, - 0xf9, 0xa2, 0x25, 0x75, 0x68, 0x60, 0xa7, 0xc7, 0x1c, 0xfd, 0x78, 0xb5, 0x95, 0xaf, 0xd4, 0xd6, 0x71, 0x4e, 0x92, - 0x8f, 0x71, 0xbc, 0x68, 0xf8, 0xe7, 0xa2, 0xa2, 0xd1, 0xc2, 0x93, 0xd8, 0xfa, 0x61, 0x27, 0xaf, 0x5f, 0xd1, 0x62, - 0x36, 0x1c, 0xb5, 0x5e, 0x96, 0x57, 0x1c, 0xee, 0xdd, 0xb6, 0x14, 0x4b, 0x58, 0x1f, 0xe3, 0x72, 0xc9, 0xd3, 0xa8, - 0x5a, 0x3a, 0xfa, 0xcb, 0x1b, 0xb8, 0x25, 0xef, 0x04, 0xc0, 0x44, 0x52, 0x1f, 0x61, 0x41, 0x7b, 0x19, 0x31, 0x42, - 0xec, 0x05, 0xd9, 0x27, 0x68, 0x7b, 0xb1, 0xaf, 0x76, 0x3d, 0x0c, 0xd9, 0x92, 0x64, 0x77, 0x6f, 0x46, 0xf8, 0x42, - 0xdd, 0x3d, 0xb2, 0x1a, 0x87, 0x6b, 0xf2, 0xe2, 0x32, 0x44, 0xb1, 0x97, 0x70, 0xc3, 0xa8, 0x2d, 0xc5, 0xdc, 0x82, - 0x1b, 0x49, 0x8b, 0x89, 0xad, 0x51, 0x46, 0x0d, 0x9b, 0x43, 0x9b, 0x43, 0x69, 0xef, 0x15, 0xdf, 0xf0, 0xdf, 0x10, - 0xef, 0x7c, 0x69, 0x4b, 0x12, 0x75, 0xef, 0x42, 0x5a, 0xe6, 0x45, 0xba, 0x92, 0xf5, 0xf3, 0x76, 0x62, 0x43, 0x71, - 0x37, 0xc7, 0x80, 0xf5, 0xc4, 0x41, 0x76, 0x69, 0xf2, 0x81, 0xc4, 0x26, 0x4a, 0x56, 0x5a, 0xfd, 0xcf, 0xee, 0xfa, - 0xb0, 0xe0, 0xa1, 0x89, 0x46, 0xc7, 0xb6, 0x43, 0x37, 0x62, 0x1e, 0x7e, 0x8d, 0x67, 0xaa, 0x16, 0x90, 0x1c, 0xe6, - 0x26, 0x51, 0xea, 0x66, 0x84, 0xea, 0xc4, 0x8d, 0x17, 0x88, 0x7a, 0xda, 0xf5, 0x4c, 0xc7, 0xd2, 0xfb, 0xbb, 0x0c, - 0xa1, 0xa9, 0x21, 0x04, 0x0f, 0x21, 0x39, 0x3f, 0x09, 0x6f, 0x46, 0x27, 0xe2, 0x1b, 0xa6, 0xcb, 0x19, 0x72, 0x0f, - 0x5f, 0xa0, 0x75, 0x27, 0xc1, 0xc2, 0xe1, 0x86, 0x90, 0x22, 0x15, 0x04, 0xc8, 0xf6, 0x31, 0x80, 0x85, 0x46, 0xf6, - 0xa2, 0xc9, 0xd4, 0x80, 0xc8, 0x66, 0x6d, 0x4b, 0x98, 0x63, 0x33, 0x35, 0x68, 0xc1, 0xd6, 0xfc, 0x12, 0x28, 0x1b, - 0xda, 0xe2, 0x2d, 0xfd, 0x4f, 0x5e, 0x13, 0x41, 0x8c, 0x69, 0x6a, 0xd3, 0xcc, 0x7a, 0xe5, 0xda, 0xde, 0xf5, 0x29, - 0x16, 0x0b, 0xe4, 0xc0, 0x75, 0x43, 0x69, 0x6c, 0x8d, 0xd5, 0x25, 0x0d, 0x68, 0xb9, 0xa8, 0x2e, 0x08, 0x84, 0xc4, - 0x10, 0xf3, 0xaa, 0xa1, 0x90, 0x92, 0x84, 0x6a, 0x6e, 0xdd, 0x89, 0x6d, 0x82, 0xc2, 0xec, 0xb8, 0x33, 0x79, 0xe8, - 0xe7, 0x70, 0xfe, 0xfe, 0xc6, 0x2c, 0x40, 0x51, 0xb8, 0xe2, 0xa5, 0x8c, 0x06, 0x89, 0x7e, 0xb3, 0x1e, 0x7a, 0xfe, - 0x83, 0x03, 0xda, 0x9d, 0xca, 0x32, 0xa3, 0xd4, 0xa9, 0x9e, 0x09, 0x4e, 0x6f, 0x0d, 0xd0, 0x88, 0x48, 0x80, 0x09, - 0xfc, 0xa8, 0x3f, 0x0a, 0x15, 0x0b, 0x98, 0xb5, 0x95, 0x53, 0xaf, 0xef, 0x31, 0x10, 0x29, 0xec, 0xb0, 0x71, 0xce, - 0xa2, 0x55, 0x8d, 0x78, 0x42, 0x82, 0x3e, 0x48, 0xc8, 0xce, 0x59, 0xf5, 0x8c, 0xaf, 0x93, 0x0b, 0xbe, 0x60, 0x77, - 0xfc, 0xb5, 0x06, 0x50, 0x8e, 0x7f, 0xb1, 0xf7, 0x86, 0xd7, 0xc3, 0x16, 0xd7, 0x23, 0xe6, 0x8b, 0x32, 0x2f, 0x7f, - 0x78, 0xd0, 0x72, 0xfa, 0xf7, 0xe7, 0x69, 0x80, 0x2a, 0x7f, 0xb1, 0x84, 0x01, 0xa9, 0x3c, 0xbc, 0xf5, 0x46, 0xe4, - 0x4a, 0x66, 0x14, 0x8d, 0x59, 0x3b, 0x6e, 0x09, 0x3b, 0xb8, 0x28, 0x8e, 0x20, 0x54, 0xfc, 0xf3, 0x16, 0x40, 0x62, - 0x2b, 0x68, 0x99, 0xd1, 0xa0, 0x11, 0xed, 0x81, 0x3a, 0x2b, 0x6c, 0xcc, 0x0b, 0xb6, 0x2e, 0x5f, 0xde, 0xad, 0xe0, - 0x20, 0x4b, 0x48, 0x82, 0x87, 0xf5, 0xf6, 0xcd, 0x26, 0xd3, 0xa5, 0x87, 0xa9, 0xd7, 0x1d, 0xbf, 0x67, 0x56, 0x20, - 0xa4, 0xd9, 0x43, 0x64, 0x6d, 0x37, 0x12, 0xd3, 0x1b, 0x4f, 0x6d, 0x3b, 0x62, 0x5e, 0xb7, 0x13, 0x91, 0x2b, 0x75, - 0x6c, 0x9b, 0x87, 0xc8, 0x08, 0x2b, 0x8c, 0x24, 0xb8, 0xfc, 0x32, 0x20, 0x36, 0x51, 0xd0, 0xd8, 0xc7, 0xe2, 0x52, - 0x16, 0x93, 0xec, 0xa3, 0xf8, 0x4b, 0x59, 0xeb, 0x5f, 0x22, 0xd5, 0xd9, 0x13, 0xf8, 0x15, 0x43, 0x7b, 0x0f, 0xa1, - 0xb1, 0x4e, 0x83, 0xbb, 0x16, 0x3c, 0xb2, 0x80, 0x72, 0x1f, 0x1a, 0x12, 0x42, 0x71, 0xba, 0x1d, 0x16, 0xd9, 0xae, - 0x25, 0x46, 0x80, 0x8f, 0x92, 0x5e, 0xa9, 0x4d, 0xc6, 0x70, 0x05, 0x05, 0x70, 0x79, 0xae, 0xc7, 0xf3, 0xd1, 0xcd, - 0xf6, 0x4a, 0x23, 0x09, 0x7d, 0x37, 0xac, 0x78, 0xb9, 0xb9, 0xee, 0x2a, 0x8b, 0x36, 0x9f, 0x62, 0x1c, 0xeb, 0x02, - 0x91, 0x19, 0x21, 0x62, 0x6e, 0xd9, 0xa0, 0x20, 0x1d, 0x6c, 0x17, 0x03, 0xf4, 0xb1, 0x81, 0xe1, 0x0c, 0x56, 0xba, - 0xaa, 0xad, 0x9d, 0xa7, 0xc8, 0xf4, 0x6f, 0xb6, 0x98, 0xc0, 0xcf, 0x17, 0x17, 0x24, 0x04, 0x24, 0x2c, 0xf4, 0xcc, - 0x83, 0x59, 0x0f, 0x27, 0x79, 0xf6, 0x12, 0x13, 0x2e, 0x64, 0xa8, 0x70, 0xfc, 0xa0, 0xad, 0xe6, 0x82, 0xe6, 0xf8, - 0xf5, 0x4c, 0x5b, 0x95, 0xaf, 0x95, 0x34, 0xc9, 0x82, 0x43, 0x5e, 0x38, 0x5d, 0xde, 0x32, 0x44, 0xf1, 0xa9, 0x76, - 0xdd, 0x77, 0xb8, 0xf9, 0x4c, 0x8a, 0x9c, 0x54, 0xda, 0x89, 0x40, 0xa5, 0x21, 0x93, 0xb7, 0x7b, 0x01, 0xb0, 0x6d, - 0x88, 0xbe, 0x68, 0x36, 0x32, 0x53, 0x99, 0x8e, 0xae, 0x96, 0x87, 0x70, 0x6c, 0x0f, 0x6f, 0x06, 0xc3, 0x10, 0xf0, - 0xfa, 0xb4, 0x66, 0xff, 0xba, 0x67, 0x25, 0x55, 0x15, 0x4d, 0x8c, 0x8a, 0xb8, 0xb9, 0x60, 0x72, 0x0f, 0x2a, 0xa6, - 0xc1, 0x43, 0x38, 0x69, 0xc0, 0xe9, 0x38, 0x53, 0xd9, 0x20, 0x79, 0x81, 0x49, 0x10, 0x7b, 0x02, 0x2d, 0x4d, 0xc0, - 0xbc, 0xa2, 0xec, 0x38, 0xda, 0x8c, 0xed, 0x88, 0x50, 0xce, 0x9c, 0x44, 0x45, 0xfc, 0x98, 0x7b, 0xd2, 0x0a, 0xb0, - 0xcf, 0x40, 0x77, 0xbd, 0xc6, 0x4f, 0x6a, 0x41, 0xd1, 0xb7, 0xb6, 0xff, 0x5f, 0x86, 0x41, 0xd8, 0x9e, 0xb6, 0x73, - 0x00, 0x0a, 0xb2, 0x84, 0x00, 0xfe, 0xf2, 0x82, 0xbe, 0x04, 0x59, 0x92, 0x0a, 0x3f, 0x90, 0x57, 0x8f, 0xad, 0x3e, - 0x55, 0xce, 0xbe, 0x3a, 0xfb, 0xf5, 0xb7, 0xec, 0x97, 0xf4, 0xc1, 0x25, 0x27, 0x77, 0xfb, 0x54, 0x62, 0x73, 0xbd, - 0x53, 0x2a, 0x1c, 0x9d, 0x63, 0xb9, 0xac, 0xaf, 0xc4, 0x70, 0x39, 0x95, 0x10, 0xfc, 0x87, 0x0f, 0xc4, 0xef, 0xb3, - 0x72, 0xb7, 0x4f, 0x21, 0xbf, 0x9f, 0x4b, 0x2b, 0xf9, 0xc9, 0xe1, 0x46, 0xba, 0x4f, 0xab, 0x28, 0xc7, 0x5c, 0x7f, - 0x5b, 0x4a, 0xe5, 0xac, 0xb3, 0xb3, 0x4b, 0xb9, 0xb9, 0x9c, 0x73, 0x78, 0xaa, 0xcb, 0xbd, 0x5e, 0xb5, 0xd6, 0xff, - 0x57, 0x66, 0x0d, 0x65, 0xb9, 0x19, 0x94, 0xcc, 0x7b, 0x28, 0x08, 0x72, 0x37, 0xb1, 0x4e, 0x2f, 0x72, 0xe7, 0xb8, - 0x43, 0x39, 0x96, 0xb6, 0xbe, 0x2a, 0x53, 0x8f, 0xcc, 0x45, 0x8c, 0xf3, 0x15, 0xf1, 0xb2, 0x9a, 0xbc, 0x6d, 0xd0, - 0x6f, 0x4f, 0xc8, 0xfc, 0xe7, 0xd7, 0x90, 0x64, 0x3f, 0xc6, 0x2f, 0xea, 0xfe, 0x02, 0x5c, 0xc3, 0x9b, 0x72, 0xe4, - 0x05, 0x3b, 0xae, 0xab, 0xa7, 0x6d, 0xb2, 0xae, 0x85, 0x63, 0xdb, 0xe5, 0xc0, 0x6b, 0x8b, 0x38, 0x04, 0x44, 0x69, - 0x65, 0xdc, 0x73, 0x7a, 0xd7, 0xe9, 0x77, 0xa6, 0x3a, 0x86, 0xdd, 0x80, 0x20, 0x11, 0x0c, 0x28, 0x30, 0x1f, 0x83, - 0xba, 0x93, 0x51, 0xe5, 0xc4, 0x9e, 0x35, 0x10, 0x4a, 0x60, 0x45, 0xf3, 0x35, 0x12, 0x80, 0x96, 0x76, 0xe0, 0x65, - 0xad, 0xa2, 0x93, 0x25, 0x6b, 0x10, 0x1c, 0xf4, 0xff, 0x88, 0xc1, 0x11, 0x07, 0xdf, 0x24, 0x21, 0xce, 0x0a, 0x45, - 0x62, 0x4e, 0xb3, 0x47, 0x1f, 0xb3, 0x8f, 0x72, 0x09, 0xd2, 0xec, 0x47, 0x60, 0x80, 0x60, 0x19, 0x8e, 0x63, 0x91, - 0xa0, 0x64, 0xbe, 0x2a, 0xc8, 0x92, 0x9a, 0xf7, 0x9f, 0x60, 0x6c, 0xff, 0x46, 0xb7, 0x8d, 0xec, 0xef, 0x9a, 0x4a, - 0x6e, 0x7f, 0xe5, 0xdd, 0xf2, 0xeb, 0xfa, 0x7a, 0x79, 0xa1, 0xfe, 0xfc, 0xba, 0x69, 0x81, 0x77, 0x72, 0xf7, 0x52, - 0x0e, 0x35, 0x3f, 0x5f, 0x67, 0xc4, 0x58, 0x30, 0x40, 0xec, 0x53, 0xc7, 0x87, 0x92, 0xee, 0xb7, 0x9e, 0x0d, 0xac, - 0x89, 0xfd, 0x1a, 0xb7, 0xa8, 0x5e, 0xce, 0x0b, 0x6c, 0x56, 0xe3, 0x1a, 0xba, 0xe7, 0x85, 0xd6, 0x3c, 0x17, 0x66, - 0xa9, 0xa0, 0x14, 0x5b, 0x53, 0xc0, 0x27, 0xb8, 0xeb, 0xca, 0x4d, 0x6a, 0xa2, 0xea, 0x4d, 0x78, 0x92, 0xa0, 0xa2, - 0x03, 0x17, 0x4d, 0x5f, 0x3d, 0xb5, 0x2d, 0x36, 0x86, 0x3f, 0x13, 0x74, 0x35, 0x86, 0xac, 0x46, 0x39, 0x66, 0x2d, - 0x56, 0x7a, 0xa1, 0xb5, 0xbc, 0x5a, 0xea, 0x6e, 0x5f, 0x03, 0xbd, 0xf2, 0x82, 0x32, 0xe0, 0x1e, 0x80, 0xac, 0x57, - 0xf4, 0x94, 0x56, 0x91, 0x2d, 0xd9, 0x27, 0x14, 0xdc, 0x3c, 0x9e, 0xe0, 0xb0, 0xf4, 0x51, 0xdd, 0x23, 0x4d, 0x62, - 0x2b, 0x5c, 0xc3, 0xde, 0x64, 0x55, 0xe9, 0x65, 0xf3, 0x84, 0x07, 0x98, 0xd3, 0x82, 0xfd, 0x1b, 0xdb, 0x62, 0xf9, - 0x71, 0x12, 0x68, 0xbb, 0x68, 0x14, 0x37, 0xca, 0x00, 0x88, 0xd2, 0x3d, 0xbd, 0x01, 0x07, 0xa2, 0x5d, 0xd7, 0x42, - 0x7d, 0x9b, 0xd8, 0x0e, 0xe7, 0x26, 0x13, 0x6a, 0xe1, 0xc2, 0x1a, 0xcd, 0xa6, 0x0b, 0x27, 0x6a, 0xef, 0xd2, 0x9e, - 0x27, 0x83, 0x8c, 0xff, 0x2a, 0x83, 0x98, 0xf4, 0xbd, 0xc0, 0xa3, 0x83, 0x70, 0x0f, 0xa1, 0x27, 0x61, 0x91, 0x8a, - 0xd6, 0x14, 0x6c, 0x83, 0x15, 0xa5, 0x71, 0x00, 0xa0, 0xbd, 0x8b, 0xb8, 0x01, 0x07, 0x37, 0x6c, 0x0c, 0x1d, 0x1b, - 0xb7, 0xe4, 0x95, 0x64, 0x82, 0xa0, 0xf2, 0x66, 0x89, 0xcd, 0x78, 0xb2, 0x13, 0x95, 0x6f, 0x70, 0xb3, 0x73, 0x27, - 0x14, 0xf6, 0x3b, 0x9d, 0x11, 0x4c, 0x59, 0x59, 0xed, 0xd0, 0x37, 0x23, 0x5e, 0x70, 0x98, 0x43, 0xb2, 0x20, 0x22, - 0x19, 0xb1, 0xaa, 0x1b, 0xbf, 0xf3, 0x7e, 0x94, 0x9b, 0x89, 0x6d, 0xb1, 0x5e, 0xf1, 0x8c, 0x60, 0xbd, 0x83, 0xa3, - 0x73, 0xf2, 0xdc, 0xcd, 0xc8, 0x5c, 0xe1, 0x3f, 0x86, 0xc9, 0xed, 0x66, 0x7e, 0x30, 0x8c, 0xa8, 0x2f, 0xff, 0x93, - 0x8c, 0x59, 0x55, 0x4e, 0xa3, 0x31, 0x24, 0x44, 0x32, 0xbc, 0x09, 0x40, 0x3c, 0xcf, 0x9a, 0x8c, 0xd1, 0x4c, 0xac, - 0xb6, 0xad, 0xd3, 0x34, 0xfb, 0xf6, 0x92, 0xd3, 0xef, 0x45, 0x85, 0x17, 0x78, 0x5c, 0x75, 0x6e, 0x64, 0xd7, 0x0f, - 0x74, 0x31, 0x87, 0xbe, 0x55, 0xe9, 0xaa, 0xbe, 0x91, 0x1f, 0x6a, 0xf8, 0x4a, 0x0c, 0xea, 0x6e, 0x50, 0xf2, 0x00, - 0x00, 0xfd, 0x71, 0x5e, 0x5e, 0xfd, 0x5f, 0xa3, 0xb9, 0x93, 0x4e, 0xb0, 0xb1, 0x62, 0x69, 0x8e, 0xe3, 0xe5, 0xd0, - 0x5f, 0xa8, 0xe8, 0x39, 0xa1, 0xdf, 0x8d, 0x48, 0xba, 0x44, 0x67, 0x18, 0x4f, 0xcc, 0xd2, 0xe0, 0xb0, 0x86, 0x12, - 0xfa, 0x9b, 0xd1, 0x6f, 0xd7, 0xde, 0x37, 0x90, 0xe2, 0xdf, 0xb8, 0xad, 0x8e, 0x67, 0x47, 0x95, 0x99, 0xd4, 0x32, - 0x0f, 0xdc, 0x16, 0x57, 0x75, 0xd5, 0xcc, 0xa7, 0xed, 0x92, 0x69, 0xda, 0x79, 0xcc, 0x2e, 0xe3, 0x57, 0x38, 0x91, - 0x44, 0x7d, 0xb7, 0x0e, 0x03, 0x34, 0x30, 0xd0, 0x5e, 0x12, 0xa7, 0x17, 0x99, 0xae, 0xde, 0x6a, 0x06, 0x43, 0x73, - 0xa5, 0x4e, 0x3f, 0xb0, 0x7a, 0x41, 0xcb, 0xb0, 0xb3, 0x66, 0xf2, 0xc8, 0x09, 0xb1, 0x8b, 0x9c, 0x9f, 0x98, 0x0f, - 0x39, 0xa1, 0xa6, 0x01, 0xbd, 0x9d, 0x97, 0x57, 0xae, 0x54, 0x91, 0x81, 0x9a, 0x09, 0x29, 0x20, 0xbb, 0xa1, 0xf5, - 0xb1, 0x26, 0xc6, 0x1e, 0xa4, 0x0b, 0xb3, 0xd6, 0x3c, 0x0d, 0x42, 0x53, 0x28, 0x0b, 0x57, 0x66, 0x64, 0xa3, 0xf0, - 0x3d, 0x39, 0x85, 0x86, 0x0b, 0x5a, 0x42, 0x7b, 0xf7, 0x3e, 0x24, 0x74, 0xf7, 0x98, 0x44, 0xd5, 0x74, 0x96, 0x16, - 0xca, 0xcd, 0x42, 0x79, 0x8e, 0xc0, 0x0b, 0x16, 0xb9, 0xe7, 0x55, 0x39, 0x12, 0xb7, 0xee, 0xd6, 0xe9, 0xeb, 0x6e, - 0xb5, 0x86, 0x7d, 0x4c, 0x79, 0x24, 0xbc, 0xa3, 0x85, 0xf9, 0x57, 0xb2, 0xe4, 0x48, 0x87, 0x8d, 0x9a, 0x66, 0xf2, - 0x15, 0x3e, 0xff, 0x47, 0x75, 0x6f, 0xe2, 0x7d, 0xe2, 0x59, 0x81, 0x70, 0x57, 0x14, 0x3a, 0xe3, 0x8e, 0x59, 0x47, - 0xeb, 0x70, 0x4e, 0x9d, 0x98, 0xf1, 0xf0, 0xb8, 0x40, 0x31, 0xfc, 0xf6, 0x8c, 0x06, 0xdc, 0xfd, 0xe9, 0x98, 0xd8, - 0xbd, 0x0e, 0x52, 0xec, 0x32, 0x8b, 0x74, 0x7f, 0xd5, 0x68, 0xaa, 0x0b, 0xb1, 0x6e, 0x95, 0xb9, 0x27, 0xa6, 0xec, - 0x30, 0x9c, 0x51, 0xed, 0xb3, 0x85, 0x9b, 0xbd, 0x91, 0xbb, 0x51, 0xd5, 0x53, 0x6c, 0xe9, 0x92, 0xc3, 0x13, 0x38, - 0x64, 0xd3, 0xa8, 0xdc, 0xfd, 0x5a, 0xcb, 0x57, 0xfb, 0x6a, 0xd1, 0x97, 0x58, 0xc4, 0xf7, 0xf3, 0x21, 0x85, 0x2d, - 0x4f, 0x44, 0xb6, 0x3a, 0x8c, 0x75, 0x80, 0xf1, 0x50, 0xeb, 0xdb, 0xcd, 0x4e, 0x6a, 0x3f, 0xa0, 0xbb, 0x75, 0x96, - 0x96, 0x6f, 0x16, 0xbf, 0xad, 0xff, 0x3c, 0x70, 0x2f, 0x39, 0x14, 0xbf, 0x56, 0x5f, 0x45, 0xa2, 0xc1, 0xfd, 0xb2, - 0x5c, 0x93, 0x49, 0x71, 0xfc, 0x04, 0xc7, 0x54, 0xa6, 0x28, 0x47, 0xd5, 0x6d, 0x37, 0x84, 0x8a, 0x8d, 0x8e, 0xcd, - 0x79, 0xb2, 0x33, 0x75, 0xf5, 0x00, 0x8f, 0x0c, 0x51, 0xfb, 0x9f, 0xca, 0x8b, 0xd3, 0x1e, 0xd9, 0x07, 0xfb, 0xb7, - 0xcc, 0x21, 0xb6, 0x4e, 0xbb, 0x4e, 0xad, 0x9a, 0x70, 0xe0, 0xc3, 0xea, 0x1a, 0xff, 0x17, 0x2f, 0xb8, 0xd1, 0x44, - 0xf4, 0x56, 0xb5, 0x65, 0xa5, 0x04, 0xb6, 0xab, 0x5d, 0x2a, 0x35, 0xbd, 0xd5, 0x4d, 0x8c, 0xcb, 0x9c, 0xd7, 0xd5, - 0xde, 0x90, 0xf5, 0x93, 0xa0, 0x0d, 0xb9, 0x7f, 0xfa, 0x30, 0xe2, 0x10, 0x23, 0x29, 0x6b, 0x17, 0x63, 0xae, 0x0d, - 0xa1, 0x93, 0x2d, 0xca, 0x98, 0xdc, 0xf5, 0x4f, 0x24, 0xaa, 0x2e, 0x9a, 0x20, 0x10, 0xe7, 0xed, 0xb1, 0xf5, 0xea, - 0x6e, 0x71, 0xc9, 0xcd, 0x70, 0x65, 0xaa, 0x12, 0xf0, 0x79, 0x62, 0xf0, 0xc5, 0x82, 0x44, 0x09, 0x3c, 0x0b, 0xd5, - 0x64, 0xdc, 0x35, 0x44, 0x1f, 0x6c, 0xbc, 0xfc, 0xc3, 0xc8, 0x31, 0x3f, 0xf3, 0x4f, 0xca, 0x1b, 0x44, 0x27, 0xc0, - 0x99, 0x00, 0x3c, 0x9e, 0xa5, 0x25, 0xd5, 0x37, 0xa7, 0x7f, 0x6d, 0x92, 0xff, 0x77, 0x6c, 0xf8, 0x56, 0xfb, 0x15, - 0xd0, 0x68, 0x61, 0xd8, 0x21, 0xd0, 0x1a, 0xd4, 0x39, 0x85, 0x71, 0x0f, 0x01, 0xb5, 0xe2, 0x1a, 0xd7, 0x77, 0x69, - 0x84, 0x30, 0x08, 0x49, 0x50, 0xd9, 0x1d, 0x76, 0xb8, 0xb7, 0xbe, 0x2f, 0x90, 0x01, 0xc2, 0x43, 0x19, 0x41, 0x8b, - 0x8c, 0x07, 0xf7, 0x06, 0x7b, 0x10, 0xd6, 0xb9, 0x94, 0x53, 0xae, 0x92, 0xae, 0x43, 0xf6, 0x71, 0xd3, 0xf4, 0x1a, - 0x27, 0xe4, 0x08, 0x52, 0xa9, 0x67, 0x40, 0xd3, 0x74, 0x91, 0x5e, 0xae, 0xb7, 0x74, 0xca, 0xb7, 0x06, 0x62, 0xeb, - 0x5a, 0x58, 0x74, 0x9f, 0x5d, 0xca, 0x43, 0x0f, 0x52, 0x08, 0x0e, 0x89, 0xe5, 0x14, 0xd4, 0x0f, 0x60, 0x52, 0x2e, - 0xff, 0xc3, 0x24, 0x5e, 0xe5, 0xee, 0xfe, 0xd7, 0x6a, 0xb1, 0xaa, 0x1e, 0xcc, 0x6c, 0xfc, 0x40, 0xf7, 0x97, 0xf0, - 0x51, 0xad, 0x3d, 0x5f, 0x39, 0x66, 0x05, 0xa6, 0x0c, 0xfe, 0x93, 0x7f, 0xd0, 0x86, 0x3a, 0x97, 0xf9, 0x6f, 0x71, - 0x25, 0xae, 0x81, 0x14, 0xe7, 0x3d, 0xd4, 0x88, 0x26, 0x69, 0xbc, 0x4c, 0x59, 0x6d, 0x1a, 0x9f, 0x66, 0x8a, 0x40, - 0x88, 0x3a, 0x7a, 0x1d, 0x2e, 0x39, 0x70, 0x91, 0xc3, 0xaa, 0x05, 0xf8, 0x67, 0xc1, 0x0a, 0xe8, 0xf6, 0xb7, 0xe4, - 0x68, 0xcd, 0xfc, 0xed, 0x8e, 0xc6, 0x95, 0x0b, 0x39, 0x34, 0xb1, 0xf6, 0xd5, 0x76, 0x4c, 0xce, 0xd4, 0x9d, 0xa7, - 0x15, 0x8a, 0xae, 0xab, 0x9b, 0x89, 0x2b, 0x02, 0x8e, 0x53, 0xcf, 0x0f, 0x02, 0x0c, 0x66, 0x85, 0x2f, 0xfb, 0x42, - 0x4d, 0xbf, 0xc6, 0x60, 0x0a, 0x32, 0x96, 0x3d, 0x8b, 0x62, 0x78, 0x17, 0xf2, 0x2a, 0x62, 0x2c, 0x97, 0x22, 0x56, - 0x08, 0x65, 0x01, 0x5b, 0x56, 0xae, 0x47, 0xa1, 0x78, 0x78, 0x9c, 0xe2, 0xdd, 0xcc, 0x39, 0x52, 0xee, 0x12, 0xcc, - 0xee, 0x90, 0xf9, 0x49, 0x22, 0xf5, 0xda, 0xb5, 0x60, 0x93, 0x62, 0x8a, 0x5d, 0x51, 0xe4, 0x06, 0x87, 0x30, 0xe1, - 0xa8, 0x7b, 0x7b, 0xa3, 0x69, 0x22, 0xa1, 0x91, 0x28, 0x30, 0x23, 0xa4, 0xbb, 0xfe, 0xe3, 0xee, 0x4d, 0x3f, 0x99, - 0x32, 0x06, 0x11, 0xd0, 0x28, 0x7a, 0x06, 0x10, 0x7a, 0xbe, 0x4a, 0xb9, 0x64, 0x3a, 0xae, 0x60, 0xc4, 0x7d, 0x05, - 0x24, 0x5c, 0x34, 0x6e, 0xcd, 0x2f, 0xd1, 0x49, 0xa6, 0x78, 0x9a, 0x00, 0x45, 0xa3, 0xad, 0xf2, 0x6c, 0x28, 0x1f, - 0x79, 0x16, 0xac, 0x44, 0x3d, 0x69, 0x70, 0x14, 0x0c, 0xba, 0xd9, 0x48, 0xc2, 0x21, 0x35, 0x19, 0xc6, 0xc8, 0x30, - 0x38, 0xfa, 0x97, 0xb6, 0xca, 0x43, 0x6a, 0x5d, 0x2d, 0x14, 0x32, 0xa3, 0x07, 0x33, 0x3f, 0x98, 0xa8, 0x61, 0x55, - 0x0b, 0xf3, 0x41, 0xba, 0x76, 0x5a, 0x65, 0x94, 0x25, 0xc6, 0x69, 0xb0, 0x30, 0x86, 0x1c, 0x6a, 0x1c, 0xb0, 0xd9, - 0x40, 0xee, 0x6a, 0xce, 0xe6, 0x51, 0x33, 0x6e, 0xaf, 0x6b, 0x46, 0x9f, 0xfa, 0xe2, 0x56, 0x7f, 0x2e, 0xd3, 0x0d, - 0x3b, 0x56, 0xf9, 0x4b, 0xbf, 0xa8, 0xa6, 0x0f, 0x3d, 0xe6, 0x4d, 0x39, 0x18, 0x66, 0x78, 0xf5, 0x59, 0x58, 0x3c, - 0x48, 0x1a, 0x94, 0xf9, 0x52, 0xad, 0x1d, 0x6e, 0x7f, 0x3f, 0x30, 0xf4, 0x66, 0x37, 0x31, 0x49, 0x1a, 0x02, 0xe5, - 0x08, 0x89, 0x08, 0x8e, 0x59, 0xf1, 0x1f, 0x57, 0x95, 0xff, 0xbd, 0x53, 0x5f, 0xd0, 0x83, 0xf0, 0xd1, 0x5e, 0xf7, - 0x34, 0x0a, 0x98, 0xb3, 0x96, 0xed, 0xea, 0xd3, 0x84, 0x1a, 0xd2, 0x5f, 0x11, 0x32, 0x6e, 0x1c, 0xab, 0x7f, 0x74, - 0x53, 0xf2, 0x3b, 0x5d, 0x25, 0xf6, 0xd1, 0x5c, 0x9f, 0xd8, 0xa2, 0x4a, 0x3a, 0x3a, 0x36, 0xa7, 0x2d, 0x29, 0xcd, - 0x49, 0xf9, 0x56, 0x7b, 0x78, 0xda, 0x4a, 0x71, 0xc9, 0xe6, 0x3d, 0xb9, 0x9a, 0x27, 0x59, 0x6d, 0xcb, 0x71, 0x84, - 0x3b, 0xc8, 0xd7, 0xe7, 0x8c, 0xd2, 0xd1, 0x07, 0xab, 0x1f, 0xf7, 0x26, 0x81, 0xcc, 0xd3, 0x13, 0x70, 0xa3, 0x6b, - 0x57, 0x7a, 0x7c, 0x2b, 0x4e, 0xcc, 0x93, 0xf7, 0x43, 0xf6, 0x6b, 0x5c, 0xc9, 0x82, 0x8e, 0x7b, 0x5f, 0x35, 0x4c, - 0xb7, 0x19, 0xd3, 0x7e, 0xa4, 0x18, 0x8c, 0xe6, 0xab, 0x2c, 0x89, 0x0a, 0x62, 0xc1, 0x6b, 0xe2, 0x83, 0xd8, 0x00, - 0x40, 0xce, 0x68, 0x8b, 0x5a, 0x7a, 0x8c, 0x25, 0x51, 0xbc, 0xad, 0x40, 0xcd, 0x79, 0x76, 0x96, 0xd1, 0xaa, 0x3a, - 0xd1, 0xab, 0x53, 0xae, 0xd2, 0xec, 0x22, 0x74, 0x3d, 0x7c, 0x65, 0x29, 0x2a, 0x59, 0x56, 0xbd, 0x0b, 0xd3, 0x57, - 0xec, 0x95, 0x17, 0x48, 0x79, 0x57, 0x4a, 0x4d, 0x21, 0x23, 0x1b, 0x83, 0xc6, 0xd6, 0xd9, 0x4b, 0x2c, 0x6e, 0xb2, - 0x3c, 0x4a, 0x28, 0x7c, 0x31, 0xf7, 0x71, 0x7b, 0x2c, 0x55, 0xc5, 0x9c, 0x43, 0x98, 0x93, 0x2a, 0x9d, 0x74, 0x95, - 0x03, 0xf8, 0xd5, 0x65, 0x10, 0xd6, 0x48, 0xa1, 0x3a, 0xc7, 0x3d, 0x6c, 0x49, 0xa6, 0x63, 0x06, 0x19, 0x8b, 0xee, - 0xfa, 0x3b, 0x2a, 0x9d, 0xc7, 0x41, 0x74, 0x1f, 0xba, 0x5a, 0x21, 0xc2, 0x60, 0x7b, 0xd6, 0x92, 0x2b, 0x9e, 0x2b, - 0x8e, 0xb2, 0x2b, 0x31, 0xb5, 0x3c, 0x1b, 0xb2, 0x6d, 0xd1, 0x15, 0x4b, 0x65, 0x4d, 0x77, 0x57, 0x13, 0xa9, 0xe0, - 0xb1, 0x1f, 0x7f, 0xe0, 0xcb, 0x92, 0x91, 0x53, 0x99, 0xc4, 0xb2, 0x0c, 0x61, 0x6e, 0xdc, 0x10, 0x3c, 0xc1, 0x68, - 0xde, 0x92, 0x79, 0xca, 0x29, 0x85, 0xd2, 0xfb, 0x9f, 0x1b, 0x8f, 0x50, 0x36, 0xdb, 0x30, 0xbd, 0x65, 0xea, 0xbb, - 0xc4, 0xf5, 0xfc, 0x87, 0xe8, 0x94, 0x44, 0x0b, 0xde, 0x9f, 0x27, 0x32, 0xda, 0x54, 0xa8, 0xb0, 0x6e, 0x66, 0xbb, - 0xcf, 0xc1, 0xc6, 0x7f, 0x51, 0x49, 0x86, 0x1c, 0x54, 0x98, 0x5e, 0xb5, 0x63, 0xa1, 0x73, 0xc8, 0x5d, 0x6f, 0x28, - 0x88, 0x8d, 0xc0, 0x6e, 0x68, 0x05, 0x89, 0x34, 0x59, 0x88, 0x7d, 0x36, 0xaa, 0xba, 0x9b, 0x55, 0xa1, 0x06, 0xfc, - 0xf2, 0x17, 0xb1, 0x38, 0xbf, 0x40, 0x52, 0x7d, 0xc1, 0x21, 0x21, 0xf4, 0xc9, 0x6f, 0xc4, 0x5e, 0x0d, 0xbe, 0x88, - 0x95, 0x66, 0xdb, 0x31, 0xfa, 0x99, 0x9f, 0x8f, 0xbb, 0xee, 0x0c, 0x53, 0x74, 0xb8, 0x01, 0x8b, 0x11, 0x43, 0x4e, - 0x52, 0x37, 0xf9, 0x4b, 0x4a, 0x7e, 0x32, 0xbd, 0xf9, 0x06, 0xdf, 0x69, 0x6d, 0x6f, 0xa0, 0x50, 0x88, 0x59, 0x67, - 0x68, 0x70, 0xc3, 0x1e, 0x9e, 0xea, 0x98, 0x59, 0x98, 0xe3, 0x90, 0x24, 0xa2, 0x45, 0x0e, 0x67, 0x88, 0xdf, 0x00, - 0x98, 0x40, 0x93, 0x95, 0x08, 0x19, 0x25, 0xb0, 0x47, 0xf0, 0x82, 0x9b, 0x6d, 0xde, 0xef, 0x79, 0x1e, 0x2e, 0xa4, - 0x56, 0xae, 0xe0, 0x0a, 0x30, 0xd5, 0xb3, 0x6b, 0x49, 0xf1, 0xe1, 0x51, 0xb4, 0x86, 0x78, 0xae, 0x25, 0x94, 0xc5, - 0xce, 0x83, 0x60, 0x55, 0x65, 0x57, 0xd9, 0x19, 0xcc, 0x52, 0x0f, 0x0e, 0x54, 0x71, 0x81, 0x24, 0xdd, 0x18, 0x6f, - 0x53, 0xcc, 0xb2, 0x16, 0x7e, 0xaa, 0x62, 0xde, 0xb0, 0xa9, 0xe0, 0xf0, 0x5a, 0x9d, 0x7f, 0x31, 0xd6, 0x30, 0xa1, - 0x4d, 0x0d, 0xac, 0x04, 0x31, 0x69, 0x58, 0xc2, 0xc6, 0xc1, 0x67, 0xd0, 0x9f, 0x07, 0x4c, 0x33, 0x9c, 0xde, 0x8f, - 0x51, 0xbd, 0x65, 0xf5, 0xc9, 0xf7, 0xd2, 0xf3, 0x46, 0x1d, 0x3d, 0xa8, 0xc4, 0xaa, 0xe5, 0xeb, 0x0c, 0x11, 0xdd, - 0xde, 0xfa, 0x8c, 0xe7, 0xd4, 0x32, 0x04, 0x40, 0xe2, 0x49, 0x9d, 0x19, 0x7b, 0x7c, 0xdc, 0x30, 0x46, 0x52, 0xa5, - 0xb7, 0x2c, 0x42, 0xa6, 0x9f, 0x94, 0x55, 0x0d, 0x87, 0x27, 0x9b, 0x76, 0x25, 0x54, 0x0c, 0xd7, 0x6f, 0x96, 0x17, - 0x50, 0x85, 0xc5, 0x0c, 0xc5, 0x1c, 0x9b, 0xca, 0xd9, 0x78, 0x83, 0x79, 0x06, 0xe3, 0x3c, 0xa5, 0x31, 0x37, 0x54, - 0xa0, 0x5f, 0x2a, 0x47, 0x53, 0x67, 0x62, 0xce, 0x58, 0x9e, 0xfb, 0xb0, 0xe3, 0x73, 0xc7, 0x98, 0x5d, 0x78, 0xee, - 0xee, 0xa9, 0xc3, 0xf6, 0x59, 0x74, 0x19, 0xee, 0x6e, 0x61, 0xc9, 0x9e, 0x92, 0x49, 0x8c, 0x03, 0x58, 0xe7, 0xd1, - 0x95, 0xad, 0xf1, 0x52, 0x06, 0xbb, 0x3f, 0x41, 0x0c, 0xe0, 0x68, 0xc1, 0x60, 0x04, 0xec, 0x5a, 0x7e, 0xed, 0x6a, - 0xac, 0x74, 0xf3, 0x71, 0x60, 0x85, 0x17, 0x99, 0xc0, 0xe5, 0x23, 0x26, 0xd2, 0xe0, 0x7f, 0xde, 0xc7, 0xc9, 0x57, - 0x9b, 0x8e, 0x26, 0xb2, 0xd7, 0x42, 0xbe, 0xf0, 0xaf, 0xe1, 0x6e, 0x1e, 0x98, 0xf2, 0xc5, 0x1e, 0x4f, 0x11, 0x05, - 0x4d, 0x62, 0x6c, 0xf5, 0x8c, 0x8b, 0x3d, 0x73, 0xb5, 0xe1, 0x17, 0x22, 0x8a, 0xbb, 0xbb, 0xb8, 0x2c, 0x04, 0x2c, - 0x99, 0xf0, 0x53, 0xce, 0xd4, 0xc8, 0x94, 0x3d, 0xc4, 0xb7, 0xcb, 0xf0, 0xe1, 0x71, 0x23, 0xf6, 0xc9, 0x5d, 0x11, - 0x9e, 0x48, 0xaa, 0xb0, 0xcf, 0xfc, 0xef, 0x90, 0x31, 0x27, 0xa2, 0xe8, 0xb1, 0x4c, 0x37, 0x06, 0xcf, 0x7f, 0x56, - 0xf1, 0x64, 0x55, 0x00, 0xb6, 0x46, 0x05, 0xe9, 0x97, 0x80, 0x73, 0x0a, 0x40, 0x3d, 0x0c, 0x63, 0x20, 0xc5, 0x9c, - 0x42, 0xe0, 0xe8, 0x92, 0x76, 0xc0, 0xf0, 0xb3, 0x59, 0xd0, 0x63, 0xf1, 0x5b, 0xba, 0xdc, 0x6d, 0xce, 0xcf, 0xd6, - 0x28, 0x6f, 0x2a, 0x77, 0xd5, 0xb0, 0xca, 0x4c, 0x4d, 0x61, 0xb2, 0xd8, 0x9b, 0xb9, 0x0e, 0xdf, 0xf9, 0x63, 0x5f, - 0xbb, 0xc4, 0xc1, 0xc8, 0x8d, 0xcc, 0x15, 0x2e, 0x3c, 0x98, 0x76, 0xf2, 0x0a, 0x88, 0x9a, 0xad, 0x04, 0x57, 0x02, - 0xad, 0x07, 0xa7, 0x0e, 0x77, 0x0a, 0x58, 0x41, 0x20, 0xf3, 0xfa, 0xab, 0x3e, 0x39, 0x90, 0xd1, 0xe6, 0x0a, 0x19, - 0xf4, 0xdc, 0xea, 0x05, 0x5a, 0xe5, 0x7d, 0xab, 0xfb, 0x39, 0x79, 0x63, 0xde, 0x75, 0x1f, 0x81, 0xc9, 0xf7, 0x8c, - 0xc4, 0x86, 0x2c, 0xaf, 0x85, 0xc2, 0x24, 0x01, 0x3d, 0x0e, 0xaa, 0x0a, 0x89, 0xd4, 0xa1, 0x6c, 0xd4, 0x0c, 0x15, - 0xc2, 0xf4, 0xfa, 0x07, 0x80, 0x80, 0xa3, 0x94, 0x42, 0x79, 0x22, 0xaa, 0x32, 0x02, 0x08, 0x8c, 0x0d, 0xd0, 0xb0, - 0x0c, 0x4c, 0x61, 0x9b, 0x51, 0xb4, 0xe5, 0x74, 0xe9, 0x6e, 0xbc, 0x2f, 0x47, 0xe6, 0xbc, 0x1b, 0x3c, 0x4b, 0xd0, - 0x6e, 0xec, 0xeb, 0x38, 0x86, 0x7e, 0x2a, 0xfa, 0x47, 0xb0, 0x83, 0x73, 0x58, 0x82, 0x82, 0x53, 0x42, 0x9f, 0x33, - 0xff, 0x83, 0xaf, 0xc4, 0xeb, 0x9e, 0xb6, 0xb8, 0xb7, 0x63, 0xc7, 0xcc, 0xca, 0x8f, 0x4d, 0x96, 0x5c, 0xcb, 0x90, - 0x44, 0x79, 0xcd, 0xa5, 0x63, 0xd0, 0x94, 0xc8, 0xcd, 0x07, 0x81, 0xa4, 0xbd, 0x41, 0xe5, 0x47, 0x9b, 0xd3, 0xfe, - 0x48, 0x08, 0xb1, 0x5a, 0x6a, 0xe6, 0xe2, 0x4b, 0x8a, 0x05, 0x50, 0x70, 0x1f, 0xc0, 0xf9, 0x3b, 0x71, 0xfd, 0xbb, - 0xe8, 0xc0, 0xa1, 0x8f, 0x00, 0x06, 0xe0, 0xad, 0x54, 0x5b, 0x79, 0x13, 0x50, 0x5a, 0x01, 0x90, 0x6b, 0x53, 0x19, - 0xe0, 0x0d, 0xf9, 0x0b, 0x1e, 0xd9, 0x97, 0x29, 0x50, 0x8c, 0xe2, 0xc6, 0xbb, 0x4c, 0xe5, 0xe5, 0xdd, 0x31, 0xe7, - 0x82, 0xdd, 0xdc, 0x30, 0xaf, 0xda, 0x44, 0x99, 0xb4, 0x5d, 0x0c, 0x62, 0x9c, 0x12, 0xa4, 0x28, 0x01, 0xd2, 0xf7, - 0x0f, 0x66, 0x21, 0x7a, 0xfe, 0xee, 0xd1, 0x5d, 0x65, 0xae, 0xc3, 0x30, 0x9a, 0xec, 0x1d, 0x51, 0x0b, 0x72, 0xba, - 0x3a, 0x26, 0xdf, 0x27, 0x07, 0xe1, 0x5f, 0x12, 0xf5, 0x33, 0x55, 0x82, 0xa6, 0xfa, 0xa6, 0x8a, 0x38, 0xa8, 0xf1, - 0x09, 0x88, 0xda, 0x32, 0xa9, 0x09, 0x73, 0x25, 0xea, 0x93, 0xeb, 0x44, 0x60, 0x5a, 0xfd, 0xb3, 0x1f, 0x5f, 0xfd, - 0xc0, 0x60, 0x7b, 0xbf, 0x77, 0xf1, 0x75, 0xa6, 0x0f, 0xe7, 0xef, 0xd8, 0xbd, 0xfb, 0x3c, 0xb8, 0x71, 0x5c, 0x5d, - 0xd6, 0x23, 0x49, 0x23, 0x33, 0xc0, 0x7b, 0xca, 0xa0, 0x61, 0x2f, 0xca, 0xe6, 0xcc, 0x35, 0xbb, 0xcf, 0xba, 0xca, - 0x5d, 0x40, 0x3f, 0x22, 0x69, 0x35, 0xa1, 0xb8, 0x00, 0x6e, 0xe7, 0x31, 0xcd, 0x0a, 0xff, 0x83, 0x42, 0xcd, 0x04, - 0x7b, 0x19, 0x19, 0xa5, 0x2f, 0x23, 0x98, 0xaf, 0x16, 0x41, 0xb6, 0xac, 0x42, 0xbb, 0x8f, 0x1a, 0x6c, 0xd6, 0xae, - 0xcd, 0xb3, 0x7b, 0xcb, 0x01, 0x39, 0x13, 0x44, 0xe3, 0x45, 0xad, 0xe4, 0x59, 0x4f, 0xf5, 0xaa, 0xe7, 0x88, 0x9b, - 0xae, 0xdc, 0xab, 0x47, 0xa6, 0xf9, 0x78, 0x85, 0x77, 0x3f, 0xea, 0x22, 0xda, 0x95, 0xb5, 0x00, 0x56, 0x16, 0x43, - 0xf2, 0x3d, 0xeb, 0xef, 0x99, 0x74, 0x09, 0x16, 0x90, 0xfa, 0xc4, 0xeb, 0xda, 0x75, 0xd0, 0x91, 0x93, 0xba, 0xfd, - 0x28, 0x0a, 0x66, 0xa5, 0x45, 0x26, 0xe5, 0x89, 0xa4, 0x2b, 0xc9, 0x47, 0xae, 0x62, 0x66, 0x98, 0xe6, 0xd2, 0x19, - 0xf6, 0xa4, 0xbf, 0xaa, 0xda, 0xab, 0xed, 0x39, 0x04, 0xc0, 0x35, 0x4f, 0x21, 0x56, 0xef, 0x56, 0xd1, 0xc2, 0x9d, - 0xf1, 0x6e, 0xff, 0xa2, 0xb5, 0xe3, 0x61, 0xec, 0xd6, 0x30, 0x0e, 0x32, 0x67, 0x05, 0xf3, 0x9b, 0x1c, 0xd3, 0x70, - 0xcd, 0x68, 0xa3, 0x0b, 0x3e, 0xc5, 0xbe, 0x5c, 0xbd, 0x8f, 0xba, 0x87, 0x0a, 0x91, 0xde, 0x83, 0x67, 0x84, 0xaf, - 0x37, 0x7f, 0x6d, 0xd4, 0x6b, 0xdf, 0xd1, 0x58, 0xde, 0x58, 0x3a, 0x82, 0xc6, 0x3f, 0x26, 0x38, 0xa3, 0x30, 0xdf, - 0xc0, 0x9b, 0x26, 0x93, 0xb5, 0x09, 0xc7, 0x8e, 0xdc, 0x26, 0xc5, 0xa6, 0xa5, 0x2a, 0xdf, 0x25, 0xb0, 0x92, 0x5b, - 0xa6, 0xfd, 0xe2, 0x9e, 0x52, 0x8f, 0x0d, 0xc1, 0x42, 0xc5, 0x82, 0x00, 0x35, 0xe6, 0xaa, 0xbd, 0x99, 0x48, 0x06, - 0xa7, 0xb4, 0xfd, 0x6c, 0xfb, 0x0c, 0xb3, 0xb3, 0x26, 0x12, 0x82, 0xb3, 0x42, 0xcb, 0xa6, 0x9b, 0xb4, 0x91, 0x00, - 0x8d, 0x54, 0xa3, 0xd0, 0x64, 0x82, 0xc6, 0xce, 0x7b, 0x41, 0xee, 0x86, 0x0e, 0xa9, 0xeb, 0x0c, 0x4a, 0xf0, 0x85, - 0x23, 0x31, 0x4b, 0x77, 0x41, 0x69, 0x0d, 0xdd, 0x63, 0xa8, 0x5e, 0x6f, 0xbf, 0x0b, 0x9e, 0x91, 0xf1, 0xa6, 0x11, - 0x68, 0x8e, 0x41, 0x20, 0xdf, 0x04, 0x63, 0x02, 0x0e, 0xbc, 0xf3, 0xf2, 0xfb, 0x48, 0x44, 0xca, 0x0f, 0xb0, 0x01, - 0xbd, 0xcc, 0x37, 0x5e, 0x04, 0x2b, 0x52, 0xa5, 0x19, 0x16, 0x66, 0x8f, 0xc1, 0xbc, 0xed, 0x8e, 0x7e, 0x32, 0xfc, - 0xcc, 0x04, 0x2f, 0xf9, 0x93, 0xe7, 0xf4, 0xce, 0x10, 0x1e, 0x62, 0xf0, 0xc1, 0x58, 0xf5, 0xc0, 0xe3, 0x94, 0xc6, - 0x0d, 0x01, 0x2e, 0x9f, 0xfd, 0xc8, 0x7c, 0x10, 0xbb, 0x11, 0x80, 0x67, 0x17, 0x57, 0x19, 0x78, 0x9b, 0xa9, 0xab, - 0x93, 0x96, 0x4e, 0xee, 0x17, 0x62, 0xc4, 0x0c, 0x52, 0x31, 0x9f, 0xde, 0xc8, 0x28, 0x0d, 0xe2, 0xa2, 0x64, 0xc6, - 0x25, 0xc5, 0xae, 0x39, 0x6f, 0xe8, 0x96, 0x9f, 0x33, 0x45, 0xed, 0x9d, 0x1d, 0xeb, 0x78, 0xff, 0x8f, 0xf3, 0x27, - 0x5d, 0x30, 0xba, 0xbc, 0xb5, 0xae, 0x66, 0xdb, 0xf3, 0x4d, 0x4d, 0xa9, 0x81, 0xe1, 0x37, 0x26, 0xfc, 0xd1, 0xc7, - 0x4b, 0x4d, 0x41, 0x0d, 0x8d, 0x5d, 0x64, 0x53, 0x8a, 0xac, 0xf0, 0xc6, 0x31, 0x65, 0xbe, 0x04, 0x52, 0x2c, 0xc6, - 0x4f, 0x3e, 0x37, 0x1a, 0x5c, 0x33, 0x52, 0x1c, 0x0e, 0x09, 0xea, 0x45, 0x91, 0xb7, 0x9f, 0x3a, 0xf9, 0x1c, 0x57, - 0x6f, 0xe6, 0x37, 0x43, 0x60, 0xa6, 0xdb, 0x56, 0x5a, 0xac, 0x9b, 0xb6, 0xe2, 0xab, 0xb5, 0x4a, 0xe3, 0x29, 0x5a, - 0xe3, 0xbb, 0x1a, 0x87, 0xe9, 0x95, 0xea, 0xab, 0xe1, 0xd7, 0xbd, 0x8d, 0xcd, 0x8c, 0x66, 0x2e, 0x74, 0x90, 0x38, - 0x24, 0xbd, 0x54, 0x4d, 0xab, 0xa8, 0xc6, 0x9e, 0x1e, 0xab, 0x1a, 0x94, 0x88, 0x77, 0xe8, 0x24, 0xd6, 0x30, 0x5b, - 0x8f, 0x72, 0xfa, 0x61, 0xb5, 0x85, 0x5a, 0xb1, 0x33, 0x31, 0xa9, 0xa9, 0x37, 0x96, 0xe5, 0xd5, 0x56, 0xd5, 0xe7, - 0x74, 0xa0, 0xc3, 0x9f, 0xc3, 0x1d, 0x84, 0xef, 0x6e, 0x05, 0x9a, 0x10, 0x64, 0x2e, 0xb6, 0xf2, 0x75, 0x88, 0xef, - 0xd2, 0x1e, 0x8f, 0x25, 0x56, 0x2b, 0x12, 0x8d, 0x6f, 0xaa, 0x2a, 0x0c, 0xc8, 0x65, 0x46, 0x15, 0x4b, 0x7b, 0x65, - 0x54, 0xc8, 0x8a, 0xec, 0x8d, 0x04, 0x69, 0x55, 0xf0, 0x42, 0x90, 0xd2, 0x2c, 0x9a, 0x98, 0xcc, 0x5f, 0x0d, 0xef, - 0x92, 0x92, 0xcc, 0x26, 0xf8, 0xd3, 0x26, 0xce, 0x66, 0x14, 0x70, 0xb7, 0x52, 0x37, 0xb8, 0xd8, 0x9a, 0x71, 0x55, - 0xae, 0x20, 0xb7, 0x05, 0x07, 0x21, 0xab, 0xa2, 0x68, 0x61, 0x9f, 0xdf, 0xf9, 0xa7, 0x48, 0x53, 0x00, 0x40, 0xe4, - 0x35, 0xa1, 0x29, 0xbb, 0x69, 0x41, 0x66, 0xe9, 0x60, 0x19, 0xc0, 0x07, 0x2c, 0x85, 0xf2, 0xd0, 0x79, 0x1e, 0xd7, - 0xdd, 0xcb, 0x4c, 0x2d, 0x2a, 0x2d, 0x1b, 0x74, 0x4f, 0x07, 0x87, 0x5c, 0xaf, 0x74, 0xfa, 0xef, 0x8f, 0xd4, 0x56, - 0x5e, 0xa0, 0x0e, 0x2a, 0x7c, 0xf7, 0x51, 0xb5, 0x10, 0xe3, 0x50, 0xab, 0xff, 0xad, 0x28, 0xd1, 0x39, 0x05, 0x4f, - 0xa2, 0xd7, 0x3f, 0x56, 0xac, 0xd3, 0x2b, 0x26, 0x91, 0xf8, 0x72, 0x22, 0xa8, 0xdb, 0x66, 0x57, 0x5d, 0x72, 0xda, - 0x5c, 0x65, 0xf6, 0x9f, 0x0e, 0x78, 0xf6, 0xad, 0x77, 0x7e, 0xa7, 0x17, 0x77, 0x90, 0x44, 0xa1, 0xd0, 0xf3, 0x21, - 0x3e, 0xa0, 0xa3, 0xb2, 0xfa, 0x13, 0x91, 0x14, 0xab, 0x96, 0xcb, 0xd0, 0x80, 0x22, 0xc5, 0x4d, 0x19, 0xd5, 0x78, - 0xd0, 0xeb, 0x19, 0xbb, 0x04, 0x69, 0x74, 0xb3, 0x57, 0x2a, 0xc1, 0x49, 0x2b, 0xad, 0x3e, 0x2f, 0x41, 0x90, 0x6d, - 0xbc, 0x93, 0x5c, 0xa5, 0x58, 0x61, 0xf6, 0x58, 0x96, 0x95, 0x51, 0xd7, 0x50, 0x8c, 0xce, 0xb2, 0x8a, 0x5a, 0xe7, - 0xda, 0x6a, 0xa7, 0x08, 0xc5, 0x3a, 0x16, 0x00, 0x9b, 0x16, 0x4e, 0x07, 0x69, 0x61, 0x0b, 0x7a, 0x3a, 0xa9, 0xc6, - 0x25, 0xcd, 0x8e, 0xb1, 0xc8, 0xbb, 0x85, 0x41, 0xd0, 0x44, 0x5f, 0x77, 0x70, 0x53, 0x1e, 0x2b, 0x8a, 0x3a, 0xb8, - 0xde, 0xb1, 0x0c, 0xaf, 0x0e, 0xcd, 0x78, 0x81, 0xae, 0xa4, 0xec, 0x08, 0x95, 0x2b, 0x61, 0x27, 0x6d, 0x4a, 0x07, - 0x6d, 0x15, 0x7e, 0x68, 0x9e, 0x94, 0x8e, 0x05, 0xaf, 0x58, 0xa1, 0xc4, 0x35, 0xdd, 0x79, 0x0f, 0xeb, 0xa5, 0x9b, - 0x18, 0x23, 0xe4, 0x2b, 0xe2, 0xaf, 0x91, 0x22, 0xcc, 0x0d, 0x64, 0x0d, 0x2c, 0x64, 0x34, 0xc5, 0x24, 0x4c, 0x90, - 0xb1, 0xc7, 0x98, 0x78, 0xd1, 0x2d, 0x2e, 0xfd, 0x19, 0xb4, 0x41, 0x9b, 0x4d, 0x2b, 0xe9, 0x3e, 0x70, 0x85, 0xf6, - 0x7b, 0x3c, 0xe9, 0x90, 0x8f, 0x2d, 0x44, 0xcf, 0x14, 0x5c, 0xbe, 0x2e, 0xa1, 0x51, 0x7f, 0x00, 0x65, 0xed, 0x58, - 0x8a, 0xcd, 0x9a, 0x84, 0x1d, 0x98, 0x4e, 0x94, 0xf6, 0x7d, 0xf0, 0xa9, 0xc7, 0x5f, 0xbe, 0xde, 0x23, 0xf8, 0x0c, - 0x65, 0x4f, 0x14, 0x9b, 0x29, 0x86, 0x8a, 0xa6, 0xa6, 0xa0, 0x99, 0x0f, 0xce, 0xc2, 0x6d, 0xf1, 0x7a, 0x42, 0x2f, - 0x57, 0xbb, 0x75, 0x4f, 0xe5, 0xe3, 0x8a, 0x34, 0x40, 0xab, 0xcf, 0x1a, 0x95, 0x0f, 0xe6, 0xc9, 0x3d, 0xaf, 0x74, - 0xcf, 0x0f, 0xe8, 0xa1, 0xd1, 0xee, 0xe2, 0x4d, 0xd7, 0x32, 0x3e, 0xb4, 0x9b, 0xac, 0xcf, 0x60, 0x11, 0x7a, 0xa8, - 0xa1, 0x14, 0xcd, 0xe1, 0x26, 0xab, 0xd9, 0xf0, 0xee, 0x8d, 0x66, 0x6d, 0x29, 0x25, 0x7f, 0x6f, 0xe7, 0xc5, 0x36, - 0x9b, 0x2a, 0x9c, 0xde, 0x0f, 0xa4, 0x77, 0x09, 0x14, 0xcd, 0x91, 0xef, 0x4e, 0xa7, 0xa9, 0x7c, 0xd0, 0x4f, 0x80, - 0xa1, 0x82, 0xef, 0x1e, 0x69, 0x74, 0x67, 0x4d, 0x71, 0xbe, 0x82, 0x4b, 0x0f, 0xa3, 0xc6, 0xb6, 0x4b, 0x4f, 0x04, - 0xe1, 0xd4, 0xe2, 0x1e, 0xe9, 0x25, 0x45, 0x4b, 0x1d, 0x29, 0xe1, 0x9f, 0x22, 0x9c, 0x53, 0x8d, 0x1d, 0xed, 0x6c, - 0x1a, 0x6f, 0xa8, 0x20, 0x3d, 0x6a, 0x72, 0x4d, 0xfb, 0x12, 0x0a, 0xe0, 0xbf, 0x9d, 0xba, 0x12, 0xe1, 0x32, 0x21, - 0x37, 0xab, 0x8a, 0x4a, 0x83, 0x32, 0x00, 0x28, 0xbf, 0x5d, 0xcb, 0xe8, 0x1a, 0x3f, 0x5a, 0xa8, 0xcb, 0x12, 0x73, - 0xa0, 0x83, 0x16, 0x67, 0x77, 0x03, 0x2d, 0x92, 0x6d, 0x73, 0xea, 0xae, 0x51, 0xb5, 0xc5, 0x93, 0xc0, 0x4b, 0x44, - 0x63, 0x39, 0xeb, 0xe7, 0xf0, 0x6d, 0xda, 0x5e, 0x0f, 0xce, 0x3a, 0x40, 0xb7, 0xb0, 0xa0, 0xda, 0x9a, 0xb5, 0xfc, - 0x5e, 0x81, 0x0f, 0xf8, 0x51, 0x6c, 0xec, 0x3c, 0x76, 0x42, 0xcd, 0xc9, 0x0e, 0xbd, 0xb9, 0x49, 0x39, 0x27, 0xca, - 0x9c, 0x4e, 0x62, 0x1c, 0x85, 0x26, 0xea, 0x8c, 0x30, 0xf9, 0xd4, 0x6b, 0x32, 0x03, 0x1e, 0x7d, 0xe3, 0x22, 0x61, - 0x1f, 0x9c, 0x53, 0xc9, 0x96, 0xb5, 0x66, 0x93, 0x9b, 0x9f, 0x49, 0xb1, 0xc9, 0x98, 0x60, 0xbd, 0xa0, 0xf7, 0x37, - 0x44, 0x42, 0x18, 0x37, 0x84, 0x62, 0x68, 0x6a, 0x52, 0xe3, 0x69, 0x73, 0xa5, 0x64, 0x46, 0x97, 0x54, 0xbf, 0xac, - 0x2e, 0x28, 0x24, 0x5a, 0x51, 0xf0, 0xcb, 0x16, 0x8e, 0xbd, 0x46, 0x10, 0x7b, 0x06, 0xa0, 0x0f, 0x1d, 0xa0, 0x89, - 0x91, 0xdc, 0x6a, 0x6a, 0x4f, 0xb6, 0x04, 0x5e, 0x79, 0x19, 0xe2, 0x0d, 0xfb, 0x4d, 0x50, 0x09, 0x3c, 0xc7, 0xbe, - 0x59, 0x0e, 0x79, 0x0e, 0x97, 0xd5, 0x5d, 0x7d, 0x8a, 0xe8, 0xdc, 0xf9, 0xc2, 0xc3, 0x14, 0xbd, 0x43, 0xb5, 0x8f, - 0xd0, 0xd5, 0x2b, 0x79, 0x15, 0x73, 0x90, 0x83, 0x45, 0xdd, 0x98, 0x6c, 0x62, 0x57, 0xa7, 0x9e, 0x6b, 0x40, 0xb7, - 0xc7, 0x6e, 0x06, 0x62, 0x0f, 0x23, 0x26, 0xeb, 0x78, 0x4a, 0x6f, 0x11, 0x31, 0xda, 0x62, 0x22, 0xa9, 0x99, 0x34, - 0x6b, 0x52, 0x03, 0x39, 0x2d, 0xc2, 0x1c, 0xd4, 0x4f, 0x74, 0x8e, 0x3d, 0x12, 0xba, 0xb3, 0x6c, 0xbb, 0x46, 0x25, - 0x93, 0xdc, 0x61, 0xae, 0x50, 0x49, 0x08, 0xa9, 0x28, 0xbb, 0x92, 0x29, 0x30, 0xa5, 0x31, 0xe1, 0x1a, 0x57, 0x83, - 0x22, 0x43, 0x97, 0x0a, 0x6a, 0x6f, 0x9d, 0xa4, 0xd4, 0x39, 0x67, 0x0e, 0xf0, 0x29, 0x94, 0x3f, 0xab, 0x9a, 0x87, - 0x4d, 0xad, 0x40, 0xc5, 0xbd, 0x7a, 0xb9, 0x58, 0xf0, 0x66, 0xfe, 0x04, 0x85, 0x41, 0xa1, 0xaf, 0xa8, 0x29, 0xcf, - 0xe5, 0xa1, 0xac, 0x44, 0xdf, 0x8c, 0xbf, 0xa7, 0xf2, 0x8a, 0xc0, 0x65, 0xbe, 0x42, 0x24, 0xa6, 0xdf, 0x78, 0xa8, - 0x3f, 0x2d, 0x0b, 0x9b, 0x2a, 0x62, 0xfa, 0xf7, 0x93, 0xbf, 0x36, 0x90, 0xe0, 0x87, 0x9e, 0xd8, 0x2f, 0x5a, 0x08, - 0x3a, 0x16, 0x19, 0x54, 0x98, 0x23, 0x72, 0xa2, 0x00, 0x4e, 0xb2, 0x47, 0xd7, 0xcb, 0x4b, 0x6a, 0xe8, 0x08, 0x6e, - 0x81, 0x9c, 0xb0, 0xa2, 0x6a, 0x24, 0xcf, 0xfe, 0xde, 0x76, 0x4b, 0xaa, 0x53, 0x0e, 0x03, 0x36, 0x7f, 0xed, 0x69, - 0x19, 0xba, 0x7b, 0x1b, 0x04, 0xb0, 0x05, 0xbd, 0x1e, 0x87, 0x1f, 0x01, 0x34, 0x82, 0xc9, 0x0c, 0x19, 0xcb, 0xa7, - 0x86, 0xea, 0x55, 0x5f, 0x9e, 0x1c, 0x85, 0xb8, 0x75, 0x8a, 0x8c, 0x28, 0x5b, 0x41, 0x2e, 0x63, 0xcb, 0x6e, 0xbf, - 0x95, 0xfe, 0xc0, 0x0a, 0xaa, 0x6b, 0x15, 0x82, 0x1c, 0x8c, 0x49, 0x78, 0x23, 0x51, 0x61, 0xcd, 0xdf, 0x74, 0x06, - 0x78, 0xcb, 0x53, 0x38, 0x84, 0x7b, 0xbb, 0x03, 0x6a, 0x7d, 0x0d, 0x41, 0xa1, 0xa9, 0x1c, 0x38, 0x9b, 0xa2, 0x35, - 0x47, 0x1c, 0x9c, 0xcf, 0x61, 0xc3, 0xf2, 0x1e, 0x77, 0x33, 0xc4, 0x32, 0x88, 0x14, 0xd1, 0xe1, 0x90, 0xa5, 0xc5, - 0xa2, 0xc6, 0x16, 0xab, 0x90, 0xf6, 0x29, 0xce, 0x08, 0x37, 0x31, 0xa5, 0x38, 0xd1, 0x87, 0x97, 0x60, 0x78, 0x06, - 0xce, 0x30, 0x6b, 0xd7, 0x1e, 0x88, 0xdb, 0x1b, 0x3a, 0x5f, 0x7e, 0xc9, 0x8e, 0x32, 0x7f, 0xae, 0x3a, 0x03, 0x96, - 0xcf, 0x7e, 0xde, 0x13, 0xa0, 0xa7, 0x9f, 0x38, 0x6c, 0xab, 0x9f, 0x4a, 0xcf, 0x46, 0x6a, 0xac, 0xee, 0x99, 0x4e, - 0xce, 0xdc, 0xd6, 0x1b, 0x1e, 0xe8, 0xcc, 0x82, 0x84, 0x0f, 0x5e, 0x63, 0x1f, 0xb4, 0x91, 0x4a, 0xd2, 0x57, 0x3c, - 0x51, 0xde, 0x5b, 0xe0, 0x57, 0x0f, 0x64, 0xe5, 0x91, 0x0b, 0x12, 0xf4, 0x12, 0xda, 0xf3, 0x79, 0x74, 0xc6, 0xc1, - 0xb0, 0x37, 0xca, 0x27, 0xc6, 0x4b, 0x4c, 0xfa, 0xe6, 0xdb, 0x61, 0x88, 0x48, 0xdf, 0x47, 0x54, 0xe0, 0x84, 0x2c, - 0xa9, 0xf2, 0xf0, 0x46, 0xc2, 0x21, 0x9e, 0x4a, 0x74, 0xa6, 0x4e, 0xcd, 0x59, 0xb5, 0x58, 0x6c, 0x59, 0x79, 0xf5, - 0xda, 0x51, 0xe4, 0x77, 0x6c, 0xd5, 0x92, 0x76, 0xb2, 0xaf, 0x94, 0xa1, 0x55, 0x89, 0x33, 0x18, 0x19, 0x5d, 0xb0, - 0xd5, 0x8b, 0x24, 0x1f, 0xb2, 0x26, 0x2a, 0x1a, 0xd6, 0x95, 0xc9, 0xcc, 0x28, 0xb9, 0x95, 0xf5, 0x45, 0xda, 0xb1, - 0x5d, 0x78, 0xad, 0x42, 0x25, 0xe9, 0xa0, 0x9e, 0x93, 0xe4, 0xfc, 0x40, 0xda, 0x3a, 0xca, 0xdf, 0xb6, 0xaa, 0x93, - 0xeb, 0xa1, 0xa7, 0xec, 0x2a, 0x1d, 0x08, 0xf3, 0x55, 0xb2, 0x06, 0x81, 0x81, 0x7c, 0x77, 0x60, 0x3b, 0x06, 0x9d, - 0xbe, 0xdd, 0x1e, 0x99, 0x74, 0x3c, 0x05, 0x3a, 0x65, 0x14, 0x30, 0x4d, 0x14, 0x46, 0x57, 0x6b, 0xcc, 0xfe, 0xee, - 0x78, 0xe9, 0xbb, 0x82, 0x03, 0x55, 0xdc, 0xe9, 0x41, 0xf8, 0xe1, 0xde, 0xd5, 0xc6, 0xe0, 0x87, 0x53, 0x47, 0x4f, - 0xcc, 0xcc, 0x52, 0xcb, 0xbf, 0xaf, 0x77, 0x87, 0xdf, 0xc7, 0x29, 0xc4, 0xf0, 0x81, 0x5e, 0xc7, 0xee, 0x8b, 0xfa, - 0x57, 0xf1, 0x32, 0x97, 0xf8, 0xc2, 0x0f, 0x5d, 0xd1, 0xec, 0xa6, 0xde, 0x95, 0x35, 0xcc, 0xa1, 0x6f, 0xc5, 0xda, - 0x48, 0xe5, 0x16, 0x58, 0xf7, 0x66, 0xb9, 0x13, 0x07, 0x0c, 0x38, 0xd7, 0x73, 0xc4, 0xc4, 0x67, 0xa6, 0x5a, 0xcf, - 0x24, 0x9d, 0x9a, 0xe9, 0x48, 0x86, 0x51, 0x0b, 0x58, 0x89, 0x89, 0x50, 0xdc, 0xe1, 0x01, 0x51, 0xd9, 0x21, 0x8f, - 0x7e, 0x2c, 0xf4, 0x6d, 0x4a, 0x28, 0x1b, 0xbe, 0x82, 0x97, 0x27, 0x97, 0x3f, 0x18, 0xd9, 0x78, 0xdc, 0xfc, 0xb1, - 0xd2, 0x8b, 0x97, 0x33, 0x4f, 0xde, 0xa0, 0x88, 0xd5, 0x79, 0x82, 0xe5, 0x01, 0x12, 0x9b, 0x33, 0x37, 0xd0, 0x49, - 0x30, 0xf5, 0x46, 0x37, 0xbf, 0x14, 0xc1, 0xad, 0xb6, 0x0d, 0x4e, 0xf9, 0x99, 0xe9, 0x1e, 0x85, 0xe2, 0x27, 0x3f, - 0xc3, 0x92, 0x59, 0x36, 0x4e, 0xc0, 0xc9, 0xb2, 0x2b, 0x0f, 0x3e, 0x6d, 0x7d, 0xf1, 0x61, 0x7d, 0x61, 0xfd, 0x79, - 0x8a, 0xb1, 0xfe, 0xfc, 0x92, 0xde, 0xdc, 0x3b, 0xac, 0x83, 0xd8, 0x7a, 0x74, 0x83, 0xfe, 0x29, 0x35, 0xec, 0xfc, - 0xb1, 0x33, 0x84, 0x0a, 0x11, 0x6a, 0xbc, 0x41, 0x58, 0x70, 0xee, 0x8e, 0x94, 0xac, 0x9a, 0x31, 0x4e, 0x2b, 0x49, - 0x6b, 0xc0, 0xbe, 0xd2, 0xa4, 0xb4, 0xca, 0xed, 0x6a, 0x45, 0xcc, 0x2e, 0x4d, 0xed, 0x50, 0x60, 0xd1, 0x77, 0x52, - 0x92, 0x24, 0xe7, 0xa5, 0x67, 0xf7, 0x48, 0x6d, 0x47, 0x40, 0xe5, 0xea, 0xbe, 0x40, 0x30, 0x6e, 0x6f, 0x77, 0x07, - 0xaa, 0xa6, 0xbe, 0x83, 0x41, 0x3d, 0xf2, 0x52, 0xff, 0x1f, 0x6c, 0x44, 0x6f, 0x5e, 0xfa, 0x0e, 0x50, 0x36, 0xa2, - 0xd9, 0xbb, 0x20, 0xd7, 0x01, 0xf2, 0x8e, 0x19, 0x4e, 0x55, 0xe5, 0x30, 0xca, 0xe8, 0xaf, 0x3e, 0x4b, 0xe1, 0xd2, - 0x7b, 0x87, 0x79, 0xb2, 0x49, 0x07, 0x9e, 0x05, 0x5b, 0xba, 0xf7, 0x12, 0x49, 0x60, 0xd9, 0x21, 0x21, 0x57, 0x69, - 0xdf, 0xa0, 0xcb, 0xaa, 0x53, 0x6d, 0x52, 0xf4, 0xd3, 0x8e, 0x1b, 0x3c, 0x62, 0x5a, 0x70, 0x8b, 0x74, 0x84, 0x5a, - 0x25, 0x84, 0x31, 0xe3, 0x29, 0x92, 0xd5, 0x80, 0xf0, 0x5a, 0x4d, 0x3c, 0x25, 0x8d, 0x28, 0x0f, 0x0c, 0x13, 0xdb, - 0x7b, 0xb1, 0xf3, 0x63, 0x17, 0xc4, 0x98, 0xdd, 0xba, 0xfb, 0x55, 0xf1, 0xa1, 0x3f, 0xc3, 0xb3, 0xb6, 0x3b, 0x99, - 0x4b, 0x48, 0x90, 0x38, 0xf4, 0x2f, 0x54, 0xfc, 0x5a, 0x23, 0x3c, 0x01, 0x0d, 0x56, 0x09, 0x86, 0xa9, 0x05, 0xbe, - 0x9d, 0xde, 0x67, 0xbd, 0x5e, 0x3d, 0x61, 0xe2, 0xe8, 0x16, 0xe0, 0xda, 0x23, 0x15, 0x97, 0xd3, 0x87, 0x03, 0xbb, - 0xaf, 0x96, 0x1a, 0xe8, 0xaa, 0x64, 0xb2, 0xf8, 0x4b, 0x6f, 0x9c, 0xff, 0x4d, 0x43, 0xd1, 0x81, 0x25, 0xfa, 0x39, - 0xbd, 0x16, 0x3b, 0xf7, 0xc9, 0x27, 0x12, 0x30, 0x1a, 0xc3, 0x93, 0x9c, 0x1e, 0x58, 0x15, 0x6d, 0x26, 0xdc, 0x9f, - 0x17, 0xa7, 0x09, 0x34, 0x76, 0xa0, 0x17, 0x37, 0x99, 0x6f, 0xe3, 0xdd, 0x95, 0xad, 0xee, 0xfd, 0x61, 0x59, 0xd5, - 0xbc, 0x4d, 0xea, 0xa8, 0xbb, 0x48, 0x10, 0xe2, 0x52, 0x47, 0xa8, 0x49, 0x57, 0x6c, 0xad, 0x39, 0x73, 0x71, 0x9a, - 0xc7, 0x56, 0x7e, 0x96, 0x6d, 0x79, 0x20, 0xeb, 0x85, 0x58, 0xd5, 0x6c, 0xd4, 0x04, 0x29, 0x43, 0x5d, 0x88, 0xee, - 0x32, 0xa2, 0x82, 0x16, 0xab, 0x5f, 0xd7, 0xb1, 0x32, 0xdd, 0xa7, 0xe9, 0x68, 0x42, 0xe0, 0xf7, 0x71, 0x00, 0x46, - 0xb4, 0xfe, 0x15, 0x83, 0xa6, 0xd6, 0x28, 0x39, 0x8d, 0x4e, 0x84, 0x88, 0x43, 0xdb, 0x34, 0x06, 0xbc, 0x74, 0xf9, - 0x99, 0x1c, 0x62, 0xaa, 0x05, 0xc6, 0x1b, 0x18, 0xaf, 0x87, 0xb6, 0x98, 0x72, 0xe7, 0xe9, 0x55, 0x65, 0x94, 0xf1, - 0xc9, 0xfd, 0xcc, 0xeb, 0x9e, 0x7d, 0x40, 0xc9, 0x00, 0x84, 0x0a, 0xaf, 0x55, 0x18, 0x32, 0x58, 0x25, 0xf8, 0xf3, - 0x16, 0x63, 0xf0, 0x73, 0xdb, 0x9c, 0x87, 0xf9, 0x10, 0x2b, 0xec, 0x30, 0x0b, 0xf4, 0xe9, 0x79, 0x01, 0xd3, 0x03, - 0xd9, 0xd9, 0xb3, 0xd2, 0x96, 0x51, 0x5a, 0x22, 0x60, 0x57, 0xb8, 0x4e, 0x01, 0x2c, 0x2a, 0x81, 0xc7, 0x41, 0x94, - 0x40, 0x1b, 0xfb, 0x81, 0x68, 0xca, 0x12, 0x52, 0x00, 0xab, 0xd5, 0x9f, 0x01, 0xec, 0xa0, 0x24, 0xdb, 0xce, 0xae, - 0x09, 0x91, 0xd9, 0x7a, 0xa4, 0xbd, 0xea, 0x30, 0x2d, 0xfa, 0xe7, 0xc4, 0x59, 0x9a, 0x18, 0xfb, 0x28, 0x89, 0x8f, - 0x1a, 0x37, 0x30, 0x25, 0x12, 0x74, 0x23, 0x0f, 0xc0, 0xcd, 0x2d, 0xf7, 0xe4, 0xbc, 0xd3, 0x9b, 0x03, 0x18, 0xe4, - 0xf4, 0x4c, 0x7f, 0xed, 0xc0, 0x82, 0xfb, 0xcf, 0x25, 0xea, 0xe8, 0x69, 0x09, 0xc9, 0x04, 0x2e, 0x7e, 0x34, 0xee, - 0x99, 0x06, 0x02, 0x62, 0xcf, 0x85, 0x51, 0x0b, 0x18, 0xfc, 0xd4, 0x11, 0xaf, 0x69, 0xcf, 0x92, 0x0e, 0xa3, 0xd2, - 0xf7, 0x44, 0x3a, 0xd5, 0xb6, 0x47, 0xa6, 0x7b, 0x9d, 0xc6, 0xd4, 0x87, 0xc8, 0x07, 0x53, 0xb8, 0x52, 0x04, 0xc0, - 0xf9, 0x1f, 0x0f, 0x58, 0xee, 0xdf, 0xc4, 0x8a, 0xae, 0x90, 0xe7, 0xda, 0x98, 0x72, 0x58, 0x25, 0x03, 0x5b, 0xc6, - 0x65, 0x47, 0x4c, 0x98, 0x8d, 0x99, 0x56, 0x46, 0x6f, 0xe6, 0xc8, 0x19, 0xa4, 0xb2, 0x31, 0x5c, 0x44, 0x39, 0xb5, - 0x25, 0x20, 0x21, 0xaa, 0x02, 0x18, 0x3c, 0xbd, 0x85, 0x8d, 0x95, 0xd4, 0xa6, 0x74, 0x26, 0x18, 0xaa, 0x21, 0xca, - 0x57, 0x39, 0xb1, 0x9d, 0xca, 0xd1, 0x7c, 0xa0, 0xc9, 0xea, 0x6f, 0x9f, 0x16, 0xee, 0x63, 0x87, 0xe7, 0xbd, 0x4e, - 0x9e, 0x99, 0x14, 0xe8, 0xd3, 0x96, 0xb9, 0x73, 0xe9, 0xc4, 0x65, 0xf1, 0xd2, 0x74, 0xb1, 0x5f, 0x9c, 0xf5, 0x4d, - 0x0a, 0xb2, 0x6c, 0xed, 0xd7, 0x83, 0xb9, 0xc3, 0xb6, 0x98, 0x3a, 0x8f, 0x45, 0x80, 0xcb, 0x12, 0x51, 0xba, 0x96, - 0x09, 0x81, 0x4d, 0xcb, 0xbc, 0x30, 0x9b, 0xd1, 0xe6, 0x0a, 0x2f, 0xcf, 0x47, 0x35, 0xad, 0xc9, 0x15, 0x7a, 0xdd, - 0xa7, 0xd3, 0x77, 0x42, 0xfe, 0x79, 0x39, 0xea, 0x9e, 0x59, 0xca, 0x40, 0x54, 0xed, 0x94, 0x0e, 0x3c, 0xe9, 0xc0, - 0xce, 0xb6, 0xa6, 0x6f, 0xdf, 0x2f, 0xfe, 0xd1, 0x3e, 0x99, 0x3a, 0xb7, 0xa1, 0xb5, 0xe8, 0xf8, 0xfd, 0x1e, 0x51, - 0xbb, 0x64, 0x85, 0x23, 0x04, 0x2a, 0xcf, 0x18, 0xd8, 0xa4, 0xde, 0xcc, 0x59, 0xdc, 0xf8, 0x48, 0xb5, 0xe8, 0x16, - 0x0e, 0xf0, 0x58, 0xdf, 0xfd, 0xea, 0x4f, 0xaa, 0x6e, 0xcf, 0xff, 0xda, 0x9e, 0x84, 0x76, 0x93, 0x7f, 0x6d, 0x6c, - 0xfd, 0xc7, 0xce, 0xc8, 0x72, 0x05, 0xd1, 0xf3, 0xda, 0x7a, 0xb5, 0x24, 0x1c, 0xbc, 0xc3, 0xdc, 0x3d, 0x01, 0xdf, - 0x8e, 0xbf, 0x35, 0xcd, 0x80, 0xa4, 0x65, 0x16, 0xad, 0x8d, 0x5c, 0xe3, 0x25, 0x0c, 0x28, 0x43, 0xd9, 0x15, 0xce, - 0x54, 0x1b, 0x43, 0xf3, 0xeb, 0x1d, 0xb7, 0x6f, 0x1b, 0x6c, 0xdc, 0xa2, 0x5b, 0x45, 0x37, 0x71, 0x61, 0x75, 0x78, - 0xa6, 0xb8, 0xca, 0xe6, 0x54, 0xb9, 0xca, 0xf8, 0xbb, 0x60, 0xa8, 0x0e, 0x03, 0x6e, 0x33, 0xec, 0xc2, 0x75, 0xe7, - 0xa1, 0x0b, 0x15, 0x45, 0x30, 0x2c, 0x48, 0xa5, 0xe5, 0x04, 0x8a, 0x32, 0xb6, 0xc5, 0x86, 0xa2, 0x04, 0xff, 0xfa, - 0xf7, 0x8c, 0x97, 0x51, 0xc0, 0x37, 0x36, 0xb4, 0xc8, 0x6c, 0x85, 0xa6, 0x29, 0xe1, 0x67, 0x98, 0x92, 0xe3, 0xca, - 0x27, 0x8d, 0xdb, 0xe1, 0x7f, 0x15, 0x4d, 0x04, 0x0a, 0xa8, 0x13, 0x0b, 0x09, 0x99, 0x69, 0x33, 0x45, 0xaf, 0x30, - 0x84, 0xae, 0x48, 0xca, 0x07, 0x97, 0x39, 0xf8, 0xae, 0xcd, 0x3d, 0x77, 0x2d, 0x6f, 0x1e, 0x04, 0x5b, 0x92, 0x4d, - 0x90, 0x96, 0x14, 0x32, 0xc9, 0xc2, 0xda, 0x91, 0x81, 0xeb, 0x6b, 0x7b, 0xa1, 0xa0, 0x24, 0x59, 0x10, 0x89, 0xe7, - 0x6b, 0x6d, 0x91, 0x3a, 0x16, 0x7f, 0x61, 0xba, 0x9f, 0xe2, 0xd3, 0x5e, 0xf8, 0x81, 0xfa, 0x3a, 0xdc, 0x75, 0x5f, - 0x45, 0xb3, 0x21, 0x6e, 0xd5, 0x8f, 0x19, 0x15, 0xd7, 0x23, 0xa5, 0xfd, 0x58, 0xfe, 0xf9, 0xfb, 0x0d, 0x8b, 0xc9, - 0x00, 0xc7, 0xc3, 0x9b, 0x9b, 0xa9, 0xc3, 0x8c, 0xbd, 0x86, 0x54, 0x6d, 0xac, 0xbe, 0x01, 0xb9, 0x45, 0x0e, 0xd5, - 0xae, 0x89, 0xa2, 0x0e, 0xb8, 0x99, 0x08, 0x0e, 0xc4, 0x7d, 0x64, 0xce, 0xa8, 0xd7, 0x9e, 0x54, 0x73, 0xba, 0x94, - 0x69, 0xd1, 0x52, 0xcd, 0x18, 0x66, 0xca, 0x74, 0x54, 0x31, 0xa0, 0xae, 0xdc, 0xd9, 0x95, 0xe7, 0x7b, 0x7e, 0x2a, - 0xcb, 0x8b, 0x0d, 0x9b, 0xa1, 0x4b, 0x2d, 0xcc, 0x51, 0xbe, 0xea, 0xf3, 0xb8, 0xbf, 0xf1, 0x8a, 0xf7, 0x7a, 0x87, - 0xa1, 0x43, 0x2d, 0x3d, 0x66, 0x6f, 0xd6, 0xfa, 0x7a, 0x3e, 0xe3, 0x20, 0x2d, 0x6a, 0xa7, 0x82, 0x71, 0xce, 0xa2, - 0x80, 0x05, 0x78, 0x85, 0xdd, 0x11, 0x8c, 0x8f, 0x67, 0xf1, 0x88, 0x7e, 0x64, 0xec, 0xe6, 0x6d, 0xf3, 0x1a, 0x10, - 0xa4, 0xca, 0x8e, 0x7b, 0x4b, 0x17, 0x2a, 0x16, 0xd1, 0xd2, 0x3b, 0xd3, 0x29, 0x94, 0x8f, 0x15, 0x00, 0xa4, 0xee, - 0xf4, 0x66, 0x30, 0x1e, 0xca, 0xcd, 0x01, 0xc2, 0x8d, 0x9c, 0x19, 0x37, 0x26, 0x0a, 0x47, 0x37, 0x06, 0x84, 0x08, - 0x71, 0x35, 0xf0, 0x95, 0x97, 0xb4, 0x4f, 0x54, 0x2c, 0x0d, 0xf1, 0xbd, 0xf2, 0xe0, 0x3e, 0xdc, 0xda, 0x6f, 0x2f, - 0x4e, 0x55, 0xc9, 0xc1, 0x22, 0x14, 0x1b, 0xc5, 0x7b, 0xe3, 0x77, 0x2f, 0xec, 0x7e, 0x62, 0x31, 0xd7, 0x12, 0x01, - 0xe5, 0x96, 0xef, 0x97, 0x56, 0x4d, 0x9c, 0x3f, 0xfd, 0x87, 0x0f, 0xe5, 0x12, 0x72, 0xe4, 0xab, 0x58, 0x76, 0x40, - 0x66, 0xbe, 0xa2, 0x9f, 0x45, 0x59, 0x4d, 0xe1, 0x53, 0xde, 0xc2, 0xdd, 0x75, 0xc7, 0xb5, 0x0e, 0x00, 0x59, 0x38, - 0x44, 0xb3, 0xd1, 0xd3, 0x2e, 0x89, 0xd0, 0x36, 0xda, 0xf8, 0x96, 0x47, 0x9a, 0x51, 0x51, 0x51, 0x34, 0x2e, 0xcd, - 0x46, 0x3d, 0xa4, 0x20, 0x9e, 0xa0, 0x17, 0xdf, 0x86, 0x80, 0x7c, 0x74, 0xed, 0x9d, 0x12, 0xb3, 0x74, 0x6b, 0xe1, - 0xfb, 0xfe, 0x46, 0xa2, 0x9e, 0x02, 0xfd, 0x28, 0xc2, 0x64, 0x41, 0x33, 0xf2, 0x95, 0xff, 0x2e, 0x00, 0x6f, 0x62, - 0xd1, 0x3f, 0xb1, 0xf0, 0x67, 0xb2, 0xee, 0xe1, 0xab, 0x9d, 0xb8, 0xde, 0x2e, 0x9a, 0xd3, 0x41, 0xfb, 0x10, 0x94, - 0xaa, 0xbe, 0xc7, 0xe1, 0x4d, 0xa1, 0xf5, 0xcb, 0x8e, 0x5d, 0xb0, 0x15, 0x05, 0x03, 0x9e, 0x75, 0x4a, 0x34, 0x31, - 0x5d, 0x97, 0x15, 0x01, 0xc6, 0x12, 0x27, 0x90, 0x5b, 0x9d, 0xb3, 0x74, 0x94, 0x9b, 0xb3, 0x9b, 0x3c, 0x6b, 0x27, - 0xd7, 0xd1, 0xbe, 0x9d, 0xcc, 0x4a, 0x59, 0xe5, 0xba, 0x21, 0x34, 0x7b, 0xf9, 0xe4, 0x2c, 0x94, 0x8b, 0x42, 0x1d, - 0x15, 0x37, 0xb4, 0x6a, 0x4d, 0x56, 0xc0, 0xca, 0xc9, 0x45, 0xab, 0xf2, 0xe6, 0xe9, 0xd0, 0xb8, 0xc9, 0x36, 0xfd, - 0x58, 0xd1, 0x76, 0x07, 0x0a, 0xaf, 0x14, 0xd6, 0xf6, 0x1c, 0x6c, 0xc3, 0x89, 0x06, 0xc7, 0x7d, 0xbb, 0x6d, 0x40, - 0x54, 0x20, 0xbb, 0x98, 0x50, 0x62, 0x6b, 0xf9, 0x2f, 0x0e, 0x28, 0xbe, 0xbd, 0x9a, 0x5e, 0x47, 0x31, 0x32, 0x7c, - 0x53, 0xff, 0x1e, 0x08, 0xf0, 0x2f, 0x7c, 0x60, 0x6a, 0x67, 0x54, 0x45, 0x28, 0x6b, 0x37, 0xab, 0xd4, 0x1f, 0x15, - 0xb9, 0xa5, 0x49, 0x6a, 0xb6, 0x79, 0x7a, 0x82, 0x29, 0x2b, 0xda, 0x9b, 0xc3, 0x06, 0x7b, 0x74, 0x6d, 0x44, 0xd8, - 0x60, 0x42, 0x1c, 0xff, 0x83, 0x9d, 0x00, 0x9d, 0x48, 0xf1, 0x82, 0xcb, 0x71, 0x65, 0x29, 0x9a, 0x12, 0xcd, 0x4b, - 0x51, 0xfb, 0x14, 0xe6, 0x3d, 0xaf, 0x02, 0xae, 0x9b, 0x93, 0xa3, 0x6e, 0x87, 0x3e, 0x71, 0x8a, 0x38, 0xcf, 0x81, - 0x08, 0x7b, 0x2a, 0xa7, 0x5d, 0xbd, 0x59, 0x5b, 0x86, 0xb8, 0x6e, 0x56, 0x88, 0x90, 0x7c, 0x18, 0xa7, 0x62, 0x88, - 0xb1, 0x7d, 0x23, 0x73, 0xde, 0xe3, 0xab, 0x85, 0x28, 0xac, 0x70, 0x11, 0x8f, 0x91, 0xa5, 0x3f, 0x79, 0x05, 0xd1, - 0x9d, 0x51, 0x93, 0x60, 0xd6, 0xea, 0x64, 0xa4, 0x38, 0x53, 0xff, 0x02, 0x02, 0x43, 0x6d, 0x90, 0xd1, 0x41, 0x4d, - 0x95, 0xf9, 0xd1, 0xd3, 0x88, 0x1b, 0x1f, 0x38, 0xaa, 0x46, 0x9b, 0x90, 0x71, 0xa6, 0xd8, 0x16, 0xbf, 0x35, 0x77, - 0x4b, 0x00, 0x66, 0x77, 0x1d, 0xa8, 0x15, 0x71, 0x24, 0xa1, 0x55, 0x7f, 0xae, 0xfe, 0x7a, 0x89, 0x44, 0x71, 0x4e, - 0xe8, 0x63, 0xa0, 0xc5, 0x47, 0x98, 0xae, 0xe6, 0x62, 0x1b, 0x87, 0x1d, 0x47, 0xa2, 0x8a, 0xd3, 0xbb, 0xe8, 0x72, - 0x3f, 0x93, 0x60, 0xf7, 0x13, 0x22, 0x9e, 0xef, 0xad, 0x0b, 0x35, 0x0b, 0x47, 0x1f, 0xb6, 0x3f, 0x09, 0x12, 0x32, - 0xd4, 0x5f, 0x0b, 0x37, 0x47, 0xed, 0xd5, 0x4b, 0x2d, 0xa3, 0x0d, 0x3f, 0x2d, 0xa2, 0xc5, 0xa9, 0x00, 0x94, 0x0c, - 0xa3, 0xf3, 0xcf, 0x5f, 0xec, 0xb0, 0x9f, 0x83, 0x73, 0x0c, 0x26, 0x0f, 0x78, 0xc0, 0xcd, 0xdd, 0x4f, 0xe8, 0xda, - 0x52, 0xce, 0x08, 0x67, 0xac, 0x0d, 0x09, 0x56, 0xc6, 0xb9, 0x66, 0x6b, 0xe3, 0x45, 0xc3, 0x09, 0xe1, 0x08, 0x3a, - 0x68, 0x8c, 0x7a, 0x9e, 0x33, 0x9a, 0xa7, 0x58, 0xfd, 0xca, 0x19, 0x6f, 0xf9, 0x81, 0x9d, 0xcb, 0x15, 0x04, 0x55, - 0x17, 0x55, 0x62, 0x4d, 0x27, 0xda, 0x81, 0xcb, 0xfd, 0x25, 0x7b, 0xca, 0x22, 0x7f, 0xbb, 0xc0, 0xa4, 0xa6, 0x42, - 0x21, 0x67, 0x85, 0x1b, 0xda, 0x15, 0x82, 0xd5, 0x6a, 0xdc, 0xf0, 0x3b, 0x6f, 0x59, 0x96, 0xaa, 0x4e, 0xed, 0x46, - 0x35, 0x34, 0xc3, 0x84, 0x29, 0x6e, 0x68, 0x19, 0xdf, 0x91, 0x94, 0xd8, 0x59, 0xd7, 0x06, 0x73, 0xfa, 0x1f, 0x52, - 0x9c, 0x0a, 0x2d, 0x44, 0xa9, 0xfa, 0x1c, 0x34, 0x3a, 0x31, 0x4d, 0xd5, 0x79, 0x23, 0x77, 0x26, 0x07, 0x83, 0x9a, - 0xb2, 0xb1, 0x53, 0xf3, 0x8e, 0xe9, 0xc8, 0x0c, 0xfe, 0x8e, 0xfc, 0xe4, 0x21, 0xab, 0x65, 0x72, 0x99, 0xe8, 0x67, - 0xbd, 0xf1, 0xaa, 0x00, 0x14, 0xd6, 0x31, 0xa8, 0xd0, 0x3c, 0x6b, 0x2a, 0x6b, 0x55, 0x65, 0xba, 0x09, 0x5f, 0x75, - 0x4b, 0x03, 0x05, 0xcf, 0x94, 0xa7, 0x10, 0x51, 0x53, 0xe2, 0xa4, 0xd5, 0x43, 0xa8, 0x01, 0xe5, 0xe8, 0xbc, 0x88, - 0xb9, 0x8e, 0x95, 0x2a, 0x1b, 0xff, 0x72, 0xef, 0xa3, 0x75, 0xeb, 0x20, 0xef, 0x67, 0x36, 0xea, 0xfd, 0x22, 0x55, - 0x4e, 0xa1, 0xcf, 0x8f, 0x34, 0x8d, 0x35, 0x09, 0xb6, 0x71, 0x32, 0x90, 0x0a, 0x3a, 0xa9, 0xc0, 0xff, 0xcb, 0x94, - 0x33, 0x56, 0x4c, 0x2a, 0x40, 0xc5, 0x62, 0xed, 0x9a, 0x7f, 0xdd, 0x87, 0x05, 0x93, 0xa0, 0xee, 0x1f, 0x80, 0x5e, - 0x0b, 0xb9, 0x90, 0x5f, 0xad, 0xb7, 0xa1, 0xec, 0x7c, 0xc3, 0x49, 0xeb, 0x73, 0xf5, 0x93, 0x23, 0x17, 0xb1, 0x9a, - 0xa2, 0xcd, 0xb4, 0x9e, 0x3a, 0x4b, 0x98, 0xf0, 0xd3, 0x72, 0x6e, 0xba, 0x41, 0xe6, 0x1a, 0x47, 0xca, 0xcb, 0xec, - 0xe3, 0xa8, 0x55, 0x66, 0xe9, 0xd8, 0x86, 0x2a, 0x6a, 0x73, 0x3c, 0x73, 0xc6, 0xf8, 0x62, 0x4f, 0x9a, 0x6a, 0x57, - 0x56, 0xf2, 0xcd, 0xb5, 0x98, 0x37, 0x87, 0x32, 0x45, 0x3d, 0x32, 0xad, 0x93, 0x8b, 0x13, 0x2a, 0xb3, 0x02, 0xc0, - 0xdb, 0x90, 0x6c, 0x84, 0xc0, 0x2e, 0xd8, 0x8f, 0xb5, 0x78, 0xe9, 0x2e, 0xa5, 0x51, 0x82, 0x97, 0x10, 0x2b, 0xfa, - 0x87, 0xd2, 0x42, 0x83, 0x54, 0x57, 0x94, 0x2c, 0x0d, 0xf5, 0xdf, 0x4a, 0x0f, 0x27, 0x39, 0x6a, 0x70, 0x0e, 0xb5, - 0xc7, 0xc2, 0xa8, 0xf7, 0x63, 0xd2, 0xa3, 0x3c, 0xd6, 0x4b, 0x81, 0x4d, 0x96, 0xc0, 0xca, 0x09, 0x76, 0x17, 0x20, - 0xe5, 0xb5, 0x87, 0xbe, 0x56, 0x64, 0xc2, 0xe3, 0xf3, 0xe4, 0xd6, 0xa5, 0x65, 0xe0, 0x15, 0xf4, 0xae, 0xbd, 0xa1, - 0xd2, 0x02, 0x77, 0xbf, 0xc8, 0x95, 0xff, 0xe8, 0x50, 0x24, 0x1d, 0xf1, 0x54, 0x12, 0x78, 0x2b, 0xa9, 0xc1, 0xc0, - 0xad, 0x65, 0xc3, 0xb5, 0x69, 0x1b, 0x7d, 0xa8, 0x8f, 0xe3, 0x3b, 0x46, 0xab, 0xe0, 0x3f, 0x9f, 0x7e, 0xc3, 0x38, - 0xb4, 0xe0, 0xd9, 0xaa, 0x54, 0x59, 0xd7, 0x53, 0x47, 0xb2, 0xfd, 0xd5, 0xce, 0x5b, 0x04, 0xb3, 0x70, 0x25, 0x0b, - 0x4d, 0x02, 0x3a, 0xb6, 0x49, 0x16, 0xb8, 0x4d, 0x81, 0x99, 0x47, 0x3f, 0x45, 0x6f, 0x23, 0xd5, 0x38, 0x52, 0xb5, - 0x68, 0x12, 0xe3, 0x70, 0x41, 0x34, 0x79, 0x73, 0xb7, 0x2a, 0x02, 0x19, 0x1c, 0xc0, 0x2d, 0xbf, 0x33, 0xce, 0x3d, - 0xf5, 0x91, 0xd6, 0x3a, 0xf0, 0xbb, 0x6e, 0xb2, 0x5d, 0xda, 0xa1, 0x51, 0x4b, 0xf4, 0xb6, 0x1d, 0x35, 0x1a, 0x64, - 0xd8, 0x23, 0xc5, 0xd8, 0xbd, 0x8f, 0xcf, 0xea, 0x31, 0x83, 0x2c, 0xd1, 0x01, 0x5f, 0x77, 0x0d, 0x54, 0x2c, 0x32, - 0x90, 0xbb, 0x0b, 0x21, 0x51, 0x87, 0x6d, 0xb4, 0x00, 0x50, 0xfa, 0x04, 0xab, 0xef, 0xc4, 0x2d, 0xf5, 0x06, 0x94, - 0xf9, 0x3e, 0xa4, 0x94, 0x42, 0x7d, 0x51, 0x91, 0x29, 0x67, 0x8b, 0xc5, 0x8c, 0x22, 0x8c, 0x3c, 0x11, 0x19, 0x6a, - 0x13, 0xc4, 0x08, 0x9c, 0xde, 0x32, 0xaa, 0x7e, 0x6c, 0x2f, 0x03, 0x2d, 0xed, 0xb5, 0x88, 0xa9, 0xca, 0x19, 0xcf, - 0x01, 0x94, 0x80, 0xc1, 0x55, 0x00, 0x67, 0xa6, 0x7a, 0x57, 0xc6, 0x9c, 0x58, 0x66, 0x05, 0x0f, 0x94, 0x4e, 0x2e, - 0xc6, 0xd7, 0xc0, 0xf9, 0x8f, 0xad, 0x89, 0xab, 0xf8, 0xeb, 0xd7, 0x2d, 0x9f, 0x67, 0xff, 0x97, 0x89, 0x76, 0x75, - 0x06, 0xac, 0x9c, 0xb0, 0xcf, 0x13, 0xc4, 0xeb, 0x06, 0xdb, 0xcb, 0xd6, 0x62, 0xc5, 0x93, 0x5e, 0x7f, 0x6c, 0xb5, - 0xa4, 0x2c, 0xab, 0xe4, 0x57, 0x1b, 0x08, 0xa4, 0xf1, 0x9d, 0x49, 0x64, 0x90, 0x0a, 0x92, 0x62, 0xba, 0x11, 0xfc, - 0xee, 0x5b, 0xef, 0x61, 0x47, 0x1a, 0x78, 0xd9, 0xea, 0xc2, 0xf0, 0x99, 0xba, 0x5d, 0xd3, 0x49, 0xce, 0xe0, 0xcc, - 0x9b, 0x09, 0x47, 0x5b, 0xef, 0xf2, 0xe5, 0x0a, 0x2d, 0xfa, 0x3c, 0xf4, 0x2b, 0xba, 0x4d, 0x5a, 0x96, 0xc7, 0x3d, - 0xcc, 0xa0, 0xfe, 0xaf, 0x62, 0xcd, 0x69, 0xf4, 0x55, 0x51, 0x5f, 0x7a, 0x41, 0x2b, 0xcd, 0x6d, 0xad, 0x2d, 0xe4, - 0x74, 0x6e, 0x91, 0x7b, 0x30, 0x34, 0xed, 0xfb, 0x8f, 0x2a, 0xc2, 0x92, 0x3d, 0xa5, 0xad, 0xf7, 0xc9, 0x45, 0x2f, - 0xd5, 0xb9, 0x11, 0xff, 0x96, 0x53, 0x79, 0xd3, 0x3a, 0x6a, 0x64, 0x27, 0xfe, 0x0f, 0xd6, 0x4d, 0x94, 0x71, 0xb9, - 0x4e, 0xee, 0xb4, 0x83, 0xe2, 0xa8, 0x4b, 0x8e, 0x87, 0x38, 0xd7, 0x8c, 0x46, 0x7a, 0x25, 0xcc, 0x33, 0xa7, 0x55, - 0x85, 0x1e, 0x8b, 0x06, 0xc9, 0x1a, 0x1a, 0x90, 0x04, 0xa8, 0xc9, 0x09, 0x71, 0xea, 0x4e, 0x70, 0x6b, 0x40, 0x72, - 0x72, 0x89, 0x90, 0x9c, 0x16, 0xde, 0xe5, 0xe7, 0x0d, 0x19, 0xa2, 0x9c, 0xd7, 0x37, 0xb1, 0x23, 0x2a, 0x3e, 0x8b, - 0x6e, 0xb9, 0x6f, 0x11, 0x1a, 0x6d, 0x1f, 0x34, 0x9a, 0x8e, 0x39, 0xb0, 0xcb, 0x9b, 0x35, 0x68, 0x39, 0x33, 0x08, - 0xf9, 0xe9, 0x19, 0x34, 0x61, 0xc0, 0x6c, 0x85, 0x10, 0x73, 0x94, 0x6c, 0x95, 0x9a, 0x34, 0x06, 0xf5, 0xc4, 0x4e, - 0x1c, 0xa8, 0xcf, 0xcf, 0xba, 0x59, 0x29, 0xd9, 0x9c, 0x9a, 0xda, 0xf4, 0x03, 0xd8, 0xe2, 0x89, 0x36, 0x1f, 0x28, - 0xc3, 0x20, 0x0d, 0x57, 0x25, 0xc2, 0xdf, 0xa8, 0xa8, 0xf3, 0x65, 0x3e, 0x6f, 0xd8, 0x46, 0xd0, 0x88, 0x21, 0x03, - 0xb3, 0x13, 0xac, 0x81, 0x20, 0x58, 0x16, 0x67, 0x72, 0x96, 0xd2, 0xd9, 0x38, 0x96, 0x58, 0x0b, 0x05, 0xb4, 0xbc, - 0x4d, 0xce, 0x1d, 0x04, 0x50, 0x46, 0xa2, 0xc4, 0xb2, 0x8d, 0x88, 0x3e, 0x30, 0x09, 0xde, 0x10, 0x2b, 0xf8, 0x05, - 0xdf, 0x50, 0x3a, 0xe9, 0x40, 0x6d, 0x92, 0x3b, 0x85, 0xaa, 0x0c, 0x0e, 0xc6, 0xe1, 0x15, 0xff, 0xed, 0xca, 0x0f, - 0x0e, 0x6d, 0x22, 0xee, 0x2a, 0xe0, 0x92, 0xd1, 0x73, 0x08, 0xea, 0xe4, 0xda, 0xb2, 0x89, 0xef, 0x34, 0xda, 0xde, - 0x55, 0xb5, 0x2b, 0x8e, 0xf8, 0xfc, 0x51, 0x60, 0x14, 0xa4, 0x5c, 0xce, 0xfe, 0x8d, 0x27, 0x69, 0xa2, 0x39, 0xb7, - 0xef, 0x1d, 0x2e, 0x16, 0x69, 0xa6, 0x3a, 0x35, 0xbd, 0xb9, 0x58, 0xb5, 0x0f, 0x46, 0xae, 0xb5, 0x67, 0x67, 0x1c, - 0x47, 0x20, 0x05, 0xe5, 0x03, 0xfe, 0x0b, 0xa9, 0x1a, 0xf2, 0xf9, 0xd0, 0xcf, 0x01, 0xd5, 0x4c, 0xf1, 0x69, 0xd5, - 0xd6, 0xbe, 0x49, 0xb5, 0xe0, 0x7f, 0x73, 0x58, 0xa4, 0x75, 0xfd, 0xfd, 0xf9, 0x8b, 0xde, 0x36, 0xd2, 0xf1, 0x23, - 0xb1, 0x92, 0xfa, 0x13, 0xa0, 0xac, 0xbd, 0x19, 0xb3, 0x76, 0x10, 0x4e, 0x63, 0x4a, 0x21, 0xfa, 0x4f, 0xe2, 0x53, - 0x4f, 0x65, 0xf0, 0x0d, 0xd4, 0x0f, 0xde, 0xc4, 0x68, 0xe9, 0x67, 0x6d, 0x6a, 0x21, 0xfc, 0x2d, 0xe6, 0x8b, 0x5a, - 0x3d, 0xe8, 0x38, 0x0f, 0xb9, 0x78, 0xc5, 0xea, 0x3f, 0x7d, 0xf1, 0xe5, 0x6c, 0x61, 0xb0, 0x96, 0xb5, 0x07, 0xff, - 0x8f, 0xf3, 0x00, 0x20, 0x5b, 0x61, 0x58, 0x4b, 0x34, 0xd3, 0x2f, 0xad, 0xa7, 0x00, 0xdf, 0x9d, 0xa7, 0x52, 0x6c, - 0x4d, 0x8b, 0x95, 0xa9, 0x67, 0x3a, 0xa8, 0x57, 0x6a, 0x6b, 0xdc, 0x37, 0xd6, 0x87, 0xd1, 0xd0, 0x77, 0x30, 0x57, - 0xbc, 0x7e, 0x8c, 0xe9, 0xee, 0x9f, 0x26, 0x26, 0x86, 0xfd, 0x4e, 0x75, 0x4a, 0x9a, 0xa5, 0xbe, 0x6d, 0xc8, 0x99, - 0xdd, 0x26, 0x70, 0xdf, 0x64, 0x8a, 0x90, 0xe3, 0x7d, 0x72, 0x94, 0xa2, 0xa6, 0x7d, 0x4b, 0x25, 0xab, 0x5b, 0x0f, - 0x69, 0xc4, 0x2c, 0x35, 0xd0, 0xfb, 0xe2, 0x55, 0x81, 0x81, 0x87, 0xea, 0xbc, 0x7e, 0x8b, 0x02, 0x9e, 0xc1, 0x47, - 0xcb, 0xac, 0xda, 0xba, 0x04, 0x8e, 0x51, 0xeb, 0xc0, 0xfd, 0xf2, 0xc0, 0x1f, 0x29, 0xfa, 0xe2, 0x6d, 0xe4, 0x60, - 0x83, 0xd7, 0x53, 0x83, 0x53, 0x1e, 0x9e, 0x8d, 0xf5, 0x31, 0xe3, 0xa7, 0x95, 0xa3, 0xb0, 0x67, 0x5c, 0x3c, 0x99, - 0x5d, 0x8c, 0xc3, 0xa6, 0xdb, 0x2a, 0x27, 0x4a, 0xa6, 0x4c, 0xd7, 0x64, 0x7e, 0xc6, 0x85, 0x9e, 0x37, 0x6b, 0xb5, - 0x84, 0xcd, 0x0f, 0xfe, 0x70, 0x53, 0x5c, 0x19, 0x27, 0xa3, 0xd0, 0xfe, 0x1f, 0xd9, 0x99, 0xa6, 0x77, 0xa1, 0x46, - 0xe0, 0x52, 0x70, 0xb5, 0x54, 0x96, 0x46, 0xda, 0xcf, 0xf6, 0xe9, 0xfb, 0x24, 0x5f, 0x41, 0x9e, 0xfe, 0x92, 0x15, - 0x1b, 0x73, 0x92, 0x64, 0xff, 0xa8, 0x14, 0x32, 0x87, 0xaa, 0x45, 0x3b, 0x46, 0x5b, 0xf9, 0x09, 0x41, 0x7d, 0xd9, - 0x21, 0xea, 0x00, 0xcc, 0xb6, 0x4a, 0x79, 0xbf, 0x18, 0x68, 0x46, 0x51, 0xb6, 0x1c, 0xf4, 0xb5, 0x61, 0x06, 0x07, - 0xaf, 0x1a, 0xd6, 0xef, 0xbd, 0xac, 0x55, 0x32, 0x52, 0x69, 0xb3, 0xcc, 0x51, 0x6a, 0xf2, 0x74, 0xbf, 0xd4, 0xb9, - 0xe8, 0x9a, 0x38, 0xf8, 0xd9, 0xda, 0xf7, 0x60, 0xd7, 0x4e, 0xcb, 0xae, 0x14, 0xe6, 0x06, 0xc7, 0x79, 0xcc, 0x71, - 0x65, 0x03, 0x11, 0x6b, 0x16, 0x5a, 0xde, 0x14, 0x2d, 0x52, 0x77, 0xea, 0xbb, 0xb3, 0xec, 0x26, 0x80, 0xad, 0x62, - 0xef, 0xa1, 0xe5, 0xdb, 0x67, 0xe9, 0x8d, 0x0e, 0x6c, 0x6b, 0xe3, 0x5e, 0xc7, 0x37, 0x16, 0x84, 0x9e, 0x2c, 0xaf, - 0xce, 0xa8, 0x8e, 0x3b, 0xa7, 0xf9, 0xfc, 0x50, 0x31, 0x96, 0x6e, 0x93, 0xe8, 0x9c, 0x8f, 0xe4, 0x09, 0xb2, 0x0c, - 0x15, 0xcb, 0x69, 0x60, 0x2d, 0x23, 0x68, 0xec, 0x24, 0x7d, 0xe5, 0x91, 0xac, 0xc6, 0x8a, 0xf9, 0x47, 0xa0, 0x76, - 0xae, 0xec, 0xb8, 0x6d, 0x86, 0xa4, 0x5a, 0xae, 0xb4, 0x46, 0x30, 0x0c, 0x8d, 0x7f, 0x2d, 0x44, 0xa2, 0xda, 0x4a, - 0x40, 0x02, 0x0e, 0x67, 0x29, 0xa8, 0xdd, 0x6d, 0x79, 0xf3, 0x6e, 0x94, 0x1e, 0x51, 0xa4, 0xa2, 0x56, 0x54, 0x4e, - 0xf1, 0x86, 0xb2, 0xf5, 0x4c, 0x34, 0x01, 0x13, 0x8d, 0x62, 0x23, 0x33, 0x28, 0x6f, 0xb7, 0x2a, 0xe4, 0x5e, 0xae, - 0xfb, 0xb7, 0x57, 0xef, 0x28, 0x0d, 0x9b, 0xbe, 0x12, 0x92, 0x06, 0xad, 0x50, 0x44, 0x7c, 0xc0, 0x8e, 0x31, 0x8e, - 0xae, 0xc9, 0xf4, 0x99, 0x3a, 0x30, 0x46, 0x75, 0x89, 0x94, 0x2f, 0xcd, 0x9f, 0xbd, 0xf1, 0xea, 0x25, 0xb0, 0xf5, - 0x3b, 0x5d, 0x6b, 0x4d, 0x66, 0xde, 0x96, 0x52, 0x2b, 0x91, 0x6e, 0x32, 0x22, 0x8d, 0xff, 0x4c, 0xb3, 0x6f, 0x26, - 0xf2, 0x87, 0x1d, 0xed, 0xc0, 0x40, 0x86, 0xf4, 0x66, 0xb3, 0x39, 0xa7, 0x6a, 0x16, 0x00, 0x0a, 0xff, 0xd5, 0xba, - 0x0f, 0x66, 0x6b, 0xa6, 0xa9, 0x88, 0xe0, 0xb3, 0x30, 0x34, 0x6f, 0xe1, 0x90, 0xd5, 0x69, 0x04, 0xe0, 0x20, 0x09, - 0x81, 0xcc, 0xd9, 0x5c, 0x6f, 0x08, 0xaa, 0xd8, 0xdb, 0xb0, 0x46, 0x9f, 0x42, 0xe8, 0x7f, 0xe4, 0xd3, 0xcf, 0xf9, - 0x5e, 0x45, 0x51, 0x0c, 0x5d, 0x1d, 0x0a, 0x87, 0xd6, 0xdf, 0x64, 0xd2, 0x78, 0x97, 0x2c, 0x14, 0x83, 0xfa, 0x8b, - 0xbd, 0x43, 0xcb, 0xdc, 0x74, 0x67, 0x03, 0x0b, 0x97, 0x0a, 0x06, 0x52, 0x2c, 0x42, 0x48, 0x73, 0x83, 0xb3, 0x7e, - 0xeb, 0xb1, 0x7c, 0xe9, 0x02, 0x4d, 0xdf, 0xca, 0xe3, 0x31, 0x3e, 0xfb, 0x76, 0xbc, 0xe3, 0x13, 0x66, 0x5a, 0x66, - 0x89, 0x4a, 0x0a, 0xe9, 0x93, 0xff, 0x0e, 0xa3, 0x96, 0xc7, 0x84, 0x05, 0xd3, 0xea, 0xee, 0xa9, 0x14, 0xc5, 0xce, - 0x73, 0x58, 0x53, 0x2f, 0xa0, 0x0e, 0x85, 0x9b, 0xea, 0x03, 0xbb, 0x12, 0x41, 0x6a, 0x53, 0x00, 0x30, 0xfe, 0x08, - 0x80, 0x88, 0x07, 0x99, 0x57, 0xaa, 0x25, 0x64, 0xb8, 0x59, 0x4e, 0xa4, 0xbb, 0x8b, 0x51, 0xe2, 0x9b, 0x23, 0x02, - 0xb4, 0xa5, 0x66, 0x18, 0x9e, 0xc9, 0x6f, 0x73, 0x79, 0x13, 0x2e, 0x81, 0xed, 0x1a, 0xc1, 0x1b, 0x21, 0x6d, 0xd6, - 0x7e, 0x38, 0x02, 0xaa, 0xb6, 0x01, 0x51, 0xfa, 0x4d, 0x79, 0x63, 0xde, 0x88, 0x14, 0xaa, 0xd5, 0xce, 0xee, 0x4d, - 0x5a, 0xa7, 0x0d, 0xab, 0xe1, 0x29, 0xdc, 0x54, 0xa9, 0x6d, 0x23, 0xd7, 0xf6, 0x7f, 0x92, 0x82, 0x9c, 0x4d, 0xdd, - 0xd5, 0x6d, 0xf7, 0xfb, 0xa7, 0x09, 0x38, 0xfc, 0x24, 0x31, 0xbe, 0xfb, 0xd5, 0x32, 0xfb, 0x3f, 0xb6, 0xf2, 0xa0, - 0x04, 0x0f, 0xa7, 0x20, 0x9f, 0x62, 0x0d, 0xd7, 0x90, 0x7a, 0xf2, 0xae, 0xaf, 0xbb, 0x80, 0xc0, 0xfa, 0x2d, 0xb9, - 0x13, 0xef, 0x32, 0x82, 0x53, 0x00, 0xdb, 0xd6, 0x11, 0x58, 0xeb, 0xe6, 0x3b, 0x90, 0x82, 0x18, 0xf9, 0x2d, 0x92, - 0xff, 0xb3, 0x32, 0x37, 0xfc, 0x48, 0x51, 0xdc, 0x9c, 0x4b, 0x17, 0xd1, 0x93, 0x55, 0xd8, 0x0e, 0x1b, 0x55, 0x80, - 0x23, 0xb0, 0xf0, 0x7e, 0x6e, 0x26, 0xff, 0x0c, 0xa1, 0x9d, 0xab, 0x33, 0xc5, 0xa1, 0x18, 0xd5, 0x4f, 0x75, 0x01, - 0xca, 0xc3, 0x64, 0xc4, 0xa6, 0x26, 0xb4, 0x18, 0x0b, 0x4b, 0x97, 0x24, 0x80, 0x40, 0x7b, 0xa8, 0x25, 0x32, 0x97, - 0x6b, 0x91, 0x5d, 0x32, 0xee, 0xd9, 0x56, 0x2c, 0x5d, 0xfb, 0x98, 0xd7, 0xd9, 0x33, 0x70, 0xe3, 0x3c, 0x06, 0x5f, - 0xdc, 0xd9, 0x52, 0x58, 0xe9, 0x19, 0xb2, 0x3a, 0x3b, 0x57, 0xe2, 0xb0, 0x4d, 0xb6, 0x1f, 0x15, 0xec, 0xee, 0xdb, - 0x5b, 0x22, 0x0b, 0xc4, 0xe0, 0x3f, 0xad, 0x35, 0x59, 0xeb, 0x6f, 0xe4, 0x00, 0xbe, 0x85, 0x95, 0x7c, 0x41, 0x33, - 0xe0, 0x72, 0x77, 0x73, 0x40, 0xea, 0x81, 0x4f, 0x26, 0xac, 0xaa, 0x72, 0xcd, 0xcd, 0x46, 0xa6, 0x09, 0x9a, 0x10, - 0xff, 0xbf, 0xb2, 0xd5, 0x10, 0x1b, 0x80, 0x27, 0x63, 0xdf, 0x7c, 0xd9, 0x85, 0xc1, 0x66, 0xa1, 0xc5, 0x16, 0xf6, - 0xe1, 0x2d, 0xa7, 0xe2, 0x75, 0x73, 0x03, 0x35, 0xfc, 0x20, 0x81, 0x95, 0xef, 0x12, 0xaa, 0xf9, 0x9e, 0x38, 0xf6, - 0xbd, 0x57, 0xbe, 0x7a, 0x4e, 0x8f, 0x40, 0xd3, 0xe8, 0xac, 0x99, 0xf4, 0xe4, 0x70, 0x6e, 0x0c, 0x55, 0x23, 0xaf, - 0x95, 0xb7, 0x07, 0x57, 0xab, 0xbf, 0x3e, 0x9b, 0xf3, 0x36, 0x3f, 0xa2, 0x1f, 0x5d, 0x63, 0x23, 0x66, 0x71, 0xc2, - 0x57, 0xd7, 0x47, 0x91, 0x50, 0x51, 0xc4, 0xc5, 0x87, 0x75, 0x9f, 0x36, 0xae, 0xb7, 0x8e, 0x6e, 0xf1, 0x2e, 0xc0, - 0x9c, 0x92, 0x54, 0x9d, 0x6d, 0x67, 0xe8, 0x0a, 0xbe, 0x97, 0xb5, 0xc5, 0xf1, 0xa5, 0xb5, 0x6e, 0xcb, 0xcb, 0xae, - 0xbc, 0x37, 0x46, 0x5d, 0xb4, 0x60, 0xd7, 0x77, 0x9c, 0xbc, 0xd5, 0xc8, 0xfd, 0xea, 0xa9, 0x2d, 0x96, 0x50, 0x40, - 0x1b, 0x5a, 0xbe, 0x20, 0x3b, 0xc6, 0x9e, 0x8d, 0x4e, 0xa5, 0xc9, 0x53, 0xf4, 0xba, 0xfb, 0xcc, 0x23, 0x1e, 0xd6, - 0x81, 0xae, 0x9c, 0x06, 0x1d, 0xff, 0xc2, 0x7f, 0x79, 0x59, 0xaa, 0xb7, 0x2a, 0xae, 0xbd, 0x12, 0x00, 0x93, 0x2a, - 0x9f, 0xf4, 0xf2, 0xf7, 0x41, 0x10, 0x19, 0xd9, 0x08, 0xf1, 0x4c, 0x54, 0x96, 0x00, 0x3a, 0xae, 0x72, 0xf1, 0xce, - 0x74, 0xd0, 0x2f, 0x67, 0x22, 0x11, 0x39, 0x03, 0x6d, 0x1b, 0x14, 0x0a, 0x91, 0x7a, 0xbb, 0x08, 0xe2, 0x1e, 0x45, - 0x4c, 0x34, 0xd7, 0x5d, 0xdf, 0xaf, 0xd1, 0x71, 0x34, 0x36, 0xa3, 0x76, 0xfb, 0x5b, 0xc1, 0x14, 0x48, 0x89, 0x83, - 0x81, 0xba, 0xa2, 0x22, 0x1e, 0xff, 0xf1, 0x40, 0xfb, 0x25, 0x35, 0x9c, 0xb2, 0xc3, 0x78, 0x15, 0x5f, 0x59, 0x55, - 0xb5, 0xe2, 0x97, 0x88, 0x99, 0x21, 0x88, 0x37, 0x1a, 0xe9, 0x95, 0xcd, 0x5e, 0xcd, 0x64, 0xa2, 0x38, 0x29, 0x2c, - 0x8f, 0x6b, 0xd7, 0x84, 0x75, 0x00, 0x6b, 0xf5, 0xd1, 0xa1, 0xa5, 0xf8, 0xfb, 0xec, 0x8f, 0x4b, 0x8e, 0x99, 0xe7, - 0xcf, 0xf0, 0xbf, 0xcd, 0x2e, 0x97, 0xfc, 0xd1, 0x3d, 0xc9, 0xf6, 0x3d, 0x76, 0x00, 0xcd, 0x32, 0xa5, 0x8e, 0x32, - 0x86, 0x00, 0xc0, 0x41, 0xe2, 0x7b, 0x8b, 0xdb, 0xff, 0xee, 0x18, 0x44, 0xce, 0xf2, 0xa6, 0xc5, 0x83, 0xff, 0x18, - 0x51, 0x5a, 0x1a, 0x6b, 0xe1, 0x08, 0x82, 0x71, 0x6d, 0xac, 0x1b, 0xc9, 0x3c, 0xd0, 0x75, 0x04, 0xb2, 0x96, 0x9c, - 0x60, 0xa2, 0x44, 0xee, 0x55, 0xcd, 0xeb, 0x10, 0x6a, 0x25, 0x96, 0xa9, 0xcd, 0x23, 0xea, 0xa8, 0xb1, 0xef, 0x40, - 0xf0, 0x32, 0x3b, 0x44, 0x6d, 0xfe, 0x63, 0x4b, 0x81, 0x5f, 0x4a, 0x79, 0x32, 0x70, 0x78, 0x23, 0x14, 0x15, 0x1f, - 0x05, 0x30, 0x9c, 0x11, 0xbc, 0xa8, 0xd5, 0x57, 0x8e, 0x63, 0xa0, 0x1f, 0x4a, 0x2a, 0x5e, 0xec, 0x3e, 0x6f, 0xbc, - 0x01, 0x77, 0xa1, 0xfc, 0x03, 0xe5, 0x3a, 0x52, 0x2d, 0x7b, 0xf9, 0xc8, 0x4e, 0x6d, 0xc7, 0xd9, 0x50, 0x15, 0x54, - 0x45, 0xef, 0xd0, 0x2f, 0x85, 0x70, 0x60, 0x79, 0xb2, 0xda, 0x1b, 0xee, 0x0c, 0x7c, 0x6c, 0xc4, 0x47, 0x7d, 0x25, - 0x7b, 0x43, 0xa2, 0x8c, 0x85, 0xe4, 0x38, 0x2a, 0x40, 0xf4, 0xe4, 0xd3, 0x75, 0x36, 0x0d, 0x7b, 0x75, 0xb6, 0x14, - 0x48, 0x23, 0x46, 0x3a, 0x97, 0x4a, 0x67, 0xf6, 0xf4, 0x48, 0x19, 0x3f, 0xef, 0xfc, 0x6a, 0xd9, 0xa0, 0xcc, 0x36, - 0xa4, 0xf2, 0xa7, 0xbc, 0x2f, 0x25, 0x65, 0xb2, 0xad, 0xd8, 0xf4, 0xc6, 0xe6, 0x14, 0xc0, 0x64, 0x05, 0x61, 0xee, - 0xbe, 0x41, 0x39, 0x18, 0x63, 0x5d, 0xa9, 0x22, 0xdf, 0xf8, 0x3c, 0x76, 0x7a, 0x7a, 0xc1, 0x33, 0x8a, 0x2c, 0xfa, - 0x53, 0x04, 0x36, 0xcb, 0x6b, 0x85, 0x09, 0xdf, 0xe7, 0xb8, 0x46, 0xbf, 0xd0, 0x14, 0x4d, 0x42, 0xf4, 0xe3, 0x8d, - 0x48, 0x35, 0x2b, 0xe0, 0xcd, 0xfb, 0xa6, 0x1b, 0xc1, 0xb3, 0x32, 0xda, 0x48, 0x24, 0xda, 0xba, 0x29, 0xf0, 0xef, - 0x11, 0x7d, 0x23, 0x66, 0xfa, 0x83, 0x34, 0x5f, 0xfd, 0x20, 0xcc, 0x37, 0xdb, 0x03, 0xaa, 0xda, 0x87, 0xdc, 0xf8, - 0xe4, 0x42, 0x01, 0x16, 0x10, 0x46, 0x2f, 0x95, 0x36, 0xd6, 0x04, 0xa5, 0x84, 0x4b, 0x51, 0x93, 0x51, 0x5e, 0x4f, - 0xf5, 0x09, 0xad, 0xeb, 0x25, 0x19, 0x60, 0x12, 0xba, 0xb1, 0x8d, 0xbe, 0x8d, 0xb9, 0x4d, 0x97, 0xfd, 0x87, 0x0a, - 0xed, 0x81, 0x2b, 0x1b, 0x2c, 0xe0, 0x73, 0xb5, 0xe7, 0xce, 0x45, 0x04, 0x5a, 0x83, 0xf8, 0x8f, 0xe3, 0x7a, 0xb1, - 0x77, 0x4b, 0x25, 0x25, 0x56, 0x59, 0x08, 0x19, 0x2a, 0x17, 0x76, 0x73, 0xc3, 0x3c, 0xeb, 0x71, 0xf0, 0x8c, 0x04, - 0x01, 0xc1, 0xa9, 0x82, 0x49, 0x5c, 0x4d, 0x69, 0x58, 0xd9, 0x73, 0x74, 0xc3, 0x69, 0xf9, 0x35, 0x53, 0x65, 0xbb, - 0x40, 0xa7, 0x6f, 0x5c, 0x31, 0x98, 0x9f, 0xd8, 0x17, 0x8e, 0x1e, 0x5a, 0x46, 0xd7, 0x67, 0x07, 0x46, 0x80, 0x1c, - 0x56, 0x96, 0x81, 0x84, 0x2d, 0x49, 0xab, 0x37, 0x79, 0x78, 0xcf, 0x14, 0x22, 0xc9, 0x02, 0x55, 0x8e, 0x5f, 0x60, - 0x6b, 0x69, 0x49, 0x39, 0x2b, 0xd1, 0x5a, 0x85, 0x32, 0x44, 0x6b, 0xbd, 0x6f, 0x57, 0x9d, 0xde, 0x7b, 0x5f, 0xd0, - 0x79, 0x69, 0x24, 0x87, 0x18, 0x02, 0x43, 0x2c, 0x8d, 0xef, 0x14, 0x36, 0x5a, 0x6f, 0x96, 0xd9, 0x7d, 0x35, 0xb6, - 0x5f, 0xc3, 0x75, 0x3d, 0xf1, 0xa6, 0xfc, 0xb6, 0xce, 0x1e, 0xe6, 0xbc, 0x72, 0xa2, 0x1b, 0xba, 0x86, 0xcd, 0xda, - 0x4e, 0x7f, 0x55, 0xdf, 0x32, 0x19, 0x16, 0x1f, 0x7b, 0x08, 0x21, 0x17, 0xaa, 0x54, 0x88, 0xf4, 0x76, 0x27, 0x90, - 0x2a, 0xf7, 0x94, 0x2b, 0x9d, 0xe3, 0x44, 0xd6, 0xb1, 0x9d, 0x1c, 0x2e, 0x4d, 0x2a, 0x88, 0x63, 0x7b, 0xf7, 0x9d, - 0x58, 0xf0, 0xc9, 0x17, 0xd2, 0x9c, 0xa7, 0xeb, 0x97, 0x7e, 0x78, 0x65, 0xac, 0x94, 0x9c, 0x6e, 0x66, 0x51, 0xd3, - 0xdd, 0x2c, 0xb2, 0xf3, 0xaf, 0x71, 0xeb, 0x92, 0xf0, 0x3a, 0x69, 0xff, 0x6a, 0x84, 0x97, 0x5c, 0xeb, 0x52, 0x44, - 0x53, 0x94, 0xba, 0x7f, 0x9d, 0xa0, 0x20, 0x12, 0xfc, 0xb9, 0x68, 0x18, 0x6b, 0x9f, 0x56, 0xcd, 0x47, 0x63, 0xc5, - 0xd6, 0xde, 0xb7, 0x92, 0x1a, 0x17, 0x05, 0xd7, 0x8c, 0x5c, 0x69, 0xa5, 0xc4, 0xe0, 0x38, 0xd0, 0x94, 0x3f, 0x50, - 0xe5, 0x0f, 0x53, 0xd2, 0x79, 0x8b, 0xd9, 0xea, 0xfb, 0xd4, 0x6e, 0x1d, 0x53, 0x45, 0x23, 0x9d, 0x19, 0xb3, 0x51, - 0x2b, 0x38, 0xda, 0xe3, 0x7a, 0x59, 0x48, 0xe7, 0xb4, 0xcd, 0xe0, 0x93, 0xf4, 0xf1, 0xad, 0x7c, 0xb6, 0xca, 0x5f, - 0xea, 0xfd, 0x5e, 0xda, 0xdb, 0xe4, 0xc5, 0x06, 0xde, 0x0a, 0x13, 0x60, 0x20, 0xa2, 0x52, 0x05, 0xb5, 0x84, 0x24, - 0xec, 0xb4, 0xd3, 0x39, 0x43, 0x55, 0x5a, 0x4c, 0x81, 0x1f, 0x97, 0xf5, 0xf1, 0xf8, 0x5a, 0x34, 0xa6, 0xd6, 0x51, - 0x23, 0x3e, 0x2e, 0xe7, 0x19, 0x20, 0x2f, 0x54, 0x3c, 0x53, 0x11, 0x7d, 0x46, 0xce, 0xf0, 0xa0, 0xcc, 0x82, 0x91, - 0x76, 0x18, 0x8a, 0x2d, 0x37, 0xa6, 0x3a, 0x03, 0xba, 0xf0, 0x67, 0x8d, 0x94, 0x69, 0x84, 0x52, 0xc8, 0xb5, 0x49, - 0xbb, 0xcc, 0x37, 0x08, 0xd3, 0x0b, 0x1a, 0x7f, 0x3d, 0xf9, 0x5e, 0x0a, 0x99, 0x02, 0xee, 0x23, 0x85, 0xd7, 0xf4, - 0x42, 0x66, 0xc0, 0x9b, 0x1a, 0x20, 0x09, 0x40, 0x9a, 0x55, 0x27, 0xbc, 0x0d, 0x0f, 0x49, 0xb3, 0xdf, 0xca, 0x52, - 0xb9, 0x27, 0x57, 0x5a, 0xf2, 0xad, 0x6e, 0x2b, 0xe6, 0x4b, 0xd6, 0x36, 0xad, 0x9d, 0x9d, 0xd0, 0xeb, 0x34, 0x4d, - 0xba, 0x44, 0x38, 0xa8, 0x24, 0xbd, 0xdf, 0x01, 0x06, 0x53, 0x5f, 0xbe, 0x45, 0xcd, 0xfc, 0x5e, 0x82, 0x9d, 0x0c, - 0xd8, 0x50, 0x65, 0xe5, 0x32, 0x0b, 0x00, 0x01, 0xba, 0x6d, 0xa3, 0x9b, 0x26, 0x8b, 0x37, 0x22, 0xf7, 0x80, 0xce, - 0x05, 0x77, 0x64, 0x6f, 0x29, 0xdd, 0x99, 0x8e, 0x95, 0x6c, 0xbc, 0x2b, 0x6b, 0xb2, 0x0b, 0x95, 0xf8, 0x26, 0x06, - 0x66, 0x3b, 0x2b, 0x09, 0x80, 0xeb, 0xc6, 0x2e, 0xf3, 0x42, 0x9d, 0xc9, 0x6c, 0xcd, 0xaa, 0x3c, 0x55, 0xc3, 0x54, - 0x3a, 0x74, 0xd5, 0x44, 0x0d, 0x41, 0x36, 0x20, 0x6c, 0x5e, 0xdb, 0x5c, 0xc7, 0x67, 0x01, 0x20, 0xe8, 0x41, 0x29, - 0x4b, 0xc6, 0x8e, 0x1b, 0x69, 0x77, 0xbd, 0xac, 0x00, 0x61, 0xbc, 0xb3, 0x26, 0x39, 0x39, 0x2d, 0xfd, 0xc9, 0x78, - 0xdb, 0x6a, 0xa6, 0xdd, 0xf1, 0x43, 0x42, 0xdb, 0xe2, 0xd0, 0x82, 0x1f, 0xa9, 0xdd, 0xb9, 0x5a, 0xc4, 0xaa, 0xbd, - 0x2c, 0x60, 0xb0, 0x8d, 0xd6, 0xba, 0x6d, 0xee, 0xe6, 0x98, 0x08, 0x27, 0xcb, 0xc6, 0x74, 0x27, 0x96, 0x17, 0x89, - 0x35, 0x06, 0x6a, 0x6b, 0xde, 0xf8, 0xa5, 0xc0, 0xd4, 0x04, 0xdf, 0xa8, 0x5c, 0x2c, 0x8d, 0xfe, 0xf4, 0x03, 0x11, - 0xa1, 0x59, 0x6c, 0xae, 0xd6, 0x4d, 0x68, 0xbc, 0xc6, 0xf5, 0x06, 0xdc, 0x0d, 0x2c, 0x1a, 0x4e, 0xf4, 0x60, 0xce, - 0xee, 0x48, 0xcd, 0x8a, 0x65, 0xf8, 0xd1, 0xa3, 0xa3, 0x02, 0xbb, 0xb3, 0x39, 0x96, 0x14, 0x48, 0x30, 0xe2, 0xd7, - 0xd7, 0x58, 0x2c, 0x6a, 0x97, 0x46, 0x07, 0x63, 0x2e, 0xf9, 0x0f, 0xaa, 0x9b, 0x69, 0x5f, 0x01, 0x9b, 0x7b, 0x86, - 0x23, 0x49, 0x99, 0x19, 0xbd, 0xbc, 0x36, 0x0d, 0xec, 0x55, 0x1e, 0x75, 0x1c, 0x46, 0x4f, 0x4a, 0xc2, 0xde, 0x6c, - 0x4d, 0xa9, 0x5c, 0x8a, 0x51, 0xe8, 0x79, 0x83, 0x58, 0xf4, 0xb8, 0x07, 0x38, 0xc9, 0x98, 0x22, 0x7d, 0xb5, 0x51, - 0x90, 0xb7, 0xda, 0x5f, 0xba, 0xec, 0xa0, 0x49, 0x87, 0xce, 0x02, 0x9c, 0x8c, 0x92, 0x82, 0x10, 0xa0, 0x0d, 0xa1, - 0xd7, 0x06, 0x2f, 0xa5, 0x08, 0x4d, 0x4d, 0x66, 0xd4, 0x85, 0xf9, 0x9c, 0x71, 0x46, 0xa1, 0xa0, 0xa7, 0x5d, 0x9a, - 0x77, 0xab, 0xdb, 0x5c, 0x38, 0xde, 0x5d, 0x54, 0x2b, 0x02, 0x29, 0x5a, 0xf1, 0xd3, 0x43, 0xe1, 0x22, 0xb7, 0x20, - 0xa2, 0xd6, 0x1c, 0xde, 0x1a, 0x9c, 0x5c, 0x4c, 0x68, 0x95, 0xea, 0xae, 0xf7, 0xf0, 0x85, 0x88, 0xef, 0xda, 0x3c, - 0x21, 0x8e, 0x5a, 0x6f, 0xe8, 0xe6, 0x2c, 0xcd, 0x53, 0x09, 0xf5, 0xcc, 0x16, 0x02, 0x97, 0x8d, 0x8c, 0x2a, 0x7c, - 0x33, 0x3e, 0xc7, 0xc8, 0x92, 0x80, 0x32, 0x38, 0x9d, 0xc5, 0x08, 0x2c, 0x32, 0xe6, 0xe3, 0xd8, 0x1f, 0xcf, 0x6c, - 0x82, 0x7c, 0xd7, 0x98, 0x11, 0x89, 0xb7, 0xbd, 0x37, 0xd4, 0x28, 0x94, 0x8c, 0x44, 0x5c, 0x1e, 0x39, 0xb4, 0x7b, - 0x50, 0x7d, 0x37, 0x20, 0x36, 0x8c, 0x29, 0xd3, 0x09, 0xa1, 0x4f, 0x1e, 0xc4, 0x9a, 0x5c, 0x98, 0xb0, 0xd2, 0x49, - 0x0c, 0xc4, 0xe8, 0x6c, 0x40, 0xf5, 0x8d, 0xd0, 0x22, 0x91, 0x05, 0x25, 0x12, 0xf9, 0x6c, 0x4e, 0x88, 0xc3, 0x56, - 0x64, 0xfc, 0x60, 0xb5, 0x77, 0x11, 0x95, 0x3e, 0xe3, 0xa4, 0xb0, 0x2e, 0x0b, 0xa3, 0x3f, 0x46, 0x09, 0x61, 0xc0, - 0xd9, 0xed, 0x49, 0x51, 0xde, 0x0d, 0x8b, 0x47, 0x17, 0xa8, 0xca, 0xb7, 0x5c, 0x01, 0xec, 0xd1, 0x42, 0x0d, 0x54, - 0x59, 0xb2, 0x9c, 0xeb, 0x47, 0x21, 0xc2, 0x53, 0x66, 0x8e, 0xaa, 0x10, 0x06, 0x84, 0x88, 0x4a, 0xed, 0xc2, 0xae, - 0x95, 0x02, 0x74, 0x30, 0xa6, 0x8d, 0x46, 0x88, 0x0b, 0x78, 0x9e, 0xb7, 0x3f, 0x0e, 0x98, 0xe6, 0x89, 0x3f, 0xa8, - 0x06, 0xfd, 0x77, 0x24, 0x9b, 0xac, 0x9f, 0xdc, 0xf7, 0xc3, 0x27, 0x7d, 0x87, 0xce, 0xde, 0xef, 0xab, 0xbf, 0x7f, - 0xec, 0xd1, 0x40, 0x16, 0xf2, 0x0b, 0xdd, 0x84, 0x56, 0xcf, 0xde, 0x18, 0xee, 0x88, 0x56, 0xcf, 0x4e, 0x2f, 0x0a, - 0xd4, 0x3b, 0xd7, 0x4e, 0x6d, 0x1b, 0x36, 0x32, 0x89, 0xc7, 0x9a, 0x27, 0x63, 0xb0, 0x22, 0x83, 0x6a, 0x05, 0x2b, - 0x9b, 0x2c, 0xd1, 0x5d, 0x9f, 0x99, 0x83, 0x7b, 0xe2, 0x46, 0xbe, 0x93, 0x67, 0x1f, 0x80, 0x9b, 0x10, 0xf9, 0x4b, - 0x0e, 0xab, 0xfa, 0x1d, 0xd5, 0xa6, 0x3b, 0x28, 0x18, 0x4a, 0x2d, 0x31, 0x5b, 0x15, 0x8d, 0x25, 0xd8, 0x1b, 0x04, - 0x5a, 0x53, 0xab, 0x0f, 0xeb, 0x70, 0xc8, 0x1f, 0x5b, 0xfb, 0x07, 0x95, 0x89, 0xba, 0x68, 0x40, 0x9e, 0x86, 0x5f, - 0xba, 0x44, 0xb8, 0x6c, 0x53, 0xff, 0xaf, 0x6e, 0x2f, 0x76, 0x46, 0xc1, 0x24, 0xe4, 0x6d, 0xc8, 0xc3, 0xdd, 0xc1, - 0x00, 0x05, 0x4a, 0xe7, 0x1b, 0x6d, 0x78, 0x12, 0x3d, 0xc9, 0xc3, 0xf6, 0x79, 0x69, 0xaf, 0x46, 0x7d, 0xae, 0x63, - 0x9b, 0xda, 0xb6, 0x49, 0x4d, 0x49, 0x73, 0x70, 0x05, 0x96, 0x18, 0x17, 0x34, 0xad, 0xe4, 0x11, 0x4b, 0x2c, 0xc7, - 0xa4, 0xca, 0xad, 0xe4, 0x29, 0xa7, 0x8c, 0xff, 0x10, 0xb4, 0x97, 0x59, 0x1e, 0x0d, 0x97, 0xe5, 0xd1, 0x65, 0xb0, - 0x36, 0xa1, 0xba, 0xb7, 0xa1, 0xfa, 0x62, 0xd6, 0xb4, 0xd4, 0x6a, 0x93, 0x24, 0x91, 0xc6, 0x7b, 0xba, 0x58, 0xd7, - 0x03, 0xe8, 0xce, 0xd4, 0x2e, 0x65, 0x12, 0xc7, 0x38, 0xd9, 0x86, 0xb9, 0xfa, 0xd8, 0x2a, 0xad, 0xcf, 0x5f, 0xd0, - 0xf8, 0xdc, 0x7d, 0x2b, 0x8f, 0x18, 0xb5, 0x18, 0x78, 0x7f, 0x78, 0x2a, 0xc1, 0xc5, 0xa1, 0xb1, 0xb3, 0x3d, 0x4c, - 0x1c, 0x76, 0xec, 0xec, 0xd7, 0x14, 0x4c, 0xcf, 0x81, 0x36, 0xf4, 0xd5, 0xe0, 0xf8, 0xda, 0x3d, 0x77, 0xf0, 0x62, - 0x40, 0x4b, 0xa4, 0xbc, 0x53, 0xe4, 0x88, 0x01, 0x26, 0x5a, 0xf9, 0x9b, 0x5f, 0xe7, 0xf5, 0x87, 0xf8, 0x7a, 0x3c, - 0x10, 0x3b, 0x51, 0x1e, 0x3d, 0x2b, 0x14, 0xa5, 0x44, 0x45, 0x4f, 0xe1, 0x2f, 0x6e, 0xa1, 0x0c, 0xa7, 0x89, 0x4e, - 0x47, 0x45, 0xb7, 0x77, 0x4f, 0x7c, 0x67, 0xff, 0xa6, 0x3a, 0x97, 0xf3, 0x0a, 0x03, 0x5d, 0x08, 0x6c, 0xa0, 0x8c, - 0x8c, 0x05, 0x4a, 0xf1, 0x63, 0xcc, 0x2e, 0x43, 0x94, 0xdc, 0xea, 0x13, 0x3e, 0x70, 0x11, 0x98, 0x3b, 0xa4, 0x49, - 0xc2, 0xe8, 0x51, 0x7b, 0x6e, 0x5a, 0x9e, 0x84, 0x99, 0x9d, 0x27, 0x99, 0x9d, 0x53, 0xc5, 0x85, 0x09, 0x53, 0x35, - 0x28, 0x16, 0x8f, 0xe5, 0xa4, 0xb6, 0x5a, 0x4d, 0x33, 0x27, 0x9a, 0xe9, 0x91, 0x3b, 0x0c, 0x81, 0x6e, 0xd2, 0x0d, - 0x35, 0xfa, 0x4d, 0x54, 0xf1, 0xd1, 0x7a, 0x11, 0x0c, 0xd1, 0xfa, 0x74, 0xd6, 0x46, 0xb9, 0x63, 0x14, 0x25, 0xdf, - 0x17, 0x80, 0xb8, 0xb7, 0xae, 0x28, 0x5d, 0x7d, 0xf2, 0xc7, 0x3f, 0x4c, 0xb5, 0x9e, 0x07, 0x10, 0x23, 0xbe, 0x66, - 0x93, 0x33, 0xa3, 0xf2, 0x48, 0xfc, 0x43, 0x98, 0xb4, 0x80, 0x3b, 0x42, 0x58, 0xb5, 0x71, 0x30, 0x49, 0x4e, 0xe7, - 0x62, 0xa8, 0xef, 0xa2, 0x91, 0x24, 0x94, 0x49, 0x7d, 0x0a, 0x9e, 0x4d, 0x4e, 0xad, 0x8f, 0x0e, 0x09, 0x77, 0xeb, - 0x20, 0x14, 0x62, 0xa6, 0x5a, 0x03, 0xdc, 0x3d, 0xa5, 0xfb, 0x7a, 0xed, 0x8d, 0xd7, 0x2a, 0xe2, 0xfe, 0xfb, 0xc5, - 0xc1, 0xfb, 0xef, 0x78, 0xa9, 0xa9, 0xdf, 0x6f, 0x9c, 0x0d, 0xdb, 0xb7, 0x3c, 0x00, 0x2f, 0x06, 0x78, 0x08, 0x70, - 0x11, 0xf5, 0x56, 0xa7, 0xfd, 0x61, 0x74, 0xe3, 0xeb, 0xca, 0xec, 0x59, 0xd1, 0xf5, 0x3b, 0x3f, 0x78, 0xb7, 0x6f, - 0x21, 0x60, 0x17, 0xdd, 0xff, 0x1f, 0x81, 0x0a, 0x08, 0x86, 0x82, 0xbf, 0x3f, 0x6e, 0x87, 0xb3, 0x23, 0x78, 0x0e, - 0xbd, 0x3e, 0x8e, 0x62, 0xa5, 0x7b, 0x27, 0x4d, 0xb1, 0x57, 0x11, 0x54, 0x99, 0x57, 0xc4, 0xa6, 0x8c, 0xcd, 0x2e, - 0xeb, 0x52, 0xaf, 0xcd, 0x37, 0x18, 0xd0, 0x97, 0x00, 0xc8, 0x48, 0xf5, 0xa6, 0x0c, 0x20, 0xfc, 0xfa, 0x52, 0x2c, - 0x46, 0xf3, 0x7c, 0xa7, 0xb5, 0x6b, 0xf7, 0x29, 0xf4, 0xc3, 0x76, 0x1d, 0x1e, 0x0c, 0xed, 0x09, 0x79, 0x9e, 0x37, - 0xbc, 0xcb, 0xf0, 0x6d, 0x5e, 0x14, 0x9c, 0x06, 0x2f, 0xa3, 0x5a, 0x1a, 0xf2, 0x49, 0x34, 0x06, 0xfa, 0xb4, 0x6f, - 0x29, 0x01, 0xb7, 0x21, 0x31, 0xd8, 0x41, 0x56, 0x7a, 0x7d, 0x24, 0xed, 0x9d, 0xeb, 0x31, 0xbc, 0xd9, 0x6e, 0x71, - 0x91, 0x32, 0x22, 0xb1, 0x63, 0xa0, 0xc9, 0x8d, 0x50, 0xed, 0xed, 0xce, 0x9e, 0x0f, 0xdf, 0xdc, 0x5c, 0xde, 0xdc, - 0xae, 0x8f, 0x43, 0xaa, 0xb1, 0x4e, 0xa7, 0xd6, 0x6a, 0x6c, 0x27, 0x6d, 0x91, 0xef, 0x2d, 0x0b, 0x9b, 0x84, 0x16, - 0xe9, 0x06, 0x96, 0x96, 0x0f, 0x93, 0xaa, 0x55, 0x06, 0x38, 0x91, 0x9a, 0xba, 0x9f, 0x9e, 0x9e, 0x33, 0x25, 0xcb, - 0x03, 0x7a, 0x71, 0xd0, 0x55, 0x21, 0xb6, 0x4b, 0xd7, 0x6f, 0x2f, 0x97, 0x9e, 0xeb, 0x06, 0x60, 0x13, 0x39, 0x30, - 0x90, 0xf2, 0x7f, 0xc7, 0xa2, 0x5e, 0x0e, 0xcb, 0x93, 0x25, 0x82, 0xc2, 0x25, 0xde, 0x75, 0x49, 0x4a, 0xb4, 0x29, - 0x45, 0x68, 0x2e, 0x5e, 0x1f, 0x15, 0xe3, 0x49, 0xdd, 0x59, 0xf3, 0xec, 0x20, 0x12, 0x19, 0x5b, 0x19, 0x1b, 0xcc, - 0x4d, 0x5a, 0x86, 0x00, 0x07, 0x85, 0x64, 0xcb, 0xf5, 0xa6, 0x0b, 0xb0, 0x5d, 0xf2, 0x57, 0xa3, 0x71, 0x9e, 0x2c, - 0xd1, 0x1d, 0x1a, 0xf6, 0xe5, 0x40, 0xc1, 0xe4, 0xe6, 0xca, 0xe9, 0x91, 0x1f, 0xc5, 0x6c, 0xb1, 0x46, 0x86, 0xc1, - 0x82, 0xe9, 0x04, 0x1c, 0x08, 0xb9, 0x57, 0x0e, 0x10, 0x5b, 0x16, 0xf8, 0x30, 0x98, 0x5b, 0x22, 0x9b, 0x3c, 0xda, - 0xd9, 0x3d, 0x55, 0x28, 0xf8, 0xe4, 0xd6, 0x6d, 0x59, 0xf2, 0xca, 0x0f, 0x82, 0x5e, 0xc5, 0xe5, 0x69, 0xbb, 0x68, - 0x8e, 0xc9, 0xd1, 0x77, 0xd9, 0x94, 0xfd, 0x30, 0x8d, 0xc8, 0xc3, 0x43, 0x92, 0xe1, 0x30, 0x0b, 0x82, 0xc5, 0x4e, - 0x78, 0x29, 0x6c, 0x60, 0xac, 0x6d, 0xc2, 0x8e, 0xd4, 0x10, 0xde, 0x21, 0x26, 0xac, 0x99, 0xb3, 0x16, 0x2c, 0x10, - 0x71, 0x39, 0xe8, 0x3e, 0x72, 0xa0, 0x5f, 0xd9, 0x0a, 0x1d, 0xed, 0xe2, 0x6e, 0xf6, 0x23, 0x16, 0xc8, 0xd8, 0x92, - 0x39, 0xe9, 0x1a, 0x7e, 0xcb, 0x50, 0xad, 0xad, 0x67, 0xa3, 0xb3, 0x7b, 0xc3, 0x34, 0xd1, 0x96, 0x25, 0x3b, 0xa2, - 0x64, 0xfd, 0x42, 0x02, 0xb7, 0x50, 0xe5, 0x46, 0xee, 0xad, 0x44, 0x11, 0xc4, 0x14, 0xba, 0x78, 0xdd, 0x2d, 0x8c, - 0x88, 0x37, 0xd3, 0xa9, 0x39, 0x2a, 0x7c, 0x22, 0x63, 0x50, 0x52, 0x92, 0x82, 0xff, 0x67, 0xbd, 0x5f, 0x80, 0x82, - 0xf8, 0xc4, 0xaf, 0x7f, 0x17, 0x44, 0x38, 0xb0, 0xdb, 0x5e, 0xb6, 0xaa, 0x1d, 0x4b, 0x50, 0x1e, 0x15, 0xe6, 0xdc, - 0x40, 0x6a, 0xfd, 0x7b, 0x6e, 0xe3, 0xcd, 0x9f, 0xbf, 0xcb, 0x5c, 0xad, 0xdb, 0xe5, 0xe6, 0xb5, 0x3b, 0xe4, 0x9a, - 0xb1, 0x03, 0xf6, 0xe5, 0xe0, 0xc3, 0x6a, 0x26, 0xdd, 0x02, 0x92, 0x86, 0x4c, 0x2f, 0xdc, 0xae, 0xe8, 0x86, 0x13, - 0x72, 0x07, 0xe4, 0x10, 0x20, 0xd0, 0x66, 0x50, 0xd6, 0xe8, 0x58, 0xef, 0xc3, 0x79, 0x7b, 0x7d, 0xf9, 0xf7, 0xba, - 0x5e, 0xa2, 0x43, 0x9a, 0x9d, 0xc5, 0xa0, 0xff, 0x7e, 0x2b, 0x19, 0xc9, 0xf6, 0xcd, 0xf6, 0xfe, 0x5d, 0x0b, 0x8a, - 0x6b, 0x9a, 0xf6, 0x0f, 0x7e, 0xf9, 0xa2, 0xb7, 0xf0, 0x7a, 0xe7, 0x23, 0xa9, 0x49, 0x53, 0x6e, 0xf8, 0x71, 0xb5, - 0x95, 0xef, 0x4a, 0x66, 0x7c, 0x40, 0x60, 0xc4, 0xc9, 0xea, 0xe2, 0xe9, 0x61, 0xc4, 0x64, 0x3d, 0x6a, 0x18, 0x4e, - 0x6e, 0x6d, 0xc6, 0xb4, 0x6a, 0x21, 0x32, 0xc0, 0x25, 0x1a, 0x95, 0x28, 0x12, 0x25, 0x31, 0x40, 0x70, 0x6f, 0x7d, - 0x9e, 0xa0, 0x2d, 0x6a, 0xd6, 0x0e, 0xd4, 0x76, 0x56, 0x36, 0x27, 0x01, 0xa3, 0xcd, 0x1c, 0xd3, 0x6a, 0x2e, 0x42, - 0xe7, 0xee, 0x34, 0x88, 0x0e, 0xbd, 0x25, 0xba, 0x94, 0xb9, 0x62, 0xdf, 0xb4, 0xac, 0x2d, 0x03, 0xf2, 0x49, 0xd4, - 0x46, 0x1d, 0x24, 0x58, 0xe5, 0x54, 0x6c, 0x26, 0xf6, 0x8d, 0xa1, 0x2d, 0xdc, 0x81, 0xbe, 0x81, 0x1e, 0xac, 0xf1, - 0x92, 0xdd, 0xe4, 0xed, 0x53, 0xca, 0x0b, 0x8b, 0x49, 0xf7, 0x3b, 0xa9, 0x1e, 0xdb, 0x5b, 0x03, 0xa2, 0x50, 0x8c, - 0x77, 0x0f, 0x09, 0x56, 0x1e, 0xbd, 0x0d, 0x38, 0xb6, 0x4a, 0xaf, 0x71, 0x55, 0x3d, 0x31, 0x26, 0x78, 0x58, 0xca, - 0x27, 0xdf, 0x3f, 0x79, 0x35, 0xee, 0x1a, 0xc6, 0x4b, 0x8b, 0x5b, 0x10, 0x54, 0x30, 0x7b, 0x8b, 0x59, 0xfc, 0xd2, - 0xfc, 0xbe, 0x7b, 0xe0, 0xc6, 0xce, 0x21, 0x37, 0x6f, 0x70, 0xf7, 0x5a, 0xdc, 0xa7, 0xce, 0x67, 0xf5, 0xec, 0xd3, - 0xe9, 0x6a, 0x6b, 0x14, 0x7d, 0x3b, 0x03, 0xed, 0x11, 0xe9, 0xac, 0x01, 0x98, 0x04, 0x28, 0x4b, 0x32, 0xa0, 0x86, - 0x05, 0x5e, 0x2e, 0xad, 0xba, 0x13, 0xd4, 0x54, 0x7b, 0xb6, 0x29, 0x9f, 0x0b, 0x6b, 0x2c, 0xbe, 0x58, 0xba, 0x4e, - 0x53, 0xc3, 0x14, 0xb5, 0xae, 0x5d, 0xf3, 0xf7, 0x6f, 0x65, 0x09, 0x34, 0x4c, 0xe5, 0x8a, 0xfd, 0x1a, 0x55, 0x43, - 0xf0, 0x29, 0x2c, 0xa2, 0x84, 0x00, 0xcf, 0x62, 0x12, 0xa8, 0x5a, 0x3f, 0xb4, 0xbd, 0xdf, 0xbb, 0x63, 0xeb, 0x64, - 0x3a, 0xb8, 0x6b, 0x40, 0x96, 0x99, 0xf3, 0xce, 0x99, 0x96, 0xa1, 0x9b, 0xc6, 0x45, 0x48, 0xd9, 0x4f, 0x5f, 0xa0, - 0x4e, 0x96, 0xdb, 0xec, 0x51, 0xd0, 0x58, 0x0e, 0x91, 0x14, 0xb9, 0x20, 0xc5, 0xbf, 0x0b, 0x47, 0x3c, 0x46, 0x6a, - 0x9d, 0xa9, 0x65, 0x8c, 0xa6, 0xff, 0x16, 0xd6, 0x82, 0xa5, 0xdd, 0x7b, 0x96, 0xc1, 0x8f, 0x93, 0x01, 0xd5, 0x3a, - 0x77, 0x52, 0x26, 0x9b, 0x25, 0x8c, 0x0c, 0xed, 0x8e, 0x5a, 0xfd, 0xf4, 0x6b, 0xbd, 0x5d, 0x9a, 0xbd, 0x34, 0xcd, - 0x2f, 0xa2, 0x85, 0x81, 0x2c, 0x01, 0x17, 0x0b, 0x4a, 0x3b, 0x27, 0xd5, 0xbf, 0xf7, 0xcd, 0xf7, 0xc4, 0xf7, 0xc2, - 0x5f, 0x66, 0x3e, 0x8f, 0x7c, 0xca, 0x2b, 0x3f, 0x40, 0x9e, 0x4f, 0xee, 0xad, 0x16, 0x0c, 0x23, 0x98, 0x88, 0xac, - 0x5c, 0x81, 0x80, 0x45, 0x91, 0x3c, 0x50, 0x01, 0x89, 0x88, 0x2b, 0xdb, 0x21, 0xad, 0x66, 0xbd, 0x9b, 0x01, 0x85, - 0x01, 0xd7, 0xfe, 0x42, 0xe3, 0x9c, 0x2e, 0xf6, 0xd6, 0x51, 0x51, 0xe9, 0x58, 0x1a, 0xfd, 0x11, 0x98, 0x18, 0x51, - 0xc9, 0xe9, 0xa8, 0x38, 0xb3, 0x18, 0xed, 0x2b, 0x3a, 0x8b, 0x19, 0xc8, 0x58, 0xa9, 0x29, 0x5b, 0xf9, 0x0d, 0x30, - 0xbb, 0x3d, 0x97, 0x34, 0xf5, 0x18, 0x0e, 0xe4, 0x05, 0x44, 0x0d, 0xac, 0x68, 0x03, 0x9d, 0xda, 0x6f, 0x08, 0xcf, - 0x1b, 0x96, 0x47, 0x80, 0x20, 0x28, 0xdf, 0x41, 0xd8, 0x9f, 0xd8, 0xbe, 0x72, 0x35, 0xc3, 0x29, 0xc3, 0xf4, 0x19, - 0x87, 0x86, 0xfa, 0x14, 0xfc, 0x04, 0x6c, 0xa2, 0xab, 0x11, 0x20, 0xdf, 0x24, 0x84, 0x1e, 0x04, 0xfd, 0x2b, 0x8f, - 0x48, 0x7f, 0xdd, 0xd4, 0xea, 0x2b, 0x98, 0xe2, 0xa8, 0x4c, 0xd6, 0x6d, 0x6a, 0x5b, 0xbd, 0xb2, 0x65, 0x5c, 0xd7, - 0x80, 0x3a, 0x2d, 0x9d, 0xe3, 0x0c, 0x27, 0x0d, 0xf1, 0xbf, 0x06, 0x86, 0x3f, 0xa8, 0xdd, 0x0e, 0xa3, 0x0f, 0xfd, - 0xc6, 0x8c, 0x79, 0x87, 0x70, 0x78, 0x3c, 0x31, 0x8d, 0xdc, 0x9f, 0x0b, 0x4c, 0x87, 0x96, 0xf8, 0x23, 0x8d, 0x38, - 0xe9, 0x83, 0xd2, 0x8b, 0xd5, 0xa1, 0x32, 0xfe, 0xdb, 0xb8, 0x1f, 0xbe, 0x6d, 0xb3, 0x8a, 0xe1, 0xc9, 0x88, 0x02, - 0xb6, 0x1a, 0xb3, 0x8e, 0x4f, 0x8e, 0xd6, 0xe3, 0x98, 0xdb, 0x80, 0xa8, 0x71, 0xbd, 0xa9, 0xda, 0x2c, 0x52, 0xb1, - 0xe5, 0x96, 0x3d, 0x1f, 0xcc, 0xa8, 0x7c, 0xfc, 0xf3, 0x32, 0x15, 0x82, 0x00, 0x55, 0xe2, 0x43, 0x34, 0xd0, 0xc5, - 0x6e, 0x27, 0x68, 0xe1, 0xb7, 0x96, 0xd2, 0x4a, 0xe6, 0xc1, 0x6a, 0xee, 0x90, 0x80, 0x8e, 0xaa, 0x01, 0xc3, 0xa7, - 0x68, 0xb2, 0xab, 0xc9, 0x31, 0x42, 0x01, 0x4d, 0xce, 0x92, 0x86, 0x93, 0x61, 0xbf, 0x2d, 0x4e, 0x7f, 0x9d, 0xf3, - 0x51, 0xb3, 0x21, 0x52, 0xdf, 0x8e, 0x89, 0x98, 0x7e, 0xc7, 0x57, 0x59, 0x19, 0x1b, 0xa1, 0x78, 0x33, 0x88, 0x8d, - 0x21, 0xc9, 0x1b, 0x05, 0x25, 0x42, 0x24, 0xbb, 0x38, 0x11, 0x66, 0xf3, 0x7e, 0xa5, 0xf0, 0xf4, 0x15, 0xa1, 0xd4, - 0x1c, 0x23, 0x8d, 0x0e, 0xb6, 0x74, 0xc2, 0xda, 0xb4, 0x7d, 0x5c, 0x7d, 0x81, 0x41, 0x87, 0xcf, 0x1c, 0xf0, 0x02, - 0xe0, 0xc6, 0xb0, 0x0a, 0x60, 0xad, 0x31, 0x77, 0x0c, 0xb7, 0x65, 0x7c, 0x62, 0x2d, 0x73, 0x40, 0xff, 0x98, 0xc8, - 0x72, 0x43, 0x7b, 0x0e, 0x41, 0xc1, 0xb4, 0x1d, 0x58, 0xa2, 0xf2, 0xef, 0xb4, 0x29, 0x76, 0x55, 0x31, 0x31, 0x0f, - 0x84, 0xcb, 0x12, 0x09, 0x95, 0xaf, 0x7b, 0xd7, 0x63, 0x06, 0xf8, 0x88, 0xa8, 0x19, 0x54, 0xbc, 0xce, 0x4d, 0x7e, - 0x55, 0x3f, 0xbf, 0x04, 0xec, 0x75, 0xf6, 0xba, 0xfe, 0xf0, 0xba, 0x7a, 0xfa, 0x93, 0x52, 0x00, 0xf4, 0x5c, 0xd8, - 0x95, 0x61, 0x26, 0x0b, 0x9b, 0xc8, 0xf0, 0x73, 0xbd, 0x84, 0xf2, 0xb4, 0x99, 0x03, 0x42, 0x38, 0xc7, 0xf9, 0xe4, - 0xfa, 0x74, 0x95, 0xb9, 0x09, 0xa4, 0x08, 0xb8, 0x09, 0x20, 0xf3, 0xfe, 0x08, 0x67, 0xce, 0x07, 0x04, 0xe2, 0x5d, - 0x5c, 0x9b, 0x1c, 0x3d, 0x0e, 0x92, 0x98, 0xdd, 0x4f, 0x3d, 0x2a, 0x88, 0xcb, 0x68, 0x01, 0x0d, 0x5b, 0x53, 0x76, - 0x2d, 0x58, 0xee, 0x08, 0x1d, 0x36, 0x84, 0x99, 0x42, 0x57, 0x89, 0xfc, 0x87, 0x47, 0x4b, 0xaa, 0xe8, 0xb1, 0x3b, - 0x7a, 0xb6, 0x22, 0xca, 0x70, 0x52, 0x47, 0x42, 0x82, 0xf0, 0x85, 0xa8, 0x81, 0x7e, 0xc0, 0xc6, 0xa8, 0x52, 0xe2, - 0x12, 0xdb, 0x12, 0xe8, 0x3b, 0x09, 0xc2, 0xb2, 0x53, 0x1a, 0x86, 0xe6, 0x90, 0xc3, 0x48, 0x14, 0x41, 0x29, 0xfc, - 0x02, 0x25, 0xcf, 0x34, 0x94, 0x80, 0x32, 0x75, 0x60, 0x47, 0x0d, 0x55, 0x89, 0x09, 0x75, 0x7a, 0x7a, 0x10, 0xdd, - 0xbb, 0x0c, 0x34, 0x4d, 0x07, 0xa7, 0x1d, 0x2a, 0xc6, 0xd2, 0x98, 0xea, 0x60, 0x3b, 0x2a, 0x04, 0x47, 0x3a, 0x1e, - 0x32, 0x0a, 0x4e, 0x6e, 0xdf, 0xe1, 0xb2, 0xe1, 0xd3, 0xed, 0xa7, 0x4a, 0x8c, 0x8e, 0x9e, 0xac, 0xce, 0xa5, 0xd5, - 0xf3, 0x6c, 0xcc, 0x24, 0x48, 0x9f, 0xc0, 0xa1, 0x52, 0xf8, 0x32, 0x03, 0xd3, 0x22, 0x8f, 0xb7, 0x65, 0xb4, 0x38, - 0x85, 0x92, 0xab, 0x6e, 0x1f, 0xe9, 0x36, 0xdf, 0xce, 0xa4, 0xdb, 0x6f, 0xa7, 0xc1, 0x51, 0xd6, 0xcc, 0xfa, 0x42, - 0xf9, 0xbc, 0x52, 0xaa, 0xed, 0x5b, 0xf9, 0x49, 0xa2, 0x83, 0x63, 0x0d, 0xd5, 0x2a, 0x2c, 0xf1, 0x93, 0x81, 0xd5, - 0x6b, 0x48, 0xb5, 0x91, 0x8a, 0x61, 0x07, 0x9e, 0x8f, 0x3c, 0x9e, 0xbb, 0xae, 0x34, 0xe3, 0xca, 0x30, 0xb3, 0x49, - 0x25, 0xc6, 0xf7, 0xc3, 0x63, 0x0f, 0xed, 0x99, 0xf6, 0xf9, 0x74, 0xf8, 0x12, 0xe8, 0x74, 0x20, 0x9a, 0x80, 0x81, - 0x39, 0x84, 0x32, 0x16, 0x68, 0x6c, 0x2c, 0x66, 0x51, 0x1e, 0x95, 0x29, 0x4d, 0x95, 0xc6, 0x30, 0x86, 0xda, 0x00, - 0xae, 0x6e, 0xd7, 0x4c, 0x4a, 0x46, 0x49, 0x77, 0x29, 0x0d, 0x14, 0xd3, 0x31, 0x8c, 0x15, 0x9e, 0x29, 0x19, 0x2e, - 0x0a, 0x71, 0x1a, 0xe0, 0xcb, 0x8b, 0xff, 0xf7, 0xaf, 0xc0, 0xa8, 0xb9, 0xed, 0x91, 0xac, 0xd9, 0xec, 0x68, 0x4b, - 0x2b, 0x3c, 0x4f, 0xe7, 0xcb, 0x17, 0x29, 0xeb, 0x52, 0x2d, 0x8a, 0xd3, 0xe8, 0x28, 0x23, 0x4a, 0xfb, 0x76, 0xf7, - 0x97, 0xba, 0x33, 0x8c, 0x98, 0x2b, 0xdf, 0xf8, 0x3d, 0xe5, 0x5a, 0xf2, 0x6e, 0xb7, 0x8c, 0xac, 0x4a, 0x31, 0xe1, - 0x43, 0xe5, 0x1a, 0x5e, 0x69, 0xfd, 0x07, 0xf9, 0x4f, 0xb9, 0xaa, 0x6d, 0x7f, 0x0c, 0xeb, 0x95, 0x6c, 0x4e, 0xb4, - 0xde, 0x3c, 0xe3, 0x88, 0xb7, 0x3d, 0xc6, 0xfd, 0x25, 0x85, 0x63, 0x69, 0xfc, 0xae, 0xea, 0x64, 0x37, 0x3f, 0xb9, - 0x5c, 0x90, 0xb4, 0x98, 0x74, 0xeb, 0xad, 0xca, 0x7e, 0xe6, 0xab, 0xf7, 0xfb, 0xb3, 0x87, 0x3b, 0x26, 0x41, 0xc2, - 0x6d, 0x43, 0x3e, 0x0d, 0x22, 0xbd, 0x6d, 0x46, 0x47, 0x69, 0xf2, 0xca, 0x99, 0x4d, 0x08, 0x84, 0xe3, 0x8d, 0xe9, - 0x01, 0x26, 0x3b, 0x93, 0xd2, 0xcb, 0xfe, 0x67, 0x76, 0xe5, 0xda, 0xd4, 0xc5, 0x5d, 0xb1, 0xc5, 0x83, 0xe4, 0xd7, - 0x43, 0x7c, 0x38, 0x86, 0x37, 0x9f, 0xe3, 0x77, 0xc8, 0x3f, 0xea, 0xb8, 0x0c, 0x0c, 0x4c, 0xac, 0x1c, 0xfb, 0x4e, - 0x78, 0xd9, 0xdf, 0x12, 0x6b, 0x50, 0x56, 0x69, 0x8a, 0x21, 0x18, 0xc4, 0x79, 0x1d, 0x00, 0xc8, 0x95, 0x0d, 0x62, - 0x9b, 0x27, 0xb2, 0xe5, 0xab, 0x60, 0xf1, 0xce, 0xf1, 0xd1, 0x0b, 0x6e, 0x4a, 0x7c, 0xaa, 0xbc, 0x3d, 0x63, 0x0c, - 0x70, 0x0b, 0xca, 0xd3, 0xb1, 0x83, 0x19, 0x31, 0x47, 0x42, 0xed, 0x8a, 0x4a, 0x2c, 0x49, 0x1d, 0x2a, 0x14, 0xcd, - 0xea, 0x82, 0x91, 0x89, 0xe4, 0xb3, 0x35, 0x55, 0x82, 0x81, 0xd4, 0x41, 0x7b, 0xf6, 0x2c, 0x4a, 0x9a, 0x7d, 0x1e, - 0x9a, 0x6c, 0x92, 0x3b, 0x7e, 0x09, 0xa6, 0x3f, 0xf8, 0x59, 0x28, 0xe9, 0x73, 0x6f, 0x62, 0x21, 0x7f, 0xb7, 0x95, - 0xf5, 0x27, 0xec, 0x1d, 0xfe, 0x26, 0x21, 0x7c, 0x39, 0x85, 0xd5, 0x24, 0x61, 0x59, 0xb8, 0xf0, 0x76, 0x49, 0x80, - 0x3c, 0x65, 0x69, 0x57, 0x83, 0x03, 0x85, 0x3e, 0x14, 0x94, 0x2c, 0x96, 0xb1, 0x12, 0x33, 0xc3, 0x22, 0xa6, 0xe4, - 0x5e, 0xf4, 0x35, 0xf3, 0xbe, 0xf9, 0x3a, 0x85, 0x47, 0x06, 0x4f, 0xe5, 0xa6, 0x6d, 0x5b, 0x88, 0x0e, 0x18, 0x9a, - 0xe9, 0x4f, 0x70, 0x40, 0xbb, 0x7f, 0xdd, 0xa5, 0xa7, 0x1c, 0xf8, 0xec, 0x39, 0x0e, 0xd6, 0x56, 0x9e, 0xa5, 0x9c, - 0x35, 0x54, 0xf7, 0x39, 0x05, 0x3f, 0x17, 0xef, 0x10, 0x57, 0x26, 0xc1, 0xd3, 0x5d, 0x4c, 0x12, 0x54, 0x9f, 0x82, - 0x21, 0xe9, 0x04, 0x74, 0xb1, 0xc2, 0xea, 0x5a, 0xb3, 0xe5, 0x09, 0xba, 0x98, 0x60, 0x05, 0x63, 0x38, 0x14, 0xf4, - 0xf2, 0x30, 0xb3, 0x1e, 0x56, 0xd3, 0xd3, 0x22, 0x48, 0x22, 0x9d, 0xec, 0xf6, 0x53, 0x92, 0xbd, 0x26, 0x12, 0x40, - 0x3f, 0x37, 0x2b, 0x69, 0x03, 0xe0, 0x41, 0xad, 0x10, 0xb1, 0xef, 0x45, 0xcc, 0x49, 0x2a, 0x55, 0x73, 0x46, 0xb7, - 0x15, 0x02, 0x62, 0x5d, 0xf8, 0x5b, 0x5e, 0xdd, 0x94, 0xfa, 0x53, 0xb0, 0x80, 0xbe, 0xe1, 0x42, 0x02, 0xaf, 0x8d, - 0x8d, 0xf7, 0x8a, 0xc6, 0x1a, 0x5f, 0x02, 0x58, 0x1c, 0x0c, 0xf0, 0xa4, 0xc6, 0x32, 0x2c, 0x01, 0x69, 0x15, 0x0f, - 0x9d, 0x98, 0xb0, 0xf2, 0xb4, 0xe0, 0x98, 0xe5, 0xbb, 0x7f, 0x98, 0xdf, 0xe9, 0xb4, 0x4e, 0x20, 0x31, 0xd3, 0xa9, - 0x76, 0x4b, 0x2f, 0x1f, 0x58, 0xbf, 0xd6, 0x98, 0x25, 0xe2, 0x9e, 0xe4, 0x65, 0xb7, 0x63, 0x15, 0xda, 0x58, 0xc4, - 0x32, 0x9e, 0x29, 0x87, 0x57, 0x53, 0x6f, 0xf3, 0xf0, 0x00, 0x0e, 0xcf, 0xa7, 0x96, 0xfb, 0xeb, 0x00, 0x13, 0x87, - 0x9b, 0x52, 0x28, 0x15, 0xf1, 0x7a, 0x10, 0x20, 0x12, 0xc4, 0x44, 0xbb, 0xc8, 0x50, 0x7a, 0xca, 0x0d, 0x62, 0xb3, - 0x01, 0x25, 0x62, 0x87, 0xb6, 0x8e, 0xd2, 0x1f, 0xc2, 0x57, 0x47, 0xf9, 0x54, 0x99, 0xea, 0xa4, 0xb7, 0x30, 0xcb, - 0xe5, 0x48, 0x35, 0x34, 0x60, 0xd9, 0x71, 0xfb, 0xc9, 0x63, 0x5b, 0x61, 0x78, 0x6e, 0xab, 0xfe, 0x6e, 0x1b, 0xfe, - 0xfe, 0x02, 0x5e, 0x3c, 0xfd, 0xbe, 0xee, 0x6b, 0x6e, 0xd9, 0x90, 0x43, 0x5d, 0xda, 0x8d, 0x88, 0xb8, 0x17, 0x2f, - 0xaf, 0x52, 0x48, 0x01, 0xd2, 0xfc, 0x01, 0x3c, 0x3b, 0xbe, 0x3d, 0xd2, 0x7d, 0x2a, 0x32, 0x41, 0x24, 0xe4, 0xed, - 0x82, 0xb0, 0xe2, 0xb1, 0xa7, 0xb0, 0x69, 0x64, 0x41, 0x9f, 0x4a, 0xe8, 0x12, 0x7e, 0x8a, 0x7c, 0x79, 0x39, 0x17, - 0xfc, 0x18, 0xd2, 0x09, 0x68, 0xb0, 0x3b, 0xeb, 0x45, 0x50, 0x06, 0x39, 0xed, 0x2d, 0xa5, 0x79, 0x27, 0x97, 0x8d, - 0x02, 0xd3, 0x96, 0x85, 0xf6, 0x4b, 0xa3, 0x6e, 0xba, 0x78, 0x6a, 0xa2, 0x10, 0xf0, 0xf0, 0xb0, 0xd9, 0xed, 0xa4, - 0xa1, 0x9c, 0x55, 0x73, 0xef, 0xab, 0x55, 0xe3, 0x8a, 0xe4, 0xe3, 0x61, 0x86, 0x20, 0xa4, 0xdd, 0x8e, 0x9c, 0x1a, - 0xc3, 0x51, 0xd1, 0xbe, 0x48, 0xd6, 0x79, 0xe2, 0x70, 0xdc, 0xcb, 0x27, 0x71, 0xb2, 0x71, 0xac, 0x8b, 0x93, 0x48, - 0x05, 0xbe, 0x58, 0x7d, 0xd5, 0x10, 0x6d, 0xa6, 0xc5, 0xe9, 0x5d, 0x55, 0xa5, 0x6a, 0x0a, 0xb4, 0x93, 0x22, 0x47, - 0x76, 0x33, 0xbb, 0x2b, 0xb6, 0xa1, 0xd0, 0x0c, 0x38, 0x7f, 0xd6, 0x5e, 0xac, 0x47, 0x78, 0xa8, 0xbc, 0xf8, 0x47, - 0xd1, 0x3f, 0x56, 0x3d, 0x91, 0x65, 0x2b, 0xfc, 0xd5, 0x78, 0xbd, 0xb4, 0xf8, 0x37, 0x0f, 0xdc, 0x67, 0xd7, 0xd9, - 0x91, 0xb7, 0xde, 0x9c, 0x8f, 0x57, 0x15, 0x4f, 0x17, 0x89, 0x6f, 0x18, 0x06, 0x70, 0x39, 0xa4, 0x79, 0xb9, 0xdb, - 0x7b, 0x0c, 0x9f, 0x86, 0x80, 0x90, 0x6c, 0xe7, 0xdc, 0x3e, 0x9f, 0x3f, 0x1c, 0x69, 0x33, 0x9c, 0xc9, 0x4b, 0x21, - 0xd9, 0x57, 0x08, 0x00, 0x64, 0xd5, 0x66, 0xa4, 0x63, 0x5d, 0x4d, 0x02, 0x69, 0x32, 0x49, 0xdd, 0x6e, 0x03, 0x5c, - 0x80, 0x54, 0x94, 0x2f, 0xd7, 0x83, 0x15, 0x35, 0xf5, 0xc2, 0x14, 0x5f, 0xee, 0xe5, 0x0b, 0x34, 0xad, 0x69, 0xda, - 0xcb, 0xb9, 0x0c, 0x05, 0xd6, 0xcb, 0x0e, 0x11, 0x1e, 0x64, 0x2b, 0xc6, 0xe3, 0x71, 0xe4, 0xbb, 0xc9, 0x07, 0x94, - 0x1b, 0x2c, 0x2e, 0xf7, 0xea, 0xcb, 0xa9, 0xdd, 0x14, 0xb6, 0x42, 0x9f, 0x61, 0x15, 0x05, 0x73, 0xc0, 0x9b, 0x6b, - 0x7a, 0x3b, 0x9b, 0x0b, 0xb2, 0xd9, 0xc5, 0x67, 0x0b, 0xdb, 0x20, 0x81, 0x78, 0x1c, 0x06, 0x6b, 0x72, 0x88, 0x94, - 0x78, 0x74, 0x4a, 0x53, 0x42, 0x01, 0xc8, 0x00, 0x5e, 0x4c, 0xe2, 0x2d, 0x24, 0xfd, 0xf7, 0xe0, 0x13, 0xec, 0xad, - 0x71, 0xc5, 0xc8, 0x79, 0xfe, 0xe1, 0x74, 0xc0, 0xe9, 0xcf, 0xed, 0x9d, 0xcf, 0x3d, 0x23, 0xa0, 0x46, 0xa9, 0x0f, - 0xe5, 0xc1, 0x7f, 0xd2, 0x15, 0x9d, 0xd6, 0x62, 0xbe, 0x13, 0xb1, 0x4a, 0x85, 0x2d, 0xf7, 0x32, 0xd8, 0xdf, 0xef, - 0x87, 0xe9, 0xff, 0xab, 0x6b, 0x43, 0x55, 0x7e, 0xfe, 0xb7, 0x35, 0xfc, 0x27, 0xbd, 0x0e, 0x4b, 0xcd, 0xfd, 0x6f, - 0x0d, 0x36, 0xfd, 0xf6, 0x1a, 0xea, 0xa1, 0x6d, 0xff, 0xd6, 0x03, 0x88, 0x3a, 0x28, 0x72, 0xb3, 0x27, 0xb2, 0xd2, - 0xaa, 0x73, 0x0f, 0x06, 0xda, 0xc2, 0xff, 0x9f, 0xe5, 0x3d, 0xcb, 0x9e, 0xad, 0x30, 0xb5, 0xf0, 0xf1, 0xfd, 0x8c, - 0x49, 0x00, 0xcb, 0x49, 0x84, 0x36, 0x0e, 0x39, 0xad, 0xfc, 0xb4, 0x46, 0xae, 0x43, 0x5a, 0xb1, 0x56, 0x01, 0xfd, - 0xb2, 0xa4, 0x4f, 0x10, 0xcf, 0x3d, 0x8c, 0xbd, 0x86, 0x92, 0xe0, 0x81, 0x7a, 0xbe, 0x75, 0x94, 0x1f, 0x49, 0xd3, - 0x62, 0x57, 0x4a, 0x7e, 0xe9, 0x9f, 0x3f, 0x66, 0xd9, 0x57, 0x96, 0x1f, 0x88, 0xa1, 0x26, 0xb7, 0xff, 0xc1, 0x42, - 0xda, 0x17, 0x24, 0x31, 0x16, 0xa6, 0x6e, 0x5d, 0x38, 0x9e, 0x38, 0xbd, 0x63, 0x5b, 0xb5, 0x19, 0x84, 0x17, 0x55, - 0x2d, 0x14, 0x67, 0xd7, 0x82, 0x32, 0xa6, 0xf7, 0xe9, 0x4c, 0x13, 0x0c, 0xa8, 0xa5, 0xe4, 0x9d, 0xdf, 0xf0, 0xef, - 0x6c, 0x85, 0x79, 0x57, 0x63, 0xee, 0xde, 0xc0, 0x3e, 0x1a, 0x39, 0x8c, 0xe3, 0x3e, 0x42, 0xa1, 0x6e, 0x70, 0x83, - 0x2f, 0x35, 0x12, 0xdd, 0xb3, 0x65, 0x1a, 0x46, 0x54, 0xf6, 0xbc, 0x05, 0x47, 0xe2, 0x9c, 0x71, 0x09, 0x32, 0xf4, - 0x08, 0x0d, 0xcb, 0x69, 0x78, 0x8b, 0x29, 0x6c, 0x2f, 0xef, 0x18, 0x77, 0x96, 0xad, 0xed, 0x55, 0x9a, 0x21, 0x90, - 0xce, 0x8b, 0xe0, 0xad, 0xe2, 0x49, 0xb8, 0x31, 0x6d, 0xcf, 0xd4, 0x83, 0x5d, 0x7b, 0x49, 0x2f, 0x6a, 0xf3, 0x37, - 0xb2, 0xdb, 0x7b, 0xe9, 0x98, 0x29, 0xcd, 0xeb, 0x9a, 0x2d, 0x5e, 0xbc, 0x20, 0x13, 0x7e, 0x1c, 0x5c, 0x1b, 0xb3, - 0x6e, 0xb7, 0x12, 0x80, 0xcc, 0x89, 0xc6, 0xd5, 0x5c, 0xec, 0x7f, 0xda, 0x1f, 0xa4, 0xf5, 0x60, 0xde, 0x3d, 0xb8, - 0x92, 0x11, 0x9b, 0xbf, 0x33, 0x37, 0x92, 0x7d, 0x93, 0x49, 0x0e, 0xb5, 0xa8, 0xaa, 0xe2, 0xc1, 0xbb, 0x17, 0xc9, - 0xdd, 0xd5, 0xa5, 0x25, 0xa3, 0xde, 0x20, 0x9f, 0xef, 0xd0, 0xcd, 0x3e, 0xac, 0xdb, 0x5a, 0xe3, 0xd4, 0xe2, 0x24, - 0x36, 0x4d, 0xac, 0xc2, 0xac, 0xa6, 0x13, 0xc1, 0xf6, 0xbf, 0xd6, 0xe0, 0x9a, 0x89, 0x3a, 0x14, 0xd6, 0x56, 0x28, - 0x94, 0x82, 0x1f, 0x25, 0x20, 0x61, 0xc6, 0x98, 0x13, 0x70, 0x82, 0x64, 0x4c, 0x27, 0x53, 0xa2, 0x69, 0x28, 0x37, - 0x3f, 0x88, 0x19, 0xbe, 0xcd, 0x28, 0x46, 0x40, 0x72, 0x3f, 0x32, 0x72, 0xc3, 0xc9, 0x92, 0x50, 0x23, 0xee, 0xf6, - 0xc9, 0x2f, 0x70, 0xcc, 0x78, 0x8e, 0xa5, 0xd4, 0xf8, 0x69, 0x7d, 0x7e, 0xcc, 0x7a, 0x3f, 0x5d, 0xff, 0xb0, 0xba, - 0xe7, 0xce, 0x3f, 0x28, 0xe9, 0xd4, 0x5c, 0x43, 0x66, 0x55, 0x00, 0xc8, 0x9b, 0xf2, 0xce, 0xb8, 0x8e, 0xd3, 0x7b, - 0xab, 0x44, 0x04, 0x2e, 0x55, 0xb4, 0xaa, 0x31, 0x82, 0xf9, 0x5e, 0x88, 0x18, 0x27, 0x2b, 0x07, 0xbe, 0xf7, 0x2b, - 0x54, 0x24, 0xe7, 0xe1, 0x73, 0xf6, 0x46, 0x9a, 0x3e, 0x16, 0x4d, 0x26, 0xcf, 0x1d, 0xf1, 0x55, 0x7c, 0x7e, 0x37, - 0x4b, 0x17, 0x9b, 0x6c, 0x0e, 0x52, 0xc1, 0x92, 0x86, 0xba, 0x80, 0xda, 0xd6, 0x62, 0x28, 0xd1, 0x8e, 0xd4, 0xea, - 0x84, 0x2f, 0xa5, 0x80, 0xa5, 0x32, 0x22, 0x67, 0xa8, 0xad, 0xc1, 0xa9, 0xa3, 0x34, 0x71, 0xdd, 0xab, 0x0a, 0xbe, - 0x28, 0xf3, 0xc8, 0x9d, 0x31, 0xfc, 0xd2, 0xc7, 0xeb, 0x90, 0x8c, 0x91, 0x69, 0x36, 0x70, 0x7e, 0x9a, 0x15, 0xeb, - 0x1d, 0x7c, 0x21, 0x74, 0xea, 0xd4, 0x4c, 0xbe, 0x40, 0xdd, 0x0a, 0x4a, 0x32, 0x1c, 0x7c, 0xad, 0x8a, 0x5b, 0xb4, - 0x12, 0xf7, 0x1f, 0x90, 0xf5, 0x49, 0x2b, 0x69, 0xd1, 0x9e, 0x56, 0x56, 0x04, 0xa5, 0x65, 0x52, 0xb5, 0x29, 0x4c, - 0xbf, 0x14, 0x1d, 0xd5, 0xd3, 0xba, 0x7b, 0x3f, 0xe4, 0x76, 0xc9, 0x25, 0xdb, 0x7d, 0x8b, 0x34, 0x34, 0xba, 0xda, - 0x15, 0x80, 0xb4, 0xeb, 0x4d, 0x5f, 0x85, 0xcc, 0x53, 0xd2, 0x94, 0x92, 0x1e, 0x1c, 0xb2, 0x23, 0x34, 0xbf, 0xef, - 0xc6, 0x56, 0x1d, 0xe9, 0x4e, 0x05, 0xfb, 0xce, 0x2f, 0x73, 0xbb, 0x19, 0x9c, 0xc4, 0xe7, 0x36, 0x7e, 0xed, 0x11, - 0x40, 0xb6, 0xa8, 0x84, 0xaf, 0x4d, 0x39, 0x68, 0x97, 0x5f, 0xe2, 0x99, 0x9a, 0x1d, 0x0a, 0xef, 0xf3, 0xd6, 0x69, - 0xba, 0x75, 0x6c, 0x94, 0x4a, 0x1e, 0x7e, 0xa3, 0x42, 0xb6, 0x62, 0x77, 0x56, 0xb8, 0x00, 0x73, 0xfe, 0xaa, 0x20, - 0xea, 0x4a, 0x56, 0xdb, 0x45, 0x8d, 0xc1, 0x06, 0xda, 0x38, 0xd4, 0x2b, 0x44, 0xcc, 0x3b, 0x46, 0x39, 0x42, 0x87, - 0xa4, 0x43, 0x49, 0x27, 0xd3, 0x40, 0x4e, 0xac, 0x3a, 0x24, 0xd8, 0x9f, 0x8e, 0x94, 0x03, 0xf8, 0x9f, 0x4c, 0x91, - 0xe5, 0x9f, 0xea, 0x55, 0xce, 0xd4, 0x29, 0xfe, 0x5c, 0xb2, 0x6b, 0x76, 0x94, 0x5a, 0x4d, 0x35, 0xee, 0x17, 0x4d, - 0x01, 0xa3, 0x52, 0x5e, 0xcb, 0x8e, 0xdc, 0xcc, 0x91, 0x14, 0xff, 0x60, 0xb2, 0xf4, 0xa4, 0x7f, 0x7c, 0xc8, 0xa5, - 0xaf, 0x9c, 0x7b, 0xf5, 0xce, 0x22, 0xa7, 0x2a, 0xdd, 0xfd, 0x34, 0x77, 0x9e, 0xfe, 0xfe, 0x92, 0x9d, 0x1f, 0xfd, - 0xc5, 0x43, 0x74, 0x86, 0xbf, 0x60, 0x43, 0xec, 0xc1, 0xda, 0x65, 0xe1, 0xc9, 0xeb, 0xf3, 0x43, 0xa3, 0x4f, 0x19, - 0x58, 0xf2, 0xee, 0x82, 0x96, 0x40, 0x99, 0xd7, 0x94, 0xa5, 0x5a, 0xdf, 0x17, 0xd3, 0xa7, 0x2b, 0x76, 0xbe, 0x98, - 0x55, 0x5b, 0x6d, 0xdf, 0x97, 0xd5, 0x6d, 0x75, 0xff, 0x72, 0xf6, 0xe1, 0xaf, 0xdb, 0x7b, 0x3e, 0x31, 0x01, 0x08, - 0xec, 0xf4, 0x50, 0xf5, 0x8b, 0x9f, 0xab, 0xb2, 0x98, 0xaa, 0xba, 0x38, 0xab, 0xc6, 0xc5, 0x79, 0x35, 0x3d, 0xfc, - 0x74, 0xc4, 0x0f, 0x3c, 0x12, 0x86, 0xd5, 0x89, 0x06, 0x59, 0x5b, 0xfc, 0xd2, 0xd4, 0x32, 0xcb, 0x27, 0x8a, 0xdd, - 0x4a, 0xad, 0x3f, 0xed, 0xd2, 0xf8, 0xd3, 0x64, 0x79, 0x23, 0x05, 0xbd, 0x52, 0xd1, 0x2e, 0x27, 0xb6, 0xd3, 0x4c, - 0x2c, 0x48, 0x2c, 0x65, 0xa7, 0xbd, 0xb5, 0x0e, 0x39, 0x83, 0x41, 0x6f, 0xbf, 0xe4, 0x1a, 0xcf, 0x22, 0x8c, 0x99, - 0xbc, 0xa1, 0xb7, 0x4c, 0x05, 0x5f, 0xa1, 0x1a, 0x33, 0xeb, 0x3b, 0x51, 0x47, 0x12, 0x0b, 0x82, 0x18, 0xba, 0xd4, - 0x49, 0xed, 0xed, 0xd2, 0xd5, 0xad, 0xab, 0xbe, 0x04, 0x70, 0x2d, 0xd6, 0x94, 0x9e, 0xfa, 0xa2, 0x46, 0x31, 0x3a, - 0x2a, 0x4b, 0x66, 0xaa, 0x84, 0x8a, 0x1e, 0x62, 0x7d, 0xcb, 0xbc, 0xce, 0xca, 0x73, 0x33, 0x4c, 0xd3, 0x2d, 0xcd, - 0x00, 0x5f, 0xd1, 0x85, 0xac, 0xcc, 0x05, 0x6f, 0x29, 0x99, 0xd6, 0x23, 0xe3, 0x54, 0xd3, 0xba, 0x7a, 0x44, 0xf6, - 0xf2, 0x97, 0xb7, 0x40, 0x64, 0x1f, 0xfa, 0xa2, 0xf6, 0x59, 0x94, 0xad, 0x30, 0x89, 0x41, 0xa6, 0x21, 0xe4, 0x28, - 0x0d, 0xd1, 0x88, 0xb3, 0x78, 0xb4, 0xab, 0x20, 0xb1, 0xf1, 0x59, 0x7e, 0xcd, 0x8c, 0xbd, 0x0e, 0x20, 0x16, 0xa8, - 0xb8, 0x2c, 0xbd, 0xe0, 0xff, 0x41, 0x0d, 0xe5, 0xbe, 0xe9, 0x7f, 0xa0, 0x98, 0x14, 0xca, 0xcd, 0xd0, 0x8f, 0x4b, - 0xae, 0x60, 0x13, 0x62, 0xd0, 0x83, 0x15, 0x51, 0x9d, 0xc5, 0xbe, 0x45, 0x9d, 0x40, 0x0a, 0x38, 0x50, 0x9c, 0x41, - 0xe3, 0x44, 0x01, 0x8e, 0x06, 0xad, 0xb5, 0x48, 0x85, 0x50, 0x78, 0x3f, 0xea, 0xaa, 0x75, 0x39, 0xd2, 0xd0, 0x4d, - 0xa4, 0xdf, 0xea, 0xd7, 0x56, 0x94, 0xc1, 0x9c, 0x5f, 0xae, 0xbc, 0xf9, 0xa0, 0xe4, 0xef, 0xdb, 0x3f, 0xa9, 0x0b, - 0x54, 0xf4, 0x0e, 0x1c, 0x46, 0xb4, 0x39, 0x62, 0x6c, 0x61, 0x71, 0x18, 0x5b, 0xea, 0x09, 0xb1, 0xfe, 0x0e, 0x3d, - 0xc2, 0xd9, 0x37, 0x49, 0xad, 0x79, 0x39, 0x99, 0xe5, 0x76, 0x3b, 0xba, 0xdd, 0xf9, 0x99, 0x29, 0xfc, 0xa4, 0xe6, - 0x60, 0x51, 0xef, 0x49, 0xa4, 0x01, 0xba, 0x5e, 0x38, 0x8f, 0xc0, 0xf5, 0x28, 0x49, 0xc1, 0x64, 0x40, 0x13, 0x1a, - 0x3b, 0x62, 0x65, 0xc5, 0x59, 0x1a, 0x8d, 0xce, 0x85, 0xab, 0xa2, 0xfa, 0xfb, 0xcb, 0x62, 0x2e, 0x00, 0x8c, 0x20, - 0xf4, 0xc1, 0x1b, 0xbb, 0x9d, 0x36, 0xbd, 0xda, 0x96, 0x34, 0xc4, 0x11, 0x44, 0x65, 0x41, 0xc5, 0x2e, 0xa8, 0x3a, - 0xda, 0x2f, 0xa8, 0x1c, 0x27, 0xd5, 0x90, 0x9f, 0x7a, 0x65, 0xb9, 0x0b, 0xfe, 0xdc, 0xa3, 0x5a, 0xfd, 0xf3, 0x43, - 0xc3, 0x53, 0xfd, 0x43, 0x98, 0xf7, 0x95, 0xf2, 0x3c, 0x97, 0x7c, 0x6c, 0x12, 0xc9, 0xd5, 0x56, 0x05, 0x1f, 0x1e, - 0x4a, 0x7a, 0x2b, 0x6a, 0x16, 0x58, 0x6f, 0x0f, 0xcf, 0x6b, 0xcf, 0x61, 0xc6, 0x8e, 0xfa, 0x25, 0x51, 0x37, 0x67, - 0xff, 0x0d, 0x06, 0xf6, 0x9b, 0x56, 0x72, 0xae, 0x9b, 0xf5, 0x9e, 0x27, 0xc5, 0x7a, 0x3d, 0xbf, 0xa2, 0x81, 0x8d, - 0x7d, 0xf6, 0x99, 0x3f, 0xa0, 0x61, 0x90, 0x3d, 0x5d, 0x37, 0xe7, 0xb4, 0xce, 0xce, 0xb9, 0x72, 0xd8, 0x69, 0x33, - 0x7e, 0xd2, 0xbd, 0xe5, 0xa0, 0xda, 0x02, 0xf9, 0x9d, 0xfd, 0x84, 0x38, 0x69, 0xf9, 0xf9, 0x69, 0xb4, 0x33, 0x0b, - 0x21, 0x0f, 0xce, 0x76, 0x2b, 0x20, 0xe5, 0x65, 0x76, 0x01, 0x49, 0x73, 0xa1, 0xe7, 0x38, 0x2a, 0x45, 0x82, 0x2f, - 0x03, 0x66, 0xdd, 0x35, 0x02, 0xd3, 0xf5, 0x6e, 0x65, 0xde, 0xc5, 0xaa, 0x06, 0x9d, 0xd7, 0x36, 0x6d, 0xdf, 0x7c, - 0xa5, 0x3b, 0x9e, 0xbe, 0x28, 0x16, 0x3b, 0xac, 0xdc, 0xe5, 0x20, 0x7f, 0xaf, 0x04, 0x1e, 0x05, 0xf0, 0x5e, 0x4c, - 0xd2, 0x4f, 0xf0, 0x74, 0x27, 0x13, 0x98, 0xa8, 0x86, 0xa4, 0x6c, 0x75, 0x77, 0x23, 0x9b, 0x51, 0x35, 0xd0, 0x29, - 0x47, 0x8e, 0x78, 0xf5, 0xb3, 0xf6, 0x98, 0x07, 0x3b, 0xf7, 0xad, 0x17, 0x7e, 0x94, 0x0d, 0x15, 0x96, 0x67, 0x0c, - 0x0d, 0x38, 0x65, 0x58, 0x5c, 0xc6, 0x60, 0x40, 0x6e, 0xae, 0xe3, 0x46, 0x0a, 0xcd, 0x3f, 0x47, 0x3f, 0xa6, 0xa0, - 0x06, 0xea, 0x8d, 0xeb, 0xf1, 0xa1, 0x19, 0xec, 0x97, 0xbf, 0x01, 0x8f, 0x0f, 0x32, 0xa0, 0x9a, 0x85, 0xce, 0x68, - 0xe3, 0x69, 0x9e, 0x7f, 0xd2, 0xb7, 0xb9, 0xe4, 0xfc, 0x47, 0xff, 0x34, 0x1b, 0xa7, 0xce, 0xc9, 0x99, 0x26, 0xc1, - 0x79, 0x0a, 0x5d, 0x9d, 0xfd, 0x7f, 0x97, 0x6c, 0x64, 0x15, 0x2f, 0x9a, 0x47, 0x71, 0x75, 0x81, 0x28, 0xaa, 0xf5, - 0x91, 0x67, 0xed, 0xce, 0x5e, 0xec, 0x7b, 0x38, 0x0c, 0x7a, 0x83, 0x0f, 0x7e, 0xaa, 0xf2, 0x24, 0x66, 0xfd, 0xca, - 0x44, 0xca, 0x25, 0x7e, 0x4a, 0x5d, 0xd9, 0xd7, 0x49, 0xb3, 0x0f, 0x97, 0xa6, 0x34, 0x1c, 0xd8, 0x94, 0x62, 0x8d, - 0x0a, 0xb0, 0x5f, 0x89, 0xd2, 0xb7, 0x76, 0xce, 0xd0, 0x07, 0xff, 0xac, 0x0a, 0x2c, 0x4e, 0xeb, 0x32, 0x40, 0x52, - 0xd7, 0xe3, 0xca, 0x7e, 0x3d, 0x09, 0x88, 0x8b, 0x7c, 0x85, 0x36, 0x47, 0x8c, 0x51, 0x91, 0x0b, 0xd1, 0x41, 0xe6, - 0xaa, 0x62, 0xa2, 0xd6, 0xa7, 0x17, 0xb4, 0xfb, 0x6e, 0x22, 0x2e, 0xd4, 0xd0, 0xf9, 0x57, 0x27, 0x16, 0x94, 0x36, - 0xc7, 0xf6, 0x8e, 0xd0, 0x23, 0x97, 0xf1, 0x11, 0x41, 0x12, 0x5f, 0x4f, 0x61, 0xde, 0x7e, 0xc7, 0x8f, 0xab, 0x08, - 0x20, 0x81, 0x77, 0x8b, 0xb8, 0x19, 0x18, 0x4a, 0x12, 0xa8, 0x9a, 0x5a, 0xeb, 0x01, 0x13, 0xf3, 0x4e, 0x47, 0xe1, - 0x56, 0x54, 0x20, 0xf0, 0x10, 0x99, 0xd8, 0x83, 0x44, 0x56, 0x8f, 0xa2, 0x87, 0x3b, 0xda, 0xe9, 0x4a, 0xa6, 0x68, - 0x04, 0x25, 0xda, 0xf4, 0x90, 0xa4, 0x87, 0x2f, 0x9b, 0x89, 0xde, 0x89, 0x73, 0xd3, 0x1f, 0xf5, 0x5e, 0xcb, 0xfe, - 0x77, 0x5d, 0x47, 0xf6, 0x2e, 0x63, 0x44, 0xcc, 0xe1, 0x51, 0xb6, 0x9e, 0xac, 0x8e, 0xdb, 0x3e, 0xe4, 0xdc, 0x0b, - 0x8a, 0x01, 0x68, 0x6f, 0x0e, 0xdd, 0x77, 0xa5, 0x44, 0xad, 0xeb, 0xd6, 0x43, 0xca, 0x35, 0x12, 0xfd, 0xc5, 0xf7, - 0xe7, 0x77, 0xb5, 0xc9, 0xc9, 0x26, 0x0a, 0x15, 0x4d, 0xf2, 0x18, 0x44, 0x87, 0x97, 0xc6, 0x30, 0xea, 0xc5, 0xc5, - 0x18, 0xb1, 0xa7, 0xd3, 0x28, 0x6e, 0x61, 0x31, 0x5a, 0x65, 0x6f, 0x11, 0x62, 0x5d, 0x3a, 0x35, 0x4c, 0x51, 0xf5, - 0xdf, 0x9f, 0x46, 0xb5, 0x3b, 0x05, 0x11, 0xf8, 0x7a, 0xee, 0x58, 0xb2, 0x0b, 0xa8, 0x97, 0xf3, 0x77, 0xac, 0x68, - 0xd3, 0x69, 0x1f, 0x84, 0x71, 0x8c, 0xcc, 0x7b, 0xf9, 0xb6, 0x08, 0x31, 0x94, 0x12, 0xa4, 0xe0, 0x6b, 0xc7, 0x30, - 0x08, 0x0e, 0xf3, 0xf2, 0x31, 0xb4, 0xff, 0x10, 0xee, 0xc8, 0x8c, 0x31, 0x99, 0xe2, 0xde, 0x00, 0xeb, 0x0d, 0x77, - 0xd8, 0x47, 0x47, 0xbd, 0xd2, 0xe4, 0x4e, 0x12, 0x7b, 0x9a, 0x49, 0x8e, 0xde, 0xed, 0xd2, 0x28, 0x53, 0x3a, 0x7c, - 0x33, 0x89, 0xf8, 0x56, 0x9c, 0x10, 0xa9, 0xba, 0xac, 0xad, 0xae, 0xfd, 0xbe, 0x74, 0x1c, 0xdd, 0xb3, 0x6b, 0xbd, - 0x8f, 0x62, 0x6c, 0xd5, 0x9b, 0x9a, 0x6d, 0xea, 0xa7, 0xa1, 0x40, 0x8e, 0x0e, 0x77, 0xba, 0x95, 0x4c, 0xc7, 0xea, - 0xf2, 0x17, 0x6d, 0x5b, 0xe4, 0x0b, 0x03, 0x98, 0x9e, 0xba, 0xb7, 0x59, 0xed, 0x27, 0x44, 0x89, 0xf4, 0x81, 0x98, - 0x25, 0x3e, 0x4a, 0x01, 0xe3, 0x2b, 0xa7, 0x89, 0x6c, 0xf0, 0xb3, 0xfc, 0x5c, 0xc4, 0xed, 0xae, 0xf1, 0x9c, 0x4f, - 0x00, 0xbd, 0x1f, 0x8f, 0xb3, 0x33, 0x68, 0xe7, 0xdb, 0x74, 0xa6, 0x53, 0x79, 0x31, 0xfd, 0xb3, 0xff, 0xcf, 0xf4, - 0x40, 0xfd, 0x01, 0x24, 0x1a, 0xff, 0xf7, 0x22, 0x93, 0xd7, 0x6a, 0x24, 0x26, 0x07, 0x31, 0xea, 0x1e, 0x14, 0x8b, - 0x68, 0x08, 0xe0, 0x2b, 0x2f, 0x88, 0x1b, 0x1c, 0x1e, 0x15, 0x3e, 0x4d, 0xef, 0x0e, 0xe4, 0x70, 0xa7, 0xe3, 0x49, - 0x5b, 0xdc, 0x57, 0xc9, 0xcd, 0x8c, 0xfd, 0x3e, 0x83, 0x68, 0x18, 0x14, 0x7d, 0x81, 0x41, 0x29, 0xe4, 0xe7, 0x4b, - 0xf1, 0xa5, 0x99, 0xab, 0x2b, 0xa3, 0xa4, 0xb5, 0x82, 0xf5, 0x2a, 0xa4, 0x06, 0x12, 0xef, 0xa5, 0xf0, 0x19, 0xf4, - 0x14, 0x8a, 0xfd, 0xfe, 0xd4, 0x29, 0x27, 0x68, 0x2f, 0xab, 0xd2, 0xa4, 0x57, 0x92, 0xdb, 0x7b, 0x67, 0x1d, 0xfd, - 0x04, 0x28, 0xc7, 0x0f, 0xa2, 0xc5, 0xd7, 0x0e, 0x8b, 0x72, 0xbb, 0x54, 0x75, 0x1c, 0x43, 0xf0, 0xfc, 0xc9, 0xb3, - 0xb0, 0x5d, 0x91, 0x9e, 0xfe, 0x6d, 0xb1, 0xe9, 0xbb, 0x73, 0xab, 0xe1, 0xff, 0xe4, 0xb3, 0x3f, 0xf0, 0x36, 0x3d, - 0xeb, 0xcf, 0xd8, 0x48, 0xe5, 0x5d, 0xc2, 0xe5, 0x36, 0xb1, 0xf9, 0x02, 0x86, 0xe1, 0x71, 0x7b, 0x9e, 0x08, 0x89, - 0xfd, 0xa6, 0x30, 0xb3, 0xc7, 0xb1, 0x68, 0x25, 0xc2, 0xdf, 0xee, 0x46, 0xde, 0xf9, 0x4f, 0x87, 0x25, 0x08, 0xc3, - 0xb9, 0x71, 0xa6, 0xdf, 0x33, 0xda, 0x7f, 0x9a, 0xa7, 0x4f, 0x7f, 0x77, 0xc9, 0xe9, 0x8f, 0xfe, 0x69, 0xf6, 0xbd, - 0x7d, 0x55, 0xa2, 0x77, 0xc0, 0x66, 0xdf, 0x44, 0x8c, 0x9a, 0xbc, 0x9e, 0x53, 0x0e, 0x7a, 0x44, 0x57, 0x33, 0xe1, - 0xe5, 0x09, 0x5c, 0xa0, 0x61, 0x54, 0xe7, 0x3d, 0xcf, 0xc1, 0x0b, 0x65, 0xbb, 0xa3, 0x58, 0x92, 0x68, 0xb3, 0x90, - 0x3b, 0xf4, 0x53, 0x83, 0x28, 0xc1, 0xac, 0xfb, 0x49, 0xb2, 0x47, 0x6d, 0x35, 0x4c, 0xac, 0x52, 0x5d, 0x7c, 0xe7, - 0x5a, 0x26, 0x29, 0xe5, 0x55, 0xbc, 0x53, 0x89, 0xbc, 0xf9, 0x21, 0xcc, 0x98, 0x0d, 0x46, 0x2f, 0x84, 0xb0, 0xdf, - 0x29, 0x02, 0x23, 0x47, 0x15, 0x2c, 0x24, 0x7e, 0xbb, 0x03, 0x24, 0xde, 0xbe, 0x0b, 0xd2, 0x57, 0x12, 0x20, 0x5f, - 0xcb, 0x96, 0x53, 0x9b, 0x9d, 0x1b, 0xe1, 0xb0, 0x47, 0xe9, 0x1b, 0xef, 0x91, 0x6f, 0x64, 0xd2, 0x56, 0xa9, 0x1f, - 0x03, 0xcc, 0xce, 0xd6, 0x61, 0x64, 0xc4, 0x0e, 0xe4, 0x10, 0x53, 0xb1, 0x03, 0x04, 0xb3, 0x0e, 0xfd, 0x1c, 0xf8, - 0x63, 0xd7, 0x0d, 0x40, 0x34, 0x6b, 0x2e, 0x7d, 0x92, 0xb1, 0x9d, 0x1c, 0x8e, 0x4d, 0x04, 0xe3, 0x7d, 0xa9, 0xfb, - 0xac, 0x79, 0x8a, 0x94, 0x6a, 0x89, 0x14, 0x34, 0x20, 0xbd, 0x8a, 0x3b, 0xf7, 0x6c, 0x0e, 0x46, 0x9c, 0xec, 0xef, - 0x4a, 0xa9, 0x3e, 0xdc, 0xb8, 0xcb, 0xa1, 0x71, 0x5e, 0x1e, 0xb0, 0x8b, 0xcd, 0xa0, 0x04, 0xda, 0xe9, 0x34, 0x4f, - 0xd6, 0x1a, 0xcc, 0xb9, 0x26, 0x25, 0x29, 0x0b, 0x9f, 0x90, 0x19, 0xb9, 0xf9, 0xbe, 0xbc, 0xbe, 0xe5, 0xc3, 0x68, - 0x4e, 0x29, 0xd8, 0x2b, 0x7d, 0xd3, 0xa7, 0xfb, 0xba, 0xfc, 0xdc, 0x05, 0xdd, 0xda, 0x41, 0x2b, 0x17, 0x0f, 0xfb, - 0x93, 0x47, 0x02, 0xc8, 0x04, 0xf1, 0xc3, 0x0d, 0xcb, 0xee, 0xbe, 0x4f, 0x60, 0xf6, 0x8d, 0x5f, 0xec, 0xa7, 0x0c, - 0x83, 0x6f, 0xec, 0x66, 0x95, 0x60, 0x39, 0xfc, 0x3f, 0xf7, 0xcf, 0xb6, 0x5e, 0xec, 0x26, 0x87, 0xab, 0xfd, 0xba, - 0x7d, 0x06, 0x18, 0x7b, 0xbf, 0x5c, 0x27, 0x54, 0xc2, 0x48, 0x6d, 0xd1, 0xe4, 0xab, 0xc2, 0x99, 0x3d, 0x9c, 0x4c, - 0xd9, 0x4e, 0xa1, 0x16, 0x69, 0x1c, 0xd7, 0x39, 0x47, 0x5a, 0xa0, 0x8d, 0x65, 0xb1, 0x68, 0x14, 0x09, 0x9d, 0x60, - 0x8b, 0x8d, 0x1c, 0xf7, 0xc3, 0xfa, 0x6c, 0x98, 0xf1, 0x96, 0x28, 0xb4, 0xe0, 0x6c, 0xc4, 0x44, 0x90, 0x51, 0x35, - 0x06, 0xa1, 0x1d, 0x72, 0xb0, 0x00, 0xd5, 0xd0, 0x29, 0x82, 0xe7, 0xc6, 0x9f, 0x16, 0x3f, 0x2e, 0x0c, 0x5e, 0x42, - 0x32, 0x0c, 0x12, 0x40, 0x8a, 0xc9, 0x4a, 0xba, 0x71, 0x6f, 0xb7, 0x70, 0xbc, 0x2f, 0x98, 0x6a, 0xec, 0xa7, 0xdd, - 0xa3, 0x9b, 0x0e, 0xd4, 0x8b, 0x8f, 0x06, 0x86, 0xed, 0x8e, 0x21, 0xf3, 0xca, 0x88, 0xce, 0x44, 0xcf, 0xfb, 0x38, - 0xe9, 0xb1, 0x55, 0x98, 0x23, 0xcc, 0x08, 0xbe, 0x31, 0x99, 0x8d, 0x3c, 0xc2, 0xdd, 0x6e, 0x3f, 0x9a, 0xe3, 0xd8, - 0x1a, 0x7b, 0x85, 0x50, 0xa8, 0x78, 0xcb, 0x74, 0x37, 0xa1, 0x59, 0x87, 0xcd, 0x3d, 0xd4, 0xd9, 0x55, 0x06, 0xfa, - 0x2c, 0xab, 0x04, 0x27, 0xf2, 0xf6, 0xdb, 0xe8, 0x42, 0x03, 0x27, 0x68, 0x6b, 0xa3, 0x87, 0x7f, 0x88, 0xd0, 0xb7, - 0xa0, 0x4e, 0x38, 0x29, 0xdf, 0x19, 0x8f, 0x89, 0x41, 0xd4, 0x38, 0x4e, 0x95, 0x59, 0x4e, 0x4f, 0x76, 0x23, 0x57, - 0x4a, 0xae, 0xb0, 0x9c, 0x59, 0x5a, 0x36, 0x4b, 0x05, 0x78, 0xff, 0x51, 0x17, 0xc7, 0x84, 0x94, 0xab, 0x46, 0x6d, - 0xea, 0x81, 0x86, 0x4f, 0xa3, 0x95, 0x54, 0x56, 0x36, 0xf1, 0x87, 0x1e, 0xee, 0xf4, 0x07, 0xd1, 0xdd, 0x8a, 0x6a, - 0x93, 0xdb, 0xd0, 0x78, 0x42, 0x8f, 0x29, 0xec, 0x83, 0x45, 0xa0, 0xce, 0xa3, 0xf0, 0xf0, 0xf8, 0x3b, 0x26, 0x6f, - 0x24, 0xd1, 0xad, 0xc0, 0xcd, 0xe2, 0x07, 0x2e, 0x58, 0x24, 0x39, 0x5a, 0xc5, 0xd2, 0xbb, 0xd3, 0xb2, 0x35, 0xa9, - 0xfc, 0x84, 0xb6, 0xaf, 0xaf, 0xe5, 0x55, 0x0b, 0xac, 0xc4, 0xec, 0x55, 0x23, 0xf9, 0x45, 0x29, 0x0e, 0xec, 0x80, - 0x69, 0x91, 0x6b, 0x34, 0xcc, 0xd4, 0xb2, 0x79, 0x30, 0xee, 0xe9, 0x36, 0x1c, 0x4a, 0x67, 0x77, 0x7f, 0xa1, 0x09, - 0x0e, 0xa1, 0x29, 0xa9, 0x09, 0x93, 0x7c, 0x3c, 0xb5, 0x71, 0x62, 0x15, 0xb5, 0x60, 0xb2, 0xe5, 0xb8, 0xe5, 0xb5, - 0x3a, 0xa6, 0xea, 0xa5, 0xf7, 0x31, 0x90, 0x24, 0xd3, 0x38, 0xa1, 0x72, 0x70, 0x43, 0xbc, 0x42, 0xc1, 0x69, 0x7b, - 0x1a, 0x27, 0x76, 0x28, 0x6f, 0xff, 0x2a, 0xde, 0x56, 0x68, 0xfe, 0x15, 0x4e, 0xde, 0xcb, 0xf5, 0xbb, 0x6e, 0xb8, - 0x99, 0xd8, 0x0d, 0xbb, 0xfd, 0xab, 0x69, 0xab, 0x54, 0xec, 0xe9, 0xa4, 0xe7, 0x23, 0x1f, 0x00, 0xf8, 0xf3, 0xca, - 0x04, 0xf9, 0x64, 0x98, 0x11, 0xb5, 0x09, 0xc2, 0x4c, 0x65, 0xc4, 0xf8, 0xa6, 0x2a, 0x37, 0xb5, 0x68, 0x45, 0x62, - 0x49, 0x69, 0x1a, 0x67, 0xe7, 0x8e, 0x34, 0x3b, 0xee, 0x8e, 0xd8, 0x6d, 0x89, 0xb9, 0x7e, 0x9a, 0xf4, 0x34, 0x58, - 0x85, 0x22, 0x54, 0x9e, 0x50, 0xae, 0x29, 0x47, 0x7b, 0xd0, 0x8d, 0xba, 0x86, 0x0c, 0x86, 0x54, 0xa1, 0x8c, 0x5e, - 0xec, 0x3c, 0x22, 0x70, 0x54, 0xa1, 0x87, 0x0c, 0xa4, 0xa8, 0x88, 0x66, 0x33, 0x7e, 0x7c, 0xfe, 0x95, 0xa2, 0x2d, - 0xea, 0x06, 0xe1, 0x10, 0x80, 0xac, 0x77, 0x87, 0x43, 0x08, 0x5c, 0xff, 0x0e, 0xcb, 0xd6, 0xa8, 0x51, 0x46, 0x06, - 0x36, 0x64, 0x3d, 0x45, 0xfa, 0x8f, 0x51, 0x5d, 0x91, 0x49, 0xdd, 0xac, 0x50, 0x46, 0x90, 0x41, 0xcc, 0x3b, 0x4a, - 0x9b, 0x6f, 0x86, 0xd1, 0x91, 0x35, 0x8a, 0x30, 0x15, 0xbb, 0x41, 0xe1, 0xaa, 0x3f, 0x48, 0x91, 0x5d, 0x88, 0x38, - 0x05, 0x78, 0x77, 0x6a, 0x48, 0xd4, 0xac, 0xa9, 0x68, 0xf8, 0x18, 0x7a, 0xee, 0xcc, 0xbb, 0x0d, 0x07, 0x12, 0xc2, - 0x22, 0x35, 0xd8, 0x81, 0x68, 0x0b, 0x32, 0x16, 0xe1, 0x8d, 0x48, 0x34, 0xd4, 0x7b, 0x02, 0xf0, 0x6e, 0xdd, 0xa7, - 0xbc, 0x03, 0x80, 0x3e, 0x59, 0x39, 0x91, 0xee, 0x8f, 0x07, 0x72, 0x88, 0xb9, 0xd9, 0x91, 0xba, 0x43, 0x5c, 0x8a, - 0xf3, 0x89, 0x62, 0xbd, 0x20, 0x07, 0x91, 0xa0, 0x15, 0xaf, 0xc9, 0x45, 0x99, 0xb4, 0xf3, 0xae, 0x33, 0xd7, 0xb9, - 0x26, 0x9e, 0xe4, 0xa8, 0x33, 0x51, 0x4c, 0xee, 0x99, 0x7c, 0xad, 0xdb, 0xb0, 0xda, 0x41, 0x9f, 0x10, 0xe3, 0xc9, - 0x58, 0xa6, 0x1e, 0xd9, 0xd9, 0x78, 0x36, 0xe2, 0x50, 0x01, 0x2d, 0x1d, 0xdc, 0x72, 0xd9, 0xac, 0xf9, 0x19, 0x77, - 0xfc, 0xb0, 0x09, 0x1f, 0xad, 0xe2, 0xda, 0xf4, 0xe9, 0x65, 0x90, 0x06, 0xf3, 0xa1, 0xa4, 0xe0, 0x4a, 0xaa, 0xb1, - 0xef, 0x4d, 0x25, 0xb5, 0x7f, 0xb7, 0x99, 0x9a, 0xb5, 0x58, 0xf1, 0x64, 0x5c, 0x04, 0x91, 0xf9, 0xfa, 0xdd, 0xd4, - 0x8c, 0xa3, 0xdd, 0xb4, 0x20, 0x42, 0x5f, 0xe5, 0x62, 0x64, 0x39, 0xfd, 0xa6, 0x89, 0x37, 0x37, 0x84, 0x3e, 0x62, - 0xfa, 0xb3, 0x8d, 0x39, 0x3e, 0x3b, 0xbc, 0x50, 0x43, 0x0f, 0xda, 0x20, 0x22, 0x35, 0x4e, 0x77, 0xb0, 0x48, 0x64, - 0x4b, 0x78, 0x45, 0xd1, 0x8a, 0xb9, 0xfa, 0xe1, 0x90, 0xb1, 0x44, 0x26, 0x88, 0x34, 0xfa, 0xf1, 0xc3, 0x2e, 0x1d, - 0xb6, 0x1e, 0x86, 0xb1, 0x02, 0x5c, 0xe6, 0x25, 0x25, 0x6f, 0xac, 0xe0, 0xb7, 0x9f, 0x03, 0xd3, 0xbc, 0xdf, 0xde, - 0x35, 0xbd, 0x11, 0x2f, 0xd5, 0x8d, 0xd3, 0x3b, 0x14, 0x4a, 0x42, 0x94, 0xd3, 0xc6, 0xc5, 0xc5, 0x9c, 0x3d, 0x0d, - 0x2c, 0xf2, 0x72, 0xc5, 0xd2, 0x2e, 0x7e, 0x0d, 0xa2, 0x61, 0xc5, 0x3b, 0x08, 0xe9, 0x22, 0xbb, 0xce, 0xf0, 0x00, - 0x8d, 0xea, 0xe1, 0x1e, 0x6d, 0xd1, 0x05, 0x04, 0x99, 0x63, 0xf4, 0x68, 0xa0, 0x04, 0x14, 0x7c, 0xc5, 0x09, 0x74, - 0x95, 0xd6, 0xcc, 0xb3, 0x35, 0x32, 0x63, 0x02, 0x84, 0xd3, 0xfa, 0x93, 0x08, 0x2e, 0x21, 0x73, 0xb8, 0x54, 0xd8, - 0x82, 0x8c, 0x5a, 0x29, 0x4e, 0x46, 0x01, 0x4d, 0x9f, 0x88, 0xe3, 0x17, 0xbd, 0x4b, 0x01, 0x38, 0x7a, 0x2c, 0xac, - 0x24, 0xf0, 0x99, 0xc6, 0x15, 0xb3, 0xcb, 0xa0, 0x39, 0xd0, 0xb8, 0xf6, 0xb5, 0xd5, 0x18, 0x8b, 0x8d, 0xd7, 0xdf, - 0x43, 0x84, 0x0d, 0xf6, 0x94, 0x42, 0xac, 0x48, 0x74, 0x80, 0xac, 0x5c, 0x43, 0x27, 0xef, 0xd9, 0xd3, 0xb1, 0xb5, - 0x5c, 0x41, 0x17, 0x3a, 0x92, 0x70, 0xad, 0xc1, 0x66, 0xff, 0x11, 0xe0, 0x4c, 0x43, 0x5a, 0xcf, 0x0c, 0x2b, 0x72, - 0x99, 0x82, 0x1a, 0xf1, 0xaf, 0x53, 0x07, 0x8b, 0x7a, 0x48, 0x17, 0x71, 0x2a, 0xea, 0x99, 0x56, 0x16, 0xe8, 0x84, - 0x3a, 0x52, 0x43, 0x6c, 0x00, 0x05, 0x6f, 0x94, 0x9e, 0x70, 0xfa, 0xdd, 0xa5, 0xe7, 0xa8, 0x2c, 0xb8, 0x0e, 0xcd, - 0xe2, 0x0f, 0x51, 0x6d, 0x3c, 0xfd, 0xf8, 0x60, 0x06, 0x0f, 0xe2, 0xed, 0x59, 0xc0, 0x87, 0x89, 0xb7, 0x63, 0xe7, - 0x79, 0x67, 0x37, 0x01, 0xc1, 0xac, 0x34, 0x11, 0x92, 0x11, 0xe6, 0xce, 0xbd, 0xc3, 0xd6, 0xf8, 0x2b, 0x76, 0x7f, - 0x29, 0x14, 0x06, 0xdb, 0x91, 0x08, 0xf3, 0xb1, 0x18, 0x45, 0xa8, 0xed, 0xe5, 0xd7, 0x2c, 0x19, 0xc9, 0xef, 0xce, - 0x9b, 0x8b, 0xb8, 0x1d, 0xd8, 0xaa, 0x54, 0xa9, 0x1f, 0x10, 0x55, 0xed, 0xf7, 0xb2, 0x61, 0x9b, 0x85, 0x8f, 0x17, - 0x3d, 0x3b, 0xf1, 0xc1, 0x72, 0x3d, 0xc7, 0x92, 0xdf, 0x3f, 0x43, 0x40, 0xcd, 0x66, 0xfb, 0xd5, 0xe2, 0xa0, 0xcf, - 0xb5, 0xf5, 0x1b, 0xb5, 0x81, 0x7e, 0x42, 0x58, 0xe0, 0xfb, 0x79, 0x8d, 0x5c, 0x3c, 0xca, 0xe6, 0xfa, 0x81, 0xdf, - 0x78, 0xb5, 0xc0, 0x3e, 0xbb, 0x33, 0x37, 0x9c, 0x1b, 0xc2, 0xd0, 0xf6, 0x44, 0xe3, 0xfe, 0x89, 0x49, 0x08, 0xaf, - 0xb3, 0x8a, 0x29, 0x9d, 0xc8, 0xac, 0xf2, 0x4f, 0xfa, 0x9d, 0xbb, 0x9b, 0xf9, 0x08, 0x25, 0xda, 0xdf, 0x80, 0xf3, - 0x72, 0xd5, 0x7e, 0x4d, 0xf2, 0x8c, 0x96, 0x1e, 0xb0, 0xa9, 0xa5, 0x9f, 0xeb, 0x95, 0xea, 0x40, 0xe9, 0xbe, 0x03, - 0x09, 0x30, 0x50, 0x87, 0x19, 0xbf, 0x8f, 0xcd, 0x10, 0x6e, 0x4a, 0x30, 0x06, 0x9e, 0xe9, 0x3f, 0x7c, 0x81, 0x83, - 0xb3, 0x92, 0x81, 0x39, 0xa2, 0xe6, 0x15, 0x41, 0xc0, 0xe7, 0x12, 0x54, 0xc8, 0x6e, 0x05, 0xf2, 0xf3, 0xbc, 0x72, - 0xe4, 0x06, 0x90, 0x5b, 0x21, 0xa8, 0xb8, 0x27, 0xcf, 0x5c, 0x1a, 0xd0, 0x03, 0x50, 0xfe, 0xe1, 0x9c, 0x93, 0x84, - 0xfe, 0x26, 0xa0, 0xa8, 0xd1, 0x49, 0x7f, 0xfe, 0xb5, 0x66, 0x64, 0xf2, 0xe7, 0xb1, 0x5f, 0x79, 0xbc, 0xec, 0xe6, - 0x2d, 0xc8, 0x48, 0x1b, 0xdf, 0x86, 0x19, 0x99, 0x81, 0x8e, 0x55, 0x50, 0x5b, 0xf8, 0x42, 0xaa, 0x55, 0x40, 0xae, - 0x2e, 0x42, 0x8b, 0x14, 0xb7, 0x90, 0xd3, 0x9f, 0xb6, 0xb3, 0x90, 0x7f, 0x9a, 0x01, 0x8e, 0x59, 0xf9, 0xcf, 0xc6, - 0x15, 0x45, 0xf6, 0x10, 0x18, 0xcd, 0x8f, 0x2e, 0x15, 0xd4, 0xb4, 0x72, 0x12, 0x7f, 0x02, 0x72, 0x09, 0x12, 0x30, - 0x3e, 0xbf, 0x51, 0x7b, 0xff, 0x9d, 0xce, 0x52, 0x8b, 0xaa, 0x63, 0xa4, 0x9f, 0xfc, 0x1a, 0xf2, 0x1f, 0xe0, 0x47, - 0x1f, 0x91, 0xd2, 0xd9, 0x3c, 0x5b, 0xfd, 0x89, 0xab, 0xd8, 0x65, 0x41, 0x75, 0x02, 0x2a, 0x48, 0x58, 0x05, 0xb5, - 0x06, 0x23, 0xfb, 0x1f, 0x16, 0xae, 0x46, 0x4c, 0xf3, 0xa7, 0x5b, 0xb4, 0x1a, 0xba, 0x57, 0xa0, 0xea, 0x70, 0x03, - 0x44, 0x0e, 0xdd, 0xa3, 0xea, 0x62, 0xc7, 0x99, 0xfe, 0x5b, 0x09, 0xd8, 0x38, 0x73, 0x82, 0xd3, 0xfd, 0x87, 0x97, - 0x2f, 0xd6, 0xf6, 0xa4, 0x5f, 0x32, 0xc3, 0xf8, 0x92, 0xba, 0x78, 0x70, 0x5f, 0xd3, 0xe2, 0x5b, 0xc2, 0xe4, 0xd3, - 0xfc, 0xf3, 0x49, 0xff, 0xea, 0x4b, 0xfe, 0xfc, 0xe8, 0x17, 0xbe, 0x95, 0xaf, 0x79, 0xf6, 0x4d, 0x5a, 0xa3, 0x1d, - 0xf6, 0x7a, 0x88, 0xbb, 0x37, 0xfd, 0xa1, 0x0e, 0xf9, 0x5a, 0xc5, 0xf8, 0xaf, 0x9e, 0xe9, 0xd3, 0x1f, 0x1e, 0x1f, - 0xdc, 0xa4, 0x77, 0x09, 0x39, 0xcd, 0x94, 0x57, 0xe7, 0xd6, 0xbe, 0xc1, 0x12, 0xb6, 0xf5, 0x26, 0xc1, 0xde, 0xa0, - 0x20, 0xd2, 0x48, 0xbb, 0x13, 0x21, 0x02, 0x95, 0x41, 0xae, 0x60, 0xc8, 0xcd, 0x71, 0xd4, 0xf0, 0x3f, 0x71, 0xc0, - 0x28, 0x97, 0x11, 0x55, 0xa5, 0x8a, 0xd3, 0xd1, 0xc1, 0x4c, 0xc0, 0x29, 0x44, 0x18, 0x21, 0xf9, 0x5e, 0xcd, 0x62, - 0x81, 0xce, 0x24, 0x0d, 0x3e, 0x7e, 0x27, 0x1d, 0x4b, 0x56, 0x5c, 0x5b, 0xe6, 0xeb, 0xfd, 0x27, 0xd9, 0x58, 0xf9, - 0x28, 0x90, 0x59, 0x79, 0x87, 0x02, 0xd5, 0x21, 0x05, 0x93, 0x8b, 0xd4, 0xf9, 0x88, 0x99, 0xf3, 0x91, 0x4a, 0x2f, - 0xd8, 0xaf, 0xe6, 0x06, 0xda, 0x8d, 0x3d, 0x1c, 0xec, 0x5b, 0x65, 0x6c, 0xc2, 0x90, 0xe4, 0x26, 0xbf, 0x46, 0x06, - 0xe5, 0xe4, 0xa6, 0x0d, 0x5b, 0xe0, 0x9b, 0x5f, 0x9f, 0xa1, 0x49, 0x0a, 0x9d, 0x8d, 0x7c, 0xcf, 0xc8, 0x83, 0xeb, - 0xfb, 0xb3, 0xd7, 0xfe, 0xd1, 0x94, 0x45, 0x13, 0xd6, 0x6e, 0xa9, 0x7d, 0x42, 0x28, 0x05, 0x2a, 0x08, 0x10, 0xa6, - 0xc2, 0x1a, 0x58, 0xd6, 0x21, 0x35, 0x87, 0x9a, 0xae, 0x3f, 0x67, 0x90, 0x23, 0xb5, 0xc3, 0xc4, 0xbe, 0x0d, 0x03, - 0x5f, 0x2b, 0xa5, 0xb7, 0x37, 0x50, 0xa5, 0x16, 0xf6, 0x59, 0x64, 0xa8, 0x33, 0x39, 0x57, 0x1c, 0x81, 0xd7, 0x2d, - 0x35, 0x33, 0x51, 0xe8, 0x2c, 0x1b, 0x69, 0x7e, 0x4a, 0x78, 0x45, 0x7f, 0x55, 0x04, 0x4c, 0x74, 0xd0, 0x99, 0xdc, - 0x9a, 0x8a, 0x02, 0x93, 0x90, 0xaa, 0xba, 0x62, 0xeb, 0x78, 0x0a, 0x84, 0x9f, 0xa7, 0x88, 0xed, 0x1a, 0x9f, 0x87, - 0xa2, 0x3c, 0xc9, 0xfb, 0x34, 0x77, 0x7d, 0xe8, 0x9c, 0x6b, 0x03, 0x91, 0x6c, 0x46, 0x74, 0xe1, 0x87, 0xd7, 0x54, - 0xa7, 0xc5, 0x6d, 0x4b, 0xf7, 0x69, 0x5e, 0x7c, 0xd2, 0xac, 0x4b, 0x2e, 0x7e, 0xf4, 0x97, 0x6c, 0x9b, 0x65, 0x08, - 0x45, 0x2a, 0x53, 0xf0, 0x6a, 0x9f, 0x2f, 0x8a, 0xc9, 0xf6, 0x7b, 0x58, 0xf2, 0xc4, 0x97, 0x41, 0x83, 0x89, 0x7e, - 0x71, 0xe7, 0x11, 0x1c, 0xaf, 0xba, 0xc8, 0x6a, 0x0e, 0x9c, 0xeb, 0x7a, 0x36, 0xe6, 0xb2, 0x35, 0x2e, 0x34, 0x42, - 0xa2, 0xae, 0x1a, 0x79, 0xd9, 0xbb, 0x80, 0x0c, 0x23, 0x29, 0x7b, 0x20, 0xc0, 0x9c, 0x5f, 0x5b, 0x46, 0xc3, 0xb3, - 0x90, 0x7c, 0xdd, 0x74, 0xba, 0xa0, 0x21, 0x54, 0x40, 0x83, 0x9f, 0xbf, 0x97, 0xd0, 0x9e, 0x0a, 0x7b, 0x7d, 0xfa, - 0x0b, 0xcf, 0x4c, 0x5a, 0x51, 0xc6, 0x33, 0x7d, 0x16, 0x4b, 0x9a, 0x27, 0x9d, 0xb1, 0x25, 0xcf, 0xfb, 0x58, 0xbe, - 0x4f, 0xe5, 0x58, 0xee, 0xee, 0x69, 0xba, 0xe4, 0x24, 0x35, 0xc7, 0x4a, 0x67, 0x42, 0x6d, 0x7c, 0x99, 0x4f, 0x22, - 0x12, 0x37, 0x78, 0x8a, 0x81, 0x58, 0xcf, 0x7d, 0x3a, 0x18, 0x4e, 0x15, 0xcd, 0xb7, 0xa7, 0xbb, 0x55, 0xe9, 0x9b, - 0x4d, 0xb5, 0x08, 0x71, 0x79, 0xc8, 0x62, 0xe2, 0xc3, 0x40, 0xd9, 0xd9, 0xa6, 0x8d, 0x9b, 0x04, 0x0f, 0xa4, 0xce, - 0xe5, 0xf4, 0x60, 0xb8, 0x88, 0xbd, 0xce, 0x3c, 0xa4, 0x57, 0x5c, 0xdc, 0x05, 0xe2, 0xbc, 0x42, 0x38, 0xa8, 0x57, - 0x8c, 0x6b, 0xf9, 0xa6, 0xd9, 0xbf, 0x9c, 0x4a, 0xe2, 0x92, 0x87, 0x6b, 0xd0, 0x4a, 0x35, 0x6b, 0x9d, 0x62, 0xab, - 0xa3, 0xf5, 0xf0, 0xdf, 0x37, 0x88, 0xac, 0xd8, 0x7c, 0xe1, 0x5b, 0xf9, 0xca, 0x76, 0x41, 0xc8, 0xec, 0x2f, 0xc7, - 0x17, 0x68, 0x3f, 0xcb, 0xd6, 0xda, 0x8b, 0xd3, 0xee, 0x74, 0xe3, 0x2e, 0xaf, 0x0f, 0xdb, 0x60, 0x7c, 0x85, 0x0e, - 0xdb, 0x05, 0x99, 0x7e, 0x62, 0xbd, 0xbe, 0xa7, 0x12, 0xfe, 0xe1, 0xfa, 0x87, 0xdf, 0xf4, 0xb9, 0x3f, 0xe6, 0x2a, - 0xe2, 0x00, 0x99, 0x97, 0xd4, 0x86, 0x71, 0xcd, 0x62, 0x2f, 0xe8, 0x56, 0x42, 0x7d, 0x6e, 0x9f, 0x01, 0x07, 0x37, - 0x37, 0xbd, 0xa7, 0x56, 0x03, 0x80, 0x45, 0x1c, 0x5d, 0xc3, 0x8e, 0x27, 0xe0, 0x13, 0x4a, 0x05, 0x61, 0x8f, 0x63, - 0x54, 0x29, 0x5d, 0xaa, 0x47, 0x1d, 0x3f, 0x0f, 0xa3, 0x3a, 0x10, 0x20, 0xe0, 0xf1, 0x98, 0xc7, 0x82, 0x44, 0x0d, - 0xea, 0x3c, 0x9a, 0xf2, 0x0a, 0x3e, 0x44, 0x02, 0xf6, 0x5d, 0xaf, 0xef, 0xc6, 0x37, 0xc3, 0x2b, 0x02, 0x5b, 0xf8, - 0x25, 0x8d, 0x6c, 0x23, 0x34, 0x8a, 0x47, 0xb9, 0x75, 0x4d, 0xf4, 0x45, 0x6d, 0xc7, 0xcc, 0x0b, 0x41, 0x56, 0x4f, - 0x78, 0x06, 0x0b, 0xe5, 0x82, 0xe0, 0x0b, 0xab, 0x80, 0xfb, 0x73, 0xa2, 0x1f, 0x83, 0x94, 0x1e, 0x8a, 0xe8, 0x88, - 0xd6, 0x91, 0xa9, 0xc1, 0x71, 0x8f, 0x65, 0x89, 0xe1, 0x3c, 0x42, 0xb0, 0xdb, 0x96, 0x35, 0x22, 0xab, 0xd5, 0x08, - 0x7e, 0xf3, 0x52, 0xd1, 0x3a, 0xa4, 0x24, 0x85, 0x0a, 0xd6, 0xd4, 0xf4, 0x5a, 0x10, 0xa9, 0x45, 0xe7, 0x7f, 0x02, - 0xc4, 0x69, 0x4f, 0x34, 0xad, 0xf6, 0x9c, 0x5a, 0x54, 0x1c, 0xda, 0x46, 0xc2, 0xdc, 0xa5, 0xc0, 0x95, 0x38, 0x70, - 0x00, 0xb1, 0xf4, 0xae, 0x48, 0xe4, 0x3d, 0xb4, 0x3f, 0xb8, 0x42, 0x9a, 0x4e, 0x8d, 0x77, 0x72, 0xca, 0x0d, 0x52, - 0x75, 0x61, 0xe4, 0x34, 0x12, 0x93, 0x2a, 0x27, 0x8c, 0x50, 0xc5, 0xed, 0x5a, 0x2d, 0xe1, 0xd4, 0x1b, 0xb7, 0x03, - 0x4f, 0x01, 0xef, 0x92, 0x21, 0x6c, 0xaf, 0x35, 0xe2, 0xcc, 0x18, 0xba, 0x7c, 0xf3, 0x9f, 0xba, 0x9d, 0x53, 0xfb, - 0x65, 0x70, 0x45, 0x87, 0x81, 0xaf, 0xc6, 0xab, 0x30, 0x79, 0x4a, 0x61, 0x5a, 0xfd, 0xa5, 0xeb, 0x33, 0x18, 0xf2, - 0x27, 0xf9, 0x4c, 0x43, 0x22, 0x48, 0xf1, 0x36, 0x7c, 0x78, 0x3f, 0xda, 0x06, 0xe4, 0x21, 0x70, 0x98, 0x8f, 0xc1, - 0xef, 0x44, 0xf6, 0x41, 0x6b, 0x44, 0x77, 0x8a, 0xb0, 0x20, 0x35, 0x77, 0xf8, 0xe8, 0x90, 0x6f, 0x1e, 0xea, 0x91, - 0x5c, 0x5e, 0x83, 0x00, 0x0a, 0x56, 0xd3, 0xc2, 0x9e, 0x3e, 0xb7, 0x79, 0xc6, 0x7b, 0xd0, 0x44, 0x47, 0xe1, 0x10, - 0x13, 0x3c, 0xe7, 0x0c, 0xed, 0x68, 0x27, 0x87, 0xe1, 0x31, 0xf4, 0x4a, 0x61, 0xee, 0x3f, 0x23, 0x72, 0xc3, 0xf9, - 0xb9, 0x9e, 0x31, 0x8d, 0x72, 0x9e, 0xb2, 0xaf, 0x57, 0x8d, 0x1e, 0xff, 0xb1, 0x03, 0x70, 0xff, 0xf4, 0xd7, 0x84, - 0xe4, 0x4f, 0x75, 0x0a, 0xdf, 0x57, 0x96, 0x84, 0xb7, 0x02, 0xff, 0x06, 0xaf, 0x59, 0x62, 0x70, 0x98, 0x82, 0x42, - 0xf9, 0x6b, 0x0b, 0x42, 0x6e, 0x73, 0x72, 0x6d, 0x0e, 0x97, 0xcf, 0x99, 0xe4, 0x0b, 0xb6, 0x09, 0xb5, 0x3e, 0x2b, - 0x70, 0xf0, 0xa6, 0xc9, 0x72, 0x3a, 0x8e, 0x9c, 0xf9, 0xad, 0xd8, 0x5c, 0x37, 0x26, 0x79, 0x14, 0x29, 0xfa, 0xcd, - 0xf4, 0x46, 0xde, 0x78, 0xb3, 0x10, 0x6d, 0x87, 0x5e, 0x9a, 0xd6, 0x8f, 0x2f, 0x08, 0x3f, 0x0d, 0xcb, 0x89, 0xd9, - 0x1f, 0x7c, 0x2f, 0xb0, 0xba, 0xc4, 0xc5, 0x80, 0x0c, 0xc3, 0xee, 0x58, 0xb0, 0x0e, 0x57, 0xd7, 0x68, 0xca, 0xb8, - 0x1c, 0xa4, 0x8a, 0x96, 0xee, 0x08, 0xa1, 0x9b, 0xb8, 0x28, 0xed, 0x4c, 0xd9, 0x7b, 0xf9, 0x3b, 0xb4, 0xfa, 0xb5, - 0x2a, 0xde, 0x5d, 0x12, 0x3e, 0xf8, 0xee, 0x5d, 0xd0, 0xdf, 0x74, 0xc8, 0xc6, 0xba, 0x5f, 0x3e, 0xbe, 0x54, 0x4d, - 0x16, 0x46, 0x83, 0x99, 0x4f, 0x79, 0x73, 0x76, 0x57, 0x65, 0x94, 0xd4, 0x35, 0x14, 0x46, 0x62, 0x8f, 0x1c, 0xe7, - 0xbd, 0x33, 0x59, 0xd7, 0xbb, 0x8e, 0x55, 0xe9, 0xf2, 0xb3, 0x04, 0x8b, 0xd6, 0x72, 0xef, 0xfe, 0x2c, 0xd5, 0xa7, - 0x50, 0x03, 0x69, 0xb3, 0x81, 0x0e, 0xdd, 0x46, 0x9b, 0x68, 0x9c, 0x49, 0xa0, 0xb4, 0x87, 0x2b, 0x2f, 0x6a, 0xfa, - 0x2c, 0x26, 0xd0, 0xba, 0x9d, 0x2d, 0x74, 0xb6, 0x0b, 0x4a, 0x83, 0xdb, 0x3f, 0xee, 0x76, 0xe9, 0xcc, 0xe0, 0xe3, - 0xfd, 0x83, 0x0c, 0xcb, 0xff, 0x1b, 0x55, 0xec, 0x9e, 0x1c, 0x80, 0x86, 0x35, 0x6f, 0x9b, 0x44, 0x44, 0x48, 0x58, - 0xdc, 0x7c, 0x72, 0xec, 0xfb, 0xc6, 0x97, 0xe8, 0xb9, 0xa1, 0x27, 0xe3, 0xc4, 0xf5, 0x52, 0x9d, 0xb2, 0x1e, 0x89, - 0x01, 0x7f, 0xd2, 0x39, 0x90, 0x68, 0x6b, 0x9a, 0xdd, 0x0e, 0xca, 0x81, 0xdd, 0x9b, 0x03, 0xeb, 0x8f, 0xf9, 0x06, - 0x23, 0x07, 0x2b, 0x9b, 0x3f, 0xb5, 0xb9, 0xed, 0xb4, 0x0e, 0x9f, 0x4d, 0xc6, 0xd2, 0xe3, 0xe1, 0x2b, 0xab, 0x23, - 0xb4, 0x35, 0x92, 0x15, 0x83, 0x6a, 0x6f, 0xf7, 0x63, 0x0f, 0x22, 0x7e, 0xa6, 0xee, 0xde, 0x45, 0xdd, 0xa1, 0xa5, - 0x67, 0xf6, 0xf6, 0xe0, 0xb1, 0x7f, 0x60, 0x8d, 0x43, 0xdd, 0xcb, 0x05, 0x08, 0x4b, 0xdc, 0x51, 0xd6, 0x56, 0x71, - 0x71, 0xfb, 0xe7, 0xd7, 0x0f, 0x9a, 0x83, 0x40, 0xe5, 0x70, 0x30, 0xd1, 0x8b, 0x11, 0xeb, 0xc8, 0xb1, 0x63, 0x18, - 0x23, 0x76, 0x73, 0x80, 0x94, 0x11, 0x23, 0xcd, 0x29, 0xdf, 0x07, 0x63, 0x5c, 0xf4, 0x46, 0xed, 0xc2, 0x86, 0x79, - 0x80, 0x15, 0xee, 0xa4, 0xaa, 0xc3, 0xc2, 0xc4, 0xfc, 0xba, 0xb5, 0x49, 0x72, 0xde, 0x91, 0xf5, 0xa9, 0xd9, 0xbb, - 0x12, 0x84, 0x3e, 0x1f, 0xfe, 0x8d, 0x8a, 0x78, 0xae, 0xb3, 0x87, 0x60, 0x02, 0x7e, 0xac, 0x3a, 0xec, 0x6f, 0xc1, - 0xa7, 0x0d, 0x27, 0xea, 0xe8, 0x93, 0xd1, 0x59, 0xe1, 0x80, 0x5d, 0x6b, 0xfa, 0x50, 0xc6, 0x43, 0x8f, 0x59, 0x18, - 0x2b, 0xd3, 0x5b, 0x15, 0x94, 0x0d, 0x9b, 0xa9, 0x2e, 0xa9, 0x06, 0xaa, 0x4c, 0x26, 0x99, 0x4c, 0xd9, 0x42, 0xce, - 0x00, 0xb6, 0xf7, 0x41, 0x72, 0x85, 0x88, 0x7a, 0x5f, 0x5a, 0x8f, 0xcc, 0x22, 0xae, 0x91, 0x23, 0xda, 0x63, 0x50, - 0x8b, 0x88, 0x77, 0x6a, 0x75, 0x94, 0xe4, 0xa3, 0x2f, 0x1f, 0x82, 0xd0, 0xb5, 0xa4, 0x3f, 0x9b, 0xa1, 0x84, 0x65, - 0x46, 0x2e, 0xdb, 0x4f, 0xdc, 0xbd, 0x3f, 0x8d, 0x7f, 0x9a, 0x08, 0x6d, 0x97, 0x67, 0xeb, 0xc1, 0xc8, 0xb5, 0x34, - 0x95, 0xd7, 0xb8, 0xa5, 0xc6, 0xb8, 0xe0, 0xa7, 0x38, 0xd2, 0xe6, 0x6b, 0xcd, 0xd3, 0x43, 0xdd, 0x7a, 0x1e, 0xb5, - 0x0f, 0xb2, 0xb6, 0x0e, 0xec, 0xc5, 0x42, 0x7b, 0x0a, 0x7b, 0xe7, 0xf8, 0xd0, 0xfd, 0xc5, 0xad, 0xcb, 0x4d, 0x95, - 0x8f, 0xce, 0x5c, 0x48, 0x64, 0x8e, 0x8a, 0xb7, 0x38, 0xc8, 0x07, 0xa0, 0x22, 0x92, 0xe1, 0xbd, 0x5b, 0x1e, 0x36, - 0xcf, 0xba, 0x47, 0x3d, 0xf6, 0xa0, 0x8c, 0x84, 0x8f, 0x77, 0x08, 0x89, 0x52, 0x21, 0xf6, 0xfc, 0x67, 0x92, 0x72, - 0x16, 0x0d, 0x95, 0xb7, 0x65, 0xe5, 0xf4, 0xf5, 0x3c, 0x92, 0x6a, 0x19, 0x0f, 0x78, 0x4f, 0x6e, 0xb6, 0x96, 0x13, - 0xc5, 0xad, 0xbe, 0xda, 0x5c, 0x82, 0xa0, 0x6c, 0xf4, 0x86, 0xdb, 0xb7, 0x11, 0x3b, 0x4e, 0xa0, 0x6d, 0xdb, 0x9f, - 0x5c, 0x2c, 0x45, 0xa9, 0x70, 0xc2, 0x58, 0x37, 0x39, 0x8a, 0xe6, 0x10, 0x86, 0x37, 0x6b, 0xab, 0x09, 0x1f, 0x70, - 0xc3, 0x31, 0x6f, 0x6f, 0x29, 0x87, 0x55, 0x2d, 0x9c, 0xa3, 0x48, 0xc6, 0xc4, 0xde, 0x2e, 0xa3, 0xdb, 0x5b, 0x85, - 0xfe, 0x13, 0xb2, 0xeb, 0xac, 0x56, 0xde, 0x04, 0x5f, 0x29, 0x88, 0x6c, 0xee, 0xc7, 0x67, 0xc6, 0x01, 0xd2, 0x0d, - 0xf0, 0xd7, 0x0a, 0x92, 0x55, 0x9e, 0xa8, 0xbc, 0x0a, 0x4c, 0xd3, 0x90, 0x82, 0xe1, 0x53, 0x7a, 0x0f, 0x96, 0xbc, - 0xe6, 0xcb, 0x66, 0xd7, 0x37, 0x17, 0x3f, 0xac, 0xf5, 0x10, 0x2f, 0x3b, 0xbd, 0xb5, 0x2a, 0x9c, 0xe0, 0x31, 0x49, - 0xfc, 0xba, 0xf4, 0xb3, 0xfd, 0x60, 0xe3, 0x96, 0x42, 0xed, 0x07, 0x9c, 0xd9, 0xba, 0xe7, 0x30, 0xb3, 0x49, 0x9f, - 0x01, 0x12, 0x16, 0x68, 0xdd, 0xc7, 0x22, 0x53, 0x60, 0xab, 0x01, 0x6e, 0x00, 0x23, 0xb6, 0x7d, 0xc8, 0x1e, 0xbd, - 0x29, 0x92, 0x2d, 0xe4, 0x7b, 0x3a, 0x72, 0xfb, 0x53, 0x4c, 0xef, 0x17, 0x75, 0x20, 0x9a, 0xaf, 0x03, 0x6e, 0xeb, - 0x81, 0x77, 0x1c, 0xa4, 0x48, 0x5c, 0x21, 0xa6, 0x49, 0xf7, 0x15, 0x5a, 0xb5, 0xba, 0x9b, 0x5c, 0xf6, 0xe7, 0x8e, - 0x93, 0xb5, 0xde, 0x86, 0xbb, 0xd8, 0xcf, 0xaa, 0x1d, 0xd2, 0x51, 0x03, 0xf8, 0xd2, 0xaf, 0x0c, 0x74, 0x7a, 0x9a, - 0xc2, 0x77, 0x25, 0x96, 0x4d, 0x08, 0x98, 0x3b, 0x28, 0xec, 0x2c, 0x90, 0x04, 0x2b, 0x9c, 0x38, 0x96, 0x77, 0x58, - 0x93, 0x17, 0xfa, 0x7a, 0x1c, 0x19, 0x18, 0x98, 0xb2, 0x27, 0x11, 0x61, 0xef, 0x2c, 0x52, 0x34, 0x6b, 0x19, 0xde, - 0x32, 0xd1, 0x93, 0x0f}; + 0x5b, 0x7b, 0x7b, 0x53, 0xc1, 0x6e, 0x19, 0x03, 0xf5, 0x04, 0xe0, 0xf8, 0xbb, 0x25, 0x3d, 0x34, 0x51, 0x94, 0xb1, + 0xe6, 0x22, 0x2f, 0x61, 0xbb, 0xc2, 0x70, 0x9e, 0x80, 0x65, 0x6b, 0x78, 0xad, 0x47, 0x01, 0x40, 0x55, 0x13, 0x0e, + 0xd8, 0x18, 0x92, 0x41, 0xc7, 0x81, 0x6a, 0xd1, 0xee, 0xdb, 0xf0, 0xa6, 0x22, 0xc8, 0x31, 0x97, 0xd9, 0x3c, 0xcc, + 0xe5, 0xa4, 0xd5, 0x13, 0x8c, 0x3a, 0x44, 0xf9, 0x1a, 0x8c, 0x4b, 0x4c, 0x51, 0x7e, 0x10, 0x4b, 0xea, 0xba, 0xe6, + 0x24, 0x45, 0x6e, 0xf3, 0x72, 0x99, 0x36, 0xac, 0xdb, 0x79, 0x37, 0x0a, 0x4f, 0x92, 0xc8, 0x21, 0xb3, 0xaa, 0x10, + 0x8e, 0x77, 0x8e, 0xb5, 0x29, 0xe5, 0xbf, 0x10, 0x83, 0x6d, 0xc3, 0xba, 0x2d, 0x5d, 0x36, 0x49, 0x12, 0x9a, 0x5b, + 0x46, 0xa5, 0x0a, 0x42, 0x62, 0xf0, 0x33, 0xd7, 0x94, 0x37, 0x3f, 0x57, 0x6b, 0x9c, 0x30, 0x61, 0xbd, 0xf1, 0x5b, + 0x28, 0xf2, 0xda, 0x5a, 0xe3, 0x0b, 0x44, 0xe0, 0x2e, 0xa4, 0xbe, 0x68, 0xed, 0xa7, 0x5b, 0x5f, 0x57, 0x34, 0xde, + 0x43, 0xba, 0xff, 0x8d, 0xbf, 0x8b, 0x43, 0x72, 0x99, 0xdb, 0x59, 0x36, 0xac, 0xdb, 0xf2, 0xa6, 0xee, 0x77, 0xca, + 0x1c, 0x5e, 0x9c, 0x1b, 0x62, 0x68, 0x1d, 0x10, 0x8f, 0xcd, 0xa1, 0x4a, 0x44, 0x8b, 0x11, 0x2b, 0xf6, 0xda, 0x13, + 0xf3, 0x5f, 0x65, 0xcd, 0x7a, 0x7d, 0x47, 0xb5, 0x8c, 0x9c, 0x1d, 0x4d, 0x37, 0x55, 0x02, 0x7c, 0x97, 0xc6, 0xd7, + 0x09, 0x1e, 0xf0, 0xb1, 0x63, 0x19, 0x43, 0x51, 0x9d, 0xdd, 0x4a, 0x10, 0x59, 0x4d, 0x65, 0x8a, 0x4b, 0xf2, 0xff, + 0xa6, 0xaa, 0xe7, 0xf8, 0x72, 0xa2, 0xbf, 0xfa, 0x1c, 0xb2, 0x16, 0x10, 0x7c, 0x80, 0xe0, 0x90, 0xca, 0x4c, 0xc9, + 0x59, 0xb2, 0xbb, 0x24, 0x27, 0x5b, 0x06, 0x49, 0x70, 0xa4, 0x1c, 0x29, 0x01, 0x6a, 0x44, 0xce, 0xfd, 0x92, 0x7d, + 0x55, 0xed, 0xa7, 0x3d, 0x4f, 0xd7, 0xa6, 0xdf, 0xb6, 0xdf, 0xdd, 0x7b, 0xe2, 0x85, 0x47, 0x51, 0x90, 0x08, 0x99, + 0x06, 0x14, 0x02, 0x6a, 0xf6, 0xb5, 0xb7, 0xb4, 0xff, 0xaf, 0xdf, 0x91, 0x10, 0xe0, 0x4a, 0x70, 0x16, 0x75, 0xe6, + 0x2d, 0x5b, 0xcf, 0x85, 0xb3, 0x2c, 0x5b, 0x5f, 0xc3, 0x61, 0x58, 0x47, 0x5d, 0x35, 0xa1, 0xc9, 0x7e, 0x24, 0x15, + 0xbb, 0x23, 0xd8, 0xcf, 0x7d, 0x96, 0x7d, 0x7d, 0x2f, 0x5a, 0x3f, 0xd7, 0x0a, 0xcf, 0x78, 0x8a, 0x0b, 0xb1, 0xfe, + 0x2e, 0xa4, 0x84, 0xe9, 0x5a, 0x35, 0xa7, 0x16, 0xc8, 0xc0, 0x38, 0x3e, 0xfb, 0x6c, 0x5f, 0x55, 0xa7, 0x6b, 0x47, + 0x9a, 0x25, 0x26, 0x47, 0xae, 0x17, 0x3d, 0x73, 0x14, 0x38, 0x9a, 0xfd, 0x5e, 0x2e, 0x1a, 0x1d, 0xe8, 0x0c, 0xe5, + 0x04, 0xb6, 0x31, 0x50, 0x0b, 0x44, 0x55, 0xb7, 0x19, 0xb2, 0xea, 0x7d, 0xf5, 0xfd, 0xaf, 0x5f, 0x72, 0xa3, 0x40, + 0x3d, 0xc6, 0x2c, 0x69, 0x29, 0xef, 0x81, 0x47, 0x88, 0x2c, 0x47, 0xc7, 0x8a, 0x1f, 0xf2, 0x95, 0x74, 0x1e, 0x2e, + 0x86, 0x45, 0x43, 0xc4, 0x92, 0x42, 0x0e, 0xb4, 0xe0, 0xc5, 0x2e, 0x2d, 0xd0, 0xc4, 0x06, 0xfe, 0xbf, 0xaa, 0x59, + 0xfd, 0xbe, 0x37, 0x2b, 0x09, 0x4b, 0x21, 0x2e, 0x71, 0x82, 0x40, 0xdd, 0xf3, 0x7b, 0x38, 0xde, 0xf3, 0x9f, 0x87, + 0xec, 0x30, 0x05, 0xad, 0xa2, 0x32, 0xc9, 0x41, 0xa4, 0x01, 0x35, 0x7a, 0x5c, 0x4b, 0xd3, 0xca, 0xd7, 0x57, 0x10, + 0xb0, 0x03, 0x97, 0xa6, 0xd8, 0xd3, 0x0c, 0x4d, 0x51, 0x24, 0x6c, 0x3a, 0xa5, 0xb7, 0xb3, 0x2e, 0x6b, 0x2f, 0x27, + 0xf3, 0xd0, 0xa9, 0x4d, 0xbb, 0x87, 0x49, 0x14, 0xd1, 0x36, 0x97, 0xb4, 0x86, 0x49, 0xc1, 0xdb, 0x49, 0x77, 0xc2, + 0x8a, 0x51, 0x79, 0x24, 0x4c, 0xf8, 0x87, 0x87, 0xe4, 0x03, 0x6a, 0xf5, 0x0d, 0xff, 0x69, 0x6a, 0xf6, 0xfa, 0xc6, + 0x0b, 0x3d, 0xe4, 0x54, 0x2e, 0xf2, 0x76, 0x80, 0xac, 0xee, 0xba, 0xb5, 0x69, 0x4e, 0x72, 0x9d, 0x98, 0x61, 0x93, + 0x02, 0xb6, 0x1b, 0x0e, 0x25, 0xd2, 0x46, 0x2c, 0x2d, 0xd5, 0xd7, 0x3b, 0x79, 0x1d, 0x25, 0x4a, 0x86, 0xf2, 0x0a, + 0x16, 0xd9, 0xb4, 0x5f, 0x29, 0x6d, 0xe0, 0xdb, 0xf8, 0xc6, 0x85, 0x03, 0x50, 0x4b, 0xf7, 0x84, 0x48, 0xea, 0xa0, + 0x10, 0x15, 0x28, 0x6c, 0xb0, 0xfc, 0xff, 0xbd, 0x95, 0x96, 0xdb, 0x1f, 0x91, 0xae, 0x08, 0x91, 0x3d, 0x00, 0x39, + 0xce, 0x70, 0x64, 0xfd, 0xbe, 0x33, 0xb3, 0x0a, 0x9c, 0x06, 0x48, 0xf6, 0x38, 0xb3, 0x92, 0xd6, 0x5a, 0x6c, 0x2a, + 0xee, 0xbd, 0xef, 0x5d, 0xe6, 0x77, 0xd1, 0x19, 0x3f, 0x0c, 0x2b, 0x4c, 0x66, 0x23, 0xed, 0xb0, 0x6c, 0x57, 0x96, + 0xd3, 0x00, 0x20, 0x78, 0xdf, 0x7b, 0x3f, 0x0a, 0xff, 0xff, 0xc8, 0xe2, 0xfc, 0x88, 0x2c, 0x50, 0x91, 0x59, 0xc5, + 0x39, 0x99, 0x05, 0xcc, 0xa8, 0x0a, 0xe0, 0x8c, 0x0a, 0x60, 0x1f, 0x1d, 0x90, 0x63, 0x41, 0xd0, 0x68, 0x9a, 0x6c, + 0xf6, 0xd1, 0x90, 0x2d, 0xef, 0x57, 0x5a, 0xac, 0x20, 0xca, 0xf5, 0x8c, 0x6c, 0x4b, 0x7e, 0xb7, 0x03, 0x65, 0xcd, + 0x52, 0x5a, 0xe9, 0xe8, 0xff, 0xe6, 0xd4, 0x66, 0x40, 0x8e, 0x40, 0x75, 0x53, 0x57, 0x21, 0xdc, 0xf2, 0xff, 0x5d, + 0xf2, 0x7a, 0x97, 0x52, 0xd2, 0xd1, 0xa5, 0x78, 0x19, 0x26, 0x1d, 0x95, 0x60, 0xe0, 0x2a, 0x27, 0xa7, 0xe7, 0x66, + 0x2c, 0xb2, 0x71, 0x53, 0x7f, 0x0c, 0xbf, 0x87, 0xd2, 0xc2, 0x60, 0xea, 0x4c, 0xd3, 0xf4, 0xdf, 0x6d, 0xa2, 0xab, + 0xae, 0x2c, 0x2c, 0xbf, 0xed, 0x66, 0x12, 0x57, 0x59, 0x96, 0x25, 0xb9, 0x24, 0x31, 0x01, 0xfe, 0x21, 0xba, 0xef, + 0x6f, 0xa8, 0xcf, 0x1b, 0x4b, 0xdb, 0x0c, 0x42, 0x26, 0x04, 0x48, 0xdb, 0xff, 0x37, 0x99, 0xeb, 0x9c, 0xb8, 0x78, + 0x7c, 0x49, 0xd3, 0x34, 0xb3, 0x6c, 0x7f, 0xae, 0xa3, 0xb4, 0x94, 0x6e, 0x9b, 0xed, 0x36, 0x7d, 0xa4, 0x0e, 0xdf, + 0xc0, 0x6f, 0x0c, 0x18, 0xc3, 0xe4, 0x6e, 0x15, 0x53, 0x43, 0x76, 0x69, 0xb6, 0xa5, 0xcd, 0x02, 0xd4, 0xb2, 0xfe, + 0x87, 0xa4, 0xdc, 0x5b, 0x42, 0x27, 0xf6, 0x34, 0xac, 0x62, 0x92, 0x7c, 0x83, 0x6f, 0x4c, 0xdf, 0x5a, 0x48, 0x2c, + 0x43, 0xd3, 0xda, 0xfe, 0x65, 0xb6, 0xf9, 0x75, 0x18, 0xb3, 0xcc, 0x14, 0x5b, 0x92, 0x63, 0x5b, 0x09, 0x76, 0xef, + 0x8a, 0x4c, 0xac, 0x3d, 0x0e, 0x00, 0xa7, 0xe5, 0x88, 0xb6, 0xe0, 0x33, 0x10, 0x8e, 0x73, 0x17, 0xbf, 0xfc, 0x55, + 0x09, 0xa6, 0xa3, 0x3d, 0x14, 0x7c, 0x75, 0x8c, 0x42, 0x2c, 0x41, 0x14, 0x79, 0xee, 0xe2, 0xde, 0x07, 0x26, 0xdf, + 0x0e, 0xaa, 0xe8, 0x1f, 0xba, 0xa6, 0x27, 0x9c, 0x21, 0x50, 0x8f, 0x5e, 0xf2, 0x0b, 0x07, 0xde, 0xfd, 0x7b, 0x00, + 0xa2, 0x12, 0x51, 0xea, 0xdb, 0x6f, 0xb8, 0x4e, 0x30, 0x7d, 0xdf, 0x4d, 0xdb, 0x03, 0xee, 0x0e, 0x1e, 0x12, 0x78, + 0x52, 0x0a, 0xcb, 0xfd, 0x97, 0xaa, 0x2b, 0x6e, 0x96, 0xa1, 0xd7, 0x31, 0x9d, 0xef, 0x26, 0xd8, 0x14, 0x2d, 0x6b, + 0x29, 0x18, 0x7a, 0xe6, 0xf1, 0xd6, 0x58, 0xfd, 0x0c, 0x56, 0xc9, 0xc0, 0x2d, 0x2d, 0xcc, 0xe4, 0xd4, 0xcf, 0xd7, + 0x54, 0xf5, 0xc1, 0x48, 0x12, 0x01, 0x90, 0xbc, 0xf9, 0x10, 0x27, 0x44, 0xe2, 0xfa, 0x7a, 0x3e, 0x5f, 0x95, 0x97, + 0xd9, 0x7e, 0x98, 0x60, 0x20, 0xd9, 0x20, 0x03, 0x98, 0xed, 0x3d, 0x5c, 0x7d, 0xb8, 0x57, 0xf3, 0x32, 0x6a, 0xfa, + 0xd7, 0x79, 0xb4, 0xa1, 0x33, 0x6d, 0x40, 0x1e, 0xb7, 0x69, 0x59, 0x9a, 0x92, 0x82, 0xc4, 0x86, 0x43, 0x06, 0x77, + 0x83, 0x39, 0xad, 0xc7, 0xa4, 0xe6, 0x9c, 0xac, 0xc9, 0x15, 0x97, 0x06, 0x37, 0xeb, 0xa3, 0x0f, 0xf7, 0xbe, 0xa4, + 0xc3, 0x2d, 0x3e, 0x6c, 0xfa, 0x24, 0x93, 0x7b, 0xde, 0x84, 0xcf, 0x4d, 0xb9, 0xbe, 0x1c, 0x02, 0x7b, 0xf3, 0x13, + 0x76, 0x85, 0xa0, 0x59, 0xdf, 0xea, 0xc8, 0x37, 0xde, 0xb5, 0xeb, 0xa1, 0x44, 0x32, 0x1a, 0x7d, 0xef, 0x41, 0xf3, + 0xa2, 0xdc, 0x88, 0x47, 0xd8, 0x2b, 0xd4, 0xb7, 0x3f, 0xb1, 0xc2, 0xb2, 0xbb, 0x99, 0x3f, 0x6c, 0x74, 0x7b, 0xf6, + 0xdd, 0xcb, 0xc1, 0xa3, 0x2f, 0xc2, 0x5c, 0x7d, 0xb8, 0xbf, 0xdd, 0x3a, 0xc1, 0x63, 0x42, 0x29, 0x76, 0x43, 0xc2, + 0xa1, 0xe6, 0xf7, 0x6e, 0xf6, 0x6e, 0xf2, 0x73, 0x59, 0x8b, 0x59, 0x4d, 0xfe, 0x93, 0xdf, 0xfe, 0xea, 0x77, 0x6a, + 0x9b, 0x8f, 0xf0, 0xed, 0x09, 0xc2, 0xd3, 0xbb, 0xa3, 0x8c, 0xb0, 0xe6, 0x30, 0xfe, 0xac, 0xa7, 0xca, 0x3f, 0xdb, + 0x2c, 0x6c, 0x73, 0x98, 0xaf, 0x4b, 0xda, 0x9e, 0xc3, 0xa4, 0x75, 0x57, 0xa2, 0xde, 0x4d, 0x94, 0xf2, 0xa0, 0x09, + 0xf2, 0xf2, 0xb9, 0x03, 0x7d, 0xe3, 0x7c, 0xcd, 0xa0, 0xc8, 0x6e, 0xa9, 0xe5, 0xd2, 0x9a, 0xc7, 0x9b, 0xf9, 0x60, + 0x59, 0xa2, 0x40, 0xbf, 0x4a, 0xbd, 0x77, 0xad, 0xbb, 0x7e, 0x21, 0xaa, 0x1f, 0x6d, 0xe6, 0x6a, 0x04, 0x42, 0xa4, + 0x5c, 0x37, 0x01, 0x22, 0x4b, 0xa4, 0xc8, 0x33, 0xf1, 0x5c, 0xa7, 0x4d, 0x86, 0x1e, 0xb9, 0xbf, 0xf2, 0x6b, 0xa4, + 0xe1, 0xf9, 0x84, 0x76, 0xf8, 0xd1, 0x66, 0x25, 0xd4, 0x2b, 0x54, 0xc8, 0x1b, 0x67, 0xc5, 0x7f, 0xee, 0x43, 0xa9, + 0xd6, 0x44, 0x0c, 0xcf, 0xcd, 0x64, 0x90, 0xf7, 0x2c, 0xbb, 0x92, 0xea, 0x58, 0x5b, 0x5b, 0x55, 0xd7, 0xb7, 0x50, + 0xde, 0xcc, 0x50, 0xee, 0x45, 0x95, 0x22, 0xf9, 0x60, 0x18, 0xd2, 0x73, 0xfc, 0xdb, 0xd6, 0x37, 0x3f, 0x15, 0x88, + 0x73, 0x91, 0x37, 0x28, 0x75, 0x43, 0x4d, 0x96, 0x12, 0xfb, 0x59, 0x9d, 0xb2, 0xdd, 0x23, 0xed, 0xa0, 0x23, 0x57, + 0x03, 0x98, 0xc2, 0x54, 0xb0, 0xe7, 0xd5, 0x4b, 0x56, 0x9d, 0xe7, 0x05, 0xf9, 0xb6, 0xe2, 0x47, 0x04, 0x40, 0xa3, + 0x78, 0x43, 0x34, 0x2b, 0xa0, 0x2a, 0x91, 0x26, 0x0b, 0xc7, 0x4e, 0xf3, 0x4f, 0x68, 0x43, 0xcd, 0x7e, 0xbf, 0xed, + 0x64, 0x50, 0xc2, 0xc5, 0x37, 0x9f, 0x7d, 0xa0, 0x09, 0x7e, 0xfb, 0x99, 0x8c, 0xac, 0x95, 0xa0, 0xa3, 0x9c, 0xbc, + 0xee, 0x40, 0xca, 0x2c, 0x53, 0x61, 0xa1, 0x8b, 0xa4, 0x84, 0x6e, 0x98, 0x9c, 0x2f, 0x78, 0x7b, 0x83, 0xf3, 0xb5, + 0xac, 0x56, 0x5a, 0xbe, 0x99, 0xaa, 0x85, 0x79, 0x07, 0x54, 0x7d, 0xec, 0x04, 0x9e, 0xd0, 0x6d, 0x32, 0xef, 0x96, + 0xd2, 0x23, 0x5a, 0xf9, 0xde, 0x4b, 0x91, 0x66, 0xb7, 0xfe, 0x84, 0xe8, 0xd5, 0x11, 0x81, 0xfb, 0x22, 0xd9, 0x8a, + 0xbe, 0x37, 0x8c, 0x88, 0xe2, 0xfe, 0x1e, 0xfd, 0x12, 0x3f, 0xcb, 0xaf, 0x5d, 0x21, 0x34, 0x56, 0xc0, 0x23, 0x69, + 0x7d, 0xef, 0x6a, 0x8f, 0x21, 0x80, 0x4e, 0xaf, 0x42, 0x31, 0xec, 0xb6, 0x22, 0x66, 0xc7, 0x99, 0x38, 0xee, 0xf3, + 0x19, 0x96, 0xf9, 0xda, 0x34, 0xa1, 0x1b, 0xaa, 0x4f, 0x71, 0x21, 0x65, 0x92, 0x36, 0x45, 0x55, 0x17, 0x8d, 0xbf, + 0x35, 0xc8, 0x98, 0x62, 0xde, 0x7a, 0x34, 0xe8, 0x2f, 0xf6, 0x05, 0xf1, 0xe0, 0x48, 0xd0, 0xcb, 0x74, 0xf4, 0xc6, + 0xb1, 0x6a, 0x2c, 0x6f, 0x2c, 0x3b, 0x30, 0x13, 0x36, 0x09, 0xd1, 0xd8, 0x60, 0xeb, 0xc8, 0x82, 0x55, 0xcf, 0x18, + 0x9b, 0x77, 0xe9, 0x2d, 0x12, 0xde, 0x95, 0x2d, 0x1c, 0xa6, 0xfa, 0x42, 0xc6, 0x59, 0x2f, 0xd7, 0xf2, 0xe9, 0x3a, + 0x01, 0x0e, 0x12, 0x86, 0x17, 0xc4, 0x18, 0xfe, 0xe2, 0xbc, 0x49, 0x55, 0xb0, 0x28, 0xb4, 0x6d, 0x7c, 0x51, 0x7b, + 0x10, 0xcf, 0x4a, 0x10, 0xdf, 0xca, 0xb8, 0xea, 0xa0, 0x1b, 0x8e, 0x30, 0x57, 0xc3, 0x26, 0x84, 0x56, 0x10, 0x81, + 0x9a, 0xfa, 0x33, 0x0d, 0xd5, 0xb5, 0xae, 0xf2, 0x42, 0xa2, 0xe4, 0xb3, 0x68, 0x1c, 0x41, 0x21, 0x07, 0x83, 0xc2, + 0x09, 0x3d, 0xd8, 0x3d, 0xf8, 0x8d, 0x83, 0x71, 0xc1, 0x71, 0x43, 0xfe, 0xda, 0x2d, 0x6b, 0xdc, 0x33, 0x30, 0x95, + 0x97, 0x2b, 0xcd, 0xe6, 0x00, 0x2a, 0x83, 0x5d, 0x6c, 0x48, 0x3e, 0x5b, 0xf4, 0x84, 0xbe, 0xbb, 0xa1, 0x01, 0x0c, + 0x0f, 0x8f, 0xbc, 0x99, 0x7f, 0x23, 0x01, 0x0f, 0x0e, 0x66, 0xe5, 0x97, 0x0b, 0xa1, 0x58, 0x7d, 0x11, 0x20, 0x40, + 0x7c, 0x8d, 0xee, 0x07, 0x41, 0x74, 0x84, 0x60, 0x45, 0x1d, 0x0b, 0xe0, 0x44, 0xc5, 0x29, 0x39, 0x22, 0xc0, 0x38, + 0x41, 0x7d, 0x13, 0x34, 0xf3, 0x7b, 0xa3, 0xfc, 0x0b, 0xb7, 0x9b, 0x79, 0xe2, 0x59, 0x3f, 0x9b, 0xd7, 0x8b, 0x24, + 0x4f, 0xe0, 0x51, 0xd3, 0x81, 0x12, 0x85, 0xd2, 0x0d, 0xee, 0xa6, 0x14, 0x71, 0x9a, 0x88, 0xd5, 0x42, 0x00, 0xb6, + 0xb5, 0xb2, 0x96, 0x7e, 0xa3, 0x74, 0x8e, 0x3a, 0x87, 0x3d, 0x0b, 0xbe, 0x50, 0x7e, 0x6f, 0x09, 0x5d, 0xd5, 0x68, + 0x3b, 0x97, 0x9a, 0x1f, 0xae, 0x36, 0xb2, 0xa1, 0x75, 0xcd, 0xde, 0x42, 0x50, 0x53, 0x54, 0x86, 0x9a, 0xf2, 0x22, + 0x19, 0xdb, 0x9d, 0x98, 0xfd, 0xd0, 0x48, 0xf2, 0x1a, 0x79, 0x65, 0x7f, 0x43, 0x3b, 0xd3, 0x26, 0x1e, 0xbb, 0x12, + 0x0c, 0xbf, 0x68, 0x29, 0x7d, 0x2d, 0x9c, 0x5d, 0xcb, 0xcf, 0x97, 0xb0, 0x36, 0xa6, 0x00, 0x82, 0x90, 0x7e, 0x36, + 0xda, 0xaa, 0x31, 0xba, 0xd5, 0x63, 0xca, 0x3e, 0xea, 0x31, 0xdf, 0xfd, 0x1e, 0xa9, 0x92, 0x85, 0x20, 0x39, 0x34, + 0xf4, 0xd7, 0x63, 0x64, 0x18, 0xa0, 0x48, 0x22, 0xe4, 0x5b, 0x29, 0x03, 0xf7, 0xef, 0x57, 0x8c, 0x0e, 0xb6, 0xd4, + 0x9c, 0x49, 0xb3, 0xab, 0x67, 0x34, 0x20, 0x6c, 0xb4, 0x1e, 0x26, 0xce, 0x08, 0xe1, 0xa4, 0xb1, 0x7d, 0xaa, 0x22, + 0x12, 0xe9, 0xbd, 0x14, 0x31, 0xd8, 0xb8, 0x52, 0xba, 0xc4, 0x08, 0x6b, 0x66, 0x2c, 0xc7, 0x06, 0x50, 0x39, 0x73, + 0x5b, 0x94, 0xc6, 0x37, 0xad, 0xa0, 0x04, 0xb8, 0x47, 0x0c, 0xf6, 0x41, 0x23, 0x40, 0xae, 0x0b, 0x2a, 0x48, 0x68, + 0x9f, 0x0b, 0xc8, 0x84, 0x06, 0x19, 0x19, 0x13, 0xeb, 0x46, 0x20, 0xb9, 0x7b, 0x7a, 0xd3, 0x2e, 0x01, 0xa6, 0x72, + 0xb2, 0x9a, 0x21, 0x62, 0xe2, 0x78, 0x5d, 0x2d, 0x9c, 0xc0, 0x58, 0x0a, 0xd8, 0x31, 0x76, 0x54, 0x72, 0x2e, 0x76, + 0x68, 0xb4, 0x69, 0xe6, 0x17, 0xba, 0x3e, 0x43, 0xe1, 0x87, 0xb5, 0x0b, 0xc8, 0xc8, 0xa9, 0xdb, 0x4b, 0x0f, 0x46, + 0x06, 0x12, 0x57, 0xeb, 0x4e, 0x8b, 0xa4, 0x15, 0x91, 0xcf, 0x8a, 0x7e, 0x75, 0x6c, 0x72, 0x25, 0x2e, 0xd6, 0x8a, + 0x1a, 0x43, 0x91, 0x07, 0xb7, 0xc1, 0x3f, 0x76, 0xf4, 0xb8, 0x75, 0xc2, 0x02, 0x80, 0xf5, 0x58, 0x4e, 0x06, 0x9c, + 0xab, 0xee, 0xe0, 0xd7, 0x40, 0x95, 0xc0, 0x2b, 0x47, 0x9d, 0x45, 0x1c, 0x5f, 0x58, 0xa0, 0x18, 0xfc, 0xeb, 0x14, + 0x79, 0x0c, 0x76, 0x83, 0x2c, 0xe9, 0xa6, 0x59, 0x04, 0x7b, 0x4a, 0x79, 0x26, 0x62, 0xfe, 0xaa, 0x91, 0x34, 0x2a, + 0xac, 0x78, 0x9a, 0x6a, 0xa9, 0x13, 0x3e, 0x55, 0x09, 0x05, 0xc2, 0x1e, 0x82, 0xa6, 0x00, 0xde, 0x9b, 0x12, 0xf3, + 0xf8, 0xa6, 0x85, 0xc4, 0xf9, 0xc9, 0x3a, 0x9b, 0x35, 0x63, 0x06, 0xba, 0x92, 0x80, 0x6e, 0x4e, 0x35, 0x0d, 0xb7, + 0xe8, 0xba, 0x2c, 0x85, 0xa5, 0x64, 0x85, 0x5a, 0x82, 0x89, 0x30, 0x19, 0xde, 0x06, 0x17, 0x90, 0xbc, 0x37, 0x69, + 0x66, 0xdc, 0x3c, 0xbd, 0xaa, 0xf2, 0x04, 0x9a, 0xc7, 0x7d, 0x99, 0x2f, 0x34, 0xa5, 0xb9, 0xc2, 0x01, 0x48, 0x7b, + 0xc1, 0x3c, 0x16, 0x1a, 0x67, 0x52, 0x32, 0xfd, 0x8e, 0x8b, 0x99, 0xd4, 0x54, 0x71, 0x17, 0xd6, 0x09, 0x2b, 0x40, + 0x22, 0x59, 0x32, 0x18, 0x3c, 0x03, 0x8a, 0xf7, 0x05, 0xe0, 0x88, 0x68, 0x14, 0xbe, 0xb3, 0xa3, 0x1c, 0xad, 0x4a, + 0x42, 0x88, 0xcc, 0x56, 0xec, 0xbc, 0x78, 0xa3, 0x1c, 0x45, 0xce, 0x38, 0xda, 0x01, 0x6c, 0x5e, 0x7f, 0xc8, 0x7c, + 0x06, 0x81, 0xac, 0x1f, 0x27, 0x3a, 0x9b, 0x9b, 0xa6, 0x4b, 0x91, 0xce, 0x46, 0x73, 0x96, 0x17, 0x78, 0xc6, 0x29, + 0x13, 0x3c, 0x96, 0x8d, 0xe2, 0x86, 0xa8, 0xf3, 0x4f, 0xd4, 0x01, 0xa7, 0xda, 0x66, 0x7b, 0x33, 0x58, 0x3d, 0x2d, + 0x4e, 0x0e, 0x18, 0x95, 0x9c, 0xcd, 0xa3, 0xd5, 0xeb, 0xfd, 0x5f, 0x4e, 0xbe, 0x2a, 0x63, 0x81, 0xc6, 0xab, 0x9c, + 0xaa, 0xc8, 0xc8, 0x74, 0xc0, 0x89, 0x97, 0x9a, 0xcf, 0xc5, 0x00, 0x2d, 0x32, 0xaf, 0x4a, 0x32, 0x14, 0x92, 0xd5, + 0xb0, 0xf2, 0x06, 0x1a, 0x64, 0xd3, 0xd5, 0x50, 0xa3, 0xe0, 0x08, 0x59, 0xd2, 0x62, 0x63, 0xb6, 0x58, 0xac, 0x79, + 0xad, 0x99, 0x36, 0xc7, 0x08, 0x22, 0xb0, 0x38, 0x20, 0xae, 0x3f, 0xab, 0x35, 0x36, 0x30, 0x89, 0x57, 0xbb, 0x11, + 0x06, 0xdd, 0x0d, 0x2d, 0xa9, 0x4e, 0x8c, 0xa5, 0x12, 0x44, 0x4e, 0x1d, 0x69, 0xec, 0x47, 0x9e, 0xaf, 0xf9, 0xe3, + 0x9e, 0x05, 0xc6, 0xb2, 0x3c, 0x18, 0x19, 0xaa, 0x18, 0x52, 0xda, 0x0d, 0x66, 0x1e, 0xa2, 0x7e, 0x7a, 0xa4, 0x56, + 0xe5, 0x4c, 0xed, 0x31, 0x3c, 0x16, 0x9d, 0x4b, 0xf2, 0xe1, 0x51, 0x37, 0x04, 0x64, 0x5b, 0x81, 0xd5, 0xa7, 0x0e, + 0x22, 0x8a, 0x40, 0xd8, 0xcf, 0xe9, 0x1f, 0xbb, 0x91, 0xff, 0x14, 0xb0, 0x54, 0xaf, 0x87, 0xba, 0xa5, 0xcc, 0x31, + 0x25, 0x6b, 0x79, 0xcb, 0x29, 0xa8, 0xb8, 0x74, 0x55, 0x3a, 0x79, 0x20, 0xb6, 0x10, 0x29, 0x58, 0xcc, 0x3e, 0x9f, + 0x2e, 0x1c, 0xa8, 0xa4, 0x50, 0x7d, 0xdf, 0x05, 0x79, 0x7e, 0xb8, 0x71, 0x50, 0x8b, 0x31, 0xc6, 0x43, 0xaa, 0xad, + 0xaf, 0x1d, 0xdc, 0x8a, 0xbd, 0x0d, 0x3c, 0x3f, 0xb1, 0xdf, 0xef, 0xb7, 0x6c, 0x94, 0x91, 0x95, 0xc2, 0x8a, 0xdc, + 0xd4, 0xa2, 0xf3, 0xc3, 0x49, 0x38, 0x79, 0x42, 0x19, 0x90, 0x97, 0x33, 0xd8, 0x02, 0x59, 0x74, 0x53, 0xf4, 0xc2, + 0x68, 0xb3, 0xbe, 0xe5, 0xe2, 0x5b, 0xbf, 0xc3, 0x21, 0x93, 0x94, 0x2e, 0x69, 0x3a, 0xdf, 0xeb, 0x52, 0x7d, 0x17, + 0x59, 0xb4, 0x48, 0x67, 0xd5, 0xb6, 0xfb, 0x45, 0xaa, 0xb5, 0x17, 0x22, 0x59, 0x32, 0x1c, 0xc5, 0xde, 0xb9, 0x20, + 0xb5, 0x74, 0x06, 0xd5, 0xf4, 0x63, 0x32, 0x8e, 0x5d, 0x02, 0xad, 0x15, 0x4c, 0x6f, 0xe1, 0xc5, 0x20, 0xdb, 0x48, + 0x61, 0x91, 0x16, 0x82, 0x35, 0x9a, 0x39, 0xd2, 0x7e, 0x90, 0x28, 0x2c, 0x03, 0x74, 0x96, 0xf4, 0x39, 0xb3, 0x87, + 0xa3, 0x78, 0x84, 0x2a, 0xa2, 0xd4, 0xfd, 0x61, 0x42, 0x85, 0x54, 0xa7, 0x79, 0x82, 0xa2, 0x3d, 0x1f, 0xb9, 0x63, + 0x03, 0xe6, 0xa7, 0x33, 0xd1, 0xae, 0xbf, 0x5a, 0x02, 0x16, 0x5e, 0x7e, 0x48, 0x71, 0x9b, 0xd2, 0xdb, 0xf9, 0x1f, + 0xf3, 0x39, 0xa5, 0x3c, 0x33, 0x74, 0x4a, 0xa9, 0xd0, 0xcc, 0xe6, 0xc2, 0x0a, 0x49, 0xa5, 0xf9, 0x70, 0x67, 0x0d, + 0xba, 0x19, 0x82, 0x12, 0x09, 0xc5, 0x8d, 0x60, 0x16, 0xa3, 0x18, 0x6b, 0xa0, 0x72, 0x37, 0x6f, 0xd5, 0x49, 0xa5, + 0xb9, 0x53, 0x95, 0x5c, 0xf1, 0xdd, 0xcf, 0x8d, 0x82, 0x61, 0x08, 0xb1, 0xe9, 0xf8, 0x22, 0x25, 0xcb, 0x4b, 0x39, + 0xac, 0xc6, 0x95, 0x21, 0x82, 0x96, 0x41, 0x9c, 0x10, 0xac, 0xe4, 0x12, 0xd4, 0x56, 0x98, 0xee, 0x54, 0x89, 0x52, + 0x41, 0x1f, 0x28, 0xbd, 0xba, 0x83, 0xe6, 0xc4, 0x36, 0x84, 0xb7, 0xa6, 0xa1, 0x80, 0x98, 0xf5, 0xdf, 0x07, 0x19, + 0x1d, 0x3a, 0x7e, 0x2b, 0x19, 0x53, 0x21, 0x50, 0x33, 0x47, 0xcb, 0xcb, 0x80, 0x4d, 0x0a, 0x71, 0xa5, 0x28, 0x4e, + 0x04, 0x71, 0xd8, 0xc7, 0xa6, 0xe6, 0xd3, 0xc7, 0x41, 0x62, 0x1a, 0xd6, 0x65, 0x03, 0xa4, 0xd6, 0x0b, 0x91, 0xf8, + 0x35, 0xf5, 0xe6, 0xa0, 0x09, 0xe3, 0x75, 0xa8, 0x20, 0x66, 0xc5, 0x69, 0x23, 0x05, 0x63, 0x95, 0x86, 0x43, 0x50, + 0x8e, 0x0c, 0xcb, 0xc4, 0xfa, 0xd2, 0x2c, 0xd1, 0x1b, 0x26, 0x06, 0xb6, 0x63, 0xa5, 0x84, 0x00, 0x38, 0x33, 0x66, + 0xdd, 0x8e, 0x49, 0xab, 0x0a, 0xea, 0x81, 0xea, 0xb3, 0xbe, 0xe8, 0x78, 0x86, 0x98, 0xf0, 0x21, 0x01, 0x47, 0xf3, + 0x45, 0xd2, 0x73, 0x6b, 0xa2, 0x25, 0x6d, 0x6a, 0x88, 0x3f, 0x31, 0xd2, 0x5a, 0xb4, 0xd2, 0x81, 0xaf, 0x00, 0x54, + 0x90, 0xa9, 0xe0, 0x12, 0x55, 0x52, 0x36, 0x15, 0x95, 0x07, 0x93, 0x72, 0x6d, 0x99, 0x95, 0x55, 0xee, 0x5d, 0x1f, + 0xe1, 0xcf, 0xb4, 0x50, 0xd2, 0xba, 0x43, 0x7c, 0xa9, 0xe0, 0xaf, 0x51, 0x48, 0x11, 0xf5, 0x99, 0x91, 0x5d, 0x1d, + 0xf3, 0xec, 0x91, 0x95, 0xff, 0xda, 0xc6, 0xaf, 0x5d, 0x28, 0x31, 0xca, 0xdd, 0x7b, 0x64, 0x32, 0xb2, 0x85, 0x80, + 0xa8, 0xdb, 0xd8, 0x0f, 0x47, 0xea, 0xf8, 0xe3, 0x90, 0xe2, 0x3f, 0x5d, 0x05, 0x51, 0x7b, 0xd2, 0x42, 0xaa, 0x83, + 0x9e, 0x03, 0x6b, 0xd0, 0x9a, 0x34, 0x7a, 0xd0, 0xbd, 0x07, 0x2a, 0x57, 0x04, 0xe7, 0x8f, 0x6e, 0xc2, 0x44, 0x05, + 0x9e, 0x02, 0xfe, 0xc2, 0x14, 0x84, 0x59, 0x23, 0x50, 0xdd, 0x2e, 0xda, 0x3e, 0x6f, 0x33, 0x66, 0x90, 0xf7, 0x6e, + 0xdf, 0x08, 0x3f, 0xa2, 0x1e, 0x36, 0x5b, 0xfd, 0x9b, 0x6f, 0x79, 0x94, 0xa8, 0x2c, 0x84, 0xa9, 0x91, 0x50, 0x53, + 0x67, 0x49, 0xe0, 0x47, 0x37, 0xb1, 0x86, 0xd9, 0x7e, 0xb2, 0x56, 0xb8, 0x54, 0x08, 0x99, 0x22, 0x10, 0x9d, 0x21, + 0xcc, 0xa8, 0xf3, 0x44, 0x01, 0xbc, 0xad, 0x00, 0xb4, 0x04, 0xfd, 0x18, 0x6c, 0x73, 0xfb, 0x84, 0xd0, 0x5c, 0xcc, + 0xf3, 0x47, 0x4c, 0x42, 0x41, 0x8a, 0x9f, 0xe5, 0xd3, 0xac, 0x79, 0xa1, 0x12, 0x15, 0xd7, 0x50, 0xb4, 0x15, 0xd7, + 0xc1, 0x03, 0x63, 0xd6, 0x47, 0xd1, 0xa9, 0x8d, 0x29, 0xcd, 0xe2, 0x66, 0x97, 0x68, 0x47, 0xea, 0x6e, 0x3c, 0x9f, + 0x34, 0xdc, 0x89, 0x24, 0xa1, 0x2c, 0x43, 0xab, 0xdb, 0xa6, 0x4b, 0x71, 0x0a, 0xe7, 0x74, 0x5e, 0x7e, 0xcb, 0x10, + 0xef, 0xbf, 0xe6, 0xf8, 0xf4, 0x39, 0xab, 0x66, 0x9e, 0x1f, 0x3d, 0x12, 0x5a, 0x60, 0x66, 0x2d, 0x76, 0xf3, 0x28, + 0x8b, 0x35, 0x24, 0xb0, 0xc3, 0x86, 0xa1, 0x13, 0x5e, 0x6f, 0x59, 0x3c, 0x54, 0x5b, 0x0f, 0x36, 0xde, 0x53, 0x18, + 0xba, 0x5b, 0xd2, 0x46, 0xd6, 0x35, 0x51, 0xd1, 0xcf, 0x6f, 0x91, 0xcd, 0xdd, 0xfe, 0xb8, 0x4c, 0x9a, 0x14, 0x15, + 0xa7, 0xa3, 0xdd, 0xe9, 0xc5, 0xdf, 0x18, 0x33, 0xf3, 0x18, 0x39, 0x65, 0x32, 0xd6, 0xd6, 0x19, 0x6d, 0xb9, 0xb5, + 0x73, 0x95, 0xf5, 0x64, 0xe0, 0x77, 0x82, 0xe4, 0xe7, 0x47, 0x39, 0x68, 0x44, 0x20, 0xa8, 0xdd, 0xae, 0x51, 0xc8, + 0x86, 0x03, 0x33, 0xc7, 0x7f, 0x67, 0x6d, 0xfb, 0x3e, 0x79, 0x9b, 0xf5, 0x62, 0x76, 0xc0, 0xf5, 0xc6, 0x46, 0x73, + 0x64, 0x6e, 0x57, 0x23, 0x1b, 0x3a, 0xdc, 0x91, 0x50, 0xdf, 0x1c, 0x99, 0x67, 0xc7, 0x7c, 0x69, 0x44, 0x70, 0x36, + 0x3a, 0x02, 0xc3, 0x41, 0x9b, 0xeb, 0xbf, 0x49, 0xf2, 0x3d, 0x93, 0x18, 0xe0, 0x80, 0x0d, 0xb2, 0x7a, 0x27, 0x0b, + 0x42, 0xc5, 0x2d, 0x1d, 0xf0, 0x72, 0x9f, 0x07, 0x14, 0x6f, 0xa2, 0x52, 0x72, 0xbd, 0x39, 0x13, 0x15, 0x9a, 0xbb, + 0x7b, 0xe3, 0x6d, 0xab, 0xf1, 0x42, 0xfd, 0xfb, 0x7a, 0x97, 0xda, 0xdf, 0xfc, 0xb1, 0xe9, 0xbb, 0x2c, 0x3f, 0x6b, + 0x51, 0xa7, 0xe7, 0x22, 0xe3, 0x79, 0xd3, 0x2e, 0xd1, 0x1e, 0x46, 0xaa, 0xc9, 0x6a, 0x09, 0xcd, 0x8d, 0xd8, 0xe6, + 0x1d, 0xb7, 0x3a, 0xe0, 0x55, 0x98, 0x55, 0xcd, 0x89, 0x78, 0xef, 0x49, 0xe0, 0x7a, 0xea, 0xc9, 0xda, 0x84, 0xdb, + 0xa1, 0x57, 0x76, 0xb3, 0x33, 0x86, 0xce, 0xa7, 0xd5, 0x3f, 0xd9, 0x47, 0xf0, 0x9b, 0x93, 0xcd, 0xbf, 0x37, 0x35, + 0xa5, 0xc9, 0xdb, 0x93, 0x69, 0xd6, 0x93, 0xa7, 0x1b, 0xc3, 0xd9, 0x96, 0xf6, 0xd3, 0xe6, 0xc3, 0x6c, 0xda, 0x7f, + 0x29, 0xdf, 0x54, 0x85, 0x49, 0xa9, 0xff, 0x04, 0x96, 0x7a, 0x64, 0xa1, 0xf7, 0x1a, 0x43, 0xfc, 0xaa, 0x0a, 0x07, + 0x17, 0x35, 0xdd, 0x0f, 0xe3, 0xdd, 0x68, 0xe2, 0xd6, 0xe5, 0x65, 0x69, 0xce, 0x6a, 0xc4, 0x49, 0xee, 0x49, 0xab, + 0xeb, 0x5d, 0xe6, 0x39, 0xb4, 0xcb, 0xbf, 0x17, 0x08, 0xb7, 0x26, 0x28, 0x68, 0x5d, 0x6a, 0x9b, 0x75, 0x7e, 0x16, + 0x58, 0xfe, 0x1b, 0x59, 0x4f, 0xd7, 0x57, 0xb1, 0xeb, 0x97, 0x2a, 0x3f, 0xff, 0x14, 0xfe, 0x5e, 0xf4, 0xa4, 0xf9, + 0xeb, 0xc1, 0xd9, 0xe7, 0xf9, 0x9f, 0xa3, 0x4c, 0x9b, 0x5a, 0x75, 0xeb, 0x29, 0xfc, 0xf3, 0x78, 0x28, 0x66, 0xb3, + 0xf1, 0xd7, 0x56, 0xf3, 0x3b, 0x84, 0x57, 0xff, 0xf1, 0xe2, 0xe7, 0x2f, 0xcd, 0xc0, 0x7c, 0xe8, 0x9f, 0xe6, 0x6c, + 0xea, 0x62, 0xfd, 0x17, 0x29, 0xeb, 0xeb, 0x3b, 0x6f, 0x4c, 0x34, 0xac, 0xf8, 0xb6, 0xe9, 0xd6, 0x6c, 0x56, 0x47, + 0x95, 0x7f, 0xe1, 0xda, 0xbf, 0x3d, 0xf5, 0x19, 0x04, 0xf9, 0xbc, 0x93, 0x7a, 0xde, 0x18, 0xee, 0x96, 0x14, 0xf6, + 0xd9, 0x72, 0xef, 0xd7, 0x0b, 0x3f, 0x1e, 0x2f, 0xca, 0xd6, 0x51, 0x37, 0x59, 0x59, 0x35, 0xd7, 0x7e, 0xb1, 0x26, + 0x39, 0xdb, 0x15, 0x38, 0xff, 0xb4, 0x7c, 0x3c, 0xfe, 0xe7, 0xe4, 0x69, 0x5d, 0x8e, 0x66, 0x30, 0xe3, 0x3d, 0x9a, + 0x27, 0x9a, 0x37, 0x26, 0xd3, 0x66, 0xbf, 0xfd, 0x10, 0xdf, 0x9a, 0x6e, 0xdd, 0x9b, 0xaf, 0xf8, 0x01, 0x57, 0xcc, + 0xa9, 0xef, 0x5a, 0xf9, 0x5d, 0x4f, 0x0d, 0x71, 0xc1, 0xd8, 0x04, 0x12, 0x8f, 0xfd, 0xdf, 0xc1, 0xd8, 0x0f, 0xbd, + 0x97, 0xde, 0x6c, 0x11, 0xdf, 0x09, 0x61, 0xd7, 0xac, 0x94, 0x73, 0x91, 0x8e, 0xd8, 0xc6, 0x70, 0x9c, 0xf9, 0x40, + 0x45, 0xdb, 0x27, 0xef, 0x37, 0x3e, 0xea, 0x77, 0xa1, 0xf6, 0xe0, 0xfa, 0xe1, 0xf2, 0xb5, 0x78, 0xef, 0x9f, 0x09, + 0xe1, 0x65, 0x03, 0x62, 0x5e, 0xf1, 0xee, 0xf6, 0x3f, 0x46, 0xc5, 0x29, 0x14, 0x75, 0xa2, 0xb2, 0x66, 0xdb, 0x66, + 0xf6, 0x7d, 0xb4, 0x78, 0xe8, 0x40, 0xcd, 0xc7, 0xfb, 0x84, 0xc8, 0xa3, 0x8b, 0x74, 0x97, 0xef, 0xa7, 0x12, 0x08, + 0xec, 0x11, 0x05, 0x76, 0x0d, 0xf2, 0x69, 0xd7, 0x83, 0xbf, 0xc0, 0x9b, 0xb0, 0xb1, 0xb9, 0x7a, 0xb7, 0x73, 0xc8, + 0x1e, 0xae, 0xe6, 0xc5, 0xfa, 0x14, 0x40, 0x12, 0x9b, 0x84, 0x80, 0xf9, 0x3f, 0xb9, 0xa0, 0x86, 0xad, 0xd7, 0xe9, + 0xe7, 0x17, 0xa3, 0xee, 0xd4, 0xa4, 0x59, 0x47, 0x18, 0xe9, 0x5e, 0x78, 0xb5, 0xa1, 0x13, 0x1e, 0x72, 0x8c, 0xf5, + 0x8a, 0xd2, 0x4a, 0x0b, 0xee, 0xd4, 0x47, 0x9d, 0x95, 0x9f, 0x1b, 0x90, 0x88, 0x6c, 0x95, 0x0d, 0xee, 0x32, 0xb3, + 0xf7, 0x0a, 0x6e, 0x58, 0xa5, 0x36, 0x2f, 0xa1, 0xce, 0xf8, 0x3d, 0x57, 0x53, 0x6a, 0xeb, 0xab, 0x6e, 0xde, 0xc4, + 0xcf, 0xed, 0xc5, 0x42, 0x7a, 0xc3, 0x2e, 0xad, 0x0d, 0x48, 0xe0, 0xea, 0x1b, 0xda, 0xed, 0xd4, 0x68, 0xc4, 0x40, + 0x3e, 0x4e, 0x82, 0xf3, 0x5c, 0x33, 0x53, 0x18, 0xef, 0x1a, 0x6a, 0x65, 0xd1, 0x2d, 0xc7, 0x45, 0x93, 0xb7, 0xed, + 0xff, 0x5d, 0x46, 0x8e, 0xeb, 0xe1, 0x0c, 0xe0, 0x36, 0x0f, 0xa0, 0x9f, 0x55, 0x17, 0x56, 0xb9, 0x99, 0x3f, 0xdd, + 0x1a, 0x0c, 0x2a, 0x1f, 0x7a, 0x98, 0x72, 0xfc, 0x46, 0x4e, 0xff, 0x11, 0xb0, 0x6b, 0xeb, 0xb9, 0x96, 0x7b, 0xde, + 0xec, 0xc5, 0x7b, 0x33, 0x9d, 0xcd, 0x4c, 0x99, 0xf5, 0x58, 0xc5, 0x94, 0x13, 0x5f, 0xdb, 0xd5, 0xb7, 0x8b, 0xef, + 0xf6, 0xe1, 0x93, 0x0d, 0x4e, 0x01, 0x2b, 0x68, 0x28, 0x88, 0x83, 0xca, 0xcf, 0xf7, 0x6f, 0xee, 0x98, 0xdc, 0x06, + 0xc9, 0xf4, 0x6a, 0xe1, 0x3a, 0x9e, 0xf4, 0x17, 0x16, 0xfd, 0x59, 0x2b, 0xa1, 0xbf, 0x00, 0xc3, 0x07, 0x6e, 0xcf, + 0x99, 0xe9, 0xdc, 0xc5, 0xa2, 0x7c, 0x9a, 0x71, 0x2b, 0xb9, 0x9b, 0x33, 0x6a, 0x4d, 0x73, 0x00, 0x90, 0x16, 0x4a, + 0x8d, 0x3f, 0x51, 0xd5, 0xa1, 0xa4, 0xc6, 0xb7, 0x91, 0x2a, 0x74, 0x74, 0x59, 0xe4, 0xa7, 0x8b, 0x2b, 0x62, 0xe1, + 0x75, 0x70, 0x7b, 0x8a, 0xe1, 0x8b, 0xef, 0x99, 0xd3, 0xf2, 0x83, 0xca, 0x37, 0x85, 0xe2, 0x6c, 0xd8, 0x18, 0x79, + 0xeb, 0xfe, 0xd4, 0x12, 0x34, 0x7c, 0x8b, 0xde, 0x9a, 0x57, 0xff, 0xed, 0x69, 0x15, 0xa0, 0x5b, 0x1c, 0xe1, 0xe9, + 0x0e, 0x8a, 0x66, 0xee, 0xa9, 0x78, 0x51, 0x06, 0xca, 0xe4, 0x75, 0x3f, 0xe3, 0x45, 0x70, 0x52, 0x68, 0x9f, 0xd7, + 0xcf, 0xeb, 0x05, 0x55, 0x33, 0xc9, 0xe9, 0xed, 0xc2, 0xbc, 0xe1, 0xfb, 0xeb, 0x16, 0x5f, 0x67, 0x29, 0x53, 0xb1, + 0x1d, 0x94, 0xd1, 0x6f, 0x0b, 0x60, 0xbe, 0xfa, 0x92, 0x89, 0x05, 0x9d, 0xac, 0xb9, 0x59, 0xcd, 0xf7, 0xb6, 0x67, + 0x7e, 0x8f, 0xe0, 0xe5, 0x5b, 0xa5, 0x68, 0xeb, 0x67, 0x95, 0x67, 0x2d, 0x30, 0x77, 0x08, 0x31, 0x37, 0x91, 0x06, + 0x8b, 0x02, 0xf2, 0xdd, 0xcd, 0x4b, 0xcb, 0x28, 0xf3, 0x68, 0xde, 0xfc, 0xb3, 0x5e, 0x50, 0x07, 0xa4, 0x17, 0xdf, + 0xb9, 0xdc, 0x40, 0x42, 0x8b, 0x7b, 0xa1, 0xc6, 0xdb, 0xe8, 0x71, 0xca, 0xac, 0x3c, 0x40, 0xb2, 0x86, 0x0e, 0x5a, + 0xdd, 0x87, 0x74, 0x5c, 0x1c, 0x5f, 0xa3, 0xe9, 0xfb, 0x26, 0xde, 0x4e, 0x74, 0x35, 0x79, 0x4f, 0xd9, 0x6d, 0x96, + 0x81, 0x12, 0xcb, 0xcb, 0x0b, 0x79, 0x27, 0xac, 0xa5, 0xa4, 0xb9, 0x0e, 0x13, 0x67, 0x83, 0xfa, 0xeb, 0x99, 0x97, + 0x5e, 0xd6, 0x3e, 0xe0, 0x1b, 0x85, 0x0a, 0xee, 0xe7, 0x09, 0x35, 0x82, 0xfd, 0x20, 0x45, 0xea, 0x41, 0x9d, 0x30, + 0xa3, 0x06, 0x23, 0x69, 0xba, 0xb4, 0x14, 0x67, 0x4e, 0xfa, 0x8b, 0x8a, 0xd2, 0x85, 0x5d, 0xbe, 0xad, 0x62, 0xa9, + 0x4e, 0x6f, 0x63, 0xa4, 0x3c, 0x6a, 0xde, 0x9b, 0xb7, 0x45, 0xde, 0x4e, 0x1b, 0x12, 0x22, 0xe9, 0x05, 0x72, 0xd9, + 0x3a, 0x3f, 0x84, 0xee, 0xe7, 0x2c, 0x1e, 0xc1, 0xa6, 0x84, 0x51, 0x90, 0xeb, 0x32, 0xd7, 0x7b, 0x43, 0x03, 0x13, + 0xf2, 0x63, 0x7e, 0x96, 0x80, 0xa5, 0x6d, 0xdd, 0x7a, 0x67, 0x7c, 0x68, 0x99, 0x43, 0xef, 0x96, 0x00, 0x32, 0xb7, + 0x6b, 0xf6, 0xae, 0xd6, 0x39, 0x99, 0x38, 0x24, 0x35, 0xa0, 0xef, 0x19, 0x75, 0xfa, 0xc6, 0x32, 0xb1, 0x48, 0xa4, + 0xa6, 0x37, 0x89, 0x95, 0xe6, 0xb1, 0xa7, 0xaf, 0x4e, 0x3d, 0x03, 0xbe, 0x36, 0xf7, 0x9a, 0xdd, 0xc7, 0x06, 0xec, + 0xb0, 0xd0, 0xc6, 0xee, 0x02, 0xe6, 0xf2, 0xa6, 0xdd, 0x4e, 0xa8, 0x3c, 0xba, 0x71, 0xcc, 0x0d, 0xc1, 0x40, 0x7a, + 0x11, 0x8d, 0xa2, 0xfc, 0xbe, 0xea, 0x49, 0xec, 0x75, 0x07, 0x76, 0xb7, 0xfb, 0xb3, 0x23, 0x55, 0xb8, 0xbd, 0x4c, + 0x06, 0x7e, 0xb2, 0x3d, 0x23, 0x79, 0x68, 0x2a, 0xf6, 0x80, 0x26, 0x1b, 0xde, 0x05, 0xe2, 0x86, 0xf1, 0xbe, 0x0f, + 0xfb, 0xfb, 0x92, 0xaf, 0x09, 0xa8, 0x71, 0x20, 0x41, 0xb5, 0x64, 0xcf, 0x5f, 0xae, 0xcc, 0xe6, 0x32, 0xcc, 0x26, + 0x5e, 0xb9, 0xa8, 0xf3, 0xfe, 0xe9, 0xb5, 0x83, 0xfb, 0x2d, 0x35, 0x94, 0x9b, 0xf1, 0xcc, 0xff, 0x47, 0x4d, 0x61, + 0x43, 0xe0, 0x01, 0x59, 0x69, 0x21, 0xb9, 0xb2, 0xc0, 0xa7, 0x6f, 0x0e, 0x75, 0x3e, 0x8c, 0xe7, 0x2d, 0x66, 0x65, + 0x46, 0xe4, 0x62, 0x7c, 0x80, 0x48, 0x36, 0x50, 0x0c, 0x13, 0x2e, 0x60, 0xf4, 0xd1, 0x65, 0xda, 0xa2, 0x79, 0x20, + 0xed, 0xca, 0xd6, 0x1f, 0xcf, 0x0c, 0xbc, 0x92, 0xff, 0xc6, 0x79, 0x5c, 0x86, 0x39, 0xbe, 0xd2, 0xd8, 0x9e, 0x92, + 0xe7, 0xc2, 0x15, 0x19, 0xe5, 0xa1, 0xaa, 0x3c, 0xe9, 0x9c, 0xbb, 0xbb, 0x7a, 0x32, 0x1d, 0xd9, 0x0c, 0x60, 0x6e, + 0x69, 0xda, 0xd8, 0xb1, 0x52, 0x5d, 0xf2, 0x10, 0x6f, 0x30, 0x18, 0xec, 0xcb, 0xd6, 0xad, 0x3f, 0xdb, 0x28, 0x68, + 0xb8, 0x42, 0x10, 0x58, 0x82, 0x81, 0xab, 0x92, 0x04, 0xe9, 0x0f, 0x45, 0xde, 0xb9, 0x29, 0x79, 0x4f, 0x3d, 0xb9, + 0x78, 0x25, 0x79, 0x70, 0x68, 0x09, 0x70, 0xd1, 0x7f, 0xd6, 0x5a, 0xc9, 0xda, 0x52, 0xbe, 0x3b, 0xce, 0x3e, 0x76, + 0xba, 0x99, 0x05, 0xd9, 0xd2, 0x87, 0x51, 0x6c, 0xcf, 0xbd, 0x1e, 0xe6, 0xa1, 0x25, 0xb0, 0x90, 0xb9, 0x59, 0xda, + 0x01, 0xf1, 0x2d, 0x9a, 0xd4, 0x66, 0xc9, 0xff, 0xc4, 0x2d, 0x6f, 0x20, 0x44, 0xd4, 0xb6, 0xbe, 0x6b, 0x68, 0x74, + 0x12, 0x27, 0xb9, 0x41, 0xde, 0x7f, 0x53, 0x0a, 0x28, 0x50, 0xb6, 0x54, 0x76, 0x92, 0xdf, 0x7f, 0xe2, 0x21, 0x84, + 0x66, 0x36, 0x5e, 0x5a, 0xb5, 0x6e, 0x33, 0x6b, 0x09, 0xa7, 0x91, 0x30, 0xb3, 0x9b, 0x83, 0xae, 0x2a, 0x12, 0x8e, + 0x92, 0x34, 0xa6, 0x48, 0x47, 0x38, 0xdc, 0x69, 0xbe, 0xbb, 0x93, 0xba, 0x63, 0x01, 0x6b, 0x9b, 0x39, 0x6e, 0x01, + 0x02, 0x8c, 0xfa, 0x5d, 0x03, 0xd1, 0x44, 0x93, 0x53, 0xa8, 0xe5, 0x8d, 0xdc, 0xd5, 0xa3, 0x5b, 0xf3, 0x58, 0x83, + 0xf6, 0x59, 0xfd, 0x29, 0x21, 0xe0, 0xb6, 0xa2, 0xde, 0x93, 0x81, 0x15, 0xa9, 0x0b, 0xc1, 0x35, 0x10, 0x58, 0xef, + 0x8c, 0xd6, 0x3e, 0x35, 0x26, 0xd2, 0xfe, 0xa2, 0xc1, 0x05, 0x24, 0x04, 0x02, 0x98, 0x97, 0x65, 0xb3, 0x84, 0x4f, + 0x22, 0x39, 0x80, 0xaa, 0xc7, 0xa5, 0xb7, 0x5a, 0x4a, 0x44, 0xc3, 0xa3, 0x1a, 0x01, 0xd7, 0xed, 0x02, 0xe5, 0x03, + 0x46, 0x58, 0x39, 0x85, 0x79, 0x26, 0xa4, 0x6a, 0x52, 0x8c, 0xba, 0x99, 0x4d, 0xa4, 0x3c, 0x33, 0xce, 0x53, 0x49, + 0xd4, 0x69, 0xfd, 0x6b, 0xe5, 0x4b, 0x1b, 0x44, 0xdb, 0xf0, 0xd9, 0x70, 0x7d, 0xac, 0xb9, 0x1e, 0x6d, 0x06, 0xa6, + 0xb5, 0xab, 0x59, 0x04, 0x88, 0x7a, 0x2a, 0xbb, 0xab, 0xcf, 0x5c, 0x90, 0x87, 0x1a, 0x3f, 0xf2, 0xe2, 0x14, 0xec, + 0x4a, 0x3f, 0xbf, 0x69, 0x28, 0x40, 0x18, 0x2f, 0x1d, 0xf1, 0x92, 0x55, 0x5e, 0x6c, 0x8a, 0x36, 0xee, 0x30, 0xf6, + 0x7a, 0xb4, 0x02, 0x52, 0x8f, 0x4d, 0xdd, 0x49, 0x96, 0xac, 0x8b, 0x73, 0xca, 0xab, 0xb8, 0x67, 0xba, 0x34, 0x7d, + 0x4c, 0xfd, 0x87, 0x4a, 0xe7, 0xc4, 0x0a, 0xe1, 0x7f, 0x4b, 0xca, 0xce, 0x2a, 0x65, 0x5a, 0x90, 0x88, 0xb5, 0x20, + 0x0a, 0x9c, 0xef, 0x04, 0xc9, 0xc2, 0xb2, 0x88, 0x24, 0x4f, 0x63, 0x79, 0xad, 0x4b, 0xf0, 0x24, 0x7b, 0xa0, 0xc8, + 0x87, 0x5d, 0xd9, 0x25, 0xc1, 0xdc, 0xf3, 0x83, 0xb4, 0x61, 0xa2, 0xb0, 0x0f, 0x5a, 0xf2, 0xb8, 0x66, 0x01, 0x38, + 0x3d, 0xf4, 0x6b, 0xef, 0xf9, 0xd8, 0x36, 0x7e, 0x8b, 0xe0, 0x5d, 0x4e, 0x84, 0xfb, 0x39, 0x97, 0x04, 0xcb, 0xaf, + 0xae, 0x53, 0x66, 0xb1, 0x5a, 0x83, 0x8a, 0x97, 0x3b, 0xbc, 0x6d, 0xdd, 0x5f, 0x96, 0xf0, 0xbe, 0x93, 0xcd, 0x70, + 0x37, 0x1d, 0x91, 0xcd, 0xc4, 0x39, 0x92, 0x8a, 0x44, 0x5c, 0x75, 0x32, 0x8d, 0xc5, 0x87, 0x39, 0x01, 0x04, 0x93, + 0xfa, 0x37, 0x2a, 0x84, 0x36, 0x24, 0x74, 0x7c, 0xec, 0xf2, 0xb5, 0x61, 0xed, 0xd6, 0xd7, 0xca, 0xd6, 0xbe, 0x75, + 0x23, 0x8a, 0x0a, 0xed, 0x58, 0x2c, 0x86, 0x64, 0x8c, 0x5e, 0xe9, 0x37, 0xd6, 0x34, 0xc9, 0xe2, 0xe1, 0xab, 0xdb, + 0x68, 0x31, 0x0e, 0x62, 0x17, 0x78, 0xfb, 0xd1, 0xec, 0x6d, 0x2d, 0x29, 0x7e, 0xff, 0xea, 0x8c, 0xa2, 0x56, 0xfc, + 0x43, 0xe9, 0xcf, 0xba, 0xc0, 0x25, 0x2a, 0x03, 0x2d, 0x66, 0xf8, 0x83, 0x48, 0xab, 0x57, 0xc8, 0xb9, 0xcf, 0xb9, + 0x3e, 0x24, 0xff, 0xc5, 0x03, 0x6f, 0x28, 0x8b, 0x42, 0xa8, 0xeb, 0x11, 0x37, 0x52, 0xc4, 0x62, 0xdd, 0x7d, 0x79, + 0xd0, 0x16, 0x39, 0x0b, 0x66, 0xcd, 0x6e, 0xca, 0x34, 0xdc, 0x85, 0x4b, 0x8b, 0x6e, 0xd3, 0x6c, 0x13, 0xbc, 0x0c, + 0x3b, 0xe9, 0x38, 0x7a, 0x67, 0x03, 0xa1, 0x28, 0x08, 0x10, 0x4a, 0x1a, 0xfa, 0x67, 0x28, 0x6d, 0xa5, 0x98, 0x87, + 0x96, 0x72, 0xca, 0x65, 0x21, 0xe6, 0x7e, 0x42, 0x86, 0x81, 0xfb, 0xc5, 0x8d, 0xdc, 0xb4, 0x16, 0x48, 0x16, 0x89, + 0x1e, 0xf5, 0xbc, 0x7b, 0x72, 0x95, 0xc5, 0xa0, 0x07, 0x44, 0x0e, 0x70, 0xbd, 0x9b, 0xaa, 0x67, 0x25, 0xc1, 0xc0, + 0xd1, 0x7d, 0xc0, 0x5a, 0x5f, 0x5b, 0xc3, 0x44, 0x2b, 0x04, 0x5e, 0x42, 0x8d, 0x19, 0x12, 0xed, 0x03, 0xf5, 0x90, + 0x98, 0x00, 0x34, 0x05, 0xaf, 0xb1, 0x25, 0xd0, 0xb6, 0x6b, 0x4c, 0x09, 0x14, 0xb0, 0x32, 0xd5, 0x88, 0xc6, 0xcc, + 0x43, 0x47, 0x8c, 0xc4, 0x71, 0xee, 0x47, 0xe4, 0xc1, 0x86, 0xd4, 0x21, 0xda, 0xfe, 0xa6, 0x7e, 0xb0, 0xc6, 0x99, + 0x31, 0x8d, 0x5c, 0x20, 0x1c, 0xaf, 0x41, 0xe1, 0x86, 0xb1, 0x61, 0xfb, 0xaa, 0x26, 0xab, 0x3a, 0x23, 0x32, 0xab, + 0x9e, 0x39, 0xec, 0x57, 0xf1, 0x47, 0x97, 0x58, 0x49, 0xb3, 0xe1, 0x9b, 0xa4, 0xd4, 0xb3, 0xe5, 0xd5, 0x37, 0x46, + 0x22, 0x3d, 0xdd, 0x07, 0x5c, 0x70, 0x0d, 0xa2, 0x9b, 0x92, 0x9f, 0x7d, 0x32, 0x6a, 0x00, 0x8f, 0xda, 0xb4, 0x43, + 0x15, 0x14, 0x83, 0x81, 0x91, 0xa6, 0xd3, 0xd2, 0x98, 0x2e, 0xd1, 0x6c, 0xa0, 0x99, 0xc7, 0x78, 0x22, 0xd2, 0x89, + 0xed, 0x1d, 0xcf, 0x57, 0x2d, 0x1a, 0x59, 0xad, 0xda, 0x20, 0xcb, 0x6f, 0xd3, 0x7a, 0xad, 0x32, 0x32, 0xde, 0x96, + 0x01, 0xf1, 0x47, 0x28, 0x0b, 0x86, 0x8a, 0x8a, 0x24, 0xc5, 0x14, 0x15, 0x97, 0xc6, 0x47, 0xae, 0x02, 0x74, 0x19, + 0x56, 0xad, 0xcd, 0xab, 0xf0, 0xf6, 0x49, 0x0c, 0xf7, 0x41, 0xa9, 0xc2, 0xe9, 0xe5, 0x62, 0xb6, 0x3c, 0x56, 0xe1, + 0x8f, 0x5d, 0x75, 0x12, 0x3c, 0x6d, 0xcf, 0xde, 0x39, 0xd5, 0xe8, 0x54, 0x5f, 0x1c, 0xb2, 0x63, 0x2f, 0xce, 0x18, + 0x88, 0x90, 0x93, 0xd9, 0x6a, 0x17, 0x7d, 0x92, 0xee, 0x35, 0x02, 0x7d, 0x39, 0xc2, 0x55, 0xcf, 0x9b, 0x13, 0xca, + 0x6c, 0x35, 0xd2, 0x51, 0x50, 0x9a, 0x21, 0x8a, 0xe1, 0x29, 0x12, 0x07, 0x9e, 0xe6, 0xc4, 0x61, 0xc2, 0x00, 0x25, + 0x6c, 0x73, 0xa2, 0x8b, 0xf6, 0x9f, 0x61, 0x96, 0xef, 0x59, 0xc6, 0x96, 0xe6, 0xd1, 0x80, 0x14, 0x01, 0x26, 0x95, + 0x62, 0x15, 0xff, 0x60, 0x2e, 0x1c, 0x0f, 0x13, 0x83, 0xc9, 0xcf, 0xb0, 0x0f, 0xe5, 0x4d, 0x0f, 0x2f, 0x8f, 0xca, + 0x81, 0x34, 0xb1, 0x4a, 0x3d, 0x45, 0x6b, 0xa4, 0x76, 0xdb, 0x0d, 0x6c, 0xb9, 0xd2, 0x0d, 0xd5, 0xf8, 0xa2, 0x08, + 0x46, 0xff, 0x52, 0x03, 0xe1, 0xe3, 0x93, 0x18, 0x63, 0x30, 0x29, 0x7a, 0x53, 0x3b, 0x30, 0xed, 0x9b, 0x52, 0x75, + 0x2d, 0x80, 0x8f, 0x4d, 0x15, 0xf8, 0xcf, 0xc1, 0x29, 0x22, 0xe6, 0xce, 0x58, 0x4c, 0x56, 0x67, 0x50, 0x97, 0xfb, + 0xdf, 0x0f, 0x1d, 0x41, 0xd8, 0xbf, 0x4e, 0xe7, 0xe8, 0x2c, 0x40, 0x26, 0x7b, 0xe0, 0x82, 0x58, 0x2a, 0xc6, 0x31, + 0x8f, 0x46, 0x84, 0xa5, 0x22, 0x6b, 0xbc, 0x8f, 0x4b, 0x49, 0xf3, 0xb5, 0x0e, 0x1c, 0x10, 0x85, 0x83, 0xf9, 0xad, + 0x41, 0xdf, 0x42, 0xc8, 0xbc, 0xaa, 0x72, 0x00, 0xa8, 0x8b, 0x71, 0x31, 0xae, 0x25, 0x24, 0x23, 0x3f, 0xee, 0xa8, + 0x1d, 0xa3, 0xa1, 0xc9, 0xc7, 0xa7, 0xeb, 0x54, 0xd3, 0xbd, 0xfa, 0x87, 0x1a, 0x8a, 0xf9, 0x7b, 0x99, 0x18, 0x24, + 0x6a, 0x96, 0xec, 0xbd, 0xf8, 0xe9, 0x3c, 0x72, 0x9e, 0x9a, 0x9e, 0x1a, 0xc6, 0xac, 0x56, 0x37, 0x26, 0x5b, 0xa6, + 0x76, 0xe4, 0x0e, 0xb4, 0x3a, 0xe3, 0xeb, 0xf4, 0x06, 0xe2, 0x78, 0x2f, 0x24, 0x6e, 0x45, 0x47, 0x8a, 0xd2, 0x8f, + 0x2b, 0x23, 0xa0, 0x46, 0xd1, 0xa1, 0x2a, 0x99, 0xe6, 0x6f, 0x86, 0x5c, 0x55, 0x41, 0x87, 0x55, 0x50, 0x4d, 0x31, + 0x33, 0xcd, 0xca, 0xa1, 0x91, 0x06, 0x14, 0x4a, 0x69, 0x0c, 0x8a, 0x5a, 0xaa, 0x90, 0xec, 0x79, 0x89, 0xa5, 0xe7, + 0x38, 0x09, 0x1d, 0xca, 0xa6, 0x83, 0xe7, 0x51, 0xb8, 0x24, 0xec, 0x79, 0xcd, 0x0c, 0xd3, 0x64, 0x2b, 0x2d, 0xab, + 0x5a, 0x54, 0x42, 0x21, 0xd7, 0xe7, 0xa5, 0x52, 0x9e, 0x46, 0xb8, 0x8d, 0xa7, 0x34, 0x5a, 0x45, 0xf9, 0x0a, 0xfb, + 0x38, 0xf9, 0x14, 0xf9, 0x77, 0xa0, 0xac, 0xbe, 0x14, 0x40, 0x06, 0x22, 0x09, 0x56, 0x02, 0xf9, 0x7e, 0xf1, 0x82, + 0x8b, 0xf0, 0x8b, 0x00, 0x5e, 0x45, 0xbc, 0xce, 0x74, 0x43, 0x9e, 0xaf, 0x7f, 0xfd, 0x9f, 0xea, 0xf5, 0x9f, 0x29, + 0x1c, 0x6e, 0x80, 0xf4, 0x06, 0xd2, 0x2c, 0xe8, 0x1f, 0xad, 0x57, 0x5f, 0xa9, 0x4b, 0x99, 0xbd, 0x8e, 0xc2, 0x77, + 0xb7, 0x74, 0x6d, 0xf4, 0x6c, 0x24, 0x42, 0xb3, 0x52, 0xfa, 0x5e, 0x48, 0x5a, 0x06, 0x6a, 0xe4, 0x8b, 0xbd, 0xd9, + 0x80, 0x69, 0x6b, 0x9c, 0xc2, 0xed, 0xbd, 0xa4, 0xc6, 0x5b, 0x8b, 0x13, 0xa0, 0xca, 0x62, 0x8a, 0xef, 0xd8, 0x79, + 0x20, 0xf7, 0xc1, 0xa3, 0x36, 0x7e, 0xbb, 0x73, 0x7b, 0x3e, 0x0d, 0xec, 0x12, 0x51, 0x0e, 0xa2, 0x6d, 0x58, 0x65, + 0xec, 0xf5, 0x45, 0x84, 0xcd, 0x65, 0x49, 0x83, 0x92, 0x0a, 0xbc, 0xf1, 0xca, 0x5d, 0xb8, 0xb9, 0x3d, 0x82, 0x00, + 0xfa, 0x4d, 0x13, 0xe6, 0x76, 0x88, 0x54, 0x18, 0x77, 0xe9, 0x71, 0x52, 0xe6, 0xf9, 0x77, 0x7a, 0x1c, 0x33, 0xc6, + 0xce, 0xcc, 0x33, 0xab, 0xd0, 0xd0, 0xb2, 0xa1, 0xf1, 0x53, 0xb0, 0x5b, 0x64, 0x14, 0x6b, 0x45, 0x01, 0xfb, 0xa0, + 0x14, 0x68, 0x79, 0x10, 0x8a, 0xea, 0x22, 0x3e, 0xc1, 0xf1, 0xe1, 0x8f, 0x86, 0x03, 0x25, 0x86, 0x16, 0x09, 0xb6, + 0xd8, 0x23, 0x1d, 0x36, 0xe5, 0xa6, 0xde, 0xa9, 0xb3, 0x0a, 0xe7, 0x4d, 0x63, 0x59, 0x07, 0xa5, 0xdf, 0xd5, 0xe3, + 0x75, 0xfd, 0x84, 0x37, 0xf8, 0x5b, 0x29, 0xd5, 0xe3, 0x17, 0xf5, 0x7e, 0x8d, 0x5d, 0xa5, 0x3a, 0x8c, 0xd1, 0xe2, + 0x4f, 0x26, 0xa4, 0x31, 0x2e, 0xec, 0xa1, 0x7e, 0x25, 0x1d, 0x7c, 0x41, 0xd9, 0xf5, 0xc8, 0xc6, 0x64, 0x3d, 0x28, + 0x80, 0xfb, 0xbc, 0x7f, 0xfb, 0xa8, 0x9f, 0x05, 0x39, 0x34, 0x22, 0x45, 0x4d, 0xfc, 0x6e, 0xc8, 0x4d, 0xaa, 0x51, + 0x10, 0xbb, 0x36, 0xa5, 0x76, 0x78, 0x0f, 0xb5, 0xf7, 0x6f, 0x32, 0xa8, 0x00, 0x6a, 0x7b, 0xd3, 0x8f, 0x65, 0x70, + 0x5a, 0x3d, 0x4d, 0x4e, 0x18, 0xa9, 0x01, 0x52, 0x53, 0xc4, 0x66, 0xc2, 0xca, 0xc5, 0xe7, 0xc0, 0x6c, 0xd6, 0xa4, + 0xb3, 0xf6, 0x16, 0x5c, 0x5a, 0x46, 0xdd, 0xef, 0x59, 0xb8, 0xfb, 0x58, 0x06, 0x9f, 0x17, 0x6e, 0xa9, 0x3b, 0x68, + 0x85, 0x2c, 0x46, 0xad, 0xdc, 0x84, 0x43, 0x7b, 0x55, 0x25, 0x30, 0xd6, 0x6f, 0xd3, 0xc6, 0x59, 0x2f, 0x70, 0x60, + 0xe8, 0xbd, 0x1f, 0xb8, 0xac, 0xfc, 0x14, 0x88, 0x61, 0x78, 0xd5, 0xbc, 0x39, 0xe6, 0x8c, 0x17, 0xef, 0x79, 0x7b, + 0x86, 0x73, 0xfb, 0x5c, 0xf1, 0x47, 0xcf, 0x37, 0x65, 0xa3, 0x7a, 0x92, 0x38, 0x33, 0xeb, 0x58, 0x52, 0xf5, 0xc8, + 0x50, 0x2e, 0xee, 0x01, 0xa0, 0x42, 0x32, 0x2a, 0x82, 0x48, 0x23, 0x8d, 0xf2, 0x53, 0xe5, 0x95, 0xea, 0x7d, 0xc2, + 0x44, 0x89, 0x80, 0x19, 0x7c, 0xff, 0xa4, 0xd2, 0x15, 0xbb, 0x1e, 0xe0, 0x1f, 0x11, 0x2b, 0x88, 0x68, 0x16, 0x49, + 0x28, 0x0a, 0x48, 0xc6, 0xef, 0x8e, 0xe5, 0x91, 0x9d, 0x49, 0x88, 0xe0, 0xa0, 0xee, 0x06, 0x08, 0x10, 0xf3, 0x35, + 0x42, 0xbb, 0xfc, 0x2b, 0x3d, 0xae, 0xd7, 0xac, 0x50, 0x87, 0x59, 0x76, 0xa1, 0x01, 0x6f, 0xb3, 0xe8, 0x97, 0xca, + 0x85, 0xef, 0xb5, 0x76, 0xb2, 0xbe, 0xbc, 0xfd, 0xb8, 0x5c, 0x93, 0xd2, 0xc1, 0xd2, 0x02, 0x50, 0xb2, 0xb1, 0xcc, + 0xc6, 0xa9, 0x5c, 0xb5, 0x5e, 0x59, 0x8a, 0xd2, 0x09, 0xc3, 0x76, 0x08, 0x29, 0x1e, 0x8c, 0x6a, 0xc4, 0xcc, 0xb1, + 0xa6, 0xc7, 0xbd, 0xf4, 0x60, 0x8f, 0x7b, 0x3f, 0x84, 0xce, 0x05, 0x3d, 0x62, 0x1e, 0x01, 0xe7, 0x65, 0xe5, 0xa9, + 0x90, 0x69, 0x42, 0x85, 0x38, 0x08, 0x20, 0x33, 0xae, 0x7b, 0x60, 0x4c, 0x99, 0x16, 0x3b, 0x2c, 0x26, 0xb3, 0x81, + 0x82, 0x90, 0x1b, 0x9b, 0x44, 0x0a, 0x39, 0x32, 0x89, 0xa5, 0x07, 0xf6, 0x33, 0x20, 0x6b, 0x3d, 0x8a, 0xd3, 0x9a, + 0x56, 0x44, 0x97, 0x22, 0x70, 0xb9, 0x91, 0xf2, 0x4d, 0x9f, 0xd0, 0x2b, 0x33, 0x47, 0xc3, 0xf7, 0xdb, 0x59, 0x09, + 0xc3, 0x72, 0x7c, 0xec, 0xec, 0x65, 0xfd, 0xe3, 0x39, 0x85, 0x6a, 0x6e, 0x67, 0x2e, 0x5f, 0x32, 0xf9, 0xef, 0x75, + 0x18, 0x48, 0x5e, 0x28, 0x7c, 0x56, 0x13, 0x88, 0xb4, 0x24, 0xa5, 0xe0, 0xad, 0xe1, 0xef, 0x45, 0x15, 0xc6, 0xfd, + 0x87, 0xef, 0xc2, 0xc5, 0x8d, 0xef, 0xaf, 0xea, 0xbe, 0x8a, 0xae, 0xbd, 0x11, 0x90, 0x74, 0xce, 0x96, 0x3b, 0x6c, + 0xa0, 0xd6, 0x5b, 0x84, 0xb2, 0xce, 0xeb, 0x8b, 0xfb, 0x9a, 0x3c, 0xbb, 0x6e, 0x3f, 0xee, 0x02, 0x8f, 0x98, 0xac, + 0xcd, 0xda, 0x42, 0xf3, 0xc8, 0x1a, 0xdc, 0xfd, 0x0c, 0xc3, 0x3d, 0x80, 0x1d, 0x4d, 0xdd, 0xe2, 0x17, 0xde, 0x8b, + 0xf4, 0x3e, 0x65, 0xab, 0xb7, 0xfa, 0xa7, 0xcd, 0x2f, 0x7f, 0x6e, 0x1c, 0x53, 0xa8, 0x61, 0xed, 0xa6, 0xba, 0x27, + 0x33, 0x7b, 0x30, 0x2d, 0x83, 0x14, 0xae, 0x74, 0xf5, 0x55, 0xc0, 0x51, 0xd0, 0x73, 0x42, 0x07, 0x9b, 0x28, 0x34, + 0x8f, 0x5f, 0x10, 0xaa, 0x64, 0xfe, 0xf1, 0x72, 0x65, 0x0c, 0x82, 0xf0, 0xb7, 0x23, 0xd6, 0x8a, 0xa8, 0xb3, 0x63, + 0x7f, 0xcc, 0xd5, 0x04, 0xbf, 0xa4, 0x1e, 0x8e, 0x16, 0xe1, 0x5f, 0xea, 0xb0, 0xdd, 0x61, 0x96, 0x1e, 0x68, 0xdc, + 0xec, 0x37, 0xf0, 0x8d, 0xe8, 0xcc, 0xc2, 0x8e, 0x2f, 0x4b, 0xb5, 0x43, 0x87, 0x43, 0xcd, 0xb0, 0x04, 0x7a, 0x1e, + 0x06, 0xe8, 0xa1, 0x1b, 0x7b, 0xbb, 0x54, 0x07, 0xe5, 0x20, 0x11, 0xbd, 0x87, 0x42, 0xe8, 0xd1, 0x5c, 0x9d, 0xf6, + 0xa8, 0x07, 0x6e, 0x2b, 0x0c, 0xc8, 0x83, 0xfe, 0xe0, 0x63, 0x56, 0x48, 0xd5, 0x45, 0x75, 0x1d, 0x35, 0xad, 0x31, + 0x23, 0x1f, 0xd3, 0x77, 0xbf, 0xbc, 0x21, 0xa2, 0x1d, 0x59, 0xaf, 0x31, 0xce, 0xb0, 0xf2, 0xa1, 0x4c, 0x85, 0x29, + 0xd5, 0x05, 0xdb, 0x63, 0x43, 0x7f, 0xd6, 0x76, 0x19, 0x59, 0xa1, 0x88, 0x8e, 0x60, 0xe1, 0x7f, 0x7c, 0x59, 0xdc, + 0x0a, 0x32, 0xb2, 0xe6, 0xb7, 0x25, 0x39, 0x63, 0x1c, 0xfa, 0xba, 0x5c, 0xce, 0xbb, 0x58, 0x3d, 0xfa, 0xf0, 0x24, + 0xa4, 0x48, 0xd6, 0x3e, 0x86, 0x56, 0x03, 0x43, 0x04, 0x21, 0xf9, 0x66, 0xad, 0xf5, 0x1c, 0x70, 0x12, 0xf3, 0xbb, + 0x0e, 0xec, 0xb7, 0xf3, 0x3c, 0xef, 0x10, 0x10, 0x20, 0xff, 0x1a, 0x62, 0x9c, 0x55, 0xd4, 0x3b, 0xd3, 0xa2, 0xaa, + 0x97, 0x8b, 0x59, 0x61, 0x4d, 0xc7, 0x98, 0x34, 0x54, 0x5e, 0xca, 0xa6, 0x52, 0x17, 0x32, 0x9a, 0xc7, 0x82, 0x7e, + 0x74, 0x79, 0x9d, 0xe1, 0xac, 0xa1, 0x3d, 0x4d, 0xbf, 0x19, 0x00, 0x23, 0x6d, 0x17, 0x61, 0xa2, 0x72, 0x58, 0x95, + 0x23, 0x23, 0x57, 0x59, 0x81, 0x8f, 0x32, 0x3e, 0x6f, 0xa0, 0x05, 0x2e, 0xac, 0x2e, 0x39, 0x92, 0x15, 0xa2, 0xa3, + 0xb8, 0xf1, 0x7e, 0x42, 0x0c, 0x1f, 0xc5, 0x4c, 0x74, 0xd2, 0x8c, 0x63, 0xde, 0xfd, 0x39, 0x08, 0xad, 0x39, 0xa2, + 0xc1, 0xc2, 0x5b, 0x1a, 0x72, 0x98, 0x25, 0xaf, 0xac, 0x48, 0x25, 0xc3, 0x6f, 0x05, 0x2a, 0xd3, 0x29, 0x44, 0x6b, + 0x5c, 0x02, 0xa7, 0xed, 0x27, 0xf3, 0x2e, 0x78, 0x66, 0x0a, 0xe7, 0xc2, 0xf1, 0x62, 0xc6, 0x9a, 0x12, 0x43, 0xb1, + 0x1c, 0x95, 0x0e, 0x79, 0xaa, 0x50, 0x77, 0xab, 0x88, 0x5a, 0x5b, 0xf7, 0x93, 0x7e, 0x52, 0x10, 0x4f, 0x5b, 0x82, + 0x8c, 0x9a, 0x1c, 0xef, 0x7a, 0x34, 0x7a, 0x62, 0x51, 0x6a, 0xa4, 0xb8, 0xf9, 0xee, 0x13, 0x96, 0x31, 0x02, 0xcf, + 0x55, 0x4a, 0x8e, 0x0d, 0x55, 0x99, 0xfd, 0x81, 0xfa, 0x66, 0x82, 0x83, 0xbd, 0x84, 0x22, 0xb5, 0x55, 0x72, 0x82, + 0xe9, 0x83, 0x2e, 0xe5, 0x78, 0x14, 0xf6, 0x8d, 0xda, 0xfc, 0x25, 0x82, 0x0b, 0x2c, 0xb9, 0xcb, 0xa7, 0x33, 0xb5, + 0x45, 0x79, 0x26, 0xb7, 0x88, 0xb8, 0x58, 0x87, 0xda, 0xa3, 0x86, 0x0c, 0xe2, 0x4d, 0xd7, 0x56, 0x0c, 0xc3, 0x27, + 0x29, 0x45, 0x38, 0xef, 0x8a, 0xc1, 0x7d, 0xdb, 0x35, 0xe6, 0x12, 0x8a, 0xc9, 0xdf, 0xdb, 0xfd, 0x2c, 0x2d, 0x15, + 0x5f, 0xb5, 0xdd, 0x1c, 0xe5, 0xf9, 0x23, 0x81, 0xee, 0x71, 0x2c, 0xb7, 0x37, 0x69, 0xe2, 0xb3, 0x3c, 0x7d, 0x9b, + 0x8d, 0xc1, 0x42, 0xfe, 0x7f, 0xb3, 0x14, 0x2f, 0xb0, 0x7a, 0x60, 0x52, 0x90, 0x3b, 0x1a, 0x53, 0xb9, 0x76, 0x6c, + 0x6c, 0x2b, 0xdf, 0x5d, 0x8c, 0x75, 0x32, 0xb5, 0xf2, 0x6d, 0xec, 0xd8, 0xf0, 0xab, 0x68, 0xbe, 0xbb, 0xd8, 0xac, + 0x2b, 0x5e, 0xdb, 0xea, 0x17, 0xdc, 0xf1, 0x9f, 0xc3, 0x71, 0xeb, 0x3c, 0x6f, 0x1e, 0x47, 0x1f, 0xf7, 0x6c, 0xdf, + 0xa5, 0x45, 0x88, 0xf5, 0x97, 0x8c, 0x3d, 0x52, 0xe7, 0xc7, 0xc4, 0xdb, 0xf1, 0xb5, 0xdf, 0xae, 0xe3, 0x88, 0x3a, + 0x55, 0xfe, 0x87, 0x85, 0xe9, 0x53, 0xb3, 0x1e, 0xed, 0xf9, 0x34, 0x4d, 0xdf, 0x09, 0xd2, 0x6d, 0x9a, 0xa6, 0xbf, + 0x15, 0x1d, 0x6d, 0xbc, 0x58, 0xd7, 0x80, 0xd9, 0x3b, 0xa0, 0x6e, 0xf6, 0x41, 0xac, 0xe4, 0x58, 0x62, 0x31, 0xac, + 0xf5, 0x38, 0x6c, 0x44, 0xde, 0x34, 0xfa, 0xe0, 0x62, 0x61, 0x62, 0x07, 0x8c, 0xfc, 0x18, 0x16, 0x86, 0x0e, 0x49, + 0x55, 0xdb, 0x35, 0x7e, 0x38, 0xa9, 0x8f, 0xb0, 0x30, 0x56, 0x13, 0xd9, 0xff, 0x2c, 0xc8, 0x7b, 0x50, 0x60, 0x8b, + 0xeb, 0x4e, 0xe3, 0x52, 0x3a, 0xf0, 0xe5, 0x2b, 0x41, 0x33, 0x39, 0xa0, 0x49, 0x6f, 0x31, 0xb6, 0x73, 0x9e, 0x44, + 0x2f, 0x0e, 0x29, 0x4d, 0xa1, 0x88, 0xae, 0xaa, 0xa4, 0xa9, 0x2d, 0xfb, 0x38, 0x1a, 0xac, 0x7d, 0xe9, 0x70, 0xf4, + 0x58, 0x01, 0xc3, 0xca, 0x7f, 0xa7, 0x29, 0x07, 0xea, 0x2e, 0xd8, 0x7c, 0xf4, 0x15, 0x0e, 0x13, 0x7c, 0x1d, 0x34, + 0x59, 0x59, 0xa2, 0x9b, 0xda, 0x50, 0x78, 0x4c, 0xfb, 0x6d, 0x0c, 0x38, 0x54, 0xe1, 0x25, 0x37, 0x61, 0xd5, 0x2d, + 0xc7, 0xfd, 0xad, 0x4c, 0x78, 0xb9, 0x1d, 0x26, 0x5b, 0xc3, 0xd6, 0x40, 0x3c, 0x63, 0x18, 0x0c, 0xa2, 0xa1, 0xc5, + 0x25, 0x89, 0x57, 0x30, 0x6b, 0x64, 0xcf, 0x45, 0xa3, 0x64, 0x58, 0x63, 0xdc, 0x98, 0x50, 0xf1, 0x7a, 0x21, 0x86, + 0xf3, 0x69, 0x9a, 0xa6, 0x28, 0x1f, 0x58, 0x70, 0x83, 0x05, 0xad, 0x0a, 0x87, 0x03, 0x9a, 0x6d, 0x8b, 0x46, 0x8b, + 0xd2, 0xa4, 0x4d, 0x25, 0x9d, 0xc4, 0x57, 0xfa, 0xb9, 0x8c, 0x75, 0xb6, 0xaa, 0x26, 0x8c, 0x38, 0xda, 0x0f, 0x8d, + 0x52, 0x75, 0x10, 0xa1, 0x3a, 0x00, 0xce, 0x26, 0x18, 0xf0, 0xd0, 0x46, 0xf7, 0x03, 0x54, 0x17, 0x32, 0xb4, 0x6b, + 0x58, 0xe4, 0xba, 0x99, 0x38, 0xe2, 0x95, 0x7e, 0xa6, 0x6f, 0xe7, 0x68, 0x68, 0x23, 0x49, 0x9b, 0x20, 0x46, 0x1c, + 0xcd, 0x98, 0xfe, 0x60, 0xd3, 0x46, 0xfb, 0xd8, 0xc4, 0x83, 0x1d, 0xf4, 0x72, 0x5c, 0x90, 0x46, 0x9f, 0x55, 0x72, + 0x50, 0xb8, 0x0c, 0xac, 0x79, 0x25, 0xe5, 0xde, 0x2f, 0xf6, 0x65, 0xac, 0xf1, 0xad, 0x5a, 0x99, 0xad, 0x9e, 0x31, + 0x12, 0x23, 0x7b, 0x21, 0x0c, 0x7e, 0x25, 0x7b, 0x3d, 0x6f, 0x79, 0x4d, 0x71, 0xdf, 0xcf, 0x21, 0x3b, 0x26, 0x0c, + 0x18, 0xe8, 0xa2, 0x4c, 0x4e, 0xbb, 0xfa, 0xe8, 0xd5, 0xe7, 0x77, 0xc3, 0xe5, 0x05, 0xe9, 0xf2, 0xc9, 0x5e, 0x47, + 0xae, 0xfb, 0xe1, 0xcf, 0xbc, 0x22, 0xd8, 0x8f, 0x95, 0xff, 0x1c, 0xa2, 0x88, 0x00, 0x60, 0x05, 0x89, 0x8d, 0x66, + 0x73, 0xb0, 0xaf, 0x8b, 0x8e, 0x76, 0x9d, 0xc8, 0x14, 0x95, 0xe1, 0x25, 0x7b, 0x11, 0x61, 0x17, 0xd1, 0x70, 0xb0, + 0x21, 0x6c, 0x62, 0x8b, 0x69, 0xe8, 0x62, 0xf9, 0x66, 0x7e, 0x5a, 0xe3, 0x76, 0xcc, 0xad, 0x43, 0xa1, 0x93, 0xd4, + 0xe8, 0x36, 0x03, 0x9f, 0xe3, 0x4f, 0xe1, 0x84, 0x63, 0x57, 0x69, 0x83, 0x0a, 0xcb, 0xb1, 0x59, 0xcd, 0xa3, 0x28, + 0x78, 0x3e, 0x5b, 0xe7, 0x50, 0xcc, 0x6d, 0x59, 0x2d, 0x58, 0x91, 0x23, 0xde, 0x71, 0xbd, 0x6e, 0xdb, 0x66, 0x17, + 0x9a, 0x1c, 0x51, 0x45, 0x0e, 0xcc, 0xb2, 0xa5, 0x02, 0x6a, 0xb1, 0xf0, 0xa4, 0x1d, 0x06, 0x13, 0xca, 0x22, 0x9e, + 0x5e, 0x74, 0x99, 0x2f, 0x4a, 0x93, 0xb2, 0x30, 0x17, 0x5b, 0x61, 0x0e, 0x6c, 0xed, 0x23, 0x6b, 0x18, 0x2c, 0x25, + 0x20, 0x8d, 0x7d, 0x58, 0xde, 0xa2, 0x4d, 0xb9, 0x63, 0xb4, 0xff, 0x99, 0xa5, 0xf6, 0xb1, 0x4b, 0x9b, 0x94, 0xbe, + 0xea, 0x0f, 0x2b, 0x13, 0x3e, 0x74, 0xfd, 0xaa, 0xdf, 0x6c, 0x8e, 0x4d, 0x50, 0x3f, 0x84, 0x2f, 0x49, 0x26, 0x35, + 0x38, 0x58, 0x18, 0xe8, 0xad, 0x0a, 0x1b, 0x83, 0x35, 0x01, 0x8f, 0xd2, 0x25, 0xd2, 0x44, 0x5c, 0x1b, 0x15, 0x55, + 0xf9, 0x22, 0x6b, 0xaf, 0xf4, 0x92, 0x7d, 0x40, 0xf0, 0xc6, 0x77, 0xb7, 0xd5, 0x68, 0xf8, 0x8e, 0x35, 0x89, 0x72, + 0x10, 0x1f, 0x56, 0xc3, 0x93, 0x66, 0x70, 0xf9, 0x8b, 0x76, 0xe2, 0x53, 0xb2, 0x5b, 0x5c, 0xa0, 0x81, 0xb3, 0xe0, + 0xe8, 0x9f, 0x11, 0xac, 0xaa, 0xab, 0xc8, 0x6a, 0xb3, 0x21, 0x41, 0x34, 0x0d, 0x96, 0x31, 0xb3, 0x36, 0x47, 0xd5, + 0x26, 0xb1, 0xc6, 0x38, 0x1a, 0xaf, 0xff, 0xce, 0x26, 0xf0, 0xf2, 0xac, 0x41, 0x7b, 0xe2, 0xba, 0xed, 0x52, 0x8b, + 0xc7, 0xe3, 0x3f, 0x1f, 0x79, 0x4c, 0xe0, 0xa0, 0xc5, 0x50, 0xcc, 0x0e, 0xc7, 0x7a, 0xd5, 0xe9, 0x55, 0x7c, 0x15, + 0x7a, 0x68, 0x7d, 0xbd, 0x99, 0xa0, 0xc8, 0xd1, 0x16, 0x66, 0xd9, 0xcc, 0x8d, 0x64, 0x6b, 0x8e, 0xbe, 0x59, 0x5b, + 0x24, 0x7b, 0x07, 0x0d, 0x96, 0x33, 0xf1, 0xc5, 0xa7, 0xd8, 0xbc, 0xd3, 0xd6, 0x31, 0x79, 0xc2, 0xb0, 0x23, 0xf8, + 0xa2, 0xa3, 0x2d, 0xc6, 0xe0, 0x7a, 0x8b, 0x75, 0xec, 0x61, 0x82, 0x94, 0x60, 0xb6, 0x00, 0x17, 0x1d, 0x3b, 0x9f, + 0x0c, 0x5f, 0xc5, 0xde, 0x19, 0xb0, 0x0d, 0xb4, 0x90, 0x3d, 0xf9, 0x45, 0x29, 0x4d, 0xe3, 0xe5, 0x53, 0x8b, 0xe0, + 0xc7, 0x0d, 0x75, 0x4e, 0xe5, 0xff, 0x8c, 0x46, 0x29, 0xe5, 0x49, 0x3a, 0xa1, 0x86, 0xc7, 0x56, 0xc0, 0x00, 0xb5, + 0xec, 0x59, 0xa5, 0xcf, 0x3e, 0xa4, 0x09, 0x5b, 0x88, 0x2d, 0xa9, 0xba, 0x79, 0x82, 0xf8, 0x3b, 0xbc, 0xe2, 0x22, + 0x06, 0x18, 0x39, 0xac, 0x1b, 0xfd, 0xe3, 0x16, 0xe9, 0x3c, 0x9e, 0xae, 0x0f, 0xe6, 0x84, 0xa3, 0xe1, 0xd7, 0x23, + 0x55, 0x26, 0x36, 0x1f, 0xaf, 0xdb, 0xd7, 0xa4, 0xb0, 0x83, 0x97, 0x4a, 0xb6, 0x7f, 0x1d, 0xbd, 0xf5, 0x66, 0x2b, + 0x23, 0x56, 0x24, 0xaf, 0x9c, 0xa2, 0x0a, 0xfa, 0xd5, 0xa7, 0xac, 0x92, 0x41, 0x0d, 0x18, 0xd6, 0x90, 0x51, 0x8d, + 0x18, 0xd7, 0x98, 0xcf, 0x04, 0x95, 0xcf, 0x0d, 0x3d, 0x5f, 0x18, 0x06, 0x2e, 0x0c, 0x23, 0x97, 0x82, 0x89, 0x57, + 0x86, 0x46, 0x85, 0x51, 0x4d, 0xab, 0x59, 0x35, 0xaf, 0xea, 0x4a, 0xd1, 0x1f, 0xf4, 0x6f, 0x81, 0xf1, 0x2f, 0x08, + 0x30, 0x1f, 0x62, 0xb2, 0xbc, 0x96, 0xed, 0x9b, 0xe7, 0x2f, 0x16, 0xcb, 0x2b, 0x18, 0x39, 0x42, 0x49, 0xeb, 0xb3, + 0x5f, 0xd4, 0x19, 0xda, 0xce, 0x01, 0xf4, 0x2d, 0x5d, 0xca, 0x78, 0x52, 0xec, 0xf7, 0x3f, 0xdf, 0xbb, 0xfd, 0x13, + 0xcf, 0x43, 0x5c, 0x36, 0xbe, 0x2c, 0x89, 0x7b, 0xec, 0x83, 0xdd, 0x06, 0x2d, 0x5e, 0x5c, 0x98, 0x51, 0x59, 0x5e, + 0xf4, 0xcc, 0xbb, 0x79, 0xcc, 0x82, 0xa1, 0x4e, 0xed, 0xa1, 0xe6, 0x5a, 0xf1, 0xf6, 0x07, 0x1d, 0xd6, 0x53, 0x71, + 0x6a, 0x25, 0xfb, 0xe6, 0x04, 0x56, 0xa2, 0x69, 0x26, 0xfe, 0x8c, 0xaa, 0x7f, 0xb0, 0xb2, 0x6b, 0x1d, 0xb2, 0x71, + 0xb3, 0xd3, 0xdb, 0x1f, 0xf5, 0x02, 0xf7, 0x71, 0x8d, 0x2b, 0x4b, 0xe0, 0x2c, 0x5f, 0x48, 0x67, 0x45, 0x53, 0x09, + 0x05, 0x68, 0x67, 0x7c, 0xc0, 0x4a, 0x46, 0xd0, 0x9f, 0x0d, 0xfd, 0x78, 0xed, 0x2e, 0xec, 0x14, 0xf9, 0xed, 0xdd, + 0xd3, 0x9d, 0xff, 0x09, 0x27, 0x94, 0x09, 0x8b, 0x44, 0xc5, 0x9f, 0x49, 0x17, 0x49, 0x2f, 0x50, 0xc5, 0xcd, 0xc4, + 0x99, 0x30, 0xd9, 0x8b, 0xb0, 0xd8, 0xed, 0x63, 0x53, 0x02, 0x2e, 0x50, 0x7f, 0xcc, 0x4f, 0x59, 0x3d, 0x8d, 0xa7, + 0xdf, 0xbd, 0x6c, 0x2a, 0x7a, 0xa3, 0xa7, 0xc5, 0x27, 0xff, 0xaa, 0xff, 0x96, 0x7a, 0x3b, 0xcb, 0xcd, 0x7c, 0xbf, + 0x26, 0x85, 0x3f, 0x98, 0x5c, 0xf5, 0x8e, 0x2f, 0x67, 0x9d, 0x87, 0x8d, 0xf3, 0x59, 0xe5, 0x6d, 0xea, 0xe6, 0x52, + 0xaa, 0xd4, 0xc6, 0x06, 0x9b, 0xdc, 0xfc, 0x53, 0xd5, 0x1e, 0x6d, 0x55, 0xb2, 0xfd, 0xf5, 0x38, 0xee, 0xc7, 0x77, + 0xfa, 0x0b, 0xfc, 0x92, 0x5e, 0x9a, 0xd9, 0x74, 0x7e, 0xfc, 0x73, 0x2b, 0xdd, 0x64, 0xf5, 0xcf, 0xe3, 0x52, 0xb7, + 0x54, 0x9b, 0xd4, 0x34, 0x8f, 0xba, 0x66, 0xc4, 0x03, 0xb4, 0xa6, 0xb7, 0x77, 0x3f, 0x65, 0xf5, 0x37, 0xea, 0xa4, + 0xda, 0xc3, 0xfd, 0x5f, 0x93, 0x37, 0x5b, 0x73, 0x31, 0x22, 0x85, 0xb1, 0x78, 0x3b, 0xa0, 0x7a, 0xbf, 0x7b, 0x0e, + 0xe9, 0xdc, 0xf8, 0x4f, 0x4f, 0x08, 0x12, 0xb3, 0x20, 0xf9, 0x7a, 0x7f, 0x43, 0xf1, 0xe0, 0x03, 0x4a, 0x7d, 0x0c, + 0xad, 0x0f, 0xfc, 0x6f, 0x9e, 0xc3, 0x1b, 0x8c, 0x5d, 0xa6, 0x03, 0xb7, 0xdc, 0x5c, 0xe8, 0xe7, 0x2f, 0xc4, 0x59, + 0x10, 0xee, 0xe1, 0x8b, 0xa9, 0x1d, 0x8c, 0x41, 0x39, 0x71, 0x04, 0x0e, 0xbe, 0x1d, 0x08, 0x93, 0x40, 0x7c, 0xbd, + 0xbf, 0xad, 0x78, 0xc8, 0x85, 0xdd, 0xcb, 0xfb, 0xd5, 0x9c, 0x4f, 0xdc, 0x71, 0x69, 0x57, 0x9f, 0x8e, 0x4f, 0xb2, + 0xdb, 0x3d, 0x0b, 0xaa, 0xdb, 0x39, 0xb7, 0x5b, 0x3e, 0x41, 0xd9, 0x2f, 0x3a, 0x52, 0xc3, 0x66, 0x35, 0xb4, 0x8c, + 0x7a, 0xd3, 0xfb, 0xf4, 0xb4, 0x70, 0xad, 0xe1, 0x2e, 0x80, 0x7f, 0x66, 0x40, 0xf6, 0x26, 0xc4, 0xde, 0x04, 0x84, + 0x6c, 0x33, 0xe3, 0x76, 0x33, 0x3e, 0x4e, 0x5e, 0xb3, 0x94, 0xb5, 0x77, 0x4e, 0x83, 0xf3, 0xb8, 0xde, 0x79, 0x5d, + 0xf9, 0x58, 0x94, 0x5c, 0xdd, 0xf1, 0x3a, 0x7d, 0xda, 0x43, 0xbe, 0x6f, 0x79, 0x2f, 0x49, 0x34, 0x38, 0x06, 0xf6, + 0xa2, 0x23, 0xa6, 0xb7, 0x2b, 0x43, 0x64, 0xda, 0x87, 0x31, 0xd4, 0x3d, 0xa9, 0xba, 0x14, 0x56, 0x5f, 0xf6, 0x4d, + 0x8d, 0x79, 0x2d, 0x8b, 0xad, 0x83, 0xae, 0xa6, 0x7b, 0x32, 0x63, 0x77, 0xcc, 0xb8, 0x8a, 0x99, 0xc1, 0x4e, 0x2f, + 0x9d, 0x11, 0x07, 0x2d, 0x1c, 0xfa, 0x23, 0x8b, 0xf7, 0xc9, 0xa8, 0x3b, 0x03, 0x43, 0xb5, 0x98, 0xbe, 0xcd, 0x56, + 0x0e, 0x98, 0x2d, 0x11, 0xe4, 0x35, 0x34, 0xbf, 0xd7, 0x14, 0x06, 0x3b, 0x85, 0x69, 0x63, 0xbd, 0xbd, 0x4b, 0xe5, + 0x52, 0x18, 0x88, 0x7a, 0xcf, 0x7d, 0x11, 0xd6, 0x3e, 0x28, 0x6e, 0xb0, 0x65, 0x82, 0xfd, 0xa2, 0xc4, 0xfe, 0x6d, + 0x3d, 0xcf, 0x0d, 0xec, 0xe2, 0x75, 0x61, 0x73, 0xd1, 0x52, 0x99, 0x22, 0x56, 0xa5, 0xe8, 0xb3, 0xfd, 0xbd, 0x72, + 0x36, 0x2a, 0x39, 0x5d, 0x4f, 0xe0, 0x2c, 0xa8, 0xba, 0xeb, 0xaa, 0x5d, 0xd1, 0x5c, 0xb6, 0x40, 0xef, 0x16, 0x38, + 0xbd, 0x3c, 0x49, 0xcb, 0xb3, 0x4d, 0x91, 0xc4, 0x52, 0x7a, 0xff, 0x89, 0xaf, 0x12, 0xf5, 0xe3, 0xec, 0xf1, 0xec, + 0x1b, 0xe1, 0x74, 0x83, 0xd3, 0xbc, 0x2c, 0x7f, 0xa6, 0x39, 0x7f, 0x57, 0xd1, 0x67, 0x96, 0xf5, 0xfc, 0xf6, 0xd1, + 0x23, 0x10, 0x4b, 0x13, 0xd8, 0x6b, 0x4b, 0xfd, 0x67, 0x6c, 0xfb, 0x10, 0xd3, 0x46, 0xd8, 0x68, 0x3c, 0xda, 0x04, + 0x9c, 0xb7, 0xf7, 0x6e, 0xe4, 0xdd, 0x75, 0x4b, 0x02, 0xae, 0xf1, 0x9e, 0xaf, 0xf9, 0xfe, 0x5e, 0xdb, 0x8a, 0x69, + 0xca, 0xb6, 0x62, 0x3e, 0xfb, 0xea, 0x5a, 0xc4, 0xdc, 0xb8, 0xdc, 0xc0, 0x5e, 0x55, 0x6b, 0xb5, 0x20, 0xd9, 0xde, + 0x87, 0x79, 0xfe, 0xd0, 0xcd, 0xec, 0xf0, 0x0c, 0x1e, 0xb5, 0x81, 0xc4, 0xcf, 0xfd, 0xf4, 0xeb, 0xd1, 0x54, 0xd6, + 0x17, 0x40, 0x98, 0x98, 0x11, 0x89, 0x4f, 0x1c, 0xdf, 0x17, 0x9e, 0x6f, 0xab, 0xf6, 0xdb, 0xe1, 0x3c, 0x6b, 0x8e, + 0x6c, 0x93, 0xee, 0x3e, 0x72, 0xb3, 0xf2, 0x03, 0x7a, 0xd5, 0x34, 0x45, 0x5c, 0xab, 0xfe, 0x89, 0x05, 0xd4, 0x52, + 0xe0, 0x40, 0x9e, 0xba, 0x9a, 0x28, 0x04, 0x3e, 0xe1, 0xf5, 0xf9, 0x4e, 0x01, 0xe8, 0xee, 0x45, 0xd0, 0x8c, 0x04, + 0xaf, 0x05, 0x15, 0x57, 0x75, 0x15, 0xcc, 0x56, 0xae, 0x12, 0x8c, 0xf5, 0x07, 0x0a, 0x9a, 0x27, 0xa5, 0x4c, 0x2a, + 0xa0, 0x07, 0xe4, 0x27, 0x1f, 0x55, 0xf1, 0x01, 0xcf, 0x35, 0x89, 0x5e, 0xaf, 0xe2, 0x9a, 0x38, 0x31, 0xa8, 0xc1, + 0xfd, 0x93, 0xaa, 0xf5, 0xa7, 0x62, 0x63, 0xc0, 0xc6, 0x1f, 0xa8, 0xcb, 0xed, 0xe1, 0x34, 0x2b, 0x49, 0x3a, 0x87, + 0x80, 0x1b, 0xd6, 0xf4, 0x18, 0xd5, 0x75, 0x1c, 0x60, 0xfa, 0xa3, 0xf4, 0x3d, 0xa2, 0xc3, 0x4d, 0x34, 0xdf, 0x0e, + 0xe4, 0x26, 0xdf, 0x0c, 0xbe, 0xd1, 0xc6, 0x7f, 0x1c, 0x7c, 0x35, 0xe8, 0xab, 0xe1, 0x8b, 0xc1, 0xe3, 0x6b, 0x6f, + 0xf8, 0x6c, 0xb0, 0xdd, 0x0d, 0x9f, 0x0c, 0xfe, 0x43, 0x7d, 0xaf, 0xfe, 0x68, 0x10, 0x5e, 0x55, 0xfc, 0x7a, 0xa7, + 0x2b, 0x0e, 0x7a, 0x1f, 0x4e, 0x75, 0xaf, 0xca, 0x7b, 0x6f, 0xf7, 0xee, 0x9d, 0xea, 0x67, 0xef, 0x3e, 0xdc, 0x3f, + 0xb5, 0x35, 0xef, 0x01, 0x50, 0xff, 0x54, 0x30, 0xef, 0xdd, 0xa9, 0x66, 0xf5, 0xde, 0x5e, 0x1d, 0xdf, 0xfd, 0xff, + 0xef, 0xbb, 0x86, 0xf7, 0x1e, 0x9e, 0x7a, 0x5a, 0x56, 0xde, 0x54, 0x7a, 0x9b, 0xf7, 0xfa, 0x5f, 0xcb, 0xea, 0x65, + 0x78, 0x65, 0xd0, 0x56, 0xc3, 0x4b, 0x83, 0xba, 0x1a, 0x5e, 0x18, 0x1c, 0x6a, 0xdb, 0x76, 0x86, 0x47, 0x06, 0x6e, + 0x35, 0x3c, 0x37, 0x58, 0x3f, 0x0d, 0x8f, 0x0d, 0xaa, 0xfd, 0xf7, 0x60, 0x78, 0x62, 0xe0, 0x66, 0xc3, 0xd3, 0xc2, + 0xbc, 0xad, 0xf7, 0xec, 0x9f, 0x89, 0x97, 0xa7, 0x31, 0x76, 0xe8, 0xb0, 0xcf, 0xb5, 0xfb, 0x2d, 0xc4, 0xbc, 0x5d, + 0x8e, 0x5d, 0x75, 0x6a, 0x03, 0x36, 0xea, 0x7f, 0x33, 0x2d, 0x37, 0x9c, 0xf0, 0x1b, 0x81, 0x04, 0x96, 0x67, 0xe7, + 0x0a, 0x30, 0xb5, 0x1f, 0x7a, 0x3c, 0x67, 0x60, 0x6a, 0x25, 0x2b, 0x46, 0xae, 0x62, 0xde, 0x9e, 0xfa, 0x3f, 0xf7, + 0x6d, 0x16, 0x6b, 0x94, 0x20, 0x3d, 0xe4, 0x0f, 0xf1, 0xe3, 0x23, 0x37, 0x84, 0x0e, 0xa3, 0x9f, 0x36, 0x29, 0xef, + 0x02, 0xfc, 0xad, 0x25, 0x39, 0xd0, 0xd7, 0x6e, 0x9f, 0x08, 0xdf, 0x82, 0xb3, 0x88, 0x33, 0x2e, 0x24, 0x22, 0x43, + 0x5c, 0xbd, 0xfe, 0x97, 0xab, 0xee, 0x28, 0x36, 0x9e, 0x69, 0x51, 0xfa, 0xc4, 0xfb, 0x62, 0x9b, 0xe4, 0x98, 0x69, + 0x6e, 0xc4, 0x3c, 0x8d, 0xeb, 0xe2, 0x1c, 0x36, 0x43, 0xa2, 0x72, 0x7b, 0x12, 0x5e, 0x22, 0x85, 0x8f, 0xe4, 0xea, + 0x05, 0xf6, 0x9e, 0x60, 0x8a, 0xfd, 0x1c, 0x98, 0xe5, 0x0c, 0xaa, 0x9c, 0xec, 0x40, 0x38, 0x62, 0x52, 0x8d, 0xbf, + 0x52, 0x1e, 0xdf, 0x8e, 0xaa, 0x3c, 0x0e, 0x80, 0xa8, 0xfd, 0x06, 0xde, 0x81, 0x50, 0x99, 0x72, 0xc8, 0x62, 0x82, + 0x17, 0xf4, 0x78, 0xd1, 0x00, 0xaf, 0x64, 0xc2, 0x6f, 0x6b, 0xe5, 0x96, 0xe0, 0x6c, 0x33, 0x32, 0x61, 0x02, 0x66, + 0x57, 0xb0, 0x0a, 0xe2, 0x7f, 0xd9, 0x93, 0x5e, 0x01, 0xa4, 0x40, 0x8b, 0x4d, 0xc3, 0xd0, 0xbd, 0xc4, 0x77, 0x6c, + 0x4c, 0xba, 0xc2, 0xb5, 0xf4, 0x1b, 0xd6, 0x26, 0xeb, 0x67, 0x40, 0xb1, 0xfb, 0xdb, 0x42, 0x1d, 0x80, 0xfe, 0x0b, + 0xa9, 0xfb, 0x97, 0x33, 0x5c, 0x74, 0xed, 0x22, 0x8a, 0x52, 0x4b, 0x0c, 0x0c, 0xb7, 0x11, 0x68, 0x3b, 0x0c, 0x1a, + 0xaf, 0xd3, 0x57, 0x22, 0xe1, 0x8b, 0x68, 0xa5, 0x5c, 0x78, 0x47, 0xb0, 0x83, 0x1a, 0x9d, 0xaa, 0x89, 0xe6, 0x8f, + 0xf2, 0x46, 0x5b, 0x58, 0x04, 0x61, 0xcb, 0x54, 0x8f, 0x14, 0x30, 0x9d, 0x07, 0xfd, 0x6f, 0x34, 0x7b, 0x49, 0xb5, + 0x84, 0x89, 0x7b, 0x7a, 0xcb, 0x7e, 0x42, 0x56, 0xfc, 0x53, 0x24, 0x8f, 0x9d, 0xa6, 0x3c, 0xf1, 0xc9, 0x79, 0x80, + 0x97, 0x5f, 0x8e, 0x80, 0xec, 0x9a, 0xa0, 0xc8, 0x87, 0xbc, 0xd0, 0x84, 0x89, 0x33, 0xe3, 0x11, 0xc1, 0x00, 0x93, + 0x05, 0xb8, 0xcd, 0xbe, 0xd5, 0x62, 0x3a, 0xe1, 0x80, 0xc9, 0x70, 0x59, 0xc9, 0x8b, 0x52, 0x9c, 0x8b, 0xda, 0xdc, + 0x6c, 0x8d, 0x67, 0x84, 0x21, 0x79, 0x73, 0x97, 0x76, 0x38, 0x18, 0x46, 0xfd, 0xad, 0x21, 0x57, 0x89, 0xd2, 0xbd, + 0x98, 0xb4, 0x2b, 0xd9, 0x95, 0x3e, 0xe9, 0x6c, 0x66, 0x3c, 0xbe, 0xf9, 0x6d, 0x48, 0x29, 0x50, 0xec, 0x0d, 0xe5, + 0xfa, 0x10, 0xbf, 0xb7, 0xda, 0x20, 0x7a, 0xe1, 0xf9, 0xf3, 0x53, 0xd0, 0x70, 0x16, 0x8c, 0x52, 0xe9, 0x00, 0x6d, + 0x10, 0x47, 0x67, 0x4d, 0x78, 0xd6, 0xc9, 0xed, 0xb3, 0x0b, 0xf1, 0x60, 0x55, 0x21, 0xe1, 0x0c, 0x9d, 0x7b, 0xda, + 0x58, 0xea, 0xcc, 0x30, 0x25, 0x31, 0x00, 0x1c, 0x01, 0x8f, 0xb6, 0xc3, 0x73, 0xea, 0x69, 0x2f, 0x05, 0xd0, 0x9b, + 0x3c, 0xef, 0xa7, 0x8f, 0x4a, 0xa5, 0x07, 0x3a, 0x8f, 0x5a, 0x7d, 0x76, 0x96, 0xd6, 0x97, 0x25, 0x64, 0x10, 0x18, + 0x8d, 0x1a, 0x9a, 0x2f, 0xa2, 0x72, 0xbf, 0x45, 0x0d, 0xd6, 0x52, 0x65, 0x8d, 0xbc, 0x79, 0xef, 0xfd, 0xc1, 0x35, + 0x6c, 0x76, 0x0d, 0x95, 0xbe, 0x1e, 0xd7, 0x1c, 0x94, 0x02, 0x73, 0x12, 0xe2, 0xd8, 0x16, 0xde, 0x9f, 0x8c, 0x70, + 0xbd, 0x7d, 0xa1, 0x66, 0x8b, 0x2d, 0x0e, 0x60, 0xee, 0x4f, 0x38, 0xe7, 0x92, 0xec, 0x78, 0xe7, 0x2c, 0x96, 0x5f, + 0xd7, 0x5a, 0x79, 0x49, 0xfe, 0xd5, 0x38, 0x3b, 0xb6, 0xac, 0x72, 0x56, 0x81, 0x10, 0x88, 0xfc, 0x74, 0x3a, 0x91, + 0x88, 0xd5, 0x16, 0x04, 0x8d, 0x14, 0x26, 0x84, 0x75, 0xf2, 0xee, 0xe8, 0x46, 0xbc, 0x75, 0x6a, 0x41, 0x66, 0xe2, + 0x40, 0x01, 0xa6, 0xe2, 0xd4, 0xda, 0x93, 0x3d, 0x03, 0x12, 0xec, 0xcb, 0x02, 0x96, 0x6a, 0x80, 0x5c, 0x48, 0x67, + 0xd2, 0x17, 0x44, 0xd1, 0x49, 0x63, 0x2e, 0xb9, 0x78, 0x0a, 0xd8, 0xd0, 0x29, 0x40, 0xa8, 0x34, 0x61, 0xd4, 0x73, + 0x7c, 0xb7, 0x26, 0xb5, 0xa3, 0x4e, 0x49, 0x98, 0xb9, 0x47, 0x0c, 0x9e, 0xcc, 0x9b, 0x0a, 0x71, 0xeb, 0xb3, 0x84, + 0x15, 0xeb, 0x7c, 0x08, 0xf8, 0x04, 0xc6, 0xdb, 0x88, 0xbd, 0x51, 0x1b, 0xf2, 0x06, 0xd6, 0x8f, 0x85, 0x11, 0x84, + 0x8d, 0x19, 0x26, 0xc7, 0x76, 0x83, 0x27, 0x81, 0x06, 0x58, 0xd8, 0x99, 0x9e, 0x13, 0x18, 0x78, 0x77, 0xd6, 0xda, + 0xd8, 0xf4, 0x7e, 0xd5, 0x89, 0x4a, 0xb5, 0x91, 0x59, 0xfe, 0x75, 0x01, 0x55, 0x5a, 0x5f, 0x01, 0xa8, 0x0a, 0xb8, + 0x88, 0xfc, 0xf1, 0x97, 0x9f, 0x27, 0xff, 0xda, 0x04, 0x19, 0x8c, 0xd8, 0x7c, 0x09, 0xb9, 0x41, 0x2d, 0xd8, 0xc8, + 0x77, 0x8c, 0xb9, 0x12, 0xab, 0xc2, 0x97, 0x30, 0x3c, 0x3f, 0xb5, 0xc3, 0x55, 0x1e, 0xd4, 0xa4, 0xc5, 0x47, 0x44, + 0x16, 0x26, 0x69, 0x79, 0x62, 0xa0, 0xa1, 0xaf, 0x84, 0xca, 0x2f, 0x2e, 0xae, 0xd1, 0xf8, 0x56, 0xf1, 0x18, 0x2c, + 0x3c, 0xbe, 0xe5, 0xda, 0x36, 0xd3, 0x46, 0xd9, 0x83, 0xa9, 0x91, 0xb9, 0xd2, 0x5b, 0xb5, 0xd1, 0x21, 0xae, 0xef, + 0xa1, 0x4d, 0x6e, 0xc2, 0x5e, 0xfc, 0x31, 0xa3, 0xac, 0xf6, 0x38, 0x5a, 0xbc, 0xc6, 0xc2, 0x15, 0x7e, 0x5d, 0x40, + 0xc1, 0xdb, 0xe9, 0x63, 0x87, 0x7e, 0x5c, 0xfa, 0x3a, 0x1c, 0x41, 0xa6, 0x4a, 0x54, 0x5c, 0x45, 0x50, 0x09, 0x51, + 0x0f, 0xd7, 0x00, 0x21, 0x4f, 0xe3, 0x4e, 0x34, 0x5a, 0xd5, 0xa6, 0xf4, 0x6a, 0xa4, 0x51, 0xe0, 0xec, 0x2e, 0xfa, + 0xb0, 0x12, 0x79, 0x4b, 0x95, 0x44, 0x0c, 0x94, 0x30, 0x45, 0xd6, 0xbf, 0x99, 0x38, 0x2b, 0x5b, 0xa2, 0x2a, 0x01, + 0x4c, 0x9d, 0x68, 0xc3, 0x4f, 0xbc, 0x11, 0x06, 0xaa, 0x48, 0xa6, 0x12, 0x09, 0x3a, 0x53, 0x65, 0x00, 0x25, 0x4d, + 0x40, 0x1d, 0xd3, 0xee, 0xc1, 0xc3, 0x0a, 0xcb, 0x4d, 0x96, 0x6b, 0x4c, 0x61, 0xb9, 0xbf, 0x7f, 0xca, 0xb3, 0x52, + 0x97, 0x71, 0x10, 0xb5, 0xf2, 0x34, 0xcd, 0x76, 0xaa, 0xaa, 0x84, 0x6e, 0xe3, 0x8a, 0xf3, 0x92, 0xb5, 0xc8, 0xfb, + 0x71, 0x36, 0x6d, 0x7c, 0x10, 0x34, 0x2c, 0x7a, 0xb7, 0xbc, 0x4c, 0xae, 0x24, 0xd6, 0x27, 0x98, 0x1d, 0x41, 0x66, + 0xd0, 0x49, 0x55, 0x2f, 0x48, 0x4a, 0x48, 0x50, 0xaa, 0x44, 0xfe, 0x47, 0xa5, 0xa4, 0x4e, 0xe2, 0xbe, 0x87, 0xf5, + 0x57, 0x95, 0xc5, 0x2b, 0x56, 0x68, 0xdc, 0xf7, 0xf5, 0xed, 0x24, 0xbf, 0x86, 0x11, 0x8a, 0x01, 0x10, 0x5f, 0x07, + 0x70, 0x84, 0x57, 0x2e, 0x9f, 0x8c, 0x60, 0x18, 0x85, 0x8a, 0x23, 0xd6, 0xb4, 0xc5, 0x95, 0xb8, 0x3c, 0x73, 0x05, + 0x23, 0x3c, 0xfc, 0xad, 0x8a, 0x1b, 0x88, 0x87, 0xaf, 0xdb, 0x80, 0x3e, 0x3e, 0xce, 0x97, 0xde, 0x0b, 0xfa, 0xd6, + 0x42, 0x93, 0x4c, 0x10, 0x67, 0xf3, 0x37, 0x8f, 0x97, 0xcd, 0x9e, 0x2f, 0xbf, 0x68, 0x1a, 0x25, 0x81, 0xbe, 0xe7, + 0x6a, 0xf2, 0xf8, 0x67, 0x91, 0x25, 0xc1, 0x21, 0x68, 0xf1, 0x66, 0x42, 0xe0, 0x8b, 0x5e, 0xb0, 0x6a, 0x56, 0x03, + 0xd3, 0x49, 0x71, 0x30, 0xba, 0xb6, 0x89, 0x3a, 0xc5, 0xea, 0x58, 0x9d, 0xd9, 0x11, 0x06, 0x95, 0x7a, 0x08, 0xd5, + 0x53, 0x3a, 0xd2, 0x9b, 0xaf, 0xe8, 0x47, 0xe1, 0xa6, 0xc4, 0xd7, 0xec, 0x52, 0x55, 0x0a, 0xab, 0xc0, 0x19, 0x88, + 0xae, 0x16, 0x1c, 0x27, 0x36, 0x74, 0xf5, 0x10, 0x2c, 0x1b, 0xc6, 0x06, 0x27, 0x6a, 0xa9, 0x42, 0xd1, 0x36, 0x1f, + 0xef, 0xf9, 0x5e, 0xe0, 0x43, 0xc2, 0xac, 0xf3, 0xe1, 0x81, 0x90, 0xed, 0x60, 0xdc, 0x65, 0xf4, 0x03, 0xaa, 0x3b, + 0x23, 0xe8, 0x35, 0xa6, 0xc7, 0xd4, 0x95, 0x44, 0x86, 0xf9, 0xf9, 0xa5, 0xc3, 0x5a, 0x67, 0xe0, 0xea, 0xa1, 0xfb, + 0x21, 0x35, 0x06, 0x35, 0xfc, 0xc1, 0xe8, 0x2a, 0x5c, 0xed, 0xee, 0x9b, 0xe9, 0x20, 0xb0, 0x55, 0x13, 0xa6, 0x66, + 0xc0, 0x34, 0x49, 0x91, 0x98, 0xac, 0x67, 0xd9, 0xd6, 0x8d, 0x7a, 0x5c, 0x50, 0x3e, 0xfb, 0x38, 0x69, 0xfb, 0xba, + 0xb2, 0x82, 0x34, 0x73, 0x21, 0x28, 0x63, 0xe8, 0xa8, 0x4f, 0xac, 0xb3, 0x1a, 0x41, 0x8e, 0x14, 0x96, 0xb6, 0x90, + 0x89, 0x62, 0xcd, 0x69, 0x57, 0x69, 0x5a, 0x59, 0xe2, 0x8f, 0xe9, 0x58, 0xe4, 0xc2, 0x26, 0x83, 0x96, 0x43, 0x29, + 0x4d, 0x9a, 0xf6, 0x4f, 0xf9, 0x44, 0xf8, 0xad, 0x44, 0xd6, 0xaf, 0x6f, 0xf0, 0xec, 0xd9, 0xed, 0x68, 0x03, 0x8c, + 0x97, 0xae, 0x91, 0x4e, 0xb1, 0x1e, 0x63, 0xb7, 0x7c, 0x8f, 0x91, 0xf0, 0x3d, 0x34, 0xd5, 0x57, 0xf9, 0x14, 0xe7, + 0x8e, 0xe8, 0x69, 0x63, 0xf9, 0x77, 0xcf, 0x6e, 0x41, 0xf9, 0x9a, 0xef, 0xb1, 0x20, 0x6d, 0xef, 0x73, 0x26, 0x95, + 0x2b, 0x4a, 0x0c, 0x39, 0x2a, 0xa9, 0xe0, 0x41, 0x03, 0x80, 0x59, 0x9d, 0x55, 0x8d, 0x06, 0x60, 0x17, 0xf9, 0x9d, + 0x52, 0x41, 0x86, 0x4b, 0x64, 0x81, 0x1b, 0x60, 0x7d, 0x00, 0x87, 0x32, 0x53, 0x32, 0x3c, 0x98, 0x5f, 0x61, 0x32, + 0x31, 0xd2, 0xef, 0x50, 0x1c, 0x8f, 0x3b, 0xde, 0xba, 0xe7, 0xa7, 0xa4, 0xd9, 0x69, 0x0f, 0x30, 0x37, 0x91, 0x3c, + 0x0b, 0x0b, 0xfb, 0x20, 0x67, 0xbf, 0x33, 0x0f, 0x84, 0xd1, 0x3a, 0x7f, 0xba, 0xd9, 0x4f, 0x4a, 0x24, 0x78, 0x48, + 0xa9, 0xed, 0xcd, 0x88, 0x72, 0x22, 0x73, 0x29, 0xf5, 0x8d, 0x6d, 0xab, 0x06, 0x53, 0xa4, 0x84, 0x41, 0xa7, 0x11, + 0xbd, 0xb6, 0xb1, 0xbb, 0xa3, 0xd1, 0xf9, 0x27, 0xaa, 0x05, 0x03, 0x99, 0xe1, 0x88, 0x03, 0x58, 0x13, 0xe1, 0x64, + 0x66, 0x67, 0x46, 0x16, 0x64, 0xde, 0x66, 0xee, 0xcf, 0xa4, 0xb9, 0x44, 0x74, 0x5b, 0x6d, 0xae, 0xc8, 0x0c, 0xd3, + 0x53, 0xdc, 0xbd, 0xad, 0xe4, 0xe8, 0xae, 0x77, 0x00, 0x5a, 0xe9, 0xc3, 0xf9, 0x5f, 0x8f, 0xe7, 0xc8, 0x68, 0xc0, + 0xeb, 0x39, 0x57, 0x41, 0xf3, 0x17, 0x38, 0x4f, 0x73, 0x6b, 0x6b, 0x62, 0xa4, 0x26, 0x73, 0x5a, 0xe5, 0xf9, 0x5e, + 0x46, 0x3f, 0x57, 0x8d, 0x3e, 0x6a, 0xe9, 0xd4, 0x6b, 0x90, 0x08, 0x95, 0x19, 0xf1, 0xe7, 0x92, 0xb7, 0x17, 0x10, + 0xdd, 0xa5, 0x12, 0xc6, 0xda, 0x09, 0x98, 0xb9, 0x17, 0xeb, 0x7c, 0x9e, 0x5e, 0x7f, 0x32, 0x69, 0x32, 0x5f, 0xee, + 0xde, 0x05, 0xf2, 0x8e, 0x13, 0x0c, 0x9f, 0x7d, 0x86, 0x21, 0xb2, 0xb8, 0xf8, 0xc5, 0xeb, 0xe9, 0xbd, 0x48, 0x40, + 0xef, 0x13, 0x66, 0x79, 0x4b, 0xc5, 0x2d, 0x98, 0x87, 0x5a, 0x1a, 0xcb, 0xcf, 0xe4, 0xf6, 0x8b, 0xde, 0x11, 0xec, + 0xbd, 0x17, 0x37, 0xbe, 0xfa, 0xbf, 0xb1, 0x67, 0x48, 0xec, 0x7f, 0x2e, 0x91, 0x8a, 0xab, 0xca, 0xdc, 0x8f, 0x25, + 0xa9, 0x82, 0xd5, 0x74, 0x9e, 0x22, 0x19, 0xec, 0xdd, 0x54, 0x83, 0x80, 0x4d, 0x91, 0x31, 0xed, 0x79, 0x80, 0xde, + 0xa0, 0xef, 0x2c, 0xc2, 0x46, 0x45, 0x11, 0xd3, 0x4f, 0x6a, 0x56, 0xe6, 0xe8, 0x74, 0x2c, 0x59, 0x39, 0xb0, 0xd3, + 0xef, 0x5e, 0x7c, 0xfb, 0x35, 0x52, 0xe5, 0xbd, 0xed, 0xdb, 0x59, 0x2b, 0x42, 0xd0, 0xf0, 0x21, 0xd3, 0xdb, 0xf3, + 0x3c, 0xcf, 0x55, 0x16, 0xf7, 0xf1, 0x77, 0x89, 0xc3, 0xc4, 0x28, 0x5b, 0xa3, 0x84, 0x27, 0x5a, 0xd0, 0xcb, 0x5f, + 0x34, 0x45, 0x83, 0xaf, 0x52, 0x14, 0x16, 0xe8, 0x55, 0x43, 0x8e, 0x96, 0xe5, 0xbb, 0x92, 0x06, 0xaa, 0x82, 0xeb, + 0x96, 0xc1, 0xc2, 0xdd, 0xa9, 0x90, 0xd6, 0xa9, 0xb9, 0x50, 0xb6, 0x4f, 0x25, 0xf8, 0x0f, 0xa9, 0xdd, 0x98, 0xa5, + 0x0a, 0xa9, 0x80, 0xea, 0x78, 0xc0, 0xdb, 0x1e, 0x03, 0x2d, 0x4f, 0x30, 0x7b, 0xaf, 0x95, 0x14, 0x83, 0x0a, 0x72, + 0x1b, 0x00, 0x5b, 0x6e, 0x08, 0xd7, 0xe0, 0xe9, 0x18, 0x44, 0xc2, 0x9d, 0x2f, 0x8b, 0xfe, 0xb7, 0x37, 0xf5, 0xac, + 0xfa, 0x4b, 0x86, 0x45, 0xf1, 0xde, 0xf4, 0x1f, 0xb5, 0x69, 0x08, 0x82, 0x6f, 0xa3, 0x44, 0xc4, 0x9f, 0xf9, 0x40, + 0xd5, 0x1a, 0x18, 0xeb, 0x3a, 0x0c, 0x1e, 0x48, 0x61, 0xb2, 0x65, 0x5a, 0x36, 0xa5, 0x4e, 0xdd, 0xc2, 0xee, 0x13, + 0x94, 0xb7, 0x41, 0xf5, 0x5e, 0x2a, 0x2b, 0x1f, 0x50, 0x04, 0x64, 0x45, 0x19, 0x94, 0x8a, 0x7b, 0xba, 0x9e, 0x55, + 0x6c, 0xc2, 0x4f, 0x2f, 0x2b, 0x67, 0xac, 0x83, 0x78, 0x29, 0xff, 0xeb, 0x51, 0xf9, 0x3d, 0xda, 0x1a, 0xea, 0x6b, + 0x51, 0x48, 0x98, 0xe3, 0x16, 0xe3, 0x07, 0x3b, 0x43, 0x27, 0x50, 0x4b, 0x29, 0x9f, 0x10, 0x5f, 0x1c, 0xa2, 0xb0, + 0x73, 0xa8, 0x50, 0x9b, 0x49, 0x08, 0x0b, 0xaf, 0x7e, 0x21, 0xbd, 0xec, 0x87, 0xe0, 0x5e, 0x71, 0x44, 0xaa, 0x4c, + 0xee, 0x58, 0xa7, 0xca, 0x6f, 0x10, 0x0b, 0xb3, 0xb7, 0xef, 0xfb, 0x7d, 0x1d, 0xfc, 0x9d, 0xfe, 0xc7, 0x4f, 0xf8, + 0x68, 0x4f, 0xfb, 0xd1, 0xce, 0xe7, 0x65, 0x40, 0xfd, 0xf1, 0xd4, 0xb4, 0x6d, 0x58, 0xd3, 0x6e, 0xb0, 0x48, 0x5f, + 0x93, 0x85, 0x99, 0x78, 0x68, 0xc6, 0xbf, 0x2d, 0xca, 0xfb, 0x94, 0xce, 0x56, 0x35, 0x83, 0xaa, 0x25, 0xff, 0xfa, + 0x57, 0x85, 0x0d, 0xc2, 0x34, 0x60, 0x27, 0x80, 0xd0, 0x17, 0x79, 0x3f, 0x73, 0x3d, 0x44, 0x08, 0xbe, 0x60, 0x00, + 0x77, 0x0e, 0x7d, 0x81, 0x3a, 0x87, 0xa1, 0x6a, 0xbd, 0x9c, 0xeb, 0xc8, 0x46, 0xcd, 0xf1, 0x6a, 0xd7, 0x47, 0x7f, + 0xa0, 0xef, 0xfd, 0x34, 0xf2, 0x67, 0x4b, 0x2d, 0xb8, 0x19, 0x37, 0xeb, 0x16, 0x70, 0x06, 0x67, 0xf1, 0x1c, 0x28, + 0xd3, 0x57, 0x83, 0x17, 0xe7, 0x32, 0x5a, 0x1b, 0x98, 0x82, 0x69, 0xe5, 0x86, 0x8b, 0xa2, 0x74, 0xec, 0xa8, 0x17, + 0xbb, 0xb6, 0x8a, 0x2e, 0xdd, 0x46, 0x8e, 0x72, 0xbe, 0x65, 0xef, 0x50, 0x95, 0xb0, 0xbe, 0x64, 0x13, 0x79, 0x17, + 0xd3, 0xcb, 0xab, 0xf3, 0x8a, 0x66, 0xbc, 0x6a, 0xcb, 0xda, 0x03, 0x11, 0x67, 0x42, 0xbe, 0xe8, 0x9e, 0xa2, 0x51, + 0xe0, 0xd0, 0x54, 0xed, 0xe2, 0xdf, 0x8f, 0xb8, 0xaa, 0x77, 0xbd, 0xf8, 0x37, 0xbb, 0x66, 0x5d, 0xcf, 0xc4, 0x80, + 0x51, 0x4e, 0xbe, 0x60, 0xe5, 0x30, 0xbc, 0xe2, 0x9e, 0xfa, 0xbe, 0x48, 0xcf, 0x33, 0xea, 0x55, 0x34, 0xb7, 0xef, + 0xd4, 0x9f, 0xe3, 0x59, 0xcd, 0xf5, 0x67, 0xdb, 0xb0, 0x87, 0x25, 0xef, 0xcb, 0xed, 0x93, 0x73, 0xd2, 0xaa, 0x53, + 0x4e, 0xa9, 0x5d, 0x78, 0x09, 0x8f, 0x6c, 0x6f, 0x68, 0x50, 0xe6, 0xce, 0xfa, 0xb4, 0x3b, 0xdc, 0x4f, 0x8e, 0x8a, + 0x32, 0x76, 0xc5, 0x61, 0x9f, 0x51, 0xd2, 0xfb, 0x8a, 0x9b, 0xc3, 0x10, 0x83, 0x53, 0x27, 0x50, 0x94, 0xf5, 0x08, + 0x2b, 0xcf, 0x03, 0xfb, 0xed, 0x8a, 0x9f, 0x81, 0x73, 0x98, 0xda, 0x6d, 0x4c, 0xee, 0xfa, 0x94, 0x4a, 0xee, 0xab, + 0x8a, 0xee, 0x23, 0xe3, 0x82, 0xbd, 0xc3, 0xfa, 0x83, 0x83, 0x3e, 0xe2, 0xb2, 0xc5, 0xc7, 0x8f, 0x59, 0x80, 0xbf, + 0xaa, 0xce, 0xfb, 0x86, 0x21, 0x14, 0x60, 0xb2, 0x4a, 0x4d, 0x1b, 0xc5, 0x4b, 0x86, 0xcd, 0xbd, 0x93, 0x8f, 0x4b, + 0xd4, 0x09, 0xee, 0xaf, 0xd1, 0xb2, 0xda, 0x0d, 0xf0, 0x79, 0x12, 0x4b, 0xcc, 0x89, 0xf6, 0xd8, 0x3f, 0xde, 0xac, + 0x66, 0xf2, 0x27, 0x66, 0xe8, 0x33, 0x54, 0x0b, 0xeb, 0x58, 0xfe, 0x20, 0xce, 0x4f, 0x7d, 0x7e, 0xbb, 0x24, 0xf9, + 0x9b, 0xa1, 0xc2, 0xc2, 0xa6, 0xb0, 0x82, 0xb0, 0x95, 0xaf, 0x2f, 0xec, 0x00, 0xea, 0xbd, 0xc9, 0xec, 0xfe, 0x0d, + 0xe3, 0xcb, 0x2e, 0xe1, 0xcb, 0xed, 0x12, 0xc5, 0xb2, 0x8b, 0xc3, 0x45, 0x2e, 0x23, 0x0a, 0x27, 0x1e, 0x8c, 0x80, + 0x17, 0x95, 0x75, 0xe0, 0x87, 0x75, 0xc4, 0xc7, 0xe7, 0x71, 0xb9, 0x20, 0x5a, 0x94, 0xe6, 0xcf, 0x83, 0x96, 0x25, + 0x1d, 0xd7, 0xf4, 0x4d, 0x74, 0x98, 0xd2, 0x04, 0x84, 0xec, 0xb1, 0x29, 0xf4, 0x63, 0x95, 0xa2, 0xba, 0x59, 0x3a, + 0x70, 0xe7, 0xc6, 0x76, 0xd5, 0x48, 0xf9, 0x5d, 0xbf, 0x4e, 0x77, 0xb2, 0x6b, 0xd9, 0x3f, 0x65, 0xc8, 0x7c, 0xd4, + 0x05, 0xf3, 0xc7, 0x99, 0x2a, 0x1d, 0x72, 0xed, 0xf5, 0x69, 0x57, 0x45, 0xd0, 0x14, 0xfb, 0x9f, 0x76, 0xf5, 0x92, + 0xee, 0x8b, 0x1f, 0x15, 0xd0, 0xea, 0xa2, 0x43, 0x8a, 0x1c, 0x18, 0xc3, 0x21, 0x61, 0xb8, 0x11, 0xb1, 0x6d, 0x48, + 0x82, 0xc7, 0xca, 0x29, 0xbc, 0x10, 0xf7, 0xc7, 0x91, 0x8a, 0x51, 0x15, 0xdd, 0xd8, 0xda, 0xd8, 0xc6, 0x66, 0x62, + 0x1e, 0xd7, 0x43, 0xf9, 0xab, 0x28, 0x93, 0x26, 0xb8, 0x1b, 0x0c, 0xea, 0xec, 0x79, 0xa2, 0x14, 0xb4, 0x99, 0xe9, + 0xb1, 0x15, 0x4e, 0x93, 0x5b, 0xee, 0x76, 0x91, 0x44, 0x97, 0x85, 0xa1, 0xd9, 0x1a, 0x4c, 0x1c, 0x23, 0xf5, 0x16, + 0x24, 0xb2, 0x2d, 0x85, 0xcb, 0x2e, 0x7e, 0xa3, 0x28, 0x61, 0xd0, 0xf9, 0x4c, 0x30, 0xde, 0x44, 0xc0, 0x94, 0x23, + 0x4f, 0x13, 0xda, 0x4a, 0x1e, 0x8d, 0x91, 0x57, 0x32, 0x4d, 0x65, 0x7b, 0x2c, 0x7f, 0x24, 0xc9, 0x94, 0x9b, 0xe9, + 0x62, 0xa1, 0x17, 0x13, 0x04, 0xaa, 0xb0, 0xea, 0xad, 0x58, 0x49, 0x04, 0x60, 0xb9, 0x82, 0xb2, 0xec, 0xd2, 0xfd, + 0xbc, 0x02, 0x47, 0x1e, 0xa6, 0x53, 0xc4, 0x86, 0x27, 0x8d, 0x8c, 0xc4, 0x89, 0xaf, 0x2f, 0xc9, 0x96, 0x53, 0x33, + 0x38, 0x8b, 0x78, 0x60, 0xaa, 0xdb, 0xdc, 0x78, 0x79, 0xa4, 0xd8, 0xba, 0x97, 0xde, 0x89, 0xb8, 0x74, 0x9d, 0x95, + 0xa2, 0x1c, 0x55, 0x52, 0xa8, 0xe7, 0x4c, 0xa3, 0xa9, 0xbc, 0xb5, 0x85, 0x12, 0x59, 0x05, 0xad, 0x92, 0xd3, 0xff, + 0xef, 0x88, 0x24, 0x24, 0x5c, 0x08, 0x2c, 0xfe, 0x32, 0x15, 0xd2, 0xec, 0xad, 0xb6, 0x63, 0x18, 0x44, 0xba, 0xce, + 0x0b, 0x6e, 0x19, 0xbf, 0xfa, 0x05, 0x00, 0x7a, 0x2b, 0xda, 0x06, 0xa6, 0x8b, 0x05, 0x9c, 0xd9, 0xd9, 0x8c, 0xde, + 0xe6, 0xc2, 0xac, 0x8e, 0x2b, 0xfa, 0x89, 0xd5, 0xbf, 0x86, 0x85, 0xdd, 0xb3, 0xfd, 0x78, 0xb0, 0x63, 0x46, 0x53, + 0x57, 0x09, 0x61, 0x98, 0x20, 0x8b, 0x5e, 0x06, 0x77, 0xc8, 0x22, 0x8c, 0xc0, 0xae, 0x1c, 0xda, 0xc8, 0x84, 0xf3, + 0x15, 0x84, 0x7f, 0x8e, 0xf9, 0x7a, 0x0a, 0x2c, 0xcb, 0xfd, 0xc9, 0x50, 0x0f, 0x03, 0xc2, 0x44, 0x46, 0x38, 0x82, + 0x24, 0x64, 0x53, 0x21, 0x98, 0x78, 0x0a, 0xea, 0x26, 0x38, 0xb0, 0xc5, 0xd1, 0x8d, 0x8d, 0x52, 0x98, 0x11, 0x7f, + 0xc5, 0x82, 0x91, 0xdb, 0xc7, 0xf8, 0xf6, 0x80, 0xc2, 0x2b, 0xd8, 0x29, 0x84, 0xea, 0xe5, 0xa5, 0x36, 0xbd, 0xd8, + 0x8f, 0x7c, 0x07, 0x7d, 0x3c, 0x9b, 0xe9, 0xc8, 0x0b, 0x32, 0x4c, 0xa7, 0x21, 0x0d, 0x40, 0x42, 0x78, 0xe1, 0xa6, + 0x6e, 0x7f, 0x72, 0x68, 0x9d, 0x4c, 0x15, 0x58, 0xde, 0xe5, 0x4d, 0x27, 0x23, 0x20, 0x2f, 0xec, 0xb2, 0x52, 0xcc, + 0xa7, 0xff, 0x54, 0x8d, 0xed, 0x30, 0x9d, 0x76, 0x38, 0xbb, 0x98, 0xbb, 0x42, 0x63, 0x26, 0x22, 0x2f, 0xca, 0x15, + 0xb6, 0x5e, 0x9c, 0xe6, 0x70, 0x80, 0xf7, 0xb8, 0x7c, 0x43, 0x42, 0xc8, 0x07, 0x2f, 0x48, 0x87, 0xe8, 0x59, 0x9a, + 0x8f, 0x19, 0xf5, 0xc2, 0x5b, 0x5f, 0x64, 0x0a, 0x02, 0xfe, 0x74, 0xeb, 0x23, 0x51, 0x8d, 0xf4, 0x14, 0x2d, 0x4e, + 0xa8, 0x2c, 0xd9, 0x16, 0xc8, 0xe9, 0xbf, 0x20, 0x3a, 0x18, 0x63, 0xf9, 0x36, 0xe1, 0xcd, 0xcb, 0x2d, 0x6b, 0xbc, + 0xfd, 0xc8, 0x76, 0x86, 0x52, 0xfe, 0xc6, 0x71, 0x88, 0xe9, 0x4c, 0x26, 0x76, 0x66, 0x02, 0x46, 0x0f, 0x0b, 0x68, + 0x1d, 0xb8, 0x19, 0x79, 0xfc, 0xe4, 0xd5, 0x9b, 0x90, 0x9b, 0xcf, 0xd5, 0xff, 0xfc, 0xb2, 0x75, 0x16, 0xf7, 0x6e, + 0x2f, 0x25, 0x0e, 0x9d, 0x99, 0xcd, 0x32, 0x18, 0xaf, 0x68, 0x80, 0xe0, 0xe4, 0x1a, 0x30, 0x0c, 0xca, 0xd2, 0x0f, + 0x04, 0x8c, 0x5d, 0x1e, 0xa9, 0xba, 0x19, 0x3f, 0x42, 0xcc, 0x76, 0x59, 0x3e, 0x44, 0x5a, 0x18, 0xed, 0x5b, 0xa0, + 0xb0, 0x03, 0x66, 0x2e, 0x8e, 0x40, 0xde, 0x73, 0x99, 0x79, 0x0d, 0x44, 0xeb, 0xf3, 0xcd, 0x79, 0x7c, 0x9d, 0x94, + 0xff, 0x28, 0x9a, 0x43, 0x5a, 0xd1, 0x8c, 0xfc, 0x3e, 0x1a, 0x3d, 0xd6, 0xdb, 0xbc, 0xd9, 0x8e, 0xab, 0x4c, 0xd9, + 0x12, 0x8c, 0x28, 0xb9, 0xb1, 0xc3, 0x7c, 0x50, 0x71, 0x15, 0xd8, 0x92, 0xaf, 0xd1, 0xad, 0x1d, 0xe2, 0x70, 0xee, + 0x37, 0x2d, 0xf2, 0xb6, 0xe5, 0xe8, 0xa2, 0xb0, 0x5b, 0x81, 0xf3, 0xab, 0x86, 0xb6, 0x12, 0xdf, 0xc8, 0x9f, 0x8c, + 0x89, 0x2a, 0x24, 0x88, 0x09, 0x7a, 0x34, 0x9c, 0x7f, 0x10, 0xa2, 0xa1, 0xcb, 0x64, 0xb7, 0x6c, 0xd2, 0x97, 0xda, + 0xc2, 0x55, 0x60, 0x16, 0xd8, 0x6d, 0xec, 0x77, 0x7d, 0x3c, 0x6f, 0xc7, 0x65, 0x66, 0xcd, 0x87, 0x5a, 0xf1, 0x15, + 0xce, 0x05, 0x41, 0xa5, 0x35, 0xdc, 0x92, 0xfc, 0xdf, 0xcf, 0xfb, 0x67, 0xdc, 0x7a, 0x5a, 0xf6, 0xea, 0x7b, 0xe8, + 0xf7, 0xf5, 0x5e, 0x2d, 0x17, 0xbd, 0x48, 0x2d, 0xfa, 0x6a, 0x34, 0x6d, 0x3c, 0xbf, 0x7f, 0x7d, 0x7d, 0x21, 0x9d, + 0xde, 0xf1, 0x2b, 0xbf, 0x85, 0xee, 0x1d, 0xb8, 0xa2, 0xdc, 0xe0, 0xe7, 0x2a, 0x1e, 0xce, 0xfe, 0x2b, 0x77, 0x58, + 0x1d, 0xd7, 0xaf, 0xaa, 0xcb, 0x36, 0xc7, 0x33, 0xd8, 0x1b, 0xfd, 0xb6, 0x3d, 0x03, 0xfe, 0xbf, 0x05, 0x48, 0x7c, + 0x91, 0x92, 0x49, 0x05, 0x0a, 0x40, 0xa0, 0xbb, 0x1e, 0xfc, 0x11, 0x84, 0x51, 0x4a, 0x3b, 0x7c, 0xfc, 0x98, 0x4c, + 0x54, 0x70, 0x78, 0x75, 0x6e, 0xa1, 0x59, 0x8f, 0xf4, 0xfb, 0x3c, 0xdd, 0xf5, 0xf8, 0x53, 0x1b, 0x55, 0x27, 0x02, + 0x99, 0xd9, 0x38, 0xd3, 0x4e, 0xb9, 0xfe, 0x6d, 0xa3, 0x3f, 0xab, 0xf0, 0xad, 0x42, 0x45, 0x77, 0x5f, 0xfc, 0xe3, + 0xaa, 0xd1, 0xbb, 0xee, 0x2a, 0xfc, 0x70, 0xd5, 0xab, 0xb7, 0xdd, 0xed, 0xbb, 0x15, 0x15, 0x6b, 0x58, 0x9e, 0x31, + 0xc3, 0xa0, 0x39, 0x22, 0x9a, 0x9d, 0xf2, 0xff, 0x7d, 0x64, 0xeb, 0x45, 0xc4, 0x92, 0xad, 0xb8, 0x00, 0x79, 0xb1, + 0x8d, 0xd3, 0x67, 0xf1, 0x46, 0x35, 0x17, 0xae, 0x3c, 0xea, 0xdd, 0x49, 0xba, 0x37, 0x18, 0xaa, 0xf9, 0xfd, 0x80, + 0xd7, 0x05, 0x5d, 0x39, 0xf1, 0xd1, 0xf1, 0x4e, 0xd9, 0xfa, 0x68, 0x6c, 0xff, 0x2b, 0x5f, 0x43, 0xc7, 0xe6, 0xc5, + 0xb6, 0x03, 0xbb, 0xe1, 0xc7, 0x6c, 0xe2, 0xcd, 0xa7, 0xf5, 0xf8, 0x8c, 0xcf, 0xd3, 0xb8, 0xc7, 0x18, 0xde, 0x19, + 0xb7, 0xe6, 0x01, 0x9f, 0x19, 0x65, 0x06, 0x72, 0x19, 0xb2, 0xf7, 0x1e, 0xd6, 0xe8, 0xa9, 0x03, 0xfa, 0x35, 0x15, + 0x0a, 0x80, 0x45, 0xb9, 0x98, 0x21, 0xad, 0x99, 0xd1, 0xbf, 0x81, 0x46, 0x94, 0x8c, 0xf2, 0xf9, 0xdc, 0x59, 0x74, + 0x43, 0xa7, 0x4f, 0x40, 0x06, 0xd6, 0xd6, 0x01, 0x6b, 0x89, 0x45, 0x85, 0x68, 0x13, 0x9a, 0x4c, 0x00, 0xee, 0x93, + 0x60, 0x43, 0xe1, 0xd7, 0x5a, 0x4e, 0x82, 0x9f, 0xbb, 0x57, 0x82, 0xa4, 0x97, 0xe2, 0x28, 0x9d, 0x4c, 0x18, 0xb4, + 0x7b, 0xcd, 0xcb, 0x97, 0xbd, 0xcf, 0xed, 0xfa, 0x90, 0xf9, 0xc8, 0x9e, 0xb5, 0xe6, 0x64, 0xe4, 0x6b, 0xcd, 0x51, + 0x77, 0xf2, 0x06, 0x52, 0x36, 0xfb, 0x85, 0x61, 0x81, 0xc5, 0x6f, 0x35, 0x4c, 0x6e, 0xbd, 0x39, 0xa5, 0xf6, 0x11, + 0x4f, 0x12, 0x38, 0x1b, 0x5e, 0x37, 0xd4, 0x5a, 0x68, 0xaf, 0x57, 0x38, 0xaa, 0xf4, 0xe9, 0x4e, 0x29, 0x37, 0xd7, + 0x63, 0xef, 0xbe, 0xf5, 0xad, 0xf4, 0x84, 0xbc, 0xf3, 0x02, 0x9c, 0x95, 0x3f, 0x5f, 0xfb, 0x8f, 0x05, 0xa4, 0xae, + 0x1a, 0x67, 0x73, 0x5b, 0xf6, 0xc6, 0x77, 0x4b, 0xde, 0xbe, 0x17, 0xd6, 0xb0, 0x6e, 0x5b, 0x27, 0x89, 0xd7, 0x6e, + 0x31, 0x2b, 0x2d, 0xe4, 0x33, 0x72, 0xc9, 0x4c, 0x22, 0xe4, 0x1a, 0xa1, 0xe1, 0x5a, 0xaf, 0xd0, 0x6d, 0xd7, 0x10, + 0xe6, 0x2a, 0x4c, 0x8f, 0x2d, 0x11, 0x1c, 0x54, 0xcd, 0xb7, 0xf5, 0xbf, 0x81, 0x1e, 0xfe, 0xd8, 0xec, 0x95, 0x05, + 0x53, 0x3c, 0xe9, 0xdc, 0xd7, 0xfa, 0xbb, 0x46, 0x3c, 0x4a, 0x4f, 0x1a, 0xa2, 0xe8, 0x11, 0x09, 0xf8, 0x5a, 0xc5, + 0xa0, 0x97, 0x15, 0xf7, 0x50, 0xa1, 0x4f, 0x5b, 0x98, 0xa3, 0xc2, 0x55, 0xaf, 0xc8, 0x93, 0x11, 0xfa, 0x4c, 0xad, + 0x0f, 0x84, 0x5c, 0x14, 0xef, 0x7d, 0xd2, 0x7a, 0xbb, 0x3e, 0x5f, 0xe4, 0x0e, 0xe9, 0xdd, 0xdb, 0x84, 0xe9, 0xa5, + 0x43, 0x37, 0xb6, 0xf1, 0x4f, 0xc4, 0xb3, 0x8d, 0xe1, 0x42, 0x95, 0xa5, 0x78, 0x5a, 0x8e, 0x52, 0xdd, 0xd1, 0x98, + 0x24, 0x15, 0xc8, 0xde, 0xd9, 0x76, 0x58, 0x73, 0xe1, 0xab, 0xec, 0xea, 0xd8, 0x03, 0x95, 0xb8, 0x87, 0xe4, 0x0e, + 0xfb, 0xb6, 0xbf, 0xcc, 0x54, 0xa6, 0x21, 0xfe, 0xc7, 0xf7, 0xdc, 0x81, 0x46, 0x7f, 0x3b, 0x8e, 0xe8, 0x58, 0x72, + 0x8b, 0x65, 0xca, 0x70, 0xe4, 0x04, 0x8b, 0xed, 0xde, 0x70, 0xca, 0xb9, 0xec, 0xb4, 0x45, 0x31, 0x4c, 0x72, 0x0f, + 0x8c, 0x6c, 0x45, 0xfb, 0x27, 0xf6, 0x44, 0xc3, 0x9c, 0x9e, 0x9a, 0x77, 0x96, 0xf8, 0x36, 0xed, 0x9f, 0xa8, 0x5d, + 0x42, 0x15, 0xa5, 0xc8, 0x4a, 0xdc, 0xe5, 0x97, 0x76, 0x9b, 0x08, 0xdb, 0x45, 0x98, 0xd6, 0x5e, 0x4f, 0x52, 0x39, + 0xd2, 0x28, 0x75, 0xec, 0xf0, 0xb6, 0x93, 0xa6, 0x02, 0x22, 0x54, 0x54, 0x4f, 0x4a, 0x5a, 0x4a, 0x5f, 0x88, 0x5a, + 0x77, 0x3e, 0xda, 0x8a, 0xf6, 0x04, 0x1c, 0xc0, 0xa6, 0xd5, 0x16, 0x95, 0xca, 0xc3, 0x0d, 0x3b, 0x04, 0xed, 0x2b, + 0x78, 0xf9, 0x00, 0x47, 0x55, 0x9e, 0xde, 0x17, 0xa4, 0xe2, 0xc7, 0x29, 0x36, 0x1e, 0x66, 0x93, 0xa1, 0x12, 0xb8, + 0x31, 0x4a, 0x87, 0xcf, 0xd7, 0xef, 0x74, 0x98, 0xbc, 0xfa, 0xb8, 0xa7, 0x17, 0xd3, 0x2b, 0x20, 0x5e, 0xb8, 0x79, + 0x7f, 0x1c, 0x26, 0xd7, 0x70, 0x82, 0xf4, 0x49, 0xaa, 0xb7, 0x6d, 0x19, 0x03, 0x0a, 0xcb, 0xbe, 0x9c, 0xc6, 0x5e, + 0x4c, 0x7c, 0x9e, 0xbf, 0x4b, 0x1b, 0xd3, 0xb2, 0xc2, 0x58, 0x7b, 0x75, 0xdb, 0x21, 0x5c, 0xe6, 0x0e, 0x7e, 0xf9, + 0x3f, 0x7c, 0xd4, 0x76, 0x73, 0xbf, 0x6e, 0xce, 0x8c, 0x00, 0xcf, 0x48, 0x88, 0xbe, 0x3c, 0x90, 0x2b, 0xd7, 0xaf, + 0xfe, 0x37, 0x50, 0xfc, 0xa4, 0x2b, 0xcd, 0xbf, 0xe6, 0xfa, 0xb0, 0x18, 0x9b, 0x82, 0x6c, 0x1f, 0x49, 0x61, 0x74, + 0x8d, 0x68, 0xbc, 0xdf, 0xb7, 0x61, 0x5d, 0x0d, 0x32, 0x72, 0x8b, 0x90, 0xd7, 0x87, 0x58, 0x60, 0xf4, 0xfd, 0x65, + 0xdb, 0xe2, 0x9b, 0x56, 0x24, 0xde, 0x30, 0xab, 0xb4, 0xfe, 0x17, 0x59, 0xb8, 0xbe, 0xfb, 0xd2, 0x80, 0x80, 0xd6, + 0xda, 0x57, 0xc2, 0x72, 0xe7, 0x08, 0x02, 0x18, 0x94, 0x30, 0x16, 0x4f, 0x22, 0xfa, 0x97, 0xcc, 0x88, 0xd4, 0x53, + 0xc5, 0x74, 0xe2, 0x84, 0xe1, 0xac, 0x04, 0x35, 0x56, 0x7a, 0x80, 0xcd, 0x5c, 0x94, 0xab, 0x61, 0x2b, 0xc6, 0x43, + 0x8a, 0xb8, 0x63, 0xd6, 0xc8, 0x7b, 0x42, 0x25, 0x0d, 0xaa, 0x88, 0x0a, 0x29, 0x8f, 0x42, 0x1c, 0x86, 0x67, 0x10, + 0x02, 0xa4, 0x52, 0xc4, 0x3a, 0x73, 0x49, 0x86, 0x71, 0xe0, 0xb5, 0x53, 0xc9, 0xab, 0xd1, 0xdd, 0x2a, 0x74, 0x0a, + 0x22, 0x3a, 0x30, 0xbb, 0x05, 0x5d, 0x6f, 0x16, 0xb0, 0x5b, 0x31, 0xb5, 0x52, 0xdc, 0xcd, 0x98, 0xad, 0x58, 0x6c, + 0x61, 0x40, 0x24, 0xb4, 0x65, 0xfe, 0x1a, 0x1d, 0xf0, 0xa2, 0x8b, 0xa2, 0x27, 0xa5, 0xf1, 0xdf, 0x94, 0x7a, 0x5f, + 0x50, 0xc3, 0xc8, 0x82, 0x82, 0xeb, 0x6c, 0xdc, 0x4a, 0xfc, 0xf0, 0x96, 0x3a, 0xdc, 0x42, 0xf0, 0x55, 0x48, 0x37, + 0xd5, 0xc2, 0x5c, 0x61, 0x0f, 0xb2, 0xe5, 0xda, 0x72, 0xa3, 0xe3, 0xbb, 0x5e, 0xbb, 0xf0, 0xfc, 0xa5, 0x36, 0xcf, + 0x95, 0x53, 0x3c, 0x96, 0x82, 0x5c, 0xe2, 0xa9, 0x95, 0x75, 0x27, 0xf5, 0x61, 0x58, 0xd3, 0x51, 0x8d, 0x3b, 0xe3, + 0xc9, 0x13, 0x32, 0xc9, 0x97, 0x56, 0xea, 0x9c, 0x50, 0x47, 0xa0, 0xb6, 0x1e, 0x94, 0xa9, 0x5f, 0x8a, 0x2d, 0x60, + 0x1e, 0x1e, 0xf8, 0x8f, 0x61, 0x91, 0x3c, 0x99, 0x44, 0x4e, 0x13, 0x4f, 0xe5, 0xf8, 0x15, 0x9f, 0x33, 0x9e, 0x0c, + 0x27, 0x7b, 0x2c, 0x49, 0x7a, 0xb6, 0x8c, 0xf9, 0x61, 0x00, 0x88, 0x13, 0x61, 0xcc, 0x45, 0x1e, 0x51, 0x28, 0x5a, + 0x9c, 0x5c, 0x57, 0x40, 0x6a, 0xaa, 0x6d, 0xbf, 0xa6, 0xe8, 0x08, 0xcc, 0xd2, 0x65, 0x1a, 0xd5, 0x2c, 0x55, 0x26, + 0x08, 0xe1, 0x73, 0x6e, 0xad, 0x1d, 0x17, 0x30, 0xd3, 0x8e, 0x9e, 0xdb, 0xe4, 0x75, 0xf6, 0x47, 0x46, 0x66, 0xea, + 0xce, 0xaa, 0xc6, 0x04, 0x63, 0x57, 0xed, 0xd2, 0x50, 0x79, 0xe3, 0x64, 0xf7, 0xd5, 0xa9, 0xdd, 0x86, 0x32, 0xb8, + 0x88, 0x89, 0x87, 0x6c, 0x04, 0x20, 0xba, 0x96, 0xab, 0x95, 0x27, 0xc7, 0xc6, 0x10, 0xe6, 0xa6, 0x38, 0xcf, 0x81, + 0xb6, 0x7f, 0xdc, 0xb5, 0x50, 0x2b, 0x44, 0x56, 0x36, 0xfb, 0x67, 0x13, 0x78, 0xbd, 0x58, 0xbc, 0x08, 0x2f, 0xe6, + 0x41, 0x2a, 0x2f, 0x16, 0xbf, 0xb2, 0x94, 0x86, 0x14, 0x61, 0x2d, 0xb0, 0xb9, 0xb4, 0x92, 0x67, 0xcb, 0xe9, 0x85, + 0xeb, 0x99, 0xcc, 0xbc, 0x10, 0x30, 0x66, 0xe9, 0x57, 0x5e, 0xa2, 0xb3, 0x03, 0xfb, 0x9f, 0xfd, 0x86, 0x3a, 0x22, + 0x53, 0xb0, 0xe9, 0x36, 0x46, 0x6a, 0x91, 0xac, 0x24, 0xea, 0x47, 0x56, 0x3e, 0x7b, 0xd7, 0xea, 0xb7, 0xda, 0xb9, + 0x21, 0x50, 0xf8, 0xde, 0x88, 0x09, 0x0d, 0x2a, 0xb1, 0xa4, 0x6e, 0xdc, 0x07, 0xe7, 0x41, 0x59, 0xd3, 0xaf, 0x04, + 0x82, 0xff, 0xc4, 0x6e, 0xda, 0x25, 0x57, 0x90, 0x2e, 0x06, 0x77, 0x2a, 0x54, 0x37, 0x44, 0x78, 0x7d, 0x76, 0x2f, + 0xd1, 0xc4, 0x61, 0xb6, 0x22, 0x0b, 0x3d, 0xbc, 0xf6, 0xe0, 0xf6, 0x79, 0x66, 0x2d, 0xee, 0x54, 0x82, 0xf6, 0xb5, + 0xd9, 0xab, 0x7e, 0xf2, 0x78, 0xf0, 0xab, 0xc1, 0x73, 0x41, 0x06, 0x37, 0xbb, 0x41, 0xd4, 0x0f, 0xa1, 0xf3, 0x2c, + 0xf8, 0x1e, 0xc1, 0x94, 0xfe, 0x95, 0x17, 0xe2, 0x57, 0x83, 0x8f, 0x32, 0x33, 0xa8, 0x1e, 0xab, 0x08, 0x52, 0x7e, + 0x92, 0x61, 0x84, 0x91, 0x61, 0xe8, 0xba, 0x0a, 0x51, 0xc2, 0x1b, 0x2c, 0x36, 0xb3, 0x7b, 0x53, 0xf3, 0x7f, 0x81, + 0xd4, 0x21, 0xfc, 0x90, 0xd8, 0x13, 0xf3, 0x10, 0xf6, 0x6a, 0xe6, 0x71, 0xb6, 0xaf, 0xa2, 0x8e, 0xf5, 0x66, 0x8b, + 0x27, 0x16, 0x54, 0x1f, 0xc2, 0xda, 0x54, 0x81, 0x4b, 0xc4, 0xdc, 0xae, 0xfd, 0x7f, 0xfc, 0x75, 0xda, 0xb1, 0x8d, + 0x98, 0x99, 0x1e, 0x8e, 0xfb, 0xc6, 0x15, 0x51, 0x17, 0xa0, 0x60, 0x0e, 0x5a, 0x57, 0xb0, 0x12, 0x8f, 0xda, 0xd3, + 0xdb, 0xae, 0xbf, 0x1f, 0x20, 0xc4, 0x0f, 0xcd, 0xf2, 0xbe, 0x42, 0x6c, 0x34, 0x69, 0xbb, 0xb1, 0x73, 0x6c, 0xab, + 0x0e, 0x2b, 0x0a, 0x25, 0x74, 0x43, 0x03, 0xe7, 0x6e, 0x20, 0xc0, 0xfa, 0x29, 0xce, 0xa2, 0x5d, 0xd8, 0x43, 0xd7, + 0x6e, 0x6b, 0x3c, 0x35, 0x7a, 0x62, 0xa4, 0x95, 0x80, 0x2d, 0x53, 0xdf, 0x79, 0x45, 0x77, 0x9b, 0x1b, 0x76, 0xae, + 0xcf, 0x6d, 0xa9, 0xf6, 0xe3, 0x78, 0x6c, 0x1b, 0x66, 0x99, 0xda, 0xbd, 0xbb, 0x66, 0xae, 0x7e, 0xb9, 0xce, 0x54, + 0x84, 0x6c, 0x38, 0x85, 0xe4, 0x84, 0xe4, 0xb6, 0xd7, 0x92, 0x18, 0xc5, 0x7a, 0xc7, 0x06, 0x8e, 0x90, 0x73, 0xb6, + 0x62, 0x06, 0x6b, 0xb3, 0xdd, 0xc7, 0xc2, 0x64, 0xc3, 0x69, 0xed, 0x1e, 0x5a, 0x68, 0x04, 0x97, 0x8c, 0xe7, 0x2a, + 0x93, 0xc5, 0xe3, 0x0e, 0xf3, 0xcb, 0xf6, 0x19, 0x8d, 0x17, 0x0d, 0xa7, 0x1a, 0x7b, 0x53, 0x52, 0x46, 0xb3, 0xef, + 0xdc, 0xd2, 0x5a, 0x24, 0xde, 0xbc, 0xa7, 0x77, 0x82, 0xa1, 0xb5, 0xf7, 0xaa, 0x2d, 0x80, 0xfa, 0x9f, 0xed, 0xac, + 0x58, 0xd0, 0x38, 0xec, 0x0c, 0x70, 0xe3, 0xe2, 0x79, 0x87, 0xe2, 0x31, 0x99, 0xe9, 0xbd, 0x15, 0x59, 0xef, 0xf2, + 0xbf, 0xda, 0xae, 0x13, 0x9f, 0x3d, 0xba, 0xdb, 0xea, 0xa0, 0xb5, 0xae, 0x8b, 0x94, 0xf8, 0x38, 0xad, 0x5d, 0x4c, + 0xdc, 0x92, 0x85, 0x97, 0x39, 0x9a, 0xff, 0x15, 0x8b, 0x1c, 0x36, 0x68, 0x9c, 0x9b, 0xf8, 0xd6, 0x52, 0x1a, 0x7d, + 0x6a, 0x50, 0x17, 0x26, 0x51, 0x89, 0x20, 0xb4, 0xd2, 0xbf, 0x62, 0xef, 0x6b, 0x6f, 0x33, 0x15, 0xd7, 0x29, 0xce, + 0x60, 0xf2, 0xa8, 0xe7, 0x1c, 0x49, 0xc7, 0x2c, 0x6b, 0x7c, 0x03, 0x4d, 0xdb, 0x4a, 0xd3, 0x64, 0x54, 0xc3, 0x46, + 0xac, 0x33, 0x1b, 0xf1, 0xc2, 0x48, 0xd3, 0xb6, 0x2b, 0xa1, 0xd3, 0xa9, 0xfa, 0xc5, 0x13, 0xe7, 0xd6, 0xc2, 0x7f, + 0xcb, 0x8b, 0x03, 0xc4, 0xb9, 0xae, 0x46, 0x1a, 0x19, 0x74, 0xe1, 0x2e, 0x3e, 0xe5, 0x8e, 0x5b, 0x39, 0x86, 0x60, + 0xd5, 0x6a, 0xe3, 0xe2, 0x50, 0xd6, 0xd7, 0x20, 0xf5, 0x3e, 0x18, 0x69, 0x32, 0x66, 0x57, 0xce, 0x9f, 0xe6, 0xe9, + 0xa1, 0x44, 0x99, 0x1a, 0x99, 0x36, 0x7c, 0xcf, 0xaf, 0xe6, 0x24, 0x76, 0x6d, 0x3c, 0x1f, 0x9c, 0x98, 0x7a, 0x2b, + 0x67, 0x25, 0x45, 0x01, 0xd0, 0x86, 0xb9, 0xb6, 0x64, 0x23, 0x65, 0xda, 0xb3, 0xce, 0xfb, 0x76, 0xe7, 0x8a, 0x93, + 0xd9, 0x69, 0x02, 0x5d, 0xa1, 0xa9, 0xea, 0xd4, 0x0c, 0x8d, 0x10, 0x98, 0xf1, 0x61, 0x0a, 0xfd, 0xa2, 0x48, 0x30, + 0x74, 0xd3, 0x0b, 0x8a, 0x15, 0x27, 0x9a, 0xe7, 0x4b, 0x5d, 0x25, 0xe1, 0xa6, 0xf6, 0x7e, 0xed, 0xfe, 0x97, 0x9e, + 0xdc, 0x45, 0x9d, 0x09, 0x41, 0x29, 0x60, 0xd2, 0x71, 0xf0, 0x61, 0x28, 0xc3, 0x1f, 0x57, 0x30, 0x7a, 0x91, 0x59, + 0x7f, 0x20, 0x92, 0x43, 0xc5, 0x77, 0x96, 0x5f, 0x5a, 0xa1, 0xf8, 0x89, 0xc8, 0x0e, 0x8a, 0xaf, 0x41, 0xc0, 0x23, + 0xa8, 0xd9, 0x4e, 0x57, 0x82, 0x27, 0x78, 0xc7, 0x8b, 0x7c, 0xc5, 0xc8, 0xeb, 0x69, 0xb5, 0xa4, 0x61, 0x68, 0x8e, + 0x25, 0x41, 0x63, 0x53, 0xc7, 0x12, 0x82, 0x79, 0x5f, 0x1f, 0xeb, 0xb9, 0xd5, 0x8e, 0x02, 0x27, 0x58, 0xfb, 0x81, + 0xb4, 0x8e, 0x74, 0x3c, 0xb5, 0x68, 0xd6, 0x36, 0x32, 0xd1, 0xd9, 0xc4, 0x40, 0x3a, 0x0b, 0x0e, 0x36, 0xe6, 0xd3, + 0x68, 0xae, 0xbc, 0x61, 0x04, 0xff, 0xbd, 0x0a, 0xcb, 0x59, 0x7a, 0xb5, 0xe5, 0x62, 0x1c, 0x55, 0xf8, 0x3f, 0x0d, + 0x13, 0xbe, 0xc9, 0xf9, 0xb8, 0x5c, 0x24, 0x44, 0xa8, 0x80, 0x07, 0x3a, 0x26, 0x7c, 0x1d, 0xad, 0x86, 0x11, 0x5a, + 0x75, 0x2b, 0xc8, 0x11, 0xd2, 0x7e, 0xdf, 0x54, 0x5b, 0xdf, 0x34, 0x67, 0x6f, 0xcf, 0x0d, 0x9b, 0x06, 0xf3, 0xe3, + 0x73, 0x8f, 0x4d, 0x37, 0x12, 0x55, 0x2c, 0xbf, 0x83, 0x8f, 0xda, 0x98, 0xe1, 0x83, 0xfe, 0xf0, 0xa6, 0x71, 0xcc, + 0x78, 0x95, 0x4d, 0x9a, 0xf4, 0xc3, 0x99, 0x6b, 0x81, 0xda, 0xa7, 0xa6, 0xee, 0x48, 0xd1, 0x81, 0xa3, 0xab, 0xf9, + 0x16, 0x5f, 0x89, 0xf0, 0xf0, 0x6b, 0x12, 0x95, 0x35, 0xcd, 0xa0, 0x4e, 0xa5, 0x34, 0x51, 0x75, 0xdb, 0x54, 0x00, + 0x7b, 0xcf, 0xb0, 0x32, 0x50, 0xa3, 0x27, 0xba, 0x13, 0x34, 0x42, 0x1a, 0xc7, 0x9f, 0x42, 0xfb, 0x91, 0xc6, 0x6f, + 0xc5, 0x94, 0x63, 0x3b, 0x86, 0x79, 0xd5, 0x00, 0x55, 0x0b, 0x7d, 0xfc, 0xeb, 0x9b, 0xad, 0xdb, 0xb5, 0xed, 0x76, + 0x87, 0xb0, 0x54, 0x2f, 0x8f, 0x5a, 0x34, 0x93, 0x98, 0xa6, 0x14, 0x16, 0x5d, 0xb4, 0x8e, 0x97, 0xd3, 0xc6, 0x41, + 0xad, 0x30, 0xd8, 0x16, 0xaa, 0x74, 0x19, 0x31, 0xdc, 0x4e, 0x61, 0x84, 0x4c, 0xa1, 0x42, 0x1f, 0xb1, 0x66, 0xba, + 0x75, 0xf7, 0x50, 0x5a, 0xcb, 0xf2, 0xad, 0x17, 0x6b, 0xd4, 0xb7, 0xde, 0x66, 0x35, 0x8a, 0x5a, 0x4c, 0xbc, 0x12, + 0x8c, 0xae, 0x2f, 0x13, 0x5a, 0xb9, 0x45, 0x5b, 0xa5, 0x20, 0x48, 0xec, 0xd6, 0xe2, 0x2b, 0xd1, 0x8e, 0xcd, 0x1c, + 0x89, 0xc9, 0xfc, 0xf4, 0xda, 0x54, 0x86, 0xca, 0x87, 0x0f, 0x3e, 0x67, 0x68, 0x8a, 0x27, 0xef, 0xc0, 0x4f, 0xba, + 0xfc, 0x49, 0xea, 0x03, 0xef, 0xb6, 0x0c, 0x4e, 0x51, 0x3b, 0xb7, 0x74, 0x18, 0xc0, 0x75, 0x52, 0xf0, 0x82, 0x2b, + 0x4c, 0x92, 0x46, 0x3e, 0x3a, 0x41, 0x4c, 0x8a, 0xce, 0x94, 0x35, 0x18, 0x94, 0xb5, 0x0c, 0x80, 0x35, 0xda, 0x84, + 0xe1, 0x23, 0x90, 0x19, 0x63, 0x06, 0x69, 0x1b, 0xe6, 0x94, 0xcf, 0xba, 0x3f, 0xbe, 0x10, 0xba, 0x3d, 0xd8, 0x13, + 0x51, 0x96, 0x0f, 0xc8, 0x07, 0x1d, 0xd2, 0xbf, 0x22, 0x31, 0xca, 0xe1, 0xb9, 0xdc, 0x7f, 0x12, 0x58, 0x38, 0x80, + 0x9b, 0xb5, 0x77, 0xec, 0x80, 0x64, 0xde, 0x2a, 0x2c, 0xbf, 0x1f, 0x02, 0x0c, 0x5b, 0x3b, 0xb1, 0x9c, 0x15, 0xa3, + 0x65, 0x39, 0x59, 0x41, 0xc3, 0xf2, 0x37, 0x80, 0xaf, 0x03, 0x56, 0xbd, 0x5f, 0xe2, 0x32, 0x53, 0x14, 0xf8, 0x67, + 0xe3, 0xb4, 0x4a, 0x5b, 0x10, 0x1f, 0x04, 0x22, 0x0f, 0xb0, 0x07, 0x57, 0x8f, 0x85, 0xb7, 0x53, 0xbe, 0x8b, 0xca, + 0xd2, 0x35, 0x1a, 0x39, 0xa5, 0x7a, 0xbf, 0xc5, 0x76, 0x83, 0x3d, 0x08, 0xa9, 0x2d, 0x94, 0x7f, 0x85, 0xaa, 0x4a, + 0x51, 0xeb, 0xcd, 0x08, 0x83, 0x16, 0x9c, 0x9b, 0x23, 0x50, 0x43, 0x60, 0xd4, 0xda, 0x5c, 0x4b, 0xa0, 0x35, 0x3f, + 0x80, 0x5d, 0xe7, 0xe3, 0x97, 0x51, 0x4c, 0x78, 0xbc, 0x6f, 0x1a, 0x93, 0x93, 0x1f, 0x3d, 0xee, 0xfa, 0x66, 0xdd, + 0x64, 0x88, 0x59, 0x24, 0xf5, 0x3c, 0xc2, 0x6c, 0xe7, 0xb5, 0x70, 0xb1, 0x3a, 0x41, 0xcf, 0xe5, 0x8a, 0x14, 0xf7, + 0xa8, 0xbb, 0x65, 0xf7, 0x7c, 0xaa, 0x9e, 0xc4, 0x58, 0x4b, 0x11, 0x3f, 0xc5, 0xb5, 0x99, 0x50, 0xa5, 0xc8, 0xcd, + 0x26, 0xb0, 0x95, 0x23, 0xed, 0xf1, 0x48, 0x96, 0x13, 0x75, 0xac, 0x41, 0xd4, 0x3c, 0xbe, 0xb3, 0x72, 0xe8, 0x46, + 0x77, 0xd8, 0x37, 0xff, 0x1f, 0xbb, 0xe9, 0xe9, 0x38, 0x93, 0x65, 0xf0, 0x32, 0x06, 0x67, 0xbc, 0xf3, 0xc2, 0xb4, + 0x4a, 0x45, 0x8c, 0x46, 0x3f, 0x16, 0x7d, 0x7f, 0xaa, 0x77, 0x5d, 0x82, 0x20, 0xd5, 0xe5, 0xbf, 0x81, 0xa3, 0xba, + 0x3a, 0x5c, 0x7a, 0x7a, 0xe6, 0x96, 0x46, 0x97, 0xef, 0x98, 0xc1, 0x5d, 0x05, 0x13, 0x60, 0x0d, 0xbc, 0x45, 0xef, + 0xdc, 0x12, 0xc2, 0x65, 0xd4, 0xbb, 0xee, 0x95, 0x53, 0x28, 0x3a, 0x47, 0x77, 0x83, 0x84, 0x1a, 0xae, 0xf3, 0xdc, + 0x3e, 0x5a, 0x29, 0x2a, 0x1f, 0xe7, 0xc3, 0x85, 0xb3, 0x44, 0x12, 0x05, 0xc7, 0x4b, 0x08, 0xd7, 0x7d, 0x3b, 0x66, + 0x84, 0x91, 0x6d, 0x4b, 0xa5, 0xba, 0xe1, 0x5d, 0xe8, 0x51, 0xcc, 0x5a, 0x36, 0xe0, 0xfc, 0x7f, 0xe9, 0xf5, 0x48, + 0xba, 0xb7, 0x29, 0xf1, 0xb8, 0xf0, 0xef, 0xe2, 0xc8, 0x29, 0x28, 0x89, 0x4a, 0xb4, 0x7d, 0x57, 0x76, 0xe0, 0x78, + 0x68, 0x0f, 0xe9, 0xb4, 0x29, 0xcb, 0x2a, 0x00, 0xad, 0x7d, 0xe6, 0x65, 0xe4, 0x64, 0xf4, 0xa4, 0xbd, 0x43, 0xd1, + 0x1b, 0x54, 0x26, 0x21, 0x87, 0x41, 0x22, 0xe6, 0x3a, 0xe0, 0xee, 0xaa, 0xdb, 0x5d, 0x73, 0x15, 0xba, 0x6b, 0x76, + 0xe5, 0x80, 0x8e, 0xe4, 0x90, 0x64, 0xe6, 0xac, 0xf6, 0x41, 0x11, 0x45, 0xde, 0x23, 0xf6, 0xc5, 0x9d, 0x4a, 0xba, + 0x99, 0x77, 0x51, 0x48, 0x14, 0x10, 0xc6, 0x29, 0x88, 0xf7, 0x04, 0x08, 0xa5, 0x75, 0x77, 0xd4, 0x26, 0x5c, 0xf5, + 0x4c, 0x5b, 0x19, 0xc3, 0x9d, 0xce, 0x9d, 0x91, 0x5d, 0xe0, 0x52, 0xf7, 0x62, 0x08, 0xa2, 0x40, 0x4e, 0x41, 0x0c, + 0x27, 0x41, 0xf1, 0xa1, 0x38, 0x90, 0x80, 0x43, 0xe4, 0x41, 0xa9, 0x71, 0xc9, 0xdc, 0x78, 0xa3, 0x10, 0x62, 0x31, + 0x12, 0x31, 0x21, 0xd9, 0x30, 0x70, 0x4c, 0x05, 0xda, 0xfd, 0x72, 0xdf, 0x7b, 0xe1, 0xf7, 0x43, 0x4d, 0x2d, 0xe6, + 0x42, 0x16, 0x46, 0xab, 0x93, 0x7b, 0x81, 0x63, 0xbe, 0x57, 0x2f, 0xb7, 0x91, 0xbd, 0xf0, 0x8d, 0x4b, 0x72, 0x95, + 0x12, 0x10, 0xf6, 0x1f, 0x8c, 0x03, 0x01, 0x30, 0x97, 0x56, 0xb5, 0x96, 0xc8, 0xc3, 0x1b, 0x69, 0xd6, 0xb4, 0x14, + 0xeb, 0x66, 0x1e, 0x2a, 0xc0, 0x92, 0x5a, 0xdc, 0x30, 0x97, 0x15, 0xce, 0x68, 0x0e, 0x4a, 0x78, 0xd3, 0x42, 0xd7, + 0xe6, 0x73, 0x78, 0x92, 0xe6, 0xe8, 0xf7, 0xf0, 0x56, 0x75, 0xcb, 0x92, 0xea, 0x4c, 0x32, 0x98, 0xc8, 0x54, 0xea, + 0x69, 0x38, 0xee, 0xa4, 0xef, 0x04, 0x63, 0xb2, 0xd0, 0x78, 0x27, 0xeb, 0x6c, 0xec, 0x0c, 0x7d, 0x60, 0x7f, 0xc0, + 0x05, 0xc5, 0x77, 0x49, 0xc7, 0xb7, 0x49, 0x84, 0x45, 0x56, 0x76, 0xed, 0xf2, 0xd2, 0xf7, 0x5d, 0x6f, 0xe6, 0xa5, + 0xfb, 0xec, 0xbb, 0xdf, 0xbd, 0x25, 0x6b, 0x45, 0xc9, 0x49, 0xf2, 0x84, 0xe5, 0x6d, 0xda, 0x1e, 0xf2, 0x74, 0x60, + 0xc8, 0xdc, 0x38, 0xae, 0x7f, 0x51, 0x8c, 0x34, 0x75, 0xd8, 0x51, 0x7a, 0x53, 0x81, 0xa7, 0xf6, 0x39, 0x8b, 0x0e, + 0x14, 0xcf, 0x30, 0x5d, 0x13, 0xe1, 0xcd, 0xfe, 0xc5, 0xfc, 0xdf, 0x03, 0xa2, 0xe3, 0xc3, 0x98, 0x36, 0xe4, 0xc3, + 0x2a, 0xbc, 0x14, 0xc7, 0xe2, 0x07, 0x8b, 0x49, 0xe4, 0x49, 0x1c, 0xe0, 0x7d, 0x60, 0x91, 0x0a, 0x23, 0x83, 0x3a, + 0x56, 0x76, 0xc7, 0xf1, 0x02, 0x30, 0xe2, 0x21, 0xe3, 0xfc, 0xe2, 0x33, 0x10, 0x38, 0x5e, 0xa8, 0x66, 0x3b, 0x87, + 0x15, 0x08, 0x80, 0x8c, 0x59, 0xa9, 0xb8, 0x18, 0xcd, 0xa2, 0x14, 0x83, 0x67, 0x7c, 0x68, 0x57, 0x0d, 0xb1, 0xcc, + 0xfc, 0x60, 0x50, 0xce, 0xad, 0x85, 0x14, 0xdc, 0xae, 0x2f, 0x8c, 0x09, 0x6e, 0xdb, 0x48, 0xb0, 0x45, 0xfd, 0x18, + 0x10, 0x8b, 0x0b, 0xea, 0x1a, 0xbf, 0xd7, 0x99, 0x3b, 0x69, 0x9f, 0xb8, 0x8e, 0xd2, 0xb2, 0x94, 0xc4, 0x75, 0x1e, + 0x46, 0x02, 0xc1, 0xf4, 0x9a, 0x10, 0x95, 0x18, 0x62, 0x1f, 0xcb, 0xbd, 0x01, 0xf0, 0x18, 0xa2, 0x23, 0xc7, 0xec, + 0xbc, 0x43, 0x78, 0xba, 0x81, 0x5f, 0x16, 0xbf, 0x95, 0xf1, 0xeb, 0xe3, 0x51, 0x76, 0x44, 0x3e, 0xbc, 0x91, 0xb8, + 0x53, 0x31, 0x07, 0xd2, 0xc8, 0x15, 0xb0, 0xb4, 0x05, 0x72, 0x91, 0x71, 0x0c, 0x5b, 0x3f, 0xb5, 0x3e, 0x06, 0x3f, + 0xf6, 0xb1, 0xe8, 0xf8, 0x75, 0xa0, 0xaf, 0x52, 0x22, 0x7f, 0x2b, 0xa5, 0x38, 0x7b, 0x6f, 0x46, 0xbb, 0x3b, 0x71, + 0x53, 0xaf, 0xec, 0x6d, 0x43, 0x7d, 0x93, 0xb8, 0x7d, 0x6b, 0x1e, 0x03, 0xee, 0xeb, 0xc4, 0x8d, 0xa1, 0xd0, 0x27, + 0xcb, 0xe3, 0x46, 0x53, 0x13, 0x43, 0x77, 0x1e, 0xe1, 0x57, 0xa7, 0x3d, 0x9c, 0xdd, 0x97, 0x26, 0xdd, 0x08, 0xb3, + 0xb8, 0xd8, 0x25, 0x19, 0x1c, 0x06, 0x2c, 0x0e, 0x45, 0x8a, 0x16, 0xb9, 0x6c, 0x0c, 0x91, 0xc3, 0x0e, 0xee, 0x26, + 0x8d, 0x53, 0xde, 0x31, 0x78, 0x69, 0x52, 0x9f, 0xb7, 0xd5, 0x62, 0x42, 0x4d, 0x98, 0x6a, 0xf0, 0xd6, 0xb6, 0x7c, + 0x2c, 0x94, 0x72, 0x12, 0x48, 0xa7, 0x2c, 0x54, 0x0a, 0x7e, 0xe2, 0x0f, 0xf7, 0x7f, 0x50, 0x94, 0x3b, 0x02, 0x6e, + 0x05, 0x1d, 0xfe, 0x7c, 0x10, 0x2f, 0x63, 0x88, 0x47, 0x46, 0xc6, 0xf4, 0x2f, 0x29, 0xab, 0x7e, 0x05, 0x99, 0x98, + 0xaf, 0xb3, 0x07, 0xb9, 0x1a, 0xdf, 0xa9, 0xb5, 0x30, 0xae, 0x23, 0x0d, 0x4d, 0xcc, 0x4f, 0xa1, 0xb0, 0xe9, 0x2a, + 0x03, 0x0b, 0xa5, 0x0c, 0xf9, 0xbe, 0xd4, 0xed, 0xa3, 0xe1, 0x27, 0xa1, 0xe7, 0xd3, 0x0c, 0x93, 0x90, 0x16, 0x40, + 0xf5, 0xe1, 0x68, 0xd2, 0x0d, 0x76, 0xf3, 0x51, 0x07, 0x2a, 0x9d, 0x1d, 0x73, 0x4a, 0x90, 0xf3, 0xfc, 0x64, 0x1b, + 0x7b, 0xc7, 0x5f, 0x1b, 0x7f, 0x83, 0xc0, 0x67, 0xfe, 0xa3, 0x37, 0x55, 0x1b, 0x88, 0xf5, 0x72, 0x46, 0xd0, 0xb6, + 0x0c, 0xb8, 0xa5, 0xca, 0xa1, 0xd9, 0x52, 0x31, 0x2c, 0xcc, 0xd4, 0xc2, 0x14, 0x2f, 0x3a, 0x41, 0xee, 0x0f, 0x21, + 0x16, 0x28, 0x37, 0x20, 0x65, 0xc9, 0x31, 0x1d, 0x44, 0x8a, 0xde, 0x06, 0x0a, 0x22, 0x94, 0x5f, 0xbf, 0xd4, 0xff, + 0x45, 0x04, 0x58, 0x8e, 0xb4, 0xca, 0x40, 0x32, 0xb5, 0xb1, 0x9c, 0xd4, 0xe2, 0x54, 0x9c, 0x55, 0xca, 0x30, 0xf9, + 0xdd, 0x78, 0xd9, 0x9a, 0xa0, 0x66, 0x08, 0x4f, 0xc9, 0xc1, 0x1a, 0x4d, 0x4c, 0x4f, 0x99, 0xfd, 0x05, 0x17, 0xa2, + 0x41, 0x7e, 0x23, 0xb8, 0x75, 0x2c, 0x6d, 0x14, 0x78, 0xd4, 0xbe, 0x89, 0x15, 0x95, 0x56, 0xe1, 0x9c, 0xa8, 0x99, + 0x6c, 0xcb, 0x5e, 0xee, 0xca, 0x3d, 0x06, 0x2e, 0x33, 0x23, 0xd0, 0x4b, 0xeb, 0x7b, 0xef, 0x00, 0xff, 0xd1, 0xa2, + 0xc8, 0x0d, 0xdb, 0x22, 0x85, 0x8c, 0x6d, 0xbd, 0xf1, 0x5b, 0x7d, 0x8a, 0x83, 0x3c, 0xf6, 0x42, 0x2b, 0x3b, 0xe1, + 0x9d, 0xef, 0x4e, 0x19, 0xe6, 0x45, 0x1c, 0xe7, 0x59, 0x54, 0xe8, 0xc3, 0xa2, 0xaa, 0x44, 0xff, 0x09, 0x00, 0x46, + 0xee, 0x72, 0x2a, 0xfc, 0x5b, 0xc2, 0x6d, 0x7c, 0xd0, 0x4e, 0x0d, 0xe7, 0x73, 0xaa, 0xcf, 0xbb, 0xee, 0x3b, 0xfc, + 0x30, 0x7c, 0xad, 0x71, 0x44, 0x05, 0xa6, 0x69, 0x9e, 0x98, 0xad, 0xe1, 0x77, 0x0a, 0xf8, 0xfe, 0xa1, 0x14, 0xdb, + 0xb0, 0x99, 0x56, 0xed, 0xcd, 0xbc, 0xde, 0xc1, 0x67, 0xce, 0x6a, 0x96, 0xaf, 0x3f, 0xf8, 0x3e, 0xa1, 0x2c, 0xc2, + 0x6f, 0xcb, 0x44, 0x3d, 0xe2, 0x2c, 0x1d, 0x5c, 0xc0, 0xe3, 0x1e, 0xc9, 0xd0, 0xf3, 0x75, 0x36, 0x22, 0x7f, 0xb4, + 0x71, 0x01, 0x69, 0xab, 0x09, 0x25, 0xea, 0x44, 0x8f, 0x48, 0xca, 0x58, 0x58, 0x68, 0x5b, 0x1d, 0x90, 0x45, 0xc1, + 0x72, 0x1b, 0x38, 0x4f, 0x4c, 0x11, 0x0e, 0xdf, 0xb5, 0xa7, 0x8b, 0xa8, 0xff, 0x31, 0x03, 0xf8, 0x0f, 0x98, 0x18, + 0x15, 0xca, 0xff, 0x0e, 0xc3, 0x1f, 0x84, 0x11, 0x71, 0x3a, 0x31, 0x3b, 0x30, 0x60, 0xe4, 0x45, 0x65, 0x46, 0x52, + 0x62, 0xad, 0x95, 0x3c, 0xf8, 0x3e, 0x14, 0x8d, 0xeb, 0x1a, 0x84, 0x60, 0x83, 0x69, 0x05, 0xf1, 0x70, 0x1a, 0x51, + 0xd6, 0x78, 0x34, 0x7e, 0x4f, 0xa5, 0x26, 0xf4, 0xf8, 0x36, 0x4a, 0x16, 0x8f, 0xaa, 0x27, 0xca, 0x47, 0x12, 0x43, + 0xda, 0xc8, 0x49, 0xf1, 0x26, 0xe3, 0xfd, 0xb4, 0x31, 0x22, 0x39, 0x39, 0x9d, 0x1d, 0x91, 0xf2, 0x0b, 0x19, 0x66, + 0xd7, 0x7f, 0xf1, 0xf2, 0x8b, 0x2f, 0xbe, 0x96, 0x4a, 0x54, 0xd7, 0x22, 0x86, 0x6e, 0xd7, 0xd1, 0xfb, 0xae, 0x84, + 0x21, 0x1d, 0x52, 0x1e, 0x14, 0x12, 0x53, 0x59, 0x20, 0x0d, 0xf9, 0x49, 0x54, 0xfe, 0x1e, 0xe6, 0xb3, 0x77, 0xaf, + 0x52, 0x97, 0xa4, 0xac, 0x24, 0x2e, 0x0f, 0x58, 0x9a, 0x4c, 0xbc, 0x39, 0x0f, 0xbb, 0x3f, 0x27, 0x6f, 0xfe, 0xaf, + 0x28, 0x63, 0xaa, 0x29, 0x47, 0x16, 0xea, 0xa0, 0x94, 0xd5, 0x70, 0xda, 0xe2, 0x8b, 0x20, 0xda, 0x2a, 0x74, 0xa9, + 0x79, 0xe0, 0xb2, 0xb0, 0x26, 0x82, 0x2d, 0xe8, 0xe9, 0x30, 0xb2, 0x25, 0xb5, 0x89, 0x4d, 0xaf, 0x23, 0xcf, 0xf2, + 0xa9, 0xda, 0x5d, 0xea, 0x63, 0xef, 0xa0, 0x1e, 0x8b, 0xab, 0xfd, 0xd4, 0x64, 0x1a, 0x70, 0x81, 0xa0, 0x7e, 0x05, + 0xb9, 0x55, 0x8c, 0xb8, 0xd1, 0xcd, 0xfd, 0x63, 0xb5, 0x75, 0x2b, 0xff, 0xb4, 0x0b, 0x22, 0x23, 0x81, 0x81, 0x66, + 0xd1, 0x6a, 0x42, 0x3f, 0x36, 0x2c, 0x85, 0x21, 0x67, 0x4b, 0x66, 0x39, 0xaf, 0x0a, 0xda, 0x95, 0xb6, 0x82, 0x03, + 0x12, 0x46, 0xeb, 0x18, 0x33, 0x83, 0xcf, 0xa1, 0x20, 0x5f, 0xb5, 0xc9, 0x05, 0xfb, 0xe2, 0x9e, 0x26, 0x98, 0x0a, + 0xc2, 0xbc, 0x52, 0x30, 0x9d, 0xf5, 0xcd, 0xc2, 0x1c, 0x0b, 0x85, 0xfc, 0xf8, 0x0b, 0x8a, 0x83, 0xa9, 0x40, 0x17, + 0xf9, 0x2b, 0x0d, 0xdb, 0xce, 0x2c, 0xfa, 0xee, 0x83, 0x02, 0xbc, 0x51, 0x47, 0xe6, 0x25, 0x8b, 0xbf, 0x7a, 0xe7, + 0xe3, 0xe4, 0x1b, 0x2d, 0xb2, 0x8b, 0x89, 0xfe, 0x52, 0x49, 0x33, 0xbf, 0x2e, 0xf5, 0x50, 0xb6, 0xa7, 0x3c, 0xae, + 0x98, 0xe6, 0x3d, 0x4a, 0x7f, 0x1a, 0xf3, 0x84, 0x4c, 0x68, 0x2f, 0xa7, 0xbf, 0x25, 0x6a, 0x76, 0x9f, 0x59, 0xaa, + 0xfe, 0x0d, 0x2f, 0x95, 0x26, 0xe5, 0x58, 0xc6, 0xb4, 0x9e, 0x12, 0xeb, 0x96, 0x05, 0x0c, 0xb2, 0x28, 0x4e, 0x6c, + 0xb4, 0xd9, 0x3b, 0xa2, 0xf9, 0x4e, 0xed, 0x65, 0x72, 0xc2, 0xc2, 0x5c, 0x5d, 0xc9, 0x76, 0x1a, 0x61, 0xb7, 0xde, + 0x13, 0xa9, 0x21, 0x68, 0x46, 0xc9, 0xae, 0x76, 0x7b, 0x41, 0xc3, 0xc4, 0x9a, 0x49, 0x91, 0x2d, 0x9a, 0xe5, 0x4e, + 0xd0, 0x43, 0x3e, 0x95, 0xfc, 0xea, 0x3f, 0x5b, 0x88, 0x9b, 0xcd, 0xf9, 0x3d, 0x23, 0x32, 0x08, 0x83, 0xdc, 0xad, + 0x22, 0x5e, 0xce, 0x04, 0x0a, 0x63, 0x67, 0x82, 0xcd, 0xbb, 0x58, 0x47, 0x58, 0x24, 0xaa, 0x23, 0x69, 0x48, 0x57, + 0x79, 0x08, 0x54, 0xb1, 0xef, 0xc9, 0xd3, 0xca, 0x28, 0x5a, 0xbf, 0x3a, 0xf6, 0x19, 0x10, 0x52, 0x25, 0xcb, 0x8a, + 0xb4, 0x72, 0x85, 0x99, 0x81, 0x91, 0x84, 0x83, 0x23, 0xd0, 0x4d, 0x13, 0xc2, 0xcb, 0x43, 0x7a, 0x69, 0x2d, 0x35, + 0xaa, 0xc5, 0x35, 0x78, 0x25, 0x80, 0xd8, 0x64, 0x8c, 0x5f, 0xef, 0xf6, 0xf4, 0xb0, 0xbe, 0x68, 0xb1, 0xfe, 0x88, + 0x80, 0x63, 0xa4, 0xfb, 0xa2, 0x1c, 0x7a, 0x03, 0x96, 0xb5, 0xc4, 0xb7, 0x8f, 0x61, 0xa8, 0x74, 0xa0, 0x5e, 0x8e, + 0xdc, 0x22, 0xaa, 0x37, 0xc0, 0xb5, 0xdb, 0x15, 0x11, 0xbe, 0x9d, 0x1f, 0xd3, 0xa4, 0x96, 0x10, 0xc4, 0xba, 0x8f, + 0x68, 0x96, 0x89, 0xb0, 0xd9, 0xb8, 0xeb, 0x70, 0x71, 0x0c, 0x45, 0x1f, 0x9e, 0xe2, 0x22, 0x96, 0x9c, 0x2d, 0xbd, + 0xb4, 0x31, 0x4f, 0x87, 0xf4, 0x53, 0xdb, 0x51, 0xe1, 0xd1, 0x0b, 0xcb, 0x85, 0xc6, 0x9d, 0xa4, 0xe0, 0xea, 0x3d, + 0x10, 0x26, 0xe9, 0x73, 0xf7, 0x98, 0xc7, 0xd5, 0xe8, 0x2d, 0x38, 0x7d, 0x0b, 0x68, 0x6f, 0x8a, 0xe0, 0x72, 0xd5, + 0x5e, 0x9a, 0x30, 0xa3, 0x3d, 0xcf, 0x74, 0xb6, 0x24, 0x55, 0x23, 0xde, 0x8b, 0x16, 0xbc, 0x86, 0x72, 0x4f, 0x2c, + 0x61, 0xcc, 0xe0, 0xb6, 0x4b, 0x48, 0xb2, 0xaf, 0xa5, 0x82, 0x95, 0xa0, 0x07, 0xf2, 0xa8, 0x48, 0x46, 0x49, 0xa6, + 0xdb, 0xfe, 0x6c, 0xe6, 0xb6, 0x37, 0x95, 0xdf, 0xb6, 0xce, 0x44, 0x95, 0xa4, 0xaf, 0x57, 0x7d, 0xda, 0x3d, 0xa3, + 0x2b, 0x0f, 0x02, 0xfa, 0x96, 0xd1, 0x5b, 0x2e, 0xb0, 0x6e, 0xc9, 0x0d, 0xa9, 0x20, 0xf6, 0x2e, 0x2b, 0x70, 0xe1, + 0xad, 0x3d, 0x98, 0xb0, 0x06, 0xef, 0x33, 0x3d, 0x69, 0xad, 0xbe, 0x7d, 0xa9, 0xeb, 0xf8, 0xec, 0xbb, 0xed, 0x86, + 0x68, 0xf0, 0x5b, 0x2e, 0xbe, 0x17, 0x9f, 0x99, 0x69, 0x15, 0x0e, 0x66, 0x51, 0xfa, 0x2a, 0xfd, 0x8b, 0x93, 0xd6, + 0x91, 0x0b, 0x70, 0x00, 0xf2, 0x6e, 0xb8, 0x2e, 0xc6, 0x61, 0xbc, 0xe6, 0x84, 0xf3, 0xd4, 0x7b, 0xb0, 0x6b, 0xa7, + 0x14, 0xfc, 0x73, 0x86, 0x8d, 0x1c, 0x32, 0x3b, 0x5e, 0x84, 0x6f, 0x6a, 0x1b, 0x7e, 0x4e, 0xfc, 0x80, 0xbf, 0xce, + 0x0c, 0xef, 0x67, 0x71, 0xf6, 0xb6, 0xc0, 0x1f, 0xa6, 0x78, 0xe1, 0xcf, 0x95, 0x30, 0xe3, 0x2b, 0xfe, 0x95, 0xf8, + 0x6f, 0x04, 0x6f, 0x98, 0x70, 0x99, 0xad, 0x35, 0x5a, 0x64, 0xf3, 0x9b, 0x7c, 0x7a, 0x77, 0xf7, 0x70, 0x76, 0xb3, + 0x5a, 0x56, 0xb4, 0x61, 0x25, 0x7b, 0x8e, 0xea, 0x3a, 0xae, 0xca, 0xfe, 0x99, 0xc2, 0x6a, 0x69, 0xdf, 0x51, 0x24, + 0xf7, 0x26, 0xe9, 0xb3, 0xb7, 0x9b, 0x53, 0x93, 0x87, 0xa2, 0x09, 0x1d, 0xe9, 0xcb, 0xa3, 0xb5, 0x04, 0x9e, 0x96, + 0x5d, 0xa9, 0x8b, 0x60, 0xf2, 0xc3, 0xcc, 0xcb, 0x5e, 0xa4, 0x34, 0x55, 0x87, 0xdd, 0xb5, 0x8a, 0x24, 0x54, 0x69, + 0x47, 0xee, 0x94, 0x62, 0xd2, 0xaa, 0x03, 0x67, 0xa0, 0x20, 0xfb, 0x4a, 0x24, 0x4e, 0xf8, 0x21, 0x7a, 0xf0, 0x81, + 0xf1, 0xa4, 0x88, 0xe6, 0xc1, 0x14, 0xe1, 0xff, 0x9f, 0xae, 0x67, 0xd5, 0x33, 0x1b, 0xa0, 0xd6, 0xff, 0x05, 0x62, + 0x0e, 0x4d, 0x55, 0x42, 0xf2, 0xc0, 0x84, 0x3b, 0x7f, 0x7a, 0xd6, 0x0c, 0x16, 0x96, 0x1f, 0xd5, 0x61, 0x90, 0xa7, + 0xd9, 0x39, 0x99, 0xc8, 0x38, 0x4e, 0xce, 0x94, 0x42, 0x3d, 0xe3, 0xea, 0xcb, 0x35, 0x88, 0xde, 0x6b, 0xaa, 0xd5, + 0xa1, 0x93, 0x74, 0x92, 0x77, 0xc6, 0x52, 0x2c, 0xa2, 0x65, 0xbb, 0x6a, 0xe3, 0x62, 0x3b, 0x82, 0x33, 0x28, 0x38, + 0xcb, 0x1c, 0x7a, 0x0f, 0x16, 0xda, 0xee, 0xdc, 0xd8, 0x61, 0xba, 0x37, 0x37, 0x0e, 0x47, 0x04, 0x8d, 0xdd, 0x36, + 0xdd, 0xb6, 0x22, 0x2a, 0xb1, 0xd9, 0xa2, 0x8b, 0x78, 0x63, 0x40, 0x40, 0x2f, 0x3e, 0x4e, 0xf5, 0xa9, 0xcf, 0xdb, + 0x4e, 0xbe, 0xd2, 0x09, 0xcb, 0x5a, 0xce, 0xbd, 0xc3, 0x78, 0xd5, 0x8d, 0x82, 0xd0, 0x2c, 0x84, 0x54, 0xf6, 0x42, + 0xe7, 0x09, 0xd8, 0xc4, 0x88, 0x3f, 0x62, 0x2b, 0xa1, 0x4c, 0x0e, 0xac, 0x0a, 0x4a, 0xc7, 0x87, 0x9a, 0x1d, 0x08, + 0x42, 0x57, 0xfb, 0xc8, 0x06, 0x72, 0x2c, 0xb4, 0x13, 0x19, 0x88, 0x26, 0x0e, 0x81, 0x6b, 0xac, 0x11, 0xd6, 0x47, + 0x32, 0x5e, 0x0e, 0x6d, 0xbd, 0x59, 0x03, 0x9b, 0xa8, 0xcd, 0x41, 0xef, 0xfe, 0x53, 0xde, 0xa1, 0x8b, 0x22, 0x6b, + 0x3d, 0x67, 0x4c, 0xcf, 0x22, 0xe6, 0x41, 0xb1, 0x2c, 0x81, 0x46, 0x11, 0x8a, 0xd0, 0xbf, 0x27, 0xf6, 0x50, 0x4f, + 0x2a, 0xa3, 0x3c, 0x61, 0x3e, 0xac, 0x50, 0x4d, 0xab, 0xa5, 0xa4, 0x53, 0x16, 0x1e, 0xc3, 0xdd, 0xc1, 0x8f, 0xaf, + 0xfd, 0xd1, 0xb8, 0xfe, 0x6a, 0xf4, 0xb1, 0xed, 0xf9, 0x9a, 0x96, 0xa9, 0x1f, 0x67, 0x4d, 0x7e, 0x1d, 0x9c, 0x37, + 0x16, 0x9b, 0xed, 0x95, 0x1e, 0xb8, 0x64, 0x22, 0x50, 0xaa, 0x5f, 0x66, 0xfb, 0x01, 0x9d, 0x2d, 0x14, 0x9f, 0x1a, + 0x15, 0xe5, 0xde, 0x5a, 0x8d, 0x65, 0x80, 0x70, 0x0c, 0x69, 0xa4, 0x5d, 0x62, 0x0a, 0x22, 0xad, 0xcf, 0xd2, 0x53, + 0xc0, 0x6b, 0x6b, 0x34, 0x8f, 0xe0, 0x90, 0x21, 0xd3, 0x24, 0xb1, 0x22, 0xfd, 0x0c, 0x10, 0xb7, 0x11, 0xd2, 0x8b, + 0xf4, 0x0f, 0x28, 0x01, 0x78, 0x15, 0xd1, 0xde, 0xa8, 0x8c, 0x45, 0xb2, 0x2a, 0xab, 0x95, 0xc2, 0x72, 0x7c, 0xc0, + 0xbf, 0xf4, 0x0f, 0x0b, 0x16, 0xa8, 0x1a, 0xe9, 0xd8, 0xc2, 0x4d, 0x04, 0x6a, 0x85, 0xed, 0x46, 0x88, 0xe2, 0xfb, + 0x75, 0x7d, 0xa4, 0x6c, 0x2e, 0x16, 0xc7, 0x18, 0x5b, 0xed, 0xcd, 0xd7, 0x5c, 0x6e, 0x3b, 0x43, 0xb7, 0x6d, 0xee, + 0xc0, 0xde, 0xa4, 0x7b, 0x1a, 0xbf, 0x7d, 0xab, 0xe1, 0x29, 0x7e, 0xf3, 0x6a, 0xa4, 0xf4, 0xf2, 0x78, 0x25, 0x74, + 0xef, 0xc9, 0x82, 0xe6, 0xc6, 0x65, 0x5c, 0xfa, 0xdd, 0x7b, 0x8a, 0xf0, 0x57, 0xfd, 0x57, 0x6a, 0x3c, 0xa9, 0xca, + 0xca, 0xdf, 0xac, 0x14, 0xf6, 0x07, 0x86, 0xaa, 0xca, 0x90, 0x21, 0x90, 0xa7, 0x4a, 0x0f, 0xb6, 0x27, 0x51, 0x6e, + 0xbf, 0x29, 0x69, 0x6c, 0x01, 0x17, 0x8a, 0x4e, 0x8d, 0xac, 0x72, 0xc2, 0xb3, 0x9e, 0xad, 0xf7, 0x90, 0x44, 0x5c, + 0xb9, 0xce, 0xc4, 0x11, 0x77, 0x3f, 0xe7, 0xf3, 0x8f, 0x2a, 0x02, 0x3a, 0x64, 0xf1, 0x51, 0x77, 0x19, 0x05, 0x41, + 0xc3, 0x0b, 0x69, 0x98, 0x20, 0x33, 0xa1, 0xac, 0xa9, 0x76, 0x5f, 0xa6, 0x69, 0xbd, 0x3e, 0x7f, 0x2e, 0x8e, 0xbd, + 0x1d, 0x90, 0xcc, 0xc6, 0x9c, 0x69, 0x56, 0x12, 0x37, 0xf2, 0xe0, 0x54, 0xd1, 0xe6, 0x2c, 0xc8, 0xd6, 0xea, 0xad, + 0x9e, 0x93, 0x39, 0xe0, 0x24, 0xd2, 0x32, 0xcc, 0x8f, 0x3c, 0xe7, 0xcf, 0x15, 0x27, 0xd3, 0xe8, 0xbe, 0x57, 0x63, + 0xdb, 0x66, 0x98, 0xf4, 0x24, 0x03, 0x40, 0x9d, 0x08, 0xe0, 0x9b, 0x12, 0xd4, 0x01, 0x8a, 0xaf, 0x2d, 0x8b, 0xc9, + 0x4c, 0x0c, 0x08, 0x26, 0x93, 0xfd, 0xda, 0x93, 0x03, 0xd3, 0x99, 0x08, 0x6c, 0x18, 0xf2, 0xcf, 0x40, 0x54, 0xe3, + 0xdb, 0x14, 0x24, 0xa1, 0x68, 0x4d, 0xa6, 0xb8, 0xf9, 0x04, 0x05, 0x9e, 0x7d, 0x11, 0xb2, 0xa5, 0x7e, 0x53, 0xc6, + 0x29, 0x84, 0xcd, 0x69, 0x3d, 0x98, 0xb2, 0x32, 0x9e, 0x49, 0x78, 0x8d, 0xe3, 0x9f, 0x2d, 0x82, 0xc7, 0xa8, 0xbc, + 0x8c, 0xc1, 0x1a, 0x8d, 0x5f, 0xc0, 0xe0, 0xb2, 0xb7, 0xa2, 0xc3, 0x28, 0xae, 0x3f, 0x6c, 0x8d, 0x71, 0x92, 0xd5, + 0x7e, 0x71, 0xf6, 0x37, 0xdb, 0x6f, 0x3d, 0x72, 0xf3, 0xd5, 0xcd, 0xce, 0x0e, 0x4d, 0x6b, 0x05, 0x83, 0x1e, 0xc6, + 0x60, 0x03, 0x70, 0xc5, 0x38, 0xb3, 0x91, 0x62, 0x47, 0xcd, 0x33, 0xe0, 0xa8, 0x57, 0x37, 0x3f, 0xd2, 0xee, 0xf8, + 0x79, 0x86, 0xf8, 0x44, 0x22, 0x4c, 0xde, 0x89, 0x60, 0x83, 0x5a, 0xba, 0xa3, 0x3f, 0xf2, 0x54, 0x86, 0x16, 0x15, + 0x6f, 0x26, 0x79, 0x4e, 0x31, 0xd1, 0xb2, 0xdc, 0x1e, 0x3d, 0xf1, 0x16, 0xd3, 0x52, 0x87, 0xda, 0x65, 0xed, 0x34, + 0x8d, 0x1d, 0x53, 0xa8, 0x27, 0xec, 0xf8, 0x3b, 0x8c, 0xf2, 0xaf, 0x85, 0x97, 0x7f, 0x3e, 0xfe, 0xd7, 0x1c, 0xff, + 0x22, 0xdc, 0xd5, 0xc9, 0xea, 0xf0, 0xe7, 0xe8, 0xff, 0x1e, 0x1f, 0x50, 0x9d, 0xbc, 0xd9, 0xda, 0x7c, 0xa3, 0x5a, + 0x6f, 0x92, 0x47, 0x6b, 0x3d, 0x4e, 0xd0, 0x57, 0x1f, 0x77, 0x4e, 0x27, 0x18, 0xe7, 0x9c, 0x3a, 0x3f, 0x8a, 0xfd, + 0xd1, 0x12, 0x17, 0x7e, 0x31, 0xfe, 0xb0, 0xb5, 0x99, 0xdc, 0xa3, 0xfd, 0x37, 0xa4, 0x72, 0x9f, 0x9f, 0x50, 0x6d, + 0x2b, 0x1a, 0x24, 0x7f, 0x7f, 0xe8, 0xdc, 0x11, 0x81, 0x0d, 0xe7, 0xfe, 0xeb, 0x35, 0x8d, 0x3f, 0x19, 0x5c, 0x44, + 0x8a, 0xd6, 0x0a, 0x8b, 0xcf, 0xf4, 0x99, 0x56, 0x56, 0xdf, 0xb8, 0x55, 0x65, 0x1b, 0x3a, 0xa1, 0x36, 0xdd, 0x00, + 0xc4, 0xa4, 0x82, 0x06, 0x65, 0xed, 0x7e, 0xf9, 0xd3, 0x44, 0x1b, 0x12, 0x8a, 0x7e, 0xf6, 0x83, 0x91, 0x76, 0x37, + 0xa7, 0x0a, 0x20, 0xb1, 0x61, 0x7a, 0xfc, 0x52, 0x30, 0x19, 0xd0, 0xf0, 0x50, 0x47, 0x17, 0xad, 0x55, 0x9f, 0x45, + 0x7a, 0x63, 0xbd, 0x26, 0xc5, 0xf5, 0x15, 0x90, 0x20, 0xa4, 0x69, 0xb8, 0xfc, 0x3f, 0xfe, 0x44, 0x24, 0x4d, 0xaf, + 0xd1, 0x50, 0x39, 0x0d, 0xfd, 0xf5, 0x3b, 0xb4, 0xec, 0x85, 0x96, 0x33, 0x7b, 0x19, 0x15, 0x03, 0xcf, 0x82, 0xa0, + 0xba, 0x22, 0xc7, 0x26, 0x57, 0xe3, 0x39, 0x29, 0xc7, 0xfc, 0x1f, 0x67, 0x79, 0xbd, 0x86, 0x39, 0xc7, 0x88, 0xef, + 0xec, 0x02, 0x61, 0x49, 0x56, 0xc3, 0xc6, 0xec, 0x41, 0x7f, 0xf8, 0xe8, 0x0d, 0x34, 0xe8, 0x87, 0x8f, 0xbf, 0x20, + 0x01, 0x5f, 0xf8, 0x61, 0x74, 0x35, 0x2a, 0x1e, 0x9b, 0xd3, 0xda, 0xf5, 0x71, 0x63, 0x60, 0xe8, 0x23, 0x11, 0x1c, + 0x72, 0x0a, 0x10, 0x5f, 0x24, 0x9d, 0x1d, 0x4d, 0x1c, 0x73, 0x39, 0x24, 0x07, 0xed, 0x38, 0x97, 0x2a, 0x53, 0x0e, + 0x35, 0xa7, 0x8e, 0x72, 0x40, 0x8e, 0xf3, 0xe8, 0x40, 0xb3, 0x6e, 0x2f, 0x27, 0xe3, 0xcb, 0x9c, 0xb4, 0xcb, 0x66, + 0xb3, 0xd7, 0xd4, 0x30, 0x93, 0x88, 0x91, 0x0a, 0xde, 0xe4, 0xac, 0x8a, 0xa0, 0x5f, 0x74, 0x8a, 0xa9, 0x8d, 0x78, + 0xb8, 0xb7, 0x9e, 0x9d, 0x3a, 0x0f, 0x34, 0xb8, 0x32, 0x20, 0xaf, 0x78, 0x0d, 0xb8, 0x51, 0xc8, 0x84, 0x59, 0xc2, + 0x02, 0x2e, 0xcc, 0xdf, 0x7d, 0xe2, 0x3e, 0xd8, 0x2f, 0xa2, 0xe0, 0x3c, 0x7b, 0x6f, 0x06, 0xcb, 0x12, 0x76, 0x41, + 0xf5, 0xc6, 0x7d, 0xee, 0x3d, 0xfe, 0x71, 0xc3, 0x14, 0x14, 0x58, 0x06, 0xf9, 0x74, 0xe7, 0x0b, 0x22, 0xf0, 0x03, + 0xfb, 0xc3, 0x3c, 0xe6, 0xec, 0x1f, 0x9a, 0x53, 0x73, 0x4b, 0x28, 0x1b, 0x48, 0x75, 0x69, 0xcb, 0x82, 0xb3, 0xd3, + 0x61, 0x8b, 0xf3, 0x9e, 0xa3, 0x46, 0xa9, 0xee, 0xa9, 0x83, 0x32, 0x21, 0x5a, 0xe6, 0x14, 0xd8, 0x22, 0x80, 0x96, + 0xad, 0x08, 0xaf, 0x03, 0xe5, 0xa5, 0x66, 0x46, 0x43, 0x7f, 0x88, 0xb3, 0x49, 0xf8, 0x06, 0x74, 0x72, 0xd1, 0xe1, + 0xa2, 0xcb, 0xa5, 0x53, 0x7a, 0x7c, 0x3c, 0x40, 0x34, 0x76, 0xce, 0xc2, 0x60, 0x5e, 0x4f, 0x52, 0xbe, 0xf4, 0xec, + 0xd7, 0xe3, 0xa2, 0xbd, 0x36, 0xfa, 0x70, 0x32, 0x4d, 0x98, 0xd8, 0x80, 0x9a, 0x56, 0xc7, 0x21, 0x1e, 0x3c, 0xa4, + 0x80, 0x1e, 0x94, 0x66, 0x79, 0xdf, 0x04, 0x52, 0x48, 0x45, 0xc8, 0x44, 0x5e, 0x16, 0x7a, 0xb6, 0x0e, 0x06, 0x82, + 0x9a, 0xed, 0x8c, 0x4f, 0x75, 0xd2, 0x68, 0xa9, 0x78, 0x81, 0x98, 0x12, 0x46, 0x48, 0xd3, 0xfa, 0x27, 0xa0, 0x1b, + 0xbe, 0x06, 0x28, 0x7f, 0x52, 0x2e, 0x3b, 0x9e, 0x59, 0xc6, 0x0e, 0xe1, 0x80, 0x9f, 0xaa, 0x02, 0x77, 0x17, 0x15, + 0xfa, 0xc7, 0xf3, 0xd1, 0x90, 0x1c, 0x22, 0x34, 0x0c, 0x95, 0x70, 0x01, 0x91, 0x51, 0xea, 0x63, 0x87, 0xd0, 0xeb, + 0x7e, 0x40, 0xbe, 0xf8, 0x23, 0x9a, 0xf0, 0x88, 0x3b, 0xe5, 0xad, 0xae, 0x5a, 0x68, 0xe2, 0x23, 0xee, 0x82, 0x06, + 0xdf, 0x7c, 0x70, 0x9a, 0xee, 0x1e, 0x55, 0x96, 0x56, 0xe8, 0x13, 0x0d, 0x64, 0x4a, 0xf5, 0xf4, 0x7a, 0xa6, 0x9a, + 0xde, 0x2c, 0xa1, 0x95, 0x40, 0xd9, 0xc6, 0x74, 0x9e, 0xc6, 0x96, 0xed, 0xb5, 0x8b, 0x14, 0xf9, 0xf3, 0x34, 0x62, + 0x0d, 0x5b, 0x02, 0x76, 0xe3, 0x8e, 0xbe, 0xed, 0x64, 0xc7, 0xd0, 0x10, 0x25, 0xbd, 0xa8, 0x38, 0x1d, 0x63, 0xe4, + 0xe6, 0x75, 0x0f, 0xd8, 0x2e, 0xa3, 0xb7, 0xd5, 0xc0, 0x70, 0xee, 0x9b, 0xd4, 0x9c, 0x14, 0x9c, 0xf3, 0xd6, 0xfd, + 0x75, 0x82, 0x34, 0x9e, 0xe7, 0xad, 0x8b, 0xf7, 0x22, 0x9e, 0x69, 0xf3, 0xaf, 0x17, 0xe5, 0xf9, 0xaa, 0xc6, 0x65, + 0xeb, 0xaf, 0x49, 0xb0, 0x85, 0xec, 0x67, 0x15, 0x52, 0xfd, 0x47, 0xc5, 0x8e, 0x78, 0x7b, 0x3e, 0xa7, 0x02, 0x67, + 0xae, 0x3a, 0x3e, 0x2a, 0xbe, 0x41, 0x2f, 0x0e, 0x07, 0x38, 0x07, 0x01, 0xf2, 0xc0, 0x49, 0xa8, 0xc9, 0x3c, 0x60, + 0xcc, 0xa9, 0x56, 0xf3, 0x15, 0xeb, 0x31, 0xeb, 0x0d, 0x33, 0x3c, 0x57, 0xff, 0x03, 0xd4, 0x80, 0x0b, 0xe8, 0x0f, + 0x3b, 0xbc, 0xaf, 0x31, 0x84, 0x46, 0xdc, 0x8d, 0x7c, 0x62, 0xf0, 0xbb, 0xfc, 0x37, 0x83, 0x99, 0x6c, 0x24, 0xc8, + 0xcc, 0x3a, 0xd5, 0x3e, 0x31, 0x59, 0x19, 0x82, 0x7a, 0x2d, 0xed, 0xa6, 0xf4, 0x10, 0x19, 0x8a, 0x70, 0x02, 0x0c, + 0x14, 0xb4, 0x31, 0x81, 0x57, 0x57, 0x68, 0xa6, 0x1b, 0xcc, 0xd5, 0x47, 0x4d, 0x9d, 0x43, 0xdc, 0x2b, 0x2d, 0x95, + 0xc1, 0xa0, 0x36, 0x08, 0xbc, 0x6b, 0xbf, 0xfc, 0xc3, 0x32, 0x9e, 0x27, 0x87, 0xaa, 0x9f, 0x0e, 0x1b, 0xc3, 0x35, + 0x75, 0xac, 0x7a, 0xfd, 0xcf, 0xd4, 0x24, 0xc6, 0xa7, 0x46, 0x82, 0xc1, 0xba, 0x8a, 0x13, 0x2d, 0x88, 0xd3, 0x46, + 0x69, 0x17, 0x8a, 0x3a, 0xd4, 0x02, 0x2e, 0x0d, 0xa9, 0x71, 0xc0, 0x2a, 0x37, 0x2f, 0xcf, 0x0d, 0x74, 0xe2, 0x39, + 0x7f, 0x9d, 0x99, 0xf0, 0xa1, 0x9e, 0xe6, 0x50, 0xd7, 0x26, 0xcf, 0xe5, 0xfd, 0xf8, 0xc5, 0xca, 0x43, 0x22, 0x27, + 0xb1, 0xd0, 0x26, 0x9b, 0xeb, 0x7c, 0xbe, 0xc0, 0x62, 0x23, 0x88, 0xfa, 0x7c, 0x85, 0x0a, 0xa2, 0xc3, 0x61, 0x53, + 0x4c, 0x75, 0xc4, 0x33, 0xc6, 0x44, 0xa5, 0xed, 0x62, 0x33, 0x1c, 0xc0, 0x00, 0x9c, 0x8b, 0xb2, 0x96, 0x8f, 0xdf, + 0xa6, 0xd1, 0x9f, 0xe4, 0xec, 0x4c, 0x4a, 0x19, 0xbf, 0x21, 0xfb, 0x33, 0xbe, 0x3f, 0x62, 0x74, 0xef, 0xdf, 0xc9, + 0x3e, 0xed, 0x5f, 0x33, 0xb6, 0x31, 0xb6, 0x24, 0x6f, 0xcc, 0xec, 0xab, 0xcd, 0xcb, 0xb8, 0x24, 0x0a, 0xc8, 0xfe, + 0x46, 0xe3, 0x61, 0x9a, 0x87, 0x38, 0x3c, 0xac, 0x1a, 0x45, 0x7e, 0x47, 0x41, 0x96, 0x18, 0xe0, 0x6d, 0xa6, 0x45, + 0xba, 0x99, 0xc0, 0xdb, 0xa0, 0x94, 0x74, 0x68, 0x77, 0xa6, 0x2c, 0x31, 0xa8, 0xc2, 0xc0, 0x20, 0x22, 0x77, 0xba, + 0x04, 0xa2, 0xdd, 0x4a, 0x66, 0x4f, 0xf0, 0x3e, 0xa6, 0xa1, 0x13, 0xb7, 0x6c, 0x79, 0x8b, 0x6d, 0x4d, 0xcd, 0xec, + 0xe8, 0x85, 0x9a, 0xa1, 0x30, 0x32, 0x3a, 0x7d, 0xa1, 0xd6, 0x8f, 0x26, 0x64, 0xa9, 0x10, 0xbf, 0x2a, 0xf1, 0x55, + 0xeb, 0x6b, 0xa9, 0x10, 0x57, 0x67, 0x17, 0x39, 0x86, 0x9f, 0x65, 0x88, 0xc7, 0xd8, 0x8e, 0x7b, 0xeb, 0x6b, 0x0f, + 0x27, 0x80, 0x8a, 0xa4, 0x65, 0x48, 0x6e, 0xe5, 0xd8, 0x90, 0x86, 0x96, 0xfe, 0xf0, 0x74, 0x86, 0x99, 0x22, 0x40, + 0x67, 0xcd, 0x13, 0x4f, 0x5d, 0x4c, 0xd5, 0x7f, 0xa7, 0xa0, 0x62, 0xfb, 0x83, 0xca, 0x00, 0x38, 0x49, 0x1d, 0x44, + 0x23, 0xb3, 0xcf, 0x3a, 0x8d, 0x3e, 0xe4, 0xe2, 0x29, 0x38, 0x02, 0x96, 0x53, 0xe4, 0x9a, 0x33, 0x5a, 0xd7, 0x32, + 0xa4, 0x49, 0xb6, 0x6f, 0x97, 0xe3, 0xde, 0x05, 0x77, 0x68, 0xd2, 0x48, 0x68, 0xa9, 0xba, 0x42, 0xae, 0x94, 0xa5, + 0xa3, 0xee, 0xb4, 0x1b, 0x53, 0x6e, 0xac, 0x70, 0x2b, 0x73, 0xd1, 0xb1, 0x8c, 0x55, 0x39, 0xc2, 0x22, 0x5d, 0x1c, + 0x05, 0x96, 0x05, 0xf8, 0x1e, 0x18, 0x44, 0xa5, 0x2a, 0xcb, 0x44, 0x11, 0x92, 0xea, 0x84, 0x05, 0xc6, 0xb2, 0xf9, + 0x7e, 0x13, 0x09, 0x1e, 0x7c, 0xfd, 0x37, 0x8c, 0x24, 0xb1, 0x11, 0x10, 0x40, 0x83, 0x86, 0x16, 0x50, 0xcd, 0xfc, + 0x5e, 0xd9, 0x2d, 0x84, 0xce, 0x93, 0xf8, 0xa0, 0x92, 0x64, 0xd0, 0x9f, 0xff, 0xc7, 0x04, 0x31, 0x68, 0x1d, 0x52, + 0xce, 0x82, 0x03, 0x6e, 0x98, 0x9b, 0x4e, 0xa2, 0xba, 0x6c, 0x51, 0x2c, 0xb6, 0xd8, 0xf3, 0xb9, 0x0d, 0x6a, 0x05, + 0x2b, 0x2f, 0x21, 0xa5, 0x1d, 0xcd, 0x57, 0x5e, 0x87, 0x2a, 0x6f, 0x79, 0x8d, 0x3b, 0x4c, 0xf4, 0x0b, 0x27, 0xba, + 0x26, 0xab, 0xd1, 0xad, 0x23, 0x00, 0x99, 0x8d, 0x03, 0xd5, 0x1b, 0x84, 0x4b, 0x48, 0xd9, 0xe8, 0x2d, 0x73, 0x6e, + 0xf0, 0xdb, 0xf9, 0x9c, 0x90, 0xc4, 0xc8, 0x85, 0x26, 0x80, 0x93, 0x38, 0x25, 0xb4, 0xa9, 0x8b, 0x9c, 0xa9, 0xd3, + 0x13, 0xde, 0x3a, 0x68, 0x6e, 0x6d, 0x36, 0x42, 0xb1, 0x97, 0xf5, 0x49, 0x11, 0x25, 0x55, 0x97, 0x83, 0x72, 0x53, + 0x82, 0x5d, 0xfb, 0x31, 0xde, 0xca, 0x30, 0x64, 0x37, 0x2b, 0x60, 0x24, 0x66, 0x42, 0x72, 0x26, 0x48, 0x92, 0x65, + 0xd2, 0x65, 0x2d, 0xcd, 0xea, 0xda, 0x7f, 0xb4, 0x10, 0x1e, 0x91, 0x8c, 0xf3, 0xb3, 0x3c, 0x94, 0x1d, 0x57, 0xd6, + 0x29, 0xb2, 0x3c, 0x3d, 0x11, 0xae, 0xbb, 0x55, 0x35, 0x35, 0xbc, 0x07, 0x44, 0x64, 0x72, 0xcb, 0x56, 0xf5, 0xb1, + 0x33, 0xc1, 0xcf, 0x5c, 0x1e, 0x88, 0x8b, 0x07, 0x15, 0x49, 0xe8, 0xe7, 0xdb, 0x3c, 0x4f, 0x14, 0x1a, 0xbd, 0x43, + 0xce, 0xad, 0xe4, 0xe2, 0x5c, 0x0b, 0x94, 0x58, 0xf0, 0xe5, 0xf6, 0xa4, 0x3a, 0x47, 0x1e, 0xf8, 0x4e, 0x9c, 0x09, + 0x5d, 0x64, 0x5e, 0xe9, 0x1a, 0x79, 0x2b, 0xbd, 0x57, 0xd5, 0xc8, 0x1f, 0xfc, 0xea, 0x7f, 0x59, 0xe9, 0x35, 0x7a, + 0x11, 0x89, 0x33, 0x5f, 0xe2, 0x12, 0xed, 0x0c, 0xec, 0x30, 0x4e, 0xea, 0x9a, 0xbb, 0x2f, 0x80, 0x56, 0x17, 0xde, + 0x74, 0xb4, 0x16, 0x09, 0x3c, 0xd7, 0xdd, 0x25, 0xae, 0x84, 0x1d, 0x6e, 0xa0, 0xd8, 0xc3, 0x0c, 0x06, 0x42, 0xa3, + 0xc8, 0x86, 0x03, 0xc0, 0xcf, 0x21, 0xfe, 0x1a, 0xf3, 0xa3, 0x6e, 0xd9, 0x46, 0x0b, 0x9c, 0x53, 0x64, 0x06, 0xd9, + 0x8b, 0xc8, 0x80, 0x1c, 0xea, 0x84, 0x2c, 0xc8, 0x35, 0x6a, 0xec, 0x80, 0xb5, 0xc2, 0x0a, 0x65, 0x35, 0xc0, 0xb1, + 0xc1, 0x66, 0xed, 0xa5, 0xb9, 0xa9, 0xc0, 0xa7, 0x4b, 0x44, 0xae, 0xe9, 0x91, 0x50, 0xbe, 0x82, 0x14, 0x54, 0xa4, + 0x9f, 0x57, 0xff, 0x0a, 0x4c, 0x7a, 0x3b, 0x27, 0x68, 0x17, 0x91, 0x71, 0xbf, 0xd0, 0x11, 0x28, 0x2d, 0x62, 0xfb, + 0x87, 0xc9, 0xf1, 0x75, 0x30, 0xa6, 0x6b, 0xe4, 0x73, 0x6b, 0xcd, 0x3f, 0x41, 0xf5, 0x3c, 0x19, 0x0f, 0x14, 0xa9, + 0x30, 0x00, 0xfc, 0xde, 0x08, 0x1a, 0xef, 0xfd, 0xdf, 0x33, 0x1c, 0x67, 0x74, 0x4b, 0x28, 0x3c, 0x02, 0xf2, 0x4d, + 0xfe, 0x17, 0xc3, 0x78, 0x54, 0x00, 0x3b, 0x2b, 0xf2, 0xde, 0xd0, 0xde, 0xad, 0x43, 0xc0, 0xd0, 0x37, 0x60, 0xcc, + 0xfc, 0x0d, 0x47, 0xd9, 0x40, 0x6e, 0xdb, 0x19, 0xae, 0xab, 0x92, 0x66, 0x26, 0x19, 0x1e, 0x49, 0x0c, 0x52, 0x69, + 0xe4, 0x47, 0x5d, 0x59, 0x9c, 0x66, 0xee, 0x2a, 0x38, 0xf2, 0xb3, 0xc7, 0x33, 0x6c, 0xde, 0xd8, 0x88, 0x3b, 0x5e, + 0x80, 0x34, 0x37, 0x34, 0x00, 0xe0, 0x85, 0x4b, 0x45, 0x87, 0x3b, 0xe6, 0x2a, 0x5b, 0x81, 0xfa, 0x69, 0xa2, 0x39, + 0x38, 0xce, 0x46, 0x15, 0xf2, 0x09, 0xb7, 0x1b, 0xf1, 0x79, 0x0e, 0x10, 0x8f, 0x63, 0xa5, 0x32, 0x18, 0x12, 0x05, + 0x3f, 0x11, 0x61, 0x47, 0xd3, 0x89, 0xb3, 0xe4, 0xae, 0x52, 0x7b, 0x0c, 0x50, 0x0d, 0x09, 0x58, 0x65, 0x6c, 0xc3, + 0xfa, 0x45, 0x90, 0xb8, 0xac, 0xef, 0x18, 0x2d, 0xeb, 0xb0, 0x50, 0x0b, 0x1f, 0x39, 0xa7, 0x1f, 0xe2, 0xa0, 0x10, + 0x67, 0x23, 0x9c, 0x67, 0x20, 0x79, 0xda, 0x40, 0x66, 0xe4, 0xc5, 0xf8, 0xbd, 0x74, 0x67, 0xbb, 0x61, 0x65, 0x48, + 0xba, 0xc5, 0x5b, 0x6d, 0x3d, 0x93, 0xfc, 0x88, 0x1c, 0x38, 0x29, 0x02, 0xc9, 0x24, 0x52, 0x41, 0x95, 0xd2, 0x60, + 0xe5, 0xaf, 0x00, 0x28, 0x98, 0x6b, 0x5e, 0xd3, 0x54, 0x4f, 0xcb, 0x84, 0xdd, 0xe6, 0x68, 0xb0, 0x4e, 0x1c, 0xaa, + 0x1f, 0x0c, 0x3a, 0x85, 0x38, 0x43, 0xbb, 0xc0, 0x03, 0x8d, 0x4c, 0xec, 0xf1, 0xe7, 0xf9, 0x49, 0xc1, 0x3b, 0xab, + 0x34, 0x4b, 0xc1, 0x33, 0x95, 0x32, 0x78, 0x0c, 0x56, 0xe7, 0xdf, 0xee, 0x6b, 0xa2, 0xd2, 0x80, 0x00, 0xd0, 0x51, + 0xcc, 0xe1, 0xbc, 0x9b, 0xa2, 0x49, 0x77, 0x6a, 0xb2, 0xff, 0xd6, 0xab, 0xdb, 0x9b, 0x71, 0x94, 0x17, 0xdd, 0x61, + 0x35, 0xf1, 0x71, 0xd2, 0x84, 0xed, 0x8c, 0xad, 0xd4, 0xf5, 0x0b, 0xb0, 0x00, 0x76, 0x99, 0xf1, 0x6c, 0x0c, 0xaf, + 0xeb, 0xc8, 0x4e, 0x17, 0xe4, 0xea, 0xe1, 0xa3, 0x9a, 0xc3, 0x47, 0xdc, 0x72, 0x72, 0xca, 0x11, 0x9c, 0x59, 0x04, + 0xcd, 0x0c, 0xa0, 0x02, 0xf2, 0x12, 0x9a, 0x92, 0x2e, 0x08, 0x7e, 0x6d, 0x90, 0x34, 0x1f, 0x30, 0x06, 0xe0, 0xa3, + 0xbe, 0xd3, 0x9c, 0xbf, 0x19, 0x9c, 0xee, 0x44, 0xbc, 0xb7, 0xa8, 0xe2, 0x97, 0x56, 0xca, 0x90, 0x29, 0x4f, 0x2e, + 0xd9, 0x2a, 0xac, 0x42, 0xd5, 0xda, 0xae, 0x43, 0x09, 0xf1, 0x19, 0xed, 0x0f, 0x2e, 0x28, 0xde, 0xc1, 0x40, 0x7d, + 0xe1, 0x47, 0xde, 0x69, 0xbd, 0x8a, 0x66, 0x2d, 0x6c, 0xbd, 0xf8, 0xbe, 0x6a, 0x5a, 0x03, 0x47, 0x76, 0xb6, 0x57, + 0xfa, 0x67, 0x75, 0x18, 0xad, 0x43, 0x54, 0xfe, 0xac, 0xfe, 0x4a, 0x37, 0x75, 0xcb, 0x9a, 0xc6, 0xaf, 0x23, 0xf1, + 0x9b, 0x24, 0x4c, 0xea, 0xb5, 0x5b, 0xd3, 0xe3, 0xf4, 0x38, 0xd1, 0x38, 0x75, 0x72, 0xf7, 0xfc, 0xd7, 0x68, 0x75, + 0xd4, 0xa0, 0xed, 0x64, 0xda, 0xa6, 0xdf, 0x35, 0x96, 0x28, 0x4d, 0xaa, 0xa7, 0xb1, 0x73, 0x6d, 0x17, 0x2f, 0x16, + 0x1d, 0x12, 0xdd, 0x9f, 0x75, 0x5f, 0x91, 0xb9, 0x16, 0x26, 0x7e, 0x66, 0x52, 0x43, 0x5c, 0x6b, 0x35, 0xf1, 0xce, + 0x5e, 0x6c, 0x4b, 0x8e, 0xdd, 0x74, 0x95, 0x64, 0x30, 0xa8, 0x8e, 0x4c, 0x0d, 0x89, 0x64, 0x88, 0xa8, 0x5f, 0x3e, + 0x08, 0x98, 0x75, 0x8d, 0x77, 0xcf, 0xd7, 0xa4, 0x71, 0xfa, 0xd6, 0x63, 0xae, 0x3f, 0x2f, 0xc3, 0xed, 0x7b, 0x04, + 0xce, 0xb6, 0x29, 0xfd, 0xe8, 0x8d, 0x52, 0xa7, 0x8d, 0x92, 0x58, 0x4e, 0xd3, 0x13, 0x28, 0xff, 0x80, 0x48, 0x22, + 0xfc, 0xa4, 0x29, 0x3b, 0x49, 0x25, 0xd3, 0x6f, 0xd4, 0xdd, 0x7e, 0xaf, 0x84, 0x40, 0x7a, 0xfb, 0x47, 0x1d, 0x55, + 0xd3, 0xcb, 0x44, 0x12, 0xab, 0x0e, 0xc4, 0x6b, 0x0a, 0x43, 0xee, 0xf3, 0x2f, 0xb6, 0x77, 0xca, 0x28, 0x14, 0x51, + 0xd6, 0x92, 0xde, 0x01, 0x4c, 0x43, 0x0d, 0x23, 0xa3, 0x68, 0xd8, 0x26, 0xe5, 0xef, 0xf1, 0xc7, 0xd9, 0x30, 0xa0, + 0x4d, 0x47, 0xa5, 0x0d, 0x5d, 0xb0, 0xaa, 0xde, 0xc2, 0xef, 0xd3, 0x53, 0x5f, 0xb0, 0xe6, 0x15, 0xf6, 0x4e, 0xdf, + 0xde, 0xe6, 0xcf, 0xe7, 0xfc, 0xfc, 0xf9, 0xac, 0x37, 0xbc, 0x61, 0x66, 0x65, 0xdc, 0xab, 0xe0, 0xe5, 0x82, 0xae, + 0x71, 0x28, 0xc1, 0x53, 0x5b, 0xfe, 0xa3, 0x13, 0x30, 0xe5, 0x01, 0xce, 0x68, 0x03, 0x7d, 0x2a, 0x03, 0xa7, 0x9b, + 0x1b, 0x66, 0x34, 0x5d, 0x99, 0x19, 0x69, 0x66, 0x3c, 0x29, 0xa2, 0xcf, 0x49, 0xcc, 0xc1, 0x1e, 0xc9, 0x59, 0xfa, + 0x58, 0xcc, 0xf8, 0x51, 0x69, 0x0b, 0xda, 0x0e, 0x85, 0x9f, 0x82, 0x4c, 0x05, 0xe8, 0x45, 0xe7, 0xdb, 0x38, 0x8d, + 0xb3, 0xf4, 0x77, 0x0e, 0xe9, 0x48, 0x4f, 0x4f, 0x44, 0xf6, 0xa0, 0xbb, 0xee, 0xbd, 0x17, 0xf0, 0x4b, 0x42, 0x53, + 0x32, 0x7e, 0x27, 0x06, 0xed, 0x8b, 0xf4, 0x51, 0x8d, 0xc0, 0xa9, 0x00, 0x79, 0x35, 0xc2, 0x38, 0x90, 0x37, 0xb4, + 0xd7, 0xc8, 0x0f, 0x4a, 0x95, 0xee, 0xb9, 0xa7, 0x25, 0xad, 0xc8, 0x42, 0xa6, 0x9f, 0x8c, 0x31, 0x66, 0x55, 0xe4, + 0xd8, 0xd2, 0xbc, 0x6f, 0x90, 0x49, 0xbe, 0x70, 0x91, 0xd1, 0x62, 0x4e, 0x8d, 0x05, 0xba, 0x55, 0xa8, 0xb5, 0x0b, + 0xaf, 0x7f, 0xa1, 0x72, 0xa0, 0xa9, 0x28, 0xfb, 0x7e, 0x88, 0x2d, 0xe2, 0x03, 0xfd, 0x8a, 0x8f, 0x90, 0x71, 0xdb, + 0x73, 0x9c, 0x10, 0x52, 0xf5, 0xae, 0x28, 0xee, 0x6d, 0x93, 0x0a, 0xc9, 0x0d, 0x55, 0x0c, 0x65, 0xd4, 0xc2, 0xf9, + 0x19, 0x9c, 0x2f, 0x9c, 0x9f, 0xe6, 0xdc, 0xa0, 0x2d, 0x99, 0xaa, 0x67, 0x24, 0x96, 0xae, 0xb0, 0xa3, 0x96, 0xdf, + 0xe4, 0x27, 0xec, 0x42, 0x06, 0x68, 0x6a, 0xa5, 0x57, 0x45, 0x82, 0x2e, 0x83, 0x0d, 0xa8, 0x51, 0x1d, 0x88, 0xbc, + 0xc4, 0x37, 0x13, 0x10, 0x80, 0xd1, 0x83, 0x4f, 0xaa, 0x29, 0x9d, 0x36, 0x7c, 0xb7, 0xcb, 0x31, 0x81, 0xa2, 0x6b, + 0x36, 0x98, 0x84, 0xbc, 0x29, 0xb8, 0xa6, 0x9a, 0x3d, 0x15, 0xc2, 0x18, 0xbc, 0x3c, 0x35, 0xb6, 0x58, 0xbd, 0x7f, + 0x2b, 0xd6, 0x57, 0x86, 0x90, 0xd8, 0x72, 0xc8, 0xbe, 0xd0, 0xbc, 0xd2, 0x83, 0x68, 0x9a, 0xe6, 0xe4, 0xd2, 0x43, + 0x5f, 0xc8, 0xeb, 0xd1, 0xd9, 0x27, 0xc8, 0xeb, 0xdb, 0x6c, 0x5b, 0x73, 0x13, 0x36, 0xf1, 0x25, 0x7d, 0xa6, 0xfb, + 0xe7, 0x6a, 0x21, 0x7b, 0x56, 0xea, 0xbc, 0x73, 0x25, 0x76, 0x4d, 0xa7, 0x88, 0x1a, 0x83, 0x4e, 0xc1, 0xdb, 0x0e, + 0x11, 0xb4, 0x05, 0x27, 0x49, 0x86, 0x48, 0x54, 0x06, 0xea, 0xb3, 0xa9, 0x48, 0x82, 0xd9, 0x00, 0x4b, 0x25, 0xaf, + 0xb9, 0xd8, 0x35, 0xbf, 0x64, 0x4d, 0x32, 0xab, 0x80, 0x8b, 0xe4, 0x99, 0x4e, 0x4e, 0xd7, 0x91, 0xd5, 0x1e, 0xa6, + 0xc6, 0x5d, 0x2c, 0x5e, 0x25, 0x5c, 0xce, 0xca, 0x4d, 0xac, 0xc4, 0x9b, 0x40, 0xcd, 0x78, 0x4f, 0x2a, 0x7f, 0x6c, + 0xb2, 0xa3, 0x36, 0x52, 0x02, 0x6d, 0x0f, 0xa9, 0xb6, 0x36, 0x8d, 0x70, 0x1b, 0xd2, 0x6f, 0x57, 0xb7, 0x2d, 0x50, + 0xe9, 0xb7, 0xb4, 0x30, 0xa4, 0xff, 0x1b, 0x15, 0xaa, 0x46, 0x85, 0x11, 0xc2, 0xfd, 0x24, 0x40, 0xb8, 0x2f, 0x9c, + 0xbc, 0x20, 0x16, 0xd5, 0x79, 0x14, 0xf6, 0x5e, 0x67, 0xcd, 0xd5, 0xb8, 0xf8, 0xfb, 0xa0, 0xfe, 0x3e, 0x0a, 0x8d, + 0x63, 0xbd, 0xc6, 0xef, 0x8c, 0x1f, 0x7f, 0x64, 0xdf, 0xd0, 0xc0, 0x08, 0x37, 0x11, 0xb4, 0x12, 0x34, 0xdb, 0x12, + 0xd6, 0xb6, 0x2a, 0xa0, 0x08, 0x61, 0x36, 0x52, 0xd5, 0x82, 0x09, 0x6d, 0xa5, 0x27, 0x58, 0xbc, 0xeb, 0x38, 0xfd, + 0x6f, 0x68, 0xbd, 0x4e, 0x08, 0x29, 0x58, 0x93, 0x23, 0x4f, 0x9e, 0x44, 0xab, 0x7d, 0xe6, 0xdf, 0x18, 0xb7, 0xbe, + 0xfa, 0x8c, 0x57, 0x23, 0x75, 0xa4, 0x98, 0x41, 0xe1, 0xb5, 0x9b, 0xd3, 0x9b, 0xf1, 0x39, 0xc9, 0x7b, 0xd1, 0x3c, + 0xda, 0xa9, 0xa0, 0x54, 0x53, 0xd7, 0xac, 0xce, 0xb5, 0x79, 0x9d, 0xd1, 0xb9, 0xc6, 0xde, 0x58, 0xd6, 0xd3, 0x35, + 0xce, 0xf8, 0x8d, 0xb6, 0x62, 0xa0, 0xd4, 0xf1, 0xb0, 0xd1, 0x73, 0xac, 0x40, 0x06, 0xe8, 0x85, 0xe3, 0x26, 0x82, + 0xf4, 0x97, 0xc0, 0xa1, 0x53, 0x1b, 0x2e, 0xb0, 0xd6, 0x72, 0xc4, 0x90, 0x67, 0x58, 0x62, 0x4a, 0xbf, 0x71, 0x1d, + 0x48, 0xbb, 0xf5, 0x9b, 0x05, 0x8f, 0x82, 0xaf, 0xec, 0xe9, 0x30, 0x8f, 0x68, 0x9c, 0x5b, 0x04, 0x2f, 0x12, 0xe5, + 0x61, 0xbb, 0xf0, 0x9c, 0x5f, 0x89, 0x74, 0x50, 0x90, 0x65, 0x3c, 0x9f, 0x79, 0x01, 0x42, 0x48, 0x77, 0x2d, 0xa1, + 0xed, 0x73, 0xc1, 0x9e, 0x18, 0xd7, 0x8e, 0x49, 0x52, 0x53, 0x82, 0xfd, 0xdf, 0x36, 0x5d, 0x96, 0x56, 0xe7, 0x2f, + 0xef, 0x2b, 0x66, 0x62, 0x3b, 0xae, 0xce, 0x52, 0x21, 0x7b, 0xef, 0x57, 0x91, 0x78, 0x8c, 0xcc, 0x1f, 0xdb, 0x20, + 0x7e, 0xef, 0x9c, 0x72, 0xfc, 0x5f, 0xd8, 0x6f, 0x7a, 0xf4, 0xca, 0xc9, 0x6c, 0x23, 0x01, 0x93, 0x23, 0xf7, 0xaa, + 0xbe, 0x1f, 0x01, 0x7b, 0xc3, 0x03, 0x81, 0xb2, 0x8a, 0xfe, 0x83, 0x7a, 0xd3, 0x00, 0x60, 0x0a, 0xc3, 0x6d, 0xb8, + 0xe7, 0x8f, 0xc6, 0x6f, 0x75, 0xc0, 0xe5, 0x8a, 0xe5, 0xbf, 0x81, 0xc1, 0xf5, 0x3a, 0x22, 0xd8, 0x6f, 0x9d, 0xf5, + 0x40, 0xd0, 0x9d, 0xc7, 0x9c, 0x62, 0x10, 0xd7, 0x92, 0x2f, 0x58, 0xaf, 0x22, 0xf3, 0x18, 0xc5, 0xe6, 0x17, 0x6b, + 0x2b, 0xf8, 0x2a, 0x93, 0xfa, 0x45, 0x1e, 0xfc, 0x17, 0xa4, 0x76, 0x08, 0x87, 0xe7, 0x89, 0x45, 0xfe, 0x4d, 0xe2, + 0x70, 0x84, 0x05, 0xb6, 0x62, 0xa5, 0xa1, 0x39, 0x33, 0x7e, 0x4c, 0xc9, 0xa1, 0x4d, 0x30, 0x0e, 0x45, 0xce, 0xd6, + 0x1c, 0x2c, 0x47, 0xa9, 0x66, 0x9e, 0x7f, 0x6f, 0xf0, 0x41, 0x98, 0xb4, 0xb4, 0xf2, 0x7c, 0x80, 0xf6, 0x31, 0xfa, + 0xf3, 0x7f, 0x16, 0x87, 0x0d, 0xc3, 0xb2, 0xf7, 0x6e, 0xe2, 0x27, 0x1b, 0x38, 0xaa, 0x79, 0x52, 0xc2, 0xd5, 0x5b, + 0xab, 0xaf, 0xda, 0x96, 0x1e, 0x3f, 0x09, 0x85, 0xc6, 0x30, 0x46, 0x0b, 0x83, 0x81, 0x3b, 0x17, 0xfb, 0x39, 0x98, + 0xb9, 0x61, 0x1b, 0x7d, 0x23, 0xe1, 0x4b, 0x3e, 0x7f, 0x07, 0xea, 0x10, 0xa3, 0xa6, 0x4b, 0x23, 0x2a, 0xfd, 0x0e, + 0x45, 0xb7, 0x06, 0x14, 0x68, 0x9e, 0xf9, 0x1c, 0x0a, 0xa7, 0xa3, 0x48, 0x24, 0x39, 0xc0, 0xda, 0x99, 0x7e, 0xd6, + 0xb2, 0xc7, 0xef, 0xb3, 0xa5, 0xc3, 0xf0, 0xba, 0xb6, 0x3d, 0x1e, 0x73, 0xe5, 0x56, 0x56, 0x1d, 0x17, 0x50, 0x5f, + 0x96, 0x6d, 0x36, 0xf6, 0x8e, 0x50, 0x67, 0xab, 0x87, 0x22, 0x72, 0x86, 0x78, 0x90, 0x58, 0xdd, 0xa0, 0x8f, 0x54, + 0xb0, 0xce, 0x67, 0x1b, 0x34, 0xf9, 0x56, 0xd1, 0x8b, 0xab, 0x85, 0xcd, 0x69, 0x48, 0x88, 0x69, 0xc4, 0x70, 0xf0, + 0x49, 0x84, 0xce, 0xa4, 0x7d, 0xdc, 0x50, 0x9d, 0x38, 0x43, 0xd2, 0x70, 0x1d, 0x71, 0x5a, 0x55, 0xc2, 0xac, 0xb2, + 0x85, 0xc5, 0x53, 0xda, 0xe1, 0xea, 0xae, 0x70, 0x3b, 0x67, 0xc2, 0x51, 0xcb, 0x35, 0xb4, 0x4d, 0x44, 0x0a, 0xd9, + 0x61, 0xcb, 0x35, 0xfa, 0xea, 0xb0, 0x62, 0x85, 0x8c, 0xb7, 0xf3, 0xe2, 0x55, 0xcc, 0x38, 0x6c, 0x09, 0x4b, 0x71, + 0x80, 0x81, 0x0f, 0x6d, 0xe5, 0x7d, 0xd5, 0xc9, 0xa9, 0x70, 0x4e, 0x79, 0x97, 0x52, 0x82, 0x2d, 0x63, 0xff, 0xdc, + 0xd5, 0xab, 0xf3, 0xcb, 0xb9, 0xab, 0xce, 0x78, 0x73, 0x61, 0xea, 0xb4, 0xbe, 0x84, 0xae, 0xed, 0x10, 0x51, 0xe5, + 0x3e, 0x57, 0xd3, 0x71, 0x6f, 0xb1, 0x86, 0x9e, 0x74, 0x8e, 0x89, 0xfe, 0xbf, 0x42, 0x94, 0x8f, 0x08, 0x9d, 0xdc, + 0xdd, 0x29, 0x5f, 0x95, 0x3c, 0x55, 0x49, 0xec, 0x63, 0xb5, 0x0d, 0x23, 0x83, 0x56, 0xda, 0x89, 0x6a, 0xdf, 0x5e, + 0xee, 0x09, 0x62, 0xc8, 0x5b, 0x62, 0x59, 0xb8, 0x5d, 0x5e, 0x96, 0xdc, 0x21, 0xce, 0xed, 0x64, 0x68, 0xa7, 0x63, + 0x34, 0x42, 0x3f, 0xb4, 0xa5, 0x98, 0x04, 0x44, 0x52, 0xfb, 0x09, 0xe9, 0x1c, 0xfe, 0x2e, 0x7b, 0x7f, 0x16, 0xef, + 0x09, 0x61, 0x3e, 0x7a, 0xd1, 0x31, 0xa8, 0x4b, 0xa8, 0x73, 0xbc, 0xce, 0xab, 0x06, 0x4c, 0x12, 0x4d, 0xaf, 0xad, + 0x38, 0xd5, 0x39, 0xf5, 0xb6, 0x08, 0xc5, 0x2e, 0xfd, 0xa2, 0x25, 0xb9, 0xd9, 0x2c, 0x33, 0x66, 0x0c, 0x02, 0x75, + 0xa8, 0xe8, 0x66, 0x80, 0x62, 0x4c, 0x89, 0xb0, 0xd3, 0xf9, 0x87, 0x4c, 0xaa, 0x29, 0x2d, 0xaa, 0x76, 0xf4, 0xfb, + 0xc6, 0x60, 0x87, 0x47, 0xd3, 0x97, 0x3f, 0xbf, 0x3d, 0xd2, 0x83, 0x2a, 0xe8, 0x10, 0x3e, 0xee, 0xee, 0x8e, 0xa1, + 0x50, 0x80, 0xac, 0x6c, 0x5f, 0xcc, 0x00, 0x6a, 0x4c, 0x45, 0x48, 0x77, 0x6d, 0xdd, 0x5f, 0x4a, 0x72, 0x5b, 0x53, + 0xe5, 0xfb, 0x40, 0x83, 0xef, 0x0d, 0xb5, 0xd3, 0x1d, 0x3e, 0x87, 0xd9, 0x88, 0xa7, 0x40, 0xc7, 0xc2, 0xe0, 0x6f, + 0x48, 0x71, 0x13, 0x06, 0x19, 0xaa, 0x64, 0x9a, 0x3d, 0xa5, 0x2d, 0xab, 0xe6, 0x5a, 0x4a, 0x3a, 0xc7, 0x84, 0xbd, + 0x2a, 0xfc, 0x91, 0xf7, 0x24, 0xb5, 0xa5, 0x1a, 0x0c, 0x70, 0x82, 0xd2, 0x86, 0xe5, 0x58, 0xc5, 0x8d, 0x7c, 0xa7, + 0xf0, 0x22, 0x02, 0x3d, 0x1d, 0xdc, 0xdb, 0xf9, 0xfd, 0xde, 0x18, 0x21, 0x48, 0x05, 0xdf, 0x4a, 0xa9, 0xc9, 0x1a, + 0x9e, 0xfb, 0x47, 0xaf, 0x6c, 0x87, 0x47, 0xba, 0x9b, 0x24, 0x6a, 0x8b, 0x4e, 0x54, 0x80, 0x15, 0x88, 0xa6, 0x80, + 0x0b, 0xd5, 0x31, 0xa6, 0x71, 0xe7, 0x77, 0x3f, 0xb1, 0xd6, 0xdd, 0xea, 0xf5, 0xac, 0x97, 0x4e, 0x1e, 0x93, 0x05, + 0x6a, 0x3c, 0x8a, 0x7d, 0x79, 0x15, 0xbe, 0x5b, 0xf6, 0x9b, 0x95, 0x2d, 0xc8, 0x0c, 0x02, 0xf4, 0x9b, 0xb5, 0x39, + 0x13, 0xbd, 0x46, 0xb8, 0x93, 0x4a, 0xf3, 0xbc, 0x92, 0x33, 0x95, 0x5f, 0x5f, 0x39, 0x8b, 0x21, 0x59, 0xed, 0xac, + 0xdd, 0xa8, 0x48, 0x8f, 0xad, 0x41, 0xd6, 0xaf, 0x99, 0x64, 0xa9, 0xff, 0x35, 0x7c, 0xd4, 0x37, 0xaf, 0xd7, 0x60, + 0xda, 0x76, 0xb5, 0xd3, 0xcb, 0x53, 0x8e, 0x8a, 0x39, 0x2f, 0x7e, 0x61, 0x8d, 0x2d, 0x3c, 0x1e, 0x6c, 0xf4, 0x84, + 0xc9, 0x54, 0xb2, 0x7a, 0x56, 0xc9, 0xca, 0x59, 0xe2, 0x72, 0xb3, 0x17, 0x5d, 0x40, 0xc7, 0x1f, 0x0e, 0x5a, 0x95, + 0x3f, 0x6c, 0xcc, 0xaa, 0x7c, 0xd8, 0x49, 0xd5, 0xfa, 0x24, 0x91, 0xd9, 0x33, 0x6b, 0xe4, 0x61, 0x61, 0xad, 0x98, + 0x4c, 0xf2, 0x7d, 0x42, 0xae, 0xd0, 0x0c, 0xab, 0x6a, 0xd5, 0xe1, 0xc9, 0x0d, 0x37, 0xb8, 0x58, 0xf8, 0xb9, 0x19, + 0xd7, 0x7f, 0x46, 0xdc, 0x59, 0x0e, 0x3a, 0x0b, 0xad, 0xbf, 0xbd, 0x0e, 0x75, 0x3f, 0x82, 0x2f, 0x4d, 0x70, 0x65, + 0xfa, 0x16, 0x5c, 0xfd, 0x4a, 0x92, 0xd9, 0x16, 0x78, 0xad, 0x00, 0xb9, 0xd8, 0x1b, 0x1b, 0xb1, 0xd6, 0x92, 0x44, + 0x63, 0x43, 0x90, 0x3a, 0x8b, 0xb4, 0x1b, 0x52, 0x3b, 0x9a, 0xed, 0xb4, 0x8e, 0xe6, 0x27, 0xfc, 0x8d, 0x3f, 0x55, + 0x43, 0x15, 0xe6, 0x5b, 0x85, 0xea, 0x15, 0x0f, 0x4e, 0x5b, 0x6f, 0x35, 0x8b, 0xf3, 0x4d, 0xb0, 0xd2, 0x8a, 0xa8, + 0x08, 0x8d, 0xc1, 0x17, 0x19, 0x1c, 0xc4, 0xfd, 0x8a, 0xb5, 0x82, 0x74, 0x53, 0xd6, 0xed, 0x7f, 0x0d, 0xb5, 0xd2, + 0xee, 0x40, 0xec, 0x1b, 0x74, 0x81, 0x95, 0xb5, 0x02, 0xb9, 0x87, 0xf5, 0xfe, 0x82, 0xd2, 0x0a, 0x71, 0xe1, 0xcc, + 0x11, 0x35, 0x61, 0xad, 0xf7, 0x88, 0xb7, 0xc8, 0xfa, 0xcb, 0x3f, 0xd3, 0x8b, 0x26, 0xce, 0xe2, 0x61, 0x19, 0xe7, + 0x0e, 0xd9, 0x91, 0xcb, 0x2c, 0x9f, 0xae, 0xbc, 0xd5, 0x22, 0x82, 0x86, 0x3c, 0x99, 0xf6, 0xf8, 0x14, 0x4e, 0x9b, + 0x35, 0x9c, 0x9e, 0xc8, 0xa7, 0xd6, 0x5a, 0xd3, 0xc9, 0xaa, 0xe1, 0x1f, 0x70, 0xc1, 0x05, 0x86, 0x1d, 0x0c, 0x4e, + 0xaf, 0x9c, 0xaf, 0xba, 0xa0, 0x49, 0x4f, 0x58, 0x70, 0x06, 0xcd, 0x6d, 0xc0, 0x93, 0x0f, 0xe9, 0x29, 0x75, 0x77, + 0x76, 0x9b, 0xd7, 0x40, 0x6e, 0x13, 0x7d, 0x6a, 0x31, 0xcf, 0x0a, 0x5b, 0x70, 0xa6, 0xce, 0x6e, 0x63, 0x7a, 0xae, + 0xae, 0xdb, 0x56, 0x82, 0xa4, 0x4d, 0x9e, 0xcf, 0x06, 0xd7, 0x8c, 0x14, 0x86, 0xc1, 0xff, 0x97, 0x90, 0x92, 0xb7, + 0xa2, 0x20, 0x98, 0x3a, 0x27, 0x7d, 0xad, 0x17, 0x57, 0xb8, 0x11, 0xb1, 0xcc, 0xaa, 0x23, 0xa8, 0x52, 0xf6, 0x04, + 0x5d, 0xfa, 0xdc, 0xc1, 0x25, 0x27, 0x62, 0xbb, 0x67, 0xa5, 0x33, 0x29, 0xa1, 0xfd, 0x79, 0xc1, 0xbb, 0x6b, 0xbc, + 0x72, 0x47, 0xf6, 0xc7, 0xca, 0x3d, 0xe3, 0x1d, 0xb8, 0x7a, 0xf6, 0xe7, 0x38, 0x6b, 0xe1, 0xa0, 0xcb, 0x30, 0x8f, + 0x27, 0x3d, 0x3c, 0xcb, 0x3f, 0xe1, 0x59, 0x39, 0xcf, 0x18, 0x82, 0xd6, 0x61, 0x85, 0x6f, 0xbe, 0x06, 0x28, 0xef, + 0x64, 0xf8, 0xf8, 0x58, 0xfc, 0xd6, 0xd8, 0x8b, 0x4e, 0xca, 0x21, 0x9a, 0xa9, 0x1d, 0x34, 0xcf, 0x5b, 0x30, 0xe4, + 0xa9, 0xdd, 0x20, 0x90, 0x46, 0xeb, 0x3c, 0x57, 0x3f, 0xc5, 0x41, 0x35, 0x7f, 0x9b, 0x79, 0x09, 0x73, 0x5b, 0xa1, + 0x88, 0xfc, 0x33, 0x21, 0x9a, 0xfd, 0x48, 0xa5, 0x81, 0x3a, 0xf9, 0x55, 0x4b, 0xf2, 0x95, 0xb7, 0x23, 0x06, 0x9d, + 0xb9, 0x09, 0xbb, 0xd8, 0x08, 0xf3, 0xd3, 0x98, 0x7c, 0xa6, 0x3a, 0x9b, 0xc9, 0x32, 0xcb, 0x6a, 0x1f, 0x13, 0x0f, + 0x8f, 0xd6, 0x4b, 0xaa, 0x5b, 0x14, 0x6a, 0xb3, 0x3c, 0x5f, 0x94, 0x59, 0xa9, 0x7d, 0x4e, 0xbd, 0x10, 0x47, 0x93, + 0xf5, 0xc2, 0xe3, 0x5e, 0x62, 0x46, 0x26, 0xd5, 0xbc, 0xcc, 0x1c, 0x22, 0x0f, 0xcf, 0x1f, 0x7c, 0xcb, 0x2e, 0x79, + 0xa2, 0xa0, 0xb4, 0x1d, 0x32, 0x0f, 0xdc, 0x37, 0x98, 0xae, 0x9c, 0x7a, 0xcc, 0xd3, 0x15, 0x70, 0x6b, 0xc0, 0x6c, + 0x69, 0x14, 0x47, 0x56, 0x59, 0x85, 0xac, 0xeb, 0xf5, 0xba, 0xf2, 0xb9, 0x65, 0x9a, 0x09, 0x37, 0xf6, 0x14, 0x64, + 0x9a, 0xae, 0x4a, 0xd7, 0xd2, 0x67, 0xfe, 0xcd, 0x9c, 0x67, 0x1f, 0xf0, 0xd3, 0x4f, 0xc1, 0x2d, 0xfa, 0xcb, 0xa9, + 0x6b, 0x5c, 0xf9, 0x36, 0xa3, 0x51, 0xe3, 0x14, 0x8d, 0x37, 0x48, 0x4c, 0x54, 0x54, 0x85, 0xd5, 0x98, 0xf2, 0x73, + 0xec, 0xdd, 0x48, 0x4e, 0xa6, 0x43, 0x3e, 0xd7, 0x76, 0x3f, 0xb3, 0x66, 0xf5, 0x19, 0x75, 0x68, 0x95, 0xd5, 0x71, + 0xc4, 0x97, 0xce, 0x6e, 0x57, 0x06, 0xa1, 0x00, 0x04, 0xd8, 0xc3, 0xe4, 0x73, 0xca, 0x5a, 0x4d, 0xfe, 0xfc, 0xfb, + 0xfb, 0x47, 0x15, 0x9c, 0x62, 0x95, 0xf7, 0xdd, 0xd8, 0x04, 0x8b, 0x64, 0x46, 0x18, 0x59, 0x23, 0xbb, 0x39, 0x46, + 0x92, 0x22, 0x44, 0xe3, 0x1e, 0x4b, 0x11, 0x7a, 0xab, 0xfb, 0x01, 0xe0, 0x1c, 0x79, 0x52, 0x9c, 0x26, 0x47, 0xa7, + 0xc8, 0xa6, 0xd9, 0x56, 0x6c, 0x91, 0x85, 0x03, 0x7c, 0x2d, 0x6a, 0x25, 0xdb, 0xc6, 0x58, 0x41, 0x83, 0x62, 0x0e, + 0x64, 0x3a, 0xf3, 0x01, 0x5f, 0x31, 0xe2, 0x9c, 0x3f, 0x4c, 0x1b, 0x93, 0x27, 0xd3, 0x5e, 0x5f, 0x25, 0xcc, 0x6c, + 0xb7, 0x5e, 0x30, 0x9c, 0xd3, 0x0c, 0x0c, 0xc8, 0xc7, 0x15, 0xaa, 0xf9, 0x13, 0x2c, 0x51, 0xf0, 0xb7, 0x36, 0xb2, + 0xf3, 0xe7, 0xa4, 0x36, 0x62, 0xc8, 0x98, 0x68, 0x6c, 0x2f, 0x8c, 0x94, 0x82, 0x17, 0x35, 0x74, 0x46, 0x58, 0x04, + 0x1f, 0xec, 0x9e, 0xc2, 0xf5, 0x59, 0xd9, 0xeb, 0x74, 0x12, 0x3d, 0x30, 0x4f, 0x94, 0xe0, 0xd2, 0x7c, 0x5f, 0xdb, + 0x20, 0xa0, 0x3e, 0x6f, 0x79, 0x26, 0x07, 0x24, 0x25, 0x26, 0xb0, 0xf0, 0xb8, 0x29, 0x5f, 0xe3, 0xd4, 0x5b, 0xef, + 0xb2, 0x1a, 0x75, 0xc5, 0x25, 0x8d, 0x36, 0xce, 0x18, 0x34, 0x18, 0x1d, 0x11, 0x89, 0xe7, 0x42, 0x30, 0x46, 0xc3, + 0xdf, 0x7a, 0x24, 0x69, 0x08, 0xce, 0x63, 0x4f, 0x10, 0x37, 0x39, 0x99, 0xde, 0x40, 0x88, 0xb2, 0x6d, 0xb9, 0xf9, + 0x79, 0x5f, 0xa0, 0xd1, 0x9c, 0x8f, 0x4d, 0xcc, 0x9c, 0xf7, 0x00, 0x65, 0x26, 0x5a, 0x04, 0xe4, 0xd0, 0xe3, 0x1e, + 0xe2, 0x2a, 0x3d, 0x58, 0xec, 0x25, 0x2e, 0xd3, 0x31, 0x10, 0x5f, 0xaf, 0x95, 0x82, 0x34, 0x3b, 0x8b, 0x14, 0x78, + 0x31, 0xdf, 0xfc, 0xc9, 0x95, 0x62, 0x95, 0x7c, 0xd3, 0x60, 0x72, 0xfe, 0xe4, 0xc7, 0xe6, 0x97, 0xe0, 0xe5, 0x5b, + 0x2d, 0xb5, 0xc8, 0x7d, 0xe0, 0x9d, 0xaf, 0x49, 0x41, 0xbb, 0xff, 0xd9, 0x92, 0x91, 0xf7, 0x31, 0xad, 0x96, 0xc5, + 0x5b, 0xed, 0xa2, 0x5b, 0x14, 0xf2, 0x26, 0x0f, 0xf7, 0xb0, 0x08, 0xa9, 0xb5, 0x96, 0x61, 0x56, 0xdb, 0xa3, 0xdc, + 0xd8, 0x7b, 0xbd, 0x16, 0xa4, 0x45, 0xcc, 0x2e, 0x51, 0xe5, 0xc6, 0x0b, 0x4c, 0xd6, 0x9f, 0x5c, 0x08, 0x96, 0xf9, + 0x05, 0x55, 0x69, 0xef, 0xb2, 0x8e, 0xa7, 0x6c, 0x66, 0xad, 0x8b, 0x9a, 0x4d, 0x01, 0xa7, 0x28, 0x2b, 0x55, 0xdc, + 0xc8, 0xe0, 0xbb, 0x46, 0xa0, 0x35, 0xf0, 0x13, 0x18, 0xa5, 0xc8, 0x6a, 0xaa, 0x8d, 0xa4, 0xff, 0xce, 0xe4, 0xdf, + 0x39, 0xe6, 0xbf, 0x41, 0xe6, 0xdf, 0x87, 0x56, 0x7e, 0xdf, 0x18, 0x6b, 0x02, 0x5c, 0xe1, 0xa4, 0x10, 0x5f, 0xa9, + 0x9c, 0x25, 0x80, 0x1a, 0x4d, 0x99, 0xec, 0xc6, 0x0b, 0x81, 0x15, 0x91, 0xe7, 0x36, 0x4e, 0xb3, 0xb4, 0x47, 0xb6, + 0xe8, 0xfe, 0xce, 0x0b, 0x70, 0x42, 0x2e, 0x0a, 0xee, 0x88, 0xed, 0xab, 0x31, 0xe7, 0x50, 0xc4, 0xd9, 0xe4, 0xa2, + 0x00, 0x31, 0x82, 0x01, 0x21, 0x1b, 0x49, 0xa0, 0xa3, 0xa4, 0x99, 0x68, 0xc4, 0x14, 0x80, 0x06, 0xd8, 0xdd, 0x03, + 0x04, 0x16, 0xc1, 0x0c, 0x13, 0x04, 0x23, 0x79, 0x25, 0xc0, 0x72, 0x4c, 0xf6, 0x8e, 0x55, 0xb0, 0xb0, 0x52, 0x07, + 0x3b, 0xd0, 0x20, 0x4e, 0x60, 0x8a, 0x66, 0x79, 0x24, 0x28, 0xaa, 0x60, 0x11, 0x25, 0xcb, 0x36, 0x17, 0x2f, 0x32, + 0xb7, 0xf5, 0x2a, 0x49, 0xa1, 0x8b, 0xa7, 0x4f, 0x33, 0x4b, 0x28, 0xfd, 0x03, 0xf0, 0xaf, 0x41, 0x1d, 0xd8, 0xb3, + 0x0e, 0xa0, 0x63, 0x2b, 0x4e, 0x4e, 0xa5, 0xca, 0x9f, 0x5d, 0x03, 0x40, 0x49, 0x4f, 0x1b, 0xc4, 0x5c, 0xa0, 0x75, + 0x0d, 0x71, 0x0d, 0x2a, 0x80, 0x61, 0x93, 0xf1, 0x52, 0x53, 0xdb, 0x7a, 0x66, 0xf1, 0x52, 0xef, 0x91, 0x99, 0xa3, + 0x43, 0x12, 0x2f, 0xa2, 0xc4, 0x5d, 0x14, 0x96, 0x23, 0xa5, 0xd6, 0xdc, 0x28, 0xd6, 0x98, 0xf2, 0xd2, 0x6e, 0x0e, + 0xf1, 0x1d, 0xa2, 0xd3, 0x45, 0x50, 0xf5, 0x79, 0x8b, 0xa7, 0xb5, 0x11, 0xf8, 0x91, 0xd3, 0xa2, 0x40, 0x79, 0xbb, + 0xe2, 0xa4, 0xa6, 0x27, 0x3b, 0x56, 0xd8, 0x34, 0x2d, 0xbd, 0x83, 0x5b, 0x4f, 0xdf, 0x96, 0x64, 0x90, 0x71, 0x20, + 0xb0, 0x23, 0x20, 0x6c, 0x8a, 0x3b, 0x33, 0xd1, 0x16, 0x47, 0x70, 0x82, 0x50, 0x46, 0x66, 0x87, 0x6f, 0x05, 0xcf, + 0x2a, 0x02, 0x9f, 0xf7, 0xa3, 0xf7, 0x9c, 0xeb, 0x6a, 0x28, 0xad, 0x8e, 0x3d, 0x6a, 0x24, 0x38, 0xca, 0xb3, 0xa6, + 0x6f, 0x38, 0xa7, 0x16, 0x21, 0x55, 0x71, 0xbf, 0x00, 0x2b, 0xb7, 0xf7, 0x49, 0x83, 0x15, 0x9f, 0xb1, 0x6c, 0x0f, + 0xb2, 0x95, 0x32, 0xa2, 0x91, 0xf2, 0xba, 0xc7, 0xcc, 0x68, 0x7b, 0xc1, 0xc8, 0x8d, 0xb9, 0xe1, 0xfd, 0xec, 0x31, + 0x8a, 0xea, 0x15, 0x46, 0xac, 0x16, 0xdb, 0x09, 0x30, 0xf7, 0xc6, 0xbd, 0x55, 0x33, 0x67, 0x3e, 0xe5, 0x42, 0x4a, + 0xa9, 0x60, 0xbe, 0x53, 0x79, 0x06, 0x27, 0x9f, 0x42, 0x30, 0xe4, 0x87, 0xef, 0x33, 0xbf, 0x5e, 0x73, 0x6b, 0x96, + 0xf1, 0xa2, 0xbe, 0xa7, 0x7d, 0x36, 0x43, 0x6d, 0x78, 0xb5, 0x94, 0x10, 0x57, 0x67, 0xd9, 0xb9, 0x78, 0x0d, 0xac, + 0xa9, 0x0c, 0xf0, 0x15, 0xab, 0xa2, 0x2e, 0xc1, 0x57, 0xc4, 0xbc, 0x91, 0x30, 0x7f, 0xc3, 0x2a, 0x06, 0xf3, 0xa6, + 0x4a, 0xca, 0x27, 0xee, 0x8f, 0xd8, 0x94, 0x71, 0x89, 0xb2, 0xa5, 0x0f, 0xe9, 0x77, 0xb0, 0x37, 0xaa, 0x78, 0xb3, + 0x12, 0xbe, 0x96, 0xec, 0xb7, 0x7d, 0x6c, 0x4d, 0xc2, 0x14, 0x00, 0x2d, 0x32, 0x16, 0x01, 0xdd, 0x7a, 0xf5, 0xb6, + 0x90, 0xad, 0x09, 0x8d, 0x34, 0x34, 0x84, 0xa2, 0xee, 0xbd, 0x60, 0x62, 0x52, 0xdc, 0x1d, 0x28, 0x31, 0x31, 0x9e, + 0x35, 0x96, 0x5f, 0x90, 0x9f, 0x57, 0x75, 0xda, 0x1a, 0x73, 0xa1, 0x63, 0x46, 0x30, 0xa9, 0x41, 0x33, 0x01, 0x92, + 0x00, 0x5e, 0x2e, 0xa3, 0xc1, 0x38, 0x4f, 0x38, 0x36, 0xf7, 0x3a, 0x4b, 0xc8, 0x00, 0x81, 0x4e, 0x31, 0xa5, 0x52, + 0xbc, 0x5a, 0x1f, 0xa4, 0x94, 0x17, 0x80, 0xb2, 0x63, 0x36, 0x58, 0x52, 0x50, 0x1f, 0x6d, 0xda, 0x4c, 0xae, 0x6d, + 0x0d, 0x7b, 0xca, 0x64, 0xd6, 0x42, 0x99, 0xe6, 0x0f, 0x97, 0xf9, 0x45, 0xc4, 0xb8, 0xa8, 0xf9, 0x84, 0x7d, 0xd5, + 0x61, 0x04, 0x5a, 0x8f, 0x41, 0x5e, 0x0f, 0x27, 0xbc, 0x9f, 0xd7, 0xfb, 0xe6, 0xd6, 0xc4, 0x93, 0x17, 0x05, 0x4e, + 0x7d, 0xa9, 0xfc, 0x4b, 0xfb, 0x13, 0xd8, 0xc4, 0x03, 0x99, 0xf8, 0x54, 0xb2, 0x95, 0x89, 0xa2, 0x04, 0xa2, 0x5a, + 0x84, 0x67, 0x92, 0x0b, 0x82, 0x94, 0x8c, 0x97, 0x81, 0x50, 0xdb, 0x8c, 0x06, 0x24, 0xef, 0x6b, 0x4b, 0x78, 0x2d, + 0xf9, 0x74, 0x11, 0xf2, 0x66, 0x33, 0xac, 0xed, 0xf9, 0xb4, 0xdb, 0xde, 0x4a, 0xa1, 0x6a, 0x80, 0x92, 0xc9, 0x70, + 0x19, 0xf4, 0x0d, 0xcd, 0x0e, 0xe5, 0x09, 0xed, 0xf6, 0x6d, 0x56, 0xca, 0x24, 0xcc, 0x4e, 0xd7, 0xe4, 0xa8, 0xf8, + 0x85, 0xd2, 0xee, 0x6c, 0x74, 0x05, 0xaf, 0x75, 0x07, 0xe3, 0xa2, 0x50, 0x0e, 0x30, 0xa6, 0x46, 0xe6, 0x0f, 0xdc, + 0xc8, 0x91, 0xa5, 0x0f, 0xcb, 0xe4, 0xa2, 0x56, 0x54, 0x26, 0x43, 0xda, 0xb4, 0xb6, 0xea, 0x36, 0x1b, 0x25, 0xe9, + 0xb2, 0x44, 0xce, 0xb7, 0x56, 0xf1, 0xb2, 0xea, 0xe1, 0x5d, 0x28, 0xa5, 0xef, 0x4b, 0x5c, 0xbc, 0x74, 0xa0, 0xee, + 0x6d, 0x25, 0x96, 0xf0, 0xa9, 0x69, 0xe2, 0x14, 0xdc, 0x01, 0x63, 0x95, 0xad, 0x88, 0x5a, 0x20, 0xa9, 0xff, 0xc2, + 0x8b, 0xfb, 0x42, 0x84, 0x78, 0xe7, 0xaa, 0x57, 0x33, 0x24, 0x66, 0x92, 0xc7, 0x68, 0xf5, 0x3b, 0x88, 0x82, 0x6e, + 0x39, 0x8d, 0x03, 0x02, 0x4f, 0x4d, 0x7a, 0xf9, 0xed, 0x48, 0xe2, 0xec, 0x36, 0x2b, 0x34, 0xd0, 0xe3, 0x59, 0x76, + 0xb0, 0xc6, 0xb6, 0x6a, 0x8f, 0x67, 0xa6, 0x2f, 0x2e, 0xb4, 0x4c, 0xc2, 0x98, 0xdf, 0x36, 0xf4, 0x03, 0xd8, 0xa5, + 0xe9, 0xc6, 0x41, 0x63, 0x76, 0x57, 0xab, 0x2f, 0xf1, 0xbc, 0xa8, 0x82, 0x24, 0x2e, 0xb1, 0x31, 0x0a, 0xeb, 0xb7, + 0x2a, 0x1f, 0x15, 0x05, 0xcb, 0xb9, 0xe5, 0xaa, 0xca, 0x6b, 0xd7, 0x91, 0x17, 0xaf, 0x45, 0x4e, 0x82, 0xca, 0x3d, + 0x32, 0xe3, 0x18, 0x5c, 0x44, 0x0b, 0xfd, 0x9c, 0x5e, 0x54, 0x15, 0x1d, 0xaf, 0x2c, 0x6b, 0x88, 0x20, 0x70, 0xab, + 0xea, 0x15, 0x52, 0x62, 0x91, 0x98, 0x67, 0x11, 0xb2, 0xbd, 0x0e, 0x72, 0x9b, 0xb3, 0x81, 0x70, 0x93, 0x4e, 0x09, + 0x9c, 0x92, 0xf0, 0x0f, 0xe5, 0xd9, 0x86, 0x11, 0xf5, 0x4c, 0x6b, 0xa4, 0x8b, 0xaa, 0x35, 0xe7, 0xb5, 0x28, 0xd4, + 0x0e, 0x94, 0xb8, 0x5a, 0xaf, 0x6e, 0x84, 0x42, 0x80, 0x70, 0x61, 0xfe, 0x1c, 0xc0, 0xfd, 0x6d, 0xcd, 0x8a, 0x07, + 0x9b, 0xca, 0xa1, 0x5a, 0x35, 0x6d, 0x1c, 0x80, 0x03, 0xf2, 0x16, 0x2b, 0x83, 0x0b, 0x24, 0xc3, 0x0c, 0xf5, 0x32, + 0xd1, 0x06, 0x43, 0xc5, 0x38, 0xb5, 0xf8, 0x5c, 0xea, 0x5c, 0xa7, 0x4f, 0xc3, 0x8a, 0x99, 0xc5, 0x1d, 0xfa, 0x6c, + 0x95, 0x39, 0xf8, 0xda, 0x11, 0xec, 0xf2, 0x93, 0x69, 0xdb, 0x07, 0x25, 0xbf, 0x0d, 0x65, 0x1a, 0xde, 0xc4, 0xb9, + 0x4d, 0xd9, 0xe9, 0x63, 0x65, 0xe1, 0xab, 0xf7, 0x9d, 0x5b, 0xf2, 0xc1, 0xcc, 0x16, 0x91, 0x7e, 0x05, 0x18, 0xf2, + 0xc7, 0xf8, 0x79, 0x32, 0x88, 0xb6, 0x9d, 0xae, 0x73, 0xcd, 0x3b, 0x54, 0x49, 0x45, 0x45, 0xae, 0x84, 0x21, 0x72, + 0x28, 0xe4, 0x32, 0x52, 0xfa, 0x5a, 0x22, 0x6b, 0x33, 0x72, 0x27, 0xd3, 0x8f, 0x96, 0xd3, 0x29, 0x0e, 0x79, 0x69, + 0xad, 0x0b, 0xeb, 0xf2, 0x37, 0xba, 0xb2, 0x4d, 0xfa, 0x4b, 0x3d, 0x91, 0x8b, 0x86, 0xf0, 0xf3, 0xb5, 0xcd, 0x01, + 0x4a, 0xfd, 0xaf, 0xd6, 0x2f, 0xe2, 0xa8, 0xa0, 0x0b, 0x5d, 0x19, 0x88, 0x0f, 0x8a, 0x52, 0x82, 0xed, 0x73, 0x96, + 0x50, 0xd7, 0x3d, 0x30, 0x4e, 0xba, 0xe2, 0xa4, 0xe8, 0x17, 0xef, 0x45, 0x78, 0x6f, 0x9f, 0x1c, 0x56, 0xee, 0x10, + 0xa7, 0xa7, 0x5a, 0xf5, 0x31, 0x32, 0x59, 0x49, 0x4c, 0x34, 0x61, 0x95, 0x37, 0x34, 0x87, 0xad, 0x32, 0x9a, 0xd5, + 0x74, 0x9d, 0x7c, 0x7f, 0xa0, 0x30, 0x12, 0x19, 0xfe, 0x6e, 0x6e, 0x22, 0x03, 0x0d, 0x1c, 0xd5, 0x19, 0xa8, 0xe4, + 0xb8, 0x9f, 0x6b, 0xd6, 0x87, 0xca, 0x4b, 0x00, 0x64, 0xf6, 0x78, 0xa3, 0xac, 0x5b, 0x7e, 0x37, 0xaf, 0x41, 0x40, + 0xaf, 0xff, 0x15, 0x6d, 0xb2, 0x80, 0x68, 0x33, 0xb8, 0x56, 0x53, 0x50, 0x3e, 0x65, 0xa2, 0x3f, 0xda, 0xa0, 0x67, + 0xbf, 0xdb, 0xe6, 0x0c, 0xd5, 0x85, 0xa5, 0xc4, 0xee, 0x5b, 0x94, 0x15, 0x0b, 0xd8, 0xcf, 0x6a, 0x84, 0xee, 0x94, + 0xf1, 0xf3, 0x47, 0xdd, 0xcc, 0x66, 0x61, 0xab, 0x08, 0xe8, 0xd1, 0x57, 0x57, 0x1c, 0x00, 0x0b, 0xe8, 0x12, 0x16, + 0x46, 0xec, 0x58, 0xca, 0x33, 0xcb, 0x54, 0xf6, 0x99, 0x47, 0x74, 0x7d, 0x33, 0xe4, 0x1e, 0x3e, 0xdd, 0x7e, 0x8b, + 0x55, 0x31, 0x8e, 0x27, 0xd6, 0xd5, 0x45, 0x67, 0x50, 0x34, 0x21, 0xe9, 0xf4, 0xcb, 0x19, 0x90, 0xaa, 0x95, 0x9d, + 0x98, 0xab, 0x36, 0x01, 0xf4, 0xf6, 0x5d, 0x49, 0xe0, 0x31, 0x39, 0xbc, 0x1b, 0xcc, 0x2c, 0x30, 0x45, 0xcb, 0x52, + 0x08, 0x7d, 0xb7, 0x14, 0xe5, 0xbc, 0x15, 0x0a, 0x06, 0xb4, 0x0b, 0xc2, 0xdf, 0x38, 0x2e, 0xb1, 0x05, 0x2d, 0xa3, + 0xf5, 0x22, 0x88, 0x8e, 0x40, 0x24, 0x37, 0x46, 0x8e, 0x0f, 0x67, 0xeb, 0x1a, 0x14, 0x43, 0x96, 0xba, 0xc0, 0xa1, + 0x9b, 0x17, 0x6c, 0x97, 0x0a, 0xc9, 0x44, 0xbe, 0x43, 0x43, 0x60, 0x79, 0xee, 0xc4, 0xe9, 0x80, 0xe8, 0xde, 0xdf, + 0x27, 0x4b, 0x56, 0x54, 0xfc, 0x50, 0x86, 0xdb, 0x17, 0x66, 0x70, 0xa8, 0x27, 0xde, 0x0c, 0x3a, 0xe0, 0x4a, 0xef, + 0x53, 0x25, 0x46, 0x32, 0xeb, 0x1d, 0x20, 0x8a, 0x88, 0x32, 0xf3, 0x4c, 0x76, 0x8b, 0xdb, 0xc3, 0x29, 0x60, 0x20, + 0x63, 0xda, 0xa4, 0x27, 0xc3, 0x44, 0x60, 0x88, 0xf9, 0x6a, 0x7c, 0xde, 0x83, 0x1f, 0xdb, 0x7d, 0x44, 0xce, 0x45, + 0xb9, 0x86, 0xc2, 0x36, 0x66, 0x33, 0x5b, 0xf4, 0x04, 0xdf, 0x48, 0xa4, 0xa3, 0x97, 0x31, 0x94, 0x0b, 0x84, 0x83, + 0x95, 0xce, 0x89, 0xe9, 0xc1, 0x8a, 0x2a, 0x40, 0x5c, 0xb9, 0x71, 0xca, 0xa8, 0x01, 0xb3, 0xe4, 0x06, 0x57, 0xd0, + 0x64, 0xd4, 0xe1, 0x57, 0x77, 0xf4, 0xec, 0x63, 0x16, 0xdc, 0x93, 0x97, 0xc1, 0xa1, 0x6e, 0xad, 0xa7, 0x75, 0xf7, + 0x06, 0x12, 0x62, 0x41, 0x59, 0x60, 0xce, 0x4e, 0x87, 0x85, 0x15, 0x6c, 0x6b, 0x6a, 0x85, 0x57, 0xeb, 0x87, 0x16, + 0x56, 0x92, 0xe1, 0x34, 0x88, 0x24, 0xce, 0xc0, 0x34, 0x0a, 0xf1, 0x87, 0xfa, 0x8b, 0x45, 0x5f, 0x9e, 0xf8, 0xad, + 0xfb, 0x6b, 0xa9, 0xb4, 0xfa, 0xfc, 0xb3, 0x58, 0xb8, 0x20, 0x13, 0xfb, 0x8d, 0x5e, 0x58, 0x98, 0x14, 0x56, 0xe0, + 0xaa, 0x7a, 0xc1, 0xb3, 0x64, 0xa5, 0xf0, 0xe4, 0xbb, 0x11, 0x5a, 0x7a, 0xc2, 0xcf, 0xa3, 0xac, 0x1a, 0x7b, 0x33, + 0xa2, 0x51, 0x2d, 0x9f, 0x82, 0xda, 0x1d, 0x1d, 0x08, 0x97, 0xc9, 0xc0, 0xaa, 0xb2, 0x00, 0xf5, 0xe7, 0x97, 0xb9, + 0x47, 0xc2, 0xba, 0x54, 0x4c, 0xd9, 0x07, 0xcf, 0x89, 0xa0, 0xb7, 0x10, 0x85, 0x18, 0x1e, 0x49, 0xdf, 0xa0, 0xfc, + 0xea, 0x8f, 0xfc, 0xbe, 0xd7, 0x93, 0xbf, 0x33, 0x76, 0xbe, 0x69, 0x96, 0x3b, 0xb3, 0xd7, 0xe8, 0xf5, 0xcf, 0x21, + 0x6b, 0x11, 0x06, 0x39, 0x4d, 0x17, 0x82, 0x26, 0x28, 0x5e, 0x18, 0x0d, 0xac, 0xe7, 0x74, 0xad, 0x37, 0x41, 0xee, + 0x85, 0xc4, 0xf8, 0x7f, 0x91, 0xf0, 0x32, 0xa0, 0x72, 0x32, 0x8a, 0x5a, 0xf0, 0x00, 0x5c, 0x55, 0x43, 0x2d, 0x50, + 0x26, 0x0f, 0x4f, 0xa0, 0x25, 0x63, 0x11, 0x9e, 0x65, 0x1f, 0xeb, 0xd4, 0xc1, 0x78, 0x24, 0xf3, 0xb0, 0xa6, 0xc2, + 0xd5, 0x72, 0x36, 0x39, 0x66, 0x76, 0xcc, 0xea, 0x6a, 0x1f, 0xbb, 0x13, 0x26, 0xf1, 0xcc, 0x79, 0xc8, 0x67, 0xdb, + 0xe3, 0x40, 0x53, 0x6f, 0x1e, 0x38, 0xac, 0x69, 0x36, 0x11, 0xe4, 0x9a, 0x06, 0xb6, 0x00, 0x83, 0x9d, 0xac, 0x55, + 0xa3, 0x84, 0x64, 0xcd, 0x0d, 0x80, 0x38, 0x92, 0x51, 0x08, 0xa9, 0x6c, 0xf8, 0x81, 0xb5, 0x54, 0x5f, 0x81, 0x1e, + 0xab, 0x2f, 0x35, 0x0c, 0x84, 0xa8, 0x6d, 0x84, 0x2a, 0x60, 0x0c, 0x5c, 0x99, 0x7f, 0x29, 0x10, 0x5c, 0xd0, 0x5f, + 0xf6, 0x1a, 0xbe, 0xdc, 0xac, 0xdb, 0x8e, 0x21, 0xea, 0x3a, 0x58, 0x8b, 0xc8, 0x78, 0xd5, 0x15, 0xfe, 0x1b, 0x6e, + 0x22, 0x45, 0x0a, 0xc5, 0x12, 0x91, 0xfc, 0x88, 0xf2, 0x1e, 0xe3, 0x1e, 0xea, 0xbd, 0x1d, 0xbc, 0x8e, 0x84, 0x41, + 0x73, 0xa8, 0xd1, 0x4a, 0x52, 0xbc, 0xc7, 0x56, 0x3d, 0xf6, 0x28, 0xb8, 0x9f, 0x2c, 0x35, 0x7c, 0x87, 0x28, 0x5d, + 0xfd, 0x14, 0x50, 0x4f, 0xfe, 0xa3, 0x67, 0x9b, 0xa7, 0x66, 0x1f, 0x11, 0x7d, 0x93, 0xd1, 0x38, 0xb2, 0x50, 0x51, + 0x14, 0x5e, 0x08, 0x81, 0xe7, 0x1c, 0xf1, 0x54, 0x1f, 0x20, 0xe6, 0x21, 0xd3, 0x64, 0xe4, 0x7a, 0x40, 0x0f, 0x34, + 0x39, 0x7a, 0x76, 0x39, 0xa6, 0x8b, 0xf6, 0x61, 0x74, 0x6c, 0x47, 0x88, 0x4b, 0xb5, 0x89, 0x68, 0x4e, 0xab, 0x2e, + 0x5b, 0x48, 0x62, 0x9d, 0xa7, 0x7c, 0xa4, 0x20, 0x07, 0x6e, 0xc2, 0xea, 0x77, 0x8e, 0x43, 0xbb, 0x28, 0xb8, 0x7d, + 0x4d, 0x25, 0x9c, 0x8d, 0x2a, 0xba, 0x2f, 0x83, 0x4f, 0xa2, 0x59, 0x34, 0x80, 0x6c, 0xc0, 0xd7, 0xfb, 0xdb, 0x09, + 0x96, 0x25, 0xd8, 0x45, 0x6d, 0xa6, 0x6c, 0x5e, 0x9e, 0xc3, 0x6c, 0x6b, 0xb8, 0x2f, 0xd0, 0xfa, 0x12, 0xea, 0x5d, + 0xea, 0x33, 0xc2, 0xb7, 0xf2, 0x60, 0x88, 0xc9, 0xca, 0xcd, 0x46, 0x16, 0x83, 0x75, 0x98, 0x75, 0x8f, 0x91, 0x39, + 0x89, 0x7f, 0xa1, 0xce, 0x5c, 0x10, 0x9e, 0x59, 0xc9, 0x82, 0x4f, 0xe8, 0x66, 0xb0, 0x61, 0x3c, 0xc6, 0xcf, 0x51, + 0xf6, 0xe0, 0xfd, 0x4e, 0x92, 0x56, 0x30, 0x1b, 0x92, 0xda, 0x71, 0xb5, 0xd6, 0xf1, 0x8b, 0x0b, 0xf4, 0x20, 0x35, + 0xf1, 0x54, 0x54, 0x76, 0xc4, 0x2c, 0x90, 0xea, 0x25, 0xf6, 0xbe, 0xf9, 0x49, 0x7c, 0xa4, 0x0d, 0x9e, 0xcb, 0x10, + 0x06, 0xf4, 0x46, 0x62, 0x7d, 0xaf, 0x94, 0xa6, 0x47, 0x65, 0x63, 0xd0, 0xda, 0x98, 0xc9, 0x1c, 0x26, 0xd6, 0x5d, + 0xa2, 0x5e, 0x2c, 0x4f, 0xf2, 0x6b, 0x5b, 0xd3, 0x8a, 0xe3, 0x91, 0xf4, 0x55, 0x95, 0x62, 0xfe, 0x18, 0xd0, 0xf8, + 0xd7, 0x14, 0xc9, 0x23, 0x03, 0x0d, 0x06, 0xa9, 0xb1, 0x62, 0x19, 0x80, 0x43, 0x0c, 0x4d, 0x44, 0x6d, 0xa0, 0x1d, + 0xc3, 0x1d, 0x8d, 0x0c, 0xa9, 0x8f, 0x68, 0x86, 0x24, 0xc0, 0x23, 0x9b, 0x98, 0xac, 0x8c, 0x5d, 0x80, 0x2b, 0x70, + 0xfb, 0x78, 0x06, 0x8d, 0xdf, 0x6e, 0xdd, 0x20, 0xa5, 0xa6, 0x9c, 0x2e, 0x02, 0xd6, 0x98, 0x00, 0x9e, 0x52, 0x4d, + 0xb4, 0x6c, 0x48, 0xf5, 0x53, 0x27, 0x60, 0xbf, 0x38, 0xa8, 0x8f, 0xad, 0x69, 0x4a, 0x59, 0x36, 0x0d, 0xbc, 0x94, + 0x34, 0x42, 0x8c, 0xd0, 0x57, 0x38, 0xe5, 0x08, 0xc4, 0x3b, 0xfc, 0xfa, 0xf4, 0x7a, 0x92, 0xde, 0x26, 0xda, 0xd8, + 0x64, 0x80, 0x61, 0xf8, 0x18, 0xe1, 0x17, 0x3d, 0xec, 0x6c, 0xcd, 0xf8, 0x6b, 0x82, 0x64, 0x3c, 0x29, 0x7c, 0x56, + 0x78, 0x36, 0xb5, 0x45, 0x93, 0x10, 0xff, 0x40, 0x74, 0x28, 0x30, 0x3a, 0x15, 0x94, 0xd9, 0x97, 0x8b, 0xea, 0x45, + 0x4e, 0x41, 0xa3, 0x7d, 0x66, 0xb9, 0xb2, 0x2c, 0x5f, 0x5f, 0xfe, 0xe3, 0x5c, 0x77, 0x5c, 0x62, 0xcf, 0x9d, 0x94, + 0xb8, 0x68, 0x65, 0xcd, 0x1f, 0x5a, 0x5b, 0x6f, 0xc9, 0x61, 0x23, 0x17, 0x9d, 0x42, 0x09, 0xff, 0xc4, 0x5f, 0x0a, + 0x82, 0x95, 0x7b, 0xb0, 0x64, 0x2a, 0xe5, 0x82, 0x8b, 0x19, 0xdd, 0x76, 0xfa, 0x5e, 0xb0, 0xd0, 0xd9, 0xd9, 0xc5, + 0x71, 0x82, 0x24, 0xe5, 0x87, 0xfc, 0x33, 0xef, 0xe2, 0x6c, 0x3b, 0xab, 0xe9, 0x68, 0x45, 0xef, 0xd8, 0xbb, 0x1c, + 0x4e, 0x6c, 0x11, 0xa5, 0xd3, 0x07, 0xe7, 0x67, 0x33, 0xf8, 0xe0, 0x28, 0x6a, 0xe9, 0x4c, 0xcd, 0x58, 0xc0, 0xb9, + 0xb9, 0x7b, 0x88, 0xa0, 0xa7, 0x90, 0x88, 0xd1, 0xf7, 0x2e, 0xa8, 0xf7, 0x8a, 0x6d, 0xce, 0x37, 0x89, 0xa0, 0xcd, + 0x0a, 0x9a, 0x45, 0xf4, 0x62, 0x78, 0x2a, 0xbc, 0x76, 0xe7, 0x5a, 0xae, 0x78, 0x5e, 0x42, 0xa3, 0x21, 0x6b, 0x90, + 0x6c, 0xbf, 0xd3, 0xc4, 0x0f, 0xfa, 0xb9, 0xd5, 0x42, 0x6d, 0x65, 0x4a, 0xfd, 0x98, 0x31, 0x4b, 0x9d, 0xb3, 0x92, + 0xfe, 0x9c, 0xfa, 0x0c, 0x6a, 0x9e, 0x6c, 0x75, 0xfa, 0x35, 0x9f, 0x5f, 0x0e, 0xd5, 0xb3, 0x99, 0xf2, 0x0e, 0x61, + 0x09, 0xf3, 0x7d, 0xa2, 0x54, 0x8f, 0xac, 0xbb, 0x25, 0xce, 0x52, 0x54, 0xc7, 0x22, 0x89, 0x22, 0x63, 0x3b, 0xc3, + 0x11, 0x7a, 0x21, 0xf1, 0x6c, 0x56, 0x67, 0xc2, 0xe4, 0x6a, 0x16, 0x6f, 0x07, 0x73, 0x25, 0x9c, 0xc4, 0x22, 0x89, + 0x50, 0xa4, 0x7d, 0x23, 0x5d, 0x4c, 0xf9, 0xa9, 0xce, 0xed, 0x48, 0xa8, 0xf4, 0x16, 0xff, 0x34, 0xb8, 0xc4, 0x44, + 0x2a, 0x50, 0x89, 0xcf, 0xef, 0x96, 0x58, 0x22, 0x49, 0x15, 0x39, 0x14, 0xd4, 0xca, 0xe4, 0x0f, 0x9b, 0xe7, 0x52, + 0x5a, 0x77, 0x47, 0xe0, 0xfa, 0x32, 0x56, 0x12, 0x77, 0xff, 0x32, 0x99, 0x47, 0x01, 0xd8, 0x2f, 0xcb, 0x75, 0x3e, + 0xc4, 0x80, 0xcb, 0xa3, 0x53, 0x8d, 0x20, 0xd8, 0xf1, 0x06, 0xde, 0x0c, 0x24, 0x08, 0x4e, 0x33, 0x12, 0x11, 0x0b, + 0xce, 0x90, 0xc5, 0x93, 0x37, 0x00, 0x24, 0xe7, 0x0f, 0xf1, 0xf3, 0x82, 0x94, 0x1d, 0xa0, 0x0a, 0x47, 0x05, 0x20, + 0x76, 0x48, 0xd0, 0xe8, 0xc2, 0xbb, 0xd9, 0x67, 0xad, 0xd9, 0xf2, 0x7a, 0x55, 0x3c, 0x07, 0x55, 0x43, 0x72, 0x52, + 0x12, 0x46, 0x9c, 0x61, 0xf6, 0x83, 0xa0, 0x44, 0xf9, 0xf6, 0x30, 0x21, 0x8c, 0xcc, 0x96, 0x78, 0xa1, 0xd1, 0x20, + 0xc0, 0xed, 0x23, 0xc4, 0x4c, 0xb6, 0x4d, 0x39, 0x26, 0x5f, 0x73, 0xc6, 0x39, 0x63, 0xce, 0x10, 0x8a, 0x06, 0x66, + 0x6b, 0x09, 0xc4, 0x3a, 0x8b, 0x32, 0x1a, 0x4a, 0x53, 0xfc, 0x4e, 0x8e, 0xa0, 0xd6, 0x91, 0xb7, 0x26, 0x43, 0xbb, + 0x0d, 0xee, 0x44, 0x80, 0x43, 0x0a, 0xf7, 0x4b, 0x60, 0x41, 0x79, 0xe5, 0xb6, 0x64, 0x96, 0xda, 0x7e, 0x48, 0xb6, + 0x92, 0xde, 0x9b, 0x81, 0xc1, 0xbb, 0x58, 0xc3, 0xc5, 0x2c, 0x1d, 0x25, 0x64, 0x15, 0x6c, 0x16, 0xeb, 0xfe, 0xe5, + 0xd7, 0x5d, 0x37, 0x19, 0xb9, 0xad, 0x92, 0xb1, 0xa2, 0x1c, 0x8f, 0xab, 0x39, 0x1b, 0x70, 0x7d, 0x19, 0xa4, 0xe1, + 0x52, 0x21, 0x74, 0xa6, 0x7d, 0xb7, 0xbf, 0x8b, 0x6b, 0xb7, 0x5c, 0x1e, 0x2d, 0xc0, 0xa0, 0x8d, 0x3d, 0x70, 0x8a, + 0x0a, 0x2c, 0x89, 0x0a, 0x49, 0xd8, 0x7c, 0x00, 0x4c, 0xb5, 0x7e, 0x10, 0xe5, 0xf8, 0x77, 0x49, 0x5f, 0x0b, 0x32, + 0x3d, 0xd7, 0x79, 0x7e, 0x96, 0xfa, 0x83, 0x69, 0xf7, 0x71, 0x8c, 0xe1, 0x8c, 0xc3, 0x1c, 0x21, 0x2a, 0x73, 0xf4, + 0xeb, 0xcf, 0xf0, 0xd8, 0xdb, 0x4a, 0xf5, 0x9f, 0x50, 0x9c, 0xdf, 0x2b, 0xa3, 0x79, 0xb6, 0x4c, 0xfa, 0x6c, 0x41, + 0xbf, 0xcf, 0x24, 0x2d, 0xdd, 0x76, 0xf9, 0xc4, 0xff, 0xa6, 0x3a, 0x3c, 0xdd, 0xed, 0x11, 0xe3, 0x22, 0x92, 0x04, + 0x9f, 0x98, 0x13, 0x9e, 0xee, 0x9a, 0x89, 0xba, 0x3c, 0x43, 0x6a, 0xf7, 0xc6, 0x68, 0x9b, 0x4a, 0xf5, 0xb6, 0xac, + 0xd8, 0xf4, 0xa2, 0x22, 0xd8, 0xd5, 0x85, 0x75, 0x79, 0xf7, 0xbb, 0x4f, 0xa9, 0x77, 0x73, 0x10, 0x6e, 0x5c, 0x6d, + 0x57, 0x35, 0x5a, 0xcc, 0x69, 0x01, 0xa5, 0x24, 0x52, 0x12, 0xcd, 0xa6, 0x71, 0xa4, 0x54, 0xf8, 0x79, 0x8e, 0x92, + 0x5b, 0x49, 0x9b, 0x5f, 0x5b, 0xc3, 0x89, 0x2a, 0xa9, 0x8e, 0xd4, 0xd4, 0x61, 0x4d, 0x7a, 0x0a, 0xcc, 0xff, 0xd9, + 0x31, 0x12, 0x82, 0xc2, 0x85, 0x33, 0x0f, 0x28, 0xf5, 0x57, 0x43, 0xb5, 0x93, 0x3e, 0x1e, 0x79, 0x7d, 0x6f, 0x1d, + 0xe7, 0x3a, 0x17, 0xce, 0x38, 0x74, 0xd3, 0xcd, 0x03, 0x3d, 0xfd, 0xae, 0xc7, 0x57, 0xf1, 0xd7, 0x86, 0x64, 0x49, + 0x22, 0x35, 0x73, 0x67, 0x7b, 0x65, 0x4b, 0xfb, 0xea, 0xa1, 0x42, 0x8b, 0xe3, 0xd2, 0x58, 0xed, 0x2b, 0xcc, 0xd3, + 0x1b, 0x35, 0x58, 0x44, 0x94, 0xa6, 0x7e, 0x38, 0x1e, 0xd2, 0x79, 0x0e, 0xd4, 0xd4, 0xe2, 0xe6, 0x29, 0xa7, 0xf5, + 0x13, 0xc6, 0xa9, 0x00, 0x3b, 0x13, 0x45, 0x2e, 0x5e, 0xab, 0xbf, 0x29, 0xfd, 0x0a, 0xf6, 0xd7, 0x2b, 0xa9, 0xfa, + 0x99, 0xc5, 0x2a, 0x9d, 0x19, 0x56, 0xe5, 0xcc, 0x9a, 0xe9, 0x0a, 0xfb, 0x39, 0x17, 0xbb, 0x1c, 0x58, 0x94, 0x24, + 0x79, 0x3a, 0xae, 0xcc, 0x22, 0x9c, 0xdb, 0x4b, 0xe7, 0x91, 0x4e, 0x9d, 0x6c, 0x30, 0x29, 0x13, 0x5a, 0x3d, 0x32, + 0x2d, 0x31, 0x32, 0x4d, 0x20, 0xd8, 0xa5, 0xb7, 0xc8, 0xd2, 0xf6, 0x8b, 0x3b, 0x16, 0x85, 0xda, 0x5c, 0x6d, 0x7a, + 0x1c, 0x85, 0x8c, 0xf9, 0xa5, 0xb5, 0xa7, 0xc4, 0xa5, 0xf3, 0x63, 0x11, 0xed, 0xa7, 0x4b, 0x75, 0xac, 0xd9, 0x89, + 0x40, 0x95, 0x6b, 0x03, 0xf9, 0x79, 0x9b, 0x1e, 0xd2, 0xe7, 0x2d, 0x9c, 0x95, 0x3f, 0x94, 0x61, 0x7d, 0x40, 0x08, + 0x13, 0x81, 0x91, 0xb1, 0x50, 0x5a, 0x49, 0x60, 0x15, 0x78, 0xc5, 0xa8, 0xd9, 0x6c, 0x57, 0x7c, 0x1f, 0x40, 0x3a, + 0xc7, 0x4d, 0x08, 0x07, 0x80, 0xbc, 0x9e, 0x42, 0x75, 0x16, 0xa2, 0x40, 0x33, 0x05, 0x48, 0xf8, 0x21, 0x3d, 0x7f, + 0x01, 0xf3, 0xc7, 0x74, 0xf4, 0x56, 0xad, 0xdc, 0x46, 0x3b, 0x1c, 0xcb, 0x53, 0xe5, 0xa6, 0x1a, 0x87, 0x8b, 0x92, + 0xa8, 0x24, 0x16, 0x35, 0xbc, 0x72, 0x45, 0x9b, 0x33, 0x1f, 0xf9, 0x0d, 0xdb, 0xc4, 0xe3, 0x5f, 0x57, 0x63, 0x5c, + 0x81, 0xaa, 0x51, 0x05, 0x5b, 0xf2, 0x05, 0x98, 0xea, 0x2e, 0x12, 0xd8, 0x62, 0xd3, 0xd8, 0x9c, 0x81, 0x0e, 0xed, + 0xa3, 0xec, 0x49, 0xa9, 0x4a, 0x16, 0xa8, 0xe4, 0x6a, 0x29, 0xac, 0xb6, 0xa6, 0x51, 0x9b, 0x90, 0xf7, 0xbf, 0xa1, + 0x79, 0xeb, 0x4b, 0x3e, 0x61, 0x7b, 0x88, 0xe8, 0x33, 0x7c, 0xee, 0xa3, 0x5a, 0x7c, 0x0f, 0x28, 0x9c, 0x2d, 0x05, + 0x23, 0x53, 0x1c, 0xda, 0xe3, 0x05, 0x4a, 0x93, 0x79, 0x78, 0xa8, 0xa3, 0x0a, 0x1b, 0xf2, 0x11, 0x0e, 0xd8, 0x7e, + 0x4c, 0x61, 0x89, 0x0a, 0x25, 0xfa, 0x2e, 0xda, 0xcd, 0xc1, 0x77, 0xa5, 0x03, 0xde, 0x96, 0x21, 0x2e, 0xa6, 0x9b, + 0x9d, 0x78, 0x8b, 0x96, 0xe5, 0xab, 0x38, 0xd8, 0x66, 0x84, 0xa1, 0x6c, 0x0a, 0x70, 0xe7, 0xbd, 0xaa, 0x50, 0xe4, + 0xf8, 0xd6, 0x0c, 0x8e, 0xea, 0x0d, 0xd2, 0x45, 0x13, 0xa0, 0x0e, 0x46, 0x3d, 0xf0, 0x13, 0x82, 0x1c, 0x50, 0x19, + 0xbd, 0xdb, 0xa2, 0x2d, 0xae, 0x05, 0xcf, 0x84, 0x80, 0x34, 0xad, 0x48, 0xb5, 0x1b, 0xa5, 0x51, 0x1f, 0x0d, 0xcd, + 0xbe, 0x89, 0x45, 0x02, 0x90, 0xcc, 0xe2, 0x55, 0x49, 0xa4, 0x02, 0xd8, 0x02, 0x3b, 0x36, 0x8b, 0x6e, 0xf8, 0x66, + 0x7d, 0x32, 0x60, 0x68, 0xe9, 0xb5, 0xef, 0xc9, 0xea, 0xa3, 0xf6, 0xb9, 0x86, 0x78, 0xc5, 0x71, 0x8e, 0x34, 0x99, + 0x2a, 0xea, 0x7c, 0xb2, 0x8e, 0xf2, 0x58, 0x9b, 0xcb, 0xe5, 0x8d, 0x0d, 0x65, 0xd0, 0x63, 0x83, 0x45, 0x4a, 0x5c, + 0x3b, 0x66, 0xbf, 0xbe, 0xb8, 0xc8, 0xa0, 0xe3, 0x9c, 0x3e, 0x90, 0x30, 0x4d, 0x27, 0x11, 0xea, 0x8e, 0x95, 0xaf, + 0xab, 0xd0, 0x2c, 0x08, 0xfb, 0xfe, 0x22, 0x19, 0x6b, 0xd8, 0x78, 0x37, 0x64, 0x73, 0x7d, 0xd5, 0xde, 0x0f, 0x50, + 0x07, 0xe2, 0x62, 0xc0, 0xc5, 0x5b, 0x50, 0xc6, 0xcc, 0xbf, 0xa3, 0x5e, 0x2b, 0xa5, 0x34, 0x6a, 0x79, 0x18, 0x6a, + 0x78, 0xab, 0xbd, 0xcc, 0x7f, 0x3c, 0xfb, 0x90, 0x0f, 0x05, 0x2a, 0x54, 0x21, 0x35, 0x4d, 0xa2, 0x6e, 0xd7, 0x41, + 0x6c, 0x6b, 0x27, 0x99, 0x5a, 0xb1, 0x88, 0x94, 0x47, 0x80, 0xbb, 0x70, 0x78, 0xb7, 0xfa, 0x85, 0x11, 0xdf, 0xec, + 0x73, 0x2d, 0xb4, 0x25, 0x9a, 0xb3, 0x23, 0xde, 0x45, 0x2b, 0x3b, 0x9c, 0x5a, 0x20, 0x1d, 0x3b, 0x15, 0xdb, 0x25, + 0x8a, 0xde, 0x63, 0x81, 0xad, 0x66, 0x6b, 0xeb, 0xb7, 0x56, 0xf4, 0x21, 0xac, 0x16, 0xb4, 0xb6, 0xe7, 0x32, 0x8d, + 0xcd, 0xc4, 0x09, 0x62, 0x01, 0x34, 0x7b, 0xfb, 0xaa, 0x24, 0xef, 0x33, 0x0b, 0x2e, 0x4b, 0xb1, 0x44, 0x8a, 0xb0, + 0x03, 0x3a, 0x89, 0x06, 0x4c, 0x54, 0x05, 0xc7, 0x46, 0xec, 0xf9, 0xa2, 0xde, 0x37, 0xae, 0x4a, 0x32, 0x28, 0x93, + 0xd6, 0x6d, 0xd5, 0x8b, 0xc9, 0xf7, 0x7e, 0x16, 0x48, 0x3e, 0x14, 0x0e, 0x60, 0xc7, 0x25, 0x5c, 0x7c, 0x16, 0x8c, + 0xdc, 0x2a, 0x65, 0x2d, 0xc0, 0x9c, 0xce, 0x99, 0xbf, 0x5a, 0x7a, 0x34, 0x2d, 0x29, 0x27, 0x0e, 0xd3, 0xf7, 0xe7, + 0x10, 0xc9, 0x15, 0x48, 0x3f, 0xef, 0x3d, 0xef, 0x15, 0x7d, 0xe3, 0x8f, 0x57, 0xfb, 0x94, 0x19, 0xcd, 0xa6, 0x2c, + 0xf5, 0x64, 0xc9, 0xd3, 0x2d, 0x15, 0x1c, 0xa3, 0x8b, 0x56, 0x37, 0x6c, 0xcd, 0x8a, 0x35, 0x23, 0xcb, 0xf0, 0x8f, + 0x60, 0x85, 0x6f, 0x60, 0x5d, 0x2c, 0x01, 0xcd, 0xdf, 0x18, 0x1f, 0x85, 0x3c, 0x2e, 0x3e, 0xd0, 0xf9, 0x19, 0x21, + 0xae, 0xc2, 0x54, 0x91, 0x70, 0xbe, 0x55, 0x6a, 0xa5, 0x04, 0x15, 0xd3, 0xf2, 0x99, 0x16, 0xdf, 0xa8, 0x6d, 0x95, + 0xd9, 0x5b, 0x7e, 0x99, 0xe4, 0xca, 0x74, 0x7e, 0x9e, 0x9c, 0x49, 0xf1, 0xf2, 0xc3, 0x12, 0x55, 0xe6, 0x9f, 0x46, + 0x68, 0xa3, 0xef, 0xe1, 0xc7, 0x0e, 0x3f, 0xc8, 0xbc, 0x40, 0x24, 0xd5, 0xb8, 0xc0, 0x38, 0x2a, 0x3f, 0x4d, 0xab, + 0x11, 0x33, 0x45, 0xf8, 0xc6, 0xa9, 0x03, 0xcb, 0xf7, 0xb9, 0x54, 0x73, 0x2e, 0x42, 0x05, 0x10, 0x7b, 0x1a, 0x3b, + 0xef, 0xc2, 0x9c, 0x31, 0x15, 0x09, 0x84, 0x71, 0x85, 0x76, 0x49, 0x30, 0x76, 0x4b, 0xa9, 0xb6, 0xd5, 0xbb, 0x05, + 0xf3, 0x9a, 0x8a, 0x08, 0x98, 0xc2, 0x3b, 0xd0, 0xbc, 0x99, 0x2d, 0x6d, 0xd0, 0x39, 0xb1, 0xa3, 0x02, 0xfb, 0x31, + 0xa6, 0xbc, 0xc3, 0xde, 0x6f, 0xa6, 0xcf, 0x19, 0xe7, 0xd0, 0x3d, 0x0f, 0xf5, 0xa6, 0x33, 0x5c, 0xf9, 0x86, 0x3e, + 0x9b, 0x11, 0x67, 0x0b, 0x24, 0x5f, 0x23, 0x5b, 0xb1, 0xae, 0x5a, 0x82, 0xba, 0x07, 0x92, 0xbd, 0x7d, 0x75, 0xdd, + 0x5b, 0x7d, 0x2e, 0x08, 0x1a, 0xdd, 0xad, 0x00, 0xbb, 0x83, 0x05, 0xef, 0x56, 0x67, 0xe2, 0x89, 0x03, 0x80, 0xec, + 0xd2, 0x7f, 0x12, 0x36, 0xd0, 0x9d, 0x76, 0x7f, 0xed, 0x84, 0xb2, 0xa0, 0x75, 0x36, 0xe5, 0x31, 0xb4, 0x65, 0x17, + 0x11, 0x43, 0x76, 0x1d, 0xf6, 0xac, 0x9b, 0xfb, 0x42, 0x58, 0x81, 0xc7, 0x3d, 0xb0, 0xbe, 0x08, 0x7c, 0x4a, 0x04, + 0x24, 0xe4, 0x5c, 0x88, 0xbf, 0x75, 0xa1, 0x66, 0x19, 0x77, 0x9b, 0x0e, 0xb1, 0x9b, 0x24, 0xf4, 0x07, 0x55, 0xe1, + 0xad, 0xa5, 0x95, 0xcf, 0x02, 0xca, 0x7c, 0x24, 0x23, 0x03, 0xe7, 0xdc, 0xd8, 0x9e, 0x76, 0x5e, 0x9a, 0x31, 0x2f, + 0x15, 0x5a, 0x66, 0xf2, 0x6e, 0xd5, 0xc0, 0xb3, 0xf6, 0xbf, 0x9b, 0xe3, 0xc4, 0x86, 0xe6, 0xb1, 0x1d, 0x73, 0xb4, + 0xbd, 0x18, 0xf7, 0x2d, 0xfb, 0xea, 0xe5, 0x32, 0x2e, 0x9b, 0x67, 0xbd, 0x5b, 0xbb, 0x55, 0xec, 0xa7, 0x88, 0x0a, + 0x9b, 0xc2, 0x64, 0xaa, 0x49, 0x0c, 0x83, 0xc0, 0x68, 0x01, 0xec, 0x4d, 0x34, 0xc3, 0x2e, 0xe6, 0xa0, 0xb9, 0x34, + 0xeb, 0x6e, 0xf6, 0x38, 0x7d, 0x9b, 0xf9, 0x4a, 0xd5, 0x5e, 0x55, 0xa3, 0x44, 0xce, 0xe9, 0xb0, 0x7f, 0x29, 0xed, + 0x3f, 0x8a, 0xbc, 0xa9, 0x61, 0x2c, 0x0e, 0x44, 0x63, 0x01, 0xc1, 0x65, 0x7a, 0xab, 0xcd, 0xb2, 0x08, 0xc9, 0xa9, + 0x15, 0xe5, 0x1f, 0x34, 0x80, 0x54, 0x5c, 0xad, 0x16, 0x37, 0xe3, 0x58, 0x70, 0x8c, 0x4a, 0x6d, 0x0c, 0x4f, 0xff, + 0x24, 0x1e, 0x52, 0xd1, 0x56, 0x97, 0x13, 0xcd, 0x4b, 0xb5, 0xe5, 0x10, 0x40, 0x20, 0x57, 0x1b, 0xd6, 0x38, 0xf4, + 0x57, 0x27, 0x73, 0x23, 0xd3, 0x61, 0x66, 0xaa, 0xc0, 0xf8, 0x5b, 0x45, 0x53, 0x30, 0x39, 0x17, 0x49, 0xcc, 0xdc, + 0xce, 0xc0, 0xb2, 0x06, 0xe8, 0x20, 0x7a, 0xc3, 0xb7, 0x93, 0x1f, 0xea, 0x4f, 0x2b, 0x8b, 0x22, 0x4e, 0x1d, 0x93, + 0xd3, 0xd7, 0x76, 0x50, 0x50, 0xab, 0xed, 0x5c, 0xc4, 0x6b, 0x9e, 0x13, 0x68, 0x5f, 0xf9, 0xd5, 0xec, 0xf4, 0xfa, + 0x85, 0xd3, 0xef, 0x90, 0x15, 0x48, 0x9d, 0xe2, 0x5f, 0xba, 0x32, 0xca, 0xd5, 0xce, 0x79, 0x36, 0xfd, 0xf2, 0x98, + 0x24, 0xdb, 0xc6, 0xbf, 0x46, 0x2e, 0x39, 0x20, 0xf9, 0x13, 0xe7, 0xc0, 0xc8, 0x16, 0xd3, 0x24, 0x61, 0xaa, 0xd7, + 0x24, 0xcd, 0x59, 0x58, 0xc7, 0x6e, 0x3a, 0xfe, 0x73, 0xec, 0xa2, 0x27, 0x91, 0x90, 0x5a, 0x6f, 0x69, 0xa4, 0x85, + 0x75, 0xef, 0x8c, 0x5c, 0xc8, 0xe6, 0xa1, 0x4c, 0x01, 0x19, 0xd3, 0xcd, 0xba, 0x4b, 0x25, 0x12, 0xb5, 0x60, 0x69, + 0x68, 0xb7, 0x93, 0xe1, 0x10, 0xb5, 0xf6, 0x91, 0xec, 0x54, 0xf4, 0x2e, 0x54, 0x85, 0xa1, 0x8e, 0xe4, 0x4b, 0x61, + 0x25, 0x16, 0x58, 0x7b, 0x29, 0xd7, 0x92, 0x05, 0x5d, 0x79, 0x79, 0x24, 0x14, 0xeb, 0x00, 0xb6, 0xd6, 0xa5, 0xd1, + 0x0d, 0xa0, 0x13, 0xc5, 0xc0, 0x75, 0xc8, 0x00, 0x94, 0x31, 0x85, 0xca, 0x2d, 0x2d, 0x2e, 0xb9, 0x16, 0xa5, 0x98, + 0x03, 0x52, 0xbf, 0xc6, 0xe0, 0x8c, 0xf9, 0xbd, 0x8f, 0x29, 0xc4, 0x91, 0x31, 0xbc, 0x6a, 0x49, 0xda, 0x32, 0xd7, + 0xd6, 0x8a, 0x69, 0x9d, 0x30, 0x75, 0x96, 0xfd, 0x34, 0xf8, 0xce, 0xbf, 0xa3, 0x8e, 0xb4, 0xbc, 0xc5, 0x91, 0x8a, + 0x70, 0x68, 0x7b, 0x62, 0x2e, 0x4c, 0x29, 0x3c, 0x66, 0xb7, 0x77, 0x84, 0x6e, 0x7a, 0x29, 0xe0, 0xb1, 0x70, 0x63, + 0x2a, 0x30, 0x8e, 0x1e, 0x3f, 0x14, 0x4e, 0x84, 0xe1, 0xd0, 0x54, 0x9d, 0xf0, 0x6e, 0x9a, 0x32, 0x0b, 0x72, 0x6a, + 0x24, 0x6c, 0x78, 0xb0, 0xee, 0x07, 0x50, 0x14, 0x09, 0x69, 0x16, 0x57, 0x8d, 0x26, 0x8a, 0xeb, 0x8a, 0x0b, 0xbb, + 0x2f, 0xc7, 0xf9, 0x45, 0x25, 0x0e, 0xdd, 0xb3, 0xaa, 0x63, 0x8b, 0xc4, 0x67, 0x53, 0x55, 0x46, 0x44, 0xd5, 0x7b, + 0x09, 0x81, 0xb9, 0xad, 0xa5, 0x1b, 0x7f, 0xec, 0x0a, 0x57, 0x06, 0x0f, 0x0c, 0x21, 0xd2, 0xf4, 0x6a, 0x5d, 0xa2, + 0xe4, 0xed, 0xea, 0x0f, 0xfb, 0x61, 0xfd, 0xc1, 0xd8, 0x64, 0x07, 0xb7, 0x0a, 0xa4, 0xcd, 0x39, 0xbf, 0x66, 0xa6, + 0xb5, 0x6c, 0xb5, 0x0f, 0x6a, 0x94, 0x07, 0x9b, 0xcb, 0x34, 0x14, 0xf3, 0x4f, 0xef, 0x0c, 0x1f, 0x9c, 0x70, 0x91, + 0xf8, 0x02, 0x12, 0x71, 0xd8, 0x9e, 0x3e, 0x3e, 0x52, 0xf9, 0x5b, 0x27, 0x54, 0xd8, 0x8d, 0x52, 0xb6, 0x83, 0xf2, + 0xbe, 0x3a, 0xdc, 0x13, 0x13, 0x35, 0xd8, 0x67, 0x97, 0xa5, 0xa3, 0x01, 0x92, 0x94, 0x26, 0xf6, 0x25, 0x8e, 0xf7, + 0xc5, 0x0c, 0xeb, 0x05, 0x22, 0x5e, 0x75, 0xb2, 0x14, 0x4a, 0xa6, 0xec, 0xf9, 0xec, 0x78, 0x1d, 0x64, 0xf2, 0x11, + 0x55, 0x1d, 0xd2, 0xdc, 0xd4, 0x72, 0x97, 0x13, 0x03, 0xdd, 0x6b, 0xd3, 0x9f, 0xdf, 0x37, 0x86, 0x6c, 0x2b, 0x91, + 0x6f, 0x7c, 0x7b, 0xd4, 0x3f, 0xbd, 0x7e, 0xa1, 0x21, 0xd9, 0x9b, 0x65, 0xec, 0x6e, 0x7f, 0xb8, 0x2c, 0xea, 0xa8, + 0xea, 0x07, 0x55, 0x30, 0x4b, 0xea, 0xa9, 0xe9, 0x2c, 0xa4, 0x04, 0x13, 0x0e, 0x04, 0x9c, 0xb5, 0x1e, 0x84, 0xaa, + 0xcb, 0xbf, 0xb6, 0x57, 0x57, 0xbb, 0xf1, 0x62, 0xe1, 0x69, 0x64, 0x23, 0x31, 0xd4, 0x61, 0xe9, 0x3b, 0xb3, 0x85, + 0xf0, 0x0c, 0xbf, 0xef, 0x6a, 0x24, 0x2e, 0x35, 0x00, 0x5f, 0x2f, 0xdf, 0x9d, 0xfb, 0xe1, 0xf0, 0x21, 0xb0, 0x17, + 0xcc, 0x8c, 0xf7, 0x59, 0x69, 0x8a, 0x25, 0x0d, 0x3f, 0x46, 0x36, 0xb3, 0xae, 0x7d, 0x12, 0x82, 0x08, 0xac, 0x21, + 0x42, 0x95, 0x87, 0x66, 0x0e, 0x65, 0xac, 0x1c, 0xab, 0x68, 0xed, 0xd9, 0x6f, 0x30, 0x25, 0xb2, 0xd9, 0x22, 0xa0, + 0x23, 0xfb, 0x7e, 0x79, 0x51, 0xcb, 0xf0, 0xba, 0x7f, 0x79, 0xf8, 0x22, 0x17, 0xb5, 0x59, 0x03, 0xf8, 0x3b, 0x92, + 0xd5, 0xb2, 0x37, 0x96, 0x5f, 0xe8, 0x14, 0x6c, 0xb5, 0x39, 0x30, 0x22, 0x92, 0x36, 0x8c, 0xb8, 0x20, 0x99, 0x33, + 0x31, 0x15, 0x42, 0x96, 0x1e, 0xf7, 0xf1, 0x32, 0x05, 0xc0, 0xe9, 0x72, 0x65, 0xc4, 0x05, 0x81, 0x90, 0x8e, 0xc3, + 0x98, 0x16, 0xd2, 0xb2, 0x9e, 0xed, 0x42, 0xb3, 0x51, 0xa3, 0xd0, 0x35, 0x87, 0x44, 0x8d, 0x99, 0x75, 0x8f, 0x43, + 0x5c, 0x6a, 0x3b, 0x21, 0x2b, 0xbf, 0xb9, 0x9a, 0x01, 0xd0, 0x98, 0x48, 0x2e, 0x97, 0xc3, 0x44, 0x96, 0x98, 0xcf, + 0x98, 0xb4, 0xe9, 0xeb, 0xc3, 0x37, 0x31, 0x3d, 0x43, 0xec, 0x1a, 0xeb, 0x0f, 0xd1, 0xf2, 0xdc, 0x8b, 0x10, 0xd4, + 0xba, 0x6c, 0xd9, 0xa3, 0x68, 0x2b, 0x64, 0xa2, 0x6d, 0x49, 0xd8, 0x02, 0x0d, 0xec, 0x33, 0x9e, 0x0d, 0x97, 0x83, + 0x28, 0x4b, 0x40, 0x6a, 0x29, 0x87, 0xfc, 0x1a, 0xed, 0x11, 0x62, 0x0c, 0x16, 0xac, 0x81, 0xe5, 0xbe, 0xe1, 0x30, + 0x0a, 0x12, 0xec, 0x81, 0xff, 0xbf, 0x20, 0x96, 0xab, 0x6f, 0x27, 0x7b, 0x5e, 0x57, 0x25, 0xda, 0x06, 0x03, 0xe0, + 0xa0, 0xe3, 0x11, 0x06, 0x8d, 0x6b, 0x1a, 0xa8, 0xae, 0x27, 0x97, 0x0b, 0x33, 0x36, 0x55, 0x90, 0x7a, 0x06, 0xdc, + 0x12, 0x6e, 0xfb, 0x59, 0xc6, 0x1c, 0x0c, 0x6c, 0x9c, 0xdd, 0x8d, 0xed, 0x1a, 0x43, 0xf0, 0xe8, 0x04, 0xed, 0x74, + 0xa7, 0x84, 0x3c, 0xaf, 0x1f, 0xad, 0xd5, 0xb0, 0xc3, 0xe7, 0xad, 0x69, 0xcf, 0x23, 0xcc, 0x88, 0xb8, 0x69, 0xba, + 0x60, 0x63, 0x29, 0xc1, 0x52, 0xa4, 0x88, 0x01, 0x6c, 0x47, 0xd9, 0x0d, 0x80, 0x16, 0xd8, 0x1f, 0xca, 0x6b, 0x8d, + 0x1e, 0x3d, 0x1b, 0x3e, 0xc7, 0xa8, 0xea, 0x32, 0x87, 0x91, 0x7a, 0xee, 0x50, 0x37, 0x1e, 0x78, 0x7e, 0xaa, 0xd6, + 0x28, 0x14, 0x8a, 0x25, 0x70, 0xf4, 0xf3, 0x7d, 0x1a, 0x89, 0x67, 0x99, 0x21, 0xec, 0xe4, 0x66, 0xf3, 0x04, 0xc4, + 0x3e, 0x34, 0x32, 0x21, 0x80, 0x10, 0x2c, 0x84, 0xd5, 0x1e, 0x50, 0xce, 0xdf, 0x13, 0xf6, 0x7d, 0x44, 0xc7, 0x4d, + 0x80, 0x07, 0x53, 0x50, 0x9c, 0xac, 0x7d, 0x2a, 0x22, 0x52, 0xf9, 0x49, 0x92, 0x6c, 0xc6, 0x49, 0x9d, 0x04, 0x66, + 0x47, 0x9c, 0x92, 0xa5, 0x58, 0x38, 0x2f, 0x9e, 0x70, 0x60, 0xd3, 0x35, 0x05, 0x4c, 0x27, 0xbe, 0xc8, 0x49, 0xd9, + 0x0c, 0x5a, 0x38, 0x1f, 0xe7, 0xb6, 0x8d, 0x05, 0x47, 0x65, 0x19, 0x3b, 0x7b, 0xab, 0xc6, 0x08, 0x1d, 0xf6, 0x4d, + 0x82, 0x7a, 0x3f, 0xa6, 0xb0, 0x76, 0xda, 0xe3, 0x23, 0x26, 0xc1, 0xa1, 0x42, 0xe8, 0x26, 0xa8, 0x59, 0xa5, 0x3f, + 0xea, 0x8e, 0x39, 0x35, 0x92, 0xa4, 0x3c, 0x2e, 0x37, 0x24, 0xa9, 0x93, 0x7d, 0xf6, 0x68, 0x4f, 0x1e, 0x28, 0x9c, + 0x26, 0x3c, 0xd1, 0x95, 0x02, 0x06, 0xc1, 0x8b, 0x04, 0xbb, 0xba, 0x2c, 0x14, 0xc9, 0x40, 0x16, 0x43, 0xbb, 0x01, + 0x67, 0x57, 0xe6, 0x94, 0x84, 0x7c, 0xe6, 0x0b, 0x9e, 0xd9, 0x6e, 0x86, 0xe8, 0x26, 0x5b, 0xd4, 0x90, 0x51, 0x30, + 0xb4, 0x5b, 0x28, 0x22, 0x74, 0xeb, 0xc2, 0xdf, 0xe1, 0x0f, 0xcf, 0x52, 0xd9, 0x5c, 0x70, 0x9d, 0x2e, 0xbc, 0xc6, + 0x5f, 0x7a, 0xd6, 0x8a, 0x9d, 0x6f, 0xad, 0x9d, 0x4b, 0x96, 0x8b, 0x5e, 0xf3, 0x1f, 0xb9, 0xc7, 0x05, 0x3a, 0xb1, + 0x05, 0xd1, 0x86, 0x26, 0xa8, 0x0c, 0xa7, 0x81, 0x0b, 0x0f, 0x14, 0x52, 0x7b, 0x1c, 0x96, 0xb2, 0x45, 0xf4, 0x93, + 0x79, 0xae, 0xae, 0xc1, 0x22, 0x31, 0x6b, 0xa5, 0xe8, 0x45, 0x53, 0xa1, 0x88, 0x8c, 0xae, 0x06, 0xa2, 0x54, 0x97, + 0x43, 0x9a, 0x02, 0x91, 0x53, 0x92, 0x78, 0x25, 0x73, 0x06, 0x45, 0x3e, 0xe8, 0x45, 0xff, 0x8b, 0x13, 0x51, 0x0f, + 0xf9, 0xfc, 0x27, 0x55, 0x3e, 0xcb, 0xa2, 0x7e, 0x14, 0x76, 0x7d, 0x19, 0x9b, 0x6c, 0x18, 0x03, 0x18, 0x34, 0xcc, + 0x21, 0xbb, 0x18, 0xd9, 0xaa, 0x76, 0xdd, 0x0c, 0x92, 0x73, 0x43, 0x7e, 0x36, 0x73, 0xc0, 0xfc, 0xfe, 0x5b, 0x28, + 0x1b, 0xbc, 0xc4, 0x8c, 0xc3, 0x7d, 0xe4, 0x27, 0x6f, 0x22, 0x0b, 0xfe, 0x70, 0x1a, 0x3a, 0x40, 0xd3, 0x21, 0xd4, + 0xe6, 0x8a, 0x09, 0x33, 0x03, 0x9b, 0xb2, 0x20, 0xa6, 0x45, 0x4f, 0x89, 0x1a, 0xff, 0xbd, 0x7f, 0xd6, 0x00, 0x34, + 0x7b, 0xe4, 0xcf, 0xd6, 0xe8, 0x40, 0xb7, 0xea, 0xd2, 0x47, 0xf7, 0x26, 0x99, 0x06, 0x00, 0x97, 0xdb, 0xeb, 0xb5, + 0xd8, 0x6e, 0xa7, 0x55, 0xc8, 0x3e, 0x98, 0xe1, 0xc6, 0xf1, 0x94, 0x9c, 0xb7, 0x29, 0x1b, 0x0b, 0x84, 0xa7, 0xcc, + 0x0a, 0x12, 0xbb, 0x6f, 0xdd, 0xb3, 0xb2, 0x7f, 0x8c, 0xff, 0xa5, 0xf1, 0xcb, 0x22, 0x3f, 0xdf, 0x6e, 0xa5, 0x12, + 0x78, 0xa5, 0x9f, 0xd1, 0x7b, 0x17, 0xc0, 0x72, 0x07, 0x91, 0x8c, 0x96, 0xf7, 0xd4, 0xa2, 0xea, 0xa9, 0x5f, 0x64, + 0xab, 0x71, 0xe3, 0xc4, 0x8e, 0xf2, 0xe6, 0xf3, 0x82, 0x8d, 0x40, 0xc5, 0xc3, 0x6b, 0x46, 0x98, 0xfe, 0x7d, 0x32, + 0x71, 0xea, 0x1d, 0x3b, 0x7b, 0x8f, 0x20, 0xeb, 0x89, 0xed, 0xdb, 0xb3, 0x2c, 0xfe, 0x1f, 0x8b, 0x93, 0x75, 0x02, + 0x4f, 0x0d, 0x82, 0xac, 0xfb, 0xcc, 0x0b, 0x2b, 0x40, 0x65, 0xf7, 0x28, 0xe3, 0xcb, 0xc3, 0xd0, 0x7f, 0xfd, 0xcc, + 0x19, 0x35, 0xba, 0x70, 0x8a, 0xe1, 0x9c, 0xa2, 0x31, 0x84, 0xe3, 0x8f, 0x4f, 0x27, 0xbd, 0xb8, 0x67, 0xfc, 0xa7, + 0x49, 0x2f, 0xac, 0xea, 0x35, 0x6d, 0x48, 0x1c, 0xff, 0xb0, 0xf9, 0x9b, 0x45, 0x1e, 0xec, 0x7c, 0xb5, 0x42, 0x8a, + 0xac, 0x0b, 0xa9, 0x4e, 0xab, 0x56, 0x55, 0x17, 0x03, 0xce, 0xd9, 0x1f, 0x8b, 0x97, 0x3a, 0xbb, 0x5f, 0xf4, 0x3f, + 0x9a, 0x79, 0x4d, 0xeb, 0xa3, 0x0f, 0xee, 0xa6, 0x50, 0x35, 0xfb, 0x19, 0xdd, 0x3b, 0xbd, 0xa3, 0x9c, 0xb2, 0x99, + 0x4b, 0x7c, 0xee, 0xab, 0xa5, 0xe7, 0x09, 0xb7, 0x16, 0x1a, 0x99, 0xa1, 0x3b, 0x75, 0x8f, 0xe0, 0x52, 0x24, 0x4d, + 0xcb, 0xde, 0xc2, 0x35, 0x13, 0xe9, 0x4c, 0x7f, 0x76, 0x92, 0xd2, 0x9b, 0xce, 0x67, 0x35, 0x45, 0xcc, 0xaf, 0x88, + 0x99, 0x71, 0x96, 0x04, 0x4f, 0x21, 0x22, 0xd0, 0xda, 0x8a, 0xf2, 0xa9, 0xa2, 0xba, 0xe2, 0x57, 0xbf, 0x9e, 0x65, + 0x81, 0x9f, 0x99, 0x4d, 0x75, 0x2b, 0x57, 0xf4, 0xd1, 0x69, 0x9e, 0xe5, 0x3a, 0x76, 0x20, 0x67, 0x1b, 0xe0, 0xc0, + 0xfe, 0x4d, 0x47, 0x30, 0xac, 0xad, 0xb9, 0x3f, 0x12, 0xbd, 0x31, 0x0a, 0xfe, 0x42, 0x00, 0x46, 0xa4, 0x68, 0xc3, + 0x3e, 0xda, 0x42, 0x17, 0x32, 0xaa, 0xf7, 0x27, 0x6e, 0xff, 0xbc, 0x71, 0xbd, 0xf3, 0x6b, 0xa7, 0x35, 0xa7, 0x54, + 0xe6, 0xe9, 0x74, 0xb4, 0x91, 0xdd, 0xf5, 0xb0, 0x0c, 0xf2, 0x5b, 0xbe, 0xd0, 0xe8, 0xc5, 0x2f, 0x1d, 0x6c, 0x69, + 0xf9, 0x11, 0xa9, 0x7a, 0x92, 0x08, 0xe4, 0x58, 0xcb, 0xc3, 0xab, 0xb9, 0x23, 0x95, 0x0a, 0x1c, 0xd5, 0x3d, 0x19, + 0xf9, 0x66, 0x4e, 0xd9, 0xb5, 0xa4, 0x1d, 0xc1, 0xc6, 0xb0, 0x6c, 0xbe, 0xe6, 0xd2, 0x2c, 0xb5, 0x5e, 0xd9, 0xb3, + 0x13, 0xe1, 0x05, 0x8b, 0x57, 0x62, 0x9b, 0x82, 0xcb, 0xaf, 0xc6, 0x92, 0xb9, 0x79, 0x3d, 0x91, 0x80, 0x59, 0xe6, + 0xd2, 0x6e, 0xf2, 0x19, 0xe9, 0x4a, 0xfd, 0x39, 0x2c, 0x4c, 0x9f, 0x7c, 0x63, 0x31, 0x41, 0xdb, 0xaa, 0x55, 0xb9, + 0xf2, 0x1c, 0xdf, 0xd0, 0xa4, 0xd8, 0x3b, 0xda, 0x33, 0xe9, 0x21, 0x1c, 0x89, 0xc1, 0xcd, 0xbc, 0xa5, 0x92, 0x32, + 0x8d, 0x63, 0x27, 0x49, 0xff, 0x55, 0x5f, 0x86, 0x49, 0x82, 0x83, 0x58, 0xfd, 0x07, 0xd5, 0x98, 0x01, 0x87, 0xd4, + 0x47, 0x27, 0x2a, 0x82, 0xd1, 0x4c, 0x21, 0xba, 0x41, 0xfd, 0x4a, 0x9d, 0x88, 0x67, 0x2f, 0x56, 0x38, 0xe9, 0xcb, + 0x1c, 0x69, 0x5e, 0xf8, 0x8e, 0xdd, 0x3e, 0x32, 0x80, 0x46, 0x61, 0x6e, 0x8c, 0x81, 0x5d, 0xd6, 0xa4, 0x2d, 0x05, + 0x37, 0x7a, 0x03, 0x4d, 0xe0, 0xe6, 0x3d, 0x9d, 0x85, 0x3e, 0x17, 0xe9, 0xc4, 0xe2, 0x8e, 0x76, 0x31, 0xb9, 0xd6, + 0x7c, 0x5d, 0xb0, 0x0b, 0xf9, 0xbb, 0xb9, 0x56, 0xde, 0xb6, 0x69, 0x2e, 0x54, 0x20, 0xc8, 0x51, 0xe0, 0x94, 0xcb, + 0x7b, 0xa2, 0x46, 0xc7, 0xc1, 0xeb, 0xd4, 0x86, 0xd2, 0x1f, 0xf8, 0x75, 0x10, 0x88, 0xce, 0x7e, 0xd0, 0xa6, 0xdf, + 0xb7, 0x54, 0x85, 0x59, 0xd4, 0x43, 0x2c, 0x89, 0x49, 0x77, 0x77, 0xeb, 0xa3, 0x8e, 0xcf, 0xea, 0x1a, 0xb7, 0xf0, + 0x12, 0x83, 0x2b, 0x38, 0x42, 0xab, 0x58, 0x48, 0x9e, 0x81, 0x4f, 0xb7, 0xb0, 0xf1, 0x63, 0xe6, 0x6e, 0x47, 0xe4, + 0xfe, 0xea, 0x7d, 0xc5, 0x91, 0xdd, 0x62, 0xac, 0x9e, 0x3c, 0x45, 0xec, 0x1d, 0xad, 0x32, 0xc3, 0x95, 0x6b, 0xde, + 0x2b, 0xdc, 0xf6, 0x9e, 0x4f, 0xf1, 0xc0, 0x0c, 0x02, 0x7b, 0x46, 0xcc, 0x8e, 0xb1, 0x7e, 0x6d, 0xd8, 0xdb, 0xbe, + 0x73, 0x5d, 0x0a, 0x18, 0xb5, 0x2e, 0xe8, 0x83, 0x20, 0xbe, 0xcf, 0x0c, 0x58, 0x7b, 0x0e, 0xcc, 0xde, 0xe8, 0x8e, + 0xdb, 0x24, 0xec, 0x4a, 0x7d, 0x3c, 0x3e, 0x64, 0xbd, 0x2b, 0x3d, 0x2a, 0x45, 0x1f, 0x05, 0x2e, 0x9a, 0x00, 0x31, + 0x07, 0x47, 0xb2, 0x17, 0x7b, 0xf2, 0x89, 0x98, 0x0b, 0x91, 0x8b, 0x66, 0xb8, 0x07, 0x04, 0x23, 0x87, 0x15, 0xb6, + 0xff, 0x88, 0xd2, 0x86, 0x87, 0x5b, 0x2c, 0x64, 0x98, 0xf3, 0x1a, 0xd7, 0xdd, 0xfd, 0x3b, 0x60, 0xce, 0x5d, 0xbd, + 0x45, 0xdf, 0xe9, 0x31, 0x28, 0xbd, 0x4f, 0x83, 0xa8, 0x55, 0xe4, 0x1e, 0x5e, 0x84, 0xf0, 0xba, 0xc8, 0x8b, 0x46, + 0x20, 0xdd, 0x1d, 0x86, 0xe1, 0x57, 0x10, 0x31, 0x7d, 0x2d, 0x01, 0x7f, 0xa2, 0x30, 0x10, 0x0b, 0x5e, 0x6e, 0xaa, + 0x4a, 0x5d, 0xd9, 0x7a, 0x0c, 0xb5, 0xf0, 0x0c, 0xac, 0xaa, 0x93, 0x8c, 0xe0, 0x6e, 0x73, 0x96, 0x32, 0xbf, 0xad, + 0xc8, 0x8f, 0x65, 0x5d, 0x1c, 0xd2, 0xa6, 0xbd, 0x8a, 0xdf, 0x32, 0xec, 0x05, 0x10, 0xa3, 0x2a, 0x33, 0x53, 0x25, + 0x22, 0x5f, 0x17, 0xa4, 0x8a, 0x94, 0x3d, 0x4b, 0xb6, 0x57, 0xf4, 0x57, 0xaf, 0xd8, 0x12, 0x67, 0xb6, 0x25, 0x27, + 0xfc, 0x54, 0x4d, 0xe2, 0xf9, 0xaf, 0xf2, 0xce, 0xfd, 0x6d, 0xfa, 0xfe, 0x7c, 0x98, 0xc4, 0x59, 0x2e, 0xe9, 0xba, + 0xb5, 0xb8, 0xf8, 0xa4, 0xf5, 0xb7, 0xab, 0x3d, 0x6a, 0xdf, 0xad, 0xe5, 0xf4, 0x76, 0xe4, 0x9a, 0xf9, 0x12, 0xd2, + 0xac, 0xf5, 0xe1, 0x24, 0x7f, 0x95, 0x61, 0x97, 0x37, 0x7a, 0xd0, 0xb4, 0x64, 0xfa, 0xe2, 0xe7, 0x8a, 0x6d, 0x19, + 0xba, 0x12, 0xbd, 0xf3, 0xd3, 0x17, 0xe3, 0xae, 0x11, 0xb3, 0x35, 0x90, 0x3c, 0x61, 0x5e, 0x44, 0x63, 0xcf, 0x8d, + 0x05, 0x02, 0xbd, 0x4f, 0xfb, 0x16, 0xcc, 0xd2, 0x6f, 0x9c, 0x28, 0xb9, 0x4f, 0xb0, 0x3f, 0xd2, 0x22, 0x18, 0xb8, + 0x73, 0x57, 0xbd, 0xe0, 0x38, 0x0b, 0x7d, 0xd4, 0xb5, 0xdc, 0x17, 0x31, 0x72, 0x9b, 0xe3, 0xf4, 0x6e, 0x29, 0x99, + 0x08, 0xfb, 0xc5, 0x53, 0xce, 0xac, 0xef, 0x7e, 0x99, 0x25, 0xad, 0xd5, 0x02, 0xfd, 0x8a, 0xab, 0xe7, 0x6e, 0xfd, + 0x27, 0x10, 0xbd, 0x9f, 0x76, 0x58, 0x2c, 0xad, 0xd4, 0x9d, 0xaa, 0xd2, 0x37, 0x78, 0x52, 0x06, 0xc8, 0x59, 0x40, + 0x67, 0xda, 0x5a, 0xee, 0x16, 0x46, 0xfd, 0xa5, 0xc7, 0xb9, 0xfe, 0xde, 0xca, 0x18, 0x1c, 0x42, 0xb4, 0xfd, 0x0a, + 0xe7, 0x71, 0x7b, 0x25, 0x5e, 0x0b, 0xaf, 0x28, 0x34, 0x5b, 0x1e, 0xbf, 0x54, 0x30, 0x89, 0x7e, 0x12, 0x91, 0x3b, + 0x3f, 0x5b, 0xb3, 0x30, 0x31, 0x9f, 0xce, 0x2d, 0xbf, 0x47, 0xa7, 0xe6, 0x02, 0x5a, 0xee, 0xf9, 0x81, 0x8b, 0xf9, + 0x3f, 0xcb, 0x2c, 0x4b, 0x6a, 0x85, 0x66, 0xd9, 0x36, 0xc0, 0xd1, 0x0d, 0x4f, 0x71, 0xe3, 0x39, 0x0e, 0x28, 0xb4, + 0x83, 0x52, 0x6f, 0xb5, 0x40, 0x8d, 0x14, 0x61, 0xa1, 0xa0, 0x90, 0x7e, 0x44, 0xf3, 0x28, 0x3b, 0x62, 0xc0, 0x48, + 0xb7, 0xfa, 0x9b, 0x5c, 0x5b, 0x64, 0x45, 0xab, 0xfd, 0xb2, 0x7c, 0xbf, 0x2f, 0x82, 0xe8, 0xbf, 0x5d, 0x80, 0x22, + 0xd6, 0x86, 0xec, 0x4d, 0xc0, 0x34, 0xa2, 0x98, 0xa2, 0xe0, 0xdb, 0x80, 0xa4, 0x50, 0x29, 0x7b, 0x17, 0xb6, 0x08, + 0x33, 0x97, 0x5a, 0x52, 0xc6, 0x98, 0x78, 0xde, 0x00, 0x74, 0xa4, 0xff, 0xda, 0xf8, 0x2e, 0x3b, 0x33, 0x1e, 0x26, + 0xe5, 0x1e, 0x11, 0x91, 0xa0, 0x9e, 0xca, 0x4a, 0xc0, 0x7e, 0xb3, 0x29, 0xbe, 0x15, 0x94, 0xa4, 0x49, 0xed, 0x45, + 0xb0, 0xdb, 0x86, 0x0c, 0x2e, 0xa3, 0xb5, 0x86, 0x82, 0x86, 0xef, 0x0d, 0xe3, 0x01, 0xab, 0x5c, 0xf4, 0x12, 0x9b, + 0xfc, 0x08, 0x9e, 0xa9, 0xe8, 0x2e, 0xdf, 0xa2, 0x8f, 0x77, 0x54, 0xe6, 0x65, 0xa7, 0x75, 0xed, 0xdd, 0x81, 0x41, + 0x18, 0x36, 0x3e, 0x35, 0xd0, 0x91, 0xbe, 0x1e, 0xb0, 0x41, 0xf3, 0x78, 0x86, 0x0d, 0x38, 0xa5, 0x2b, 0x32, 0x5a, + 0xe7, 0x23, 0xcb, 0x17, 0x7b, 0xfc, 0x3e, 0x1a, 0x21, 0x63, 0xe2, 0x08, 0xec, 0xa8, 0x01, 0x1e, 0x12, 0x66, 0x08, + 0x3f, 0xf6, 0x0e, 0xf6, 0xb5, 0x81, 0xff, 0x4a, 0x13, 0x50, 0x40, 0x8e, 0xf6, 0xb8, 0x90, 0x54, 0x3c, 0x86, 0x19, + 0x83, 0xc2, 0x87, 0x64, 0x28, 0x73, 0xfc, 0xef, 0xbb, 0x92, 0x62, 0xcd, 0x70, 0x57, 0x8c, 0x4c, 0x1b, 0xee, 0xbe, + 0x6b, 0xcc, 0x6f, 0xe9, 0xde, 0x51, 0x14, 0x3d, 0x1d, 0x03, 0x0f, 0xa1, 0x14, 0xa1, 0xec, 0xcc, 0x84, 0x2a, 0x00, + 0xfd, 0xa2, 0x19, 0x6d, 0x40, 0xeb, 0xc7, 0xc8, 0x1d, 0xdf, 0x5e, 0xc1, 0xc9, 0x45, 0xa2, 0xc0, 0xba, 0xf8, 0xfa, + 0x97, 0x4a, 0x7a, 0xef, 0xde, 0x25, 0x5b, 0xe5, 0xca, 0x9c, 0xda, 0xe2, 0xa1, 0x0b, 0xbe, 0x4c, 0xd7, 0xc7, 0xde, + 0xcb, 0x13, 0xa4, 0xa6, 0x61, 0xb5, 0x8e, 0x6d, 0xc2, 0x93, 0x16, 0xbb, 0xe4, 0xed, 0xfc, 0xe5, 0x49, 0x36, 0xf1, + 0x8a, 0xa5, 0x40, 0xa7, 0x67, 0x56, 0xc5, 0x36, 0xd2, 0xd3, 0x65, 0xc3, 0x67, 0x06, 0xf8, 0x3c, 0x1b, 0xc8, 0x3d, + 0xcf, 0xf5, 0xe7, 0xfa, 0xed, 0x92, 0x87, 0x84, 0x92, 0xdd, 0xd6, 0x38, 0xbd, 0x6b, 0x6c, 0x33, 0x1f, 0xcd, 0xdc, + 0x3e, 0xb6, 0x3e, 0xf3, 0x91, 0xc9, 0xd2, 0x05, 0x25, 0x61, 0x7b, 0x3c, 0x24, 0x9d, 0x6c, 0xb2, 0xe0, 0xcc, 0xa9, + 0x2f, 0x91, 0xcb, 0xe2, 0xbc, 0xae, 0x34, 0x17, 0x36, 0x2b, 0xe8, 0x32, 0x80, 0x53, 0x9d, 0x3a, 0x09, 0xae, 0x2a, + 0x02, 0xa7, 0xa6, 0x66, 0xaa, 0x28, 0x9e, 0xb2, 0x66, 0xbb, 0x39, 0x51, 0xfd, 0x14, 0x2d, 0x2e, 0x75, 0x2a, 0x4a, + 0xd4, 0x4c, 0xb6, 0xcc, 0x14, 0xc8, 0x64, 0x51, 0xa4, 0x39, 0x89, 0x15, 0x0e, 0xfa, 0x9e, 0x53, 0x24, 0x7b, 0xd1, + 0x6e, 0x3e, 0x5e, 0xd9, 0x5a, 0xb2, 0xc2, 0x68, 0x66, 0xab, 0x79, 0x76, 0x22, 0x15, 0xdb, 0x07, 0xca, 0xa1, 0x70, + 0xdf, 0x26, 0xb0, 0x52, 0x23, 0xe5, 0xa5, 0xa8, 0x23, 0x35, 0x3c, 0xc5, 0x5f, 0x9b, 0x6e, 0x88, 0xd1, 0x6c, 0xd8, + 0xd1, 0x46, 0xb3, 0xd9, 0x0c, 0x8a, 0x4d, 0x8d, 0x43, 0xab, 0xd4, 0x74, 0x1b, 0x91, 0xaf, 0x50, 0x35, 0xb2, 0x6f, + 0xac, 0x2c, 0x88, 0x25, 0x73, 0x88, 0xd7, 0x50, 0x98, 0x24, 0xf7, 0x28, 0xb6, 0xe8, 0xf5, 0xa2, 0xcd, 0xcd, 0x91, + 0x63, 0x43, 0x76, 0xae, 0xe2, 0x5c, 0xa6, 0x2b, 0x91, 0x47, 0x81, 0x50, 0x58, 0x89, 0xa4, 0x04, 0x93, 0x31, 0x4f, + 0xdf, 0xf8, 0x29, 0xe9, 0xb9, 0x47, 0x40, 0x34, 0xfb, 0x82, 0x6a, 0x45, 0x7d, 0x11, 0x23, 0x3e, 0x92, 0x90, 0x63, + 0xf8, 0x8a, 0x61, 0xf8, 0xde, 0xa6, 0xa2, 0xff, 0x6a, 0xe7, 0x53, 0x13, 0x65, 0x72, 0x54, 0xed, 0x10, 0x69, 0x03, + 0xb1, 0x35, 0x40, 0x3c, 0x4d, 0xc7, 0x12, 0x94, 0x46, 0x8f, 0xc1, 0xce, 0xe7, 0xe5, 0x69, 0x27, 0xd4, 0xe2, 0x48, + 0x77, 0x99, 0x9b, 0x00, 0x67, 0xfd, 0x30, 0xbd, 0x4d, 0xcc, 0xee, 0xfe, 0xcc, 0x01, 0xdd, 0x89, 0x71, 0x84, 0x8f, + 0x66, 0x97, 0x55, 0x08, 0x4f, 0xfc, 0x3b, 0xaf, 0xda, 0x94, 0x84, 0x13, 0xe2, 0x8d, 0x63, 0x03, 0x98, 0xce, 0xb4, + 0xa7, 0x6a, 0x39, 0x10, 0x29, 0x7e, 0x0d, 0xbe, 0xc1, 0x95, 0xd0, 0xa0, 0x20, 0x51, 0x3f, 0x8f, 0x5c, 0x13, 0x53, + 0x3d, 0xce, 0x7f, 0x44, 0x28, 0x03, 0x83, 0x04, 0x32, 0x2a, 0xd8, 0x3d, 0x6f, 0x8d, 0x28, 0xd6, 0x7a, 0xd2, 0xb2, + 0xcb, 0x99, 0xeb, 0x36, 0xb5, 0x33, 0x7b, 0xdf, 0x0a, 0x0e, 0x04, 0xfd, 0xe5, 0x56, 0xa6, 0x1f, 0x01, 0x06, 0xc3, + 0xac, 0x30, 0xff, 0x89, 0x0c, 0x9a, 0x2b, 0x64, 0xd4, 0x5d, 0x77, 0xd5, 0x3b, 0xc1, 0xd8, 0x99, 0x8c, 0x23, 0x9f, + 0xfc, 0x3c, 0x70, 0xf7, 0xad, 0x48, 0x35, 0x9e, 0xb9, 0x8d, 0x91, 0x4f, 0x26, 0x81, 0xd9, 0xb6, 0x6e, 0x54, 0x53, + 0x26, 0x38, 0x12, 0x31, 0x95, 0x7e, 0x73, 0x1f, 0xb7, 0xe1, 0x59, 0x7e, 0xf0, 0xdf, 0x6f, 0xd3, 0xc4, 0xb9, 0x17, + 0x76, 0x61, 0xba, 0x89, 0x37, 0x0e, 0xba, 0xdf, 0xb5, 0x8f, 0xe6, 0x1a, 0x0f, 0x53, 0x91, 0xd4, 0x76, 0xa2, 0xce, + 0x47, 0xea, 0xe1, 0x35, 0x9d, 0x9f, 0x49, 0xb3, 0xce, 0xf5, 0x9f, 0xaa, 0x0e, 0x06, 0xfd, 0x15, 0x73, 0xb6, 0x45, + 0xbc, 0xd7, 0x9e, 0x6b, 0x29, 0xbc, 0x83, 0xaf, 0xcc, 0xb9, 0x15, 0xf4, 0x2b, 0x17, 0x95, 0x67, 0xaf, 0x49, 0xd7, + 0x78, 0x52, 0x56, 0x13, 0x36, 0xf5, 0x20, 0x4e, 0xf9, 0xab, 0xe0, 0x18, 0xa0, 0x37, 0x54, 0x8d, 0x91, 0xb2, 0x8b, + 0xf7, 0xd5, 0xc0, 0x99, 0x0a, 0xf1, 0x8f, 0x82, 0xa1, 0x51, 0xda, 0x96, 0xea, 0x18, 0x5b, 0xef, 0x31, 0x8f, 0x47, + 0x95, 0xcb, 0xea, 0x09, 0x0b, 0x4e, 0x9d, 0x9d, 0xdf, 0xfd, 0x88, 0x6b, 0x1e, 0x60, 0x9d, 0xd5, 0xfe, 0x0a, 0x9c, + 0xd7, 0xfe, 0x33, 0xdd, 0x7c, 0x28, 0xba, 0x27, 0x5a, 0x6f, 0xe6, 0xde, 0xf3, 0x6c, 0xd6, 0x9f, 0xef, 0x45, 0x68, + 0x35, 0x5c, 0x67, 0x7c, 0x7a, 0xcb, 0xef, 0x40, 0x67, 0x3b, 0xe8, 0x1a, 0xef, 0x2b, 0xcd, 0x7b, 0x3b, 0x0b, 0x56, + 0xaa, 0xa8, 0x75, 0x8e, 0x1d, 0xba, 0xd6, 0x78, 0x3c, 0xb8, 0xc8, 0xa4, 0xb1, 0x3a, 0x59, 0x79, 0x68, 0x85, 0xca, + 0xd7, 0x8b, 0xb8, 0x63, 0x27, 0xd1, 0xcd, 0xb2, 0x11, 0x25, 0x12, 0xe4, 0x6f, 0x83, 0x42, 0x31, 0x1c, 0x32, 0xe1, + 0x61, 0xdc, 0x9b, 0x08, 0x61, 0x5e, 0x4b, 0xb9, 0x10, 0xab, 0x1d, 0x5e, 0xaf, 0xd0, 0x23, 0xe0, 0x60, 0x49, 0x95, + 0xb4, 0x91, 0x88, 0xba, 0x94, 0x7d, 0x58, 0xdd, 0xfe, 0x50, 0x2f, 0xee, 0xca, 0x5f, 0xd5, 0xb6, 0x66, 0xd1, 0xfc, + 0x8b, 0x12, 0x8e, 0x95, 0x08, 0x9b, 0x29, 0xb6, 0x75, 0xf4, 0x7f, 0x44, 0x85, 0x0e, 0x9d, 0x0b, 0x80, 0xda, 0x0f, + 0x95, 0x05, 0x8a, 0x62, 0x04, 0x68, 0x3f, 0xa9, 0xb2, 0x90, 0x7a, 0xc7, 0x1f, 0xcc, 0xae, 0x5b, 0x86, 0x2c, 0x17, + 0xc1, 0x58, 0x9d, 0x6d, 0x00, 0x08, 0xab, 0x4e, 0x60, 0x02, 0x51, 0x34, 0x8a, 0xb2, 0x29, 0x37, 0xd8, 0x2d, 0x5e, + 0x41, 0xb4, 0xfa, 0xfa, 0x4c, 0xf4, 0x8c, 0xac, 0xa4, 0x2a, 0x59, 0xe6, 0xfb, 0x57, 0x16, 0xcc, 0x95, 0x34, 0x7c, + 0x6b, 0xcf, 0xed, 0x6c, 0xd1, 0x79, 0x7f, 0x57, 0xd3, 0xbf, 0xb0, 0x9b, 0xe1, 0x6f, 0xba, 0x01, 0x33, 0xcc, 0x27, + 0xb7, 0xdf, 0x4f, 0xb1, 0x26, 0x1c, 0xff, 0xc8, 0x2a, 0x86, 0x85, 0x2b, 0x08, 0x16, 0x35, 0x46, 0x9c, 0x92, 0x7f, + 0xec, 0x03, 0x05, 0xda, 0xc3, 0x86, 0x02, 0x83, 0x51, 0xe5, 0xa1, 0x12, 0xe9, 0x53, 0xf1, 0xcb, 0x36, 0x90, 0x41, + 0x27, 0x1c, 0x4a, 0x06, 0x76, 0x6a, 0xd7, 0x2a, 0x31, 0x5b, 0x73, 0xeb, 0x3f, 0x66, 0x05, 0x9b, 0x61, 0xc0, 0x12, + 0xf5, 0x90, 0x46, 0x7a, 0x59, 0xb5, 0x08, 0xef, 0x0d, 0x4d, 0xdd, 0x43, 0x90, 0x5a, 0x16, 0x09, 0x7f, 0x60, 0x1e, + 0xa0, 0x46, 0x30, 0x66, 0x9a, 0x67, 0xa5, 0x1c, 0x42, 0x2e, 0xd3, 0xe3, 0x54, 0x14, 0xa3, 0x96, 0xe5, 0x3a, 0x63, + 0x15, 0x47, 0x5e, 0xb3, 0x38, 0x6f, 0x66, 0x51, 0xae, 0x51, 0x36, 0x2c, 0xb8, 0xfe, 0x0c, 0x89, 0x46, 0xb1, 0x41, + 0x43, 0xec, 0x8e, 0x73, 0x52, 0xa6, 0x39, 0x47, 0x1d, 0x92, 0x5b, 0x72, 0x8f, 0x58, 0xcd, 0x6c, 0x25, 0x4c, 0x8e, + 0x56, 0x6d, 0x46, 0xd8, 0xee, 0x68, 0x1c, 0x33, 0x4d, 0x1c, 0x4f, 0x21, 0xf4, 0x40, 0x9b, 0x3d, 0x2d, 0xd9, 0x71, + 0xf1, 0x7f, 0x90, 0x02, 0xba, 0x79, 0xb4, 0x42, 0x30, 0x17, 0xfb, 0x18, 0xa5, 0x86, 0x9b, 0x63, 0x17, 0xd8, 0xb0, + 0xfd, 0xe7, 0x26, 0xba, 0xa2, 0xe3, 0xb9, 0x5e, 0xa9, 0x91, 0x83, 0x38, 0xb1, 0x3e, 0xdb, 0x83, 0xd0, 0x7a, 0x44, + 0xc2, 0x81, 0xb2, 0xce, 0x7a, 0x65, 0x1e, 0xeb, 0xd2, 0x7f, 0xfd, 0x4b, 0x6d, 0x09, 0x41, 0x60, 0x58, 0x3d, 0xd8, + 0xfe, 0x04, 0x56, 0x5c, 0xc8, 0x12, 0x99, 0xf1, 0xc2, 0xbf, 0x62, 0x87, 0xaf, 0x69, 0x56, 0x56, 0x3a, 0xc7, 0xe5, + 0xcc, 0x42, 0xa7, 0xa1, 0x6a, 0x8e, 0x79, 0x1e, 0x32, 0x16, 0xd3, 0x0b, 0x83, 0x9c, 0x0b, 0x02, 0x1a, 0x9a, 0x73, + 0xee, 0xca, 0x7a, 0x93, 0xe0, 0x36, 0x82, 0x62, 0x29, 0x40, 0x57, 0xe8, 0x32, 0xbd, 0xf3, 0xcd, 0x30, 0x0e, 0x86, + 0xdc, 0xcc, 0x00, 0x84, 0x2d, 0x11, 0x54, 0x32, 0xf0, 0xac, 0xd8, 0xb3, 0x92, 0x73, 0x30, 0xe7, 0x15, 0xea, 0xbd, + 0x46, 0xfa, 0x1b, 0x24, 0x5c, 0xa0, 0x5a, 0x29, 0x70, 0x32, 0xa0, 0xcb, 0x52, 0x2b, 0x34, 0x2f, 0x11, 0x62, 0xac, + 0x01, 0x49, 0x6d, 0xe2, 0x97, 0xf3, 0x02, 0xf7, 0xbc, 0x9f, 0x0d, 0x67, 0x5d, 0x97, 0x00, 0xf2, 0x30, 0x2f, 0xbf, + 0xbd, 0xcc, 0x70, 0x90, 0x13, 0x90, 0xb8, 0x18, 0x98, 0x39, 0xa1, 0x9d, 0x5d, 0xc1, 0x96, 0xba, 0x18, 0x55, 0xb8, + 0xad, 0x61, 0xb2, 0x14, 0x95, 0x6d, 0xb8, 0x3e, 0x86, 0xce, 0x48, 0xfa, 0xce, 0x4f, 0x33, 0x09, 0x33, 0x74, 0xcd, + 0xc9, 0x54, 0xee, 0x04, 0x9b, 0x4f, 0x9a, 0x81, 0xbe, 0xd8, 0xfa, 0x73, 0xe8, 0x7f, 0xda, 0xd8, 0x04, 0xd3, 0xf7, + 0x8c, 0x64, 0xc4, 0x54, 0xa2, 0xcf, 0x1b, 0xcc, 0x3e, 0xed, 0xf7, 0xf9, 0x0e, 0x16, 0xeb, 0xcb, 0xd8, 0xcb, 0x8a, + 0x8d, 0xfa, 0xd8, 0x5a, 0xc6, 0x24, 0x71, 0x2c, 0xb9, 0x3d, 0x28, 0x29, 0xa8, 0xcc, 0x9b, 0xa8, 0x21, 0x23, 0xa6, + 0x35, 0x27, 0x3b, 0xf1, 0xbf, 0x73, 0xc5, 0xcc, 0xc4, 0xc0, 0x8f, 0xb1, 0xc7, 0x3e, 0xbe, 0x7a, 0xe2, 0xad, 0xf6, + 0x23, 0x67, 0xe8, 0x98, 0x3c, 0x40, 0x20, 0x17, 0x98, 0x97, 0x2e, 0x30, 0xe7, 0xd6, 0x8a, 0x35, 0x6b, 0x6a, 0xe5, + 0x3f, 0xbb, 0x2b, 0x7d, 0x60, 0xec, 0x13, 0x41, 0x7f, 0x36, 0xed, 0x66, 0xec, 0x1b, 0xb3, 0x57, 0x03, 0x4e, 0x1d, + 0xcc, 0x6c, 0xbc, 0xa9, 0xf4, 0x1f, 0x6a, 0x73, 0xc5, 0x02, 0x14, 0x39, 0x1b, 0xf9, 0xa4, 0xa9, 0x08, 0xfe, 0xb8, + 0x3a, 0x7b, 0xb1, 0xdd, 0xa2, 0x50, 0x70, 0x65, 0x34, 0xe1, 0x5d, 0x46, 0x3e, 0xd1, 0xd0, 0x06, 0x6f, 0xe4, 0x8d, + 0x6d, 0x5c, 0x46, 0xfb, 0x68, 0x3f, 0x07, 0xb1, 0x0b, 0x82, 0xb6, 0x26, 0x16, 0x04, 0x59, 0x53, 0xe7, 0x0d, 0x23, + 0x12, 0xfc, 0xd6, 0x5a, 0xe9, 0xbc, 0x8e, 0xbd, 0xd2, 0x1d, 0xe7, 0x43, 0x22, 0x46, 0xe0, 0xb6, 0xe8, 0x7a, 0x4b, + 0x42, 0x19, 0x97, 0x8e, 0x4e, 0x26, 0x78, 0xd4, 0x26, 0x4e, 0xaa, 0x6d, 0xaf, 0x47, 0x1d, 0x1e, 0xf5, 0xdd, 0xbc, + 0x18, 0x94, 0xb6, 0x3b, 0xfa, 0x6f, 0xe1, 0xad, 0xcc, 0x91, 0xc7, 0xb5, 0xbe, 0xd3, 0xdc, 0x02, 0xbd, 0x89, 0xe8, + 0x44, 0x51, 0x27, 0x9c, 0xbc, 0x52, 0x8e, 0xff, 0x0b, 0x85, 0x15, 0x0c, 0x81, 0xc9, 0x4c, 0x24, 0xaa, 0x2d, 0x48, + 0x67, 0xa1, 0xbf, 0xf5, 0xf1, 0xb5, 0x42, 0x16, 0xd8, 0x62, 0x06, 0x71, 0xa8, 0x07, 0x8d, 0xe0, 0x25, 0x14, 0x88, + 0xe2, 0xde, 0x19, 0x1a, 0x83, 0x1e, 0x94, 0x3b, 0xa4, 0x81, 0x62, 0xd0, 0xb2, 0x14, 0x1a, 0xda, 0x84, 0x54, 0xbb, + 0xdf, 0x1b, 0xca, 0xfa, 0x25, 0x37, 0xd4, 0x28, 0xa2, 0x51, 0x6f, 0x1d, 0x24, 0x20, 0xe8, 0x15, 0x07, 0x69, 0xa0, + 0xbc, 0x5e, 0x12, 0x23, 0x96, 0xf1, 0x38, 0xc8, 0xd5, 0xc2, 0xe3, 0x95, 0x90, 0x53, 0xb3, 0x42, 0xc8, 0x31, 0x80, + 0x61, 0xec, 0x81, 0x7b, 0x39, 0xec, 0x60, 0x11, 0xf0, 0xbc, 0x5c, 0x51, 0xcf, 0x46, 0xb1, 0xb0, 0xfd, 0xbb, 0xbc, + 0x98, 0x5f, 0xd2, 0xde, 0x26, 0x29, 0x8f, 0x55, 0x9a, 0x4a, 0xf0, 0xdd, 0x9f, 0xde, 0xc5, 0x7c, 0x2c, 0x59, 0xb3, + 0xa5, 0x32, 0x07, 0x13, 0xa2, 0xeb, 0x90, 0x91, 0x3e, 0x55, 0xc5, 0xb1, 0x49, 0x01, 0x35, 0x1c, 0x87, 0x9d, 0x0b, + 0xc2, 0xe3, 0x84, 0x35, 0x9c, 0x4b, 0xcc, 0x61, 0x87, 0x0a, 0x36, 0xc2, 0xe8, 0x86, 0x12, 0x62, 0x49, 0x6d, 0xc4, + 0xb7, 0x03, 0x5c, 0x82, 0xef, 0x17, 0x5a, 0x79, 0x1f, 0x20, 0xfe, 0xd8, 0xa4, 0x33, 0x40, 0x2e, 0xb1, 0xb2, 0x98, + 0xb0, 0xed, 0xdf, 0x2a, 0x6d, 0x2b, 0x0f, 0xd3, 0xcd, 0xbd, 0x39, 0xbb, 0x03, 0x85, 0x33, 0x27, 0x19, 0xf9, 0x31, + 0xe9, 0x51, 0x39, 0x93, 0xff, 0xdc, 0x30, 0x06, 0x64, 0xe6, 0x0e, 0xf6, 0x95, 0xc0, 0x98, 0xbe, 0xd2, 0xd1, 0x84, + 0x7f, 0x89, 0x94, 0x9f, 0x8d, 0x46, 0x4c, 0x5e, 0x61, 0xc8, 0x55, 0xfa, 0x4a, 0xbf, 0xcf, 0x5c, 0xf4, 0x52, 0xde, + 0x38, 0xc6, 0xa8, 0xb8, 0xc9, 0xf8, 0xc5, 0xc8, 0x16, 0x22, 0xf5, 0x66, 0xcc, 0xb6, 0x3f, 0x5b, 0xa2, 0x7b, 0x86, + 0x07, 0x92, 0xa0, 0x71, 0xa3, 0x40, 0x01, 0x76, 0x31, 0xc1, 0x90, 0xdc, 0x01, 0x93, 0xa6, 0x69, 0x9e, 0xa7, 0x50, + 0xd7, 0x6a, 0x38, 0xa9, 0x6c, 0xab, 0xbb, 0xac, 0x4c, 0x65, 0xdb, 0xc1, 0x70, 0x8d, 0x82, 0xc4, 0x51, 0xe3, 0x14, + 0x15, 0xb3, 0xea, 0x69, 0x52, 0x86, 0x05, 0x44, 0x5a, 0x71, 0x8e, 0xdf, 0x5c, 0x9a, 0x4c, 0x67, 0xa7, 0xd8, 0x2b, + 0x3c, 0x4f, 0x85, 0x08, 0x76, 0x67, 0x15, 0x09, 0xbb, 0xb6, 0x65, 0x1d, 0x2d, 0x64, 0xee, 0x5b, 0x17, 0xe8, 0x12, + 0xe2, 0x07, 0x6f, 0xf5, 0xdb, 0xfd, 0x04, 0xec, 0x20, 0x8c, 0xf5, 0x11, 0x5d, 0x7c, 0xd4, 0x0b, 0x4a, 0x2b, 0x3f, + 0x09, 0xce, 0xd9, 0x66, 0xe9, 0xfd, 0x2f, 0x58, 0xdf, 0x94, 0x17, 0x0b, 0x0a, 0x85, 0x15, 0xcb, 0x52, 0x5c, 0xb5, + 0x8c, 0xcf, 0x51, 0x85, 0x55, 0xc8, 0xb1, 0x87, 0x1e, 0x37, 0x10, 0xa9, 0x65, 0x91, 0x34, 0x69, 0xee, 0xac, 0x44, + 0xa6, 0x6b, 0xb0, 0xf3, 0x4a, 0x00, 0x76, 0x6c, 0x52, 0xd5, 0x8b, 0x85, 0xa7, 0x24, 0xc1, 0xd1, 0xad, 0x90, 0xbb, + 0x50, 0x65, 0x0f, 0x14, 0x62, 0x58, 0x07, 0x58, 0x38, 0x2b, 0x58, 0x12, 0xb6, 0x0f, 0xab, 0xf1, 0x63, 0x54, 0x5b, + 0xc0, 0xf8, 0x10, 0x42, 0x7d, 0xb7, 0x83, 0x8e, 0xa2, 0xa3, 0x35, 0x9a, 0xdc, 0xe3, 0x00, 0x19, 0xf4, 0x73, 0x3f, + 0x15, 0x5c, 0xf2, 0x80, 0xbc, 0x18, 0x39, 0x89, 0xab, 0xf1, 0xa6, 0x65, 0xea, 0x5c, 0xf9, 0xee, 0x4b, 0x1b, 0x61, + 0x5d, 0x20, 0x2e, 0xe4, 0x7d, 0xec, 0x90, 0x7d, 0x77, 0x18, 0xad, 0xae, 0x9b, 0x27, 0x8b, 0xfc, 0x59, 0x56, 0x4d, + 0x45, 0xf8, 0xd3, 0xf7, 0x1b, 0x6a, 0x73, 0x16, 0x50, 0xee, 0xbd, 0x5e, 0x70, 0x8a, 0x7a, 0x47, 0x05, 0x22, 0x98, + 0x64, 0xf8, 0xed, 0x23, 0xd2, 0x16, 0x24, 0x62, 0xcd, 0x87, 0x4b, 0xaf, 0x59, 0x7f, 0x0b, 0x82, 0x55, 0x13, 0xe1, + 0xec, 0x57, 0x1a, 0xc4, 0xc1, 0x4b, 0x11, 0x92, 0xae, 0x08, 0x06, 0x3a, 0x2a, 0x88, 0xad, 0xd8, 0xca, 0x5e, 0x56, + 0x6b, 0x08, 0x44, 0x9c, 0x83, 0xcd, 0x67, 0x96, 0xe1, 0x39, 0xf1, 0xea, 0x97, 0x07, 0x29, 0x5c, 0x8c, 0x41, 0xff, + 0xab, 0x65, 0xe1, 0x07, 0x07, 0x07, 0x56, 0x46, 0x56, 0x8e, 0x7a, 0xd7, 0x4b, 0xe5, 0xb6, 0xac, 0xe3, 0xd6, 0xaa, + 0xf7, 0xe4, 0x05, 0x28, 0x8d, 0x36, 0x83, 0x64, 0xb7, 0x8e, 0x99, 0x1a, 0xc3, 0x43, 0x56, 0x8b, 0xfa, 0x98, 0x70, + 0x87, 0xbd, 0x91, 0x86, 0xbd, 0x83, 0x89, 0x68, 0xbc, 0x6f, 0xff, 0xc4, 0x48, 0x43, 0xc2, 0x74, 0xcc, 0x21, 0x77, + 0x50, 0x66, 0x4c, 0x4f, 0x05, 0x6d, 0xc7, 0x11, 0xcf, 0x45, 0x92, 0xce, 0xfd, 0x2b, 0xc3, 0xfb, 0x0b, 0x19, 0x5b, + 0x42, 0x46, 0x77, 0x24, 0xa5, 0x08, 0xd7, 0xd2, 0x60, 0x60, 0x8c, 0x60, 0x3e, 0x25, 0x9a, 0x88, 0x65, 0xb7, 0xb9, + 0x20, 0xb1, 0xcf, 0xd5, 0x92, 0xbd, 0x55, 0x2c, 0xa6, 0x04, 0x2d, 0x8a, 0x5e, 0xbc, 0x5c, 0x99, 0x31, 0xe1, 0xd1, + 0xb5, 0x71, 0x13, 0x23, 0x76, 0x67, 0x56, 0x7b, 0x1b, 0x3c, 0x68, 0x9f, 0x7f, 0xbd, 0x51, 0xbc, 0xb8, 0x5d, 0xbe, + 0x84, 0xe0, 0x07, 0x4f, 0x93, 0xc5, 0x50, 0x06, 0xb9, 0xd8, 0x70, 0xc1, 0x03, 0x59, 0x44, 0x6d, 0xb7, 0x1e, 0x23, + 0x36, 0xcf, 0x27, 0x9f, 0xb6, 0x30, 0x3c, 0x93, 0x93, 0xc1, 0xfe, 0x45, 0x07, 0xbf, 0x01, 0x5a, 0x37, 0x29, 0xf2, + 0xef, 0x4a, 0xd5, 0x41, 0x46, 0xf0, 0xf1, 0xcb, 0xed, 0x2f, 0xca, 0x50, 0xd3, 0x33, 0x9a, 0x86, 0xdd, 0xf2, 0xf7, + 0xc9, 0x29, 0xd8, 0x77, 0x65, 0x00, 0xa8, 0xd3, 0xa5, 0x8c, 0xf8, 0x9c, 0x7c, 0x83, 0x30, 0x00, 0x22, 0xbf, 0xf9, + 0x55, 0x3b, 0x3e, 0x36, 0xc7, 0xe5, 0x0f, 0x6d, 0x7b, 0x96, 0x88, 0xfe, 0xae, 0x0d, 0xb3, 0x1d, 0xfb, 0x80, 0x15, + 0x0f, 0xa3, 0x44, 0xb4, 0xac, 0xf9, 0x90, 0xb9, 0x4f, 0xf1, 0xb0, 0x79, 0xb4, 0x6a, 0x23, 0x8a, 0x6c, 0xb0, 0x5d, + 0xb2, 0xbf, 0xd0, 0xd2, 0xf9, 0x66, 0x87, 0x66, 0x50, 0xb7, 0x47, 0xc8, 0xab, 0x08, 0x20, 0x1e, 0x83, 0xc1, 0x7f, + 0x6d, 0xe6, 0x3d, 0x5b, 0xac, 0x00, 0x3f, 0x3b, 0x76, 0xfe, 0xf2, 0x7c, 0x6a, 0x11, 0x04, 0x7d, 0xd6, 0x3c, 0xaa, + 0x47, 0x44, 0xd2, 0x4f, 0x67, 0x5b, 0xbd, 0x4f, 0x87, 0x51, 0x89, 0x47, 0x6c, 0xda, 0xfe, 0x1d, 0x8b, 0xba, 0xd8, + 0xde, 0xb3, 0xe9, 0xfc, 0xb9, 0x29, 0x74, 0x06, 0x91, 0xda, 0xc6, 0x99, 0x8c, 0x64, 0x47, 0xa6, 0x01, 0x0d, 0xd1, + 0x5e, 0x28, 0xaf, 0x1f, 0x50, 0x32, 0x0a, 0xe4, 0x18, 0x41, 0xae, 0x8d, 0x8c, 0x2d, 0x27, 0x4b, 0x10, 0x86, 0x25, + 0xce, 0xef, 0xa9, 0x43, 0xd0, 0x4b, 0x85, 0xe4, 0xec, 0x22, 0x5c, 0x6f, 0x87, 0x34, 0x1a, 0x00, 0x4a, 0x8d, 0xb7, + 0x09, 0x1e, 0xb6, 0x20, 0x46, 0x2a, 0xb2, 0x22, 0xf1, 0xa7, 0xc4, 0x45, 0xe5, 0x18, 0x8f, 0x00, 0x24, 0xc6, 0xf1, + 0x50, 0xea, 0x3c, 0xa8, 0x43, 0xf2, 0x8a, 0x89, 0x39, 0xd2, 0xb3, 0x0a, 0x3d, 0x98, 0x69, 0x68, 0x73, 0x35, 0x9a, + 0x2a, 0x68, 0x0e, 0x4a, 0xff, 0x81, 0xea, 0x2a, 0x1f, 0x92, 0x47, 0x06, 0x41, 0x18, 0xae, 0xd6, 0x5b, 0xea, 0xf7, + 0x15, 0x42, 0x8b, 0x03, 0x33, 0xc9, 0x20, 0xce, 0x8d, 0x0f, 0x5b, 0x5d, 0xe3, 0x8b, 0x7a, 0x02, 0x34, 0x27, 0xae, + 0x7c, 0xf8, 0x78, 0x32, 0x50, 0x38, 0x41, 0xc9, 0xe8, 0x4f, 0x50, 0x53, 0x2d, 0xe9, 0x76, 0x1e, 0x37, 0xdd, 0x94, + 0xaf, 0x92, 0x5b, 0x6a, 0x66, 0x29, 0x7a, 0x2d, 0xe5, 0x81, 0x66, 0xbb, 0x95, 0xf5, 0xd7, 0x7f, 0x6a, 0xf8, 0x04, + 0xd0, 0x45, 0xc2, 0xca, 0xc4, 0xb7, 0xa8, 0xc1, 0x2f, 0x3e, 0x1c, 0x9c, 0x8c, 0x61, 0x7b, 0xa8, 0xc5, 0xdc, 0xe1, + 0x38, 0xc7, 0xfe, 0x3d, 0x90, 0x1b, 0xdc, 0x4a, 0xa0, 0xe4, 0x6b, 0x59, 0x84, 0x99, 0xcc, 0x62, 0xa0, 0x72, 0x35, + 0xe8, 0x3a, 0xb0, 0x90, 0x35, 0xb5, 0xe6, 0x87, 0xfe, 0xa7, 0x0a, 0x32, 0xf7, 0x6c, 0x95, 0x02, 0x24, 0xc8, 0xb7, + 0xd2, 0x36, 0xed, 0x7d, 0x8b, 0xdc, 0x41, 0xf7, 0x08, 0x16, 0xb5, 0xdd, 0x61, 0x02, 0x68, 0xa1, 0x83, 0x10, 0x52, + 0xe7, 0x53, 0x28, 0x7a, 0xb9, 0x49, 0x26, 0x74, 0xae, 0x05, 0x9e, 0x2f, 0x1d, 0x1c, 0xfd, 0xfb, 0xe3, 0x81, 0x72, + 0x45, 0x02, 0x97, 0x13, 0x7c, 0x0a, 0x9b, 0xda, 0x9c, 0x01, 0x65, 0xa4, 0x7d, 0x75, 0xb8, 0x62, 0x1f, 0x05, 0xac, + 0x0b, 0x9d, 0x59, 0x08, 0x15, 0x99, 0xec, 0x48, 0xd8, 0x17, 0x45, 0x33, 0xc4, 0x79, 0xc1, 0x55, 0x6c, 0x03, 0x9f, + 0xdb, 0xa4, 0x83, 0x98, 0x8b, 0xb6, 0x05, 0x1f, 0x0b, 0xaa, 0xcc, 0x09, 0x8b, 0x6e, 0x80, 0xd1, 0x5e, 0x7b, 0xa9, + 0xf5, 0x83, 0x76, 0x42, 0x67, 0xc5, 0xbd, 0xeb, 0x2a, 0xc2, 0xc0, 0x27, 0xd8, 0xa9, 0xfd, 0x2b, 0x8a, 0xe3, 0x6f, + 0x9b, 0x71, 0xb4, 0xe0, 0x53, 0x04, 0x06, 0x90, 0x90, 0x6e, 0x98, 0x6d, 0xcd, 0x08, 0x3a, 0x7e, 0x08, 0x35, 0x4a, + 0x01, 0x29, 0x8d, 0x30, 0x38, 0xca, 0xe4, 0x37, 0x41, 0x86, 0xe4, 0xbc, 0x9c, 0xa3, 0x87, 0x21, 0x46, 0x0e, 0x48, + 0x65, 0xae, 0x6c, 0xc7, 0x5e, 0x55, 0x4f, 0x85, 0x3c, 0x71, 0x0e, 0x62, 0x31, 0xf4, 0xc8, 0x88, 0x3f, 0xc8, 0x54, + 0x67, 0xa0, 0x89, 0x01, 0x33, 0x82, 0x03, 0xb1, 0x29, 0x68, 0x84, 0xc0, 0x09, 0x59, 0xb6, 0x7c, 0x29, 0x56, 0x01, + 0x89, 0x50, 0xc4, 0xa2, 0x25, 0x92, 0x1f, 0x31, 0x32, 0x30, 0x43, 0x12, 0xe8, 0x31, 0x7b, 0x4d, 0x07, 0xc6, 0x05, + 0x18, 0x53, 0xa9, 0x1e, 0x40, 0x3e, 0x05, 0xa3, 0xb0, 0x88, 0x50, 0xcb, 0x5d, 0x79, 0x91, 0x34, 0x34, 0x58, 0xc3, + 0xb1, 0x68, 0x2e, 0xe6, 0x28, 0xbd, 0x67, 0xca, 0x10, 0x24, 0x57, 0xad, 0x8c, 0xb0, 0xd3, 0x9f, 0xc7, 0x21, 0xe4, + 0xab, 0x0e, 0x42, 0x9b, 0x1b, 0x67, 0x11, 0x20, 0xf4, 0x48, 0x6c, 0x63, 0x8c, 0x80, 0xa4, 0xa1, 0x03, 0xa9, 0x0b, + 0x10, 0x21, 0x21, 0x44, 0x92, 0x80, 0xe6, 0x7c, 0x8b, 0x44, 0x7c, 0x06, 0x61, 0xae, 0x0b, 0xd2, 0x64, 0x89, 0x4a, + 0xbf, 0x6f, 0x96, 0x61, 0xb9, 0xc3, 0xc9, 0x2c, 0xc8, 0x55, 0x95, 0xb3, 0x00, 0x89, 0x84, 0xd9, 0xea, 0x84, 0xa1, + 0xf3, 0x46, 0xfb, 0x49, 0xc0, 0xd9, 0xc2, 0x84, 0x0c, 0x04, 0xa3, 0x58, 0x14, 0x85, 0x4a, 0xf5, 0x49, 0x81, 0xc3, + 0x08, 0x0d, 0xef, 0x2e, 0x0a, 0x37, 0xf3, 0x64, 0x2d, 0xab, 0xe2, 0x11, 0x93, 0xfb, 0xa1, 0x96, 0x38, 0xa7, 0x40, + 0x72, 0x82, 0xa2, 0xd1, 0xfd, 0xd7, 0xcf, 0x1d, 0x95, 0x44, 0x78, 0xd1, 0xa2, 0xf4, 0x6b, 0x8b, 0xdb, 0x5c, 0xcd, + 0x09, 0x34, 0x69, 0x66, 0xc8, 0x37, 0x9d, 0x8a, 0xf9, 0x95, 0xc1, 0xe5, 0x2e, 0xd8, 0x10, 0x40, 0x9b, 0x41, 0xef, + 0x4b, 0xeb, 0x53, 0xfa, 0x01, 0x46, 0xdf, 0xb8, 0xf3, 0xc2, 0x68, 0x27, 0xeb, 0xbd, 0xa1, 0x0b, 0xeb, 0x67, 0x57, + 0xb5, 0xd3, 0x71, 0x44, 0x02, 0x67, 0x2d, 0x74, 0xc8, 0xe6, 0x95, 0xb0, 0x9c, 0xd9, 0xe2, 0xec, 0xd1, 0xaa, 0xb5, + 0x1c, 0x91, 0x8e, 0x34, 0x1c, 0x90, 0xe3, 0xd9, 0x07, 0xa8, 0xf3, 0x08, 0x18, 0x49, 0x39, 0xf3, 0x5e, 0x71, 0x9c, + 0x37, 0x44, 0x1a, 0xea, 0x39, 0x2f, 0x00, 0xec, 0xca, 0x22, 0x29, 0x79, 0x1d, 0x72, 0x2d, 0xfd, 0xe9, 0x98, 0x47, + 0x8c, 0xb1, 0x73, 0x2a, 0x23, 0x8c, 0x4e, 0xae, 0x6b, 0x8e, 0x8c, 0xb2, 0x0b, 0x26, 0x54, 0xf3, 0xae, 0x34, 0xe5, + 0x81, 0x2c, 0xb2, 0xe9, 0x4a, 0x0b, 0x4e, 0x47, 0x62, 0xae, 0x6e, 0x56, 0x51, 0x3d, 0x4c, 0x10, 0xb1, 0xde, 0xbe, + 0xc1, 0xe4, 0x11, 0xcf, 0x27, 0x82, 0x54, 0xa4, 0xcd, 0xe9, 0x59, 0xc9, 0x07, 0xcc, 0x16, 0x68, 0xb4, 0xf2, 0x5e, + 0x00, 0x94, 0xdf, 0x94, 0xa8, 0x48, 0xb9, 0x6c, 0xd1, 0x41, 0x34, 0xe2, 0xd7, 0x41, 0x36, 0xeb, 0x3d, 0x39, 0x9e, + 0x6f, 0x8d, 0xac, 0x86, 0xc8, 0xd0, 0xea, 0xe8, 0x37, 0x74, 0xe8, 0x2b, 0xc2, 0xa4, 0xd3, 0xf3, 0xd8, 0xd6, 0x02, + 0x2d, 0x86, 0x8a, 0xa7, 0x62, 0x8c, 0x93, 0xea, 0x1a, 0xb1, 0x4c, 0xa9, 0x6f, 0x31, 0xd1, 0x15, 0xf4, 0x93, 0x2d, + 0x05, 0x9b, 0x6f, 0x59, 0xc9, 0x8b, 0x8c, 0x08, 0x7b, 0x8d, 0xf0, 0x62, 0x18, 0x03, 0xf4, 0xaa, 0xa5, 0x74, 0x1e, + 0xe8, 0xad, 0xe8, 0x8a, 0x79, 0xec, 0xc3, 0xeb, 0x2e, 0x49, 0x5e, 0xe0, 0xd6, 0x3c, 0x66, 0x35, 0x96, 0xdf, 0xbc, + 0xfe, 0xc6, 0x54, 0x25, 0xd6, 0xca, 0xca, 0x4f, 0xba, 0x6c, 0xdf, 0x0f, 0x49, 0x83, 0xbc, 0x4d, 0x6b, 0xfb, 0xbd, + 0xc9, 0x37, 0x10, 0x1b, 0x8c, 0xa2, 0x99, 0x2e, 0x16, 0x87, 0x05, 0xd2, 0xaf, 0x97, 0xa0, 0x2b, 0xd3, 0x0c, 0xd2, + 0xbe, 0xaf, 0x2f, 0x7f, 0x03, 0x98, 0x11, 0x63, 0x1f, 0x72, 0x22, 0x5a, 0x89, 0x66, 0xcb, 0xfc, 0xec, 0xec, 0x2d, + 0x08, 0x01, 0x33, 0xd9, 0xcf, 0x0f, 0x33, 0x43, 0xc2, 0x5e, 0x33, 0x13, 0xa1, 0xc0, 0x9a, 0x66, 0x9e, 0x5d, 0xcd, + 0xed, 0xd3, 0x52, 0xb4, 0x78, 0xac, 0x75, 0x95, 0xfa, 0x5e, 0xc6, 0x93, 0x8b, 0xd8, 0x9e, 0x67, 0x68, 0x3d, 0x63, + 0xa4, 0x41, 0x87, 0x17, 0x22, 0x62, 0x8b, 0x67, 0xff, 0x81, 0x99, 0x19, 0x85, 0x80, 0x6a, 0x0a, 0x7d, 0x7b, 0x8b, + 0x78, 0x2c, 0x4d, 0x9e, 0x91, 0xd9, 0xf7, 0x24, 0xdf, 0xac, 0x93, 0xf7, 0x5e, 0xaf, 0x5c, 0xad, 0x70, 0x6a, 0x85, + 0x1b, 0xe8, 0x51, 0xbf, 0xd5, 0x90, 0x28, 0x42, 0x0e, 0xe3, 0xd2, 0x2f, 0xea, 0x08, 0xe7, 0x02, 0xaf, 0xa7, 0x6e, + 0xeb, 0x7a, 0x48, 0x35, 0x05, 0x71, 0xee, 0xb6, 0x70, 0x46, 0x6f, 0xcd, 0x91, 0xa1, 0x3b, 0xce, 0xf2, 0x42, 0x5d, + 0xdd, 0x1d, 0x98, 0x76, 0x68, 0x68, 0x78, 0x5c, 0xd7, 0xa3, 0xc9, 0x23, 0x11, 0x4d, 0xdc, 0x5a, 0xac, 0xbf, 0x23, + 0xca, 0x3c, 0x0d, 0x60, 0xa7, 0x31, 0xea, 0xbf, 0x4b, 0xf6, 0x68, 0x74, 0xc7, 0x24, 0x91, 0x0d, 0x99, 0x6d, 0x40, + 0x9b, 0x83, 0x23, 0x3d, 0xf5, 0x15, 0x95, 0xdf, 0x4b, 0x14, 0x1c, 0x2f, 0xc5, 0x2d, 0x97, 0xf8, 0xab, 0x78, 0xe8, + 0xe9, 0x24, 0xa6, 0xc1, 0x0d, 0x59, 0x5c, 0x19, 0xe0, 0x32, 0x69, 0x0b, 0x0b, 0x68, 0xd8, 0xc0, 0x02, 0x0a, 0xa3, + 0xcf, 0x61, 0x92, 0x88, 0x7b, 0x38, 0x64, 0xbb, 0xc9, 0x7b, 0x71, 0x4c, 0x14, 0xcf, 0xd5, 0xe4, 0xe8, 0x82, 0x17, + 0xd3, 0x41, 0xd4, 0xec, 0x34, 0xd2, 0xcf, 0x30, 0xbd, 0x97, 0xad, 0xeb, 0xc8, 0x00, 0x61, 0x06, 0x15, 0xea, 0x17, + 0xd2, 0x3e, 0x7b, 0x39, 0x64, 0x40, 0xd1, 0xa0, 0xce, 0x86, 0x1d, 0x62, 0x51, 0xc8, 0x6b, 0x17, 0x4f, 0xb8, 0x96, + 0x78, 0x8f, 0x1e, 0x65, 0x58, 0x5c, 0xe6, 0x63, 0xb4, 0xf3, 0x56, 0x96, 0xa6, 0x0b, 0xcb, 0xf9, 0x5d, 0x8c, 0x16, + 0xe8, 0x70, 0xf5, 0xb8, 0x48, 0xf7, 0x53, 0x7b, 0x5e, 0xf8, 0x9f, 0x43, 0x17, 0x5d, 0xfb, 0x4c, 0x26, 0x75, 0x25, + 0x8f, 0x11, 0xf5, 0x55, 0x2f, 0xac, 0xe2, 0xde, 0x6b, 0xcd, 0xf4, 0x51, 0x8e, 0x32, 0x0f, 0x55, 0x66, 0x0d, 0xc6, + 0xd3, 0x92, 0x0c, 0x1f, 0x1d, 0x01, 0x0e, 0x41, 0x13, 0x82, 0x99, 0xfb, 0x92, 0x18, 0xa3, 0x12, 0x30, 0xee, 0x2c, + 0xb0, 0xbc, 0x9e, 0xdd, 0xd3, 0xd0, 0x16, 0x5a, 0x3e, 0xe5, 0xfc, 0x83, 0x2d, 0x96, 0xf9, 0xa9, 0xb0, 0x59, 0xe2, + 0xe2, 0x8e, 0x85, 0x3c, 0xea, 0x45, 0x55, 0xda, 0x5a, 0xf9, 0x8a, 0x54, 0x76, 0x43, 0x16, 0x5e, 0xd6, 0x2d, 0x2f, + 0x45, 0xe7, 0x55, 0x8c, 0x72, 0x92, 0x63, 0x0c, 0xc5, 0x10, 0xe0, 0xcd, 0x1c, 0x74, 0xf7, 0x02, 0x67, 0x72, 0x03, + 0x99, 0xe9, 0xeb, 0xd8, 0x52, 0x41, 0x1e, 0xec, 0xea, 0x99, 0x85, 0x07, 0x90, 0xc8, 0xf2, 0xf1, 0x9c, 0x8c, 0x2d, + 0xcb, 0x93, 0xef, 0xe5, 0x93, 0x60, 0x06, 0xaf, 0x02, 0x64, 0xd9, 0x79, 0xcd, 0xc1, 0x9f, 0x75, 0x87, 0x73, 0x4b, + 0x6b, 0x83, 0x6a, 0x1f, 0x7a, 0xce, 0x96, 0x0c, 0xbe, 0x12, 0x60, 0x34, 0x13, 0xa8, 0x2c, 0x41, 0x30, 0x4b, 0x8b, + 0xf9, 0x82, 0x60, 0x8e, 0xa3, 0x50, 0xb0, 0x3a, 0xe5, 0xe7, 0x61, 0x53, 0x14, 0x45, 0x3c, 0xfc, 0x3c, 0x0e, 0x95, + 0x67, 0x84, 0x55, 0x7c, 0xad, 0x88, 0xf2, 0xa1, 0xc6, 0x93, 0x81, 0x14, 0x40, 0xff, 0xa6, 0x2b, 0xa2, 0xfd, 0x15, + 0x69, 0x14, 0x14, 0xf6, 0x99, 0xbb, 0xd0, 0xce, 0x1a, 0x71, 0x91, 0x7e, 0x93, 0x61, 0x5e, 0x89, 0x67, 0x7e, 0x65, + 0x5d, 0xd6, 0x3a, 0xdf, 0x83, 0x6a, 0x3f, 0x52, 0xda, 0x59, 0xce, 0x2c, 0x39, 0x40, 0xbb, 0xa6, 0x69, 0x33, 0x9f, + 0x90, 0xb3, 0xb8, 0xda, 0x61, 0x0a, 0x52, 0x81, 0x57, 0x4d, 0x23, 0x95, 0xe2, 0xbc, 0x13, 0x05, 0x1c, 0x2e, 0xa7, + 0xf8, 0xbf, 0x39, 0x51, 0xbb, 0xf9, 0x05, 0x79, 0x6c, 0xef, 0xea, 0x97, 0x83, 0xac, 0x2d, 0x1c, 0x1d, 0x5c, 0xe7, + 0xb8, 0x89, 0x1a, 0xa2, 0x2a, 0x78, 0x6b, 0xc8, 0x97, 0xe6, 0x21, 0x05, 0x96, 0x23, 0x2d, 0x5a, 0x7d, 0x1e, 0xf7, + 0x89, 0x68, 0x9f, 0xba, 0x70, 0x3a, 0x2e, 0x33, 0x36, 0x87, 0xba, 0xc8, 0x8f, 0x49, 0xdb, 0x03, 0x06, 0x96, 0x7a, + 0xa2, 0x8d, 0x0f, 0x5d, 0xc4, 0x6d, 0x77, 0x06, 0xd2, 0xf5, 0x72, 0x1a, 0x4a, 0x66, 0x31, 0x70, 0xe1, 0x68, 0xcc, + 0xe3, 0x06, 0x9d, 0x76, 0xc5, 0x46, 0x64, 0x77, 0x30, 0x5c, 0x89, 0x51, 0xd5, 0x61, 0xec, 0x2e, 0x6a, 0x4e, 0xb0, + 0x52, 0x3d, 0xf6, 0x59, 0x74, 0x40, 0x82, 0x27, 0x14, 0x1c, 0x79, 0xe0, 0x11, 0x3e, 0xab, 0x83, 0x0e, 0x8f, 0x3a, + 0x03, 0xab, 0xea, 0x06, 0xdb, 0xea, 0x30, 0x06, 0xca, 0x11, 0x84, 0x22, 0xf2, 0xdd, 0x82, 0x3a, 0x85, 0xc7, 0xfc, + 0x86, 0x30, 0xa5, 0xf4, 0x7c, 0xce, 0xf6, 0xe2, 0xdb, 0x01, 0xfb, 0xdd, 0x27, 0x5e, 0xd2, 0x35, 0x8c, 0xc3, 0x0f, + 0xff, 0xaa, 0xc5, 0xf2, 0xeb, 0x01, 0xe6, 0xf7, 0x41, 0xaa, 0x4b, 0x58, 0xcb, 0x19, 0xc0, 0x1f, 0x6d, 0x19, 0x77, + 0x0d, 0x86, 0xf5, 0x11, 0x2a, 0x22, 0x3c, 0xe2, 0xa0, 0x7f, 0xaa, 0x05, 0x80, 0xe2, 0x38, 0xad, 0x80, 0xc8, 0x42, + 0x34, 0x3f, 0x2f, 0x67, 0x5f, 0x96, 0x65, 0x68, 0x4b, 0x4b, 0x56, 0x8f, 0x13, 0x69, 0xd8, 0x4c, 0x82, 0x4a, 0x88, + 0x5e, 0x11, 0x31, 0x22, 0x66, 0x86, 0xd6, 0x4b, 0xfb, 0x3d, 0x75, 0x57, 0x10, 0x46, 0xad, 0xdb, 0x70, 0xaf, 0xeb, + 0x51, 0x6f, 0xa4, 0xd9, 0xaf, 0xb5, 0x32, 0x80, 0x7d, 0x4b, 0xbe, 0xc0, 0x91, 0x84, 0x2d, 0xed, 0xf8, 0xef, 0x03, + 0xb1, 0xe8, 0x1f, 0x42, 0xd8, 0xc4, 0x26, 0xc8, 0x19, 0xbc, 0xd4, 0x3a, 0x7b, 0x1b, 0x24, 0xc2, 0x24, 0xd6, 0x6a, + 0x3d, 0x85, 0x24, 0x9a, 0x00, 0x52, 0xa1, 0x7d, 0xc6, 0xf4, 0x8a, 0x54, 0x9c, 0x3f, 0xdf, 0xb5, 0x6c, 0xae, 0x9a, + 0xf2, 0x89, 0x95, 0x23, 0xce, 0xd6, 0x4f, 0x96, 0x24, 0x9b, 0xf0, 0x5d, 0x22, 0xc1, 0x37, 0x16, 0xbb, 0xca, 0xab, + 0x7c, 0x0d, 0x9a, 0x14, 0x02, 0x1d, 0x5c, 0xee, 0x1c, 0x32, 0xd4, 0x62, 0x19, 0xd5, 0xd1, 0x16, 0x8b, 0x4c, 0xef, + 0x77, 0xca, 0xea, 0xb3, 0x08, 0x0d, 0x27, 0x16, 0xc3, 0x28, 0x95, 0x5e, 0x6c, 0xd1, 0xca, 0x9f, 0xf4, 0x7f, 0xc8, + 0x02, 0xa5, 0xea, 0x78, 0x89, 0x5b, 0x35, 0x74, 0x87, 0xae, 0xa8, 0x37, 0xa2, 0xb5, 0x63, 0xff, 0xf2, 0xc6, 0xa4, + 0x8e, 0x35, 0x6d, 0x10, 0xbc, 0x0e, 0xfa, 0x99, 0x29, 0x38, 0xd9, 0x78, 0x15, 0xe9, 0x14, 0x06, 0x04, 0x0a, 0x61, + 0x08, 0xf6, 0x19, 0xc9, 0xa6, 0xa5, 0x74, 0x67, 0x17, 0x27, 0xea, 0xd8, 0x38, 0x33, 0xca, 0xda, 0x45, 0xbc, 0xb4, + 0xf1, 0xd6, 0x13, 0x7a, 0xf1, 0xbd, 0x78, 0xb6, 0xe2, 0xa4, 0xb6, 0x8c, 0x88, 0x17, 0x1c, 0x0f, 0x97, 0x31, 0x87, + 0x6a, 0xe3, 0xd6, 0x82, 0x1e, 0x13, 0x5a, 0x0d, 0x9b, 0x9d, 0xb5, 0x9c, 0xf2, 0xb5, 0x18, 0x17, 0xe5, 0x8b, 0x37, + 0x0b, 0x28, 0x03, 0x42, 0x47, 0x8b, 0x48, 0x02, 0x9f, 0x15, 0x76, 0x63, 0x8e, 0x27, 0xc9, 0x92, 0xf9, 0xb5, 0x92, + 0x47, 0x80, 0x99, 0x18, 0x2e, 0xde, 0x86, 0xac, 0x9e, 0xa0, 0x4b, 0x76, 0xb0, 0x52, 0x37, 0x08, 0xb2, 0x04, 0x3b, + 0xc0, 0x5f, 0x78, 0x3f, 0xc6, 0xde, 0x39, 0xbf, 0xd9, 0x3a, 0xfc, 0x3f, 0xc1, 0x83, 0x79, 0x58, 0xdb, 0xee, 0x17, + 0x1b, 0xf5, 0xe5, 0xff, 0x4f, 0x75, 0x0d, 0xad, 0x03, 0x1f, 0x3e, 0x80, 0xf0, 0x78, 0x79, 0xa8, 0x45, 0xab, 0xad, + 0xbd, 0xc3, 0x90, 0x4c, 0x9c, 0x28, 0x2b, 0x76, 0x54, 0xef, 0x50, 0xb4, 0x9b, 0xf9, 0xb3, 0x23, 0x03, 0xd4, 0x3f, + 0x98, 0x78, 0x1f, 0x34, 0xd2, 0xdd, 0x2f, 0x20, 0x13, 0xeb, 0x51, 0x87, 0x5c, 0xa5, 0xf4, 0xf3, 0x73, 0xf7, 0xd6, + 0x7d, 0x94, 0xae, 0xd2, 0xc1, 0xfd, 0x45, 0x57, 0xed, 0xc1, 0x06, 0x17, 0x3b, 0xc5, 0xad, 0x5a, 0xfb, 0xa4, 0x74, + 0x95, 0x25, 0x3e, 0x04, 0x20, 0xc0, 0x56, 0x99, 0xc9, 0xca, 0x53, 0xbe, 0x85, 0x84, 0x77, 0xad, 0x4f, 0x67, 0x7f, + 0xbd, 0x0e, 0x6f, 0x14, 0x6b, 0xbb, 0x8b, 0x47, 0x6b, 0x07, 0x04, 0xe5, 0xdc, 0x6b, 0x28, 0x27, 0x10, 0xe2, 0x25, + 0x62, 0xae, 0x00, 0x97, 0xc3, 0xc8, 0x78, 0x8a, 0x1c, 0x39, 0x44, 0xb7, 0x11, 0xc1, 0xba, 0x4a, 0x5b, 0x15, 0xc7, + 0x5e, 0xcb, 0x23, 0xb3, 0x85, 0x71, 0x13, 0x11, 0x87, 0x45, 0x05, 0x46, 0x9e, 0x86, 0x1d, 0xce, 0x76, 0x86, 0x5e, + 0xcd, 0x42, 0x16, 0xa4, 0x09, 0xdb, 0xa5, 0x7e, 0x1f, 0x4e, 0x4e, 0x58, 0x7d, 0xd5, 0x42, 0xec, 0x05, 0x70, 0x9a, + 0xbc, 0x35, 0xe4, 0x57, 0x67, 0x7a, 0x46, 0xb8, 0x2c, 0x92, 0x7b, 0x2c, 0x04, 0xa1, 0xb2, 0xb5, 0x5d, 0x26, 0xcb, + 0xd2, 0x31, 0xc4, 0xfb, 0x8c, 0x21, 0xcc, 0xf0, 0x82, 0x40, 0xa6, 0x09, 0x4a, 0x19, 0x7e, 0x0b, 0xf7, 0x5c, 0x60, + 0x6c, 0x90, 0x9b, 0xe9, 0x30, 0x12, 0xae, 0xe8, 0x76, 0x80, 0xc8, 0xd2, 0x7c, 0xa2, 0x58, 0x4d, 0x55, 0x87, 0x7d, + 0x67, 0x12, 0xa2, 0xf6, 0x88, 0xf5, 0x78, 0x4a, 0xb7, 0xdb, 0x49, 0xbe, 0xca, 0x5c, 0x8a, 0x21, 0xa2, 0x4a, 0x47, + 0xee, 0x92, 0x6b, 0xe2, 0x94, 0x58, 0x5a, 0x65, 0x1c, 0x24, 0xb4, 0x63, 0xa1, 0x6d, 0x3c, 0xa5, 0x07, 0x91, 0xb6, + 0x8b, 0x5d, 0x52, 0xa5, 0x93, 0xc7, 0xfc, 0x88, 0x18, 0x32, 0xd3, 0x2f, 0xb0, 0xb6, 0xbf, 0xdc, 0x7c, 0x0a, 0x47, + 0x45, 0x62, 0xe7, 0x8e, 0xc0, 0x1f, 0x03, 0x6c, 0x5e, 0x4a, 0x4b, 0x61, 0x54, 0xa1, 0x73, 0xd5, 0x56, 0x2f, 0x0c, + 0x65, 0x43, 0x88, 0x40, 0x32, 0xcb, 0x12, 0x3e, 0xca, 0x1a, 0x06, 0x39, 0xf5, 0xbd, 0x06, 0x64, 0xdb, 0x83, 0x60, + 0xf9, 0x48, 0x95, 0xa5, 0xbe, 0xbf, 0x7c, 0x36, 0x09, 0x1f, 0xeb, 0x10, 0x66, 0x19, 0x70, 0xcd, 0x7a, 0xef, 0x86, + 0xc6, 0xfd, 0x61, 0x06, 0xf5, 0x2f, 0x5c, 0xe9, 0x1b, 0x7c, 0x8d, 0x3c, 0x16, 0x2e, 0xf5, 0xc8, 0x7b, 0x4b, 0x9e, + 0x6d, 0x53, 0xf2, 0x99, 0x16, 0x2b, 0xde, 0xc0, 0x67, 0x11, 0xef, 0x5a, 0xf1, 0x7d, 0x59, 0xdd, 0xd9, 0x76, 0xe6, + 0x04, 0xd3, 0x0c, 0xf6, 0x60, 0x86, 0xee, 0xfa, 0xa0, 0x95, 0x4a, 0x53, 0x47, 0xfa, 0xf6, 0xc1, 0xc7, 0xad, 0xf7, + 0x7f, 0x21, 0x4d, 0x74, 0x03, 0x84, 0xa2, 0xd2, 0xd7, 0x21, 0xca, 0x0e, 0x69, 0x62, 0xda, 0xa1, 0x4a, 0x14, 0x1d, + 0x3a, 0x65, 0x96, 0xa5, 0x00, 0xc3, 0x37, 0x96, 0x1f, 0x29, 0x5c, 0x2b, 0xc9, 0x0d, 0x84, 0x5a, 0x83, 0xf8, 0x6c, + 0x32, 0xbd, 0x2f, 0xd3, 0x82, 0x02, 0x16, 0x4c, 0xbe, 0x8e, 0x61, 0x17, 0xe9, 0xef, 0xe6, 0x0d, 0x09, 0xce, 0x09, + 0x87, 0x23, 0x1b, 0x08, 0xa0, 0x4c, 0xdb, 0x05, 0x17, 0xf7, 0x1b, 0xca, 0x9f, 0x5b, 0x69, 0xcf, 0x90, 0x5a, 0x70, + 0x18, 0xe8, 0x25, 0xfa, 0xbf, 0xee, 0x0c, 0x1f, 0xca, 0xe3, 0x85, 0x83, 0x39, 0x11, 0x6e, 0x71, 0xf6, 0x95, 0x65, + 0x56, 0xb9, 0xe2, 0xfe, 0xc0, 0xc8, 0x44, 0x6b, 0xd7, 0xd7, 0x07, 0xab, 0x15, 0xb5, 0x0a, 0x35, 0xf4, 0x95, 0xfb, + 0x9f, 0xe9, 0x5e, 0xee, 0x99, 0x31, 0x0f, 0xc5, 0xdc, 0x61, 0x5e, 0x34, 0x34, 0x3e, 0x43, 0x34, 0x44, 0xa9, 0xb1, + 0x1a, 0x70, 0x32, 0x26, 0xf5, 0xf1, 0xa0, 0xc3, 0x52, 0x3a, 0x27, 0x46, 0x95, 0x5a, 0x64, 0x90, 0x60, 0x72, 0x3c, + 0x97, 0x36, 0x87, 0x02, 0x11, 0x34, 0xf3, 0x1a, 0x1a, 0xfd, 0x28, 0x87, 0x15, 0x6e, 0x2c, 0xcb, 0x25, 0x86, 0x8c, + 0x20, 0xa8, 0x2c, 0x1b, 0x37, 0x75, 0x93, 0xa0, 0x28, 0x9c, 0xfa, 0xb1, 0x41, 0x41, 0xf1, 0xdb, 0x99, 0x2f, 0x4d, + 0x76, 0xdc, 0x3d, 0x1a, 0xc0, 0xa2, 0x58, 0x97, 0x78, 0xd9, 0xc5, 0x44, 0x6e, 0x72, 0x83, 0x55, 0x46, 0x20, 0xe6, + 0xf0, 0x27, 0xa8, 0x92, 0x22, 0xa6, 0x8b, 0xb8, 0xb9, 0x34, 0x17, 0x47, 0x32, 0xb5, 0xab, 0x07, 0x6e, 0x43, 0xa3, + 0x5a, 0x4d, 0xf4, 0xda, 0x32, 0x3f, 0x91, 0x88, 0x4e, 0x58, 0x3c, 0x91, 0x57, 0x4c, 0x44, 0x12, 0x0c, 0x0c, 0x28, + 0xda, 0x16, 0x42, 0x51, 0xe8, 0x35, 0x9f, 0xae, 0x96, 0xf3, 0x73, 0xb9, 0x05, 0x49, 0xa1, 0xd1, 0xef, 0x13, 0x48, + 0xf5, 0xd3, 0xa6, 0x3f, 0x61, 0xf1, 0x3f, 0x89, 0x09, 0xb7, 0x3d, 0xf4, 0x0c, 0xc4, 0xa7, 0x1e, 0xe0, 0xd3, 0x53, + 0x07, 0x0a, 0xd3, 0xcb, 0x17, 0xc1, 0x83, 0x22, 0xea, 0xc6, 0x9c, 0x58, 0xf2, 0x18, 0x4a, 0x7c, 0x5f, 0x95, 0x4f, + 0x31, 0xa3, 0xda, 0x4a, 0xe1, 0x9e, 0x04, 0x8a, 0x26, 0xae, 0x64, 0xf3, 0x39, 0x65, 0x5c, 0x86, 0xe2, 0xe3, 0x84, + 0xf3, 0x86, 0xe5, 0x52, 0x16, 0x4a, 0x5e, 0xe1, 0xfd, 0x60, 0x0e, 0x21, 0xcb, 0x15, 0xa9, 0x21, 0xbf, 0x2a, 0x61, + 0x7f, 0x0f, 0xa4, 0x71, 0x05, 0x63, 0xb6, 0xf6, 0x0a, 0xeb, 0xc7, 0x62, 0xa5, 0x1f, 0x90, 0x6b, 0xc4, 0x3d, 0x1c, + 0x32, 0x00, 0xc3, 0x7e, 0x77, 0x44, 0xcd, 0x48, 0x85, 0x0b, 0x73, 0xf7, 0x92, 0x40, 0xc2, 0x36, 0x08, 0x9b, 0xed, + 0x8b, 0x79, 0xf8, 0xf8, 0x57, 0x6b, 0xce, 0x0e, 0xd6, 0x4a, 0xb8, 0x74, 0x74, 0x95, 0x09, 0xf2, 0xf2, 0x31, 0x12, + 0x67, 0x6e, 0xa7, 0xa9, 0x65, 0x41, 0x54, 0x5a, 0x8c, 0x67, 0x2b, 0x71, 0xb3, 0x4c, 0xe1, 0xb1, 0xc7, 0x04, 0xed, + 0xcc, 0x4b, 0x70, 0x09, 0x88, 0x3e, 0xc8, 0xf8, 0xca, 0x3a, 0x89, 0x5e, 0x79, 0x36, 0xfe, 0x2c, 0xbb, 0xf7, 0xa8, + 0xff, 0xaa, 0x48, 0xed, 0x7a, 0xd6, 0xdd, 0xa1, 0x24, 0x15, 0x4c, 0xbb, 0x1b, 0xf0, 0x71, 0xd2, 0x4f, 0x4c, 0xbe, + 0x51, 0x10, 0x37, 0xc0, 0xd9, 0x77, 0xe3, 0x40, 0xb7, 0x80, 0xf5, 0xe6, 0x83, 0x44, 0x03, 0x57, 0x23, 0xd2, 0xb9, + 0x59, 0xaf, 0xaf, 0x4d, 0x0b, 0x05, 0x20, 0x05, 0xb3, 0x92, 0x90, 0xbc, 0x2b, 0x17, 0x6d, 0x7d, 0x22, 0xb6, 0x00, + 0x62, 0xba, 0x81, 0xc4, 0x71, 0x44, 0xb9, 0xc6, 0xa3, 0x6f, 0x96, 0x1e, 0x3d, 0xeb, 0x88, 0xdd, 0x3f, 0x85, 0xd6, + 0xf4, 0xb2, 0x83, 0xed, 0x9c, 0x22, 0xa8, 0x50, 0x86, 0x8e, 0xea, 0xd9, 0x0d, 0x9b, 0x5b, 0xc7, 0xb2, 0xd0, 0xa3, + 0x87, 0x20, 0x96, 0xcc, 0x7b, 0xdb, 0x08, 0x8d, 0x10, 0xdf, 0xfd, 0x42, 0xc0, 0x38, 0x5a, 0xff, 0x42, 0xab, 0x6c, + 0xa8, 0xe3, 0xd4, 0xc6, 0x83, 0x8f, 0x9b, 0x55, 0x61, 0xe5, 0x92, 0xf9, 0xdc, 0xbb, 0x63, 0x8a, 0x7a, 0x2a, 0xdf, + 0x7a, 0x2d, 0x7b, 0x32, 0x3a, 0x6a, 0x68, 0x8f, 0x7c, 0xd2, 0xd6, 0xb7, 0x86, 0xad, 0x48, 0x1a, 0xc9, 0xa4, 0xb9, + 0xf3, 0xc1, 0x09, 0xb5, 0x79, 0xd8, 0x21, 0x71, 0xc2, 0xdc, 0xfa, 0xdd, 0x3c, 0x92, 0xb2, 0x78, 0x04, 0x5b, 0xf8, + 0x66, 0x68, 0xd3, 0x30, 0x26, 0x1d, 0x27, 0xe0, 0xba, 0xd2, 0x3f, 0xcd, 0xa0, 0xc4, 0x6a, 0x61, 0x61, 0x3c, 0x03, + 0x98, 0x8a, 0x29, 0xe2, 0xa5, 0x0a, 0x86, 0x1a, 0x24, 0xe7, 0x6a, 0x10, 0xcc, 0x74, 0xcc, 0xd8, 0x99, 0x97, 0x79, + 0x0f, 0x6d, 0x6d, 0xcc, 0xc2, 0x42, 0xcf, 0xc6, 0xd4, 0x3c, 0xaa, 0x14, 0x30, 0x35, 0x82, 0x6e, 0x87, 0x71, 0x71, + 0xb7, 0x47, 0x7e, 0x5a, 0x8e, 0x9c, 0x5d, 0x0c, 0x8e, 0xc7, 0x5e, 0x66, 0x8b, 0x53, 0x0f, 0x9e, 0x07, 0x98, 0x11, + 0x2a, 0x6c, 0x15, 0x2f, 0xd0, 0x9e, 0x35, 0xfd, 0x07, 0xbe, 0x89, 0x8d, 0x31, 0x98, 0x37, 0xc6, 0xd1, 0x9a, 0xa5, + 0x2b, 0xde, 0xd3, 0x30, 0x42, 0x16, 0x31, 0x22, 0xcb, 0x59, 0x53, 0xcc, 0xad, 0x54, 0x31, 0x9e, 0x41, 0x22, 0x58, + 0xbe, 0xc2, 0x54, 0x00, 0xe1, 0x60, 0x76, 0xa3, 0xc1, 0x6e, 0xd6, 0xc7, 0xb5, 0x7e, 0x04, 0x44, 0x60, 0x00, 0xd5, + 0xc5, 0x39, 0xd7, 0x26, 0x3a, 0x00, 0x96, 0xdf, 0x47, 0x00, 0x20, 0x09, 0xcc, 0x50, 0x24, 0xa0, 0xe8, 0x55, 0x4b, + 0x5f, 0xf3, 0x62, 0x0e, 0x9d, 0x1e, 0x0a, 0x82, 0x60, 0x2b, 0xf7, 0xe8, 0x34, 0x48, 0xb3, 0xb9, 0x41, 0x1f, 0xf1, + 0xed, 0x59, 0x51, 0x89, 0x83, 0xcb, 0xaf, 0x8a, 0xa0, 0xf8, 0x27, 0x43, 0xf6, 0x26, 0x63, 0xa6, 0x23, 0xde, 0xea, + 0xc8, 0xa3, 0x85, 0x7c, 0x31, 0x4e, 0x17, 0x9f, 0xa1, 0xd8, 0x43, 0x36, 0x28, 0xab, 0x64, 0xec, 0xc4, 0x93, 0xa1, + 0x11, 0x49, 0xfd, 0xe3, 0x30, 0xf7, 0x45, 0x3d, 0x8a, 0xd2, 0x3c, 0xad, 0x27, 0xd4, 0x8a, 0xa9, 0x76, 0x23, 0xb0, + 0x26, 0xe5, 0x99, 0xd0, 0x19, 0x5b, 0xea, 0x97, 0x0a, 0x52, 0x76, 0x6a, 0x4c, 0xc5, 0x4e, 0xce, 0x8b, 0x9c, 0xa3, + 0xa7, 0x3c, 0x08, 0xe3, 0xc0, 0xd8, 0x9f, 0x4e, 0x97, 0xd5, 0xee, 0xd9, 0x09, 0xe2, 0xf1, 0x6a, 0xa8, 0xf6, 0x21, + 0x5d, 0xab, 0x26, 0xa6, 0x40, 0xd3, 0x9e, 0xa6, 0xff, 0x25, 0x81, 0x3e, 0x0f, 0xc1, 0x9e, 0xe9, 0xb3, 0x91, 0x6a, + 0x07, 0xd1, 0xfe, 0xa0, 0x85, 0x77, 0xf8, 0x1a, 0x25, 0x54, 0xbf, 0xe7, 0x04, 0xe8, 0xf8, 0x06, 0x6b, 0xc4, 0x96, + 0x24, 0xce, 0xe7, 0x22, 0x95, 0x9d, 0x63, 0x46, 0x2d, 0x20, 0x17, 0x44, 0x81, 0xe7, 0x3a, 0x8d, 0xca, 0x42, 0x96, + 0xbc, 0xc1, 0x8d, 0x9f, 0xfd, 0x9a, 0x29, 0x14, 0xfe, 0x69, 0x38, 0x08, 0x58, 0x06, 0xb0, 0x30, 0x9f, 0x5e, 0x61, + 0xce, 0x99, 0x9d, 0x25, 0x0c, 0x59, 0x80, 0x96, 0x3a, 0x7a, 0x0b, 0x9d, 0x04, 0x00, 0x44, 0x47, 0xc5, 0x18, 0xc8, + 0xab, 0x1d, 0x55, 0x9f, 0xc0, 0xa1, 0x77, 0xd2, 0x73, 0x69, 0xee, 0x26, 0x10, 0x45, 0x08, 0x08, 0x90, 0xd8, 0x1a, + 0x0a, 0x22, 0x6f, 0x39, 0x88, 0xa8, 0x4a, 0xec, 0x04, 0xb7, 0x42, 0xb3, 0xe0, 0x46, 0x32, 0x22, 0x8d, 0x00, 0x7a, + 0x05, 0x08, 0x31, 0x23, 0x50, 0xe6, 0x3c, 0xd2, 0xf8, 0x05, 0x1e, 0x26, 0x2f, 0x44, 0xc1, 0xe7, 0x14, 0xb5, 0xde, + 0x83, 0xe8, 0x9e, 0x9b, 0xb3, 0xf6, 0xc7, 0x84, 0x10, 0x3d, 0x02, 0x6b, 0x28, 0xab, 0x7f, 0x45, 0x29, 0x60, 0x34, + 0xc0, 0xd9, 0xde, 0xe1, 0xdc, 0x63, 0xfe, 0x51, 0xf2, 0xa0, 0x0a, 0x1d, 0xf3, 0x88, 0x5c, 0x3a, 0x9f, 0x74, 0xab, + 0xb0, 0x5e, 0xd4, 0x0e, 0x6c, 0xb7, 0x1e, 0x8f, 0xd5, 0x4b, 0x75, 0xad, 0x41, 0x1a, 0x8a, 0xff, 0xa2, 0xfc, 0x68, + 0x0c, 0x95, 0xf3, 0x8b, 0xf1, 0xa0, 0x7b, 0xd1, 0x61, 0xbd, 0x8b, 0x5c, 0x40, 0x45, 0x09, 0x00, 0xb4, 0xdb, 0xa1, + 0x9d, 0x33, 0x9b, 0x7f, 0xbb, 0xfd, 0x85, 0xaf, 0x2c, 0x55, 0x8b, 0x3a, 0xcf, 0x1a, 0x0a, 0xce, 0xcb, 0x71, 0xfe, + 0x2f, 0x3c, 0xd8, 0xcb, 0x93, 0xce, 0x98, 0x2a, 0x42, 0x9c, 0xba, 0x33, 0xfb, 0x26, 0x1f, 0x87, 0x2d, 0x21, 0x76, + 0xaa, 0x9b, 0xbf, 0xd9, 0xcc, 0x83, 0xa9, 0xaf, 0x76, 0x80, 0x1b, 0x37, 0xb7, 0xcc, 0xd8, 0xab, 0xc7, 0xd0, 0x31, + 0x01, 0xe0, 0xad, 0x25, 0x8a, 0x22, 0xe2, 0x25, 0xe1, 0xdf, 0x1f, 0x8f, 0x0f, 0x55, 0xc3, 0x07, 0x7d, 0x1b, 0xef, + 0x44, 0xa1, 0x29, 0x30, 0xc1, 0x3a, 0x60, 0x98, 0x0f, 0xe8, 0x7b, 0x85, 0xcd, 0x8c, 0x1a, 0xdf, 0x76, 0xba, 0x28, + 0x40, 0x4c, 0x61, 0x70, 0xa5, 0xf1, 0x49, 0x5e, 0x64, 0x3c, 0xa8, 0x02, 0x6d, 0xde, 0x26, 0xfb, 0xaa, 0x30, 0x34, + 0x3c, 0xed, 0xd6, 0x43, 0x8f, 0x1d, 0x34, 0x8b, 0x5b, 0xc3, 0xf8, 0x85, 0x74, 0x90, 0xbf, 0xb1, 0xc9, 0x2c, 0x51, + 0xfc, 0xfe, 0x47, 0xe7, 0x24, 0xf7, 0x7c, 0xd0, 0x4e, 0x8a, 0x9a, 0x0a, 0x9d, 0x3f, 0x2b, 0x1f, 0x97, 0xf3, 0xb3, + 0xf0, 0xee, 0x2c, 0xd4, 0x1d, 0x59, 0x0a, 0x12, 0x39, 0x0d, 0x4d, 0xae, 0xd5, 0x62, 0xcd, 0x89, 0x8b, 0xb7, 0xb6, + 0xc5, 0x27, 0x70, 0xb3, 0xe4, 0x0c, 0x61, 0x2a, 0xde, 0xc4, 0x84, 0xe0, 0x30, 0x10, 0x14, 0x86, 0x8b, 0xe2, 0x10, + 0x09, 0x83, 0x37, 0x3b, 0x3c, 0xb1, 0x5b, 0x06, 0x1b, 0x5f, 0xcd, 0x1b, 0x65, 0x9e, 0xb1, 0x9e, 0x98, 0x81, 0x6a, + 0x16, 0x55, 0xd7, 0x8b, 0x01, 0x56, 0xff, 0x84, 0xd7, 0xd2, 0x89, 0xd9, 0x7a, 0x90, 0x25, 0xa9, 0x61, 0x53, 0x2e, + 0x51, 0x4d, 0x19, 0xdb, 0x58, 0x43, 0xc1, 0xb5, 0xc3, 0x23, 0xfd, 0xe1, 0xfa, 0x4f, 0xce, 0x67, 0x89, 0x67, 0xa1, + 0xe7, 0x2b, 0x87, 0xc0, 0x5a, 0xec, 0xb2, 0x76, 0x7d, 0xe8, 0x6b, 0x36, 0x47, 0x61, 0x1b, 0x0d, 0xa5, 0x74, 0x16, + 0x2f, 0x88, 0xae, 0x83, 0x32, 0x90, 0x2e, 0x1d, 0x26, 0x3a, 0x7b, 0x5f, 0x35, 0xeb, 0x0e, 0x34, 0xde, 0xf4, 0x88, + 0x44, 0x1b, 0xbb, 0x6a, 0x30, 0xaf, 0xe8, 0x9c, 0xa2, 0x9b, 0x63, 0x4b, 0xa0, 0xbf, 0xda, 0x1c, 0x6e, 0x4c, 0x5f, + 0x02, 0x31, 0xa5, 0x80, 0x7c, 0xcb, 0xa6, 0xe6, 0x9e, 0xf3, 0x40, 0x3e, 0x61, 0x2a, 0x34, 0x64, 0xed, 0x3a, 0xec, + 0xc6, 0x1a, 0x2f, 0x39, 0x22, 0xf5, 0xcf, 0xb5, 0x08, 0x0b, 0xaf, 0x2e, 0x58, 0xb6, 0xc5, 0x47, 0x27, 0xac, 0x49, + 0xd2, 0xb6, 0x87, 0x05, 0xb4, 0xd8, 0x61, 0x51, 0x9e, 0x5a, 0xcf, 0x25, 0x2e, 0x66, 0x62, 0x7c, 0x4d, 0x97, 0x2e, + 0x39, 0xb0, 0xec, 0x1c, 0x01, 0x8d, 0x07, 0x2b, 0xbd, 0x15, 0xbe, 0x55, 0x74, 0xbf, 0x6a, 0x46, 0x25, 0xce, 0x34, + 0x90, 0xd6, 0x0b, 0x58, 0x23, 0xd4, 0xb5, 0xfc, 0xc0, 0x19, 0xc7, 0x02, 0x6c, 0xcb, 0xf4, 0xfe, 0x76, 0x29, 0x2d, + 0xc4, 0x0e, 0x01, 0x9e, 0x71, 0x17, 0xfd, 0x03, 0xcd, 0x0a, 0x60, 0x4c, 0x4e, 0x4d, 0xc8, 0xc5, 0x7b, 0xdd, 0x10, + 0x32, 0xa6, 0x7f, 0xd2, 0x3e, 0xb6, 0x6c, 0x47, 0x87, 0x04, 0x1c, 0x19, 0x06, 0xc6, 0xad, 0x57, 0x29, 0x6b, 0x77, + 0x33, 0x1c, 0x23, 0xaa, 0xa5, 0x15, 0xf7, 0xcb, 0x44, 0x81, 0x67, 0xc0, 0x6e, 0x5c, 0x34, 0xed, 0xb5, 0x41, 0x2e, + 0x91, 0x9d, 0xc1, 0xab, 0x53, 0x45, 0x66, 0x61, 0x8c, 0x5d, 0x25, 0x0b, 0x3c, 0x3e, 0xf6, 0x84, 0x31, 0xfe, 0x27, + 0x29, 0x41, 0xf9, 0xfe, 0xbb, 0xa4, 0x93, 0x0a, 0x95, 0xc2, 0x1e, 0x4e, 0xaf, 0xe3, 0x2b, 0xfa, 0x2a, 0x11, 0x58, + 0xf3, 0xa8, 0x7e, 0xdc, 0x00, 0x83, 0xaa, 0x0d, 0x78, 0x74, 0x43, 0x29, 0xde, 0x54, 0xf8, 0x26, 0x77, 0xa1, 0x55, + 0x51, 0x8e, 0xca, 0x01, 0x6b, 0x8e, 0xdc, 0x1c, 0x59, 0x22, 0xd8, 0xb2, 0x76, 0x90, 0xa2, 0x02, 0xc3, 0x9e, 0x55, + 0x83, 0xb4, 0x2a, 0x3d, 0x1c, 0x19, 0x7f, 0x4d, 0x80, 0x16, 0x40, 0x18, 0x96, 0x3f, 0x33, 0x93, 0x8c, 0x97, 0x29, + 0x2b, 0xb9, 0xa9, 0xe6, 0x28, 0x9a, 0x98, 0x86, 0x4e, 0xee, 0xe9, 0x84, 0x1f, 0x6a, 0x8e, 0x38, 0x1b, 0x04, 0xb5, + 0x55, 0xd5, 0x3a, 0x83, 0x61, 0x50, 0x27, 0x1d, 0x01, 0xf2, 0x51, 0xd2, 0x60, 0xc2, 0x73, 0x73, 0x8e, 0x9e, 0xc7, + 0x79, 0x19, 0x96, 0x93, 0x76, 0x36, 0x4b, 0x00, 0x3e, 0xb5, 0x14, 0xb6, 0x90, 0x81, 0x31, 0x8c, 0x3f, 0x02, 0x72, + 0xc7, 0xa7, 0xcf, 0x4b, 0xcb, 0x1e, 0x95, 0x5e, 0xde, 0xfc, 0xf0, 0xf1, 0x07, 0x83, 0x37, 0x18, 0x2a, 0x1a, 0xbc, + 0x7b, 0xaf, 0x2f, 0xe9, 0x3b, 0x99, 0x60, 0xac, 0x41, 0xe7, 0x20, 0x8a, 0x55, 0x68, 0x47, 0xb6, 0x2a, 0xeb, 0x22, + 0x27, 0xdb, 0xd7, 0x27, 0xe5, 0xe7, 0x97, 0x22, 0x94, 0x6a, 0x41, 0x21, 0x6f, 0xb1, 0x8a, 0x0d, 0x42, 0x28, 0x54, + 0xe0, 0xa0, 0x08, 0x01, 0x8e, 0x22, 0xee, 0xee, 0x34, 0x14, 0x00, 0x52, 0x52, 0x14, 0xcc, 0xa9, 0xcb, 0xda, 0xdb, + 0x5c, 0x60, 0xb3, 0x73, 0xa6, 0xee, 0x23, 0x3e, 0xc7, 0x84, 0xd5, 0x39, 0x47, 0x8a, 0x04, 0xb2, 0xb6, 0xec, 0xd6, + 0x22, 0x4b, 0x75, 0x77, 0x34, 0x64, 0xc8, 0xac, 0x20, 0xe7, 0x5e, 0x3e, 0x2b, 0x10, 0x5a, 0x41, 0xfe, 0x93, 0x26, + 0x36, 0x60, 0x8c, 0x63, 0xfb, 0xc7, 0xef, 0x54, 0xf0, 0x37, 0x5f, 0xc3, 0x3d, 0xf9, 0x6d, 0x3a, 0xc1, 0x2a, 0xc5, + 0x60, 0x50, 0xf3, 0x2b, 0xe7, 0x4c, 0xaf, 0xcd, 0x18, 0x88, 0x89, 0x63, 0x56, 0xbe, 0x87, 0x57, 0xe9, 0x8b, 0x52, + 0xb4, 0x19, 0x54, 0xa4, 0x4c, 0x2a, 0x80, 0x84, 0x26, 0xed, 0x21, 0xf5, 0x1a, 0x4c, 0xca, 0xb2, 0x29, 0xb6, 0x69, + 0xae, 0xd4, 0xf6, 0xb1, 0xa3, 0xa6, 0xd6, 0x83, 0x32, 0x89, 0x87, 0x38, 0x7d, 0x16, 0x78, 0x1c, 0x63, 0x42, 0x88, + 0x14, 0x12, 0x7f, 0x71, 0xa6, 0xd5, 0xe3, 0x2b, 0x2a, 0xee, 0xb9, 0x8f, 0xa0, 0x63, 0x0c, 0x8d, 0xe9, 0x54, 0xb0, + 0x1b, 0xd2, 0x19, 0x12, 0x7b, 0x9d, 0x1b, 0x99, 0xee, 0xd6, 0xab, 0x0e, 0x1f, 0x8c, 0xcc, 0x4f, 0x79, 0xc7, 0xae, + 0xf7, 0x46, 0x06, 0x6b, 0x9d, 0xd2, 0xd3, 0x9a, 0xf2, 0xf4, 0x7f, 0xc3, 0x15, 0xee, 0xa8, 0x2e, 0x2d, 0x12, 0x5d, + 0x9e, 0x21, 0xc1, 0xb8, 0x48, 0x8a, 0xb4, 0xde, 0x25, 0x4c, 0x36, 0xbd, 0x62, 0xed, 0x9a, 0xd1, 0x65, 0x61, 0x7e, + 0xc8, 0xe6, 0x17, 0x5d, 0x8b, 0xf1, 0x0e, 0xac, 0xb3, 0xaf, 0xf2, 0xcc, 0x39, 0x46, 0x9e, 0xc1, 0x8c, 0x85, 0xbd, + 0x2a, 0xa8, 0x43, 0x5a, 0x58, 0x07, 0xa8, 0x1e, 0xa3, 0x28, 0xe3, 0xd1, 0x4b, 0x9b, 0x42, 0x7a, 0xa0, 0xdb, 0xee, + 0x95, 0x5f, 0x5e, 0x45, 0x85, 0x02, 0x20, 0x2e, 0x44, 0x58, 0x78, 0x34, 0x83, 0xc1, 0x05, 0x0a, 0x85, 0xb7, 0x39, + 0xe8, 0xc5, 0x35, 0x9c, 0xb7, 0x1f, 0xa4, 0xd4, 0x70, 0x8a, 0x29, 0x1d, 0x27, 0x5f, 0x70, 0x67, 0xbd, 0xac, 0x40, + 0x7e, 0x38, 0xb3, 0x16, 0xbb, 0x66, 0x97, 0x42, 0x36, 0xa4, 0xe8, 0xaa, 0xdd, 0xed, 0x9d, 0xb2, 0xb6, 0x67, 0xe6, + 0xc3, 0xb2, 0xa6, 0x68, 0x56, 0x12, 0x85, 0x9e, 0x43, 0x14, 0x43, 0xc5, 0xd0, 0xcc, 0xb5, 0x65, 0x5d, 0xd4, 0x52, + 0x0d, 0x95, 0xba, 0x46, 0x50, 0xd5, 0xcd, 0x51, 0xfd, 0x73, 0xd6, 0xe3, 0xdc, 0xb5, 0xc1, 0xd0, 0x7a, 0xf2, 0x30, + 0x5e, 0xc6, 0xea, 0x1c, 0x1f, 0x2f, 0x7c, 0x8e, 0x73, 0xdb, 0xbe, 0x57, 0xf7, 0x3b, 0x05, 0x6d, 0x59, 0x7c, 0x13, + 0xff, 0x83, 0xea, 0xff, 0xb2, 0x01, 0x23, 0x93, 0x8f, 0x0f, 0xcb, 0x99, 0xd6, 0x17, 0x59, 0x4c, 0x76, 0xe4, 0xb1, + 0x33, 0x4d, 0x9e, 0xb1, 0xb0, 0x57, 0x77, 0x6f, 0x23, 0x67, 0xc1, 0x61, 0x73, 0xe6, 0x10, 0x06, 0xb2, 0x32, 0xfe, + 0xb0, 0x65, 0xb4, 0x6e, 0x9d, 0x36, 0x75, 0xf8, 0x30, 0x34, 0x31, 0xd9, 0x6b, 0x3c, 0xc5, 0x10, 0xe6, 0xd9, 0x94, + 0xb1, 0x2d, 0xe0, 0x45, 0x65, 0x28, 0xe2, 0x32, 0xae, 0x39, 0x82, 0x29, 0xad, 0x06, 0xf6, 0x59, 0x45, 0xf1, 0x1c, + 0x55, 0xba, 0xa8, 0x9e, 0xdb, 0x37, 0x3d, 0x60, 0x48, 0x46, 0xce, 0x7e, 0xb9, 0xfa, 0x18, 0x1a, 0x58, 0xb7, 0xa3, + 0xaf, 0x06, 0x3c, 0x43, 0x24, 0xfa, 0xbc, 0x33, 0x36, 0x20, 0xb6, 0x58, 0x99, 0xe5, 0x50, 0x48, 0xfe, 0x71, 0x3b, + 0x5c, 0xc6, 0xea, 0x53, 0x7e, 0xa4, 0x2f, 0x59, 0xec, 0x86, 0xa6, 0xd6, 0xc1, 0x5f, 0xa9, 0x0a, 0x22, 0xe5, 0x5d, + 0x4b, 0x75, 0x97, 0x21, 0x6d, 0x4a, 0x3d, 0xfa, 0x7b, 0xa0, 0x2c, 0x8d, 0x58, 0x89, 0xa5, 0x51, 0x35, 0x26, 0xfe, + 0xef, 0xf4, 0x29, 0x3a, 0x23, 0x3f, 0xb5, 0xb0, 0xe2, 0xbe, 0x22, 0x16, 0x2e, 0xe1, 0x98, 0xe9, 0xd5, 0x16, 0x1d, + 0x15, 0x22, 0x28, 0xe0, 0xb3, 0x45, 0xef, 0xcd, 0x86, 0x4c, 0x04, 0x8d, 0xb7, 0x79, 0x7a, 0x1d, 0x4f, 0xf7, 0xf3, + 0x19, 0xd9, 0x11, 0x9a, 0x2e, 0xac, 0x4d, 0x41, 0xe1, 0x20, 0x70, 0x6e, 0x21, 0xd0, 0x5c, 0x95, 0x81, 0x09, 0x8e, + 0xf3, 0x62, 0xcb, 0x27, 0x50, 0x9d, 0xee, 0x81, 0x34, 0xa8, 0x5a, 0x9e, 0x6a, 0x95, 0xba, 0x8f, 0xe9, 0xb4, 0xd5, + 0x3a, 0x6b, 0x83, 0x52, 0xfc, 0x00, 0xbb, 0xa0, 0x80, 0x56, 0x2f, 0x51, 0x82, 0xb8, 0x39, 0x34, 0x5f, 0xca, 0x5e, + 0x33, 0xe7, 0x68, 0xef, 0xd0, 0x92, 0x71, 0x41, 0xfb, 0xfb, 0xfb, 0x03, 0x21, 0x73, 0x14, 0xad, 0x83, 0xa6, 0x64, + 0x2e, 0xf7, 0x88, 0xab, 0x48, 0xe5, 0x9f, 0x17, 0x6c, 0xa8, 0xe0, 0xe5, 0xf6, 0x77, 0xa8, 0x1f, 0x16, 0x75, 0xd1, + 0x7e, 0x0b, 0xf1, 0x1a, 0xf9, 0x47, 0xf0, 0xfe, 0x28, 0x20, 0x1a, 0x7e, 0x9a, 0xf0, 0x3b, 0x68, 0xb3, 0x57, 0xf7, + 0x0b, 0xdf, 0xf7, 0x7d, 0x8b, 0xdd, 0xe0, 0xad, 0xef, 0x9f, 0x3a, 0x58, 0x85, 0xc3, 0x1e, 0xb8, 0x9e, 0x18, 0xdd, + 0xfe, 0xfc, 0xfc, 0xbe, 0x86, 0x8a, 0x2f, 0xce, 0xb0, 0x9b, 0xa9, 0x7c, 0xa0, 0xee, 0x9d, 0xdc, 0xd2, 0x7e, 0xa1, + 0xe6, 0x35, 0x04, 0xa4, 0x5c, 0x38, 0x27, 0xae, 0x4f, 0x0a, 0x5c, 0x81, 0x16, 0x52, 0x3a, 0xba, 0x2d, 0xf1, 0x9e, + 0x35, 0xa4, 0xfd, 0xb0, 0x01, 0x36, 0x9d, 0xf6, 0x1d, 0x52, 0x71, 0x98, 0xc9, 0xd2, 0x6c, 0x42, 0xfe, 0x6b, 0x8e, + 0x3a, 0x55, 0x07, 0xf7, 0x79, 0xb1, 0x2e, 0x0c, 0xeb, 0x6e, 0x3c, 0xce, 0x9f, 0xaa, 0x3d, 0x61, 0xc4, 0x0d, 0x63, + 0x75, 0xc8, 0x6f, 0x90, 0x06, 0xf4, 0x76, 0x34, 0x93, 0x22, 0xfb, 0x81, 0x00, 0x80, 0xaf, 0xd6, 0x8c, 0xa5, 0x41, + 0xd9, 0x37, 0xfd, 0x1c, 0x2a, 0x34, 0x41, 0x8c, 0xca, 0x5e, 0x03, 0x24, 0xe0, 0x22, 0x5b, 0x97, 0xc5, 0x7b, 0xa1, + 0x22, 0xa1, 0x5b, 0x97, 0xd0, 0xa9, 0xde, 0xc9, 0x10, 0x56, 0x5d, 0x22, 0xc2, 0x9c, 0xf6, 0x84, 0xaf, 0xeb, 0x7c, + 0xf8, 0x3c, 0x16, 0x7b, 0xce, 0xd3, 0xcf, 0xb0, 0xb9, 0x30, 0x0d, 0x0d, 0x44, 0x33, 0x0e, 0xdd, 0x8f, 0xd4, 0x96, + 0xe2, 0xd6, 0xac, 0x62, 0x3c, 0xfe, 0x72, 0x5e, 0x55, 0x64, 0xfd, 0xe5, 0x22, 0xc3, 0x14, 0xe1, 0x66, 0x16, 0xf5, + 0xf2, 0xa2, 0x10, 0x66, 0xa7, 0x8b, 0x06, 0x82, 0x66, 0xb4, 0x6d, 0x3d, 0xb8, 0xa1, 0xb4, 0x11, 0xfa, 0x45, 0x95, + 0x68, 0x6d, 0xd5, 0xf7, 0xfd, 0x06, 0xd9, 0xe5, 0x1c, 0x07, 0x6d, 0x5e, 0xc0, 0xf1, 0xbd, 0x7f, 0xea, 0x97, 0xab, + 0xbd, 0x75, 0x9a, 0xbf, 0xe0, 0x16, 0x5f, 0x90, 0xb0, 0xfc, 0x30, 0xc3, 0x41, 0x29, 0x21, 0xc3, 0xc9, 0x47, 0x38, + 0x17, 0xd6, 0xe8, 0x92, 0xcf, 0xf6, 0x5c, 0x18, 0xe8, 0x60, 0x45, 0xb4, 0x23, 0xbe, 0xe1, 0xa7, 0xba, 0x2d, 0x44, + 0x10, 0x3b, 0x58, 0xc6, 0x80, 0x67, 0x64, 0x72, 0x22, 0xa3, 0x3a, 0x4c, 0x60, 0x9a, 0x4d, 0x98, 0x06, 0x76, 0x9b, + 0x00, 0x9a, 0x3a, 0x18, 0xa7, 0x38, 0x03, 0x7d, 0x18, 0xaa, 0xad, 0x67, 0x25, 0x19, 0xf3, 0x81, 0xa0, 0x9d, 0xed, + 0x8f, 0x1a, 0x65, 0x5e, 0x6c, 0x37, 0xdb, 0x48, 0xf3, 0xaa, 0x14, 0x43, 0x3b, 0x90, 0xd9, 0x91, 0x34, 0x64, 0xea, + 0x1e, 0xd4, 0xb8, 0x50, 0xa8, 0x36, 0x0c, 0xc2, 0x01, 0x4a, 0x91, 0xa6, 0x39, 0xf5, 0x08, 0xb3, 0xe8, 0xd6, 0x14, + 0xde, 0x59, 0x66, 0xb8, 0x5a, 0x22, 0xa0, 0x04, 0x11, 0xc7, 0x5d, 0x74, 0x18, 0xc5, 0x83, 0xbd, 0x51, 0x77, 0x4a, + 0xa8, 0xaf, 0x5c, 0x2c, 0xd6, 0xa3, 0xad, 0x16, 0x7b, 0x82, 0x69, 0x5a, 0xd7, 0xfb, 0x81, 0x18, 0xed, 0xf9, 0x66, + 0x22, 0x55, 0xea, 0x12, 0x54, 0x95, 0xde, 0xb7, 0x1f, 0xb2, 0x8a, 0x3d, 0x86, 0xc7, 0x4a, 0xa5, 0x44, 0xb1, 0x53, + 0xd3, 0xce, 0xe2, 0x34, 0x45, 0xda, 0x65, 0x99, 0x78, 0x13, 0xfa, 0x1d, 0x49, 0xbb, 0x2d, 0xb3, 0xb6, 0x17, 0x8b, + 0x9b, 0x93, 0x48, 0xb1, 0x1c, 0xac, 0x35, 0xbc, 0x2d, 0x73, 0xec, 0x82, 0xb7, 0x39, 0xb7, 0x7e, 0xc1, 0x58, 0x43, + 0xeb, 0x33, 0xd6, 0xdf, 0xa4, 0x47, 0x46, 0x14, 0xa0, 0xfa, 0x37, 0x59, 0x08, 0x12, 0x37, 0xcc, 0xf8, 0x1d, 0xb5, + 0x61, 0x51, 0x5d, 0xd4, 0x3d, 0x4b, 0xac, 0x88, 0x58, 0x38, 0x7f, 0x5f, 0x9d, 0x05, 0x72, 0xe9, 0x6c, 0xc5, 0x35, + 0x0f, 0x47, 0x5d, 0x76, 0x3d, 0xb8, 0x53, 0x18, 0x53, 0xf3, 0xc9, 0x42, 0xf5, 0x86, 0x7b, 0x2e, 0x3e, 0xd7, 0x12, + 0x5e, 0x57, 0xfb, 0xdc, 0x9c, 0xe6, 0xf2, 0x2d, 0x2e, 0xab, 0x2a, 0xb5, 0x99, 0xc0, 0xa4, 0x6b, 0xad, 0xfe, 0x38, + 0x82, 0x35, 0x14, 0x91, 0xb8, 0x49, 0xd4, 0xc1, 0x66, 0x59, 0x87, 0x72, 0x9b, 0x09, 0x56, 0x92, 0x0d, 0xf6, 0x80, + 0x70, 0x6a, 0xb1, 0x99, 0x63, 0xa7, 0x0d, 0xe1, 0xf0, 0x1d, 0xb7, 0xa6, 0x88, 0x8a, 0x53, 0x77, 0xe1, 0xa9, 0x65, + 0xf9, 0xc3, 0xec, 0x6a, 0x4d, 0xd3, 0xf5, 0x1d, 0x6a, 0x64, 0x49, 0xb8, 0x72, 0x2f, 0x63, 0x98, 0x0f, 0x2d, 0xe4, + 0x59, 0xaa, 0x8e, 0x60, 0xd0, 0xd2, 0x2d, 0x37, 0xfc, 0x7d, 0xf8, 0x74, 0x5c, 0x6b, 0x22, 0xda, 0x38, 0xbe, 0xdc, + 0x43, 0x2a, 0x27, 0xfb, 0x49, 0xcc, 0x0b, 0x95, 0xd3, 0xe9, 0x49, 0x91, 0x80, 0x87, 0x9b, 0xb8, 0x70, 0x89, 0x72, + 0x2d, 0xcb, 0x74, 0x35, 0xc9, 0xa9, 0xa1, 0x42, 0xce, 0x8c, 0xa1, 0xc5, 0xfb, 0x59, 0xc4, 0x30, 0x63, 0x13, 0x66, + 0x66, 0x53, 0x53, 0xd3, 0x0e, 0x45, 0xee, 0x43, 0x25, 0x8f, 0xc4, 0x64, 0xe5, 0xd0, 0x38, 0x3a, 0x35, 0xdd, 0x63, + 0x70, 0x5d, 0x21, 0x9c, 0x6a, 0x54, 0xfb, 0x01, 0x74, 0x71, 0xfe, 0x85, 0xdb, 0x51, 0xbf, 0x1c, 0x8c, 0x7e, 0x6b, + 0x54, 0x13, 0x95, 0xf9, 0xd0, 0x0c, 0x5d, 0x3b, 0x32, 0x98, 0x1c, 0x03, 0xe0, 0x26, 0x13, 0x84, 0x0d, 0x1f, 0x57, + 0x60, 0x16, 0x7b, 0xaa, 0xaf, 0x7f, 0x0e, 0x52, 0x38, 0x97, 0xa9, 0x67, 0x61, 0xd4, 0x72, 0x80, 0x4b, 0x03, 0x0b, + 0xe3, 0x4a, 0x43, 0x0c, 0x9b, 0xdf, 0x8f, 0xb6, 0x89, 0x4c, 0xd2, 0x3d, 0xab, 0x29, 0x00, 0x9a, 0x4e, 0x41, 0xe4, + 0xdf, 0xa3, 0xe4, 0x05, 0xc7, 0xd1, 0x29, 0x3d, 0xfd, 0xa2, 0xd4, 0xa3, 0x19, 0xb4, 0xf7, 0x78, 0x75, 0xc1, 0xac, + 0x27, 0x23, 0xed, 0x88, 0x87, 0xd9, 0x09, 0xe4, 0x07, 0x48, 0x4d, 0xe9, 0x5a, 0x73, 0x63, 0xf7, 0x35, 0xc8, 0x96, + 0xed, 0x68, 0x90, 0xc3, 0x1a, 0xf9, 0x1a, 0x54, 0xca, 0xa1, 0x7c, 0x93, 0xcc, 0xe3, 0x24, 0xd8, 0xd7, 0xc8, 0xed, + 0x3b, 0xee, 0x6b, 0xb6, 0xb7, 0x43, 0x52, 0x1d, 0x92, 0xb0, 0xef, 0xb6, 0x69, 0x92, 0xe0, 0x70, 0x83, 0x0c, 0xc2, + 0x05, 0x6c, 0x64, 0xe8, 0xdb, 0xeb, 0x46, 0x21, 0x9a, 0xef, 0x1a, 0x7c, 0xaf, 0xee, 0x8b, 0x37, 0x66, 0x30, 0x49, + 0x92, 0x44, 0x60, 0x36, 0x53, 0x1a, 0x13, 0xe5, 0x1b, 0xc3, 0x73, 0xb5, 0xe7, 0x07, 0xe5, 0x5c, 0x4b, 0xd8, 0x33, + 0x1d, 0xbf, 0x1d, 0x8d, 0x57, 0xa5, 0xdf, 0xe0, 0x55, 0x52, 0x12, 0xdd, 0xf9, 0xfb, 0x00, 0x8e, 0xbc, 0x29, 0xeb, + 0x17, 0xf3, 0x1d, 0xa7, 0xc7, 0xd2, 0xe6, 0xed, 0x26, 0x2e, 0xf0, 0x37, 0x4f, 0xa4, 0x5e, 0xf1, 0xa5, 0xa6, 0x49, + 0xbf, 0x6e, 0xf1, 0x60, 0x17, 0x30, 0x79, 0xcb, 0x0d, 0xb3, 0x06, 0x7d, 0xb3, 0xca, 0x4d, 0xdf, 0x42, 0x79, 0x58, + 0xce, 0x63, 0x9e, 0x3a, 0x84, 0x5f, 0x3c, 0xaa, 0x43, 0x65, 0x34, 0xb7, 0x66, 0x27, 0xf4, 0x37, 0x98, 0xd7, 0xdc, + 0xc1, 0x0c, 0x27, 0xb2, 0x24, 0x0d, 0x6f, 0x7a, 0x7a, 0x3b, 0xca, 0x3c, 0x08, 0x42, 0x92, 0x22, 0xda, 0x06, 0x76, + 0xd0, 0x82, 0x0a, 0xb8, 0x41, 0xd4, 0xec, 0x3d, 0x62, 0xb6, 0x97, 0x76, 0x1f, 0xe7, 0xbd, 0x77, 0x3c, 0x59, 0x13, + 0x21, 0x67, 0x08, 0xa1, 0xf8, 0x7b, 0xda, 0xcf, 0x61, 0xcf, 0x08, 0x57, 0x5a, 0xa1, 0x60, 0xc4, 0x0d, 0xaa, 0x7e, + 0xcc, 0x16, 0x10, 0x2d, 0x12, 0x90, 0xb3, 0x5d, 0x0b, 0x6b, 0x26, 0x33, 0xf9, 0x49, 0x0c, 0x95, 0xd4, 0xb6, 0x7c, + 0xc3, 0x7f, 0xae, 0x0a, 0x49, 0x60, 0x31, 0x27, 0x75, 0xdf, 0x47, 0x12, 0x8b, 0x9b, 0x35, 0x9b, 0x87, 0x72, 0xed, + 0xf3, 0x72, 0xac, 0xbd, 0x83, 0xbe, 0x50, 0x71, 0x59, 0x2e, 0xaf, 0x4a, 0xbb, 0x44, 0x5d, 0xeb, 0x30, 0xb4, 0xa4, + 0xb4, 0x62, 0xd8, 0x87, 0x56, 0xf5, 0xc8, 0x91, 0xc3, 0xdf, 0x03, 0x69, 0xb8, 0xbb, 0xcc, 0xf0, 0xe6, 0xa5, 0xeb, + 0x5d, 0x34, 0x6d, 0xa5, 0x22, 0xe1, 0x4e, 0x6e, 0xbb, 0xa2, 0x33, 0x24, 0x88, 0x58, 0x0f, 0x1f, 0xe5, 0x87, 0x0b, + 0x86, 0x55, 0x8a, 0x36, 0xa4, 0xdb, 0x6c, 0x2e, 0x33, 0x37, 0x92, 0xb2, 0xdd, 0x9f, 0x56, 0xbd, 0x09, 0xaa, 0x75, + 0xa2, 0x36, 0xcf, 0xed, 0xb6, 0xd8, 0xba, 0x67, 0x00, 0xf5, 0x93, 0x33, 0x85, 0x23, 0x26, 0x88, 0x89, 0x56, 0x29, + 0x17, 0x61, 0xe6, 0x11, 0x0c, 0xf7, 0xd6, 0xfc, 0x84, 0xd8, 0xc7, 0x8b, 0x1c, 0x3f, 0xa6, 0x07, 0xb8, 0xe7, 0x13, + 0xb7, 0xcf, 0x69, 0x92, 0x83, 0xec, 0x88, 0xed, 0x46, 0xf1, 0x90, 0x8b, 0xee, 0x86, 0x4d, 0x25, 0x2c, 0x13, 0xe7, + 0xaa, 0xe5, 0xda, 0x18, 0x94, 0x0a, 0x45, 0x45, 0xee, 0x23, 0x65, 0xf1, 0xfb, 0x49, 0x55, 0xbe, 0x07, 0x91, 0xd8, + 0xf6, 0x49, 0x04, 0x52, 0xfd, 0xa3, 0xa0, 0x94, 0x12, 0xe6, 0xa5, 0x91, 0x67, 0xea, 0x4f, 0x28, 0x65, 0xc1, 0x43, + 0xc0, 0x17, 0x07, 0x9c, 0x0b, 0x6d, 0xfd, 0xf7, 0xb9, 0xee, 0x79, 0x3a, 0xf4, 0x92, 0xc2, 0x9d, 0xa3, 0xba, 0x4b, + 0xe4, 0x4e, 0xc9, 0xf8, 0x14, 0xa7, 0xe8, 0x41, 0xae, 0xd5, 0xb7, 0xdd, 0xbe, 0xa1, 0x6b, 0xbc, 0x7c, 0xa2, 0xf8, + 0xd6, 0xa6, 0xf2, 0x47, 0x51, 0xa7, 0xd3, 0x18, 0x9b, 0xec, 0x99, 0x72, 0x26, 0x17, 0x67, 0xb9, 0x9f, 0x1a, 0x0c, + 0x8d, 0x78, 0xc4, 0xd5, 0x12, 0xeb, 0xec, 0x3d, 0x66, 0x15, 0x27, 0xbc, 0x21, 0x0d, 0x04, 0xa8, 0xa4, 0x17, 0x1c, + 0xd1, 0x17, 0x68, 0xcb, 0xfa, 0xd2, 0xdd, 0xed, 0x47, 0x7a, 0xdc, 0xc1, 0xd1, 0x68, 0x55, 0x45, 0xbe, 0x4e, 0x0e, + 0x2a, 0xb9, 0x10, 0xa2, 0xd6, 0xf3, 0x1b, 0xd8, 0x42, 0xf3, 0x8b, 0xc9, 0x82, 0xfe, 0x2e, 0x6b, 0x4e, 0xd9, 0x7f, + 0xd6, 0xca, 0xb5, 0x21, 0x40, 0x1e, 0x17, 0xe4, 0xee, 0x15, 0xb8, 0x4c, 0x88, 0xfa, 0xc3, 0x7d, 0xcf, 0x76, 0x22, + 0xf2, 0xa1, 0x46, 0x8b, 0x45, 0xaf, 0x2a, 0x64, 0xbf, 0x3d, 0x1b, 0x77, 0xce, 0x1c, 0xf8, 0x3d, 0x2f, 0xbc, 0x92, + 0x4f, 0xfc, 0x86, 0x86, 0xf4, 0x1e, 0xd6, 0xb3, 0xa2, 0x6b, 0x16, 0x80, 0x52, 0x43, 0x0a, 0x7d, 0x0d, 0xdb, 0x73, + 0x50, 0x69, 0x9f, 0x79, 0x51, 0x8a, 0x80, 0xf1, 0x8d, 0xdd, 0x33, 0xf9, 0x54, 0x56, 0xc4, 0x25, 0x62, 0x96, 0x0e, + 0x18, 0x60, 0x64, 0x8e, 0x91, 0x51, 0xad, 0x1e, 0xaf, 0x70, 0x07, 0x8e, 0x94, 0x60, 0xab, 0xfd, 0xf3, 0xb8, 0x49, + 0xe6, 0xcf, 0x1c, 0x94, 0xfa, 0x84, 0xbc, 0xe7, 0x12, 0x82, 0xf1, 0xfc, 0xe4, 0x40, 0x75, 0xae, 0xc5, 0x06, 0x7b, + 0x3d, 0x67, 0x39, 0xce, 0xbc, 0x07, 0x46, 0xb0, 0xf5, 0xaf, 0xe2, 0x1f, 0xcb, 0x13, 0x77, 0x8b, 0x07, 0x31, 0xa9, + 0x95, 0xd3, 0xb5, 0x36, 0x5b, 0xe8, 0x6e, 0x20, 0xc3, 0x99, 0xe6, 0xcd, 0x9a, 0xba, 0xdc, 0x54, 0xc3, 0xc0, 0x6a, + 0xe6, 0x84, 0x5c, 0xcc, 0x91, 0xf8, 0x2f, 0x18, 0xe7, 0x66, 0x0d, 0x65, 0x2e, 0xc7, 0x66, 0x72, 0x09, 0xe4, 0xea, + 0x14, 0xfb, 0xcd, 0x3f, 0xfc, 0x06, 0x54, 0xc2, 0xf2, 0xa3, 0x7f, 0x88, 0xf2, 0x03, 0xcb, 0xd4, 0x1c, 0x7e, 0xe4, + 0xa8, 0x87, 0x32, 0x97, 0xc7, 0xff, 0x80, 0xac, 0xcf, 0xfa, 0xca, 0xf7, 0x93, 0xbb, 0xef, 0x9b, 0xe4, 0xcf, 0x6c, + 0x35, 0x27, 0x9b, 0x5d, 0x6b, 0xef, 0xe7, 0x7b, 0xf0, 0xbd, 0xf9, 0x3d, 0x32, 0xab, 0x85, 0xde, 0x28, 0xd4, 0x55, + 0x0f, 0x58, 0xe8, 0xd5, 0x4f, 0x7d, 0x8b, 0xd0, 0xec, 0x43, 0xac, 0xc1, 0x39, 0x44, 0x54, 0xba, 0xa7, 0x5e, 0xc3, + 0xb6, 0xbe, 0x77, 0x27, 0x06, 0xba, 0x66, 0x38, 0xef, 0x69, 0x93, 0xc8, 0x6d, 0xd4, 0xc3, 0xe6, 0xfd, 0xd9, 0x15, + 0xd6, 0x44, 0xb7, 0xba, 0x61, 0xe7, 0x52, 0xb2, 0x7c, 0x6b, 0x0f, 0xe0, 0xb1, 0x7a, 0x10, 0xf6, 0xae, 0x99, 0x3f, + 0x96, 0x03, 0x7f, 0x96, 0xf2, 0x4e, 0xb5, 0xb4, 0xfa, 0x8d, 0x6f, 0x55, 0x1f, 0xfb, 0x80, 0x37, 0xc2, 0x53, 0x41, + 0x75, 0xf6, 0x9c, 0x3d, 0x79, 0x71, 0x21, 0xbe, 0xd1, 0x0d, 0x2e, 0xa1, 0x5b, 0x15, 0x79, 0x03, 0x5f, 0xda, 0xbc, + 0xaa, 0xe0, 0x79, 0x68, 0xc9, 0x28, 0x4f, 0x9a, 0x72, 0x6c, 0xe6, 0x76, 0x31, 0x49, 0xb7, 0x32, 0x3f, 0xba, 0x51, + 0x81, 0x0b, 0x04, 0x92, 0x74, 0x65, 0x08, 0xff, 0x04, 0x27, 0x5e, 0x2b, 0xe1, 0xd3, 0x8d, 0x66, 0xbd, 0xd7, 0x55, + 0x3d, 0xee, 0x1a, 0xf4, 0x22, 0x3e, 0xb5, 0xd3, 0x5e, 0x7b, 0x84, 0xbf, 0xbf, 0x7f, 0x9e, 0x69, 0xe4, 0xbf, 0xce, + 0xec, 0xe4, 0x3f, 0xcf, 0xcd, 0xe4, 0xbf, 0xce, 0x0d, 0x9c, 0x5a, 0x7d, 0xcf, 0xbe, 0x7a, 0x61, 0x5f, 0xbd, 0xb2, + 0xc7, 0x4c, 0xed, 0xa1, 0x75, 0xad, 0x73, 0xd0, 0x8e, 0x5d, 0xcf, 0xf5, 0x96, 0x1c, 0xf0, 0xad, 0xae, 0xb2, 0x64, + 0xfd, 0xdb, 0xc9, 0xee, 0xde, 0x15, 0x53, 0xf9, 0xfe, 0x00, 0xc1, 0x93, 0xef, 0x87, 0x65, 0xad, 0xa2, 0x6c, 0xce, + 0xb4, 0x8c, 0xad, 0x74, 0xb6, 0xf7, 0x50, 0x3c, 0x9d, 0x3e, 0x42, 0xb2, 0xad, 0xe1, 0x0c, 0x55, 0x26, 0xf0, 0x1f, + 0x49, 0x3f, 0x36, 0x2a, 0xbd, 0x68, 0xbc, 0x74, 0xef, 0x48, 0xca, 0xf3, 0x17, 0x43, 0xc4, 0xc8, 0xb4, 0x9c, 0xda, + 0x3b, 0x98, 0xba, 0xc7, 0xac, 0xc5, 0xcb, 0x0e, 0xc8, 0x6c, 0xe9, 0x56, 0x52, 0x81, 0x10, 0xc6, 0xb6, 0x85, 0xff, + 0x2c, 0xc0, 0xaa, 0xfa, 0x96, 0x59, 0x3a, 0xcd, 0x9e, 0xa2, 0xa5, 0xd3, 0x0b, 0xd0, 0x20, 0x0e, 0x43, 0x99, 0xee, + 0x0a, 0x99, 0xc3, 0xf3, 0x2a, 0xae, 0x20, 0xab, 0x5f, 0x28, 0xf9, 0xef, 0x73, 0xf6, 0x70, 0xfd, 0x41, 0x40, 0x83, + 0xff, 0xdb, 0x64, 0x3b, 0xe8, 0x4f, 0x68, 0x6b, 0x9c, 0x72, 0x49, 0xa4, 0xfd, 0x5c, 0xc9, 0xdb, 0x33, 0xdf, 0x67, + 0xd7, 0xb7, 0xcf, 0x18, 0xce, 0xcf, 0x55, 0x08, 0x64, 0xce, 0xda, 0x4f, 0xf7, 0xf5, 0x31, 0x15, 0xb9, 0xeb, 0xbc, + 0xe7, 0x04, 0xab, 0xdc, 0x99, 0x52, 0x6b, 0x66, 0x72, 0x7e, 0xfe, 0xf2, 0x3f, 0xcc, 0xaf, 0x25, 0xe5, 0xa0, 0xef, + 0xf5, 0x92, 0xdd, 0xdc, 0x17, 0xca, 0xd2, 0xf3, 0x4c, 0xf9, 0xe8, 0x83, 0x4a, 0x3e, 0x1f, 0xd0, 0x74, 0x3f, 0xdd, + 0xf9, 0x8f, 0xea, 0x01, 0xdd, 0xa6, 0xf9, 0xac, 0xfb, 0x65, 0x49, 0x39, 0xe0, 0x07, 0xbd, 0x7c, 0x7e, 0x7b, 0x8b, + 0x7f, 0x6c, 0x3a, 0xdf, 0xd3, 0x05, 0x80, 0xf0, 0xfc, 0x28, 0xd9, 0x1c, 0x87, 0x9c, 0xc9, 0x9d, 0xeb, 0x0a, 0xcf, + 0xa8, 0x5a, 0x0e, 0x85, 0x5c, 0x2c, 0xf1, 0x19, 0xf9, 0x98, 0x27, 0xb2, 0xd1, 0x27, 0xb0, 0x4b, 0x99, 0xbd, 0x87, + 0x25, 0x64, 0xb7, 0xcd, 0xa7, 0x70, 0x94, 0xcf, 0x3d, 0xa2, 0x6d, 0x76, 0x1d, 0x16, 0x26, 0x6d, 0x69, 0x2a, 0x2e, + 0x3c, 0x60, 0xdf, 0x09, 0x0a, 0x83, 0xd5, 0x48, 0xed, 0x63, 0x46, 0x4e, 0x6f, 0x21, 0xba, 0xce, 0x38, 0x95, 0xbd, + 0xdf, 0xc1, 0x80, 0xa5, 0xf0, 0xf0, 0xd0, 0x7b, 0x0d, 0x68, 0x87, 0xcf, 0xb9, 0xe8, 0xa3, 0x9b, 0x50, 0xaf, 0x06, + 0xe0, 0xc4, 0x59, 0x36, 0xdd, 0x78, 0xb9, 0x9f, 0xf3, 0x87, 0xce, 0xe5, 0xca, 0xea, 0x63, 0x0d, 0x6d, 0x9b, 0xa3, + 0x33, 0xce, 0x57, 0x09, 0x2a, 0x8c, 0x30, 0x67, 0x78, 0xfe, 0xf5, 0xd4, 0x7d, 0xa0, 0x04, 0x7d, 0xa2, 0xd7, 0x9c, + 0x90, 0xd1, 0x7f, 0x22, 0x50, 0xa7, 0x93, 0xb4, 0x67, 0xf5, 0x47, 0xff, 0x1e, 0x3d, 0xb4, 0x4d, 0x8f, 0x7a, 0xab, + 0xe0, 0x3e, 0x85, 0x06, 0xa5, 0x52, 0x69, 0xac, 0x6d, 0x8e, 0x7f, 0x75, 0x72, 0x9d, 0x46, 0x6d, 0x8f, 0x70, 0x76, + 0xa6, 0xcd, 0x79, 0xdc, 0xde, 0xcc, 0xdc, 0xab, 0x17, 0x0f, 0xfd, 0x17, 0xff, 0x65, 0x18, 0x97, 0x8c, 0xb4, 0x20, + 0x37, 0xa9, 0x3d, 0xab, 0x1e, 0x1b, 0xf3, 0xaa, 0x7f, 0xab, 0x7e, 0x64, 0x54, 0xc0, 0xc6, 0x58, 0xcf, 0xe1, 0x32, + 0x3e, 0xcd, 0xeb, 0xa8, 0x28, 0x0b, 0x36, 0xc4, 0xf9, 0x70, 0xbb, 0xd7, 0xde, 0x23, 0x3b, 0xd0, 0xe4, 0xd7, 0x5f, + 0x66, 0xd3, 0x8f, 0xb6, 0xf3, 0x3b, 0x50, 0xcc, 0xfa, 0xfe, 0x94, 0x62, 0x83, 0xba, 0x02, 0xb7, 0x01, 0x97, 0xef, + 0xd8, 0x34, 0xf3, 0xaa, 0xf1, 0xbe, 0x7f, 0xc0, 0x5a, 0x12, 0x8a, 0x56, 0x0a, 0x0e, 0x8b, 0x75, 0x19, 0x45, 0x69, + 0xb1, 0x26, 0xfa, 0x55, 0xa7, 0xaa, 0xd3, 0xb6, 0x1b, 0x38, 0x37, 0x11, 0xa6, 0xaa, 0xb7, 0xa2, 0x1f, 0x22, 0xf2, + 0x36, 0x9e, 0xea, 0xab, 0x6d, 0x20, 0x86, 0xa7, 0xb8, 0x6e, 0xad, 0x7a, 0x0d, 0x67, 0x30, 0xa0, 0x27, 0x7d, 0x71, + 0x0c, 0xc1, 0xc3, 0x97, 0x01, 0x4b, 0xbd, 0xe9, 0xd2, 0xe1, 0xed, 0x63, 0xad, 0xd6, 0x9b, 0x3a, 0xaf, 0x3e, 0x55, + 0x6a, 0xd3, 0xf2, 0x74, 0x8f, 0x92, 0x21, 0xd1, 0xfe, 0xaa, 0x7c, 0xf6, 0xd3, 0x21, 0x63, 0x7b, 0x26, 0x9e, 0x2a, + 0x5e, 0x28, 0x69, 0x79, 0x57, 0xf1, 0x30, 0x8e, 0x3b, 0x29, 0x6a, 0x08, 0x56, 0xfc, 0x63, 0x18, 0x16, 0xe9, 0x9c, + 0xad, 0x0f, 0x75, 0xf0, 0x4a, 0x28, 0xe9, 0xca, 0xb5, 0xd6, 0x0a, 0x74, 0x6c, 0xe3, 0x99, 0x9f, 0x39, 0x9d, 0x09, + 0x50, 0xf9, 0x55, 0x98, 0x04, 0x14, 0x44, 0x22, 0x3c, 0x51, 0x2d, 0xbc, 0x28, 0xfa, 0x0c, 0xe6, 0xd0, 0x0c, 0xab, + 0xc1, 0x34, 0x15, 0xfd, 0x8d, 0x32, 0x30, 0xd7, 0x21, 0x82, 0x17, 0x99, 0x6b, 0xf3, 0x31, 0x0f, 0x1d, 0x8a, 0x9c, + 0x91, 0x53, 0x7f, 0xb0, 0xa4, 0xbc, 0x81, 0x3c, 0x56, 0xa1, 0xf8, 0x57, 0x30, 0x88, 0x73, 0x36, 0x00, 0x85, 0x8c, + 0x3d, 0x8f, 0x00, 0x60, 0x49, 0x3e, 0x49, 0x02, 0x6f, 0xfa, 0xbb, 0xb3, 0xf1, 0x59, 0x51, 0xb0, 0x5f, 0xed, 0x9b, + 0x49, 0xd3, 0x2c, 0xdc, 0xdd, 0xb3, 0x65, 0xf7, 0x14, 0x41, 0x04, 0x48, 0x32, 0x9b, 0x56, 0xec, 0x3d, 0xc4, 0xaf, + 0x14, 0x30, 0x03, 0x93, 0x0c, 0xe0, 0x84, 0x69, 0x49, 0xeb, 0x8a, 0x9f, 0x5c, 0x1d, 0xb6, 0x72, 0x5b, 0x28, 0xc1, + 0x22, 0x32, 0x8f, 0x6e, 0x89, 0x34, 0x4b, 0xe9, 0x9e, 0x5b, 0xeb, 0x3b, 0x19, 0xc7, 0x0f, 0x23, 0xe7, 0x89, 0xe3, + 0xf8, 0x35, 0x89, 0x68, 0x45, 0x44, 0x71, 0xba, 0x75, 0x0e, 0xd9, 0x15, 0x94, 0x8a, 0x15, 0x80, 0xaa, 0x07, 0x4c, + 0x35, 0xc1, 0x9a, 0x5f, 0xdc, 0x05, 0x7b, 0xf9, 0x40, 0x7b, 0x42, 0x71, 0x92, 0xac, 0x8c, 0xf5, 0xd0, 0x17, 0x7c, + 0x85, 0x5d, 0x2e, 0x46, 0x9b, 0x1d, 0x93, 0x24, 0xb5, 0xa2, 0x09, 0x06, 0xd4, 0x35, 0xc3, 0x69, 0xd7, 0xce, 0x3f, + 0x72, 0x9a, 0xd9, 0x74, 0x40, 0x8e, 0x71, 0x29, 0x74, 0x1b, 0xf7, 0xa4, 0x10, 0x47, 0x43, 0xe8, 0xe3, 0x30, 0x14, + 0x46, 0x3f, 0xc3, 0x66, 0x56, 0x9f, 0xf6, 0x31, 0x17, 0xb4, 0x35, 0xa6, 0xa8, 0xaa, 0xcb, 0xae, 0x29, 0x00, 0x1b, + 0x29, 0x67, 0xb0, 0x02, 0xfe, 0x78, 0xd9, 0x4e, 0x57, 0x0f, 0x37, 0x36, 0xf9, 0x0f, 0x6e, 0xf6, 0x1b, 0xe9, 0x27, + 0xf0, 0x47, 0x48, 0x66, 0xd6, 0x04, 0xd6, 0x10, 0xce, 0x4b, 0x62, 0x81, 0xe8, 0x71, 0xbe, 0x1f, 0x04, 0x7f, 0x5c, + 0x2d, 0x1e, 0x14, 0x5b, 0x98, 0xb4, 0x92, 0x73, 0xa2, 0x5e, 0x53, 0xa7, 0x8e, 0x7c, 0x90, 0x98, 0x44, 0x4c, 0x28, + 0xcf, 0xa3, 0x9f, 0x66, 0xb5, 0x9a, 0x05, 0xb5, 0x4d, 0x54, 0xec, 0x15, 0xba, 0x73, 0x3b, 0x67, 0x48, 0xb2, 0x23, + 0x38, 0xd5, 0x65, 0xd9, 0x70, 0x7b, 0xdb, 0x9a, 0x79, 0xd3, 0xf0, 0x35, 0x9d, 0xc3, 0x32, 0xee, 0x82, 0x8e, 0xb5, + 0xf1, 0x9a, 0xd8, 0x1e, 0x0c, 0x1e, 0x16, 0x4f, 0x94, 0x4e, 0xa3, 0xe9, 0xa6, 0x9e, 0x99, 0x9b, 0x7d, 0x4d, 0x5d, + 0x4d, 0xb4, 0xb3, 0x04, 0x9a, 0xcf, 0x46, 0xf1, 0x1a, 0x5b, 0xe6, 0x1a, 0x39, 0xb6, 0x96, 0xb8, 0x5b, 0xe6, 0x1d, + 0x8b, 0x91, 0xbb, 0x81, 0x51, 0x62, 0xee, 0x22, 0x86, 0x9a, 0x9f, 0xc3, 0xdc, 0x9e, 0x98, 0x40, 0xa8, 0x7f, 0x5d, + 0x4f, 0x66, 0x70, 0x31, 0x4d, 0x23, 0x19, 0xd6, 0x83, 0xd2, 0xf7, 0x44, 0x73, 0x8f, 0x78, 0xce, 0x09, 0xb6, 0x6d, + 0x2b, 0x5f, 0x7c, 0xcd, 0x18, 0xf8, 0xc0, 0x54, 0x77, 0x10, 0x5c, 0xd1, 0x5b, 0xd0, 0x3c, 0x83, 0xeb, 0x01, 0xb3, + 0x6f, 0x84, 0xf9, 0xbc, 0x10, 0x75, 0xfb, 0x44, 0x26, 0xff, 0x05, 0x84, 0x62, 0x7a, 0xab, 0xf3, 0x47, 0xfb, 0x1c, + 0xee, 0x3c, 0x64, 0x81, 0xc7, 0x92, 0x38, 0x64, 0xf8, 0xc7, 0x8d, 0xb6, 0x8c, 0x45, 0xcf, 0x9c, 0xc7, 0x2d, 0x89, + 0x09, 0xa5, 0xda, 0x5d, 0x4b, 0xa2, 0xbc, 0x16, 0x61, 0x51, 0x85, 0xd8, 0x6d, 0x15, 0x52, 0x19, 0x75, 0x45, 0xa4, + 0x8a, 0xc7, 0x59, 0x37, 0x3b, 0x43, 0x69, 0x04, 0x19, 0x0a, 0x26, 0xa8, 0x6a, 0x9f, 0x44, 0xb5, 0x14, 0xf3, 0xa0, + 0x4d, 0x13, 0xf5, 0xf0, 0xba, 0x2a, 0x63, 0xe1, 0x71, 0xd6, 0xbd, 0xed, 0x88, 0x75, 0xeb, 0x3a, 0xce, 0xb3, 0x75, + 0xe4, 0xad, 0x1c, 0x99, 0xd7, 0x15, 0x61, 0x2b, 0xc2, 0xf6, 0x41, 0x2d, 0x22, 0xca, 0x50, 0x22, 0xe1, 0xc0, 0x16, + 0xd4, 0xdb, 0x0b, 0x65, 0x36, 0x10, 0xee, 0x95, 0xf5, 0x51, 0xc9, 0x56, 0xd2, 0xb6, 0x95, 0x52, 0xb0, 0x80, 0x42, + 0x58, 0x68, 0xec, 0x39, 0xeb, 0xfe, 0xf6, 0xb9, 0x8e, 0xad, 0xff, 0xdb, 0x40, 0x6c, 0xf6, 0xef, 0xde, 0xdf, 0x8f, + 0x31, 0xc0, 0xa8, 0x7b, 0xd6, 0x15, 0xe9, 0x5b, 0x5d, 0xdf, 0x22, 0x7d, 0xf3, 0xf5, 0x4d, 0x6d, 0x4e, 0x78, 0x96, + 0xb1, 0x36, 0x6a, 0xe3, 0xce, 0x0d, 0xb4, 0x0e, 0xfb, 0x92, 0x92, 0xda, 0xef, 0xdb, 0xe5, 0xa7, 0xb1, 0x2a, 0xf3, + 0xa5, 0x99, 0x94, 0xb2, 0xe9, 0xc1, 0xa9, 0x5a, 0xd3, 0x65, 0x84, 0xd4, 0xbd, 0x18, 0x6a, 0x2b, 0xd5, 0xa9, 0xab, + 0xdb, 0x7c, 0x7c, 0x31, 0x26, 0xc6, 0x2f, 0xff, 0x0a, 0x17, 0xcf, 0x77, 0x4c, 0x87, 0xb6, 0xbc, 0xf3, 0xbe, 0xad, + 0xc4, 0xb8, 0xdc, 0x94, 0x70, 0x8e, 0x66, 0x16, 0x32, 0x46, 0x5c, 0x56, 0x9d, 0xbb, 0xe0, 0x32, 0x82, 0xc0, 0x17, + 0x74, 0x55, 0x29, 0x99, 0xa5, 0xbe, 0xad, 0xa3, 0xcf, 0xf7, 0x44, 0x95, 0xc3, 0x9f, 0x0b, 0x4c, 0xe8, 0x42, 0x57, + 0x95, 0xeb, 0x7b, 0x45, 0xc4, 0x50, 0x14, 0x71, 0xce, 0xa9, 0xf4, 0x2e, 0x2c, 0x7c, 0x53, 0x8f, 0xa7, 0x44, 0x6d, + 0x1b, 0xa4, 0x98, 0xc5, 0x98, 0x4b, 0x4b, 0x31, 0x97, 0xf2, 0x88, 0xed, 0xf3, 0x18, 0x08, 0x8b, 0x49, 0x20, 0xf2, + 0xe1, 0xca, 0x85, 0x63, 0xf9, 0x22, 0x60, 0xb0, 0x8a, 0x3e, 0x10, 0x9c, 0xdf, 0x99, 0x65, 0x17, 0x7f, 0x9b, 0x0f, + 0x47, 0x26, 0xe3, 0x2a, 0x0c, 0x81, 0x3b, 0xe2, 0xb7, 0x4e, 0x3b, 0x94, 0x01, 0xce, 0x19, 0x4d, 0x0c, 0x98, 0x75, + 0xd3, 0x34, 0x38, 0x55, 0x4d, 0x5b, 0xe5, 0x6e, 0x5e, 0x61, 0x26, 0x24, 0x31, 0x10, 0xe5, 0x66, 0xf8, 0x95, 0x1a, + 0x09, 0xc8, 0xf9, 0xfb, 0x2e, 0xce, 0xc9, 0x29, 0x85, 0x13, 0x95, 0x4c, 0x82, 0xaf, 0x1d, 0x78, 0x87, 0xba, 0x15, + 0x2f, 0xc4, 0x71, 0x9a, 0xf2, 0xc8, 0x04, 0xf4, 0x40, 0xed, 0x40, 0x94, 0x55, 0x4b, 0x8e, 0xc2, 0x44, 0x42, 0x28, + 0x85, 0x8f, 0xf8, 0x4c, 0xe6, 0xa2, 0xaa, 0x35, 0xaf, 0xfa, 0x82, 0x6e, 0x41, 0x62, 0x40, 0x54, 0x11, 0x22, 0xc9, + 0xa4, 0x5a, 0x37, 0x54, 0x58, 0x2c, 0x5d, 0x5a, 0x0c, 0xe2, 0x04, 0xc9, 0x3c, 0x2e, 0x04, 0xff, 0x32, 0xb0, 0xb7, + 0x1c, 0x6f, 0x7a, 0xef, 0x06, 0x75, 0x35, 0x32, 0x93, 0x9d, 0xf7, 0xe6, 0x45, 0xaf, 0xa4, 0x25, 0x97, 0x0f, 0x89, + 0x42, 0x7f, 0x5f, 0xb7, 0x9d, 0x65, 0x35, 0x91, 0x82, 0x79, 0x59, 0x54, 0x17, 0x95, 0xed, 0xa5, 0x95, 0x0b, 0x3c, + 0xee, 0x1e, 0x26, 0x48, 0xf0, 0xdd, 0x66, 0xf2, 0x14, 0xb8, 0x48, 0xd6, 0xd8, 0x72, 0x9f, 0x48, 0xa3, 0xa3, 0xdb, + 0x28, 0x59, 0x1d, 0xd9, 0xda, 0x3f, 0x41, 0x94, 0xe4, 0xcc, 0x5a, 0x89, 0xae, 0xff, 0x59, 0xea, 0x26, 0x17, 0x85, + 0xb5, 0x38, 0xe4, 0x20, 0x6e, 0x3a, 0x0b, 0x61, 0x4a, 0xf6, 0x56, 0x60, 0x23, 0x44, 0x86, 0x8b, 0x49, 0x16, 0xe4, + 0xdc, 0x8b, 0x1f, 0x1c, 0x29, 0xf8, 0x8f, 0x48, 0x0d, 0x2d, 0x99, 0xd2, 0xff, 0x70, 0x1d, 0xe1, 0x5b, 0x19, 0x0e, + 0x92, 0xd9, 0x8b, 0x17, 0xdc, 0x96, 0x9e, 0x77, 0xcc, 0x06, 0x49, 0xf8, 0xfd, 0xec, 0xf2, 0x59, 0x6f, 0x0f, 0xe2, + 0x0f, 0x65, 0x42, 0xf0, 0x45, 0x47, 0xb5, 0x8b, 0xa7, 0x51, 0x71, 0x3a, 0x94, 0x5f, 0x8f, 0x4f, 0xcd, 0xef, 0xed, + 0xf2, 0x02, 0x7e, 0xfa, 0xe5, 0x9c, 0x03, 0x33, 0xf0, 0x85, 0xb6, 0x1a, 0x6b, 0xd8, 0x0b, 0x83, 0x3d, 0x86, 0x92, + 0x45, 0x3a, 0xb4, 0x9f, 0x8d, 0x30, 0x1f, 0xba, 0xde, 0x66, 0xfd, 0x1d, 0xc3, 0xac, 0xce, 0x30, 0xbe, 0xb1, 0xaf, + 0x6a, 0x65, 0x76, 0xdb, 0xb0, 0xa7, 0x92, 0x9d, 0xf6, 0xe5, 0x06, 0x53, 0x37, 0x67, 0x6f, 0x43, 0xcd, 0xe5, 0x9b, + 0x51, 0x5c, 0x79, 0x33, 0x0f, 0x4b, 0x08, 0x18, 0x33, 0xcc, 0xb9, 0x22, 0xe7, 0x5a, 0xd9, 0x0f, 0x96, 0xd8, 0x1f, + 0xb6, 0x42, 0xda, 0x54, 0x45, 0x32, 0xb3, 0x81, 0x8f, 0xb5, 0x5a, 0x7b, 0x5a, 0x0f, 0xcc, 0xd2, 0x89, 0xe9, 0x58, + 0xb3, 0xb4, 0x82, 0xa1, 0x54, 0x68, 0xb5, 0xd4, 0x1d, 0xae, 0xd2, 0x97, 0x5a, 0x5e, 0xf2, 0x84, 0x84, 0xfd, 0x04, + 0xb2, 0x13, 0xdf, 0xc3, 0x3d, 0x69, 0xfb, 0xce, 0xac, 0xb1, 0x31, 0x95, 0x25, 0xca, 0x93, 0x72, 0x05, 0x65, 0xea, + 0x1d, 0x60, 0xa8, 0xa8, 0x31, 0x36, 0x74, 0x87, 0x06, 0x6d, 0x34, 0x0e, 0xf7, 0x85, 0xeb, 0x6d, 0x41, 0xfe, 0xa3, + 0xbe, 0xcf, 0xc9, 0x57, 0x67, 0xb3, 0xa8, 0xa7, 0xf5, 0x56, 0x63, 0xe4, 0xc8, 0x78, 0x80, 0xd7, 0x9b, 0x93, 0x2a, + 0x5b, 0x30, 0x64, 0xaf, 0xa1, 0xfe, 0xa9, 0x99, 0xba, 0x90, 0x76, 0x62, 0x46, 0x94, 0xf1, 0x20, 0x92, 0x04, 0x3d, + 0x59, 0x0f, 0x82, 0x6b, 0x96, 0x85, 0xb5, 0xc9, 0xc8, 0x3d, 0x18, 0xce, 0x91, 0x8a, 0xe8, 0x12, 0x8a, 0xe2, 0x9c, + 0xcd, 0xe3, 0x13, 0x86, 0x1c, 0xe5, 0xb1, 0x58, 0x96, 0x2c, 0xa8, 0xf7, 0x2d, 0x8c, 0xd4, 0x64, 0x9b, 0x8e, 0xa5, + 0xe4, 0xb2, 0x03, 0x38, 0xb1, 0xa3, 0xed, 0x3c, 0x61, 0x4e, 0x6d, 0x5d, 0x82, 0x9d, 0xec, 0xd4, 0xdc, 0xad, 0xc8, + 0x00, 0xc9, 0x03, 0x21, 0x0a, 0x03, 0x3e, 0xdf, 0xaf, 0x08, 0x50, 0xcd, 0x71, 0x8a, 0xc4, 0x1f, 0x84, 0xf2, 0xc7, + 0x13, 0x49, 0xa7, 0xc2, 0x72, 0xd7, 0x33, 0xbc, 0x39, 0x0e, 0xa0, 0x95, 0x7a, 0xb2, 0xf9, 0x41, 0x89, 0xb2, 0x91, + 0xbf, 0x8a, 0xb5, 0x8e, 0x18, 0x22, 0x1c, 0xf8, 0xcd, 0x6a, 0x43, 0xd2, 0x78, 0xb3, 0xba, 0x38, 0x1a, 0x85, 0x42, + 0x57, 0x07, 0xdc, 0x47, 0x2a, 0x00, 0xfb, 0x66, 0xc3, 0x53, 0x37, 0x4e, 0x77, 0x51, 0x96, 0x25, 0x9c, 0x06, 0x13, + 0xf8, 0x67, 0xd3, 0xb5, 0xba, 0x85, 0x8b, 0x35, 0xcd, 0xc4, 0x47, 0x71, 0x3a, 0xdd, 0xd7, 0xbd, 0x0e, 0x01, 0xff, + 0x72, 0x89, 0x1d, 0xd2, 0x27, 0xa4, 0x8a, 0x83, 0x11, 0x73, 0x74, 0x8c, 0x4b, 0x9a, 0xe9, 0xa9, 0x21, 0x77, 0x97, + 0xca, 0x47, 0x28, 0x07, 0xaa, 0x73, 0x3c, 0x3d, 0x64, 0x37, 0xc3, 0x31, 0x42, 0x6d, 0x67, 0x88, 0x2b, 0x03, 0xf5, + 0x04, 0xc8, 0x95, 0x04, 0xc2, 0x32, 0xcf, 0x67, 0x48, 0xdf, 0x33, 0x66, 0x02, 0x1a, 0x3a, 0x50, 0x6e, 0x7a, 0x52, + 0xe6, 0x90, 0x7a, 0xa8, 0x83, 0x10, 0x13, 0x1e, 0xf4, 0xb2, 0xa9, 0x69, 0x65, 0x1d, 0x8d, 0x50, 0x69, 0x42, 0x41, + 0xfc, 0x02, 0xa7, 0xe8, 0xab, 0x21, 0xf2, 0x97, 0x91, 0xf2, 0x3a, 0x2b, 0xf3, 0x86, 0xf4, 0x12, 0x2d, 0xb2, 0xfa, + 0xc6, 0xc8, 0xec, 0x48, 0x5d, 0x56, 0x7a, 0xed, 0x05, 0x60, 0x1e, 0x0e, 0xc1, 0x89, 0x44, 0xc4, 0x3c, 0x89, 0x26, + 0xb2, 0xa9, 0x50, 0xfe, 0xcc, 0xee, 0x49, 0x01, 0x5c, 0xce, 0x23, 0x41, 0x13, 0x81, 0x8f, 0x1d, 0x00, 0x67, 0x66, + 0x10, 0xe0, 0x6c, 0x35, 0x69, 0x04, 0xc6, 0x5c, 0x2b, 0x6f, 0x35, 0xfb, 0x98, 0x11, 0xe5, 0xb8, 0x98, 0x1b, 0xd9, + 0x5d, 0x93, 0xfb, 0x53, 0xcc, 0x13, 0x1b, 0x73, 0xf8, 0xb9, 0xf6, 0x2a, 0x99, 0xfe, 0x65, 0x06, 0x3e, 0x29, 0x51, + 0x7d, 0x69, 0x50, 0xbc, 0x6e, 0xe3, 0x82, 0x36, 0xda, 0x35, 0xe4, 0xb2, 0xe8, 0x30, 0x58, 0xae, 0xfd, 0xbf, 0x7e, + 0x7b, 0x3e, 0xef, 0x2b, 0xe7, 0x63, 0x76, 0xc5, 0x7d, 0x70, 0x58, 0x33, 0xe4, 0xfc, 0xba, 0x2e, 0x9e, 0xe3, 0xfb, + 0xf5, 0xb7, 0xb9, 0xf1, 0x74, 0x77, 0x10, 0x64, 0x2e, 0xa4, 0x3e, 0xb3, 0x84, 0xe8, 0xc3, 0xd0, 0xe2, 0xd9, 0x18, + 0x55, 0xa2, 0xf1, 0xa5, 0x43, 0x8a, 0x65, 0x8b, 0xa7, 0x27, 0x81, 0x78, 0x39, 0xdc, 0x93, 0x2d, 0x10, 0x2b, 0x4a, + 0x84, 0x39, 0x9d, 0x88, 0x34, 0x8e, 0x80, 0xf1, 0x4a, 0xdc, 0x33, 0x04, 0x46, 0x1a, 0x65, 0xd6, 0xb4, 0xff, 0xd8, + 0x88, 0xec, 0x73, 0x48, 0x34, 0x19, 0x36, 0xe5, 0x93, 0xcd, 0xa8, 0xbd, 0x12, 0x09, 0x45, 0xc3, 0xba, 0x9f, 0xa6, + 0x19, 0x95, 0xf7, 0x62, 0x1c, 0x12, 0x87, 0x70, 0xd2, 0xbb, 0xdf, 0xaf, 0xbf, 0x95, 0x3c, 0xfc, 0x1e, 0xf6, 0x1f, + 0xbf, 0xf8, 0x1f, 0xbf, 0x87, 0x7b, 0xf2, 0x8b, 0x9f, 0xfc, 0x1e, 0xf2, 0xc9, 0x2f, 0xe2, 0xa5, 0xd2, 0xf4, 0x95, + 0xdd, 0x79, 0x30, 0x16, 0x0c, 0xe5, 0xb2, 0x8c, 0x6c, 0xa5, 0x0a, 0x7e, 0xf1, 0x21, 0xe1, 0x3e, 0x17, 0x48, 0xc9, + 0xa9, 0x64, 0x82, 0x95, 0xa8, 0x64, 0x65, 0xe8, 0x14, 0xd4, 0xa7, 0x01, 0x3e, 0x4a, 0xbd, 0xfd, 0x9c, 0x7f, 0xba, + 0x35, 0x92, 0xc6, 0x40, 0x3c, 0x19, 0x82, 0xae, 0xdc, 0x99, 0x5b, 0xcf, 0x4d, 0x49, 0x18, 0x65, 0x39, 0x62, 0xb4, + 0xa2, 0xd2, 0x8e, 0xb3, 0x44, 0xef, 0x3c, 0x18, 0x34, 0x13, 0xf4, 0xed, 0x7b, 0xe8, 0xa4, 0xb0, 0x3b, 0x43, 0x01, + 0x72, 0x96, 0x95, 0x02, 0x1e, 0xd8, 0xc7, 0x5e, 0x3c, 0x47, 0x5a, 0x79, 0x35, 0xa9, 0xa2, 0x06, 0xd7, 0xe4, 0x60, + 0x8c, 0x11, 0x12, 0xf7, 0xf4, 0x2f, 0xf9, 0x98, 0x9c, 0xb9, 0x79, 0xab, 0x59, 0xb8, 0xc7, 0xd4, 0x72, 0x40, 0x73, + 0x62, 0x54, 0xcd, 0x0c, 0x5b, 0x44, 0xad, 0x59, 0xcd, 0x99, 0x45, 0x9c, 0x2c, 0xc5, 0xd6, 0x55, 0xd8, 0xf3, 0x1e, + 0x3f, 0xe5, 0x1f, 0xe6, 0x34, 0x57, 0x8f, 0x34, 0xd8, 0x17, 0x19, 0xbb, 0x0f, 0xae, 0x70, 0x5a, 0x6b, 0x30, 0x3d, + 0xe1, 0x6c, 0x2d, 0xae, 0xaf, 0xa6, 0xf0, 0x05, 0x69, 0x75, 0xcf, 0xa5, 0x88, 0x46, 0x37, 0xc9, 0xc4, 0x86, 0xa1, + 0xb5, 0xd9, 0x7d, 0x6d, 0xa1, 0xd1, 0x66, 0x05, 0xad, 0x59, 0xd9, 0xfd, 0xe6, 0x8d, 0x36, 0xb1, 0xc9, 0x9c, 0x05, + 0x99, 0xa8, 0xba, 0x09, 0xd2, 0xa6, 0xc0, 0x27, 0x27, 0x2b, 0x8c, 0x47, 0x20, 0x8b, 0xdc, 0xe6, 0x64, 0x7f, 0xe9, + 0xa8, 0x65, 0x54, 0x95, 0x10, 0x89, 0xcf, 0xca, 0x2d, 0xe4, 0x12, 0x74, 0xbc, 0x38, 0x10, 0xc1, 0xe5, 0x30, 0x2e, + 0x95, 0x9a, 0x46, 0xdb, 0x35, 0xda, 0x5b, 0xc8, 0x73, 0xa8, 0xcb, 0x4f, 0x83, 0x0d, 0x61, 0x88, 0x6a, 0xf4, 0xa1, + 0xcd, 0x3c, 0xbd, 0xa6, 0x4b, 0xfb, 0xf5, 0xf7, 0x01, 0x38, 0x7a, 0xb1, 0xbd, 0x90, 0xcc, 0x5d, 0x9f, 0x92, 0x48, + 0x20, 0x51, 0xf2, 0x05, 0xa0, 0x07, 0x80, 0x5e, 0xf5, 0x12, 0x56, 0x03, 0x06, 0xad, 0x54, 0x81, 0x9e, 0x29, 0x78, + 0x00, 0x32, 0x43, 0xcb, 0x41, 0xe5, 0x8f, 0x48, 0xf0, 0xb5, 0x43, 0xb2, 0x98, 0xf0, 0xd2, 0x50, 0xbc, 0x8e, 0x09, + 0xed, 0x7c, 0x98, 0x9a, 0x5e, 0x22, 0xf7, 0x14, 0x29, 0x1d, 0xb1, 0x45, 0x3f, 0xfd, 0xf4, 0xaa, 0xa7, 0x85, 0x93, + 0x3c, 0xb2, 0x7c, 0xac, 0xfd, 0x5b, 0xd6, 0xb6, 0xab, 0xea, 0x8f, 0x4c, 0x49, 0x1d, 0x68, 0x43, 0x28, 0xd7, 0x33, + 0x65, 0x4f, 0xe9, 0x2b, 0xd8, 0x59, 0x0c, 0x8b, 0x5e, 0xbb, 0xcf, 0x6a, 0x73, 0xf8, 0xd0, 0x45, 0x0f, 0x44, 0x13, + 0x6e, 0x5f, 0x23, 0x81, 0xe6, 0x12, 0xc1, 0x62, 0x78, 0x46, 0x97, 0x76, 0xe3, 0x43, 0x4e, 0x51, 0x10, 0xab, 0xc0, + 0x87, 0x74, 0xfd, 0x84, 0x86, 0x0c, 0x65, 0xbb, 0x8d, 0x02, 0x67, 0x35, 0xd0, 0x7c, 0x5f, 0xe3, 0xb0, 0x57, 0x27, + 0x60, 0x6d, 0xc9, 0x7c, 0xb5, 0x69, 0xa3, 0xd8, 0x6b, 0x2e, 0xaf, 0xf6, 0xda, 0x0a, 0x81, 0x3f, 0x17, 0x9f, 0xfd, + 0xed, 0x79, 0x52, 0x7d, 0x9f, 0x9f, 0x94, 0xde, 0xdb, 0xac, 0xfa, 0xa0, 0x35, 0xd8, 0xfb, 0xe3, 0x94, 0xf7, 0x91, + 0xe5, 0x30, 0x29, 0x3d, 0x1f, 0x8d, 0x6a, 0xb1, 0x7b, 0x4d, 0xe6, 0xf1, 0x61, 0x25, 0x54, 0xb3, 0xa9, 0x91, 0x07, + 0xf7, 0x5a, 0x73, 0xa1, 0xef, 0x51, 0xa0, 0xba, 0xd7, 0xc2, 0xa9, 0xba, 0x2a, 0x25, 0x88, 0xc9, 0xc8, 0x68, 0xa6, + 0xd9, 0x58, 0x6f, 0x03, 0xf3, 0x71, 0xaa, 0x5f, 0xf0, 0x27, 0x52, 0x72, 0xd8, 0xed, 0xac, 0x2c, 0x4a, 0xc5, 0x24, + 0x25, 0xa0, 0xc5, 0xf6, 0x6f, 0x71, 0x70, 0x60, 0x50, 0xb5, 0xea, 0x3c, 0x60, 0x24, 0xf6, 0xc5, 0xe2, 0x23, 0x50, + 0xf1, 0x5b, 0x3b, 0xc8, 0xec, 0x86, 0x8f, 0x65, 0x29, 0x2c, 0xfc, 0x20, 0x4a, 0xa5, 0x9e, 0x80, 0x40, 0x4d, 0x9d, + 0xbc, 0x29, 0x41, 0xb0, 0x7c, 0x33, 0xa7, 0x8d, 0xbd, 0x30, 0x5d, 0x1d, 0xc8, 0xb5, 0x69, 0x24, 0x86, 0x22, 0xfe, + 0xc9, 0xb1, 0xe1, 0x3a, 0x9a, 0xb0, 0xea, 0x89, 0xe5, 0x5e, 0x94, 0x07, 0xa1, 0x41, 0xe8, 0x90, 0xa7, 0xca, 0x6d, + 0x19, 0xd6, 0xe7, 0x2d, 0x2f, 0x4f, 0xfa, 0x17, 0x1e, 0x1f, 0x2c, 0x3a, 0x7f, 0x42, 0x33, 0x17, 0x02, 0x29, 0xa8, + 0x62, 0x93, 0xc2, 0x1d, 0xa1, 0x2a, 0xcb, 0x9d, 0x97, 0x15, 0xcd, 0x6b, 0x33, 0x0f, 0xd2, 0xd5, 0x47, 0x05, 0x99, + 0x4b, 0x28, 0x09, 0xa5, 0x2e, 0x60, 0x0a, 0xa3, 0x2c, 0xde, 0xe8, 0xbb, 0xf5, 0x0f, 0xbb, 0x94, 0x84, 0x03, 0x3e, + 0x86, 0xc1, 0x4c, 0xe0, 0xdf, 0x0f, 0x29, 0x0d, 0xdc, 0xd4, 0xba, 0x16, 0xca, 0x18, 0xd2, 0x0a, 0xc1, 0x7c, 0x24, + 0xd1, 0x60, 0x82, 0xef, 0x3b, 0x83, 0x22, 0x27, 0x05, 0x2b, 0x8d, 0xdf, 0x8c, 0x7b, 0x0c, 0x1d, 0x67, 0xc6, 0x3b, + 0x3b, 0x5d, 0xb1, 0xb7, 0xe6, 0xb8, 0x3a, 0x84, 0x80, 0xcb, 0xb1, 0xdc, 0xca, 0xba, 0x20, 0xeb, 0x18, 0xf2, 0x2c, + 0xdc, 0x22, 0x71, 0xc9, 0x08, 0x3d, 0xa5, 0x43, 0x23, 0x95, 0x61, 0x09, 0x4e, 0x9b, 0xe1, 0x03, 0xdb, 0xb8, 0x82, + 0xba, 0x9d, 0x9d, 0x06, 0xea, 0xf6, 0x0a, 0x78, 0xb0, 0x6b, 0x42, 0x89, 0xd2, 0xc8, 0xaa, 0x80, 0x06, 0x23, 0xa0, + 0x2d, 0x0b, 0x94, 0x6a, 0x22, 0x26, 0x1a, 0x85, 0x51, 0x22, 0xb5, 0x94, 0xb2, 0xa3, 0xe9, 0x77, 0x5d, 0x24, 0x93, + 0x64, 0x1d, 0x8a, 0x83, 0x9e, 0x98, 0x24, 0xb5, 0x5a, 0x97, 0x2d, 0x3e, 0x1c, 0x88, 0xfd, 0x22, 0x95, 0x9e, 0xd8, + 0xdb, 0x69, 0x81, 0xdc, 0xec, 0x7b, 0x1a, 0x52, 0x43, 0xa3, 0xb3, 0xad, 0xd1, 0x79, 0x79, 0x2a, 0x9b, 0x1f, 0x74, + 0xd4, 0x72, 0xeb, 0xc6, 0x98, 0xa2, 0x0a, 0xa8, 0x3f, 0xd6, 0x82, 0xf4, 0xfd, 0x4b, 0xa1, 0x4e, 0x50, 0x34, 0x4c, + 0xed, 0x7b, 0x2c, 0x46, 0xba, 0x4e, 0xf3, 0x48, 0x48, 0x70, 0xef, 0x09, 0x02, 0x3c, 0x22, 0x4f, 0x23, 0x19, 0xd3, + 0x09, 0xc2, 0x10, 0x91, 0x75, 0xb2, 0xe6, 0x7d, 0x6e, 0xfd, 0xfe, 0x92, 0xbc, 0xef, 0xe2, 0x06, 0x93, 0xab, 0xfd, + 0x94, 0xde, 0xfb, 0xed, 0x76, 0x68, 0xed, 0x71, 0x12, 0x37, 0xe3, 0x85, 0xa5, 0xf6, 0x58, 0xd8, 0xff, 0x66, 0xf3, + 0xa9, 0x53, 0xa5, 0xb7, 0x6b, 0x0d, 0x69, 0x3c, 0xb3, 0xc6, 0x66, 0x3f, 0x09, 0xda, 0x91, 0x0b, 0xb4, 0x13, 0x3b, + 0x39, 0xab, 0x20, 0xa1, 0x21, 0x31, 0xa6, 0xb6, 0x73, 0x08, 0xd0, 0x8c, 0x75, 0xe6, 0xf6, 0xad, 0xf6, 0xed, 0x29, + 0x27, 0x65, 0x80, 0xf2, 0x52, 0xf8, 0x67, 0xdb, 0x49, 0x89, 0x7d, 0x1c, 0x63, 0x6c, 0x05, 0xf1, 0x21, 0x81, 0x54, + 0x05, 0x13, 0x5a, 0x4d, 0x1e, 0xd0, 0xc5, 0x29, 0x1d, 0x7f, 0xa6, 0x1f, 0x3e, 0xc0, 0xea, 0x6b, 0x1e, 0xd9, 0x66, + 0x0f, 0x1c, 0x63, 0x4a, 0xbd, 0xce, 0x0e, 0x58, 0x3f, 0xa5, 0xf7, 0xba, 0x58, 0x1b, 0x43, 0xca, 0x96, 0x5c, 0xbb, + 0xb6, 0x08, 0x99, 0x30, 0x64, 0x5d, 0x47, 0x28, 0xac, 0xe0, 0xfc, 0x86, 0x9c, 0xc0, 0xea, 0xfd, 0x9c, 0x2b, 0xf5, + 0x2c, 0x52, 0xb3, 0x4c, 0xd0, 0xce, 0x8e, 0x1c, 0xe9, 0x3c, 0xa9, 0xff, 0x6f, 0x25, 0x84, 0xe0, 0xd2, 0x9a, 0x6e, + 0x4b, 0xa8, 0x93, 0xfc, 0xe4, 0x2a, 0x5a, 0xc0, 0x73, 0x37, 0xca, 0x1f, 0xc9, 0xea, 0x6d, 0x82, 0x67, 0x83, 0x48, + 0x60, 0xc3, 0x72, 0x4a, 0x54, 0xc3, 0x6a, 0xab, 0x5b, 0xf8, 0xee, 0xd1, 0xed, 0x8d, 0x62, 0x0c, 0x15, 0x4e, 0x7e, + 0x0e, 0x94, 0x54, 0xdc, 0xeb, 0x92, 0x5a, 0x47, 0xe5, 0x7f, 0xa3, 0xb8, 0xc2, 0x49, 0x7c, 0x73, 0x93, 0xb3, 0x81, + 0x47, 0xdd, 0x53, 0x43, 0xb2, 0xbf, 0x5f, 0xa8, 0x10, 0x6d, 0xb4, 0x8e, 0x19, 0xa0, 0x0a, 0x1f, 0x41, 0x2e, 0x47, + 0xbe, 0x9f, 0x75, 0xe5, 0x17, 0xf9, 0xa5, 0x6f, 0xcf, 0x0d, 0x62, 0xcd, 0x5c, 0xa8, 0x59, 0xca, 0x28, 0xbf, 0x0c, + 0x6f, 0xe2, 0xb6, 0xc8, 0x20, 0xab, 0xcf, 0x6b, 0xec, 0x1d, 0x62, 0xe5, 0xd8, 0x6d, 0x4f, 0x58, 0x41, 0x4c, 0x90, + 0x2e, 0xc1, 0x53, 0x5d, 0x50, 0xc4, 0x28, 0x35, 0x67, 0x38, 0xd5, 0xa2, 0xba, 0x50, 0xce, 0xd5, 0x7a, 0x49, 0x05, + 0x84, 0xea, 0x7b, 0x2a, 0xe7, 0x25, 0x30, 0xec, 0x9d, 0xc7, 0x7e, 0xb0, 0x3c, 0x6f, 0xea, 0x5a, 0x99, 0x9d, 0xa6, + 0xeb, 0x1e, 0x2a, 0x1c, 0x68, 0x53, 0x7a, 0x4b, 0x57, 0xf3, 0x7c, 0xad, 0x16, 0xf8, 0x6d, 0x68, 0xc1, 0x33, 0xe7, + 0x13, 0xd0, 0x57, 0xc9, 0x23, 0x89, 0x3b, 0x4b, 0xd7, 0xae, 0x80, 0x16, 0x26, 0x93, 0xc0, 0x83, 0xd3, 0x7d, 0xad, + 0x92, 0xb5, 0x91, 0x70, 0x4c, 0x08, 0x03, 0x72, 0xd6, 0x07, 0xdb, 0x6e, 0x8c, 0x5c, 0xa2, 0xf6, 0xfa, 0x91, 0x86, + 0x16, 0x59, 0x3f, 0x68, 0xd2, 0xf3, 0x40, 0x51, 0x39, 0xaa, 0xde, 0xdc, 0x29, 0xa3, 0x87, 0x98, 0x27, 0x8c, 0xda, + 0xc4, 0xa0, 0x91, 0x1e, 0xa8, 0x33, 0x42, 0xce, 0x4f, 0x6c, 0x52, 0x7d, 0x8d, 0x0f, 0x9f, 0x09, 0x61, 0xac, 0x36, + 0x0d, 0xf9, 0x3c, 0x81, 0xf6, 0x6c, 0xe9, 0xb8, 0x53, 0x43, 0x86, 0xd7, 0xa6, 0xcb, 0x21, 0x19, 0x0b, 0x2e, 0x9b, + 0x21, 0x0c, 0x6a, 0x25, 0xe3, 0x34, 0xb1, 0xcf, 0xa9, 0x1b, 0x49, 0x57, 0xe5, 0x1a, 0x02, 0x1c, 0x77, 0x9c, 0x49, + 0xb3, 0xd8, 0x72, 0x8b, 0x92, 0xab, 0x4b, 0x4d, 0x88, 0x2d, 0x9a, 0x88, 0x12, 0x00, 0x7a, 0x39, 0xec, 0x23, 0x20, + 0xe1, 0xdb, 0x0a, 0xe7, 0xe6, 0x89, 0x2d, 0xad, 0x5c, 0x73, 0x41, 0x61, 0xb8, 0xa3, 0xaf, 0xf7, 0x62, 0x53, 0x11, + 0x7b, 0x06, 0xf3, 0xd0, 0x6c, 0x2c, 0xb3, 0xf9, 0x23, 0xdf, 0x9f, 0x87, 0x66, 0x20, 0xfd, 0x03, 0x16, 0xc4, 0x7f, + 0x0d, 0x15, 0xe2, 0x19, 0x17, 0xe4, 0x0f, 0xb4, 0x92, 0x86, 0x2f, 0x58, 0xb7, 0xd3, 0x95, 0x9f, 0x4d, 0x9f, 0xaa, + 0x05, 0x04, 0xe5, 0x81, 0x5c, 0x48, 0x73, 0x03, 0x6b, 0xbc, 0xc1, 0x8a, 0xf5, 0xc6, 0x0e, 0x49, 0x60, 0xeb, 0xe9, + 0x48, 0x26, 0x8d, 0x74, 0x8a, 0x07, 0xbe, 0xd5, 0xb1, 0xfd, 0xad, 0xce, 0x29, 0xbd, 0x29, 0x4f, 0x9b, 0xe6, 0xad, + 0x78, 0xe8, 0x59, 0x5b, 0x45, 0x98, 0x30, 0x78, 0x2a, 0x9c, 0xf0, 0x7a, 0x2f, 0x57, 0xd9, 0x35, 0x7c, 0x06, 0x3f, + 0xf4, 0x6c, 0x30, 0x17, 0x36, 0xd7, 0x22, 0x41, 0x07, 0x61, 0xbc, 0xf1, 0xf9, 0x11, 0x46, 0xa6, 0x4b, 0xe9, 0x15, + 0xfd, 0x68, 0x90, 0x28, 0xde, 0xae, 0xbf, 0xdd, 0x7d, 0x8f, 0xe0, 0xe0, 0xde, 0x82, 0x6c, 0x4c, 0x9b, 0xbd, 0x61, + 0x0f, 0x69, 0x51, 0xd5, 0x18, 0x23, 0xa4, 0x42, 0x1c, 0x43, 0xc4, 0xe5, 0xf6, 0x55, 0x5b, 0x1e, 0xdc, 0xf2, 0x4b, + 0x9e, 0x51, 0xf8, 0x28, 0xfe, 0xce, 0x7c, 0xd7, 0x47, 0xe8, 0x8a, 0xeb, 0x3c, 0x87, 0xf8, 0xda, 0x6f, 0xaf, 0x91, + 0x10, 0x25, 0xe1, 0x7f, 0x06, 0x0f, 0x30, 0x33, 0x5e, 0xac, 0x01, 0x7b, 0x5e, 0xdd, 0xc8, 0x49, 0x70, 0x5f, 0x30, + 0xf4, 0xb6, 0xf9, 0x42, 0x3f, 0x9e, 0x92, 0x78, 0x8b, 0xb6, 0x88, 0x5d, 0xa9, 0x83, 0x19, 0x3b, 0x71, 0xcd, 0x87, + 0xc9, 0xec, 0x3f, 0x46, 0x58, 0x00, 0x84, 0x82, 0x5a, 0x0b, 0x3f, 0x6d, 0x05, 0x70, 0xab, 0xff, 0x60, 0xa4, 0xc0, + 0x4d, 0xf4, 0xc4, 0xcf, 0x76, 0x4f, 0xb0, 0x09, 0x4e, 0xc4, 0x5e, 0x91, 0xb6, 0xe7, 0x40, 0xaf, 0x56, 0x35, 0x84, + 0xea, 0xd6, 0xe9, 0x20, 0x74, 0xb1, 0x28, 0x8c, 0xf5, 0x3a, 0x0a, 0x6c, 0x56, 0x2d, 0xab, 0x0e, 0x43, 0x6d, 0x57, + 0xa1, 0xf6, 0x24, 0x1b, 0x16, 0x25, 0x2a, 0x72, 0xe3, 0x78, 0x53, 0xac, 0x03, 0xea, 0xd7, 0x7e, 0x6d, 0x82, 0x5b, + 0x2f, 0x78, 0x74, 0x2c, 0xc8, 0xd5, 0x14, 0x31, 0x78, 0x81, 0xc8, 0xe0, 0x55, 0x59, 0xa0, 0x93, 0x5e, 0xb8, 0xef, + 0x9b, 0x4f, 0x75, 0x61, 0xe9, 0x6e, 0x1a, 0x3e, 0xfb, 0x79, 0xf4, 0xab, 0xe1, 0xeb, 0x25, 0x63, 0x64, 0x5c, 0x24, + 0x2d, 0x7a, 0xea, 0x1c, 0x97, 0x6b, 0x30, 0x7b, 0x68, 0x75, 0xcc, 0xb0, 0xfb, 0x74, 0xa5, 0xc5, 0x18, 0xbf, 0x13, + 0xc5, 0xb4, 0x07, 0xcb, 0x32, 0x13, 0xf7, 0xf4, 0x82, 0x00, 0x69, 0x2d, 0xf1, 0xa6, 0xd5, 0x5b, 0x6d, 0x7d, 0x36, + 0x2d, 0x83, 0xe8, 0x1b, 0x8b, 0x4c, 0xdd, 0x2c, 0x64, 0xb9, 0x4c, 0xb1, 0x46, 0xab, 0xb0, 0x2f, 0x97, 0x47, 0x37, + 0x7d, 0x5d, 0x1a, 0xff, 0x16, 0x55, 0x4f, 0x86, 0x44, 0xd2, 0x12, 0xa5, 0x52, 0x81, 0x93, 0x2e, 0xec, 0x62, 0x4d, + 0x47, 0x2d, 0xd7, 0x89, 0x33, 0xde, 0x8f, 0x97, 0x0e, 0xcb, 0x1f, 0x9f, 0x0b, 0x42, 0xad, 0xfc, 0x3f, 0x10, 0xfb, + 0xec, 0x70, 0x32, 0xa0, 0x9c, 0xc2, 0x19, 0xd9, 0xfd, 0x0f, 0xba, 0xda, 0x15, 0x40, 0xcd, 0x30, 0x7a, 0xb9, 0x54, + 0x38, 0x54, 0x94, 0x7e, 0x3a, 0xe9, 0xc6, 0x50, 0x58, 0x5f, 0xad, 0x85, 0xd7, 0x5e, 0x52, 0xd1, 0x25, 0xfe, 0x4a, + 0xfa, 0x98, 0x70, 0x2a, 0x65, 0x87, 0xfa, 0xaa, 0x21, 0x01, 0xa0, 0x43, 0xbc, 0x12, 0x01, 0x37, 0xf3, 0x16, 0x34, + 0x99, 0xc8, 0xb8, 0xf8, 0xe0, 0x02, 0xb8, 0x30, 0xde, 0x3e, 0xcd, 0x40, 0xb2, 0xd6, 0x12, 0x3b, 0x09, 0xdd, 0xf4, + 0x31, 0x61, 0x04, 0x48, 0xb0, 0xe3, 0x01, 0x34, 0x79, 0x27, 0xbc, 0xc7, 0x7a, 0x35, 0x31, 0x05, 0x41, 0x44, 0xf7, + 0x9e, 0x83, 0xdd, 0x5c, 0xcb, 0x6a, 0x85, 0x4d, 0x88, 0xcd, 0x8e, 0xaa, 0xef, 0xa7, 0x0a, 0xbc, 0x5e, 0x98, 0x54, + 0x6c, 0x14, 0xba, 0x4e, 0x1e, 0x68, 0x1c, 0x60, 0x3a, 0x4b, 0x0e, 0x35, 0x5c, 0xf9, 0x50, 0x96, 0x93, 0x94, 0xd0, + 0x52, 0x38, 0xe0, 0x0c, 0x24, 0x07, 0xff, 0x63, 0x41, 0x03, 0x59, 0x87, 0x9f, 0x18, 0xd7, 0xe0, 0x5f, 0x48, 0x6b, + 0x9a, 0x16, 0xd1, 0x6a, 0xaf, 0x61, 0x0d, 0x9a, 0x97, 0xc9, 0x97, 0x13, 0x03, 0xd8, 0xac, 0x16, 0xb2, 0xfa, 0xb1, + 0xe7, 0x9a, 0x3f, 0x52, 0x7e, 0xca, 0x42, 0xed, 0xa9, 0x9e, 0xb6, 0x42, 0xb2, 0xd3, 0xb4, 0xa8, 0x88, 0xe2, 0x7a, + 0xb2, 0x5d, 0x17, 0x2f, 0xbe, 0x88, 0x04, 0x7e, 0x31, 0x81, 0x18, 0x12, 0x40, 0x60, 0x70, 0x04, 0x35, 0x24, 0x74, + 0xd4, 0xd7, 0x9b, 0xc7, 0x57, 0x15, 0x04, 0xcd, 0x63, 0xa6, 0x80, 0x98, 0xae, 0x98, 0x9d, 0xbf, 0x04, 0x5a, 0xf1, + 0xfe, 0x0d, 0xd6, 0x55, 0xcd, 0x9f, 0x37, 0x69, 0xe3, 0x17, 0xd6, 0x7f, 0xd4, 0xb1, 0x2a, 0xb0, 0x21, 0x36, 0xa8, + 0x52, 0x24, 0xac, 0x32, 0x06, 0x88, 0x46, 0xcf, 0x5c, 0x45, 0x9a, 0xc2, 0xfe, 0xee, 0x3c, 0x1e, 0xd4, 0x3a, 0xb5, + 0xf9, 0xa6, 0xe7, 0x52, 0x62, 0x09, 0x97, 0x99, 0xe9, 0x73, 0x39, 0x00, 0x32, 0xd3, 0x83, 0xdc, 0x40, 0x83, 0xaf, + 0xc1, 0xab, 0x2b, 0xe6, 0x2c, 0x3d, 0xbb, 0x1f, 0x36, 0x7e, 0x7f, 0x95, 0x5e, 0xd1, 0x3b, 0x18, 0x99, 0x6f, 0xee, + 0xf5, 0xee, 0x5a, 0x5d, 0xbf, 0xb0, 0x98, 0x51, 0x97, 0xaa, 0xe5, 0xe9, 0xe7, 0xed, 0xbe, 0x2f, 0x1e, 0xac, 0xfd, + 0x29, 0x28, 0x63, 0x7b, 0x92, 0x77, 0xad, 0xe4, 0xc6, 0xbf, 0x40, 0xd3, 0xaa, 0xa0, 0x96, 0x91, 0x29, 0x6f, 0x6b, + 0xbf, 0xe5, 0xba, 0xbc, 0x3d, 0x91, 0x71, 0xc4, 0xb9, 0x63, 0xc8, 0xfb, 0xd2, 0x36, 0x3e, 0xf7, 0x1a, 0x02, 0x85, + 0x5f, 0x9e, 0x4e, 0x29, 0x68, 0x6b, 0xc2, 0x25, 0xe2, 0x0c, 0x2d, 0xaf, 0x4b, 0x37, 0xc5, 0x20, 0x72, 0xf4, 0x81, + 0xdd, 0xd2, 0x86, 0xe0, 0xdb, 0x22, 0xfc, 0x6c, 0x26, 0xd4, 0x93, 0xad, 0x40, 0xad, 0x88, 0x2a, 0x7b, 0x88, 0x16, + 0x02, 0xcb, 0x89, 0xe4, 0xa4, 0x37, 0x75, 0x26, 0x90, 0x60, 0xea, 0x15, 0x6f, 0xbb, 0x60, 0xc8, 0x62, 0x97, 0x2b, + 0x0c, 0x2c, 0xa2, 0x64, 0x2a, 0x7e, 0xbd, 0x3c, 0x95, 0x46, 0x0b, 0x0c, 0x01, 0x4c, 0x73, 0x2f, 0x2f, 0x1a, 0x03, + 0xee, 0xfe, 0xee, 0x46, 0x9a, 0x6e, 0x48, 0xe0, 0x9b, 0x67, 0xf3, 0x5e, 0x4a, 0x06, 0x7a, 0x6e, 0xf2, 0xeb, 0x49, + 0xda, 0x89, 0x9c, 0x93, 0xda, 0x9c, 0xe1, 0x10, 0xa0, 0xaa, 0xd9, 0x43, 0x9a, 0x56, 0xa5, 0xec, 0xc4, 0x25, 0x90, + 0xe5, 0x37, 0x11, 0xf8, 0xf2, 0xcb, 0x63, 0xec, 0x9d, 0x8a, 0xcc, 0x14, 0x61, 0x4f, 0x94, 0x4f, 0x1b, 0x56, 0x77, + 0xf3, 0xf0, 0x34, 0x47, 0xb0, 0xf3, 0x87, 0x69, 0xdc, 0xd7, 0x0d, 0xcf, 0x00, 0x30, 0x03, 0xe1, 0x13, 0x82, 0x4f, + 0x30, 0x44, 0x33, 0xdd, 0xdc, 0x76, 0x1f, 0x55, 0xa5, 0xaa, 0x78, 0x0a, 0x70, 0x7c, 0x82, 0xe1, 0x9d, 0xa9, 0xc7, + 0x66, 0x09, 0x36, 0xcf, 0x23, 0x30, 0x84, 0xdc, 0x34, 0xa7, 0x9a, 0x72, 0x03, 0xe4, 0xbb, 0x88, 0x61, 0x8a, 0x67, + 0xb1, 0x47, 0xc3, 0x07, 0xd4, 0x2b, 0x6f, 0xee, 0xbc, 0xc0, 0x6f, 0xb3, 0x88, 0x65, 0xcf, 0x93, 0x51, 0x06, 0x9f, + 0x88, 0x7c, 0x8b, 0x14, 0x32, 0xf7, 0x83, 0xa6, 0xb0, 0xda, 0xa6, 0xf5, 0x33, 0x20, 0x72, 0x73, 0x75, 0x63, 0xa2, + 0x35, 0x70, 0xa1, 0x37, 0x51, 0x5d, 0x40, 0x6b, 0x9b, 0xf5, 0xe1, 0x66, 0x57, 0x22, 0x19, 0x3c, 0x10, 0xe6, 0xdf, + 0x78, 0xf1, 0x60, 0xf2, 0x2d, 0xe4, 0xc9, 0xf0, 0x91, 0x87, 0xd3, 0xbd, 0xb5, 0xe7, 0xad, 0xfb, 0x96, 0xbb, 0x6a, + 0x4d, 0x9e, 0xd3, 0x22, 0x94, 0xd8, 0x49, 0x06, 0x70, 0x04, 0x1f, 0x9b, 0xb1, 0xee, 0x03, 0xd4, 0x89, 0x0c, 0x2e, + 0x54, 0x31, 0xe3, 0xcc, 0x38, 0xca, 0xf2, 0x2b, 0xae, 0x39, 0xb8, 0xfd, 0xbc, 0x72, 0x31, 0x10, 0xb0, 0xd0, 0x81, + 0x32, 0xf5, 0x47, 0x32, 0xb5, 0x35, 0x4d, 0x8e, 0xf9, 0x19, 0x2c, 0x10, 0x19, 0x05, 0x01, 0xc8, 0xc2, 0xd3, 0xb6, + 0x4a, 0xf7, 0xf1, 0xa0, 0x1b, 0x50, 0xde, 0x08, 0xcc, 0xc8, 0xa0, 0x43, 0x30, 0x63, 0x6d, 0x67, 0x22, 0x11, 0x61, + 0x12, 0xae, 0x2c, 0x6a, 0xf8, 0x17, 0x4f, 0x49, 0xf9, 0x98, 0x87, 0xbe, 0x20, 0x8c, 0x8b, 0x79, 0x45, 0xe1, 0x90, + 0x82, 0x74, 0x2e, 0xae, 0xbe, 0x65, 0x99, 0x9c, 0x53, 0x2f, 0x43, 0xa1, 0x8b, 0x84, 0x51, 0x66, 0x93, 0x7a, 0x22, + 0x03, 0x48, 0xc6, 0x2a, 0x33, 0x94, 0x2b, 0xbc, 0x1e, 0x55, 0x72, 0x51, 0xf3, 0x6f, 0xcc, 0xca, 0xb8, 0x1c, 0x5b, + 0xd6, 0x0d, 0xeb, 0x0c, 0x8e, 0x57, 0xaa, 0x65, 0xf2, 0x4d, 0x51, 0x9c, 0x78, 0xf1, 0x19, 0x03, 0xf1, 0x7e, 0x56, + 0x6f, 0xb3, 0x9b, 0x43, 0x5c, 0xee, 0xda, 0xc2, 0x95, 0x49, 0xc5, 0x20, 0x96, 0x30, 0x11, 0xb4, 0x28, 0x8d, 0x3f, + 0x72, 0x30, 0xc5, 0x29, 0x40, 0x1b, 0x0b, 0x3f, 0x19, 0x49, 0x55, 0xe5, 0xb0, 0x5c, 0x46, 0x6f, 0xa5, 0xa8, 0xb1, + 0x59, 0x5e, 0x46, 0x9b, 0x79, 0x12, 0x10, 0xe0, 0xea, 0x4a, 0x59, 0xcd, 0xae, 0x4f, 0x1d, 0xb6, 0x67, 0x5c, 0x59, + 0xca, 0x09, 0x53, 0x34, 0x6b, 0x2c, 0x25, 0xc2, 0xb8, 0xcd, 0xc5, 0xb6, 0x38, 0x7e, 0x57, 0xf3, 0x97, 0xd2, 0x6f, + 0xe0, 0x2e, 0x77, 0x4d, 0x01, 0x6e, 0x91, 0x47, 0xf4, 0x8e, 0x5c, 0x06, 0x7c, 0x67, 0x54, 0x6f, 0xd0, 0x80, 0x2d, + 0x5a, 0x6e, 0xcd, 0xc7, 0xb2, 0x3c, 0xf4, 0x55, 0x74, 0xe1, 0x62, 0x11, 0xd1, 0xea, 0x50, 0xeb, 0xfd, 0xde, 0xfe, + 0xd3, 0x5e, 0xb5, 0xd3, 0x80, 0x0e, 0x28, 0x7d, 0xad, 0xd3, 0xdb, 0x2e, 0xff, 0xab, 0x1f, 0x6e, 0x8b, 0x44, 0x9f, + 0x97, 0xd4, 0x0d, 0x74, 0x08, 0x72, 0x07, 0x82, 0xad, 0x74, 0x3d, 0x67, 0x8e, 0x83, 0x5e, 0x58, 0x12, 0x6a, 0xe1, + 0x75, 0x79, 0x1b, 0x04, 0x0f, 0xa6, 0x94, 0xc4, 0x1a, 0x8f, 0xaa, 0x39, 0x0c, 0xe8, 0xc3, 0x2d, 0xd6, 0x6a, 0x62, + 0xfa, 0x13, 0xa2, 0xca, 0x44, 0x7a, 0x60, 0x7b, 0xd1, 0xc4, 0x84, 0x87, 0xfd, 0xa0, 0x24, 0x25, 0x54, 0x07, 0x82, + 0x36, 0x50, 0x26, 0xd6, 0xf1, 0x65, 0x87, 0x82, 0xe7, 0x42, 0x0b, 0x6c, 0x62, 0xb0, 0xef, 0xb8, 0x18, 0x12, 0x15, + 0x3b, 0xa4, 0xd4, 0x63, 0xa4, 0x76, 0x87, 0x2d, 0x62, 0x7f, 0x52, 0x0d, 0x94, 0xfe, 0x6e, 0xdc, 0xf7, 0xad, 0x15, + 0x40, 0xa9, 0x6b, 0x7e, 0xdc, 0xf7, 0x28, 0xf6, 0x60, 0x11, 0xbf, 0x0e, 0xc1, 0x99, 0x6c, 0xd7, 0x54, 0xc4, 0x9a, + 0xcf, 0x92, 0x3d, 0x37, 0x6c, 0xf8, 0xfb, 0x8a, 0x40, 0xc6, 0x48, 0xd3, 0xa1, 0x8c, 0xcd, 0xf8, 0x59, 0x46, 0x31, + 0x45, 0xd8, 0x17, 0x7e, 0x27, 0x09, 0x11, 0x22, 0x64, 0x0c, 0xd3, 0x1c, 0x41, 0x3b, 0xf3, 0x79, 0x52, 0x0b, 0x54, + 0xd7, 0x24, 0xf4, 0x3d, 0xdd, 0x1d, 0x88, 0x07, 0x39, 0x7a, 0x54, 0x02, 0xa0, 0xff, 0x5b, 0x3c, 0x7b, 0x72, 0xce, + 0x18, 0xc1, 0x5a, 0x71, 0x22, 0x8d, 0x2b, 0x70, 0x9c, 0xe3, 0x93, 0x16, 0x12, 0xc4, 0x4b, 0x75, 0x27, 0xa1, 0x4f, + 0xda, 0x38, 0x35, 0x78, 0x82, 0x5c, 0x14, 0x2b, 0x15, 0x80, 0xda, 0x2d, 0x78, 0xb3, 0x84, 0x19, 0x33, 0xa4, 0x47, + 0xde, 0x83, 0x35, 0x0f, 0x75, 0x29, 0x97, 0xc7, 0x9c, 0x9c, 0x21, 0x6a, 0x2e, 0xf2, 0xa4, 0xc6, 0x5c, 0x41, 0x5f, + 0x83, 0xe2, 0x14, 0xda, 0x18, 0x13, 0xab, 0xcd, 0x53, 0x9f, 0xaa, 0xa1, 0x28, 0x3d, 0x9b, 0xe5, 0xc5, 0x3a, 0xe2, + 0x12, 0xd8, 0x85, 0x66, 0xf4, 0xc1, 0xaf, 0x64, 0x92, 0xc3, 0x41, 0x9a, 0x27, 0x82, 0x8e, 0xf2, 0xc1, 0xd0, 0xc9, + 0x8c, 0xf6, 0x2e, 0x3d, 0x62, 0x47, 0x0f, 0x25, 0xa7, 0x2f, 0x50, 0x7a, 0x08, 0x01, 0xfa, 0xab, 0xe1, 0x4d, 0xdb, + 0x5f, 0xd1, 0x49, 0xf1, 0x62, 0xc2, 0x3b, 0x49, 0x14, 0xe1, 0x21, 0x9c, 0x11, 0x85, 0x8c, 0x44, 0xfb, 0x60, 0x30, + 0xf3, 0xce, 0xb6, 0x35, 0xe5, 0x7d, 0x51, 0xa7, 0x4e, 0x73, 0xf0, 0xf4, 0xbd, 0x78, 0x2d, 0x37, 0x0f, 0x02, 0x7a, + 0xec, 0xcb, 0x96, 0x90, 0x9d, 0x27, 0x03, 0x08, 0x90, 0x2f, 0x76, 0xc8, 0x98, 0x20, 0x0d, 0x6b, 0x5a, 0x92, 0x35, + 0xfd, 0x68, 0x11, 0xfa, 0xa7, 0xea, 0xe3, 0x34, 0xcb, 0x84, 0x50, 0x5b, 0x18, 0x03, 0x22, 0xf4, 0x94, 0x93, 0x82, + 0x15, 0xb9, 0x0f, 0x5e, 0x52, 0x38, 0x1c, 0xac, 0xd7, 0xc5, 0xf0, 0xa4, 0x39, 0x1b, 0x02, 0xdb, 0x31, 0x01, 0x9d, + 0x66, 0x48, 0x14, 0x62, 0xc3, 0x7d, 0x8c, 0x66, 0x92, 0x0a, 0xc6, 0x34, 0x51, 0xf9, 0xd0, 0x3f, 0xa8, 0x8d, 0xb8, + 0x49, 0x3d, 0x8a, 0x87, 0x11, 0xf6, 0x1c, 0x87, 0xae, 0x13, 0xcb, 0x80, 0xa8, 0xb2, 0xa4, 0xb2, 0xe6, 0x7a, 0xd4, + 0x34, 0x23, 0x83, 0x2a, 0x91, 0xfa, 0x45, 0x5b, 0x07, 0x97, 0x06, 0xd4, 0xb3, 0xf8, 0x66, 0xe0, 0xb9, 0x25, 0xb4, + 0xdc, 0x9f, 0x23, 0x89, 0x27, 0x83, 0x51, 0x8f, 0xe6, 0x08, 0x2f, 0xdd, 0x1d, 0x02, 0xe0, 0xad, 0xf2, 0x76, 0xd5, + 0xf3, 0xef, 0x28, 0x63, 0x27, 0x6e, 0xaa, 0xad, 0x52, 0x92, 0x5a, 0x83, 0x12, 0xf3, 0xef, 0xf2, 0xc7, 0x38, 0x77, + 0x15, 0x0b, 0xee, 0xbd, 0xa7, 0x6b, 0x85, 0xfa, 0xd3, 0x27, 0xb2, 0x93, 0xc2, 0x8d, 0xd3, 0x1b, 0x44, 0xe6, 0xe1, + 0x23, 0x6a, 0xc1, 0x5c, 0xe0, 0xee, 0xb8, 0xa8, 0x7b, 0xf3, 0x37, 0x84, 0x9b, 0xa2, 0xa6, 0xd0, 0x85, 0x92, 0x8d, + 0x16, 0x5f, 0xc9, 0xcc, 0x00, 0xcd, 0xe5, 0x4a, 0x2d, 0x3c, 0x67, 0x3d, 0x50, 0xfb, 0x15, 0x89, 0x5b, 0xeb, 0xf5, + 0xb5, 0x5b, 0xdb, 0x43, 0xb8, 0x9a, 0x2c, 0xa8, 0x63, 0x24, 0x79, 0xcc, 0x1c, 0x5a, 0x2b, 0x32, 0x5d, 0x93, 0x84, + 0xe6, 0x92, 0x5a, 0xaf, 0x2e, 0x1a, 0x7e, 0xfe, 0xda, 0x44, 0x10, 0x13, 0x46, 0x56, 0x2b, 0xe8, 0x1d, 0xb6, 0x9b, + 0x5f, 0x2c, 0x5c, 0x6d, 0x52, 0xa6, 0xc2, 0x21, 0x50, 0x9b, 0x2c, 0x3f, 0xc7, 0xd2, 0x53, 0x14, 0x44, 0xea, 0xb4, + 0xd5, 0x55, 0x42, 0x42, 0xb0, 0x52, 0xa9, 0x7f, 0x1d, 0x98, 0x90, 0x23, 0x2a, 0x47, 0x64, 0xf7, 0xba, 0x9c, 0xf3, + 0x53, 0x03, 0xd2, 0xdd, 0x88, 0x48, 0xc8, 0xe9, 0x8d, 0x01, 0x5d, 0x16, 0x1a, 0xfb, 0xdb, 0x80, 0x2b, 0x7c, 0x88, + 0xd0, 0xe9, 0xd8, 0x95, 0x72, 0x5d, 0x84, 0xfb, 0xbe, 0x40, 0x8a, 0xaa, 0x22, 0x82, 0x05, 0xd5, 0x8e, 0x6c, 0xce, + 0x8e, 0xfc, 0xc6, 0x1a, 0x1c, 0xce, 0xcd, 0xf1, 0xae, 0x51, 0x84, 0xd2, 0xc5, 0xce, 0xe3, 0x40, 0x4f, 0x94, 0x24, + 0x7c, 0x77, 0x8c, 0xd0, 0x5a, 0xeb, 0xfc, 0xac, 0xfb, 0x01, 0xcf, 0x92, 0x70, 0xfe, 0x81, 0x4d, 0xde, 0x97, 0xe4, + 0xbc, 0xbc, 0xda, 0xd4, 0x6d, 0xc1, 0x08, 0x40, 0x7d, 0xe3, 0x79, 0x5b, 0x79, 0x70, 0x83, 0x91, 0x41, 0x9e, 0xcc, + 0x09, 0xc6, 0x33, 0x57, 0x83, 0x79, 0x76, 0xec, 0x2c, 0xef, 0xb1, 0x10, 0xc8, 0x53, 0x4d, 0x6d, 0x5a, 0x2b, 0xb1, + 0x45, 0x3b, 0x66, 0xbf, 0x65, 0x03, 0x9c, 0x00, 0xa7, 0xc3, 0xf1, 0xd2, 0x36, 0xf8, 0x40, 0x2e, 0xe9, 0xad, 0x65, + 0x14, 0x64, 0x17, 0xfe, 0x6d, 0xa8, 0x8f, 0x28, 0xaf, 0x40, 0xa8, 0x48, 0xea, 0xd8, 0x28, 0x29, 0x45, 0xa9, 0x11, + 0x5a, 0x66, 0x5b, 0x90, 0x15, 0x67, 0x7b, 0xc4, 0xa3, 0x66, 0x86, 0x87, 0x22, 0xb7, 0x45, 0x3a, 0x6b, 0xb8, 0x2f, + 0x05, 0x2a, 0x36, 0x85, 0x34, 0xd3, 0x1a, 0xd8, 0xc6, 0x3d, 0x59, 0x53, 0x7b, 0xb7, 0x11, 0x35, 0x83, 0x47, 0xf4, + 0x2d, 0x4d, 0x4d, 0xdf, 0xaf, 0x8d, 0xb4, 0x52, 0x0c, 0x94, 0x39, 0xc4, 0x74, 0x4d, 0x8d, 0x99, 0x54, 0xa9, 0xc5, + 0x7e, 0xdd, 0xe6, 0xd3, 0x6f, 0x17, 0xca, 0x21, 0x39, 0x70, 0x42, 0xc9, 0x11, 0x43, 0x76, 0x86, 0x21, 0xb8, 0x95, + 0xb3, 0x89, 0x64, 0xb9, 0x11, 0xb9, 0xcc, 0x3a, 0xa3, 0x3b, 0xfe, 0xc1, 0x04, 0x50, 0xe8, 0x8b, 0x05, 0x0a, 0xfa, + 0xb1, 0xda, 0xfa, 0x44, 0x1d, 0x49, 0x25, 0x29, 0x3e, 0x5d, 0xb8, 0x8a, 0xca, 0xa1, 0xe6, 0xea, 0x55, 0x51, 0x81, + 0x5a, 0x13, 0x3a, 0x70, 0x3d, 0x42, 0x60, 0x03, 0x61, 0xf4, 0x47, 0x53, 0x08, 0xcb, 0x7d, 0x15, 0x37, 0xed, 0x26, + 0xef, 0x9e, 0xce, 0xf6, 0x18, 0xa9, 0x41, 0x16, 0x5a, 0x56, 0x1c, 0xc3, 0xe9, 0x01, 0x4f, 0x06, 0x8f, 0x1d, 0x33, + 0x6c, 0x36, 0x4e, 0x8f, 0x31, 0x06, 0x58, 0xb2, 0xc2, 0x62, 0x9b, 0x4a, 0x6b, 0x45, 0x84, 0xd4, 0x36, 0xab, 0x97, + 0x36, 0x77, 0x8a, 0xfc, 0xf6, 0x67, 0x00, 0x98, 0x57, 0x4d, 0xa6, 0x75, 0x14, 0x53, 0xc4, 0x28, 0x69, 0xb3, 0x38, + 0x5e, 0x88, 0x95, 0x17, 0x1f, 0x0b, 0xdc, 0x1f, 0xa1, 0x72, 0x65, 0xb9, 0xe0, 0xea, 0x4c, 0xee, 0x87, 0x9b, 0xef, + 0x33, 0x27, 0x11, 0x2f, 0x98, 0xe8, 0x33, 0x66, 0xc3, 0xd5, 0x85, 0x77, 0xa4, 0x4e, 0xb3, 0x98, 0xdc, 0xfb, 0xe2, + 0x2d, 0x9f, 0xe7, 0x2e, 0xa0, 0xb2, 0x07, 0xb1, 0xdb, 0xaa, 0x8c, 0xf5, 0x3a, 0x23, 0x83, 0x84, 0x6f, 0x29, 0xd9, + 0x2b, 0x19, 0x3b, 0xf1, 0x19, 0x64, 0x7a, 0xb0, 0x0c, 0x0b, 0x4f, 0x19, 0xc9, 0xed, 0x33, 0x55, 0xd4, 0xae, 0xa7, + 0x54, 0xae, 0x8b, 0xee, 0xbc, 0xe6, 0xde, 0x56, 0xb8, 0x53, 0x33, 0x93, 0x4e, 0xbc, 0x2e, 0x40, 0x9d, 0x0f, 0x2e, + 0x2d, 0xd2, 0x39, 0x2f, 0x60, 0xd1, 0x0c, 0x85, 0xeb, 0xa9, 0x1a, 0x7d, 0xb6, 0xdc, 0x47, 0x16, 0xc3, 0xa6, 0x3b, + 0xbf, 0x2c, 0x7b, 0x34, 0xf9, 0x64, 0x81, 0x40, 0xec, 0x29, 0x3c, 0xbe, 0xa4, 0xc1, 0xad, 0xc5, 0xcf, 0xb4, 0xd5, + 0x56, 0x06, 0xaa, 0x4d, 0x52, 0x0b, 0xfc, 0x64, 0x39, 0xe2, 0xe4, 0x70, 0x6a, 0x79, 0xd7, 0xc0, 0x97, 0xf8, 0x05, + 0xf4, 0x87, 0xb0, 0x2a, 0x52, 0x97, 0x88, 0x6f, 0x09, 0x65, 0xe5, 0x98, 0xfb, 0x0d, 0xc8, 0x7a, 0x98, 0x2d, 0x14, + 0xc7, 0x9b, 0x70, 0x44, 0xa2, 0xb4, 0xfd, 0xdc, 0x1f, 0x1f, 0xf4, 0x2b, 0x7a, 0x0c, 0x86, 0xe3, 0x40, 0x85, 0xc8, + 0x99, 0x12, 0x22, 0x0a, 0xa7, 0x25, 0x5c, 0x86, 0xc6, 0x3c, 0x14, 0x04, 0x64, 0xd4, 0xff, 0x81, 0x70, 0x70, 0x31, + 0x6f, 0x9d, 0xa0, 0x52, 0x55, 0x5a, 0x58, 0x2e, 0x7b, 0xb1, 0x1f, 0x40, 0x95, 0x87, 0x3c, 0x60, 0x7d, 0xde, 0x71, + 0x9d, 0x33, 0x0b, 0x1e, 0x08, 0x46, 0x40, 0x12, 0x33, 0x5b, 0x47, 0xb7, 0x7a, 0xfa, 0x8b, 0xbb, 0x4e, 0x40, 0x3f, + 0x6e, 0x18, 0x7f, 0x84, 0x53, 0x51, 0x5a, 0xc8, 0x5f, 0xb5, 0x24, 0x9b, 0x30, 0xba, 0x0d, 0x8d, 0x75, 0x88, 0xc4, + 0xc5, 0x25, 0x47, 0xcf, 0x79, 0x51, 0xa0, 0x1c, 0xba, 0xee, 0x00, 0x8f, 0x85, 0x77, 0x57, 0x14, 0x68, 0x2e, 0xdc, + 0x35, 0x7d, 0x21, 0x27, 0xd6, 0x3a, 0x3c, 0x62, 0xad, 0x6d, 0x1b, 0xa2, 0x07, 0xcb, 0x29, 0x9e, 0xa1, 0xa1, 0x5c, + 0x2b, 0xd5, 0x92, 0x6c, 0x52, 0xcf, 0x80, 0x8c, 0x95, 0x7a, 0x82, 0x26, 0x65, 0xde, 0x21, 0x9e, 0x3a, 0x18, 0x3b, + 0x74, 0x93, 0x41, 0xf4, 0x5f, 0x47, 0xe6, 0x44, 0xe5, 0x9e, 0xf4, 0x63, 0xdb, 0xa8, 0xe0, 0x00, 0xe8, 0x68, 0x79, + 0xbf, 0xec, 0xbe, 0x77, 0xab, 0xb3, 0x14, 0x6d, 0x78, 0x55, 0x91, 0x84, 0x5a, 0x47, 0xfb, 0xbc, 0x86, 0xe7, 0xdb, + 0x11, 0x61, 0x44, 0xb7, 0x07, 0x66, 0x85, 0xb3, 0x6d, 0x52, 0x8c, 0x5d, 0xb5, 0xe0, 0x84, 0x79, 0x08, 0x88, 0x77, + 0x3d, 0xa9, 0x0e, 0x2b, 0x0d, 0xd1, 0x79, 0x1e, 0x5e, 0x2e, 0xae, 0x58, 0x98, 0xaa, 0x5e, 0x0a, 0x62, 0xbf, 0xf9, + 0xe0, 0x03, 0xf7, 0x79, 0x86, 0x61, 0xe3, 0xcd, 0x28, 0x4f, 0x8f, 0x31, 0x3b, 0x3f, 0xc4, 0x6e, 0xea, 0x48, 0x5f, + 0x71, 0x01, 0x7a, 0xbd, 0x27, 0xa7, 0xef, 0xd0, 0xf7, 0xa2, 0xe3, 0x8c, 0x2f, 0x0c, 0xd7, 0x8e, 0xf3, 0x05, 0x71, + 0x4a, 0x84, 0x28, 0xf5, 0x78, 0x51, 0x8f, 0x59, 0x22, 0x5e, 0x05, 0xc1, 0xb6, 0xe5, 0xcd, 0xf8, 0xef, 0xc2, 0x49, + 0xca, 0x77, 0xc7, 0x90, 0xc0, 0xe3, 0xc1, 0x9f, 0xa3, 0xe3, 0x02, 0x67, 0x22, 0x72, 0x18, 0x87, 0x3b, 0xb7, 0x55, + 0x4a, 0xef, 0xdb, 0xb0, 0x66, 0xea, 0xf5, 0xe7, 0x05, 0x21, 0xe3, 0x86, 0x1c, 0xb8, 0x83, 0x22, 0x9e, 0x96, 0xc0, + 0x5c, 0x9b, 0x42, 0x88, 0x7a, 0xfc, 0x37, 0xdc, 0x3c, 0x45, 0xf8, 0xa8, 0x11, 0x85, 0x89, 0xa6, 0xa6, 0xe4, 0xae, + 0xd8, 0x00, 0xac, 0xc4, 0x09, 0xed, 0x20, 0xf5, 0x43, 0x59, 0x79, 0x85, 0x81, 0xd5, 0xa2, 0xae, 0x04, 0x6a, 0x59, + 0x20, 0x8f, 0x0c, 0x4e, 0xec, 0xbd, 0x08, 0x8b, 0xae, 0x61, 0x14, 0xf4, 0x60, 0xaa, 0xb6, 0x5e, 0xc2, 0xeb, 0x6e, + 0x0b, 0xcb, 0x0f, 0xef, 0x57, 0x53, 0xcb, 0x5d, 0x95, 0x3f, 0x1e, 0x20, 0x67, 0xc9, 0xe9, 0x03, 0x80, 0x15, 0x0f, + 0x53, 0xc0, 0x56, 0xef, 0xcd, 0x61, 0x6b, 0x77, 0x89, 0xc6, 0x6d, 0xe6, 0x4f, 0x77, 0x48, 0x30, 0x4a, 0xfa, 0xd9, + 0xe7, 0x3f, 0xcf, 0x60, 0x71, 0xf4, 0x06, 0xc0, 0x43, 0xac, 0x3b, 0x59, 0xd5, 0xad, 0xec, 0x1e, 0xff, 0xf4, 0xa1, + 0x29, 0x12, 0xe9, 0x89, 0x69, 0xfc, 0xe2, 0xa8, 0x26, 0x7b, 0xab, 0x1d, 0x23, 0x67, 0x77, 0x24, 0xce, 0x4a, 0x09, + 0xc9, 0xe5, 0x88, 0x4a, 0x74, 0xdb, 0x23, 0x8a, 0xe0, 0xb5, 0x77, 0x96, 0x61, 0xa3, 0x5f, 0xc3, 0x08, 0x05, 0xa0, + 0x26, 0x60, 0xf8, 0xa0, 0xb2, 0xde, 0x09, 0x00, 0x8c, 0xd2, 0xaa, 0xa9, 0x53, 0x46, 0x17, 0xbb, 0xe9, 0xf2, 0xe2, + 0x41, 0xa6, 0xb4, 0x13, 0x35, 0x93, 0xdb, 0x13, 0x2a, 0x5b, 0x2d, 0x8c, 0x6d, 0xbf, 0x64, 0xc4, 0xa7, 0x81, 0x44, + 0x2b, 0x2c, 0x30, 0xa3, 0x83, 0x65, 0x29, 0xcb, 0x51, 0x22, 0xb1, 0x4c, 0x90, 0x5d, 0x79, 0x33, 0x8c, 0xbc, 0x0d, + 0xac, 0xc8, 0x8c, 0x48, 0x24, 0x5b, 0xd4, 0x74, 0x44, 0x0c, 0x8f, 0xda, 0x31, 0xab, 0xba, 0xb4, 0xb1, 0x62, 0xe1, + 0xe9, 0xe6, 0xd0, 0x93, 0x2b, 0xa4, 0xe8, 0x72, 0x1f, 0xa4, 0x50, 0x4c, 0x17, 0x6d, 0x5c, 0x9d, 0xdb, 0xec, 0x8b, + 0x28, 0xf3, 0x15, 0x99, 0x17, 0xb1, 0x98, 0xdd, 0x3f, 0xd9, 0xd8, 0x61, 0xb2, 0x3c, 0xce, 0xc9, 0x64, 0xe6, 0x40, + 0x35, 0x6d, 0xc8, 0xb5, 0xe4, 0xb5, 0x64, 0xc5, 0x49, 0x5c, 0xfc, 0xbb, 0xbc, 0x6c, 0xf3, 0x64, 0xaa, 0x10, 0x41, + 0x0f, 0xb3, 0x64, 0x81, 0x59, 0xaa, 0xa5, 0x83, 0x12, 0xce, 0x22, 0xb2, 0xa3, 0x81, 0xe9, 0x4d, 0x49, 0x9b, 0x7c, + 0xd0, 0x49, 0x77, 0x27, 0x6f, 0x0d, 0x09, 0xd7, 0x6b, 0x9c, 0xd8, 0x16, 0x73, 0x31, 0xe2, 0xa9, 0xef, 0xca, 0x24, + 0x5a, 0x91, 0x78, 0x90, 0x25, 0x31, 0x57, 0x9e, 0x8d, 0x45, 0x89, 0x2f, 0x72, 0x7a, 0x5a, 0x2f, 0x66, 0xa3, 0x45, + 0x1a, 0xfb, 0xc3, 0xc8, 0x2f, 0x8b, 0x9f, 0xdd, 0x8e, 0x1c, 0xf5, 0xf6, 0x84, 0xf2, 0xac, 0xa6, 0xb6, 0xae, 0x99, + 0x39, 0x66, 0x94, 0x69, 0xa4, 0x10, 0x4b, 0x48, 0x9f, 0x8c, 0x08, 0x5a, 0x9c, 0x0e, 0x6c, 0xd8, 0xfc, 0x4e, 0x05, + 0x9e, 0xa9, 0xdd, 0x5e, 0x0d, 0x0d, 0xcf, 0x2b, 0x24, 0x82, 0x0b, 0x1a, 0x6f, 0x70, 0xd4, 0x0c, 0xf5, 0x7f, 0x78, + 0x3a, 0x6f, 0xcd, 0x74, 0xf6, 0x44, 0x32, 0xb2, 0xb4, 0xf0, 0x0c, 0x70, 0x3e, 0xa9, 0x4a, 0x73, 0x7b, 0x3f, 0xc8, + 0x23, 0xeb, 0xfe, 0x49, 0x54, 0xbf, 0x22, 0xb0, 0x3b, 0x49, 0x4c, 0x08, 0xd0, 0xf0, 0xba, 0x9e, 0x0d, 0x13, 0x09, + 0xad, 0x04, 0xef, 0xbb, 0x0a, 0xfe, 0x4e, 0xca, 0x24, 0x5d, 0x9a, 0xd0, 0xe4, 0xa2, 0x5c, 0x0d, 0x76, 0xb2, 0x40, + 0xbe, 0x05, 0xd8, 0x40, 0x10, 0x08, 0xac, 0x30, 0xef, 0x98, 0x4a, 0x68, 0x07, 0xd2, 0x40, 0xe6, 0x04, 0x98, 0x64, + 0xe3, 0x5c, 0x19, 0x14, 0xd5, 0x46, 0x3e, 0xad, 0x72, 0x36, 0x24, 0x1a, 0x06, 0x99, 0xf5, 0xc7, 0xd0, 0xd9, 0x2b, + 0x26, 0xc9, 0xbc, 0xbf, 0x73, 0x34, 0x9e, 0x6c, 0xcf, 0x90, 0x28, 0xe4, 0x6a, 0x9f, 0x41, 0x3c, 0xa1, 0x19, 0x2e, + 0x2b, 0x51, 0x5f, 0xd6, 0xb5, 0xfa, 0x4f, 0xaa, 0xf7, 0x1d, 0x3c, 0x39, 0x90, 0x45, 0x6f, 0xe3, 0xc0, 0x72, 0xcb, + 0x16, 0x01, 0xe6, 0xf9, 0x1a, 0x68, 0x46, 0x09, 0x20, 0x43, 0x13, 0x60, 0xae, 0x31, 0x7b, 0x69, 0x68, 0x46, 0x28, + 0xfb, 0x22, 0xd7, 0x26, 0xa1, 0xe8, 0x61, 0xee, 0xcb, 0x2b, 0x71, 0x9b, 0xeb, 0x1d, 0xd2, 0xeb, 0xb6, 0x7a, 0x8f, + 0x5d, 0x8e, 0xc8, 0x72, 0x8a, 0xb8, 0x4d, 0xa8, 0x1e, 0xa0, 0x90, 0x55, 0x13, 0xa6, 0x75, 0xb0, 0x3b, 0xe3, 0x2f, + 0x49, 0x88, 0x30, 0x21, 0x31, 0xaa, 0x8f, 0xd0, 0xb1, 0x1a, 0xfb, 0x44, 0x8f, 0x25, 0x47, 0xa2, 0x37, 0x48, 0x1d, + 0xb9, 0x14, 0x3a, 0x8f, 0x0b, 0x75, 0x6d, 0xad, 0xb6, 0xe0, 0x12, 0x61, 0xc0, 0x89, 0x55, 0x0e, 0x87, 0xcb, 0xa9, + 0x50, 0xd9, 0x12, 0xf7, 0x36, 0x66, 0xd4, 0xcb, 0x9d, 0xb7, 0x59, 0x97, 0x7a, 0x6f, 0x12, 0x16, 0x91, 0xe5, 0xa1, + 0x62, 0x1c, 0x08, 0x05, 0x6b, 0xfb, 0x60, 0x79, 0x8d, 0x33, 0xf2, 0x2c, 0xc3, 0x66, 0x30, 0x7a, 0x1f, 0xa0, 0xac, + 0xa8, 0xc7, 0xe1, 0x02, 0x10, 0xeb, 0x43, 0xf2, 0xa2, 0xc9, 0x0c, 0x01, 0x16, 0xd9, 0xe2, 0x52, 0x93, 0x2c, 0x14, + 0x3a, 0xea, 0xaf, 0x7b, 0x40, 0xbb, 0x16, 0x12, 0x03, 0xe9, 0xf0, 0xa8, 0x93, 0xae, 0x66, 0x89, 0x65, 0x73, 0x0c, + 0x0d, 0x85, 0xc5, 0x69, 0x9e, 0xa7, 0x23, 0xdb, 0xda, 0x3b, 0xc0, 0x89, 0x8e, 0xae, 0x17, 0xe0, 0xb6, 0x83, 0x4b, + 0x21, 0xc7, 0x11, 0xdc, 0x34, 0x47, 0x79, 0x76, 0x2a, 0x6d, 0x0a, 0x46, 0x13, 0x37, 0x2b, 0xcd, 0x85, 0x2e, 0xa7, + 0xf0, 0x3c, 0xdd, 0xfa, 0x13, 0x15, 0xfd, 0xd3, 0x52, 0x3b, 0x83, 0x41, 0x95, 0xd3, 0xae, 0x94, 0xb1, 0xa4, 0x5d, + 0x73, 0xf4, 0x85, 0x40, 0x1e, 0x16, 0xfa, 0x7e, 0xa1, 0x71, 0xe7, 0xd4, 0x41, 0xf1, 0x8e, 0x71, 0x66, 0xa7, 0x07, + 0x0d, 0x7b, 0xa5, 0xf1, 0x68, 0x44, 0x29, 0x2b, 0xf5, 0x03, 0xe3, 0x5a, 0xde, 0x9e, 0x10, 0x6d, 0x32, 0x0a, 0x77, + 0x28, 0xcb, 0xe4, 0xdb, 0x1e, 0x07, 0x9a, 0xb6, 0x67, 0xdc, 0x76, 0x5b, 0xdf, 0xae, 0x93, 0x5b, 0x44, 0xe2, 0xf6, + 0x17, 0x5c, 0xc2, 0x33, 0xf8, 0xc6, 0x90, 0x8a, 0x3d, 0xeb, 0xc4, 0xe5, 0xcb, 0x28, 0xcb, 0xf9, 0x0a, 0x47, 0x57, + 0x4c, 0xc6, 0xc2, 0x0b, 0x2d, 0x22, 0xdc, 0x34, 0x50, 0xc7, 0x95, 0x24, 0xb1, 0x9b, 0x92, 0xf8, 0xb9, 0xe5, 0x9f, + 0xb7, 0xe6, 0x46, 0xc0, 0x54, 0x24, 0xd7, 0x21, 0xfa, 0xcc, 0xa9, 0x5a, 0xdd, 0x6b, 0x95, 0x05, 0xf5, 0x98, 0xa7, + 0x72, 0xc4, 0x9c, 0xba, 0xdd, 0x14, 0x59, 0x26, 0x3d, 0x6c, 0xae, 0x29, 0x4a, 0x14, 0x68, 0xab, 0x0b, 0xbd, 0xcc, + 0x9c, 0xb3, 0xd0, 0xd1, 0x89, 0x94, 0x6d, 0x8d, 0x66, 0x13, 0x73, 0x1c, 0xce, 0x7e, 0x12, 0xd9, 0x13, 0x5c, 0xf5, + 0x9e, 0xb7, 0xf6, 0x61, 0xb3, 0xf1, 0x75, 0xa8, 0xd5, 0x90, 0x1d, 0x10, 0x68, 0xe6, 0xce, 0x14, 0x28, 0xc2, 0xfe, + 0x2b, 0x3b, 0x12, 0xa5, 0x2c, 0xff, 0xd8, 0x69, 0x5d, 0xdf, 0x36, 0xaa, 0x8e, 0xc9, 0x5f, 0xd3, 0xbe, 0x86, 0xab, + 0x0e, 0x8a, 0x9c, 0xc3, 0xf1, 0x49, 0xbb, 0x33, 0xdd, 0x3c, 0x10, 0x9e, 0xb3, 0xc3, 0xa8, 0x2c, 0x67, 0x57, 0xd4, + 0x1b, 0xba, 0x0a, 0x18, 0xa8, 0x51, 0x32, 0x29, 0x7b, 0xa3, 0xb0, 0x8e, 0xfa, 0x9d, 0xb8, 0xd6, 0x57, 0x14, 0xdd, + 0xb2, 0xc6, 0xad, 0x4d, 0x76, 0xe0, 0x8f, 0x18, 0x2b, 0x77, 0x98, 0x21, 0x3f, 0x5c, 0x63, 0xd5, 0x22, 0xf5, 0x46, + 0xe3, 0x62, 0xdb, 0x6a, 0x3a, 0xd3, 0x40, 0xb7, 0xad, 0x99, 0x1b, 0x61, 0x07, 0xd5, 0x70, 0x5b, 0xb7, 0x95, 0xaa, + 0xb6, 0x9d, 0xc7, 0xaf, 0xf6, 0xd5, 0x89, 0x98, 0xd0, 0x86, 0xa1, 0xaf, 0x81, 0xe9, 0x5a, 0x54, 0x73, 0x31, 0xb0, + 0xa9, 0x5e, 0x2d, 0xf6, 0x5d, 0xc8, 0xee, 0xdd, 0x5f, 0x43, 0x12, 0xaa, 0xe2, 0xca, 0x2d, 0x2f, 0xb7, 0x9f, 0x74, + 0xb2, 0x4a, 0x65, 0x6a, 0x1f, 0xf9, 0x1d, 0x66, 0xca, 0x87, 0x99, 0xe2, 0x71, 0xa5, 0x63, 0x2d, 0x20, 0x0a, 0x43, + 0xe1, 0x55, 0x0a, 0x74, 0x6b, 0x16, 0xf1, 0x0f, 0x74, 0xec, 0xca, 0x98, 0x11, 0x32, 0x1a, 0x95, 0x33, 0x74, 0x43, + 0x42, 0x35, 0x34, 0xb1, 0x9c, 0xa4, 0x4b, 0x0d, 0xba, 0xda, 0xe1, 0x3a, 0xb2, 0x3c, 0x10, 0x02, 0x71, 0x22, 0x87, + 0x39, 0x53, 0x23, 0xda, 0xfd, 0x24, 0x30, 0x91, 0x66, 0x5d, 0xb5, 0x5f, 0x74, 0x38, 0xdd, 0x50, 0x7b, 0x4f, 0xbf, + 0x7c, 0x68, 0xb4, 0xa7, 0x5f, 0xae, 0xb4, 0x3e, 0x39, 0x31, 0xe5, 0xd4, 0x4a, 0xc7, 0x0d, 0x8c, 0xc3, 0x45, 0xe9, + 0xc0, 0xf7, 0x48, 0x35, 0xb8, 0x31, 0xdc, 0x8d, 0x4e, 0xe0, 0x8c, 0xdc, 0x36, 0x22, 0x2b, 0x37, 0x81, 0x99, 0x81, + 0x94, 0xd2, 0x8b, 0x63, 0xe0, 0xbe, 0xed, 0xfd, 0x28, 0xc9, 0x78, 0xd3, 0x64, 0xfc, 0x7a, 0x99, 0x15, 0x4a, 0xdf, + 0x33, 0xb3, 0xd0, 0x55, 0xfc, 0xce, 0x24, 0x77, 0xb5, 0xc6, 0x4e, 0xaa, 0xe5, 0x0c, 0x18, 0xe5, 0x6a, 0x85, 0xe5, + 0x8e, 0xf7, 0xe4, 0xb0, 0xb9, 0x9f, 0x65, 0x09, 0x69, 0xb2, 0x15, 0x55, 0x89, 0x31, 0x22, 0x85, 0xf6, 0x17, 0x67, + 0xe7, 0xfe, 0x68, 0xf1, 0x01, 0x1d, 0xf5, 0x1d, 0x33, 0xae, 0xc6, 0xad, 0xd8, 0x2e, 0x56, 0xec, 0x60, 0x1a, 0xae, + 0x0d, 0xa6, 0x79, 0x80, 0xd0, 0x3d, 0x73, 0x07, 0xf5, 0x0b, 0xfc, 0x8f, 0x7c, 0x5c, 0x55, 0x48, 0x87, 0x2e, 0x9b, + 0xa9, 0x28, 0x5f, 0xa2, 0x06, 0x05, 0x2c, 0x5a, 0xb7, 0x4b, 0x13, 0x30, 0x45, 0x16, 0xd2, 0x2d, 0xa4, 0x20, 0x4a, + 0x16, 0x82, 0x19, 0x54, 0x7c, 0xe5, 0x2f, 0x13, 0x5f, 0xeb, 0xab, 0x85, 0x5e, 0xd2, 0x13, 0xb6, 0x0a, 0xb9, 0xba, + 0x61, 0xb4, 0x98, 0x55, 0xa7, 0x1d, 0xa7, 0x89, 0x43, 0x83, 0x1a, 0x75, 0x44, 0xe8, 0x3a, 0x3e, 0xf8, 0x6c, 0x13, + 0x79, 0x83, 0xc9, 0x4f, 0x4e, 0x02, 0xfe, 0x5e, 0x9f, 0xbc, 0xc5, 0xd9, 0x43, 0xac, 0x4a, 0x33, 0x1e, 0x2f, 0x94, + 0x3d, 0x2a, 0x7b, 0x41, 0xad, 0xb1, 0x9f, 0x5d, 0x98, 0xd6, 0x46, 0x25, 0x85, 0xdc, 0x79, 0xb8, 0x90, 0xef, 0x9c, + 0xc2, 0xb9, 0x1b, 0x95, 0x88, 0xf2, 0x00, 0x66, 0xc2, 0xe6, 0xc4, 0x8d, 0x8a, 0x5b, 0x40, 0xe5, 0x4c, 0x4f, 0x9a, + 0xc4, 0x74, 0x56, 0x22, 0xc6, 0x8c, 0x4e, 0xe1, 0x7a, 0x1c, 0xa2, 0x31, 0x34, 0xc3, 0x9c, 0xde, 0xc7, 0xe8, 0x09, + 0x72, 0x80, 0xb3, 0x76, 0xad, 0x21, 0xc4, 0x4c, 0x2a, 0x7c, 0xef, 0x56, 0xc4, 0x96, 0xd9, 0x17, 0x82, 0xda, 0x36, + 0xef, 0xbb, 0x11, 0x51, 0x5e, 0x29, 0x7c, 0x9f, 0xfb, 0xcb, 0x2f, 0x18, 0xaf, 0x64, 0x68, 0x0d, 0xcf, 0x92, 0x9f, + 0xc3, 0xfc, 0xec, 0x37, 0x76, 0x60, 0x02, 0x12, 0xa7, 0x15, 0x8d, 0x7a, 0x4a, 0x96, 0xe6, 0x3a, 0xeb, 0x7d, 0x13, + 0xce, 0x28, 0x99, 0x06, 0x4c, 0xac, 0x65, 0x16, 0x40, 0x27, 0x52, 0x09, 0x9c, 0x25, 0x95, 0x75, 0x34, 0x93, 0x47, + 0x0b, 0xbd, 0x37, 0xf1, 0xf4, 0x45, 0x49, 0x7a, 0x05, 0xfe, 0xd8, 0x52, 0x63, 0x51, 0xa6, 0x6d, 0x5e, 0x04, 0xaa, + 0x66, 0x2d, 0x8f, 0x83, 0x5c, 0x7a, 0xbd, 0xac, 0x7a, 0xe5, 0x69, 0x2d, 0xd5, 0x05, 0xda, 0x4e, 0xc8, 0x31, 0x6a, + 0x51, 0x5e, 0x41, 0x1a, 0x8a, 0xf6, 0x40, 0xe9, 0x6b, 0x98, 0xd0, 0x03, 0x7e, 0xa9, 0x06, 0x65, 0x34, 0x78, 0x67, + 0xcd, 0x16, 0x17, 0x93, 0x23, 0x67, 0xcd, 0x00, 0x02, 0x6e, 0xd7, 0xdb, 0x52, 0x13, 0x21, 0x15, 0x6e, 0x30, 0x4c, + 0x8b, 0x44, 0xfd, 0x44, 0x73, 0x58, 0xbb, 0x42, 0x52, 0x87, 0x58, 0x87, 0x16, 0x26, 0xa0, 0x35, 0xe3, 0x62, 0x43, + 0x8b, 0xb2, 0x13, 0x39, 0xb0, 0x36, 0x8b, 0x24, 0xe3, 0xb0, 0x47, 0x33, 0x6d, 0x06, 0x72, 0x2d, 0xc1, 0x65, 0x89, + 0xe8, 0x2d, 0x8a, 0xee, 0x9e, 0xc8, 0xb0, 0xb9, 0xc9, 0x4a, 0xa6, 0xcc, 0xf4, 0x68, 0x08, 0xb4, 0x6b, 0x0f, 0x06, + 0xdb, 0xa1, 0x82, 0xbf, 0x84, 0x77, 0x49, 0xd2, 0xfd, 0x3e, 0x7b, 0xdc, 0x81, 0x0f, 0xe1, 0xd4, 0x69, 0xbf, 0x09, + 0xb0, 0xce, 0x81, 0x53, 0xac, 0x13, 0x63, 0x9c, 0x71, 0x54, 0xef, 0x66, 0xb4, 0xb1, 0x9f, 0x10, 0x43, 0xa0, 0x70, + 0xf8, 0xb6, 0x47, 0x2b, 0xaf, 0xda, 0xb1, 0x36, 0xd3, 0x4b, 0xda, 0x91, 0x8f, 0xc8, 0x11, 0x4c, 0x82, 0x48, 0x5a, + 0x26, 0x10, 0x9a, 0x31, 0x78, 0x0b, 0x57, 0xb0, 0x36, 0x67, 0x40, 0x4b, 0x5d, 0x2f, 0x14, 0x5a, 0xe0, 0xe9, 0x19, + 0x03, 0x93, 0xc2, 0xbc, 0x83, 0x4b, 0xda, 0x7f, 0x34, 0xc2, 0xac, 0xa1, 0x5a, 0xad, 0xed, 0x36, 0x2d, 0x1f, 0x12, + 0x05, 0xc2, 0xf6, 0x53, 0xbd, 0xe9, 0x7e, 0xe4, 0x67, 0xd7, 0x02, 0xd4, 0x55, 0x6c, 0xbb, 0xc6, 0x8b, 0x7a, 0xef, + 0x6d, 0x6b, 0xf4, 0xb1, 0xbf, 0xd2, 0xf0, 0x2d, 0xc4, 0xb0, 0x2c, 0x99, 0x30, 0x5d, 0x99, 0x0f, 0x7e, 0xce, 0x14, + 0xf7, 0x79, 0x1a, 0x93, 0xee, 0x0e, 0x25, 0x26, 0xf1, 0x75, 0x67, 0x77, 0xd8, 0xb6, 0x8c, 0xe8, 0x65, 0xfd, 0x56, + 0xaf, 0xb0, 0xd3, 0xe7, 0xdf, 0x41, 0x4c, 0xbd, 0xa2, 0x64, 0x3c, 0x4c, 0xb4, 0xc5, 0x43, 0x50, 0x18, 0xbf, 0xca, + 0x9c, 0x0c, 0x3e, 0xb9, 0xb7, 0x2d, 0x24, 0xc2, 0x6f, 0xe3, 0x55, 0x9c, 0xcc, 0x5a, 0x34, 0x9c, 0x76, 0x3d, 0x29, + 0x0e, 0x8c, 0x84, 0xd6, 0xcc, 0xb7, 0x49, 0x5a, 0x73, 0x29, 0x0c, 0xbf, 0x58, 0x88, 0x8d, 0x66, 0xe3, 0x28, 0x5a, + 0x0a, 0xa0, 0xa5, 0x3d, 0x72, 0xc9, 0x62, 0xe0, 0x61, 0xc1, 0x43, 0xf9, 0xd2, 0x12, 0x96, 0x3d, 0x7f, 0x9d, 0x4e, + 0xe4, 0x9b, 0x9b, 0x9c, 0x6e, 0xb7, 0x73, 0x75, 0xf9, 0xfc, 0x4b, 0x1a, 0x51, 0x56, 0xbf, 0xe8, 0x91, 0x44, 0x35, + 0xd6, 0xc7, 0xd6, 0xf3, 0x2f, 0xb9, 0x57, 0x27, 0x92, 0xd3, 0xce, 0x76, 0xc0, 0x70, 0x4d, 0x01, 0x5b, 0xa6, 0xed, + 0x61, 0x53, 0xf6, 0xf7, 0x5b, 0x17, 0x07, 0x75, 0x41, 0xe2, 0x13, 0xe6, 0x14, 0x49, 0x8a, 0xc7, 0x06, 0x1d, 0x08, + 0xb5, 0x0c, 0xa8, 0x47, 0xb0, 0x2f, 0x27, 0x76, 0xe4, 0x9b, 0xa7, 0xd1, 0x2f, 0xca, 0x74, 0xe8, 0x90, 0xa6, 0x43, + 0x1e, 0x02, 0x1b, 0xb7, 0xb9, 0xcb, 0x81, 0x22, 0x71, 0xa0, 0x22, 0x66, 0xda, 0x2f, 0x52, 0x7b, 0x39, 0x2f, 0xc2, + 0x9c, 0xa3, 0xea, 0xca, 0xe9, 0x53, 0x62, 0xdf, 0x85, 0x18, 0x7d, 0x88, 0x5b, 0x79, 0x67, 0x87, 0xbd, 0x91, 0x7e, + 0x88, 0x73, 0xf3, 0x25, 0x0e, 0x8c, 0xa8, 0xd2, 0x1c, 0xcd, 0x42, 0xa4, 0x14, 0xb9, 0xa6, 0x95, 0x7d, 0x47, 0x91, + 0xe9, 0x7a, 0x16, 0x7d, 0x79, 0x96, 0xc8, 0xec, 0x89, 0x30, 0x99, 0x43, 0xbd, 0x83, 0x97, 0x94, 0x68, 0xd6, 0xb6, + 0x5b, 0x07, 0x04, 0x76, 0x02, 0xe6, 0x69, 0x89, 0xbc, 0x4e, 0xc9, 0xc9, 0x7f, 0x7c, 0xfb, 0x2f, 0x2a, 0x79, 0x04, + 0x0f, 0x35, 0x75, 0x61, 0x19, 0x2d, 0x44, 0x1c, 0xc7, 0xf9, 0xdd, 0xba, 0x4e, 0x40, 0x8c, 0xf5, 0xe7, 0x67, 0x6b, + 0xcc, 0xd6, 0x41, 0xad, 0xa4, 0xa1, 0x48, 0xcc, 0xcd, 0x8e, 0x99, 0x95, 0xc9, 0x95, 0x71, 0xc5, 0x6e, 0x83, 0x7e, + 0x12, 0x59, 0x28, 0xd1, 0x8c, 0xe2, 0xe1, 0x14, 0x8b, 0xa4, 0xa4, 0x15, 0x16, 0xb5, 0xe4, 0x33, 0x43, 0x39, 0x4c, + 0x96, 0xa5, 0x6d, 0x67, 0x2e, 0x85, 0x64, 0x2d, 0x4b, 0x80, 0xec, 0x62, 0x89, 0x9a, 0xf3, 0x8a, 0x5c, 0x86, 0x15, + 0x91, 0x13, 0xc0, 0x38, 0x30, 0x85, 0x9f, 0xfc, 0x49, 0x68, 0x7f, 0x27, 0x0f, 0x3e, 0x85, 0xf0, 0x32, 0x4e, 0xd0, + 0x83, 0x71, 0x2b, 0x98, 0xc1, 0xc1, 0x10, 0xbd, 0x50, 0xc2, 0xba, 0xdc, 0x89, 0x17, 0x24, 0xcb, 0x52, 0x37, 0x40, + 0x68, 0xd6, 0xcd, 0x5a, 0xdd, 0xb7, 0xb0, 0x2a, 0x59, 0x42, 0x68, 0xc4, 0x4a, 0x2b, 0xb6, 0x62, 0x9b, 0x82, 0x8e, + 0x28, 0xc9, 0x09, 0x60, 0x66, 0x00, 0xce, 0x4e, 0x22, 0x2a, 0x35, 0xb0, 0x8e, 0x61, 0xc5, 0x62, 0xa6, 0x31, 0x29, + 0x80, 0xd5, 0xae, 0xf1, 0x51, 0x36, 0x4d, 0x17, 0x28, 0x54, 0x5f, 0x3b, 0x27, 0xe8, 0xa3, 0x4b, 0x2b, 0xf5, 0xd8, + 0x27, 0x60, 0xff, 0xe3, 0x0e, 0xea, 0x60, 0xd1, 0xa8, 0xfb, 0xd6, 0xbf, 0xc4, 0x90, 0xe7, 0x35, 0x62, 0xdc, 0xdc, + 0x1f, 0x38, 0xd5, 0x01, 0x9b, 0x64, 0x35, 0x1b, 0x49, 0x9c, 0x04, 0x3d, 0x87, 0xea, 0x4d, 0x28, 0xc1, 0x50, 0x5d, + 0xba, 0xca, 0x9e, 0x47, 0x46, 0xbc, 0x35, 0x96, 0x95, 0x2c, 0xf9, 0x19, 0xd0, 0x05, 0xe5, 0x29, 0x21, 0x38, 0xdb, + 0xce, 0x4a, 0xa2, 0x30, 0xd6, 0xa2, 0x38, 0xc6, 0x09, 0xbf, 0x23, 0x59, 0x19, 0x97, 0x4c, 0x51, 0x98, 0xf2, 0x39, + 0x38, 0x57, 0xe6, 0xc3, 0xdf, 0x9e, 0xfc, 0xf2, 0x9c, 0xae, 0x2e, 0x45, 0xec, 0xf3, 0xe3, 0x9c, 0x5e, 0x7f, 0x9b, + 0xfe, 0x25, 0xf3, 0x59, 0xf8, 0x27, 0xbc, 0xb3, 0x84, 0x9c, 0x77, 0x3f, 0x3e, 0x15, 0x2d, 0x0e, 0x8a, 0x85, 0xae, + 0x62, 0x8b, 0x5a, 0x70, 0xfe, 0xfc, 0xca, 0x66, 0xaa, 0x3c, 0x26, 0x68, 0xa6, 0x92, 0xb2, 0xfa, 0x4d, 0x91, 0x02, + 0x69, 0x1b, 0x95, 0x84, 0x8d, 0xff, 0x31, 0x05, 0xc5, 0xff, 0x47, 0x19, 0x0a, 0x0d, 0x59, 0xfb, 0xeb, 0x2d, 0x93, + 0xfc, 0x0a, 0x9e, 0xff, 0x31, 0x29, 0x50, 0xab, 0x9f, 0x08, 0x50, 0x49, 0x5b, 0x49, 0xa5, 0x0f, 0x0e, 0x3c, 0xd6, + 0xd1, 0xe4, 0x8c, 0x69, 0x18, 0xcf, 0x3c, 0x61, 0x3f, 0x03, 0x86, 0xb6, 0x59, 0x97, 0xbc, 0xdb, 0x36, 0xf1, 0x1f, + 0x28, 0xbc, 0x29, 0x53, 0x1b, 0x8d, 0x41, 0x72, 0xaa, 0x00, 0x69, 0x8e, 0xb3, 0x55, 0xe8, 0x8a, 0x36, 0x9c, 0x73, + 0xb3, 0xa5, 0x05, 0x67, 0xc3, 0xd8, 0x6a, 0xf8, 0xf2, 0x17, 0xc4, 0x56, 0xd8, 0x35, 0xa9, 0x83, 0xaa, 0xac, 0x79, + 0x71, 0x13, 0xfe, 0x09, 0xdb, 0x4b, 0x0c, 0x66, 0xf2, 0x92, 0xe6, 0x93, 0xe9, 0x08, 0x69, 0x9e, 0x21, 0x67, 0x36, + 0xff, 0xa3, 0x98, 0xc9, 0xf2, 0x52, 0x46, 0x33, 0x5f, 0x26, 0xc6, 0xbf, 0xf9, 0x33, 0x09, 0xec, 0x57, 0xce, 0x87, + 0x51, 0x64, 0x62, 0x79, 0x6c, 0x1b, 0x2f, 0xc8, 0x7d, 0x0c, 0xdd, 0x68, 0xb1, 0xca, 0xb2, 0x8c, 0x7d, 0xa5, 0xcc, + 0xd2, 0x18, 0x83, 0xc3, 0xd3, 0xf5, 0x88, 0x2a, 0x74, 0xd6, 0x87, 0x3c, 0x97, 0xfe, 0x65, 0x95, 0x0a, 0xd3, 0x87, + 0x32, 0x53, 0x5a, 0x6f, 0x81, 0xd8, 0xeb, 0x89, 0xe2, 0xc3, 0x57, 0x12, 0x6d, 0x72, 0x24, 0xe7, 0x83, 0x53, 0x58, + 0x4d, 0xf2, 0xda, 0x23, 0x13, 0xf1, 0x0c, 0x3f, 0xd9, 0xf6, 0xf3, 0x5c, 0x49, 0xcf, 0x2f, 0x3e, 0xc3, 0x6e, 0x97, + 0xc6, 0xde, 0x4b, 0x7e, 0x27, 0x3f, 0x47, 0x1f, 0x06, 0x77, 0xe4, 0xa4, 0xa4, 0xb6, 0xbf, 0xf4, 0x39, 0xae, 0x03, + 0x65, 0xf7, 0x3f, 0xa8, 0xbe, 0x86, 0x2c, 0x2a, 0x1e, 0x4d, 0xd2, 0x15, 0xe6, 0x60, 0xa9, 0x1f, 0x66, 0x2e, 0xfc, + 0x45, 0x9a, 0xe0, 0x2c, 0xba, 0xd1, 0xcb, 0x83, 0x69, 0x3d, 0xf9, 0x47, 0x64, 0xe9, 0x4f, 0xb3, 0x6c, 0x72, 0x38, + 0x0d, 0x17, 0xfc, 0x48, 0x46, 0x3f, 0xde, 0xab, 0xdb, 0x93, 0x7a, 0xad, 0x97, 0x7b, 0x08, 0x98, 0x7e, 0xa4, 0x21, + 0x92, 0x37, 0xcb, 0x54, 0x61, 0x40, 0xf2, 0x06, 0x17, 0xb4, 0x06, 0x5d, 0x6a, 0x9a, 0xa5, 0x55, 0xe0, 0x8c, 0xee, + 0x09, 0x3a, 0xa8, 0xe0, 0x68, 0xb9, 0xf2, 0xf5, 0x59, 0xc4, 0xe2, 0xa4, 0x62, 0xbb, 0x2d, 0x8a, 0x68, 0xcf, 0xe0, + 0x38, 0x5a, 0x44, 0x45, 0x66, 0xf4, 0xbb, 0xd4, 0x56, 0x28, 0xfb, 0x82, 0x15, 0xdc, 0xd1, 0x17, 0xb2, 0x52, 0xae, + 0xa5, 0x21, 0xdf, 0x4a, 0xc9, 0x16, 0x1a, 0x50, 0x29, 0xc5, 0x96, 0xaa, 0x71, 0x19, 0x07, 0x57, 0xc6, 0xe6, 0x58, + 0xc2, 0x92, 0x56, 0xc5, 0xab, 0xc8, 0x90, 0x8e, 0xaf, 0x13, 0x41, 0xca, 0x65, 0x19, 0x38, 0x3c, 0x9c, 0xa3, 0x0c, + 0x79, 0xb2, 0x0d, 0x25, 0x79, 0x26, 0x60, 0x0e, 0x66, 0x5c, 0xab, 0x27, 0xd5, 0xaa, 0x01, 0x8d, 0x14, 0xd5, 0x55, + 0x4c, 0x67, 0xab, 0x03, 0xea, 0xf8, 0x15, 0x81, 0x59, 0x58, 0xc6, 0xf3, 0x28, 0xc4, 0x5d, 0x29, 0xc3, 0x2e, 0xdc, + 0x4e, 0x12, 0xac, 0xc7, 0xc9, 0x70, 0xb8, 0xa3, 0x8d, 0x9d, 0x8b, 0x5e, 0xe3, 0x47, 0x21, 0x5c, 0x4a, 0xf7, 0x18, + 0x19, 0x81, 0xc9, 0xc5, 0xce, 0xa5, 0xf3, 0x49, 0x13, 0xee, 0x64, 0x41, 0x00, 0x44, 0x1e, 0xf6, 0x7d, 0xb0, 0xb8, + 0x3c, 0xea, 0x2c, 0x60, 0x62, 0x9e, 0x2b, 0x3b, 0x2a, 0x6f, 0xe0, 0xab, 0x75, 0x28, 0x2b, 0x7b, 0x47, 0x5f, 0x26, + 0x31, 0x56, 0xda, 0x8c, 0xdf, 0x96, 0xe5, 0x51, 0x7a, 0x63, 0x59, 0x4d, 0x5b, 0x54, 0x0f, 0x1e, 0xdd, 0xe1, 0xda, + 0x11, 0x63, 0x63, 0x99, 0x75, 0x62, 0x11, 0x98, 0xff, 0x3e, 0xb3, 0x08, 0x1b, 0x55, 0x2d, 0xdf, 0x04, 0xd2, 0x11, + 0xa3, 0x59, 0xd4, 0xf0, 0x80, 0x4f, 0x47, 0xcb, 0x18, 0x16, 0x33, 0x82, 0x59, 0xf6, 0xa0, 0xe5, 0x6a, 0x08, 0xd2, + 0x8c, 0x47, 0x89, 0x20, 0xdd, 0x88, 0xa1, 0x19, 0xc9, 0x19, 0x01, 0x9b, 0xa4, 0x10, 0x83, 0x67, 0xc0, 0xfe, 0xd8, + 0x39, 0x22, 0x15, 0x1c, 0xd1, 0x03, 0xc2, 0xaa, 0x8a, 0xcb, 0x0f, 0x0b, 0x1b, 0x06, 0x62, 0x48, 0xc5, 0x8b, 0x59, + 0xf9, 0xb4, 0x00, 0x18, 0x59, 0xa3, 0x8a, 0x87, 0x64, 0x88, 0x8c, 0xbc, 0x69, 0x91, 0x51, 0x87, 0x64, 0x0c, 0xbf, + 0x11, 0x31, 0x90, 0x94, 0x9c, 0x41, 0x1e, 0x73, 0xb2, 0x55, 0x2e, 0x5f, 0xe6, 0x2e, 0xfd, 0xd3, 0xfe, 0x54, 0x8e, + 0xf7, 0xa9, 0xd4, 0xd0, 0xa6, 0x97, 0x71, 0x39, 0x17, 0x15, 0x07, 0xd7, 0xcb, 0x76, 0xd3, 0xd3, 0x8e, 0xe6, 0x0b, + 0xd7, 0xe6, 0x66, 0xbb, 0x30, 0xde, 0x1d, 0xab, 0xec, 0xc3, 0x27, 0x94, 0x71, 0x41, 0x33, 0x3c, 0xec, 0xd4, 0x6d, + 0x23, 0x63, 0x18, 0x41, 0xff, 0x36, 0xbe, 0x9e, 0xc8, 0x2e, 0x5d, 0xe6, 0x82, 0xe4, 0x30, 0x6f, 0xf0, 0x6d, 0x61, + 0xfc, 0x25, 0xd9, 0x8d, 0xd6, 0xc9, 0xba, 0xa7, 0x35, 0xba, 0x7b, 0x69, 0xc3, 0x17, 0x1c, 0xa0, 0xf3, 0x4b, 0x1c, + 0xea, 0xd1, 0x14, 0x58, 0xee, 0xf3, 0xa6, 0x3e, 0x41, 0xa6, 0xf1, 0xb0, 0xb6, 0x03, 0x72, 0x8d, 0xe7, 0xba, 0x8d, + 0x1a, 0xf5, 0x1d, 0x5b, 0xa6, 0xb7, 0xc4, 0x56, 0xde, 0xdb, 0x6c, 0x83, 0x39, 0x50, 0xf5, 0xdf, 0x3e, 0x44, 0x22, + 0x18, 0x49, 0xd3, 0x3e, 0x47, 0xeb, 0x77, 0x2e, 0xcf, 0xfc, 0xeb, 0xcc, 0xd1, 0x86, 0x95, 0x61, 0x46, 0x83, 0x19, + 0x5f, 0xe9, 0xce, 0xd0, 0xcc, 0x6b, 0xe6, 0x1e, 0xb8, 0xdd, 0x4b, 0xef, 0xc6, 0x9a, 0x35, 0xfa, 0x61, 0xba, 0x53, + 0x92, 0x59, 0xe0, 0x74, 0xfc, 0x9b, 0xa0, 0xa7, 0x82, 0xf4, 0xa3, 0x3a, 0xb0, 0xf8, 0x8e, 0x93, 0x98, 0x90, 0x0c, + 0x39, 0x58, 0x90, 0xab, 0xe6, 0xbd, 0xa7, 0xdb, 0x5e, 0x9b, 0xb2, 0x46, 0x5c, 0x3a, 0x5d, 0x7d, 0x79, 0xbd, 0xf0, + 0x02, 0xed, 0xf1, 0xde, 0x8f, 0x36, 0xde, 0xd0, 0xc9, 0xe3, 0x0d, 0x54, 0x44, 0xfc, 0x86, 0xdc, 0xd0, 0x18, 0x5f, + 0x85, 0x29, 0x03, 0xc7, 0x7c, 0xef, 0xae, 0xbd, 0x69, 0xee, 0xf1, 0x8b, 0xb9, 0x56, 0x67, 0x4e, 0xb4, 0x57, 0x66, + 0xbd, 0x32, 0x71, 0xb1, 0xa0, 0x24, 0x1f, 0x1e, 0x10, 0x5c, 0xc7, 0x3f, 0xad, 0x56, 0xe1, 0xae, 0xc7, 0x0f, 0x72, + 0xb0, 0x14, 0x03, 0xd3, 0x0d, 0x5c, 0x07, 0x62, 0x1d, 0xc6, 0x16, 0x69, 0x60, 0xa9, 0x1f, 0xca, 0x88, 0x51, 0x30, + 0x7e, 0x7e, 0xbc, 0x8c, 0x7a, 0xc7, 0x7f, 0x58, 0x02, 0x58, 0xb7, 0x11, 0x8e, 0x40, 0x33, 0x2b, 0x4e, 0x39, 0x1f, + 0x17, 0xfa, 0x08, 0xae, 0x6c, 0xca, 0xbe, 0x61, 0xe0, 0x90, 0x15, 0x98, 0xf6, 0x47, 0x43, 0xe5, 0xf7, 0x4f, 0xe4, + 0xc7, 0xb5, 0xbb, 0xdf, 0x6b, 0xd3, 0xc6, 0x0c, 0x47, 0x8f, 0x90, 0x89, 0x0e, 0xe6, 0x40, 0x87, 0x47, 0xc3, 0x62, + 0xca, 0x8e, 0x9b, 0xda, 0xb3, 0x1a, 0x6f, 0xc9, 0xf1, 0x18, 0x7e, 0xad, 0xa2, 0xd9, 0x78, 0x90, 0x6e, 0xab, 0x5c, + 0xcf, 0x76, 0x94, 0x6f, 0x7e, 0xe8, 0x34, 0xd9, 0xc2, 0x37, 0xfa, 0xd7, 0x39, 0xb4, 0x68, 0xbe, 0x46, 0xb4, 0xc8, + 0x1a, 0xea, 0x03, 0xf0, 0xe3, 0x42, 0x63, 0xcd, 0x63, 0x28, 0x08, 0x9b, 0x9b, 0xd6, 0xb5, 0x0d, 0x0d, 0x9a, 0x39, + 0x79, 0x27, 0x48, 0x51, 0x00, 0x89, 0x3b, 0x56, 0xa1, 0xa7, 0x73, 0x10, 0x18, 0x3c, 0xf6, 0x3e, 0xb5, 0x6e, 0x4c, + 0x51, 0x97, 0x7b, 0x4c, 0x34, 0x76, 0xb3, 0x6f, 0x8b, 0xf6, 0xe9, 0x57, 0xfa, 0x8f, 0xc8, 0x85, 0x08, 0x0c, 0x9e, + 0x1f, 0x00, 0xfb, 0x38, 0xb0, 0x15, 0xcd, 0x26, 0x95, 0x37, 0x7c, 0x6e, 0x5f, 0x7f, 0xee, 0xcb, 0xa7, 0xd9, 0x5c, + 0x20, 0xd1, 0xf7, 0xe7, 0xa6, 0x4e, 0xa6, 0x2a, 0xd7, 0x72, 0x07, 0xbb, 0x38, 0x9a, 0x86, 0x18, 0x2d, 0x00, 0x1a, + 0x65, 0x20, 0xf8, 0x09, 0x3e, 0x52, 0x67, 0xfc, 0xf3, 0x79, 0x97, 0xe7, 0x74, 0xff, 0xe1, 0x2d, 0x99, 0xde, 0xd2, + 0x1c, 0xf0, 0x6d, 0xc8, 0xff, 0xed, 0xbf, 0xd1, 0xad, 0x63, 0xac, 0x08, 0xcc, 0x0e, 0xae, 0xcd, 0xa2, 0x5c, 0x7a, + 0x5b, 0x9b, 0xb8, 0xf2, 0x71, 0xf6, 0x03, 0xdc, 0xe6, 0xbe, 0x11, 0x18, 0x4d, 0xe1, 0x63, 0x16, 0x93, 0xb6, 0xca, + 0x75, 0xd3, 0x13, 0x66, 0xdb, 0xe8, 0x12, 0xa9, 0x21, 0xb8, 0xde, 0xc7, 0xb2, 0xd8, 0x78, 0x32, 0x92, 0xd5, 0xf6, + 0xc5, 0x53, 0x01, 0x2e, 0x34, 0x96, 0x7f, 0xa2, 0xce, 0xdb, 0x3d, 0x6a, 0x93, 0xd3, 0xfe, 0x87, 0xd6, 0xee, 0xb9, + 0x54, 0x74, 0x6d, 0x8f, 0x4d, 0x9f, 0x5a, 0x0b, 0x86, 0x60, 0xdf, 0x92, 0x15, 0x7b, 0x01, 0xd0, 0x0e, 0xf0, 0x42, + 0xb5, 0x89, 0x6e, 0xab, 0xfe, 0xb1, 0x07, 0xa4, 0x31, 0xbe, 0xc7, 0x24, 0x55, 0x6e, 0x64, 0x42, 0xcd, 0x22, 0x41, + 0xd1, 0x71, 0x7c, 0x7c, 0x47, 0x5b, 0xad, 0x87, 0x17, 0x62, 0x55, 0x0a, 0x63, 0xcb, 0xdc, 0x9b, 0x32, 0xc8, 0x69, + 0xaa, 0x0f, 0x49, 0x0b, 0xb7, 0x0d, 0x5d, 0x0a, 0x1f, 0x8b, 0x47, 0xad, 0x76, 0x20, 0x27, 0x1b, 0x08, 0xe1, 0x88, + 0xce, 0x5f, 0x4a, 0x9d, 0x02, 0xbc, 0x0e, 0xdc, 0x15, 0xc7, 0xb0, 0x6c, 0xc7, 0xdd, 0xa8, 0xd5, 0x16, 0xfe, 0xec, + 0x00, 0xd4, 0xb0, 0xae, 0xda, 0xed, 0x1d, 0xf5, 0xba, 0x4c, 0x61, 0x94, 0x0a, 0x09, 0x08, 0x87, 0xcb, 0xd9, 0xa4, + 0x20, 0x94, 0x04, 0x8c, 0x55, 0x51, 0xfd, 0xa1, 0xcc, 0x6d, 0xb7, 0x1b, 0x35, 0xe7, 0x91, 0x78, 0x18, 0xa8, 0x58, + 0x8f, 0x69, 0x6d, 0xe6, 0xe0, 0x80, 0x42, 0xd4, 0x6c, 0x7a, 0x2c, 0x7f, 0x58, 0x8f, 0xe4, 0x52, 0xf0, 0x48, 0xc4, + 0xe2, 0x6d, 0x8f, 0xd1, 0xe4, 0x8f, 0x67, 0xc8, 0xec, 0x2d, 0x17, 0x3f, 0xcc, 0xe1, 0x76, 0x62, 0x97, 0x01, 0x4f, + 0x30, 0x31, 0x35, 0xea, 0xc9, 0x56, 0xf4, 0x14, 0x90, 0x0e, 0xb3, 0x82, 0x01, 0xc2, 0x29, 0xf5, 0xcb, 0x68, 0xcc, + 0x9b, 0xcb, 0x95, 0x5b, 0x89, 0x46, 0xb4, 0x94, 0x85, 0xb6, 0xdc, 0x96, 0x1f, 0x26, 0x94, 0xac, 0xb8, 0xa6, 0xb6, + 0x99, 0xad, 0xa2, 0x45, 0x2b, 0x08, 0x7f, 0x5c, 0xcd, 0x8c, 0xa8, 0xbf, 0x90, 0x6e, 0xd6, 0x74, 0x77, 0x06, 0x69, + 0x35, 0xa7, 0x76, 0x76, 0x8e, 0xe6, 0x82, 0x06, 0xea, 0x35, 0x82, 0x8c, 0xc5, 0xa5, 0x26, 0xe5, 0xac, 0x73, 0xa1, + 0xc6, 0x1b, 0x86, 0xaf, 0x9b, 0xa4, 0x5e, 0x94, 0x36, 0xae, 0x6e, 0x74, 0xea, 0x4b, 0xd0, 0xc1, 0xa0, 0x83, 0x84, + 0x94, 0x5a, 0x85, 0x8a, 0xec, 0xd3, 0xc5, 0xba, 0x70, 0x9a, 0x90, 0x74, 0xba, 0xe2, 0xe5, 0xa4, 0x78, 0xcf, 0x08, + 0x71, 0xf4, 0x03, 0x52, 0x26, 0x8f, 0x50, 0x93, 0xbc, 0xf6, 0x01, 0x65, 0xf2, 0x34, 0x6a, 0x71, 0xd8, 0xd0, 0x06, + 0x11, 0x0f, 0x06, 0xc7, 0xe3, 0x08, 0x52, 0xc1, 0x7a, 0x4a, 0x46, 0x97, 0x00, 0x49, 0x2f, 0xc9, 0xd3, 0x03, 0x0b, + 0xa6, 0xe6, 0x4e, 0x29, 0x28, 0x9e, 0x0c, 0x30, 0xb4, 0x95, 0x46, 0x65, 0xc9, 0x0c, 0x45, 0x0f, 0x74, 0xeb, 0xf7, + 0x14, 0x0a, 0x18, 0x23, 0xce, 0x1e, 0xfb, 0xdc, 0x04, 0x10, 0x14, 0x87, 0x35, 0x08, 0xdd, 0x67, 0x04, 0x1b, 0x79, + 0x46, 0xc1, 0x22, 0xcf, 0x07, 0xe4, 0xa8, 0xec, 0x65, 0x35, 0xf7, 0x5f, 0xce, 0x90, 0x0d, 0x0c, 0x1e, 0xd5, 0x93, + 0x4e, 0xae, 0xf5, 0xeb, 0x70, 0x82, 0x9c, 0xd1, 0xa7, 0xac, 0x9e, 0xb4, 0x73, 0x53, 0x4f, 0xd1, 0xac, 0x50, 0x7f, + 0xe6, 0x1e, 0x5e, 0xe1, 0x5b, 0x39, 0x33, 0xca, 0x22, 0x15, 0xf1, 0xc2, 0x0f, 0x60, 0xe3, 0xe7, 0x59, 0xc7, 0xe0, + 0xf0, 0xc4, 0xd9, 0xea, 0x84, 0x38, 0xc4, 0x35, 0x39, 0xf8, 0xb8, 0x45, 0x8c, 0x1a, 0x34, 0x26, 0xb7, 0xa8, 0xd6, + 0x94, 0x78, 0x0b, 0xf5, 0xa9, 0xc1, 0x50, 0x1b, 0x27, 0x5d, 0x59, 0x09, 0x26, 0x34, 0xbc, 0xe4, 0x53, 0x25, 0xeb, + 0x28, 0x56, 0xf8, 0xe5, 0x0a, 0x30, 0x1b, 0x98, 0xe6, 0xae, 0x13, 0x0c, 0x56, 0x9a, 0x53, 0x33, 0xf2, 0xea, 0xdc, + 0x21, 0x94, 0xba, 0xd1, 0x0b, 0x98, 0x00, 0x86, 0x43, 0x46, 0x1b, 0xf4, 0xf2, 0xc2, 0x97, 0x0b, 0x52, 0xb5, 0x23, + 0x87, 0x0c, 0x16, 0x39, 0x91, 0x06, 0x87, 0xf8, 0x9f, 0x09, 0x41, 0xd2, 0x66, 0x07, 0xe2, 0xcd, 0xb1, 0x9b, 0x3a, + 0x56, 0x3d, 0x07, 0xf9, 0xdd, 0x0d, 0xf6, 0x5a, 0xf1, 0xda, 0x34, 0xa9, 0xa1, 0x57, 0xa3, 0x71, 0x28, 0x48, 0xcb, + 0x8b, 0xd9, 0x95, 0x27, 0x4d, 0xa2, 0xdb, 0xd2, 0x55, 0x83, 0x1e, 0xc2, 0x3b, 0xf3, 0x90, 0xdf, 0xf0, 0xbe, 0x9e, + 0xcc, 0x05, 0x45, 0x87, 0x70, 0x0d, 0xb9, 0x89, 0x44, 0xfd, 0x44, 0x57, 0x6c, 0x41, 0x59, 0xec, 0x67, 0xa8, 0x03, + 0xbc, 0xb4, 0x38, 0x41, 0x61, 0x8f, 0xd4, 0xb8, 0xe0, 0xb6, 0x27, 0x0c, 0x53, 0xeb, 0xb2, 0x70, 0xd9, 0xe9, 0xb6, + 0x68, 0x72, 0x2d, 0x50, 0x0c, 0x02, 0xcd, 0x79, 0xfe, 0x7a, 0x7b, 0xea, 0x1a, 0xcf, 0xe0, 0x74, 0xec, 0x60, 0x74, + 0x32, 0xe3, 0x2a, 0x61, 0x83, 0xa8, 0xc3, 0x5d, 0xba, 0x69, 0x20, 0x97, 0x3d, 0xa8, 0x6e, 0x9e, 0xf7, 0xa7, 0xb3, + 0x6b, 0xe3, 0xad, 0x06, 0xd0, 0x1e, 0x00, 0xca, 0x8b, 0x5d, 0xfa, 0xc0, 0x89, 0x9b, 0x76, 0xf7, 0x25, 0xd6, 0x1b, + 0xa8, 0x91, 0x88, 0x20, 0x0a, 0x48, 0x98, 0xfa, 0xe7, 0x4e, 0xd9, 0xf4, 0xf1, 0x1d, 0xaf, 0x3a, 0x51, 0xa8, 0x90, + 0x34, 0x70, 0x8d, 0xa3, 0x87, 0x43, 0x1b, 0x73, 0xc0, 0x1a, 0xe3, 0x44, 0xb8, 0xdf, 0x62, 0xdf, 0xb5, 0x56, 0x1c, + 0xd7, 0x65, 0xb8, 0xe8, 0x3b, 0x45, 0x35, 0x07, 0xc3, 0xab, 0xc3, 0xe3, 0x3c, 0xf8, 0x15, 0xaa, 0xa8, 0xe4, 0xdb, + 0x2e, 0x47, 0x1e, 0x57, 0xa0, 0xcb, 0xf9, 0xb6, 0xbd, 0xbf, 0xc1, 0x30, 0x80, 0x28, 0xf0, 0x41, 0x15, 0xbb, 0x54, + 0x39, 0xb1, 0x3e, 0x70, 0xd6, 0x08, 0x32, 0xaf, 0x22, 0xc4, 0x2b, 0x2e, 0xf9, 0x7d, 0x07, 0x80, 0x5d, 0xb9, 0xca, + 0xb2, 0xae, 0x2b, 0xff, 0x6f, 0x86, 0x11, 0x42, 0xc6, 0xd0, 0xb1, 0x6f, 0xb7, 0xe4, 0x34, 0x06, 0xf5, 0x74, 0xdc, + 0xec, 0x4d, 0x3c, 0x37, 0x0e, 0x5c, 0x00, 0x14, 0xb1, 0x7c, 0xcd, 0x13, 0xde, 0x45, 0x9c, 0x05, 0x88, 0x0d, 0x92, + 0xcf, 0x60, 0xca, 0x71, 0xbf, 0xbe, 0x96, 0x2c, 0xab, 0x38, 0x73, 0x50, 0x1f, 0x9c, 0xfb, 0xa7, 0xe6, 0xf0, 0xb2, + 0x4d, 0x31, 0x0e, 0xc7, 0x8f, 0x3f, 0xd0, 0x55, 0x0c, 0xac, 0x54, 0x7b, 0x20, 0x2d, 0x98, 0xf7, 0x5a, 0xa1, 0x61, + 0xa1, 0xf5, 0xa1, 0x4f, 0x4d, 0xe6, 0x7d, 0xfc, 0x78, 0x55, 0x3d, 0xd0, 0x01, 0x3a, 0xb9, 0x43, 0x69, 0x7f, 0x68, + 0xa9, 0x6f, 0x56, 0xbf, 0x44, 0x05, 0x76, 0x99, 0x83, 0xdd, 0x1e, 0xc7, 0x39, 0x9b, 0x15, 0xd9, 0xd1, 0x2f, 0x44, + 0x97, 0x09, 0x3b, 0x7c, 0x9c, 0x9a, 0xe6, 0x0f, 0xb0, 0x2b, 0x5f, 0x6e, 0xfe, 0x44, 0x09, 0x4c, 0xd4, 0xd9, 0x60, + 0x1f, 0x01, 0xd0, 0x7d, 0xf0, 0x79, 0x82, 0xe4, 0xe3, 0xfa, 0x71, 0xf7, 0x5f, 0xfb, 0x03, 0xd4, 0x79, 0x57, 0x62, + 0xd9, 0x40, 0x9c, 0xb8, 0x42, 0x02, 0xda, 0x14, 0x42, 0x7f, 0x2a, 0xe5, 0x65, 0x1c, 0x8a, 0x67, 0x4d, 0x07, 0x95, + 0xbb, 0xb9, 0x4a, 0x26, 0xa0, 0xc1, 0x9b, 0x64, 0x96, 0xfd, 0x98, 0x0e, 0x7b, 0xa9, 0x69, 0xea, 0x27, 0x73, 0x5d, + 0x59, 0xad, 0xa6, 0x7c, 0xbb, 0x7d, 0x57, 0x7e, 0xba, 0xe9, 0x09, 0xd2, 0x78, 0xcf, 0x03, 0xb7, 0x75, 0xdf, 0xc8, + 0x1a, 0x0c, 0xf0, 0xcd, 0xc2, 0xa8, 0xca, 0xe9, 0x08, 0x85, 0xa8, 0x98, 0x07, 0x7f, 0x01, 0x62, 0x3c, 0xac, 0xc6, + 0xf1, 0x93, 0x4e, 0x27, 0xc0, 0x32, 0xfb, 0xf2, 0x66, 0x63, 0x1d, 0xb1, 0x27, 0x30, 0xbc, 0xa8, 0xcc, 0x15, 0x2f, + 0xd1, 0x31, 0x70, 0xdb, 0xbb, 0xb2, 0x4a, 0xa6, 0xcb, 0xe7, 0xbe, 0x0d, 0x0a, 0x5f, 0x1f, 0x90, 0x20, 0x05, 0x2a, + 0x05, 0xf6, 0xc1, 0xe6, 0xfb, 0x08, 0x68, 0x1e, 0xe7, 0xaa, 0x9e, 0xae, 0xdb, 0xab, 0x2d, 0xda, 0x6f, 0xe1, 0x88, + 0xad, 0xad, 0x82, 0x3d, 0xec, 0xe5, 0xbc, 0x77, 0x7a, 0xf3, 0xe0, 0x17, 0xa6, 0x61, 0x16, 0x12, 0xef, 0x36, 0xea, + 0x1b, 0xd6, 0x6b, 0xb6, 0xf4, 0x99, 0xcc, 0x9a, 0x78, 0x98, 0xac, 0xa7, 0x91, 0x87, 0x93, 0x53, 0x79, 0x8e, 0xcd, + 0x63, 0x61, 0x81, 0x37, 0x74, 0xf5, 0xf4, 0x9a, 0x29, 0x3e, 0x9a, 0x8a, 0xe4, 0x25, 0x3e, 0xb9, 0x8a, 0x16, 0x80, + 0x63, 0xa2, 0x72, 0x7a, 0xed, 0x02, 0x27, 0xd8, 0xeb, 0x45, 0x09, 0x0d, 0x8e, 0x91, 0x63, 0x5b, 0x82, 0xa7, 0xa3, + 0x33, 0x31, 0x6b, 0x5c, 0x40, 0xfa, 0x9a, 0xac, 0xbf, 0xae, 0x42, 0x9a, 0x91, 0x49, 0x06, 0x1f, 0x3d, 0x4b, 0x53, + 0x37, 0x2f, 0x37, 0x80, 0xc0, 0x51, 0xf1, 0xbe, 0x0b, 0x64, 0x79, 0xc3, 0x90, 0x3c, 0xc9, 0xc1, 0x4a, 0xb7, 0x27, + 0xb8, 0x09, 0xc1, 0xff, 0xf9, 0xdd, 0xc2, 0x4a, 0xa6, 0x22, 0x97, 0x63, 0x14, 0xa2, 0xd8, 0x3d, 0xe7, 0x06, 0x73, + 0x53, 0xc9, 0x55, 0x02, 0xb5, 0xfc, 0x83, 0xed, 0xcf, 0x6a, 0x48, 0x72, 0xe6, 0x0b, 0xc8, 0x8b, 0xd9, 0x45, 0x28, + 0x70, 0x56, 0x6f, 0x51, 0xc4, 0x06, 0x82, 0x3d, 0xe6, 0x5a, 0xd3, 0xc3, 0x1c, 0x48, 0x66, 0x35, 0xc0, 0x68, 0x4b, + 0x04, 0xa9, 0x17, 0xec, 0xec, 0x52, 0xd1, 0x7d, 0x5d, 0x50, 0xa4, 0xbb, 0x2c, 0x11, 0x53, 0x69, 0x25, 0xc7, 0xe7, + 0x2d, 0xf6, 0xd7, 0x9a, 0xaa, 0xa5, 0xbe, 0xca, 0xce, 0x31, 0xa6, 0xa7, 0xe3, 0x4f, 0x1b, 0x3f, 0x12, 0x7e, 0x9f, + 0x2b, 0x66, 0x30, 0x1b, 0x86, 0xd1, 0x2e, 0x61, 0xd2, 0x50, 0x7d, 0xa6, 0x38, 0x6e, 0x2c, 0x37, 0x5e, 0x6e, 0x5f, + 0x74, 0xc5, 0x56, 0xe9, 0x9f, 0xbb, 0x05, 0xbe, 0x26, 0xdd, 0x6b, 0x32, 0x2f, 0x48, 0x6c, 0xf0, 0x44, 0xf7, 0x60, + 0x9d, 0xa8, 0xae, 0xfd, 0xcb, 0xf3, 0xd3, 0x84, 0x10, 0xb3, 0x6d, 0x2b, 0xf2, 0xca, 0x0a, 0x50, 0x0e, 0x69, 0x37, + 0x01, 0xf5, 0xa5, 0x1b, 0xce, 0x83, 0xba, 0xb1, 0x81, 0x97, 0x90, 0x5a, 0x03, 0xc5, 0x2e, 0x8c, 0x7d, 0x75, 0x3a, + 0x0a, 0x69, 0x72, 0x26, 0x7b, 0x48, 0x28, 0x26, 0x0c, 0xd0, 0x3f, 0x2d, 0x8e, 0x66, 0x54, 0xd0, 0x7a, 0x77, 0x45, + 0x75, 0x2c, 0x3b, 0xd7, 0x40, 0x94, 0x99, 0x8d, 0x66, 0xda, 0x41, 0x86, 0x37, 0x0e, 0x91, 0xef, 0x32, 0xd3, 0xd1, + 0x81, 0x1d, 0x53, 0xee, 0xa4, 0x0e, 0x1b, 0x57, 0xd9, 0x91, 0x04, 0xf6, 0xbd, 0xcc, 0x89, 0x50, 0xf8, 0x66, 0xb6, + 0x3c, 0x90, 0xaf, 0x75, 0xe5, 0x7f, 0xcd, 0xa8, 0xcf, 0x0a, 0x77, 0xb4, 0x2d, 0x57, 0x33, 0x0e, 0x63, 0xc3, 0x81, + 0xcc, 0xc7, 0x07, 0x26, 0x78, 0xe5, 0xa9, 0x2a, 0xfb, 0x4d, 0xd8, 0x65, 0x0f, 0xec, 0xd9, 0xe4, 0x28, 0x2d, 0x1d, + 0xb5, 0xff, 0xb5, 0xcb, 0xa2, 0x43, 0xd1, 0xb0, 0x68, 0x5d, 0x24, 0x88, 0x5a, 0x6d, 0xf1, 0xc3, 0x3c, 0x22, 0x41, + 0xed, 0x8b, 0xc5, 0x4b, 0x7b, 0xe0, 0xa3, 0x29, 0x06, 0xbe, 0xcf, 0x58, 0x3c, 0x89, 0xbe, 0x3f, 0xc2, 0x49, 0x19, + 0x28, 0x1d, 0x3a, 0x03, 0xd2, 0xc4, 0x2a, 0x1e, 0x93, 0x3c, 0x67, 0xb1, 0xc2, 0x5e, 0xf2, 0x3a, 0x2a, 0x83, 0x16, + 0xc9, 0x3f, 0x47, 0x7c, 0xd0, 0xe0, 0x18, 0x3c, 0x8a, 0xbc, 0xf4, 0x4b, 0x70, 0xcb, 0x7d, 0x7f, 0xc0, 0x08, 0x26, + 0x54, 0x6f, 0xd2, 0x62, 0xf4, 0x42, 0x44, 0xe6, 0x23, 0x34, 0x1e, 0xbf, 0x6f, 0x0d, 0x5e, 0x50, 0xfa, 0xd2, 0xce, + 0x40, 0x72, 0x13, 0xe8, 0xd2, 0x6e, 0x6a, 0x9c, 0x06, 0x72, 0x22, 0x53, 0xd7, 0x76, 0xdc, 0x77, 0xc3, 0x63, 0x41, + 0x5b, 0x82, 0x8c, 0xe9, 0x2e, 0x34, 0x73, 0x14, 0x18, 0xfe, 0xbd, 0xd5, 0x38, 0x02, 0x06, 0xec, 0x1a, 0xeb, 0xe1, + 0x97, 0x62, 0xdc, 0xa4, 0x4a, 0x3f, 0x5c, 0xe1, 0x9c, 0x5d, 0xd2, 0xe9, 0xcd, 0xef, 0x07, 0x4a, 0x20, 0x2e, 0xde, + 0x88, 0x55, 0xdf, 0x06, 0xf3, 0xcb, 0xa0, 0x00, 0x8c, 0xa9, 0x34, 0x64, 0xfa, 0xbf, 0x58, 0x17, 0xf4, 0x4e, 0x0c, + 0xd6, 0x0c, 0x0e, 0x0c, 0x22, 0x3e, 0xee, 0xe0, 0x1e, 0x7f, 0x1d, 0xfe, 0x37, 0x25, 0xa8, 0x2b, 0x77, 0x3f, 0x51, + 0xd6, 0x7c, 0x9f, 0x94, 0x22, 0xd3, 0x97, 0xef, 0x5e, 0xb6, 0x42, 0x1d, 0xd4, 0xd8, 0xe6, 0x16, 0x35, 0xaf, 0x2d, + 0x7e, 0x3d, 0x8d, 0xc5, 0xdc, 0xe4, 0x37, 0xbd, 0x5d, 0x75, 0xf5, 0xd4, 0xa8, 0x51, 0x4f, 0x08, 0x46, 0x6f, 0x6e, + 0x86, 0xdd, 0x1a, 0x3f, 0xcf, 0x4a, 0x40, 0x23, 0x9b, 0xbd, 0x7a, 0x03, 0x05, 0xb9, 0xae, 0xd6, 0xcf, 0x63, 0x59, + 0x65, 0x5c, 0x7c, 0x47, 0x00, 0x5e, 0x1a, 0x1f, 0x12, 0x55, 0xaa, 0x65, 0x65, 0x88, 0x9a, 0x04, 0x10, 0x1c, 0xfe, + 0xa0, 0x7b, 0x73, 0x69, 0x3f, 0xc5, 0x6d, 0x56, 0xe4, 0xb5, 0x15, 0x41, 0x07, 0x19, 0x6a, 0xba, 0x32, 0xb8, 0x81, + 0x0e, 0x0f, 0xa7, 0xe8, 0x7f, 0x15, 0x7f, 0x58, 0xb1, 0x7f, 0xd2, 0x4d, 0x09, 0xe5, 0x53, 0x33, 0x3b, 0xf1, 0x64, + 0xcf, 0x14, 0xa9, 0x59, 0x84, 0x9a, 0x55, 0x6b, 0x06, 0xcb, 0x86, 0xda, 0x7d, 0x0d, 0x09, 0x5b, 0x04, 0x29, 0xa6, + 0x60, 0xdc, 0xd8, 0x9d, 0x11, 0x70, 0xc4, 0x39, 0x83, 0x72, 0xe8, 0x14, 0x65, 0x7e, 0x33, 0x5c, 0x36, 0x4e, 0xdd, + 0xf4, 0x06, 0x05, 0x7e, 0x18, 0xf0, 0xb9, 0xbc, 0xb5, 0x20, 0xcf, 0x1e, 0x65, 0xc5, 0x74, 0x16, 0xfb, 0x56, 0x02, + 0x31, 0x51, 0xb4, 0x03, 0x5b, 0x5e, 0xf1, 0xf2, 0x74, 0x66, 0xb5, 0x4f, 0x3a, 0xd7, 0x1d, 0xc2, 0xfd, 0x21, 0x71, + 0x1d, 0x84, 0x5e, 0xa7, 0x1c, 0x36, 0x79, 0x3d, 0x29, 0x61, 0xb7, 0x28, 0xbb, 0x2e, 0x16, 0xd3, 0x19, 0x0a, 0xbd, + 0x05, 0xf6, 0xbb, 0xdf, 0x7a, 0xfe, 0xa4, 0x72, 0x8c, 0xeb, 0xc3, 0xe5, 0x24, 0x86, 0xf1, 0xb5, 0xd4, 0x10, 0x2d, + 0x5b, 0x4a, 0xf7, 0x58, 0xdb, 0xb0, 0x80, 0xad, 0xd9, 0xfb, 0x47, 0x22, 0xa5, 0x89, 0x32, 0x15, 0xa7, 0x7d, 0xa1, + 0x32, 0x6e, 0xac, 0x3b, 0xab, 0x77, 0xb5, 0x16, 0x1f, 0xad, 0xce, 0x46, 0x1b, 0xa7, 0x12, 0xec, 0xbd, 0xa1, 0xbb, + 0xe8, 0x0b, 0xa6, 0x6c, 0xa1, 0xef, 0xe0, 0xdd, 0x06, 0x6d, 0x31, 0x3e, 0x63, 0x68, 0x9a, 0xdd, 0x79, 0xe0, 0xc5, + 0x67, 0x59, 0x74, 0xb9, 0x68, 0x3e, 0xcd, 0x1c, 0x69, 0xd4, 0xfd, 0x7f, 0x79, 0x6b, 0xa5, 0x0c, 0x77, 0x79, 0x42, + 0x86, 0x9d, 0xdc, 0xaf, 0x4b, 0x56, 0x01, 0xf9, 0x18, 0x5b, 0xe9, 0x79, 0x65, 0x97, 0x44, 0xa1, 0xa3, 0x38, 0xd3, + 0x7f, 0xf8, 0xca, 0x5d, 0xed, 0x3b, 0x6d, 0xfa, 0xd1, 0x65, 0xc9, 0x5f, 0x59, 0x4e, 0x8a, 0x36, 0x4f, 0x88, 0x4c, + 0xfe, 0x4f, 0x24, 0x25, 0x47, 0x06, 0xe2, 0xd1, 0x01, 0x14, 0x30, 0x53, 0x27, 0x93, 0xd3, 0x62, 0x70, 0x02, 0x22, + 0x4b, 0x34, 0x87, 0x33, 0x80, 0x49, 0x5a, 0x80, 0x09, 0xcf, 0x6b, 0xb5, 0xef, 0x31, 0x35, 0x8f, 0xbf, 0xcc, 0xa3, + 0x19, 0x8a, 0x33, 0x87, 0x16, 0x4d, 0x40, 0x32, 0x92, 0x30, 0xac, 0xb5, 0xed, 0x9c, 0x9f, 0x6c, 0x27, 0x78, 0x42, + 0xbd, 0x3f, 0xe0, 0x96, 0x43, 0x70, 0xb9, 0x13, 0xa5, 0xa8, 0xee, 0x93, 0x2f, 0x5b, 0xbd, 0x39, 0xe4, 0x3a, 0xeb, + 0xa1, 0x1e, 0x19, 0x28, 0x6e, 0xdb, 0xd9, 0x24, 0xfd, 0xf5, 0x8a, 0x7f, 0xfc, 0x65, 0xa2, 0x8b, 0x8a, 0x66, 0x0d, + 0x1a, 0x28, 0x00, 0xb7, 0x31, 0xe7, 0x7b, 0x1d, 0xb7, 0xb6, 0x83, 0xb9, 0x0d, 0x70, 0xb7, 0x51, 0x28, 0x06, 0x73, + 0x3f, 0x4f, 0x18, 0x10, 0xcc, 0x6b, 0x4f, 0x14, 0x20, 0xd2, 0x83, 0xfb, 0xe4, 0x54, 0x72, 0x99, 0x8d, 0x20, 0x58, + 0xc3, 0x2c, 0xe8, 0x76, 0xd7, 0xac, 0xcb, 0x8c, 0x3f, 0xf9, 0x21, 0xc3, 0x35, 0xd0, 0x3f, 0x99, 0x28, 0xe9, 0xdc, + 0x90, 0x50, 0xd1, 0x83, 0x78, 0x99, 0x43, 0xe5, 0x79, 0xcf, 0x50, 0x4f, 0xaf, 0x3f, 0xfa, 0xfb, 0xd6, 0xcc, 0xa1, + 0xbc, 0x64, 0x4d, 0xfe, 0xee, 0x31, 0xaf, 0x67, 0x79, 0x45, 0x67, 0xbe, 0x9a, 0x75, 0x56, 0x5c, 0x64, 0x9c, 0x1d, + 0x91, 0x0a, 0x4e, 0xad, 0x68, 0x7d, 0xe2, 0x29, 0x36, 0x8d, 0xdf, 0x1b, 0xa4, 0xce, 0x1e, 0x99, 0x7b, 0x76, 0x50, + 0x51, 0x5a, 0x42, 0x81, 0xf5, 0x22, 0x6a, 0xe0, 0xdb, 0x23, 0x9b, 0x31, 0xd3, 0xe7, 0xa4, 0xc0, 0x8b, 0x96, 0x60, + 0xb3, 0xbc, 0xd4, 0x41, 0x13, 0x2f, 0x4b, 0xe6, 0x8a, 0x13, 0xfe, 0x74, 0x99, 0x29, 0xf6, 0x43, 0x46, 0xea, 0x60, + 0xcf, 0x8b, 0x15, 0x7b, 0x96, 0xcb, 0xa7, 0xcb, 0x87, 0x68, 0x93, 0x7b, 0x8f, 0x88, 0x19, 0xaf, 0x1f, 0x2f, 0xda, + 0xa4, 0x04, 0x94, 0xc8, 0xc8, 0x86, 0x71, 0x1b, 0x09, 0x35, 0x8a, 0xf2, 0xd1, 0x15, 0x28, 0x39, 0xd6, 0xa9, 0x08, + 0x00, 0xf8, 0x63, 0x3a, 0x14, 0x36, 0xf0, 0x60, 0x3e, 0x91, 0x80, 0x32, 0xf2, 0xf4, 0x9d, 0xc9, 0x90, 0x10, 0x1d, + 0x35, 0x33, 0x7c, 0x4f, 0x18, 0xab, 0x67, 0x1e, 0x1d, 0x1f, 0x45, 0x1d, 0x6e, 0x84, 0x81, 0xc4, 0xb2, 0x6c, 0xb2, + 0x9b, 0xb7, 0x6e, 0x2b, 0x7c, 0x57, 0xac, 0x40, 0x9a, 0x02, 0x34, 0x2f, 0xe3, 0x46, 0xc0, 0x69, 0x18, 0xb3, 0x2f, + 0x03, 0xd4, 0x58, 0xc1, 0x58, 0x7e, 0xb5, 0xb2, 0xe1, 0xd9, 0x24, 0xef, 0x7e, 0x74, 0x99, 0x0b, 0x84, 0xbc, 0x58, + 0x60, 0x5b, 0x12, 0x75, 0xe2, 0x37, 0x83, 0xdf, 0xd3, 0xef, 0xd5, 0xf4, 0xd1, 0xc6, 0x88, 0x36, 0x3a, 0xcb, 0x4d, + 0x0f, 0x7a, 0xb4, 0x5b, 0xb0, 0x6a, 0x21, 0x52, 0xcd, 0xf1, 0x30, 0x03, 0x1b, 0xd1, 0x97, 0xd8, 0x60, 0xf5, 0x83, + 0x8d, 0x02, 0xc9, 0xc2, 0x90, 0x6d, 0x9b, 0x3d, 0x36, 0x30, 0x04, 0xe5, 0x59, 0x35, 0x05, 0x58, 0x23, 0xb6, 0xab, + 0x14, 0x46, 0x93, 0x7f, 0xd5, 0x16, 0xfd, 0x27, 0xff, 0x53, 0xac, 0xf7, 0x4c, 0x80, 0x64, 0x7b, 0x38, 0x9f, 0x9d, + 0xa6, 0x05, 0x33, 0x78, 0x14, 0x84, 0xf6, 0x60, 0x4a, 0xcd, 0x49, 0x24, 0x06, 0x25, 0x17, 0x22, 0xfb, 0x93, 0xea, + 0x2d, 0xc7, 0x67, 0x1e, 0x2a, 0xbf, 0xb9, 0x93, 0xe2, 0xa4, 0xd3, 0xea, 0x52, 0x19, 0xc1, 0x5d, 0x81, 0x13, 0x94, + 0x60, 0x36, 0xa0, 0x7f, 0xf2, 0xdb, 0x4d, 0x48, 0xa2, 0x4f, 0x5d, 0x60, 0x28, 0x63, 0xf6, 0x8c, 0xc8, 0xcc, 0xc2, + 0x23, 0x5a, 0x85, 0x28, 0xc6, 0x05, 0x72, 0xc0, 0x6c, 0x3f, 0x1b, 0x59, 0xb0, 0xd5, 0xb0, 0x9f, 0xfb, 0x46, 0xb4, + 0x0f, 0x61, 0x32, 0x62, 0x73, 0xe2, 0x2d, 0xc9, 0x03, 0x68, 0x88, 0x1e, 0xe6, 0x42, 0xe3, 0x82, 0x97, 0xae, 0x52, + 0xa3, 0x14, 0xe8, 0x26, 0x1e, 0xf5, 0x76, 0x68, 0xd4, 0x6a, 0x79, 0x33, 0x46, 0x17, 0xc0, 0x21, 0xaf, 0xf7, 0x4f, + 0xf0, 0xd4, 0x63, 0x86, 0xd8, 0x8b, 0x37, 0x1c, 0x58, 0xad, 0x71, 0xb1, 0x9d, 0x13, 0x37, 0x45, 0xc1, 0xc5, 0x99, + 0x4a, 0x7f, 0xb7, 0x85, 0xff, 0xad, 0xbc, 0xbb, 0x2a, 0xb2, 0x26, 0x28, 0x3f, 0x08, 0xce, 0xdc, 0xf3, 0x02, 0x3e, + 0x59, 0xe9, 0x74, 0xf8, 0x8d, 0xd2, 0x7c, 0x70, 0xf3, 0x84, 0xd1, 0x16, 0x6e, 0xaf, 0x30, 0x57, 0xe1, 0x0a, 0x96, + 0x11, 0xda, 0x67, 0xdc, 0x7a, 0xfc, 0xb9, 0x68, 0x8c, 0x29, 0x47, 0xe7, 0x1c, 0xe4, 0x67, 0x84, 0x04, 0xd3, 0xc0, + 0x26, 0x3d, 0xda, 0x61, 0x99, 0x16, 0x48, 0x09, 0x42, 0x4e, 0x2a, 0xba, 0x1f, 0xc3, 0x50, 0x89, 0xcd, 0x24, 0x24, + 0xad, 0x2a, 0x76, 0xe8, 0xc4, 0x29, 0x37, 0x1b, 0xa6, 0x58, 0x23, 0x7c, 0xba, 0xe9, 0x67, 0x88, 0x92, 0xc8, 0x7b, + 0x2e, 0x6e, 0x46, 0x1d, 0xbc, 0x22, 0x53, 0xc5, 0xd2, 0x57, 0x9e, 0x70, 0xeb, 0xaf, 0xb5, 0x0f, 0x90, 0xef, 0x10, + 0x0a, 0x7a, 0x5c, 0xe5, 0x5f, 0xce, 0x61, 0x56, 0xf2, 0x12, 0xae, 0xf0, 0x53, 0x1c, 0xca, 0x5c, 0x54, 0xd0, 0xe3, + 0xb9, 0x08, 0xf1, 0x96, 0xc3, 0x5b, 0x05, 0x9f, 0x44, 0x5f, 0x24, 0xc2, 0x7d, 0xcb, 0xce, 0xa6, 0xcf, 0x4a, 0x78, + 0xfd, 0xb9, 0x39, 0x29, 0x05, 0xd7, 0x81, 0x46, 0xcf, 0x61, 0xe0, 0x65, 0xd0, 0x62, 0xec, 0xd4, 0xc0, 0x3d, 0x91, + 0xec, 0x5b, 0x7f, 0x60, 0x49, 0xf5, 0xd3, 0x0f, 0x1a, 0x10, 0xcf, 0xd4, 0x7f, 0x3b, 0x30, 0xf1, 0x58, 0xfe, 0x91, + 0xe7, 0x3f, 0x93, 0x44, 0xd5, 0xc5, 0x03, 0x6c, 0x9d, 0x64, 0x0b, 0x05, 0x14, 0x1d, 0x1e, 0x10, 0xb0, 0x68, 0x6f, + 0x57, 0x69, 0x99, 0x9d, 0x30, 0x87, 0x3c, 0xdd, 0x55, 0xaf, 0xb3, 0x04, 0xa7, 0xaf, 0xd6, 0xb3, 0x15, 0xe8, 0xb4, + 0xb0, 0x00, 0x94, 0x38, 0xb3, 0x44, 0x75, 0xc6, 0xc1, 0xa9, 0xc5, 0x67, 0xfc, 0xaf, 0x57, 0x2a, 0x61, 0xec, 0xc1, + 0xc3, 0x41, 0x75, 0xa1, 0x82, 0xfc, 0xec, 0x85, 0xa6, 0x34, 0x0c, 0x20, 0xe1, 0x9c, 0xc6, 0x21, 0x59, 0xc6, 0x16, + 0x8f, 0xbc, 0x32, 0x15, 0x3a, 0x81, 0x75, 0xa7, 0x4f, 0xa7, 0x83, 0x60, 0x5c, 0x62, 0x85, 0xc1, 0x6b, 0x2e, 0x0c, + 0x47, 0x5a, 0x2e, 0xa7, 0xf8, 0x2b, 0x4d, 0xd4, 0xb5, 0xc8, 0x26, 0xf3, 0x1a, 0x57, 0x0d, 0xc4, 0x99, 0x76, 0x41, + 0x86, 0xe5, 0x53, 0xe4, 0xd6, 0x62, 0xb9, 0xf6, 0x5b, 0x9f, 0x57, 0x18, 0x86, 0xca, 0xcd, 0xaf, 0xf7, 0xf4, 0xcd, + 0x1d, 0x89, 0x53, 0x2f, 0xde, 0xa2, 0x40, 0xd3, 0x89, 0x5e, 0x0c, 0x35, 0xc2, 0xd3, 0x71, 0x17, 0x91, 0x61, 0x34, + 0xe0, 0xf4, 0x6d, 0x55, 0x33, 0x66, 0xd2, 0x0e, 0xa0, 0x9f, 0x0b, 0xea, 0x1c, 0x00, 0x9a, 0x22, 0x94, 0x1d, 0x08, + 0x57, 0xa1, 0x5a, 0xaf, 0x97, 0x95, 0x36, 0x36, 0x96, 0x07, 0x0a, 0x21, 0x30, 0x2b, 0x5e, 0x52, 0x28, 0xb9, 0x42, + 0x20, 0x2f, 0xb6, 0xa9, 0x4a, 0x65, 0xa6, 0x65, 0xb3, 0x76, 0xd7, 0x15, 0xed, 0x00, 0xa2, 0x26, 0x6d, 0x64, 0x32, + 0x81, 0x0d, 0x15, 0xd2, 0x14, 0x17, 0x49, 0xad, 0x04, 0x5c, 0xf3, 0x61, 0x0a, 0xa6, 0x11, 0x38, 0x3b, 0x80, 0x16, + 0xcc, 0xe1, 0x5e, 0x33, 0x64, 0x9a, 0x3c, 0xa7, 0x7d, 0x46, 0x8f, 0xb6, 0x5a, 0x63, 0xab, 0x5a, 0xb5, 0x8b, 0xfb, + 0xc9, 0x3a, 0x60, 0x62, 0xc0, 0x6a, 0x7b, 0xfc, 0x6f, 0x85, 0x74, 0xe6, 0x63, 0x21, 0x95, 0xfe, 0x6f, 0x46, 0xe7, + 0x62, 0xde, 0x3c, 0x3f, 0x8c, 0x5c, 0x61, 0x4c, 0x85, 0x3c, 0xc6, 0x49, 0x78, 0xb1, 0x1d, 0x5e, 0x34, 0x06, 0xb5, + 0x1f, 0x30, 0x18, 0x72, 0xaa, 0x63, 0xef, 0x7d, 0x10, 0x92, 0x7d, 0x31, 0xb7, 0x68, 0xac, 0x4e, 0x69, 0x51, 0xac, + 0xfb, 0x00, 0x32, 0x28, 0x8a, 0xfd, 0xff, 0xb8, 0x75, 0x91, 0x85, 0xe6, 0x0f, 0xe4, 0x25, 0x2e, 0x79, 0x98, 0xfe, + 0xf8, 0x5d, 0x50, 0xac, 0x4f, 0x1b, 0xf1, 0x12, 0xcd, 0x95, 0x83, 0x7f, 0xd3, 0x65, 0x8b, 0xea, 0x2e, 0xe5, 0xe1, + 0xde, 0x81, 0x31, 0x8d, 0x6f, 0x6e, 0xbe, 0x8c, 0x0b, 0x6b, 0x9c, 0xbb, 0x19, 0xef, 0x70, 0x13, 0xbb, 0xde, 0x56, + 0x56, 0x6c, 0x17, 0x99, 0xa2, 0xa2, 0xa9, 0xd1, 0x47, 0x33, 0x30, 0x76, 0x68, 0x40, 0xfb, 0xb7, 0x18, 0x32, 0x58, + 0x3c, 0xac, 0xcd, 0x85, 0x68, 0x79, 0x9d, 0xcb, 0x1d, 0x05, 0xe7, 0x64, 0xc4, 0x91, 0x04, 0x69, 0xd2, 0x7d, 0xc7, + 0xc9, 0x83, 0x3a, 0xa8, 0x1a, 0x71, 0xa7, 0x9a, 0xec, 0x57, 0xc2, 0xff, 0x21, 0x1f, 0xd7, 0x9d, 0xb6, 0x72, 0x0e, + 0x08, 0xf1, 0x59, 0xe7, 0xcd, 0x09, 0x91, 0x51, 0xdb, 0x46, 0x6d, 0x25, 0xcd, 0xc8, 0xaf, 0x10, 0x89, 0xfa, 0x57, + 0x8c, 0x02, 0x53, 0x7c, 0x06, 0x30, 0xb0, 0x4d, 0x82, 0xd5, 0x6f, 0xd6, 0x0d, 0xd9, 0x52, 0x40, 0xe3, 0x97, 0xb3, + 0x6d, 0x3e, 0xb1, 0x71, 0x3b, 0xfa, 0x05, 0x51, 0xdb, 0x5a, 0xd1, 0x04, 0xd7, 0xdd, 0x0b, 0xab, 0x37, 0xe2, 0xf7, + 0xd4, 0xdb, 0x23, 0xc8, 0x0d, 0xe4, 0x93, 0x74, 0xbf, 0x73, 0xa6, 0x0f, 0xd8, 0x83, 0x31, 0x8e, 0x31, 0xd8, 0x15, + 0xf3, 0xcc, 0xe8, 0x4d, 0x55, 0xd9, 0x04, 0x7a, 0x77, 0xcb, 0x51, 0x71, 0x8f, 0xdf, 0xd2, 0x2f, 0xde, 0x30, 0xc3, + 0xe8, 0x3e, 0x5f, 0x40, 0xd9, 0xa2, 0x1d, 0x57, 0x1a, 0xc9, 0x65, 0xb4, 0x4d, 0xe5, 0x88, 0x12, 0x58, 0x50, 0x92, + 0x1a, 0x5d, 0xde, 0xdc, 0xb2, 0x79, 0x71, 0x1d, 0x4d, 0x28, 0xb7, 0xfe, 0x74, 0xe4, 0x73, 0x3d, 0x38, 0x2a, 0x6f, + 0x43, 0x04, 0xa6, 0x89, 0x36, 0x2c, 0xe0, 0x30, 0xd3, 0xe6, 0xa5, 0x08, 0x02, 0xf0, 0x6e, 0xf0, 0x67, 0x9b, 0x81, + 0x22, 0x17, 0x10, 0x79, 0xe7, 0x2d, 0x58, 0xa0, 0x1b, 0x3c, 0x05, 0xfa, 0x38, 0x36, 0xfc, 0x77, 0xc1, 0xca, 0xd8, + 0x90, 0x2c, 0x61, 0x7c, 0xaf, 0x73, 0x22, 0x39, 0x49, 0x5d, 0x24, 0xad, 0x9f, 0xc2, 0x33, 0xb5, 0x8d, 0x5b, 0xf3, + 0x17, 0xe9, 0x27, 0xd1, 0x50, 0x79, 0x01, 0xf3, 0x35, 0xaa, 0xb3, 0xcb, 0xfc, 0x85, 0x79, 0x4e, 0x7a, 0x66, 0x5e, + 0xa3, 0xd5, 0x1a, 0xf0, 0xc0, 0xd2, 0x8a, 0xb0, 0x94, 0x59, 0x32, 0xe7, 0x32, 0x00, 0xf0, 0xb5, 0xf1, 0x79, 0x6d, + 0x08, 0xf1, 0x89, 0x5d, 0xdf, 0x15, 0x84, 0xca, 0x54, 0xd3, 0xae, 0x33, 0xf7, 0xc9, 0x2a, 0x84, 0xa5, 0xda, 0x76, + 0xc5, 0x6d, 0xa6, 0xb9, 0xad, 0x0d, 0xcf, 0x3d, 0x5f, 0x37, 0x05, 0xa6, 0xe8, 0x0c, 0xfa, 0x3b, 0xdb, 0x88, 0x53, + 0x04, 0x21, 0x62, 0x06, 0x1f, 0xb0, 0x36, 0x82, 0x6c, 0xca, 0x89, 0xfe, 0x6c, 0x17, 0xd4, 0x34, 0xbd, 0x4c, 0x55, + 0x85, 0xcb, 0x39, 0x26, 0x13, 0x9b, 0xb3, 0x01, 0x8b, 0x39, 0x78, 0xf0, 0xf0, 0x36, 0xb7, 0x65, 0xd9, 0x1b, 0x11, + 0xac, 0x06, 0x2d, 0x9c, 0x3b, 0x58, 0x2a, 0xf4, 0x9d, 0xcc, 0x7a, 0x57, 0x07, 0x37, 0xb3, 0xdf, 0xa4, 0xdd, 0x1f, + 0x39, 0xfa, 0xaa, 0xd2, 0xb8, 0x03, 0xdb, 0x58, 0x02, 0x1b, 0x1e, 0x23, 0x52, 0x0e, 0x89, 0xea, 0x53, 0x1f, 0x54, + 0x8f, 0x6a, 0x4c, 0x72, 0x1c, 0x48, 0x87, 0x89, 0x2b, 0x12, 0x7b, 0x93, 0x16, 0x62, 0x57, 0x2a, 0xa4, 0xa7, 0xb3, + 0x90, 0xaf, 0x25, 0x37, 0x5d, 0x27, 0x89, 0x6c, 0x51, 0xfb, 0x90, 0x57, 0x2d, 0xa9, 0x53, 0x83, 0xf2, 0x78, 0xcc, + 0xd1, 0x8f, 0x77, 0x5b, 0xf9, 0x4a, 0x6d, 0x1d, 0xe7, 0x24, 0xf8, 0x1c, 0xc7, 0x8b, 0x86, 0x7f, 0x2e, 0xca, 0x1b, + 0x2d, 0x3c, 0x8f, 0x2b, 0x3f, 0xec, 0xe4, 0xf5, 0x2b, 0x34, 0x4c, 0xc3, 0x51, 0xeb, 0xb6, 0xbc, 0xe2, 0x70, 0xef, + 0x76, 0x62, 0xb1, 0x84, 0xf5, 0x31, 0x2e, 0x97, 0x3c, 0x8d, 0xaa, 0xa5, 0xa3, 0x3f, 0xdd, 0x01, 0xb7, 0xe4, 0x9d, + 0x00, 0x98, 0xe8, 0xd0, 0x47, 0x58, 0xd0, 0x5e, 0x46, 0x8c, 0x10, 0x7b, 0xc1, 0xe4, 0x30, 0x64, 0xef, 0xfe, 0x0f, + 0xbb, 0x1e, 0x86, 0x6c, 0x49, 0xb2, 0xbb, 0x37, 0x23, 0x7c, 0xa1, 0x9e, 0x1e, 0x58, 0x8d, 0xc3, 0x35, 0x79, 0xb1, + 0x0d, 0x51, 0xec, 0x25, 0xdc, 0x30, 0x6a, 0x4b, 0x31, 0xb7, 0x60, 0x8d, 0x71, 0x48, 0xb1, 0x35, 0xca, 0xa8, 0x61, + 0x73, 0x68, 0x73, 0x28, 0xed, 0xbd, 0xe2, 0xbb, 0xfc, 0x1d, 0xe2, 0x83, 0x6f, 0x6d, 0x8f, 0xa2, 0xee, 0x9d, 0x7b, + 0xcb, 0xbc, 0x48, 0x57, 0xb2, 0xfe, 0xb9, 0x9d, 0xd8, 0x50, 0xdc, 0x4d, 0x37, 0x63, 0x3d, 0x71, 0x90, 0x5d, 0x9a, + 0x7c, 0x20, 0xa8, 0xa2, 0x64, 0xa5, 0xd5, 0xff, 0xec, 0xf6, 0xdf, 0x72, 0x1e, 0x9a, 0x68, 0x74, 0x6c, 0x3b, 0xb4, + 0x46, 0xef, 0xe1, 0xd7, 0xf8, 0x18, 0xab, 0x05, 0x24, 0x87, 0xb9, 0x4e, 0x94, 0xba, 0x19, 0x11, 0x3a, 0x71, 0xe3, + 0x05, 0xa2, 0xde, 0x76, 0x3d, 0xd3, 0xb9, 0xf4, 0xfe, 0x2e, 0x03, 0x34, 0x35, 0x84, 0xe0, 0x21, 0x24, 0xe7, 0x37, + 0xe1, 0xcd, 0xe8, 0x44, 0x7c, 0xc3, 0x74, 0x39, 0x43, 0xee, 0xe1, 0x0b, 0xb4, 0xee, 0x24, 0x58, 0x38, 0xdc, 0x10, + 0x52, 0xa4, 0x82, 0x00, 0xd9, 0x3e, 0x06, 0xb0, 0x30, 0xc9, 0x5e, 0x34, 0x19, 0x0d, 0x88, 0x6c, 0xd6, 0xb6, 0x84, + 0x39, 0x36, 0x53, 0x80, 0x16, 0x6c, 0xcd, 0x2f, 0x81, 0xb3, 0xa1, 0x2d, 0xde, 0xd2, 0xff, 0xe4, 0x35, 0x11, 0x60, + 0x4c, 0x53, 0x9b, 0x66, 0xd6, 0x2b, 0xab, 0x85, 0xa3, 0x28, 0x59, 0x2c, 0x90, 0x03, 0xd7, 0x0d, 0xa5, 0xb1, 0x35, + 0x56, 0x97, 0x34, 0xa0, 0xe5, 0xa2, 0xba, 0x20, 0x10, 0x12, 0x43, 0xcc, 0xab, 0x86, 0x42, 0x4a, 0x12, 0xaa, 0xb9, + 0x75, 0x27, 0xb6, 0x09, 0x0a, 0xb3, 0xe3, 0xce, 0xe4, 0xa1, 0x9f, 0xe1, 0xf8, 0xe3, 0x8d, 0xd9, 0x41, 0xa0, 0x70, + 0xc5, 0x4b, 0x19, 0x0d, 0x2a, 0xcb, 0x66, 0x3d, 0xf4, 0xca, 0xcd, 0x02, 0xda, 0x9d, 0xca, 0x32, 0xa3, 0xda, 0xa9, + 0x9e, 0x09, 0x4e, 0x6f, 0x0d, 0xd0, 0x88, 0x48, 0x80, 0x09, 0xfc, 0xa8, 0xbf, 0x34, 0x2a, 0x16, 0x18, 0x6b, 0x2b, + 0x8f, 0x7a, 0x7d, 0x8f, 0x33, 0x99, 0xce, 0x03, 0x6c, 0x9c, 0xb3, 0x68, 0x55, 0x23, 0x9e, 0x90, 0xa0, 0x4f, 0x72, + 0xb2, 0x73, 0x56, 0x2d, 0xe3, 0xeb, 0xe4, 0x82, 0x2f, 0xd8, 0x1d, 0x7f, 0xad, 0x11, 0x94, 0xe3, 0x5f, 0x5c, 0xbc, + 0xc5, 0x6b, 0xe1, 0x14, 0xd7, 0x23, 0xe6, 0x8b, 0x32, 0x2f, 0x7f, 0x78, 0x61, 0xe6, 0xf4, 0xef, 0xaf, 0x30, 0x01, + 0x55, 0xfe, 0x62, 0x89, 0x04, 0x52, 0x79, 0x78, 0xeb, 0x8d, 0xe0, 0x4a, 0x66, 0x14, 0x8d, 0x59, 0x3b, 0x6e, 0x09, + 0x3b, 0x58, 0x14, 0x47, 0x10, 0x2a, 0xfe, 0xf9, 0x0c, 0x20, 0x71, 0x16, 0xb4, 0xcc, 0x68, 0xd0, 0x88, 0xf6, 0xc0, + 0x9d, 0x15, 0x36, 0xe6, 0x85, 0x5c, 0x97, 0x6f, 0x1f, 0x56, 0x70, 0x90, 0x25, 0x24, 0xc1, 0xc3, 0x7a, 0xfb, 0xa6, + 0xca, 0x74, 0xe9, 0x61, 0xea, 0x75, 0xc7, 0xef, 0x99, 0x09, 0x08, 0x69, 0xf6, 0x10, 0xd9, 0xdc, 0x8d, 0xc4, 0xf4, + 0xc6, 0x53, 0xdb, 0x8e, 0x98, 0x8f, 0xed, 0x44, 0xe4, 0x4a, 0x1d, 0xdb, 0xe6, 0x21, 0x32, 0xc2, 0x0a, 0x23, 0x09, + 0x2e, 0xbf, 0x8c, 0xc8, 0x4d, 0x16, 0x34, 0xf6, 0x31, 0xba, 0x94, 0xc5, 0x24, 0xfb, 0x08, 0xfe, 0x52, 0xd6, 0xfa, + 0x97, 0xa8, 0x75, 0xf6, 0x04, 0x7e, 0xc5, 0xd0, 0xde, 0x43, 0x68, 0xac, 0xb3, 0xe0, 0x5d, 0x0b, 0x1e, 0x29, 0xa0, + 0xdc, 0x87, 0x89, 0x84, 0x50, 0x5c, 0x1f, 0x87, 0x5d, 0xb9, 0x6b, 0x89, 0x11, 0xe1, 0xa3, 0xa4, 0x57, 0x6a, 0x93, + 0x31, 0x5c, 0x81, 0x00, 0x2e, 0xcf, 0xf5, 0x78, 0x3e, 0xc3, 0x6c, 0xaf, 0x34, 0x92, 0xd0, 0x77, 0xc3, 0x8c, 0x97, + 0x9b, 0x6e, 0x51, 0x59, 0xb4, 0x79, 0x2b, 0x85, 0xbd, 0x2e, 0x10, 0x99, 0x11, 0x22, 0xe6, 0x96, 0xdf, 0x14, 0xa4, + 0x93, 0xed, 0x7c, 0x83, 0x3e, 0x36, 0x30, 0x9c, 0xc1, 0x4a, 0x57, 0xb5, 0xb5, 0x73, 0x2b, 0xb1, 0xfe, 0x9d, 0x15, + 0x13, 0xf8, 0xf9, 0x62, 0x41, 0x42, 0x40, 0xc2, 0x42, 0xcf, 0x3c, 0x98, 0xf5, 0x70, 0x92, 0x4e, 0x79, 0xf6, 0x12, + 0x13, 0x2e, 0x64, 0xe8, 0x70, 0xfc, 0xa0, 0xa5, 0xb9, 0xa0, 0x39, 0x7e, 0x3e, 0xd3, 0x52, 0xf9, 0x5a, 0x49, 0x93, + 0x2c, 0x58, 0xe5, 0x85, 0xd3, 0xe5, 0x23, 0x43, 0x14, 0x9f, 0x6a, 0xd7, 0x7d, 0x87, 0x9b, 0xcf, 0xa4, 0x68, 0x24, + 0x95, 0x76, 0x22, 0x50, 0x69, 0xc8, 0xe4, 0xed, 0x5e, 0x00, 0x62, 0x1b, 0xa2, 0x2f, 0x9a, 0x8d, 0xcc, 0x54, 0xa6, + 0xa3, 0xab, 0xe5, 0x21, 0x1c, 0xdb, 0xc3, 0x9b, 0xa1, 0x61, 0x08, 0x78, 0x7d, 0x5a, 0xb3, 0x7f, 0x1d, 0x75, 0xa8, + 0x68, 0x62, 0x54, 0xc4, 0xcd, 0x05, 0x93, 0x25, 0x2b, 0xa6, 0x21, 0x41, 0x38, 0x69, 0xc0, 0xe9, 0x6c, 0xc6, 0xd8, + 0x20, 0x79, 0x81, 0x49, 0x26, 0xf6, 0x04, 0x5a, 0x9a, 0x80, 0x79, 0x45, 0xd9, 0x79, 0xb4, 0x19, 0xdb, 0x19, 0xa1, + 0x9c, 0x39, 0x89, 0x8a, 0xf8, 0x67, 0xee, 0x49, 0x2b, 0xe0, 0x3e, 0x63, 0xba, 0xeb, 0x35, 0x9e, 0x71, 0x04, 0x45, + 0xbf, 0x6d, 0x9b, 0xff, 0x65, 0x18, 0x84, 0xa7, 0xcb, 0x76, 0x0e, 0x50, 0x41, 0x96, 0x10, 0xf0, 0x27, 0x2f, 0xe8, + 0x4b, 0xc0, 0x43, 0x0c, 0xf8, 0x81, 0xbd, 0x7a, 0x6d, 0x05, 0x3a, 0xb8, 0xfa, 0xea, 0xec, 0xf7, 0xdf, 0x32, 0x38, + 0xfc, 0x07, 0x57, 0xda, 0xf7, 0x8f, 0x4f, 0x09, 0x9b, 0x93, 0xa7, 0x78, 0x3a, 0x3a, 0xc7, 0xe1, 0xb6, 0x1e, 0xe5, + 0x74, 0x3b, 0x25, 0x14, 0x5e, 0xf8, 0x40, 0xfc, 0x31, 0x8b, 0xbb, 0x81, 0xa6, 0xf2, 0x71, 0xce, 0x1f, 0xe4, 0x27, + 0xc7, 0x27, 0xe9, 0x3e, 0xcd, 0x73, 0x38, 0xe6, 0x82, 0x6d, 0x0c, 0x83, 0xab, 0xce, 0xce, 0x2e, 0xe5, 0x66, 0x58, + 0x4a, 0x7a, 0xab, 0xdb, 0xbd, 0x8e, 0x51, 0xe9, 0xff, 0x2b, 0x7b, 0x4b, 0x47, 0x38, 0x8c, 0x7f, 0xf8, 0x0c, 0x05, + 0x41, 0xee, 0x14, 0xeb, 0xf4, 0xa2, 0x70, 0x8d, 0x3b, 0x94, 0x6f, 0xad, 0xb6, 0xbe, 0xaa, 0x52, 0x8f, 0xcc, 0x45, + 0x8c, 0xf3, 0x15, 0xf1, 0xb2, 0x9a, 0xbc, 0x6e, 0xd0, 0x6f, 0x4f, 0x94, 0xf9, 0xcf, 0xaf, 0x21, 0xc1, 0x76, 0x74, + 0xbf, 0x86, 0xfb, 0x1d, 0x71, 0x0d, 0x6b, 0xce, 0x91, 0x17, 0x9c, 0x71, 0x5d, 0x3d, 0x6d, 0x93, 0x75, 0x2d, 0x1c, + 0xdb, 0x2e, 0x07, 0x5e, 0xeb, 0x52, 0xe7, 0x10, 0xa5, 0x95, 0x71, 0xcf, 0xe9, 0x5d, 0x97, 0xdf, 0x99, 0xea, 0x18, + 0x76, 0x03, 0x9c, 0x8a, 0x60, 0x40, 0x81, 0x79, 0x1f, 0xd4, 0x9d, 0x0c, 0x21, 0x27, 0xf6, 0xac, 0x81, 0x5c, 0x82, + 0x28, 0x9a, 0x2f, 0x41, 0x00, 0x5a, 0xda, 0x81, 0x97, 0xb5, 0x8a, 0x46, 0x96, 0xac, 0x81, 0xb3, 0xd7, 0xff, 0x23, + 0x06, 0x43, 0x9c, 0x7c, 0x93, 0x80, 0x38, 0xc9, 0x14, 0x89, 0x39, 0x8d, 0x45, 0x9f, 0xb3, 0x8f, 0x72, 0x09, 0xd2, + 0xec, 0x67, 0x60, 0x80, 0x60, 0x1a, 0x8e, 0x63, 0x41, 0xa1, 0x64, 0xbe, 0x2a, 0xfa, 0x69, 0xb3, 0xf8, 0xfc, 0x09, + 0xc6, 0xf6, 0x6f, 0x74, 0xdb, 0xa8, 0xfc, 0x5e, 0x53, 0xc9, 0xed, 0xaf, 0x3c, 0x9f, 0xfe, 0xb6, 0x3a, 0x3c, 0xfd, + 0x44, 0xfd, 0xf8, 0x75, 0xd3, 0x02, 0xef, 0xe4, 0xee, 0xa5, 0x0c, 0x35, 0x3f, 0x5f, 0x67, 0x40, 0x58, 0x18, 0x80, + 0xfa, 0xd1, 0xf1, 0xa1, 0xa4, 0xdd, 0xd6, 0xb3, 0x41, 0x34, 0xb1, 0x8f, 0x71, 0x8b, 0xea, 0xe5, 0xbc, 0xc0, 0x66, + 0x35, 0xae, 0xa1, 0x7b, 0x5e, 0x68, 0xcd, 0x33, 0x61, 0x96, 0x0a, 0x4a, 0xe1, 0x64, 0x0a, 0xb8, 0x01, 0x5c, 0x57, + 0x4e, 0x9b, 0x85, 0x17, 0xbd, 0x09, 0x4f, 0x12, 0xcc, 0xe8, 0xc0, 0x45, 0xd3, 0x57, 0x4f, 0xed, 0x8b, 0x8e, 0xe1, + 0xcf, 0x44, 0x5d, 0x8d, 0x21, 0xa9, 0x51, 0x8e, 0x49, 0x8b, 0x95, 0x56, 0x68, 0x2d, 0xaf, 0x96, 0xba, 0xdb, 0x39, + 0x42, 0xaf, 0xbc, 0xa0, 0x0c, 0xc0, 0x03, 0x98, 0xf5, 0x92, 0xde, 0xd2, 0x2a, 0xb2, 0x29, 0xfb, 0x84, 0x5c, 0x9b, + 0xc7, 0x13, 0x9c, 0x96, 0x3e, 0xaa, 0x5b, 0xa4, 0x49, 0x6c, 0x85, 0x6b, 0x38, 0x37, 0x59, 0x55, 0xf5, 0xa2, 0xf9, + 0xda, 0x0f, 0x30, 0xa7, 0x05, 0xfb, 0x37, 0xf6, 0x45, 0xd3, 0x72, 0x12, 0x68, 0xbb, 0x68, 0x64, 0x0b, 0xca, 0x00, + 0x88, 0xd2, 0x3d, 0xbd, 0x01, 0x07, 0xa2, 0x5d, 0xd3, 0x89, 0xf8, 0x36, 0xb1, 0x1d, 0xce, 0x4d, 0x56, 0xa8, 0x85, + 0x0b, 0x73, 0x34, 0x9b, 0x2e, 0x9c, 0xa8, 0xbd, 0x4b, 0x7b, 0x9e, 0x0d, 0x34, 0x6e, 0xf3, 0x40, 0x21, 0x7d, 0x2f, + 0xf0, 0xa8, 0x41, 0xdc, 0x50, 0xa1, 0x17, 0x21, 0x53, 0x81, 0x6b, 0x0a, 0xb6, 0x21, 0x33, 0xd3, 0x38, 0x00, 0xc8, + 0xde, 0x45, 0xdc, 0x80, 0x83, 0x2b, 0x35, 0x86, 0x8e, 0xad, 0xd7, 0xe4, 0x95, 0x64, 0x82, 0xa0, 0xf2, 0x66, 0x89, + 0xcd, 0x58, 0x72, 0x10, 0x95, 0x6f, 0x70, 0xb3, 0x73, 0x27, 0x64, 0xf6, 0x3b, 0x9d, 0x21, 0x4c, 0x59, 0x59, 0xed, + 0x90, 0x9b, 0x11, 0x2f, 0x14, 0x98, 0x5a, 0xb4, 0x20, 0x22, 0x19, 0xb1, 0xaa, 0x1b, 0xbf, 0xf3, 0x76, 0x94, 0x9b, + 0x89, 0x6d, 0xb1, 0x5e, 0xf1, 0x8c, 0x60, 0xbd, 0x83, 0xb5, 0x73, 0xf4, 0x6a, 0x67, 0x64, 0xae, 0xf0, 0x62, 0x98, + 0xdc, 0xae, 0xe7, 0x83, 0x61, 0x44, 0x7d, 0xf9, 0x3f, 0xdb, 0x98, 0x55, 0xe5, 0x34, 0x1a, 0x43, 0x42, 0x24, 0xc3, + 0x9b, 0x00, 0xc4, 0xf3, 0xac, 0xc9, 0x18, 0xcd, 0xc4, 0x6a, 0xdb, 0x3a, 0x4d, 0xb3, 0x9f, 0x4f, 0x39, 0xfd, 0xde, + 0x48, 0x38, 0xc0, 0xf3, 0xaa, 0x73, 0x23, 0xbb, 0x7e, 0xa0, 0x8b, 0x39, 0xf4, 0x65, 0x26, 0x57, 0xf5, 0x8d, 0xec, + 0x54, 0x23, 0xcc, 0xcc, 0xa0, 0xef, 0x06, 0x25, 0x0f, 0x00, 0xd0, 0x1f, 0xe7, 0xe5, 0xd5, 0xff, 0x35, 0x9a, 0x3b, + 0x61, 0x04, 0x1b, 0x2b, 0x96, 0xe6, 0x38, 0x5e, 0x0e, 0xed, 0x40, 0x45, 0xcf, 0x89, 0xda, 0xd3, 0x88, 0xa4, 0x4b, + 0x6a, 0x0c, 0xe3, 0x89, 0x59, 0x1a, 0x1c, 0xd6, 0x50, 0x82, 0xfd, 0x32, 0xfa, 0xed, 0xda, 0xfb, 0x06, 0x52, 0xfc, + 0x1b, 0xd7, 0xd5, 0xf1, 0xec, 0xa8, 0x32, 0x93, 0x5a, 0xe6, 0x89, 0xdb, 0xe2, 0xaa, 0xae, 0x9a, 0xf9, 0xb4, 0x5d, + 0x32, 0x4d, 0x3b, 0x8f, 0xd9, 0x65, 0xfc, 0x19, 0x4d, 0x24, 0x23, 0x3f, 0xac, 0xc3, 0x00, 0x0d, 0x0c, 0xb4, 0x97, + 0xf8, 0xe9, 0x49, 0xa6, 0xab, 0xb7, 0xba, 0x49, 0xd0, 0xba, 0x5c, 0xa7, 0x1f, 0x48, 0xbd, 0xa0, 0x65, 0xd8, 0x59, + 0x33, 0x78, 0xe6, 0x84, 0xe8, 0x02, 0xe7, 0x27, 0xe6, 0x21, 0x67, 0xd4, 0x34, 0xa0, 0x5f, 0xe7, 0xe5, 0x55, 0x97, + 0xbb, 0xc8, 0xc0, 0xcd, 0x04, 0x76, 0xc8, 0x6e, 0x68, 0x7d, 0xac, 0x89, 0xa1, 0x07, 0xe9, 0xc2, 0xb4, 0x35, 0x8f, + 0x83, 0xd0, 0x14, 0xca, 0xc2, 0x95, 0x29, 0xd9, 0x28, 0x7c, 0x4f, 0x8e, 0xae, 0xe1, 0x82, 0x96, 0xd0, 0xde, 0xfd, + 0xdb, 0x05, 0x74, 0xf7, 0x98, 0x40, 0x95, 0x78, 0x92, 0x16, 0xca, 0xcd, 0x42, 0x79, 0x4e, 0x81, 0x15, 0x2c, 0x32, + 0xcf, 0xaa, 0xe9, 0x48, 0xb3, 0xd6, 0x8f, 0x4e, 0xe7, 0xba, 0xd5, 0x1a, 0xf6, 0x31, 0x65, 0x41, 0xf1, 0x8e, 0x16, + 0xe6, 0x5f, 0x89, 0x92, 0x23, 0x0d, 0xfe, 0x2f, 0x12, 0xab, 0xa6, 0x19, 0x7c, 0x85, 0xf9, 0x7f, 0x54, 0xb7, 0x26, + 0xde, 0x27, 0x70, 0x05, 0xc2, 0x5d, 0xa9, 0xb6, 0x33, 0xee, 0x18, 0x75, 0xb4, 0x0e, 0x3c, 0x75, 0x62, 0xc6, 0xc3, + 0xe3, 0x62, 0x8b, 0xe1, 0xb7, 0xa7, 0x37, 0xe0, 0xee, 0xb3, 0x63, 0xdd, 0xdd, 0xeb, 0x20, 0xa4, 0x57, 0x66, 0x91, + 0xee, 0xaf, 0x5a, 0x4d, 0x35, 0x21, 0xd6, 0xb5, 0x32, 0xf7, 0xc4, 0x98, 0x0d, 0x86, 0x33, 0x62, 0x7c, 0x76, 0x70, + 0xb3, 0x35, 0x72, 0x77, 0xa4, 0x24, 0x8a, 0x1d, 0x5d, 0x4a, 0x78, 0x02, 0x43, 0x36, 0xac, 0xca, 0xcd, 0xaf, 0xb5, + 0x7a, 0xb5, 0xaf, 0x3e, 0xfb, 0x12, 0x93, 0xf4, 0x8b, 0x1f, 0x52, 0xd8, 0xf1, 0x44, 0x64, 0xab, 0xc3, 0x58, 0x07, + 0x74, 0x1f, 0x6a, 0xfd, 0xf2, 0xba, 0xa1, 0xda, 0x0f, 0xf8, 0x6e, 0x9d, 0x95, 0xe5, 0x57, 0x8b, 0xdf, 0xd6, 0xb7, + 0x07, 0xee, 0x25, 0x83, 0xe2, 0x17, 0xf8, 0x2a, 0x22, 0x03, 0xee, 0x97, 0xd5, 0x9a, 0x4c, 0x8a, 0xe3, 0x27, 0x74, + 0x8c, 0x65, 0x8a, 0xf2, 0x48, 0xd3, 0x76, 0xb7, 0xde, 0xa8, 0xd1, 0xb1, 0xe1, 0x93, 0x9d, 0xa9, 0xcb, 0x07, 0x64, + 0x64, 0x08, 0xdb, 0xff, 0x54, 0x5e, 0x9c, 0x0e, 0x88, 0x36, 0xd8, 0xbf, 0x65, 0x86, 0xd0, 0x3a, 0x6c, 0x26, 0xb5, + 0x1a, 0xc2, 0xb1, 0x1f, 0x56, 0x57, 0xff, 0xbf, 0xf8, 0x52, 0x1a, 0x0d, 0x44, 0x6f, 0xd5, 0x5b, 0x02, 0x25, 0xb0, + 0x5e, 0xed, 0x52, 0xea, 0xaf, 0x4e, 0x61, 0x13, 0xe3, 0xb2, 0xe4, 0x75, 0xed, 0xce, 0xd0, 0xfa, 0x49, 0xab, 0x0d, + 0xb9, 0x7f, 0xda, 0xd0, 0xe3, 0x10, 0x23, 0x29, 0x6b, 0x13, 0x63, 0x86, 0x86, 0x10, 0xb3, 0x45, 0x19, 0x83, 0xbb, + 0xfe, 0x89, 0x44, 0x6d, 0x9c, 0x44, 0x68, 0x38, 0xcf, 0xdb, 0x60, 0xed, 0xd5, 0xdd, 0xd2, 0x14, 0x37, 0xc3, 0x95, + 0xa9, 0x4b, 0xc0, 0x7c, 0x62, 0xf0, 0xc5, 0x0e, 0x16, 0x14, 0xf0, 0x12, 0x74, 0x93, 0x71, 0xd3, 0x10, 0x7d, 0xb0, + 0xf1, 0xe6, 0xcf, 0x3d, 0xc7, 0xfc, 0xcc, 0xb7, 0x83, 0x35, 0xa8, 0x9d, 0x00, 0x27, 0x3a, 0xd0, 0xf5, 0x59, 0xb5, + 0xa4, 0xfa, 0xe6, 0xf0, 0xaf, 0x4d, 0xe5, 0x77, 0xc7, 0x86, 0x6f, 0xb5, 0xb9, 0x00, 0xbc, 0x9e, 0x19, 0x76, 0x08, + 0xb4, 0x06, 0x75, 0x4e, 0x61, 0xdc, 0x5d, 0x40, 0xad, 0x7b, 0x8d, 0xeb, 0x9b, 0x22, 0x42, 0x18, 0xb8, 0x2c, 0xa8, + 0xec, 0xf6, 0x1b, 0xcc, 0x5b, 0xdf, 0x17, 0xa8, 0x01, 0xc2, 0x43, 0x19, 0xda, 0x16, 0x19, 0x77, 0xee, 0x0d, 0x36, + 0x4b, 0x58, 0xe7, 0x52, 0x4e, 0xb9, 0xa6, 0x74, 0x1d, 0xaa, 0x8f, 0x9b, 0xa2, 0x97, 0x18, 0x90, 0x23, 0x88, 0xa5, + 0x9e, 0x01, 0xab, 0x86, 0x8b, 0xf4, 0x32, 0x4d, 0xd2, 0x29, 0x5f, 0x06, 0x88, 0xad, 0xeb, 0x44, 0xa3, 0xfb, 0x6c, + 0x29, 0x0f, 0x3d, 0x88, 0x21, 0x24, 0x24, 0x92, 0x52, 0x50, 0x3f, 0x90, 0x49, 0xb9, 0xfc, 0x0f, 0x2b, 0xf1, 0x2a, + 0x4f, 0xc7, 0x5f, 0x9e, 0x4e, 0x56, 0xd5, 0x83, 0x0f, 0x84, 0x1f, 0xe8, 0xbe, 0x75, 0xbc, 0x56, 0x6b, 0xcf, 0x57, + 0x75, 0x93, 0x1c, 0xfd, 0xc4, 0xbe, 0xe4, 0x1f, 0xb4, 0xa5, 0xce, 0x4d, 0x78, 0x16, 0x57, 0xc2, 0x9a, 0xe9, 0xf2, + 0xe5, 0x3d, 0x54, 0x79, 0x24, 0x69, 0x3c, 0x4d, 0x59, 0x6d, 0x1a, 0xef, 0x66, 0x8a, 0x40, 0x1b, 0x75, 0xf4, 0x0a, + 0x4e, 0x39, 0x70, 0x51, 0x87, 0x45, 0x27, 0xcb, 0x3f, 0x0b, 0x96, 0x85, 0x6e, 0x7f, 0x4b, 0x66, 0x1f, 0x27, 0x5f, + 0x6f, 0xa8, 0x5c, 0x38, 0x91, 0x43, 0x13, 0x4b, 0x5b, 0x6d, 0xc7, 0xe0, 0x4c, 0xdd, 0x79, 0x5c, 0x92, 0xe8, 0x3a, + 0x96, 0xe5, 0x79, 0x45, 0xac, 0xe3, 0xd4, 0x7b, 0x3d, 0x88, 0x90, 0x35, 0x2b, 0x7c, 0xd9, 0x7b, 0xfd, 0xd5, 0xad, + 0xd0, 0x99, 0x82, 0xac, 0x65, 0xcf, 0xa2, 0x18, 0xde, 0x85, 0xbc, 0x8a, 0xe8, 0xcb, 0xa5, 0x90, 0x15, 0x42, 0x59, + 0xc0, 0x56, 0xe9, 0x8f, 0xa3, 0x90, 0x3c, 0x3c, 0x4e, 0xf1, 0x62, 0xe6, 0x1c, 0x29, 0x77, 0x09, 0x61, 0x77, 0xc8, + 0xf2, 0x24, 0x92, 0x7a, 0xed, 0x46, 0xb0, 0x29, 0x31, 0xc5, 0xa6, 0x28, 0x72, 0x83, 0x5d, 0x10, 0x1c, 0x75, 0xab, + 0x6f, 0x34, 0x6d, 0x24, 0x0c, 0x12, 0xf9, 0xce, 0x08, 0xe9, 0x53, 0xdf, 0xdc, 0xbd, 0xe9, 0x07, 0x53, 0xc6, 0x20, + 0x02, 0x1e, 0x45, 0xcb, 0x00, 0xda, 0x9e, 0xaf, 0xd2, 0x2e, 0x19, 0x0f, 0x33, 0x18, 0x71, 0x5b, 0x01, 0xb9, 0x2e, + 0x1a, 0xb7, 0xe1, 0x97, 0xf0, 0x24, 0x51, 0x3c, 0x4d, 0x0b, 0x45, 0x23, 0x52, 0x79, 0x36, 0x24, 0x6b, 0x9e, 0x04, + 0x0b, 0x52, 0x4f, 0x1a, 0xcc, 0x86, 0xc1, 0x62, 0x34, 0x92, 0xb0, 0x4f, 0x4d, 0x86, 0xb1, 0x32, 0xec, 0x1c, 0xfd, + 0x4b, 0x9b, 0xd3, 0x16, 0x6b, 0x53, 0x0b, 0xb5, 0x99, 0xd1, 0x83, 0x19, 0x6f, 0x8c, 0xd4, 0xb0, 0x6a, 0x86, 0xf1, + 0x45, 0xa6, 0x76, 0x3a, 0x65, 0x14, 0x25, 0xc6, 0x69, 0x30, 0x77, 0x0c, 0x39, 0x54, 0x3f, 0x60, 0xb3, 0x82, 0xdc, + 0x55, 0x9d, 0xcd, 0xbd, 0x66, 0xdc, 0x5e, 0xd7, 0x8c, 0x3e, 0xf5, 0x4f, 0xb7, 0xfe, 0x73, 0x99, 0xae, 0xdb, 0xb1, + 0xca, 0x5f, 0xfa, 0x79, 0x37, 0x7d, 0x68, 0x31, 0x6f, 0xca, 0xce, 0x30, 0xc3, 0xeb, 0xcf, 0xa7, 0xc5, 0x83, 0xa2, + 0x81, 0xcd, 0x97, 0x6a, 0xe3, 0x70, 0xfd, 0xfb, 0x81, 0xad, 0xb7, 0xbb, 0xb9, 0x93, 0xa4, 0x21, 0xb6, 0x1c, 0x21, + 0x37, 0x82, 0x63, 0x02, 0xfe, 0xe3, 0x04, 0xf9, 0xdf, 0x3b, 0xf4, 0x6d, 0x7b, 0x10, 0x3e, 0xc6, 0xeb, 0x1e, 0x46, + 0x01, 0x73, 0xd6, 0xb2, 0x5e, 0x7d, 0x1a, 0x57, 0x45, 0xfa, 0x2b, 0x82, 0xfa, 0x8d, 0x23, 0xf8, 0x47, 0x57, 0x25, + 0xbf, 0xd3, 0x65, 0xd4, 0xbe, 0xfb, 0xdc, 0x0f, 0xd6, 0xa8, 0x32, 0x8e, 0xee, 0xcd, 0x69, 0x4b, 0x4a, 0x7b, 0x52, + 0xbe, 0xd5, 0x1e, 0x9e, 0xb6, 0x42, 0x9a, 0xb3, 0x79, 0x4f, 0x2e, 0xe7, 0x51, 0x82, 0x6d, 0x39, 0x8e, 0x70, 0x07, + 0xf9, 0xfa, 0x94, 0x51, 0x3a, 0x7a, 0x97, 0xe5, 0xed, 0xde, 0x04, 0x36, 0xf3, 0xf4, 0x04, 0xcc, 0x68, 0xda, 0x95, + 0x7e, 0xbf, 0x15, 0x27, 0xe6, 0xc3, 0xf6, 0x2e, 0xfb, 0x35, 0xae, 0xb4, 0x00, 0x8f, 0x7b, 0x5f, 0xb5, 0xfd, 0x6b, + 0xdb, 0x43, 0xdc, 0x8c, 0x14, 0x83, 0xb7, 0xf9, 0x2a, 0x4b, 0xa2, 0x02, 0x59, 0xf0, 0x1a, 0xf9, 0x20, 0xb6, 0x05, + 0x20, 0x67, 0xb4, 0x46, 0x2d, 0xfd, 0x8e, 0x25, 0xf1, 0x7c, 0x5b, 0x81, 0x9a, 0xf3, 0xec, 0xac, 0xa2, 0x55, 0x77, + 0xc2, 0x57, 0xa7, 0x9c, 0xa5, 0xd9, 0x85, 0xe8, 0x7a, 0xf8, 0xcc, 0x52, 0x54, 0xb2, 0x6c, 0x78, 0x37, 0xc6, 0xaf, + 0xd8, 0x2b, 0xcf, 0x50, 0xf2, 0xae, 0x94, 0x86, 0x42, 0x41, 0xb6, 0x06, 0xf5, 0xad, 0xb3, 0x97, 0x58, 0xdc, 0x68, + 0x79, 0x94, 0xab, 0xf0, 0xc5, 0xdc, 0xc7, 0xed, 0x71, 0x54, 0x15, 0x73, 0x0e, 0x61, 0x4f, 0x02, 0x3a, 0x69, 0x90, + 0x03, 0xa4, 0xd5, 0x65, 0x11, 0x36, 0x48, 0xa1, 0x5e, 0x8e, 0x7b, 0x94, 0x2b, 0xda, 0x8e, 0x05, 0x64, 0x2c, 0xba, + 0xcb, 0x8c, 0x4c, 0xe7, 0xb1, 0x13, 0xdd, 0x87, 0x2e, 0x17, 0x28, 0x30, 0x58, 0x9f, 0xb5, 0xe4, 0x92, 0xc7, 0x8a, + 0xa3, 0xec, 0x4a, 0x0c, 0x94, 0x67, 0x43, 0xd6, 0x6b, 0x7c, 0xc5, 0x02, 0xac, 0xe9, 0x76, 0x8e, 0x85, 0x0a, 0x96, + 0x7d, 0xff, 0x0b, 0x9f, 0x96, 0x8c, 0x9c, 0xca, 0x24, 0x96, 0xa5, 0x0f, 0x73, 0xe3, 0x86, 0xe0, 0x09, 0x41, 0x33, + 0x49, 0xe6, 0x29, 0xa7, 0x14, 0x4a, 0xeb, 0x7f, 0xae, 0x3c, 0x42, 0xd5, 0x6c, 0xdd, 0xf4, 0x96, 0x71, 0x77, 0x09, + 0x8d, 0xff, 0x21, 0x3a, 0x56, 0x71, 0xc1, 0xfb, 0xf3, 0x44, 0x92, 0x9c, 0x0a, 0x65, 0x2d, 0x9b, 0x17, 0x5b, 0xc8, + 0xa0, 0xe3, 0x96, 0x72, 0x08, 0xe4, 0x00, 0x60, 0x7a, 0xd5, 0x86, 0xba, 0xc6, 0x3e, 0x77, 0xbd, 0x21, 0x21, 0x56, + 0x04, 0xbb, 0xa1, 0x13, 0x24, 0xd4, 0x54, 0x21, 0xf1, 0x59, 0xaf, 0xf2, 0x6e, 0x14, 0x85, 0x1e, 0xf0, 0x8f, 0x7f, + 0x93, 0x88, 0xf3, 0x37, 0x58, 0xaa, 0xdf, 0xb0, 0x4a, 0x1b, 0xfa, 0xe4, 0x5f, 0x24, 0x5e, 0x75, 0xfe, 0x29, 0x66, + 0x9a, 0x6d, 0x87, 0xee, 0x67, 0x7e, 0x3e, 0xe1, 0x51, 0xf6, 0xc2, 0x21, 0x63, 0x0d, 0x19, 0x3a, 0x86, 0x2e, 0x12, + 0x6c, 0xf2, 0x97, 0x14, 0xfa, 0x64, 0x5a, 0xfa, 0x8a, 0xdf, 0x69, 0xdd, 0x9d, 0xad, 0x42, 0x21, 0x16, 0xcc, 0x50, + 0x4a, 0xa3, 0xee, 0x98, 0xea, 0x98, 0x59, 0x98, 0xe3, 0x90, 0x24, 0xa2, 0x45, 0x0e, 0x67, 0xb8, 0xbf, 0x01, 0x08, + 0x81, 0x06, 0x2b, 0x11, 0x2a, 0xca, 0xc5, 0x1e, 0xc1, 0x13, 0x6e, 0xb6, 0xb9, 0xdf, 0xc9, 0x3c, 0x9c, 0x48, 0xa3, + 0x5c, 0xc1, 0x02, 0x30, 0xd5, 0xb3, 0x1b, 0x49, 0xc9, 0xe1, 0x5e, 0xb4, 0xc6, 0xf9, 0x0c, 0x25, 0x94, 0xc5, 0xce, + 0x83, 0x60, 0x5d, 0x65, 0x53, 0xd9, 0x19, 0xcc, 0xaa, 0xee, 0x1c, 0xa8, 0xe2, 0x02, 0x89, 0xba, 0x31, 0x26, 0x53, + 0xcc, 0xb2, 0x19, 0x7e, 0x02, 0x31, 0x6f, 0xc8, 0x54, 0x70, 0xf7, 0x5a, 0x9d, 0x2d, 0xef, 0x1a, 0x26, 0x94, 0xa1, + 0x81, 0xd5, 0x49, 0x8c, 0x1a, 0x96, 0x70, 0x71, 0xc1, 0x67, 0xd0, 0x9f, 0x06, 0x42, 0x33, 0x3a, 0xbd, 0x19, 0xa3, + 0x7e, 0xcb, 0xc6, 0x93, 0xef, 0x15, 0xe7, 0xbd, 0xee, 0xf0, 0x4a, 0x25, 0x54, 0x25, 0x5f, 0x46, 0x88, 0xe8, 0x56, + 0x5f, 0x2a, 0x9e, 0x53, 0xf7, 0xde, 0x2f, 0x24, 0x9e, 0xf4, 0x99, 0x91, 0xc7, 0xfb, 0x5d, 0x28, 0x28, 0x80, 0xde, + 0xb2, 0x08, 0x99, 0x7e, 0x50, 0x56, 0xd5, 0x1d, 0x9e, 0x5c, 0xda, 0x95, 0x50, 0xf1, 0xba, 0x7e, 0xb3, 0x3c, 0x81, + 0x2a, 0x4c, 0x66, 0x28, 0xe6, 0xd8, 0x54, 0x8e, 0xc6, 0x1b, 0x4c, 0x23, 0x18, 0xe7, 0x39, 0xa1, 0x02, 0xfd, 0x50, + 0x25, 0x9a, 0x3a, 0x33, 0x73, 0xc6, 0xf2, 0xba, 0x0f, 0x7b, 0x3e, 0x77, 0x8a, 0xd9, 0x85, 0x57, 0xfb, 0x96, 0x3a, + 0x6e, 0x9f, 0x05, 0x97, 0xe5, 0xee, 0x16, 0x85, 0xec, 0x29, 0x95, 0xc4, 0x38, 0x80, 0x75, 0x1e, 0x5d, 0xd9, 0x9a, + 0x2e, 0x65, 0xb0, 0xfb, 0x13, 0xa4, 0x00, 0x8e, 0x96, 0x0c, 0x24, 0x60, 0x37, 0xf2, 0x6b, 0xd7, 0x64, 0xe6, 0x9b, + 0x8f, 0x03, 0x0b, 0x82, 0xc8, 0x04, 0xce, 0x10, 0x31, 0x91, 0x86, 0xf0, 0xf3, 0x3e, 0xce, 0xbe, 0xda, 0x4c, 0x34, + 0x51, 0x7b, 0x23, 0xe4, 0xf3, 0xf0, 0x1a, 0x76, 0xf3, 0xc0, 0x94, 0xf7, 0x5b, 0x3a, 0x45, 0x1c, 0x34, 0x89, 0xa9, + 0xd5, 0x33, 0xf6, 0x5b, 0xe6, 0x72, 0xc3, 0x2f, 0xc4, 0x14, 0x77, 0x77, 0x71, 0x2a, 0x0c, 0x2c, 0x99, 0xf0, 0xcb, + 0x83, 0xa9, 0x89, 0x29, 0x7b, 0x88, 0xef, 0xfb, 0xf0, 0xe1, 0x71, 0x63, 0xf6, 0xc9, 0x5d, 0x71, 0x9d, 0x58, 0xaa, + 0xb0, 0xaf, 0xe9, 0xeb, 0x21, 0x63, 0x4e, 0x44, 0xd2, 0x52, 0x99, 0xae, 0x0f, 0x36, 0xfe, 0xac, 0x62, 0xc9, 0xca, + 0x11, 0xb6, 0x46, 0x80, 0xf4, 0x4b, 0x83, 0xa6, 0xe1, 0x90, 0x7a, 0x18, 0xfa, 0x40, 0x8a, 0x39, 0xc1, 0xc0, 0xd1, + 0x25, 0x71, 0x6d, 0xeb, 0x70, 0x58, 0x24, 0x3d, 0x96, 0x68, 0xe9, 0xe7, 0x6e, 0x73, 0x7e, 0xb6, 0x07, 0xc7, 0xc2, + 0x65, 0xe5, 0x65, 0x65, 0x5e, 0x78, 0xc6, 0xc9, 0x62, 0xaf, 0x5a, 0x35, 0x7e, 0xe7, 0xf7, 0x7d, 0x2d, 0x99, 0x83, + 0x91, 0x1b, 0x99, 0x2b, 0x5a, 0x78, 0x30, 0xef, 0xe4, 0x15, 0x34, 0x6e, 0xb6, 0x12, 0x87, 0x12, 0xda, 0x7a, 0x70, + 0xea, 0xfd, 0x99, 0x02, 0x57, 0x10, 0x28, 0xbc, 0x7e, 0x3f, 0x1e, 0x6f, 0xc8, 0x68, 0x73, 0x85, 0x0c, 0x7a, 0x6e, + 0xf5, 0x02, 0xd5, 0x79, 0xdf, 0x7c, 0x3e, 0x67, 0x6f, 0xcc, 0xb3, 0xee, 0x63, 0x48, 0x7d, 0x64, 0x88, 0x1a, 0xb2, + 0xbc, 0x16, 0x0a, 0x93, 0x05, 0xf4, 0x38, 0xaa, 0x2a, 0x44, 0x56, 0x87, 0xb2, 0x71, 0x33, 0x54, 0xd8, 0x4f, 0xaf, + 0x7f, 0x80, 0x11, 0x72, 0x94, 0x52, 0x68, 0x4f, 0x4c, 0x55, 0x46, 0x08, 0x81, 0xb1, 0x21, 0x1a, 0x96, 0x91, 0x29, + 0x6c, 0xb3, 0x8a, 0x76, 0x9c, 0xae, 0xec, 0xd6, 0x37, 0xab, 0x14, 0x73, 0xde, 0x0d, 0x9e, 0x25, 0x68, 0xf7, 0xf6, + 0xb6, 0xc7, 0x31, 0xf4, 0x53, 0xf1, 0x3f, 0x82, 0x1d, 0x9d, 0xc3, 0x12, 0x15, 0x9c, 0x12, 0xfb, 0x9c, 0xf9, 0xab, + 0x63, 0x25, 0x8e, 0x7b, 0xda, 0xe2, 0xde, 0x8e, 0x1d, 0x33, 0x2b, 0x3f, 0x36, 0x59, 0x72, 0x2d, 0x43, 0x12, 0xd5, + 0x35, 0x97, 0x8e, 0x41, 0x53, 0x22, 0x37, 0x6f, 0x66, 0x96, 0xf6, 0x06, 0xcc, 0x8f, 0xf6, 0x41, 0xfb, 0x25, 0x21, + 0xc2, 0x6a, 0xa9, 0x99, 0x8b, 0x2f, 0x71, 0xca, 0x38, 0xc9, 0x7d, 0x03, 0xe6, 0xef, 0xc4, 0xf5, 0xef, 0xa2, 0x07, + 0x87, 0x39, 0x02, 0x18, 0x88, 0xb7, 0x52, 0x6d, 0xe5, 0x4d, 0x44, 0x69, 0x05, 0x86, 0x5d, 0x9b, 0xca, 0x86, 0xa3, + 0x21, 0x7f, 0xc3, 0x23, 0xfb, 0x32, 0x5f, 0x6f, 0x6c, 0xa1, 0x38, 0xf1, 0x2e, 0xff, 0xfc, 0xd3, 0x87, 0xe7, 0xc7, + 0x9c, 0x0b, 0x76, 0x73, 0xe3, 0xbc, 0x6a, 0x13, 0xc8, 0xb6, 0x5d, 0x0c, 0x12, 0x9c, 0x42, 0x23, 0x27, 0x40, 0xfa, + 0xe1, 0xc2, 0x22, 0xc4, 0xcf, 0xdf, 0x3d, 0xd9, 0xf5, 0xf6, 0x3a, 0x6c, 0x46, 0xb3, 0xbd, 0x23, 0x1a, 0x41, 0x4e, + 0x57, 0xc7, 0xec, 0xfb, 0xe4, 0x60, 0xfc, 0x4b, 0xe2, 0x7e, 0xa6, 0xca, 0xcf, 0x35, 0xd7, 0x37, 0x55, 0x7e, 0xea, + 0xe0, 0xc6, 0x27, 0xb0, 0x6a, 0x53, 0x72, 0x13, 0xe6, 0xca, 0xad, 0x3e, 0x79, 0x4c, 0x0c, 0xa6, 0xd5, 0x3f, 0x7d, + 0x7f, 0x1a, 0x06, 0x06, 0x17, 0xbb, 0x3b, 0x4f, 0xbe, 0xce, 0xf4, 0xc7, 0x79, 0xdf, 0xb1, 0xfb, 0x3a, 0xf8, 0x71, + 0x5c, 0x5d, 0xd6, 0x23, 0x49, 0x23, 0x07, 0xc4, 0x7b, 0xca, 0xa8, 0x61, 0x2f, 0x77, 0x95, 0x87, 0x55, 0xfd, 0x7d, + 0xd6, 0xbb, 0x44, 0xef, 0x8e, 0x9d, 0x65, 0xad, 0x26, 0x94, 0x17, 0x98, 0xd3, 0x79, 0x4c, 0xb3, 0x42, 0x47, 0x85, + 0x9a, 0x89, 0xf6, 0x32, 0xb2, 0x4a, 0x7f, 0xf3, 0x4b, 0xda, 0xaf, 0x16, 0xc1, 0xb0, 0xac, 0xc2, 0xe5, 0x3c, 0x6a, + 0xb0, 0x59, 0xbb, 0x36, 0x7f, 0xfd, 0xef, 0x69, 0xc3, 0xce, 0x04, 0x51, 0x7d, 0x52, 0x2b, 0x79, 0xd6, 0x77, 0xb8, + 0xea, 0xf6, 0x7c, 0xbe, 0x91, 0x79, 0xaf, 0x9e, 0x2f, 0x9a, 0x8f, 0xb7, 0x5f, 0xbb, 0x07, 0xe0, 0x97, 0x5d, 0x59, + 0xab, 0x37, 0x2b, 0x8b, 0x21, 0xf5, 0x9e, 0xf5, 0x7e, 0x21, 0x53, 0x02, 0x03, 0x52, 0x5f, 0xf8, 0xbc, 0x76, 0x1d, + 0xf4, 0x3a, 0x2a, 0xdd, 0x7e, 0x59, 0xb4, 0x16, 0x85, 0x94, 0x27, 0x92, 0x52, 0x92, 0x4d, 0x5c, 0xc5, 0xcc, 0x30, + 0xcd, 0x3b, 0xbf, 0xa9, 0x27, 0xfd, 0x55, 0x6d, 0xf6, 0xf5, 0xd6, 0x66, 0x6f, 0x08, 0xaf, 0x79, 0x8a, 0xb0, 0x7a, + 0xb7, 0x4e, 0x39, 0x5e, 0xbd, 0xed, 0xf4, 0x2f, 0x5a, 0xfb, 0xf4, 0xbd, 0x5b, 0xc3, 0xd8, 0xa8, 0x9c, 0x15, 0xca, + 0x6f, 0x72, 0x4a, 0xc3, 0x35, 0xa3, 0x0d, 0x1b, 0x61, 0x8a, 0x7d, 0xb9, 0x7a, 0xb7, 0x3a, 0x61, 0x85, 0x48, 0xef, + 0xc1, 0x33, 0xc2, 0xe3, 0xcd, 0x1f, 0x24, 0x54, 0xfd, 0x82, 0x8c, 0xe5, 0x8d, 0xba, 0xb5, 0x68, 0x3c, 0xda, 0x46, + 0xce, 0x24, 0xcc, 0x37, 0xe8, 0xa6, 0xc9, 0x6c, 0x6d, 0xc2, 0xa9, 0x23, 0xb7, 0x49, 0xb1, 0x19, 0xa9, 0x6a, 0xef, + 0x32, 0x98, 0xd2, 0x7d, 0xd2, 0x3e, 0xb1, 0xa7, 0xd4, 0x63, 0xd9, 0x19, 0x62, 0x5a, 0x10, 0xa0, 0xa6, 0x5c, 0xb5, + 0x57, 0x88, 0x65, 0x70, 0x4a, 0x5b, 0x4f, 0xb6, 0xcf, 0x30, 0x5b, 0x34, 0x93, 0x10, 0x9c, 0x15, 0x5a, 0x36, 0xdd, + 0xa4, 0xad, 0x04, 0x2f, 0x23, 0xd5, 0x68, 0xb4, 0x99, 0xe0, 0xb1, 0xf3, 0x5e, 0x34, 0xf3, 0x43, 0x87, 0xb4, 0xb0, + 0x16, 0x25, 0xfc, 0xc2, 0x91, 0x9c, 0xa5, 0x8d, 0xe0, 0xb4, 0x86, 0xee, 0xde, 0x35, 0xaf, 0xb7, 0xf7, 0x23, 0x1f, + 0xd8, 0x78, 0xd3, 0x88, 0x34, 0xc7, 0x7a, 0xc3, 0xbe, 0x09, 0xc6, 0x04, 0x1c, 0x78, 0xe6, 0xe5, 0x2f, 0x9e, 0x00, + 0xe7, 0x07, 0xd8, 0x90, 0x5e, 0xe6, 0xab, 0x8a, 0x60, 0x25, 0xaa, 0x34, 0xe3, 0xc2, 0xec, 0x31, 0xe8, 0xbb, 0x6d, + 0xe9, 0x37, 0xe3, 0xcf, 0x4c, 0x1c, 0xa5, 0x70, 0xf2, 0x9c, 0x6e, 0x2c, 0xdc, 0x43, 0x02, 0xbe, 0x21, 0xab, 0x9e, + 0x78, 0x93, 0xd3, 0xb8, 0xc1, 0xf5, 0x9b, 0x57, 0xb3, 0x13, 0x3e, 0x28, 0xcd, 0x0a, 0x10, 0xb2, 0xeb, 0x50, 0xd9, + 0xf0, 0x32, 0x53, 0x55, 0x7b, 0xad, 0x9c, 0xdc, 0x2f, 0xc4, 0x88, 0x82, 0x52, 0x31, 0x1f, 0x1f, 0xc8, 0x28, 0x8d, + 0xe2, 0xa2, 0xe4, 0xde, 0x43, 0x8a, 0x5d, 0x73, 0xde, 0xd0, 0x29, 0x3f, 0xa7, 0x81, 0xb6, 0x7e, 0x37, 0x14, 0x5e, + 0xfd, 0xee, 0x1e, 0x8d, 0x1d, 0xdc, 0x7a, 0x7a, 0xeb, 0x64, 0xbd, 0xb1, 0xb5, 0xf9, 0x08, 0x39, 0x35, 0x20, 0x7e, + 0x63, 0xc2, 0x1f, 0x7d, 0x7b, 0xa9, 0x29, 0xac, 0xa1, 0xb1, 0x8f, 0x6c, 0x6e, 0xc4, 0x56, 0x78, 0xe3, 0xd4, 0x0a, + 0x5f, 0x82, 0x28, 0x16, 0xe3, 0x17, 0x3f, 0x6b, 0x34, 0xb8, 0xa6, 0x12, 0x1a, 0x0e, 0x09, 0xee, 0x45, 0x91, 0xa7, + 0x9f, 0xba, 0xf8, 0x59, 0x5c, 0xbc, 0x98, 0xaf, 0x86, 0xc4, 0xcc, 0xd3, 0xb6, 0xd2, 0x62, 0xd9, 0xb4, 0x15, 0x3f, + 0x5b, 0x13, 0x0d, 0x77, 0xd1, 0x1a, 0x9f, 0xd5, 0xd8, 0x56, 0x55, 0xaa, 0x1f, 0xca, 0xef, 0x7b, 0x1b, 0x9b, 0x4c, + 0x9d, 0x81, 0x0e, 0x92, 0x86, 0xa4, 0x97, 0x8a, 0x6e, 0x81, 0x8c, 0x3d, 0x3d, 0x26, 0x0d, 0x4b, 0xc4, 0x58, 0x05, + 0xa1, 0x9c, 0x61, 0xd6, 0x8e, 0x72, 0xf3, 0xb0, 0xde, 0x42, 0xaf, 0xd8, 0x6d, 0x4c, 0x7a, 0xea, 0x8d, 0x65, 0x79, + 0xd6, 0xaa, 0xfb, 0x1c, 0x05, 0x14, 0xff, 0x1c, 0xee, 0xc0, 0x1f, 0x6e, 0x0d, 0x9a, 0xbd, 0x51, 0xb9, 0xd8, 0xd4, + 0xeb, 0x10, 0x6f, 0xd2, 0x1d, 0x8f, 0x25, 0x64, 0x21, 0xa2, 0xf1, 0x4d, 0x37, 0x05, 0x0c, 0xcd, 0x54, 0x46, 0x1d, + 0x4b, 0xe3, 0x28, 0xa8, 0x88, 0x15, 0xd9, 0x3b, 0x47, 0xe4, 0x55, 0x41, 0x85, 0x20, 0xad, 0x59, 0x36, 0x09, 0x99, + 0x7f, 0x1a, 0x64, 0x40, 0x49, 0x61, 0x13, 0xfd, 0x69, 0x13, 0x27, 0x85, 0x04, 0xdc, 0xad, 0xec, 0xa2, 0x8b, 0xad, + 0xa9, 0x15, 0xfa, 0x0c, 0x46, 0x5b, 0x70, 0x14, 0xb2, 0x2a, 0x44, 0x0b, 0xcd, 0x7c, 0xc3, 0xbf, 0x45, 0x9e, 0x02, + 0x12, 0x44, 0x41, 0x13, 0x0e, 0x65, 0x37, 0xdd, 0x41, 0x8a, 0x74, 0xf4, 0x10, 0xc1, 0x07, 0xa4, 0x84, 0x0a, 0xd0, + 0x79, 0x1e, 0xd7, 0xdd, 0x4b, 0x4d, 0x23, 0x2a, 0x23, 0x1b, 0x7c, 0x4f, 0x07, 0x45, 0xae, 0x57, 0xac, 0xf4, 0xff, + 0x1f, 0x79, 0x2c, 0xe5, 0x05, 0xec, 0x50, 0xc0, 0x9b, 0x0f, 0xd8, 0x42, 0x8a, 0x43, 0xad, 0x9e, 0x2f, 0x28, 0x51, + 0x1c, 0x49, 0x34, 0xbd, 0xaf, 0x68, 0xa7, 0x47, 0x8c, 0x32, 0xf1, 0xe1, 0x24, 0x50, 0xb7, 0xcd, 0xad, 0xba, 0x64, + 0xb8, 0xba, 0xca, 0xda, 0x7f, 0x3a, 0xec, 0xab, 0xa9, 0x82, 0x0b, 0x3b, 0xbd, 0xb8, 0x83, 0x60, 0x0a, 0x85, 0x1e, + 0x3b, 0x7f, 0x87, 0x89, 0xca, 0xea, 0x2f, 0x24, 0x52, 0xac, 0x5a, 0x2e, 0x43, 0x03, 0x1c, 0xc4, 0x4d, 0x81, 0xda, + 0x11, 0x0c, 0x7a, 0xc6, 0x2e, 0x41, 0x1a, 0xcb, 0x72, 0x49, 0x65, 0x38, 0x69, 0xa5, 0xd5, 0xe7, 0x93, 0x23, 0xe4, + 0x31, 0xde, 0x49, 0xad, 0x12, 0x15, 0x9c, 0x3d, 0x96, 0x65, 0x6d, 0xd4, 0x73, 0xd8, 0x8c, 0xce, 0xb2, 0x8a, 0x5a, + 0xe7, 0xda, 0x6a, 0xa7, 0x14, 0x4a, 0x75, 0x2c, 0x08, 0x36, 0x2d, 0x1c, 0x0f, 0xd2, 0xc2, 0x0e, 0xf4, 0x74, 0x42, + 0x8d, 0x4b, 0x9a, 0x1d, 0x52, 0x91, 0x77, 0x8b, 0x8e, 0xd0, 0x4c, 0xa7, 0x1b, 0x74, 0x53, 0x1e, 0x2b, 0x82, 0x3a, + 0x98, 0xd9, 0x11, 0x86, 0x57, 0x87, 0x61, 0x3c, 0x47, 0x5f, 0x52, 0x36, 0xc4, 0xca, 0x15, 0xb7, 0xb3, 0x36, 0xa5, + 0x83, 0xb7, 0x0a, 0x3f, 0x34, 0x8f, 0xca, 0xc4, 0xe2, 0xa8, 0x58, 0xa1, 0xc4, 0x35, 0xcd, 0x68, 0x8b, 0xab, 0xa5, + 0x9b, 0x18, 0x23, 0xe4, 0x2b, 0xe6, 0xaf, 0x91, 0x32, 0xcc, 0x0d, 0x64, 0x0d, 0xd2, 0x45, 0x32, 0xc5, 0x2c, 0x4c, + 0x90, 0x71, 0xc0, 0x98, 0xf8, 0x3e, 0x5b, 0x5c, 0xfa, 0x33, 0x70, 0x85, 0x63, 0x36, 0xad, 0xa4, 0xfb, 0x10, 0x14, + 0xba, 0xef, 0xf1, 0xa0, 0x41, 0x3d, 0x76, 0x10, 0x3d, 0x53, 0x70, 0xf9, 0x5c, 0x62, 0xa3, 0x7e, 0x6f, 0xd8, 0xd9, + 0x11, 0x8a, 0xcd, 0x86, 0x04, 0x03, 0xcc, 0x27, 0x4a, 0xf7, 0x3e, 0xf8, 0xd0, 0xd2, 0x2f, 0x5f, 0xdf, 0x22, 0x84, + 0x0c, 0xe5, 0x4e, 0x94, 0x9a, 0x29, 0x81, 0x8a, 0xa6, 0xe6, 0xa0, 0x99, 0x0f, 0x4e, 0xdc, 0xed, 0xf0, 0x7a, 0x42, + 0x2f, 0x57, 0xbb, 0x75, 0x4f, 0xe5, 0xe5, 0x8a, 0x34, 0x42, 0xab, 0x4f, 0x1b, 0x95, 0x0f, 0xe6, 0xb1, 0xb8, 0x5e, + 0xe9, 0xae, 0x1f, 0xb8, 0x43, 0xab, 0xdd, 0xc5, 0x87, 0xd2, 0x32, 0x3e, 0xd4, 0x93, 0xac, 0xd7, 0x60, 0x11, 0x78, + 0xa8, 0xb1, 0x14, 0xcd, 0xf1, 0x26, 0xeb, 0xd9, 0x60, 0x53, 0xa3, 0xd9, 0x58, 0x4a, 0xcb, 0xdf, 0xdb, 0xb8, 0x5f, + 0x67, 0x53, 0x85, 0xd2, 0x87, 0x81, 0xf4, 0x3e, 0x81, 0x92, 0x39, 0x0a, 0xdd, 0xe9, 0x0c, 0x95, 0x8f, 0xe6, 0x09, + 0x30, 0x56, 0xf0, 0x8b, 0x4b, 0x1a, 0xd3, 0x59, 0x73, 0x9c, 0xaf, 0xe0, 0xd4, 0xe2, 0xa8, 0xb1, 0x75, 0xe9, 0x89, + 0x20, 0xbc, 0x5a, 0xdc, 0x12, 0xbd, 0x24, 0xe9, 0xa8, 0x23, 0x25, 0xfe, 0x53, 0x8c, 0x73, 0xaa, 0xa9, 0xa3, 0x9d, + 0x4d, 0xe3, 0x0d, 0x15, 0x9c, 0x01, 0x35, 0x39, 0x6c, 0xfb, 0x12, 0x0a, 0xe0, 0xff, 0x9d, 0xa6, 0x12, 0xf1, 0x32, + 0x11, 0x37, 0xab, 0x0a, 0x65, 0x40, 0x19, 0x01, 0x94, 0x5f, 0xab, 0x91, 0xd1, 0x37, 0x7e, 0x34, 0x51, 0x9f, 0xc7, + 0x98, 0x03, 0x1d, 0xb4, 0x34, 0xf9, 0x1b, 0x38, 0x22, 0xd9, 0x36, 0xc7, 0x66, 0x06, 0x55, 0x5b, 0x3c, 0x09, 0xbc, + 0x44, 0x34, 0x56, 0xb3, 0x7e, 0x8c, 0xdf, 0xa6, 0x73, 0x3f, 0x78, 0xeb, 0x00, 0xfc, 0xc2, 0x82, 0x6a, 0x67, 0xd6, + 0xea, 0x7b, 0x09, 0x1a, 0xf0, 0xa3, 0xd8, 0xe8, 0x2c, 0x75, 0x42, 0xcd, 0xc9, 0x0e, 0xbd, 0xa9, 0xc9, 0x39, 0x27, + 0xca, 0x9e, 0x4e, 0x66, 0x1c, 0x85, 0x43, 0xd4, 0x19, 0x71, 0xf2, 0xa9, 0xf7, 0xcd, 0x8c, 0x78, 0xf4, 0x89, 0x8b, + 0x84, 0x43, 0x70, 0x4e, 0x15, 0x5b, 0x36, 0x9b, 0x8b, 0xd8, 0xfc, 0x4c, 0x8a, 0x4d, 0xc6, 0x04, 0xab, 0x05, 0xbd, + 0xbf, 0x21, 0x12, 0xc2, 0xb4, 0x21, 0x24, 0x4b, 0x53, 0x93, 0x1a, 0x8f, 0x9b, 0xab, 0x60, 0x33, 0xba, 0xc4, 0xfc, + 0x73, 0x75, 0x41, 0xa1, 0xf0, 0x8a, 0x82, 0x9f, 0xd7, 0x70, 0xec, 0x35, 0xc2, 0xd8, 0x33, 0x24, 0xfa, 0xd0, 0x0e, + 0x9a, 0x19, 0xc9, 0x2d, 0xa6, 0xf6, 0x64, 0x47, 0xe0, 0x95, 0x97, 0x21, 0xdd, 0xb0, 0xdf, 0x04, 0x94, 0xc0, 0x6b, + 0xea, 0x9b, 0xe5, 0x50, 0xe6, 0x70, 0x39, 0xdd, 0xd5, 0x6f, 0x80, 0xc6, 0xce, 0x16, 0x1e, 0xa6, 0x68, 0x1d, 0xaa, + 0x7d, 0x48, 0x5d, 0x3d, 0xb3, 0x57, 0x31, 0x47, 0x39, 0x08, 0xea, 0xc6, 0x6c, 0x13, 0xfb, 0x3a, 0x75, 0x5d, 0x03, + 0xf5, 0x7b, 0xec, 0x67, 0xa0, 0xf5, 0x30, 0xd2, 0x6c, 0x1d, 0x4f, 0xe9, 0x2d, 0x12, 0x46, 0x5b, 0x4a, 0x24, 0x0d, + 0x93, 0x66, 0x4d, 0x6a, 0x00, 0xd3, 0x22, 0xcc, 0x41, 0xfd, 0x46, 0xef, 0xd8, 0x53, 0xc2, 0x74, 0x96, 0x6d, 0xd7, + 0xa8, 0x6c, 0x92, 0x3b, 0xce, 0x15, 0x3a, 0x09, 0x29, 0x15, 0x65, 0x5f, 0x32, 0x05, 0xa9, 0x3c, 0x26, 0x9c, 0xe3, + 0x6a, 0x40, 0x32, 0x4c, 0xa9, 0xa0, 0xf6, 0xd6, 0x59, 0x4a, 0x5d, 0x72, 0xe6, 0x08, 0x9f, 0x62, 0xf9, 0xb3, 0xaa, + 0x79, 0xd8, 0x54, 0x63, 0x38, 0xed, 0xd5, 0xcb, 0xc5, 0x82, 0x07, 0xf3, 0x27, 0x70, 0x01, 0x45, 0xbe, 0xa2, 0xa6, + 0x3c, 0x97, 0x87, 0x72, 0x12, 0x7d, 0x32, 0xfe, 0x3d, 0xd5, 0x2d, 0x88, 0x5c, 0xe6, 0x33, 0x44, 0x66, 0xfa, 0x8d, + 0xfb, 0xea, 0xc3, 0x72, 0xb0, 0xa9, 0x24, 0xa6, 0x7f, 0x3f, 0x79, 0xb7, 0x42, 0x19, 0x7e, 0xe8, 0x89, 0x6d, 0xd1, + 0x52, 0xd0, 0xb3, 0xc8, 0xa8, 0xc2, 0x1c, 0x91, 0x13, 0x25, 0x70, 0x96, 0x3d, 0x4a, 0xf0, 0x92, 0x1a, 0x3a, 0x42, + 0x5b, 0x10, 0x27, 0xac, 0xa8, 0x1c, 0xc9, 0xb3, 0xbf, 0x55, 0xf5, 0x92, 0xea, 0x94, 0xc7, 0x80, 0xd5, 0x5f, 0x7b, + 0xa8, 0xbe, 0xbe, 0xb7, 0x41, 0x04, 0x5b, 0xd0, 0xea, 0x71, 0xf8, 0x12, 0xc0, 0x41, 0x30, 0x59, 0x20, 0x53, 0x1e, + 0x53, 0x43, 0xf5, 0xaa, 0x6f, 0x4f, 0x8e, 0x42, 0xde, 0x80, 0x22, 0xdc, 0x12, 0x4f, 0xa7, 0xa7, 0x71, 0x29, 0x6e, + 0x3f, 0x95, 0xfe, 0x81, 0x2f, 0xc0, 0xae, 0x55, 0x0a, 0x1e, 0x60, 0x4a, 0xc2, 0x1b, 0x99, 0x0a, 0x6b, 0xf9, 0xa6, + 0xd3, 0x9b, 0x97, 0x3c, 0xc6, 0x43, 0xb8, 0xb7, 0x3b, 0x70, 0xd6, 0x57, 0xef, 0x35, 0x9a, 0xca, 0xc0, 0xc5, 0x14, + 0x6d, 0x38, 0x3a, 0xc0, 0xe5, 0x1c, 0x61, 0x59, 0xdd, 0x7d, 0x38, 0xa3, 0x54, 0x06, 0x91, 0x32, 0x3a, 0xec, 0xaa, + 0xb4, 0x98, 0xf4, 0xd8, 0x62, 0x16, 0xf2, 0x3e, 0xc5, 0x19, 0x61, 0x13, 0x51, 0x4c, 0x13, 0x7d, 0x58, 0x04, 0xe2, + 0x19, 0x18, 0xdd, 0xae, 0x5d, 0x3f, 0x90, 0xec, 0x0d, 0x43, 0x28, 0xbf, 0x54, 0xee, 0x52, 0xbb, 0xae, 0xba, 0x00, + 0x96, 0xef, 0xc2, 0x7c, 0x42, 0x90, 0xa7, 0xaf, 0x38, 0xac, 0xab, 0xdf, 0xca, 0xcc, 0x46, 0x6e, 0xac, 0x6e, 0x85, + 0x4e, 0x2e, 0xdc, 0xd6, 0x2b, 0x1d, 0xe8, 0x4c, 0x40, 0xc2, 0x07, 0xcf, 0xbe, 0x8f, 0xb6, 0x91, 0x4a, 0x32, 0x57, + 0xdc, 0x73, 0xde, 0x5b, 0x10, 0x56, 0x0f, 0x64, 0x3d, 0x22, 0x17, 0x24, 0xe8, 0x05, 0x1c, 0xcf, 0xe7, 0xd1, 0xa9, + 0x79, 0xc5, 0xde, 0x28, 0x9f, 0x18, 0x96, 0xb8, 0x99, 0x9b, 0x4f, 0x87, 0xa7, 0x88, 0xf4, 0x7d, 0x8c, 0x84, 0x83, + 0x30, 0x24, 0x55, 0xde, 0xbc, 0x91, 0x50, 0xc4, 0x53, 0xc9, 0xce, 0xb8, 0xdc, 0x9c, 0xd5, 0x88, 0xc5, 0xa5, 0x28, + 0xaf, 0x5e, 0x3b, 0x8a, 0xf2, 0x8e, 0xad, 0x5a, 0xd2, 0xce, 0xf6, 0x95, 0x32, 0xb6, 0x2a, 0x71, 0x44, 0x23, 0xa3, + 0x13, 0xb7, 0x7a, 0x51, 0x2a, 0x87, 0xac, 0x91, 0xb2, 0x81, 0x75, 0x65, 0x32, 0x0b, 0xca, 0xc3, 0xca, 0xf9, 0x22, + 0xee, 0xd8, 0x2e, 0x82, 0x56, 0xa1, 0xb0, 0x74, 0x50, 0x2f, 0x28, 0x7a, 0x3f, 0x00, 0x5b, 0x27, 0xf9, 0xdb, 0x52, + 0x74, 0xf1, 0x3d, 0x74, 0x95, 0x5d, 0xd0, 0x81, 0x30, 0x1e, 0x25, 0x69, 0x18, 0x18, 0xc8, 0x37, 0x0f, 0xb6, 0x23, + 0xd0, 0xe5, 0xf5, 0xf6, 0xa8, 0xa4, 0xd3, 0x29, 0xc8, 0x29, 0x03, 0xc0, 0x34, 0x51, 0x58, 0x5d, 0xad, 0x31, 0xfb, + 0xbb, 0xe3, 0xe2, 0x57, 0xc7, 0xae, 0x15, 0xcc, 0xf0, 0x41, 0xd8, 0xcd, 0xbd, 0xab, 0xad, 0xc1, 0x17, 0xa7, 0x8e, + 0x99, 0x58, 0x98, 0x95, 0x96, 0x3f, 0xaf, 0x37, 0xb3, 0x7f, 0x74, 0x7d, 0x4c, 0xe1, 0x03, 0x3d, 0x8e, 0x5d, 0x8b, + 0xda, 0x47, 0xf1, 0xbc, 0x94, 0xf8, 0xc2, 0xef, 0x25, 0x8f, 0x9b, 0xa1, 0xaf, 0xbe, 0x86, 0x39, 0x0c, 0xad, 0x58, + 0x1b, 0xa9, 0xfc, 0x02, 0x9b, 0xde, 0x84, 0x3b, 0x71, 0xc4, 0x80, 0x73, 0x3d, 0x47, 0x9a, 0xf9, 0xcc, 0x64, 0xeb, + 0x99, 0xa4, 0xd1, 0x30, 0x1d, 0x89, 0x38, 0x6a, 0x01, 0x2a, 0x3e, 0x71, 0x70, 0x7f, 0x78, 0xc0, 0x48, 0x71, 0x18, + 0xa3, 0x1f, 0x8b, 0x7c, 0x9b, 0x12, 0xcb, 0x86, 0xaf, 0xe0, 0xb9, 0x66, 0x97, 0x3f, 0xd8, 0x6f, 0x8d, 0x8b, 0x55, + 0x8f, 0x95, 0x41, 0xbc, 0x9c, 0xae, 0xd9, 0x1b, 0x94, 0xb1, 0x3a, 0x8f, 0xb0, 0xdc, 0x9b, 0xcc, 0xe6, 0xcc, 0x05, + 0x72, 0x12, 0xa8, 0xde, 0xe8, 0xe6, 0x97, 0x22, 0xba, 0xd5, 0xae, 0xc1, 0x39, 0x3f, 0x33, 0xdf, 0xa3, 0x48, 0xfc, + 0xe4, 0x47, 0x5d, 0x32, 0xcb, 0xd6, 0x09, 0x74, 0xb2, 0xec, 0xca, 0x8d, 0xef, 0xb6, 0xbe, 0x78, 0xb7, 0xbe, 0xb0, + 0xfe, 0x44, 0x86, 0xf3, 0x4b, 0x72, 0xf7, 0xf8, 0x27, 0xd6, 0x41, 0x6c, 0xfd, 0xe7, 0x2f, 0x94, 0xfc, 0x4f, 0xa9, + 0x71, 0xe7, 0x8f, 0x9d, 0x21, 0x54, 0x8c, 0x50, 0xe3, 0x0d, 0xc6, 0x82, 0x73, 0x77, 0xa4, 0x64, 0xdd, 0x8c, 0x71, + 0x5a, 0x49, 0x59, 0x03, 0xf7, 0x95, 0x26, 0xa7, 0x55, 0x6e, 0xaf, 0x05, 0x31, 0xbb, 0x34, 0xb5, 0x43, 0x81, 0x4f, + 0x7d, 0x26, 0x65, 0x49, 0x72, 0x9d, 0xf2, 0xec, 0x1f, 0xa9, 0xed, 0x08, 0x60, 0xae, 0x7e, 0x05, 0x10, 0x8c, 0xf3, + 0xe5, 0xee, 0x5a, 0xe9, 0xa9, 0xcf, 0x60, 0x54, 0x8f, 0xbc, 0xf4, 0x2a, 0x5f, 0x16, 0x71, 0xa5, 0x1b, 0xe5, 0x3b, + 0x5c, 0xc2, 0x46, 0xb4, 0x78, 0xf7, 0xd2, 0x6b, 0x85, 0xbc, 0xa3, 0x7c, 0x50, 0x55, 0x39, 0x8c, 0x8a, 0xe6, 0xab, + 0x9b, 0x12, 0x2e, 0xb3, 0x77, 0xb0, 0xc9, 0x26, 0x1d, 0x74, 0x16, 0x6c, 0xc9, 0x04, 0x89, 0xc4, 0xb0, 0xec, 0x90, + 0x90, 0xab, 0xb4, 0x6f, 0xf0, 0x32, 0x54, 0xa7, 0x3a, 0xa7, 0xe8, 0xa7, 0x1d, 0x2f, 0xe9, 0x88, 0x69, 0xa1, 0x2d, + 0xd2, 0x11, 0xa5, 0x03, 0x63, 0xcc, 0x78, 0x85, 0x54, 0x35, 0x30, 0xbc, 0x56, 0x13, 0x4f, 0xb1, 0xbc, 0xf6, 0xe0, + 0x31, 0x91, 0x09, 0x62, 0xdf, 0xc2, 0xd5, 0x46, 0x1c, 0xdc, 0xc8, 0xf2, 0x5a, 0xb9, 0x0a, 0xaf, 0xee, 0xe1, 0x59, + 0xdb, 0x99, 0x7c, 0x48, 0x28, 0x90, 0x58, 0xe6, 0x17, 0x3a, 0x7e, 0xad, 0xa7, 0xbb, 0xe2, 0x25, 0xda, 0x65, 0x07, + 0x48, 0x53, 0x4b, 0xbc, 0x9c, 0xbe, 0xcb, 0x5e, 0x99, 0xd5, 0x4b, 0x4d, 0x1a, 0xdd, 0x42, 0xba, 0xf6, 0x08, 0xf1, + 0x30, 0x1f, 0x0a, 0x76, 0x5f, 0x92, 0x06, 0xe8, 0x0a, 0xb3, 0x5b, 0xfc, 0xa5, 0x8d, 0x0b, 0xf7, 0xe1, 0xa3, 0x88, + 0x48, 0xf4, 0x25, 0xbf, 0x16, 0x2f, 0xfe, 0x93, 0xef, 0x48, 0xc0, 0xe8, 0x22, 0x3e, 0xc9, 0xed, 0x07, 0x56, 0x45, + 0x97, 0x09, 0xcd, 0xfd, 0xe2, 0x34, 0x81, 0xd9, 0x0d, 0xf4, 0xe2, 0x26, 0xf3, 0x35, 0xdd, 0x5d, 0x69, 0xea, 0xde, + 0x1f, 0x8e, 0x55, 0x1d, 0xb5, 0xf9, 0xd2, 0x53, 0x77, 0x93, 0xed, 0x75, 0x8d, 0x4b, 0xdd, 0x42, 0x4d, 0xba, 0x62, + 0x6b, 0x6d, 0x51, 0x9f, 0xa6, 0x79, 0x6f, 0x56, 0xfe, 0x55, 0xb6, 0x75, 0x03, 0xb8, 0x5e, 0x88, 0x75, 0xcd, 0x46, + 0x4d, 0x90, 0xb2, 0xd4, 0x85, 0x68, 0xdf, 0xb8, 0x01, 0x68, 0xb1, 0x86, 0x75, 0x9d, 0x2a, 0xd3, 0x79, 0x9a, 0x8f, + 0x26, 0x04, 0x7d, 0x1f, 0x07, 0x97, 0xf2, 0x46, 0xff, 0x8a, 0x46, 0xad, 0xea, 0xf3, 0xcd, 0x73, 0x1e, 0x9d, 0x08, + 0x6f, 0x1c, 0xd0, 0x2e, 0x8d, 0x11, 0x2f, 0x3d, 0xfd, 0x4c, 0xf9, 0xd2, 0x8d, 0x51, 0x60, 0xbc, 0x00, 0x79, 0x3d, + 0xf4, 0xcb, 0x8d, 0x74, 0x81, 0x5e, 0x55, 0x46, 0x19, 0x5f, 0xdc, 0xcf, 0xbc, 0x7e, 0x25, 0x3e, 0xa0, 0x7c, 0x83, + 0x20, 0x54, 0x58, 0x56, 0x71, 0xc8, 0x20, 0x03, 0x7c, 0xdd, 0xa2, 0x5f, 0x46, 0xbf, 0x68, 0x73, 0x1e, 0xe6, 0x45, + 0xac, 0xbc, 0x39, 0xfc, 0x66, 0x78, 0x79, 0xf5, 0xbc, 0x1e, 0xf3, 0x03, 0xd9, 0xdb, 0xb5, 0xd2, 0x8e, 0x51, 0x3a, + 0x38, 0xc4, 0xae, 0x70, 0x9d, 0x02, 0x30, 0x2a, 0x41, 0xc7, 0x41, 0x54, 0x40, 0x5b, 0xfb, 0x81, 0xd4, 0x8a, 0x32, + 0x52, 0x90, 0x56, 0x6b, 0x38, 0x83, 0xb4, 0x03, 0x4a, 0xb6, 0x4d, 0xb3, 0x18, 0x99, 0x9d, 0x47, 0xba, 0xab, 0x8e, + 0xd3, 0xa2, 0xc1, 0x8b, 0xb3, 0x32, 0x31, 0xee, 0x51, 0x92, 0xab, 0xa4, 0x71, 0x63, 0x78, 0x79, 0xf3, 0x46, 0xa4, + 0x1b, 0xf7, 0x87, 0x4b, 0xae, 0x6e, 0xd9, 0xb9, 0x04, 0xa7, 0x37, 0xbf, 0x04, 0xe2, 0xe3, 0xa6, 0xf9, 0xda, 0x47, + 0x02, 0xee, 0x3f, 0x97, 0x80, 0xbb, 0x62, 0xf2, 0x32, 0x9b, 0xc0, 0xe0, 0x47, 0xe3, 0x9c, 0x69, 0xf0, 0x03, 0x63, + 0xcf, 0x85, 0x5e, 0x0b, 0x18, 0xfc, 0xa8, 0x23, 0x5e, 0xa3, 0x96, 0x90, 0x0e, 0xa3, 0xd2, 0xf7, 0xc4, 0xd8, 0xa0, + 0x3d, 0x32, 0xdd, 0xeb, 0xe0, 0xc6, 0x10, 0x22, 0x2f, 0x4c, 0xa1, 0x70, 0x04, 0xc0, 0xf9, 0xff, 0x0a, 0x92, 0xe6, + 0x9b, 0x43, 0xe1, 0x02, 0xe4, 0xb9, 0xd6, 0x8d, 0x48, 0x87, 0x4e, 0x2c, 0x63, 0xd8, 0x11, 0x33, 0x66, 0x63, 0xa6, + 0xaa, 0xe8, 0x31, 0x27, 0xce, 0x00, 0xca, 0x86, 0xaf, 0xc1, 0xd7, 0x36, 0x03, 0x13, 0xa2, 0x2a, 0x82, 0xc1, 0xbb, + 0xb7, 0xb0, 0x11, 0x74, 0x36, 0x25, 0x46, 0x34, 0x54, 0x43, 0x93, 0xaf, 0xf2, 0x62, 0x3b, 0xa1, 0xb1, 0x3f, 0x06, + 0x99, 0xac, 0x7e, 0xfa, 0xcc, 0x70, 0x1f, 0xeb, 0xf7, 0x3b, 0xd1, 0x56, 0x98, 0x14, 0xe4, 0xd3, 0x96, 0xa5, 0x73, + 0xe9, 0xc5, 0x25, 0x78, 0x69, 0xfa, 0xa6, 0xe6, 0x60, 0x7d, 0x99, 0x83, 0x2c, 0xa7, 0xfe, 0x3c, 0x98, 0x3b, 0x48, + 0x30, 0x75, 0x9e, 0x16, 0x01, 0x2e, 0x21, 0xa2, 0xf4, 0x5c, 0x66, 0x04, 0x36, 0x93, 0x87, 0x99, 0x6c, 0xae, 0xb0, + 0x78, 0x7e, 0xa4, 0x99, 0x9b, 0x51, 0xa1, 0xd7, 0xfd, 0xfc, 0xee, 0xa3, 0x34, 0xfb, 0xba, 0x3c, 0x8e, 0xbb, 0x5b, + 0xcd, 0x19, 0x88, 0xaa, 0x9d, 0xd2, 0x83, 0x5f, 0x64, 0x1d, 0xd8, 0xdb, 0xd6, 0xf4, 0xed, 0xe3, 0x9f, 0x7e, 0xe9, + 0x90, 0x4c, 0x9d, 0xdb, 0xd0, 0x59, 0x74, 0xfa, 0x7e, 0x8f, 0x91, 0x36, 0x5b, 0xe1, 0x88, 0x81, 0xca, 0x53, 0x43, + 0x36, 0xa9, 0x37, 0x71, 0x82, 0x1b, 0x1f, 0x91, 0xaa, 0x4d, 0x7f, 0x03, 0x8f, 0xf5, 0xc3, 0x8f, 0xe6, 0x4e, 0xd5, + 0xed, 0x85, 0xef, 0xdb, 0x3b, 0xa1, 0xdd, 0x3c, 0xbe, 0x56, 0xaf, 0xcd, 0xfb, 0xce, 0x48, 0x5d, 0x50, 0xf4, 0xbc, + 0xf6, 0xbf, 0x52, 0x33, 0x0e, 0xde, 0x36, 0xf7, 0x89, 0x81, 0x6f, 0xc7, 0xe7, 0x31, 0xcf, 0x80, 0xac, 0x65, 0x16, + 0x2d, 0x8d, 0x5c, 0xe3, 0x1a, 0x07, 0x94, 0x15, 0xe2, 0x8a, 0x66, 0xaa, 0x8d, 0x87, 0xa8, 0xeb, 0x1d, 0x2f, 0x67, + 0x2b, 0x5c, 0xdc, 0x62, 0x5a, 0xc5, 0x37, 0x71, 0xe1, 0xec, 0xe6, 0x99, 0xe2, 0x2a, 0x9b, 0x53, 0x75, 0x91, 0xe9, + 0x77, 0x41, 0x57, 0x1d, 0x06, 0xc1, 0x66, 0xd2, 0x87, 0xeb, 0xce, 0x43, 0x17, 0x6e, 0x5c, 0x0c, 0x0f, 0x01, 0xa9, + 0xb4, 0x9c, 0x40, 0x01, 0x63, 0x5b, 0xdc, 0x50, 0x96, 0x38, 0xbe, 0xfe, 0xf9, 0xc0, 0xc3, 0x00, 0xf0, 0x8d, 0x3d, + 0xc4, 0xc4, 0x6c, 0x65, 0x33, 0xcd, 0x09, 0x3f, 0xc3, 0x40, 0x8e, 0x2b, 0xef, 0x34, 0x6e, 0x87, 0xff, 0x33, 0x36, + 0x11, 0x29, 0xa0, 0x49, 0x2c, 0x2c, 0x64, 0xa6, 0xed, 0x14, 0x7d, 0xa2, 0x10, 0xba, 0x62, 0x29, 0x1f, 0x5c, 0xe6, + 0xe0, 0xbb, 0xd6, 0x7b, 0x5f, 0x57, 0x7e, 0x7d, 0x10, 0xb4, 0x54, 0x4d, 0xb0, 0x96, 0x14, 0x0a, 0x49, 0x60, 0xed, + 0x48, 0xa7, 0xf5, 0xb5, 0x1d, 0x28, 0x28, 0x59, 0x16, 0x44, 0xd2, 0xf9, 0x5a, 0x3b, 0xa4, 0x4e, 0xc5, 0x5f, 0xf8, + 0x6f, 0x3f, 0x4d, 0xe0, 0xd7, 0x56, 0xc4, 0x40, 0x7d, 0x1d, 0x5f, 0x77, 0x5f, 0x45, 0xbb, 0x21, 0x6d, 0xd5, 0x8f, + 0xa9, 0xb2, 0x99, 0x91, 0xf2, 0x7e, 0xac, 0xfe, 0xfc, 0xd9, 0x86, 0xa1, 0x69, 0xe2, 0x78, 0x78, 0x73, 0x33, 0x77, + 0x98, 0x29, 0x9f, 0x43, 0xaf, 0x36, 0x56, 0xdf, 0x00, 0x6c, 0x91, 0x93, 0xda, 0x35, 0x51, 0x30, 0xc1, 0x34, 0xd9, + 0x44, 0xdf, 0x3d, 0x52, 0x92, 0x98, 0xb5, 0x47, 0xa1, 0x77, 0x97, 0x32, 0x2d, 0x5a, 0xaa, 0xb9, 0x1a, 0x0b, 0x65, + 0x3a, 0xa9, 0x18, 0x6c, 0x6a, 0xfc, 0xd9, 0x95, 0xf3, 0x99, 0x53, 0x10, 0x79, 0xb1, 0xe1, 0x91, 0xeb, 0x73, 0xc8, + 0xc3, 0xad, 0x7c, 0xd5, 0xe7, 0xe7, 0xf6, 0xc2, 0x2b, 0xde, 0xeb, 0xbd, 0x72, 0x1d, 0x6a, 0xe9, 0x31, 0xcf, 0x8b, + 0xba, 0x5f, 0x96, 0x6b, 0x1c, 0x18, 0x50, 0x3b, 0x01, 0xc6, 0xb9, 0x88, 0x02, 0x0c, 0xf0, 0x4a, 0xba, 0x67, 0x24, + 0x3d, 0x9e, 0xc5, 0x25, 0xfa, 0x91, 0xa1, 0x9a, 0xa7, 0xcd, 0x4b, 0x40, 0x94, 0x2a, 0x3b, 0xce, 0x2d, 0x9d, 0x4c, + 0xb3, 0xa8, 0x2d, 0xbd, 0x33, 0x9d, 0x46, 0xf9, 0xbe, 0x02, 0x80, 0xf4, 0x9d, 0x7e, 0xe4, 0x4c, 0x87, 0x72, 0x73, + 0x80, 0x70, 0xa3, 0x64, 0xc6, 0x8d, 0x89, 0xc2, 0xf3, 0x13, 0x03, 0x22, 0x84, 0xb8, 0x1a, 0xf8, 0xca, 0x4b, 0xda, + 0x27, 0x2a, 0x42, 0x43, 0xfc, 0x80, 0x1e, 0xdc, 0x87, 0x5b, 0xfb, 0xf7, 0x7e, 0x50, 0x55, 0x72, 0xb0, 0x0c, 0x25, + 0x46, 0xe9, 0xde, 0xf8, 0x55, 0x81, 0xdd, 0x4f, 0xcc, 0x4a, 0x2d, 0x11, 0x50, 0x69, 0xf9, 0x7e, 0x71, 0x51, 0xe6, + 0xfc, 0xe9, 0x0f, 0xd7, 0x71, 0x48, 0xa8, 0x91, 0x2f, 0x53, 0xd9, 0x01, 0xf9, 0xf0, 0x1d, 0xfd, 0x2c, 0xca, 0x6a, + 0x0a, 0xbf, 0x8d, 0x2d, 0xdc, 0x5d, 0x16, 0x59, 0xea, 0x00, 0x50, 0x84, 0x63, 0x34, 0x1b, 0x3f, 0xed, 0x92, 0x0c, + 0xed, 0xa2, 0x8d, 0xdf, 0x69, 0x49, 0x33, 0x2a, 0x2a, 0x8a, 0x86, 0xd0, 0x6c, 0x34, 0x43, 0x0a, 0xe6, 0x09, 0x7a, + 0xf1, 0x31, 0x3b, 0xf0, 0xe7, 0x46, 0x49, 0x59, 0xba, 0x35, 0x7f, 0xbd, 0xbd, 0x90, 0xac, 0xa7, 0xac, 0x6e, 0x8a, + 0x30, 0x59, 0xd0, 0x0c, 0x7d, 0xe5, 0xff, 0x30, 0x80, 0xa7, 0x90, 0x97, 0x2b, 0x16, 0xfe, 0x5e, 0xd5, 0x3d, 0x7c, + 0xb9, 0x11, 0xc7, 0xf5, 0xa2, 0x29, 0x1f, 0xb4, 0x0f, 0x21, 0xa9, 0xea, 0x7b, 0x1c, 0xf6, 0x9c, 0xfa, 0x8f, 0x85, + 0x4d, 0xb8, 0x15, 0x05, 0x02, 0xcf, 0x66, 0x2d, 0x9a, 0x88, 0xa9, 0xcb, 0x8c, 0x08, 0x63, 0x49, 0x10, 0xc4, 0xad, + 0xce, 0x79, 0x3e, 0xca, 0xcd, 0xc9, 0x49, 0x9e, 0xb7, 0xb3, 0xeb, 0x68, 0xdf, 0x9b, 0x5b, 0x29, 0xab, 0x5c, 0x37, + 0x84, 0x16, 0x2f, 0x5d, 0x5c, 0xa5, 0x32, 0x4c, 0xcb, 0x55, 0x71, 0x43, 0xab, 0xd6, 0xb4, 0x6a, 0xc0, 0x07, 0x19, + 0xb4, 0x2a, 0x4f, 0x9e, 0x76, 0x95, 0x9b, 0x6c, 0xd3, 0x97, 0x15, 0x5d, 0x77, 0xc0, 0xf0, 0x4a, 0x61, 0x6d, 0xd7, + 0xc1, 0x36, 0x9c, 0x68, 0x70, 0xde, 0xb7, 0xdb, 0x06, 0x90, 0xbc, 0xdd, 0xc5, 0x0a, 0x1e, 0x4e, 0x8e, 0xff, 0x62, + 0x87, 0xe2, 0xf7, 0xbe, 0x68, 0x65, 0x14, 0x23, 0x23, 0x34, 0xf5, 0xaf, 0x8e, 0x08, 0xff, 0xc2, 0x77, 0xa5, 0xf6, + 0x98, 0xab, 0x08, 0x65, 0xed, 0x66, 0x15, 0xfb, 0x83, 0x24, 0xbf, 0x34, 0x49, 0xf5, 0x36, 0x4f, 0x4f, 0xb0, 0x4a, + 0x41, 0x7b, 0x73, 0xd8, 0x60, 0x6b, 0xae, 0x8d, 0x14, 0x37, 0x98, 0xd0, 0xc6, 0xff, 0x60, 0x23, 0xc0, 0x27, 0x52, + 0xbc, 0xe0, 0x72, 0x5c, 0x59, 0x8a, 0xe6, 0x44, 0xf3, 0xd2, 0xc8, 0x3e, 0x85, 0x79, 0x3e, 0xaa, 0x90, 0xeb, 0xe6, + 0x3c, 0x50, 0x2f, 0x87, 0x3e, 0x71, 0xca, 0x38, 0xcf, 0x8e, 0x70, 0x3e, 0x95, 0xd3, 0xae, 0xde, 0xac, 0x2d, 0x43, + 0x5c, 0x27, 0x2b, 0x42, 0x48, 0x3e, 0x8c, 0x53, 0x51, 0xa4, 0xd8, 0xbe, 0xda, 0x39, 0xcf, 0xf1, 0x95, 0x21, 0x0a, + 0x27, 0x5c, 0x44, 0x63, 0x4a, 0xe8, 0x4f, 0x5e, 0x50, 0x74, 0x67, 0xd4, 0x24, 0x98, 0xb5, 0x3a, 0x99, 0x04, 0xce, + 0xd4, 0x7f, 0xc0, 0xc2, 0xd0, 0x1b, 0x20, 0x3a, 0xa8, 0xa9, 0x32, 0x3f, 0xba, 0x5b, 0x71, 0xe3, 0x93, 0x8e, 0xcc, + 0x68, 0x13, 0x33, 0xce, 0x94, 0xda, 0xe2, 0x6b, 0xb3, 0x7b, 0x8e, 0xc0, 0xec, 0x6e, 0x01, 0xc1, 0x22, 0x8e, 0x54, + 0x68, 0xd5, 0x9f, 0xab, 0x77, 0xbb, 0x48, 0x80, 0x73, 0x42, 0x1b, 0x03, 0x2d, 0x3e, 0xe3, 0x74, 0x35, 0xe7, 0xdb, + 0x38, 0xec, 0x18, 0x32, 0x55, 0x9c, 0xdf, 0x45, 0x9f, 0xfb, 0x99, 0x00, 0xdd, 0x2d, 0x44, 0x3a, 0xdf, 0x5b, 0x17, + 0x6a, 0x16, 0x0e, 0x21, 0x6c, 0x7f, 0x12, 0x25, 0x64, 0xa8, 0xbf, 0x16, 0x7e, 0x8e, 0xda, 0xab, 0x97, 0x5a, 0x26, + 0x1b, 0x7e, 0x30, 0xa2, 0xc5, 0xa3, 0x00, 0x92, 0x0c, 0xa3, 0xf7, 0xcf, 0xdf, 0xdc, 0xb0, 0x9f, 0xa1, 0xf0, 0x0c, + 0xe6, 0x11, 0x50, 0xc0, 0xcd, 0xdd, 0x4f, 0xe8, 0xda, 0x52, 0x2e, 0x08, 0x67, 0xb2, 0x0d, 0x09, 0x56, 0xc6, 0xb9, + 0x66, 0x6b, 0xe3, 0x45, 0xc3, 0x09, 0xe9, 0x88, 0x3a, 0x68, 0x4c, 0x7a, 0x9e, 0x33, 0x9a, 0xc7, 0x58, 0xfd, 0xc9, + 0x99, 0x60, 0xf9, 0x81, 0x8d, 0xc9, 0x15, 0x04, 0x55, 0x8b, 0x82, 0x58, 0xd3, 0x1d, 0xed, 0xc0, 0x70, 0x7f, 0x29, + 0x9e, 0x12, 0xe4, 0x6f, 0x97, 0x98, 0x38, 0x2a, 0x14, 0x72, 0xd6, 0xb8, 0xa1, 0x6f, 0x44, 0xb0, 0x5e, 0x8d, 0x07, + 0xbd, 0xe7, 0x4b, 0x91, 0xa5, 0xaa, 0x73, 0xbb, 0x51, 0x0e, 0xcd, 0x30, 0x61, 0x8c, 0x13, 0x5a, 0xca, 0x37, 0x64, + 0x25, 0x76, 0x36, 0xb5, 0x14, 0x4e, 0xff, 0x69, 0xc8, 0x53, 0xb1, 0x85, 0x80, 0xaa, 0xcf, 0x41, 0x93, 0x13, 0xd3, + 0xd4, 0x9d, 0x37, 0x72, 0x67, 0x1e, 0x60, 0x54, 0x53, 0x36, 0x3a, 0xa1, 0x77, 0xcc, 0x47, 0x66, 0xf0, 0x33, 0xb2, + 0x3b, 0x0f, 0x59, 0x2d, 0x93, 0xcb, 0x24, 0x3f, 0xeb, 0x8d, 0xef, 0x1c, 0x20, 0xb1, 0x8e, 0x41, 0xc5, 0xe6, 0x59, + 0x57, 0x59, 0xab, 0x2a, 0xd3, 0x4d, 0xfc, 0xaa, 0x5b, 0x1a, 0x28, 0x78, 0xa2, 0x02, 0x85, 0x48, 0x9a, 0x92, 0xa0, + 0x56, 0x0f, 0x21, 0x47, 0x94, 0xa3, 0xbb, 0x45, 0xcc, 0x75, 0xbc, 0xaa, 0x6c, 0xfc, 0x1b, 0xd3, 0x47, 0x8b, 0xda, + 0xa1, 0xdb, 0xcf, 0x6c, 0x54, 0xc3, 0x22, 0x55, 0x4e, 0x61, 0xc8, 0x8f, 0x38, 0x8f, 0x35, 0x09, 0xb2, 0x71, 0x32, + 0x00, 0x05, 0xbd, 0x54, 0xe0, 0x7f, 0x33, 0xe7, 0x8c, 0x15, 0x2b, 0x17, 0xa0, 0x22, 0x58, 0xbb, 0xe6, 0x5f, 0xf7, + 0x69, 0xc4, 0x28, 0x54, 0x67, 0x0f, 0xc0, 0xac, 0x85, 0x0c, 0xe4, 0x57, 0xeb, 0x6d, 0x28, 0x17, 0xb6, 0xe1, 0xa4, + 0xf5, 0xba, 0xfa, 0x2c, 0xe4, 0x22, 0xad, 0xa6, 0x68, 0xb3, 0x3a, 0x4f, 0x9d, 0x15, 0x4c, 0xf8, 0x25, 0x9c, 0x9b, + 0x4e, 0x90, 0xa5, 0xc6, 0x91, 0xf2, 0x30, 0xfb, 0x38, 0x6a, 0x9d, 0x59, 0x39, 0x76, 0xa1, 0x0a, 0xdb, 0x3c, 0xcf, + 0x9c, 0x30, 0xbd, 0xd8, 0x93, 0xaa, 0xda, 0x95, 0x95, 0xee, 0xe6, 0x5a, 0xcc, 0x9b, 0x5d, 0x1d, 0x49, 0x2d, 0x31, + 0xad, 0x93, 0xfd, 0x89, 0x95, 0x59, 0x81, 0xe0, 0x6d, 0xe8, 0x36, 0x42, 0x64, 0x17, 0xec, 0x47, 0x5a, 0xbc, 0x74, + 0x4b, 0xae, 0x8e, 0x60, 0x11, 0x5a, 0x45, 0xff, 0x50, 0x5a, 0x18, 0x90, 0xea, 0x8a, 0x92, 0xd2, 0x48, 0xff, 0xad, + 0xcc, 0x70, 0x92, 0x59, 0xbd, 0x77, 0xa8, 0x3d, 0x16, 0x41, 0xbd, 0x1f, 0x93, 0x1e, 0xe5, 0x5c, 0x2f, 0x05, 0x9c, + 0x2c, 0x81, 0xd9, 0x0b, 0x76, 0x0b, 0x00, 0x79, 0xed, 0x6d, 0x2d, 0x15, 0x99, 0x70, 0xf9, 0x3c, 0x99, 0x73, 0x69, + 0x15, 0x78, 0x05, 0xbd, 0x6b, 0x6f, 0xb0, 0xb2, 0x10, 0xdc, 0x2f, 0x72, 0xa6, 0xcf, 0x0a, 0x92, 0x4a, 0x43, 0xbc, + 0xb4, 0x04, 0xde, 0x4a, 0xaa, 0x29, 0x70, 0x6b, 0xd9, 0x70, 0x6d, 0xda, 0x46, 0x1f, 0xea, 0xfd, 0x78, 0xc7, 0x68, + 0x15, 0xfc, 0xe7, 0xd3, 0xdf, 0x2a, 0x76, 0x47, 0xf0, 0x6c, 0x15, 0xaa, 0xac, 0xeb, 0x61, 0x22, 0xd9, 0xfe, 0x6a, + 0xe7, 0x0b, 0xa0, 0x45, 0xb8, 0x52, 0xba, 0x26, 0x01, 0x9d, 0xd4, 0x14, 0x0b, 0xdc, 0xa6, 0xc0, 0x2c, 0xa3, 0x9f, + 0xc2, 0xb7, 0x91, 0x6b, 0x1c, 0xa9, 0x46, 0x34, 0x99, 0x71, 0xb8, 0x20, 0x9a, 0xbc, 0xb9, 0x5b, 0x15, 0x01, 0x04, + 0x07, 0x68, 0x2b, 0xef, 0x8c, 0xd3, 0x3b, 0xf7, 0x91, 0xd6, 0x39, 0xf0, 0x43, 0x37, 0xd9, 0x2e, 0x75, 0x68, 0xd5, + 0x12, 0xbd, 0x5d, 0x47, 0x8d, 0x06, 0x19, 0xb6, 0x44, 0x31, 0xb6, 0xe0, 0xe3, 0x13, 0x3e, 0x66, 0x90, 0x55, 0x72, + 0xc0, 0xd7, 0x8b, 0x06, 0x2a, 0x16, 0x15, 0xc8, 0xdf, 0x85, 0x50, 0xa8, 0xa3, 0x6d, 0xb4, 0x00, 0x40, 0x7d, 0x82, + 0x12, 0x3a, 0x71, 0x4b, 0xbd, 0x01, 0x55, 0xbe, 0x0f, 0x29, 0x95, 0x50, 0xdf, 0x54, 0x64, 0xca, 0xd1, 0x52, 0x31, + 0x03, 0x84, 0x91, 0x47, 0x26, 0x43, 0x6d, 0xe2, 0x2c, 0x62, 0xee, 0xde, 0x32, 0xaa, 0x7e, 0x6c, 0xcf, 0x3b, 0x59, + 0xda, 0x6b, 0x11, 0x73, 0x95, 0x33, 0xde, 0x07, 0x50, 0x02, 0x07, 0x57, 0x81, 0xb9, 0x67, 0xaa, 0x77, 0x55, 0xbc, + 0xcf, 0x2c, 0xb3, 0x86, 0x07, 0x4a, 0xcf, 0x2e, 0xc6, 0xd7, 0x98, 0xeb, 0xcf, 0xad, 0x89, 0x67, 0xf1, 0x5f, 0x1f, + 0xb7, 0x7c, 0x9e, 0xc3, 0xef, 0x26, 0xda, 0xd5, 0x19, 0xb8, 0x72, 0xc2, 0x3e, 0x4f, 0xd0, 0xae, 0x1b, 0xbc, 0x5b, + 0xb6, 0x16, 0x6b, 0x9e, 0xbc, 0x09, 0xef, 0x5b, 0x33, 0x87, 0xaa, 0xaa, 0x3c, 0xae, 0x36, 0x10, 0x48, 0xe3, 0x3b, + 0x93, 0xcc, 0xa0, 0x6b, 0x48, 0x9a, 0xe9, 0x46, 0xf0, 0xbb, 0x6f, 0xdd, 0x82, 0x8e, 0x34, 0xb0, 0xd8, 0xda, 0x3b, + 0x81, 0xcf, 0x4c, 0x86, 0x15, 0xb3, 0xe4, 0x0c, 0x7e, 0x7b, 0x1b, 0xc2, 0xd3, 0xd6, 0x9b, 0x72, 0xb9, 0x22, 0x8b, + 0x3e, 0x0f, 0xfd, 0x8a, 0x7e, 0x93, 0x96, 0xe5, 0x71, 0x0f, 0x55, 0x72, 0xff, 0x57, 0xb1, 0xe6, 0x34, 0xfa, 0x2a, + 0xa8, 0x5f, 0xbd, 0x63, 0xc0, 0xe6, 0xb6, 0xf6, 0x16, 0x72, 0xba, 0xb4, 0xc8, 0x3d, 0x18, 0x9a, 0xe9, 0xfd, 0x8f, + 0x02, 0x61, 0xc9, 0x9e, 0xd2, 0xd6, 0xf3, 0xe4, 0xa2, 0x97, 0xea, 0xdc, 0x88, 0x7f, 0xcb, 0x95, 0xdf, 0xbc, 0x8e, + 0x1a, 0xa5, 0x89, 0xff, 0x83, 0xff, 0xb5, 0x51, 0x26, 0x97, 0x3a, 0xb9, 0xd3, 0x0e, 0xca, 0xa3, 0x2e, 0x39, 0x1e, + 0xc5, 0x52, 0x33, 0x1a, 0xc5, 0x33, 0x61, 0x9f, 0xb9, 0xa0, 0x2a, 0xf4, 0x58, 0x36, 0x00, 0x6b, 0x18, 0x40, 0x32, + 0xa0, 0x26, 0x67, 0xc4, 0xa9, 0x3b, 0xc1, 0xad, 0x86, 0xd2, 0x55, 0x64, 0x46, 0x72, 0x5a, 0x78, 0x97, 0xf7, 0x2b, + 0x31, 0x44, 0xb9, 0xac, 0x6f, 0x52, 0x47, 0x54, 0x7c, 0x15, 0x5d, 0x4a, 0xdf, 0x22, 0x36, 0xda, 0x7e, 0xd8, 0xd0, + 0x8e, 0x39, 0x60, 0xe4, 0xbd, 0xd1, 0xa8, 0xe5, 0xcc, 0x20, 0xe6, 0xa7, 0x67, 0xd0, 0xc4, 0x01, 0xb3, 0x15, 0x43, + 0xcc, 0x51, 0x72, 0x55, 0x6a, 0xd2, 0x18, 0x14, 0x13, 0x3b, 0x71, 0xa4, 0x3e, 0xbf, 0xee, 0x4e, 0x0a, 0x3f, 0xcc, + 0xa9, 0xa9, 0x75, 0x3f, 0x80, 0x2d, 0x3e, 0xd5, 0xfa, 0x1d, 0x55, 0x18, 0x98, 0xed, 0x1a, 0x22, 0xfc, 0x8d, 0x8a, + 0x8b, 0xf4, 0x24, 0xfd, 0x3b, 0xf5, 0x55, 0x75, 0x1b, 0x31, 0x64, 0xcc, 0xec, 0x04, 0x6b, 0x26, 0x07, 0xb4, 0x2c, + 0xce, 0xcc, 0x2c, 0xe5, 0xb3, 0x71, 0x2c, 0xb1, 0x16, 0x58, 0x6c, 0x79, 0x9b, 0x07, 0x77, 0x68, 0x41, 0xa8, 0x48, + 0x9c, 0x58, 0xb6, 0x31, 0x73, 0x13, 0xda, 0xe0, 0x09, 0xb1, 0xa2, 0x5f, 0xf0, 0x8d, 0x10, 0x3f, 0x3a, 0xe8, 0x4d, + 0x6a, 0xa7, 0xd1, 0x95, 0xd1, 0xc1, 0x38, 0xbc, 0xe6, 0xbf, 0x5d, 0x37, 0x11, 0x74, 0x89, 0xb8, 0xa9, 0x80, 0x4b, + 0x8e, 0x9f, 0x62, 0x50, 0x27, 0x37, 0x83, 0x4d, 0x7c, 0xa7, 0xe3, 0xad, 0x1d, 0xac, 0x77, 0xc0, 0xb9, 0x3f, 0xfe, + 0x3b, 0x71, 0x1b, 0xa5, 0x5c, 0x9e, 0xfc, 0x16, 0x3b, 0x19, 0xa2, 0x39, 0x4f, 0x6f, 0x1d, 0x5e, 0x2d, 0xd2, 0x4c, + 0x75, 0x6a, 0x7a, 0x73, 0x3c, 0xd2, 0x09, 0xfc, 0x95, 0xf1, 0xec, 0x82, 0xe3, 0xb4, 0x60, 0x05, 0xe5, 0x03, 0x7e, + 0x0f, 0xa5, 0x1a, 0xae, 0x5c, 0xf4, 0x75, 0x40, 0x3d, 0x53, 0x7c, 0x59, 0x8d, 0xb5, 0x6f, 0xd2, 0x2d, 0xf8, 0xc3, + 0x1e, 0x16, 0x65, 0x5d, 0x3f, 0x3f, 0x7f, 0xb3, 0x97, 0x8d, 0xf4, 0xfc, 0x77, 0x60, 0x49, 0xfd, 0x53, 0x09, 0xaa, + 0xf6, 0xa6, 0xe6, 0x8d, 0x83, 0x78, 0x1a, 0x53, 0x1a, 0xd1, 0xff, 0xd2, 0x31, 0x75, 0x55, 0x06, 0x57, 0xc0, 0x3c, + 0x78, 0x12, 0x93, 0xa5, 0x9f, 0x8d, 0xa9, 0xa5, 0xf0, 0x6b, 0xcc, 0x4f, 0x6a, 0xf5, 0x90, 0xe3, 0x3c, 0xe4, 0xe2, + 0x95, 0xa4, 0x7b, 0x6f, 0x56, 0xdf, 0xce, 0x16, 0x06, 0xa7, 0xf9, 0x2a, 0x80, 0xff, 0xc7, 0x39, 0x01, 0x74, 0xf7, + 0xcc, 0xc5, 0x63, 0x9e, 0x7c, 0x78, 0xb3, 0xb5, 0x9a, 0x16, 0xe4, 0xdd, 0x79, 0x2a, 0xcd, 0xd6, 0x82, 0x58, 0x9b, + 0x7a, 0x34, 0x41, 0xbd, 0xd3, 0x5b, 0xd3, 0xbe, 0xb1, 0x3e, 0x8c, 0x86, 0xbe, 0x23, 0x0b, 0x85, 0xe7, 0x8f, 0x09, + 0x67, 0xc7, 0xb3, 0x89, 0x89, 0x61, 0xbf, 0x53, 0xed, 0x62, 0x60, 0xab, 0xab, 0x15, 0x0b, 0xc6, 0xfb, 0x81, 0xee, + 0x9b, 0x4c, 0x96, 0x72, 0x3c, 0xc6, 0x4c, 0x25, 0x6a, 0xda, 0xb7, 0xd4, 0xb2, 0xbb, 0x17, 0x28, 0x23, 0x66, 0xa9, + 0x81, 0xd9, 0x17, 0xaf, 0x0a, 0x0c, 0x14, 0xaa, 0xf3, 0xe1, 0x8d, 0x15, 0x94, 0xc1, 0x47, 0xf3, 0xba, 0x94, 0x15, + 0x04, 0x8e, 0x49, 0xeb, 0xc0, 0xfd, 0xf2, 0x40, 0x8f, 0x14, 0x7d, 0xf1, 0x36, 0x0a, 0x58, 0x5e, 0xd7, 0x53, 0x83, + 0xb7, 0x1a, 0xae, 0x8d, 0xf5, 0x32, 0xe3, 0x97, 0xf5, 0x40, 0x61, 0x14, 0x5c, 0xdc, 0x99, 0x5d, 0x8c, 0xc3, 0xbe, + 0xdb, 0x2a, 0x67, 0x4a, 0xa6, 0x5c, 0xaf, 0x6c, 0x7e, 0xc6, 0x40, 0xcf, 0x9b, 0xb5, 0xac, 0x71, 0xfd, 0xc4, 0xef, + 0x6e, 0x8e, 0x2b, 0xe3, 0x6c, 0x14, 0xba, 0xff, 0x23, 0x1b, 0x6a, 0x7c, 0x03, 0x35, 0x82, 0x90, 0x83, 0xab, 0xa5, + 0xb2, 0x34, 0xd2, 0x7e, 0xb6, 0x9f, 0xbe, 0x4f, 0x1e, 0x2b, 0xc8, 0xf2, 0x5f, 0xb2, 0x62, 0x63, 0x0e, 0x93, 0xc9, + 0xaf, 0x3a, 0x85, 0x74, 0x40, 0xd5, 0xa2, 0x1d, 0xa3, 0x57, 0xd9, 0x09, 0x41, 0x7d, 0x31, 0x10, 0x75, 0x00, 0x66, + 0x5b, 0xa5, 0xbc, 0x2c, 0x06, 0x9a, 0x49, 0x94, 0x2d, 0x07, 0x7d, 0x6d, 0xf8, 0xf0, 0x1a, 0xbc, 0x6a, 0x94, 0xd5, + 0xf4, 0xb2, 0x9a, 0x42, 0xa5, 0xd3, 0xa6, 0x95, 0xe0, 0x35, 0x79, 0xba, 0x5f, 0xea, 0x5c, 0x77, 0x4d, 0x1c, 0xfc, + 0x6c, 0xf5, 0x7b, 0xb0, 0xa3, 0xc9, 0xb1, 0x2b, 0xb9, 0xb9, 0xc1, 0x71, 0x1e, 0x73, 0x5c, 0xb9, 0x40, 0x44, 0xcd, + 0x42, 0x2b, 0x18, 0xd0, 0x22, 0x75, 0xa7, 0xbe, 0xbb, 0xc4, 0x6e, 0x02, 0xd8, 0x2a, 0xf6, 0x1e, 0x24, 0xdb, 0x3e, + 0x4b, 0x6f, 0x74, 0x60, 0x3b, 0x78, 0x8b, 0x26, 0xbe, 0x31, 0x57, 0xaa, 0xa9, 0xc8, 0xea, 0x8c, 0xea, 0xb0, 0x73, + 0x9a, 0xcf, 0x0f, 0x9a, 0xb1, 0x72, 0x9b, 0x84, 0xdb, 0x31, 0x52, 0x27, 0x88, 0x05, 0x2a, 0x56, 0xd3, 0xa0, 0x5a, + 0x46, 0x50, 0xb9, 0x49, 0xfa, 0xca, 0x23, 0x59, 0x8d, 0x15, 0xeb, 0x67, 0xa0, 0x6e, 0xae, 0xdc, 0xb8, 0x6d, 0x86, + 0xac, 0x5a, 0xae, 0x70, 0x46, 0x20, 0x86, 0xc6, 0x67, 0xd6, 0x48, 0x54, 0x5b, 0x09, 0xe8, 0xc0, 0xe1, 0x22, 0x05, + 0xb5, 0xbb, 0x2d, 0xaf, 0xdf, 0x8d, 0xd2, 0x23, 0x4a, 0x54, 0xd4, 0x8a, 0xca, 0x29, 0xdd, 0x50, 0xae, 0x9e, 0x89, + 0x26, 0x60, 0xa2, 0x51, 0x6c, 0xa4, 0x16, 0xe5, 0xed, 0x56, 0x85, 0xec, 0xe5, 0xba, 0x7f, 0x79, 0xff, 0x91, 0xd3, + 0xb0, 0xe9, 0x3b, 0x21, 0x69, 0x30, 0x48, 0x45, 0xc2, 0x07, 0xec, 0xa8, 0xb7, 0xe4, 0x9b, 0xcc, 0x90, 0xa9, 0x23, + 0x63, 0xd4, 0x97, 0x58, 0xf9, 0xd2, 0xfc, 0xdd, 0xab, 0x7b, 0xa3, 0x80, 0xad, 0xdf, 0xe9, 0xda, 0xdc, 0x94, 0xc2, + 0xdb, 0x0e, 0x61, 0x0a, 0xe9, 0x26, 0x23, 0xd2, 0xfa, 0xcf, 0x54, 0xfd, 0x66, 0xe2, 0x77, 0x35, 0xb6, 0x6b, 0x82, + 0x3c, 0xd1, 0x9b, 0xcd, 0xe6, 0x9c, 0xaa, 0x59, 0x00, 0x20, 0xfe, 0xab, 0xcd, 0x37, 0xf3, 0x95, 0x2a, 0x1a, 0x88, + 0xe0, 0xb3, 0xd0, 0xf5, 0x6f, 0x64, 0x54, 0x7d, 0x1a, 0xd1, 0xbf, 0x06, 0x49, 0x08, 0x65, 0xce, 0xe6, 0x7a, 0x43, + 0x50, 0xc7, 0x9e, 0x67, 0x6f, 0xf5, 0x29, 0x4c, 0xfc, 0x8f, 0xbc, 0xfa, 0x39, 0xee, 0x55, 0x14, 0xa5, 0xd8, 0xd5, + 0xa1, 0x71, 0x98, 0xc2, 0x4d, 0xa6, 0x5b, 0xef, 0x92, 0x21, 0xe0, 0xf4, 0x5f, 0x1c, 0x0e, 0x23, 0x73, 0xd3, 0x9d, + 0x0d, 0x0c, 0x06, 0x05, 0x23, 0x29, 0x96, 0x21, 0x94, 0xb9, 0xc1, 0x5c, 0xbc, 0x75, 0x80, 0x2f, 0x5d, 0x90, 0xe5, + 0x9b, 0x85, 0x8e, 0xf1, 0xd9, 0xb7, 0xe7, 0x1d, 0x1f, 0xa9, 0xd0, 0x32, 0x4b, 0x04, 0x29, 0xa4, 0x2f, 0xfe, 0x19, + 0x46, 0x2d, 0x8f, 0x89, 0x0b, 0xa6, 0xd5, 0xc3, 0x4b, 0x29, 0xc0, 0xce, 0x73, 0x50, 0x53, 0x2f, 0xa0, 0x8e, 0x85, + 0x9b, 0xca, 0x03, 0xbb, 0x12, 0x43, 0x6a, 0x53, 0x04, 0x30, 0x7e, 0xeb, 0x08, 0x11, 0x0f, 0xd2, 0xa0, 0x54, 0x4b, + 0xc8, 0x78, 0xb3, 0x9c, 0x58, 0x77, 0x17, 0x03, 0xe2, 0x9b, 0x23, 0x06, 0xb4, 0xa5, 0x66, 0x18, 0x1e, 0xe7, 0x5f, + 0x4b, 0x79, 0x13, 0x32, 0x88, 0x5d, 0x03, 0x5d, 0x49, 0xb9, 0x59, 0xfb, 0xe1, 0x18, 0xa8, 0xda, 0x86, 0x44, 0xe9, + 0x37, 0xd5, 0x95, 0x75, 0x25, 0x56, 0xa8, 0x56, 0x3b, 0xbb, 0x37, 0x79, 0x9d, 0x36, 0x34, 0xc3, 0x53, 0xb8, 0xb9, + 0x52, 0xdb, 0xc6, 0xae, 0xed, 0xff, 0x24, 0x73, 0xd0, 0x14, 0xac, 0x95, 0x1f, 0xec, 0x78, 0x36, 0xd1, 0xbf, 0x9e, + 0xd5, 0x99, 0x74, 0xfd, 0x51, 0x79, 0x96, 0x9f, 0x5b, 0x75, 0x50, 0x81, 0x87, 0xd3, 0x22, 0xff, 0xd1, 0xd7, 0x70, + 0x0d, 0xbd, 0x27, 0xef, 0x7a, 0xbb, 0xc1, 0x18, 0xbe, 0x78, 0x13, 0x4f, 0xfb, 0x9b, 0x4c, 0xe0, 0x14, 0xc2, 0xb6, + 0x75, 0x02, 0xd6, 0x3a, 0x7d, 0x47, 0x52, 0xd0, 0x22, 0xbf, 0x45, 0xb3, 0x5f, 0x2b, 0x73, 0xc3, 0x2f, 0x1c, 0xc5, + 0xcd, 0xa5, 0x74, 0x91, 0x3c, 0x59, 0xa5, 0xed, 0x30, 0xcb, 0x20, 0x8e, 0xc0, 0x72, 0xf4, 0x73, 0x27, 0x72, 0xeb, + 0x63, 0x35, 0xcc, 0xee, 0x38, 0x0e, 0xc5, 0xa8, 0x7e, 0xaa, 0x23, 0x52, 0x1e, 0x26, 0x03, 0x36, 0x35, 0xa1, 0xc5, + 0x58, 0x58, 0xba, 0x24, 0x41, 0x0a, 0x74, 0x80, 0x5a, 0x22, 0x73, 0x52, 0x8b, 0xec, 0x8a, 0x71, 0xcf, 0xb6, 0x62, + 0xe9, 0xda, 0xc7, 0x47, 0x9d, 0x3d, 0x03, 0x37, 0x8e, 0x93, 0x93, 0xcd, 0x9d, 0x2d, 0xc0, 0x4a, 0x8f, 0xc9, 0xe9, + 0xec, 0x87, 0x12, 0xcb, 0x35, 0xd9, 0x7d, 0x54, 0xb4, 0xbb, 0xef, 0xe0, 0x88, 0x2c, 0x11, 0xa3, 0xff, 0xb4, 0xce, + 0x64, 0xad, 0xbf, 0x91, 0x03, 0xf8, 0x16, 0x1a, 0xf5, 0x82, 0xc5, 0x80, 0xcb, 0xdd, 0xe5, 0x5d, 0x8d, 0x0f, 0xbc, + 0x32, 0xe1, 0xac, 0x2a, 0xd7, 0xdc, 0x6c, 0x64, 0x9a, 0xa8, 0x09, 0xe9, 0xff, 0x2b, 0x5b, 0x0d, 0xb1, 0x05, 0x78, + 0x32, 0xf6, 0xcd, 0x9b, 0x0d, 0x4c, 0xcd, 0x42, 0x8b, 0x2b, 0xec, 0x43, 0x1c, 0xa7, 0x22, 0xba, 0xb9, 0x81, 0x1a, + 0x7e, 0x90, 0xd0, 0xca, 0x77, 0x09, 0x55, 0xff, 0x41, 0x34, 0xf6, 0xbd, 0x57, 0x59, 0xc2, 0x41, 0xcf, 0x41, 0xa6, + 0xd1, 0xbd, 0x66, 0xd2, 0x93, 0xbd, 0xb9, 0x31, 0x54, 0x8d, 0xbc, 0x56, 0xee, 0x1e, 0xdc, 0x2d, 0xe1, 0xf9, 0xd9, + 0x9c, 0xf7, 0xe6, 0x23, 0xe1, 0x51, 0x37, 0x5e, 0xf5, 0x0f, 0x71, 0x87, 0xaf, 0xae, 0x1f, 0x27, 0x62, 0x45, 0x11, + 0x17, 0x1f, 0xd6, 0xbb, 0x5a, 0x79, 0xdc, 0x3a, 0x3c, 0xc5, 0xfb, 0x06, 0x74, 0x4a, 0x4a, 0x75, 0xde, 0x35, 0x81, + 0xae, 0xe0, 0xfb, 0x73, 0xed, 0xf2, 0xfd, 0x8d, 0xb3, 0x6e, 0xcb, 0xcd, 0xc6, 0xc1, 0x1b, 0x93, 0x2e, 0x5a, 0xb0, + 0xeb, 0x3b, 0x9e, 0xbe, 0xf9, 0x38, 0xfc, 0x68, 0x64, 0x58, 0xd5, 0x58, 0x40, 0x1b, 0x5a, 0xbe, 0x20, 0xef, 0xc9, + 0x22, 0x46, 0x77, 0xa5, 0xc9, 0x53, 0x72, 0xbb, 0xf9, 0x3e, 0x44, 0xbc, 0x59, 0x07, 0xba, 0x72, 0xd0, 0xdd, 0xf8, + 0xd7, 0xfa, 0xe5, 0x65, 0xe9, 0xde, 0xbc, 0x7a, 0xee, 0xb5, 0x90, 0x30, 0xa9, 0xf3, 0xc9, 0x20, 0x97, 0x0f, 0x86, + 0xc8, 0xc8, 0xe6, 0x18, 0xcf, 0x24, 0x65, 0x09, 0xbc, 0x1c, 0x57, 0x19, 0xbc, 0x33, 0x6d, 0xe4, 0x1f, 0xf7, 0x44, + 0x22, 0x1e, 0x0c, 0xb4, 0x6d, 0x50, 0x28, 0x4c, 0xea, 0xed, 0x62, 0x88, 0x7b, 0x94, 0x31, 0xd1, 0x3c, 0x76, 0x7d, + 0xbf, 0x46, 0x27, 0x47, 0x6f, 0x66, 0xd4, 0x6e, 0xff, 0x61, 0x35, 0x05, 0x7a, 0xe2, 0xe0, 0x89, 0xba, 0xa2, 0x12, + 0x1e, 0xff, 0xf4, 0x89, 0xf6, 0x4b, 0x7a, 0x38, 0x55, 0x87, 0xe7, 0xab, 0xf8, 0xca, 0x45, 0x55, 0x2b, 0x7e, 0x09, + 0xfa, 0x70, 0xb1, 0xc8, 0xc9, 0xf3, 0x48, 0xaf, 0x6c, 0xf6, 0x6a, 0x66, 0x13, 0xc5, 0x9d, 0xc2, 0xf2, 0xb8, 0xf9, + 0x8a, 0xe6, 0xd4, 0x90, 0x68, 0xf5, 0xef, 0x43, 0x7f, 0x0c, 0xf6, 0x36, 0xfb, 0xbf, 0x25, 0x71, 0xe6, 0xe9, 0x33, + 0xe2, 0x77, 0xb3, 0xf5, 0x92, 0x1f, 0xba, 0xbf, 0xc4, 0xbf, 0x8f, 0x4d, 0xa0, 0x59, 0xa6, 0x34, 0x51, 0xc6, 0x30, + 0x00, 0x38, 0x00, 0x7e, 0x6d, 0xfe, 0xe2, 0xdf, 0x2d, 0x9b, 0xdc, 0xcc, 0xe2, 0xa4, 0xc5, 0x9d, 0x7f, 0xfa, 0x42, + 0x69, 0x69, 0x9c, 0xe6, 0x01, 0x41, 0x35, 0xae, 0x4d, 0x8f, 0x8d, 0x64, 0x1e, 0xc8, 0x3a, 0x18, 0xb6, 0x96, 0x9c, + 0x60, 0x02, 0x22, 0xf7, 0xaa, 0xe6, 0x4b, 0x97, 0x6a, 0x65, 0x96, 0xa9, 0xcd, 0xd7, 0xd2, 0xc1, 0x60, 0xdf, 0x41, + 0xcc, 0xf7, 0xb9, 0xc7, 0x6c, 0x26, 0x3f, 0xb7, 0xb4, 0xe0, 0x6f, 0xa5, 0x3c, 0x19, 0x73, 0xf3, 0x46, 0x28, 0x2e, + 0x3e, 0x0a, 0xcc, 0x70, 0x46, 0xb0, 0x50, 0xab, 0xaf, 0xbc, 0x89, 0x0d, 0xff, 0x50, 0x12, 0x78, 0xb1, 0x7b, 0xb9, + 0xf2, 0x0a, 0xbc, 0x09, 0xed, 0x1f, 0x28, 0xff, 0xef, 0xa9, 0x96, 0xbd, 0xbc, 0x57, 0xa7, 0xb6, 0xe3, 0x5a, 0x50, + 0x91, 0x54, 0x05, 0x6f, 0xd7, 0xbf, 0x65, 0xa2, 0x81, 0xe5, 0xc9, 0x52, 0xf6, 0xb5, 0x33, 0xf0, 0xb1, 0x81, 0x2e, + 0xf5, 0x95, 0x54, 0xbd, 0x10, 0x67, 0x2c, 0x24, 0xcd, 0x0c, 0x80, 0xe8, 0x75, 0x9f, 0x9e, 0x54, 0xd3, 0xb0, 0x57, + 0x67, 0x2b, 0x7a, 0xd6, 0x88, 0x91, 0xde, 0xa5, 0xd2, 0x98, 0x3d, 0x3d, 0x52, 0xa6, 0xcf, 0x3b, 0x3f, 0x2a, 0x6f, + 0x48, 0x66, 0x1b, 0x12, 0xfc, 0x29, 0x2f, 0x50, 0x52, 0x66, 0xdb, 0x8a, 0x4d, 0xf1, 0x66, 0xee, 0x02, 0x98, 0xac, + 0x27, 0x98, 0xbb, 0x6f, 0x5e, 0x72, 0x30, 0xc6, 0xba, 0x52, 0x45, 0xb9, 0xf1, 0x79, 0x9c, 0x75, 0xb9, 0x43, 0xd8, + 0x44, 0x16, 0x3d, 0x07, 0x81, 0xcd, 0xea, 0x5a, 0x1e, 0xcc, 0xc7, 0x9c, 0x64, 0x97, 0x35, 0xfa, 0x85, 0x49, 0x90, + 0x6e, 0xde, 0xf0, 0x5c, 0xb3, 0x42, 0xde, 0xbc, 0x2f, 0xb9, 0x11, 0xcc, 0x60, 0xb4, 0x11, 0x29, 0xb4, 0x75, 0xca, + 0xb0, 0x8f, 0x88, 0x5e, 0x49, 0x98, 0xfe, 0x41, 0x9e, 0xaf, 0x7e, 0x10, 0xa6, 0xe7, 0xeb, 0x05, 0xaa, 0xfa, 0x87, + 0x02, 0x5e, 0x4c, 0x38, 0xc0, 0x02, 0xea, 0xe8, 0xa5, 0x5c, 0xc7, 0x9a, 0xa0, 0x9c, 0x70, 0xa9, 0xaf, 0xd9, 0x28, + 0xaf, 0xa5, 0xfa, 0x84, 0xd6, 0xb1, 0x66, 0x03, 0x4c, 0x46, 0x37, 0xb6, 0xf1, 0xb7, 0x31, 0xb7, 0xe9, 0xb2, 0x7f, + 0xaa, 0xd8, 0x1e, 0x82, 0xb2, 0xe1, 0x02, 0x3e, 0xf7, 0x08, 0xdc, 0xb9, 0x9e, 0x80, 0xd6, 0x10, 0xff, 0xe3, 0x38, + 0xd6, 0xf2, 0x65, 0x9d, 0x29, 0x89, 0x55, 0x16, 0x42, 0x85, 0xca, 0x89, 0xfd, 0xdc, 0x30, 0xd7, 0x7a, 0x1c, 0x5c, + 0x23, 0xc1, 0x40, 0x70, 0x0a, 0x30, 0x89, 0xab, 0x29, 0x0d, 0x8d, 0x3b, 0x47, 0x7f, 0x78, 0x2d, 0xbf, 0xf0, 0xaa, + 0x5c, 0x17, 0xdc, 0xf4, 0xbd, 0x19, 0x01, 0xf3, 0x0b, 0xfb, 0xc2, 0xd1, 0x45, 0xcb, 0xe8, 0xfa, 0xec, 0x80, 0x04, + 0xc8, 0x63, 0x65, 0x19, 0x49, 0xd8, 0x92, 0xb5, 0x7a, 0x93, 0x9f, 0xef, 0x99, 0x42, 0x24, 0x5b, 0xa0, 0xca, 0xf1, + 0x0b, 0x6c, 0x2d, 0x2d, 0xa9, 0x64, 0x25, 0x5a, 0xab, 0x50, 0x81, 0x68, 0xad, 0x09, 0xd5, 0xaa, 0xd3, 0x7b, 0xdf, + 0x22, 0x3a, 0x2f, 0x8d, 0xd4, 0x21, 0x86, 0x80, 0x88, 0xa5, 0xf5, 0x9d, 0xd2, 0x46, 0xeb, 0xc9, 0xb2, 0xb8, 0xaf, + 0xc6, 0xf6, 0x6b, 0xb8, 0x7a, 0x26, 0xde, 0x54, 0xde, 0xd6, 0xc5, 0xc3, 0x9c, 0x55, 0x4e, 0x74, 0x5d, 0x87, 0x69, + 0xb3, 0xb6, 0xd3, 0x5f, 0xd5, 0x55, 0x26, 0x43, 0xf0, 0xb1, 0x87, 0x50, 0x73, 0xa1, 0x4a, 0x85, 0x48, 0x2f, 0x77, + 0x62, 0x73, 0xe5, 0x1e, 0x73, 0xa5, 0x73, 0x1c, 0xd9, 0x3a, 0xb6, 0x93, 0xe1, 0xa9, 0xc9, 0x05, 0x71, 0xec, 0xee, + 0x7e, 0x88, 0x0b, 0xfe, 0xcf, 0x17, 0xd2, 0x9c, 0xc7, 0xe7, 0x2f, 0xfd, 0xf4, 0x93, 0xb1, 0x92, 0xd2, 0x38, 0x99, + 0x65, 0x4d, 0x2f, 0xcb, 0x20, 0xce, 0x7f, 0xc6, 0xcb, 0x9c, 0x85, 0xd7, 0x59, 0xfb, 0x57, 0xc3, 0xad, 0x38, 0xb4, + 0x2e, 0x45, 0x32, 0x45, 0xb9, 0xfb, 0xd7, 0x71, 0x12, 0x22, 0xc3, 0x9f, 0xf3, 0x86, 0xb1, 0xf6, 0x69, 0xd5, 0x7c, + 0x24, 0x2b, 0x76, 0xf6, 0x7e, 0xe9, 0xb1, 0x71, 0x51, 0x70, 0x27, 0xc8, 0x95, 0x56, 0x4a, 0x0e, 0x8e, 0x03, 0x4d, + 0xe5, 0x03, 0x05, 0x7f, 0x98, 0x92, 0xc6, 0x53, 0xcc, 0x56, 0xdf, 0xa7, 0x36, 0xcb, 0x98, 0x0c, 0x8f, 0x74, 0x66, + 0xcc, 0x46, 0xad, 0xa0, 0xb4, 0xc7, 0xf9, 0xb0, 0xb0, 0xce, 0x69, 0x9b, 0x71, 0x4c, 0xf2, 0xc7, 0xb7, 0x0a, 0xd9, + 0xaa, 0x7c, 0xa9, 0xf7, 0x7b, 0x69, 0x6f, 0x93, 0x17, 0x2b, 0x7a, 0x2b, 0x4c, 0x84, 0x81, 0x88, 0x4a, 0x15, 0x34, + 0x12, 0xb2, 0xb0, 0xd3, 0x4e, 0xed, 0x0c, 0x55, 0x69, 0x31, 0x00, 0x3f, 0x86, 0xf5, 0xf1, 0xf8, 0x5a, 0x34, 0xa6, + 0xd6, 0x51, 0x23, 0x36, 0x2e, 0xe7, 0x19, 0x00, 0x2f, 0x54, 0x3c, 0xb3, 0x62, 0xfa, 0x8c, 0x9c, 0x39, 0x82, 0x2a, + 0x0b, 0x41, 0xda, 0x61, 0x28, 0xb6, 0xdc, 0x98, 0xaa, 0x0d, 0xe4, 0xc2, 0x9f, 0x75, 0x52, 0xa5, 0x11, 0xca, 0x21, + 0xd7, 0x26, 0xef, 0x32, 0xdf, 0x20, 0x44, 0x1f, 0xda, 0xf8, 0xeb, 0xc9, 0x8d, 0x04, 0x64, 0x0a, 0x38, 0x8f, 0x34, + 0x5e, 0xd3, 0xf7, 0x3c, 0x03, 0xde, 0x54, 0x6f, 0x92, 0x04, 0xe4, 0x59, 0x75, 0xa2, 0xdb, 0xf0, 0x90, 0x3c, 0xfb, + 0xad, 0x1c, 0x95, 0x7b, 0x72, 0xa5, 0x65, 0xdf, 0xea, 0x36, 0x63, 0xbe, 0x64, 0xed, 0xd2, 0xda, 0xdb, 0x09, 0xb3, + 0x4e, 0x53, 0x65, 0x4a, 0xc4, 0x83, 0x4a, 0xd2, 0xda, 0x19, 0x40, 0x98, 0xfa, 0xe9, 0x5b, 0xd4, 0x8e, 0x37, 0x92, + 0x73, 0x93, 0x01, 0x0b, 0xaa, 0xac, 0x5c, 0x76, 0x81, 0x44, 0x40, 0x6e, 0xdb, 0xf8, 0xa6, 0xc9, 0x12, 0x8c, 0xc8, + 0x3f, 0xa0, 0x77, 0xc1, 0x1d, 0xd9, 0x5b, 0xa0, 0x3b, 0xd3, 0xc7, 0x9e, 0x1a, 0xef, 0xca, 0x9a, 0xec, 0x42, 0x66, + 0xbe, 0x89, 0x81, 0x6b, 0x57, 0x2d, 0x21, 0xe1, 0xba, 0xb1, 0xcb, 0xbc, 0xa8, 0x33, 0x99, 0xad, 0x59, 0x95, 0xc7, + 0x6a, 0x98, 0x4a, 0x87, 0xa9, 0x9a, 0xb0, 0x25, 0xc8, 0x05, 0x84, 0xcb, 0x6b, 0x97, 0xeb, 0xf8, 0x2a, 0x01, 0x22, + 0x3d, 0x88, 0x93, 0x62, 0xec, 0xb9, 0x91, 0x77, 0xd7, 0xcb, 0x0a, 0x14, 0xc6, 0x3b, 0x6b, 0x92, 0x93, 0x4b, 0xed, + 0x4f, 0xc6, 0xdb, 0x56, 0x33, 0xdd, 0x8e, 0x2f, 0x12, 0xba, 0x16, 0xc7, 0x16, 0x7c, 0x49, 0xed, 0xde, 0xd5, 0x22, + 0x57, 0xed, 0x65, 0x01, 0xa3, 0x6d, 0x74, 0xd6, 0x6d, 0xb1, 0x30, 0xa7, 0x44, 0x38, 0x59, 0x36, 0xe6, 0x3b, 0x11, + 0x5e, 0x24, 0xd6, 0x18, 0xa8, 0x9d, 0x79, 0xe3, 0x4f, 0x0c, 0xc1, 0x09, 0xbe, 0x10, 0x5c, 0x2c, 0x8d, 0xf9, 0xf4, + 0x05, 0x11, 0xb1, 0x59, 0x1c, 0x9e, 0xad, 0x9b, 0xe0, 0x74, 0x8d, 0xeb, 0x0d, 0xb8, 0x1b, 0x58, 0xd4, 0xdf, 0xd1, + 0x83, 0x79, 0xfb, 0xa3, 0xb0, 0x69, 0x20, 0xc3, 0xe8, 0xd1, 0x23, 0x41, 0xdc, 0xd9, 0x1c, 0x4b, 0x4a, 0x24, 0x1c, + 0xf1, 0xeb, 0xe7, 0x08, 0x16, 0xb5, 0x2b, 0xa3, 0xa3, 0x31, 0x97, 0xfa, 0x07, 0xb9, 0xb4, 0xed, 0x2b, 0x60, 0xf1, + 0xcf, 0x50, 0x92, 0x94, 0x9d, 0x31, 0xc8, 0x6b, 0xdb, 0x80, 0xa9, 0x0a, 0xa8, 0xe3, 0x10, 0x7e, 0x52, 0x12, 0xee, + 0x66, 0x6b, 0x4a, 0xe5, 0xd2, 0x8c, 0x62, 0xcf, 0x1b, 0x44, 0xd1, 0xc5, 0x16, 0xe1, 0x24, 0x03, 0x27, 0xfa, 0x6a, + 0xa3, 0x20, 0x6f, 0xb5, 0xbd, 0xf8, 0x3c, 0x03, 0x67, 0x1d, 0x3a, 0x05, 0x34, 0x19, 0x25, 0x0d, 0xa1, 0x42, 0x1b, + 0xc2, 0xac, 0x0d, 0x2e, 0x5b, 0x11, 0x9a, 0x86, 0xcc, 0xb0, 0x0f, 0xf3, 0x79, 0xe0, 0x8c, 0x22, 0x41, 0x4f, 0xbb, + 0xd4, 0x6f, 0x56, 0xbf, 0xb9, 0x30, 0xdf, 0xdd, 0x48, 0x27, 0x02, 0x10, 0xad, 0xf4, 0xe9, 0xa1, 0x78, 0x91, 0x5b, + 0x10, 0x51, 0x6b, 0x0e, 0x6f, 0x09, 0x0e, 0x3e, 0x26, 0x2c, 0xb5, 0xea, 0xae, 0xb6, 0xf8, 0x17, 0x09, 0xdf, 0xb5, + 0x79, 0x40, 0xcc, 0x46, 0x6f, 0xe8, 0xfa, 0x5e, 0x9a, 0xa7, 0x92, 0xea, 0x89, 0x2d, 0x06, 0x2e, 0x0b, 0x05, 0x55, + 0xfc, 0x66, 0x7c, 0x8d, 0x91, 0x15, 0x01, 0x34, 0x38, 0xbd, 0xc5, 0x08, 0x1c, 0x32, 0xe6, 0xe5, 0xd8, 0x1f, 0xd7, + 0x6c, 0x82, 0x7c, 0xd6, 0x98, 0x90, 0x88, 0xb7, 0xbd, 0x37, 0xd8, 0x2a, 0x94, 0x8d, 0x44, 0x5a, 0x1e, 0x39, 0x8c, + 0x7b, 0x50, 0xf1, 0x30, 0x22, 0x36, 0xac, 0x29, 0xf3, 0x09, 0xa1, 0xcd, 0x1e, 0xc4, 0x9c, 0x5d, 0x98, 0xb0, 0xd0, + 0x4b, 0x0c, 0x44, 0xe8, 0x6d, 0x00, 0xfb, 0x46, 0x6c, 0x91, 0x48, 0x21, 0x89, 0x44, 0x3e, 0x9a, 0x13, 0xe2, 0xb0, + 0x15, 0x19, 0x1e, 0xac, 0xf6, 0x2e, 0x46, 0xf2, 0x67, 0x9c, 0x94, 0xd6, 0x65, 0x62, 0xf3, 0xc7, 0x28, 0x61, 0x0c, + 0x38, 0xbb, 0x3b, 0x29, 0xce, 0xbb, 0x61, 0xf9, 0xe8, 0x03, 0x15, 0x7c, 0xcb, 0x15, 0xc1, 0x1e, 0x4d, 0xe4, 0x48, + 0x95, 0x15, 0xcb, 0xb9, 0x7e, 0x14, 0x1a, 0x3c, 0x65, 0xe1, 0xa8, 0x6a, 0xc3, 0x48, 0x10, 0x51, 0x69, 0x5c, 0x30, + 0x5a, 0xc9, 0x40, 0x47, 0x63, 0xda, 0x6a, 0x44, 0xb8, 0x80, 0xe7, 0x59, 0xfb, 0xa7, 0x05, 0xe3, 0x3c, 0x5e, 0x86, + 0xe3, 0x0f, 0x9a, 0x41, 0xff, 0x1d, 0x99, 0x8c, 0x96, 0x4f, 0xee, 0x46, 0xff, 0x49, 0x3f, 0x68, 0x67, 0xef, 0xf7, + 0xd5, 0xe9, 0xc7, 0xbe, 0x5c, 0x48, 0x43, 0x7e, 0xa1, 0x2b, 0x57, 0x73, 0xbb, 0x35, 0x3c, 0x30, 0x35, 0xb7, 0xd3, + 0xeb, 0x04, 0xf5, 0xce, 0xb9, 0x41, 0xdb, 0x86, 0x0d, 0x4c, 0xe2, 0x31, 0xe7, 0xc9, 0x68, 0xac, 0xc8, 0x80, 0x5a, + 0xc1, 0xca, 0x3c, 0x4b, 0x70, 0xd7, 0x67, 0xc6, 0xe0, 0x9e, 0xb8, 0x28, 0xb3, 0xe4, 0xde, 0x07, 0xe0, 0x24, 0x68, + 0xfe, 0x92, 0xdd, 0xa2, 0x7e, 0xa2, 0x5a, 0x74, 0x07, 0x29, 0x43, 0xad, 0x25, 0xde, 0x57, 0xb5, 0xc6, 0x10, 0xec, + 0x0d, 0x00, 0xad, 0xa9, 0xd5, 0x87, 0x89, 0x1c, 0xf2, 0xc7, 0x56, 0xf5, 0x41, 0x69, 0xa2, 0x2e, 0x18, 0x90, 0xa7, + 0xe6, 0x97, 0x2e, 0x11, 0x26, 0x9d, 0xd4, 0xff, 0xab, 0x97, 0xff, 0x6d, 0x0c, 0x94, 0x89, 0xca, 0xdb, 0x90, 0x87, + 0x93, 0xc7, 0xbd, 0x29, 0xde, 0xd2, 0xf9, 0x46, 0x1b, 0xee, 0x04, 0x4f, 0xf2, 0xf0, 0xfa, 0xbc, 0xb5, 0x37, 0x43, + 0xdc, 0xd7, 0xd1, 0xa6, 0xb2, 0x6d, 0x52, 0x52, 0x52, 0x1d, 0x9c, 0x81, 0x25, 0xda, 0x05, 0x4d, 0xcb, 0x79, 0xa4, + 0x1c, 0xcb, 0x36, 0xa9, 0x72, 0x0b, 0x78, 0xca, 0x29, 0xe5, 0x3f, 0x04, 0x1d, 0xa5, 0x9a, 0x47, 0xcd, 0x65, 0x79, + 0xea, 0x52, 0x58, 0x5b, 0x21, 0xba, 0x37, 0xa7, 0xfc, 0x62, 0x96, 0xb4, 0x94, 0x6a, 0x93, 0x00, 0x91, 0xc6, 0x7b, + 0x9a, 0x58, 0xd6, 0x03, 0xe8, 0x44, 0xd5, 0x2e, 0x61, 0x12, 0x43, 0x3b, 0xd9, 0x86, 0xba, 0xfa, 0x68, 0x15, 0xd6, + 0xe7, 0x2f, 0x68, 0x78, 0xb5, 0xdf, 0xd2, 0x23, 0x46, 0xcd, 0x1a, 0xde, 0x1f, 0x1e, 0x4a, 0x70, 0xb1, 0x69, 0xec, + 0x6c, 0xb3, 0x26, 0x0e, 0x3b, 0x7e, 0x0e, 0x2b, 0x08, 0xa6, 0x67, 0x47, 0x1b, 0xc6, 0x6a, 0x70, 0x7c, 0x95, 0x5f, + 0xed, 0x7a, 0x31, 0xa0, 0x26, 0x52, 0xdc, 0x29, 0x72, 0xc0, 0x00, 0x13, 0x2d, 0xe4, 0xcd, 0xd3, 0x79, 0xfc, 0x21, + 0xbe, 0x1e, 0x0f, 0xb4, 0x9f, 0x20, 0x8f, 0x9e, 0x05, 0x8a, 0x0c, 0x50, 0xd1, 0x93, 0xfb, 0x8b, 0x53, 0x28, 0xc3, + 0x6e, 0xa2, 0xd3, 0x41, 0xd1, 0xed, 0xdd, 0x23, 0x6f, 0x7c, 0xbc, 0xa9, 0xca, 0xe5, 0x3c, 0xc2, 0x40, 0xd7, 0x1b, + 0xd8, 0x40, 0x11, 0x19, 0xcb, 0x2a, 0xc5, 0x8f, 0x31, 0xaa, 0x0c, 0x51, 0x70, 0xab, 0x4f, 0x58, 0xc3, 0x45, 0x60, + 0xef, 0x10, 0x26, 0x09, 0xa3, 0x47, 0xee, 0xb9, 0xa9, 0x79, 0x72, 0xcd, 0xec, 0x3c, 0xca, 0x1c, 0xac, 0x2a, 0x0e, + 0x4c, 0x98, 0xb2, 0x41, 0x31, 0x79, 0x2c, 0x97, 0x72, 0xab, 0x55, 0x37, 0x73, 0xa2, 0x98, 0x1e, 0xd9, 0xc3, 0xd0, + 0xc2, 0x4d, 0xba, 0x21, 0x46, 0x7f, 0xe1, 0x85, 0x7e, 0xb4, 0x1a, 0x04, 0x43, 0xb4, 0xc2, 0xce, 0xda, 0x28, 0x67, + 0x8c, 0xa2, 0xf8, 0xfb, 0x02, 0x10, 0x6c, 0xeb, 0xfa, 0x96, 0xae, 0x3e, 0x79, 0x6b, 0x77, 0xab, 0x4a, 0xcf, 0x83, + 0x12, 0x23, 0x7e, 0xcd, 0x2a, 0xe7, 0x9d, 0xea, 0x40, 0xe2, 0x87, 0x50, 0x69, 0x01, 0x57, 0x84, 0xb0, 0x4a, 0xe3, + 0x60, 0x02, 0x9c, 0xce, 0x45, 0x53, 0xdf, 0x45, 0x03, 0x48, 0x28, 0x93, 0xf8, 0xe4, 0x3c, 0x9b, 0x84, 0x5a, 0x1e, + 0x1d, 0xd2, 0x7b, 0xb7, 0x0e, 0x42, 0xe1, 0x3b, 0x53, 0xad, 0x17, 0xdc, 0x3d, 0xa5, 0xfd, 0x7a, 0xed, 0x0b, 0x2b, + 0x95, 0xc6, 0xfd, 0x77, 0xd3, 0xc7, 0xb7, 0xdf, 0xf1, 0xe2, 0xa8, 0xef, 0x26, 0xce, 0x86, 0xe5, 0x5b, 0x1e, 0x80, + 0x37, 0x0b, 0x0e, 0x08, 0xf0, 0x11, 0xf5, 0x54, 0xa7, 0xfd, 0x1e, 0xba, 0xf1, 0x75, 0x66, 0xf6, 0x2c, 0xe9, 0xfc, + 0x9d, 0x1f, 0x7c, 0xd8, 0xb6, 0x20, 0xd0, 0x05, 0xe3, 0xff, 0xa3, 0xa5, 0x02, 0x02, 0x50, 0xf0, 0xf7, 0xe1, 0x75, + 0x38, 0x45, 0xc1, 0x73, 0x18, 0xf5, 0x71, 0x44, 0x99, 0xee, 0x9d, 0x34, 0xf9, 0x5e, 0x45, 0x36, 0xcb, 0xbc, 0x42, + 0x36, 0x61, 0x6c, 0x7a, 0x59, 0xa7, 0x7c, 0x6d, 0x66, 0x60, 0xac, 0xbe, 0x04, 0xa8, 0x8c, 0x44, 0x6f, 0x4a, 0xbf, + 0x84, 0x5f, 0x5f, 0x8a, 0xc5, 0x90, 0x07, 0xdf, 0x69, 0xf5, 0xda, 0xad, 0x8f, 0x8d, 0xdf, 0xae, 0xdc, 0x83, 0xa1, + 0x0f, 0x42, 0xee, 0xe7, 0x0d, 0x59, 0x19, 0x47, 0x9b, 0xe7, 0x05, 0x97, 0xc6, 0xcb, 0x28, 0x97, 0x86, 0x8e, 0x24, + 0x6a, 0x03, 0x7d, 0x5a, 0x5a, 0x72, 0xc0, 0x65, 0x48, 0x8c, 0xfd, 0x20, 0x2b, 0x3d, 0x3e, 0x92, 0xf6, 0xc1, 0xe4, + 0x18, 0x3e, 0x9f, 0x6e, 0x71, 0x11, 0xef, 0x44, 0x60, 0xc7, 0x40, 0x95, 0x1b, 0xae, 0xda, 0xdb, 0xbd, 0xbd, 0xfd, + 0xc3, 0xf6, 0xe1, 0x66, 0xfd, 0x75, 0x85, 0x0e, 0xa9, 0xc6, 0x38, 0x9d, 0x5a, 0xab, 0xb5, 0x9c, 0xb4, 0x85, 0xbf, + 0xb7, 0x2c, 0xda, 0x24, 0xa4, 0x48, 0x0c, 0x98, 0x5b, 0x46, 0x26, 0x55, 0x2b, 0x0f, 0x30, 0x91, 0x9a, 0xba, 0x4d, + 0x4f, 0xf7, 0x99, 0x92, 0xa5, 0x06, 0xbd, 0xd8, 0xe9, 0xaa, 0x10, 0xeb, 0xa5, 0xeb, 0xc7, 0x8b, 0xa5, 0xd7, 0xba, + 0x2e, 0xb0, 0x89, 0x6c, 0x18, 0x48, 0x1d, 0x7f, 0xc7, 0x46, 0xee, 0xd7, 0xc3, 0x93, 0x25, 0x80, 0xc2, 0x25, 0xd2, + 0x75, 0x09, 0x72, 0xb4, 0x29, 0x49, 0x48, 0x2e, 0x5e, 0xa1, 0x8a, 0xf1, 0xa4, 0x66, 0x7b, 0xf3, 0x6c, 0x21, 0x12, + 0x19, 0x4a, 0x19, 0x1b, 0xbb, 0x9b, 0x74, 0xef, 0x02, 0x1c, 0xd4, 0xa2, 0x2e, 0xd7, 0x17, 0x55, 0x80, 0xed, 0x9c, + 0xbf, 0x1a, 0x8d, 0xf3, 0xa8, 0x89, 0x6e, 0xd7, 0xb0, 0x2f, 0xbb, 0xe6, 0x4c, 0x6e, 0x2e, 0x9d, 0xe6, 0xf9, 0x91, + 0xcf, 0x16, 0xab, 0x67, 0x18, 0x5c, 0xee, 0x3a, 0x01, 0x03, 0x54, 0xee, 0x95, 0x01, 0x7c, 0xcb, 0x02, 0xeb, 0x06, + 0x73, 0x49, 0x64, 0x93, 0x44, 0x5b, 0xbb, 0xa7, 0x9c, 0x84, 0x26, 0xb7, 0xee, 0x59, 0xe2, 0xca, 0x0f, 0x82, 0xaa, + 0x6c, 0xf3, 0xb4, 0x5e, 0x34, 0xf7, 0x68, 0xe9, 0x7f, 0x7a, 0x58, 0x04, 0x45, 0x81, 0xe6, 0xe1, 0x2d, 0x52, 0x73, + 0x98, 0x05, 0x51, 0x63, 0x27, 0xbc, 0xa1, 0x7d, 0x60, 0xad, 0x6d, 0xd4, 0x8e, 0x54, 0xef, 0x6f, 0x90, 0x12, 0xd6, + 0xec, 0x92, 0x14, 0x2c, 0x2b, 0xe2, 0x72, 0xd0, 0x8e, 0x08, 0xf0, 0x58, 0xd9, 0x0a, 0x1e, 0xe5, 0xc5, 0xdd, 0x6c, + 0xec, 0x0b, 0x64, 0xac, 0xc9, 0x1c, 0x74, 0x0d, 0xbf, 0x45, 0xa8, 0xd6, 0x56, 0xb7, 0x83, 0xb5, 0x7b, 0xc3, 0x34, + 0xd1, 0x3a, 0x09, 0x76, 0x44, 0x49, 0xfb, 0x05, 0x07, 0x6e, 0xaa, 0xca, 0x8e, 0xdc, 0x5b, 0x89, 0x34, 0x68, 0x57, + 0xe8, 0xfc, 0x75, 0x37, 0x35, 0x02, 0xde, 0x4c, 0xa7, 0xe4, 0x28, 0xf1, 0x89, 0x94, 0x41, 0x41, 0x49, 0x72, 0xfe, + 0x9f, 0xf5, 0xb1, 0x03, 0x05, 0xf1, 0x8d, 0x9f, 0x7f, 0x17, 0x04, 0x38, 0xb0, 0xdb, 0x41, 0xd6, 0xbe, 0x1c, 0x4b, + 0x60, 0x51, 0x85, 0x39, 0xd7, 0x83, 0x5a, 0xff, 0x9e, 0x17, 0xe1, 0xf9, 0xaf, 0x17, 0x5b, 0xaa, 0x75, 0xdb, 0x5e, + 0xf7, 0x16, 0xc9, 0x35, 0x63, 0x3b, 0xec, 0xcb, 0xc1, 0x87, 0xd3, 0x4c, 0xb2, 0x05, 0x24, 0x0d, 0x99, 0xbe, 0x94, + 0x36, 0xe9, 0x86, 0x03, 0x72, 0x07, 0x64, 0x70, 0x10, 0x68, 0x32, 0x28, 0x6b, 0x78, 0xac, 0xe6, 0xe1, 0xbc, 0xbd, + 0x7a, 0xf2, 0xd7, 0x2a, 0x5f, 0xa2, 0x43, 0xea, 0x9d, 0xc5, 0x80, 0xff, 0x7e, 0x2b, 0x18, 0xc9, 0xf6, 0xcd, 0x7e, + 0x77, 0xd3, 0x94, 0xe2, 0x0a, 0xa6, 0xfd, 0x83, 0xff, 0x3f, 0xf4, 0x16, 0x5e, 0xef, 0x64, 0x68, 0xaa, 0xc3, 0x94, + 0x1b, 0xd6, 0x8b, 0x0b, 0xf9, 0xae, 0x4c, 0x8c, 0x11, 0x04, 0x46, 0x60, 0x56, 0x97, 0xe8, 0x1e, 0x86, 0x3b, 0xeb, + 0x51, 0xcd, 0x70, 0x72, 0x69, 0x33, 0x86, 0x55, 0x0b, 0x11, 0x01, 0x2e, 0x51, 0xa0, 0x44, 0x91, 0x20, 0x89, 0x01, + 0xa2, 0x7b, 0xeb, 0xf3, 0x08, 0x65, 0x51, 0xb3, 0xbe, 0xa1, 0xb6, 0xb3, 0xb2, 0x39, 0x09, 0x68, 0x6d, 0xe6, 0x98, + 0x56, 0xa3, 0x00, 0x9d, 0xbb, 0xd3, 0x00, 0x3a, 0xf4, 0x16, 0xe9, 0xa5, 0x8c, 0x15, 0xfb, 0xae, 0x67, 0x6d, 0xe9, + 0x90, 0x4f, 0xa2, 0xd6, 0xea, 0x20, 0xad, 0x55, 0x4e, 0x45, 0x66, 0x42, 0x5f, 0xe8, 0xd2, 0xc2, 0x19, 0xe8, 0x1b, + 0x6f, 0x0f, 0xd6, 0x78, 0x4a, 0x6f, 0xf2, 0xa5, 0x29, 0xe5, 0x65, 0x8f, 0x09, 0xf7, 0x3b, 0xa9, 0x8c, 0xed, 0xad, + 0x01, 0x91, 0x4b, 0xfa, 0xbb, 0x87, 0x84, 0x66, 0x1e, 0xbd, 0x0d, 0x38, 0xec, 0x82, 0x56, 0xfc, 0xaa, 0x7a, 0xbc, + 0x63, 0x82, 0x87, 0xa5, 0x34, 0xf9, 0xfe, 0xc5, 0x9b, 0x61, 0xd6, 0x30, 0x5e, 0x58, 0xec, 0x82, 0x80, 0x82, 0xd9, + 0x5b, 0xcc, 0xdd, 0xff, 0xe5, 0x8f, 0xd6, 0xc0, 0x8d, 0x99, 0x43, 0x6e, 0x3e, 0xe0, 0xf1, 0x3d, 0xbd, 0x4f, 0xbd, + 0x9b, 0xd5, 0xab, 0x4f, 0xa7, 0xc5, 0x85, 0x91, 0xf7, 0xed, 0x74, 0xb4, 0x47, 0x24, 0x5c, 0x03, 0x30, 0x01, 0x50, + 0x96, 0x78, 0x40, 0x09, 0x8b, 0xf7, 0xe5, 0xd2, 0x2a, 0x3b, 0x01, 0x4d, 0xb5, 0x67, 0x9b, 0x3a, 0x72, 0xe1, 0x19, + 0xdb, 0x51, 0x2c, 0x6d, 0xa7, 0x29, 0x61, 0xf2, 0x5a, 0xd7, 0xee, 0xf4, 0xf2, 0xa3, 0x34, 0x81, 0x9a, 0xa9, 0x5c, + 0x29, 0xbf, 0x46, 0xd6, 0x10, 0x7c, 0x0a, 0x8b, 0x28, 0x2a, 0xc0, 0xb3, 0xe8, 0x04, 0xaa, 0xd6, 0x0f, 0xed, 0x77, + 0x77, 0x58, 0x6c, 0x5d, 0x4c, 0x8f, 0x1f, 0x2a, 0x90, 0x79, 0xe6, 0xb8, 0x73, 0xa6, 0xd9, 0xd1, 0x4d, 0xe3, 0x5d, + 0x4c, 0xd9, 0x4f, 0x5f, 0xa0, 0x4f, 0x16, 0x66, 0x76, 0x2f, 0x68, 0x2c, 0x83, 0x27, 0x45, 0x36, 0x48, 0x91, 0xef, + 0xc2, 0x10, 0xc6, 0x48, 0xa5, 0x33, 0x35, 0x8f, 0xd1, 0xf4, 0xb7, 0xd0, 0x16, 0x4c, 0xed, 0xde, 0x53, 0x7d, 0xe8, + 0x7a, 0xa3, 0x54, 0x6b, 0xdf, 0x49, 0x99, 0x49, 0x2f, 0x61, 0xa4, 0x68, 0xb7, 0xd7, 0xea, 0xa7, 0x5f, 0x2b, 0x73, + 0xa9, 0xf6, 0xd2, 0x34, 0x79, 0x11, 0xdd, 0x29, 0xc8, 0xe2, 0x70, 0x31, 0xa5, 0xb4, 0x7d, 0x52, 0xfd, 0x7b, 0xbf, + 0xb8, 0x41, 0xfc, 0x6c, 0xfc, 0x63, 0xe6, 0xf3, 0xc0, 0x97, 0xba, 0xb4, 0x01, 0x72, 0x7f, 0x72, 0x6f, 0x95, 0x18, + 0x86, 0x21, 0x05, 0x64, 0xe5, 0x6a, 0x09, 0x58, 0x14, 0xc8, 0x03, 0x15, 0x10, 0x8d, 0x38, 0xa3, 0x1d, 0x52, 0x6b, + 0xd6, 0x97, 0x25, 0x40, 0x18, 0x70, 0xed, 0x2f, 0x34, 0xce, 0x7e, 0xb1, 0xb7, 0x20, 0xa8, 0x65, 0xc3, 0x4b, 0x9e, + 0x3f, 0x02, 0x23, 0x03, 0x84, 0x9c, 0x1e, 0x89, 0x3d, 0x8b, 0xd1, 0xbc, 0xa2, 0xb3, 0xe8, 0x81, 0x8c, 0x85, 0x9a, + 0x2a, 0x6f, 0xec, 0x04, 0x98, 0xdd, 0x07, 0x97, 0x54, 0xf5, 0x18, 0x0c, 0xe0, 0x05, 0x44, 0x05, 0xac, 0x68, 0x02, + 0x9d, 0xfa, 0xd8, 0x10, 0x07, 0x6f, 0x68, 0x51, 0x80, 0x20, 0xb0, 0x37, 0x10, 0xf6, 0x27, 0xd6, 0x1f, 0x5c, 0xcd, + 0xb0, 0xcb, 0x30, 0x8d, 0xe3, 0xd0, 0xd0, 0x9e, 0x82, 0x9f, 0x0a, 0x9b, 0x68, 0xaa, 0x04, 0x28, 0x37, 0x09, 0xb1, + 0x07, 0x01, 0xff, 0xca, 0x23, 0xf2, 0xb8, 0x6e, 0x6a, 0xff, 0x09, 0xa6, 0x38, 0x2a, 0x83, 0x75, 0x9b, 0xba, 0xeb, + 0xef, 0x75, 0x19, 0xc7, 0x35, 0xa0, 0xb0, 0xa5, 0x73, 0x9c, 0x1e, 0xd3, 0x10, 0xff, 0x6b, 0xa0, 0x7f, 0xd7, 0xaa, + 0xad, 0xef, 0x42, 0x6c, 0xd6, 0x66, 0xcc, 0x07, 0x0d, 0xbb, 0x8b, 0x13, 0xe3, 0xc8, 0xe3, 0xbe, 0xc0, 0xb4, 0x6b, + 0x89, 0x8f, 0x34, 0xf4, 0xe4, 0x11, 0x94, 0x9e, 0xae, 0x76, 0x95, 0xf1, 0xab, 0xf1, 0x78, 0x7b, 0xb3, 0xf5, 0x2a, + 0x86, 0x98, 0x11, 0x05, 0x6c, 0xf5, 0x3b, 0xeb, 0xf8, 0xe4, 0x60, 0x39, 0x8e, 0xb9, 0xf5, 0x12, 0x35, 0xae, 0x2f, + 0xb2, 0x14, 0x8b, 0x54, 0xfb, 0x72, 0xf7, 0x35, 0x1f, 0x4c, 0xaf, 0x7c, 0xfc, 0xfb, 0xf3, 0x50, 0x08, 0x2e, 0xa8, + 0x12, 0x23, 0xd1, 0x40, 0x77, 0x6e, 0x5b, 0x41, 0x0b, 0xbf, 0x95, 0x94, 0x56, 0x3c, 0x0f, 0x56, 0xa3, 0x5d, 0x02, + 0x42, 0x55, 0x03, 0x5e, 0x9f, 0xa2, 0xc9, 0x85, 0x03, 0xc7, 0x08, 0xb5, 0x68, 0x72, 0x96, 0x30, 0x9c, 0x74, 0xfb, + 0x6d, 0x7e, 0xfa, 0xeb, 0x9c, 0x0c, 0x91, 0x02, 0x90, 0xfa, 0x76, 0x4c, 0xf8, 0xf4, 0x3b, 0x5e, 0x4c, 0xfe, 0xf3, + 0x8d, 0x90, 0xbe, 0xe9, 0xc4, 0xc6, 0x43, 0x90, 0x37, 0x8a, 0x42, 0x84, 0x08, 0x76, 0x71, 0x20, 0xcc, 0x76, 0xf8, + 0x95, 0xdc, 0xc2, 0x57, 0xf4, 0x96, 0x9a, 0xa3, 0xa7, 0xd1, 0x41, 0x0b, 0x27, 0xac, 0x4d, 0x7f, 0x9e, 0x47, 0x5f, + 0x60, 0xc0, 0xe1, 0x33, 0x2b, 0xc0, 0x8d, 0x61, 0x15, 0xc0, 0x5a, 0x63, 0xee, 0x18, 0xbe, 0x96, 0xe9, 0x89, 0xb5, + 0xcc, 0x01, 0xf8, 0xb8, 0x92, 0xe3, 0x86, 0xee, 0x1c, 0x2a, 0x05, 0xf3, 0x76, 0x60, 0x8b, 0xfc, 0x9f, 0x69, 0x47, + 0x59, 0x55, 0x4c, 0x2c, 0x03, 0xe1, 0x72, 0x44, 0x42, 0xe6, 0xeb, 0xde, 0xc5, 0x20, 0x0a, 0x3e, 0x62, 0x64, 0xa7, + 0x54, 0x5c, 0xe7, 0x26, 0xbf, 0xea, 0x9f, 0x5f, 0x22, 0xf6, 0xba, 0x78, 0x5d, 0xbf, 0x7f, 0xe8, 0xef, 0xfe, 0xa4, + 0x15, 0xa0, 0x7a, 0xae, 0xec, 0xca, 0x6a, 0x26, 0x07, 0x9b, 0xc8, 0xf0, 0x73, 0xbd, 0x84, 0xca, 0xb4, 0x99, 0x00, + 0x21, 0x9c, 0xe3, 0x72, 0x72, 0x3d, 0x5a, 0x4c, 0xfc, 0x04, 0xd2, 0x18, 0x7a, 0x09, 0x4a, 0xe6, 0xfd, 0x11, 0x1e, + 0x5c, 0x0e, 0x08, 0xc4, 0xbb, 0xb8, 0x0a, 0x39, 0x5a, 0x1a, 0x24, 0x31, 0xbb, 0x9f, 0x62, 0x08, 0x25, 0x2e, 0x23, + 0x05, 0x6a, 0xd9, 0x9a, 0xb2, 0x6f, 0xc1, 0x72, 0x47, 0xd5, 0x61, 0x47, 0x98, 0x29, 0x4c, 0x95, 0xc8, 0x7f, 0x78, + 0x8c, 0xa4, 0x0a, 0x4f, 0xdd, 0xc9, 0xb3, 0x15, 0x52, 0x96, 0x93, 0x06, 0x12, 0x12, 0x78, 0x28, 0x44, 0x01, 0xfa, + 0x01, 0x5b, 0xa3, 0x8a, 0xc7, 0xff, 0x61, 0x5b, 0x02, 0xdd, 0x12, 0x9f, 0x58, 0x76, 0xbc, 0x61, 0x68, 0x0e, 0x79, + 0x8c, 0x44, 0x11, 0xb4, 0xc2, 0xcf, 0xaa, 0xe4, 0x07, 0x81, 0x12, 0x50, 0xc6, 0x45, 0x76, 0x14, 0xa8, 0x4a, 0x4c, + 0x70, 0x35, 0xd0, 0x83, 0xe8, 0xde, 0x65, 0xa0, 0x69, 0x3a, 0x78, 0xed, 0xd0, 0x30, 0x96, 0xc6, 0x54, 0x07, 0xdb, + 0x51, 0x21, 0x38, 0xd2, 0xe9, 0x90, 0x51, 0x70, 0x72, 0xfb, 0x0e, 0x97, 0x0d, 0x39, 0xdd, 0xee, 0x5a, 0xa1, 0xe8, + 0x19, 0xc8, 0xea, 0x5c, 0x6c, 0x9e, 0x67, 0x63, 0x22, 0x40, 0xfa, 0xc4, 0x3c, 0x54, 0x9c, 0x97, 0x19, 0x98, 0x36, + 0x79, 0xbc, 0x2d, 0x13, 0xc5, 0x1c, 0x4b, 0xae, 0x86, 0x7d, 0xc4, 0xd3, 0x7c, 0x3d, 0x93, 0xb2, 0xdf, 0x85, 0x01, + 0x47, 0x62, 0x98, 0xf5, 0x3b, 0xed, 0xf3, 0xda, 0x68, 0x73, 0x09, 0xf5, 0xa6, 0xc2, 0x24, 0xd1, 0xec, 0x58, 0x43, + 0xbd, 0x0a, 0x2d, 0x7e, 0x32, 0xb0, 0x7e, 0x0d, 0xa9, 0x37, 0x52, 0x33, 0xec, 0x8a, 0xe7, 0x23, 0x8f, 0x1f, 0xdd, + 0xa6, 0x56, 0xd6, 0x95, 0xd5, 0xcc, 0x36, 0x95, 0x18, 0xdf, 0x0f, 0xbb, 0xad, 0x6a, 0xcf, 0xb4, 0xca, 0xc7, 0xc3, + 0x97, 0x94, 0x4e, 0x07, 0xa2, 0xa9, 0x30, 0xb0, 0x87, 0x50, 0xc7, 0x02, 0xad, 0x8d, 0xc5, 0x2e, 0xca, 0xa3, 0x32, + 0xa5, 0xad, 0xd2, 0x18, 0xc6, 0x50, 0x1b, 0xc0, 0xd5, 0xed, 0x7a, 0x90, 0x96, 0x51, 0xd6, 0x5d, 0x4a, 0x0b, 0xc5, + 0x74, 0x0c, 0x6b, 0x85, 0x33, 0x25, 0xc3, 0x4d, 0x21, 0x4e, 0x03, 0x7c, 0x79, 0xe1, 0xff, 0xfe, 0x00, 0x56, 0xcd, + 0xed, 0x8e, 0x64, 0x1b, 0x97, 0x1d, 0x5d, 0x69, 0x85, 0xe7, 0xe9, 0xbc, 0x7c, 0x91, 0xb2, 0x2d, 0xd5, 0xa2, 0x61, + 0x1a, 0x1d, 0x65, 0x0c, 0xb5, 0x7d, 0xbb, 0x98, 0x31, 0x9c, 0x61, 0xc4, 0x5c, 0xf9, 0x06, 0x67, 0xbd, 0x96, 0xbc, + 0xfb, 0x2d, 0x23, 0xa7, 0x52, 0x5c, 0xf1, 0xa2, 0x4a, 0x0d, 0xaf, 0x7c, 0xf2, 0x1f, 0xe4, 0x6d, 0x52, 0xfc, 0x6a, + 0xd5, 0x18, 0x4a, 0x92, 0xcb, 0x89, 0xce, 0x9b, 0xd7, 0x70, 0xc2, 0xdb, 0x9e, 0xa6, 0x62, 0x86, 0xe2, 0xb1, 0x04, + 0xbf, 0xab, 0x3a, 0xdb, 0xcd, 0x1f, 0x7c, 0x2e, 0xc8, 0x5a, 0x4c, 0x96, 0xe5, 0xab, 0x0a, 0xce, 0xa4, 0x1e, 0x3f, + 0x7b, 0xb8, 0x53, 0x12, 0xa4, 0xba, 0x6d, 0xc8, 0xa7, 0x41, 0xa4, 0xb7, 0xcd, 0xea, 0x28, 0x43, 0x5e, 0x99, 0xd8, + 0x84, 0xa9, 0x53, 0xc7, 0x1b, 0x77, 0x5b, 0x2a, 0x26, 0x3b, 0x13, 0xe7, 0xe1, 0xff, 0xcc, 0x16, 0xbe, 0x4d, 0x3d, + 0xf9, 0x2b, 0xb6, 0x74, 0x90, 0xfc, 0x0a, 0xc4, 0x87, 0x63, 0x04, 0xf3, 0x39, 0x7d, 0x87, 0xc2, 0xa3, 0x8e, 0x65, + 0x60, 0x60, 0x62, 0xe5, 0xd9, 0x77, 0xfc, 0xdb, 0xf1, 0x96, 0x58, 0xa3, 0xb2, 0xca, 0x50, 0x0c, 0xc1, 0x20, 0xcd, + 0xeb, 0x00, 0x40, 0xae, 0x6c, 0x2a, 0xb6, 0x05, 0x22, 0x5b, 0x5e, 0x44, 0x8b, 0x77, 0xda, 0xb9, 0x11, 0xdc, 0x94, + 0xf8, 0x94, 0xbd, 0x3d, 0x65, 0x0c, 0x70, 0x0b, 0xec, 0x74, 0xec, 0xe0, 0x81, 0x98, 0x23, 0xa1, 0x76, 0x45, 0x16, + 0x4b, 0x52, 0x87, 0x8a, 0x45, 0xb3, 0xbe, 0x50, 0x62, 0x22, 0x86, 0x6c, 0x4d, 0x9d, 0x60, 0x45, 0xea, 0xa8, 0x3d, + 0x07, 0x16, 0x25, 0xcd, 0x3e, 0x43, 0x5e, 0x4c, 0x72, 0xc7, 0x44, 0x34, 0xe3, 0xc1, 0xcf, 0x42, 0x49, 0xcf, 0xbd, + 0x89, 0x85, 0xfc, 0xdd, 0x66, 0xf9, 0x0c, 0x7b, 0x87, 0x3f, 0x49, 0x15, 0xbe, 0x9c, 0xc2, 0x6a, 0x92, 0xd0, 0x56, + 0x2e, 0xbc, 0x5d, 0x12, 0xa0, 0x40, 0x59, 0xda, 0xa7, 0xc1, 0x81, 0x42, 0x1f, 0x0a, 0xca, 0x16, 0xcb, 0x94, 0x12, + 0x33, 0xe3, 0x22, 0xa6, 0xe4, 0x5e, 0xf4, 0x79, 0x3c, 0x5f, 0xd3, 0x77, 0x40, 0xa0, 0x72, 0xb3, 0xdf, 0x6c, 0x4c, + 0x72, 0xc0, 0xd0, 0x4c, 0x7f, 0xc2, 0x27, 0xb4, 0x7b, 0xbd, 0x64, 0x3f, 0x72, 0xe0, 0xfb, 0xc0, 0x71, 0x30, 0x7b, + 0xf2, 0x43, 0xca, 0x59, 0xab, 0xea, 0x3e, 0x0b, 0xf8, 0xfb, 0xe2, 0x05, 0xe2, 0xca, 0x24, 0x04, 0xba, 0x8b, 0x49, + 0x82, 0xd5, 0xa7, 0x60, 0x48, 0x3a, 0x01, 0x5d, 0xac, 0xb0, 0xb9, 0xd6, 0x6c, 0x39, 0x41, 0x17, 0x53, 0x59, 0xc1, + 0x9d, 0x3a, 0x94, 0xea, 0xe5, 0x61, 0x66, 0x3d, 0xac, 0xa6, 0xa7, 0x29, 0x48, 0x22, 0x9d, 0xec, 0xf6, 0x53, 0x92, + 0xbd, 0x26, 0x61, 0x64, 0xdf, 0x37, 0x33, 0x22, 0x00, 0xbe, 0xe8, 0x15, 0x22, 0xf6, 0xbd, 0x48, 0x39, 0x49, 0xa5, + 0x6b, 0xce, 0xe4, 0xb6, 0x42, 0x83, 0x58, 0x17, 0xfe, 0x55, 0x50, 0x37, 0xa5, 0xf9, 0x14, 0xdc, 0xa9, 0xbe, 0x81, + 0x5d, 0x02, 0xaf, 0xcd, 0xbb, 0x10, 0x34, 0x8d, 0x0d, 0xbe, 0x04, 0xb0, 0xb8, 0x0b, 0x03, 0x4f, 0xe0, 0x17, 0x5e, + 0x07, 0x70, 0xb3, 0x59, 0xa1, 0x56, 0x31, 0xd1, 0x9b, 0xf9, 0xa3, 0x5e, 0xd9, 0x78, 0xde, 0x9d, 0x04, 0x0b, 0xcb, + 0x49, 0x90, 0x7d, 0x86, 0x01, 0x2d, 0x5d, 0xbf, 0xe3, 0x62, 0x55, 0x0a, 0x2a, 0x21, 0xa4, 0xf4, 0xdd, 0xbf, 0x99, + 0xef, 0xe9, 0xb4, 0x5e, 0x8c, 0xf4, 0xcc, 0x00, 0x82, 0x5b, 0x92, 0x79, 0xd7, 0xd1, 0xb7, 0xa6, 0x67, 0x11, 0xf7, + 0x24, 0x2f, 0xbb, 0xcb, 0xae, 0x90, 0xd5, 0x22, 0xa6, 0xd4, 0xad, 0x9c, 0x5e, 0xc3, 0xbd, 0xcd, 0x3b, 0x09, 0xdc, + 0xcc, 0xe2, 0x96, 0x47, 0x09, 0x01, 0x57, 0x8e, 0xad, 0xa5, 0xd0, 0x30, 0xe2, 0xf5, 0x20, 0x83, 0x48, 0x90, 0xfe, + 0xed, 0x22, 0x43, 0xe9, 0x29, 0x9f, 0x8f, 0x6d, 0x24, 0xd4, 0xc3, 0x4d, 0xed, 0x08, 0x0e, 0xef, 0xde, 0x5c, 0x7d, + 0xc4, 0x1f, 0xa5, 0xd7, 0xf1, 0xa1, 0x37, 0x4e, 0xcb, 0xe5, 0x35, 0x36, 0x12, 0xc0, 0xed, 0xe3, 0xf6, 0x72, 0xe1, + 0x16, 0x0d, 0xcf, 0x6d, 0x35, 0xde, 0xed, 0xe8, 0x6f, 0x5f, 0xc0, 0xcd, 0xe7, 0xdb, 0x75, 0xe7, 0x7e, 0xf3, 0x33, + 0xe5, 0xe2, 0xa5, 0x8b, 0x8c, 0xe8, 0x82, 0xf1, 0xf2, 0x6a, 0x85, 0x14, 0x20, 0xcd, 0x0f, 0x60, 0xf7, 0xf1, 0xed, + 0x91, 0xee, 0x53, 0xd9, 0x2b, 0x24, 0x7d, 0xde, 0x2e, 0x15, 0x56, 0x22, 0x8e, 0x4f, 0x36, 0x8d, 0x2c, 0xe8, 0xb3, + 0x10, 0x5d, 0xaa, 0x9f, 0x92, 0x7c, 0x5e, 0xce, 0x0d, 0x3f, 0xfc, 0x74, 0x02, 0xba, 0x09, 0xcf, 0x06, 0x11, 0x94, + 0x45, 0x4e, 0x7b, 0x4a, 0x69, 0xdf, 0xc9, 0x3f, 0xa5, 0x28, 0xbc, 0x65, 0xa3, 0xfd, 0xd2, 0xaa, 0x9b, 0xfe, 0xac, + 0xba, 0x52, 0xbc, 0x7b, 0x78, 0xb5, 0xd9, 0x5d, 0xa6, 0xa1, 0x3c, 0x73, 0x73, 0xef, 0xab, 0x05, 0xfa, 0x15, 0xc9, + 0xc7, 0xc3, 0x00, 0x11, 0x57, 0xbb, 0xcb, 0x3c, 0x55, 0xbb, 0x67, 0x4d, 0xfb, 0x22, 0x6d, 0x0c, 0x57, 0x8e, 0x3d, + 0xbe, 0x7c, 0x12, 0x27, 0x17, 0xc7, 0xba, 0x39, 0x89, 0x54, 0x94, 0x8f, 0xf5, 0x57, 0x01, 0x86, 0x33, 0x6d, 0xce, + 0x40, 0xb2, 0xaa, 0xcb, 0x53, 0xa0, 0x1e, 0x99, 0x84, 0x27, 0x67, 0xf6, 0xcd, 0x6c, 0x00, 0xd8, 0x0c, 0x98, 0x86, + 0xd6, 0xbe, 0x9b, 0x27, 0xb3, 0xa8, 0x1a, 0x00, 0x47, 0xc9, 0x2f, 0x4e, 0x3d, 0x91, 0x65, 0x57, 0x58, 0xb3, 0xf1, + 0x7a, 0xe9, 0xee, 0xd7, 0x29, 0x49, 0x21, 0x3b, 0x67, 0x47, 0x91, 0x09, 0xf3, 0x71, 0x7c, 0xd5, 0xe8, 0x65, 0x69, + 0xfa, 0x86, 0x61, 0x00, 0x8b, 0x30, 0xcd, 0xdb, 0xdd, 0x76, 0xab, 0x3e, 0xad, 0x02, 0x42, 0xed, 0x9d, 0x73, 0x2b, + 0xed, 0x3f, 0x9c, 0x54, 0x34, 0x9c, 0xcd, 0x4b, 0x21, 0xd9, 0x57, 0x68, 0x50, 0x90, 0xd5, 0x98, 0x91, 0x8e, 0xf5, + 0x29, 0x09, 0x4c, 0x9b, 0x49, 0xfa, 0x76, 0x1b, 0xd4, 0x05, 0xa8, 0x4c, 0xf9, 0x72, 0x5d, 0x58, 0x53, 0x53, 0x6f, + 0x4c, 0xf1, 0xe5, 0xde, 0xbe, 0x40, 0xd3, 0xcc, 0xd0, 0x5e, 0xce, 0x6d, 0x28, 0x65, 0xbd, 0xec, 0x2a, 0xc2, 0x83, + 0x6c, 0xa5, 0xf3, 0xf8, 0x2e, 0xc9, 0xdf, 0xe4, 0x03, 0x6a, 0x2b, 0x16, 0x97, 0x7b, 0xf5, 0x22, 0x6e, 0x37, 0x19, + 0x9a, 0x11, 0x1a, 0x56, 0x53, 0xb0, 0xdc, 0xbd, 0xf9, 0x4c, 0xef, 0x66, 0x73, 0xf5, 0x39, 0xbb, 0xf8, 0xec, 0x60, + 0x1b, 0x24, 0x90, 0x7a, 0xc4, 0xca, 0x9a, 0xec, 0x21, 0x25, 0x86, 0x89, 0x69, 0xca, 0x9e, 0x00, 0x19, 0xc0, 0x1f, + 0x93, 0xf8, 0x7f, 0xfc, 0xfd, 0xef, 0xc1, 0x1d, 0xda, 0xef, 0xce, 0x17, 0x23, 0xef, 0xf9, 0x87, 0xd3, 0x03, 0xa7, + 0x9f, 0xdb, 0xfb, 0x38, 0xb7, 0x47, 0x44, 0x8d, 0x2a, 0x2e, 0x2a, 0x5a, 0xf1, 0xe4, 0x50, 0x55, 0x5a, 0x87, 0xf9, + 0x4e, 0xdc, 0x29, 0x15, 0xae, 0xdc, 0xcb, 0xe0, 0x7e, 0xbf, 0x1f, 0xae, 0xff, 0x5f, 0x9d, 0x2d, 0x59, 0x7f, 0xff, + 0x6f, 0x6b, 0xfa, 0x7f, 0xe9, 0x4d, 0x58, 0x1a, 0xee, 0x7f, 0x6b, 0x70, 0xe9, 0xb7, 0x67, 0x5a, 0x5f, 0xbb, 0xf6, + 0x6f, 0x1d, 0x20, 0x28, 0x64, 0x3f, 0xd9, 0xb3, 0x76, 0xe9, 0xa9, 0xcb, 0x2c, 0x06, 0xca, 0xc1, 0xff, 0x9f, 0x65, + 0x77, 0xec, 0xd9, 0x09, 0x53, 0x1b, 0x1f, 0xdf, 0xcf, 0x30, 0x0e, 0xb8, 0x55, 0x22, 0x8c, 0x71, 0xc8, 0xeb, 0xca, + 0xef, 0x6a, 0xe4, 0x73, 0x48, 0x27, 0xd6, 0x2a, 0xa0, 0x5f, 0xd6, 0x2f, 0x0a, 0xe2, 0xbe, 0x87, 0x3b, 0x13, 0xb1, + 0x24, 0x78, 0xa0, 0x6e, 0x9c, 0x0a, 0xca, 0x8f, 0xa4, 0x69, 0x7a, 0x8e, 0x92, 0x5f, 0xda, 0xff, 0x31, 0x5b, 0xc3, + 0xaa, 0xf7, 0x17, 0xc4, 0x8b, 0x93, 0xdb, 0x7f, 0x61, 0x21, 0xed, 0x1b, 0x92, 0x18, 0x1b, 0x53, 0xb7, 0x6e, 0x9c, + 0x3a, 0x9d, 0xde, 0xb3, 0xad, 0xea, 0x0c, 0xc2, 0x1f, 0x55, 0x29, 0x4c, 0xde, 0xae, 0x05, 0x51, 0x4d, 0xef, 0xb3, + 0x77, 0x75, 0x34, 0xa0, 0x96, 0x92, 0x67, 0x7e, 0x9b, 0xc1, 0xb3, 0x2b, 0x7c, 0xbf, 0x1a, 0xeb, 0xa7, 0xe0, 0x84, + 0x34, 0x72, 0x99, 0xb2, 0x7e, 0x04, 0x6b, 0xed, 0xe6, 0x83, 0x17, 0x38, 0x89, 0xce, 0xd9, 0x2a, 0xe7, 0x24, 0xaa, + 0xc6, 0xfb, 0x82, 0xf0, 0x3f, 0x67, 0x2c, 0x7c, 0x86, 0x86, 0x0b, 0xb1, 0x9c, 0x80, 0x6a, 0x4c, 0xe1, 0x98, 0x79, + 0xc7, 0xf5, 0x73, 0x7b, 0x6d, 0xbf, 0xf2, 0x8b, 0x21, 0xd2, 0x6c, 0x0c, 0xde, 0xaa, 0x7e, 0xc1, 0x50, 0xb2, 0x1f, + 0x0f, 0x7b, 0x70, 0xe8, 0xa5, 0xe9, 0x45, 0xd6, 0xfe, 0x29, 0x7c, 0x91, 0xaf, 0x7c, 0x48, 0x2d, 0xcd, 0x6b, 0xa5, + 0x18, 0x2f, 0x6e, 0xd8, 0xc5, 0xbf, 0x83, 0xf4, 0xc6, 0xec, 0xb0, 0xdb, 0xb8, 0x81, 0x22, 0x91, 0xc6, 0x1a, 0x32, + 0xf6, 0x3f, 0xad, 0x93, 0x1c, 0x26, 0x2c, 0x31, 0x08, 0xeb, 0x27, 0xb1, 0x79, 0xd5, 0xe7, 0x4e, 0xb2, 0x6f, 0x92, + 0x66, 0x57, 0xa1, 0x69, 0x00, 0x08, 0xcf, 0x1e, 0x91, 0xbb, 0xab, 0x8f, 0x96, 0x6c, 0x7b, 0xc9, 0xe5, 0x6f, 0xc3, + 0xc8, 0xd9, 0x87, 0x4d, 0x5b, 0x1b, 0x9c, 0xda, 0x9c, 0xc4, 0xa6, 0x8d, 0x55, 0xf8, 0xdc, 0x74, 0xc2, 0x7d, 0x7f, + 0xed, 0x59, 0x5c, 0x33, 0x2b, 0x89, 0xe2, 0xda, 0x0a, 0x71, 0x53, 0xf0, 0x03, 0x0c, 0x24, 0xcc, 0x18, 0x73, 0xb6, + 0x51, 0x20, 0x20, 0x49, 0x99, 0xb2, 0x6a, 0x43, 0x7c, 0xf9, 0x41, 0x0c, 0x70, 0x33, 0x13, 0x36, 0x01, 0xb5, 0xfe, + 0xc8, 0xca, 0x0d, 0x27, 0x4b, 0x42, 0xc8, 0xb8, 0xdb, 0x27, 0xbf, 0x60, 0x60, 0xc6, 0x8f, 0x18, 0xa5, 0xc6, 0x77, + 0xeb, 0xfd, 0x63, 0x26, 0x7f, 0xba, 0xfe, 0x93, 0x6d, 0xe3, 0xb7, 0xe1, 0x42, 0x19, 0xb6, 0xe6, 0x33, 0xb4, 0xac, + 0x0a, 0x0c, 0xca, 0xa8, 0xbc, 0xb3, 0x9e, 0xb9, 0xed, 0x93, 0x58, 0x55, 0x49, 0x7c, 0x43, 0xab, 0x32, 0x47, 0xf0, + 0xb8, 0x17, 0xa5, 0x34, 0x25, 0x58, 0x82, 0xdb, 0xf7, 0x2b, 0xe4, 0x2a, 0xe7, 0xe1, 0xcb, 0x13, 0x47, 0x92, 0x2b, + 0x17, 0xa5, 0x57, 0x6f, 0x38, 0xe2, 0xd4, 0xa5, 0x94, 0x9d, 0x65, 0x60, 0x4f, 0x36, 0x0f, 0xa9, 0x20, 0xa5, 0xa1, + 0x96, 0x6d, 0xdb, 0x5a, 0xf9, 0x25, 0x7a, 0x2d, 0xb5, 0xba, 0x60, 0x69, 0x29, 0xe0, 0xc6, 0x8c, 0x28, 0x8f, 0x6a, + 0xeb, 0xe6, 0xea, 0x28, 0xa5, 0x79, 0x50, 0x57, 0xc1, 0x43, 0x6d, 0x1e, 0xb9, 0xb0, 0x86, 0x5f, 0xfa, 0xf8, 0xe8, + 0x91, 0x31, 0x32, 0xed, 0x06, 0x3e, 0x9e, 0x66, 0xc3, 0x66, 0x07, 0x5f, 0xa8, 0x3a, 0x35, 0x21, 0x94, 0x2f, 0xd0, + 0x79, 0xa3, 0x4a, 0xb2, 0x1c, 0xbc, 0x42, 0xc6, 0x2d, 0x4e, 0x12, 0xf7, 0x6f, 0xc8, 0xfa, 0xa2, 0x58, 0x5a, 0xb4, + 0xa7, 0x95, 0x55, 0x41, 0x69, 0x9b, 0xd4, 0xfc, 0xd7, 0x98, 0x7e, 0xe5, 0x21, 0xa9, 0xa7, 0x35, 0xde, 0x1f, 0x72, + 0xbb, 0xe4, 0x1e, 0x77, 0xdf, 0x82, 0x33, 0xa3, 0x76, 0xbb, 0x02, 0x90, 0x76, 0x7d, 0x1c, 0x21, 0x91, 0x39, 0x11, + 0x4e, 0x29, 0xe9, 0xc1, 0x8d, 0x1c, 0xa1, 0xf9, 0xdd, 0x3e, 0xb6, 0x9a, 0x48, 0xb7, 0x70, 0x1c, 0xb1, 0xbf, 0x2c, + 0x63, 0x67, 0x70, 0x12, 0xaf, 0x5d, 0xfc, 0xda, 0x23, 0x14, 0xd9, 0x92, 0x4a, 0x7d, 0x6d, 0xc9, 0x95, 0x76, 0xf9, + 0x4e, 0xed, 0x65, 0xdc, 0xa1, 0xb0, 0x4d, 0x6f, 0x5d, 0x8a, 0xff, 0xc3, 0x29, 0xa5, 0xfa, 0x8e, 0xdf, 0xa8, 0xf4, + 0xb7, 0xdd, 0xfd, 0x5e, 0x6d, 0x05, 0xcb, 0xf9, 0xab, 0x1a, 0xd1, 0x36, 0xed, 0xda, 0x2e, 0x5a, 0xbc, 0x39, 0xd0, + 0xd6, 0xa1, 0xbe, 0x42, 0xff, 0xbc, 0x63, 0x54, 0x05, 0x3a, 0x24, 0x1d, 0xca, 0xb0, 0x99, 0x36, 0xe4, 0xc4, 0x6a, + 0x18, 0x84, 0xfd, 0xa2, 0x50, 0xfb, 0xe0, 0x7f, 0x32, 0x65, 0x45, 0x03, 0x6a, 0xcd, 0x39, 0xd3, 0x96, 0x33, 0xe0, + 0xfa, 0x64, 0xb3, 0xdb, 0xd4, 0x7a, 0xaa, 0x31, 0xce, 0x68, 0xca, 0xb0, 0xad, 0x5b, 0xb6, 0xec, 0xd6, 0xcd, 0x1c, + 0x49, 0xf1, 0x07, 0x33, 0xc3, 0x27, 0xfd, 0xe7, 0xd7, 0xba, 0x01, 0xca, 0xbb, 0x57, 0xef, 0x67, 0x72, 0xaa, 0x3a, + 0xe5, 0x4f, 0xf3, 0xf5, 0xd3, 0x5f, 0x2d, 0x79, 0xfd, 0xa3, 0xbf, 0x78, 0x89, 0xde, 0xf0, 0x17, 0x6c, 0x19, 0xe3, + 0x66, 0xbb, 0x4c, 0x7a, 0x09, 0x3a, 0x2b, 0x35, 0xfa, 0x6c, 0x83, 0xa5, 0xe0, 0x2e, 0x18, 0x09, 0xd4, 0xb4, 0x4d, + 0x59, 0x97, 0xf6, 0x7d, 0x71, 0xfd, 0x74, 0xa3, 0xad, 0x2f, 0xb6, 0xaa, 0x87, 0xb8, 0xef, 0xab, 0xd7, 0xc1, 0x7c, + 0x3e, 0xec, 0xbe, 0xfd, 0x84, 0x4d, 0xf8, 0xa7, 0x10, 0xa0, 0x0d, 0x3b, 0x3d, 0x56, 0x8d, 0x8b, 0xf7, 0xd5, 0xb0, + 0xb8, 0xae, 0xda, 0xe2, 0xac, 0x9a, 0x17, 0xe7, 0xd5, 0xf5, 0xe1, 0xdd, 0x5d, 0xbf, 0x65, 0xf8, 0x1b, 0x56, 0xd3, + 0x1b, 0xb2, 0xf6, 0x33, 0xa6, 0xa9, 0x65, 0xc2, 0xe9, 0x69, 0xb7, 0x7c, 0x84, 0xd3, 0x2e, 0xdd, 0x9d, 0xdd, 0x79, + 0xbc, 0x7d, 0x83, 0x5e, 0xa5, 0x68, 0x97, 0x05, 0x86, 0xea, 0xc4, 0x82, 0xc4, 0xbc, 0xc6, 0xb6, 0x37, 0xeb, 0x90, + 0x33, 0x18, 0xc8, 0x73, 0xc5, 0x35, 0xce, 0x5d, 0x8c, 0x99, 0xbc, 0xa1, 0x00, 0x85, 0x63, 0x49, 0x54, 0xc3, 0xaa, + 0x95, 0x15, 0x75, 0x24, 0xb1, 0x20, 0x88, 0x17, 0x4c, 0x9d, 0x54, 0xc1, 0x2e, 0xdd, 0xc8, 0xbb, 0x1a, 0xc1, 0x00, + 0xb7, 0x9d, 0x4d, 0xb9, 0xb8, 0x2f, 0x1a, 0xd9, 0x62, 0x2b, 0x55, 0x2d, 0xc2, 0x95, 0x48, 0x39, 0x2e, 0xad, 0x6f, + 0x99, 0xdb, 0xf7, 0xba, 0x5f, 0x9c, 0x97, 0xe2, 0x7f, 0xda, 0x01, 0x5e, 0x47, 0x86, 0xac, 0xec, 0x05, 0xbf, 0x52, + 0x32, 0xad, 0x13, 0xeb, 0x54, 0xd3, 0xba, 0xc6, 0x61, 0xf6, 0xf2, 0xd7, 0xf2, 0x40, 0x14, 0x23, 0xfa, 0xa2, 0x56, + 0x2a, 0x6b, 0x74, 0x98, 0xc4, 0x20, 0xd3, 0xd0, 0x94, 0x63, 0x0d, 0xad, 0x15, 0x67, 0xf1, 0x68, 0x57, 0x41, 0x62, + 0xe3, 0x5b, 0xf9, 0x35, 0x27, 0x36, 0xe8, 0x00, 0x62, 0x81, 0x8e, 0xcb, 0x3a, 0x13, 0xfe, 0x3f, 0xea, 0xa1, 0xdc, + 0x37, 0xfd, 0x9f, 0x28, 0xaf, 0x0a, 0xd1, 0x67, 0xe8, 0xdb, 0x25, 0x57, 0x70, 0x09, 0x31, 0xea, 0xc1, 0x9a, 0xa8, + 0xe6, 0xce, 0x6f, 0xd1, 0x27, 0x90, 0x02, 0x82, 0xa7, 0x33, 0x18, 0x9c, 0xa8, 0x36, 0xd2, 0xa0, 0x99, 0x11, 0xa9, + 0x18, 0x0a, 0xef, 0x47, 0x53, 0xb5, 0x6e, 0x47, 0x32, 0xb6, 0x57, 0x32, 0x6f, 0xf5, 0x6b, 0xab, 0x40, 0x61, 0x3e, + 0x5e, 0xae, 0x1a, 0x01, 0xa0, 0xe5, 0xef, 0xdb, 0x9f, 0xd4, 0xd5, 0x38, 0x7a, 0xd7, 0x6d, 0x0a, 0x47, 0xe7, 0x88, + 0x27, 0x86, 0xc5, 0x16, 0xa2, 0xd5, 0x13, 0xa8, 0xf9, 0x0e, 0x0d, 0x57, 0xed, 0x9b, 0xcc, 0x60, 0x5e, 0x4e, 0x4e, + 0x72, 0x7e, 0x87, 0xa9, 0x77, 0xbe, 0x67, 0x8a, 0x30, 0xa9, 0x09, 0xa2, 0xea, 0x3d, 0x14, 0x04, 0x0b, 0xf6, 0x42, + 0x0b, 0xf7, 0xeb, 0x51, 0x92, 0x82, 0xc9, 0x80, 0xae, 0x68, 0xed, 0x88, 0x95, 0x15, 0x53, 0x6a, 0x34, 0x12, 0x19, + 0xae, 0x72, 0xd3, 0xdf, 0xc7, 0x84, 0x4a, 0x01, 0x68, 0xb7, 0x7f, 0x21, 0x63, 0xe4, 0xe0, 0x82, 0x35, 0xd1, 0x6e, + 0x48, 0x43, 0x0b, 0xb7, 0x54, 0x16, 0x04, 0xf0, 0x82, 0x06, 0xab, 0xfd, 0x82, 0xca, 0x71, 0xe1, 0x13, 0x0b, 0x53, + 0xaf, 0x84, 0x5d, 0xf0, 0xe7, 0x86, 0xa5, 0xf5, 0xcf, 0x0f, 0x03, 0x8a, 0xf5, 0x0f, 0x61, 0xd8, 0x97, 0xcf, 0xf3, + 0x9c, 0xf8, 0xd8, 0x08, 0xc9, 0xd5, 0x56, 0x83, 0x10, 0x2f, 0x4a, 0x7a, 0x2b, 0x66, 0x16, 0xb5, 0xde, 0x1e, 0x9e, + 0xd7, 0xbe, 0x74, 0x07, 0xb1, 0xea, 0x97, 0xd8, 0xd8, 0xec, 0x6e, 0x40, 0x90, 0xfd, 0xa6, 0xa8, 0x94, 0xb1, 0xc9, + 0xf7, 0x3c, 0xc9, 0xee, 0xe5, 0xf3, 0x19, 0x81, 0x53, 0xf6, 0xd9, 0x67, 0xbe, 0x26, 0xe0, 0xcb, 0x9e, 0x1e, 0x9b, + 0x3d, 0xad, 0xb3, 0x73, 0x4e, 0x1f, 0x1e, 0xa2, 0x86, 0xda, 0x74, 0x2f, 0x0c, 0x86, 0x2b, 0x90, 0x5f, 0xb8, 0x4f, + 0x88, 0x09, 0x97, 0x9f, 0x9f, 0x46, 0x3b, 0x73, 0x27, 0xe4, 0xc1, 0xd9, 0xe1, 0x13, 0x50, 0x01, 0x33, 0x7b, 0xa7, + 0x92, 0xe6, 0x6d, 0xf5, 0x88, 0x8f, 0x5a, 0x91, 0xd8, 0x03, 0x98, 0xae, 0xbb, 0xe0, 0x3e, 0x5d, 0xef, 0x56, 0xf6, + 0x5d, 0x3c, 0x15, 0xa8, 0x7b, 0x6d, 0xab, 0x6d, 0xea, 0xaf, 0x74, 0xc7, 0xd3, 0x17, 0x85, 0x01, 0xc0, 0xec, 0x2e, + 0x41, 0x0b, 0xbe, 0x92, 0x18, 0xf6, 0xe0, 0xbd, 0x9c, 0xa4, 0xdf, 0x62, 0x07, 0x4f, 0xc6, 0xb5, 0x51, 0x0d, 0xd4, + 0xc2, 0x7c, 0x77, 0x43, 0xcd, 0xaa, 0x1a, 0x48, 0x9c, 0x23, 0xe1, 0x6c, 0xfd, 0xac, 0x3d, 0xe6, 0x8b, 0x9d, 0x2b, + 0x8e, 0xfd, 0x8f, 0x0a, 0xbf, 0xc2, 0xf6, 0x8c, 0xa5, 0x03, 0xaf, 0x0c, 0x2b, 0xe9, 0x18, 0x0c, 0xc8, 0xcf, 0x75, + 0x9c, 0x48, 0xa3, 0xf9, 0xfb, 0xe8, 0x8b, 0x04, 0x35, 0xd0, 0x6f, 0x7c, 0x1e, 0x5f, 0xba, 0xe4, 0x53, 0xad, 0x1f, + 0x08, 0xf8, 0x20, 0x03, 0x6a, 0xcf, 0xe8, 0x8c, 0x16, 0x4f, 0x73, 0xfd, 0x49, 0x7f, 0xcc, 0x25, 0xeb, 0x1f, 0xfd, + 0xd3, 0x2c, 0x4e, 0xad, 0xc5, 0x45, 0x35, 0xc1, 0x7b, 0x0a, 0xfb, 0x9e, 0x02, 0xfe, 0x2e, 0x59, 0x64, 0xc3, 0x32, + 0x9a, 0x47, 0xb1, 0xa6, 0x41, 0x94, 0xd4, 0xfa, 0xc8, 0xad, 0x4d, 0x3e, 0xf6, 0x7d, 0x0f, 0xab, 0x42, 0x5f, 0xe9, + 0xc2, 0x77, 0x55, 0x8b, 0xc5, 0x6c, 0xd5, 0x99, 0x48, 0xb9, 0x9e, 0x51, 0xa9, 0xc0, 0x11, 0x56, 0x9a, 0x23, 0xc7, + 0x34, 0xa5, 0xe1, 0xc0, 0xe1, 0x14, 0x6b, 0x52, 0x80, 0x7d, 0xfd, 0x4b, 0xdf, 0xda, 0x5a, 0x9e, 0x4f, 0xe1, 0xb6, + 0xe1, 0x2d, 0xce, 0xeb, 0x32, 0x94, 0xa4, 0x56, 0x01, 0xcb, 0xbe, 0x8a, 0x05, 0xc4, 0x45, 0xbe, 0xaa, 0x36, 0x27, + 0x8c, 0x51, 0x93, 0x0b, 0xb5, 0x87, 0xcc, 0x0d, 0xd4, 0x44, 0xa7, 0x90, 0x5e, 0x70, 0xda, 0x77, 0x93, 0xd8, 0x5a, + 0xd7, 0x32, 0xeb, 0xeb, 0xc4, 0x52, 0xa5, 0xcd, 0xb3, 0xbd, 0x23, 0x1d, 0x90, 0xcb, 0x98, 0x84, 0x20, 0x89, 0x25, + 0xa8, 0xf0, 0xd8, 0xfe, 0xaa, 0x9f, 0x8b, 0x04, 0x20, 0x81, 0xed, 0x8b, 0xf8, 0x32, 0x70, 0x94, 0xa4, 0xa2, 0x6a, + 0x6a, 0x6d, 0x06, 0x4c, 0xcc, 0x3b, 0x1d, 0x55, 0x6a, 0x51, 0x83, 0x20, 0x40, 0x64, 0xe2, 0x2c, 0x12, 0x39, 0x3d, + 0x8a, 0x1e, 0xee, 0x68, 0xa7, 0x85, 0x4c, 0xd1, 0x0a, 0x4a, 0x64, 0xed, 0x21, 0x49, 0x0f, 0x5f, 0x23, 0x14, 0x83, + 0x13, 0xe7, 0xcc, 0x05, 0xbf, 0xd7, 0x26, 0xbf, 0x9f, 0x5a, 0xe6, 0xde, 0xb5, 0xd8, 0x59, 0x7c, 0xe5, 0x51, 0xae, + 0x9e, 0x6c, 0x04, 0xdc, 0x0e, 0xe8, 0xee, 0x05, 0x05, 0xd8, 0xdb, 0x9b, 0x00, 0x03, 0xaf, 0xb4, 0xa8, 0xb5, 0x6c, + 0xe3, 0xb2, 0x5c, 0x13, 0xd6, 0x96, 0xfc, 0x9f, 0xdf, 0x4b, 0x27, 0x27, 0x9b, 0x28, 0x74, 0x34, 0xc9, 0xa9, 0x12, + 0x1d, 0x41, 0x1a, 0xc3, 0xaa, 0x17, 0x17, 0x90, 0x69, 0x4f, 0x93, 0x37, 0x6e, 0xd9, 0x12, 0x46, 0x66, 0x6f, 0x01, + 0xbb, 0xa7, 0xb7, 0x0c, 0x1c, 0xa9, 0xfa, 0xbf, 0x9f, 0xa6, 0x12, 0x3b, 0x05, 0x11, 0x84, 0x7a, 0xee, 0x58, 0xb2, + 0x0b, 0x64, 0x6c, 0xf5, 0x77, 0xcc, 0xb4, 0x69, 0xb2, 0x09, 0xe1, 0x11, 0x32, 0xe7, 0xbd, 0x72, 0x5b, 0x84, 0x18, + 0x4a, 0x0b, 0x52, 0xf0, 0xb5, 0xd3, 0x29, 0x82, 0xc3, 0x3c, 0x5d, 0x86, 0x0e, 0x1f, 0xc2, 0x19, 0x99, 0x31, 0xfe, + 0x54, 0xdc, 0x1b, 0x60, 0xde, 0x5d, 0x88, 0x1d, 0x26, 0xeb, 0x95, 0x21, 0x77, 0x44, 0x1e, 0xdf, 0x26, 0x79, 0x7a, + 0xb7, 0xcb, 0xa0, 0x4c, 0xe9, 0xf0, 0xc9, 0x24, 0xe2, 0x53, 0x71, 0xaa, 0x48, 0xb5, 0xa0, 0x6d, 0xf5, 0xed, 0xf7, + 0x65, 0xd0, 0x7b, 0xcf, 0xbe, 0xf5, 0x3e, 0x0a, 0x88, 0xae, 0x37, 0x0d, 0xdb, 0x34, 0x4f, 0x43, 0x83, 0x1c, 0xc3, + 0xfc, 0x74, 0x6b, 0x99, 0x4e, 0xd5, 0xe5, 0x2f, 0x7a, 0x6d, 0x91, 0x2f, 0x80, 0x4d, 0x3d, 0x0d, 0xaa, 0xb3, 0xda, + 0x26, 0x10, 0x21, 0x7d, 0x20, 0x66, 0x89, 0x8f, 0x62, 0xc5, 0xf8, 0xec, 0x35, 0x91, 0x0b, 0x7e, 0x96, 0x9f, 0x43, + 0xee, 0xed, 0x8d, 0x1f, 0xf9, 0xa4, 0xa0, 0xf7, 0xe3, 0x71, 0x76, 0x06, 0xf1, 0x7c, 0x9c, 0xce, 0x76, 0xaa, 0x20, + 0xa6, 0xbf, 0xfb, 0xff, 0x4c, 0x53, 0xd4, 0x1f, 0x20, 0x6c, 0x12, 0x2f, 0x0e, 0x13, 0xbc, 0x56, 0x09, 0x37, 0x09, + 0x3a, 0xa9, 0x7b, 0x28, 0x07, 0x6c, 0x22, 0x80, 0xaf, 0x3c, 0x23, 0x6e, 0x60, 0xba, 0x54, 0xf0, 0x34, 0xf2, 0x0e, + 0xc2, 0xe1, 0x4e, 0xc7, 0x93, 0x76, 0xb8, 0xaf, 0xa2, 0x8d, 0xc5, 0xe3, 0x63, 0x06, 0x91, 0x3f, 0x28, 0xfa, 0x9f, + 0x1a, 0x94, 0x46, 0x7e, 0xbe, 0x98, 0x2f, 0xcd, 0x5c, 0xad, 0xc7, 0x92, 0x36, 0x0a, 0x36, 0xab, 0x50, 0xba, 0x65, + 0xbc, 0x17, 0x17, 0xb6, 0xe8, 0x29, 0x34, 0xfb, 0xfd, 0x69, 0x52, 0x4e, 0xa5, 0xbd, 0xac, 0x5a, 0x93, 0x5e, 0x4b, + 0x6e, 0xef, 0x99, 0x4d, 0xf4, 0x13, 0x60, 0x25, 0x7e, 0x2b, 0x5a, 0xbc, 0xf4, 0x58, 0x94, 0xdf, 0xa5, 0x1a, 0x01, + 0x19, 0x82, 0xe7, 0x4f, 0x1e, 0x03, 0xbb, 0x15, 0xe9, 0xe9, 0xdf, 0x16, 0x97, 0xbe, 0x3b, 0x89, 0xd3, 0xff, 0x53, + 0xc8, 0xfe, 0xc0, 0x8f, 0x19, 0x58, 0x7f, 0xc6, 0x22, 0x55, 0x70, 0x09, 0xb7, 0xdb, 0xc4, 0xe6, 0x0b, 0xa8, 0x8a, + 0xcb, 0xed, 0xb9, 0xa3, 0x4a, 0xec, 0x27, 0x85, 0x0f, 0x3e, 0x8e, 0x4d, 0x6b, 0x11, 0xfe, 0x76, 0x17, 0x99, 0xfc, + 0xab, 0xe3, 0x12, 0x84, 0x57, 0xdd, 0xf8, 0xa0, 0xdf, 0x33, 0x5a, 0x3d, 0xcd, 0x7f, 0x9e, 0xfe, 0x9b, 0x25, 0xff, + 0xfc, 0xe8, 0x9f, 0x66, 0xe5, 0xad, 0x54, 0x3d, 0xe2, 0x01, 0x57, 0xe3, 0x25, 0xe2, 0xf1, 0xe4, 0xf5, 0xfc, 0xa3, + 0x64, 0x57, 0x75, 0x0d, 0x15, 0x5e, 0x9e, 0xc8, 0x05, 0x5a, 0x46, 0x35, 0xdb, 0x7a, 0x8e, 0x5e, 0x28, 0xd7, 0x1d, + 0xc5, 0x92, 0x44, 0x9b, 0x5e, 0x7e, 0x8b, 0xf4, 0x6a, 0x90, 0x24, 0x98, 0xed, 0xbf, 0x93, 0x35, 0x20, 0xd4, 0x1a, + 0x66, 0x56, 0xa9, 0x81, 0xc5, 0x73, 0xdb, 0x96, 0x94, 0xf3, 0x2a, 0xde, 0x1f, 0x45, 0x7e, 0xf9, 0x21, 0x0c, 0x58, + 0x0c, 0x46, 0x6f, 0x84, 0x26, 0xe0, 0x29, 0x22, 0x23, 0x47, 0x55, 0x5d, 0x48, 0xfc, 0x76, 0x47, 0x48, 0xbc, 0x95, + 0x4b, 0xa5, 0xaf, 0x04, 0x90, 0xaf, 0x65, 0xf5, 0xa9, 0xab, 0xc1, 0x5d, 0x7f, 0xd8, 0x93, 0xf4, 0x8d, 0x77, 0xe6, + 0x37, 0xea, 0xf2, 0x56, 0x69, 0xf4, 0x04, 0xcc, 0xce, 0x36, 0x4c, 0x65, 0xc4, 0x49, 0xe4, 0xd0, 0xe6, 0x62, 0x07, + 0x56, 0x99, 0x75, 0x33, 0xba, 0x82, 0x3f, 0x76, 0xe7, 0x2e, 0x24, 0x65, 0xcd, 0xb5, 0x4f, 0x32, 0xfd, 0xd0, 0x8a, + 0xe3, 0x2e, 0x81, 0xf1, 0xbe, 0xb4, 0xbc, 0x30, 0x3c, 0x45, 0x4a, 0x6d, 0x53, 0x0a, 0x1a, 0x90, 0x5f, 0xc5, 0x43, + 0x8a, 0x36, 0x41, 0x20, 0x27, 0x7b, 0xa5, 0x95, 0xea, 0x23, 0x95, 0xbb, 0xec, 0x19, 0xd3, 0xe6, 0x01, 0xa7, 0xd9, + 0x0c, 0x4a, 0x60, 0x9c, 0x4e, 0xfb, 0x64, 0x6d, 0x37, 0x9d, 0xdb, 0x6f, 0x92, 0xb2, 0xf0, 0x6b, 0x14, 0x4a, 0x6e, + 0xfe, 0x36, 0xfd, 0xfb, 0x96, 0xaf, 0x9e, 0xf9, 0x47, 0x82, 0xbd, 0xd2, 0x9f, 0xfd, 0xf5, 0xbe, 0xb2, 0x8b, 0x73, + 0xa5, 0x5b, 0x87, 0x85, 0xe5, 0xe2, 0x61, 0x7f, 0x74, 0x24, 0x80, 0x4c, 0x10, 0x2b, 0xdd, 0xb0, 0xc6, 0xf0, 0xfb, + 0x44, 0xcd, 0x3e, 0xf3, 0x8b, 0xa3, 0xa3, 0x61, 0xe5, 0x1b, 0xbb, 0x59, 0x27, 0x58, 0x0e, 0xff, 0xcf, 0xfd, 0x97, + 0xcd, 0x37, 0xbb, 0xcd, 0xe1, 0xc6, 0xc6, 0x6e, 0x9f, 0x05, 0xc6, 0x31, 0x37, 0xd7, 0x6b, 0x04, 0xc6, 0x48, 0xed, + 0xd0, 0xe4, 0x87, 0xc6, 0x99, 0xe3, 0xaa, 0x4c, 0xd9, 0x3b, 0xa2, 0x16, 0x69, 0x5c, 0xcf, 0x4a, 0x8e, 0xb4, 0xd0, + 0x2e, 0x96, 0xc5, 0xa1, 0x51, 0x24, 0x34, 0xad, 0x17, 0x1b, 0x39, 0xee, 0x87, 0xe7, 0xb3, 0x61, 0xc0, 0x53, 0xc2, + 0xda, 0x81, 0xb3, 0x11, 0x13, 0x41, 0x86, 0xdb, 0x29, 0x42, 0x37, 0xe4, 0x60, 0x80, 0x6e, 0xe8, 0x1c, 0xc1, 0x73, + 0x27, 0x67, 0xce, 0x8f, 0x0b, 0x6f, 0x98, 0x90, 0x0c, 0xa3, 0x04, 0x90, 0x63, 0xb2, 0x92, 0x6e, 0xdc, 0xdb, 0xbd, + 0x69, 0x77, 0x5e, 0x50, 0xd5, 0xc5, 0x50, 0x5b, 0xea, 0x49, 0x47, 0xea, 0xc5, 0x07, 0x12, 0xc3, 0xb6, 0xd3, 0xc9, + 0xf3, 0xca, 0xe8, 0xd5, 0x44, 0xf7, 0xfb, 0x98, 0xe6, 0xba, 0x2f, 0x9a, 0x23, 0xba, 0x02, 0x96, 0x33, 0x99, 0x5d, + 0x4b, 0xc2, 0xd9, 0xee, 0x3e, 0x9a, 0xd0, 0x73, 0x8d, 0x63, 0x51, 0x28, 0x14, 0x6c, 0x69, 0xba, 0x1b, 0xcf, 0xac, + 0xc3, 0xc5, 0x3f, 0xd4, 0xc5, 0x55, 0x06, 0x8a, 0xb3, 0xa6, 0x77, 0x22, 0x71, 0xdf, 0x46, 0x17, 0x06, 0x38, 0x41, + 0x93, 0x8b, 0x1e, 0xf6, 0x44, 0x18, 0x5a, 0x50, 0xd3, 0x5c, 0xca, 0x9f, 0x5b, 0x8f, 0x89, 0x6e, 0x30, 0x38, 0xce, + 0x95, 0x59, 0x4e, 0x4d, 0x1e, 0x0a, 0x57, 0x4a, 0xae, 0xb0, 0x9d, 0x59, 0x5c, 0x36, 0x4b, 0xa5, 0xf0, 0xfe, 0x7f, + 0x71, 0xf0, 0x4c, 0x48, 0xbb, 0x6a, 0xd4, 0xa6, 0xfa, 0x04, 0x3e, 0x03, 0x57, 0x52, 0x39, 0xd9, 0xc4, 0x1f, 0x06, + 0xb8, 0xd3, 0x1f, 0x44, 0x77, 0xcb, 0x86, 0x4b, 0x6e, 0x43, 0x1e, 0x0a, 0x0d, 0xc9, 0xd8, 0x07, 0xc3, 0xd5, 0xe7, + 0x51, 0xf6, 0xf0, 0xf8, 0x3b, 0x46, 0x6b, 0x54, 0xbd, 0xb8, 0x6e, 0x16, 0x3f, 0x70, 0x61, 0xdd, 0xa9, 0xab, 0x5f, + 0x51, 0xde, 0xfc, 0x69, 0xd9, 0x87, 0x55, 0x7e, 0x42, 0x16, 0xd8, 0xd7, 0xf2, 0xe6, 0x04, 0xac, 0xc5, 0x1c, 0x54, + 0x23, 0xf9, 0x45, 0x29, 0x0d, 0xec, 0x80, 0x69, 0xca, 0x35, 0x5a, 0x66, 0xea, 0x4f, 0x3d, 0x38, 0x19, 0x5f, 0x37, + 0x1c, 0x4a, 0x67, 0x77, 0xff, 0xd2, 0x71, 0x0f, 0xa1, 0x29, 0xd2, 0x84, 0xbf, 0x3e, 0x9e, 0xd8, 0x38, 0xb1, 0x8a, + 0x5a, 0x60, 0x5c, 0x39, 0xee, 0xef, 0xad, 0xae, 0x73, 0xf5, 0xd2, 0x87, 0x18, 0x48, 0x92, 0x69, 0xbc, 0x50, 0x09, + 0x52, 0x11, 0xaf, 0x50, 0x70, 0xda, 0xde, 0xef, 0xae, 0xec, 0x51, 0xde, 0xfe, 0xa7, 0x78, 0x33, 0xa3, 0xf9, 0x57, + 0x78, 0x79, 0x2f, 0xd7, 0xef, 0xba, 0xf3, 0xf5, 0x95, 0xfd, 0xb0, 0xdb, 0xff, 0x34, 0x03, 0xc9, 0x55, 0x2a, 0xfd, + 0xe9, 0x52, 0xcf, 0x67, 0x9f, 0x00, 0xf0, 0xeb, 0x95, 0xa1, 0x86, 0x64, 0x58, 0x13, 0xcd, 0x44, 0xc2, 0x5a, 0x25, + 0x62, 0x7c, 0xb3, 0x84, 0xaf, 0x69, 0x77, 0x45, 0x78, 0xa4, 0x8c, 0x8d, 0xb3, 0xb6, 0x43, 0xd6, 0xc7, 0x4c, 0x90, + 0xdd, 0x16, 0xcc, 0xf5, 0xd3, 0xac, 0x9f, 0x86, 0x55, 0xb5, 0x08, 0xd5, 0x27, 0x94, 0xe9, 0xf3, 0x68, 0x00, 0xdd, + 0xa0, 0x70, 0x64, 0x68, 0x24, 0x32, 0x36, 0xfa, 0x61, 0x37, 0x11, 0x1d, 0x47, 0x64, 0x44, 0x64, 0x25, 0x45, 0x21, + 0x9a, 0x4d, 0xfc, 0xf8, 0xfc, 0x27, 0xa5, 0x5a, 0x50, 0x24, 0xe1, 0x1a, 0x80, 0xa4, 0xf6, 0xc3, 0x35, 0x04, 0xa6, + 0xfa, 0xc3, 0xb6, 0x35, 0xe8, 0xd8, 0xc8, 0xca, 0x86, 0xa4, 0x8e, 0xa4, 0xbf, 0x0d, 0x22, 0x49, 0xa6, 0x72, 0x93, + 0x8c, 0x8d, 0x90, 0x03, 0xcc, 0x3b, 0x5a, 0x9b, 0x6f, 0x46, 0xd4, 0x91, 0x74, 0x4c, 0x58, 0x89, 0x3d, 0xa2, 0x30, + 0xc1, 0x11, 0xc2, 0xfd, 0x9a, 0xec, 0x42, 0xff, 0x29, 0xc0, 0xf6, 0x53, 0x43, 0xa2, 0x66, 0xfb, 0x48, 0xc3, 0xa7, + 0xd0, 0xf3, 0x10, 0xe2, 0x6d, 0x98, 0x97, 0x10, 0x16, 0xb9, 0xc1, 0x0e, 0xf4, 0x5e, 0x90, 0xa9, 0x08, 0x6f, 0x24, + 0x6c, 0x62, 0x2d, 0x10, 0x80, 0x67, 0xeb, 0x3e, 0x15, 0x1c, 0x00, 0xa4, 0xcd, 0xca, 0xb1, 0x7c, 0x7f, 0x3c, 0x90, + 0x43, 0x5b, 0x9a, 0x1d, 0xa9, 0x3b, 0xc4, 0xa5, 0x34, 0x9f, 0xe8, 0xd8, 0x1a, 0xc9, 0x41, 0xc2, 0x68, 0xc5, 0x33, + 0xb9, 0x28, 0x9b, 0x76, 0x7e, 0x18, 0xde, 0x57, 0xa5, 0x26, 0x9e, 0xb4, 0xbd, 0xca, 0x1c, 0xc5, 0xe4, 0xf1, 0xd0, + 0xd7, 0xba, 0x0d, 0x97, 0x1e, 0xf4, 0x34, 0x1c, 0x4f, 0x52, 0xfe, 0x7a, 0x8e, 0x62, 0x6d, 0xfc, 0x30, 0xd2, 0x50, + 0x01, 0x19, 0x1e, 0xdc, 0x72, 0xd9, 0x6c, 0xf5, 0xc3, 0xee, 0xf8, 0x61, 0x13, 0x3e, 0xda, 0x8b, 0x6b, 0x33, 0xa7, + 0x97, 0x41, 0x1a, 0xcc, 0x87, 0x92, 0x82, 0x2b, 0xab, 0xc6, 0xbe, 0x37, 0x95, 0xd4, 0xfe, 0xdd, 0xa6, 0x60, 0xdb, + 0xda, 0x46, 0x2f, 0xae, 0x3f, 0x2a, 0x91, 0xf9, 0xfa, 0xdd, 0x34, 0xee, 0x76, 0x76, 0xdb, 0x82, 0x68, 0x84, 0x95, + 0x3b, 0x26, 0x96, 0xd3, 0x6f, 0x9a, 0x74, 0x73, 0x43, 0xe8, 0x23, 0x8a, 0x7f, 0x9b, 0x94, 0xe3, 0xb3, 0xc3, 0xf3, + 0x6b, 0xe8, 0x41, 0x13, 0xa6, 0xaa, 0xc7, 0xe9, 0x0e, 0x16, 0x89, 0xe2, 0x09, 0xaf, 0x88, 0x44, 0xf6, 0xea, 0x87, + 0x43, 0xc6, 0x12, 0x85, 0x21, 0xd2, 0x98, 0xc7, 0x0f, 0xbb, 0x74, 0xd8, 0x79, 0x18, 0xc6, 0x09, 0x70, 0xd9, 0x97, + 0x94, 0xbc, 0xb1, 0x86, 0xdf, 0x7e, 0x0e, 0x4c, 0xfb, 0x7e, 0x7b, 0x9f, 0xe9, 0xad, 0x78, 0x69, 0x6c, 0xbc, 0xde, + 0xa1, 0x10, 0x21, 0xa2, 0x9c, 0x36, 0x3e, 0xae, 0x7f, 0xa4, 0xd8, 0xb0, 0x65, 0x59, 0xae, 0x18, 0xdd, 0xe2, 0xd7, + 0xc0, 0x26, 0x34, 0x6c, 0x87, 0x90, 0x3e, 0xb2, 0x6b, 0x5e, 0x09, 0x68, 0x55, 0x0f, 0x4b, 0xbd, 0xa2, 0x0b, 0x68, + 0x39, 0xc7, 0x48, 0xd9, 0x40, 0x19, 0x28, 0xf8, 0x17, 0x67, 0xd0, 0x55, 0x36, 0xb3, 0xcc, 0xd6, 0xc8, 0x82, 0x7f, + 0x10, 0x4e, 0xe7, 0x4f, 0xa2, 0xd5, 0x84, 0x2c, 0xe1, 0x52, 0xf1, 0x16, 0x14, 0xd8, 0x4a, 0x31, 0x05, 0x06, 0xb4, + 0x7d, 0x22, 0x8d, 0x5f, 0x8c, 0x69, 0x05, 0xd4, 0xd1, 0xe3, 0x32, 0xca, 0xe0, 0x33, 0xad, 0x2b, 0x16, 0x97, 0x41, + 0x7b, 0xa0, 0x31, 0xfc, 0x6b, 0x6b, 0xec, 0x5b, 0xdb, 0x65, 0xfe, 0x3d, 0xe0, 0x35, 0xb5, 0xa7, 0x14, 0x62, 0x05, + 0xd1, 0x01, 0xb2, 0x76, 0x0d, 0x9d, 0xbd, 0x67, 0xcf, 0xc7, 0xd6, 0x72, 0x05, 0x53, 0xe8, 0xa0, 0x62, 0x78, 0x83, + 0xcd, 0xfd, 0x23, 0x85, 0x33, 0x0d, 0xe9, 0x3c, 0xb3, 0x5a, 0x91, 0xcb, 0x14, 0xd4, 0x88, 0x7f, 0x9d, 0x3b, 0x58, + 0x24, 0x51, 0x3d, 0xe2, 0x14, 0x91, 0xa6, 0x93, 0x05, 0x26, 0xa1, 0x8e, 0xd4, 0xd0, 0x76, 0xbb, 0x82, 0x27, 0xca, + 0x4f, 0x38, 0xfd, 0x9b, 0xa5, 0x6b, 0xd4, 0x16, 0x7c, 0x0e, 0xcd, 0xe2, 0x0f, 0x51, 0x4b, 0x7f, 0xfd, 0xf1, 0xc1, + 0x00, 0x01, 0xc4, 0xdb, 0xb3, 0x41, 0x08, 0x13, 0x4f, 0xc7, 0xd6, 0x99, 0x7c, 0xc8, 0x40, 0x30, 0x9b, 0x6a, 0x84, + 0x6c, 0x84, 0xb9, 0xb5, 0x77, 0xd3, 0x3a, 0xf9, 0x03, 0xa7, 0xc0, 0x14, 0xe2, 0x84, 0xed, 0xa0, 0xc0, 0xfc, 0x61, + 0x1c, 0x45, 0x08, 0xf5, 0xe5, 0xd7, 0x22, 0x19, 0xc9, 0xf9, 0x36, 0x98, 0x8b, 0x18, 0x25, 0xd8, 0x5a, 0xf1, 0x5a, + 0x3f, 0x20, 0xaa, 0xda, 0xef, 0x4d, 0x86, 0xed, 0x8c, 0x3e, 0xbe, 0x1b, 0x4f, 0x8a, 0x6f, 0x1c, 0xd7, 0x73, 0x18, + 0xca, 0xfb, 0x67, 0x48, 0xa2, 0x65, 0xde, 0xff, 0xc4, 0xb9, 0xdb, 0xd5, 0xb1, 0x09, 0x2f, 0x6a, 0x03, 0xc3, 0x84, + 0xb0, 0xc1, 0xed, 0x79, 0x9b, 0xec, 0x34, 0x58, 0x9c, 0x4e, 0x17, 0xbc, 0xe1, 0x1a, 0x85, 0x7d, 0xb6, 0x33, 0x29, + 0xee, 0x5d, 0xfb, 0xd7, 0x71, 0x23, 0x1a, 0xf7, 0x19, 0x93, 0x90, 0x7f, 0x67, 0x39, 0x53, 0x9a, 0x3e, 0xad, 0x0a, + 0x4f, 0xfa, 0xce, 0xd9, 0xcd, 0x7c, 0x04, 0x17, 0xed, 0x6f, 0x80, 0xe5, 0x4e, 0xb6, 0x39, 0x27, 0x79, 0x46, 0xf3, + 0x0b, 0xbc, 0xd4, 0xd2, 0xcf, 0xed, 0xb4, 0xea, 0x40, 0x74, 0xb7, 0x00, 0x15, 0x0c, 0xd4, 0xe1, 0x81, 0xb7, 0x63, + 0x3b, 0xc4, 0xa7, 0x1a, 0x8c, 0x41, 0x60, 0xfa, 0x0f, 0xdf, 0xcd, 0xf5, 0x2e, 0x14, 0xa2, 0xcf, 0xa2, 0xe5, 0x2e, + 0xa3, 0x2f, 0xec, 0x84, 0x28, 0x23, 0x17, 0x31, 0xfa, 0x39, 0xba, 0x4b, 0xc8, 0x0d, 0x32, 0x17, 0x11, 0x54, 0xdc, + 0x93, 0xef, 0x88, 0x1f, 0xb0, 0x0b, 0x20, 0x1a, 0xc4, 0x39, 0x87, 0x8a, 0xfe, 0x26, 0x94, 0xa2, 0xd9, 0x61, 0x3c, + 0xff, 0xbb, 0x2c, 0x42, 0xe4, 0xcf, 0xa3, 0x78, 0x57, 0xc8, 0xf7, 0xee, 0xb1, 0xc5, 0x48, 0xf0, 0xc5, 0xb7, 0x41, + 0x2f, 0xe4, 0xc9, 0x9e, 0xc8, 0x20, 0xba, 0xf1, 0x0b, 0xa9, 0x56, 0x89, 0x5c, 0x5d, 0x64, 0x2c, 0x98, 0x5f, 0x21, + 0xa7, 0x3f, 0x6d, 0xef, 0xfc, 0xf2, 0x0f, 0x0c, 0xea, 0x98, 0xc5, 0x7f, 0x36, 0xee, 0x9b, 0x50, 0xa4, 0xef, 0xc5, + 0xe3, 0x03, 0xe2, 0x07, 0xd1, 0xf5, 0x2e, 0x41, 0xb1, 0x95, 0xcc, 0x09, 0x41, 0xa2, 0x70, 0x7c, 0x51, 0x7b, 0xff, + 0x9d, 0xde, 0x85, 0x9b, 0xa8, 0x3d, 0x08, 0xe8, 0x27, 0xff, 0xf0, 0xcb, 0x1f, 0x10, 0x1f, 0x88, 0x2c, 0xb8, 0xbe, + 0x9b, 0x67, 0xab, 0x3f, 0x71, 0x9e, 0xbb, 0x18, 0x44, 0x9f, 0x80, 0x0a, 0x12, 0x56, 0xa9, 0x9e, 0xc1, 0x03, 0xf6, + 0x3f, 0x2c, 0x5c, 0x8d, 0x78, 0xfd, 0xf8, 0xf4, 0x26, 0x5e, 0x43, 0xe7, 0x0a, 0xab, 0x0e, 0x5f, 0x80, 0xc8, 0x21, + 0xb9, 0x54, 0x5d, 0xec, 0x38, 0xd3, 0xff, 0x55, 0x02, 0x36, 0xde, 0x11, 0xc1, 0xe9, 0xfc, 0xc3, 0xcb, 0x17, 0x1b, + 0x7b, 0xb2, 0x9b, 0xdb, 0x61, 0xfc, 0x93, 0x06, 0x96, 0x70, 0x5f, 0xd3, 0xf4, 0x47, 0xc6, 0xe4, 0xd3, 0xfc, 0xf6, + 0x49, 0x3f, 0x1f, 0x4b, 0xbe, 0xfd, 0xe8, 0x17, 0xfe, 0xa8, 0x5f, 0xf3, 0xec, 0x57, 0xb2, 0x26, 0x3b, 0xec, 0x35, + 0xc0, 0xa7, 0xbd, 0xf1, 0xa5, 0xe5, 0xfa, 0x5a, 0xc5, 0xf8, 0x8b, 0x51, 0xe8, 0xd3, 0xef, 0x2e, 0x1f, 0xbc, 0x92, + 0x77, 0x0b, 0x25, 0xcd, 0x54, 0x50, 0xe7, 0xd6, 0xa6, 0xb6, 0x15, 0xda, 0x4d, 0x30, 0x09, 0xf6, 0x06, 0x05, 0x91, + 0x46, 0x15, 0x9e, 0xc8, 0xa2, 0x6d, 0x19, 0x94, 0x0a, 0x86, 0xd2, 0x1c, 0x47, 0x5d, 0x0f, 0x89, 0x03, 0x46, 0xf4, + 0x8c, 0x68, 0x55, 0xab, 0x38, 0x1d, 0x1d, 0x2c, 0x04, 0x9c, 0x42, 0x84, 0x11, 0xc8, 0xf7, 0xea, 0x84, 0x0a, 0x74, + 0x21, 0x69, 0x08, 0xf1, 0x3b, 0xe9, 0x58, 0x8a, 0xe2, 0xda, 0x0a, 0x5f, 0xef, 0x3f, 0xc9, 0xc6, 0xca, 0x47, 0x01, + 0x16, 0xe5, 0x1d, 0x4a, 0xa9, 0x0e, 0x29, 0x98, 0x5c, 0xa4, 0x2e, 0x47, 0xcc, 0x9c, 0x8f, 0x64, 0xb3, 0xe0, 0xb0, + 0x9a, 0x5b, 0xd1, 0x6e, 0x9c, 0xe5, 0xe0, 0xd0, 0x2a, 0xe3, 0x30, 0x86, 0x24, 0x37, 0xf9, 0x35, 0x0a, 0x28, 0x27, + 0xeb, 0x53, 0xdc, 0x02, 0xdf, 0x72, 0xfb, 0x8c, 0x5c, 0xa5, 0xd0, 0xd9, 0x23, 0xdf, 0x33, 0xfc, 0xc1, 0xe3, 0xfd, + 0xee, 0x73, 0x78, 0x34, 0x65, 0xd5, 0x84, 0xb5, 0x7f, 0xb4, 0x21, 0x21, 0x94, 0x02, 0x55, 0x04, 0x08, 0x53, 0x65, + 0x0d, 0xac, 0xeb, 0x90, 0x9a, 0x43, 0x4d, 0xd7, 0x9f, 0x58, 0xe4, 0x88, 0x77, 0x98, 0x38, 0xbf, 0x61, 0x60, 0x89, + 0xa5, 0x0c, 0xf6, 0x06, 0xbc, 0xd6, 0xc2, 0x3e, 0x8b, 0x02, 0x75, 0x26, 0xe7, 0x8a, 0x23, 0x08, 0xba, 0xa5, 0x66, + 0x26, 0x2a, 0x9d, 0x65, 0x8f, 0x34, 0x3f, 0xc5, 0xbc, 0x62, 0xbf, 0x2a, 0x93, 0x86, 0x74, 0xd0, 0x99, 0xdc, 0x9a, + 0x9a, 0x02, 0x57, 0x21, 0x55, 0xb5, 0x4e, 0xec, 0x38, 0xf1, 0xc2, 0xcf, 0xd3, 0x11, 0xc7, 0x36, 0x3e, 0x0f, 0x45, + 0x7d, 0x92, 0xf7, 0x69, 0xe9, 0xfa, 0xd0, 0x25, 0xd7, 0x06, 0x69, 0x7a, 0x3b, 0xa2, 0x2b, 0x3f, 0xbc, 0xa6, 0x31, + 0x4d, 0x5f, 0x39, 0xba, 0x4f, 0x73, 0xf3, 0x49, 0xdb, 0x58, 0xb2, 0xf9, 0xd1, 0x5f, 0xf2, 0xca, 0xac, 0x43, 0x28, + 0x72, 0x99, 0x82, 0xfb, 0x7d, 0x3e, 0xaa, 0xc9, 0xf6, 0x3b, 0x78, 0x14, 0x88, 0x2f, 0x1b, 0x81, 0x30, 0xfd, 0xe2, + 0xc1, 0x70, 0x23, 0xaf, 0x06, 0xe6, 0x6a, 0x82, 0xeb, 0x75, 0x7d, 0x02, 0xe9, 0xd9, 0x1a, 0x03, 0x1b, 0x21, 0x53, + 0x57, 0xc1, 0x7b, 0xf6, 0x2e, 0x68, 0x6f, 0xe0, 0x37, 0x7b, 0xc0, 0x08, 0xb3, 0x7a, 0xcb, 0x08, 0x28, 0x0c, 0x29, + 0xd4, 0x4d, 0x93, 0x14, 0x0d, 0xa1, 0x02, 0x06, 0xfc, 0xfc, 0x45, 0xe8, 0xc2, 0xd3, 0x12, 0xe8, 0x7f, 0x70, 0x3e, + 0xd4, 0x8a, 0x32, 0xce, 0x2f, 0x5a, 0x6c, 0x69, 0xee, 0x34, 0x4f, 0x4c, 0x7e, 0xec, 0x23, 0x3c, 0x4f, 0xe5, 0x38, + 0x9c, 0xdd, 0xd7, 0xe9, 0x4a, 0x7b, 0xa9, 0x39, 0x9e, 0x34, 0xff, 0x6a, 0xe3, 0xcb, 0xbc, 0x13, 0x91, 0x38, 0xc1, + 0x5d, 0x80, 0x7f, 0x3d, 0x8f, 0x24, 0x61, 0x38, 0x5d, 0x34, 0xdf, 0x14, 0xef, 0x56, 0x13, 0x6f, 0x71, 0xd5, 0x22, + 0x8d, 0xdb, 0x43, 0xdc, 0xf7, 0x7e, 0x0d, 0x9e, 0x3b, 0xdb, 0xf5, 0x7c, 0xd8, 0x0a, 0x1e, 0x90, 0xc9, 0xe5, 0x14, + 0x08, 0x5e, 0xc4, 0x62, 0x32, 0x0f, 0xa9, 0x57, 0xb6, 0x0e, 0xcb, 0xd2, 0x79, 0xa5, 0xb6, 0x89, 0x7a, 0xc5, 0xb8, + 0x96, 0x6f, 0x76, 0xeb, 0x45, 0x5c, 0x12, 0x0b, 0x2d, 0xae, 0x95, 0x56, 0xaa, 0x59, 0x9f, 0x18, 0x5b, 0x8e, 0xda, + 0x76, 0xff, 0x7e, 0x83, 0xc8, 0x6a, 0x9f, 0x5f, 0x3e, 0x2e, 0x88, 0x81, 0x0f, 0x99, 0xa3, 0xf4, 0xf8, 0x02, 0xad, + 0xb2, 0x6e, 0xad, 0xbd, 0x3a, 0xed, 0x4e, 0xa6, 0xdf, 0x96, 0xf5, 0x61, 0x17, 0x8c, 0xd7, 0x05, 0xb1, 0xed, 0x51, + 0xe8, 0x27, 0xd6, 0xe7, 0x7b, 0xaa, 0x10, 0xfe, 0x6d, 0x7a, 0xff, 0xb3, 0xb7, 0xcd, 0x73, 0xa9, 0x22, 0x8e, 0x90, + 0x79, 0xa2, 0x36, 0x8c, 0x95, 0x92, 0xbd, 0xa0, 0x43, 0x8a, 0xd5, 0x8c, 0x42, 0x03, 0x81, 0x54, 0x37, 0xbd, 0x93, + 0x57, 0x03, 0x80, 0x29, 0x66, 0xb0, 0xe1, 0x70, 0x17, 0xf0, 0x0e, 0xb5, 0x82, 0x70, 0x9c, 0x33, 0xaa, 0x96, 0x2e, + 0xb5, 0xde, 0x8e, 0x9f, 0xc2, 0x51, 0x1d, 0x70, 0xd1, 0xfe, 0x78, 0x2c, 0xd8, 0x8a, 0x44, 0x0d, 0x71, 0x2e, 0x4d, + 0x5a, 0xa8, 0x0f, 0x51, 0x8f, 0x7d, 0x37, 0x68, 0x33, 0xbc, 0x05, 0x5f, 0x11, 0xb8, 0xc2, 0x2f, 0x71, 0x70, 0xcb, + 0x74, 0xb8, 0x87, 0xad, 0xeb, 0x9a, 0xe8, 0x8b, 0xfa, 0x33, 0x66, 0x59, 0x08, 0x72, 0x7a, 0xc2, 0x3f, 0xa8, 0x85, + 0x4a, 0x41, 0xf0, 0x72, 0x2e, 0xe0, 0xfe, 0x1c, 0x46, 0x4f, 0x48, 0xf9, 0xa1, 0x88, 0x04, 0x69, 0x1d, 0x99, 0x1a, + 0x1c, 0xf7, 0x58, 0x97, 0x18, 0x66, 0x2f, 0x82, 0x83, 0xc5, 0xac, 0x11, 0x59, 0xd5, 0x23, 0xf8, 0xcd, 0x93, 0xa6, + 0x75, 0x88, 0x25, 0x85, 0x1a, 0xd6, 0x54, 0xfa, 0x5b, 0x10, 0xa9, 0x4d, 0x97, 0x7f, 0x02, 0x74, 0x6d, 0x4f, 0x94, + 0x9e, 0xf6, 0x92, 0x5a, 0x54, 0x1d, 0xda, 0x46, 0xc2, 0xdc, 0xa5, 0xc0, 0xd0, 0x38, 0xf0, 0x00, 0xb1, 0xf6, 0xae, + 0xc8, 0xe4, 0x3d, 0x74, 0x99, 0x3c, 0x44, 0xd5, 0x4e, 0x8d, 0xed, 0x72, 0xca, 0x0f, 0x52, 0x6d, 0x61, 0xe4, 0x14, + 0x75, 0x4a, 0x95, 0x17, 0x46, 0x08, 0xea, 0xd6, 0x57, 0xc7, 0xba, 0x08, 0xcd, 0xc3, 0x6a, 0xed, 0x44, 0x2f, 0xb1, + 0xcc, 0xfe, 0xd1, 0x20, 0xce, 0xcc, 0xc2, 0x40, 0x73, 0xfe, 0x53, 0xb7, 0x18, 0xa2, 0xfd, 0x5f, 0xf2, 0xb0, 0x5e, + 0x77, 0xfe, 0x74, 0x5c, 0x78, 0x69, 0xb7, 0x4b, 0x77, 0x1b, 0xbd, 0x37, 0xe0, 0x1a, 0x8c, 0xf9, 0x93, 0x7c, 0xa6, + 0x8d, 0x08, 0xa8, 0xf8, 0x36, 0x7c, 0x7c, 0x3f, 0xda, 0x47, 0xe4, 0x21, 0x72, 0x98, 0x3f, 0x46, 0xbf, 0x13, 0x6c, + 0x51, 0x6b, 0x44, 0xb2, 0x8a, 0xb0, 0x20, 0x35, 0x77, 0xf8, 0xd6, 0x23, 0xdf, 0x5c, 0x57, 0x3b, 0xf1, 0x79, 0x0d, + 0x02, 0xa8, 0x58, 0x4d, 0x1b, 0x07, 0xfa, 0xdc, 0xf6, 0x19, 0xcf, 0x41, 0x13, 0x1d, 0x85, 0x43, 0xfc, 0xf3, 0x9c, + 0x73, 0xb4, 0xa3, 0x9d, 0x1c, 0x87, 0xc7, 0xd8, 0x2b, 0xc5, 0xb9, 0xff, 0x8c, 0x42, 0x13, 0x96, 0x9f, 0xe5, 0x3b, + 0xd4, 0x07, 0xfc, 0x3c, 0xe5, 0x7f, 0x5c, 0xf5, 0x40, 0x88, 0x3d, 0x21, 0x00, 0xce, 0x9f, 0xfe, 0x23, 0x14, 0xf2, + 0xa7, 0x12, 0x2e, 0xfc, 0x07, 0x86, 0x84, 0x17, 0xc1, 0x3f, 0xc1, 0xef, 0x2c, 0x31, 0x3a, 0x4c, 0x51, 0xa1, 0xfc, + 0xa3, 0x03, 0x21, 0x5f, 0x73, 0x76, 0x6d, 0x0e, 0x9f, 0xcf, 0x99, 0xe5, 0x0b, 0xae, 0x09, 0xf5, 0x79, 0x2b, 0x30, + 0xff, 0xa6, 0xc9, 0x3e, 0x09, 0x48, 0x2e, 0xfc, 0x56, 0xdc, 0xad, 0x56, 0x93, 0x3c, 0x8a, 0x14, 0xfd, 0x66, 0x1a, + 0x2b, 0x6f, 0xbc, 0x45, 0x89, 0xb6, 0x43, 0x2f, 0x4d, 0x9f, 0xcb, 0x17, 0x84, 0xd9, 0x56, 0xc7, 0x89, 0xd9, 0x1f, + 0xdc, 0x5d, 0xa7, 0x4b, 0x2c, 0x41, 0x64, 0x18, 0x77, 0xc7, 0x60, 0x1d, 0xbe, 0x5a, 0x19, 0x2a, 0x63, 0x11, 0x4a, + 0x15, 0x2d, 0x3d, 0xfc, 0x42, 0x37, 0x71, 0x51, 0xba, 0x99, 0x72, 0xcc, 0xf4, 0x77, 0x68, 0xfd, 0x6b, 0x35, 0x3a, + 0xbb, 0x24, 0x7c, 0xf0, 0x78, 0x2f, 0xe8, 0x6f, 0x3a, 0x64, 0x17, 0xe1, 0x2f, 0x1f, 0x5f, 0xaa, 0x25, 0x0b, 0xa3, + 0x9b, 0xce, 0xa7, 0x34, 0x7b, 0xbb, 0xaf, 0x32, 0x0a, 0x4d, 0x0d, 0x85, 0x91, 0x38, 0x2b, 0xc7, 0x65, 0xef, 0x4c, + 0xd6, 0xf5, 0x73, 0xcf, 0xaa, 0x94, 0x5d, 0x48, 0xb0, 0xa8, 0x97, 0x7b, 0xf7, 0x0d, 0x5a, 0x48, 0xa1, 0x06, 0xd2, + 0x16, 0x03, 0x1d, 0xba, 0x67, 0x38, 0xd1, 0x25, 0x94, 0x40, 0xa4, 0x0f, 0x57, 0x59, 0xd4, 0xf4, 0x45, 0x4c, 0xa0, + 0x4f, 0x3d, 0x5b, 0xec, 0x6c, 0xd7, 0x28, 0x3b, 0x8c, 0x02, 0x72, 0xf7, 0x86, 0x67, 0x46, 0x1f, 0xef, 0xdf, 0xc8, + 0x6a, 0xf9, 0x7f, 0xa3, 0x46, 0xdb, 0x3b, 0x47, 0xa0, 0xe1, 0x99, 0xb7, 0x4b, 0x22, 0x12, 0x24, 0x2c, 0x7e, 0x3e, + 0x79, 0xf6, 0x7d, 0x17, 0x4a, 0xa4, 0xe0, 0xd0, 0x57, 0x63, 0xba, 0x7c, 0xa9, 0x26, 0xca, 0x47, 0x62, 0xc0, 0x4f, + 0x3a, 0x0f, 0x12, 0x5d, 0x4d, 0x73, 0xb0, 0x43, 0x39, 0x70, 0x7b, 0x73, 0xc6, 0xf9, 0x63, 0xbe, 0xc1, 0xca, 0xc1, + 0x93, 0xed, 0x9f, 0x7a, 0xb9, 0x8d, 0x51, 0xc5, 0x4f, 0x44, 0x63, 0x19, 0xf0, 0xf0, 0xd9, 0xe9, 0x08, 0xed, 0x8c, + 0x64, 0x01, 0xca, 0x7b, 0xbb, 0x3f, 0x86, 0x4b, 0xf8, 0x99, 0x1a, 0xef, 0x59, 0xdb, 0xa1, 0xa5, 0xdb, 0xf8, 0xa6, + 0xe4, 0x71, 0x78, 0x60, 0x2d, 0xc5, 0x6a, 0x6c, 0x0d, 0x10, 0x97, 0xb8, 0xa3, 0x6c, 0xad, 0xe2, 0xe2, 0xfe, 0x5f, + 0x1e, 0x9e, 0x39, 0x07, 0x81, 0x2a, 0xe1, 0x60, 0x22, 0x35, 0x23, 0xb6, 0x91, 0x63, 0xc7, 0x6b, 0x46, 0x1c, 0x5c, + 0x01, 0x69, 0x23, 0x26, 0x9a, 0x53, 0xb9, 0x0f, 0xc6, 0xf3, 0xe8, 0x8d, 0xaa, 0x8f, 0x73, 0xe6, 0x81, 0x6d, 0x70, + 0x27, 0x55, 0x1b, 0x16, 0x26, 0xbe, 0xd9, 0xad, 0xa5, 0xe9, 0xcb, 0x8e, 0xac, 0x17, 0x6c, 0xcf, 0x4a, 0x10, 0xfa, + 0x54, 0xfa, 0x37, 0x1a, 0xe2, 0xb9, 0xae, 0x5f, 0x47, 0x17, 0xed, 0x87, 0xb9, 0xc3, 0xfe, 0xee, 0xf8, 0xb4, 0x61, + 0x62, 0x1d, 0x7d, 0x1e, 0x3b, 0x2b, 0xcc, 0xb3, 0x6b, 0x4d, 0x3f, 0xb5, 0xf1, 0xd0, 0xc7, 0xbe, 0xb4, 0x56, 0x66, + 0xb0, 0x2a, 0x28, 0xbb, 0x53, 0x53, 0x03, 0x61, 0x0d, 0xea, 0x3a, 0x99, 0x64, 0x33, 0x65, 0xbf, 0x3c, 0x03, 0xb3, + 0xdf, 0x45, 0xc9, 0x15, 0xfa, 0xeb, 0x7d, 0x69, 0x52, 0xe7, 0x3b, 0xda, 0x22, 0x47, 0xb4, 0xc5, 0xa0, 0x16, 0x11, + 0xef, 0xd4, 0xd7, 0x29, 0xc9, 0x47, 0x2f, 0x5a, 0x82, 0x30, 0xb5, 0xa4, 0xdd, 0x15, 0x28, 0x61, 0x99, 0x91, 0xcf, + 0xf6, 0x13, 0xe3, 0xfd, 0xd3, 0xf8, 0xa5, 0x63, 0xd2, 0x76, 0xb5, 0x6b, 0x07, 0x23, 0xd7, 0xd0, 0x54, 0x41, 0xe3, + 0x16, 0xdf, 0x61, 0xa0, 0x9f, 0xe2, 0x48, 0xdb, 0xaf, 0x35, 0x4f, 0x5f, 0xda, 0xd6, 0xf3, 0xea, 0xf6, 0x89, 0x5a, + 0xeb, 0xc0, 0xb1, 0x33, 0xb4, 0x27, 0x6f, 0x4c, 0x90, 0x0f, 0x7d, 0x3e, 0x3c, 0x0d, 0xa7, 0x26, 0x1f, 0x9d, 0xa5, + 0x90, 0xc8, 0x1e, 0x15, 0x5f, 0x60, 0x3e, 0x1f, 0x28, 0x15, 0x51, 0x1b, 0xef, 0xdd, 0xd6, 0x6e, 0xbe, 0x8f, 0x47, + 0xab, 0x76, 0x8d, 0xd1, 0x46, 0xc2, 0x02, 0x3c, 0x54, 0x89, 0xd2, 0x21, 0x0e, 0xfc, 0x67, 0x92, 0x76, 0x16, 0x75, + 0x8d, 0xb7, 0x65, 0xc3, 0xa4, 0xf9, 0x3c, 0x95, 0x7a, 0x19, 0x77, 0xd8, 0x56, 0x6e, 0xf6, 0xd1, 0x13, 0xd1, 0xbe, + 0x66, 0x6d, 0x3e, 0x41, 0x50, 0x76, 0xb5, 0xc3, 0xbd, 0xea, 0x88, 0x1d, 0x27, 0x6c, 0xbf, 0xd9, 0x7c, 0xe7, 0xa8, + 0x14, 0xa5, 0xc6, 0x09, 0x6b, 0xdd, 0xd4, 0x4e, 0x34, 0x87, 0x30, 0xfc, 0xd2, 0x37, 0xf1, 0x12, 0x52, 0x37, 0x1c, + 0xf3, 0xf6, 0xfe, 0x79, 0x58, 0xd7, 0xc2, 0x09, 0x45, 0xb2, 0x26, 0xf6, 0xde, 0x20, 0xdd, 0xc1, 0x2a, 0x0c, 0x9f, + 0x90, 0x5b, 0x67, 0x75, 0xf2, 0x26, 0x78, 0xa1, 0x21, 0xb2, 0x93, 0x21, 0xdf, 0x32, 0x0e, 0x2c, 0xdd, 0xc0, 0xfe, + 0x5a, 0x95, 0x64, 0x95, 0x27, 0x6a, 0xaf, 0x52, 0xa6, 0x69, 0x49, 0xc1, 0xf2, 0x29, 0xb3, 0x07, 0x47, 0x5e, 0xf3, + 0x65, 0x73, 0xeb, 0x9b, 0x77, 0x4f, 0x9d, 0xf5, 0xd0, 0x2e, 0x76, 0xbd, 0xb5, 0x29, 0x9c, 0xe0, 0x23, 0x49, 0xfc, + 0x50, 0xfb, 0xd9, 0x7e, 0xb0, 0x71, 0xff, 0xa4, 0xf6, 0x03, 0xce, 0xec, 0x53, 0x74, 0x98, 0x87, 0x49, 0x9f, 0x15, + 0x24, 0x1c, 0xd0, 0xba, 0x8f, 0x45, 0xa6, 0xc0, 0x4e, 0x03, 0x9c, 0x40, 0x8d, 0xd8, 0xe3, 0x22, 0x07, 0xf4, 0xa6, + 0x6a, 0x6a, 0x31, 0xdf, 0xd3, 0x91, 0x3b, 0x9c, 0x62, 0x06, 0xbf, 0x68, 0xd8, 0xd2, 0xbc, 0xfa, 0xb8, 0xad, 0x1b, + 0xf4, 0x1c, 0xa4, 0x48, 0xdc, 0x20, 0xa6, 0x49, 0xf7, 0x15, 0x7a, 0xea, 0xeb, 0x37, 0xb9, 0x1d, 0xf7, 0x1d, 0xa7, + 0x8d, 0x76, 0x1b, 0xee, 0x62, 0x95, 0x4d, 0x3b, 0xa4, 0xa3, 0x06, 0xea, 0x4b, 0xff, 0x64, 0x45, 0xa7, 0xa7, 0x29, + 0x42, 0x57, 0x62, 0xdb, 0x04, 0x60, 0x72, 0x50, 0xd8, 0x59, 0x20, 0x09, 0x36, 0x38, 0x71, 0x2c, 0x13, 0x8d, 0xec, + 0x85, 0xbe, 0xda, 0xed, 0x18, 0x18, 0xf8, 0xb9, 0x27, 0xd1, 0x6f, 0xef, 0x2c, 0x52, 0x34, 0x6b, 0x19, 0x7e, 0x65, + 0x22, 0x45, 0x1f}; // Backwards compatibility alias #define INDEX_GZ INDEX_BR From e38ae51de20195d09bf680c47177da35439f8436 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:02:31 -1000 Subject: [PATCH 1110/1815] Bump bundled esphome-device-builder to 1.6.10 (#17801) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 8649dbcd77..638bbdbf9e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.6.9 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.10 RUN \ platformio settings set enable_telemetry No \ From 013c5d7217ad23c7470d9c638bdb34afe75675aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcus=20Vo=C3=9F?= <46140304+mvoss96@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:47:27 +0200 Subject: [PATCH 1111/1815] [esp32_ble] Forward ESP_GAP_BLE_SCAN_RSP_DATA_RAW_SET_COMPLETE_EVT to GAP handlers (#17833) --- esphome/components/esp32_ble/ble.cpp | 1 + esphome/components/esp32_ble/ble_event.h | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index a2d19f1042..fb75e8837f 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -58,6 +58,7 @@ static constexpr uint32_t HOSTED_BT_WDT_TIMEOUT_MS = 60000; case ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT: \ case ESP_GAP_BLE_SCAN_RSP_DATA_SET_COMPLETE_EVT: \ case ESP_GAP_BLE_ADV_DATA_RAW_SET_COMPLETE_EVT: \ + case ESP_GAP_BLE_SCAN_RSP_DATA_RAW_SET_COMPLETE_EVT: \ case ESP_GAP_BLE_ADV_START_COMPLETE_EVT: \ case ESP_GAP_BLE_ADV_STOP_COMPLETE_EVT diff --git a/esphome/components/esp32_ble/ble_event.h b/esphome/components/esp32_ble/ble_event.h index ba87fd8805..babfa937c7 100644 --- a/esphome/components/esp32_ble/ble_event.h +++ b/esphome/components/esp32_ble/ble_event.h @@ -207,7 +207,7 @@ class BLEEvent { StatusOnlyData scan_complete; // 1 byte // Advertising complete events all have same structure // Used by: esp32_ble_beacon, esp32_ble server components - // ADV_DATA_SET, SCAN_RSP_DATA_SET, ADV_DATA_RAW_SET, ADV_START, ADV_STOP + // ADV_DATA_SET, SCAN_RSP_DATA_SET, ADV_DATA_RAW_SET, SCAN_RSP_DATA_RAW_SET, ADV_START, ADV_STOP StatusOnlyData adv_complete; // 1 byte // RSSI complete event // Used by: ble_client (ble_rssi_sensor component) @@ -324,6 +324,9 @@ class BLEEvent { case ESP_GAP_BLE_ADV_DATA_RAW_SET_COMPLETE_EVT: // Used by: esp32_ble_beacon this->event_.gap.adv_complete.status = p->adv_data_raw_cmpl.status; break; + case ESP_GAP_BLE_SCAN_RSP_DATA_RAW_SET_COMPLETE_EVT: // Used by: raw advertisers with scan response + this->event_.gap.adv_complete.status = p->scan_rsp_data_raw_cmpl.status; + break; case ESP_GAP_BLE_ADV_START_COMPLETE_EVT: // Used by: esp32_ble_beacon this->event_.gap.adv_complete.status = p->adv_start_cmpl.status; break; From 3829d368ffe9cac74bc19e91737f1e6f26bcaf39 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:59:33 -0400 Subject: [PATCH 1112/1815] [espidf] Honor compile_process_limit in native ESP-IDF builds (#17857) --- esphome/espidf/toolchain.py | 16 ++++-- tests/unit_tests/test_espidf_toolchain.py | 59 ++++++++++++++++++++++- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 000ce739db..b8196d2fda 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -10,7 +10,12 @@ import shutil import subprocess from esphome.components.esp32.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION -from esphome.const import CONF_FRAMEWORK, CONF_SOURCE +from esphome.const import ( + CONF_COMPILE_PROCESS_LIMIT, + CONF_ESPHOME, + CONF_FRAMEWORK, + CONF_SOURCE, +) from esphome.core import CORE, EsphomeError from esphome.espidf.framework import check_esp_idf_install, get_framework_env from esphome.espidf.size_summary import print_summary @@ -147,7 +152,10 @@ def _get_idf_tool(name: str) -> str: def run_idf_py( - *args, cwd: Path | None = None, capture_output: bool = False + *args, + cwd: Path | None = None, + capture_output: bool = False, + jobs: int | None = None, ) -> int | str: """Run idf.py with the given arguments.""" idf_path = _get_idf_path() @@ -155,6 +163,8 @@ def run_idf_py( raise EsphomeError("ESP-IDF not found") env = _get_idf_env() + if jobs is not None: + env = {**env, "IDF_PY_BUILD_JOBS": str(jobs)} python_executable = _get_idf_tool("python") idf_py = idf_path / "tools" / "idf.py" # Dispatch idf.py through esphome.espidf.runner, which wraps @@ -384,7 +394,7 @@ def run_compile(config, verbose: bool) -> int: args.append("build") args.append("size") - rc = run_idf_py(*args) + rc = run_idf_py(*args, jobs=config[CONF_ESPHOME].get(CONF_COMPILE_PROCESS_LIMIT)) if rc == 0: size_json = CORE.relative_build_path("build", "esp_idf_size.json") partitions = CORE.relative_build_path("partitions.csv") diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 017d8c49b4..f98cc70428 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -7,7 +7,12 @@ import os from pathlib import Path from unittest.mock import patch -from esphome.const import CONF_FRAMEWORK, CONF_SOURCE +from esphome.const import ( + CONF_COMPILE_PROCESS_LIMIT, + CONF_ESPHOME, + CONF_FRAMEWORK, + CONF_SOURCE, +) from esphome.core import CORE from esphome.espidf import toolchain @@ -184,6 +189,58 @@ def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None: assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep) +def test_run_idf_py_jobs_sets_build_jobs_env(setup_core: Path) -> None: + """The jobs argument is exported to idf.py as IDF_PY_BUILD_JOBS.""" + _setup_build(setup_core) + + with ( + patch.object(toolchain, "_get_idf_path", return_value=Path("/idf")), + patch.object(toolchain, "_get_idf_env", return_value={"PATH": "/bin"}), + patch.object(toolchain, "_get_idf_tool", return_value="python"), + patch.object(toolchain.subprocess, "run") as mock_run, + ): + mock_run.return_value.returncode = 0 + + toolchain.run_idf_py("build", jobs=2) + env = mock_run.call_args.kwargs["env"] + assert env["IDF_PY_BUILD_JOBS"] == "2" + assert env["PATH"] == "/bin" + + toolchain.run_idf_py("build") + env = mock_run.call_args.kwargs["env"] + assert "IDF_PY_BUILD_JOBS" not in env + + +def test_run_compile_passes_compile_process_limit(setup_core: Path) -> None: + """compile_process_limit is forwarded to run_idf_py as the job limit.""" + _setup_build(setup_core) + config = {CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 1}} + + with ( + patch.object(toolchain, "need_reconfigure", return_value=False), + patch.object(toolchain, "run_idf_py", return_value=0) as mock_run, + patch.object(toolchain, "print_summary"), + ): + assert toolchain.run_compile(config, verbose=False) == 0 + + mock_run.assert_called_once_with("build", "size", jobs=1) + + +def test_run_compile_without_compile_process_limit(setup_core: Path) -> None: + """When no compile_process_limit is set, no job limit is passed to idf.py.""" + _setup_build(setup_core) + config = {CONF_ESPHOME: {}} + + with ( + patch.object(toolchain, "need_reconfigure", return_value=False), + patch.object(toolchain, "run_idf_py", return_value=0) as mock_run, + patch.object(toolchain, "print_summary"), + ): + assert toolchain.run_compile(config, verbose=False) == 0 + + mock_run.assert_called_once_with("build", "size", jobs=None) + + def test_get_core_framework_version_from_core_data(): """The version is read from CORE.data when validation populated it.""" from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION From 37fe59dc37dfe8ddd58f1364dda79df2a8ede244 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Sun, 26 Jul 2026 23:34:29 +0100 Subject: [PATCH 1113/1815] [espidf] Include .cc, .cxx and .c++ sources in the app source glob (#17754) --- esphome/build_gen/espidf.py | 12 ++++++++++++ tests/unit_tests/build_gen/test_espidf.py | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index cc2fc5c4cd..cf476555e7 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -210,15 +210,27 @@ def get_component_cmakelists() -> str: if(CMAKE_SCRIPT_MODE_FILE) file(GLOB_RECURSE app_sources "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp" + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cc" + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cxx" + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c++" "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c" "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cc" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cxx" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c++" "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c" ) else() file(GLOB_RECURSE app_sources CONFIGURE_DEPENDS "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp" + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cc" + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cxx" + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c++" "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c" "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cc" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cxx" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c++" "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c" ) endif() diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index bcd9fa655a..f21549b48c 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -184,6 +184,18 @@ def test_get_component_cmakelists_compile_flags_excluded_from_link_opts() -> Non assert "-Wl,--gc-sections" in content +def test_get_component_cmakelists_globs_alternate_cpp_extensions() -> None: + """Both app_sources glob variants include .cc/.cxx/.c++ so vendored sources + are compiled, matching the extensions PlatformIO's builder globs by default.""" + CORE.build_flags = set() + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + for ext in ("cc", "cxx", "c++"): + assert content.count(f'"${{CMAKE_CURRENT_SOURCE_DIR}}/*.{ext}"') == 2 + assert content.count(f'"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.{ext}"') == 2 + + def test_get_project_cmakelists_emits_managed_components_property( tmp_path: Path, ) -> None: From e40579ad939811b56ee8b2475786a060e3e77412 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:13:55 -1000 Subject: [PATCH 1114/1815] Bump bundled esphome-device-builder to 1.7.0 (#17871) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 638bbdbf9e..4a9edb1b64 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.6.10 +RUN uv pip install --no-cache-dir esphome-device-builder==1.7.0 RUN \ platformio settings set enable_telemetry No \ From 5a87ad8fc0e12a6dd5315538793075ceab4d3cf2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Jul 2026 19:31:24 -1000 Subject: [PATCH 1115/1815] [wifi] Lock scan results shared with the captive portal web task (#17850) --- esphome/components/captive_portal/__init__.py | 5 +- .../captive_portal/captive_portal.cpp | 31 +++--- esphome/components/wifi/__init__.py | 16 +++ esphome/components/wifi/wifi_component.cpp | 34 ++++--- esphome/components/wifi/wifi_component.h | 36 +++++++ .../wifi/wifi_component_esp8266.cpp | 2 + .../wifi/wifi_component_esp_idf.cpp | 98 +++++++++---------- .../wifi/wifi_component_libretiny.cpp | 66 +++++++------ .../components/wifi/wifi_component_pico_w.cpp | 4 + esphome/core/defines.h | 3 + 10 files changed, 187 insertions(+), 108 deletions(-) diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index 703ae98392..d62c718097 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -1,7 +1,7 @@ import logging import esphome.codegen as cg -from esphome.components import web_server_base +from esphome.components import web_server_base, wifi from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv @@ -101,6 +101,9 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID], paren) await cg.register_component(var, config) cg.add_define("USE_CAPTIVE_PORTAL") + # The portal reads wifi scan results from the web server task; this makes the + # wifi component guard them with a lock on multi-threaded platforms. + wifi.request_wifi_scan_results_lock() if config[CONF_COMPRESSION] == "gzip": cg.add_define("USE_CAPTIVE_PORTAL_GZIP") diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 365e5f64db..228fdf7934 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -24,23 +24,28 @@ void CaptivePortal::handle_config(AsyncWebServerRequest *request) { stream->printf(R"({"mac":"%s","name":"%s","aps":[{})", mac_str, App.get_name().c_str()); #endif - for (auto &scan : wifi::global_wifi_component->get_scan_result()) { - if (scan.get_is_hidden()) - continue; + { + // Invariant: only bounded in-memory work under the lock; the network send + // happens later in request->send() + wifi::ScanResultsLock lock(wifi::global_wifi_component); + for (const auto &scan : wifi::global_wifi_component->get_scan_result()) { + if (scan.get_is_hidden()) + continue; - // Assumes no " in ssid, possible unicode isses? + // Assumes no " in ssid, possible unicode issues? #ifdef USE_ESP8266 - stream->print(ESPHOME_F(",{\"ssid\":\"")); - stream->print(scan.get_ssid().c_str()); - stream->print(ESPHOME_F("\",\"rssi\":")); - stream->print(scan.get_rssi()); - stream->print(ESPHOME_F(",\"lock\":")); - stream->print(scan.get_with_auth()); - stream->print(ESPHOME_F("}")); + stream->print(ESPHOME_F(",{\"ssid\":\"")); + stream->print(scan.get_ssid().c_str()); + stream->print(ESPHOME_F("\",\"rssi\":")); + stream->print(scan.get_rssi()); + stream->print(ESPHOME_F(",\"lock\":")); + stream->print(scan.get_with_auth()); + stream->print(ESPHOME_F("}")); #else - stream->printf(R"(,{"ssid":"%s","rssi":%d,"lock":%d})", scan.get_ssid().c_str(), scan.get_rssi(), - scan.get_with_auth()); + stream->printf(R"(,{"ssid":"%s","rssi":%d,"lock":%d})", scan.get_ssid().c_str(), scan.get_rssi(), + scan.get_with_auth()); #endif + } } stream->print(ESPHOME_F("]}")); request->send(stream); diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 137304c807..8068e1b022 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -818,6 +818,7 @@ IP_STATE_LISTENERS_KEY = "wifi_ip_state_listeners" SCAN_RESULTS_LISTENERS_KEY = "wifi_scan_results_listeners" CONNECT_STATE_LISTENERS_KEY = "wifi_connect_state_listeners" POWER_SAVE_LISTENERS_KEY = "wifi_power_save_listeners" +SCAN_RESULTS_LOCK_KEY = "wifi_scan_results_lock" def request_wifi_scan_results(): @@ -830,6 +831,19 @@ def request_wifi_scan_results(): CORE.data[KEEP_SCAN_RESULTS_KEY] = True +def request_wifi_scan_results_lock() -> None: + """Request that scan results be guarded by a lock for cross-task readers. + + Components that read WiFi scan results from a task other than the main loop + (for example a web server handler) must call this function during their code + generation, and their C++ code must hold a wifi::ScanResultsLock while + iterating get_scan_result(). On multi-threaded platforms this compiles in a + lock that scan result writers hold; on single-threaded platforms it compiles + to nothing. + """ + CORE.data[SCAN_RESULTS_LOCK_KEY] = True + + def enable_runtime_power_save_control(): """Enable runtime WiFi power save control. @@ -891,6 +905,8 @@ async def final_step(): cg.add_define("USE_WIFI_RUNTIME_POWER_SAVE") if CORE.data.get(RUNTIME_ROAMING_SUPPRESSION_KEY, False): cg.add_define("USE_WIFI_RUNTIME_ROAMING_SUPPRESSION") + if CORE.data.get(SCAN_RESULTS_LOCK_KEY): + cg.add_define("USE_WIFI_SCAN_RESULTS_LOCK") # Generate listener defines - each listener type has its own #ifdef ip_state_count = CORE.data.get(IP_STATE_LISTENERS_KEY, 0) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 44e3cb6af9..650b06cae1 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1483,23 +1483,26 @@ void WiFiComponent::check_scanning_finished() { } ESP_LOGD(TAG, "Found networks:"); - for (auto &res : this->scan_result_) { - for (auto &ap : this->sta_) { - if (res.matches(ap)) { - res.set_matches(true); - // Cache priority lookup - do single search instead of 2 separate searches - const bssid_t &bssid = res.get_bssid(); - if (!this->has_sta_priority(bssid)) { - this->set_sta_priority(bssid, ap.get_priority()); + { + ScanResultsLock lock(this); + for (auto &res : this->scan_result_) { + for (auto &ap : this->sta_) { + if (res.matches(ap)) { + res.set_matches(true); + // Cache priority lookup - do single search instead of 2 separate searches + const bssid_t &bssid = res.get_bssid(); + if (!this->has_sta_priority(bssid)) { + this->set_sta_priority(bssid, ap.get_priority()); + } + res.set_priority(this->get_sta_priority(bssid)); + break; } - res.set_priority(this->get_sta_priority(bssid)); - break; } } - } - // Sort scan results using insertion sort for better memory efficiency - insertion_sort_scan_results(this->scan_result_); + // Sort scan results using insertion sort for better memory efficiency + insertion_sort_scan_results(this->scan_result_); + } // Log matching networks (non-matching already logged at VERBOSE in scan callback) for (auto &res : this->scan_result_) { @@ -1885,11 +1888,13 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { // Phase-specific setup switch (new_phase) { #ifdef USE_WIFI_FAST_CONNECT - case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS: + case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS: { // Move to next configured AP - clear old scan data so new AP is tried with config only this->selected_sta_index_++; + ScanResultsLock lock(this); this->scan_result_.clear(); break; + } #endif case WiFiRetryPhase::EXPLICIT_HIDDEN: @@ -2404,6 +2409,7 @@ void WiFiComponent::clear_roaming_state_() { void WiFiComponent::release_scan_results_() { if (!this->keep_scan_results_) { + ScanResultsLock lock(this); #if defined(USE_RP2) || defined(USE_ESP32) // std::vector - use swap trick since shrink_to_fit is non-binding decltype(this->scan_result_)().swap(this->scan_result_); diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 23b7558564..03946e0a17 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -187,6 +187,13 @@ template using wifi_scan_vector_t = std::vector; template using wifi_scan_vector_t = FixedVector; #endif +// A consumer component (e.g. the captive portal) reads scan results from another +// task; guard them with a real lock only on platforms that actually run multiple +// threads. See ScanResultsLock below the WiFiComponent class. +#if defined(USE_WIFI_SCAN_RESULTS_LOCK) && !defined(ESPHOME_THREAD_SINGLE) +#define WIFI_SCAN_RESULTS_LOCK_ENABLED +#endif + /// 20-byte string: 18 chars inline + null, heap for longer. Always null-terminated. /// Used internally for WiFi SSID/password storage to reduce heap fragmentation. class CompactString { @@ -506,6 +513,9 @@ class WiFiComponent final : public Component { const char *get_use_address() const { return this->use_address_; } void set_use_address(const char *use_address) { this->use_address_ = use_address; } + /// Main-loop callers may read this directly. Callers on any other task must + /// hold a ScanResultsLock for the whole iteration and must call + /// wifi.request_wifi_scan_results_lock() from their code generation. const wifi_scan_vector_t &get_scan_result() const { return scan_result_; } network::IPAddress wifi_soft_ap_ip(); @@ -817,6 +827,8 @@ class WiFiComponent final : public Component { friend void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data); #endif + friend class ScanResultsLock; + #ifdef USE_RP2 static int s_wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result); void wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result); @@ -831,7 +843,11 @@ class WiFiComponent final : public Component { // Large/pointer-aligned members first FixedVector sta_; std::vector sta_priorities_; + // Guarded by ScanResultsLock (see below this class) wifi_scan_vector_t scan_result_; +#ifdef WIFI_SCAN_RESULTS_LOCK_ENABLED + Mutex scan_result_lock_; +#endif #ifdef USE_WIFI_AP WiFiAP ap_; #endif @@ -1003,5 +1019,25 @@ class WiFiComponent final : public Component { extern WiFiComponent *global_wifi_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +/// Guards WiFiComponent::scan_result_. Invariant: every mutation and every read +/// from outside the main loop holds this lock, and holders only do bounded work +/// (never unbounded waits or network sends). On every platform where the lock is +/// enabled (ESP32, LibreTiny) scan-done events are drained from the event queue +/// on the main loop, so all writers are main-loop there and main-loop reads take +/// no lock. Single-threaded platforms write from driver context and the lock is +/// a no-op. Compiles to nothing unless a cross-task reader is in the build and +/// the platform is multi-threaded (WIFI_SCAN_RESULTS_LOCK_ENABLED). +class ScanResultsLock { + public: +#ifdef WIFI_SCAN_RESULTS_LOCK_ENABLED + ScanResultsLock(WiFiComponent *parent) : guard_(parent->scan_result_lock_) {} + + private: + LockGuard guard_; +#else + ScanResultsLock(WiFiComponent *) {} +#endif +}; + } // namespace esphome::wifi #endif diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 84b864c0c5..e082b2c8c1 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -733,6 +733,8 @@ void WiFiComponent::s_wifi_scan_done_callback(void *arg, STATUS status) { } void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) { + // Compiles to nothing here; kept so every scan_result_ mutation holds the lock + ScanResultsLock lock(this); this->scan_result_.clear(); if (status != OK) { diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 2ade015a25..d78cd21380 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -891,65 +891,65 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { const auto &it = data->data.sta_scan_done; ESP_LOGV(TAG, "Scan done: status=%" PRIu32 " number=%u scan_id=%u", it.status, it.number, it.scan_id); - scan_result_.clear(); - this->scan_done_ = true; - if (it.status != 0) { - // scan error - return; - } - - if (it.number == 0) { - // no results - return; - } - uint16_t number = it.number; bool needs_full = this->needs_full_scan_results_(); + { + // Mutate in place under the lock; blocking a portal request is fine and + // avoids scratch buffers + ScanResultsLock lock(this); + this->scan_result_.clear(); + this->scan_done_ = true; + if (it.status != 0) { + // scan error + return; + } - // Smart reserve: full capacity if needed, small reserve otherwise - if (needs_full) { - this->scan_result_.reserve(number); - } else { - this->scan_result_.reserve(WIFI_SCAN_RESULT_FILTERED_RESERVE); - } + if (number == 0) { + // no results + return; + } + + // Smart reserve: full capacity if needed, small reserve otherwise + this->scan_result_.reserve(needs_full ? number : WIFI_SCAN_RESULT_FILTERED_RESERVE); #ifdef USE_ESP32_HOSTED - // getting records one at a time fails on P4 with hosted esp32 WiFi coprocessor - // Presumably an upstream bug, work-around by getting all records at once - // Use stack buffer (3904 bytes / ~80 bytes per record = ~48 records) with heap fallback - static constexpr size_t SCAN_RECORD_STACK_COUNT = 3904 / sizeof(wifi_ap_record_t); - SmallBufferWithHeapFallback records(number); - err = esp_wifi_scan_get_ap_records(&number, records.get()); - if (err != ESP_OK) { - esp_wifi_clear_ap_list(); - ESP_LOGW(TAG, "esp_wifi_scan_get_ap_records failed: %s", esp_err_to_name(err)); - return; - } - for (uint16_t i = 0; i < number; i++) { - wifi_ap_record_t &record = records.get()[i]; -#else - // Process one record at a time to avoid large buffer allocation - for (uint16_t i = 0; i < number; i++) { - wifi_ap_record_t record; - err = esp_wifi_scan_get_ap_record(&record); + // getting records one at a time fails on P4 with hosted esp32 WiFi coprocessor + // Presumably an upstream bug, work-around by getting all records at once + // Use stack buffer (3904 bytes / ~80 bytes per record = ~48 records) with heap fallback + static constexpr size_t SCAN_RECORD_STACK_COUNT = 3904 / sizeof(wifi_ap_record_t); + SmallBufferWithHeapFallback records(number); + err = esp_wifi_scan_get_ap_records(&number, records.get()); if (err != ESP_OK) { - ESP_LOGW(TAG, "esp_wifi_scan_get_ap_record failed: %s", esp_err_to_name(err)); - esp_wifi_clear_ap_list(); // Free remaining records not yet retrieved - break; + esp_wifi_clear_ap_list(); + ESP_LOGW(TAG, "esp_wifi_scan_get_ap_records failed: %s", esp_err_to_name(err)); + return; } + for (uint16_t i = 0; i < number; i++) { + wifi_ap_record_t &record = records.get()[i]; +#else + // Process one record at a time to avoid large buffer allocation + for (uint16_t i = 0; i < number; i++) { + wifi_ap_record_t record; + err = esp_wifi_scan_get_ap_record(&record); + if (err != ESP_OK) { + ESP_LOGW(TAG, "esp_wifi_scan_get_ap_record failed: %s", esp_err_to_name(err)); + esp_wifi_clear_ap_list(); // Free remaining records not yet retrieved + break; + } #endif // USE_ESP32_HOSTED - // Check C string first - avoid std::string construction for non-matching networks - const char *ssid_cstr = reinterpret_cast(record.ssid); + // Check C string first - avoid std::string construction for non-matching networks + const char *ssid_cstr = reinterpret_cast(record.ssid); - // Only construct std::string and store if needed - if (needs_full || this->matches_configured_network_(ssid_cstr, record.bssid)) { - bssid_t bssid; - std::copy(record.bssid, record.bssid + 6, bssid.begin()); - this->scan_result_.emplace_back(bssid, ssid_cstr, strlen(ssid_cstr), record.primary, record.rssi, - record.authmode != WIFI_AUTH_OPEN, ssid_cstr[0] == '\0'); - } else { - this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary); + // Only construct std::string and store if needed + if (needs_full || this->matches_configured_network_(ssid_cstr, record.bssid)) { + bssid_t bssid; + std::copy(record.bssid, record.bssid + 6, bssid.begin()); + this->scan_result_.emplace_back(bssid, ssid_cstr, strlen(ssid_cstr), record.primary, record.rssi, + record.authmode != WIFI_AUTH_OPEN, ssid_cstr[0] == '\0'); + } else { + this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary); + } } } ESP_LOGV(TAG, "Scan complete: %u found, %zu stored%s", number, this->scan_result_.size(), diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 59efa4f842..ce9c4eb6ce 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -657,44 +657,48 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { return true; } void WiFiComponent::wifi_scan_done_callback_() { - this->scan_result_.clear(); - this->scan_done_ = true; - int16_t num = WiFi.scanComplete(); - if (num < 0) - return; - bool needs_full = this->needs_full_scan_results_(); + { + // Mutate in place under the lock; blocking a portal request is fine and + // avoids scratch buffers + ScanResultsLock lock(this); + this->scan_result_.clear(); + this->scan_done_ = true; - // Access scan results directly via WiFi.scan struct to avoid Arduino String allocations - // WiFi.scan is public in LibreTiny for WiFiEvents & WiFiScan static handlers - auto *scan = WiFi.scan; + if (num < 0) + return; - // First pass: count matching networks - size_t count = 0; - for (int i = 0; i < num; i++) { - const char *ssid_cstr = scan->ap[i].ssid; - if (needs_full || this->matches_configured_network_(ssid_cstr, scan->ap[i].bssid.addr)) { - count++; + // Access scan results directly via WiFi.scan struct to avoid Arduino String allocations + // WiFi.scan is public in LibreTiny for WiFiEvents & WiFiScan static handlers + auto *scan = WiFi.scan; + + // First pass: count matching networks + size_t count = 0; + for (int i = 0; i < num; i++) { + const char *ssid_cstr = scan->ap[i].ssid; + if (needs_full || this->matches_configured_network_(ssid_cstr, scan->ap[i].bssid.addr)) { + count++; + } + } + + this->scan_result_.init(count); // Exact allocation + + // Second pass: store matching networks + for (int i = 0; i < num; i++) { + const char *ssid_cstr = scan->ap[i].ssid; + auto &ap = scan->ap[i]; + if (needs_full || this->matches_configured_network_(ssid_cstr, ap.bssid.addr)) { + this->scan_result_.emplace_back(bssid_t{ap.bssid.addr[0], ap.bssid.addr[1], ap.bssid.addr[2], ap.bssid.addr[3], + ap.bssid.addr[4], ap.bssid.addr[5]}, + ssid_cstr, strlen(ssid_cstr), ap.channel, ap.rssi, ap.auth != WIFI_AUTH_OPEN, + ssid_cstr[0] == '\0'); + } else { + this->log_discarded_scan_result_(ssid_cstr, ap.bssid.addr, ap.rssi, ap.channel); + } } } - this->scan_result_.init(count); // Exact allocation - - // Second pass: store matching networks - for (int i = 0; i < num; i++) { - const char *ssid_cstr = scan->ap[i].ssid; - if (needs_full || this->matches_configured_network_(ssid_cstr, scan->ap[i].bssid.addr)) { - auto &ap = scan->ap[i]; - this->scan_result_.emplace_back(bssid_t{ap.bssid.addr[0], ap.bssid.addr[1], ap.bssid.addr[2], ap.bssid.addr[3], - ap.bssid.addr[4], ap.bssid.addr[5]}, - ssid_cstr, strlen(ssid_cstr), ap.channel, ap.rssi, ap.auth != WIFI_AUTH_OPEN, - ssid_cstr[0] == '\0'); - } else { - auto &ap = scan->ap[i]; - this->log_discarded_scan_result_(ssid_cstr, ap.bssid.addr, ap.rssi, ap.channel); - } - } ESP_LOGV(TAG, "Scan complete: %d found, %zu stored%s", num, this->scan_result_.size(), needs_full ? "" : " (filtered)"); WiFi.scanDelete(); diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 1a70f81a2b..00a07d4085 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -193,12 +193,16 @@ void WiFiComponent::wifi_scan_result(void *env, const cyw43_ev_scan_result_t *re std::copy(result->bssid, result->bssid + 6, bssid.begin()); WiFiScanResult res(bssid, ssid_buf, len, result->channel, result->rssi, result->auth_mode != CYW43_AUTH_OPEN, len == 0); + // Compiles to nothing here; kept so every scan_result_ mutation holds the lock + ScanResultsLock lock(this); if (std::find(this->scan_result_.begin(), this->scan_result_.end(), res) == this->scan_result_.end()) { this->scan_result_.push_back(res); } } bool WiFiComponent::wifi_scan_start_(bool passive) { + // Compiles to nothing here; kept so every scan_result_ mutation holds the lock + ScanResultsLock lock(this); this->scan_result_.clear(); this->scan_done_ = false; s_scan_result_count = 0; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 5c5fc5e8b9..7cb8dc9002 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -252,6 +252,7 @@ #define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 #define BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE 16 #define USE_CAPTIVE_PORTAL +#define USE_WIFI_SCAN_RESULTS_LOCK #define USE_ESP32_BLE #define USE_ESP32_BLE_MAX_CONNECTIONS 3 #define USE_ESP32_BLE_CLIENT @@ -387,6 +388,7 @@ #define USE_ESP8266_CRASH_HANDLER #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 1, 2) #define USE_CAPTIVE_PORTAL +#define USE_WIFI_SCAN_RESULTS_LOCK #define USE_ESP8266_LOGGER_SERIAL #define USE_ESP8266_LOGGER_SERIAL1 #define USE_ESP8266_PREFERENCES_FLASH @@ -436,6 +438,7 @@ #ifdef USE_LIBRETINY #define USE_CAPTIVE_PORTAL +#define USE_WIFI_SCAN_RESULTS_LOCK #define USE_SOCKET_IMPL_LWIP_SOCKETS #define USE_LWIP_FAST_SELECT #define USE_WEBSERVER From 50f02aa5230721dc7d5646fa3466a0e54897fbd6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Jul 2026 21:42:21 -1000 Subject: [PATCH 1116/1815] [git] Fix submodule update failure when cloning libraries on the esp-idf toolchain (#17862) --- esphome/espidf/framework.py | 26 +- esphome/git.py | 194 ++++++--- esphome/platformio/library.py | 2 +- tests/unit_tests/test_espidf_framework.py | 33 +- tests/unit_tests/test_git.py | 500 +++++++++++++++++++--- 5 files changed, 614 insertions(+), 141 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index b54a0c294b..cfbae9ea46 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -397,9 +397,10 @@ def _clone_idf_with_submodules( handles branches, tags, and SHAs uniformly (mirrors the approach in ``esphome.git.clone_or_update``). """ - from esphome.git import run_git_command + from esphome.git import run_git_command, update_submodules - _LOGGER.info("Cloning ESP-IDF from %s%s", git_url, f"@{ref}" if ref else "") + key = f"{git_url}@{ref}" if ref else git_url + _LOGGER.info("Cloning ESP-IDF from %s", key) run_git_command(["git", "clone", "--depth=1", "--", git_url, str(framework_path)]) if ref: run_git_command( @@ -410,25 +411,14 @@ def _clone_idf_with_submodules( ["git", "reset", "--hard", "FETCH_HEAD"], git_dir=framework_path, ) - run_git_command( - [ - "git", - "submodule", - "update", - "--init", - "--recursive", - "--depth=1", - ], - git_dir=framework_path, - ) + update_submodules(framework_path, key) - # Sanity-check the resulting tree. run_git_command only raises when - # stderr is non-empty, so a clone that silently produces no working - # tree would otherwise be marked extracted and stuck until - # ``esphome clean``. + # Sanity-check the resulting tree: a clone can exit 0 yet produce no + # usable ESP-IDF checkout, which would otherwise be marked extracted and + # stuck until ``esphome clean``. if not (framework_path / "tools" / "idf_tools.py").is_file(): raise RuntimeError( - f"Clone of {git_url} produced no usable ESP-IDF tree at {framework_path}" + f"Clone of {key} produced no usable ESP-IDF tree at {framework_path}" ) diff --git a/esphome/git.py b/esphome/git.py index 0c1ad56367..46cce50d9d 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -2,6 +2,7 @@ from collections.abc import Callable from dataclasses import dataclass import hashlib import logging +import os from pathlib import Path import re import subprocess @@ -11,7 +12,7 @@ import urllib.parse import esphome.config_validation as cv from esphome.core import CORE, EsphomeError, TimePeriodSeconds -from esphome.helpers import rmtree, write_file +from esphome.helpers import add_git_ceiling_directory, rmtree, write_file _LOGGER = logging.getLogger(__name__) @@ -26,6 +27,24 @@ NEVER_REFRESH = TimePeriodSeconds(seconds=-1) # it does not pollute the worktree. _CLONE_COMPLETE_MARKER = "esphome_clone_complete" +# Environment variables that scope git to a specific repository. Git hooks and +# some CI wrappers export these; if they leak into the git commands run here, +# git binds to the caller's repository instead of the one being managed. The +# effects range from loud (`git clone` producing a bare-style directory with +# no working tree) to silent (an ambient GIT_INDEX_FILE makes +# `git submodule update --init` exit 0 without initializing anything). +_GIT_REPO_SCOPING_ENV = frozenset( + { + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_COMMON_DIR", + "GIT_NAMESPACE", + } +) + class GitException(cv.Invalid): """Base exception for git-related errors.""" @@ -43,32 +62,61 @@ class GitRepositoryError(GitException): """Exception raised when a git repository is in an invalid state.""" -def run_git_command(cmd: list[str], git_dir: Path | None = None) -> str: - if git_dir is not None: - _LOGGER.debug( - "Running git command with repository isolation: %s (git_dir=%s)", - " ".join(cmd), - git_dir, - ) - else: - _LOGGER.debug("Running git command: %s", " ".join(cmd)) +def _redact_url_credentials(text: str) -> str: + """Mask userinfo in any URLs embedded in ``text``. - # Set up environment for repository isolation if git_dir is provided - # Force git to only operate on this specific repository by setting - # GIT_DIR and GIT_WORK_TREE. This prevents git from walking up the - # directory tree to find parent repositories when the target repo's - # .git directory is corrupt. Without this, commands like 'git stash' - # could accidentally operate on parent repositories (e.g., the main - # ESPHome repo) instead of failing, causing data loss. - env: dict[str, str] | None = None - cwd: str | None = None + Users can put credentials directly in a git URL, and log output is + routinely pasted into public issues. + """ + return re.sub(r"://[^/@\s]+@", "://***@", text) + + +def run_git_command( + cmd: list[str], git_dir: Path | None = None, *, cwd: Path | None = None +) -> str: + """Run a git command and return its stdout. + + The repository-scoping environment variables in ``_GIT_REPO_SCOPING_ENV`` + are always stripped. ``git_dir`` additionally pins GIT_DIR/GIT_WORK_TREE + to that repository and runs the command there; ``cwd`` alone runs the + command in that directory with GIT_CEILING_DIRECTORIES capping repository + discovery at its parent. + """ + # Every invocation starts from an environment with the repository-scoping + # variables stripped (see _GIT_REPO_SCOPING_ENV) so a git hook or CI + # wrapper invoking ESPHome can never redirect these commands to its own + # repository or index. + # + # ``git_dir`` then re-adds GIT_DIR and GIT_WORK_TREE pointing at the + # managed repository. This prevents git from walking up the directory + # tree to find parent repositories when the target repo's .git directory + # is corrupt. Without this, commands like 'git stash' could accidentally + # operate on parent repositories (e.g., the main ESPHome repo) instead of + # failing, causing data loss. + # + # ``cwd`` (without ``git_dir``) runs the command in that directory + # without GIT_DIR/GIT_WORK_TREE. The ``git submodule`` porcelain needs + # this: on some installations (e.g. Windows setups where a shim hands + # git untranslated paths) it refuses to run when GIT_DIR/GIT_WORK_TREE + # are set, failing with "cannot be used without a working tree". + # GIT_CEILING_DIRECTORIES (which git only honors as an absolute path) + # keeps the parent-repo-walk protection instead: if the repo's .git is + # missing or corrupt, git fails rather than discovering an enclosing + # repository. + env = {k: v for k, v in os.environ.items() if k not in _GIT_REPO_SCOPING_ENV} if git_dir is not None: - env = { - **subprocess.os.environ, - "GIT_DIR": str(Path(git_dir) / ".git"), - "GIT_WORK_TREE": str(git_dir), - } - cwd = str(git_dir) + env["GIT_DIR"] = str(Path(git_dir) / ".git") + env["GIT_WORK_TREE"] = str(git_dir) + cwd = git_dir + elif cwd is not None: + add_git_ceiling_directory(env, Path(cwd).absolute().parent) + + _LOGGER.debug( + "Running git command: %s (cwd=%s, isolated=%s)", + _redact_url_credentials(" ".join(cmd)), + cwd, + git_dir is not None, + ) try: ret = subprocess.run( @@ -86,12 +134,17 @@ def run_git_command(cmd: list[str], git_dir: Path | None = None) -> str: "for installation instructions." ) from err - if ret.returncode != 0 and ret.stderr: - err_str = ret.stderr.decode("utf-8") - lines = [x.strip() for x in err_str.splitlines()] - if lines[-1].startswith("fatal:"): - raise GitCommandError(lines[-1][len("fatal: ") :]) - raise GitCommandError(err_str) + if ret.returncode != 0: + if ret.stderr: + err_str = ret.stderr.decode("utf-8") + lines = [x.strip() for x in err_str.splitlines()] + if lines[-1].startswith("fatal:"): + raise GitCommandError(lines[-1][len("fatal: ") :]) + raise GitCommandError(err_str) + raise GitCommandError( + f"git exited with code {ret.returncode}: " + f"{_redact_url_credentials(' '.join(cmd))}" + ) return ret.stdout.decode("utf-8").strip() @@ -123,6 +176,27 @@ def _remove_repo_dir(repo_dir: Path) -> None: rmtree(repo_dir) +def update_submodules(repo_dir: Path, key: str) -> None: + """Initialize/update every submodule the repository declares, recursively, + matching how PlatformIO clones libraries. + + Most repositories declare no submodules, so this does nothing when there + is no ``.gitmodules`` file. Which submodules get populated is git's own + policy (``update = none``, ``submodule.active``, sparse checkouts); + git's exit code is the error signal. + + Runs with plain ``cwd`` rather than ``git_dir`` isolation, which the + ``git submodule`` porcelain does not tolerate (see ``run_git_command``). + """ + if not (repo_dir / ".gitmodules").is_file(): + return + _LOGGER.info("Updating submodules for %s", _redact_url_credentials(key)) + run_git_command( + ["git", "submodule", "update", "--init", "--recursive", "--depth=1"], + cwd=repo_dir, + ) + + def resolve_symlink_stub(repo_dir: Path, file_path: Path) -> Path | None: """Return the symlink target if ``file_path`` is a Windows-checked-out symlink stub. @@ -217,12 +291,19 @@ def clone_or_update( domain: str, username: str = None, password: str = None, - submodules: list[str] | None = None, + init_submodules: bool = False, subpath: Path | None = None, _recover_broken: bool = True, ) -> tuple[Path, Callable[[], None] | None]: key = f"{url}@{ref}" + # The user may have embedded credentials in the URL itself; log this + # instead of key. + safe_key = _redact_url_credentials(key) + # Keep the caller's URL for the recovery re-clone below: rewriting the + # rewritten URL would double the userinfo, and the recursive call must + # compute the same cache key as this one. + original_url = url if username is not None and password is not None: url = url.replace( "://", f"://{urllib.parse.quote(username)}:{urllib.parse.quote(password)}@" @@ -238,12 +319,12 @@ def clone_or_update( # predates the marker; either way it cannot be trusted, especially # with NEVER_REFRESH where it would otherwise be reused forever. _LOGGER.warning( - "Removing incomplete clone of %s at %s, will re-clone", key, repo_dir + "Removing incomplete clone of %s at %s, will re-clone", safe_key, repo_dir ) _remove_repo_dir(repo_dir) if not repo_dir.is_dir(): - _LOGGER.info("Cloning %s", key) + _LOGGER.info("Cloning %s", safe_key) _LOGGER.debug("Location: %s", repo_dir) try: cmd = ["git", "clone", "--depth=1"] @@ -262,15 +343,8 @@ def clone_or_update( ["git", "reset", "--hard", "FETCH_HEAD"], git_dir=repo_dir ) - if submodules is not None: - _LOGGER.info( - "Initializing submodules (%s) for %s", ", ".join(submodules), key - ) - run_git_command( - ["git", "submodule", "update", "--init", "--depth=1", "--"] - + submodules, - git_dir=repo_dir, - ) + if init_submodules: + update_submodules(repo_dir, key) except GitException: # Remove incomplete clone to prevent stale state. Without this, @@ -290,12 +364,12 @@ def clone_or_update( ) except EsphomeError as err: _LOGGER.warning( - "Could not write clone completion marker for %s: %s", key, err + "Could not write clone completion marker for %s: %s", safe_key, err ) else: if refresh == NEVER_REFRESH or CORE.skip_external_update: - _LOGGER.debug("Skipping update for %s (refresh disabled)", key) + _LOGGER.debug("Skipping update for %s (refresh disabled)", safe_key) return repo_dir, None file_timestamp = Path(repo_dir / ".git" / "FETCH_HEAD") @@ -319,7 +393,7 @@ def clone_or_update( ["git", "rev-parse", "HEAD"], git_dir=repo_dir ) - _LOGGER.info("Updating %s", key) + _LOGGER.info("Updating %s", safe_key) _LOGGER.debug("Location: %s", repo_dir) # Stash local changes (if any) @@ -345,19 +419,25 @@ def clone_or_update( ["git", "reset", "--hard", "FETCH_HEAD"], git_dir=repo_dir, ) + + # Inside the try so a submodule failure routes through the + # recovery re-clone below instead of leaving a repo that the + # refresh window would silently accept on the next run. + if init_submodules: + update_submodules(repo_dir, key) except GitException as err: # Repository is in a broken state or update failed # Only attempt recovery once to prevent infinite recursion if not _recover_broken: _LOGGER.error( "Repository %s recovery failed, cannot retry (already attempted once)", - key, + safe_key, ) raise _LOGGER.warning( "Repository %s has issues (%s), attempting recovery", - key, + safe_key, err, ) _LOGGER.info("Removing broken repository at %s", repo_dir) @@ -367,31 +447,21 @@ def clone_or_update( # Recursively call clone_or_update to re-clone # Set _recover_broken=False to prevent infinite recursion result = clone_or_update( - url=url, + url=original_url, ref=ref, refresh=refresh, domain=domain, username=username, password=password, - submodules=submodules, + init_submodules=init_submodules, subpath=subpath, _recover_broken=False, ) - _LOGGER.info("Repository %s successfully recovered", key) + _LOGGER.info("Repository %s successfully recovered", safe_key) return result - if submodules is not None: - _LOGGER.info( - "Updating submodules (%s) for %s", ", ".join(submodules), key - ) - run_git_command( - ["git", "submodule", "update", "--init", "--depth=1", "--"] - + submodules, - git_dir=repo_dir, - ) - def revert(): - _LOGGER.info("Reverting changes to %s -> %s", key, old_sha) + _LOGGER.info("Reverting changes to %s -> %s", safe_key, old_sha) run_git_command(["git", "reset", "--hard", old_sha], git_dir=repo_dir) return repo_dir, revert diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 7c8566b77a..1a523ce0ab 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -134,7 +134,7 @@ class GitSource(Source): ref=self.ref, refresh=git.NEVER_REFRESH if not force else None, domain=domain, - submodules=[], + init_submodules=True, subpath=Path(dir_suffix), ) return path diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index eb68b17572..cbc9fe2cda 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -137,10 +137,17 @@ def test_parse_git_source_rejected(source: str) -> None: assert _parse_git_source(source) is None -def _make_idf_tree(framework_path: Path) -> None: - """Create the minimum tree _clone_idf_with_submodules sanity-checks for.""" +def _make_idf_tree(framework_path: Path, *, gitmodules: bool = True) -> None: + """Create the minimum tree _clone_idf_with_submodules sanity-checks for. + + ``gitmodules=False`` simulates a fork that vendors components in-tree + instead of declaring submodules; update_submodules skips the git call + when that file is missing. + """ (framework_path / "tools").mkdir(parents=True) (framework_path / "tools" / "idf_tools.py").write_text("# stub\n") + if gitmodules: + (framework_path / ".gitmodules").write_text("# stub\n") def test_clone_idf_with_submodules_without_ref(tmp_path: Path) -> None: @@ -214,6 +221,28 @@ def test_clone_idf_with_submodules_raises_when_tree_missing( ) +def test_clone_idf_accepts_flattened_fork_without_gitmodules( + tmp_path: Path, +) -> None: + """A fork that vendors components in-tree instead of as submodules is valid. + + No .gitmodules means the submodule step is skipped entirely. + """ + framework_path = tmp_path / "idf" + framework_path.mkdir() + _make_idf_tree(framework_path, gitmodules=False) + + with patch("esphome.git.run_git_command", return_value="") as run_git_command_mock: + _clone_idf_with_submodules( + framework_path, + "https://github.com/example/flattened-esp-idf.git", + None, + ) + + calls = [c.args[0] for c in run_git_command_mock.call_args_list] + assert not any(c[1] == "submodule" for c in calls) + + # --------------------------------------------------------------------------- # Helpers for _tar_extract_all hard-link prefix-stripping tests # --------------------------------------------------------------------------- diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index c9e0339ad7..858eee5e9f 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -1,8 +1,10 @@ """Tests for git.py module.""" from collections.abc import Callable +import logging import os from pathlib import Path +import subprocess import time from typing import Any from unittest.mock import Mock, patch @@ -71,19 +73,40 @@ def _simulate_cloned_repo(repo_dir: Path) -> None: (repo_dir / ".git").mkdir(exist_ok=True) -def _make_clone_side_effect(repo_dir: Path) -> Callable[..., str]: - """Return a run_git_command side effect whose clone creates the repo dir.""" +def _make_clone_side_effect( + repo_dir: Path, gitmodules: bool = False +) -> Callable[..., str]: + """Return a run_git_command side effect whose clone creates the repo dir. + + With ``gitmodules`` the cloned repo also declares submodules. + """ def git_command_side_effect( cmd: list[str], cwd: str | None = None, **kwargs: Any ) -> str: if _get_git_command_type(cmd) == "clone": _simulate_cloned_repo(repo_dir) + if gitmodules: + (repo_dir / ".gitmodules").write_text("test") return "" return git_command_side_effect +def _submodule_calls(mock: Mock) -> list[Any]: + """Return the mock's `git submodule` calls.""" + return [ + c for c in mock.call_args_list if _get_git_command_type(c[0][0]) == "submodule" + ] + + +def _assert_submodule_runs_without_isolation(call: Any, repo_dir: Path) -> None: + """Assert a git submodule call ran with plain cwd, not GIT_DIR/GIT_WORK_TREE + isolation, which breaks the submodule porcelain on some installations.""" + assert call.kwargs.get("git_dir") is None + assert call.kwargs.get("cwd") == repo_dir + + def test_run_git_command_success(tmp_path: Path) -> None: """Test that run_git_command returns output on success.""" # Create a simple git repo to test with @@ -100,6 +123,22 @@ def test_run_git_command_success(tmp_path: Path) -> None: assert isinstance(result, str) +def test_run_git_command_debug_log_redacts_credentials( + tmp_path: Path, mock_subprocess_run: Mock, caplog: pytest.LogCaptureFixture +) -> None: + """Embedded URL credentials never reach the debug log; -v output is + routinely pasted into public issues. subprocess is mocked so no real + git ever sees the URL (the path is not creatable on Windows).""" + mock_subprocess_run.return_value = Mock(returncode=0, stdout=b"", stderr=b"") + with caplog.at_level(logging.DEBUG, logger="esphome.git"): + git.run_git_command( + ["git", "clone", "https://user:hunter2@github.com/test/repo"], + cwd=tmp_path, + ) + assert "hunter2" not in caplog.text + assert "://***@github.com/test/repo" in caplog.text + + def test_run_git_command_with_git_dir_isolation( tmp_path: Path, mock_subprocess_run: Mock ) -> None: @@ -116,10 +155,17 @@ def test_run_git_command_with_git_dir_isolation( stderr=b"", ) - result = git.run_git_command( - ["git", "rev-parse", "HEAD"], - git_dir=repo_dir, - ) + # Ambient repo-scoping vars simulate a git hook invoking ESPHome; an + # ambient GIT_INDEX_FILE surviving into a git_dir invocation fails + # silently (git operates on the caller's index and exits 0). + with patch.dict( + os.environ, + {"GIT_INDEX_FILE": "/caller/index", "GIT_OBJECT_DIRECTORY": "/caller/objects"}, + ): + result = git.run_git_command( + ["git", "rev-parse", "HEAD"], + git_dir=repo_dir, + ) # Verify subprocess.run was called assert mock_subprocess_run.called @@ -131,6 +177,9 @@ def test_run_git_command_with_git_dir_isolation( assert "GIT_WORK_TREE" in env assert env["GIT_DIR"] == str(repo_dir / ".git") assert env["GIT_WORK_TREE"] == str(repo_dir) + # The ambient scoping vars must be stripped, not passed through. + assert "GIT_INDEX_FILE" not in env + assert "GIT_OBJECT_DIRECTORY" not in env assert result == "test output" @@ -216,6 +265,89 @@ def test_run_git_command_without_git_dir(mock_subprocess_run: Mock) -> None: assert result == "Cloning into 'test_repo'..." +@pytest.mark.parametrize("relative", [False, True], ids=["absolute", "relative"]) +def test_run_git_command_with_cwd_runs_in_dir_without_isolation( + tmp_path: Path, + mock_subprocess_run: Mock, + monkeypatch: pytest.MonkeyPatch, + relative: bool, +) -> None: + """The cwd parameter sets the working directory without GIT_DIR/GIT_WORK_TREE. + + Ambient GIT_DIR/GIT_WORK_TREE (e.g. from a git hook or CI wrapper) must be + stripped too, and GIT_CEILING_DIRECTORIES must stop git from walking up to + an enclosing repository if the target repo's .git is missing or corrupt. + Git silently ignores a relative ceiling entry, so the variable must come + out absolute even when the given cwd is relative. + """ + repo_dir = tmp_path / "test_repo" + repo_dir.mkdir() + if relative: + monkeypatch.chdir(tmp_path) + cwd_arg = Path("test_repo") + else: + cwd_arg = repo_dir + + mock_subprocess_run.return_value = Mock( + returncode=0, + stdout=b"test output", + stderr=b"", + ) + + with patch.dict( + os.environ, + { + "GIT_DIR": "/ambient/.git", + "GIT_WORK_TREE": "/ambient", + "GIT_INDEX_FILE": "/ambient/.git/index", + }, + ): + result = git.run_git_command(["git", "submodule", "update"], cwd=cwd_arg) + + call_args = mock_subprocess_run.call_args + env = call_args[1]["env"] + assert "GIT_DIR" not in env + assert "GIT_WORK_TREE" not in env + assert "GIT_INDEX_FILE" not in env + ceiling = Path(env["GIT_CEILING_DIRECTORIES"]) + assert ceiling.is_absolute() + assert ceiling.samefile(tmp_path) + assert call_args[1]["cwd"] == cwd_arg + assert result == "test output" + + +def test_run_git_command_raises_on_nonfatal_stderr( + tmp_path: Path, mock_subprocess_run: Mock +) -> None: + """Nonzero exit with stderr lacking a fatal: prefix raises with full stderr.""" + mock_subprocess_run.return_value = Mock( + returncode=1, + stdout=b"", + stderr=b"error: pathspec 'nope' did not match any file(s)\n", + ) + + with pytest.raises(GitCommandError, match="did not match"): + git.run_git_command(["git", "checkout", "nope"], git_dir=tmp_path) + + +def test_run_git_command_raises_on_nonzero_exit_without_stderr( + tmp_path: Path, mock_subprocess_run: Mock +) -> None: + """A nonzero exit must raise even when git printed nothing to stderr. + + Silent nonzero exits were previously treated as success, which is how + broken checkouts could be cached as complete. + """ + mock_subprocess_run.return_value = Mock( + returncode=1, + stdout=b"", + stderr=b"", + ) + + with pytest.raises(GitCommandError, match="exited with code 1"): + git.run_git_command(["git", "submodule", "update"], cwd=tmp_path) + + def test_run_git_command_without_git_dir_raises_error( mock_subprocess_run: Mock, ) -> None: @@ -1156,46 +1288,6 @@ def test_clone_with_ref_uses_shallow_fetch( 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" @@ -1220,10 +1312,91 @@ def test_refresh_fetch_is_shallow(tmp_path: Path, mock_run_git_command: Mock) -> assert cmd[-1] == ref -def test_refresh_submodule_update_is_shallow( +@pytest.mark.parametrize( + "refresh", [None, TimePeriodSeconds(days=1)], ids=["clone", "refresh"] +) +def test_all_submodules_skipped_without_gitmodules( + tmp_path: Path, mock_run_git_command: Mock, refresh: TimePeriodSeconds | None +) -> None: + """init_submodules is a no-op for repos with no .gitmodules. + + This is the esp-idf toolchain library scenario from issue #17860: the + PlatformIO library converter requests "all submodules" for every git + library, and most libraries declare none. The git submodule porcelain + must not run at all in that case — it fails outright on some git + installations. + """ + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + + if refresh is None: + mock_run_git_command.side_effect = _make_clone_side_effect(repo_dir) + else: + _setup_old_repo(repo_dir) + mock_run_git_command.return_value = "abc123" + + git.clone_or_update( + url=url, + ref=None, + refresh=refresh, + domain=domain, + init_submodules=True, + ) + + assert not _submodule_calls(mock_run_git_command) + + +@pytest.mark.parametrize( + "refresh", [None, TimePeriodSeconds(days=1)], ids=["clone", "refresh"] +) +def test_all_submodules_updated_with_gitmodules( + tmp_path: Path, mock_run_git_command: Mock, refresh: TimePeriodSeconds | None +) -> None: + """init_submodules initializes all submodules when .gitmodules exists.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + + if refresh is None: + mock_run_git_command.side_effect = _make_clone_side_effect( + repo_dir, gitmodules=True + ) + else: + _setup_old_repo(repo_dir) + (repo_dir / ".gitmodules").write_text("test") + mock_run_git_command.return_value = "abc123" + + git.clone_or_update( + url=url, + ref=None, + refresh=refresh, + domain=domain, + init_submodules=True, + ) + + submodule_calls = _submodule_calls(mock_run_git_command) + # Which submodules get populated is git's own policy, so no status + # verification follows the update. + assert len(submodule_calls) == 1 + cmd = submodule_calls[0][0][0] + assert cmd[2] == "update" + assert "--depth=1" in cmd + # Recursive, mirroring PlatformIO's recursive library clones. + assert "--recursive" in cmd + _assert_submodule_runs_without_isolation(submodule_calls[0], repo_dir) + + +def test_recovery_reclone_keeps_credentials_and_cache_key( tmp_path: Path, mock_run_git_command: Mock ) -> None: - """The refresh-path submodule update should use --depth=1.""" + """The recovery re-clone must not re-apply credentials to the already + rewritten URL (no doubled userinfo) and must land in the same cache + directory, or a credentialed private repo re-clones on every run.""" CORE.config_path = tmp_path / "test.yaml" url = "https://github.com/test/repo" @@ -1231,24 +1404,235 @@ def test_refresh_submodule_update_is_shallow( repo_dir = _compute_repo_dir(url, None, domain) _setup_old_repo(repo_dir) - mock_run_git_command.return_value = "abc123" + (repo_dir / ".gitmodules").write_text("test") - git.clone_or_update( + calls = {"submodule": 0} + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + if _get_git_command_type(cmd) == "clone": + _simulate_cloned_repo(repo_dir) + if _get_git_command_type(cmd) == "submodule": + calls["submodule"] += 1 + if calls["submodule"] == 1: + raise git.GitCommandError("git submodule update exited with code 1") + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + recovered_dir, _ = git.clone_or_update( url=url, ref=None, refresh=TimePeriodSeconds(days=1), domain=domain, - submodules=["components/foo"], + username="user", + password="hunter2", + init_submodules=True, ) - submodule_calls = [ - c for c in mock_run_git_command.call_args_list if "submodule" in c[0][0] + assert recovered_dir == repo_dir + clone_cmds = [ + c[0][0] + for c in mock_run_git_command.call_args_list + if _get_git_command_type(c[0][0]) == "clone" ] - 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") + assert clone_cmds + clone_url = clone_cmds[0][-2] + assert clone_url == "https://user:hunter2@github.com/test/repo" + assert clone_url.count("@") == 1 + + +def test_refresh_submodule_failure_recovers_then_raises( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A refresh-path submodule failure routes through the recovery re-clone. + + The broken repo is removed and re-cloned; when the submodule update fails + again on the fresh clone the cache entry is removed and the error + propagates, instead of leaving behind a repo the refresh window would + silently accept on the next run. + """ + 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) + (repo_dir / ".gitmodules").write_text("test") + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + if _get_git_command_type(cmd) == "clone": + _simulate_cloned_repo(repo_dir) + (repo_dir / ".gitmodules").write_text("test") + if _get_git_command_type(cmd) == "submodule": + raise git.GitCommandError("git submodule update exited with code 1") + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + with pytest.raises(git.GitCommandError, match="exited with code 1"): + git.clone_or_update( + url=url, + ref=None, + refresh=TimePeriodSeconds(days=1), + domain=domain, + init_submodules=True, + ) + + assert not repo_dir.is_dir() + # Recovery removed the repo and re-cloned before failing again. + assert any( + _get_git_command_type(c[0][0]) == "clone" + for c in mock_run_git_command.call_args_list + ) + + +def _real_git(*args: str, cwd: Path) -> None: + """Run real git to build a test fixture repository.""" + subprocess.run( + [ + "git", + "-c", + "user.email=test@test.invalid", + "-c", + "user.name=test", + "-c", + "commit.gpgsign=false", + "-c", + "protocol.file.allow=always", + *args, + ], + cwd=cwd, + check=True, + capture_output=True, + ) + + +# Git blocks file-protocol submodules by default (CVE-2022-39253); the e2e +# tests allow them via GIT_CONFIG_* environment variables, which reach the +# child git processes through run_git_command's filtered environment. +_ALLOW_FILE_PROTOCOL_ENV = { + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "protocol.file.allow", + "GIT_CONFIG_VALUE_0": "always", +} + + +def _make_real_repo(path: Path, filename: str) -> None: + """Create a real git repository containing one committed file.""" + path.mkdir() + _real_git("init", "-q", cwd=path) + (path / filename).write_text("content") + _real_git("add", filename, cwd=path) + _real_git("commit", "-q", "-m", "init", cwd=path) + + +def _add_submodule( + repo: Path, url: Path, path: str, *, update_none: bool = False +) -> None: + """Add ``url`` as a submodule of ``repo`` at ``path`` and commit it.""" + _real_git("submodule", "add", str(url), path, cwd=repo) + if update_none: + _real_git( + "config", "-f", ".gitmodules", f"submodule.{path}.update", "none", cwd=repo + ) + _real_git("add", ".gitmodules", cwd=repo) + _real_git("commit", "-q", "-m", f"add submodule {path}", cwd=repo) + + +def test_clone_or_update_real_git_without_submodules(tmp_path: Path) -> None: + """End-to-end with real git: a repo with no .gitmodules clones cleanly. + + This is the issue #17860 scenario: requesting "all submodules" on a + submodule-less repository must not invoke the git submodule porcelain + and must produce a usable checkout. + """ + CORE.config_path = tmp_path / "test.yaml" + + upstream = tmp_path / "upstream" + _make_real_repo(upstream, "README.md") + + repo_dir, _ = git.clone_or_update( + url=str(upstream), + ref=None, + refresh=None, + domain="test_e2e", + init_submodules=True, + ) + + assert (repo_dir / "README.md").is_file() + + +def test_clone_or_update_real_git_initializes_submodules(tmp_path: Path) -> None: + """End-to-end with real git: submodules are actually checked out. + + Exercises the real `git submodule update` invocation, including the + env handling in run_git_command that the mocked tests cannot cover. + """ + CORE.config_path = tmp_path / "test.yaml" + + sub_repo = tmp_path / "sub" + _make_real_repo(sub_repo, "sub_file.txt") + + upstream = tmp_path / "upstream" + _make_real_repo(upstream, "README.md") + _add_submodule(upstream, sub_repo, "vendor/sub") + + with patch.dict(os.environ, _ALLOW_FILE_PROTOCOL_ENV): + repo_dir, _ = git.clone_or_update( + url=str(upstream), + ref=None, + refresh=None, + domain="test_e2e", + init_submodules=True, + ) + + assert (repo_dir / "vendor" / "sub" / "sub_file.txt").is_file() + + +def test_clone_or_update_real_git_honors_update_none_submodule( + tmp_path: Path, +) -> None: + """End-to-end with real git: submodules declared `update = none` stay skipped. + + Shows git itself skipping the declared paths at both nesting levels + (and exiting 0) while the regular submodules check out. + """ + CORE.config_path = tmp_path / "test.yaml" + + sub_repo = tmp_path / "sub" + _make_real_repo(sub_repo, "sub_file.txt") + + # Intermediate submodule that itself declares a skipped nested submodule. + mid_repo = tmp_path / "mid" + _make_real_repo(mid_repo, "mid_file.txt") + _add_submodule(mid_repo, sub_repo, "vendor/leaf", update_none=True) + + upstream = tmp_path / "upstream" + _make_real_repo(upstream, "README.md") + _add_submodule(upstream, sub_repo, "vendor/sub") + _add_submodule(upstream, sub_repo, "vendor/skipped", update_none=True) + _add_submodule(upstream, mid_repo, "vendor/mid") + + with patch.dict(os.environ, _ALLOW_FILE_PROTOCOL_ENV): + repo_dir, _ = git.clone_or_update( + url=str(upstream), + ref=None, + refresh=None, + domain="test_e2e", + init_submodules=True, + ) + + assert (repo_dir / "vendor" / "sub" / "sub_file.txt").is_file() + assert not (repo_dir / "vendor" / "skipped" / "sub_file.txt").exists() + assert (repo_dir / "vendor" / "mid" / "mid_file.txt").is_file() + assert not ( + repo_dir / "vendor" / "mid" / "vendor" / "leaf" / "sub_file.txt" + ).exists() def test_refresh_picks_up_new_remote_commits( From d93772fed694d662913047ab6a79e9c7c0a55c91 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:42:44 +1200 Subject: [PATCH 1117/1815] [captive_portal] Escape SSID when building config JSON (#17872) --- .../captive_portal/captive_portal.cpp | 11 +- .../components/captive_portal/json_escape.h | 85 ++++++++++++++ tests/components/captive_portal/__init__.py | 23 ++++ .../captive_portal/json_escape_test.cpp | 107 ++++++++++++++++++ 4 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 esphome/components/captive_portal/json_escape.h create mode 100644 tests/components/captive_portal/__init__.py create mode 100644 tests/components/captive_portal/json_escape_test.cpp diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 228fdf7934..e6a63b8275 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -4,6 +4,7 @@ #include "esphome/core/application.h" #include "esphome/components/wifi/wifi_component.h" #include "captive_index.h" +#include "json_escape.h" namespace esphome::captive_portal { @@ -24,6 +25,9 @@ void CaptivePortal::handle_config(AsyncWebServerRequest *request) { stream->printf(R"({"mac":"%s","name":"%s","aps":[{})", mac_str, App.get_name().c_str()); #endif + // An SSID can contain a " or \ that would break the JSON, so escape it before writing it out. An SSID is at most + // 32 bytes (IEEE 802.11), so this is large enough that nothing is ever dropped. Reused for every scan result. + char escaped_ssid[32 * JSON_ESCAPE_MAX_EXPANSION + 1]; { // Invariant: only bounded in-memory work under the lock; the network send // happens later in request->send() @@ -32,18 +36,17 @@ void CaptivePortal::handle_config(AsyncWebServerRequest *request) { if (scan.get_is_hidden()) continue; - // Assumes no " in ssid, possible unicode issues? + json_escape_into_buffer(escaped_ssid, scan.get_ssid()); #ifdef USE_ESP8266 stream->print(ESPHOME_F(",{\"ssid\":\"")); - stream->print(scan.get_ssid().c_str()); + stream->print(escaped_ssid); stream->print(ESPHOME_F("\",\"rssi\":")); stream->print(scan.get_rssi()); stream->print(ESPHOME_F(",\"lock\":")); stream->print(scan.get_with_auth()); stream->print(ESPHOME_F("}")); #else - stream->printf(R"(,{"ssid":"%s","rssi":%d,"lock":%d})", scan.get_ssid().c_str(), scan.get_rssi(), - scan.get_with_auth()); + stream->printf(R"(,{"ssid":"%s","rssi":%d,"lock":%d})", escaped_ssid, scan.get_rssi(), scan.get_with_auth()); #endif } } diff --git a/esphome/components/captive_portal/json_escape.h b/esphome/components/captive_portal/json_escape.h new file mode 100644 index 0000000000..0b3c71cd74 --- /dev/null +++ b/esphome/components/captive_portal/json_escape.h @@ -0,0 +1,85 @@ +#pragma once +#include +#include +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" + +namespace esphome::captive_portal { + +/// Largest number of output bytes a single input byte can expand to (a \u00XX sequence). +static constexpr size_t JSON_ESCAPE_MAX_EXPANSION = 6; + +/// Copy value into buf, escaping the characters that cannot appear raw inside a JSON string literal. +/// +/// Escapes " and \ along with the control characters below 0x20, using the short forms where JSON defines one and +/// \u00XX otherwise. Bytes >= 0x20 are copied verbatim, so text containing valid UTF-8 survives intact. The result is +/// always null terminated; anything that would not fit is dropped rather than written partially. Returns buf so the +/// call can be used directly as an argument. +/// +/// To size buf so that no input is ever dropped, allow JSON_ESCAPE_MAX_EXPANSION bytes per input byte plus one for +/// the null terminator. +inline const char *json_escape_into_buffer(std::span buf, StringRef value) { + if (buf.empty()) + return ""; + // Reserve one byte for the null terminator. + const size_t limit = buf.size() - 1; + size_t pos = 0; + for (char ch : value) { + auto c = static_cast(ch); + // Every short form is a backslash followed by a single character, so only that character is needed here. Keeping + // it a char rather than a string avoids putting the sequences in read only data, which is RAM on the ESP8266. + char escape = '\0'; + switch (c) { + case '"': + escape = '"'; + break; + case '\\': + escape = '\\'; + break; + case '\n': + escape = 'n'; + break; + case '\r': + escape = 'r'; + break; + case '\t': + escape = 't'; + break; + case '\b': + escape = 'b'; + break; + case '\f': + escape = 'f'; + break; + default: + break; + } + if (escape != '\0') { + if (pos + 2 > limit) + break; + buf[pos++] = '\\'; + buf[pos++] = escape; + } else if (c < 0x20) { + // Remaining control characters have no short form and must be written as \u00XX. The value is below 0x20, so + // the two high hex digits are always zero. + if (pos + JSON_ESCAPE_MAX_EXPANSION > limit) + break; + buf[pos++] = '\\'; + buf[pos++] = 'u'; + buf[pos++] = '0'; + buf[pos++] = '0'; + buf[pos++] = format_hex_char(static_cast(c >> 4)); + buf[pos++] = format_hex_char(static_cast(c & 0x0F)); + } else { + if (pos + 1 > limit) + break; + buf[pos++] = static_cast(c); + } + } + buf[pos] = '\0'; + return buf.data(); +} + +} // namespace esphome::captive_portal diff --git a/tests/components/captive_portal/__init__.py b/tests/components/captive_portal/__init__.py new file mode 100644 index 0000000000..b13c81912c --- /dev/null +++ b/tests/components/captive_portal/__init__.py @@ -0,0 +1,23 @@ +"""Test-manifest overrides for the captive_portal C++ unit tests. + +``json_escape`` lives in a standalone, dependency-free header +(``esphome/components/captive_portal/json_escape.h``). The rest of the +captive_portal component and its auto-loaded dependencies (``web_server_base``, +``ota.web_server``) do not build for the ``host`` platform that the C++ unit +test harness targets. Strip those away and replace the real schema -- which is +restricted to non-host platforms via ``cv.only_on`` and requires a +``web_server_base`` instance via ``use_id`` -- with an empty one so the host +test config validates. ``to_code`` stays suppressed (the default), so +``USE_CAPTIVE_PORTAL`` is never defined and ``captive_portal.cpp`` compiles to an +empty translation unit; only ``json_escape.h`` is exercised by the test. +""" + +import esphome.config_validation as cv +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.auto_load = [] + manifest.dependencies = [] + manifest.config_schema = cv.Schema({}) + manifest.final_validate_schema = None diff --git a/tests/components/captive_portal/json_escape_test.cpp b/tests/components/captive_portal/json_escape_test.cpp new file mode 100644 index 0000000000..98b5ce4ff7 --- /dev/null +++ b/tests/components/captive_portal/json_escape_test.cpp @@ -0,0 +1,107 @@ +#include + +#include + +#include "esphome/components/captive_portal/json_escape.h" + +namespace esphome::captive_portal::testing { + +namespace { + +// Large enough that none of the inputs below are ever dropped. +constexpr size_t TEST_BUFFER_SIZE = 64 * JSON_ESCAPE_MAX_EXPANSION + 1; + +// Escape into a stack buffer and return the result as a string so the expectations stay readable. +std::string escape(const std::string &value) { + char buf[TEST_BUFFER_SIZE]; + return json_escape_into_buffer(buf, StringRef(value.c_str(), value.size())); +} + +} // namespace + +// Plain ASCII with no special characters is passed through unchanged. +TEST(CaptivePortalJsonEscape, PlainStringUnchanged) { + EXPECT_EQ(escape("MyNetwork"), "MyNetwork"); + EXPECT_EQ(escape(""), ""); +} + +// A double quote is escaped so it does not terminate the surrounding JSON string. +TEST(CaptivePortalJsonEscape, EscapesDoubleQuote) { + EXPECT_EQ(escape("a\"b"), "a\\\"b"); + // A double quote followed by other characters stays inside the JSON string. + EXPECT_EQ(escape("\">end"), "\\\">end"); +} + +// A backslash is doubled so it does not start an escape sequence in the output. +TEST(CaptivePortalJsonEscape, EscapesBackslash) { + EXPECT_EQ(escape("a\\b"), "a\\\\b"); + // A trailing backslash must not escape the closing quote of the JSON string. + EXPECT_EQ(escape("net\\"), "net\\\\"); +} + +// The control characters with short JSON forms use those forms. +TEST(CaptivePortalJsonEscape, EscapesShortFormControls) { + EXPECT_EQ(escape("\n"), "\\n"); + EXPECT_EQ(escape("\r"), "\\r"); + EXPECT_EQ(escape("\t"), "\\t"); + EXPECT_EQ(escape("\b"), "\\b"); + EXPECT_EQ(escape("\f"), "\\f"); +} + +// Other control characters (< 0x20) without a short form become \u00XX with lowercase hex. +TEST(CaptivePortalJsonEscape, EscapesOtherControlsAsUnicode) { + EXPECT_EQ(escape(std::string("\x00", 1)), "\\u0000"); + EXPECT_EQ(escape("\x01"), "\\u0001"); + EXPECT_EQ(escape("\x10"), "\\u0010"); + EXPECT_EQ(escape("\x1f"), "\\u001f"); + // 0x7f (DEL) is >= 0x20, so it is NOT escaped by this helper. + EXPECT_EQ(escape("\x7f"), "\x7f"); +} + +// Bytes >= 0x20, including multi-byte UTF-8 sequences, are passed through verbatim. +TEST(CaptivePortalJsonEscape, PassesThroughUtf8) { + // "café" in UTF-8 (é == 0xC3 0xA9). + EXPECT_EQ(escape("caf\xc3\xa9"), "caf\xc3\xa9"); + // Emoji (📶, 4-byte UTF-8) survives unchanged. + EXPECT_EQ(escape("\xf0\x9f\x93\xb6"), "\xf0\x9f\x93\xb6"); +} + +// A mix of special and normal characters is escaped in place without disturbing the rest. +TEST(CaptivePortalJsonEscape, MixedContent) { EXPECT_EQ(escape("a\"b\\c\nd"), "a\\\"b\\\\c\\nd"); } + +// A buffer sized at JSON_ESCAPE_MAX_EXPANSION bytes per input byte holds the worst case exactly. +TEST(CaptivePortalJsonEscape, WorstCaseInputFitsExactly) { + constexpr size_t input_len = 8; + char buf[input_len * JSON_ESCAPE_MAX_EXPANSION + 1]; + const std::string input(input_len, '\x01'); + std::string expected; + for (size_t i = 0; i < input_len; i++) + expected += "\\u0001"; + EXPECT_EQ(json_escape_into_buffer(buf, StringRef(input.c_str(), input.size())), expected); +} + +// An escape sequence that would not fit is dropped whole rather than written partially, and the result stays null +// terminated. +TEST(CaptivePortalJsonEscape, DropsEscapeThatWouldNotFit) { + // Room for one \u00XX sequence plus the null terminator, but two are requested. + char buf[JSON_ESCAPE_MAX_EXPANSION + 1]; + const std::string input(2, '\x01'); + const std::string result = json_escape_into_buffer(buf, StringRef(input.c_str(), input.size())); + EXPECT_EQ(result, "\\u0001"); + EXPECT_EQ(buf[JSON_ESCAPE_MAX_EXPANSION], '\0'); +} + +// Plain characters are truncated at the buffer size, leaving room for the null terminator. +TEST(CaptivePortalJsonEscape, TruncatesPlainInput) { + char buf[5]; + const std::string input(20, 'a'); + EXPECT_STREQ(json_escape_into_buffer(buf, StringRef(input.c_str(), input.size())), "aaaa"); +} + +// A zero length buffer cannot even hold a null terminator, so an empty string is returned instead of writing. +TEST(CaptivePortalJsonEscape, EmptyBufferIsSafe) { + const std::string input("test"); + EXPECT_STREQ(json_escape_into_buffer(std::span(), StringRef(input.c_str(), input.size())), ""); +} + +} // namespace esphome::captive_portal::testing From 489e3d17cadd6596d616249e50100ee0f9e7ae16 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:24:53 -0400 Subject: [PATCH 1118/1815] [ethernet] Set SPI CS hold time for ENC28J60 (#17885) --- esphome/components/ethernet/__init__.py | 73 +++++++++++-------- .../ethernet/ethernet_component_esp32.cpp | 4 +- 2 files changed, 45 insertions(+), 32 deletions(-) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 03fba7164d..2b1a256599 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -433,37 +433,48 @@ GENERIC_SCHEMA = cv.All( cv.only_on([Platform.ESP32]), ) -SPI_SCHEMA = cv.All( - BASE_SCHEMA.extend( - cv.Schema( - { - cv.Required(CONF_CLK_PIN): pins.internal_gpio_output_pin_number, - cv.Required(CONF_MISO_PIN): pins.internal_gpio_input_pin_number, - cv.Required(CONF_MOSI_PIN): pins.internal_gpio_output_pin_number, - cv.Required(CONF_CS_PIN): pins.internal_gpio_output_pin_number, - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_number, - cv.Optional(CONF_RESET_PIN): pins.internal_gpio_output_pin_number, - cv.SplitDefault(CONF_CLOCK_SPEED, esp32="26.67MHz"): cv.All( - cv.only_on_esp32, - cv.frequency, - cv.int_range(int(8e6), int(80e6)), - ), - cv.Optional(CONF_INTERFACE): cv.All( - cv.only_on_esp32, - cv.one_of(*SPI_INTERFACE_MAP.keys(), lower=True), - ), - # Set default value (SPI_ETHERNET_DEFAULT_POLLING_INTERVAL) at _validate() - cv.Optional(CONF_POLLING_INTERVAL): cv.All( - cv.only_on_esp32, - cv.positive_time_period_milliseconds, - cv.Range(min=TimePeriodMilliseconds(milliseconds=1)), - ), - } + +def _spi_schema(default_clock: str = "26.67MHz", max_clock: int = int(80e6)): + return cv.All( + BASE_SCHEMA.extend( + cv.Schema( + { + cv.Required(CONF_CLK_PIN): pins.internal_gpio_output_pin_number, + cv.Required(CONF_MISO_PIN): pins.internal_gpio_input_pin_number, + cv.Required(CONF_MOSI_PIN): pins.internal_gpio_output_pin_number, + cv.Required(CONF_CS_PIN): pins.internal_gpio_output_pin_number, + cv.Optional( + CONF_INTERRUPT_PIN + ): pins.internal_gpio_input_pin_number, + cv.Optional(CONF_RESET_PIN): pins.internal_gpio_output_pin_number, + cv.SplitDefault(CONF_CLOCK_SPEED, esp32=default_clock): cv.All( + cv.only_on_esp32, + cv.frequency, + cv.int_range(int(8e6), max_clock), + ), + cv.Optional(CONF_INTERFACE): cv.All( + cv.only_on_esp32, + cv.one_of(*SPI_INTERFACE_MAP.keys(), lower=True), + ), + # Set default value (SPI_ETHERNET_DEFAULT_POLLING_INTERVAL) at _validate() + cv.Optional(CONF_POLLING_INTERVAL): cv.All( + cv.only_on_esp32, + cv.positive_time_period_milliseconds, + cv.Range(min=TimePeriodMilliseconds(milliseconds=1)), + ), + } + ), ), - ), - cv.only_on([Platform.ESP32, Platform.RP2]), - _validate_spi_interface, -) + cv.only_on([Platform.ESP32, Platform.RP2]), + _validate_spi_interface, + ) + + +SPI_SCHEMA = _spi_schema() + +# The ENC28J60's SCK maximum is 20 MHz, so the shared 26.67 MHz default is out +# of spec for it and makes the driver's CS hold time helper compute no hold +SPI_SCHEMA_ENC28J60 = _spi_schema(default_clock="20MHz", max_clock=int(20e6)) CONFIG_SCHEMA = cv.All( cv.typed_schema( @@ -479,7 +490,7 @@ CONFIG_SCHEMA = cv.All( "W5500": SPI_SCHEMA, "OPENETH": cv.All(BASE_SCHEMA, cv.only_on([Platform.ESP32])), "DM9051": SPI_SCHEMA, - "ENC28J60": SPI_SCHEMA, + "ENC28J60": SPI_SCHEMA_ENC28J60, "W6100": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])), "W6300": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])), "LAN8670": RMII_SCHEMA, diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 5ad1e7d483..94f4c23479 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -232,8 +232,10 @@ void EthernetComponent::ethernet_lazy_init_() { dm9051_config.poll_period_ms = this->polling_interval_; #endif #elif defined(USE_ETHERNET_ENC28J60) + // ENC28J60 does not support poll_period_ms. CS must stay asserted for the chip's CS hold + // time (t10, 210 ns) after the last clock or MAC/MII register reads fail ("wrong chip ID") + enc28j60_config.spi_devcfg->cs_ena_posttrans = enc28j60_cal_spi_cs_hold_time((this->clock_speed_ + 999999) / 1000000); enc28j60_config.int_gpio_num = this->interrupt_pin_; - // ENC28J60 does not support poll_period_ms #endif phy_config.phy_addr = this->phy_addr_spi_; From 71349a6feb7200d03c89ff65b6eb516f73b39a6b Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:19:44 -0500 Subject: [PATCH 1119/1815] [light] Ensure binary light is off with brightness 0 (#17893) --- esphome/components/light/light_call.cpp | 12 + .../light/test_light_call_brightness.cpp | 120 ++++++++++ .../light_binary_effect_off_phase.yaml | 29 +++ ...binary_zero_brightness_is_recoverable.yaml | 21 ++ .../test_light_binary_effect_off_phase.py | 207 ++++++++++++++++++ 5 files changed, 389 insertions(+) create mode 100644 tests/components/light/test_light_call_brightness.cpp create mode 100644 tests/integration/fixtures/light_binary_effect_off_phase.yaml create mode 100644 tests/integration/fixtures/light_binary_zero_brightness_is_recoverable.yaml create mode 100644 tests/integration/test_light_binary_effect_off_phase.py diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 67fd175ce6..4251565e85 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -219,6 +219,18 @@ LightColorValues LightCall::validate_() { this->set_flag_(FLAG_HAS_STATE); } + // A light without brightness control has no way to represent "on but dark", so zero + // brightness -- how effects encode their dark phase -- means the light is off. Clear the + // brightness as well, so a zero can't linger in remote_values and leave the light stuck + // off: a later turn-on can't heal it, because the capability check below drops any + // brightness this mode doesn't support. explicit_turn_off_request was captured above, so + // a running effect is not stopped by this. + if (this->has_brightness() && this->brightness_ == 0.0f && !(color_mode & ColorCapability::BRIGHTNESS)) { + this->state_ = false; + this->set_flag_(FLAG_HAS_STATE); + this->clear_flag_(FLAG_HAS_BRIGHTNESS); + } + // Make sure a simple (no specific brightness) turn-on makes the light visible if (this->has_state() && this->state_ && (color_mode & ColorCapability::BRIGHTNESS) && !this->has_brightness() && this->parent_->remote_values.get_brightness() == 0.0f) { diff --git a/tests/components/light/test_light_call_brightness.cpp b/tests/components/light/test_light_call_brightness.cpp new file mode 100644 index 0000000000..3c5dffd2f1 --- /dev/null +++ b/tests/components/light/test_light_call_brightness.cpp @@ -0,0 +1,120 @@ +#include + +#include "esphome/components/light/light_call.h" +#include "esphome/components/light/light_output.h" +#include "esphome/components/light/light_state.h" + +namespace esphome::light::testing { + +namespace { + +// A light that only supports ON_OFF, like the `binary` platform and `status_led`. +class OnOffOutput : public LightOutput { + public: + LightTraits get_traits() override { + LightTraits traits; + traits.set_supported_color_modes({ColorMode::ON_OFF}); + return traits; + } + void write_state(LightState *state) override {} +}; + +// A dimmable light, like the `monochromatic` platform. +class BrightnessOutput : public LightOutput { + public: + LightTraits get_traits() override { + LightTraits traits; + traits.set_supported_color_modes({ColorMode::BRIGHTNESS}); + return traits; + } + void write_state(LightState *state) override {} +}; + +// validate_() is where zero brightness is resolved against the light's capabilities. +class TestableLightCall : public LightCall { + public: + using LightCall::LightCall; + using LightCall::validate_; +}; + +bool as_binary(const LightColorValues &values) { + bool binary; + values.as_binary(&binary); + return binary; +} + +} // namespace + +// An ON/OFF light has no "on but dark" state, so a zero brightness -- how effects encode +// their dark phase -- must turn the light off. Regression test for +// https://github.com/esphome/esphome/issues/17873. +TEST(LightCallOnOff, ZeroBrightnessTurnsOutputOff) { + OnOffOutput output; + LightState state(&output); + TestableLightCall call(&state); + + call.set_state(true).set_brightness(0.0f); + auto values = call.validate_(); + + EXPECT_FALSE(as_binary(values)); +} + +// The zero must not be stored, or no later turn-on could clear it: the capability check in +// validate_() drops any brightness an ON/OFF light doesn't support, so a stored zero would +// leave the light permanently off. +TEST(LightCallOnOff, ZeroBrightnessIsNotStored) { + OnOffOutput output; + LightState state(&output); + + TestableLightCall dark_call(&state); + dark_call.set_state(true).set_brightness(0.0f); + state.remote_values = dark_call.validate_(); + + EXPECT_FLOAT_EQ(state.remote_values.get_brightness(), 1.0f); + + // A plain turn-on afterwards must switch the light back on. + TestableLightCall on_call(&state); + on_call.set_state(true); + auto values = on_call.validate_(); + + EXPECT_TRUE(as_binary(values)); +} + +// A plain turn-on with no brightness must still light up. +TEST(LightCallOnOff, PlainTurnOnIsVisible) { + OnOffOutput output; + LightState state(&output); + TestableLightCall call(&state); + + call.set_state(true); + auto values = call.validate_(); + + EXPECT_TRUE(as_binary(values)); +} + +TEST(LightCallOnOff, TurnOffTurnsOutputOff) { + OnOffOutput output; + LightState state(&output); + TestableLightCall call(&state); + + call.set_state(false); + auto values = call.validate_(); + + EXPECT_FALSE(as_binary(values)); +} + +// A dimmable light can represent "on but dark", so zero brightness must be kept as-is and +// must not be rewritten into a turn-off. +TEST(LightCallBrightness, ZeroBrightnessStaysOnButDark) { + BrightnessOutput output; + LightState state(&output); + TestableLightCall call(&state); + + call.set_state(true).set_brightness(0.0f); + auto values = call.validate_(); + + EXPECT_TRUE(values.is_on()); + EXPECT_FLOAT_EQ(values.get_brightness(), 0.0f); +} + +} // namespace esphome::light::testing diff --git a/tests/integration/fixtures/light_binary_effect_off_phase.yaml b/tests/integration/fixtures/light_binary_effect_off_phase.yaml new file mode 100644 index 0000000000..c5c74001c3 --- /dev/null +++ b/tests/integration/fixtures/light_binary_effect_off_phase.yaml @@ -0,0 +1,29 @@ +esphome: + name: light-binary-effect-off +host: +api: # Port will be automatically injected +logger: + level: DEBUG + +output: + - platform: template + id: binary_output + type: binary + write_action: + - logger.log: + format: "BINARY_OUTPUT:%s" + args: [YESNO(state)] + +light: + - platform: binary + name: "Test Binary Light" + id: test_binary_light + output: binary_output + effects: + - strobe: + name: "Fast Strobe" + colors: + - state: true + duration: 50ms + - state: false + duration: 50ms diff --git a/tests/integration/fixtures/light_binary_zero_brightness_is_recoverable.yaml b/tests/integration/fixtures/light_binary_zero_brightness_is_recoverable.yaml new file mode 100644 index 0000000000..1db8b44fd1 --- /dev/null +++ b/tests/integration/fixtures/light_binary_zero_brightness_is_recoverable.yaml @@ -0,0 +1,21 @@ +esphome: + name: light-binary-zero-bright +host: +api: # Port will be automatically injected +logger: + level: DEBUG + +output: + - platform: template + id: binary_output + type: binary + write_action: + - logger.log: + format: "BINARY_OUTPUT:%s" + args: [YESNO(state)] + +light: + - platform: binary + name: "Test Binary Light" + id: test_binary_light + output: binary_output diff --git a/tests/integration/test_light_binary_effect_off_phase.py b/tests/integration/test_light_binary_effect_off_phase.py new file mode 100644 index 0000000000..571fca19f6 --- /dev/null +++ b/tests/integration/test_light_binary_effect_off_phase.py @@ -0,0 +1,207 @@ +"""Integration test verifying the off phase of an effect reaches an ON/OFF-only light. + +Regression test for https://github.com/esphome/esphome/issues/17873. A strobe effect +encodes its dark phase as `brightness = 0` while keeping `state = true`, so that the +effect keeps running instead of being stopped by an explicit turn-off. On a dimmable +light that works, because the output is driven by `state * brightness`. On a binary +light the dark phase used to be dropped, so the output stayed on forever. + +Effect ticks are published with `publish: false` (so Home Assistant isn't spammed with +every frame), so the effect's actual output can't be observed via API state broadcasts. +Instead, this test reads the output component's log lines, which are written on every +update regardless of the publish flag. + +The output log line is emitted strictly after the API state response: `perform()` +publishes inline, but the write is deferred to the next `LightState::loop()` iteration +and then has to cross the subprocess stdout pipe. So a future is armed *before* each +command and awaited afterwards, rather than reading the last observed value. +""" + +from __future__ import annotations + +import asyncio +import re +from typing import Any + +from aioesphomeapi import EntityState, LightState +import pytest + +from .state_utils import InitialStateHelper +from .types import APIClientConnectedFactory, RunCompiledFunction + +OUTPUT_PATTERN = re.compile(r"BINARY_OUTPUT:(YES|NO)") + + +@pytest.mark.asyncio +async def test_light_binary_effect_off_phase( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A strobe effect must drive a binary light's output both on and off.""" + loop = asyncio.get_running_loop() + observed: list[bool] = [] + pending: list[asyncio.Future[bool]] = [] + + def on_log_line(line: str) -> None: + if match := OUTPUT_PATTERN.search(line): + value = match.group(1) == "YES" + observed.append(value) + while pending: + future = pending.pop(0) + if not future.done(): + future.set_result(value) + break + + def arm_output() -> asyncio.Future[bool]: + """Arm a future for the next output write, before sending the command.""" + future: asyncio.Future[bool] = loop.create_future() + pending.append(future) + return future + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + light = next(e for e in entities if e.object_id == "test_binary_light") + + state_futures: dict[int, asyncio.Future[LightState]] = {} + + def on_state(state: EntityState) -> None: + if isinstance(state, LightState) and state.key in state_futures: + future = state_futures[state.key] + if not future.done(): + future.set_result(state) + + # ESPHome sends the current state of every entity right after connecting; drain + # that initial burst so it can't be mistaken for the response to a command below. + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + async def send_and_wait(timeout: float = 5.0, **kwargs: Any) -> LightState: + """Send a light command and wait for the matching state response.""" + state_futures[light.key] = loop.create_future() + client.light_command(key=light.key, **kwargs) + return await asyncio.wait_for(state_futures[light.key], timeout=timeout) + + # A plain turn-on must drive the output on -- brightness defaults to 100% and + # must not be mistaken for a dark phase. + output = arm_output() + state = await send_and_wait(state=True) + assert state.state is True + assert await asyncio.wait_for(output, timeout=5.0) is True, ( + "Plain turn-on did not switch the output on" + ) + + # Run the strobe effect; both phases must reach the output. + observed.clear() + state = await send_and_wait(effect="Fast Strobe") + assert state.effect == "Fast Strobe" + # Let several effect cycles run (each phase is 50ms in the fixture). + await asyncio.sleep(1.0) + + assert True in observed, ( + f"Strobe effect never switched the output on -- got {observed}" + ) + assert False in observed, ( + f"Strobe effect never switched the output off; its dark phase was lost -- " + f"got {observed}" + ) + + # Stopping the effect must leave the light usable. + state = await send_and_wait(effect="None") + assert state.effect == "None" + output = arm_output() + state = await send_and_wait(state=True) + assert state.state is True + assert await asyncio.wait_for(output, timeout=5.0) is True, ( + "Light stayed off after the effect stopped" + ) + + # An explicit turn-off still switches the output off. + output = arm_output() + state = await send_and_wait(state=False) + assert state.state is False + assert await asyncio.wait_for(output, timeout=5.0) is False, ( + "Turn-off did not switch the output off" + ) + + +@pytest.mark.asyncio +async def test_light_binary_zero_brightness_is_recoverable( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Zero brightness on an ON/OFF light must not leave it permanently stuck off. + + An ON/OFF light has no brightness capability, so `turn_on` with 0% brightness has + no representable "on but dark" state. It must switch the output off and report the + light as off, and a later plain turn-on must bring it back. + """ + loop = asyncio.get_running_loop() + pending: list[asyncio.Future[bool]] = [] + + def on_log_line(line: str) -> None: + if match := OUTPUT_PATTERN.search(line): + value = match.group(1) == "YES" + while pending: + future = pending.pop(0) + if not future.done(): + future.set_result(value) + break + + def arm_output() -> asyncio.Future[bool]: + future: asyncio.Future[bool] = loop.create_future() + pending.append(future) + return future + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + light = next(e for e in entities if e.object_id == "test_binary_light") + + state_futures: dict[int, asyncio.Future[LightState]] = {} + + def on_state(state: EntityState) -> None: + if isinstance(state, LightState) and state.key in state_futures: + future = state_futures[state.key] + if not future.done(): + future.set_result(state) + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + async def send_and_wait(timeout: float = 5.0, **kwargs: Any) -> LightState: + state_futures[light.key] = loop.create_future() + client.light_command(key=light.key, **kwargs) + return await asyncio.wait_for(state_futures[light.key], timeout=timeout) + + output = arm_output() + state = await send_and_wait(state=True) + assert state.state is True + assert await asyncio.wait_for(output, timeout=5.0) is True + + # Turning on at 0% brightness has no representable "on but dark" state here, + # so the light must switch off and report itself as off. + output = arm_output() + state = await send_and_wait(state=True, brightness=0.0) + assert await asyncio.wait_for(output, timeout=5.0) is False, ( + "Zero brightness did not switch the output off" + ) + assert state.state is False, ( + "Light reported itself as on while its output was off" + ) + + # A plain turn-on must recover -- the stored zero brightness must not persist. + output = arm_output() + state = await send_and_wait(state=True) + assert state.state is True + assert await asyncio.wait_for(output, timeout=5.0) is True, ( + "Light was left permanently off by a zero-brightness turn-on" + ) From 56512abb7ed62734f3a7299c6acdc45b3f5a2fde Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:28:23 +1200 Subject: [PATCH 1120/1815] Update webserver local assets to 20260728-053845 (#17899) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- .../components/captive_portal/captive_index.h | 273 +- .../components/web_server/server_index_v2.h | 2600 +++--- .../components/web_server/server_index_v3.h | 8096 +++++++++-------- 3 files changed, 5487 insertions(+), 5482 deletions(-) diff --git a/esphome/components/captive_portal/captive_index.h b/esphome/components/captive_portal/captive_index.h index a81edc1900..a25ac8d010 100644 --- a/esphome/components/captive_portal/captive_index.h +++ b/esphome/components/captive_portal/captive_index.h @@ -7,145 +7,146 @@ namespace esphome::captive_portal { #ifdef USE_CAPTIVE_PORTAL_GZIP constexpr uint8_t INDEX_GZ[] PROGMEM = { - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x95, 0x16, 0x6b, 0x8f, 0xdb, 0x36, 0xf2, 0x7b, 0x7e, - 0x05, 0x8f, 0x49, 0xbb, 0x52, 0xb3, 0x7a, 0x7a, 0xed, 0x6c, 0x24, 0x51, 0x45, 0x9a, 0xbb, 0xa2, 0x05, 0x9a, 0x36, - 0xc0, 0x6e, 0x73, 0x1f, 0x82, 0x00, 0x4b, 0x53, 0x23, 0x8b, 0x31, 0x45, 0xea, 0x48, 0xca, 0x8f, 0x18, 0xbe, 0xdf, - 0x7e, 0xa0, 0x24, 0x7b, 0x9d, 0x45, 0x73, 0xb8, 0xb3, 0x60, 0x61, 0x38, 0xef, 0x19, 0xcd, 0x83, 0xc5, 0xdf, 0x2a, - 0xc5, 0xec, 0xbe, 0x03, 0xd4, 0xd8, 0x56, 0x94, 0x85, 0x7b, 0x23, 0x41, 0xe5, 0x8a, 0x80, 0x2c, 0x8b, 0x06, 0x68, - 0x55, 0x16, 0x2d, 0x58, 0x8a, 0x58, 0x43, 0xb5, 0x01, 0x4b, 0xfe, 0xbc, 0xff, 0x39, 0xb8, 0x2d, 0x0b, 0xc1, 0xe5, - 0x1a, 0x69, 0x10, 0x84, 0x33, 0x25, 0x51, 0xa3, 0xa1, 0x26, 0x15, 0xb5, 0x34, 0xe3, 0x2d, 0x5d, 0xc1, 0x24, 0x22, - 0x69, 0x0b, 0x64, 0xc3, 0x61, 0xdb, 0x29, 0x6d, 0x11, 0x53, 0xd2, 0x82, 0xb4, 0x04, 0x6f, 0x79, 0x65, 0x1b, 0x52, - 0xc1, 0x86, 0x33, 0x08, 0x86, 0xc3, 0x35, 0x97, 0xdc, 0x72, 0x2a, 0x02, 0xc3, 0xa8, 0x00, 0x92, 0x5c, 0xf7, 0x06, - 0xf4, 0x70, 0xa0, 0x4b, 0x01, 0x44, 0x2a, 0x5c, 0x16, 0x86, 0x69, 0xde, 0x59, 0xe4, 0x5c, 0x25, 0xad, 0xaa, 0x7a, - 0x01, 0x65, 0x14, 0x51, 0x63, 0xc0, 0x9a, 0x88, 0xcb, 0x0a, 0x76, 0xe1, 0x32, 0x66, 0x2c, 0x86, 0xdb, 0xdb, 0xf0, - 0xb3, 0x79, 0x56, 0x29, 0xd6, 0xb7, 0x20, 0x6d, 0x28, 0x14, 0xa3, 0x96, 0x2b, 0x19, 0x1a, 0xa0, 0x9a, 0x35, 0x84, - 0x10, 0xfc, 0xa3, 0xa1, 0x1b, 0xc0, 0xdf, 0x7f, 0xef, 0x9d, 0x99, 0x56, 0x60, 0xff, 0x21, 0xc0, 0x81, 0xe6, 0xa7, - 0xfd, 0x3d, 0x5d, 0xfd, 0x4e, 0x5b, 0xf0, 0x30, 0x35, 0xbc, 0x02, 0xec, 0x7f, 0x8c, 0x3f, 0x85, 0xc6, 0xee, 0x05, - 0x84, 0x15, 0x37, 0x9d, 0xa0, 0x7b, 0x82, 0x97, 0x42, 0xb1, 0x35, 0xf6, 0xf3, 0xba, 0x97, 0xcc, 0x29, 0x47, 0xc6, - 0x03, 0xff, 0x20, 0xc0, 0x22, 0x4b, 0xde, 0x51, 0xdb, 0x84, 0x2d, 0xdd, 0x79, 0x23, 0xc0, 0xa5, 0x97, 0xfe, 0xe0, - 0xc1, 0xcb, 0x24, 0x8e, 0xfd, 0xeb, 0xe1, 0x15, 0xfb, 0x51, 0x12, 0xc7, 0xb9, 0x06, 0xdb, 0x6b, 0x89, 0xa8, 0xf7, - 0x50, 0x74, 0xd4, 0x36, 0xa8, 0x22, 0xf8, 0x5d, 0x92, 0xa2, 0xe4, 0x75, 0x98, 0xce, 0x7f, 0x0b, 0x5f, 0xa1, 0x9b, - 0x30, 0x9d, 0xb3, 0x57, 0xc1, 0x1c, 0x25, 0x37, 0xc1, 0x1c, 0xa5, 0x69, 0x38, 0x47, 0xf1, 0x17, 0x8c, 0x6a, 0x2e, - 0x04, 0xc1, 0x52, 0x49, 0xc0, 0xc8, 0x58, 0xad, 0xd6, 0x40, 0x30, 0xeb, 0xb5, 0x06, 0x69, 0xdf, 0x2a, 0xa1, 0x34, - 0x8e, 0xca, 0x67, 0xff, 0x97, 0x42, 0xab, 0xa9, 0x34, 0xb5, 0xd2, 0x2d, 0xc1, 0x43, 0xf6, 0xbd, 0x17, 0x07, 0x7b, - 0x44, 0xee, 0xe5, 0x5f, 0x10, 0x03, 0xa5, 0xf9, 0x8a, 0x4b, 0x82, 0x9d, 0xc6, 0x5b, 0x1c, 0x95, 0x0f, 0xfe, 0xf1, - 0x1c, 0x3d, 0x75, 0xd1, 0x4f, 0xf1, 0x28, 0xef, 0xe3, 0x43, 0x61, 0x36, 0x2b, 0xb4, 0x6b, 0x85, 0x34, 0x04, 0x37, - 0xd6, 0x76, 0x59, 0x14, 0x6d, 0xb7, 0xdb, 0x70, 0x3b, 0x0b, 0x95, 0x5e, 0x45, 0x69, 0x1c, 0xc7, 0x91, 0xd9, 0xac, - 0x30, 0x1a, 0x0b, 0x01, 0xa7, 0x37, 0x18, 0x35, 0xc0, 0x57, 0x8d, 0x1d, 0xe0, 0xf2, 0xc5, 0x01, 0x8e, 0x85, 0xe3, - 0x28, 0x1f, 0x3e, 0x5d, 0x58, 0xe1, 0x17, 0x56, 0xe0, 0x47, 0xea, 0xe1, 0x53, 0x98, 0x57, 0x43, 0x98, 0xaf, 0x68, - 0x8a, 0x52, 0x14, 0x0f, 0x4f, 0x1a, 0x38, 0x78, 0x3a, 0x05, 0x4f, 0x4e, 0xe8, 0xe2, 0xe4, 0xa0, 0x76, 0x11, 0xbc, - 0x3e, 0xcb, 0x26, 0x0e, 0xb3, 0x49, 0xe2, 0x47, 0x84, 0x13, 0xf8, 0x65, 0x71, 0x79, 0x0e, 0xd2, 0x0f, 0x97, 0x0c, - 0xce, 0x5a, 0x93, 0x7c, 0x58, 0xd0, 0x39, 0x9a, 0x4f, 0x98, 0x79, 0xe0, 0xe0, 0xf3, 0x09, 0xcd, 0x37, 0x69, 0x93, - 0xb4, 0xc1, 0x22, 0x98, 0xd3, 0x19, 0x9a, 0x4d, 0x8e, 0xcc, 0xd0, 0x6c, 0x93, 0x36, 0x8b, 0x0f, 0x8b, 0x4b, 0x5c, - 0x30, 0xfb, 0x72, 0x15, 0x95, 0xd8, 0xcf, 0x30, 0x7e, 0x8c, 0x5c, 0x5d, 0x46, 0x1e, 0x7e, 0x56, 0x5c, 0x7a, 0x18, - 0xfb, 0xc7, 0x1a, 0x2c, 0x6b, 0x3c, 0x1c, 0x31, 0x25, 0x6b, 0xbe, 0x0a, 0x3f, 0x1b, 0x25, 0xb1, 0x1f, 0xda, 0x06, - 0xa4, 0x77, 0x12, 0x75, 0x82, 0x30, 0x50, 0xbc, 0xa7, 0x14, 0xeb, 0x1f, 0xce, 0xf5, 0x6f, 0xb9, 0x15, 0x40, 0x6c, - 0xe8, 0x1a, 0xf6, 0xfa, 0x8c, 0x5d, 0xaa, 0x6a, 0xff, 0x8d, 0xd6, 0x68, 0x92, 0xb1, 0x2f, 0xb8, 0x94, 0xa0, 0xef, - 0x61, 0x67, 0x09, 0x7e, 0xf7, 0xe6, 0x2d, 0x7a, 0x53, 0x55, 0x1a, 0x8c, 0xc9, 0x10, 0x7e, 0x69, 0xc3, 0x96, 0xb2, - 0xff, 0x5d, 0x57, 0xf2, 0x95, 0xae, 0x7f, 0xf2, 0x9f, 0x39, 0xfa, 0x1d, 0xec, 0x56, 0xe9, 0xf5, 0xa4, 0xcd, 0xb9, - 0x96, 0xbb, 0x0e, 0xd3, 0xc4, 0x86, 0xb4, 0x33, 0xa1, 0x11, 0x9c, 0x81, 0x97, 0xf8, 0x61, 0x4b, 0xbb, 0xc7, 0xa8, - 0xe4, 0x29, 0x51, 0x0f, 0x45, 0xc5, 0x37, 0x88, 0x09, 0x6a, 0x0c, 0xc1, 0x72, 0x54, 0x85, 0xd1, 0x33, 0x34, 0xfc, - 0x94, 0x64, 0x82, 0xb3, 0x35, 0xc1, 0x7f, 0x31, 0x01, 0x7e, 0xda, 0xff, 0x5a, 0x79, 0x57, 0xc6, 0xf0, 0xea, 0xca, - 0x0f, 0x37, 0x54, 0xf4, 0x80, 0x08, 0xb2, 0x0d, 0x37, 0x8f, 0x0e, 0xe6, 0xdf, 0x14, 0xeb, 0xcc, 0xfa, 0xca, 0x0f, - 0x6b, 0xc5, 0x7a, 0xe3, 0xf9, 0xb8, 0x9c, 0xcc, 0x15, 0x74, 0x1c, 0x90, 0xf8, 0x39, 0x7e, 0xe2, 0x51, 0x20, 0xa0, - 0xb6, 0x67, 0x3e, 0x84, 0x5e, 0x1c, 0x8c, 0x27, 0x43, 0x6d, 0x0c, 0xf7, 0x8f, 0x67, 0x64, 0x61, 0x3a, 0x2a, 0x9f, - 0x0a, 0x3a, 0x07, 0x5d, 0xab, 0xc8, 0xd0, 0x41, 0xae, 0x5f, 0x3a, 0x2a, 0xcf, 0x06, 0x23, 0x7a, 0x02, 0x5f, 0x1c, - 0xb8, 0x27, 0xdd, 0x14, 0x5c, 0x9f, 0x35, 0x16, 0x51, 0xc5, 0x37, 0xe5, 0xc3, 0xd1, 0x7f, 0x8c, 0xe3, 0x5f, 0x3d, - 0xe8, 0xfd, 0x1d, 0x08, 0x60, 0x56, 0x69, 0x0f, 0x3f, 0x97, 0x60, 0xb1, 0x3f, 0x06, 0xfc, 0xcb, 0xfd, 0xbb, 0xdf, - 0x88, 0xf2, 0xb4, 0x7f, 0xfd, 0x2d, 0x6e, 0xb7, 0x0a, 0x3e, 0x6a, 0x10, 0xff, 0x26, 0x57, 0x6e, 0x19, 0x5c, 0x7d, - 0xc2, 0x7e, 0x38, 0xc4, 0xfb, 0xf0, 0xb8, 0x11, 0x5c, 0x3b, 0xbf, 0xdc, 0xb5, 0xe2, 0xda, 0x45, 0x18, 0x2c, 0xe6, - 0xfe, 0xf1, 0xe1, 0xe8, 0x1f, 0xfd, 0xbc, 0x88, 0xc6, 0xb9, 0x5e, 0x16, 0xc3, 0x88, 0x2d, 0x7f, 0x38, 0x2c, 0xd5, - 0x2e, 0x30, 0xfc, 0x0b, 0x97, 0xab, 0x8c, 0xcb, 0x06, 0x34, 0xb7, 0xc7, 0x8a, 0x6f, 0xae, 0xb9, 0xec, 0x7a, 0x7b, - 0xe8, 0x68, 0x55, 0x39, 0xca, 0xbc, 0xdb, 0xe5, 0xb5, 0x92, 0xd6, 0x71, 0x42, 0x96, 0x40, 0x7b, 0x1c, 0xe9, 0xc3, - 0x44, 0xc9, 0x5e, 0xcf, 0xbf, 0x3b, 0xba, 0x82, 0x3b, 0x58, 0xd8, 0xd9, 0x80, 0x0a, 0xbe, 0x92, 0x19, 0x03, 0x69, - 0x41, 0x8f, 0x42, 0x35, 0x6d, 0xb9, 0xd8, 0x67, 0x86, 0x4a, 0x13, 0x18, 0xd0, 0xbc, 0x3e, 0x2e, 0x7b, 0x6b, 0x95, - 0x3c, 0x2c, 0x95, 0xae, 0x40, 0x67, 0x71, 0x3e, 0x02, 0x81, 0xa6, 0x15, 0xef, 0x4d, 0x16, 0xce, 0x34, 0xb4, 0xf9, - 0x92, 0xb2, 0xf5, 0x4a, 0xab, 0x5e, 0x56, 0x01, 0x73, 0x93, 0x36, 0x7b, 0x9e, 0xd4, 0x74, 0x06, 0x2c, 0x9f, 0x4e, - 0x75, 0x5d, 0xe7, 0x82, 0x4b, 0x08, 0xc6, 0x59, 0x96, 0xa5, 0xe1, 0x8d, 0x13, 0xbb, 0x70, 0x33, 0x4c, 0x1d, 0x62, - 0xf4, 0x31, 0x89, 0xe3, 0xef, 0xf2, 0x53, 0x38, 0x71, 0xce, 0x7a, 0x6d, 0x94, 0xce, 0x3a, 0xc5, 0x9d, 0x9b, 0xc7, - 0x96, 0x72, 0x79, 0xe9, 0xbd, 0x2b, 0x93, 0x7c, 0x5a, 0x3f, 0x19, 0x97, 0x83, 0x99, 0x61, 0x09, 0xe5, 0x2d, 0x97, - 0xe3, 0x0e, 0xcd, 0xd2, 0x45, 0xdc, 0xed, 0x8e, 0xe1, 0x54, 0x20, 0x87, 0x13, 0x77, 0x2d, 0x60, 0x97, 0x7f, 0xee, - 0x8d, 0xe5, 0xf5, 0x3e, 0x98, 0x76, 0x70, 0x66, 0x3a, 0xca, 0x20, 0x58, 0x82, 0xdd, 0x02, 0xc8, 0x7c, 0xb0, 0x11, - 0x70, 0x0b, 0xad, 0x99, 0xf2, 0x74, 0x56, 0x33, 0x14, 0xe8, 0xd7, 0xba, 0xfe, 0x1b, 0xb7, 0xab, 0xc5, 0x43, 0x4b, - 0xf5, 0x8a, 0xcb, 0x60, 0xa9, 0xac, 0x55, 0x6d, 0x16, 0xbc, 0xea, 0x76, 0xf9, 0x84, 0x72, 0xca, 0xb2, 0xc4, 0xb9, - 0x39, 0xec, 0xd6, 0x53, 0xbe, 0x93, 0x6e, 0x87, 0x8c, 0x12, 0xbc, 0x9a, 0xf8, 0x06, 0x16, 0x14, 0x9f, 0xd3, 0x93, - 0xcc, 0xbb, 0x1d, 0x72, 0xb8, 0x53, 0xaa, 0x6f, 0xea, 0x5b, 0x9a, 0xc4, 0x7f, 0xf1, 0x45, 0xaa, 0xba, 0x4e, 0x97, - 0xf5, 0x39, 0x53, 0x6e, 0x4d, 0xba, 0xd6, 0x18, 0x4a, 0xab, 0x88, 0xc6, 0xdb, 0x8c, 0xab, 0x8c, 0xb2, 0x70, 0x19, - 0x2e, 0x8b, 0x26, 0x41, 0xbc, 0x22, 0x2d, 0x65, 0xe5, 0xc5, 0xf8, 0x2a, 0xa2, 0x26, 0x39, 0x91, 0x9a, 0xa4, 0xfc, - 0x6a, 0x18, 0x8d, 0xb4, 0xc1, 0xfb, 0xf2, 0xad, 0x92, 0x12, 0x98, 0xe5, 0x72, 0x85, 0xac, 0x42, 0x53, 0x0a, 0xc2, - 0x30, 0x2c, 0x96, 0xba, 0x7c, 0x2f, 0x80, 0x1a, 0x40, 0x5b, 0xca, 0x6d, 0x58, 0x44, 0x23, 0xff, 0xd8, 0xc7, 0xbc, - 0x22, 0x12, 0x6c, 0x39, 0x35, 0x6c, 0xd1, 0xcc, 0x46, 0x03, 0x77, 0x60, 0x9d, 0x26, 0x67, 0x60, 0x56, 0x16, 0x6e, - 0xe5, 0x22, 0x3a, 0x8c, 0x34, 0x12, 0x6d, 0x79, 0xcd, 0xdd, 0x95, 0xa5, 0x2c, 0x86, 0x22, 0x77, 0x1a, 0x5c, 0x9e, - 0xc7, 0xeb, 0xd5, 0x00, 0x09, 0x90, 0x2b, 0xdb, 0x90, 0x59, 0x8a, 0x3a, 0x41, 0x19, 0x34, 0x4a, 0x54, 0xa0, 0xc9, - 0xdd, 0xdd, 0xaf, 0x7f, 0x2f, 0x9d, 0x33, 0x8f, 0x72, 0x9d, 0x59, 0x8f, 0x62, 0x0e, 0x98, 0xa4, 0x16, 0x37, 0xe3, - 0xa5, 0xaa, 0xa3, 0xc6, 0x6c, 0x95, 0xae, 0xbe, 0xd2, 0xf1, 0x7e, 0x42, 0x8e, 0x7a, 0x86, 0xff, 0xd0, 0x2a, 0xe5, - 0x1d, 0xdd, 0x40, 0x11, 0x4d, 0x87, 0x22, 0x72, 0x0e, 0x8f, 0xf4, 0x66, 0xe2, 0x6b, 0x92, 0xf2, 0x8f, 0xfb, 0x37, - 0xe8, 0xcf, 0xae, 0xa2, 0x16, 0xc6, 0xb4, 0x0d, 0x51, 0xb5, 0x60, 0x1b, 0x55, 0x91, 0xf7, 0x7f, 0xdc, 0xdd, 0x9f, - 0x23, 0xec, 0x07, 0x26, 0x04, 0x92, 0x8d, 0xd7, 0xbb, 0x5e, 0x58, 0xde, 0x51, 0x6d, 0x07, 0xb5, 0x81, 0x9b, 0x22, - 0xa7, 0x18, 0x06, 0x7a, 0xcd, 0x05, 0x8c, 0x61, 0x8c, 0x82, 0x25, 0x3a, 0x79, 0x75, 0xb2, 0xf6, 0xc4, 0xaf, 0x68, - 0xfc, 0xda, 0xd1, 0xf8, 0xe9, 0xa3, 0xe1, 0xa6, 0xfb, 0x1f, 0x53, 0x58, 0x46, 0xb2, 0xf9, 0x0a, 0x00, 0x00}; + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x95, 0x16, 0x6b, 0x8f, 0xdb, 0x36, 0xf2, 0x7b, 0x7f, + 0x05, 0x8f, 0x4d, 0x1b, 0xa9, 0xb1, 0xa8, 0x87, 0xd7, 0xde, 0x44, 0x96, 0x54, 0xa4, 0x7b, 0x2d, 0x5a, 0xa0, 0x69, + 0x03, 0xec, 0x36, 0xf7, 0x21, 0x08, 0xb0, 0x34, 0x39, 0xb2, 0x98, 0xa5, 0x48, 0x1d, 0x49, 0xbf, 0x62, 0xf8, 0x7e, + 0xfb, 0x81, 0x92, 0xec, 0xf5, 0x2e, 0x9a, 0x03, 0x0e, 0x86, 0x85, 0x19, 0xce, 0x7b, 0x38, 0x0f, 0x16, 0xff, 0xe0, + 0x9a, 0xb9, 0x7d, 0x07, 0xa8, 0x71, 0xad, 0xac, 0x0a, 0xff, 0x45, 0x92, 0xaa, 0x55, 0x09, 0xaa, 0x2a, 0x1a, 0xa0, + 0xbc, 0x2a, 0x5a, 0x70, 0x14, 0xb1, 0x86, 0x1a, 0x0b, 0xae, 0xfc, 0xeb, 0xee, 0x97, 0xe8, 0x75, 0x55, 0x48, 0xa1, + 0x1e, 0x90, 0x01, 0x59, 0x0a, 0xa6, 0x15, 0x6a, 0x0c, 0xd4, 0x25, 0xa7, 0x8e, 0xe6, 0xa2, 0xa5, 0x2b, 0x18, 0x45, + 0x14, 0x6d, 0xa1, 0xdc, 0x08, 0xd8, 0x76, 0xda, 0x38, 0xc4, 0xb4, 0x72, 0xa0, 0x5c, 0x89, 0xb7, 0x82, 0xbb, 0xa6, + 0xe4, 0xb0, 0x11, 0x0c, 0xa2, 0x1e, 0x99, 0x08, 0x25, 0x9c, 0xa0, 0x32, 0xb2, 0x8c, 0x4a, 0x28, 0xd3, 0xc9, 0xda, + 0x82, 0xe9, 0x11, 0xba, 0x94, 0x50, 0x2a, 0x8d, 0xab, 0xc2, 0x32, 0x23, 0x3a, 0x87, 0xbc, 0xab, 0x65, 0xab, 0xf9, + 0x5a, 0x42, 0x15, 0xc7, 0xd4, 0x5a, 0x70, 0x36, 0x16, 0x8a, 0xc3, 0x8e, 0xd0, 0x6b, 0xb8, 0xa6, 0x2c, 0x4d, 0xc8, + 0x67, 0xfb, 0x0d, 0xd7, 0x6c, 0xdd, 0x82, 0x72, 0x44, 0x6a, 0x46, 0x9d, 0xd0, 0x8a, 0x58, 0xa0, 0x86, 0x35, 0x65, + 0x59, 0xe2, 0x1f, 0x2d, 0xdd, 0x00, 0xfe, 0xfe, 0xfb, 0xe0, 0xcc, 0xb4, 0x02, 0xf7, 0xb3, 0x04, 0x0f, 0xda, 0x9f, + 0xf6, 0x77, 0x74, 0xf5, 0x07, 0x6d, 0x21, 0xc0, 0xd4, 0x0a, 0x0e, 0x38, 0xfc, 0x98, 0x7c, 0x22, 0xd6, 0xed, 0x25, + 0x10, 0x2e, 0x6c, 0x27, 0xe9, 0xbe, 0xc4, 0x4b, 0xa9, 0xd9, 0x03, 0x0e, 0x17, 0xf5, 0x5a, 0x31, 0xaf, 0x1c, 0xe9, + 0x00, 0xc2, 0x83, 0x04, 0x87, 0x5c, 0xf9, 0x8e, 0xba, 0x86, 0xb4, 0x74, 0x17, 0x0c, 0x80, 0x50, 0x41, 0xf6, 0x43, + 0x00, 0xaf, 0xd2, 0x24, 0x09, 0x27, 0xfd, 0x27, 0x09, 0xe3, 0x34, 0x49, 0x16, 0x06, 0xdc, 0xda, 0x28, 0x44, 0x83, + 0xfb, 0xa2, 0xa3, 0xae, 0x41, 0xbc, 0xc4, 0xef, 0xd2, 0x0c, 0xa5, 0x6f, 0x48, 0x36, 0xfb, 0x9d, 0x5c, 0xa3, 0x2b, + 0x92, 0xcd, 0xd8, 0x75, 0x34, 0x43, 0xe9, 0x55, 0x34, 0x43, 0x59, 0x46, 0x66, 0x28, 0xf9, 0x82, 0x51, 0x2d, 0xa4, + 0x2c, 0xb1, 0xd2, 0x0a, 0x30, 0xb2, 0xce, 0xe8, 0x07, 0x28, 0x31, 0x5b, 0x1b, 0x03, 0xca, 0xdd, 0x68, 0xa9, 0x0d, + 0x8e, 0xab, 0x6f, 0xfe, 0x2f, 0x85, 0xce, 0x50, 0x65, 0x6b, 0x6d, 0xda, 0x12, 0xf7, 0xd9, 0x0f, 0x5e, 0x1c, 0xdc, + 0x11, 0xf9, 0x4f, 0x78, 0x41, 0x8c, 0xb4, 0x11, 0x2b, 0xa1, 0x4a, 0xec, 0x35, 0xbe, 0xc6, 0x71, 0x75, 0x1f, 0x1e, + 0xcf, 0xd1, 0x53, 0x1f, 0xfd, 0x18, 0x0f, 0x0f, 0x3e, 0xde, 0x17, 0x76, 0xb3, 0x42, 0xbb, 0x56, 0x2a, 0x5b, 0xe2, + 0xc6, 0xb9, 0x2e, 0x8f, 0xe3, 0xed, 0x76, 0x4b, 0xb6, 0x53, 0xa2, 0xcd, 0x2a, 0xce, 0x92, 0x24, 0x89, 0xed, 0x66, + 0x85, 0xd1, 0x50, 0x08, 0x38, 0xbb, 0xc2, 0xa8, 0x01, 0xb1, 0x6a, 0x5c, 0x0f, 0x57, 0x2f, 0x0e, 0x70, 0x2c, 0x3c, + 0x47, 0x75, 0xff, 0xe9, 0xc2, 0x8a, 0xb9, 0xb0, 0x02, 0x3f, 0xd2, 0x00, 0x9f, 0xc2, 0x7c, 0xd9, 0x87, 0x79, 0x4d, + 0x33, 0x94, 0xa1, 0xa4, 0xff, 0x65, 0x91, 0x87, 0x47, 0x2c, 0x7a, 0x86, 0xa1, 0x0b, 0xcc, 0x43, 0xed, 0x3c, 0x7a, + 0x73, 0x96, 0x4d, 0xfd, 0xc9, 0x26, 0x4d, 0x1e, 0x0f, 0xbc, 0xc0, 0xaf, 0xf3, 0x4b, 0x3c, 0xca, 0x3e, 0x5c, 0x32, + 0x78, 0x6b, 0x4d, 0xfa, 0x61, 0x4e, 0x67, 0x68, 0x36, 0x9e, 0xcc, 0x22, 0x0f, 0x9f, 0x31, 0x34, 0xdb, 0x64, 0x4d, + 0xda, 0x46, 0xf3, 0x68, 0x46, 0xa7, 0x68, 0x3a, 0x3a, 0x32, 0x45, 0xd3, 0x4d, 0xd6, 0xcc, 0x3f, 0xcc, 0x2f, 0xcf, + 0xa2, 0xe9, 0x97, 0x97, 0x71, 0x85, 0xc3, 0x1c, 0xe3, 0xc7, 0xc8, 0xf9, 0x65, 0xe4, 0xe4, 0xb3, 0x16, 0x2a, 0xc0, + 0x38, 0x3c, 0xd6, 0xe0, 0x58, 0x13, 0xe0, 0x98, 0x69, 0x55, 0x8b, 0x15, 0xf9, 0x6c, 0xb5, 0xc2, 0x21, 0x71, 0x0d, + 0xa8, 0xe0, 0x24, 0xea, 0x05, 0xa1, 0xa7, 0x04, 0xcf, 0x29, 0x2e, 0x3c, 0x9c, 0xeb, 0xdf, 0x09, 0x27, 0xa1, 0x74, + 0xc4, 0x37, 0xec, 0xe4, 0x6f, 0xba, 0xe2, 0xa7, 0xfd, 0x6f, 0x3c, 0xc0, 0x2d, 0x65, 0x38, 0x24, 0x42, 0x29, 0x30, + 0x77, 0xb0, 0x73, 0x25, 0x7e, 0xf7, 0xf6, 0x06, 0xbd, 0xe5, 0xdc, 0x80, 0xb5, 0x39, 0xc2, 0xaf, 0x1c, 0x69, 0x29, + 0xfb, 0xba, 0x78, 0x93, 0x3e, 0x95, 0xfe, 0x97, 0xf8, 0x45, 0xa0, 0x3f, 0xc0, 0x6d, 0xb5, 0x79, 0x18, 0xe5, 0xbd, + 0xfd, 0x85, 0x6f, 0x23, 0x56, 0x7e, 0x55, 0x8d, 0x02, 0x87, 0xc3, 0x89, 0xf8, 0x3a, 0x83, 0xb5, 0x82, 0xe3, 0x70, + 0x22, 0xbf, 0xce, 0xd1, 0x59, 0xdf, 0xbc, 0x8e, 0xd0, 0xce, 0x12, 0x2b, 0x05, 0x83, 0x20, 0x0d, 0x49, 0xad, 0xcd, + 0xcf, 0x94, 0x35, 0x8f, 0x09, 0xb2, 0x43, 0x47, 0xab, 0x47, 0x3d, 0xcc, 0x00, 0x75, 0x30, 0xaa, 0x0a, 0x30, 0x17, + 0x1b, 0x1c, 0x2e, 0x14, 0x61, 0x92, 0x5a, 0xeb, 0x47, 0x46, 0xe9, 0x9d, 0xf3, 0xe1, 0xe0, 0x89, 0x1a, 0x22, 0xfd, + 0xf5, 0xee, 0xdd, 0xef, 0xe5, 0x7d, 0x41, 0x87, 0x01, 0x89, 0xbf, 0xc5, 0xa8, 0x67, 0x3e, 0x33, 0x46, 0x12, 0x6a, + 0xe7, 0x2b, 0x5e, 0x07, 0x96, 0x18, 0x6b, 0x45, 0x78, 0x2c, 0x6c, 0x47, 0xd5, 0x73, 0xb6, 0x3e, 0xa6, 0xaa, 0x88, + 0x3d, 0xad, 0x2a, 0x62, 0x5a, 0xbd, 0x38, 0x98, 0xc0, 0xfa, 0xe1, 0xf6, 0x10, 0x1e, 0xef, 0x27, 0x8a, 0xfc, 0x7b, + 0x0d, 0x66, 0x7f, 0x0b, 0x12, 0x98, 0xd3, 0x26, 0xc0, 0xe4, 0x89, 0x60, 0x48, 0x1c, 0xec, 0xdc, 0xcd, 0x38, 0x7f, + 0x2d, 0xf1, 0x87, 0x13, 0x45, 0xb4, 0x62, 0x52, 0xb0, 0x87, 0xf2, 0x1c, 0x71, 0x78, 0x10, 0x64, 0x43, 0xe5, 0x1a, + 0x4e, 0x3c, 0x92, 0xd4, 0x9a, 0xad, 0x6d, 0x10, 0x1e, 0x27, 0x8c, 0xd0, 0xae, 0x03, 0xc5, 0x6f, 0x1a, 0x21, 0x79, + 0xa0, 0xc2, 0x63, 0xf8, 0x78, 0xd3, 0xcf, 0x8c, 0xfb, 0xd5, 0xf0, 0xd1, 0x80, 0xfc, 0x4f, 0xf9, 0xd2, 0x2f, 0x87, + 0x97, 0x9f, 0x70, 0x48, 0xfa, 0xf8, 0xef, 0x1f, 0x37, 0x84, 0x6f, 0xef, 0x57, 0xbb, 0x56, 0x4e, 0x7c, 0xe8, 0xd1, + 0x7c, 0x16, 0x1e, 0xef, 0x8f, 0xe1, 0x31, 0x5c, 0x14, 0xf1, 0x30, 0xe7, 0xab, 0xa2, 0x1f, 0xb9, 0xd5, 0x0f, 0x87, + 0xa5, 0xde, 0x45, 0x56, 0x7c, 0x11, 0x6a, 0x95, 0x0b, 0xd5, 0x80, 0x11, 0xee, 0xc8, 0xc5, 0x66, 0x22, 0x54, 0xb7, + 0x76, 0x87, 0x8e, 0x72, 0xee, 0x29, 0xb3, 0x6e, 0xb7, 0xa8, 0xb5, 0x72, 0x9e, 0x13, 0xf2, 0x14, 0xda, 0xe3, 0x40, + 0xef, 0x27, 0x4c, 0xfe, 0x66, 0xf6, 0xdd, 0x71, 0xa9, 0xf9, 0xfe, 0xe0, 0xd3, 0x10, 0x51, 0x29, 0x56, 0x2a, 0x67, + 0xa0, 0x1c, 0x98, 0x41, 0xa8, 0xa6, 0xad, 0x90, 0xfb, 0xdc, 0x52, 0x65, 0x23, 0x0b, 0x46, 0xd4, 0xc7, 0xe5, 0xda, + 0x39, 0xad, 0x0e, 0x4b, 0x6d, 0x38, 0x98, 0x3c, 0x59, 0x0c, 0x40, 0x64, 0x28, 0x17, 0x6b, 0x9b, 0x93, 0xa9, 0x81, + 0x76, 0xb1, 0xa4, 0xec, 0x61, 0x65, 0xf4, 0x5a, 0xf1, 0x88, 0xf9, 0xc9, 0x9b, 0x7f, 0x9b, 0xd6, 0x74, 0x0a, 0x6c, + 0x31, 0x62, 0x75, 0x5d, 0x2f, 0xa4, 0x50, 0x10, 0x0d, 0xb3, 0x2d, 0xcf, 0xc8, 0x95, 0x17, 0xbb, 0x70, 0x93, 0x64, + 0xfe, 0x60, 0xf0, 0x31, 0x4d, 0x92, 0xef, 0x16, 0xa7, 0x70, 0x92, 0x05, 0x5b, 0x1b, 0xab, 0x4d, 0xde, 0x69, 0xe1, + 0xdd, 0x3c, 0xb6, 0x54, 0xa8, 0x4b, 0xef, 0x7d, 0xd9, 0x2c, 0xc6, 0x75, 0x94, 0x0b, 0xd5, 0x9b, 0xe9, 0x97, 0xd2, + 0xa2, 0x15, 0x6a, 0xd8, 0xa9, 0x79, 0x36, 0x4f, 0xba, 0xdd, 0xf1, 0x54, 0x09, 0x87, 0x13, 0x77, 0x2d, 0x61, 0xb7, + 0xf8, 0xbc, 0xb6, 0x4e, 0xd4, 0xfb, 0x68, 0xdc, 0xc9, 0xb9, 0xed, 0x28, 0x83, 0x68, 0x09, 0x6e, 0x0b, 0xa0, 0x16, + 0xbd, 0x8d, 0x48, 0x38, 0x68, 0xed, 0x98, 0xa7, 0xb3, 0x9a, 0xbe, 0x60, 0x9f, 0xea, 0xfa, 0x5f, 0xdc, 0xbe, 0x8a, + 0x0e, 0x2d, 0x35, 0x2b, 0xa1, 0xa2, 0xa5, 0x76, 0x4e, 0xb7, 0x79, 0x74, 0xdd, 0xed, 0x16, 0xe3, 0x91, 0x57, 0x96, + 0xa7, 0xde, 0xcd, 0x7e, 0xd7, 0x9e, 0xf2, 0x9d, 0x76, 0x3b, 0x64, 0xb5, 0x14, 0x7c, 0xe4, 0xeb, 0x59, 0x50, 0x72, + 0x4e, 0x4f, 0x3a, 0xeb, 0x76, 0xc8, 0x9f, 0x9d, 0x52, 0x7d, 0x55, 0xbf, 0xa6, 0x69, 0xf2, 0x37, 0x37, 0xc2, 0xeb, + 0x3a, 0x5b, 0xd6, 0xe7, 0x4c, 0xf9, 0xb5, 0xe9, 0x57, 0x4b, 0x5f, 0x5a, 0x45, 0x3c, 0xbc, 0x6e, 0x7c, 0x65, 0x54, + 0x85, 0xcf, 0x70, 0x55, 0x34, 0x29, 0x12, 0xbc, 0x6c, 0x29, 0xab, 0x2e, 0x66, 0x5b, 0x11, 0x37, 0xe9, 0x89, 0xd4, + 0xa4, 0xd5, 0x93, 0xb9, 0x35, 0xd0, 0x7a, 0xef, 0xab, 0x1b, 0xad, 0x14, 0x30, 0x27, 0xd4, 0x0a, 0x39, 0x8d, 0xc6, + 0x14, 0x10, 0x42, 0x8a, 0xa5, 0xa9, 0xde, 0x4b, 0xa0, 0x16, 0xd0, 0x96, 0x0a, 0x47, 0x8a, 0x78, 0xe0, 0x1f, 0x3a, + 0x5d, 0xf0, 0x52, 0x81, 0x3b, 0xf7, 0x76, 0x33, 0x1d, 0x0c, 0xdc, 0x82, 0xf3, 0x9a, 0xbc, 0x81, 0x69, 0x55, 0xf8, + 0x15, 0x8c, 0x68, 0xdf, 0xa5, 0x65, 0xbc, 0x15, 0xb5, 0xf0, 0x4f, 0x98, 0xaa, 0xe8, 0x8b, 0xdc, 0x6b, 0xf0, 0x79, + 0x1e, 0x9e, 0x5b, 0x3d, 0x24, 0x41, 0xad, 0x5c, 0x53, 0x4e, 0x33, 0xd4, 0x49, 0xca, 0xa0, 0xd1, 0x92, 0x83, 0x29, + 0x6f, 0x6f, 0x7f, 0xfb, 0x67, 0xe5, 0x9d, 0x79, 0x94, 0xeb, 0xec, 0xc3, 0x20, 0xe6, 0x81, 0x51, 0x6a, 0x7e, 0x35, + 0x3c, 0xb2, 0x3a, 0x6a, 0xed, 0x56, 0x1b, 0xfe, 0x44, 0xc7, 0xfb, 0xf1, 0x70, 0xd0, 0xd3, 0xff, 0xfb, 0x56, 0xa9, + 0x6e, 0xe9, 0x06, 0x8a, 0x78, 0x44, 0x8a, 0xd8, 0x3b, 0x3c, 0xd0, 0x9b, 0x91, 0xaf, 0x49, 0xab, 0x3f, 0xef, 0xde, + 0xa2, 0xbf, 0x3a, 0x4e, 0x1d, 0x0c, 0x69, 0xeb, 0xa3, 0x6a, 0xc1, 0x35, 0x9a, 0x97, 0xef, 0xff, 0xbc, 0xbd, 0x3b, + 0x47, 0xb8, 0xee, 0x99, 0x10, 0x28, 0x36, 0x3c, 0xf7, 0xd6, 0xd2, 0x89, 0x8e, 0x1a, 0xd7, 0xab, 0x8d, 0xfc, 0x14, + 0x39, 0xc5, 0xd0, 0xd3, 0x6b, 0x21, 0x61, 0x08, 0x63, 0x10, 0xac, 0xd0, 0xc9, 0xab, 0x93, 0xb5, 0x67, 0x7e, 0xc5, + 0xc3, 0x6d, 0xc7, 0xc3, 0xd5, 0xc7, 0xfd, 0xcb, 0xf7, 0xbf, 0x81, 0xdb, 0x13, 0xb5, 0x09, 0x0b, 0x00, 0x00}; #else // Brotli (default, smaller) constexpr uint8_t INDEX_BR[] PROGMEM = { - 0x1b, 0xf8, 0x0a, 0x00, 0x64, 0x5a, 0xd3, 0xfa, 0xe7, 0xf3, 0x62, 0xd8, 0x06, 0x1b, 0xe9, 0x6a, 0x8a, 0x81, 0x2b, - 0xb5, 0x49, 0x14, 0x37, 0xdc, 0x9e, 0x1a, 0xcb, 0x56, 0x87, 0xfb, 0xff, 0xf7, 0x73, 0x75, 0x12, 0x0a, 0xd6, 0x48, - 0x84, 0xc6, 0x21, 0xa4, 0x6d, 0xb5, 0x71, 0xef, 0x13, 0xbe, 0x4e, 0x54, 0xf1, 0x64, 0x8f, 0x3f, 0xcc, 0x9a, 0x78, - 0xa5, 0x89, 0x25, 0xb3, 0xda, 0x2c, 0xa2, 0x32, 0x9c, 0x57, 0x07, 0x56, 0xbc, 0x34, 0x13, 0xff, 0x5c, 0x0a, 0xa1, - 0x67, 0x82, 0xb8, 0x6b, 0x4c, 0x76, 0x31, 0x6c, 0xe3, 0x40, 0x46, 0xea, 0xb0, 0xd4, 0xf4, 0x3b, 0x02, 0x65, 0x18, - 0xa4, 0xaf, 0xac, 0x6d, 0x55, 0xd6, 0xbe, 0x59, 0x66, 0x7a, 0x7c, 0x60, 0xb2, 0x83, 0x33, 0x23, 0xc9, 0x79, 0x82, - 0x47, 0xb4, 0x28, 0xf4, 0x24, 0xb5, 0x23, 0x5a, 0x44, 0xe1, 0xc3, 0x27, 0x04, 0xe8, 0x0c, 0xdd, 0xb4, 0xd0, 0x8c, - 0xfb, 0x10, 0x39, 0x93, 0x04, 0x2a, 0x66, 0x18, 0x4b, 0x74, 0xca, 0x31, 0x7f, 0xb2, 0xe5, 0x45, 0xc1, 0xdd, 0x72, - 0x49, 0xff, 0x0e, 0xb3, 0xf0, 0x93, 0x18, 0xab, 0x68, 0xad, 0xe1, 0x9d, 0xe4, 0x29, 0xc0, 0xe3, 0x63, 0x54, 0x61, - 0x1b, 0x45, 0xb9, 0x6c, 0x23, 0x0f, 0x99, 0x7f, 0x8e, 0x69, 0xaa, 0xc1, 0xb8, 0x4e, 0x42, 0x9c, 0xc5, 0x6e, 0x69, - 0x40, 0x0e, 0x4f, 0x97, 0xd3, 0x23, 0x18, 0xf5, 0xc8, 0x75, 0x73, 0xb5, 0xbd, 0x46, 0x8a, 0x97, 0x7d, 0x83, 0xe4, - 0x29, 0x72, 0x73, 0xc1, 0x39, 0x8e, 0x7e, 0x84, 0x39, 0x66, 0x57, 0xc6, 0x85, 0x19, 0x8b, 0xf2, 0x4d, 0xd9, 0xfe, - 0x75, 0xa9, 0xe1, 0x2b, 0x21, 0x81, 0x58, 0x51, 0x99, 0xbc, 0xa4, 0x0b, 0x10, 0x6f, 0x86, 0x17, 0x0b, 0x92, 0x00, - 0x11, 0x6f, 0x3b, 0xa4, 0xa4, 0x11, 0x7e, 0x0b, 0x97, 0x85, 0x23, 0x0c, 0x01, 0x6f, 0x2a, 0x18, 0xc6, 0xbe, 0x3d, - 0x77, 0x1a, 0xe6, 0x00, 0x5c, 0x1a, 0x14, 0x47, 0xc6, 0xcc, 0xcc, 0x52, 0xbe, 0x04, 0x19, 0x31, 0x05, 0x46, 0xa0, - 0xc3, 0x69, 0x0c, 0x60, 0xb7, 0x14, 0x57, 0xa0, 0x92, 0xbf, 0xb7, 0x0c, 0xd8, 0x3a, 0x79, 0x09, 0x99, 0xc9, 0x71, - 0x88, 0x01, 0x8b, 0xa5, 0x61, 0x0a, 0xb5, 0xe8, 0xc7, 0x71, 0xe7, 0x70, 0x79, 0xb6, 0xe4, 0x01, 0xfc, 0x1a, 0x4a, - 0x7b, 0x60, 0x6e, 0xef, 0x95, 0x62, 0x59, 0x28, 0xb5, 0x25, 0x56, 0x15, 0xe7, 0xca, 0xad, 0x32, 0xe6, 0xf7, 0x01, - 0x31, 0x34, 0x87, 0x93, 0x0b, 0x9b, 0x9d, 0x26, 0xff, 0xe5, 0x92, 0xad, 0x6f, 0xb8, 0x3b, 0x16, 0xc1, 0xa0, 0x5a, - 0x4f, 0x52, 0x0b, 0x2b, 0xc1, 0xa7, 0x95, 0x7b, 0x24, 0x51, 0xd3, 0xb3, 0x23, 0x62, 0x0b, 0xcc, 0xa0, 0x58, 0xa7, - 0x64, 0x45, 0x2f, 0x0b, 0xdd, 0x1d, 0x97, 0x82, 0x1f, 0xcc, 0x64, 0xdb, 0xd3, 0xf4, 0xb0, 0x8b, 0xc8, 0xcf, 0x15, - 0x81, 0x8b, 0xa1, 0x9d, 0xf8, 0xfc, 0xec, 0x49, 0x40, 0x12, 0x01, 0x09, 0x51, 0xf3, 0x73, 0x18, 0x24, 0x97, 0x55, - 0x85, 0x6a, 0x92, 0x1a, 0xf5, 0x5a, 0x05, 0x54, 0x1f, 0x27, 0x0a, 0xa8, 0xa1, 0x94, 0x58, 0x78, 0x7d, 0x87, 0xa8, - 0xdb, 0x13, 0x66, 0x20, 0x5e, 0x43, 0x18, 0x7a, 0xbb, 0x16, 0x16, 0x07, 0xc8, 0xab, 0x10, 0xe2, 0x50, 0xb9, 0xb1, - 0xd8, 0x21, 0xc8, 0x4a, 0x2e, 0x99, 0x0e, 0x23, 0x52, 0xc6, 0xcb, 0x29, 0x84, 0x91, 0x03, 0xb1, 0xe2, 0x4c, 0x1d, - 0x22, 0xd3, 0xc8, 0x79, 0x00, 0x8b, 0x8b, 0x88, 0x1e, 0x29, 0xd3, 0xae, 0x10, 0x15, 0x22, 0x6d, 0xb0, 0x87, 0x6f, - 0x27, 0x2e, 0x7c, 0xc2, 0x7a, 0x61, 0xbd, 0x22, 0xe5, 0x5f, 0xdd, 0x7b, 0x00, 0x04, 0xf2, 0x7d, 0x5a, 0x03, 0x38, - 0x1f, 0x69, 0x6d, 0x0b, 0xfb, 0xec, 0x45, 0xfe, 0x8b, 0x7f, 0xec, 0x7b, 0xad, 0xc2, 0x33, 0xf1, 0x9e, 0x9c, 0x71, - 0xd9, 0xe8, 0x5e, 0x8f, 0xd4, 0xee, 0x87, 0x45, 0x6c, 0xe2, 0x12, 0xf8, 0xb8, 0xc5, 0xee, 0x43, 0xa6, 0x37, 0x91, - 0xb5, 0x2c, 0x2f, 0xe9, 0xe8, 0x24, 0xd0, 0x45, 0xc1, 0x0c, 0x7c, 0xf0, 0xb2, 0xb5, 0x2d, 0x10, 0x36, 0x7e, 0x18, - 0x7c, 0x79, 0x82, 0x69, 0x3d, 0x35, 0xca, 0x52, 0xee, 0xc9, 0xb5, 0x65, 0xa4, 0xa1, 0xfd, 0x70, 0x7e, 0xe0, 0x7d, - 0x67, 0xf9, 0xa1, 0x71, 0xd2, 0x08, 0x74, 0x33, 0x5f, 0x69, 0xa4, 0x59, 0x03, 0xfd, 0xf8, 0xf0, 0x70, 0x1a, 0x50, - 0x43, 0xfb, 0x61, 0xf0, 0x38, 0x18, 0x88, 0x85, 0x36, 0x23, 0x06, 0x4f, 0x02, 0xbb, 0x78, 0x1a, 0xaa, 0xd2, 0x02, - 0x5e, 0xa0, 0x74, 0x30, 0xc8, 0x7a, 0x66, 0xab, 0xd9, 0x43, 0x99, 0x45, 0xb7, 0x0c, 0x5c, 0xec, 0xc8, 0x03, 0x0e, - 0x0b, 0xca, 0x4a, 0x22, 0x48, 0xfb, 0xb7, 0x3d, 0x82, 0x07, 0x8d, 0x1b, 0x21, 0x87, 0x4d, 0x57, 0xa4, 0x5b, 0xd4, - 0xe3, 0x88, 0x02, 0xc4, 0x81, 0xf9, 0x47, 0xe4, 0xbf, 0x3e, 0x39, 0xbb, 0x4f, 0x7e, 0x91, 0x63, 0x98, 0x97, 0xe4, - 0x52, 0x01, 0x58, 0xba, 0x32, 0xbf, 0xae, 0xff, 0x45, 0xa1, 0xbc, 0x9b, 0xa4, 0x09, 0x0e, 0x79, 0xc0, 0x41, 0x86, - 0x52, 0x88, 0x55, 0x39, 0x9d, 0xb6, 0xed, 0x35, 0x68, 0x29, 0xfa, 0xe6, 0x6c, 0x3d, 0x0a, 0xcd, 0x6a, 0x28, 0xfd, - 0x65, 0x24, 0xce, 0x38, 0x98, 0x01, 0xd9, 0x3f, 0x1b, 0x4c, 0xc4, 0x5c, 0x1d, 0xaa, 0x21, 0x78, 0x67, 0xaf, 0x55, - 0x72, 0x34, 0xf8, 0x1b, 0x03, 0x21, 0x27, 0x08, 0xbd, 0x59, 0x60, 0x48, 0x0d, 0xe2, 0x56, 0x9b, 0x30, 0x92, 0x8f, - 0x67, 0x8a, 0x7f, 0x20, 0xbd, 0x2d, 0xfd, 0xc5, 0xb0, 0xa6, 0xaa, 0x77, 0x75, 0x26, 0x33, 0x2f, 0x20, 0x2a, 0xab, - 0x5c, 0xd1, 0x3b, 0xda, 0xb2, 0x4c, 0xa4, 0x86, 0x25, 0x8d, 0x49, 0x05, 0xaf, 0x7a, 0xa8, 0xd4, 0x9c, 0x0d, 0xd3, - 0x38, 0xa6, 0x5c, 0x29, 0x6b, 0x16, 0x27, 0x07, 0xf1, 0xbe, 0xe2, 0x24, 0xc1, 0x8d, 0x25, 0x76, 0xbc, 0xf6, 0x0d, - 0xc2, 0x94, 0x25, 0xb8, 0xf3, 0x07, 0x9a, 0x49, 0xf4, 0x89, 0x82, 0x4d, 0x51, 0xb1, 0x96, 0x61, 0x62, 0x8d, 0xc8, - 0x61, 0x65, 0x0d, 0x14, 0x34, 0x02, 0x65, 0x94, 0xcc, 0x1d, 0x85, 0x00, 0x0f, 0x1a, 0x57, 0x68, 0x15, 0xcf, 0xa4, - 0xa2, 0x7d, 0x6d, 0x53, 0x60, 0xce, 0x5c, 0x61, 0x82, 0x17, 0x32, 0xc1, 0x87, 0x02, 0x0c, 0x91, 0x85, 0x57, 0x51, - 0xbe, 0xb2, 0x38, 0x9f, 0x3d, 0x2a, 0x52, 0x5a, 0xad, 0xba, 0x46, 0x9e, 0x3c, 0x8a, 0xa0, 0x46, 0x15, 0xf4, 0x59, - 0x74, 0x5f, 0x2a, 0xae, 0x96, 0x56, 0xf0, 0x54, 0x39, 0xaf, 0xac, 0x2a, 0xb9, 0xad, 0x32, 0x50, 0xc9, 0xc1, 0xee, - 0xd2, 0x0d, 0x34, 0xaa, 0x98, 0x4d, 0x6d, 0x3d, 0xc6, 0xb9, 0x5b, 0x00, 0x5f, 0xea, 0xda, 0x16, 0xa6, 0x08, 0x43, - 0x58, 0x4d, 0x8d, 0x07, 0x55, 0x62, 0x81, 0x44, 0xcc, 0x31, 0x04, 0x4b, 0x4c, 0x8b, 0x3e, 0xff, 0xd8, 0xf6, 0x65, - 0x19, 0xa1, 0x94, 0x62, 0x65, 0x0a, 0xdd, 0x60, 0x38, 0xd3, 0xbe, 0x0d, 0xa3, 0x99, 0xd5, 0x37, 0x68, 0xa1, 0x71, - 0xa3, 0x41, 0xe7, 0xbe, 0x9d, 0x72, 0x84, 0x75, 0xb6, 0x8d, 0x98, 0xd6, 0xb8, 0x2d, 0x43, 0x85, 0x5d, 0xf9, 0xca, - 0xc3, 0x96, 0xa5, 0xa6, 0xe7, 0x50, 0x88, 0x6b, 0x84, 0x58, 0x44, 0x45, 0x20, 0xdf, 0x1e, 0x5a, 0xc9, 0xce, 0x42, - 0x2a, 0x1f, 0x3e, 0x3c, 0x7b, 0x68, 0x3c, 0x34, 0x8b, 0x36, 0xba, 0x1f, 0xce, 0x0f, 0xa0, 0x60, 0x37, 0x5f, 0x1a, - 0x03, 0x2b, 0x86, 0x29, 0x45, 0x7b, 0xb4, 0xb7, 0x06, 0x68, 0x17, 0x7e, 0x13, 0x76, 0x91, 0x4d, 0x27, 0xee, 0xbc, - 0x7e, 0x80, 0xc2, 0x66, 0xac, 0xc6, 0xbf, 0xeb, 0x7f, 0xd7, 0x84, 0x79, 0xf3, 0xf1, 0xde, 0xec, 0xa6, 0x93, 0xa8, - 0x13, 0x3b, 0x4a, 0x81, 0xfa, 0x11, 0x1e, 0x4a, 0xd2, 0x50, 0x2a, 0xea, 0x9a, 0xc2, 0x37, 0x08, 0xed, 0x01, 0xf5, - 0xa2, 0xd5, 0x32, 0x29, 0x49, 0xc4, 0x1a, 0x11, 0xc0, 0xda, 0x24, 0x28, 0x84, 0x38, 0x60, 0x80, 0xcf, 0xd0, 0x45, - 0x83, 0xa7, 0xca, 0x52, 0x5c, 0xac, 0x23, 0x01}; + 0x1b, 0x08, 0x0b, 0x00, 0xe4, 0x7f, 0x9b, 0xad, 0xbb, 0x97, 0x53, 0xde, 0xb7, 0x25, 0x0e, 0x69, 0xd4, 0x69, 0x89, + 0xba, 0xa5, 0x55, 0x22, 0x04, 0x27, 0xeb, 0x00, 0x52, 0xac, 0xbc, 0xec, 0x5f, 0xfb, 0xb5, 0x7a, 0x12, 0x0a, 0xd6, + 0x48, 0x84, 0x4a, 0x48, 0x5a, 0xcb, 0xbd, 0xb7, 0x72, 0x66, 0x4e, 0x62, 0xd8, 0xbd, 0x7f, 0x88, 0x68, 0x86, 0x28, + 0xd6, 0xf1, 0xda, 0x2c, 0xa2, 0x32, 0x5c, 0xdd, 0xab, 0xb2, 0x15, 0xbc, 0x24, 0x13, 0xe6, 0xbd, 0x0c, 0x52, 0x63, + 0x82, 0x88, 0x6d, 0x74, 0x71, 0x51, 0x9c, 0xe2, 0x40, 0x56, 0xea, 0xb0, 0x5c, 0xb7, 0x1d, 0x81, 0x3a, 0x0c, 0xf2, + 0xd7, 0xa8, 0x5c, 0x35, 0x2d, 0x43, 0xb3, 0x42, 0x37, 0x7c, 0x60, 0xd8, 0xc1, 0x95, 0x91, 0x94, 0x3c, 0x29, 0x20, + 0x96, 0x20, 0xf6, 0x24, 0xb7, 0x83, 0x4a, 0x06, 0xf1, 0xc3, 0x2f, 0x88, 0x50, 0x55, 0x35, 0x2d, 0xe8, 0xb6, 0x21, + 0x38, 0x61, 0x8e, 0x44, 0x2a, 0xac, 0x39, 0xcf, 0x74, 0xaa, 0x31, 0x7f, 0x62, 0x32, 0x9b, 0x99, 0x42, 0x0a, 0xf6, + 0x6f, 0xd8, 0x89, 0x3f, 0x49, 0xb1, 0xa2, 0x52, 0x0a, 0x8e, 0xb3, 0x27, 0x0b, 0x07, 0x07, 0x58, 0xb0, 0x8f, 0xaa, + 0xdc, 0x68, 0x12, 0x0f, 0x95, 0xbf, 0xc7, 0x36, 0xd5, 0x60, 0x5a, 0xc7, 0x80, 0xac, 0x52, 0xf7, 0xa7, 0x2d, 0xb6, + 0x64, 0xda, 0xda, 0x11, 0x8d, 0x6a, 0xe5, 0xba, 0xe9, 0xda, 0xdc, 0x62, 0xc3, 0xcb, 0xae, 0xc1, 0xe1, 0x11, 0xb6, + 0x33, 0x29, 0x04, 0x09, 0xfe, 0x09, 0x08, 0xc2, 0x1f, 0x98, 0x16, 0x26, 0x0d, 0xce, 0xd7, 0x75, 0xfb, 0xd7, 0xa5, + 0x82, 0xd7, 0x32, 0x44, 0x72, 0xc1, 0xc2, 0xe4, 0x15, 0xcb, 0x50, 0xfc, 0xd3, 0x58, 0x64, 0x34, 0x41, 0x32, 0xbe, + 0x1f, 0x08, 0x43, 0x16, 0x14, 0xf7, 0x70, 0x2c, 0x1c, 0x61, 0x09, 0x78, 0x73, 0xd1, 0x30, 0xf6, 0xed, 0x85, 0x55, + 0x50, 0x02, 0xf0, 0x68, 0x50, 0x5c, 0x1a, 0xd7, 0x3b, 0x8e, 0xf2, 0x23, 0xc8, 0x88, 0x39, 0xd0, 0x84, 0xf7, 0xa6, + 0xd1, 0xa3, 0xfb, 0xe5, 0x44, 0xc0, 0x4c, 0x2f, 0x6f, 0x19, 0x70, 0x75, 0xf6, 0x1c, 0xb8, 0xce, 0x89, 0x4f, 0x01, + 0x8d, 0xa1, 0x71, 0xf2, 0x97, 0xf8, 0xe7, 0xd3, 0xf1, 0xe1, 0xfa, 0xfc, 0xc8, 0x03, 0x78, 0x14, 0xa0, 0x3d, 0x28, + 0xb7, 0xeb, 0x26, 0x52, 0x59, 0xa8, 0xb5, 0x0d, 0x5c, 0x8a, 0x6b, 0xe5, 0xf6, 0x30, 0xd6, 0xf7, 0x71, 0x31, 0xe8, + 0xbd, 0xc9, 0xfa, 0xf5, 0x71, 0x9d, 0xff, 0xf6, 0x69, 0x62, 0x5f, 0xb5, 0xc7, 0x06, 0x43, 0x54, 0xb5, 0x87, 0xf1, + 0xcc, 0x84, 0xe8, 0xb7, 0x56, 0x38, 0x43, 0x6a, 0xa6, 0x2f, 0x53, 0xd1, 0x89, 0x48, 0x8d, 0x72, 0x75, 0x4a, 0x17, + 0xf6, 0x8d, 0xb2, 0xeb, 0xb1, 0x6b, 0x29, 0x1e, 0xd5, 0x74, 0xc7, 0xb3, 0xf4, 0xf1, 0x04, 0x0d, 0xbf, 0x08, 0x81, + 0x8f, 0xfe, 0x8d, 0xfc, 0xf2, 0x35, 0x92, 0xa0, 0x24, 0x88, 0x12, 0x6a, 0xe6, 0x2f, 0x01, 0x94, 0x5c, 0x4b, 0xf9, + 0x6b, 0x9a, 0xf6, 0x1a, 0x35, 0x11, 0x8a, 0xc6, 0x04, 0x8d, 0x50, 0x64, 0x48, 0x2d, 0x8b, 0xdf, 0xdf, 0xa1, 0xd1, + 0xfd, 0x21, 0xd7, 0x40, 0x96, 0x00, 0xb1, 0x9f, 0x54, 0xc2, 0xe1, 0x00, 0x79, 0x15, 0x80, 0xf8, 0xca, 0x8e, 0xc5, + 0x06, 0x03, 0xdf, 0x72, 0x49, 0xc0, 0x08, 0x27, 0x90, 0xe3, 0x14, 0xc2, 0xac, 0xc3, 0x83, 0xc9, 0xf6, 0x9d, 0x12, + 0xab, 0x46, 0xae, 0x03, 0x74, 0x1d, 0x27, 0xce, 0x91, 0x1d, 0x4e, 0xb4, 0xbe, 0x80, 0xe7, 0x6a, 0x6d, 0x0a, 0x20, + 0x47, 0x6b, 0x00, 0x4e, 0x25, 0x52, 0xe6, 0xf5, 0xe9, 0x43, 0x84, 0xc1, 0x0d, 0x4b, 0x04, 0xb3, 0x91, 0x49, 0xf5, + 0xe9, 0xc4, 0x2e, 0x1b, 0xf9, 0x98, 0xf9, 0xea, 0x9e, 0xb8, 0xf6, 0x53, 0xf8, 0x42, 0xc2, 0x60, 0x5c, 0xa9, 0xd2, + 0xfe, 0x0a, 0xe5, 0xd4, 0x7d, 0x36, 0x76, 0x04, 0x12, 0x38, 0xa1, 0xc4, 0x30, 0xb8, 0xf2, 0x69, 0x86, 0x5b, 0xe7, + 0xe5, 0xa0, 0xc0, 0xfe, 0x91, 0x19, 0x03, 0x1e, 0xa9, 0x93, 0x26, 0x49, 0x58, 0xd5, 0xf6, 0x33, 0x4e, 0x0a, 0x89, + 0x64, 0x1e, 0xb4, 0x7a, 0x5d, 0x0d, 0x12, 0xcf, 0x2b, 0xdd, 0x35, 0x90, 0x55, 0xc3, 0x0e, 0xcd, 0x0e, 0xad, 0x6b, + 0x8f, 0x43, 0xd0, 0xb4, 0x6a, 0xdb, 0x4b, 0xe5, 0xa4, 0x9b, 0x09, 0x23, 0x1a, 0x43, 0xa9, 0xff, 0x61, 0x8b, 0x07, + 0xd6, 0x0f, 0x83, 0x23, 0xbe, 0x8a, 0x66, 0xa2, 0x10, 0x2f, 0x11, 0x01, 0x0d, 0xc6, 0x96, 0xba, 0x87, 0xaa, 0xa8, + 0x44, 0x7c, 0x1e, 0x34, 0xdb, 0xba, 0x41, 0x90, 0xf0, 0x7a, 0xdb, 0x63, 0x60, 0x96, 0x8d, 0x64, 0xcb, 0x2f, 0x28, + 0x96, 0x04, 0xd5, 0xc0, 0x7a, 0xe8, 0xec, 0xd1, 0x61, 0x1b, 0x09, 0x4b, 0x4c, 0x6e, 0x1b, 0x31, 0x98, 0x9c, 0x6d, + 0xdb, 0xd0, 0xec, 0xa4, 0x0f, 0x0a, 0xe0, 0xc8, 0x35, 0xc4, 0x93, 0xdc, 0xee, 0x19, 0x00, 0x80, 0x07, 0xf5, 0xcf, + 0xe0, 0x7f, 0x75, 0xf8, 0x52, 0x3a, 0xfc, 0x0d, 0x84, 0xa5, 0xc1, 0xb2, 0x1c, 0x25, 0x40, 0xc5, 0x8b, 0xb3, 0xdb, + 0x7a, 0x1b, 0x44, 0xff, 0x79, 0x9a, 0x26, 0xc4, 0xe7, 0x9e, 0x78, 0x4c, 0xa5, 0x94, 0xab, 0x78, 0x34, 0x9d, 0xb5, + 0xb7, 0x24, 0x26, 0xe7, 0x9a, 0xf3, 0xe5, 0x2a, 0x35, 0x4b, 0xbe, 0x74, 0xd7, 0x01, 0xbc, 0x71, 0x72, 0x03, 0x5c, + 0x40, 0x24, 0x17, 0x61, 0x5b, 0x7b, 0x19, 0xc2, 0x7f, 0x4e, 0x5b, 0x24, 0xfb, 0x8b, 0xc3, 0x51, 0x00, 0x3d, 0x09, + 0x04, 0x05, 0x72, 0x64, 0xf6, 0xf0, 0x6b, 0x99, 0x38, 0x93, 0x5b, 0xac, 0x0c, 0xff, 0x40, 0x7b, 0x53, 0xba, 0xab, + 0x61, 0xc9, 0xa2, 0xde, 0xd6, 0x2b, 0x0c, 0xbd, 0x85, 0xac, 0x4c, 0x64, 0x8b, 0xe6, 0x69, 0x2b, 0x56, 0x10, 0x1b, + 0x08, 0x59, 0x6c, 0x55, 0x0a, 0xaa, 0x93, 0x85, 0x1d, 0x45, 0xda, 0xc6, 0x39, 0xe6, 0x6a, 0xab, 0x71, 0x73, 0x46, + 0x0f, 0xf7, 0xab, 0x4e, 0x88, 0xae, 0x8c, 0xe0, 0x91, 0xda, 0x35, 0x8c, 0xd3, 0x18, 0x92, 0x3d, 0xab, 0x2f, 0x0d, + 0xd6, 0xc9, 0x06, 0xdb, 0xc2, 0x62, 0xcb, 0x8a, 0x23, 0x5b, 0x28, 0x2e, 0x9b, 0x96, 0xc4, 0xc1, 0x42, 0xa9, 0x8d, + 0x69, 0xe5, 0x8f, 0x89, 0x12, 0x11, 0x9a, 0x56, 0x3b, 0x3c, 0x2b, 0xb4, 0xb2, 0x7b, 0xfd, 0xdb, 0x80, 0x92, 0xb4, + 0xca, 0x44, 0x37, 0x8c, 0x94, 0x2f, 0x4a, 0xb4, 0xc4, 0x28, 0x83, 0x8a, 0x78, 0xcb, 0xd2, 0x5c, 0x5c, 0x35, 0x29, + 0x95, 0x35, 0x2f, 0x99, 0x28, 0x4f, 0x22, 0x18, 0x70, 0x85, 0xee, 0x2c, 0xb9, 0xef, 0x14, 0x57, 0x73, 0x23, 0x45, + 0xae, 0xdc, 0x54, 0x56, 0x55, 0x78, 0x56, 0xad, 0x48, 0x26, 0x27, 0xbf, 0xcb, 0xd7, 0x54, 0xa9, 0xa8, 0xd7, 0xb5, + 0x71, 0x9c, 0xe7, 0x79, 0x89, 0x5c, 0xa9, 0x6a, 0x53, 0xe8, 0xfa, 0x0d, 0x69, 0x36, 0xed, 0xad, 0x33, 0xc9, 0x75, + 0x17, 0xe9, 0x8f, 0x31, 0x58, 0xa6, 0x27, 0xfc, 0x79, 0xc6, 0xb6, 0xd5, 0x65, 0x90, 0x31, 0xc6, 0x9d, 0x29, 0x95, + 0x83, 0xe5, 0x4e, 0xbb, 0xd7, 0xdc, 0x8e, 0xd0, 0x3a, 0x54, 0x27, 0xce, 0xf5, 0xdb, 0xbd, 0x89, 0x3c, 0x61, 0xb3, + 0x71, 0x23, 0xc7, 0x55, 0x9e, 0xea, 0x50, 0xe4, 0x37, 0xae, 0x72, 0x34, 0x66, 0xb9, 0x6e, 0x2c, 0x0a, 0x69, 0x8d, + 0x94, 0x8b, 0x98, 0x08, 0x05, 0x3c, 0x45, 0x45, 0xe1, 0x6c, 0x22, 0xc5, 0x8f, 0x1f, 0x9f, 0x3f, 0xd2, 0x01, 0x12, + 0xec, 0x86, 0x2f, 0x87, 0x8b, 0x47, 0x30, 0xd0, 0x77, 0x77, 0x1a, 0x13, 0x2d, 0xc6, 0x31, 0x65, 0x77, 0xd8, 0x8c, + 0xe7, 0xe0, 0x16, 0xf9, 0x4b, 0xd4, 0xc5, 0xa8, 0x67, 0x79, 0xe7, 0xc3, 0x07, 0x94, 0x46, 0xa3, 0x8c, 0x67, 0xd3, + 0xff, 0x5f, 0x96, 0xfa, 0xed, 0xa7, 0xd3, 0xdd, 0x4f, 0x27, 0x49, 0x27, 0xcf, 0xa4, 0x02, 0xc3, 0x27, 0x3c, 0x96, + 0x64, 0x24, 0x55, 0xc8, 0x36, 0x45, 0x68, 0x90, 0xea, 0x03, 0x0b, 0x46, 0xa7, 0x8d, 0xb4, 0x26, 0x91, 0x07, 0x4c, + 0x80, 0x7b, 0x93, 0xa8, 0x10, 0xcb, 0x5e, 0x8d, 0x42, 0x86, 0x3e, 0x2a, 0x7c, 0x99, 0x79, 0x8e, 0xcb, 0x43, 0x28}; // Backwards compatibility alias #define INDEX_GZ INDEX_BR diff --git a/esphome/components/web_server/server_index_v2.h b/esphome/components/web_server/server_index_v2.h index ac2195f387..0c1a6c7f79 100644 --- a/esphome/components/web_server/server_index_v2.h +++ b/esphome/components/web_server/server_index_v2.h @@ -10,1308 +10,1310 @@ namespace esphome::web_server { #ifdef USE_WEBSERVER_GZIP constexpr uint8_t INDEX_GZ[] PROGMEM = { - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xed, 0x7d, 0xd9, 0x72, 0xdb, 0x48, 0xb6, 0xe0, 0xf3, - 0xd4, 0x57, 0x40, 0x28, 0xb5, 0x8c, 0x2c, 0x26, 0xc1, 0x45, 0x92, 0x2d, 0x83, 0x4a, 0xb2, 0x65, 0xd9, 0xd5, 0x76, - 0x97, 0xb7, 0xb6, 0xec, 0xda, 0x58, 0x6c, 0x09, 0x02, 0x92, 0x44, 0x96, 0x41, 0x80, 0x05, 0x24, 0xb5, 0x14, 0x89, - 0x1b, 0xf3, 0x01, 0x13, 0x31, 0x11, 0xf3, 0x34, 0x2f, 0x13, 0x73, 0x1f, 0xe6, 0x23, 0xe6, 0xf9, 0x7e, 0xca, 0xfd, - 0x81, 0x99, 0x4f, 0x98, 0x38, 0xb9, 0x00, 0x09, 0x2e, 0xb2, 0x5c, 0x55, 0x7d, 0xef, 0x7d, 0x98, 0xa8, 0x28, 0x99, - 0x48, 0xe4, 0x72, 0xf2, 0xe4, 0xc9, 0xb3, 0x67, 0xe2, 0x78, 0x27, 0x4c, 0x03, 0x7e, 0x3b, 0xa3, 0x56, 0xc4, 0xa7, - 0x71, 0xff, 0x58, 0xfd, 0xa5, 0x7e, 0xd8, 0x3f, 0x8e, 0x59, 0xf2, 0xd1, 0xca, 0x68, 0x4c, 0x58, 0x90, 0x26, 0x56, - 0x94, 0xd1, 0x31, 0x09, 0x7d, 0xee, 0x7b, 0x6c, 0xea, 0x4f, 0xa8, 0xd5, 0xea, 0x1f, 0x4f, 0x29, 0xf7, 0xad, 0x20, - 0xf2, 0xb3, 0x9c, 0x72, 0xf2, 0xe1, 0xfd, 0xd7, 0xcd, 0xa3, 0xfe, 0x71, 0x1e, 0x64, 0x6c, 0xc6, 0x2d, 0xe8, 0x92, - 0x4c, 0xd3, 0x70, 0x1e, 0xd3, 0x7e, 0xab, 0x75, 0x7d, 0x7d, 0xed, 0xfe, 0x9c, 0x7f, 0x11, 0xa4, 0x49, 0xce, 0xad, - 0xa7, 0xe4, 0x9a, 0x25, 0x61, 0x7a, 0x8d, 0x73, 0x4e, 0x9e, 0xba, 0x67, 0x91, 0x1f, 0xa6, 0xd7, 0xef, 0xd2, 0x94, - 0xef, 0xed, 0x39, 0xf2, 0xf1, 0xf6, 0xf4, 0xec, 0x8c, 0x10, 0x72, 0x95, 0xb2, 0xd0, 0x6a, 0x2f, 0x97, 0x55, 0xa1, - 0x9b, 0xf8, 0x9c, 0x5d, 0x51, 0xd9, 0x04, 0xed, 0xed, 0xd9, 0x7e, 0x98, 0xce, 0x38, 0x0d, 0xcf, 0xf8, 0x6d, 0x4c, - 0xcf, 0x22, 0x4a, 0x79, 0x6e, 0xb3, 0xc4, 0x7a, 0x9a, 0x06, 0xf3, 0x29, 0x4d, 0xb8, 0x3b, 0xcb, 0x52, 0x9e, 0x02, - 0x24, 0x7b, 0x7b, 0x76, 0x46, 0x67, 0xb1, 0x1f, 0x50, 0x78, 0x7f, 0x7a, 0x76, 0x56, 0xb5, 0xa8, 0x2a, 0xe1, 0x84, - 0x93, 0xb3, 0xdb, 0xe9, 0x65, 0x1a, 0x3b, 0x08, 0x47, 0x9c, 0x24, 0xf4, 0xda, 0xfa, 0x8e, 0xfa, 0x1f, 0x5f, 0xf9, - 0xb3, 0x5e, 0x10, 0xfb, 0x79, 0x6e, 0x9d, 0xf0, 0x85, 0x98, 0x42, 0x36, 0x0f, 0x78, 0x9a, 0x39, 0x1c, 0x53, 0xcc, - 0xd0, 0x82, 0x8d, 0x1d, 0x1e, 0xb1, 0xdc, 0x3d, 0xdf, 0x0d, 0xf2, 0xfc, 0x1d, 0xcd, 0xe7, 0x31, 0xdf, 0x25, 0x3b, - 0x6d, 0xcc, 0x76, 0x08, 0x49, 0x38, 0xe2, 0x51, 0x96, 0x5e, 0x5b, 0xcf, 0xb2, 0x2c, 0xcd, 0x1c, 0xfb, 0xf4, 0xec, - 0x4c, 0xd6, 0xb0, 0x58, 0x6e, 0x25, 0x29, 0xb7, 0xca, 0xfe, 0xfc, 0xcb, 0x98, 0xba, 0xd6, 0x87, 0x9c, 0x5a, 0x17, - 0xf3, 0x24, 0xf7, 0xc7, 0xf4, 0xf4, 0xec, 0xec, 0xc2, 0x4a, 0x33, 0xeb, 0x22, 0xc8, 0xf3, 0x0b, 0x8b, 0x25, 0x39, - 0xa7, 0x7e, 0xe8, 0xda, 0xa8, 0x27, 0x06, 0x0b, 0xf2, 0xfc, 0x3d, 0xbd, 0xe1, 0x84, 0x63, 0xf1, 0xc8, 0x09, 0x2d, - 0x26, 0x94, 0x5b, 0x79, 0x39, 0x2f, 0x07, 0x2d, 0x62, 0xca, 0x2d, 0x4e, 0xc4, 0xfb, 0xb4, 0x27, 0x71, 0x4f, 0xe5, - 0x23, 0xef, 0xb1, 0xb1, 0x93, 0xf3, 0xbd, 0x3d, 0x5e, 0xe2, 0x19, 0xc9, 0xa9, 0x59, 0x8c, 0xd0, 0x1d, 0x5d, 0xb6, - 0xb7, 0x47, 0xdd, 0x98, 0x26, 0x13, 0x1e, 0x11, 0x42, 0x3a, 0x3d, 0xb6, 0xb7, 0xe7, 0x70, 0x12, 0x71, 0x77, 0x42, - 0xb9, 0x43, 0x11, 0xc2, 0x55, 0xeb, 0xbd, 0x3d, 0x47, 0x22, 0x21, 0x25, 0x12, 0x71, 0x35, 0x1c, 0x23, 0x57, 0x61, - 0xff, 0xec, 0x36, 0x09, 0x1c, 0x13, 0x7e, 0x84, 0xd9, 0xde, 0x5e, 0xc4, 0xdd, 0x1c, 0x7a, 0xc4, 0x1c, 0xa1, 0x22, - 0xa3, 0x7c, 0x9e, 0x25, 0x16, 0x2f, 0x78, 0x7a, 0xc6, 0x33, 0x96, 0x4c, 0x1c, 0xb4, 0xd0, 0x65, 0x46, 0xc3, 0xa2, - 0x90, 0xe0, 0xbe, 0xe3, 0x24, 0x21, 0x7d, 0x18, 0xf1, 0x84, 0x3b, 0xb0, 0x8a, 0xe9, 0xd8, 0x4a, 0x08, 0xb1, 0x73, - 0xd1, 0xd6, 0x1e, 0x24, 0x5e, 0xd2, 0xb0, 0x6d, 0x2c, 0xa1, 0xc4, 0x09, 0x47, 0xf8, 0x23, 0x71, 0x12, 0xec, 0xba, - 0x2e, 0x47, 0xa4, 0xbf, 0xd0, 0x58, 0x49, 0x8c, 0x79, 0x0e, 0x92, 0x61, 0x7b, 0xe4, 0x71, 0x37, 0xa3, 0xe1, 0x3c, - 0xa0, 0x8e, 0xc3, 0x70, 0x8e, 0x33, 0x44, 0xfa, 0xac, 0xe1, 0xa4, 0xa4, 0x0f, 0xcb, 0x9d, 0xd6, 0xd7, 0x9a, 0x90, - 0x9d, 0x36, 0x52, 0x30, 0xa6, 0x1a, 0x40, 0xc0, 0xb0, 0x82, 0x27, 0x25, 0xc4, 0x4e, 0xe6, 0xd3, 0x4b, 0x9a, 0xd9, - 0x65, 0xb5, 0x5e, 0x8d, 0x2c, 0xe6, 0x39, 0xb5, 0x82, 0x3c, 0xb7, 0xc6, 0xf3, 0x24, 0xe0, 0x2c, 0x4d, 0x2c, 0xbb, - 0x91, 0x36, 0x6c, 0x49, 0x0e, 0x25, 0x35, 0xd8, 0xa8, 0x40, 0x4e, 0x8e, 0x1a, 0xc9, 0x30, 0x6b, 0x74, 0x46, 0x18, - 0xa0, 0x44, 0x3d, 0xd5, 0x9f, 0x42, 0x00, 0xc5, 0x09, 0xcc, 0xb1, 0xc0, 0x4f, 0x38, 0xcc, 0x52, 0x4c, 0x31, 0xe7, - 0x83, 0xc4, 0x5d, 0xdf, 0x28, 0x84, 0xbb, 0x53, 0x7f, 0xe6, 0x50, 0xd2, 0xa7, 0x82, 0xb8, 0xfc, 0x24, 0x00, 0x58, - 0x6b, 0xeb, 0x36, 0xa0, 0x1e, 0x75, 0x2b, 0x92, 0x42, 0x1e, 0x77, 0xc7, 0x69, 0xf6, 0xcc, 0x0f, 0x22, 0x68, 0x57, - 0x12, 0x4c, 0xa8, 0xf7, 0x5b, 0x90, 0x51, 0x9f, 0xd3, 0x67, 0x31, 0x85, 0x27, 0xc7, 0x16, 0x2d, 0x6d, 0x84, 0x73, - 0xf2, 0xd4, 0x8d, 0x19, 0x7f, 0x9d, 0x26, 0x01, 0xed, 0xe5, 0x06, 0x75, 0x31, 0x58, 0xf7, 0x13, 0xce, 0x33, 0x76, - 0x39, 0xe7, 0xd4, 0xb1, 0x13, 0xa8, 0x61, 0xe3, 0x1c, 0x61, 0xe6, 0x72, 0x7a, 0xc3, 0x4f, 0xd3, 0x84, 0xd3, 0x84, - 0x13, 0xaa, 0x91, 0x8a, 0x13, 0xd7, 0x9f, 0xcd, 0x68, 0x12, 0x9e, 0x46, 0x2c, 0x0e, 0x1d, 0x86, 0x0a, 0x54, 0xe0, - 0x80, 0x13, 0x98, 0x23, 0xe9, 0x27, 0x1e, 0xfc, 0xd9, 0x3e, 0x1b, 0x87, 0x93, 0xbe, 0xd8, 0x14, 0x94, 0xd8, 0x76, - 0x6f, 0x9c, 0x66, 0x8e, 0x9a, 0x81, 0x95, 0x8e, 0x2d, 0x0e, 0x63, 0xbc, 0x9b, 0xc7, 0x34, 0x47, 0xb4, 0x41, 0x58, - 0xb9, 0x8c, 0x0a, 0xc1, 0xef, 0x80, 0xe2, 0x0b, 0xe4, 0x24, 0xc8, 0x4b, 0x7a, 0x57, 0x7e, 0x66, 0xfd, 0xa8, 0x76, - 0xd4, 0xcf, 0x9a, 0x9b, 0x85, 0x9c, 0xfc, 0xec, 0xf2, 0x6c, 0x9e, 0x73, 0x1a, 0xbe, 0xbf, 0x9d, 0xd1, 0x1c, 0x3f, - 0xe7, 0x24, 0xe4, 0x83, 0x90, 0xbb, 0x74, 0x3a, 0xe3, 0xb7, 0x67, 0x82, 0x31, 0x7a, 0xb6, 0x8d, 0xe7, 0x50, 0x33, - 0xa3, 0x7e, 0x00, 0xcc, 0x4c, 0x61, 0xeb, 0x6d, 0x1a, 0xdf, 0x8e, 0x59, 0x1c, 0x9f, 0xcd, 0x67, 0xb3, 0x34, 0xe3, - 0x98, 0x73, 0xb2, 0xe0, 0x69, 0x85, 0x1b, 0x58, 0xcc, 0x45, 0x7e, 0xcd, 0x78, 0x10, 0x39, 0x1c, 0x2d, 0x02, 0x3f, - 0xa7, 0xd6, 0x93, 0x34, 0x8d, 0xa9, 0x0f, 0xb3, 0x4e, 0x06, 0xcf, 0xb9, 0x97, 0xcc, 0xe3, 0xb8, 0x77, 0x99, 0x51, - 0xff, 0x63, 0x4f, 0xbc, 0x7e, 0x73, 0xf9, 0x33, 0x0d, 0xb8, 0x27, 0x7e, 0x9f, 0x64, 0x99, 0x7f, 0x0b, 0x15, 0x09, - 0x81, 0x6a, 0x83, 0xc4, 0xfb, 0xeb, 0xd9, 0x9b, 0xd7, 0xae, 0xdc, 0x25, 0x6c, 0x7c, 0xeb, 0x24, 0xe5, 0xce, 0x4b, - 0x0a, 0x3c, 0xce, 0xd2, 0xe9, 0xca, 0xd0, 0x12, 0x6d, 0x49, 0x6f, 0x0b, 0x08, 0x94, 0x24, 0x3b, 0xb2, 0x6b, 0x13, - 0x82, 0xd7, 0x82, 0xe8, 0xe1, 0x25, 0xd1, 0xe3, 0xce, 0xe3, 0xd8, 0x93, 0xc5, 0x4e, 0x82, 0xee, 0x86, 0x96, 0x67, - 0xb7, 0x0b, 0x4a, 0x04, 0x9c, 0x33, 0x10, 0x31, 0x00, 0x63, 0xe0, 0xf3, 0x20, 0x5a, 0x50, 0xd1, 0x59, 0xa1, 0x21, - 0xa6, 0x45, 0x81, 0x9f, 0x95, 0x04, 0xcf, 0x01, 0x10, 0xc1, 0xa9, 0x08, 0x5f, 0x2e, 0x61, 0xc2, 0x08, 0xff, 0x95, - 0x2c, 0x7c, 0x3d, 0x1f, 0x6f, 0xa7, 0x8d, 0x61, 0x63, 0x7a, 0x92, 0xbd, 0xe0, 0x20, 0x4d, 0xae, 0x68, 0xc6, 0x69, - 0xe6, 0x71, 0x8e, 0x33, 0x3a, 0x8e, 0x01, 0x8c, 0x9d, 0x0e, 0x8e, 0xfc, 0xfc, 0x34, 0xf2, 0x93, 0x09, 0x0d, 0xbd, - 0x67, 0xbc, 0xc0, 0x94, 0x13, 0x7b, 0xcc, 0x12, 0x3f, 0x66, 0xbf, 0xd2, 0xd0, 0x56, 0x02, 0xe1, 0x99, 0x45, 0x6f, - 0x38, 0x4d, 0xc2, 0xdc, 0x7a, 0xfe, 0xfe, 0xd5, 0x4b, 0xb5, 0x94, 0x35, 0x19, 0x81, 0x16, 0xf9, 0x7c, 0x46, 0x33, - 0x07, 0x61, 0x25, 0x23, 0x9e, 0x31, 0xc1, 0x1f, 0x5f, 0xf9, 0x33, 0x59, 0xc2, 0xf2, 0x0f, 0xb3, 0xd0, 0xe7, 0xf4, - 0x2d, 0x4d, 0x42, 0x96, 0x4c, 0xc8, 0x4e, 0x47, 0x96, 0x47, 0xbe, 0x7a, 0x11, 0x96, 0x45, 0xe7, 0xbb, 0xcf, 0x62, - 0x31, 0xf3, 0xf2, 0x71, 0xee, 0xa0, 0x22, 0xe7, 0x3e, 0x67, 0x81, 0xe5, 0x87, 0xe1, 0x8b, 0x84, 0x71, 0x26, 0x00, - 0xcc, 0x60, 0x81, 0x80, 0x4a, 0xa9, 0x94, 0x16, 0x1a, 0x70, 0x07, 0x61, 0xc7, 0x51, 0x32, 0x20, 0x42, 0x6a, 0xc5, - 0xf6, 0xf6, 0x2a, 0x8e, 0x3f, 0xa0, 0x9e, 0x7c, 0x49, 0x86, 0x23, 0xe4, 0xce, 0xe6, 0x39, 0x2c, 0xb5, 0x1e, 0x02, - 0x04, 0x4c, 0x7a, 0x99, 0xd3, 0xec, 0x8a, 0x86, 0x25, 0x79, 0xe4, 0x0e, 0x5a, 0xac, 0x8c, 0xa1, 0x76, 0x06, 0x27, - 0xc3, 0x51, 0xcf, 0x64, 0xdd, 0x54, 0x91, 0x7a, 0x96, 0xce, 0x68, 0xc6, 0x19, 0xcd, 0x4b, 0x6e, 0xe2, 0x80, 0x20, - 0x2d, 0x39, 0x4a, 0x4e, 0xf4, 0xfc, 0x66, 0x0e, 0xc3, 0x14, 0xd5, 0x78, 0x86, 0x96, 0xb5, 0xcf, 0xae, 0x84, 0xd0, - 0xc8, 0x31, 0x43, 0x98, 0x4b, 0x48, 0x73, 0x84, 0x0a, 0x84, 0xb9, 0x06, 0x57, 0x72, 0x23, 0x35, 0xda, 0x2d, 0x48, - 0x6b, 0xf2, 0x57, 0x21, 0xad, 0x81, 0xa7, 0xf9, 0x9c, 0xee, 0xed, 0x39, 0xd4, 0x2d, 0xc9, 0x82, 0xec, 0x74, 0xd4, - 0x1a, 0x19, 0xc8, 0xda, 0x02, 0x36, 0x0c, 0xcc, 0x31, 0x45, 0x78, 0x87, 0xba, 0x49, 0x7a, 0x12, 0x04, 0x34, 0xcf, - 0xd3, 0x6c, 0x6f, 0x6f, 0x47, 0xd4, 0x2f, 0x15, 0x0a, 0x58, 0xc3, 0x37, 0xd7, 0x49, 0x05, 0x01, 0xaa, 0x84, 0xac, - 0x12, 0x0d, 0x1c, 0x44, 0x95, 0xd0, 0x39, 0xec, 0x81, 0xd6, 0x3d, 0x3c, 0xfb, 0xfc, 0xdc, 0x6e, 0x70, 0xac, 0xd0, - 0x30, 0xa1, 0x7a, 0xe8, 0xdb, 0xa7, 0x54, 0x6a, 0x57, 0x42, 0xf7, 0x58, 0xc3, 0x8c, 0xdc, 0x41, 0x6e, 0x48, 0xc7, - 0x2c, 0x31, 0xa6, 0x5d, 0x03, 0x09, 0x73, 0x9c, 0xa3, 0xc2, 0x58, 0xd0, 0x8d, 0x5d, 0x0b, 0xb5, 0x46, 0xae, 0xdc, - 0x62, 0x22, 0x54, 0x09, 0x63, 0x19, 0x87, 0x74, 0x54, 0x60, 0x81, 0x7a, 0x3d, 0x9b, 0x4c, 0x00, 0x3a, 0xe4, 0xa3, - 0x9e, 0x7a, 0x4f, 0x72, 0x89, 0xb9, 0x8c, 0xfe, 0x32, 0xa7, 0x39, 0x97, 0x74, 0xec, 0x70, 0x9c, 0x61, 0x06, 0xfc, - 0x3a, 0x4d, 0xc6, 0x6c, 0x32, 0xcf, 0x40, 0xe3, 0x81, 0xcd, 0x48, 0x93, 0xf9, 0x94, 0xea, 0xa7, 0x4d, 0xb0, 0xbd, - 0x99, 0x81, 0x4c, 0xcc, 0x81, 0xa6, 0xef, 0x26, 0x27, 0x80, 0x95, 0xa3, 0xe5, 0xf2, 0xaf, 0xba, 0x93, 0x6a, 0x29, - 0x4b, 0x2d, 0x6d, 0x65, 0x4d, 0x28, 0x47, 0x4a, 0x26, 0xef, 0x74, 0x14, 0xf8, 0x7c, 0x44, 0x76, 0xda, 0x25, 0x0d, - 0x2b, 0xac, 0x4a, 0x70, 0x24, 0x12, 0xdf, 0xc8, 0xae, 0x90, 0x10, 0xf1, 0x35, 0x72, 0x71, 0xa3, 0x35, 0x4a, 0x8d, - 0xc8, 0x10, 0x94, 0x0d, 0x37, 0x1a, 0x6d, 0x23, 0x27, 0xcd, 0x0f, 0x1c, 0xbe, 0xfe, 0xae, 0x62, 0x1b, 0x57, 0x75, - 0xb6, 0xb1, 0x32, 0x0d, 0x7b, 0x56, 0x36, 0xb1, 0x4b, 0x2a, 0x53, 0x1b, 0xbd, 0x7a, 0x85, 0x99, 0x00, 0xa6, 0x9a, - 0x92, 0xd1, 0xc5, 0x6b, 0x7f, 0x4a, 0x73, 0x87, 0x22, 0xbc, 0xad, 0x82, 0x24, 0x4f, 0xa8, 0x32, 0x32, 0x64, 0x67, - 0x0e, 0xb2, 0x93, 0x21, 0xa9, 0x9a, 0xd5, 0x37, 0x5c, 0x8e, 0xe9, 0x30, 0x1f, 0x55, 0x1a, 0x9d, 0x31, 0x79, 0x21, - 0x94, 0x15, 0x7d, 0x6b, 0xfc, 0xc9, 0x32, 0x89, 0x34, 0xa1, 0x39, 0xe4, 0x08, 0xef, 0xb4, 0x57, 0x57, 0x52, 0xd7, - 0xaa, 0xe6, 0x38, 0x1c, 0xc1, 0x3a, 0x08, 0x91, 0xe1, 0xb2, 0x5c, 0xfc, 0x5b, 0xdb, 0x69, 0x80, 0xb6, 0x33, 0x20, - 0x0c, 0x77, 0x1c, 0xfb, 0xdc, 0xe9, 0xb4, 0xda, 0xa0, 0x8e, 0x5e, 0x51, 0x90, 0x28, 0x08, 0xad, 0x4f, 0x85, 0xba, - 0xf3, 0x24, 0x8f, 0xd8, 0x98, 0x3b, 0x01, 0x17, 0x2c, 0x85, 0xc6, 0x39, 0xb5, 0x78, 0x4d, 0x29, 0x16, 0xec, 0x26, - 0x00, 0x62, 0x2b, 0x35, 0x30, 0xaa, 0x21, 0x15, 0x6c, 0x0b, 0xb8, 0x43, 0xa5, 0x50, 0x57, 0x5c, 0x46, 0xd7, 0x66, - 0xa0, 0x34, 0x76, 0x06, 0xb2, 0x47, 0x4f, 0x31, 0x03, 0x66, 0xe8, 0xad, 0xcc, 0x33, 0x39, 0x84, 0x2a, 0xe4, 0x2e, - 0x4f, 0x5f, 0xa6, 0xd7, 0x34, 0x3b, 0xf5, 0x01, 0x78, 0x4f, 0x36, 0x2f, 0xa4, 0x20, 0x10, 0xfc, 0x9e, 0xf7, 0x34, - 0xbd, 0x9c, 0x8b, 0x89, 0xbf, 0xcd, 0xd2, 0x29, 0xcb, 0x29, 0xa8, 0x6b, 0x12, 0xff, 0x09, 0xec, 0x33, 0xb1, 0x21, - 0x41, 0xd8, 0xd0, 0x92, 0xbe, 0x4e, 0x5e, 0xd6, 0xe9, 0xeb, 0x7c, 0xf7, 0xd9, 0x44, 0x33, 0xc0, 0xfa, 0x36, 0x46, - 0xd8, 0x51, 0x46, 0x85, 0x21, 0xe7, 0xdc, 0x08, 0x29, 0x11, 0xbf, 0x5c, 0x72, 0xc3, 0x76, 0xab, 0x29, 0x8c, 0x54, - 0x6e, 0x1b, 0x54, 0xf8, 0x61, 0x08, 0xaa, 0x5d, 0x96, 0xc6, 0xb1, 0x21, 0xaa, 0x30, 0xeb, 0x95, 0xc2, 0xe9, 0x7c, - 0xf7, 0xd9, 0xd9, 0x5d, 0xf2, 0x09, 0xde, 0x9b, 0x22, 0x4a, 0x03, 0x9a, 0x84, 0x34, 0x03, 0x5b, 0xd2, 0x58, 0x2d, - 0x25, 0x65, 0x4f, 0xd3, 0x24, 0xa1, 0x01, 0xa7, 0x21, 0x98, 0x2a, 0x8c, 0x70, 0x37, 0x4a, 0x73, 0x5e, 0x16, 0x56, - 0xd0, 0x33, 0x03, 0x7a, 0xe6, 0x06, 0x7e, 0x1c, 0x3b, 0xd2, 0x2c, 0x99, 0xa6, 0x57, 0x74, 0x03, 0xd4, 0xbd, 0x1a, - 0xc8, 0x65, 0x37, 0xd4, 0xe8, 0x86, 0xba, 0xf9, 0x2c, 0x66, 0x01, 0x2d, 0x45, 0xd7, 0x99, 0xcb, 0x92, 0x90, 0xde, - 0x00, 0x1f, 0x41, 0xfd, 0x7e, 0xbf, 0x8d, 0x3b, 0xa8, 0x90, 0x08, 0x5f, 0xac, 0x21, 0xf6, 0x0e, 0xa1, 0x09, 0x44, - 0x46, 0xfa, 0x8b, 0x8d, 0x6c, 0x0d, 0x19, 0x92, 0x92, 0x69, 0xf3, 0x4a, 0x72, 0x67, 0x84, 0x43, 0x1a, 0x53, 0x4e, - 0x35, 0x37, 0x07, 0x25, 0x5a, 0x6e, 0xdd, 0x77, 0x25, 0xfe, 0x4a, 0x72, 0xd2, 0xbb, 0x4c, 0xaf, 0x79, 0x5e, 0x9a, - 0xeb, 0xd5, 0xf2, 0x54, 0xd8, 0x1e, 0x70, 0xb9, 0x3c, 0x3e, 0xe7, 0x7e, 0x10, 0x49, 0x3b, 0xdd, 0x59, 0x9b, 0x52, - 0xd5, 0x87, 0xe2, 0xec, 0xe5, 0x26, 0x7a, 0xa2, 0xc1, 0xdc, 0x84, 0x82, 0x33, 0xc5, 0x14, 0x28, 0x98, 0x7e, 0x72, - 0xd9, 0x4e, 0xfd, 0x38, 0xbe, 0xf4, 0x83, 0x8f, 0x75, 0xea, 0xaf, 0xc8, 0x80, 0xac, 0x72, 0x63, 0xe3, 0x95, 0xc1, - 0xb2, 0xcc, 0x79, 0x6b, 0x2e, 0x5d, 0xdb, 0x28, 0xce, 0x4e, 0xbb, 0x22, 0xfb, 0xfa, 0x42, 0x6f, 0xa5, 0x76, 0x01, - 0x11, 0x53, 0x33, 0x73, 0x80, 0x0b, 0x7c, 0x92, 0xe2, 0x34, 0x3f, 0x50, 0x74, 0x07, 0x06, 0x47, 0xb1, 0x02, 0x08, - 0x47, 0x8b, 0x22, 0x64, 0xf9, 0x76, 0x0c, 0xfc, 0x21, 0x50, 0x3e, 0x35, 0x46, 0xb8, 0x2f, 0xa0, 0x25, 0x8f, 0x53, - 0x5a, 0x73, 0x09, 0x99, 0xd2, 0x27, 0x34, 0xa3, 0xf9, 0x06, 0x74, 0x17, 0x41, 0xef, 0x6f, 0xe4, 0x2b, 0xd0, 0xca, - 0x00, 0x8a, 0xbc, 0x67, 0xaa, 0x13, 0x35, 0x0a, 0x50, 0x3c, 0x95, 0x09, 0x91, 0x9b, 0xd5, 0x2c, 0x48, 0xa5, 0xb1, - 0x4b, 0x23, 0x5c, 0xb1, 0xdc, 0x94, 0x38, 0x8e, 0x93, 0x83, 0x11, 0xa7, 0x75, 0xfb, 0x6a, 0x12, 0xf9, 0xda, 0x24, - 0x72, 0xd7, 0x30, 0xb4, 0x50, 0x45, 0xcb, 0x46, 0x73, 0x8f, 0x73, 0x64, 0xd6, 0x02, 0x7d, 0xd5, 0x05, 0x06, 0x8d, - 0x4a, 0x7e, 0x1b, 0x13, 0x8e, 0x53, 0x65, 0xe5, 0x28, 0x52, 0x03, 0x8e, 0x51, 0x35, 0xc9, 0x90, 0xdc, 0x1b, 0x35, - 0x93, 0x37, 0xc3, 0x29, 0x5a, 0x51, 0xee, 0x8b, 0x42, 0x21, 0x89, 0x22, 0xb5, 0x38, 0x35, 0xad, 0xd8, 0x40, 0x0b, - 0xce, 0x88, 0xd2, 0x84, 0xa5, 0xe2, 0xb3, 0x8a, 0x9c, 0xb2, 0xdf, 0x1d, 0x42, 0xb2, 0x0a, 0x37, 0x35, 0x95, 0x52, - 0xeb, 0x56, 0x19, 0xc2, 0x91, 0x56, 0x4a, 0xd3, 0x6a, 0xe2, 0x84, 0xd8, 0xda, 0x27, 0x61, 0x0f, 0x16, 0x35, 0xbb, - 0xd0, 0x33, 0xaa, 0x15, 0x1e, 0xf0, 0xd4, 0x74, 0x13, 0xbe, 0x37, 0x11, 0x4d, 0xad, 0x1f, 0x03, 0xe3, 0x69, 0x0d, - 0xe3, 0x06, 0x6a, 0x33, 0xc9, 0xbb, 0xb2, 0x11, 0x89, 0xea, 0x8d, 0x1d, 0x8a, 0x53, 0xb9, 0x10, 0x6b, 0x58, 0x5c, - 0x55, 0x3e, 0x05, 0x11, 0x82, 0x19, 0x9b, 0x83, 0x7a, 0x67, 0x4a, 0x08, 0x07, 0x80, 0x67, 0xcb, 0xe5, 0x1a, 0xd9, - 0x6d, 0xd4, 0x41, 0x91, 0x5b, 0x59, 0x86, 0xcb, 0xe5, 0x33, 0x8e, 0x1c, 0xa5, 0xfd, 0x62, 0x8a, 0x06, 0x9a, 0xe7, - 0x9e, 0xbc, 0x84, 0x5a, 0x42, 0x19, 0xad, 0x4a, 0x4a, 0xb3, 0xa1, 0x4e, 0xb5, 0xf5, 0x85, 0xe2, 0x06, 0xe3, 0x3e, - 0x5d, 0xe3, 0x5f, 0xa2, 0x50, 0x09, 0xea, 0x6a, 0xca, 0xa7, 0xaa, 0x6b, 0x86, 0x10, 0xf2, 0x72, 0x61, 0xc9, 0xec, - 0x6c, 0x32, 0x2e, 0xf7, 0xf6, 0x72, 0xa3, 0xa3, 0xf3, 0x92, 0x51, 0xfc, 0xec, 0x80, 0x50, 0xce, 0x6f, 0x13, 0xa1, - 0xbd, 0xfc, 0xac, 0xc5, 0xd0, 0x9a, 0x69, 0xda, 0xee, 0x81, 0x4d, 0xee, 0x5f, 0xfb, 0x8c, 0x5b, 0x65, 0x2f, 0xd2, - 0x26, 0x77, 0x28, 0x5a, 0x28, 0x65, 0xc3, 0xcd, 0x28, 0xa8, 0x8f, 0xc0, 0x15, 0xb4, 0x12, 0x2d, 0x09, 0x3f, 0x88, - 0x28, 0xf8, 0x83, 0xb5, 0x1e, 0x51, 0xda, 0x86, 0x3b, 0x4a, 0x8e, 0xa8, 0x8e, 0x37, 0xc3, 0x5e, 0xac, 0x36, 0xaf, - 0xd9, 0x02, 0x33, 0x9a, 0x8d, 0xd3, 0x6c, 0xaa, 0xdf, 0x15, 0x2b, 0xcf, 0x8a, 0x37, 0xb2, 0xb1, 0xb3, 0xb1, 0x6f, - 0x65, 0x01, 0xf4, 0x56, 0x0c, 0xef, 0xca, 0x64, 0xaf, 0x09, 0xd3, 0x52, 0xfe, 0x4a, 0xb7, 0xa0, 0xa6, 0xcc, 0xdc, - 0x34, 0xf1, 0x95, 0x4f, 0xb5, 0x27, 0xdd, 0x26, 0x3b, 0x9d, 0x5e, 0x69, 0xf7, 0x69, 0x6a, 0xe8, 0x49, 0xf7, 0x86, - 0x12, 0xaa, 0xe9, 0x3c, 0x0e, 0x15, 0xb0, 0x0c, 0x61, 0xaa, 0xe8, 0xe8, 0x9a, 0xc5, 0x71, 0x55, 0xfa, 0x39, 0x9c, - 0x3d, 0x57, 0x9c, 0x3d, 0xd3, 0x9c, 0x1d, 0x58, 0x05, 0x70, 0x76, 0xd9, 0x5d, 0xd5, 0x3c, 0x5b, 0xdb, 0x9e, 0x99, - 0xe4, 0xe9, 0xb9, 0xb0, 0xa5, 0x61, 0xbc, 0xb9, 0x86, 0x00, 0x95, 0xba, 0xd7, 0x47, 0x47, 0xb9, 0x62, 0xc0, 0x08, - 0x94, 0x9e, 0x4c, 0x6a, 0xba, 0x29, 0x3e, 0x3a, 0x08, 0xe7, 0x05, 0x2d, 0x29, 0xfb, 0xe4, 0x19, 0xf8, 0xea, 0x8c, - 0xe9, 0x80, 0x18, 0x13, 0xc5, 0x9f, 0xa5, 0x46, 0xe9, 0xd9, 0x31, 0x35, 0xbb, 0x5c, 0xcf, 0x0e, 0x78, 0x7d, 0x35, - 0xbb, 0xf0, 0x6e, 0x6e, 0x2f, 0xa6, 0xc7, 0xca, 0xe9, 0x55, 0xeb, 0xbd, 0x5c, 0x3a, 0x2b, 0x25, 0xe0, 0xc6, 0x57, - 0x46, 0x4a, 0x56, 0xf6, 0x0e, 0x3c, 0xc0, 0xc4, 0x0c, 0x14, 0x14, 0x72, 0xd2, 0xa5, 0x90, 0x7b, 0xf9, 0x29, 0x27, - 0x8f, 0xf0, 0xd6, 0xcb, 0xf6, 0xa7, 0xe9, 0x74, 0x06, 0xfa, 0xd8, 0x0a, 0x49, 0x4f, 0xa8, 0x1a, 0xb0, 0x7a, 0x5f, - 0x6c, 0x28, 0xab, 0xb5, 0x11, 0xfb, 0xb1, 0x46, 0x4d, 0xa5, 0xcd, 0xbc, 0xd3, 0x2e, 0xe6, 0x65, 0x51, 0xc9, 0x38, - 0x36, 0x39, 0x56, 0x4e, 0x57, 0xdd, 0x32, 0xfa, 0xc5, 0x1b, 0x87, 0x49, 0x3e, 0xcc, 0x80, 0xd7, 0x19, 0xec, 0x47, - 0x93, 0xbb, 0xb9, 0xfe, 0x45, 0x85, 0x9c, 0x45, 0xb1, 0x82, 0xbe, 0x45, 0x51, 0x3c, 0x53, 0x76, 0x36, 0x7e, 0xb6, - 0xdd, 0x20, 0xae, 0xde, 0x29, 0x7b, 0x71, 0x38, 0xc2, 0xcf, 0xd6, 0xb5, 0x47, 0xb2, 0x98, 0xa6, 0x21, 0xf5, 0xec, - 0x74, 0x46, 0x13, 0xbb, 0x00, 0xef, 0xaa, 0x5a, 0xfc, 0x39, 0x77, 0x16, 0xef, 0xea, 0x6e, 0x56, 0xef, 0x59, 0x01, - 0x2e, 0xb0, 0x1f, 0xd7, 0x1d, 0xb0, 0xdf, 0xd2, 0x2c, 0x17, 0xba, 0x68, 0xa9, 0xd6, 0xfe, 0x58, 0x09, 0xa6, 0x1f, - 0xbd, 0xad, 0xf5, 0x2b, 0x2b, 0xc4, 0xee, 0xb8, 0x0f, 0xdd, 0x7d, 0x1b, 0x09, 0xf7, 0xf0, 0x37, 0x6a, 0xc7, 0xff, - 0xa2, 0xdd, 0xc3, 0x67, 0xe4, 0x97, 0xba, 0x77, 0x78, 0xc6, 0xc9, 0xd9, 0xe0, 0x4c, 0x1b, 0xcd, 0x69, 0xcc, 0x82, - 0x5b, 0xc7, 0x8e, 0x19, 0x6f, 0x42, 0x08, 0xce, 0xc6, 0x0b, 0xf9, 0x02, 0xfc, 0x8a, 0xc2, 0xad, 0x5d, 0x68, 0x73, - 0x0f, 0x33, 0x4e, 0xec, 0xdd, 0x98, 0xf1, 0x5d, 0x1b, 0xdf, 0x92, 0x0b, 0xf8, 0xb1, 0xbb, 0x70, 0x5e, 0xf9, 0x3c, - 0x72, 0x33, 0x3f, 0x09, 0xd3, 0xa9, 0x83, 0x1a, 0xb6, 0x8d, 0xdc, 0x5c, 0x98, 0x1c, 0x8f, 0x51, 0xb1, 0x7b, 0x81, - 0xcf, 0x38, 0xb1, 0x07, 0x76, 0xe3, 0x16, 0xbf, 0xe2, 0xe4, 0xe2, 0x78, 0x77, 0x71, 0xc6, 0x8b, 0xfe, 0x05, 0x3e, - 0x29, 0x3d, 0xf7, 0xf8, 0x35, 0x71, 0x10, 0xe9, 0x9f, 0x28, 0x68, 0x4e, 0xd3, 0xa9, 0xf4, 0xe0, 0xdb, 0x08, 0xbf, - 0x13, 0xf1, 0x95, 0x8a, 0xdd, 0xa8, 0x10, 0xcb, 0x0e, 0xb1, 0x53, 0xe1, 0x25, 0xb0, 0xf7, 0xf6, 0x8c, 0xb2, 0x52, - 0x59, 0xc0, 0xa7, 0x9c, 0xd4, 0x6c, 0x72, 0xfc, 0x52, 0x44, 0x6a, 0x4e, 0xb9, 0x93, 0x20, 0xdd, 0x8d, 0xa3, 0xdd, - 0xd1, 0x6a, 0x6f, 0x26, 0x43, 0xe9, 0x64, 0x70, 0x19, 0xa7, 0x99, 0xcf, 0xd3, 0x6c, 0x84, 0x4c, 0x05, 0x04, 0xff, - 0x8d, 0x5c, 0x0c, 0xad, 0xff, 0xf4, 0xc5, 0x4f, 0xe3, 0x9f, 0xb2, 0xd1, 0x05, 0xfe, 0x40, 0x5a, 0xc7, 0xce, 0xc0, - 0x73, 0x76, 0x9a, 0xcd, 0xe5, 0x4f, 0xad, 0xe1, 0xdf, 0xfd, 0xe6, 0xaf, 0x27, 0xcd, 0x1f, 0x47, 0x68, 0xe9, 0xfc, - 0xd4, 0x1a, 0x0c, 0xd5, 0xd3, 0xf0, 0xef, 0xfd, 0x9f, 0xf2, 0xd1, 0x57, 0xb2, 0x70, 0x17, 0xa1, 0xd6, 0x04, 0x4f, - 0x38, 0x69, 0x35, 0x9b, 0xfd, 0xd6, 0x04, 0x4f, 0x39, 0x69, 0xc1, 0xbf, 0xd7, 0xe4, 0x1d, 0x9d, 0x3c, 0xbb, 0x99, - 0x39, 0x17, 0xfd, 0xe5, 0xee, 0xe2, 0x6f, 0x05, 0xf4, 0x3a, 0xfc, 0xfb, 0x4f, 0x3f, 0xe5, 0xf6, 0x83, 0x3e, 0x69, - 0x8d, 0x1a, 0xc8, 0x81, 0xd2, 0xaf, 0x88, 0xf8, 0xeb, 0x0c, 0xbc, 0xe1, 0xdf, 0x15, 0x14, 0xf6, 0x83, 0x9f, 0x2e, - 0x8e, 0xfb, 0x64, 0xb4, 0x74, 0xec, 0xe5, 0x03, 0xb4, 0x44, 0x68, 0xb9, 0x8b, 0x2e, 0xb0, 0x3d, 0xb1, 0x11, 0x1e, - 0x73, 0xd2, 0x7a, 0xd0, 0x9a, 0xe0, 0x73, 0x4e, 0x5a, 0x76, 0x6b, 0x82, 0xdf, 0x70, 0xd2, 0xfa, 0xbb, 0x33, 0xf0, - 0xa4, 0x9b, 0x6d, 0x29, 0x3c, 0x1c, 0x4b, 0x08, 0x72, 0xf8, 0x19, 0xf5, 0x97, 0x9c, 0xf1, 0x98, 0xa2, 0xdd, 0x16, - 0xc3, 0x1f, 0x05, 0x9a, 0x1c, 0x0e, 0x7e, 0x18, 0x30, 0xef, 0x9c, 0xc5, 0x39, 0x2c, 0x36, 0xd0, 0xcc, 0xae, 0x97, - 0x60, 0xe9, 0x0a, 0xc8, 0x3d, 0x8e, 0xaf, 0xfc, 0x78, 0x4e, 0x73, 0x8f, 0x16, 0x08, 0xc7, 0xe4, 0x23, 0x77, 0x3a, - 0x08, 0xbf, 0xe0, 0xf0, 0xa3, 0x8b, 0xf0, 0xa9, 0x0a, 0x64, 0xc2, 0x4e, 0x96, 0x44, 0x95, 0xa4, 0x52, 0x65, 0xb1, - 0x11, 0x9e, 0x6c, 0x78, 0xc9, 0x23, 0x70, 0x30, 0x20, 0x7c, 0x55, 0x0b, 0x7b, 0xe2, 0x1b, 0xa2, 0x49, 0xe2, 0x7d, - 0x46, 0xe9, 0x77, 0x7e, 0xfc, 0x91, 0x66, 0xce, 0x09, 0xee, 0x74, 0x1f, 0x63, 0xe1, 0x87, 0xde, 0xe9, 0xa0, 0x5e, - 0x19, 0xb3, 0x7a, 0xcb, 0x65, 0xa8, 0x00, 0xa4, 0x6c, 0xdd, 0x1d, 0x03, 0x2b, 0xbe, 0x93, 0xac, 0xf9, 0xac, 0x32, - 0xff, 0xda, 0x46, 0xf5, 0xf8, 0x28, 0x4b, 0xae, 0xfc, 0x98, 0x85, 0x16, 0xa7, 0xd3, 0x59, 0xec, 0x73, 0x6a, 0xa9, - 0xf9, 0x5a, 0x3e, 0x74, 0x64, 0x97, 0x3a, 0xc3, 0xcc, 0xb0, 0x39, 0x67, 0x3a, 0xf0, 0x04, 0x7b, 0xc5, 0x81, 0x28, - 0x95, 0xd2, 0x3b, 0x9e, 0x56, 0x41, 0xb0, 0xd5, 0x38, 0x5f, 0xb3, 0x03, 0xbe, 0xb0, 0x91, 0x90, 0xcf, 0x39, 0xce, - 0x08, 0x48, 0xd1, 0xee, 0xc0, 0x3e, 0xce, 0xaf, 0x26, 0x7d, 0x1b, 0x62, 0x34, 0x29, 0xf9, 0x20, 0x5c, 0x43, 0x50, - 0x21, 0x22, 0xed, 0x5e, 0x74, 0x4c, 0x7b, 0x51, 0xa3, 0xa1, 0xb5, 0x68, 0x9f, 0x24, 0xc3, 0x48, 0x36, 0x0f, 0x70, - 0x88, 0xe7, 0xa4, 0xd9, 0xc1, 0x33, 0xd2, 0x16, 0x4d, 0x7a, 0xb3, 0x63, 0x5f, 0x0d, 0xb3, 0xb7, 0xe7, 0xa4, 0x6e, - 0xec, 0xe7, 0xfc, 0x05, 0xd8, 0xfb, 0x64, 0x86, 0x43, 0x92, 0xba, 0xf4, 0x86, 0x06, 0x8e, 0x8f, 0x70, 0xa8, 0x38, - 0x0d, 0xea, 0xa1, 0x19, 0x31, 0xaa, 0x81, 0x19, 0x41, 0x3e, 0x0c, 0xc2, 0x61, 0x67, 0x44, 0x08, 0xb1, 0x77, 0x9a, - 0x4d, 0x7b, 0x90, 0x92, 0x09, 0xf7, 0xa0, 0xc4, 0x50, 0x96, 0xc9, 0x14, 0x8a, 0xba, 0x46, 0x91, 0xf3, 0x86, 0xbb, - 0x9c, 0xe6, 0xdc, 0x81, 0x62, 0xf0, 0x00, 0xe4, 0x9a, 0xb0, 0xed, 0xe3, 0x96, 0xdd, 0x80, 0x52, 0x41, 0x9c, 0x08, - 0xa7, 0xe4, 0x1a, 0x79, 0xe1, 0x70, 0x7f, 0x64, 0x0a, 0x00, 0x51, 0x08, 0x83, 0x5f, 0x0f, 0xc2, 0x61, 0x5b, 0x0c, - 0xde, 0xb7, 0x07, 0x4e, 0x4a, 0x72, 0xa9, 0xa1, 0x0d, 0x72, 0xef, 0x83, 0x98, 0x2a, 0xf2, 0x14, 0x70, 0x6a, 0xdc, - 0x39, 0x69, 0x76, 0x3d, 0x67, 0x6e, 0x4e, 0xa2, 0x09, 0x83, 0x29, 0x2c, 0xe0, 0x80, 0x40, 0x7d, 0x9c, 0x12, 0x18, - 0xb1, 0x6a, 0x76, 0xed, 0xa9, 0xe7, 0x07, 0xf6, 0x83, 0xc1, 0x39, 0xf7, 0xc6, 0x5c, 0x0e, 0x7f, 0xce, 0x97, 0x4b, - 0xf8, 0x77, 0xcc, 0x07, 0x29, 0xb9, 0x16, 0x45, 0x13, 0x55, 0x34, 0x85, 0xa2, 0x0f, 0x1e, 0x80, 0x8a, 0xf3, 0x52, - 0xcb, 0x92, 0x6b, 0x32, 0x25, 0x02, 0xf6, 0xbd, 0xbd, 0x64, 0x18, 0x35, 0x3a, 0x23, 0x70, 0xf2, 0x67, 0x3c, 0xff, - 0x8e, 0xf1, 0xc8, 0xb1, 0x5b, 0x7d, 0x1b, 0x0d, 0x6c, 0x0b, 0x96, 0xb6, 0x97, 0x35, 0x88, 0xc4, 0xb0, 0xdf, 0x78, - 0xc5, 0xbd, 0x79, 0x9f, 0xb4, 0x07, 0x0e, 0x53, 0x2e, 0x3d, 0x84, 0x7d, 0xc5, 0x38, 0xdb, 0x78, 0x8e, 0x1a, 0x8c, - 0x37, 0xf4, 0xf3, 0x1c, 0x35, 0x6e, 0x1b, 0x53, 0xe4, 0xf9, 0x8d, 0xdb, 0x86, 0x33, 0x27, 0x84, 0x34, 0xbb, 0x65, - 0x33, 0x2d, 0xfe, 0x22, 0xe4, 0x4d, 0xb5, 0xbf, 0x73, 0x28, 0xb6, 0x43, 0xd6, 0x70, 0x92, 0x21, 0x1d, 0x2d, 0x97, - 0xf6, 0xf1, 0xa0, 0x6f, 0xa3, 0x86, 0xa3, 0x09, 0xad, 0xa5, 0x29, 0x0d, 0x21, 0xcc, 0x46, 0x85, 0x8a, 0x27, 0x3d, - 0xa9, 0xc5, 0x8e, 0x16, 0xd5, 0x66, 0x37, 0x78, 0x00, 0x2d, 0x4a, 0x43, 0x46, 0x2a, 0xac, 0x33, 0x98, 0xa6, 0x26, - 0xe6, 0x8c, 0xb4, 0x71, 0x4a, 0xb4, 0xfb, 0x3a, 0x22, 0xbc, 0x22, 0x78, 0x9f, 0x54, 0xd5, 0xf1, 0x30, 0xc0, 0xe1, - 0x88, 0x3c, 0x95, 0x06, 0x49, 0x4f, 0x3b, 0xc7, 0x69, 0x4c, 0x9e, 0xac, 0x44, 0x71, 0x03, 0x08, 0xb0, 0xdc, 0xb8, - 0xc1, 0x3c, 0xcb, 0x68, 0xc2, 0x5f, 0xa7, 0xa1, 0xd2, 0xd3, 0x68, 0x0c, 0xa6, 0x12, 0x84, 0x67, 0x31, 0x28, 0x69, - 0x5d, 0xbd, 0x33, 0xe6, 0x6b, 0xaf, 0x67, 0x64, 0x2e, 0xf5, 0x27, 0x11, 0xb4, 0xed, 0xcd, 0x94, 0x65, 0xec, 0x20, - 0x3c, 0x57, 0xd1, 0x5c, 0xc7, 0x75, 0xdd, 0x99, 0x1b, 0xc0, 0x6b, 0x18, 0x20, 0x47, 0x85, 0xd8, 0x47, 0x4e, 0x4e, - 0x6e, 0xdc, 0x84, 0xde, 0x88, 0x51, 0x1d, 0x54, 0x49, 0x66, 0xbd, 0xbd, 0x8e, 0xa3, 0x9e, 0x60, 0x37, 0xb9, 0x9b, - 0xa4, 0x21, 0x05, 0xf4, 0x40, 0xfc, 0x5e, 0x15, 0x45, 0x7e, 0x6e, 0x06, 0xa9, 0x2a, 0xf8, 0x86, 0xa6, 0xff, 0x7a, - 0x06, 0x4e, 0x5f, 0xa1, 0x6c, 0x95, 0x95, 0xa5, 0x27, 0x1c, 0x21, 0x36, 0x76, 0x66, 0x2e, 0x04, 0xf7, 0x04, 0x09, - 0x31, 0xb0, 0xe5, 0x66, 0x26, 0x51, 0xdd, 0x96, 0x7d, 0x4e, 0x49, 0x38, 0x4c, 0x1b, 0x0d, 0xe1, 0x88, 0x9e, 0x4b, - 0x92, 0x98, 0x21, 0x3c, 0x2d, 0xf7, 0x96, 0xae, 0xf7, 0x96, 0xd4, 0x47, 0x72, 0xa6, 0x75, 0x87, 0x6e, 0x83, 0x71, - 0x24, 0x7c, 0x85, 0xdc, 0xb9, 0x45, 0x78, 0x4c, 0x5a, 0xce, 0xd0, 0x1d, 0xfc, 0x79, 0x84, 0x06, 0x8e, 0xfb, 0x15, - 0x6a, 0x49, 0xc6, 0x31, 0x45, 0x3d, 0x5f, 0x0e, 0xb1, 0x10, 0x51, 0xcc, 0x0e, 0x16, 0xbe, 0x44, 0x2f, 0xc3, 0x89, - 0x3f, 0xa5, 0xde, 0x18, 0xf6, 0xb8, 0xa6, 0x9b, 0xb7, 0x18, 0xe8, 0xc8, 0x1b, 0x2b, 0x4e, 0xe2, 0xda, 0x83, 0x5f, - 0x78, 0xf9, 0x34, 0xb0, 0x07, 0x5f, 0x57, 0x4f, 0x7f, 0xb6, 0x07, 0xdf, 0x72, 0xef, 0xdb, 0x42, 0xb9, 0xbb, 0x6b, - 0x43, 0x3c, 0xd4, 0x43, 0x14, 0x72, 0x61, 0x0c, 0xcc, 0xcd, 0xd1, 0xba, 0xa3, 0x63, 0x86, 0x0a, 0x36, 0x2e, 0x59, - 0x51, 0xee, 0x72, 0x7f, 0x02, 0x28, 0x35, 0x56, 0x20, 0x37, 0xa3, 0xfb, 0xd5, 0x84, 0x81, 0x50, 0x34, 0xb5, 0x02, - 0x2a, 0x67, 0xfd, 0x36, 0x5a, 0xd4, 0xea, 0x0a, 0x8d, 0xa9, 0x1e, 0x4d, 0x2f, 0xb9, 0xf4, 0x94, 0xb4, 0x7b, 0xd3, - 0xe3, 0x59, 0x6f, 0xda, 0x68, 0xa0, 0x5c, 0x13, 0xd6, 0x7c, 0x38, 0x1d, 0xe1, 0xd7, 0xe0, 0xd5, 0x33, 0x29, 0x09, - 0xd7, 0xa6, 0xd7, 0x55, 0xd3, 0x6b, 0x34, 0xb2, 0x02, 0xf5, 0x8c, 0xa6, 0x33, 0xd9, 0xb4, 0x28, 0x24, 0x4e, 0x56, - 0x09, 0xed, 0x08, 0x89, 0x12, 0x48, 0x89, 0x22, 0x84, 0x9c, 0x71, 0xb4, 0xb1, 0x57, 0xe8, 0x13, 0x9a, 0x8b, 0x1d, - 0x0b, 0xcc, 0x53, 0xca, 0x08, 0x07, 0xb0, 0x00, 0x4d, 0x4b, 0x57, 0xf0, 0x2d, 0x9e, 0x37, 0x3a, 0x82, 0xc8, 0x9b, - 0x9d, 0x5e, 0xbd, 0xaf, 0x47, 0x55, 0x5f, 0x78, 0xde, 0x20, 0xb7, 0x25, 0x96, 0x8a, 0xac, 0xd1, 0x28, 0xea, 0xf1, - 0x4e, 0xbd, 0x6f, 0x6b, 0x11, 0x88, 0x93, 0xd5, 0xd4, 0x0c, 0x2d, 0x5f, 0x2b, 0x89, 0xca, 0x5c, 0x96, 0x24, 0x34, - 0x03, 0x19, 0x4a, 0x38, 0x66, 0x45, 0x51, 0xca, 0xf5, 0x37, 0x20, 0x44, 0x31, 0x25, 0x09, 0xf0, 0x1d, 0x61, 0x76, - 0xe1, 0x0c, 0xa7, 0x38, 0x12, 0x5c, 0x83, 0x10, 0x72, 0xaa, 0x93, 0x5a, 0xb8, 0xe0, 0x40, 0x3e, 0x61, 0x86, 0x44, - 0xca, 0x09, 0x75, 0xcf, 0x77, 0x4f, 0xd3, 0x3b, 0x4d, 0xb2, 0x21, 0x1b, 0x79, 0xa2, 0x5a, 0xac, 0xf8, 0x56, 0x40, - 0xde, 0x39, 0x1c, 0x95, 0xe1, 0x11, 0x57, 0xb0, 0xbf, 0xa7, 0x2c, 0xa3, 0x42, 0x03, 0xdf, 0xd5, 0x66, 0x9f, 0x5f, - 0x57, 0x1f, 0x7d, 0xd3, 0x79, 0x03, 0x88, 0x0c, 0xc0, 0xb7, 0x93, 0x91, 0xb5, 0x6a, 0xe7, 0xbb, 0x27, 0x6f, 0x36, - 0x99, 0xc0, 0xcb, 0xa5, 0x32, 0x7e, 0x7d, 0xd0, 0x6c, 0x70, 0x50, 0x41, 0xea, 0xab, 0x1f, 0x9e, 0xe3, 0x0b, 0x05, - 0x29, 0x70, 0x12, 0xa0, 0xa2, 0xf3, 0xdd, 0x93, 0xf7, 0x4e, 0x22, 0x5c, 0x4b, 0x08, 0x9b, 0xd3, 0x76, 0x52, 0xe2, - 0x44, 0x84, 0x22, 0x39, 0xf7, 0x92, 0x71, 0xa5, 0x86, 0xf8, 0xf6, 0x22, 0xf1, 0x12, 0xec, 0x87, 0x21, 0x1b, 0x11, - 0x5f, 0x61, 0x80, 0xf8, 0x08, 0xfb, 0x35, 0xb3, 0x8c, 0xc0, 0x02, 0x88, 0xb1, 0xce, 0x60, 0x25, 0x5c, 0xa9, 0xf8, - 0x21, 0xec, 0x8b, 0x51, 0x79, 0x21, 0x45, 0xc7, 0xcf, 0x6b, 0xb9, 0x69, 0x95, 0x35, 0xfa, 0x2d, 0x58, 0x4e, 0xfa, - 0xe1, 0xb5, 0xea, 0xba, 0x2c, 0x78, 0xaa, 0x93, 0xc8, 0xce, 0x77, 0x4f, 0x5e, 0xa9, 0x3c, 0xb2, 0x99, 0xaf, 0xb9, - 0xfd, 0x9a, 0x85, 0x79, 0xf2, 0xca, 0xad, 0xde, 0x8a, 0xca, 0xe7, 0xbb, 0x27, 0x1f, 0x36, 0x55, 0x83, 0xf2, 0x62, - 0x5e, 0x99, 0xf8, 0x02, 0xbe, 0x05, 0x8d, 0xbd, 0x85, 0x12, 0x0d, 0x1e, 0x2b, 0xb0, 0x10, 0x47, 0x5e, 0x5e, 0x94, - 0x9e, 0x91, 0xa7, 0x38, 0x23, 0x22, 0x0e, 0x54, 0x5f, 0x35, 0xa5, 0xe4, 0xb1, 0x34, 0x39, 0x0b, 0xd2, 0x19, 0xdd, - 0x12, 0x1c, 0x3a, 0x41, 0x2e, 0x9b, 0x42, 0x02, 0x8d, 0x00, 0x9d, 0xe1, 0x9d, 0x36, 0xea, 0xd5, 0x85, 0x57, 0x26, - 0x88, 0x34, 0xad, 0x49, 0x16, 0x1c, 0x91, 0x36, 0xf6, 0x49, 0x1b, 0x07, 0x24, 0x1f, 0xb6, 0xa5, 0x78, 0xe8, 0x05, - 0x65, 0xbf, 0x52, 0xc8, 0x40, 0x6e, 0x58, 0x20, 0x77, 0xab, 0x14, 0xbf, 0x61, 0x2f, 0x10, 0xae, 0x47, 0x21, 0xd1, - 0x43, 0x69, 0xb4, 0x3a, 0x29, 0x4e, 0x45, 0xc7, 0x67, 0xec, 0x32, 0x86, 0xec, 0x12, 0x98, 0x15, 0xe6, 0xc8, 0x2b, - 0xab, 0x76, 0x54, 0xd5, 0xc0, 0x15, 0xeb, 0x94, 0xe2, 0xc0, 0x05, 0xc6, 0x8d, 0x03, 0x95, 0x8c, 0x93, 0xaf, 0x37, - 0x79, 0xb8, 0xb7, 0xe7, 0xc8, 0x46, 0xdf, 0x71, 0x27, 0xd5, 0xef, 0xab, 0xd0, 0xdd, 0xb7, 0x92, 0x57, 0x84, 0x48, - 0xc0, 0xdf, 0x68, 0xf8, 0xa3, 0x02, 0xe2, 0xd0, 0x4e, 0x50, 0xc7, 0xa0, 0x06, 0x5e, 0x68, 0x7a, 0xf5, 0xe9, 0x37, - 0x1a, 0x65, 0x98, 0xb6, 0x8e, 0xad, 0x13, 0x9c, 0x15, 0x57, 0x4e, 0x99, 0xff, 0xd3, 0x5e, 0xcb, 0x9a, 0xd2, 0x20, - 0x20, 0x66, 0xd2, 0x2c, 0xd3, 0x93, 0x31, 0xb6, 0x04, 0x83, 0x7a, 0x2f, 0x54, 0xe2, 0x02, 0x16, 0x39, 0x56, 0xaa, - 0x92, 0x66, 0x67, 0x5d, 0xe4, 0xe9, 0x4a, 0x10, 0x96, 0x82, 0x4a, 0x8d, 0x42, 0x91, 0xf7, 0xab, 0xf5, 0xcc, 0x4b, - 0x9c, 0x23, 0xe5, 0xe3, 0x12, 0x50, 0x08, 0x64, 0x75, 0x4b, 0xa4, 0x3c, 0x27, 0x93, 0xed, 0x24, 0x7f, 0x62, 0x90, - 0xfc, 0x13, 0x42, 0x0d, 0xf2, 0x97, 0x1e, 0x0e, 0x37, 0x55, 0xae, 0x85, 0x5c, 0xbf, 0x3a, 0x9d, 0x11, 0xf0, 0xa1, - 0xd5, 0x31, 0x5a, 0x8b, 0x2b, 0x6e, 0x61, 0x28, 0xe6, 0x0e, 0x11, 0x5e, 0x48, 0xac, 0x83, 0xc0, 0x4e, 0x15, 0x55, - 0x83, 0xa1, 0x37, 0xb9, 0xf4, 0x4c, 0x0e, 0x78, 0xf2, 0xe1, 0xee, 0x80, 0xe8, 0xe9, 0x6c, 0x7d, 0xe7, 0x1a, 0x19, - 0xa0, 0x30, 0x6b, 0x63, 0xe3, 0xd6, 0xf3, 0x41, 0x61, 0xfc, 0x32, 0x90, 0x5d, 0x67, 0x3e, 0x2b, 0x9b, 0x50, 0xcb, - 0x3f, 0x80, 0xb6, 0xd3, 0x11, 0x35, 0xa8, 0xd1, 0x2d, 0xf0, 0x23, 0x99, 0x87, 0xea, 0x67, 0x5b, 0xd8, 0xc7, 0x89, - 0xa8, 0x40, 0x93, 0x70, 0xf3, 0xeb, 0x27, 0x85, 0x22, 0x13, 0x09, 0x1a, 0x5a, 0x00, 0xff, 0x93, 0x24, 0x0f, 0x74, - 0x23, 0xe4, 0x02, 0x20, 0x68, 0x22, 0xf0, 0x54, 0x21, 0xcc, 0xb6, 0x2b, 0xe7, 0xfb, 0xf3, 0x1d, 0x42, 0x26, 0x95, - 0xf3, 0xf1, 0x5d, 0x95, 0x7d, 0x05, 0x64, 0x81, 0x3c, 0x30, 0x1e, 0xcb, 0x02, 0x19, 0xbf, 0x3c, 0xd5, 0xd5, 0x85, - 0x01, 0xe9, 0x56, 0xfa, 0xb6, 0x11, 0xdb, 0x14, 0x5e, 0x39, 0xf9, 0x5e, 0xa3, 0x61, 0xe5, 0xed, 0x2e, 0xbc, 0x7d, - 0xc9, 0x05, 0x8c, 0xf0, 0xfc, 0x5e, 0xd4, 0xd6, 0xfd, 0x16, 0x1f, 0x57, 0x53, 0x58, 0x56, 0x16, 0xc5, 0x65, 0x49, - 0x4e, 0x33, 0xfe, 0x84, 0x8e, 0xd3, 0x0c, 0x42, 0x16, 0x25, 0x4e, 0x50, 0xb1, 0x6b, 0xb8, 0xed, 0xc4, 0xfc, 0x8c, - 0x38, 0xc1, 0xca, 0x04, 0xc5, 0xaf, 0x8f, 0x22, 0x6a, 0x7d, 0xbe, 0xda, 0x6a, 0xb2, 0xb7, 0xf7, 0xae, 0x42, 0x93, - 0x82, 0x52, 0x40, 0x61, 0x30, 0x2d, 0xa9, 0xd2, 0xa8, 0x50, 0xee, 0xae, 0x53, 0xba, 0x00, 0x34, 0xc3, 0x30, 0x79, - 0xcf, 0x73, 0xc2, 0x8b, 0xc9, 0x2a, 0x8b, 0x57, 0xae, 0x09, 0x66, 0x9a, 0x2d, 0xc0, 0xe1, 0xc1, 0xd0, 0x96, 0xbe, - 0xa2, 0xbc, 0x4a, 0x89, 0x2d, 0x61, 0x38, 0x05, 0x64, 0x39, 0xc2, 0x08, 0x31, 0x28, 0x70, 0xa3, 0x51, 0xf2, 0x16, - 0xf4, 0xca, 0x08, 0xe7, 0x6e, 0x04, 0x49, 0xb0, 0xb5, 0x2d, 0x8b, 0x10, 0x96, 0x99, 0x39, 0x46, 0x2e, 0xc1, 0xc9, - 0xf3, 0x4d, 0x1e, 0x65, 0x4d, 0xd4, 0x54, 0x48, 0x1d, 0xa8, 0x91, 0xa1, 0xb2, 0x81, 0x7b, 0xe5, 0x30, 0xa5, 0xb8, - 0xe9, 0xb8, 0x19, 0x30, 0xe0, 0x9f, 0xb9, 0x23, 0x63, 0x51, 0x20, 0x33, 0x52, 0x77, 0xee, 0xd4, 0x86, 0xee, 0xa5, - 0xa2, 0x19, 0x56, 0x88, 0x8b, 0x4c, 0x34, 0xa5, 0x22, 0xae, 0x77, 0x5a, 0xf1, 0xd2, 0x2b, 0x99, 0x47, 0xcd, 0x35, - 0x17, 0xac, 0x32, 0x49, 0x8c, 0xe9, 0x5f, 0xc9, 0xd4, 0xe8, 0xb2, 0x12, 0xa8, 0x61, 0xf4, 0xda, 0x7a, 0x22, 0xd6, - 0x80, 0x16, 0x40, 0x5f, 0x8b, 0x53, 0x6e, 0xac, 0xa8, 0xf6, 0x61, 0x8b, 0x31, 0x0d, 0xa9, 0xff, 0x0e, 0x72, 0x5d, - 0x56, 0xf7, 0xfc, 0x73, 0x21, 0x0b, 0x19, 0xce, 0x6b, 0x8c, 0x3d, 0x13, 0x8c, 0x1d, 0x81, 0x9e, 0xa6, 0xd3, 0xbf, - 0x07, 0x2a, 0xe5, 0x45, 0xe5, 0x2e, 0x3a, 0x8a, 0xc4, 0x5e, 0x97, 0xe1, 0x72, 0xe3, 0xf7, 0xca, 0x6a, 0x78, 0x8c, - 0x40, 0x1a, 0x10, 0x56, 0x9c, 0x3d, 0x43, 0x38, 0x6f, 0x34, 0x7a, 0xf9, 0x31, 0xad, 0x5c, 0x24, 0x15, 0x8c, 0x0c, - 0x22, 0xba, 0x40, 0xf0, 0x35, 0x19, 0x0a, 0x31, 0x7f, 0x9d, 0x9f, 0x9d, 0x83, 0xab, 0xfd, 0xe4, 0x9d, 0x63, 0x72, - 0x35, 0xb3, 0x6e, 0x19, 0x34, 0x85, 0xf9, 0x38, 0x55, 0xbc, 0xe5, 0xed, 0xdd, 0x19, 0x1e, 0x00, 0xf7, 0x4e, 0x07, - 0x43, 0x36, 0x1a, 0xea, 0x71, 0xc9, 0x12, 0xca, 0xdd, 0xd7, 0x43, 0x55, 0x62, 0xa2, 0x39, 0x58, 0x8f, 0x57, 0xa6, - 0x2c, 0x27, 0x79, 0x51, 0xe4, 0xb4, 0x8a, 0xef, 0xaf, 0x64, 0x60, 0x0a, 0xe1, 0xb2, 0xee, 0x6c, 0x3f, 0x9d, 0x11, - 0x8e, 0x0d, 0x42, 0x7d, 0xbb, 0x2d, 0xf4, 0x51, 0x81, 0x09, 0xfb, 0x5a, 0x09, 0xc5, 0x6f, 0x37, 0x09, 0x45, 0x9c, - 0xa9, 0x2d, 0x2f, 0x04, 0x62, 0xe7, 0x1e, 0x02, 0x51, 0x39, 0xd9, 0xb5, 0x4c, 0x04, 0x75, 0xa4, 0x26, 0x13, 0xeb, - 0x4b, 0x4a, 0x32, 0xcc, 0xd4, 0x6a, 0xf4, 0xbb, 0xcb, 0x25, 0x1b, 0xb6, 0xc1, 0x89, 0x64, 0xdb, 0xf0, 0xb3, 0x23, - 0x7f, 0x1a, 0x9c, 0x58, 0x3a, 0x81, 0x1d, 0x56, 0x9a, 0x2c, 0xc8, 0x85, 0x34, 0x67, 0x47, 0x64, 0x65, 0x09, 0x9a, - 0x56, 0x14, 0xa4, 0x08, 0x9c, 0xb0, 0x32, 0xca, 0x04, 0x10, 0x0b, 0x59, 0xa1, 0x0c, 0x48, 0x67, 0x63, 0xfa, 0x9f, - 0x36, 0x2f, 0x3f, 0xad, 0x89, 0xd6, 0xe4, 0x8a, 0x54, 0x1f, 0x6a, 0x09, 0x07, 0x0a, 0x02, 0xa5, 0x1f, 0xee, 0x08, - 0x13, 0xb4, 0x12, 0xe5, 0xc8, 0x94, 0x43, 0xb8, 0x0d, 0x2e, 0xb4, 0x9d, 0x77, 0x32, 0xc0, 0xbb, 0x41, 0x9a, 0xe0, - 0xd4, 0xa0, 0xeb, 0xe7, 0x84, 0xd7, 0x58, 0x49, 0x44, 0x94, 0xa5, 0x84, 0x03, 0x41, 0xa6, 0x9c, 0x64, 0xc3, 0xf6, - 0x08, 0x14, 0xd0, 0x9e, 0x7f, 0x9c, 0x55, 0x26, 0xb0, 0xdf, 0x68, 0xa0, 0x40, 0x8f, 0x1a, 0x0d, 0x59, 0xc3, 0x1f, - 0x61, 0x8a, 0x7d, 0x69, 0x98, 0x9c, 0xee, 0xed, 0x39, 0x41, 0x35, 0xee, 0xd0, 0x1f, 0x21, 0x9c, 0x2e, 0x97, 0x8e, - 0x00, 0x2b, 0x40, 0xcb, 0x65, 0x60, 0x82, 0x25, 0x5e, 0x43, 0xb3, 0xc9, 0x80, 0x93, 0x89, 0x10, 0x80, 0x13, 0x80, - 0xb0, 0x41, 0x9c, 0x40, 0x39, 0xf7, 0x02, 0x70, 0x46, 0x35, 0xb2, 0xa1, 0xdf, 0xe8, 0x8c, 0x0c, 0xc6, 0x35, 0xf4, - 0x47, 0x24, 0x28, 0xd2, 0xbd, 0xbd, 0x9d, 0x5c, 0x89, 0xc8, 0x9f, 0x41, 0x94, 0xfd, 0x2c, 0x24, 0x8b, 0xec, 0xd0, - 0x5c, 0x8d, 0x55, 0x67, 0x40, 0x49, 0x51, 0x6a, 0x59, 0x75, 0xbd, 0x5a, 0x16, 0x44, 0x59, 0x09, 0xab, 0x58, 0xf0, - 0x00, 0x2c, 0xfb, 0x92, 0xcc, 0x7f, 0xe1, 0x65, 0x9a, 0xf5, 0xb7, 0x1b, 0x93, 0xab, 0x5d, 0xd7, 0xf5, 0xb3, 0x89, - 0x88, 0x64, 0xe8, 0x28, 0xac, 0x20, 0xfe, 0x7d, 0x05, 0xa6, 0x31, 0xf0, 0xb0, 0x1c, 0x6b, 0x44, 0x24, 0xf8, 0x5a, - 0xb5, 0xd1, 0x27, 0x4a, 0x7e, 0xdd, 0xe8, 0x65, 0x90, 0x90, 0x7c, 0xfd, 0x5b, 0x21, 0x39, 0x50, 0x90, 0x48, 0xf2, - 0x58, 0xc1, 0xd9, 0x16, 0x5c, 0xfc, 0xca, 0x57, 0x70, 0xb6, 0x1d, 0xb7, 0x25, 0x43, 0xd8, 0x06, 0x9f, 0xc1, 0x1b, - 0x24, 0xa0, 0x55, 0x81, 0x01, 0xe5, 0xe1, 0xaa, 0xee, 0x25, 0x59, 0x29, 0x08, 0x53, 0x4e, 0x1c, 0x56, 0xdf, 0x00, - 0x95, 0x36, 0x6a, 0x18, 0xbe, 0xcc, 0x9b, 0x20, 0xc3, 0x25, 0x50, 0x4f, 0x5d, 0x01, 0x72, 0x52, 0xbe, 0x76, 0x48, - 0x45, 0xd8, 0x91, 0x4a, 0x9c, 0x1b, 0xf8, 0x33, 0x3e, 0xcf, 0x40, 0x95, 0xca, 0xf5, 0x6f, 0x28, 0x86, 0xb3, 0x20, - 0xa2, 0x0c, 0x7e, 0x40, 0xc1, 0xcc, 0xcf, 0x73, 0x76, 0x25, 0xcb, 0xd4, 0x6f, 0x9c, 0x12, 0x4d, 0xca, 0xb9, 0xd4, - 0x09, 0x33, 0xd4, 0xcb, 0x14, 0x9d, 0xd6, 0xd1, 0xf6, 0xec, 0x8a, 0x26, 0xfc, 0x25, 0xcb, 0x39, 0x4d, 0x60, 0xfa, - 0x15, 0xc5, 0xc1, 0x8c, 0x72, 0x04, 0x1b, 0xb6, 0xd6, 0xca, 0x0f, 0xc3, 0x3b, 0x9b, 0xf0, 0xba, 0x0e, 0x14, 0xf9, - 0x49, 0x18, 0xcb, 0x41, 0xcc, 0x84, 0x46, 0x9d, 0xc4, 0x59, 0xd6, 0x34, 0xf3, 0x69, 0x2a, 0x65, 0x43, 0x70, 0x77, - 0x87, 0x11, 0x2d, 0x09, 0xb4, 0xf4, 0xbc, 0x53, 0x6b, 0x81, 0x80, 0xf7, 0x96, 0x45, 0x30, 0x67, 0x82, 0xb9, 0xc1, - 0x51, 0xdd, 0x3a, 0x9c, 0x9a, 0x6e, 0xbe, 0xdb, 0x78, 0xb0, 0x6d, 0x93, 0x70, 0x10, 0x74, 0xf2, 0x70, 0xbb, 0x65, - 0xf5, 0x4a, 0x4b, 0x0e, 0x2d, 0x2d, 0xd8, 0x7d, 0x19, 0x33, 0x5a, 0x68, 0xf2, 0x42, 0x7a, 0x2b, 0xee, 0x72, 0xf2, - 0x0b, 0x9c, 0x1c, 0x7a, 0xce, 0xa7, 0xf1, 0xca, 0x01, 0x99, 0xde, 0x6e, 0xa9, 0xfd, 0xef, 0x72, 0xe7, 0x09, 0x7e, - 0x05, 0x61, 0xdd, 0x6f, 0xaa, 0xea, 0xeb, 0xe1, 0xdc, 0x6f, 0x2a, 0x04, 0x7d, 0xe3, 0xad, 0xd5, 0x33, 0xc2, 0xb8, - 0x5d, 0xf7, 0xc8, 0x6d, 0xdb, 0x5a, 0x5b, 0xfa, 0x51, 0x06, 0x91, 0x64, 0xaa, 0xa5, 0xd8, 0x0f, 0xb8, 0x4a, 0x54, - 0x83, 0x84, 0xb9, 0xba, 0x85, 0x44, 0x55, 0x8a, 0xa1, 0xd4, 0xe1, 0xb7, 0x2d, 0x8f, 0x92, 0x31, 0x99, 0xb4, 0x33, - 0xde, 0xfa, 0x19, 0xdf, 0x85, 0x5d, 0x96, 0xae, 0x9d, 0xc6, 0x8b, 0x08, 0x78, 0xd0, 0xee, 0x37, 0x84, 0x61, 0x6c, - 0xe7, 0xf2, 0x30, 0x90, 0xd9, 0x3f, 0x49, 0xb5, 0xee, 0x56, 0xb7, 0x32, 0x5e, 0x83, 0xfd, 0x8f, 0x70, 0xa4, 0x8f, - 0xc8, 0x51, 0xc5, 0x81, 0xa9, 0xb7, 0x28, 0x4a, 0xa7, 0x40, 0x2a, 0x95, 0xb7, 0x04, 0xe1, 0xb4, 0x10, 0xe1, 0xed, - 0xef, 0xf1, 0x0f, 0x8a, 0x25, 0x9e, 0x97, 0x1c, 0xe7, 0xd9, 0x7d, 0x39, 0xa2, 0x04, 0xbf, 0x8c, 0xde, 0x03, 0x1d, - 0x0b, 0x0a, 0x2d, 0x34, 0x15, 0x3d, 0x4d, 0xd5, 0x44, 0xb6, 0xe6, 0xa5, 0x62, 0x5a, 0x66, 0xd4, 0x88, 0x61, 0x36, - 0x24, 0x72, 0x6a, 0x2b, 0x9b, 0x97, 0xbb, 0xaa, 0x36, 0x2e, 0xda, 0x82, 0xc5, 0x2a, 0xb0, 0xb8, 0x5c, 0x3a, 0x75, - 0x54, 0x13, 0x66, 0xc4, 0x31, 0x10, 0x66, 0x46, 0x42, 0x45, 0x4d, 0xb3, 0x96, 0x6d, 0x1c, 0xb4, 0x9a, 0x4f, 0xa4, - 0x75, 0xf3, 0x1a, 0x1c, 0xa6, 0x0b, 0x41, 0x36, 0x37, 0x7d, 0x0a, 0x58, 0xce, 0xae, 0x1c, 0xc8, 0xc0, 0xd0, 0x8f, - 0x65, 0xae, 0x6c, 0x95, 0xd4, 0xba, 0x01, 0xbf, 0xe8, 0x8e, 0x6c, 0x59, 0x85, 0xba, 0xf5, 0xf7, 0x46, 0xae, 0xd1, - 0xd3, 0x74, 0x5b, 0xae, 0x51, 0x4d, 0xdb, 0xdd, 0x69, 0xa3, 0xbb, 0xf3, 0x52, 0xe5, 0x58, 0x9b, 0xab, 0xfc, 0x86, - 0xe1, 0x3a, 0x40, 0x9b, 0x12, 0xcd, 0x9a, 0xab, 0x9c, 0x16, 0xc5, 0x79, 0x79, 0x9a, 0x40, 0xa4, 0xee, 0x9c, 0x4b, - 0xfa, 0x57, 0x56, 0xa3, 0x38, 0x94, 0xeb, 0x7c, 0x4f, 0x26, 0x71, 0x7a, 0xe9, 0xc7, 0xef, 0x61, 0xbc, 0xea, 0xe5, - 0xf3, 0xdb, 0x30, 0xf3, 0x39, 0x55, 0xdc, 0xa5, 0x82, 0xe1, 0x7b, 0x03, 0x86, 0xef, 0x25, 0x9f, 0xae, 0xda, 0xe3, - 0xc5, 0xcb, 0xb2, 0x03, 0xef, 0xbc, 0xd0, 0x2c, 0xe3, 0x96, 0x6f, 0x1e, 0x63, 0x95, 0x85, 0xdd, 0x96, 0x2c, 0xec, - 0x96, 0x3b, 0xab, 0x5d, 0x39, 0xce, 0x0f, 0x9b, 0x7b, 0x59, 0xe7, 0x6c, 0x3f, 0x54, 0x1b, 0xff, 0x07, 0xef, 0xce, - 0x36, 0x06, 0x97, 0xdb, 0x77, 0xf7, 0x45, 0xb2, 0x8a, 0x04, 0xf9, 0x25, 0x24, 0x1d, 0x70, 0xd2, 0x37, 0x0e, 0x1d, - 0x54, 0x72, 0x4a, 0xe7, 0x01, 0x39, 0xc1, 0x3c, 0xe7, 0xe9, 0x54, 0xf5, 0x99, 0xab, 0x93, 0x46, 0xe2, 0x25, 0xb8, - 0xa2, 0x45, 0xac, 0xdd, 0xab, 0x9f, 0xe5, 0x5a, 0x7c, 0x64, 0x49, 0xe8, 0xe5, 0x58, 0x49, 0x91, 0xdc, 0xcb, 0x0a, - 0xa2, 0xb3, 0x8d, 0xd7, 0xdf, 0xe1, 0x31, 0x4b, 0x58, 0x1e, 0xd1, 0xcc, 0x49, 0xd1, 0x62, 0xdb, 0x60, 0x29, 0x04, - 0x64, 0xe4, 0x60, 0xf8, 0xaf, 0xd5, 0xa9, 0x3f, 0x17, 0x7a, 0x03, 0x3f, 0xd0, 0x94, 0xf2, 0x28, 0x0d, 0x21, 0x2d, - 0xc5, 0x0d, 0xcb, 0x43, 0x4d, 0x7b, 0x7b, 0x3b, 0x8e, 0x2d, 0xdc, 0x12, 0x70, 0x00, 0xdc, 0x7c, 0x83, 0x06, 0x0b, - 0x38, 0x9f, 0x53, 0x0d, 0x4d, 0xd1, 0x82, 0xae, 0x1e, 0x65, 0xe1, 0xee, 0x47, 0x7a, 0x8b, 0x13, 0x54, 0x14, 0x9e, - 0x84, 0xda, 0x1e, 0x33, 0x1a, 0x87, 0x36, 0xfe, 0x48, 0x6f, 0xbd, 0xf2, 0xcc, 0xb8, 0x38, 0xe2, 0x2c, 0x16, 0xd0, - 0x4e, 0xaf, 0x13, 0x1b, 0x57, 0x83, 0x78, 0x8b, 0x02, 0xa7, 0x19, 0x9b, 0x00, 0x71, 0x7e, 0x43, 0x6f, 0x3d, 0xd9, - 0x1f, 0x33, 0xce, 0xeb, 0xa1, 0x85, 0x46, 0xbd, 0x6b, 0x14, 0x9b, 0xcb, 0xa0, 0x0c, 0x8a, 0xa1, 0x68, 0x3b, 0x22, - 0xb5, 0x7a, 0x95, 0x79, 0x88, 0x50, 0x71, 0xdf, 0xa9, 0xe0, 0x6f, 0x4c, 0xd1, 0xc6, 0x6b, 0x99, 0xaf, 0x2b, 0x8d, - 0x28, 0x34, 0xa8, 0x32, 0x3d, 0x76, 0x9d, 0x44, 0xef, 0x3a, 0x75, 0x08, 0xc1, 0x70, 0x84, 0x7d, 0xc3, 0x55, 0xa7, - 0xde, 0x5f, 0x65, 0x42, 0x48, 0x15, 0x49, 0x7a, 0x51, 0xb5, 0xb3, 0x76, 0x1d, 0xc0, 0x3b, 0x24, 0xb4, 0xf8, 0xe2, - 0x4c, 0x66, 0xa1, 0xb3, 0x45, 0xff, 0xc6, 0x89, 0xb3, 0xd0, 0x53, 0xf0, 0x12, 0x13, 0x8b, 0xbc, 0x00, 0x2a, 0x54, - 0xf4, 0x25, 0x13, 0x00, 0xd9, 0xd8, 0x61, 0x6b, 0x52, 0x33, 0x13, 0x52, 0xd3, 0x35, 0x30, 0xbe, 0x45, 0x4a, 0x52, - 0x81, 0x0c, 0xa1, 0x44, 0x0a, 0xa1, 0xa7, 0x16, 0x57, 0x91, 0x90, 0xb9, 0xa0, 0xe5, 0x09, 0x3a, 0xb9, 0xe6, 0x59, - 0x0d, 0x2c, 0x47, 0xf4, 0x83, 0x0a, 0x0f, 0xa6, 0x44, 0x65, 0x85, 0xa2, 0x3c, 0x9a, 0xad, 0xd3, 0x5b, 0x9d, 0xd4, - 0xd5, 0xd3, 0x22, 0x1a, 0x25, 0x4e, 0x84, 0x16, 0x89, 0x13, 0xe1, 0x0c, 0xd2, 0x11, 0xd3, 0xa2, 0x84, 0x9f, 0x9a, - 0xab, 0x51, 0x4b, 0x56, 0xde, 0x7c, 0xca, 0x0f, 0x94, 0x79, 0x0e, 0x29, 0x9a, 0x38, 0xd1, 0x3c, 0x25, 0x71, 0xc4, - 0x71, 0x3b, 0x63, 0xd9, 0xbe, 0x57, 0x09, 0x3a, 0x0a, 0xb0, 0xbf, 0x71, 0x67, 0x61, 0xcc, 0xc2, 0x3c, 0xd1, 0xad, - 0x4e, 0xfd, 0xa9, 0x60, 0x5f, 0x95, 0x43, 0xea, 0xe4, 0x64, 0x45, 0xe2, 0xdc, 0x9d, 0x6a, 0xf9, 0xcb, 0x9c, 0x66, - 0xb7, 0x67, 0x14, 0x52, 0x9d, 0x53, 0x38, 0xf0, 0x5b, 0x2d, 0x43, 0x95, 0xa7, 0x3e, 0xc8, 0x84, 0xb2, 0x52, 0xd4, - 0xcf, 0x01, 0xae, 0x9e, 0x12, 0x2c, 0x44, 0xb4, 0xd1, 0x70, 0xc4, 0xc8, 0xdd, 0x42, 0xb7, 0x9e, 0x9f, 0xa4, 0x3d, - 0x06, 0xfe, 0xb5, 0x0a, 0xd3, 0x2a, 0x58, 0x80, 0x53, 0xf3, 0x4c, 0xea, 0x30, 0x1f, 0xad, 0x7a, 0x65, 0xa0, 0x08, - 0xc2, 0x77, 0xd9, 0xf6, 0xa9, 0x6e, 0x4a, 0x9a, 0xdd, 0x3e, 0xd5, 0x5a, 0xd0, 0x4f, 0x24, 0xfc, 0x60, 0x35, 0x4e, - 0x79, 0x82, 0x99, 0x15, 0x05, 0x2a, 0x00, 0xbc, 0xbf, 0xf4, 0x1c, 0xe7, 0x2f, 0x2a, 0x65, 0xd0, 0x85, 0x58, 0xec, - 0x59, 0x9c, 0x6a, 0x26, 0x5e, 0x8d, 0xff, 0x97, 0xb5, 0xf1, 0xff, 0x62, 0x9c, 0x3a, 0x05, 0xd3, 0x68, 0x92, 0xd0, - 0x50, 0xb3, 0x4e, 0x24, 0x09, 0x50, 0xe8, 0x6d, 0x19, 0x27, 0x1f, 0x2f, 0x3c, 0xd0, 0xb8, 0x16, 0xe3, 0x34, 0xe1, - 0xcd, 0xb1, 0x3f, 0x65, 0xf1, 0xad, 0x37, 0x67, 0xcd, 0x69, 0x9a, 0xa4, 0xf9, 0xcc, 0x0f, 0x28, 0xce, 0x6f, 0x73, - 0x4e, 0xa7, 0xcd, 0x39, 0xc3, 0xcf, 0x69, 0x7c, 0x45, 0x39, 0x0b, 0x7c, 0x6c, 0x9f, 0x64, 0xcc, 0x8f, 0xad, 0xd7, - 0x7e, 0x96, 0xa5, 0xd7, 0x36, 0x7e, 0x97, 0x5e, 0xa6, 0x3c, 0xc5, 0x6f, 0x6e, 0x6e, 0x27, 0x34, 0xc1, 0x1f, 0x2e, - 0xe7, 0x09, 0x9f, 0xe3, 0xdc, 0x4f, 0xf2, 0x66, 0x4e, 0x33, 0x36, 0xee, 0x05, 0x69, 0x9c, 0x66, 0x4d, 0xc8, 0xd8, - 0x9e, 0x52, 0x2f, 0x66, 0x93, 0x88, 0x5b, 0xa1, 0x9f, 0x7d, 0xec, 0x35, 0x9b, 0xb3, 0x8c, 0x4d, 0xfd, 0xec, 0xb6, - 0x29, 0x6a, 0x78, 0x5f, 0xb6, 0xf7, 0xfd, 0xc7, 0xe3, 0x83, 0x1e, 0xcf, 0xfc, 0x24, 0x67, 0xb0, 0x4c, 0x9e, 0x1f, - 0xc7, 0xd6, 0xfe, 0x61, 0x7b, 0x9a, 0xef, 0xc8, 0x40, 0x9e, 0x9f, 0xf0, 0xe2, 0x02, 0xbf, 0x07, 0xb8, 0xdd, 0x4b, - 0x9e, 0xe0, 0xcb, 0x39, 0xe7, 0x69, 0xb2, 0x08, 0xe6, 0x59, 0x9e, 0x66, 0xde, 0x2c, 0x65, 0x09, 0xa7, 0x59, 0xef, - 0x32, 0xcd, 0x42, 0x9a, 0x35, 0x33, 0x3f, 0x64, 0xf3, 0xdc, 0x3b, 0x98, 0xdd, 0xf4, 0x40, 0xb3, 0x98, 0x64, 0xe9, - 0x3c, 0x09, 0xd5, 0x58, 0x2c, 0x89, 0x68, 0xc6, 0xb8, 0xf9, 0x42, 0x5c, 0x64, 0xe2, 0xc5, 0x2c, 0xa1, 0x7e, 0xd6, - 0x9c, 0x40, 0x63, 0x30, 0x8b, 0xda, 0x21, 0x9d, 0xe0, 0x6c, 0x72, 0xe9, 0x3b, 0x9d, 0xee, 0x23, 0xac, 0xff, 0x77, - 0x0f, 0x91, 0xd5, 0xde, 0x5c, 0xdc, 0x69, 0xb7, 0xff, 0x84, 0x7a, 0x2b, 0xa3, 0x08, 0x80, 0xbc, 0xce, 0xec, 0xc6, - 0xca, 0x53, 0xc8, 0x68, 0xdb, 0xd4, 0xb2, 0x37, 0xf3, 0x43, 0xc8, 0x07, 0xf6, 0xba, 0xb3, 0x9b, 0x02, 0x66, 0xe7, - 0xc9, 0x14, 0x53, 0x35, 0x49, 0xf5, 0xb4, 0xf8, 0xad, 0x10, 0x1f, 0x6d, 0x86, 0xb8, 0xab, 0x21, 0xae, 0xb0, 0xde, - 0x0c, 0xe7, 0x99, 0x88, 0xad, 0x7a, 0x9d, 0x5c, 0x02, 0x12, 0xa5, 0x57, 0x34, 0xd3, 0x70, 0x88, 0x87, 0xdf, 0x0c, - 0x46, 0x77, 0x33, 0x18, 0x47, 0x9f, 0x02, 0x23, 0x4b, 0xc2, 0x45, 0x7d, 0x5d, 0x3b, 0x19, 0x9d, 0xf6, 0x22, 0x0a, - 0xf4, 0xe4, 0x75, 0xe1, 0xf7, 0x35, 0x0b, 0x79, 0x24, 0x7f, 0x0a, 0x72, 0xbe, 0x96, 0xef, 0x0e, 0xdb, 0x6d, 0xf9, - 0x9c, 0xb3, 0x5f, 0xa9, 0xd7, 0x71, 0xa1, 0x42, 0x71, 0x81, 0x7f, 0x28, 0x4f, 0xf3, 0xd6, 0xb9, 0x27, 0xfe, 0x8b, - 0x79, 0xcc, 0xd7, 0x48, 0x51, 0xac, 0x0e, 0x45, 0xe3, 0x54, 0xcb, 0x4a, 0x29, 0x7c, 0xc0, 0x6d, 0x27, 0xb8, 0x23, - 0x61, 0xfd, 0xf2, 0x18, 0x27, 0x1b, 0xfc, 0x45, 0xe6, 0x5d, 0x78, 0x10, 0xe9, 0x30, 0x52, 0x0d, 0xd3, 0x5e, 0xd6, - 0x27, 0xed, 0x5e, 0xd6, 0x6c, 0x22, 0x27, 0x25, 0xc9, 0x30, 0x53, 0xc9, 0x79, 0x0e, 0x1b, 0xa4, 0xc2, 0xd8, 0xce, - 0x91, 0x97, 0xc2, 0x59, 0xd3, 0xe5, 0xb2, 0x0a, 0x03, 0x30, 0x71, 0x5a, 0xe3, 0x07, 0xae, 0x2a, 0xe0, 0xdc, 0xe0, - 0xe4, 0xbe, 0xbe, 0xde, 0x25, 0xd1, 0xbc, 0x22, 0x4e, 0x03, 0x81, 0x39, 0x77, 0xe6, 0xf3, 0x08, 0xbc, 0x14, 0xa5, - 0xf8, 0xa9, 0x52, 0x98, 0xec, 0x96, 0x8d, 0x06, 0x49, 0x99, 0xdf, 0x06, 0x79, 0x7c, 0x49, 0x01, 0xbd, 0x5c, 0x72, - 0x02, 0x3d, 0x56, 0xfd, 0x7f, 0xe0, 0x86, 0xa4, 0x4e, 0x5c, 0x96, 0x04, 0xf1, 0x3c, 0xa4, 0xb9, 0xe8, 0xa1, 0x12, - 0xe7, 0x70, 0x37, 0x44, 0x59, 0x4b, 0x34, 0x81, 0xde, 0x45, 0x36, 0x0f, 0x54, 0x84, 0x5b, 0x54, 0xca, 0xe7, 0xa6, - 0x78, 0xae, 0xda, 0xbe, 0xae, 0x92, 0x45, 0xa1, 0xa5, 0x3b, 0x4f, 0xd8, 0x2f, 0x73, 0x7a, 0xce, 0x42, 0xe3, 0xe4, - 0x2e, 0x4d, 0x82, 0x34, 0xa4, 0x1f, 0xde, 0xbd, 0x80, 0x6c, 0xf7, 0x34, 0x01, 0x12, 0x4b, 0xa4, 0xbf, 0x0b, 0xe7, - 0x24, 0x71, 0x43, 0x7a, 0xc5, 0x02, 0x3a, 0xb8, 0xd8, 0x5d, 0x6c, 0xac, 0x28, 0x5f, 0xa3, 0xa2, 0x75, 0x21, 0x92, - 0xfe, 0x04, 0x94, 0x17, 0xbb, 0x8b, 0x4b, 0x5e, 0xb4, 0x76, 0x17, 0x89, 0x1b, 0xa6, 0x53, 0x9f, 0x25, 0xf0, 0x3b, - 0x2f, 0x76, 0x17, 0x0c, 0x7e, 0xf0, 0xe2, 0xa2, 0xa8, 0x12, 0x45, 0x4b, 0x88, 0x8c, 0x29, 0x28, 0xdc, 0x75, 0x90, - 0xfb, 0x73, 0xca, 0x12, 0x51, 0x74, 0x57, 0xcf, 0x54, 0xf7, 0x0a, 0x48, 0xfe, 0x95, 0x48, 0x83, 0x59, 0x9b, 0xcb, - 0xe7, 0xf7, 0x35, 0x97, 0x69, 0xc2, 0x99, 0x48, 0x8b, 0xd7, 0xe1, 0x9c, 0xc8, 0xcf, 0xcf, 0x03, 0x79, 0x12, 0x35, - 0xaf, 0x4e, 0x5d, 0xf8, 0x02, 0xb1, 0xd2, 0x02, 0xa6, 0x99, 0x30, 0xf6, 0xe9, 0xf6, 0xa3, 0x92, 0xc9, 0x5d, 0xc6, - 0x5f, 0x49, 0x55, 0x79, 0x3a, 0xcf, 0x02, 0x88, 0xf5, 0x2a, 0x95, 0x62, 0xdd, 0x2b, 0x66, 0x0b, 0xfd, 0xcd, 0xc6, - 0xdc, 0x48, 0xb2, 0xe5, 0x70, 0xa6, 0xaf, 0xba, 0xb6, 0x83, 0x8a, 0x78, 0x22, 0xac, 0x19, 0x13, 0xab, 0x77, 0xce, - 0x42, 0x08, 0xbc, 0xb0, 0x50, 0x25, 0x2c, 0xd6, 0x26, 0x09, 0x2a, 0x52, 0x28, 0x32, 0x48, 0xe1, 0xb2, 0x9d, 0xb4, - 0x5a, 0x05, 0x42, 0x88, 0x8c, 0xeb, 0x81, 0xf0, 0x6d, 0x76, 0xf6, 0xf6, 0xf2, 0xea, 0x44, 0x1b, 0x53, 0x38, 0x5f, - 0x2e, 0x39, 0x75, 0x72, 0x79, 0xea, 0x26, 0x22, 0xa0, 0x8c, 0x31, 0x2c, 0xdf, 0x78, 0x29, 0x2e, 0x7b, 0xf2, 0xf2, - 0xa2, 0x17, 0x09, 0x24, 0x4a, 0x94, 0x11, 0x8d, 0xd4, 0x13, 0xad, 0x92, 0x61, 0xf3, 0x75, 0x79, 0x90, 0xbf, 0x86, - 0xf5, 0xf6, 0xca, 0xe2, 0x48, 0xab, 0x2a, 0x5a, 0x2d, 0xcd, 0xd3, 0x8c, 0x3b, 0x8e, 0x8f, 0x03, 0x44, 0xfa, 0xbe, - 0x98, 0xfd, 0xb1, 0xcc, 0xf7, 0x18, 0x34, 0x3b, 0x5e, 0xa7, 0xf4, 0x87, 0xd4, 0xce, 0x57, 0xcb, 0x6c, 0x33, 0x75, - 0x46, 0x17, 0xf0, 0x84, 0xcb, 0xdf, 0x0a, 0x7d, 0x55, 0x81, 0x9c, 0x5d, 0xf5, 0x5c, 0x4e, 0x12, 0x2b, 0x86, 0x26, - 0x95, 0x01, 0xa7, 0x06, 0xd5, 0x30, 0x1b, 0x61, 0xb6, 0x65, 0x6c, 0x54, 0x54, 0x88, 0x28, 0x37, 0xf7, 0x85, 0x54, - 0x82, 0xce, 0x0d, 0xea, 0xbe, 0x60, 0xda, 0x8d, 0x57, 0xa7, 0xbb, 0x42, 0xa1, 0xc8, 0xe0, 0x0c, 0x9b, 0xaa, 0x49, - 0x58, 0x6e, 0x49, 0xb2, 0x91, 0x78, 0x5d, 0xf9, 0x48, 0x25, 0x6d, 0x6c, 0xae, 0x22, 0x92, 0x21, 0x37, 0x01, 0x06, - 0x8e, 0x81, 0x9c, 0xeb, 0x29, 0x00, 0x8f, 0x19, 0x53, 0x38, 0xa9, 0xa4, 0x38, 0x0e, 0x5e, 0x48, 0xed, 0xde, 0xb3, - 0xdf, 0xbe, 0x39, 0x7b, 0x6f, 0x63, 0xb8, 0xea, 0x8c, 0x66, 0xb9, 0xb7, 0xb0, 0x55, 0x8e, 0x61, 0x13, 0xe2, 0xd5, - 0xb6, 0x67, 0xfb, 0x33, 0x38, 0xb4, 0x2d, 0x98, 0x6a, 0xeb, 0xa6, 0x79, 0x7d, 0x7d, 0xdd, 0x84, 0x13, 0x65, 0xcd, - 0x79, 0x16, 0x4b, 0x76, 0x13, 0xda, 0x45, 0x81, 0x5c, 0x1e, 0xd1, 0xa4, 0xbc, 0x0c, 0x29, 0x8d, 0xa9, 0x1b, 0xa7, - 0x13, 0x79, 0x1e, 0x76, 0xd5, 0x3d, 0x11, 0x5f, 0x1c, 0x8b, 0x4b, 0xbe, 0xfa, 0xc7, 0x5c, 0x5e, 0xaf, 0xc6, 0x33, - 0xf8, 0xd9, 0x87, 0xe0, 0xd5, 0x71, 0x8b, 0x47, 0xe2, 0xe1, 0x0c, 0x76, 0x93, 0x78, 0xda, 0x5d, 0xac, 0x51, 0xdd, - 0x00, 0xba, 0x88, 0xfa, 0x72, 0x6a, 0xb9, 0xa8, 0x75, 0xe1, 0xc5, 0x17, 0x17, 0xc5, 0x71, 0x0b, 0xfa, 0x6a, 0xe9, - 0x7e, 0x2f, 0xd3, 0xf0, 0x56, 0xb7, 0x2f, 0x29, 0x11, 0x2e, 0x7b, 0x4a, 0x48, 0x1f, 0xba, 0x80, 0x71, 0xc3, 0xbe, - 0xc0, 0x99, 0x62, 0xa1, 0xc3, 0xea, 0xa1, 0x18, 0x59, 0xc0, 0x30, 0x0b, 0x28, 0x01, 0x72, 0x83, 0xce, 0xc3, 0xb2, - 0x81, 0xd8, 0xed, 0xb2, 0x68, 0x1b, 0x80, 0xb2, 0x62, 0xb5, 0x7f, 0xa4, 0x9b, 0xbb, 0x22, 0x0b, 0x0d, 0x71, 0x68, - 0x02, 0x7f, 0x81, 0xe0, 0x5f, 0x01, 0xf8, 0x71, 0x4b, 0xa2, 0xe9, 0xc2, 0xbc, 0x76, 0x46, 0x5e, 0x08, 0x51, 0x22, - 0x73, 0x98, 0x71, 0xfc, 0x9e, 0xe3, 0x8f, 0x17, 0xa2, 0xaa, 0xd6, 0x12, 0x40, 0x7d, 0x05, 0x6d, 0xaa, 0xad, 0xd5, - 0xc1, 0x20, 0x8d, 0x63, 0x7f, 0x96, 0x53, 0x4f, 0xff, 0x50, 0x0a, 0x03, 0xe8, 0x1d, 0xeb, 0x1a, 0x9a, 0xca, 0x7b, - 0x3a, 0x05, 0x3d, 0x6e, 0x5d, 0x7d, 0xbc, 0xf2, 0x33, 0xa7, 0xd9, 0x0c, 0x9a, 0x97, 0x13, 0x54, 0xf0, 0x68, 0x61, - 0xaa, 0x1b, 0x0f, 0xdb, 0xed, 0x1e, 0x24, 0xa9, 0x36, 0xfd, 0x98, 0x4d, 0x12, 0x2f, 0xa6, 0x63, 0x5e, 0x70, 0x38, - 0x3d, 0xb8, 0xd0, 0xfa, 0x9d, 0xdb, 0x3d, 0xcc, 0xe8, 0xd4, 0x72, 0xe1, 0xef, 0xdd, 0x03, 0x17, 0x3c, 0xf4, 0x12, - 0x1e, 0x35, 0x45, 0x32, 0x34, 0x1c, 0xe5, 0xe0, 0x51, 0xed, 0x79, 0x61, 0x0c, 0x14, 0x50, 0xd0, 0x7d, 0x0b, 0x9e, - 0x59, 0x3c, 0xc2, 0x3c, 0x33, 0xeb, 0x25, 0x68, 0xb1, 0x36, 0x83, 0x75, 0x15, 0x6c, 0x1f, 0x15, 0xb9, 0xb0, 0x58, - 0x16, 0x6b, 0x78, 0x31, 0x54, 0xe9, 0x82, 0x25, 0xb3, 0x39, 0x1f, 0x0a, 0xcf, 0x7f, 0x06, 0x67, 0x48, 0x46, 0xd8, - 0x28, 0x01, 0x78, 0x46, 0xaa, 0x7d, 0xe0, 0xc7, 0x81, 0x03, 0x9d, 0x58, 0x4d, 0xeb, 0x28, 0xa3, 0x53, 0xd4, 0x9b, - 0xb2, 0xa4, 0x29, 0xdf, 0x1d, 0x1a, 0xba, 0x9b, 0xfb, 0x08, 0x9e, 0x0a, 0x57, 0xf4, 0x86, 0x45, 0x82, 0xef, 0x86, - 0x79, 0x5d, 0x8c, 0x8a, 0xa2, 0x97, 0x72, 0x67, 0xf8, 0xc2, 0x41, 0x23, 0xfc, 0xab, 0x71, 0x89, 0x8d, 0xad, 0xa9, - 0xda, 0xc6, 0x5d, 0xb4, 0xa5, 0x8a, 0x49, 0x97, 0xa2, 0xda, 0xaf, 0x04, 0x2a, 0xbe, 0x74, 0x6c, 0x9a, 0xcf, 0x9a, - 0x92, 0xfd, 0x34, 0x05, 0xf9, 0xd8, 0xd0, 0x14, 0x29, 0x77, 0x36, 0xa5, 0x0b, 0xc1, 0x59, 0xd4, 0x39, 0x16, 0xe9, - 0x71, 0x19, 0x95, 0xe7, 0x9e, 0xd4, 0xb3, 0x79, 0xd2, 0x09, 0xd5, 0xb6, 0xfe, 0xc5, 0x49, 0x9d, 0x4d, 0x81, 0xfc, - 0x2f, 0xef, 0xfa, 0xf3, 0xe3, 0x18, 0x06, 0xbc, 0xd0, 0x4a, 0x83, 0x79, 0x35, 0xca, 0x90, 0x8f, 0x1c, 0x54, 0xa8, - 0x3d, 0xf3, 0x44, 0xe8, 0xdd, 0xc6, 0x05, 0x83, 0x3b, 0x5c, 0x47, 0xd4, 0xe4, 0x09, 0x66, 0x06, 0x39, 0x01, 0xb5, - 0xdc, 0xf1, 0x5e, 0xc5, 0x66, 0xa4, 0xd6, 0x6e, 0x89, 0x09, 0x11, 0x3b, 0x4b, 0x42, 0xdb, 0xfa, 0x73, 0x10, 0xb3, - 0xe0, 0x23, 0xb1, 0x77, 0x17, 0x0e, 0x5a, 0x3f, 0x1a, 0x2a, 0x76, 0xa8, 0xe6, 0xb9, 0xa8, 0x1e, 0x6d, 0xc8, 0x5c, - 0x83, 0x9d, 0xca, 0xdb, 0x83, 0xec, 0x3e, 0xa8, 0x36, 0xc7, 0x2d, 0x39, 0x4e, 0xff, 0xa2, 0x38, 0xaf, 0x6e, 0x05, - 0xab, 0xa0, 0x00, 0x34, 0xcb, 0x72, 0x4b, 0xd0, 0x1f, 0xb1, 0xe5, 0x16, 0xaa, 0x59, 0x80, 0xd8, 0xa4, 0x7d, 0x64, - 0x5b, 0x92, 0xc1, 0x00, 0x9c, 0x5c, 0xf1, 0x1a, 0xdb, 0xfa, 0x73, 0x59, 0x46, 0x4b, 0xb7, 0x8f, 0xc8, 0x5b, 0x21, - 0x36, 0x8c, 0x05, 0xb6, 0xbe, 0x1b, 0x52, 0xee, 0xb3, 0x58, 0x36, 0xe9, 0x69, 0x2f, 0xc5, 0xca, 0x8c, 0x96, 0xcb, - 0xbc, 0x3e, 0x17, 0x56, 0xc7, 0xa0, 0x98, 0xd9, 0x71, 0xab, 0x82, 0x5b, 0xcc, 0x4c, 0xec, 0x0f, 0x33, 0x7e, 0x5a, - 0xcd, 0x50, 0xbe, 0xb3, 0xfe, 0x1c, 0x88, 0x93, 0x55, 0x00, 0x60, 0xaa, 0x00, 0x84, 0xc8, 0xbe, 0x54, 0x42, 0x1c, - 0x9f, 0xa4, 0x2e, 0xf7, 0xb3, 0x09, 0xe5, 0x2b, 0x88, 0xf5, 0x65, 0x22, 0x6f, 0x4f, 0x47, 0xf1, 0xd7, 0xa0, 0x0d, - 0xea, 0xd0, 0x82, 0x9e, 0x5b, 0x0c, 0x40, 0x55, 0x25, 0x1b, 0x35, 0xde, 0x08, 0x81, 0xec, 0x13, 0x8b, 0x23, 0xb9, - 0x7d, 0x2a, 0xb8, 0xbd, 0x8c, 0xc3, 0x59, 0x62, 0x2c, 0x01, 0x62, 0x61, 0x5b, 0x03, 0x09, 0x39, 0x0d, 0x25, 0xcc, - 0x24, 0x13, 0xad, 0xd2, 0xe2, 0xb8, 0x25, 0x6b, 0x4b, 0x76, 0x2c, 0x2b, 0x01, 0x12, 0xc4, 0x3e, 0xad, 0x70, 0x00, - 0xc9, 0xdf, 0x26, 0x1e, 0x42, 0x76, 0x55, 0x12, 0x9b, 0x38, 0x63, 0xd6, 0x3f, 0x8e, 0xfd, 0x4b, 0x1a, 0xf7, 0x77, - 0x17, 0xd9, 0x72, 0xd9, 0x2e, 0x8e, 0x5b, 0xf2, 0xd1, 0x3a, 0x16, 0x7c, 0x43, 0xde, 0x0d, 0x2a, 0x96, 0x18, 0x0e, - 0x6e, 0x42, 0x4a, 0xac, 0xce, 0x05, 0xf3, 0x54, 0x07, 0x85, 0x6d, 0x89, 0x2c, 0x14, 0x51, 0xa9, 0xd4, 0x69, 0x0a, - 0xdb, 0x62, 0xe1, 0x7a, 0x59, 0xce, 0xe9, 0x0c, 0x4a, 0xa3, 0xe5, 0xb2, 0x53, 0xd8, 0xd6, 0x94, 0x25, 0xf0, 0x94, - 0x2d, 0x97, 0xe2, 0x4c, 0xe4, 0x94, 0x25, 0x4e, 0x1b, 0xc8, 0xd6, 0xb6, 0xa6, 0xfe, 0x8d, 0x98, 0xb0, 0x7e, 0xe3, - 0xdf, 0x38, 0x1d, 0xf5, 0xca, 0x2d, 0xf1, 0x93, 0x03, 0xc5, 0x55, 0x2b, 0xea, 0xab, 0x15, 0x0d, 0xf1, 0x5c, 0x9e, - 0xf6, 0x22, 0x4e, 0x48, 0xfc, 0xcd, 0x2b, 0x1a, 0xea, 0x15, 0x9d, 0x6f, 0x59, 0xd1, 0xf9, 0x1d, 0x2b, 0x1a, 0xa8, - 0xd5, 0xb3, 0x4a, 0xdc, 0xa5, 0xcb, 0x65, 0xa7, 0x5d, 0x61, 0xef, 0xb8, 0x15, 0xb2, 0x2b, 0x58, 0x0d, 0xd0, 0xd4, - 0x38, 0x9b, 0xd2, 0xcd, 0x44, 0x59, 0x47, 0x31, 0xfd, 0x2c, 0x4c, 0x56, 0x58, 0xc8, 0xea, 0x58, 0x30, 0xe9, 0xba, - 0x0c, 0x4c, 0xfe, 0x91, 0x94, 0xcd, 0x00, 0x0f, 0x39, 0xe0, 0x21, 0xd2, 0x77, 0x85, 0x3a, 0xf6, 0x7b, 0x1b, 0xdb, - 0x96, 0xad, 0xc9, 0xfa, 0xa2, 0x38, 0x07, 0x19, 0x21, 0xe6, 0x77, 0x2f, 0x5a, 0x84, 0xda, 0x76, 0x7f, 0x3b, 0xcd, - 0x41, 0x0e, 0xc1, 0x75, 0x9a, 0x85, 0xb6, 0x27, 0xab, 0x7e, 0x16, 0xaa, 0xa6, 0x2c, 0x51, 0x19, 0x69, 0x5b, 0x69, - 0xad, 0x7a, 0x6f, 0x52, 0x5c, 0xf7, 0xf0, 0x50, 0xd6, 0x98, 0xf9, 0x9c, 0xd3, 0x2c, 0x51, 0x94, 0x6b, 0xdb, 0xff, - 0x21, 0xa8, 0x70, 0x03, 0x5f, 0x09, 0xf4, 0x02, 0x68, 0x02, 0x54, 0x3a, 0xb7, 0xe2, 0xf9, 0x52, 0x3c, 0xed, 0x54, - 0xca, 0xe6, 0x2d, 0x32, 0xf5, 0x7e, 0x59, 0x04, 0x66, 0xc8, 0x7c, 0x4a, 0xc3, 0x73, 0xc1, 0xa0, 0x07, 0xf1, 0x85, - 0x52, 0x1e, 0x57, 0xc4, 0x5d, 0xd5, 0x00, 0xdb, 0x3f, 0xcd, 0xbb, 0x8f, 0x0e, 0x4e, 0x6d, 0x2c, 0x79, 0x7c, 0x3a, - 0x1e, 0xdb, 0xa8, 0xb0, 0xee, 0xd7, 0xac, 0x73, 0xf0, 0xd3, 0xfc, 0xeb, 0x67, 0xed, 0xaf, 0xcb, 0xc6, 0x09, 0x10, - 0x91, 0x4a, 0x82, 0xd0, 0xa2, 0xca, 0x80, 0x57, 0xcf, 0x68, 0xec, 0x27, 0xdb, 0xa7, 0x33, 0x34, 0xa7, 0x93, 0xcf, - 0x28, 0x0d, 0x81, 0x38, 0xf1, 0x5a, 0xe9, 0x79, 0x4c, 0xaf, 0xa8, 0xbe, 0xa1, 0x71, 0xc3, 0x60, 0x1b, 0x5a, 0x04, - 0xe9, 0x3c, 0xe1, 0x2a, 0x1b, 0x44, 0xb1, 0x5a, 0x63, 0x4a, 0x17, 0x62, 0x0e, 0xa6, 0x3a, 0x7f, 0x2b, 0xe5, 0x5c, - 0x5d, 0x7a, 0x15, 0x17, 0xd8, 0x36, 0x00, 0xd8, 0x0a, 0xd9, 0x60, 0x4b, 0xb9, 0xd7, 0xc6, 0xed, 0x6d, 0xb0, 0xe1, - 0x0e, 0xf2, 0x6c, 0x7b, 0xa4, 0xf1, 0x24, 0x1c, 0xba, 0xb5, 0x4b, 0x35, 0xb6, 0xe2, 0xeb, 0x93, 0x18, 0xb8, 0xcc, - 0xa0, 0xb3, 0x84, 0xe6, 0xf9, 0x56, 0x04, 0x94, 0x8b, 0x88, 0xed, 0xaa, 0xb6, 0xbd, 0xa5, 0x17, 0xdc, 0xc6, 0xb0, - 0xc3, 0x04, 0xc0, 0x65, 0x58, 0x59, 0xd5, 0xa2, 0xe3, 0x31, 0x0d, 0x4a, 0x7f, 0x38, 0x04, 0x08, 0xc7, 0x2c, 0xe6, - 0x10, 0x27, 0x13, 0x01, 0x2c, 0xfb, 0x75, 0x9a, 0x50, 0x1b, 0xe9, 0x94, 0x57, 0x05, 0xbf, 0x92, 0xff, 0x9b, 0xe1, - 0x91, 0x3d, 0xd6, 0x61, 0x51, 0xa3, 0x2c, 0x97, 0xda, 0x5d, 0x53, 0x2b, 0xaf, 0x23, 0x32, 0x15, 0xfe, 0x98, 0x6d, - 0x1b, 0xe8, 0x7e, 0xdb, 0x64, 0xd1, 0xf9, 0xfa, 0xb0, 0xd3, 0x2e, 0x6c, 0x6c, 0x43, 0x77, 0xf7, 0xdd, 0x25, 0xa2, - 0xd5, 0x3e, 0xb4, 0x9a, 0x27, 0x9f, 0xd3, 0xae, 0xdb, 0x79, 0xdc, 0xb1, 0xb1, 0xbc, 0x6b, 0x01, 0x15, 0x25, 0x33, - 0x08, 0xc0, 0x43, 0xfc, 0xbb, 0xa7, 0x52, 0xef, 0xfc, 0x7e, 0xf0, 0x3c, 0xec, 0xb4, 0x6d, 0x6c, 0xe7, 0x3c, 0x9d, - 0x7d, 0xc6, 0x14, 0xf6, 0x6d, 0x6c, 0x07, 0x71, 0x9a, 0x53, 0x73, 0x0e, 0x52, 0x9d, 0xfd, 0xfd, 0x93, 0x90, 0x10, - 0xcd, 0x32, 0x9a, 0xe7, 0x96, 0xd9, 0xbf, 0x22, 0xa5, 0x4f, 0x30, 0xcc, 0x8d, 0x14, 0x97, 0x53, 0x2e, 0xf0, 0x22, - 0xaf, 0x41, 0x30, 0xa9, 0x4a, 0x96, 0xad, 0x11, 0x9b, 0x10, 0x01, 0x25, 0x63, 0x93, 0xda, 0xd5, 0x27, 0x47, 0xde, - 0xb0, 0xf5, 0xe4, 0xc0, 0x32, 0x70, 0xbe, 0x3e, 0x40, 0xad, 0x64, 0xca, 0x92, 0xf3, 0x0d, 0xa5, 0xfe, 0xcd, 0x86, - 0x52, 0x50, 0xd9, 0x4a, 0xe8, 0xd4, 0x15, 0x3d, 0x9f, 0xc6, 0x7a, 0xa5, 0xf8, 0x98, 0x20, 0x86, 0xc2, 0xff, 0xf8, - 0x09, 0x48, 0x8d, 0x65, 0x10, 0x3d, 0xfc, 0xf6, 0xe1, 0xa0, 0xe4, 0x73, 0x86, 0x2b, 0x7b, 0xf9, 0x7d, 0x33, 0x84, - 0xd2, 0x26, 0x38, 0xf9, 0xe3, 0xcf, 0x9a, 0x2b, 0xbd, 0xf9, 0x34, 0xc1, 0x19, 0x5a, 0xd5, 0xef, 0x58, 0x7a, 0x75, - 0xd4, 0x7f, 0x75, 0xed, 0x37, 0x14, 0x2b, 0xc5, 0xa7, 0x5c, 0xff, 0x20, 0x66, 0xd3, 0x8a, 0x04, 0xd6, 0xc1, 0x14, - 0x1a, 0x0f, 0x64, 0x7c, 0x99, 0x9d, 0x48, 0xd5, 0xe7, 0x1c, 0xce, 0xb1, 0xc2, 0x55, 0x21, 0xf3, 0x8c, 0x9e, 0xc7, - 0xe9, 0xf5, 0xea, 0xe5, 0x67, 0xdb, 0x2b, 0x47, 0x6c, 0x12, 0x19, 0x87, 0xd3, 0x28, 0x29, 0x17, 0xe1, 0xce, 0x01, - 0x8a, 0x7f, 0xf9, 0x67, 0xd7, 0xfd, 0x97, 0x7f, 0xfe, 0x64, 0x55, 0xe8, 0xbe, 0xb8, 0xc0, 0xbc, 0xea, 0x76, 0xfb, - 0xee, 0xda, 0x3c, 0x52, 0x1d, 0xe7, 0x9b, 0xeb, 0xac, 0x2d, 0x02, 0xbc, 0x5f, 0x5b, 0x82, 0xb5, 0x42, 0xb9, 0xfb, - 0xac, 0xdf, 0x02, 0x18, 0xcc, 0xeb, 0x93, 0x90, 0x41, 0xa5, 0xdf, 0x05, 0xda, 0x05, 0xf2, 0xee, 0xb5, 0x22, 0xbf, - 0x1d, 0xc3, 0x9f, 0x9a, 0xc3, 0xef, 0x04, 0x5f, 0xf9, 0x27, 0xe2, 0x8b, 0x8b, 0x32, 0x0b, 0xd1, 0x6c, 0x0a, 0x77, - 0x1c, 0x0c, 0xd6, 0x4a, 0x94, 0xe2, 0xe1, 0xb5, 0x51, 0x5f, 0x9c, 0xa1, 0x24, 0xf1, 0xc5, 0x2b, 0xb8, 0xd8, 0xe8, - 0xf8, 0x32, 0xd3, 0xce, 0xd6, 0x3b, 0x84, 0x03, 0x74, 0x51, 0x9f, 0x95, 0xe8, 0x74, 0x4d, 0x32, 0x40, 0x29, 0x98, - 0x1b, 0x00, 0x26, 0x8e, 0x2f, 0x94, 0xb5, 0x79, 0x2a, 0xdd, 0x30, 0xde, 0x2a, 0x69, 0x2b, 0xf7, 0x4c, 0x0d, 0xe9, - 0xd8, 0x7a, 0x2f, 0xf0, 0x25, 0x2a, 0xd3, 0xca, 0xba, 0x17, 0xae, 0x2e, 0xb0, 0x23, 0x4a, 0xf6, 0x73, 0xe5, 0xc7, - 0x57, 0xf7, 0x63, 0x7c, 0xdb, 0x05, 0xea, 0xd2, 0x5a, 0xfe, 0xa3, 0x55, 0x82, 0x65, 0x73, 0xb9, 0x49, 0x1f, 0xb8, - 0xf6, 0x39, 0xcd, 0xce, 0x23, 0x48, 0x84, 0xca, 0x3e, 0xc1, 0x9c, 0x60, 0xa5, 0x31, 0x15, 0x7f, 0x19, 0x51, 0x17, - 0x49, 0xff, 0x83, 0x38, 0x15, 0x83, 0x2c, 0x46, 0x18, 0xca, 0x58, 0x84, 0xff, 0xcf, 0xb7, 0xfe, 0xc3, 0xf0, 0xad, - 0xbb, 0x87, 0xa8, 0x9d, 0x91, 0xfe, 0xec, 0x85, 0xfc, 0x8f, 0xcd, 0xee, 0x72, 0xc1, 0xee, 0x7e, 0x03, 0xa3, 0xcb, - 0xff, 0x31, 0x8c, 0x4e, 0xd8, 0xc8, 0x9a, 0xd3, 0xad, 0x85, 0x9a, 0x6f, 0x5d, 0xff, 0xda, 0xbf, 0xad, 0xf6, 0x55, - 0x7c, 0x71, 0x72, 0xed, 0xdf, 0x56, 0x8b, 0xb0, 0x9d, 0x5d, 0xac, 0xf6, 0x31, 0xb0, 0xdf, 0xbc, 0xb6, 0x3d, 0xfb, - 0xcd, 0xd7, 0x5f, 0xdb, 0xf8, 0x22, 0xa7, 0x7c, 0x00, 0x85, 0x64, 0x77, 0xb1, 0xb3, 0x5a, 0x11, 0xdc, 0x28, 0x30, - 0x45, 0x11, 0xf6, 0x82, 0xa4, 0x43, 0xe3, 0x3d, 0xcb, 0xcf, 0xd3, 0xc4, 0x84, 0xe6, 0x2d, 0x58, 0xf6, 0x9f, 0x0b, - 0x8e, 0xe8, 0x65, 0x0d, 0x1e, 0x51, 0xba, 0x0a, 0x90, 0x28, 0xac, 0x41, 0x54, 0x5d, 0x19, 0x74, 0x37, 0xff, 0xaf, - 0xae, 0x45, 0x90, 0xb7, 0x7d, 0x44, 0x83, 0xf8, 0xe2, 0x73, 0xc4, 0x87, 0x1c, 0xac, 0xf2, 0xd8, 0x69, 0x77, 0xa7, - 0x5f, 0xec, 0x2e, 0xa2, 0xbd, 0x3d, 0x36, 0xb0, 0xb1, 0xb8, 0xa7, 0xa9, 0xd8, 0x24, 0x5c, 0x72, 0xf8, 0x93, 0xc1, - 0x9f, 0xb4, 0x62, 0xd4, 0x2c, 0x19, 0x67, 0x7e, 0x46, 0xc3, 0xed, 0x4c, 0xba, 0xbc, 0xdf, 0x48, 0x91, 0x86, 0x4c, - 0xc0, 0xce, 0xcf, 0x45, 0xea, 0xd1, 0x94, 0x81, 0x3e, 0xba, 0x63, 0x7e, 0xc5, 0x47, 0x5d, 0x88, 0x56, 0x7e, 0x04, - 0xc0, 0x44, 0x38, 0x25, 0x79, 0x99, 0xeb, 0x00, 0xb7, 0x6a, 0xaa, 0xec, 0x10, 0x6c, 0x23, 0xe1, 0x75, 0x0f, 0x49, - 0x5f, 0xa4, 0x3d, 0xbc, 0x48, 0xb8, 0x13, 0xba, 0x3c, 0x63, 0x53, 0x07, 0xe1, 0x4e, 0x1b, 0x21, 0xed, 0x6c, 0x08, - 0x49, 0x7f, 0x87, 0xe5, 0xaf, 0xfd, 0xd7, 0x4e, 0x28, 0x2e, 0xe2, 0x12, 0x9f, 0xee, 0x81, 0x43, 0x92, 0x4f, 0xe6, - 0xe3, 0x31, 0xcd, 0x1c, 0x7d, 0x00, 0xf0, 0xab, 0x03, 0x38, 0x63, 0x0c, 0x6f, 0x9f, 0xfa, 0xdc, 0xff, 0x96, 0xd1, - 0x6b, 0x27, 0x45, 0xbd, 0xac, 0xba, 0x9c, 0x31, 0xc4, 0x73, 0x44, 0xfa, 0x11, 0x24, 0xc6, 0xbf, 0x48, 0xf8, 0x7e, - 0xd7, 0x99, 0x7f, 0x75, 0x80, 0x43, 0xb8, 0xf2, 0x42, 0x67, 0x75, 0xcb, 0xbb, 0x4a, 0x3e, 0xb0, 0x84, 0x1f, 0xc9, - 0x63, 0x98, 0x29, 0x52, 0xee, 0xc3, 0x32, 0x23, 0xc6, 0xf2, 0xcb, 0x0e, 0x43, 0xd2, 0x0f, 0x1a, 0x44, 0x1e, 0xca, - 0x14, 0xb7, 0xec, 0x9e, 0x46, 0x7e, 0x76, 0x0a, 0x07, 0xbe, 0x01, 0xd0, 0x4b, 0x9e, 0xfa, 0x4e, 0x50, 0x7e, 0xc9, - 0xc9, 0x69, 0xfd, 0xd4, 0x68, 0x4d, 0xb0, 0x48, 0x8a, 0xa9, 0x8a, 0x5a, 0x50, 0x74, 0x6e, 0x16, 0x91, 0xc6, 0x6e, - 0x0b, 0xc3, 0x1e, 0xec, 0x6d, 0xf4, 0xd1, 0xea, 0xa5, 0x6b, 0x5e, 0x67, 0xfe, 0xac, 0x8c, 0x1b, 0x9c, 0xfa, 0x59, - 0xc6, 0x68, 0x66, 0x39, 0xcf, 0x7f, 0x45, 0xde, 0xbf, 0xfc, 0xf3, 0xe6, 0xf8, 0x81, 0x0a, 0x19, 0x58, 0x90, 0x5c, - 0xd2, 0x14, 0xe9, 0xd8, 0xc4, 0x0e, 0x64, 0x43, 0x5b, 0x87, 0x3b, 0xf6, 0x8f, 0xda, 0xed, 0xb6, 0x0a, 0x09, 0x74, - 0xe4, 0x4f, 0x88, 0x01, 0xc0, 0x4f, 0x78, 0x10, 0x51, 0x65, 0x62, 0xcb, 0x00, 0xe5, 0x51, 0x7b, 0x76, 0x63, 0xf7, - 0x61, 0x3b, 0x28, 0x28, 0xde, 0xd1, 0x19, 0xf5, 0xf9, 0x67, 0x8d, 0x9f, 0x89, 0x26, 0xe5, 0xf0, 0x1d, 0x3d, 0x74, - 0x35, 0xee, 0xca, 0xa0, 0x87, 0xab, 0x83, 0xbe, 0x67, 0x53, 0x71, 0x75, 0xd3, 0xb6, 0x51, 0x85, 0xa7, 0xba, 0x36, - 0x26, 0x97, 0x2d, 0x6c, 0x4b, 0x60, 0x3c, 0x4a, 0xe3, 0x90, 0x66, 0xc4, 0xa6, 0xee, 0xc4, 0xb5, 0x1e, 0xb7, 0xdb, - 0x6d, 0xdc, 0x3c, 0x38, 0x6c, 0xb7, 0xf1, 0xe1, 0xc3, 0x36, 0x6e, 0xc2, 0x1f, 0xd7, 0x75, 0x57, 0x60, 0xb8, 0x2b, - 0x6a, 0xdb, 0x69, 0x67, 0x74, 0xaa, 0x00, 0xbc, 0x33, 0xac, 0x58, 0xed, 0x09, 0xb8, 0x60, 0x5a, 0xed, 0x7b, 0x29, - 0xd9, 0xd4, 0x05, 0x07, 0x2a, 0x1d, 0x55, 0xf8, 0x0b, 0xd3, 0x2a, 0x68, 0x4a, 0xe5, 0xc5, 0x7f, 0x2f, 0x14, 0x21, - 0x78, 0xd6, 0x29, 0xdc, 0x5e, 0x2a, 0xe2, 0xa5, 0x90, 0x0a, 0x04, 0x1f, 0x48, 0xe3, 0x3e, 0x4b, 0xe0, 0xdb, 0x59, - 0x3a, 0x6a, 0xaa, 0x19, 0x55, 0xba, 0x92, 0x74, 0xfb, 0x40, 0x86, 0xa5, 0x37, 0x11, 0xc4, 0xe8, 0x01, 0xc2, 0xfe, - 0x7d, 0x1a, 0xa8, 0x15, 0x84, 0xfa, 0xc1, 0x7d, 0xea, 0x6b, 0xec, 0x8f, 0x1e, 0x88, 0xe4, 0xa4, 0x9d, 0x68, 0xb9, - 0xdc, 0xf1, 0x97, 0xcb, 0x9d, 0xe0, 0xfe, 0x33, 0x94, 0xcb, 0xab, 0x4f, 0x41, 0xc0, 0xcd, 0x9f, 0x12, 0xe8, 0x17, - 0x50, 0xee, 0x45, 0x58, 0x82, 0x24, 0x9f, 0x7c, 0xac, 0x06, 0x94, 0x8f, 0x41, 0xb1, 0x82, 0x94, 0x90, 0x44, 0xd2, - 0x3e, 0x5f, 0x2e, 0x15, 0xf1, 0xe3, 0x39, 0xf1, 0xcb, 0xa2, 0x8e, 0x8d, 0x67, 0x24, 0x28, 0x1f, 0x6d, 0x01, 0xf2, - 0x4c, 0x71, 0xa9, 0x0a, 0xe2, 0x6b, 0x3f, 0x4b, 0x4c, 0x80, 0x5f, 0xa7, 0x96, 0x1a, 0xd6, 0x9a, 0x65, 0xe9, 0x15, - 0x83, 0xe4, 0x97, 0x95, 0x81, 0xa7, 0x04, 0x2e, 0xfe, 0xea, 0x99, 0xa1, 0x70, 0xa3, 0x83, 0xf7, 0x9a, 0xcf, 0xc2, - 0x2d, 0x93, 0xe5, 0x04, 0xbd, 0x50, 0xcd, 0xcd, 0x9b, 0xeb, 0x69, 0xbd, 0xf3, 0xaf, 0xbd, 0x99, 0x7e, 0x78, 0x26, - 0xf3, 0x6c, 0xbc, 0x69, 0x79, 0xb2, 0xe6, 0x2d, 0x79, 0x0d, 0xb1, 0x1f, 0x5b, 0xf3, 0x6d, 0xb8, 0x67, 0x53, 0xf2, - 0xb8, 0x77, 0x2f, 0xcf, 0xa8, 0x9f, 0x05, 0xd1, 0x5b, 0x3f, 0xf3, 0xa7, 0x79, 0x6f, 0xac, 0x6f, 0xf1, 0xd2, 0x14, - 0x70, 0x3e, 0x16, 0x99, 0x4e, 0x49, 0x70, 0x6b, 0xe3, 0x10, 0xe1, 0xea, 0xbd, 0x84, 0x40, 0xfa, 0xb9, 0x6d, 0x3c, - 0x37, 0x5f, 0xc1, 0x3a, 0xdb, 0x78, 0x8a, 0xb0, 0x4c, 0x20, 0x7a, 0xfb, 0x47, 0xa6, 0x0e, 0x61, 0xc8, 0x75, 0xf1, - 0xc6, 0x6e, 0xf5, 0x95, 0x3b, 0x9d, 0x4c, 0xf4, 0x7e, 0x25, 0x99, 0x68, 0x03, 0x1a, 0xad, 0x8c, 0xe6, 0xb3, 0x34, - 0xc9, 0xa9, 0x8d, 0xdf, 0x43, 0x3b, 0x79, 0x15, 0xb3, 0xd9, 0x70, 0x8d, 0xe6, 0xca, 0xa6, 0xe2, 0x8d, 0x6c, 0x07, - 0x41, 0x9d, 0xf7, 0xdf, 0x97, 0x71, 0x7c, 0x1d, 0xdf, 0x11, 0x89, 0xe8, 0x8c, 0x6e, 0xc9, 0x95, 0xcd, 0xe9, 0x27, - 0x73, 0x65, 0xe3, 0x7b, 0xe5, 0xca, 0xe6, 0xf4, 0x8f, 0xce, 0x95, 0x65, 0xd4, 0xc8, 0x95, 0x05, 0x39, 0xf7, 0xf5, - 0xbd, 0x52, 0x2e, 0x75, 0x26, 0x5c, 0x7a, 0x9d, 0x93, 0x8e, 0x8a, 0x81, 0xc4, 0xe9, 0x04, 0xf2, 0x2d, 0xff, 0xf1, - 0xe9, 0x93, 0x71, 0x3a, 0x31, 0x93, 0x27, 0xe1, 0xc3, 0x24, 0x40, 0x76, 0x38, 0x23, 0x0b, 0xfb, 0xa7, 0x9b, 0xce, - 0x93, 0x61, 0xa7, 0xb7, 0xdf, 0x99, 0xda, 0x9e, 0x0d, 0x4e, 0x47, 0x51, 0xd0, 0xee, 0xed, 0xef, 0x43, 0xc1, 0xb5, - 0x51, 0xd0, 0x85, 0x02, 0x66, 0x14, 0x1c, 0x42, 0x41, 0x60, 0x14, 0x3c, 0x84, 0x82, 0xd0, 0x28, 0x78, 0x04, 0x05, - 0x57, 0x76, 0x31, 0x64, 0x65, 0x42, 0xf0, 0x23, 0x24, 0x6e, 0x30, 0xdc, 0xc9, 0xea, 0xa7, 0xb7, 0x23, 0xa2, 0xab, - 0x3c, 0x2a, 0x6f, 0x7e, 0x68, 0x1e, 0xe8, 0x8b, 0x0a, 0x2f, 0xbe, 0xb8, 0x00, 0xd6, 0x0a, 0x17, 0xb1, 0x60, 0x88, - 0x49, 0xca, 0x9a, 0xfb, 0xfa, 0xb5, 0xed, 0x95, 0x59, 0xb3, 0x6d, 0xdc, 0xd5, 0x79, 0xb3, 0x9e, 0x8d, 0x04, 0x5f, - 0x92, 0x2f, 0x0e, 0x1b, 0xa1, 0xea, 0x16, 0xee, 0x00, 0xac, 0x2e, 0xe0, 0xdc, 0x47, 0x78, 0xaa, 0x15, 0x20, 0xea, - 0xc0, 0x07, 0x18, 0xde, 0xb3, 0x29, 0xd5, 0xfb, 0x45, 0x0f, 0x60, 0x89, 0xcc, 0xe2, 0x5e, 0x54, 0x29, 0x46, 0x6f, - 0xf1, 0xb8, 0xba, 0xf3, 0xf5, 0x3d, 0x91, 0x77, 0xe8, 0x65, 0x58, 0x86, 0xb9, 0x66, 0x98, 0xfb, 0x13, 0x0f, 0x52, - 0x28, 0x21, 0x63, 0xc4, 0x1b, 0x13, 0x42, 0xda, 0x83, 0xb9, 0xf7, 0x16, 0x5f, 0x47, 0x34, 0xf1, 0xa6, 0x45, 0xaf, - 0x5c, 0x7f, 0x99, 0xd2, 0xf9, 0xbe, 0xbc, 0x28, 0x5c, 0xd0, 0x44, 0xf5, 0x56, 0x42, 0xd9, 0x2c, 0x69, 0x67, 0x4b, - 0xce, 0x9f, 0xa1, 0xec, 0x8c, 0xe3, 0xf4, 0xba, 0x09, 0xe2, 0x7e, 0x63, 0x1e, 0x20, 0xcc, 0xad, 0xcc, 0x03, 0x7c, - 0x09, 0xb0, 0x96, 0x4f, 0xef, 0xfd, 0x49, 0xf9, 0xfb, 0x15, 0xcd, 0x73, 0x7f, 0xa2, 0x6a, 0x6e, 0xcf, 0xfb, 0x13, - 0x20, 0x9a, 0x39, 0x7f, 0x1a, 0x08, 0x48, 0xce, 0x03, 0x84, 0x40, 0x40, 0x57, 0xe5, 0xea, 0xc1, 0xcc, 0xeb, 0x69, - 0x7e, 0x02, 0x55, 0xf5, 0x22, 0xee, 0x4f, 0xaa, 0x82, 0xe3, 0x59, 0x46, 0x55, 0x02, 0x21, 0x60, 0xb1, 0x38, 0x6e, - 0x41, 0x81, 0x7c, 0xbd, 0x25, 0x9d, 0x4f, 0x73, 0x97, 0xed, 0x49, 0x7d, 0x96, 0x4e, 0xe7, 0x33, 0x4f, 0xa6, 0x94, - 0xc7, 0x52, 0xd6, 0x33, 0xf2, 0xbe, 0xec, 0x04, 0xf0, 0x9f, 0x3a, 0x78, 0xf1, 0xe5, 0x78, 0x3c, 0xbe, 0x33, 0xbd, - 0xef, 0xcb, 0x70, 0x4c, 0xbb, 0xf4, 0xb0, 0x07, 0xa7, 0x16, 0x9a, 0x2a, 0x11, 0xad, 0x53, 0x08, 0xdc, 0x2d, 0xee, - 0x57, 0x19, 0x72, 0xd6, 0x78, 0xb4, 0xb8, 0x7f, 0xaa, 0x5f, 0x31, 0xcb, 0xe8, 0x62, 0xea, 0x67, 0x13, 0x96, 0x78, - 0xed, 0xc2, 0xbd, 0x5a, 0x28, 0x50, 0x8f, 0x8e, 0x8e, 0x0a, 0x37, 0xd4, 0x4f, 0xed, 0x30, 0x2c, 0xdc, 0x60, 0x51, - 0x4e, 0xa3, 0xdd, 0x1e, 0x8f, 0x0b, 0x97, 0xe9, 0x82, 0xfd, 0x6e, 0x10, 0xee, 0x77, 0x0b, 0xf7, 0xda, 0xa8, 0x51, - 0xb8, 0x54, 0x3d, 0x65, 0x34, 0xac, 0x1d, 0x7d, 0x78, 0xd4, 0x6e, 0x17, 0xae, 0x24, 0xb4, 0x05, 0xc4, 0xe4, 0xe4, - 0x4f, 0xcf, 0x9f, 0x73, 0x30, 0x98, 0x8a, 0x5e, 0xcc, 0x9d, 0xe1, 0xae, 0xba, 0x56, 0x52, 0x7e, 0x87, 0xb1, 0x40, - 0x23, 0xfc, 0xb5, 0x99, 0x39, 0x07, 0xc4, 0x2c, 0x32, 0xe6, 0x62, 0x9d, 0x58, 0x57, 0x7b, 0x0d, 0x94, 0x25, 0x5e, - 0x7f, 0x4d, 0xe2, 0x2a, 0xa1, 0x0e, 0xf8, 0x18, 0xd4, 0x94, 0xb7, 0x9f, 0x27, 0xdb, 0xa4, 0x47, 0xf6, 0x69, 0xe9, - 0x71, 0x79, 0x1f, 0xe1, 0x91, 0xfd, 0xe1, 0xc2, 0x23, 0x31, 0x85, 0x87, 0x64, 0x1d, 0xd7, 0x9c, 0xd8, 0x41, 0x44, - 0x83, 0x8f, 0x97, 0xe9, 0x4d, 0x13, 0xb6, 0x44, 0x66, 0x0b, 0xb1, 0x72, 0xf5, 0x5b, 0x33, 0xf9, 0x75, 0x67, 0xc6, - 0x47, 0x1c, 0x85, 0x8e, 0xff, 0x26, 0x21, 0xf6, 0x1b, 0x1d, 0xd8, 0x93, 0x25, 0xe3, 0x31, 0xb1, 0xdf, 0x8c, 0xc7, - 0xb6, 0xbe, 0x1c, 0xc7, 0xe7, 0x54, 0xd4, 0x7a, 0x5d, 0x2b, 0x11, 0xb5, 0xc0, 0xd0, 0xaf, 0xca, 0xcc, 0x02, 0x95, - 0x77, 0x67, 0xe6, 0xd8, 0xa9, 0x37, 0x21, 0xcb, 0x61, 0xab, 0xc1, 0xb7, 0x25, 0xeb, 0x97, 0xf3, 0x27, 0xb5, 0x2f, - 0x29, 0x95, 0x00, 0x6f, 0xf8, 0xfc, 0xd3, 0xea, 0xcd, 0x70, 0x13, 0xaa, 0x55, 0xfc, 0x27, 0xb7, 0x2f, 0x42, 0xe7, - 0x9a, 0xa3, 0x82, 0xe5, 0x6f, 0x92, 0x95, 0x5b, 0x1f, 0x24, 0x8c, 0x84, 0x98, 0xd3, 0x2a, 0x78, 0x3a, 0x99, 0xc4, - 0xe2, 0x30, 0x49, 0xcd, 0xe0, 0x96, 0xcd, 0x07, 0xb5, 0xf9, 0x7a, 0x66, 0x43, 0xf5, 0x79, 0x0d, 0xf1, 0xbd, 0x61, - 0x79, 0x5a, 0xf8, 0x4a, 0x7d, 0x78, 0x56, 0xc4, 0x04, 0x17, 0x8a, 0xc7, 0x2f, 0xe4, 0x19, 0x53, 0x8e, 0x59, 0x28, - 0x9b, 0xb3, 0xb0, 0x28, 0xd4, 0xe9, 0xfc, 0x90, 0xe5, 0x33, 0xd0, 0x9e, 0x64, 0x4b, 0xfa, 0x29, 0x16, 0x9e, 0x5f, - 0x1b, 0xc9, 0x6d, 0xb5, 0xe5, 0x2a, 0xb4, 0x9d, 0x26, 0xb3, 0x85, 0xae, 0x79, 0x61, 0x2b, 0x93, 0x4d, 0x23, 0xd1, - 0xb6, 0x24, 0x3e, 0x65, 0xda, 0x9d, 0x31, 0x43, 0xc8, 0xfc, 0x29, 0x17, 0x44, 0xbf, 0xd2, 0x05, 0x85, 0x69, 0x65, - 0x89, 0x37, 0x12, 0x5b, 0x22, 0x55, 0x2c, 0x9f, 0xf9, 0x89, 0x36, 0xe6, 0x24, 0x3f, 0xd8, 0x5d, 0x54, 0x2b, 0x5f, - 0xd8, 0x1a, 0x6c, 0x49, 0xbc, 0xfd, 0xe3, 0x16, 0x34, 0xe8, 0x5b, 0x35, 0xd0, 0x93, 0xb5, 0x0c, 0xb3, 0xbb, 0xf3, - 0xae, 0x3f, 0x5e, 0xb8, 0xf9, 0x35, 0x76, 0xf3, 0x6b, 0xeb, 0xab, 0x45, 0xf3, 0x9a, 0x5e, 0x7e, 0x64, 0xbc, 0xc9, - 0xfd, 0x59, 0x13, 0xbc, 0xa7, 0x22, 0x33, 0x44, 0xb1, 0x67, 0xa1, 0xa3, 0x4b, 0xd3, 0xaf, 0x37, 0xcf, 0x21, 0x3d, - 0x5b, 0x98, 0x51, 0x5e, 0x92, 0x26, 0xb4, 0x57, 0x3f, 0xbe, 0x67, 0x66, 0x18, 0x6b, 0x6c, 0x8d, 0x16, 0x29, 0xa4, - 0x73, 0xf3, 0x5b, 0xaf, 0xad, 0xd8, 0x7a, 0x5b, 0xa7, 0x0f, 0xb7, 0x37, 0xd6, 0xf7, 0x14, 0x72, 0x1b, 0x42, 0x7a, - 0x65, 0xeb, 0xf9, 0xcf, 0xdb, 0xf2, 0xbb, 0x3f, 0x75, 0x98, 0x0d, 0xf2, 0x49, 0xf4, 0xff, 0xc6, 0x29, 0xc0, 0xd5, - 0x62, 0x71, 0x98, 0xed, 0x3e, 0x90, 0x79, 0xfe, 0x98, 0xd3, 0x0c, 0xdf, 0xa7, 0xe6, 0xa5, 0xb8, 0x77, 0x62, 0x01, - 0x62, 0xc6, 0xeb, 0x1c, 0xd5, 0x53, 0xb1, 0xef, 0xee, 0xfe, 0xee, 0xe9, 0x17, 0x0a, 0x47, 0xfa, 0x1e, 0x56, 0xdb, - 0xee, 0xc1, 0x46, 0x88, 0xfd, 0x5b, 0x8f, 0x25, 0x42, 0xe6, 0x5d, 0x42, 0x52, 0x48, 0x6f, 0x96, 0xaa, 0x53, 0x99, - 0x19, 0x8d, 0xc5, 0xa7, 0xd7, 0xd5, 0x52, 0xec, 0x3f, 0x9c, 0xdd, 0xe8, 0xd5, 0xe8, 0xac, 0x9c, 0xb6, 0xfc, 0x43, - 0x0f, 0x55, 0x6e, 0x3f, 0xc5, 0x59, 0x3f, 0x18, 0x78, 0x38, 0xbb, 0xe9, 0x49, 0x41, 0xdb, 0xcc, 0x24, 0x54, 0xed, - 0xd9, 0x8d, 0x79, 0xac, 0xb4, 0xea, 0xc8, 0x72, 0xf7, 0x73, 0x8b, 0xfa, 0x39, 0xed, 0xc1, 0x97, 0xa6, 0x58, 0xe0, - 0xc7, 0x4a, 0x98, 0x4f, 0x59, 0x18, 0xc6, 0xb4, 0xa7, 0xe5, 0xb5, 0xd5, 0x79, 0x08, 0xa7, 0x32, 0xcd, 0x25, 0xab, - 0xaf, 0x8a, 0x81, 0xbc, 0x12, 0x4f, 0xfe, 0x65, 0x9e, 0xc6, 0xf0, 0x9d, 0xc7, 0x8d, 0xe8, 0x54, 0xc7, 0x15, 0xdb, - 0x15, 0xf2, 0xc4, 0xef, 0xfa, 0x5c, 0x0e, 0xdb, 0x7f, 0xea, 0x89, 0x05, 0x6f, 0xf7, 0x78, 0x3a, 0xf3, 0x9a, 0xfb, - 0xf5, 0x89, 0xc0, 0xab, 0x72, 0x0a, 0x78, 0xc3, 0xb4, 0x30, 0x48, 0x2b, 0xc9, 0xa7, 0x2d, 0xb7, 0xa3, 0xca, 0x44, - 0x07, 0x60, 0x84, 0x96, 0x45, 0x45, 0x7d, 0x32, 0xff, 0x98, 0xdd, 0xf2, 0x78, 0xf3, 0x6e, 0x79, 0xac, 0x77, 0xcb, - 0xdd, 0x14, 0xfb, 0xe5, 0xb8, 0x03, 0xff, 0xf5, 0xaa, 0x09, 0x79, 0x6d, 0x6b, 0x7f, 0x76, 0x63, 0x81, 0x9e, 0xd6, - 0xec, 0xce, 0x6e, 0xe4, 0xa1, 0x5a, 0x48, 0x5c, 0x6b, 0xc3, 0x31, 0x53, 0xdc, 0xb6, 0xa0, 0x10, 0xfe, 0x6f, 0xd7, - 0x5e, 0x75, 0x0e, 0xe0, 0x1d, 0xb4, 0x3a, 0x5c, 0x7f, 0xd7, 0xbd, 0x7b, 0xd3, 0x7a, 0x49, 0xca, 0x1d, 0x4f, 0x73, - 0x63, 0xe4, 0x72, 0xff, 0xf2, 0x92, 0x86, 0xde, 0x38, 0x0d, 0xe6, 0xf9, 0x3f, 0x29, 0xf8, 0x15, 0x12, 0xef, 0xdc, - 0xd2, 0x2b, 0xfd, 0xe8, 0xa6, 0xf2, 0x88, 0xaf, 0xee, 0x61, 0x51, 0xae, 0x93, 0x97, 0x07, 0x7e, 0x4c, 0x9d, 0xae, - 0x7b, 0xb0, 0x61, 0x13, 0xfc, 0x9b, 0xac, 0xcd, 0xc6, 0xc9, 0xfc, 0x5e, 0x64, 0xdc, 0x89, 0x84, 0xcf, 0xc2, 0x81, - 0xb9, 0x86, 0xed, 0xa3, 0xcd, 0xe0, 0x0e, 0xf5, 0x48, 0x23, 0x2d, 0x14, 0x94, 0xdc, 0x09, 0xe9, 0xd8, 0x9f, 0xc7, - 0xfc, 0xee, 0x5e, 0xb7, 0x51, 0xc6, 0x5a, 0xaf, 0x77, 0x30, 0xf4, 0xaa, 0xee, 0x3d, 0xb9, 0xf4, 0x97, 0x8f, 0x0f, - 0xe0, 0x3f, 0x79, 0xf8, 0xe5, 0xb2, 0xd2, 0xd5, 0xa5, 0xd5, 0x0b, 0xba, 0xfa, 0x55, 0x4d, 0x19, 0x97, 0x22, 0x5c, - 0xe8, 0xe3, 0xf7, 0xad, 0x0d, 0x5a, 0xe5, 0xbd, 0xaa, 0x2b, 0x2d, 0xeb, 0xb3, 0x6a, 0x7f, 0x5e, 0xe7, 0xf7, 0xac, - 0x1b, 0x48, 0xcd, 0xb5, 0x5e, 0x57, 0x7d, 0x7a, 0x7e, 0xad, 0xb2, 0xc6, 0xb8, 0xa8, 0x7f, 0x45, 0x2e, 0x4b, 0x13, - 0x45, 0xa6, 0xa2, 0x82, 0x95, 0x72, 0x25, 0xad, 0x94, 0x94, 0x92, 0x8b, 0xe3, 0xc1, 0xcd, 0x34, 0xb6, 0xae, 0xe4, - 0xfd, 0x38, 0xc4, 0xee, 0xb8, 0x6d, 0xdb, 0x12, 0x4e, 0x3a, 0xf8, 0x4c, 0x97, 0xfd, 0xe1, 0xfd, 0xd7, 0xcd, 0x23, - 0x7b, 0x00, 0x9a, 0xd6, 0xd5, 0x44, 0x68, 0x76, 0x2f, 0xfd, 0x5b, 0x9a, 0x9d, 0x77, 0x95, 0x0b, 0x5e, 0xe6, 0x8b, - 0x8b, 0x32, 0xab, 0x6b, 0x5b, 0x37, 0xd3, 0x38, 0xc9, 0x89, 0x1d, 0x71, 0x3e, 0xf3, 0x5a, 0xad, 0xeb, 0xeb, 0x6b, - 0xf7, 0x7a, 0xdf, 0x4d, 0xb3, 0x49, 0xab, 0xdb, 0x6e, 0xb7, 0xe1, 0x8b, 0x1f, 0xb6, 0x75, 0xc5, 0xe8, 0xf5, 0x93, - 0xf4, 0x86, 0xd8, 0x6d, 0xab, 0x6d, 0x75, 0xba, 0x47, 0x56, 0xa7, 0x7b, 0xe0, 0x3e, 0x3c, 0xb2, 0xfb, 0x5f, 0x58, - 0xd6, 0x71, 0x48, 0xc7, 0x39, 0xfc, 0xb0, 0xac, 0x63, 0xa1, 0x78, 0xc9, 0xdf, 0x96, 0xe5, 0x06, 0x71, 0xde, 0xec, - 0x58, 0x0b, 0xf5, 0x68, 0x59, 0x70, 0x8b, 0x90, 0x67, 0x7d, 0x39, 0xee, 0x8e, 0x0f, 0xc6, 0x8f, 0x7b, 0xaa, 0xb8, - 0xf8, 0xa2, 0x56, 0x1d, 0xcb, 0x7f, 0xbb, 0x46, 0xb3, 0x9c, 0x67, 0xe9, 0x47, 0xaa, 0x5c, 0xfb, 0x16, 0x88, 0x9e, - 0x8d, 0x4d, 0xbb, 0xeb, 0x23, 0x75, 0x8e, 0x2e, 0x83, 0x71, 0xb7, 0xaa, 0x2e, 0x60, 0x6c, 0x95, 0x40, 0x1e, 0xb7, - 0x34, 0xe8, 0xc7, 0x26, 0x9a, 0x3a, 0xcd, 0x4d, 0x88, 0xea, 0xd8, 0x6a, 0x8e, 0x13, 0x3d, 0xbf, 0x63, 0x38, 0xb4, - 0xae, 0x75, 0x55, 0x01, 0x81, 0x6d, 0x85, 0xc4, 0x7e, 0xd5, 0xe9, 0x1e, 0xe1, 0x4e, 0xe7, 0xa1, 0xfb, 0xf0, 0x28, - 0x68, 0xe3, 0x03, 0xf7, 0xa0, 0xb9, 0xef, 0x3e, 0xc4, 0x47, 0xcd, 0x23, 0x7c, 0xf4, 0xfc, 0x28, 0x68, 0x1e, 0xb8, - 0x07, 0xb8, 0xdd, 0x3c, 0x82, 0xc2, 0xe6, 0x51, 0xf3, 0xe8, 0xaa, 0x79, 0x70, 0x14, 0xb4, 0x45, 0x69, 0xd7, 0x3d, - 0x3c, 0x6c, 0x76, 0xda, 0xee, 0xe1, 0x21, 0x3e, 0x74, 0x1f, 0x3e, 0x6c, 0x76, 0xf6, 0xdd, 0x87, 0x0f, 0x5f, 0x1e, - 0x1e, 0xb9, 0xfb, 0xf0, 0x6e, 0x7f, 0x3f, 0xd8, 0x77, 0x3b, 0x9d, 0x26, 0xfc, 0xc1, 0x47, 0x6e, 0x57, 0xfe, 0xe8, - 0x74, 0xdc, 0xfd, 0x0e, 0x6e, 0xc7, 0x87, 0x5d, 0xf7, 0xe1, 0x63, 0x2c, 0xfe, 0x8a, 0x6a, 0x58, 0xfc, 0x81, 0x6e, - 0xf0, 0x63, 0xb7, 0xfb, 0x50, 0xfe, 0x12, 0x1d, 0x5e, 0x1d, 0x1c, 0xfd, 0x68, 0xb7, 0xb6, 0xce, 0xa1, 0x23, 0xe7, - 0x70, 0x74, 0xe8, 0xee, 0xef, 0xe3, 0x83, 0x8e, 0x7b, 0xb4, 0x1f, 0x35, 0x0f, 0xba, 0xee, 0xc3, 0x47, 0x41, 0xb3, - 0xe3, 0x3e, 0x7a, 0x84, 0xdb, 0xcd, 0x7d, 0xb7, 0x8b, 0x3b, 0xee, 0xc1, 0xbe, 0xf8, 0xb1, 0xef, 0x76, 0xaf, 0x1e, - 0x3d, 0x76, 0x1f, 0x1e, 0x46, 0x0f, 0xdd, 0x83, 0x6f, 0x0f, 0x8e, 0xdc, 0xee, 0x7e, 0xb4, 0xff, 0xd0, 0xed, 0x3e, - 0xba, 0x7a, 0xe8, 0x1e, 0x44, 0xcd, 0xee, 0xc3, 0x3b, 0x5b, 0x76, 0xba, 0x2e, 0xe0, 0x48, 0xbc, 0x86, 0x17, 0x58, - 0xbd, 0x80, 0xff, 0x23, 0xd1, 0xf6, 0xdf, 0xb0, 0x9b, 0x7c, 0xbd, 0xe9, 0x63, 0xf7, 0xe8, 0x51, 0x20, 0xab, 0x43, - 0x41, 0x53, 0xd7, 0x80, 0x26, 0x57, 0x4d, 0x39, 0xac, 0xe8, 0xae, 0xa9, 0x3b, 0xd2, 0xff, 0xab, 0xc1, 0xae, 0x9a, - 0x30, 0xb0, 0x1c, 0xf7, 0xdf, 0xb5, 0x9f, 0x72, 0xc9, 0x8f, 0x5b, 0x13, 0x49, 0xfa, 0x93, 0xfe, 0x17, 0xf2, 0x73, - 0x3e, 0x5f, 0x5c, 0x60, 0x7f, 0x9b, 0xe3, 0x23, 0xfe, 0xb4, 0xe3, 0x23, 0xa2, 0xf7, 0xf1, 0x7c, 0xc4, 0x7f, 0xb8, - 0xe7, 0xc3, 0x5f, 0x75, 0x9b, 0xdf, 0xf0, 0x35, 0x07, 0xc7, 0xaa, 0x55, 0xfc, 0x82, 0x3b, 0xc3, 0x14, 0x3e, 0x1d, - 0x5d, 0xf4, 0x6e, 0x38, 0x89, 0xa8, 0xe9, 0x07, 0x4a, 0x81, 0xc5, 0xde, 0x70, 0xc9, 0x63, 0x83, 0x6d, 0x08, 0x09, - 0x3f, 0x8d, 0x90, 0xef, 0xee, 0x83, 0x8f, 0xf0, 0x0f, 0xc7, 0x47, 0x60, 0xe2, 0xa3, 0xe6, 0xc9, 0x17, 0x9e, 0x06, - 0xe1, 0x29, 0x38, 0x13, 0xcf, 0x0e, 0xdc, 0x9a, 0xd1, 0xb0, 0x5b, 0xf4, 0x4a, 0x44, 0xee, 0x64, 0x70, 0xfd, 0xf9, - 0xe7, 0x04, 0x1d, 0xe4, 0x15, 0x39, 0xc4, 0x56, 0x6e, 0x99, 0x99, 0x90, 0x3a, 0xea, 0xa1, 0x14, 0x4a, 0x5d, 0xb7, - 0xed, 0xb6, 0x4b, 0x97, 0x0e, 0x5c, 0x8b, 0x44, 0x16, 0x29, 0xf7, 0xbd, 0x9d, 0x0e, 0x8e, 0xd3, 0x09, 0x5c, 0x96, - 0x24, 0x3e, 0x1f, 0x07, 0x27, 0x1e, 0x02, 0xf9, 0xe5, 0x3e, 0x48, 0x9f, 0x50, 0x8e, 0x1e, 0x3f, 0xfb, 0xf8, 0x37, - 0x08, 0x62, 0xea, 0x98, 0xc4, 0x14, 0xbc, 0x1d, 0xaf, 0x68, 0xc8, 0x7c, 0xc7, 0x76, 0x66, 0x19, 0x1d, 0xd3, 0x2c, - 0x6f, 0xd6, 0xee, 0xeb, 0x11, 0x57, 0xf5, 0x20, 0x5b, 0x41, 0x38, 0xce, 0xe0, 0x73, 0x48, 0x64, 0xa8, 0xfc, 0x8d, - 0xb6, 0x32, 0xc0, 0xec, 0x02, 0xeb, 0x92, 0x0c, 0x64, 0x6d, 0xa5, 0xb4, 0xd9, 0x52, 0x6b, 0xeb, 0xb8, 0xdd, 0x43, - 0x64, 0x89, 0x62, 0xf8, 0xd0, 0xcc, 0x0f, 0x4e, 0x73, 0xbf, 0xfd, 0x27, 0x64, 0x34, 0x2b, 0x3b, 0x1a, 0x29, 0x77, - 0x5b, 0x52, 0x7e, 0x8e, 0x70, 0x25, 0xec, 0x6a, 0x4b, 0x8a, 0xf8, 0x52, 0xce, 0xdd, 0x46, 0xbd, 0x44, 0x25, 0xcd, - 0xc9, 0x2b, 0x01, 0xc7, 0x6c, 0xe2, 0x18, 0xd7, 0x4d, 0x24, 0xf2, 0x43, 0x36, 0x70, 0x5b, 0x3d, 0x42, 0x45, 0x55, - 0x25, 0x41, 0x0b, 0x11, 0x6d, 0x61, 0x89, 0x95, 0x2c, 0x97, 0x4e, 0x02, 0x2e, 0x72, 0x62, 0xe0, 0x14, 0x9e, 0x51, - 0x0d, 0xc9, 0x09, 0x2e, 0x01, 0x12, 0x08, 0x26, 0x89, 0xfc, 0xb7, 0x2a, 0xd6, 0x3f, 0x94, 0xe3, 0xcb, 0x8d, 0xfd, - 0x64, 0x02, 0x54, 0xe8, 0x27, 0x93, 0x35, 0xb7, 0x9a, 0x0c, 0x18, 0xad, 0x94, 0x56, 0x5d, 0x55, 0xee, 0xb3, 0xfc, - 0xc9, 0xed, 0x7b, 0x75, 0xe3, 0xb5, 0x0d, 0xde, 0x69, 0x11, 0xdf, 0xa8, 0xbe, 0xce, 0xd3, 0x20, 0x0f, 0x8e, 0xa7, - 0x94, 0xfb, 0xf2, 0xb0, 0x1a, 0xe8, 0x13, 0x90, 0xcb, 0x62, 0x29, 0x6b, 0x54, 0x05, 0xf5, 0x89, 0x3c, 0xcc, 0x2f, - 0x45, 0x3d, 0xb6, 0xd4, 0x55, 0x71, 0x4d, 0xb1, 0x34, 0xa4, 0x83, 0xa5, 0x3f, 0x26, 0xf0, 0xc5, 0x71, 0x64, 0x92, - 0xa4, 0x76, 0xff, 0x41, 0x99, 0xeb, 0xb2, 0x6d, 0x11, 0x62, 0x96, 0x7c, 0x1c, 0x66, 0x34, 0xfe, 0x27, 0xf2, 0x80, - 0x05, 0x69, 0xf2, 0x60, 0x64, 0xa3, 0x1e, 0x77, 0xa3, 0x8c, 0x8e, 0xc9, 0x03, 0x90, 0xf1, 0x9e, 0xb0, 0x3e, 0x80, - 0x11, 0x36, 0x6e, 0xa6, 0x31, 0x16, 0x1a, 0xd3, 0x3d, 0x14, 0x22, 0x09, 0xae, 0xdd, 0x3d, 0xb4, 0x2d, 0x69, 0x13, - 0x8b, 0xdf, 0x7d, 0x29, 0x4e, 0x85, 0x12, 0x60, 0x75, 0xba, 0xee, 0x61, 0xd4, 0x75, 0x1f, 0x5f, 0x3d, 0x72, 0x8f, - 0xa2, 0xce, 0xa3, 0xab, 0x26, 0xfc, 0xdb, 0x75, 0x1f, 0xc7, 0xcd, 0xae, 0xfb, 0x18, 0xfe, 0xff, 0xf6, 0xc0, 0x3d, - 0x8c, 0x9a, 0x1d, 0xf7, 0xe8, 0x6a, 0xdf, 0xdd, 0x7f, 0xd9, 0xe9, 0xba, 0xfb, 0x56, 0xc7, 0x92, 0xed, 0x80, 0x5d, - 0x4b, 0xee, 0xfc, 0x60, 0x65, 0x43, 0x6c, 0x08, 0xc6, 0xc9, 0x03, 0x77, 0x36, 0x16, 0x67, 0xa4, 0xcd, 0xfd, 0xa9, - 0x9c, 0x75, 0x4f, 0xfd, 0x0c, 0xbe, 0x6c, 0x5a, 0xdf, 0xbb, 0xb5, 0x77, 0xb8, 0xc6, 0x2f, 0x36, 0x0c, 0x31, 0x13, - 0x11, 0x70, 0xf3, 0xae, 0x35, 0x2a, 0xee, 0xb0, 0x93, 0xdf, 0x82, 0x52, 0x51, 0xb0, 0x32, 0xbb, 0xc8, 0x20, 0x6b, - 0x59, 0x03, 0x12, 0x80, 0x04, 0x0d, 0xae, 0xe6, 0x8f, 0x56, 0x74, 0x9e, 0xc1, 0xfd, 0x04, 0x9a, 0x97, 0x30, 0xf1, - 0x45, 0x3e, 0x01, 0xc3, 0x8b, 0xb0, 0x58, 0x05, 0x0f, 0x8e, 0x05, 0x66, 0xa9, 0x71, 0x1b, 0x1d, 0xad, 0x72, 0x00, - 0x42, 0x06, 0xf7, 0x07, 0x16, 0x85, 0x9e, 0x59, 0xcd, 0x8b, 0x5b, 0x21, 0x51, 0xb0, 0x13, 0x9a, 0x0f, 0x6c, 0x28, - 0xb2, 0x3d, 0x5b, 0x78, 0x00, 0xed, 0xf2, 0xe3, 0xaf, 0x25, 0xdd, 0x57, 0x05, 0x58, 0x5c, 0x0e, 0x01, 0x9b, 0x1a, - 0xd0, 0x67, 0xa3, 0xbd, 0xbd, 0xad, 0xdb, 0x49, 0xe8, 0x97, 0x30, 0xb5, 0xea, 0x9b, 0x91, 0x26, 0xa7, 0xb2, 0xcd, - 0x75, 0x28, 0xfb, 0x15, 0x18, 0x46, 0x0a, 0x2d, 0x97, 0xd4, 0xe7, 0xae, 0x9f, 0xc8, 0x03, 0x06, 0x06, 0x3f, 0xc3, - 0x1d, 0xba, 0x8f, 0x8a, 0x94, 0xfb, 0x32, 0x67, 0xcc, 0x64, 0x03, 0x29, 0xf7, 0xf5, 0xdd, 0x4a, 0x3e, 0xaf, 0x9d, - 0xab, 0x8f, 0xba, 0xfd, 0x37, 0xef, 0x4f, 0x2c, 0xb9, 0x7b, 0x8f, 0x5b, 0x51, 0xb7, 0x7f, 0x2c, 0x5c, 0x2a, 0x32, - 0x2b, 0x80, 0xc8, 0xac, 0x00, 0x4b, 0x5d, 0x2a, 0x03, 0x81, 0xb6, 0xa2, 0x25, 0xa7, 0x2d, 0x4c, 0x0a, 0xe9, 0x0c, - 0x9e, 0xce, 0x63, 0xce, 0xe0, 0x9b, 0x47, 0x2d, 0x91, 0x12, 0x20, 0x52, 0x0c, 0xf4, 0x19, 0x55, 0xa5, 0x3c, 0x5e, - 0xf2, 0x44, 0xbb, 0x8e, 0xc7, 0x2c, 0xa6, 0xfa, 0x54, 0xaa, 0xea, 0xaa, 0xcc, 0x07, 0x5a, 0xaf, 0x9d, 0xcf, 0x2f, - 0x21, 0x27, 0x42, 0x67, 0x1f, 0x7d, 0x50, 0x0d, 0x8e, 0xc5, 0x50, 0x10, 0xd8, 0x97, 0x52, 0x5c, 0x7f, 0xdd, 0xb5, - 0xbe, 0xa4, 0x6a, 0xf6, 0x4a, 0x80, 0xc0, 0x4d, 0x1e, 0xd1, 0x7e, 0xbf, 0xf4, 0x26, 0x9b, 0xef, 0x8a, 0xe3, 0x56, - 0xb4, 0xdf, 0xbf, 0xf0, 0x26, 0xaa, 0xbf, 0x97, 0xe9, 0x64, 0x73, 0x5f, 0x71, 0x3a, 0x19, 0x88, 0x63, 0xf2, 0xf2, - 0xca, 0x27, 0xad, 0x1b, 0xa7, 0xb1, 0xdd, 0x3f, 0x56, 0xba, 0x82, 0x25, 0xa2, 0xee, 0xf6, 0x61, 0x5b, 0x9f, 0xbc, - 0x8f, 0xd3, 0x09, 0xec, 0x57, 0xd9, 0xc4, 0x18, 0xa4, 0xe6, 0x90, 0x8f, 0x3a, 0xfd, 0x63, 0xdf, 0x12, 0xac, 0x47, - 0xf0, 0x96, 0xdc, 0x6b, 0x41, 0xe3, 0x28, 0x9d, 0x52, 0x97, 0xa5, 0xad, 0x6b, 0x7a, 0xd9, 0xf4, 0x67, 0xac, 0xf2, - 0x7e, 0x83, 0x4e, 0x52, 0x0e, 0x99, 0xae, 0x64, 0x60, 0x75, 0x2b, 0x6f, 0xdc, 0x01, 0x98, 0x44, 0xda, 0x73, 0x27, - 0x5c, 0x76, 0x06, 0x58, 0x69, 0xff, 0xb8, 0xe5, 0xaf, 0x60, 0x44, 0x6c, 0xc5, 0x42, 0xf9, 0xe1, 0xc1, 0xee, 0xb9, - 0x14, 0xe9, 0x5f, 0x52, 0x5a, 0x68, 0x7f, 0xbd, 0x92, 0xe3, 0x85, 0xdd, 0xff, 0xd7, 0xff, 0xf1, 0xbf, 0x94, 0x0b, - 0xfe, 0xb8, 0x15, 0x75, 0x74, 0x5f, 0x2b, 0xab, 0x52, 0x1c, 0xc3, 0x3d, 0x36, 0x55, 0xcc, 0x98, 0xde, 0x34, 0x27, - 0x19, 0x0b, 0x9b, 0x91, 0x1f, 0x8f, 0xed, 0xfe, 0x76, 0x6c, 0xca, 0xf4, 0xc4, 0xa6, 0x8e, 0xb6, 0xae, 0x17, 0x01, - 0xbd, 0xfe, 0xa6, 0x4b, 0x19, 0x74, 0xc6, 0x97, 0xd8, 0xda, 0xe6, 0x15, 0x0d, 0xd5, 0xee, 0xab, 0x5d, 0xd3, 0x90, - 0xa8, 0x4f, 0x46, 0x2b, 0x06, 0x99, 0xd4, 0x6e, 0x67, 0x28, 0x6c, 0xab, 0x8c, 0x79, 0xfd, 0xdf, 0xff, 0xf9, 0x5f, - 0xfe, 0x9b, 0x7e, 0x84, 0x50, 0xd6, 0xbf, 0xfe, 0xf7, 0xff, 0xfc, 0x7f, 0xfe, 0xf7, 0x7f, 0x85, 0xf4, 0x34, 0x15, - 0xee, 0x12, 0x4c, 0xc5, 0xaa, 0x62, 0x5d, 0x92, 0xbb, 0x58, 0x70, 0xe8, 0x6d, 0xca, 0x72, 0xce, 0x82, 0xfa, 0x7d, - 0x0d, 0x67, 0x62, 0x40, 0xb1, 0x33, 0x15, 0x74, 0x62, 0x87, 0x17, 0x15, 0x41, 0xd5, 0x50, 0x2e, 0x08, 0xb7, 0x38, - 0x6e, 0x01, 0xbe, 0xef, 0x77, 0xdd, 0x8c, 0x5b, 0x2e, 0xc7, 0x42, 0x93, 0x09, 0x94, 0x14, 0x55, 0xb9, 0x05, 0xa1, - 0x97, 0x05, 0x3c, 0x7a, 0x5d, 0xa3, 0x58, 0xac, 0x5e, 0xad, 0x4d, 0xef, 0xe7, 0x79, 0xce, 0xd9, 0x18, 0x50, 0x2e, - 0xdd, 0xc8, 0x22, 0xca, 0xdd, 0x04, 0x55, 0x32, 0xbe, 0x2d, 0x44, 0x2f, 0x92, 0x40, 0x0f, 0x8e, 0xfe, 0x54, 0xfc, - 0x79, 0x0a, 0x0a, 0x9b, 0xe5, 0x4c, 0xfd, 0x1b, 0x65, 0xbd, 0x3f, 0x6c, 0xb7, 0x67, 0x37, 0x68, 0x51, 0x8d, 0x80, - 0xb7, 0x0d, 0x26, 0xe8, 0xd8, 0xec, 0x50, 0x84, 0xc7, 0x4b, 0x2f, 0x77, 0xdb, 0x02, 0x57, 0xb9, 0xd5, 0x2e, 0x8a, - 0xaf, 0x16, 0xc2, 0xd1, 0xca, 0x7e, 0x85, 0x30, 0xb6, 0xf2, 0x49, 0x5f, 0xa6, 0xe6, 0xe4, 0x16, 0x46, 0xab, 0xae, - 0x6c, 0x15, 0x75, 0xd6, 0x6f, 0x6e, 0x31, 0xc3, 0xf0, 0x66, 0x00, 0xfd, 0x00, 0x42, 0xe2, 0x51, 0x07, 0x47, 0xdd, - 0x45, 0xd9, 0x3d, 0xe7, 0xe9, 0xd4, 0x8c, 0xbb, 0x53, 0x9f, 0x06, 0x74, 0xac, 0x7d, 0xf9, 0xea, 0xbd, 0x8c, 0xa9, - 0x17, 0xd1, 0xfe, 0x86, 0xb1, 0x14, 0x48, 0x22, 0xde, 0x6e, 0xb5, 0x8b, 0x2f, 0x61, 0x07, 0x2e, 0xc6, 0x71, 0xea, - 0x73, 0x4f, 0x10, 0x6c, 0xcf, 0x8c, 0xde, 0xfb, 0xc0, 0x93, 0xd2, 0x85, 0x01, 0x4f, 0x4f, 0x56, 0x05, 0xaf, 0x7a, - 0xfd, 0x06, 0xc7, 0xc2, 0x15, 0xcd, 0xcd, 0xae, 0xa4, 0x53, 0xee, 0x3b, 0x15, 0x14, 0x7f, 0x5e, 0xf3, 0x66, 0x29, - 0x81, 0xd4, 0x45, 0x9b, 0xdf, 0x4b, 0xb1, 0x2f, 0xdf, 0x7e, 0xcf, 0x1d, 0x5b, 0x80, 0x69, 0xaf, 0xd6, 0x12, 0x85, - 0x50, 0xeb, 0x39, 0xf9, 0xae, 0xb4, 0xa8, 0xfc, 0xd9, 0x4c, 0x54, 0x44, 0xbd, 0xe3, 0x96, 0x54, 0x84, 0x81, 0x7b, - 0x88, 0x8c, 0x0f, 0x99, 0x60, 0xa1, 0x2a, 0xa9, 0xad, 0x20, 0x7f, 0xa9, 0xd4, 0x0b, 0xf8, 0x94, 0x78, 0xff, 0xff, - 0x01, 0x65, 0x21, 0x07, 0x4b, 0xe3, 0x97, 0x00, 0x00}; + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xed, 0x7d, 0xdb, 0x72, 0xdb, 0xc6, 0xb6, 0xe0, 0xf3, + 0xe4, 0x2b, 0x20, 0x44, 0x5b, 0x46, 0x87, 0x4d, 0xf0, 0x22, 0xc9, 0x96, 0x41, 0x35, 0xb9, 0x65, 0xd9, 0xd9, 0xf6, + 0x8e, 0x6f, 0xdb, 0xb2, 0x73, 0x53, 0xb8, 0x25, 0x08, 0x68, 0x12, 0x1d, 0x83, 0x00, 0x03, 0x34, 0x45, 0x29, 0x24, + 0x4e, 0xcd, 0x07, 0x4c, 0xd5, 0x54, 0xcd, 0xd3, 0xbc, 0x4c, 0xcd, 0x79, 0x98, 0x8f, 0x98, 0xe7, 0xf3, 0x29, 0xe7, + 0x07, 0x66, 0x3e, 0x61, 0x6a, 0xf5, 0x05, 0x68, 0xf0, 0x22, 0xcb, 0x49, 0xf6, 0x39, 0xe7, 0x61, 0x2a, 0x15, 0x99, + 0x68, 0xf4, 0x65, 0xf5, 0xea, 0xd5, 0xeb, 0xde, 0x8d, 0xe3, 0x9d, 0x30, 0x0d, 0xf8, 0xed, 0x94, 0x5a, 0x11, 0x9f, + 0xc4, 0xfd, 0x63, 0xf5, 0x97, 0xfa, 0x61, 0xff, 0x38, 0x66, 0xc9, 0x47, 0x2b, 0xa3, 0x31, 0x61, 0x41, 0x9a, 0x58, + 0x51, 0x46, 0x47, 0x24, 0xf4, 0xb9, 0xef, 0xb1, 0x89, 0x3f, 0xa6, 0x56, 0xab, 0x7f, 0x3c, 0xa1, 0xdc, 0xb7, 0x82, + 0xc8, 0xcf, 0x72, 0xca, 0xc9, 0x87, 0xf7, 0x5f, 0x37, 0x8f, 0xfa, 0xc7, 0x79, 0x90, 0xb1, 0x29, 0xb7, 0xa0, 0x4b, + 0x32, 0x49, 0xc3, 0x59, 0x4c, 0xfb, 0xad, 0xd6, 0x7c, 0x3e, 0x77, 0x7f, 0xce, 0xbf, 0x08, 0xd2, 0x24, 0xe7, 0xd6, + 0x53, 0x32, 0x67, 0x49, 0x98, 0xce, 0x71, 0xc2, 0xc9, 0x53, 0xf7, 0x2c, 0xf2, 0xc3, 0x74, 0xfe, 0x2e, 0x4d, 0xf9, + 0xde, 0x9e, 0x23, 0x1f, 0x6f, 0x4f, 0xcf, 0xce, 0x08, 0x21, 0xd7, 0x29, 0x0b, 0xad, 0xf6, 0x72, 0x59, 0x15, 0xba, + 0x89, 0xcf, 0xd9, 0x35, 0x95, 0x4d, 0xd0, 0xde, 0x9e, 0xed, 0x87, 0xe9, 0x94, 0xd3, 0xf0, 0x8c, 0xdf, 0xc6, 0xf4, + 0x2c, 0xa2, 0x94, 0xe7, 0x36, 0x4b, 0xac, 0xa7, 0x69, 0x30, 0x9b, 0xd0, 0x84, 0xbb, 0xd3, 0x2c, 0xe5, 0x29, 0x40, + 0xb2, 0xb7, 0x67, 0x67, 0x74, 0x1a, 0xfb, 0x01, 0x85, 0xf7, 0xa7, 0x67, 0x67, 0x55, 0x8b, 0xaa, 0x12, 0xce, 0x39, + 0x39, 0xbb, 0x9d, 0x5c, 0xa5, 0xb1, 0x83, 0x70, 0xc4, 0x49, 0x42, 0xe7, 0xd6, 0x77, 0xd4, 0xff, 0xf8, 0xca, 0x9f, + 0xf6, 0x82, 0xd8, 0xcf, 0x73, 0xeb, 0x84, 0x2f, 0xc4, 0x14, 0xb2, 0x59, 0xc0, 0xd3, 0xcc, 0xe1, 0x98, 0x62, 0x86, + 0x16, 0x6c, 0xe4, 0xf0, 0x88, 0xe5, 0xee, 0xc5, 0x6e, 0x90, 0xe7, 0xef, 0x68, 0x3e, 0x8b, 0xf9, 0x2e, 0xd9, 0x69, + 0x63, 0xb6, 0x43, 0x48, 0xce, 0x11, 0x8f, 0xb2, 0x74, 0x6e, 0x3d, 0xcb, 0xb2, 0x34, 0x73, 0xec, 0xd3, 0xb3, 0x33, + 0x59, 0xc3, 0x62, 0xb9, 0x95, 0xa4, 0xdc, 0x2a, 0xfb, 0xf3, 0xaf, 0x62, 0xea, 0x5a, 0x1f, 0x72, 0x6a, 0x5d, 0xce, + 0x92, 0xdc, 0x1f, 0xd1, 0xd3, 0xb3, 0xb3, 0x4b, 0x2b, 0xcd, 0xac, 0xcb, 0x20, 0xcf, 0x2f, 0x2d, 0x96, 0xe4, 0x9c, + 0xfa, 0xa1, 0x6b, 0xa3, 0x9e, 0x18, 0x2c, 0xc8, 0xf3, 0xf7, 0xf4, 0x86, 0x13, 0x8e, 0xc5, 0x23, 0x27, 0xb4, 0x18, + 0x53, 0x6e, 0xe5, 0xe5, 0xbc, 0x1c, 0xb4, 0x88, 0x29, 0xb7, 0x38, 0x11, 0xef, 0xd3, 0x9e, 0xc4, 0x3d, 0x95, 0x8f, + 0xbc, 0xc7, 0x46, 0x4e, 0xc2, 0xf7, 0xf6, 0x78, 0x89, 0x67, 0x24, 0xa7, 0x66, 0x31, 0x42, 0x77, 0x74, 0xd9, 0xde, + 0x1e, 0x75, 0x63, 0x9a, 0x8c, 0x79, 0x44, 0x08, 0xe9, 0xf4, 0xd8, 0xde, 0x9e, 0xc3, 0x49, 0xc4, 0xdd, 0x31, 0xe5, + 0x0e, 0x45, 0x08, 0x57, 0xad, 0xf7, 0xf6, 0x1c, 0x89, 0x84, 0x94, 0x48, 0xc4, 0xd5, 0x70, 0x8c, 0x5c, 0x85, 0xfd, + 0xb3, 0xdb, 0x24, 0x70, 0x4c, 0xf8, 0x11, 0x66, 0x7b, 0x7b, 0x11, 0x77, 0x73, 0xe8, 0x11, 0x73, 0x84, 0x8a, 0x8c, + 0xf2, 0x59, 0x96, 0x58, 0xbc, 0xe0, 0xe9, 0x19, 0xcf, 0x58, 0x32, 0x76, 0xd0, 0x42, 0x97, 0x19, 0x0d, 0x8b, 0x42, + 0x82, 0xfb, 0x8e, 0x93, 0x9c, 0xf4, 0x61, 0xc4, 0x13, 0xee, 0xc0, 0x2a, 0xa6, 0x23, 0x2b, 0x27, 0xc4, 0xce, 0x45, + 0x5b, 0x7b, 0x90, 0x7b, 0x79, 0xc3, 0xb6, 0xb1, 0x84, 0x12, 0xe7, 0x1c, 0xe1, 0x8f, 0xc4, 0xc9, 0xb1, 0xeb, 0xba, + 0x1c, 0x91, 0xfe, 0x42, 0x63, 0x25, 0x37, 0xe6, 0x39, 0xc8, 0xcf, 0xdb, 0x43, 0x8f, 0xbb, 0x19, 0x0d, 0x67, 0x01, + 0x75, 0x1c, 0x86, 0x13, 0x9c, 0x21, 0xd2, 0x67, 0x0d, 0x27, 0x25, 0x7d, 0x58, 0xee, 0xb4, 0xbe, 0xd6, 0x84, 0xec, + 0xb4, 0x91, 0x82, 0x31, 0xd5, 0x00, 0x02, 0x86, 0x15, 0x3c, 0x29, 0x21, 0x76, 0x32, 0x9b, 0x5c, 0xd1, 0xcc, 0x2e, + 0xab, 0xf5, 0x6a, 0x64, 0x31, 0xcb, 0xa9, 0x15, 0xe4, 0xb9, 0x35, 0x9a, 0x25, 0x01, 0x67, 0x69, 0x62, 0xd9, 0x8d, + 0xb4, 0x61, 0x4b, 0x72, 0x28, 0xa9, 0xc1, 0x46, 0x05, 0x72, 0x12, 0xd4, 0xc8, 0xcf, 0xb3, 0x46, 0x67, 0x88, 0x01, + 0x4a, 0xd4, 0x53, 0xfd, 0x29, 0x04, 0x50, 0x9c, 0xc3, 0x1c, 0x0b, 0xfc, 0x84, 0xc3, 0x2c, 0xc5, 0x14, 0x13, 0x3e, + 0xc8, 0xdd, 0xf5, 0x8d, 0x42, 0xb8, 0x3b, 0xf1, 0xa7, 0x0e, 0x25, 0x7d, 0x2a, 0x88, 0xcb, 0x4f, 0x02, 0x80, 0xb5, + 0xb6, 0x6e, 0x03, 0xea, 0x51, 0xb7, 0x22, 0x29, 0xe4, 0x71, 0x77, 0x94, 0x66, 0xcf, 0xfc, 0x20, 0x82, 0x76, 0x25, + 0xc1, 0x84, 0x7a, 0xbf, 0x05, 0x19, 0xf5, 0x39, 0x7d, 0x16, 0x53, 0x78, 0x72, 0x6c, 0xd1, 0xd2, 0x46, 0x38, 0x21, + 0x4f, 0xdd, 0x98, 0xf1, 0xd7, 0x69, 0x12, 0xd0, 0x5e, 0x62, 0x50, 0x17, 0x83, 0x75, 0x3f, 0xe1, 0x3c, 0x63, 0x57, + 0x33, 0x4e, 0x1d, 0x3b, 0x81, 0x1a, 0x36, 0x4e, 0x10, 0x66, 0x2e, 0xa7, 0x37, 0xfc, 0x34, 0x4d, 0x38, 0x4d, 0x38, + 0xa1, 0x1a, 0xa9, 0x38, 0x77, 0xfd, 0xe9, 0x94, 0x26, 0xe1, 0x69, 0xc4, 0xe2, 0xd0, 0x61, 0xa8, 0x40, 0x05, 0x0e, + 0x38, 0x81, 0x39, 0x92, 0x7e, 0xee, 0xc1, 0x9f, 0xed, 0xb3, 0x71, 0x38, 0xe9, 0x8b, 0x4d, 0x41, 0x89, 0x6d, 0xf7, + 0x46, 0x69, 0xe6, 0xa8, 0x19, 0x58, 0xe9, 0xc8, 0xe2, 0x30, 0xc6, 0xbb, 0x59, 0x4c, 0x73, 0x44, 0x1b, 0x84, 0x95, + 0xcb, 0xa8, 0x10, 0xfc, 0x0e, 0x28, 0xbe, 0x40, 0x4e, 0x8e, 0xbc, 0xbc, 0x77, 0xed, 0x67, 0xd6, 0x8f, 0x6a, 0x47, + 0xfd, 0xac, 0xb9, 0x59, 0xc8, 0xc9, 0xcf, 0x2e, 0xcf, 0x66, 0x39, 0xa7, 0xe1, 0xfb, 0xdb, 0x29, 0xcd, 0xf1, 0x73, + 0x4e, 0x42, 0x3e, 0x08, 0xb9, 0x4b, 0x27, 0x53, 0x7e, 0x7b, 0x26, 0x18, 0xa3, 0x67, 0xdb, 0x78, 0x06, 0x35, 0x33, + 0xea, 0x07, 0xc0, 0xcc, 0x14, 0xb6, 0xde, 0xa6, 0xf1, 0xed, 0x88, 0xc5, 0xf1, 0xd9, 0x6c, 0x3a, 0x4d, 0x33, 0x8e, + 0x39, 0x27, 0x0b, 0x9e, 0x56, 0xb8, 0x81, 0xc5, 0x5c, 0xe4, 0x73, 0xc6, 0x83, 0xc8, 0xe1, 0x68, 0x11, 0xf8, 0x39, + 0xb5, 0x9e, 0xa4, 0x69, 0x4c, 0xfd, 0xc4, 0xcb, 0x49, 0x3e, 0x78, 0xce, 0xbd, 0x64, 0x16, 0xc7, 0xbd, 0xab, 0x8c, + 0xfa, 0x1f, 0x7b, 0xe2, 0xf5, 0x9b, 0xab, 0x9f, 0x69, 0xc0, 0x3d, 0xf1, 0xfb, 0x24, 0xcb, 0xfc, 0x5b, 0xa8, 0x48, + 0x08, 0x54, 0x1b, 0xe4, 0xde, 0x5f, 0xcf, 0xde, 0xbc, 0x76, 0xe5, 0x2e, 0x61, 0xa3, 0x5b, 0x27, 0x2f, 0x77, 0x5e, + 0x5e, 0xe0, 0x51, 0x96, 0x4e, 0x56, 0x86, 0x96, 0x68, 0xcb, 0x7b, 0x5b, 0x40, 0xa0, 0x24, 0xdf, 0x91, 0x5d, 0x9b, + 0x10, 0xbc, 0x16, 0x44, 0x0f, 0x2f, 0x89, 0x1a, 0x17, 0xfe, 0x78, 0xb2, 0xd8, 0xc9, 0xd1, 0xdd, 0xd0, 0xf2, 0xec, + 0x76, 0x41, 0x89, 0x80, 0x73, 0x0a, 0x22, 0x06, 0x60, 0x0c, 0x7c, 0x1e, 0x44, 0x0b, 0x2a, 0x3a, 0x2b, 0x34, 0xc4, + 0xb4, 0x28, 0xf0, 0xb3, 0x92, 0xe0, 0x39, 0xb0, 0x5d, 0xc1, 0xa9, 0x08, 0x5f, 0x2e, 0x73, 0x42, 0x72, 0x84, 0xff, + 0x4a, 0x16, 0xbe, 0x9e, 0x8f, 0xb7, 0xd3, 0xc6, 0xb0, 0x31, 0x3d, 0xc9, 0x5e, 0x70, 0x90, 0x26, 0xd7, 0x34, 0xe3, + 0x34, 0xf3, 0x38, 0xc7, 0x19, 0x1d, 0xc5, 0x00, 0xc6, 0x4e, 0x07, 0x47, 0x7e, 0x7e, 0x1a, 0xf9, 0xc9, 0x98, 0x86, + 0xde, 0x33, 0x5e, 0x60, 0xca, 0x89, 0x3d, 0x62, 0x89, 0x1f, 0xb3, 0x5f, 0x69, 0x68, 0x2b, 0x81, 0xf0, 0xcc, 0xa2, + 0x37, 0x9c, 0x26, 0x61, 0x6e, 0x3d, 0x7f, 0xff, 0xea, 0xa5, 0x5a, 0xca, 0x9a, 0x8c, 0x40, 0x8b, 0x7c, 0x36, 0xa5, + 0x99, 0x83, 0xb0, 0x92, 0x11, 0xcf, 0x98, 0xe0, 0x8f, 0xaf, 0xfc, 0xa9, 0x2c, 0x61, 0xf9, 0x87, 0x69, 0xe8, 0x73, + 0xfa, 0x96, 0x26, 0x21, 0x4b, 0xc6, 0x64, 0xa7, 0x23, 0xcb, 0x23, 0x5f, 0xbd, 0x08, 0xcb, 0xa2, 0x8b, 0xdd, 0x67, + 0xb1, 0x98, 0x79, 0xf9, 0x38, 0x73, 0x50, 0x91, 0x73, 0x9f, 0xb3, 0xc0, 0xf2, 0xc3, 0xf0, 0x45, 0xc2, 0x38, 0x13, + 0x00, 0x66, 0xb0, 0x40, 0x40, 0xa5, 0x54, 0x4a, 0x0b, 0x0d, 0xb8, 0x83, 0xb0, 0xe3, 0x28, 0x19, 0x10, 0x21, 0xb5, + 0x62, 0x7b, 0x7b, 0x15, 0xc7, 0x1f, 0x50, 0x4f, 0xbe, 0x24, 0xe7, 0x43, 0xe4, 0x4e, 0x67, 0x39, 0x2c, 0xb5, 0x1e, + 0x02, 0x04, 0x4c, 0x7a, 0x95, 0xd3, 0xec, 0x9a, 0x86, 0x25, 0x79, 0xe4, 0x0e, 0x5a, 0xac, 0x8c, 0xa1, 0x76, 0x06, + 0x27, 0xe7, 0xc3, 0x9e, 0xc9, 0xba, 0xa9, 0x22, 0xf5, 0x2c, 0x9d, 0xd2, 0x8c, 0x33, 0x9a, 0x97, 0xdc, 0xc4, 0x01, + 0x41, 0x5a, 0x72, 0x94, 0x84, 0xe8, 0xf9, 0x4d, 0x1d, 0x86, 0x29, 0xaa, 0xf1, 0x0c, 0x2d, 0x6b, 0x9f, 0x5d, 0x0b, + 0xa1, 0x91, 0x60, 0x86, 0x30, 0x97, 0x90, 0x26, 0x08, 0x15, 0x08, 0x73, 0x0d, 0xae, 0xe4, 0x46, 0x6a, 0xb4, 0x5b, + 0x90, 0xd6, 0xe4, 0xaf, 0x42, 0x5a, 0x03, 0x4f, 0xf3, 0x39, 0xdd, 0xdb, 0x73, 0xa8, 0x5b, 0x92, 0x05, 0xd9, 0xe9, + 0xa8, 0x35, 0x32, 0x90, 0xb5, 0x05, 0x6c, 0x18, 0x98, 0x63, 0x8a, 0xf0, 0x0e, 0x75, 0x93, 0xf4, 0x24, 0x08, 0x68, + 0x9e, 0xa7, 0xd9, 0xde, 0xde, 0x8e, 0xa8, 0x5f, 0x2a, 0x14, 0xb0, 0x86, 0x6f, 0xe6, 0x49, 0x05, 0x01, 0xaa, 0x84, + 0xac, 0x12, 0x0d, 0x1c, 0x44, 0x95, 0xd0, 0x39, 0xec, 0x81, 0xd6, 0x3d, 0x3c, 0xfb, 0xe2, 0xc2, 0x6e, 0x70, 0xac, + 0xd0, 0x30, 0xa6, 0x7a, 0xe8, 0xdb, 0xa7, 0x54, 0x6a, 0x57, 0x42, 0xf7, 0x58, 0xc3, 0x8c, 0xdc, 0x41, 0x6e, 0x48, + 0x47, 0x2c, 0x31, 0xa6, 0x5d, 0x03, 0x09, 0x73, 0x9c, 0xa0, 0xc2, 0x58, 0xd0, 0x8d, 0x5d, 0x0b, 0xb5, 0x46, 0xae, + 0xdc, 0x62, 0x2c, 0x54, 0x09, 0x63, 0x19, 0xcf, 0xe9, 0xb0, 0xc0, 0x02, 0xf5, 0x7a, 0x36, 0x99, 0x00, 0xf4, 0x9c, + 0x0f, 0x7b, 0xea, 0x3d, 0x49, 0x24, 0xe6, 0x32, 0xfa, 0xcb, 0x8c, 0xe6, 0x5c, 0xd2, 0xb1, 0xc3, 0x71, 0x86, 0x19, + 0xf0, 0xeb, 0x34, 0x19, 0xb1, 0xf1, 0x2c, 0x03, 0x8d, 0x07, 0x36, 0x23, 0x4d, 0x66, 0x13, 0xaa, 0x9f, 0x36, 0xc1, + 0xf6, 0x66, 0x0a, 0x32, 0x31, 0x07, 0x9a, 0xbe, 0x9b, 0x9c, 0x00, 0x56, 0x8e, 0x96, 0xcb, 0xbf, 0xea, 0x4e, 0xaa, + 0xa5, 0x2c, 0xb5, 0xb4, 0x95, 0x35, 0xa1, 0x1c, 0x29, 0x99, 0xbc, 0xd3, 0x51, 0xe0, 0xf3, 0x21, 0xd9, 0x69, 0x97, + 0x34, 0xac, 0xb0, 0x2a, 0xc1, 0x91, 0x48, 0x7c, 0x23, 0xbb, 0x42, 0x42, 0xc4, 0xd7, 0xc8, 0xc5, 0x8d, 0xd6, 0x28, + 0x35, 0x22, 0xe7, 0xa0, 0x6c, 0xb8, 0xd1, 0x70, 0x1b, 0x39, 0x69, 0x7e, 0xe0, 0xf0, 0xf5, 0x77, 0x15, 0xdb, 0xb8, + 0xae, 0xb3, 0x8d, 0x95, 0x69, 0xd8, 0xd3, 0xb2, 0x89, 0x5d, 0x52, 0x99, 0xda, 0xe8, 0xd5, 0x2b, 0xcc, 0x04, 0x30, + 0xd5, 0x94, 0x8c, 0x2e, 0x5e, 0xfb, 0x13, 0x9a, 0x3b, 0x14, 0xe1, 0x6d, 0x15, 0x24, 0x79, 0x42, 0x95, 0xa1, 0x21, + 0x3b, 0x13, 0x90, 0x9d, 0x0c, 0x49, 0xd5, 0xac, 0xbe, 0xe1, 0x12, 0x4c, 0xcf, 0x93, 0x61, 0xa5, 0xd1, 0x19, 0x93, + 0x17, 0x42, 0x39, 0x27, 0xb5, 0xed, 0x26, 0xcb, 0x24, 0xd2, 0x84, 0xe6, 0x90, 0x23, 0xbc, 0xd3, 0x5e, 0x5d, 0x49, + 0x5d, 0xab, 0x9a, 0xe3, 0xf9, 0x10, 0xd6, 0x41, 0x88, 0x0c, 0x97, 0xe5, 0xe2, 0xdf, 0xda, 0x4e, 0x03, 0xb4, 0x9d, + 0x01, 0x61, 0xb8, 0xa3, 0xd8, 0xe7, 0x4e, 0xa7, 0xd5, 0x06, 0x75, 0xf4, 0x9a, 0x82, 0x44, 0x41, 0x68, 0x7d, 0x2a, + 0xd4, 0x9d, 0x25, 0x79, 0xc4, 0x46, 0xdc, 0x09, 0xb8, 0x60, 0x29, 0x34, 0xce, 0xa9, 0xc5, 0x6b, 0x4a, 0xb1, 0x60, + 0x37, 0x01, 0x10, 0x5b, 0xa9, 0x81, 0x51, 0x0d, 0xa9, 0x60, 0x5b, 0xc0, 0x1d, 0x2a, 0x85, 0xba, 0xe2, 0x32, 0xba, + 0x36, 0x03, 0xa5, 0xb1, 0x33, 0x90, 0x3d, 0x7a, 0x8a, 0x19, 0x30, 0x43, 0x6f, 0x65, 0x9e, 0xc9, 0x21, 0x54, 0x21, + 0x77, 0x79, 0xfa, 0x32, 0x9d, 0xd3, 0xec, 0xd4, 0x07, 0xe0, 0x3d, 0xd9, 0xbc, 0x90, 0x82, 0x40, 0xf0, 0x7b, 0xde, + 0xd3, 0xf4, 0x72, 0x21, 0x26, 0xfe, 0x36, 0x4b, 0x27, 0x2c, 0xa7, 0xa0, 0xae, 0x49, 0xfc, 0x27, 0xb0, 0xcf, 0xc4, + 0x86, 0x04, 0x61, 0x43, 0x4b, 0xfa, 0x3a, 0x79, 0x59, 0xa7, 0xaf, 0x8b, 0xdd, 0x67, 0x63, 0xcd, 0x00, 0xeb, 0xdb, + 0x18, 0x61, 0x47, 0x19, 0x15, 0x86, 0x9c, 0x73, 0x23, 0xa4, 0x44, 0xfc, 0x72, 0xc9, 0x0d, 0xdb, 0xad, 0xa6, 0x30, + 0x52, 0xb9, 0x6d, 0x50, 0xe1, 0x87, 0x21, 0xa8, 0x76, 0x59, 0x1a, 0xc7, 0x86, 0xa8, 0xc2, 0xac, 0x57, 0x0a, 0xa7, + 0x8b, 0xdd, 0x67, 0x67, 0x77, 0xc9, 0x27, 0x78, 0x6f, 0x8a, 0x28, 0x0d, 0x68, 0x12, 0xd2, 0x0c, 0x6c, 0x49, 0x63, + 0xb5, 0x94, 0x94, 0x3d, 0x4d, 0x93, 0x84, 0x06, 0x9c, 0x86, 0x60, 0xaa, 0x30, 0xc2, 0xdd, 0x28, 0xcd, 0x79, 0x59, + 0x58, 0x41, 0xcf, 0x0c, 0xe8, 0x99, 0x1b, 0xf8, 0x71, 0xec, 0x48, 0xb3, 0x64, 0x92, 0x5e, 0xd3, 0x0d, 0x50, 0xf7, + 0x6a, 0x20, 0x97, 0xdd, 0x50, 0xa3, 0x1b, 0xea, 0xe6, 0xd3, 0x98, 0x05, 0xb4, 0x14, 0x5d, 0x67, 0x2e, 0x4b, 0x42, + 0x7a, 0x03, 0x7c, 0x04, 0xf5, 0xfb, 0xfd, 0x36, 0xee, 0xa0, 0x42, 0x22, 0x7c, 0xb1, 0x86, 0xd8, 0x3b, 0x84, 0x26, + 0x10, 0x19, 0xe9, 0x2f, 0x36, 0xb2, 0x35, 0x64, 0x48, 0x4a, 0xa6, 0xcd, 0x2b, 0xc9, 0x9d, 0x11, 0x0e, 0x69, 0x4c, + 0x39, 0xd5, 0xdc, 0x1c, 0x94, 0x68, 0xb9, 0x75, 0xdf, 0x95, 0xf8, 0x2b, 0xc9, 0x49, 0xef, 0x32, 0xbd, 0xe6, 0x79, + 0x69, 0xae, 0x57, 0xcb, 0x53, 0x61, 0x7b, 0xc0, 0xe5, 0xf2, 0xf8, 0x9c, 0xfb, 0x41, 0x24, 0xed, 0x74, 0x67, 0x6d, + 0x4a, 0x55, 0x1f, 0x8a, 0xb3, 0x97, 0x9b, 0xe8, 0x89, 0x06, 0x73, 0x13, 0x0a, 0xce, 0x14, 0x53, 0xa0, 0x60, 0xfa, + 0xc9, 0x65, 0x3b, 0xf5, 0xe3, 0xf8, 0xca, 0x0f, 0x3e, 0xd6, 0xa9, 0xbf, 0x22, 0x03, 0xb2, 0xca, 0x8d, 0x8d, 0x57, + 0x06, 0xcb, 0x32, 0xe7, 0xad, 0xb9, 0x74, 0x6d, 0xa3, 0x38, 0x3b, 0xed, 0x8a, 0xec, 0xeb, 0x0b, 0xbd, 0x95, 0xda, + 0x05, 0x44, 0x4c, 0xcd, 0xcc, 0x01, 0x2e, 0xf0, 0x49, 0x8a, 0xd3, 0xfc, 0x40, 0xd1, 0x1d, 0x18, 0x1c, 0xc5, 0x0a, + 0x20, 0x1c, 0x2d, 0x8a, 0x90, 0xe5, 0xdb, 0x31, 0xf0, 0x87, 0x40, 0xf9, 0xd4, 0x18, 0xe1, 0xbe, 0x80, 0x96, 0x3c, + 0x4e, 0x69, 0xcd, 0x25, 0x64, 0x4a, 0x9f, 0xd0, 0x8c, 0xe6, 0x1b, 0xd0, 0x5d, 0x04, 0xbd, 0xbf, 0x91, 0xaf, 0x40, + 0x2b, 0x03, 0x28, 0x92, 0x9e, 0xa9, 0x4e, 0xd4, 0x28, 0x40, 0xf1, 0x54, 0x26, 0x44, 0x6e, 0x56, 0xb3, 0x20, 0x95, + 0xc6, 0x2e, 0x8d, 0x70, 0xc5, 0x72, 0x53, 0xe2, 0x38, 0x4e, 0x02, 0x46, 0x9c, 0xd6, 0xed, 0xab, 0x49, 0x24, 0x6b, + 0x93, 0x48, 0x5c, 0xc3, 0xd0, 0x42, 0x15, 0x2d, 0x1b, 0xcd, 0x3d, 0xce, 0x91, 0x59, 0x0b, 0xf4, 0x55, 0x17, 0x18, + 0x34, 0x2a, 0xf9, 0x6d, 0x4c, 0x38, 0x4e, 0x95, 0x95, 0xa3, 0x48, 0x0d, 0x38, 0x46, 0xd5, 0x24, 0x43, 0x72, 0x6f, + 0xd4, 0x4c, 0xde, 0x0c, 0xa7, 0x68, 0x45, 0xb9, 0x2f, 0x0a, 0x85, 0x24, 0x8a, 0xd4, 0xe2, 0xd4, 0xb4, 0x62, 0x03, + 0x2d, 0x38, 0x23, 0x89, 0xd4, 0x84, 0xa5, 0xe2, 0xb3, 0x8a, 0x9c, 0xb2, 0xdf, 0x1d, 0x42, 0xb2, 0x0a, 0x37, 0x89, + 0xbb, 0x41, 0xb7, 0xca, 0x10, 0x8e, 0xb4, 0x52, 0x9a, 0x56, 0x13, 0x27, 0xc4, 0xd6, 0x3e, 0x09, 0x7b, 0xb0, 0xa8, + 0xd9, 0x85, 0x9e, 0x51, 0xad, 0xf0, 0x80, 0xa7, 0xa6, 0x9b, 0xf0, 0xbd, 0x89, 0x68, 0x6a, 0xfd, 0x18, 0x18, 0x4f, + 0x6b, 0x18, 0x37, 0x50, 0x9b, 0x49, 0xde, 0x95, 0x0d, 0x49, 0x54, 0x6f, 0xec, 0x50, 0x9c, 0xca, 0x85, 0x58, 0xc3, + 0xe2, 0xaa, 0xf2, 0x29, 0x88, 0x10, 0xcc, 0xd8, 0x04, 0xd4, 0x3b, 0x53, 0x42, 0x38, 0x00, 0x3c, 0x5b, 0x2e, 0xd7, + 0xc8, 0x6e, 0xa3, 0x0e, 0x8a, 0xdc, 0xca, 0x32, 0x5c, 0x2e, 0x9f, 0x71, 0xe4, 0x28, 0xed, 0x17, 0x53, 0x34, 0xd0, + 0x3c, 0xf7, 0xe4, 0x25, 0xd4, 0x12, 0xca, 0x68, 0x55, 0x52, 0x9a, 0x0d, 0x75, 0xaa, 0xad, 0x2f, 0x14, 0x37, 0x18, + 0xf7, 0xe9, 0x1a, 0xff, 0x12, 0x85, 0x4a, 0x50, 0x57, 0x53, 0x3e, 0x55, 0x5d, 0x33, 0x84, 0x90, 0x97, 0x08, 0x4b, + 0x66, 0x67, 0x93, 0x71, 0xb9, 0xb7, 0x97, 0x18, 0x1d, 0x5d, 0x94, 0x8c, 0xe2, 0x67, 0x07, 0x84, 0x72, 0x7e, 0x9b, + 0x08, 0xed, 0xe5, 0x67, 0x2d, 0x86, 0xd6, 0x4c, 0xd3, 0x76, 0x0f, 0x6c, 0x72, 0x7f, 0xee, 0x33, 0x6e, 0x95, 0xbd, + 0x48, 0x9b, 0xdc, 0xa1, 0x68, 0xa1, 0x94, 0x0d, 0x37, 0xa3, 0xa0, 0x3e, 0x02, 0x57, 0xd0, 0x4a, 0xb4, 0x24, 0xfc, + 0x20, 0xa2, 0xe0, 0x0f, 0xd6, 0x7a, 0x44, 0x69, 0x1b, 0xee, 0x28, 0x39, 0xa2, 0x3a, 0xde, 0x0c, 0x7b, 0xb1, 0xda, + 0xbc, 0x66, 0x0b, 0x4c, 0x69, 0x36, 0x4a, 0xb3, 0x89, 0x7e, 0x57, 0xac, 0x3c, 0x2b, 0xde, 0xc8, 0x46, 0xce, 0xc6, + 0xbe, 0x95, 0x05, 0xd0, 0x5b, 0x31, 0xbc, 0x2b, 0x93, 0xbd, 0x26, 0x4c, 0x4b, 0xf9, 0x2b, 0xdd, 0x82, 0x9a, 0x32, + 0x13, 0xd3, 0xc4, 0x57, 0x3e, 0xd5, 0x9e, 0x74, 0x9b, 0xec, 0x74, 0x7a, 0xa5, 0xdd, 0xa7, 0xa9, 0xa1, 0x27, 0xdd, + 0x1b, 0x4a, 0xa8, 0xa6, 0xb3, 0x38, 0x54, 0xc0, 0x32, 0x84, 0xa9, 0xa2, 0xa3, 0x39, 0x8b, 0xe3, 0xaa, 0xf4, 0x73, + 0x38, 0x7b, 0xa2, 0x38, 0x7b, 0xa6, 0x39, 0x3b, 0xb0, 0x0a, 0xe0, 0xec, 0xb2, 0xbb, 0xaa, 0x79, 0xb6, 0xb6, 0x3d, + 0x33, 0xc9, 0xd3, 0x13, 0x61, 0x4b, 0xc3, 0x78, 0x33, 0x0d, 0x01, 0x2a, 0x75, 0xaf, 0x8f, 0x8e, 0x72, 0xc5, 0x80, + 0x11, 0x28, 0x3d, 0x99, 0xd4, 0x74, 0x53, 0x7c, 0x74, 0x10, 0x4e, 0x0a, 0x5a, 0x52, 0xf6, 0xc9, 0x33, 0xf0, 0xd5, + 0x19, 0xd3, 0x01, 0x31, 0x26, 0x8a, 0x3f, 0x4b, 0x8d, 0xd2, 0xb3, 0x63, 0x6a, 0x76, 0x89, 0x9e, 0x1d, 0xf0, 0xfa, + 0x6a, 0x76, 0xe1, 0xdd, 0xdc, 0x5e, 0x4c, 0x8f, 0x95, 0xd3, 0xab, 0xd6, 0x7b, 0xb9, 0x74, 0x56, 0x4a, 0xc0, 0x8d, + 0xaf, 0x8c, 0x94, 0xac, 0xec, 0x1d, 0x78, 0x80, 0x89, 0x19, 0x28, 0x28, 0xe4, 0xa4, 0x4b, 0x21, 0xf7, 0xf2, 0x53, + 0x4e, 0x1e, 0xe1, 0xad, 0x97, 0xed, 0x4f, 0xd3, 0xc9, 0x14, 0xf4, 0xb1, 0x15, 0x92, 0x1e, 0x53, 0x35, 0x60, 0xf5, + 0xbe, 0xd8, 0x50, 0x56, 0x6b, 0x23, 0xf6, 0x63, 0x8d, 0x9a, 0x4a, 0x9b, 0x79, 0xa7, 0x5d, 0xcc, 0xca, 0xa2, 0x92, + 0x71, 0x6c, 0x72, 0xac, 0x9c, 0xae, 0xba, 0x65, 0xf4, 0x8b, 0x37, 0x0e, 0x93, 0x7c, 0x98, 0x01, 0xaf, 0x33, 0xd8, + 0x8f, 0x26, 0x77, 0x73, 0xfd, 0x8b, 0x0a, 0x39, 0x8b, 0x62, 0x05, 0x7d, 0x8b, 0xa2, 0x78, 0xa6, 0xec, 0x6c, 0xfc, + 0x6c, 0xbb, 0x41, 0x5c, 0xbd, 0x53, 0xf6, 0xe2, 0xf9, 0x10, 0x3f, 0x5b, 0xd7, 0x1e, 0xc9, 0x62, 0x92, 0x86, 0xd4, + 0xb3, 0xd3, 0x29, 0x4d, 0xec, 0x02, 0xbc, 0xab, 0x6a, 0xf1, 0x67, 0xdc, 0x59, 0xbc, 0xab, 0xbb, 0x59, 0xbd, 0x67, + 0x05, 0xb8, 0xc0, 0x7e, 0x5c, 0x77, 0xc0, 0x7e, 0x4b, 0xb3, 0x5c, 0xe8, 0xa2, 0xa5, 0x5a, 0xfb, 0x63, 0x25, 0x98, + 0x7e, 0xf4, 0xb6, 0xd6, 0xaf, 0xac, 0x10, 0xbb, 0xe3, 0x3e, 0x74, 0xf7, 0x6d, 0x24, 0xdc, 0xc3, 0xdf, 0xa8, 0x1d, + 0xff, 0x8b, 0x76, 0x0f, 0x9f, 0x91, 0x5f, 0xea, 0xde, 0xe1, 0x29, 0x27, 0x67, 0x83, 0x33, 0x6d, 0x34, 0xa7, 0x31, + 0x0b, 0x6e, 0x1d, 0x3b, 0x66, 0xbc, 0x09, 0x21, 0x38, 0x1b, 0x2f, 0xe4, 0x0b, 0xf0, 0x2b, 0x0a, 0xb7, 0x76, 0xa1, + 0xcd, 0x3d, 0xcc, 0x38, 0xb1, 0x77, 0x63, 0xc6, 0x77, 0x6d, 0xbc, 0x4b, 0x2e, 0xe1, 0xc7, 0xee, 0xc2, 0x79, 0xe5, + 0xf3, 0xc8, 0xcd, 0xfc, 0x24, 0x4c, 0x27, 0x0e, 0x6a, 0xd8, 0x36, 0x72, 0x73, 0x61, 0x72, 0x3c, 0x46, 0xc5, 0xee, + 0x25, 0x3e, 0xe3, 0xc4, 0x1e, 0xd8, 0x8d, 0x5d, 0xfc, 0x8a, 0x93, 0xcb, 0xe3, 0xdd, 0xc5, 0x19, 0x2f, 0xfa, 0x97, + 0xf8, 0xa4, 0xf4, 0xdc, 0xe3, 0xd7, 0xc4, 0x41, 0xa4, 0x7f, 0xa2, 0xa0, 0x39, 0x4d, 0x27, 0xd2, 0x83, 0x6f, 0x23, + 0xfc, 0x0e, 0xe2, 0x2b, 0x79, 0xc5, 0x6e, 0x54, 0x88, 0x65, 0x87, 0xd8, 0xa9, 0xf0, 0x12, 0xd8, 0x7b, 0x7b, 0x46, + 0x59, 0xa9, 0x2c, 0xe0, 0x53, 0x4e, 0x6a, 0x36, 0x39, 0x7e, 0x29, 0x22, 0x35, 0xa7, 0xdc, 0xc9, 0x91, 0xee, 0xc6, + 0xd1, 0xee, 0x68, 0xb5, 0x37, 0xf3, 0x73, 0xe9, 0x64, 0x70, 0x19, 0xa7, 0x99, 0xcf, 0xd3, 0x6c, 0x88, 0x4c, 0x05, + 0x04, 0xff, 0x8d, 0x5c, 0x9e, 0x5b, 0xff, 0xe9, 0x8b, 0x9f, 0x46, 0x3f, 0x65, 0xc3, 0x4b, 0xfc, 0x81, 0xb4, 0x8e, + 0x9d, 0x81, 0xe7, 0xec, 0x34, 0x9b, 0xcb, 0x9f, 0x5a, 0xe7, 0x7f, 0xf7, 0x9b, 0xbf, 0x9e, 0x34, 0x7f, 0x1c, 0xa2, + 0xa5, 0xf3, 0x53, 0x6b, 0x70, 0xae, 0x9e, 0xce, 0xff, 0xde, 0xff, 0x29, 0x1f, 0x7e, 0x25, 0x0b, 0x77, 0x11, 0x6a, + 0x8d, 0xf1, 0x98, 0x93, 0x56, 0xb3, 0xd9, 0x6f, 0x8d, 0xf1, 0x84, 0x93, 0x16, 0xfc, 0x3b, 0x27, 0xef, 0xe8, 0xf8, + 0xd9, 0xcd, 0xd4, 0xb9, 0xec, 0x2f, 0x77, 0x17, 0x7f, 0x2b, 0xa0, 0xd7, 0xf3, 0xbf, 0xff, 0xf4, 0x53, 0x6e, 0x3f, + 0xe8, 0x93, 0xd6, 0xb0, 0x81, 0x1c, 0x28, 0xfd, 0x8a, 0x88, 0xbf, 0xce, 0xc0, 0x3b, 0xff, 0xbb, 0x82, 0xc2, 0x7e, + 0xf0, 0xd3, 0xe5, 0x71, 0x9f, 0x0c, 0x97, 0x8e, 0xbd, 0x7c, 0x80, 0x96, 0x08, 0x2d, 0x77, 0xd1, 0x25, 0xb6, 0xc7, + 0x36, 0xc2, 0x17, 0x9c, 0xb4, 0x1e, 0xb4, 0xc6, 0x78, 0xc4, 0x49, 0xcb, 0x6e, 0x8d, 0xf1, 0x1b, 0x4e, 0x5a, 0x7f, + 0x77, 0x06, 0x9e, 0x74, 0xb3, 0x2d, 0x85, 0x87, 0x63, 0x09, 0x41, 0x0e, 0x3f, 0xa3, 0xfe, 0x92, 0x33, 0x1e, 0x53, + 0xb4, 0xdb, 0x62, 0xf8, 0xa3, 0x40, 0x93, 0xc3, 0xc1, 0x0f, 0x03, 0xe6, 0x9d, 0xb3, 0xb8, 0x80, 0xc5, 0x06, 0x9a, + 0xd9, 0xf5, 0x20, 0xba, 0x03, 0xae, 0x80, 0xdc, 0xe3, 0xf8, 0xda, 0x8f, 0x67, 0x34, 0xf7, 0x68, 0x81, 0x70, 0x4c, + 0x3e, 0x72, 0xa7, 0x83, 0xf0, 0x0b, 0x0e, 0x3f, 0xba, 0x08, 0x9f, 0xaa, 0x40, 0x26, 0xec, 0x64, 0x49, 0x54, 0x49, + 0x2a, 0x55, 0x16, 0x1b, 0xe1, 0xf1, 0x86, 0x97, 0x3c, 0x02, 0x07, 0x03, 0xc2, 0xd7, 0xb5, 0xb0, 0x27, 0xbe, 0x21, + 0x9a, 0x24, 0xde, 0x67, 0x94, 0x7e, 0xe7, 0xc7, 0x1f, 0x69, 0xe6, 0x9c, 0xe0, 0x4e, 0xf7, 0x31, 0x16, 0x7e, 0xe8, + 0x9d, 0x0e, 0xea, 0x95, 0x31, 0xab, 0xb7, 0x5c, 0x86, 0x0a, 0x40, 0xca, 0xd6, 0xdd, 0x31, 0xb0, 0xe2, 0x3b, 0xeb, + 0x3e, 0xab, 0xcc, 0x9f, 0xdb, 0xa8, 0x1e, 0x1f, 0x65, 0xc9, 0xb5, 0x1f, 0xb3, 0xd0, 0xe2, 0x74, 0x32, 0x8d, 0x7d, + 0x4e, 0x2d, 0x35, 0x5f, 0xcb, 0x87, 0x8e, 0xec, 0x52, 0x67, 0x98, 0x1a, 0x36, 0xe7, 0x54, 0x07, 0x9e, 0x60, 0xaf, + 0x38, 0x10, 0xa5, 0x52, 0x7a, 0xc7, 0xd3, 0x2a, 0x08, 0xb6, 0x1a, 0xe7, 0x6b, 0x76, 0xc0, 0x17, 0x36, 0x14, 0xf2, + 0x39, 0xc1, 0x19, 0x01, 0x29, 0xda, 0x1d, 0xd8, 0xc7, 0xf9, 0xf5, 0xb8, 0x6f, 0x43, 0x8c, 0x26, 0x25, 0x1f, 0x84, + 0x6b, 0x08, 0x2a, 0x44, 0xa4, 0xdd, 0x8b, 0x8e, 0x69, 0x2f, 0x6a, 0x34, 0xb4, 0x16, 0xed, 0x93, 0xfc, 0x3c, 0x92, + 0xcd, 0x03, 0x1c, 0xe2, 0x19, 0x69, 0x76, 0xf0, 0x94, 0xb4, 0x45, 0x93, 0xde, 0xf4, 0xd8, 0x57, 0xc3, 0xec, 0xed, + 0x39, 0xa9, 0x1b, 0xfb, 0x39, 0x7f, 0x01, 0xf6, 0x3e, 0x99, 0xe2, 0x90, 0xa4, 0x2e, 0xbd, 0xa1, 0x81, 0xe3, 0x23, + 0x1c, 0x2a, 0x4e, 0x83, 0x7a, 0x68, 0x4a, 0x8c, 0x6a, 0x60, 0x46, 0x90, 0x0f, 0x83, 0xf0, 0xbc, 0x33, 0x24, 0x84, + 0xd8, 0x3b, 0xcd, 0xa6, 0x3d, 0x48, 0xc9, 0x98, 0x7b, 0x50, 0x62, 0x28, 0xcb, 0x64, 0x02, 0x45, 0x5d, 0xa3, 0xc8, + 0x79, 0xc3, 0x5d, 0x4e, 0x73, 0xee, 0x40, 0x31, 0x78, 0x00, 0x12, 0x4d, 0xd8, 0xf6, 0x71, 0xcb, 0x6e, 0x40, 0xa9, + 0x20, 0x4e, 0x84, 0x53, 0x32, 0x47, 0x5e, 0x78, 0xbe, 0x3f, 0x34, 0x05, 0x80, 0x28, 0x84, 0xc1, 0xe7, 0x83, 0xf0, + 0xbc, 0x2d, 0x06, 0xef, 0xdb, 0x03, 0x27, 0x25, 0xc9, 0x8e, 0x8a, 0xde, 0x78, 0x1f, 0xc4, 0x54, 0x91, 0xa7, 0x80, + 0x53, 0xe3, 0xce, 0x48, 0xb3, 0xeb, 0x39, 0x33, 0x73, 0x12, 0x4d, 0x18, 0x4c, 0x61, 0x01, 0x07, 0x04, 0xea, 0xe3, + 0x94, 0xc0, 0x88, 0x55, 0xb3, 0xb9, 0xa7, 0x9e, 0x1f, 0xd8, 0x0f, 0x06, 0x23, 0xee, 0x5d, 0x70, 0x39, 0xfc, 0x88, + 0x2f, 0x97, 0xf0, 0xef, 0x05, 0x1f, 0xa4, 0x64, 0x2e, 0x8a, 0xc6, 0xaa, 0x68, 0x02, 0x45, 0x1f, 0x3c, 0x00, 0x15, + 0x27, 0xa5, 0x96, 0x25, 0xd7, 0x64, 0x42, 0x04, 0xec, 0x7b, 0x7b, 0xf9, 0x79, 0xd4, 0xe8, 0x0c, 0xc1, 0xc9, 0x9f, + 0xf1, 0xfc, 0x3b, 0xc6, 0x23, 0xc7, 0x6e, 0xf5, 0x6d, 0x34, 0xb0, 0x2d, 0x58, 0xda, 0x5e, 0xd6, 0x20, 0x12, 0xc3, + 0x7e, 0xe3, 0x15, 0xf7, 0x66, 0x7d, 0xd2, 0x1e, 0x38, 0x4c, 0xb9, 0xf4, 0x10, 0xf6, 0x15, 0xe3, 0x6c, 0xe3, 0x19, + 0x6a, 0x30, 0xde, 0xd0, 0xcf, 0x33, 0xd4, 0xd8, 0x6d, 0x4c, 0x90, 0xe7, 0x37, 0x76, 0x1b, 0xce, 0x8c, 0x10, 0xd2, + 0xec, 0x96, 0xcd, 0xb4, 0xf8, 0x8b, 0x90, 0x37, 0xd1, 0xfe, 0xce, 0x73, 0xb1, 0x1d, 0xb2, 0x86, 0x03, 0x2e, 0x96, + 0xe5, 0xd2, 0x3e, 0x1e, 0xf4, 0x6d, 0xd4, 0x70, 0x34, 0xa1, 0xb5, 0x34, 0xa5, 0x21, 0x84, 0xd9, 0xb0, 0x50, 0xf1, + 0xa4, 0x27, 0xb5, 0xd8, 0xd1, 0xa2, 0xda, 0xec, 0x06, 0x0f, 0xa0, 0x45, 0x69, 0xc8, 0x48, 0x85, 0x75, 0x0a, 0xd3, + 0xd4, 0xc4, 0x9c, 0x91, 0x36, 0x4e, 0x89, 0x76, 0x5f, 0x47, 0x84, 0x57, 0x04, 0xef, 0x93, 0xaa, 0x3a, 0x3e, 0x0f, + 0x70, 0x38, 0x24, 0x4f, 0xa5, 0x41, 0xd2, 0xd3, 0xce, 0x71, 0x1a, 0x93, 0x27, 0x2b, 0x51, 0xdc, 0x00, 0x02, 0x2c, + 0x37, 0x6e, 0x30, 0xcb, 0x32, 0x9a, 0xf0, 0xd7, 0x69, 0xa8, 0xf4, 0x34, 0x1a, 0x83, 0xa9, 0x04, 0xe1, 0x59, 0x0c, + 0x4a, 0x5a, 0x57, 0xef, 0x8c, 0xd9, 0xda, 0xeb, 0x29, 0x99, 0x49, 0xfd, 0x49, 0x04, 0x6d, 0x7b, 0x53, 0x65, 0x19, + 0x3b, 0x08, 0xcf, 0x54, 0x34, 0xd7, 0x71, 0x5d, 0x77, 0xea, 0x06, 0xf0, 0x1a, 0x06, 0xc8, 0x51, 0x21, 0xf6, 0x91, + 0x93, 0x90, 0x1b, 0x37, 0xa1, 0x37, 0x62, 0x54, 0x07, 0x55, 0x92, 0x59, 0x6f, 0xaf, 0xe3, 0xa8, 0x27, 0xd8, 0x4d, + 0xe2, 0x26, 0x69, 0x48, 0x01, 0x3d, 0x10, 0xbf, 0x57, 0x45, 0x91, 0x9f, 0x9b, 0x41, 0xaa, 0x0a, 0xbe, 0x73, 0xd3, + 0x7f, 0x3d, 0x05, 0xa7, 0xaf, 0xb0, 0x88, 0xcb, 0xca, 0xd2, 0x13, 0x8e, 0x10, 0x1b, 0x39, 0x53, 0x17, 0x82, 0x7b, + 0x82, 0x84, 0x18, 0xd8, 0x72, 0x53, 0x93, 0xa8, 0x76, 0xcb, 0x3e, 0x27, 0x24, 0x3c, 0x4f, 0x1b, 0x0d, 0xe1, 0x88, + 0x9e, 0x49, 0x92, 0x98, 0x22, 0x3c, 0x29, 0xf7, 0x96, 0xae, 0xf7, 0x96, 0xd4, 0x47, 0x72, 0x26, 0x75, 0x87, 0x6e, + 0x83, 0x71, 0x24, 0x7c, 0x85, 0xdc, 0xd9, 0x45, 0xf8, 0x82, 0xb4, 0x9c, 0x73, 0x77, 0xf0, 0xe7, 0x21, 0x1a, 0x38, + 0xee, 0x57, 0xa8, 0x25, 0x19, 0xc7, 0x04, 0xf5, 0x7c, 0x39, 0xc4, 0x42, 0x44, 0x31, 0x3b, 0x58, 0xf8, 0x12, 0xbd, + 0x0c, 0x27, 0xfe, 0x84, 0x7a, 0x17, 0xb0, 0xc7, 0x35, 0xdd, 0xbc, 0xc5, 0x40, 0x47, 0xde, 0x85, 0xe2, 0x24, 0xae, + 0x3d, 0xf8, 0x85, 0x97, 0x4f, 0x03, 0x7b, 0xf0, 0x75, 0xf5, 0xf4, 0x67, 0x7b, 0xf0, 0x2d, 0xf7, 0xbe, 0x2d, 0x94, + 0xbb, 0xbb, 0x36, 0xc4, 0x43, 0x3d, 0x44, 0x21, 0x17, 0xc6, 0xc0, 0xdc, 0x0c, 0x25, 0x6b, 0x8e, 0x8e, 0x29, 0x2a, + 0xd8, 0xa8, 0x64, 0x45, 0x89, 0xcb, 0xfd, 0x31, 0xa0, 0xd4, 0x58, 0x81, 0xc4, 0x8c, 0xee, 0x57, 0x13, 0x06, 0x42, + 0xd1, 0xd4, 0x0a, 0xa8, 0x9c, 0xf6, 0xdb, 0x68, 0x51, 0xab, 0x2b, 0x34, 0xa6, 0x7a, 0x34, 0xbd, 0xe4, 0xd2, 0x13, + 0xd2, 0xee, 0x4d, 0x8e, 0xa7, 0xbd, 0x49, 0xa3, 0x81, 0x12, 0x4d, 0x58, 0xb3, 0xf3, 0xc9, 0x10, 0xbf, 0x06, 0xaf, + 0x9e, 0x49, 0x49, 0xb8, 0x36, 0xbd, 0xae, 0x9a, 0x5e, 0xa3, 0x91, 0x15, 0xa8, 0x67, 0x34, 0x9d, 0xca, 0xa6, 0x45, + 0x21, 0x71, 0xb2, 0x4a, 0x68, 0x47, 0x48, 0x94, 0x40, 0x4a, 0x14, 0x21, 0xe4, 0x8c, 0xa3, 0x8d, 0xbd, 0x42, 0x9f, + 0xd0, 0x5c, 0xec, 0x58, 0x60, 0x9e, 0x52, 0x46, 0x38, 0x80, 0x05, 0x68, 0x5a, 0xba, 0x82, 0x77, 0xf1, 0xac, 0xd1, + 0x11, 0x44, 0xde, 0xec, 0xf4, 0xea, 0x7d, 0x3d, 0xaa, 0xfa, 0xc2, 0xb3, 0x06, 0xd9, 0x2d, 0xb1, 0x54, 0x64, 0x8d, + 0x46, 0x51, 0x8f, 0x77, 0xea, 0x7d, 0x5b, 0x8b, 0x40, 0x9c, 0xac, 0xa6, 0x66, 0x68, 0xf9, 0x5a, 0x49, 0x54, 0xe6, + 0xb2, 0x24, 0xa1, 0x19, 0xc8, 0x50, 0xc2, 0x31, 0x2b, 0x8a, 0x52, 0xae, 0xbf, 0x01, 0x21, 0x8a, 0x29, 0xc9, 0x81, + 0xef, 0x08, 0xb3, 0x0b, 0x67, 0x38, 0xc5, 0x91, 0xe0, 0x1a, 0x84, 0x90, 0x53, 0x9d, 0xd4, 0xc2, 0x05, 0x07, 0xf2, + 0x09, 0x33, 0x24, 0x52, 0x42, 0xa8, 0x7b, 0xb1, 0x7b, 0x9a, 0xde, 0x69, 0x92, 0x9d, 0xb3, 0xa1, 0x27, 0xaa, 0xc5, + 0x8a, 0x6f, 0x05, 0xe4, 0x9d, 0xc3, 0x51, 0x19, 0x1e, 0x71, 0x05, 0xfb, 0x7b, 0xca, 0x32, 0x2a, 0x34, 0xf0, 0x5d, + 0x6d, 0xf6, 0xf9, 0x75, 0xf5, 0xd1, 0x37, 0x9d, 0x37, 0x80, 0xc8, 0x00, 0x7c, 0x3b, 0x19, 0x59, 0xab, 0x76, 0xb1, + 0x7b, 0xf2, 0x66, 0x93, 0x09, 0xbc, 0x5c, 0x2a, 0xe3, 0xd7, 0x07, 0xcd, 0x06, 0x07, 0x15, 0xa4, 0xbe, 0xfa, 0xe1, + 0x39, 0xbe, 0x50, 0x90, 0x02, 0x27, 0x07, 0x2a, 0xba, 0xd8, 0x3d, 0x79, 0xef, 0xe4, 0xc2, 0xb5, 0x84, 0xb0, 0x39, + 0x6d, 0x27, 0x25, 0x4e, 0x44, 0x28, 0x92, 0x73, 0x2f, 0x19, 0x57, 0x6a, 0x88, 0x6f, 0x2f, 0x12, 0x2f, 0xc1, 0x7e, + 0x38, 0x67, 0x43, 0xe2, 0x2b, 0x0c, 0x10, 0x1f, 0x61, 0xbf, 0x66, 0x96, 0x11, 0x58, 0x00, 0x31, 0xd6, 0x19, 0xac, + 0x84, 0x2b, 0x15, 0x3f, 0x84, 0x7d, 0x31, 0x2a, 0x2f, 0xa4, 0xe8, 0xf8, 0x79, 0x2d, 0x37, 0xad, 0xb2, 0x46, 0xbf, + 0x05, 0xcb, 0x49, 0x3f, 0xbc, 0x56, 0x5d, 0x97, 0x05, 0x4f, 0x75, 0x12, 0xd9, 0xc5, 0xee, 0xc9, 0x2b, 0x95, 0x47, + 0x36, 0xf5, 0x35, 0xb7, 0x5f, 0xb3, 0x30, 0x4f, 0x5e, 0xb9, 0xd5, 0x5b, 0x51, 0xf9, 0x62, 0xf7, 0xe4, 0xc3, 0xa6, + 0x6a, 0x50, 0x5e, 0xcc, 0x2a, 0x13, 0x5f, 0xc0, 0xb7, 0xa0, 0xb1, 0xb7, 0x50, 0xa2, 0xc1, 0x63, 0x05, 0x16, 0xe2, + 0xc8, 0x4b, 0x8a, 0xd2, 0x33, 0xf2, 0x14, 0x67, 0x44, 0xc4, 0x81, 0xea, 0xab, 0xa6, 0x94, 0x3c, 0x96, 0x26, 0x67, + 0x41, 0x3a, 0xa5, 0x5b, 0x82, 0x43, 0x27, 0xc8, 0x65, 0x13, 0x48, 0xa0, 0x11, 0xa0, 0x33, 0xbc, 0xd3, 0x46, 0xbd, + 0xba, 0xf0, 0xca, 0x04, 0x91, 0xa6, 0x35, 0xc9, 0x82, 0x23, 0xd2, 0xc6, 0x3e, 0x69, 0xe3, 0x80, 0x24, 0xe7, 0x6d, + 0x29, 0x1e, 0x7a, 0x41, 0xd9, 0xaf, 0x14, 0x32, 0x90, 0x1b, 0x16, 0xc8, 0xdd, 0x2a, 0xc5, 0x6f, 0xd8, 0x0b, 0x84, + 0xeb, 0x51, 0x48, 0xf4, 0x50, 0x1a, 0xad, 0x4e, 0x8a, 0x53, 0xd1, 0xf1, 0x19, 0xbb, 0x8a, 0x21, 0xbb, 0x04, 0x66, + 0x85, 0x39, 0xf2, 0xca, 0xaa, 0x1d, 0x55, 0x35, 0x70, 0xc5, 0x3a, 0xa5, 0x38, 0x70, 0x81, 0x71, 0xe3, 0x40, 0x25, + 0xe3, 0xe4, 0xeb, 0x4d, 0x1e, 0xee, 0xed, 0x39, 0xb2, 0xd1, 0x77, 0xdc, 0x49, 0xf5, 0xfb, 0x2a, 0x74, 0xf7, 0xad, + 0xe4, 0x15, 0x21, 0x12, 0xf0, 0x37, 0x1a, 0xfe, 0xb0, 0x80, 0x38, 0xb4, 0x13, 0xd4, 0x31, 0xa8, 0x81, 0x17, 0x9a, + 0x5e, 0x7d, 0xfa, 0x8d, 0x46, 0x19, 0xa6, 0xad, 0x63, 0xeb, 0x04, 0x67, 0xc5, 0xb5, 0x53, 0xe6, 0xff, 0xb4, 0xd7, + 0xb2, 0xa6, 0x34, 0x08, 0x88, 0x99, 0x34, 0xcb, 0xf4, 0x64, 0x8c, 0x2d, 0xc1, 0xa0, 0xde, 0x0b, 0x95, 0xb8, 0x80, + 0x45, 0x8e, 0x95, 0xaa, 0xa4, 0xd9, 0x59, 0x17, 0x79, 0xba, 0x12, 0x84, 0xa5, 0xa0, 0x52, 0xa3, 0x50, 0xe4, 0xfd, + 0x6a, 0x3d, 0xf3, 0x12, 0x27, 0x48, 0xf9, 0xb8, 0x04, 0x14, 0x02, 0x59, 0xdd, 0x12, 0x29, 0xcf, 0xc9, 0x78, 0x3b, + 0xc9, 0x9f, 0x18, 0x24, 0xff, 0x84, 0x50, 0x83, 0xfc, 0xa5, 0x87, 0xc3, 0x4d, 0x95, 0x6b, 0x21, 0xd1, 0xaf, 0x4e, + 0xa7, 0x04, 0x7c, 0x68, 0x75, 0x8c, 0x26, 0x66, 0x5c, 0x71, 0x0b, 0x43, 0x31, 0x77, 0x88, 0xf0, 0x42, 0x62, 0x1d, + 0x04, 0x76, 0xaa, 0xa8, 0x1a, 0x0c, 0xbd, 0xc9, 0xa5, 0x67, 0x72, 0xc0, 0x93, 0x0f, 0x77, 0x07, 0x44, 0x4f, 0xa7, + 0xeb, 0x3b, 0xd7, 0xc8, 0x00, 0x85, 0x59, 0x1b, 0x1b, 0xb7, 0x9e, 0x0f, 0x0a, 0xe3, 0x97, 0x81, 0xec, 0x3a, 0xf3, + 0x59, 0xd9, 0x84, 0x5a, 0xfe, 0x01, 0xb4, 0x9d, 0x8e, 0xa8, 0x41, 0x8d, 0x6e, 0x81, 0x1f, 0xc9, 0x3c, 0x54, 0x3f, + 0xdb, 0xc2, 0x3e, 0x4e, 0x44, 0x05, 0x9a, 0x84, 0x9b, 0x5f, 0x3f, 0x29, 0x14, 0x99, 0x48, 0xd0, 0xd0, 0x02, 0xf8, + 0x9f, 0x24, 0x79, 0xa0, 0x1b, 0x21, 0x17, 0x00, 0x41, 0x63, 0x81, 0xa7, 0x0a, 0x61, 0xb6, 0x5d, 0x39, 0xdf, 0x9f, + 0xef, 0x10, 0x32, 0xae, 0x9c, 0x8f, 0xef, 0xaa, 0xec, 0x2b, 0x20, 0x0b, 0xe4, 0x81, 0xf1, 0x58, 0x16, 0xc8, 0xf8, + 0xe5, 0xa9, 0xae, 0x2e, 0x0c, 0x48, 0xb7, 0xd2, 0xb7, 0x8d, 0xd8, 0xa6, 0xf0, 0xca, 0xc9, 0xf7, 0x1a, 0x0d, 0x2b, + 0x6f, 0x77, 0xe1, 0xed, 0x4b, 0x2e, 0x60, 0x84, 0xe7, 0xf7, 0xa2, 0xb6, 0xee, 0xb7, 0xf8, 0xb8, 0x9a, 0xc2, 0xb2, + 0xb2, 0x28, 0x2e, 0x4b, 0x72, 0x9a, 0xf1, 0x27, 0x74, 0x94, 0x66, 0x10, 0xb2, 0x28, 0x71, 0x82, 0x8a, 0x5d, 0xc3, + 0x6d, 0x27, 0xe6, 0x67, 0xc4, 0x09, 0x56, 0x26, 0x28, 0x7e, 0x7d, 0x14, 0x51, 0xeb, 0x8b, 0xd5, 0x56, 0xe3, 0xbd, + 0xbd, 0x77, 0x15, 0x9a, 0x14, 0x94, 0x02, 0x0a, 0x83, 0x69, 0x49, 0x95, 0x46, 0x85, 0x72, 0x77, 0x9d, 0xd2, 0x05, + 0xa0, 0x19, 0x86, 0xc9, 0x7b, 0x9e, 0x13, 0x5e, 0x8c, 0x57, 0x59, 0xbc, 0x72, 0x4d, 0x30, 0xd3, 0x6c, 0x01, 0x0e, + 0x0f, 0x86, 0xb6, 0xf4, 0x15, 0x25, 0x55, 0x4a, 0x6c, 0x09, 0xc3, 0x29, 0x20, 0xcb, 0x49, 0xc0, 0x08, 0x31, 0x28, + 0x30, 0xd9, 0x64, 0x94, 0xbc, 0x05, 0xbd, 0x32, 0xc2, 0x89, 0x1b, 0x41, 0x12, 0x6c, 0x6d, 0xcb, 0x22, 0x84, 0x13, + 0x61, 0xd0, 0x18, 0xb9, 0x04, 0x27, 0xcf, 0x37, 0x79, 0x94, 0x35, 0x51, 0x53, 0x21, 0x75, 0xa0, 0x46, 0x86, 0xca, + 0x06, 0xee, 0xb5, 0xc3, 0x94, 0xe2, 0x56, 0xc6, 0xcd, 0xe8, 0xdc, 0xfa, 0x99, 0x3b, 0x32, 0x16, 0x05, 0x32, 0x23, + 0x75, 0x67, 0x4e, 0x6d, 0xe8, 0x5e, 0x2a, 0x9a, 0x61, 0x85, 0xb8, 0xc8, 0x44, 0x53, 0x2a, 0xe2, 0x7a, 0xa7, 0x15, + 0x2f, 0xbd, 0x96, 0x79, 0xd4, 0x5c, 0x73, 0xc1, 0x2a, 0x93, 0xc4, 0x98, 0xfe, 0xb5, 0x4c, 0x8d, 0x2e, 0x2b, 0x61, + 0x2a, 0xc0, 0x78, 0x22, 0xd6, 0x80, 0x16, 0x40, 0x5f, 0x8b, 0x53, 0x6e, 0xac, 0xa8, 0xf6, 0x61, 0x8b, 0x31, 0x0d, + 0xa9, 0xff, 0x0e, 0x72, 0x5d, 0x56, 0xf7, 0xfc, 0x73, 0x21, 0x0b, 0x19, 0x4e, 0x6a, 0x8c, 0x3d, 0x13, 0x8c, 0x1d, + 0x81, 0x9e, 0xa6, 0xd3, 0xbf, 0x07, 0x2a, 0xe5, 0x45, 0xe5, 0x2e, 0x3a, 0x8a, 0xc4, 0x5e, 0x97, 0xe1, 0x72, 0xe3, + 0xf7, 0xca, 0x6a, 0x78, 0x8c, 0x40, 0x1a, 0x10, 0x56, 0x9c, 0x3d, 0x43, 0x38, 0x69, 0x34, 0x7a, 0xc9, 0x31, 0xad, + 0x5c, 0x24, 0x15, 0x8c, 0x0c, 0x22, 0xba, 0x40, 0xf0, 0x35, 0x19, 0x9a, 0x20, 0x5c, 0xe6, 0xa1, 0x27, 0xe0, 0x6a, + 0x3f, 0x79, 0xe7, 0x98, 0x5c, 0xcd, 0xac, 0x5b, 0x06, 0x4d, 0x61, 0x3e, 0x4e, 0x15, 0x6f, 0x79, 0x7b, 0x77, 0x86, + 0x07, 0xc0, 0xbd, 0xd3, 0xc1, 0x90, 0x8d, 0x86, 0x7a, 0x5c, 0xb2, 0x84, 0x72, 0xf7, 0xf5, 0x50, 0x95, 0x98, 0x68, + 0x0e, 0xd6, 0xe3, 0x95, 0x29, 0xcb, 0x49, 0x52, 0x14, 0x39, 0xad, 0xe2, 0xfb, 0x2b, 0x19, 0x98, 0x42, 0xb8, 0xac, + 0x3b, 0xdb, 0x4f, 0xa7, 0x84, 0x63, 0x83, 0x50, 0xdf, 0x6e, 0x0b, 0x7d, 0x54, 0x60, 0xc2, 0xbe, 0x56, 0x42, 0xf1, + 0xdb, 0x4d, 0x42, 0x11, 0x67, 0x6a, 0xcb, 0x0b, 0x81, 0xd8, 0xb9, 0x87, 0x40, 0x54, 0x4e, 0x76, 0x2d, 0x13, 0x41, + 0x1d, 0xa9, 0xc9, 0xc4, 0xa4, 0x2e, 0x13, 0x33, 0xcc, 0xd4, 0x6a, 0xf4, 0xbb, 0xcb, 0x25, 0x3b, 0x6f, 0x83, 0x13, + 0xc9, 0xb6, 0xe1, 0x67, 0x47, 0xfe, 0x34, 0x38, 0xb1, 0x74, 0x02, 0x3b, 0xac, 0x34, 0x59, 0x90, 0x0b, 0x69, 0xce, + 0x8e, 0xc8, 0xca, 0x12, 0x34, 0xad, 0x28, 0x48, 0x11, 0x38, 0x61, 0x65, 0x94, 0x09, 0x20, 0x16, 0xb2, 0x42, 0x19, + 0x90, 0xce, 0xc6, 0xf4, 0x3f, 0x6d, 0x5e, 0x7e, 0x5a, 0x13, 0xad, 0xc9, 0x15, 0xa9, 0x3e, 0xd4, 0x12, 0x0e, 0x14, + 0x04, 0x4a, 0x3f, 0xdc, 0x11, 0x26, 0x68, 0x25, 0xca, 0x91, 0x29, 0x87, 0x70, 0x1b, 0x5c, 0x68, 0x3b, 0xef, 0x64, + 0x80, 0x77, 0x83, 0x34, 0xc1, 0xa9, 0x41, 0xd7, 0xcf, 0x09, 0xaf, 0xb1, 0x92, 0x88, 0x28, 0x4b, 0x09, 0x07, 0x82, + 0x4c, 0x39, 0xc9, 0xce, 0xdb, 0x43, 0x50, 0x40, 0x7b, 0xfe, 0x71, 0x56, 0x99, 0xc0, 0x7e, 0xa3, 0x81, 0x02, 0x3d, + 0x6a, 0x74, 0xce, 0x1a, 0xfe, 0x10, 0x53, 0xec, 0x4b, 0xc3, 0xe4, 0x74, 0x6f, 0xcf, 0x09, 0xaa, 0x71, 0xcf, 0xfd, + 0x21, 0xc2, 0xe9, 0x72, 0xe9, 0x08, 0xb0, 0x02, 0xb4, 0x5c, 0x06, 0x26, 0x58, 0xe2, 0x35, 0x34, 0x1b, 0x0f, 0x38, + 0x19, 0x0b, 0x01, 0x38, 0x06, 0x08, 0x1b, 0xc4, 0x09, 0x94, 0x73, 0x2f, 0x00, 0x67, 0x54, 0x23, 0x3b, 0xf7, 0x1b, + 0x9d, 0xa1, 0xc1, 0xb8, 0xce, 0xfd, 0x21, 0x09, 0x8a, 0x74, 0x6f, 0x6f, 0x27, 0x51, 0x22, 0xf2, 0x67, 0x10, 0x65, + 0x3f, 0x0b, 0xc9, 0x22, 0x3b, 0x34, 0x57, 0x63, 0xd5, 0x19, 0x50, 0x52, 0x94, 0x5a, 0x56, 0x5d, 0xaf, 0x96, 0x05, + 0x51, 0x56, 0xc2, 0x2a, 0x16, 0x3c, 0x00, 0xcb, 0xbe, 0x24, 0xf3, 0x5f, 0x78, 0x99, 0x66, 0xfd, 0xed, 0xc6, 0xe4, + 0x6a, 0xd7, 0x75, 0xfd, 0x6c, 0x2c, 0x22, 0x19, 0x3a, 0x63, 0x52, 0x10, 0xff, 0xbe, 0x02, 0xd3, 0x18, 0xf8, 0xbc, + 0x1c, 0x6b, 0x48, 0x24, 0xf8, 0x5a, 0xb5, 0xd1, 0x27, 0x4a, 0x7e, 0xdd, 0xe8, 0x65, 0x90, 0x90, 0x7c, 0xfd, 0x5b, + 0x21, 0x39, 0x50, 0x90, 0x48, 0xf2, 0x58, 0xc1, 0xd9, 0x16, 0x5c, 0xfc, 0xca, 0x57, 0x70, 0xb6, 0x1d, 0xb7, 0x25, + 0x43, 0xd8, 0x06, 0x9f, 0xc1, 0x1b, 0x24, 0xa0, 0x55, 0x81, 0x01, 0xe5, 0xe1, 0xaa, 0xee, 0x25, 0x59, 0x29, 0x08, + 0x53, 0x4e, 0x1c, 0x56, 0xdf, 0x00, 0x95, 0x36, 0x6a, 0x18, 0xbe, 0xcc, 0x1b, 0x23, 0xc3, 0x25, 0x50, 0x4f, 0x5d, + 0x01, 0x72, 0x52, 0xbe, 0x76, 0x48, 0x45, 0xd8, 0x91, 0x4a, 0x9c, 0x1b, 0xf8, 0x53, 0x3e, 0xcb, 0x40, 0x95, 0x4a, + 0xf4, 0x6f, 0x28, 0x86, 0xb3, 0x20, 0xa2, 0x0c, 0x7e, 0x40, 0xc1, 0xd4, 0xcf, 0x73, 0x76, 0x2d, 0xcb, 0xd4, 0x6f, + 0x9c, 0x12, 0x4d, 0xca, 0x89, 0xd4, 0x09, 0x33, 0xd4, 0xcb, 0x14, 0x9d, 0xd6, 0xd1, 0xf6, 0xec, 0x9a, 0x26, 0xfc, + 0x25, 0xcb, 0x39, 0x4d, 0x60, 0xfa, 0x15, 0xc5, 0xc1, 0x8c, 0x12, 0x04, 0x1b, 0xb6, 0xd6, 0xca, 0x0f, 0xc3, 0x3b, + 0x9b, 0xf0, 0xba, 0x0e, 0x14, 0xf9, 0x49, 0x18, 0xcb, 0x41, 0xcc, 0x84, 0x46, 0x9d, 0xc4, 0x59, 0xd6, 0x34, 0xf3, + 0x69, 0x2a, 0x65, 0x43, 0x70, 0x77, 0x87, 0x11, 0x2d, 0x09, 0xb4, 0xf4, 0xbc, 0x53, 0x6b, 0x81, 0x80, 0xf7, 0x96, + 0x45, 0x30, 0x67, 0x82, 0xb9, 0xc1, 0x51, 0xdd, 0x3a, 0x9c, 0x9a, 0x6e, 0xbe, 0xdb, 0x78, 0xb0, 0x6d, 0x93, 0x70, + 0x10, 0x74, 0xf2, 0x70, 0xbb, 0x65, 0xf5, 0x4a, 0x4b, 0x0e, 0x2d, 0x2d, 0xd8, 0x7d, 0x19, 0x33, 0x5a, 0x68, 0xf2, + 0x42, 0x7a, 0x2b, 0xde, 0x72, 0xf2, 0x0b, 0x9c, 0x1c, 0x7a, 0xce, 0x27, 0xf1, 0xca, 0x01, 0x99, 0xde, 0x6d, 0xa9, + 0xfd, 0xdf, 0x72, 0xe7, 0x09, 0x7e, 0x05, 0x61, 0xdd, 0x6f, 0xaa, 0xea, 0xeb, 0xe1, 0xdc, 0x6f, 0x2a, 0x04, 0x7d, + 0xe3, 0xad, 0xd5, 0x33, 0xc2, 0xb8, 0x5d, 0xf7, 0xc8, 0x6d, 0xdb, 0x5a, 0x5b, 0xfa, 0x51, 0x06, 0x91, 0x64, 0xaa, + 0xa5, 0xd8, 0x0f, 0xb8, 0x4a, 0x54, 0x83, 0x84, 0xb9, 0xba, 0x85, 0x44, 0x55, 0x8a, 0xa1, 0xd4, 0xe1, 0xb7, 0x2d, + 0x8f, 0x92, 0x31, 0x99, 0xb4, 0x33, 0xde, 0xfa, 0x19, 0xdf, 0x85, 0x5d, 0x96, 0xae, 0x9d, 0xc6, 0x8b, 0x08, 0x78, + 0xd0, 0xee, 0x37, 0x44, 0x75, 0x16, 0x60, 0x90, 0xc8, 0xc3, 0x40, 0x66, 0xff, 0x24, 0xd5, 0xba, 0x5b, 0xdd, 0xca, + 0x78, 0x0d, 0xf6, 0x3f, 0xc2, 0x91, 0x3e, 0x22, 0x47, 0x15, 0x07, 0xa6, 0xde, 0xa2, 0x28, 0x9d, 0x02, 0xa9, 0x54, + 0xde, 0x72, 0x84, 0xd3, 0x42, 0x84, 0xb7, 0xbf, 0xc7, 0x3f, 0x28, 0x96, 0x38, 0x2a, 0x39, 0xce, 0xb3, 0xfb, 0x72, + 0x44, 0x09, 0x7e, 0x19, 0xbd, 0x07, 0x3a, 0x16, 0x14, 0x5a, 0x68, 0x2a, 0x7a, 0x9a, 0xaa, 0x89, 0x6c, 0xcd, 0x4b, + 0xc5, 0xb4, 0xcc, 0xa8, 0x11, 0xc3, 0x6c, 0x48, 0xe4, 0xd4, 0x56, 0x36, 0x2f, 0x77, 0x55, 0x6d, 0x5c, 0xb4, 0x05, + 0x8b, 0x55, 0x60, 0x71, 0xb9, 0x74, 0xea, 0xa8, 0x26, 0xcc, 0x88, 0x63, 0x20, 0xcc, 0x8c, 0x84, 0x8a, 0x9a, 0x66, + 0x2d, 0xdb, 0x38, 0x68, 0x35, 0x9f, 0x48, 0xeb, 0xe6, 0x35, 0x38, 0x4c, 0x17, 0x82, 0x6c, 0x6e, 0xfa, 0x14, 0xb0, + 0x9c, 0x5d, 0x39, 0x90, 0x81, 0xa1, 0x1f, 0xcb, 0x5c, 0xd9, 0x2a, 0xa9, 0x75, 0x03, 0x7e, 0xd1, 0x1d, 0xd9, 0xb2, + 0x0a, 0x75, 0xeb, 0xef, 0x8d, 0x5c, 0xa3, 0xa7, 0xe9, 0xb6, 0x5c, 0xa3, 0x9a, 0xb6, 0xbb, 0xd3, 0x46, 0x77, 0xe7, + 0xa5, 0xca, 0xb1, 0x36, 0x57, 0xf9, 0x0d, 0xc3, 0x75, 0x80, 0x36, 0x25, 0x9a, 0x35, 0x57, 0x39, 0x2d, 0x8a, 0x51, + 0x79, 0x9a, 0x40, 0xa4, 0xee, 0x8c, 0x24, 0xfd, 0x2b, 0xab, 0x51, 0x1c, 0xca, 0x75, 0xbe, 0x27, 0xe3, 0x38, 0xbd, + 0xf2, 0xe3, 0xf7, 0x30, 0x5e, 0xf5, 0xf2, 0xf9, 0x6d, 0x98, 0xf9, 0x9c, 0x2a, 0xee, 0x52, 0xc1, 0xf0, 0xbd, 0x01, + 0xc3, 0xf7, 0x92, 0x4f, 0x57, 0xed, 0xf1, 0xe2, 0x65, 0xd9, 0x81, 0x37, 0x2a, 0x34, 0xcb, 0xd8, 0xe5, 0x9b, 0xc7, + 0x58, 0x65, 0x61, 0xbb, 0x25, 0x0b, 0xdb, 0xe5, 0xce, 0x6a, 0x57, 0x8e, 0xf3, 0xc3, 0xe6, 0x5e, 0xd6, 0x39, 0xdb, + 0x0f, 0xd5, 0xc6, 0xff, 0xc1, 0xbb, 0xb3, 0x8d, 0xc1, 0xe5, 0xf6, 0xdd, 0x7d, 0x91, 0xac, 0x22, 0x41, 0x7e, 0x09, + 0x49, 0x07, 0x9c, 0xf4, 0x8d, 0x43, 0x07, 0x95, 0x9c, 0xd2, 0x79, 0x40, 0x4e, 0x30, 0xcb, 0x79, 0x3a, 0x51, 0x7d, + 0xe6, 0xea, 0xa4, 0x91, 0x78, 0x09, 0xae, 0x68, 0x11, 0x6b, 0xf7, 0xea, 0x67, 0xb9, 0x16, 0x1f, 0x59, 0x12, 0x7a, + 0x09, 0x56, 0x52, 0x24, 0xf7, 0xb2, 0x82, 0xe8, 0x6c, 0xe3, 0xf5, 0x77, 0x78, 0xc4, 0x12, 0x96, 0x47, 0x34, 0x73, + 0x52, 0xb4, 0xd8, 0x36, 0x58, 0x0a, 0x01, 0x19, 0x39, 0x18, 0xfe, 0x6b, 0x75, 0xea, 0xcf, 0x85, 0xde, 0xc0, 0x0f, + 0x34, 0xa1, 0x3c, 0x4a, 0x43, 0x48, 0x4b, 0x71, 0xc3, 0xf2, 0x50, 0xd3, 0xde, 0xde, 0x8e, 0x63, 0x0b, 0xb7, 0x04, + 0x1c, 0x00, 0x37, 0xdf, 0xa0, 0xc1, 0x02, 0xce, 0xe7, 0x54, 0x43, 0x53, 0xb4, 0xa0, 0xab, 0x47, 0x59, 0xb8, 0xfb, + 0x91, 0xde, 0xe2, 0x1c, 0x15, 0x85, 0x27, 0xa1, 0xb6, 0x47, 0x8c, 0xc6, 0xa1, 0x8d, 0x3f, 0xd2, 0x5b, 0xaf, 0x3c, + 0x33, 0x2e, 0x8e, 0x38, 0x8b, 0x05, 0xb4, 0xd3, 0x79, 0x62, 0xe3, 0x6a, 0x10, 0x6f, 0x51, 0xe0, 0x34, 0x63, 0x63, + 0x20, 0xce, 0x6f, 0xe8, 0xad, 0x27, 0xfb, 0x63, 0xc6, 0x79, 0x3d, 0xb4, 0xd0, 0xa8, 0x77, 0x8d, 0x62, 0x73, 0x19, + 0x94, 0x41, 0x71, 0x2e, 0xda, 0x0e, 0x49, 0xad, 0x5e, 0x65, 0x1e, 0x22, 0x54, 0xdc, 0x77, 0x2a, 0xf8, 0x1b, 0x53, + 0xb4, 0xf1, 0x5a, 0xe6, 0xeb, 0x4a, 0x23, 0x0a, 0x0d, 0xaa, 0x4c, 0x0f, 0xc8, 0xe8, 0x58, 0x68, 0xf6, 0x2a, 0x9a, + 0x1b, 0x8e, 0xb0, 0x6f, 0xb8, 0xea, 0xd4, 0xfb, 0xab, 0x4c, 0x08, 0xa9, 0x22, 0x49, 0x2f, 0xaa, 0x76, 0xd6, 0xad, + 0x03, 0x78, 0x87, 0x84, 0x16, 0x5f, 0x9c, 0xc9, 0x2c, 0x74, 0xb6, 0xe8, 0xdf, 0x38, 0x71, 0x16, 0x7a, 0x0a, 0x5e, + 0x6e, 0x62, 0x91, 0x17, 0x40, 0x85, 0x8a, 0xbe, 0x64, 0x02, 0x20, 0x1b, 0x39, 0x6c, 0x4d, 0x6a, 0x66, 0x42, 0x6a, + 0xba, 0x06, 0xc6, 0xb7, 0x48, 0x49, 0x2a, 0x90, 0x21, 0x94, 0x48, 0x21, 0xf4, 0xd4, 0xe2, 0x2a, 0x12, 0x32, 0x17, + 0xb4, 0x3c, 0x41, 0x27, 0xd7, 0x3c, 0xab, 0x81, 0xe5, 0x88, 0x7e, 0x50, 0xe1, 0xc1, 0x94, 0xa8, 0xac, 0x50, 0x68, + 0x77, 0x4e, 0xae, 0xd3, 0x5b, 0x9d, 0xd4, 0xd5, 0xd3, 0x22, 0x1a, 0x25, 0x4e, 0x84, 0x16, 0xb9, 0x13, 0xe1, 0x0c, + 0xd2, 0x11, 0xd3, 0xa2, 0x84, 0x9f, 0x9a, 0xab, 0x51, 0x4b, 0x56, 0xde, 0x7c, 0xca, 0x0f, 0x94, 0x79, 0x0e, 0x29, + 0x9a, 0x38, 0xd7, 0x3c, 0x25, 0x77, 0xc4, 0x71, 0x3b, 0x63, 0xd9, 0xbe, 0x57, 0x09, 0x3a, 0x0a, 0xb0, 0xbf, 0x71, + 0x67, 0x61, 0xcc, 0xc2, 0x3c, 0xd1, 0xad, 0x4e, 0xfd, 0xa9, 0x60, 0x5f, 0x95, 0x43, 0xea, 0x24, 0x64, 0x45, 0xe2, + 0xdc, 0x9d, 0x6a, 0xf9, 0xcb, 0x8c, 0x66, 0xb7, 0x67, 0x14, 0x52, 0x9d, 0x53, 0x38, 0xf0, 0x5b, 0x2d, 0x43, 0x95, + 0xa7, 0x3e, 0xc8, 0x84, 0xb2, 0x52, 0xd4, 0xcf, 0x01, 0xae, 0x9e, 0x12, 0x2c, 0x44, 0xb4, 0xd1, 0x70, 0xc4, 0xc8, + 0xdd, 0x42, 0xb7, 0x9e, 0x9f, 0xa4, 0x3d, 0x06, 0xfe, 0xb5, 0x0a, 0xd3, 0x2a, 0x58, 0x80, 0x53, 0xf3, 0x4c, 0xea, + 0x79, 0x32, 0x5c, 0xf5, 0xca, 0x40, 0x11, 0x84, 0xef, 0xb2, 0xed, 0x53, 0xdd, 0x94, 0x34, 0xbb, 0x7d, 0xaa, 0xb5, + 0xa0, 0x9f, 0x48, 0xf8, 0xc1, 0x6a, 0x9c, 0xf2, 0x04, 0x33, 0x2b, 0x0a, 0x54, 0x00, 0x78, 0x7f, 0xe9, 0x39, 0xce, + 0x5f, 0x54, 0xca, 0xa0, 0x0b, 0xb1, 0xd8, 0xb3, 0x38, 0xd5, 0x4c, 0xbc, 0x1a, 0xff, 0x2f, 0x6b, 0xe3, 0xff, 0xc5, + 0x38, 0x75, 0x0a, 0xa6, 0xd1, 0x38, 0xa1, 0xa1, 0x66, 0x9d, 0x48, 0x12, 0xa0, 0xd0, 0xdb, 0x32, 0x4e, 0x3e, 0x5e, + 0x7a, 0xa0, 0x71, 0x2d, 0x46, 0x69, 0xc2, 0x9b, 0x23, 0x7f, 0xc2, 0xe2, 0x5b, 0x6f, 0xc6, 0x9a, 0x93, 0x34, 0x49, + 0xf3, 0xa9, 0x1f, 0x50, 0x9c, 0xdf, 0xe6, 0x9c, 0x4e, 0x9a, 0x33, 0x86, 0x9f, 0xd3, 0xf8, 0x9a, 0x72, 0x16, 0xf8, + 0xd8, 0x3e, 0xc9, 0x98, 0x1f, 0x5b, 0xaf, 0xfd, 0x2c, 0x4b, 0xe7, 0x36, 0x7e, 0x97, 0x5e, 0xa5, 0x3c, 0xc5, 0x6f, + 0x6e, 0x6e, 0xc7, 0x34, 0xc1, 0x1f, 0xae, 0x66, 0x09, 0x9f, 0xe1, 0xdc, 0x4f, 0xf2, 0x66, 0x4e, 0x33, 0x36, 0xea, + 0x05, 0x69, 0x9c, 0x66, 0x4d, 0xc8, 0xd8, 0x9e, 0x50, 0x2f, 0x66, 0xe3, 0x88, 0x5b, 0xa1, 0x9f, 0x7d, 0xec, 0x35, + 0x9b, 0xd3, 0x8c, 0x4d, 0xfc, 0xec, 0xb6, 0x29, 0x6a, 0x78, 0x5f, 0xb6, 0xf7, 0xfd, 0xc7, 0xa3, 0x83, 0x1e, 0xcf, + 0xfc, 0x24, 0x67, 0xb0, 0x4c, 0x9e, 0x1f, 0xc7, 0xd6, 0xfe, 0x61, 0x7b, 0x92, 0xef, 0xc8, 0x40, 0x9e, 0x9f, 0xf0, + 0xe2, 0x12, 0xbf, 0x07, 0xb8, 0xdd, 0x2b, 0x9e, 0xe0, 0xab, 0x19, 0xe7, 0x69, 0xb2, 0x08, 0x66, 0x59, 0x9e, 0x66, + 0xde, 0x34, 0x65, 0x09, 0xa7, 0x59, 0xef, 0x2a, 0xcd, 0x42, 0x9a, 0x35, 0x33, 0x3f, 0x64, 0xb3, 0xdc, 0x3b, 0x98, + 0xde, 0xf4, 0x40, 0xb3, 0x18, 0x67, 0xe9, 0x2c, 0x09, 0xd5, 0x58, 0x2c, 0x89, 0x68, 0xc6, 0xb8, 0xf9, 0x42, 0x5c, + 0x64, 0xe2, 0xc5, 0x2c, 0xa1, 0x7e, 0xd6, 0x1c, 0x43, 0x63, 0x30, 0x8b, 0xda, 0x21, 0x1d, 0xe3, 0x6c, 0x7c, 0xe5, + 0x3b, 0x9d, 0xee, 0x23, 0xac, 0xff, 0x77, 0x0f, 0x91, 0xd5, 0xde, 0x5c, 0xdc, 0x69, 0xb7, 0xff, 0x84, 0x7a, 0x2b, + 0xa3, 0x08, 0x80, 0xbc, 0xce, 0xf4, 0xc6, 0xca, 0x53, 0xc8, 0x68, 0xdb, 0xd4, 0xb2, 0x37, 0xf5, 0x43, 0xc8, 0x07, + 0xf6, 0xba, 0xd3, 0x9b, 0x02, 0x66, 0xe7, 0xc9, 0x14, 0x53, 0x35, 0x49, 0xf5, 0xb4, 0xf8, 0xad, 0x10, 0x1f, 0x6d, + 0x86, 0xb8, 0xab, 0x21, 0xae, 0xb0, 0xde, 0x0c, 0x67, 0x99, 0x88, 0xad, 0x7a, 0x9d, 0x5c, 0x02, 0x12, 0xa5, 0xd7, + 0x34, 0xd3, 0x70, 0x88, 0x87, 0xdf, 0x0c, 0x46, 0x77, 0x33, 0x18, 0x47, 0x9f, 0x02, 0x23, 0x4b, 0xc2, 0x45, 0x7d, + 0x5d, 0x3b, 0x19, 0x9d, 0xf4, 0x22, 0x0a, 0xf4, 0xe4, 0x75, 0xe1, 0xf7, 0x9c, 0x85, 0x3c, 0x92, 0x3f, 0x05, 0x39, + 0xcf, 0xe5, 0xbb, 0xc3, 0x76, 0x5b, 0x3e, 0xe7, 0xec, 0x57, 0xea, 0x75, 0x5c, 0xa8, 0x50, 0x5c, 0xe2, 0x1f, 0xca, + 0xd3, 0xbc, 0x75, 0xee, 0x89, 0xff, 0x62, 0x1e, 0xf3, 0x35, 0x52, 0x14, 0xab, 0x43, 0xd1, 0x38, 0xd5, 0xb2, 0x52, + 0x0a, 0x1f, 0x70, 0xdb, 0x09, 0xee, 0x48, 0x58, 0xbf, 0x3c, 0xc6, 0xc9, 0x06, 0x7f, 0x91, 0x79, 0x17, 0x1e, 0x44, + 0x3a, 0x8c, 0x54, 0xc3, 0xb4, 0x97, 0xf5, 0x49, 0xbb, 0x97, 0x35, 0x9b, 0xc8, 0x49, 0x09, 0x9c, 0x16, 0x90, 0xc9, + 0x79, 0x0e, 0x1b, 0xa4, 0xc2, 0xd8, 0x4e, 0x90, 0x97, 0xc2, 0x59, 0xd3, 0xe5, 0x32, 0xa9, 0x12, 0x32, 0xc4, 0x69, + 0x8d, 0x1f, 0xb8, 0xaa, 0x80, 0x13, 0x83, 0x93, 0xfb, 0xfa, 0x7a, 0x97, 0x5c, 0xf3, 0x8a, 0x38, 0x0d, 0x04, 0xe6, + 0xdc, 0xa9, 0xcf, 0x23, 0xf0, 0x52, 0x94, 0xe2, 0xa7, 0x4a, 0x61, 0xb2, 0x5b, 0x36, 0x1a, 0xe4, 0x65, 0x7e, 0x1b, + 0xe4, 0xf1, 0xe5, 0x05, 0xf4, 0x72, 0xc5, 0x09, 0xf4, 0x58, 0xf5, 0xff, 0x81, 0x1b, 0x92, 0x3a, 0x77, 0x59, 0x12, + 0xc4, 0xb3, 0x90, 0xe6, 0xa2, 0x87, 0x4a, 0x9c, 0xc3, 0xdd, 0x10, 0x65, 0x2d, 0xd1, 0x04, 0x7a, 0x17, 0xd9, 0x3c, + 0x50, 0x11, 0x6e, 0x51, 0x29, 0x9f, 0x9b, 0xe2, 0xb9, 0x6a, 0xfb, 0xba, 0x4a, 0x16, 0x85, 0x96, 0xee, 0x2c, 0x61, + 0xbf, 0xcc, 0xe8, 0x05, 0x0b, 0x8d, 0x93, 0xbb, 0x34, 0x09, 0xd2, 0x90, 0x7e, 0x78, 0xf7, 0x02, 0xb2, 0xdd, 0xd3, + 0x04, 0x48, 0x4c, 0xf9, 0xbb, 0x70, 0x42, 0x40, 0x23, 0xbc, 0x66, 0x01, 0x1d, 0x5c, 0xee, 0x2e, 0x36, 0x56, 0x94, + 0xaf, 0x51, 0xd1, 0xba, 0x14, 0x49, 0x7f, 0x02, 0xca, 0xcb, 0xdd, 0xc5, 0x15, 0x2f, 0x5a, 0xbb, 0x8b, 0xdc, 0x0d, + 0xd3, 0x89, 0xcf, 0x12, 0xf8, 0x9d, 0x14, 0xbb, 0x0b, 0x06, 0x3f, 0x78, 0x71, 0x59, 0x54, 0x89, 0xa2, 0x25, 0x44, + 0xc6, 0x14, 0x14, 0xee, 0x3a, 0xc8, 0xfd, 0x39, 0x65, 0x89, 0x28, 0xba, 0xab, 0x67, 0xaa, 0x7b, 0x05, 0x24, 0xff, + 0x4a, 0xa4, 0xc1, 0xac, 0xcd, 0xe5, 0xd1, 0x7d, 0xcd, 0x65, 0x9a, 0x70, 0x26, 0xd2, 0xe2, 0x75, 0x38, 0x27, 0xf2, + 0xf3, 0x8b, 0x40, 0x9e, 0x44, 0xcd, 0xab, 0x53, 0x17, 0xbe, 0x40, 0xac, 0xb4, 0x80, 0x69, 0x26, 0x8c, 0x7d, 0xba, + 0xfd, 0xa8, 0x64, 0x7e, 0x97, 0xf1, 0x57, 0x52, 0x55, 0x9e, 0xce, 0xb2, 0x00, 0x62, 0xbd, 0x4a, 0xa5, 0x58, 0xf7, + 0x8a, 0xd9, 0x42, 0x7f, 0xb3, 0x31, 0x37, 0x92, 0x6c, 0x39, 0x9c, 0xe9, 0xab, 0xae, 0xed, 0xa0, 0x22, 0x9e, 0x08, + 0x6b, 0xc6, 0xc4, 0xea, 0x5d, 0xb0, 0x10, 0x02, 0x2f, 0x2c, 0x54, 0x09, 0x8b, 0xb5, 0x49, 0x82, 0x8a, 0x14, 0x8a, + 0x0c, 0x52, 0xb8, 0x6c, 0x27, 0xad, 0x56, 0x01, 0x84, 0x1f, 0xd2, 0x2e, 0xf9, 0x66, 0x67, 0x6f, 0x2f, 0xa9, 0x4e, + 0xb4, 0x31, 0x85, 0xf3, 0xe5, 0x92, 0x53, 0x27, 0x91, 0xa7, 0x6e, 0x22, 0x02, 0xca, 0x18, 0xc3, 0xf2, 0x8d, 0x97, + 0xe2, 0xb2, 0x27, 0x2f, 0x29, 0x7a, 0x91, 0x40, 0xa2, 0x44, 0x19, 0xd1, 0x48, 0x3d, 0xd1, 0x2a, 0x19, 0x36, 0x5f, + 0x97, 0x07, 0xf9, 0x6b, 0x58, 0x6f, 0xaf, 0x2c, 0x8e, 0xb4, 0xaa, 0xa2, 0xd5, 0xd2, 0x3c, 0xcd, 0xb8, 0xe3, 0xf8, + 0x38, 0x40, 0xa4, 0xef, 0x8b, 0xd9, 0x1f, 0xcb, 0x7c, 0x8f, 0x41, 0xb3, 0xe3, 0x75, 0x4a, 0x7f, 0x48, 0xed, 0x7c, + 0xb5, 0xcc, 0x36, 0x53, 0x67, 0x74, 0x01, 0x4f, 0xb8, 0xfc, 0xad, 0xd0, 0x57, 0x15, 0xc8, 0xd9, 0x55, 0xcf, 0xe5, + 0x24, 0xb1, 0x62, 0x68, 0x52, 0x19, 0x70, 0x6a, 0x50, 0x9d, 0x67, 0x43, 0xcc, 0xb6, 0x8c, 0x8d, 0x8a, 0x0a, 0x11, + 0xe5, 0xe6, 0xbe, 0x94, 0x4a, 0xd0, 0x85, 0x41, 0xdd, 0x97, 0x4c, 0xbb, 0xf1, 0xea, 0x74, 0x57, 0x28, 0x14, 0x19, + 0x9c, 0x61, 0x53, 0x35, 0x09, 0xcb, 0x2d, 0xc9, 0x37, 0x12, 0xaf, 0x2b, 0x1f, 0xa9, 0xa4, 0x8d, 0xcd, 0x55, 0x44, + 0x32, 0xe4, 0x26, 0xc0, 0xc0, 0x31, 0x90, 0x73, 0x3d, 0x05, 0xe0, 0x31, 0x23, 0x0a, 0x27, 0x95, 0x14, 0xc7, 0xc1, + 0x0b, 0xa9, 0xdd, 0x7b, 0xf6, 0xdb, 0x37, 0x67, 0xef, 0x6d, 0x0c, 0x57, 0x9d, 0xd1, 0x2c, 0xf7, 0x16, 0xb6, 0xca, + 0x31, 0x6c, 0x42, 0xbc, 0xda, 0xf6, 0x6c, 0x7f, 0x0a, 0x87, 0xb6, 0x05, 0x53, 0x6d, 0xdd, 0x34, 0xe7, 0xf3, 0x79, + 0x13, 0x4e, 0x94, 0x35, 0x67, 0x59, 0x2c, 0xd9, 0x4d, 0x68, 0x17, 0x05, 0x72, 0x79, 0x44, 0x93, 0xf2, 0x32, 0xa4, + 0x34, 0xa6, 0x6e, 0x9c, 0x8e, 0xe5, 0x79, 0xd8, 0x55, 0xf7, 0x44, 0x7c, 0x79, 0x2c, 0x2e, 0xf9, 0xea, 0x1f, 0x73, + 0x79, 0xbd, 0x1a, 0xcf, 0xe0, 0x67, 0x1f, 0x82, 0x57, 0xc7, 0x2d, 0x1e, 0x89, 0x87, 0x33, 0xd8, 0x4d, 0xe2, 0x69, + 0x77, 0xb1, 0x46, 0x75, 0x03, 0xe8, 0x22, 0xea, 0xcb, 0xa9, 0xe5, 0xa2, 0xd6, 0xa5, 0x17, 0x5f, 0x5e, 0x16, 0xc7, + 0x2d, 0xe8, 0xab, 0xa5, 0xfb, 0xbd, 0x4a, 0xc3, 0x5b, 0xdd, 0xbe, 0xa4, 0x44, 0xb8, 0xec, 0x29, 0x27, 0x7d, 0xe8, + 0x02, 0xc6, 0x0d, 0xfb, 0x02, 0x67, 0x8a, 0x85, 0x9e, 0x57, 0x0f, 0xc5, 0xd0, 0x02, 0x86, 0x59, 0x40, 0x09, 0x90, + 0x1b, 0x74, 0x1e, 0x96, 0x0d, 0xc4, 0x6e, 0x97, 0x45, 0xdb, 0x00, 0x94, 0x15, 0xab, 0xfd, 0x23, 0xdd, 0xdc, 0x15, + 0x59, 0x68, 0x88, 0x43, 0x13, 0xf8, 0x4b, 0x04, 0xff, 0x0a, 0xc0, 0x8f, 0x5b, 0x12, 0x4d, 0x97, 0xe6, 0xb5, 0x33, + 0xf2, 0x42, 0x88, 0x12, 0x99, 0xe7, 0x19, 0xc7, 0xef, 0x39, 0xfe, 0x78, 0x29, 0xaa, 0x6a, 0x2d, 0x01, 0xd4, 0x57, + 0xd0, 0xa6, 0xda, 0x5a, 0x1d, 0x0c, 0xd2, 0x38, 0xf6, 0xa7, 0x39, 0xf5, 0xf4, 0x0f, 0xa5, 0x30, 0x80, 0xde, 0xb1, + 0xae, 0xa1, 0xa9, 0xbc, 0xa7, 0x53, 0xd0, 0xe3, 0xd6, 0xd5, 0xc7, 0x6b, 0x3f, 0x73, 0x9a, 0xcd, 0xa0, 0x79, 0x35, + 0x46, 0x05, 0x8f, 0x16, 0xa6, 0xba, 0xf1, 0xb0, 0xdd, 0xee, 0x41, 0x92, 0x6a, 0xd3, 0x8f, 0xd9, 0x38, 0xf1, 0x62, + 0x3a, 0xe2, 0x05, 0x87, 0xd3, 0x83, 0x0b, 0xad, 0xdf, 0xb9, 0xdd, 0xc3, 0x8c, 0x4e, 0x2c, 0x17, 0xfe, 0xde, 0x3d, + 0x70, 0xc1, 0x43, 0x2f, 0xe1, 0x51, 0x53, 0x24, 0x43, 0xc3, 0x51, 0x0e, 0x1e, 0xd5, 0x9e, 0x17, 0xc6, 0x40, 0x01, + 0x05, 0xdd, 0xb7, 0xe0, 0x99, 0xc5, 0x23, 0xcc, 0x33, 0xb3, 0x5e, 0x82, 0x16, 0x6b, 0x33, 0x58, 0x57, 0xc1, 0xf6, + 0x51, 0x91, 0x0b, 0x8b, 0x65, 0xb1, 0x86, 0x17, 0x43, 0x95, 0x2e, 0x58, 0x32, 0x9d, 0xf1, 0x73, 0xe1, 0xf9, 0xcf, + 0xe0, 0x0c, 0xc9, 0x10, 0x1b, 0x25, 0x00, 0xcf, 0x50, 0xb5, 0x0f, 0xfc, 0x38, 0x70, 0xa0, 0x13, 0xab, 0x69, 0x1d, + 0x65, 0x74, 0x82, 0x7a, 0x13, 0x96, 0x34, 0xe5, 0xbb, 0x43, 0x43, 0x77, 0x73, 0x1f, 0xc1, 0x53, 0xe1, 0x8a, 0xde, + 0xb0, 0x48, 0xf0, 0xdd, 0x30, 0xaf, 0xcb, 0x61, 0x51, 0xf4, 0x52, 0xee, 0x9c, 0xbf, 0x70, 0xd0, 0x10, 0xff, 0x6a, + 0x5c, 0x62, 0x63, 0x6b, 0xaa, 0xb6, 0x71, 0x17, 0x6d, 0xa9, 0x62, 0xd2, 0xa5, 0xa8, 0xf6, 0x2b, 0x81, 0x8a, 0x2f, + 0x1d, 0x9b, 0xe6, 0xd3, 0xa6, 0x64, 0x3f, 0x4d, 0x41, 0x3e, 0x36, 0x34, 0x45, 0xca, 0x9d, 0x4d, 0xe9, 0x42, 0x70, + 0x16, 0x75, 0x8e, 0x45, 0x7a, 0x5c, 0x86, 0xe5, 0xb9, 0x27, 0xf5, 0x6c, 0x9e, 0x74, 0x42, 0xb5, 0xad, 0x7f, 0x79, + 0x52, 0x67, 0x53, 0x20, 0xff, 0xcb, 0xbb, 0xfe, 0xfc, 0x38, 0x86, 0x01, 0x2f, 0xb5, 0xd2, 0x60, 0x5e, 0x8d, 0x72, + 0xce, 0x87, 0x0e, 0x2a, 0xd4, 0x9e, 0x79, 0x22, 0xf4, 0x6e, 0xe3, 0x82, 0xc1, 0x1d, 0xae, 0x23, 0x6a, 0xf2, 0x04, + 0x33, 0x83, 0x9c, 0x80, 0x5a, 0xee, 0x78, 0xaf, 0x62, 0x33, 0x52, 0x6b, 0xb7, 0xc4, 0x84, 0x88, 0x9d, 0x25, 0xa1, + 0x6d, 0xfd, 0x39, 0x88, 0x59, 0xf0, 0x91, 0xd8, 0xbb, 0x0b, 0x07, 0xad, 0x1f, 0x0d, 0x15, 0x3b, 0x54, 0xf3, 0x5c, + 0x54, 0x8f, 0x36, 0x64, 0xae, 0xc1, 0x4e, 0xe5, 0xed, 0x41, 0x76, 0x1f, 0x54, 0x9b, 0xe3, 0x96, 0x1c, 0xa7, 0x7f, + 0x59, 0x5c, 0x54, 0xb7, 0x82, 0x55, 0x50, 0x00, 0x9a, 0x65, 0xb9, 0x25, 0xe8, 0x8f, 0xd8, 0x72, 0x0b, 0xd5, 0x2c, + 0x40, 0x6c, 0xd2, 0x3e, 0xb2, 0x2d, 0xc9, 0x60, 0x00, 0x4e, 0xae, 0x78, 0x8d, 0x6d, 0xfd, 0xb9, 0x2c, 0xa3, 0xa5, + 0xdb, 0x47, 0xe4, 0xad, 0x10, 0x1b, 0xc6, 0x02, 0x5b, 0xdf, 0x0d, 0x29, 0xf7, 0x59, 0x2c, 0x9b, 0xf4, 0xb4, 0x97, + 0x62, 0x65, 0x46, 0xcb, 0x65, 0x52, 0x9f, 0x0b, 0xab, 0x63, 0x50, 0xcc, 0xec, 0xb8, 0x55, 0xc1, 0x2d, 0x66, 0x26, + 0xf6, 0x87, 0x19, 0x3f, 0xad, 0x66, 0x28, 0xdf, 0x59, 0x7f, 0x0e, 0xc4, 0xc9, 0x2a, 0x00, 0x30, 0x55, 0x00, 0x42, + 0x64, 0x5f, 0x2a, 0x21, 0x8e, 0x4f, 0x52, 0x97, 0xfb, 0xd9, 0x98, 0xf2, 0x15, 0xc4, 0xfa, 0x32, 0x91, 0xb7, 0xa7, + 0xa3, 0xf8, 0x6b, 0xd0, 0x06, 0x75, 0x68, 0x41, 0xcf, 0x2d, 0x06, 0xa0, 0xaa, 0x92, 0x8d, 0x1a, 0x6f, 0x84, 0x40, + 0xf6, 0x89, 0xc5, 0x49, 0x04, 0xb7, 0x4f, 0x05, 0xb7, 0x97, 0x71, 0x38, 0x4b, 0x8c, 0x25, 0x40, 0x2c, 0x6c, 0x6b, + 0x20, 0x21, 0xa7, 0xa1, 0x84, 0x99, 0x64, 0xa2, 0x55, 0x5a, 0x1c, 0xb7, 0x64, 0x6d, 0xc9, 0x8e, 0x65, 0x25, 0x40, + 0x82, 0xd8, 0xa7, 0x15, 0x0e, 0x20, 0xf9, 0xdb, 0xc4, 0x43, 0xc8, 0xae, 0x4b, 0x62, 0x13, 0x67, 0xcc, 0xfa, 0xc7, + 0xb1, 0x7f, 0x45, 0xe3, 0xfe, 0xee, 0x22, 0x5b, 0x2e, 0xdb, 0xc5, 0x71, 0x4b, 0x3e, 0x5a, 0xc7, 0x82, 0x6f, 0xc8, + 0xbb, 0x41, 0xc5, 0x12, 0xc3, 0xc1, 0x4d, 0x48, 0x89, 0xd5, 0xb9, 0x60, 0x9e, 0xea, 0xa0, 0xb0, 0x2d, 0x91, 0x85, + 0x22, 0x2a, 0x95, 0x3a, 0x4d, 0x61, 0x5b, 0x2c, 0x5c, 0x2f, 0xcb, 0x39, 0x9d, 0x42, 0x69, 0xb4, 0x5c, 0x76, 0x0a, + 0xdb, 0x9a, 0xb0, 0x04, 0x9e, 0xb2, 0xe5, 0x52, 0x9c, 0x89, 0x9c, 0xb0, 0xc4, 0x69, 0x03, 0xd9, 0xda, 0xd6, 0xc4, + 0xbf, 0x11, 0x13, 0xd6, 0x6f, 0xfc, 0x1b, 0xa7, 0xa3, 0x5e, 0xb9, 0x25, 0x7e, 0x12, 0xa0, 0xb8, 0x6a, 0x45, 0x7d, + 0xb5, 0xa2, 0x21, 0x9e, 0xc9, 0xd3, 0x5e, 0xc4, 0x09, 0x89, 0xbf, 0x79, 0x45, 0x43, 0xbd, 0xa2, 0xb3, 0x2d, 0x2b, + 0x3a, 0xbb, 0x63, 0x45, 0x03, 0xb5, 0x7a, 0x56, 0x89, 0xbb, 0x74, 0xb9, 0xec, 0xb4, 0x2b, 0xec, 0x1d, 0xb7, 0x42, + 0x76, 0x0d, 0xab, 0x01, 0x9a, 0x1a, 0x67, 0x13, 0xba, 0x99, 0x28, 0xeb, 0x28, 0xa6, 0x9f, 0x85, 0xc9, 0x0a, 0x0b, + 0x59, 0x1d, 0x0b, 0x26, 0x5d, 0x97, 0x81, 0xc9, 0x3f, 0x92, 0xb2, 0x19, 0xe0, 0x21, 0x01, 0x3c, 0x44, 0xfa, 0xae, + 0x50, 0xc7, 0x7e, 0x6f, 0x63, 0xdb, 0xb2, 0x35, 0x59, 0x5f, 0x16, 0x17, 0x20, 0x23, 0xc4, 0xfc, 0xee, 0x45, 0x8b, + 0x50, 0xdb, 0xee, 0x6f, 0xa7, 0x39, 0xc8, 0x21, 0x98, 0xa7, 0x59, 0x68, 0x7b, 0xb2, 0xea, 0x67, 0xa1, 0x6a, 0xc2, + 0x12, 0x95, 0x91, 0xb6, 0x95, 0xd6, 0xaa, 0xf7, 0x26, 0xc5, 0x75, 0x0f, 0x0f, 0x65, 0x8d, 0xa9, 0xcf, 0x39, 0xcd, + 0x12, 0x45, 0xb9, 0xb6, 0xfd, 0x1f, 0x82, 0x0a, 0x37, 0xf0, 0x95, 0x40, 0x2f, 0x80, 0x26, 0x40, 0xa5, 0x73, 0x2b, + 0x9e, 0x2f, 0xc5, 0xd3, 0x4e, 0xa5, 0x6c, 0xde, 0x22, 0x53, 0xef, 0x97, 0x45, 0x60, 0x86, 0xcc, 0x26, 0x34, 0xbc, + 0x10, 0x0c, 0x7a, 0x10, 0x5f, 0x2a, 0xe5, 0x71, 0x45, 0xdc, 0x55, 0x0d, 0xb0, 0xfd, 0xd3, 0xac, 0xfb, 0xe8, 0xe0, + 0xd4, 0xc6, 0x92, 0xc7, 0xa7, 0xa3, 0x91, 0x8d, 0x0a, 0xeb, 0x7e, 0xcd, 0x3a, 0x07, 0x3f, 0xcd, 0xbe, 0x7e, 0xd6, + 0xfe, 0xba, 0x6c, 0x9c, 0x00, 0x11, 0xa9, 0x24, 0x08, 0x2d, 0xaa, 0x0c, 0x78, 0xf5, 0x8c, 0x46, 0x7e, 0xb2, 0x7d, + 0x3a, 0xe7, 0xe6, 0x74, 0xf2, 0x29, 0xa5, 0x21, 0x10, 0x27, 0x5e, 0x2b, 0xbd, 0x88, 0xe9, 0x35, 0xd5, 0x37, 0x34, + 0x6e, 0x18, 0x6c, 0x43, 0x8b, 0x20, 0x9d, 0x25, 0x5c, 0x65, 0x83, 0x28, 0x56, 0x6b, 0x4c, 0xe9, 0x52, 0xcc, 0xc1, + 0x54, 0xe7, 0x6f, 0xa5, 0x9c, 0xab, 0x4b, 0xaf, 0xe2, 0x12, 0xdb, 0x06, 0x00, 0x5b, 0x21, 0x1b, 0x6c, 0x29, 0xf7, + 0xda, 0xb8, 0xbd, 0x0d, 0x36, 0xdc, 0x41, 0x9e, 0x6d, 0x0f, 0x35, 0x9e, 0x84, 0x43, 0xb7, 0x76, 0xa9, 0xc6, 0x56, + 0x7c, 0x7d, 0x12, 0x03, 0x57, 0x19, 0x74, 0x96, 0xd0, 0x3c, 0xdf, 0x8a, 0x80, 0x72, 0x11, 0xb1, 0x5d, 0xd5, 0xb6, + 0xb7, 0xf4, 0x82, 0xdb, 0x18, 0x76, 0x98, 0x00, 0xb8, 0x56, 0x45, 0xa8, 0x1b, 0x17, 0x70, 0xee, 0xe9, 0x3e, 0x03, + 0x55, 0xb5, 0xb7, 0xf5, 0x82, 0x3b, 0x87, 0x07, 0x78, 0xff, 0x51, 0x5b, 0x0d, 0xa5, 0x23, 0xd8, 0xaa, 0x1e, 0x1d, + 0x8d, 0x68, 0x50, 0xba, 0xde, 0x21, 0x16, 0x39, 0x62, 0x31, 0x87, 0x90, 0x9c, 0x88, 0x95, 0xd9, 0xaf, 0xd3, 0x84, + 0xda, 0x48, 0x67, 0xd7, 0x2a, 0x54, 0x29, 0x55, 0x63, 0x33, 0x44, 0xb2, 0xc7, 0x3a, 0x34, 0x6a, 0x94, 0xe5, 0x52, + 0x7b, 0x86, 0x6a, 0xe5, 0xf5, 0x35, 0x4b, 0x85, 0xeb, 0x67, 0xdb, 0x5e, 0xbd, 0xdf, 0x8e, 0x5c, 0x74, 0xbe, 0x3e, + 0xec, 0xb4, 0x0b, 0x1b, 0xdb, 0xd0, 0xdd, 0x7d, 0x37, 0xa4, 0x68, 0xb5, 0x0f, 0xad, 0x66, 0xc9, 0xe7, 0xb4, 0xeb, + 0x76, 0x1e, 0x77, 0x6c, 0x2c, 0xaf, 0x75, 0x40, 0x45, 0xc9, 0x77, 0x02, 0x70, 0x46, 0xff, 0xee, 0xa9, 0xd4, 0x3b, + 0xbf, 0x1f, 0x3c, 0x0f, 0x3b, 0x6d, 0x1b, 0xdb, 0x39, 0x4f, 0xa7, 0x9f, 0x31, 0x85, 0x7d, 0xa0, 0xa6, 0x38, 0xcd, + 0xa9, 0x39, 0x07, 0xa9, 0x39, 0xff, 0xfe, 0x49, 0x48, 0x88, 0xa6, 0x19, 0xcd, 0x73, 0xcb, 0xec, 0x5f, 0x91, 0xd2, + 0x27, 0x78, 0xf3, 0x46, 0x8a, 0xcb, 0x29, 0x17, 0x78, 0x91, 0x37, 0x2e, 0x98, 0x54, 0x25, 0xcb, 0xd6, 0x88, 0x4d, + 0x48, 0x9b, 0x92, 0x87, 0x4a, 0x45, 0xee, 0x93, 0x23, 0x6f, 0xd8, 0x7c, 0x72, 0x60, 0x19, 0xa3, 0x5f, 0x1f, 0xa0, + 0x56, 0x32, 0x61, 0xc9, 0xc5, 0x86, 0x52, 0xff, 0x66, 0x43, 0x29, 0x68, 0x87, 0x25, 0x74, 0xea, 0x36, 0xa0, 0x4f, + 0x63, 0xbd, 0xd2, 0xb1, 0x4c, 0x10, 0x43, 0xe1, 0xea, 0xfc, 0x04, 0xa4, 0xc6, 0x32, 0x88, 0x1e, 0x7e, 0xfb, 0x70, + 0x50, 0xf2, 0x39, 0xc3, 0x95, 0xbd, 0xfc, 0xbe, 0x19, 0x42, 0x69, 0x13, 0xe2, 0x09, 0xf1, 0x67, 0xcd, 0x95, 0xde, + 0x7c, 0x9a, 0xe0, 0x0c, 0x05, 0xee, 0x77, 0x2c, 0xbd, 0xba, 0x55, 0x60, 0x75, 0xed, 0x37, 0x14, 0x2b, 0x1d, 0xab, + 0x5c, 0xff, 0x20, 0x66, 0x93, 0x8a, 0x04, 0xd6, 0xc1, 0x14, 0xca, 0x15, 0x24, 0x97, 0x99, 0x9d, 0x48, 0x2d, 0x4b, + 0x30, 0x7d, 0xb8, 0x95, 0x64, 0x96, 0xd1, 0x8b, 0x38, 0x9d, 0xaf, 0xde, 0xb3, 0xb6, 0xbd, 0x72, 0xc4, 0xc6, 0x91, + 0x71, 0x0e, 0x8e, 0x92, 0x72, 0x11, 0xee, 0x1c, 0xa0, 0xf8, 0x97, 0x7f, 0x76, 0xdd, 0x7f, 0xf9, 0xe7, 0x4f, 0x56, + 0x85, 0xee, 0x8b, 0x4b, 0xcc, 0xab, 0x6e, 0xb7, 0xef, 0xae, 0xcd, 0x23, 0xd5, 0x71, 0xbe, 0xb9, 0xce, 0xda, 0x22, + 0x08, 0x19, 0xb8, 0xba, 0x04, 0x6b, 0x85, 0x72, 0xf7, 0x59, 0xbf, 0x05, 0x30, 0x98, 0xd7, 0x27, 0x21, 0x83, 0x4a, + 0xbf, 0x0b, 0xb4, 0x4b, 0xe4, 0xdd, 0x6b, 0x45, 0x7e, 0x3b, 0x86, 0x3f, 0x35, 0x87, 0xdf, 0x09, 0xbe, 0x72, 0x85, + 0xc4, 0x97, 0x97, 0x65, 0xc2, 0xa3, 0xd9, 0x14, 0xae, 0x53, 0x18, 0xac, 0x95, 0x28, 0xc5, 0xc3, 0x6b, 0xa3, 0xbe, + 0x38, 0xae, 0x49, 0xe2, 0xcb, 0x57, 0x70, 0x87, 0xd2, 0xf1, 0x55, 0xa6, 0xfd, 0xba, 0x77, 0x08, 0x07, 0xe8, 0xa2, + 0x3e, 0x2b, 0xd1, 0xe9, 0x9a, 0x64, 0x80, 0x52, 0xb0, 0x6c, 0x00, 0x4c, 0x1c, 0x5f, 0x2a, 0xc3, 0xf6, 0x54, 0x7a, + 0x7c, 0xbc, 0x55, 0xd2, 0x56, 0x9e, 0xa0, 0x1a, 0xd2, 0xb1, 0xf5, 0x5e, 0xe0, 0x4b, 0x54, 0xa6, 0x95, 0x23, 0x41, + 0x78, 0xd5, 0xc0, 0x64, 0x29, 0xd9, 0xcf, 0xb5, 0x1f, 0x5f, 0xdf, 0x8f, 0xf1, 0x6d, 0x17, 0xa8, 0x4b, 0x6b, 0xf9, + 0x8f, 0x56, 0x09, 0x96, 0xcd, 0xe5, 0x26, 0x7d, 0x60, 0xee, 0x73, 0x9a, 0x5d, 0x44, 0x90, 0x73, 0x95, 0x7d, 0x82, + 0x39, 0xc1, 0x4a, 0x63, 0x2a, 0xfe, 0x32, 0xa2, 0xee, 0xac, 0xfe, 0x07, 0x71, 0x2a, 0x06, 0x09, 0x93, 0x30, 0x94, + 0xb1, 0x08, 0xff, 0x9f, 0x6f, 0xfd, 0x87, 0xe1, 0x5b, 0x77, 0x0f, 0x51, 0x3b, 0x8e, 0xfd, 0xd9, 0x0b, 0xf9, 0x1f, + 0x9b, 0xdd, 0x25, 0x82, 0xdd, 0xfd, 0x06, 0x46, 0x97, 0xfc, 0x63, 0x18, 0x9d, 0x30, 0xc7, 0x35, 0xa7, 0x5b, 0x8b, + 0x6a, 0xdf, 0xba, 0xfe, 0xdc, 0xbf, 0xad, 0xf6, 0x55, 0x7c, 0x79, 0x32, 0xf7, 0x6f, 0xab, 0x45, 0xd8, 0xce, 0x2e, + 0x56, 0xfb, 0x18, 0xd8, 0x6f, 0x5e, 0xdb, 0x9e, 0xfd, 0xe6, 0xeb, 0xaf, 0x6d, 0x7c, 0x99, 0x53, 0x3e, 0x80, 0x42, + 0xb2, 0xbb, 0xd8, 0x59, 0xad, 0x08, 0x1e, 0x1b, 0x98, 0xa2, 0x88, 0xb0, 0x41, 0x7e, 0xa3, 0xf1, 0x9e, 0xe5, 0x17, + 0x69, 0x62, 0x42, 0xf3, 0x16, 0x9c, 0x08, 0x9f, 0x0b, 0x8e, 0xe8, 0x65, 0x0d, 0x1e, 0x51, 0xba, 0x0a, 0x90, 0x28, + 0xac, 0x41, 0x54, 0xdd, 0x4e, 0x74, 0x37, 0xff, 0xaf, 0x6e, 0x60, 0x90, 0x17, 0x8b, 0x44, 0x83, 0xf8, 0xf2, 0x73, + 0xc4, 0x87, 0x1c, 0xac, 0x72, 0x0e, 0x6a, 0xcf, 0xaa, 0x5f, 0xec, 0x2e, 0xa2, 0xbd, 0x3d, 0x36, 0xb0, 0xb1, 0xb8, + 0x12, 0xaa, 0xd8, 0x24, 0x5c, 0x12, 0xf8, 0x93, 0xc1, 0x9f, 0xb4, 0x62, 0xd4, 0x2c, 0x19, 0x65, 0x7e, 0x46, 0xc3, + 0xed, 0x4c, 0xba, 0xbc, 0x4a, 0x49, 0x91, 0x86, 0xcc, 0xf5, 0xce, 0x2f, 0x44, 0x96, 0xd3, 0x84, 0x81, 0x3e, 0xba, + 0x63, 0x7e, 0x30, 0x48, 0xdd, 0xbd, 0x56, 0x7e, 0x6f, 0xc0, 0x44, 0x38, 0x25, 0x49, 0x99, 0x56, 0x01, 0x17, 0x78, + 0xaa, 0x44, 0x14, 0x6c, 0x23, 0xe1, 0xe0, 0x0f, 0x49, 0x5f, 0x64, 0x58, 0xbc, 0x48, 0xb8, 0x13, 0xba, 0x3c, 0x63, + 0x13, 0x07, 0xe1, 0x4e, 0x1b, 0x21, 0xed, 0x6c, 0x08, 0x49, 0x7f, 0x87, 0xe5, 0xaf, 0xfd, 0xd7, 0x4e, 0x28, 0xee, + 0xfc, 0x12, 0x5f, 0x09, 0x82, 0xf3, 0x98, 0x4f, 0x66, 0xa3, 0x11, 0xcd, 0x1c, 0x7d, 0xd6, 0xf0, 0xab, 0x03, 0x38, + 0xce, 0x0c, 0x6f, 0x9f, 0xfa, 0xdc, 0xff, 0x96, 0xd1, 0xb9, 0x93, 0xa2, 0x5e, 0x56, 0xdd, 0x03, 0x19, 0xe2, 0x19, + 0x22, 0xfd, 0x08, 0x72, 0xf0, 0x5f, 0x24, 0x7c, 0xbf, 0xeb, 0xcc, 0xbe, 0x3a, 0xc0, 0x21, 0xdc, 0xae, 0xa1, 0x13, + 0xc8, 0xe5, 0xb5, 0x28, 0x1f, 0x58, 0xc2, 0x8f, 0xe4, 0x89, 0xcf, 0x14, 0x29, 0x4f, 0x65, 0x99, 0x7c, 0x63, 0xf9, + 0x65, 0x87, 0x21, 0xe9, 0x07, 0x0d, 0x22, 0xcf, 0x7f, 0x8a, 0x0b, 0x7d, 0x4f, 0x23, 0x3f, 0x3b, 0x85, 0xb3, 0xe5, + 0x00, 0xe8, 0x15, 0x4f, 0x7d, 0x27, 0x28, 0x3f, 0x1a, 0xe5, 0xb4, 0x7e, 0x6a, 0xb4, 0xc6, 0x58, 0xe4, 0xdf, 0x54, + 0x45, 0x2d, 0x28, 0xba, 0x30, 0x8b, 0x48, 0x63, 0xb7, 0x85, 0x61, 0x0f, 0xf6, 0x36, 0xba, 0x83, 0xf5, 0xd2, 0x35, + 0xe7, 0x99, 0x3f, 0x2d, 0x43, 0x14, 0xa7, 0x7e, 0x96, 0x31, 0x9a, 0x59, 0xce, 0xf3, 0x5f, 0x91, 0xf7, 0x2f, 0xff, + 0xbc, 0x39, 0x54, 0xa1, 0xa2, 0x13, 0x16, 0xe4, 0xb1, 0x34, 0x45, 0xe6, 0x37, 0xb1, 0x03, 0xd9, 0xd0, 0xd6, 0x91, + 0x95, 0xfd, 0xa3, 0x76, 0xbb, 0xad, 0xa2, 0x0f, 0x1d, 0xf9, 0x13, 0xc2, 0x0d, 0xf0, 0x13, 0x1e, 0x44, 0x00, 0x9b, + 0xd8, 0x32, 0x16, 0x7a, 0xd4, 0x9e, 0xde, 0xd8, 0x7d, 0xd8, 0x0e, 0x0a, 0x8a, 0x77, 0x74, 0x4a, 0x7d, 0xfe, 0x59, + 0xe3, 0x67, 0xa2, 0x49, 0x39, 0x7c, 0x47, 0x0f, 0x5d, 0x8d, 0xbb, 0x32, 0xe8, 0xe1, 0xea, 0xa0, 0xef, 0xd9, 0x44, + 0xdc, 0x12, 0xb5, 0x6d, 0x54, 0xe1, 0x14, 0xaf, 0x8d, 0xc9, 0x65, 0x0b, 0xdb, 0x12, 0x18, 0x8f, 0xd2, 0x38, 0xa4, + 0x19, 0xb1, 0xa9, 0x3b, 0x76, 0xad, 0xc7, 0xed, 0x76, 0x1b, 0x37, 0x0f, 0x0e, 0xdb, 0x6d, 0x7c, 0xf8, 0xb0, 0x8d, + 0x9b, 0xf0, 0xc7, 0x75, 0xdd, 0x15, 0x18, 0xee, 0x0a, 0x10, 0x77, 0xda, 0x19, 0x9d, 0x28, 0x00, 0xef, 0x8c, 0x60, + 0x56, 0x7b, 0x02, 0xee, 0xb2, 0x56, 0xfb, 0x5e, 0x4a, 0x36, 0x75, 0x97, 0x82, 0xca, 0x7c, 0x15, 0xae, 0xc9, 0xb4, + 0x8a, 0xcf, 0x52, 0x79, 0xc7, 0xe0, 0x0b, 0x45, 0x08, 0x9e, 0x75, 0x0a, 0x17, 0xa5, 0x8a, 0xd0, 0x2c, 0x64, 0x1d, + 0xc1, 0xb7, 0xd8, 0xb8, 0xcf, 0x12, 0xf8, 0x4c, 0x97, 0x0e, 0xd0, 0x6a, 0x46, 0x95, 0xae, 0xe4, 0xf7, 0x3e, 0x90, + 0x11, 0xf0, 0x4d, 0x04, 0x31, 0x7c, 0x80, 0xb0, 0x7f, 0x9f, 0x06, 0x6a, 0x05, 0xa1, 0x7e, 0x70, 0x9f, 0xfa, 0x1a, + 0xfb, 0xc3, 0x07, 0x22, 0x0f, 0x6a, 0x27, 0x5a, 0x2e, 0x77, 0xfc, 0xe5, 0x72, 0x27, 0xb8, 0xff, 0x0c, 0xe5, 0xf2, + 0xea, 0x03, 0x17, 0x70, 0xc9, 0xa8, 0x04, 0xfa, 0x05, 0x94, 0x7b, 0x11, 0x96, 0x20, 0xc9, 0x27, 0x1f, 0xab, 0x01, + 0xe5, 0x63, 0x50, 0xac, 0x20, 0x25, 0x24, 0x91, 0xb4, 0xcf, 0x97, 0x4b, 0x45, 0xfc, 0x78, 0x46, 0xfc, 0xb2, 0xa8, + 0x63, 0xe3, 0x29, 0x09, 0xca, 0x47, 0x5b, 0x80, 0x3c, 0x55, 0x5c, 0xaa, 0x82, 0x78, 0xee, 0x67, 0x89, 0x09, 0xf0, + 0xeb, 0xd4, 0x52, 0xc3, 0x5a, 0xd3, 0x2c, 0xbd, 0x66, 0x90, 0x67, 0xb3, 0x32, 0xf0, 0x84, 0xc0, 0x1d, 0x63, 0x3d, + 0x33, 0xea, 0x6e, 0x74, 0xf0, 0x5e, 0xf3, 0x59, 0xb8, 0xd0, 0xb2, 0x9c, 0xa0, 0x17, 0xaa, 0xb9, 0x79, 0x33, 0x3d, + 0xad, 0x77, 0xfe, 0xdc, 0x9b, 0xea, 0x87, 0x67, 0x32, 0xa5, 0xc7, 0x9b, 0x94, 0x87, 0x78, 0xde, 0x92, 0xd7, 0x10, + 0x66, 0xb2, 0x35, 0xdf, 0x86, 0x2b, 0x3d, 0x25, 0x8f, 0x7b, 0xf7, 0xf2, 0x8c, 0xfa, 0x59, 0x10, 0xbd, 0xf5, 0x33, + 0x7f, 0x92, 0xf7, 0x2e, 0xf4, 0x85, 0x61, 0x9a, 0x02, 0x2e, 0x46, 0x22, 0xa9, 0x2a, 0x09, 0x6e, 0x6d, 0x1c, 0x22, + 0x5c, 0xbd, 0x97, 0x10, 0x48, 0x97, 0xba, 0x8d, 0x67, 0xe6, 0x2b, 0x58, 0x67, 0x1b, 0x4f, 0x10, 0x96, 0xb9, 0x4a, + 0x6f, 0xff, 0xc8, 0x2c, 0x25, 0x0c, 0x69, 0x35, 0xde, 0x85, 0x5b, 0x7d, 0x50, 0x4f, 0xe7, 0x2d, 0xbd, 0x5f, 0xc9, + 0x5b, 0xda, 0x80, 0x46, 0x2b, 0xa3, 0xf9, 0x34, 0x4d, 0x72, 0x6a, 0xe3, 0xf7, 0xd0, 0x4e, 0xde, 0xfa, 0x6c, 0x36, + 0x5c, 0xa3, 0xb9, 0xb2, 0xa9, 0x78, 0x23, 0xdb, 0x41, 0xfc, 0xe8, 0xfd, 0xf7, 0x65, 0xca, 0x80, 0x0e, 0x25, 0x89, + 0x9c, 0x77, 0x46, 0xb7, 0xa4, 0xe5, 0x26, 0xf4, 0x93, 0x69, 0xb9, 0xf1, 0xbd, 0xd2, 0x72, 0x13, 0xfa, 0x47, 0xa7, + 0xe5, 0x32, 0x6a, 0xa4, 0xe5, 0x82, 0x9c, 0xfb, 0xfa, 0x5e, 0xd9, 0x9d, 0x3a, 0xe9, 0x2e, 0x9d, 0xe7, 0xa4, 0xa3, + 0xc2, 0x2d, 0x71, 0x3a, 0x86, 0xd4, 0xce, 0x7f, 0x7c, 0xa6, 0x66, 0x9c, 0x8e, 0xcd, 0x3c, 0x4d, 0xf8, 0x06, 0x0a, + 0x90, 0x1d, 0xce, 0xc8, 0xc2, 0xfe, 0xe9, 0xa6, 0xf3, 0xe4, 0xbc, 0xd3, 0xdb, 0xef, 0x4c, 0x6c, 0xcf, 0x06, 0xa7, + 0xa3, 0x28, 0x68, 0xf7, 0xf6, 0xf7, 0xa1, 0x60, 0x6e, 0x14, 0x74, 0xa1, 0x80, 0x19, 0x05, 0x87, 0x50, 0x10, 0x18, + 0x05, 0x0f, 0xa1, 0x20, 0x34, 0x0a, 0x1e, 0x41, 0xc1, 0xb5, 0x5d, 0x9c, 0xb3, 0x32, 0xf7, 0xf8, 0x11, 0x12, 0x97, + 0x25, 0xee, 0x64, 0xf5, 0x83, 0xe2, 0x11, 0xd1, 0x55, 0x1e, 0x95, 0x97, 0x4c, 0x34, 0x0f, 0xf4, 0x9d, 0x88, 0x97, + 0x5f, 0x5c, 0x02, 0x6b, 0x85, 0x3b, 0x5f, 0x30, 0x84, 0x3f, 0x65, 0xcd, 0x7d, 0xfd, 0xda, 0xf6, 0xca, 0x04, 0xdd, + 0x36, 0xee, 0xea, 0x14, 0x5d, 0xcf, 0x46, 0x82, 0x2f, 0xc9, 0x17, 0x87, 0x8d, 0x50, 0x75, 0x0b, 0xd7, 0x0d, 0x56, + 0x77, 0x7d, 0xee, 0x23, 0x3c, 0xd1, 0x0a, 0x10, 0x75, 0xe0, 0x5b, 0x0f, 0xef, 0xd9, 0x84, 0xea, 0xfd, 0xa2, 0x07, + 0xb0, 0x44, 0x12, 0x73, 0x2f, 0xaa, 0x14, 0xa3, 0xb7, 0xf8, 0xa2, 0xba, 0x5e, 0xf6, 0x3d, 0x91, 0xd7, 0xf5, 0x65, + 0x58, 0x46, 0xd4, 0xa6, 0x98, 0xfb, 0x63, 0x0f, 0xb2, 0x35, 0x21, 0x39, 0xc5, 0xbb, 0x20, 0x84, 0xb4, 0x07, 0x33, + 0xef, 0x2d, 0x9e, 0x47, 0x34, 0xf1, 0x26, 0x45, 0xaf, 0x5c, 0x7f, 0x99, 0x3d, 0xfa, 0xbe, 0xbc, 0x93, 0x5c, 0xd0, + 0x44, 0xf5, 0x56, 0x42, 0xd9, 0x2c, 0x69, 0x67, 0x4b, 0x7a, 0xa1, 0xa1, 0xec, 0x8c, 0xe2, 0x74, 0xde, 0x04, 0x71, + 0xbf, 0x31, 0xe5, 0x10, 0xe6, 0x56, 0xa6, 0x1c, 0xbe, 0x04, 0x58, 0xcb, 0xa7, 0xf7, 0xfe, 0xb8, 0xfc, 0xfd, 0x8a, + 0xe6, 0xb9, 0x3f, 0x56, 0x35, 0xb7, 0xa7, 0x18, 0x0a, 0x10, 0xcd, 0xf4, 0x42, 0x0d, 0x04, 0xe4, 0x01, 0x02, 0x42, + 0x20, 0x76, 0xac, 0xd2, 0x02, 0x61, 0xe6, 0xf5, 0x8c, 0x42, 0x81, 0xaa, 0x7a, 0x11, 0xf7, 0xc7, 0x55, 0xc1, 0xf1, + 0x34, 0xa3, 0x2a, 0x57, 0x11, 0xb0, 0x58, 0x1c, 0xb7, 0xa0, 0x40, 0xbe, 0xde, 0x92, 0x39, 0xa8, 0xb9, 0xcb, 0xf6, + 0xfc, 0x41, 0x4b, 0x67, 0x0e, 0x9a, 0x87, 0x60, 0xca, 0x13, 0x30, 0xeb, 0xc9, 0x7f, 0x5f, 0x76, 0x02, 0xf8, 0x4f, + 0x9d, 0xf1, 0xf8, 0x72, 0x34, 0x1a, 0xdd, 0x99, 0x49, 0xf8, 0x65, 0x38, 0xa2, 0x5d, 0x7a, 0xd8, 0x83, 0x03, 0x12, + 0x4d, 0x95, 0xf3, 0xd6, 0x29, 0x04, 0xee, 0x16, 0xf7, 0xab, 0x0c, 0xe9, 0x71, 0x3c, 0x5a, 0xdc, 0x3f, 0xab, 0xb0, + 0x98, 0x66, 0x74, 0x31, 0xf1, 0xb3, 0x31, 0x4b, 0xbc, 0x76, 0xe1, 0x5e, 0x2f, 0x14, 0xa8, 0x47, 0x47, 0x47, 0x85, + 0x1b, 0xea, 0xa7, 0x76, 0x18, 0x16, 0x6e, 0xb0, 0x28, 0xa7, 0xd1, 0x6e, 0x8f, 0x46, 0x85, 0xcb, 0x74, 0xc1, 0x7e, + 0x37, 0x08, 0xf7, 0xbb, 0x85, 0x3b, 0x37, 0x6a, 0x14, 0x2e, 0x55, 0x4f, 0x19, 0x0d, 0x6b, 0xa7, 0x2c, 0x1e, 0xb5, + 0xdb, 0x85, 0x2b, 0x09, 0x6d, 0x01, 0x31, 0x39, 0xf9, 0xd3, 0xf3, 0x67, 0x1c, 0x0c, 0xa6, 0xa2, 0x17, 0x73, 0xe7, + 0xfc, 0x56, 0xdd, 0x60, 0x29, 0x3f, 0xf9, 0x58, 0xa0, 0x21, 0xfe, 0xda, 0x4c, 0xd2, 0x03, 0x62, 0x16, 0xc9, 0x79, + 0xb1, 0xce, 0xe1, 0xab, 0xbd, 0x06, 0xca, 0x12, 0xaf, 0xbf, 0x26, 0x71, 0x95, 0xbb, 0x07, 0x7c, 0x0c, 0x6a, 0xca, + 0x8b, 0xd6, 0xf3, 0x6d, 0xd2, 0x23, 0xfb, 0xb4, 0xf4, 0xb8, 0xba, 0x8f, 0xf0, 0xc8, 0xfe, 0x70, 0xe1, 0x91, 0x9b, + 0xc2, 0x43, 0xb2, 0x8e, 0x39, 0x27, 0x76, 0x10, 0xd1, 0xe0, 0xe3, 0x55, 0x7a, 0xd3, 0x84, 0x2d, 0x91, 0xd9, 0x42, + 0xac, 0x5c, 0xff, 0xd6, 0x43, 0x03, 0xba, 0x33, 0xe3, 0x7b, 0x91, 0x42, 0xc7, 0x7f, 0x93, 0x10, 0xfb, 0x8d, 0x0e, + 0xec, 0xc9, 0x92, 0xd1, 0x88, 0xd8, 0x6f, 0x46, 0x23, 0x5b, 0xdf, 0xc3, 0xe3, 0x73, 0x2a, 0x6a, 0xbd, 0xae, 0x95, + 0x88, 0x5a, 0x60, 0xe8, 0x57, 0x65, 0x66, 0x81, 0x4a, 0xf1, 0x33, 0xd3, 0xf9, 0xd4, 0x9b, 0x90, 0xe5, 0xb0, 0xd5, + 0xe0, 0x33, 0x96, 0xf5, 0xef, 0x00, 0xe4, 0xb5, 0x8f, 0x36, 0x95, 0x00, 0x6f, 0xf8, 0xd2, 0xd4, 0xea, 0x25, 0x74, + 0x63, 0xaa, 0x55, 0xfc, 0x27, 0xb7, 0x2f, 0x42, 0x67, 0xce, 0x51, 0xc1, 0xf2, 0x37, 0xc9, 0xca, 0x05, 0x13, 0x12, + 0x46, 0x42, 0xcc, 0x69, 0x15, 0x3c, 0x1d, 0x8f, 0x63, 0x71, 0x6e, 0xa5, 0x66, 0x70, 0xcb, 0xe6, 0x83, 0xda, 0x7c, + 0x3d, 0xb3, 0xa1, 0xfa, 0x92, 0x87, 0xf8, 0xb4, 0xb1, 0x3c, 0x98, 0x7c, 0xad, 0xbe, 0x71, 0x2b, 0x62, 0x82, 0x0b, + 0xc5, 0xe3, 0x17, 0xf2, 0x38, 0x2b, 0xc7, 0x2c, 0x94, 0xcd, 0x59, 0x58, 0x14, 0xea, 0x22, 0x80, 0x90, 0xe5, 0x53, + 0xd0, 0x9e, 0x64, 0x4b, 0xfa, 0x29, 0x16, 0x9e, 0xcf, 0x8d, 0x3c, 0xba, 0xda, 0x72, 0x15, 0xda, 0x4e, 0x93, 0x89, + 0x49, 0x73, 0x5e, 0xd8, 0xca, 0x64, 0xd3, 0x48, 0xb4, 0x2d, 0x89, 0x4f, 0x99, 0xe1, 0x67, 0xcc, 0x10, 0x92, 0x8c, + 0xca, 0x05, 0xd1, 0xaf, 0x74, 0x41, 0x61, 0x5a, 0x59, 0xe2, 0x8d, 0xc4, 0x96, 0xc8, 0x4a, 0xcb, 0xa7, 0x7e, 0xa2, + 0x8d, 0x39, 0xc9, 0x0f, 0x76, 0x17, 0xd5, 0xca, 0x17, 0xb6, 0x06, 0x5b, 0x12, 0x6f, 0xff, 0xb8, 0x05, 0x0d, 0xfa, + 0x56, 0x0d, 0xf4, 0x64, 0x2d, 0x99, 0xed, 0xee, 0x14, 0xef, 0x8f, 0x97, 0x6e, 0x3e, 0xc7, 0x6e, 0x3e, 0xb7, 0xbe, + 0x5a, 0x34, 0xe7, 0xf4, 0xea, 0x23, 0xe3, 0x4d, 0xee, 0x4f, 0x9b, 0xe0, 0x3d, 0x15, 0x49, 0x28, 0x8a, 0x3d, 0x0b, + 0x1d, 0x5d, 0x9a, 0x7e, 0xbd, 0x59, 0x0e, 0x99, 0xe0, 0xc2, 0x8c, 0xf2, 0x92, 0x34, 0xa1, 0xbd, 0xfa, 0x49, 0x41, + 0x33, 0x99, 0x59, 0x63, 0x6b, 0xb8, 0x48, 0x21, 0x73, 0x9c, 0xdf, 0x7a, 0x6d, 0xc5, 0xd6, 0xdb, 0x3a, 0x53, 0xb9, + 0xbd, 0xb1, 0xbe, 0xa7, 0x90, 0xdb, 0x10, 0xd2, 0x2b, 0x5b, 0x4f, 0xb5, 0xde, 0x96, 0x4a, 0xfe, 0xa9, 0x73, 0x73, + 0x90, 0xba, 0xa2, 0xff, 0x37, 0x0e, 0x1c, 0xae, 0x16, 0x8b, 0x73, 0x73, 0xf7, 0x81, 0xcc, 0xf3, 0x47, 0x9c, 0x66, + 0xf8, 0x3e, 0x35, 0xaf, 0xc4, 0x15, 0x17, 0x0b, 0x10, 0x33, 0x5e, 0xe7, 0xa8, 0x9e, 0xf5, 0x7d, 0x77, 0xf7, 0x77, + 0x4f, 0xbf, 0x50, 0x38, 0xd2, 0x57, 0xbe, 0xda, 0x76, 0x0f, 0x36, 0x42, 0xec, 0xdf, 0x7a, 0x2c, 0x11, 0x32, 0xef, + 0x0a, 0x92, 0x42, 0x7a, 0xd3, 0x54, 0x1d, 0x00, 0xcd, 0x68, 0x2c, 0xbe, 0xf2, 0xae, 0x96, 0x62, 0xff, 0xe1, 0xf4, + 0x46, 0xaf, 0x46, 0x67, 0xe5, 0x60, 0xe7, 0x1f, 0x7a, 0x7e, 0x73, 0xfb, 0x81, 0xd1, 0xfa, 0x19, 0xc4, 0xc3, 0xe9, + 0x4d, 0x4f, 0x0a, 0xda, 0x66, 0x26, 0xa1, 0x6a, 0x4f, 0x6f, 0xcc, 0x13, 0xac, 0x55, 0x47, 0x96, 0xbb, 0x9f, 0x5b, + 0xd4, 0xcf, 0x69, 0x0f, 0x3e, 0x6a, 0xc5, 0x02, 0x3f, 0x56, 0xc2, 0x7c, 0xc2, 0xc2, 0x30, 0xa6, 0x3d, 0x2d, 0xaf, + 0xad, 0xce, 0x43, 0x38, 0x00, 0x6a, 0x2e, 0x59, 0x7d, 0x55, 0x0c, 0xe4, 0x95, 0x78, 0xf2, 0xaf, 0xf2, 0x34, 0x86, + 0x4f, 0x4a, 0x6e, 0x44, 0xa7, 0x3a, 0x19, 0xd9, 0xae, 0x90, 0x27, 0x7e, 0xd7, 0xe7, 0x72, 0xd8, 0xfe, 0x53, 0x4f, + 0x2c, 0x78, 0xbb, 0xc7, 0xd3, 0xa9, 0xd7, 0xdc, 0xaf, 0x4f, 0x04, 0x5e, 0x95, 0x53, 0xc0, 0x1b, 0xa6, 0x85, 0x41, + 0x5a, 0x49, 0x3e, 0x6d, 0xb9, 0x1d, 0x55, 0x26, 0x3a, 0x00, 0x23, 0xb4, 0x2c, 0x2a, 0xea, 0x93, 0xf9, 0xc7, 0xec, + 0x96, 0xc7, 0x9b, 0x77, 0xcb, 0x63, 0xbd, 0x5b, 0xee, 0xa6, 0xd8, 0x2f, 0x47, 0x1d, 0xf8, 0xaf, 0x57, 0x4d, 0xc8, + 0x6b, 0x5b, 0xfb, 0xd3, 0x1b, 0x0b, 0xf4, 0xb4, 0x66, 0x77, 0x7a, 0x23, 0xcf, 0xef, 0x42, 0x8e, 0x5c, 0x1b, 0x4e, + 0xb4, 0xe2, 0xb6, 0x05, 0x85, 0xf0, 0x7f, 0xbb, 0xf6, 0xaa, 0x73, 0x00, 0xef, 0xa0, 0xd5, 0xe1, 0xfa, 0xbb, 0xee, + 0xdd, 0x9b, 0xd6, 0x4b, 0x52, 0xee, 0x78, 0x9a, 0x1b, 0x23, 0x97, 0xfb, 0x57, 0x57, 0x34, 0xf4, 0x46, 0x69, 0x30, + 0xcb, 0xff, 0x49, 0xc1, 0xaf, 0x90, 0x78, 0xe7, 0x96, 0x5e, 0xe9, 0x47, 0x37, 0x95, 0xa7, 0x89, 0x75, 0x0f, 0x8b, + 0x72, 0x9d, 0xbc, 0x3c, 0xf0, 0x63, 0xea, 0x74, 0xdd, 0x83, 0x0d, 0x9b, 0xe0, 0xdf, 0x64, 0x6d, 0x36, 0x4e, 0xe6, + 0xf7, 0x22, 0xe3, 0x4e, 0x24, 0x7c, 0x16, 0x0e, 0xcc, 0x35, 0x6c, 0x1f, 0x6d, 0x06, 0xf7, 0x5c, 0x8f, 0x34, 0xd4, + 0x42, 0x41, 0xc9, 0x9d, 0x90, 0x8e, 0xfc, 0x59, 0xcc, 0xef, 0xee, 0x75, 0x1b, 0x65, 0xac, 0xf5, 0x7a, 0x07, 0x43, + 0xaf, 0xea, 0xde, 0x93, 0x4b, 0x7f, 0xf9, 0xf8, 0x00, 0xfe, 0x93, 0xe7, 0x6c, 0xae, 0x2a, 0x5d, 0x5d, 0x5a, 0xbd, + 0xa0, 0xab, 0x5f, 0xd7, 0x94, 0x71, 0x29, 0xc2, 0x85, 0x3e, 0x7e, 0xdf, 0xda, 0xa0, 0x55, 0xde, 0xab, 0xba, 0xd2, + 0xb2, 0x3e, 0xab, 0xf6, 0xe7, 0x75, 0x7e, 0xcf, 0xba, 0x81, 0xd4, 0x5c, 0xeb, 0x75, 0xd5, 0x57, 0xee, 0xd7, 0x2a, + 0x6b, 0x8c, 0x8b, 0xfa, 0xd7, 0xe4, 0xaa, 0x34, 0x51, 0x64, 0xd6, 0x2b, 0x58, 0x29, 0xd7, 0xd2, 0x4a, 0x49, 0x29, + 0xb9, 0x3c, 0x1e, 0xdc, 0x4c, 0x62, 0xeb, 0x5a, 0x5e, 0xc5, 0x43, 0xec, 0x8e, 0xdb, 0xb6, 0x2d, 0xe1, 0xa4, 0x83, + 0x2f, 0x82, 0xd9, 0x1f, 0xde, 0x7f, 0xdd, 0x3c, 0xb2, 0x07, 0xa0, 0x69, 0x5d, 0x8f, 0x85, 0x66, 0xf7, 0xd2, 0xbf, + 0xa5, 0xd9, 0x45, 0x57, 0xb9, 0xe0, 0x65, 0x6a, 0xba, 0x28, 0xb3, 0xba, 0xb6, 0x75, 0x33, 0x89, 0x93, 0x9c, 0xd8, + 0x11, 0xe7, 0x53, 0xaf, 0xd5, 0x9a, 0xcf, 0xe7, 0xee, 0x7c, 0xdf, 0x4d, 0xb3, 0x71, 0xab, 0xdb, 0x6e, 0xb7, 0xe1, + 0xe3, 0x22, 0xb6, 0x75, 0xcd, 0xe8, 0xfc, 0x49, 0x7a, 0x43, 0xec, 0xb6, 0xd5, 0xb6, 0x3a, 0xdd, 0x23, 0xab, 0xd3, + 0x3d, 0x70, 0x1f, 0x1e, 0xd9, 0xfd, 0x2f, 0x2c, 0xeb, 0x38, 0xa4, 0xa3, 0x1c, 0x7e, 0x58, 0xd6, 0xb1, 0x50, 0xbc, + 0xe4, 0x6f, 0xcb, 0x72, 0x83, 0x38, 0x6f, 0x76, 0xac, 0x85, 0x7a, 0xb4, 0x2c, 0xb8, 0xb0, 0xc8, 0xb3, 0xbe, 0x1c, + 0x75, 0x47, 0x07, 0xa3, 0xc7, 0x3d, 0x55, 0x5c, 0x7c, 0x51, 0xab, 0x8e, 0xe5, 0xbf, 0x5d, 0xa3, 0x59, 0xce, 0xb3, + 0xf4, 0x23, 0x55, 0xae, 0x7d, 0x0b, 0x44, 0xcf, 0xc6, 0xa6, 0xdd, 0xf5, 0x91, 0x3a, 0x47, 0x57, 0xc1, 0xa8, 0x5b, + 0x55, 0x17, 0x30, 0xb6, 0x4a, 0x20, 0x8f, 0x5b, 0x1a, 0xf4, 0x63, 0x13, 0x4d, 0x9d, 0xe6, 0x26, 0x44, 0x75, 0x6c, + 0x35, 0xc7, 0xb1, 0x9e, 0xdf, 0x31, 0x9c, 0x8f, 0xd7, 0xba, 0xaa, 0x80, 0xc0, 0xb6, 0x42, 0x62, 0xbf, 0xea, 0x74, + 0x8f, 0x70, 0xa7, 0xf3, 0xd0, 0x7d, 0x78, 0x14, 0xb4, 0xf1, 0x81, 0x7b, 0xd0, 0xdc, 0x77, 0x1f, 0xe2, 0xa3, 0xe6, + 0x11, 0x3e, 0x7a, 0x7e, 0x14, 0x34, 0x0f, 0xdc, 0x03, 0xdc, 0x6e, 0x1e, 0x41, 0x61, 0xf3, 0xa8, 0x79, 0x74, 0xdd, + 0x3c, 0x38, 0x0a, 0xda, 0xa2, 0xb4, 0xeb, 0x1e, 0x1e, 0x36, 0x3b, 0x6d, 0xf7, 0xf0, 0x10, 0x1f, 0xba, 0x0f, 0x1f, + 0x36, 0x3b, 0xfb, 0xee, 0xc3, 0x87, 0x2f, 0x0f, 0x8f, 0xdc, 0x7d, 0x78, 0xb7, 0xbf, 0x1f, 0xec, 0xbb, 0x9d, 0x4e, + 0x13, 0xfe, 0xe0, 0x23, 0xb7, 0x2b, 0x7f, 0x74, 0x3a, 0xee, 0x7e, 0x07, 0xb7, 0xe3, 0xc3, 0xae, 0xfb, 0xf0, 0x31, + 0x16, 0x7f, 0x45, 0x35, 0x2c, 0xfe, 0x40, 0x37, 0xf8, 0xb1, 0xdb, 0x7d, 0x28, 0x7f, 0x89, 0x0e, 0xaf, 0x0f, 0x8e, + 0x7e, 0xb4, 0x5b, 0x5b, 0xe7, 0xd0, 0x91, 0x73, 0x38, 0x3a, 0x74, 0xf7, 0xf7, 0xf1, 0x41, 0xc7, 0x3d, 0xda, 0x8f, + 0x9a, 0x07, 0x5d, 0xf7, 0xe1, 0xa3, 0xa0, 0xd9, 0x71, 0x1f, 0x3d, 0xc2, 0xed, 0xe6, 0xbe, 0xdb, 0xc5, 0x1d, 0xf7, + 0x60, 0x5f, 0xfc, 0xd8, 0x77, 0xbb, 0xd7, 0x8f, 0x1e, 0xbb, 0x0f, 0x0f, 0xa3, 0x87, 0xee, 0xc1, 0xb7, 0x07, 0x47, + 0x6e, 0x77, 0x3f, 0xda, 0x7f, 0xe8, 0x76, 0x1f, 0x5d, 0x3f, 0x74, 0x0f, 0xa2, 0x66, 0xf7, 0xe1, 0x9d, 0x2d, 0x3b, + 0x5d, 0x17, 0x70, 0x24, 0x5e, 0xc3, 0x0b, 0xac, 0x5e, 0xc0, 0xff, 0x91, 0x68, 0xfb, 0x6f, 0xd8, 0x4d, 0xbe, 0xde, + 0xf4, 0xb1, 0x7b, 0xf4, 0x28, 0x90, 0xd5, 0xa1, 0xa0, 0xa9, 0x6b, 0x40, 0x93, 0xeb, 0xa6, 0x1c, 0x56, 0x74, 0xd7, + 0xd4, 0x1d, 0xe9, 0xff, 0xd5, 0x60, 0xd7, 0x4d, 0x18, 0x58, 0x8e, 0xfb, 0xef, 0xda, 0x4f, 0xb9, 0xe4, 0xc7, 0xad, + 0xb1, 0x24, 0xfd, 0x71, 0xff, 0x0b, 0xf9, 0xe5, 0xa0, 0x2f, 0x2e, 0xb1, 0xbf, 0xcd, 0xf1, 0x11, 0x7f, 0xda, 0xf1, + 0x11, 0xd1, 0xfb, 0x78, 0x3e, 0xe2, 0x3f, 0xdc, 0xf3, 0xe1, 0xaf, 0xba, 0xcd, 0x6f, 0xf8, 0x9a, 0x83, 0x63, 0xd5, + 0x2a, 0x7e, 0xc1, 0x9d, 0xf3, 0x14, 0xbe, 0x52, 0x5d, 0xf4, 0x6e, 0x38, 0x89, 0xa8, 0xe9, 0x07, 0x4a, 0x81, 0xc5, + 0xde, 0x70, 0xc9, 0x63, 0x83, 0x6d, 0x08, 0x09, 0x3f, 0x8d, 0x90, 0xef, 0xee, 0x83, 0x8f, 0xf0, 0x0f, 0xc7, 0x47, + 0x60, 0xe2, 0xa3, 0xe6, 0xc9, 0x17, 0x9e, 0x06, 0xe1, 0x29, 0x38, 0x13, 0xcf, 0x0e, 0x5c, 0xd0, 0xd1, 0xb0, 0x5b, + 0xf4, 0x5a, 0x44, 0xee, 0x64, 0x70, 0xfd, 0xf9, 0xe7, 0x04, 0x1d, 0xe4, 0x6d, 0x3c, 0x44, 0x1f, 0x8b, 0x98, 0x0a, + 0xa9, 0xa3, 0x1e, 0x4a, 0xa1, 0xd4, 0x75, 0xdb, 0x6e, 0xbb, 0x74, 0xe9, 0xc0, 0x0d, 0x4c, 0x64, 0x91, 0x72, 0xdf, + 0xdb, 0xe9, 0xe0, 0x38, 0x1d, 0xc3, 0xbd, 0x4c, 0xe2, 0x4b, 0x75, 0x70, 0xe2, 0x21, 0x90, 0x1f, 0x09, 0x84, 0xf4, + 0x09, 0xe5, 0xe8, 0xf1, 0xb3, 0x8f, 0x7f, 0x83, 0x20, 0xa6, 0x8e, 0x49, 0x4c, 0xc0, 0xdb, 0xf1, 0x8a, 0x86, 0xcc, + 0x77, 0x6c, 0x67, 0x9a, 0xd1, 0x11, 0xcd, 0xf2, 0x66, 0xed, 0x6a, 0x20, 0x71, 0x2b, 0x10, 0xb2, 0x15, 0x84, 0xa3, + 0x0c, 0xbe, 0xbc, 0x44, 0xce, 0x95, 0xbf, 0xd1, 0x56, 0x06, 0x98, 0x5d, 0x60, 0x5d, 0x92, 0x81, 0xac, 0xad, 0x94, + 0x36, 0x5b, 0x6a, 0x6d, 0x1d, 0xb7, 0x7b, 0x88, 0x2c, 0x51, 0x0c, 0xdf, 0xb4, 0xf9, 0xc1, 0x69, 0xee, 0xb7, 0xff, + 0x84, 0x8c, 0x66, 0x65, 0x47, 0x43, 0xe5, 0x6e, 0xcb, 0xcb, 0x2f, 0x1f, 0xae, 0x84, 0x5d, 0x6d, 0x49, 0x11, 0x5f, + 0xca, 0xb9, 0xdb, 0xa8, 0x97, 0xab, 0xa4, 0x39, 0x79, 0xfb, 0xe0, 0x88, 0x8d, 0x1d, 0xe3, 0x66, 0x8b, 0x5c, 0x7e, + 0x33, 0x07, 0x2e, 0xc6, 0x47, 0xa8, 0xa8, 0xaa, 0xe4, 0x68, 0x21, 0xa2, 0x2d, 0x2c, 0xb1, 0xf2, 0xe5, 0xd2, 0x11, + 0x2e, 0x72, 0x62, 0xe0, 0x14, 0x9e, 0x51, 0x0d, 0xc9, 0x39, 0x2e, 0x01, 0x12, 0x08, 0x26, 0xb9, 0xfc, 0xb7, 0x2a, + 0xd6, 0x3f, 0x94, 0xe3, 0xcb, 0x8d, 0xfd, 0x64, 0x0c, 0x54, 0xe8, 0x27, 0xe3, 0x35, 0xb7, 0x9a, 0x0c, 0x18, 0xad, + 0x94, 0x56, 0x5d, 0x55, 0xee, 0xb3, 0xfc, 0xc9, 0xed, 0x7b, 0x75, 0xb9, 0xb6, 0x0d, 0xde, 0x69, 0x11, 0xdf, 0xa8, + 0x3e, 0x04, 0xd4, 0x20, 0x0f, 0x8e, 0x27, 0x94, 0xfb, 0xf2, 0x5c, 0x1c, 0xe8, 0x13, 0x90, 0xcb, 0x62, 0x29, 0x6b, + 0x54, 0x05, 0xf5, 0x89, 0xbc, 0x37, 0x40, 0x8a, 0x7a, 0x6c, 0xa9, 0x5b, 0xe9, 0x9a, 0x62, 0x69, 0x48, 0x07, 0x4b, + 0x7f, 0x4c, 0xe0, 0x8b, 0x93, 0xcf, 0x24, 0x49, 0xed, 0xfe, 0x83, 0x32, 0xd7, 0x65, 0xdb, 0x22, 0xc4, 0x2c, 0xf9, + 0x78, 0x9e, 0xd1, 0xf8, 0x9f, 0xc8, 0x03, 0x16, 0xa4, 0xc9, 0x83, 0xa1, 0x8d, 0x7a, 0xdc, 0x8d, 0x32, 0x3a, 0x22, + 0x0f, 0x40, 0xc6, 0x7b, 0xc2, 0xfa, 0x00, 0x46, 0xd8, 0xb8, 0x99, 0xc4, 0x58, 0x68, 0x4c, 0xf7, 0x50, 0x88, 0x24, + 0xb8, 0x76, 0xf7, 0xd0, 0xb6, 0xa4, 0x4d, 0x2c, 0x7e, 0xf7, 0xa5, 0x38, 0x15, 0x4a, 0x80, 0xd5, 0xe9, 0xba, 0x87, + 0x51, 0xd7, 0x7d, 0x7c, 0xfd, 0xc8, 0x3d, 0x8a, 0x3a, 0x8f, 0xae, 0x9b, 0xf0, 0x6f, 0xd7, 0x7d, 0x1c, 0x37, 0xbb, + 0xee, 0x63, 0xf8, 0xff, 0xdb, 0x03, 0xf7, 0x30, 0x6a, 0x76, 0xdc, 0xa3, 0xeb, 0x7d, 0x77, 0xff, 0x65, 0xa7, 0xeb, + 0xee, 0x5b, 0x1d, 0x4b, 0xb6, 0x03, 0x76, 0x2d, 0xb9, 0xf3, 0x83, 0x95, 0x0d, 0xb1, 0x21, 0x18, 0x27, 0xcf, 0xf6, + 0xd9, 0x58, 0x1c, 0xc7, 0x36, 0xf7, 0xa7, 0x72, 0xd6, 0x3d, 0xf5, 0x33, 0xf8, 0x88, 0x6a, 0x7d, 0xef, 0xd6, 0xde, + 0xe1, 0x1a, 0xbf, 0xd8, 0x30, 0xc4, 0x54, 0x44, 0xc0, 0xcd, 0x6b, 0xdd, 0xa8, 0xb8, 0x2e, 0x4f, 0x7e, 0x76, 0x4a, + 0x45, 0xc1, 0xca, 0xec, 0x22, 0x83, 0xac, 0x65, 0x0d, 0x48, 0x00, 0x12, 0x34, 0xb8, 0x9a, 0x3f, 0x5a, 0xd1, 0x79, + 0x06, 0x57, 0x21, 0x68, 0x5e, 0xc2, 0xc4, 0xc7, 0xff, 0x04, 0x0c, 0x2f, 0xc2, 0x62, 0x15, 0x3c, 0x38, 0x81, 0x98, + 0xa5, 0xc6, 0xc5, 0x77, 0xb4, 0xca, 0x01, 0x08, 0x19, 0x5c, 0x55, 0x58, 0x14, 0x7a, 0x66, 0x35, 0x2f, 0x6e, 0x85, + 0x44, 0xc1, 0x4e, 0x68, 0x3e, 0xb0, 0xa1, 0xc8, 0xf6, 0x6c, 0xe1, 0x01, 0xb4, 0xcb, 0xef, 0xcc, 0x96, 0x74, 0x5f, + 0x15, 0x60, 0x71, 0x0f, 0x05, 0x6c, 0x6a, 0x40, 0x9f, 0x8d, 0xf6, 0xf6, 0xb6, 0x6e, 0x27, 0xa1, 0x5f, 0xc2, 0xd4, + 0xaa, 0xcf, 0x53, 0x9a, 0x9c, 0xca, 0x36, 0xd7, 0xa1, 0xec, 0x57, 0x60, 0x18, 0x29, 0xb4, 0x5c, 0x51, 0x9f, 0xbb, + 0x7e, 0x22, 0x0f, 0x18, 0x18, 0xfc, 0x0c, 0x77, 0xe8, 0x3e, 0x2a, 0x52, 0xee, 0xcb, 0x9c, 0x31, 0x93, 0x0d, 0xa4, + 0xdc, 0xd7, 0xd7, 0x38, 0xf9, 0xbc, 0x76, 0x84, 0x3f, 0xea, 0xf6, 0xdf, 0xbc, 0x3f, 0xb1, 0xe4, 0xee, 0x3d, 0x6e, + 0x45, 0xdd, 0xfe, 0xb1, 0x70, 0xa9, 0xc8, 0xac, 0x00, 0x22, 0xb3, 0x02, 0x2c, 0x75, 0x7f, 0x0d, 0x04, 0xda, 0x8a, + 0x96, 0x9c, 0xb6, 0x30, 0x29, 0xa4, 0x33, 0x78, 0x32, 0x8b, 0x39, 0x83, 0xcf, 0x2b, 0xb5, 0x44, 0x4a, 0x80, 0x48, + 0x31, 0xd0, 0xc7, 0x61, 0x95, 0xf2, 0x78, 0xc5, 0x13, 0xed, 0x3a, 0x1e, 0xb1, 0x98, 0xea, 0x03, 0xb0, 0xaa, 0xab, + 0x32, 0x1f, 0x68, 0xbd, 0x76, 0x3e, 0xbb, 0x82, 0x9c, 0x08, 0x9d, 0x7d, 0xf4, 0x41, 0x35, 0x38, 0x16, 0x43, 0x41, + 0x60, 0x5f, 0x4a, 0x71, 0xfd, 0x21, 0xd9, 0xfa, 0x92, 0xaa, 0xd9, 0x2b, 0x01, 0x02, 0x97, 0x86, 0x44, 0xfb, 0xfd, + 0xd2, 0x9b, 0x6c, 0xbe, 0x2b, 0x8e, 0x5b, 0xd1, 0x7e, 0xff, 0xd2, 0x1b, 0xab, 0xfe, 0x5e, 0xa6, 0xe3, 0xcd, 0x7d, + 0xc5, 0xe9, 0x78, 0x20, 0x4e, 0xe4, 0xcb, 0xdb, 0xa5, 0xb4, 0x6e, 0x9c, 0xc6, 0x76, 0xff, 0x58, 0xe9, 0x0a, 0x96, + 0x88, 0xba, 0xdb, 0x87, 0x6d, 0x7d, 0xc8, 0x3f, 0x4e, 0xc7, 0xb0, 0x5f, 0x65, 0x13, 0x63, 0x90, 0x9a, 0x43, 0x3e, + 0xea, 0xf4, 0x8f, 0x7d, 0x4b, 0xb0, 0x1e, 0xc1, 0x5b, 0x72, 0xaf, 0x05, 0x8d, 0xa3, 0x74, 0x42, 0x5d, 0x96, 0xb6, + 0xe6, 0xf4, 0xaa, 0xe9, 0x4f, 0x59, 0xe5, 0xfd, 0x06, 0x9d, 0xa4, 0x1c, 0x32, 0x5d, 0xc9, 0xc0, 0xea, 0x56, 0xde, + 0xb8, 0x03, 0x30, 0x89, 0xb4, 0xe7, 0x4e, 0xb8, 0xec, 0x0c, 0xb0, 0xd2, 0xfe, 0x71, 0xcb, 0x5f, 0xc1, 0x88, 0xd8, + 0x8a, 0x85, 0xf2, 0xc3, 0x83, 0xdd, 0x73, 0x25, 0xd2, 0xbf, 0xa4, 0xb4, 0xd0, 0xfe, 0x7a, 0x25, 0xc7, 0x0b, 0xbb, + 0xff, 0xaf, 0xff, 0xe3, 0x7f, 0x29, 0x17, 0xfc, 0x71, 0x2b, 0xea, 0xe8, 0xbe, 0x56, 0x56, 0xa5, 0x38, 0x86, 0x2b, + 0x73, 0xaa, 0x98, 0x31, 0xbd, 0x69, 0x8e, 0x33, 0x16, 0x36, 0x23, 0x3f, 0x1e, 0xd9, 0xfd, 0xed, 0xd8, 0x94, 0xe9, + 0x89, 0x4d, 0x1d, 0x6d, 0x5d, 0x2f, 0x02, 0x7a, 0xfd, 0x4d, 0xf7, 0x3f, 0xe8, 0x8c, 0x2f, 0xb1, 0xb5, 0xcd, 0xdb, + 0x20, 0xaa, 0xdd, 0x57, 0xbb, 0x11, 0x22, 0x57, 0x5f, 0xa7, 0x56, 0x0c, 0x32, 0xaf, 0x5d, 0x04, 0x51, 0xd8, 0x56, + 0x19, 0xf3, 0xfa, 0xbf, 0xff, 0xf3, 0xbf, 0xfc, 0x37, 0xfd, 0x08, 0xa1, 0xac, 0x7f, 0xfd, 0xef, 0xff, 0xf9, 0xff, + 0xfc, 0xef, 0xff, 0x0a, 0xe9, 0x69, 0x2a, 0xdc, 0x25, 0x98, 0x8a, 0x55, 0xc5, 0xba, 0x24, 0x77, 0xb1, 0xe0, 0xd0, + 0xdb, 0x84, 0xe5, 0x9c, 0x05, 0xf5, 0xab, 0x21, 0xce, 0xc4, 0x80, 0x62, 0x67, 0x2a, 0xe8, 0xc4, 0x0e, 0x2f, 0x2a, + 0x82, 0xaa, 0xa1, 0x5c, 0x10, 0x6e, 0x71, 0xdc, 0x02, 0x7c, 0xdf, 0xef, 0x66, 0x1b, 0xb7, 0x5c, 0x8e, 0x85, 0x26, + 0x13, 0x28, 0x29, 0xaa, 0x72, 0x0b, 0x42, 0x2f, 0x0b, 0x78, 0xf4, 0xba, 0x46, 0xb1, 0x58, 0xbd, 0x5a, 0x9b, 0xde, + 0xcf, 0xb3, 0x9c, 0xb3, 0x11, 0xa0, 0x5c, 0xba, 0x91, 0x45, 0x94, 0xbb, 0x09, 0xaa, 0x64, 0x7c, 0x5b, 0x88, 0x5e, + 0x24, 0x81, 0x1e, 0x1c, 0xfd, 0xa9, 0xf8, 0xf3, 0x04, 0x14, 0x36, 0xcb, 0x99, 0xf8, 0x37, 0xca, 0x7a, 0x7f, 0xd8, + 0x6e, 0x4f, 0x6f, 0xd0, 0xa2, 0x1a, 0x01, 0x6f, 0x1b, 0x4c, 0xd0, 0xb1, 0xd9, 0xa1, 0x08, 0x8f, 0x97, 0x5e, 0xee, + 0xb6, 0x05, 0xae, 0x72, 0xab, 0x5d, 0x14, 0x5f, 0x2d, 0x84, 0xa3, 0x95, 0xfd, 0x0a, 0x61, 0x6c, 0xe5, 0x93, 0xbe, + 0x4a, 0xcd, 0xc9, 0x2d, 0x8c, 0x56, 0x5d, 0xd9, 0x2a, 0xea, 0xac, 0x5f, 0x12, 0x63, 0x86, 0xe1, 0xcd, 0x00, 0xfa, + 0x01, 0x84, 0xc4, 0xa3, 0x0e, 0x8e, 0xba, 0x8b, 0xb2, 0x7b, 0xce, 0xd3, 0x89, 0x19, 0x77, 0xa7, 0x3e, 0x0d, 0xe8, + 0x48, 0xfb, 0xf2, 0xd5, 0x7b, 0x19, 0x53, 0x2f, 0xa2, 0xfd, 0x0d, 0x63, 0x29, 0x90, 0x44, 0xbc, 0xdd, 0x6a, 0x17, + 0x5f, 0xc2, 0x0e, 0x5c, 0x8c, 0xe2, 0xd4, 0xe7, 0x9e, 0x20, 0xd8, 0x9e, 0x19, 0xbd, 0xf7, 0x81, 0x27, 0xa5, 0x0b, + 0x03, 0x9e, 0x9e, 0xac, 0x0a, 0x5e, 0xf5, 0xfa, 0x65, 0x91, 0x85, 0x2b, 0x9a, 0x9b, 0x5d, 0x49, 0xa7, 0xdc, 0x77, + 0x2a, 0x28, 0xfe, 0xbc, 0xe6, 0xcd, 0x52, 0x02, 0xa9, 0x8b, 0x36, 0xbf, 0x97, 0x62, 0x5f, 0xbe, 0xfd, 0x9e, 0x3b, + 0xb6, 0x00, 0xd3, 0x5e, 0xad, 0x25, 0x0a, 0xa1, 0xd6, 0x73, 0xf2, 0x5d, 0x69, 0x51, 0xf9, 0xd3, 0xa9, 0xa8, 0x88, + 0x7a, 0xc7, 0x2d, 0xa9, 0x08, 0x03, 0xf7, 0x10, 0x19, 0x1f, 0x32, 0xc1, 0x42, 0x55, 0x52, 0x5b, 0x41, 0xfe, 0x52, + 0xa9, 0x17, 0xf0, 0xd5, 0xf2, 0xfe, 0xff, 0x03, 0x0c, 0xbd, 0x72, 0x0a, 0x4e, 0x98, 0x00, 0x00}; #else // Brotli (default, smaller) constexpr uint8_t INDEX_BR[] PROGMEM = { - 0x1b, 0xe2, 0x97, 0xa3, 0x90, 0xa2, 0x95, 0x55, 0x51, 0x04, 0x1b, 0x07, 0x80, 0x20, 0x79, 0x0e, 0x50, 0xab, 0x02, - 0xdb, 0x98, 0x16, 0xf4, 0x7b, 0x22, 0xa3, 0x4d, 0xd3, 0x86, 0xc1, 0x26, 0x48, 0x49, 0x60, 0xbe, 0xb3, 0xc9, 0xa1, - 0x8c, 0x96, 0x10, 0x1b, 0x21, 0xcf, 0x48, 0x68, 0xce, 0x10, 0x34, 0x32, 0x7c, 0xbf, 0x71, 0x7b, 0x03, 0x8f, 0xdd, - 0x37, 0x06, 0x9a, 0x30, 0x50, 0xe4, 0x08, 0x47, 0x68, 0xec, 0x93, 0xdc, 0x7d, 0x53, 0xf5, 0x4f, 0xd7, 0x8a, 0xcf, - 0x2f, 0x85, 0x3a, 0x6c, 0xa9, 0x63, 0xcb, 0xf5, 0xc8, 0x18, 0xe3, 0xf5, 0xdb, 0x0c, 0x05, 0x9b, 0x48, 0x2c, 0x42, - 0x21, 0xa0, 0x2c, 0x25, 0xf7, 0xcb, 0xb7, 0xaa, 0x65, 0xd5, 0x7f, 0x3e, 0x2f, 0x94, 0x2b, 0x53, 0xa7, 0x0f, 0x4e, - 0x56, 0xa9, 0xf7, 0xce, 0x54, 0x88, 0x39, 0xef, 0xea, 0x3d, 0x69, 0x56, 0xd0, 0x52, 0x96, 0x0a, 0x5b, 0x35, 0xd4, - 0x42, 0xd6, 0x35, 0x10, 0xf3, 0x7f, 0xec, 0x95, 0xd3, 0x2a, 0xfe, 0x4d, 0x22, 0x6a, 0xd6, 0x69, 0x6e, 0x7b, 0x5a, - 0xdd, 0x33, 0x58, 0x21, 0xa4, 0x33, 0xd6, 0x61, 0x05, 0xf5, 0x08, 0xa9, 0x10, 0x32, 0xf5, 0xdc, 0x04, 0x59, 0x72, - 0x41, 0xf4, 0x09, 0xfa, 0x08, 0x08, 0x9b, 0xcc, 0xf3, 0x2d, 0x71, 0xb4, 0x1c, 0xe3, 0x04, 0x64, 0x9a, 0x96, 0x5b, - 0x16, 0x58, 0xed, 0xbf, 0x37, 0xd5, 0x2a, 0x6d, 0x80, 0x66, 0xcf, 0xba, 0xd4, 0xb8, 0xd4, 0x38, 0x65, 0x10, 0x57, - 0x3a, 0xe7, 0x93, 0xe0, 0x82, 0x90, 0x78, 0xef, 0xfd, 0xff, 0x96, 0xed, 0x30, 0x44, 0x77, 0x13, 0x3b, 0x70, 0x92, - 0xe8, 0xb0, 0xf4, 0x25, 0x5a, 0xc9, 0xff, 0xff, 0xbb, 0x01, 0x76, 0x03, 0x94, 0x16, 0x20, 0xa5, 0x2d, 0x8a, 0xe2, - 0x56, 0x51, 0xd4, 0xdc, 0x18, 0xcb, 0x99, 0xb3, 0x4e, 0x3b, 0x55, 0x67, 0x5d, 0x64, 0xa3, 0xc4, 0xf8, 0xec, 0x2a, - 0xb7, 0x51, 0x68, 0x6c, 0x7a, 0xd9, 0x85, 0x97, 0x9e, 0xcb, 0x61, 0x26, 0xbe, 0xeb, 0x3a, 0x6b, 0xe5, 0x38, 0xd8, - 0x63, 0xa8, 0xb5, 0xba, 0xbe, 0xbd, 0x1d, 0xe3, 0xc4, 0x11, 0xa3, 0x08, 0xc4, 0xfe, 0x26, 0x3a, 0xfb, 0x01, 0x5d, - 0xb4, 0xfc, 0x1d, 0x78, 0xca, 0xb2, 0xac, 0x61, 0x39, 0x21, 0xe5, 0x6b, 0x5a, 0x70, 0x76, 0x57, 0x91, 0x4c, 0x8c, - 0x3d, 0x0e, 0x50, 0x4e, 0x41, 0x1c, 0xda, 0x62, 0xd2, 0xf1, 0x25, 0xce, 0x03, 0xf4, 0xfa, 0x3b, 0xc1, 0xc4, 0xed, - 0xc1, 0xdf, 0x8f, 0xe1, 0xc0, 0x0e, 0x34, 0x72, 0x3c, 0xb3, 0x3f, 0xfa, 0xc0, 0xe6, 0xcd, 0xf4, 0x01, 0x19, 0xf4, - 0x28, 0x5b, 0xde, 0x02, 0x6e, 0xa2, 0x24, 0x59, 0x76, 0x94, 0x8d, 0x00, 0x35, 0xab, 0xbe, 0xd9, 0xc0, 0xfb, 0xfa, - 0x97, 0x4f, 0x8f, 0x6e, 0xa4, 0x28, 0x0a, 0xfd, 0x43, 0xd9, 0xf2, 0xb2, 0xc2, 0x75, 0x49, 0x97, 0x8c, 0xcd, 0x61, - 0xb3, 0x64, 0x52, 0x1e, 0x46, 0x1e, 0xa2, 0xe0, 0xb5, 0x26, 0xd3, 0xf5, 0x3c, 0x9e, 0x19, 0x32, 0x45, 0xb9, 0x28, - 0xf5, 0x40, 0xcf, 0xa5, 0xf1, 0xcd, 0x8d, 0x29, 0x55, 0xe3, 0x2c, 0x43, 0x12, 0xa2, 0x4d, 0x37, 0xda, 0x94, 0x3e, - 0x49, 0x88, 0x4f, 0x2c, 0xa5, 0x67, 0xbe, 0xb7, 0x75, 0x28, 0xb8, 0x2f, 0x0d, 0x75, 0xce, 0x87, 0x3f, 0xe3, 0x30, - 0x5a, 0x25, 0x92, 0x30, 0xd3, 0x2d, 0x45, 0xca, 0xe5, 0x49, 0xc7, 0x4d, 0x13, 0x95, 0xa5, 0x9f, 0x7f, 0x2d, 0x49, - 0x46, 0x5a, 0x49, 0x11, 0x12, 0xd2, 0xdd, 0x38, 0x3c, 0x31, 0x63, 0x5e, 0xb6, 0xe6, 0xde, 0xcd, 0xcd, 0x6d, 0x67, - 0x46, 0x53, 0x16, 0x86, 0xd4, 0xad, 0x07, 0xac, 0xdb, 0xd7, 0x9f, 0x48, 0xcc, 0xa6, 0x4d, 0x9f, 0x6c, 0x8b, 0xf2, - 0xcb, 0xcd, 0x1b, 0x1e, 0xa9, 0x39, 0x37, 0x4d, 0xcd, 0x80, 0x9b, 0x19, 0x67, 0x64, 0x70, 0xb0, 0x38, 0x00, 0x9f, - 0xab, 0x26, 0xca, 0xb7, 0xbb, 0x55, 0x50, 0xcf, 0x31, 0x65, 0x12, 0xb6, 0x2b, 0x36, 0x9d, 0x17, 0x2b, 0x50, 0xd1, - 0x13, 0x72, 0x80, 0x7f, 0x88, 0x62, 0xe4, 0xee, 0x40, 0xb7, 0x56, 0xd2, 0x66, 0xa3, 0xb0, 0x0e, 0x31, 0xeb, 0x9a, - 0x27, 0xc1, 0xd5, 0x7b, 0x63, 0xb3, 0x84, 0x1b, 0xc8, 0xbf, 0x35, 0x29, 0x8a, 0x3c, 0xd0, 0x66, 0x03, 0x2a, 0x3e, - 0xb9, 0x79, 0x4c, 0x16, 0x01, 0xaa, 0xe8, 0x0e, 0x01, 0x1c, 0x73, 0x05, 0x78, 0x3a, 0x9c, 0x23, 0xb8, 0x18, 0x78, - 0xc5, 0xcd, 0x53, 0x7f, 0xb4, 0x61, 0xb8, 0x11, 0xa4, 0xcd, 0xc6, 0x27, 0x27, 0xb6, 0xae, 0x51, 0x01, 0x1d, 0xec, - 0xe4, 0x6b, 0x99, 0xe4, 0xdb, 0x2d, 0xbb, 0x66, 0x38, 0x54, 0xf5, 0xf2, 0xba, 0xc3, 0x24, 0x41, 0xfa, 0xae, 0x46, - 0x43, 0xdd, 0xfd, 0x9d, 0x0d, 0x52, 0x98, 0x1c, 0x7f, 0xab, 0x29, 0x83, 0x0f, 0xb9, 0x11, 0xe0, 0xd3, 0x49, 0x86, - 0xef, 0xbb, 0xdf, 0x0a, 0x04, 0x76, 0x11, 0x07, 0x48, 0x7f, 0x86, 0x4c, 0x3a, 0x84, 0xf5, 0xb6, 0x32, 0x54, 0x59, - 0xed, 0xd5, 0xf1, 0xf0, 0xf8, 0x39, 0x2d, 0x10, 0x85, 0x11, 0xd2, 0xef, 0x72, 0xcb, 0xd2, 0x8f, 0xf2, 0x2e, 0x7c, - 0x9b, 0x28, 0xa6, 0x07, 0x7f, 0x7a, 0x7c, 0x43, 0x28, 0x0b, 0x3f, 0xe5, 0x98, 0x64, 0x6f, 0x63, 0xad, 0xd9, 0x90, - 0x34, 0x84, 0x30, 0xf9, 0x53, 0x6e, 0xea, 0xe3, 0x5f, 0x36, 0x39, 0xe7, 0x26, 0x49, 0xf0, 0xe9, 0xe7, 0x32, 0x50, - 0x58, 0x41, 0x1e, 0xaa, 0x98, 0x6f, 0x6b, 0xfa, 0x94, 0x4b, 0x20, 0x01, 0x84, 0x2a, 0x32, 0x84, 0x73, 0x5a, 0x39, - 0x4a, 0x57, 0xbc, 0xbd, 0x86, 0xa4, 0xb2, 0x77, 0x99, 0xe5, 0xdd, 0x44, 0x5d, 0xd5, 0xde, 0x5b, 0x94, 0x7e, 0xec, - 0x53, 0xdd, 0x67, 0xb8, 0x8d, 0xbb, 0x1d, 0x65, 0xf4, 0xe8, 0xe4, 0x73, 0x3d, 0xbc, 0xba, 0xd9, 0x30, 0xbe, 0x1f, - 0xeb, 0x0b, 0x21, 0xaf, 0x24, 0x9a, 0x44, 0xa2, 0x0a, 0xbf, 0xfe, 0xfa, 0x06, 0x14, 0x59, 0x76, 0x6d, 0x97, 0x7e, - 0xe0, 0x70, 0x8c, 0x41, 0x28, 0xac, 0x0b, 0xad, 0x4b, 0xb5, 0xbb, 0x54, 0xeb, 0x0d, 0x05, 0x24, 0xc7, 0xad, 0x04, - 0xfb, 0x9b, 0x12, 0x44, 0xec, 0x20, 0x03, 0xff, 0xba, 0x91, 0xa0, 0x50, 0xba, 0x24, 0xed, 0x9c, 0x96, 0x7e, 0xef, - 0x6f, 0x24, 0x6c, 0xd1, 0x8c, 0x55, 0xe9, 0x0f, 0x8c, 0x2f, 0x8b, 0x62, 0x44, 0x3c, 0x1b, 0x46, 0x3b, 0x49, 0x99, - 0xdc, 0xd6, 0x7a, 0x70, 0x5d, 0xe5, 0x2a, 0x62, 0x2e, 0x54, 0xab, 0x44, 0xf2, 0xf4, 0x61, 0xb2, 0xd8, 0x07, 0x8b, - 0x01, 0x3e, 0x04, 0x19, 0xe1, 0x5d, 0x8e, 0x2a, 0xff, 0x86, 0xa3, 0x59, 0xe5, 0xcc, 0x8d, 0xd3, 0x51, 0x6f, 0xc1, - 0x15, 0x9f, 0x37, 0x73, 0x3d, 0x49, 0x99, 0xca, 0x53, 0x3a, 0x96, 0x0c, 0x92, 0x2b, 0x8b, 0xde, 0x08, 0x68, 0x52, - 0x87, 0x31, 0xb2, 0x68, 0x81, 0xb1, 0xe9, 0x9f, 0x78, 0xf1, 0x22, 0xe8, 0x84, 0x48, 0xdb, 0x49, 0x4d, 0xd2, 0xea, - 0x80, 0x1f, 0xec, 0x50, 0x77, 0x66, 0xe7, 0x13, 0x36, 0x02, 0x85, 0x6f, 0xdd, 0x68, 0xe0, 0x4b, 0x6c, 0x5b, 0xbe, - 0x18, 0xca, 0xaf, 0x92, 0x97, 0xdd, 0x4e, 0x90, 0x28, 0x4e, 0x48, 0x42, 0x62, 0xc3, 0xf1, 0xf7, 0x71, 0x59, 0x2b, - 0x24, 0x2e, 0x4b, 0xf1, 0x52, 0x2d, 0x7b, 0xbf, 0x8f, 0x5d, 0x1a, 0x29, 0x6b, 0xdd, 0xed, 0x8b, 0x0d, 0xa3, 0xaf, - 0x1a, 0x94, 0x32, 0xc4, 0x54, 0x3d, 0xa1, 0xee, 0x41, 0x42, 0x00, 0xc3, 0xc2, 0x23, 0x57, 0x52, 0x9c, 0x48, 0x54, - 0x42, 0x82, 0x61, 0xb1, 0xcb, 0x1b, 0xae, 0x8f, 0xfa, 0x30, 0x6c, 0x00, 0xc4, 0x1b, 0x74, 0x7c, 0x99, 0x51, 0x60, - 0x45, 0x6d, 0x05, 0xe0, 0x44, 0x15, 0x24, 0x98, 0xb1, 0x40, 0x5f, 0xa1, 0x5e, 0x43, 0x55, 0xae, 0x10, 0xbd, 0x9d, - 0x80, 0x41, 0x6e, 0x35, 0x5d, 0xe8, 0xb2, 0x34, 0x7a, 0x1b, 0xb8, 0x29, 0xad, 0x6d, 0xd3, 0xb4, 0x4f, 0x32, 0x0e, - 0x4e, 0xd7, 0xb3, 0x98, 0x12, 0x37, 0xd4, 0x5c, 0x19, 0xbd, 0x26, 0xaa, 0xbb, 0x5b, 0x7d, 0x92, 0xd3, 0xb7, 0xd3, - 0x2e, 0xfa, 0x6e, 0xf6, 0x2b, 0xaa, 0xc4, 0x24, 0x46, 0x6d, 0x58, 0xe9, 0x7e, 0x8d, 0x27, 0x23, 0x14, 0xbc, 0x15, - 0xaf, 0x1b, 0x88, 0x7b, 0xd1, 0x9d, 0xba, 0x9c, 0x08, 0xd2, 0xf9, 0x9b, 0x81, 0xfd, 0xf8, 0x94, 0xc1, 0x0a, 0xf1, - 0xc8, 0xfa, 0x4e, 0x5b, 0x87, 0x86, 0x34, 0xed, 0x92, 0xcf, 0xfd, 0x49, 0xda, 0xd7, 0x25, 0xc9, 0xe3, 0x22, 0xdf, - 0x9e, 0xdd, 0x53, 0x30, 0x15, 0xe0, 0x2c, 0x5a, 0xcf, 0x41, 0xb3, 0x0d, 0xa4, 0x3a, 0x7b, 0x70, 0xc8, 0x9e, 0x4e, - 0x6b, 0xa7, 0xeb, 0xa3, 0x55, 0xd5, 0xc6, 0x45, 0x89, 0x21, 0x81, 0x5f, 0xb1, 0x29, 0x01, 0xe4, 0x40, 0xe4, 0xd1, - 0x6b, 0xe3, 0x4b, 0xe1, 0xfa, 0xf5, 0x12, 0x7d, 0x82, 0x59, 0xb9, 0xfa, 0x07, 0x0d, 0xa9, 0xa4, 0xd5, 0x80, 0x90, - 0x91, 0xfa, 0x8c, 0xf2, 0xcc, 0x0a, 0xee, 0x97, 0xce, 0x17, 0x31, 0x3a, 0x3c, 0xfd, 0x6e, 0x3f, 0x34, 0xf6, 0x2d, - 0x94, 0x17, 0x65, 0xa5, 0x32, 0x73, 0x94, 0x13, 0x80, 0x24, 0x96, 0x3c, 0x25, 0xd2, 0xc6, 0xb7, 0xad, 0x2d, 0x11, - 0xc1, 0x37, 0x7c, 0x88, 0x77, 0xee, 0x05, 0xc7, 0x26, 0x21, 0x81, 0x0a, 0xed, 0x76, 0x01, 0x14, 0x54, 0x90, 0x89, - 0x23, 0xc9, 0xd5, 0xd1, 0x20, 0xb1, 0x3f, 0x56, 0x36, 0x1d, 0x3c, 0x22, 0x92, 0xb5, 0xcd, 0x06, 0xd0, 0x91, 0xc6, - 0xab, 0x4a, 0x92, 0x83, 0x08, 0x4b, 0x00, 0x3a, 0x56, 0xfe, 0x49, 0x4a, 0x5c, 0x4e, 0xd0, 0x85, 0x41, 0xc1, 0x5d, - 0x1a, 0xc6, 0xcd, 0x26, 0xb9, 0xb0, 0x52, 0x01, 0xfd, 0x78, 0xe8, 0xc7, 0x63, 0x0f, 0x45, 0x0a, 0x82, 0x56, 0x88, - 0x87, 0x9c, 0xd2, 0x81, 0x22, 0xfa, 0xa5, 0xfe, 0x71, 0x9b, 0x37, 0xbf, 0x26, 0xe6, 0x46, 0x89, 0x8a, 0xe6, 0x3c, - 0xa6, 0x52, 0xd4, 0xc7, 0x88, 0xc1, 0x3f, 0x66, 0xec, 0xd0, 0x61, 0xa2, 0x92, 0x5e, 0xaa, 0x54, 0xac, 0x83, 0x75, - 0x26, 0x95, 0x02, 0xed, 0xd4, 0xf8, 0xe2, 0x9b, 0x48, 0x12, 0xbc, 0x13, 0xb3, 0xce, 0x20, 0x85, 0x97, 0x2a, 0xac, - 0x95, 0xe8, 0x97, 0x2d, 0x0a, 0xa2, 0xc4, 0x35, 0xb4, 0x0e, 0x69, 0x42, 0x11, 0xec, 0x09, 0x1d, 0x94, 0x68, 0xf9, - 0x87, 0xb6, 0xca, 0x48, 0x82, 0x72, 0xdf, 0xf3, 0xc1, 0xbb, 0xcb, 0x80, 0xf4, 0xf0, 0x51, 0x0f, 0x29, 0x24, 0x16, - 0x3e, 0x61, 0xcb, 0x01, 0x5d, 0xb7, 0x41, 0x52, 0x00, 0xef, 0xaa, 0x62, 0x79, 0xd9, 0x2c, 0x88, 0xbb, 0x93, 0x35, - 0x35, 0x63, 0xbf, 0x4c, 0x60, 0xa7, 0x82, 0xa3, 0xd5, 0xb6, 0x09, 0x6b, 0xa9, 0x96, 0x24, 0xa3, 0x63, 0x81, 0x59, - 0x02, 0x89, 0x10, 0xe9, 0xfe, 0x58, 0x9c, 0x03, 0x31, 0xaf, 0x93, 0xcc, 0x80, 0xf3, 0xd4, 0x2a, 0x47, 0x13, 0x28, - 0x1c, 0xc7, 0x72, 0xbe, 0x26, 0x29, 0xc9, 0x13, 0x0e, 0xb0, 0x1a, 0xaf, 0xb0, 0x8e, 0x82, 0xfb, 0xb8, 0xa6, 0xa4, - 0xcc, 0xee, 0x7f, 0x99, 0xd2, 0xc4, 0x60, 0x57, 0xa2, 0x03, 0x12, 0x40, 0x4a, 0xb3, 0xd4, 0x62, 0xf0, 0x79, 0x44, - 0x3c, 0x16, 0x82, 0x89, 0x88, 0x44, 0xe1, 0x2b, 0x5d, 0xcb, 0xcf, 0xbc, 0x04, 0x84, 0xca, 0x4c, 0x83, 0xce, 0x92, - 0xd7, 0xaa, 0xa4, 0x86, 0xf6, 0x1b, 0xed, 0x46, 0x35, 0x2b, 0x9f, 0x14, 0x3e, 0x64, 0x1d, 0xb9, 0x7f, 0x1a, 0x98, - 0x64, 0xbd, 0xc9, 0x29, 0x95, 0x76, 0x96, 0xaf, 0xfe, 0xf5, 0x05, 0x8a, 0x8d, 0xaa, 0xa3, 0xe9, 0xb6, 0x3e, 0xda, - 0x10, 0x75, 0xf6, 0x11, 0x71, 0xc0, 0x13, 0x56, 0x33, 0x97, 0x5f, 0x65, 0xf8, 0xe1, 0x32, 0x39, 0x20, 0x45, 0x73, - 0x66, 0xa2, 0x6b, 0xfa, 0xef, 0x22, 0x39, 0x70, 0x89, 0xad, 0xc0, 0x14, 0x50, 0x46, 0x15, 0x63, 0x64, 0x39, 0x90, - 0xc4, 0x52, 0xc9, 0xe5, 0x7c, 0x84, 0x16, 0x59, 0x57, 0x4e, 0x19, 0x0a, 0x95, 0xd3, 0xc8, 0x1c, 0x36, 0x29, 0x8e, - 0x61, 0x5e, 0x96, 0xea, 0x79, 0x86, 0x90, 0x26, 0xdd, 0xd5, 0xa7, 0x88, 0x42, 0xcd, 0xaa, 0x7d, 0x17, 0xa6, 0xbe, - 0x08, 0x57, 0x85, 0x01, 0xf2, 0xfc, 0x61, 0x2d, 0xb2, 0xce, 0xa4, 0xf1, 0x62, 0x67, 0xbc, 0xa0, 0xb2, 0x61, 0x24, - 0x59, 0x96, 0x38, 0x28, 0x41, 0xe0, 0x94, 0x90, 0xc6, 0x3e, 0x71, 0xb8, 0x2d, 0x3f, 0x1e, 0x33, 0xb7, 0xe9, 0x50, - 0x46, 0x31, 0xe2, 0xea, 0x49, 0x95, 0x75, 0x0d, 0xe7, 0x21, 0xe6, 0x0f, 0x2f, 0x8b, 0xda, 0x6f, 0xba, 0xd2, 0xe8, - 0xc6, 0xa1, 0xb3, 0x02, 0x6d, 0x4f, 0x27, 0x73, 0x3a, 0x7d, 0x11, 0x57, 0x75, 0x52, 0x10, 0x50, 0x04, 0xc2, 0x1e, - 0x8f, 0xfe, 0x51, 0x1a, 0xed, 0x1f, 0x01, 0x4b, 0xd6, 0x31, 0xd8, 0x93, 0x6a, 0x8f, 0x09, 0x49, 0xcb, 0xdb, 0x1f, - 0x81, 0xb9, 0x52, 0x25, 0xd1, 0x43, 0xf0, 0xe1, 0x08, 0xa5, 0x05, 0x85, 0x64, 0xd3, 0x93, 0x6e, 0x43, 0xa6, 0x09, - 0x98, 0xe8, 0x71, 0x90, 0x67, 0xc3, 0x1b, 0x17, 0x55, 0x88, 0x3e, 0x3e, 0x30, 0xd9, 0xa5, 0x63, 0xb4, 0x69, 0x95, - 0x6d, 0xf6, 0x9f, 0xa1, 0xd8, 0xef, 0xf7, 0xd7, 0xcc, 0xa1, 0x48, 0xef, 0x3a, 0x23, 0x37, 0xb1, 0xe0, 0xfc, 0x14, - 0x25, 0xc6, 0xb3, 0xb6, 0x34, 0xa4, 0xe5, 0x10, 0x45, 0x48, 0x0e, 0x1d, 0x82, 0xbe, 0x0c, 0x19, 0x56, 0x57, 0xe8, - 0xf0, 0x2d, 0xfd, 0x82, 0x43, 0x26, 0x29, 0x39, 0xd2, 0x64, 0xbf, 0x97, 0xc4, 0x64, 0x57, 0xba, 0xa8, 0x40, 0x87, - 0xd5, 0xb4, 0x13, 0x43, 0xb2, 0xd5, 0xbb, 0xda, 0x66, 0xa9, 0xe5, 0x08, 0xee, 0xce, 0x03, 0xc9, 0x1f, 0x81, 0xaa, - 0xe7, 0xd1, 0x19, 0x47, 0x0b, 0x44, 0x9d, 0x4b, 0x92, 0xdb, 0x49, 0x31, 0xc8, 0x26, 0x52, 0x28, 0x90, 0xae, 0x10, - 0x8d, 0x61, 0x31, 0x6d, 0x3f, 0x08, 0x1c, 0x2c, 0x75, 0x9b, 0x64, 0xa4, 0xcf, 0x9d, 0xdd, 0x26, 0xc5, 0x23, 0x54, - 0x1e, 0xb5, 0xee, 0xbb, 0x69, 0x49, 0x90, 0xea, 0x24, 0x4f, 0x10, 0xb4, 0x67, 0x63, 0xef, 0x98, 0x80, 0xf9, 0xde, - 0x54, 0xcc, 0xaf, 0xa7, 0x6e, 0xc2, 0xc2, 0xee, 0x43, 0x8a, 0x5b, 0x66, 0x76, 0xf2, 0x9d, 0xf9, 0x1c, 0x69, 0xce, - 0x0c, 0x9d, 0xd4, 0x29, 0x24, 0xb3, 0xb1, 0xb7, 0xf4, 0x17, 0xa4, 0x79, 0x77, 0x2f, 0x3a, 0x94, 0x4d, 0xf8, 0x3d, - 0x21, 0xb8, 0x1e, 0x91, 0xc3, 0x08, 0xbe, 0xea, 0x90, 0xd8, 0xcd, 0x46, 0x2b, 0x52, 0x68, 0xed, 0x68, 0x88, 0x4b, - 0xb6, 0x7b, 0x37, 0x0b, 0x00, 0x88, 0x90, 0xd3, 0xef, 0x95, 0x86, 0x8c, 0x2d, 0xfd, 0xe2, 0x8c, 0xad, 0x14, 0xe8, - 0x59, 0x2d, 0xe2, 0x09, 0xaf, 0x09, 0x29, 0x41, 0x67, 0x85, 0x63, 0x86, 0xea, 0x43, 0xd0, 0xce, 0x1b, 0x4a, 0xb6, - 0x74, 0x30, 0x9c, 0xb8, 0x86, 0x92, 0x2d, 0x8c, 0x18, 0x1f, 0xba, 0xd9, 0x7b, 0x5a, 0x24, 0x43, 0xc1, 0x8f, 0x54, - 0x11, 0xe5, 0x22, 0x6a, 0x46, 0x68, 0x6c, 0x69, 0x30, 0x8a, 0x36, 0x9c, 0x9b, 0x77, 0x57, 0x04, 0x71, 0xd9, 0x27, - 0x56, 0x52, 0xc4, 0x8f, 0x83, 0xc4, 0xe9, 0x57, 0xab, 0x11, 0xf4, 0x32, 0x67, 0xa1, 0x34, 0xbe, 0x29, 0x85, 0x79, - 0xe4, 0x81, 0xc1, 0xb2, 0xb5, 0x0d, 0xd3, 0x3e, 0x69, 0x59, 0x3d, 0x5f, 0x55, 0x03, 0xdb, 0x20, 0x1c, 0xb5, 0x2c, - 0x1d, 0xeb, 0xe7, 0xb3, 0x8a, 0x5e, 0x37, 0xf2, 0xaf, 0x56, 0xac, 0xc5, 0x17, 0x20, 0x3b, 0x63, 0x98, 0xcd, 0x98, - 0x34, 0x2a, 0xa0, 0x16, 0x92, 0x29, 0x6b, 0x8b, 0x8a, 0xa7, 0x49, 0x09, 0x1b, 0x1a, 0x70, 0x34, 0x2d, 0x0b, 0xe9, - 0xc5, 0xeb, 0xa1, 0x7d, 0x70, 0xd6, 0xe1, 0x73, 0xcb, 0xd2, 0x23, 0x58, 0x0d, 0x78, 0x8d, 0x88, 0x12, 0x44, 0x2a, - 0xa4, 0x44, 0x85, 0x94, 0x43, 0x15, 0xd3, 0x41, 0xa7, 0x5c, 0x53, 0x67, 0xa5, 0x95, 0x79, 0x97, 0xc6, 0xf8, 0xd3, - 0x22, 0xa4, 0xb0, 0xae, 0x80, 0xc1, 0xa2, 0xf8, 0x0d, 0x04, 0xc0, 0x8b, 0x35, 0xd3, 0x33, 0x31, 0x30, 0xc7, 0x4b, - 0x5a, 0xde, 0x4b, 0x13, 0x66, 0xb1, 0x74, 0x63, 0x53, 0xa8, 0x8f, 0x8c, 0x42, 0x7a, 0xce, 0x25, 0x20, 0xea, 0xa6, - 0xc7, 0x97, 0xd9, 0x7a, 0xcf, 0xb8, 0x24, 0xff, 0x75, 0xbe, 0xdd, 0x9b, 0x15, 0x0e, 0xcf, 0x3d, 0x72, 0x38, 0x70, - 0x06, 0xa9, 0x48, 0x63, 0x06, 0x39, 0x05, 0x2f, 0x7a, 0x85, 0x19, 0x7f, 0xa4, 0x2b, 0x59, 0x22, 0x0a, 0x4f, 0x00, - 0x7f, 0x57, 0x2d, 0x42, 0xb7, 0x07, 0x84, 0xef, 0x42, 0xc6, 0x67, 0x35, 0x4c, 0xf2, 0x47, 0x18, 0x23, 0xf1, 0xe5, - 0x7b, 0x70, 0x53, 0x99, 0x8c, 0x6f, 0x7e, 0xcb, 0x92, 0x40, 0x65, 0x19, 0x4c, 0x53, 0x83, 0x92, 0x3a, 0xfb, 0x04, - 0x79, 0xe4, 0xbc, 0xaa, 0x1b, 0xa6, 0x4e, 0x9a, 0x49, 0x1e, 0xf4, 0x41, 0xa6, 0x08, 0x44, 0xa7, 0x8b, 0x61, 0xe4, - 0x81, 0x10, 0x00, 0xcf, 0x11, 0x88, 0xb4, 0x04, 0xce, 0x00, 0x8e, 0xe9, 0x9c, 0x0c, 0x1a, 0x91, 0xd1, 0xf8, 0xa9, - 0x51, 0x84, 0x8a, 0x54, 0xae, 0x63, 0xc7, 0xe1, 0x68, 0x89, 0x68, 0x94, 0xdf, 0x40, 0x31, 0x05, 0xff, 0xd2, 0xb8, - 0xb5, 0x69, 0xd7, 0x7b, 0xe2, 0x19, 0xc6, 0x96, 0xa6, 0x99, 0xa6, 0x45, 0xd1, 0x48, 0xdd, 0x67, 0x0c, 0x57, 0x2c, - 0x41, 0x9b, 0x84, 0xa2, 0x0c, 0xa3, 0x3a, 0xa6, 0x4a, 0x71, 0x0b, 0x47, 0x68, 0x54, 0xbe, 0xb5, 0x08, 0xed, 0xfd, - 0xc4, 0xf1, 0xe9, 0x32, 0x42, 0x5a, 0x9f, 0x1f, 0xbd, 0x2c, 0x30, 0xfd, 0x32, 0x9c, 0xa1, 0xaf, 0x44, 0x44, 0x34, - 0xad, 0x02, 0x3b, 0x1c, 0xe8, 0x6a, 0xc3, 0x4b, 0x73, 0x17, 0xb7, 0x35, 0xd1, 0x83, 0x33, 0xf6, 0x54, 0x86, 0xf4, - 0xed, 0x99, 0xc8, 0xba, 0x28, 0xea, 0xf6, 0xb7, 0x93, 0xaf, 0xe1, 0xb1, 0xf9, 0x78, 0x4c, 0xea, 0x14, 0xe5, 0x6b, - 0xa2, 0xd6, 0xea, 0x5a, 0x1f, 0x82, 0x99, 0x79, 0xf4, 0x5c, 0x31, 0x19, 0xe3, 0xd4, 0x8c, 0x8c, 0xac, 0xef, 0x59, - 0xe2, 0xc5, 0x36, 0xf1, 0x3b, 0x85, 0xe4, 0x47, 0xc7, 0x19, 0xd2, 0x88, 0x82, 0xa0, 0xca, 0xfc, 0x8a, 0x42, 0x19, - 0x18, 0xe9, 0xe7, 0xb6, 0xf6, 0x03, 0x72, 0xc5, 0x28, 0x96, 0xf1, 0x6c, 0x33, 0x3e, 0xe5, 0xea, 0x1f, 0x57, 0x34, - 0xc8, 0xb2, 0xb4, 0xdf, 0x89, 0xa7, 0x6d, 0x1e, 0xda, 0x66, 0x5e, 0xd9, 0x24, 0x02, 0x78, 0x95, 0x26, 0xd9, 0xf6, - 0x70, 0xaa, 0xf5, 0x47, 0xe0, 0x57, 0x5e, 0x41, 0x80, 0xcb, 0x49, 0x58, 0xb9, 0x8b, 0x02, 0x45, 0xb5, 0x2d, 0xb8, - 0x7c, 0xb0, 0x4b, 0x9f, 0x47, 0xb1, 0x44, 0x36, 0xf7, 0xc0, 0x6c, 0x51, 0x44, 0x78, 0x4a, 0xbd, 0xad, 0x51, 0xef, - 0xdf, 0x4d, 0x11, 0x1f, 0x71, 0x24, 0x77, 0x27, 0xab, 0x6e, 0x1c, 0x95, 0x47, 0x5a, 0x28, 0xfd, 0x00, 0x2f, 0x2e, - 0x9a, 0x4b, 0x97, 0x8a, 0xc7, 0x5e, 0x0a, 0xd9, 0x46, 0xc2, 0xdc, 0x22, 0x4e, 0x6d, 0xfb, 0x6a, 0xf2, 0xfd, 0x5c, - 0xd0, 0x24, 0x31, 0xeb, 0x4b, 0x97, 0xd6, 0x86, 0x4f, 0x32, 0xbd, 0xb3, 0xcf, 0x7a, 0xf6, 0x64, 0xce, 0xe4, 0xc6, - 0xe0, 0x39, 0xa8, 0xfa, 0xbd, 0xfd, 0x94, 0xba, 0x6e, 0x78, 0x94, 0xc4, 0x94, 0x26, 0x7f, 0xe1, 0x4e, 0x92, 0xe9, - 0xae, 0x33, 0x1f, 0x25, 0xdd, 0x37, 0x1c, 0xce, 0xde, 0xdf, 0xc6, 0x5d, 0x81, 0x54, 0x16, 0x1f, 0x43, 0x24, 0x3c, - 0xf1, 0xeb, 0xad, 0x31, 0xe0, 0xa1, 0x40, 0xc0, 0x83, 0x4a, 0xba, 0x59, 0xac, 0x15, 0x1d, 0xe7, 0x74, 0xff, 0x66, - 0x13, 0xce, 0x0a, 0xc3, 0x93, 0x1c, 0x27, 0xb1, 0xcb, 0xab, 0xdc, 0x4e, 0xa5, 0xad, 0x7e, 0x9a, 0x6c, 0xc0, 0x5b, - 0x68, 0x43, 0xd1, 0x72, 0x7c, 0x86, 0x5d, 0xf5, 0x43, 0x53, 0xf9, 0x27, 0xa1, 0x14, 0xc7, 0x36, 0x0d, 0x63, 0x0d, - 0xb9, 0xfe, 0xbe, 0x1d, 0xc8, 0xb4, 0x7a, 0xf3, 0xcf, 0xd9, 0xf7, 0xea, 0xed, 0x58, 0x37, 0x3c, 0x91, 0xde, 0x0e, - 0xfe, 0x7a, 0x48, 0x8a, 0x62, 0x79, 0x7b, 0x55, 0xfd, 0xd7, 0x16, 0xbf, 0x7e, 0xac, 0x2e, 0x6b, 0x2c, 0x96, 0xf9, - 0xf8, 0xb2, 0x1a, 0x4f, 0x2d, 0xef, 0xdf, 0x4e, 0xf5, 0x87, 0x2f, 0x3f, 0xf5, 0x1a, 0xb8, 0x3a, 0x73, 0x9e, 0xa4, - 0x57, 0x14, 0xfb, 0x28, 0x57, 0xc1, 0x4b, 0xf8, 0x20, 0x3f, 0x6d, 0x8f, 0xeb, 0xa7, 0xfb, 0x65, 0x3d, 0x1f, 0x68, - 0xc9, 0xe3, 0x66, 0xeb, 0xed, 0x8d, 0xe6, 0xd5, 0x5e, 0xa6, 0x75, 0x0e, 0x1b, 0x13, 0x7c, 0x28, 0xb7, 0x8a, 0x82, - 0xf1, 0x26, 0x20, 0xf9, 0x03, 0x31, 0xaf, 0xde, 0x36, 0xbb, 0xbe, 0xfc, 0x58, 0xac, 0x59, 0x5e, 0x61, 0x01, 0x96, - 0x35, 0x1a, 0x9a, 0xb3, 0x01, 0x67, 0x49, 0x7a, 0xaf, 0xae, 0xce, 0x9c, 0xe0, 0x9c, 0xc9, 0xed, 0x4d, 0xfc, 0xc7, - 0x4f, 0x53, 0x6d, 0xce, 0x32, 0xcb, 0xe1, 0x2f, 0x8e, 0x62, 0x67, 0x71, 0xd8, 0x6e, 0xc0, 0xfa, 0xaa, 0xe3, 0x2d, - 0xaa, 0xca, 0x56, 0xe7, 0x62, 0x26, 0x4b, 0x44, 0xe5, 0x76, 0xd2, 0xe1, 0x40, 0x37, 0x73, 0x6b, 0x1f, 0xf8, 0xdf, - 0x63, 0x17, 0x2a, 0x9d, 0xc2, 0x3f, 0x97, 0x47, 0x05, 0x17, 0x72, 0x9b, 0x6c, 0x2e, 0xb9, 0x91, 0xee, 0x58, 0x9f, - 0xbc, 0xb1, 0x33, 0x13, 0x46, 0x33, 0x11, 0x56, 0xd8, 0x0f, 0x47, 0xc0, 0x3d, 0x2e, 0x18, 0x7b, 0x2e, 0xfc, 0xd6, - 0xc5, 0x96, 0xbd, 0x77, 0x7d, 0x36, 0xf9, 0x28, 0x64, 0x01, 0xfb, 0x0d, 0x81, 0x1d, 0x68, 0xdc, 0x1c, 0x47, 0x3b, - 0x24, 0xeb, 0x08, 0xe6, 0xa2, 0x5b, 0xc9, 0x56, 0x06, 0xbf, 0x55, 0xb8, 0x9f, 0xdc, 0x05, 0x20, 0x69, 0xf5, 0xee, - 0xc7, 0x5e, 0xdf, 0x7f, 0xd5, 0xcf, 0x5b, 0xbd, 0xca, 0xd8, 0x3e, 0x19, 0xf8, 0x48, 0x83, 0xae, 0x77, 0xc3, 0xcb, - 0x95, 0x6a, 0xa2, 0xcd, 0xb8, 0x59, 0x5e, 0xc9, 0xe8, 0x0d, 0xe9, 0xda, 0xee, 0x3c, 0x54, 0x27, 0x37, 0x5e, 0xb6, - 0x4c, 0x06, 0x78, 0x55, 0x67, 0x33, 0xf9, 0x05, 0x62, 0x7d, 0x7d, 0xd3, 0xc5, 0x16, 0x9a, 0xd9, 0x23, 0xd4, 0x09, - 0x7f, 0xbd, 0x8c, 0xa2, 0x52, 0x24, 0x7a, 0x69, 0x76, 0xf2, 0x78, 0xde, 0x1b, 0x6f, 0x0c, 0x59, 0x4e, 0xa6, 0x87, - 0x20, 0xd1, 0x47, 0xf4, 0x92, 0xc5, 0xf5, 0x0e, 0x83, 0xcf, 0x73, 0x94, 0x66, 0xd9, 0xb0, 0xf2, 0x45, 0xfc, 0x8c, - 0x8e, 0x25, 0x1b, 0xf9, 0xb8, 0x85, 0xad, 0x64, 0xd9, 0xe6, 0x7e, 0xd7, 0x3b, 0x5b, 0xd5, 0xcb, 0x37, 0x1b, 0xcb, - 0xee, 0x58, 0x79, 0x7e, 0x51, 0xa9, 0x59, 0x0a, 0x3b, 0x7c, 0x8e, 0x47, 0x6b, 0xc1, 0x92, 0xdb, 0xbc, 0x1c, 0x21, - 0xbd, 0x97, 0xa4, 0xbf, 0x76, 0xb2, 0x14, 0xc9, 0x87, 0xb2, 0xac, 0xf2, 0x1f, 0x92, 0x24, 0x62, 0x55, 0x14, 0xa6, - 0x1c, 0x64, 0x9e, 0x7c, 0x28, 0x37, 0xbd, 0x78, 0xe7, 0xab, 0xc2, 0x4f, 0x75, 0xd8, 0x4b, 0xeb, 0x37, 0x20, 0x0a, - 0x66, 0x01, 0x7c, 0x21, 0xee, 0x45, 0xb3, 0xeb, 0x99, 0x3c, 0x06, 0x09, 0xf4, 0x39, 0xf0, 0x0f, 0x3e, 0xfa, 0x01, - 0x05, 0x9f, 0x8b, 0x0e, 0x8c, 0x00, 0x00, 0x72, 0xc7, 0xa1, 0xec, 0xf9, 0xbd, 0x29, 0x57, 0x28, 0xab, 0xa1, 0x5a, - 0x9b, 0xfc, 0x49, 0x73, 0x1d, 0x09, 0xce, 0x7b, 0xa4, 0xc8, 0x3f, 0xf1, 0x7a, 0x36, 0x69, 0x1a, 0x62, 0x17, 0x15, - 0x0a, 0x4c, 0x54, 0xe9, 0x24, 0x4f, 0x95, 0x2c, 0xb5, 0x2a, 0x59, 0xa3, 0x07, 0x1f, 0x76, 0xeb, 0xb5, 0x79, 0x55, - 0x1e, 0x92, 0x9f, 0x25, 0x10, 0xa6, 0x7f, 0xa5, 0x65, 0xb9, 0xbd, 0xa1, 0x4a, 0xe5, 0x20, 0x0f, 0xc1, 0x23, 0xab, - 0xfc, 0xba, 0x7c, 0xe2, 0xc1, 0x8d, 0x28, 0x7f, 0xa5, 0xcf, 0x21, 0xa8, 0x04, 0xfe, 0x5b, 0x36, 0x9b, 0xbe, 0xf2, - 0xf9, 0xf6, 0xc7, 0x73, 0x41, 0x04, 0x4f, 0x97, 0xec, 0x0d, 0xe6, 0x6a, 0x6f, 0x50, 0x9a, 0xac, 0x7b, 0x67, 0x43, - 0xcc, 0x05, 0xcb, 0x1f, 0xd1, 0xe6, 0xdf, 0x27, 0xdf, 0xec, 0x85, 0x96, 0x40, 0xd3, 0x7a, 0x0d, 0xd0, 0x32, 0xcf, - 0x3b, 0x32, 0xd8, 0x23, 0xef, 0x52, 0x1e, 0x56, 0x1c, 0x57, 0xbd, 0xe4, 0xef, 0xe1, 0x2d, 0xdc, 0x69, 0x1b, 0x29, - 0x12, 0xf5, 0x3a, 0xab, 0x14, 0x81, 0xf3, 0x1e, 0xbe, 0x9a, 0xf3, 0x74, 0xaa, 0x21, 0xf1, 0x4b, 0xc7, 0xdc, 0x86, - 0x0a, 0xeb, 0x65, 0x31, 0xbb, 0xbf, 0x7b, 0x83, 0xdc, 0x12, 0x9b, 0xdf, 0x3b, 0x6f, 0x01, 0x48, 0xb5, 0xfa, 0x94, - 0xb2, 0x23, 0xff, 0x51, 0xaa, 0x1d, 0xf0, 0x2a, 0x1f, 0xa8, 0xa0, 0x1a, 0x33, 0xa4, 0xb4, 0xe2, 0xaa, 0x13, 0x49, - 0xd0, 0xdb, 0xa2, 0x64, 0xb0, 0x91, 0x29, 0xec, 0x43, 0x5e, 0x94, 0xe8, 0xfb, 0x52, 0xc9, 0x72, 0x0b, 0xaf, 0x1c, - 0xcb, 0x5a, 0xee, 0x1b, 0x25, 0x7e, 0x98, 0x20, 0x3f, 0x0d, 0x2f, 0x32, 0xf2, 0xe4, 0x6e, 0x71, 0x24, 0xf0, 0xf1, - 0x49, 0x26, 0x82, 0x7d, 0x03, 0x79, 0x92, 0x5c, 0x3c, 0x89, 0x44, 0x65, 0x62, 0xbb, 0xe4, 0xe8, 0xe8, 0xde, 0x24, - 0xa9, 0xd7, 0xd2, 0xe8, 0x50, 0xe8, 0xb8, 0x8d, 0x42, 0x6d, 0x1d, 0xcf, 0xd9, 0x94, 0x8d, 0x47, 0x77, 0xc9, 0x76, - 0x11, 0xb7, 0x87, 0xd2, 0x08, 0x95, 0xd4, 0x26, 0xe8, 0x58, 0x9a, 0x06, 0x91, 0xc7, 0x03, 0x5b, 0x84, 0x88, 0x3e, - 0x9b, 0x4a, 0x67, 0x39, 0xc4, 0x6a, 0x3b, 0x1f, 0x58, 0x8e, 0x2d, 0x87, 0x2c, 0x09, 0x28, 0x9a, 0x95, 0x22, 0xe1, - 0x60, 0xe0, 0x38, 0x9a, 0xa3, 0x4a, 0x81, 0x31, 0x73, 0x35, 0x87, 0x9d, 0xaf, 0x33, 0x72, 0x2c, 0x8d, 0x34, 0x1b, - 0xbe, 0x2e, 0x45, 0x77, 0x6b, 0xeb, 0x63, 0x6d, 0x44, 0x32, 0xb2, 0xc9, 0x9e, 0xcb, 0xdc, 0x13, 0x56, 0xe9, 0xa9, - 0xdc, 0x8d, 0x95, 0x94, 0x15, 0xe7, 0xf9, 0x64, 0xb4, 0xdb, 0x90, 0x45, 0xab, 0x06, 0xab, 0xf1, 0x25, 0xd3, 0xee, - 0xb3, 0x2f, 0xb5, 0x0d, 0xda, 0x6a, 0x52, 0x17, 0x68, 0xce, 0xc3, 0xba, 0xc2, 0xdf, 0xc8, 0x13, 0x38, 0x93, 0x9e, - 0xbd, 0xea, 0x2e, 0x3f, 0xaa, 0x51, 0xda, 0xee, 0x2d, 0x5c, 0x31, 0x8f, 0xb9, 0x0a, 0xc1, 0xae, 0xd5, 0x44, 0x4f, - 0x66, 0x90, 0xc3, 0xf7, 0x04, 0x5c, 0x8e, 0xfc, 0x66, 0x80, 0xed, 0xd9, 0x38, 0x97, 0xb4, 0x53, 0xde, 0x27, 0x2d, - 0x45, 0x7e, 0xc6, 0x4d, 0xd6, 0x9c, 0x28, 0xff, 0x97, 0x42, 0xac, 0xb2, 0xe0, 0x0b, 0xeb, 0x23, 0xeb, 0xef, 0xd1, - 0xa9, 0x4e, 0x79, 0xeb, 0xd5, 0x8c, 0x7e, 0x2b, 0x2a, 0x4c, 0xd8, 0x3b, 0xa0, 0xc1, 0x64, 0xc7, 0x5a, 0x0d, 0xed, - 0x6e, 0xd9, 0xd1, 0xa2, 0x38, 0x6d, 0xcf, 0x68, 0x55, 0x7b, 0x21, 0x23, 0x1e, 0x7e, 0x6e, 0x34, 0x12, 0x8b, 0xa4, - 0x58, 0x40, 0xe7, 0x2b, 0xea, 0xfd, 0xb5, 0x9c, 0xd3, 0x93, 0xd6, 0x64, 0xf0, 0xa8, 0x33, 0xa7, 0xce, 0x55, 0x9f, - 0xbd, 0xde, 0xd5, 0xa1, 0xf2, 0x27, 0xe2, 0xfa, 0x13, 0x34, 0x55, 0x55, 0x73, 0xd7, 0x4a, 0x90, 0x9a, 0xb2, 0x6c, - 0xe2, 0xc8, 0xa7, 0xc9, 0xf7, 0xf9, 0x4c, 0x87, 0xec, 0xc3, 0x75, 0xa8, 0x8a, 0x17, 0x23, 0xb1, 0x05, 0x21, 0xb9, - 0x70, 0x82, 0x65, 0x51, 0xdf, 0x63, 0x29, 0x8a, 0xa3, 0x84, 0x92, 0xe1, 0x05, 0x8a, 0xc0, 0x51, 0x0b, 0x0c, 0x94, - 0xe4, 0x44, 0x1d, 0x1a, 0xc9, 0xce, 0xd3, 0xc1, 0x8b, 0x4f, 0x6c, 0xf2, 0x2d, 0xa1, 0x43, 0x3a, 0x43, 0x79, 0x05, - 0xdf, 0x8b, 0x77, 0x45, 0x9e, 0x30, 0xd5, 0xd2, 0x31, 0xcf, 0xbd, 0x66, 0xfa, 0xd8, 0x35, 0x78, 0x21, 0xba, 0x0e, - 0x97, 0x67, 0x48, 0x19, 0xab, 0x48, 0xd5, 0x34, 0xed, 0x87, 0x77, 0x87, 0x28, 0x49, 0x55, 0xb6, 0xdb, 0x7b, 0xa7, - 0x2d, 0x44, 0x09, 0xa4, 0xc9, 0xba, 0x0d, 0x8c, 0x64, 0x7a, 0xce, 0xb1, 0x2a, 0x45, 0x31, 0x86, 0x19, 0x82, 0x5c, - 0xe8, 0xb0, 0x15, 0x92, 0x4a, 0x3f, 0xca, 0x22, 0xe8, 0x26, 0x9d, 0xf1, 0x60, 0x96, 0x81, 0x51, 0xe1, 0xf8, 0xa4, - 0xde, 0x85, 0x77, 0x6d, 0x73, 0x72, 0x68, 0x15, 0x69, 0x02, 0x75, 0x2c, 0x90, 0x1e, 0xe5, 0x6f, 0xbe, 0x7b, 0x8c, - 0x6b, 0xd3, 0xaf, 0x8b, 0x55, 0x21, 0x33, 0x76, 0x02, 0x07, 0x90, 0x69, 0xbb, 0xf9, 0x9d, 0x7d, 0xa3, 0x24, 0x05, - 0x23, 0xad, 0xe7, 0x9e, 0x19, 0x5c, 0x8c, 0xc9, 0x42, 0xb4, 0xad, 0x76, 0xd3, 0x47, 0x07, 0x6d, 0x64, 0xe5, 0x35, - 0x00, 0xab, 0x24, 0x2d, 0x39, 0x1b, 0xc0, 0xc2, 0x6a, 0xc7, 0x36, 0x4c, 0x2f, 0x0d, 0x80, 0x4d, 0xbf, 0x05, 0x58, - 0xc0, 0x0b, 0xa2, 0xfd, 0xcc, 0xbc, 0xd2, 0x2c, 0xc2, 0x03, 0xef, 0x13, 0x80, 0x0d, 0xb1, 0x52, 0x51, 0x2d, 0x16, - 0x0b, 0xaf, 0x14, 0x0b, 0x5a, 0xd3, 0xcc, 0x96, 0x4e, 0xc0, 0xfa, 0x72, 0x47, 0xa1, 0x3a, 0xe5, 0xaf, 0xa8, 0xc4, - 0x9a, 0x43, 0x55, 0xf1, 0xd1, 0xfd, 0x66, 0x7d, 0x9a, 0x62, 0x91, 0xda, 0xa7, 0xd6, 0x93, 0x0c, 0xf0, 0x76, 0x8b, - 0xe7, 0xc0, 0x4b, 0xcb, 0xa2, 0xf7, 0xbd, 0x9d, 0xb5, 0x64, 0x51, 0xdd, 0x72, 0x7c, 0xd5, 0xca, 0xe4, 0x74, 0x25, - 0x97, 0x82, 0x32, 0x14, 0xf9, 0x9e, 0x27, 0x49, 0x21, 0xae, 0x4f, 0x1f, 0xcc, 0x7c, 0x84, 0x71, 0x65, 0xc6, 0x8b, - 0x6f, 0xc5, 0xc3, 0x8c, 0x27, 0xa5, 0x48, 0x6a, 0x79, 0x51, 0x01, 0xa9, 0xc6, 0x28, 0xbe, 0x20, 0x43, 0xcf, 0xf9, - 0x7a, 0xd4, 0xa7, 0xb8, 0x73, 0xb6, 0x24, 0x0e, 0xa2, 0x70, 0x98, 0x3e, 0x2b, 0x54, 0x08, 0xfe, 0x3b, 0x20, 0x26, - 0x19, 0x82, 0x00, 0x73, 0x22, 0xa1, 0x0e, 0xb2, 0xf0, 0x84, 0x67, 0x7b, 0xc9, 0x68, 0x25, 0xa3, 0x13, 0xa9, 0x32, - 0x11, 0x51, 0xf9, 0xed, 0xca, 0x26, 0x41, 0xb3, 0xec, 0xa5, 0x28, 0xdc, 0x88, 0x25, 0x11, 0x5c, 0x2b, 0x83, 0x9b, - 0x7e, 0x44, 0xa9, 0xee, 0x96, 0x86, 0xeb, 0x8c, 0x65, 0x72, 0xe1, 0x1b, 0x6f, 0xe8, 0xfa, 0xd2, 0xf5, 0x67, 0xe4, - 0xb6, 0xa8, 0xf0, 0xc9, 0x47, 0xf2, 0xc0, 0xb9, 0xbe, 0x9a, 0x66, 0x3a, 0xc7, 0xbc, 0x8b, 0x50, 0x5c, 0x63, 0xb2, - 0xcd, 0x7e, 0xdb, 0x2c, 0xc1, 0xeb, 0xfc, 0x59, 0xb2, 0x9c, 0xa6, 0x02, 0x76, 0x31, 0xf1, 0x8b, 0xa0, 0x44, 0x1b, - 0xae, 0x7a, 0xff, 0xde, 0xd6, 0x7a, 0xf7, 0xd9, 0x07, 0x1c, 0x16, 0xe5, 0x6f, 0xd4, 0xf6, 0x0c, 0x28, 0xa8, 0xe4, - 0xb9, 0xab, 0xd6, 0x80, 0xb1, 0x1d, 0xc3, 0x0f, 0x51, 0x1f, 0xed, 0x1a, 0xa0, 0xae, 0xd9, 0xca, 0x29, 0x06, 0x63, - 0x00, 0x1d, 0xdd, 0xfa, 0xd4, 0x20, 0x75, 0x58, 0xd8, 0x94, 0xd6, 0xdb, 0x58, 0xbc, 0xd0, 0x22, 0xe6, 0x72, 0x95, - 0x2c, 0xad, 0xf9, 0x1a, 0xfe, 0x37, 0xb8, 0xa6, 0xe0, 0x77, 0x94, 0xbf, 0x92, 0x78, 0xd7, 0x9d, 0xd3, 0x0a, 0x89, - 0x82, 0x79, 0x2e, 0xbc, 0x22, 0xc2, 0x4a, 0x9f, 0x10, 0x73, 0xcc, 0x75, 0x99, 0x93, 0x7d, 0xe1, 0xd0, 0x1a, 0xa9, - 0x43, 0xc0, 0xa5, 0xfb, 0x1e, 0x4f, 0x2f, 0xf8, 0x12, 0x5d, 0x1d, 0xdf, 0xcb, 0x3c, 0x9a, 0x01, 0xab, 0xba, 0x6f, - 0x31, 0x09, 0x53, 0x51, 0x46, 0x09, 0x41, 0xdc, 0x54, 0x22, 0x0b, 0x43, 0xcf, 0x1a, 0x57, 0x1f, 0x3b, 0xad, 0xa7, - 0x0c, 0x00, 0x28, 0x25, 0x09, 0xdd, 0x33, 0x94, 0x31, 0xa3, 0x97, 0x56, 0x81, 0x72, 0xcb, 0xd5, 0xc1, 0xcb, 0xce, - 0x3d, 0x86, 0x81, 0x9d, 0xd9, 0x5a, 0x67, 0x1a, 0x07, 0x22, 0xcb, 0x40, 0x80, 0x38, 0xc8, 0xb7, 0xa9, 0xd2, 0x58, - 0x74, 0x03, 0xd4, 0xb5, 0xa8, 0x0f, 0xd2, 0x8e, 0x2c, 0xc4, 0x19, 0x26, 0x3a, 0x06, 0xf6, 0xa7, 0x97, 0x68, 0xa4, - 0xa4, 0x42, 0xe0, 0x95, 0x31, 0xf3, 0x40, 0x22, 0x7b, 0xa0, 0x1d, 0x94, 0x0d, 0x80, 0x24, 0x67, 0x8e, 0x2b, 0x05, - 0x69, 0x2d, 0x23, 0x96, 0xd0, 0xff, 0x13, 0x2b, 0x8d, 0x32, 0x01, 0xf9, 0xc8, 0xa1, 0x4d, 0x49, 0xe3, 0x79, 0x78, - 0x2d, 0x1c, 0x48, 0x3e, 0x4c, 0x7f, 0x9c, 0x05, 0x8d, 0xc8, 0x94, 0xd3, 0xb9, 0x15, 0x6c, 0xe3, 0x8b, 0x3b, 0x4f, - 0x23, 0x51, 0x61, 0xfa, 0xcc, 0x77, 0x96, 0xb7, 0x93, 0x55, 0x84, 0xe5, 0x8f, 0xb9, 0xec, 0xa7, 0xe8, 0x7f, 0xad, - 0xa2, 0x24, 0xc9, 0x06, 0x5f, 0x2a, 0x99, 0x8e, 0xdb, 0xf3, 0x6b, 0xad, 0x8d, 0x96, 0xee, 0x01, 0xce, 0x78, 0x0f, - 0xaa, 0x3b, 0x12, 0x7a, 0xc8, 0x69, 0xcd, 0x53, 0x87, 0xa8, 0xaa, 0xa0, 0x84, 0x44, 0x63, 0x5c, 0xa4, 0xd6, 0x44, - 0xea, 0x9b, 0xc5, 0xd3, 0x00, 0x92, 0x69, 0x0c, 0x2a, 0xaa, 0xdc, 0x57, 0xcf, 0x6c, 0x5e, 0x4c, 0x4f, 0xa4, 0xc9, - 0x74, 0x41, 0x2e, 0x3f, 0xc5, 0xe8, 0x66, 0x4a, 0xc9, 0xb2, 0x58, 0x86, 0xc3, 0x1e, 0x23, 0xcc, 0x01, 0x43, 0x44, - 0xa5, 0xc2, 0x78, 0xc3, 0xa4, 0xbc, 0x9f, 0x94, 0xfc, 0x69, 0x8a, 0xf3, 0x0b, 0x61, 0xf5, 0x61, 0x5b, 0x07, 0xb8, - 0x3a, 0x07, 0xe5, 0x08, 0xb7, 0x96, 0x07, 0xd8, 0xd8, 0x98, 0x47, 0x7b, 0x39, 0x55, 0x25, 0x22, 0xd3, 0xac, 0x3b, - 0x58, 0x52, 0xde, 0xa4, 0xef, 0x0c, 0x99, 0xb0, 0x75, 0x33, 0x14, 0x41, 0x6e, 0x3c, 0xc9, 0xae, 0xb1, 0xd5, 0x07, - 0x81, 0xb3, 0x7a, 0x44, 0x5e, 0xc6, 0xa3, 0xaa, 0xce, 0xaa, 0xed, 0x94, 0xfc, 0x34, 0xbc, 0x4f, 0xae, 0x3b, 0xc9, - 0x09, 0x95, 0xd5, 0x24, 0x2a, 0x0a, 0x8a, 0xc2, 0xf3, 0x24, 0x20, 0x82, 0xe2, 0x8c, 0xfa, 0xa1, 0x8a, 0xfa, 0xa6, - 0xc8, 0xfe, 0x71, 0xea, 0xd5, 0xbe, 0xe5, 0x25, 0x80, 0x24, 0x3f, 0x93, 0x49, 0x77, 0x8c, 0x7b, 0x67, 0x5d, 0x1e, - 0x3e, 0x4a, 0x14, 0xe6, 0x3e, 0x14, 0x57, 0x31, 0x49, 0x51, 0x2c, 0x01, 0xf7, 0xca, 0x65, 0x2f, 0x1e, 0x49, 0x3a, - 0x41, 0x66, 0xa8, 0x5c, 0x1b, 0xef, 0x9b, 0x7a, 0xa4, 0xea, 0x49, 0x96, 0x02, 0x79, 0xe4, 0xee, 0x77, 0x46, 0x50, - 0xf2, 0xfc, 0xb7, 0xec, 0x8a, 0xd7, 0x49, 0x79, 0x86, 0x36, 0x1b, 0x22, 0x7a, 0x5d, 0x8e, 0x36, 0x05, 0xcc, 0x31, - 0x23, 0x02, 0xfd, 0x73, 0xb0, 0x0a, 0xf9, 0xb2, 0xe3, 0x14, 0xdb, 0x54, 0x01, 0x4a, 0xb9, 0xf7, 0xfd, 0x55, 0x1e, - 0x08, 0xfb, 0x33, 0x92, 0x9c, 0x49, 0x46, 0x44, 0x50, 0xb6, 0xc0, 0x23, 0xb0, 0x2f, 0xa4, 0x8b, 0x13, 0xb5, 0x0a, - 0xfb, 0x82, 0x9a, 0xb3, 0x67, 0xa9, 0x88, 0xea, 0x14, 0x7d, 0xfc, 0xca, 0xb8, 0x60, 0x2e, 0xab, 0x91, 0xb6, 0x22, - 0x5a, 0x9e, 0xaa, 0x04, 0x04, 0xb5, 0x10, 0x0b, 0xb1, 0xa9, 0x80, 0x60, 0x7c, 0xaf, 0xa7, 0x27, 0x18, 0x31, 0x0b, - 0xc5, 0x8b, 0xcc, 0xe5, 0x44, 0xbb, 0xfc, 0x87, 0x9b, 0x30, 0x9d, 0x33, 0x86, 0x34, 0x22, 0xa9, 0x47, 0xd6, 0xef, - 0x5f, 0x2f, 0x2f, 0x54, 0x65, 0x13, 0x49, 0x65, 0x23, 0xee, 0xe6, 0x73, 0x9d, 0x1b, 0xd3, 0x44, 0x70, 0xc5, 0x92, - 0xd9, 0x62, 0xe3, 0xe9, 0xfc, 0xc3, 0x95, 0x59, 0x48, 0xd7, 0x44, 0x39, 0x92, 0xc8, 0x4f, 0x0a, 0xc1, 0x43, 0x8d, - 0xf2, 0x42, 0x18, 0x91, 0xfa, 0x6f, 0x86, 0xdc, 0x75, 0x29, 0xda, 0xd5, 0x46, 0x75, 0xd9, 0x02, 0xd8, 0xd2, 0xd7, - 0x30, 0x32, 0x14, 0x42, 0x47, 0x0c, 0x92, 0xdc, 0xa5, 0x3e, 0x2a, 0x19, 0xc8, 0xa2, 0x2b, 0xcc, 0x40, 0x99, 0x7b, - 0xe8, 0xe4, 0x8d, 0x93, 0x28, 0x01, 0xb9, 0x9f, 0x99, 0x4f, 0xea, 0xec, 0x24, 0xf2, 0x62, 0x2d, 0x05, 0x74, 0xa4, - 0xba, 0x4e, 0x25, 0x56, 0x59, 0xad, 0x84, 0x9e, 0x08, 0x76, 0x17, 0xcd, 0xe0, 0x55, 0x9b, 0xe7, 0xe9, 0xb1, 0xe6, - 0x9f, 0xc7, 0x57, 0x94, 0xb7, 0x35, 0x91, 0x16, 0x74, 0x22, 0xb4, 0xfa, 0xc0, 0x7d, 0xdd, 0x5c, 0xd9, 0x1a, 0xe4, - 0x65, 0x01, 0xd0, 0x72, 0xce, 0x72, 0x7a, 0x12, 0xca, 0xba, 0x79, 0x5e, 0x26, 0x99, 0x7b, 0x15, 0x07, 0x5b, 0x40, - 0x73, 0x01, 0x37, 0xc1, 0x67, 0x1f, 0x27, 0xa4, 0x7e, 0xa8, 0x3c, 0x56, 0x36, 0xdf, 0xd6, 0x60, 0xee, 0xdb, 0xc4, - 0xe9, 0xb0, 0xd9, 0x24, 0x22, 0x26, 0x73, 0x37, 0xb6, 0xde, 0x08, 0x67, 0x2d, 0x54, 0xed, 0x11, 0xf3, 0x84, 0x00, - 0x53, 0xd5, 0x20, 0x7c, 0xda, 0xc7, 0x49, 0x4c, 0x6f, 0x11, 0x15, 0xa0, 0x5c, 0x62, 0x52, 0xaf, 0xdc, 0xa5, 0xa5, - 0xd6, 0xbd, 0x4f, 0x17, 0x58, 0x57, 0xba, 0x78, 0xbc, 0xd3, 0x7d, 0xe0, 0x00, 0x70, 0x3f, 0x83, 0xaa, 0x55, 0x5e, - 0xaa, 0xea, 0x0b, 0x6a, 0x69, 0x82, 0x94, 0x04, 0xbc, 0x55, 0x49, 0xef, 0xe7, 0x99, 0x06, 0x82, 0xe6, 0x6b, 0x64, - 0x75, 0xe4, 0x0b, 0x91, 0xc8, 0x43, 0xcf, 0x4b, 0x7c, 0xbc, 0x08, 0xcf, 0x09, 0x1e, 0xbf, 0x8c, 0xad, 0x0b, 0x3a, - 0x65, 0xfe, 0x20, 0x81, 0xe5, 0x40, 0xed, 0xda, 0xe5, 0xeb, 0x38, 0x11, 0xec, 0x14, 0x05, 0xea, 0x29, 0x2a, 0x40, - 0x83, 0x40, 0xd1, 0x48, 0x0b, 0xe8, 0x24, 0xf9, 0x39, 0xa6, 0x05, 0x84, 0xd4, 0x29, 0x10, 0x31, 0xdf, 0x0e, 0xcb, - 0x11, 0xdc, 0x95, 0x22, 0x27, 0x9e, 0x38, 0x37, 0x6b, 0xe5, 0xcb, 0x7d, 0x88, 0xaa, 0x73, 0x7f, 0x7c, 0x83, 0x3b, - 0x70, 0x15, 0xdb, 0x8d, 0xe3, 0x1f, 0x71, 0xbf, 0x49, 0x16, 0x72, 0x0e, 0x44, 0x8a, 0xbc, 0x1c, 0x11, 0x22, 0x13, - 0x87, 0x3a, 0xdc, 0x84, 0x90, 0x8e, 0x2f, 0xa0, 0x3f, 0x8e, 0x98, 0xc6, 0x56, 0x9d, 0x26, 0x20, 0xe7, 0x3c, 0xbe, - 0x3d, 0x9d, 0xde, 0xba, 0xa8, 0x1e, 0x44, 0xdb, 0x22, 0xe2, 0x87, 0xb6, 0xa8, 0x51, 0xa8, 0x3c, 0x9c, 0x5a, 0x5f, - 0x53, 0xc3, 0x31, 0xc4, 0xe1, 0xdf, 0x06, 0x48, 0x00, 0x85, 0xdd, 0x26, 0xd7, 0x5c, 0xd0, 0xe9, 0x9d, 0xa4, 0x23, - 0xb4, 0xd6, 0xf4, 0x53, 0xb9, 0x6a, 0xd6, 0xc1, 0xca, 0xb4, 0xd3, 0xfb, 0x6c, 0xe3, 0xb6, 0x38, 0x01, 0x41, 0xb4, - 0xd2, 0xeb, 0x9b, 0x30, 0x61, 0x89, 0x31, 0x06, 0xde, 0x17, 0x62, 0xce, 0x53, 0x98, 0x49, 0xcc, 0xc7, 0x70, 0xb4, - 0x3a, 0x8b, 0x77, 0x6e, 0xd1, 0xa5, 0xbd, 0xd1, 0x9b, 0x36, 0x92, 0xa9, 0x84, 0x8e, 0x05, 0xf0, 0xc7, 0xe9, 0xa8, - 0x1d, 0x71, 0x07, 0x04, 0xd8, 0xca, 0x12, 0xe3, 0xd2, 0x2d, 0xd8, 0xaa, 0x6c, 0xf9, 0xb4, 0x71, 0xae, 0xdc, 0xcf, - 0xd6, 0x2e, 0x74, 0x44, 0x70, 0x58, 0x97, 0x34, 0x07, 0xe6, 0x63, 0xc1, 0x5c, 0x8a, 0x8b, 0xd5, 0x4e, 0x01, 0x12, - 0xb4, 0x92, 0x3c, 0x5c, 0x66, 0x48, 0x7a, 0x7c, 0xa2, 0x2e, 0x12, 0x72, 0xc6, 0x8d, 0xb6, 0x06, 0xec, 0xe0, 0xdd, - 0x5e, 0x8f, 0xb4, 0xee, 0xbc, 0x45, 0xde, 0x8b, 0xe2, 0x05, 0xa4, 0x9a, 0x02, 0x71, 0x65, 0x83, 0x20, 0xed, 0x3a, - 0x25, 0xac, 0xbf, 0x19, 0x2c, 0x8d, 0xdb, 0x77, 0x6d, 0x4a, 0x0f, 0x7a, 0x76, 0xa6, 0x87, 0x5c, 0xf8, 0xb3, 0xa2, - 0x6f, 0x5f, 0x79, 0xcb, 0x36, 0xec, 0xa7, 0xa5, 0x00, 0xb2, 0xba, 0xb8, 0x1b, 0xe7, 0xbc, 0x60, 0x8b, 0xa5, 0xc9, - 0xe9, 0xab, 0x65, 0x85, 0x9a, 0xc0, 0x1e, 0x7c, 0xa0, 0x65, 0xa4, 0x52, 0x5f, 0x29, 0x29, 0x5a, 0x1e, 0x1a, 0x93, - 0x6c, 0x6d, 0x6a, 0x85, 0xb8, 0xaa, 0x06, 0xab, 0xea, 0xe1, 0x12, 0x4b, 0x4b, 0xdb, 0x52, 0x0b, 0xcd, 0x75, 0xef, - 0x05, 0x98, 0x7c, 0x8f, 0x1e, 0xb1, 0xbc, 0x00, 0xba, 0xfb, 0x85, 0x7c, 0x19, 0x87, 0x41, 0x5a, 0x54, 0x41, 0x00, - 0xe9, 0x75, 0x1d, 0xc3, 0xa6, 0x61, 0x8d, 0x89, 0x0e, 0x8b, 0x3e, 0x8d, 0x40, 0x45, 0xa8, 0x81, 0x21, 0xd8, 0x42, - 0xae, 0x4c, 0xc5, 0xd2, 0xa9, 0x97, 0xc9, 0xe2, 0xd2, 0xe7, 0x5e, 0x6c, 0x6b, 0xd2, 0x15, 0xb3, 0x54, 0x41, 0x5c, - 0x1a, 0x75, 0xbd, 0xd1, 0x37, 0x6a, 0x79, 0xd0, 0x35, 0xde, 0xe3, 0x26, 0x19, 0xd6, 0xa6, 0x72, 0x7d, 0x94, 0x6c, - 0xb7, 0xff, 0x59, 0xb9, 0x44, 0xb5, 0xe4, 0x2c, 0xad, 0xb1, 0xea, 0x61, 0x8b, 0x02, 0x5c, 0xbe, 0xe3, 0x4e, 0xc6, - 0x00, 0x59, 0x8e, 0xb4, 0x61, 0x6e, 0x1d, 0xce, 0x65, 0x1b, 0x68, 0xfb, 0xcd, 0xa7, 0x92, 0x60, 0xeb, 0x57, 0x4f, - 0xa7, 0xb1, 0x4d, 0x91, 0xc3, 0x28, 0x70, 0x14, 0x9e, 0xbb, 0xe7, 0xbc, 0x5a, 0x29, 0xe3, 0x12, 0xdb, 0xed, 0x73, - 0xd3, 0x4f, 0x5e, 0xd9, 0x86, 0xf5, 0x14, 0x5f, 0x8f, 0x91, 0x8d, 0xbd, 0xe7, 0xc9, 0x7a, 0x32, 0x16, 0x77, 0x0c, - 0x80, 0x8b, 0x92, 0x62, 0xb8, 0x5b, 0xc1, 0xc5, 0x83, 0x5f, 0xf8, 0x7c, 0x2a, 0xa7, 0x9b, 0x34, 0xee, 0xcd, 0xbf, - 0xed, 0xed, 0x85, 0x07, 0xcd, 0x24, 0x7d, 0x99, 0xa5, 0xcb, 0x2a, 0xb9, 0x16, 0xc8, 0xb5, 0x1b, 0x9e, 0x8b, 0x72, - 0xbd, 0x69, 0x6b, 0x23, 0x4c, 0x50, 0xbc, 0x1c, 0xf8, 0xdb, 0xbb, 0xf8, 0xed, 0xb9, 0xec, 0xf9, 0xb9, 0xb7, 0x68, - 0x08, 0x31, 0xdf, 0xbc, 0x8f, 0x2a, 0x2b, 0x8e, 0x63, 0xe2, 0x7d, 0x3e, 0x34, 0xde, 0xeb, 0x1a, 0x2d, 0x63, 0xae, - 0xf0, 0xf3, 0x18, 0xaa, 0xda, 0xc7, 0xcd, 0x2d, 0xff, 0x6c, 0x77, 0x9d, 0x95, 0xa2, 0x30, 0x9b, 0xc9, 0xc3, 0xd2, - 0x94, 0xb4, 0x3b, 0x0e, 0x36, 0xdc, 0x3e, 0x2b, 0x00, 0x73, 0x00, 0x2c, 0x8f, 0x74, 0x7d, 0x16, 0x7b, 0x16, 0xca, - 0xb6, 0x8b, 0x38, 0x54, 0x6f, 0x61, 0x57, 0xdc, 0x9c, 0xe5, 0x59, 0xea, 0x6e, 0xe3, 0x0b, 0x03, 0x54, 0x3d, 0xe4, - 0x8e, 0x39, 0x92, 0x96, 0x09, 0xa6, 0x4a, 0x7e, 0xbb, 0x71, 0xdc, 0xcc, 0x19, 0x3b, 0xf1, 0x0c, 0xb3, 0x79, 0xea, - 0x0d, 0x2b, 0x9a, 0xf7, 0xed, 0x2b, 0xf7, 0x34, 0x30, 0xf1, 0xad, 0x8d, 0xca, 0x54, 0xf6, 0x75, 0x00, 0x94, 0x2c, - 0xd1, 0x9f, 0x76, 0x51, 0x5a, 0x57, 0x08, 0xa3, 0xc2, 0xa9, 0xf2, 0x0f, 0xd6, 0x92, 0x56, 0x31, 0x11, 0x8b, 0xa3, - 0x23, 0xcd, 0x19, 0xe0, 0x96, 0x78, 0xcb, 0xa8, 0x03, 0xc5, 0x98, 0xd1, 0xc6, 0x4c, 0xca, 0x6a, 0x8f, 0x66, 0x07, - 0xc2, 0xc8, 0x73, 0x6d, 0x11, 0xe9, 0x28, 0x60, 0xbd, 0x54, 0x70, 0xe0, 0x37, 0xef, 0x55, 0xa0, 0x79, 0xdf, 0xb3, - 0x01, 0xe5, 0x00, 0xee, 0x37, 0x74, 0x94, 0xd4, 0xa6, 0x8d, 0xbf, 0xe4, 0x8a, 0xd1, 0xd5, 0x83, 0x24, 0xd0, 0x36, - 0x63, 0xc0, 0x07, 0x4c, 0xae, 0xa8, 0x42, 0xfa, 0x34, 0x46, 0xde, 0x28, 0x90, 0x9c, 0x63, 0xd3, 0x50, 0x4c, 0x3b, - 0xac, 0x27, 0x91, 0x94, 0x0e, 0x22, 0x64, 0x8a, 0xc5, 0xf4, 0xa0, 0x0e, 0x96, 0x64, 0xa4, 0x75, 0x2a, 0x6f, 0x45, - 0x47, 0xfd, 0x9e, 0x8d, 0xa0, 0x39, 0xb6, 0xac, 0x2a, 0xd4, 0x37, 0xcb, 0x2d, 0x13, 0x95, 0x74, 0xf3, 0x6c, 0x2a, - 0x1f, 0x97, 0x83, 0xc8, 0xa6, 0x69, 0xc7, 0x6f, 0xfb, 0xbc, 0xc7, 0x0c, 0xee, 0x62, 0x84, 0x82, 0xac, 0x6d, 0xc8, - 0x60, 0x8f, 0x3c, 0x5c, 0xd0, 0x2d, 0xfd, 0x40, 0xa1, 0xdf, 0xae, 0x96, 0x00, 0x7e, 0x4a, 0xe0, 0x2b, 0x41, 0x6f, - 0x37, 0xb9, 0x53, 0xbb, 0xce, 0x3d, 0xef, 0x13, 0xd9, 0x0b, 0x27, 0x0f, 0x92, 0x6d, 0x5b, 0xa2, 0x6d, 0xd5, 0x8d, - 0x5b, 0xfe, 0xb1, 0xc3, 0x4f, 0x4a, 0x53, 0x44, 0xad, 0x49, 0xea, 0xb4, 0xb1, 0xdc, 0x12, 0xb5, 0xa3, 0xc1, 0x51, - 0xba, 0x11, 0x5e, 0xb8, 0xdf, 0x86, 0xfd, 0x86, 0x82, 0xb1, 0x1c, 0xbd, 0x72, 0x17, 0x1d, 0x0b, 0x68, 0x1c, 0x29, - 0xe8, 0xd8, 0x1e, 0x47, 0xb5, 0x31, 0x86, 0x72, 0xcc, 0xde, 0x70, 0x0c, 0x65, 0x35, 0x06, 0x6a, 0x63, 0xeb, 0x26, - 0x74, 0x37, 0x9e, 0x88, 0xe4, 0x30, 0xa0, 0x71, 0x40, 0xea, 0x96, 0x19, 0xa9, 0xfc, 0x3a, 0x27, 0x2c, 0x10, 0x83, - 0x3b, 0xf6, 0x78, 0xa1, 0x7d, 0xc1, 0x30, 0xc4, 0x11, 0xe8, 0x16, 0x8f, 0x62, 0xb6, 0xa8, 0x0c, 0xf5, 0xe2, 0xca, - 0x5a, 0x98, 0xc0, 0xda, 0x11, 0xa2, 0x42, 0x7f, 0x6c, 0xf3, 0x5d, 0x3b, 0x14, 0xe4, 0x8a, 0x1f, 0xc6, 0xfe, 0x32, - 0xfd, 0x68, 0xe4, 0xa9, 0xa4, 0xff, 0x22, 0x8c, 0x7e, 0xea, 0x84, 0x95, 0x13, 0x40, 0xf0, 0x27, 0x48, 0x72, 0xdb, - 0x78, 0x3f, 0x4c, 0x69, 0xa6, 0xff, 0xb1, 0xb1, 0xe9, 0x8a, 0xf7, 0x43, 0x3f, 0xcc, 0x1f, 0x3a, 0x51, 0x07, 0xf9, - 0xa7, 0x5f, 0x3c, 0x74, 0x5c, 0x8f, 0xed, 0x63, 0x4c, 0xdd, 0xb1, 0xa5, 0xf9, 0x78, 0xec, 0xda, 0x4b, 0xb6, 0xdb, - 0xf6, 0xe3, 0xf0, 0x64, 0x78, 0x38, 0x64, 0x43, 0x1a, 0xb8, 0xf7, 0xfc, 0x72, 0x8e, 0x3d, 0x4f, 0xde, 0x3d, 0xf4, - 0xe9, 0x81, 0x9c, 0x8b, 0x94, 0x31, 0xd9, 0x2d, 0x9e, 0xb6, 0x5d, 0xa4, 0x34, 0x02, 0xd4, 0xd1, 0x1b, 0xe1, 0x63, - 0x41, 0xd7, 0x24, 0x55, 0xc8, 0xa0, 0x7c, 0x86, 0x49, 0xa3, 0xea, 0x0f, 0xf1, 0x0c, 0x85, 0x88, 0x83, 0xc0, 0x7f, - 0xf9, 0x67, 0x8f, 0xd6, 0x13, 0xa7, 0xd5, 0x69, 0x6d, 0x78, 0xec, 0xf7, 0x65, 0x97, 0xa5, 0x9e, 0x94, 0x51, 0xba, - 0xcd, 0xc4, 0x4b, 0x8c, 0xcc, 0x4d, 0x7e, 0xc8, 0xb1, 0x3d, 0x75, 0x0f, 0x93, 0xff, 0x42, 0x04, 0x45, 0xb8, 0xc7, - 0x02, 0x19, 0xef, 0x21, 0x50, 0x39, 0x15, 0xa2, 0x98, 0x96, 0x8b, 0xb3, 0x05, 0xb0, 0x6b, 0xf4, 0x4b, 0x64, 0x45, - 0x3c, 0x33, 0x9e, 0xdf, 0xb5, 0x36, 0xd7, 0x01, 0xfc, 0x7e, 0x6d, 0xf4, 0x64, 0x46, 0xab, 0x80, 0xac, 0xfb, 0xa0, - 0x0c, 0x2e, 0x09, 0x4f, 0xa5, 0x3d, 0x97, 0xd5, 0x58, 0xd3, 0x7e, 0xa0, 0x57, 0x33, 0xfd, 0x69, 0xfb, 0xac, 0x21, - 0x74, 0x3d, 0x9a, 0x29, 0x05, 0x54, 0xaa, 0x7c, 0x50, 0x66, 0x5f, 0x5f, 0x40, 0x38, 0xa2, 0x55, 0xc8, 0x2f, 0x15, - 0xa7, 0x87, 0xb1, 0x8d, 0x82, 0x20, 0xdf, 0x79, 0x86, 0xc8, 0x0f, 0xc9, 0x13, 0x2a, 0xec, 0xce, 0xfd, 0x02, 0xf4, - 0x45, 0x85, 0xa7, 0xf4, 0xfd, 0x69, 0x8e, 0xdb, 0xd5, 0xbc, 0x8f, 0xef, 0x03, 0x19, 0x25, 0x58, 0x46, 0xba, 0x39, - 0x74, 0xd2, 0xa8, 0x1d, 0x3d, 0xf2, 0x95, 0x48, 0x8e, 0x2e, 0xd0, 0xf4, 0x3d, 0xd6, 0x86, 0x17, 0x49, 0x4a, 0xd0, - 0xa7, 0x72, 0x2d, 0xc9, 0xb0, 0x57, 0x75, 0x60, 0x74, 0x44, 0xde, 0x5e, 0x8a, 0x0d, 0x90, 0x24, 0xd5, 0xd3, 0x12, - 0xa1, 0xfd, 0x50, 0xce, 0x7a, 0x53, 0x7e, 0x89, 0x7b, 0xf1, 0x84, 0x57, 0x46, 0x74, 0xc3, 0x5f, 0x7c, 0x13, 0xe2, - 0x5e, 0x28, 0xee, 0x8b, 0x02, 0x96, 0x25, 0x54, 0x11, 0x41, 0x6f, 0x1a, 0xa8, 0x1c, 0x0c, 0xfd, 0xb1, 0x28, 0xf0, - 0x6c, 0x05, 0x58, 0x96, 0x09, 0x29, 0x03, 0x47, 0x6c, 0x44, 0xff, 0x4a, 0x9a, 0xfa, 0x29, 0xa5, 0xb9, 0x6f, 0x49, - 0xbc, 0xec, 0x17, 0x84, 0x94, 0x37, 0x10, 0x0a, 0x82, 0x96, 0x0a, 0xde, 0x04, 0x29, 0x68, 0x4c, 0x3b, 0xcc, 0x95, - 0x41, 0xd9, 0xe3, 0xb8, 0x01, 0x2e, 0x5f, 0x39, 0xa8, 0x4d, 0xd5, 0xeb, 0x24, 0xb6, 0x2a, 0x6e, 0xf4, 0x9f, 0xe8, - 0xd6, 0xda, 0x0f, 0x07, 0x28, 0x82, 0xb6, 0x28, 0x9b, 0xf4, 0x8a, 0xc6, 0xb3, 0x30, 0x16, 0x96, 0x3d, 0x46, 0x0f, - 0x6a, 0x06, 0x4a, 0x2a, 0xac, 0x36, 0x54, 0x28, 0xe6, 0x53, 0xbb, 0x08, 0xa3, 0xf0, 0x41, 0x53, 0x19, 0x79, 0xf8, - 0xd0, 0x9d, 0x46, 0xef, 0xc6, 0x51, 0x2c, 0x72, 0x43, 0x9b, 0xd7, 0x2c, 0x45, 0xc2, 0xa4, 0x49, 0x3e, 0xbd, 0x6c, - 0xd6, 0xb3, 0x66, 0xd2, 0xb2, 0x15, 0x7a, 0xd5, 0x78, 0x63, 0x20, 0x52, 0xd4, 0x6f, 0xbe, 0x4e, 0x7a, 0xb5, 0x9e, - 0xc3, 0xec, 0x47, 0xc2, 0xf2, 0xa2, 0xe8, 0x7a, 0xa6, 0xdb, 0xbc, 0x6a, 0xa3, 0x3b, 0x73, 0xaa, 0xaf, 0xd4, 0x60, - 0x08, 0xf8, 0x95, 0x73, 0x79, 0x50, 0x26, 0xa8, 0x9c, 0xd8, 0x76, 0x0f, 0x6d, 0x46, 0x40, 0x07, 0xcf, 0xb2, 0xd3, - 0xcc, 0x97, 0xaf, 0x96, 0x49, 0x31, 0xac, 0x77, 0xa9, 0x43, 0x81, 0x97, 0x7b, 0x95, 0xfe, 0x81, 0x46, 0x95, 0x32, - 0xf2, 0x82, 0xa8, 0x3a, 0xd1, 0x5e, 0x70, 0x10, 0xc7, 0x1d, 0xfe, 0x3d, 0xe2, 0x70, 0xc9, 0x3d, 0x87, 0x1d, 0x40, - 0x4e, 0x59, 0x44, 0x3a, 0xca, 0xc7, 0x77, 0x8f, 0xbe, 0x65, 0xcc, 0x31, 0xd2, 0x65, 0xf5, 0x53, 0x11, 0x6d, 0x1f, - 0x51, 0x12, 0xe9, 0x0e, 0x07, 0xfb, 0x14, 0x21, 0xde, 0x6c, 0x8a, 0x41, 0x00, 0x2b, 0x74, 0xbe, 0x44, 0x74, 0x42, - 0x5a, 0xd4, 0x03, 0x0a, 0x87, 0xad, 0x82, 0xcf, 0x72, 0xc1, 0x09, 0x96, 0xfe, 0x10, 0x13, 0xab, 0x52, 0x24, 0x3b, - 0x34, 0xcb, 0xbf, 0x4c, 0x6d, 0xaf, 0x96, 0xa6, 0x51, 0x6d, 0x1e, 0xc1, 0x7d, 0xe3, 0xb2, 0xa4, 0x68, 0x05, 0x76, - 0x97, 0xbd, 0x54, 0xc8, 0xc2, 0x86, 0x6b, 0x2f, 0x79, 0xa6, 0x6d, 0x4b, 0x5e, 0x34, 0x78, 0x40, 0x12, 0xd8, 0x7c, - 0x01, 0xac, 0xff, 0x71, 0xb5, 0x2c, 0x43, 0x2d, 0x54, 0x35, 0x30, 0x42, 0xbe, 0xdb, 0x75, 0x04, 0xd1, 0x9e, 0x55, - 0x37, 0xbf, 0x06, 0x26, 0x5a, 0xf6, 0x26, 0xb0, 0x74, 0x90, 0x45, 0x0b, 0x81, 0x60, 0xe7, 0xfe, 0x7c, 0xed, 0xb2, - 0xd8, 0xce, 0x78, 0x8c, 0x35, 0x61, 0xe1, 0x11, 0xb9, 0x71, 0x80, 0x95, 0xc7, 0x65, 0x09, 0x42, 0x56, 0x94, 0x61, - 0x57, 0xee, 0x1c, 0x50, 0x8f, 0x85, 0x1a, 0x55, 0x08, 0xb2, 0xd6, 0x67, 0xaf, 0xa7, 0x8a, 0x35, 0xc9, 0xfd, 0x3e, - 0x28, 0x30, 0x38, 0x83, 0xbb, 0x4d, 0x45, 0x28, 0x7d, 0x48, 0xe1, 0x4f, 0x6d, 0xba, 0x3e, 0x4b, 0x7b, 0x9e, 0x82, - 0x49, 0xb1, 0x20, 0x5e, 0x2b, 0xf9, 0xe7, 0xe9, 0x2f, 0x12, 0xa8, 0x83, 0x94, 0xdc, 0x98, 0x3e, 0xe2, 0xb5, 0x11, - 0x42, 0x64, 0xac, 0xe7, 0xa0, 0x71, 0x20, 0x9c, 0x52, 0x30, 0xa8, 0x9c, 0xd9, 0x32, 0x8b, 0xe9, 0x78, 0x67, 0x4b, - 0x9d, 0x90, 0x6d, 0x0d, 0x3f, 0xf0, 0x66, 0x1a, 0xfb, 0x89, 0x70, 0xdd, 0xdc, 0xe4, 0x5b, 0x83, 0x67, 0xe8, 0x14, - 0x33, 0x7e, 0x93, 0x31, 0x14, 0xd3, 0xd6, 0x3d, 0x17, 0x4f, 0x4f, 0x4f, 0xc5, 0xa8, 0xb2, 0xb9, 0xe2, 0x61, 0xbc, - 0x1c, 0xab, 0x6a, 0x55, 0x15, 0xd3, 0x42, 0x2b, 0xab, 0xcf, 0x7f, 0x16, 0xc3, 0x25, 0xba, 0xc5, 0x70, 0xb6, 0x08, - 0x6d, 0xa2, 0x88, 0x16, 0x8d, 0x74, 0xcd, 0xd5, 0xfd, 0x4e, 0xdd, 0x95, 0xec, 0xe3, 0xab, 0x77, 0xfb, 0x1f, 0x12, - 0x46, 0xad, 0x97, 0xee, 0x14, 0x90, 0x57, 0x23, 0x9e, 0xf7, 0x5f, 0xcf, 0x29, 0xaf, 0x5a, 0x5e, 0x1a, 0x7d, 0x14, - 0x3c, 0x67, 0xfa, 0xdc, 0xd0, 0xf8, 0x45, 0xd3, 0x28, 0xcd, 0x3e, 0x50, 0x23, 0xbb, 0x81, 0xd6, 0x9b, 0xb4, 0x43, - 0xc6, 0x3b, 0x12, 0x7c, 0xb2, 0x42, 0x78, 0x69, 0xdc, 0x9e, 0x38, 0x89, 0x94, 0x62, 0x34, 0x55, 0x29, 0x54, 0xb5, - 0xce, 0x0a, 0x4d, 0x5b, 0x55, 0x21, 0xc9, 0x81, 0x03, 0xa5, 0x93, 0x21, 0xcc, 0xf1, 0xa4, 0x9c, 0xc4, 0x93, 0xa4, - 0x59, 0xcd, 0x43, 0x4e, 0x79, 0x51, 0x92, 0x86, 0xf4, 0x75, 0xe6, 0x14, 0x80, 0x66, 0x03, 0x25, 0x70, 0x28, 0x49, - 0x01, 0x66, 0x1a, 0xd2, 0x33, 0x44, 0x14, 0x82, 0x01, 0x7a, 0x73, 0x15, 0x13, 0x8f, 0x13, 0x6f, 0x1b, 0xed, 0xb2, - 0xa6, 0x20, 0x9e, 0x7c, 0xec, 0x7d, 0xb3, 0x98, 0xd6, 0x9d, 0x5c, 0x50, 0xc9, 0xf3, 0xc5, 0xd4, 0xd2, 0x04, 0xee, - 0x13, 0x32, 0xd5, 0x8c, 0xa9, 0x42, 0xfe, 0x4d, 0xee, 0xdb, 0xd1, 0x7e, 0x2c, 0x8e, 0xc5, 0xbb, 0x33, 0x34, 0xdd, - 0xcd, 0x55, 0x8e, 0xdc, 0x37, 0x23, 0xb9, 0xd5, 0xb2, 0xa6, 0x11, 0x84, 0x2c, 0x7c, 0xe1, 0x7a, 0xed, 0xf5, 0xf1, - 0x7d, 0xd6, 0xfd, 0xab, 0x0d, 0xc7, 0x8b, 0xe6, 0x25, 0x1f, 0xd2, 0x5d, 0x31, 0xb1, 0x68, 0xaf, 0xfc, 0x24, 0xa9, - 0x77, 0x6a, 0x3d, 0x66, 0xc2, 0xdd, 0x3f, 0x94, 0xa6, 0x31, 0xd3, 0x3b, 0xea, 0x78, 0x3f, 0xba, 0xc3, 0x1c, 0x8a, - 0x98, 0x6a, 0x58, 0xdd, 0x48, 0xa5, 0x5c, 0x98, 0x9e, 0x61, 0x63, 0xae, 0x4e, 0x3b, 0x4a, 0x4a, 0xd0, 0xa9, 0x5a, - 0xff, 0x51, 0x1e, 0xe1, 0x34, 0x55, 0xc1, 0x4f, 0x5e, 0x6d, 0xc7, 0xa2, 0x6b, 0x2f, 0x47, 0x6b, 0xd1, 0xb3, 0x1d, - 0xe5, 0x84, 0x7d, 0x7c, 0x8f, 0x50, 0x75, 0x7d, 0xb1, 0x3e, 0xfd, 0xb5, 0xfe, 0x56, 0xee, 0x06, 0x2a, 0x81, 0x3a, - 0x1b, 0xcb, 0xec, 0x5a, 0x13, 0x17, 0xb6, 0xbf, 0x6e, 0x53, 0xab, 0x06, 0x4e, 0xf6, 0x6a, 0xc3, 0xca, 0x9a, 0xcf, - 0x84, 0x6c, 0x7c, 0x93, 0xb2, 0x5f, 0x88, 0xe1, 0x27, 0xa9, 0x4d, 0x4d, 0x9b, 0xa4, 0xb5, 0xfc, 0x2c, 0xd7, 0xcd, - 0xdb, 0x56, 0xc4, 0xe9, 0xbe, 0x28, 0x82, 0x9c, 0x22, 0x09, 0xd9, 0xc6, 0x78, 0x84, 0xb0, 0x85, 0x0e, 0xe2, 0x5c, - 0xba, 0x88, 0xb1, 0x2c, 0x62, 0x78, 0x2f, 0x8f, 0x7d, 0x12, 0x6a, 0xda, 0x68, 0xa7, 0x2c, 0xb2, 0xff, 0x3e, 0xd3, - 0x8f, 0x8b, 0x2a, 0xa8, 0x03, 0x30, 0xbd, 0xbf, 0x6a, 0x7b, 0xb9, 0x38, 0xea, 0x37, 0x15, 0x07, 0x57, 0xff, 0x94, - 0x36, 0x37, 0x6c, 0xaa, 0xf9, 0x86, 0xa8, 0x54, 0xca, 0xbe, 0x18, 0xf4, 0x8c, 0xec, 0x55, 0xa3, 0x51, 0xcc, 0xa7, - 0xd0, 0xb2, 0x44, 0xfc, 0xf1, 0x54, 0x28, 0x6a, 0xa8, 0xe6, 0x2e, 0xe4, 0xe4, 0xd8, 0x30, 0xf6, 0x27, 0x93, 0xdd, - 0x9e, 0xb6, 0xea, 0xa7, 0xac, 0x67, 0x48, 0x87, 0x87, 0x82, 0x1f, 0xb8, 0xdc, 0x75, 0xf1, 0xa6, 0xec, 0xdd, 0xaa, - 0x45, 0x2a, 0x51, 0x10, 0x2a, 0x9b, 0x7d, 0xf5, 0x86, 0xa9, 0x81, 0x1e, 0x6a, 0xf4, 0x40, 0x19, 0x4c, 0xf1, 0x09, - 0x80, 0x9a, 0xd6, 0xe1, 0xd3, 0xd4, 0x42, 0xd9, 0x48, 0xdf, 0x0b, 0xcc, 0x30, 0xfd, 0xd7, 0x61, 0xb2, 0x42, 0x06, - 0xfc, 0xea, 0x69, 0x79, 0x33, 0xce, 0xbf, 0xe7, 0xb6, 0x87, 0xde, 0xa7, 0x7e, 0xfa, 0x2a, 0x89, 0x71, 0x0f, 0xf6, - 0xf7, 0x69, 0xe6, 0x4c, 0xc9, 0xd8, 0x51, 0x01, 0x24, 0x54, 0xdc, 0x4c, 0x61, 0x08, 0x4f, 0x17, 0x82, 0x22, 0x86, - 0xae, 0x6f, 0xd7, 0xf3, 0x3b, 0xbe, 0x62, 0x1e, 0x51, 0xbb, 0x4c, 0xd5, 0x50, 0xd2, 0xfa, 0x30, 0x1b, 0x10, 0xd6, - 0x04, 0x4f, 0x8e, 0x70, 0xc3, 0xd2, 0x55, 0x44, 0x66, 0xc1, 0x0a, 0xcf, 0xc0, 0xa9, 0x09, 0xb8, 0x6e, 0x8a, 0xcc, - 0x7b, 0x9c, 0x00, 0xce, 0xc7, 0x63, 0x1c, 0xed, 0x29, 0xe0, 0xed, 0xb2, 0xba, 0xda, 0x5b, 0x6a, 0xbd, 0x73, 0x1e, - 0xda, 0x44, 0x10, 0x95, 0xf8, 0x79, 0x36, 0x91, 0xfb, 0x07, 0x6f, 0xce, 0xab, 0xc9, 0x96, 0xa4, 0x43, 0xc9, 0xdf, - 0x41, 0xd1, 0x9b, 0xac, 0xb0, 0x92, 0x1b, 0xc5, 0x22, 0x99, 0x34, 0x02, 0x20, 0x30, 0xaf, 0xf2, 0x1d, 0x11, 0xc0, - 0x55, 0x58, 0x68, 0x34, 0x45, 0x51, 0x5e, 0x51, 0x6d, 0x9e, 0xd1, 0xee, 0xd8, 0xaf, 0xe7, 0xb8, 0x2c, 0xd7, 0x96, - 0xd4, 0x6a, 0x2c, 0xeb, 0x48, 0x8a, 0x66, 0x18, 0xbc, 0x39, 0x2f, 0x05, 0x2f, 0xf1, 0xc1, 0x3c, 0x6f, 0x89, 0xaf, - 0x54, 0x5a, 0x41, 0x23, 0xd7, 0x6b, 0x8a, 0x99, 0x03, 0x9a, 0xd3, 0x65, 0x7a, 0x97, 0xe2, 0xfd, 0xeb, 0x15, 0xbf, - 0x2c, 0x5b, 0xaa, 0xba, 0xee, 0xfa, 0x93, 0x15, 0x71, 0x5c, 0x64, 0xb1, 0x6f, 0x59, 0xb4, 0x19, 0xec, 0x10, 0xfb, - 0x31, 0xed, 0xf3, 0x28, 0xcf, 0xb5, 0xcf, 0x36, 0x3f, 0x97, 0x10, 0x47, 0x96, 0x68, 0xbd, 0x3a, 0x62, 0x3f, 0xb5, - 0x64, 0x63, 0xb9, 0xef, 0x44, 0x29, 0x76, 0xb4, 0xb8, 0x90, 0xe6, 0x42, 0x1f, 0x3c, 0xd7, 0x83, 0xa5, 0x0c, 0x7f, - 0x16, 0x57, 0xb6, 0xf4, 0xaa, 0x1c, 0xad, 0xf4, 0x9f, 0x75, 0xa3, 0x87, 0x53, 0x9b, 0x62, 0xea, 0xde, 0x47, 0xc2, - 0x34, 0xa1, 0xf9, 0xbe, 0x21, 0x36, 0x55, 0x4c, 0x14, 0x44, 0x23, 0x6d, 0x03, 0xc7, 0xfb, 0xe7, 0xf5, 0x95, 0xa7, - 0xbc, 0x94, 0xfc, 0xe1, 0x3a, 0x6e, 0x79, 0x63, 0x68, 0x32, 0xf1, 0x06, 0xad, 0x07, 0x39, 0x81, 0x6d, 0x6c, 0x9f, - 0x1e, 0x69, 0x8f, 0xc2, 0x09, 0xe9, 0x4e, 0x39, 0xb4, 0x0e, 0xd7, 0x27, 0xef, 0xd0, 0x85, 0x28, 0x8d, 0x4c, 0xfc, - 0x84, 0xf4, 0xc6, 0x69, 0x74, 0xaa, 0xab, 0x7f, 0xf2, 0xbc, 0xb3, 0xd8, 0x37, 0xb0, 0xa0, 0xde, 0xff, 0xe9, 0xc6, - 0x50, 0x62, 0x3c, 0x6f, 0x19, 0x71, 0x4c, 0x84, 0xa4, 0xdc, 0x4a, 0xbe, 0x4f, 0x22, 0x2a, 0xb5, 0x52, 0x38, 0xa3, - 0x17, 0xf4, 0x88, 0x1a, 0x2c, 0x9e, 0x9f, 0x5a, 0xe7, 0xc0, 0xa4, 0x1b, 0xe5, 0xa5, 0x51, 0x20, 0x0d, 0x22, 0x4f, - 0xcd, 0xf4, 0x0c, 0x9a, 0xb7, 0x0f, 0xaf, 0x03, 0xf7, 0x9e, 0x20, 0x9f, 0xff, 0xfe, 0x30, 0xdc, 0xde, 0x1a, 0x68, - 0x96, 0xf5, 0x39, 0x76, 0x51, 0xeb, 0x8b, 0x15, 0x7a, 0x58, 0x80, 0xdd, 0x13, 0x92, 0xeb, 0x3f, 0x05, 0xe8, 0x1a, - 0xcc, 0xb2, 0x55, 0xc7, 0xbc, 0x6d, 0xfb, 0xb7, 0xf3, 0x2a, 0xdc, 0x1d, 0x33, 0x10, 0x68, 0x77, 0xc6, 0x38, 0x87, - 0xff, 0x67, 0x89, 0x64, 0x15, 0xc6, 0xe4, 0xa2, 0xbd, 0x6e, 0x0f, 0x97, 0xc4, 0x6e, 0xb5, 0x66, 0x39, 0xd3, 0x76, - 0x60, 0xeb, 0x39, 0x2f, 0xa2, 0xd2, 0x20, 0xc1, 0x4e, 0x6a, 0x43, 0x03, 0x44, 0x32, 0xe8, 0xf6, 0x52, 0xc6, 0xbd, - 0x20, 0x9f, 0x01, 0x7d, 0x6d, 0x67, 0x2e, 0xbd, 0x31, 0x35, 0xae, 0x70, 0x52, 0x97, 0x9d, 0xbb, 0xc9, 0x70, 0xd6, - 0x3e, 0x16, 0xca, 0xd7, 0x63, 0x81, 0x2f, 0xac, 0x8f, 0xd3, 0xf4, 0xc1, 0x1d, 0xd9, 0x47, 0x93, 0x63, 0x2f, 0xa6, - 0xa4, 0x2a, 0x33, 0x18, 0x65, 0x08, 0xb4, 0x74, 0x2d, 0xcb, 0x94, 0x62, 0x8f, 0xde, 0x3e, 0x9c, 0x32, 0x6e, 0xfa, - 0x79, 0x98, 0x73, 0xd0, 0x89, 0x65, 0x8b, 0xe7, 0x15, 0xd9, 0xc3, 0xd4, 0x9d, 0x00, 0x89, 0x04, 0x61, 0xa2, 0x0b, - 0x95, 0x7a, 0x90, 0x61, 0x4d, 0x78, 0x84, 0x34, 0x71, 0x71, 0x3a, 0x32, 0x61, 0x77, 0xe4, 0x49, 0x07, 0x51, 0x07, - 0x86, 0xca, 0xd5, 0x73, 0xfe, 0xd0, 0x63, 0xb2, 0x17, 0x14, 0xd9, 0xf6, 0x48, 0xe1, 0x9c, 0x79, 0xf3, 0x21, 0x7b, - 0xe8, 0x5f, 0x37, 0xbd, 0xe6, 0x88, 0x05, 0xf7, 0xb7, 0x50, 0x81, 0x32, 0x04, 0xdc, 0x1f, 0xfa, 0xee, 0x36, 0x47, - 0xad, 0xa0, 0x33, 0x30, 0x7d, 0xb2, 0xcf, 0xf4, 0x62, 0x4d, 0x69, 0xb8, 0x6f, 0x46, 0xce, 0xe0, 0x4e, 0xd0, 0xb5, - 0x33, 0xa9, 0xb4, 0xbb, 0x7c, 0x21, 0xa8, 0xf0, 0xe1, 0x1a, 0xb4, 0x3a, 0x88, 0x9c, 0x92, 0xfe, 0x4e, 0x48, 0x75, - 0xb5, 0x29, 0x26, 0xdc, 0x40, 0xcd, 0x06, 0x8a, 0xa3, 0x70, 0xe3, 0x07, 0x89, 0x01, 0x66, 0x6e, 0xa4, 0x61, 0x25, - 0xaf, 0x9d, 0x87, 0x5f, 0xec, 0x07, 0x39, 0xcf, 0x63, 0x2a, 0xd1, 0x43, 0x9f, 0x56, 0x75, 0xfd, 0x21, 0xe6, 0x1b, - 0x6a, 0x9f, 0x41, 0x6d, 0x93, 0x10, 0xa2, 0x4e, 0xd3, 0x3e, 0xe6, 0x59, 0xf9, 0xd1, 0xc1, 0x84, 0x98, 0x7b, 0x32, - 0xd0, 0xaa, 0x5d, 0x81, 0xa5, 0xec, 0x52, 0x95, 0x70, 0xed, 0xd4, 0x6f, 0x2a, 0x69, 0x17, 0xab, 0x95, 0x57, 0xa7, - 0xd8, 0xb3, 0x7f, 0xe7, 0xda, 0xfb, 0x90, 0xf1, 0x99, 0xe8, 0x58, 0xb3, 0xda, 0xbd, 0xee, 0x27, 0xce, 0x69, 0xbc, - 0xc4, 0x46, 0x09, 0xe5, 0x87, 0x69, 0x40, 0x3c, 0x78, 0x83, 0x78, 0xd7, 0x4f, 0x6c, 0xf6, 0xe2, 0xaa, 0x2f, 0x35, - 0x5a, 0xa8, 0x3f, 0xe9, 0xc3, 0xa3, 0x1a, 0x9c, 0x3c, 0x5c, 0x86, 0x27, 0x5f, 0x79, 0x3b, 0x19, 0xe0, 0xb1, 0x12, - 0xf8, 0xdc, 0x5a, 0x02, 0x4a, 0x47, 0x24, 0xaf, 0xe4, 0x03, 0xfa, 0x7f, 0x0e, 0xcf, 0x87, 0x5d, 0x8f, 0x9f, 0x2d, - 0x6d, 0xa8, 0x45, 0x27, 0x1d, 0x61, 0x09, 0x6a, 0x7b, 0x48, 0x43, 0x88, 0x8c, 0x1d, 0x81, 0x69, 0xcc, 0x9f, 0x14, - 0x61, 0x1e, 0x81, 0xf7, 0x39, 0x03, 0x8e, 0xda, 0x96, 0xf8, 0xc2, 0x09, 0x77, 0xef, 0xf2, 0xe1, 0x37, 0xf0, 0xbd, - 0xb2, 0x4b, 0x58, 0x6e, 0xab, 0x1d, 0xbb, 0xd9, 0x04, 0x9a, 0xa3, 0x28, 0x6e, 0xbf, 0x99, 0x68, 0xd1, 0xb3, 0xc3, - 0x7e, 0x0e, 0xba, 0x97, 0xa1, 0x42, 0xf9, 0x98, 0xf6, 0x99, 0xdc, 0xaf, 0x47, 0x80, 0x22, 0xe0, 0x10, 0x43, 0x6c, - 0xff, 0xd8, 0x2b, 0x0f, 0xb5, 0x9e, 0x05, 0x04, 0x14, 0xc3, 0x9f, 0x5c, 0x70, 0x66, 0xfa, 0xe0, 0x18, 0x30, 0x39, - 0x00, 0xd4, 0x06, 0x17, 0x8d, 0xc5, 0x29, 0xfe, 0xbf, 0xf3, 0x8d, 0xe4, 0xed, 0xba, 0x38, 0x1d, 0xf1, 0x2e, 0x9f, - 0x51, 0x54, 0xcc, 0x90, 0x42, 0x0b, 0xbf, 0xe8, 0x06, 0xc2, 0x4a, 0x11, 0x0b, 0x7a, 0x2b, 0x1f, 0xdb, 0xcb, 0x63, - 0x14, 0xaa, 0xff, 0xab, 0x97, 0xec, 0x8f, 0x5a, 0xf0, 0xd8, 0xa5, 0x58, 0xde, 0xf0, 0x91, 0x53, 0xaa, 0x87, 0xbb, - 0x78, 0xb3, 0x1d, 0x06, 0x05, 0xbd, 0x1d, 0x10, 0x6f, 0xfd, 0x9f, 0x25, 0x49, 0xb6, 0xdc, 0x6a, 0x86, 0x24, 0xb9, - 0xae, 0x8e, 0x3b, 0xe2, 0xdf, 0x8f, 0x78, 0x57, 0x1b, 0x1d, 0xaa, 0xf6, 0x7c, 0x5c, 0x67, 0xfe, 0x2b, 0xce, 0xf2, - 0x86, 0xa4, 0xd3, 0xcc, 0xee, 0x6b, 0x5c, 0xce, 0x65, 0x3b, 0x99, 0x2f, 0x66, 0x77, 0xb3, 0xfd, 0xf2, 0xfd, 0x96, - 0x2a, 0x63, 0xeb, 0xf9, 0x45, 0xf3, 0x31, 0xc7, 0x1d, 0x91, 0x94, 0x65, 0x18, 0xcb, 0xf9, 0xb9, 0x4b, 0xf3, 0xe3, - 0x0f, 0xc2, 0x9b, 0x1f, 0xbf, 0x78, 0x28, 0x38, 0x9d, 0x62, 0x2a, 0x23, 0x4e, 0x95, 0xce, 0x9c, 0x24, 0x86, 0xa9, - 0x14, 0x68, 0x26, 0xba, 0xbe, 0x06, 0xc9, 0x00, 0xbd, 0x82, 0xa6, 0xc3, 0xd0, 0x9f, 0xf1, 0x01, 0xae, 0x3a, 0x79, - 0xa6, 0x92, 0xcc, 0x17, 0x8c, 0x31, 0x5e, 0xf0, 0x43, 0xbf, 0xf0, 0xe4, 0x5e, 0x3b, 0x32, 0x80, 0x21, 0x15, 0x7b, - 0xfc, 0x78, 0xd1, 0x7c, 0x79, 0x69, 0x44, 0x08, 0x55, 0xc8, 0x52, 0x80, 0xa7, 0x3c, 0x7f, 0x26, 0xab, 0xeb, 0xd9, - 0x6f, 0x36, 0x5d, 0x69, 0xb8, 0xaf, 0xa6, 0x9e, 0x2a, 0x60, 0x6c, 0xb9, 0x91, 0x8f, 0x29, 0x66, 0xd6, 0x06, 0xeb, - 0x74, 0x50, 0xab, 0xc7, 0x1c, 0xe3, 0xa9, 0xa0, 0x2e, 0xa6, 0xd4, 0x93, 0x3c, 0xd6, 0xd9, 0xf4, 0x41, 0x36, 0xb8, - 0x81, 0x71, 0xc5, 0xc9, 0x47, 0x10, 0x45, 0x13, 0x60, 0x39, 0x4f, 0x5b, 0x44, 0x11, 0x7c, 0x87, 0x66, 0x14, 0xc1, - 0x10, 0xb1, 0x88, 0x2d, 0xef, 0x56, 0xc9, 0xbc, 0xbd, 0xec, 0x72, 0x92, 0xe9, 0xb7, 0xa5, 0xcc, 0x49, 0xa2, 0xc1, - 0xc1, 0x2a, 0x9f, 0xb5, 0xea, 0xa6, 0x1f, 0xec, 0x4b, 0x28, 0x00, 0x8e, 0xcc, 0xc0, 0x81, 0x92, 0x62, 0x56, 0xaa, - 0x8a, 0x1a, 0x39, 0x08, 0x70, 0xf2, 0xc3, 0x3f, 0x54, 0x5f, 0x84, 0xa5, 0xb3, 0xdb, 0x29, 0x08, 0x3d, 0xc1, 0x08, - 0x91, 0x40, 0xe3, 0x27, 0x97, 0x6c, 0xfa, 0xef, 0xdc, 0xcc, 0x48, 0x5f, 0xfe, 0xbd, 0x9e, 0xec, 0x6d, 0x6b, 0x50, - 0x30, 0xb9, 0x1e, 0xed, 0xeb, 0x58, 0x2b, 0x96, 0x4e, 0xa8, 0x4b, 0x7f, 0x71, 0x05, 0x3e, 0xa9, 0x09, 0x91, 0xb1, - 0x62, 0xa6, 0x32, 0x6b, 0x29, 0x78, 0xae, 0x7e, 0xcc, 0x65, 0x60, 0x26, 0x52, 0xda, 0x15, 0x93, 0xa6, 0x34, 0xf3, - 0x29, 0x17, 0xd1, 0xb3, 0x67, 0x5d, 0xa7, 0xa1, 0xb5, 0x0e, 0xac, 0xcb, 0x7e, 0x88, 0xb7, 0xf9, 0xd5, 0x99, 0xa6, - 0x30, 0xca, 0xf9, 0xab, 0xf3, 0x0e, 0x8b, 0x72, 0xb3, 0xbe, 0x62, 0x3e, 0xec, 0x1d, 0xda, 0x69, 0x65, 0xf4, 0xf1, - 0x5c, 0xad, 0x70, 0xdf, 0x81, 0x90, 0xf3, 0xe8, 0x7b, 0x03, 0x1e, 0xff, 0x0a, 0xff, 0xbf, 0x3e, 0x04, 0xda, 0xb1, - 0x15, 0x0c, 0xdd, 0xf0, 0x89, 0x4d, 0x70, 0x8f, 0x86, 0x99, 0xd3, 0xd9, 0xca, 0xef, 0x43, 0x22, 0xea, 0x16, 0x70, - 0xb7, 0x8b, 0x1f, 0xd7, 0x3e, 0xc3, 0xd5, 0xc8, 0xc6, 0x18, 0x0e, 0xb9, 0x01, 0xb2, 0x84, 0xf0, 0x09, 0x09, 0x63, - 0xdd, 0x39, 0x3f, 0x38, 0xa3, 0x31, 0xbe, 0xfb, 0x5b, 0xe7, 0xf9, 0x66, 0xbc, 0x8d, 0xf9, 0x75, 0xf2, 0x4d, 0xe7, - 0x7a, 0xa0, 0xf3, 0xf4, 0xa0, 0xd6, 0x6a, 0xfd, 0xc3, 0x4d, 0xef, 0x5d, 0x0c, 0x4b, 0xb8, 0x9f, 0x3a, 0xba, 0xb9, - 0x7b, 0x13, 0x11, 0x11, 0xa8, 0x3f, 0x78, 0x68, 0xd1, 0xf3, 0x09, 0xd4, 0xe9, 0x12, 0x22, 0xfa, 0xa3, 0xcd, 0x9e, - 0xdb, 0xc9, 0x9c, 0x3a, 0x79, 0xb2, 0x8d, 0xae, 0x45, 0x25, 0x5f, 0x58, 0x2c, 0xf3, 0x3e, 0x6d, 0xdd, 0x88, 0xc8, - 0x81, 0xc4, 0x64, 0xc5, 0x36, 0xc3, 0xd4, 0xd0, 0x71, 0xea, 0x22, 0xf1, 0x3f, 0xef, 0xeb, 0xc4, 0x50, 0xf2, 0xb2, - 0xd4, 0x02, 0x0b, 0x4b, 0x55, 0xd8, 0x3e, 0xee, 0x39, 0x95, 0x85, 0x55, 0x37, 0x46, 0xbc, 0x75, 0xdf, 0x76, 0x4d, - 0xc7, 0x26, 0x8a, 0xd7, 0x5f, 0xbf, 0x02, 0xad, 0x21, 0x3d, 0x16, 0xf1, 0x7e, 0x91, 0x8e, 0x63, 0x00, 0xde, 0x31, - 0x74, 0x0b, 0x77, 0xcb, 0xb2, 0x6a, 0xcf, 0xfb, 0x74, 0x0c, 0x25, 0x45, 0xb1, 0x94, 0xdc, 0x3d, 0x62, 0xeb, 0x71, - 0x94, 0xe0, 0xa9, 0xee, 0x3d, 0xbd, 0x45, 0x2a, 0x91, 0xa5, 0xa3, 0xf4, 0xd8, 0xcf, 0x29, 0x60, 0xea, 0xa5, 0xf8, - 0x7d, 0xf4, 0x68, 0x59, 0x32, 0x40, 0x8b, 0x8d, 0x58, 0xe5, 0x1d, 0x5b, 0xc1, 0x5a, 0x9c, 0x92, 0x63, 0xbc, 0xed, - 0xdd, 0x97, 0x54, 0xca, 0x5d, 0xcc, 0x6c, 0x94, 0x76, 0x6a, 0xbc, 0x1c, 0x1c, 0xa7, 0xa5, 0xb0, 0x22, 0xc6, 0x18, - 0x39, 0xbb, 0x12, 0xb4, 0x35, 0x42, 0x77, 0xb8, 0x66, 0x89, 0xff, 0xbe, 0xac, 0x2d, 0x6e, 0x25, 0x90, 0x91, 0x2f, - 0xc3, 0x37, 0xe5, 0x9b, 0xa0, 0xad, 0xfe, 0x62, 0x8f, 0xbe, 0x56, 0x10, 0x32, 0xe1, 0x57, 0x7c, 0x35, 0xba, 0xe6, - 0xf6, 0x7d, 0xd9, 0x4d, 0x56, 0x69, 0x92, 0x9d, 0x40, 0x6b, 0x93, 0xca, 0xb9, 0xf0, 0xf0, 0x39, 0x77, 0x47, 0x92, - 0x3e, 0x7d, 0x2a, 0xcc, 0x28, 0x79, 0xc9, 0x54, 0x50, 0x3a, 0xc8, 0x66, 0x7f, 0x82, 0x25, 0xa8, 0x87, 0x7c, 0x41, - 0x6d, 0xdd, 0xe3, 0xe9, 0xf3, 0x1a, 0x88, 0xeb, 0x65, 0xc3, 0x0a, 0x44, 0x22, 0xfa, 0x6f, 0xb3, 0x8f, 0x3e, 0x64, - 0x73, 0x42, 0xf6, 0xfa, 0x66, 0x8e, 0xd3, 0x9d, 0x44, 0x28, 0xca, 0x1d, 0xb7, 0x03, 0x4a, 0x29, 0x0e, 0x4a, 0xd5, - 0xf0, 0xd8, 0x2c, 0x91, 0x63, 0xe6, 0x07, 0xa7, 0xbb, 0xd8, 0x4f, 0x5c, 0x8b, 0x5f, 0xd8, 0xb1, 0x53, 0x79, 0xf3, - 0xcf, 0xbe, 0x7c, 0xd9, 0xc7, 0x83, 0xc8, 0xe8, 0x0f, 0x42, 0x11, 0x5f, 0xf6, 0x9b, 0x26, 0xf1, 0xe2, 0x17, 0xdf, - 0xd2, 0x53, 0x3c, 0xf7, 0x6b, 0x75, 0x11, 0xb7, 0x75, 0xf7, 0xbe, 0x8a, 0x76, 0x29, 0xb1, 0xe1, 0x36, 0x0c, 0x4f, - 0x93, 0xe4, 0xf4, 0x00, 0xe0, 0x03, 0xce, 0xe5, 0x3f, 0x73, 0x14, 0xca, 0x47, 0x2e, 0xc3, 0xf9, 0x62, 0x11, 0x62, - 0x4c, 0xfe, 0xc6, 0x18, 0xa5, 0x35, 0x6f, 0x9f, 0xb7, 0x77, 0xbf, 0x71, 0x6c, 0x78, 0x6d, 0xbc, 0x89, 0x86, 0x8a, - 0x16, 0xe5, 0x4d, 0xe1, 0x53, 0x5e, 0x17, 0x76, 0x79, 0xaf, 0xf0, 0x98, 0xf7, 0x0b, 0x4f, 0xf9, 0x60, 0xed, 0xd1, - 0x68, 0x45, 0x48, 0xc1, 0xb5, 0x40, 0xd6, 0x85, 0x42, 0x97, 0x71, 0x04, 0xf7, 0x94, 0x17, 0x6d, 0xcd, 0xef, 0xd0, - 0x44, 0x96, 0xff, 0x07, 0x62, 0x85, 0xd5, 0xe9, 0x07, 0x4d, 0xf1, 0x0a, 0xc4, 0x58, 0xe6, 0x58, 0x8a, 0xd5, 0xed, - 0x7f, 0xd6, 0x52, 0x31, 0x1e, 0x73, 0xb6, 0x99, 0x81, 0xbe, 0x5a, 0xbe, 0xc2, 0xc6, 0x40, 0xe3, 0xeb, 0x4d, 0x69, - 0xf5, 0x1a, 0x58, 0x8b, 0xfd, 0x7c, 0x4d, 0x23, 0x59, 0x89, 0xb0, 0x52, 0xe5, 0x61, 0x60, 0xa2, 0x2a, 0xf3, 0x8c, - 0x74, 0x04, 0xc5, 0xf3, 0xe9, 0x0b, 0xbe, 0x72, 0xd4, 0xda, 0x67, 0x05, 0xa8, 0x86, 0xc7, 0x42, 0x47, 0x2f, 0x8c, - 0xec, 0xea, 0xba, 0xa5, 0xa6, 0xb6, 0x67, 0x5f, 0x12, 0x6b, 0xe4, 0xb7, 0xe3, 0x67, 0x52, 0x24, 0xb4, 0x6c, 0xfc, - 0x3e, 0x8f, 0x77, 0xb1, 0xf7, 0x95, 0x86, 0x34, 0x40, 0x68, 0x9d, 0x90, 0x59, 0xd4, 0x74, 0xc1, 0x4b, 0xc2, 0xa7, - 0xa5, 0x8f, 0xe9, 0x47, 0xc7, 0xfb, 0x8b, 0xaf, 0xf0, 0x00, 0x47, 0x5a, 0xbb, 0xd8, 0xe4, 0xc7, 0xe3, 0x02, 0x7e, - 0xed, 0x37, 0x1d, 0x0a, 0x6b, 0xc6, 0x2a, 0x97, 0xde, 0xb4, 0xab, 0x8b, 0xe0, 0x6b, 0x4b, 0x9f, 0xf1, 0xb8, 0x7f, - 0xec, 0x4d, 0x1d, 0xef, 0x4f, 0x7a, 0x04, 0xbe, 0x01, 0x28, 0x15, 0x35, 0x88, 0x7d, 0x10, 0x7a, 0xbc, 0xb3, 0x2a, - 0x82, 0xcb, 0xf0, 0x38, 0xa4, 0xed, 0xf9, 0x32, 0xb3, 0xab, 0xc7, 0xf8, 0x8d, 0x90, 0x04, 0xdd, 0xf0, 0x4e, 0x5a, - 0x12, 0xa0, 0xf4, 0x51, 0x09, 0x93, 0x1c, 0xb1, 0xcf, 0x2f, 0x5a, 0xf6, 0xa6, 0x8d, 0x4e, 0xe1, 0x5b, 0x8f, 0x98, - 0x67, 0x6d, 0x99, 0xf3, 0x9f, 0x06, 0x71, 0x30, 0x93, 0xa3, 0xf8, 0xfd, 0x10, 0xe7, 0x45, 0x15, 0x75, 0xe9, 0xc5, - 0x6c, 0x6f, 0x03, 0xb6, 0xf0, 0xbb, 0x0f, 0xb3, 0x81, 0xef, 0x4f, 0x7d, 0xb9, 0xd6, 0xa1, 0x9e, 0xd1, 0xfd, 0x56, - 0x75, 0xdb, 0xc7, 0x91, 0x75, 0xf2, 0x9c, 0xc5, 0xc3, 0xe8, 0xdd, 0xf7, 0x85, 0xaf, 0x71, 0x66, 0xb4, 0xf8, 0x24, - 0x2a, 0x0a, 0x2b, 0x97, 0x41, 0xb9, 0x7c, 0x4d, 0x55, 0xb5, 0x47, 0x9b, 0x2f, 0x62, 0x74, 0x5e, 0xfc, 0x5e, 0xa7, - 0x8f, 0xba, 0xc6, 0xeb, 0x48, 0xf9, 0x68, 0x5f, 0x16, 0xc3, 0x1f, 0xac, 0x20, 0xb4, 0x98, 0xd8, 0xec, 0xb1, 0x5f, - 0x8e, 0x16, 0xa7, 0x67, 0x69, 0x33, 0xec, 0x34, 0x6d, 0xb5, 0x71, 0x3b, 0xd8, 0x6f, 0x1d, 0xd2, 0x92, 0xc4, 0x8b, - 0xf1, 0x15, 0x2a, 0x7f, 0xc0, 0x43, 0xec, 0x39, 0x48, 0xd0, 0x88, 0x35, 0xe7, 0xb7, 0xc8, 0x75, 0xba, 0x16, 0x48, - 0x5d, 0xf8, 0x7a, 0xe8, 0x61, 0xd2, 0x22, 0xd5, 0x41, 0x59, 0x06, 0xba, 0x89, 0x02, 0xfa, 0x9e, 0xba, 0x2d, 0xc8, - 0x45, 0xf6, 0xf7, 0x9c, 0x9d, 0xbe, 0xc6, 0xfb, 0x73, 0x0b, 0x3b, 0x51, 0xf8, 0xcd, 0x1f, 0x93, 0x18, 0xd6, 0xdc, - 0x76, 0x91, 0x2d, 0x82, 0xde, 0x6c, 0x5a, 0x3e, 0x28, 0x07, 0x6c, 0x7e, 0x69, 0xa1, 0xca, 0xc8, 0x11, 0xeb, 0xf9, - 0x6f, 0xf7, 0x63, 0x97, 0x98, 0x57, 0x41, 0xa8, 0x5e, 0xa9, 0x2a, 0x31, 0x80, 0x3e, 0xa9, 0x3d, 0x03, 0x75, 0x66, - 0x76, 0x55, 0xe9, 0xf5, 0xeb, 0xac, 0x3e, 0xd4, 0xee, 0x02, 0xf7, 0x4e, 0xc3, 0xb3, 0x13, 0x6b, 0x25, 0x8b, 0xe8, - 0x23, 0x24, 0x61, 0x02, 0xfd, 0x7e, 0xd7, 0xb5, 0xaf, 0x7b, 0x3a, 0x96, 0x05, 0x94, 0x89, 0x3a, 0x5c, 0x9c, 0x20, - 0x18, 0x3f, 0xc8, 0x71, 0x80, 0x6d, 0xe4, 0xc7, 0x2e, 0x8b, 0xab, 0xfe, 0x1c, 0x28, 0x92, 0xa0, 0xb9, 0x96, 0xfb, - 0x35, 0xb8, 0xaf, 0xef, 0x74, 0x93, 0x15, 0xd9, 0x65, 0x98, 0x33, 0xde, 0x30, 0xc6, 0x08, 0x51, 0xc5, 0x22, 0x9e, - 0xe7, 0xb8, 0x81, 0xe5, 0x71, 0x09, 0xde, 0x58, 0xce, 0x3b, 0xa3, 0xda, 0xf2, 0x6c, 0x80, 0xa6, 0xb4, 0x62, 0x1b, - 0x95, 0x6a, 0x65, 0x0c, 0x0c, 0x64, 0xcb, 0x4e, 0xa6, 0xef, 0xa9, 0x2c, 0xc6, 0xfb, 0x77, 0x47, 0x04, 0x37, 0x3d, - 0xca, 0x7c, 0x7d, 0x10, 0xc6, 0xd0, 0xdc, 0xc3, 0xa0, 0x62, 0xb7, 0x4d, 0x39, 0x06, 0x17, 0x5c, 0x74, 0xa2, 0x26, - 0x35, 0x94, 0x45, 0xb5, 0x8c, 0x14, 0x5e, 0xcd, 0x8a, 0xbe, 0xee, 0x69, 0xf1, 0x5a, 0x84, 0x18, 0x94, 0xe1, 0xba, - 0x24, 0x21, 0x54, 0x26, 0x08, 0x7d, 0xa8, 0x30, 0xa5, 0xc2, 0xeb, 0x94, 0x80, 0xfd, 0x3d, 0xcf, 0x79, 0xdd, 0xfb, - 0x5d, 0x3b, 0x2c, 0xb3, 0xe4, 0xb8, 0xd7, 0x70, 0xbb, 0x82, 0xbb, 0x23, 0xcf, 0x46, 0x76, 0x6b, 0x64, 0xf2, 0xbe, - 0x56, 0x0c, 0xe9, 0xb6, 0x60, 0x2a, 0x2e, 0x8a, 0x68, 0x95, 0xc5, 0xb8, 0x1d, 0xf8, 0x95, 0xbb, 0x45, 0xb3, 0x9e, - 0x3a, 0x93, 0xf5, 0x86, 0x21, 0x7c, 0x1a, 0x96, 0xb1, 0x84, 0x58, 0xbd, 0x1e, 0xf9, 0x7f, 0x97, 0x85, 0x47, 0x45, - 0xbb, 0x4f, 0x28, 0xc4, 0xbd, 0xc9, 0x8c, 0x37, 0x03, 0x70, 0x90, 0x63, 0x88, 0x63, 0x70, 0xa0, 0xb5, 0xac, 0xd0, - 0xa9, 0x91, 0x80, 0x88, 0xb5, 0x25, 0x7f, 0xd3, 0x5b, 0xec, 0x2a, 0x7a, 0x6d, 0xdb, 0x77, 0x8e, 0x7f, 0xfe, 0xb6, - 0xda, 0xd6, 0x4d, 0x2c, 0xe4, 0x9d, 0x91, 0x41, 0x3d, 0xb0, 0xbf, 0xef, 0x88, 0x13, 0x6d, 0x81, 0xc0, 0xd5, 0x07, - 0xd3, 0x62, 0x7d, 0xbc, 0x10, 0x31, 0x3f, 0xf8, 0x18, 0x26, 0xf1, 0x14, 0x1d, 0x7d, 0xc6, 0xe7, 0x86, 0x8f, 0xc2, - 0x0f, 0xff, 0xb3, 0x1c, 0x58, 0x99, 0x74, 0x24, 0xa7, 0x8e, 0xa9, 0x8e, 0x02, 0x02, 0xe8, 0x4c, 0xee, 0x91, 0xef, - 0xbf, 0x3a, 0xb4, 0x54, 0xb1, 0x6c, 0x3a, 0x43, 0xb3, 0x93, 0x4e, 0xac, 0x5b, 0xcc, 0x06, 0x9f, 0x38, 0xf7, 0x8b, - 0xcb, 0x0f, 0xe9, 0xc9, 0x61, 0x7f, 0x7b, 0xd2, 0x68, 0xd3, 0x63, 0x46, 0x03, 0x60, 0x0c, 0x2b, 0xfd, 0x78, 0x90, - 0xd2, 0xeb, 0x27, 0x6a, 0xa2, 0x65, 0x43, 0x78, 0x66, 0x3c, 0xba, 0x0c, 0x91, 0xfe, 0xc3, 0xa0, 0x78, 0xd8, 0x6c, - 0xbd, 0x32, 0x5f, 0xb0, 0x9a, 0x83, 0xd1, 0x0b, 0x82, 0x66, 0xc3, 0x16, 0x8b, 0xca, 0xea, 0x71, 0x7e, 0x84, 0x59, - 0x50, 0x00, 0x3e, 0x65, 0x6d, 0x80, 0xfe, 0x39, 0xe6, 0x98, 0x0b, 0x88, 0x46, 0xa3, 0x36, 0x52, 0x6d, 0xf5, 0xbc, - 0xe2, 0x9f, 0xa9, 0x38, 0x50, 0xeb, 0x3d, 0x39, 0x66, 0x7b, 0xca, 0xea, 0x6a, 0x93, 0x4a, 0x03, 0xb4, 0xbe, 0x4c, - 0xf0, 0xb5, 0x0e, 0xb5, 0x04, 0x72, 0x56, 0xc0, 0x67, 0x96, 0x56, 0x97, 0xd9, 0x3d, 0xe7, 0xf8, 0xbd, 0x78, 0xf7, - 0xa0, 0x33, 0xee, 0x36, 0xdf, 0x6d, 0x06, 0x3b, 0x2b, 0x91, 0xdf, 0x0f, 0x1c, 0xb0, 0xf5, 0xce, 0xf1, 0xb2, 0x16, - 0x78, 0xbf, 0x85, 0x41, 0x00, 0xf2, 0x7e, 0x81, 0x5d, 0xd2, 0x38, 0x0d, 0xf3, 0x95, 0xb6, 0x94, 0xc6, 0xb8, 0x72, - 0xfc, 0x94, 0x33, 0xff, 0x3f, 0xd4, 0x58, 0x19, 0xc7, 0x4f, 0x6c, 0x80, 0x76, 0x15, 0x20, 0xc9, 0x01, 0xd1, 0xc1, - 0x93, 0x16, 0x8f, 0xdf, 0x08, 0x0a, 0xfd, 0x6f, 0xae, 0xf9, 0xf5, 0x86, 0x41, 0x6c, 0x7b, 0x84, 0xf0, 0x0b, 0x6d, - 0xd8, 0xfc, 0x4d, 0x67, 0xcd, 0x25, 0x44, 0x72, 0xfd, 0x1d, 0x29, 0xa9, 0xab, 0xe7, 0x91, 0xfb, 0x93, 0x06, 0xc0, - 0xa4, 0xb2, 0xfa, 0x3a, 0xed, 0xf9, 0xc2, 0xeb, 0x79, 0x07, 0xb1, 0x19, 0xc7, 0xef, 0x8e, 0x98, 0xf8, 0x50, 0x54, - 0xd5, 0x59, 0xd4, 0xb4, 0x3a, 0xf6, 0xd6, 0x49, 0x07, 0x3a, 0x71, 0x41, 0xf0, 0x18, 0xbf, 0x04, 0xfb, 0x79, 0xf3, - 0x43, 0x42, 0x1d, 0xbf, 0xeb, 0x87, 0xe4, 0x7a, 0x37, 0x85, 0x07, 0x76, 0xc0, 0xf7, 0xf0, 0xc1, 0xda, 0x44, 0xd3, - 0xb9, 0x10, 0x1f, 0x42, 0x52, 0x11, 0x90, 0xf5, 0x24, 0x4e, 0x6e, 0x4a, 0x92, 0x60, 0xc3, 0x5e, 0xd6, 0xb6, 0x82, - 0xc3, 0xb9, 0x76, 0x87, 0x22, 0x9c, 0x46, 0x07, 0xdd, 0x0c, 0x8f, 0x38, 0xe3, 0xa4, 0x6e, 0x65, 0xea, 0xb3, 0x6d, - 0x10, 0x89, 0x91, 0x70, 0x05, 0x04, 0x9f, 0x08, 0x1e, 0x8c, 0x98, 0x1a, 0x20, 0xa9, 0x08, 0x70, 0xfd, 0xb0, 0x8d, - 0x50, 0x76, 0x3f, 0xe5, 0x27, 0x7c, 0x12, 0x43, 0x0e, 0x39, 0xac, 0xc3, 0xf3, 0xe7, 0x70, 0xd1, 0x50, 0x2c, 0xce, - 0x1c, 0x67, 0x5e, 0x94, 0xd5, 0xb4, 0x50, 0x9c, 0x58, 0xf9, 0x82, 0x07, 0x5c, 0x6f, 0xc0, 0xbc, 0x9d, 0x0a, 0x76, - 0xc6, 0x33, 0x5e, 0x61, 0x4a, 0x4c, 0x6f, 0x77, 0xce, 0x2b, 0x5d, 0xb9, 0x55, 0x14, 0xaf, 0x1a, 0xb4, 0x67, 0x46, - 0x5c, 0xf8, 0x3b, 0xad, 0x8d, 0x6e, 0xd9, 0xa5, 0x71, 0xf8, 0x37, 0x4a, 0x24, 0x04, 0x9b, 0x9f, 0x78, 0xe3, 0x3d, - 0xb4, 0x6b, 0xdf, 0x05, 0x87, 0x59, 0x7e, 0xfb, 0x1a, 0xfd, 0xe9, 0x4d, 0xcf, 0xb0, 0x28, 0xbd, 0x9f, 0x99, 0x83, - 0xea, 0x40, 0x56, 0x57, 0x87, 0x03, 0x0c, 0xda, 0xe1, 0x8e, 0x57, 0x90, 0x6e, 0xc5, 0x2c, 0x43, 0xa4, 0x33, 0x19, - 0xfd, 0xdd, 0x8b, 0x79, 0xc1, 0x3a, 0x04, 0x66, 0x1f, 0x0d, 0x73, 0x02, 0x17, 0xab, 0x0c, 0x0a, 0xa1, 0x0a, 0x21, - 0x7c, 0x1c, 0xe6, 0x8a, 0x9c, 0x06, 0x52, 0xe1, 0x8a, 0x9c, 0xfa, 0xa4, 0x83, 0x72, 0x1d, 0x3a, 0x5f, 0xad, 0x71, - 0x3c, 0xc5, 0x84, 0xbe, 0x18, 0x78, 0xa8, 0xaf, 0xd8, 0x2c, 0x3e, 0xf7, 0x42, 0x64, 0xfd, 0x0d, 0x98, 0xdc, 0xe0, - 0x65, 0x75, 0x9f, 0x85, 0x10, 0xb3, 0x70, 0x99, 0x19, 0xa9, 0x5f, 0x8a, 0x5a, 0x4f, 0xa3, 0x11, 0xa0, 0xd6, 0x3c, - 0xa0, 0x55, 0xcb, 0x10, 0x61, 0xfc, 0x25, 0xb4, 0xf4, 0x7b, 0xed, 0xe0, 0x86, 0x5f, 0xc5, 0x34, 0x1c, 0xc3, 0xfc, - 0x47, 0x11, 0x7a, 0x88, 0x01, 0x97, 0x71, 0x4d, 0xad, 0x5c, 0x8d, 0x06, 0xb9, 0x62, 0x7c, 0x01, 0x90, 0x32, 0x18, - 0x60, 0xac, 0x59, 0x28, 0x9e, 0x7f, 0xc7, 0x1f, 0x82, 0x08, 0xf5, 0x6a, 0x1f, 0xfb, 0xd1, 0x0d, 0x31, 0xa6, 0x36, - 0x3e, 0x26, 0x38, 0xf8, 0xd8, 0x5a, 0x69, 0xdf, 0x74, 0x95, 0x35, 0xc2, 0x09, 0xb4, 0xe0, 0xca, 0x3c, 0x88, 0x0f, - 0xa7, 0x36, 0xff, 0x2f, 0xc5, 0xaa, 0x1e, 0xbb, 0xfb, 0xfb, 0x23, 0x5c, 0x0f, 0x9d, 0x72, 0x90, 0x57, 0xb8, 0x00, - 0x2e, 0xbb, 0xea, 0x9c, 0x57, 0xbe, 0xb2, 0x4c, 0xfe, 0x16, 0x0e, 0x96, 0x0f, 0xca, 0x71, 0x3a, 0xfd, 0xcb, 0xb5, - 0x8b, 0xa3, 0x3d, 0x98, 0x4f, 0xd3, 0x30, 0xfe, 0x49, 0x2c, 0x7d, 0x5e, 0xd0, 0xd9, 0x6f, 0x48, 0x1b, 0x3f, 0x2e, - 0xb2, 0x7d, 0xe8, 0xba, 0x3c, 0x7f, 0x8d, 0xb7, 0xe7, 0x76, 0x4d, 0x9b, 0xce, 0xf7, 0x3f, 0xa5, 0xb3, 0x71, 0xcf, - 0xf8, 0x6f, 0xf4, 0x44, 0x27, 0xdf, 0x18, 0x7f, 0x48, 0x6b, 0xe3, 0xd3, 0x20, 0xbe, 0x6c, 0x0b, 0xb2, 0x87, 0x73, - 0x78, 0x1a, 0xce, 0x17, 0x94, 0x5f, 0x64, 0x71, 0xd1, 0x9f, 0xbe, 0xc6, 0x8b, 0x73, 0xcf, 0xcb, 0xb5, 0xd6, 0x7c, - 0x6a, 0x6d, 0xc0, 0xd6, 0x02, 0xe7, 0x46, 0xed, 0x96, 0x49, 0xaa, 0x56, 0xde, 0x88, 0xe9, 0x6c, 0x1a, 0x51, 0x07, - 0xfb, 0x7d, 0x7b, 0xdc, 0xf1, 0x40, 0xff, 0xb3, 0x79, 0x5d, 0x71, 0x6d, 0xd5, 0x4d, 0x77, 0x56, 0xe0, 0x0d, 0x93, - 0xa5, 0x23, 0x3c, 0x2b, 0x88, 0x34, 0xd2, 0x07, 0xa4, 0x65, 0x6d, 0xdb, 0x12, 0x43, 0xbb, 0x59, 0xc9, 0x34, 0x71, - 0x5b, 0x33, 0x5c, 0xe2, 0x4c, 0x08, 0x10, 0x49, 0xa6, 0x18, 0xba, 0xd6, 0x0c, 0x90, 0xde, 0x41, 0x49, 0x88, 0x65, - 0xbf, 0x04, 0x8a, 0x25, 0x83, 0x4f, 0xff, 0x61, 0x45, 0x4c, 0x8e, 0x37, 0x74, 0x70, 0x2a, 0x68, 0xf6, 0xd8, 0x8e, - 0xb9, 0x08, 0xc2, 0x97, 0x28, 0xf4, 0x4c, 0x63, 0x27, 0x57, 0x6d, 0x8e, 0x9e, 0xd8, 0x09, 0x6b, 0x1a, 0x05, 0x55, - 0xbb, 0xdf, 0xde, 0x2a, 0x15, 0x37, 0x57, 0x9c, 0xcf, 0x60, 0x8c, 0x27, 0x1d, 0x41, 0xe4, 0xcf, 0xfe, 0x02, 0xca, - 0xd0, 0x25, 0x8c, 0xb2, 0x65, 0xde, 0x8f, 0x26, 0xb7, 0x52, 0xc7, 0x92, 0xd0, 0xd4, 0xf5, 0xea, 0x8a, 0x54, 0xe1, - 0xfe, 0x2e, 0xfc, 0xb3, 0x06, 0x71, 0x87, 0x38, 0x87, 0x64, 0x01, 0x51, 0x3d, 0x63, 0x25, 0xc5, 0x20, 0x66, 0x36, - 0x28, 0x61, 0x4a, 0x9f, 0xb4, 0xda, 0x6a, 0x9d, 0x1c, 0x7b, 0x5c, 0xae, 0xea, 0x42, 0xd6, 0x2d, 0x7f, 0xa4, 0x45, - 0x22, 0x2d, 0x70, 0x85, 0xef, 0x2c, 0x00, 0x5d, 0x09, 0xe0, 0x29, 0x04, 0x72, 0x98, 0x84, 0xbf, 0x95, 0x55, 0xf4, - 0xe0, 0xfe, 0x6d, 0x98, 0x5b, 0x8e, 0x40, 0xc2, 0x87, 0xb9, 0x69, 0x8d, 0x3a, 0x8d, 0x4c, 0x6b, 0xd8, 0xba, 0x04, - 0xe2, 0x24, 0x41, 0x0b, 0x35, 0xf6, 0x71, 0x28, 0x1c, 0x7a, 0x1e, 0xb9, 0x49, 0xae, 0xe5, 0xca, 0x97, 0xa2, 0x39, - 0x89, 0x3d, 0x52, 0xd1, 0xb1, 0x9f, 0x91, 0xe3, 0xbc, 0x10, 0xe4, 0xe2, 0x48, 0x9a, 0x9e, 0x6a, 0x92, 0x43, 0x9b, - 0x0c, 0x2a, 0x94, 0xdb, 0x2c, 0x68, 0x73, 0x1b, 0xb1, 0xbf, 0x8e, 0x88, 0x0b, 0x1b, 0x40, 0x22, 0x9c, 0x5c, 0x55, - 0xfd, 0x2d, 0xb9, 0xbe, 0x6e, 0x7c, 0x55, 0x0b, 0x19, 0x0f, 0x28, 0x19, 0x4e, 0xea, 0xed, 0x19, 0x0a, 0xc3, 0xc5, - 0xfc, 0xb4, 0xbe, 0xb0, 0xd6, 0xd4, 0x6e, 0xa5, 0x48, 0x0a, 0x43, 0x9a, 0xf2, 0x44, 0xe2, 0x87, 0x65, 0x77, 0xb1, - 0x49, 0xc5, 0x8a, 0xc0, 0xfb, 0x9c, 0xf9, 0x73, 0xe1, 0xd4, 0x1a, 0xff, 0x21, 0xc0, 0xad, 0x39, 0x38, 0xa8, 0xbf, - 0x8b, 0xdc, 0x64, 0xab, 0x1e, 0x38, 0x4d, 0x7e, 0x74, 0x45, 0x3f, 0x8b, 0x62, 0xdc, 0x83, 0x41, 0x9e, 0xb3, 0x46, - 0x1c, 0x27, 0x5e, 0xa1, 0xc8, 0xa6, 0x12, 0xba, 0xdb, 0x75, 0xa6, 0x88, 0xeb, 0x90, 0xa3, 0x19, 0x72, 0x72, 0x38, - 0x4e, 0x5a, 0xcd, 0xa3, 0xb2, 0x49, 0x12, 0x9e, 0xe2, 0x47, 0xee, 0x13, 0x8a, 0x5d, 0x9f, 0x85, 0x32, 0x23, 0xce, - 0x19, 0x67, 0xdb, 0x0b, 0xae, 0xd1, 0x5b, 0x73, 0x90, 0x8e, 0x1d, 0xf6, 0xfc, 0x89, 0x22, 0x4c, 0x21, 0x65, 0xa7, - 0x26, 0x6d, 0xd2, 0x55, 0x97, 0x71, 0x9f, 0x0e, 0x75, 0x1c, 0x52, 0x3d, 0x3b, 0x1c, 0xea, 0xa5, 0x2d, 0x4f, 0x1c, - 0xe2, 0xca, 0x87, 0xfe, 0x38, 0xf2, 0xeb, 0xc2, 0x7a, 0x51, 0xc8, 0xf8, 0xa4, 0xd0, 0x49, 0x4b, 0x95, 0x78, 0x00, - 0xb7, 0x95, 0x4d, 0x6f, 0xcb, 0xd4, 0xda, 0xd0, 0x71, 0xe9, 0x6f, 0x02, 0xa4, 0x90, 0xc5, 0xa9, 0x5c, 0x0a, 0xe5, - 0x9a, 0xf1, 0xe2, 0xb0, 0xe2, 0xf6, 0xd5, 0x7d, 0xda, 0x57, 0x14, 0x1d, 0x20, 0x10, 0x11, 0x5a, 0x01, 0xc2, 0x17, - 0x26, 0x70, 0x75, 0x95, 0xa5, 0xb0, 0x8e, 0x09, 0xc1, 0x53, 0xf8, 0x46, 0x6a, 0xa5, 0x55, 0x46, 0xc4, 0x05, 0xdb, - 0x8d, 0x50, 0xf6, 0x00, 0x1a, 0x10, 0xc3, 0x49, 0xfc, 0x2f, 0x4f, 0x55, 0xcb, 0xb4, 0x5b, 0xc9, 0xa5, 0x91, 0x76, - 0xa3, 0x2d, 0xde, 0x98, 0x56, 0x14, 0x14, 0x13, 0x92, 0xbe, 0xd2, 0xa0, 0xd5, 0xb1, 0xf5, 0x9b, 0xbd, 0x5e, 0xbc, - 0x3a, 0xbe, 0xe3, 0xe4, 0x60, 0x94, 0x63, 0xc9, 0x20, 0x53, 0x11, 0xca, 0xc5, 0x45, 0xd8, 0x7a, 0xd8, 0xd9, 0x16, - 0xda, 0x69, 0xd0, 0x71, 0xb7, 0x82, 0x1a, 0x84, 0xf9, 0xd0, 0x73, 0xa7, 0xdb, 0x3e, 0x5d, 0x19, 0xb7, 0x8b, 0x78, - 0x95, 0xe3, 0x54, 0x55, 0x09, 0xa4, 0x64, 0xf3, 0x31, 0x48, 0x95, 0x24, 0x47, 0xa6, 0x0a, 0xeb, 0x1e, 0x6c, 0xef, - 0x98, 0x30, 0x09, 0x79, 0xe4, 0x7d, 0xf8, 0x27, 0x84, 0x5a, 0x8a, 0x7e, 0xdb, 0xf6, 0x6d, 0xc9, 0xe1, 0x95, 0xa3, - 0x55, 0x83, 0x80, 0xd8, 0x88, 0x00, 0x35, 0x8f, 0x8f, 0xf6, 0x26, 0x6e, 0xbd, 0xa3, 0x72, 0x37, 0x35, 0x7e, 0xcf, - 0x56, 0x76, 0x1e, 0xf9, 0x1d, 0xaf, 0xec, 0xe3, 0x42, 0x15, 0xec, 0x92, 0x12, 0x3d, 0xc9, 0xfa, 0xf1, 0xca, 0xa6, - 0x35, 0xfb, 0x79, 0x7d, 0x41, 0xc8, 0xe6, 0x55, 0xf6, 0xc8, 0xab, 0x42, 0xbd, 0x18, 0x09, 0x63, 0xaa, 0x43, 0x78, - 0xe3, 0xc8, 0xd8, 0x9f, 0x17, 0x32, 0x8d, 0x81, 0x05, 0x28, 0xb4, 0xd4, 0xbb, 0x11, 0x4f, 0x8f, 0x65, 0x56, 0xa4, - 0x75, 0x27, 0x5c, 0xc5, 0x7a, 0x09, 0x3f, 0xba, 0x0d, 0x58, 0x58, 0x29, 0xdd, 0x22, 0x97, 0x77, 0x75, 0x91, 0xf5, - 0xd9, 0x6b, 0x13, 0x43, 0xef, 0x92, 0x42, 0x85, 0xb2, 0x63, 0xca, 0x8a, 0xf9, 0x0a, 0x69, 0x8e, 0x05, 0x6f, 0x42, - 0xfd, 0xbc, 0x2d, 0x7f, 0x87, 0x2a, 0x16, 0x7f, 0x5d, 0xd1, 0x5b, 0xa7, 0x6a, 0xb6, 0xcf, 0x14, 0x33, 0x65, 0x3b, - 0x17, 0xee, 0x8b, 0xfb, 0x8d, 0x6f, 0x88, 0xa7, 0x62, 0xd5, 0x77, 0x45, 0x71, 0xe4, 0xa0, 0xc9, 0x20, 0xaa, 0x93, - 0xb5, 0x10, 0x77, 0x5d, 0x19, 0x92, 0x70, 0xe7, 0x09, 0x33, 0x48, 0xe7, 0xb0, 0x71, 0x55, 0x23, 0xd3, 0xa0, 0xe6, - 0x40, 0x9d, 0x54, 0x83, 0x15, 0xb4, 0x42, 0x52, 0xf6, 0x14, 0x33, 0x51, 0x07, 0xee, 0xf7, 0x9a, 0xfd, 0x3f, 0xa0, - 0x12, 0x7d, 0xdd, 0xf5, 0x57, 0x7d, 0x0b, 0xe7, 0x82, 0x05, 0x4b, 0x2a, 0xfb, 0x72, 0x5b, 0x6f, 0xfc, 0x29, 0x6c, - 0xea, 0xd4, 0xad, 0xbb, 0x5d, 0xea, 0x72, 0x9a, 0x0d, 0xce, 0x3b, 0x47, 0x31, 0x77, 0x0a, 0x1f, 0x62, 0x2e, 0x2f, - 0xd9, 0x44, 0x25, 0x57, 0x71, 0xe2, 0x45, 0x0d, 0x00, 0xf3, 0x0e, 0x90, 0x9c, 0x29, 0x61, 0x94, 0xf8, 0x73, 0x52, - 0x01, 0xd5, 0x94, 0xae, 0xb3, 0xb3, 0xee, 0x17, 0x7b, 0xfe, 0x8a, 0xbc, 0xbe, 0x72, 0x0c, 0xea, 0xe6, 0xbc, 0x20, - 0xa7, 0x98, 0x5f, 0x34, 0x25, 0x63, 0x4f, 0xb7, 0xad, 0xaa, 0x93, 0xb5, 0xcb, 0x8b, 0xda, 0x44, 0x89, 0x74, 0xc9, - 0x0d, 0x2f, 0xf5, 0xb6, 0xbc, 0x66, 0xcb, 0x93, 0x75, 0x7a, 0x2a, 0xd6, 0xd8, 0xbe, 0x08, 0x63, 0x7d, 0x18, 0x5d, - 0xe9, 0x49, 0x07, 0x39, 0x2d, 0x4b, 0x4b, 0xb9, 0x8b, 0x9c, 0x5b, 0xba, 0x5d, 0x3a, 0xcc, 0x8f, 0x19, 0x6b, 0x6f, - 0x8d, 0x8d, 0xad, 0xe5, 0xe6, 0xbf, 0xae, 0x6c, 0xc3, 0x54, 0xa1, 0x68, 0x01, 0x4c, 0xcf, 0x26, 0x87, 0xf5, 0x01, - 0x35, 0x53, 0x6f, 0x51, 0xbb, 0xe2, 0xf5, 0x4e, 0xf4, 0xbc, 0xfb, 0x0e, 0x6a, 0x86, 0x5a, 0x8f, 0x92, 0x68, 0xa9, - 0x7d, 0xef, 0x5b, 0x4a, 0x5b, 0xe6, 0xb1, 0xf2, 0xa2, 0xd4, 0x43, 0xfd, 0xea, 0x8f, 0xd3, 0xda, 0xb8, 0x27, 0xbc, - 0x65, 0xa3, 0xae, 0xe2, 0x63, 0x9f, 0xe7, 0xc2, 0xcc, 0x2c, 0x3e, 0x97, 0xd6, 0x83, 0x5f, 0x4e, 0xbb, 0x99, 0x39, - 0x3d, 0xbe, 0xa7, 0x83, 0xc4, 0x5c, 0x7a, 0x2f, 0x43, 0xa0, 0x68, 0x85, 0x66, 0x1d, 0x35, 0xcc, 0x79, 0x9f, 0x3a, - 0xc6, 0xcf, 0x7b, 0x4c, 0xc9, 0x1d, 0x3f, 0xe3, 0xf5, 0xd0, 0xa6, 0x9f, 0x3e, 0x66, 0xce, 0x87, 0x89, 0xf0, 0x6a, - 0x57, 0xa3, 0x13, 0x56, 0xe0, 0xeb, 0xa5, 0xc7, 0xc9, 0xa7, 0xbd, 0xaa, 0x5a, 0x5a, 0xdf, 0x7f, 0x6b, 0x62, 0x80, - 0xa9, 0x52, 0x7e, 0x45, 0xfb, 0xb9, 0xc6, 0x62, 0x86, 0x97, 0x74, 0xd9, 0xcb, 0x00}; + 0x1b, 0x4d, 0x98, 0xa3, 0x10, 0xd8, 0x38, 0x00, 0x40, 0x74, 0x87, 0x5d, 0x14, 0x95, 0xac, 0x9e, 0x80, 0x5a, 0x16, + 0xd8, 0x44, 0x44, 0xed, 0xc1, 0xec, 0xbf, 0x23, 0x3c, 0x6d, 0x9a, 0x66, 0x3e, 0x6c, 0xc2, 0x28, 0x09, 0x3c, 0x6e, + 0x5f, 0x78, 0xfd, 0x3f, 0x03, 0xf2, 0xf9, 0xd8, 0x63, 0x23, 0xd6, 0x47, 0x4b, 0xb8, 0x52, 0xbd, 0x19, 0x5a, 0x7e, + 0xdb, 0x62, 0xfe, 0x45, 0xae, 0x2e, 0x1e, 0x2d, 0x75, 0x91, 0x2c, 0x66, 0xae, 0xa8, 0x95, 0x97, 0xe5, 0x64, 0x1c, + 0xa1, 0xb1, 0x4f, 0x72, 0x37, 0x53, 0xad, 0xd7, 0x77, 0xc5, 0xf3, 0xe4, 0xa0, 0x14, 0x9b, 0xb4, 0xb7, 0x34, 0x79, + 0x53, 0x5a, 0x99, 0x3d, 0x9b, 0xa1, 0x10, 0x13, 0x89, 0x05, 0x2a, 0x24, 0x94, 0xa6, 0xe4, 0xff, 0xb9, 0xff, 0x75, + 0xd9, 0xfb, 0x75, 0xa6, 0xa9, 0x98, 0x1b, 0x1f, 0x5b, 0x7a, 0x5c, 0x81, 0xb1, 0xb3, 0x0a, 0xae, 0xc9, 0xb2, 0xec, + 0x1e, 0x07, 0x18, 0x23, 0x63, 0x4e, 0x30, 0xf8, 0x20, 0x79, 0x99, 0x27, 0x6e, 0xfa, 0xe2, 0x57, 0x65, 0x8a, 0xea, + 0x5b, 0x96, 0x99, 0xfe, 0xf3, 0x79, 0x49, 0xca, 0x42, 0xe8, 0x32, 0x2b, 0xe3, 0xe3, 0xd9, 0xb3, 0x25, 0xe6, 0xbc, + 0x95, 0xbc, 0x08, 0x22, 0xcb, 0xac, 0xb8, 0x36, 0x11, 0x41, 0x34, 0xc4, 0xb6, 0x9d, 0x84, 0x6a, 0x33, 0x6d, 0x59, + 0xb5, 0x0e, 0xb0, 0xb4, 0x63, 0x81, 0x75, 0xba, 0x40, 0x8f, 0x35, 0x96, 0x4f, 0x21, 0x76, 0xb0, 0x49, 0x5c, 0x24, + 0xdf, 0x39, 0xe5, 0x80, 0xb5, 0x79, 0x5b, 0x9a, 0xb4, 0x82, 0x0b, 0x5c, 0xab, 0x35, 0x95, 0x7d, 0xa0, 0x4c, 0x23, + 0x72, 0x77, 0x0f, 0x25, 0x36, 0x0e, 0xd8, 0x95, 0x65, 0x67, 0xff, 0xde, 0x54, 0xab, 0xb4, 0x01, 0x9a, 0xb3, 0x36, + 0x35, 0x2e, 0x35, 0x4e, 0x19, 0xc4, 0x95, 0xce, 0xf9, 0x24, 0xb8, 0x20, 0x64, 0xbf, 0xf7, 0xfe, 0x7f, 0xcb, 0x76, + 0x18, 0xa2, 0xbb, 0x89, 0x1d, 0x38, 0x8d, 0xe8, 0xb0, 0xf4, 0x35, 0xb4, 0x72, 0x9c, 0xf9, 0xff, 0x77, 0x03, 0xec, + 0x06, 0x28, 0x2d, 0x40, 0x51, 0x5b, 0x24, 0x87, 0x5b, 0x25, 0xb7, 0xc6, 0x6b, 0xe6, 0xac, 0xd3, 0x4c, 0xd5, 0x69, + 0xce, 0xd9, 0xc8, 0x46, 0x89, 0xf1, 0xd9, 0x55, 0x6e, 0xa3, 0xd0, 0xd8, 0xf4, 0xb2, 0x0b, 0x2f, 0x3d, 0x9d, 0x63, + 0x9b, 0xa9, 0x66, 0x65, 0x06, 0x9c, 0xbf, 0x4d, 0x72, 0xd6, 0x90, 0x03, 0xc2, 0x41, 0xca, 0x7d, 0xd8, 0x75, 0x91, + 0x65, 0x59, 0x72, 0x91, 0x0b, 0x9b, 0x37, 0x91, 0xcd, 0x94, 0x37, 0xa3, 0x12, 0x2a, 0xa9, 0x25, 0xac, 0xdc, 0xc4, + 0xf4, 0xc4, 0xbd, 0x7f, 0x17, 0x64, 0x62, 0x6c, 0x71, 0x00, 0x3e, 0x05, 0x71, 0xe8, 0xc8, 0xa6, 0xeb, 0x1c, 0xe7, + 0x18, 0xbd, 0x61, 0x21, 0x98, 0x66, 0x7b, 0x08, 0x0e, 0x7d, 0x38, 0xb0, 0x01, 0x8d, 0x3c, 0xcf, 0x1d, 0x5e, 0x7d, + 0x60, 0xeb, 0x7e, 0xf9, 0x68, 0x18, 0xf4, 0x78, 0xb3, 0x2a, 0x01, 0xb7, 0x91, 0x43, 0xab, 0x65, 0x64, 0x23, 0x50, + 0xcd, 0x6a, 0xec, 0xe7, 0xf0, 0x67, 0xf3, 0x7f, 0x5f, 0x9c, 0x1c, 0x55, 0x14, 0x4c, 0xff, 0x84, 0xbe, 0xbd, 0xef, + 0xf0, 0x0a, 0xe9, 0x92, 0xb5, 0x05, 0xec, 0x67, 0x14, 0xe5, 0x61, 0xe4, 0xa1, 0x0a, 0x5e, 0x7b, 0xd9, 0x4d, 0x97, + 0x39, 0x9e, 0x19, 0x33, 0x36, 0x5d, 0x70, 0x3d, 0x30, 0x73, 0x65, 0xe5, 0x78, 0xb4, 0xa5, 0x1a, 0x9c, 0x67, 0x0a, + 0x0d, 0xda, 0x74, 0xa3, 0xe5, 0xf4, 0x21, 0x1e, 0x3f, 0x4c, 0x29, 0x3d, 0xf7, 0x93, 0xad, 0x2b, 0xe3, 0xbe, 0xb2, + 0x24, 0x6b, 0x3a, 0xfc, 0x39, 0xe7, 0xab, 0x09, 0x91, 0x84, 0x95, 0x9e, 0x46, 0xa4, 0x5c, 0x1d, 0x75, 0xdc, 0xb2, + 0x8c, 0xb2, 0xf4, 0xe6, 0x77, 0x94, 0x69, 0xd2, 0x96, 0x8a, 0x14, 0x88, 0x37, 0xd7, 0xf9, 0xc3, 0x64, 0xcc, 0xab, + 0xa6, 0x3e, 0x3e, 0x1e, 0x6f, 0x7b, 0x37, 0xb4, 0x6c, 0x78, 0x8e, 0x66, 0x3d, 0x98, 0xba, 0x7d, 0xf3, 0x59, 0x1a, + 0x37, 0xe3, 0xe6, 0x42, 0xb6, 0x36, 0xfd, 0x75, 0x1e, 0x3e, 0x85, 0xeb, 0x66, 0xec, 0x96, 0xe5, 0xa1, 0xc3, 0xcd, + 0x5c, 0x51, 0xb2, 0x7a, 0x68, 0x77, 0xd0, 0xe7, 0xaa, 0x4b, 0xb8, 0xe9, 0xe5, 0x22, 0xcc, 0xd7, 0x63, 0x6c, 0x52, + 0xd8, 0xae, 0x5a, 0xbe, 0x4e, 0x57, 0x28, 0x32, 0x13, 0x85, 0x8a, 0xff, 0x08, 0x65, 0xe5, 0xe5, 0x95, 0x9e, 0x8c, + 0x63, 0x3f, 0x17, 0x5c, 0x47, 0x58, 0x4d, 0x2d, 0x92, 0x70, 0xfb, 0xa7, 0xb9, 0x9f, 0x41, 0x01, 0xf9, 0xb7, 0xa6, + 0xa2, 0x30, 0x95, 0xf6, 0x73, 0x10, 0xf9, 0x28, 0x93, 0x31, 0x0c, 0x01, 0x8a, 0x4c, 0x47, 0x00, 0x9e, 0x79, 0x42, + 0x9e, 0x8e, 0xe7, 0x18, 0x25, 0x06, 0xde, 0xef, 0xf8, 0x7a, 0x79, 0xb4, 0x30, 0xdc, 0x08, 0xd2, 0x7e, 0xee, 0xa3, + 0x24, 0x57, 0x37, 0x28, 0x40, 0x76, 0x76, 0x0a, 0x92, 0x27, 0x05, 0x26, 0xd9, 0x35, 0xcb, 0x51, 0x26, 0xc8, 0x9b, + 0x8e, 0x43, 0x49, 0x43, 0x5f, 0xe3, 0x25, 0x29, 0xfe, 0xce, 0x27, 0x15, 0x2a, 0xc5, 0xdf, 0xba, 0x6b, 0x93, 0xa7, + 0x06, 0x00, 0x21, 0x9d, 0x66, 0xba, 0xde, 0xf8, 0x41, 0x90, 0xd8, 0x2d, 0x25, 0xa0, 0xe1, 0x8a, 0x99, 0x6c, 0x0a, + 0xeb, 0xbe, 0x36, 0x75, 0xba, 0x77, 0x29, 0x73, 0x78, 0x3c, 0xd3, 0x80, 0xc8, 0x8c, 0xd0, 0x70, 0x6a, 0x44, 0xd0, + 0x30, 0xca, 0x7b, 0x84, 0xdb, 0x54, 0x31, 0x7c, 0xc0, 0xe9, 0x87, 0x1b, 0x62, 0x59, 0xc0, 0x54, 0x08, 0xb4, 0xf6, + 0x36, 0x36, 0x9a, 0xaf, 0xa4, 0x21, 0x16, 0x93, 0x3f, 0xe3, 0x96, 0x36, 0xfe, 0x55, 0xd5, 0x84, 0x06, 0x48, 0x82, + 0xcf, 0xcf, 0xda, 0x21, 0x61, 0x8c, 0x26, 0x75, 0xb1, 0x49, 0x7a, 0x42, 0xca, 0x1c, 0x48, 0x20, 0xa1, 0x86, 0x4c, + 0xe1, 0x9c, 0x4d, 0x2e, 0xc7, 0x3b, 0xde, 0x3e, 0x08, 0x47, 0x6b, 0x4b, 0x62, 0x79, 0x23, 0x49, 0xa9, 0x86, 0xb7, + 0x86, 0x1a, 0x8e, 0x6d, 0xaa, 0x47, 0xcc, 0x4f, 0x71, 0xb7, 0xa7, 0x35, 0x8e, 0x25, 0x7d, 0x6e, 0x86, 0x4b, 0xb9, + 0x9b, 0xaf, 0xfa, 0x85, 0x60, 0x57, 0x12, 0x4d, 0x2a, 0x51, 0x85, 0x9f, 0x7f, 0xfd, 0x1d, 0xc8, 0x5a, 0x2d, 0x0f, + 0x53, 0xfc, 0x1c, 0xf8, 0x71, 0xac, 0x4c, 0x61, 0x3d, 0x48, 0x2f, 0xd5, 0xe9, 0x4d, 0x6d, 0xde, 0x91, 0x41, 0x2a, + 0xdc, 0x4a, 0xb0, 0xbf, 0x19, 0x21, 0x62, 0x87, 0x99, 0xf8, 0xd7, 0x8d, 0x84, 0x44, 0xd2, 0x85, 0xc2, 0x39, 0xab, + 0xe1, 0xe2, 0x3f, 0xa4, 0x0c, 0xd1, 0x9c, 0x10, 0x0d, 0x13, 0xc6, 0x57, 0x46, 0x29, 0x48, 0xef, 0xe6, 0xab, 0x4d, + 0xd3, 0x86, 0xee, 0x88, 0x3f, 0xa8, 0x57, 0xb9, 0x8e, 0xb1, 0x21, 0xf2, 0x55, 0x22, 0x79, 0xf6, 0x38, 0x0c, 0xe3, + 0x60, 0x39, 0xc1, 0x53, 0x95, 0x11, 0xfe, 0x75, 0xaa, 0x0a, 0xee, 0x39, 0x9a, 0x55, 0xce, 0xdd, 0x17, 0xb9, 0xe6, + 0x5b, 0x50, 0xe3, 0xf3, 0x66, 0x6e, 0x86, 0xca, 0x68, 0xeb, 0x58, 0x4b, 0x06, 0xc9, 0x95, 0x65, 0x57, 0x80, 0x8d, + 0xe9, 0x28, 0x8e, 0x2c, 0x5a, 0x60, 0x6c, 0xf6, 0xd7, 0x30, 0xfd, 0x4f, 0xd0, 0x09, 0x91, 0xb6, 0x97, 0x12, 0x6a, + 0x65, 0xc6, 0x0f, 0x46, 0xa8, 0xb7, 0xb2, 0xf3, 0x29, 0x8b, 0x20, 0xc3, 0xf7, 0xac, 0xe5, 0x39, 0x9c, 0xc7, 0xe1, + 0xe2, 0x49, 0xa9, 0xfd, 0x22, 0x5c, 0x76, 0xbb, 0x55, 0xa2, 0xb8, 0xd5, 0x08, 0x89, 0x0d, 0xd7, 0x3f, 0x51, 0xad, + 0x15, 0x0c, 0x57, 0x54, 0x5c, 0x6b, 0xad, 0xf5, 0x31, 0x76, 0x29, 0xa5, 0x6c, 0x4c, 0x4f, 0xff, 0x59, 0x4a, 0x7d, + 0xb5, 0x94, 0x94, 0x21, 0x26, 0xef, 0x09, 0x75, 0xc7, 0x49, 0x15, 0x0c, 0x0b, 0xcf, 0xdc, 0x52, 0x71, 0x26, 0x51, + 0x09, 0x06, 0x46, 0xe5, 0xb4, 0x3f, 0x78, 0xbe, 0xeb, 0x5d, 0xb0, 0x04, 0x88, 0xb7, 0xc8, 0xf5, 0xbf, 0x15, 0x05, + 0x2b, 0xe6, 0x56, 0x00, 0x4e, 0xcc, 0x82, 0x84, 0x2b, 0x3a, 0x18, 0x24, 0xd4, 0x1b, 0x98, 0xc9, 0x35, 0xa2, 0x77, + 0x82, 0xf5, 0x22, 0xb7, 0xfa, 0x25, 0x0a, 0xb9, 0x4d, 0x49, 0x45, 0x02, 0xb7, 0xd5, 0xda, 0x30, 0xcd, 0x7a, 0xa6, + 0x99, 0x38, 0x3d, 0x67, 0x31, 0x65, 0x76, 0xd4, 0x5c, 0xbb, 0xba, 0x04, 0xb3, 0xbb, 0x3b, 0x3d, 0x33, 0xf4, 0xc3, + 0x32, 0x44, 0xdf, 0xcd, 0xbe, 0x26, 0x49, 0x0c, 0xa9, 0x6c, 0xc3, 0xda, 0xf4, 0xff, 0x78, 0xda, 0x09, 0x05, 0x7f, + 0xad, 0xd5, 0x0d, 0xc4, 0xbd, 0x88, 0x4e, 0x5d, 0x4e, 0x84, 0xf9, 0xfa, 0x62, 0x60, 0x3f, 0x29, 0x67, 0xb8, 0x46, + 0x3c, 0xb2, 0xb1, 0x37, 0xd4, 0x5b, 0x23, 0x5a, 0x86, 0xe4, 0xf3, 0x7e, 0x95, 0xf6, 0x0d, 0x65, 0x66, 0x5f, 0xec, + 0x87, 0x8b, 0x77, 0xda, 0x4c, 0x0e, 0xb8, 0xd3, 0xd0, 0xf3, 0xa6, 0xf9, 0x06, 0xf2, 0xac, 0x3d, 0x38, 0x61, 0x4f, + 0x27, 0xdd, 0xe9, 0xc6, 0xd5, 0x24, 0x6b, 0xe3, 0xa1, 0xc4, 0x90, 0xc0, 0xaf, 0x59, 0x4e, 0x00, 0x39, 0x10, 0x7b, + 0xc4, 0xda, 0xe4, 0x52, 0xb8, 0x7e, 0xa3, 0x45, 0x37, 0x30, 0xaf, 0x9b, 0xbf, 0xc8, 0x21, 0x95, 0xc5, 0x1b, 0x10, + 0x32, 0x32, 0x3f, 0xa3, 0x1c, 0x59, 0xc1, 0xa3, 0xf2, 0xf5, 0x21, 0x52, 0x87, 0x2f, 0xaf, 0xf6, 0x43, 0x63, 0xdf, + 0x22, 0xf3, 0xa2, 0x68, 0x2a, 0x33, 0x47, 0xb9, 0x0f, 0x90, 0xc4, 0x92, 0x67, 0x58, 0x61, 0x7c, 0xd5, 0xda, 0x88, + 0x08, 0xbe, 0x11, 0xc0, 0x7e, 0xf7, 0x49, 0x70, 0x6c, 0x63, 0x12, 0x28, 0xd0, 0xee, 0x66, 0x20, 0x41, 0x01, 0x99, + 0x38, 0x92, 0xd4, 0x8e, 0x06, 0x89, 0xfd, 0x09, 0xda, 0x76, 0x71, 0x45, 0x24, 0x1b, 0xfb, 0x39, 0x60, 0x21, 0x8d, + 0x0f, 0xa8, 0xcc, 0x80, 0x08, 0x4b, 0x01, 0x3a, 0x7a, 0xfe, 0xa9, 0x42, 0x5c, 0xcd, 0xb0, 0xf0, 0x9c, 0xc1, 0x5d, + 0x99, 0xaf, 0xfb, 0x79, 0xf6, 0xe0, 0x4c, 0x05, 0xc4, 0xe3, 0x89, 0x5f, 0x6e, 0x17, 0x28, 0x32, 0x10, 0xb4, 0x42, + 0x3c, 0x14, 0x84, 0x16, 0x8a, 0x18, 0xb4, 0xf9, 0x8f, 0x7d, 0xae, 0x8a, 0x91, 0x0a, 0x85, 0xa8, 0x68, 0x4d, 0xc6, + 0x70, 0x45, 0x9d, 0x23, 0x06, 0xdf, 0xcc, 0xd8, 0xa1, 0x65, 0xa2, 0x52, 0xbe, 0x54, 0xf1, 0x58, 0x07, 0xeb, 0x89, + 0x14, 0x32, 0x32, 0x52, 0x93, 0x9d, 0x6f, 0x21, 0x49, 0xf0, 0x4e, 0xad, 0x3c, 0x83, 0x14, 0x5e, 0xe9, 0xb0, 0x4f, + 0xa2, 0x5f, 0x86, 0x28, 0x8c, 0xda, 0xd7, 0x94, 0xbe, 0x9a, 0x89, 0xc4, 0xd8, 0x13, 0x79, 0x50, 0xa2, 0xe5, 0x1f, + 0xd9, 0x84, 0x91, 0x84, 0xe4, 0xd8, 0xf3, 0xe1, 0xdf, 0xe7, 0x04, 0xe9, 0xe3, 0xac, 0x87, 0xb4, 0x25, 0x11, 0x3e, + 0x51, 0x96, 0x03, 0xba, 0xee, 0x80, 0xa4, 0x00, 0xde, 0x75, 0xc1, 0xed, 0x7d, 0xdb, 0x21, 0x8e, 0x4e, 0xce, 0xa9, + 0x19, 0xe3, 0x65, 0x0a, 0x1b, 0x39, 0x1c, 0x6f, 0x93, 0x20, 0x6c, 0x44, 0xaf, 0x4c, 0xd3, 0xb1, 0xc0, 0x2c, 0x81, + 0x44, 0x88, 0xf4, 0x7e, 0x71, 0xce, 0x85, 0x98, 0xd7, 0x49, 0x66, 0xa8, 0x78, 0x6a, 0x95, 0xa9, 0x09, 0x32, 0x1c, + 0xe7, 0x2a, 0xbe, 0x27, 0x29, 0xc9, 0x13, 0xee, 0x62, 0xb2, 0x5f, 0x61, 0x1d, 0x25, 0x4f, 0x49, 0x41, 0xc9, 0xa8, + 0xe1, 0x7f, 0x99, 0xd2, 0x44, 0x62, 0x57, 0x76, 0x87, 0x24, 0x80, 0x94, 0x60, 0xa9, 0xce, 0xe0, 0x71, 0x44, 0x3c, + 0x17, 0x82, 0x86, 0x88, 0x44, 0xe1, 0x33, 0xdb, 0xcb, 0xcf, 0x22, 0x87, 0x04, 0xcf, 0x4c, 0x89, 0xce, 0xe2, 0x0f, + 0xd6, 0x71, 0x8f, 0x8c, 0x37, 0x1a, 0x46, 0x35, 0xaf, 0x0f, 0xda, 0x3e, 0x62, 0x6e, 0x7a, 0xfe, 0x30, 0x30, 0xd3, + 0xb1, 0xc9, 0x26, 0x95, 0x70, 0x56, 0x6e, 0xfe, 0xc6, 0x05, 0x8a, 0x8d, 0x5a, 0xa1, 0xe5, 0x67, 0x3d, 0xb5, 0x21, + 0xea, 0x9c, 0x13, 0xe2, 0x80, 0x03, 0x56, 0xb3, 0x60, 0x9e, 0x6b, 0xfc, 0xcf, 0x65, 0x72, 0x97, 0x1c, 0xc1, 0x99, + 0x1b, 0x4b, 0x73, 0x79, 0x15, 0xc9, 0xa1, 0x0b, 0xb6, 0x02, 0x55, 0x40, 0x39, 0xc9, 0x18, 0x23, 0xcb, 0x01, 0x23, + 0x96, 0x48, 0x2e, 0x17, 0x20, 0xb4, 0xc8, 0xba, 0x0a, 0xc2, 0x50, 0xa8, 0x9c, 0x46, 0xda, 0x70, 0x28, 0xe3, 0x18, + 0x99, 0xd6, 0x55, 0xdf, 0x19, 0x42, 0x96, 0xf2, 0xae, 0x01, 0xed, 0x28, 0x95, 0xbc, 0x94, 0xef, 0xa2, 0xdc, 0x9d, + 0xf0, 0x52, 0x18, 0x20, 0xcf, 0x1f, 0x15, 0x1b, 0x75, 0x47, 0x81, 0x17, 0x83, 0xf1, 0x42, 0x96, 0x0d, 0x77, 0x52, + 0xc9, 0x12, 0x13, 0x25, 0x08, 0x9c, 0x32, 0xd2, 0xd8, 0xa7, 0x2c, 0xed, 0xca, 0xfb, 0x2b, 0x4c, 0x2c, 0x4f, 0xca, + 0x28, 0x46, 0x3c, 0x39, 0xab, 0xb2, 0xae, 0x59, 0x3c, 0xc4, 0xfc, 0xc9, 0xdb, 0x24, 0xe5, 0x37, 0x3d, 0xd3, 0xe8, + 0x8d, 0x49, 0x67, 0x0d, 0x39, 0x9c, 0x4e, 0xc5, 0xe9, 0xec, 0x59, 0x5c, 0x35, 0x48, 0x55, 0x40, 0x11, 0x08, 0x87, + 0x3c, 0xf9, 0x26, 0x33, 0xda, 0x37, 0x01, 0x4b, 0xa5, 0x63, 0x28, 0x4f, 0xaa, 0x31, 0x26, 0x24, 0x2d, 0xf7, 0x3f, + 0x82, 0xe2, 0x4a, 0x8d, 0x24, 0x4b, 0xf0, 0xe1, 0x1d, 0x4a, 0x08, 0x4a, 0xc9, 0xa1, 0x83, 0x6e, 0x43, 0x45, 0x13, + 0x28, 0xa2, 0x27, 0x41, 0x9e, 0xaf, 0x37, 0x76, 0xaa, 0x14, 0x03, 0x9c, 0x98, 0xec, 0xca, 0xe3, 0x68, 0x66, 0x95, + 0x3e, 0xfb, 0x4f, 0x11, 0x1c, 0x0e, 0x87, 0x17, 0x34, 0x48, 0xa4, 0xf7, 0x5c, 0x91, 0x9b, 0x5a, 0x70, 0x7e, 0xba, + 0x10, 0x93, 0x59, 0x5b, 0x16, 0xd2, 0x72, 0x84, 0x62, 0x24, 0x87, 0x8e, 0xc0, 0xb6, 0x0c, 0xb9, 0xad, 0x91, 0xc8, + 0xe4, 0x5b, 0xfe, 0x1d, 0x87, 0x4c, 0x52, 0x32, 0xa5, 0xc9, 0x78, 0x2f, 0xa7, 0x22, 0xbb, 0x12, 0x45, 0x25, 0x32, + 0x2a, 0xa6, 0x41, 0x0c, 0xa9, 0xac, 0xde, 0xd3, 0x82, 0xa5, 0xba, 0x23, 0xb8, 0x3b, 0x27, 0xa4, 0x60, 0x19, 0x54, + 0xdd, 0x8e, 0xce, 0x38, 0xda, 0x20, 0x66, 0x5d, 0x92, 0xec, 0x27, 0xc5, 0x20, 0x9b, 0x48, 0xa1, 0x44, 0x3d, 0x61, + 0x37, 0x6e, 0x4b, 0x08, 0xfb, 0xdd, 0xc0, 0xc4, 0xd2, 0xb2, 0x4c, 0x93, 0x3e, 0x45, 0x62, 0xa7, 0x14, 0x8f, 0x50, + 0xf9, 0x14, 0xba, 0x77, 0xd3, 0x48, 0x48, 0x75, 0x92, 0x27, 0x08, 0xda, 0x73, 0x30, 0x76, 0x4c, 0xc0, 0x7c, 0x7f, + 0x0a, 0xd6, 0x8f, 0xd3, 0xb4, 0x60, 0xe1, 0xe0, 0x21, 0xc5, 0x9e, 0x99, 0xdd, 0xfc, 0xcb, 0x7c, 0x8e, 0x72, 0xce, + 0x0c, 0x9d, 0xcc, 0x53, 0x48, 0x66, 0xe3, 0xec, 0xe4, 0x5f, 0x90, 0xe6, 0xbd, 0x83, 0xdd, 0x91, 0xb6, 0xe1, 0xf7, + 0x99, 0xe0, 0xfa, 0x44, 0x0e, 0x23, 0xf8, 0xaa, 0x4b, 0x62, 0x37, 0x1f, 0x23, 0x8c, 0x22, 0x45, 0xaf, 0x1d, 0x07, + 0xe2, 0xb2, 0xda, 0x7d, 0x79, 0x10, 0x00, 0xb0, 0xa8, 0xf4, 0xef, 0x95, 0x88, 0x4c, 0xcc, 0x83, 0x5c, 0x06, 0x5b, + 0x19, 0xf0, 0xb3, 0x4a, 0xe2, 0x01, 0x97, 0x80, 0x4b, 0xe8, 0xb3, 0x02, 0x66, 0xa8, 0x01, 0xd4, 0xde, 0x79, 0x53, + 0x18, 0x46, 0x3a, 0x68, 0x4e, 0xb5, 0x86, 0xe2, 0x2d, 0x8a, 0x28, 0x1f, 0xfa, 0xb0, 0xf7, 0x61, 0x91, 0x01, 0x1d, + 0xfc, 0x38, 0x33, 0xa1, 0x3c, 0x4c, 0x9a, 0x31, 0x9a, 0x98, 0xe7, 0x19, 0xc5, 0xbd, 0xe1, 0xc2, 0xa4, 0xb7, 0x24, + 0x10, 0xd3, 0xbe, 0x6f, 0x4b, 0x45, 0x7c, 0xbf, 0x1b, 0x97, 0xfe, 0xd5, 0x7a, 0x04, 0xbd, 0x64, 0x16, 0x4a, 0xe4, + 0x5b, 0x2a, 0xd4, 0x91, 0x07, 0x86, 0xdb, 0x76, 0x6c, 0x98, 0x75, 0xa7, 0x95, 0xf4, 0x7a, 0x55, 0x35, 0xec, 0x80, + 0x71, 0x54, 0x5a, 0x7a, 0xaa, 0x5f, 0x1c, 0xd4, 0xe4, 0xf5, 0x62, 0xfd, 0xd5, 0x8e, 0xbd, 0x3c, 0x01, 0x99, 0x19, + 0xa3, 0xc1, 0x9c, 0x92, 0xc6, 0x0e, 0xa8, 0x85, 0x34, 0x94, 0x75, 0xb8, 0x8b, 0xa7, 0xb5, 0x12, 0x0e, 0x44, 0xe0, + 0x6c, 0xba, 0x4d, 0xac, 0x97, 0xdc, 0x0f, 0x1d, 0x40, 0x19, 0x1d, 0x3e, 0x77, 0x9b, 0x5a, 0x0c, 0xeb, 0x01, 0x6f, + 0x10, 0xd1, 0x42, 0x93, 0x0a, 0x2e, 0xb1, 0x43, 0xca, 0xa6, 0xca, 0xd0, 0x41, 0xe7, 0x5c, 0x53, 0x66, 0x65, 0xa5, + 0xf2, 0x2e, 0xaf, 0xa4, 0x9f, 0x66, 0x21, 0x1b, 0xeb, 0x2a, 0x68, 0x2c, 0xc8, 0x6f, 0x21, 0x00, 0xce, 0xa3, 0x99, + 0xbe, 0xd9, 0x00, 0x73, 0xb2, 0x64, 0xf9, 0xad, 0x3c, 0xaa, 0x2c, 0x56, 0xee, 0x2d, 0x47, 0xea, 0xc8, 0xc8, 0xa4, + 0xef, 0x4a, 0x01, 0x92, 0x0e, 0xc6, 0xe5, 0x8e, 0xd5, 0x9e, 0x31, 0x25, 0xba, 0x5f, 0x30, 0xc4, 0xda, 0xe1, 0xf0, + 0xcb, 0x91, 0xc3, 0xa1, 0x66, 0x90, 0x1d, 0x69, 0xf4, 0x20, 0x45, 0xf0, 0x22, 0x57, 0xb8, 0xe2, 0x8f, 0x65, 0xdb, + 0x96, 0x08, 0xe2, 0x29, 0xc2, 0xdf, 0x33, 0x49, 0xe8, 0xe3, 0x01, 0xa1, 0xbb, 0x90, 0xf6, 0xf9, 0x34, 0x93, 0xf5, + 0x23, 0x94, 0x91, 0x64, 0xfa, 0x3e, 0xd4, 0x54, 0xa6, 0xc1, 0x37, 0xbf, 0xe6, 0xa9, 0x41, 0xe5, 0x36, 0x98, 0x44, + 0x83, 0x92, 0x3b, 0x07, 0x18, 0x7e, 0xa4, 0x5c, 0xd5, 0xab, 0xa2, 0x93, 0x56, 0x66, 0x6a, 0x7f, 0x90, 0x39, 0x02, + 0x93, 0xd3, 0x43, 0x33, 0xd2, 0x40, 0x88, 0x00, 0x2f, 0x10, 0x88, 0xbc, 0x04, 0xca, 0x00, 0xb6, 0xe9, 0x5e, 0x1b, + 0x34, 0xc6, 0xe3, 0xf1, 0x33, 0xa2, 0x98, 0x48, 0x2a, 0xdf, 0x13, 0xc7, 0xd1, 0x68, 0xb1, 0x88, 0x54, 0xd0, 0x84, + 0x62, 0x06, 0xfe, 0xdc, 0x7c, 0xb0, 0x3c, 0xeb, 0x7d, 0xd6, 0x0c, 0x63, 0x4c, 0xb3, 0x74, 0xd3, 0x26, 0xe7, 0xc8, + 0xdd, 0x4f, 0x58, 0x5a, 0x33, 0x42, 0x46, 0x09, 0x9b, 0x32, 0xb4, 0xea, 0x5a, 0x57, 0x8a, 0x63, 0x38, 0x46, 0xe3, + 0xfc, 0x1d, 0x59, 0x74, 0xf8, 0x53, 0x8d, 0x4f, 0x1f, 0x63, 0xa4, 0xe5, 0xf9, 0xd9, 0xb7, 0x09, 0xc4, 0x2f, 0xa3, + 0x1a, 0x75, 0x25, 0xc2, 0xa2, 0x65, 0x82, 0xd4, 0x61, 0x43, 0x2f, 0x23, 0x5e, 0x5e, 0xb3, 0xb8, 0x23, 0x41, 0x0f, + 0x4a, 0xec, 0x89, 0x86, 0xd4, 0xed, 0x99, 0xd8, 0xda, 0x26, 0xf5, 0xfa, 0xf3, 0xc9, 0x4f, 0xf3, 0x64, 0x7f, 0x5c, + 0x26, 0x75, 0x8e, 0x0a, 0xc4, 0x51, 0x7b, 0xbb, 0xcc, 0x77, 0xc6, 0x5c, 0x79, 0xf4, 0xdd, 0x56, 0x32, 0x46, 0xd1, + 0x8c, 0xb4, 0x6c, 0x1c, 0x18, 0xd5, 0xc5, 0x0e, 0xd5, 0x77, 0x0a, 0xcb, 0x8f, 0xaf, 0xe4, 0xc8, 0x23, 0x4a, 0x02, + 0x55, 0xd7, 0x8f, 0x24, 0x94, 0x86, 0x71, 0x7e, 0x35, 0xd6, 0x3e, 0x26, 0xd7, 0x06, 0xc5, 0xd2, 0x9e, 0xc7, 0x8c, + 0x8f, 0xb8, 0xf9, 0xcb, 0xb5, 0x1e, 0x64, 0x45, 0xed, 0x39, 0xf1, 0x74, 0xd4, 0xa1, 0x6d, 0x16, 0x93, 0x4d, 0x30, + 0xc0, 0x07, 0x68, 0xc2, 0xda, 0x63, 0x51, 0xeb, 0x8f, 0xc1, 0xd7, 0x3e, 0x40, 0x80, 0x6b, 0x21, 0xac, 0x9c, 0xa2, + 0x40, 0xe9, 0xda, 0x96, 0x5c, 0x1f, 0xef, 0xda, 0xfd, 0x28, 0x23, 0x91, 0xed, 0x2a, 0x29, 0x51, 0x6c, 0xa7, 0x29, + 0xf5, 0x77, 0x4a, 0x7d, 0xf0, 0x28, 0x22, 0x3e, 0xe3, 0x44, 0x8f, 0x4f, 0x56, 0xdd, 0x3c, 0x69, 0x4f, 0x7a, 0xa1, + 0x74, 0x03, 0x5e, 0x5c, 0x56, 0xdd, 0x14, 0x9f, 0x1c, 0x7b, 0x29, 0x62, 0x6b, 0x09, 0xb2, 0x45, 0x14, 0x6d, 0x07, + 0x39, 0xe4, 0x7b, 0x16, 0x29, 0xa4, 0x66, 0xd3, 0x29, 0xee, 0x00, 0x3b, 0xc5, 0x78, 0xe5, 0x88, 0x59, 0xab, 0xc9, + 0x9c, 0x6b, 0x14, 0xc8, 0xcb, 0xa0, 0xea, 0xf7, 0xf6, 0x03, 0x79, 0x37, 0x9e, 0x3f, 0x09, 0xa9, 0x5c, 0x85, 0x9d, + 0xf9, 0xbd, 0xd0, 0xf8, 0x77, 0xa7, 0x3d, 0x89, 0x3c, 0x3c, 0xcc, 0x2f, 0x49, 0xef, 0xf7, 0x71, 0x5f, 0x90, 0x6b, + 0xf8, 0x59, 0x88, 0x84, 0x26, 0x7e, 0xb3, 0x29, 0x90, 0x3c, 0x56, 0x08, 0xb8, 0x50, 0x49, 0x35, 0x8b, 0xb5, 0x25, + 0x9c, 0xd3, 0x83, 0xfb, 0x19, 0x73, 0xee, 0x30, 0x3c, 0xc8, 0x95, 0xd0, 0xb8, 0xbc, 0xc6, 0xdd, 0xa0, 0xb6, 0xfe, + 0x45, 0x58, 0xc2, 0x6b, 0x64, 0x89, 0xb4, 0x2c, 0x9f, 0x51, 0xea, 0x04, 0x0d, 0x5f, 0xba, 0x50, 0x8c, 0xd7, 0x21, + 0x4e, 0xf5, 0x10, 0xdd, 0xdf, 0xb7, 0x23, 0xb5, 0x32, 0xde, 0x7e, 0x7a, 0xe3, 0xe1, 0xe9, 0xf0, 0x34, 0xee, 0x4a, + 0x3c, 0xa3, 0x5e, 0x06, 0x7f, 0x34, 0x64, 0x4a, 0x4d, 0x4f, 0xf1, 0xf6, 0xbf, 0x4a, 0xbd, 0xfe, 0x70, 0xe1, 0x7a, + 0x87, 0x49, 0x20, 0x9f, 0x94, 0x6f, 0x27, 0x53, 0xab, 0x9b, 0x27, 0xbb, 0x7b, 0xf5, 0xfc, 0x33, 0xcf, 0xa4, 0x8c, + 0x1b, 0x9c, 0x38, 0xea, 0x29, 0xb5, 0x89, 0x0a, 0x15, 0x3c, 0x47, 0xcf, 0x74, 0x6b, 0x7b, 0xdc, 0x3c, 0xde, 0x4c, + 0x33, 0x7f, 0xc4, 0x94, 0x27, 0xc5, 0xd6, 0xd3, 0x8d, 0x50, 0x6e, 0x28, 0xde, 0x85, 0x52, 0xd8, 0x84, 0xcf, 0xe8, + 0x3f, 0x9b, 0x30, 0x59, 0x45, 0x48, 0xfe, 0x40, 0xa0, 0x7c, 0x2a, 0xb3, 0x21, 0xed, 0x26, 0xa1, 0xa6, 0x85, 0x9c, + 0xa4, 0x9c, 0x66, 0xb2, 0x44, 0xd5, 0x00, 0x70, 0xe4, 0xa8, 0xb7, 0x88, 0x1b, 0xbc, 0xf3, 0x0b, 0x50, 0x38, 0x98, + 0xfa, 0x5b, 0x4f, 0xa2, 0x36, 0x77, 0x92, 0x72, 0x04, 0x93, 0xa2, 0xd8, 0x9d, 0x14, 0xb6, 0x5b, 0xe4, 0x2c, 0x6e, + 0xf1, 0x21, 0xa9, 0x2a, 0x42, 0x64, 0x31, 0x30, 0xc4, 0xab, 0x89, 0x76, 0x94, 0xe1, 0xc0, 0x37, 0x0b, 0x33, 0x9d, + 0xf0, 0xea, 0xb1, 0x8b, 0x04, 0x95, 0xc2, 0xcf, 0xd2, 0xc8, 0x12, 0xa7, 0xf4, 0xe0, 0x84, 0x01, 0xb7, 0xdc, 0x8a, + 0xd5, 0xf7, 0x57, 0x94, 0x99, 0x50, 0x9a, 0x89, 0xb1, 0xa2, 0x7e, 0x40, 0xc0, 0x3d, 0x49, 0x98, 0x78, 0x22, 0xf4, + 0xd6, 0x76, 0xcd, 0x3f, 0xa9, 0x3e, 0x5b, 0xf8, 0x42, 0x6c, 0x01, 0xf3, 0x86, 0xc0, 0x04, 0x1a, 0x37, 0x9b, 0x51, + 0x2c, 0xa1, 0xf1, 0x03, 0xca, 0xa2, 0xdb, 0x59, 0x82, 0xaa, 0xb7, 0x8a, 0x0e, 0x43, 0x5d, 0x00, 0x2d, 0xad, 0x9e, + 0xfd, 0x98, 0xeb, 0x7d, 0x1e, 0xe5, 0x56, 0x1f, 0x60, 0xac, 0x6e, 0x00, 0x1d, 0x69, 0xd8, 0xf6, 0x6a, 0x78, 0xb9, + 0xa7, 0x9a, 0x88, 0x33, 0x9e, 0x2c, 0xaf, 0x0c, 0xfd, 0x86, 0x6c, 0x3d, 0xee, 0x3c, 0x51, 0xbb, 0xa8, 0xbc, 0xec, + 0x00, 0x91, 0x5a, 0x58, 0xd9, 0x8c, 0x7a, 0x81, 0x64, 0x5d, 0xdf, 0xac, 0x4a, 0x48, 0xd2, 0x23, 0xec, 0x13, 0xfe, + 0x7a, 0x19, 0x49, 0xa8, 0xa0, 0xd5, 0x4c, 0x65, 0xe9, 0xda, 0x6c, 0x40, 0x2b, 0xc0, 0x40, 0x67, 0xe2, 0x21, 0x70, + 0xf4, 0xba, 0x5e, 0x7a, 0xe4, 0x33, 0x4c, 0x7d, 0x18, 0x4a, 0x6a, 0x96, 0x8d, 0xb6, 0x9e, 0xc4, 0xcf, 0xe9, 0x58, + 0x62, 0x43, 0x0b, 0x09, 0x6b, 0xd2, 0xde, 0x16, 0x7e, 0xd5, 0x99, 0xdd, 0xd4, 0xfb, 0xce, 0xe7, 0x22, 0x44, 0x58, + 0x79, 0x7e, 0x51, 0xaa, 0xb1, 0xa4, 0x10, 0xe1, 0xdd, 0xec, 0x85, 0x95, 0x58, 0xd6, 0x36, 0xef, 0x2b, 0xd3, 0xfc, + 0x4c, 0x4e, 0x7f, 0xed, 0x18, 0xa8, 0xa0, 0x5f, 0xf3, 0x72, 0x6b, 0x76, 0x22, 0x82, 0x47, 0xa5, 0x20, 0x1f, 0x68, + 0xe2, 0xb4, 0x29, 0x47, 0xdd, 0xbe, 0x8b, 0x55, 0x69, 0xbf, 0x01, 0x07, 0x6e, 0xff, 0x0d, 0xb0, 0x02, 0x29, 0x40, + 0xc0, 0xcc, 0xbd, 0xac, 0xb2, 0x1e, 0x84, 0x36, 0xc8, 0xa0, 0xcf, 0x49, 0xfc, 0xc1, 0xc7, 0x3d, 0xcb, 0x92, 0x81, + 0xad, 0x40, 0x0b, 0x08, 0x40, 0xe1, 0x36, 0xa2, 0x9f, 0xdf, 0x40, 0xbe, 0x62, 0x7e, 0xd4, 0xe0, 0x84, 0xfa, 0x2c, + 0xba, 0x2e, 0x82, 0xf3, 0x31, 0xb2, 0xf1, 0x07, 0x56, 0x43, 0x68, 0x22, 0xe2, 0xa8, 0x0d, 0x8a, 0x94, 0xa8, 0xa1, + 0x23, 0x3f, 0x35, 0x06, 0xda, 0xaa, 0xe2, 0x35, 0x7e, 0xd6, 0x66, 0xb7, 0x2e, 0x60, 0x91, 0x1f, 0x9c, 0x1e, 0xb9, + 0x20, 0xcc, 0x1e, 0xdc, 0x34, 0xfd, 0xbf, 0xa5, 0x70, 0xf9, 0x40, 0xcf, 0xc6, 0x63, 0x4d, 0xf1, 0x54, 0x39, 0xd3, + 0xc1, 0x8d, 0x91, 0x1f, 0xa5, 0xce, 0x21, 0xac, 0x14, 0xfe, 0x5b, 0xe6, 0x73, 0xbb, 0xf5, 0x61, 0xb2, 0xbb, 0x2c, + 0x88, 0xe0, 0xe2, 0x92, 0xbd, 0x41, 0xc5, 0x1b, 0x90, 0x39, 0x04, 0xd9, 0x3b, 0x9f, 0x6a, 0x7f, 0x6c, 0x28, 0x95, + 0x5f, 0xd7, 0x36, 0xdf, 0x86, 0x37, 0x07, 0xe9, 0x16, 0x48, 0xac, 0xd7, 0x04, 0x6d, 0xe5, 0xf9, 0x12, 0xcd, 0x06, + 0x0d, 0x45, 0x63, 0x66, 0xf7, 0x17, 0x75, 0xe6, 0x2a, 0xb8, 0x75, 0xf7, 0x42, 0xa3, 0xa2, 0x58, 0xe8, 0x7b, 0x95, + 0x4d, 0xe0, 0xa2, 0x87, 0x57, 0x32, 0x4f, 0xb7, 0x2b, 0x12, 0xb5, 0xd8, 0x08, 0x31, 0xcb, 0x1b, 0xdc, 0xde, 0x55, + 0xf6, 0x67, 0xb8, 0x93, 0x0d, 0x30, 0x5b, 0xd0, 0x5b, 0x76, 0x48, 0x90, 0xfa, 0xd4, 0x29, 0xe5, 0x97, 0xf5, 0x47, + 0x99, 0xb6, 0xc0, 0xab, 0xf5, 0x40, 0x15, 0x73, 0x30, 0x43, 0x9d, 0x56, 0xdc, 0xeb, 0x44, 0x32, 0xf4, 0xae, 0x28, + 0xcd, 0x20, 0x11, 0xf6, 0x09, 0x2f, 0x61, 0xfa, 0x01, 0x2b, 0x2f, 0xb7, 0xf0, 0xc6, 0xb1, 0xec, 0xb5, 0x3a, 0x28, + 0x09, 0xaa, 0x80, 0xfc, 0x61, 0x78, 0xd6, 0xb2, 0x26, 0x77, 0x87, 0x23, 0x81, 0x2f, 0x17, 0x32, 0x11, 0xcc, 0x0d, + 0xe4, 0xcb, 0xb9, 0xb8, 0x10, 0x89, 0x2a, 0xc4, 0x78, 0xc9, 0xd2, 0xd1, 0xbb, 0x71, 0xd2, 0xa8, 0xd5, 0xf4, 0xa1, + 0x50, 0x71, 0x1b, 0xd7, 0x7a, 0x74, 0xbc, 0x60, 0x39, 0x1b, 0x8d, 0xee, 0x8a, 0x75, 0x4b, 0x79, 0x0b, 0xa5, 0x11, + 0x36, 0x52, 0x5f, 0x90, 0x65, 0x69, 0x16, 0x58, 0x2f, 0xc0, 0x16, 0xc1, 0x62, 0xc0, 0xf2, 0xd6, 0x59, 0x16, 0xb1, + 0xfa, 0xbd, 0xaf, 0x55, 0x8e, 0xc3, 0x90, 0x25, 0x21, 0x89, 0xe6, 0x55, 0x14, 0xc6, 0x18, 0x6a, 0x1c, 0x4d, 0x51, + 0xa5, 0x84, 0x31, 0x77, 0x23, 0xc3, 0x2e, 0xd6, 0x39, 0xc6, 0xd2, 0x48, 0xd2, 0xf0, 0x4d, 0x39, 0xa6, 0x27, 0xab, + 0xb1, 0x36, 0x22, 0x1b, 0x39, 0x34, 0x9e, 0xcb, 0xd5, 0x8c, 0x55, 0xee, 0xd0, 0xdd, 0x5a, 0xa9, 0xec, 0x42, 0x13, + 0x0a, 0xa3, 0xbd, 0xc6, 0x35, 0xc9, 0xa2, 0x5d, 0x83, 0x55, 0xfa, 0x92, 0x66, 0x8f, 0x38, 0x94, 0x6f, 0xc3, 0x56, + 0x55, 0xea, 0x02, 0xcd, 0xf9, 0xd0, 0x2b, 0xfc, 0x8d, 0x74, 0x72, 0x8e, 0x8a, 0x1e, 0xdc, 0x74, 0xdb, 0xc5, 0xbf, + 0x68, 0xa1, 0xfb, 0x2c, 0x7f, 0xce, 0x3c, 0x16, 0x2a, 0x54, 0xab, 0xab, 0x89, 0x2d, 0x99, 0xa1, 0xe1, 0x6b, 0x02, + 0xae, 0x44, 0xbe, 0x18, 0x60, 0x67, 0x94, 0xce, 0x25, 0xed, 0x54, 0x0e, 0x49, 0x4b, 0x36, 0x4e, 0xdc, 0x64, 0x23, + 0xda, 0xe5, 0x8f, 0xb1, 0xc5, 0xca, 0x4b, 0xd6, 0xad, 0x0f, 0xac, 0xf3, 0xf8, 0x3c, 0xab, 0xbc, 0x75, 0x6f, 0xc6, + 0xbf, 0xda, 0x0c, 0x13, 0xf6, 0xce, 0x6e, 0x70, 0xa9, 0xec, 0xd8, 0xa8, 0x91, 0xd3, 0x13, 0x3b, 0x5a, 0xe6, 0x22, + 0xc3, 0x6b, 0xb4, 0xaa, 0xb1, 0x90, 0xe3, 0x16, 0x7e, 0x0e, 0x34, 0x12, 0x8b, 0xa4, 0x58, 0x40, 0xe7, 0xfb, 0xd5, + 0x87, 0x17, 0x58, 0xcd, 0x63, 0xae, 0xc9, 0xd4, 0xa2, 0xce, 0x9c, 0xba, 0x50, 0x7d, 0x5e, 0x75, 0x5f, 0xd7, 0x2a, + 0xb8, 0x10, 0xd7, 0x9f, 0xa0, 0xe9, 0xaa, 0x9e, 0xfb, 0x96, 0x83, 0xd4, 0x94, 0x67, 0x10, 0xc7, 0xfa, 0xd3, 0x73, + 0x73, 0x23, 0x5b, 0xad, 0x8f, 0xd6, 0x51, 0x26, 0x5e, 0x8c, 0xc4, 0x16, 0x7e, 0xc7, 0x19, 0xd4, 0xa2, 0xbe, 0xcf, + 0x2a, 0x8a, 0x93, 0x80, 0xcb, 0x70, 0x05, 0x27, 0x30, 0xd5, 0x02, 0x03, 0x25, 0x39, 0xd1, 0x80, 0x46, 0xd6, 0xb9, + 0x3a, 0x78, 0xb9, 0x33, 0xdf, 0x34, 0x09, 0xa1, 0x83, 0x39, 0x83, 0x7b, 0x25, 0xdf, 0xec, 0xbb, 0x4a, 0x1d, 0x4c, + 0xb5, 0xf3, 0xda, 0x84, 0xad, 0x66, 0x7a, 0xda, 0x35, 0xb4, 0x42, 0xf4, 0x5c, 0x52, 0xcf, 0x90, 0x32, 0x56, 0x91, + 0xaa, 0x59, 0x1a, 0x87, 0x77, 0x8f, 0x84, 0x94, 0x29, 0xdb, 0x9d, 0x83, 0xf3, 0x0e, 0xa2, 0x12, 0xa9, 0xb2, 0x6e, + 0x0b, 0x23, 0x03, 0x3d, 0xe7, 0x58, 0x57, 0x51, 0xac, 0xa0, 0x18, 0x82, 0x5c, 0xe8, 0xa4, 0x15, 0x49, 0xa5, 0x1f, + 0x77, 0x16, 0x96, 0x51, 0x67, 0x65, 0x2e, 0x96, 0xcd, 0x75, 0xd4, 0xbb, 0x51, 0xff, 0xcc, 0xbb, 0x76, 0x39, 0x1d, + 0x9b, 0xc0, 0x4c, 0x28, 0x85, 0x05, 0xd2, 0x2c, 0x7f, 0x8b, 0xd3, 0xfb, 0xf1, 0xae, 0xe8, 0xd7, 0xc3, 0x66, 0x21, + 0x73, 0xb6, 0x02, 0x07, 0x90, 0xe9, 0xb8, 0xfa, 0x9d, 0x23, 0xa3, 0x8c, 0x42, 0x21, 0xad, 0xef, 0x41, 0x31, 0xd8, + 0x8e, 0xa9, 0x84, 0xe8, 0xd8, 0xdc, 0xcd, 0x00, 0x1d, 0xb4, 0xb1, 0xd5, 0x7b, 0x08, 0x36, 0x93, 0xb4, 0xe2, 0x2c, + 0x81, 0x8e, 0xd5, 0x4f, 0x2d, 0x55, 0x2f, 0x0d, 0x81, 0x41, 0xbf, 0x05, 0x82, 0xc0, 0x0b, 0x11, 0x7e, 0x66, 0x5e, + 0xd9, 0x20, 0xc2, 0x43, 0xf7, 0x06, 0xa0, 0x0c, 0xb1, 0xd6, 0x51, 0x2f, 0x8b, 0x85, 0xf7, 0x97, 0x05, 0x6d, 0xd1, + 0xcc, 0x51, 0x24, 0xa0, 0x7f, 0x85, 0x13, 0x57, 0x96, 0xf1, 0x09, 0x20, 0xa0, 0xcf, 0x91, 0xa4, 0xf8, 0xe8, 0x7d, + 0xaf, 0x9f, 0xa6, 0x94, 0x48, 0x9d, 0xf3, 0xd2, 0x93, 0xdc, 0xe0, 0xef, 0x3b, 0xcf, 0x1b, 0xaf, 0xac, 0x4a, 0x9e, + 0xfb, 0x7b, 0xba, 0x64, 0x71, 0x3d, 0x70, 0x7c, 0xb5, 0x94, 0xc9, 0xe6, 0xca, 0xc5, 0x04, 0x59, 0xb0, 0xf1, 0xbe, + 0x67, 0x46, 0x61, 0xdf, 0x40, 0xbe, 0x2b, 0xe6, 0x23, 0x8c, 0x6b, 0x2b, 0x9e, 0xbd, 0x15, 0x0f, 0x73, 0x4e, 0x49, + 0x91, 0xd4, 0x76, 0x4e, 0x81, 0x54, 0x67, 0x54, 0x5b, 0x90, 0x21, 0xe6, 0x02, 0x59, 0xf5, 0x29, 0x0e, 0xce, 0x96, + 0xa6, 0x81, 0x28, 0x5a, 0xca, 0x8f, 0x0a, 0x15, 0x82, 0xff, 0x1a, 0x88, 0x99, 0x46, 0x15, 0x60, 0x6e, 0x24, 0xd4, + 0xe1, 0x20, 0x9e, 0xf0, 0x74, 0x2f, 0x4d, 0x2b, 0x4d, 0x27, 0xee, 0xb4, 0x88, 0xa8, 0xfe, 0x72, 0x6e, 0x93, 0xa0, + 0x59, 0xf5, 0x2a, 0x0a, 0x97, 0x62, 0x49, 0x04, 0xd7, 0xcb, 0xea, 0xaa, 0x1f, 0x51, 0xaa, 0x7b, 0x65, 0xc1, 0x75, + 0xce, 0x02, 0x83, 0xe3, 0x5b, 0x8f, 0x74, 0x7b, 0x9e, 0x2e, 0xaf, 0x91, 0xdb, 0xa6, 0xc0, 0x8d, 0x8f, 0x99, 0xd0, + 0x95, 0xb8, 0x9a, 0x0d, 0x74, 0x85, 0x79, 0xdb, 0xae, 0xf8, 0x4a, 0xb0, 0x36, 0xff, 0x75, 0x3f, 0x03, 0xef, 0x8b, + 0x17, 0x61, 0xc1, 0x4c, 0x15, 0x8c, 0x62, 0xe2, 0x17, 0x61, 0x89, 0x30, 0xbc, 0x68, 0x6e, 0xce, 0xf6, 0xf9, 0xe6, + 0x3c, 0x02, 0x1c, 0x16, 0xe5, 0x09, 0x73, 0x7b, 0x06, 0x14, 0x54, 0x9b, 0xb0, 0xa9, 0xd6, 0x80, 0xb1, 0x3d, 0x4b, + 0xf3, 0x31, 0xdf, 0x9b, 0x0e, 0x50, 0x4f, 0xad, 0x39, 0xc5, 0x60, 0x0c, 0x61, 0xa2, 0xdb, 0x80, 0x02, 0xa4, 0x26, + 0x0b, 0x87, 0xcc, 0xfa, 0x5b, 0xca, 0x0b, 0x6d, 0x62, 0x43, 0x5f, 0x92, 0xa5, 0xb5, 0x56, 0xf0, 0x13, 0x34, 0x4d, + 0xc1, 0x29, 0x0e, 0x3f, 0x48, 0xbc, 0xe7, 0xde, 0x79, 0x8d, 0x44, 0x46, 0x3d, 0x17, 0x7e, 0x21, 0xc2, 0xca, 0x7d, + 0xc4, 0x9c, 0x73, 0x53, 0x13, 0xb2, 0x2f, 0x5d, 0xb2, 0x96, 0xd5, 0x24, 0xe0, 0xd1, 0x73, 0xa1, 0x42, 0x3b, 0x23, + 0xde, 0x5d, 0x5b, 0x79, 0xab, 0x7a, 0x34, 0x03, 0x56, 0x73, 0xdc, 0xb6, 0x98, 0x86, 0xa9, 0x28, 0xa9, 0x84, 0x20, + 0x6e, 0x09, 0x91, 0x85, 0x61, 0xcb, 0x1a, 0x7b, 0x9f, 0x58, 0xad, 0xa7, 0x24, 0x00, 0x70, 0x25, 0x0d, 0xdd, 0x33, + 0x94, 0x09, 0xa9, 0x97, 0xb4, 0x40, 0x39, 0xe4, 0x6a, 0xe2, 0xe5, 0xc6, 0x3d, 0x86, 0x81, 0x1b, 0xb3, 0xb5, 0xc8, + 0x34, 0x26, 0x44, 0x96, 0x81, 0x00, 0x71, 0x68, 0x5e, 0x9a, 0xca, 0xa2, 0xd3, 0x4d, 0x50, 0x74, 0x51, 0x8f, 0x33, + 0x5c, 0x59, 0x88, 0xbb, 0x64, 0xe8, 0x1c, 0x78, 0x39, 0x5d, 0xe3, 0xe5, 0x24, 0x15, 0x02, 0xaf, 0x82, 0x95, 0x07, + 0x12, 0xd9, 0x03, 0xed, 0xa0, 0x6c, 0x00, 0x24, 0xb9, 0x13, 0x5c, 0x29, 0x48, 0x6b, 0x2b, 0xc8, 0x21, 0xfe, 0xa7, + 0xb6, 0x1c, 0xa5, 0x02, 0xf2, 0xd4, 0xb1, 0xe5, 0xa4, 0xf1, 0x3c, 0x5c, 0x0a, 0x6f, 0xa4, 0x36, 0xcc, 0x60, 0xc5, + 0x0a, 0x16, 0x22, 0x33, 0x25, 0xcf, 0xad, 0x60, 0x1b, 0xaf, 0xde, 0xc4, 0x8c, 0x44, 0x85, 0xe9, 0xa3, 0xd8, 0x59, + 0xdd, 0x0d, 0x13, 0x6c, 0x2b, 0x9e, 0xb2, 0xdb, 0x8f, 0xc8, 0x7f, 0x4c, 0x50, 0x92, 0xa6, 0xc3, 0x97, 0x4a, 0xa6, + 0x93, 0xf2, 0xe2, 0x9d, 0x16, 0x46, 0x4b, 0x0e, 0x01, 0x17, 0x7c, 0x06, 0xde, 0x9d, 0x89, 0xfc, 0xcb, 0xa6, 0x35, + 0xc9, 0x1c, 0xa3, 0xaa, 0x8a, 0x16, 0x12, 0x8d, 0x71, 0x51, 0xb6, 0x26, 0x16, 0x0f, 0x16, 0x57, 0x03, 0x48, 0xa6, + 0x31, 0x2c, 0xf0, 0xf2, 0xc8, 0x7c, 0xcd, 0xe6, 0x45, 0xf5, 0x44, 0x96, 0x8a, 0x2e, 0xc8, 0xe5, 0x67, 0x18, 0x9b, + 0x99, 0x32, 0xac, 0x16, 0xcb, 0x70, 0x38, 0x2b, 0x08, 0x7b, 0x3f, 0xe0, 0x82, 0xe7, 0xa6, 0x8f, 0xbe, 0x5c, 0x30, + 0xa9, 0x1c, 0x46, 0x26, 0x7f, 0x96, 0x5c, 0xbc, 0x22, 0xac, 0x9e, 0x6c, 0xe7, 0x00, 0x72, 0xa1, 0x06, 0xe5, 0x08, + 0x87, 0x96, 0x13, 0xd8, 0xc4, 0x58, 0x44, 0x67, 0xd5, 0x54, 0x35, 0xc2, 0xd2, 0x7c, 0xe9, 0x46, 0x99, 0x37, 0xd9, + 0x76, 0x86, 0x4c, 0xd8, 0xba, 0x1f, 0x89, 0x20, 0x37, 0x1e, 0x0c, 0xa5, 0x31, 0xef, 0xc3, 0x1a, 0xac, 0xfa, 0x44, + 0x5e, 0xce, 0xa3, 0xaa, 0x11, 0x32, 0xc3, 0x29, 0xf9, 0x69, 0xf4, 0x14, 0x4d, 0x77, 0x92, 0x13, 0x2a, 0xda, 0x24, + 0x2a, 0x0a, 0x6c, 0xe2, 0x45, 0x29, 0x24, 0x82, 0xe2, 0x2e, 0xc7, 0x43, 0x0d, 0xf3, 0x0f, 0x27, 0x07, 0x57, 0x22, + 0x56, 0x07, 0x6e, 0xef, 0x1b, 0x48, 0xf2, 0x33, 0x99, 0xf4, 0x46, 0xba, 0x77, 0x37, 0xe5, 0xe1, 0x69, 0x22, 0x33, + 0xf7, 0x91, 0xb8, 0x8e, 0x8b, 0x8a, 0x42, 0x05, 0xdc, 0x6f, 0xd5, 0x5e, 0x7c, 0x92, 0x74, 0x82, 0xcc, 0x30, 0x73, + 0x6d, 0x7c, 0x6e, 0x8a, 0x91, 0x9a, 0x93, 0x54, 0x81, 0x7c, 0x72, 0xf7, 0x97, 0x46, 0x90, 0x9b, 0xf0, 0x17, 0xdf, + 0x3b, 0xaf, 0x93, 0xf2, 0x1c, 0xed, 0xe7, 0x44, 0xf4, 0xba, 0x1c, 0x6d, 0x31, 0x68, 0x63, 0x4e, 0x8c, 0x7c, 0xbb, + 0xbb, 0x88, 0x7c, 0xb9, 0xe1, 0x14, 0xc3, 0x54, 0x05, 0x32, 0x79, 0xf8, 0x43, 0x2d, 0x0f, 0x84, 0xfd, 0x29, 0x99, + 0x61, 0xa6, 0x89, 0xa8, 0xc2, 0x16, 0x38, 0x05, 0x0e, 0xd4, 0x5c, 0x39, 0x51, 0xf3, 0x70, 0xa0, 0x4a, 0x71, 0xf6, + 0x49, 0x22, 0xce, 0x5c, 0xc6, 0x36, 0x7e, 0x25, 0x5d, 0x30, 0x97, 0xd5, 0x48, 0x5b, 0x11, 0x2d, 0x8f, 0x14, 0x02, + 0x82, 0x5a, 0x8a, 0xa5, 0xd8, 0x12, 0x40, 0x30, 0xbe, 0xc5, 0xf3, 0xfb, 0x18, 0xb1, 0x0a, 0xc5, 0xcb, 0x34, 0xb2, + 0xa2, 0x5d, 0x7e, 0x63, 0x17, 0xa6, 0x0b, 0x26, 0xe0, 0x66, 0x24, 0xc5, 0xc8, 0x73, 0x87, 0x57, 0xe5, 0x46, 0xea, + 0x74, 0xbf, 0x2d, 0x0a, 0x05, 0x4f, 0x8b, 0x46, 0xe7, 0xc6, 0x54, 0x11, 0x5c, 0x35, 0x2a, 0xb6, 0x38, 0x38, 0x9c, + 0x7f, 0xa8, 0x99, 0x85, 0x74, 0x4d, 0x94, 0x23, 0x89, 0xfc, 0x7e, 0x11, 0x1c, 0x6a, 0x94, 0x17, 0xa2, 0x10, 0xa9, + 0x9f, 0x18, 0x72, 0x59, 0xc4, 0xec, 0x30, 0x37, 0xaa, 0xcb, 0x16, 0xc0, 0x96, 0xae, 0xc3, 0xc8, 0x50, 0x88, 0x3c, + 0x62, 0x98, 0x99, 0x26, 0xf5, 0x71, 0xe5, 0x20, 0x8b, 0xae, 0x52, 0x83, 0x34, 0xef, 0xb8, 0x91, 0x37, 0x49, 0xa2, + 0x84, 0x0c, 0xf1, 0xcc, 0x7c, 0x52, 0x67, 0x27, 0xb1, 0x57, 0x69, 0x29, 0xa4, 0x23, 0xd5, 0x4d, 0xa2, 0xd8, 0xe9, + 0x3e, 0x13, 0x7a, 0x5f, 0xb5, 0xf7, 0xb1, 0x18, 0xbc, 0x6e, 0x9b, 0x30, 0x7f, 0xf4, 0xf9, 0x4d, 0x7c, 0x47, 0x4d, + 0xd5, 0x13, 0x69, 0x41, 0x27, 0xa1, 0x35, 0x00, 0xee, 0xf3, 0xe6, 0xce, 0xf6, 0x60, 0xb8, 0x4d, 0x00, 0x5a, 0xc1, + 0x59, 0x4e, 0x37, 0x42, 0x56, 0xb7, 0x4f, 0x5a, 0xa7, 0x89, 0x8b, 0x38, 0xd8, 0x01, 0xd2, 0x10, 0xb8, 0x0a, 0x3e, + 0x67, 0x5f, 0x21, 0xf5, 0x23, 0x35, 0xb1, 0xb3, 0x4d, 0xd2, 0x83, 0xb6, 0xf7, 0xc9, 0xe5, 0xbc, 0x9f, 0x67, 0x2c, + 0x26, 0x0b, 0xf7, 0x4e, 0xfe, 0x10, 0xae, 0xe2, 0xa8, 0x16, 0x23, 0xe6, 0x0a, 0x01, 0xa6, 0xaa, 0x61, 0xb8, 0xd9, + 0x57, 0x4a, 0x63, 0xdc, 0x62, 0x0a, 0x40, 0x05, 0xc7, 0xa4, 0x5e, 0x7d, 0x0c, 0x55, 0xeb, 0xfe, 0xe7, 0x0a, 0xd6, + 0xd5, 0x6e, 0x59, 0xef, 0xf4, 0x00, 0x98, 0x00, 0xfc, 0x01, 0xa8, 0xaa, 0xe7, 0xe5, 0xce, 0xbf, 0xb0, 0x57, 0x10, + 0xa4, 0x24, 0xe0, 0x5e, 0x25, 0xfd, 0xdf, 0x6a, 0x1a, 0x08, 0x9a, 0xaf, 0x97, 0xf5, 0xb1, 0xcf, 0x44, 0x22, 0xf7, + 0x3c, 0x69, 0xf1, 0xf1, 0x1e, 0x78, 0x0b, 0x38, 0x7e, 0x19, 0x5b, 0x97, 0x74, 0xce, 0xfc, 0x41, 0x02, 0xcb, 0x1b, + 0xb5, 0xaf, 0x1e, 0x5f, 0xd2, 0x89, 0x60, 0xa7, 0x28, 0x50, 0x1f, 0x22, 0x02, 0x4a, 0x04, 0x4a, 0x8e, 0xb4, 0x84, + 0xee, 0x27, 0x9f, 0xa0, 0x5a, 0x40, 0x48, 0x9d, 0x12, 0x16, 0xf5, 0xed, 0xa0, 0x8e, 0xe0, 0x6d, 0x33, 0x72, 0xe2, + 0xc0, 0xb9, 0x81, 0x93, 0xf2, 0x39, 0xec, 0x6a, 0x84, 0xcb, 0xe3, 0x0d, 0x9e, 0xc0, 0x97, 0xe8, 0x37, 0x8e, 0x6f, + 0xe2, 0x79, 0x8b, 0x41, 0xe4, 0x1c, 0xb2, 0x9c, 0x7c, 0x21, 0xa2, 0x46, 0x24, 0x09, 0x75, 0xd8, 0x85, 0x90, 0xd6, + 0x17, 0x30, 0x38, 0x5e, 0x31, 0x8d, 0xa1, 0x7a, 0x18, 0x83, 0xc1, 0xe6, 0xf9, 0xed, 0xe9, 0x74, 0xeb, 0x21, 0xf9, + 0x20, 0xea, 0x8b, 0x88, 0x77, 0x4d, 0xa9, 0x51, 0x64, 0x79, 0xd8, 0xb4, 0xae, 0x53, 0xc3, 0x7b, 0x88, 0xc3, 0xbf, + 0x0a, 0x90, 0x00, 0xc5, 0x6e, 0xd3, 0xe7, 0x5c, 0xb0, 0xd1, 0x3b, 0x4d, 0x44, 0x68, 0xa1, 0x19, 0xa4, 0x70, 0xd5, + 0x7c, 0x81, 0x95, 0x69, 0xa7, 0xff, 0x45, 0xe7, 0xb6, 0x24, 0x01, 0x41, 0xb4, 0xd2, 0xef, 0xab, 0x30, 0x61, 0x89, + 0x31, 0x01, 0xde, 0x11, 0x62, 0xce, 0x33, 0x58, 0x49, 0x2c, 0x40, 0x72, 0xb4, 0x5e, 0x97, 0x1f, 0xcb, 0x74, 0x8a, + 0xd1, 0xe8, 0x4d, 0x9d, 0x64, 0xaa, 0xf5, 0xb5, 0x04, 0xf0, 0xc7, 0x79, 0x0d, 0x5b, 0xe6, 0x1e, 0x08, 0xb0, 0x63, + 0x25, 0xa1, 0x49, 0xb7, 0x64, 0xa7, 0xba, 0xe3, 0x66, 0x93, 0x9a, 0x72, 0x3f, 0x6f, 0x55, 0xb2, 0x54, 0x82, 0xc3, + 0xba, 0xf6, 0xa0, 0xfc, 0x21, 0x15, 0xe6, 0x32, 0x54, 0x56, 0x7b, 0x08, 0x90, 0xb0, 0x94, 0xe4, 0xa3, 0x9a, 0x21, + 0xe5, 0xe3, 0x53, 0x45, 0x91, 0x90, 0x33, 0x5e, 0x2c, 0x6b, 0xc0, 0x00, 0xef, 0xce, 0x5d, 0x4a, 0xeb, 0x1d, 0x7a, + 0xe4, 0xbd, 0x47, 0xbc, 0x80, 0xbd, 0x29, 0x61, 0x8f, 0x3b, 0x04, 0x69, 0x5f, 0x33, 0x14, 0xf2, 0x6f, 0x86, 0x92, + 0xc6, 0xfe, 0x7d, 0xcb, 0xe9, 0x41, 0xcf, 0xc8, 0xf4, 0x91, 0x0b, 0x7f, 0xae, 0xf1, 0xf6, 0x83, 0x7b, 0xb6, 0x61, + 0x3c, 0xad, 0x24, 0x30, 0x64, 0x13, 0x77, 0xf3, 0x92, 0x57, 0x6c, 0xb1, 0x7c, 0x77, 0xfe, 0x3a, 0x59, 0xa3, 0x20, + 0x70, 0x0b, 0x3e, 0xd0, 0x32, 0x52, 0x69, 0x90, 0x94, 0x14, 0xaf, 0xce, 0x81, 0x49, 0xa7, 0x9b, 0x5a, 0x25, 0x6a, + 0xd5, 0xa0, 0x57, 0x7d, 0x8a, 0x61, 0x59, 0xd2, 0xb6, 0xc4, 0x42, 0xb3, 0xdf, 0x87, 0x01, 0x26, 0x3f, 0x46, 0xce, + 0xb8, 0xbd, 0x03, 0xba, 0x07, 0x45, 0x6d, 0x19, 0x27, 0x41, 0x52, 0xaa, 0x20, 0x80, 0x74, 0xbf, 0xce, 0x63, 0x79, + 0xd5, 0x31, 0xd1, 0x61, 0xd1, 0xaa, 0x11, 0xc8, 0x09, 0x75, 0x63, 0x04, 0x86, 0x90, 0x3d, 0x53, 0xb1, 0xf4, 0xd0, + 0xeb, 0x30, 0x54, 0x7d, 0xee, 0xc7, 0xb0, 0xa6, 0xd5, 0x98, 0x25, 0x0f, 0x92, 0xcc, 0xa8, 0xfa, 0x46, 0xdf, 0xa2, + 0xd5, 0x59, 0xcf, 0xf1, 0x9e, 0x37, 0xd3, 0xd0, 0x4d, 0x65, 0xff, 0xd0, 0xd8, 0x81, 0x7f, 0x5b, 0x46, 0xa2, 0x5a, + 0x72, 0x96, 0xf6, 0x4a, 0xe6, 0x53, 0x8f, 0x02, 0x54, 0xdf, 0xf1, 0xee, 0xd2, 0x80, 0x28, 0x39, 0x3a, 0x77, 0x9b, + 0x1b, 0x70, 0xa9, 0x3e, 0xd0, 0x38, 0x3d, 0x86, 0x62, 0x60, 0xe7, 0xb7, 0xaf, 0xa7, 0xeb, 0x10, 0x23, 0x87, 0x51, + 0xe0, 0x28, 0xbd, 0xf4, 0x2e, 0x79, 0xb5, 0xe2, 0xc6, 0x15, 0xb6, 0xbb, 0x97, 0x96, 0xdf, 0x25, 0xdb, 0xb0, 0x3a, + 0xc9, 0xfe, 0x18, 0xd9, 0xd8, 0xc7, 0x74, 0xac, 0x8e, 0xd1, 0xb9, 0x73, 0x00, 0x5c, 0xb9, 0x94, 0xc0, 0xdd, 0x4a, + 0xae, 0x8e, 0x7f, 0xe5, 0xf6, 0x54, 0x4e, 0x37, 0xbd, 0x2e, 0x5f, 0x3e, 0x39, 0xbb, 0x8a, 0x07, 0xad, 0xd0, 0x50, + 0x66, 0xe9, 0xb2, 0x4a, 0xea, 0x02, 0x79, 0xd6, 0xf1, 0x5c, 0xb8, 0xeb, 0x2f, 0xbd, 0x8d, 0xd0, 0x80, 0x3d, 0x43, + 0x58, 0xcd, 0xa5, 0xa1, 0x3f, 0x97, 0xb3, 0x1e, 0x7b, 0x8b, 0x26, 0x13, 0xed, 0x2d, 0x7a, 0x4c, 0x69, 0x1c, 0x27, + 0xec, 0x0f, 0x38, 0x35, 0xde, 0x87, 0x74, 0xb5, 0x80, 0xd5, 0xc3, 0x2f, 0x0c, 0xc8, 0xcc, 0x01, 0x6e, 0xf7, 0xfc, + 0x73, 0xca, 0xd7, 0xbc, 0x8a, 0x42, 0x75, 0x93, 0x07, 0xd5, 0x94, 0x6c, 0x59, 0x07, 0x1b, 0xf6, 0xcf, 0x0a, 0x41, + 0x2d, 0x80, 0xe5, 0xd4, 0x74, 0xd9, 0xec, 0x7d, 0x12, 0xda, 0xb6, 0x5b, 0x4a, 0x78, 0x6f, 0x61, 0x4f, 0xec, 0xce, + 0xf2, 0x34, 0x29, 0x0f, 0xe3, 0x7f, 0x4c, 0xc8, 0x74, 0xc8, 0x5d, 0xb5, 0x92, 0x96, 0x29, 0xa6, 0xca, 0xde, 0x6f, + 0x1c, 0xbb, 0x39, 0x63, 0x24, 0x3e, 0x41, 0x0d, 0x1f, 0x2e, 0x3b, 0x7a, 0xb4, 0xe8, 0xed, 0x07, 0x47, 0x1a, 0x98, + 0xfa, 0x41, 0x46, 0x6e, 0x2a, 0x63, 0x1d, 0x00, 0x25, 0x4b, 0xf4, 0x67, 0xcb, 0x2e, 0x2d, 0x2a, 0x44, 0xa1, 0xc2, + 0xed, 0xec, 0x0f, 0xf7, 0x32, 0xab, 0x14, 0x11, 0xed, 0xde, 0x95, 0xe0, 0x0c, 0x71, 0x47, 0xbc, 0xe5, 0xa4, 0x01, + 0xc5, 0x68, 0xd1, 0x41, 0x4b, 0x8a, 0xb6, 0x47, 0xeb, 0xd5, 0x52, 0xca, 0xf3, 0xcc, 0x89, 0xec, 0x28, 0x60, 0xfd, + 0x70, 0x38, 0xf4, 0xed, 0x67, 0x55, 0xa4, 0xdd, 0x8f, 0xd9, 0x02, 0x77, 0x00, 0xf7, 0x5b, 0x16, 0xa6, 0x18, 0xa2, + 0xf3, 0x97, 0xd4, 0x18, 0x5d, 0x3f, 0x0a, 0x41, 0x1b, 0x8c, 0x21, 0x4f, 0x98, 0x5c, 0x93, 0x84, 0x86, 0x34, 0x46, + 0xad, 0x51, 0x20, 0x39, 0x27, 0xa6, 0x91, 0x98, 0x2d, 0x58, 0x4f, 0x23, 0x29, 0x5d, 0x44, 0xc8, 0x4c, 0x50, 0xd1, + 0x83, 0x22, 0x58, 0x92, 0x91, 0x16, 0xa9, 0xdc, 0x8b, 0x8e, 0xe2, 0x3d, 0x1f, 0x41, 0x73, 0xcd, 0xad, 0x1a, 0xd2, + 0x83, 0xe5, 0x8d, 0x86, 0x82, 0xac, 0xd2, 0xf1, 0x92, 0xfb, 0xa8, 0x0e, 0x22, 0x83, 0xa6, 0xad, 0xdf, 0xf6, 0x97, + 0xf1, 0x58, 0x93, 0x79, 0x46, 0x24, 0x18, 0x32, 0x0c, 0x39, 0x8c, 0x91, 0x7b, 0xab, 0xd2, 0xd3, 0x0f, 0x32, 0xf4, + 0xbb, 0xc5, 0x08, 0x60, 0xe2, 0x2b, 0x61, 0xb2, 0x2e, 0x77, 0x6a, 0xd4, 0x79, 0x97, 0x71, 0x22, 0x63, 0xe1, 0xfe, + 0xa3, 0xb0, 0x36, 0x24, 0x5a, 0xaf, 0x6e, 0xec, 0xf9, 0xc7, 0x0d, 0x7e, 0x52, 0x9a, 0x22, 0x6a, 0x4d, 0x52, 0xa7, + 0x03, 0x75, 0x4b, 0x1c, 0x83, 0xa3, 0x7c, 0x5c, 0xbc, 0xf0, 0xa0, 0xa5, 0x72, 0x43, 0x49, 0xac, 0x44, 0xdf, 0xdc, + 0x23, 0xfb, 0x02, 0x1a, 0x7b, 0x0a, 0xba, 0xd9, 0xe2, 0xa8, 0x56, 0xc6, 0x50, 0x8a, 0x39, 0x1c, 0xf6, 0xa1, 0xac, + 0x61, 0xa5, 0x3a, 0xb6, 0x5e, 0x1a, 0x77, 0xe3, 0x81, 0xc8, 0x50, 0x3b, 0x34, 0x0e, 0x71, 0x5f, 0x33, 0x23, 0x37, + 0x43, 0x13, 0xde, 0x21, 0x63, 0x70, 0x27, 0x8e, 0x97, 0x1a, 0x4b, 0xc2, 0x48, 0x88, 0x41, 0xbf, 0xb8, 0x17, 0xb3, + 0x45, 0x15, 0x24, 0x88, 0x6b, 0x1b, 0x15, 0x60, 0xe3, 0x15, 0xa2, 0x42, 0x7b, 0x6c, 0xeb, 0x78, 0x9e, 0x19, 0xb9, + 0x02, 0xc3, 0xc4, 0x1b, 0xd9, 0x8d, 0x9e, 0xa7, 0x72, 0xfc, 0x17, 0x61, 0xf5, 0x33, 0x16, 0x6c, 0xdd, 0x8a, 0x82, + 0x3f, 0x41, 0xe8, 0xe1, 0x41, 0xfb, 0x79, 0x89, 0x75, 0xfc, 0x8f, 0xad, 0xdf, 0x50, 0xd3, 0xaa, 0xd3, 0xd0, 0x0f, + 0xc7, 0x0f, 0x9d, 0x46, 0x07, 0xf9, 0xa7, 0xaf, 0x2e, 0x2d, 0x6e, 0x9a, 0xee, 0x6a, 0x5c, 0xbb, 0xaf, 0x50, 0x7d, + 0x38, 0xb6, 0x55, 0x17, 0xec, 0x0f, 0xe3, 0x38, 0xdc, 0x80, 0xc7, 0xc3, 0xf3, 0xe0, 0x06, 0x3c, 0xb8, 0xbf, 0x34, + 0xa6, 0xc7, 0xb3, 0xe7, 0x4b, 0xef, 0x2e, 0xc3, 0xb9, 0xc8, 0x35, 0x26, 0x7b, 0xea, 0xd7, 0xb6, 0x8b, 0x23, 0x8d, + 0xc0, 0xe8, 0xe8, 0xcd, 0x74, 0x41, 0x8d, 0x6b, 0x92, 0x51, 0x6b, 0x50, 0x7e, 0x42, 0x38, 0xbd, 0x7f, 0x7f, 0x6b, + 0x74, 0x84, 0x42, 0xc4, 0x8b, 0xc0, 0x7f, 0xdf, 0xc1, 0xdb, 0x7a, 0xd8, 0x99, 0x56, 0x67, 0xb9, 0xc4, 0x53, 0xd8, + 0x57, 0xa3, 0x5b, 0xd7, 0xe3, 0xc8, 0x28, 0xbd, 0xfc, 0xe0, 0x25, 0xc6, 0xc9, 0x4d, 0x7e, 0xc4, 0xb1, 0xaa, 0xdb, + 0x8b, 0xd5, 0x9f, 0x07, 0x41, 0x11, 0xfe, 0xf1, 0x82, 0x8c, 0x0f, 0x91, 0x8e, 0x72, 0x2a, 0x96, 0x62, 0x5a, 0x51, + 0x8d, 0x03, 0x50, 0x34, 0xfa, 0x25, 0xf4, 0xd5, 0x34, 0x18, 0x9b, 0xe7, 0x4a, 0x18, 0xdf, 0xf1, 0xbf, 0x1f, 0xbc, + 0xfb, 0x05, 0x9b, 0xe5, 0x2e, 0x18, 0xd6, 0x7d, 0x18, 0xa9, 0x4f, 0x02, 0xa8, 0xac, 0x9e, 0x65, 0x35, 0xd1, 0x76, + 0x50, 0xc7, 0xab, 0x99, 0xed, 0xbf, 0xef, 0x1c, 0x42, 0x4f, 0xab, 0x99, 0x52, 0x40, 0xe5, 0x96, 0x77, 0x88, 0x87, + 0xfa, 0x12, 0xbe, 0x8f, 0xf5, 0x55, 0xcc, 0xaf, 0xa8, 0xfa, 0x32, 0x56, 0x51, 0x10, 0x9a, 0x1f, 0x30, 0x34, 0xfc, + 0x90, 0x3c, 0xe3, 0x86, 0x83, 0xb9, 0x5f, 0x42, 0xff, 0xb2, 0xbe, 0x3f, 0x24, 0xf6, 0xb5, 0x8f, 0xdb, 0x75, 0xf3, + 0x35, 0xa7, 0x74, 0x18, 0x25, 0x78, 0x8e, 0xe3, 0xe6, 0xd0, 0x59, 0x1b, 0xed, 0xe8, 0xd4, 0x17, 0x69, 0x1d, 0x5d, + 0x60, 0xe8, 0xfb, 0xcc, 0x25, 0x5e, 0x39, 0xe2, 0xa0, 0x8f, 0xc4, 0x0d, 0x47, 0xdd, 0x5e, 0xd5, 0x8e, 0xd1, 0x31, + 0x06, 0x79, 0x29, 0x04, 0x90, 0x1c, 0xaa, 0xa7, 0xcd, 0xa2, 0x4d, 0x57, 0xce, 0x06, 0xe5, 0x9f, 0xeb, 0x5e, 0x3c, + 0xa0, 0x05, 0xa3, 0xba, 0xe1, 0x2f, 0x1e, 0xd2, 0xb8, 0xa1, 0xe5, 0x28, 0x2a, 0x25, 0x45, 0xa0, 0xb4, 0x8d, 0x0a, + 0x7a, 0xb3, 0x40, 0xf9, 0x60, 0xe9, 0x8f, 0x85, 0x2c, 0x75, 0x10, 0x2c, 0xe5, 0x34, 0xf5, 0x4a, 0x19, 0xd8, 0x63, + 0x23, 0xfe, 0xd3, 0x19, 0x1a, 0x44, 0xe6, 0xe6, 0x81, 0x1d, 0xe2, 0xe5, 0xa8, 0xa4, 0xa1, 0xbc, 0x61, 0xa0, 0x20, + 0xa8, 0xa9, 0x60, 0x11, 0xa4, 0xa8, 0x31, 0xed, 0x51, 0x31, 0xc8, 0xdc, 0xea, 0xb8, 0x81, 0x2e, 0x5f, 0x25, 0xb1, + 0x4b, 0xb5, 0xdb, 0x20, 0x57, 0x15, 0x3f, 0x06, 0xcf, 0x44, 0x5a, 0x07, 0xe9, 0x05, 0x8a, 0xa0, 0x2b, 0x8a, 0x48, + 0xaf, 0xca, 0x78, 0x11, 0xd6, 0xa2, 0xdc, 0x6a, 0xf4, 0xa0, 0x61, 0x18, 0x49, 0x85, 0xb7, 0x8d, 0x28, 0xc5, 0x7e, + 0x66, 0x5f, 0x61, 0x14, 0x3e, 0xe8, 0x50, 0x46, 0x9e, 0x2c, 0xda, 0xba, 0xf7, 0x6e, 0xd2, 0x88, 0x45, 0xa2, 0xce, + 0x6b, 0x1e, 0x99, 0xd2, 0x41, 0x93, 0x7c, 0x74, 0x5e, 0xce, 0xbc, 0x61, 0x32, 0xb2, 0x53, 0x72, 0x5c, 0x6a, 0x05, + 0x18, 0xb1, 0xf9, 0xdb, 0x6f, 0x1d, 0xc7, 0x33, 0x9f, 0x8e, 0x7e, 0x24, 0x3c, 0x5f, 0x66, 0x9e, 0x79, 0xba, 0x2d, + 0x0a, 0x97, 0x5c, 0x98, 0x53, 0xa1, 0x52, 0x83, 0x21, 0xf0, 0x57, 0x31, 0x78, 0x51, 0x26, 0xb8, 0x39, 0xb5, 0xeb, + 0x3e, 0xba, 0x8c, 0x88, 0x0e, 0xdf, 0x54, 0x68, 0xe6, 0xeb, 0xd7, 0xc9, 0x9d, 0x5c, 0x28, 0xa7, 0xd7, 0xaa, 0xc0, + 0xcb, 0x52, 0x65, 0x50, 0x8c, 0x51, 0xa5, 0xf4, 0xbc, 0xa0, 0x51, 0x9d, 0xa8, 0x14, 0x1c, 0x9a, 0xb1, 0xc0, 0x7f, + 0x48, 0xec, 0x2e, 0x79, 0xe8, 0x54, 0x00, 0x64, 0xca, 0xa2, 0xa1, 0xa3, 0x02, 0xf9, 0xdd, 0xc7, 0xd6, 0x8c, 0xb9, + 0x6a, 0x75, 0x59, 0x83, 0x14, 0x45, 0xdb, 0x53, 0x82, 0x34, 0x74, 0x87, 0x8b, 0x6d, 0x8a, 0x10, 0x6f, 0x0e, 0xc5, + 0x20, 0xa0, 0x15, 0x1a, 0x5f, 0x62, 0xaa, 0x95, 0x16, 0xf5, 0x80, 0xc2, 0xcb, 0x56, 0xc1, 0xdf, 0x72, 0xc1, 0x7d, + 0x81, 0x86, 0x43, 0x4c, 0x80, 0x00, 0x0c, 0x64, 0xb5, 0xfc, 0xfb, 0xa8, 0xa4, 0x98, 0xe9, 0x7b, 0xb5, 0xf9, 0x84, + 0xf7, 0xa5, 0x69, 0x72, 0x46, 0x30, 0x49, 0x71, 0x17, 0x32, 0x64, 0x11, 0xe1, 0xde, 0x2b, 0x3a, 0xa0, 0x6b, 0x2b, + 0x9a, 0x39, 0xf5, 0x88, 0x24, 0xb4, 0x05, 0x84, 0xd8, 0xe0, 0xc3, 0x6c, 0x59, 0x0e, 0x8d, 0x60, 0xd6, 0xc0, 0x8c, + 0xf9, 0x5e, 0xcb, 0x08, 0xa2, 0x92, 0x55, 0x2f, 0xbf, 0x07, 0x0e, 0xb4, 0xec, 0x4d, 0x60, 0xd1, 0x49, 0xda, 0x54, + 0x18, 0x08, 0x33, 0xf7, 0xe3, 0x07, 0xcf, 0x55, 0x32, 0x34, 0x7d, 0xac, 0x49, 0x0b, 0x8f, 0x86, 0x1b, 0x07, 0x5c, + 0xf9, 0xf8, 0x5c, 0xa2, 0x90, 0x37, 0xca, 0xb0, 0x2b, 0x77, 0x0e, 0xa8, 0x8f, 0x4c, 0x8d, 0x32, 0x04, 0x39, 0x01, + 0x19, 0xf0, 0xa0, 0xe3, 0x20, 0xf9, 0x3f, 0x20, 0x19, 0x19, 0x9c, 0xc0, 0xbd, 0x32, 0x23, 0x94, 0x2d, 0x28, 0xfc, + 0x91, 0x65, 0xdb, 0x07, 0xb4, 0xe7, 0x33, 0x9a, 0x14, 0x07, 0x92, 0x8d, 0x12, 0x3c, 0x8f, 0x7e, 0xa1, 0x84, 0x26, + 0x68, 0x93, 0x67, 0xe8, 0x23, 0xd9, 0x18, 0x29, 0x44, 0x26, 0x02, 0x07, 0x95, 0x03, 0xb1, 0x75, 0xc1, 0x40, 0x3e, + 0xb3, 0xe3, 0xce, 0xb8, 0xfd, 0x51, 0x70, 0x9d, 0x08, 0xdb, 0x1c, 0x7e, 0xa8, 0xd5, 0x61, 0xec, 0xa7, 0x81, 0xeb, + 0x16, 0xac, 0x6e, 0x95, 0x9e, 0xa1, 0xab, 0x8e, 0xf8, 0x4d, 0x4e, 0x8d, 0x98, 0xb6, 0xe9, 0xae, 0x6e, 0xb7, 0x9b, + 0xea, 0x55, 0xb6, 0xa0, 0x2e, 0x63, 0xf7, 0x5a, 0x55, 0x6b, 0xc6, 0xf2, 0xb0, 0xd0, 0xca, 0xec, 0xf3, 0x9f, 0xc5, + 0xd0, 0x99, 0x68, 0x3a, 0x34, 0x02, 0x25, 0x57, 0x51, 0xc4, 0xd3, 0x87, 0xd5, 0x35, 0xd7, 0x36, 0x99, 0xf8, 0x2b, + 0xa7, 0x8f, 0xaf, 0x1d, 0x37, 0xdf, 0x11, 0x46, 0xbd, 0xe7, 0x8e, 0x1b, 0x70, 0xae, 0x46, 0xbc, 0x1c, 0x3d, 0xf3, + 0x94, 0x57, 0xcb, 0xbb, 0xd2, 0x1c, 0x05, 0xcf, 0xb5, 0x9f, 0x5b, 0x4a, 0x3d, 0x2d, 0x4b, 0x1e, 0xb3, 0x0f, 0xb6, + 0x91, 0xdb, 0x30, 0xd6, 0x9b, 0x74, 0x43, 0xc6, 0x3b, 0x0e, 0xf8, 0x64, 0xa5, 0xa8, 0x2b, 0xfd, 0x9e, 0xaa, 0x49, + 0x0a, 0x1b, 0xcd, 0x6c, 0x37, 0xd4, 0x78, 0x17, 0x30, 0x4d, 0x87, 0xb7, 0x02, 0xc9, 0x81, 0x07, 0xe5, 0xda, 0x12, + 0xa6, 0x78, 0xdc, 0x9c, 0x0a, 0x48, 0x32, 0xac, 0xa6, 0x21, 0x37, 0xbf, 0x2a, 0xa4, 0x21, 0xa1, 0xce, 0xd5, 0x01, + 0x68, 0x95, 0x92, 0x07, 0x38, 0x94, 0x43, 0x01, 0xe6, 0xca, 0xa1, 0x67, 0x68, 0x50, 0x08, 0x46, 0xe8, 0xcd, 0xdb, + 0xe8, 0xf0, 0xd4, 0xe1, 0x43, 0x69, 0x5c, 0xe6, 0x14, 0xc4, 0x2f, 0x1f, 0xfb, 0x48, 0x3d, 0x1a, 0xeb, 0x4e, 0x3e, + 0x51, 0x87, 0xe7, 0x4b, 0xc8, 0xa5, 0x09, 0xdd, 0x27, 0x9c, 0x54, 0x33, 0x21, 0x0b, 0xf9, 0x37, 0x79, 0xaa, 0x46, + 0xb1, 0xa0, 0xf6, 0xea, 0xb9, 0x91, 0xec, 0x8e, 0x3e, 0xcb, 0x51, 0xf8, 0x6a, 0x1c, 0x6e, 0xb5, 0xc2, 0xae, 0x07, + 0x21, 0x2f, 0xbe, 0x70, 0x73, 0xbf, 0xf9, 0x9a, 0x53, 0xd0, 0xfd, 0xa9, 0x03, 0xcf, 0x6d, 0xf1, 0x8a, 0x66, 0x77, + 0x54, 0x07, 0x16, 0xed, 0xfd, 0xfb, 0xa0, 0x1c, 0xb7, 0xf5, 0x59, 0x07, 0xee, 0xfe, 0x91, 0xd8, 0x8d, 0x81, 0xbc, + 0x41, 0x19, 0xef, 0x67, 0x3f, 0xa5, 0x0f, 0x45, 0x42, 0x36, 0xac, 0x31, 0x40, 0x8e, 0x5c, 0x98, 0xf5, 0xb8, 0x31, + 0x67, 0xa7, 0x5d, 0x1e, 0x4a, 0xd0, 0xdd, 0xd6, 0xfe, 0xe3, 0x7a, 0x84, 0xb3, 0xb8, 0x15, 0x60, 0xf2, 0x77, 0x6e, + 0x2c, 0xbb, 0xaa, 0xdb, 0x0b, 0x87, 0x9e, 0x1e, 0xa9, 0xe0, 0xbd, 0xd1, 0x9c, 0x64, 0x5a, 0xb5, 0xbd, 0xda, 0x9f, + 0xfd, 0x92, 0x7f, 0xab, 0x74, 0xbf, 0x6d, 0x09, 0x39, 0x72, 0xb1, 0x82, 0x5d, 0x67, 0x92, 0xc2, 0xf6, 0xd7, 0x2d, + 0x77, 0xcc, 0x69, 0x70, 0xe2, 0x66, 0x4b, 0xe4, 0x3b, 0x7c, 0x1b, 0xc8, 0x26, 0x50, 0x94, 0xfd, 0x38, 0xc0, 0x3e, + 0x8c, 0xa9, 0xb4, 0x49, 0x46, 0x2b, 0x6f, 0xf4, 0xbe, 0x7d, 0x57, 0x28, 0x63, 0xcf, 0x8a, 0x05, 0xb9, 0x8a, 0x84, + 0x1c, 0xb0, 0x1e, 0xcb, 0x54, 0x42, 0x87, 0xc6, 0x73, 0x17, 0xd1, 0x97, 0x45, 0x74, 0xef, 0xe5, 0xbe, 0x4f, 0x62, + 0x9b, 0xd6, 0xdb, 0x29, 0x8f, 0xe4, 0x7f, 0xc4, 0xf8, 0x43, 0x56, 0x05, 0x79, 0x00, 0x1e, 0xef, 0xaf, 0x36, 0x74, + 0x9d, 0xa7, 0x41, 0x99, 0x71, 0x10, 0x45, 0x40, 0xe9, 0x72, 0x53, 0xe4, 0x9c, 0x6e, 0x68, 0x94, 0x4a, 0xd9, 0x16, + 0x83, 0xc0, 0xc8, 0x56, 0x35, 0xea, 0xc5, 0xfc, 0x10, 0x9a, 0x26, 0xa3, 0x3f, 0x9e, 0x49, 0x59, 0x0d, 0xe5, 0xdc, + 0xc5, 0x3a, 0x39, 0xb6, 0x8c, 0x7d, 0x0d, 0xd1, 0x07, 0x87, 0xad, 0xfa, 0x11, 0x33, 0x0f, 0x79, 0xf7, 0x50, 0x80, + 0x81, 0xf9, 0xae, 0x27, 0xdf, 0x94, 0xd2, 0xad, 0xca, 0x52, 0x69, 0x04, 0xa1, 0x0a, 0x6c, 0xb2, 0x37, 0x3c, 0x1a, + 0xe8, 0x89, 0x92, 0x8b, 0x91, 0xc1, 0x14, 0x48, 0x00, 0xd5, 0xb4, 0x0f, 0x7f, 0x4d, 0x2d, 0x94, 0x8c, 0xf4, 0x52, + 0x60, 0x0e, 0xe9, 0xbf, 0x21, 0x21, 0x60, 0x32, 0x00, 0xab, 0x2f, 0xfc, 0x66, 0x12, 0xff, 0x98, 0x0f, 0x7c, 0x04, + 0x9f, 0x30, 0x51, 0x23, 0x52, 0xfe, 0x41, 0x79, 0x9f, 0x8e, 0x9c, 0x29, 0x59, 0x3b, 0x2b, 0x05, 0x0e, 0x15, 0x57, + 0x53, 0x18, 0xc2, 0xd3, 0x83, 0xb0, 0x88, 0xa1, 0x1b, 0xc8, 0x7a, 0xb0, 0xe3, 0x09, 0xd3, 0x88, 0xda, 0x64, 0xaa, + 0x86, 0x92, 0xf6, 0x47, 0xc1, 0xe2, 0xc0, 0x9a, 0x00, 0xe4, 0x58, 0x68, 0x5a, 0x74, 0x19, 0x91, 0x79, 0xb0, 0x14, + 0x8e, 0xc0, 0xa9, 0x09, 0xb9, 0x9e, 0x55, 0xe6, 0x3d, 0x4f, 0x0a, 0x0e, 0xe2, 0x09, 0xf6, 0xce, 0x18, 0xf1, 0x4e, + 0x9e, 0x5d, 0xed, 0x4f, 0xb9, 0xde, 0x05, 0x2f, 0xb9, 0x8c, 0x20, 0x97, 0x39, 0x7e, 0x31, 0x98, 0x86, 0xfb, 0x07, + 0x30, 0x17, 0x19, 0x82, 0x7c, 0xe8, 0x50, 0x82, 0x3b, 0x2c, 0x46, 0x9b, 0xd5, 0xc0, 0xc3, 0x8d, 0x22, 0x4b, 0x26, + 0x83, 0x80, 0x08, 0x4c, 0xab, 0x7c, 0x47, 0x05, 0x70, 0x15, 0x17, 0xda, 0x98, 0xa2, 0xb8, 0x5e, 0x51, 0xed, 0x38, + 0xa3, 0xbd, 0x64, 0x33, 0xf3, 0x71, 0x9a, 0x96, 0x36, 0xd4, 0x6a, 0xe2, 0xd4, 0x91, 0x14, 0xcd, 0xd0, 0x79, 0x73, + 0x91, 0x8a, 0x64, 0xa6, 0x0f, 0xe6, 0x0f, 0x1d, 0x09, 0x6c, 0x94, 0x56, 0x30, 0xc8, 0xf9, 0x1a, 0x3b, 0x73, 0x97, + 0xb6, 0xbe, 0xce, 0xda, 0x30, 0xe7, 0xd3, 0x55, 0x3f, 0x4d, 0x09, 0x54, 0x3b, 0x4d, 0xfd, 0xd9, 0x8a, 0xd8, 0x2f, + 0xd2, 0x2e, 0xcb, 0x42, 0x93, 0xe5, 0xde, 0x8f, 0x1f, 0xee, 0xe3, 0x61, 0xa1, 0xba, 0x0b, 0x73, 0x29, 0x47, 0x38, + 0xb2, 0x58, 0x8b, 0xd5, 0x31, 0xfb, 0x19, 0x25, 0x1b, 0xcb, 0x7d, 0x0f, 0x4a, 0xb2, 0xe3, 0xe5, 0xa5, 0x34, 0x97, + 0x7a, 0xf1, 0x5d, 0x0c, 0x96, 0x03, 0xfc, 0x59, 0xa1, 0x9a, 0xe8, 0x5d, 0x59, 0xad, 0xf4, 0x9f, 0x75, 0xc9, 0x45, + 0x5d, 0x39, 0xe3, 0xda, 0x93, 0x21, 0x4c, 0x13, 0x9a, 0xef, 0x18, 0x62, 0x53, 0xc5, 0x44, 0x49, 0x34, 0xd2, 0x36, + 0x70, 0xbc, 0x7f, 0x5e, 0x9f, 0x45, 0x2a, 0x72, 0xd9, 0x2f, 0xd7, 0x71, 0xc7, 0x2f, 0x40, 0x15, 0xc0, 0x0d, 0x42, + 0x0f, 0x72, 0x02, 0xc3, 0xd8, 0x39, 0x3d, 0xd2, 0x88, 0xc2, 0x29, 0xe9, 0x4e, 0x59, 0x5a, 0x87, 0x37, 0x34, 0xde, + 0xa5, 0x07, 0x51, 0x1a, 0x15, 0xf1, 0x53, 0xd2, 0x1b, 0x9b, 0xd1, 0xa9, 0xae, 0xd1, 0x6f, 0x9a, 0x8b, 0x18, 0x1b, + 0x58, 0x50, 0xef, 0xff, 0x74, 0x00, 0x4a, 0x4c, 0xe6, 0x2d, 0x63, 0x8e, 0x89, 0x90, 0x32, 0xb7, 0x92, 0xef, 0x93, + 0x88, 0xca, 0x3c, 0x66, 0x38, 0xe3, 0x17, 0x19, 0x23, 0xea, 0x66, 0x71, 0x7c, 0x6a, 0xdd, 0x82, 0x49, 0x37, 0xf3, + 0xae, 0xcc, 0x40, 0x1a, 0x44, 0x9e, 0x6a, 0xe9, 0x29, 0xa8, 0x9e, 0x2e, 0xab, 0xae, 0x5e, 0x29, 0xf2, 0xf9, 0x1f, + 0x0c, 0xc3, 0xe1, 0x00, 0xe0, 0xc0, 0xea, 0x73, 0xae, 0xf6, 0xda, 0x9f, 0xad, 0x69, 0xeb, 0x80, 0xd3, 0x13, 0x92, + 0xa7, 0x3f, 0x04, 0xe8, 0x1a, 0xcc, 0x32, 0x54, 0xe7, 0x3c, 0x54, 0xfd, 0xed, 0xa2, 0x2d, 0x0e, 0xc7, 0x0c, 0x04, + 0xda, 0x9b, 0x7b, 0xdc, 0xe2, 0xf7, 0x2c, 0x91, 0xce, 0xc3, 0x04, 0x5b, 0x34, 0xea, 0xf6, 0x48, 0x4e, 0xec, 0x56, + 0x0f, 0x96, 0x3b, 0x0e, 0x07, 0x86, 0x9e, 0xed, 0x22, 0x2a, 0x0d, 0x12, 0xec, 0x7e, 0x2e, 0x51, 0x01, 0x91, 0x0e, + 0xba, 0xc3, 0xe4, 0xfb, 0x1e, 0x4b, 0x6b, 0xea, 0xcf, 0xdd, 0x68, 0xe0, 0x0a, 0x76, 0xb8, 0xc2, 0x4a, 0x5d, 0x6e, + 0xdc, 0x4d, 0x87, 0xb3, 0xce, 0xb1, 0x50, 0xb9, 0x1d, 0x1d, 0x7c, 0xbc, 0xde, 0x58, 0x7b, 0xe7, 0x88, 0x1c, 0xa0, + 0xb2, 0x71, 0x18, 0x70, 0xa9, 0x86, 0x9a, 0xa9, 0x0c, 0x81, 0xd6, 0x8d, 0x61, 0x96, 0xc3, 0x29, 0xfa, 0x3e, 0x75, + 0xec, 0x99, 0x62, 0x23, 0x95, 0x4b, 0xfb, 0xd0, 0x34, 0x35, 0x07, 0x9d, 0xbc, 0x3b, 0x05, 0x42, 0x7f, 0xc5, 0xe0, + 0xe1, 0x81, 0x6c, 0xff, 0xf1, 0xdc, 0xff, 0x7a, 0x73, 0x2e, 0xb6, 0x97, 0x39, 0x70, 0x08, 0x93, 0x7d, 0xd4, 0x12, + 0x0a, 0xa0, 0x48, 0xe6, 0xa6, 0x7a, 0x90, 0xab, 0x77, 0x03, 0xfa, 0x84, 0x8b, 0xb1, 0x49, 0xda, 0xa7, 0xc7, 0x1b, + 0x8c, 0x7c, 0x9e, 0x36, 0xbd, 0x76, 0x21, 0x55, 0xbe, 0xd8, 0x9b, 0xed, 0x17, 0x27, 0x9b, 0xe0, 0x24, 0x27, 0xca, + 0x8e, 0x6d, 0x16, 0xc3, 0x3b, 0xed, 0xd2, 0xbf, 0x6f, 0x5b, 0xb9, 0x81, 0x4b, 0x38, 0xb4, 0x43, 0x15, 0xcc, 0x7b, + 0x70, 0xe8, 0xf5, 0x83, 0x0d, 0x8e, 0xea, 0x41, 0x77, 0x60, 0xfa, 0xb4, 0xd6, 0x09, 0x06, 0x84, 0xef, 0x56, 0x30, + 0x0a, 0x01, 0xc7, 0x5b, 0xd7, 0x2e, 0x94, 0x7b, 0x3e, 0xe0, 0x07, 0x41, 0x85, 0x33, 0x43, 0x68, 0x7e, 0x10, 0x39, + 0xa5, 0x3d, 0xa5, 0xa4, 0xba, 0xba, 0x35, 0x0e, 0x37, 0xe0, 0xb3, 0x81, 0xe2, 0x68, 0xbb, 0xf1, 0x4e, 0x12, 0x87, + 0xf9, 0x28, 0x65, 0xe6, 0xd2, 0xfb, 0xce, 0x09, 0x30, 0xf1, 0x4e, 0xed, 0xf4, 0x09, 0x9e, 0xe8, 0x5b, 0x1f, 0x55, + 0xb5, 0x7d, 0xb1, 0xe7, 0x9b, 0xab, 0xee, 0x90, 0x43, 0x94, 0x10, 0x62, 0xf6, 0xa9, 0x73, 0xcc, 0x73, 0x3e, 0x4b, + 0x07, 0x13, 0xf6, 0xdc, 0x16, 0x40, 0xab, 0x46, 0x05, 0xba, 0x72, 0x40, 0x5e, 0xc2, 0x57, 0xb7, 0x4e, 0xe8, 0xd2, + 0x41, 0x7a, 0x2b, 0xbf, 0x5c, 0x35, 0x89, 0x40, 0xf7, 0xc2, 0x7b, 0x8f, 0xe6, 0x4e, 0x74, 0x9c, 0x89, 0x3b, 0xb8, + 0xe8, 0x27, 0xce, 0x69, 0x7c, 0x24, 0xee, 0x12, 0xf9, 0x2c, 0xa6, 0x01, 0x31, 0x4f, 0x84, 0xf8, 0xab, 0x9f, 0xb9, + 0x84, 0x8d, 0x0a, 0x66, 0xea, 0x6e, 0x91, 0xd3, 0xca, 0x16, 0x13, 0x28, 0xdc, 0x5f, 0x74, 0xc3, 0xad, 0x59, 0xbe, + 0x13, 0x0b, 0x30, 0x2d, 0x03, 0x5f, 0xda, 0x39, 0x20, 0x45, 0x44, 0x7a, 0x4f, 0xde, 0xce, 0xff, 0x9b, 0xda, 0x7b, + 0xc5, 0x7f, 0xb4, 0xb9, 0x44, 0x71, 0x3a, 0x6d, 0x0a, 0x4b, 0xe1, 0xdb, 0x3d, 0x02, 0x21, 0x32, 0x46, 0x04, 0x9a, + 0x31, 0x7f, 0xd0, 0x0e, 0x73, 0x0a, 0xbc, 0xc3, 0x01, 0x70, 0x14, 0xb6, 0xd4, 0x0f, 0x36, 0x78, 0x70, 0x8f, 0x77, + 0xbd, 0x94, 0x3a, 0x56, 0x0e, 0x08, 0xcb, 0x1d, 0x85, 0xe3, 0x20, 0x83, 0x40, 0xd5, 0x21, 0xf6, 0x0e, 0xca, 0x3a, + 0x1d, 0xdd, 0x3a, 0x0c, 0xa9, 0xd0, 0x3b, 0xdf, 0x2a, 0x32, 0x1f, 0xb3, 0x5a, 0xe3, 0xa0, 0xfa, 0x00, 0x4e, 0xc0, + 0x6a, 0x46, 0x1c, 0x3d, 0xcd, 0xcb, 0x3d, 0xcd, 0xbc, 0x80, 0x00, 0x67, 0xf8, 0x83, 0x1d, 0xce, 0xd9, 0x3b, 0xef, + 0x81, 0xd2, 0x0d, 0x80, 0xda, 0xc4, 0x69, 0x59, 0xb8, 0x15, 0xbf, 0x5a, 0x7d, 0x23, 0x79, 0x7b, 0x6e, 0x9f, 0x8e, + 0x78, 0x0f, 0x0f, 0x5e, 0x2a, 0x5a, 0xc8, 0x60, 0x65, 0x82, 0xad, 0x06, 0xc2, 0xca, 0x10, 0x0b, 0xfa, 0x68, 0x5e, + 0xab, 0xee, 0x6a, 0x84, 0xea, 0xff, 0xea, 0x29, 0x98, 0xb2, 0x05, 0xaf, 0x38, 0xa9, 0xe9, 0x86, 0x93, 0xb4, 0xd4, + 0x8a, 0xa3, 0xf9, 0xb1, 0x13, 0x06, 0x05, 0xb1, 0x1d, 0x22, 0xfe, 0xf4, 0x7f, 0x96, 0x28, 0x4b, 0xb8, 0xd5, 0x1c, + 0x51, 0xb6, 0xf4, 0x8e, 0x23, 0xe2, 0xdf, 0x8f, 0x78, 0x57, 0x07, 0x11, 0xaa, 0xc6, 0x7c, 0x52, 0x64, 0xfe, 0x2b, + 0xce, 0xf2, 0x46, 0xb8, 0xdb, 0xcc, 0xee, 0xeb, 0x9d, 0x2f, 0xe4, 0x22, 0x39, 0x3f, 0xcc, 0x2d, 0xdb, 0xce, 0xcb, + 0x4b, 0x3b, 0x55, 0xd2, 0xd6, 0xf3, 0xd3, 0xf2, 0x43, 0x8c, 0x23, 0x22, 0x2d, 0xcb, 0x30, 0xba, 0xf3, 0xec, 0x1c, + 0xbe, 0xff, 0x4e, 0xb9, 0xfa, 0xfe, 0xb3, 0x75, 0xc5, 0xb1, 0x35, 0x2e, 0xdf, 0xf1, 0x50, 0xea, 0xf8, 0xcc, 0x30, + 0xd4, 0xda, 0x40, 0x30, 0xb1, 0x95, 0x6d, 0x18, 0x03, 0x40, 0xef, 0x47, 0xb6, 0x18, 0xfa, 0x0b, 0xce, 0xa5, 0xd5, + 0xcd, 0x0b, 0xb9, 0x64, 0x7e, 0xe0, 0x1e, 0xe3, 0x03, 0xef, 0xfa, 0x83, 0xeb, 0x11, 0x3b, 0x91, 0x01, 0x0c, 0xa9, + 0x18, 0x5c, 0xe4, 0x3d, 0xe6, 0xf3, 0xae, 0x14, 0x21, 0xe4, 0x21, 0x4b, 0x01, 0xae, 0x5d, 0xfd, 0xb9, 0x2c, 0xcf, + 0xbc, 0x9f, 0xcf, 0xdb, 0x1c, 0x70, 0x58, 0xa8, 0xbe, 0x2c, 0x60, 0x1c, 0xfe, 0xa1, 0x18, 0x33, 0x81, 0x59, 0x1b, + 0x5e, 0x3f, 0xeb, 0x39, 0x47, 0x53, 0x33, 0x6a, 0x3b, 0xa5, 0x9e, 0xe5, 0xcb, 0xaa, 0xcd, 0x16, 0xcb, 0x90, 0x1b, + 0x1a, 0x1f, 0x27, 0x0d, 0x12, 0xe3, 0xaf, 0x09, 0xb4, 0x5c, 0x24, 0x6d, 0x44, 0x62, 0xd5, 0x8a, 0x56, 0x14, 0x2b, + 0xa3, 0x58, 0x88, 0x95, 0xfc, 0x56, 0xc9, 0xd3, 0x88, 0x39, 0xe5, 0xf1, 0xac, 0xdf, 0x95, 0xa3, 0x11, 0x50, 0xe1, + 0x60, 0x95, 0x4f, 0x7b, 0x6c, 0xed, 0x77, 0xf6, 0x3b, 0x28, 0xa5, 0xc4, 0x4e, 0x20, 0xd8, 0x27, 0x53, 0x1c, 0x00, + 0x2b, 0x9b, 0x23, 0xfb, 0x1b, 0x4e, 0xbf, 0x7a, 0xeb, 0x08, 0x68, 0x5d, 0x76, 0x4e, 0x75, 0xb4, 0x43, 0xef, 0x0b, + 0x32, 0x8d, 0x63, 0x41, 0x7e, 0x5a, 0x89, 0xcd, 0xe0, 0x31, 0x3a, 0x08, 0xd3, 0x2f, 0xac, 0x7d, 0xd5, 0xc8, 0x36, + 0x58, 0x62, 0xb6, 0xec, 0x58, 0xec, 0x5a, 0x27, 0x56, 0xce, 0xa8, 0x77, 0xef, 0xf9, 0x02, 0x9f, 0x54, 0x5b, 0xc9, + 0x0a, 0x38, 0x33, 0x81, 0x75, 0x14, 0x80, 0x6b, 0xec, 0x43, 0xea, 0x03, 0x8a, 0x83, 0xfa, 0x8a, 0xe3, 0xb3, 0x04, + 0x8b, 0x12, 0x42, 0x60, 0x9f, 0x74, 0xeb, 0x86, 0x4a, 0x38, 0x39, 0x4b, 0xda, 0x8f, 0xf0, 0x14, 0xc6, 0x0d, 0x2a, + 0x05, 0x9b, 0x8b, 0xf1, 0x45, 0xc5, 0x32, 0x85, 0xb3, 0x18, 0xd3, 0x61, 0xff, 0xb4, 0x4a, 0x58, 0x46, 0x1f, 0x1f, + 0x16, 0x16, 0x6e, 0xa6, 0x10, 0x4b, 0x4a, 0xfa, 0xfe, 0x80, 0xcd, 0xd7, 0x52, 0xff, 0xbf, 0xe6, 0x0a, 0x2a, 0xd8, + 0x8a, 0xb9, 0xe3, 0xf0, 0x77, 0xa8, 0xe0, 0xed, 0x2d, 0xf3, 0xa4, 0x6a, 0x6b, 0xeb, 0xbe, 0xa4, 0x16, 0x08, 0x2f, + 0x79, 0xfa, 0x89, 0xc3, 0x1d, 0xde, 0x8d, 0x33, 0x26, 0xc3, 0xab, 0x7b, 0x80, 0x24, 0x21, 0x20, 0xa1, 0x75, 0x5f, + 0x77, 0x0f, 0x06, 0x77, 0xb4, 0xc6, 0xf7, 0x20, 0xf1, 0x9e, 0x6f, 0xc6, 0xdb, 0x84, 0x5f, 0xa6, 0x7f, 0x69, 0x55, + 0xc7, 0x6a, 0xec, 0x83, 0x2a, 0xc6, 0xf5, 0x0f, 0xcf, 0xfd, 0xe9, 0x01, 0xb3, 0xcf, 0xfd, 0xcc, 0x26, 0x9a, 0x44, + 0x9f, 0xde, 0x5f, 0x4a, 0x5c, 0x78, 0xab, 0xf1, 0x96, 0xbf, 0xb4, 0xe2, 0xc2, 0xf9, 0x4a, 0xb7, 0xdb, 0x85, 0xa3, + 0xc3, 0x46, 0xe2, 0x69, 0xa3, 0x26, 0x80, 0x4c, 0xdf, 0x8a, 0xa9, 0xa4, 0x0b, 0x2b, 0xc8, 0xb4, 0x4f, 0xd2, 0x8d, + 0xc6, 0x53, 0x90, 0x4a, 0xb3, 0x58, 0x3d, 0x99, 0x19, 0xda, 0x68, 0x3d, 0x1c, 0x67, 0xd0, 0xff, 0x92, 0x18, 0xca, + 0x7a, 0xd9, 0xb6, 0x30, 0x5b, 0xa6, 0xba, 0xae, 0x3f, 0x6e, 0xa4, 0x95, 0xcc, 0xaa, 0x57, 0xd0, 0xf1, 0xf5, 0xfe, + 0x6d, 0x05, 0x4f, 0x24, 0x8a, 0x0f, 0x7b, 0xb7, 0x90, 0x68, 0x2d, 0x51, 0x2c, 0xe2, 0xfd, 0x32, 0x1d, 0xc7, 0x00, + 0xfc, 0xc1, 0xd0, 0x2d, 0x1d, 0xa7, 0xe9, 0xb1, 0xb2, 0x77, 0x68, 0x1f, 0x4a, 0x8a, 0x62, 0xb9, 0x48, 0xf8, 0x98, + 0x2d, 0xe0, 0xb8, 0x58, 0xa8, 0x86, 0xf6, 0x08, 0x16, 0x6d, 0x89, 0x2d, 0x7a, 0x4a, 0x8f, 0xa3, 0x1c, 0x23, 0xa6, + 0x51, 0xca, 0x97, 0xd1, 0xe3, 0x69, 0x4a, 0x00, 0x6d, 0x36, 0x64, 0x37, 0xef, 0x49, 0x12, 0xd6, 0xe4, 0x36, 0xb9, + 0x00, 0xb6, 0x7f, 0xe6, 0x54, 0xca, 0x5d, 0x1c, 0x44, 0x29, 0xed, 0xdc, 0xfc, 0x6e, 0x0e, 0xbc, 0x96, 0xeb, 0x22, + 0x31, 0xc6, 0xc8, 0x93, 0x2b, 0xa1, 0xa8, 0x65, 0xda, 0xf2, 0xae, 0x39, 0x12, 0xfc, 0xc2, 0x6b, 0xed, 0xad, 0x04, + 0x32, 0xd6, 0x65, 0xf8, 0x66, 0x74, 0x13, 0xb4, 0xf5, 0x9f, 0xec, 0x4d, 0xd7, 0x1b, 0xc4, 0xf2, 0xf3, 0xab, 0xba, + 0xea, 0xc8, 0xb3, 0xff, 0x50, 0xfb, 0x4e, 0x08, 0x2a, 0xe7, 0x26, 0x0c, 0xeb, 0x49, 0x7c, 0x2e, 0x3a, 0xde, 0x9d, + 0x48, 0x92, 0x16, 0xba, 0x3e, 0x93, 0x8e, 0x06, 0x0d, 0x93, 0x54, 0x50, 0x2e, 0x96, 0x81, 0xdf, 0x66, 0x29, 0xd1, + 0x8c, 0x88, 0x74, 0xaa, 0x56, 0x9f, 0x4c, 0x5f, 0xd4, 0x40, 0x5c, 0xaf, 0x02, 0x2b, 0x89, 0xfa, 0x4a, 0xff, 0x6d, + 0x0e, 0x35, 0x95, 0x1c, 0x3c, 0xf6, 0x7b, 0x03, 0xa3, 0x68, 0x52, 0x3d, 0xa9, 0xbb, 0x54, 0x38, 0xed, 0x04, 0x95, + 0x72, 0xe5, 0x29, 0x35, 0xe0, 0xa9, 0x5d, 0x1c, 0xf9, 0x99, 0x1f, 0x4c, 0x77, 0xc9, 0x9f, 0xb8, 0x17, 0xbf, 0xb0, + 0x0d, 0xa9, 0xfa, 0xcb, 0x1f, 0x6a, 0x03, 0xb2, 0x39, 0x09, 0xf5, 0xde, 0x8f, 0x43, 0x46, 0x35, 0xf7, 0x5b, 0xc7, + 0xe6, 0xf2, 0xa7, 0xdf, 0xde, 0xbd, 0xb9, 0xf0, 0x1b, 0xb4, 0x06, 0x65, 0xdd, 0xed, 0xaf, 0x6b, 0x78, 0x4a, 0xc5, + 0xbb, 0x2d, 0xc3, 0xcd, 0x92, 0xd1, 0x03, 0x90, 0x0f, 0xea, 0x9d, 0xff, 0xcc, 0xb5, 0x35, 0x4f, 0x75, 0x43, 0x73, + 0xb5, 0x08, 0x95, 0x33, 0x7f, 0x63, 0x18, 0xa9, 0x55, 0x4f, 0xf7, 0x64, 0x79, 0x63, 0x46, 0x03, 0xf7, 0x80, 0xa1, + 0x32, 0xc0, 0x8d, 0x96, 0x2e, 0x86, 0xd2, 0x5b, 0xd1, 0x97, 0xb6, 0xc5, 0xa6, 0x74, 0x5f, 0x6c, 0x4b, 0xeb, 0x62, + 0xb7, 0x71, 0x05, 0xdb, 0x92, 0xfe, 0x71, 0xfd, 0x1b, 0x7a, 0xa6, 0xd0, 0x63, 0xec, 0x2c, 0x3e, 0xe3, 0x65, 0xac, + 0xf5, 0x8d, 0x14, 0x59, 0xc1, 0x5b, 0xe3, 0x22, 0xd6, 0xc6, 0x0f, 0x25, 0x7b, 0x85, 0x71, 0x5f, 0x16, 0x58, 0x92, + 0x35, 0x1d, 0x0c, 0x8c, 0x54, 0x95, 0x56, 0xd2, 0x6d, 0x69, 0xf6, 0xdf, 0x2d, 0x5d, 0xc5, 0xc6, 0x42, 0xf3, 0x4b, + 0xaf, 0x74, 0x7a, 0x43, 0x62, 0x4d, 0xf6, 0xf3, 0x83, 0x0e, 0xc0, 0x8a, 0xd6, 0x45, 0x55, 0x91, 0x61, 0xbe, 0x56, + 0x91, 0x66, 0xd8, 0x13, 0x5c, 0x09, 0xd0, 0x40, 0xf5, 0x99, 0xa3, 0xf6, 0x21, 0x8e, 0x24, 0x56, 0xa3, 0x53, 0x0d, + 0xd9, 0x17, 0x45, 0x6c, 0x55, 0xbb, 0xa5, 0x46, 0xa9, 0x1a, 0x5d, 0xa2, 0x82, 0xca, 0x6f, 0x47, 0x8f, 0x88, 0x68, + 0x39, 0x6b, 0xf4, 0x21, 0x3e, 0x1f, 0x4d, 0xaf, 0x2b, 0x4e, 0x69, 0x80, 0x34, 0x48, 0x21, 0xb1, 0xa8, 0x74, 0xc1, + 0xcf, 0x04, 0xa4, 0xe5, 0x05, 0xfa, 0xd1, 0x55, 0x0c, 0x93, 0x33, 0x3c, 0x30, 0xf9, 0xad, 0xa3, 0x44, 0x7e, 0xb2, + 0xda, 0xe1, 0x37, 0xfc, 0xa5, 0xc5, 0x75, 0xa1, 0xf1, 0x96, 0x2b, 0xbf, 0x54, 0xcd, 0x55, 0x4c, 0xa1, 0x4b, 0x9f, + 0xc9, 0x6a, 0x86, 0x0c, 0xa6, 0xae, 0x62, 0x28, 0x01, 0x81, 0x6f, 0x40, 0x4a, 0x95, 0x41, 0x87, 0x10, 0x42, 0x6f, + 0xd0, 0x2a, 0x44, 0x74, 0x19, 0x36, 0x9f, 0x90, 0xaa, 0xb9, 0xce, 0x65, 0xf5, 0x68, 0xfe, 0x20, 0x26, 0xc1, 0x34, + 0xfc, 0x41, 0x23, 0x69, 0xb4, 0x07, 0x89, 0xc3, 0xa4, 0xd7, 0x21, 0xf4, 0x83, 0x37, 0xbd, 0x23, 0x0c, 0xdf, 0xfa, + 0xa4, 0xd3, 0xe3, 0x56, 0x92, 0xcb, 0xbf, 0x86, 0x95, 0x67, 0xa6, 0xd7, 0x26, 0xfc, 0x11, 0xfe, 0xa9, 0x0f, 0x66, + 0x75, 0x7b, 0x7b, 0x34, 0xc3, 0x6e, 0x68, 0x0c, 0x7f, 0x77, 0xc0, 0x5b, 0xf8, 0xc1, 0xf4, 0x35, 0x27, 0x76, 0x47, + 0xaf, 0x59, 0xb8, 0xce, 0xe7, 0xd1, 0xf8, 0x59, 0x9a, 0x17, 0xac, 0x3c, 0x79, 0x70, 0xdf, 0xfa, 0xde, 0x67, 0x12, + 0x19, 0x2f, 0x3f, 0x75, 0x79, 0xad, 0x2d, 0x27, 0x83, 0xf2, 0xc2, 0x52, 0xf7, 0xc3, 0x1e, 0xa7, 0x2f, 0xb5, 0xf6, + 0x97, 0x7a, 0xed, 0xb3, 0xcf, 0xa6, 0x26, 0x8f, 0x31, 0x3c, 0x1d, 0x4d, 0xdd, 0xd3, 0xc2, 0xfa, 0x16, 0x59, 0x21, + 0x36, 0xc7, 0xb7, 0xcb, 0xd1, 0xd3, 0x59, 0x6d, 0x2f, 0xae, 0xd0, 0xb4, 0x93, 0xd3, 0xa9, 0x13, 0x37, 0xaf, 0xa3, + 0x58, 0xd2, 0xb4, 0x8f, 0xef, 0xc7, 0x72, 0x87, 0xeb, 0x8a, 0x7a, 0x40, 0xd0, 0xa8, 0xa0, 0x17, 0x4c, 0xf5, 0xf8, + 0x74, 0x23, 0x40, 0x5d, 0x78, 0x3a, 0xb1, 0x4e, 0x53, 0xfd, 0x3d, 0xe0, 0x65, 0x60, 0x9a, 0x06, 0x5b, 0x3f, 0x54, + 0x92, 0x20, 0x97, 0x19, 0x6f, 0xfb, 0xf6, 0xfc, 0xf5, 0x3e, 0x5e, 0x58, 0x6a, 0x05, 0xf3, 0x5b, 0x7c, 0x0e, 0x52, + 0xb3, 0x80, 0x3b, 0x2a, 0x59, 0x84, 0x23, 0x88, 0x96, 0x77, 0xc8, 0x53, 0xc7, 0x01, 0xe9, 0xa0, 0x3a, 0x67, 0x24, + 0xe6, 0xf3, 0x5f, 0xed, 0x7b, 0x26, 0xf5, 0x7d, 0x0f, 0x5b, 0xaf, 0xde, 0x1d, 0x48, 0x39, 0xf4, 0x49, 0xf5, 0x19, + 0x68, 0x32, 0xf7, 0xdd, 0x56, 0x3a, 0x7d, 0xa3, 0xcf, 0xd6, 0xb5, 0xbb, 0x50, 0xf3, 0xd3, 0x54, 0xfa, 0xc4, 0x5e, + 0x39, 0x1b, 0xf5, 0x19, 0x94, 0x25, 0x73, 0x01, 0x04, 0x49, 0x8b, 0x40, 0x07, 0x3a, 0x71, 0xb6, 0x29, 0xd3, 0x40, + 0x74, 0x49, 0x6b, 0xce, 0xf8, 0x61, 0x9e, 0x9d, 0xe4, 0xd6, 0x7e, 0xcf, 0x49, 0x5c, 0x85, 0x73, 0xa8, 0xa0, 0xa0, + 0x79, 0x3c, 0xd1, 0x36, 0xf8, 0xaf, 0x17, 0xba, 0xc9, 0x89, 0x7c, 0x1e, 0xe6, 0x9c, 0xb6, 0x8c, 0x31, 0x42, 0x03, + 0x70, 0xd1, 0xf4, 0xea, 0x28, 0x60, 0xb9, 0x0b, 0x84, 0xdf, 0xf2, 0x79, 0xb7, 0xdd, 0xb6, 0xaa, 0x05, 0xa9, 0x76, + 0x62, 0x17, 0xd5, 0xcc, 0x32, 0x45, 0x06, 0xce, 0x00, 0x4f, 0xb6, 0x6f, 0x0b, 0xd9, 0xf8, 0xa0, 0xbd, 0xe9, 0xd2, + 0xe9, 0x51, 0x16, 0xf0, 0x83, 0x94, 0x93, 0x16, 0x9e, 0x1d, 0x43, 0xb1, 0x4d, 0x79, 0xb9, 0x2f, 0xf8, 0xd4, 0x35, + 0x86, 0xd4, 0x50, 0xda, 0x6c, 0x19, 0x29, 0xbc, 0x9b, 0x37, 0x06, 0x5c, 0xd2, 0xe2, 0xbd, 0x88, 0x31, 0xe0, 0xe1, + 0xfa, 0xa2, 0x45, 0x88, 0x27, 0x08, 0x73, 0xb8, 0x61, 0x86, 0x01, 0x74, 0x22, 0xe0, 0x60, 0x3a, 0xbd, 0xbd, 0x0e, + 0x7e, 0x4f, 0x56, 0x68, 0x4b, 0xaf, 0xe6, 0x0d, 0xb7, 0xab, 0x51, 0xba, 0xa1, 0x6d, 0x06, 0xb3, 0x7e, 0x3e, 0xf9, + 0x0d, 0xe5, 0xaa, 0xb3, 0x9a, 0xbf, 0x5c, 0xb0, 0x68, 0x75, 0x36, 0x73, 0x27, 0x9d, 0x1a, 0xdd, 0x53, 0xd5, 0x7a, + 0xea, 0x41, 0xb3, 0x37, 0xf4, 0x16, 0xd4, 0x14, 0x9a, 0x25, 0xc6, 0x1a, 0x3b, 0x1f, 0xfe, 0x47, 0xb6, 0xf0, 0x35, + 0x6b, 0x0f, 0xb4, 0xb6, 0x72, 0x7f, 0x6d, 0xc7, 0xd7, 0x08, 0x0e, 0xc3, 0x28, 0xc4, 0x09, 0xea, 0xd6, 0x5a, 0x52, + 0xe8, 0x56, 0xa7, 0x43, 0x54, 0x10, 0x93, 0xff, 0xa5, 0x37, 0xf3, 0x2e, 0x3e, 0x75, 0x0c, 0x9d, 0xab, 0x7f, 0x55, + 0x5c, 0x1d, 0x9b, 0xa6, 0xd9, 0xea, 0x5d, 0x3f, 0x17, 0x3e, 0xcc, 0xb4, 0xdf, 0x15, 0x2f, 0x3a, 0x42, 0x81, 0xc7, + 0x0f, 0x1e, 0xf6, 0xf5, 0x95, 0x15, 0xa4, 0x53, 0xcf, 0x27, 0xcc, 0x47, 0x4f, 0xd1, 0x31, 0x70, 0x43, 0x16, 0x13, + 0xef, 0xe3, 0x3a, 0x8b, 0xff, 0x59, 0xf6, 0xe1, 0x4c, 0xdb, 0x69, 0x54, 0x57, 0x8a, 0xc7, 0xb5, 0x08, 0xe8, 0xf3, + 0xe9, 0xe3, 0x12, 0x03, 0xd4, 0x5e, 0xac, 0x8a, 0x63, 0xb3, 0x41, 0x37, 0xbc, 0x2f, 0x84, 0xac, 0x57, 0x3a, 0xe3, + 0x3e, 0x2d, 0x12, 0x40, 0x5c, 0x7f, 0x44, 0x5d, 0x8b, 0xf9, 0xfa, 0xf2, 0xcd, 0xd1, 0xa6, 0xc7, 0x8c, 0x86, 0xc0, + 0x84, 0x59, 0xfb, 0x93, 0x51, 0x4a, 0xa7, 0x4f, 0xd1, 0x8a, 0xcf, 0x4d, 0xe1, 0x99, 0x6b, 0x75, 0x6d, 0x14, 0xe9, + 0x3f, 0x8a, 0xba, 0xf7, 0x31, 0x9c, 0x35, 0xaf, 0xbf, 0x60, 0x37, 0x07, 0xa3, 0x1f, 0x06, 0xcd, 0x41, 0x89, 0x45, + 0xbc, 0x7a, 0x12, 0x1f, 0x73, 0xbc, 0x26, 0x01, 0x3e, 0xe7, 0x39, 0x40, 0xff, 0x1c, 0x53, 0xcc, 0x25, 0x8c, 0xe3, + 0x63, 0x07, 0x54, 0x5b, 0x5b, 0x39, 0x24, 0xff, 0x66, 0xf6, 0x02, 0xb5, 0x59, 0xd7, 0x32, 0xa8, 0xbf, 0x83, 0xbc, + 0xda, 0xf4, 0xc2, 0xca, 0x41, 0xe7, 0x2b, 0x4b, 0xfa, 0xda, 0x04, 0xdd, 0xe2, 0xb2, 0xec, 0x80, 0xcf, 0xbc, 0xde, + 0x5d, 0x11, 0xbf, 0x14, 0xcc, 0x5b, 0xf8, 0x72, 0x1b, 0x9a, 0x70, 0x77, 0xe9, 0xa7, 0xc1, 0x09, 0xcd, 0x91, 0xdf, + 0x26, 0xa3, 0x0f, 0xdf, 0x7a, 0x76, 0xf5, 0xb2, 0x0e, 0xfc, 0x7f, 0xc3, 0x20, 0x10, 0x79, 0xa7, 0xd0, 0x2d, 0x69, + 0x9d, 0x7a, 0x14, 0x4b, 0x57, 0xca, 0x3e, 0xae, 0x5c, 0x7d, 0x74, 0x9b, 0xff, 0x1f, 0xae, 0xe0, 0x5b, 0xa3, 0xf8, + 0x49, 0x0c, 0xd0, 0x81, 0x22, 0x24, 0x3d, 0x22, 0xba, 0x78, 0xd6, 0xe2, 0xf1, 0x5b, 0x50, 0x33, 0xd8, 0xfa, 0x16, + 0xec, 0x04, 0x83, 0x90, 0x3d, 0x62, 0x9d, 0x0d, 0x1d, 0xb8, 0xfc, 0xad, 0x17, 0x65, 0x0e, 0x91, 0xde, 0x7c, 0x57, + 0x38, 0x75, 0x6d, 0xe5, 0x7d, 0xff, 0x97, 0xfa, 0xda, 0x64, 0x9e, 0xf3, 0xeb, 0x54, 0xf2, 0x85, 0xd3, 0x45, 0x57, + 0x21, 0xc6, 0xf1, 0xbb, 0x2b, 0x36, 0xde, 0x19, 0xf7, 0xc5, 0x45, 0xe4, 0xb4, 0xba, 0xf6, 0xd6, 0x4d, 0x0f, 0xba, + 0x71, 0x45, 0xf4, 0x18, 0xbf, 0xc4, 0x4c, 0xf7, 0xe6, 0x87, 0xc4, 0x3a, 0x7e, 0x37, 0xae, 0xf4, 0x5c, 0x4c, 0xe1, + 0x3e, 0x24, 0xf0, 0x3d, 0x7a, 0xb5, 0x42, 0x5c, 0x66, 0xdd, 0xf0, 0x82, 0x08, 0x50, 0x24, 0x00, 0x2b, 0x25, 0x09, + 0xa2, 0x25, 0x81, 0xe5, 0x70, 0xf2, 0xde, 0x56, 0x78, 0x6d, 0x7a, 0x77, 0x88, 0x16, 0x35, 0x2e, 0x54, 0x0c, 0x8f, + 0xbb, 0xa7, 0x93, 0xb9, 0x15, 0xa8, 0x57, 0xec, 0x41, 0x4c, 0x00, 0xa6, 0x05, 0x30, 0x56, 0x84, 0xcf, 0x6b, 0x44, + 0x1c, 0x00, 0x8a, 0x04, 0x0e, 0x30, 0xe2, 0x00, 0xfe, 0xbb, 0x9f, 0xf1, 0xa3, 0xf9, 0x85, 0x60, 0x59, 0xf4, 0x27, + 0xd3, 0x4f, 0xfb, 0x0c, 0x47, 0xe4, 0xf2, 0xe6, 0x21, 0x08, 0xb2, 0xda, 0x1c, 0xec, 0x8a, 0x1f, 0x60, 0x1b, 0xb7, + 0x27, 0xbc, 0xdc, 0x10, 0x5d, 0x3a, 0xab, 0xa4, 0xb4, 0x0b, 0xbe, 0xc0, 0xa5, 0x6f, 0xba, 0xbf, 0xa4, 0x87, 0xd5, + 0xc2, 0x17, 0xe3, 0x9e, 0xd5, 0x30, 0x3f, 0x78, 0xf1, 0xe8, 0xff, 0xac, 0x7a, 0xdd, 0x61, 0x63, 0x1c, 0xfe, 0x31, + 0xe0, 0x87, 0xa0, 0xf9, 0x49, 0xf6, 0xde, 0x47, 0xb7, 0xf6, 0xbd, 0x24, 0x39, 0x99, 0x1e, 0x56, 0x18, 0x4e, 0x3f, + 0x5e, 0x60, 0x55, 0x06, 0x3f, 0x97, 0x25, 0xd5, 0x5d, 0x85, 0x5f, 0x5c, 0x13, 0x61, 0x70, 0x0e, 0xef, 0xf8, 0x02, + 0x40, 0x5a, 0xcc, 0x70, 0x25, 0x5d, 0xeb, 0xf5, 0x77, 0x2f, 0xf8, 0xd6, 0x69, 0x92, 0x48, 0x20, 0x72, 0x5a, 0xc9, + 0xe1, 0x6c, 0x08, 0x4a, 0x4e, 0xca, 0xc3, 0x9c, 0x32, 0x38, 0x4b, 0x95, 0xd3, 0xa2, 0xc0, 0x9f, 0xda, 0xd9, 0xdd, + 0xba, 0xbc, 0x58, 0xd1, 0x1a, 0x4b, 0xf5, 0xbe, 0x0c, 0x35, 0x44, 0xb0, 0xd8, 0xf2, 0x69, 0x4b, 0x98, 0xfd, 0x0d, + 0x66, 0x53, 0x83, 0x08, 0xbf, 0xcf, 0x53, 0x42, 0x57, 0xde, 0x44, 0x04, 0x26, 0x54, 0x1f, 0x9a, 0x22, 0x46, 0x7a, + 0x44, 0xa7, 0x45, 0x42, 0x52, 0xab, 0x34, 0x42, 0x63, 0x0d, 0x89, 0x7e, 0xbf, 0x75, 0xcf, 0xab, 0xe5, 0x38, 0x1e, + 0xa3, 0xf2, 0x47, 0xd1, 0x6f, 0x30, 0x23, 0x17, 0xa4, 0xdd, 0xb0, 0x2b, 0x62, 0x98, 0xb2, 0x60, 0x18, 0xa8, 0xb2, + 0x41, 0x49, 0xe0, 0xb6, 0x62, 0xdb, 0xbf, 0xe3, 0xfb, 0x30, 0x22, 0xda, 0x4d, 0xc0, 0xcf, 0x3c, 0xa1, 0x76, 0x63, + 0x01, 0x1d, 0x7a, 0xc0, 0x6f, 0x58, 0xc3, 0x77, 0x4d, 0x14, 0xe9, 0x04, 0x4e, 0xd0, 0xb2, 0x48, 0xe2, 0xd3, 0xbd, + 0xf1, 0xff, 0x2f, 0x85, 0x54, 0x9f, 0xf7, 0xf7, 0xb7, 0x8d, 0x48, 0x0d, 0x3d, 0x15, 0xa8, 0xc8, 0xb8, 0x02, 0x5b, + 0xf6, 0x78, 0x29, 0x72, 0xc0, 0xc4, 0xe4, 0x5f, 0xb1, 0xc1, 0x4a, 0xe7, 0x8d, 0xe3, 0xd3, 0xbf, 0x60, 0x5a, 0x9c, + 0xed, 0x61, 0x16, 0xf3, 0x30, 0xfe, 0x4b, 0x47, 0x0f, 0x7a, 0xac, 0x87, 0x12, 0x2b, 0xe1, 0xc7, 0x65, 0x3e, 0xdc, + 0xf3, 0x8d, 0x59, 0xbe, 0xde, 0x1f, 0x2e, 0xec, 0x59, 0x89, 0xce, 0x8f, 0x7e, 0x89, 0xc5, 0x38, 0x32, 0xfe, 0x1b, + 0x6d, 0xc9, 0xe6, 0x36, 0xe0, 0x4e, 0x32, 0xa7, 0x77, 0x47, 0x47, 0x23, 0x0b, 0x72, 0x86, 0x25, 0xba, 0xbb, 0xe5, + 0x92, 0xdc, 0x65, 0xce, 0x2e, 0xfb, 0xfc, 0xeb, 0x7d, 0x76, 0xe1, 0x45, 0x7b, 0x4d, 0x9a, 0x4f, 0xd2, 0x06, 0x94, + 0x16, 0xb8, 0x3f, 0x9b, 0xdd, 0x22, 0x2a, 0x11, 0x32, 0x84, 0xf8, 0x82, 0x3b, 0x22, 0x05, 0xfb, 0x1d, 0xdb, 0x54, + 0x3c, 0xd0, 0x8d, 0xa8, 0xd7, 0x83, 0x97, 0x76, 0xdd, 0xf6, 0x8d, 0x01, 0x37, 0x4c, 0xd6, 0x2a, 0x46, 0xb5, 0xa0, + 0x59, 0x98, 0xde, 0x4e, 0x3e, 0x48, 0x55, 0x57, 0x12, 0x7a, 0x18, 0x1a, 0xf8, 0x14, 0xfb, 0x5a, 0xd7, 0x19, 0xbd, + 0x0c, 0x88, 0x7e, 0xc6, 0x0e, 0x3d, 0xf6, 0x03, 0xb3, 0xfc, 0x20, 0xe8, 0x62, 0xa9, 0x97, 0x40, 0x04, 0x34, 0x78, + 0x4d, 0x23, 0x56, 0x41, 0x9c, 0xb5, 0xd1, 0x61, 0xab, 0xa6, 0x07, 0x72, 0x8a, 0xbf, 0x58, 0x42, 0x28, 0x11, 0x5f, + 0x4d, 0xd3, 0xd2, 0x56, 0xe6, 0xe8, 0x2f, 0x0f, 0xc2, 0x5a, 0x90, 0x68, 0xea, 0x8c, 0xed, 0xad, 0xd2, 0x71, 0xf3, + 0x96, 0x97, 0x27, 0x24, 0xd0, 0xb6, 0x15, 0x61, 0x9e, 0x7f, 0xf2, 0x9f, 0xa6, 0xd6, 0x75, 0x0d, 0x5e, 0x99, 0x98, + 0xbf, 0x13, 0xb9, 0x95, 0xd3, 0xd1, 0x0f, 0x4d, 0xaa, 0x57, 0x0f, 0xb8, 0xc2, 0x7b, 0x33, 0xfe, 0xf3, 0x80, 0xd4, + 0xee, 0x38, 0x87, 0x33, 0x10, 0xa2, 0x79, 0x4e, 0x80, 0xd2, 0xa0, 0xe3, 0xe6, 0x20, 0x98, 0x95, 0x01, 0xc9, 0xce, + 0xea, 0x56, 0x7a, 0x8d, 0xcb, 0xd6, 0x89, 0x83, 0x74, 0xfb, 0x17, 0x62, 0xf2, 0x2c, 0xa5, 0x2b, 0x98, 0xe5, 0x65, + 0x42, 0x57, 0x2d, 0x06, 0x0a, 0x13, 0x39, 0x22, 0xfb, 0xbf, 0x62, 0x45, 0x1f, 0xac, 0xdf, 0x86, 0x8b, 0xb1, 0x23, + 0x24, 0xfb, 0x69, 0x16, 0xad, 0x91, 0xd2, 0xc8, 0x64, 0xc3, 0xe4, 0x52, 0x20, 0x57, 0x02, 0x09, 0x35, 0xea, 0x38, + 0x94, 0x83, 0x01, 0x9d, 0xda, 0x39, 0x28, 0x21, 0xec, 0x4b, 0x14, 0x50, 0x62, 0x44, 0x2a, 0x14, 0xfb, 0x39, 0x3a, + 0x4b, 0x19, 0x62, 0x66, 0x3a, 0x02, 0xee, 0x53, 0xa3, 0x84, 0x64, 0x32, 0x68, 0x00, 0xbd, 0xa5, 0x1d, 0xd4, 0x0f, + 0x70, 0x58, 0x64, 0xc4, 0xa5, 0x09, 0x80, 0xcf, 0x29, 0x6c, 0x6b, 0xff, 0x1e, 0x94, 0x2f, 0x5b, 0x17, 0x3d, 0xc8, + 0xd4, 0x45, 0x20, 0x74, 0x32, 0x8b, 0x05, 0x2a, 0xc3, 0xe5, 0xf0, 0xfb, 0xd4, 0x61, 0xaf, 0xa9, 0xd3, 0x4e, 0x91, + 0xc4, 0x5d, 0x9a, 0x69, 0xe8, 0xfb, 0x61, 0xdd, 0xdb, 0x34, 0xa9, 0xd8, 0x11, 0x78, 0x6b, 0x19, 0xcf, 0x42, 0xbb, + 0x51, 0x8c, 0x7d, 0x40, 0xde, 0xc8, 0xd0, 0xf9, 0x7f, 0x1b, 0x9b, 0x7e, 0xe0, 0x17, 0x9e, 0xa6, 0xcf, 0x9c, 0xf4, + 0xf3, 0xb0, 0x20, 0xbb, 0xc1, 0x4e, 0x45, 0x61, 0x45, 0x89, 0x2f, 0x50, 0x65, 0x53, 0x0d, 0xdd, 0x6b, 0x51, 0x28, + 0x92, 0x14, 0x72, 0x74, 0x61, 0x3c, 0xb9, 0x3c, 0x49, 0xb6, 0x5a, 0x46, 0xa5, 0x48, 0x12, 0xae, 0x4d, 0x48, 0xd6, + 0x09, 0x25, 0xda, 0xe7, 0xb1, 0xce, 0x48, 0xda, 0x8c, 0x0b, 0x76, 0xd6, 0x82, 0x6b, 0x53, 0xbb, 0xb9, 0x38, 0x65, + 0x1e, 0x6a, 0xfe, 0x44, 0x15, 0xa6, 0xcc, 0x9a, 0xa7, 0xb2, 0x36, 0xb9, 0x6a, 0xc8, 0x34, 0xf2, 0xa1, 0xbe, 0x0f, + 0xa9, 0x5e, 0x1c, 0x4e, 0x44, 0xc9, 0xf5, 0x89, 0x4b, 0x07, 0x00, 0xc4, 0x70, 0x9c, 0xf9, 0x65, 0xc9, 0x41, 0x14, + 0x70, 0xa2, 0x94, 0x29, 0xd9, 0x32, 0xb8, 0x1f, 0xc0, 0xbe, 0xb2, 0x1d, 0x05, 0x4a, 0xe6, 0xd8, 0x71, 0xed, 0x6f, + 0x4a, 0x48, 0x01, 0xfb, 0xa9, 0x92, 0x83, 0x71, 0x1d, 0x86, 0xea, 0x1c, 0xcc, 0x1d, 0x69, 0x17, 0xde, 0x57, 0x0c, + 0x5d, 0x9c, 0x8b, 0x22, 0x12, 0x2a, 0x09, 0x1f, 0x0f, 0x30, 0x9f, 0x17, 0x29, 0xec, 0x63, 0x42, 0xf4, 0x94, 0x43, + 0x54, 0x6a, 0xb5, 0x55, 0x0e, 0xf2, 0x82, 0x69, 0x63, 0x2a, 0xfb, 0x48, 0x1a, 0x40, 0xfc, 0x24, 0x6e, 0x50, 0xaa, + 0x6a, 0x9d, 0x76, 0x2b, 0x9a, 0xdb, 0x1a, 0x39, 0xb8, 0x6a, 0xbf, 0x31, 0xad, 0x2a, 0xb0, 0x13, 0x72, 0x2a, 0xa7, + 0x61, 0xeb, 0x4e, 0xc6, 0xbf, 0xdd, 0xaf, 0xa6, 0xbf, 0xfc, 0xb2, 0x14, 0x95, 0x60, 0x84, 0xcc, 0x64, 0x80, 0x6f, + 0x84, 0x10, 0xbc, 0x68, 0x6f, 0x3d, 0x54, 0xb6, 0x45, 0x1c, 0x47, 0x1d, 0x87, 0x15, 0x24, 0x10, 0xe6, 0x73, 0xb9, + 0x3b, 0x5b, 0x8d, 0x2e, 0xf6, 0xee, 0xa8, 0x7c, 0x95, 0x27, 0x89, 0x55, 0xc1, 0x4e, 0x49, 0xf1, 0x31, 0x00, 0x58, + 0x92, 0x27, 0x82, 0x15, 0xe4, 0x8e, 0x37, 0x0d, 0x7c, 0x98, 0xf0, 0x24, 0xf9, 0xbf, 0xbe, 0x09, 0xfc, 0x4c, 0x71, + 0xc9, 0xf2, 0x6d, 0x79, 0x30, 0x59, 0xce, 0x56, 0x2d, 0x05, 0x44, 0x23, 0x02, 0x13, 0x87, 0x7c, 0x9c, 0x5f, 0x27, + 0xd9, 0xbb, 0x0c, 0xf1, 0xa9, 0xf9, 0xa0, 0x27, 0x34, 0xcf, 0xfc, 0x26, 0x34, 0xf4, 0xb8, 0x52, 0x05, 0x5a, 0x02, + 0x42, 0x13, 0xf7, 0x8f, 0xd7, 0x86, 0x0e, 0xa6, 0x59, 0x7f, 0x41, 0xc0, 0x00, 0xab, 0xbc, 0xad, 0xa0, 0x0a, 0x73, + 0x3b, 0x12, 0xde, 0xd4, 0x0d, 0xe1, 0x2b, 0x67, 0xc6, 0xd1, 0xc9, 0x14, 0x3e, 0x19, 0x10, 0x40, 0x7c, 0x54, 0x6f, + 0x44, 0x43, 0x7c, 0x33, 0xcf, 0xaa, 0x3a, 0xb7, 0xb0, 0x55, 0xec, 0x97, 0xf0, 0x47, 0xaf, 0x61, 0x2f, 0xac, 0x8c, + 0x97, 0xc8, 0x15, 0x3f, 0xeb, 0xe8, 0xf8, 0x39, 0x68, 0x53, 0x43, 0xeb, 0x51, 0xa5, 0x0a, 0x15, 0xc7, 0x0c, 0x23, + 0x8a, 0x05, 0x9e, 0x63, 0x8c, 0x4f, 0xe8, 0x9e, 0xdb, 0xf2, 0xd7, 0xc8, 0xb0, 0xf9, 0x2f, 0x87, 0xf2, 0x75, 0xe6, + 0x98, 0xd0, 0x33, 0xe5, 0x4c, 0x85, 0x33, 0x1c, 0x61, 0xac, 0x37, 0xbe, 0xc1, 0xdc, 0x55, 0x33, 0xb6, 0xb5, 0x3a, + 0x93, 0xa2, 0xe9, 0x52, 0x54, 0x9f, 0x41, 0x43, 0xbc, 0xeb, 0xc6, 0xc0, 0xc2, 0xdd, 0x9f, 0x03, 0x42, 0x6e, 0x0e, + 0x85, 0xab, 0xda, 0x8c, 0x10, 0x6a, 0x09, 0xd4, 0x67, 0x85, 0xb0, 0x92, 0x56, 0x49, 0x4a, 0x4d, 0x31, 0xcf, 0x1f, + 0xc1, 0x7a, 0xaf, 0xf9, 0xff, 0x97, 0x19, 0xd1, 0xf7, 0xcb, 0xfe, 0x33, 0x7e, 0x41, 0xf4, 0x8c, 0x15, 0x4b, 0x26, + 0xfa, 0xf6, 0xba, 0x60, 0xc0, 0x09, 0xdf, 0x5e, 0xc3, 0xa9, 0xb5, 0xae, 0xdd, 0x4f, 0x0f, 0xe1, 0xfe, 0xbc, 0x51, + 0x2c, 0x9d, 0x22, 0x84, 0x58, 0xca, 0xcb, 0xcc, 0x54, 0xd2, 0x8a, 0x99, 0x17, 0x1d, 0x40, 0x9a, 0x77, 0x61, 0x76, + 0x9b, 0x72, 0x94, 0x25, 0x81, 0x67, 0x15, 0x30, 0xcd, 0xb0, 0x9d, 0x13, 0xa8, 0x5f, 0x1c, 0xff, 0x1d, 0xeb, 0xfe, + 0x0b, 0xe7, 0xa0, 0xee, 0xcf, 0x4f, 0x21, 0x91, 0x05, 0x4a, 0x94, 0x8c, 0x9a, 0x6e, 0x47, 0x75, 0x27, 0xeb, 0xdd, + 0x0b, 0x53, 0x22, 0x26, 0x5d, 0xf9, 0xdc, 0xcf, 0xed, 0x03, 0x68, 0x68, 0xab, 0x50, 0x55, 0x77, 0x65, 0xe3, 0x7c, + 0x45, 0x4b, 0x36, 0x20, 0xfd, 0x56, 0xfa, 0xe2, 0x06, 0x99, 0x97, 0x25, 0x51, 0x56, 0x91, 0xb3, 0xa4, 0xdb, 0xd3, + 0x39, 0x0a, 0x99, 0xe3, 0x7c, 0xe5, 0x85, 0xad, 0x95, 0xf6, 0xb5, 0x2a, 0xdb, 0x70, 0xa9, 0xa4, 0x68, 0x11, 0xcc, + 0x7a, 0x9f, 0xa3, 0xfe, 0x2e, 0x6f, 0x92, 0x89, 0x62, 0x54, 0x55, 0xbc, 0xae, 0x44, 0x2f, 0x7e, 0x7e, 0x0d, 0xc7, + 0x84, 0x7e, 0xf5, 0x07, 0xbd, 0xa5, 0xea, 0xde, 0x77, 0x98, 0xca, 0xec, 0xcd, 0x21, 0x88, 0xd2, 0x0d, 0xe9, 0xd5, + 0x5f, 0x89, 0x8f, 0xeb, 0xed, 0x89, 0x60, 0x39, 0x5d, 0x57, 0xf6, 0xeb, 0x7c, 0x5c, 0x0a, 0x73, 0x1e, 0xa9, 0x97, + 0xa6, 0xc1, 0xaf, 0x54, 0x51, 0x61, 0xce, 0xfa, 0xc7, 0x6c, 0x0a, 0xce, 0x4b, 0xd7, 0x32, 0x84, 0x1c, 0x91, 0xd0, + 0xc8, 0x91, 0x60, 0xce, 0xbf, 0x50, 0x8c, 0x5f, 0xb4, 0x49, 0xec, 0x8e, 0x5f, 0xc9, 0x6e, 0xa8, 0xe9, 0xa7, 0xcf, + 0xb9, 0x4b, 0x27, 0x54, 0x50, 0x7b, 0x82, 0x4b, 0xb0, 0xc0, 0xfb, 0x2b, 0x9b, 0x74, 0x31, 0xaa, 0xaa, 0x57, 0xe7, + 0xf3, 0x8f, 0x86, 0x38, 0x4c, 0x05, 0x14, 0x16, 0x6f, 0x32, 0x87, 0x76, 0x86, 0xd7, 0x74, 0x98, 0x67}; // Backwards compatibility alias #define INDEX_GZ INDEX_BR diff --git a/esphome/components/web_server/server_index_v3.h b/esphome/components/web_server/server_index_v3.h index a1cafe8707..37c80ddf96 100644 --- a/esphome/components/web_server/server_index_v3.h +++ b/esphome/components/web_server/server_index_v3.h @@ -3634,4058 +3634,4060 @@ constexpr uint8_t INDEX_GZ[] PROGMEM = { 0x36, 0x16, 0x43, 0x14, 0xa2, 0x85, 0xb1, 0x9f, 0x44, 0x7a, 0x6a, 0xf7, 0x8b, 0xa8, 0x63, 0x84, 0xe7, 0x2e, 0x8e, 0x40, 0xbb, 0x60, 0xb2, 0xd8, 0xed, 0x3a, 0x06, 0xb0, 0x93, 0x12, 0x46, 0xf3, 0x4c, 0x91, 0xc8, 0x45, 0x3d, 0x95, 0x78, 0xf0, 0xa9, 0x53, 0x8d, 0x99, 0x83, 0xf0, 0x54, 0x31, 0xd4, 0xc0, 0xc7, 0x7a, 0xaf, 0x4d, 0xc8, 0x99, 0xff, - 0xaf, 0xbd, 0xa7, 0xdd, 0x6e, 0x13, 0x49, 0xf6, 0xff, 0x3c, 0x05, 0x26, 0xd9, 0x04, 0x12, 0xc0, 0x20, 0xf9, 0x43, - 0x91, 0x8c, 0x3c, 0x93, 0xc4, 0x99, 0x8f, 0xf5, 0x4c, 0xe6, 0x24, 0x9e, 0xec, 0xbd, 0xeb, 0xf5, 0xb1, 0x90, 0xd4, - 0x92, 0xd8, 0x20, 0xd0, 0x01, 0x64, 0xd9, 0xa3, 0xb0, 0xcf, 0xb2, 0x8f, 0x70, 0x9f, 0x61, 0x9f, 0xec, 0x9e, 0xaa, - 0xea, 0x86, 0x06, 0x81, 0x2c, 0x4f, 0x32, 0xb3, 0x7b, 0xcf, 0xb9, 0x67, 0x26, 0x89, 0x68, 0xba, 0x9b, 0xea, 0xaf, + 0xaf, 0xbd, 0xa7, 0xdd, 0x6e, 0x13, 0x49, 0xf6, 0xff, 0x3c, 0x05, 0x26, 0xd9, 0x04, 0x12, 0xc0, 0x20, 0x59, 0xb6, + 0x22, 0x19, 0x79, 0x26, 0x89, 0x33, 0x1f, 0xeb, 0x99, 0xcc, 0x49, 0x3c, 0xd9, 0x7b, 0xd7, 0xeb, 0x63, 0x21, 0xa9, + 0x25, 0xb1, 0x41, 0xa0, 0x03, 0xc8, 0x1f, 0xa3, 0xb0, 0xcf, 0xb2, 0x8f, 0x70, 0x9f, 0x61, 0x9f, 0xec, 0x9e, 0xaa, + 0xea, 0x86, 0x06, 0x81, 0x2c, 0x4f, 0x32, 0xb3, 0x7b, 0xcf, 0xb9, 0x67, 0x26, 0x89, 0x68, 0x9a, 0xee, 0xea, 0xaf, 0xaa, 0xea, 0xfa, 0x2c, 0x53, 0x4a, 0xbb, 0x82, 0x7f, 0x85, 0x15, 0x72, 0xa5, 0x94, 0xb6, 0x1c, 0x88, 0x39, 0xdd, - 0x3e, 0xae, 0x1a, 0x58, 0x15, 0x8a, 0x7b, 0x32, 0x98, 0x09, 0x56, 0x76, 0x9d, 0x98, 0x87, 0x59, 0x97, 0x36, 0xbc, - 0x11, 0xb8, 0x60, 0x7a, 0xd8, 0x50, 0x6b, 0xdc, 0xf5, 0x4a, 0xa1, 0x30, 0xe3, 0xf2, 0xa4, 0x9e, 0x78, 0xe5, 0x67, - 0xd8, 0xd2, 0x95, 0x2a, 0xe0, 0x1b, 0x53, 0xa9, 0x04, 0x52, 0xb0, 0xe0, 0x94, 0x3e, 0x6f, 0xa5, 0xd1, 0x79, 0xb4, - 0x12, 0x82, 0xe3, 0x13, 0xaf, 0xa6, 0x10, 0xcf, 0x49, 0x77, 0x74, 0x12, 0xd0, 0x0f, 0x27, 0x6b, 0xa0, 0x00, 0x59, - 0x31, 0xc1, 0x39, 0xdb, 0x3a, 0xa0, 0xcb, 0xd4, 0xf5, 0xe3, 0xb5, 0xd8, 0x72, 0xd9, 0xc0, 0xcf, 0xd3, 0xc2, 0x96, + 0x01, 0xae, 0x1a, 0x58, 0x15, 0x8a, 0x7b, 0x32, 0x98, 0x09, 0x56, 0x76, 0x9d, 0x98, 0x87, 0x79, 0x8f, 0x36, 0xbc, + 0x11, 0xb8, 0x60, 0x7a, 0xd8, 0x50, 0x6b, 0xd2, 0xf3, 0x4a, 0xa1, 0x30, 0xe3, 0xf2, 0xa4, 0x1e, 0x7b, 0xe5, 0x67, + 0xd8, 0xd2, 0x95, 0x2a, 0xe0, 0x1b, 0x53, 0xa9, 0x04, 0x52, 0xb0, 0xe0, 0x84, 0xba, 0xb7, 0xd2, 0xe8, 0x2c, 0xba, + 0x11, 0x82, 0xe3, 0x63, 0xaf, 0xa6, 0x10, 0xcf, 0x49, 0x6f, 0x7c, 0x1c, 0xd0, 0x0f, 0x27, 0x6b, 0xa0, 0x00, 0x59, + 0x31, 0xc1, 0x39, 0xdb, 0x3a, 0xa4, 0xcb, 0xd4, 0xd5, 0xe3, 0xb5, 0xd8, 0x72, 0xd9, 0xd0, 0xcf, 0xd3, 0xc2, 0x96, 0x58, 0x8e, 0x8c, 0x4f, 0xbd, 0x1c, 0x85, 0xb4, 0xa6, 0x1a, 0xdf, 0x1f, 0xae, 0x5f, 0xcb, 0xb7, 0x58, 0xf4, 0xc8, - 0x88, 0xa6, 0xd7, 0x57, 0x61, 0xb7, 0x6c, 0xa4, 0xd5, 0x01, 0x46, 0x82, 0x27, 0x31, 0x04, 0x0c, 0xcc, 0x8c, 0xa8, - 0xff, 0xdb, 0xb8, 0x8a, 0xfa, 0xd1, 0xfe, 0x2e, 0x47, 0xfe, 0x3f, 0xbf, 0x7d, 0x7f, 0x01, 0x7a, 0x2d, 0x0f, 0x15, - 0xd1, 0x6b, 0x95, 0xdb, 0xb0, 0x98, 0xa0, 0x29, 0x52, 0xbb, 0xaa, 0xb7, 0x00, 0xea, 0x8c, 0x37, 0x86, 0xfd, 0x5b, - 0x73, 0xb5, 0x5a, 0x99, 0x60, 0xd1, 0x6a, 0x2e, 0xe3, 0x80, 0xb8, 0xc3, 0xb1, 0x9a, 0x09, 0xa4, 0xce, 0x2a, 0x48, - 0x1d, 0xc2, 0xe1, 0xf2, 0x7c, 0x2a, 0xef, 0x67, 0xd1, 0xea, 0x9b, 0x20, 0x90, 0xc5, 0x36, 0x82, 0x89, 0xe3, 0x92, - 0x8c, 0x12, 0x32, 0xd0, 0x40, 0xfb, 0x64, 0xf9, 0xc9, 0x35, 0xb7, 0x17, 0x18, 0x5f, 0x0f, 0xef, 0xae, 0xb9, 0x4e, - 0x22, 0x8f, 0x47, 0xfc, 0x7e, 0x70, 0x32, 0xf6, 0x6f, 0x14, 0xe4, 0x34, 0x5d, 0x15, 0x9c, 0xb9, 0x02, 0x36, 0x5c, - 0xa6, 0x69, 0x14, 0x9a, 0x71, 0xb4, 0x52, 0xfb, 0x27, 0xf4, 0x20, 0x2a, 0x78, 0xf4, 0xa8, 0x2a, 0x5f, 0x8f, 0x02, - 0x7f, 0xf4, 0xd1, 0x55, 0x1f, 0xaf, 0x7d, 0xb7, 0x5f, 0xe1, 0x27, 0xed, 0x4c, 0xed, 0x03, 0xac, 0xca, 0x37, 0x41, - 0x70, 0xb2, 0x4f, 0x2d, 0xfa, 0x27, 0xfb, 0x63, 0xff, 0xa6, 0x2f, 0xa5, 0x86, 0xe1, 0x7a, 0x53, 0x97, 0x87, 0xe0, - 0xcc, 0x2d, 0xcd, 0x12, 0x8c, 0xe9, 0x30, 0x62, 0x5a, 0x71, 0xf9, 0x85, 0x58, 0x33, 0x04, 0xaf, 0x36, 0x42, 0x71, - 0x7a, 0x00, 0x57, 0xbd, 0x4f, 0x9f, 0xb4, 0xdc, 0x0e, 0x75, 0x26, 0x05, 0x69, 0x43, 0x35, 0x1f, 0x56, 0x31, 0x30, - 0xd2, 0x8c, 0xae, 0x89, 0x50, 0x72, 0x81, 0x6e, 0x8c, 0x32, 0x03, 0x33, 0xec, 0x78, 0x0b, 0xd0, 0x38, 0xf2, 0x9f, - 0xd2, 0x8d, 0x78, 0x04, 0x59, 0xb5, 0x25, 0x24, 0xae, 0x4b, 0x3a, 0x17, 0x3a, 0x85, 0x3c, 0x4e, 0x20, 0x28, 0x4b, - 0xf0, 0x3b, 0xa4, 0x07, 0xd1, 0x02, 0x1d, 0xb2, 0xba, 0xe5, 0xc1, 0x79, 0xbc, 0x4c, 0xe4, 0x51, 0x13, 0xf3, 0x72, - 0x5a, 0x5a, 0xa1, 0x6e, 0x75, 0xbd, 0x44, 0xd4, 0xc8, 0xbd, 0xa4, 0x69, 0xc9, 0x40, 0x87, 0xa7, 0xa5, 0x46, 0x85, - 0xe6, 0x82, 0x57, 0x9f, 0xa4, 0x38, 0x62, 0x86, 0x76, 0x99, 0x18, 0xd1, 0x55, 0x41, 0xa7, 0x12, 0x42, 0x94, 0xdd, - 0x28, 0x2b, 0x02, 0x38, 0xd3, 0xaa, 0xf7, 0x1f, 0xaf, 0x43, 0x24, 0x6c, 0x89, 0xdb, 0x2f, 0xef, 0x83, 0xd4, 0x1b, - 0x9a, 0xb4, 0x99, 0x55, 0xe5, 0xeb, 0xf1, 0x30, 0xc8, 0x17, 0x9b, 0x0e, 0xc1, 0xcc, 0x0b, 0xc7, 0x01, 0xbb, 0xf0, - 0x86, 0xdf, 0x61, 0x9d, 0xd7, 0xc3, 0xe0, 0x15, 0x54, 0xc8, 0xd4, 0xfe, 0xe3, 0x35, 0x91, 0xee, 0x3a, 0x84, 0x9d, - 0xd1, 0x16, 0xa8, 0x7e, 0x87, 0xa7, 0x5c, 0x62, 0x31, 0xb5, 0x46, 0x60, 0x89, 0xdc, 0x52, 0x1c, 0xdb, 0x32, 0x64, - 0x3c, 0xe5, 0x0f, 0xec, 0x4d, 0x85, 0x9f, 0x5a, 0x80, 0x2b, 0x12, 0x27, 0x58, 0xde, 0x99, 0x32, 0xb0, 0x44, 0x56, - 0xdf, 0x45, 0x2b, 0x01, 0x29, 0x9f, 0x00, 0x0a, 0x51, 0x79, 0xfa, 0x7e, 0x70, 0x22, 0xab, 0x85, 0x50, 0x76, 0x4e, - 0xfd, 0xc2, 0xaf, 0x4c, 0x55, 0x8a, 0x04, 0x50, 0x8b, 0x5b, 0xb5, 0x7f, 0xb2, 0x2f, 0xd7, 0xee, 0x0f, 0xba, 0x67, - 0xd2, 0xe0, 0xb0, 0x57, 0x71, 0x6f, 0xbe, 0x2c, 0x1e, 0xb2, 0x2b, 0x05, 0x6e, 0xc9, 0x19, 0x94, 0xc0, 0x1c, 0x95, - 0x9b, 0x6c, 0x90, 0x1f, 0x48, 0x99, 0x58, 0x10, 0x28, 0xda, 0x3d, 0x02, 0x3f, 0x46, 0x7a, 0x37, 0x5f, 0x42, 0xb2, - 0xcc, 0x14, 0xbd, 0x0d, 0xf8, 0xbf, 0xc5, 0x94, 0xa0, 0xa4, 0x9b, 0x85, 0x49, 0x14, 0xab, 0x30, 0xcc, 0x6a, 0xde, - 0x24, 0x45, 0xca, 0xd7, 0x86, 0x03, 0xae, 0x25, 0xab, 0x30, 0x61, 0xfb, 0xd5, 0xa6, 0xd2, 0xb8, 0x07, 0x7a, 0xf1, - 0x43, 0xe1, 0x83, 0xa9, 0x20, 0xad, 0x1c, 0xc0, 0xe6, 0x7c, 0x54, 0x97, 0x8f, 0x7d, 0xe3, 0x2f, 0x91, 0x31, 0xf4, - 0x8c, 0x6b, 0xcf, 0xf8, 0x31, 0xbc, 0xca, 0x6a, 0x17, 0x2f, 0xcf, 0x25, 0x67, 0xb0, 0x9e, 0x06, 0x11, 0x98, 0xca, - 0x97, 0x0a, 0xdf, 0xe2, 0x36, 0x23, 0x17, 0x5e, 0x3c, 0x65, 0x22, 0x85, 0x9b, 0x78, 0x2b, 0x64, 0x07, 0xba, 0x34, - 0x2d, 0x10, 0x9e, 0x6c, 0x8f, 0x9b, 0xd6, 0xf9, 0xd6, 0x28, 0x8d, 0x83, 0x3f, 0xb3, 0x3b, 0x60, 0xb3, 0x92, 0x34, - 0x5a, 0x80, 0xcc, 0xca, 0x9b, 0x72, 0x1d, 0x84, 0xa1, 0xb1, 0xdd, 0x3e, 0xf7, 0xe9, 0x13, 0x93, 0xb2, 0x8a, 0xa5, - 0xd1, 0x74, 0x1a, 0x30, 0x4d, 0xca, 0x3e, 0x96, 0x7f, 0xe6, 0x74, 0xcf, 0x16, 0x91, 0xab, 0xf5, 0xac, 0xe9, 0x60, - 0x89, 0x11, 0xb3, 0x9c, 0x1b, 0x04, 0xc4, 0x45, 0xc6, 0x55, 0xc8, 0x90, 0x6b, 0xe2, 0x5c, 0x14, 0x07, 0xd7, 0x1c, - 0x47, 0xcb, 0x61, 0xc0, 0x4c, 0x3c, 0x0d, 0xf0, 0xc9, 0xf5, 0x70, 0x39, 0x1c, 0x06, 0x94, 0x2e, 0x0c, 0xe2, 0xaf, - 0x45, 0x09, 0xca, 0x45, 0x33, 0xbd, 0x07, 0x83, 0xb2, 0xd2, 0x2a, 0xf8, 0x60, 0x33, 0x09, 0x37, 0x07, 0xfa, 0x40, - 0x0a, 0x32, 0xd0, 0xcd, 0x33, 0xed, 0xaa, 0x70, 0x63, 0x61, 0x89, 0xda, 0xab, 0x61, 0xe9, 0xdc, 0x4b, 0xf5, 0x3d, - 0xce, 0xb0, 0xe2, 0x85, 0x63, 0xe5, 0x15, 0xed, 0x5d, 0xd5, 0x50, 0xc9, 0xf4, 0x8b, 0x67, 0x97, 0x53, 0x0d, 0xf5, - 0xb5, 0xef, 0x4d, 0xc3, 0x28, 0x49, 0xfd, 0x91, 0x7a, 0xd5, 0x7b, 0xed, 0x6b, 0x97, 0xf3, 0x54, 0xd3, 0xaf, 0x8c, - 0x6f, 0xe5, 0x3c, 0x60, 0x02, 0x53, 0x62, 0x1a, 0xb0, 0x86, 0x3a, 0xf2, 0xe9, 0xd9, 0x56, 0x4f, 0x60, 0x64, 0xac, - 0xf3, 0xad, 0x0b, 0xb5, 0x2a, 0x19, 0xc5, 0x30, 0x55, 0x24, 0x64, 0x14, 0xfb, 0x56, 0xef, 0x91, 0x10, 0xe6, 0x9b, - 0xe5, 0x1a, 0x99, 0x86, 0xb4, 0x20, 0xbe, 0x18, 0x04, 0x5f, 0x78, 0x8e, 0xd2, 0xf3, 0x9e, 0xec, 0xf5, 0x50, 0x22, - 0xe3, 0x83, 0x6f, 0xca, 0x1c, 0xc8, 0xe3, 0x75, 0x9a, 0x81, 0xc9, 0x61, 0x18, 0xa5, 0x0a, 0x44, 0x76, 0x83, 0x0f, - 0x0e, 0xaa, 0x56, 0xd2, 0xbc, 0x57, 0x4d, 0xcf, 0x38, 0x16, 0x78, 0x89, 0xb4, 0x14, 0x25, 0x97, 0x10, 0x88, 0x02, - 0x82, 0x94, 0x96, 0xe2, 0x38, 0x71, 0xdf, 0x3c, 0x58, 0xbe, 0x12, 0xff, 0x26, 0xe1, 0xfd, 0x32, 0x3d, 0x7f, 0xbc, - 0x4e, 0x4e, 0x05, 0x51, 0xff, 0x3e, 0xc1, 0xb5, 0x04, 0x76, 0x85, 0x53, 0xf9, 0x4c, 0x55, 0x4e, 0x05, 0x25, 0xc2, - 0xba, 0x25, 0xf4, 0xaa, 0x09, 0x76, 0x37, 0x16, 0x31, 0xf3, 0xb9, 0x18, 0x45, 0x30, 0x60, 0x95, 0xa3, 0x07, 0xc1, - 0x9a, 0x72, 0xde, 0x2a, 0x05, 0x8b, 0x6b, 0x24, 0x18, 0x80, 0xb9, 0x38, 0x8f, 0x30, 0xc8, 0xae, 0x81, 0x91, 0x84, - 0x08, 0x66, 0x62, 0x8c, 0x46, 0x24, 0x27, 0x91, 0xf3, 0xc3, 0xc5, 0x32, 0xc5, 0xc8, 0xf4, 0x00, 0x00, 0xcb, 0x54, - 0x05, 0x2f, 0x8c, 0x80, 0xeb, 0x8b, 0x0b, 0x4f, 0xa6, 0x2a, 0xfe, 0x78, 0xb3, 0x8c, 0x4b, 0x67, 0x00, 0xc7, 0xe1, - 0x30, 0x50, 0xaf, 0x03, 0x8f, 0x31, 0x1f, 0xc6, 0xc8, 0x28, 0xd2, 0xba, 0x68, 0x23, 0xb4, 0x7f, 0xa8, 0x41, 0x20, - 0x23, 0xea, 0xa7, 0xa7, 0x05, 0xb5, 0x83, 0x85, 0x68, 0xd5, 0xa5, 0x61, 0x0e, 0x40, 0x46, 0x79, 0x0a, 0x73, 0xe7, - 0xc2, 0xa5, 0x5e, 0x98, 0xd6, 0xa9, 0x17, 0x2a, 0xd9, 0xd5, 0x0d, 0x70, 0x1a, 0x06, 0xd9, 0x75, 0xe1, 0xe8, 0x5a, - 0x8c, 0x17, 0xb6, 0x24, 0x95, 0x2b, 0x68, 0xe9, 0xe6, 0x72, 0x7b, 0xb6, 0x45, 0xec, 0xcf, 0xbd, 0xf8, 0x8e, 0xcc, - 0xdf, 0x0c, 0xd9, 0x46, 0x4e, 0x57, 0x15, 0xa2, 0x07, 0x34, 0x01, 0x44, 0x1a, 0x54, 0xe5, 0xeb, 0xbc, 0x8c, 0xf1, - 0xd1, 0xe6, 0x36, 0x40, 0xf0, 0xad, 0x6b, 0xf5, 0x39, 0xb3, 0x48, 0xfe, 0x48, 0x4d, 0x7a, 0x5a, 0xd2, 0x30, 0xbc, - 0xa4, 0x3c, 0xbc, 0xb0, 0xbc, 0xd1, 0x70, 0x30, 0x44, 0x29, 0x08, 0x6e, 0x1c, 0x19, 0x26, 0xc1, 0xac, 0x5f, 0x51, - 0x7a, 0xf7, 0x87, 0x2e, 0x07, 0x83, 0xe5, 0x08, 0x61, 0x39, 0x6a, 0x44, 0xb3, 0x9e, 0x58, 0x11, 0xe0, 0x45, 0x80, - 0x0b, 0x89, 0x91, 0x03, 0xa1, 0xfc, 0x98, 0x4a, 0xbe, 0x85, 0x62, 0x38, 0x1a, 0x04, 0x3b, 0x1d, 0x8d, 0xd8, 0x75, - 0x23, 0x6c, 0x15, 0x67, 0x27, 0xfb, 0x54, 0x9b, 0x88, 0x22, 0x55, 0x82, 0x69, 0x88, 0x61, 0x84, 0xc5, 0x2c, 0x40, - 0x82, 0x70, 0xd7, 0x29, 0x2e, 0x3a, 0xd6, 0x1c, 0xd5, 0xd2, 0xce, 0x69, 0x99, 0xe1, 0xc1, 0x56, 0x6a, 0xff, 0x04, - 0x53, 0x7e, 0x02, 0x59, 0x87, 0xa0, 0x58, 0x27, 0xfb, 0xf4, 0xa8, 0x54, 0x4e, 0x44, 0xd1, 0x89, 0x90, 0x41, 0x76, - 0x79, 0x07, 0x0f, 0x3a, 0x2a, 0x49, 0xca, 0x16, 0x50, 0xea, 0x65, 0xaa, 0x32, 0xe7, 0x0c, 0x16, 0x8f, 0xbe, 0x07, - 0xa1, 0x79, 0x6c, 0x70, 0x89, 0x50, 0x95, 0xb9, 0x77, 0x8b, 0x23, 0x17, 0x6f, 0xbc, 0x5b, 0xcd, 0xe1, 0xaf, 0x8a, - 0xb3, 0x96, 0x94, 0xcf, 0xda, 0x68, 0xe3, 0x86, 0x1c, 0xc0, 0x0d, 0x79, 0x54, 0xbf, 0xb8, 0x33, 0xb1, 0xb8, 0xe3, - 0x86, 0xc5, 0x1d, 0x6f, 0x59, 0xdc, 0x80, 0x2f, 0xa4, 0x92, 0x4f, 0x5d, 0x8c, 0xbe, 0xd4, 0xf9, 0xe4, 0x71, 0x7e, - 0xa4, 0xcb, 0xcf, 0x19, 0xce, 0x93, 0x99, 0x04, 0x60, 0x4b, 0xdc, 0x30, 0x57, 0x75, 0xf3, 0x22, 0x4d, 0xc4, 0xe6, - 0xc0, 0xf3, 0x53, 0x27, 0xc6, 0x0d, 0x29, 0xbc, 0xb5, 0xa0, 0x3a, 0x5e, 0xd8, 0xa5, 0xd8, 0xd0, 0xd0, 0x66, 0x0d, - 0x23, 0x9d, 0x6d, 0x19, 0xe9, 0xa8, 0x74, 0x74, 0xf9, 0xb0, 0xe9, 0x10, 0xca, 0x83, 0x82, 0x3d, 0x08, 0xfe, 0x15, - 0xb8, 0x65, 0xca, 0xfb, 0xb0, 0x19, 0xc7, 0x4a, 0x3b, 0x6a, 0xe1, 0x25, 0xc9, 0x2a, 0x8a, 0xc1, 0x40, 0x01, 0xba, - 0x79, 0xd8, 0x96, 0x9a, 0xfb, 0x21, 0x8f, 0x7d, 0xd6, 0xb8, 0x99, 0x8a, 0xf7, 0xf2, 0x96, 0x6a, 0x1d, 0x1e, 0x52, - 0x8d, 0x85, 0x97, 0xa6, 0x2c, 0xc6, 0x49, 0xf7, 0x20, 0x49, 0xc6, 0x7f, 0xc8, 0x36, 0xab, 0xc1, 0x21, 0x81, 0x84, - 0xd5, 0x11, 0x43, 0x2f, 0x80, 0x05, 0x23, 0x8d, 0x64, 0xa8, 0xaf, 0xa5, 0x38, 0xaa, 0x71, 0x3e, 0xf1, 0x3f, 0xe1, - 0x71, 0xd5, 0x62, 0xc9, 0xd3, 0xd7, 0x39, 0xd2, 0xad, 0x85, 0x37, 0x7e, 0x0f, 0x76, 0x30, 0x5a, 0xcb, 0x00, 0x9f, - 0x16, 0x39, 0x6a, 0x6a, 0x4c, 0x3c, 0xe1, 0xa8, 0x40, 0x92, 0x88, 0x25, 0xb9, 0xc5, 0x30, 0x04, 0x1b, 0xf0, 0xcc, - 0xc9, 0xd5, 0xba, 0x95, 0xed, 0x4f, 0x7d, 0x7d, 0x03, 0x6b, 0x02, 0x6a, 0x0b, 0xdc, 0x7e, 0x2e, 0x74, 0x0b, 0x0c, - 0xe7, 0x48, 0x07, 0x45, 0xe9, 0x25, 0xa4, 0x43, 0xb7, 0xc5, 0x65, 0x7a, 0x10, 0x03, 0xd5, 0x02, 0xb5, 0xe2, 0x93, - 0x29, 0xfe, 0x72, 0xae, 0xb2, 0x27, 0x43, 0xfc, 0xd5, 0xba, 0xca, 0x95, 0x58, 0x15, 0x29, 0x82, 0x34, 0x66, 0xb5, - 0x5f, 0xda, 0x4f, 0x64, 0xae, 0xfd, 0x80, 0x6d, 0xc3, 0x17, 0xf8, 0xd1, 0xe3, 0x75, 0x02, 0x01, 0x0a, 0xe4, 0x31, - 0x84, 0x56, 0xac, 0x67, 0xb5, 0xe5, 0xd3, 0x86, 0xf2, 0xa1, 0xfe, 0x07, 0x13, 0x7e, 0xdc, 0x25, 0x51, 0x41, 0x53, - 0xca, 0x32, 0x90, 0xeb, 0xa1, 0x1f, 0x7a, 0xf1, 0xdd, 0x35, 0xdd, 0x42, 0x34, 0xc1, 0xe2, 0xe7, 0xb2, 0x1d, 0xe2, - 0x45, 0xcb, 0xd6, 0x21, 0xa9, 0xa4, 0xa8, 0xba, 0xe3, 0x84, 0xde, 0xfd, 0x73, 0x2c, 0xf1, 0x77, 0xa5, 0x6b, 0x2c, - 0x5f, 0x90, 0xd2, 0x87, 0xae, 0x1f, 0xaf, 0x35, 0xb6, 0xd9, 0x4d, 0x65, 0xb4, 0x15, 0x06, 0x12, 0x96, 0x07, 0xaf, - 0xc4, 0xf3, 0xb1, 0xdf, 0x45, 0xf3, 0x8f, 0x61, 0x74, 0x6b, 0x3e, 0x5e, 0xa7, 0xa7, 0xea, 0xdc, 0x8b, 0x3f, 0xb2, - 0xb1, 0x39, 0xf2, 0xe3, 0x51, 0x00, 0xcc, 0xe3, 0x30, 0xf0, 0xc2, 0x8f, 0xfc, 0xd1, 0x8c, 0x96, 0x29, 0x1a, 0x74, - 0xdd, 0x7b, 0x83, 0x16, 0x73, 0x42, 0x82, 0x44, 0xe4, 0x6a, 0x6b, 0x66, 0x41, 0x79, 0x3f, 0x10, 0xd7, 0xfa, 0x82, - 0x51, 0x2c, 0x6a, 0x19, 0xe0, 0x8f, 0x00, 0x36, 0x66, 0x10, 0xe0, 0xc1, 0x50, 0x71, 0xbd, 0x54, 0x43, 0x1e, 0x2a, - 0x69, 0xd5, 0xf2, 0x0c, 0xc5, 0xd7, 0xd8, 0xc3, 0x6f, 0xff, 0x1c, 0x94, 0x3c, 0xe4, 0x73, 0x79, 0x2f, 0x9f, 0x37, - 0x42, 0x28, 0x35, 0xc9, 0xb1, 0xf0, 0x01, 0x1f, 0xe7, 0x0c, 0x66, 0xf3, 0xa7, 0xe5, 0xc6, 0x5e, 0x92, 0x2c, 0xe7, - 0x6c, 0x4c, 0x2a, 0xb1, 0xd3, 0x02, 0xa8, 0xf2, 0x3d, 0x44, 0x06, 0xec, 0x6f, 0xcb, 0xd6, 0xf1, 0xc1, 0x2b, 0x30, - 0xf0, 0x03, 0x86, 0x32, 0x9a, 0x4c, 0xd4, 0x42, 0x14, 0x70, 0x4f, 0x33, 0xe7, 0xe0, 0x6f, 0xcb, 0x37, 0x67, 0xf6, - 0x9b, 0xbc, 0x71, 0x08, 0x8c, 0xb1, 0xb0, 0x56, 0xe2, 0x7c, 0xb1, 0x04, 0xaf, 0x18, 0xd1, 0xc4, 0x0b, 0x9b, 0x87, - 0x73, 0x59, 0xda, 0xe2, 0x0b, 0xc6, 0xc6, 0xc0, 0x70, 0x1b, 0x1b, 0xa5, 0xd7, 0x01, 0xbb, 0x61, 0xb9, 0x25, 0xd4, - 0xe6, 0xc7, 0x6a, 0x5a, 0x60, 0xa8, 0x56, 0xae, 0x7b, 0xe4, 0x5c, 0x9d, 0x34, 0xa4, 0x01, 0x8e, 0x81, 0x8f, 0x5c, - 0x3e, 0x62, 0x95, 0x23, 0x35, 0x30, 0x54, 0x09, 0x80, 0x46, 0xc8, 0x4e, 0x1b, 0xca, 0xbb, 0x80, 0xa8, 0x1b, 0x60, - 0x33, 0x1c, 0xbd, 0x0b, 0xa9, 0x2d, 0xf8, 0x3c, 0x05, 0x70, 0xf2, 0xb4, 0x42, 0x6a, 0xd2, 0x34, 0x63, 0x75, 0xa2, - 0x36, 0x95, 0x84, 0x34, 0xc2, 0x39, 0x00, 0xbd, 0x64, 0x84, 0xb8, 0xaa, 0x76, 0x6d, 0x94, 0xf2, 0xc8, 0x87, 0x98, - 0xf8, 0x3d, 0x64, 0x49, 0xd2, 0x38, 0x61, 0xf9, 0xa2, 0x1b, 0x6a, 0x51, 0xbb, 0x3c, 0x1f, 0x45, 0xb9, 0x61, 0x1b, - 0xc0, 0x12, 0xe0, 0x00, 0xab, 0xdf, 0x42, 0xf2, 0x72, 0x3d, 0xe7, 0xe6, 0x9d, 0xf1, 0x74, 0xa8, 0x72, 0xd3, 0xbb, - 0xa6, 0xf7, 0x2b, 0x95, 0x03, 0x55, 0x22, 0xd3, 0xb5, 0xa0, 0x69, 0x25, 0xd4, 0xbb, 0x21, 0x55, 0xc2, 0x0e, 0x04, - 0x4c, 0x15, 0xfc, 0xca, 0x26, 0x13, 0x36, 0x4a, 0x13, 0x5d, 0xc8, 0x98, 0xf2, 0x60, 0xeb, 0xe0, 0x64, 0xbb, 0xe7, - 0xaa, 0x3f, 0x41, 0xc8, 0x19, 0x11, 0x93, 0x90, 0x03, 0x24, 0xee, 0x4c, 0xf5, 0xd3, 0x44, 0x3d, 0x96, 0xa7, 0x88, - 0x7f, 0x05, 0xa4, 0xd0, 0x35, 0xe5, 0x08, 0x1a, 0xa7, 0x3f, 0xc5, 0xbe, 0x88, 0x72, 0x23, 0xd0, 0xed, 0xa8, 0x68, - 0xdb, 0xf1, 0x5d, 0x3b, 0x6f, 0x0e, 0x1d, 0x3b, 0x53, 0x0d, 0x70, 0x75, 0xfe, 0x58, 0xd9, 0xc6, 0x44, 0xa0, 0x5c, - 0xf5, 0xfc, 0xed, 0xab, 0x3f, 0x9f, 0xbd, 0xde, 0x15, 0x23, 0x60, 0x97, 0x6d, 0xe8, 0x72, 0x19, 0x6e, 0xe9, 0xf4, - 0x97, 0x9f, 0x1e, 0xd6, 0x6d, 0xcb, 0x79, 0xe1, 0xa8, 0x06, 0x59, 0xa7, 0x4b, 0x78, 0x71, 0x14, 0xdd, 0xb0, 0xf8, - 0xb3, 0xa7, 0x41, 0xee, 0xbc, 0x1e, 0xdc, 0xb7, 0x3f, 0x9f, 0xfd, 0xb4, 0x33, 0xa8, 0x47, 0x8e, 0x0d, 0xb8, 0x3d, - 0x8d, 0x16, 0x0f, 0x18, 0x5d, 0x5b, 0x35, 0xd4, 0x51, 0x10, 0x25, 0xac, 0x01, 0x82, 0x57, 0xe7, 0x6f, 0xdf, 0xe3, - 0x74, 0x15, 0x2c, 0x08, 0x75, 0xf5, 0x79, 0x83, 0xff, 0xf9, 0xdd, 0xd9, 0xfb, 0xf7, 0xaa, 0x81, 0xc9, 0xba, 0x13, - 0xb9, 0x77, 0xbe, 0x89, 0xef, 0xa1, 0x38, 0xb5, 0x7b, 0x9d, 0xa8, 0x1a, 0x5d, 0xa4, 0xcb, 0xa3, 0xa1, 0xb2, 0x8d, - 0x6d, 0xce, 0xa9, 0x1d, 0xff, 0x32, 0xdd, 0x7e, 0x77, 0x1a, 0x57, 0x0d, 0x3e, 0xda, 0x4e, 0x52, 0x4b, 0x25, 0x73, - 0x3f, 0xbc, 0xae, 0x29, 0xf5, 0x6e, 0x6b, 0x4a, 0xe1, 0xfa, 0xb8, 0x81, 0x1f, 0x97, 0xd1, 0x5c, 0x62, 0x47, 0xd8, - 0xed, 0xfd, 0xd3, 0x25, 0xdd, 0xe1, 0x3e, 0x03, 0x68, 0x9e, 0x6c, 0xa5, 0x0a, 0x75, 0x4d, 0x31, 0xbf, 0x78, 0xe5, - 0x73, 0x3b, 0x0a, 0xc0, 0x26, 0x9f, 0xc9, 0x6a, 0xc8, 0x32, 0xab, 0xca, 0x3d, 0x6a, 0xdc, 0xca, 0xad, 0x80, 0x9a, - 0x91, 0xea, 0x86, 0xd3, 0x94, 0x85, 0x37, 0x06, 0x43, 0x77, 0x73, 0x18, 0xa5, 0x69, 0x34, 0xef, 0x3a, 0xf6, 0xe2, - 0x56, 0x55, 0x7a, 0x42, 0xd8, 0xc1, 0xed, 0xf0, 0xbb, 0xff, 0xfa, 0x67, 0x05, 0xcd, 0x53, 0xf9, 0x75, 0xca, 0xe6, - 0x0b, 0x16, 0x7b, 0xe9, 0x32, 0x66, 0x99, 0xf2, 0xaf, 0xff, 0x79, 0x55, 0xb9, 0xd8, 0xf7, 0xe4, 0x36, 0xc4, 0xd2, - 0xcb, 0x4d, 0xae, 0x83, 0x68, 0xb5, 0x57, 0x78, 0xdc, 0xdd, 0x53, 0x79, 0xe6, 0x4f, 0x67, 0x79, 0xed, 0xd3, 0x74, - 0xcb, 0xd8, 0x04, 0xf4, 0xa4, 0x0f, 0x50, 0xce, 0xa3, 0x55, 0xf7, 0x5f, 0xff, 0xcc, 0x05, 0x36, 0xf7, 0xee, 0xba, - 0x7a, 0x40, 0xcb, 0x2b, 0x5a, 0x5f, 0x67, 0x63, 0x89, 0xe1, 0xfd, 0xc6, 0x02, 0x6f, 0x14, 0xd2, 0xae, 0xdc, 0xd4, - 0xcd, 0x6d, 0x19, 0xd3, 0x77, 0xfe, 0x74, 0xf6, 0xb9, 0x83, 0x82, 0x09, 0xbd, 0x77, 0x54, 0x50, 0xe9, 0x0b, 0x0c, - 0x6b, 0xd0, 0xdd, 0x7d, 0xc1, 0x3e, 0x73, 0x5c, 0xf7, 0x0d, 0xe9, 0x4b, 0x8c, 0x86, 0x4b, 0x6e, 0xdf, 0x0f, 0x06, - 0x79, 0xb2, 0x5a, 0xb9, 0x3d, 0xf8, 0x0c, 0x9e, 0x6e, 0x94, 0x70, 0xf6, 0xa2, 0x6b, 0xeb, 0x14, 0xcc, 0x67, 0x87, - 0x09, 0x41, 0xeb, 0xf7, 0x9a, 0xe9, 0x68, 0xc6, 0xd7, 0xe4, 0xc4, 0xb6, 0xf1, 0xed, 0x0d, 0x64, 0x0d, 0xa5, 0x98, - 0xe8, 0x34, 0xd7, 0x1a, 0x1a, 0xf5, 0xe0, 0xac, 0x62, 0x6f, 0x41, 0x4a, 0x02, 0x05, 0x35, 0x26, 0x20, 0x74, 0xa9, - 0xdc, 0xa2, 0x6f, 0xbc, 0xe0, 0x66, 0xb7, 0x0b, 0x55, 0x33, 0x05, 0x43, 0xd2, 0xfc, 0xef, 0x23, 0xde, 0x48, 0x97, - 0x1f, 0x4c, 0xbb, 0x57, 0x5e, 0xca, 0xe2, 0xeb, 0x19, 0x78, 0xfb, 0x0a, 0xe9, 0x01, 0xc4, 0xd1, 0xdd, 0x86, 0x94, - 0x4b, 0x6c, 0x69, 0x0d, 0x1a, 0x2d, 0x30, 0xdc, 0x6f, 0xc3, 0xdd, 0x5f, 0x08, 0x73, 0x77, 0xcf, 0xc0, 0x1f, 0xf3, - 0x77, 0xc3, 0xde, 0xdb, 0x28, 0xd3, 0xff, 0x63, 0xef, 0xff, 0x44, 0xec, 0xbd, 0xf5, 0x3b, 0xbf, 0x65, 0x61, 0xff, - 0x0f, 0x60, 0xf9, 0x2e, 0x73, 0xcf, 0x38, 0xa6, 0xd7, 0x34, 0xcf, 0xd5, 0xe2, 0xd2, 0xe1, 0x45, 0xbc, 0xba, 0xa1, - 0x60, 0xe5, 0xc1, 0xd6, 0xb8, 0xe5, 0xa0, 0x87, 0xc8, 0x7e, 0xcb, 0x51, 0xfe, 0xfd, 0x11, 0x7d, 0x42, 0x19, 0xaa, - 0x24, 0x4c, 0xdf, 0x3d, 0x33, 0x92, 0xd2, 0x48, 0xbc, 0x95, 0x77, 0xb7, 0x0b, 0xde, 0x11, 0xc0, 0x7e, 0xb3, 0xf2, - 0xee, 0xea, 0x80, 0x6d, 0x44, 0xaf, 0xd5, 0x8f, 0x9d, 0x82, 0x97, 0x4f, 0x17, 0x5d, 0x7c, 0x8c, 0x41, 0xc2, 0xd2, - 0x53, 0x28, 0x74, 0x1f, 0xaf, 0xf7, 0xaa, 0x15, 0xb3, 0x01, 0xf8, 0x3f, 0x4b, 0x80, 0x47, 0x25, 0xc0, 0xfd, 0xe4, - 0x3a, 0x0a, 0x1f, 0x02, 0xf9, 0xcf, 0x20, 0xfc, 0xf9, 0xcd, 0xa0, 0xe3, 0xe7, 0x36, 0x60, 0xc7, 0xd2, 0x2a, 0xf0, - 0x58, 0x58, 0x85, 0xbe, 0x57, 0x2f, 0xab, 0xaf, 0x10, 0x5a, 0xa4, 0xb1, 0x8c, 0x08, 0xad, 0x02, 0x7a, 0x15, 0x05, - 0x74, 0x5c, 0x15, 0x92, 0xeb, 0x87, 0x93, 0xd8, 0x8b, 0xd9, 0xb8, 0xf9, 0x0a, 0x50, 0xb2, 0x4e, 0xbe, 0xb3, 0x92, - 0xe5, 0x62, 0x11, 0xc5, 0x69, 0x72, 0x8d, 0x71, 0x5a, 0xe6, 0x3e, 0x5c, 0x28, 0x20, 0xa3, 0x58, 0x1e, 0xb5, 0xf7, - 0xac, 0x4e, 0xbe, 0x6d, 0x30, 0xb7, 0x9c, 0x6c, 0x83, 0x7b, 0xdf, 0x18, 0xdc, 0x7f, 0x67, 0x26, 0xe9, 0x2f, 0x66, - 0x56, 0x1a, 0xfb, 0x73, 0x4d, 0x37, 0x1c, 0x5b, 0xd7, 0x85, 0x7c, 0x65, 0xe6, 0xf6, 0xf7, 0x28, 0xda, 0xf0, 0x4c, - 0x87, 0xa8, 0x85, 0xe8, 0xd1, 0x02, 0xb6, 0x72, 0x2f, 0x97, 0x93, 0x09, 0x8b, 0x35, 0x11, 0x96, 0x11, 0xe2, 0xc2, - 0x92, 0x31, 0x20, 0xf8, 0x39, 0x7e, 0xf0, 0xd9, 0x0a, 0xf2, 0x3f, 0x15, 0x61, 0xd5, 0xc1, 0xd7, 0x93, 0xcc, 0xc9, - 0x21, 0xb7, 0x5c, 0xda, 0x6e, 0x69, 0xe3, 0x67, 0x07, 0xc6, 0x0c, 0x82, 0x31, 0x15, 0xee, 0xf1, 0x18, 0xe7, 0xcf, - 0x0f, 0xd3, 0x0e, 0x7e, 0x01, 0x3a, 0x80, 0xc3, 0x1b, 0xb8, 0xb9, 0x5f, 0x94, 0x32, 0xca, 0x3b, 0x9c, 0xb9, 0xfd, - 0xe0, 0xb9, 0x4b, 0x7a, 0x1e, 0xb4, 0xdb, 0x7b, 0x35, 0xf3, 0xe2, 0x57, 0xd1, 0x98, 0x21, 0xa0, 0xc3, 0x34, 0x02, - 0x6f, 0x4d, 0x29, 0x0c, 0x0f, 0x46, 0xe1, 0x31, 0x4b, 0x91, 0x79, 0xf6, 0xa1, 0xe8, 0x5a, 0x2e, 0x72, 0x9f, 0x3f, - 0xde, 0x37, 0xe0, 0xa4, 0xd5, 0xaf, 0xb4, 0x58, 0x34, 0xbe, 0xd4, 0xb5, 0xaf, 0xe4, 0xdd, 0xfa, 0xca, 0x8b, 0x63, - 0x9f, 0xc5, 0x8a, 0xf6, 0xdd, 0xaf, 0xba, 0xbc, 0x69, 0x4b, 0x0a, 0x1d, 0xae, 0x65, 0x56, 0x30, 0x1a, 0xdd, 0xc4, - 0x67, 0xc1, 0xd8, 0x55, 0x47, 0xd4, 0x30, 0x57, 0xde, 0xb4, 0x3b, 0xb6, 0x6d, 0x73, 0x85, 0xa9, 0x43, 0x3f, 0x41, - 0x61, 0x0a, 0x3f, 0xe1, 0xa1, 0x24, 0x5e, 0xec, 0x10, 0x17, 0xb1, 0x41, 0xce, 0x6a, 0x21, 0x7c, 0x47, 0xf1, 0x7c, - 0x1e, 0x02, 0x1b, 0x8f, 0xfb, 0x23, 0x40, 0x73, 0x04, 0x58, 0x05, 0x4c, 0x15, 0x80, 0x0e, 0x1f, 0x02, 0xd0, 0x85, - 0x3f, 0xf7, 0xc3, 0x69, 0xd2, 0x08, 0x11, 0xaa, 0x4d, 0x4b, 0xf0, 0xa4, 0xd4, 0x42, 0x55, 0x70, 0x0d, 0x67, 0x51, - 0x00, 0x79, 0x88, 0x54, 0x66, 0x4d, 0x2d, 0xe5, 0x85, 0x6d, 0xdb, 0x86, 0x79, 0x00, 0x19, 0xff, 0x0e, 0x8f, 0x6c, - 0xc3, 0x84, 0xbf, 0x2c, 0xcb, 0xaa, 0x91, 0xc7, 0xf6, 0xe6, 0x7e, 0x68, 0xd2, 0x63, 0xcb, 0xde, 0x0d, 0xde, 0x7b, - 0xad, 0x7a, 0x13, 0xae, 0x1b, 0x1b, 0xe6, 0xae, 0xa3, 0xda, 0xd0, 0x4d, 0xca, 0xb6, 0x6e, 0x16, 0x05, 0x5c, 0xe2, - 0xf1, 0x30, 0x2a, 0xc4, 0x68, 0x58, 0x7e, 0x8b, 0x6c, 0x69, 0x5c, 0xcd, 0x63, 0xa1, 0x7e, 0xcf, 0xc1, 0xea, 0x2a, - 0xaf, 0xa2, 0x65, 0x30, 0x46, 0x73, 0x28, 0xb0, 0x5d, 0x56, 0x0a, 0xab, 0xd0, 0x4a, 0xb2, 0x29, 0xc8, 0x25, 0x8e, - 0x89, 0xd6, 0xde, 0x23, 0x71, 0x8a, 0x62, 0xed, 0x29, 0x4e, 0xf1, 0x65, 0xdd, 0x16, 0xbc, 0x7a, 0x0a, 0xf1, 0x84, - 0x76, 0x68, 0xc0, 0xf7, 0x05, 0xd4, 0x0f, 0x76, 0xa9, 0x2f, 0xd6, 0xed, 0xea, 0x29, 0x05, 0x9d, 0xf5, 0x3e, 0x7d, - 0xda, 0x1b, 0x7d, 0xfa, 0xb4, 0xb7, 0x91, 0xa9, 0xa3, 0x79, 0x84, 0xb4, 0x31, 0x18, 0x0f, 0x31, 0x02, 0x71, 0x83, - 0x08, 0xe8, 0xef, 0xa1, 0xbc, 0xeb, 0xf1, 0x68, 0x55, 0xf4, 0x34, 0x32, 0xf8, 0x07, 0xe9, 0x31, 0xc8, 0x2a, 0x93, - 0x32, 0x73, 0x3d, 0x12, 0xf3, 0x7c, 0xfa, 0xc4, 0x8f, 0x9b, 0x31, 0x76, 0x47, 0x79, 0x91, 0xa3, 0x1a, 0x4b, 0x37, - 0xc8, 0x1f, 0x55, 0x04, 0x79, 0xc9, 0x31, 0x66, 0x01, 0xf1, 0xca, 0x8b, 0x43, 0x19, 0xe0, 0x9f, 0x22, 0x85, 0x7f, - 0x56, 0xe1, 0x11, 0x51, 0xc7, 0xd5, 0xd5, 0x98, 0xb8, 0x4c, 0x5b, 0x12, 0x0e, 0x14, 0x96, 0x6e, 0x52, 0x07, 0x17, - 0x02, 0xdb, 0x63, 0x5a, 0x54, 0x31, 0x40, 0xf4, 0xaf, 0xc6, 0x93, 0x3b, 0x16, 0xc3, 0x7a, 0xe7, 0xad, 0xba, 0x4b, - 0xf1, 0x70, 0x46, 0x26, 0xf1, 0xdd, 0x49, 0xee, 0xb7, 0xbc, 0x20, 0xaf, 0xc3, 0xa9, 0xfb, 0x6d, 0xac, 0x2d, 0x8c, - 0xd4, 0x50, 0x05, 0x19, 0x51, 0x75, 0x63, 0x5e, 0x17, 0x60, 0xb5, 0x37, 0xe7, 0xe1, 0x66, 0x34, 0xb1, 0x15, 0xae, - 0x27, 0xe8, 0xab, 0x10, 0x8e, 0xee, 0x30, 0x80, 0x72, 0xf1, 0x9e, 0x40, 0xb9, 0xe6, 0xd9, 0xf7, 0xc6, 0xf2, 0x2b, - 0x58, 0x70, 0xd5, 0x98, 0xe8, 0x06, 0xf9, 0x00, 0x4c, 0xbf, 0xa4, 0xb9, 0x3f, 0xc5, 0x54, 0x9e, 0x4b, 0xe1, 0x5e, - 0x85, 0x03, 0xc0, 0x75, 0xc5, 0x01, 0xa0, 0x66, 0x3e, 0x95, 0x98, 0x25, 0x8b, 0x28, 0x84, 0xbb, 0xe2, 0x75, 0xe1, - 0xe1, 0x75, 0xbd, 0xe9, 0xe1, 0x55, 0xd3, 0x14, 0xdf, 0x50, 0x3b, 0x50, 0x49, 0x5f, 0xfc, 0x57, 0xc5, 0x42, 0x5f, - 0x90, 0x7a, 0xcc, 0x52, 0x7e, 0xd6, 0xe4, 0xd9, 0xfd, 0xfd, 0xfd, 0x9e, 0xdd, 0xe7, 0x3b, 0x79, 0x76, 0x7f, 0xff, - 0xc5, 0x3d, 0xbb, 0xcf, 0x64, 0xcf, 0x6e, 0x20, 0xc1, 0x67, 0x6c, 0x27, 0x47, 0x5a, 0xe1, 0xd2, 0x12, 0xad, 0x12, - 0xd7, 0xe1, 0x9a, 0xb5, 0x64, 0x34, 0x63, 0x60, 0xaa, 0xc0, 0x59, 0xdd, 0x20, 0x9a, 0x82, 0xbf, 0x6b, 0xb3, 0x47, - 0xeb, 0x97, 0xf2, 0x67, 0x0d, 0xa2, 0xa9, 0x2a, 0xe5, 0x69, 0x0b, 0x45, 0x9e, 0x36, 0x88, 0x4d, 0xf7, 0xb7, 0x5b, - 0xe7, 0xe5, 0xa5, 0xd3, 0x6b, 0x3b, 0x10, 0xe7, 0x14, 0xb4, 0xcf, 0x58, 0x60, 0xf7, 0xda, 0x6d, 0x28, 0x58, 0x49, - 0x05, 0x2d, 0x28, 0xf0, 0xa5, 0x82, 0x43, 0x28, 0x18, 0x49, 0x05, 0x47, 0x50, 0x30, 0x96, 0x0a, 0x8e, 0xa1, 0xe0, - 0x46, 0xcd, 0x2e, 0xc3, 0xdc, 0x6f, 0xfd, 0x58, 0xbf, 0x2a, 0xa5, 0xe8, 0xcc, 0x4d, 0x25, 0x44, 0x95, 0x63, 0x43, - 0xe4, 0x8b, 0x30, 0x0f, 0x74, 0xce, 0xa3, 0x0d, 0xbe, 0x1a, 0x00, 0xe6, 0x05, 0xcb, 0x11, 0x03, 0xec, 0x6e, 0xa8, - 0x66, 0x5b, 0xbc, 0x56, 0xbb, 0xb9, 0x9f, 0xb7, 0x6d, 0xb4, 0x84, 0xdf, 0x74, 0x17, 0xa3, 0x78, 0x88, 0xca, 0x87, - 0xcf, 0x67, 0x79, 0xf0, 0xe8, 0xa5, 0x5b, 0x04, 0xc3, 0x69, 0x43, 0x0a, 0x1d, 0xce, 0xab, 0x31, 0x0d, 0xec, 0x65, - 0x20, 0xd6, 0x89, 0x38, 0x45, 0xe2, 0x03, 0x0a, 0x3a, 0xc3, 0xf7, 0xbc, 0x82, 0x87, 0xe3, 0xa1, 0xd6, 0x09, 0xfa, - 0x79, 0x1e, 0xc1, 0x9a, 0x74, 0xa9, 0x4b, 0x23, 0xf5, 0xa6, 0xdd, 0x99, 0x41, 0x86, 0x54, 0xdd, 0x29, 0xa4, 0x24, - 0x39, 0x1d, 0x77, 0x17, 0xc6, 0x6a, 0xc6, 0xc2, 0xee, 0x84, 0xbb, 0x1d, 0xc2, 0xfa, 0x93, 0x27, 0xc9, 0x5c, 0x17, - 0x2e, 0x50, 0xb8, 0x27, 0x8a, 0xb7, 0x04, 0xa5, 0x99, 0x6f, 0xa5, 0xc2, 0x7b, 0x47, 0x93, 0x8d, 0xac, 0xbe, 0x84, - 0xaf, 0xc5, 0x6b, 0x36, 0x5c, 0x4e, 0x95, 0xf3, 0x68, 0x7a, 0xaf, 0x5f, 0x85, 0xfc, 0x0a, 0xa0, 0x54, 0xc9, 0x9a, - 0xd4, 0x14, 0xdb, 0x9b, 0x7f, 0x8b, 0x1e, 0xb3, 0x72, 0xfd, 0x14, 0x60, 0x53, 0x52, 0x62, 0x1b, 0xe0, 0x3b, 0x30, - 0xdb, 0x92, 0xe7, 0xc2, 0x39, 0xcc, 0x9f, 0xf4, 0x7c, 0xe1, 0x49, 0xf0, 0xf4, 0x7f, 0x64, 0x49, 0xe2, 0x4d, 0x99, - 0x8c, 0x5a, 0x4a, 0x9d, 0x03, 0x16, 0xcc, 0xd5, 0xc9, 0x38, 0x81, 0xc0, 0xd8, 0xfb, 0x1b, 0xfe, 0x28, 0xe0, 0x32, - 0x0b, 0x7e, 0x5a, 0xb0, 0x68, 0x85, 0xf3, 0x86, 0x6f, 0xc1, 0xf2, 0x94, 0xfd, 0x28, 0x00, 0x89, 0xdc, 0xb0, 0xa0, - 0x5a, 0x98, 0x7a, 0xd3, 0x6a, 0x11, 0xad, 0x75, 0x56, 0x42, 0x7b, 0x7a, 0xe9, 0x51, 0xe0, 0xc2, 0xcf, 0xb0, 0xcb, - 0x0f, 0xa2, 0xe9, 0xef, 0x6a, 0x94, 0xbf, 0xc5, 0x99, 0xe2, 0xc7, 0xd0, 0x08, 0xd3, 0x81, 0x85, 0x73, 0xac, 0x58, - 0x30, 0x85, 0xdd, 0x30, 0x9d, 0x99, 0x18, 0x58, 0x4e, 0x6b, 0x85, 0xba, 0x61, 0xe1, 0xda, 0xae, 0xab, 0xe1, 0x34, - 0xbb, 0xf1, 0x74, 0xe8, 0x69, 0x4e, 0xeb, 0xd8, 0x10, 0x7f, 0x2c, 0xfb, 0x50, 0xcf, 0xb0, 0x07, 0x65, 0xec, 0xdf, - 0xac, 0x27, 0x51, 0x98, 0x9a, 0x13, 0x6f, 0xee, 0x07, 0x77, 0xdd, 0x79, 0x14, 0x46, 0xc9, 0xc2, 0x1b, 0xb1, 0x9e, - 0xc4, 0x8f, 0x62, 0xa0, 0x66, 0x1e, 0x2b, 0xd0, 0xb1, 0x5a, 0x31, 0x9b, 0x53, 0xeb, 0x3c, 0x0e, 0xf3, 0x24, 0x60, - 0xb7, 0x19, 0xff, 0x7c, 0xa9, 0x32, 0x55, 0xc5, 0x2d, 0x47, 0x2d, 0x80, 0x65, 0xe6, 0x41, 0x9e, 0x21, 0xb5, 0x41, - 0x8f, 0x4b, 0x1d, 0xbb, 0x56, 0xeb, 0x30, 0x66, 0x73, 0xc5, 0x3a, 0x6c, 0xec, 0x3c, 0x8e, 0x56, 0x7d, 0x80, 0x16, - 0x1b, 0x9b, 0x09, 0x0b, 0x26, 0xf8, 0xc6, 0xc4, 0xb8, 0x52, 0xa2, 0x1f, 0x13, 0xed, 0x0a, 0xa0, 0x37, 0x36, 0xef, - 0xc1, 0xeb, 0x6e, 0x4b, 0xb1, 0x25, 0x7e, 0xfa, 0xd8, 0x5e, 0x48, 0x7d, 0xc9, 0xf3, 0xa7, 0xaf, 0xb1, 0xba, 0xa3, - 0xd8, 0x3d, 0xd0, 0x1f, 0x4f, 0x82, 0x68, 0xd5, 0x9d, 0xf9, 0xe3, 0x31, 0x0b, 0x7b, 0x08, 0x73, 0x5e, 0xc8, 0x82, - 0xc0, 0x5f, 0x24, 0x7e, 0xd2, 0x9b, 0x7b, 0xb7, 0xbc, 0xd7, 0x83, 0xa6, 0x5e, 0xdb, 0xbc, 0xd7, 0xf6, 0xce, 0xbd, - 0x4a, 0xdd, 0x40, 0x0c, 0x2b, 0xea, 0x87, 0x83, 0x76, 0xa8, 0xd8, 0x95, 0x71, 0xee, 0xdc, 0xeb, 0x22, 0x66, 0xeb, - 0xb9, 0x17, 0x4f, 0xfd, 0xb0, 0x6b, 0x67, 0xd6, 0xcd, 0x9a, 0x36, 0xc6, 0xa3, 0x4e, 0xa7, 0x93, 0x59, 0x63, 0xf1, - 0x64, 0x8f, 0xc7, 0x99, 0x35, 0x12, 0x4f, 0x93, 0x89, 0x6d, 0x4f, 0x26, 0x99, 0xe5, 0x8b, 0x82, 0x76, 0x6b, 0x34, - 0x6e, 0xb7, 0x32, 0x6b, 0x25, 0xd5, 0xc8, 0x2c, 0xc6, 0x9f, 0x62, 0x36, 0xee, 0xe1, 0x46, 0xe2, 0xfe, 0xcf, 0xc7, - 0xb6, 0x9d, 0x21, 0x06, 0xb8, 0x2c, 0xe1, 0x26, 0x34, 0x5d, 0xb9, 0x5a, 0xef, 0x5c, 0x53, 0x29, 0x3e, 0x37, 0x1a, - 0xd5, 0xd6, 0x1b, 0x7b, 0xf1, 0xc7, 0x2b, 0x45, 0x1a, 0x85, 0xe7, 0x51, 0xb5, 0xb5, 0x98, 0x06, 0xf3, 0xb6, 0x0b, - 0x09, 0x3b, 0x7a, 0xc3, 0x28, 0x86, 0x33, 0x1b, 0x7b, 0x63, 0x7f, 0x99, 0x74, 0x9d, 0xd6, 0xe2, 0x56, 0x14, 0xf1, - 0xbd, 0x5e, 0x14, 0xe0, 0xd9, 0xeb, 0x26, 0x51, 0xe0, 0x8f, 0x45, 0x51, 0xd3, 0x59, 0x72, 0x5a, 0x7a, 0x0f, 0xf9, - 0x57, 0x1f, 0x83, 0x2e, 0x7b, 0x41, 0xa0, 0x58, 0xed, 0x44, 0x61, 0x5e, 0x82, 0xe6, 0x72, 0x8a, 0x9d, 0xd0, 0xbc, - 0x60, 0x68, 0x5a, 0xe7, 0x60, 0x71, 0x9b, 0xef, 0x79, 0xe7, 0x68, 0x71, 0x9b, 0x7d, 0x3d, 0x67, 0x63, 0xdf, 0x53, - 0xb4, 0x62, 0x37, 0x39, 0x36, 0x98, 0xd4, 0xe9, 0xeb, 0x86, 0x6d, 0x2a, 0x8e, 0x05, 0x24, 0x36, 0xda, 0xf3, 0xe7, - 0x20, 0x87, 0xf1, 0xc2, 0x34, 0xcb, 0x06, 0x57, 0x59, 0xd6, 0x3b, 0xf7, 0xb5, 0xcb, 0xff, 0xd6, 0x88, 0x16, 0x92, - 0x09, 0x6a, 0xa6, 0x5f, 0x19, 0x67, 0x4c, 0x76, 0x97, 0x01, 0x32, 0x86, 0xae, 0x32, 0x72, 0x65, 0xa2, 0xb7, 0x9b, - 0x95, 0x69, 0x92, 0xf3, 0xea, 0xe4, 0x7d, 0x53, 0xae, 0x82, 0x14, 0x08, 0x2a, 0x9c, 0x31, 0xf7, 0x5c, 0xf2, 0xbd, - 0x01, 0xa6, 0x07, 0x2b, 0x53, 0x54, 0xa1, 0xd7, 0x4d, 0xbc, 0xe7, 0xc5, 0xfd, 0xbc, 0xe7, 0x5f, 0xd3, 0x5d, 0x78, - 0xcf, 0x8b, 0x2f, 0xce, 0x7b, 0xbe, 0xde, 0x8c, 0x2a, 0x74, 0x11, 0xb9, 0x6a, 0x6e, 0x30, 0x09, 0xa4, 0x29, 0xa6, - 0x78, 0xfd, 0xaf, 0xd3, 0xdf, 0x1a, 0xde, 0x45, 0xf4, 0x86, 0x44, 0x81, 0xf3, 0xa9, 0x20, 0x66, 0x7d, 0x1b, 0xba, - 0x7f, 0x8e, 0xe5, 0xe7, 0xc9, 0xc4, 0x7d, 0x1d, 0x49, 0x05, 0xf9, 0x13, 0xf7, 0x25, 0x29, 0xc5, 0x56, 0xa6, 0x37, - 0xb9, 0xb7, 0x0f, 0x64, 0x9f, 0x86, 0xd0, 0xac, 0xe4, 0xda, 0x3d, 0xce, 0x7d, 0xee, 0x7a, 0x65, 0x10, 0xb4, 0xdc, - 0xc9, 0x55, 0x04, 0xe0, 0xda, 0xb0, 0x8c, 0x9a, 0x32, 0x21, 0x03, 0x78, 0x79, 0xf7, 0xfd, 0x58, 0xbb, 0x88, 0xf4, - 0xcc, 0x4f, 0xde, 0x56, 0xc3, 0x5f, 0x09, 0x3d, 0x97, 0x3c, 0x9c, 0x8c, 0xfb, 0xcd, 0x49, 0x51, 0x6e, 0xf1, 0x35, - 0x35, 0x3f, 0x2d, 0x8d, 0xb4, 0x2b, 0x37, 0xec, 0x51, 0xcc, 0xef, 0x0d, 0x62, 0xcc, 0xc3, 0xc4, 0xac, 0x39, 0x97, - 0xb7, 0xc6, 0x67, 0x88, 0x1a, 0x3a, 0xa6, 0xe6, 0xfe, 0x38, 0xcb, 0xf4, 0x9e, 0x98, 0x08, 0x89, 0xd0, 0xb2, 0xfb, - 0x98, 0xb8, 0xa4, 0x10, 0x02, 0x71, 0x89, 0x0f, 0x59, 0x33, 0x5f, 0x80, 0x7f, 0x00, 0xb7, 0x7d, 0xe6, 0x73, 0xa6, - 0x2a, 0x34, 0x7d, 0xe4, 0x37, 0x22, 0x0d, 0x08, 0x0c, 0xda, 0x65, 0x6f, 0xab, 0xd2, 0x82, 0x6c, 0x3a, 0xb6, 0xd2, - 0xe4, 0xa0, 0x83, 0x03, 0xc4, 0xf8, 0x15, 0x62, 0x21, 0x42, 0x3b, 0xbc, 0x0e, 0x3e, 0x64, 0x6a, 0xce, 0xfb, 0xe1, - 0xf6, 0xeb, 0x9f, 0xec, 0x43, 0x83, 0x7e, 0x45, 0xe9, 0x76, 0x8f, 0x5f, 0x26, 0xb0, 0x12, 0xc9, 0xca, 0xb0, 0x92, - 0x95, 0xf2, 0x6c, 0x2d, 0xe2, 0x63, 0xa7, 0xde, 0xc2, 0x04, 0x2d, 0x0f, 0xe2, 0x5e, 0x8e, 0xf1, 0xa4, 0x50, 0xdc, - 0xbd, 0x65, 0x02, 0xb8, 0x11, 0xe5, 0x28, 0x88, 0x7f, 0x7a, 0xa3, 0x65, 0x9c, 0x44, 0x71, 0x77, 0x11, 0xf9, 0x61, - 0xca, 0xe2, 0x8c, 0x04, 0x2b, 0x38, 0x3f, 0x62, 0x7a, 0xae, 0xd6, 0xd1, 0xc2, 0x1b, 0xf9, 0xe9, 0x5d, 0xd7, 0xe6, - 0x2c, 0x85, 0xdd, 0xe3, 0xdc, 0x81, 0x5d, 0x5b, 0xbf, 0xcb, 0x67, 0xf3, 0x39, 0x32, 0x7e, 0xf1, 0x26, 0x3b, 0x23, - 0x6f, 0xf3, 0x9e, 0xf4, 0x96, 0x22, 0x84, 0x03, 0xfb, 0xe1, 0xc5, 0xe6, 0x14, 0xb0, 0x3c, 0x2c, 0xb5, 0x3d, 0x66, - 0x53, 0x03, 0xb1, 0x36, 0x98, 0x19, 0x8a, 0x3f, 0xd6, 0xa1, 0xae, 0xd8, 0xf5, 0xc5, 0xc0, 0xf1, 0xe8, 0xbb, 0x40, - 0xd6, 0xf5, 0x26, 0x29, 0x8b, 0x8d, 0x5d, 0x6a, 0x0e, 0xd9, 0x24, 0x8a, 0x19, 0x65, 0x93, 0x73, 0x3a, 0x8b, 0xdb, - 0xdd, 0xbb, 0xdf, 0x3e, 0xfc, 0xfa, 0x7e, 0xc2, 0x28, 0xd5, 0x44, 0x67, 0xfa, 0x3d, 0xbd, 0x6d, 0xd2, 0x33, 0x60, - 0x0d, 0x69, 0xe6, 0x47, 0x24, 0x05, 0x81, 0x48, 0x60, 0xb5, 0x49, 0x3b, 0x16, 0x11, 0xa7, 0x79, 0x31, 0x0b, 0xbc, - 0xd4, 0xbf, 0x11, 0x3c, 0x63, 0xfb, 0x68, 0x71, 0x2b, 0xd6, 0x18, 0x09, 0xde, 0x03, 0x16, 0xa9, 0x02, 0x8a, 0x58, - 0xa4, 0x6a, 0x31, 0x2e, 0x52, 0x6f, 0x63, 0x34, 0x22, 0x8e, 0x75, 0x85, 0xd2, 0x1f, 0x2e, 0x6e, 0x65, 0x12, 0x5d, - 0x34, 0xcb, 0x29, 0x75, 0x35, 0x01, 0xc9, 0xdc, 0x1f, 0x8f, 0x03, 0x96, 0x95, 0x16, 0xba, 0xbc, 0x96, 0xd2, 0xe4, - 0xe4, 0xf3, 0xe0, 0x0d, 0x93, 0x28, 0x58, 0xa6, 0xac, 0x7e, 0xba, 0x84, 0x44, 0xb7, 0x98, 0x1c, 0xfc, 0x5d, 0x86, - 0xf5, 0x10, 0xd8, 0x6d, 0xd8, 0x26, 0x76, 0x0f, 0xf2, 0x0d, 0x9a, 0xed, 0x32, 0xe8, 0xf0, 0x2a, 0x07, 0xda, 0xa8, - 0x19, 0x88, 0x01, 0x64, 0x89, 0xb0, 0xb7, 0x62, 0x39, 0xbc, 0x2c, 0xcf, 0xb9, 0x96, 0x17, 0x65, 0xe5, 0xc1, 0xfc, - 0x3e, 0x67, 0xec, 0x45, 0xfd, 0x19, 0x7b, 0x21, 0xce, 0xd8, 0xf6, 0x9d, 0xf9, 0x68, 0xe2, 0xc0, 0x7f, 0xbd, 0x62, - 0x40, 0x5d, 0x5b, 0x69, 0x2f, 0x6e, 0x15, 0x67, 0x71, 0xab, 0x98, 0xad, 0xc5, 0xad, 0x82, 0x5d, 0xa3, 0x7b, 0x8b, - 0x61, 0xb5, 0x74, 0xc3, 0x56, 0xa0, 0x10, 0xfe, 0xd8, 0xa5, 0x57, 0xce, 0x01, 0xbc, 0x83, 0x56, 0x87, 0x9b, 0xef, - 0x5a, 0xdb, 0x8f, 0x3a, 0x9d, 0x25, 0x81, 0xb4, 0x75, 0x2b, 0xf5, 0x86, 0x43, 0x10, 0x65, 0x46, 0xa3, 0x65, 0xf2, - 0x0f, 0x0e, 0x3f, 0x9f, 0xc4, 0xad, 0x88, 0xa0, 0xd2, 0x8f, 0x68, 0x0a, 0x8a, 0xc2, 0x1b, 0x26, 0x7a, 0x58, 0xe7, - 0xeb, 0xd4, 0xa5, 0xe4, 0x88, 0x2d, 0xeb, 0xa0, 0x66, 0x93, 0xd7, 0x4f, 0xf4, 0xef, 0xb6, 0x4a, 0xcd, 0x28, 0xe6, - 0x33, 0xa6, 0x65, 0xeb, 0x74, 0x3c, 0x7c, 0x36, 0xf8, 0x6a, 0xda, 0x9d, 0x7a, 0x70, 0x2f, 0xc5, 0x97, 0xae, 0x04, - 0x51, 0xe1, 0x74, 0x8b, 0x87, 0xe2, 0xd8, 0xde, 0x6b, 0xd3, 0x1e, 0xd9, 0xe8, 0x75, 0x0b, 0x41, 0x28, 0xea, 0xee, - 0x88, 0xe5, 0x1f, 0xbd, 0x38, 0x80, 0xff, 0x88, 0xab, 0xff, 0x6b, 0x5a, 0xc7, 0xa8, 0xbf, 0x4e, 0x4b, 0x8c, 0x3a, - 0xb1, 0x4a, 0xc8, 0x88, 0xef, 0x5e, 0x7f, 0x32, 0x79, 0x58, 0x83, 0x9d, 0x6b, 0x93, 0x67, 0x58, 0xb5, 0xf6, 0xcb, - 0x28, 0x0a, 0x98, 0x17, 0x6e, 0x56, 0x17, 0xd3, 0x43, 0x6e, 0xfe, 0xa9, 0x0b, 0x8d, 0xc4, 0x3d, 0x82, 0x9c, 0x12, - 0x54, 0x6c, 0x43, 0x57, 0x89, 0xf3, 0xa6, 0xab, 0xc4, 0xbb, 0xfb, 0xaf, 0x12, 0x3f, 0xec, 0x74, 0x95, 0x78, 0xf7, - 0xc5, 0xaf, 0x12, 0xe7, 0x9b, 0x57, 0x89, 0xf3, 0x48, 0xb8, 0x03, 0x1b, 0x6f, 0x96, 0xfc, 0xe7, 0x07, 0xb2, 0xf7, - 0x7d, 0x17, 0xb9, 0x87, 0x36, 0x25, 0x3c, 0xbc, 0xf8, 0xcd, 0x17, 0x0b, 0xdc, 0x88, 0xef, 0xd0, 0x3b, 0xae, 0xb8, - 0x5a, 0x70, 0xcc, 0x8e, 0xdf, 0x91, 0x8a, 0x83, 0x28, 0x9c, 0xfe, 0x0c, 0xf6, 0xde, 0x20, 0x0e, 0x8c, 0xa5, 0x17, - 0x7e, 0xf2, 0x73, 0xb4, 0x58, 0x2e, 0x50, 0x51, 0xf5, 0xc1, 0x4f, 0xfc, 0x61, 0xc0, 0xf2, 0x08, 0x93, 0xa4, 0x75, - 0xe5, 0xb2, 0x75, 0x50, 0xbc, 0x8a, 0x9f, 0xde, 0xad, 0xf8, 0x89, 0x2e, 0xb6, 0xfc, 0x37, 0xb9, 0x09, 0xaa, 0xf5, - 0x17, 0x11, 0x61, 0x21, 0x26, 0x01, 0xfd, 0xf0, 0xcb, 0xc8, 0xb9, 0x88, 0xe5, 0x55, 0x1a, 0xa5, 0x70, 0xdf, 0x68, - 0xec, 0x87, 0x55, 0xfb, 0x79, 0xb3, 0xd4, 0x8d, 0x3c, 0x01, 0xc7, 0xa6, 0x38, 0x7f, 0x1e, 0x2d, 0x13, 0x36, 0x8e, - 0x56, 0xa1, 0x6a, 0x84, 0x5c, 0xaf, 0x1a, 0xa1, 0x4c, 0x3d, 0x6f, 0x53, 0x56, 0x38, 0xaa, 0xd6, 0x02, 0xe6, 0xd0, - 0x24, 0x0d, 0xb6, 0x89, 0x43, 0x54, 0x45, 0xc8, 0xa6, 0xde, 0x9e, 0xa6, 0x45, 0xee, 0xc3, 0x5a, 0x0a, 0xcf, 0x93, - 0xc8, 0xe2, 0x52, 0xe1, 0x44, 0x0b, 0x85, 0x70, 0x51, 0x44, 0xc1, 0xae, 0x59, 0x38, 0xfe, 0x86, 0x22, 0x44, 0x16, - 0x6f, 0x41, 0x57, 0x95, 0x2d, 0xf9, 0x7a, 0xf0, 0x98, 0xd0, 0xf4, 0xf8, 0x4a, 0x9a, 0xc6, 0xb7, 0x37, 0x2c, 0x0e, - 0xbc, 0x3b, 0x4d, 0xcf, 0xa2, 0xf0, 0x47, 0x98, 0x80, 0xd7, 0xd1, 0x2a, 0x94, 0x2b, 0x60, 0xaa, 0xf6, 0x9a, 0xbd, - 0x54, 0x1b, 0xbd, 0x1c, 0x62, 0x76, 0x48, 0x10, 0xf8, 0xd6, 0xc2, 0x9b, 0xb2, 0xff, 0x32, 0xe8, 0xdf, 0xff, 0xd6, - 0x33, 0xe3, 0x5d, 0x94, 0x7f, 0xe8, 0x97, 0xc5, 0x0e, 0x9f, 0x79, 0xf2, 0x64, 0xaf, 0x79, 0xd8, 0xda, 0x28, 0x60, - 0x5e, 0x2c, 0xa0, 0xa8, 0x69, 0xad, 0x37, 0x9e, 0x02, 0x80, 0xe2, 0x22, 0x5a, 0x8e, 0x66, 0xe8, 0xb7, 0xfb, 0xe5, - 0xc6, 0x9b, 0x42, 0x9f, 0x2c, 0xb9, 0xb4, 0xaf, 0xf2, 0xa1, 0x57, 0x8a, 0x8a, 0x59, 0xc0, 0xef, 0x9f, 0x41, 0xfa, - 0xad, 0x7f, 0xe3, 0x34, 0x6c, 0xee, 0x9a, 0x3c, 0xe4, 0xd7, 0x83, 0x36, 0x6f, 0xcf, 0x87, 0xa8, 0x3c, 0x14, 0xd8, - 0x5a, 0x28, 0xe9, 0xea, 0x91, 0x4c, 0x56, 0x9d, 0x34, 0x39, 0x89, 0x4c, 0x53, 0x7e, 0x1c, 0xf1, 0x15, 0x66, 0x95, - 0xac, 0x46, 0x0c, 0xc6, 0xb1, 0x55, 0x05, 0xc9, 0x70, 0x6f, 0x0a, 0x86, 0xe8, 0xab, 0xfa, 0x6e, 0xee, 0x87, 0x06, - 0xe6, 0x80, 0xdd, 0x7c, 0xe3, 0xdd, 0x42, 0x16, 0x44, 0x40, 0x6e, 0xd5, 0x57, 0x50, 0x68, 0xc8, 0xd1, 0x82, 0xbc, - 0xf1, 0x58, 0x53, 0x6b, 0x67, 0x42, 0x68, 0x03, 0x07, 0x5f, 0x29, 0x8a, 0xa2, 0xe4, 0xd7, 0x08, 0x25, 0xbf, 0x47, - 0x60, 0x39, 0x5e, 0x07, 0x40, 0x5b, 0x92, 0x2d, 0x6e, 0xa9, 0x04, 0x6e, 0x06, 0x68, 0x3f, 0x2d, 0x0a, 0x78, 0xa2, - 0x1f, 0x30, 0x6e, 0xa1, 0x02, 0x71, 0xa1, 0x07, 0xd5, 0xb7, 0x17, 0x43, 0x3e, 0xc0, 0xae, 0x82, 0x17, 0x76, 0x7c, - 0xcb, 0x25, 0xc1, 0x8a, 0x4d, 0x8f, 0x83, 0x1e, 0xab, 0xcf, 0x08, 0x13, 0x4a, 0x58, 0x10, 0xb4, 0x0e, 0x95, 0x04, - 0x8f, 0x06, 0xab, 0xc1, 0x8d, 0x78, 0x2f, 0xba, 0x4d, 0xe7, 0x2c, 0x5c, 0xaa, 0x06, 0x58, 0x9d, 0x60, 0x86, 0x1e, - 0xa8, 0xf3, 0x9a, 0x98, 0x2d, 0xc0, 0x36, 0xf5, 0x2d, 0x67, 0x44, 0x0b, 0x85, 0xa9, 0x8a, 0x67, 0x8c, 0x78, 0x00, - 0x9c, 0x84, 0xe3, 0xb6, 0x2a, 0x85, 0xe0, 0x4b, 0x1a, 0x95, 0xb1, 0x39, 0x0f, 0x79, 0x85, 0x9c, 0x02, 0xd9, 0x88, - 0x71, 0x71, 0x91, 0x98, 0x76, 0xcd, 0xab, 0x2e, 0x5a, 0xae, 0x91, 0xf1, 0x2a, 0x82, 0xa2, 0x58, 0xdf, 0xec, 0x86, - 0xc3, 0x09, 0x69, 0x09, 0x1a, 0xfb, 0x19, 0x6d, 0xf4, 0xd3, 0x30, 0xe8, 0x8f, 0xec, 0x8e, 0x08, 0x09, 0x4d, 0xd5, - 0x47, 0x76, 0x07, 0xc6, 0xe1, 0x67, 0x20, 0x4d, 0x51, 0xb7, 0xa0, 0x6b, 0x03, 0x12, 0xfd, 0x8e, 0x20, 0x55, 0xc5, - 0x96, 0x03, 0x64, 0x67, 0x5b, 0xb0, 0x38, 0x85, 0x23, 0x35, 0x92, 0x9e, 0x38, 0xc4, 0x3c, 0x62, 0x81, 0x56, 0x3b, - 0xc7, 0x66, 0xcd, 0xd1, 0xd0, 0x9f, 0x39, 0xb6, 0xbd, 0xbf, 0x51, 0x1f, 0x04, 0xd9, 0x75, 0xb5, 0x75, 0x23, 0x75, - 0x1d, 0xdb, 0xf4, 0x9f, 0x59, 0xad, 0xde, 0x06, 0x8d, 0x96, 0x32, 0x49, 0x0d, 0x50, 0xfc, 0xd5, 0x7f, 0xbc, 0xd6, - 0x36, 0x0e, 0xa4, 0x5e, 0x8d, 0x00, 0x80, 0xb0, 0x65, 0x5c, 0xfe, 0x35, 0xd8, 0x24, 0xfd, 0x94, 0xc7, 0x8a, 0xb2, - 0x9a, 0x0f, 0x20, 0x17, 0xa2, 0x06, 0xc7, 0xe8, 0x4f, 0xca, 0x73, 0x45, 0xa3, 0xe3, 0xa3, 0xeb, 0x83, 0x9e, 0xc0, - 0x28, 0x22, 0x44, 0x8e, 0xdc, 0x41, 0xe5, 0x8b, 0x49, 0x15, 0xc3, 0xf1, 0xac, 0x6b, 0xac, 0xd0, 0xe8, 0x6d, 0xe5, - 0x16, 0xb0, 0xff, 0x06, 0xf2, 0x69, 0x0d, 0x21, 0xc6, 0x23, 0xd4, 0x80, 0xcc, 0xa9, 0xf7, 0x76, 0x08, 0xe1, 0x79, - 0xe5, 0xee, 0xca, 0x44, 0x72, 0xf7, 0xce, 0x90, 0xe8, 0xa0, 0x0e, 0x2d, 0xef, 0xaf, 0x9e, 0xdc, 0x3d, 0xb0, 0x4b, - 0x16, 0x8e, 0xcb, 0x1d, 0x56, 0xe8, 0xd7, 0xee, 0xdd, 0x95, 0x30, 0x0a, 0xa4, 0x14, 0x8e, 0x6a, 0x30, 0x4a, 0x16, - 0x85, 0xb8, 0xf9, 0xe9, 0xb8, 0xf9, 0x3b, 0x71, 0x31, 0xd8, 0x80, 0xf2, 0x81, 0xe4, 0xcd, 0x24, 0xa1, 0x38, 0xe4, - 0xad, 0xc4, 0x08, 0x5a, 0x9a, 0x60, 0x44, 0x1b, 0x77, 0x62, 0x2a, 0xdc, 0x15, 0x8b, 0x36, 0x3e, 0xcf, 0x44, 0xb5, - 0xab, 0xd4, 0xda, 0xbf, 0x5f, 0x6a, 0x9d, 0xde, 0x27, 0xb5, 0xa6, 0xe8, 0x30, 0xdc, 0x1e, 0x54, 0x44, 0xc9, 0x11, - 0xcc, 0xb9, 0x1c, 0x67, 0xa8, 0x24, 0xea, 0xc6, 0x60, 0x32, 0x35, 0x56, 0xa4, 0xd4, 0x1b, 0x39, 0x20, 0xa2, 0xf8, - 0x5b, 0xba, 0xa0, 0x08, 0x85, 0xba, 0x2c, 0x1b, 0x3f, 0x2f, 0x64, 0xe3, 0x74, 0xab, 0x29, 0xe2, 0x82, 0x08, 0xee, - 0x5f, 0x8a, 0xb9, 0x93, 0xdf, 0x0e, 0x8a, 0xd8, 0x3b, 0x05, 0xa4, 0x52, 0x34, 0x99, 0xe2, 0xa2, 0x21, 0xc5, 0x28, - 0x12, 0xb7, 0x8c, 0x72, 0xa8, 0xa2, 0x72, 0xd5, 0x22, 0x98, 0x4c, 0x51, 0x0e, 0x52, 0x77, 0x04, 0x39, 0x2f, 0x96, - 0xb7, 0x4d, 0x39, 0x9a, 0x88, 0xfc, 0x5a, 0xda, 0x24, 0x79, 0xd8, 0x0f, 0x9a, 0x60, 0x21, 0xa6, 0xaf, 0xe8, 0xb5, - 0x73, 0x1b, 0x08, 0x04, 0xb2, 0x26, 0x4a, 0xd1, 0xfd, 0xd2, 0x79, 0xca, 0x96, 0x5c, 0xa8, 0xae, 0x1d, 0xa4, 0xee, - 0xa4, 0x09, 0x96, 0xe5, 0x11, 0x38, 0xd7, 0x57, 0x92, 0x04, 0xa1, 0x6b, 0x2b, 0x76, 0xaf, 0x86, 0x01, 0x40, 0xfa, - 0x5f, 0x7d, 0xe6, 0xac, 0x00, 0x48, 0x22, 0x15, 0x5b, 0xd6, 0xf9, 0xe3, 0x21, 0x36, 0xc9, 0x92, 0x1d, 0xab, 0x6e, - 0x7e, 0x93, 0xe4, 0x3d, 0x6b, 0x1e, 0x13, 0xa4, 0x2c, 0xce, 0xe7, 0x35, 0xba, 0x02, 0x0e, 0xbe, 0xcb, 0xe2, 0x65, - 0x88, 0x49, 0x70, 0xcd, 0x34, 0xf6, 0x46, 0x1f, 0xd7, 0xd2, 0xf7, 0xb8, 0x48, 0x14, 0xc4, 0xc5, 0x65, 0xa5, 0x42, - 0xcf, 0xc3, 0x9c, 0x51, 0xac, 0x6b, 0xb5, 0x12, 0x49, 0x50, 0xd3, 0x7d, 0x64, 0xb7, 0xbd, 0x17, 0x93, 0x83, 0x8a, - 0xfc, 0xb4, 0x75, 0x58, 0x96, 0xae, 0xe7, 0x70, 0xcc, 0xa3, 0x5f, 0x79, 0xf4, 0xa4, 0x3f, 0xfe, 0xd3, 0x09, 0xff, - 0x66, 0x65, 0x8d, 0x3e, 0x07, 0x04, 0x68, 0x5f, 0x52, 0x4c, 0xcb, 0x6a, 0x9a, 0x8d, 0x92, 0x26, 0xb0, 0x26, 0x7e, - 0x10, 0x98, 0x01, 0xb8, 0x31, 0xac, 0x3f, 0x6b, 0x78, 0xd8, 0xcf, 0x12, 0xb2, 0x15, 0x7e, 0x46, 0x3f, 0xe5, 0x9d, - 0x92, 0xce, 0x96, 0xf3, 0xe1, 0x5a, 0x16, 0x94, 0x4b, 0xf2, 0xf3, 0x4d, 0x99, 0xb9, 0xfc, 0xd9, 0xc9, 0x64, 0x52, - 0x96, 0x1a, 0xdb, 0xca, 0x01, 0x4a, 0x7e, 0x1f, 0xd9, 0xb6, 0x5d, 0x9d, 0xdf, 0xa6, 0x83, 0x42, 0x07, 0xc3, 0x44, - 0x21, 0x7c, 0xe7, 0xfe, 0x3d, 0xf5, 0x07, 0x41, 0x4b, 0x5d, 0x35, 0x9d, 0x47, 0xda, 0x6a, 0xff, 0x11, 0xa0, 0x20, - 0x6a, 0xb8, 0xef, 0xf8, 0x6f, 0xee, 0x95, 0x2d, 0x3d, 0x55, 0x0f, 0xf0, 0xc3, 0x1a, 0xdf, 0xb3, 0xd7, 0x77, 0x68, - 0xda, 0xb4, 0xbd, 0x33, 0xab, 0x20, 0xbb, 0x25, 0x9b, 0xa5, 0x1e, 0x59, 0x2a, 0xf9, 0x29, 0x9b, 0x27, 0xdd, 0x11, - 0x43, 0x05, 0xa9, 0x25, 0x51, 0x5b, 0xb4, 0xea, 0x31, 0xa7, 0x60, 0xc7, 0xe5, 0x08, 0x3c, 0x6c, 0x2b, 0xa8, 0xac, - 0xda, 0xd0, 0xac, 0x89, 0x8f, 0x20, 0x15, 0x5b, 0x6f, 0x2a, 0x9c, 0x70, 0x9b, 0x1e, 0xda, 0x7f, 0x2a, 0xd5, 0x53, - 0x80, 0x3b, 0x5d, 0x0b, 0x6b, 0x13, 0x52, 0x9e, 0xe0, 0xdf, 0xb9, 0x72, 0xee, 0xc5, 0xe2, 0xb6, 0x6c, 0xdc, 0xd5, - 0x01, 0x75, 0x53, 0x41, 0xca, 0x08, 0xea, 0x3a, 0xd4, 0x97, 0x9b, 0x00, 0x4d, 0x64, 0xeb, 0x16, 0xb0, 0xa0, 0x11, - 0x53, 0x50, 0xd1, 0x11, 0xe6, 0xa0, 0xe2, 0x75, 0x16, 0x76, 0x5e, 0x21, 0xdf, 0xc7, 0x5f, 0x90, 0xa5, 0x1c, 0xd2, - 0x9d, 0xfc, 0xc9, 0x78, 0xde, 0x41, 0xe5, 0x5e, 0x69, 0xab, 0xa2, 0xa9, 0x0c, 0xee, 0x01, 0x71, 0x23, 0x55, 0x96, - 0x71, 0x60, 0x52, 0xe2, 0x7a, 0x4d, 0x5f, 0x6f, 0x8e, 0xbb, 0xb9, 0x7b, 0xe7, 0x10, 0xf4, 0x1a, 0x9b, 0x53, 0xb5, - 0x93, 0x6a, 0xaf, 0xaa, 0xc3, 0x16, 0x70, 0xc2, 0x0a, 0x80, 0xcf, 0xac, 0x82, 0x46, 0x43, 0x4a, 0x05, 0xf7, 0xd1, - 0xa0, 0xf3, 0xb7, 0x32, 0xb2, 0x16, 0xe3, 0xc4, 0xee, 0xea, 0xab, 0x50, 0xdf, 0x42, 0x33, 0x08, 0x73, 0xc7, 0xb1, - 0x13, 0x3e, 0x9b, 0xb0, 0x63, 0x64, 0x74, 0xe5, 0xe0, 0x0e, 0xc2, 0x53, 0x6a, 0x52, 0xf2, 0x13, 0x3a, 0xa5, 0xa8, - 0x4b, 0xf8, 0xa1, 0x56, 0x78, 0x7f, 0x51, 0x92, 0xc6, 0xf3, 0xa0, 0x13, 0x2d, 0x7d, 0xa7, 0xda, 0x73, 0x3f, 0xdc, - 0xbd, 0xae, 0x77, 0xbb, 0x73, 0x5d, 0x60, 0x0e, 0x77, 0xae, 0x0c, 0xdc, 0x25, 0x56, 0xbe, 0x48, 0xdd, 0x1f, 0x24, - 0xe5, 0x81, 0x1c, 0x30, 0x51, 0xc5, 0x56, 0x74, 0xa3, 0xff, 0x69, 0xe9, 0x0e, 0x4e, 0x4e, 0x6f, 0xe7, 0x81, 0x72, - 0xc3, 0xe2, 0x04, 0x12, 0x4a, 0xa8, 0x8e, 0x65, 0xab, 0x0a, 0x1a, 0xf4, 0xfb, 0xe1, 0xd4, 0x55, 0x7f, 0xb9, 0x78, - 0x63, 0x76, 0xd4, 0x53, 0x30, 0xc7, 0xb8, 0x99, 0x22, 0x8b, 0x7b, 0xee, 0xdd, 0xb1, 0xf8, 0xba, 0xc5, 0x3d, 0x7e, - 0x88, 0xb9, 0xc5, 0x32, 0xa5, 0xa5, 0xee, 0x90, 0x12, 0x5e, 0xb9, 0xf1, 0xd9, 0xea, 0x65, 0x74, 0xeb, 0xaa, 0x80, - 0x58, 0x9d, 0x56, 0x47, 0x71, 0x5a, 0x07, 0xd6, 0x51, 0x47, 0xed, 0x7f, 0xa5, 0x28, 0x27, 0x63, 0x36, 0x49, 0xfa, - 0x28, 0x8e, 0x39, 0x41, 0x7e, 0x90, 0x7e, 0x2b, 0x8a, 0x35, 0x0a, 0x12, 0xd3, 0x51, 0xd6, 0xfc, 0x51, 0x51, 0x00, - 0x19, 0x75, 0x95, 0x47, 0x93, 0xd6, 0xe4, 0x60, 0xf2, 0xa2, 0xc7, 0x8b, 0xb3, 0xaf, 0x4a, 0xd5, 0x0d, 0xfa, 0xb7, - 0x25, 0x35, 0x4b, 0xd2, 0x38, 0xfa, 0xc8, 0x38, 0x2f, 0xa9, 0xe4, 0x82, 0xa2, 0x6a, 0xd3, 0xd6, 0xe6, 0x97, 0x9c, - 0xce, 0x70, 0x34, 0x69, 0x15, 0xd5, 0x11, 0xc6, 0xfd, 0x1c, 0xc8, 0x93, 0x7d, 0x01, 0xfa, 0x89, 0x3c, 0x4d, 0x8e, - 0x59, 0x37, 0x51, 0x8e, 0xca, 0xc7, 0x38, 0x15, 0xe3, 0x3b, 0x81, 0x8c, 0x6b, 0x85, 0xf7, 0x62, 0x82, 0xcd, 0x5c, - 0xf5, 0x47, 0xa7, 0xd5, 0x31, 0x1c, 0xe7, 0xc8, 0x3a, 0xea, 0x8c, 0x6c, 0xe3, 0xc0, 0x3a, 0x30, 0xdb, 0xd6, 0x91, - 0xd1, 0x31, 0x3b, 0x46, 0xe7, 0xbb, 0xce, 0xc8, 0x3c, 0xb0, 0x0e, 0x0c, 0xdb, 0xec, 0x40, 0xa1, 0xd9, 0x31, 0x3b, - 0x37, 0xe6, 0x41, 0x67, 0x64, 0x63, 0x69, 0xcb, 0x3a, 0x3c, 0x34, 0x1d, 0xdb, 0x3a, 0x3c, 0x34, 0x0e, 0xad, 0xa3, - 0x23, 0xd3, 0x69, 0x5b, 0x47, 0x47, 0xe7, 0x87, 0x1d, 0xab, 0x0d, 0xef, 0xda, 0xed, 0x51, 0xdb, 0x72, 0x1c, 0x13, - 0xfe, 0x32, 0x3a, 0x56, 0x8b, 0x7e, 0x38, 0x8e, 0xd5, 0x76, 0x0c, 0x3b, 0x38, 0x6c, 0x59, 0x47, 0x2f, 0x0c, 0xfc, - 0x1b, 0xab, 0x19, 0xf8, 0x17, 0x74, 0x63, 0xbc, 0xb0, 0x5a, 0x47, 0xf4, 0x0b, 0x3b, 0xbc, 0x39, 0xe8, 0xfc, 0x55, - 0xdd, 0x6f, 0x1c, 0x83, 0x43, 0x63, 0xe8, 0x1c, 0x5a, 0xed, 0xb6, 0x71, 0xe0, 0x58, 0x9d, 0xf6, 0xcc, 0x3c, 0x68, - 0x59, 0x47, 0xc7, 0x23, 0xd3, 0xb1, 0x8e, 0x8f, 0x0d, 0xdb, 0x6c, 0x5b, 0x2d, 0xc3, 0xb1, 0x0e, 0xda, 0xf8, 0xa3, - 0x6d, 0xb5, 0x6e, 0x8e, 0x5f, 0x58, 0x47, 0x87, 0xb3, 0x23, 0xeb, 0xe0, 0xc3, 0x41, 0xc7, 0x6a, 0xb5, 0x67, 0xed, - 0x23, 0xab, 0x75, 0x7c, 0x73, 0x64, 0x1d, 0xcc, 0xcc, 0xd6, 0xd1, 0xd6, 0x96, 0x4e, 0xcb, 0x82, 0x39, 0xc2, 0xd7, - 0xf0, 0xc2, 0xe0, 0x2f, 0xe0, 0xcf, 0x0c, 0xdb, 0xfe, 0x81, 0xdd, 0x24, 0x9b, 0x4d, 0x5f, 0x58, 0x9d, 0xe3, 0x11, - 0x55, 0x87, 0x02, 0x53, 0xd4, 0x80, 0x26, 0x37, 0x26, 0x7d, 0x16, 0xbb, 0x33, 0x45, 0x47, 0xe2, 0x0f, 0xff, 0xd8, - 0x8d, 0x09, 0x1f, 0xa6, 0xef, 0xfe, 0x5b, 0xfb, 0xc9, 0x97, 0xfc, 0x64, 0x7f, 0x4a, 0x5b, 0x7f, 0xda, 0xff, 0xea, - 0x04, 0x0e, 0x77, 0x7f, 0x60, 0xfc, 0xda, 0xa4, 0x94, 0xfc, 0xfb, 0xfd, 0x4a, 0xc9, 0x97, 0xcb, 0x5d, 0x94, 0x92, - 0x7f, 0xff, 0xe2, 0x4a, 0xc9, 0x5f, 0xab, 0xbe, 0x35, 0x6f, 0xaa, 0x59, 0xa8, 0x7f, 0x58, 0x57, 0x45, 0x0e, 0x89, - 0xa7, 0x5d, 0xfe, 0xb4, 0xbc, 0x82, 0xf8, 0xf1, 0x6f, 0x22, 0xf7, 0xe5, 0xb2, 0x64, 0xf0, 0x19, 0x01, 0x8e, 0x7d, - 0x13, 0x11, 0x8e, 0xfd, 0xb0, 0x74, 0xc1, 0xca, 0x8c, 0xb3, 0x39, 0xfe, 0xd8, 0x9c, 0x79, 0xc1, 0x24, 0x67, 0x91, - 0xa0, 0xa4, 0x87, 0xc5, 0xe0, 0x37, 0x0f, 0xe4, 0x19, 0x6e, 0x32, 0xcb, 0x79, 0x98, 0x80, 0x45, 0x30, 0x58, 0x72, - 0x4c, 0xe2, 0xac, 0xd2, 0xd8, 0x12, 0x11, 0xf7, 0xaf, 0xb9, 0x47, 0x71, 0xe3, 0x7b, 0x34, 0x00, 0xae, 0xef, 0xdd, - 0xd9, 0xec, 0x57, 0x01, 0xcb, 0x3a, 0x61, 0x20, 0x0d, 0xdc, 0x7e, 0xdd, 0xfb, 0xb2, 0x19, 0x6e, 0xc5, 0xf0, 0xba, - 0x19, 0x52, 0x80, 0xa4, 0xda, 0xde, 0x29, 0x9b, 0xf1, 0xde, 0x37, 0xcc, 0x9a, 0xcf, 0x97, 0x9a, 0x6f, 0xb1, 0x21, - 0xce, 0x3b, 0xae, 0x4e, 0xd5, 0xba, 0xc4, 0xa7, 0xd5, 0x4f, 0x48, 0x71, 0x41, 0x2d, 0x0c, 0x8d, 0x0b, 0x4e, 0xd5, - 0x56, 0x90, 0xdf, 0xb1, 0xa5, 0x77, 0xa5, 0x3e, 0x65, 0xe3, 0xe4, 0x67, 0x6b, 0xbc, 0x57, 0xf8, 0xbf, 0x02, 0x27, - 0xca, 0x39, 0x9e, 0x61, 0x24, 0xcf, 0xf3, 0x5a, 0xea, 0x97, 0xa4, 0x11, 0xd9, 0xcc, 0x59, 0x6f, 0xf2, 0xa2, 0x8d, - 0x6e, 0x09, 0x0e, 0x9b, 0x0b, 0x2e, 0x08, 0x3f, 0x4f, 0x4e, 0x00, 0x19, 0x39, 0x6a, 0xa0, 0x9f, 0xc3, 0xb6, 0xce, - 0x44, 0xbd, 0x47, 0xb0, 0x89, 0xb9, 0x27, 0xa0, 0x22, 0x87, 0x34, 0x5d, 0x4f, 0x82, 0xc8, 0x4b, 0xbb, 0xc8, 0xa6, - 0x49, 0x2c, 0x6f, 0x0b, 0x3d, 0x16, 0x7a, 0x5b, 0x8c, 0xe9, 0xe4, 0x8e, 0x79, 0x27, 0xe8, 0xf9, 0xb0, 0xcd, 0xfe, - 0x2e, 0x77, 0x38, 0x5b, 0x97, 0xcc, 0x51, 0x9c, 0xc3, 0x63, 0xc3, 0x39, 0x32, 0xac, 0xe3, 0x43, 0x3d, 0x13, 0x07, - 0x4e, 0xee, 0xb2, 0x34, 0x21, 0xe0, 0x00, 0x91, 0x83, 0xe9, 0x87, 0x7e, 0xea, 0x7b, 0x41, 0x06, 0xfc, 0x70, 0xf9, - 0x92, 0xf2, 0xf7, 0x65, 0x92, 0xc2, 0x18, 0x05, 0xd3, 0x8b, 0xce, 0x1f, 0xe6, 0x90, 0xa5, 0x2b, 0xc6, 0xc2, 0x06, - 0xc3, 0x98, 0xaa, 0x2f, 0xc9, 0xef, 0x67, 0x59, 0x9f, 0x91, 0xd5, 0xda, 0x30, 0x0d, 0xf9, 0xfe, 0x10, 0x8e, 0x0f, - 0xd9, 0xc0, 0xf8, 0xae, 0x09, 0xe1, 0xfe, 0x72, 0x3f, 0xc2, 0x4d, 0xd9, 0x2e, 0x08, 0xf7, 0x97, 0x2f, 0x8e, 0x70, - 0xbf, 0x93, 0x11, 0x6e, 0xc9, 0x7f, 0xb0, 0xd0, 0x30, 0xbd, 0xc7, 0x67, 0x0d, 0x5c, 0x64, 0x9f, 0xab, 0xfb, 0xc4, - 0xc0, 0xab, 0x7a, 0x91, 0xbd, 0xf6, 0x2f, 0x4b, 0xd9, 0x82, 0x1a, 0x05, 0xa0, 0x98, 0xd7, 0xd1, 0x47, 0xd7, 0x65, - 0x1f, 0x5c, 0xdd, 0x44, 0x18, 0x06, 0xe8, 0xf3, 0xfb, 0x30, 0x0d, 0xac, 0x77, 0xfc, 0x1e, 0x09, 0x0a, 0xdd, 0x37, - 0x51, 0x3c, 0xf7, 0x30, 0xc5, 0x88, 0xaa, 0x83, 0x3b, 0x1d, 0x3c, 0xd8, 0x10, 0x08, 0x64, 0x14, 0x85, 0xe3, 0x5c, - 0x2b, 0xc9, 0xdc, 0x4b, 0xe2, 0xb8, 0xd5, 0x3b, 0xe6, 0xc5, 0xaa, 0x41, 0xaf, 0x61, 0x71, 0x9f, 0xb5, 0xed, 0x67, - 0xad, 0x83, 0x67, 0x47, 0x36, 0xfc, 0xef, 0xb0, 0x76, 0x66, 0xf0, 0x8a, 0xf3, 0x28, 0x4c, 0x67, 0x45, 0xcd, 0xa6, - 0x6a, 0x2b, 0xc6, 0x3e, 0x16, 0xb5, 0x8e, 0xeb, 0x2b, 0x8d, 0xbd, 0xbb, 0xa2, 0x4e, 0x6d, 0x8d, 0x59, 0xb4, 0x94, - 0xc0, 0xaa, 0x81, 0xc6, 0x0f, 0x97, 0x20, 0x67, 0x97, 0x6a, 0xc8, 0xaf, 0xf9, 0x70, 0x8b, 0x71, 0xb1, 0x76, 0x76, - 0x25, 0x72, 0x28, 0xa8, 0x3d, 0x91, 0x56, 0xef, 0xde, 0x19, 0xe4, 0x2a, 0x4a, 0x1b, 0x73, 0x4e, 0x61, 0x66, 0x43, - 0xc8, 0x38, 0xc5, 0xc4, 0x02, 0x79, 0xb4, 0x40, 0x69, 0xbc, 0x0c, 0x47, 0x1a, 0xfe, 0xf4, 0x86, 0x89, 0xe6, 0xef, - 0xc7, 0x16, 0xff, 0xb0, 0x8e, 0xab, 0xe6, 0xf5, 0xed, 0x22, 0xe9, 0x7c, 0x22, 0x56, 0xc5, 0x7b, 0x96, 0x1a, 0x31, - 0xea, 0xb1, 0x69, 0x69, 0x4d, 0xd7, 0x7b, 0x96, 0x37, 0x7c, 0x96, 0x1a, 0xe1, 0x73, 0xd0, 0x7d, 0xba, 0xf6, 0x93, - 0x27, 0x54, 0x6b, 0xcf, 0x15, 0xc3, 0x3a, 0x1d, 0x15, 0x99, 0x29, 0x14, 0x6f, 0x1a, 0x51, 0x72, 0x8a, 0xee, 0xc8, - 0x88, 0x9e, 0x3f, 0xef, 0xbb, 0x8e, 0x3e, 0x8c, 0x99, 0xf7, 0x31, 0x13, 0xe1, 0xbe, 0x43, 0xcc, 0x4f, 0x7b, 0xbe, - 0x9b, 0xa1, 0x91, 0x5e, 0xeb, 0x4a, 0xbb, 0x80, 0x3b, 0x93, 0x2d, 0xdc, 0x11, 0x38, 0xf6, 0x82, 0xdc, 0xf5, 0x64, - 0x50, 0xe0, 0x09, 0x83, 0x1f, 0x51, 0xe7, 0x7a, 0xe6, 0x25, 0x3f, 0x24, 0x51, 0xf8, 0xcb, 0x02, 0x82, 0x1f, 0x17, - 0x16, 0x45, 0xe2, 0x32, 0xd6, 0xb6, 0x6c, 0xcb, 0x56, 0xf3, 0xfe, 0x26, 0xfe, 0xd4, 0x5d, 0x47, 0xa9, 0xd7, 0xdd, - 0x73, 0x8c, 0x20, 0x9a, 0x82, 0x7b, 0x5d, 0xea, 0xa7, 0x01, 0xeb, 0xaa, 0x2a, 0xf8, 0xd9, 0xcd, 0xe9, 0xba, 0x9e, - 0x71, 0xa7, 0x07, 0x2f, 0x86, 0x6c, 0xe6, 0xf1, 0x9d, 0xf0, 0xd0, 0xc5, 0x18, 0xea, 0x3f, 0x02, 0x8d, 0xd4, 0x54, - 0x0d, 0x44, 0x06, 0x2c, 0x4e, 0x4c, 0xd9, 0x89, 0xa8, 0xab, 0x40, 0x1b, 0x5d, 0xe5, 0x63, 0x9b, 0xc4, 0xde, 0x1c, - 0xd2, 0xed, 0xae, 0x33, 0x83, 0x23, 0x60, 0x95, 0x63, 0x60, 0xc5, 0x79, 0x71, 0x64, 0x28, 0x2d, 0xc7, 0x50, 0x6c, - 0xc0, 0xc2, 0x6a, 0x66, 0xac, 0xb3, 0xab, 0xde, 0x7d, 0x76, 0x10, 0x84, 0x76, 0x1e, 0xd1, 0x38, 0xc8, 0x02, 0x82, - 0x6b, 0x98, 0x52, 0xca, 0x9d, 0xa3, 0x49, 0x89, 0x35, 0x7d, 0xd2, 0x85, 0x5e, 0xb0, 0xdb, 0x54, 0x07, 0x85, 0x92, - 0xa8, 0xe2, 0xeb, 0x6b, 0xf4, 0x23, 0xf6, 0x43, 0xc5, 0xff, 0xf4, 0x49, 0xf3, 0xc1, 0xc7, 0xc9, 0x95, 0xe6, 0x07, - 0x9e, 0xf5, 0xd2, 0x84, 0xf9, 0x85, 0xf6, 0x1e, 0x27, 0x0b, 0x1c, 0x10, 0xe1, 0xdf, 0xa2, 0x58, 0xfc, 0xe0, 0xd6, - 0x13, 0x56, 0xe0, 0x85, 0x53, 0xc0, 0x74, 0x5e, 0x38, 0xdd, 0xb0, 0xd2, 0x22, 0x57, 0xe8, 0x4a, 0x69, 0xd1, 0x55, - 0x61, 0x41, 0x95, 0xbc, 0xbc, 0xbb, 0xf0, 0xa6, 0x3f, 0x79, 0x73, 0xa6, 0xa9, 0x40, 0xfc, 0xd0, 0x73, 0xb7, 0x50, - 0xf0, 0x3e, 0x77, 0x9f, 0x9e, 0xcc, 0x59, 0xea, 0x91, 0x76, 0x08, 0xee, 0xc4, 0xc0, 0x25, 0x28, 0x9c, 0xfe, 0xf0, - 0x38, 0x18, 0x2e, 0xa5, 0xd8, 0x22, 0xf2, 0x61, 0x28, 0x9c, 0x7c, 0x99, 0x68, 0x08, 0xea, 0x3a, 0x06, 0xf9, 0x21, - 0x8c, 0x3c, 0x4c, 0xb3, 0xe3, 0x86, 0x91, 0xda, 0x7f, 0x9a, 0xbb, 0x6c, 0x36, 0x2d, 0x42, 0xe0, 0x87, 0x1f, 0x2f, - 0x63, 0x16, 0xfc, 0xc3, 0x7d, 0x0a, 0xf4, 0xfc, 0xe9, 0x95, 0xaa, 0xf7, 0x52, 0x6b, 0x16, 0xb3, 0x89, 0xfb, 0x14, - 0xee, 0xa9, 0x5d, 0xb4, 0x9a, 0x05, 0x66, 0xfe, 0xf9, 0xed, 0x3c, 0x30, 0xf0, 0xd6, 0x4f, 0xb0, 0xa8, 0xed, 0x56, - 0x11, 0xee, 0xbc, 0xbd, 0xd3, 0x5d, 0xbf, 0xcf, 0x2f, 0xf1, 0x70, 0x31, 0x5c, 0x97, 0xae, 0xde, 0x4e, 0x0f, 0xaf, - 0xd5, 0xc3, 0xc0, 0x1b, 0x7d, 0xec, 0xd1, 0x9b, 0xd2, 0x83, 0x09, 0x44, 0x7c, 0xe4, 0x2d, 0xba, 0x48, 0x75, 0xe5, - 0x42, 0x70, 0xaa, 0xa6, 0xd2, 0x9c, 0xe1, 0xab, 0xdd, 0xcb, 0xb8, 0x95, 0xd7, 0xf8, 0x65, 0xfc, 0xd4, 0x6a, 0xe6, - 0xa7, 0x4c, 0x7c, 0x0a, 0x1f, 0xb2, 0x4c, 0xdc, 0xdf, 0xe9, 0xe6, 0x8a, 0xf7, 0x6d, 0xab, 0xad, 0x38, 0x9d, 0xef, - 0x0e, 0x6f, 0x1c, 0x7b, 0xd6, 0x72, 0xac, 0xce, 0x07, 0xa7, 0x33, 0x6b, 0x5b, 0xc7, 0x81, 0xd9, 0xb6, 0x8e, 0xe1, - 0xcf, 0x87, 0x63, 0xab, 0x33, 0x33, 0x5b, 0xd6, 0xc1, 0x07, 0xa7, 0x15, 0x98, 0x1d, 0xeb, 0x18, 0xfe, 0x9c, 0x53, - 0x2b, 0xb8, 0x17, 0xd1, 0x35, 0xe8, 0x69, 0x09, 0x39, 0x48, 0xbf, 0x73, 0x55, 0xad, 0x51, 0xa2, 0x7a, 0x35, 0xea, - 0xde, 0x05, 0x06, 0x97, 0x10, 0x69, 0x6d, 0x30, 0xf4, 0x90, 0x16, 0xba, 0x8c, 0xd2, 0xcd, 0x0a, 0xc3, 0x37, 0xe1, - 0xa1, 0x5e, 0xe4, 0x3f, 0x95, 0x4e, 0x10, 0xaf, 0xdb, 0x4b, 0x68, 0xbb, 0x4b, 0x61, 0xe5, 0xb4, 0xca, 0xb1, 0x6b, - 0xc8, 0x7c, 0xac, 0x1b, 0xa0, 0x3a, 0x06, 0xc4, 0x54, 0x84, 0x83, 0xd2, 0x6a, 0xd1, 0x96, 0xc0, 0x66, 0x09, 0x4b, - 0xa9, 0x48, 0x13, 0x2d, 0x81, 0xd8, 0xe8, 0xba, 0x48, 0x58, 0x8c, 0x1d, 0xf3, 0x1a, 0x8c, 0x67, 0x56, 0xae, 0x7d, - 0xd5, 0xab, 0xe2, 0x4b, 0xf0, 0x8a, 0xb7, 0xc2, 0x68, 0x85, 0x56, 0x1f, 0xf7, 0xcd, 0x1d, 0xc6, 0x19, 0x60, 0xc2, - 0xe6, 0xac, 0x0c, 0x2c, 0x0f, 0x9d, 0x5d, 0xfd, 0xe0, 0x06, 0x82, 0x7e, 0xd0, 0x07, 0xa5, 0x44, 0xdd, 0x9f, 0xd5, - 0x0f, 0x8f, 0x62, 0x21, 0x27, 0xcb, 0x1c, 0xfb, 0x71, 0x0e, 0x9e, 0x44, 0x51, 0x9c, 0xfa, 0x4c, 0xa5, 0xba, 0x41, - 0xb1, 0x9c, 0x58, 0x7c, 0xe3, 0x05, 0x92, 0xdd, 0x9d, 0xd4, 0x72, 0x2f, 0x27, 0x54, 0x4f, 0x9e, 0x14, 0xc0, 0x99, - 0x15, 0xb8, 0x4f, 0x9c, 0x43, 0xe0, 0x12, 0x0e, 0x59, 0x7b, 0xab, 0x09, 0x28, 0x5d, 0xcc, 0xb6, 0xb9, 0x82, 0x17, - 0x89, 0x99, 0x84, 0x99, 0x97, 0x30, 0x30, 0x69, 0xb4, 0x43, 0xdd, 0x30, 0x2f, 0x81, 0xcc, 0x76, 0x95, 0x9b, 0x99, - 0xaa, 0xf7, 0x42, 0x61, 0x2d, 0x11, 0x6e, 0xc9, 0x49, 0xc7, 0xaf, 0x8e, 0x2a, 0x4c, 0xcd, 0x96, 0x71, 0xdc, 0xe3, - 0xcf, 0xfe, 0xef, 0x1e, 0x04, 0xfa, 0x96, 0x82, 0x79, 0x47, 0x05, 0x8b, 0x94, 0x7c, 0x0d, 0x73, 0x7a, 0x4f, 0x84, - 0x9e, 0x25, 0xa7, 0x2a, 0x14, 0xa9, 0x5d, 0x15, 0xfd, 0xd8, 0xd4, 0xdc, 0xb6, 0x35, 0xa7, 0x62, 0x45, 0x81, 0xe1, - 0x63, 0xfe, 0x4f, 0xe1, 0xe7, 0xaa, 0x3f, 0x79, 0xd2, 0x48, 0x1c, 0xc9, 0x96, 0x28, 0x61, 0xa9, 0xb8, 0x4f, 0x68, - 0xaa, 0x8c, 0x77, 0x55, 0x19, 0xf5, 0xe5, 0xfd, 0x22, 0x36, 0x13, 0x26, 0xb9, 0xb4, 0xf7, 0xf0, 0xe7, 0x90, 0x79, - 0xa9, 0xc5, 0x75, 0xbb, 0x9a, 0xc4, 0x74, 0x18, 0x80, 0x36, 0x32, 0x42, 0x21, 0xf9, 0x30, 0x07, 0x8f, 0xd7, 0x7f, - 0x59, 0xf2, 0x20, 0x14, 0xd0, 0xc7, 0xa7, 0x4f, 0x76, 0x11, 0x37, 0xf4, 0x6d, 0xea, 0x51, 0xdc, 0x36, 0x99, 0x17, - 0x88, 0x52, 0x8f, 0xec, 0x4f, 0x7c, 0x0c, 0xb5, 0x53, 0x1f, 0x41, 0x4c, 0x8a, 0x54, 0xd1, 0x7f, 0x7b, 0xf1, 0x8d, - 0xc2, 0x0f, 0x00, 0x59, 0x37, 0xe0, 0xc5, 0x8b, 0xe2, 0xe3, 0xb8, 0x14, 0x1f, 0x47, 0xe1, 0x99, 0x97, 0x21, 0x47, - 0x6c, 0xb6, 0x4f, 0x53, 0x88, 0x02, 0x73, 0xb2, 0xf9, 0x98, 0x2f, 0x83, 0xd4, 0x5f, 0x78, 0x71, 0xba, 0x8f, 0xc1, - 0x71, 0x30, 0xd8, 0x4e, 0x53, 0xfc, 0x0a, 0x32, 0x1b, 0x11, 0xd9, 0x4c, 0xd2, 0x50, 0xd8, 0x8d, 0x4c, 0xfc, 0x20, - 0x37, 0x1b, 0x11, 0x1f, 0xf0, 0x46, 0x23, 0xb6, 0x48, 0xdd, 0x52, 0x10, 0x9e, 0x68, 0x94, 0xb2, 0xd4, 0x4c, 0xd2, - 0x98, 0x79, 0x73, 0x35, 0x0f, 0xca, 0xb5, 0xd9, 0x5f, 0xb2, 0x1c, 0x42, 0x54, 0x21, 0x11, 0x1e, 0x8c, 0x06, 0x08, - 0x06, 0x1c, 0x00, 0x22, 0x04, 0xc5, 0xa1, 0x29, 0x3c, 0x8f, 0xa6, 0x95, 0x2d, 0x55, 0xb0, 0x54, 0xa7, 0x98, 0xd4, - 0x8c, 0x6e, 0x5e, 0x20, 0xdd, 0x1e, 0x45, 0xc1, 0x35, 0x8f, 0xb9, 0x91, 0x67, 0xc7, 0x51, 0xfb, 0x27, 0xfc, 0x3a, - 0xae, 0x60, 0xb8, 0x19, 0xf5, 0xd0, 0x86, 0xb4, 0x6d, 0x4d, 0xd1, 0x38, 0xf6, 0x79, 0x65, 0xa0, 0x99, 0xd4, 0x33, - 0x66, 0xde, 0x24, 0x58, 0x2e, 0x80, 0x64, 0x95, 0x0c, 0x7c, 0x66, 0x4e, 0x3f, 0x77, 0xff, 0x44, 0xa8, 0x90, 0xaa, - 0x7d, 0xfa, 0xf4, 0x7e, 0xf0, 0xaf, 0x7f, 0x42, 0x7a, 0xd0, 0x99, 0x23, 0x62, 0x60, 0x5c, 0xca, 0xb5, 0x38, 0x5b, - 0x6c, 0x0c, 0xd0, 0xb8, 0x8b, 0x8d, 0x45, 0x74, 0x42, 0xb1, 0xb7, 0xb2, 0xc1, 0x95, 0x88, 0xab, 0x07, 0x89, 0x85, - 0x75, 0x11, 0xa9, 0x63, 0x00, 0xcb, 0x3b, 0x10, 0x31, 0x5c, 0x94, 0xbf, 0xdd, 0xbe, 0x3c, 0x56, 0x8a, 0x70, 0x8f, - 0x75, 0x16, 0x48, 0xb4, 0x87, 0xfa, 0x27, 0x9e, 0x82, 0xdc, 0x14, 0xf2, 0x45, 0x49, 0x77, 0x1f, 0x86, 0x39, 0x8b, - 0xe6, 0xcc, 0xf2, 0xa3, 0xfd, 0x15, 0x1b, 0x9a, 0xde, 0xc2, 0x27, 0x3b, 0x22, 0x94, 0x13, 0x2a, 0xc4, 0x92, 0xe6, - 0xe6, 0x39, 0xc4, 0xf8, 0x67, 0xc5, 0x54, 0x46, 0x95, 0xc0, 0x6d, 0xad, 0x42, 0x6f, 0x79, 0xc0, 0x83, 0xa2, 0x89, - 0x9a, 0xfd, 0x93, 0x7d, 0xaf, 0x5f, 0xce, 0x94, 0x63, 0x89, 0x8c, 0xaf, 0x65, 0x2a, 0x70, 0x4a, 0x09, 0x6f, 0x44, - 0x6e, 0x9b, 0xe2, 0xc1, 0x8c, 0x26, 0x13, 0x39, 0xbb, 0x8d, 0x55, 0x06, 0x2f, 0x9f, 0xb4, 0x62, 0x4b, 0x47, 0x0b, - 0xfa, 0xd2, 0xe6, 0x27, 0xf2, 0x9f, 0x6a, 0x17, 0xd3, 0x5a, 0xc1, 0x98, 0xe1, 0xbc, 0x6f, 0x64, 0xc9, 0xc9, 0x67, - 0xec, 0x11, 0x55, 0xe2, 0x88, 0xa4, 0x9a, 0x93, 0xb1, 0x81, 0xa5, 0xda, 0x73, 0x5d, 0xc2, 0x73, 0x55, 0x74, 0x07, - 0x93, 0x58, 0x93, 0xfd, 0x16, 0x06, 0x9b, 0x42, 0x43, 0x93, 0xdc, 0x7b, 0xb1, 0x51, 0x75, 0x38, 0x9b, 0x30, 0xee, - 0x7b, 0x62, 0xfb, 0x95, 0x36, 0x28, 0x6c, 0x3c, 0xbe, 0xee, 0x80, 0xe0, 0x45, 0x3f, 0x15, 0x3c, 0xaf, 0x7c, 0x4d, - 0x28, 0xdd, 0x0c, 0xbc, 0xbb, 0x48, 0x32, 0xbb, 0xe2, 0x11, 0x58, 0xce, 0xb1, 0xf4, 0x42, 0x78, 0x3e, 0x6f, 0x1c, - 0x34, 0xa4, 0x61, 0x90, 0x25, 0x74, 0xf3, 0xb0, 0x15, 0x04, 0x38, 0x60, 0xf7, 0x9d, 0x35, 0xb9, 0x6e, 0x79, 0x30, - 0x88, 0x3c, 0xb3, 0xe2, 0x1c, 0x96, 0x5e, 0x22, 0x5a, 0xc8, 0x4e, 0xf6, 0x61, 0x7c, 0x94, 0xf7, 0x50, 0x30, 0x79, - 0xc2, 0xbe, 0x10, 0x6f, 0xbd, 0x7e, 0xd3, 0xad, 0xb7, 0xca, 0xa3, 0x94, 0x59, 0x2f, 0x5f, 0x87, 0xd8, 0x36, 0x5e, - 0x42, 0xb6, 0x67, 0xdf, 0x8f, 0x39, 0x61, 0x90, 0xbe, 0x92, 0x07, 0xc3, 0x2c, 0xd5, 0xd3, 0xb7, 0x06, 0x89, 0xa1, - 0x8c, 0xbb, 0x1f, 0x96, 0x98, 0x6e, 0x37, 0xeb, 0xa5, 0x4c, 0xc4, 0x6c, 0x38, 0x4f, 0x1b, 0xc2, 0x3a, 0x34, 0x55, - 0x21, 0x3e, 0x7c, 0x4b, 0x85, 0x62, 0x9b, 0x6f, 0xab, 0x55, 0x70, 0x56, 0x45, 0x35, 0x4f, 0x53, 0x1f, 0xe1, 0x81, - 0xd8, 0xa8, 0x8d, 0xa5, 0x18, 0x6c, 0x22, 0x75, 0xa1, 0xaa, 0x50, 0x2d, 0x78, 0x8b, 0x05, 0x55, 0xd6, 0x7b, 0x27, - 0xfb, 0x74, 0x9d, 0xee, 0xd3, 0x06, 0xec, 0x9f, 0x80, 0x65, 0x3a, 0xed, 0x09, 0x6f, 0xb1, 0xe0, 0x2b, 0x4e, 0xbf, - 0xe8, 0xcd, 0xfe, 0x2c, 0x9d, 0x07, 0xfd, 0xff, 0x05, 0x64, 0x23, 0xa6, 0xdb, 0x06, 0x7b, 0x03, 0x00}; + 0x88, 0xa6, 0xd7, 0x57, 0x61, 0xb7, 0x6c, 0xac, 0xd5, 0x01, 0x46, 0x82, 0x27, 0x31, 0x04, 0x0c, 0xcc, 0x8c, 0xa8, + 0xff, 0xdb, 0xb8, 0x8a, 0xfa, 0xd1, 0xfe, 0x2e, 0x47, 0xfe, 0x3f, 0xbf, 0x7d, 0x7f, 0x0e, 0x7a, 0x2d, 0x0f, 0x15, + 0xd1, 0x6b, 0x95, 0xdb, 0xb0, 0x98, 0xa0, 0x29, 0x52, 0x7b, 0xaa, 0xb7, 0x04, 0xea, 0x8c, 0x37, 0x86, 0xfd, 0x5b, + 0xf3, 0xe6, 0xe6, 0xc6, 0x04, 0x8b, 0x56, 0x73, 0x15, 0x07, 0xc4, 0x1d, 0x4e, 0xd4, 0x4c, 0x20, 0x75, 0x56, 0x41, + 0xea, 0x10, 0x0e, 0x97, 0xe7, 0x53, 0x79, 0x3f, 0x8f, 0x6e, 0xbe, 0x09, 0x02, 0x59, 0x6c, 0x23, 0x98, 0x38, 0x2e, + 0xc9, 0x28, 0x21, 0x03, 0x0d, 0xb4, 0x4f, 0x96, 0x9f, 0x5c, 0x71, 0x7b, 0x81, 0xc9, 0xd5, 0xe8, 0xee, 0x8a, 0xeb, + 0x24, 0xf2, 0x78, 0xc4, 0xef, 0x87, 0xc7, 0x13, 0xff, 0x5a, 0x41, 0x4e, 0xd3, 0x55, 0xc1, 0x99, 0x2b, 0x60, 0xa3, + 0x55, 0x9a, 0x46, 0xa1, 0x19, 0x47, 0x37, 0xea, 0xe0, 0x98, 0x1e, 0x44, 0x05, 0x8f, 0x1e, 0x55, 0xe5, 0xeb, 0x71, + 0xe0, 0x8f, 0x3f, 0xba, 0xea, 0xe3, 0xb5, 0xef, 0x0e, 0x2a, 0xfc, 0xa4, 0x9d, 0xa9, 0x03, 0x80, 0x55, 0xf9, 0x26, + 0x08, 0x8e, 0xf7, 0xe9, 0x8b, 0xc1, 0xf1, 0xfe, 0xc4, 0xbf, 0x1e, 0x48, 0xa9, 0x61, 0xb8, 0xde, 0xd4, 0xe5, 0x21, + 0x38, 0x73, 0x4b, 0xb3, 0x04, 0x63, 0x3a, 0x8c, 0x99, 0x56, 0x5c, 0x7e, 0x21, 0xd6, 0x0c, 0xc1, 0xab, 0x8d, 0x51, + 0x9c, 0x1e, 0xc0, 0x55, 0xef, 0xd3, 0x27, 0x2d, 0xb7, 0x43, 0x9d, 0x4b, 0x41, 0xda, 0x50, 0xcd, 0x87, 0x55, 0x0c, + 0x8c, 0x34, 0xa3, 0x6b, 0x22, 0x94, 0x5c, 0xa0, 0x1b, 0xe3, 0xcc, 0xc0, 0x0c, 0x3b, 0xde, 0x12, 0x34, 0x8e, 0xfc, + 0xa7, 0x74, 0x23, 0x1e, 0x43, 0x56, 0x6d, 0x09, 0x89, 0xeb, 0x92, 0xce, 0x85, 0x4e, 0x21, 0x8f, 0x13, 0x08, 0xca, + 0x12, 0xec, 0x87, 0xf4, 0x20, 0x5a, 0xa0, 0x43, 0x56, 0xb7, 0x3c, 0x38, 0x8f, 0x97, 0x89, 0x3c, 0x6a, 0x62, 0x5e, + 0x4e, 0x4a, 0x2b, 0xd4, 0xab, 0xae, 0x97, 0x88, 0x1a, 0xb9, 0x97, 0x34, 0x2d, 0x19, 0xe8, 0xf0, 0xb4, 0xd4, 0xa8, + 0xd0, 0x5c, 0xf0, 0xea, 0x93, 0x14, 0x47, 0xcc, 0xd0, 0x2e, 0x12, 0x23, 0xba, 0x2c, 0xe8, 0x54, 0x42, 0x88, 0xb2, + 0x17, 0x65, 0x45, 0x00, 0x67, 0x5a, 0xf5, 0xc1, 0xe3, 0x75, 0x88, 0x84, 0x2d, 0x71, 0x07, 0xe5, 0x7d, 0x90, 0x7a, + 0x23, 0x93, 0x36, 0xb3, 0xaa, 0x7c, 0x3d, 0x19, 0x05, 0xf9, 0x62, 0xd3, 0x21, 0x98, 0x7b, 0xe1, 0x24, 0x60, 0xe7, + 0xde, 0xe8, 0x3b, 0xac, 0xf3, 0x7a, 0x14, 0xbc, 0x82, 0x0a, 0x99, 0x3a, 0x78, 0xbc, 0x26, 0xd2, 0x5d, 0x87, 0xb0, + 0x33, 0xda, 0x02, 0xd5, 0x7e, 0x78, 0xca, 0x25, 0x16, 0xd3, 0xd7, 0x08, 0x2c, 0x91, 0x5b, 0x8a, 0x63, 0x5b, 0x86, + 0x8c, 0xa7, 0xfc, 0x81, 0xbd, 0xa9, 0xf0, 0x53, 0x0b, 0x70, 0x45, 0xe2, 0x04, 0xcb, 0x3b, 0x53, 0x06, 0x96, 0xc8, + 0xea, 0xbb, 0xe8, 0x46, 0x40, 0xca, 0x27, 0x80, 0x42, 0x54, 0x9e, 0xbc, 0x1f, 0x1e, 0xcb, 0x6a, 0x21, 0x94, 0x9d, + 0x53, 0xbb, 0xf0, 0x2b, 0x53, 0x95, 0x22, 0x01, 0xd4, 0xf2, 0x56, 0x1d, 0x1c, 0xef, 0xcb, 0xb5, 0x07, 0xc3, 0xde, + 0xa9, 0x34, 0x38, 0x6c, 0x55, 0xdc, 0x9b, 0x2f, 0x8a, 0x87, 0xec, 0x52, 0x81, 0x5b, 0x72, 0x06, 0x25, 0x30, 0x47, + 0xe5, 0x4f, 0x36, 0xc8, 0x0f, 0xa4, 0x4c, 0x2c, 0x08, 0x14, 0xed, 0x1e, 0x81, 0x1f, 0x23, 0xbd, 0x97, 0x2f, 0x21, + 0x59, 0x66, 0x8a, 0xd6, 0x86, 0xfc, 0xdf, 0x62, 0x4a, 0x50, 0xd2, 0xcd, 0xc2, 0x24, 0x8a, 0x55, 0x18, 0x66, 0x35, + 0x6f, 0x92, 0x22, 0xe5, 0x6b, 0xc3, 0x01, 0xd7, 0x92, 0x55, 0x98, 0xb0, 0xfd, 0xea, 0xa7, 0xd2, 0xb8, 0x87, 0x7a, + 0xf1, 0x43, 0xe1, 0x83, 0xa9, 0x20, 0xad, 0x1c, 0xc0, 0xe6, 0x7c, 0x54, 0x17, 0x8f, 0x7d, 0xe3, 0x2f, 0x91, 0x31, + 0xf2, 0x8c, 0x2b, 0xcf, 0xf8, 0x31, 0xbc, 0xcc, 0x6a, 0x17, 0x2f, 0xcf, 0x25, 0x67, 0xb0, 0xbe, 0x06, 0x11, 0x98, + 0xca, 0x97, 0x0a, 0xdf, 0xe2, 0x36, 0x23, 0xe7, 0x5e, 0x3c, 0x63, 0x22, 0x85, 0x9b, 0x78, 0x2b, 0x64, 0x07, 0xba, + 0x34, 0x2d, 0x10, 0x9e, 0x6c, 0x8f, 0x9b, 0xd6, 0xf9, 0xd6, 0x38, 0x8d, 0x83, 0x3f, 0xb3, 0x3b, 0x60, 0xb3, 0x92, + 0x34, 0x5a, 0x82, 0xcc, 0xca, 0x9b, 0x71, 0x1d, 0x84, 0xa1, 0xb1, 0xdd, 0xba, 0xfb, 0xf4, 0x89, 0x49, 0x59, 0xc5, + 0xd2, 0x68, 0x36, 0x0b, 0x98, 0x26, 0x65, 0x1f, 0xcb, 0xbb, 0x39, 0xd9, 0xb3, 0x45, 0xe4, 0x6a, 0x3d, 0x6b, 0x3a, + 0x58, 0x62, 0xc4, 0x2c, 0xe7, 0x06, 0x01, 0x71, 0x91, 0x71, 0x15, 0x32, 0xe4, 0x9a, 0x38, 0x17, 0xc5, 0xc1, 0x35, + 0x27, 0xd1, 0x6a, 0x14, 0x30, 0x13, 0x4f, 0x03, 0x74, 0xb9, 0x1e, 0xad, 0x46, 0xa3, 0x80, 0xd2, 0x85, 0x41, 0xfc, + 0xb5, 0x28, 0x41, 0xb9, 0x68, 0xa6, 0xf7, 0x61, 0x50, 0x56, 0x5a, 0x05, 0x1f, 0x6c, 0x26, 0xe1, 0xe6, 0x40, 0x1d, + 0xa4, 0x20, 0x03, 0xdd, 0x3c, 0xd3, 0xae, 0x0a, 0x37, 0x16, 0x96, 0xa8, 0xfd, 0x1a, 0x96, 0xce, 0xbd, 0x50, 0xdf, + 0xe3, 0x0c, 0x2b, 0x5e, 0x38, 0x51, 0x5e, 0xd1, 0xde, 0x55, 0x0d, 0x95, 0x4c, 0xbf, 0x78, 0x76, 0x39, 0xd5, 0x50, + 0x5f, 0xfb, 0xde, 0x2c, 0x8c, 0x92, 0xd4, 0x1f, 0xab, 0x97, 0xfd, 0xd7, 0xbe, 0x76, 0xb1, 0x48, 0x35, 0xfd, 0xd2, + 0xf8, 0x56, 0xce, 0x03, 0x26, 0x30, 0x25, 0xa6, 0x01, 0x6b, 0xa8, 0x23, 0x9f, 0x9e, 0x6d, 0xf5, 0x04, 0x46, 0xc6, + 0x3a, 0xdf, 0xba, 0x50, 0xab, 0x92, 0x51, 0x0c, 0x53, 0x45, 0x42, 0x46, 0xb1, 0x6f, 0xf5, 0x3e, 0x09, 0x61, 0xbe, + 0x59, 0xad, 0x91, 0x69, 0x48, 0x0b, 0xe2, 0x8b, 0x41, 0xf0, 0x85, 0xe7, 0x28, 0x3d, 0xef, 0xc9, 0x5e, 0x0f, 0x25, + 0x32, 0x3e, 0xfc, 0xa6, 0xcc, 0x81, 0x3c, 0x5e, 0xa7, 0x19, 0x98, 0x1c, 0x86, 0x51, 0xaa, 0x40, 0x64, 0x37, 0xe8, + 0x70, 0x58, 0xb5, 0x92, 0xe6, 0xad, 0x6a, 0x7a, 0xc6, 0xb1, 0xc0, 0x4b, 0xa4, 0xa5, 0x28, 0xb9, 0x84, 0x40, 0x14, + 0x10, 0xa4, 0xb4, 0x14, 0xc7, 0x89, 0xfb, 0xe6, 0xc1, 0xf2, 0x95, 0xf8, 0x37, 0x09, 0xef, 0x97, 0xe9, 0xf9, 0xe3, + 0x75, 0x72, 0x22, 0x88, 0xfa, 0xf7, 0x09, 0xae, 0x25, 0xb0, 0x2b, 0x9c, 0xca, 0x67, 0xaa, 0x72, 0x22, 0x28, 0x11, + 0xd6, 0x2d, 0xa1, 0x57, 0x4d, 0xb0, 0xbb, 0xb1, 0x88, 0x99, 0xcf, 0xc5, 0x28, 0x82, 0x01, 0xab, 0x1c, 0x3d, 0x08, + 0xd6, 0x94, 0xf3, 0x56, 0x29, 0x58, 0x5c, 0x23, 0xc1, 0x00, 0xcc, 0xc5, 0x79, 0x84, 0x61, 0x76, 0x05, 0x8c, 0x24, + 0x44, 0x30, 0x13, 0x63, 0x34, 0x22, 0x39, 0x89, 0x9c, 0x1f, 0x2e, 0x57, 0x29, 0x46, 0xa6, 0x07, 0x00, 0x58, 0xa6, + 0x2a, 0x78, 0x61, 0x04, 0x5c, 0x5f, 0x5c, 0x78, 0x32, 0x55, 0xf1, 0x27, 0x9b, 0x65, 0x5c, 0x3a, 0x03, 0x38, 0x0e, + 0x87, 0x81, 0x7a, 0x1d, 0x78, 0x8c, 0xf9, 0x30, 0xc6, 0x46, 0x91, 0xd6, 0x45, 0x1b, 0xa3, 0xfd, 0x43, 0x0d, 0x02, + 0x19, 0x53, 0x3b, 0x7d, 0x2d, 0xa8, 0x1d, 0x2c, 0x44, 0xab, 0x2e, 0x0d, 0x73, 0x08, 0x32, 0xca, 0x13, 0x98, 0x3b, + 0x17, 0x2e, 0xf5, 0xc2, 0xb4, 0x4e, 0x3d, 0x57, 0xc9, 0xae, 0x6e, 0x88, 0xd3, 0x30, 0xcc, 0xae, 0x0a, 0x47, 0xd7, + 0x62, 0xbc, 0xb0, 0x25, 0xa9, 0x5c, 0x41, 0x4b, 0x37, 0x97, 0xdb, 0xb3, 0x2d, 0x63, 0x7f, 0xe1, 0xc5, 0x77, 0x64, + 0xfe, 0x66, 0xc8, 0x36, 0x72, 0xba, 0xaa, 0x10, 0x3d, 0xa0, 0x09, 0x20, 0xd2, 0xa0, 0x2a, 0x5f, 0xe7, 0x65, 0x8c, + 0x8f, 0x36, 0xb7, 0x01, 0x82, 0xbe, 0xae, 0xd4, 0xe7, 0xcc, 0x22, 0xf9, 0x23, 0x7d, 0xd2, 0xd7, 0x92, 0x86, 0xe1, + 0x25, 0xe5, 0xe1, 0x85, 0xe5, 0x8d, 0x86, 0x83, 0x21, 0x4a, 0x41, 0x70, 0xe3, 0xc8, 0x30, 0x09, 0x66, 0xfd, 0x8a, + 0xd2, 0xbb, 0x3f, 0x74, 0x39, 0x18, 0x2c, 0x47, 0x08, 0xcb, 0x51, 0x23, 0x9a, 0xf5, 0xc4, 0x8a, 0x00, 0x2f, 0x02, + 0x5c, 0x48, 0x8c, 0x1c, 0x08, 0xe5, 0xc7, 0x54, 0xf2, 0x2d, 0x14, 0xc3, 0xd1, 0x20, 0xd8, 0xe9, 0x68, 0xc4, 0xae, + 0x1b, 0xe1, 0x57, 0x71, 0x76, 0xbc, 0x4f, 0xb5, 0x89, 0x28, 0x52, 0x25, 0x98, 0x86, 0x18, 0x46, 0x58, 0xcc, 0x02, + 0x24, 0x08, 0x77, 0x9d, 0xe2, 0xa2, 0x63, 0x2d, 0x50, 0x2d, 0xed, 0x9c, 0x94, 0x19, 0x1e, 0xfc, 0x4a, 0x1d, 0x1c, + 0x63, 0xca, 0x4f, 0x20, 0xeb, 0x10, 0x14, 0xeb, 0x78, 0x9f, 0x1e, 0x95, 0xca, 0x89, 0x28, 0x1a, 0x11, 0x32, 0xc8, + 0x1e, 0x6f, 0xe0, 0x41, 0x47, 0x25, 0x49, 0xd9, 0x12, 0x4a, 0xbd, 0x4c, 0x55, 0x16, 0x9c, 0xc1, 0xe2, 0xd1, 0xf7, + 0x20, 0x34, 0x8f, 0x0d, 0x2e, 0x11, 0xaa, 0xb2, 0xf0, 0x6e, 0x71, 0xe4, 0xe2, 0x8d, 0x77, 0xab, 0x39, 0xfc, 0x55, + 0x71, 0xd6, 0x92, 0xf2, 0x59, 0x1b, 0x6f, 0xdc, 0x90, 0x03, 0xb8, 0x21, 0x8f, 0xeb, 0x17, 0x77, 0x2e, 0x16, 0x77, + 0xd2, 0xb0, 0xb8, 0x93, 0x2d, 0x8b, 0x1b, 0xf0, 0x85, 0x54, 0xf2, 0xa9, 0x8b, 0xd1, 0x97, 0x3a, 0x9f, 0x3c, 0xce, + 0x8f, 0xf4, 0xf8, 0x39, 0xc3, 0x79, 0x32, 0x93, 0x00, 0x6c, 0x89, 0x1b, 0xe6, 0xaa, 0x6e, 0x5e, 0xa4, 0x89, 0xd8, + 0x1c, 0x78, 0x7e, 0xea, 0xc4, 0xb8, 0x21, 0x85, 0xb7, 0x16, 0x54, 0xc7, 0x0b, 0xbb, 0x14, 0x3f, 0x34, 0xb4, 0x79, + 0xc3, 0x48, 0xe7, 0x5b, 0x46, 0x3a, 0x2e, 0x1d, 0x5d, 0x3e, 0x6c, 0x3a, 0x84, 0xf2, 0xa0, 0x60, 0x0f, 0x82, 0x7f, + 0x05, 0x6e, 0x99, 0xf2, 0x3e, 0x6c, 0xc6, 0xb1, 0xd2, 0x8e, 0x5a, 0x7a, 0x49, 0x72, 0x13, 0xc5, 0x60, 0xa0, 0x00, + 0xcd, 0x3c, 0x6c, 0x4b, 0x2d, 0xfc, 0x90, 0xc7, 0x3e, 0x6b, 0xdc, 0x4c, 0xc5, 0x7b, 0x79, 0x4b, 0xb5, 0x3a, 0x1d, + 0xaa, 0xb1, 0xf4, 0xd2, 0x94, 0xc5, 0x38, 0xe9, 0x1e, 0x24, 0xc9, 0xf8, 0x0f, 0xd9, 0x66, 0x35, 0x38, 0x24, 0x90, + 0xb0, 0x3a, 0x62, 0xe8, 0x25, 0xb0, 0x60, 0xa4, 0x91, 0x0c, 0xf5, 0xb5, 0x14, 0x47, 0x35, 0xce, 0x27, 0xfe, 0x27, + 0x3c, 0xae, 0x5a, 0x2c, 0x79, 0xfa, 0x3a, 0x87, 0xba, 0xb5, 0xf4, 0x26, 0xef, 0xc1, 0x0e, 0x46, 0x6b, 0x19, 0xe0, + 0xd3, 0x22, 0x47, 0x4d, 0x8d, 0x89, 0x27, 0x1c, 0x17, 0x48, 0x12, 0xb1, 0x24, 0xb7, 0x18, 0x86, 0x60, 0x03, 0x9e, + 0x39, 0xbd, 0x5c, 0xb7, 0xb2, 0xfd, 0x99, 0xaf, 0x6f, 0x60, 0x4d, 0x40, 0x6d, 0x81, 0x3b, 0xc8, 0x85, 0x6e, 0x81, + 0xe1, 0x1c, 0xea, 0xa0, 0x28, 0xbd, 0x80, 0x74, 0xe8, 0xb6, 0xb8, 0x4c, 0x0f, 0x63, 0xa0, 0x5a, 0xa0, 0x56, 0x7c, + 0x32, 0xc3, 0x5f, 0xce, 0x65, 0xf6, 0x64, 0x84, 0xbf, 0x5a, 0x97, 0xb9, 0x12, 0xab, 0x22, 0x45, 0x90, 0xc6, 0xac, + 0x0e, 0x4a, 0xfb, 0x89, 0xcc, 0xb5, 0x1f, 0xb0, 0x6d, 0xf8, 0x02, 0x3f, 0x7a, 0xbc, 0x4e, 0x20, 0x40, 0x81, 0x3c, + 0x86, 0xd0, 0x8a, 0xf5, 0xac, 0xb6, 0x7c, 0xd6, 0x50, 0x3e, 0xd2, 0xff, 0x60, 0xc2, 0x8f, 0xbb, 0x24, 0x2a, 0x68, + 0x4a, 0x59, 0x06, 0x72, 0x35, 0xf2, 0x43, 0x2f, 0xbe, 0xbb, 0xa2, 0x5b, 0x88, 0x26, 0x58, 0xfc, 0x5c, 0xb6, 0x43, + 0xbc, 0x68, 0xd9, 0x3a, 0x24, 0x95, 0x14, 0x55, 0x77, 0x9c, 0xd0, 0xbb, 0x7f, 0x8e, 0x25, 0xfe, 0xae, 0x74, 0x8d, + 0xe5, 0x0b, 0x52, 0xea, 0xe8, 0xea, 0xf1, 0x5a, 0x63, 0x9b, 0xcd, 0x54, 0x46, 0x5b, 0x61, 0x20, 0x61, 0x79, 0xf0, + 0x4a, 0xbc, 0x98, 0xf8, 0x3d, 0x34, 0xff, 0x18, 0x45, 0xb7, 0xe6, 0xe3, 0x75, 0x7a, 0xa2, 0x2e, 0xbc, 0xf8, 0x23, + 0x9b, 0x98, 0x63, 0x3f, 0x1e, 0x07, 0xc0, 0x3c, 0x8e, 0x02, 0x2f, 0xfc, 0xc8, 0x1f, 0xcd, 0x68, 0x95, 0xa2, 0x41, + 0xd7, 0xbd, 0x37, 0x68, 0x31, 0x27, 0x24, 0x48, 0x44, 0xae, 0xb6, 0x66, 0x16, 0x94, 0xf7, 0x43, 0x71, 0xad, 0x2f, + 0x18, 0xc5, 0xa2, 0x96, 0x01, 0xfe, 0x08, 0x60, 0x63, 0x06, 0x01, 0x1e, 0x0c, 0x15, 0xd7, 0x4b, 0x35, 0xe4, 0xa1, + 0x92, 0x56, 0x2d, 0xcf, 0x50, 0x7c, 0x85, 0x2d, 0xfc, 0xf6, 0xee, 0xa0, 0xe4, 0x21, 0xdd, 0xe5, 0xad, 0x7c, 0xde, + 0x08, 0xa1, 0xd4, 0x24, 0xc7, 0xc2, 0x07, 0x74, 0xce, 0x19, 0xcc, 0xe6, 0xae, 0xe5, 0x8f, 0xbd, 0x24, 0x59, 0x2d, + 0xd8, 0x84, 0x54, 0x62, 0x27, 0x05, 0x50, 0xe5, 0x7b, 0x88, 0x0c, 0xd8, 0xdf, 0x56, 0xad, 0xa3, 0x83, 0x57, 0x60, + 0xe0, 0x07, 0x0c, 0x65, 0x34, 0x9d, 0xaa, 0x85, 0x28, 0xe0, 0x9e, 0xcf, 0x9c, 0x83, 0xbf, 0xad, 0xde, 0x9c, 0xda, + 0x6f, 0xf2, 0x8f, 0x43, 0x60, 0x8c, 0x85, 0xb5, 0x12, 0xe7, 0x8b, 0x25, 0x78, 0xc5, 0x88, 0xa6, 0x5e, 0xd8, 0x3c, + 0x9c, 0x8b, 0xd2, 0x16, 0x5f, 0x32, 0x36, 0x01, 0x86, 0xdb, 0xd8, 0x28, 0xbd, 0x0a, 0xd8, 0x35, 0xcb, 0x2d, 0xa1, + 0x36, 0x3b, 0xab, 0xf9, 0x02, 0x43, 0xb5, 0x72, 0xdd, 0x23, 0xe7, 0xea, 0xa4, 0x21, 0x0d, 0x71, 0x0c, 0x7c, 0xe4, + 0xf2, 0x11, 0xab, 0x1c, 0xa9, 0xa1, 0xa1, 0x4a, 0x00, 0x34, 0x42, 0x76, 0xd2, 0x50, 0xde, 0x03, 0x44, 0xdd, 0x00, + 0x9b, 0xe1, 0xe8, 0x3d, 0x48, 0x6d, 0xc1, 0xe7, 0x29, 0x80, 0x93, 0xa7, 0x15, 0x52, 0x93, 0xa6, 0x19, 0xab, 0x13, + 0xb5, 0xa9, 0x24, 0xa4, 0x11, 0xce, 0x01, 0xe8, 0x25, 0x23, 0xc4, 0x55, 0xb5, 0x6b, 0xa3, 0x94, 0x47, 0x3e, 0xc2, + 0xc4, 0xef, 0x21, 0x4b, 0x92, 0xc6, 0x09, 0xcb, 0x17, 0xdd, 0x50, 0x8b, 0xda, 0xe5, 0xf9, 0x28, 0xca, 0x81, 0x0e, + 0x1a, 0x6a, 0xab, 0xd3, 0x51, 0x69, 0x90, 0xd5, 0xfe, 0x90, 0xc4, 0x5c, 0xa5, 0x6c, 0xb1, 0xdc, 0xa5, 0xbf, 0xa2, + 0x76, 0xb9, 0xbf, 0xa2, 0xdc, 0x50, 0x9d, 0xce, 0x81, 0x6a, 0xa8, 0xed, 0x23, 0x7b, 0x6b, 0x8f, 0x0b, 0x6e, 0x54, + 0x1a, 0xcf, 0x46, 0x2a, 0x37, 0xf8, 0x6b, 0x7a, 0x7f, 0xa3, 0x72, 0xd0, 0x4a, 0xcc, 0x41, 0x2d, 0x80, 0x5a, 0x09, + 0xe1, 0x6f, 0xc8, 0xb2, 0xb0, 0x01, 0x01, 0x53, 0x05, 0xab, 0xb3, 0xe9, 0x94, 0x8d, 0xd3, 0x44, 0x17, 0x92, 0xad, + 0x3c, 0xc4, 0x3b, 0xb8, 0xf6, 0xee, 0xb9, 0xea, 0x4f, 0x10, 0xe8, 0x46, 0x44, 0x42, 0xe4, 0x00, 0x89, 0x9b, 0x5a, + 0xfd, 0x64, 0x51, 0x8b, 0xe5, 0x89, 0xe2, 0xbd, 0x80, 0xec, 0xbb, 0xa6, 0x1c, 0x41, 0xe3, 0x54, 0xaf, 0xd8, 0x8d, + 0x51, 0x6e, 0x7a, 0xba, 0x1d, 0x01, 0x6e, 0x43, 0x1a, 0x6b, 0xe7, 0x4d, 0xc7, 0xb1, 0x33, 0xd5, 0x00, 0x07, 0xeb, + 0x8f, 0x95, 0xc3, 0x43, 0x64, 0xd1, 0x55, 0xcf, 0xde, 0xbe, 0xfa, 0xf3, 0xe9, 0xeb, 0x5d, 0xf1, 0x10, 0x36, 0xd9, + 0x86, 0x26, 0x57, 0xe1, 0x96, 0x46, 0x7f, 0xf9, 0xe9, 0x61, 0xcd, 0xb6, 0x9c, 0x17, 0x8e, 0x6a, 0x90, 0x4d, 0xbc, + 0x84, 0x8d, 0xc7, 0xd1, 0x35, 0x8b, 0x3f, 0x7b, 0x1a, 0xe4, 0xc6, 0xeb, 0xc1, 0x7d, 0xfb, 0xf3, 0xe9, 0x4f, 0x3b, + 0x83, 0x7a, 0xe8, 0xc0, 0xe1, 0x02, 0xb1, 0xe7, 0x03, 0x46, 0xd7, 0x86, 0x73, 0x14, 0x44, 0x09, 0x6b, 0x80, 0xe0, + 0xd5, 0xd9, 0xdb, 0xf7, 0x38, 0x5d, 0x05, 0xe3, 0x43, 0x4d, 0x7d, 0xde, 0xe0, 0x7f, 0x7e, 0x77, 0xfa, 0xfe, 0xbd, + 0x6a, 0x60, 0x8a, 0xf0, 0x44, 0x6e, 0x9d, 0x6f, 0xe2, 0x7b, 0xe8, 0x5c, 0xed, 0x5e, 0x27, 0x5a, 0x4a, 0xd7, 0xf7, + 0xf2, 0x68, 0xa8, 0x6c, 0x63, 0x9b, 0x73, 0x1a, 0xcb, 0x7b, 0xa6, 0x3b, 0xf7, 0x4e, 0xe3, 0xaa, 0xc1, 0x4a, 0xdb, + 0x09, 0x79, 0xa9, 0x64, 0xe1, 0x87, 0x57, 0x35, 0xa5, 0xde, 0x6d, 0x4d, 0x29, 0x5c, 0x5a, 0x37, 0xb0, 0xf2, 0x2a, + 0x5a, 0x48, 0x4c, 0x10, 0xbb, 0xbd, 0x7f, 0xba, 0xa4, 0x9b, 0xe3, 0x67, 0x00, 0xcd, 0x53, 0xbc, 0x54, 0xa1, 0xae, + 0x29, 0xe6, 0xd7, 0xbd, 0x7c, 0x6e, 0xc7, 0x01, 0x78, 0x02, 0x30, 0x59, 0xf9, 0x59, 0x66, 0x90, 0xb9, 0x1f, 0x8f, + 0x5b, 0xb9, 0x8b, 0xd0, 0x67, 0xa4, 0x30, 0xe2, 0x94, 0x6c, 0xe9, 0x4d, 0xc0, 0xbc, 0xde, 0x1c, 0x45, 0x69, 0x1a, + 0x2d, 0x7a, 0x8e, 0xbd, 0xbc, 0x55, 0x95, 0xbe, 0x10, 0xb1, 0x70, 0xeb, 0xff, 0xde, 0xbf, 0xfe, 0x59, 0x41, 0xf3, + 0x54, 0x8e, 0x44, 0x81, 0xc5, 0x5e, 0xba, 0x8a, 0x59, 0xa6, 0xfc, 0xeb, 0x7f, 0x5e, 0x55, 0xc4, 0x09, 0x7d, 0xf9, + 0x1b, 0xba, 0x48, 0xc8, 0x9f, 0x5c, 0x05, 0xd1, 0xcd, 0x5e, 0xe1, 0xe7, 0x77, 0x4f, 0xe5, 0xb9, 0x3f, 0x9b, 0xe7, + 0xb5, 0x4f, 0xd2, 0x2d, 0x63, 0x13, 0xd0, 0x93, 0x16, 0x42, 0x39, 0x8b, 0x6e, 0x7a, 0xff, 0xfa, 0x67, 0x2e, 0x26, + 0xba, 0x77, 0xd7, 0xd5, 0x03, 0x5a, 0x5e, 0xd1, 0xfa, 0x3a, 0x1b, 0x4b, 0x8c, 0x44, 0xb3, 0xba, 0xc0, 0x1b, 0x85, + 0xb4, 0x2b, 0x37, 0x35, 0x82, 0x5b, 0xc6, 0xf4, 0x9d, 0x3f, 0x9b, 0x7f, 0xee, 0xa0, 0x60, 0x42, 0xef, 0x1d, 0x15, + 0x54, 0xfa, 0x02, 0xc3, 0x1a, 0xf6, 0x76, 0x5f, 0xb0, 0xcf, 0x1c, 0xd7, 0x7d, 0x43, 0xfa, 0x12, 0xa3, 0xe1, 0xf2, + 0xe2, 0xf7, 0xc3, 0x61, 0x9e, 0x22, 0x57, 0xfe, 0x1e, 0x3c, 0x15, 0x4f, 0x36, 0x4a, 0x38, 0x7b, 0xd1, 0xb3, 0x75, + 0x0a, 0x21, 0xb4, 0xc3, 0x84, 0xa0, 0xcd, 0x7d, 0xcd, 0x74, 0x34, 0xe3, 0x6b, 0x72, 0x9d, 0xdb, 0xe8, 0x7b, 0x03, + 0x59, 0x43, 0x29, 0xa6, 0x57, 0xcd, 0x75, 0x95, 0x46, 0x3d, 0x38, 0x37, 0xb1, 0xb7, 0x24, 0xd5, 0x84, 0x82, 0x7a, + 0x1a, 0x10, 0xf5, 0x54, 0xee, 0xee, 0xd7, 0x5e, 0x70, 0xbd, 0xdb, 0x35, 0xae, 0x99, 0x82, 0x21, 0x69, 0xfe, 0xf7, + 0x11, 0x6f, 0xa4, 0xcb, 0x0f, 0xa6, 0xdd, 0x37, 0x5e, 0xca, 0xe2, 0xab, 0x39, 0xf8, 0x18, 0x0b, 0x99, 0x05, 0x44, + 0xef, 0xdd, 0x86, 0x94, 0x4b, 0x6c, 0x69, 0x0d, 0x1a, 0x2d, 0x30, 0xdc, 0x6f, 0xc3, 0xdd, 0x5f, 0x08, 0x73, 0xf7, + 0x4e, 0xc1, 0x0b, 0xf4, 0x77, 0xc3, 0xde, 0xdb, 0x28, 0xd3, 0xff, 0x63, 0xef, 0xff, 0x44, 0xec, 0xbd, 0xb5, 0x9f, + 0xdf, 0xb2, 0xb0, 0xff, 0x07, 0xb0, 0x7c, 0x8f, 0xb9, 0xa7, 0x1c, 0xd3, 0x6b, 0x9a, 0xe7, 0x6a, 0x71, 0xe9, 0xf0, + 0x22, 0x5e, 0xdd, 0x50, 0xeb, 0xf2, 0x10, 0x6f, 0xdc, 0x5e, 0xd1, 0x43, 0x64, 0xbf, 0xe5, 0x28, 0xff, 0xfe, 0x88, + 0x3e, 0xa1, 0xbc, 0x58, 0x12, 0xa6, 0xef, 0x9d, 0x1a, 0x49, 0x69, 0x24, 0xde, 0x8d, 0x77, 0xb7, 0x0b, 0xde, 0x11, + 0xc0, 0x7e, 0x73, 0xe3, 0xdd, 0xd5, 0x01, 0xdb, 0x88, 0x5e, 0xab, 0x9d, 0x9d, 0x80, 0x6f, 0x51, 0x0f, 0x1d, 0x8b, + 0x8c, 0x61, 0xc2, 0xd2, 0x13, 0x28, 0x74, 0x1f, 0xaf, 0xf7, 0xaa, 0x15, 0xb3, 0x21, 0x78, 0x5d, 0x4b, 0x80, 0x47, + 0x25, 0xc0, 0xfd, 0xe4, 0x2a, 0x0a, 0x1f, 0x02, 0xf9, 0xcf, 0x20, 0x72, 0xfa, 0xcd, 0xa0, 0x63, 0x77, 0x1b, 0xb0, + 0x63, 0x69, 0x15, 0x78, 0x2c, 0xac, 0x42, 0xdf, 0xaf, 0xd7, 0x10, 0x54, 0x08, 0x2d, 0xd2, 0x58, 0x46, 0x84, 0x56, + 0x01, 0x6d, 0x8e, 0x02, 0x9a, 0xb5, 0x0a, 0xc9, 0xf5, 0xc3, 0x69, 0xec, 0xc5, 0x6c, 0xd2, 0x7c, 0x05, 0x28, 0xd9, + 0x44, 0xdf, 0x59, 0xc9, 0x6a, 0xb9, 0x8c, 0xe2, 0x34, 0xb9, 0xc2, 0xe8, 0x30, 0x0b, 0x1f, 0x2e, 0x14, 0x90, 0xc7, + 0x2c, 0x8f, 0x15, 0x7c, 0x5a, 0x27, 0x55, 0x37, 0x98, 0x5b, 0x4e, 0xf1, 0xc1, 0x7d, 0x7e, 0x0c, 0xee, 0x35, 0x34, + 0x97, 0xb4, 0x26, 0x73, 0x2b, 0x8d, 0xfd, 0x85, 0xa6, 0x1b, 0x8e, 0xad, 0xeb, 0x42, 0xbe, 0x32, 0x77, 0x07, 0x7b, + 0x14, 0xe3, 0x78, 0xae, 0x43, 0xac, 0x44, 0xf4, 0xa3, 0x01, 0x0b, 0xbd, 0x97, 0xab, 0xe9, 0x94, 0xc5, 0x9a, 0x08, + 0x06, 0x09, 0xd1, 0x68, 0xc9, 0x04, 0x11, 0xbc, 0x2b, 0x3f, 0xf8, 0xec, 0x06, 0xb2, 0x4e, 0x15, 0xc1, 0xdc, 0xc1, + 0xc3, 0x94, 0x8c, 0xd8, 0x21, 0xa3, 0x5d, 0xda, 0x6e, 0x69, 0x93, 0x67, 0x07, 0xc6, 0x1c, 0x42, 0x40, 0x15, 0x4e, + 0xf9, 0x18, 0x5d, 0xd0, 0x0f, 0xd3, 0x2e, 0xf6, 0x00, 0x0d, 0xc0, 0xe1, 0x0d, 0xdc, 0xdc, 0x1b, 0x4b, 0x19, 0xe7, + 0x0d, 0xce, 0xdd, 0x41, 0xf0, 0xdc, 0x25, 0xed, 0x12, 0x5a, 0x0b, 0xbe, 0x9a, 0x7b, 0xf1, 0xab, 0x68, 0xc2, 0x10, + 0xd0, 0x51, 0x1a, 0x81, 0x8f, 0xa8, 0x14, 0xfc, 0x07, 0x63, 0xff, 0x98, 0xa5, 0x78, 0x40, 0xfb, 0x50, 0x74, 0x25, + 0x17, 0xb9, 0xcf, 0x1f, 0xef, 0x1b, 0x70, 0xd2, 0xea, 0x57, 0x5a, 0x2c, 0x1a, 0x5f, 0xea, 0xda, 0x57, 0xf2, 0x6e, + 0x7d, 0xe5, 0xc5, 0xb1, 0xcf, 0x62, 0x45, 0xfb, 0xee, 0x57, 0x5d, 0xde, 0xb4, 0x25, 0x35, 0x12, 0xd7, 0x6d, 0x2b, + 0x18, 0x03, 0x6f, 0xea, 0xb3, 0x60, 0xe2, 0xaa, 0x63, 0xfa, 0x30, 0x57, 0x19, 0xb5, 0xbb, 0xb6, 0x6d, 0x73, 0x35, + 0xad, 0x43, 0x3f, 0x41, 0x4d, 0x0b, 0x3f, 0xe1, 0xa1, 0x24, 0xd4, 0xec, 0x12, 0x17, 0xb1, 0x41, 0xce, 0x6a, 0x21, + 0x7c, 0x47, 0x51, 0x84, 0x1e, 0x02, 0x1b, 0x8f, 0x36, 0x24, 0x40, 0x73, 0x04, 0x58, 0x05, 0x4c, 0x15, 0x80, 0x3a, + 0x0f, 0x01, 0xe8, 0xdc, 0x5f, 0xf8, 0xe1, 0x2c, 0x69, 0x84, 0x08, 0x95, 0xb5, 0x25, 0x78, 0x52, 0xfa, 0x42, 0x55, + 0x70, 0x0d, 0xe7, 0x51, 0x00, 0xd9, 0x8f, 0x54, 0x66, 0xcd, 0x2c, 0xe5, 0x85, 0x6d, 0xdb, 0x86, 0x79, 0x00, 0x79, + 0x06, 0x3b, 0x87, 0xb6, 0x61, 0xc2, 0x5f, 0x96, 0x65, 0xd5, 0x48, 0x81, 0xfb, 0x0b, 0x3f, 0x34, 0xe9, 0xb1, 0x65, + 0xef, 0x06, 0xef, 0xbd, 0xb6, 0xc4, 0x09, 0xd7, 0xc8, 0x8d, 0x72, 0x87, 0x55, 0x6d, 0xe4, 0x26, 0x65, 0x0b, 0x3b, + 0x8b, 0xc2, 0x3c, 0xf1, 0x28, 0x1c, 0x15, 0x62, 0x34, 0x2a, 0xbf, 0x45, 0xb6, 0x34, 0xae, 0x66, 0xcf, 0x50, 0xbf, + 0xe7, 0x60, 0xf5, 0x94, 0x57, 0xd1, 0x2a, 0x98, 0xa0, 0x11, 0x16, 0x58, 0x4c, 0x2b, 0x85, 0x2d, 0x6a, 0x25, 0xc5, + 0x15, 0x64, 0x30, 0xc7, 0xf4, 0x6e, 0xef, 0x91, 0x38, 0x45, 0xb1, 0xf6, 0x14, 0xa7, 0xf8, 0xa2, 0x6e, 0x0b, 0x5e, + 0x3e, 0x85, 0x28, 0x46, 0x3b, 0x7c, 0xc0, 0xf7, 0x05, 0xd4, 0x0f, 0x76, 0xa9, 0x2f, 0xd6, 0xed, 0xf2, 0x29, 0x85, + 0xba, 0xf5, 0x3e, 0x7d, 0xda, 0x1b, 0x7f, 0xfa, 0xb4, 0xb7, 0x91, 0x1f, 0xa4, 0x79, 0x84, 0xb4, 0x31, 0x18, 0x0f, + 0x6c, 0x02, 0xd1, 0x8a, 0x08, 0xe8, 0xef, 0xa1, 0xbc, 0xe7, 0xf1, 0x18, 0x59, 0xf4, 0x34, 0x36, 0x78, 0x87, 0xf4, + 0x18, 0x64, 0x95, 0x49, 0x99, 0xbb, 0x1e, 0x89, 0x79, 0x3e, 0x7d, 0xe2, 0xc7, 0xcd, 0x98, 0xb8, 0xe3, 0xbc, 0xc8, + 0x51, 0x8d, 0x95, 0x1b, 0xe4, 0x8f, 0x2a, 0x82, 0xbc, 0xe2, 0x18, 0xb3, 0x80, 0xf8, 0xc6, 0x8b, 0x43, 0x19, 0xe0, + 0x9f, 0x22, 0x85, 0x77, 0xab, 0xf0, 0x38, 0xac, 0x93, 0xea, 0x6a, 0x4c, 0x5d, 0xa6, 0xad, 0x08, 0x07, 0x0a, 0xfb, + 0x3a, 0xa9, 0x81, 0x73, 0x81, 0xed, 0x31, 0x19, 0xab, 0x18, 0x20, 0x7a, 0x75, 0xe3, 0xc9, 0x9d, 0x88, 0x61, 0xbd, + 0xf3, 0x6e, 0x7a, 0x2b, 0xf1, 0x70, 0x4a, 0x86, 0xf8, 0xbd, 0x69, 0xee, 0x2d, 0xbd, 0x24, 0x5f, 0xc7, 0x99, 0xfb, + 0x6d, 0xac, 0x2d, 0x8d, 0xd4, 0x50, 0x05, 0x19, 0x51, 0x75, 0x63, 0x51, 0x17, 0xd6, 0xb5, 0xbf, 0xe0, 0x41, 0x6e, + 0x34, 0xb1, 0x15, 0xae, 0xa6, 0xe8, 0x21, 0x11, 0x8e, 0xef, 0x30, 0x6c, 0x73, 0xf1, 0x9e, 0x40, 0xb9, 0xe2, 0x39, + 0xff, 0x26, 0xf2, 0x2b, 0x58, 0x70, 0xd5, 0x98, 0xea, 0x06, 0x79, 0x1e, 0xcc, 0xbe, 0xa4, 0x93, 0x01, 0x45, 0x72, + 0x5e, 0x48, 0x41, 0x66, 0x85, 0xdb, 0xc1, 0x55, 0xc5, 0xed, 0xa0, 0x66, 0x3e, 0x95, 0x98, 0x25, 0xcb, 0x28, 0x84, + 0xbb, 0xe2, 0x55, 0xe1, 0x57, 0x76, 0xb5, 0xe9, 0x57, 0x56, 0xf3, 0x29, 0xbe, 0xa1, 0xef, 0x40, 0x11, 0x7e, 0xfe, + 0x5f, 0x15, 0xbf, 0x00, 0x41, 0xea, 0x31, 0x37, 0xfa, 0x69, 0x93, 0x3f, 0xf9, 0xf7, 0xf7, 0xfb, 0x93, 0x9f, 0xed, + 0xe4, 0x4f, 0xfe, 0xfd, 0x17, 0xf7, 0x27, 0x3f, 0x95, 0xfd, 0xc9, 0x81, 0x04, 0x9f, 0xb2, 0x9d, 0xdc, 0x77, 0x85, + 0x23, 0x4d, 0x74, 0x93, 0xb8, 0x0e, 0xd7, 0xe7, 0x25, 0xe3, 0x39, 0x03, 0x03, 0x09, 0xce, 0xea, 0x06, 0xd1, 0x0c, + 0xbc, 0x6c, 0x9b, 0xfd, 0x68, 0xbf, 0x94, 0x17, 0x6d, 0x10, 0xcd, 0x54, 0x29, 0x3b, 0x5c, 0x28, 0xb2, 0xc3, 0x41, + 0x44, 0xbc, 0xbf, 0xdd, 0x3a, 0x2f, 0x2f, 0x9c, 0x7e, 0xdb, 0x81, 0xe8, 0xaa, 0xa0, 0xf3, 0xc6, 0x02, 0xbb, 0xdf, + 0x6e, 0x43, 0xc1, 0x8d, 0x54, 0xd0, 0x82, 0x02, 0x5f, 0x2a, 0xe8, 0x40, 0xc1, 0x58, 0x2a, 0x38, 0x84, 0x82, 0x89, + 0x54, 0x70, 0x04, 0x05, 0xd7, 0x6a, 0x76, 0x11, 0xe6, 0xde, 0xf2, 0x47, 0xfa, 0x65, 0x29, 0x31, 0x68, 0x6e, 0xa0, + 0x21, 0xaa, 0x1c, 0x19, 0x22, 0x4b, 0x85, 0x79, 0xa0, 0x73, 0x1e, 0x6d, 0xf8, 0xd5, 0x10, 0x30, 0x2f, 0xd8, 0xab, + 0x18, 0x60, 0xed, 0x43, 0x35, 0xdb, 0xe2, 0xb5, 0xda, 0xcb, 0xbd, 0xcb, 0x6d, 0xa3, 0x25, 0xbc, 0xb5, 0x7b, 0x18, + 0x3b, 0x44, 0x54, 0xee, 0x3c, 0x9f, 0xe7, 0x21, 0xab, 0x57, 0x6e, 0x11, 0x82, 0xa7, 0x0d, 0x89, 0x7b, 0x38, 0xaf, + 0xc6, 0x34, 0xb0, 0xd2, 0x81, 0x08, 0x2b, 0xe2, 0x14, 0x89, 0x0e, 0x14, 0x74, 0xc1, 0xef, 0x7b, 0x05, 0x0f, 0xc7, + 0x03, 0xbc, 0x13, 0xf4, 0x8b, 0x3c, 0x6e, 0x36, 0x69, 0x70, 0x57, 0x46, 0xea, 0xcd, 0x7a, 0x73, 0x83, 0xcc, 0xb7, + 0x7a, 0x33, 0x48, 0x84, 0x72, 0x32, 0xe9, 0x2d, 0x8d, 0x9b, 0x39, 0x0b, 0x7b, 0x53, 0xee, 0xec, 0x08, 0xeb, 0x4f, + 0xfe, 0x2b, 0x0b, 0x5d, 0x38, 0x5e, 0xe1, 0x9e, 0x28, 0xde, 0x12, 0x94, 0x66, 0xbe, 0x95, 0x0a, 0x9f, 0x21, 0x4d, + 0x36, 0xed, 0xfa, 0x12, 0x1e, 0x1e, 0xaf, 0xd9, 0x68, 0x35, 0x53, 0xce, 0xa2, 0xd9, 0xbd, 0xde, 0x1c, 0xf2, 0x2b, + 0x80, 0x52, 0x25, 0x1b, 0x56, 0x53, 0x6c, 0x6f, 0xde, 0x17, 0x3d, 0x66, 0xe5, 0xfa, 0x29, 0xc0, 0xa6, 0xa4, 0xc4, + 0x36, 0x40, 0x3f, 0x30, 0xdb, 0x92, 0xbf, 0xc4, 0x19, 0xcc, 0x9f, 0xf4, 0x7c, 0xee, 0x49, 0xf0, 0x0c, 0x7e, 0x64, + 0x49, 0xe2, 0xcd, 0x98, 0x8c, 0x5a, 0x4a, 0x8d, 0x03, 0x16, 0xcc, 0x95, 0xd8, 0x38, 0x81, 0xc0, 0xd8, 0xfb, 0x1b, + 0x5e, 0x30, 0xe0, 0xa8, 0x0b, 0xde, 0x61, 0xb0, 0x68, 0x85, 0xcb, 0x88, 0x6f, 0xc1, 0xf2, 0x94, 0xbd, 0x37, 0x00, + 0x89, 0x5c, 0xb3, 0xa0, 0x5a, 0x98, 0x7a, 0xb3, 0x6a, 0x11, 0xad, 0x75, 0x56, 0x42, 0x7b, 0x7a, 0xe9, 0x51, 0xe0, + 0xc2, 0xcf, 0xf0, 0x06, 0x08, 0xa2, 0xd9, 0xef, 0xea, 0x0a, 0xb0, 0xc5, 0x85, 0xe3, 0xc7, 0xd0, 0x08, 0xd3, 0xa1, + 0x85, 0x73, 0xac, 0x58, 0x30, 0x85, 0xbd, 0x30, 0x9d, 0x9b, 0x18, 0xce, 0x4e, 0x6b, 0x85, 0xba, 0x61, 0xe1, 0xda, + 0xae, 0xab, 0x41, 0x3c, 0x7b, 0xf1, 0x6c, 0xe4, 0x69, 0x4e, 0xeb, 0xc8, 0x10, 0x7f, 0x2c, 0xbb, 0xa3, 0x67, 0xd8, + 0x82, 0x32, 0xf1, 0xaf, 0xd7, 0xd3, 0x28, 0x4c, 0xcd, 0xa9, 0xb7, 0xf0, 0x83, 0xbb, 0xde, 0x22, 0x0a, 0xa3, 0x64, + 0xe9, 0x8d, 0x59, 0x5f, 0xe2, 0x47, 0x31, 0x3c, 0x34, 0x8f, 0x50, 0xe8, 0x58, 0xad, 0x98, 0x2d, 0xe8, 0xeb, 0x3c, + 0xfa, 0xf3, 0x34, 0x60, 0xb7, 0x19, 0xef, 0xbe, 0x54, 0x99, 0xaa, 0xe2, 0x96, 0xa3, 0x2f, 0x80, 0x65, 0xe6, 0xa1, + 0xa5, 0x21, 0xa1, 0x42, 0x9f, 0x4b, 0x1d, 0x7b, 0x56, 0xab, 0x13, 0xb3, 0x85, 0x62, 0x75, 0x1a, 0x1b, 0x8f, 0xa3, + 0x9b, 0x01, 0x40, 0x8b, 0x1f, 0x9b, 0x09, 0x0b, 0xa6, 0xf8, 0xc6, 0xc4, 0x68, 0x56, 0xa2, 0x1d, 0x13, 0xad, 0x19, + 0xa0, 0x35, 0xb6, 0xe8, 0xc3, 0xeb, 0x5e, 0x4b, 0xb1, 0x25, 0x7e, 0xfa, 0xc8, 0x5e, 0x4a, 0x6d, 0xc9, 0xf3, 0xa7, + 0xaf, 0xb1, 0xba, 0xa3, 0xd8, 0x7d, 0xd0, 0x1f, 0x4f, 0x83, 0xe8, 0xa6, 0x37, 0xf7, 0x27, 0x13, 0x16, 0xf6, 0x11, + 0xe6, 0xbc, 0x90, 0x05, 0x81, 0xbf, 0x4c, 0xfc, 0xa4, 0xbf, 0xf0, 0x6e, 0x79, 0xab, 0x07, 0x4d, 0xad, 0xb6, 0x79, + 0xab, 0xed, 0x9d, 0x5b, 0x95, 0x9a, 0x81, 0xc8, 0x59, 0xd4, 0x0e, 0x07, 0xad, 0xa3, 0xd8, 0x95, 0x71, 0xee, 0xdc, + 0xea, 0x32, 0x66, 0xeb, 0x85, 0x17, 0xcf, 0xfc, 0xb0, 0x67, 0x67, 0xd6, 0xf5, 0x9a, 0x36, 0xc6, 0xa3, 0x6e, 0xb7, + 0x9b, 0x59, 0x13, 0xf1, 0x64, 0x4f, 0x26, 0x99, 0x35, 0x16, 0x4f, 0xd3, 0xa9, 0x6d, 0x4f, 0xa7, 0x99, 0xe5, 0x8b, + 0x82, 0x76, 0x6b, 0x3c, 0x69, 0xb7, 0x32, 0xeb, 0x46, 0xaa, 0x91, 0x59, 0x8c, 0x3f, 0xc5, 0x6c, 0xd2, 0xc7, 0x8d, + 0xc4, 0xbd, 0xae, 0x8f, 0x6c, 0x3b, 0x43, 0x0c, 0x70, 0x51, 0xc2, 0x4d, 0x68, 0x30, 0x73, 0xb9, 0xde, 0xb9, 0xa6, + 0x52, 0x74, 0x37, 0x1e, 0xd7, 0xd6, 0x9b, 0x78, 0xf1, 0xc7, 0x4b, 0x45, 0x1a, 0x85, 0xe7, 0x51, 0xb5, 0xb5, 0x98, + 0x06, 0xf3, 0xb6, 0x07, 0x69, 0x42, 0xfa, 0xa3, 0x28, 0x86, 0x33, 0x1b, 0x7b, 0x13, 0x7f, 0x95, 0xf4, 0x9c, 0xd6, + 0xf2, 0x56, 0x14, 0xf1, 0xbd, 0x5e, 0x14, 0xe0, 0xd9, 0xeb, 0x25, 0x51, 0xe0, 0x4f, 0x44, 0x51, 0xd3, 0x59, 0x72, + 0x5a, 0x7a, 0x1f, 0xf9, 0x57, 0x1f, 0x43, 0x3d, 0x7b, 0x41, 0xa0, 0x58, 0xed, 0x44, 0x61, 0x5e, 0x82, 0x46, 0x7a, + 0x8a, 0x9d, 0xd0, 0xbc, 0x60, 0x40, 0x5c, 0xe7, 0x60, 0x79, 0x9b, 0xef, 0x79, 0xe7, 0x70, 0x79, 0x9b, 0x7d, 0xbd, + 0x60, 0x13, 0xdf, 0x53, 0xb4, 0x62, 0x37, 0x39, 0x36, 0x18, 0xf2, 0xe9, 0xeb, 0x86, 0x6d, 0x2a, 0x8e, 0x05, 0xa4, + 0x53, 0xda, 0xf3, 0x17, 0x20, 0x87, 0xf1, 0xc2, 0x34, 0xcb, 0x86, 0x97, 0x59, 0xd6, 0x3f, 0xf3, 0xb5, 0x8b, 0xff, + 0xd6, 0x88, 0x16, 0x92, 0xe1, 0x6b, 0xa6, 0x5f, 0x1a, 0xa7, 0x4c, 0x76, 0xd2, 0x01, 0x32, 0x86, 0x0e, 0x3a, 0x72, + 0x65, 0xa2, 0xb7, 0x9b, 0x95, 0x69, 0x92, 0xf3, 0xea, 0xe4, 0xf3, 0x53, 0xae, 0x82, 0x14, 0x08, 0x2a, 0x9c, 0x32, + 0xf7, 0x4c, 0xf2, 0xf8, 0x01, 0xa6, 0x07, 0x2b, 0x53, 0x2c, 0xa3, 0xd7, 0x4d, 0xbc, 0xe7, 0xf9, 0xfd, 0xbc, 0xe7, + 0x5f, 0xd3, 0x5d, 0x78, 0xcf, 0xf3, 0x2f, 0xce, 0x7b, 0xbe, 0xde, 0x8c, 0x65, 0x74, 0x1e, 0xb9, 0x6a, 0x6e, 0xa6, + 0x09, 0xa4, 0x29, 0xa6, 0x2c, 0x01, 0xaf, 0xd3, 0xdf, 0x1a, 0x54, 0x46, 0xb4, 0x86, 0x44, 0x81, 0xf3, 0xa9, 0x20, + 0x66, 0x7d, 0x1b, 0xba, 0x7f, 0x8e, 0xe5, 0xe7, 0xe9, 0xd4, 0x7d, 0x1d, 0x49, 0x05, 0xf9, 0x13, 0xf7, 0x60, 0x29, + 0x45, 0x74, 0xa6, 0x37, 0xb9, 0x8f, 0x11, 0xe4, 0xbc, 0x86, 0x80, 0xb0, 0xe4, 0x50, 0x3e, 0xc9, 0x3d, 0xfd, 0xfa, + 0x65, 0x10, 0xb4, 0xdc, 0xb5, 0x56, 0x84, 0xfd, 0xda, 0xb0, 0x8c, 0x9a, 0x31, 0x21, 0x03, 0x78, 0x79, 0xf7, 0xfd, + 0x44, 0x3b, 0x8f, 0xf4, 0xcc, 0x4f, 0xde, 0x56, 0x83, 0x6e, 0x09, 0x3d, 0x97, 0x3c, 0x9c, 0x8c, 0x7b, 0xeb, 0x49, + 0xb1, 0x75, 0xf1, 0x35, 0x7d, 0x7e, 0x52, 0x1a, 0x69, 0x4f, 0xfe, 0xb0, 0x4f, 0x91, 0xc6, 0x37, 0x88, 0x31, 0x0f, + 0x4e, 0xb3, 0xe6, 0x5c, 0xde, 0x1a, 0x9f, 0x21, 0x56, 0xe9, 0x84, 0x3e, 0xf7, 0x27, 0x59, 0xa6, 0xf7, 0xc5, 0x44, + 0x48, 0x84, 0x96, 0xdd, 0xc7, 0xc4, 0x25, 0x85, 0x10, 0x88, 0x4b, 0x7c, 0xc8, 0x86, 0xfa, 0x1c, 0xbc, 0x12, 0xb8, + 0xc5, 0x35, 0x9f, 0x33, 0x55, 0xa1, 0xe9, 0x23, 0x6f, 0x15, 0x69, 0x40, 0x60, 0x46, 0x2f, 0xfb, 0x78, 0x95, 0x16, + 0x64, 0xd3, 0x9d, 0x96, 0x26, 0x07, 0xdd, 0x2a, 0x20, 0xb2, 0xb0, 0x10, 0x0b, 0x11, 0xda, 0xe1, 0x75, 0xf0, 0x21, + 0x53, 0x73, 0xde, 0x0f, 0xb7, 0xdf, 0xe0, 0x78, 0x1f, 0x3e, 0x18, 0x54, 0x94, 0x6e, 0xf7, 0x78, 0x83, 0x02, 0x2b, + 0x91, 0xdc, 0x18, 0x56, 0x72, 0xa3, 0x3c, 0x5b, 0x8b, 0xa8, 0xdc, 0xa9, 0xb7, 0x34, 0x41, 0xcb, 0x83, 0xb8, 0x97, + 0x63, 0x3c, 0x29, 0x00, 0x78, 0x7f, 0x95, 0x00, 0x6e, 0x44, 0x39, 0x0a, 0xe2, 0x9f, 0xfe, 0x78, 0x15, 0x27, 0x51, + 0xdc, 0x5b, 0x46, 0x7e, 0x98, 0xb2, 0x38, 0x23, 0xc1, 0x0a, 0xce, 0x8f, 0x98, 0x9e, 0xcb, 0x75, 0xb4, 0xf4, 0xc6, + 0x7e, 0x7a, 0xd7, 0xb3, 0x39, 0x4b, 0x61, 0xf7, 0x39, 0x77, 0x60, 0xd7, 0xd6, 0xef, 0xf1, 0xd9, 0x7c, 0x8e, 0x8c, + 0x5f, 0xbc, 0xc9, 0xce, 0xc8, 0xdb, 0xbc, 0x2f, 0xbd, 0xa5, 0xb8, 0xe4, 0xc0, 0x7e, 0x78, 0xb1, 0x39, 0x03, 0x2c, + 0x0f, 0x4b, 0x6d, 0x4f, 0xd8, 0xcc, 0x40, 0xac, 0x0d, 0xfe, 0x0e, 0xe2, 0x8f, 0xd5, 0xd1, 0x15, 0xbb, 0xbe, 0x18, + 0x38, 0x1e, 0x7d, 0x17, 0xc8, 0x7a, 0xde, 0x34, 0x65, 0xb1, 0xb1, 0x4b, 0xcd, 0x11, 0x9b, 0x46, 0x31, 0xa3, 0x1c, + 0x76, 0x4e, 0x77, 0x79, 0xbb, 0x7b, 0xf3, 0xdb, 0x87, 0x5f, 0xdf, 0x4e, 0x18, 0xa5, 0x9a, 0x68, 0x4c, 0xbf, 0xa7, + 0xb5, 0x4d, 0x7a, 0x06, 0xac, 0x21, 0xcd, 0xfc, 0x98, 0xa4, 0x20, 0x10, 0x7f, 0xac, 0x36, 0x55, 0xc8, 0x32, 0xe2, + 0x34, 0x2f, 0x66, 0x81, 0x97, 0xfa, 0xd7, 0x82, 0x67, 0x6c, 0x1f, 0x2e, 0x6f, 0xc5, 0x1a, 0x23, 0xc1, 0x7b, 0xc0, + 0x22, 0x55, 0x40, 0x11, 0x8b, 0x54, 0x2d, 0xc6, 0x45, 0xea, 0x6f, 0x8c, 0x46, 0x44, 0xcf, 0xae, 0x50, 0xfa, 0xce, + 0xf2, 0x56, 0x26, 0xd1, 0xc5, 0x67, 0x39, 0xa5, 0xae, 0xa6, 0x3d, 0x59, 0xf8, 0x93, 0x49, 0xc0, 0xb2, 0xd2, 0x42, + 0x97, 0xd7, 0x52, 0x9a, 0x9c, 0x7c, 0x1e, 0xbc, 0x51, 0x12, 0x05, 0xab, 0x94, 0xd5, 0x4f, 0x97, 0x90, 0xe8, 0x16, + 0x93, 0x83, 0xbf, 0xcb, 0xb0, 0x76, 0x80, 0xdd, 0x86, 0x6d, 0x62, 0xf7, 0x21, 0xcb, 0xa1, 0xd9, 0x2e, 0x83, 0x0e, + 0xaf, 0x72, 0xa0, 0x8d, 0x9a, 0x81, 0x18, 0x40, 0x96, 0x08, 0x7b, 0x2b, 0x96, 0xc3, 0xcb, 0xf2, 0x4c, 0x6f, 0x79, + 0x51, 0x56, 0x1e, 0xcc, 0xef, 0x73, 0xc6, 0x5e, 0xd4, 0x9f, 0xb1, 0x17, 0xe2, 0x8c, 0x6d, 0xdf, 0x99, 0x8f, 0xa6, + 0x0e, 0xfc, 0xd7, 0x2f, 0x06, 0xd4, 0xb3, 0x95, 0xf6, 0xf2, 0x56, 0x71, 0x96, 0xb7, 0x8a, 0xd9, 0x5a, 0xde, 0x2a, + 0xd8, 0x34, 0x3a, 0xd5, 0x18, 0x56, 0x4b, 0x37, 0x6c, 0x05, 0x0a, 0xe1, 0x8f, 0x5d, 0x7a, 0xe5, 0x1c, 0xc0, 0x3b, + 0xf8, 0xaa, 0xb3, 0xf9, 0xae, 0xb5, 0xfd, 0xa8, 0xd3, 0x59, 0x12, 0x48, 0x5b, 0xb7, 0x52, 0x6f, 0x34, 0x02, 0x51, + 0x66, 0x34, 0x5e, 0x25, 0xff, 0xe0, 0xf0, 0xf3, 0x49, 0xdc, 0x8a, 0x08, 0x2a, 0xed, 0x88, 0x4f, 0x41, 0x51, 0x78, + 0xcd, 0x44, 0x0b, 0xeb, 0x7c, 0x9d, 0x7a, 0x94, 0x92, 0xb1, 0x65, 0x1d, 0xd4, 0x6c, 0xf2, 0xfa, 0x89, 0xfe, 0xdd, + 0x56, 0xa9, 0x19, 0xc5, 0x7c, 0xc6, 0xb4, 0x6c, 0x9d, 0x8e, 0x87, 0xcf, 0x06, 0x5f, 0x4d, 0xbb, 0x5b, 0x0f, 0xee, + 0x85, 0xe8, 0xe9, 0x52, 0x10, 0x15, 0x4e, 0xb7, 0x78, 0x00, 0x90, 0xed, 0xad, 0x36, 0xed, 0x91, 0x8d, 0x56, 0xb7, + 0x10, 0x84, 0xa2, 0xee, 0x8e, 0x58, 0xfe, 0xd1, 0x8b, 0x03, 0xf8, 0x8f, 0xb8, 0xfa, 0xbf, 0xa6, 0x75, 0x8c, 0xfa, + 0xeb, 0xb4, 0xc4, 0xa8, 0x13, 0xab, 0x84, 0x8c, 0xf8, 0xee, 0xf5, 0xa7, 0xd3, 0x87, 0x7d, 0xb0, 0x73, 0x6d, 0xf2, + 0x47, 0xab, 0xd6, 0x7e, 0x19, 0x45, 0x01, 0xf3, 0xc2, 0xcd, 0xea, 0x62, 0x7a, 0x28, 0xb8, 0x40, 0xea, 0xc2, 0x47, + 0xe2, 0x1e, 0x41, 0xae, 0x10, 0x2a, 0x7e, 0x43, 0x57, 0x89, 0xb3, 0xa6, 0xab, 0xc4, 0xbb, 0xfb, 0xaf, 0x12, 0x3f, + 0xec, 0x74, 0x95, 0x78, 0xf7, 0xc5, 0xaf, 0x12, 0x67, 0x9b, 0x57, 0x89, 0xb3, 0x48, 0x38, 0x21, 0x1b, 0x6f, 0x56, + 0xfc, 0xe7, 0x07, 0xb2, 0xf7, 0x7d, 0x17, 0xb9, 0x1d, 0x9b, 0xd2, 0x2c, 0x9e, 0xff, 0xe6, 0x8b, 0x05, 0x6e, 0xc4, + 0x77, 0xe8, 0x93, 0x57, 0x5c, 0x2d, 0x38, 0x66, 0xc7, 0x7e, 0xa4, 0xe2, 0x20, 0x0a, 0x67, 0x3f, 0x83, 0xbd, 0x37, + 0x88, 0x03, 0x63, 0xe9, 0x85, 0x9f, 0xfc, 0x1c, 0x2d, 0x57, 0x4b, 0x54, 0x54, 0x7d, 0xf0, 0x13, 0x7f, 0x14, 0xb0, + 0x3c, 0xae, 0x25, 0x69, 0x5d, 0xb9, 0x6c, 0x1d, 0x14, 0xaf, 0xe2, 0xa7, 0x77, 0x2b, 0x7e, 0xa2, 0x63, 0x2f, 0xff, + 0x4d, 0xce, 0x89, 0x6a, 0xfd, 0x45, 0x44, 0x58, 0x88, 0x49, 0x40, 0x3f, 0xfc, 0x32, 0x72, 0x26, 0x22, 0x88, 0x95, + 0x46, 0x29, 0xdc, 0x37, 0x1a, 0xdb, 0x61, 0xd5, 0x76, 0xde, 0xac, 0x74, 0x23, 0x4f, 0xfb, 0xb1, 0x29, 0xce, 0x5f, + 0x44, 0xab, 0x84, 0x4d, 0xa2, 0x9b, 0x50, 0x35, 0x42, 0xae, 0x57, 0x8d, 0x50, 0xa6, 0x9e, 0x7f, 0x53, 0x56, 0x38, + 0xaa, 0xd6, 0x12, 0xe6, 0xd0, 0x24, 0x0d, 0xb6, 0x89, 0x43, 0x54, 0x45, 0xa0, 0xa8, 0xfe, 0x9e, 0xa6, 0x45, 0xee, + 0xc3, 0xbe, 0x14, 0x9e, 0x27, 0x91, 0xc5, 0xa5, 0xc2, 0x89, 0x16, 0x0a, 0xe1, 0xa2, 0x88, 0xbd, 0x5d, 0xb3, 0x70, + 0xfc, 0x0d, 0xc5, 0xa5, 0x2c, 0xde, 0x82, 0xae, 0x2a, 0x5b, 0xf1, 0xf5, 0xe0, 0x91, 0xa8, 0xe9, 0xf1, 0x95, 0x34, + 0x8d, 0x6f, 0xaf, 0x59, 0x1c, 0x78, 0x77, 0x9a, 0x9e, 0x45, 0xe1, 0x8f, 0x30, 0x01, 0xaf, 0xa3, 0x9b, 0x50, 0xae, + 0x80, 0x09, 0xe2, 0x6b, 0xf6, 0x52, 0x6d, 0xcc, 0x74, 0x88, 0x14, 0x22, 0x41, 0xe0, 0x5b, 0x4b, 0x6f, 0xc6, 0xfe, + 0xcb, 0xa0, 0x7f, 0xff, 0x5b, 0xcf, 0x8c, 0x77, 0x51, 0xde, 0xd1, 0x2f, 0xcb, 0x1d, 0xba, 0x79, 0xf2, 0x64, 0xaf, + 0x79, 0xd8, 0xda, 0x38, 0x60, 0x5e, 0x2c, 0xa0, 0xa8, 0xf9, 0x5a, 0x6f, 0x3c, 0x05, 0x00, 0xc5, 0x79, 0xb4, 0x1a, + 0xcf, 0xd1, 0x5b, 0xf8, 0xcb, 0x8d, 0x37, 0x85, 0x36, 0x59, 0x72, 0x61, 0x5f, 0xe6, 0x43, 0xaf, 0x14, 0x15, 0xb3, + 0x80, 0xfd, 0x9f, 0x42, 0xd2, 0xaf, 0x7f, 0xe3, 0x34, 0x6c, 0xee, 0x9a, 0x3c, 0xd0, 0xd8, 0x83, 0x36, 0x6f, 0xdf, + 0x87, 0x58, 0x40, 0x14, 0x4e, 0x5b, 0x28, 0xe9, 0xea, 0x91, 0x4c, 0x56, 0x9d, 0x34, 0x39, 0x75, 0x4d, 0x53, 0x56, + 0x1e, 0xd1, 0x0b, 0xb3, 0x4a, 0x56, 0x23, 0x06, 0xe3, 0xd8, 0xaa, 0x82, 0x64, 0xb8, 0x37, 0x05, 0x43, 0xf4, 0x55, + 0x7d, 0xb7, 0xf0, 0x43, 0x03, 0x33, 0xcf, 0x6e, 0xbe, 0xf1, 0x6e, 0x21, 0xf7, 0x22, 0x20, 0xb7, 0xea, 0x2b, 0x28, + 0x34, 0xe4, 0x18, 0x45, 0xde, 0x64, 0xa2, 0xa9, 0xb5, 0x33, 0x21, 0xb4, 0x81, 0xc3, 0xaf, 0x14, 0x45, 0x51, 0xf2, + 0x6b, 0x84, 0x92, 0xdf, 0x23, 0xb0, 0x1c, 0xaf, 0x03, 0xa0, 0x2d, 0xc9, 0x96, 0xb7, 0x54, 0x02, 0x37, 0x03, 0xb4, + 0x9f, 0x16, 0x05, 0x3c, 0xbd, 0x10, 0x18, 0xb7, 0x50, 0x81, 0xb8, 0xd0, 0x83, 0xea, 0xdb, 0x8b, 0x21, 0x0b, 0x61, + 0x4f, 0xc1, 0x0b, 0x3b, 0xbe, 0xe5, 0x92, 0x60, 0xc5, 0xa6, 0xc7, 0x61, 0x9f, 0xd5, 0xe7, 0xa1, 0x09, 0x25, 0x2c, + 0x08, 0x5a, 0x87, 0x4a, 0x5a, 0x49, 0x83, 0xd5, 0xe0, 0x46, 0xbc, 0x17, 0xdd, 0xa6, 0x0b, 0x16, 0xae, 0x54, 0x03, + 0xac, 0x4e, 0x30, 0x2f, 0x10, 0xd4, 0x79, 0x4d, 0xcc, 0x16, 0x60, 0x9b, 0xfa, 0x2f, 0xe7, 0x44, 0x0b, 0x85, 0xa9, + 0x8a, 0x67, 0x8c, 0x79, 0xd8, 0x9d, 0x84, 0xe3, 0xb6, 0x2a, 0x85, 0xe0, 0x4b, 0x1a, 0x95, 0xb1, 0x39, 0x0f, 0xb4, + 0x85, 0x9c, 0x02, 0xd9, 0x88, 0x71, 0x71, 0x91, 0x98, 0x76, 0xcd, 0xab, 0x2e, 0x5a, 0xae, 0x91, 0xf1, 0x2a, 0x82, + 0xa2, 0x58, 0xdf, 0x6c, 0x86, 0xc3, 0x09, 0xc9, 0x10, 0x1a, 0xdb, 0x19, 0x6f, 0xb4, 0xd3, 0x30, 0xe8, 0x8f, 0xec, + 0x8e, 0x08, 0x09, 0x4d, 0xd5, 0x47, 0x76, 0x07, 0xc6, 0xe1, 0xa7, 0x20, 0x4d, 0x51, 0xb7, 0xa0, 0x6b, 0x03, 0xd2, + 0x0b, 0x8f, 0x21, 0x41, 0xc6, 0x96, 0x03, 0x64, 0x67, 0x5b, 0xb0, 0x38, 0x05, 0x41, 0x35, 0x92, 0xbe, 0x38, 0xc4, + 0x3c, 0x4e, 0x82, 0x56, 0x3b, 0xc7, 0x66, 0xcd, 0xd1, 0xd0, 0x9f, 0x39, 0xb6, 0xbd, 0xbf, 0x51, 0x1f, 0x04, 0xd9, + 0x75, 0xb5, 0x75, 0x23, 0x75, 0x1d, 0xdb, 0xf4, 0x9f, 0x59, 0xad, 0xfe, 0x06, 0x8d, 0x96, 0xf2, 0x57, 0x0d, 0x51, + 0xfc, 0x35, 0x78, 0xbc, 0xd6, 0x36, 0x0e, 0xa4, 0x5e, 0x8d, 0x3b, 0x80, 0xb0, 0x65, 0x5c, 0xfe, 0x35, 0xdc, 0x24, + 0xfd, 0x94, 0x3d, 0x8b, 0x72, 0xa9, 0x0f, 0x21, 0x03, 0xa3, 0x06, 0xc7, 0xe8, 0x4f, 0xca, 0x73, 0x45, 0xa3, 0xe3, + 0xa3, 0xeb, 0xc3, 0xbe, 0xc0, 0x28, 0x22, 0x30, 0x8f, 0xdc, 0x40, 0xa5, 0xc7, 0xa4, 0x8a, 0xe1, 0x78, 0xae, 0x37, + 0x56, 0x68, 0xf4, 0xb6, 0x72, 0x0b, 0xd8, 0x7e, 0x03, 0xf9, 0xb4, 0x46, 0x10, 0x59, 0x12, 0x6a, 0x40, 0xbe, 0xd6, + 0x7b, 0x1b, 0x5c, 0x2d, 0xcb, 0xcd, 0x95, 0x89, 0xe4, 0xee, 0x8d, 0x21, 0xd1, 0x41, 0x1d, 0x5a, 0xde, 0x5e, 0x3d, + 0xb9, 0x7b, 0x60, 0x93, 0x2c, 0x9c, 0x94, 0x1b, 0xac, 0xd0, 0xaf, 0xdd, 0x9b, 0x2b, 0x61, 0x14, 0x48, 0x64, 0x1c, + 0xd5, 0x60, 0x94, 0x2c, 0x0a, 0x71, 0xf3, 0xd3, 0x71, 0xf3, 0x77, 0xe2, 0x62, 0xf0, 0x03, 0xca, 0x42, 0x92, 0x7f, + 0x26, 0x09, 0xc5, 0x21, 0x5b, 0x26, 0xc6, 0xed, 0xd2, 0x04, 0x23, 0xda, 0xb8, 0x13, 0x53, 0xe1, 0xae, 0x58, 0x7c, + 0xe3, 0xf3, 0xfc, 0x57, 0xbb, 0x4a, 0xad, 0xfd, 0xfb, 0xa5, 0xd6, 0xe9, 0x7d, 0x52, 0x6b, 0x8a, 0x49, 0xc3, 0xed, + 0x41, 0x45, 0x6c, 0x1e, 0xc1, 0x9c, 0xcb, 0xd1, 0x8d, 0x4a, 0xa2, 0x6e, 0x0c, 0x61, 0x53, 0x63, 0x45, 0x4a, 0xad, + 0x91, 0x03, 0x22, 0x8a, 0xbf, 0xa5, 0x0b, 0x8a, 0x50, 0xa8, 0xcb, 0xb2, 0xf1, 0xb3, 0x42, 0x36, 0x4e, 0xb7, 0x9a, + 0x22, 0x1a, 0x89, 0xe0, 0xfe, 0xa5, 0x48, 0x3f, 0xf9, 0xed, 0xa0, 0x88, 0xf8, 0x53, 0x40, 0x2a, 0xc5, 0xb0, 0x29, + 0x2e, 0x1a, 0x52, 0x64, 0x24, 0x71, 0xcb, 0x28, 0x07, 0x48, 0x2a, 0x57, 0x2d, 0x42, 0xd8, 0x14, 0xe5, 0x20, 0x75, + 0x47, 0x90, 0xf3, 0x62, 0x79, 0xdb, 0x94, 0x63, 0x98, 0xc8, 0xaf, 0xa5, 0x4d, 0x92, 0x07, 0x1b, 0xa1, 0x09, 0x16, + 0x62, 0xfa, 0x8a, 0x5e, 0x3b, 0xb7, 0x81, 0x40, 0x20, 0x6b, 0x62, 0x23, 0xdd, 0x2f, 0x9d, 0xa7, 0x1c, 0xcd, 0x85, + 0xea, 0xda, 0x41, 0xea, 0x4e, 0x9a, 0x60, 0x59, 0x1e, 0x81, 0x73, 0x7d, 0x29, 0x49, 0x10, 0x7a, 0xb6, 0x62, 0xf7, + 0x6b, 0x18, 0x00, 0xa4, 0xff, 0xd5, 0x67, 0xce, 0x0a, 0x80, 0x24, 0x52, 0xb1, 0x65, 0x9d, 0x3f, 0x1e, 0x62, 0x93, + 0x2c, 0xd9, 0xb1, 0xea, 0x66, 0x9f, 0x24, 0xef, 0x59, 0xf3, 0x48, 0x24, 0x65, 0x71, 0x3e, 0xaf, 0xd1, 0x13, 0x70, + 0xf0, 0x5d, 0x16, 0xaf, 0x42, 0x4c, 0xbd, 0x6b, 0xa6, 0xb1, 0x37, 0xfe, 0xb8, 0x96, 0xfa, 0xe3, 0x22, 0x51, 0x10, + 0x17, 0x97, 0x95, 0x0a, 0x7d, 0x0f, 0x33, 0x55, 0xb1, 0x9e, 0xd5, 0x4a, 0x24, 0x41, 0x4d, 0xef, 0x91, 0xdd, 0xf6, + 0x5e, 0x4c, 0x0f, 0x2a, 0xf2, 0xd3, 0x56, 0xa7, 0x2c, 0x5d, 0xcf, 0xe1, 0x58, 0x44, 0xbf, 0xf2, 0x98, 0x4d, 0x7f, + 0x7c, 0xd7, 0x09, 0xef, 0xb3, 0xb2, 0x46, 0x9f, 0x03, 0x02, 0x7c, 0x5f, 0x52, 0x4c, 0xcb, 0x6a, 0x9a, 0x8d, 0x92, + 0x26, 0xb0, 0xa6, 0x7e, 0x10, 0x98, 0x01, 0xb8, 0x31, 0xac, 0x3f, 0x6b, 0x78, 0xd8, 0xce, 0x0a, 0x72, 0x24, 0x7e, + 0x46, 0x3b, 0xe5, 0x9d, 0x92, 0xce, 0x57, 0x8b, 0xd1, 0x5a, 0x16, 0x94, 0x4b, 0xf2, 0xf3, 0x4d, 0x99, 0xb9, 0xdc, + 0xed, 0x74, 0x3a, 0x2d, 0x4b, 0x8d, 0x6d, 0xe5, 0x00, 0x25, 0xbf, 0x8f, 0x6c, 0xdb, 0xae, 0xce, 0x6f, 0xd3, 0x41, + 0xa1, 0x83, 0x61, 0xa2, 0x10, 0xbe, 0x7b, 0xff, 0x9e, 0xfa, 0x83, 0xa0, 0xa5, 0xa6, 0x9a, 0xce, 0x23, 0x6d, 0xb5, + 0xff, 0x08, 0x50, 0x10, 0x35, 0xdc, 0x77, 0xfc, 0x37, 0xf7, 0xca, 0x96, 0x96, 0xaa, 0x07, 0xf8, 0x61, 0x1f, 0xdf, + 0xb3, 0xd7, 0x77, 0xf8, 0xb4, 0x69, 0x7b, 0x67, 0x56, 0x41, 0x76, 0x4b, 0x36, 0x4b, 0x7d, 0xb2, 0x54, 0xf2, 0x53, + 0xb6, 0x48, 0x7a, 0x63, 0x86, 0x0a, 0x52, 0x4b, 0xa2, 0xb6, 0x68, 0xd5, 0x63, 0xce, 0xc0, 0x8e, 0xcb, 0x11, 0x78, + 0xd8, 0x56, 0x50, 0x59, 0xb5, 0xa1, 0x59, 0x13, 0x9d, 0x20, 0x15, 0x5b, 0x6f, 0x2a, 0x9c, 0x70, 0x9b, 0x76, 0xec, + 0x3f, 0x95, 0xea, 0x29, 0xc0, 0x9d, 0xae, 0x85, 0xb5, 0x09, 0x29, 0x4f, 0xf0, 0xef, 0x5c, 0x39, 0xf7, 0x62, 0x79, + 0x5b, 0x36, 0xee, 0xea, 0x82, 0xba, 0xa9, 0x20, 0x65, 0x04, 0x75, 0x1d, 0xea, 0xcb, 0x4d, 0x80, 0xa6, 0xb2, 0x75, + 0x0b, 0x58, 0xd0, 0x88, 0x29, 0xa8, 0xe8, 0x08, 0x73, 0x50, 0xf1, 0x3a, 0x0b, 0x3b, 0xaf, 0x90, 0xef, 0xe3, 0x2f, + 0xc8, 0x8d, 0x0e, 0x49, 0x56, 0xfe, 0x64, 0x3c, 0xef, 0xa2, 0x72, 0xaf, 0xb4, 0x55, 0xd1, 0x54, 0x06, 0xf7, 0x80, + 0xb8, 0x91, 0x2a, 0xab, 0x38, 0x30, 0x97, 0x31, 0x9b, 0xfa, 0xb7, 0x9a, 0xbe, 0xde, 0x1c, 0x77, 0x73, 0xf3, 0x4e, + 0x07, 0xf4, 0x1a, 0x9b, 0x53, 0xb5, 0x93, 0x6a, 0xaf, 0xaa, 0xc3, 0x16, 0x70, 0xc2, 0x0a, 0x80, 0xcf, 0xac, 0x82, + 0x46, 0x43, 0x4a, 0x05, 0xf7, 0xd1, 0xa0, 0xf3, 0xb7, 0x32, 0xb2, 0x16, 0xe3, 0xc4, 0xe6, 0xea, 0xab, 0x50, 0xdb, + 0x42, 0x33, 0x08, 0x73, 0xc7, 0xb1, 0x13, 0x3e, 0x9b, 0xb0, 0x63, 0x64, 0x74, 0xe5, 0xe0, 0x0e, 0xc2, 0x53, 0x6a, + 0x52, 0xca, 0x15, 0x3a, 0xa5, 0xa8, 0x4b, 0xf8, 0xa1, 0x56, 0x78, 0x7f, 0x5e, 0x92, 0xc6, 0xf3, 0xa0, 0x13, 0x2d, + 0x7d, 0xa7, 0xda, 0x0b, 0x3f, 0xdc, 0xbd, 0xae, 0x77, 0xbb, 0x73, 0x5d, 0x60, 0x0e, 0x77, 0xae, 0x0c, 0xdc, 0x25, + 0x56, 0x3e, 0x4f, 0xdd, 0x1f, 0x24, 0xe5, 0x81, 0x1c, 0xa6, 0x51, 0xc5, 0xaf, 0xe8, 0x46, 0xff, 0xd3, 0xca, 0x1d, + 0x1e, 0x9f, 0xdc, 0x2e, 0x02, 0xe5, 0x9a, 0xc5, 0x09, 0xa4, 0xb1, 0x50, 0x1d, 0xcb, 0x56, 0x15, 0x34, 0xe8, 0xf7, + 0xc3, 0x99, 0xab, 0xfe, 0x72, 0xfe, 0xc6, 0xec, 0xaa, 0x27, 0x60, 0x8e, 0x71, 0x3d, 0x43, 0x16, 0xf7, 0xcc, 0xbb, + 0x63, 0xf1, 0x55, 0x8b, 0x7b, 0xfc, 0x10, 0x73, 0x8b, 0x65, 0x4a, 0x4b, 0xdd, 0x21, 0x11, 0xbd, 0x72, 0xed, 0xb3, + 0x9b, 0x97, 0xd1, 0xad, 0xab, 0x02, 0x62, 0x75, 0x5a, 0x5d, 0xc5, 0x69, 0x1d, 0x58, 0x87, 0x5d, 0x75, 0xf0, 0x95, + 0xa2, 0x1c, 0x4f, 0xd8, 0x34, 0x19, 0xa0, 0x38, 0xe6, 0x18, 0xf9, 0x41, 0xfa, 0xad, 0x28, 0xd6, 0x38, 0x48, 0x4c, + 0x47, 0x59, 0xf3, 0x47, 0x45, 0x01, 0x64, 0xd4, 0x53, 0x1e, 0x4d, 0x5b, 0xd3, 0x83, 0xe9, 0x8b, 0x3e, 0x2f, 0xce, + 0xbe, 0x2a, 0x55, 0x37, 0xe8, 0xdf, 0x96, 0xf4, 0x59, 0x92, 0xc6, 0xd1, 0x47, 0xc6, 0x79, 0x49, 0x25, 0x17, 0x14, + 0x55, 0x3f, 0x6d, 0x6d, 0xf6, 0xe4, 0x74, 0x47, 0xe3, 0x69, 0xab, 0xa8, 0x8e, 0x30, 0xee, 0xe7, 0x40, 0x1e, 0xef, + 0x0b, 0xd0, 0x8f, 0xe5, 0x69, 0x72, 0xcc, 0xba, 0x89, 0x72, 0x54, 0x3e, 0xc6, 0x99, 0x18, 0xdf, 0x31, 0xe4, 0x79, + 0x2b, 0xbc, 0x17, 0x13, 0xfc, 0xcc, 0x55, 0x7f, 0x74, 0x5a, 0x5d, 0xc3, 0x71, 0x0e, 0xad, 0xc3, 0xee, 0xd8, 0x36, + 0x0e, 0xac, 0x03, 0xb3, 0x6d, 0x1d, 0x1a, 0x5d, 0xb3, 0x6b, 0x74, 0xbf, 0xeb, 0x8e, 0xcd, 0x03, 0xeb, 0xc0, 0xb0, + 0xcd, 0x2e, 0x14, 0x9a, 0x5d, 0xb3, 0x7b, 0x6d, 0x1e, 0x74, 0xc7, 0x36, 0x96, 0xb6, 0xac, 0x4e, 0xc7, 0x74, 0x6c, + 0xab, 0xd3, 0x31, 0x3a, 0xd6, 0xe1, 0xa1, 0xe9, 0xb4, 0xad, 0xc3, 0xc3, 0xb3, 0x4e, 0xd7, 0x6a, 0xc3, 0xbb, 0x76, + 0x7b, 0xdc, 0xb6, 0x1c, 0xc7, 0x84, 0xbf, 0x8c, 0xae, 0xd5, 0xa2, 0x1f, 0x8e, 0x63, 0xb5, 0x1d, 0xc3, 0x0e, 0x3a, + 0x2d, 0xeb, 0xf0, 0x85, 0x81, 0x7f, 0x63, 0x35, 0x03, 0xff, 0x82, 0x66, 0x8c, 0x17, 0x56, 0xeb, 0x90, 0x7e, 0x61, + 0x83, 0xd7, 0x07, 0xdd, 0xbf, 0xaa, 0xfb, 0x8d, 0x63, 0x70, 0x68, 0x0c, 0xdd, 0x8e, 0xd5, 0x6e, 0x1b, 0x07, 0x8e, + 0xd5, 0x6d, 0xcf, 0xcd, 0x83, 0x96, 0x75, 0x78, 0x34, 0x36, 0x1d, 0xeb, 0xe8, 0xc8, 0xb0, 0xcd, 0xb6, 0xd5, 0x32, + 0x1c, 0xeb, 0xa0, 0x8d, 0x3f, 0xda, 0x56, 0xeb, 0xfa, 0xe8, 0x85, 0x75, 0xd8, 0x99, 0x1f, 0x5a, 0x07, 0x1f, 0x0e, + 0xba, 0x56, 0xab, 0x3d, 0x6f, 0x1f, 0x5a, 0xad, 0xa3, 0xeb, 0x43, 0xeb, 0x60, 0x6e, 0xb6, 0x0e, 0xb7, 0x7e, 0xe9, + 0xb4, 0x2c, 0x98, 0x23, 0x7c, 0x0d, 0x2f, 0x0c, 0xfe, 0x02, 0xfe, 0xcc, 0xf1, 0xdb, 0x3f, 0xb0, 0x99, 0x64, 0xf3, + 0xd3, 0x17, 0x56, 0xf7, 0x68, 0x4c, 0xd5, 0xa1, 0xc0, 0x14, 0x35, 0xe0, 0x93, 0x6b, 0x93, 0xba, 0xc5, 0xe6, 0x4c, + 0xd1, 0x90, 0xf8, 0xc3, 0x3b, 0xbb, 0x36, 0xa1, 0x63, 0xea, 0xf7, 0xdf, 0xda, 0x4e, 0xbe, 0xe4, 0xc7, 0xfb, 0x33, + 0xda, 0xfa, 0xb3, 0xc1, 0x57, 0xc7, 0x70, 0xb8, 0x07, 0x43, 0xe3, 0xd7, 0x26, 0xa5, 0xe4, 0xdf, 0xef, 0x57, 0x4a, + 0xbe, 0x5c, 0xed, 0xa2, 0x94, 0xfc, 0xfb, 0x17, 0x57, 0x4a, 0xfe, 0x5a, 0xf5, 0xad, 0x79, 0x53, 0xcd, 0x7d, 0xfd, + 0xc3, 0xba, 0x2a, 0x72, 0x48, 0x3c, 0xed, 0xe2, 0xa7, 0xd5, 0x25, 0x44, 0xad, 0x7f, 0x13, 0xb9, 0x2f, 0x57, 0x25, + 0x83, 0xcf, 0x08, 0x70, 0xec, 0x9b, 0x88, 0x70, 0xec, 0x87, 0x95, 0x0b, 0x56, 0x66, 0x9c, 0xcd, 0xf1, 0x27, 0xe6, + 0xdc, 0x0b, 0xa6, 0x39, 0x8b, 0x04, 0x25, 0x7d, 0x2c, 0x06, 0xbf, 0x79, 0x20, 0xcf, 0x70, 0x93, 0x59, 0x2d, 0xc2, + 0x04, 0x2c, 0x82, 0xc1, 0x92, 0x63, 0x1a, 0x67, 0x95, 0x8f, 0x2d, 0x11, 0xe7, 0xff, 0x8a, 0x7b, 0x14, 0x37, 0xbe, + 0x47, 0x03, 0xe0, 0xfa, 0xd6, 0x9d, 0xcd, 0x76, 0x15, 0xb0, 0xac, 0x13, 0x06, 0xd2, 0xc0, 0xed, 0xd7, 0xbd, 0x2f, + 0x9b, 0xe1, 0x56, 0x0c, 0xaf, 0x9b, 0x21, 0x05, 0x48, 0xaa, 0xdf, 0x3b, 0x65, 0x33, 0xde, 0xfb, 0x86, 0x59, 0xd3, + 0x7d, 0xe9, 0xf3, 0x2d, 0x36, 0xc4, 0x79, 0xc3, 0xd5, 0xa9, 0x5a, 0x97, 0xf8, 0xb4, 0xfa, 0x09, 0x29, 0x2e, 0xa8, + 0x85, 0xa1, 0x71, 0xc1, 0xa9, 0xda, 0x0a, 0xf2, 0x3b, 0xb6, 0xf4, 0xae, 0xd4, 0xa6, 0x6c, 0x9c, 0xfc, 0x6c, 0x8d, + 0xf7, 0x0a, 0xff, 0x57, 0xe0, 0x44, 0x39, 0xc7, 0x33, 0x8a, 0xe4, 0x79, 0x5e, 0x4b, 0xed, 0x92, 0x34, 0x22, 0x9b, + 0x3b, 0xeb, 0x4d, 0x5e, 0xb4, 0xd1, 0x2d, 0xc1, 0x61, 0x0b, 0xc1, 0x05, 0x61, 0xf7, 0xe4, 0x04, 0x90, 0x91, 0xa3, + 0x06, 0xfa, 0x39, 0x6c, 0x6b, 0x4c, 0xd4, 0x7b, 0x04, 0x9b, 0x98, 0x7b, 0x02, 0x2a, 0x72, 0x20, 0xd5, 0xf5, 0x34, + 0x88, 0xbc, 0xb4, 0x87, 0x6c, 0x9a, 0xc4, 0xf2, 0xb6, 0xd0, 0x63, 0xa1, 0xbf, 0xc5, 0x98, 0x4e, 0x6e, 0x98, 0x37, + 0x82, 0x9e, 0x0f, 0xdb, 0xec, 0xef, 0x72, 0x87, 0xb3, 0x75, 0xc9, 0x1c, 0xc5, 0xe9, 0x1c, 0x19, 0xce, 0xa1, 0x61, + 0x1d, 0x75, 0xf4, 0x4c, 0x1c, 0x38, 0xb9, 0xc9, 0xd2, 0x84, 0x80, 0x03, 0x44, 0x0e, 0xa6, 0x1f, 0xfa, 0xa9, 0xef, + 0x05, 0x19, 0xf0, 0xc3, 0xe5, 0x4b, 0xca, 0xdf, 0x57, 0x49, 0x0a, 0x63, 0x14, 0x4c, 0x2f, 0x3a, 0x7f, 0x98, 0x23, + 0x96, 0xde, 0x30, 0x16, 0x36, 0x18, 0xc6, 0x54, 0x7d, 0x49, 0x7e, 0x3f, 0xcb, 0xfa, 0x8c, 0xac, 0xd6, 0x46, 0x69, + 0xc8, 0xf7, 0x87, 0x70, 0x7c, 0xc8, 0x86, 0xc6, 0x77, 0x4d, 0x08, 0xf7, 0x97, 0xfb, 0x11, 0x6e, 0xca, 0x76, 0x41, + 0xb8, 0xbf, 0x7c, 0x71, 0x84, 0xfb, 0x9d, 0x8c, 0x70, 0x4b, 0xfe, 0x83, 0x85, 0x86, 0xe9, 0x3d, 0x3e, 0x6b, 0xe0, + 0x22, 0xfb, 0x5c, 0xdd, 0x27, 0x06, 0x5e, 0xd5, 0x8b, 0x9c, 0xb9, 0x7f, 0x59, 0xc9, 0x16, 0xd4, 0x28, 0x00, 0xc5, + 0x6c, 0x92, 0x3e, 0xba, 0x2e, 0xfb, 0xe0, 0xea, 0x26, 0xc2, 0x30, 0x40, 0x9b, 0xdf, 0x87, 0x69, 0x60, 0xbd, 0xe3, + 0xf7, 0x48, 0x50, 0xe8, 0xbe, 0x89, 0xe2, 0x85, 0x87, 0x89, 0x4d, 0x54, 0x1d, 0xdc, 0xe9, 0xe0, 0xc1, 0x86, 0x40, + 0x20, 0xe3, 0x28, 0x9c, 0xe4, 0x5a, 0x49, 0xe6, 0x5e, 0x10, 0xc7, 0xad, 0xde, 0x31, 0x2f, 0x56, 0x0d, 0x7a, 0x0d, + 0x8b, 0xfb, 0xac, 0x6d, 0x3f, 0x6b, 0x1d, 0x3c, 0x3b, 0xb4, 0xe1, 0x7f, 0x87, 0xb5, 0x33, 0x83, 0x57, 0x5c, 0x44, + 0x61, 0x3a, 0x2f, 0x6a, 0x36, 0x55, 0xbb, 0x61, 0xec, 0x63, 0x51, 0xeb, 0xa8, 0xbe, 0xd2, 0xc4, 0xbb, 0x2b, 0xea, + 0xd4, 0xd6, 0x98, 0x47, 0x2b, 0x09, 0xac, 0x1a, 0x68, 0xfc, 0x70, 0x05, 0x72, 0x76, 0xa9, 0x86, 0xfc, 0x9a, 0x0f, + 0xb7, 0x18, 0x17, 0x6b, 0x67, 0x97, 0x22, 0x73, 0x83, 0xda, 0x17, 0xc9, 0xfc, 0xee, 0x9d, 0x41, 0xae, 0xa2, 0xb4, + 0x31, 0xd3, 0x15, 0xe6, 0x53, 0x84, 0x3c, 0x57, 0x4c, 0x2c, 0x90, 0x47, 0x0b, 0x94, 0xc6, 0xab, 0x70, 0xac, 0xe1, + 0x4f, 0x6f, 0x94, 0x68, 0xfe, 0x7e, 0x6c, 0xf1, 0x8e, 0x75, 0x5c, 0x35, 0x6f, 0x60, 0x17, 0xa9, 0xee, 0x13, 0xb1, + 0x2a, 0xde, 0xb3, 0xd4, 0x88, 0x51, 0x8f, 0x4d, 0x4b, 0x6b, 0xba, 0xde, 0xb3, 0xfc, 0xc3, 0x67, 0xa9, 0x11, 0x3e, + 0x07, 0xdd, 0xa7, 0x6b, 0x3f, 0x79, 0x42, 0xb5, 0xf6, 0x5c, 0x31, 0xac, 0x93, 0x71, 0x91, 0x0f, 0x43, 0xf1, 0x66, + 0x11, 0xa5, 0xc4, 0xe8, 0x8d, 0x8d, 0xe8, 0xf9, 0xf3, 0x81, 0xeb, 0xe8, 0xa3, 0x98, 0x79, 0x1f, 0x33, 0x11, 0x64, + 0x3c, 0xc4, 0xac, 0xb8, 0x67, 0xbb, 0x19, 0x1a, 0xe9, 0xb5, 0xae, 0xb4, 0x4b, 0xb8, 0x33, 0xd9, 0xc2, 0x1d, 0x81, + 0x63, 0x2f, 0x77, 0x8f, 0x97, 0x80, 0x2b, 0x13, 0x19, 0xfc, 0x88, 0x3a, 0x57, 0x73, 0x2f, 0xf9, 0x21, 0x89, 0xc2, + 0x5f, 0x96, 0x10, 0x72, 0xb9, 0xb0, 0x28, 0x12, 0x97, 0xb1, 0xb6, 0x65, 0x5b, 0xb6, 0x9a, 0xb7, 0x37, 0xf5, 0x67, + 0xee, 0x3a, 0x4a, 0xbd, 0xde, 0x9e, 0x63, 0x04, 0xd1, 0x0c, 0xdc, 0xeb, 0x52, 0x3f, 0x0d, 0x58, 0x4f, 0x55, 0xc1, + 0xcf, 0x6e, 0x41, 0xd7, 0xf5, 0x8c, 0x3b, 0x3d, 0x78, 0x31, 0xe4, 0x50, 0x8f, 0xef, 0x84, 0x87, 0x2e, 0x46, 0x6e, + 0xff, 0x11, 0x68, 0xa4, 0xa6, 0x6a, 0x20, 0x32, 0x60, 0x71, 0x62, 0xca, 0x4e, 0x44, 0x3d, 0x05, 0xbe, 0xd1, 0x55, + 0x3e, 0xb6, 0x69, 0xec, 0x2d, 0x20, 0xc9, 0xef, 0x3a, 0x33, 0x38, 0x02, 0x56, 0x39, 0x06, 0x56, 0x9c, 0x17, 0x87, + 0x86, 0xd2, 0x72, 0x0c, 0xc5, 0x06, 0x2c, 0xac, 0x66, 0xc6, 0x3a, 0xbb, 0xec, 0xdf, 0x67, 0x07, 0x41, 0x68, 0xe7, + 0x11, 0x8d, 0x83, 0x2c, 0x20, 0xb8, 0x86, 0x29, 0xa5, 0x8c, 0x3d, 0x9a, 0x94, 0xce, 0xd3, 0x27, 0x5d, 0xe8, 0x39, + 0xbb, 0x4d, 0x75, 0x50, 0x28, 0x89, 0x2a, 0xbe, 0xbe, 0x46, 0x3f, 0x62, 0x3f, 0x54, 0xfc, 0x4f, 0x9f, 0x34, 0x1f, + 0x7c, 0x9c, 0x5c, 0x69, 0x7e, 0xe0, 0x59, 0x2f, 0x4d, 0x98, 0x5f, 0x68, 0xef, 0x71, 0xb2, 0xc0, 0x01, 0x11, 0xfe, + 0x2d, 0x8a, 0xc5, 0x0f, 0x6e, 0x3d, 0x61, 0x05, 0x5e, 0x38, 0x03, 0x4c, 0xe7, 0x85, 0xb3, 0x0d, 0x2b, 0x2d, 0x72, + 0x85, 0xae, 0x94, 0x16, 0x4d, 0x15, 0x16, 0x54, 0xc9, 0xcb, 0xbb, 0x73, 0x6f, 0xf6, 0x93, 0xb7, 0x60, 0x9a, 0x0a, + 0xc4, 0x0f, 0x3d, 0x77, 0x0b, 0x05, 0xef, 0x73, 0xf7, 0xe9, 0xf1, 0x82, 0xa5, 0x1e, 0x69, 0x87, 0xe0, 0x4e, 0x0c, + 0x5c, 0x82, 0xc2, 0xe9, 0x0f, 0x8f, 0x83, 0xe1, 0x52, 0x62, 0x2f, 0x22, 0x1f, 0x86, 0xc2, 0xc9, 0x97, 0x89, 0x86, + 0xa0, 0xae, 0x63, 0x90, 0x1f, 0xc2, 0xd8, 0xc3, 0xe4, 0x3e, 0x6e, 0x18, 0xa9, 0x83, 0xa7, 0xb9, 0xcb, 0x66, 0xd3, + 0x22, 0x04, 0x7e, 0xf8, 0xf1, 0x22, 0x66, 0xc1, 0x3f, 0xdc, 0xa7, 0x40, 0xcf, 0x9f, 0x5e, 0xaa, 0x7a, 0x3f, 0xb5, + 0xe6, 0x31, 0x9b, 0xba, 0x4f, 0xe1, 0x9e, 0xda, 0x43, 0xab, 0x59, 0x60, 0xe6, 0x9f, 0xdf, 0x2e, 0x02, 0x03, 0x6f, + 0xfd, 0x04, 0x8b, 0xda, 0x6e, 0x15, 0x41, 0xd6, 0xdb, 0x3b, 0xdd, 0xf5, 0x07, 0xfc, 0x12, 0x0f, 0x17, 0xc3, 0x75, + 0xe9, 0xea, 0xed, 0xf4, 0xf1, 0x5a, 0x3d, 0x0a, 0xbc, 0xf1, 0xc7, 0x3e, 0xbd, 0x29, 0x3d, 0x98, 0x40, 0xc4, 0xc7, + 0xde, 0xb2, 0x87, 0x54, 0x57, 0x2e, 0x04, 0xa7, 0x6a, 0x2a, 0xcd, 0x19, 0xbe, 0xda, 0xbd, 0x8c, 0x5b, 0x79, 0x8d, + 0x3d, 0x63, 0x57, 0x37, 0x73, 0x3f, 0x65, 0xa2, 0x2b, 0x7c, 0xc8, 0x32, 0x71, 0x7f, 0xa7, 0x9b, 0x2b, 0xde, 0xb7, + 0xad, 0xb6, 0xe2, 0x74, 0xbf, 0xeb, 0x5c, 0x3b, 0xf6, 0xbc, 0xe5, 0x58, 0xdd, 0x0f, 0x4e, 0x77, 0xde, 0xb6, 0x8e, + 0x02, 0xb3, 0x6d, 0x1d, 0xc1, 0x9f, 0x0f, 0x47, 0x56, 0x77, 0x6e, 0xb6, 0xac, 0x83, 0x0f, 0x4e, 0x2b, 0x30, 0xbb, + 0xd6, 0x11, 0xfc, 0x39, 0xa3, 0xaf, 0xe0, 0x5e, 0x44, 0xd7, 0xa0, 0xa7, 0x25, 0xe4, 0x20, 0xfd, 0xce, 0x55, 0xb5, + 0x46, 0x89, 0xea, 0xd5, 0xa8, 0x7b, 0x97, 0x18, 0x5c, 0x42, 0x24, 0xd3, 0xc1, 0xd0, 0x43, 0x5a, 0xe8, 0x32, 0x4a, + 0x72, 0x2b, 0x0c, 0xdf, 0x84, 0x87, 0x7a, 0x91, 0x75, 0x55, 0x3a, 0x41, 0xbc, 0x6e, 0x3f, 0xa1, 0xed, 0x2e, 0x85, + 0x95, 0xd3, 0x2a, 0xc7, 0xae, 0x21, 0xdf, 0xb2, 0x6e, 0x80, 0xea, 0x18, 0x10, 0x53, 0x11, 0x0e, 0x4a, 0xab, 0x45, + 0x5b, 0x02, 0x9b, 0x25, 0x2c, 0xa5, 0x22, 0x4d, 0x7c, 0x09, 0xc4, 0x46, 0xd7, 0x45, 0x9a, 0x64, 0x6c, 0x98, 0xd7, + 0x60, 0x3c, 0x9f, 0x73, 0xed, 0xab, 0x7e, 0x15, 0x5f, 0x82, 0x57, 0xbc, 0x15, 0x46, 0x37, 0x68, 0xf5, 0x71, 0xdf, + 0xdc, 0x61, 0x9c, 0x01, 0x26, 0x6c, 0xce, 0xca, 0xc0, 0xf2, 0xd0, 0xd9, 0xd5, 0x0e, 0x37, 0x10, 0xf4, 0x83, 0x3a, + 0x94, 0xd2, 0x83, 0x7f, 0x56, 0x3b, 0x3c, 0x8a, 0x85, 0x9c, 0xa2, 0x73, 0xe2, 0xc7, 0x39, 0x78, 0x12, 0x45, 0x71, + 0xea, 0xf3, 0xa3, 0xea, 0x06, 0xc5, 0x72, 0x62, 0xf1, 0xb5, 0x17, 0x48, 0x76, 0x77, 0xd2, 0x97, 0x7b, 0x39, 0xa1, + 0x7a, 0xf2, 0xa4, 0x00, 0xce, 0xac, 0xc0, 0x7d, 0xec, 0x74, 0x80, 0x4b, 0xe8, 0xb0, 0xf6, 0x56, 0x13, 0x50, 0xba, + 0x98, 0x6d, 0x73, 0x05, 0x2f, 0xd2, 0x41, 0x09, 0x33, 0x2f, 0x61, 0x60, 0xd2, 0x68, 0x87, 0xba, 0x61, 0x5e, 0x02, + 0xf9, 0xf4, 0x2a, 0x37, 0x33, 0x55, 0xef, 0x87, 0xc2, 0x5a, 0x22, 0xdc, 0x92, 0x09, 0x8f, 0x5f, 0x1d, 0x55, 0x98, + 0x9a, 0x2d, 0xe3, 0xb8, 0xc7, 0x9f, 0xfd, 0xdf, 0x3d, 0x08, 0xf4, 0x2d, 0x05, 0xf3, 0x8e, 0x0a, 0x16, 0x29, 0xf9, + 0x1a, 0xe6, 0xf4, 0x9e, 0x08, 0x3d, 0x4b, 0x4e, 0x54, 0x28, 0x52, 0x7b, 0x2a, 0xfa, 0xb1, 0xa9, 0xb9, 0x6d, 0x6b, + 0x4e, 0xc5, 0x8a, 0x02, 0xc3, 0xc7, 0xac, 0xa3, 0xc2, 0xcf, 0x55, 0x7f, 0xf2, 0xa4, 0x91, 0x38, 0x92, 0x2d, 0x51, + 0xc2, 0x52, 0x71, 0x9f, 0xd0, 0x54, 0x19, 0xef, 0xaa, 0x32, 0xea, 0xcb, 0xdb, 0x45, 0x6c, 0x26, 0x4c, 0x72, 0x69, + 0xef, 0xe1, 0xcf, 0x11, 0xf3, 0x52, 0x8b, 0xeb, 0x76, 0x35, 0x89, 0xe9, 0x30, 0x00, 0x6d, 0x64, 0x84, 0x42, 0xf2, + 0x61, 0x0e, 0x1f, 0xaf, 0xff, 0xb2, 0xe2, 0x41, 0x28, 0xa0, 0x8d, 0x4f, 0x9f, 0xec, 0x22, 0x6e, 0xe8, 0xdb, 0xd4, + 0xa3, 0xb8, 0x6d, 0x32, 0x2f, 0x10, 0xa5, 0x1e, 0xd9, 0x9f, 0xf8, 0x18, 0x6a, 0xa7, 0x3e, 0x82, 0x98, 0x14, 0xa9, + 0x62, 0xf0, 0xf6, 0xfc, 0x1b, 0x85, 0x1f, 0x00, 0xb2, 0x6e, 0xc0, 0x8b, 0x17, 0xc5, 0xc7, 0x71, 0x29, 0x3e, 0x8e, + 0xc2, 0xf3, 0x3d, 0x43, 0x66, 0xda, 0x6c, 0x9f, 0xa6, 0x10, 0x05, 0xe6, 0x64, 0xf3, 0xb1, 0x58, 0x05, 0xa9, 0xbf, + 0xf4, 0xe2, 0x74, 0x1f, 0x83, 0xe3, 0x60, 0xb0, 0x9d, 0xa6, 0xf8, 0x15, 0x64, 0x36, 0x22, 0x72, 0xa8, 0xa4, 0xa1, + 0xb0, 0x1b, 0x99, 0xfa, 0x41, 0x6e, 0x36, 0x22, 0x3a, 0xf0, 0xc6, 0x63, 0xb6, 0x4c, 0xdd, 0x52, 0x10, 0x9e, 0x68, + 0x9c, 0xb2, 0xd4, 0x4c, 0xd2, 0x98, 0x79, 0x0b, 0x35, 0x0f, 0xca, 0xb5, 0xd9, 0x5e, 0xb2, 0x1a, 0x41, 0x54, 0x21, + 0x11, 0x1e, 0x8c, 0x06, 0x08, 0x06, 0x1c, 0x00, 0x22, 0x04, 0xc5, 0xa1, 0x29, 0x3c, 0x8b, 0x66, 0x95, 0x2d, 0x55, + 0xb0, 0x54, 0x27, 0x98, 0x4a, 0x8d, 0x6e, 0x5e, 0x20, 0xdd, 0x1e, 0x47, 0xc1, 0x15, 0x8f, 0xb9, 0x91, 0xe7, 0xe4, + 0x51, 0x07, 0xc7, 0xfc, 0x3a, 0xae, 0x60, 0xb8, 0x19, 0xb5, 0x63, 0x43, 0xb2, 0xb8, 0xa6, 0x68, 0x1c, 0xfb, 0xbc, + 0x32, 0xd0, 0x4c, 0x6a, 0x19, 0xf3, 0x7d, 0x12, 0x2c, 0xe7, 0x40, 0xb2, 0x4a, 0x06, 0x3e, 0x73, 0x67, 0x90, 0xbb, + 0x7f, 0x22, 0x54, 0x48, 0xd5, 0x3e, 0x7d, 0x7a, 0x3f, 0xfc, 0xd7, 0x3f, 0x21, 0x29, 0xe9, 0xdc, 0x11, 0x31, 0x30, + 0x2e, 0xe4, 0x5a, 0x9c, 0x2d, 0x36, 0x86, 0x68, 0xdc, 0xc5, 0x26, 0x22, 0x3a, 0xa1, 0xd8, 0x5b, 0xd9, 0xf0, 0x52, + 0xc4, 0xd5, 0x83, 0x74, 0xc6, 0xba, 0x88, 0xd4, 0x31, 0x84, 0xe5, 0x1d, 0x8a, 0x18, 0x2e, 0xca, 0xdf, 0x6e, 0x5f, + 0x1e, 0x29, 0x45, 0xb8, 0xc7, 0x3a, 0x0b, 0x24, 0xda, 0x43, 0x83, 0x63, 0x4f, 0x41, 0x6e, 0x0a, 0xf9, 0xa2, 0xa4, + 0xb7, 0x0f, 0xc3, 0x9c, 0x47, 0x0b, 0x66, 0xf9, 0xd1, 0xfe, 0x0d, 0x1b, 0x99, 0xde, 0xd2, 0x27, 0x3b, 0x22, 0x94, + 0x13, 0x2a, 0xc4, 0x92, 0xe6, 0xe6, 0x39, 0xc4, 0xf8, 0x67, 0xc5, 0x54, 0x46, 0x95, 0xc0, 0x6d, 0xad, 0x42, 0x6f, + 0x79, 0xc0, 0x83, 0xa2, 0x89, 0x9a, 0x83, 0xe3, 0x7d, 0x6f, 0x50, 0xce, 0xcf, 0x63, 0x89, 0x3c, 0xb3, 0x65, 0x2a, + 0x70, 0x42, 0x69, 0x76, 0x44, 0x46, 0x9d, 0xe2, 0xc1, 0x8c, 0xa6, 0x53, 0x39, 0xa7, 0x8e, 0x55, 0x06, 0x2f, 0x9f, + 0xb4, 0x62, 0x4b, 0x47, 0x4b, 0xea, 0x69, 0xb3, 0x8b, 0xfc, 0xa7, 0xda, 0xc3, 0x64, 0x5a, 0x30, 0x66, 0x38, 0xef, + 0x1b, 0xb9, 0x79, 0xf2, 0x19, 0x7b, 0x44, 0x95, 0x38, 0x22, 0xa9, 0x66, 0x82, 0x6c, 0x60, 0xa9, 0xf6, 0x5c, 0x97, + 0xf0, 0x5c, 0x15, 0xdd, 0xc1, 0x24, 0xd6, 0xe4, 0xdc, 0x85, 0xc1, 0xa6, 0xf0, 0xa1, 0x49, 0xee, 0xbd, 0xf8, 0x51, + 0x75, 0x38, 0x9b, 0x30, 0xee, 0x7b, 0x62, 0xfb, 0x95, 0x36, 0x28, 0x6c, 0x3c, 0xbe, 0xee, 0x80, 0xe0, 0x45, 0x3b, + 0x15, 0x3c, 0xaf, 0x7c, 0x4d, 0x28, 0xdd, 0x0c, 0xbc, 0xbb, 0x48, 0x32, 0xbb, 0xe2, 0x11, 0x58, 0xce, 0xb0, 0xf4, + 0x5c, 0x78, 0x3e, 0x6f, 0x1c, 0x34, 0xa4, 0x61, 0x90, 0x9b, 0x74, 0xf3, 0xb0, 0x15, 0x04, 0x38, 0x60, 0xf7, 0x9d, + 0x35, 0xb9, 0x6e, 0x79, 0x30, 0x88, 0x3c, 0xb3, 0xe2, 0x1c, 0x96, 0x5e, 0x22, 0x5a, 0xc8, 0x8e, 0xf7, 0x61, 0x7c, + 0x94, 0x6d, 0x51, 0x30, 0x79, 0xc2, 0xbe, 0x10, 0x6f, 0xbd, 0x7e, 0xd3, 0xad, 0xb7, 0xca, 0xa3, 0x94, 0x59, 0x2f, + 0x5f, 0x87, 0xd8, 0x36, 0x5e, 0x42, 0xb6, 0x67, 0xdf, 0x4f, 0x38, 0x61, 0x90, 0x7a, 0xc9, 0x83, 0x61, 0x96, 0xea, + 0xe9, 0x5b, 0x83, 0xc4, 0x50, 0x9e, 0xdf, 0x0f, 0x2b, 0x4c, 0xf2, 0x9b, 0xf5, 0x53, 0x26, 0x62, 0x36, 0x9c, 0xa5, + 0x0d, 0x61, 0x1d, 0x9a, 0xaa, 0x10, 0x1f, 0xbe, 0xa5, 0x42, 0xb1, 0xcd, 0xb7, 0xd5, 0x2a, 0x38, 0xab, 0xa2, 0x9a, + 0xa7, 0xa9, 0x8f, 0xf0, 0x40, 0x6c, 0xd4, 0xc6, 0x52, 0x0c, 0x36, 0x91, 0xba, 0x50, 0x55, 0xa8, 0x16, 0xbc, 0xe5, + 0x92, 0x2a, 0xeb, 0xfd, 0xe3, 0x7d, 0xba, 0x4e, 0x0f, 0x68, 0x03, 0x0e, 0x8e, 0xc1, 0x32, 0x9d, 0xf6, 0x84, 0xb7, + 0x5c, 0xf2, 0x15, 0xa7, 0x5f, 0xf4, 0x66, 0x7f, 0x9e, 0x2e, 0x82, 0xc1, 0xff, 0x02, 0xf1, 0xc9, 0x8b, 0x98, 0x7c, + 0x7b, 0x03, 0x00}; #else // Brotli (default, smaller) constexpr uint8_t INDEX_BR[] PROGMEM = { - 0x5b, 0x05, 0x7b, 0x53, 0xc1, 0xb6, 0x69, 0x3d, 0x41, 0xeb, 0x04, 0x30, 0xf6, 0xd6, 0x77, 0x35, 0xdb, 0xa3, 0x08, - 0x36, 0x0e, 0x04, 0x80, 0x90, 0x4f, 0xf1, 0xb2, 0x21, 0xa4, 0x82, 0xee, 0x00, 0xaa, 0x20, 0x7f, 0x3b, 0xff, 0x00, - 0xaa, 0x9a, 0x73, 0x74, 0x8c, 0xe1, 0xa6, 0x1f, 0xa0, 0xa2, 0x59, 0xf5, 0xaa, 0x92, 0x79, 0x50, 0x43, 0x1f, 0xe8, - 0x3c, 0x52, 0x68, 0x2c, 0xf6, 0x36, 0x31, 0x90, 0x4e, 0x2b, 0x91, 0x69, 0x38, 0xb4, 0x61, 0xa7, 0xbb, 0x57, 0x79, - 0x3b, 0x6d, 0x62, 0x85, 0x9f, 0x24, 0x24, 0xda, 0x45, 0xc1, 0xe2, 0x85, 0x44, 0x50, 0x6c, 0x8c, 0x1e, 0xab, 0xbb, - 0x7b, 0x1e, 0x30, 0x98, 0x59, 0xe5, 0xb9, 0xd1, 0x34, 0x03, 0x0b, 0x79, 0x37, 0x8c, 0x4b, 0x68, 0x2c, 0xbe, 0x21, - 0xcc, 0x30, 0xc4, 0x9c, 0x4d, 0x34, 0xd6, 0x01, 0xc1, 0xe1, 0x66, 0x48, 0x3c, 0x94, 0xf4, 0x3c, 0x5b, 0x12, 0xfd, - 0x15, 0xb4, 0x05, 0xdb, 0xaf, 0xc1, 0x59, 0x5d, 0x9f, 0xc5, 0x8d, 0xaf, 0xf7, 0xc6, 0x5f, 0x58, 0xfb, 0xc3, 0x74, - 0x45, 0x97, 0x23, 0xac, 0x2e, 0x4a, 0xb9, 0xab, 0xd7, 0x5b, 0xe5, 0x85, 0x6f, 0x21, 0xba, 0x7e, 0xa3, 0x57, 0xa5, - 0xb3, 0x24, 0x7e, 0x60, 0x30, 0x0e, 0x15, 0x1f, 0x04, 0x5e, 0xba, 0x06, 0x04, 0xff, 0x02, 0x26, 0x2d, 0x03, 0x6c, - 0x97, 0x1b, 0x23, 0x84, 0x28, 0x25, 0xb4, 0xe2, 0xca, 0xbd, 0xc8, 0xc3, 0x96, 0x4a, 0xef, 0xed, 0xe7, 0x65, 0x4f, - 0xb2, 0x2d, 0x6c, 0x02, 0x91, 0x04, 0x76, 0xae, 0xba, 0x67, 0x9c, 0xe3, 0x38, 0xd6, 0x96, 0x61, 0x0c, 0xd3, 0x80, - 0xe4, 0x4a, 0x43, 0x2e, 0x92, 0xff, 0xa7, 0xdb, 0xd2, 0xec, 0xf5, 0xf5, 0x90, 0x4a, 0x22, 0x74, 0x92, 0x40, 0x80, - 0xed, 0x2b, 0xb5, 0xbc, 0xb6, 0xe0, 0x71, 0xda, 0x35, 0x3b, 0xe9, 0xec, 0xeb, 0xac, 0xbe, 0x6a, 0x2f, 0xf0, 0xde, - 0x96, 0xf7, 0x43, 0x51, 0x4d, 0xce, 0xbb, 0xd3, 0x70, 0xc2, 0x08, 0x2c, 0xf0, 0xc8, 0xac, 0x25, 0x82, 0x67, 0xeb, - 0xcd, 0xf5, 0x7d, 0x7d, 0xb7, 0x8b, 0xca, 0x2a, 0x15, 0x66, 0x68, 0xf3, 0x6e, 0xb6, 0xda, 0x5b, 0x81, 0x7b, 0x2e, - 0xe6, 0xc1, 0xdc, 0x5e, 0x0a, 0x7a, 0xda, 0x4a, 0x2c, 0x68, 0xa4, 0xd0, 0x92, 0xe7, 0xb2, 0xb3, 0xef, 0xab, 0xda, - 0xd7, 0xef, 0x89, 0xe7, 0x22, 0xd0, 0x93, 0xe5, 0x08, 0xcc, 0xea, 0x82, 0xdb, 0xde, 0x1a, 0xdd, 0x49, 0x47, 0x26, - 0x23, 0xc1, 0x16, 0x7b, 0x2a, 0x99, 0x23, 0xe9, 0x4c, 0xf8, 0x65, 0xaa, 0xfe, 0xe9, 0x6a, 0x41, 0x0d, 0x8f, 0xfb, - 0x48, 0x9a, 0x49, 0x4e, 0x0b, 0x2e, 0x91, 0xfc, 0x76, 0x6e, 0x55, 0xee, 0xb4, 0xbb, 0x3c, 0x63, 0xc1, 0x95, 0x5a, - 0xf8, 0x37, 0x35, 0xb1, 0xaa, 0xcd, 0xa9, 0x99, 0x2f, 0x3d, 0x52, 0xd0, 0x01, 0xd9, 0x06, 0x27, 0x41, 0x92, 0xdd, - 0x6d, 0x4a, 0xee, 0xad, 0xa6, 0x33, 0xe1, 0xff, 0xeb, 0x6b, 0x5a, 0xff, 0xfd, 0xf3, 0x05, 0x0a, 0xbb, 0xc4, 0x55, - 0x1d, 0x28, 0x69, 0x66, 0x7f, 0x4f, 0x49, 0xce, 0xb2, 0xec, 0x8a, 0x0a, 0x15, 0x2d, 0x63, 0x1b, 0x6f, 0x28, 0x00, - 0xf5, 0x44, 0x47, 0xd6, 0xf5, 0xb5, 0xd7, 0x6f, 0xff, 0xf5, 0x0b, 0x3d, 0xd9, 0x1d, 0x42, 0xe9, 0xb3, 0x32, 0xa5, - 0xf6, 0xa3, 0x1b, 0xc3, 0x58, 0x8a, 0x7c, 0x24, 0xc7, 0x03, 0xbc, 0x92, 0xd5, 0x4f, 0xbf, 0xfc, 0xfa, 0x3d, 0x3b, - 0x6d, 0x8c, 0xc5, 0xdb, 0x9d, 0x77, 0x97, 0xd2, 0x5e, 0x82, 0x85, 0x53, 0x9a, 0xaf, 0xb6, 0x09, 0x45, 0x51, 0x25, - 0x90, 0x44, 0x85, 0xa4, 0xc6, 0x23, 0x18, 0xfb, 0xbb, 0x56, 0xef, 0xe9, 0x3c, 0x13, 0x62, 0xf0, 0xaf, 0x6b, 0xe9, - 0xa2, 0xa7, 0x26, 0x32, 0xa1, 0x8d, 0x5d, 0x56, 0x22, 0xa0, 0xe8, 0xb8, 0x06, 0xc1, 0x07, 0xb4, 0x7e, 0xf5, 0xbe, - 0xae, 0xff, 0xfa, 0x95, 0xa7, 0xb8, 0xe2, 0x34, 0x6a, 0xc9, 0x3c, 0xee, 0xb3, 0x45, 0x67, 0xae, 0x93, 0x6c, 0x20, - 0xbc, 0x42, 0xc5, 0xe1, 0x91, 0x52, 0xb9, 0x8c, 0x62, 0x0d, 0x46, 0xbb, 0x46, 0x72, 0x69, 0x26, 0xe0, 0xac, 0x67, - 0xec, 0xb5, 0x51, 0x5f, 0x9d, 0xae, 0x5d, 0x25, 0x15, 0x72, 0xf0, 0xc2, 0x2f, 0x67, 0xe6, 0x68, 0x08, 0xac, 0xdd, - 0xcb, 0xd5, 0xe3, 0x03, 0x9d, 0x49, 0x4d, 0xa1, 0xcd, 0x81, 0xbf, 0x09, 0xd9, 0xdd, 0x2d, 0xe1, 0x7b, 0x7f, 0x9a, - 0x7d, 0xfd, 0x92, 0xd9, 0x26, 0x19, 0x8d, 0x9d, 0x2b, 0x6d, 0x38, 0x99, 0x2b, 0x2d, 0xb5, 0xfa, 0xed, 0xe3, 0xb0, - 0x11, 0x2c, 0x17, 0x79, 0xe0, 0x24, 0xb1, 0x86, 0x68, 0x15, 0xb3, 0x7f, 0x4b, 0xf3, 0xeb, 0xd7, 0xc3, 0xdd, 0x24, - 0x64, 0x69, 0x0f, 0x4e, 0xae, 0xdb, 0xc7, 0x71, 0x4e, 0xaa, 0x64, 0x18, 0xec, 0xd1, 0x7b, 0x59, 0xa8, 0x31, 0x05, - 0x80, 0x2b, 0xcb, 0x0c, 0x91, 0x02, 0x85, 0x0d, 0x16, 0x1d, 0x1b, 0xa6, 0x6e, 0x48, 0x13, 0x04, 0xa1, 0xc5, 0xff, - 0x33, 0xa7, 0xdf, 0xb4, 0x67, 0x98, 0xb0, 0x60, 0xdb, 0x52, 0xfa, 0xaf, 0x26, 0xbf, 0x98, 0xed, 0xc1, 0x7b, 0x18, - 0x2e, 0x02, 0x9e, 0x60, 0x3c, 0xef, 0xff, 0xef, 0xad, 0xb4, 0xdc, 0xfe, 0x88, 0x74, 0x45, 0x88, 0xec, 0x01, 0xc8, - 0x71, 0x86, 0x23, 0xeb, 0xf7, 0x9d, 0x99, 0x55, 0xe0, 0x34, 0x40, 0xb2, 0xc7, 0x99, 0x95, 0xb4, 0xd6, 0x62, 0x53, - 0x71, 0xef, 0x7d, 0xef, 0x32, 0xbf, 0x8b, 0xce, 0xf8, 0x61, 0x58, 0x61, 0x32, 0x1b, 0x69, 0x87, 0x65, 0xbb, 0xb2, - 0x9c, 0x06, 0x00, 0xc1, 0xfb, 0xde, 0xfb, 0x51, 0xf8, 0xff, 0x47, 0x16, 0xe7, 0x47, 0x64, 0x81, 0x8a, 0xcc, 0x2a, - 0xce, 0xc9, 0x2c, 0x60, 0x46, 0x55, 0x00, 0x67, 0x54, 0x00, 0xfb, 0xe8, 0x80, 0x1c, 0x0b, 0x82, 0x46, 0xd3, 0x64, - 0xb3, 0x8f, 0x86, 0x6c, 0x79, 0xbf, 0xd2, 0x62, 0x05, 0x51, 0xae, 0x67, 0x64, 0x5b, 0xf2, 0xbb, 0x1d, 0x28, 0x6b, - 0x96, 0xd2, 0x4a, 0x47, 0xff, 0xeb, 0x96, 0xeb, 0x89, 0x6e, 0x15, 0x9f, 0x68, 0xa7, 0xdc, 0x30, 0x8c, 0xfc, 0x9f, - 0xc0, 0x23, 0xe1, 0x0c, 0x4e, 0xc3, 0x69, 0xa8, 0xc2, 0xd5, 0xa0, 0xa6, 0x5b, 0xc7, 0x8e, 0xb3, 0xe9, 0x34, 0x54, - 0xfd, 0x31, 0xfc, 0x1e, 0x4a, 0x0b, 0x83, 0xa9, 0x33, 0x4d, 0xd3, 0x7f, 0x77, 0x8d, 0x72, 0x55, 0x71, 0x4d, 0x74, - 0xe3, 0xaa, 0x55, 0x19, 0xf8, 0x9b, 0xa6, 0x69, 0xf8, 0x87, 0xe4, 0x6e, 0x5f, 0xac, 0xf9, 0x3d, 0xda, 0x61, 0xa0, - 0x50, 0xca, 0x2e, 0x93, 0x38, 0x3e, 0xe4, 0x5b, 0x96, 0x25, 0xd9, 0xf0, 0x75, 0x2c, 0xad, 0x37, 0x37, 0x2a, 0x15, - 0x48, 0xe8, 0xbd, 0xf6, 0x19, 0xb5, 0x55, 0xf0, 0x10, 0x5d, 0x14, 0x49, 0xa4, 0xa7, 0xff, 0xa7, 0xaa, 0x7a, 0xbc, - 0x43, 0xa6, 0x66, 0xdd, 0xd8, 0xc4, 0x05, 0xf2, 0xe9, 0x1b, 0x34, 0x4f, 0x12, 0x1a, 0x1b, 0xac, 0xf3, 0x32, 0x32, - 0xad, 0x2b, 0xeb, 0xd8, 0x29, 0x4e, 0xfe, 0x6f, 0x80, 0xa1, 0x0d, 0xad, 0x4a, 0xc2, 0xb6, 0x83, 0xa0, 0xff, 0x50, - 0x68, 0x62, 0xad, 0x71, 0x00, 0x77, 0x5a, 0x66, 0xb4, 0x05, 0x9d, 0x01, 0x51, 0x9c, 0xbb, 0xf8, 0xd3, 0x64, 0x82, - 0x69, 0xb6, 0x87, 0x82, 0xef, 0x73, 0x14, 0x62, 0x0c, 0x22, 0xcb, 0x73, 0xdb, 0xb3, 0x0f, 0x4c, 0xfe, 0x77, 0x60, - 0x45, 0xff, 0x14, 0xea, 0x9f, 0xb0, 0x02, 0xa0, 0x6e, 0xbd, 0xe4, 0xd7, 0x0a, 0xbc, 0xe7, 0xf7, 0x00, 0x44, 0x26, - 0xc2, 0x96, 0xe5, 0x37, 0xda, 0x32, 0xca, 0xef, 0x42, 0xb4, 0xdd, 0xe2, 0x70, 0xf0, 0xe0, 0xc1, 0x93, 0xd4, 0xed, - 0x18, 0xbf, 0x54, 0xdd, 0xb0, 0x5b, 0x86, 0x3e, 0x46, 0x7f, 0xbe, 0x1b, 0x61, 0x83, 0x52, 0xe1, 0x6a, 0x62, 0x7a, - 0xa6, 0x7e, 0x6f, 0xc4, 0x5b, 0x63, 0x55, 0x1a, 0xb8, 0xa5, 0x87, 0xd9, 0x84, 0xd4, 0xcf, 0xd7, 0xd4, 0x62, 0x26, - 0x28, 0x13, 0x41, 0x44, 0xca, 0x76, 0xf6, 0x21, 0x63, 0x4b, 0xae, 0xaf, 0xe7, 0xc7, 0x4d, 0x79, 0x5d, 0x8f, 0xc3, - 0x04, 0x0d, 0xc9, 0x06, 0x39, 0xa0, 0xd8, 0xde, 0x87, 0xb3, 0xd7, 0x4b, 0x65, 0x4e, 0xa3, 0xa6, 0xff, 0x79, 0xab, - 0x0c, 0xed, 0xb4, 0x01, 0x69, 0xdc, 0xa6, 0x15, 0x98, 0x98, 0x82, 0xc8, 0x86, 0x4d, 0x06, 0x77, 0xbd, 0x39, 0x6c, - 0x73, 0x52, 0x73, 0x48, 0xd6, 0xe4, 0x8a, 0x4a, 0x83, 0x9a, 0xf5, 0xc9, 0xeb, 0xa5, 0x2e, 0xa9, 0x70, 0x4b, 0x9d, - 0xb9, 0xbe, 0xc8, 0xe4, 0x9e, 0x17, 0xf2, 0xca, 0x95, 0x97, 0xd3, 0x14, 0xd8, 0x9b, 0xef, 0xb1, 0xaf, 0x13, 0x9a, - 0xf5, 0x3d, 0x8f, 0x74, 0xe3, 0x5d, 0x47, 0x07, 0x26, 0xd2, 0x60, 0xa2, 0xef, 0x11, 0x34, 0x2f, 0x6e, 0xb3, 0xfe, - 0xf0, 0xa3, 0x42, 0x7d, 0xfb, 0x47, 0x5c, 0x75, 0x98, 0x0f, 0xe6, 0x0f, 0x4e, 0xe5, 0x67, 0x1a, 0x4f, 0x93, 0x47, - 0x5f, 0x04, 0x7c, 0xff, 0x7a, 0xf9, 0x79, 0x6b, 0x46, 0x47, 0xc6, 0x0c, 0xd5, 0x90, 0x30, 0xd5, 0xfc, 0xde, 0x8b, - 0xd5, 0x65, 0x7f, 0x87, 0x2d, 0x9a, 0xd5, 0xe4, 0x3f, 0xf9, 0xed, 0x07, 0xfb, 0xa2, 0xb6, 0xd3, 0xe1, 0xbf, 0xbd, - 0x40, 0x78, 0x7a, 0x67, 0x24, 0x0b, 0x6b, 0x0e, 0xed, 0xcf, 0x7a, 0x6a, 0x7c, 0xdb, 0x36, 0x61, 0x5b, 0xc3, 0x7c, - 0x5d, 0xfc, 0xf6, 0x1c, 0xc2, 0xa9, 0xba, 0x12, 0x15, 0x35, 0x11, 0x87, 0x41, 0x13, 0xa5, 0xe5, 0x73, 0x07, 0xfa, - 0xc6, 0xe3, 0x96, 0x43, 0x01, 0x76, 0x4b, 0x53, 0x28, 0xad, 0x09, 0x7e, 0x88, 0x0f, 0x26, 0x90, 0x04, 0xfd, 0x2a, - 0x0d, 0x4c, 0xcd, 0x5c, 0xbf, 0x10, 0xd6, 0x47, 0xaf, 0xe2, 0x12, 0x80, 0x00, 0x59, 0xaa, 0x9b, 0x18, 0x58, 0x96, - 0xc8, 0x80, 0x67, 0xc2, 0xb9, 0x4e, 0x5d, 0x86, 0x1e, 0x79, 0xf5, 0xcf, 0xb0, 0x81, 0x1f, 0x9e, 0x4f, 0x34, 0x1d, - 0x7c, 0xf2, 0x4a, 0x4d, 0xbd, 0x42, 0x06, 0xbc, 0x73, 0x56, 0xbc, 0x9d, 0x43, 0xa9, 0xe6, 0x44, 0x0c, 0xcd, 0xcd, - 0xe4, 0x4e, 0xde, 0xb3, 0x0e, 0x25, 0x35, 0xb6, 0xb6, 0xf6, 0xcc, 0xae, 0x6f, 0x91, 0x82, 0x59, 0xa1, 0xdc, 0x8b, - 0xaa, 0x4f, 0x64, 0x26, 0xd0, 0xa5, 0xe7, 0x38, 0xf3, 0xf5, 0xcd, 0x4f, 0x05, 0x62, 0x8c, 0x38, 0xc3, 0x96, 0x13, - 0x68, 0xb2, 0xe4, 0xd9, 0xcf, 0x4a, 0x5f, 0x44, 0x57, 0xf6, 0x49, 0x47, 0xae, 0x16, 0x81, 0xa1, 0xa7, 0x2d, 0xd8, - 0xb3, 0x35, 0x74, 0x6a, 0xc2, 0xbc, 0xc0, 0x7d, 0xae, 0xf0, 0x88, 0xe4, 0xd0, 0x28, 0x7c, 0x22, 0x98, 0x95, 0xa3, - 0x2a, 0x81, 0x16, 0x0b, 0xc7, 0x4a, 0xf3, 0x07, 0xb8, 0xa1, 0x56, 0xbf, 0xdf, 0x36, 0x6b, 0xa3, 0x84, 0x8b, 0xbf, - 0x24, 0x99, 0xc1, 0x09, 0x7e, 0xff, 0x99, 0x8c, 0x1c, 0xd1, 0x43, 0x7c, 0xb1, 0x46, 0x9d, 0x2e, 0x65, 0x92, 0xa9, - 0xa0, 0xd0, 0x45, 0x92, 0x47, 0x37, 0x9c, 0x3c, 0x5f, 0xf1, 0xf3, 0x0d, 0x1e, 0x37, 0xeb, 0x3d, 0xb6, 0x7c, 0x33, - 0x35, 0xaf, 0xf3, 0x08, 0x54, 0x33, 0x56, 0x02, 0x4f, 0x18, 0xda, 0xc0, 0xbb, 0xc5, 0x4a, 0x42, 0xd7, 0xef, 0xbd, - 0xa4, 0xec, 0x61, 0x77, 0x1b, 0xa2, 0x57, 0x47, 0x00, 0xee, 0x8b, 0xd3, 0x56, 0xd4, 0xbd, 0x01, 0xa2, 0x8f, 0xee, - 0xef, 0xb1, 0xac, 0xe1, 0x03, 0x87, 0x8d, 0x2b, 0x3c, 0x8e, 0x15, 0x84, 0x96, 0xb4, 0xfe, 0x56, 0xd5, 0x1e, 0xc0, - 0x83, 0x66, 0x79, 0x15, 0x8a, 0x60, 0xb7, 0x15, 0x21, 0x3b, 0xce, 0x44, 0x71, 0x9f, 0x6f, 0xe0, 0x70, 0xde, 0xb8, - 0x7a, 0x74, 0x43, 0xcd, 0x4d, 0x7a, 0x90, 0xd2, 0x4b, 0x9b, 0xc8, 0x6a, 0xa2, 0xc6, 0xdf, 0x1a, 0x55, 0x85, 0x34, - 0xdd, 0x1d, 0x1a, 0x7c, 0x88, 0x7c, 0x81, 0x3d, 0xd8, 0x12, 0xf4, 0x32, 0x8d, 0xc6, 0xc1, 0x56, 0x0d, 0xe5, 0x8d, - 0x75, 0x00, 0x03, 0x61, 0x93, 0xa0, 0x44, 0x06, 0x5b, 0x67, 0x8b, 0x58, 0xf5, 0x9c, 0xb0, 0x79, 0x7f, 0xbd, 0x2e, - 0xb1, 0x17, 0xb3, 0x85, 0xcd, 0x54, 0x5f, 0xc8, 0x38, 0xeb, 0xa7, 0xcd, 0xfe, 0xbf, 0x2d, 0x80, 0x83, 0x12, 0xc3, - 0x0b, 0x02, 0x41, 0x44, 0xd5, 0x07, 0xe5, 0xcd, 0xb0, 0x24, 0x2c, 0x0a, 0x6c, 0x1b, 0x1f, 0xb9, 0x7b, 0x48, 0x9e, - 0x55, 0x42, 0x7c, 0x2b, 0x63, 0xd3, 0xd1, 0x76, 0x18, 0x61, 0xa8, 0x86, 0x2d, 0x11, 0x5a, 0x41, 0x04, 0x6c, 0xea, - 0xcf, 0x34, 0xf6, 0x71, 0xe7, 0xda, 0x41, 0xba, 0x28, 0xcb, 0x2c, 0x1c, 0x47, 0x50, 0xa7, 0x83, 0x41, 0xad, 0x84, - 0x9e, 0xec, 0x1e, 0xfc, 0xc6, 0xc6, 0xb8, 0xa0, 0xb8, 0xa1, 0x70, 0xeb, 0x5a, 0x9f, 0x46, 0x06, 0xa6, 0xf8, 0x72, - 0xa5, 0xff, 0xfe, 0x80, 0x1e, 0x07, 0xbb, 0xd4, 0x48, 0xf9, 0x6c, 0xd6, 0x13, 0xf8, 0xee, 0x86, 0x06, 0x67, 0x78, - 0x38, 0xf2, 0x83, 0xc3, 0x3b, 0x25, 0xf0, 0xa0, 0x60, 0x56, 0xbe, 0x7d, 0x10, 0x8a, 0xd4, 0x17, 0x81, 0x0e, 0x17, - 0x5f, 0x53, 0xaf, 0x87, 0x23, 0xe4, 0x56, 0xe4, 0xb1, 0xc0, 0x9d, 0xc8, 0x38, 0x25, 0x47, 0x18, 0x18, 0x27, 0x57, - 0xdf, 0x84, 0xcd, 0x7c, 0xdb, 0x31, 0xff, 0xc2, 0xe5, 0x83, 0x03, 0xd1, 0xac, 0x9f, 0x2d, 0xd8, 0xa5, 0xa4, 0x40, - 0xe0, 0x1e, 0xe9, 0x18, 0x3d, 0x85, 0xa9, 0x1b, 0x9c, 0xa6, 0x14, 0x51, 0x9a, 0x88, 0xd9, 0x42, 0x70, 0x6c, 0x6b, - 0x63, 0x2e, 0xfd, 0x46, 0xd9, 0xd5, 0xb4, 0xd9, 0x8f, 0x2c, 0xf8, 0x42, 0xf9, 0xb6, 0x27, 0xb8, 0x69, 0xc5, 0xed, - 0x5c, 0xea, 0xff, 0x76, 0x9d, 0x49, 0x1b, 0x5a, 0xf7, 0xaa, 0x2d, 0x04, 0x36, 0x45, 0x05, 0xa8, 0x99, 0x5e, 0x24, - 0x53, 0x3b, 0x89, 0xd9, 0x0f, 0x4d, 0x24, 0x27, 0x44, 0x2b, 0xfb, 0x3b, 0xd9, 0x8b, 0x36, 0xe9, 0x18, 0x4a, 0x30, - 0xfc, 0xc8, 0xa5, 0xf4, 0xd5, 0xd5, 0x72, 0x2d, 0x3b, 0x5f, 0xc3, 0x4e, 0x98, 0x0c, 0x08, 0xb2, 0xff, 0x59, 0x68, - 0x6b, 0xc0, 0xe4, 0x52, 0x8f, 0x29, 0x78, 0x74, 0x6d, 0xbe, 0xfb, 0x13, 0x8a, 0x25, 0x0b, 0x31, 0xe5, 0xd0, 0xae, - 0xbf, 0x1e, 0x13, 0x23, 0xa0, 0x2c, 0x89, 0x10, 0x6e, 0xa5, 0x1c, 0xa8, 0x7f, 0xbf, 0x62, 0x52, 0xb0, 0xa5, 0xf6, - 0x46, 0x9c, 0x5d, 0xbd, 0xac, 0x81, 0xc4, 0x46, 0xf3, 0x61, 0x62, 0x47, 0x08, 0x27, 0x4d, 0xed, 0x4b, 0x45, 0x91, - 0x48, 0xcf, 0x52, 0xc4, 0x20, 0xe3, 0x8a, 0xe9, 0x12, 0x2d, 0xac, 0x99, 0xb0, 0x1c, 0x1b, 0x91, 0xa4, 0xcc, 0x6d, - 0x11, 0x3f, 0xbe, 0xe9, 0x06, 0x24, 0x40, 0x3d, 0x62, 0x90, 0x0f, 0xbe, 0x25, 0x20, 0xd7, 0x25, 0x09, 0xca, 0xb5, - 0xcf, 0x25, 0x64, 0x42, 0x3b, 0x19, 0x09, 0x13, 0xf3, 0x46, 0x90, 0x72, 0xf7, 0x74, 0x4a, 0xd7, 0x00, 0x4b, 0x39, - 0x59, 0xcd, 0x21, 0x62, 0xe4, 0x78, 0x5d, 0x75, 0xb5, 0x80, 0x58, 0x0a, 0xb7, 0xa3, 0xed, 0xc8, 0xe4, 0x5c, 0xdc, - 0xa1, 0xf3, 0xce, 0x99, 0x5f, 0x18, 0xa7, 0x1c, 0x9c, 0x1e, 0xe6, 0x2e, 0x20, 0x20, 0xa7, 0xae, 0xfa, 0xc1, 0x19, - 0x19, 0xa4, 0xb8, 0x9a, 0x77, 0x5a, 0x24, 0x9a, 0x11, 0xf9, 0xac, 0x18, 0xaa, 0xdb, 0x2a, 0x37, 0xc2, 0x62, 0xad, - 0x5c, 0x82, 0x29, 0x72, 0x72, 0x1b, 0x7c, 0xb3, 0x83, 0xc7, 0xcd, 0x13, 0x16, 0xc0, 0x59, 0x8f, 0xe5, 0x62, 0xc2, - 0xa1, 0xea, 0x36, 0x7e, 0x0d, 0x64, 0x0a, 0xbc, 0x72, 0xd4, 0x59, 0x92, 0xe3, 0x0b, 0x0d, 0xaa, 0x81, 0xbf, 0xf6, - 0x91, 0xe7, 0x41, 0x6e, 0x50, 0x35, 0xd5, 0x34, 0x8b, 0x42, 0x4f, 0x31, 0xcf, 0x84, 0xcc, 0x5f, 0x35, 0x8a, 0x4e, - 0xc2, 0x8c, 0xa7, 0xc9, 0x96, 0x3a, 0xdd, 0xa7, 0x32, 0xa1, 0x80, 0xd8, 0x43, 0xe0, 0x14, 0xb8, 0xf7, 0xa6, 0xc2, - 0x3c, 0x9d, 0x92, 0x49, 0x1c, 0x9e, 0xcc, 0xb3, 0x59, 0x03, 0x66, 0xc0, 0x8d, 0x12, 0xe8, 0xe6, 0x8c, 0xfc, 0x70, - 0x0b, 0xb7, 0x55, 0x71, 0x1e, 0x93, 0x15, 0x68, 0xc9, 0x4d, 0x04, 0xc9, 0xf0, 0xca, 0xb8, 0x80, 0xd2, 0x7b, 0x13, - 0x67, 0xc6, 0xdd, 0xe2, 0xab, 0x8a, 0x4f, 0xc0, 0x79, 0xdc, 0x97, 0xdb, 0x8a, 0x53, 0x9a, 0x2a, 0x1c, 0x80, 0x92, - 0x17, 0xc4, 0x63, 0xe1, 0x9b, 0xd3, 0x2b, 0x99, 0x61, 0xe0, 0x62, 0x46, 0x35, 0x15, 0xdd, 0x85, 0x74, 0xc2, 0x74, - 0x90, 0xf0, 0x96, 0x34, 0x06, 0x77, 0x40, 0xf1, 0xbe, 0x00, 0x14, 0x11, 0x8e, 0xc2, 0x77, 0x76, 0x4c, 0x47, 0xab, - 0x92, 0xf0, 0x68, 0x99, 0x2d, 0xda, 0x79, 0xf9, 0x46, 0x25, 0xab, 0x1c, 0x70, 0x34, 0x00, 0x6c, 0x5e, 0x7f, 0x48, - 0x7c, 0x06, 0x81, 0x1c, 0x1f, 0x27, 0x76, 0x3e, 0x34, 0x4d, 0x95, 0xc2, 0x9f, 0x8d, 0xf6, 0x26, 0x2c, 0x70, 0xc7, - 0x29, 0x13, 0x3a, 0x1e, 0x1b, 0xd1, 0x0d, 0x41, 0xe7, 0x5f, 0xb0, 0x03, 0xb6, 0xda, 0x66, 0x7b, 0xbf, 0x7a, 0xbd, - 0x2c, 0x4e, 0x0e, 0x98, 0xe4, 0x9d, 0xcd, 0xbd, 0xb7, 0xdb, 0xf9, 0x2f, 0x27, 0x1f, 0x99, 0xb0, 0x40, 0xe1, 0x55, - 0x4e, 0x59, 0x64, 0x24, 0x3a, 0xa0, 0xc4, 0x2b, 0x4d, 0xe7, 0x62, 0x74, 0x2d, 0x12, 0xaf, 0x4a, 0xb1, 0x2b, 0x24, - 0xa9, 0x61, 0xe6, 0x0d, 0x38, 0xc8, 0x66, 0x9d, 0xa6, 0x46, 0x41, 0x11, 0xb2, 0xcc, 0xc5, 0xc6, 0x2c, 0xb1, 0x58, - 0xf3, 0x96, 0x33, 0x6d, 0x4e, 0x61, 0x44, 0xe0, 0xe4, 0x80, 0xa8, 0xfe, 0xac, 0xd6, 0xd8, 0xe0, 0xd6, 0xf3, 0x6a, - 0x18, 0x61, 0xe0, 0xdd, 0x50, 0x92, 0xf2, 0xc4, 0x18, 0x2b, 0x21, 0xc9, 0xa9, 0x23, 0x8e, 0xfd, 0xc8, 0xf2, 0x15, - 0xdf, 0xef, 0xb9, 0xc6, 0x14, 0x97, 0x07, 0x13, 0x63, 0x16, 0x43, 0xa6, 0x76, 0x83, 0xca, 0x22, 0xa9, 0x9f, 0x8e, - 0x6a, 0x59, 0x39, 0x93, 0x7b, 0x0c, 0xf7, 0x51, 0xe7, 0x92, 0xbc, 0x7b, 0x74, 0x05, 0x01, 0xd9, 0x5d, 0x82, 0xd5, - 0x27, 0x87, 0x24, 0x8a, 0x9c, 0xb0, 0x9f, 0xeb, 0x5f, 0x56, 0x23, 0x7f, 0x95, 0x63, 0x29, 0x5f, 0x0f, 0x79, 0x4b, - 0x19, 0x62, 0x2a, 0xad, 0xe5, 0x9e, 0x53, 0x90, 0x71, 0xe9, 0xb2, 0x6c, 0xf1, 0x40, 0x2c, 0x21, 0x7c, 0xb0, 0x98, - 0x7d, 0xde, 0x2e, 0x1c, 0xc8, 0xa4, 0x50, 0x7e, 0xdf, 0xe5, 0xf2, 0xfc, 0xf0, 0xc9, 0x46, 0x2d, 0xc6, 0x18, 0xa7, - 0x54, 0x5b, 0xdf, 0x38, 0xa8, 0x15, 0xa3, 0x0d, 0x3c, 0xbf, 0xb0, 0xdd, 0x6e, 0x6f, 0xd9, 0x2b, 0x20, 0x2b, 0x6b, - 0x2b, 0x70, 0x53, 0x27, 0x9d, 0x1f, 0x36, 0xc2, 0x49, 0x13, 0xca, 0x40, 0x7a, 0x39, 0x43, 0x2d, 0x90, 0x44, 0x37, - 0x45, 0x2d, 0x8c, 0x16, 0xeb, 0x5b, 0x8e, 0xbe, 0xf5, 0x6b, 0x18, 0x32, 0x4a, 0xe9, 0x98, 0xa6, 0xc3, 0xbd, 0x2e, - 0xd9, 0x77, 0x11, 0x44, 0x8b, 0x6c, 0xd6, 0x6c, 0xc3, 0x2f, 0x52, 0xae, 0xbd, 0x10, 0xde, 0x92, 0xe6, 0x28, 0xf2, - 0xce, 0x11, 0xa9, 0xa5, 0x70, 0xaa, 0xa9, 0xc7, 0x24, 0x1c, 0xbb, 0x04, 0x5a, 0x29, 0x98, 0xee, 0xe1, 0xc5, 0x4e, - 0xb6, 0xa1, 0xc2, 0x22, 0x2d, 0x04, 0x69, 0x14, 0x73, 0xf8, 0xfd, 0x20, 0x11, 0x59, 0x06, 0xd8, 0x79, 0xd4, 0xe7, - 0xc0, 0x1e, 0x0e, 0xe3, 0xd1, 0x55, 0x11, 0xfb, 0xee, 0x0f, 0x13, 0x14, 0x62, 0x9d, 0xa6, 0x09, 0x0a, 0xf7, 0x7c, - 0x84, 0x8e, 0xcd, 0x31, 0x3f, 0x9d, 0x85, 0xb6, 0x7d, 0xb3, 0x7a, 0x28, 0xbc, 0xfc, 0x2e, 0xc5, 0x3e, 0xa5, 0xb7, - 0xf3, 0x2f, 0xd3, 0x39, 0xc5, 0x3c, 0xb3, 0xeb, 0x14, 0x53, 0xa1, 0x89, 0xcd, 0x85, 0x97, 0x28, 0x12, 0xe7, 0xc3, - 0x4b, 0x69, 0xe0, 0xcd, 0xd0, 0x29, 0x91, 0x60, 0xdc, 0x08, 0x4f, 0x62, 0x14, 0x61, 0x0d, 0x98, 0xee, 0xe6, 0x3d, - 0x3b, 0xa9, 0x38, 0x77, 0xca, 0x92, 0x2b, 0xba, 0xfb, 0xb9, 0x41, 0x00, 0x40, 0xc5, 0xce, 0xe3, 0x0b, 0x9f, 0x2c, - 0xaf, 0xe5, 0xf4, 0x1c, 0x57, 0x86, 0x10, 0x5a, 0x1a, 0x71, 0x42, 0xb0, 0x91, 0x4a, 0x60, 0x5b, 0x61, 0xb9, 0x53, - 0x25, 0x4a, 0x06, 0x7d, 0x60, 0x8c, 0xec, 0x0e, 0x8a, 0x13, 0xd9, 0x10, 0xda, 0x9a, 0x8e, 0x08, 0x62, 0xe6, 0x7f, - 0x1f, 0xe4, 0x0c, 0xf0, 0xf8, 0xad, 0x64, 0x48, 0x85, 0x00, 0xcd, 0x1c, 0x2f, 0x2f, 0x03, 0x76, 0x31, 0xc4, 0x95, - 0xb2, 0xb8, 0x00, 0xc4, 0x66, 0x1f, 0x9b, 0x71, 0x90, 0xfb, 0x41, 0xea, 0x1c, 0xd6, 0x65, 0x07, 0xa2, 0xd2, 0x0b, - 0xe1, 0xf9, 0x35, 0x0d, 0xd5, 0xa4, 0x09, 0xe3, 0x75, 0xcc, 0x20, 0x66, 0x45, 0x69, 0x23, 0x05, 0x61, 0x95, 0x82, - 0x43, 0x60, 0x8e, 0x34, 0xcb, 0x84, 0xfa, 0xd2, 0x22, 0xc1, 0x1b, 0x0a, 0x01, 0xdb, 0xf1, 0x5a, 0xa2, 0x01, 0x1c, - 0x18, 0xb3, 0x61, 0x87, 0xa4, 0x95, 0x05, 0xf5, 0x40, 0xf9, 0x59, 0x5f, 0x78, 0x3c, 0x23, 0x99, 0xf0, 0xc1, 0x03, - 0x47, 0xf3, 0xa5, 0xa8, 0xe7, 0xe6, 0x44, 0x4b, 0xda, 0xe4, 0x10, 0x7f, 0x22, 0xaa, 0xb5, 0x68, 0xc5, 0x03, 0x5f, - 0x01, 0xa8, 0x90, 0xa6, 0x82, 0x4a, 0x64, 0x49, 0x59, 0x54, 0x64, 0x1e, 0x4c, 0xcc, 0xb5, 0x05, 0x56, 0xd6, 0xa8, - 0x77, 0xfd, 0x08, 0x7e, 0xa6, 0x84, 0x4a, 0xad, 0x3b, 0xc4, 0x3f, 0x65, 0xfc, 0x35, 0x84, 0x14, 0x99, 0x9f, 0x19, - 0xb9, 0xcb, 0x63, 0x9e, 0x3d, 0xb2, 0xfa, 0xb7, 0x7d, 0x1b, 0x8e, 0x2e, 0xdc, 0x63, 0x92, 0xab, 0xf7, 0x48, 0x64, - 0x64, 0x33, 0x01, 0x41, 0xb7, 0xf1, 0x7a, 0x38, 0x4a, 0xc5, 0x1f, 0x9b, 0x14, 0x6f, 0xab, 0x0a, 0xa2, 0xf6, 0xa2, - 0x85, 0x54, 0x93, 0x9e, 0x03, 0x69, 0xd0, 0x9c, 0x34, 0x6a, 0xd0, 0xb5, 0x07, 0x2a, 0x54, 0x04, 0xe5, 0x8f, 0x0e, - 0x27, 0x26, 0xca, 0xf0, 0x14, 0xee, 0x2f, 0x4c, 0x46, 0x98, 0x39, 0x02, 0x55, 0xed, 0xa2, 0xe5, 0xf3, 0x16, 0x63, - 0x02, 0x79, 0xef, 0xfe, 0x0d, 0xf3, 0x23, 0xaa, 0x61, 0xb3, 0xe5, 0xbf, 0xf9, 0x33, 0x4f, 0x29, 0x2a, 0x27, 0xc2, - 0x54, 0x48, 0xa8, 0xb1, 0xb3, 0xa4, 0xd0, 0xa3, 0x8b, 0x58, 0xc3, 0x2c, 0x3f, 0x59, 0x73, 0x75, 0x63, 0x08, 0x19, - 0x23, 0x10, 0x9c, 0x21, 0xc4, 0xa8, 0xf2, 0x44, 0x39, 0x78, 0x9b, 0x00, 0xb8, 0x04, 0xf5, 0x18, 0x2c, 0x73, 0xfb, - 0x12, 0xa1, 0xb9, 0x9c, 0xf7, 0x1f, 0x71, 0x29, 0x19, 0x29, 0x7e, 0x96, 0x97, 0x59, 0xf7, 0x52, 0x79, 0x2a, 0x6e, - 0x5d, 0xd1, 0x56, 0xdc, 0x06, 0x0f, 0x98, 0x82, 0x3e, 0xca, 0x4a, 0x6d, 0xf4, 0x69, 0x16, 0x35, 0xbb, 0x64, 0xdb, - 0x63, 0x77, 0x63, 0x79, 0x52, 0x70, 0x27, 0xbc, 0x84, 0x69, 0x19, 0x4a, 0xdd, 0x36, 0x5e, 0x8a, 0x7d, 0x38, 0xc7, - 0x79, 0xf9, 0x2d, 0x53, 0xbc, 0xff, 0x8e, 0xe2, 0xd3, 0xd7, 0x2c, 0xcd, 0x30, 0x3f, 0x3a, 0x2a, 0x94, 0xc0, 0xcc, - 0x7a, 0xac, 0xe6, 0x51, 0x12, 0x6b, 0x28, 0x40, 0x87, 0x05, 0x43, 0x7b, 0xbc, 0xde, 0x82, 0x78, 0xa8, 0xb2, 0x1e, - 0x2c, 0xbc, 0x27, 0x33, 0x74, 0xb5, 0xa4, 0x0d, 0xad, 0x6b, 0xa2, 0xa2, 0x4f, 0xef, 0x91, 0xc5, 0xdd, 0xfa, 0x38, - 0x4e, 0x9a, 0x18, 0x15, 0x97, 0xa2, 0xdd, 0x59, 0x37, 0x5e, 0x84, 0x99, 0x78, 0x8c, 0x9c, 0x11, 0x19, 0x6b, 0xe9, - 0x8c, 0x96, 0xdc, 0xba, 0x04, 0x99, 0x4f, 0x06, 0x7a, 0x27, 0x50, 0x7e, 0xfe, 0x28, 0x07, 0x8e, 0x08, 0x00, 0xb5, - 0xdb, 0x16, 0x84, 0x2c, 0x38, 0xf0, 0xbf, 0x2c, 0xb7, 0x7d, 0x9f, 0xbc, 0xb3, 0x7c, 0x31, 0x2b, 0xe0, 0x7c, 0x63, - 0xa3, 0xbd, 0x11, 0xb7, 0xb3, 0x91, 0x0d, 0x1d, 0x4f, 0x24, 0xd4, 0x1f, 0x66, 0xe6, 0xd9, 0x31, 0x1f, 0x18, 0x09, - 0xce, 0x46, 0x47, 0x60, 0xbb, 0x2a, 0x73, 0xfd, 0x37, 0x49, 0xde, 0x59, 0x12, 0x23, 0x5c, 0x51, 0x81, 0xac, 0x1e, - 0x64, 0x41, 0xb0, 0xb8, 0xa5, 0x2b, 0xba, 0xde, 0xe7, 0x15, 0x89, 0x36, 0x91, 0x29, 0xb9, 0x1e, 0x20, 0xa0, 0xab, - 0x57, 0x3d, 0x3e, 0x55, 0xf7, 0xc6, 0xfb, 0x52, 0xe3, 0x85, 0xf6, 0xf7, 0xb5, 0x9d, 0x3a, 0xdc, 0xbc, 0xe7, 0x7a, - 0x9e, 0xf6, 0x8f, 0x12, 0x75, 0x7a, 0x2d, 0x32, 0x9e, 0x87, 0xfb, 0x35, 0x94, 0xd3, 0x48, 0x35, 0x69, 0x25, 0xa1, - 0xb8, 0x11, 0xcb, 0xbc, 0xe3, 0x96, 0x07, 0xbc, 0x0a, 0xbf, 0x69, 0xb5, 0x10, 0xef, 0x7d, 0x64, 0x58, 0x9e, 0x7a, - 0x77, 0x34, 0x61, 0xf6, 0x50, 0x2b, 0xbb, 0x29, 0x26, 0x60, 0x3f, 0xad, 0xfe, 0xc9, 0xae, 0xca, 0x17, 0x17, 0xf3, - 0x3f, 0xbb, 0xae, 0xb2, 0x91, 0xf1, 0x64, 0x9a, 0xf5, 0xdd, 0xf3, 0xf9, 0x87, 0x3f, 0x7b, 0x1a, 0x4e, 0xe6, 0xc3, - 0x2c, 0xda, 0x7f, 0x29, 0xbf, 0x2c, 0x1a, 0x42, 0x5b, 0xfe, 0xe8, 0x2c, 0xf5, 0xcc, 0x42, 0xef, 0x85, 0x00, 0x3e, - 0x2d, 0xda, 0x1d, 0x1c, 0xd5, 0x74, 0x3d, 0x8c, 0xf7, 0x41, 0x0b, 0xb7, 0x2e, 0x77, 0x20, 0xce, 0x6c, 0xc4, 0x49, - 0x7e, 0x51, 0xb3, 0xeb, 0x5d, 0x36, 0xb3, 0x69, 0x97, 0xbf, 0x23, 0x08, 0x9f, 0x4d, 0x90, 0xd1, 0x3a, 0xd6, 0x36, - 0xa9, 0xbc, 0x0a, 0x2c, 0xff, 0x8d, 0xe4, 0xd3, 0xb9, 0x36, 0x8c, 0x6e, 0x05, 0xfb, 0xf9, 0xa7, 0xf0, 0x6d, 0xd5, - 0x77, 0xc7, 0x9d, 0xff, 0x7c, 0xba, 0x5f, 0xfd, 0x3b, 0xd5, 0x93, 0xb9, 0x59, 0xf4, 0xde, 0xf3, 0xe0, 0xfb, 0xd3, - 0xa0, 0xdb, 0xb6, 0x7a, 0xb2, 0x7d, 0xfc, 0x7f, 0x17, 0xef, 0xff, 0x27, 0xfa, 0xfa, 0x15, 0x7b, 0x64, 0x3e, 0xf4, - 0xe2, 0xf8, 0x6c, 0xea, 0xe2, 0xf2, 0xff, 0x5c, 0xab, 0xdb, 0x3b, 0xcf, 0x6a, 0xb4, 0x6b, 0x6e, 0xb9, 0xee, 0xb5, - 0xed, 0x32, 0xaa, 0x9c, 0xc0, 0xad, 0xff, 0x7a, 0xea, 0x2b, 0x08, 0xf2, 0x79, 0xe3, 0xe5, 0x7e, 0xf6, 0xf0, 0xb6, - 0x26, 0xb3, 0xcf, 0x96, 0x7b, 0x27, 0x2f, 0xfc, 0xbc, 0x1e, 0x6c, 0xfb, 0x54, 0xea, 0x28, 0xd5, 0xac, 0xf8, 0x61, - 0x4d, 0x72, 0xb6, 0x2b, 0xe7, 0xfc, 0xd3, 0xf2, 0x79, 0xfd, 0xff, 0xc5, 0xf3, 0x6f, 0x76, 0xba, 0x47, 0xd8, 0x6f, - 0x61, 0x5f, 0x63, 0x3f, 0xab, 0x4f, 0xe6, 0x70, 0xf5, 0x29, 0xde, 0xba, 0xee, 0x6d, 0xb5, 0x6b, 0xee, 0xe2, 0x8a, - 0x39, 0x75, 0xc9, 0x95, 0xb3, 0x7a, 0x2a, 0x88, 0x0b, 0x62, 0x11, 0x48, 0x7c, 0x57, 0xff, 0x1d, 0xdc, 0xd5, 0x43, - 0xef, 0x95, 0x8b, 0x6d, 0xcf, 0x6d, 0x02, 0xec, 0x9a, 0xc8, 0x8c, 0x11, 0x2b, 0x62, 0x1b, 0xed, 0x28, 0xf0, 0x01, - 0x8b, 0xb6, 0xcf, 0x9f, 0x12, 0x9f, 0xf5, 0xbb, 0x90, 0x6b, 0x70, 0x7d, 0x7f, 0xfd, 0x5a, 0x3c, 0x93, 0x3f, 0x08, - 0x29, 0xfa, 0xf9, 0xc0, 0x53, 0xfd, 0x8b, 0x54, 0x98, 0x42, 0x54, 0x27, 0x2c, 0x6b, 0x86, 0x7d, 0x65, 0xdf, 0x47, - 0x8b, 0x43, 0x05, 0x6a, 0x3d, 0xde, 0x27, 0x44, 0x3e, 0xaa, 0x48, 0x77, 0xf9, 0x77, 0x2a, 0x82, 0xc0, 0x88, 0x28, - 0x30, 0x34, 0xc8, 0xa7, 0x9d, 0x03, 0x7f, 0x81, 0xe5, 0xfe, 0xa2, 0xb8, 0x7a, 0xb7, 0x4b, 0x4a, 0x35, 0x5c, 0xcd, - 0x1b, 0xeb, 0x14, 0x20, 0x25, 0x36, 0x11, 0x01, 0xc3, 0x7f, 0x72, 0xe5, 0x0b, 0xb6, 0x5e, 0xe7, 0x69, 0x3e, 0x19, - 0x55, 0xa7, 0x26, 0x37, 0x5b, 0x0b, 0x23, 0x5d, 0x0b, 0xaf, 0x3a, 0x3a, 0xe1, 0x94, 0x63, 0xcc, 0x57, 0x94, 0x36, - 0x1e, 0x71, 0xa7, 0xfe, 0xf6, 0x2a, 0xfe, 0xdc, 0x80, 0x84, 0x72, 0xcb, 0x6c, 0x70, 0x95, 0x99, 0xbd, 0x56, 0x70, - 0xa3, 0x24, 0x4c, 0xf1, 0x12, 0xf2, 0x8c, 0xdf, 0x73, 0xb1, 0x32, 0x05, 0xb6, 0x69, 0x7a, 0xff, 0xc7, 0xf6, 0x24, - 0x28, 0x04, 0x3a, 0xf1, 0x52, 0x80, 0x04, 0xaa, 0xbe, 0x21, 0xc8, 0x37, 0x3d, 0xe2, 0xa0, 0xad, 0xa9, 0x8d, 0xf3, - 0x4c, 0xb3, 0x68, 0x33, 0xde, 0xd5, 0x95, 0x92, 0xb9, 0x9e, 0x11, 0x8d, 0xbc, 0xb6, 0xed, 0xf5, 0x66, 0xcc, 0xec, - 0x7a, 0x38, 0x07, 0x74, 0xbe, 0x0e, 0xa0, 0x9f, 0x55, 0x07, 0x96, 0x2a, 0x4e, 0x7f, 0xb9, 0x03, 0x32, 0xb0, 0xa4, - 0x43, 0x0f, 0x53, 0x0a, 0x9f, 0xa5, 0xf4, 0x1f, 0x01, 0x3b, 0x05, 0x0e, 0x4b, 0x39, 0x16, 0xd9, 0x8d, 0xd9, 0xcc, - 0xda, 0xd6, 0xe4, 0xa4, 0x33, 0x17, 0x51, 0xf6, 0x7c, 0x6d, 0x57, 0xff, 0x5d, 0xac, 0xfa, 0x78, 0xc9, 0xc6, 0x29, - 0x20, 0x05, 0x05, 0x05, 0xb1, 0xc7, 0xf2, 0xf3, 0xd0, 0x2f, 0x19, 0x91, 0x41, 0x34, 0xbd, 0x1a, 0x74, 0xfa, 0x79, - 0xd2, 0x5f, 0x58, 0x94, 0xd4, 0x4a, 0xe8, 0x0f, 0xb8, 0xe1, 0x03, 0x77, 0xe7, 0x8c, 0x38, 0x37, 0x84, 0xfc, 0x34, - 0xf5, 0x23, 0xe6, 0x6e, 0xe6, 0x64, 0x16, 0x3f, 0x07, 0x72, 0xd2, 0x82, 0xd5, 0xf8, 0x13, 0x36, 0x82, 0xdc, 0x37, - 0x7e, 0x69, 0xa9, 0x02, 0x47, 0x97, 0x2b, 0xe9, 0xe9, 0xd2, 0xc9, 0x62, 0xa1, 0x75, 0x70, 0x77, 0x8a, 0xe0, 0x8b, - 0xb7, 0xc2, 0x3a, 0xff, 0xe5, 0x72, 0xeb, 0xae, 0x38, 0x1b, 0x36, 0xa2, 0x7e, 0x76, 0xbf, 0xba, 0x75, 0x1a, 0xbe, - 0x47, 0xbf, 0x8b, 0x57, 0xdf, 0xf5, 0x78, 0x21, 0x47, 0xb7, 0x6e, 0x0d, 0x56, 0xf3, 0x74, 0x05, 0x45, 0x33, 0xb0, - 0x2f, 0x5e, 0x94, 0x83, 0x2f, 0x60, 0x34, 0xee, 0x78, 0x11, 0x6c, 0x0a, 0xed, 0xf3, 0xce, 0xf3, 0xe5, 0x15, 0x55, - 0x93, 0x85, 0xf4, 0x76, 0xcd, 0xc6, 0xf0, 0xfe, 0xba, 0xbd, 0xf9, 0x59, 0xfc, 0x54, 0x7c, 0x85, 0x46, 0xe8, 0xb7, - 0x0b, 0x40, 0xba, 0xfa, 0x92, 0x15, 0x8f, 0x3e, 0x6f, 0x85, 0x58, 0xcd, 0xf7, 0x8e, 0x67, 0xb1, 0x42, 0xe0, 0xe3, - 0x5b, 0xd1, 0xeb, 0x25, 0x3c, 0xb3, 0x9a, 0x75, 0x82, 0xb9, 0x03, 0x88, 0x50, 0x44, 0x1a, 0x34, 0x11, 0xe4, 0xbb, - 0xb3, 0x61, 0x2e, 0x54, 0x67, 0x35, 0x2f, 0xff, 0xbc, 0xbc, 0xa2, 0x1e, 0x51, 0x2f, 0x7e, 0x73, 0xbd, 0x81, 0x44, - 0xab, 0xae, 0x85, 0x1a, 0xbf, 0x4b, 0x8d, 0x53, 0xe6, 0x41, 0x03, 0x24, 0x69, 0xe8, 0x50, 0xcb, 0xfb, 0x10, 0x8f, - 0x8b, 0xed, 0x6b, 0x34, 0x7e, 0xdf, 0xc4, 0x5a, 0x11, 0x15, 0x79, 0x4f, 0x81, 0x2c, 0x0e, 0x94, 0x50, 0x5a, 0x5e, - 0xc8, 0xdf, 0x15, 0xa6, 0x5a, 0x64, 0xce, 0xc3, 0xc4, 0x59, 0xa0, 0xfe, 0x7a, 0xf4, 0xd4, 0xfb, 0x52, 0x07, 0x7c, - 0x63, 0x61, 0x03, 0xd7, 0x73, 0x43, 0x8d, 0x60, 0x1c, 0xa4, 0x48, 0x35, 0xa8, 0x0d, 0x33, 0x6a, 0x30, 0x79, 0x4d, - 0xc7, 0x96, 0x62, 0xaf, 0xa4, 0x3f, 0xe4, 0x99, 0x2e, 0xac, 0xf2, 0x6d, 0xd5, 0x23, 0x3b, 0xbd, 0x8d, 0x0b, 0xfe, - 0x59, 0xf3, 0xde, 0x7c, 0x8d, 0x78, 0xba, 0x6c, 0x48, 0xb0, 0xa4, 0xe7, 0xc9, 0x71, 0xeb, 0xfc, 0xa2, 0xba, 0x9e, - 0xab, 0x78, 0x04, 0x99, 0x12, 0x2e, 0x09, 0xb9, 0x8e, 0x73, 0xbd, 0x97, 0x30, 0x10, 0x21, 0x5f, 0xd7, 0x67, 0x09, - 0x50, 0xda, 0xd6, 0x97, 0x50, 0x0b, 0x1f, 0x4a, 0xb3, 0xaf, 0xdd, 0x12, 0x8e, 0xcc, 0x7d, 0x57, 0xec, 0x5d, 0x1d, - 0x39, 0x5d, 0x38, 0x24, 0x15, 0xa0, 0xef, 0xb9, 0xe8, 0xf0, 0x8d, 0xb1, 0xb0, 0x48, 0xc4, 0xa6, 0x37, 0xd1, 0x8d, - 0x66, 0xdd, 0xe0, 0x57, 0x27, 0x9d, 0x06, 0x5f, 0x1b, 0x38, 0x15, 0xb1, 0xa6, 0x00, 0x3b, 0xf4, 0xb8, 0xb1, 0x3b, - 0x40, 0x88, 0x6f, 0x5a, 0xed, 0x84, 0xce, 0xd6, 0x8d, 0x23, 0x14, 0x04, 0x03, 0xea, 0x45, 0xd4, 0x8a, 0xf2, 0xfb, - 0x4a, 0x27, 0x11, 0xf5, 0x06, 0xbf, 0x7b, 0x7d, 0xef, 0xe5, 0xa9, 0x2a, 0xbe, 0xde, 0xab, 0x07, 0x7a, 0xb2, 0x1d, - 0x90, 0xd8, 0x34, 0x15, 0x6b, 0x40, 0x93, 0x67, 0x4e, 0x81, 0xb8, 0xc1, 0x3c, 0xf7, 0x61, 0x3f, 0xc6, 0x7c, 0x8d, - 0x40, 0x8d, 0x4e, 0x84, 0x6a, 0x49, 0xa4, 0x2f, 0x57, 0x2a, 0x63, 0xdd, 0x2c, 0xe2, 0xd9, 0x45, 0x93, 0xf7, 0x7f, - 0x6f, 0x1c, 0x5c, 0xd7, 0x54, 0x50, 0x6e, 0xda, 0x33, 0xff, 0xeb, 0x9a, 0xc2, 0x06, 0xc0, 0x03, 0x7c, 0x5d, 0x42, - 0x72, 0x65, 0xc0, 0x87, 0x6f, 0x0a, 0x75, 0x5e, 0xdd, 0xe6, 0x2d, 0x64, 0x65, 0x40, 0xe4, 0xb4, 0x7d, 0x80, 0xf0, - 0x36, 0x60, 0x0c, 0x23, 0x2e, 0x40, 0xf4, 0xd1, 0x31, 0xdd, 0xaa, 0x69, 0x20, 0xee, 0xca, 0x56, 0x1f, 0xcf, 0x04, - 0xbc, 0x12, 0x7e, 0x63, 0x18, 0xc7, 0x10, 0xe2, 0x33, 0x8e, 0xed, 0xf1, 0x25, 0x54, 0x0e, 0x34, 0xca, 0x83, 0x55, - 0x79, 0xfc, 0x39, 0xf7, 0xfe, 0xea, 0xab, 0x66, 0x64, 0xd3, 0x80, 0xb9, 0xd1, 0xb4, 0xa1, 0x63, 0xc5, 0xba, 0xe4, - 0x6f, 0xbd, 0x43, 0x63, 0xb0, 0x2f, 0x5b, 0x5f, 0xc2, 0xfd, 0x4e, 0x46, 0xc3, 0x0d, 0x8c, 0xc0, 0x18, 0x0c, 0x54, - 0x95, 0x52, 0x90, 0xfe, 0xe2, 0xa5, 0x9d, 0x8b, 0x92, 0xf7, 0xa4, 0x93, 0xbd, 0x11, 0xca, 0x83, 0x42, 0x8b, 0x81, - 0x8b, 0x7e, 0x53, 0x6b, 0x25, 0xee, 0x63, 0xf9, 0xae, 0x8f, 0xd6, 0x54, 0xba, 0x99, 0x11, 0xd9, 0x52, 0x87, 0x51, - 0xdc, 0x9c, 0x3b, 0x69, 0xe6, 0xa1, 0x53, 0x60, 0x91, 0xe6, 0x66, 0x79, 0x00, 0xe2, 0x5b, 0xd4, 0xc8, 0xe6, 0x94, - 0xff, 0x89, 0x47, 0xdd, 0x40, 0x88, 0xc8, 0xb2, 0xbe, 0x6b, 0x8a, 0x33, 0x28, 0x94, 0xe4, 0x06, 0x85, 0xf0, 0xde, - 0xc8, 0xa0, 0x40, 0xc9, 0x52, 0xd9, 0x48, 0xfa, 0xfd, 0x27, 0x1e, 0x54, 0xe8, 0xe9, 0xce, 0x91, 0x64, 0xeb, 0x36, - 0x0b, 0x6b, 0x28, 0x8d, 0x32, 0x31, 0xbb, 0xd9, 0xc9, 0xb7, 0x05, 0x05, 0x45, 0x49, 0x39, 0x51, 0xa4, 0x19, 0x0e, - 0x77, 0xfa, 0x5f, 0xee, 0x51, 0xde, 0xb1, 0x40, 0xb9, 0xcd, 0x9c, 0x96, 0x00, 0x01, 0x44, 0xfd, 0x5c, 0x40, 0x34, - 0x51, 0xa4, 0x14, 0x72, 0x79, 0x23, 0x2f, 0xf3, 0xd1, 0xad, 0x79, 0xca, 0x41, 0xfb, 0xca, 0xfe, 0x94, 0x30, 0xe7, - 0xb6, 0x92, 0x3e, 0x92, 0x31, 0x31, 0x52, 0x17, 0xdc, 0xd0, 0x81, 0x61, 0xbd, 0x77, 0xe8, 0xe5, 0x53, 0x63, 0xc2, - 0xef, 0x2f, 0x82, 0x22, 0x88, 0x42, 0x00, 0x80, 0x69, 0x59, 0xb6, 0xa4, 0xf0, 0x49, 0x12, 0x05, 0x90, 0xf5, 0xb8, - 0xf4, 0xc0, 0xb5, 0x24, 0x30, 0x3c, 0xaa, 0x09, 0x68, 0xde, 0x2e, 0x50, 0x38, 0xa0, 0x85, 0x95, 0xeb, 0xb0, 0x76, - 0x42, 0xaa, 0x26, 0x45, 0xab, 0x9b, 0xd5, 0x92, 0x94, 0x67, 0x06, 0x4c, 0x15, 0x91, 0xa7, 0xf5, 0x3f, 0x64, 0xbe, - 0xb4, 0x40, 0xf4, 0xc6, 0x7c, 0x16, 0x5c, 0x3f, 0x56, 0x3b, 0x8e, 0x5e, 0x37, 0x4c, 0x6b, 0x37, 0x48, 0x02, 0x44, - 0x3e, 0x95, 0xd5, 0xd5, 0x2b, 0x15, 0xa4, 0xa1, 0xc6, 0x8f, 0x7c, 0xaf, 0x14, 0xe4, 0x4a, 0xe9, 0xa5, 0xa0, 0x00, - 0xdd, 0x78, 0xe9, 0x88, 0xa7, 0x6c, 0xe9, 0xc5, 0xa6, 0xb0, 0x71, 0xc2, 0xd8, 0xeb, 0xd9, 0x8a, 0x93, 0x7a, 0xec, - 0xaa, 0x4e, 0xb2, 0x04, 0x5d, 0x5c, 0x4b, 0x5e, 0xc5, 0x91, 0xe9, 0xd2, 0xd4, 0x31, 0xf5, 0xef, 0x1a, 0xed, 0x89, - 0x15, 0xba, 0xff, 0x2d, 0x91, 0x3b, 0xaf, 0x4c, 0xd3, 0x02, 0x41, 0xd6, 0x82, 0x90, 0xe0, 0x7c, 0x27, 0x44, 0x1e, - 0x95, 0xc7, 0xa4, 0x65, 0xee, 0xf1, 0xb5, 0x6e, 0xc6, 0x53, 0xda, 0x03, 0x51, 0x3e, 0xcc, 0x71, 0x97, 0x12, 0xe6, - 0x9e, 0x3d, 0xb0, 0x32, 0x4c, 0x4c, 0xec, 0x83, 0x1e, 0x3d, 0xae, 0x59, 0x01, 0xc1, 0x30, 0xfd, 0xda, 0xa5, 0xdd, - 0xed, 0xfa, 0x61, 0x0b, 0xe0, 0x5d, 0x2e, 0x84, 0xfa, 0xb9, 0x3a, 0x71, 0x53, 0x78, 0x75, 0x83, 0xb6, 0x8a, 0xd5, - 0x1a, 0x54, 0xb4, 0xdc, 0xa1, 0x6d, 0xeb, 0xcf, 0x69, 0x06, 0x1f, 0x3b, 0x39, 0x10, 0x6a, 0x3a, 0x22, 0x98, 0x89, - 0x72, 0x24, 0x0d, 0x9e, 0xb8, 0xea, 0x6c, 0x91, 0xaa, 0x77, 0x73, 0x02, 0x64, 0x48, 0xea, 0x0b, 0x32, 0x84, 0x36, - 0x20, 0x74, 0xac, 0xa9, 0xf2, 0xb5, 0x41, 0xed, 0xd6, 0x13, 0x63, 0x6f, 0xdf, 0x84, 0x16, 0x45, 0x85, 0xbe, 0x2d, - 0x16, 0xbb, 0x64, 0x8c, 0xbe, 0xe0, 0x6f, 0xcc, 0x7e, 0x92, 0xd1, 0xc3, 0x67, 0xb5, 0xd1, 0x45, 0x3b, 0x88, 0x5d, - 0x60, 0xfc, 0xa3, 0xc9, 0xdb, 0x9a, 0x55, 0x5c, 0x7e, 0x75, 0x41, 0x55, 0xab, 0xd9, 0x62, 0xfa, 0xb3, 0xae, 0x70, - 0x89, 0x64, 0xa2, 0xc4, 0x0c, 0x7f, 0x10, 0x68, 0xf5, 0x7d, 0xe0, 0xdc, 0xe7, 0xb9, 0x9e, 0xa0, 0xff, 0xe2, 0xa5, - 0x77, 0x28, 0xa3, 0x42, 0xc8, 0xc7, 0x91, 0x2f, 0xa4, 0x88, 0x55, 0xba, 0x7b, 0xb4, 0xd1, 0x12, 0x39, 0x0b, 0x64, - 0xcd, 0x6a, 0xca, 0x34, 0xd4, 0x85, 0x63, 0x8b, 0x2e, 0xd3, 0x6c, 0x17, 0xd0, 0x32, 0xac, 0xa4, 0x63, 0xeb, 0x9d, - 0x05, 0x84, 0x22, 0x22, 0x80, 0x29, 0x69, 0xf0, 0x9f, 0x5d, 0x69, 0x8b, 0xc5, 0xdc, 0xb4, 0x94, 0x7d, 0x2e, 0x23, - 0x31, 0xd7, 0x13, 0xb2, 0x1b, 0xb8, 0x5e, 0xdc, 0x08, 0x4d, 0x6b, 0x84, 0xe4, 0x24, 0xd1, 0xa3, 0x5e, 0xa8, 0x37, - 0x55, 0x59, 0x34, 0x7a, 0x40, 0x54, 0x03, 0xd7, 0xbb, 0x69, 0x27, 0x52, 0x12, 0x2c, 0x15, 0xdd, 0x07, 0x6e, 0xdd, - 0xad, 0x75, 0x98, 0x70, 0x85, 0x9c, 0x97, 0x50, 0x23, 0x86, 0x84, 0xfb, 0x80, 0x3d, 0x94, 0x4c, 0x00, 0x98, 0x82, - 0x13, 0x68, 0x09, 0xb0, 0xed, 0x56, 0x50, 0x02, 0x06, 0xac, 0xcc, 0x34, 0xa2, 0x30, 0xf3, 0xd0, 0x15, 0x26, 0xe4, - 0x38, 0x37, 0x8f, 0x3a, 0x58, 0x90, 0x2a, 0x44, 0xdb, 0xef, 0x4d, 0x0f, 0xe6, 0x38, 0x33, 0xce, 0x91, 0x0b, 0x80, - 0xe3, 0x2d, 0x28, 0xd5, 0x30, 0x34, 0x6c, 0xff, 0xaa, 0xc9, 0x2a, 0x67, 0x44, 0x62, 0xd5, 0x2b, 0x9b, 0xfd, 0x2a, - 0xfe, 0x18, 0x0a, 0x2a, 0x69, 0x3a, 0xbe, 0x49, 0x4c, 0x3d, 0x5b, 0x5e, 0x7d, 0x65, 0x78, 0xd2, 0xb3, 0x7d, 0xc0, - 0x15, 0x8f, 0xc0, 0xba, 0x29, 0xf9, 0x51, 0x27, 0x83, 0x06, 0xe0, 0xa8, 0x45, 0x3b, 0x54, 0x9d, 0x62, 0x10, 0x30, - 0xe2, 0x74, 0x5a, 0x96, 0xfc, 0x25, 0x8a, 0x0d, 0x34, 0xf1, 0x18, 0x2f, 0x58, 0x3a, 0xb1, 0xbd, 0xa3, 0xf9, 0xaa, - 0x44, 0x23, 0xcb, 0xac, 0x0d, 0x92, 0xfc, 0x36, 0xbd, 0xd6, 0x2a, 0x23, 0xed, 0x6d, 0xd9, 0x21, 0xfe, 0x11, 0xc8, - 0x82, 0x31, 0xa3, 0x22, 0x51, 0x31, 0x45, 0xc6, 0xa5, 0xf1, 0x56, 0xb2, 0x00, 0x5d, 0xa6, 0x67, 0x6b, 0xf3, 0x8a, - 0xbc, 0x7d, 0x12, 0xcd, 0x7d, 0x30, 0x55, 0x61, 0xff, 0x72, 0x34, 0x5b, 0x1e, 0xab, 0xf0, 0x8f, 0x55, 0x75, 0x04, - 0x9a, 0xb6, 0xab, 0xa7, 0x40, 0x8e, 0x4e, 0xd5, 0xc5, 0x21, 0x39, 0xf6, 0xc2, 0x8c, 0x43, 0x12, 0x72, 0xb2, 0x78, - 0x1b, 0xac, 0x4f, 0x32, 0xb4, 0x46, 0x80, 0x2f, 0x17, 0x61, 0xd5, 0x2b, 0xcd, 0x1e, 0x65, 0xb2, 0x1a, 0x59, 0x2b, - 0x28, 0x4d, 0x10, 0x45, 0xf3, 0x14, 0x09, 0x03, 0xcf, 0x72, 0xa2, 0x30, 0x61, 0x38, 0x25, 0xec, 0x43, 0xa2, 0x8b, - 0xf6, 0x0f, 0x33, 0xcb, 0x87, 0x12, 0xb0, 0xa5, 0x79, 0x12, 0x20, 0x46, 0x80, 0x51, 0xa5, 0x58, 0xd1, 0x3f, 0x38, - 0x4f, 0x1c, 0x0f, 0x73, 0x49, 0x22, 0x3f, 0xe3, 0xfd, 0x91, 0x79, 0xd3, 0xcd, 0xcb, 0x23, 0xdb, 0x90, 0x26, 0x66, - 0xaa, 0xa7, 0x70, 0x8d, 0xd8, 0x6e, 0xbb, 0x80, 0x2d, 0x54, 0xba, 0x41, 0xb5, 0x2f, 0x8a, 0x20, 0xf4, 0x2f, 0x75, - 0x90, 0xd6, 0xfc, 0x37, 0x47, 0x1b, 0x4c, 0x8c, 0xde, 0x64, 0x07, 0x8c, 0xfb, 0x66, 0xaa, 0xba, 0x96, 0x40, 0xc7, - 0xa6, 0x2a, 0xfc, 0x76, 0x70, 0x09, 0x89, 0xb9, 0x32, 0x16, 0xbd, 0xd5, 0x19, 0x59, 0xe5, 0xfe, 0xdf, 0x36, 0x1d, - 0x41, 0xb7, 0x7f, 0x9d, 0x5d, 0xcd, 0xce, 0x03, 0x64, 0x91, 0x07, 0x8e, 0x88, 0xa5, 0x7a, 0x6a, 0xf3, 0x68, 0x58, - 0x58, 0xaa, 0x2b, 0xc7, 0xfb, 0xb8, 0x92, 0x36, 0x9f, 0x97, 0x86, 0x03, 0x22, 0x72, 0x30, 0xbd, 0x35, 0xf0, 0x5b, - 0x24, 0x32, 0xaf, 0x6a, 0x1c, 0xd1, 0xa9, 0x8b, 0x71, 0x31, 0xae, 0x15, 0x94, 0x46, 0x7e, 0xdc, 0x49, 0x3f, 0x46, - 0x47, 0x4b, 0x1f, 0x9f, 0x6e, 0xad, 0x8a, 0xee, 0xd5, 0x2f, 0x72, 0x28, 0xe6, 0x65, 0x19, 0x1d, 0x08, 0x19, 0x24, - 0x7b, 0x4f, 0xbe, 0xf3, 0x9e, 0xb8, 0xcc, 0x45, 0x4f, 0x8d, 0x0a, 0x0e, 0xbd, 0xbd, 0x8d, 0x2c, 0x53, 0x39, 0x72, - 0x07, 0xcc, 0xce, 0xf8, 0xda, 0xde, 0x40, 0x6c, 0xef, 0x85, 0xc8, 0xad, 0xf0, 0x48, 0x61, 0xfa, 0x71, 0x65, 0x84, - 0xab, 0x31, 0xe9, 0x50, 0x99, 0x4c, 0xf3, 0xc2, 0x2e, 0x57, 0x59, 0xd0, 0x61, 0x19, 0x54, 0x33, 0x99, 0x99, 0x66, - 0xb2, 0x69, 0xa4, 0xe1, 0x0a, 0xc5, 0x34, 0x06, 0x2e, 0x97, 0x2a, 0x52, 0xf6, 0xbc, 0x92, 0xa5, 0xe7, 0x38, 0x0b, - 0x1d, 0xa6, 0x4d, 0x07, 0xcf, 0x53, 0xe2, 0x92, 0x70, 0x84, 0x35, 0x13, 0x4c, 0x93, 0xac, 0xb4, 0x40, 0xb9, 0xa8, - 0xa4, 0x18, 0xba, 0x3e, 0xaf, 0x24, 0x65, 0xee, 0x68, 0x19, 0x4f, 0x69, 0xf4, 0x8c, 0xf2, 0x15, 0xb5, 0x66, 0xfe, - 0xc9, 0xf2, 0xef, 0x20, 0x85, 0xd6, 0x57, 0x40, 0x05, 0xa6, 0x14, 0xac, 0x04, 0xf9, 0xfb, 0xc5, 0x8d, 0x56, 0x11, - 0x97, 0x82, 0xf3, 0x2a, 0xe6, 0x65, 0x53, 0x0d, 0x69, 0xbe, 0xfe, 0xe4, 0x7f, 0xa6, 0x93, 0x83, 0x4a, 0x1c, 0x6e, - 0x00, 0x33, 0x86, 0x5c, 0x2c, 0xe8, 0x4f, 0xa5, 0x57, 0x5f, 0xa9, 0x97, 0xa2, 0x46, 0x5d, 0xe8, 0xee, 0x96, 0xdc, - 0x5a, 0xcf, 0x46, 0x9a, 0x68, 0x56, 0x2a, 0xdf, 0x0f, 0x92, 0x66, 0x86, 0x1a, 0xe1, 0x62, 0x2f, 0x36, 0x60, 0xdc, - 0x1a, 0xa7, 0x50, 0x7b, 0x2f, 0x59, 0xc2, 0x67, 0x8b, 0xcb, 0x41, 0x95, 0xc2, 0x18, 0xdf, 0x81, 0xb9, 0x21, 0xf7, - 0xc1, 0x93, 0xde, 0x7e, 0xbb, 0xf3, 0x53, 0xbc, 0x0c, 0xec, 0x12, 0x11, 0x0f, 0xa2, 0xdf, 0xdc, 0x2a, 0x6d, 0xaf, - 0x37, 0x16, 0x36, 0x57, 0xc5, 0x0f, 0x2a, 0x55, 0xe0, 0xce, 0x2b, 0x77, 0x61, 0x50, 0x1e, 0x41, 0x0e, 0xfa, 0x4d, - 0xe3, 0xe6, 0x7e, 0x27, 0x54, 0x61, 0xd8, 0xa5, 0x87, 0x49, 0x59, 0xe7, 0x4b, 0x7a, 0x18, 0x33, 0xc4, 0xce, 0xcc, - 0x32, 0xa9, 0xd0, 0xae, 0x65, 0x41, 0xe3, 0xa7, 0xe0, 0x8f, 0x28, 0xa3, 0x48, 0x2b, 0x26, 0xb0, 0x0f, 0x32, 0x01, - 0xc7, 0x07, 0xc1, 0xa8, 0x2e, 0xe2, 0x13, 0x9c, 0xee, 0xce, 0x0b, 0x0e, 0x54, 0x32, 0xb4, 0x48, 0xb0, 0xc4, 0x1e, - 0xf1, 0xb0, 0xa9, 0x1f, 0xec, 0x9d, 0xda, 0x55, 0x38, 0x6f, 0x16, 0xeb, 0x31, 0x48, 0xf5, 0xfc, 0xb6, 0xf9, 0x84, - 0x03, 0xfc, 0x51, 0x9d, 0xea, 0xf1, 0x4d, 0x1d, 0xaf, 0x71, 0x08, 0xab, 0x43, 0xe5, 0x16, 0x7f, 0x52, 0x90, 0xce, - 0xb8, 0xa0, 0x87, 0xfd, 0x2b, 0x69, 0xf1, 0x05, 0x65, 0x37, 0x01, 0x1b, 0xbd, 0xf5, 0xa0, 0x04, 0xa1, 0xf3, 0xfe, - 0xe1, 0xd1, 0x7d, 0x16, 0x14, 0x6b, 0x44, 0x1d, 0x35, 0xf1, 0x6e, 0xb4, 0x9b, 0x54, 0x5c, 0x10, 0xab, 0x36, 0x5b, - 0xed, 0xb0, 0x0c, 0xd1, 0xfb, 0x37, 0x19, 0x59, 0x80, 0xa2, 0xbd, 0xe9, 0x79, 0x19, 0xac, 0x56, 0x4f, 0x13, 0x12, - 0x86, 0x6f, 0x20, 0xab, 0x29, 0x6c, 0x33, 0xdd, 0xca, 0xe8, 0x73, 0x60, 0x8e, 0x9e, 0x74, 0xd6, 0xd4, 0x82, 0xb1, - 0x65, 0xd4, 0x9f, 0x29, 0x0b, 0x27, 0x1f, 0xcb, 0xe0, 0xe7, 0x85, 0x29, 0x75, 0x07, 0x0d, 0xc9, 0x62, 0xc4, 0xca, - 0x4d, 0x3c, 0x74, 0xe8, 0xaa, 0x04, 0x83, 0xf5, 0xdb, 0x7a, 0xe3, 0xac, 0xd7, 0x38, 0x20, 0xf4, 0xde, 0x0f, 0x5c, - 0x2d, 0xfc, 0x10, 0x89, 0x11, 0xde, 0x90, 0x36, 0x47, 0x9d, 0xf1, 0xe2, 0x37, 0xde, 0x1b, 0x43, 0xb9, 0xbd, 0xae, - 0xf8, 0xa3, 0x5f, 0xd7, 0x95, 0x2a, 0x74, 0x25, 0x71, 0x66, 0xee, 0x63, 0x49, 0xd1, 0x23, 0x53, 0xda, 0xc5, 0x3d, - 0x00, 0x58, 0x98, 0x8d, 0x8a, 0xd0, 0xa4, 0x91, 0xb8, 0xfc, 0x54, 0x61, 0xa5, 0x52, 0x9f, 0x50, 0x72, 0x22, 0x30, - 0x0c, 0xbe, 0xff, 0x28, 0xd2, 0x15, 0x47, 0x3f, 0xc0, 0x3f, 0x22, 0x20, 0x50, 0x6b, 0x16, 0x69, 0xa8, 0x1d, 0x90, - 0x8c, 0x9f, 0x0e, 0x17, 0xce, 0xce, 0xcc, 0x88, 0x20, 0x53, 0x77, 0x03, 0x02, 0x84, 0xe1, 0x1a, 0x81, 0x2e, 0xff, - 0x4a, 0x49, 0xdb, 0x96, 0x3b, 0xd4, 0x61, 0x90, 0x5d, 0xe8, 0x20, 0x5a, 0x2d, 0xfa, 0xa5, 0xca, 0xf8, 0x16, 0xd1, - 0xc9, 0xfa, 0xfa, 0xfd, 0xc7, 0xe5, 0x5e, 0xe4, 0x0f, 0x6e, 0x2d, 0x00, 0x98, 0x8d, 0x38, 0x1b, 0x27, 0xbc, 0x6a, - 0x1d, 0x8b, 0x8f, 0xd2, 0x35, 0x86, 0xed, 0x14, 0xb4, 0xe2, 0x41, 0xab, 0x46, 0x54, 0x8a, 0x75, 0x7e, 0xdc, 0x2b, - 0x0f, 0xed, 0x76, 0xef, 0x87, 0xc0, 0xb9, 0x60, 0x47, 0xcc, 0x13, 0xc0, 0xbc, 0xac, 0x5c, 0x15, 0x32, 0x4d, 0xb0, - 0x11, 0x07, 0x39, 0xc8, 0xb4, 0xeb, 0x1e, 0x98, 0xb2, 0x4d, 0x8b, 0xdd, 0x2d, 0x66, 0x61, 0x03, 0x19, 0x21, 0x05, - 0x9b, 0x84, 0x0f, 0xd9, 0x32, 0x09, 0xa5, 0x07, 0x0e, 0x33, 0xd0, 0xd6, 0x7a, 0x14, 0xfb, 0x35, 0x6d, 0x13, 0x5d, - 0xb2, 0xc0, 0xa5, 0x46, 0xb6, 0x6f, 0xfa, 0x84, 0x8e, 0xc5, 0xe4, 0x86, 0xef, 0x77, 0xe7, 0xd5, 0x18, 0x96, 0xed, - 0x63, 0x65, 0x2f, 0xeb, 0xaf, 0x57, 0x2b, 0x50, 0x33, 0x9d, 0xb9, 0x7c, 0xab, 0xe4, 0xdf, 0xf5, 0x61, 0xa0, 0xf6, - 0x42, 0xe1, 0xa3, 0x98, 0x40, 0x59, 0x4b, 0xea, 0x14, 0xbc, 0x35, 0xde, 0xaf, 0xda, 0x61, 0xdc, 0xbf, 0xb9, 0x0b, - 0x15, 0x37, 0xbe, 0xfe, 0xa7, 0x9b, 0xda, 0xe0, 0xe8, 0x8d, 0x70, 0x49, 0xe7, 0x7e, 0xbd, 0x82, 0x00, 0x51, 0x6f, - 0x61, 0xca, 0x3a, 0x6f, 0xaf, 0xae, 0x6b, 0xf2, 0x68, 0x4b, 0x3f, 0xee, 0x82, 0x0e, 0x9b, 0xac, 0x64, 0x6d, 0xa1, - 0x7b, 0x64, 0x35, 0xee, 0x7e, 0x46, 0x38, 0x74, 0xb0, 0x83, 0xd4, 0x2d, 0x3e, 0xf4, 0x0e, 0xd3, 0xeb, 0x94, 0x54, - 0x6f, 0xf5, 0x9b, 0xfa, 0xcb, 0x97, 0xc6, 0xb9, 0x1a, 0x35, 0xac, 0x5d, 0xd4, 0xa4, 0x64, 0x66, 0x0a, 0xa6, 0xdb, - 0x20, 0x85, 0xab, 0xbe, 0xfa, 0xca, 0xe0, 0xc8, 0xf7, 0x73, 0x42, 0x05, 0x9b, 0x10, 0xaa, 0xc7, 0x2f, 0x88, 0xae, - 0x64, 0xfe, 0x71, 0xbb, 0x32, 0x06, 0x49, 0xe8, 0xdb, 0x11, 0x6f, 0xa5, 0xa9, 0xb3, 0x43, 0x3e, 0xe6, 0x6c, 0x82, - 0x5f, 0xd2, 0x84, 0x40, 0xb3, 0xf0, 0x2f, 0x0d, 0xd8, 0xee, 0x70, 0x6c, 0x3d, 0xd0, 0xb8, 0xf8, 0x0f, 0x94, 0x1b, - 0xd1, 0x99, 0x85, 0x1d, 0xef, 0x66, 0xe6, 0x4b, 0x87, 0xc3, 0x9e, 0x61, 0x09, 0x54, 0x65, 0x18, 0xd0, 0x0f, 0xdd, - 0x90, 0xed, 0x52, 0x1d, 0x3b, 0x07, 0x09, 0xeb, 0x3d, 0x14, 0x62, 0x3f, 0x9a, 0xab, 0xcb, 0xeb, 0xe5, 0x81, 0xfb, - 0x0a, 0xc3, 0xe5, 0xc1, 0xb0, 0xf8, 0x98, 0x15, 0x52, 0x75, 0xe9, 0xba, 0x8e, 0x3d, 0xad, 0x31, 0x20, 0x1f, 0x33, - 0x0c, 0x7f, 0x0e, 0x06, 0x8b, 0x76, 0x64, 0xdb, 0x32, 0x38, 0xc3, 0xca, 0xdb, 0x32, 0x65, 0xa6, 0xec, 0x2e, 0xd8, - 0x9e, 0x1a, 0xf8, 0xb3, 0x93, 0x94, 0x11, 0x14, 0x2a, 0xd3, 0x11, 0x34, 0xfa, 0xc7, 0x57, 0x45, 0xad, 0xc8, 0x46, - 0xd6, 0xfc, 0xb6, 0x78, 0x67, 0x8c, 0x53, 0x5a, 0x97, 0xb3, 0x7a, 0x17, 0xab, 0x46, 0x1f, 0x9a, 0x44, 0x2b, 0x92, - 0xb5, 0xaf, 0xb1, 0xc7, 0xc0, 0x10, 0x46, 0xc8, 0x72, 0xb3, 0xd6, 0x66, 0x36, 0x38, 0x89, 0xe3, 0x51, 0x07, 0xd6, - 0xdb, 0x79, 0xe5, 0x15, 0x0c, 0x02, 0xe0, 0x5f, 0x43, 0xcc, 0xb3, 0x0d, 0xfd, 0xce, 0x74, 0x53, 0xd5, 0xcb, 0x25, - 0x14, 0x46, 0x7f, 0x8c, 0x49, 0x07, 0xe5, 0xa5, 0x6a, 0x2a, 0x0d, 0x92, 0x51, 0x3d, 0x16, 0xa4, 0xa3, 0xcb, 0x73, - 0xc6, 0x67, 0x1d, 0xec, 0x69, 0xd2, 0xcd, 0x00, 0x10, 0x69, 0x87, 0x32, 0x26, 0x2a, 0x84, 0x15, 0x1e, 0x19, 0xa1, - 0xca, 0x1d, 0xf8, 0x28, 0xe0, 0xf3, 0xee, 0xb4, 0x20, 0x98, 0xd5, 0x25, 0x87, 0xb7, 0x42, 0x54, 0x14, 0xb7, 0xb2, - 0x9f, 0x90, 0xcc, 0xc7, 0x66, 0x26, 0xda, 0x6b, 0xc6, 0x9a, 0x77, 0x7f, 0x0e, 0x41, 0xa3, 0x20, 0x74, 0x58, 0x44, - 0xf3, 0x43, 0x0e, 0x83, 0xe4, 0x95, 0xd5, 0xd5, 0xc9, 0xf0, 0x5b, 0x81, 0x64, 0x05, 0x42, 0x74, 0xe2, 0x12, 0x84, - 0xde, 0x7e, 0x32, 0xec, 0x82, 0x57, 0xa0, 0x70, 0x28, 0x1c, 0x2f, 0x81, 0xcd, 0x27, 0x46, 0xc7, 0x72, 0xec, 0x74, - 0xc8, 0x55, 0x85, 0x3a, 0x59, 0x45, 0xd0, 0xda, 0x92, 0x9f, 0xf4, 0x95, 0x82, 0x98, 0x64, 0xcb, 0x96, 0x20, 0xa6, - 0x26, 0xc7, 0xc7, 0x09, 0xbd, 0x3f, 0xb1, 0x48, 0x1a, 0x92, 0x84, 0xef, 0x3e, 0xc1, 0x19, 0x23, 0x18, 0x63, 0x95, - 0x12, 0x63, 0x43, 0x59, 0x66, 0x7f, 0x38, 0x7d, 0x33, 0xc1, 0x81, 0x5f, 0x42, 0x91, 0xf2, 0x2a, 0x39, 0xe1, 0x19, - 0xc3, 0x5c, 0xca, 0xf1, 0xac, 0xe8, 0x1b, 0x75, 0xf8, 0x4b, 0x84, 0x22, 0x90, 0xe0, 0x2e, 0x2f, 0x67, 0xea, 0x8b, - 0xca, 0x4c, 0x69, 0x11, 0x6e, 0xf1, 0x1c, 0x6a, 0x8f, 0x1b, 0xb2, 0x13, 0x6f, 0xba, 0xf7, 0x7a, 0x84, 0x0f, 0x54, - 0x8a, 0x70, 0xde, 0x15, 0x83, 0xe5, 0x6e, 0x2c, 0xcc, 0xa5, 0x2f, 0x26, 0x7f, 0xa0, 0xe7, 0xb3, 0xb4, 0x9c, 0xf8, - 0xaa, 0xf9, 0xe6, 0xa8, 0x8b, 0x3f, 0xe2, 0x69, 0x89, 0x6d, 0xb9, 0xbd, 0x49, 0x2f, 0x3e, 0xdf, 0xe7, 0x7f, 0xd7, - 0x78, 0xb0, 0x50, 0xfd, 0x6c, 0x96, 0xe2, 0x06, 0xab, 0x07, 0x3e, 0x04, 0xb9, 0xc8, 0x4c, 0xe5, 0xda, 0xa6, 0xc7, - 0x9a, 0x3c, 0x6c, 0xc6, 0x3a, 0xb1, 0x5a, 0xf9, 0x36, 0xd8, 0xf4, 0x6a, 0x5b, 0xab, 0x5b, 0x13, 0xfa, 0x43, 0xad, - 0x64, 0x5b, 0xfd, 0x82, 0x07, 0x75, 0x34, 0x5f, 0x76, 0x86, 0xd5, 0xc5, 0xfe, 0x94, 0xc3, 0xa4, 0xf8, 0xbb, 0xb4, - 0xa2, 0x88, 0xfb, 0x4b, 0x06, 0x35, 0x75, 0x7e, 0x8c, 0x5f, 0xac, 0x0e, 0xa1, 0xdf, 0xc7, 0x11, 0xb5, 0x54, 0xfe, - 0x87, 0x13, 0xa5, 0x93, 0x49, 0x1e, 0xed, 0xf9, 0x34, 0x2d, 0x5e, 0xd1, 0xa5, 0xdb, 0xf1, 0x32, 0xf9, 0x56, 0x14, - 0x79, 0xbc, 0x58, 0x65, 0xc0, 0xec, 0x75, 0x68, 0xfa, 0xc9, 0xd3, 0x28, 0x39, 0x16, 0x57, 0x0c, 0x6e, 0x3d, 0x0e, - 0x1e, 0x91, 0x37, 0x35, 0xec, 0x4d, 0x28, 0x58, 0xec, 0xc0, 0x20, 0x3f, 0x06, 0x87, 0xa1, 0x43, 0x3d, 0x22, 0xde, - 0x35, 0xbe, 0x9c, 0xd4, 0x47, 0x58, 0x30, 0xab, 0x89, 0xf0, 0xdb, 0x82, 0xbc, 0x47, 0x22, 0x5a, 0x9f, 0x24, 0x8d, - 0xad, 0x74, 0xe0, 0xed, 0x2b, 0x41, 0x36, 0x39, 0xd0, 0x93, 0xde, 0xc2, 0x6c, 0xe7, 0x7c, 0xb4, 0xf2, 0xf7, 0x22, - 0xf9, 0x29, 0x24, 0xd2, 0x55, 0x15, 0x34, 0x75, 0x64, 0x1f, 0x91, 0x61, 0xed, 0x4b, 0xdd, 0xaf, 0x7d, 0x2d, 0xa4, - 0x24, 0xf8, 0xff, 0x69, 0x14, 0x0b, 0xba, 0x0b, 0x98, 0x77, 0x57, 0xc3, 0x30, 0x91, 0x93, 0xd5, 0xc4, 0x65, 0x89, - 0x6e, 0xea, 0x40, 0x61, 0x0c, 0xfb, 0x6d, 0xb4, 0x38, 0x5c, 0xd8, 0x97, 0x3c, 0x50, 0xa9, 0x5b, 0x8e, 0xe7, 0xb7, - 0x32, 0xa7, 0xf2, 0x62, 0x39, 0xa8, 0x0c, 0x5b, 0x03, 0xf0, 0x0c, 0x61, 0x18, 0x10, 0x0d, 0x39, 0x2e, 0x49, 0xb8, - 0xc2, 0xb0, 0x46, 0xf6, 0x58, 0x34, 0x52, 0x86, 0xd5, 0xc5, 0x8d, 0x2c, 0x4e, 0xa6, 0x89, 0x18, 0xce, 0xa7, 0x69, - 0x71, 0x02, 0xfc, 0xc0, 0x84, 0x1b, 0xec, 0xfa, 0xaa, 0xb0, 0x1c, 0xd0, 0x6c, 0x13, 0x1a, 0x2d, 0x52, 0x93, 0x66, - 0x95, 0x74, 0x52, 0xfe, 0x2f, 0xc7, 0x34, 0xd6, 0x19, 0xe9, 0x9c, 0x30, 0x22, 0xf2, 0x0f, 0x8d, 0x52, 0x76, 0x10, - 0xb6, 0x3a, 0x3c, 0x9c, 0x4d, 0x7e, 0x40, 0xd5, 0x46, 0xf7, 0x83, 0xaf, 0x2e, 0x64, 0xb0, 0x07, 0xc1, 0x91, 0xeb, - 0xe6, 0xa8, 0x49, 0xfe, 0x97, 0xa3, 0xfc, 0x73, 0x2e, 0x4e, 0x3a, 0x92, 0xb4, 0xb1, 0x62, 0x44, 0x64, 0x63, 0xfa, - 0x83, 0x4d, 0x9b, 0xc2, 0xa1, 0x0f, 0x0b, 0x3a, 0x9e, 0xa2, 0x40, 0x1a, 0xbd, 0xaa, 0xe4, 0xa0, 0x30, 0x19, 0x20, - 0x7b, 0x25, 0x65, 0xde, 0x2f, 0xe4, 0x32, 0xc8, 0x7c, 0xab, 0x56, 0x66, 0xcb, 0x67, 0x0c, 0xc4, 0x08, 0x37, 0xc2, - 0xe0, 0x57, 0x8a, 0xa9, 0xaf, 0x59, 0xa6, 0xb8, 0x9f, 0x86, 0x90, 0x1d, 0x12, 0x86, 0x1f, 0x68, 0x53, 0x26, 0xa7, - 0x4d, 0x13, 0x2f, 0xf5, 0xd5, 0xd5, 0x30, 0x79, 0x81, 0x4c, 0xde, 0xe0, 0x3e, 0x72, 0xdd, 0x8f, 0xfa, 0x6d, 0x93, - 0x60, 0x7f, 0x95, 0xe0, 0xff, 0x0e, 0x21, 0x5b, 0x0b, 0x60, 0x06, 0x89, 0x8d, 0xbe, 0x5d, 0xf0, 0x71, 0x51, 0xe4, - 0xeb, 0x44, 0xac, 0xa8, 0x8c, 0x4b, 0xb2, 0x5b, 0x07, 0xbb, 0xd2, 0x36, 0x18, 0x6c, 0x90, 0x68, 0x68, 0x32, 0x0d, - 0x9d, 0x2c, 0xdf, 0x2c, 0x0d, 0xc9, 0xb7, 0x65, 0x9d, 0x3b, 0x14, 0x1a, 0x49, 0x0d, 0x6e, 0xd3, 0xf2, 0x39, 0xf5, - 0x94, 0x6c, 0x78, 0x6c, 0x2b, 0x79, 0x50, 0x61, 0x3a, 0x36, 0xb3, 0x79, 0xb4, 0x62, 0x3d, 0xaf, 0xd6, 0x39, 0x24, - 0x76, 0x5b, 0x56, 0x0e, 0x56, 0x64, 0x88, 0x4f, 0x5c, 0x8f, 0xd6, 0xf6, 0xa3, 0xef, 0x63, 0x44, 0x25, 0x39, 0x18, - 0x96, 0x2d, 0x95, 0xa7, 0x16, 0x27, 0x9e, 0xda, 0xfd, 0x60, 0xfa, 0xb2, 0x48, 0x85, 0x17, 0x4d, 0xe6, 0x8b, 0xd4, - 0xa4, 0x38, 0xcc, 0xc5, 0x48, 0xcc, 0x81, 0xa5, 0x7d, 0x84, 0x8c, 0xc1, 0x52, 0x7a, 0xa4, 0xb1, 0x8d, 0x91, 0xb7, - 0xe8, 0x50, 0x2e, 0x3a, 0xfb, 0x9f, 0x59, 0x7a, 0x0a, 0x43, 0xda, 0x2c, 0x8d, 0x6b, 0xb7, 0x54, 0x5e, 0xd9, 0x0f, - 0xae, 0xb2, 0x6d, 0x1b, 0x8b, 0xa0, 0x7e, 0xb2, 0xde, 0x92, 0x4c, 0x2a, 0x70, 0x30, 0x31, 0xd0, 0x43, 0x65, 0x37, - 0x5a, 0x6b, 0x7a, 0x3c, 0x4a, 0x5b, 0xa4, 0x89, 0xb0, 0x36, 0x32, 0xaa, 0xba, 0x8a, 0xb8, 0xbd, 0xd2, 0x97, 0x7c, - 0x05, 0x18, 0x6f, 0xbc, 0xb9, 0xb9, 0x46, 0xc3, 0x1d, 0x73, 0x12, 0xc5, 0x20, 0xde, 0xad, 0x8c, 0x27, 0x4d, 0xeb, - 0xf2, 0xdb, 0x72, 0xe2, 0x53, 0xbd, 0x6e, 0x31, 0x81, 0x0c, 0xce, 0x82, 0x78, 0x3d, 0x03, 0x58, 0x65, 0x57, 0x11, - 0xd5, 0x66, 0x41, 0x82, 0x60, 0x1a, 0x5c, 0xc7, 0x1a, 0xb7, 0x39, 0xca, 0x36, 0x09, 0x7a, 0xe6, 0x68, 0x2c, 0xff, - 0x9d, 0x59, 0xe0, 0xe5, 0x45, 0x81, 0xf6, 0xc4, 0x81, 0xef, 0xc6, 0x33, 0xf6, 0xfa, 0x9f, 0x8f, 0x8c, 0x01, 0x1c, - 0xd4, 0x98, 0x93, 0xd9, 0xe1, 0x55, 0xda, 0xaa, 0xf4, 0x2a, 0xa9, 0xb2, 0x3d, 0xc4, 0xab, 0xde, 0x8c, 0x24, 0x4a, - 0xa9, 0x0b, 0xb6, 0x6c, 0xe6, 0x41, 0xe2, 0x35, 0x47, 0x7f, 0xac, 0x25, 0x92, 0xbd, 0x82, 0x86, 0x91, 0x33, 0xf1, - 0x8b, 0x4f, 0x89, 0x79, 0xa7, 0x7d, 0xc5, 0xe4, 0x79, 0x83, 0x8f, 0xe0, 0x8b, 0x4a, 0x5d, 0x34, 0xde, 0x38, 0x84, - 0x3a, 0xd6, 0x30, 0x41, 0x0a, 0x30, 0x73, 0x80, 0x8b, 0x62, 0xe5, 0x93, 0x51, 0x52, 0xec, 0x9d, 0x16, 0xdb, 0xbc, - 0x16, 0x8a, 0x57, 0xfe, 0xa2, 0x94, 0xa6, 0xf6, 0xf2, 0xa9, 0x0d, 0xe5, 0xfb, 0x0d, 0x74, 0x1a, 0xeb, 0x8d, 0xe8, - 0xa3, 0x94, 0xf2, 0xec, 0x3a, 0xe1, 0x0c, 0x8f, 0xad, 0x8a, 0x59, 0x3c, 0x58, 0x35, 0xce, 0x3e, 0x84, 0x12, 0x1e, - 0x2d, 0x5b, 0x52, 0x76, 0xf3, 0x14, 0x86, 0xbf, 0xc3, 0x4a, 0xa1, 0xa8, 0x45, 0x80, 0xc0, 0xba, 0x8d, 0x7f, 0xdc, - 0x69, 0x3a, 0x6f, 0xa7, 0xb3, 0xc1, 0xc6, 0xe2, 0x68, 0xd8, 0x1e, 0xa9, 0x32, 0xf0, 0xcb, 0xc7, 0x33, 0x6b, 0x4d, - 0x0a, 0x17, 0x78, 0xa8, 0xc2, 0xf6, 0xaf, 0xa3, 0x3d, 0x69, 0xbe, 0x23, 0xe0, 0x8e, 0x41, 0x3a, 0x01, 0xdf, 0x79, - 0x08, 0x5d, 0x00, 0xed, 0x14, 0x62, 0x17, 0x21, 0x75, 0x09, 0x72, 0x97, 0xa1, 0xed, 0x5a, 0x78, 0x40, 0x4c, 0xf0, - 0xb0, 0x32, 0xc3, 0xa3, 0xca, 0x02, 0x8f, 0x2b, 0x7b, 0x78, 0x42, 0x1c, 0xe0, 0x69, 0x65, 0x85, 0x85, 0x8a, 0xea, - 0xb2, 0xba, 0xaa, 0xae, 0xab, 0xa5, 0x32, 0xf4, 0x2b, 0x96, 0x05, 0xc6, 0xbf, 0x20, 0x46, 0xb0, 0xf8, 0xa0, 0x31, - 0xe5, 0xf6, 0xc1, 0xc3, 0x47, 0x8f, 0x9f, 0x3c, 0x0d, 0x55, 0xa6, 0xf3, 0x75, 0xd6, 0xa4, 0xe6, 0x71, 0x6b, 0xa8, - 0x23, 0xef, 0x7c, 0x39, 0xf4, 0x2d, 0x03, 0xea, 0xec, 0x69, 0x1a, 0x86, 0x2f, 0x5d, 0x7e, 0x3d, 0xf1, 0x4b, 0xc5, - 0xeb, 0xc6, 0x37, 0x66, 0x7c, 0xef, 0x8b, 0x4f, 0x13, 0x5a, 0x3c, 0xbd, 0xe0, 0x82, 0x15, 0x3c, 0x98, 0x8f, 0xf3, - 0x2e, 0x0b, 0x11, 0x75, 0xaa, 0x87, 0xc2, 0x5a, 0xf1, 0xfc, 0x83, 0x0e, 0x63, 0x2f, 0x4e, 0x11, 0xd9, 0x37, 0xe7, - 0x86, 0x88, 0xa6, 0xc9, 0xbb, 0x88, 0xaa, 0x7f, 0xb0, 0x7b, 0x29, 0x1a, 0x5d, 0x52, 0xee, 0xd3, 0xdf, 0x9f, 0xad, - 0xc8, 0xbe, 0xa9, 0xe8, 0x41, 0x71, 0x70, 0x56, 0x2d, 0x4c, 0x66, 0x93, 0xa6, 0x4e, 0x36, 0xc0, 0xf7, 0x4c, 0x0a, - 0xaa, 0x92, 0xa1, 0xf6, 0xcf, 0x1e, 0xf8, 0x4f, 0x5a, 0x77, 0xe1, 0xa7, 0xb2, 0x7e, 0xbb, 0xff, 0x9a, 0xbe, 0x2d, - 0x4e, 0x58, 0x9c, 0x58, 0x94, 0x54, 0xd2, 0x99, 0xc9, 0xa2, 0x9e, 0xd7, 0x57, 0xce, 0xcd, 0x3c, 0xc9, 0x2c, 0x9f, - 0xf6, 0x56, 0xd0, 0xd9, 0x6d, 0x80, 0x98, 0x04, 0xea, 0x35, 0x3e, 0x65, 0xf5, 0x66, 0x3c, 0xfd, 0xc4, 0xb2, 0x29, - 0x85, 0x00, 0xa7, 0xc5, 0x17, 0xff, 0xdb, 0xf1, 0x2d, 0xf9, 0x6e, 0xa6, 0x97, 0xf9, 0xfd, 0x9a, 0x6c, 0xe6, 0x46, - 0x0a, 0xdc, 0xf1, 0xcb, 0xd9, 0xe8, 0xb9, 0xc6, 0x7f, 0x33, 0xc8, 0xdb, 0xe4, 0xe9, 0x52, 0xca, 0xd5, 0xc6, 0x0e, - 0x5d, 0x6e, 0xfe, 0xa9, 0x2a, 0x8f, 0x5d, 0x5b, 0xea, 0xf8, 0xe7, 0x5d, 0x1c, 0x8f, 0x5f, 0xf4, 0x3f, 0xde, 0xe7, - 0x1e, 0xef, 0xa2, 0x66, 0x51, 0xfe, 0x3d, 0x54, 0xa9, 0xe3, 0xf7, 0xa7, 0xf9, 0xd2, 0x51, 0x3e, 0x4e, 0x4d, 0xf3, - 0x41, 0x5e, 0x46, 0x2c, 0x8f, 0xd6, 0xf4, 0xf6, 0xcf, 0x9f, 0x12, 0xff, 0x6b, 0x73, 0x9d, 0xed, 0xe1, 0xfe, 0xa4, - 0x5e, 0x6c, 0x63, 0x31, 0x22, 0x66, 0x63, 0xc1, 0x0e, 0xa8, 0xde, 0x3f, 0x3d, 0x87, 0x94, 0x65, 0xfc, 0xa7, 0xc7, - 0x0b, 0x2c, 0x9d, 0xc0, 0x9a, 0xbf, 0xed, 0x98, 0x96, 0xbc, 0xcb, 0x96, 0x63, 0x0c, 0x7d, 0xc6, 0xff, 0xe6, 0x2e, - 0xee, 0xb0, 0x5a, 0x12, 0xa5, 0x18, 0x11, 0x16, 0xfa, 0xf9, 0x8d, 0x0f, 0x42, 0xf4, 0x61, 0xe5, 0xc1, 0x54, 0xaa, - 0x4d, 0xa6, 0x9c, 0x38, 0x81, 0x0f, 0xbe, 0x1d, 0x78, 0x5a, 0x81, 0x37, 0x7f, 0xdb, 0x37, 0x2d, 0xa3, 0x63, 0xf7, - 0x92, 0xbe, 0xbc, 0x9c, 0xb7, 0x4c, 0x4b, 0xbc, 0xd4, 0xc5, 0xed, 0x49, 0x13, 0x7a, 0x67, 0x41, 0x35, 0xc9, 0xc9, - 0xa7, 0xe5, 0x13, 0x0c, 0xfb, 0x45, 0x49, 0xac, 0x9a, 0x56, 0x43, 0x63, 0xd4, 0x9b, 0x26, 0x53, 0x37, 0xb8, 0xd6, - 0xa8, 0x57, 0x81, 0x7f, 0x66, 0x40, 0xee, 0x39, 0x13, 0x7b, 0xce, 0xa0, 0x97, 0x6f, 0x7e, 0x4c, 0x9a, 0xb7, 0xa6, - 0xde, 0x16, 0x5f, 0x2b, 0xa6, 0x52, 0xa2, 0xf0, 0x7d, 0x5d, 0xb8, 0x10, 0x1f, 0x0b, 0x97, 0xab, 0x07, 0xa6, 0xe9, - 0xd3, 0x29, 0xe4, 0xfb, 0x56, 0xe0, 0x9c, 0x91, 0xa3, 0xa0, 0x0d, 0x86, 0x3c, 0x62, 0xde, 0x0f, 0x21, 0xe5, 0x73, - 0x1f, 0xc6, 0xe8, 0xf7, 0xd8, 0x08, 0x28, 0x96, 0xa3, 0xec, 0x2e, 0xc4, 0x3c, 0xcf, 0x62, 0x76, 0xd0, 0xd5, 0x72, - 0xb3, 0xee, 0x98, 0x63, 0x26, 0x56, 0xfc, 0x8c, 0xeb, 0x58, 0xc1, 0x8c, 0x7c, 0xd0, 0xa2, 0x3b, 0xfa, 0x23, 0x2b, - 0xfb, 0x3a, 0x50, 0x77, 0x12, 0x5a, 0x3b, 0x4c, 0xdf, 0x66, 0x19, 0x0e, 0x28, 0x96, 0x08, 0x12, 0x85, 0xe6, 0xf7, - 0x2a, 0x31, 0xb1, 0x52, 0x9c, 0x1a, 0xcd, 0xf1, 0x86, 0xaa, 0xc4, 0x98, 0xa0, 0xe5, 0xed, 0xea, 0x8b, 0xcc, 0x74, - 0x09, 0x1b, 0x37, 0x58, 0xd2, 0x8a, 0xfd, 0xa2, 0xa4, 0x7e, 0xdf, 0x96, 0x85, 0x8a, 0x77, 0xd9, 0xd5, 0x51, 0x5d, - 0xda, 0xa1, 0xa3, 0x22, 0x66, 0xa0, 0xe8, 0xbb, 0x9d, 0x57, 0xce, 0x46, 0xf6, 0x81, 0xdb, 0x09, 0x9c, 0x05, 0x55, - 0x79, 0x9d, 0xea, 0x50, 0x0a, 0x97, 0x05, 0xe0, 0x16, 0x38, 0x3d, 0x3d, 0x09, 0xca, 0xb3, 0xae, 0xac, 0x44, 0x57, - 0x7a, 0x37, 0xe4, 0x3f, 0xd2, 0xe8, 0xc7, 0x39, 0xe2, 0xd9, 0xe7, 0xdd, 0xe9, 0x46, 0xa7, 0xf9, 0x5d, 0xfe, 0x26, - 0xe6, 0xef, 0x2a, 0xfa, 0xf2, 0x97, 0x2d, 0x6f, 0x5f, 0x07, 0xc2, 0x60, 0x69, 0x86, 0xf8, 0xda, 0x52, 0x79, 0x8d, - 0xb9, 0x0f, 0x31, 0x4d, 0xc2, 0xc6, 0xc8, 0xa3, 0x29, 0xe0, 0x3c, 0xdf, 0xbb, 0x51, 0x7a, 0x6d, 0x4b, 0x82, 0x50, - 0xe2, 0x3d, 0xdf, 0x4a, 0xbf, 0xe7, 0x71, 0x11, 0x0b, 0x32, 0xdb, 0xd0, 0xe9, 0xf9, 0xa8, 0x8e, 0x21, 0xe6, 0xc6, - 0xd3, 0x2e, 0xec, 0xa9, 0x5a, 0xab, 0x05, 0x49, 0xd2, 0xeb, 0x3c, 0xdf, 0xfd, 0xce, 0xa4, 0x8c, 0xe0, 0x51, 0x13, - 0x24, 0x7e, 0xee, 0xeb, 0x3f, 0xef, 0x4c, 0x0d, 0x7a, 0x0b, 0x78, 0x5a, 0x3a, 0xe8, 0xc9, 0x27, 0x8e, 0x5f, 0x0b, - 0xcb, 0x6d, 0xa3, 0x4b, 0xff, 0x7d, 0x9e, 0xad, 0x4b, 0x26, 0x4c, 0xba, 0x45, 0x32, 0x1f, 0x2b, 0x13, 0xe8, 0xa9, - 0x69, 0x9d, 0x90, 0xaa, 0xfe, 0x89, 0x15, 0xd4, 0x28, 0x70, 0xa0, 0x94, 0xba, 0x9a, 0xb0, 0x10, 0xdc, 0x09, 0x8f, - 0xcf, 0x4b, 0x01, 0xd1, 0xdc, 0xbd, 0x08, 0x92, 0x8b, 0xc4, 0x6b, 0x41, 0x25, 0x56, 0x65, 0xc5, 0x65, 0x55, 0x52, - 0x09, 0xa6, 0xf0, 0x03, 0x00, 0xcd, 0x7b, 0x05, 0x4c, 0x72, 0xa0, 0x47, 0x54, 0x3a, 0xf9, 0xa8, 0x11, 0x1f, 0xf0, - 0x64, 0x93, 0xfe, 0xa1, 0xa7, 0xb8, 0x24, 0x4e, 0x9c, 0xe9, 0x50, 0x82, 0xfb, 0x63, 0xd2, 0xfa, 0x53, 0xc5, 0x7c, - 0x8b, 0x89, 0x0e, 0xea, 0xf2, 0xf6, 0xfa, 0x3a, 0xab, 0x89, 0x84, 0x1a, 0x70, 0xc3, 0x9a, 0xd6, 0x94, 0xba, 0x6e, - 0x03, 0x4d, 0x1f, 0x40, 0xdf, 0xb3, 0xea, 0xf6, 0x47, 0x85, 0xe5, 0x40, 0x6e, 0x72, 0x5b, 0x8d, 0xa2, 0x8d, 0xff, - 0x38, 0xb8, 0xa9, 0xc6, 0x29, 0x70, 0x5d, 0xcd, 0x64, 0x99, 0x80, 0xab, 0x6a, 0x8a, 0x00, 0x2e, 0xab, 0xf9, 0x09, - 0xf5, 0xbd, 0xfa, 0x82, 0x8c, 0x5f, 0xea, 0xf0, 0xf5, 0x7e, 0xae, 0x20, 0xf4, 0xce, 0x4f, 0x59, 0x5b, 0x39, 0xf3, - 0x9c, 0xf7, 0xeb, 0x94, 0x83, 0xf7, 0x1b, 0xba, 0xee, 0xf0, 0xa9, 0xce, 0xbc, 0x0d, 0xd0, 0xfe, 0x29, 0x6f, 0xde, - 0xe9, 0x29, 0x62, 0xef, 0xe4, 0x6a, 0xee, 0xee, 0xff, 0x69, 0xe8, 0x79, 0x6f, 0x78, 0xaa, 0xb4, 0x95, 0xe3, 0x4a, - 0x8f, 0xde, 0xd1, 0xbf, 0x96, 0xda, 0x02, 0x87, 0xd5, 0x54, 0x0a, 0x1c, 0x54, 0x93, 0x2b, 0xb0, 0x5f, 0xcd, 0xab, - 0x5a, 0x3b, 0x00, 0x9b, 0xd5, 0x20, 0x03, 0x7b, 0xd5, 0x64, 0x0d, 0xd8, 0xaa, 0x46, 0xbb, 0x4f, 0x1e, 0xd8, 0xae, - 0x06, 0x6b, 0x60, 0x67, 0x64, 0x5e, 0xe7, 0xed, 0xfe, 0x33, 0xf1, 0xdc, 0xc0, 0x58, 0xaf, 0xdb, 0xcb, 0x3e, 0x33, - 0xef, 0x75, 0x0d, 0x4c, 0x97, 0x65, 0x5b, 0xac, 0x5a, 0x80, 0x0d, 0xfa, 0xef, 0x48, 0xd3, 0xd6, 0x0a, 0x7e, 0x23, - 0x90, 0xc0, 0xfc, 0xec, 0x2c, 0x60, 0x01, 0xa3, 0xff, 0xd0, 0xe3, 0xd8, 0xc0, 0x50, 0x4b, 0xac, 0x4f, 0xd6, 0x31, - 0x6d, 0xd3, 0xff, 0xc7, 0xc6, 0x66, 0x1b, 0x25, 0x48, 0xb7, 0xed, 0x35, 0xbe, 0xbc, 0x65, 0x47, 0x68, 0x3f, 0x52, - 0xd4, 0xa5, 0xb4, 0x0a, 0xf0, 0x57, 0x4b, 0x72, 0xf1, 0xe8, 0xb2, 0x3d, 0x13, 0xbe, 0x57, 0x67, 0xb1, 0xce, 0x94, - 0x90, 0x88, 0x1d, 0xe2, 0xea, 0xfe, 0xbf, 0x3c, 0xf5, 0x95, 0x42, 0x65, 0x11, 0x27, 0xc5, 0x47, 0x7c, 0x4c, 0xb6, - 0x0a, 0x4e, 0x11, 0xc7, 0x42, 0x8c, 0x43, 0x37, 0x4f, 0xce, 0x61, 0xd5, 0x24, 0x0a, 0xfb, 0x47, 0xc2, 0x6b, 0xd0, - 0xf0, 0x9b, 0x6c, 0x39, 0xc0, 0xce, 0x19, 0x86, 0xd0, 0x8c, 0xbe, 0xb3, 0x9c, 0x49, 0x95, 0x93, 0x57, 0x30, 0x8e, - 0xe8, 0x58, 0xfd, 0x8e, 0x94, 0x97, 0xf7, 0xa5, 0xca, 0x97, 0x43, 0x50, 0xb6, 0x1f, 0xe0, 0x1d, 0x14, 0xc5, 0xf4, - 0xb9, 0x0c, 0x16, 0xf8, 0x5e, 0x77, 0x0f, 0x0f, 0xf0, 0xc2, 0x26, 0xfc, 0x58, 0x2b, 0x4f, 0x78, 0x67, 0xab, 0x96, - 0xd1, 0x63, 0xe1, 0x60, 0x06, 0xeb, 0x20, 0xfe, 0xaf, 0x79, 0xdd, 0x32, 0x80, 0x12, 0x38, 0xe1, 0xe6, 0x4c, 0x7b, - 0x7d, 0x9d, 0x3e, 0xb0, 0x32, 0x69, 0x8a, 0x5d, 0xf4, 0x07, 0xe6, 0x2a, 0xca, 0x6b, 0xa8, 0xe2, 0x34, 0xbb, 0x91, - 0xba, 0xc0, 0xe3, 0x7b, 0xa5, 0x3b, 0x8f, 0x3b, 0x3c, 0x74, 0xed, 0x0a, 0x8a, 0x5c, 0x23, 0x06, 0xda, 0x5d, 0x00, - 0xdd, 0x2e, 0xc3, 0xc6, 0xbf, 0xea, 0x27, 0xa9, 0x08, 0x44, 0x34, 0xe3, 0x5e, 0xf8, 0x17, 0xb0, 0xad, 0x1b, 0xdd, - 0xa2, 0x94, 0x8e, 0x2e, 0x72, 0xeb, 0xad, 0xc4, 0x9d, 0xa9, 0x64, 0xa3, 0x41, 0x0d, 0x58, 0xce, 0xbd, 0xff, 0x3b, - 0xd5, 0x5c, 0x52, 0x7f, 0xc8, 0xc4, 0x41, 0xdf, 0x9a, 0xad, 0x90, 0x05, 0xff, 0x32, 0xc9, 0x7f, 0xc6, 0x3d, 0x3e, - 0xf1, 0xd5, 0xf3, 0x90, 0x36, 0xdd, 0xb4, 0x02, 0x76, 0xd7, 0x14, 0x59, 0x9f, 0xf2, 0x71, 0x11, 0x46, 0xce, 0x82, - 0x47, 0xf8, 0x03, 0x3a, 0x09, 0x71, 0xd9, 0xfb, 0xda, 0x8b, 0xbd, 0xe8, 0xb9, 0x4c, 0xfa, 0xc7, 0xac, 0x5e, 0xa4, - 0xe3, 0x3c, 0xd4, 0x66, 0xef, 0xa8, 0x5f, 0xa3, 0x0c, 0xd5, 0x9b, 0xb3, 0x9e, 0xc3, 0x36, 0x30, 0xea, 0x4e, 0x89, - 0x56, 0x11, 0xa5, 0x1b, 0x31, 0x68, 0x28, 0x39, 0x19, 0x5f, 0xe9, 0x6c, 0xbf, 0xbb, 0xbc, 0xe3, 0x56, 0x53, 0x0a, - 0x14, 0xfb, 0x46, 0xb9, 0xc6, 0x87, 0x9f, 0x25, 0x2b, 0x22, 0xcb, 0x7a, 0xc1, 0x78, 0xf6, 0x1a, 0xd6, 0x82, 0x69, - 0x8a, 0xb6, 0x50, 0x7b, 0x71, 0x71, 0xd1, 0x84, 0x6b, 0x9d, 0xdc, 0xb8, 0x3b, 0x1f, 0x16, 0x76, 0x41, 0x09, 0xb7, - 0xbd, 0xc2, 0xd3, 0xc1, 0xdc, 0x67, 0x46, 0x28, 0x89, 0x06, 0xe0, 0x04, 0x5c, 0x3a, 0x86, 0xeb, 0xc6, 0xf1, 0x5e, - 0x03, 0x78, 0x58, 0x2f, 0xe0, 0x0c, 0x50, 0xa9, 0x78, 0x45, 0x77, 0xab, 0x1e, 0xbf, 0x3b, 0x4b, 0xf3, 0xeb, 0x44, - 0x19, 0x14, 0x96, 0x83, 0x87, 0x96, 0x32, 0xcf, 0x34, 0xa8, 0x51, 0x83, 0x8d, 0x54, 0xac, 0x90, 0x17, 0xaf, 0x79, - 0xd0, 0xba, 0xea, 0x85, 0x3d, 0x41, 0x4a, 0x3f, 0x2f, 0x73, 0x0b, 0xaa, 0x81, 0x81, 0x84, 0x58, 0x36, 0x99, 0xd5, - 0x1f, 0x1d, 0x3c, 0x77, 0xaf, 0xa6, 0x9a, 0x6c, 0xb2, 0x05, 0x99, 0xf7, 0xeb, 0xef, 0xad, 0x24, 0x5b, 0x7e, 0x71, - 0x27, 0xdb, 0x5f, 0xef, 0x97, 0x42, 0xc9, 0xe8, 0xcd, 0x38, 0x3b, 0xd6, 0x83, 0x72, 0xd6, 0x81, 0x40, 0x44, 0x7e, - 0x5b, 0x1d, 0x0a, 0xc4, 0x62, 0x32, 0x82, 0x32, 0x85, 0x19, 0x61, 0xdf, 0xd0, 0x5d, 0x1e, 0xc6, 0xdb, 0xb7, 0x13, - 0xd8, 0x4c, 0x2c, 0x28, 0xc0, 0x58, 0xdc, 0x4e, 0x4e, 0x27, 0xd7, 0x50, 0x09, 0xe6, 0x31, 0xc7, 0x50, 0x09, 0xc9, - 0xbd, 0x72, 0x66, 0xfd, 0x58, 0x64, 0x15, 0x35, 0x06, 0xc9, 0x95, 0x55, 0xc0, 0xb2, 0xb7, 0x90, 0x90, 0x71, 0xc8, - 0x28, 0x72, 0x02, 0xfb, 0x45, 0x6a, 0x46, 0x41, 0x49, 0xfc, 0x6f, 0xe6, 0x9e, 0xb9, 0x99, 0x7b, 0xe5, 0xc3, 0xb2, - 0x34, 0x29, 0x49, 0x56, 0x69, 0x13, 0xf4, 0x13, 0x3a, 0x7e, 0x89, 0xd8, 0xe9, 0xb4, 0x21, 0x34, 0xb0, 0xc7, 0x38, - 0x32, 0x82, 0xa9, 0x30, 0xa1, 0x7a, 0x57, 0x6f, 0x40, 0x12, 0x78, 0x80, 0xd1, 0xce, 0x44, 0x4e, 0x68, 0xe0, 0xc3, - 0xd9, 0x68, 0xe3, 0xe6, 0xe1, 0x77, 0x4e, 0x54, 0xaa, 0x85, 0x9d, 0xe5, 0xdf, 0x5b, 0x70, 0xa5, 0xfd, 0x4d, 0x40, - 0x95, 0xc0, 0xc5, 0xe4, 0xdf, 0xbf, 0xfb, 0x71, 0xe8, 0xdf, 0x2a, 0xe7, 0x8f, 0x16, 0x8b, 0x6f, 0x23, 0x3b, 0x5c, - 0x82, 0xb5, 0xae, 0x23, 0xe7, 0x22, 0x3f, 0x33, 0x1b, 0xaa, 0xe1, 0xf7, 0x43, 0xdd, 0xee, 0xe4, 0x42, 0xa9, 0x5a, - 0x7c, 0x32, 0x72, 0xdc, 0x51, 0xc9, 0x13, 0x89, 0x86, 0x58, 0xd1, 0xf2, 0x2b, 0x26, 0x9e, 0xd1, 0xf8, 0x52, 0xf1, - 0x48, 0x16, 0xee, 0x1f, 0xb9, 0xf6, 0x92, 0xa9, 0x83, 0x6c, 0xc0, 0x64, 0x64, 0xae, 0xfc, 0x76, 0xdc, 0x68, 0x1f, - 0xe6, 0x0f, 0x7f, 0xaa, 0x4f, 0xc4, 0xde, 0xff, 0x9a, 0x51, 0x76, 0xbd, 0x2c, 0x24, 0x3f, 0x61, 0xe1, 0x09, 0x7f, - 0x1c, 0xa1, 0xe0, 0xe5, 0xf4, 0xf1, 0x82, 0x38, 0x8e, 0x9e, 0x2b, 0x76, 0x20, 0x4b, 0x25, 0x2a, 0xee, 0x22, 0x48, - 0x42, 0x14, 0xe1, 0x09, 0xa0, 0xe4, 0x7d, 0x5c, 0x89, 0x4a, 0xb3, 0x98, 0x98, 0x4f, 0x46, 0x2a, 0x00, 0x67, 0x07, - 0xd1, 0xd0, 0x4a, 0xa4, 0x27, 0xea, 0x24, 0xa2, 0x21, 0x47, 0x67, 0x83, 0xfe, 0x9d, 0x78, 0x16, 0x1b, 0xa2, 0x22, - 0x40, 0x4c, 0x41, 0xd4, 0xb1, 0x15, 0x6f, 0x94, 0x81, 0x2b, 0xea, 0xa1, 0x44, 0x82, 0xd6, 0xd4, 0x19, 0xa0, 0x29, - 0x21, 0x20, 0xc7, 0x5c, 0xee, 0xa1, 0x87, 0x19, 0xa6, 0x6d, 0xb7, 0xab, 0xa8, 0x30, 0xde, 0x1f, 0xce, 0xcb, 0xa8, - 0xd4, 0x76, 0x0a, 0x44, 0xa9, 0x7a, 0x1a, 0xc6, 0x38, 0x1d, 0x2b, 0x84, 0x77, 0x01, 0xc5, 0x25, 0xc9, 0x4a, 0x8c, - 0xfa, 0x6e, 0x34, 0x6d, 0xaa, 0x11, 0x12, 0x38, 0xe9, 0xdd, 0xf4, 0x3a, 0x40, 0x49, 0x0c, 0x26, 0x58, 0x1c, 0xc1, - 0x66, 0xf0, 0xc9, 0xb1, 0x46, 0x90, 0x94, 0x50, 0xa0, 0x54, 0x99, 0xf1, 0x47, 0xad, 0xa4, 0x20, 0xf1, 0x3e, 0xfc, - 0xfc, 0x53, 0x65, 0xf1, 0xc4, 0x0a, 0x1b, 0xef, 0x63, 0x7e, 0x5f, 0xc9, 0xab, 0x1e, 0x84, 0x89, 0x07, 0xc4, 0xb7, - 0x09, 0x1c, 0x61, 0x99, 0xca, 0x65, 0x83, 0x0c, 0x05, 0x28, 0x38, 0xd5, 0x9a, 0xb3, 0x38, 0x13, 0x9b, 0x46, 0xaa, - 0x30, 0xe8, 0x11, 0x4c, 0x91, 0xfc, 0x8b, 0xb8, 0x7f, 0xdf, 0x06, 0xc4, 0xf8, 0x14, 0x1f, 0xfa, 0xc9, 0xeb, 0x9d, - 0x85, 0x2a, 0x98, 0x10, 0xf5, 0xe2, 0x3f, 0x8f, 0x97, 0xc5, 0xae, 0x1f, 0x1f, 0x36, 0x15, 0x48, 0x20, 0xf6, 0x3c, - 0x0d, 0xce, 0x7f, 0xc6, 0x2c, 0x09, 0x03, 0x69, 0x71, 0x6f, 0x4a, 0xe0, 0x4d, 0x2f, 0x58, 0x9a, 0x4d, 0x80, 0x09, - 0x52, 0x9c, 0x8c, 0xb6, 0x16, 0xac, 0x53, 0x4f, 0x8d, 0xd2, 0x99, 0x13, 0x60, 0x52, 0xa9, 0x87, 0x90, 0x9e, 0x7a, - 0x81, 0xde, 0xb1, 0xa2, 0x1f, 0xe3, 0xad, 0x86, 0xb7, 0xec, 0x6a, 0x91, 0x0a, 0x8b, 0xd0, 0x19, 0x08, 0x2e, 0x0b, - 0x4e, 0xf1, 0xfb, 0x08, 0x75, 0x0d, 0xc3, 0x9a, 0xb1, 0xc9, 0x89, 0x1a, 0x2a, 0xc8, 0xb4, 0xee, 0xe9, 0x60, 0xef, - 0x65, 0xde, 0x24, 0xcc, 0x9c, 0x0f, 0x47, 0x94, 0x6c, 0x78, 0xe3, 0x1e, 0x4f, 0x3f, 0x20, 0xdd, 0x19, 0xa4, 0x4f, - 0x30, 0x3d, 0xa6, 0x50, 0x12, 0x41, 0x73, 0x5f, 0x5e, 0x92, 0xab, 0x47, 0x87, 0xbd, 0x52, 0x37, 0xdd, 0x2f, 0xf7, - 0x72, 0xd2, 0xb8, 0x85, 0xe9, 0x2a, 0xec, 0x76, 0xf7, 0xa3, 0x7e, 0x10, 0x38, 0xea, 0xa5, 0x9a, 0x66, 0xa0, 0x66, - 0x92, 0x62, 0x67, 0xf2, 0x56, 0xca, 0x37, 0x8e, 0xa5, 0x17, 0xd4, 0x79, 0x5a, 0x33, 0x6f, 0x72, 0x9f, 0x15, 0x8a, - 0xb2, 0xc2, 0xda, 0xa1, 0x8c, 0x61, 0xa8, 0x92, 0xe1, 0x71, 0x1a, 0xc1, 0xba, 0x28, 0x8a, 0xb6, 0xe8, 0x3a, 0x73, - 0x34, 0xa7, 0x5d, 0xa5, 0x4b, 0xb2, 0xc4, 0xf7, 0xe8, 0x47, 0x13, 0x95, 0xfd, 0x25, 0x7a, 0x91, 0x5a, 0x5a, 0x99, - 0xb6, 0x77, 0xd7, 0xd4, 0xf1, 0x3b, 0x1b, 0x85, 0x3f, 0xba, 0xc1, 0x67, 0xa6, 0xf3, 0xd1, 0x06, 0x37, 0x96, 0xee, - 0x08, 0x67, 0xb0, 0x6d, 0x63, 0x3f, 0xef, 0x03, 0x46, 0x85, 0x0f, 0xa8, 0xbe, 0x9e, 0xe6, 0x1d, 0xae, 0x1d, 0x71, - 0x53, 0x5b, 0xcf, 0xff, 0x66, 0x3a, 0x07, 0xe5, 0x07, 0x7c, 0xc0, 0xa6, 0xcf, 0xf6, 0x16, 0x2c, 0x95, 0xaf, 0x3b, - 0x01, 0x63, 0x49, 0xd5, 0x55, 0x98, 0xad, 0xd9, 0xd2, 0x60, 0x80, 0xe2, 0x22, 0x3f, 0x73, 0x2a, 0x88, 0x70, 0x89, - 0x4c, 0x70, 0x03, 0x45, 0x1f, 0x50, 0x42, 0x99, 0xb1, 0x08, 0x0f, 0xc6, 0x57, 0x98, 0x48, 0x8c, 0xf4, 0x27, 0x14, - 0xc6, 0xe3, 0x8e, 0x7f, 0x7e, 0xce, 0xcf, 0xbb, 0x66, 0xa7, 0xbd, 0xc2, 0xd8, 0xc4, 0xee, 0x59, 0xe8, 0xf8, 0x83, - 0x9c, 0xfd, 0xce, 0xfc, 0x45, 0x30, 0xad, 0xf3, 0x17, 0xc2, 0x7e, 0xe6, 0x44, 0x82, 0xbf, 0xf6, 0xd4, 0xf6, 0x06, - 0x62, 0x39, 0x11, 0xa5, 0x94, 0xfa, 0xc6, 0xbc, 0x55, 0x83, 0x05, 0x50, 0xc2, 0xec, 0xa7, 0x51, 0x7f, 0x6d, 0xe3, - 0x4f, 0x1d, 0x5a, 0x17, 0x4c, 0xbd, 0x16, 0x30, 0x64, 0x86, 0x11, 0x2d, 0x58, 0x13, 0x69, 0x64, 0xe6, 0x8f, 0x33, - 0xb9, 0x90, 0xf3, 0x3e, 0xf3, 0xe7, 0x19, 0x9f, 0x73, 0xea, 0xb7, 0xd5, 0xe6, 0x4a, 0xce, 0xd0, 0x5f, 0xc5, 0xb3, - 0xb7, 0x97, 0x18, 0xdd, 0xf5, 0x0e, 0x62, 0x24, 0xf6, 0x95, 0x3e, 0x9c, 0xfe, 0x73, 0x77, 0x8e, 0x5c, 0x06, 0x3c, - 0x9e, 0x8b, 0x15, 0x67, 0x7e, 0x80, 0xf2, 0x34, 0xb7, 0x36, 0x4e, 0x10, 0xa9, 0xc9, 0xd2, 0xb8, 0xf2, 0x7c, 0x2f, - 0xe3, 0x60, 0x41, 0x76, 0x6c, 0x87, 0x3d, 0xd3, 0xf7, 0xb4, 0xd7, 0xd7, 0x1e, 0xc8, 0x88, 0x3f, 0x97, 0xbe, 0x9f, - 0x40, 0x94, 0xab, 0xad, 0xa7, 0x2a, 0xaa, 0xe6, 0x5e, 0x1e, 0xac, 0xf3, 0x2b, 0x2f, 0xfd, 0xc9, 0xd3, 0x4e, 0xf7, - 0xe5, 0x6e, 0xdd, 0x80, 0x4c, 0x71, 0xa2, 0xf6, 0xc9, 0xc7, 0x0c, 0x11, 0xc5, 0xc5, 0x6f, 0x5c, 0x4f, 0xef, 0x30, - 0xe1, 0x7b, 0x5f, 0x20, 0xcb, 0x47, 0x2a, 0x71, 0xc1, 0x7a, 0x4e, 0x53, 0x63, 0xf9, 0x2e, 0xbb, 0xfd, 0xa2, 0x6d, - 0x83, 0x31, 0xe9, 0xd1, 0x8c, 0xcf, 0xfa, 0x6f, 0xac, 0x03, 0x00, 0x16, 0x7f, 0x97, 0xf8, 0x4a, 0xac, 0xe6, 0xb9, - 0xc9, 0x12, 0x5b, 0x71, 0xd5, 0x78, 0x5e, 0x27, 0x72, 0x90, 0xdd, 0x64, 0x87, 0x08, 0x4d, 0x11, 0x31, 0xed, 0xa5, - 0x7a, 0xbd, 0x41, 0xdf, 0x41, 0x84, 0x85, 0x8a, 0xea, 0x4c, 0x3f, 0x69, 0x0a, 0x33, 0x46, 0xa7, 0x23, 0x40, 0xe5, - 0x80, 0xa4, 0x2f, 0x0f, 0xbe, 0x7d, 0x25, 0x64, 0x05, 0xee, 0x72, 0xe7, 0xb2, 0x90, 0x02, 0xfb, 0xf0, 0xa1, 0xe9, - 0x6f, 0xcf, 0xf3, 0x3c, 0x57, 0x59, 0x3c, 0x8f, 0x7f, 0x92, 0x1c, 0x26, 0x26, 0xda, 0x1a, 0x45, 0x3c, 0xd1, 0x02, - 0x6f, 0x7f, 0xd1, 0x14, 0x09, 0x57, 0x95, 0xc2, 0x8a, 0x02, 0xbd, 0x6a, 0x74, 0x49, 0x50, 0xbc, 0x2b, 0x93, 0x40, - 0x67, 0x70, 0x5d, 0x32, 0x58, 0xb0, 0x3b, 0x15, 0xd2, 0x73, 0x6a, 0x2e, 0xd8, 0xce, 0x04, 0x78, 0x7d, 0x48, 0xe5, - 0xc6, 0x2c, 0x55, 0x48, 0x59, 0x54, 0xe7, 0x05, 0x78, 0xdb, 0xe3, 0xa0, 0xd5, 0x89, 0xc2, 0xde, 0x6b, 0x01, 0x68, - 0xc0, 0xf2, 0xb9, 0xcd, 0x03, 0x5b, 0x72, 0x80, 0xa6, 0x41, 0xd3, 0x31, 0x80, 0xec, 0xef, 0x7c, 0x95, 0xf4, 0xbf, - 0xbd, 0xe3, 0x4f, 0xeb, 0x16, 0x0c, 0x21, 0x63, 0x36, 0x4f, 0x9f, 0xb5, 0x68, 0x08, 0x14, 0x6f, 0xa3, 0x48, 0xc4, - 0x9f, 0x59, 0xab, 0x2a, 0x0d, 0x0c, 0xb9, 0x0e, 0x83, 0x5a, 0xa5, 0x9d, 0xcc, 0x99, 0x96, 0x59, 0xa9, 0x53, 0xb5, - 0xb0, 0x7b, 0x80, 0x0a, 0x3c, 0xa8, 0xde, 0x4b, 0x0d, 0x2a, 0x07, 0x18, 0x8a, 0xe2, 0xa2, 0x0c, 0x9d, 0x8a, 0x7b, - 0xfa, 0x3e, 0x4f, 0xb1, 0x09, 0xff, 0xf9, 0xb2, 0xf2, 0x8d, 0xe7, 0x20, 0x5e, 0x4a, 0xff, 0xb9, 0x53, 0x7e, 0x8c, - 0xbc, 0x86, 0xfa, 0xea, 0x2a, 0x0a, 0x73, 0x3c, 0x62, 0xbc, 0xb3, 0x31, 0x34, 0x02, 0xb1, 0x94, 0xe2, 0x09, 0xe1, - 0xc5, 0x36, 0x0a, 0x3e, 0x87, 0x0a, 0xb4, 0x19, 0x01, 0x58, 0xb8, 0xbb, 0x16, 0xfc, 0xcb, 0xd7, 0x90, 0xbf, 0x57, - 0x3c, 0xa2, 0xa9, 0x4c, 0x6e, 0x57, 0xa7, 0xec, 0x6b, 0xb8, 0xc2, 0xe8, 0xed, 0xfb, 0xbe, 0x5e, 0xe6, 0xeb, 0x4e, - 0xff, 0xe1, 0x13, 0x3e, 0x76, 0xa7, 0xfd, 0x65, 0xe7, 0x61, 0x9d, 0x91, 0x7f, 0x3c, 0x36, 0x79, 0x1b, 0xd6, 0xb4, - 0x1b, 0xec, 0x65, 0x8f, 0x89, 0xc3, 0x4c, 0x3c, 0x14, 0xe3, 0xdf, 0x16, 0x15, 0x38, 0xe6, 0x85, 0x06, 0x52, 0x6b, - 0x7f, 0x4e, 0x1f, 0xff, 0xaa, 0xb0, 0x43, 0xcc, 0x0e, 0xac, 0x04, 0x81, 0xee, 0x7b, 0x05, 0x6e, 0x29, 0x5d, 0x0a, - 0xb4, 0xf5, 0x68, 0x51, 0x2e, 0xae, 0xef, 0x01, 0x21, 0xb5, 0xb6, 0x59, 0xd5, 0x65, 0xa2, 0x71, 0x29, 0x8e, 0x57, - 0xa7, 0x3e, 0xfa, 0x03, 0x2d, 0xf6, 0xd9, 0xc5, 0x16, 0x9a, 0x5a, 0x88, 0x33, 0x71, 0x36, 0x2e, 0xb8, 0x19, 0x37, - 0xeb, 0xce, 0x81, 0x0a, 0x7d, 0x35, 0x18, 0xca, 0x92, 0xd0, 0xda, 0xc0, 0x10, 0x4c, 0x2b, 0x37, 0x58, 0x14, 0x25, - 0xdd, 0x51, 0x2f, 0x8e, 0xb6, 0x0d, 0x2e, 0xdc, 0x46, 0x8c, 0x72, 0xbc, 0x65, 0x7f, 0x81, 0x2a, 0x61, 0xfe, 0x92, - 0x59, 0xe4, 0x5d, 0x93, 0xce, 0x9f, 0x23, 0x3b, 0x10, 0xb3, 0xd4, 0x96, 0xb5, 0x5a, 0x85, 0x9b, 0x09, 0xf9, 0xa6, - 0x7b, 0x8a, 0x42, 0x81, 0x6d, 0x53, 0xb9, 0x8b, 0xff, 0x3d, 0xe2, 0x6a, 0xe7, 0x1c, 0xf9, 0x37, 0x9b, 0xe6, 0xb9, - 0x9e, 0x89, 0x03, 0xa2, 0x9c, 0xae, 0x82, 0x99, 0xc3, 0x70, 0xc7, 0x3d, 0xf5, 0xf3, 0x22, 0x3d, 0xcf, 0xa8, 0x63, - 0x25, 0xb7, 0xef, 0xd4, 0x2f, 0xfc, 0x52, 0x3c, 0xf6, 0x0b, 0x7d, 0x61, 0xcf, 0x95, 0x78, 0x4b, 0xb7, 0x4f, 0x4e, - 0xa2, 0xd5, 0xa8, 0x9c, 0x9a, 0x76, 0xe1, 0x25, 0xd4, 0x6c, 0x6f, 0x68, 0xaf, 0xcc, 0x2d, 0x7a, 0xd9, 0x1d, 0xee, - 0x57, 0x76, 0x8a, 0x22, 0x76, 0xa5, 0x60, 0x9f, 0x56, 0xd2, 0xe3, 0x4a, 0x9c, 0x73, 0xa1, 0xb3, 0x4e, 0x2d, 0xa0, - 0x28, 0xcb, 0x00, 0x2b, 0x2f, 0x15, 0xfa, 0x6d, 0xc5, 0x13, 0x94, 0x1c, 0xa6, 0x36, 0x1b, 0x47, 0xcf, 0x7a, 0x49, - 0x25, 0xf7, 0xd5, 0x06, 0xf7, 0x92, 0x91, 0xd6, 0xde, 0xe1, 0xf2, 0xce, 0x56, 0x1f, 0xf1, 0xb4, 0x87, 0xfb, 0x8f, - 0x59, 0x81, 0x7f, 0xab, 0xca, 0xfb, 0x86, 0x31, 0x14, 0x02, 0xb5, 0xce, 0xa5, 0xd4, 0xb4, 0x49, 0xb8, 0x64, 0x10, - 0xee, 0x1d, 0xac, 0x11, 0x51, 0x27, 0xb0, 0xbf, 0x46, 0xc9, 0x6a, 0x67, 0xc0, 0xe7, 0x49, 0x88, 0x98, 0x13, 0xe5, - 0xb1, 0x7f, 0x7e, 0xb7, 0xb2, 0xc9, 0x9f, 0x98, 0x43, 0x9d, 0xa1, 0x4a, 0x58, 0x87, 0xf8, 0x83, 0xb8, 0x79, 0xd5, - 0xeb, 0xdb, 0x25, 0xca, 0xdf, 0x4c, 0x4f, 0x2c, 0xcc, 0x0a, 0x2b, 0xf8, 0x4b, 0x29, 0x5f, 0xdf, 0xf1, 0x01, 0xd4, - 0x67, 0x93, 0x1f, 0xff, 0xbc, 0xa1, 0x7b, 0xd9, 0x95, 0xff, 0x72, 0x87, 0x40, 0x31, 0xed, 0xe2, 0x70, 0x8e, 0x4b, - 0x87, 0xc2, 0xec, 0x02, 0xc3, 0xe2, 0x45, 0x55, 0x1d, 0xf0, 0xe1, 0x39, 0xe2, 0xe3, 0xf3, 0x24, 0x2e, 0x88, 0x84, - 0xd2, 0xfc, 0x79, 0x50, 0x37, 0xa3, 0xe3, 0xc2, 0x86, 0x3e, 0x38, 0x9c, 0xd1, 0x00, 0x84, 0xac, 0xb1, 0xc9, 0xf6, - 0x63, 0x95, 0x52, 0x99, 0x59, 0x3a, 0x72, 0xc7, 0xc6, 0x76, 0xb5, 0xd4, 0xe3, 0xbb, 0x7e, 0x1f, 0xee, 0x64, 0xd3, - 0xb2, 0x7e, 0xca, 0x10, 0xfb, 0xa8, 0x0b, 0xc3, 0x05, 0xc8, 0xda, 0x0f, 0xb9, 0xf6, 0x62, 0x19, 0xdb, 0x80, 0x3e, - 0xc4, 0xfe, 0xa7, 0x5d, 0x9c, 0xd1, 0xad, 0xf0, 0xb1, 0x58, 0xb4, 0x3a, 0xdb, 0x90, 0x24, 0x07, 0x46, 0x73, 0x48, - 0x30, 0x6e, 0x44, 0x68, 0x1b, 0x94, 0xe0, 0xb1, 0x62, 0x0a, 0x37, 0xe2, 0xfe, 0x38, 0x58, 0x68, 0x55, 0xd1, 0x8d, - 0xb9, 0x8d, 0x6d, 0x14, 0x67, 0xf0, 0xf5, 0x80, 0x7f, 0x15, 0x65, 0x7c, 0x80, 0xbb, 0x41, 0xe4, 0xce, 0x9e, 0x97, - 0x92, 0x48, 0x2d, 0xa7, 0xdb, 0x56, 0x6c, 0xe7, 0x97, 0xd8, 0xed, 0x82, 0x3d, 0x5f, 0x16, 0xe6, 0xcc, 0xd6, 0x20, - 0x33, 0x8c, 0xbd, 0xb7, 0xc0, 0xbb, 0x6d, 0x29, 0x4c, 0x76, 0xe9, 0x1b, 0xa8, 0x84, 0x56, 0xe7, 0xa3, 0xc3, 0x78, - 0x53, 0x07, 0xae, 0xe2, 0x78, 0x1a, 0xdb, 0x56, 0x62, 0x34, 0x46, 0x1e, 0xc9, 0x30, 0x95, 0xe1, 0x10, 0x7f, 0x24, - 0xbb, 0x29, 0x37, 0xd3, 0xa5, 0xce, 0x2e, 0x0d, 0x10, 0x74, 0x85, 0x55, 0x0f, 0x85, 0x24, 0x11, 0x70, 0xcb, 0x15, - 0xc8, 0xc3, 0x70, 0x3f, 0xaf, 0xc6, 0xc8, 0xe1, 0x6c, 0x81, 0xd0, 0xf0, 0xb2, 0x51, 0x90, 0x39, 0xf1, 0xed, 0x21, - 0xf1, 0x72, 0x6a, 0x7a, 0xc7, 0x11, 0x0f, 0x86, 0xea, 0x36, 0x0f, 0x5e, 0x8e, 0xaa, 0x4e, 0x67, 0xe9, 0x91, 0x70, - 0x4b, 0xe7, 0x59, 0x49, 0xca, 0x51, 0x35, 0x05, 0x7a, 0x8e, 0x34, 0x1a, 0xca, 0x5b, 0x5b, 0x29, 0x81, 0x55, 0xd0, - 0x32, 0x39, 0xfd, 0xbc, 0x23, 0x12, 0x91, 0x70, 0x21, 0xa0, 0xf8, 0xcb, 0x28, 0xa4, 0xd1, 0x5b, 0xcd, 0xc7, 0x30, - 0xc8, 0x70, 0x9d, 0x57, 0x5c, 0x0a, 0xfe, 0xf8, 0xc5, 0x01, 0xf4, 0x50, 0x94, 0x0d, 0xdc, 0x2f, 0x16, 0xfe, 0xcc, - 0x2e, 0x46, 0xf4, 0x36, 0x9b, 0xa1, 0xba, 0xcd, 0xe8, 0x27, 0xd6, 0xe7, 0x95, 0x22, 0x76, 0xcf, 0x0e, 0xed, 0xc1, - 0x8e, 0x19, 0x59, 0x5d, 0x25, 0x1d, 0xcd, 0x04, 0x99, 0xf4, 0x32, 0xf2, 0x0c, 0x41, 0x84, 0x0e, 0xd8, 0x27, 0x87, - 0x36, 0x4a, 0x87, 0xf9, 0x0a, 0xc2, 0x3f, 0xc7, 0x7c, 0x0e, 0x89, 0x6e, 0x76, 0x09, 0x8e, 0x7a, 0x8d, 0x48, 0x3a, - 0x89, 0x50, 0x04, 0x5e, 0xc8, 0x7a, 0x22, 0x98, 0x78, 0x41, 0xef, 0x26, 0xb8, 0xe2, 0xc5, 0xd1, 0x4d, 0x07, 0xcb, - 0x61, 0x46, 0xfc, 0x1b, 0x13, 0x46, 0xee, 0x12, 0xe2, 0xdb, 0x03, 0x84, 0x3b, 0xd8, 0x29, 0x88, 0xea, 0xe5, 0x56, - 0x9b, 0x5e, 0x9c, 0xa2, 0x9e, 0xc7, 0xf9, 0x78, 0x36, 0xd6, 0x91, 0x17, 0x72, 0x38, 0x5b, 0xc4, 0x30, 0x40, 0x16, - 0xc2, 0x93, 0x9b, 0x76, 0x97, 0x10, 0x90, 0x9c, 0x4c, 0x65, 0x59, 0xde, 0xc4, 0x4d, 0x27, 0x60, 0x91, 0xe7, 0x76, - 0x69, 0x4a, 0xf1, 0xf4, 0x9f, 0xaa, 0xb1, 0x0d, 0x67, 0x8b, 0x8c, 0x63, 0xd0, 0x62, 0x95, 0xce, 0x20, 0x10, 0x17, - 0xe5, 0x46, 0x4b, 0x2f, 0x0e, 0x73, 0xb8, 0xf9, 0xf7, 0xb8, 0xbc, 0x8d, 0x09, 0x20, 0x1f, 0xbc, 0x49, 0x3a, 0x40, - 0xcf, 0xf2, 0x7c, 0xce, 0xd0, 0x4b, 0x6f, 0x73, 0x91, 0x4c, 0x84, 0xff, 0xd3, 0xc9, 0x47, 0xa2, 0x1c, 0xe9, 0x15, - 0x72, 0x9c, 0x50, 0x51, 0xb2, 0x9d, 0x10, 0xd5, 0xcb, 0xc3, 0x7f, 0x61, 0xd5, 0x11, 0x02, 0xe7, 0xdb, 0x84, 0x2f, - 0x5f, 0x6e, 0xf9, 0xc1, 0xd7, 0x97, 0xec, 0x44, 0x28, 0xe5, 0x1f, 0x18, 0x87, 0x98, 0x56, 0x32, 0xb1, 0x63, 0x40, - 0x64, 0x7a, 0x58, 0xc0, 0x72, 0xe0, 0x66, 0xe4, 0xf1, 0xe3, 0xd6, 0x38, 0xd3, 0x14, 0x9f, 0xab, 0xff, 0x9f, 0xd8, - 0x7a, 0x10, 0xd7, 0x6e, 0x2f, 0x8d, 0x48, 0x62, 0x1a, 0xa3, 0x01, 0xf3, 0x8a, 0x06, 0x68, 0x9c, 0x94, 0x01, 0xc3, - 0x5e, 0x59, 0xfa, 0x85, 0x1e, 0x63, 0x93, 0x47, 0xaa, 0x99, 0x98, 0x1f, 0x21, 0x64, 0xbb, 0x46, 0xc1, 0x44, 0x12, - 0x8c, 0xf6, 0x2d, 0x50, 0xd8, 0x81, 0x14, 0x53, 0xdd, 0x01, 0xf9, 0x9c, 0xcb, 0xc8, 0x6b, 0x20, 0x1b, 0x7d, 0xbe, - 0xb9, 0xd7, 0xaf, 0x13, 0xfb, 0xd2, 0xa3, 0x39, 0x84, 0x48, 0x23, 0xf2, 0xfb, 0xf0, 0xfe, 0x58, 0xaf, 0x99, 0x77, - 0xbd, 0xca, 0x14, 0x2f, 0xc1, 0xc8, 0x07, 0x37, 0x96, 0x90, 0x0f, 0x1a, 0xac, 0x02, 0x73, 0xf2, 0x35, 0xaa, 0xb5, - 0x43, 0xcc, 0xce, 0xf3, 0x26, 0x47, 0xde, 0x76, 0x75, 0x54, 0x51, 0x58, 0xad, 0xc0, 0xf9, 0x55, 0x03, 0xad, 0xc4, - 0x07, 0xf2, 0x2f, 0x43, 0xa2, 0x8a, 0x09, 0x61, 0x80, 0x1e, 0x19, 0xe7, 0x1f, 0x84, 0x28, 0xe8, 0x32, 0xa9, 0x5a, - 0x36, 0xfb, 0x97, 0x9a, 0xc3, 0x55, 0x60, 0x04, 0xec, 0x36, 0xa6, 0x31, 0x8d, 0xe7, 0xe3, 0x28, 0x66, 0xd6, 0xbc, - 0x2b, 0x89, 0xaf, 0x70, 0x2e, 0x08, 0x2a, 0xac, 0xe1, 0xbe, 0xcb, 0xff, 0xfd, 0x7c, 0xfc, 0x90, 0x97, 0x62, 0xe7, - 0xd7, 0xe5, 0x1a, 0xfa, 0x61, 0xff, 0x75, 0x29, 0x56, 0xbd, 0x49, 0x2d, 0x7a, 0x37, 0x9a, 0x36, 0x8e, 0xff, 0x7c, - 0x76, 0xb1, 0x91, 0x4e, 0xef, 0x78, 0xcb, 0x7b, 0xd0, 0x37, 0xa7, 0xe9, 0x69, 0x5c, 0xe0, 0xe7, 0x2c, 0x2f, 0x67, - 0xff, 0x95, 0xbb, 0x94, 0xc7, 0xf5, 0x7b, 0x76, 0xdd, 0xa1, 0x39, 0xad, 0xbd, 0xb1, 0xec, 0xdd, 0xb3, 0x2b, 0xfe, - 0x1e, 0x81, 0x2c, 0xbe, 0x08, 0xc9, 0xa4, 0x52, 0x09, 0x20, 0xd0, 0x5c, 0x0f, 0x7e, 0xf7, 0xc4, 0x28, 0xa5, 0x1e, - 0xef, 0x3f, 0x26, 0x5f, 0x95, 0x75, 0xb8, 0x3b, 0xb7, 0x40, 0xd6, 0x23, 0xfd, 0x3b, 0x4f, 0x37, 0xba, 0x5f, 0xd0, - 0xa8, 0x3a, 0x75, 0x90, 0x19, 0x8d, 0x33, 0x2d, 0x0d, 0xf9, 0xb7, 0x8d, 0xe6, 0x8c, 0xc2, 0xb7, 0x82, 0x46, 0x74, - 0x13, 0xe1, 0x1f, 0x57, 0x8d, 0x03, 0x4a, 0x0a, 0xf8, 0x61, 0x9b, 0xf6, 0x6d, 0xf7, 0x72, 0x2f, 0xa4, 0xa9, 0xf2, - 0xcb, 0x33, 0x16, 0x18, 0xb4, 0x0f, 0x74, 0x66, 0x47, 0xff, 0x7f, 0x0a, 0x68, 0xbd, 0x88, 0x51, 0xb2, 0x95, 0x3a, - 0x40, 0x5c, 0x6c, 0xe3, 0xe6, 0x0b, 0xbd, 0x71, 0x9a, 0x0b, 0x67, 0x1e, 0xf5, 0xe8, 0x24, 0xdd, 0x02, 0x18, 0xd5, - 0xfc, 0x7e, 0xc4, 0xab, 0x53, 0x57, 0x46, 0x7c, 0x54, 0xbc, 0xa3, 0xbb, 0x0b, 0xcc, 0xf6, 0xbf, 0xf2, 0x2e, 0x46, - 0x34, 0x7f, 0xf7, 0x11, 0xe8, 0x86, 0x1f, 0xb3, 0xd3, 0x37, 0x9f, 0xf9, 0xe3, 0x03, 0x3e, 0x0c, 0xed, 0x1e, 0xa3, - 0x79, 0x67, 0xdc, 0x9a, 0x27, 0x3c, 0x31, 0xc8, 0x0c, 0xe0, 0xb2, 0xcf, 0xde, 0x7b, 0x2c, 0xe3, 0xc0, 0x77, 0x20, - 0x56, 0x26, 0xf3, 0x16, 0x30, 0x29, 0x17, 0x23, 0xa4, 0x35, 0x32, 0xfa, 0x37, 0xe0, 0x45, 0xc9, 0xe8, 0x9f, 0xce, - 0x3d, 0x8a, 0x6e, 0x48, 0xf4, 0xc9, 0x93, 0x01, 0xcb, 0x3a, 0x28, 0x5a, 0x62, 0x52, 0x21, 0x3a, 0x84, 0x2c, 0x13, - 0xa0, 0xf4, 0x49, 0xa0, 0xa1, 0xf0, 0x77, 0x2d, 0x27, 0xbd, 0x9f, 0x7b, 0x66, 0x82, 0xa4, 0xc7, 0xe4, 0x28, 0x8d, - 0x4c, 0x18, 0xf9, 0x73, 0xcd, 0xcb, 0xeb, 0xeb, 0xa7, 0x76, 0x7b, 0xd0, 0x7c, 0x64, 0xbf, 0x95, 0xe6, 0xc4, 0xe4, - 0x6b, 0xad, 0x06, 0x2b, 0x79, 0x03, 0x28, 0x9b, 0x7d, 0x41, 0x2b, 0x60, 0xf1, 0x5b, 0x0d, 0x61, 0xe9, 0x99, 0x0c, - 0xb4, 0x06, 0x4e, 0xd2, 0x73, 0x36, 0xb8, 0x6e, 0x98, 0x1f, 0x91, 0x5e, 0xaf, 0x98, 0xa8, 0x32, 0xa7, 0x27, 0x7d, - 0xba, 0xb9, 0x1e, 0x7b, 0xb1, 0xd0, 0x87, 0xd4, 0x13, 0xfa, 0x93, 0x17, 0xe1, 0x6c, 0xf9, 0xb9, 0xec, 0x3f, 0x4d, - 0x20, 0x75, 0xd5, 0x18, 0x2d, 0x74, 0x7e, 0x3d, 0xbe, 0x9b, 0x35, 0x3e, 0x1a, 0xd9, 0xea, 0x6d, 0xbb, 0x73, 0x64, - 0xb9, 0x77, 0x8b, 0x59, 0x5f, 0x42, 0x3e, 0xa3, 0x58, 0x33, 0x99, 0x83, 0x9c, 0x23, 0xb4, 0xbf, 0xd6, 0x95, 0xe4, - 0xb8, 0xf6, 0x61, 0x4e, 0x41, 0x7a, 0x6c, 0x0d, 0xeb, 0x20, 0x6a, 0xbe, 0xad, 0x7d, 0x06, 0x2d, 0xbf, 0x9e, 0x7a, - 0x9d, 0x16, 0x4c, 0xf2, 0xa4, 0x73, 0x5f, 0xf7, 0x8f, 0x34, 0xe2, 0x5e, 0x7a, 0x59, 0x13, 0x45, 0xb7, 0x48, 0x40, - 0xd7, 0x2a, 0x2d, 0xf4, 0xb2, 0xe2, 0x3c, 0xad, 0xe8, 0x4f, 0x33, 0xe6, 0x51, 0xc9, 0xaa, 0x51, 0xa9, 0x9e, 0x5c, - 0x63, 0x9c, 0x29, 0xeb, 0x09, 0x20, 0x17, 0x45, 0x02, 0xc7, 0x59, 0x6f, 0xd7, 0xa7, 0x4b, 0x43, 0x07, 0xf1, 0xd1, - 0xdb, 0xb8, 0xe9, 0xbc, 0x83, 0x69, 0x2c, 0xdd, 0x9f, 0x48, 0x67, 0x19, 0xc3, 0x89, 0x2a, 0x4b, 0xf2, 0xb4, 0x1c, - 0x85, 0xba, 0xa3, 0xbb, 0x20, 0x29, 0x4b, 0xf6, 0x46, 0x3b, 0xfb, 0xe3, 0x7a, 0xf2, 0x28, 0xfb, 0x30, 0xec, 0xa1, - 0x0a, 0xdc, 0x43, 0xaa, 0xef, 0x72, 0xff, 0xba, 0xcc, 0x94, 0xa6, 0xc1, 0xfe, 0xc7, 0xd7, 0xa1, 0x03, 0x3f, 0x0e, - 0x6e, 0xc7, 0x11, 0x12, 0x28, 0xb7, 0x98, 0xa6, 0x0c, 0x5b, 0x4e, 0x30, 0xd9, 0xee, 0x0d, 0x37, 0xc5, 0xd5, 0x9e, - 0x4b, 0x14, 0x83, 0x25, 0xf7, 0xc0, 0xcb, 0x67, 0xb4, 0x7f, 0x62, 0xeb, 0x26, 0xe6, 0xa9, 0x6b, 0xe1, 0xa3, 0xd4, - 0xc2, 0x34, 0x74, 0x60, 0xb0, 0xc8, 0x59, 0x92, 0x8c, 0x04, 0xbb, 0xfc, 0xd2, 0x6a, 0x27, 0xcf, 0x72, 0x25, 0xd3, - 0xd7, 0x5e, 0x4f, 0x4c, 0x31, 0xd2, 0xb0, 0xa5, 0xed, 0x70, 0xd8, 0xc9, 0x79, 0x02, 0x22, 0x44, 0x54, 0xcf, 0x97, - 0xb8, 0xa6, 0xbe, 0x10, 0x67, 0xdd, 0xf9, 0x32, 0x56, 0xb4, 0xe7, 0x41, 0x01, 0x08, 0xad, 0x36, 0xad, 0x54, 0x17, - 0xdc, 0xd0, 0x23, 0x48, 0x77, 0xeb, 0xe5, 0x1d, 0x14, 0x55, 0xcd, 0xf4, 0x60, 0xd2, 0x8b, 0x1f, 0xe7, 0x5d, 0xe1, - 0x61, 0x16, 0x19, 0x2a, 0x80, 0x1b, 0xa3, 0xef, 0xe0, 0x72, 0x7d, 0xcf, 0x43, 0xb8, 0xb5, 0xe6, 0x4c, 0xcf, 0x4f, - 0x5b, 0x8f, 0x78, 0xf1, 0xe6, 0x61, 0x1c, 0xc2, 0x5d, 0x6c, 0x7d, 0xfa, 0x24, 0x5f, 0x3b, 0x6c, 0xe7, 0xd1, 0xa2, - 0xd0, 0xd6, 0xe5, 0xd4, 0xf6, 0xe2, 0xce, 0xe7, 0xf9, 0x27, 0xb5, 0x05, 0xca, 0x0a, 0xbc, 0xf6, 0xea, 0x3e, 0x22, - 0x14, 0x73, 0x07, 0xdf, 0xfe, 0x2f, 0x1d, 0xb5, 0xdd, 0x7c, 0xde, 0x0e, 0x67, 0x46, 0x0f, 0xcf, 0x48, 0x88, 0xba, - 0x3c, 0xd8, 0x24, 0xd7, 0xaf, 0xfe, 0xe9, 0x29, 0x7e, 0xa5, 0x9d, 0xe6, 0x5f, 0x73, 0xce, 0x0b, 0x63, 0x53, 0x3e, - 0xdb, 0x47, 0x9a, 0x30, 0xba, 0x46, 0x84, 0xcb, 0xef, 0xdb, 0xd0, 0x4a, 0x83, 0x8c, 0x48, 0x08, 0x79, 0xbd, 0x75, - 0x05, 0xb8, 0xef, 0x2f, 0xdb, 0x1d, 0xbc, 0xa5, 0x44, 0xe2, 0x8d, 0xea, 0x38, 0x6e, 0xcf, 0xc8, 0xc2, 0xf5, 0xfd, - 0x5b, 0x07, 0x82, 0x7d, 0xad, 0x7d, 0x25, 0xbf, 0xdc, 0x39, 0x7a, 0x01, 0x06, 0x94, 0x30, 0x84, 0x27, 0x51, 0xff, - 0x97, 0xd8, 0x88, 0xd4, 0x6d, 0xc6, 0x74, 0xc2, 0x84, 0xfd, 0x59, 0xd1, 0xaa, 0xad, 0xf4, 0x00, 0x28, 0xa6, 0x4e, - 0xae, 0x06, 0x51, 0x74, 0x87, 0x26, 0xe2, 0x8e, 0x39, 0x5a, 0xde, 0x13, 0x9a, 0xb5, 0x40, 0x15, 0x4e, 0x61, 0xcf, - 0xa3, 0x50, 0x9a, 0xe1, 0x19, 0xf4, 0x01, 0xf6, 0x52, 0x84, 0x9c, 0xb9, 0x24, 0x79, 0xe6, 0xc0, 0x6b, 0x13, 0x04, - 0x69, 0x74, 0xb7, 0x7a, 0x4a, 0xb1, 0x8b, 0x6c, 0xb7, 0x20, 0xe9, 0xcd, 0x22, 0x74, 0x2b, 0x56, 0x49, 0x8a, 0xbb, - 0x99, 0x8a, 0xad, 0x0e, 0x1e, 0x61, 0x8f, 0x48, 0xdf, 0x96, 0xfd, 0xbd, 0x75, 0xc0, 0x42, 0x17, 0x45, 0x4d, 0x4a, - 0xed, 0xbf, 0x29, 0x1d, 0x38, 0xa1, 0x86, 0x09, 0x05, 0x05, 0xfb, 0x6c, 0xdc, 0x62, 0xbc, 0x7b, 0x6b, 0x6d, 0x6f, - 0x21, 0xf0, 0x2a, 0x34, 0x37, 0xd5, 0x82, 0x5c, 0xe1, 0x0b, 0x64, 0xc9, 0xb5, 0x15, 0x42, 0xd7, 0x37, 0x2d, 0xbb, - 0xf0, 0xfc, 0xc2, 0xf4, 0xc7, 0x56, 0x29, 0xea, 0x52, 0x90, 0x4b, 0x38, 0xb5, 0xb2, 0x46, 0x57, 0x1f, 0xd8, 0x9a, - 0x8e, 0x51, 0xbb, 0x33, 0xce, 0x5e, 0x21, 0x90, 0xfc, 0x89, 0x4a, 0x9d, 0x53, 0x9a, 0x11, 0x18, 0x5e, 0x0f, 0x8a, - 0xd5, 0x2f, 0xb9, 0x16, 0x30, 0x0e, 0x0f, 0xf4, 0xc7, 0xa0, 0x48, 0x9e, 0x64, 0x62, 0x0e, 0x03, 0x4f, 0xe5, 0xb0, - 0x73, 0xcf, 0xe9, 0x4e, 0xe6, 0xf7, 0xbe, 0xb1, 0xb7, 0xc7, 0xae, 0xe3, 0x96, 0x31, 0x3f, 0x8c, 0x20, 0x6a, 0x25, - 0xc2, 0x48, 0x45, 0x1e, 0x31, 0x80, 0x12, 0x4e, 0xae, 0x1b, 0x70, 0xa8, 0xa9, 0x36, 0xdc, 0xa7, 0xe8, 0x08, 0xcc, - 0xa9, 0xcb, 0x34, 0xaa, 0x39, 0x55, 0x99, 0x20, 0x84, 0xcf, 0x8d, 0x5b, 0xe7, 0x78, 0x02, 0x33, 0xed, 0x80, 0xd5, - 0x26, 0xaf, 0x53, 0x1c, 0x84, 0xcc, 0xd4, 0x9d, 0x2d, 0x1a, 0x13, 0x49, 0x4d, 0xb5, 0x4b, 0xad, 0x05, 0xe3, 0x64, - 0xb3, 0x6b, 0xd4, 0x6e, 0x2b, 0x32, 0xb8, 0x88, 0x15, 0x0f, 0x64, 0x04, 0x38, 0xba, 0x96, 0x6b, 0x94, 0x27, 0x47, - 0x5a, 0x10, 0xe6, 0x26, 0x39, 0x8e, 0x98, 0xb6, 0x7f, 0xdc, 0x8d, 0xe8, 0x66, 0x9e, 0x99, 0x8a, 0xc3, 0x5f, 0xbd, - 0xe7, 0xb6, 0x5e, 0x59, 0x2a, 0xd6, 0xf3, 0x2c, 0x25, 0xeb, 0x95, 0xcf, 0x2c, 0xa5, 0x21, 0xb9, 0xb0, 0x16, 0xd8, - 0x6c, 0x9a, 0xa5, 0xd9, 0x72, 0x7a, 0xde, 0xb9, 0x45, 0x66, 0x5e, 0xf0, 0x08, 0x53, 0xde, 0xae, 0xbc, 0x44, 0x67, - 0x03, 0xf6, 0x3f, 0xfb, 0x7c, 0x09, 0x9a, 0x19, 0x2b, 0x34, 0xc7, 0xbb, 0xc2, 0x1c, 0x12, 0x59, 0x61, 0xd4, 0x8f, - 0x4b, 0xf9, 0xec, 0x5d, 0x70, 0xda, 0x6a, 0xe7, 0x46, 0x05, 0x85, 0xef, 0x4d, 0x52, 0x60, 0x22, 0x09, 0x6c, 0x72, - 0x34, 0xee, 0x83, 0xf3, 0xac, 0x9c, 0xe9, 0x97, 0x03, 0x04, 0xff, 0x89, 0x6d, 0xc6, 0x35, 0x27, 0x30, 0x77, 0x06, - 0x77, 0x4a, 0xa8, 0x6e, 0x88, 0xe1, 0xf5, 0xd9, 0x75, 0x4e, 0x56, 0x1c, 0x73, 0x4b, 0xb2, 0x10, 0xe0, 0xb5, 0x07, - 0xb7, 0xcf, 0x33, 0x6b, 0x71, 0xa7, 0xe2, 0x34, 0xd4, 0x66, 0x5f, 0xfa, 0xcc, 0xd7, 0x83, 0x5f, 0x8d, 0x1c, 0x65, - 0x5c, 0xe0, 0x66, 0xd7, 0x8b, 0x81, 0x21, 0x34, 0x9e, 0x05, 0xe8, 0x11, 0x4f, 0xe9, 0xbf, 0x80, 0x10, 0xbf, 0x1b, - 0xfc, 0x2a, 0x33, 0x83, 0xd5, 0xd7, 0x2a, 0x06, 0x89, 0x9e, 0x64, 0x42, 0x81, 0x91, 0x61, 0xe8, 0xba, 0x2a, 0x8b, - 0x84, 0x37, 0xbc, 0xd8, 0xcd, 0xee, 0xcd, 0x98, 0x3f, 0x60, 0xa8, 0x43, 0xf8, 0x25, 0xb1, 0x27, 0xe6, 0x39, 0x9c, - 0x6a, 0xe6, 0x65, 0x76, 0x56, 0x45, 0x63, 0xbd, 0x59, 0xe3, 0x89, 0x09, 0xd5, 0x87, 0x68, 0xdb, 0x37, 0xc5, 0xdc, - 0x6e, 0xf7, 0xd6, 0x87, 0xd3, 0x44, 0x8d, 0x98, 0x99, 0x9a, 0x8f, 0xfb, 0xc6, 0x0a, 0x69, 0x33, 0x52, 0x64, 0x12, - 0xaa, 0x0c, 0x56, 0xc2, 0xc8, 0x3d, 0xbd, 0x6d, 0x75, 0x74, 0x5a, 0x00, 0x4e, 0x34, 0xcb, 0xdb, 0x4a, 0x64, 0xa3, - 0xbd, 0xb6, 0x1b, 0x85, 0xa8, 0x17, 0x3d, 0x9e, 0x51, 0x28, 0x15, 0x37, 0x34, 0x70, 0x6e, 0x06, 0x02, 0x4b, 0x3f, - 0xc5, 0x4b, 0xd8, 0x8b, 0xae, 0x3d, 0x6b, 0xc2, 0xb5, 0x51, 0x7b, 0x87, 0xb4, 0xac, 0x54, 0x4b, 0xd9, 0x77, 0x8e, - 0x74, 0xe3, 0x85, 0xaa, 0x97, 0xb9, 0xd0, 0xb9, 0xda, 0x4f, 0x7c, 0x6c, 0x1b, 0x23, 0x4d, 0xed, 0x9a, 0xfe, 0x66, - 0xce, 0x36, 0xd7, 0x99, 0xac, 0x90, 0x1f, 0x2c, 0x43, 0xfe, 0x04, 0xe9, 0xb6, 0x91, 0x4d, 0xac, 0xc4, 0xfa, 0x85, - 0x1f, 0xf0, 0x0e, 0x3a, 0x67, 0x2d, 0x3b, 0xb0, 0x36, 0xdb, 0x2e, 0x58, 0x26, 0x3f, 0x58, 0xae, 0x5d, 0xe3, 0x37, - 0x7c, 0x08, 0x57, 0xb2, 0x3a, 0x97, 0x9d, 0xec, 0x3d, 0xfe, 0x45, 0xfd, 0xf2, 0xfb, 0x19, 0x3d, 0x8b, 0x0f, 0x96, - 0x35, 0xde, 0x4c, 0x9f, 0xb2, 0x32, 0xfb, 0xc5, 0xed, 0x5b, 0x8b, 0x8f, 0x37, 0x97, 0x36, 0x38, 0x8f, 0x61, 0x68, - 0xef, 0xc5, 0xdd, 0x83, 0xfa, 0xc3, 0x70, 0x56, 0x4e, 0xd0, 0x6a, 0x18, 0x19, 0xe0, 0xce, 0xd6, 0xf3, 0x05, 0xbd, - 0xc7, 0xc6, 0x4c, 0x1f, 0xee, 0xf9, 0xd0, 0xbb, 0xfc, 0xc7, 0xcb, 0x7e, 0x24, 0x9c, 0x3d, 0x3a, 0xbb, 0x40, 0xd0, - 0x5a, 0xd7, 0x56, 0x4a, 0xf5, 0x98, 0xd7, 0x2e, 0x8e, 0xd0, 0x92, 0x3d, 0x2f, 0x75, 0x34, 0xff, 0xd0, 0x2a, 0x87, - 0x0d, 0x1a, 0x63, 0xf5, 0xbe, 0xd5, 0x96, 0x46, 0x6f, 0x3f, 0x10, 0x16, 0xa6, 0xa1, 0x52, 0x81, 0x80, 0x4a, 0xff, - 0xcc, 0x26, 0x5c, 0x7b, 0x9b, 0xc9, 0x28, 0x7d, 0x8a, 0x30, 0x7b, 0xd4, 0x93, 0xc5, 0xfb, 0x8e, 0x9d, 0xac, 0xd5, - 0x1b, 0xca, 0x74, 0x58, 0x69, 0x33, 0x59, 0xa9, 0x11, 0x46, 0x0c, 0x33, 0x9b, 0xe1, 0x85, 0x2a, 0xa7, 0xc3, 0xae, - 0x04, 0x81, 0xa9, 0xda, 0x78, 0xe2, 0xdc, 0xc1, 0xf3, 0xdf, 0xb2, 0x3e, 0x40, 0x8c, 0xe9, 0xe1, 0xc4, 0xde, 0x81, - 0x2e, 0xb5, 0x8b, 0x27, 0xfc, 0xcb, 0xdf, 0x92, 0x43, 0xb0, 0x42, 0xb5, 0xf1, 0xfd, 0x00, 0xd6, 0xd7, 0x20, 0xe6, - 0x6d, 0x77, 0xe2, 0xe0, 0x8e, 0x5d, 0x59, 0x3e, 0xcd, 0xcb, 0x03, 0x88, 0x52, 0x36, 0x72, 0xb3, 0x61, 0xcd, 0xaf, - 0x66, 0x16, 0xbb, 0x36, 0x9e, 0x1f, 0xc8, 0xfa, 0xb0, 0xcd, 0x9f, 0x0b, 0x90, 0xa2, 0x28, 0xd0, 0x96, 0xbb, 0xda, - 0xa2, 0x8d, 0x80, 0x69, 0xd7, 0x3a, 0x6f, 0x6d, 0x21, 0xeb, 0xa4, 0x76, 0xca, 0xa0, 0x2b, 0x65, 0x8a, 0x9c, 0x9a, - 0x51, 0x23, 0x44, 0xc7, 0xf8, 0x41, 0x0e, 0xfd, 0x62, 0xf5, 0xdd, 0xf5, 0x3b, 0x5d, 0x80, 0xb8, 0xe2, 0x54, 0xe6, - 0x59, 0x49, 0xac, 0x0f, 0x37, 0x79, 0xcf, 0x1b, 0xf4, 0xbf, 0xd4, 0x95, 0xef, 0xcb, 0xda, 0x13, 0x24, 0x03, 0x41, - 0x3a, 0x0e, 0xfe, 0x18, 0xc0, 0xf0, 0xc7, 0x06, 0x46, 0x2f, 0x7a, 0x78, 0x1e, 0x54, 0xbf, 0x76, 0xc2, 0x77, 0x96, - 0x5f, 0xaa, 0xd0, 0xfb, 0x49, 0xf5, 0x0b, 0x58, 0x5f, 0x83, 0xa0, 0x8e, 0x44, 0xcd, 0xef, 0x69, 0x5b, 0xf7, 0x2b, - 0x8c, 0x78, 0x91, 0x0f, 0x15, 0xf9, 0xeb, 0xba, 0xfa, 0x3c, 0x87, 0x01, 0x39, 0xf6, 0x09, 0x06, 0x36, 0xfd, 0xb2, - 0x0f, 0x21, 0x78, 0x5f, 0x5f, 0xd5, 0x42, 0xe3, 0x97, 0x22, 0x4e, 0x50, 0xe1, 0x81, 0x2c, 0x74, 0x3c, 0xb5, 0x72, - 0x6b, 0x1d, 0x99, 0x68, 0x6c, 0x62, 0x14, 0x3a, 0x8b, 0x15, 0x6c, 0xcc, 0x27, 0xa3, 0xba, 0xf2, 0x86, 0x09, 0x86, - 0x5f, 0xad, 0x3f, 0x9d, 0xa5, 0x57, 0x5b, 0x85, 0xbd, 0xaa, 0xf0, 0x5f, 0x75, 0x13, 0xbe, 0xc9, 0x70, 0x58, 0x05, - 0x2f, 0x08, 0x15, 0xfc, 0x40, 0x27, 0x55, 0xa8, 0xa3, 0xd3, 0x10, 0xa1, 0x55, 0xb3, 0x82, 0x1c, 0x15, 0xda, 0xef, - 0xdb, 0xd4, 0xd6, 0x9b, 0xea, 0xec, 0xed, 0x58, 0xd5, 0x54, 0x98, 0x1f, 0x8f, 0x59, 0x4d, 0x33, 0x12, 0x95, 0x2c, - 0xbf, 0x83, 0xdd, 0x69, 0x0b, 0x6f, 0x9f, 0xc0, 0xfb, 0x9b, 0xfa, 0x31, 0xe3, 0xb3, 0x6c, 0xd2, 0x04, 0xba, 0x33, - 0xd7, 0x02, 0xb5, 0x4f, 0x4d, 0xdd, 0x91, 0xb9, 0x0e, 0xec, 0x5d, 0xcd, 0x97, 0xf8, 0x4c, 0x84, 0xbb, 0x5f, 0x93, - 0xa8, 0xcc, 0x69, 0x06, 0x6d, 0x2c, 0xa5, 0x89, 0xaa, 0xdb, 0x70, 0xca, 0xb0, 0xf7, 0x0c, 0xed, 0x02, 0x6a, 0xf4, - 0x44, 0x77, 0x62, 0x8c, 0x90, 0xc6, 0xfd, 0x22, 0xb4, 0x1f, 0xe9, 0x79, 0x2b, 0x90, 0x8e, 0xed, 0x18, 0xa6, 0x9b, - 0x06, 0xc8, 0x5a, 0xe8, 0xe3, 0x5f, 0x5f, 0xed, 0xc3, 0xd8, 0xe6, 0xfd, 0x06, 0x61, 0xa9, 0xde, 0x1e, 0x1d, 0x20, - 0xf9, 0x9e, 0x52, 0x58, 0x5c, 0xd1, 0x1a, 0xad, 0x86, 0x8d, 0x83, 0x5c, 0x61, 0x30, 0xca, 0x54, 0xe9, 0x3c, 0x62, - 0x38, 0x1a, 0xc2, 0x08, 0x85, 0x42, 0x5e, 0x7d, 0xc4, 0x9a, 0x79, 0xdc, 0x9e, 0x3d, 0x94, 0x56, 0x07, 0xbf, 0x7a, - 0xb2, 0x46, 0x7d, 0xe9, 0x5d, 0x6e, 0xc6, 0x52, 0x8b, 0x8f, 0x57, 0xbc, 0xd1, 0xeb, 0xcb, 0x84, 0x66, 0x6e, 0xd1, - 0xa0, 0x14, 0x1b, 0x12, 0xbb, 0x95, 0xdf, 0x13, 0xeb, 0xb1, 0x59, 0x21, 0x09, 0x99, 0x5f, 0x5e, 0x99, 0xca, 0x53, - 0x79, 0x7f, 0x65, 0x39, 0xc3, 0x51, 0x3c, 0x78, 0x07, 0x7e, 0xd1, 0xcb, 0x9f, 0xa4, 0xde, 0xaa, 0x6e, 0x4b, 0x1b, - 0x14, 0xb5, 0x73, 0xcb, 0x86, 0x73, 0xe1, 0x3a, 0x29, 0x54, 0xc1, 0x0d, 0x16, 0x49, 0x23, 0x6f, 0x1d, 0x2f, 0x3e, - 0xc5, 0x60, 0xca, 0xc2, 0x19, 0x94, 0xb5, 0xcc, 0x05, 0xd6, 0x68, 0x1f, 0x86, 0x67, 0x8b, 0xcc, 0x18, 0x33, 0x18, - 0xdb, 0x70, 0x6e, 0xf9, 0xac, 0xfb, 0xfa, 0x85, 0xe0, 0xfd, 0xc6, 0x48, 0x44, 0x2c, 0x1f, 0xa0, 0x0f, 0x06, 0xa4, - 0x7f, 0x59, 0x62, 0xe4, 0xc3, 0x73, 0x05, 0x7e, 0xd2, 0xb2, 0x70, 0x00, 0x36, 0x6b, 0xef, 0x30, 0x2e, 0x92, 0x79, - 0xab, 0xdb, 0x31, 0x3b, 0x04, 0x37, 0x6c, 0x8d, 0x22, 0x18, 0x15, 0xa3, 0x25, 0x18, 0xac, 0xa0, 0x21, 0xb8, 0x80, - 0xf3, 0x75, 0xc4, 0xaa, 0xc7, 0x29, 0x2e, 0x33, 0x75, 0x86, 0x7f, 0x76, 0x37, 0xcd, 0xb2, 0x1a, 0xc4, 0x07, 0xa1, - 0xc8, 0x16, 0xec, 0xc1, 0xc5, 0x63, 0xe1, 0xcf, 0x21, 0xdf, 0x45, 0x61, 0xe9, 0x1a, 0xff, 0xaf, 0x43, 0xaa, 0xf7, - 0x3d, 0xec, 0x9e, 0x60, 0x0f, 0x3a, 0xa9, 0x2d, 0x34, 0x7f, 0x85, 0x55, 0x15, 0x55, 0xf3, 0xcd, 0x08, 0x8f, 0x16, - 0x5c, 0xab, 0x23, 0xd0, 0x41, 0x20, 0xd4, 0x6a, 0x06, 0x03, 0xb4, 0xe3, 0x07, 0xf8, 0xd2, 0xf1, 0xf8, 0x25, 0x89, - 0x09, 0xcf, 0xef, 0x9b, 0x10, 0xc4, 0xe3, 0xe8, 0x71, 0xe7, 0xfa, 0x43, 0x95, 0x21, 0xb2, 0x48, 0xea, 0x7e, 0x84, - 0xb9, 0xfd, 0x34, 0x17, 0x2e, 0x16, 0x27, 0xe8, 0xb1, 0x5c, 0x71, 0xc7, 0x3d, 0xea, 0x6e, 0xda, 0x3d, 0x9f, 0xb2, - 0x27, 0x31, 0x96, 0x52, 0xc4, 0x1d, 0xad, 0xcd, 0xb8, 0x22, 0x45, 0xae, 0x36, 0x81, 0x5e, 0x8e, 0xf4, 0x1c, 0x8f, - 0x64, 0x29, 0x51, 0xc7, 0x12, 0x44, 0xad, 0xe2, 0x3b, 0x23, 0x05, 0xd5, 0x28, 0xef, 0x72, 0xf7, 0xad, 0xd3, 0xd4, - 0xdd, 0xcf, 0xee, 0xa7, 0xc1, 0xcb, 0x54, 0xe7, 0x8c, 0x77, 0x5e, 0xb4, 0x5a, 0xfb, 0x22, 0x46, 0xaf, 0x1f, 0x0b, - 0x32, 0x9c, 0xf6, 0x5d, 0x67, 0x01, 0x6a, 0x95, 0xe5, 0xbf, 0x41, 0x20, 0x53, 0x74, 0x97, 0x9e, 0x8e, 0x68, 0xae, - 0x74, 0xf9, 0x8e, 0x0e, 0x54, 0x26, 0x0a, 0x31, 0xd3, 0x68, 0xf6, 0x80, 0xce, 0x2d, 0xcf, 0x75, 0x19, 0xf5, 0x2e, - 0xa2, 0x0d, 0x0a, 0xb5, 0xcf, 0xd1, 0x5d, 0x2f, 0x3a, 0x87, 0xeb, 0x94, 0xdb, 0x47, 0xcb, 0x45, 0xe5, 0xb3, 0xf1, - 0x70, 0x61, 0x97, 0x48, 0x22, 0x1f, 0x78, 0x09, 0x31, 0x74, 0xdf, 0xce, 0x30, 0x83, 0xb3, 0xda, 0xbb, 0x5d, 0xaa, - 0x1b, 0x3e, 0x84, 0x1e, 0xc5, 0xc2, 0xb5, 0x59, 0xce, 0xff, 0x97, 0xde, 0x45, 0xf5, 0xb7, 0x3a, 0x25, 0xee, 0x17, - 0xfe, 0x5d, 0x24, 0x8a, 0x84, 0x1e, 0xd2, 0x90, 0xde, 0x9f, 0x95, 0x1d, 0x98, 0x0f, 0xed, 0xa1, 0x32, 0x35, 0x79, - 0x9e, 0x05, 0xa0, 0xf5, 0xaa, 0x50, 0x46, 0x0e, 0x46, 0x4f, 0xce, 0x3b, 0xa4, 0x10, 0x86, 0x90, 0xc3, 0x20, 0x11, - 0x73, 0x1d, 0x70, 0x73, 0xd5, 0xed, 0x2c, 0x45, 0x85, 0xee, 0x1a, 0x96, 0x12, 0xd0, 0x11, 0x1d, 0x92, 0xcc, 0x9c, - 0xd0, 0x10, 0x14, 0x28, 0xf2, 0x1e, 0x31, 0x18, 0x4d, 0xe0, 0x3f, 0x98, 0x7d, 0x14, 0xd2, 0x08, 0x08, 0xe3, 0x14, - 0xc5, 0x7b, 0x20, 0x0e, 0x94, 0xd6, 0x3d, 0x98, 0x56, 0xe1, 0xaa, 0x57, 0xda, 0xca, 0x18, 0xbe, 0xc6, 0xb9, 0x33, - 0xc8, 0x05, 0x9e, 0xea, 0x5e, 0xcc, 0x80, 0x28, 0x40, 0x29, 0x68, 0xc1, 0x49, 0x90, 0x7c, 0xa8, 0x15, 0x48, 0xc0, - 0x21, 0xae, 0x41, 0xa9, 0xb1, 0xe0, 0xd5, 0x78, 0xa3, 0x10, 0x96, 0x62, 0x24, 0x02, 0x21, 0xd9, 0x30, 0xac, 0x98, - 0x0a, 0xb4, 0xfb, 0xc5, 0xbe, 0xf7, 0xc2, 0xe3, 0x43, 0x7d, 0x23, 0xe6, 0x02, 0x09, 0xa3, 0xb3, 0x93, 0x7b, 0x81, - 0x24, 0x7f, 0xb5, 0xa7, 0x2b, 0xb3, 0xbc, 0xf0, 0x8d, 0x85, 0x73, 0xb5, 0x12, 0x10, 0xf6, 0x6f, 0x8c, 0x03, 0x01, - 0x30, 0x97, 0xce, 0x6a, 0x2d, 0x91, 0x95, 0x0b, 0x69, 0xd6, 0x63, 0x29, 0xd6, 0xdd, 0x3c, 0x54, 0x80, 0x29, 0xb5, - 0xb8, 0x20, 0x95, 0x15, 0xde, 0x68, 0x0e, 0xa6, 0xf0, 0xa6, 0x83, 0xae, 0xcd, 0x67, 0xff, 0x43, 0xee, 0x1e, 0x1e, - 0x87, 0x57, 0xaa, 0x5b, 0x82, 0x51, 0x67, 0x92, 0xc1, 0x89, 0x4c, 0xa5, 0x9e, 0x06, 0xb1, 0x93, 0xbe, 0x13, 0x20, - 0x90, 0xd0, 0x38, 0x25, 0x9d, 0x8d, 0x74, 0xe8, 0x03, 0xf7, 0x43, 0x59, 0x50, 0x7c, 0x1d, 0x75, 0x7c, 0x11, 0x45, - 0x58, 0x64, 0xa5, 0x67, 0x97, 0x57, 0x37, 0x8d, 0xce, 0xcc, 0x4b, 0xcb, 0x9c, 0xc6, 0x4f, 0x60, 0xc9, 0x0a, 0x51, - 0xf2, 0x92, 0xb4, 0xb0, 0x9c, 0xe0, 0x7a, 0xa0, 0xe9, 0xb0, 0x20, 0x73, 0xe3, 0xb8, 0xfe, 0x51, 0x31, 0x8e, 0xa9, - 0xc3, 0x9e, 0xd2, 0x9b, 0x0a, 0x3c, 0x75, 0x64, 0x15, 0x3a, 0x10, 0x9e, 0x61, 0xbc, 0xa6, 0x81, 0x37, 0xfb, 0xf5, - 0xfc, 0xdf, 0x01, 0x8d, 0xe3, 0xc3, 0x25, 0x6d, 0xb8, 0x0e, 0xab, 0x70, 0x21, 0x8e, 0xc9, 0x0f, 0x26, 0x93, 0xb8, - 0x26, 0x71, 0xe0, 0xf7, 0x61, 0x89, 0x54, 0x88, 0x0c, 0xea, 0x58, 0xb9, 0x1d, 0xfb, 0x0b, 0x40, 0x8f, 0x87, 0x4c, - 0xe7, 0x81, 0x2f, 0x58, 0xe0, 0x38, 0xa8, 0x66, 0x37, 0x87, 0x8a, 0x05, 0xc0, 0x85, 0x59, 0x29, 0x5c, 0x8c, 0xd6, - 0x28, 0xc5, 0xec, 0x19, 0x1f, 0xda, 0x55, 0x03, 0x96, 0x99, 0x77, 0x66, 0xe5, 0xdc, 0x5a, 0x48, 0xc1, 0xed, 0xfa, - 0x46, 0x4c, 0x70, 0xbb, 0x46, 0xb2, 0x2d, 0xea, 0xd7, 0xe0, 0x58, 0x5c, 0x5c, 0xd7, 0xf8, 0xac, 0xcc, 0xdc, 0x49, - 0xfb, 0xc4, 0x75, 0x94, 0x56, 0x20, 0x89, 0xe7, 0x79, 0x18, 0x89, 0x05, 0xd3, 0xe7, 0x84, 0xa8, 0xc4, 0xb0, 0xf4, - 0xb1, 0xec, 0x0c, 0x83, 0xc7, 0x1c, 0x1d, 0x79, 0x66, 0xe7, 0x1c, 0xfe, 0xc7, 0x05, 0x60, 0x59, 0x7c, 0x2a, 0xe3, - 0x5f, 0x1c, 0x8f, 0xb2, 0x27, 0xf2, 0xfe, 0x4a, 0xe2, 0x4e, 0xc5, 0x1c, 0x48, 0x23, 0x5b, 0xc6, 0xd2, 0x16, 0xc8, - 0x45, 0xc6, 0x33, 0xec, 0xfc, 0xd4, 0xfa, 0x98, 0xfd, 0xd8, 0xc7, 0xaa, 0xe1, 0xd7, 0x81, 0x6e, 0x93, 0x12, 0xf4, - 0xad, 0x94, 0xe9, 0xec, 0xbd, 0x99, 0xd2, 0xdc, 0x89, 0xab, 0x7a, 0x65, 0x6b, 0x1b, 0x6a, 0x9b, 0xc4, 0xf5, 0x5b, - 0xf3, 0x18, 0x98, 0xb6, 0x4e, 0x5c, 0x19, 0x0a, 0x6d, 0xb2, 0x3c, 0xd3, 0x20, 0x55, 0x31, 0x74, 0xf7, 0x8a, 0x0f, - 0x9d, 0xee, 0x70, 0x36, 0x5f, 0x9a, 0xf4, 0x30, 0x9e, 0xc5, 0xb5, 0x5c, 0x92, 0xc1, 0x07, 0x85, 0xc3, 0x21, 0x49, - 0xd1, 0x22, 0x97, 0x21, 0x80, 0xdc, 0xed, 0xe0, 0x6e, 0xb2, 0xdd, 0x94, 0x77, 0xcc, 0x5e, 0x9a, 0xa3, 0xcf, 0xdb, - 0x72, 0x31, 0xa1, 0x46, 0x4c, 0xd5, 0x79, 0x6b, 0xbb, 0x6e, 0x0a, 0x4a, 0x39, 0x0a, 0xa4, 0x53, 0x16, 0xa2, 0x82, - 0x9f, 0x98, 0xef, 0xff, 0xa0, 0x28, 0x37, 0x04, 0xdc, 0xf2, 0x3a, 0x7e, 0xdc, 0x69, 0x2d, 0x63, 0x58, 0x8e, 0x8c, - 0x0b, 0xd3, 0xbf, 0xa4, 0x59, 0xcd, 0x96, 0x65, 0xe2, 0x75, 0x9d, 0x3d, 0x28, 0x2e, 0xe1, 0x5c, 0xad, 0x65, 0xe1, - 0x3a, 0xd2, 0xd0, 0x84, 0xfe, 0x10, 0x0a, 0xdb, 0xa6, 0x32, 0x70, 0xa2, 0x94, 0x21, 0x3f, 0x97, 0x86, 0x29, 0x18, - 0x7e, 0x13, 0x60, 0x9d, 0x66, 0x18, 0x85, 0xb4, 0x00, 0xaa, 0x0f, 0x47, 0x93, 0x6e, 0x08, 0x3b, 0x07, 0x1d, 0x47, - 0xe9, 0xec, 0xc0, 0x7a, 0x40, 0xce, 0xf3, 0xd9, 0x5e, 0xef, 0xcd, 0xfa, 0xda, 0xf8, 0x07, 0x04, 0x3e, 0xf3, 0x2d, - 0xfa, 0xda, 0x06, 0xe2, 0x7c, 0x39, 0x23, 0xc6, 0xb6, 0x0c, 0xd8, 0x52, 0xe5, 0x10, 0xb6, 0x54, 0x0c, 0x13, 0x33, - 0x75, 0x62, 0x8a, 0x17, 0x65, 0xdd, 0x79, 0x3e, 0x04, 0x2c, 0x50, 0x7e, 0xc0, 0x91, 0x25, 0xc7, 0x74, 0x14, 0x29, - 0x3a, 0x0d, 0x14, 0x2c, 0x50, 0x7e, 0x7d, 0x5b, 0xfe, 0x61, 0x08, 0xb0, 0x1c, 0x69, 0x95, 0x81, 0x64, 0x6a, 0x63, - 0x39, 0xa9, 0xc5, 0xa9, 0x38, 0x8b, 0xca, 0x30, 0xfa, 0xdd, 0xb8, 0x78, 0xe9, 0xbd, 0x56, 0x6f, 0xe1, 0x29, 0x57, - 0xb0, 0x46, 0x13, 0xd3, 0x13, 0xb1, 0xbf, 0xe0, 0x7c, 0x30, 0xc8, 0x6f, 0x78, 0x77, 0x08, 0xa9, 0x8d, 0x62, 0x8f, - 0xda, 0x0f, 0x4c, 0x46, 0xa5, 0xd5, 0x25, 0x2f, 0xea, 0x45, 0xb6, 0x65, 0x17, 0xbb, 0x72, 0x8f, 0x81, 0xcb, 0x8b, - 0x11, 0xe8, 0xf1, 0xf6, 0x1a, 0x1c, 0x00, 0x1f, 0x2d, 0x8a, 0xab, 0x61, 0x5b, 0xa4, 0x40, 0xd8, 0xd6, 0x7b, 0xde, - 0xea, 0x53, 0x2b, 0xc8, 0x63, 0x10, 0x5a, 0x97, 0x13, 0xde, 0xb9, 0x75, 0xca, 0x90, 0x16, 0x31, 0xce, 0xb3, 0xa8, - 0xd0, 0x87, 0x49, 0x55, 0xc9, 0x86, 0x7f, 0xc0, 0x60, 0xe4, 0x16, 0x53, 0xe1, 0xdf, 0xe2, 0xaf, 0xcc, 0x0d, 0xf7, - 0x6a, 0x98, 0xce, 0xa9, 0x36, 0xef, 0xba, 0xed, 0xf0, 0xc3, 0xf0, 0xdd, 0x12, 0x7a, 0x54, 0x60, 0x9c, 0xe6, 0x89, - 0xd9, 0x1a, 0x7e, 0xa5, 0x80, 0x6f, 0x1f, 0xca, 0xb4, 0x0d, 0x37, 0xd3, 0xaa, 0xbd, 0xe9, 0xb6, 0x1b, 0x40, 0xe6, - 0xac, 0x66, 0xf9, 0xe6, 0x83, 0x3b, 0x09, 0x69, 0x11, 0xfe, 0x58, 0x26, 0xea, 0x11, 0xb6, 0x74, 0xe8, 0x04, 0x3c, - 0xd3, 0xd3, 0xaa, 0xc6, 0xf3, 0x75, 0x56, 0x22, 0x7f, 0xb4, 0x37, 0xfe, 0xe4, 0x83, 0xb7, 0xbe, 0x83, 0x1a, 0x79, - 0xa2, 0x47, 0x84, 0x0b, 0xd5, 0x25, 0xb4, 0xad, 0x1a, 0xb2, 0x28, 0x96, 0xdc, 0x06, 0xde, 0x13, 0x53, 0x84, 0xc3, - 0x4f, 0xed, 0xe9, 0x52, 0xd4, 0xfe, 0x98, 0x19, 0xfc, 0x07, 0x80, 0x44, 0xe5, 0xf2, 0xbf, 0xc3, 0xe3, 0x1d, 0x85, - 0x88, 0x78, 0x0b, 0xc9, 0x82, 0x05, 0x18, 0x79, 0xa8, 0xcc, 0x48, 0x4a, 0xca, 0xb5, 0x12, 0x80, 0xef, 0xc3, 0xd0, - 0x56, 0x5d, 0x83, 0x1c, 0x6c, 0xf0, 0xb7, 0x0c, 0xe2, 0x61, 0xd7, 0x23, 0xad, 0xf1, 0xf2, 0xf8, 0xd2, 0xa7, 0x9a, - 0xd0, 0xe2, 0xdb, 0x48, 0x59, 0xbc, 0x5c, 0x3d, 0x10, 0x1d, 0x49, 0x0c, 0x71, 0x23, 0x27, 0xc9, 0x9b, 0xc4, 0xfb, - 0x69, 0x63, 0x44, 0x72, 0x62, 0x9d, 0xbd, 0x20, 0xe5, 0x17, 0x62, 0xf3, 0xdd, 0xb8, 0x73, 0xb8, 0x73, 0xbd, 0xaf, - 0x94, 0x45, 0x5d, 0x8b, 0x7a, 0x68, 0x76, 0x1d, 0xfd, 0xd9, 0x94, 0x30, 0xa4, 0x43, 0xa2, 0x41, 0x21, 0x2d, 0x2a, - 0x0b, 0xa4, 0x81, 0x9e, 0x44, 0xf6, 0x71, 0x58, 0xcc, 0xde, 0xbd, 0x4a, 0x7d, 0x92, 0x48, 0x49, 0x6c, 0x0f, 0x58, - 0x9a, 0x4c, 0xbc, 0xb9, 0x30, 0xfb, 0xbf, 0xb2, 0xf3, 0xf2, 0x21, 0xd2, 0x98, 0xaa, 0x63, 0x64, 0xa1, 0x06, 0x4a, - 0x59, 0x0b, 0xa7, 0x2d, 0xbe, 0x14, 0x45, 0x5b, 0x85, 0x9e, 0x6a, 0x1e, 0x78, 0x5a, 0x58, 0x13, 0xc5, 0x16, 0xf4, - 0x74, 0x98, 0x96, 0x25, 0xb5, 0x09, 0x4f, 0x5f, 0x7a, 0x9e, 0xe5, 0x39, 0xdb, 0x5d, 0x9a, 0x7d, 0xeb, 0xa0, 0x5e, - 0x53, 0xcb, 0xf6, 0x53, 0x95, 0x69, 0xd0, 0x12, 0x04, 0xf5, 0x10, 0xe4, 0x56, 0x61, 0xe2, 0xc6, 0x38, 0x4f, 0x77, - 0xed, 0xd6, 0x9d, 0xf9, 0xa7, 0x5d, 0x10, 0x17, 0x12, 0x18, 0x34, 0x12, 0xad, 0x26, 0xf4, 0x63, 0xc3, 0x52, 0x18, - 0x72, 0xb6, 0x64, 0x96, 0xf3, 0x6a, 0x20, 0x3f, 0xd3, 0x56, 0x70, 0x40, 0xc2, 0xe8, 0x1c, 0x63, 0x66, 0xf0, 0x39, - 0x12, 0xc3, 0x57, 0x6d, 0xd2, 0x73, 0x24, 0xf7, 0x34, 0xc1, 0x54, 0x00, 0xf3, 0x4a, 0xc1, 0x74, 0xd6, 0x37, 0x8b, - 0x0a, 0x56, 0xfc, 0xf0, 0xe3, 0x2f, 0xa8, 0xde, 0x07, 0x05, 0x9d, 0x04, 0x57, 0xea, 0xb6, 0x9d, 0xf1, 0x6d, 0xf7, - 0x41, 0x01, 0x5e, 0xa8, 0x21, 0xf3, 0x12, 0xff, 0x57, 0x2f, 0xd6, 0xd4, 0x2f, 0xf2, 0xd9, 0x61, 0xa2, 0xef, 0x32, - 0x69, 0xe6, 0xf7, 0xa5, 0x01, 0x65, 0x7e, 0xc9, 0xe3, 0x8a, 0x69, 0xde, 0x23, 0xfe, 0xd3, 0x98, 0xdb, 0xc2, 0x84, - 0x76, 0x98, 0x3e, 0x4a, 0xd4, 0xdc, 0x3e, 0x13, 0x54, 0xfb, 0x86, 0x97, 0xea, 0x31, 0x17, 0xac, 0x63, 0x72, 0x4b, - 0x89, 0xf5, 0x95, 0xc0, 0x83, 0x2c, 0x92, 0x89, 0x7b, 0xa9, 0xf6, 0x8e, 0xf2, 0x7c, 0xa7, 0xf6, 0x34, 0x39, 0x61, - 0x5d, 0x5c, 0x5d, 0xc9, 0xd7, 0x31, 0xc2, 0x6e, 0xbd, 0x59, 0x5e, 0xab, 0x62, 0xcc, 0x28, 0xd9, 0xd4, 0x6e, 0xef, - 0x62, 0x31, 0xe3, 0x26, 0x0c, 0x45, 0xb6, 0x28, 0x97, 0x8f, 0x5c, 0x3c, 0xe4, 0xfb, 0x94, 0x5f, 0xfd, 0x67, 0x0b, - 0x71, 0xf3, 0xf9, 0xf9, 0x1b, 0x23, 0x2c, 0x08, 0x03, 0xdb, 0xad, 0x22, 0x3e, 0x9d, 0x09, 0x14, 0xc6, 0xc6, 0x04, - 0x9b, 0xd7, 0xba, 0x09, 0xbc, 0x48, 0x94, 0x91, 0x34, 0xcc, 0xcf, 0xf2, 0x10, 0xa8, 0x62, 0xe8, 0x49, 0x6b, 0x25, - 0x8a, 0xd6, 0xf7, 0x63, 0x9f, 0x01, 0x21, 0x55, 0xb2, 0xac, 0x88, 0x2b, 0x57, 0x28, 0x04, 0x22, 0x09, 0x07, 0x47, - 0x60, 0x9b, 0x26, 0x84, 0x4f, 0x0f, 0xe9, 0xa5, 0x2e, 0x73, 0xc9, 0xc5, 0x35, 0x38, 0x0a, 0x60, 0x69, 0x32, 0xe2, - 0xd7, 0xbb, 0x55, 0x5e, 0xfa, 0xa5, 0x9d, 0x6e, 0xfe, 0x9e, 0x03, 0x8e, 0x0b, 0xdd, 0x17, 0x05, 0x68, 0x0d, 0x58, - 0x56, 0x28, 0x6f, 0x1f, 0x83, 0x8b, 0xd2, 0x61, 0xf4, 0x72, 0x5c, 0x2d, 0xa2, 0xba, 0x42, 0x59, 0xbb, 0x5d, 0x11, - 0x95, 0xb7, 0xf3, 0xd7, 0x34, 0xa9, 0x45, 0x04, 0x71, 0xde, 0x47, 0x34, 0xcb, 0x44, 0x98, 0x5d, 0xdc, 0x75, 0xa8, - 0xc7, 0x90, 0xf4, 0xa1, 0x15, 0x17, 0x11, 0xf8, 0xb4, 0x02, 0x69, 0x63, 0x6e, 0x0f, 0xe9, 0xb7, 0xb6, 0xa3, 0x00, - 0xe8, 0x85, 0xb0, 0x90, 0xb9, 0x91, 0x14, 0x3c, 0x7b, 0x0f, 0x54, 0x92, 0xf4, 0xb9, 0x1a, 0xb3, 0xae, 0xc7, 0x17, - 0xaf, 0x95, 0xbe, 0x05, 0x79, 0x6f, 0x8a, 0xe0, 0xe9, 0xaa, 0xbd, 0x74, 0x21, 0xa0, 0xbd, 0xce, 0x74, 0xb6, 0x34, - 0x7b, 0x1f, 0xbc, 0x17, 0x1d, 0x78, 0x0d, 0xf5, 0x66, 0x89, 0x3c, 0x66, 0xf0, 0x65, 0x93, 0x90, 0xe4, 0xb5, 0x91, - 0x0a, 0xa2, 0xa0, 0x07, 0xae, 0x51, 0x91, 0x8c, 0x92, 0x8b, 0x6e, 0xfb, 0xb3, 0x19, 0xa4, 0x5c, 0x5e, 0x7d, 0xcd, - 0xdb, 0x9d, 0x83, 0x28, 0xa5, 0xf9, 0xeb, 0x85, 0x4f, 0xbb, 0x67, 0x74, 0xe5, 0x35, 0x81, 0x56, 0x33, 0x7a, 0x4b, - 0x8d, 0x6a, 0xa4, 0xa9, 0x48, 0x05, 0xb1, 0x77, 0x59, 0x83, 0xb5, 0xf1, 0x78, 0x30, 0x95, 0x1a, 0xbc, 0xcf, 0xf4, - 0xa4, 0x75, 0xfa, 0xf6, 0x69, 0x39, 0x84, 0x57, 0xdf, 0x6d, 0x37, 0x2a, 0xf5, 0x5c, 0x7c, 0x2f, 0xdb, 0x45, 0xa6, - 0x75, 0x9c, 0xe8, 0x64, 0xd2, 0x57, 0x69, 0xb7, 0x27, 0x55, 0x3d, 0x73, 0xe1, 0x00, 0xfd, 0xbb, 0x51, 0xa6, 0x94, - 0xa5, 0xf1, 0xda, 0x25, 0xac, 0xa7, 0xde, 0x08, 0xbb, 0x2e, 0x0a, 0xc0, 0x3f, 0x67, 0x1c, 0x68, 0xa2, 0x63, 0xc5, - 0x3a, 0xbe, 0x2e, 0x75, 0x3c, 0x94, 0xfc, 0x80, 0x6d, 0x66, 0x92, 0x77, 0x28, 0x6e, 0xde, 0x10, 0xd8, 0x9f, 0x29, - 0xd6, 0x76, 0xab, 0xc4, 0x19, 0xab, 0xd8, 0x2b, 0xb1, 0xd7, 0x1e, 0x6f, 0x98, 0x40, 0x99, 0xad, 0x2b, 0x4c, 0x99, - 0x33, 0xbf, 0xc9, 0x67, 0x2f, 0xaf, 0x6f, 0x9e, 0xfd, 0x65, 0xe7, 0x35, 0xc3, 0xb0, 0x92, 0x3d, 0x27, 0xcb, 0x21, - 0xac, 0xca, 0xf8, 0x99, 0x9e, 0x6a, 0x1e, 0xdf, 0x51, 0x6f, 0xdc, 0x9b, 0xa4, 0x07, 0xe3, 0xdb, 0x13, 0x92, 0x87, - 0x82, 0x09, 0x18, 0xe9, 0xcf, 0x47, 0xa3, 0x00, 0x9d, 0x5c, 0x76, 0x0d, 0x2e, 0x12, 0x93, 0x3f, 0xa6, 0x5e, 0xc6, - 0x22, 0x05, 0xa9, 0x3a, 0xc2, 0x5d, 0x83, 0x48, 0x8a, 0x2a, 0xe8, 0xc8, 0x8b, 0x02, 0x4c, 0x5a, 0x70, 0x60, 0x01, - 0x0a, 0x3a, 0x5f, 0x79, 0x89, 0x25, 0x7e, 0x88, 0xfe, 0xf1, 0x1f, 0x56, 0x27, 0xbd, 0x68, 0xfe, 0x31, 0xbd, 0xf0, - 0xff, 0x4f, 0xe7, 0xb3, 0x75, 0x8e, 0x0d, 0x08, 0xd6, 0xff, 0x05, 0x62, 0x17, 0x9a, 0xa0, 0x04, 0xe9, 0x01, 0x84, - 0x43, 0xfe, 0xf4, 0x4e, 0x33, 0x9c, 0xb0, 0x7c, 0xaa, 0x26, 0xc3, 0x78, 0x2a, 0xce, 0xc9, 0x84, 0xc6, 0x71, 0x1a, - 0x4d, 0x29, 0xc0, 0x33, 0x6e, 0xcc, 0x5e, 0xc3, 0xd0, 0x7b, 0xdd, 0xa3, 0x40, 0xe8, 0x24, 0xdd, 0xcc, 0x58, 0x8a, - 0x49, 0xb4, 0xac, 0x57, 0x6d, 0x9c, 0x1c, 0xf6, 0xe0, 0x0c, 0xb4, 0xcc, 0x32, 0x27, 0x5a, 0x0f, 0x16, 0x42, 0x73, - 0x6e, 0x6c, 0x30, 0xdd, 0x5b, 0x28, 0xdd, 0x11, 0x41, 0x63, 0xf7, 0x98, 0xca, 0x56, 0x44, 0x25, 0xa5, 0x88, 0x78, - 0x63, 0x20, 0x4c, 0x2f, 0x7e, 0x9d, 0x9a, 0x53, 0xdf, 0xf6, 0x51, 0xbe, 0x32, 0xa9, 0x94, 0xb5, 0x5c, 0x48, 0x83, - 0xf1, 0xea, 0xcb, 0x48, 0x08, 0xcd, 0x44, 0x48, 0x91, 0x17, 0xb2, 0x27, 0x60, 0x13, 0x23, 0xbe, 0xc7, 0xa5, 0x84, - 0x32, 0x39, 0x28, 0x55, 0x50, 0x3c, 0x3e, 0xd4, 0x8f, 0x18, 0x10, 0xba, 0x1a, 0x23, 0x89, 0xe5, 0x58, 0xe8, 0x27, - 0xd2, 0x17, 0x34, 0x71, 0x08, 0x5c, 0xe3, 0x07, 0xd1, 0x67, 0x4f, 0xc6, 0xcb, 0x5e, 0x81, 0xcd, 0x39, 0xb0, 0x89, - 0xdc, 0x1c, 0xb4, 0xee, 0x3f, 0x65, 0x02, 0x4d, 0x14, 0x59, 0xeb, 0x39, 0x4b, 0xf6, 0x2c, 0xc5, 0x3c, 0x54, 0x4b, - 0x96, 0x40, 0xa3, 0xa8, 0x88, 0xd0, 0x5f, 0x0e, 0xf6, 0x50, 0xcf, 0x2a, 0x23, 0xb6, 0x30, 0xaf, 0x4e, 0xa8, 0xb2, - 0xd5, 0x51, 0xd2, 0x2b, 0x0b, 0xf5, 0x70, 0x37, 0x80, 0xf1, 0xb5, 0x9f, 0x7b, 0xe3, 0xce, 0xfa, 0x84, 0x6d, 0xcf, - 0x37, 0xb9, 0x38, 0xfe, 0x7a, 0xe6, 0xe5, 0xe1, 0xe0, 0xb9, 0x67, 0xb1, 0xd9, 0x50, 0x09, 0x81, 0x48, 0x26, 0x82, - 0x4a, 0xf5, 0xdb, 0x6c, 0x38, 0x20, 0xb0, 0x83, 0x62, 0x2b, 0xe1, 0xa5, 0x52, 0x51, 0xee, 0xad, 0xd5, 0x58, 0x0e, - 0x1c, 0x8e, 0x21, 0x8d, 0x63, 0x97, 0x98, 0x82, 0x38, 0xd6, 0x67, 0xe9, 0x21, 0xf0, 0x6b, 0x6b, 0x34, 0x4f, 0xec, - 0x90, 0x21, 0xd3, 0xa4, 0x4a, 0x45, 0xfa, 0x39, 0x00, 0x6e, 0x23, 0xa7, 0x17, 0xe9, 0x1f, 0x50, 0x02, 0xfc, 0x2a, - 0x16, 0x7b, 0xa3, 0x32, 0x16, 0xc9, 0xaa, 0xac, 0x56, 0x0a, 0xa7, 0xe3, 0x03, 0xf0, 0xd2, 0xbf, 0x2c, 0x58, 0xa0, - 0x6a, 0xa4, 0x67, 0x0b, 0x77, 0x08, 0xd4, 0x4a, 0xdf, 0x8d, 0x10, 0xb5, 0xee, 0xd7, 0xf5, 0xa8, 0xba, 0xb9, 0x58, - 0x8c, 0x31, 0xb6, 0xdc, 0x9b, 0x4f, 0x2a, 0xb7, 0x6d, 0x31, 0xfa, 0x36, 0x77, 0x60, 0x6b, 0xd2, 0x3d, 0xfd, 0xb0, - 0x3d, 0xe5, 0xe1, 0x25, 0x7e, 0xf3, 0x6a, 0x22, 0x33, 0x79, 0x7c, 0x26, 0x74, 0xef, 0xa9, 0x42, 0xcd, 0x8d, 0x85, - 0x3c, 0xf5, 0xbb, 0xf7, 0x34, 0xe1, 0x87, 0xfa, 0xaf, 0xd4, 0x78, 0x52, 0x93, 0x93, 0xbf, 0x99, 0xc5, 0x64, 0x13, - 0x86, 0xfd, 0xb0, 0x81, 0x20, 0x10, 0x50, 0x25, 0x80, 0xed, 0x51, 0x94, 0xeb, 0x6f, 0x4a, 0xaa, 0x5b, 0xc0, 0x85, - 0xa2, 0x53, 0x51, 0xf7, 0x29, 0xe1, 0x59, 0xcf, 0xb6, 0x5b, 0x28, 0x22, 0x2e, 0xaa, 0x33, 0x71, 0xa2, 0xcd, 0xcf, - 0xd9, 0xfe, 0x51, 0x75, 0x40, 0x87, 0x2c, 0x3e, 0xea, 0x9a, 0x50, 0x10, 0x37, 0xbc, 0x50, 0x86, 0x09, 0x92, 0x08, - 0x65, 0xd3, 0x6c, 0xf7, 0x65, 0xba, 0xc6, 0xad, 0xfd, 0xb9, 0xd0, 0xf7, 0x76, 0x20, 0x12, 0x17, 0x33, 0xd1, 0xac, - 0x08, 0x57, 0xf2, 0xe0, 0x54, 0xd1, 0xe6, 0x2c, 0xc8, 0xd6, 0xec, 0xad, 0x9e, 0x93, 0x39, 0xe0, 0x28, 0xd2, 0x72, - 0xcc, 0x8f, 0x3c, 0x17, 0xcf, 0xd5, 0x67, 0x8b, 0xe4, 0x7e, 0xc9, 0xc6, 0xb6, 0xdd, 0x4b, 0xd2, 0x93, 0x1c, 0x60, - 0x22, 0x04, 0xdf, 0x94, 0x90, 0x0e, 0xc0, 0xfa, 0xda, 0xb2, 0x04, 0x99, 0xa9, 0x02, 0x22, 0x93, 0xe9, 0xfc, 0xda, - 0x93, 0x83, 0xa0, 0x33, 0x15, 0x70, 0xc0, 0x10, 0x7e, 0x06, 0x06, 0x1a, 0xdf, 0x73, 0x90, 0xa4, 0x44, 0x43, 0x32, - 0xc5, 0x3d, 0x20, 0x52, 0xc0, 0xcd, 0x37, 0x4f, 0xb6, 0x68, 0x5d, 0x19, 0xa7, 0x90, 0x5e, 0xd2, 0x7a, 0x30, 0x25, - 0x31, 0x9e, 0x49, 0xb8, 0xc5, 0xf1, 0xcf, 0x96, 0xc0, 0xeb, 0x44, 0x5e, 0xa5, 0x64, 0x4b, 0xce, 0x09, 0x0c, 0x2e, - 0x47, 0x2b, 0x36, 0xb0, 0xb8, 0x7e, 0x0a, 0x6b, 0x8c, 0x27, 0x59, 0x1e, 0x8b, 0x9b, 0xbf, 0xef, 0xbf, 0xf5, 0xc8, - 0xee, 0xb3, 0xdd, 0x9d, 0x4f, 0x4d, 0x6b, 0x05, 0x83, 0x18, 0x53, 0xb2, 0x01, 0x6a, 0x05, 0xcf, 0x6c, 0xc8, 0xd8, - 0x95, 0xe6, 0x59, 0xe0, 0xa8, 0x67, 0xbb, 0x6f, 0xcd, 0xee, 0x58, 0xbe, 0x41, 0xfc, 0x44, 0x43, 0xd8, 0x78, 0xa7, - 0x02, 0x9b, 0xa8, 0xe5, 0xf7, 0xe8, 0xb7, 0xbb, 0xb3, 0x1d, 0xda, 0xab, 0x78, 0x37, 0x8d, 0x67, 0x19, 0x53, 0x59, - 0x96, 0xbf, 0x8f, 0x9e, 0xf9, 0xd1, 0xd1, 0x12, 0x86, 0xfa, 0xc8, 0x39, 0x4e, 0xd3, 0x38, 0xe8, 0x08, 0xf5, 0x84, - 0x1d, 0x7f, 0x87, 0x88, 0xff, 0xef, 0x0c, 0xff, 0x77, 0xf0, 0xbf, 0xe7, 0xf8, 0x17, 0xa1, 0xae, 0xf3, 0xd5, 0xe6, - 0xaf, 0x28, 0xff, 0x8d, 0x8f, 0x64, 0x9d, 0xbf, 0xda, 0xdb, 0x7d, 0x61, 0x5a, 0x6f, 0x92, 0x47, 0x6b, 0x13, 0x05, - 0xf4, 0xd5, 0xe3, 0xce, 0xe9, 0x04, 0x51, 0xe6, 0xd4, 0xf9, 0x88, 0xf2, 0xfb, 0x6e, 0x70, 0xe1, 0x17, 0xe3, 0x06, - 0x96, 0xad, 0x2f, 0xff, 0xf4, 0xaf, 0x90, 0xcb, 0x7d, 0x7e, 0xce, 0xb5, 0xad, 0x68, 0x94, 0xfd, 0x7d, 0x68, 0xdc, - 0x11, 0x41, 0x0d, 0xe7, 0xfe, 0xf3, 0x44, 0xf3, 0x4f, 0x06, 0x97, 0x22, 0x47, 0x6b, 0x85, 0xc5, 0x67, 0xfe, 0x4c, - 0x2b, 0x7b, 0xdf, 0xb8, 0x75, 0x65, 0x1b, 0x5a, 0x8c, 0x36, 0xdd, 0x00, 0x83, 0x49, 0x05, 0xad, 0x95, 0xb5, 0xfb, - 0xfc, 0x97, 0x89, 0x31, 0x24, 0x3c, 0xfa, 0xd9, 0xaf, 0x81, 0xfa, 0x8d, 0x6b, 0xb3, 0x06, 0x12, 0x5b, 0xa6, 0xe7, - 0x2f, 0x05, 0xb3, 0x09, 0x0d, 0x0f, 0xf5, 0x74, 0xa9, 0xf7, 0xea, 0xb3, 0x48, 0x34, 0xc6, 0xed, 0x50, 0x5c, 0x5f, - 0x01, 0x0a, 0xa4, 0x38, 0x4f, 0x97, 0xff, 0xc7, 0x5f, 0x88, 0xa2, 0xeb, 0x12, 0x3a, 0x2a, 0xe7, 0xa4, 0xe6, 0xa7, - 0xd0, 0x4b, 0x54, 0x7a, 0xa9, 0x12, 0x75, 0x54, 0x4c, 0x3c, 0x0b, 0x82, 0xeb, 0x8a, 0x9c, 0x9b, 0x5c, 0x0d, 0xe7, - 0x64, 0xdc, 0xe7, 0xff, 0x38, 0xcb, 0x49, 0x82, 0x87, 0xc0, 0x8c, 0xef, 0xec, 0x0a, 0x61, 0x49, 0x5e, 0xc3, 0xc1, - 0xec, 0xc1, 0xb0, 0x7c, 0x52, 0x03, 0x0d, 0x86, 0xf9, 0xf3, 0x05, 0x24, 0xe0, 0x1b, 0xdf, 0x0c, 0x52, 0xa3, 0xe2, - 0xa9, 0x3d, 0xad, 0x43, 0x95, 0x36, 0x06, 0x86, 0x21, 0x11, 0xc1, 0x21, 0xe7, 0x00, 0xf1, 0x41, 0xa7, 0xb3, 0xa3, - 0x8d, 0x63, 0x26, 0xa7, 0xe4, 0xa0, 0x1f, 0xe7, 0x52, 0x15, 0xca, 0xa1, 0x96, 0xd4, 0x51, 0xd1, 0x90, 0xe3, 0x32, - 0x3a, 0xd0, 0xa2, 0xdb, 0x2b, 0xc8, 0xfa, 0x32, 0x17, 0xef, 0xb2, 0xd9, 0xea, 0x91, 0x08, 0x33, 0x89, 0x18, 0xa9, - 0xe0, 0xb4, 0x64, 0x55, 0x0c, 0xfd, 0xa2, 0x25, 0xa6, 0x36, 0xe2, 0xe9, 0xde, 0x5a, 0x3a, 0x75, 0x1e, 0x79, 0x70, - 0x65, 0x90, 0xbd, 0xe2, 0x35, 0xe0, 0xde, 0x24, 0x1b, 0x66, 0x19, 0x2b, 0xb8, 0xb0, 0xbe, 0xf6, 0x69, 0xf5, 0xc1, - 0xbe, 0x43, 0xc1, 0x79, 0xf6, 0x5e, 0x0c, 0xba, 0x19, 0xec, 0x82, 0xea, 0x8d, 0xfd, 0x3c, 0x0d, 0xf8, 0xdd, 0x03, - 0xa5, 0xa0, 0xc0, 0x31, 0xa8, 0xa7, 0x3b, 0x7f, 0x43, 0x04, 0x7e, 0x60, 0x37, 0x60, 0x9d, 0xb3, 0x6d, 0xc1, 0x95, - 0x84, 0x6a, 0x20, 0xd5, 0x65, 0x2c, 0x0b, 0x6c, 0xa7, 0x87, 0x2d, 0xae, 0x7b, 0x8e, 0x06, 0xa5, 0xba, 0x37, 0x13, - 0x94, 0x89, 0xa3, 0x65, 0xae, 0x03, 0x5b, 0x04, 0x30, 0xb2, 0x15, 0xe1, 0x36, 0x20, 0xaf, 0x2e, 0x66, 0x34, 0xf4, - 0x87, 0xb8, 0x9a, 0x04, 0x34, 0xa0, 0x91, 0x8b, 0x8e, 0x17, 0x5d, 0x2f, 0x9d, 0xd2, 0xdb, 0xe3, 0x09, 0x72, 0xb0, - 0x73, 0x16, 0x86, 0xf2, 0x7a, 0x92, 0xf3, 0xa5, 0xe7, 0xbf, 0x9e, 0x10, 0xed, 0xf5, 0x31, 0x86, 0xb3, 0x85, 0x94, - 0xc4, 0x06, 0x32, 0xb4, 0x3a, 0x8e, 0xe8, 0xe0, 0x21, 0x0f, 0xe8, 0x49, 0x69, 0x96, 0xd7, 0x4d, 0x88, 0x8a, 0xb9, - 0x08, 0x95, 0xc8, 0xcb, 0xe2, 0x9a, 0xad, 0x2b, 0x10, 0x98, 0xd9, 0x2e, 0xf8, 0x82, 0xa3, 0xd1, 0xca, 0xf0, 0x82, - 0x30, 0x27, 0x8c, 0x90, 0xc5, 0xfa, 0x27, 0xc0, 0x1b, 0xbe, 0x06, 0x45, 0xfe, 0x64, 0x5c, 0x12, 0x9e, 0x59, 0xe6, - 0x0e, 0xe1, 0x84, 0x9f, 0x2a, 0x61, 0x75, 0x17, 0x15, 0xfa, 0xc7, 0xf3, 0x52, 0x50, 0x1c, 0x35, 0x06, 0x0c, 0xfb, - 0x22, 0x04, 0x91, 0x8a, 0xd2, 0xec, 0x3b, 0x88, 0x54, 0xf7, 0x03, 0xb1, 0x1e, 0x8e, 0x68, 0xc3, 0x23, 0x11, 0xc8, - 0x1f, 0x49, 0xb5, 0xd0, 0xc6, 0x47, 0x22, 0x24, 0xbd, 0x7d, 0xf3, 0xc1, 0x79, 0xb9, 0x29, 0xaa, 0x2c, 0xa3, 0xd0, - 0x27, 0x3a, 0xa8, 0x9c, 0xea, 0xf1, 0x7a, 0x96, 0xda, 0x0a, 0xae, 0x5b, 0x2b, 0x81, 0x8a, 0x8d, 0xe9, 0x32, 0xf7, - 0x91, 0xed, 0xb5, 0x4b, 0x29, 0xf1, 0xe7, 0x79, 0xc6, 0x1a, 0x8e, 0x04, 0xec, 0xa6, 0x17, 0x86, 0xb1, 0x93, 0x1d, - 0x43, 0x47, 0x92, 0xf4, 0x82, 0xd2, 0x74, 0x8c, 0x91, 0x9b, 0xd7, 0xdd, 0x60, 0xbb, 0x8a, 0xc1, 0x57, 0x03, 0xc3, - 0x39, 0x34, 0x69, 0x38, 0x29, 0x38, 0xd7, 0xad, 0xfb, 0xeb, 0x78, 0xe9, 0x3c, 0x2f, 0xfb, 0x18, 0x96, 0x47, 0x3c, - 0xd7, 0xf6, 0x5f, 0x2f, 0xe4, 0xf9, 0x7a, 0x09, 0xcb, 0xd6, 0x5f, 0x93, 0xe4, 0x08, 0xd9, 0xcf, 0x2a, 0xa2, 0xe6, - 0x17, 0x8c, 0x43, 0xe4, 0x4f, 0x71, 0x4c, 0x15, 0xcd, 0x5c, 0x75, 0x7a, 0x54, 0x7a, 0x83, 0x5e, 0x3c, 0x3c, 0x20, - 0x38, 0x09, 0x90, 0x27, 0x4e, 0x42, 0x18, 0x30, 0xe4, 0x14, 0xd6, 0x7c, 0x05, 0x3c, 0xe6, 0x71, 0xc3, 0x53, 0x9a, - 0xab, 0x3f, 0x01, 0x34, 0xa0, 0x02, 0xea, 0xc3, 0x0e, 0x5f, 0x2c, 0xc1, 0x86, 0x46, 0x08, 0x21, 0x9f, 0x10, 0xfc, - 0x2e, 0xff, 0x4a, 0x50, 0x16, 0x3b, 0x05, 0x32, 0x5a, 0xa7, 0xda, 0x15, 0x93, 0x95, 0x36, 0xa8, 0xff, 0x96, 0x76, - 0x53, 0x5e, 0x10, 0x39, 0x88, 0x50, 0x01, 0x06, 0xb2, 0xda, 0x50, 0xe0, 0xd5, 0x65, 0x9b, 0x69, 0x06, 0x63, 0xf5, - 0x51, 0xd3, 0x66, 0xef, 0xf6, 0x4a, 0x4f, 0x65, 0x40, 0xd4, 0x06, 0x86, 0x77, 0xfd, 0xcf, 0xe1, 0x69, 0x19, 0xcf, - 0x8b, 0x41, 0x35, 0x4e, 0x87, 0xc8, 0x70, 0x9d, 0x3a, 0x56, 0xad, 0xfc, 0xcf, 0x94, 0x12, 0xe3, 0x53, 0x51, 0xe1, - 0xc0, 0xba, 0x6a, 0xa8, 0x5a, 0x10, 0xa6, 0x8d, 0xd2, 0x3f, 0x28, 0xca, 0xa0, 0x05, 0x58, 0x1a, 0xb5, 0xc6, 0x01, - 0x9b, 0x9a, 0x5e, 0x9e, 0x1b, 0xd3, 0x89, 0x57, 0xfb, 0x9b, 0xc0, 0x84, 0xbb, 0xfa, 0x31, 0x87, 0xba, 0xb6, 0x78, - 0xae, 0xef, 0xbb, 0x8f, 0xa7, 0x3c, 0x24, 0x7a, 0x26, 0x16, 0x9a, 0xb2, 0xb9, 0xce, 0xe7, 0x1d, 0x14, 0x1b, 0x5e, - 0xd8, 0xe7, 0x2b, 0x24, 0xc8, 0x01, 0x87, 0x4d, 0x71, 0x35, 0x0e, 0xcf, 0x38, 0x27, 0x2a, 0x7d, 0x2e, 0xf6, 0x83, - 0x03, 0x38, 0x3c, 0xe7, 0x4a, 0x59, 0xcb, 0xe7, 0x6f, 0xd3, 0x19, 0x4f, 0xfa, 0x12, 0x9c, 0x94, 0x32, 0xf1, 0x8a, - 0xc5, 0x9f, 0xf1, 0xf1, 0x11, 0x67, 0x78, 0x7f, 0xa3, 0xf3, 0x69, 0x7f, 0x9b, 0xae, 0x8d, 0x79, 0x0f, 0xf2, 0x46, - 0x05, 0xcf, 0x5d, 0x58, 0xc6, 0x36, 0x51, 0x40, 0xf0, 0x37, 0x3a, 0xa7, 0x69, 0x1e, 0x42, 0x3c, 0xac, 0xa2, 0x22, - 0xbf, 0xa3, 0x48, 0x96, 0x68, 0xe1, 0x6d, 0x4e, 0x8b, 0xf4, 0x6a, 0x7a, 0xde, 0xe6, 0x4b, 0x99, 0x0e, 0xed, 0xc6, - 0xd4, 0x49, 0xb4, 0xaa, 0x30, 0x21, 0x88, 0xc0, 0x9d, 0x2e, 0x91, 0x60, 0xb7, 0x26, 0xb3, 0x27, 0xfc, 0x6b, 0x4c, - 0xe3, 0x20, 0x6e, 0xd9, 0x73, 0x8b, 0x7d, 0x4f, 0xcd, 0x1c, 0xe8, 0x85, 0x9c, 0xa1, 0x38, 0x64, 0x0c, 0xfa, 0x42, - 0xae, 0x1f, 0x8f, 0xd0, 0x49, 0x05, 0xf8, 0x55, 0xc9, 0xaf, 0x7a, 0xbc, 0x96, 0x0a, 0x95, 0xea, 0x6c, 0x22, 0x47, - 0xfb, 0xb3, 0xb4, 0xf1, 0x18, 0xfb, 0xb1, 0x3c, 0x7d, 0xed, 0xe9, 0x04, 0x50, 0xb1, 0x25, 0xb7, 0xb2, 0x6f, 0xc8, - 0x43, 0x4b, 0xbf, 0x79, 0x9c, 0x61, 0xe6, 0x08, 0xd0, 0xd9, 0xaa, 0xe0, 0xa9, 0x8b, 0xd9, 0xdb, 0x5b, 0x6e, 0x2a, - 0xb6, 0xdf, 0xf0, 0x19, 0x60, 0x27, 0x89, 0x40, 0x34, 0xaa, 0xf6, 0x59, 0xa7, 0x31, 0x79, 0x2a, 0x9e, 0x42, 0x23, - 0x60, 0x29, 0x22, 0xd7, 0x94, 0x68, 0x5d, 0xcb, 0xd0, 0x45, 0x72, 0x7c, 0xbb, 0x1c, 0x82, 0x04, 0x77, 0x68, 0xd6, - 0x48, 0xe8, 0xa9, 0xba, 0x62, 0xae, 0x54, 0x95, 0x50, 0x77, 0x3a, 0x4c, 0x79, 0x6e, 0xac, 0xf0, 0x28, 0x73, 0xd1, - 0xb9, 0x8e, 0x55, 0x25, 0xc2, 0x22, 0x2e, 0xce, 0x02, 0x2f, 0x02, 0xfc, 0x08, 0x0c, 0xe2, 0x52, 0x95, 0x65, 0xa6, - 0x08, 0x49, 0x93, 0x6d, 0x81, 0xb1, 0xe2, 0xd0, 0x6f, 0xa2, 0x9a, 0x07, 0x5f, 0xff, 0x34, 0xa1, 0x28, 0xec, 0x04, - 0x44, 0xa3, 0x41, 0xb7, 0x16, 0xd0, 0xcc, 0xfc, 0x9b, 0x72, 0x78, 0x0c, 0x9d, 0x27, 0xf1, 0x86, 0x25, 0xc9, 0xa2, - 0x3f, 0xff, 0x97, 0x06, 0x61, 0xd2, 0x3b, 0x90, 0x52, 0x95, 0x40, 0xbb, 0x61, 0x6e, 0x3e, 0x89, 0x0c, 0xd9, 0xa2, - 0x5c, 0xec, 0x71, 0x94, 0x73, 0x1b, 0xd2, 0x0a, 0x66, 0x5e, 0x42, 0xc9, 0x3b, 0x5a, 0xaf, 0xbc, 0x0e, 0xab, 0x6e, - 0x79, 0x8d, 0x97, 0x38, 0xd1, 0x2f, 0x58, 0x74, 0x4d, 0xae, 0xc1, 0x6d, 0x42, 0x03, 0x32, 0x2b, 0x13, 0xd5, 0x1b, - 0x82, 0xa7, 0x90, 0xb2, 0x31, 0xe0, 0xe2, 0xdc, 0xe0, 0xd1, 0xf9, 0x9c, 0x90, 0xb2, 0x90, 0x0b, 0xcd, 0x00, 0x27, - 0x31, 0x92, 0xd1, 0xa6, 0x2e, 0xe5, 0x42, 0x9d, 0x7e, 0xe0, 0xad, 0x83, 0x1e, 0xd6, 0x66, 0x67, 0x2b, 0xf6, 0xb2, - 0x5e, 0x31, 0xb2, 0xa6, 0xea, 0x72, 0x52, 0x6e, 0x4a, 0x68, 0x68, 0x3f, 0xc6, 0x7b, 0x19, 0x87, 0x1c, 0x66, 0xd5, - 0x18, 0x89, 0x3d, 0x23, 0x39, 0xb3, 0x49, 0x92, 0x65, 0xd6, 0x65, 0x2d, 0xfd, 0x6c, 0xae, 0xfd, 0x8f, 0x6c, 0xe1, - 0x11, 0xcb, 0x38, 0xbf, 0xd2, 0x43, 0x49, 0xb8, 0xb2, 0x4e, 0x93, 0xe7, 0xe9, 0x07, 0xe1, 0xba, 0xdb, 0xd4, 0x8c, - 0x86, 0xf7, 0x80, 0x84, 0x2c, 0x6d, 0xd9, 0xaa, 0x36, 0x36, 0x36, 0xf8, 0xb9, 0xcf, 0x03, 0x0b, 0xf1, 0xa0, 0x21, - 0x19, 0xfd, 0x7c, 0x9b, 0xc7, 0x86, 0x85, 0xce, 0xe8, 0xa0, 0x94, 0x5e, 0x4b, 0x71, 0xee, 0x05, 0x4a, 0x2c, 0xf8, - 0x72, 0xaf, 0xa4, 0xc9, 0x99, 0x07, 0x7e, 0x10, 0x67, 0x46, 0x17, 0x99, 0x77, 0xbe, 0x46, 0xdd, 0x4a, 0xef, 0x95, - 0x96, 0xf4, 0x83, 0x5f, 0xfd, 0x6c, 0x95, 0x5e, 0xa7, 0x5f, 0x22, 0x73, 0xe6, 0x2b, 0x5c, 0xa2, 0x5d, 0x81, 0x1d, - 0xc6, 0x49, 0x5d, 0xf3, 0xeb, 0x0e, 0xd0, 0xfb, 0xc2, 0x9b, 0x81, 0x46, 0x89, 0xc0, 0x0b, 0x4d, 0x2e, 0x71, 0x25, - 0xbe, 0x10, 0xde, 0x14, 0x7b, 0x98, 0xc1, 0x44, 0x6c, 0x14, 0x41, 0x3c, 0x00, 0xff, 0x1c, 0xe2, 0x97, 0x31, 0xbf, - 0xd7, 0x41, 0x6d, 0xb4, 0x20, 0x34, 0x45, 0xe6, 0x26, 0x7b, 0x11, 0x5b, 0x90, 0xc3, 0x2b, 0x81, 0x04, 0xb9, 0x66, - 0x8d, 0x1d, 0xb0, 0x65, 0x5b, 0xa1, 0x6c, 0xde, 0x70, 0x6c, 0xb0, 0x3b, 0x7b, 0x69, 0x6d, 0x12, 0x84, 0xb8, 0x44, - 0xd4, 0x9a, 0x1e, 0x19, 0xe5, 0xab, 0x96, 0x82, 0x4a, 0xf4, 0xf3, 0xf4, 0xbf, 0xb1, 0x49, 0x6f, 0x17, 0x04, 0xfd, - 0x52, 0x64, 0xda, 0x2f, 0x34, 0x0c, 0x4a, 0x8b, 0xbc, 0xff, 0x43, 0x09, 0x7c, 0x1d, 0xac, 0xe9, 0x3a, 0xd5, 0x6e, - 0x7d, 0xf1, 0x4f, 0x50, 0x3d, 0x4f, 0xc6, 0x1d, 0x46, 0x2e, 0x0c, 0x68, 0x7e, 0x6f, 0x24, 0x9d, 0xb7, 0xfe, 0xcf, - 0x12, 0x02, 0x67, 0x90, 0x25, 0x14, 0xae, 0x11, 0xf9, 0x26, 0xff, 0xc0, 0x30, 0xed, 0x15, 0xc1, 0xce, 0x8e, 0xbc, - 0x37, 0xb6, 0x93, 0x75, 0x88, 0x36, 0xf4, 0x0d, 0x98, 0xb2, 0x7e, 0xa3, 0x59, 0x36, 0x40, 0x6d, 0x3b, 0xe3, 0xb6, - 0x2b, 0x69, 0x16, 0x92, 0xe1, 0x99, 0xc5, 0x20, 0xd5, 0x46, 0x7e, 0xe6, 0x95, 0x85, 0x33, 0x73, 0x77, 0xa1, 0x91, - 0x9f, 0x3d, 0x9d, 0xe1, 0xf0, 0xc6, 0x46, 0x7a, 0xe1, 0x09, 0x4b, 0x73, 0x43, 0x07, 0x00, 0x20, 0x5c, 0x2a, 0x3a, - 0xde, 0xb1, 0x54, 0xd9, 0x4a, 0xd4, 0x57, 0x82, 0xe6, 0xe0, 0x38, 0x1b, 0x5d, 0xc8, 0x27, 0x4c, 0x18, 0xe9, 0x79, - 0x0e, 0x11, 0x8f, 0x63, 0xe5, 0x32, 0x18, 0x32, 0x07, 0x3f, 0x91, 0x60, 0x47, 0xb3, 0xc3, 0x59, 0x0e, 0x57, 0xa9, - 0x5f, 0x27, 0xa8, 0x86, 0xd4, 0x58, 0x65, 0x6c, 0xc3, 0xfa, 0xff, 0x0a, 0x27, 0x2e, 0xeb, 0x79, 0x42, 0x2f, 0x3a, - 0x2c, 0xdc, 0xc2, 0x47, 0x2e, 0xf9, 0x87, 0x38, 0x38, 0xc4, 0xd9, 0x18, 0xe7, 0x19, 0x48, 0x9e, 0x36, 0x50, 0x18, - 0x79, 0x39, 0xbe, 0x54, 0x72, 0xb6, 0x1b, 0xaa, 0x4a, 0x4a, 0x97, 0x74, 0xab, 0xbd, 0x67, 0xb2, 0x1f, 0x91, 0x13, - 0x27, 0x45, 0x24, 0x99, 0x4c, 0x2a, 0xa8, 0x53, 0x1a, 0x6c, 0xfa, 0x15, 0xc0, 0x08, 0xe6, 0x5a, 0xd7, 0x34, 0x45, - 0x69, 0x99, 0x70, 0xf8, 0x1c, 0x2d, 0xd6, 0x99, 0x43, 0xd1, 0xc1, 0x60, 0x50, 0x48, 0x33, 0xb4, 0x0b, 0x3c, 0xc8, - 0xa8, 0xcc, 0x1e, 0x7f, 0x9e, 0x9f, 0x14, 0x4d, 0xd8, 0x64, 0xc5, 0x2a, 0x51, 0xa4, 0x96, 0xb1, 0xa4, 0x54, 0x75, - 0xfe, 0xed, 0x3e, 0x1a, 0x1a, 0x1d, 0x58, 0x03, 0x3a, 0x0a, 0x25, 0x9c, 0xf7, 0x50, 0x3c, 0xe9, 0x4e, 0xcd, 0xde, - 0x7e, 0xeb, 0x77, 0x57, 0x1f, 0xe2, 0x54, 0xdf, 0x25, 0x9d, 0xdc, 0x37, 0x9e, 0xb0, 0x9d, 0xb1, 0xd7, 0xba, 0xbe, - 0x63, 0x0b, 0x60, 0x97, 0x19, 0xcf, 0xc6, 0xf0, 0xbe, 0x8e, 0xbd, 0xf8, 0x82, 0x5c, 0x3d, 0x7f, 0xd6, 0x6a, 0xf9, - 0x8c, 0x5b, 0x0a, 0xa7, 0x1c, 0xa1, 0x85, 0x45, 0x40, 0x76, 0xc0, 0x28, 0x20, 0x2f, 0xa1, 0xcb, 0x18, 0xc2, 0x83, - 0x5f, 0x1b, 0x14, 0xad, 0x0e, 0x38, 0x13, 0xe8, 0x51, 0x3f, 0xe8, 0xc1, 0xdf, 0x74, 0x12, 0x6f, 0x4c, 0xbc, 0xb7, - 0x68, 0x4a, 0xbf, 0x5a, 0x69, 0x53, 0xa6, 0x3c, 0xbb, 0xe4, 0xc3, 0xd8, 0x0e, 0x55, 0x5b, 0xbb, 0x0d, 0x35, 0xc4, - 0x67, 0xb8, 0x9f, 0xb8, 0xa2, 0x78, 0x6c, 0x06, 0xea, 0x6f, 0xfc, 0xc8, 0x3b, 0x6d, 0x3f, 0x92, 0xfb, 0x10, 0x87, - 0x1e, 0xcb, 0xbe, 0x2a, 0xfb, 0x00, 0xae, 0xd9, 0xd9, 0x58, 0x89, 0x9f, 0x15, 0x61, 0xb8, 0x1c, 0x82, 0xf1, 0x67, - 0xb5, 0x0d, 0x3d, 0xa9, 0xa7, 0xac, 0x79, 0xfc, 0x3a, 0x32, 0xbf, 0xc9, 0xc2, 0xa4, 0x5e, 0xc8, 0x9a, 0x1e, 0xa7, - 0x33, 0x43, 0xe7, 0x4c, 0xe4, 0xee, 0xd9, 0xaf, 0xd1, 0xda, 0xc8, 0x48, 0xed, 0x6c, 0xd1, 0xc7, 0xdf, 0x35, 0x55, - 0x36, 0x9a, 0x34, 0xe3, 0xb1, 0x73, 0xad, 0x4b, 0x97, 0xba, 0x8c, 0x4c, 0xf7, 0x67, 0xbd, 0x58, 0x50, 0xa5, 0x55, - 0x66, 0xde, 0x27, 0x52, 0xc3, 0xb8, 0xd6, 0x6a, 0xe2, 0x9d, 0x5e, 0x7c, 0xcb, 0x8e, 0xdd, 0x74, 0x96, 0x64, 0x30, - 0x98, 0x8e, 0xdc, 0x0c, 0x1d, 0xc9, 0x08, 0x53, 0xbf, 0x7c, 0x32, 0x30, 0xeb, 0x3a, 0x7f, 0x6f, 0x5f, 0x33, 0x8e, - 0xe2, 0x5b, 0x8f, 0x15, 0xfd, 0xb6, 0x4e, 0xb7, 0xef, 0x09, 0x70, 0x6d, 0x53, 0xfb, 0xd4, 0x5b, 0xa5, 0x9c, 0x8d, - 0xe4, 0x58, 0x4e, 0xe2, 0x09, 0x14, 0x3c, 0x00, 0x49, 0x04, 0x4c, 0x9a, 0xf9, 0xc5, 0x52, 0xc9, 0x84, 0x8d, 0xba, - 0xdb, 0xef, 0x15, 0x17, 0x18, 0xb5, 0x7f, 0x94, 0xb9, 0x9a, 0x5e, 0x8e, 0x14, 0x25, 0xd3, 0x81, 0x81, 0xa6, 0x30, - 0x96, 0x3e, 0xff, 0x62, 0x5b, 0x89, 0x05, 0x95, 0x45, 0xb1, 0xb0, 0x66, 0x74, 0x00, 0x22, 0xe8, 0x63, 0x2a, 0xa8, - 0x1a, 0x7e, 0x43, 0xca, 0xe7, 0xf4, 0xeb, 0xd9, 0xf9, 0x88, 0x6d, 0xba, 0x29, 0x7d, 0x8c, 0x07, 0xab, 0xec, 0x75, - 0x7e, 0x1f, 0x9f, 0xfa, 0x42, 0x2f, 0x5e, 0x49, 0xde, 0x59, 0xf7, 0x4f, 0xf3, 0xcf, 0xe7, 0x6c, 0xfe, 0xf9, 0xac, - 0x19, 0x60, 0x98, 0xd9, 0x18, 0xf7, 0x2a, 0x7a, 0xb5, 0xc0, 0x35, 0x4e, 0x65, 0x78, 0x1a, 0xcb, 0x7f, 0x74, 0x16, - 0xae, 0x32, 0xc0, 0x99, 0x6c, 0xa0, 0x4d, 0x65, 0x10, 0x7c, 0x73, 0xc3, 0x82, 0xa6, 0x2b, 0x27, 0x26, 0x9a, 0x99, - 0x48, 0x5c, 0x82, 0x9c, 0xc4, 0x1c, 0xec, 0xc9, 0xa5, 0xea, 0x3a, 0x25, 0x33, 0x7e, 0x66, 0xda, 0x82, 0x9e, 0xa0, - 0xf0, 0x53, 0x90, 0xa9, 0x00, 0x41, 0x74, 0xbe, 0x4f, 0xf3, 0x38, 0x4a, 0x7f, 0xe7, 0x98, 0xa7, 0xfc, 0xff, 0x89, - 0x2c, 0x11, 0xa9, 0x6b, 0x19, 0xc2, 0x01, 0xbf, 0x46, 0x34, 0x75, 0xc6, 0xef, 0xc5, 0xa0, 0x7e, 0x91, 0xde, 0xaa, - 0x11, 0x38, 0x17, 0xa0, 0xae, 0x46, 0x98, 0x06, 0xf2, 0x8e, 0xf6, 0x1a, 0xf9, 0xc5, 0xa9, 0xd2, 0x2d, 0x7b, 0x5a, - 0xf2, 0x8a, 0x2c, 0x54, 0xfe, 0xc9, 0x98, 0x62, 0x56, 0x15, 0x1d, 0x47, 0x9a, 0xf7, 0x0d, 0x36, 0xc9, 0x17, 0x4e, - 0x12, 0x7a, 0xca, 0xa9, 0xb1, 0x30, 0x6e, 0x15, 0x5e, 0xda, 0x85, 0xd1, 0x07, 0x64, 0x0e, 0x34, 0x17, 0x65, 0x3f, - 0x0e, 0x71, 0x44, 0x7c, 0xa0, 0x9f, 0xf1, 0x2d, 0x64, 0xdc, 0xf6, 0x1c, 0x27, 0xc4, 0xa9, 0xfa, 0x62, 0x28, 0x5e, - 0xc6, 0x26, 0x15, 0x92, 0x1b, 0xb2, 0x18, 0xca, 0xaa, 0x85, 0xe7, 0x67, 0xf0, 0x7c, 0xe1, 0xf9, 0x69, 0x9e, 0x1b, - 0xbc, 0x25, 0x53, 0x51, 0x46, 0x62, 0xed, 0x0a, 0x3b, 0x6a, 0xf9, 0x4d, 0x5e, 0x61, 0xef, 0x73, 0x00, 0x12, 0xd5, - 0x5e, 0x15, 0x0b, 0x72, 0x01, 0x1b, 0xd0, 0xa0, 0x3a, 0x0c, 0xf2, 0x12, 0x5f, 0x0a, 0x20, 0x00, 0xa3, 0x07, 0xef, - 0xd9, 0x71, 0x3a, 0x6d, 0x0c, 0xe3, 0x2e, 0xc7, 0x04, 0x4a, 0xae, 0xd9, 0x54, 0x12, 0xf2, 0x26, 0x27, 0x9c, 0x7d, - 0x01, 0x2a, 0x84, 0x31, 0x80, 0x3c, 0x35, 0xb6, 0x58, 0xbd, 0x7e, 0x2b, 0xd5, 0xe7, 0x04, 0xa3, 0xb0, 0x97, 0x98, - 0x7d, 0xa1, 0x1b, 0x11, 0x11, 0x39, 0x8b, 0x39, 0xb9, 0xf4, 0xda, 0x55, 0xea, 0xfb, 0xd1, 0xd9, 0x27, 0x04, 0xf4, - 0xed, 0xe4, 0x5b, 0x73, 0x13, 0x0e, 0xc7, 0x97, 0x2c, 0x33, 0xd1, 0x7f, 0xae, 0x16, 0xc8, 0xb3, 0x32, 0xe7, 0x3d, - 0x57, 0xc7, 0xae, 0x89, 0x34, 0xc5, 0xa6, 0xa0, 0x53, 0x70, 0x4a, 0x10, 0x41, 0x5b, 0x70, 0x92, 0xe4, 0x90, 0x88, - 0xca, 0x40, 0x7f, 0x36, 0x15, 0x4b, 0x30, 0x5b, 0xc3, 0x52, 0x9d, 0xd7, 0x5a, 0xec, 0x9a, 0x1f, 0xb2, 0x27, 0x99, - 0x65, 0xc3, 0x45, 0xea, 0x4c, 0x27, 0xc5, 0x75, 0x64, 0x8d, 0xc2, 0xd4, 0xb8, 0x8b, 0xc5, 0xab, 0x84, 0x2b, 0xa9, - 0xdc, 0xa4, 0x4a, 0xbc, 0xd9, 0xa8, 0x19, 0x8f, 0xc6, 0xe5, 0x8f, 0x6d, 0x76, 0xf4, 0x46, 0x4a, 0xc0, 0xe3, 0x21, - 0xd5, 0xde, 0xa6, 0x33, 0x6e, 0x43, 0xfe, 0xed, 0xea, 0x69, 0x0b, 0x58, 0xfc, 0x4f, 0x7a, 0x18, 0xe2, 0xff, 0x8d, - 0x0a, 0x8a, 0xc8, 0x36, 0x42, 0x78, 0x14, 0x02, 0x84, 0xfb, 0xc2, 0xc9, 0x0b, 0x62, 0x51, 0xc4, 0xa3, 0xb0, 0xf7, - 0x3a, 0x6b, 0xe5, 0x12, 0x16, 0x7f, 0x1f, 0x74, 0xff, 0x8e, 0x42, 0xe7, 0x5c, 0xaf, 0xf1, 0xa7, 0xe2, 0xc7, 0x1f, - 0x39, 0x76, 0x74, 0x28, 0xc2, 0x4d, 0x0c, 0xad, 0x04, 0xcf, 0xb6, 0x44, 0x75, 0xac, 0x0a, 0x46, 0x84, 0x30, 0x07, - 0xa9, 0x6a, 0xc1, 0x04, 0xbb, 0xf0, 0x13, 0x2c, 0x3e, 0x10, 0x4e, 0xff, 0x1b, 0x1a, 0xb7, 0x09, 0x61, 0x36, 0x58, - 0xe5, 0xa8, 0x93, 0x27, 0xf1, 0x6a, 0x9f, 0xf9, 0x23, 0xe3, 0xd6, 0x57, 0x5f, 0xa4, 0xd5, 0x48, 0xf6, 0x14, 0x33, - 0x38, 0xbc, 0x76, 0x4b, 0x99, 0x19, 0x9f, 0x23, 0x26, 0x55, 0xf3, 0xe4, 0x45, 0x41, 0xa9, 0xa1, 0xae, 0x59, 0x9e, - 0x0b, 0xf3, 0x9c, 0xe1, 0xb9, 0xc0, 0x60, 0x2c, 0xeb, 0xb2, 0xc6, 0x19, 0x9f, 0xc6, 0x56, 0x0c, 0x8c, 0x3b, 0x1e, - 0x0e, 0x7a, 0x8e, 0x39, 0x54, 0x80, 0x5e, 0x04, 0x6e, 0x22, 0x3e, 0xe9, 0x25, 0x70, 0xea, 0xd4, 0x86, 0xf3, 0xcc, - 0x5c, 0x8e, 0x98, 0xf2, 0x0c, 0xa7, 0x98, 0xd2, 0xef, 0xdc, 0x26, 0xd2, 0x6e, 0x7d, 0xad, 0xc9, 0x47, 0xc1, 0xad, - 0x7a, 0x3a, 0xac, 0x23, 0x1a, 0x97, 0x16, 0xc1, 0x93, 0x45, 0x79, 0xd8, 0xbf, 0xf1, 0x9c, 0x5f, 0x89, 0xb4, 0x93, - 0x52, 0x25, 0x3a, 0x9f, 0x79, 0x0e, 0x42, 0x48, 0x93, 0x96, 0xd0, 0xf6, 0xb9, 0x20, 0x25, 0xc6, 0xb5, 0x53, 0x8a, - 0xd8, 0xcd, 0xc1, 0xfe, 0xd7, 0x53, 0xb9, 0x48, 0xab, 0xeb, 0x87, 0x77, 0x8b, 0x85, 0xd8, 0x4e, 0xc8, 0x79, 0x2e, - 0x64, 0xef, 0xfd, 0x6b, 0x12, 0x5e, 0x27, 0xe5, 0x97, 0xfd, 0x20, 0x71, 0xef, 0x5c, 0x6a, 0xfe, 0x5f, 0x38, 0x6c, - 0x7a, 0xf4, 0xca, 0xc1, 0x6c, 0x33, 0x01, 0x93, 0xa3, 0x52, 0x55, 0xdf, 0x8f, 0x80, 0xd4, 0xf0, 0x30, 0x40, 0x59, - 0xc5, 0xfe, 0x41, 0xbd, 0x25, 0x00, 0xb8, 0xd4, 0xb3, 0xfd, 0xb0, 0xb4, 0xbf, 0xfb, 0x61, 0xab, 0x13, 0x2e, 0x57, - 0x4a, 0xfe, 0x1b, 0x18, 0x42, 0x97, 0x86, 0xe4, 0xb0, 0x75, 0xd6, 0x03, 0x21, 0x77, 0x9e, 0x73, 0x8a, 0x51, 0x5c, - 0x4b, 0xbe, 0x60, 0xfb, 0x91, 0x94, 0xd7, 0xc9, 0x7c, 0x7e, 0xb1, 0xb5, 0x82, 0xcf, 0x3a, 0xa9, 0xbf, 0xa8, 0x44, - 0xff, 0x05, 0xec, 0x3b, 0x88, 0x0f, 0xcf, 0x13, 0xab, 0xca, 0x0f, 0x1d, 0x87, 0x23, 0xac, 0xb0, 0x95, 0x2a, 0x0d, - 0xcd, 0x23, 0xd3, 0xc7, 0x94, 0x9c, 0x9a, 0x80, 0x71, 0x48, 0x72, 0xb6, 0x56, 0x91, 0x4b, 0x92, 0x6a, 0x16, 0xee, - 0xeb, 0x06, 0x9f, 0x84, 0xe9, 0x92, 0x56, 0xe1, 0x1e, 0xa0, 0x7f, 0x0c, 0x7a, 0xfe, 0xcf, 0x4a, 0x71, 0xc0, 0xb0, - 0xea, 0x85, 0x4c, 0xfc, 0x64, 0x00, 0xd7, 0x66, 0x9e, 0xcc, 0x71, 0xe3, 0x6d, 0xd4, 0x47, 0x6d, 0x4b, 0xc0, 0x4f, - 0xa2, 0x27, 0x8f, 0x61, 0x4c, 0x16, 0x86, 0x02, 0x77, 0x2e, 0xf5, 0x73, 0xb0, 0x70, 0xc3, 0x31, 0xfa, 0x86, 0xe0, - 0x5b, 0xfe, 0xfc, 0x0e, 0xd4, 0x71, 0x8c, 0x86, 0x2e, 0x8d, 0xa4, 0xf4, 0x3b, 0x14, 0x44, 0x1a, 0x48, 0xa0, 0x79, - 0xee, 0x6b, 0x28, 0x9c, 0x4e, 0x22, 0x3b, 0xc9, 0x11, 0xd6, 0xce, 0x82, 0x59, 0xab, 0x9c, 0xbe, 0x9e, 0xed, 0x3b, - 0x0c, 0xd0, 0xb5, 0xcb, 0xe9, 0x7d, 0xae, 0xbf, 0x95, 0xa9, 0x9f, 0x2b, 0x84, 0x96, 0x65, 0x9b, 0x9d, 0x43, 0x20, - 0x94, 0x6b, 0xf5, 0x34, 0x44, 0xc1, 0x10, 0xef, 0x74, 0xac, 0x6e, 0xd0, 0x47, 0x2a, 0x5a, 0xe7, 0xab, 0x0d, 0xda, - 0x7c, 0xab, 0xf0, 0xd2, 0xf5, 0xca, 0xf6, 0x34, 0x24, 0xc5, 0x34, 0x62, 0x38, 0xf8, 0x66, 0x42, 0x67, 0xf2, 0x3e, - 0x6e, 0x90, 0x4d, 0x9c, 0x21, 0x79, 0xb8, 0x8e, 0x58, 0xba, 0x4a, 0x58, 0x54, 0xb6, 0xf0, 0x74, 0x4a, 0x3b, 0x5c, - 0xdd, 0x15, 0xe1, 0xe6, 0x4c, 0x06, 0x6a, 0xb9, 0x8e, 0xbe, 0x89, 0xc4, 0x20, 0x07, 0x6c, 0xb9, 0x4e, 0x1f, 0x1d, - 0x55, 0xaa, 0x90, 0xe9, 0x76, 0xde, 0xbc, 0x86, 0x05, 0x87, 0x2d, 0x63, 0x29, 0x0d, 0x30, 0xf0, 0x5d, 0x7b, 0x79, - 0x5f, 0x6d, 0x76, 0x2a, 0x3c, 0xe6, 0xbc, 0x4b, 0x69, 0x91, 0x57, 0xa9, 0x7f, 0x6e, 0xe3, 0xb5, 0xf9, 0xd5, 0xdc, - 0x46, 0x17, 0xbc, 0x39, 0x27, 0x3a, 0xad, 0x6f, 0x31, 0x5e, 0x76, 0x88, 0x68, 0xe7, 0x3e, 0x97, 0x79, 0xba, 0x85, - 0xd4, 0x62, 0xcc, 0x3a, 0xc7, 0x4c, 0x4f, 0x4b, 0x8b, 0xf2, 0x11, 0x63, 0x50, 0xc9, 0x9d, 0xf2, 0xcd, 0xc8, 0x53, - 0x8d, 0xc2, 0x10, 0xab, 0x6d, 0xe8, 0x19, 0xb4, 0xd2, 0x4f, 0x34, 0xfb, 0xf6, 0x76, 0xb3, 0x06, 0xa8, 0xc4, 0x62, - 0x1f, 0x99, 0x65, 0xe1, 0xe3, 0x72, 0x07, 0xa1, 0x83, 0x34, 0xb7, 0x93, 0xc3, 0x38, 0x3d, 0x46, 0x2b, 0xf4, 0xbb, - 0xf6, 0x14, 0xb3, 0x80, 0x48, 0x52, 0x2f, 0x90, 0xce, 0x01, 0x77, 0x49, 0xfd, 0x59, 0x9c, 0x19, 0x61, 0x3e, 0x7a, - 0x29, 0xa1, 0xdc, 0x25, 0x94, 0x1b, 0xaf, 0xf3, 0x24, 0x00, 0x3e, 0x89, 0xb6, 0xd7, 0x56, 0x9c, 0xe6, 0x9c, 0xd7, - 0xb6, 0x08, 0xc3, 0xae, 0xeb, 0xc5, 0x48, 0x72, 0x33, 0x7b, 0x61, 0xcc, 0x18, 0x04, 0x22, 0xa8, 0xe8, 0xe6, 0x80, - 0xc9, 0x98, 0x3a, 0xc2, 0x41, 0xe7, 0x4f, 0xb2, 0xa9, 0xa6, 0xb4, 0x98, 0xda, 0xd1, 0xff, 0xce, 0x1e, 0xfc, 0xf0, - 0x68, 0x7a, 0xfe, 0xeb, 0xdb, 0xfc, 0x1a, 0x34, 0x41, 0x0f, 0xe1, 0x7e, 0x77, 0x35, 0x53, 0xa1, 0xa0, 0xb0, 0xb2, - 0x7d, 0x69, 0x01, 0x50, 0x63, 0x2a, 0x4a, 0x57, 0xd7, 0xd6, 0xfd, 0x49, 0xc6, 0xa7, 0x35, 0x6d, 0x7c, 0x1d, 0xa8, - 0xf2, 0xb5, 0xa1, 0x6c, 0xdd, 0xe1, 0xf3, 0x50, 0xcd, 0x78, 0x0a, 0xda, 0x5c, 0x18, 0xfc, 0x0a, 0x39, 0x6e, 0x42, - 0x27, 0x43, 0x95, 0x4d, 0xb3, 0x13, 0x6f, 0x59, 0x25, 0x6b, 0x29, 0x39, 0x91, 0x12, 0xf6, 0xaa, 0xf2, 0x47, 0xdd, - 0x93, 0xd4, 0x96, 0x6a, 0x70, 0x83, 0x13, 0x94, 0x36, 0x3a, 0xfa, 0x2a, 0x6e, 0xe4, 0xa7, 0x1b, 0x40, 0x44, 0x4d, - 0x4f, 0x87, 0xf6, 0x76, 0xfe, 0x79, 0x6f, 0x8c, 0xb0, 0x49, 0x05, 0x3f, 0xca, 0xa8, 0xa9, 0x1a, 0x9e, 0xfb, 0x53, - 0xaf, 0x6c, 0x87, 0x67, 0xba, 0x9b, 0x2c, 0x6a, 0x8b, 0x3e, 0xe3, 0x02, 0xac, 0x9a, 0x68, 0xaa, 0x71, 0xa1, 0x08, - 0x63, 0x1a, 0x6f, 0xce, 0xda, 0x89, 0xb5, 0xea, 0xd5, 0xed, 0xac, 0x97, 0x1e, 0x6d, 0xb3, 0x05, 0x6a, 0xbc, 0x68, - 0xda, 0xf2, 0x2a, 0x7c, 0xb7, 0xa4, 0x9b, 0x95, 0x23, 0xc8, 0xdc, 0x04, 0xe8, 0x67, 0x3f, 0x64, 0x26, 0x7a, 0x07, - 0xe1, 0x4e, 0x2a, 0xd9, 0x93, 0x8a, 0xcd, 0x78, 0xbe, 0xbd, 0x72, 0x7e, 0x5a, 0x92, 0x6d, 0xc4, 0xda, 0x8d, 0x2a, - 0xe3, 0xb1, 0x35, 0xc8, 0xdb, 0x35, 0xd3, 0x59, 0xa2, 0xbf, 0x86, 0x7b, 0xfd, 0xf9, 0x76, 0x0d, 0x4d, 0xb7, 0xd5, - 0xdc, 0x79, 0xe9, 0xe4, 0x28, 0x29, 0x79, 0xf1, 0x03, 0x7b, 0x6c, 0xe1, 0x7c, 0xb0, 0xf1, 0x90, 0x60, 0x99, 0x8a, - 0x55, 0x9f, 0x55, 0xac, 0xf2, 0x2c, 0x11, 0x85, 0xd9, 0xd3, 0x2e, 0x80, 0xe3, 0x0f, 0x2b, 0xb9, 0x8a, 0x1f, 0x66, - 0x5a, 0xb5, 0x7c, 0x58, 0x08, 0xd5, 0xf4, 0x24, 0x10, 0x19, 0x98, 0x35, 0xf2, 0x70, 0x61, 0xea, 0x98, 0x4c, 0x4a, - 0x49, 0x20, 0x57, 0x58, 0x4d, 0xab, 0x6a, 0xd5, 0x87, 0x1f, 0x6e, 0xb8, 0x41, 0x5d, 0xf9, 0x67, 0x33, 0xae, 0xff, - 0xaf, 0x99, 0x89, 0xe5, 0xa0, 0xb1, 0xd0, 0xfa, 0x27, 0x2d, 0xcc, 0xfd, 0x08, 0xdd, 0x35, 0xc3, 0x95, 0xe9, 0x4b, - 0x14, 0xf3, 0x2b, 0x46, 0x66, 0x5b, 0xe4, 0xb5, 0x32, 0xd8, 0xc5, 0xde, 0x98, 0x49, 0x5b, 0x27, 0xc6, 0x34, 0x36, - 0x24, 0x56, 0x67, 0x51, 0x23, 0x43, 0x6a, 0x6b, 0xf3, 0x9d, 0xbe, 0xa2, 0xf9, 0x25, 0x2f, 0xf1, 0x97, 0x69, 0xc8, - 0xc2, 0x7c, 0xab, 0xe8, 0x8d, 0xf4, 0x1a, 0x7a, 0x09, 0xfe, 0x62, 0xe1, 0xde, 0x04, 0x78, 0x8e, 0x22, 0x2a, 0x46, - 0x63, 0xf0, 0x4d, 0x16, 0x07, 0x7a, 0xbf, 0xc2, 0xad, 0x20, 0xc3, 0x94, 0x15, 0xff, 0x5f, 0x03, 0xad, 0xf4, 0x2f, - 0x20, 0xf6, 0x0d, 0xbe, 0xc0, 0xca, 0x5b, 0x41, 0xb9, 0x87, 0xe9, 0xfe, 0x02, 0xdf, 0x0a, 0x71, 0x30, 0x68, 0x44, - 0x4d, 0x58, 0xeb, 0x3d, 0xc5, 0x38, 0x31, 0x3d, 0xf8, 0x67, 0x7a, 0x68, 0x25, 0x08, 0x87, 0x31, 0x86, 0x0e, 0x52, - 0x80, 0x60, 0xa5, 0x7c, 0xf5, 0xf5, 0xa1, 0x55, 0xa1, 0x64, 0xe4, 0xab, 0x66, 0x83, 0x4f, 0xb1, 0x9d, 0xa7, 0xd8, - 0x3e, 0x2b, 0x5f, 0x5a, 0x6b, 0x4d, 0x67, 0xab, 0x46, 0x7a, 0xbe, 0x0a, 0x2e, 0x30, 0xec, 0x60, 0xe0, 0xbc, 0x0a, - 0xbe, 0x22, 0x41, 0x93, 0x40, 0x58, 0x40, 0x83, 0xe7, 0x36, 0x94, 0x93, 0x0f, 0x09, 0x94, 0xba, 0x04, 0xbb, 0xcd, - 0x8f, 0x48, 0x6e, 0x03, 0x21, 0xb5, 0x18, 0x67, 0x0b, 0x7b, 0x70, 0x26, 0xcd, 0xfa, 0xb9, 0xb1, 0x1e, 0x5a, 0x89, - 0x92, 0x36, 0x5d, 0x9e, 0x0d, 0xae, 0x19, 0x29, 0xac, 0x93, 0xff, 0x2f, 0x39, 0x6e, 0xf3, 0xbd, 0x2a, 0x08, 0xae, - 0xc4, 0x49, 0x5f, 0xeb, 0x29, 0x28, 0x77, 0x3a, 0x74, 0xe0, 0x8e, 0x20, 0x4b, 0xd9, 0xf3, 0xcc, 0xd3, 0xe7, 0x76, - 0x81, 0x9d, 0x98, 0xed, 0x9e, 0x95, 0xce, 0x70, 0xc2, 0xf1, 0xfb, 0x05, 0xef, 0x2e, 0xfd, 0x39, 0xa4, 0xdc, 0xdf, - 0x57, 0x96, 0x8c, 0x77, 0xc7, 0xdd, 0xb3, 0x3f, 0xc7, 0x1b, 0xb7, 0xfc, 0x29, 0xf7, 0xc3, 0x6d, 0x24, 0xdd, 0xf0, - 0xaa, 0xf9, 0x17, 0x8f, 0x6c, 0xe5, 0x56, 0x42, 0xd0, 0x3a, 0x9d, 0xe3, 0x9b, 0xaf, 0x41, 0xa7, 0x12, 0xb6, 0xf8, - 0xf1, 0x5d, 0xf2, 0x5b, 0x63, 0x2f, 0x46, 0x61, 0x70, 0x68, 0xa6, 0x76, 0x95, 0x9c, 0xb7, 0xe0, 0xb6, 0x4c, 0xed, - 0x56, 0x81, 0x34, 0x7a, 0xe7, 0xb9, 0xfa, 0x3a, 0xfd, 0x54, 0x35, 0xff, 0x31, 0x73, 0x13, 0xf6, 0xb1, 0x42, 0x91, - 0xf8, 0x67, 0x6a, 0x74, 0x83, 0x91, 0xca, 0x03, 0x75, 0x81, 0x55, 0x4b, 0x82, 0xca, 0xdb, 0x11, 0x3f, 0xe5, 0xd7, - 0xdc, 0xe5, 0x48, 0x6c, 0x84, 0xfd, 0x69, 0x6c, 0x3e, 0x53, 0x9f, 0xed, 0x6c, 0x99, 0x65, 0xb7, 0x8f, 0x99, 0x87, - 0x47, 0xf9, 0x5b, 0xaa, 0x5b, 0xe4, 0x7b, 0xb3, 0x2c, 0x2f, 0xca, 0xec, 0xd4, 0x3e, 0x87, 0x4f, 0xb4, 0xd1, 0x24, - 0x7f, 0xe3, 0x71, 0x2f, 0xb1, 0x21, 0xd9, 0x34, 0x2f, 0x33, 0x87, 0xd8, 0xc3, 0xf3, 0x1b, 0x3f, 0x65, 0x53, 0x99, - 0x28, 0x38, 0x6d, 0x87, 0xa6, 0x03, 0xf7, 0x0d, 0xd4, 0x41, 0x15, 0x20, 0xa7, 0x2b, 0xb0, 0xd1, 0x80, 0x59, 0x6d, - 0xd4, 0x97, 0xa5, 0xb2, 0x8a, 0xb3, 0xae, 0xdb, 0x75, 0xfa, 0xc5, 0x46, 0x12, 0xb8, 0xb1, 0x47, 0x91, 0x3c, 0x9d, - 0x95, 0xae, 0xf1, 0x57, 0x79, 0xc9, 0x1c, 0xa5, 0x0f, 0xf8, 0xfc, 0x5b, 0xf0, 0x48, 0xfe, 0x52, 0x74, 0x8d, 0xab, - 0xdc, 0x66, 0x34, 0x6a, 0xcc, 0xc9, 0x78, 0x43, 0xd8, 0x58, 0x14, 0x55, 0x31, 0x35, 0xa7, 0xfc, 0x9c, 0x19, 0x8d, - 0xe4, 0x05, 0x09, 0xf2, 0xb9, 0xb7, 0xfb, 0x99, 0x5c, 0xf0, 0x2b, 0xd7, 0xa1, 0x57, 0x56, 0xa3, 0xe2, 0x4b, 0xe7, - 0xb8, 0xb7, 0xee, 0x50, 0x80, 0x0c, 0xd8, 0x43, 0x86, 0x9c, 0xf2, 0x56, 0xd3, 0xbe, 0x5e, 0x7e, 0xff, 0x82, 0x91, - 0x14, 0xab, 0xb2, 0xef, 0xc6, 0x26, 0x4c, 0x22, 0x3b, 0xc2, 0x5e, 0x35, 0xb2, 0x9b, 0x63, 0x11, 0x21, 0x21, 0x19, - 0xf7, 0x4c, 0xc8, 0xe8, 0xad, 0x5e, 0x26, 0x80, 0x73, 0xe2, 0x49, 0xe1, 0x4c, 0x4e, 0x9c, 0xc8, 0xb2, 0xb4, 0x15, - 0x5b, 0x62, 0xe1, 0x08, 0x5f, 0x6b, 0xca, 0x6c, 0xdb, 0x18, 0x2b, 0x68, 0x70, 0xcc, 0x01, 0xa2, 0x33, 0x9f, 0xf1, - 0x86, 0x09, 0xe7, 0xfc, 0xa9, 0xf2, 0xbe, 0x59, 0xa6, 0x41, 0x5f, 0x15, 0x2c, 0x6c, 0xb7, 0x9e, 0x20, 0xca, 0x34, - 0x03, 0x03, 0xf2, 0x6d, 0x85, 0x6a, 0xfe, 0x95, 0x97, 0x28, 0xf8, 0xee, 0x32, 0xf2, 0xf3, 0xe7, 0x70, 0x1b, 0x21, - 0x1a, 0x04, 0x8d, 0xed, 0x85, 0x51, 0xb2, 0xb3, 0xac, 0x86, 0x5c, 0x84, 0x93, 0xe0, 0x83, 0xdd, 0x53, 0xb8, 0x3e, - 0x87, 0xa4, 0x97, 0xe0, 0x29, 0x30, 0x4f, 0x68, 0xa4, 0xb4, 0xdf, 0xf7, 0x2e, 0x08, 0x84, 0xe6, 0x2d, 0x8f, 0x70, - 0x40, 0x32, 0x62, 0x36, 0x16, 0x1e, 0x37, 0x0b, 0x97, 0x56, 0xc1, 0xa9, 0xcb, 0x6a, 0xd4, 0x15, 0xc9, 0x25, 0x85, - 0x34, 0x63, 0xf0, 0x60, 0x74, 0x24, 0x24, 0x96, 0x85, 0x60, 0xcc, 0x86, 0xbf, 0xf5, 0x02, 0xeb, 0xa7, 0x39, 0x00, - 0xf6, 0x04, 0xf1, 0xd8, 0x84, 0xe9, 0x0d, 0x44, 0x78, 0xb6, 0x2d, 0x0f, 0x98, 0xf7, 0x05, 0x1a, 0xcf, 0xf9, 0x98, - 0x52, 0xe6, 0x7c, 0x01, 0x2e, 0x33, 0xf1, 0x62, 0x20, 0x87, 0x80, 0x7b, 0x88, 0x87, 0xfc, 0xe0, 0xb1, 0x97, 0x60, - 0x67, 0x64, 0xa5, 0xbb, 0x5d, 0xbb, 0x71, 0xcc, 0x2d, 0x45, 0x0e, 0xbc, 0xd8, 0x3f, 0x38, 0xac, 0x6b, 0xb1, 0x4a, - 0xbf, 0x69, 0xa0, 0xd2, 0xbf, 0xfa, 0xf7, 0xcd, 0xe7, 0xc8, 0xb7, 0xcf, 0xb5, 0xd4, 0xa2, 0xf8, 0x81, 0x77, 0x56, - 0x93, 0x82, 0x76, 0xff, 0xab, 0x26, 0x23, 0x5f, 0x52, 0x5a, 0x2d, 0x8b, 0x4f, 0xb5, 0x8b, 0x9e, 0xa2, 0x5a, 0x36, - 0x79, 0x6e, 0x0f, 0x49, 0x8e, 0x5e, 0x6b, 0x19, 0x56, 0xb5, 0x3d, 0xea, 0x83, 0xbd, 0xd7, 0x7b, 0x41, 0x1e, 0x52, - 0x0f, 0x89, 0xaa, 0x37, 0x5e, 0x40, 0xc5, 0x7f, 0xf5, 0x95, 0xea, 0x99, 0x5f, 0xd0, 0x90, 0x37, 0xca, 0x31, 0xbe, - 0x6c, 0x9c, 0xb6, 0xae, 0x1e, 0xc5, 0x14, 0xac, 0x14, 0x65, 0x65, 0x88, 0x1b, 0x59, 0xa1, 0x6b, 0x44, 0x5a, 0x03, - 0x7f, 0x63, 0xa3, 0x14, 0x5b, 0x4d, 0xb5, 0x91, 0xf4, 0xdf, 0x99, 0xfe, 0x0f, 0x89, 0xec, 0xff, 0x14, 0xc7, 0xfe, - 0x8f, 0x73, 0x84, 0x16, 0xf6, 0x8d, 0x65, 0x44, 0xc0, 0x15, 0x4d, 0x8a, 0xe3, 0x2b, 0x83, 0xb3, 0x44, 0x50, 0xa3, - 0x89, 0xc8, 0x6e, 0x3c, 0x51, 0x99, 0x11, 0x79, 0x6e, 0x15, 0x3c, 0x4b, 0x7b, 0x74, 0x4f, 0xee, 0xef, 0x9c, 0x40, - 0x94, 0x63, 0x52, 0x6d, 0x6f, 0xc6, 0x9c, 0xc3, 0x10, 0x17, 0x93, 0x8b, 0x0a, 0x60, 0x04, 0x37, 0x84, 0x6c, 0x2c, - 0x81, 0x8e, 0x92, 0xec, 0x47, 0x23, 0xe6, 0x00, 0x34, 0xc0, 0x7e, 0xd9, 0x20, 0xb0, 0x1c, 0xcc, 0x30, 0x43, 0x30, - 0x3a, 0xaf, 0x0e, 0xf0, 0x39, 0x66, 0x7b, 0xc7, 0x26, 0xf8, 0xb0, 0x32, 0x07, 0x3b, 0xd0, 0x20, 0x1c, 0x30, 0x8f, - 0x66, 0x79, 0x26, 0x28, 0x9a, 0xe0, 0x23, 0xea, 0x2c, 0xfb, 0x5c, 0xfc, 0x92, 0xa5, 0xad, 0xd7, 0x25, 0x87, 0x2e, - 0x16, 0x9f, 0x66, 0xd6, 0x50, 0xfa, 0x13, 0xf8, 0x5f, 0x83, 0x3b, 0xb0, 0xc7, 0x1d, 0x24, 0xc2, 0x56, 0x14, 0x4e, - 0xa5, 0xea, 0x9f, 0x5d, 0x06, 0x84, 0x92, 0x9e, 0x66, 0x48, 0xb9, 0x40, 0xeb, 0x1a, 0xe2, 0x1a, 0x6c, 0x0c, 0x86, - 0xed, 0x8c, 0x67, 0x9a, 0xdb, 0xd6, 0x33, 0xe3, 0xa4, 0x5e, 0xa3, 0x4d, 0x46, 0x87, 0x64, 0x5e, 0x44, 0x99, 0xbb, - 0xc8, 0x2f, 0x47, 0x4a, 0xad, 0xb9, 0x51, 0xac, 0x31, 0xe5, 0xa5, 0xd3, 0xec, 0xc3, 0x39, 0x82, 0xd3, 0x45, 0x50, - 0xf5, 0xc3, 0x1e, 0x9c, 0xb5, 0x31, 0xf8, 0x91, 0x62, 0x51, 0x20, 0xbd, 0x5d, 0x11, 0x52, 0xf3, 0x93, 0x1d, 0xab, - 0x59, 0x4c, 0x4b, 0x6f, 0x17, 0x1e, 0xce, 0xb7, 0xc5, 0x14, 0x64, 0x1c, 0x08, 0xf9, 0x23, 0x18, 0xd8, 0x14, 0x77, - 0x66, 0xa2, 0x2d, 0x82, 0xe0, 0x04, 0xa1, 0x8c, 0x48, 0x87, 0x6f, 0x45, 0x29, 0x2a, 0x02, 0x91, 0xfb, 0xd1, 0x7b, - 0x4c, 0xcb, 0x6a, 0x28, 0xad, 0x81, 0x3d, 0x2a, 0x2a, 0x06, 0xca, 0xeb, 0x4c, 0x68, 0x38, 0x45, 0x8b, 0x90, 0xa9, - 0x78, 0xb3, 0x00, 0x2b, 0x37, 0xf8, 0xa4, 0xc5, 0x4a, 0xcf, 0x58, 0xf6, 0x07, 0xf9, 0x4a, 0x59, 0xd1, 0x30, 0x81, - 0xee, 0x31, 0x55, 0xdb, 0x7f, 0xe3, 0xa2, 0x8d, 0xb9, 0x01, 0x7e, 0x06, 0x8c, 0xe2, 0x7a, 0x85, 0x09, 0xab, 0x15, - 0xdc, 0x01, 0x2c, 0xbd, 0x71, 0x6f, 0xd5, 0x4c, 0xc9, 0xa7, 0x5c, 0x49, 0x29, 0x13, 0xac, 0x77, 0x2a, 0x4b, 0x70, - 0xf2, 0x21, 0x04, 0x43, 0x7c, 0xf7, 0x65, 0xe6, 0xd7, 0x6b, 0x9e, 0xda, 0x84, 0x27, 0xd1, 0x9e, 0xf6, 0xd1, 0x37, - 0x6e, 0xc3, 0xab, 0x16, 0x43, 0x5c, 0x9d, 0x65, 0xe7, 0xe4, 0x4b, 0x64, 0x4d, 0xe5, 0x80, 0x1f, 0xb1, 0x1a, 0xea, - 0x12, 0xf8, 0x8c, 0x98, 0x37, 0x0c, 0xe6, 0x6f, 0x74, 0x8a, 0xc5, 0xbc, 0xa9, 0x22, 0xc5, 0xe1, 0xfe, 0x08, 0x8b, - 0x8c, 0x4b, 0x94, 0x23, 0x7d, 0xc8, 0xbf, 0x83, 0xc1, 0xa8, 0xd2, 0xcd, 0x8a, 0xfa, 0x9a, 0xf1, 0xbc, 0xed, 0x63, - 0x6b, 0x12, 0xb6, 0x00, 0x6b, 0x91, 0xf1, 0x04, 0xe8, 0xbe, 0x56, 0x6f, 0x0b, 0xd9, 0x9a, 0x68, 0x89, 0x86, 0xa6, - 0x50, 0xd4, 0x0d, 0x04, 0x13, 0x93, 0xd2, 0xee, 0xc0, 0x48, 0x82, 0xf1, 0xac, 0xa9, 0xfc, 0x82, 0xfc, 0xb8, 0x5e, - 0xc4, 0xd6, 0x98, 0x0b, 0x1d, 0x33, 0xa2, 0x49, 0x4d, 0x9a, 0x09, 0x88, 0x05, 0xf0, 0x72, 0x16, 0x3d, 0xac, 0xf3, - 0x84, 0x53, 0x73, 0xef, 0xd4, 0x11, 0x33, 0x30, 0x40, 0xa7, 0x10, 0xa9, 0x14, 0x3f, 0xbc, 0x0f, 0x52, 0x0a, 0x04, - 0xa0, 0xec, 0x98, 0x0d, 0x2d, 0x29, 0xa8, 0x4f, 0x6b, 0x97, 0x99, 0x5a, 0xdb, 0x1a, 0xfe, 0x94, 0xd9, 0xac, 0x45, - 0x55, 0xcc, 0x1f, 0x2e, 0xf3, 0x8b, 0x98, 0x71, 0xd1, 0xf0, 0x09, 0x43, 0xd5, 0x61, 0x05, 0x7a, 0x8f, 0x9b, 0xbc, - 0x5e, 0x99, 0xf0, 0x7e, 0x5e, 0xef, 0x9b, 0xfb, 0x22, 0x16, 0x5e, 0x14, 0x38, 0xf7, 0xa5, 0x82, 0x97, 0x86, 0x13, - 0xb8, 0xc4, 0x43, 0x99, 0xf9, 0x54, 0xb6, 0x95, 0x99, 0xa2, 0x04, 0x25, 0xb5, 0x88, 0x5c, 0x92, 0x0b, 0x82, 0x94, - 0x8a, 0x97, 0x81, 0x50, 0xdb, 0xb7, 0x0b, 0x90, 0xbd, 0xaf, 0x2d, 0xe3, 0xb5, 0x64, 0xe7, 0x22, 0x94, 0xcd, 0x66, - 0x5c, 0xdb, 0xcb, 0x69, 0xb7, 0xbf, 0x95, 0x41, 0x35, 0x00, 0x25, 0xb3, 0xe1, 0x32, 0xe2, 0x9b, 0x9b, 0x1d, 0x0a, - 0x08, 0xed, 0xfc, 0x6d, 0x57, 0xca, 0x2c, 0xcc, 0x41, 0xd7, 0xec, 0xa8, 0xf8, 0x17, 0xe5, 0xdd, 0x59, 0xed, 0x66, - 0x7c, 0xc9, 0x3b, 0x18, 0xdf, 0x14, 0xca, 0x01, 0x2e, 0x79, 0x21, 0xeb, 0x07, 0x6e, 0x94, 0xc8, 0x12, 0xc2, 0x32, - 0xbb, 0xa8, 0x15, 0x95, 0xc9, 0x98, 0x36, 0xbd, 0xad, 0xc8, 0x66, 0x23, 0x63, 0x5d, 0x96, 0x68, 0x79, 0x6e, 0xc5, - 0xc9, 0xab, 0x87, 0x3f, 0x52, 0xe5, 0xf4, 0x7d, 0x89, 0xfa, 0xd4, 0x07, 0x77, 0x6f, 0x2b, 0xb3, 0x84, 0x4f, 0x4d, - 0x13, 0x45, 0x70, 0x07, 0xcc, 0x55, 0xb6, 0x22, 0x6a, 0x61, 0x48, 0xfd, 0x17, 0x5e, 0xfa, 0xc6, 0x13, 0xaa, 0xb6, - 0x73, 0xd5, 0xab, 0x39, 0x24, 0x66, 0x8c, 0xe7, 0x68, 0xf5, 0x01, 0xb2, 0x81, 0xae, 0xcd, 0xbe, 0x02, 0x02, 0xaf, - 0x99, 0xfc, 0xf2, 0xdb, 0x61, 0xcc, 0xd9, 0x6d, 0x5e, 0x68, 0x64, 0x2b, 0x67, 0xd9, 0xc1, 0x8d, 0xb4, 0x55, 0x7b, - 0x3c, 0xd3, 0x4d, 0x5c, 0x69, 0x99, 0x8c, 0x31, 0xbf, 0xad, 0xea, 0xab, 0x05, 0xdf, 0x99, 0xdb, 0xce, 0x4a, 0xa6, - 0x91, 0xab, 0xd5, 0x57, 0x78, 0x5e, 0x34, 0x41, 0x27, 0x2e, 0x31, 0x53, 0xab, 0xea, 0xb7, 0xaa, 0x1c, 0x15, 0x15, - 0xcb, 0xb9, 0xd5, 0xa6, 0xca, 0x6b, 0xb7, 0xa8, 0xdf, 0x9f, 0x8d, 0x09, 0x41, 0x65, 0x8a, 0xcc, 0x58, 0xa2, 0x8b, - 0x78, 0xa1, 0x9f, 0xd3, 0xba, 0xa8, 0xe8, 0xf1, 0xca, 0xaa, 0x86, 0x18, 0x02, 0xb7, 0xda, 0xc9, 0x20, 0x65, 0x16, - 0x89, 0x79, 0x16, 0x31, 0xdb, 0xeb, 0xd1, 0xb6, 0x39, 0x1b, 0x06, 0x6e, 0xd2, 0x39, 0x81, 0x73, 0x12, 0xfe, 0xa6, - 0x32, 0xdb, 0xb0, 0xa2, 0x6e, 0x79, 0x8d, 0xae, 0xa2, 0x6e, 0xcd, 0x79, 0x3d, 0x55, 0xc5, 0x0f, 0xd4, 0x71, 0xb5, - 0x5e, 0xdd, 0x88, 0x0e, 0x41, 0x81, 0x0b, 0xeb, 0xe7, 0x00, 0xfe, 0x6f, 0xab, 0x18, 0x1e, 0xec, 0xaa, 0x0f, 0xd5, - 0xaa, 0x69, 0x63, 0xdf, 0x38, 0x20, 0xb0, 0x58, 0x15, 0x5c, 0xa0, 0x33, 0xac, 0x50, 0x2f, 0x33, 0x6d, 0x30, 0x4c, - 0x8c, 0x53, 0x4b, 0xcf, 0xa5, 0x13, 0x11, 0x7d, 0x1a, 0x5e, 0xcc, 0x34, 0x77, 0x68, 0xb3, 0x55, 0x6e, 0x11, 0x6a, - 0x47, 0xb0, 0x08, 0x26, 0xd3, 0x65, 0x1c, 0x8c, 0xfc, 0x36, 0xb4, 0xd1, 0x00, 0x13, 0x97, 0x36, 0x65, 0x21, 0xc4, - 0xca, 0x02, 0xaa, 0xf7, 0x5d, 0xb0, 0x40, 0x30, 0xb3, 0x27, 0xa4, 0x5f, 0x41, 0x54, 0xf9, 0x69, 0x7c, 0x3e, 0xa9, - 0xa4, 0xb6, 0x9d, 0xaf, 0x73, 0x0d, 0x1c, 0xaa, 0xa6, 0xa2, 0x2a, 0x57, 0xb6, 0x21, 0x72, 0x18, 0xe4, 0x3a, 0x52, - 0x42, 0x2d, 0x91, 0x2f, 0x33, 0x4a, 0x27, 0x13, 0x46, 0xcb, 0xb5, 0x29, 0x56, 0x81, 0xb4, 0xd6, 0x85, 0x77, 0xf9, - 0x1f, 0x86, 0xb2, 0x0d, 0x7a, 0x21, 0x4a, 0xe4, 0xa2, 0x67, 0xf1, 0xe7, 0x6b, 0x9d, 0x03, 0xa4, 0xfa, 0x5f, 0xad, - 0x5c, 0x2a, 0x63, 0x03, 0xa7, 0xd8, 0x95, 0x91, 0xf8, 0x20, 0xad, 0x25, 0xfa, 0x3e, 0xd7, 0x31, 0xea, 0xba, 0x07, - 0x8d, 0xeb, 0x55, 0xb1, 0x18, 0xfa, 0xc5, 0x7b, 0x12, 0xdd, 0xc2, 0x97, 0x05, 0x25, 0x1d, 0xf4, 0xd3, 0x5d, 0xdd, - 0x5f, 0xa7, 0x84, 0x44, 0x65, 0x31, 0x31, 0x84, 0x55, 0x7e, 0x66, 0x38, 0x6c, 0x15, 0xd1, 0xac, 0xb6, 0xeb, 0xec, - 0xfb, 0x03, 0x05, 0x13, 0x88, 0xa0, 0xb0, 0xf8, 0xff, 0x73, 0x1f, 0x14, 0x68, 0xe0, 0xa4, 0xce, 0x8d, 0x4a, 0x4e, - 0xfb, 0xb9, 0x76, 0x7d, 0xa8, 0xbc, 0x04, 0x40, 0x56, 0x8f, 0x37, 0xb2, 0xbe, 0xe5, 0x77, 0x2b, 0x8b, 0x17, 0x30, - 0x96, 0x3f, 0x85, 0x4d, 0x56, 0x23, 0xda, 0x8c, 0xae, 0xd5, 0x6c, 0x94, 0x4f, 0x99, 0x18, 0x8e, 0x36, 0xd0, 0xf5, - 0xbb, 0x6d, 0x6f, 0x50, 0x5d, 0x58, 0x4b, 0xec, 0x3e, 0x60, 0x5d, 0xa9, 0x80, 0xfd, 0xac, 0xa9, 0xa5, 0x3b, 0x15, - 0xfc, 0xfc, 0x56, 0x4f, 0xa5, 0x59, 0xd8, 0x3a, 0x02, 0x7a, 0xf6, 0xd5, 0x15, 0x07, 0xc0, 0x07, 0x74, 0x0d, 0x0b, - 0x3d, 0x76, 0x2c, 0xf5, 0x99, 0x45, 0x94, 0x7d, 0xe6, 0x1e, 0x5f, 0xdf, 0x0c, 0x85, 0x87, 0x9d, 0xdb, 0x6f, 0xbd, - 0x2a, 0xc6, 0xf1, 0xc2, 0xba, 0xba, 0xc8, 0x05, 0xc5, 0x13, 0x92, 0x9c, 0x5f, 0xce, 0x61, 0xa8, 0x5a, 0x49, 0xc4, - 0x5c, 0x85, 0x04, 0x41, 0xed, 0xbb, 0x22, 0xe0, 0x31, 0x39, 0x5e, 0x81, 0xbb, 0x07, 0xa6, 0xa8, 0x9b, 0x1d, 0x42, - 0xe8, 0x96, 0xb4, 0xba, 0x5b, 0x91, 0x00, 0xd0, 0x2e, 0xd8, 0xfe, 0xc6, 0x79, 0x89, 0x2d, 0x68, 0x19, 0xad, 0x17, - 0x21, 0x0c, 0x44, 0x22, 0x85, 0x31, 0x72, 0x7a, 0x38, 0x5b, 0xd7, 0xa0, 0x18, 0xfa, 0x53, 0x17, 0x38, 0xf5, 0xe3, - 0xd9, 0xb6, 0x4b, 0x85, 0x64, 0x22, 0xe8, 0xd0, 0x14, 0x58, 0x96, 0x9d, 0x38, 0xed, 0x91, 0xe4, 0xfd, 0x7d, 0x9e, - 0x92, 0x15, 0x15, 0x3f, 0x94, 0xc3, 0xcf, 0x27, 0x24, 0x38, 0xd4, 0x13, 0x30, 0x83, 0x0e, 0x78, 0xa6, 0xf7, 0xa9, - 0x13, 0x23, 0x95, 0xf5, 0x0e, 0x38, 0x8a, 0x88, 0x32, 0xd3, 0x25, 0xbb, 0xc7, 0xed, 0xf1, 0x14, 0x70, 0x23, 0x63, - 0xda, 0x65, 0x8e, 0x61, 0x26, 0x30, 0x8e, 0xf9, 0x6a, 0x7c, 0x3e, 0xa2, 0x1f, 0xc7, 0x7d, 0x44, 0xc9, 0x45, 0xa5, - 0x86, 0xc2, 0x36, 0x66, 0x8b, 0x5a, 0xf4, 0xd4, 0xbe, 0x91, 0x48, 0x47, 0xaf, 0x60, 0x2c, 0x17, 0x98, 0x06, 0x2b, - 0x9d, 0xf3, 0x8a, 0x82, 0x15, 0x4d, 0x80, 0xb8, 0x0a, 0xe3, 0x94, 0x51, 0x6b, 0xcc, 0x52, 0x18, 0x5c, 0x51, 0x93, - 0x11, 0xc1, 0xaf, 0x26, 0xf4, 0xec, 0x63, 0x31, 0xdc, 0x93, 0x97, 0xc3, 0xa1, 0x1e, 0xad, 0xa7, 0x75, 0xf7, 0x06, - 0x16, 0x63, 0xc1, 0xb2, 0xc0, 0x9c, 0x9d, 0x0e, 0x3a, 0xaf, 0xd8, 0xd6, 0xd4, 0x3a, 0x5d, 0xad, 0x1f, 0x5a, 0xd9, - 0x28, 0x96, 0xd3, 0x24, 0x92, 0x38, 0x6f, 0xa6, 0x51, 0x8c, 0x3f, 0x34, 0x5c, 0xea, 0x86, 0xfa, 0xc4, 0x6f, 0xcd, - 0x5f, 0x4b, 0xa5, 0xbf, 0xce, 0x3f, 0x8a, 0x85, 0x1d, 0x9b, 0xd8, 0x6f, 0xb4, 0x60, 0x65, 0xd1, 0xd8, 0x40, 0xa8, - 0xea, 0x0b, 0x9e, 0x25, 0x2b, 0x95, 0x27, 0xdf, 0x8d, 0xd8, 0xd2, 0x02, 0x3f, 0x8f, 0xf2, 0x6a, 0xea, 0xcd, 0x88, - 0x41, 0xb5, 0x7c, 0x8a, 0x6a, 0x77, 0x72, 0x20, 0x5c, 0x26, 0x37, 0x56, 0x95, 0x07, 0x88, 0x9e, 0x5f, 0x96, 0x1e, - 0x09, 0x73, 0xa9, 0x98, 0x92, 0x06, 0xcf, 0x89, 0xa0, 0xb7, 0x30, 0x85, 0x18, 0x1e, 0x49, 0xdf, 0xa0, 0xf2, 0xee, - 0x8f, 0xeb, 0x7d, 0xaf, 0x0b, 0xdc, 0x19, 0x3b, 0xdf, 0x74, 0x2f, 0x9d, 0x19, 0x34, 0x7a, 0xfd, 0x73, 0xa8, 0x5a, - 0x84, 0xc1, 0x4e, 0xd3, 0x85, 0xa0, 0x09, 0xea, 0x5f, 0x8c, 0x06, 0xd6, 0x32, 0x5d, 0xeb, 0xed, 0x20, 0x53, 0x21, - 0x31, 0xfe, 0x5f, 0x64, 0xbc, 0x0c, 0x98, 0x9c, 0x8c, 0xe2, 0x16, 0x3c, 0x00, 0x37, 0xd3, 0x90, 0x0b, 0x94, 0xd9, - 0xc3, 0x13, 0xa8, 0xc9, 0x58, 0x84, 0x67, 0x39, 0xe6, 0x3a, 0x75, 0x60, 0x3d, 0xb2, 0x79, 0x58, 0xa3, 0x70, 0xb5, - 0x9c, 0x4d, 0x4e, 0xc9, 0x8e, 0x59, 0x5d, 0xed, 0x63, 0x77, 0xc6, 0x25, 0x9e, 0x39, 0x1f, 0xf2, 0x6d, 0xec, 0x71, - 0xc0, 0x1c, 0x87, 0x07, 0x0e, 0x33, 0xe7, 0x21, 0x82, 0x5c, 0x37, 0xc0, 0x16, 0x60, 0xb2, 0x93, 0xb5, 0x6b, 0x94, - 0xb0, 0x78, 0x73, 0x03, 0x20, 0x8f, 0x64, 0x12, 0x42, 0x2e, 0x1b, 0x7e, 0x96, 0x5a, 0xaa, 0x8f, 0x80, 0x8f, 0xd5, - 0x87, 0x9a, 0x06, 0x42, 0xdc, 0x36, 0x42, 0x1e, 0x30, 0x26, 0xae, 0xcc, 0x2f, 0xaa, 0x09, 0x2e, 0xf8, 0x2f, 0x7b, - 0x1d, 0x7f, 0xdd, 0xac, 0xfb, 0x9e, 0x21, 0x22, 0x1d, 0xac, 0x45, 0x64, 0xbd, 0x22, 0x85, 0xff, 0x86, 0xdf, 0x03, - 0x45, 0x09, 0xc5, 0x52, 0xb1, 0xfc, 0x88, 0xea, 0x1e, 0xe3, 0x1e, 0xf2, 0xde, 0x4e, 0x7e, 0x1f, 0x09, 0x83, 0x1e, - 0x50, 0x63, 0x94, 0xa4, 0x38, 0x52, 0xab, 0x9e, 0x7b, 0x14, 0x2c, 0x85, 0xa5, 0x86, 0xe7, 0x88, 0xd2, 0xd5, 0xf7, - 0x0a, 0xb5, 0xf0, 0x1f, 0x2d, 0x6d, 0x9e, 0x86, 0x7d, 0x44, 0xf2, 0x4d, 0x46, 0xd7, 0xc8, 0x42, 0x25, 0x51, 0x78, - 0x23, 0x04, 0x9e, 0x73, 0xc6, 0x53, 0x7d, 0x84, 0x98, 0x07, 0xa2, 0xc9, 0xc8, 0xf5, 0x80, 0xde, 0xd1, 0xe6, 0xe8, - 0x45, 0x72, 0x4c, 0xdf, 0xb4, 0x0f, 0x83, 0xb0, 0x1d, 0x5b, 0x5c, 0x6a, 0x4c, 0x44, 0x6b, 0x5a, 0x75, 0xd9, 0x23, - 0x52, 0xef, 0x3c, 0x15, 0xa3, 0x04, 0x25, 0x72, 0x13, 0x15, 0xdd, 0x39, 0x4e, 0xed, 0xa2, 0xe8, 0xf6, 0x19, 0x4b, - 0xb8, 0x18, 0x55, 0x7c, 0x5f, 0x06, 0x9f, 0x44, 0x52, 0x34, 0x60, 0xd8, 0x80, 0xaf, 0xf7, 0xff, 0x60, 0xe8, 0x66, - 0x20, 0x97, 0xda, 0x30, 0x65, 0xf3, 0xe9, 0x9c, 0x66, 0x5b, 0xc3, 0x7d, 0x83, 0xd6, 0x97, 0x50, 0xcf, 0xb9, 0xcf, - 0x88, 0xdf, 0x4a, 0xcd, 0x2d, 0x26, 0xab, 0x36, 0x1b, 0x59, 0x4c, 0xd6, 0x61, 0xd5, 0x3d, 0x46, 0xa6, 0x10, 0xff, - 0x42, 0x93, 0x5c, 0x10, 0x1e, 0x55, 0xc9, 0x82, 0x7f, 0xd0, 0xcc, 0x60, 0xc3, 0x79, 0x9d, 0xfe, 0x8d, 0x32, 0x80, - 0xf7, 0x3b, 0xcb, 0x5a, 0x41, 0x35, 0x25, 0xb5, 0xe3, 0xba, 0x4b, 0xc7, 0x2f, 0x5d, 0xa0, 0x07, 0x99, 0x89, 0x67, - 0x47, 0x25, 0x21, 0x66, 0x81, 0x75, 0x2f, 0x91, 0xfa, 0xe6, 0x27, 0xe9, 0x91, 0x36, 0x78, 0xce, 0x42, 0xb4, 0xa0, - 0x17, 0x15, 0xfb, 0x7b, 0xa5, 0x34, 0xbd, 0x57, 0x76, 0x06, 0xaf, 0x8d, 0x99, 0xca, 0x41, 0xb0, 0xee, 0x12, 0x7a, - 0xb9, 0x3c, 0xc9, 0x8f, 0x6d, 0xe6, 0x92, 0xe6, 0x23, 0xe9, 0xef, 0xaa, 0x14, 0xeb, 0xc7, 0x80, 0xd6, 0xbf, 0xa6, - 0xcc, 0x92, 0x14, 0x68, 0x30, 0x58, 0x8d, 0x15, 0xdb, 0x04, 0x1c, 0x52, 0x68, 0x22, 0xa2, 0x89, 0x76, 0xdc, 0xee, - 0x68, 0x7c, 0x86, 0xd4, 0x47, 0xb8, 0x40, 0x32, 0xe0, 0x91, 0x43, 0x4c, 0x56, 0xc5, 0x2e, 0xc0, 0x17, 0xb8, 0x7d, - 0x3c, 0x83, 0x7e, 0xd8, 0x6e, 0xdd, 0x20, 0xe5, 0xa6, 0x1c, 0x17, 0x01, 0x6b, 0x08, 0x80, 0xa7, 0x5c, 0x13, 0xad, - 0x06, 0x52, 0x7d, 0x69, 0x04, 0xec, 0xbb, 0x83, 0xfa, 0x38, 0x9a, 0xa6, 0x8c, 0x65, 0xd3, 0xc4, 0x4b, 0xc9, 0x23, - 0xc4, 0x88, 0x7d, 0x85, 0x53, 0x8e, 0xc0, 0xbc, 0xc3, 0xef, 0xac, 0xd7, 0x42, 0x7a, 0x9b, 0xe8, 0x73, 0x93, 0x81, - 0x87, 0xe1, 0xc7, 0xd8, 0x7e, 0xd1, 0xd3, 0xce, 0xd6, 0x9c, 0xbf, 0x0a, 0x48, 0x46, 0x47, 0xe1, 0x5f, 0x85, 0x67, - 0xa5, 0x6d, 0x12, 0x42, 0xfc, 0x03, 0xd1, 0x75, 0x86, 0x33, 0x48, 0xb0, 0x48, 0x5f, 0x2e, 0x6a, 0x17, 0x39, 0x05, - 0x95, 0xf6, 0x99, 0xd5, 0xca, 0xb2, 0x7c, 0x7b, 0xfb, 0x8f, 0x73, 0x6b, 0x53, 0x8d, 0x0b, 0x1e, 0x72, 0x8d, 0xcb, - 0x56, 0x36, 0xfc, 0xa2, 0x8d, 0xf7, 0x96, 0x6c, 0x36, 0x72, 0xd5, 0x57, 0x2e, 0xe1, 0x9f, 0xf8, 0x51, 0x46, 0xb2, - 0xf1, 0x02, 0xac, 0x45, 0x6a, 0x79, 0xe4, 0xea, 0xa9, 0xed, 0x7b, 0xbd, 0x50, 0xac, 0x0c, 0xee, 0xfc, 0xe2, 0x38, - 0x41, 0x92, 0xca, 0x43, 0xfe, 0x9c, 0xaf, 0xe3, 0x1c, 0x3b, 0xab, 0xe9, 0x68, 0x45, 0xef, 0x48, 0x5d, 0x0e, 0x16, - 0x5b, 0x8e, 0x92, 0xf3, 0xc1, 0xb9, 0x6b, 0x86, 0x1e, 0x1c, 0x45, 0x3d, 0x9f, 0x29, 0x89, 0x05, 0x5c, 0x9a, 0xbb, - 0xa7, 0x08, 0x7a, 0x84, 0x48, 0x8c, 0xd0, 0xbb, 0xa0, 0x21, 0x18, 0xb6, 0x39, 0xdf, 0x64, 0x82, 0x36, 0x6b, 0xd1, - 0x2e, 0xe2, 0x17, 0xc3, 0xa2, 0xf0, 0xda, 0xbb, 0x7a, 0xe4, 0x8a, 0xe5, 0x12, 0x1a, 0x03, 0x59, 0x83, 0x62, 0xff, - 0x9a, 0x04, 0x3f, 0xe8, 0x9f, 0xad, 0x16, 0x1a, 0x2b, 0x53, 0xe6, 0xc7, 0x8c, 0x55, 0xea, 0x9c, 0xb5, 0xf4, 0x6e, - 0x6a, 0x17, 0x94, 0x9c, 0x6c, 0xe5, 0xfc, 0x5a, 0xcc, 0xbf, 0x1f, 0xaa, 0x9f, 0x66, 0xca, 0x3b, 0x84, 0x27, 0xcc, - 0xd7, 0x89, 0xa2, 0x80, 0xac, 0x9b, 0x25, 0xce, 0x52, 0x52, 0xc7, 0x2a, 0x89, 0x12, 0x63, 0x3b, 0x87, 0x47, 0x08, - 0x42, 0xd2, 0xd9, 0xac, 0xce, 0x8c, 0xc9, 0x95, 0x14, 0x6f, 0x87, 0x72, 0x25, 0x9c, 0xc5, 0x22, 0x4d, 0x50, 0x74, - 0xf9, 0x86, 0x5c, 0x2a, 0xf4, 0x54, 0x97, 0x76, 0x74, 0xa8, 0xf4, 0x80, 0x7f, 0x74, 0x73, 0x89, 0x99, 0x54, 0xa0, - 0x11, 0x9f, 0xc7, 0x96, 0x54, 0x22, 0x59, 0x15, 0x39, 0x0c, 0xd4, 0xca, 0xe4, 0x27, 0xdb, 0xe7, 0x32, 0x5a, 0x37, - 0x21, 0x70, 0xdd, 0xe6, 0x4a, 0xe2, 0xee, 0x5f, 0x26, 0xf3, 0x74, 0x00, 0xf6, 0xcb, 0x72, 0x9d, 0x37, 0x3a, 0xe1, - 0xf2, 0xe8, 0xec, 0x53, 0x13, 0xec, 0x78, 0x07, 0x2f, 0x26, 0x12, 0x04, 0x07, 0x89, 0x44, 0xa4, 0x82, 0x33, 0x90, - 0x78, 0x02, 0x03, 0x70, 0x72, 0xfe, 0x88, 0x9f, 0x17, 0x64, 0x79, 0x01, 0x5c, 0xe1, 0xa8, 0x02, 0x44, 0x82, 0x04, - 0x8d, 0x2e, 0xbc, 0x9b, 0x63, 0xf5, 0x9a, 0x2d, 0xb7, 0xab, 0xd2, 0x79, 0x50, 0x73, 0x24, 0x85, 0x92, 0x30, 0xe2, - 0x0c, 0x8b, 0x1f, 0x6c, 0x4a, 0x94, 0xaf, 0x1a, 0x81, 0x30, 0xb2, 0x58, 0xe2, 0x85, 0x46, 0x83, 0x00, 0x8f, 0x8f, - 0x90, 0x32, 0xd9, 0x36, 0xe3, 0x98, 0x7d, 0x4d, 0x89, 0x73, 0x86, 0xcc, 0x10, 0x4a, 0x06, 0xe6, 0x68, 0x09, 0x60, - 0x9d, 0xc5, 0x18, 0x4d, 0xa5, 0x29, 0x3e, 0x3f, 0x47, 0xad, 0xd6, 0x91, 0x57, 0x36, 0x43, 0xbd, 0x0d, 0x56, 0x4c, - 0x89, 0x00, 0xc7, 0x21, 0xa4, 0x97, 0xc0, 0x82, 0x32, 0xe6, 0xb6, 0x24, 0x97, 0x22, 0x3f, 0x24, 0x6b, 0x49, 0xd3, - 0x66, 0x60, 0x70, 0xae, 0x9b, 0x7c, 0x31, 0x1f, 0x8e, 0x92, 0x69, 0x15, 0x6c, 0x1a, 0xeb, 0xfe, 0x3d, 0x6c, 0x9a, - 0x6e, 0x72, 0xe5, 0xb6, 0x0a, 0xc6, 0x6a, 0xe6, 0x78, 0xc4, 0xe6, 0x6c, 0xc0, 0xcb, 0xef, 0x41, 0x1a, 0x2e, 0x1e, - 0x42, 0x64, 0xda, 0x4f, 0xfb, 0xa7, 0xd8, 0xbb, 0xe5, 0xf2, 0x64, 0x06, 0xce, 0xda, 0xd8, 0x1d, 0xa7, 0xa8, 0xae, - 0x25, 0x51, 0x2e, 0x09, 0x9b, 0x0f, 0x80, 0xa1, 0xd6, 0xf7, 0xa2, 0xec, 0xff, 0x2e, 0xe9, 0x89, 0xa2, 0xc2, 0x73, - 0x9d, 0xd7, 0x67, 0xa9, 0x3f, 0x80, 0x76, 0x1f, 0xc7, 0xe4, 0xce, 0x38, 0xcc, 0x11, 0x50, 0x99, 0xbd, 0x5f, 0xbf, - 0xa2, 0x83, 0xb6, 0x95, 0xea, 0x4f, 0x28, 0xce, 0x1f, 0x94, 0xd1, 0x3a, 0x5b, 0xe6, 0xfc, 0x6c, 0xc1, 0x40, 0x67, - 0x92, 0x96, 0x92, 0xca, 0x27, 0xfe, 0x87, 0xea, 0xf0, 0x31, 0xb5, 0x47, 0x8c, 0x4d, 0x24, 0x09, 0x7e, 0x92, 0x27, - 0x7c, 0x4c, 0x35, 0x13, 0xb5, 0x3d, 0x43, 0x8a, 0x7a, 0x63, 0x44, 0xa6, 0x52, 0x4d, 0x96, 0x15, 0x9b, 0x58, 0xe4, - 0x04, 0xbb, 0xba, 0xb0, 0x2e, 0x7d, 0xa2, 0x3e, 0xa5, 0xa6, 0xe6, 0x20, 0x5c, 0x18, 0x48, 0x77, 0xdb, 0x55, 0x8f, - 0x16, 0x4b, 0x5a, 0x28, 0x52, 0x12, 0x39, 0x89, 0xa4, 0x69, 0x1c, 0xa9, 0x0b, 0x60, 0x9e, 0xa3, 0xec, 0x56, 0xb2, - 0x06, 0x6b, 0x6b, 0x3c, 0x51, 0x27, 0xd5, 0x91, 0x9b, 0x3a, 0xcc, 0xac, 0xa7, 0x9a, 0xf9, 0x3f, 0x3b, 0x26, 0x52, - 0xd0, 0x71, 0xe5, 0x91, 0x27, 0x94, 0xe6, 0xcd, 0x54, 0xed, 0x64, 0x48, 0x8f, 0x3c, 0xd7, 0xdb, 0xa4, 0x63, 0x9f, - 0x0b, 0x25, 0x0e, 0xdd, 0x74, 0x39, 0xd5, 0x25, 0xf0, 0xf1, 0x55, 0xfc, 0x91, 0x50, 0x2c, 0x49, 0xa4, 0x61, 0xee, - 0x6c, 0x34, 0xb6, 0x34, 0x56, 0x97, 0x8a, 0x2c, 0x0e, 0x4b, 0x43, 0xd5, 0x55, 0x94, 0xc5, 0x1b, 0x35, 0xd8, 0x44, - 0xd4, 0x45, 0x7d, 0x0d, 0x3a, 0xa5, 0xf3, 0x1c, 0x68, 0xa9, 0xc5, 0xdd, 0x53, 0x41, 0xef, 0x27, 0x5c, 0x53, 0x01, - 0x0e, 0x26, 0x1e, 0xb9, 0x78, 0xc1, 0xe9, 0x32, 0xa7, 0x2b, 0xd8, 0x5f, 0x34, 0x52, 0x8d, 0x33, 0x0b, 0x55, 0x39, - 0x33, 0xaa, 0xda, 0x99, 0x75, 0xd3, 0x0d, 0x86, 0x39, 0x57, 0xbb, 0x1c, 0x59, 0x94, 0x25, 0x59, 0x1c, 0x57, 0xa6, - 0x89, 0xe7, 0xf6, 0xca, 0x65, 0xa4, 0xf3, 0x4a, 0xb6, 0x98, 0x8c, 0x89, 0x4b, 0x3d, 0x32, 0x3d, 0x31, 0xb2, 0x6c, - 0xa0, 0xb6, 0x4b, 0xaf, 0xa9, 0x3a, 0xf6, 0x8b, 0x3b, 0x16, 0x85, 0x97, 0xb9, 0xc6, 0xf4, 0x38, 0x09, 0x19, 0xf2, - 0xa5, 0x35, 0x50, 0x62, 0x1b, 0xfc, 0x58, 0x8e, 0xf6, 0xd3, 0x69, 0x09, 0xac, 0x49, 0x44, 0xa0, 0xea, 0xb5, 0x81, - 0xfc, 0xb8, 0x4d, 0x0f, 0xe9, 0xcb, 0x16, 0x2e, 0xca, 0x1f, 0xca, 0x61, 0x73, 0xe0, 0x10, 0x66, 0x02, 0xa3, 0x60, - 0xa1, 0xbc, 0x92, 0xc0, 0x26, 0xf0, 0x3b, 0x46, 0xcd, 0x76, 0xbb, 0xd2, 0xfb, 0x00, 0x32, 0x19, 0x37, 0x21, 0x3c, - 0x80, 0xc2, 0xeb, 0x29, 0x28, 0x57, 0x88, 0x03, 0xcd, 0x14, 0xa0, 0xc3, 0x0f, 0xe9, 0xc3, 0x13, 0x90, 0x1f, 0xd3, - 0xe1, 0x47, 0xb7, 0x72, 0x1b, 0x6d, 0x73, 0x2c, 0x4f, 0x95, 0x87, 0x6a, 0x1c, 0x21, 0x4a, 0x72, 0x61, 0xb1, 0xa8, - 0xdb, 0x2b, 0x57, 0xb4, 0xbd, 0xf1, 0x5e, 0xdf, 0xb0, 0x4d, 0x3a, 0xfe, 0x18, 0xe6, 0xb8, 0xc2, 0xa8, 0x46, 0x15, - 0x6c, 0xe9, 0x1d, 0xb0, 0xd5, 0x5d, 0x25, 0xb0, 0xc7, 0xa6, 0xb1, 0xb9, 0x00, 0x1d, 0x1a, 0xa2, 0x0c, 0xa4, 0x54, - 0x35, 0x0b, 0x64, 0x72, 0xf5, 0x29, 0xec, 0xb6, 0xa6, 0x31, 0x9b, 0x90, 0xf7, 0xbf, 0xa1, 0x79, 0x15, 0x96, 0x7c, - 0xc2, 0xfe, 0x10, 0xc9, 0x67, 0xf8, 0xd2, 0x47, 0x8d, 0xf8, 0x1e, 0xe0, 0x6a, 0x5f, 0x0a, 0x45, 0xa6, 0x38, 0xb6, - 0xc7, 0x6b, 0xd4, 0x26, 0xf3, 0xf0, 0x50, 0x47, 0x17, 0x36, 0xe4, 0x47, 0x38, 0x61, 0xfb, 0x31, 0xce, 0x93, 0x0b, - 0x8c, 0xe8, 0xbb, 0x18, 0x37, 0x07, 0xe8, 0xca, 0x00, 0xbc, 0x2d, 0xa3, 0x5e, 0x2a, 0x1f, 0xec, 0xf0, 0x16, 0x75, - 0xb3, 0xe3, 0x34, 0xd8, 0x66, 0xc4, 0xa1, 0x1c, 0x0a, 0x70, 0x97, 0xbd, 0xaa, 0x52, 0xe4, 0xf4, 0xd6, 0xf4, 0x8e, - 0xeb, 0x0d, 0xf2, 0x45, 0x13, 0x4d, 0x1d, 0x4c, 0x7a, 0x00, 0x13, 0x6a, 0x39, 0xa0, 0x31, 0x7a, 0xb5, 0x25, 0x5b, - 0x5c, 0x0b, 0x9e, 0xd9, 0x02, 0xd2, 0xbc, 0x22, 0xd5, 0x6e, 0x94, 0x46, 0x53, 0x32, 0x34, 0x69, 0x13, 0x8b, 0xd4, - 0x40, 0x32, 0xab, 0x57, 0x75, 0x22, 0x55, 0x83, 0x2d, 0x70, 0x60, 0xb3, 0x20, 0xc3, 0x37, 0xfb, 0x93, 0x01, 0x63, - 0x4b, 0xaf, 0x7d, 0x4f, 0x76, 0x1f, 0x35, 0xe4, 0x1a, 0x12, 0x19, 0xc7, 0x39, 0xd1, 0x6c, 0xaa, 0x88, 0xf8, 0x64, - 0x1d, 0x15, 0xb0, 0x36, 0x97, 0x6d, 0xe5, 0x83, 0x0a, 0x7a, 0x6c, 0xb0, 0xc9, 0x06, 0xd7, 0x8e, 0x19, 0xd6, 0x17, - 0x9b, 0x8a, 0xd4, 0x65, 0xfa, 0x40, 0xc4, 0x34, 0x83, 0x44, 0xa8, 0x3b, 0x36, 0xbe, 0xae, 0x4a, 0xbb, 0x20, 0xec, - 0xfb, 0xab, 0x74, 0xae, 0x61, 0xe3, 0x15, 0x90, 0xb9, 0xbe, 0xea, 0x00, 0x03, 0xbc, 0x02, 0x71, 0x31, 0xe0, 0xe3, - 0x2d, 0x90, 0x29, 0xf9, 0x77, 0xd4, 0x73, 0xa3, 0x94, 0x47, 0x2d, 0xef, 0x86, 0x19, 0xde, 0x6a, 0x2f, 0xf3, 0x0f, - 0x4b, 0x1f, 0xf2, 0x21, 0x41, 0x85, 0x2c, 0xa4, 0xe6, 0x49, 0xd4, 0xcd, 0x1d, 0x88, 0x6d, 0xdd, 0xe7, 0x22, 0xa3, - 0x58, 0xc4, 0xca, 0x23, 0xc0, 0x5d, 0x04, 0xbc, 0xdb, 0xac, 0x94, 0x88, 0x6f, 0x0e, 0xa5, 0x55, 0xde, 0x12, 0xcd, - 0x55, 0x60, 0xde, 0x45, 0x2b, 0x3b, 0x9c, 0xea, 0x90, 0x81, 0x9d, 0x4a, 0xed, 0x94, 0x44, 0xef, 0xb1, 0xc2, 0x5e, - 0xb3, 0x8d, 0xf5, 0x5b, 0x3b, 0xfa, 0x10, 0x56, 0x0b, 0x56, 0xdb, 0x73, 0x85, 0xe6, 0x66, 0xa2, 0x80, 0x58, 0x60, - 0xcd, 0xde, 0xbe, 0x49, 0x14, 0x42, 0xe1, 0x82, 0xcb, 0x52, 0x2a, 0x91, 0x62, 0xdb, 0x01, 0x83, 0x44, 0x13, 0x26, - 0xaa, 0x8a, 0x73, 0x23, 0xf6, 0x3c, 0x6b, 0xb0, 0xc0, 0xb3, 0x92, 0x0c, 0x8a, 0xd0, 0xba, 0xad, 0x76, 0xa9, 0x40, - 0xef, 0x67, 0x81, 0x95, 0x53, 0xe5, 0x04, 0x0e, 0xa9, 0x46, 0x1c, 0x9f, 0x05, 0x23, 0xb7, 0x4a, 0xf9, 0x16, 0x61, - 0xce, 0xe0, 0xcc, 0x7f, 0x5d, 0x7a, 0x6d, 0x7a, 0x52, 0x0a, 0x0e, 0xd3, 0xf7, 0xe7, 0x30, 0xe9, 0x15, 0x18, 0x9d, - 0xf7, 0x9e, 0xf7, 0x4a, 0x16, 0xf8, 0xeb, 0xb5, 0xbe, 0x14, 0x45, 0xb3, 0x29, 0x4f, 0x3d, 0xb9, 0x94, 0xf9, 0x96, - 0x06, 0xae, 0x53, 0x1c, 0xad, 0xee, 0xd9, 0x9b, 0x15, 0x30, 0x13, 0xcb, 0xf0, 0xef, 0xc1, 0xd6, 0xbe, 0x81, 0xf3, - 0x62, 0x09, 0x44, 0x7e, 0x63, 0x7c, 0x7d, 0xc8, 0xd3, 0xe2, 0x85, 0xcf, 0xcf, 0x08, 0x0b, 0x15, 0xe6, 0x8a, 0x84, - 0xc7, 0xa7, 0x4a, 0xab, 0x2c, 0x41, 0xc3, 0xb4, 0x7c, 0xa6, 0xc7, 0x8b, 0xfa, 0x56, 0x39, 0x7a, 0xeb, 0x2f, 0xb3, - 0xd2, 0x98, 0xcf, 0xcf, 0x93, 0x47, 0x4a, 0xbc, 0x7e, 0xf2, 0x89, 0xaa, 0xf2, 0x67, 0xc3, 0x6d, 0xbd, 0xef, 0xe1, - 0xd7, 0xde, 0x7e, 0x90, 0x65, 0x81, 0xc8, 0xaa, 0x71, 0x6d, 0xe3, 0xa8, 0xf2, 0x34, 0xed, 0x85, 0x58, 0x28, 0xc2, - 0x0f, 0x8a, 0x0e, 0x2c, 0x2f, 0x73, 0xa9, 0xe6, 0x5c, 0x85, 0x8a, 0x46, 0xec, 0x69, 0xfc, 0x7c, 0x08, 0x4b, 0x61, - 0x2a, 0x32, 0x08, 0xe3, 0x0e, 0xed, 0x92, 0x64, 0xec, 0x96, 0x32, 0x6d, 0xeb, 0x77, 0x0b, 0xe5, 0x35, 0x15, 0x13, - 0x30, 0x85, 0x77, 0x20, 0xb9, 0x99, 0x2d, 0xb6, 0xd6, 0x39, 0xa9, 0xa3, 0x02, 0xfb, 0x31, 0xa6, 0xc0, 0x61, 0xa7, - 0x9b, 0xe9, 0x73, 0x81, 0x1b, 0x9a, 0xf2, 0x50, 0x6f, 0x3a, 0xc3, 0x95, 0xd7, 0xf1, 0x43, 0x75, 0xe6, 0x6c, 0x81, - 0x96, 0x6b, 0xe4, 0x2b, 0xbe, 0xaa, 0x96, 0x20, 0xf2, 0x40, 0xf2, 0xb7, 0xaf, 0xbe, 0x7b, 0xab, 0x4b, 0x45, 0xd2, - 0x19, 0x6e, 0xd5, 0xb0, 0x3b, 0x58, 0xe8, 0x6e, 0x75, 0x26, 0x91, 0x04, 0x1a, 0x90, 0x5d, 0x1b, 0xde, 0x0b, 0x1b, - 0xe8, 0x4e, 0x3b, 0x5c, 0x3b, 0xa9, 0x22, 0x68, 0x9d, 0x5d, 0x65, 0x0c, 0x6d, 0xd9, 0x45, 0xc4, 0x2d, 0xbb, 0x0e, - 0x47, 0xd1, 0xcd, 0xb4, 0x10, 0xd6, 0xc6, 0xe3, 0x1e, 0x54, 0x6f, 0x33, 0x20, 0x25, 0x22, 0x12, 0x28, 0x17, 0xe2, - 0x6f, 0x5d, 0xa8, 0x59, 0xc6, 0xdd, 0xa6, 0x43, 0xec, 0x26, 0x89, 0xeb, 0x83, 0x66, 0xf0, 0xd6, 0xa5, 0x95, 0xd7, - 0x19, 0x52, 0xf8, 0x48, 0x45, 0x06, 0xce, 0x0d, 0x53, 0x7b, 0xda, 0x65, 0x1d, 0xc6, 0xbc, 0x54, 0xca, 0xaa, 0x08, - 0xb8, 0xd5, 0x00, 0xcf, 0xda, 0xb7, 0x70, 0x4c, 0x13, 0x1b, 0x9a, 0xa7, 0xbe, 0xcf, 0xd1, 0x76, 0x37, 0x5e, 0xb4, - 0xe2, 0xab, 0xd7, 0xd6, 0x71, 0xd9, 0x3c, 0xeb, 0x6e, 0x13, 0x56, 0xb1, 0x9f, 0x22, 0x29, 0x6c, 0x1a, 0x8b, 0xb9, - 0x26, 0x71, 0x4c, 0x02, 0xa3, 0x05, 0xb0, 0x37, 0xd1, 0x2c, 0xbb, 0x58, 0x22, 0xb5, 0x75, 0x58, 0x77, 0x73, 0xc0, - 0xe1, 0xdb, 0xce, 0x57, 0xaa, 0x76, 0x53, 0x83, 0x12, 0x39, 0xe7, 0xc3, 0xfe, 0xc2, 0xed, 0xfe, 0x50, 0xe2, 0x4d, - 0xdd, 0xc6, 0xe2, 0x48, 0x34, 0x16, 0x10, 0x5c, 0x66, 0x8c, 0xda, 0x2c, 0x8b, 0x10, 0x9d, 0x5a, 0x59, 0xff, 0x40, - 0x05, 0x48, 0x25, 0xd4, 0x6a, 0x71, 0x33, 0x81, 0x05, 0xc7, 0xa4, 0xd4, 0xc6, 0xe1, 0xc7, 0x3f, 0x89, 0xa7, 0x54, - 0xb4, 0x69, 0xd4, 0x13, 0xcd, 0x05, 0xfb, 0x72, 0x88, 0x46, 0x20, 0x77, 0x1b, 0xd6, 0x38, 0xf5, 0xa2, 0xb3, 0xb9, - 0x51, 0xe8, 0xb0, 0x32, 0x55, 0x60, 0xfc, 0xad, 0xc2, 0x6c, 0x20, 0xe7, 0x2a, 0x89, 0x95, 0xdb, 0x19, 0x78, 0x61, - 0x84, 0x0e, 0x62, 0x00, 0xbd, 0x9d, 0xfc, 0x54, 0x7f, 0x5a, 0x5d, 0x94, 0x71, 0x22, 0x4c, 0x4e, 0xdf, 0xdb, 0xc1, - 0x83, 0xda, 0x6c, 0xe7, 0x52, 0xbc, 0xe6, 0x39, 0x81, 0xf6, 0x95, 0x9f, 0xfd, 0x5e, 0xbf, 0x3f, 0x71, 0x13, 0x5a, - 0x56, 0x20, 0x75, 0x8a, 0x7f, 0xd5, 0x9d, 0x51, 0xee, 0x76, 0xce, 0xb3, 0x09, 0xcb, 0x63, 0x92, 0xec, 0x1b, 0x7f, - 0x5b, 0xb8, 0xe4, 0x68, 0xc9, 0x9f, 0x38, 0x0f, 0x8c, 0x62, 0x31, 0x4d, 0x16, 0xa6, 0x7e, 0x4d, 0xd2, 0xde, 0xc4, - 0x75, 0x6c, 0xc4, 0xf1, 0x9f, 0xe3, 0x10, 0x3d, 0x49, 0x84, 0xd4, 0x7a, 0x4b, 0x51, 0x3d, 0xaa, 0x7b, 0x27, 0xea, - 0x42, 0x36, 0x0f, 0x39, 0x5a, 0x93, 0x31, 0x9d, 0xd4, 0x5d, 0xaa, 0x91, 0x68, 0x04, 0x4b, 0xb7, 0x76, 0x3b, 0x39, - 0x3c, 0xc4, 0x4b, 0xfb, 0x28, 0x12, 0x15, 0xbd, 0x0b, 0x4d, 0x71, 0x68, 0xa3, 0x58, 0x5f, 0x59, 0x89, 0x05, 0xd6, - 0x5e, 0x2a, 0xad, 0xe6, 0x82, 0xae, 0xbc, 0x1c, 0x15, 0x3a, 0x27, 0x00, 0x5b, 0xcb, 0x39, 0x91, 0x01, 0x74, 0x62, - 0xb1, 0x70, 0x1d, 0x72, 0x03, 0xca, 0x10, 0xa1, 0x72, 0x4b, 0x8f, 0x53, 0x69, 0xd5, 0x28, 0x96, 0x80, 0xc4, 0x70, - 0xc6, 0x7c, 0xeb, 0x63, 0x36, 0x6e, 0x64, 0x0c, 0xae, 0x5a, 0x76, 0x6d, 0x19, 0x6b, 0x6b, 0x85, 0xb4, 0x0e, 0x98, - 0x5a, 0x65, 0x3f, 0x35, 0xbe, 0xf3, 0xe7, 0xbd, 0x23, 0x4d, 0x6f, 0x71, 0x24, 0x11, 0x06, 0x6d, 0xaf, 0x18, 0x0b, - 0x53, 0x84, 0xdb, 0xec, 0xf6, 0x8a, 0xd0, 0xdd, 0x5f, 0x0a, 0x7c, 0x5b, 0xb8, 0x31, 0x15, 0x37, 0x8e, 0x1e, 0x5f, - 0x14, 0x4c, 0x04, 0xe3, 0xd0, 0x54, 0x95, 0xf0, 0x6e, 0xba, 0x0a, 0x0a, 0x72, 0x2a, 0x2a, 0x1c, 0x78, 0xb0, 0x5e, - 0x66, 0xf4, 0x28, 0x12, 0x8a, 0x2d, 0xae, 0x6a, 0x4d, 0x14, 0x77, 0x19, 0x17, 0xa4, 0x2f, 0x87, 0xf9, 0xb7, 0xaa, - 0x1b, 0xba, 0x66, 0x55, 0xba, 0x45, 0xe2, 0x6b, 0x53, 0x8d, 0x46, 0x44, 0xe5, 0x7b, 0xe9, 0x03, 0xf3, 0x58, 0x4b, - 0x77, 0x3e, 0xed, 0x13, 0xae, 0x0c, 0x1c, 0x18, 0xfa, 0x48, 0xf7, 0x57, 0xeb, 0xea, 0x24, 0x1f, 0x57, 0x9f, 0x7c, - 0x0d, 0xcf, 0x1f, 0x8c, 0x9d, 0x76, 0x70, 0xcb, 0x21, 0x7d, 0xcc, 0xf9, 0x35, 0x33, 0xbd, 0x45, 0xab, 0xbd, 0x51, - 0xa3, 0x2e, 0xb0, 0x99, 0x4c, 0xd3, 0x63, 0xfe, 0xe9, 0xad, 0xe8, 0xc1, 0x05, 0x27, 0x89, 0x2f, 0x20, 0xe1, 0x86, - 0xed, 0xd5, 0xc7, 0x47, 0xaa, 0xeb, 0xd6, 0x09, 0x25, 0x76, 0x23, 0x95, 0xed, 0xa0, 0x42, 0xc8, 0x0e, 0xf7, 0xc4, - 0xd5, 0x1b, 0xec, 0x73, 0x88, 0xd3, 0xd1, 0x00, 0x29, 0x93, 0x26, 0xf6, 0x25, 0x8c, 0xf7, 0xc5, 0x1c, 0x36, 0x2b, - 0x48, 0xbc, 0xea, 0xc2, 0x29, 0x94, 0x58, 0xd9, 0xf3, 0xea, 0x78, 0x1d, 0xdd, 0xe4, 0x23, 0x9a, 0x32, 0xd0, 0xdc, - 0xbd, 0xe5, 0x2e, 0x17, 0x18, 0xdd, 0x6b, 0xf3, 0xf1, 0xdd, 0xce, 0x68, 0x4d, 0x12, 0xf9, 0xc6, 0xb7, 0xf9, 0x70, - 0xf3, 0xf8, 0x85, 0x86, 0xe2, 0x00, 0xd7, 0x71, 0xb8, 0xfd, 0xe1, 0xb2, 0x2a, 0xf7, 0xaa, 0x1f, 0xd4, 0xc0, 0x91, - 0x52, 0x4f, 0x0d, 0x67, 0x61, 0x4f, 0x30, 0xe1, 0xd4, 0x81, 0xb3, 0xe6, 0x83, 0x90, 0x73, 0xf9, 0xd7, 0x2e, 0x94, - 0x73, 0x37, 0x6e, 0x16, 0x9e, 0x06, 0x36, 0x76, 0x86, 0x3a, 0x5c, 0xea, 0xce, 0x6c, 0x49, 0x3c, 0xc3, 0x8f, 0xbb, - 0x9a, 0x08, 0x4b, 0xed, 0x81, 0xaf, 0x57, 0xbc, 0x9c, 0xfb, 0xdb, 0xe1, 0x8d, 0xe2, 0x82, 0x30, 0x33, 0xbe, 0x88, - 0x4a, 0x93, 0x2c, 0x69, 0xf8, 0x36, 0xb2, 0x19, 0x75, 0xed, 0xbb, 0x68, 0x45, 0x50, 0x32, 0x22, 0x54, 0x71, 0x68, - 0xc6, 0x50, 0x06, 0xe8, 0x58, 0x45, 0x69, 0xcf, 0xd7, 0x06, 0xb3, 0x4e, 0x36, 0x73, 0x04, 0x74, 0x44, 0xdf, 0x2f, - 0x5f, 0xd4, 0xb3, 0x7f, 0xdd, 0x1f, 0x1e, 0xbe, 0xc8, 0x55, 0x7d, 0xd4, 0x00, 0xfc, 0x8e, 0x54, 0xf5, 0xe8, 0x8d, - 0xe5, 0x57, 0x5a, 0x82, 0xad, 0x66, 0x07, 0x46, 0x9d, 0xa4, 0x8d, 0xd4, 0x86, 0xcc, 0x32, 0x67, 0x52, 0x28, 0x04, - 0x2d, 0x3d, 0x9e, 0x63, 0x31, 0x05, 0x50, 0xd2, 0xe5, 0x8a, 0x88, 0x0b, 0x06, 0x61, 0x15, 0x87, 0x31, 0x2c, 0xa4, - 0x69, 0x3d, 0xdb, 0xce, 0xa2, 0x51, 0x83, 0xd0, 0x35, 0x86, 0x44, 0x85, 0x99, 0xf5, 0x8c, 0x83, 0x5c, 0x6a, 0xbb, - 0x20, 0x4f, 0x7e, 0x73, 0x15, 0x03, 0xd0, 0x63, 0x22, 0x79, 0x5a, 0xd5, 0x44, 0x96, 0x90, 0xcf, 0xa4, 0x61, 0xd3, - 0xfb, 0xc3, 0x37, 0x31, 0x3d, 0xfa, 0xd8, 0xd5, 0xd6, 0x1f, 0xa2, 0xe4, 0xb9, 0x17, 0x29, 0x5f, 0xeb, 0xb4, 0x65, - 0x77, 0xa2, 0x4d, 0xd0, 0x44, 0xdb, 0x82, 0xb0, 0x05, 0x3a, 0xd0, 0x67, 0x3c, 0x1a, 0x2e, 0x1b, 0x51, 0x16, 0x8b, - 0x94, 0x28, 0x87, 0xfc, 0xe6, 0xec, 0x11, 0xe2, 0xb4, 0x16, 0x46, 0x03, 0xcb, 0xbd, 0x60, 0x18, 0x45, 0x17, 0xec, - 0x81, 0x4f, 0x2b, 0x52, 0x5c, 0x7d, 0xbb, 0x58, 0xf3, 0xba, 0x32, 0xd1, 0x36, 0x98, 0xf0, 0x0e, 0x1a, 0x1e, 0x61, - 0xaf, 0x71, 0x4e, 0x83, 0xae, 0xeb, 0xc9, 0xd3, 0x8a, 0x8c, 0x4d, 0x65, 0xa5, 0x1e, 0x01, 0xb7, 0xd8, 0xdb, 0x7e, - 0xd4, 0x36, 0x07, 0x7b, 0x36, 0xb6, 0xea, 0xc6, 0xb6, 0xc7, 0x10, 0xdc, 0x3a, 0x41, 0x3e, 0xdd, 0x29, 0x7d, 0x9e, - 0xe7, 0x4b, 0x6b, 0x13, 0xe8, 0xf0, 0xba, 0x35, 0xed, 0x71, 0x84, 0x11, 0x11, 0xb7, 0x99, 0x2e, 0x58, 0x58, 0x4a, - 0x6f, 0xa9, 0xae, 0x88, 0xe1, 0xd9, 0x0e, 0x59, 0x0d, 0x40, 0x2f, 0xb0, 0x3f, 0x94, 0xe7, 0x25, 0x5c, 0xe8, 0xf9, - 0xf0, 0xd1, 0x45, 0x95, 0x97, 0xa5, 0x1d, 0x66, 0x7b, 0xd6, 0xdd, 0xa0, 0xc2, 0xf5, 0xa9, 0x5a, 0x9d, 0x50, 0x20, - 0x96, 0x9e, 0xa3, 0xbf, 0xef, 0x53, 0x47, 0x3c, 0xcf, 0x08, 0x61, 0x27, 0x36, 0x9b, 0x07, 0x20, 0xf6, 0x41, 0xc7, - 0x04, 0x01, 0x42, 0xd0, 0x10, 0xab, 0x3d, 0xa0, 0x1e, 0xbf, 0x33, 0xf4, 0x7d, 0x44, 0x7a, 0x13, 0xa0, 0x32, 0x05, - 0xc5, 0x89, 0xda, 0xa7, 0x24, 0x22, 0x27, 0x3f, 0xc9, 0x2e, 0x9b, 0xb1, 0xa8, 0x93, 0xc0, 0xf9, 0x88, 0x53, 0xb0, - 0x14, 0x0a, 0xe7, 0xc5, 0x33, 0x01, 0x7c, 0x3a, 0x67, 0x8b, 0x69, 0xe1, 0x8b, 0x1c, 0x94, 0xcd, 0xa4, 0xc7, 0xf5, - 0x38, 0xb7, 0x7d, 0x4c, 0x38, 0x2a, 0xca, 0xd8, 0xd8, 0x5b, 0x75, 0x66, 0x8c, 0xf0, 0xd5, 0x44, 0xa8, 0xf7, 0x63, - 0x9c, 0xb7, 0xf7, 0x3d, 0x3e, 0xe2, 0x52, 0x6c, 0x2a, 0x84, 0xc2, 0x82, 0x9a, 0xa7, 0xf4, 0x47, 0x59, 0xe7, 0xd4, - 0xc8, 0x82, 0xf2, 0xb8, 0x82, 0x91, 0xa2, 0x4c, 0xfb, 0xec, 0xc9, 0x9e, 0x32, 0x48, 0x6c, 0xe7, 0x65, 0xa2, 0x2b, - 0x05, 0x0c, 0xa2, 0x54, 0x0a, 0x76, 0xb5, 0x2d, 0x14, 0xc9, 0x20, 0x1c, 0x43, 0xbb, 0x11, 0x47, 0x55, 0xe6, 0x90, - 0x84, 0x7c, 0xcd, 0xd7, 0x38, 0xb3, 0xdd, 0x1c, 0x52, 0x18, 0x6c, 0x51, 0x4d, 0x46, 0x81, 0xd0, 0x6e, 0x41, 0x40, - 0xe8, 0xd2, 0x85, 0xdf, 0xe0, 0x97, 0x47, 0xa9, 0x6c, 0x26, 0x38, 0x4f, 0x17, 0x6e, 0xe1, 0x97, 0x1e, 0xb5, 0x62, - 0xc7, 0x5b, 0x6b, 0xe3, 0x12, 0xe5, 0xa2, 0x65, 0xfe, 0x23, 0xf6, 0xb8, 0x80, 0x03, 0x5b, 0x60, 0x6d, 0xe8, 0x0e, - 0x95, 0x61, 0x34, 0x70, 0xe2, 0x01, 0x24, 0xb5, 0xbb, 0x61, 0x49, 0x5b, 0xd4, 0x7f, 0x32, 0xd7, 0xea, 0x1a, 0x34, - 0x81, 0x59, 0xab, 0xc5, 0x36, 0x4d, 0x85, 0x1c, 0x32, 0xaa, 0x1a, 0xb0, 0x52, 0x6d, 0x87, 0x34, 0x59, 0x22, 0x87, - 0x24, 0x71, 0x27, 0x73, 0x06, 0x55, 0x56, 0x7a, 0xd1, 0xff, 0xa8, 0x44, 0xe4, 0x43, 0x5e, 0xff, 0xa4, 0x8a, 0x67, - 0x99, 0xd4, 0x8f, 0xc2, 0xa1, 0x4a, 0x63, 0x93, 0x0d, 0x6d, 0x00, 0xa3, 0x0f, 0x73, 0xa8, 0x2c, 0x74, 0x6c, 0x95, - 0xfb, 0x6e, 0x5a, 0xc9, 0xb1, 0x21, 0x9f, 0xcc, 0x18, 0x30, 0xdf, 0x7e, 0x0b, 0x62, 0x8f, 0x5b, 0xcc, 0x38, 0xdc, - 0x4b, 0x7e, 0xbe, 0x4c, 0x44, 0xc1, 0x1f, 0x4e, 0xc3, 0x0e, 0x7c, 0xd3, 0x21, 0xa6, 0xcd, 0x15, 0x33, 0x64, 0x06, - 0xa5, 0x6d, 0x09, 0x31, 0x2d, 0x78, 0x4a, 0xa4, 0xfb, 0xf7, 0xfe, 0xc4, 0xde, 0xd3, 0x7c, 0x21, 0x3f, 0x59, 0x9d, - 0x83, 0xbe, 0x55, 0x97, 0x3e, 0x86, 0x17, 0xc6, 0x7d, 0x00, 0x50, 0xb9, 0xbd, 0x6d, 0xc5, 0x71, 0x7b, 0x5f, 0x85, - 0xf8, 0x83, 0x19, 0x66, 0x1c, 0xaf, 0x52, 0x64, 0x63, 0x81, 0xe9, 0x94, 0x59, 0xa9, 0x5b, 0x35, 0x2b, 0xfb, 0xc7, - 0xf4, 0x5f, 0x1a, 0x0d, 0xb0, 0x2f, 0x17, 0xf9, 0xf9, 0x76, 0x99, 0x28, 0xb0, 0xc2, 0x22, 0xd1, 0x7b, 0x17, 0x40, - 0xba, 0x83, 0x48, 0xc6, 0x9f, 0xf7, 0x70, 0xd1, 0xf0, 0xf0, 0x17, 0x39, 0x68, 0xd9, 0x79, 0xe5, 0x44, 0x69, 0x3e, - 0xaf, 0xd8, 0x09, 0x44, 0x9e, 0x3a, 0x45, 0x98, 0xfe, 0x7d, 0x72, 0xe5, 0xb5, 0x47, 0x4e, 0xce, 0x5e, 0x40, 0xbf, - 0x26, 0x6e, 0x9f, 0x9f, 0x65, 0x1d, 0xfe, 0xb1, 0x44, 0x85, 0xb0, 0x52, 0xe8, 0x41, 0x55, 0x08, 0x5f, 0x70, 0xe2, - 0x00, 0x4e, 0x3d, 0x7c, 0x78, 0xc6, 0xdf, 0x0e, 0x43, 0xfb, 0xf8, 0x99, 0xb3, 0x69, 0x89, 0xf1, 0x12, 0x83, 0x45, - 0xb5, 0xd4, 0x78, 0x7e, 0xff, 0x34, 0xeb, 0xe9, 0x9e, 0xb1, 0x4f, 0x8b, 0x9e, 0xac, 0x6a, 0x9a, 0x37, 0x24, 0xce, - 0x7f, 0xd8, 0xfc, 0x5a, 0x1b, 0x1f, 0xec, 0xdc, 0x56, 0x25, 0x47, 0xd6, 0x05, 0xae, 0xcb, 0xaa, 0x55, 0xd5, 0x37, - 0x03, 0xce, 0x49, 0x8f, 0xc5, 0x4b, 0x9d, 0xdd, 0x2f, 0xe8, 0x8f, 0x66, 0x3a, 0x5a, 0x1f, 0x7d, 0x70, 0x2d, 0x42, - 0xd5, 0xa4, 0x33, 0xba, 0x37, 0xbf, 0xc3, 0x39, 0xe5, 0x33, 0xd7, 0xf1, 0xb9, 0x5b, 0x0b, 0xe5, 0x09, 0x8f, 0x16, - 0x1a, 0x85, 0xa1, 0x3b, 0x77, 0x8f, 0xe0, 0x5a, 0x24, 0xcd, 0xc8, 0xde, 0xc2, 0x39, 0xd3, 0xf8, 0x4c, 0x7f, 0x36, - 0x0b, 0xf5, 0xa7, 0x3e, 0x14, 0x14, 0x11, 0xf3, 0x2b, 0xa6, 0x62, 0x28, 0x25, 0xc1, 0x43, 0x44, 0x04, 0x5a, 0x47, - 0x51, 0x3e, 0x55, 0x57, 0x57, 0xca, 0xea, 0x97, 0xb3, 0x2c, 0x28, 0x92, 0xd9, 0x14, 0x59, 0xb9, 0xe2, 0x8f, 0x4e, - 0x72, 0x96, 0xeb, 0x42, 0x40, 0xce, 0x3e, 0xc0, 0x89, 0xfd, 0x9b, 0x41, 0xe0, 0xb6, 0xb6, 0xd6, 0xfe, 0x48, 0x50, - 0x63, 0x14, 0x7c, 0x8b, 0x00, 0x8c, 0xc4, 0xd0, 0x46, 0xf9, 0xe4, 0x56, 0xba, 0x50, 0x51, 0xbd, 0x3f, 0x71, 0xf7, - 0x3f, 0xbf, 0xbb, 0xc9, 0x0d, 0x1b, 0x77, 0x69, 0x0e, 0x4d, 0xe6, 0xc9, 0x39, 0xda, 0xc8, 0xee, 0xba, 0x5f, 0x06, - 0xf9, 0x2d, 0x5f, 0x92, 0xf4, 0x74, 0x44, 0x60, 0x4b, 0xcb, 0x8f, 0x48, 0x45, 0x49, 0x22, 0x90, 0x63, 0xad, 0x00, - 0x50, 0x33, 0x21, 0x95, 0x8a, 0x1c, 0x45, 0x9e, 0x8c, 0x7a, 0x33, 0xa7, 0x24, 0x2d, 0x69, 0x37, 0xa8, 0x31, 0x2c, - 0x87, 0xaf, 0xb9, 0x36, 0x4b, 0x7d, 0xad, 0xa4, 0xec, 0xc4, 0xf6, 0x82, 0x05, 0x94, 0x38, 0xa6, 0xe0, 0x82, 0xd5, - 0x58, 0x9a, 0x36, 0xaf, 0x27, 0x18, 0xd0, 0x32, 0x97, 0x76, 0xd9, 0x12, 0xf2, 0x95, 0xfa, 0x7d, 0x58, 0x8c, 0x90, - 0x7c, 0x63, 0xa1, 0x58, 0xda, 0xaa, 0x55, 0xb9, 0xf3, 0x1c, 0x3f, 0xd0, 0xa4, 0x48, 0x1d, 0xed, 0x61, 0xfa, 0x16, - 0x8e, 0xc4, 0xe0, 0x66, 0x4e, 0xb9, 0xa4, 0x4c, 0xe3, 0xd2, 0x9f, 0xa4, 0xff, 0xaa, 0x2f, 0x43, 0x3e, 0xc1, 0x51, - 0xac, 0xfe, 0x83, 0x6a, 0xcc, 0x40, 0x40, 0xea, 0x4b, 0x10, 0x15, 0xc3, 0x68, 0xe6, 0x10, 0xdd, 0xa0, 0xf5, 0x99, - 0x3a, 0x91, 0xce, 0x5e, 0x6c, 0x70, 0xd2, 0x97, 0x73, 0xa2, 0x79, 0xe1, 0x3b, 0x8c, 0xf7, 0x81, 0x01, 0x0c, 0x0a, - 0xf3, 0x60, 0x0c, 0xec, 0xb2, 0x26, 0x6d, 0x29, 0xb8, 0x41, 0x0d, 0x34, 0x81, 0x07, 0x78, 0x3a, 0x89, 0x90, 0x8b, - 0x7c, 0x66, 0x71, 0x27, 0xbb, 0x98, 0x52, 0x6b, 0x7e, 0x2c, 0x84, 0x85, 0xfe, 0xdd, 0x62, 0x2b, 0xcb, 0x1d, 0x3c, - 0x13, 0x11, 0x54, 0x05, 0x0a, 0xbc, 0x72, 0x79, 0x43, 0xa7, 0x25, 0x70, 0xf0, 0x3e, 0xb5, 0xe2, 0x06, 0x07, 0xbe, - 0x0d, 0x2a, 0x5d, 0xb0, 0x1f, 0xb4, 0xeb, 0xdf, 0x7b, 0xae, 0xc2, 0x22, 0xea, 0x21, 0xde, 0x6a, 0xae, 0x57, 0x77, - 0xf7, 0xbe, 0xd7, 0xf1, 0x59, 0x53, 0xcb, 0x1e, 0x7f, 0xc6, 0x10, 0x0a, 0x4e, 0xd0, 0x2a, 0x15, 0x12, 0x30, 0xf0, - 0xc7, 0x2d, 0x6c, 0xfc, 0x92, 0xa5, 0xdb, 0x11, 0x4b, 0x7f, 0xfd, 0xba, 0xa2, 0xc9, 0xae, 0xba, 0xa9, 0x27, 0xa0, - 0x88, 0xbd, 0xa3, 0x55, 0x76, 0xb8, 0x4a, 0xcd, 0x7b, 0xc5, 0xbb, 0x1e, 0xf8, 0x94, 0x0e, 0xcc, 0x28, 0xb0, 0x17, - 0xc4, 0x1c, 0x18, 0xeb, 0xc7, 0x46, 0x79, 0xd7, 0x4f, 0xbe, 0x4b, 0xd1, 0x46, 0xad, 0xaf, 0xfc, 0x41, 0x10, 0xdf, - 0x67, 0x46, 0xac, 0xbd, 0x04, 0x66, 0x30, 0xba, 0xd3, 0x36, 0x1d, 0x76, 0xe5, 0x3e, 0x9e, 0x1f, 0xb2, 0xde, 0x41, - 0x40, 0xa5, 0xe8, 0x47, 0x81, 0x4b, 0x26, 0x30, 0x98, 0x83, 0x23, 0xdb, 0x8b, 0x3d, 0xf9, 0x44, 0xcc, 0x85, 0x28, - 0x45, 0x33, 0x46, 0x01, 0xc1, 0xc8, 0x61, 0x85, 0xed, 0x3f, 0xc2, 0x76, 0x01, 0x70, 0x8b, 0x87, 0x0c, 0x7b, 0x5e, - 0xe3, 0x4d, 0xbc, 0x1d, 0x35, 0xcc, 0x99, 0xd4, 0x5b, 0xd0, 0x4e, 0x8f, 0x21, 0xf9, 0x7d, 0x1a, 0x24, 0xa3, 0x22, - 0xf7, 0x28, 0x12, 0x84, 0xd7, 0x45, 0x4e, 0x5a, 0x80, 0x75, 0x77, 0xe8, 0xa6, 0x5f, 0x01, 0x62, 0xfa, 0x5e, 0x02, - 0xfe, 0x44, 0x6e, 0x22, 0x16, 0xbc, 0xdd, 0x34, 0xc4, 0x1d, 0x4c, 0x80, 0xa1, 0x11, 0x9e, 0x41, 0xd0, 0x08, 0x92, - 0x11, 0xdd, 0x6d, 0xee, 0xa7, 0xcc, 0x7f, 0x56, 0xe4, 0xc7, 0xb2, 0x31, 0xae, 0x79, 0xd3, 0xde, 0xc5, 0x6f, 0x11, - 0xa9, 0x00, 0x62, 0x67, 0xca, 0x2c, 0x54, 0x89, 0xc9, 0xd7, 0x85, 0x8d, 0x7d, 0x6e, 0x94, 0x25, 0xdb, 0xe7, 0xf5, - 0xd7, 0x66, 0xd8, 0x92, 0x66, 0xb6, 0xb7, 0x39, 0xe3, 0xb3, 0x8a, 0x89, 0x85, 0x17, 0x05, 0xce, 0xfd, 0xed, 0xd7, - 0xfd, 0xf9, 0x70, 0x95, 0x2d, 0xdb, 0x29, 0x53, 0x8f, 0x23, 0x25, 0xcb, 0x5a, 0x7f, 0xbb, 0x32, 0x93, 0xb7, 0x6e, - 0xd1, 0x13, 0xec, 0xa8, 0x35, 0xf3, 0x25, 0x47, 0xda, 0xba, 0x87, 0x93, 0xec, 0xba, 0xc0, 0x2e, 0xef, 0x04, 0xd0, - 0xb4, 0x74, 0x42, 0xf1, 0x73, 0x25, 0xb4, 0xac, 0x1d, 0xe0, 0x24, 0x7e, 0xfa, 0x62, 0xe2, 0xa5, 0x98, 0xad, 0xc1, - 0x36, 0xbf, 0x62, 0x5e, 0xc4, 0x60, 0xcf, 0x8d, 0x0a, 0xe1, 0x8c, 0xf3, 0xbe, 0x05, 0xb3, 0xf4, 0x1b, 0xaf, 0xdc, - 0xe6, 0x73, 0x82, 0xfd, 0x96, 0x16, 0xc1, 0xc0, 0xc4, 0x5d, 0xf5, 0x5a, 0xe3, 0x2c, 0x84, 0xa8, 0x6b, 0xb9, 0x2f, - 0x62, 0xe6, 0x36, 0xa7, 0xe9, 0x5d, 0xad, 0xc9, 0x8c, 0xfd, 0xe2, 0x4a, 0x33, 0xeb, 0xbb, 0xef, 0x20, 0x6b, 0xad, - 0x2a, 0xf4, 0x2b, 0x52, 0xcf, 0x64, 0xfd, 0x27, 0xb0, 0x19, 0x8b, 0x1d, 0x16, 0x4b, 0x2b, 0x75, 0xe7, 0xaa, 0xf4, - 0x03, 0x9e, 0x54, 0x00, 0x72, 0x11, 0xd0, 0x99, 0x6e, 0x3d, 0x77, 0x8b, 0x45, 0x3d, 0xea, 0xc1, 0xad, 0xbf, 0xb7, - 0x1a, 0x06, 0x41, 0xac, 0x63, 0xbf, 0x22, 0x78, 0x3c, 0x5e, 0x89, 0xdf, 0x0b, 0xaf, 0xc8, 0x0f, 0x5b, 0x1e, 0xff, - 0x7c, 0x01, 0x65, 0xfa, 0x49, 0x34, 0xed, 0xfc, 0x6c, 0xc3, 0xc2, 0xa4, 0x7c, 0x3a, 0x8f, 0xfc, 0x1e, 0xcd, 0xcd, - 0x15, 0xb4, 0xdc, 0xf3, 0x03, 0x97, 0xf2, 0x7f, 0x16, 0x29, 0x4b, 0x6a, 0x85, 0x66, 0xd9, 0x36, 0xc1, 0xd1, 0xdd, - 0x9e, 0xe2, 0xc1, 0x73, 0x9c, 0x50, 0x68, 0x6f, 0x4a, 0xbd, 0x55, 0x85, 0x9a, 0xa8, 0xb5, 0x85, 0x02, 0x65, 0xfd, - 0x88, 0xf6, 0x51, 0x71, 0xc4, 0x0d, 0x23, 0x3d, 0xea, 0x6f, 0x6a, 0x6d, 0x91, 0x5d, 0x47, 0xed, 0x97, 0xb5, 0xfb, - 0x7d, 0x12, 0x24, 0xff, 0x6d, 0x05, 0xc8, 0xac, 0x0d, 0xd5, 0x9b, 0x80, 0x69, 0x44, 0x31, 0x47, 0xc1, 0x8f, 0xd1, - 0x92, 0x42, 0xa3, 0x0c, 0x2e, 0x1c, 0x11, 0x66, 0x2d, 0xb5, 0xe4, 0x19, 0x43, 0xf0, 0xbc, 0xd1, 0xd0, 0x91, 0xf0, - 0xb5, 0xe9, 0x5d, 0x76, 0x66, 0x36, 0x4c, 0xce, 0x3d, 0xa2, 0x21, 0x9b, 0x7a, 0xaa, 0x28, 0x01, 0xf7, 0xcd, 0x72, - 0x7c, 0x75, 0x50, 0xb2, 0x26, 0xb5, 0x57, 0xc1, 0x6e, 0x1f, 0x72, 0x73, 0x19, 0xbd, 0x35, 0x54, 0x6b, 0xf8, 0xde, - 0x48, 0xd6, 0xb0, 0xca, 0x35, 0x90, 0xd8, 0xce, 0x8f, 0x30, 0x49, 0x45, 0x77, 0xf9, 0x16, 0x34, 0xde, 0x51, 0x95, - 0xcb, 0x4e, 0xeb, 0xda, 0xbb, 0x03, 0x37, 0x61, 0xd8, 0xfa, 0xd4, 0x8d, 0x8e, 0xf4, 0xfd, 0x80, 0x0d, 0x9a, 0x95, - 0x4a, 0x03, 0x4e, 0xf9, 0x05, 0x15, 0xad, 0xf3, 0xbb, 0x25, 0x5f, 0xec, 0x19, 0xee, 0x83, 0x11, 0x32, 0x26, 0x8e, - 0xc0, 0x8e, 0x1a, 0xe0, 0x29, 0x61, 0xc6, 0xe1, 0xc7, 0x9e, 0xdb, 0xd7, 0xc6, 0xa0, 0x7f, 0xa5, 0xd9, 0x50, 0x40, - 0x8d, 0xf6, 0xb8, 0x92, 0x54, 0x3a, 0x86, 0x19, 0x93, 0xc2, 0x87, 0x54, 0x28, 0x73, 0xfc, 0xbb, 0x73, 0x4d, 0xb1, - 0x66, 0x38, 0x57, 0x23, 0xd3, 0x86, 0xf3, 0xbf, 0x1a, 0xf3, 0x5b, 0x8e, 0xef, 0x20, 0xaa, 0x9e, 0x8e, 0x41, 0x87, - 0x50, 0x4a, 0x50, 0x76, 0x65, 0x42, 0x55, 0x03, 0xfd, 0xa2, 0x19, 0x6d, 0x9a, 0xd6, 0x8f, 0x91, 0xf3, 0xbf, 0x6e, - 0xbf, 0xb6, 0x93, 0x8b, 0xd6, 0x0a, 0xeb, 0xe2, 0x07, 0x3f, 0x30, 0xe4, 0xb5, 0x7b, 0x7e, 0x76, 0xab, 0x5c, 0xd9, - 0x53, 0x5b, 0x3c, 0x75, 0xc1, 0x97, 0xe9, 0xfa, 0x18, 0xbc, 0x2c, 0x20, 0x35, 0x8d, 0xaa, 0x75, 0xec, 0x13, 0x16, - 0x5a, 0xec, 0x3a, 0x6f, 0xd7, 0x2f, 0x4f, 0xaa, 0x89, 0x57, 0x2c, 0x03, 0x3a, 0x3f, 0xb3, 0x29, 0xf6, 0x91, 0x16, - 0x97, 0x0d, 0xff, 0x32, 0xa0, 0xe7, 0xd9, 0x40, 0x9e, 0xf9, 0x59, 0x7f, 0xae, 0x3f, 0xbe, 0xe5, 0x21, 0xa1, 0x14, - 0xb7, 0x35, 0x4e, 0xef, 0x1a, 0xdb, 0xcc, 0x3b, 0xb3, 0xb4, 0x8f, 0x9d, 0x66, 0x3e, 0xa2, 0x22, 0x5d, 0x70, 0x12, - 0xb6, 0xa7, 0x43, 0xba, 0x92, 0x6d, 0x16, 0x9a, 0x39, 0xf5, 0xa5, 0x71, 0x59, 0x9c, 0xd7, 0x69, 0x73, 0x31, 0xf7, - 0x82, 0xae, 0x03, 0x38, 0xd7, 0x29, 0x47, 0x70, 0x55, 0x11, 0x28, 0x9a, 0x9a, 0xb9, 0xa2, 0x78, 0x68, 0x2d, 0x76, - 0x73, 0x6b, 0xf7, 0x53, 0x8c, 0xb8, 0xd4, 0xa5, 0x2a, 0x51, 0x92, 0x6c, 0x59, 0x29, 0x90, 0xc9, 0x82, 0xac, 0x39, - 0x49, 0x15, 0x0e, 0xfa, 0x37, 0x87, 0x74, 0xf6, 0x62, 0xd8, 0x87, 0x70, 0xe6, 0x6b, 0xa9, 0x0a, 0xa3, 0x59, 0xac, - 0xe6, 0x39, 0x88, 0x54, 0x6d, 0x1f, 0x28, 0xa7, 0xca, 0x7d, 0x5b, 0x40, 0xe6, 0x46, 0xca, 0x4b, 0x51, 0x47, 0x6e, - 0x78, 0x4a, 0xbf, 0x36, 0x3d, 0x10, 0xa3, 0xd5, 0xb0, 0xa3, 0x8d, 0x66, 0xb3, 0x59, 0x14, 0x53, 0x8f, 0x43, 0x9b, - 0xd4, 0x7c, 0x1b, 0x51, 0xaf, 0x50, 0x35, 0xb3, 0x6f, 0x4c, 0x3d, 0x62, 0xc9, 0x9c, 0xe2, 0x35, 0x14, 0x26, 0xc9, - 0x3d, 0x8b, 0x2d, 0xea, 0x16, 0x6d, 0x6e, 0xce, 0x1c, 0x1b, 0x92, 0xb8, 0x8a, 0x4b, 0x99, 0xae, 0x34, 0x1e, 0x05, - 0xc2, 0x61, 0x25, 0xda, 0x4e, 0x30, 0x1b, 0xf3, 0xf4, 0x83, 0x9f, 0x92, 0x9f, 0x7b, 0x04, 0x4c, 0xb3, 0x2f, 0x60, - 0x2b, 0xe9, 0xce, 0x8c, 0xf8, 0x48, 0x41, 0xce, 0xe1, 0x2b, 0x86, 0xe9, 0x7b, 0x9b, 0xc8, 0x72, 0x1f, 0xe7, 0x53, - 0x82, 0x32, 0x39, 0xa9, 0x76, 0x68, 0xbc, 0x81, 0xd8, 0x1b, 0x20, 0x9e, 0x86, 0xb0, 0x04, 0x4f, 0x23, 0x60, 0x90, - 0xf8, 0xbc, 0x5c, 0xc5, 0x43, 0x2d, 0x6e, 0x7c, 0x97, 0x79, 0x08, 0x70, 0xb6, 0x0c, 0x43, 0x6d, 0x62, 0x92, 0xfb, - 0xb3, 0x06, 0x74, 0x27, 0xa2, 0x62, 0x49, 0x66, 0x97, 0x75, 0x15, 0x85, 0xf9, 0x77, 0x5e, 0x2f, 0x53, 0x27, 0x9c, - 0x2d, 0xde, 0xb8, 0x0d, 0x80, 0xe9, 0x42, 0x7b, 0xaa, 0x93, 0x13, 0x93, 0xe2, 0xd7, 0x50, 0x1a, 0xd6, 0x09, 0x0d, - 0x14, 0x89, 0xfa, 0x79, 0xb4, 0x9e, 0x98, 0xa2, 0x38, 0xff, 0x11, 0x91, 0x0c, 0x4c, 0x12, 0xc8, 0x60, 0xb4, 0x7b, - 0xc5, 0x9a, 0x50, 0xac, 0xfd, 0xa4, 0x65, 0xd3, 0x99, 0xfb, 0x36, 0x83, 0x98, 0xbd, 0x1f, 0x04, 0x0f, 0x04, 0xff, - 0xe5, 0x56, 0x27, 0x8c, 0xa0, 0x04, 0xc3, 0xec, 0x30, 0xff, 0x89, 0xac, 0xba, 0x2d, 0xe8, 0xa8, 0x57, 0xe4, 0xaa, - 0x77, 0xe2, 0x52, 0x67, 0x12, 0x55, 0x4f, 0x7e, 0x9e, 0xb8, 0xfb, 0x56, 0x36, 0x46, 0x0b, 0xdc, 0xe7, 0xc8, 0x27, - 0x57, 0x6e, 0x66, 0xdb, 0xc8, 0xa8, 0xa6, 0x28, 0x12, 0x8b, 0x98, 0xca, 0xbc, 0x79, 0x8e, 0xfb, 0xf0, 0xaa, 0xb9, - 0x83, 0xef, 0xb7, 0x39, 0xd8, 0xca, 0xac, 0xdc, 0xe5, 0xf2, 0x6d, 0x7a, 0x68, 0xd0, 0xfd, 0xae, 0x73, 0xa4, 0x4b, - 0xef, 0xa6, 0x72, 0x5b, 0xb7, 0x3b, 0x53, 0xe7, 0x23, 0xf5, 0xd4, 0xf1, 0xf9, 0x99, 0x74, 0x63, 0xb7, 0xfe, 0x53, - 0x35, 0xc1, 0x4f, 0xf9, 0x02, 0xb4, 0x34, 0x55, 0x7c, 0x2c, 0x28, 0xa3, 0x16, 0xdd, 0xc1, 0x57, 0x6e, 0xb9, 0x15, - 0xf4, 0x2b, 0x9f, 0xab, 0xbc, 0x70, 0x8d, 0x5c, 0x3a, 0x7b, 0xe1, 0x04, 0x36, 0xf5, 0xe0, 0x9d, 0xf1, 0x57, 0xc1, - 0x25, 0xe0, 0xda, 0x10, 0x07, 0x23, 0x25, 0x89, 0xf7, 0xd5, 0xc0, 0x1b, 0x11, 0xf1, 0x8f, 0x82, 0xa1, 0x51, 0xfa, - 0x96, 0xfa, 0x18, 0x5b, 0x0c, 0xb3, 0x3e, 0xaa, 0x94, 0xab, 0xb3, 0xb9, 0xe0, 0xd4, 0x39, 0xfa, 0x13, 0x46, 0xdc, - 0xf3, 0x00, 0xe7, 0xac, 0xae, 0x7f, 0x06, 0xe7, 0xb5, 0xfd, 0xcc, 0x38, 0x1f, 0x8a, 0xa6, 0x44, 0xeb, 0xdd, 0x78, - 0xa7, 0x3c, 0x9b, 0x2d, 0xe7, 0x67, 0x15, 0x5e, 0x0d, 0xf7, 0x19, 0x9f, 0x5e, 0xfa, 0x1d, 0x98, 0x3c, 0x0e, 0xba, - 0xf4, 0xbe, 0x72, 0x78, 0xef, 0x4e, 0xc5, 0x4a, 0x15, 0x35, 0xe2, 0xd8, 0xa1, 0x7b, 0x8d, 0xc7, 0xbd, 0x0b, 0xcc, - 0x1a, 0xab, 0x93, 0xc3, 0x43, 0x6b, 0xab, 0x7c, 0x5d, 0x65, 0x84, 0x9d, 0xc4, 0x37, 0xcb, 0xc6, 0x94, 0x48, 0xb0, - 0xbf, 0x0d, 0x94, 0x63, 0x38, 0x10, 0xe1, 0x61, 0xc2, 0x9b, 0xac, 0xc2, 0xbc, 0x96, 0x8a, 0x32, 0xab, 0x1d, 0xfe, - 0x5a, 0x71, 0x8d, 0x68, 0x07, 0x4b, 0xae, 0xa4, 0x0d, 0x66, 0xd1, 0xa5, 0xa4, 0x61, 0x75, 0xc3, 0xa1, 0x5e, 0xdf, - 0x15, 0x5c, 0xd5, 0xb6, 0x66, 0x91, 0xfc, 0x45, 0xd9, 0x8e, 0x95, 0x08, 0x9b, 0x29, 0xf3, 0x3c, 0xfb, 0x3f, 0xa2, - 0x4a, 0x87, 0xdc, 0x02, 0xa6, 0xf6, 0x43, 0xba, 0x42, 0x52, 0x8c, 0x0d, 0xda, 0x4f, 0x4a, 0x57, 0x32, 0xef, 0xf8, - 0x8d, 0xc5, 0x75, 0xcb, 0x50, 0xe4, 0x62, 0x33, 0x56, 0x17, 0x1b, 0xc0, 0xc2, 0x2a, 0x07, 0xcc, 0x46, 0x14, 0xcd, - 0xa2, 0x6c, 0xca, 0xa3, 0xed, 0x16, 0xaf, 0x5b, 0xb4, 0xfa, 0xfb, 0x33, 0xd1, 0x33, 0x5b, 0x27, 0x55, 0x9d, 0x65, - 0xbd, 0x7f, 0x65, 0xc5, 0x5c, 0xe1, 0xe1, 0x47, 0x7b, 0x6e, 0xe7, 0x88, 0xce, 0xfb, 0xbb, 0x9b, 0xfe, 0x85, 0xdd, - 0xfc, 0x7f, 0x49, 0x37, 0x61, 0x86, 0xf5, 0xe4, 0xf6, 0xd3, 0x4b, 0xac, 0x09, 0xe7, 0x3f, 0xb2, 0x89, 0x61, 0xdb, - 0x15, 0xd4, 0x16, 0x35, 0x66, 0x9c, 0x12, 0x3c, 0xf6, 0x81, 0x0a, 0xed, 0x61, 0xe2, 0x0a, 0x61, 0x54, 0x79, 0xaa, - 0x44, 0xfa, 0x5c, 0xfc, 0xb2, 0x4d, 0x64, 0xd0, 0x19, 0x87, 0xb2, 0x81, 0x9d, 0xdb, 0xb5, 0xca, 0xcc, 0xd6, 0xd2, - 0xfa, 0x8f, 0x99, 0x62, 0xf3, 0x7f, 0xc0, 0x12, 0xf5, 0x90, 0x47, 0x7e, 0x59, 0xb5, 0x08, 0xef, 0x0d, 0xe5, 0xe6, - 0x21, 0xc8, 0x2d, 0x8b, 0x0e, 0x7f, 0x60, 0x3e, 0x40, 0x8e, 0x60, 0x8c, 0x1a, 0xb0, 0x52, 0x4e, 0x21, 0x97, 0xf9, - 0x71, 0xaa, 0xc9, 0x50, 0xcb, 0x72, 0x9d, 0xb1, 0x4a, 0x23, 0xaf, 0x59, 0x99, 0xa7, 0x59, 0x91, 0x6b, 0x94, 0x0d, - 0x15, 0xd7, 0x9f, 0x91, 0xa3, 0x51, 0x1b, 0xd0, 0x10, 0xbb, 0xe3, 0x9c, 0xd8, 0x28, 0x73, 0xd4, 0x71, 0x72, 0x4b, - 0x9e, 0x59, 0x57, 0x33, 0x5b, 0x89, 0x93, 0x8b, 0x77, 0x9b, 0xb1, 0x6d, 0x77, 0x34, 0x2e, 0x99, 0x27, 0x8e, 0x73, - 0x74, 0x7d, 0xa3, 0xcd, 0x9e, 0x97, 0xec, 0xb8, 0xf8, 0x3f, 0x48, 0x0e, 0xdd, 0x3c, 0x1a, 0x11, 0xcc, 0xc5, 0x25, - 0x45, 0xa9, 0xe9, 0xe6, 0x48, 0x02, 0x1b, 0x1e, 0xff, 0xb9, 0x89, 0xae, 0xf8, 0x78, 0x6e, 0x56, 0x46, 0x14, 0x5b, - 0x9c, 0xd8, 0x9f, 0xed, 0x61, 0xd5, 0x7a, 0x44, 0xc2, 0x81, 0xb3, 0xce, 0xfa, 0x60, 0x9f, 0xeb, 0xd2, 0xff, 0xe0, - 0x07, 0x36, 0x12, 0x82, 0x8d, 0x61, 0xf5, 0xce, 0xfe, 0xa7, 0x66, 0xc5, 0x85, 0xae, 0x35, 0x3b, 0x5e, 0xf8, 0x57, - 0x5c, 0xe1, 0x2d, 0x49, 0x65, 0x25, 0x37, 0x2e, 0x77, 0x2a, 0xe3, 0x05, 0x55, 0x3a, 0x66, 0x61, 0xe8, 0x58, 0x4c, - 0xaf, 0x0e, 0x4a, 0xaf, 0x08, 0x68, 0xa8, 0xce, 0xb9, 0xab, 0x95, 0xd9, 0x04, 0x97, 0x11, 0x92, 0x4a, 0x81, 0xbb, - 0xc2, 0x90, 0xe9, 0x9d, 0x6f, 0x86, 0x7e, 0x30, 0x14, 0x66, 0x6e, 0x40, 0xd8, 0x32, 0x41, 0xa5, 0xc3, 0x9a, 0x15, - 0x7b, 0x41, 0x9b, 0x0c, 0xe6, 0x3c, 0xa2, 0xde, 0x6b, 0xa4, 0xbf, 0x73, 0xc2, 0x05, 0x38, 0x4a, 0x81, 0xc2, 0x80, - 0x2e, 0x6f, 0x3c, 0x40, 0x72, 0x89, 0x10, 0x63, 0x0d, 0x85, 0xd4, 0x26, 0x7e, 0x39, 0xbf, 0xe2, 0x9e, 0xf7, 0xb3, - 0xe3, 0xac, 0xeb, 0x5b, 0x03, 0x79, 0x98, 0x5f, 0xbf, 0xbd, 0xce, 0x7a, 0x90, 0xb3, 0x21, 0x71, 0xb1, 0xb2, 0xf3, - 0x8a, 0x76, 0x76, 0x45, 0x5b, 0xea, 0x6a, 0x54, 0xe1, 0xb6, 0x86, 0x29, 0x52, 0x54, 0xb1, 0xe1, 0x7a, 0x1b, 0xba, - 0x20, 0xe9, 0x8b, 0x35, 0x85, 0x84, 0x19, 0xbb, 0xa6, 0x30, 0x95, 0x3b, 0xa1, 0x47, 0x67, 0xc3, 0x40, 0x5f, 0x6c, - 0xfd, 0x02, 0xf4, 0xa7, 0x8d, 0x8d, 0x36, 0x7d, 0x4f, 0x54, 0x46, 0xcc, 0x29, 0xfa, 0xbc, 0xc3, 0xec, 0xd3, 0xfe, - 0x44, 0x77, 0xb0, 0x5a, 0x5f, 0xc6, 0x5f, 0x56, 0x6c, 0xd4, 0xc7, 0xd6, 0x33, 0x26, 0x89, 0x53, 0xc9, 0xed, 0x41, - 0x49, 0x41, 0x66, 0xde, 0x44, 0x0d, 0x19, 0x29, 0xad, 0x39, 0x8f, 0x20, 0xfe, 0x77, 0xae, 0x98, 0x99, 0x98, 0xf6, - 0x63, 0x5c, 0x52, 0x1f, 0x7f, 0xf7, 0xc4, 0x5b, 0xbb, 0x77, 0x9a, 0xa1, 0x63, 0xf6, 0x00, 0x81, 0x9c, 0x57, 0x5e, - 0xba, 0x60, 0x68, 0x6e, 0xad, 0x54, 0xb3, 0xa6, 0x51, 0xfe, 0xb3, 0xbb, 0x32, 0x05, 0x03, 0xfb, 0x44, 0xad, 0x3f, - 0xdb, 0xe5, 0x66, 0xea, 0x1b, 0xb3, 0x57, 0x03, 0x4e, 0x04, 0x66, 0x36, 0xdd, 0x54, 0xfa, 0xaf, 0xfb, 0xfe, 0x3b, - 0x16, 0xa0, 0xd8, 0xd9, 0xc8, 0x1f, 0x9a, 0x8a, 0xe0, 0xc6, 0x77, 0x67, 0x2f, 0x86, 0x2d, 0x0a, 0x05, 0x5f, 0x46, - 0x99, 0xee, 0x32, 0xf2, 0x07, 0x0d, 0x6d, 0xf0, 0x4b, 0x7a, 0x63, 0x1b, 0x97, 0x61, 0x1f, 0xed, 0x61, 0x12, 0xbb, - 0x60, 0x68, 0x6b, 0x62, 0x41, 0x50, 0x35, 0x75, 0xde, 0x30, 0x22, 0xa1, 0x6f, 0xad, 0x95, 0xcf, 0xeb, 0xd8, 0x33, - 0xde, 0x71, 0x3e, 0x64, 0x62, 0x04, 0x7e, 0x8b, 0xb6, 0x5b, 0x12, 0xca, 0xb8, 0x74, 0x0c, 0x32, 0xb5, 0x47, 0x6d, - 0xc7, 0xc9, 0xb4, 0xed, 0x76, 0xd4, 0xee, 0xd1, 0xdd, 0xcd, 0x6f, 0x06, 0xa5, 0xed, 0x8e, 0xf0, 0x2d, 0xbc, 0x3a, - 0x73, 0xe4, 0x7e, 0xeb, 0xee, 0x24, 0x5b, 0xa0, 0x37, 0x33, 0x15, 0x14, 0x75, 0xc2, 0xc9, 0x33, 0xd6, 0xf8, 0xbf, - 0xd0, 0x54, 0xc1, 0x10, 0x98, 0xcc, 0x44, 0xb2, 0xdb, 0x82, 0x7c, 0x16, 0xfa, 0xfb, 0x14, 0x6e, 0x15, 0xb2, 0xb4, - 0x2d, 0x66, 0x08, 0xa7, 0x7a, 0xd0, 0x0c, 0x5e, 0x42, 0x81, 0x28, 0xed, 0x9d, 0xa1, 0x32, 0xe8, 0x41, 0xa5, 0x03, - 0x99, 0x28, 0x06, 0x35, 0x4b, 0x61, 0xca, 0x9b, 0x90, 0x7a, 0xf7, 0x7b, 0xbd, 0xf5, 0x77, 0xf9, 0xde, 0x8c, 0x22, - 0x1e, 0xf5, 0xd6, 0x49, 0x02, 0x82, 0x5f, 0x71, 0x20, 0x13, 0xe5, 0xf5, 0x92, 0x18, 0xb1, 0x8e, 0xc7, 0x49, 0xae, - 0x16, 0x1d, 0xaf, 0xc4, 0x39, 0x25, 0x15, 0x42, 0xce, 0x01, 0x0c, 0x13, 0x05, 0xee, 0xe5, 0x38, 0x82, 0xf5, 0x80, - 0x67, 0x72, 0x45, 0x3d, 0x1b, 0x8b, 0xbb, 0xfd, 0xef, 0xe5, 0xd5, 0xed, 0x9a, 0xf6, 0x36, 0x49, 0x01, 0x56, 0x5d, - 0x54, 0x82, 0xef, 0xfe, 0xfc, 0x29, 0xe4, 0xb1, 0x64, 0x87, 0x5a, 0x2a, 0x73, 0x30, 0x5b, 0x74, 0x1d, 0x72, 0xd6, - 0xa7, 0xaa, 0x3a, 0x36, 0x39, 0xa0, 0x86, 0xd3, 0xb4, 0x73, 0xc1, 0x78, 0x9c, 0xb0, 0x86, 0x73, 0xc2, 0x1a, 0x76, - 0xa8, 0x68, 0x23, 0x8c, 0x6e, 0x68, 0x31, 0x96, 0xb4, 0xc6, 0x7c, 0x3b, 0x20, 0x24, 0xf8, 0x7a, 0xa1, 0x95, 0x8b, - 0x8c, 0xe3, 0x8f, 0x2d, 0x06, 0x13, 0xec, 0x12, 0x2b, 0xdd, 0x84, 0x7f, 0x0d, 0xcf, 0x95, 0xbe, 0x95, 0x27, 0x71, - 0x73, 0x6f, 0xce, 0xe1, 0x44, 0xe3, 0x51, 0x93, 0x8c, 0xfc, 0x94, 0xf5, 0xa8, 0x94, 0xe4, 0x3f, 0x37, 0x8f, 0x81, - 0x33, 0x73, 0x8b, 0x7d, 0x25, 0x30, 0x26, 0x54, 0x3a, 0x96, 0xf1, 0x2f, 0x11, 0xf5, 0xd9, 0x68, 0xc4, 0x0c, 0x0a, - 0xe3, 0x5c, 0x25, 0x56, 0xe2, 0x3e, 0xdb, 0xa2, 0x97, 0xf2, 0xae, 0x31, 0x46, 0x25, 0x4c, 0xc5, 0x2f, 0x46, 0xf6, - 0x18, 0xa9, 0xb7, 0x73, 0xb6, 0xfd, 0x5c, 0x13, 0xdd, 0x73, 0x3a, 0x90, 0x04, 0x8d, 0x4b, 0x66, 0x0a, 0x90, 0xc4, - 0x04, 0x63, 0x72, 0x07, 0x2c, 0xda, 0xa6, 0x75, 0x9e, 0xc2, 0xab, 0x56, 0xe3, 0x49, 0x65, 0x7b, 0xdf, 0x65, 0x65, - 0x2e, 0xdb, 0x8e, 0x4e, 0x5b, 0x12, 0x24, 0x8d, 0x1a, 0xa7, 0x48, 0x48, 0xd5, 0xd3, 0xac, 0x0c, 0x0b, 0x84, 0xb5, - 0xe2, 0x9c, 0xbe, 0xb9, 0x35, 0x99, 0x9d, 0x17, 0xb1, 0x57, 0x78, 0x15, 0x85, 0x08, 0x6e, 0x67, 0x13, 0x89, 0x0f, - 0x63, 0xcb, 0x3a, 0x59, 0xc8, 0xd2, 0xb7, 0x6e, 0xad, 0x4b, 0xc0, 0x0f, 0xde, 0xea, 0xb7, 0xfb, 0xf1, 0x38, 0xb4, - 0x30, 0xd6, 0x47, 0xb8, 0xf8, 0xa8, 0x17, 0x2c, 0xad, 0x7c, 0x89, 0x08, 0x4a, 0x9b, 0xa5, 0xd7, 0xbf, 0x60, 0xb1, - 0x29, 0x2f, 0x57, 0x2c, 0x34, 0x36, 0x74, 0x33, 0x0d, 0xd5, 0x32, 0x31, 0x27, 0x15, 0x55, 0x31, 0xc7, 0x00, 0x3d, - 0xee, 0x20, 0x73, 0xcb, 0x22, 0x6b, 0xd2, 0xc3, 0x59, 0x09, 0xcc, 0xd7, 0x60, 0xe7, 0x38, 0x03, 0xea, 0xd8, 0xa4, - 0xea, 0x17, 0x0b, 0xa0, 0x24, 0x6e, 0xe0, 0x5b, 0x21, 0x77, 0xa1, 0xca, 0x1e, 0x29, 0xa4, 0xb0, 0x0e, 0x2c, 0xe1, - 0xac, 0x60, 0xc5, 0xd8, 0x3e, 0x6c, 0xe6, 0x8f, 0x51, 0x6f, 0x01, 0xd3, 0x43, 0x08, 0xf3, 0xdd, 0x1d, 0xb8, 0x11, - 0x1d, 0xad, 0xc9, 0xe4, 0x1e, 0x27, 0xc8, 0xa2, 0x9f, 0xfb, 0x25, 0x31, 0x14, 0x4f, 0xc8, 0xcb, 0x51, 0x33, 0x16, - 0xb5, 0x60, 0x5a, 0xa6, 0xcd, 0x2d, 0xdf, 0x7d, 0x6d, 0x23, 0xaa, 0x47, 0xc4, 0xa5, 0x42, 0x48, 0x1d, 0x14, 0xe8, - 0x0e, 0x73, 0xa9, 0xeb, 0xc9, 0xb3, 0x45, 0xf1, 0x2c, 0x9b, 0xae, 0x12, 0xfc, 0xe9, 0xe3, 0x0d, 0xb5, 0xbd, 0x09, - 0xa8, 0xf4, 0x5e, 0x77, 0x9c, 0x93, 0xde, 0x51, 0x89, 0x88, 0x26, 0x19, 0x7f, 0xfb, 0xc8, 0xbc, 0x05, 0x91, 0x58, - 0xeb, 0xe1, 0xd2, 0xeb, 0xb7, 0xaf, 0x51, 0xb0, 0x6a, 0x22, 0x9c, 0xbd, 0xa5, 0x49, 0x1c, 0xbc, 0x14, 0x21, 0x19, - 0x8a, 0x60, 0xe4, 0xa3, 0x82, 0xd8, 0x8a, 0xad, 0x12, 0x75, 0xb5, 0x86, 0x40, 0xc4, 0x39, 0xd8, 0x20, 0xb3, 0x8c, - 0xce, 0x99, 0xd7, 0xbe, 0x3c, 0x44, 0xf1, 0xd2, 0x14, 0xf5, 0xbf, 0x5a, 0x16, 0x7e, 0xf4, 0x70, 0xe0, 0x75, 0x64, - 0xe5, 0xac, 0x77, 0xbd, 0x54, 0x6e, 0xcb, 0x3a, 0x6e, 0xad, 0x7a, 0x4f, 0x9e, 0x20, 0xa7, 0xd1, 0xa6, 0x97, 0xe2, - 0xd6, 0x21, 0xa9, 0x31, 0xbc, 0x56, 0xb5, 0xa8, 0x8f, 0x0b, 0x77, 0xd8, 0x8b, 0x5a, 0xa9, 0x77, 0x30, 0x11, 0x5d, - 0xf7, 0xed, 0x9f, 0x88, 0x6a, 0xc8, 0x98, 0x8e, 0x35, 0xe4, 0x0e, 0x6c, 0xc1, 0xf4, 0x54, 0xd2, 0x77, 0x02, 0xf1, - 0xf8, 0x48, 0xb2, 0xab, 0xff, 0x94, 0xd1, 0xfd, 0x85, 0x8c, 0x81, 0x91, 0xd1, 0x1d, 0x61, 0x2d, 0xc2, 0xbd, 0x34, - 0xe8, 0x18, 0x23, 0x94, 0x4f, 0x89, 0x66, 0x66, 0xd9, 0x6d, 0x5e, 0x90, 0xd8, 0xe7, 0x5a, 0xcd, 0xde, 0x72, 0x9d, - 0x48, 0xd0, 0xa2, 0x04, 0xe2, 0xe5, 0x96, 0x19, 0x17, 0x80, 0xae, 0x8d, 0x9b, 0x14, 0x71, 0xb8, 0xb1, 0xd9, 0xdb, - 0x00, 0xa0, 0x7d, 0xfe, 0xfd, 0x4c, 0xe9, 0xe2, 0x76, 0x41, 0x09, 0x9b, 0x1f, 0x2c, 0x26, 0x8b, 0x5b, 0x19, 0x14, - 0x62, 0x23, 0x04, 0x0f, 0x64, 0x13, 0x8d, 0xdd, 0x7a, 0x8a, 0xd8, 0x3c, 0x5f, 0x20, 0x6d, 0x51, 0x78, 0x26, 0x67, - 0x93, 0xfd, 0x8b, 0x76, 0xb0, 0x81, 0xb1, 0x6e, 0x52, 0x94, 0xdf, 0x95, 0xa6, 0xa3, 0x8c, 0xda, 0xc7, 0x2f, 0x37, - 0x5c, 0x94, 0xa5, 0x26, 0x30, 0x9a, 0x46, 0xdd, 0xf2, 0xf7, 0x89, 0x13, 0x0c, 0x5d, 0x19, 0x01, 0xca, 0xb9, 0x94, - 0x09, 0x9f, 0xb3, 0x6f, 0x90, 0x16, 0x00, 0xf2, 0x9b, 0x1f, 0xb5, 0xe3, 0x63, 0x73, 0xbd, 0xfc, 0xd2, 0xb6, 0xa5, - 0x44, 0xf4, 0x5f, 0xda, 0x2a, 0xdb, 0xb1, 0x0f, 0x54, 0xf1, 0x30, 0x6a, 0x44, 0xcb, 0x9a, 0x0f, 0x59, 0xfb, 0x14, - 0x0f, 0x9b, 0x7b, 0x6f, 0x76, 0xa6, 0xc8, 0x86, 0xda, 0x25, 0xfb, 0xcb, 0x4b, 0x3a, 0x2f, 0xaf, 0xd6, 0x0c, 0x5e, - 0xed, 0x11, 0xea, 0x2a, 0x02, 0x05, 0x8f, 0xc1, 0x01, 0xbe, 0x36, 0xfb, 0x9e, 0x2d, 0x28, 0xf0, 0xcf, 0x8e, 0x9d, - 0xbf, 0x3c, 0x9f, 0x43, 0x02, 0x59, 0x9f, 0x35, 0x49, 0x04, 0x44, 0x24, 0x74, 0x3a, 0xdb, 0x1a, 0x82, 0x3c, 0x8c, - 0x2c, 0x1e, 0xb1, 0x59, 0xc6, 0x7f, 0xb1, 0x98, 0x8b, 0xcb, 0x7b, 0x36, 0xb9, 0x9f, 0x9b, 0xb7, 0xce, 0x00, 0xa9, - 0x6d, 0x9a, 0xc9, 0x48, 0x75, 0x64, 0x1a, 0x40, 0x05, 0xed, 0x85, 0x52, 0x4a, 0x46, 0xa9, 0x1c, 0x23, 0xb6, 0x6b, - 0x23, 0xe3, 0xe2, 0x64, 0x49, 0xc3, 0xb0, 0x24, 0xf8, 0x35, 0x11, 0x04, 0xbd, 0x54, 0x44, 0xf5, 0x70, 0x51, 0xca, - 0xdb, 0x21, 0x8f, 0x06, 0xd0, 0x52, 0xe3, 0x6d, 0x92, 0xa7, 0xdd, 0x8b, 0x73, 0x17, 0x59, 0x71, 0xf3, 0xa7, 0xc4, - 0x0f, 0x95, 0x63, 0x3c, 0x29, 0x90, 0x18, 0xe7, 0x5d, 0xb9, 0xf3, 0xa0, 0x0e, 0xc4, 0x1c, 0x13, 0x3c, 0xd2, 0xb3, - 0xaa, 0x3d, 0x98, 0x19, 0x68, 0x53, 0x1a, 0x4d, 0x15, 0xb5, 0x01, 0xe5, 0xff, 0x80, 0xbe, 0xca, 0xa7, 0xe5, 0x91, - 0x6b, 0x10, 0x86, 0xd2, 0x7a, 0x4b, 0xc3, 0x4b, 0x42, 0x68, 0x71, 0xae, 0x4c, 0x32, 0x08, 0xbc, 0xf1, 0xa1, 0xd7, - 0x35, 0x7e, 0x10, 0x25, 0x40, 0x73, 0xe6, 0x27, 0x1f, 0x3e, 0x9e, 0x03, 0x14, 0xce, 0x5a, 0x32, 0xfa, 0xb3, 0xab, - 0x09, 0x4b, 0xba, 0x5d, 0x34, 0xbb, 0x11, 0xca, 0x57, 0x29, 0x58, 0x5a, 0x58, 0x8a, 0xde, 0xa2, 0x3c, 0x30, 0x6c, - 0xb7, 0xb2, 0x7d, 0xfb, 0x5f, 0x1e, 0xde, 0x2b, 0x74, 0x91, 0xb0, 0x1d, 0xe2, 0xa7, 0xa8, 0xe9, 0x2f, 0x3e, 0x9c, - 0x9e, 0x8c, 0x61, 0xbb, 0x2b, 0x61, 0xee, 0x30, 0xcf, 0xb1, 0xbf, 0x74, 0xe4, 0x86, 0xb6, 0x12, 0x31, 0xf9, 0x5a, - 0x36, 0x61, 0x11, 0x07, 0x0c, 0x64, 0xae, 0x06, 0xb9, 0x83, 0x23, 0x04, 0xa6, 0xd6, 0x7c, 0xf2, 0xff, 0x54, 0x2d, - 0x1e, 0x9f, 0x2d, 0x8b, 0x4a, 0x82, 0x7c, 0x2b, 0xed, 0xf3, 0xd8, 0x87, 0xa4, 0x1d, 0xd8, 0xf7, 0x08, 0x16, 0xbd, - 0xdd, 0x61, 0x51, 0x68, 0xa1, 0x83, 0xb8, 0xa4, 0xce, 0xa7, 0xf0, 0xea, 0xe5, 0x32, 0x85, 0xd0, 0x29, 0x0b, 0x3c, - 0x5f, 0x45, 0x38, 0xa6, 0xf7, 0xc7, 0x03, 0x95, 0x05, 0xa5, 0x5c, 0x4e, 0xf0, 0x29, 0x6f, 0xea, 0x70, 0x06, 0xd4, - 0x90, 0xf6, 0xa9, 0x70, 0xc5, 0x3f, 0x4a, 0x59, 0x17, 0x3a, 0xb3, 0x90, 0xaa, 0x30, 0xd9, 0x91, 0xf0, 0xbf, 0x54, - 0xcc, 0x90, 0xe1, 0x85, 0x50, 0xa5, 0x0d, 0x7c, 0x6d, 0x8b, 0xae, 0x94, 0x17, 0x6d, 0x0b, 0x7d, 0x2c, 0x76, 0x65, - 0x4e, 0x00, 0xba, 0x01, 0x5a, 0x7b, 0xed, 0x82, 0xbb, 0x1b, 0xee, 0x65, 0x9f, 0x15, 0xf7, 0x6e, 0xda, 0x00, 0x07, - 0x5f, 0x20, 0xa7, 0xbe, 0x7f, 0x45, 0x71, 0xfe, 0x69, 0x2b, 0x1e, 0x2d, 0xc4, 0x94, 0x80, 0x09, 0x24, 0xe4, 0x1b, - 0x3e, 0xb6, 0x66, 0xc4, 0x3e, 0x7e, 0x08, 0x37, 0x4a, 0x09, 0x2b, 0x8d, 0x3c, 0x38, 0xca, 0xed, 0x37, 0x55, 0x86, - 0xe4, 0xb6, 0x9c, 0x83, 0xc2, 0x10, 0x0b, 0x07, 0xdc, 0x65, 0xae, 0x6c, 0x7f, 0xbc, 0x4a, 0x8f, 0xc2, 0x9e, 0xb8, - 0x50, 0xb1, 0x18, 0x6a, 0x64, 0xc4, 0x2b, 0x1e, 0xaa, 0xb3, 0xd2, 0xc4, 0x00, 0x19, 0x61, 0x80, 0x8e, 0x29, 0x6d, - 0x84, 0x40, 0x09, 0x01, 0x5b, 0x7e, 0xa8, 0xa3, 0x42, 0x13, 0xa1, 0x08, 0xa1, 0x25, 0xd2, 0x1c, 0x1d, 0x64, 0x65, - 0x86, 0xa4, 0xd2, 0x63, 0x76, 0x4c, 0x07, 0x96, 0x05, 0x58, 0x52, 0x29, 0x0a, 0x20, 0x9f, 0x8c, 0x51, 0xab, 0x88, - 0x50, 0xe2, 0xae, 0xbc, 0x4c, 0x1a, 0x0e, 0x58, 0xc3, 0x5c, 0x34, 0x17, 0x4b, 0xd6, 0x75, 0x38, 0x94, 0x21, 0x4d, - 0xae, 0x5a, 0x05, 0x79, 0xa7, 0x3f, 0x4f, 0x63, 0xce, 0x57, 0x04, 0x42, 0x9b, 0xfb, 0x91, 0xcb, 0x05, 0xc2, 0x8f, - 0x74, 0x6c, 0x8c, 0x91, 0x91, 0xb4, 0x76, 0x20, 0x75, 0x51, 0x22, 0x24, 0xc4, 0x95, 0x74, 0x41, 0x73, 0x3e, 0x14, - 0x22, 0x3e, 0x3b, 0x61, 0xae, 0x0f, 0x12, 0xb3, 0x44, 0xe5, 0xdf, 0x37, 0xcb, 0x61, 0xf5, 0x42, 0xf0, 0xb0, 0xd8, - 0xae, 0xaa, 0x1c, 0x28, 0x24, 0x12, 0xd6, 0xa8, 0x13, 0xe6, 0xce, 0x1b, 0xcb, 0xdf, 0x14, 0xc1, 0x9e, 0x27, 0x64, - 0x26, 0x18, 0xa5, 0x57, 0x51, 0xae, 0x54, 0xef, 0x94, 0x39, 0x8c, 0xdc, 0xf0, 0xee, 0xa6, 0xf8, 0xc1, 0x81, 0xbc, - 0x67, 0x53, 0x7a, 0xc4, 0xdb, 0xfd, 0x50, 0x4b, 0x9c, 0x53, 0x24, 0x39, 0x41, 0x29, 0xe8, 0xfe, 0xc3, 0x6b, 0x47, - 0x25, 0x31, 0xfe, 0xd0, 0xa2, 0xf4, 0x5b, 0x8b, 0xa7, 0xb9, 0x96, 0x33, 0x6d, 0xd2, 0xcc, 0x9c, 0x6f, 0x46, 0x15, - 0x9b, 0x2b, 0x63, 0x68, 0x5d, 0x70, 0x20, 0x00, 0x37, 0x83, 0x75, 0x2a, 0xad, 0xcf, 0xf5, 0x07, 0x08, 0x7d, 0xe3, - 0x3e, 0x28, 0xb3, 0x1d, 0x3c, 0x1a, 0x63, 0xc8, 0xeb, 0x67, 0x57, 0x75, 0xd0, 0x65, 0x44, 0x82, 0x00, 0x16, 0x7a, - 0xc8, 0xe1, 0x95, 0xba, 0x9c, 0xd9, 0xca, 0xec, 0xd1, 0xe6, 0xb5, 0x1c, 0x6f, 0x1d, 0x69, 0x38, 0x2e, 0x8e, 0x67, - 0x1f, 0x2c, 0x9d, 0x47, 0xe8, 0x48, 0xca, 0x8d, 0xf7, 0x4a, 0x20, 0xdf, 0x10, 0x19, 0xa8, 0xe7, 0xa2, 0x02, 0xb0, - 0x2b, 0x8b, 0xaa, 0xe4, 0x75, 0x78, 0xe8, 0xf9, 0x38, 0x32, 0x8f, 0x18, 0xe3, 0x10, 0x55, 0x46, 0x1e, 0x9d, 0xdc, - 0x2e, 0x2d, 0x32, 0x6a, 0x2e, 0x98, 0x5a, 0xcd, 0xbb, 0xea, 0x94, 0x07, 0xb2, 0xc9, 0xd7, 0x2b, 0x2d, 0xb4, 0x1e, - 0x89, 0x15, 0xdd, 0xac, 0x8a, 0x7a, 0x58, 0x20, 0x62, 0xbd, 0xff, 0x04, 0x91, 0x47, 0x2c, 0x1f, 0x64, 0xd4, 0x22, - 0x6d, 0xae, 0xc4, 0x4a, 0x29, 0x60, 0x76, 0x81, 0x42, 0x2b, 0xef, 0x10, 0x5c, 0xf9, 0x4d, 0x85, 0x44, 0xda, 0xc5, - 0x5d, 0x07, 0xea, 0x11, 0xbf, 0x35, 0xb2, 0x59, 0x1f, 0xa8, 0xe5, 0x7c, 0x2b, 0x2a, 0x1a, 0x22, 0x23, 0xd2, 0xd1, - 0x6f, 0x38, 0x01, 0xc3, 0x82, 0x0c, 0xe9, 0xf4, 0x3c, 0xf5, 0x58, 0xa0, 0xc5, 0x50, 0xe5, 0x54, 0x8c, 0x65, 0x52, - 0xdd, 0x0a, 0x96, 0x29, 0xb3, 0x50, 0x12, 0x5d, 0x41, 0xcb, 0xec, 0x35, 0xd8, 0x7c, 0xcf, 0x6a, 0x5b, 0x64, 0x44, - 0xc2, 0x35, 0xc2, 0x1f, 0x86, 0x31, 0x00, 0xaf, 0x12, 0xa5, 0xf3, 0xc0, 0x68, 0xc5, 0x24, 0xe6, 0x71, 0x0a, 0xaf, - 0x9b, 0x2a, 0x79, 0x81, 0x5b, 0xf3, 0xd4, 0xd4, 0x58, 0x7e, 0xff, 0xfa, 0xfb, 0x41, 0x53, 0x65, 0xad, 0x40, 0x7e, - 0xb2, 0x6e, 0xfd, 0xfb, 0x2e, 0xf7, 0x20, 0x6f, 0xd3, 0xfb, 0x7e, 0x1c, 0xf2, 0x0d, 0x04, 0x82, 0x51, 0x0a, 0xd3, - 0xc5, 0xfa, 0xb4, 0xc2, 0xe8, 0x7a, 0x49, 0xbb, 0x32, 0x7d, 0x40, 0xc2, 0xfb, 0x7a, 0xfb, 0x19, 0xa1, 0xcc, 0x12, - 0xfb, 0x50, 0x10, 0xc5, 0x4a, 0x94, 0x47, 0xe6, 0x67, 0x73, 0x6f, 0x45, 0x5c, 0x30, 0x53, 0xfd, 0x62, 0xf2, 0x30, - 0x24, 0x1c, 0x98, 0x99, 0x08, 0x07, 0xd6, 0xb4, 0xf0, 0xec, 0x6a, 0xc1, 0x9f, 0x96, 0x12, 0xe0, 0x11, 0xeb, 0x2a, - 0xfd, 0xbd, 0x8c, 0xa5, 0x18, 0xb1, 0xbd, 0x9d, 0xa1, 0xf4, 0x9c, 0x59, 0x07, 0x1d, 0x5e, 0x89, 0x82, 0x2d, 0xce, - 0xf4, 0x03, 0x33, 0x39, 0x8a, 0x0b, 0xaa, 0x25, 0xfc, 0xed, 0xad, 0xe2, 0xbe, 0x54, 0x3c, 0xa7, 0xb0, 0xef, 0x49, - 0xbe, 0xf8, 0x20, 0xed, 0xbd, 0xa8, 0x82, 0x56, 0x38, 0xb5, 0xc1, 0x0d, 0xf1, 0xd1, 0x9d, 0xf2, 0x50, 0x29, 0x42, - 0x08, 0xa3, 0xe8, 0x17, 0xf5, 0x08, 0x79, 0x81, 0xd7, 0xcb, 0xb7, 0x75, 0x7d, 0x48, 0x98, 0x82, 0xb8, 0xbe, 0xdb, - 0xc2, 0x19, 0x7d, 0x6a, 0x46, 0xcd, 0xdd, 0x71, 0xf5, 0x17, 0xea, 0x16, 0xef, 0x40, 0xb4, 0xc3, 0x74, 0x0f, 0x8f, - 0xeb, 0xfa, 0x68, 0x72, 0xd4, 0x85, 0x26, 0x6e, 0x35, 0xdb, 0xaf, 0xb4, 0x64, 0x9e, 0x06, 0x30, 0x68, 0x8c, 0xfa, - 0x7d, 0xc9, 0x1e, 0x8d, 0xef, 0x78, 0x4b, 0x64, 0x43, 0xb8, 0x0d, 0xe8, 0x70, 0x70, 0xa1, 0xa7, 0xfe, 0x46, 0xf6, - 0x6b, 0xb9, 0x04, 0xc7, 0x4b, 0xf1, 0x63, 0xdd, 0xf0, 0x47, 0x99, 0xd0, 0xec, 0x24, 0xa6, 0xc1, 0xfd, 0xb6, 0xb8, - 0xb2, 0xc2, 0x65, 0x12, 0x0a, 0x0b, 0x68, 0x40, 0x60, 0x01, 0x45, 0xd0, 0xe7, 0x30, 0x49, 0x94, 0x3d, 0x9c, 0xb3, - 0xdd, 0xdb, 0x7b, 0x71, 0x4c, 0x24, 0x9d, 0xd2, 0xe4, 0xe8, 0x07, 0x5e, 0x4c, 0x67, 0x51, 0x93, 0x68, 0xa4, 0x5f, - 0x31, 0x27, 0x2f, 0x1b, 0xe9, 0xc8, 0x00, 0x31, 0x67, 0x15, 0xa2, 0x0b, 0x69, 0xdf, 0x3f, 0x23, 0x32, 0xa0, 0x62, - 0x50, 0x37, 0xc3, 0x0e, 0xb1, 0x29, 0xe7, 0xb5, 0xeb, 0x07, 0x46, 0x4b, 0x7c, 0xb1, 0x3c, 0xca, 0xb0, 0xfc, 0x31, - 0x1f, 0xa3, 0xef, 0xbc, 0x95, 0x65, 0xe8, 0xc2, 0x72, 0x7e, 0x17, 0x93, 0xa5, 0x3a, 0x5c, 0x3d, 0x09, 0xe9, 0x7e, - 0x6a, 0xcd, 0x4b, 0xff, 0xb3, 0xe9, 0x82, 0xb4, 0xcf, 0x3c, 0xa4, 0x7e, 0x92, 0xc7, 0x68, 0xf7, 0x55, 0x2f, 0xac, - 0xd3, 0x85, 0x11, 0x66, 0xfa, 0xa8, 0x9a, 0x85, 0x0f, 0x55, 0x66, 0xcb, 0xc6, 0xd3, 0x52, 0xcc, 0x1f, 0x1d, 0xc1, - 0x1a, 0x82, 0x26, 0x24, 0x1b, 0xf7, 0x25, 0xf1, 0x83, 0xea, 0x82, 0xf1, 0x60, 0x87, 0xe5, 0xf5, 0xfc, 0x66, 0xd7, - 0xbe, 0xd3, 0xf2, 0xa9, 0xe0, 0x1f, 0x3c, 0xc4, 0xca, 0x9f, 0x0a, 0x87, 0x25, 0x2e, 0xbe, 0xb0, 0x52, 0x67, 0xbd, - 0x28, 0xa4, 0xad, 0xd5, 0xac, 0xa8, 0x65, 0x37, 0x64, 0xe5, 0x55, 0xde, 0xf2, 0x52, 0x0a, 0x7e, 0x4d, 0x45, 0x4e, - 0x72, 0x9d, 0x72, 0x31, 0x18, 0x78, 0x33, 0x27, 0xfd, 0x7a, 0x42, 0x1b, 0xb9, 0x81, 0x71, 0xfa, 0x3a, 0xb6, 0x2e, - 0x90, 0x04, 0x76, 0xf5, 0x91, 0x0b, 0x4f, 0x20, 0x91, 0xd5, 0xc7, 0x73, 0x36, 0xd6, 0xcd, 0x4e, 0xbe, 0x97, 0x77, - 0x19, 0x47, 0xf0, 0x4c, 0x21, 0xde, 0x9c, 0xd7, 0x06, 0xfc, 0x23, 0xef, 0x70, 0x6e, 0xe5, 0x7d, 0x50, 0x8d, 0xa1, - 0x35, 0x6c, 0x69, 0xe0, 0xab, 0x0b, 0x8c, 0x61, 0x02, 0xd5, 0x24, 0x08, 0x8e, 0xd6, 0x62, 0xbb, 0x20, 0x38, 0x96, - 0x51, 0x78, 0xb1, 0x3a, 0xe5, 0x17, 0x66, 0x53, 0x11, 0x45, 0x26, 0xfc, 0xc2, 0x0e, 0xd5, 0x66, 0x84, 0x43, 0xfc, - 0x58, 0x11, 0xe5, 0x43, 0x8d, 0x07, 0x20, 0x0e, 0x60, 0x7a, 0xd3, 0x88, 0x68, 0x7f, 0x8d, 0x1a, 0x05, 0x35, 0x3c, - 0x73, 0x97, 0xde, 0x59, 0x23, 0x2e, 0xeb, 0x6f, 0x0a, 0xcc, 0x2b, 0xb1, 0x6c, 0xaf, 0xac, 0xcb, 0x92, 0xf3, 0x3d, - 0x68, 0xe2, 0xb8, 0xd3, 0xce, 0x92, 0xb3, 0xe4, 0x00, 0x6d, 0xbb, 0xa7, 0xcd, 0x7c, 0x42, 0x72, 0x71, 0xb5, 0xeb, - 0x14, 0xa4, 0x32, 0xaf, 0x62, 0x23, 0x95, 0xe2, 0xbc, 0x73, 0x09, 0x38, 0xdc, 0x4f, 0xf1, 0xff, 0x55, 0xa2, 0xbe, - 0x9b, 0x5f, 0x10, 0xc6, 0x76, 0x52, 0xbf, 0x1c, 0x36, 0x6d, 0x61, 0x76, 0x70, 0xdd, 0xe2, 0xa6, 0xdd, 0x10, 0x55, - 0xd9, 0x5b, 0x6b, 0xbe, 0x34, 0x0f, 0x79, 0x61, 0x39, 0xb3, 0xd0, 0xea, 0xf3, 0xb8, 0x65, 0x44, 0xf9, 0xd4, 0x85, - 0xc3, 0xb1, 0xd0, 0x90, 0xcb, 0x9b, 0x43, 0x5d, 0xe4, 0xc7, 0xa4, 0xed, 0x01, 0x03, 0x49, 0x3d, 0xd1, 0xc6, 0x87, - 0x6e, 0xe6, 0xb6, 0x3b, 0x03, 0xe9, 0x7a, 0x39, 0x0d, 0x25, 0xb3, 0x18, 0xb8, 0x70, 0x34, 0xe6, 0xa9, 0x43, 0xa7, - 0x5d, 0xb1, 0x11, 0xd6, 0x1d, 0x0c, 0x57, 0x62, 0x54, 0x75, 0x18, 0xbb, 0xa6, 0xd5, 0x39, 0x56, 0xaa, 0xc7, 0xde, - 0x67, 0x1d, 0x91, 0xe0, 0x09, 0x05, 0x47, 0x1e, 0x78, 0x86, 0xcf, 0xea, 0xa0, 0xc3, 0xa3, 0x8e, 0xc0, 0xa1, 0xba, - 0x40, 0x5f, 0x1d, 0xc6, 0x40, 0x39, 0x82, 0x50, 0x44, 0xbe, 0x7b, 0xa0, 0x0e, 0xe1, 0x6b, 0x7e, 0x82, 0x99, 0x52, - 0x7a, 0x3e, 0x66, 0x7b, 0xf0, 0xe5, 0x80, 0xfb, 0xfd, 0x17, 0x9e, 0xd2, 0x35, 0x8c, 0xc3, 0x0f, 0xbf, 0xd5, 0x62, - 0xf9, 0xfd, 0x00, 0xf3, 0xed, 0x20, 0xd5, 0x25, 0x1c, 0xe5, 0x2a, 0xc0, 0x1f, 0x6d, 0x19, 0x77, 0x0d, 0x86, 0xf5, - 0x11, 0x14, 0x11, 0x1e, 0x71, 0x30, 0xdc, 0x2c, 0x05, 0x80, 0xe2, 0x3c, 0xac, 0x80, 0xc8, 0x42, 0x34, 0x3f, 0x2f, - 0x97, 0x58, 0x57, 0x65, 0x68, 0x4b, 0x4b, 0x36, 0x8f, 0x13, 0x31, 0x6c, 0x26, 0x49, 0x25, 0x44, 0xaf, 0x88, 0x18, - 0x11, 0x33, 0x43, 0xeb, 0xa5, 0xfd, 0x9e, 0xba, 0x2a, 0x08, 0xa3, 0xd6, 0x6d, 0xb8, 0xd7, 0xf5, 0x55, 0x2f, 0x6a, - 0xb5, 0x5f, 0x6b, 0x65, 0x00, 0xfb, 0x96, 0x7c, 0x80, 0x22, 0x09, 0x5b, 0xda, 0xf1, 0x6f, 0x07, 0x72, 0xd1, 0x3f, - 0x84, 0xb0, 0x89, 0x4d, 0x90, 0x73, 0x78, 0xa9, 0x75, 0xf6, 0x36, 0x10, 0xc2, 0x24, 0xd6, 0x6a, 0x3d, 0x82, 0x17, - 0x4d, 0x00, 0xa9, 0xd0, 0x3e, 0x63, 0xf9, 0x88, 0x54, 0x9c, 0x3f, 0x1f, 0x5a, 0x36, 0xb7, 0x3f, 0xe5, 0x13, 0x2b, - 0x47, 0x9c, 0xad, 0x5f, 0x2c, 0x49, 0x56, 0xf0, 0x5d, 0x22, 0xc1, 0x37, 0x96, 0xa1, 0xfa, 0xb4, 0x0f, 0xa0, 0x49, - 0x21, 0xd0, 0xc1, 0xe5, 0x5d, 0x8d, 0x0c, 0xb5, 0x58, 0x46, 0x75, 0xb4, 0xc7, 0x22, 0xd3, 0x17, 0x2f, 0xca, 0xea, - 0xb3, 0x08, 0x0d, 0x27, 0x16, 0xc3, 0x28, 0x95, 0x5e, 0x6c, 0xd1, 0xc6, 0x9f, 0xf4, 0x3f, 0xe6, 0x06, 0xa5, 0xea, - 0x78, 0x85, 0x5b, 0x35, 0x54, 0x87, 0xae, 0xd0, 0x1b, 0xd9, 0xca, 0xb1, 0x7f, 0x79, 0x67, 0x51, 0xc7, 0x9a, 0x36, - 0x08, 0x5e, 0x07, 0xfd, 0xcd, 0x14, 0x9c, 0xec, 0xfc, 0x9a, 0xe8, 0x14, 0x06, 0x08, 0x0a, 0x66, 0x08, 0xf6, 0x19, - 0xcd, 0xa6, 0xa5, 0x74, 0x67, 0xcd, 0x89, 0x3a, 0x36, 0xce, 0x8c, 0xb2, 0x76, 0x29, 0x9e, 0xda, 0x78, 0xeb, 0x05, - 0x3d, 0xf8, 0x5a, 0xbc, 0x58, 0x71, 0x52, 0x5b, 0x46, 0xc4, 0x0b, 0x8e, 0x87, 0xeb, 0x98, 0x43, 0xb5, 0x71, 0x6b, - 0xc1, 0x84, 0x09, 0xad, 0x86, 0xcd, 0xce, 0x5a, 0x4e, 0xf9, 0x5a, 0x1e, 0x27, 0x95, 0x4b, 0x7f, 0xac, 0x90, 0x00, - 0x08, 0x1d, 0x2d, 0x22, 0x09, 0x7c, 0x56, 0x18, 0xc6, 0x1c, 0x0f, 0x92, 0x25, 0xf3, 0x63, 0x25, 0x8f, 0x00, 0x33, - 0x31, 0x5c, 0xbc, 0x0d, 0xbd, 0x7a, 0x82, 0x2e, 0xd9, 0xc1, 0x46, 0xdd, 0x20, 0x08, 0x12, 0xec, 0x00, 0x7f, 0xe1, - 0x7d, 0x17, 0x26, 0xe7, 0xfc, 0x66, 0xeb, 0xf0, 0xff, 0x04, 0x4f, 0xe6, 0x61, 0x6d, 0xbb, 0x1f, 0x6c, 0xd4, 0x97, - 0xff, 0x3f, 0xd5, 0x35, 0xb4, 0x0e, 0x7c, 0xf8, 0xc0, 0x85, 0xc7, 0xab, 0x55, 0x3d, 0x5a, 0x6d, 0xed, 0x80, 0x21, - 0x99, 0x38, 0x51, 0x56, 0xec, 0xa8, 0xde, 0xa1, 0xee, 0x1f, 0x1c, 0xee, 0x8f, 0x1c, 0xa0, 0xfe, 0xc1, 0xc4, 0xdb, - 0x48, 0x23, 0xdd, 0xfd, 0x22, 0x64, 0x62, 0x3d, 0xea, 0x20, 0x57, 0x29, 0xfd, 0xfc, 0xdc, 0xb3, 0x75, 0x84, 0xa5, - 0xab, 0x74, 0x70, 0x7f, 0xd1, 0x59, 0x7b, 0xb0, 0xc1, 0xe5, 0x8b, 0xec, 0x56, 0xad, 0x7d, 0x52, 0xba, 0xca, 0x1a, - 0x6f, 0x02, 0x10, 0x60, 0xab, 0xcc, 0x64, 0xe5, 0xe9, 0x9e, 0x52, 0xc2, 0xbb, 0xd6, 0xe6, 0xec, 0xaf, 0xd7, 0xc1, - 0x29, 0x63, 0x6d, 0x77, 0x71, 0x6b, 0xbd, 0x00, 0x41, 0x39, 0xf7, 0x1a, 0xca, 0x29, 0x84, 0x78, 0x49, 0x0d, 0x2e, - 0x87, 0xb1, 0xf1, 0x10, 0x39, 0x72, 0x88, 0x6e, 0x23, 0x82, 0x75, 0x95, 0xb6, 0x2a, 0x8e, 0xbd, 0x96, 0x27, 0x66, - 0x0b, 0xe3, 0x26, 0xa6, 0x14, 0x16, 0x15, 0x18, 0x79, 0x1a, 0x76, 0x38, 0xdb, 0x11, 0x7a, 0x34, 0x6b, 0x5b, 0x90, - 0x26, 0xec, 0x97, 0xfa, 0x7d, 0x58, 0x82, 0xb1, 0xf9, 0xaa, 0x85, 0xd0, 0x4b, 0xe0, 0x34, 0x79, 0x6f, 0xc8, 0xaf, - 0x2e, 0xf4, 0x8c, 0x70, 0x59, 0x24, 0xf7, 0x58, 0x08, 0x42, 0x65, 0x6b, 0xbb, 0x4c, 0xba, 0x99, 0x63, 0x88, 0xe7, - 0x19, 0x63, 0x68, 0xe1, 0x05, 0x81, 0x4c, 0x13, 0x94, 0x32, 0xfc, 0x16, 0xe1, 0x71, 0x86, 0xb3, 0x43, 0x6e, 0xa6, - 0xc3, 0x48, 0xb8, 0xc2, 0xed, 0x04, 0x99, 0xa5, 0xf9, 0x44, 0xe9, 0xc7, 0x54, 0x75, 0xd8, 0x67, 0x26, 0x21, 0x6a, - 0x8f, 0x58, 0x8f, 0xa7, 0x34, 0x6c, 0x67, 0xfc, 0x9c, 0xe7, 0x52, 0x6c, 0x20, 0xee, 0xae, 0xdc, 0x25, 0xd7, 0xc4, - 0x29, 0xb1, 0xb4, 0xca, 0x38, 0xa4, 0xd0, 0x8e, 0x85, 0xb6, 0xf1, 0x90, 0x1e, 0x44, 0xda, 0xae, 0x0f, 0x49, 0x95, - 0x4e, 0x1e, 0xf3, 0x23, 0x62, 0xc8, 0x4c, 0xbf, 0xc0, 0xda, 0xfe, 0x72, 0xf3, 0x21, 0x04, 0x2a, 0x12, 0x3b, 0x77, - 0x04, 0x3e, 0x0d, 0xb0, 0x79, 0x29, 0x2d, 0x85, 0x56, 0x85, 0xce, 0x55, 0x5b, 0xbd, 0x34, 0x94, 0x0d, 0x51, 0x04, - 0x92, 0x59, 0x96, 0xf0, 0x51, 0xd6, 0x30, 0xc8, 0xa9, 0xdf, 0x35, 0x20, 0xdb, 0x1e, 0x06, 0xeb, 0x7b, 0xaa, 0x2c, - 0xf5, 0xfd, 0xd9, 0x4f, 0x9f, 0xf0, 0xb1, 0x0e, 0x61, 0x95, 0x01, 0xd7, 0x6c, 0x5e, 0xe3, 0xa1, 0x77, 0x9f, 0xcc, - 0xa0, 0x7e, 0xc2, 0x91, 0xbe, 0xc1, 0xd7, 0xc8, 0xfd, 0xcc, 0xcb, 0xf2, 0xca, 0x7b, 0x49, 0x9e, 0x6d, 0x53, 0xea, - 0x27, 0x2d, 0x56, 0xbc, 0x81, 0x3f, 0x75, 0x7e, 0x68, 0xc5, 0xf7, 0x65, 0x75, 0x67, 0xdb, 0x99, 0x33, 0x2c, 0x33, - 0xd8, 0x83, 0x19, 0xba, 0xeb, 0xa3, 0x56, 0x2a, 0xa4, 0x5e, 0xe9, 0xcb, 0x07, 0xef, 0x53, 0xef, 0x53, 0x26, 0x4d, - 0x74, 0x13, 0x98, 0xa2, 0xd2, 0xd7, 0x21, 0xca, 0x0e, 0x69, 0x62, 0xda, 0xa1, 0x4a, 0x14, 0x1d, 0x5e, 0x98, 0x65, - 0x29, 0xc0, 0xf0, 0x8d, 0xe5, 0x53, 0x86, 0x6b, 0x25, 0xa9, 0x20, 0xd4, 0x1a, 0xc4, 0x67, 0x93, 0xe9, 0x7d, 0x99, - 0x1b, 0x0a, 0x58, 0xb0, 0xf8, 0x3a, 0x86, 0x5d, 0xa4, 0x7f, 0x38, 0x7e, 0x27, 0xc1, 0x39, 0xe1, 0x70, 0x64, 0x03, - 0x01, 0x94, 0x69, 0xbb, 0xe0, 0xe2, 0x7e, 0x83, 0x3d, 0xcc, 0xd2, 0x7f, 0x42, 0x6a, 0xc1, 0x69, 0xa0, 0x97, 0xe8, - 0xff, 0xba, 0x33, 0x7f, 0x2a, 0x5f, 0x2f, 0x2c, 0xe6, 0x44, 0xb8, 0xc5, 0xd9, 0x57, 0x96, 0x59, 0xe5, 0x8a, 0xfb, - 0x03, 0x23, 0x13, 0xad, 0x5d, 0x9f, 0x1f, 0xac, 0x56, 0xd4, 0x2a, 0xd4, 0xd0, 0x57, 0xee, 0x7f, 0xa6, 0x7b, 0xb9, - 0x67, 0xc6, 0x3c, 0x14, 0x73, 0x87, 0x75, 0xd1, 0xd0, 0xf8, 0x0c, 0xd1, 0x10, 0xa5, 0xc6, 0x6a, 0xc0, 0x66, 0x4c, - 0xea, 0xd7, 0x83, 0x08, 0x4b, 0xe9, 0x9c, 0x18, 0x55, 0x6a, 0x91, 0x41, 0x82, 0xc9, 0xf1, 0x5c, 0xda, 0x1c, 0x0a, - 0x44, 0xd0, 0xcc, 0x6b, 0x68, 0xf4, 0x35, 0x1e, 0x56, 0xb8, 0xd1, 0xcd, 0x1e, 0x31, 0x64, 0x04, 0x41, 0x65, 0xd9, - 0x18, 0xd9, 0x2e, 0x46, 0x51, 0x38, 0xf5, 0xb3, 0x43, 0x41, 0xf1, 0xcb, 0x99, 0x2f, 0x4d, 0x76, 0xdc, 0x3d, 0x1a, - 0x80, 0xa2, 0x58, 0x97, 0x78, 0xd9, 0x66, 0x22, 0x37, 0xb9, 0xc1, 0x94, 0x20, 0x88, 0x39, 0xfc, 0x09, 0xb2, 0xa4, - 0x88, 0xe9, 0x22, 0x6e, 0x2e, 0xcd, 0xc5, 0xa8, 0x4c, 0x76, 0xf5, 0xc0, 0x6d, 0x68, 0x54, 0xab, 0x89, 0x5e, 0x6b, - 0xdd, 0x0f, 0x0a, 0xd1, 0x09, 0x8b, 0x27, 0xf2, 0x8a, 0x85, 0x48, 0x82, 0x81, 0x00, 0x45, 0xdb, 0xc2, 0x28, 0x0a, - 0xbd, 0x16, 0xd3, 0xd9, 0x72, 0x7e, 0x2e, 0xd3, 0x50, 0x34, 0x3a, 0xe3, 0x96, 0x81, 0x55, 0x3f, 0x6d, 0x96, 0x1f, - 0x78, 0xfc, 0x4f, 0x62, 0xc2, 0xdb, 0x1e, 0x7a, 0x06, 0xe2, 0x53, 0x0f, 0x28, 0xd3, 0x5d, 0x02, 0x85, 0xe9, 0xb9, - 0x8b, 0x50, 0x22, 0x29, 0xea, 0xc6, 0x9c, 0x58, 0x72, 0x1f, 0x95, 0xf8, 0xbe, 0x1a, 0x1f, 0xc2, 0x11, 0xd5, 0x87, - 0xc4, 0x15, 0x05, 0x2c, 0xf2, 0xac, 0x64, 0xf3, 0x39, 0xcb, 0xb8, 0x0e, 0x8b, 0x35, 0x73, 0xce, 0x1b, 0x5e, 0x96, - 0xfa, 0xa0, 0x64, 0x04, 0xef, 0x07, 0x73, 0x08, 0x55, 0xae, 0xc8, 0x0c, 0xf9, 0x55, 0x89, 0xda, 0x0a, 0x58, 0xe3, - 0x0a, 0xc2, 0x6c, 0xed, 0x15, 0xaf, 0x6f, 0x8b, 0x95, 0xfe, 0x40, 0xae, 0x11, 0xf7, 0x70, 0x08, 0x00, 0x0c, 0xfb, - 0xdd, 0x09, 0x8a, 0x91, 0x0a, 0x17, 0xe6, 0x62, 0x68, 0x40, 0xc2, 0x36, 0x48, 0x99, 0xed, 0xc7, 0xf9, 0xf0, 0xee, - 0x9f, 0xd6, 0x9c, 0x1d, 0xac, 0x95, 0x70, 0xed, 0xe8, 0x2a, 0x13, 0xe4, 0xe5, 0x43, 0x94, 0x9d, 0xb9, 0x6d, 0xae, - 0x96, 0x05, 0x51, 0x69, 0x31, 0x9e, 0xad, 0xc4, 0xfd, 0x32, 0x85, 0xc7, 0xce, 0x22, 0xd8, 0x99, 0x97, 0xe0, 0x12, - 0x10, 0x7d, 0x90, 0xf1, 0x91, 0x0d, 0x12, 0xbd, 0xf2, 0x6c, 0xfc, 0x59, 0x75, 0xef, 0x51, 0x9b, 0x2a, 0x52, 0xbb, - 0x1e, 0xb8, 0x3b, 0x94, 0xa4, 0x82, 0x69, 0x77, 0x03, 0xd6, 0xcc, 0xeb, 0x89, 0xc9, 0x37, 0x0a, 0xe2, 0x06, 0x38, - 0xfb, 0x6e, 0x1c, 0x68, 0x1a, 0x58, 0x6f, 0x3e, 0xa2, 0x68, 0x10, 0x6a, 0x44, 0x9c, 0x9b, 0xf5, 0xfa, 0xa5, 0xdf, - 0x81, 0x00, 0xa4, 0x60, 0x56, 0x12, 0xbc, 0x77, 0xe5, 0xa4, 0x10, 0x84, 0xd8, 0x02, 0x88, 0xe9, 0x06, 0x12, 0xc7, - 0x11, 0xe5, 0x1a, 0xcf, 0xbe, 0x59, 0x7a, 0xf4, 0xa2, 0x23, 0x76, 0x7f, 0x09, 0xac, 0xe9, 0x65, 0x07, 0xdb, 0xb9, - 0x09, 0x49, 0x85, 0x32, 0xf4, 0xaa, 0x9e, 0xdd, 0xb0, 0xb9, 0x75, 0x2c, 0x0b, 0x3d, 0x7a, 0x08, 0x72, 0xc9, 0xbc, - 0xb7, 0xd5, 0x18, 0x08, 0xa5, 0x9b, 0x5f, 0x08, 0x14, 0x47, 0xeb, 0x5f, 0x68, 0x93, 0x0c, 0x6d, 0x9c, 0xdb, 0xb4, - 0xb3, 0x66, 0xb7, 0x29, 0xac, 0x5c, 0x72, 0x73, 0xbd, 0x38, 0xa6, 0xa8, 0xa7, 0xf2, 0xbd, 0xd6, 0xb2, 0x67, 0x63, - 0xa0, 0x86, 0xf6, 0xc8, 0x27, 0x85, 0xd0, 0x1b, 0xb6, 0x62, 0x69, 0x24, 0x93, 0xe6, 0xce, 0x3b, 0x27, 0xd4, 0xe6, - 0x61, 0x87, 0xc4, 0x09, 0x73, 0xeb, 0xbf, 0xcf, 0x23, 0x29, 0x8b, 0xf7, 0x29, 0x14, 0x6e, 0x86, 0xea, 0x86, 0xb1, - 0xe8, 0x38, 0x01, 0xb7, 0x95, 0xf5, 0xd3, 0x8c, 0x44, 0xac, 0x16, 0x16, 0xc6, 0x33, 0x80, 0xa9, 0x98, 0x22, 0x6e, - 0x55, 0x30, 0xd4, 0x20, 0x39, 0x57, 0x83, 0x60, 0xa6, 0xd7, 0x8c, 0x9d, 0x79, 0x99, 0xb7, 0xd0, 0xd6, 0xc6, 0x2c, - 0x2c, 0xd4, 0x6c, 0x4c, 0xcd, 0x93, 0x49, 0x01, 0x4b, 0x23, 0xe8, 0xf6, 0x98, 0x1e, 0xee, 0xae, 0x91, 0xef, 0x96, - 0x23, 0x67, 0x17, 0x83, 0xf9, 0xd8, 0xcb, 0xec, 0x71, 0xea, 0xc1, 0xcb, 0x04, 0x33, 0x42, 0x85, 0xad, 0xe2, 0x02, - 0xda, 0xb3, 0xa6, 0xff, 0xc0, 0x37, 0xf1, 0x31, 0x07, 0x37, 0x66, 0xec, 0xad, 0x59, 0xba, 0xe2, 0x3d, 0x1d, 0x23, - 0x64, 0x11, 0x23, 0xf2, 0x9c, 0x35, 0xc5, 0xdc, 0x4a, 0x15, 0xe3, 0x1e, 0x14, 0x82, 0xe5, 0x2b, 0x4c, 0x05, 0x10, - 0x0e, 0x66, 0x37, 0x1a, 0x1c, 0x62, 0x7d, 0xdc, 0xba, 0x5b, 0x20, 0x04, 0x06, 0x50, 0x5d, 0x9c, 0x73, 0x34, 0xd1, - 0x01, 0x90, 0xfc, 0x3e, 0x12, 0x00, 0x49, 0x60, 0x86, 0x22, 0x01, 0x46, 0xaf, 0x5a, 0xfa, 0x9a, 0x17, 0x6b, 0x8c, - 0x8c, 0xd8, 0x23, 0x08, 0xb6, 0x72, 0x8f, 0x2c, 0x90, 0x66, 0x73, 0xaf, 0x75, 0xc2, 0xb7, 0x67, 0x45, 0x25, 0x0e, - 0x2e, 0xbf, 0x2a, 0x23, 0xe9, 0x9f, 0x0c, 0x6a, 0xac, 0x63, 0xe5, 0x29, 0xfd, 0x98, 0xa9, 0xa3, 0x47, 0x77, 0x69, - 0x9a, 0x4e, 0xe6, 0xa0, 0xd8, 0x43, 0x36, 0x28, 0xab, 0x64, 0xec, 0xc4, 0x39, 0x74, 0x22, 0xa9, 0x7f, 0x1c, 0xbd, - 0xbc, 0x53, 0x8f, 0xa2, 0x34, 0xb7, 0xeb, 0x09, 0xb5, 0x72, 0xaa, 0xdd, 0x08, 0xbc, 0x49, 0x79, 0x26, 0x75, 0xc6, - 0x96, 0xfa, 0xa5, 0x42, 0x2a, 0x3b, 0x35, 0x26, 0xb1, 0x93, 0xf3, 0x32, 0xe7, 0xe8, 0x29, 0xbf, 0x10, 0xc6, 0x81, - 0xb1, 0x3f, 0x9d, 0xb6, 0xde, 0xaf, 0xd9, 0x19, 0xe2, 0xf1, 0x6a, 0xaa, 0xf6, 0x21, 0x5d, 0xab, 0x26, 0xa6, 0x40, - 0xd3, 0x9e, 0xa6, 0xff, 0xc9, 0x80, 0x3e, 0x0f, 0xc1, 0x9e, 0xe9, 0x27, 0x21, 0xd5, 0x0e, 0xa2, 0xfd, 0x41, 0x0b, - 0xaf, 0xf0, 0x35, 0x4a, 0xa8, 0xfe, 0xce, 0x09, 0xd0, 0xf1, 0xbd, 0x6e, 0x10, 0x5b, 0x92, 0xb8, 0x98, 0x8b, 0x54, - 0x76, 0x8e, 0x19, 0xd5, 0x40, 0x2e, 0x88, 0x14, 0xcf, 0x75, 0x1a, 0x95, 0x85, 0x2c, 0x79, 0x83, 0x1b, 0x3f, 0xfb, - 0x35, 0x53, 0x28, 0xfc, 0x6a, 0x38, 0x08, 0x58, 0x06, 0x90, 0x30, 0xef, 0x5e, 0x69, 0xce, 0x99, 0x9d, 0x8d, 0x18, - 0xb2, 0x00, 0x2e, 0x75, 0xec, 0x23, 0x74, 0x12, 0x00, 0x10, 0x1d, 0x13, 0x63, 0x20, 0xaf, 0x76, 0x54, 0xff, 0x03, - 0x1c, 0x7a, 0x27, 0xbd, 0x5a, 0x73, 0x37, 0x81, 0x28, 0x42, 0x40, 0x80, 0xc4, 0xde, 0x50, 0x10, 0x45, 0xcb, 0x41, - 0x24, 0x55, 0x62, 0x27, 0xb4, 0x15, 0x9a, 0x05, 0x37, 0xb2, 0x11, 0x69, 0x04, 0xd0, 0x2b, 0xb8, 0x10, 0x33, 0x02, - 0x65, 0x1e, 0x47, 0x1a, 0xbf, 0xa0, 0xc4, 0xcc, 0x4b, 0xc5, 0xe8, 0x73, 0x8a, 0x7a, 0xef, 0x41, 0x74, 0xcf, 0xcd, - 0xa3, 0xd6, 0xc7, 0x84, 0x10, 0x3d, 0x01, 0x6b, 0x28, 0xab, 0x9f, 0xa2, 0x0c, 0x30, 0x1a, 0xa0, 0x6c, 0xef, 0x70, - 0x1e, 0xb0, 0x7c, 0xa9, 0x79, 0x52, 0x0f, 0x1d, 0xf3, 0x88, 0x5c, 0x3a, 0x9f, 0xf7, 0xeb, 0xb8, 0x5e, 0xd4, 0x0e, - 0x2a, 0x04, 0x3c, 0x56, 0x2f, 0xd5, 0x8d, 0x20, 0x37, 0x14, 0xff, 0x45, 0xc5, 0xd4, 0x18, 0xf6, 0x95, 0x5f, 0x4c, - 0x27, 0x1d, 0x6a, 0x87, 0xf5, 0x2e, 0x72, 0x75, 0x2a, 0x4a, 0x00, 0x4e, 0xbb, 0x1d, 0xda, 0x39, 0xb3, 0xe3, 0x6f, - 0x77, 0xfd, 0x68, 0x95, 0x95, 0x6a, 0x51, 0xe7, 0x59, 0x43, 0x41, 0x79, 0x39, 0x1d, 0xff, 0x0b, 0x4f, 0xf6, 0xf2, - 0x64, 0x30, 0xa7, 0x16, 0x61, 0x9c, 0xba, 0xf3, 0xec, 0xfd, 0xc1, 0xdb, 0x61, 0x4b, 0x88, 0x9d, 0xea, 0xe6, 0xd7, - 0x7a, 0xe4, 0x8b, 0xa9, 0xdb, 0x7a, 0x82, 0x1b, 0x37, 0xb7, 0xce, 0xd8, 0xab, 0xc7, 0xd0, 0x31, 0x01, 0xe0, 0xad, - 0x25, 0x8a, 0xa2, 0x22, 0xfc, 0xfb, 0xe3, 0xe9, 0xa1, 0xe6, 0xf4, 0xa0, 0x6f, 0xe3, 0x9d, 0x18, 0x34, 0x05, 0x26, - 0x58, 0x07, 0x0c, 0xf3, 0x01, 0xfd, 0xae, 0xa4, 0x1a, 0xdf, 0xf7, 0xa2, 0xc8, 0x41, 0x4c, 0x66, 0xf0, 0xa5, 0xf1, - 0x4d, 0x56, 0x64, 0x7c, 0x51, 0x01, 0xda, 0xbc, 0x4c, 0xb6, 0x0b, 0xc7, 0x30, 0x85, 0x69, 0xb7, 0xee, 0x7b, 0xec, - 0xa0, 0x5c, 0xdc, 0x1a, 0xc6, 0x6f, 0x24, 0x82, 0xec, 0x8d, 0x65, 0xe6, 0x03, 0xc5, 0xaf, 0x9f, 0x3a, 0x26, 0xb9, - 0xe7, 0x0d, 0xf7, 0x87, 0xa8, 0xa9, 0xd0, 0xf9, 0x5c, 0x79, 0xb7, 0x9c, 0xdf, 0x85, 0xd7, 0xaa, 0x50, 0x77, 0x64, - 0xc9, 0x48, 0xd3, 0x69, 0xe8, 0xe9, 0x5a, 0x2d, 0xda, 0x9c, 0xb8, 0x0e, 0xda, 0xb6, 0xd8, 0x04, 0x12, 0x4b, 0xce, - 0x60, 0xa6, 0xe4, 0x4d, 0x2c, 0x08, 0x0e, 0x1d, 0x41, 0xa1, 0xbb, 0x28, 0x0e, 0x91, 0x30, 0x60, 0xb3, 0x43, 0x85, - 0xdd, 0x47, 0xb0, 0xf1, 0xd5, 0xbc, 0x50, 0xe4, 0x19, 0xab, 0xc2, 0x9c, 0xa9, 0x66, 0x56, 0xb5, 0x5f, 0x0c, 0x70, - 0xf6, 0x4f, 0xb8, 0x97, 0x4e, 0x0c, 0xd6, 0x83, 0x4c, 0x49, 0x0d, 0x9d, 0x72, 0x8a, 0x6a, 0x1e, 0xb1, 0x8d, 0x35, - 0x14, 0x50, 0x3b, 0x3c, 0x32, 0x1c, 0x6e, 0x7a, 0x6f, 0x7c, 0x3e, 0xf0, 0x2c, 0x36, 0xf0, 0xce, 0x21, 0xb0, 0x10, - 0xfb, 0x51, 0xbb, 0x38, 0xb4, 0x35, 0x9b, 0xa1, 0xb0, 0x8d, 0x86, 0x42, 0x3a, 0xb3, 0x17, 0x24, 0xd3, 0x41, 0x1a, - 0x48, 0x5b, 0x87, 0x89, 0xc6, 0xde, 0x57, 0x07, 0xba, 0x03, 0x8d, 0x37, 0x3d, 0xa2, 0xd0, 0xc6, 0xae, 0x1a, 0xc0, - 0x2b, 0x3a, 0x97, 0xe8, 0x66, 0xdf, 0x12, 0xd8, 0xaf, 0x36, 0x83, 0x1b, 0xcb, 0x97, 0x00, 0xa6, 0x14, 0x90, 0x6d, - 0xd9, 0xf8, 0xdc, 0x73, 0x3e, 0x90, 0x4d, 0x98, 0x1c, 0x8d, 0xa3, 0x76, 0x11, 0x76, 0x69, 0x8d, 0xb7, 0x1c, 0x4d, - 0xf5, 0xcf, 0xa5, 0x08, 0x0b, 0xac, 0x2e, 0x98, 0xb6, 0xc5, 0x47, 0x23, 0xac, 0x49, 0x51, 0xb7, 0x87, 0x05, 0xd4, - 0xd8, 0x61, 0x21, 0x95, 0xd6, 0x6b, 0x89, 0x8b, 0x95, 0x18, 0x5f, 0xd3, 0x53, 0x28, 0x0e, 0x2c, 0x3b, 0x47, 0x40, - 0xe3, 0xc1, 0x3a, 0xdf, 0x0a, 0x5f, 0x2a, 0xdc, 0x2f, 0xe5, 0xa8, 0xe4, 0x99, 0x26, 0xce, 0xf5, 0x02, 0xce, 0x08, - 0x89, 0xcb, 0x0f, 0xd8, 0x38, 0x96, 0x00, 0x5b, 0xa6, 0xf7, 0xff, 0x50, 0x87, 0x05, 0xdb, 0x21, 0xc0, 0x2b, 0xee, - 0xa2, 0x7d, 0xa0, 0x5c, 0x01, 0xa4, 0xc9, 0x51, 0x86, 0x5c, 0x7e, 0xd6, 0xbd, 0x42, 0xc6, 0xb4, 0x4f, 0xa2, 0x63, - 0xcb, 0x76, 0x76, 0x20, 0x99, 0x23, 0x43, 0xc2, 0xb8, 0xf5, 0x2a, 0x65, 0xe1, 0x6e, 0x80, 0x63, 0x44, 0xb1, 0xb4, - 0x62, 0x7e, 0x99, 0x29, 0xf2, 0x0a, 0xd8, 0x8d, 0x93, 0xa6, 0xbd, 0x36, 0xa6, 0x4b, 0x64, 0x63, 0x30, 0x76, 0xaa, - 0x08, 0x2c, 0x8c, 0x41, 0x55, 0xb2, 0x20, 0xfc, 0x63, 0x4f, 0x38, 0xe0, 0x7f, 0x72, 0x26, 0x28, 0x3f, 0x1e, 0xcb, - 0x79, 0x52, 0x61, 0x1e, 0xc8, 0x14, 0xf6, 0x70, 0xfa, 0xd2, 0xbf, 0xa2, 0x4f, 0xa9, 0xc0, 0x9a, 0x24, 0xc2, 0xb8, - 0x01, 0x06, 0x55, 0x1b, 0x00, 0x74, 0x83, 0x14, 0x6f, 0x12, 0xd0, 0xe4, 0x26, 0xb4, 0x0a, 0xe5, 0x28, 0x1d, 0xb0, - 0x52, 0xe4, 0x66, 0xd4, 0x14, 0xc1, 0x46, 0xda, 0x41, 0x8a, 0x12, 0x0c, 0x3b, 0xa9, 0x06, 0x69, 0x95, 0x7a, 0x38, - 0x08, 0x7f, 0x4d, 0x80, 0x0e, 0x40, 0x1e, 0x96, 0xff, 0x65, 0x32, 0xc2, 0xcb, 0x23, 0x2b, 0xb9, 0x47, 0xcd, 0x51, - 0x63, 0x62, 0x1a, 0x3a, 0xb9, 0xa5, 0x13, 0xde, 0xd5, 0x1c, 0x71, 0x36, 0x0e, 0x6a, 0xab, 0x9a, 0x0f, 0x06, 0x43, - 0xa7, 0x4e, 0x3a, 0x82, 0xc2, 0x47, 0x39, 0x06, 0x13, 0xda, 0xcd, 0x14, 0x3d, 0x0f, 0x7b, 0x99, 0x97, 0x93, 0x6e, - 0x36, 0x53, 0x00, 0xb6, 0x5a, 0x0a, 0x5b, 0x86, 0x81, 0x31, 0x8c, 0x3f, 0x02, 0x72, 0xc3, 0xa7, 0xcf, 0x4b, 0x23, - 0x8f, 0x4a, 0x2f, 0x6f, 0x7e, 0xf8, 0xf8, 0x31, 0x80, 0xc1, 0x50, 0xd1, 0xe0, 0xc3, 0x67, 0x7d, 0x35, 0xbe, 0x93, - 0xc9, 0xc6, 0x1a, 0xe3, 0x1c, 0x44, 0x79, 0x16, 0xda, 0x91, 0x9f, 0x95, 0x75, 0x51, 0x0c, 0xdb, 0xd7, 0x17, 0x95, - 0xd7, 0x97, 0x22, 0xa4, 0x6a, 0x41, 0x2d, 0x6f, 0x71, 0x8a, 0x8d, 0x43, 0x28, 0x34, 0xe6, 0xa0, 0x08, 0x19, 0x8e, - 0x22, 0x6e, 0xee, 0x34, 0x14, 0x03, 0x52, 0x22, 0x12, 0xe6, 0xd4, 0x69, 0xed, 0x6d, 0x4e, 0xb0, 0xd9, 0x3b, 0x53, - 0x4b, 0x0d, 0xaf, 0x31, 0x61, 0x65, 0xe7, 0x48, 0x91, 0xc0, 0xa5, 0x2d, 0xbb, 0xb5, 0xc8, 0x54, 0xdd, 0x8d, 0x86, - 0xcc, 0x99, 0x15, 0x14, 0xab, 0x97, 0xcf, 0x0a, 0x87, 0x56, 0x50, 0xfc, 0xa6, 0xc1, 0x06, 0xc4, 0x38, 0x76, 0x7f, - 0x7c, 0xae, 0x82, 0x7f, 0xf8, 0x39, 0xdc, 0x93, 0x3f, 0xa6, 0x17, 0xac, 0x52, 0xcc, 0x06, 0x35, 0xdf, 0x25, 0xca, - 0xf4, 0xda, 0x9c, 0x09, 0x4c, 0x1c, 0xf3, 0x82, 0x1e, 0x3e, 0x4b, 0x5f, 0x14, 0xa3, 0xcd, 0xa0, 0x22, 0x65, 0x52, - 0x01, 0x44, 0x34, 0xe9, 0x0e, 0xa9, 0xd7, 0x58, 0xa4, 0x2c, 0x9b, 0x62, 0x9b, 0xe6, 0x4a, 0x6d, 0x1f, 0x3b, 0x6a, - 0x6a, 0xdd, 0x90, 0x48, 0x3c, 0xa4, 0xf9, 0x0f, 0xc5, 0xd7, 0x31, 0x16, 0x84, 0x48, 0x21, 0xad, 0x2f, 0xce, 0x74, - 0x7a, 0x7c, 0x36, 0x8a, 0x3b, 0x1a, 0xc7, 0xa3, 0xfc, 0x6b, 0x8d, 0xe9, 0x54, 0xb0, 0x1f, 0xd2, 0x19, 0x12, 0x47, - 0x9e, 0x1b, 0x17, 0xdd, 0xad, 0xcf, 0x3a, 0x7c, 0x10, 0xb5, 0xbc, 0xe4, 0x1d, 0xbb, 0x21, 0x54, 0x32, 0x98, 0xeb, - 0x94, 0x40, 0x6b, 0xea, 0x0f, 0xfc, 0x0d, 0x5f, 0xb8, 0x51, 0x5d, 0x3a, 0x24, 0x5a, 0xd8, 0x90, 0x60, 0x3a, 0x49, - 0x8a, 0xb4, 0xe0, 0x12, 0x26, 0x9b, 0xa0, 0x58, 0xbb, 0xb0, 0xe0, 0xb3, 0xb0, 0x38, 0x64, 0xf3, 0x8b, 0x2e, 0xc4, - 0x78, 0x07, 0xd6, 0x19, 0xaa, 0x3c, 0x73, 0x8e, 0x41, 0x33, 0x78, 0x61, 0x61, 0xaf, 0x0a, 0x72, 0x88, 0x0b, 0xeb, - 0x80, 0xca, 0xc7, 0xa8, 0x91, 0xf1, 0xe8, 0xad, 0x4d, 0x21, 0x3d, 0xd0, 0x7d, 0xff, 0xce, 0x6f, 0xaf, 0x22, 0x67, - 0x60, 0x88, 0x0b, 0x91, 0x17, 0x1e, 0xcd, 0x6c, 0x70, 0x81, 0x71, 0xe1, 0x6d, 0x8e, 0x7a, 0xf1, 0x1c, 0xce, 0xdb, - 0x37, 0x54, 0x6a, 0x78, 0xc5, 0x94, 0x8e, 0x13, 0x14, 0xdc, 0xa4, 0x97, 0x15, 0xc8, 0xbb, 0x93, 0xb4, 0xd8, 0x35, - 0xbb, 0x14, 0xb2, 0x21, 0x45, 0x67, 0xed, 0x6e, 0x70, 0xca, 0xdc, 0x9e, 0x49, 0x87, 0x65, 0x4e, 0xd1, 0xcc, 0x24, - 0x0a, 0x3d, 0x87, 0x28, 0xc6, 0x8c, 0xa1, 0x49, 0xb5, 0x65, 0x5e, 0xd4, 0x92, 0x0d, 0x95, 0xba, 0x46, 0x50, 0xd6, - 0xcd, 0x91, 0xfd, 0x73, 0xe6, 0xe3, 0xdc, 0xb9, 0xc1, 0xd0, 0x03, 0x79, 0x18, 0x90, 0xb1, 0x3a, 0xc7, 0xfd, 0x85, - 0x8f, 0x69, 0xde, 0xed, 0x5e, 0x73, 0xfc, 0x6b, 0x04, 0x6d, 0x99, 0x7c, 0xd3, 0xfa, 0x07, 0xd5, 0xff, 0x65, 0x03, - 0x46, 0xd6, 0x3a, 0x3e, 0x2c, 0x36, 0xad, 0x37, 0x55, 0x4d, 0x76, 0xd0, 0xd8, 0x99, 0x26, 0x6d, 0x2c, 0x1c, 0xd4, - 0xdd, 0xdb, 0xc8, 0x24, 0x38, 0x6c, 0xde, 0x1c, 0xc2, 0x40, 0x56, 0xc6, 0x77, 0x1b, 0xa1, 0x75, 0xeb, 0xb4, 0xa9, - 0xc3, 0x2f, 0x43, 0x13, 0x0f, 0x7b, 0x8d, 0x56, 0x0c, 0x61, 0x9e, 0x4d, 0x19, 0xbb, 0x02, 0xde, 0x14, 0x41, 0x11, - 0x4f, 0xe3, 0x9a, 0x23, 0x98, 0xd2, 0x6a, 0x60, 0xc8, 0xaa, 0x11, 0xcf, 0x51, 0xa5, 0x6b, 0xd4, 0x73, 0xfb, 0xa6, - 0x07, 0x0c, 0xb9, 0x90, 0xb3, 0x5f, 0x4e, 0x6b, 0x42, 0x67, 0xeb, 0x76, 0xf4, 0x18, 0xf0, 0x0a, 0x91, 0xe8, 0xf3, - 0x41, 0x96, 0x01, 0xb1, 0xc5, 0x64, 0xa5, 0x43, 0x21, 0xc1, 0xe3, 0x76, 0xf8, 0x8c, 0xd5, 0xa7, 0xbc, 0xa7, 0x4f, - 0x59, 0xec, 0x86, 0xa6, 0xd6, 0xc1, 0xdf, 0xa9, 0x12, 0x22, 0x05, 0xae, 0xa5, 0xba, 0xcb, 0x90, 0x55, 0xa5, 0x1e, - 0xfd, 0x41, 0x91, 0x96, 0x46, 0xac, 0xc4, 0x52, 0xa9, 0x1a, 0xd3, 0xfa, 0xef, 0xb4, 0x15, 0x9d, 0xa8, 0xff, 0xb4, - 0xb0, 0x5a, 0x7d, 0x45, 0xac, 0xab, 0x84, 0xe3, 0x45, 0xaf, 0xb6, 0xe8, 0xc8, 0x10, 0x41, 0x02, 0x9f, 0x75, 0xad, - 0x37, 0x1b, 0x3a, 0x0d, 0x68, 0xbc, 0xcd, 0xe3, 0xbf, 0xea, 0xf9, 0x8c, 0xec, 0x05, 0x9a, 0xae, 0x52, 0x9b, 0x02, - 0x5d, 0x41, 0xe0, 0x9c, 0x25, 0xd6, 0x5c, 0xa5, 0x81, 0x09, 0xa6, 0x79, 0xb1, 0xe5, 0x0b, 0xa8, 0x4e, 0xb7, 0x40, - 0x1a, 0x08, 0xd2, 0x50, 0x7a, 0xa9, 0x55, 0xea, 0x36, 0xa6, 0xd3, 0x41, 0x79, 0xd6, 0x06, 0xa5, 0xf8, 0x01, 0xe5, - 0xc0, 0x95, 0x5b, 0xbd, 0x44, 0x09, 0xb2, 0xea, 0xd0, 0xbc, 0x95, 0xbd, 0x66, 0x1e, 0x52, 0xb8, 0x61, 0x4d, 0xc6, - 0x05, 0x6f, 0xfb, 0xdb, 0xdd, 0x40, 0xe6, 0x28, 0x5a, 0x47, 0x4d, 0xc9, 0x42, 0xf7, 0x88, 0xab, 0x08, 0xe9, 0xe7, - 0x85, 0xd2, 0x2d, 0xb0, 0xd3, 0xed, 0xef, 0xd0, 0xba, 0x5b, 0xd4, 0xc5, 0xf2, 0xd0, 0xc3, 0xce, 0x91, 0x7f, 0x04, - 0xef, 0x8f, 0x02, 0x16, 0xc3, 0x4f, 0x13, 0x65, 0x07, 0x6d, 0xf6, 0xfa, 0x7e, 0xb0, 0x4d, 0xbf, 0xa9, 0xb1, 0x1b, - 0xbc, 0x1d, 0xf0, 0x57, 0x1d, 0xac, 0xc2, 0x61, 0x0f, 0xcc, 0x27, 0x16, 0xbf, 0x3f, 0xbf, 0xd8, 0xd7, 0xe0, 0xf8, - 0x44, 0xb8, 0xcd, 0x54, 0x3e, 0xc0, 0xdb, 0x24, 0xb7, 0x74, 0xbd, 0x30, 0xf2, 0xea, 0x02, 0x52, 0x56, 0xce, 0x89, - 0xeb, 0x8b, 0x02, 0x57, 0xa0, 0x85, 0x12, 0x8f, 0x6e, 0x0b, 0xde, 0xb3, 0x86, 0xb4, 0x77, 0x1b, 0x63, 0xd3, 0x69, - 0xef, 0x38, 0x15, 0x87, 0x99, 0x2c, 0xcd, 0x26, 0xe4, 0xdf, 0xae, 0xa8, 0x53, 0x35, 0x70, 0x9f, 0x5f, 0xd7, 0x57, - 0xb3, 0x74, 0x37, 0xee, 0xe7, 0x4f, 0xd9, 0x9e, 0xb0, 0x95, 0x0d, 0x63, 0x76, 0xc8, 0xef, 0x0b, 0x0d, 0xe8, 0x7a, - 0x34, 0x8b, 0x90, 0xfd, 0x40, 0x80, 0xc1, 0x57, 0xe7, 0x8c, 0xa5, 0x59, 0xd9, 0x37, 0xfd, 0xc2, 0x43, 0x49, 0x41, - 0x8c, 0xca, 0x5e, 0x03, 0x64, 0xb4, 0x24, 0x5b, 0xa7, 0xc5, 0x7b, 0x61, 0x01, 0xa3, 0xef, 0x53, 0xe8, 0x54, 0x9f, - 0x64, 0x08, 0xab, 0x2e, 0xd1, 0x78, 0x4e, 0x7b, 0xc4, 0xd7, 0x79, 0x3e, 0x7c, 0x1d, 0x8b, 0x3d, 0x57, 0x58, 0x66, - 0xd8, 0x4c, 0x8c, 0x43, 0x03, 0xf1, 0xc4, 0xa1, 0xfb, 0x91, 0xd1, 0x62, 0xdc, 0x5a, 0x50, 0xc3, 0xe3, 0x2f, 0xe7, - 0x89, 0xa5, 0xeb, 0xdb, 0x80, 0x0c, 0x53, 0x44, 0x9c, 0x59, 0xd4, 0xeb, 0x8b, 0x6a, 0xd8, 0xbd, 0x2e, 0x2a, 0x08, - 0x9a, 0xcd, 0xb7, 0xf5, 0xe0, 0x06, 0xdb, 0x45, 0xed, 0x87, 0x2c, 0xd1, 0xda, 0xba, 0xdf, 0xb4, 0x1b, 0x64, 0x9f, - 0x33, 0x0e, 0xda, 0x40, 0xc0, 0xf8, 0xde, 0xbf, 0xb4, 0xd5, 0xd9, 0xde, 0x3a, 0xcd, 0x5f, 0x88, 0x8b, 0xbf, 0x93, - 0xb0, 0xbc, 0x9b, 0xe1, 0xa0, 0x94, 0x90, 0xe1, 0x04, 0x11, 0xa6, 0xc2, 0xba, 0xb8, 0xe4, 0xb3, 0x0b, 0x13, 0x2b, - 0x23, 0xac, 0x88, 0x76, 0xc4, 0x37, 0x7c, 0x9f, 0xb7, 0x05, 0x04, 0xb1, 0xb3, 0x65, 0xcc, 0x78, 0x46, 0x44, 0x89, - 0x8c, 0xec, 0x30, 0xb1, 0x69, 0x36, 0x61, 0xea, 0xd8, 0x6d, 0x32, 0x68, 0xea, 0x60, 0x9c, 0xc2, 0x06, 0xba, 0x1b, - 0xaa, 0xad, 0xb6, 0x92, 0x8c, 0xf9, 0x85, 0xac, 0x9d, 0xed, 0x8f, 0xea, 0x65, 0x5e, 0x6c, 0x3f, 0xdb, 0x86, 0xe6, - 0x55, 0x31, 0x86, 0x76, 0x20, 0xb3, 0x23, 0xdc, 0x65, 0xea, 0x1e, 0xd2, 0x78, 0xa2, 0x50, 0x6d, 0x25, 0x08, 0x07, - 0xa0, 0x90, 0xa6, 0x39, 0xe3, 0x02, 0xb3, 0xe8, 0xa3, 0x2a, 0xbc, 0xd3, 0x41, 0x59, 0x2d, 0x0d, 0xa0, 0x04, 0x88, - 0xe3, 0x4e, 0x3a, 0x8c, 0xe4, 0xc1, 0x5e, 0xa9, 0x3b, 0xb5, 0x6a, 0x9d, 0xb9, 0x58, 0xec, 0x47, 0x97, 0x5a, 0xec, - 0x11, 0xa6, 0x59, 0x52, 0xeb, 0x07, 0x5a, 0x68, 0xcf, 0x37, 0x5b, 0x13, 0xf3, 0x94, 0x71, 0x48, 0x7b, 0x68, 0x3f, - 0x14, 0xd4, 0x7a, 0x09, 0x8f, 0x95, 0x41, 0x89, 0x64, 0xa7, 0xa6, 0x9d, 0x95, 0x69, 0x0a, 0xde, 0x65, 0x99, 0x78, - 0x15, 0xfa, 0x1d, 0xe1, 0xdd, 0x96, 0x59, 0xdb, 0xc8, 0xc4, 0xcd, 0x49, 0xa4, 0x58, 0x0e, 0xce, 0x35, 0xbc, 0x2d, - 0x3a, 0x76, 0xf1, 0xdb, 0x4c, 0xad, 0x5f, 0xb0, 0x8c, 0x38, 0x5a, 0xc2, 0xcd, 0x0f, 0xe9, 0x91, 0x88, 0x02, 0xa3, - 0xfe, 0x4d, 0x5e, 0xc5, 0x89, 0x1b, 0x66, 0xfc, 0x8e, 0x86, 0x38, 0x54, 0x87, 0xba, 0x67, 0x89, 0x15, 0x88, 0x85, - 0xf3, 0xf7, 0xd5, 0x4d, 0x20, 0x97, 0xde, 0x56, 0xab, 0xe6, 0x61, 0xd4, 0x65, 0xdf, 0x83, 0x3f, 0x85, 0x31, 0x35, - 0x9f, 0xbc, 0x2a, 0xdf, 0x70, 0xcf, 0x64, 0x29, 0x97, 0xf0, 0xba, 0xde, 0x52, 0x73, 0x9c, 0xcb, 0x37, 0x5c, 0x56, - 0x59, 0x6a, 0x33, 0x82, 0x49, 0xdf, 0x5a, 0xe1, 0x38, 0x82, 0x35, 0x14, 0x91, 0xb8, 0x39, 0xa8, 0x83, 0xcd, 0xb1, - 0x0e, 0xe5, 0x36, 0x13, 0xac, 0x43, 0x36, 0xd8, 0x03, 0xc2, 0xa9, 0xc5, 0x66, 0x8e, 0x7d, 0x6c, 0x08, 0x07, 0x74, - 0xdc, 0x9a, 0x22, 0x4a, 0x4e, 0xdd, 0x89, 0xa7, 0x96, 0xe5, 0xbb, 0x19, 0xd3, 0x74, 0x7c, 0x87, 0x18, 0x59, 0x12, - 0x8b, 0xdc, 0xcb, 0x10, 0x97, 0x43, 0x8b, 0xf6, 0x2c, 0x55, 0x21, 0x37, 0xe8, 0xd6, 0x2d, 0x37, 0x1c, 0x3e, 0x7d, - 0x38, 0x2e, 0x7a, 0x22, 0xda, 0x4a, 0x7c, 0x39, 0x85, 0x54, 0x4a, 0xf6, 0x93, 0x0a, 0x2f, 0x54, 0x48, 0xa7, 0xcf, - 0x8a, 0x04, 0xdc, 0xdc, 0x99, 0x0b, 0x57, 0x53, 0xae, 0x65, 0x8d, 0x76, 0x93, 0x9c, 0x08, 0x15, 0x96, 0xcc, 0x18, - 0xbd, 0x78, 0x3f, 0x8b, 0x16, 0xdb, 0x39, 0xc5, 0x76, 0xd8, 0xd4, 0xd4, 0x79, 0x87, 0x22, 0xa7, 0xa1, 0xb2, 0x8c, - 0xc4, 0x2c, 0xca, 0x21, 0x3e, 0x3a, 0x75, 0xbe, 0xc7, 0x28, 0x75, 0x05, 0x73, 0xaa, 0x4d, 0xc9, 0x83, 0xd3, 0xc5, - 0xf9, 0x17, 0x6e, 0x37, 0xfd, 0xe3, 0x28, 0xe8, 0xb7, 0x5a, 0x35, 0x51, 0xad, 0x1c, 0x9a, 0x5d, 0xd7, 0x6e, 0x34, - 0x26, 0xc7, 0x0e, 0x70, 0x67, 0x13, 0xc4, 0x18, 0x3e, 0x2e, 0xc3, 0x2c, 0x9a, 0xe7, 0x53, 0x7d, 0xfd, 0xf3, 0x20, - 0xca, 0xb6, 0x4c, 0xdd, 0x0a, 0xa3, 0xc0, 0xa5, 0x81, 0x07, 0xe3, 0xa8, 0x21, 0x86, 0xcd, 0xa3, 0xa3, 0x6d, 0x22, - 0x93, 0x74, 0xcf, 0x6a, 0xae, 0x00, 0x4d, 0xa7, 0x20, 0xf2, 0xef, 0x71, 0x9b, 0x2f, 0x38, 0x8e, 0x4e, 0xe9, 0xf9, - 0x17, 0xa5, 0x1f, 0x69, 0xf0, 0xc6, 0x78, 0x75, 0xc1, 0xaa, 0x27, 0x23, 0xef, 0x88, 0x87, 0x39, 0x0a, 0xe4, 0x07, - 0xf0, 0x4d, 0xe9, 0x82, 0x73, 0x63, 0xf7, 0x3d, 0xc8, 0x96, 0xed, 0x68, 0x55, 0xc4, 0x1a, 0xf9, 0x1a, 0x27, 0x2e, - 0xb5, 0xfe, 0x92, 0xcc, 0x3a, 0x09, 0xf6, 0x35, 0xf2, 0x78, 0x47, 0xbc, 0xcf, 0xf6, 0x76, 0x68, 0xe3, 0x35, 0x92, - 0xb0, 0xef, 0xb6, 0xeb, 0xaa, 0x2b, 0x4e, 0x7f, 0x62, 0x01, 0xe1, 0x02, 0x36, 0x16, 0xe8, 0x9b, 0xdb, 0x86, 0xd2, - 0xb9, 0xee, 0x1a, 0x7c, 0xaf, 0xf1, 0xce, 0x3b, 0x3b, 0xb8, 0x8a, 0x93, 0x44, 0x60, 0x76, 0xa1, 0x34, 0x26, 0xea, - 0x17, 0x86, 0xe7, 0x6a, 0xcf, 0xb7, 0xea, 0x65, 0x54, 0xb3, 0x67, 0x02, 0xfe, 0x3a, 0x1a, 0x1f, 0x95, 0x79, 0x83, - 0x85, 0xdb, 0x3a, 0x85, 0xee, 0xfc, 0x7d, 0x00, 0xaf, 0xbc, 0x6b, 0xb6, 0x2c, 0xe6, 0x3b, 0xce, 0x82, 0xa5, 0xcd, - 0xdb, 0x5d, 0x99, 0xe0, 0x97, 0x1f, 0x70, 0xaf, 0xf8, 0xd2, 0xc1, 0x93, 0x7e, 0xdd, 0x52, 0xc0, 0x5d, 0xc0, 0xe4, - 0xa5, 0x1b, 0x6e, 0x42, 0xdc, 0xac, 0x72, 0xd3, 0xb7, 0x48, 0xf9, 0xe9, 0xe9, 0xac, 0x79, 0xea, 0x10, 0xde, 0x78, - 0x54, 0x87, 0xca, 0x68, 0x6e, 0xdd, 0xc2, 0x95, 0xfe, 0x05, 0xb7, 0x35, 0x77, 0x30, 0xc3, 0x89, 0x2c, 0x89, 0xc7, - 0x93, 0xee, 0x1e, 0xa0, 0xcc, 0x03, 0x2f, 0x22, 0x29, 0xa2, 0x6d, 0x60, 0x07, 0x2d, 0x28, 0x6b, 0x0d, 0xa2, 0xd6, - 0xde, 0x23, 0x66, 0x7b, 0x69, 0xf7, 0x43, 0xf7, 0xe1, 0x03, 0x0f, 0xd6, 0x44, 0xc8, 0x19, 0x4c, 0x28, 0x7e, 0x97, - 0xf6, 0x73, 0xd8, 0x33, 0xc2, 0x95, 0x56, 0x28, 0x78, 0x8e, 0x1b, 0x54, 0x7d, 0x9f, 0x2d, 0x20, 0x5a, 0x24, 0x20, - 0x67, 0xbb, 0x16, 0xd6, 0xa8, 0x18, 0xf9, 0x49, 0x0c, 0x95, 0xd4, 0xb6, 0x7c, 0x83, 0xa5, 0xbf, 0xae, 0x02, 0x49, - 0x20, 0x31, 0x27, 0xf5, 0xbc, 0xb6, 0x2d, 0x16, 0x37, 0x4b, 0x36, 0x0f, 0x15, 0xa5, 0xe7, 0xe5, 0xd8, 0x79, 0x07, - 0xf5, 0x28, 0xb8, 0x2c, 0xdb, 0xeb, 0xdc, 0x2e, 0xed, 0xae, 0x75, 0x18, 0x4e, 0x52, 0x4e, 0x31, 0xe0, 0xa1, 0x6d, - 0x3d, 0x72, 0xb1, 0xf8, 0xf7, 0x40, 0x1a, 0xde, 0x5d, 0x7e, 0x78, 0x73, 0x4b, 0x6b, 0x4c, 0xd4, 0x66, 0x2a, 0x12, - 0xe1, 0xe4, 0x86, 0x15, 0xc9, 0x90, 0x26, 0x12, 0x3d, 0x7c, 0x91, 0x6f, 0xae, 0x35, 0xac, 0x12, 0xd9, 0x90, 0x61, - 0xb3, 0xd9, 0xcd, 0x64, 0xdc, 0xca, 0x76, 0x7f, 0x3a, 0xf4, 0x26, 0xc8, 0xd6, 0x89, 0xd2, 0x3c, 0x77, 0xd8, 0x62, - 0xed, 0x9e, 0xb1, 0xa8, 0x9f, 0x1c, 0x65, 0x8e, 0x78, 0x43, 0x4c, 0xb4, 0x4d, 0xb9, 0xb6, 0x66, 0x1e, 0x21, 0x70, - 0x6f, 0xed, 0x3f, 0x2b, 0xf6, 0xf1, 0x1a, 0xd3, 0xc7, 0xf4, 0x0b, 0xee, 0xf9, 0xc4, 0xc3, 0xcf, 0x34, 0xc9, 0x0d, - 0xb1, 0xdd, 0x45, 0x3c, 0xe4, 0xa3, 0xbb, 0x61, 0xca, 0x84, 0x65, 0xda, 0x5c, 0xb5, 0x9c, 0x1b, 0x83, 0x54, 0xa1, - 0x48, 0xe5, 0x3e, 0xa2, 0xeb, 0xb0, 0x1f, 0xce, 0xf2, 0x3d, 0x48, 0x64, 0xbe, 0x4f, 0x22, 0x10, 0xeb, 0x1f, 0x05, - 0x71, 0x8e, 0x25, 0x2f, 0x8d, 0x22, 0x8d, 0xfe, 0x84, 0x52, 0x96, 0x72, 0x08, 0xf8, 0xe6, 0x80, 0x73, 0x15, 0x5b, - 0xff, 0x7d, 0xf6, 0x7d, 0x98, 0x76, 0x7d, 0xce, 0xee, 0x16, 0x12, 0xde, 0x72, 0xe2, 0x4e, 0xe5, 0xf1, 0xa9, 0x90, - 0x7b, 0x0f, 0x72, 0x2d, 0xbf, 0xed, 0x86, 0x86, 0xce, 0xf1, 0xb2, 0x45, 0x71, 0x55, 0xa7, 0xf2, 0x47, 0x51, 0xab, - 0xaa, 0xa4, 0x2a, 0x7b, 0x1e, 0x39, 0x93, 0x93, 0xb3, 0xdc, 0xab, 0x0a, 0x43, 0x03, 0x8f, 0x38, 0x5b, 0x62, 0x9d, - 0xbd, 0xc7, 0xcc, 0xe2, 0x84, 0x8f, 0x42, 0x03, 0xc1, 0x2a, 0xe9, 0x13, 0x8e, 0xe8, 0x0b, 0xb4, 0xd3, 0xfa, 0xd2, - 0xcd, 0xed, 0x47, 0x96, 0xb2, 0x83, 0xa3, 0xf1, 0xca, 0x8a, 0x7c, 0x9d, 0x02, 0x31, 0x53, 0xac, 0x62, 0xe4, 0xf3, - 0x1b, 0x84, 0x44, 0xf3, 0x8b, 0xe9, 0x82, 0xf6, 0x2e, 0x6b, 0x51, 0x1d, 0x3e, 0x6b, 0xf6, 0xca, 0x0b, 0x40, 0x1e, - 0x57, 0x15, 0x87, 0x48, 0x7b, 0x83, 0x38, 0x4d, 0x88, 0x7a, 0x76, 0xdb, 0xb3, 0x85, 0x38, 0x31, 0xcb, 0xd1, 0x62, - 0xd2, 0xab, 0x12, 0xd9, 0x6f, 0x4f, 0x4f, 0x20, 0x25, 0x3a, 0x63, 0x2c, 0x19, 0x69, 0x4b, 0xf9, 0x86, 0xe6, 0xf4, - 0x1e, 0xd6, 0x31, 0x11, 0xb1, 0x5a, 0x00, 0x52, 0x0d, 0x29, 0xf4, 0x35, 0x6a, 0x47, 0x44, 0xf8, 0xf9, 0xc8, 0x52, - 0x29, 0x32, 0xc6, 0x37, 0xf6, 0x23, 0x6d, 0x31, 0xab, 0x88, 0x53, 0xc4, 0xac, 0x61, 0x18, 0xd9, 0xb8, 0xc4, 0x28, - 0xa8, 0x56, 0x8f, 0x37, 0xf8, 0x04, 0x83, 0x94, 0x62, 0xab, 0xeb, 0x71, 0xdc, 0xc4, 0x4f, 0x47, 0x22, 0xc2, 0x7d, - 0x82, 0x38, 0x29, 0x81, 0x8d, 0xe7, 0x6f, 0xce, 0x54, 0xe7, 0x7c, 0x69, 0xb0, 0xe7, 0x48, 0xf5, 0x38, 0xc3, 0x40, - 0x13, 0x7c, 0xf5, 0xa3, 0xd9, 0x1f, 0xcb, 0x37, 0x32, 0x8b, 0x3b, 0x09, 0xa9, 0x95, 0xd3, 0xad, 0x36, 0x5b, 0x22, - 0x7e, 0x80, 0x0b, 0x67, 0xbc, 0x47, 0x35, 0x77, 0xf9, 0xa5, 0x26, 0x06, 0x56, 0x98, 0x13, 0x72, 0x37, 0x47, 0x92, - 0xbf, 0x00, 0xe3, 0x66, 0x0d, 0x6d, 0x2e, 0xc7, 0x2e, 0x38, 0x09, 0xe4, 0xea, 0xa6, 0xf4, 0x9b, 0xcf, 0x9e, 0x81, - 0xe9, 0x61, 0xf9, 0xd1, 0xef, 0x32, 0xff, 0x65, 0xac, 0x53, 0x13, 0xfc, 0xc8, 0x51, 0x59, 0x9f, 0xcb, 0xe3, 0xdf, - 0x01, 0xb7, 0x67, 0x7d, 0xe3, 0xcf, 0x93, 0x20, 0xce, 0x35, 0x7f, 0x66, 0xc1, 0x44, 0x36, 0xbb, 0xd6, 0xde, 0xcf, - 0x9f, 0xc1, 0x9b, 0xfb, 0x3d, 0x42, 0xe5, 0x6b, 0xe7, 0x14, 0xea, 0xa6, 0x01, 0x16, 0x7a, 0xf5, 0x60, 0x1f, 0x90, - 0x9b, 0x7d, 0x88, 0x0a, 0x14, 0x39, 0xa2, 0xd2, 0x35, 0xe3, 0x1a, 0xb6, 0x55, 0x3b, 0x94, 0x3d, 0xc3, 0x79, 0x4f, - 0x9b, 0x5d, 0xb5, 0xad, 0x57, 0x69, 0x13, 0x52, 0x98, 0xc2, 0x1a, 0xe8, 0x56, 0x37, 0xec, 0xdc, 0x1c, 0x2c, 0xdf, - 0x5a, 0x06, 0x1e, 0x16, 0xae, 0x98, 0xbd, 0x98, 0xf9, 0x8e, 0x0e, 0xfc, 0x59, 0xca, 0x2b, 0x0a, 0xb5, 0xfa, 0x8d, - 0xe3, 0xa8, 0x87, 0x36, 0xe0, 0x0d, 0x7f, 0x9d, 0x50, 0x9d, 0x3d, 0x67, 0x0b, 0x23, 0x17, 0xe2, 0xd7, 0xba, 0xc1, - 0x27, 0x74, 0xa9, 0x0a, 0x26, 0xe0, 0x4b, 0x9b, 0x1d, 0x0f, 0x5e, 0x87, 0x96, 0xa4, 0xf2, 0xb8, 0x79, 0x8c, 0xe5, - 0xdc, 0x4e, 0xb9, 0x54, 0x2b, 0xf3, 0xa3, 0x1b, 0x25, 0xd8, 0x20, 0x90, 0x24, 0x58, 0x84, 0xf0, 0x4f, 0x30, 0x67, - 0x1c, 0x89, 0x21, 0x7b, 0xa3, 0x62, 0x8a, 0x16, 0x14, 0x0c, 0x91, 0x52, 0xb6, 0x58, 0x60, 0xb3, 0xf7, 0x08, 0x7f, - 0x7f, 0xdf, 0x3a, 0xf5, 0xb4, 0xef, 0x9d, 0x02, 0xed, 0xdb, 0x27, 0xa5, 0xb4, 0xef, 0x9f, 0x14, 0x9d, 0xa5, 0x56, - 0x19, 0xfb, 0x6a, 0xc9, 0xbe, 0x1a, 0xd9, 0x63, 0x3b, 0xdb, 0x43, 0x2b, 0x8e, 0x6b, 0xd0, 0x8e, 0xc5, 0x82, 0x6f, - 0xc9, 0x01, 0xc7, 0x11, 0xcb, 0x92, 0xf5, 0x63, 0xa1, 0xb9, 0x77, 0xb5, 0x1e, 0xf9, 0xf6, 0x00, 0x15, 0x85, 0xb7, - 0xc3, 0xda, 0x8d, 0xac, 0x2c, 0xcf, 0x34, 0xb7, 0x4e, 0xe2, 0xd4, 0xd9, 0xde, 0x42, 0xf1, 0x74, 0xea, 0x08, 0x7e, - 0xd6, 0xe4, 0x70, 0x86, 0x4a, 0x13, 0xf8, 0x8f, 0x1c, 0x3f, 0x36, 0x5a, 0x5b, 0xd1, 0x78, 0xf3, 0xbd, 0x27, 0x1a, - 0x9a, 0xbf, 0xb8, 0x8a, 0x58, 0x98, 0x96, 0x93, 0x7b, 0x07, 0x53, 0x1f, 0x4a, 0xd6, 0xe2, 0x76, 0x07, 0x64, 0xb6, - 0x94, 0x97, 0x39, 0x41, 0x08, 0xa7, 0xba, 0x85, 0xff, 0x2c, 0xa0, 0x31, 0x27, 0x2d, 0x17, 0x53, 0xf9, 0xbc, 0xd5, - 0x86, 0xda, 0xce, 0x59, 0x83, 0x78, 0xec, 0xca, 0x74, 0x57, 0x82, 0x29, 0x3c, 0x9f, 0xc5, 0x15, 0x90, 0xfa, 0x85, - 0x35, 0xff, 0x46, 0xd9, 0xc3, 0xe5, 0x4e, 0x4c, 0x83, 0xff, 0xf7, 0x64, 0x3b, 0x08, 0x27, 0x74, 0x35, 0x4e, 0xb9, - 0x24, 0xe2, 0x7e, 0x6e, 0xa5, 0xed, 0x99, 0x37, 0x72, 0x7d, 0xfb, 0x8c, 0x61, 0x7a, 0xae, 0x42, 0x20, 0x53, 0x68, - 0x3f, 0xdd, 0x3f, 0x8f, 0x71, 0x48, 0xb0, 0x62, 0xcf, 0x09, 0x56, 0x19, 0x98, 0x92, 0x77, 0x33, 0x99, 0x9e, 0xbf, - 0xfc, 0x5f, 0xe6, 0xf7, 0x92, 0xf5, 0xa0, 0x37, 0x7b, 0xcb, 0x36, 0xb7, 0x85, 0x75, 0x69, 0x31, 0xb3, 0x7e, 0x34, - 0xd3, 0xfa, 0x5f, 0x71, 0x80, 0xb7, 0xdb, 0xe9, 0x8a, 0x3f, 0xaa, 0x0c, 0xd2, 0x38, 0x64, 0xdd, 0x6f, 0x4b, 0xd6, - 0x03, 0xde, 0xed, 0xed, 0xf3, 0x5b, 0x1c, 0xfe, 0xb1, 0x09, 0x7c, 0x4b, 0x17, 0x00, 0x02, 0xf8, 0x91, 0xbb, 0x1c, - 0xbb, 0x9c, 0xc9, 0x9d, 0xeb, 0x0a, 0x0b, 0xaa, 0x96, 0x43, 0x16, 0x2e, 0x96, 0x6c, 0x41, 0x3e, 0x5e, 0x13, 0xd9, - 0xe8, 0xc0, 0xec, 0x12, 0xb1, 0xf7, 0x44, 0x09, 0xde, 0x6c, 0xf3, 0x29, 0xd1, 0xf3, 0xe7, 0x04, 0xda, 0x66, 0xd7, - 0x89, 0x0d, 0xb9, 0xb6, 0x38, 0x15, 0x27, 0x00, 0xec, 0x7b, 0xe6, 0xc2, 0xe0, 0x6c, 0xa4, 0xf6, 0x41, 0x0b, 0xa7, - 0xb7, 0x04, 0xfa, 0xd9, 0x38, 0x45, 0xde, 0xef, 0x00, 0x95, 0x52, 0x78, 0xe2, 0x10, 0x13, 0x2a, 0x76, 0xf8, 0x9c, - 0x52, 0x9f, 0x43, 0x8e, 0x9c, 0x77, 0xc0, 0x89, 0x5b, 0xda, 0x74, 0x63, 0x79, 0xbb, 0xe1, 0xbb, 0x8a, 0x74, 0x65, - 0xf5, 0xb0, 0x84, 0xb6, 0xcd, 0xd1, 0x19, 0x67, 0x94, 0xa0, 0xc4, 0x08, 0x29, 0xc3, 0xf3, 0x63, 0x30, 0x48, 0x26, - 0xe3, 0x30, 0xd1, 0x6b, 0xce, 0x0a, 0xa3, 0xff, 0x44, 0x08, 0x23, 0x84, 0x9f, 0x5f, 0x67, 0x6c, 0xdf, 0xa3, 0xbb, - 0xb6, 0xe9, 0x5e, 0x6f, 0x15, 0xb1, 0xcf, 0x2b, 0x0d, 0x4a, 0xa5, 0xd2, 0x58, 0xdb, 0x8c, 0x7f, 0x75, 0x52, 0x9d, - 0x46, 0x1d, 0x8e, 0x70, 0x76, 0xa6, 0xcd, 0xf9, 0xbf, 0x9e, 0x99, 0xb9, 0xd7, 0xce, 0xcb, 0x9e, 0x19, 0xeb, 0xc6, - 0x25, 0x91, 0x16, 0xe4, 0x26, 0x0d, 0x25, 0xeb, 0xb1, 0xd1, 0xde, 0x94, 0x4c, 0xfd, 0xc8, 0xa4, 0x80, 0x8d, 0xb1, - 0xda, 0xe1, 0x32, 0x3e, 0xcd, 0xfb, 0xa8, 0x24, 0x0b, 0x2e, 0xc4, 0xf9, 0x70, 0xbb, 0xb1, 0x22, 0x85, 0x1d, 0x68, - 0xf2, 0xeb, 0x4f, 0xc9, 0xae, 0xf0, 0x9d, 0xdf, 0x81, 0x64, 0xd6, 0x17, 0x2b, 0xc5, 0x06, 0x75, 0x05, 0x7a, 0x03, - 0x2e, 0xdf, 0xd1, 0x1b, 0x65, 0x34, 0x7c, 0x5d, 0x4a, 0x54, 0x92, 0x50, 0x63, 0xa5, 0x60, 0xb7, 0x58, 0x97, 0x51, - 0x14, 0x17, 0x6b, 0xa2, 0x5f, 0x15, 0x4c, 0x16, 0xdb, 0x6e, 0xe0, 0xdc, 0x04, 0xea, 0x51, 0x07, 0x27, 0xfa, 0x3b, - 0x44, 0xde, 0x16, 0xc5, 0x86, 0x6c, 0x1b, 0x88, 0xa1, 0x15, 0xd7, 0x75, 0xaa, 0xb1, 0x3e, 0xf2, 0x80, 0x1e, 0xf7, - 0xaf, 0x8e, 0x21, 0x78, 0xf8, 0x69, 0xc0, 0x52, 0x57, 0x9d, 0x3a, 0xbc, 0x7d, 0x74, 0x63, 0x49, 0xea, 0x71, 0x7e, - 0xf3, 0x4f, 0xc5, 0x36, 0x2d, 0x4f, 0xb7, 0xc8, 0x0d, 0x89, 0xe0, 0x5d, 0x41, 0xf6, 0xd3, 0xc1, 0x6d, 0x7b, 0xa6, - 0x28, 0x0a, 0x2f, 0x94, 0xb4, 0xbc, 0x29, 0x3c, 0x8c, 0xe3, 0x46, 0x8a, 0x1a, 0x2a, 0x32, 0xfe, 0x31, 0x36, 0x2c, - 0x92, 0x9d, 0xad, 0x0f, 0x63, 0xe7, 0x95, 0x90, 0x8f, 0x2b, 0xd7, 0xea, 0x46, 0xa6, 0x63, 0x5b, 0x14, 0x39, 0x7a, - 0x9d, 0x07, 0xa0, 0xf2, 0x47, 0x61, 0x12, 0x50, 0x1c, 0x89, 0x00, 0xa2, 0x3a, 0xf1, 0xa2, 0xe8, 0x81, 0xcd, 0xa1, - 0x19, 0x56, 0x83, 0x7c, 0x2a, 0xfa, 0x1b, 0x15, 0x9d, 0xd9, 0xf1, 0x50, 0xd8, 0x8b, 0x4c, 0xac, 0x3e, 0xe6, 0xae, - 0x43, 0x91, 0x36, 0x72, 0xea, 0x3b, 0xd7, 0x98, 0x37, 0xd0, 0xc3, 0x22, 0x14, 0x7f, 0x61, 0x83, 0x98, 0xb2, 0x01, - 0x2b, 0x64, 0xec, 0x79, 0x04, 0x30, 0x4b, 0xf2, 0x45, 0x12, 0x78, 0xd3, 0xaf, 0x40, 0xbf, 0xc1, 0x7d, 0xb5, 0x6f, - 0x26, 0x4d, 0xb3, 0x70, 0x77, 0x63, 0xbf, 0x2f, 0x36, 0x72, 0x4f, 0x08, 0x22, 0x58, 0x92, 0xd9, 0xb2, 0x96, 0xde, - 0x03, 0x7e, 0xa5, 0xe0, 0x19, 0x78, 0xc8, 0x00, 0x8e, 0x98, 0x96, 0xb8, 0xae, 0xd6, 0x93, 0xab, 0xc3, 0x56, 0x6e, - 0x0b, 0x25, 0x38, 0x44, 0xd2, 0xe8, 0x96, 0x4a, 0xb3, 0x94, 0xee, 0x99, 0xad, 0xbb, 0x91, 0x71, 0xfc, 0x65, 0x50, - 0x9e, 0x58, 0x8f, 0x5f, 0x93, 0xc8, 0x96, 0x44, 0x14, 0x97, 0x5f, 0xe7, 0x90, 0xdd, 0x58, 0xa9, 0x98, 0x0c, 0x54, - 0x3d, 0x79, 0xaa, 0x09, 0xde, 0x60, 0x71, 0x17, 0x1c, 0xe9, 0x03, 0xed, 0x09, 0xc5, 0x49, 0xaa, 0x4a, 0xa9, 0x87, - 0x7e, 0xc2, 0x57, 0xd8, 0xe7, 0xa2, 0xb7, 0xd9, 0x31, 0xcd, 0xd8, 0x67, 0x34, 0xc1, 0x80, 0x3a, 0x09, 0x4e, 0xbb, - 0x66, 0x78, 0xe4, 0x48, 0x6c, 0x3a, 0x90, 0x8f, 0xf1, 0x54, 0xe8, 0x36, 0x6e, 0x56, 0x21, 0x8e, 0x86, 0xd0, 0xfd, - 0x30, 0x14, 0x46, 0x3f, 0xa3, 0x74, 0xac, 0x3e, 0xed, 0xd7, 0x5c, 0xd4, 0xce, 0x98, 0xa2, 0x29, 0x2f, 0xbb, 0xa6, - 0x00, 0x6f, 0xa4, 0xbc, 0xc1, 0x0a, 0xf8, 0xfe, 0xb2, 0x59, 0x57, 0x8f, 0x17, 0x36, 0xf9, 0x0f, 0xae, 0x83, 0x8d, - 0x84, 0x09, 0xfc, 0x11, 0x92, 0x99, 0x2d, 0x82, 0x35, 0x8c, 0xf3, 0x92, 0x58, 0x38, 0x7a, 0x9c, 0xef, 0x07, 0xd9, - 0x1f, 0x57, 0x8d, 0x07, 0x61, 0x0b, 0x0f, 0xad, 0xe4, 0x9c, 0xa8, 0xd7, 0xd4, 0xa9, 0x91, 0x0f, 0x22, 0x93, 0xc0, - 0x84, 0xf2, 0x3c, 0xc1, 0x34, 0xab, 0xb3, 0x59, 0x50, 0xdb, 0x44, 0xc5, 0xa0, 0xd0, 0x8d, 0xdb, 0x99, 0x20, 0xc9, - 0x86, 0xe0, 0x94, 0x97, 0x65, 0xc3, 0xed, 0x75, 0x6b, 0xe6, 0x45, 0xf3, 0xd7, 0x64, 0x87, 0xa5, 0xdf, 0x05, 0x1d, - 0x6b, 0xe3, 0xf5, 0x60, 0x7b, 0xd0, 0x79, 0x58, 0xbc, 0x50, 0x3a, 0x8d, 0xaa, 0x9b, 0x7a, 0x11, 0x37, 0xfb, 0x39, - 0x75, 0x35, 0xd1, 0x6c, 0x09, 0x48, 0x67, 0xa3, 0x7c, 0x8f, 0x9d, 0xb8, 0x46, 0x51, 0x5a, 0x4b, 0xab, 0x5b, 0xe6, - 0x1d, 0x8b, 0x91, 0xbb, 0x81, 0x51, 0x62, 0xed, 0x22, 0x86, 0x9a, 0x9f, 0xc3, 0xdc, 0x9e, 0x98, 0x40, 0x45, 0xff, - 0x3a, 0x9f, 0xcc, 0xec, 0x62, 0x9a, 0x4a, 0x32, 0xcc, 0x07, 0xa5, 0x6f, 0x89, 0xe6, 0xee, 0xf1, 0x9c, 0x93, 0xd2, - 0xb6, 0xad, 0x62, 0x1d, 0xba, 0x85, 0x81, 0x0f, 0x5c, 0x4d, 0x03, 0xc1, 0x15, 0xbd, 0xc5, 0x98, 0x67, 0xf0, 0x7c, - 0xc0, 0xec, 0x1b, 0x79, 0x3e, 0x2f, 0x45, 0xde, 0x3e, 0x91, 0x19, 0xbe, 0x50, 0xa0, 0x98, 0xde, 0xe9, 0x7c, 0x6f, - 0x9f, 0x87, 0x1b, 0x77, 0x59, 0xe0, 0xbe, 0x24, 0x0e, 0x19, 0xfe, 0x75, 0x17, 0x5b, 0xc6, 0x1a, 0xcf, 0x9c, 0xfb, - 0x2d, 0x89, 0x09, 0xa5, 0xda, 0xae, 0x1f, 0xa2, 0xbc, 0x16, 0x61, 0x52, 0x85, 0xa5, 0xdb, 0x2a, 0xa4, 0x32, 0xf4, - 0x45, 0xa4, 0x8a, 0xc7, 0x99, 0x9b, 0x9d, 0xa1, 0x34, 0x82, 0x0c, 0x05, 0x13, 0x64, 0xb5, 0x4f, 0xa2, 0x79, 0x56, - 0xf2, 0xa0, 0x4d, 0x13, 0xf9, 0xf0, 0xba, 0x2a, 0x63, 0xe1, 0x71, 0xe6, 0xde, 0x76, 0xc4, 0xdc, 0xba, 0x8e, 0xf3, - 0xea, 0x03, 0x75, 0x2b, 0x47, 0xe6, 0xb9, 0x62, 0x6c, 0xc5, 0xd8, 0x3d, 0xa8, 0x45, 0xa0, 0x0c, 0x45, 0x12, 0x0e, - 0x6c, 0x31, 0x7a, 0x7b, 0xa1, 0xce, 0x06, 0xc2, 0xad, 0xb2, 0x3e, 0xaa, 0xc5, 0x6b, 0xda, 0xb6, 0x52, 0x0a, 0x4e, - 0xa0, 0x10, 0x4e, 0x34, 0xf6, 0x9c, 0x0f, 0xff, 0xfc, 0x5c, 0xa7, 0x1e, 0xff, 0x99, 0x10, 0x9b, 0xfd, 0x97, 0xf7, - 0xaf, 0x63, 0x0a, 0xd0, 0xeb, 0x9e, 0x75, 0x45, 0x7a, 0xad, 0xeb, 0x35, 0xd2, 0xab, 0xaf, 0x57, 0xb5, 0x39, 0xe1, - 0x59, 0x2d, 0xb5, 0x51, 0x1b, 0x77, 0x6e, 0x14, 0xeb, 0x30, 0x94, 0x94, 0xd4, 0x7e, 0x4f, 0x4f, 0x3f, 0x8d, 0x55, - 0x99, 0x6f, 0xcd, 0xa4, 0x98, 0x4d, 0x5f, 0x1c, 0xab, 0xf5, 0xb8, 0x8c, 0x10, 0xbb, 0x17, 0x43, 0x6d, 0xa5, 0x3a, - 0x35, 0x75, 0x9b, 0xcf, 0x2f, 0xc6, 0xc5, 0xe5, 0xcb, 0xbf, 0x22, 0xc4, 0xf3, 0x11, 0xe3, 0xa1, 0x8d, 0x76, 0xde, - 0x37, 0x48, 0x8d, 0xcb, 0xcd, 0x11, 0xe7, 0x68, 0x56, 0x15, 0xc6, 0x88, 0xa7, 0x55, 0xe7, 0x2e, 0xb8, 0xf6, 0x20, - 0xf0, 0x73, 0x71, 0x55, 0xa9, 0x48, 0x52, 0xdf, 0x36, 0xb0, 0x2e, 0x37, 0x4b, 0x93, 0xc3, 0xdf, 0x0b, 0x2c, 0xe8, - 0x63, 0x53, 0x95, 0xeb, 0x07, 0x25, 0xc4, 0xd8, 0x29, 0x62, 0xca, 0xa9, 0xf4, 0x2e, 0xac, 0x7c, 0x51, 0x5f, 0x4f, - 0x99, 0xfa, 0x36, 0x88, 0x31, 0x8b, 0x31, 0x27, 0x4b, 0x31, 0x27, 0x79, 0x60, 0xfb, 0x3c, 0x06, 0xc6, 0xc5, 0x24, - 0x10, 0xf9, 0x70, 0xe3, 0xca, 0x58, 0xbe, 0x08, 0x18, 0xac, 0xa2, 0x0d, 0x04, 0xd3, 0x3b, 0x33, 0xed, 0xe2, 0x1f, - 0xf3, 0xcb, 0x41, 0x64, 0x5c, 0x89, 0x21, 0xac, 0x8e, 0xf8, 0xad, 0xd3, 0x0b, 0x64, 0x62, 0xe5, 0x8c, 0x66, 0x09, - 0x98, 0x75, 0xd3, 0x34, 0x38, 0x56, 0x4d, 0x83, 0x4a, 0x33, 0xaf, 0xb0, 0x0c, 0x24, 0x31, 0x30, 0x95, 0x6a, 0xf8, - 0x95, 0x16, 0x09, 0xce, 0xf9, 0xfb, 0xae, 0xcf, 0x29, 0x90, 0xc6, 0x99, 0x46, 0xa5, 0xc0, 0xe7, 0x0e, 0xf8, 0x05, - 0xfa, 0x15, 0x9f, 0x88, 0xe3, 0x34, 0xe9, 0x91, 0xc9, 0xe8, 0x81, 0xda, 0x81, 0x90, 0x59, 0x4b, 0x46, 0x61, 0x1a, - 0x42, 0x28, 0x05, 0x44, 0x7c, 0x3f, 0xcc, 0x45, 0x95, 0x35, 0xaf, 0xc6, 0x02, 0xb7, 0x10, 0x31, 0x23, 0xaa, 0x06, - 0x22, 0xc9, 0x48, 0xae, 0x1b, 0x32, 0x2c, 0x96, 0x26, 0x2d, 0xc6, 0xe0, 0x04, 0xc9, 0x3c, 0x9d, 0x08, 0xfe, 0x65, - 0x48, 0xc6, 0x05, 0x6f, 0x7a, 0xaf, 0x80, 0xbe, 0xc6, 0xc5, 0x64, 0xe7, 0xcd, 0xbc, 0xe8, 0x89, 0xb4, 0x5c, 0xe5, - 0x43, 0xa2, 0xd0, 0xdf, 0xd7, 0x7d, 0xef, 0x58, 0x3d, 0x48, 0xc1, 0xbc, 0x2d, 0x6a, 0x8b, 0x96, 0xed, 0xb5, 0x95, - 0xc7, 0x72, 0xdc, 0x3d, 0x4a, 0x50, 0x00, 0xdd, 0x66, 0xd1, 0x0a, 0x3c, 0x49, 0xd6, 0xd8, 0xa9, 0x4f, 0x44, 0x74, - 0x74, 0x1b, 0x25, 0xb3, 0x23, 0x5b, 0x17, 0x3f, 0x90, 0x91, 0xe4, 0xcc, 0x5c, 0x89, 0xce, 0xff, 0x59, 0xf2, 0x26, - 0x17, 0x33, 0x5b, 0x75, 0xc8, 0x01, 0x6e, 0x3a, 0x13, 0x61, 0x8a, 0xf6, 0x56, 0x66, 0x23, 0x44, 0x86, 0x93, 0x49, - 0x16, 0x64, 0xea, 0xc5, 0x5f, 0x8c, 0x14, 0xfc, 0x47, 0xc4, 0x86, 0x96, 0x3c, 0xd2, 0xff, 0x70, 0x0d, 0xe1, 0x5b, - 0x39, 0x1c, 0x24, 0xd5, 0x7b, 0x2d, 0xb8, 0x2d, 0x2d, 0x1b, 0x66, 0x83, 0x24, 0x3c, 0x3e, 0xbb, 0x7c, 0xe6, 0xdb, - 0x83, 0xfc, 0x43, 0x44, 0x08, 0x84, 0xfa, 0xdf, 0xf4, 0xaa, 0x76, 0xf1, 0x32, 0x2a, 0x4e, 0x83, 0xf2, 0xf5, 0xf8, - 0x8c, 0xc3, 0x1b, 0x2a, 0x2f, 0xe0, 0xb7, 0x6f, 0xe7, 0x1c, 0x98, 0x81, 0x2f, 0x63, 0xab, 0xb1, 0x80, 0xbd, 0x70, - 0xd8, 0x63, 0x28, 0x59, 0xc4, 0xa1, 0xed, 0x6c, 0x84, 0xdb, 0xd0, 0xf5, 0x36, 0xdb, 0xaf, 0x94, 0x72, 0x75, 0xc6, - 0xf9, 0x3b, 0xdb, 0xaa, 0xe6, 0x66, 0xd7, 0x0d, 0x5b, 0x2a, 0xe9, 0x69, 0x5f, 0x6e, 0x30, 0x75, 0x43, 0xf6, 0x36, - 0xd4, 0x5a, 0xbe, 0x19, 0xd6, 0x95, 0x37, 0x0b, 0x83, 0x42, 0xc0, 0x98, 0x61, 0xcd, 0x15, 0xb9, 0xd6, 0xca, 0x7e, - 0x30, 0xc5, 0xfe, 0x30, 0x08, 0x89, 0xa8, 0x8a, 0x24, 0x67, 0x83, 0x1e, 0xe7, 0x6a, 0xed, 0x59, 0x3d, 0x02, 0x4b, - 0x27, 0x96, 0x63, 0xcd, 0x0a, 0x06, 0x43, 0xa9, 0xaa, 0xd5, 0x52, 0x77, 0xb8, 0x4a, 0x9f, 0x6a, 0x79, 0xc5, 0x0b, - 0x12, 0xf6, 0x0b, 0x88, 0x4e, 0x7c, 0x77, 0xf7, 0x24, 0xf2, 0x9d, 0x59, 0x7d, 0x63, 0x2a, 0x4d, 0x94, 0x67, 0xc5, - 0x0a, 0x4a, 0xd6, 0x3b, 0xc0, 0x50, 0x51, 0x63, 0x6c, 0xe8, 0x0e, 0x0d, 0xd6, 0xe6, 0x38, 0xdc, 0x17, 0xf6, 0xdb, - 0x82, 0xec, 0x47, 0xfd, 0x9c, 0x93, 0xfb, 0x68, 0xb3, 0xa8, 0x97, 0xf5, 0x56, 0x63, 0xe4, 0x08, 0xaf, 0x37, 0x27, - 0x55, 0xb6, 0xa0, 0xc9, 0x5e, 0x83, 0xd3, 0x4b, 0x33, 0x75, 0xa1, 0xec, 0xc4, 0x8c, 0x28, 0xd3, 0x41, 0x24, 0x09, - 0xba, 0xb3, 0x1e, 0x04, 0xd7, 0x2c, 0x0b, 0x6b, 0x93, 0x91, 0x7b, 0x30, 0x9c, 0x23, 0x15, 0xd1, 0x25, 0x14, 0xc5, - 0x39, 0x9b, 0xd7, 0x3f, 0x30, 0xe4, 0x28, 0x8f, 0xc5, 0xb2, 0x64, 0x41, 0xbd, 0x6f, 0x61, 0xa4, 0x26, 0xfb, 0x74, - 0x2c, 0xa5, 0x90, 0x1d, 0xc0, 0xc6, 0x8e, 0xb6, 0x73, 0xc1, 0x9c, 0xda, 0xba, 0x04, 0x3b, 0xd9, 0xa9, 0xb9, 0x5b, - 0x91, 0x01, 0x91, 0x07, 0x42, 0x14, 0x06, 0x7c, 0x7f, 0x5e, 0x11, 0xc0, 0x9a, 0xe3, 0x14, 0x89, 0x3f, 0x08, 0xe3, - 0x97, 0x1f, 0x14, 0x83, 0x84, 0xe5, 0xae, 0xe7, 0x70, 0xfa, 0x3a, 0x80, 0x56, 0xea, 0xc5, 0xe6, 0x7b, 0x26, 0xca, - 0x46, 0xfe, 0x2a, 0xd6, 0x3a, 0x62, 0x88, 0x70, 0xe0, 0xcb, 0x66, 0x43, 0xd2, 0x78, 0xb3, 0x5c, 0x9c, 0x8d, 0x9a, - 0xae, 0xab, 0x23, 0xee, 0x23, 0x15, 0x84, 0xb1, 0xd9, 0xf0, 0xd0, 0x8d, 0xf3, 0x43, 0xd6, 0x6e, 0x06, 0x87, 0x41, - 0x04, 0x7e, 0x6d, 0xba, 0x56, 0x97, 0x70, 0xb5, 0xa6, 0x99, 0x78, 0x2f, 0xce, 0xa6, 0xfb, 0xba, 0xd7, 0x35, 0xc2, - 0xbf, 0x5c, 0xe2, 0x80, 0xf9, 0x37, 0x52, 0xc5, 0x41, 0x8b, 0x39, 0x7a, 0x8d, 0x4b, 0x9a, 0xe9, 0xa9, 0x21, 0x77, - 0x57, 0xca, 0x7b, 0x28, 0x07, 0xaa, 0x63, 0x3c, 0x3d, 0x64, 0x37, 0x87, 0x5b, 0x80, 0xda, 0x8e, 0x10, 0x57, 0x06, - 0xea, 0x09, 0x80, 0x2b, 0x09, 0x84, 0x65, 0x1e, 0xcf, 0x90, 0xbe, 0x67, 0xd2, 0x09, 0x68, 0xe8, 0x40, 0xb9, 0xe9, - 0x49, 0x99, 0x43, 0xea, 0xa1, 0x0e, 0x52, 0x4c, 0x78, 0xd0, 0xcb, 0xae, 0x96, 0xea, 0x3a, 0x1a, 0x21, 0x69, 0x42, - 0x41, 0xfc, 0x82, 0xa0, 0xe8, 0xab, 0x21, 0xf2, 0x97, 0x89, 0xca, 0xba, 0xaa, 0xf0, 0x06, 0xff, 0x12, 0x2d, 0xb2, - 0xfa, 0xce, 0xcc, 0xec, 0x48, 0x5d, 0x56, 0xa2, 0xf6, 0x02, 0xb0, 0x0e, 0x87, 0xe0, 0x40, 0x22, 0x62, 0x9e, 0x44, - 0x13, 0xd9, 0x54, 0x28, 0x7f, 0xe6, 0xd0, 0x28, 0x80, 0xcb, 0x79, 0x24, 0x68, 0x22, 0xf0, 0xb1, 0x13, 0xe0, 0xcc, - 0x0c, 0x3c, 0x9c, 0xad, 0x26, 0x8d, 0xc0, 0x98, 0x6b, 0xe5, 0xa5, 0x66, 0x1f, 0x33, 0xa2, 0x1c, 0x17, 0x73, 0x23, - 0xbb, 0x6b, 0xf2, 0x70, 0x88, 0x79, 0x62, 0x63, 0x0e, 0xdf, 0xd7, 0x9e, 0x19, 0xd3, 0xbf, 0xcc, 0xc0, 0x27, 0x25, - 0xea, 0x4e, 0x0d, 0x8a, 0xd7, 0xed, 0x9d, 0xd7, 0x56, 0xbb, 0x86, 0x5c, 0x16, 0x1d, 0x06, 0xab, 0xb5, 0xff, 0xd7, - 0x7f, 0x8a, 0xe3, 0xbe, 0x72, 0x3e, 0x06, 0x57, 0x3c, 0x04, 0x87, 0x35, 0x43, 0xcd, 0xaf, 0xeb, 0xe2, 0x39, 0x3e, - 0x6d, 0x1f, 0xe6, 0xc6, 0xd3, 0xdd, 0x81, 0x97, 0xb9, 0x90, 0xfa, 0xcc, 0x12, 0xa2, 0x0f, 0x43, 0x8b, 0x67, 0x63, - 0x54, 0x89, 0xc6, 0x97, 0x0e, 0x29, 0x96, 0x2d, 0x9e, 0xee, 0x04, 0xe2, 0xe5, 0x70, 0x77, 0xb6, 0x40, 0xac, 0x28, - 0x11, 0xe6, 0x74, 0x22, 0xd2, 0x38, 0x02, 0xc6, 0x2b, 0xf1, 0xc0, 0x10, 0x18, 0x69, 0x94, 0x59, 0xd3, 0xfe, 0xb0, - 0x11, 0xd9, 0xe7, 0x90, 0x68, 0x32, 0x6c, 0xca, 0x3b, 0x9b, 0x51, 0x7b, 0x25, 0x12, 0x8a, 0x86, 0x75, 0xdf, 0x4d, - 0x33, 0x2a, 0xef, 0xc5, 0x38, 0x24, 0x0e, 0xe1, 0xa4, 0x77, 0x3f, 0x6d, 0x1f, 0x4a, 0x1e, 0x7e, 0x0e, 0xfb, 0xc3, - 0x0f, 0xfe, 0xe1, 0xe7, 0x70, 0x77, 0x7e, 0xf0, 0x9d, 0x9f, 0x43, 0xde, 0xf9, 0x41, 0xbc, 0x54, 0x9a, 0xbe, 0xd2, - 0x9e, 0x07, 0x63, 0xc5, 0x50, 0x2e, 0xcb, 0xc8, 0x56, 0xaa, 0xe0, 0x17, 0x3f, 0x24, 0xdc, 0xe7, 0x12, 0x29, 0x39, - 0x25, 0x2e, 0x58, 0x89, 0x4a, 0x56, 0x86, 0x4e, 0x81, 0x7d, 0x1a, 0xd0, 0xc3, 0xea, 0xed, 0xe7, 0xfc, 0xcb, 0x6d, - 0x50, 0x74, 0x26, 0xe2, 0x01, 0x24, 0x43, 0xb9, 0x33, 0x07, 0x2f, 0x4c, 0x49, 0x18, 0x65, 0x39, 0x62, 0xb4, 0xa2, - 0xd2, 0x8e, 0xb3, 0x44, 0xef, 0xdc, 0x01, 0x16, 0x82, 0xbe, 0x5d, 0xe8, 0xb5, 0x72, 0x58, 0x7f, 0xff, 0x05, 0x28, - 0x55, 0x57, 0x0c, 0x78, 0x60, 0x1f, 0x7b, 0x71, 0x1f, 0x69, 0xe5, 0xd5, 0xa4, 0x8a, 0x1a, 0x5c, 0x93, 0x83, 0x31, - 0x46, 0x48, 0xdc, 0xd3, 0xbf, 0x64, 0x4d, 0x72, 0xe6, 0xe6, 0xad, 0x66, 0xe1, 0x1e, 0xa3, 0xe7, 0x80, 0xe6, 0xc4, - 0xa8, 0x9a, 0x19, 0xb6, 0x88, 0x5a, 0xb3, 0x9a, 0x33, 0x8b, 0x38, 0x59, 0x8a, 0xad, 0xab, 0xb0, 0xe7, 0x3d, 0x7e, - 0xca, 0xef, 0xe0, 0x2a, 0x37, 0x43, 0x1a, 0xec, 0x8b, 0x0c, 0xec, 0x83, 0x2b, 0x6c, 0x6b, 0x0d, 0xa6, 0x27, 0x9c, - 0xad, 0xc5, 0xf5, 0xd5, 0x14, 0xbe, 0x20, 0xad, 0xa1, 0x2d, 0x45, 0x34, 0xba, 0x4b, 0x26, 0x36, 0x52, 0xda, 0xfa, - 0xe1, 0x6b, 0x0b, 0x8d, 0x36, 0x2b, 0x96, 0x60, 0xc9, 0xee, 0x37, 0x2f, 0xb9, 0x0f, 0x4d, 0xe6, 0x2c, 0xc8, 0x44, - 0xd5, 0x4d, 0x90, 0x36, 0x05, 0xbe, 0x38, 0x59, 0x61, 0x3c, 0x02, 0x59, 0xe4, 0x36, 0x17, 0x87, 0x53, 0x47, 0x2d, - 0xa3, 0xaa, 0x84, 0x48, 0x7d, 0x56, 0xae, 0x93, 0x4b, 0xd0, 0xf1, 0xe2, 0x40, 0x04, 0x97, 0xc3, 0x84, 0x54, 0x6a, - 0x3a, 0x6d, 0xd7, 0x68, 0x6f, 0x21, 0xcf, 0xa1, 0x4e, 0x3f, 0x0d, 0x36, 0x84, 0x21, 0xaa, 0x31, 0xf8, 0x32, 0xf3, - 0xf4, 0x9a, 0x2e, 0x4d, 0xdb, 0xc7, 0x01, 0x04, 0x7a, 0xb1, 0x3d, 0x95, 0xce, 0x5d, 0x9f, 0x92, 0x48, 0x20, 0x91, - 0xf8, 0x02, 0xe0, 0x03, 0x80, 0xaf, 0x7a, 0x89, 0xaa, 0x45, 0x26, 0xbd, 0x54, 0x81, 0x9e, 0x29, 0xb8, 0x03, 0x32, - 0x43, 0x2b, 0x40, 0xe5, 0x8f, 0x48, 0xf1, 0xb5, 0x43, 0xb2, 0x98, 0xf0, 0xd2, 0x50, 0xbc, 0x8e, 0x09, 0xed, 0x7c, - 0x98, 0x9a, 0x5e, 0x22, 0x77, 0x81, 0x94, 0x8e, 0xd8, 0xa2, 0x9f, 0xbe, 0x3c, 0xbb, 0x69, 0xe1, 0x24, 0x8f, 0x2c, - 0xbf, 0xd6, 0xfe, 0x2d, 0x6b, 0xdb, 0x55, 0xf5, 0x47, 0xa6, 0xa4, 0x0e, 0xb4, 0x21, 0x94, 0xeb, 0x99, 0xb2, 0xa7, - 0xf4, 0x15, 0xec, 0x2c, 0x86, 0x45, 0xaf, 0x2d, 0xb3, 0xdd, 0x1c, 0x3e, 0x74, 0xd1, 0x03, 0xd1, 0x84, 0xdb, 0xd7, - 0x48, 0xa0, 0xb9, 0x44, 0xb0, 0x18, 0x9e, 0xe1, 0xd2, 0x6e, 0xfc, 0x92, 0x53, 0x14, 0xc4, 0x2a, 0xf0, 0x21, 0x7d, - 0xff, 0x01, 0x43, 0x86, 0xb2, 0xdd, 0x86, 0xc3, 0x55, 0x0d, 0x34, 0x5f, 0xf7, 0x71, 0xd8, 0xab, 0x13, 0xb0, 0xb6, - 0x64, 0xbe, 0xda, 0xb4, 0x51, 0xec, 0x35, 0x97, 0xa7, 0xbd, 0xb6, 0x52, 0xe0, 0xcf, 0xc5, 0x47, 0x7f, 0x7b, 0x5e, - 0xd4, 0x2c, 0xcb, 0x8b, 0xd2, 0x7b, 0x5b, 0xd5, 0xec, 0xb4, 0x06, 0xa3, 0x3f, 0x4e, 0x85, 0x90, 0x58, 0x0e, 0x93, - 0xd2, 0xf3, 0xd1, 0xa8, 0x16, 0xbb, 0xd7, 0x64, 0x1e, 0x1f, 0x26, 0xa1, 0x9a, 0x4d, 0x8d, 0x3c, 0xb8, 0xd7, 0x9b, - 0x0b, 0x7d, 0x8f, 0x02, 0xd5, 0xbd, 0x16, 0x4e, 0xd5, 0x55, 0x29, 0x41, 0x4c, 0x46, 0x46, 0x33, 0xcd, 0xc6, 0xbc, - 0x0c, 0xdc, 0x9a, 0xa9, 0x7e, 0x41, 0x9f, 0x48, 0xc9, 0x61, 0xd8, 0x59, 0x59, 0x94, 0x8a, 0x49, 0x4a, 0x00, 0x8b, - 0xed, 0x67, 0x71, 0x72, 0x60, 0x50, 0xb5, 0xea, 0x3c, 0x60, 0x24, 0x8e, 0xc5, 0xe2, 0x23, 0x50, 0xf1, 0x5b, 0x07, - 0xa8, 0x12, 0x4e, 0x8f, 0x55, 0x71, 0x1e, 0x7e, 0x10, 0xa5, 0x52, 0x4f, 0x40, 0xa0, 0xa6, 0x4e, 0x5e, 0xe6, 0x5e, - 0xb0, 0x7c, 0x33, 0xa7, 0x8d, 0xbd, 0x30, 0x2b, 0x1d, 0x90, 0x6b, 0xd3, 0x48, 0x0c, 0x45, 0xfc, 0x93, 0x63, 0xe3, - 0x36, 0xba, 0xb0, 0xea, 0x85, 0xe5, 0x5e, 0x54, 0x07, 0xa1, 0x41, 0xe8, 0x90, 0xa7, 0xca, 0x6d, 0x19, 0xd6, 0xe7, - 0x81, 0x97, 0x27, 0xfd, 0x0b, 0x4f, 0x0f, 0x36, 0x3d, 0xfc, 0x80, 0x45, 0x2b, 0x89, 0x34, 0x54, 0xb1, 0x49, 0xe1, - 0x8e, 0x48, 0x95, 0xe5, 0xce, 0xd3, 0x1e, 0xdd, 0x6b, 0x33, 0x0f, 0xd2, 0xd5, 0x47, 0x05, 0x45, 0x6b, 0x68, 0x09, - 0xa5, 0x2e, 0x60, 0x0a, 0xa3, 0x2c, 0xde, 0xe9, 0xab, 0xf5, 0x93, 0x5d, 0x4a, 0xc2, 0x01, 0x1f, 0xc3, 0x60, 0x26, - 0xf0, 0xef, 0x87, 0x48, 0x07, 0x37, 0xb5, 0x6e, 0x85, 0x32, 0x86, 0xb4, 0x42, 0x30, 0x1f, 0x49, 0x74, 0x98, 0xe0, - 0xfb, 0xc1, 0xa4, 0xc8, 0x49, 0xc1, 0x46, 0xe3, 0x37, 0xe3, 0x1a, 0x43, 0xc7, 0x99, 0xf1, 0x9d, 0x9f, 0xae, 0xd8, - 0xdb, 0x72, 0x5c, 0x1d, 0x42, 0xc0, 0xe5, 0x58, 0xee, 0x75, 0x5d, 0x90, 0x75, 0x8c, 0x76, 0x14, 0x3e, 0x23, 0x71, - 0xa9, 0x0b, 0x3d, 0xa5, 0xda, 0x91, 0x33, 0x86, 0x25, 0x38, 0x5d, 0xcd, 0x9f, 0xd8, 0xc6, 0x15, 0xb4, 0xed, 0xec, - 0x34, 0x50, 0xb7, 0x57, 0xc0, 0x83, 0x5d, 0x63, 0x4a, 0x94, 0x25, 0x56, 0x05, 0x34, 0x18, 0x01, 0x6d, 0x59, 0x60, - 0x54, 0x13, 0x31, 0xd1, 0x28, 0x8c, 0x12, 0xa9, 0xa5, 0x94, 0x1d, 0xcb, 0x1f, 0x75, 0x92, 0x4c, 0x92, 0x75, 0x28, - 0x4e, 0x7a, 0x62, 0x92, 0xd4, 0x6a, 0x5d, 0xb6, 0x78, 0x79, 0x21, 0xf6, 0x8b, 0x54, 0x7a, 0x62, 0xef, 0xa0, 0x05, - 0x72, 0xb3, 0xef, 0x69, 0x48, 0x0d, 0x8d, 0xce, 0xf6, 0x46, 0xe7, 0xe5, 0xa9, 0x6c, 0xbe, 0xd5, 0x51, 0xcb, 0xf8, - 0xc6, 0x98, 0xa2, 0x0a, 0xa8, 0x3f, 0xd6, 0x82, 0xf4, 0xfd, 0x4b, 0xb1, 0xce, 0x50, 0x34, 0x4c, 0x5d, 0xf6, 0x58, - 0x8c, 0x74, 0x9d, 0xe6, 0x89, 0x90, 0xe0, 0xde, 0x1d, 0x18, 0x78, 0x44, 0x99, 0x3b, 0x19, 0xd3, 0x09, 0xc2, 0x10, - 0x91, 0x75, 0xb2, 0xe6, 0x7d, 0x6e, 0xfd, 0x7c, 0x14, 0x87, 0x61, 0x0c, 0x1b, 0x4c, 0xae, 0xf6, 0x53, 0x7a, 0xef, - 0xc7, 0x62, 0xa4, 0x76, 0x9d, 0xd9, 0xcd, 0x78, 0x61, 0xa9, 0x3d, 0x16, 0xf6, 0x3f, 0x64, 0x3e, 0xf5, 0x58, 0xe9, - 0xbd, 0xb4, 0x86, 0x34, 0x9e, 0x59, 0x63, 0xd5, 0x5f, 0x82, 0x76, 0xe4, 0x12, 0xed, 0xc4, 0x4e, 0xa9, 0x2a, 0x48, - 0x28, 0x48, 0x8c, 0xa9, 0xed, 0x1c, 0x0c, 0x34, 0x63, 0x9d, 0xb9, 0x63, 0x8b, 0xbe, 0x3d, 0xe5, 0xa4, 0x1c, 0xa0, - 0xbc, 0x14, 0xfe, 0xd9, 0x76, 0x50, 0x62, 0x1f, 0xc7, 0x18, 0x5b, 0x81, 0x7d, 0x48, 0x20, 0x55, 0xc1, 0x84, 0x56, - 0x93, 0x07, 0x74, 0x71, 0x4a, 0xc7, 0x9f, 0x19, 0xe6, 0x4f, 0xb0, 0xfa, 0x9a, 0x27, 0xb6, 0xd9, 0x85, 0x63, 0x4c, - 0xa9, 0xd7, 0xd9, 0x11, 0xeb, 0xa7, 0x74, 0x61, 0x8b, 0xb5, 0x31, 0xa4, 0x6c, 0xc9, 0xd6, 0xb5, 0x45, 0xc8, 0x84, - 0x21, 0xeb, 0x3a, 0x52, 0x71, 0x03, 0xe7, 0x37, 0xe4, 0x02, 0x5e, 0xef, 0xe7, 0x5c, 0xa9, 0x67, 0x11, 0xcd, 0x32, - 0x41, 0xbb, 0x04, 0x72, 0xa4, 0xf3, 0xa2, 0xfe, 0xbf, 0x95, 0x10, 0xa2, 0x4b, 0x6b, 0xba, 0x2d, 0xa1, 0x4e, 0xf2, - 0xd9, 0x59, 0xb4, 0x80, 0xc7, 0x6e, 0x94, 0x1b, 0xe7, 0xb1, 0xb4, 0x09, 0x9e, 0x0d, 0x22, 0x81, 0x0d, 0xcb, 0x29, - 0x51, 0x0d, 0xab, 0xad, 0xee, 0x7a, 0x18, 0x9f, 0xdd, 0xde, 0x28, 0xc4, 0x50, 0x61, 0xf6, 0x77, 0xa0, 0xa4, 0xe2, - 0x5e, 0x97, 0xd4, 0x3a, 0x2a, 0xff, 0x1b, 0xa5, 0x0d, 0x41, 0xe1, 0x8b, 0x9b, 0x82, 0x1d, 0xdc, 0xeb, 0x9e, 0x1a, - 0x8a, 0xfd, 0xfd, 0x42, 0x85, 0x69, 0xa7, 0x0f, 0xca, 0x04, 0x4d, 0x78, 0x0b, 0x72, 0x39, 0xf2, 0xfd, 0x6c, 0x2a, - 0xbf, 0xc8, 0x2f, 0x7d, 0x7b, 0x6d, 0x08, 0x5b, 0xd1, 0x4a, 0x2b, 0x56, 0x47, 0xf9, 0x61, 0x78, 0x13, 0xb7, 0x45, - 0x06, 0x45, 0x7d, 0x5e, 0x63, 0xef, 0x90, 0xaa, 0xc4, 0x6e, 0x7b, 0xe2, 0x06, 0x61, 0x39, 0xe9, 0x12, 0x5c, 0x58, - 0x23, 0x11, 0xa3, 0xd4, 0x9c, 0xe1, 0x54, 0x8b, 0xda, 0xc2, 0x72, 0xae, 0xd5, 0x11, 0x15, 0x10, 0xaa, 0xef, 0xa9, - 0x52, 0xd6, 0xc0, 0xb0, 0x77, 0x9e, 0x86, 0xc1, 0xcb, 0xb1, 0xab, 0x6b, 0xe5, 0xe8, 0x34, 0x5d, 0xf7, 0xb4, 0x40, - 0x81, 0x36, 0xa5, 0xb7, 0x76, 0x59, 0x8e, 0xb7, 0xea, 0x02, 0x17, 0x43, 0x0b, 0x9e, 0x3b, 0xef, 0x00, 0xbe, 0x4a, - 0x1e, 0x29, 0x3c, 0x58, 0xba, 0x76, 0x05, 0xb4, 0x30, 0x99, 0x04, 0x1e, 0x9c, 0xc5, 0x5a, 0x25, 0x6b, 0x51, 0xe1, - 0x35, 0x21, 0x0c, 0xc8, 0x59, 0x1f, 0x6c, 0xbb, 0x31, 0x72, 0x89, 0xda, 0xeb, 0x47, 0x1a, 0x5a, 0x64, 0xfd, 0xa0, - 0x49, 0xcf, 0x03, 0x45, 0xe5, 0xa8, 0x7a, 0x77, 0xa7, 0x8c, 0xbe, 0xc4, 0x3c, 0x61, 0xd4, 0x27, 0x06, 0x8d, 0xf4, - 0x85, 0x3a, 0x22, 0xe4, 0xfc, 0xc4, 0x66, 0xcd, 0x57, 0xfb, 0xf0, 0x9e, 0x10, 0xc6, 0x6a, 0xd3, 0x91, 0xcf, 0x13, - 0x68, 0xcf, 0x96, 0xae, 0x5f, 0xd4, 0x90, 0xe1, 0xb5, 0xe9, 0x72, 0x48, 0xc6, 0x82, 0xa7, 0x66, 0x08, 0x83, 0x5a, - 0xc9, 0x38, 0x4d, 0xec, 0x73, 0x16, 0x26, 0xd2, 0x55, 0xb9, 0x86, 0x00, 0xd7, 0x2f, 0x9c, 0x49, 0xb3, 0xd8, 0x72, - 0x8b, 0x92, 0xd1, 0xa5, 0x26, 0xc4, 0x16, 0x4d, 0x44, 0x06, 0x00, 0xbd, 0x1c, 0xf6, 0x11, 0x90, 0xf0, 0x6d, 0xc2, - 0xb9, 0x79, 0x62, 0x4b, 0x1b, 0xd7, 0x5c, 0x50, 0x18, 0xee, 0xe8, 0xc9, 0x5e, 0x6c, 0x2a, 0x62, 0xcf, 0x60, 0x1e, - 0x9a, 0x8d, 0x65, 0x36, 0x7f, 0xe4, 0xa7, 0xe3, 0x50, 0x0c, 0xa4, 0xff, 0xc0, 0x82, 0xf8, 0x9f, 0xa1, 0x42, 0x5c, - 0x71, 0x41, 0xfe, 0x80, 0x2b, 0x69, 0xf8, 0x82, 0x74, 0x3b, 0x9d, 0xf9, 0xd9, 0xf4, 0xa9, 0x5a, 0x40, 0x50, 0x1e, - 0x08, 0x85, 0x34, 0x17, 0x90, 0xc6, 0x0b, 0x1c, 0x58, 0x2f, 0xec, 0x90, 0x04, 0xb6, 0x9e, 0x8e, 0x64, 0xd2, 0x48, - 0xa7, 0x78, 0xe0, 0x53, 0xbd, 0xb6, 0x3f, 0xd5, 0x31, 0xa5, 0x37, 0xe5, 0x69, 0xd3, 0x3c, 0x15, 0x0f, 0x3d, 0x6b, - 0xab, 0x08, 0x13, 0x06, 0x4f, 0x85, 0x13, 0x5e, 0xef, 0xe9, 0x5a, 0xbb, 0x86, 0xaf, 0xe0, 0x8b, 0x9e, 0x0d, 0xe6, - 0xc2, 0xe6, 0x5a, 0x24, 0xe8, 0x20, 0x4c, 0x17, 0x3e, 0x3e, 0xc2, 0xc8, 0x74, 0x29, 0xbd, 0xa2, 0x1f, 0x0d, 0x0a, - 0xc5, 0xdb, 0xf5, 0x87, 0xf6, 0x2e, 0x82, 0x83, 0xb3, 0x05, 0xd9, 0x98, 0x76, 0x07, 0x20, 0x0f, 0x69, 0x51, 0xd5, - 0x18, 0x23, 0xa4, 0x42, 0x1c, 0x43, 0xc4, 0xe9, 0xf6, 0x55, 0x5b, 0x1e, 0xba, 0xe5, 0x97, 0x3c, 0x23, 0xff, 0x5e, - 0xfc, 0x99, 0xf9, 0xae, 0x6f, 0xd0, 0x15, 0xd7, 0x79, 0x0e, 0xf1, 0xbd, 0xdf, 0xb5, 0x46, 0x42, 0x94, 0x84, 0x7f, - 0x0c, 0x1e, 0x20, 0x66, 0x3c, 0x58, 0x03, 0xf6, 0xbc, 0xba, 0x91, 0x93, 0xe0, 0xbe, 0x60, 0xe8, 0x6d, 0xf3, 0xb5, - 0x7e, 0x3c, 0x26, 0xf1, 0x16, 0x6d, 0x11, 0xbb, 0x52, 0x07, 0x33, 0x76, 0xe2, 0x9c, 0x0f, 0x93, 0xd9, 0x7f, 0x8c, - 0xb0, 0xc0, 0x11, 0x0a, 0x6a, 0x2d, 0xfc, 0xb2, 0x15, 0xc0, 0xad, 0xfe, 0x83, 0x91, 0x02, 0x37, 0xd1, 0x13, 0x3f, - 0xdb, 0x3d, 0xc5, 0x26, 0x38, 0x11, 0x7b, 0x45, 0x6c, 0xcf, 0x81, 0x5a, 0xad, 0x6a, 0x0a, 0xd5, 0xad, 0xd3, 0x41, - 0xe8, 0x62, 0x51, 0x98, 0xeb, 0x75, 0x14, 0xf8, 0xac, 0x5a, 0x56, 0x1d, 0x86, 0x6c, 0x57, 0xa1, 0xf6, 0x24, 0x1b, - 0x16, 0x25, 0x2a, 0x72, 0xe3, 0x78, 0x53, 0xac, 0x03, 0xea, 0xb7, 0x7a, 0x6d, 0x82, 0x5b, 0x2f, 0x78, 0x74, 0x2c, - 0xc8, 0xb5, 0x14, 0x31, 0x78, 0x82, 0xc8, 0xe0, 0x55, 0xb9, 0x40, 0x07, 0xbd, 0x74, 0x5f, 0x37, 0x1f, 0x5a, 0xe3, - 0xe9, 0x6e, 0x1a, 0x3e, 0xfb, 0xb9, 0xf7, 0xd6, 0x88, 0xed, 0x9a, 0x31, 0x32, 0x2e, 0x92, 0x16, 0x3d, 0x75, 0x8d, - 0xcb, 0x35, 0x98, 0x3d, 0xb4, 0x3a, 0x66, 0x98, 0xbf, 0x5c, 0x69, 0x31, 0xc6, 0xef, 0x44, 0x31, 0xed, 0x41, 0x37, - 0x2b, 0xc4, 0x3d, 0xbd, 0x60, 0xc0, 0x5a, 0x4b, 0xbc, 0x69, 0xf5, 0x56, 0x5b, 0x9f, 0x2d, 0xcb, 0x20, 0xfa, 0x46, - 0x53, 0xbe, 0x9b, 0x85, 0x2c, 0x97, 0x29, 0xd6, 0x68, 0x13, 0xf6, 0xe5, 0x72, 0x6f, 0x37, 0xb6, 0x95, 0xf1, 0x6f, - 0x51, 0xf5, 0x64, 0x48, 0x24, 0x2d, 0x51, 0x2a, 0x15, 0x38, 0xe9, 0xc2, 0x10, 0x6b, 0x3a, 0x6a, 0xb9, 0x4e, 0x82, - 0xf9, 0xbe, 0x3b, 0x75, 0x58, 0xfe, 0xf8, 0x9c, 0x17, 0x69, 0xe5, 0x93, 0x22, 0xf6, 0xd9, 0xe1, 0x62, 0x42, 0x39, - 0x85, 0x33, 0xb2, 0xfb, 0x6f, 0x78, 0xb5, 0x2b, 0x80, 0x9a, 0x60, 0xf4, 0x72, 0xc9, 0xd5, 0x50, 0x94, 0x7e, 0x3a, - 0x19, 0xa6, 0x20, 0xac, 0xaf, 0xd6, 0xc2, 0x6b, 0xaf, 0x48, 0x74, 0x89, 0xbf, 0x92, 0x5e, 0x1a, 0x82, 0xa4, 0xed, - 0x50, 0x5f, 0xd5, 0x25, 0x08, 0x74, 0x88, 0x57, 0x12, 0xe0, 0x66, 0xde, 0x82, 0x26, 0x13, 0x19, 0x17, 0x6f, 0x5c, - 0x00, 0x17, 0xc6, 0xdb, 0xa7, 0x1b, 0x48, 0xd6, 0x5a, 0x62, 0x27, 0xa1, 0x9b, 0x5e, 0x1a, 0x9c, 0x00, 0x09, 0x76, - 0x3c, 0x81, 0x26, 0xef, 0x84, 0xcf, 0x5c, 0xaf, 0x26, 0xa6, 0x20, 0x88, 0xe8, 0xde, 0x73, 0xb0, 0x9b, 0xeb, 0x59, - 0x56, 0xd8, 0x84, 0xd8, 0xec, 0xa8, 0xfa, 0x7e, 0xaa, 0xc0, 0xeb, 0xa5, 0x49, 0xc5, 0x46, 0xa1, 0xeb, 0xe4, 0x0e, - 0xc7, 0x01, 0xa6, 0xb3, 0xe4, 0x50, 0xc3, 0x95, 0x8f, 0x65, 0x39, 0x49, 0x09, 0x2d, 0x85, 0x03, 0xce, 0x40, 0x72, - 0xf0, 0x3f, 0x96, 0x74, 0x90, 0x75, 0xf8, 0x89, 0x69, 0x0b, 0xfe, 0x4c, 0x5a, 0xd3, 0xb4, 0x88, 0x56, 0x7b, 0x1d, - 0x6b, 0xd0, 0xbc, 0x4a, 0x9e, 0x4f, 0x0c, 0x60, 0xb3, 0x5a, 0xc8, 0xea, 0xc7, 0x5e, 0x5b, 0xfe, 0x48, 0xf9, 0x29, - 0x0b, 0xb5, 0xa7, 0x7a, 0x6c, 0x85, 0x64, 0xa7, 0x69, 0x51, 0x11, 0xc5, 0xf5, 0x64, 0xbb, 0x21, 0x7e, 0xf8, 0x22, - 0x11, 0x94, 0x4b, 0x05, 0xc4, 0x90, 0x00, 0x04, 0x83, 0x19, 0xd4, 0x90, 0xd0, 0x51, 0x5f, 0x6f, 0x9e, 0x8e, 0x7b, - 0x08, 0x34, 0x4f, 0x85, 0x02, 0x62, 0xba, 0x62, 0x76, 0xbe, 0x0b, 0xa8, 0xe2, 0xfd, 0x1b, 0x6c, 0x9b, 0x56, 0xdf, - 0xd6, 0xb4, 0xca, 0x4f, 0xd6, 0x7f, 0xd4, 0xb9, 0x29, 0xb0, 0x21, 0x36, 0xa8, 0x52, 0x24, 0xac, 0x32, 0x06, 0x88, - 0x46, 0xcf, 0xdc, 0x64, 0x9a, 0xc2, 0xfe, 0xee, 0x3c, 0x5d, 0xf5, 0x75, 0x6a, 0xf3, 0x5d, 0xcf, 0xa5, 0xc4, 0x12, - 0x2e, 0xb3, 0xd0, 0xc7, 0x72, 0x00, 0x64, 0xa6, 0x87, 0xa5, 0x83, 0x06, 0x5f, 0x83, 0x57, 0x57, 0x2c, 0x55, 0xd7, - 0xec, 0x7e, 0xc8, 0xf8, 0xeb, 0x9b, 0xf4, 0x8a, 0xde, 0xc9, 0xc8, 0x7c, 0x73, 0xaf, 0x77, 0xd7, 0xea, 0xfa, 0x85, - 0xf5, 0x8c, 0xba, 0x54, 0x2d, 0x4f, 0x7f, 0x6f, 0xf7, 0x7d, 0x71, 0x67, 0xed, 0x4f, 0x41, 0x19, 0xdb, 0x93, 0x7c, - 0xa0, 0x9a, 0x1b, 0xff, 0x02, 0xcd, 0x9b, 0x82, 0x5a, 0x46, 0xa6, 0xbc, 0xad, 0xfd, 0x92, 0x1b, 0xf2, 0xf6, 0x44, - 0xc6, 0x11, 0xe7, 0x8e, 0x21, 0xef, 0x4b, 0xdb, 0xf8, 0xdc, 0xeb, 0x08, 0x14, 0x7e, 0x79, 0x3a, 0xa5, 0x80, 0xd6, - 0x84, 0x4b, 0xc4, 0x11, 0x5a, 0x5e, 0x97, 0x2e, 0x8a, 0x41, 0xe4, 0xe8, 0x03, 0xd8, 0xd2, 0x86, 0xe0, 0xd3, 0x22, - 0xfc, 0x6c, 0x26, 0xd4, 0x93, 0xad, 0x40, 0xad, 0x88, 0x2a, 0x7b, 0x48, 0x56, 0x02, 0xcb, 0x89, 0xe4, 0xa4, 0x27, - 0x75, 0x26, 0x90, 0x60, 0xea, 0x15, 0xef, 0xbb, 0x60, 0xc8, 0x62, 0x97, 0x2b, 0x0c, 0x2c, 0xe2, 0x64, 0xa1, 0x7e, - 0xbd, 0x3c, 0x95, 0x46, 0x0b, 0x0c, 0x01, 0x4c, 0x73, 0x2f, 0x2f, 0x1b, 0x23, 0x9e, 0xfe, 0xee, 0x86, 0x4c, 0x17, - 0x78, 0xf0, 0xcd, 0x8b, 0x9b, 0xd4, 0x52, 0x80, 0x9e, 0x9b, 0xfc, 0x6e, 0xa4, 0x9d, 0xc8, 0x09, 0xa9, 0xcd, 0x19, - 0x0e, 0x01, 0xaa, 0x9a, 0x3d, 0xc4, 0x5c, 0x2a, 0x65, 0x27, 0xae, 0x81, 0x2c, 0xbf, 0x89, 0xc0, 0x97, 0x6f, 0xe7, - 0xd8, 0x3b, 0x15, 0x95, 0xad, 0xd0, 0x0e, 0xa1, 0xa2, 0x36, 0xac, 0xee, 0xe6, 0xe1, 0x31, 0x47, 0xb0, 0xf3, 0x87, - 0x79, 0xdc, 0xd7, 0x0d, 0x8f, 0x10, 0x60, 0x05, 0xc2, 0x27, 0x04, 0x1f, 0x60, 0x88, 0x66, 0xba, 0xb5, 0xef, 0xef, - 0x55, 0x52, 0x55, 0x3c, 0x05, 0x38, 0x3e, 0xc0, 0xf0, 0xce, 0xd4, 0x63, 0xb3, 0x04, 0x9b, 0x79, 0x04, 0x86, 0x90, - 0x9b, 0xe6, 0x54, 0x53, 0x6e, 0x80, 0xf9, 0x2e, 0x62, 0x98, 0xe2, 0x91, 0xee, 0xd1, 0xf0, 0x01, 0xed, 0xc6, 0x9b, - 0x3b, 0x2f, 0xf0, 0xd3, 0x2c, 0x62, 0xd9, 0xf3, 0x64, 0x94, 0xc1, 0x27, 0x22, 0xdf, 0x22, 0x85, 0xcc, 0xfd, 0xc4, - 0x29, 0xac, 0xb6, 0x69, 0x7d, 0x51, 0x88, 0xdc, 0x5c, 0xdd, 0x98, 0x68, 0x0d, 0x5c, 0xa8, 0x4d, 0x54, 0x27, 0xd0, - 0xda, 0x66, 0x7b, 0xb8, 0xea, 0x4c, 0x24, 0x83, 0x27, 0xc2, 0xfc, 0x1b, 0xaf, 0xee, 0x64, 0xeb, 0x90, 0x8b, 0xd3, - 0xa3, 0x30, 0x57, 0x7b, 0x6b, 0xcf, 0x5b, 0xf7, 0x2d, 0x77, 0xd5, 0x9a, 0x3c, 0xa7, 0x45, 0x28, 0xb1, 0x93, 0x0c, - 0xa0, 0x08, 0xee, 0x9b, 0x41, 0xef, 0x3d, 0xd4, 0x89, 0x0c, 0x2e, 0x54, 0x31, 0xe3, 0xcc, 0x38, 0xca, 0xf3, 0x2b, - 0xae, 0x39, 0xb8, 0xfd, 0xbc, 0x71, 0x31, 0x10, 0xa0, 0xd0, 0x01, 0x99, 0xfa, 0x51, 0x99, 0xda, 0x9a, 0x26, 0xc7, - 0x7c, 0x05, 0x0b, 0x44, 0x86, 0x20, 0x00, 0x59, 0x78, 0xda, 0x56, 0xe9, 0x3e, 0x9e, 0x0c, 0x07, 0xca, 0x1b, 0x81, - 0x19, 0x19, 0x74, 0x10, 0xcd, 0x58, 0xdb, 0x99, 0x44, 0x44, 0x98, 0x84, 0x1b, 0x8b, 0x1a, 0xfe, 0xc5, 0x53, 0x52, - 0x3e, 0xe6, 0xa1, 0x87, 0x11, 0xd3, 0x62, 0x5e, 0x51, 0x7c, 0x49, 0x41, 0x3a, 0x97, 0x56, 0xdf, 0xb2, 0x4c, 0xce, - 0xa9, 0x97, 0xa1, 0xd0, 0x45, 0xc2, 0xa8, 0xb0, 0x49, 0x3d, 0x91, 0x01, 0x24, 0x63, 0x95, 0x19, 0xca, 0x15, 0x5e, - 0x8f, 0x2a, 0x79, 0x5c, 0xf2, 0x6f, 0xcc, 0xca, 0xb8, 0x1c, 0x5b, 0xd6, 0x0d, 0xeb, 0x1c, 0x1c, 0xaf, 0x54, 0xcb, - 0xe4, 0x9b, 0xa2, 0x38, 0xf1, 0xe2, 0x23, 0x06, 0xe2, 0xfd, 0xac, 0xde, 0x66, 0x9e, 0x7d, 0x58, 0xee, 0xda, 0xc2, - 0x95, 0x49, 0xc5, 0x20, 0x96, 0x30, 0x11, 0xb4, 0x28, 0x8d, 0xdf, 0x71, 0x30, 0xc5, 0x29, 0x40, 0x1b, 0x0b, 0xdf, - 0x1b, 0x49, 0x55, 0xe5, 0xb0, 0x5c, 0x46, 0x6f, 0xa5, 0xa8, 0xb3, 0x59, 0x5e, 0x46, 0x9b, 0x79, 0x12, 0x10, 0xe0, - 0xea, 0x4c, 0x59, 0xcd, 0x6e, 0x0e, 0x1d, 0x86, 0x33, 0xac, 0x2c, 0xe5, 0x84, 0x29, 0x9a, 0x35, 0x96, 0x12, 0x61, - 0xdc, 0x66, 0xb7, 0x2f, 0x8e, 0xdf, 0xd5, 0x72, 0x67, 0xfa, 0x0d, 0xdc, 0xe5, 0xae, 0x59, 0x40, 0x78, 0xe0, 0x11, - 0x9d, 0x93, 0xcb, 0x80, 0xaf, 0x8c, 0xea, 0x0d, 0x1a, 0xb0, 0x25, 0xeb, 0xa5, 0xf9, 0x58, 0x95, 0x87, 0xbe, 0x8a, - 0x5d, 0xbc, 0xd4, 0x25, 0xb4, 0x3a, 0xd4, 0xfa, 0xb0, 0xb7, 0xff, 0xb4, 0x57, 0xed, 0x34, 0xa0, 0x03, 0x62, 0x5f, - 0xeb, 0xf1, 0x65, 0x97, 0xff, 0xd5, 0x1f, 0xb7, 0x45, 0xa2, 0xed, 0x94, 0xba, 0x81, 0x0a, 0x41, 0xee, 0x40, 0xb0, - 0x95, 0xce, 0x67, 0xe5, 0x38, 0xe8, 0x85, 0x25, 0xa1, 0x16, 0x5e, 0x97, 0x97, 0x4a, 0xf0, 0x60, 0x4a, 0x49, 0xac, - 0x71, 0xaf, 0x37, 0x87, 0x01, 0x7d, 0xb8, 0xc5, 0x5a, 0x4d, 0x4c, 0x7f, 0x42, 0x54, 0x99, 0x48, 0x0f, 0x6c, 0x2f, - 0x9a, 0x98, 0xf0, 0xb0, 0x1f, 0x54, 0xa4, 0x84, 0xea, 0x40, 0xd0, 0x06, 0xca, 0xc4, 0x1c, 0x5f, 0x76, 0x28, 0x79, - 0x2e, 0xb4, 0xc0, 0x27, 0x06, 0xfb, 0x8e, 0xab, 0xb1, 0x50, 0xb1, 0x03, 0xc9, 0x31, 0x65, 0x0e, 0x37, 0xd8, 0x22, - 0xf6, 0x27, 0xd5, 0x40, 0xe9, 0xaf, 0xc6, 0x75, 0xdf, 0x56, 0x01, 0x94, 0xba, 0xe6, 0xc7, 0x7d, 0x8d, 0x42, 0x0f, - 0x16, 0xf1, 0x76, 0x08, 0xcf, 0x64, 0xbb, 0xa6, 0x22, 0xd6, 0x7c, 0x96, 0xec, 0xb9, 0x61, 0xc3, 0xdf, 0x57, 0x04, - 0x32, 0x46, 0x9a, 0x0e, 0x65, 0x6c, 0xc6, 0x2f, 0x65, 0x14, 0x53, 0x84, 0x7d, 0xe1, 0x77, 0x92, 0x10, 0x21, 0x42, - 0xc6, 0x30, 0xcd, 0x11, 0xb4, 0x33, 0x9f, 0x27, 0xb5, 0x40, 0x75, 0x4d, 0x42, 0xdf, 0xd3, 0xc3, 0x8a, 0x78, 0x90, - 0xa3, 0x47, 0x25, 0x00, 0xea, 0xbf, 0xc5, 0xbd, 0x27, 0x59, 0x31, 0x82, 0xb4, 0xe2, 0x44, 0x1a, 0x57, 0xe0, 0x38, - 0xc7, 0x27, 0x2d, 0x24, 0x88, 0x97, 0xea, 0x4e, 0x42, 0x5f, 0xb4, 0x71, 0x6a, 0xf0, 0x02, 0xb9, 0x28, 0x56, 0x2a, - 0x00, 0xb5, 0x5b, 0xf0, 0x66, 0x09, 0x33, 0x66, 0x48, 0x8f, 0xbc, 0x07, 0x6b, 0x1e, 0xf2, 0x52, 0x2e, 0x8f, 0x39, - 0x39, 0x87, 0xa8, 0xb9, 0x28, 0x92, 0x1a, 0x73, 0x05, 0x7d, 0x0d, 0x8a, 0x53, 0xe8, 0x63, 0x4c, 0xac, 0x36, 0x4f, - 0x7d, 0xaa, 0x86, 0xa2, 0xf4, 0x6c, 0x56, 0x17, 0xeb, 0x88, 0x2d, 0xb0, 0x0b, 0xcd, 0x18, 0x82, 0x5f, 0xc9, 0x24, - 0x87, 0x83, 0xb4, 0x4c, 0x04, 0x1d, 0x95, 0x17, 0x43, 0x27, 0x33, 0xda, 0xbb, 0xf4, 0x84, 0x3b, 0x7a, 0x28, 0x39, - 0x7d, 0x81, 0xd2, 0x43, 0x08, 0xd0, 0x5f, 0x8d, 0x68, 0xdc, 0xfe, 0x0a, 0x27, 0xc5, 0x8b, 0x09, 0x1f, 0x24, 0x51, - 0x84, 0x87, 0x70, 0x46, 0x14, 0x32, 0x12, 0xed, 0x43, 0xc1, 0xcc, 0x3b, 0xdb, 0xd6, 0x94, 0xf7, 0x45, 0x9d, 0x3a, - 0xcd, 0xc1, 0xcb, 0xf7, 0xe2, 0xb5, 0x5c, 0x4e, 0x3d, 0x7a, 0xec, 0xcb, 0x96, 0x90, 0x9d, 0x07, 0x00, 0x02, 0xe4, - 0x8b, 0x1d, 0x32, 0x26, 0x68, 0xc3, 0x9a, 0x96, 0x64, 0x4d, 0x3f, 0x5a, 0x84, 0x7e, 0x54, 0x7d, 0x9c, 0x66, 0x99, - 0x90, 0x6a, 0x0b, 0x63, 0x40, 0x84, 0x9e, 0x2a, 0x94, 0x60, 0x45, 0xee, 0x83, 0x97, 0xb8, 0x9a, 0x00, 0xdb, 0xb6, - 0x18, 0x9e, 0xb4, 0x37, 0x43, 0x60, 0x3b, 0x22, 0xa0, 0xd3, 0x0c, 0x89, 0x42, 0x6c, 0xb8, 0x8f, 0xd1, 0x4c, 0x52, - 0xc1, 0x98, 0x26, 0x2a, 0x1f, 0xfc, 0x07, 0xb5, 0x11, 0x37, 0x69, 0xaf, 0xe2, 0x79, 0x84, 0x3d, 0xc7, 0xa1, 0xeb, - 0xc2, 0x65, 0x40, 0x54, 0xd9, 0x72, 0x59, 0x73, 0x3d, 0x5a, 0x9e, 0x91, 0x41, 0x95, 0x48, 0xfd, 0x85, 0x5b, 0x07, - 0x95, 0x06, 0xd4, 0xb3, 0xf8, 0x64, 0xe0, 0xb9, 0x25, 0xb4, 0xdc, 0x9f, 0x23, 0x89, 0x07, 0xe0, 0xd4, 0xa3, 0x39, - 0xc2, 0x4b, 0x77, 0x87, 0x00, 0xf7, 0x56, 0x75, 0xbb, 0x69, 0x09, 0x28, 0x63, 0x27, 0xe1, 0xaa, 0xad, 0x52, 0x92, - 0x5a, 0x83, 0x12, 0xf3, 0xef, 0xf2, 0x4b, 0x3d, 0x76, 0x15, 0x1b, 0x96, 0x21, 0xd0, 0xb5, 0x42, 0xfd, 0xe5, 0x13, - 0xda, 0x49, 0xe1, 0xc6, 0xe1, 0x0d, 0xb2, 0x68, 0xf3, 0x11, 0xb5, 0x60, 0x2e, 0x50, 0x77, 0x5c, 0xd4, 0xbd, 0xf9, - 0x1b, 0xc1, 0x4d, 0x51, 0x53, 0xe8, 0x42, 0xc9, 0x46, 0x8f, 0x37, 0x12, 0x33, 0x40, 0x73, 0xb9, 0xd2, 0x0a, 0xcf, - 0xaa, 0x07, 0x6a, 0xbf, 0x21, 0x71, 0x6b, 0xbd, 0xbe, 0x0d, 0x1b, 0x3d, 0x44, 0xab, 0xc9, 0x82, 0x36, 0x46, 0x92, - 0xc7, 0xcc, 0xa1, 0xb5, 0x22, 0xd3, 0x35, 0x49, 0xb0, 0x2c, 0xa9, 0xf5, 0x6a, 0xd7, 0xf0, 0xf3, 0xb7, 0x3e, 0x40, - 0x58, 0x30, 0xb0, 0x5a, 0x49, 0xef, 0xb0, 0xdd, 0xca, 0xa5, 0x85, 0xab, 0x4d, 0xfe, 0x2c, 0x95, 0x43, 0x40, 0x9b, - 0x2c, 0xbf, 0xc4, 0xa5, 0xa7, 0x28, 0x88, 0xd4, 0x69, 0xab, 0xab, 0x84, 0x84, 0x60, 0xa5, 0x52, 0x3f, 0x1d, 0x98, - 0x90, 0x23, 0x2a, 0x47, 0x64, 0xf7, 0xba, 0x9c, 0xf3, 0x53, 0x03, 0xd2, 0xdd, 0x88, 0x48, 0xc8, 0xe9, 0x8d, 0x01, - 0x5c, 0x16, 0x1a, 0xfb, 0xdb, 0x80, 0x2b, 0x7c, 0x88, 0xe0, 0xb4, 0xef, 0x4a, 0xb9, 0x2e, 0x82, 0xfb, 0xbe, 0x40, - 0x8a, 0xaa, 0x22, 0x82, 0x05, 0xd5, 0x8e, 0x6c, 0xce, 0x8e, 0xfc, 0xc6, 0x8c, 0x02, 0xe7, 0xe6, 0x78, 0xd7, 0x28, - 0x42, 0xe9, 0x62, 0xe7, 0xbe, 0x62, 0x20, 0x4a, 0x12, 0x3e, 0x3b, 0x46, 0x68, 0xad, 0x75, 0x3e, 0xf1, 0x7e, 0xc0, - 0xb3, 0x24, 0x9c, 0x7f, 0x60, 0x93, 0xf7, 0xa5, 0x38, 0x2f, 0xaf, 0x36, 0x75, 0x5b, 0x30, 0x02, 0x50, 0x5f, 0x78, - 0xde, 0x56, 0x1e, 0xdc, 0x60, 0x64, 0x90, 0x27, 0x73, 0x81, 0xf1, 0xcc, 0xd5, 0x60, 0x9e, 0x1f, 0x3b, 0x2a, 0x04, - 0x2c, 0x04, 0xf2, 0x54, 0x53, 0x9b, 0xd6, 0x4a, 0x6c, 0xd1, 0x8e, 0xd9, 0x6f, 0xd9, 0x00, 0x27, 0xc0, 0xe9, 0x70, - 0xbc, 0xb4, 0x0d, 0xde, 0x90, 0x4b, 0x7a, 0x6b, 0x19, 0x05, 0xd9, 0x85, 0x7f, 0x1b, 0xb4, 0x06, 0xe5, 0x15, 0x08, - 0x15, 0x49, 0x1d, 0x1b, 0x25, 0xa5, 0x48, 0x1a, 0xa1, 0x65, 0xb6, 0x05, 0x59, 0x71, 0xb6, 0x47, 0x7c, 0xd5, 0xcc, - 0xe1, 0xa6, 0xc8, 0x6d, 0x91, 0xce, 0x1a, 0xee, 0x8b, 0x40, 0xc5, 0xa6, 0x90, 0x66, 0x5a, 0x23, 0xdb, 0xb8, 0x27, - 0xab, 0xb4, 0x77, 0x1b, 0x51, 0x33, 0x68, 0x44, 0xdf, 0xd2, 0x54, 0xf9, 0x7d, 0x2d, 0xaa, 0x97, 0x62, 0xa0, 0xcc, - 0x21, 0xa6, 0x6b, 0x5a, 0xc1, 0xa4, 0x4a, 0x2d, 0x8e, 0xf3, 0x36, 0x9f, 0x3e, 0x5c, 0x28, 0x87, 0xe4, 0xc0, 0x09, - 0x25, 0x47, 0x0c, 0xd9, 0x0a, 0x43, 0x70, 0x2b, 0x67, 0x13, 0xc9, 0x72, 0x23, 0x72, 0x99, 0x35, 0x46, 0x77, 0xfc, - 0x83, 0x05, 0xa0, 0xd0, 0x17, 0x1b, 0x14, 0xf4, 0x63, 0xad, 0xf5, 0x89, 0x3a, 0x52, 0x6a, 0x52, 0x7c, 0xba, 0x70, - 0x13, 0x95, 0x43, 0xcd, 0xd5, 0xab, 0xa2, 0x01, 0xb5, 0x26, 0x74, 0xc0, 0xf5, 0x08, 0x83, 0x0d, 0x84, 0xd1, 0x1f, - 0x4d, 0x21, 0x2c, 0xf7, 0x55, 0xdc, 0xb4, 0x9b, 0xbc, 0x7b, 0x3a, 0xdb, 0x63, 0xa4, 0x06, 0x15, 0x69, 0x59, 0x71, - 0x0c, 0xa7, 0x07, 0x9c, 0x83, 0xc7, 0x8e, 0x19, 0x36, 0x1b, 0xa7, 0xc7, 0x18, 0x03, 0x2c, 0x59, 0x61, 0xb1, 0x4d, - 0xa5, 0xb5, 0x22, 0x42, 0x6a, 0x9b, 0xd5, 0x4b, 0x9b, 0x3b, 0x45, 0x7e, 0xfb, 0x33, 0x00, 0xcc, 0xab, 0x26, 0xd3, - 0x3a, 0x8a, 0x29, 0x62, 0x94, 0xb4, 0x59, 0x1c, 0x2f, 0xc4, 0xca, 0x8b, 0x8f, 0x05, 0xee, 0x8f, 0x54, 0xb9, 0xb2, - 0xec, 0xb8, 0x3a, 0x93, 0xfb, 0xe1, 0xe6, 0x87, 0xcc, 0x49, 0xc4, 0x03, 0x16, 0xfa, 0x8c, 0xd9, 0x70, 0x75, 0xe2, - 0x1d, 0xa9, 0xc3, 0x2c, 0x26, 0xf7, 0xba, 0x78, 0xcb, 0xc7, 0xb9, 0x0b, 0xa8, 0xec, 0x41, 0xec, 0xb6, 0x2a, 0x63, - 0xbd, 0xce, 0xc8, 0x20, 0xe1, 0x5b, 0x2a, 0xf6, 0x4a, 0xc6, 0x4e, 0x7c, 0x06, 0x99, 0x1e, 0x2c, 0xc3, 0xc2, 0x53, - 0x46, 0x72, 0xfb, 0x4c, 0x15, 0xb5, 0xeb, 0x29, 0x95, 0xeb, 0xa2, 0x3b, 0xaf, 0xb9, 0xb7, 0x15, 0xee, 0xd4, 0xcc, - 0xa4, 0x13, 0xaf, 0x0b, 0x50, 0xe7, 0x83, 0x97, 0x16, 0xe9, 0x9c, 0x37, 0xb0, 0x6a, 0x85, 0xc2, 0x75, 0xa9, 0x46, - 0x9f, 0x5d, 0xee, 0xa3, 0x2d, 0x8e, 0x4d, 0x77, 0x7e, 0x5d, 0xf6, 0x68, 0xf2, 0x59, 0x87, 0x40, 0xec, 0x29, 0x22, - 0x3e, 0xa5, 0xc1, 0xad, 0x75, 0x98, 0x69, 0xab, 0xad, 0x0c, 0x54, 0x9b, 0xa4, 0x16, 0xf8, 0x49, 0x9b, 0xd2, 0xec, - 0x70, 0x6a, 0x79, 0xd7, 0x20, 0x96, 0xf8, 0x05, 0x0e, 0xab, 0x62, 0xf5, 0xec, 0xf1, 0x2d, 0xae, 0xac, 0x0c, 0x73, - 0xbf, 0x1e, 0x55, 0x0e, 0xb3, 0xb9, 0xe2, 0x78, 0x53, 0x1d, 0x91, 0x48, 0x6d, 0x3f, 0xf7, 0xf3, 0x27, 0x43, 0x45, - 0x8f, 0x83, 0x81, 0x38, 0x50, 0x55, 0xe4, 0x4c, 0x89, 0xb0, 0x0a, 0xa7, 0x25, 0x9a, 0x86, 0xc6, 0x3a, 0x14, 0x04, - 0x64, 0xd4, 0xff, 0x81, 0x70, 0x10, 0x99, 0xb7, 0x4e, 0x48, 0xaa, 0x2a, 0x35, 0x2c, 0xd1, 0x5e, 0xec, 0x7b, 0x48, - 0xe1, 0x21, 0x4f, 0xb6, 0x3e, 0x6f, 0xbf, 0xce, 0x91, 0x05, 0x0f, 0x04, 0xa3, 0x4c, 0x12, 0x03, 0x5b, 0x47, 0x97, - 0x7a, 0xd9, 0x8b, 0xbb, 0x4c, 0x40, 0x4f, 0x77, 0x1e, 0x7f, 0x84, 0x43, 0x51, 0xda, 0x9c, 0xbf, 0x6a, 0x49, 0x36, - 0xf3, 0xe8, 0xb6, 0x6a, 0xac, 0x43, 0x24, 0x36, 0x97, 0x1c, 0x2d, 0xe7, 0x45, 0x9e, 0x72, 0x74, 0xf9, 0x00, 0x8c, - 0x85, 0x77, 0xe7, 0x5c, 0x35, 0x17, 0x52, 0x4d, 0x5f, 0x1c, 0x13, 0xb8, 0x0e, 0x8f, 0xd8, 0x4a, 0xdb, 0x06, 0xeb, - 0xc1, 0x72, 0x88, 0xe7, 0xdc, 0x50, 0xae, 0x3f, 0xd4, 0x92, 0x6a, 0x52, 0xcf, 0x60, 0x1a, 0x2b, 0x75, 0x82, 0x26, - 0x65, 0xce, 0x2b, 0x9e, 0x3a, 0x98, 0x3a, 0x74, 0x93, 0x44, 0xf4, 0xd7, 0x91, 0x39, 0x91, 0xa4, 0x49, 0x3f, 0xb6, - 0x8d, 0x0a, 0x08, 0x80, 0x8e, 0x56, 0x08, 0x68, 0xf7, 0xbd, 0x5b, 0x7d, 0x26, 0xc9, 0x87, 0x67, 0x3d, 0x8a, 0xb9, - 0xd6, 0xd1, 0x56, 0xd7, 0xb0, 0x7c, 0x7b, 0x45, 0x18, 0xcd, 0xdb, 0x03, 0xb3, 0xc2, 0xd9, 0x88, 0x14, 0x63, 0xe7, - 0x2d, 0x20, 0x61, 0x1e, 0x22, 0xc7, 0xbb, 0x2e, 0x6a, 0xdc, 0x4a, 0xe7, 0xe8, 0xbc, 0x08, 0x4f, 0x9b, 0x2b, 0x16, - 0x4a, 0xd1, 0x4b, 0xe5, 0xd8, 0x6f, 0xde, 0x99, 0x51, 0x43, 0x5e, 0xf2, 0xb0, 0xf1, 0x7e, 0x94, 0xa7, 0xa7, 0x30, - 0x3a, 0x3f, 0xc4, 0x61, 0xee, 0x48, 0x5f, 0xa9, 0x03, 0xf4, 0x7a, 0x4f, 0x0e, 0xdf, 0xae, 0xef, 0x65, 0x27, 0x38, - 0x5c, 0x18, 0x2e, 0x8a, 0xf3, 0x05, 0xa9, 0x24, 0xe6, 0x28, 0xf5, 0x78, 0x51, 0x4f, 0xf1, 0x81, 0x78, 0xe5, 0x04, - 0xdb, 0x5e, 0xf6, 0xfd, 0xdf, 0x85, 0x33, 0x29, 0xbf, 0xef, 0x2e, 0x81, 0xaf, 0x07, 0x7f, 0xe8, 0xf6, 0x0b, 0x1c, - 0x89, 0xc8, 0x61, 0x1c, 0xee, 0xd8, 0x56, 0x71, 0xbd, 0x6f, 0xc3, 0x16, 0xa9, 0xd7, 0x1f, 0x27, 0x84, 0x5c, 0x37, - 0xe4, 0xa8, 0x3b, 0x28, 0xe2, 0x65, 0x09, 0x4c, 0xdc, 0x14, 0x42, 0x14, 0xe3, 0xbf, 0x5c, 0xcd, 0x53, 0x84, 0x5f, - 0x35, 0xa2, 0xb0, 0x55, 0x53, 0x53, 0x70, 0x57, 0x60, 0x00, 0x56, 0xf2, 0x04, 0x77, 0xa0, 0xe5, 0x43, 0x59, 0x78, - 0x85, 0x8e, 0xd5, 0xa2, 0xac, 0x04, 0x6a, 0x99, 0x21, 0x8f, 0x08, 0x4e, 0xd0, 0x5e, 0x84, 0x59, 0xd7, 0x30, 0x29, - 0xf7, 0x60, 0xf2, 0xb6, 0x6e, 0xe1, 0x75, 0xb7, 0xa9, 0xd3, 0xc3, 0xfb, 0x55, 0x69, 0xb1, 0xab, 0xb2, 0xc7, 0x03, - 0xe4, 0x28, 0x39, 0xbd, 0x03, 0x30, 0xe7, 0x61, 0x12, 0xd8, 0xea, 0xd2, 0x1c, 0xb6, 0x76, 0x97, 0xd0, 0x6f, 0x33, - 0x7c, 0xba, 0x43, 0x66, 0xa3, 0xa4, 0x9d, 0x7d, 0xfe, 0x53, 0x05, 0x8b, 0xa1, 0x37, 0x00, 0x9e, 0xb0, 0xee, 0x64, - 0xb5, 0xb0, 0x81, 0x7b, 0xfc, 0xd3, 0x87, 0xa6, 0x28, 0xa4, 0x25, 0xa6, 0xb1, 0x8b, 0xa3, 0x9a, 0x6c, 0xad, 0xf6, - 0x1a, 0x39, 0xbb, 0x21, 0x71, 0x55, 0x4a, 0x88, 0x2e, 0x47, 0xb4, 0x42, 0xb2, 0x47, 0x14, 0xc1, 0x6a, 0xef, 0x2c, - 0xdd, 0x46, 0x5f, 0xc3, 0x74, 0x05, 0x18, 0x4d, 0xc0, 0xb0, 0x41, 0xa5, 0xbd, 0x13, 0x00, 0x18, 0xa5, 0x55, 0x53, - 0xa7, 0xf4, 0x2e, 0x76, 0xd9, 0xe5, 0xe6, 0x41, 0xa6, 0xd4, 0x13, 0x35, 0x93, 0xdb, 0x03, 0x2a, 0x6b, 0x2d, 0x54, - 0xb2, 0x5f, 0x72, 0xc5, 0xa7, 0x51, 0x89, 0x56, 0xe8, 0x6a, 0x46, 0x07, 0xdd, 0x4c, 0xd1, 0x51, 0x22, 0xb6, 0x4c, - 0x4c, 0xbb, 0xf2, 0x66, 0x98, 0x78, 0xa9, 0xd8, 0x2a, 0x33, 0x22, 0x4d, 0xd9, 0xa2, 0x96, 0x23, 0xe2, 0xfc, 0xa8, - 0xbd, 0x66, 0x55, 0xa7, 0x36, 0xd6, 0x5a, 0x78, 0xba, 0x38, 0xc4, 0xe4, 0xea, 0x43, 0xb4, 0xdd, 0x07, 0x29, 0x38, - 0xd3, 0xa6, 0x8d, 0x2b, 0xb5, 0xcd, 0xbe, 0x88, 0x32, 0x5e, 0x91, 0x71, 0x11, 0xb3, 0xd9, 0xed, 0x93, 0xa5, 0x1d, - 0x26, 0xca, 0xe3, 0x98, 0x4c, 0x46, 0x0e, 0x54, 0xd2, 0x06, 0xaa, 0x25, 0xf7, 0x92, 0x15, 0x17, 0x71, 0xf1, 0xdf, - 0x68, 0xd9, 0xe6, 0xd9, 0xc2, 0xc0, 0x82, 0x16, 0x66, 0x89, 0x02, 0xb3, 0x54, 0x4a, 0x07, 0x25, 0x1c, 0x45, 0x64, - 0x27, 0x09, 0xd3, 0xcb, 0x92, 0x36, 0xf8, 0xa0, 0x91, 0xee, 0x4e, 0x26, 0x0d, 0x09, 0x97, 0x6b, 0x9c, 0xb5, 0x2d, - 0x26, 0x32, 0xe2, 0xa9, 0x6f, 0x4a, 0x26, 0xc2, 0x48, 0x3c, 0xc8, 0x94, 0x98, 0x0b, 0xcf, 0x06, 0x52, 0xe2, 0x8b, - 0x9c, 0x7e, 0xae, 0x17, 0xb3, 0xd1, 0x22, 0x8d, 0xfe, 0xa1, 0xe7, 0x97, 0xc5, 0xce, 0x6e, 0x47, 0x8c, 0x7a, 0x7b, - 0x5c, 0x79, 0x56, 0x53, 0x6b, 0xd7, 0x8c, 0x1c, 0x33, 0x06, 0x34, 0x52, 0x08, 0x14, 0xd2, 0x27, 0x23, 0x9c, 0x16, - 0x97, 0x03, 0x1b, 0x36, 0xbe, 0x53, 0x8e, 0x67, 0x0a, 0xb7, 0x17, 0x43, 0xc3, 0x73, 0x87, 0x44, 0x10, 0xa1, 0xf1, - 0x06, 0x67, 0xce, 0x50, 0xff, 0xe1, 0xe9, 0xbc, 0x35, 0xd7, 0xfd, 0x0f, 0x8a, 0x9e, 0xa5, 0x45, 0x44, 0x80, 0xf3, - 0x45, 0x45, 0x9a, 0xdb, 0x7b, 0x27, 0x8b, 0xac, 0xc7, 0x37, 0xcd, 0xfa, 0x15, 0x01, 0xdc, 0x49, 0x02, 0x42, 0x80, - 0x86, 0xd7, 0xf5, 0x7c, 0x38, 0x4b, 0x58, 0x1e, 0x60, 0xba, 0xab, 0xe0, 0xef, 0xc4, 0x4d, 0xce, 0x4b, 0x13, 0xfa, - 0xb1, 0xa8, 0xe0, 0x83, 0x9d, 0x2c, 0x10, 0x6e, 0x01, 0x96, 0x10, 0x04, 0x82, 0x92, 0x99, 0x29, 0xa6, 0x12, 0xfa, - 0x0b, 0x29, 0x21, 0x43, 0x02, 0x4c, 0x47, 0xe3, 0x82, 0x1b, 0x24, 0xd5, 0x46, 0x3c, 0xad, 0x62, 0x36, 0x9c, 0x34, - 0x0c, 0x88, 0xf5, 0xc7, 0x30, 0xd8, 0x2a, 0x26, 0xc9, 0xb0, 0xbf, 0xb3, 0x37, 0x9e, 0x0c, 0xa7, 0x4b, 0x14, 0x72, - 0xb1, 0xcf, 0x98, 0x3c, 0xa1, 0xe9, 0x17, 0x85, 0xa8, 0x2f, 0xeb, 0x82, 0xd3, 0x7b, 0x76, 0x74, 0x07, 0x4f, 0xae, - 0x32, 0xd2, 0xdb, 0x38, 0xb0, 0xdc, 0x42, 0x22, 0xc0, 0xbc, 0xdf, 0x03, 0xcd, 0x48, 0x32, 0x64, 0x28, 0x03, 0xcc, - 0x35, 0x66, 0x4f, 0x0d, 0x4d, 0x0f, 0x65, 0x47, 0x72, 0x6d, 0x12, 0xac, 0x1e, 0xe6, 0xbe, 0xbc, 0xb2, 0x6e, 0x73, - 0xbd, 0x03, 0xb9, 0x6e, 0x6b, 0x08, 0xd8, 0xe5, 0x88, 0x34, 0xa7, 0x26, 0xb7, 0x09, 0xd5, 0x03, 0x14, 0x48, 0x35, - 0xd5, 0xb4, 0x0e, 0x0e, 0x37, 0x7c, 0xd0, 0x01, 0xe1, 0x26, 0xc4, 0x46, 0xe5, 0x11, 0x7a, 0xad, 0xc6, 0x3e, 0xd1, - 0xd7, 0x92, 0x6b, 0x9a, 0x6f, 0x90, 0x3a, 0x72, 0xa9, 0xea, 0x3c, 0x4e, 0xd4, 0xb5, 0xb6, 0xda, 0x82, 0x2d, 0xc2, - 0x00, 0x8b, 0x55, 0x0c, 0x87, 0xe8, 0x54, 0xa8, 0x68, 0x89, 0x7b, 0x1b, 0x73, 0xd5, 0xcb, 0x9d, 0xb7, 0x55, 0x97, - 0x7a, 0xa7, 0x06, 0x8d, 0xc8, 0xf4, 0x50, 0x01, 0x0e, 0x84, 0x8c, 0xb5, 0x7d, 0xb0, 0x8c, 0xe3, 0x8c, 0x54, 0x65, - 0xd8, 0x08, 0x46, 0xd3, 0x01, 0xca, 0x5a, 0xf5, 0x38, 0x9c, 0x03, 0x62, 0x79, 0x48, 0x6e, 0x9a, 0xcc, 0x10, 0xd9, - 0x22, 0x9b, 0x5f, 0x6a, 0xf2, 0xe4, 0x0a, 0x1d, 0x0d, 0xfb, 0x1e, 0xd0, 0xae, 0xee, 0xc0, 0x40, 0x76, 0xf8, 0xaa, - 0x93, 0xce, 0x72, 0x09, 0xb4, 0x39, 0x86, 0xce, 0x85, 0xc5, 0x29, 0x9f, 0xa7, 0x23, 0x1b, 0xee, 0x1d, 0xe0, 0x45, - 0x47, 0xd7, 0x0b, 0xf0, 0xdb, 0xc1, 0xc5, 0x1d, 0x63, 0x0f, 0x6e, 0xca, 0xa3, 0x2c, 0x3b, 0x95, 0x30, 0x95, 0x47, - 0x13, 0x17, 0xeb, 0x9c, 0x0b, 0x5d, 0xce, 0xe6, 0x75, 0xba, 0xf5, 0x27, 0x2a, 0x86, 0x9b, 0xb5, 0x73, 0x06, 0xcf, - 0x55, 0x4e, 0x87, 0x24, 0x62, 0x49, 0x8b, 0x73, 0xf4, 0x85, 0x44, 0x9e, 0xd6, 0xf9, 0xfd, 0x42, 0x81, 0xce, 0xa9, - 0x83, 0x6a, 0x1d, 0xe3, 0xcc, 0x4e, 0x0f, 0x3a, 0xef, 0x95, 0xc6, 0xa2, 0xb1, 0x4a, 0x59, 0xf1, 0x1f, 0x38, 0xb7, - 0xf4, 0xf6, 0x84, 0xb0, 0x49, 0x2a, 0xa4, 0x50, 0x96, 0x09, 0xb7, 0x3d, 0x0e, 0x34, 0x6d, 0xe7, 0x44, 0x76, 0x5b, - 0xdf, 0xbe, 0x93, 0x24, 0x22, 0x71, 0xdb, 0x0b, 0xa2, 0xf0, 0x0c, 0xd0, 0x18, 0x92, 0xb3, 0xe7, 0x9d, 0x75, 0xf9, - 0xd2, 0xcb, 0x72, 0xbc, 0xc2, 0xde, 0x15, 0x83, 0xb1, 0xb0, 0x42, 0x0b, 0x0b, 0x37, 0x0d, 0xd4, 0xb1, 0x93, 0x24, - 0x76, 0x59, 0x12, 0x3f, 0xb6, 0xfc, 0x33, 0x69, 0x6e, 0x44, 0x9e, 0x8a, 0x8e, 0x75, 0xc8, 0x3e, 0x73, 0xaa, 0x54, - 0xf7, 0x5a, 0xe5, 0x41, 0x39, 0xe6, 0xa9, 0x1a, 0x31, 0x67, 0x6e, 0x33, 0x45, 0x3e, 0x92, 0x3e, 0x6f, 0xae, 0x67, - 0x94, 0x28, 0x10, 0xa9, 0x0b, 0xbd, 0xca, 0x9c, 0xab, 0xd0, 0x91, 0x42, 0x4a, 0xb7, 0x46, 0xb3, 0x89, 0x39, 0x0e, - 0x67, 0x3f, 0x55, 0xd9, 0x13, 0x7c, 0xed, 0x3d, 0x6f, 0xed, 0xc3, 0x66, 0x83, 0xeb, 0x50, 0xf3, 0x21, 0x3d, 0x60, - 0xa6, 0x99, 0x3b, 0x53, 0x20, 0x0b, 0xdb, 0xaf, 0xec, 0x48, 0x94, 0x32, 0xfd, 0x63, 0xa3, 0x75, 0x7d, 0xd9, 0x47, - 0x75, 0x4c, 0xfe, 0xfd, 0x2d, 0x5d, 0xc3, 0x55, 0x07, 0x45, 0x8e, 0xe1, 0x58, 0xd1, 0x6e, 0xa5, 0x3b, 0x00, 0xe1, - 0x35, 0x3b, 0x8c, 0xdc, 0x72, 0x36, 0x45, 0xbd, 0x55, 0x57, 0xc1, 0x02, 0x6a, 0xd4, 0x91, 0x94, 0xbd, 0x51, 0x58, - 0x44, 0xfd, 0x9a, 0x5d, 0x8b, 0x2b, 0x8a, 0x6e, 0x59, 0xe3, 0x7e, 0xc8, 0xec, 0xa8, 0x3f, 0xe2, 0x5a, 0xb9, 0xc3, - 0x0c, 0xd9, 0xe1, 0x1a, 0x53, 0x48, 0xea, 0x8d, 0xc6, 0xcd, 0xb6, 0xd5, 0xf3, 0x4c, 0xc3, 0xb8, 0x6d, 0xcd, 0xd2, - 0x26, 0x76, 0x50, 0x0d, 0xb7, 0x75, 0xc1, 0x54, 0xb5, 0x5d, 0xf8, 0xfa, 0xd5, 0x6e, 0x25, 0xb2, 0x26, 0xb4, 0xe1, - 0x68, 0x6b, 0x60, 0x9a, 0x16, 0xf9, 0x5c, 0xf4, 0xec, 0x6a, 0xb0, 0xc5, 0xbe, 0x0b, 0xd9, 0xbc, 0xfb, 0x6b, 0x95, - 0x84, 0x2a, 0xb9, 0x72, 0x1f, 0x97, 0xe4, 0x27, 0x9d, 0xac, 0xc2, 0x33, 0xb5, 0x8d, 0xfc, 0x0e, 0x27, 0xda, 0x87, - 0x95, 0xe6, 0x69, 0x25, 0xb3, 0x10, 0x10, 0x85, 0xae, 0xf0, 0x2a, 0x04, 0xba, 0x05, 0x0b, 0xff, 0x07, 0x3a, 0x76, - 0x65, 0x5c, 0x0a, 0xe9, 0x8d, 0xca, 0x39, 0x74, 0x43, 0x42, 0x3e, 0xb4, 0xb0, 0x9c, 0x9c, 0x97, 0x1a, 0x74, 0xb5, - 0x35, 0x74, 0x64, 0x79, 0x20, 0x02, 0xfc, 0x44, 0x0e, 0x79, 0xa6, 0x26, 0xd8, 0xfd, 0x24, 0x70, 0x96, 0x66, 0xb3, - 0x08, 0xbf, 0x18, 0x70, 0x86, 0xa4, 0xf6, 0x9e, 0x7e, 0xf9, 0x14, 0x68, 0x0f, 0xbf, 0x5c, 0x68, 0x7d, 0x72, 0x66, - 0xce, 0x51, 0x4b, 0xc7, 0x0d, 0x1c, 0xc2, 0x45, 0x69, 0xc0, 0xf7, 0x48, 0x35, 0x86, 0x29, 0x22, 0x4c, 0x0e, 0xe0, - 0x5c, 0xb9, 0x6d, 0x78, 0x56, 0x6e, 0x02, 0x33, 0x1d, 0x29, 0xa5, 0x15, 0xc7, 0xa8, 0xfb, 0xb6, 0xf7, 0xa3, 0x24, - 0xe9, 0xcd, 0xc7, 0xcb, 0xac, 0x50, 0xfa, 0x9e, 0x99, 0x85, 0xae, 0xe2, 0x77, 0x26, 0xb9, 0xab, 0x4b, 0xe8, 0xa4, - 0x5a, 0xce, 0x80, 0x51, 0xae, 0x56, 0x58, 0xee, 0x84, 0x40, 0x0e, 0x9b, 0xfb, 0xe9, 0x66, 0x90, 0x26, 0x5b, 0x51, - 0x95, 0x18, 0x23, 0x52, 0x68, 0xbf, 0xd9, 0x9d, 0xfb, 0xa3, 0xd5, 0x0c, 0x3a, 0xea, 0x3b, 0x66, 0x5c, 0xcd, 0xb7, - 0x62, 0xbb, 0xd8, 0xb0, 0x83, 0x69, 0x14, 0x75, 0x98, 0xe6, 0x01, 0x42, 0xf7, 0x2c, 0x1d, 0xa8, 0x5f, 0x10, 0x9f, - 0xf2, 0x76, 0x55, 0x6d, 0x1d, 0xe4, 0x62, 0xa6, 0xa2, 0x7c, 0x8a, 0x1a, 0x14, 0xb0, 0x68, 0xdd, 0x2e, 0x4d, 0xc0, - 0x14, 0x59, 0x48, 0xb7, 0x90, 0x82, 0x28, 0x59, 0x08, 0x66, 0x50, 0xf1, 0x99, 0xbf, 0x4c, 0x7c, 0xad, 0x8f, 0x16, - 0x3c, 0xa5, 0x27, 0x6c, 0x15, 0x72, 0x75, 0xc7, 0x68, 0x31, 0xab, 0x4e, 0x3b, 0x4e, 0x13, 0x87, 0x0e, 0x35, 0xea, - 0x88, 0xd8, 0x75, 0x7c, 0xf0, 0x54, 0x32, 0x79, 0x83, 0xec, 0x2f, 0x27, 0x01, 0x3f, 0xd6, 0xb3, 0x5f, 0x32, 0x7b, - 0x88, 0x55, 0x69, 0xc6, 0xe3, 0x85, 0xb2, 0x47, 0xe5, 0xa8, 0xa8, 0x35, 0xf6, 0x73, 0x17, 0xa7, 0xb5, 0x51, 0x49, - 0x21, 0x77, 0x1e, 0x2e, 0xe4, 0x2b, 0xa7, 0x70, 0xee, 0x46, 0x25, 0xa2, 0x3c, 0x80, 0x99, 0xb0, 0x39, 0x71, 0xa3, - 0xe2, 0x16, 0x50, 0x39, 0xd3, 0x93, 0x26, 0x31, 0x9d, 0x95, 0x88, 0x31, 0xa3, 0x4b, 0xb8, 0x1e, 0x87, 0x68, 0x0c, - 0xcd, 0x30, 0xa7, 0xf7, 0x31, 0x7a, 0x82, 0x1c, 0x50, 0x0f, 0xed, 0x5a, 0x43, 0x88, 0x99, 0x54, 0xf8, 0x56, 0xad, - 0x88, 0x2d, 0xb3, 0x4f, 0x04, 0xb5, 0x6d, 0x2e, 0xf3, 0x88, 0x28, 0x6f, 0x29, 0x7c, 0x9f, 0xfb, 0xcb, 0x77, 0x8c, - 0x57, 0x72, 0xe8, 0x9d, 0x8e, 0x92, 0x9f, 0xc3, 0xfc, 0xec, 0x37, 0x0e, 0x60, 0x01, 0x11, 0xe7, 0x92, 0x9c, 0x7a, - 0x4a, 0x96, 0xe6, 0x3a, 0xeb, 0x75, 0x13, 0xc1, 0x2c, 0x99, 0x06, 0x4c, 0xac, 0x65, 0x16, 0x40, 0x07, 0x52, 0x09, - 0x9c, 0x15, 0x95, 0x75, 0x34, 0x93, 0x47, 0x0b, 0xbd, 0x37, 0xf1, 0xf0, 0x45, 0x29, 0xc6, 0x02, 0xfc, 0xb1, 0xa5, - 0xc6, 0xa2, 0x4c, 0xdf, 0xbc, 0x08, 0x54, 0xcd, 0x5a, 0x1e, 0x87, 0x74, 0xe9, 0xf5, 0x8a, 0x9a, 0x55, 0x49, 0x6b, - 0xa9, 0x2e, 0xd0, 0x76, 0x40, 0x8e, 0x51, 0x8b, 0xea, 0x0c, 0xd2, 0x50, 0xb4, 0x07, 0x4a, 0x5f, 0xc3, 0x84, 0x1e, - 0xf0, 0x4b, 0x35, 0x90, 0xd9, 0xe0, 0x9d, 0x4d, 0xb7, 0xb8, 0x98, 0x1c, 0x39, 0xeb, 0x06, 0x10, 0x70, 0xbb, 0xde, - 0x96, 0x9a, 0x08, 0xa9, 0x70, 0x83, 0x71, 0x59, 0x24, 0xea, 0x2f, 0x9a, 0xc3, 0xda, 0x15, 0x92, 0x3a, 0xc4, 0x3a, - 0xb4, 0x30, 0x01, 0xad, 0x19, 0x17, 0x1b, 0x5a, 0x94, 0x9d, 0xc8, 0x81, 0xb5, 0x59, 0x24, 0x19, 0x87, 0x3d, 0x9a, - 0x69, 0x33, 0x91, 0x6b, 0x09, 0x9e, 0x4a, 0x44, 0x6f, 0xd1, 0xf4, 0xeb, 0x07, 0x15, 0x36, 0x37, 0x99, 0x54, 0xca, - 0x4c, 0x8f, 0x86, 0x40, 0xbb, 0x76, 0x07, 0x7c, 0x87, 0x0a, 0xfe, 0x12, 0x3e, 0x18, 0x45, 0xf7, 0xfb, 0xec, 0x69, - 0x07, 0x7c, 0x08, 0xa7, 0x4e, 0xfb, 0x45, 0x80, 0x75, 0x0e, 0x94, 0x62, 0x5d, 0x98, 0xe3, 0x8c, 0xa3, 0x76, 0x35, - 0xa3, 0x8d, 0xfd, 0xc4, 0x18, 0x02, 0x85, 0xc3, 0xb7, 0x3d, 0x5a, 0x79, 0xd5, 0xcc, 0xd6, 0x4c, 0x2f, 0x69, 0x47, - 0x3e, 0xa2, 0x46, 0x30, 0x09, 0x22, 0x69, 0x99, 0x40, 0x68, 0xc6, 0xe8, 0x2d, 0x5c, 0xc1, 0xda, 0x9c, 0x01, 0x2d, - 0x75, 0xbd, 0x50, 0xe8, 0x81, 0xa7, 0xe7, 0x4c, 0x4c, 0x0a, 0xf3, 0x01, 0x2e, 0x69, 0xff, 0xde, 0x1c, 0x66, 0x0d, - 0xd5, 0x6a, 0x6d, 0xb7, 0x65, 0x7d, 0x97, 0x28, 0x10, 0xb6, 0x1f, 0xda, 0x45, 0xf7, 0x23, 0x3f, 0xbb, 0x16, 0xa0, - 0xce, 0x62, 0xdb, 0x35, 0x9e, 0xf4, 0xd7, 0x5e, 0xb7, 0x04, 0x1f, 0xfb, 0x2b, 0x0d, 0x9f, 0x54, 0x0c, 0xcb, 0x92, - 0x09, 0xd3, 0x95, 0xe5, 0x18, 0x67, 0xa5, 0xb8, 0xcf, 0xcb, 0x98, 0x74, 0x77, 0x28, 0x31, 0x89, 0xaf, 0x3b, 0x1b, - 0xd0, 0xb7, 0x8c, 0xe8, 0x65, 0xfd, 0x56, 0xcf, 0xb0, 0xd7, 0x25, 0x80, 0x98, 0x7a, 0x45, 0xc5, 0x78, 0x98, 0xe8, - 0x8b, 0x87, 0xa0, 0x30, 0x7e, 0x94, 0xb9, 0x18, 0x7c, 0x52, 0x6f, 0x5b, 0x48, 0x84, 0x9f, 0xc6, 0xa3, 0xb8, 0x98, - 0xb5, 0x68, 0xd8, 0x76, 0x3d, 0x29, 0x0e, 0x84, 0x84, 0xd6, 0xcc, 0xa7, 0x49, 0x5a, 0x73, 0x29, 0x0c, 0xbf, 0x59, - 0x88, 0x8d, 0x66, 0xe3, 0x28, 0x5a, 0x0b, 0x60, 0x74, 0x55, 0x73, 0xc5, 0x62, 0xe0, 0x61, 0xc1, 0x43, 0xf9, 0xd2, - 0x12, 0x96, 0x3d, 0x7f, 0x9f, 0x4e, 0xe4, 0x9b, 0xbb, 0x9c, 0x6e, 0xbf, 0x77, 0x9d, 0xbd, 0xb9, 0x4b, 0x27, 0xca, - 0xea, 0x17, 0x1d, 0x95, 0xa8, 0xc6, 0xfa, 0xd8, 0xfa, 0x70, 0x97, 0x5b, 0xfd, 0x44, 0x72, 0xda, 0xd9, 0x0e, 0x18, - 0xb7, 0x14, 0xb0, 0x65, 0xda, 0x1e, 0x36, 0xe5, 0x3f, 0xde, 0xba, 0x38, 0xd2, 0x28, 0x48, 0x7c, 0xc2, 0x9c, 0x21, - 0x49, 0xf1, 0xd8, 0x64, 0x00, 0xa3, 0x96, 0x01, 0xf5, 0x08, 0xf6, 0x75, 0x63, 0x47, 0xbe, 0xb9, 0x8c, 0x71, 0xa9, - 0x4e, 0xbb, 0x0e, 0x64, 0xda, 0xe5, 0x21, 0xb0, 0x71, 0x9b, 0xbb, 0x1c, 0x28, 0x12, 0x07, 0x2a, 0x62, 0xa6, 0xfd, - 0x22, 0xf5, 0xa7, 0x1b, 0xc4, 0x46, 0xed, 0xc0, 0xf5, 0x39, 0xd8, 0x14, 0xfb, 0x64, 0xb1, 0xdf, 0xca, 0x3b, 0x3b, - 0xec, 0x8d, 0xf4, 0x47, 0x9c, 0x9b, 0xcf, 0x38, 0x30, 0xa2, 0x4a, 0x73, 0x31, 0x2b, 0x91, 0x2a, 0xb2, 0xa7, 0x95, - 0xef, 0x2f, 0xa4, 0x49, 0xe0, 0xdc, 0xad, 0x0f, 0x3d, 0x9c, 0xcc, 0x9e, 0x08, 0x93, 0x39, 0xe4, 0x3b, 0x78, 0x49, - 0x89, 0xa6, 0x0b, 0x8d, 0xb6, 0x5b, 0x07, 0x04, 0x76, 0x02, 0xe6, 0x69, 0x89, 0xbc, 0x4e, 0xc9, 0x4b, 0x7e, 0xff, - 0xf6, 0xcf, 0xd2, 0xb2, 0x80, 0xe1, 0xc8, 0x53, 0x5c, 0xa5, 0x00, 0x11, 0xc7, 0x71, 0xfe, 0x6a, 0xdd, 0x67, 0x24, - 0xc6, 0xfa, 0xf3, 0xbb, 0x1f, 0xec, 0x3e, 0x41, 0xae, 0xa4, 0xa1, 0xf0, 0xcc, 0xcd, 0x91, 0x9d, 0x83, 0xec, 0xca, - 0xb8, 0x62, 0xb7, 0x41, 0x3f, 0x89, 0x2c, 0x2a, 0xd1, 0x4c, 0xeb, 0xcd, 0x29, 0x16, 0x49, 0x49, 0x2b, 0x2c, 0x6a, - 0xc9, 0x17, 0x0c, 0xe5, 0x30, 0x59, 0x96, 0xb6, 0x9d, 0x39, 0x0e, 0xc5, 0x5a, 0x96, 0x00, 0xd9, 0xc5, 0x12, 0x9c, - 0x2b, 0x8a, 0x5c, 0x86, 0x95, 0x35, 0xb7, 0x02, 0xe3, 0xc0, 0x14, 0x7e, 0xf2, 0x8f, 0x12, 0xed, 0xef, 0x64, 0x58, - 0xb2, 0x8b, 0x3f, 0xa7, 0x2b, 0xf4, 0xda, 0xb9, 0x17, 0xcc, 0x60, 0x32, 0x44, 0xef, 0xb1, 0x84, 0x79, 0xb9, 0x13, - 0xaf, 0x4a, 0x96, 0xa5, 0x71, 0xe0, 0xa0, 0x59, 0x37, 0x6b, 0x75, 0xdf, 0x22, 0x48, 0xcb, 0x86, 0xab, 0x46, 0xac, - 0xb4, 0x12, 0x2a, 0xa1, 0x29, 0xe8, 0x88, 0x92, 0xbc, 0x44, 0x98, 0x19, 0x80, 0xb2, 0x93, 0x88, 0xca, 0x08, 0x82, - 0x63, 0x58, 0xb1, 0x98, 0x69, 0x5c, 0xd9, 0x80, 0xd5, 0xa9, 0xf1, 0x51, 0x1e, 0xba, 0x5e, 0xc0, 0x50, 0x7d, 0xed, - 0x4d, 0xc6, 0x39, 0xc6, 0xbc, 0xd6, 0x4c, 0x73, 0x72, 0xec, 0x7f, 0xdc, 0x55, 0x13, 0x24, 0x2d, 0xbe, 0x1f, 0xed, - 0x67, 0x0c, 0x4d, 0x33, 0x20, 0x96, 0x3d, 0x7c, 0xc2, 0x56, 0x07, 0x6c, 0xd2, 0x75, 0xd8, 0x48, 0x12, 0x25, 0xe8, - 0x4d, 0x9c, 0x66, 0xfb, 0x26, 0x80, 0xa1, 0xba, 0x34, 0xc8, 0x9e, 0x47, 0x46, 0xbc, 0x35, 0x96, 0x03, 0x4b, 0xbe, - 0x02, 0xba, 0xa0, 0x3c, 0xf3, 0x08, 0xce, 0xb6, 0x73, 0x20, 0x0a, 0x63, 0x2d, 0x8a, 0x4b, 0x9c, 0xf0, 0x3b, 0x92, - 0x43, 0x59, 0x32, 0x43, 0x61, 0xca, 0xe7, 0xe0, 0x5c, 0x99, 0x0f, 0x1f, 0xfe, 0x90, 0x7f, 0xfc, 0x4c, 0x57, 0x97, - 0x22, 0xf6, 0xf9, 0x71, 0x8e, 0xcf, 0xbf, 0x4d, 0x7a, 0xca, 0xed, 0x2c, 0xfc, 0x06, 0xde, 0x59, 0x42, 0xce, 0xbb, - 0x1f, 0x7e, 0xd4, 0x2d, 0x0e, 0x8a, 0x85, 0xce, 0x62, 0x8b, 0x5a, 0x70, 0xfe, 0xf9, 0x79, 0x31, 0x17, 0x55, 0x1e, - 0x13, 0x38, 0x53, 0x49, 0x59, 0xfd, 0xa6, 0x48, 0x81, 0xb4, 0x8d, 0x4a, 0xc2, 0xc6, 0xff, 0x18, 0x45, 0xf1, 0xff, - 0xa3, 0x0c, 0x85, 0x86, 0xac, 0xfd, 0xf1, 0x96, 0x45, 0x65, 0x83, 0xcd, 0xff, 0x98, 0x68, 0xad, 0x56, 0x9f, 0x09, - 0x50, 0x49, 0x5b, 0x49, 0xa5, 0x0f, 0x0a, 0x3c, 0xd3, 0xd1, 0xe4, 0x0c, 0x35, 0xc8, 0x88, 0x27, 0x8c, 0x33, 0x60, - 0x68, 0x9b, 0x75, 0xc9, 0xbb, 0x6d, 0x13, 0x7f, 0x8d, 0xc2, 0x9b, 0x32, 0xb5, 0xd1, 0x18, 0x24, 0xa7, 0x0a, 0x90, - 0xe6, 0x38, 0x5b, 0x85, 0xae, 0x68, 0xc3, 0x39, 0x37, 0x6b, 0x2d, 0x38, 0x1b, 0xc6, 0x56, 0xc3, 0x97, 0xbf, 0x20, - 0x41, 0x60, 0xd7, 0xa4, 0x0e, 0xaa, 0xb2, 0xe6, 0xc5, 0x4d, 0xf8, 0x27, 0x6c, 0x2f, 0x31, 0x98, 0xc9, 0x4b, 0x9a, - 0x77, 0xa6, 0x23, 0xa4, 0x79, 0x84, 0x9c, 0xd9, 0xfc, 0x8f, 0x62, 0x26, 0xcb, 0x43, 0x19, 0xcd, 0x7c, 0x98, 0x18, - 0xff, 0xe6, 0x49, 0x02, 0xfb, 0x99, 0xf3, 0x61, 0x14, 0x99, 0x58, 0x1e, 0xdb, 0xc6, 0x0b, 0x72, 0x1f, 0x43, 0x37, - 0x5a, 0xac, 0xb2, 0x2c, 0x63, 0x5f, 0x29, 0xb3, 0xb4, 0xb4, 0xe0, 0xf0, 0x74, 0x03, 0xa2, 0x0a, 0x9d, 0x0d, 0x21, - 0xcf, 0xa5, 0x7f, 0x59, 0xa5, 0xc2, 0xf4, 0xa1, 0xcc, 0x58, 0xeb, 0x2d, 0x10, 0x7b, 0x3d, 0x51, 0x7f, 0x72, 0x87, - 0x44, 0x9b, 0xdc, 0x68, 0xf9, 0xe0, 0x14, 0x56, 0x93, 0x74, 0x1e, 0x99, 0x88, 0x47, 0xf8, 0xce, 0xb6, 0x9f, 0xb7, - 0x4a, 0x7a, 0x7e, 0xf0, 0x11, 0x76, 0xbb, 0x34, 0xf6, 0x5e, 0xf2, 0x3b, 0xf9, 0x39, 0xfa, 0x30, 0xb8, 0x23, 0x27, - 0x25, 0xb5, 0xfd, 0xa1, 0x8f, 0x71, 0x1d, 0x28, 0xbb, 0xff, 0x41, 0xe3, 0x39, 0x64, 0x51, 0xf1, 0x68, 0x92, 0xce, - 0x30, 0x07, 0x4b, 0xfd, 0x30, 0x73, 0xe1, 0x6f, 0xd2, 0x04, 0x67, 0xd1, 0x8d, 0x5e, 0x1e, 0x4c, 0xeb, 0xc9, 0x3f, - 0x22, 0x2b, 0x7f, 0x9a, 0x65, 0x93, 0xc3, 0x69, 0xb8, 0xe0, 0x47, 0x32, 0xfa, 0xed, 0xac, 0x6e, 0x4f, 0x96, 0xad, - 0x5b, 0xed, 0x21, 0x60, 0xfa, 0x91, 0x86, 0x48, 0xde, 0x2c, 0x53, 0x85, 0x81, 0xa8, 0x18, 0x5c, 0xd0, 0x1a, 0x74, - 0x29, 0x35, 0xb5, 0x55, 0xe0, 0x8c, 0x4e, 0x04, 0x1d, 0x54, 0x70, 0xb4, 0x5c, 0xf9, 0xea, 0x07, 0x0d, 0x8b, 0x93, - 0x8a, 0xed, 0xb6, 0x28, 0x92, 0x3d, 0x83, 0xe3, 0x68, 0x11, 0x15, 0x99, 0xf1, 0xef, 0x32, 0x5a, 0x29, 0xa5, 0x54, - 0x82, 0xe0, 0x8e, 0xbe, 0xd0, 0xc5, 0x65, 0x94, 0x86, 0xfc, 0x90, 0x4a, 0xb6, 0xd0, 0x80, 0x4a, 0x29, 0x0e, 0x54, - 0x8d, 0xcb, 0x44, 0xb8, 0x32, 0x36, 0x17, 0x8d, 0x2b, 0x5a, 0x15, 0xaf, 0x62, 0x87, 0xf4, 0xfa, 0x3a, 0x51, 0x85, - 0x21, 0xcb, 0xc0, 0xe1, 0xe1, 0x1c, 0x65, 0xcd, 0x93, 0x6d, 0x28, 0xc9, 0x33, 0x15, 0x73, 0x30, 0xe3, 0x5c, 0x3d, - 0xa9, 0xc6, 0x06, 0x34, 0x54, 0x54, 0x67, 0x31, 0x9d, 0xad, 0x0e, 0xa8, 0x53, 0x02, 0x02, 0xb3, 0xf0, 0x18, 0x8f, - 0xa3, 0x10, 0x77, 0xa5, 0x0c, 0xbb, 0x70, 0x9b, 0x25, 0x58, 0x8f, 0x93, 0xe1, 0x70, 0x47, 0x1b, 0x3b, 0x17, 0x75, - 0xf8, 0x51, 0xb0, 0x4b, 0x31, 0x68, 0x48, 0x23, 0x24, 0xb9, 0xd8, 0x39, 0x75, 0x2c, 0x9a, 0x70, 0x27, 0x0b, 0x02, - 0x20, 0xf2, 0x30, 0xf5, 0xc1, 0xe2, 0xf2, 0xa8, 0xb3, 0x80, 0x89, 0x79, 0xae, 0xec, 0xa2, 0xbc, 0x81, 0xaf, 0xd6, - 0xa1, 0x1c, 0x62, 0x99, 0xc4, 0x58, 0x69, 0x33, 0xfe, 0x77, 0x59, 0x1e, 0xa5, 0x37, 0x96, 0xd5, 0xb4, 0x45, 0xf5, - 0xa0, 0xd1, 0x1d, 0xae, 0x1d, 0x31, 0x36, 0x96, 0x59, 0x27, 0x86, 0x05, 0xf3, 0xdf, 0x67, 0x86, 0xc5, 0x46, 0x55, - 0xcb, 0x37, 0x39, 0xdf, 0xbd, 0xe3, 0x53, 0x08, 0x66, 0x51, 0xc3, 0x03, 0xee, 0x1e, 0x96, 0x31, 0x2c, 0x16, 0x04, - 0xb3, 0xec, 0xc1, 0xc3, 0xba, 0x1a, 0x82, 0x34, 0xe3, 0x51, 0x92, 0x40, 0x37, 0x62, 0x28, 0x46, 0x72, 0x46, 0xc0, - 0x26, 0x29, 0xc4, 0xe0, 0x15, 0xb0, 0x3f, 0xd6, 0x25, 0xa4, 0x82, 0x23, 0x3c, 0x20, 0x1c, 0xaa, 0xb8, 0xfc, 0xb0, - 0x88, 0x61, 0x20, 0x86, 0x54, 0xbc, 0x98, 0x95, 0x4f, 0x0b, 0x80, 0x91, 0x35, 0xaa, 0x78, 0x48, 0x86, 0xc8, 0xc8, - 0x9b, 0x16, 0x19, 0x75, 0xf0, 0xc6, 0xf0, 0x1b, 0x11, 0x03, 0x5e, 0xc9, 0x19, 0xe4, 0x31, 0x27, 0x2b, 0x73, 0xf9, - 0x32, 0x77, 0xe9, 0xb7, 0xe3, 0xa9, 0x1c, 0xb7, 0xcd, 0x3c, 0xb4, 0xe9, 0x65, 0xac, 0x73, 0x51, 0x71, 0x70, 0xbd, - 0xcc, 0x31, 0xa2, 0xa7, 0xf9, 0xc2, 0xb5, 0x45, 0xf6, 0xb4, 0x86, 0xeb, 0xd7, 0x2a, 0xfb, 0xf0, 0x09, 0x19, 0x5b, - 0x40, 0x86, 0x87, 0x9d, 0xba, 0x6d, 0x64, 0x0c, 0x23, 0xf0, 0xdf, 0xc6, 0xf7, 0x13, 0xe7, 0x98, 0x2e, 0x73, 0x41, - 0x72, 0x98, 0x17, 0xf8, 0xb6, 0x30, 0xfe, 0x92, 0x73, 0x1c, 0x8d, 0xc9, 0xba, 0x87, 0x1a, 0xdd, 0xbd, 0xb4, 0xe1, - 0x0b, 0x26, 0xe8, 0xfc, 0x12, 0x1d, 0x5f, 0x93, 0x06, 0xcb, 0x7d, 0xde, 0xd7, 0x33, 0x64, 0x1a, 0x0f, 0x63, 0x4c, - 0xc8, 0x35, 0x9e, 0x33, 0xd1, 0x8d, 0x7a, 0xcf, 0x96, 0xb1, 0x96, 0xd8, 0x2a, 0xa2, 0xcd, 0x36, 0x58, 0x39, 0xaa, - 0xfe, 0xf5, 0x5d, 0x24, 0x82, 0x11, 0x35, 0xed, 0xd3, 0x5a, 0xdf, 0xa8, 0x3c, 0xf3, 0xdb, 0x99, 0x77, 0x1b, 0x56, - 0x86, 0x19, 0x04, 0x33, 0xbe, 0x62, 0xce, 0xd0, 0xcf, 0x23, 0x73, 0x0f, 0xbc, 0xdd, 0x4b, 0xef, 0xc6, 0x9a, 0x35, - 0xfa, 0x61, 0xba, 0x53, 0x92, 0x59, 0x60, 0x3b, 0xfe, 0x4d, 0xd0, 0x53, 0x21, 0xf5, 0xa3, 0x3a, 0xb0, 0xf8, 0x9a, - 0x93, 0x98, 0x90, 0x0c, 0x39, 0x58, 0x90, 0xab, 0xe6, 0xbd, 0xa7, 0xdb, 0xb6, 0x8c, 0x0a, 0x71, 0xe9, 0x74, 0xf5, - 0xe5, 0xf5, 0xda, 0x0b, 0xb4, 0xa3, 0xfa, 0xd1, 0xc6, 0xcb, 0x78, 0xf1, 0x78, 0x03, 0x77, 0x22, 0x7e, 0x43, 0x6e, - 0x68, 0x8c, 0xaf, 0xc2, 0xa3, 0xb5, 0xda, 0x2b, 0xae, 0xbd, 0x69, 0xee, 0xf1, 0x8b, 0xb9, 0x56, 0x67, 0x4e, 0xb5, - 0x57, 0x66, 0x5c, 0x99, 0xb8, 0x58, 0x51, 0x92, 0x0f, 0x5f, 0x10, 0x5c, 0xc7, 0xcf, 0xd6, 0x41, 0xb8, 0xeb, 0xf1, - 0x9d, 0x1c, 0x2c, 0xc5, 0xc0, 0x74, 0x03, 0xd7, 0x81, 0x18, 0xc3, 0xd8, 0x22, 0x01, 0x92, 0xfa, 0xa1, 0x6c, 0xc5, - 0x28, 0x18, 0xbf, 0x3e, 0x5e, 0xb6, 0xea, 0x1d, 0xff, 0x61, 0x09, 0xe0, 0xd8, 0x46, 0x38, 0x02, 0xcd, 0xac, 0x38, - 0xe5, 0x52, 0x5c, 0xe8, 0x23, 0x38, 0xb3, 0x29, 0xfb, 0x80, 0xe3, 0x90, 0x4d, 0x30, 0xed, 0x8f, 0x86, 0xca, 0xef, - 0x9f, 0xc8, 0x8f, 0x6b, 0x77, 0xbf, 0x57, 0xd7, 0x16, 0x9e, 0xfe, 0x73, 0x87, 0xde, 0x49, 0x47, 0x5e, 0x3b, 0x1d, - 0x75, 0xb8, 0x02, 0xd9, 0x71, 0x53, 0x7b, 0x56, 0x57, 0x5f, 0xc9, 0xf1, 0x63, 0x7f, 0x5b, 0x95, 0x6e, 0xe3, 0xfd, - 0xf3, 0xb2, 0xaa, 0xec, 0x6c, 0xaf, 0x5c, 0x17, 0x7f, 0x59, 0x69, 0xf2, 0xda, 0x7f, 0xd1, 0x6f, 0xe7, 0xa4, 0x1f, - 0xe6, 0x9b, 0x45, 0x8b, 0xbc, 0x61, 0x9b, 0x01, 0xfe, 0xf1, 0xa0, 0xf1, 0x50, 0x44, 0xd8, 0xdc, 0x75, 0xbf, 0xb5, - 0xa1, 0x41, 0x31, 0x27, 0xef, 0x04, 0x29, 0x0a, 0x20, 0x71, 0xc7, 0x5a, 0x01, 0x38, 0x06, 0x86, 0xc1, 0x73, 0xef, - 0x53, 0xeb, 0xc6, 0x14, 0x75, 0xb9, 0x7a, 0xa2, 0xb1, 0x9b, 0xad, 0x37, 0xf4, 0x35, 0x6e, 0xf4, 0x1f, 0x91, 0x0b, - 0x11, 0x18, 0x3c, 0x3f, 0x80, 0xfb, 0xc7, 0x29, 0x7b, 0xd1, 0x62, 0x52, 0x79, 0xc3, 0xe7, 0xf6, 0xf5, 0xe1, 0xb5, - 0x7c, 0x9a, 0xcd, 0x05, 0x12, 0xbd, 0x3e, 0x36, 0xb5, 0xd0, 0x14, 0xb9, 0x96, 0x3b, 0xd9, 0xc5, 0xd1, 0x34, 0xc4, - 0x68, 0x01, 0x50, 0x28, 0x03, 0xc6, 0x4f, 0xb0, 0x86, 0x3a, 0xe3, 0x9f, 0xcf, 0xa7, 0x3c, 0xa7, 0xfb, 0xcd, 0x5b, - 0x33, 0xbd, 0xa5, 0x39, 0xe0, 0xdb, 0x90, 0xff, 0xdb, 0x3f, 0xd1, 0xad, 0x63, 0xac, 0xf6, 0x98, 0x1d, 0x5c, 0x9b, - 0x6b, 0x59, 0xf4, 0x6f, 0x6b, 0xe2, 0xca, 0xeb, 0xd1, 0x0f, 0xf8, 0x75, 0xee, 0x0b, 0x81, 0xd1, 0x14, 0x9e, 0xb1, - 0x98, 0xb4, 0x55, 0xae, 0xef, 0x7a, 0xc2, 0x6c, 0x1b, 0x9d, 0x22, 0x35, 0x04, 0xd7, 0xfb, 0x18, 0x57, 0x1b, 0x4f, - 0xa2, 0xb2, 0xda, 0xbe, 0x79, 0x2a, 0xc0, 0x85, 0xc6, 0xf2, 0x4f, 0xd4, 0x79, 0xbb, 0x47, 0x6d, 0x72, 0xda, 0x3f, - 0x69, 0xed, 0x9e, 0x4b, 0x0f, 0x1d, 0xe9, 0xb1, 0xe9, 0x53, 0x6b, 0xde, 0x10, 0xec, 0x5b, 0xd2, 0x62, 0x2f, 0x00, - 0xdc, 0x01, 0x9e, 0xa8, 0x36, 0xd1, 0xb3, 0xaa, 0x7f, 0xec, 0x01, 0x69, 0x8c, 0xef, 0x31, 0x49, 0x95, 0x1b, 0xb9, - 0x50, 0xb3, 0x48, 0x50, 0x74, 0x1c, 0x1f, 0xdf, 0x31, 0xad, 0xd6, 0xc3, 0xf3, 0x62, 0x55, 0x0a, 0x63, 0xcb, 0xdc, - 0x9b, 0x79, 0x90, 0xd3, 0x54, 0x1f, 0xbc, 0x16, 0xee, 0x1b, 0xba, 0x14, 0x3e, 0x16, 0x8f, 0x5a, 0xed, 0x80, 0x9c, - 0x6c, 0x41, 0x08, 0x47, 0x74, 0xfe, 0x52, 0x32, 0x53, 0x80, 0xd7, 0x81, 0xbb, 0xe2, 0x18, 0x3d, 0xb6, 0xe3, 0x6e, - 0x54, 0xdc, 0xc2, 0x9f, 0x1d, 0x44, 0x11, 0xd6, 0x55, 0xbb, 0x35, 0x61, 0xce, 0xcb, 0x14, 0x46, 0xa9, 0x90, 0x80, - 0x70, 0xb8, 0xcc, 0x0d, 0x50, 0x42, 0x49, 0x40, 0x5b, 0x15, 0xd5, 0x1f, 0xca, 0xdc, 0x76, 0xbb, 0x51, 0x73, 0x1e, - 0x89, 0x87, 0x81, 0x8a, 0xf5, 0x98, 0xd6, 0x5a, 0x92, 0x03, 0x0a, 0x51, 0xb3, 0xc9, 0xf3, 0xf2, 0x8f, 0xf5, 0x48, - 0x2e, 0x05, 0x8f, 0x44, 0x2c, 0xde, 0x96, 0xe4, 0x9b, 0xfc, 0xf1, 0x0c, 0x99, 0xbd, 0xe5, 0xe4, 0x87, 0x39, 0x4c, - 0x27, 0x76, 0x19, 0xf0, 0x04, 0x05, 0xac, 0x51, 0x8f, 0xb6, 0xa2, 0xa7, 0x80, 0x74, 0x98, 0x15, 0x0c, 0x08, 0x4e, - 0xa9, 0x5f, 0xe6, 0x47, 0xbe, 0xd9, 0x96, 0x42, 0x55, 0x89, 0x68, 0x29, 0x0b, 0xbe, 0xdc, 0x9e, 0x6f, 0x26, 0x94, - 0xac, 0xb8, 0xa6, 0xb6, 0x99, 0xad, 0xa2, 0x45, 0x2b, 0x08, 0x7f, 0x5c, 0xcd, 0x8c, 0xa8, 0xbf, 0x90, 0x6e, 0xd6, - 0xb4, 0x7d, 0x80, 0xb4, 0x9a, 0x53, 0x3b, 0x3b, 0x47, 0x73, 0x41, 0x03, 0xf5, 0x18, 0xc1, 0xc6, 0xe2, 0x52, 0x93, - 0x72, 0xd6, 0x39, 0xaf, 0xc6, 0x1b, 0x86, 0xdb, 0x4d, 0x52, 0x2f, 0x8a, 0x1b, 0x57, 0x37, 0x3a, 0xe9, 0x4b, 0xd0, - 0xc1, 0xa0, 0x03, 0x86, 0x94, 0x5a, 0x85, 0x8a, 0xec, 0xce, 0x62, 0x5d, 0x38, 0x4d, 0x48, 0x3a, 0x5d, 0xf1, 0x72, - 0x52, 0xbc, 0x67, 0x84, 0x38, 0xfa, 0x01, 0x29, 0x93, 0x47, 0xa8, 0x49, 0x5e, 0xfb, 0x80, 0x21, 0xf3, 0x67, 0xd4, - 0xe2, 0xb0, 0xa1, 0x0d, 0xa2, 0x7f, 0x10, 0x38, 0x1e, 0x47, 0x90, 0x0a, 0xd6, 0x53, 0x32, 0xba, 0x04, 0x48, 0x7a, - 0x09, 0x9f, 0x1e, 0xb1, 0x60, 0x6a, 0xee, 0x94, 0x82, 0xe2, 0xc9, 0x00, 0x43, 0x5b, 0x69, 0x54, 0x96, 0x54, 0x4e, - 0xf4, 0x40, 0x03, 0xef, 0x29, 0x14, 0x10, 0x46, 0x9c, 0x3d, 0xf6, 0x39, 0x2f, 0x62, 0x50, 0xec, 0xad, 0x41, 0xe8, - 0x3e, 0x03, 0xd8, 0xc8, 0x33, 0x0c, 0x16, 0x79, 0x5e, 0x21, 0x47, 0x65, 0x2f, 0xab, 0xb9, 0xff, 0x72, 0x46, 0xd9, - 0xc0, 0xe0, 0x51, 0x3d, 0xe9, 0xe4, 0x5a, 0xbf, 0x0e, 0x27, 0xc8, 0x59, 0xfa, 0x94, 0xd5, 0xa3, 0x76, 0x6e, 0xca, - 0x98, 0xac, 0x2b, 0xf5, 0x67, 0xee, 0x61, 0x24, 0xdf, 0xca, 0x99, 0x51, 0x16, 0xa9, 0x88, 0x17, 0x7e, 0x00, 0xa5, - 0x9f, 0x67, 0x1d, 0x83, 0xc2, 0x13, 0x0b, 0x0d, 0x81, 0x38, 0xc4, 0x35, 0x36, 0xb8, 0x71, 0x20, 0x18, 0x35, 0x68, - 0x4c, 0x6e, 0x51, 0xad, 0x29, 0x72, 0x2d, 0xd4, 0xa7, 0x06, 0x43, 0x6d, 0x9c, 0x79, 0x66, 0x25, 0x98, 0xd0, 0xf0, - 0x92, 0x4f, 0x95, 0xac, 0xa3, 0xb8, 0xc2, 0x2f, 0x57, 0x80, 0xd9, 0xc0, 0x34, 0x77, 0x1d, 0x60, 0xb0, 0xd2, 0x9c, - 0x9a, 0x91, 0x67, 0xe7, 0x0e, 0xa1, 0xd4, 0x8d, 0x5e, 0xc0, 0x04, 0x30, 0x1c, 0x02, 0xda, 0xa0, 0x97, 0x17, 0x3e, - 0x5c, 0x90, 0xaa, 0x1d, 0x19, 0x70, 0xb4, 0xc8, 0x89, 0xb2, 0x75, 0x88, 0xff, 0x99, 0x48, 0x48, 0xda, 0xec, 0x40, - 0xbc, 0x39, 0x76, 0x53, 0xc7, 0xaa, 0xe7, 0x20, 0xbf, 0xba, 0xc1, 0x5e, 0x2b, 0xae, 0x4c, 0x93, 0x1a, 0x7a, 0x35, - 0x1a, 0x87, 0x82, 0xb4, 0xbc, 0x98, 0xdd, 0x78, 0xd2, 0x24, 0xba, 0x2c, 0xdd, 0x34, 0xe8, 0x21, 0xbc, 0x33, 0x0f, - 0xf9, 0x1d, 0xef, 0xeb, 0xc9, 0xfe, 0x81, 0xa2, 0x43, 0x60, 0xb7, 0x21, 0xd7, 0x15, 0x5f, 0x3f, 0x21, 0xc5, 0x32, - 0xda, 0xa7, 0x5d, 0x5b, 0xf3, 0xd4, 0xe2, 0x04, 0x86, 0xbd, 0x9c, 0x8d, 0x0b, 0x6e, 0x55, 0x84, 0x61, 0x6a, 0x28, - 0x25, 0x97, 0x9d, 0x6e, 0x49, 0x4e, 0xae, 0x05, 0x1a, 0x83, 0x40, 0x71, 0x1e, 0xf7, 0x9f, 0xd3, 0x97, 0x12, 0x6c, - 0xc7, 0x0e, 0x46, 0x27, 0xe9, 0x3d, 0xad, 0x93, 0xa3, 0xa2, 0xb0, 0xdd, 0x29, 0xdd, 0x38, 0xf0, 0xc7, 0x89, 0x2a, - 0xb5, 0x25, 0x06, 0x9e, 0xef, 0xae, 0x4d, 0x42, 0x5b, 0x73, 0x0e, 0xb0, 0x12, 0x00, 0x28, 0x2f, 0x06, 0x55, 0xbb, - 0x78, 0xe0, 0xa6, 0xa5, 0x6d, 0x70, 0xd3, 0x40, 0x8d, 0x44, 0x04, 0x51, 0x40, 0xc2, 0xd4, 0x3f, 0x37, 0x25, 0x3b, - 0xf1, 0x8e, 0x79, 0x27, 0x0a, 0x15, 0x92, 0x06, 0xda, 0x79, 0xf5, 0xf0, 0xe8, 0x63, 0x42, 0x58, 0x63, 0x9c, 0x18, - 0xdb, 0x80, 0x7d, 0xd7, 0x5a, 0xd1, 0x5c, 0x17, 0xf4, 0xb6, 0xee, 0x14, 0xd5, 0x1c, 0xa0, 0x93, 0x70, 0x3b, 0x0f, - 0x3e, 0x42, 0x46, 0x95, 0xbc, 0xdf, 0xe5, 0xc8, 0xe3, 0x12, 0x04, 0x39, 0xdf, 0x36, 0x54, 0x1e, 0x83, 0x07, 0x51, - 0xe0, 0x83, 0x2a, 0x06, 0x53, 0xe5, 0xc4, 0x4d, 0x20, 0xd5, 0x08, 0x32, 0xaf, 0x22, 0xc4, 0x2b, 0x3a, 0xf9, 0x7d, - 0x8f, 0x40, 0xac, 0x12, 0x9e, 0x24, 0xf3, 0x2a, 0xf9, 0x34, 0x23, 0xae, 0x6a, 0x83, 0x61, 0x66, 0xd8, 0x6e, 0xc9, - 0x69, 0x8c, 0x88, 0xa7, 0xe3, 0x66, 0x6f, 0xe2, 0xb9, 0x11, 0xd8, 0xc2, 0x51, 0xc4, 0xf2, 0x35, 0xd1, 0x19, 0x98, - 0x21, 0x55, 0x85, 0xd8, 0x5c, 0xf2, 0x19, 0x91, 0x8d, 0xc2, 0xf5, 0xb5, 0xf8, 0xbb, 0x4a, 0x29, 0x11, 0x19, 0xea, - 0x6b, 0xf5, 0x4f, 0xd1, 0x08, 0xdb, 0xa9, 0x62, 0xf4, 0xfa, 0xf1, 0x81, 0x7a, 0x04, 0x3a, 0x53, 0xaa, 0x8d, 0x52, - 0x2d, 0x98, 0xd7, 0x5a, 0xa1, 0x61, 0xa1, 0x75, 0xd4, 0xa7, 0x26, 0xc3, 0xe2, 0xc7, 0xab, 0xdc, 0x2e, 0x06, 0x80, - 0x4e, 0x02, 0x94, 0xec, 0x0f, 0x2d, 0xf5, 0xcd, 0x2a, 0x4b, 0x12, 0x80, 0xcc, 0x01, 0xd8, 0xe3, 0x38, 0xa9, 0x59, - 0x91, 0x1d, 0xfd, 0x42, 0x54, 0x4e, 0xd8, 0xe1, 0x8b, 0xa5, 0x69, 0xfe, 0x00, 0x57, 0x09, 0xcc, 0x08, 0x31, 0x19, - 0xd7, 0x51, 0x67, 0x83, 0xd0, 0x02, 0xa0, 0x5b, 0xda, 0xa9, 0x87, 0xe4, 0xed, 0x7a, 0x0c, 0x52, 0xf6, 0x01, 0xea, - 0xbc, 0xab, 0xe1, 0xfa, 0x08, 0xc3, 0x9c, 0x1d, 0x24, 0xa0, 0x9d, 0xaa, 0xd0, 0x9f, 0x9a, 0xa6, 0x72, 0x44, 0xaf, - 0xa7, 0x4d, 0x07, 0x95, 0xbb, 0x89, 0x2a, 0x13, 0xe0, 0xe0, 0x4d, 0x3c, 0x49, 0xe2, 0xb0, 0xdb, 0x4b, 0x4d, 0x53, - 0x3f, 0x99, 0xb8, 0xb2, 0x5a, 0x4d, 0xf9, 0x76, 0x6e, 0x95, 0xd0, 0xd2, 0xe3, 0x42, 0x88, 0x79, 0xbc, 0xe7, 0x81, - 0xeb, 0x65, 0xdf, 0xc8, 0x1a, 0x2c, 0xee, 0x9b, 0x95, 0x51, 0x95, 0xd3, 0x11, 0x1a, 0x93, 0x62, 0x9e, 0xfc, 0x05, - 0x88, 0xd1, 0xdd, 0x4e, 0xd3, 0xeb, 0x10, 0x42, 0x44, 0x37, 0xfb, 0xf6, 0x9e, 0x1a, 0xeb, 0x88, 0x3d, 0x21, 0x2c, - 0x73, 0xc3, 0x4b, 0x74, 0x0c, 0xdc, 0xf6, 0xae, 0x2c, 0xc9, 0x74, 0xf9, 0xdc, 0x17, 0x20, 0x7c, 0x1d, 0x30, 0x43, - 0x0a, 0x54, 0x4a, 0xec, 0x83, 0xcd, 0xf7, 0x91, 0xd0, 0x3c, 0x3d, 0x17, 0xb6, 0x11, 0x7a, 0xbe, 0xec, 0xb3, 0xf5, - 0x5b, 0x38, 0x62, 0x6b, 0xab, 0x60, 0x0f, 0x7b, 0xb9, 0x6e, 0x91, 0xd1, 0x3c, 0xf8, 0x85, 0xe9, 0x2c, 0x0b, 0x89, - 0x57, 0x1b, 0xf5, 0x0d, 0xeb, 0x1d, 0x5b, 0xfa, 0x4c, 0x66, 0x4d, 0x3c, 0x4c, 0xd6, 0xd3, 0xc8, 0xc3, 0xc9, 0xa9, - 0x3c, 0xc7, 0xe6, 0xa9, 0xb0, 0xc0, 0x1b, 0xba, 0x7a, 0x7a, 0xcb, 0xb8, 0xf7, 0xa6, 0x21, 0x79, 0x89, 0xcf, 0xce, - 0xa2, 0x05, 0xa0, 0x98, 0xa8, 0x9c, 0x5e, 0xbb, 0xc0, 0x09, 0xf6, 0x7a, 0x51, 0x41, 0x83, 0x63, 0xe4, 0xd8, 0x96, - 0xe0, 0xe9, 0x70, 0x26, 0x67, 0x9d, 0x0b, 0x48, 0x5f, 0x33, 0xa9, 0x39, 0x0b, 0x73, 0x4e, 0x4a, 0x11, 0xf8, 0xe8, - 0x51, 0x9c, 0xa3, 0x79, 0xba, 0x01, 0x04, 0x86, 0x8a, 0xf7, 0x5d, 0x60, 0x8f, 0x37, 0x1c, 0xa9, 0x8b, 0x1c, 0xac, - 0xe4, 0x3d, 0x31, 0xcc, 0x0a, 0xfd, 0xeb, 0xe7, 0x07, 0x2b, 0x85, 0x8a, 0x5c, 0x8e, 0x51, 0x88, 0x62, 0xf7, 0x8c, - 0x08, 0xcc, 0x4d, 0xa5, 0x3a, 0x08, 0xd4, 0xf2, 0x0f, 0xb6, 0x5f, 0x08, 0x57, 0x4a, 0x70, 0xeb, 0x41, 0x5d, 0x5a, - 0x42, 0xc6, 0x1e, 0xce, 0xea, 0x2d, 0xd2, 0x58, 0x40, 0xb0, 0xc7, 0x5c, 0x6b, 0x7a, 0x98, 0x03, 0xc9, 0xac, 0x06, - 0x18, 0x6d, 0x89, 0x20, 0xf5, 0x82, 0xc1, 0x2e, 0x15, 0xdd, 0xd7, 0x05, 0x45, 0xba, 0xcb, 0xa8, 0x31, 0x95, 0x56, - 0x72, 0x7c, 0x1e, 0x62, 0x7f, 0xad, 0xa9, 0x5a, 0xea, 0xab, 0xec, 0x6b, 0x72, 0xba, 0xbb, 0x5f, 0x6c, 0xfc, 0x48, - 0xf8, 0x79, 0xae, 0x98, 0x41, 0x95, 0x8c, 0xa3, 0x5d, 0xc2, 0xa4, 0xa1, 0x7a, 0xa5, 0x38, 0x6e, 0x2c, 0x37, 0x9e, - 0x6e, 0x5f, 0x74, 0xc6, 0x56, 0xe9, 0xbf, 0xbb, 0x05, 0x3e, 0x27, 0xdd, 0x6b, 0x32, 0x4f, 0x49, 0x6c, 0xf0, 0x43, - 0xf7, 0x20, 0x9d, 0x28, 0xcf, 0xfd, 0xcb, 0xf3, 0xe3, 0x39, 0x29, 0x62, 0xdb, 0x56, 0xe4, 0x95, 0x15, 0xa0, 0x1c, - 0xd2, 0x6e, 0x02, 0xea, 0x4b, 0x37, 0xea, 0x4d, 0xe4, 0x8d, 0x0d, 0xbc, 0x84, 0xd4, 0x1a, 0x28, 0x76, 0x61, 0xec, - 0xab, 0xd3, 0x51, 0x48, 0x93, 0x33, 0xd9, 0x43, 0x42, 0x31, 0x61, 0x80, 0xfe, 0x69, 0x71, 0x34, 0xa3, 0x82, 0xd6, - 0xfb, 0xbd, 0xa8, 0x8e, 0x65, 0xe7, 0x1a, 0x08, 0x99, 0xd9, 0x68, 0x96, 0xbf, 0xc8, 0xf0, 0xc6, 0x21, 0xf2, 0x55, - 0x66, 0x3a, 0x3a, 0xf0, 0xd7, 0x94, 0x3b, 0xa9, 0xc3, 0xc6, 0x55, 0x76, 0x24, 0x81, 0xff, 0x2e, 0x73, 0x22, 0x14, - 0xbe, 0x99, 0x2d, 0x0f, 0xe4, 0x6b, 0x5d, 0xf9, 0x5f, 0x33, 0xea, 0xb3, 0xc2, 0x1d, 0x6d, 0xcb, 0xd5, 0x8c, 0xc3, - 0xd9, 0x70, 0x20, 0xf3, 0xf1, 0x81, 0x0b, 0x5e, 0x79, 0xaa, 0xca, 0x7e, 0x13, 0x0e, 0xc9, 0x03, 0x7b, 0x36, 0x39, - 0x4a, 0x4b, 0x47, 0xed, 0x7f, 0xe5, 0xb4, 0xe8, 0x50, 0x34, 0x2c, 0x5a, 0x17, 0x05, 0xa2, 0x56, 0x1b, 0xcb, 0xcb, - 0x3c, 0x22, 0x41, 0xed, 0x8b, 0xc5, 0x43, 0x7b, 0xe0, 0xa3, 0x29, 0x46, 0xbe, 0xcf, 0x58, 0x07, 0x12, 0x7d, 0x7f, - 0x44, 0x90, 0x32, 0x50, 0x3a, 0x74, 0x06, 0xa5, 0x89, 0x29, 0x1e, 0x93, 0x3c, 0x67, 0xb1, 0xc2, 0x5e, 0xf2, 0x3a, - 0x2a, 0x07, 0x2b, 0x92, 0x7f, 0x8e, 0x08, 0x70, 0x14, 0x0c, 0x1e, 0x45, 0x9e, 0xfa, 0x25, 0xb8, 0xe5, 0xbe, 0x3f, - 0x60, 0x44, 0x56, 0xd2, 0x58, 0x5b, 0x8c, 0x5e, 0x88, 0x91, 0xf9, 0x08, 0x8e, 0xc7, 0xef, 0x9b, 0xa3, 0x14, 0x94, - 0xbe, 0xb4, 0x2b, 0x50, 0xdc, 0x04, 0xba, 0xb4, 0x9b, 0x1a, 0xa7, 0x81, 0x9c, 0xc8, 0xb4, 0xb5, 0x1d, 0xf7, 0xdd, - 0xd9, 0xb1, 0xa0, 0x2d, 0x41, 0xc6, 0x74, 0x17, 0x9a, 0x39, 0x0a, 0x0c, 0xef, 0xb7, 0x1a, 0x47, 0xc0, 0x80, 0x5d, - 0x63, 0x3d, 0xfc, 0x52, 0x4c, 0xfb, 0x54, 0xe9, 0x87, 0x2b, 0x9c, 0xb3, 0x4b, 0x3a, 0xbd, 0xf9, 0xfd, 0x40, 0x06, - 0xc4, 0xc5, 0x1b, 0x31, 0xf5, 0x05, 0xcc, 0x2f, 0x83, 0x02, 0x10, 0xa6, 0x12, 0x58, 0xfa, 0xbf, 0x98, 0x0b, 0xbc, - 0x13, 0x87, 0x35, 0x83, 0x03, 0x83, 0x88, 0x8f, 0x3b, 0xb8, 0xc5, 0x5f, 0x87, 0xff, 0x68, 0x80, 0xba, 0x72, 0xf7, - 0x19, 0x65, 0xcd, 0xf7, 0x49, 0x29, 0x32, 0x7d, 0xf9, 0xee, 0x65, 0x2b, 0xd4, 0x41, 0x8e, 0x6d, 0x6e, 0x55, 0xf3, - 0xda, 0xe2, 0xf7, 0xd3, 0x58, 0xcd, 0x4d, 0x7e, 0xd3, 0xdb, 0x55, 0x57, 0x4f, 0x8d, 0x1a, 0xf5, 0x84, 0x60, 0xf4, - 0xe6, 0x66, 0xd8, 0xad, 0xf1, 0xcb, 0x59, 0x09, 0x68, 0x64, 0xb3, 0x57, 0xbf, 0x47, 0x41, 0xae, 0xaf, 0xf5, 0xf3, - 0xbc, 0xac, 0x32, 0x2e, 0xbe, 0x09, 0xc0, 0x53, 0xe3, 0x43, 0xa2, 0x4a, 0xb5, 0x2c, 0x0d, 0x51, 0x93, 0x00, 0x82, - 0xc3, 0x1f, 0x74, 0x0b, 0x2e, 0xed, 0x57, 0x72, 0x9b, 0x55, 0x79, 0x6d, 0x45, 0xd0, 0x81, 0x45, 0x9f, 0xae, 0x0c, - 0x76, 0x30, 0xe0, 0xe1, 0x14, 0xfd, 0x43, 0xf1, 0x87, 0x89, 0xed, 0x9d, 0x6d, 0x4a, 0x28, 0x1f, 0x9a, 0xb9, 0x17, - 0x77, 0xf6, 0x4c, 0x11, 0xcd, 0x22, 0xd4, 0xac, 0x82, 0x19, 0x2c, 0x1b, 0x6a, 0xe7, 0x1a, 0x12, 0xf6, 0x08, 0x52, - 0x4c, 0xc1, 0xb8, 0xd1, 0xde, 0x90, 0xee, 0x88, 0x6b, 0x06, 0xe5, 0xb0, 0x50, 0x94, 0xf9, 0xcd, 0x08, 0xc9, 0x38, - 0xa7, 0xe9, 0x0d, 0x0a, 0xfc, 0x30, 0xe0, 0x73, 0x79, 0xb0, 0x20, 0xcf, 0x1f, 0x55, 0xe9, 0x74, 0x14, 0xfb, 0x56, - 0x12, 0x31, 0x63, 0xda, 0x81, 0x2d, 0xaf, 0xf7, 0xca, 0x74, 0x61, 0xb3, 0x4f, 0x3a, 0xd6, 0x1d, 0xe2, 0xed, 0x29, - 0x71, 0x1d, 0xc4, 0x9e, 0xa6, 0x1c, 0x36, 0x79, 0x3d, 0x99, 0xe3, 0x68, 0x51, 0x76, 0x5d, 0xac, 0xa6, 0x33, 0x14, - 0x7a, 0x0b, 0xc9, 0x46, 0x1b, 0x7a, 0xfe, 0xa4, 0x72, 0x8c, 0xf3, 0xc3, 0xe5, 0x24, 0x86, 0xe9, 0x4b, 0xa9, 0x21, - 0x5a, 0xb7, 0x94, 0xee, 0xb1, 0xbe, 0x63, 0x05, 0x5b, 0xb3, 0xf7, 0x8f, 0x44, 0x96, 0x26, 0x96, 0xa9, 0xd4, 0x96, - 0x9d, 0xba, 0x71, 0xef, 0x59, 0x7b, 0xfc, 0xde, 0x62, 0xb1, 0x46, 0xea, 0x6c, 0xb4, 0x31, 0xcd, 0x40, 0x3e, 0x1a, - 0xda, 0x07, 0x5f, 0x30, 0x65, 0x0b, 0xfd, 0x70, 0xde, 0x6d, 0xd0, 0x16, 0xe3, 0x33, 0x86, 0xa6, 0xd9, 0x9d, 0x0f, - 0xbc, 0xfa, 0x2c, 0x8b, 0x2e, 0x17, 0x1d, 0x4f, 0x73, 0x8c, 0x18, 0x75, 0xff, 0x5f, 0x1e, 0xf6, 0x52, 0x86, 0xbb, - 0x3c, 0x21, 0xc3, 0x4e, 0xee, 0xdb, 0x29, 0xab, 0x80, 0x7c, 0x8c, 0xad, 0xf4, 0xbc, 0x72, 0x30, 0xa2, 0xd2, 0x51, - 0x9c, 0xe9, 0x3f, 0x7c, 0xe5, 0xd7, 0x32, 0x8d, 0xda, 0xf4, 0xa3, 0xcb, 0x92, 0xbf, 0xb2, 0x1a, 0x88, 0x36, 0x4f, - 0x88, 0x4c, 0xfe, 0x4f, 0x24, 0x25, 0x47, 0x06, 0xe2, 0xd1, 0x01, 0x14, 0x30, 0x53, 0x27, 0x93, 0xd3, 0x62, 0x70, - 0x02, 0x22, 0x4b, 0x34, 0x87, 0x73, 0x00, 0x93, 0xb4, 0x04, 0x13, 0x1e, 0xd7, 0x6a, 0xdf, 0x63, 0xc6, 0x01, 0x7f, - 0x99, 0x47, 0x73, 0x70, 0xf7, 0x01, 0x2d, 0x9a, 0x80, 0x64, 0x24, 0x61, 0x58, 0x6b, 0xdb, 0x79, 0x38, 0xd9, 0x4e, - 0xf0, 0xac, 0x7a, 0x7d, 0xc0, 0x8f, 0xb5, 0x82, 0xcb, 0x9d, 0x28, 0x45, 0x75, 0x1f, 0x7c, 0xd9, 0xea, 0xcd, 0x21, - 0xd4, 0x59, 0x0f, 0xf5, 0xcc, 0x40, 0x71, 0xdb, 0xce, 0x66, 0x54, 0xf3, 0x05, 0xff, 0xf8, 0xcb, 0xc2, 0x50, 0x2c, - 0x9a, 0x35, 0x64, 0xc0, 0x00, 0xdc, 0xc6, 0x9c, 0xef, 0x75, 0xfc, 0x97, 0x4f, 0x90, 0xb0, 0x17, 0x11, 0xf6, 0x26, - 0xc5, 0x28, 0xe1, 0x97, 0x13, 0x06, 0x04, 0xf1, 0xda, 0x13, 0x25, 0x88, 0xf4, 0xa0, 0x3e, 0x99, 0x66, 0x5c, 0x66, - 0x33, 0x48, 0xd6, 0xb0, 0x08, 0xba, 0xdd, 0x35, 0xeb, 0x32, 0xe3, 0x4f, 0x7e, 0xc8, 0x70, 0x0d, 0xf4, 0x4f, 0x26, - 0x4a, 0x3a, 0x37, 0x24, 0xa8, 0xf8, 0x20, 0x5e, 0xe6, 0x50, 0x79, 0xde, 0x33, 0xe4, 0xe9, 0xf9, 0x47, 0x7f, 0xdf, - 0xcc, 0x1c, 0xca, 0x53, 0xd6, 0xe4, 0xef, 0x9e, 0xea, 0xfe, 0xa7, 0xc8, 0x2b, 0x3a, 0xf3, 0xd5, 0xac, 0xb3, 0xe2, - 0x3a, 0xe3, 0xec, 0x88, 0x54, 0x70, 0x6a, 0x45, 0xeb, 0x1d, 0x0f, 0xb1, 0x69, 0xfc, 0xb5, 0x40, 0xea, 0xec, 0x91, - 0xb9, 0x67, 0x07, 0x15, 0xa3, 0x25, 0x14, 0x58, 0x2f, 0xa2, 0x06, 0xbe, 0x1d, 0xb5, 0x19, 0x33, 0x7d, 0x4e, 0x0a, - 0xb4, 0x68, 0x09, 0x36, 0x6d, 0x17, 0xa3, 0x26, 0x5e, 0x96, 0xcc, 0x15, 0x27, 0xfc, 0xe9, 0x32, 0x53, 0xec, 0x87, - 0x8c, 0xd4, 0xc1, 0x9e, 0x17, 0x2b, 0x96, 0x2c, 0x97, 0x4f, 0xd7, 0x0f, 0xc9, 0x2e, 0xf7, 0x1e, 0x11, 0x33, 0x5e, - 0x3f, 0x5e, 0xb2, 0x4b, 0x09, 0x28, 0x91, 0x91, 0x0d, 0xe3, 0x36, 0x12, 0x6a, 0x14, 0x15, 0xa3, 0x2b, 0x50, 0x72, - 0xac, 0x53, 0x11, 0x00, 0xf0, 0xc7, 0xf4, 0x52, 0xd8, 0xc0, 0x83, 0xd3, 0x89, 0x02, 0x94, 0x91, 0xa7, 0xef, 0x4c, - 0xc6, 0x82, 0xe8, 0xa8, 0x99, 0xc3, 0xef, 0x84, 0xb1, 0x7a, 0xe6, 0xde, 0xeb, 0xa3, 0x48, 0xb0, 0x7b, 0xd9, 0x08, - 0x03, 0x89, 0x65, 0xd9, 0x64, 0x1c, 0xb6, 0x6e, 0x2b, 0xfc, 0xb4, 0x58, 0x81, 0x34, 0x05, 0x68, 0xde, 0xd3, 0x46, - 0xc0, 0x69, 0x18, 0xb3, 0x2f, 0x13, 0x48, 0xa9, 0x82, 0xb1, 0xfc, 0xa4, 0x64, 0xc3, 0xb3, 0x49, 0xde, 0xfd, 0xc4, - 0xd3, 0x5c, 0x20, 0xe4, 0xc5, 0x02, 0xdb, 0x9a, 0xa9, 0x13, 0xbf, 0x19, 0xe5, 0x66, 0x3f, 0x56, 0xcd, 0xa2, 0x0d, - 0x47, 0x1e, 0x95, 0xe5, 0xa6, 0x1b, 0xdd, 0xda, 0x2d, 0x58, 0xb5, 0x10, 0xa9, 0xe6, 0x78, 0x19, 0x80, 0x8d, 0xe8, - 0x97, 0x94, 0x61, 0xf5, 0x83, 0x4e, 0x81, 0x64, 0x61, 0xc8, 0xb6, 0xcd, 0x92, 0x32, 0x18, 0x82, 0xf2, 0xa8, 0x9a, - 0x02, 0xac, 0x91, 0xf2, 0x4d, 0x0a, 0xa3, 0xc9, 0xbf, 0x6a, 0x8b, 0xfe, 0x93, 0xff, 0x29, 0xd6, 0x7b, 0x26, 0x88, - 0x64, 0x7b, 0x38, 0x9f, 0x9d, 0xa6, 0x05, 0x33, 0x68, 0x14, 0x84, 0xf6, 0x60, 0x4a, 0xcd, 0x49, 0x24, 0x06, 0x25, - 0x17, 0x22, 0xfb, 0x93, 0xea, 0x2d, 0xc7, 0x47, 0x1e, 0xb2, 0xaf, 0x6f, 0x92, 0x26, 0x9d, 0x56, 0xa7, 0xca, 0x08, - 0xee, 0x0a, 0x9c, 0xa0, 0x04, 0xb3, 0x01, 0xfd, 0x93, 0x9f, 0x3f, 0x85, 0x24, 0xfa, 0xd0, 0x05, 0x84, 0x52, 0x67, - 0xcf, 0x88, 0xdc, 0x2c, 0x3c, 0xa2, 0x55, 0x88, 0x62, 0x5c, 0x20, 0x07, 0xc8, 0xfc, 0xb7, 0x91, 0x05, 0xbd, 0x86, - 0xfd, 0x42, 0x37, 0xa2, 0x7d, 0x08, 0x8b, 0x11, 0x9b, 0x2b, 0xde, 0xe8, 0x3d, 0x90, 0x67, 0x88, 0x1b, 0xf7, 0x34, - 0x2e, 0x68, 0xe9, 0x2a, 0x9b, 0x95, 0x02, 0xdd, 0xc4, 0xa3, 0x3e, 0x09, 0x1d, 0xb5, 0x5a, 0xde, 0x0c, 0xd1, 0x3b, - 0xd0, 0xf3, 0x7a, 0xff, 0x04, 0xdf, 0x0e, 0x08, 0x10, 0x51, 0xb8, 0xa3, 0x33, 0xf9, 0xc1, 0xe1, 0x77, 0xae, 0x3c, - 0xff, 0xc8, 0x44, 0x3d, 0x52, 0x99, 0xef, 0x96, 0xf8, 0xbb, 0x5b, 0xde, 0xff, 0xa1, 0x29, 0x53, 0x82, 0xf2, 0x83, - 0x60, 0x60, 0x64, 0x85, 0x0f, 0xae, 0x9d, 0x0e, 0xbf, 0x91, 0x79, 0x89, 0xe2, 0x85, 0xe4, 0xb2, 0x85, 0xdb, 0x2b, - 0xc6, 0x55, 0xac, 0xe2, 0xca, 0x0e, 0xda, 0x67, 0xdc, 0x7a, 0xfc, 0x10, 0x35, 0xc6, 0x1a, 0x47, 0xe7, 0x1c, 0x94, - 0x06, 0x84, 0x04, 0xd3, 0xc0, 0x26, 0x3d, 0x5a, 0x60, 0x99, 0x16, 0x48, 0x09, 0x42, 0x48, 0x2a, 0xba, 0x1f, 0x43, - 0x53, 0x89, 0xcd, 0x8c, 0x20, 0xad, 0x2a, 0x76, 0xa8, 0xc4, 0x29, 0x67, 0x1f, 0xa6, 0x58, 0x23, 0x7c, 0xaa, 0xe9, - 0x3b, 0x88, 0x92, 0xc8, 0x7b, 0x4e, 0x2e, 0x2e, 0x1d, 0x68, 0x45, 0xa6, 0x4a, 0x49, 0xdf, 0x79, 0xc1, 0xad, 0xbf, - 0xd6, 0x3e, 0x20, 0xd6, 0x41, 0x15, 0xf4, 0xac, 0x8a, 0xbf, 0xdc, 0x62, 0xae, 0xa4, 0x25, 0x56, 0xb1, 0xa7, 0x2c, - 0xf6, 0x73, 0x5a, 0x71, 0x1e, 0xce, 0x69, 0xe8, 0x2e, 0x39, 0x77, 0xa9, 0xb8, 0x27, 0x9d, 0xf5, 0x12, 0xe1, 0xbe, - 0x65, 0x07, 0xd3, 0x67, 0x25, 0xfc, 0xf8, 0xbb, 0x39, 0x29, 0x29, 0xd7, 0x81, 0x46, 0xcf, 0x61, 0xe0, 0x65, 0xd0, - 0xa2, 0xee, 0xd4, 0xc0, 0x3d, 0x91, 0xe8, 0x5b, 0x7f, 0x60, 0xc6, 0x66, 0xd9, 0x12, 0x19, 0x14, 0xcf, 0xd4, 0xff, - 0x24, 0x48, 0xe2, 0xb1, 0xfc, 0x23, 0x2f, 0x0e, 0x49, 0x22, 0xa9, 0x3e, 0x80, 0x3e, 0x09, 0x9e, 0x58, 0x80, 0x57, - 0x7f, 0xa0, 0x80, 0xa1, 0x28, 0x57, 0x39, 0x32, 0x77, 0xc2, 0x1c, 0xf2, 0x74, 0x57, 0xbd, 0x53, 0x07, 0x38, 0x7d, - 0xb5, 0x9e, 0x4d, 0x40, 0xa7, 0x85, 0x1e, 0xa0, 0xc4, 0x99, 0x11, 0xa5, 0x19, 0x07, 0xa7, 0x86, 0x39, 0xfc, 0xaf, - 0x57, 0x12, 0x61, 0xec, 0xc1, 0xc3, 0x41, 0xe3, 0x41, 0x05, 0xf9, 0xd9, 0x8e, 0xa6, 0x34, 0x0c, 0x48, 0xc2, 0xb9, - 0x16, 0xab, 0x64, 0x19, 0x5e, 0x3c, 0xf2, 0xca, 0x0c, 0xe1, 0x04, 0xd6, 0x9d, 0x3e, 0x95, 0x0e, 0x82, 0x71, 0x09, - 0x17, 0x2a, 0xaf, 0x39, 0x35, 0x1c, 0x69, 0xb9, 0x40, 0xf1, 0x57, 0x9a, 0xa8, 0x6b, 0x11, 0x4f, 0xe6, 0x47, 0x5c, - 0x35, 0x10, 0x61, 0xda, 0x05, 0x01, 0x96, 0x97, 0xc8, 0xad, 0x85, 0x72, 0xed, 0xb7, 0x1e, 0x36, 0x30, 0x06, 0xeb, - 0xe6, 0xd7, 0x4b, 0x7e, 0x7d, 0xd3, 0xb4, 0xf6, 0xe2, 0x2d, 0x2a, 0x34, 0x9d, 0xe8, 0xe9, 0x90, 0x22, 0x3c, 0x1d, - 0x77, 0x11, 0x19, 0x46, 0x03, 0x4c, 0xdf, 0x56, 0xd5, 0x62, 0x26, 0xed, 0x00, 0xfa, 0xb9, 0x20, 0xcd, 0x01, 0xa0, - 0x29, 0x42, 0xd9, 0x01, 0x70, 0x15, 0xaa, 0xf5, 0xba, 0x5f, 0x69, 0x63, 0x63, 0x3c, 0xe0, 0x11, 0x81, 0x59, 0xf1, - 0x94, 0x42, 0xc9, 0x79, 0x02, 0x79, 0xb1, 0x4d, 0x55, 0xba, 0x99, 0x96, 0xcd, 0xfa, 0xdd, 0xfa, 0x47, 0x96, 0x00, - 0xa2, 0x26, 0x79, 0x64, 0x32, 0x81, 0x0d, 0x15, 0xd2, 0x14, 0xc7, 0xa4, 0x56, 0x02, 0xae, 0xf9, 0xb0, 0x8f, 0x6c, - 0x09, 0x38, 0x3b, 0x70, 0x2d, 0x88, 0xc3, 0x59, 0x33, 0x64, 0xb2, 0x3c, 0xa7, 0xad, 0xd1, 0x3f, 0x5b, 0xad, 0xb1, - 0xb5, 0xff, 0x43, 0x4b, 0x71, 0x3f, 0x19, 0x0b, 0x4d, 0x0c, 0x48, 0x6d, 0x8f, 0xbf, 0xbb, 0x95, 0x74, 0xe6, 0x6d, - 0xc1, 0x49, 0xff, 0x37, 0xd3, 0xe6, 0x74, 0x9e, 0x3d, 0x39, 0x8c, 0x7c, 0xc0, 0x98, 0x0a, 0x61, 0x8c, 0x93, 0xf0, - 0x62, 0x3b, 0xbc, 0x68, 0x0c, 0x6a, 0xff, 0xe5, 0x0e, 0x86, 0x9c, 0xea, 0xd8, 0x7b, 0x1f, 0x44, 0xc9, 0xbe, 0x98, - 0x5b, 0x34, 0x56, 0x87, 0xb4, 0x28, 0x6e, 0xfb, 0x00, 0x32, 0xf0, 0xd2, 0xfd, 0xff, 0xb8, 0x75, 0x88, 0x63, 0xb0, - 0x09, 0x79, 0x89, 0x4b, 0x12, 0xb3, 0x4d, 0x1f, 0x05, 0xf5, 0xfa, 0xb4, 0x11, 0x2e, 0xd1, 0x5c, 0xe9, 0xfe, 0x07, - 0x2f, 0x5b, 0x54, 0x77, 0x29, 0x0f, 0xf7, 0x0e, 0x8c, 0x69, 0x7c, 0x73, 0xf3, 0x3d, 0x0d, 0xa6, 0x14, 0xba, 0x19, - 0xef, 0x60, 0x13, 0xbb, 0xde, 0x56, 0x56, 0x6c, 0x17, 0x99, 0xa2, 0xa2, 0xa9, 0xd1, 0x47, 0x33, 0xd8, 0xec, 0xd0, - 0x80, 0xf6, 0x6f, 0x31, 0xc9, 0x60, 0xf1, 0x70, 0x6b, 0x2e, 0x44, 0xcb, 0xeb, 0x9c, 0xed, 0x28, 0x38, 0x27, 0x23, - 0x8e, 0x24, 0x48, 0x93, 0xee, 0x3b, 0x8e, 0x1e, 0xd4, 0x41, 0xd5, 0x88, 0x3b, 0x6d, 0xc9, 0x7e, 0x45, 0x7d, 0x97, - 0x3e, 0xae, 0x0b, 0x79, 0xe5, 0x1c, 0x48, 0xc4, 0x67, 0x85, 0x37, 0x27, 0x44, 0x46, 0x6d, 0x1b, 0xa9, 0x15, 0x59, - 0x91, 0x5f, 0x21, 0x25, 0xea, 0x5f, 0x51, 0x2b, 0xc8, 0x62, 0x0e, 0xc0, 0xc0, 0x36, 0x00, 0xab, 0xdf, 0xac, 0x18, - 0xb2, 0xa5, 0x80, 0xc6, 0x2f, 0x67, 0xdb, 0x7c, 0xe2, 0x96, 0xec, 0xe8, 0x17, 0x44, 0x6d, 0x6b, 0x45, 0x13, 0x9c, - 0x77, 0x2f, 0xac, 0x9e, 0x89, 0xdf, 0x53, 0xcf, 0xb7, 0xc0, 0x36, 0x90, 0x4f, 0xd2, 0xfd, 0xce, 0x99, 0x3e, 0x60, - 0x0f, 0xc6, 0x58, 0xc7, 0x60, 0x57, 0xd8, 0x63, 0xa3, 0x37, 0x55, 0xe5, 0x39, 0x68, 0x57, 0xb7, 0x1c, 0x15, 0xf1, - 0xf8, 0x2d, 0xcb, 0x3a, 0x18, 0x66, 0x18, 0x3d, 0xf3, 0x05, 0x94, 0x2d, 0xda, 0x11, 0x99, 0x93, 0x5c, 0x46, 0xdb, - 0x54, 0x0e, 0x28, 0x81, 0x05, 0x31, 0xa9, 0x71, 0x4a, 0xdd, 0x2d, 0x9b, 0x97, 0xae, 0xa3, 0x09, 0xf1, 0xd6, 0x5f, - 0x67, 0x3e, 0xd7, 0x83, 0xa3, 0xf2, 0x3c, 0x44, 0x60, 0x1a, 0xc8, 0xc3, 0x02, 0x0e, 0x23, 0x79, 0x5e, 0x8a, 0x40, - 0x01, 0xef, 0x06, 0x7d, 0xb6, 0x19, 0x28, 0x72, 0x0a, 0x91, 0x77, 0x9e, 0x83, 0x05, 0xba, 0xc1, 0x53, 0x44, 0x19, - 0x87, 0x87, 0xff, 0x2e, 0x70, 0x19, 0x1e, 0x92, 0x25, 0x8c, 0xef, 0x1d, 0x4e, 0x24, 0x27, 0xa9, 0x8b, 0xa4, 0xf5, - 0x4b, 0x78, 0xa6, 0xb6, 0x71, 0x6b, 0xfe, 0x22, 0xfb, 0x24, 0x76, 0xaf, 0xbc, 0x80, 0xf9, 0x18, 0x35, 0xd9, 0x65, - 0xfe, 0xc2, 0x3c, 0x26, 0x3d, 0x33, 0xaf, 0xd1, 0x6a, 0x0d, 0x78, 0x20, 0x69, 0x45, 0x58, 0xca, 0x2c, 0x99, 0x73, - 0x19, 0x00, 0xe8, 0xda, 0x78, 0xd8, 0x1a, 0x42, 0x7c, 0x22, 0xd7, 0x77, 0x45, 0x42, 0x65, 0xaa, 0x59, 0x96, 0x23, - 0xf7, 0xc9, 0x4d, 0x08, 0x4b, 0xb5, 0xcb, 0x12, 0xb7, 0x99, 0xe6, 0xb6, 0x36, 0x3c, 0xf7, 0xca, 0xf2, 0xbd, 0xc0, - 0x14, 0xf5, 0xa0, 0xbf, 0xb3, 0x8d, 0x38, 0x45, 0x10, 0x22, 0x66, 0x70, 0x87, 0xa3, 0x11, 0x64, 0x53, 0x4e, 0xf4, - 0x67, 0xbb, 0xc4, 0xe6, 0xa7, 0x97, 0xa9, 0xaa, 0x70, 0x39, 0x62, 0x32, 0xb1, 0x39, 0x1b, 0xb0, 0x98, 0x83, 0x57, - 0x7b, 0x72, 0x9b, 0xdb, 0xb2, 0xec, 0x8d, 0x08, 0x56, 0x83, 0x16, 0xce, 0x1d, 0x2c, 0x15, 0xfa, 0x4e, 0x66, 0xbd, - 0xab, 0x83, 0x9b, 0xd9, 0x6f, 0xd2, 0xee, 0x8f, 0x1c, 0x7d, 0x55, 0x69, 0xdc, 0x81, 0x6d, 0x2c, 0x81, 0x0d, 0x8f, - 0x11, 0x29, 0x87, 0x44, 0xf5, 0xa9, 0x4f, 0x6c, 0x1e, 0xd5, 0x98, 0xe4, 0x38, 0xc8, 0x1d, 0x26, 0xae, 0x68, 0x3a, - 0x9b, 0xb4, 0x10, 0xbb, 0x52, 0x21, 0x3d, 0x9d, 0x85, 0xfc, 0x16, 0x73, 0xd3, 0x75, 0x92, 0xc8, 0xd6, 0xb5, 0x0f, - 0xf9, 0xa2, 0x25, 0x75, 0x68, 0x60, 0xa7, 0xc7, 0x1c, 0xfd, 0x78, 0xb5, 0x95, 0xaf, 0xd4, 0xd6, 0x71, 0x4e, 0x92, - 0x8f, 0x71, 0xbc, 0x68, 0xf8, 0xe7, 0xa2, 0xa2, 0xd1, 0xc2, 0x93, 0xd8, 0xfa, 0x61, 0x27, 0xaf, 0x5f, 0xd1, 0x62, - 0x36, 0x1c, 0xb5, 0x5e, 0x96, 0x57, 0x1c, 0xee, 0xdd, 0xb6, 0x14, 0x4b, 0x58, 0x1f, 0xe3, 0x72, 0xc9, 0xd3, 0xa8, - 0x5a, 0x3a, 0xfa, 0xcb, 0x1b, 0xb8, 0x25, 0xef, 0x04, 0xc0, 0x44, 0x52, 0x1f, 0x61, 0x41, 0x7b, 0x19, 0x31, 0x42, - 0xec, 0x05, 0xd9, 0x27, 0x68, 0x7b, 0xb1, 0xaf, 0x76, 0x3d, 0x0c, 0xd9, 0x92, 0x64, 0x77, 0x6f, 0x46, 0xf8, 0x42, - 0xdd, 0x3d, 0xb2, 0x1a, 0x87, 0x6b, 0xf2, 0xe2, 0x32, 0x44, 0xb1, 0x97, 0x70, 0xc3, 0xa8, 0x2d, 0xc5, 0xdc, 0x82, - 0x1b, 0x49, 0x8b, 0x89, 0xad, 0x51, 0x46, 0x0d, 0x9b, 0x43, 0x9b, 0x43, 0x69, 0xef, 0x15, 0xdf, 0xf0, 0xdf, 0x10, - 0xef, 0x7c, 0x69, 0x4b, 0x12, 0x75, 0xef, 0x42, 0x5a, 0xe6, 0x45, 0xba, 0x92, 0xf5, 0xf3, 0x76, 0x62, 0x43, 0x71, - 0x37, 0xc7, 0x80, 0xf5, 0xc4, 0x41, 0x76, 0x69, 0xf2, 0x81, 0xc4, 0x26, 0x4a, 0x56, 0x5a, 0xfd, 0xcf, 0xee, 0xfa, - 0xb0, 0xe0, 0xa1, 0x89, 0x46, 0xc7, 0xb6, 0x43, 0x37, 0x62, 0x1e, 0x7e, 0x8d, 0x67, 0xaa, 0x16, 0x90, 0x1c, 0xe6, - 0x26, 0x51, 0xea, 0x66, 0x84, 0xea, 0xc4, 0x8d, 0x17, 0x88, 0x7a, 0xda, 0xf5, 0x4c, 0xc7, 0xd2, 0xfb, 0xbb, 0x0c, - 0xa1, 0xa9, 0x21, 0x04, 0x0f, 0x21, 0x39, 0x3f, 0x09, 0x6f, 0x46, 0x27, 0xe2, 0x1b, 0xa6, 0xcb, 0x19, 0x72, 0x0f, - 0x5f, 0xa0, 0x75, 0x27, 0xc1, 0xc2, 0xe1, 0x86, 0x90, 0x22, 0x15, 0x04, 0xc8, 0xf6, 0x31, 0x80, 0x85, 0x46, 0xf6, - 0xa2, 0xc9, 0xd4, 0x80, 0xc8, 0x66, 0x6d, 0x4b, 0x98, 0x63, 0x33, 0x35, 0x68, 0xc1, 0xd6, 0xfc, 0x12, 0x28, 0x1b, - 0xda, 0xe2, 0x2d, 0xfd, 0x4f, 0x5e, 0x13, 0x41, 0x8c, 0x69, 0x6a, 0xd3, 0xcc, 0x7a, 0xe5, 0xda, 0xde, 0xf5, 0x29, - 0x16, 0x0b, 0xe4, 0xc0, 0x75, 0x43, 0x69, 0x6c, 0x8d, 0xd5, 0x25, 0x0d, 0x68, 0xb9, 0xa8, 0x2e, 0x08, 0x84, 0xc4, - 0x10, 0xf3, 0xaa, 0xa1, 0x90, 0x92, 0x84, 0x6a, 0x6e, 0xdd, 0x89, 0x6d, 0x82, 0xc2, 0xec, 0xb8, 0x33, 0x79, 0xe8, - 0xe7, 0x70, 0xfe, 0xfe, 0xc6, 0x2c, 0x40, 0x51, 0xb8, 0xe2, 0xa5, 0x8c, 0x06, 0x89, 0x7e, 0xb3, 0x1e, 0x7a, 0xfe, - 0x83, 0x03, 0xda, 0x9d, 0xca, 0x32, 0xa3, 0xd4, 0xa9, 0x9e, 0x09, 0x4e, 0x6f, 0x0d, 0xd0, 0x88, 0x48, 0x80, 0x09, - 0xfc, 0xa8, 0x3f, 0x0a, 0x15, 0x0b, 0x98, 0xb5, 0x95, 0x53, 0xaf, 0xef, 0x31, 0x10, 0x29, 0xec, 0xb0, 0x71, 0xce, - 0xa2, 0x55, 0x8d, 0x78, 0x42, 0x82, 0x3e, 0x48, 0xc8, 0xce, 0x59, 0xf5, 0x8c, 0xaf, 0x93, 0x0b, 0xbe, 0x60, 0x77, - 0xfc, 0xb5, 0x06, 0x50, 0x8e, 0x7f, 0xb1, 0xf7, 0x86, 0xd7, 0xc3, 0x16, 0xd7, 0x23, 0xe6, 0x8b, 0x32, 0x2f, 0x7f, - 0x78, 0xd0, 0x72, 0xfa, 0xf7, 0xe7, 0x69, 0x80, 0x2a, 0x7f, 0xb1, 0x84, 0x01, 0xa9, 0x3c, 0xbc, 0xf5, 0x46, 0xe4, - 0x4a, 0x66, 0x14, 0x8d, 0x59, 0x3b, 0x6e, 0x09, 0x3b, 0xb8, 0x28, 0x8e, 0x20, 0x54, 0xfc, 0xf3, 0x16, 0x40, 0x62, - 0x2b, 0x68, 0x99, 0xd1, 0xa0, 0x11, 0xed, 0x81, 0x3a, 0x2b, 0x6c, 0xcc, 0x0b, 0xb6, 0x2e, 0x5f, 0xde, 0xad, 0xe0, - 0x20, 0x4b, 0x48, 0x82, 0x87, 0xf5, 0xf6, 0xcd, 0x26, 0xd3, 0xa5, 0x87, 0xa9, 0xd7, 0x1d, 0xbf, 0x67, 0x56, 0x20, - 0xa4, 0xd9, 0x43, 0x64, 0x6d, 0x37, 0x12, 0xd3, 0x1b, 0x4f, 0x6d, 0x3b, 0x62, 0x5e, 0xb7, 0x13, 0x91, 0x2b, 0x75, - 0x6c, 0x9b, 0x87, 0xc8, 0x08, 0x2b, 0x8c, 0x24, 0xb8, 0xfc, 0x32, 0x20, 0x36, 0x51, 0xd0, 0xd8, 0xc7, 0xe2, 0x52, - 0x16, 0x93, 0xec, 0xa3, 0xf8, 0x4b, 0x59, 0xeb, 0x5f, 0x22, 0xd5, 0xd9, 0x13, 0xf8, 0x15, 0x43, 0x7b, 0x0f, 0xa1, - 0xb1, 0x4e, 0x83, 0xbb, 0x16, 0x3c, 0xb2, 0x80, 0x72, 0x1f, 0x1a, 0x12, 0x42, 0x71, 0xba, 0x1d, 0x16, 0xd9, 0xae, - 0x25, 0x46, 0x80, 0x8f, 0x92, 0x5e, 0xa9, 0x4d, 0xc6, 0x70, 0x05, 0x05, 0x70, 0x79, 0xae, 0xc7, 0xf3, 0xd1, 0xcd, - 0xf6, 0x4a, 0x23, 0x09, 0x7d, 0x37, 0xac, 0x78, 0xb9, 0xb9, 0xee, 0x2a, 0x8b, 0x36, 0x9f, 0x62, 0x1c, 0xeb, 0x02, - 0x91, 0x19, 0x21, 0x62, 0x6e, 0xd9, 0xa0, 0x20, 0x1d, 0x6c, 0x17, 0x03, 0xf4, 0xb1, 0x81, 0xe1, 0x0c, 0x56, 0xba, - 0xaa, 0xad, 0x9d, 0xa7, 0xc8, 0xf4, 0x6f, 0xb6, 0x98, 0xc0, 0xcf, 0x17, 0x17, 0x24, 0x04, 0x24, 0x2c, 0xf4, 0xcc, - 0x83, 0x59, 0x0f, 0x27, 0x79, 0xf6, 0x12, 0x13, 0x2e, 0x64, 0xa8, 0x70, 0xfc, 0xa0, 0xad, 0xe6, 0x82, 0xe6, 0xf8, - 0xf5, 0x4c, 0x5b, 0x95, 0xaf, 0x95, 0x34, 0xc9, 0x82, 0x43, 0x5e, 0x38, 0x5d, 0xde, 0x32, 0x44, 0xf1, 0xa9, 0x76, - 0xdd, 0x77, 0xb8, 0xf9, 0x4c, 0x8a, 0x9c, 0x54, 0xda, 0x89, 0x40, 0xa5, 0x21, 0x93, 0xb7, 0x7b, 0x01, 0xb0, 0x6d, - 0x88, 0xbe, 0x68, 0x36, 0x32, 0x53, 0x99, 0x8e, 0xae, 0x96, 0x87, 0x70, 0x6c, 0x0f, 0x6f, 0x06, 0xc3, 0x10, 0xf0, - 0xfa, 0xb4, 0x66, 0xff, 0xba, 0x67, 0x25, 0x55, 0x15, 0x4d, 0x8c, 0x8a, 0xb8, 0xb9, 0x60, 0x72, 0x0f, 0x2a, 0xa6, - 0xc1, 0x43, 0x38, 0x69, 0xc0, 0xe9, 0x38, 0x53, 0xd9, 0x20, 0x79, 0x81, 0x49, 0x10, 0x7b, 0x02, 0x2d, 0x4d, 0xc0, - 0xbc, 0xa2, 0xec, 0x38, 0xda, 0x8c, 0xed, 0x88, 0x50, 0xce, 0x9c, 0x44, 0x45, 0xfc, 0x98, 0x7b, 0xd2, 0x0a, 0xb0, - 0xcf, 0x40, 0x77, 0xbd, 0xc6, 0x4f, 0x6a, 0x41, 0xd1, 0xb7, 0xb6, 0xff, 0x5f, 0x86, 0x41, 0xd8, 0x9e, 0xb6, 0x73, - 0x00, 0x0a, 0xb2, 0x84, 0x00, 0xfe, 0xf2, 0x82, 0xbe, 0x04, 0x59, 0x92, 0x0a, 0x3f, 0x90, 0x57, 0x8f, 0xad, 0x3e, - 0x55, 0xce, 0xbe, 0x3a, 0xfb, 0xf5, 0xb7, 0xec, 0x97, 0xf4, 0xc1, 0x25, 0x27, 0x77, 0xfb, 0x54, 0x62, 0x73, 0xbd, - 0x53, 0x2a, 0x1c, 0x9d, 0x63, 0xb9, 0xac, 0xaf, 0xc4, 0x70, 0x39, 0x95, 0x10, 0xfc, 0x87, 0x0f, 0xc4, 0xef, 0xb3, - 0x72, 0xb7, 0x4f, 0x21, 0xbf, 0x9f, 0x4b, 0x2b, 0xf9, 0xc9, 0xe1, 0x46, 0xba, 0x4f, 0xab, 0x28, 0xc7, 0x5c, 0x7f, - 0x5b, 0x4a, 0xe5, 0xac, 0xb3, 0xb3, 0x4b, 0xb9, 0xb9, 0x9c, 0x73, 0x78, 0xaa, 0xcb, 0xbd, 0x5e, 0xb5, 0xd6, 0xff, - 0x57, 0x66, 0x0d, 0x65, 0xb9, 0x19, 0x94, 0xcc, 0x7b, 0x28, 0x08, 0x72, 0x37, 0xb1, 0x4e, 0x2f, 0x72, 0xe7, 0xb8, - 0x43, 0x39, 0x96, 0xb6, 0xbe, 0x2a, 0x53, 0x8f, 0xcc, 0x45, 0x8c, 0xf3, 0x15, 0xf1, 0xb2, 0x9a, 0xbc, 0x6d, 0xd0, - 0x6f, 0x4f, 0xc8, 0xfc, 0xe7, 0xd7, 0x90, 0x64, 0x3f, 0xc6, 0x2f, 0xea, 0xfe, 0x02, 0x5c, 0xc3, 0x9b, 0x72, 0xe4, - 0x05, 0x3b, 0xae, 0xab, 0xa7, 0x6d, 0xb2, 0xae, 0x85, 0x63, 0xdb, 0xe5, 0xc0, 0x6b, 0x8b, 0x38, 0x04, 0x44, 0x69, - 0x65, 0xdc, 0x73, 0x7a, 0xd7, 0xe9, 0x77, 0xa6, 0x3a, 0x86, 0xdd, 0x80, 0x20, 0x11, 0x0c, 0x28, 0x30, 0x1f, 0x83, - 0xba, 0x93, 0x51, 0xe5, 0xc4, 0x9e, 0x35, 0x10, 0x4a, 0x60, 0x45, 0xf3, 0x35, 0x12, 0x80, 0x96, 0x76, 0xe0, 0x65, - 0xad, 0xa2, 0x93, 0x25, 0x6b, 0x10, 0x1c, 0xf4, 0xff, 0x88, 0xc1, 0x11, 0x07, 0xdf, 0x24, 0x21, 0xce, 0x0a, 0x45, - 0x62, 0x4e, 0xb3, 0x47, 0x1f, 0xb3, 0x8f, 0x72, 0x09, 0xd2, 0xec, 0x47, 0x60, 0x80, 0x60, 0x19, 0x8e, 0x63, 0x91, - 0xa0, 0x64, 0xbe, 0x2a, 0xc8, 0x92, 0x9a, 0xf7, 0x9f, 0x60, 0x6c, 0xff, 0x46, 0xb7, 0x8d, 0xec, 0xef, 0x9a, 0x4a, - 0x6e, 0x7f, 0xe5, 0xdd, 0xf2, 0xeb, 0xfa, 0x7a, 0x79, 0xa1, 0xfe, 0xfc, 0xba, 0x69, 0x81, 0x77, 0x72, 0xf7, 0x52, - 0x0e, 0x35, 0x3f, 0x5f, 0x67, 0xc4, 0x58, 0x30, 0x40, 0xec, 0x53, 0xc7, 0x87, 0x92, 0xee, 0xb7, 0x9e, 0x0d, 0xac, - 0x89, 0xfd, 0x1a, 0xb7, 0xa8, 0x5e, 0xce, 0x0b, 0x6c, 0x56, 0xe3, 0x1a, 0xba, 0xe7, 0x85, 0xd6, 0x3c, 0x17, 0x66, - 0xa9, 0xa0, 0x14, 0x5b, 0x53, 0xc0, 0x27, 0xb8, 0xeb, 0xca, 0x4d, 0x6a, 0xa2, 0xea, 0x4d, 0x78, 0x92, 0xa0, 0xa2, - 0x03, 0x17, 0x4d, 0x5f, 0x3d, 0xb5, 0x2d, 0x36, 0x86, 0x3f, 0x13, 0x74, 0x35, 0x86, 0xac, 0x46, 0x39, 0x66, 0x2d, - 0x56, 0x7a, 0xa1, 0xb5, 0xbc, 0x5a, 0xea, 0x6e, 0x5f, 0x03, 0xbd, 0xf2, 0x82, 0x32, 0xe0, 0x1e, 0x80, 0xac, 0x57, - 0xf4, 0x94, 0x56, 0x91, 0x2d, 0xd9, 0x27, 0x14, 0xdc, 0x3c, 0x9e, 0xe0, 0xb0, 0xf4, 0x51, 0xdd, 0x23, 0x4d, 0x62, - 0x2b, 0x5c, 0xc3, 0xde, 0x64, 0x55, 0xe9, 0x65, 0xf3, 0x84, 0x07, 0x98, 0xd3, 0x82, 0xfd, 0x1b, 0xdb, 0x62, 0xf9, - 0x71, 0x12, 0x68, 0xbb, 0x68, 0x14, 0x37, 0xca, 0x00, 0x88, 0xd2, 0x3d, 0xbd, 0x01, 0x07, 0xa2, 0x5d, 0xd7, 0x42, - 0x7d, 0x9b, 0xd8, 0x0e, 0xe7, 0x26, 0x13, 0x6a, 0xe1, 0xc2, 0x1a, 0xcd, 0xa6, 0x0b, 0x27, 0x6a, 0xef, 0xd2, 0x9e, - 0x27, 0x83, 0x8c, 0xff, 0x2a, 0x83, 0x98, 0xf4, 0xbd, 0xc0, 0xa3, 0x83, 0x70, 0x0f, 0xa1, 0x27, 0x61, 0x91, 0x8a, - 0xd6, 0x14, 0x6c, 0x83, 0x15, 0xa5, 0x71, 0x00, 0xa0, 0xbd, 0x8b, 0xb8, 0x01, 0x07, 0x37, 0x6c, 0x0c, 0x1d, 0x1b, - 0xb7, 0xe4, 0x95, 0x64, 0x82, 0xa0, 0xf2, 0x66, 0x89, 0xcd, 0x78, 0xb2, 0x13, 0x95, 0x6f, 0x70, 0xb3, 0x73, 0x27, - 0x14, 0xf6, 0x3b, 0x9d, 0x11, 0x4c, 0x59, 0x59, 0xed, 0xd0, 0x37, 0x23, 0x5e, 0x70, 0x98, 0x43, 0xb2, 0x20, 0x22, - 0x19, 0xb1, 0xaa, 0x1b, 0xbf, 0xf3, 0x7e, 0x94, 0x9b, 0x89, 0x6d, 0xb1, 0x5e, 0xf1, 0x8c, 0x60, 0xbd, 0x83, 0xa3, - 0x73, 0xf2, 0xdc, 0xcd, 0xc8, 0x5c, 0xe1, 0x3f, 0x86, 0xc9, 0xed, 0x66, 0x7e, 0x30, 0x8c, 0xa8, 0x2f, 0xff, 0x93, - 0x8c, 0x59, 0x55, 0x4e, 0xa3, 0x31, 0x24, 0x44, 0x32, 0xbc, 0x09, 0x40, 0x3c, 0xcf, 0x9a, 0x8c, 0xd1, 0x4c, 0xac, - 0xb6, 0xad, 0xd3, 0x34, 0xfb, 0xf6, 0x92, 0xd3, 0xef, 0x45, 0x85, 0x17, 0x78, 0x5c, 0x75, 0x6e, 0x64, 0xd7, 0x0f, - 0x74, 0x31, 0x87, 0xbe, 0x55, 0xe9, 0xaa, 0xbe, 0x91, 0x1f, 0x6a, 0xf8, 0x4a, 0x0c, 0xea, 0x6e, 0x50, 0xf2, 0x00, - 0x00, 0xfd, 0x71, 0x5e, 0x5e, 0xfd, 0x5f, 0xa3, 0xb9, 0x93, 0x4e, 0xb0, 0xb1, 0x62, 0x69, 0x8e, 0xe3, 0xe5, 0xd0, - 0x5f, 0xa8, 0xe8, 0x39, 0xa1, 0xdf, 0x8d, 0x48, 0xba, 0x44, 0x67, 0x18, 0x4f, 0xcc, 0xd2, 0xe0, 0xb0, 0x86, 0x12, - 0xfa, 0x9b, 0xd1, 0x6f, 0xd7, 0xde, 0x37, 0x90, 0xe2, 0xdf, 0xb8, 0xad, 0x8e, 0x67, 0x47, 0x95, 0x99, 0xd4, 0x32, - 0x0f, 0xdc, 0x16, 0x57, 0x75, 0xd5, 0xcc, 0xa7, 0xed, 0x92, 0x69, 0xda, 0x79, 0xcc, 0x2e, 0xe3, 0x57, 0x38, 0x91, - 0x44, 0x7d, 0xb7, 0x0e, 0x03, 0x34, 0x30, 0xd0, 0x5e, 0x12, 0xa7, 0x17, 0x99, 0xae, 0xde, 0x6a, 0x06, 0x43, 0x73, - 0xa5, 0x4e, 0x3f, 0xb0, 0x7a, 0x41, 0xcb, 0xb0, 0xb3, 0x66, 0xf2, 0xc8, 0x09, 0xb1, 0x8b, 0x9c, 0x9f, 0x98, 0x0f, - 0x39, 0xa1, 0xa6, 0x01, 0xbd, 0x9d, 0x97, 0x57, 0xae, 0x54, 0x91, 0x81, 0x9a, 0x09, 0x29, 0x20, 0xbb, 0xa1, 0xf5, - 0xb1, 0x26, 0xc6, 0x1e, 0xa4, 0x0b, 0xb3, 0xd6, 0x3c, 0x0d, 0x42, 0x53, 0x28, 0x0b, 0x57, 0x66, 0x64, 0xa3, 0xf0, - 0x3d, 0x39, 0x85, 0x86, 0x0b, 0x5a, 0x42, 0x7b, 0xf7, 0x3e, 0x24, 0x74, 0xf7, 0x98, 0x44, 0xd5, 0x74, 0x96, 0x16, - 0xca, 0xcd, 0x42, 0x79, 0x8e, 0xc0, 0x0b, 0x16, 0xb9, 0xe7, 0x55, 0x39, 0x12, 0xb7, 0xee, 0xd6, 0xe9, 0xeb, 0x6e, - 0xb5, 0x86, 0x7d, 0x4c, 0x79, 0x24, 0xbc, 0xa3, 0x85, 0xf9, 0x57, 0xb2, 0xe4, 0x48, 0x87, 0x8d, 0x9a, 0x66, 0xf2, - 0x15, 0x3e, 0xff, 0x47, 0x75, 0x6f, 0xe2, 0x7d, 0xe2, 0x59, 0x81, 0x70, 0x57, 0x14, 0x3a, 0xe3, 0x8e, 0x59, 0x47, - 0xeb, 0x70, 0x4e, 0x9d, 0x98, 0xf1, 0xf0, 0xb8, 0x40, 0x31, 0xfc, 0xf6, 0x8c, 0x06, 0xdc, 0xfd, 0xe9, 0x98, 0xd8, - 0xbd, 0x0e, 0x52, 0xec, 0x32, 0x8b, 0x74, 0x7f, 0xd5, 0x68, 0xaa, 0x0b, 0xb1, 0x6e, 0x95, 0xb9, 0x27, 0xa6, 0xec, - 0x30, 0x9c, 0x51, 0xed, 0xb3, 0x85, 0x9b, 0xbd, 0x91, 0xbb, 0x51, 0xd5, 0x53, 0x6c, 0xe9, 0x92, 0xc3, 0x13, 0x38, - 0x64, 0xd3, 0xa8, 0xdc, 0xfd, 0x5a, 0xcb, 0x57, 0xfb, 0x6a, 0xd1, 0x97, 0x58, 0xc4, 0xf7, 0xf3, 0x21, 0x85, 0x2d, - 0x4f, 0x44, 0xb6, 0x3a, 0x8c, 0x75, 0x80, 0xf1, 0x50, 0xeb, 0xdb, 0xcd, 0x4e, 0x6a, 0x3f, 0xa0, 0xbb, 0x75, 0x96, - 0x96, 0x6f, 0x16, 0xbf, 0xad, 0xff, 0x3c, 0x70, 0x2f, 0x39, 0x14, 0xbf, 0x56, 0x5f, 0x45, 0xa2, 0xc1, 0xfd, 0xb2, - 0x5c, 0x93, 0x49, 0x71, 0xfc, 0x04, 0xc7, 0x54, 0xa6, 0x28, 0x47, 0xd5, 0x6d, 0x37, 0x84, 0x8a, 0x8d, 0x8e, 0xcd, - 0x79, 0xb2, 0x33, 0x75, 0xf5, 0x00, 0x8f, 0x0c, 0x51, 0xfb, 0x9f, 0xca, 0x8b, 0xd3, 0x1e, 0xd9, 0x07, 0xfb, 0xb7, - 0xcc, 0x21, 0xb6, 0x4e, 0xbb, 0x4e, 0xad, 0x9a, 0x70, 0xe0, 0xc3, 0xea, 0x1a, 0xff, 0x17, 0x2f, 0xb8, 0xd1, 0x44, - 0xf4, 0x56, 0xb5, 0x65, 0xa5, 0x04, 0xb6, 0xab, 0x5d, 0x2a, 0x35, 0xbd, 0xd5, 0x4d, 0x8c, 0xcb, 0x9c, 0xd7, 0xd5, - 0xde, 0x90, 0xf5, 0x93, 0xa0, 0x0d, 0xb9, 0x7f, 0xfa, 0x30, 0xe2, 0x10, 0x23, 0x29, 0x6b, 0x17, 0x63, 0xae, 0x0d, - 0xa1, 0x93, 0x2d, 0xca, 0x98, 0xdc, 0xf5, 0x4f, 0x24, 0xaa, 0x2e, 0x9a, 0x20, 0x10, 0xe7, 0xed, 0xb1, 0xf5, 0xea, - 0x6e, 0x71, 0xc9, 0xcd, 0x70, 0x65, 0xaa, 0x12, 0xf0, 0x79, 0x62, 0xf0, 0xc5, 0x82, 0x44, 0x09, 0x3c, 0x0b, 0xd5, - 0x64, 0xdc, 0x35, 0x44, 0x1f, 0x6c, 0xbc, 0xfc, 0xc3, 0xc8, 0x31, 0x3f, 0xf3, 0x4f, 0xca, 0x1b, 0x44, 0x27, 0xc0, - 0x99, 0x00, 0x3c, 0x9e, 0xa5, 0x25, 0xd5, 0x37, 0xa7, 0x7f, 0x6d, 0x92, 0xff, 0x77, 0x6c, 0xf8, 0x56, 0xfb, 0x15, - 0xd0, 0x68, 0x61, 0xd8, 0x21, 0xd0, 0x1a, 0xd4, 0x39, 0x85, 0x71, 0x0f, 0x01, 0xb5, 0xe2, 0x1a, 0xd7, 0x77, 0x69, - 0x84, 0x30, 0x08, 0x49, 0x50, 0xd9, 0x1d, 0x76, 0xb8, 0xb7, 0xbe, 0x2f, 0x90, 0x01, 0xc2, 0x43, 0x19, 0x41, 0x8b, - 0x8c, 0x07, 0xf7, 0x06, 0x7b, 0x10, 0xd6, 0xb9, 0x94, 0x53, 0xae, 0x92, 0xae, 0x43, 0xf6, 0x71, 0xd3, 0xf4, 0x1a, - 0x27, 0xe4, 0x08, 0x52, 0xa9, 0x67, 0x40, 0xd3, 0x74, 0x91, 0x5e, 0xae, 0xb7, 0x74, 0xca, 0xb7, 0x06, 0x62, 0xeb, - 0x5a, 0x58, 0x74, 0x9f, 0x5d, 0xca, 0x43, 0x0f, 0x52, 0x08, 0x0e, 0x89, 0xe5, 0x14, 0xd4, 0x0f, 0x60, 0x52, 0x2e, - 0xff, 0xc3, 0x24, 0x5e, 0xe5, 0xee, 0xfe, 0xd7, 0x6a, 0xb1, 0xaa, 0x1e, 0xcc, 0x6c, 0xfc, 0x40, 0xf7, 0x97, 0xf0, - 0x51, 0xad, 0x3d, 0x5f, 0x39, 0x66, 0x05, 0xa6, 0x0c, 0xfe, 0x93, 0x7f, 0xd0, 0x86, 0x3a, 0x97, 0xf9, 0x6f, 0x71, - 0x25, 0xae, 0x81, 0x14, 0xe7, 0x3d, 0xd4, 0x88, 0x26, 0x69, 0xbc, 0x4c, 0x59, 0x6d, 0x1a, 0x9f, 0x66, 0x8a, 0x40, - 0x88, 0x3a, 0x7a, 0x1d, 0x2e, 0x39, 0x70, 0x91, 0xc3, 0xaa, 0x05, 0xf8, 0x67, 0xc1, 0x0a, 0xe8, 0xf6, 0xb7, 0xe4, - 0x68, 0xcd, 0xfc, 0xed, 0x8e, 0xc6, 0x95, 0x0b, 0x39, 0x34, 0xb1, 0xf6, 0xd5, 0x76, 0x4c, 0xce, 0xd4, 0x9d, 0xa7, - 0x15, 0x8a, 0xae, 0xab, 0x9b, 0x89, 0x2b, 0x02, 0x8e, 0x53, 0xcf, 0x0f, 0x02, 0x0c, 0x66, 0x85, 0x2f, 0xfb, 0x42, - 0x4d, 0xbf, 0xc6, 0x60, 0x0a, 0x32, 0x96, 0x3d, 0x8b, 0x62, 0x78, 0x17, 0xf2, 0x2a, 0x62, 0x2c, 0x97, 0x22, 0x56, - 0x08, 0x65, 0x01, 0x5b, 0x56, 0xae, 0x47, 0xa1, 0x78, 0x78, 0x9c, 0xe2, 0xdd, 0xcc, 0x39, 0x52, 0xee, 0x12, 0xcc, - 0xee, 0x90, 0xf9, 0x49, 0x22, 0xf5, 0xda, 0xb5, 0x60, 0x93, 0x62, 0x8a, 0x5d, 0x51, 0xe4, 0x06, 0x87, 0x30, 0xe1, - 0xa8, 0x7b, 0x7b, 0xa3, 0x69, 0x22, 0xa1, 0x91, 0x28, 0x30, 0x23, 0xa4, 0xbb, 0xfe, 0xe3, 0xee, 0x4d, 0x3f, 0x99, - 0x32, 0x06, 0x11, 0xd0, 0x28, 0x7a, 0x06, 0x10, 0x7a, 0xbe, 0x4a, 0xb9, 0x64, 0x3a, 0xae, 0x60, 0xc4, 0x7d, 0x05, - 0x24, 0x5c, 0x34, 0x6e, 0xcd, 0x2f, 0xd1, 0x49, 0xa6, 0x78, 0x9a, 0x00, 0x45, 0xa3, 0xad, 0xf2, 0x6c, 0x28, 0x1f, - 0x79, 0x16, 0xac, 0x44, 0x3d, 0x69, 0x70, 0x14, 0x0c, 0xba, 0xd9, 0x48, 0xc2, 0x21, 0x35, 0x19, 0xc6, 0xc8, 0x30, - 0x38, 0xfa, 0x97, 0xb6, 0xca, 0x43, 0x6a, 0x5d, 0x2d, 0x14, 0x32, 0xa3, 0x07, 0x33, 0x3f, 0x98, 0xa8, 0x61, 0x55, - 0x0b, 0xf3, 0x41, 0xba, 0x76, 0x5a, 0x65, 0x94, 0x25, 0xc6, 0x69, 0xb0, 0x30, 0x86, 0x1c, 0x6a, 0x1c, 0xb0, 0xd9, - 0x40, 0xee, 0x6a, 0xce, 0xe6, 0x51, 0x33, 0x6e, 0xaf, 0x6b, 0x46, 0x9f, 0xfa, 0xe2, 0x56, 0x7f, 0x2e, 0xd3, 0x0d, - 0x3b, 0x56, 0xf9, 0x4b, 0xbf, 0xa8, 0xa6, 0x0f, 0x3d, 0xe6, 0x4d, 0x39, 0x18, 0x66, 0x78, 0xf5, 0x59, 0x58, 0x3c, - 0x48, 0x1a, 0x94, 0xf9, 0x52, 0xad, 0x1d, 0x6e, 0x7f, 0x3f, 0x30, 0xf4, 0x66, 0x37, 0x31, 0x49, 0x1a, 0x02, 0xe5, - 0x08, 0x89, 0x08, 0x8e, 0x59, 0xf1, 0x1f, 0x57, 0x95, 0xff, 0xbd, 0x53, 0x5f, 0xd0, 0x83, 0xf0, 0xd1, 0x5e, 0xf7, - 0x34, 0x0a, 0x98, 0xb3, 0x96, 0xed, 0xea, 0xd3, 0x84, 0x1a, 0xd2, 0x5f, 0x11, 0x32, 0x6e, 0x1c, 0xab, 0x7f, 0x74, - 0x53, 0xf2, 0x3b, 0x5d, 0x25, 0xf6, 0xd1, 0x5c, 0x9f, 0xd8, 0xa2, 0x4a, 0x3a, 0x3a, 0x36, 0xa7, 0x2d, 0x29, 0xcd, - 0x49, 0xf9, 0x56, 0x7b, 0x78, 0xda, 0x4a, 0x71, 0xc9, 0xe6, 0x3d, 0xb9, 0x9a, 0x27, 0x59, 0x6d, 0xcb, 0x71, 0x84, - 0x3b, 0xc8, 0xd7, 0xe7, 0x8c, 0xd2, 0xd1, 0x07, 0xab, 0x1f, 0xf7, 0x26, 0x81, 0xcc, 0xd3, 0x13, 0x70, 0xa3, 0x6b, - 0x57, 0x7a, 0x7c, 0x2b, 0x4e, 0xcc, 0x93, 0xf7, 0x43, 0xf6, 0x6b, 0x5c, 0xc9, 0x82, 0x8e, 0x7b, 0x5f, 0x35, 0x4c, - 0xb7, 0x19, 0xd3, 0x7e, 0xa4, 0x18, 0x8c, 0xe6, 0xab, 0x2c, 0x89, 0x0a, 0x62, 0xc1, 0x6b, 0xe2, 0x83, 0xd8, 0x00, - 0x40, 0xce, 0x68, 0x8b, 0x5a, 0x7a, 0x8c, 0x25, 0x51, 0xbc, 0xad, 0x40, 0xcd, 0x79, 0x76, 0x96, 0xd1, 0xaa, 0x3a, - 0xd1, 0xab, 0x53, 0xae, 0xd2, 0xec, 0x22, 0x74, 0x3d, 0x7c, 0x65, 0x29, 0x2a, 0x59, 0x56, 0xbd, 0x0b, 0xd3, 0x57, - 0xec, 0x95, 0x17, 0x48, 0x79, 0x57, 0x4a, 0x4d, 0x21, 0x23, 0x1b, 0x83, 0xc6, 0xd6, 0xd9, 0x4b, 0x2c, 0x6e, 0xb2, - 0x3c, 0x4a, 0x28, 0x7c, 0x31, 0xf7, 0x71, 0x7b, 0x2c, 0x55, 0xc5, 0x9c, 0x43, 0x98, 0x93, 0x2a, 0x9d, 0x74, 0x95, - 0x03, 0xf8, 0xd5, 0x65, 0x10, 0xd6, 0x48, 0xa1, 0x3a, 0xc7, 0x3d, 0x6c, 0x49, 0xa6, 0x63, 0x06, 0x19, 0x8b, 0xee, - 0xfa, 0x3b, 0x2a, 0x9d, 0xc7, 0x41, 0x74, 0x1f, 0xba, 0x5a, 0x21, 0xc2, 0x60, 0x7b, 0xd6, 0x92, 0x2b, 0x9e, 0x2b, - 0x8e, 0xb2, 0x2b, 0x31, 0xb5, 0x3c, 0x1b, 0xb2, 0x6d, 0xd1, 0x15, 0x4b, 0x65, 0x4d, 0x77, 0x57, 0x13, 0xa9, 0xe0, - 0xb1, 0x1f, 0x7f, 0xe0, 0xcb, 0x92, 0x91, 0x53, 0x99, 0xc4, 0xb2, 0x0c, 0x61, 0x6e, 0xdc, 0x10, 0x3c, 0xc1, 0x68, - 0xde, 0x92, 0x79, 0xca, 0x29, 0x85, 0xd2, 0xfb, 0x9f, 0x1b, 0x8f, 0x50, 0x36, 0xdb, 0x30, 0xbd, 0x65, 0xea, 0xbb, - 0xc4, 0xf5, 0xfc, 0x87, 0xe8, 0x94, 0x44, 0x0b, 0xde, 0x9f, 0x27, 0x32, 0xda, 0x54, 0xa8, 0xb0, 0x6e, 0x66, 0xbb, - 0xcf, 0xc1, 0xc6, 0x7f, 0x51, 0x49, 0x86, 0x1c, 0x54, 0x98, 0x5e, 0xb5, 0x63, 0xa1, 0x73, 0xc8, 0x5d, 0x6f, 0x28, - 0x88, 0x8d, 0xc0, 0x6e, 0x68, 0x05, 0x89, 0x34, 0x59, 0x88, 0x7d, 0x36, 0xaa, 0xba, 0x9b, 0x55, 0xa1, 0x06, 0xfc, - 0xf2, 0x17, 0xb1, 0x38, 0xbf, 0x40, 0x52, 0x7d, 0xc1, 0x21, 0x21, 0xf4, 0xc9, 0x6f, 0xc4, 0x5e, 0x0d, 0xbe, 0x88, - 0x95, 0x66, 0xdb, 0x31, 0xfa, 0x99, 0x9f, 0x8f, 0xbb, 0xee, 0x0c, 0x53, 0x74, 0xb8, 0x01, 0x8b, 0x11, 0x43, 0x4e, - 0x52, 0x37, 0xf9, 0x4b, 0x4a, 0x7e, 0x32, 0xbd, 0xf9, 0x06, 0xdf, 0x69, 0x6d, 0x6f, 0xa0, 0x50, 0x88, 0x59, 0x67, - 0x68, 0x70, 0xc3, 0x1e, 0x9e, 0xea, 0x98, 0x59, 0x98, 0xe3, 0x90, 0x24, 0xa2, 0x45, 0x0e, 0x67, 0x88, 0xdf, 0x00, - 0x98, 0x40, 0x93, 0x95, 0x08, 0x19, 0x25, 0xb0, 0x47, 0xf0, 0x82, 0x9b, 0x6d, 0xde, 0xef, 0x79, 0x1e, 0x2e, 0xa4, - 0x56, 0xae, 0xe0, 0x0a, 0x30, 0xd5, 0xb3, 0x6b, 0x49, 0xf1, 0xe1, 0x51, 0xb4, 0x86, 0x78, 0xae, 0x25, 0x94, 0xc5, - 0xce, 0x83, 0x60, 0x55, 0x65, 0x57, 0xd9, 0x19, 0xcc, 0x52, 0x0f, 0x0e, 0x54, 0x71, 0x81, 0x24, 0xdd, 0x18, 0x6f, - 0x53, 0xcc, 0xb2, 0x16, 0x7e, 0xaa, 0x62, 0xde, 0xb0, 0xa9, 0xe0, 0xf0, 0x5a, 0x9d, 0x7f, 0x31, 0xd6, 0x30, 0xa1, - 0x4d, 0x0d, 0xac, 0x04, 0x31, 0x69, 0x58, 0xc2, 0xc6, 0xc1, 0x67, 0xd0, 0x9f, 0x07, 0x4c, 0x33, 0x9c, 0xde, 0x8f, - 0x51, 0xbd, 0x65, 0xf5, 0xc9, 0xf7, 0xd2, 0xf3, 0x46, 0x1d, 0x3d, 0xa8, 0xc4, 0xaa, 0xe5, 0xeb, 0x0c, 0x11, 0xdd, - 0xde, 0xfa, 0x8c, 0xe7, 0xd4, 0x32, 0x04, 0x40, 0xe2, 0x49, 0x9d, 0x19, 0x7b, 0x7c, 0xdc, 0x30, 0x46, 0x52, 0xa5, - 0xb7, 0x2c, 0x42, 0xa6, 0x9f, 0x94, 0x55, 0x0d, 0x87, 0x27, 0x9b, 0x76, 0x25, 0x54, 0x0c, 0xd7, 0x6f, 0x96, 0x17, - 0x50, 0x85, 0xc5, 0x0c, 0xc5, 0x1c, 0x9b, 0xca, 0xd9, 0x78, 0x83, 0x79, 0x06, 0xe3, 0x3c, 0xa5, 0x31, 0x37, 0x54, - 0xa0, 0x5f, 0x2a, 0x47, 0x53, 0x67, 0x62, 0xce, 0x58, 0x9e, 0xfb, 0xb0, 0xe3, 0x73, 0xc7, 0x98, 0x5d, 0x78, 0xee, - 0xee, 0xa9, 0xc3, 0xf6, 0x59, 0x74, 0x19, 0xee, 0x6e, 0x61, 0xc9, 0x9e, 0x92, 0x49, 0x8c, 0x03, 0x58, 0xe7, 0xd1, - 0x95, 0xad, 0xf1, 0x52, 0x06, 0xbb, 0x3f, 0x41, 0x0c, 0xe0, 0x68, 0xc1, 0x60, 0x04, 0xec, 0x5a, 0x7e, 0xed, 0x6a, - 0xac, 0x74, 0xf3, 0x71, 0x60, 0x85, 0x17, 0x99, 0xc0, 0xe5, 0x23, 0x26, 0xd2, 0xe0, 0x7f, 0xde, 0xc7, 0xc9, 0x57, - 0x9b, 0x8e, 0x26, 0xb2, 0xd7, 0x42, 0xbe, 0xf0, 0xaf, 0xe1, 0x6e, 0x1e, 0x98, 0xf2, 0xc5, 0x1e, 0x4f, 0x11, 0x05, - 0x4d, 0x62, 0x6c, 0xf5, 0x8c, 0x8b, 0x3d, 0x73, 0xb5, 0xe1, 0x17, 0x22, 0x8a, 0xbb, 0xbb, 0xb8, 0x2c, 0x04, 0x2c, - 0x99, 0xf0, 0x53, 0xce, 0xd4, 0xc8, 0x94, 0x3d, 0xc4, 0xb7, 0xcb, 0xf0, 0xe1, 0x71, 0x23, 0xf6, 0xc9, 0x5d, 0x11, - 0x9e, 0x48, 0xaa, 0xb0, 0xcf, 0xfc, 0xef, 0x90, 0x31, 0x27, 0xa2, 0xe8, 0xb1, 0x4c, 0x37, 0x06, 0xcf, 0x7f, 0x56, - 0xf1, 0x64, 0x55, 0x00, 0xb6, 0x46, 0x05, 0xe9, 0x97, 0x80, 0x73, 0x0a, 0x40, 0x3d, 0x0c, 0x63, 0x20, 0xc5, 0x9c, - 0x42, 0xe0, 0xe8, 0x92, 0x76, 0xc0, 0xf0, 0xb3, 0x59, 0xd0, 0x63, 0xf1, 0x5b, 0xba, 0xdc, 0x6d, 0xce, 0xcf, 0xd6, - 0x28, 0x6f, 0x2a, 0x77, 0xd5, 0xb0, 0xca, 0x4c, 0x4d, 0x61, 0xb2, 0xd8, 0x9b, 0xb9, 0x0e, 0xdf, 0xf9, 0x63, 0x5f, - 0xbb, 0xc4, 0xc1, 0xc8, 0x8d, 0xcc, 0x15, 0x2e, 0x3c, 0x98, 0x76, 0xf2, 0x0a, 0x88, 0x9a, 0xad, 0x04, 0x57, 0x02, - 0xad, 0x07, 0xa7, 0x0e, 0x77, 0x0a, 0x58, 0x41, 0x20, 0xf3, 0xfa, 0xab, 0x3e, 0x39, 0x90, 0xd1, 0xe6, 0x0a, 0x19, - 0xf4, 0xdc, 0xea, 0x05, 0x5a, 0xe5, 0x7d, 0xab, 0xfb, 0x39, 0x79, 0x63, 0xde, 0x75, 0x1f, 0x81, 0xc9, 0xf7, 0x8c, - 0xc4, 0x86, 0x2c, 0xaf, 0x85, 0xc2, 0x24, 0x01, 0x3d, 0x0e, 0xaa, 0x0a, 0x89, 0xd4, 0xa1, 0x6c, 0xd4, 0x0c, 0x15, - 0xc2, 0xf4, 0xfa, 0x07, 0x80, 0x80, 0xa3, 0x94, 0x42, 0x79, 0x22, 0xaa, 0x32, 0x02, 0x08, 0x8c, 0x0d, 0xd0, 0xb0, - 0x0c, 0x4c, 0x61, 0x9b, 0x51, 0xb4, 0xe5, 0x74, 0xe9, 0x6e, 0xbc, 0x2f, 0x47, 0xe6, 0xbc, 0x1b, 0x3c, 0x4b, 0xd0, - 0x6e, 0xec, 0xeb, 0x38, 0x86, 0x7e, 0x2a, 0xfa, 0x47, 0xb0, 0x83, 0x73, 0x58, 0x82, 0x82, 0x53, 0x42, 0x9f, 0x33, - 0xff, 0x83, 0xaf, 0xc4, 0xeb, 0x9e, 0xb6, 0xb8, 0xb7, 0x63, 0xc7, 0xcc, 0xca, 0x8f, 0x4d, 0x96, 0x5c, 0xcb, 0x90, - 0x44, 0x79, 0xcd, 0xa5, 0x63, 0xd0, 0x94, 0xc8, 0xcd, 0x07, 0x81, 0xa4, 0xbd, 0x41, 0xe5, 0x47, 0x9b, 0xd3, 0xfe, - 0x48, 0x08, 0xb1, 0x5a, 0x6a, 0xe6, 0xe2, 0x4b, 0x8a, 0x05, 0x50, 0x70, 0x1f, 0xc0, 0xf9, 0x3b, 0x71, 0xfd, 0xbb, - 0xe8, 0xc0, 0xa1, 0x8f, 0x00, 0x06, 0xe0, 0xad, 0x54, 0x5b, 0x79, 0x13, 0x50, 0x5a, 0x01, 0x90, 0x6b, 0x53, 0x19, - 0xe0, 0x0d, 0xf9, 0x0b, 0x1e, 0xd9, 0x97, 0x29, 0x50, 0x8c, 0xe2, 0xc6, 0xbb, 0x4c, 0xe5, 0xe5, 0xdd, 0x31, 0xe7, - 0x82, 0xdd, 0xdc, 0x30, 0xaf, 0xda, 0x44, 0x99, 0xb4, 0x5d, 0x0c, 0x62, 0x9c, 0x12, 0xa4, 0x28, 0x01, 0xd2, 0xf7, - 0x0f, 0x66, 0x21, 0x7a, 0xfe, 0xee, 0xd1, 0x5d, 0x65, 0xae, 0xc3, 0x30, 0x9a, 0xec, 0x1d, 0x51, 0x0b, 0x72, 0xba, - 0x3a, 0x26, 0xdf, 0x27, 0x07, 0xe1, 0x5f, 0x12, 0xf5, 0x33, 0x55, 0x82, 0xa6, 0xfa, 0xa6, 0x8a, 0x38, 0xa8, 0xf1, - 0x09, 0x88, 0xda, 0x32, 0xa9, 0x09, 0x73, 0x25, 0xea, 0x93, 0xeb, 0x44, 0x60, 0x5a, 0xfd, 0xb3, 0x1f, 0x5f, 0xfd, - 0xc0, 0x60, 0x7b, 0xbf, 0x77, 0xf1, 0x75, 0xa6, 0x0f, 0xe7, 0xef, 0xd8, 0xbd, 0xfb, 0x3c, 0xb8, 0x71, 0x5c, 0x5d, - 0xd6, 0x23, 0x49, 0x23, 0x33, 0xc0, 0x7b, 0xca, 0xa0, 0x61, 0x2f, 0xca, 0xe6, 0xcc, 0x35, 0xbb, 0xcf, 0xba, 0xca, - 0x5d, 0x40, 0x3f, 0x22, 0x69, 0x35, 0xa1, 0xb8, 0x00, 0x6e, 0xe7, 0x31, 0xcd, 0x0a, 0xff, 0x83, 0x42, 0xcd, 0x04, - 0x7b, 0x19, 0x19, 0xa5, 0x2f, 0x23, 0x98, 0xaf, 0x16, 0x41, 0xb6, 0xac, 0x42, 0xbb, 0x8f, 0x1a, 0x6c, 0xd6, 0xae, - 0xcd, 0xb3, 0x7b, 0xcb, 0x01, 0x39, 0x13, 0x44, 0xe3, 0x45, 0xad, 0xe4, 0x59, 0x4f, 0xf5, 0xaa, 0xe7, 0x88, 0x9b, - 0xae, 0xdc, 0xab, 0x47, 0xa6, 0xf9, 0x78, 0x85, 0x77, 0x3f, 0xea, 0x22, 0xda, 0x95, 0xb5, 0x00, 0x56, 0x16, 0x43, - 0xf2, 0x3d, 0xeb, 0xef, 0x99, 0x74, 0x09, 0x16, 0x90, 0xfa, 0xc4, 0xeb, 0xda, 0x75, 0xd0, 0x91, 0x93, 0xba, 0xfd, - 0x28, 0x0a, 0x66, 0xa5, 0x45, 0x26, 0xe5, 0x89, 0xa4, 0x2b, 0xc9, 0x47, 0xae, 0x62, 0x66, 0x98, 0xe6, 0xd2, 0x19, - 0xf6, 0xa4, 0xbf, 0xaa, 0xda, 0xab, 0xed, 0x39, 0x04, 0xc0, 0x35, 0x4f, 0x21, 0x56, 0xef, 0x56, 0xd1, 0xc2, 0x9d, - 0xf1, 0x6e, 0xff, 0xa2, 0xb5, 0xe3, 0x61, 0xec, 0xd6, 0x30, 0x0e, 0x32, 0x67, 0x05, 0xf3, 0x9b, 0x1c, 0xd3, 0x70, - 0xcd, 0x68, 0xa3, 0x0b, 0x3e, 0xc5, 0xbe, 0x5c, 0xbd, 0x8f, 0xba, 0x87, 0x0a, 0x91, 0xde, 0x83, 0x67, 0x84, 0xaf, - 0x37, 0x7f, 0x6d, 0xd4, 0x6b, 0xdf, 0xd1, 0x58, 0xde, 0x58, 0x3a, 0x82, 0xc6, 0x3f, 0x26, 0x38, 0xa3, 0x30, 0xdf, - 0xc0, 0x9b, 0x26, 0x93, 0xb5, 0x09, 0xc7, 0x8e, 0xdc, 0x26, 0xc5, 0xa6, 0xa5, 0x2a, 0xdf, 0x25, 0xb0, 0x92, 0x5b, - 0xa6, 0xfd, 0xe2, 0x9e, 0x52, 0x8f, 0x0d, 0xc1, 0x42, 0xc5, 0x82, 0x00, 0x35, 0xe6, 0xaa, 0xbd, 0x99, 0x48, 0x06, - 0xa7, 0xb4, 0xfd, 0x6c, 0xfb, 0x0c, 0xb3, 0xb3, 0x26, 0x12, 0x82, 0xb3, 0x42, 0xcb, 0xa6, 0x9b, 0xb4, 0x91, 0x00, - 0x8d, 0x54, 0xa3, 0xd0, 0x64, 0x82, 0xc6, 0xce, 0x7b, 0x41, 0xee, 0x86, 0x0e, 0xa9, 0xeb, 0x0c, 0x4a, 0xf0, 0x85, - 0x23, 0x31, 0x4b, 0x77, 0x41, 0x69, 0x0d, 0xdd, 0x63, 0xa8, 0x5e, 0x6f, 0xbf, 0x0b, 0x9e, 0x91, 0xf1, 0xa6, 0x11, - 0x68, 0x8e, 0x41, 0x20, 0xdf, 0x04, 0x63, 0x02, 0x0e, 0xbc, 0xf3, 0xf2, 0xfb, 0x48, 0x44, 0xca, 0x0f, 0xb0, 0x01, - 0xbd, 0xcc, 0x37, 0x5e, 0x04, 0x2b, 0x52, 0xa5, 0x19, 0x16, 0x66, 0x8f, 0xc1, 0xbc, 0xed, 0x8e, 0x7e, 0x32, 0xfc, - 0xcc, 0x04, 0x2f, 0xf9, 0x93, 0xe7, 0xf4, 0xce, 0x10, 0x1e, 0x62, 0xf0, 0xc1, 0x58, 0xf5, 0xc0, 0xe3, 0x94, 0xc6, - 0x0d, 0x01, 0x2e, 0x9f, 0xfd, 0xc8, 0x7c, 0x10, 0xbb, 0x11, 0x80, 0x67, 0x17, 0x57, 0x19, 0x78, 0x9b, 0xa9, 0xab, - 0x93, 0x96, 0x4e, 0xee, 0x17, 0x62, 0xc4, 0x0c, 0x52, 0x31, 0x9f, 0xde, 0xc8, 0x28, 0x0d, 0xe2, 0xa2, 0x64, 0xc6, - 0x25, 0xc5, 0xae, 0x39, 0x6f, 0xe8, 0x96, 0x9f, 0x33, 0x45, 0xed, 0x9d, 0x1d, 0xeb, 0x78, 0xff, 0x8f, 0xf3, 0x27, - 0x5d, 0x30, 0xba, 0xbc, 0xb5, 0xae, 0x66, 0xdb, 0xf3, 0x4d, 0x4d, 0xa9, 0x81, 0xe1, 0x37, 0x26, 0xfc, 0xd1, 0xc7, - 0x4b, 0x4d, 0x41, 0x0d, 0x8d, 0x5d, 0x64, 0x53, 0x8a, 0xac, 0xf0, 0xc6, 0x31, 0x65, 0xbe, 0x04, 0x52, 0x2c, 0xc6, - 0x4f, 0x3e, 0x37, 0x1a, 0x5c, 0x33, 0x52, 0x1c, 0x0e, 0x09, 0xea, 0x45, 0x91, 0xb7, 0x9f, 0x3a, 0xf9, 0x1c, 0x57, - 0x6f, 0xe6, 0x37, 0x43, 0x60, 0xa6, 0xdb, 0x56, 0x5a, 0xac, 0x9b, 0xb6, 0xe2, 0xab, 0xb5, 0x4a, 0xe3, 0x29, 0x5a, - 0xe3, 0xbb, 0x1a, 0x87, 0xe9, 0x95, 0xea, 0xab, 0xe1, 0xd7, 0xbd, 0x8d, 0xcd, 0x8c, 0x66, 0x2e, 0x74, 0x90, 0x38, - 0x24, 0xbd, 0x54, 0x4d, 0xab, 0xa8, 0xc6, 0x9e, 0x1e, 0xab, 0x1a, 0x94, 0x88, 0x77, 0xe8, 0x24, 0xd6, 0x30, 0x5b, - 0x8f, 0x72, 0xfa, 0x61, 0xb5, 0x85, 0x5a, 0xb1, 0x33, 0x31, 0xa9, 0xa9, 0x37, 0x96, 0xe5, 0xd5, 0x56, 0xd5, 0xe7, - 0x74, 0xa0, 0xc3, 0x9f, 0xc3, 0x1d, 0x84, 0xef, 0x6e, 0x05, 0x9a, 0x10, 0x64, 0x2e, 0xb6, 0xf2, 0x75, 0x88, 0xef, - 0xd2, 0x1e, 0x8f, 0x25, 0x56, 0x2b, 0x12, 0x8d, 0x6f, 0xaa, 0x2a, 0x0c, 0xc8, 0x65, 0x46, 0x15, 0x4b, 0x7b, 0x65, - 0x54, 0xc8, 0x8a, 0xec, 0x8d, 0x04, 0x69, 0x55, 0xf0, 0x42, 0x90, 0xd2, 0x2c, 0x9a, 0x98, 0xcc, 0x5f, 0x0d, 0xef, - 0x92, 0x92, 0xcc, 0x26, 0xf8, 0xd3, 0x26, 0xce, 0x66, 0x14, 0x70, 0xb7, 0x52, 0x37, 0xb8, 0xd8, 0x9a, 0x71, 0x55, - 0xae, 0x20, 0xb7, 0x05, 0x07, 0x21, 0xab, 0xa2, 0x68, 0x61, 0x9f, 0xdf, 0xf9, 0xa7, 0x48, 0x53, 0x00, 0x40, 0xe4, - 0x35, 0xa1, 0x29, 0xbb, 0x69, 0x41, 0x66, 0xe9, 0x60, 0x19, 0xc0, 0x07, 0x2c, 0x85, 0xf2, 0xd0, 0x79, 0x1e, 0xd7, - 0xdd, 0xcb, 0x4c, 0x2d, 0x2a, 0x2d, 0x1b, 0x74, 0x4f, 0x07, 0x87, 0x5c, 0xaf, 0x74, 0xfa, 0xef, 0x8f, 0xd4, 0x56, - 0x5e, 0xa0, 0x0e, 0x2a, 0x7c, 0xf7, 0x51, 0xb5, 0x10, 0xe3, 0x50, 0xab, 0xff, 0xad, 0x28, 0xd1, 0x39, 0x05, 0x4f, - 0xa2, 0xd7, 0x3f, 0x56, 0xac, 0xd3, 0x2b, 0x26, 0x91, 0xf8, 0x72, 0x22, 0xa8, 0xdb, 0x66, 0x57, 0x5d, 0x72, 0xda, - 0x5c, 0x65, 0xf6, 0x9f, 0x0e, 0x78, 0xf6, 0xad, 0x77, 0x7e, 0xa7, 0x17, 0x77, 0x90, 0x44, 0xa1, 0xd0, 0xf3, 0x21, - 0x3e, 0xa0, 0xa3, 0xb2, 0xfa, 0x13, 0x91, 0x14, 0xab, 0x96, 0xcb, 0xd0, 0x80, 0x22, 0xc5, 0x4d, 0x19, 0xd5, 0x78, - 0xd0, 0xeb, 0x19, 0xbb, 0x04, 0x69, 0x74, 0xb3, 0x57, 0x2a, 0xc1, 0x49, 0x2b, 0xad, 0x3e, 0x2f, 0x41, 0x90, 0x6d, - 0xbc, 0x93, 0x5c, 0xa5, 0x58, 0x61, 0xf6, 0x58, 0x96, 0x95, 0x51, 0xd7, 0x50, 0x8c, 0xce, 0xb2, 0x8a, 0x5a, 0xe7, - 0xda, 0x6a, 0xa7, 0x08, 0xc5, 0x3a, 0x16, 0x00, 0x9b, 0x16, 0x4e, 0x07, 0x69, 0x61, 0x0b, 0x7a, 0x3a, 0xa9, 0xc6, - 0x25, 0xcd, 0x8e, 0xb1, 0xc8, 0xbb, 0x85, 0x41, 0xd0, 0x44, 0x5f, 0x77, 0x70, 0x53, 0x1e, 0x2b, 0x8a, 0x3a, 0xb8, - 0xde, 0xb1, 0x0c, 0xaf, 0x0e, 0xcd, 0x78, 0x81, 0xae, 0xa4, 0xec, 0x08, 0x95, 0x2b, 0x61, 0x27, 0x6d, 0x4a, 0x07, - 0x6d, 0x15, 0x7e, 0x68, 0x9e, 0x94, 0x8e, 0x05, 0xaf, 0x58, 0xa1, 0xc4, 0x35, 0xdd, 0x79, 0x0f, 0xeb, 0xa5, 0x9b, - 0x18, 0x23, 0xe4, 0x2b, 0xe2, 0xaf, 0x91, 0x22, 0xcc, 0x0d, 0x64, 0x0d, 0x2c, 0x64, 0x34, 0xc5, 0x24, 0x4c, 0x90, - 0xb1, 0xc7, 0x98, 0x78, 0xd1, 0x2d, 0x2e, 0xfd, 0x19, 0xb4, 0x41, 0x9b, 0x4d, 0x2b, 0xe9, 0x3e, 0x70, 0x85, 0xf6, - 0x7b, 0x3c, 0xe9, 0x90, 0x8f, 0x2d, 0x44, 0xcf, 0x14, 0x5c, 0xbe, 0x2e, 0xa1, 0x51, 0x7f, 0x00, 0x65, 0xed, 0x58, - 0x8a, 0xcd, 0x9a, 0x84, 0x1d, 0x98, 0x4e, 0x94, 0xf6, 0x7d, 0xf0, 0xa9, 0xc7, 0x5f, 0xbe, 0xde, 0x23, 0xf8, 0x0c, - 0x65, 0x4f, 0x14, 0x9b, 0x29, 0x86, 0x8a, 0xa6, 0xa6, 0xa0, 0x99, 0x0f, 0xce, 0xc2, 0x6d, 0xf1, 0x7a, 0x42, 0x2f, - 0x57, 0xbb, 0x75, 0x4f, 0xe5, 0xe3, 0x8a, 0x34, 0x40, 0xab, 0xcf, 0x1a, 0x95, 0x0f, 0xe6, 0xc9, 0x3d, 0xaf, 0x74, - 0xcf, 0x0f, 0xe8, 0xa1, 0xd1, 0xee, 0xe2, 0x4d, 0xd7, 0x32, 0x3e, 0xb4, 0x9b, 0xac, 0xcf, 0x60, 0x11, 0x7a, 0xa8, - 0xa1, 0x14, 0xcd, 0xe1, 0x26, 0xab, 0xd9, 0xf0, 0xee, 0x8d, 0x66, 0x6d, 0x29, 0x25, 0x7f, 0x6f, 0xe7, 0xc5, 0x36, - 0x9b, 0x2a, 0x9c, 0xde, 0x0f, 0xa4, 0x77, 0x09, 0x14, 0xcd, 0x91, 0xef, 0x4e, 0xa7, 0xa9, 0x7c, 0xd0, 0x4f, 0x80, - 0xa1, 0x82, 0xef, 0x1e, 0x69, 0x74, 0x67, 0x4d, 0x71, 0xbe, 0x82, 0x4b, 0x0f, 0xa3, 0xc6, 0xb6, 0x4b, 0x4f, 0x04, - 0xe1, 0xd4, 0xe2, 0x1e, 0xe9, 0x25, 0x45, 0x4b, 0x1d, 0x29, 0xe1, 0x9f, 0x22, 0x9c, 0x53, 0x8d, 0x1d, 0xed, 0x6c, - 0x1a, 0x6f, 0xa8, 0x20, 0x3d, 0x6a, 0x72, 0x4d, 0xfb, 0x12, 0x0a, 0xe0, 0xbf, 0x9d, 0xba, 0x12, 0xe1, 0x32, 0x21, - 0x37, 0xab, 0x8a, 0x4a, 0x83, 0x32, 0x00, 0x28, 0xbf, 0x5d, 0xcb, 0xe8, 0x1a, 0x3f, 0x5a, 0xa8, 0xcb, 0x12, 0x73, - 0xa0, 0x83, 0x16, 0x67, 0x77, 0x03, 0x2d, 0x92, 0x6d, 0x73, 0xea, 0xae, 0x51, 0xb5, 0xc5, 0x93, 0xc0, 0x4b, 0x44, - 0x63, 0x39, 0xeb, 0xe7, 0xf0, 0x6d, 0xda, 0x5e, 0x0f, 0xce, 0x3a, 0x40, 0xb7, 0xb0, 0xa0, 0xda, 0x9a, 0xb5, 0xfc, - 0x5e, 0x81, 0x0f, 0xf8, 0x51, 0x6c, 0xec, 0x3c, 0x76, 0x42, 0xcd, 0xc9, 0x0e, 0xbd, 0xb9, 0x49, 0x39, 0x27, 0xca, - 0x9c, 0x4e, 0x62, 0x1c, 0x85, 0x26, 0xea, 0x8c, 0x30, 0xf9, 0xd4, 0x6b, 0x32, 0x03, 0x1e, 0x7d, 0xe3, 0x22, 0x61, - 0x1f, 0x9c, 0x53, 0xc9, 0x96, 0xb5, 0x66, 0x93, 0x9b, 0x9f, 0x49, 0xb1, 0xc9, 0x98, 0x60, 0xbd, 0xa0, 0xf7, 0x37, - 0x44, 0x42, 0x18, 0x37, 0x84, 0x62, 0x68, 0x6a, 0x52, 0xe3, 0x69, 0x73, 0xa5, 0x64, 0x46, 0x97, 0x54, 0xbf, 0xac, - 0x2e, 0x28, 0x24, 0x5a, 0x51, 0xf0, 0xcb, 0x16, 0x8e, 0xbd, 0x46, 0x10, 0x7b, 0x06, 0xa0, 0x0f, 0x1d, 0xa0, 0x89, - 0x91, 0xdc, 0x6a, 0x6a, 0x4f, 0xb6, 0x04, 0x5e, 0x79, 0x19, 0xe2, 0x0d, 0xfb, 0x4d, 0x50, 0x09, 0x3c, 0xc7, 0xbe, - 0x59, 0x0e, 0x79, 0x0e, 0x97, 0xd5, 0x5d, 0x7d, 0x8a, 0xe8, 0xdc, 0xf9, 0xc2, 0xc3, 0x14, 0xbd, 0x43, 0xb5, 0x8f, - 0xd0, 0xd5, 0x2b, 0x79, 0x15, 0x73, 0x90, 0x83, 0x45, 0xdd, 0x98, 0x6c, 0x62, 0x57, 0xa7, 0x9e, 0x6b, 0x40, 0xb7, - 0xc7, 0x6e, 0x06, 0x62, 0x0f, 0x23, 0x26, 0xeb, 0x78, 0x4a, 0x6f, 0x11, 0x31, 0xda, 0x62, 0x22, 0xa9, 0x99, 0x34, - 0x6b, 0x52, 0x03, 0x39, 0x2d, 0xc2, 0x1c, 0xd4, 0x4f, 0x74, 0x8e, 0x3d, 0x12, 0xba, 0xb3, 0x6c, 0xbb, 0x46, 0x25, - 0x93, 0xdc, 0x61, 0xae, 0x50, 0x49, 0x08, 0xa9, 0x28, 0xbb, 0x92, 0x29, 0x30, 0xa5, 0x31, 0xe1, 0x1a, 0x57, 0x83, - 0x22, 0x43, 0x97, 0x0a, 0x6a, 0x6f, 0x9d, 0xa4, 0xd4, 0x39, 0x67, 0x0e, 0xf0, 0x29, 0x94, 0x3f, 0xab, 0x9a, 0x87, - 0x4d, 0xad, 0x40, 0xc5, 0xbd, 0x7a, 0xb9, 0x58, 0xf0, 0x66, 0xfe, 0x04, 0x85, 0x41, 0xa1, 0xaf, 0xa8, 0x29, 0xcf, - 0xe5, 0xa1, 0xac, 0x44, 0xdf, 0x8c, 0xbf, 0xa7, 0xf2, 0x8a, 0xc0, 0x65, 0xbe, 0x42, 0x24, 0xa6, 0xdf, 0x78, 0xa8, - 0x3f, 0x2d, 0x0b, 0x9b, 0x2a, 0x62, 0xfa, 0xf7, 0x93, 0xbf, 0x36, 0x90, 0xe0, 0x87, 0x9e, 0xd8, 0x2f, 0x5a, 0x08, - 0x3a, 0x16, 0x19, 0x54, 0x98, 0x23, 0x72, 0xa2, 0x00, 0x4e, 0xb2, 0x47, 0xd7, 0xcb, 0x4b, 0x6a, 0xe8, 0x08, 0x6e, - 0x81, 0x9c, 0xb0, 0xa2, 0x6a, 0x24, 0xcf, 0xfe, 0xde, 0x76, 0x4b, 0xaa, 0x53, 0x0e, 0x03, 0x36, 0x7f, 0xed, 0x69, - 0x19, 0xba, 0x7b, 0x1b, 0x04, 0xb0, 0x05, 0xbd, 0x1e, 0x87, 0x1f, 0x01, 0x34, 0x82, 0xc9, 0x0c, 0x19, 0xcb, 0xa7, - 0x86, 0xea, 0x55, 0x5f, 0x9e, 0x1c, 0x85, 0xb8, 0x75, 0x8a, 0x8c, 0x28, 0x5b, 0x41, 0x2e, 0x63, 0xcb, 0x6e, 0xbf, - 0x95, 0xfe, 0xc0, 0x0a, 0xaa, 0x6b, 0x15, 0x82, 0x1c, 0x8c, 0x49, 0x78, 0x23, 0x51, 0x61, 0xcd, 0xdf, 0x74, 0x06, - 0x78, 0xcb, 0x53, 0x38, 0x84, 0x7b, 0xbb, 0x03, 0x6a, 0x7d, 0x0d, 0x41, 0xa1, 0xa9, 0x1c, 0x38, 0x9b, 0xa2, 0x35, - 0x47, 0x1c, 0x9c, 0xcf, 0x61, 0xc3, 0xf2, 0x1e, 0x77, 0x33, 0xc4, 0x32, 0x88, 0x14, 0xd1, 0xe1, 0x90, 0xa5, 0xc5, - 0xa2, 0xc6, 0x16, 0xab, 0x90, 0xf6, 0x29, 0xce, 0x08, 0x37, 0x31, 0xa5, 0x38, 0xd1, 0x87, 0x97, 0x60, 0x78, 0x06, - 0xce, 0x30, 0x6b, 0xd7, 0x1e, 0x88, 0xdb, 0x1b, 0x3a, 0x5f, 0x7e, 0xc9, 0x8e, 0x32, 0x7f, 0xae, 0x3a, 0x03, 0x96, - 0xcf, 0x7e, 0xde, 0x13, 0xa0, 0xa7, 0x9f, 0x38, 0x6c, 0xab, 0x9f, 0x4a, 0xcf, 0x46, 0x6a, 0xac, 0xee, 0x99, 0x4e, - 0xce, 0xdc, 0xd6, 0x1b, 0x1e, 0xe8, 0xcc, 0x82, 0x84, 0x0f, 0x5e, 0x63, 0x1f, 0xb4, 0x91, 0x4a, 0xd2, 0x57, 0x3c, - 0x51, 0xde, 0x5b, 0xe0, 0x57, 0x0f, 0x64, 0xe5, 0x91, 0x0b, 0x12, 0xf4, 0x12, 0xda, 0xf3, 0x79, 0x74, 0xc6, 0xc1, - 0xb0, 0x37, 0xca, 0x27, 0xc6, 0x4b, 0x4c, 0xfa, 0xe6, 0xdb, 0x61, 0x88, 0x48, 0xdf, 0x47, 0x54, 0xe0, 0x84, 0x2c, - 0xa9, 0xf2, 0xf0, 0x46, 0xc2, 0x21, 0x9e, 0x4a, 0x74, 0xa6, 0x4e, 0xcd, 0x59, 0xb5, 0x58, 0x6c, 0x59, 0x79, 0xf5, - 0xda, 0x51, 0xe4, 0x77, 0x6c, 0xd5, 0x92, 0x76, 0xb2, 0xaf, 0x94, 0xa1, 0x55, 0x89, 0x33, 0x18, 0x19, 0x5d, 0xb0, - 0xd5, 0x8b, 0x24, 0x1f, 0xb2, 0x26, 0x2a, 0x1a, 0xd6, 0x95, 0xc9, 0xcc, 0x28, 0xb9, 0x95, 0xf5, 0x45, 0xda, 0xb1, - 0x5d, 0x78, 0xad, 0x42, 0x25, 0xe9, 0xa0, 0x9e, 0x93, 0xe4, 0xfc, 0x40, 0xda, 0x3a, 0xca, 0xdf, 0xb6, 0xaa, 0x93, - 0xeb, 0xa1, 0xa7, 0xec, 0x2a, 0x1d, 0x08, 0xf3, 0x55, 0xb2, 0x06, 0x81, 0x81, 0x7c, 0x77, 0x60, 0x3b, 0x06, 0x9d, - 0xbe, 0xdd, 0x1e, 0x99, 0x74, 0x3c, 0x05, 0x3a, 0x65, 0x14, 0x30, 0x4d, 0x14, 0x46, 0x57, 0x6b, 0xcc, 0xfe, 0xee, - 0x78, 0xe9, 0xbb, 0x82, 0x03, 0x55, 0xdc, 0xe9, 0x41, 0xf8, 0xe1, 0xde, 0xd5, 0xc6, 0xe0, 0x87, 0x53, 0x47, 0x4f, - 0xcc, 0xcc, 0x52, 0xcb, 0xbf, 0xaf, 0x77, 0x87, 0xdf, 0xc7, 0x29, 0xc4, 0xf0, 0x81, 0x5e, 0xc7, 0xee, 0x8b, 0xfa, - 0x57, 0xf1, 0x32, 0x97, 0xf8, 0xc2, 0x0f, 0x5d, 0xd1, 0xec, 0xa6, 0xde, 0x95, 0x35, 0xcc, 0xa1, 0x6f, 0xc5, 0xda, - 0x48, 0xe5, 0x16, 0x58, 0xf7, 0x66, 0xb9, 0x13, 0x07, 0x0c, 0x38, 0xd7, 0x73, 0xc4, 0xc4, 0x67, 0xa6, 0x5a, 0xcf, - 0x24, 0x9d, 0x9a, 0xe9, 0x48, 0x86, 0x51, 0x0b, 0x58, 0x89, 0x89, 0x50, 0xdc, 0xe1, 0x01, 0x51, 0xd9, 0x21, 0x8f, - 0x7e, 0x2c, 0xf4, 0x6d, 0x4a, 0x28, 0x1b, 0xbe, 0x82, 0x97, 0x27, 0x97, 0x3f, 0x18, 0xd9, 0x78, 0xdc, 0xfc, 0xb1, - 0xd2, 0x8b, 0x97, 0x33, 0x4f, 0xde, 0xa0, 0x88, 0xd5, 0x79, 0x82, 0xe5, 0x01, 0x12, 0x9b, 0x33, 0x37, 0xd0, 0x49, - 0x30, 0xf5, 0x46, 0x37, 0xbf, 0x14, 0xc1, 0xad, 0xb6, 0x0d, 0x4e, 0xf9, 0x99, 0xe9, 0x1e, 0x85, 0xe2, 0x27, 0x3f, - 0xc3, 0x92, 0x59, 0x36, 0x4e, 0xc0, 0xc9, 0xb2, 0x2b, 0x0f, 0x3e, 0x6d, 0x7d, 0xf1, 0x61, 0x7d, 0x61, 0xfd, 0x79, - 0x8a, 0xb1, 0xfe, 0xfc, 0x92, 0xde, 0xdc, 0x3b, 0xac, 0x83, 0xd8, 0x7a, 0x74, 0x83, 0xfe, 0x29, 0x35, 0xec, 0xfc, - 0xb1, 0x33, 0x84, 0x0a, 0x11, 0x6a, 0xbc, 0x41, 0x58, 0x70, 0xee, 0x8e, 0x94, 0xac, 0x9a, 0x31, 0x4e, 0x2b, 0x49, - 0x6b, 0xc0, 0xbe, 0xd2, 0xa4, 0xb4, 0xca, 0xed, 0x6a, 0x45, 0xcc, 0x2e, 0x4d, 0xed, 0x50, 0x60, 0xd1, 0x77, 0x52, - 0x92, 0x24, 0xe7, 0xa5, 0x67, 0xf7, 0x48, 0x6d, 0x47, 0x40, 0xe5, 0xea, 0xbe, 0x40, 0x30, 0x6e, 0x6f, 0x77, 0x07, - 0xaa, 0xa6, 0xbe, 0x83, 0x41, 0x3d, 0xf2, 0x52, 0xff, 0x1f, 0x6c, 0x44, 0x6f, 0x5e, 0xfa, 0x0e, 0x50, 0x36, 0xa2, - 0xd9, 0xbb, 0x20, 0xd7, 0x01, 0xf2, 0x8e, 0x19, 0x4e, 0x55, 0xe5, 0x30, 0xca, 0xe8, 0xaf, 0x3e, 0x4b, 0xe1, 0xd2, - 0x7b, 0x87, 0x79, 0xb2, 0x49, 0x07, 0x9e, 0x05, 0x5b, 0xba, 0xf7, 0x12, 0x49, 0x60, 0xd9, 0x21, 0x21, 0x57, 0x69, - 0xdf, 0xa0, 0xcb, 0xaa, 0x53, 0x6d, 0x52, 0xf4, 0xd3, 0x8e, 0x1b, 0x3c, 0x62, 0x5a, 0x70, 0x8b, 0x74, 0x84, 0x5a, - 0x25, 0x84, 0x31, 0xe3, 0x29, 0x92, 0xd5, 0x80, 0xf0, 0x5a, 0x4d, 0x3c, 0x25, 0x8d, 0x28, 0x0f, 0x0c, 0x13, 0xdb, - 0x7b, 0xb1, 0xf3, 0x63, 0x17, 0xc4, 0x98, 0xdd, 0xba, 0xfb, 0x55, 0xf1, 0xa1, 0x3f, 0xc3, 0xb3, 0xb6, 0x3b, 0x99, - 0x4b, 0x48, 0x90, 0x38, 0xf4, 0x2f, 0x54, 0xfc, 0x5a, 0x23, 0x3c, 0x01, 0x0d, 0x56, 0x09, 0x86, 0xa9, 0x05, 0xbe, - 0x9d, 0xde, 0x67, 0xbd, 0x5e, 0x3d, 0x61, 0xe2, 0xe8, 0x16, 0xe0, 0xda, 0x23, 0x15, 0x97, 0xd3, 0x87, 0x03, 0xbb, - 0xaf, 0x96, 0x1a, 0xe8, 0xaa, 0x64, 0xb2, 0xf8, 0x4b, 0x6f, 0x9c, 0xff, 0x4d, 0x43, 0xd1, 0x81, 0x25, 0xfa, 0x39, - 0xbd, 0x16, 0x3b, 0xf7, 0xc9, 0x27, 0x12, 0x30, 0x1a, 0xc3, 0x93, 0x9c, 0x1e, 0x58, 0x15, 0x6d, 0x26, 0xdc, 0x9f, - 0x17, 0xa7, 0x09, 0x34, 0x76, 0xa0, 0x17, 0x37, 0x99, 0x6f, 0xe3, 0xdd, 0x95, 0xad, 0xee, 0xfd, 0x61, 0x59, 0xd5, - 0xbc, 0x4d, 0xea, 0xa8, 0xbb, 0x48, 0x10, 0xe2, 0x52, 0x47, 0xa8, 0x49, 0x57, 0x6c, 0xad, 0x39, 0x73, 0x71, 0x9a, - 0xc7, 0x56, 0x7e, 0x96, 0x6d, 0x79, 0x20, 0xeb, 0x85, 0x58, 0xd5, 0x6c, 0xd4, 0x04, 0x29, 0x43, 0x5d, 0x88, 0xee, - 0x32, 0xa2, 0x82, 0x16, 0xab, 0x5f, 0xd7, 0xb1, 0x32, 0xdd, 0xa7, 0xe9, 0x68, 0x42, 0xe0, 0xf7, 0x71, 0x00, 0x46, - 0xb4, 0xfe, 0x15, 0x83, 0xa6, 0xd6, 0x28, 0x39, 0x8d, 0x4e, 0x84, 0x88, 0x43, 0xdb, 0x34, 0x06, 0xbc, 0x74, 0xf9, - 0x99, 0x1c, 0x62, 0xaa, 0x05, 0xc6, 0x1b, 0x18, 0xaf, 0x87, 0xb6, 0x98, 0x72, 0xe7, 0xe9, 0x55, 0x65, 0x94, 0xf1, - 0xc9, 0xfd, 0xcc, 0xeb, 0x9e, 0x7d, 0x40, 0xc9, 0x00, 0x84, 0x0a, 0xaf, 0x55, 0x18, 0x32, 0x58, 0x25, 0xf8, 0xf3, - 0x16, 0x63, 0xf0, 0x73, 0xdb, 0x9c, 0x87, 0xf9, 0x10, 0x2b, 0xec, 0x30, 0x0b, 0xf4, 0xe9, 0x79, 0x01, 0xd3, 0x03, - 0xd9, 0xd9, 0xb3, 0xd2, 0x96, 0x51, 0x5a, 0x22, 0x60, 0x57, 0xb8, 0x4e, 0x01, 0x2c, 0x2a, 0x81, 0xc7, 0x41, 0x94, - 0x40, 0x1b, 0xfb, 0x81, 0x68, 0xca, 0x12, 0x52, 0x00, 0xab, 0xd5, 0x9f, 0x01, 0xec, 0xa0, 0x24, 0xdb, 0xce, 0xae, - 0x09, 0x91, 0xd9, 0x7a, 0xa4, 0xbd, 0xea, 0x30, 0x2d, 0xfa, 0xe7, 0xc4, 0x59, 0x9a, 0x18, 0xfb, 0x28, 0x89, 0x8f, - 0x1a, 0x37, 0x30, 0x25, 0x12, 0x74, 0x23, 0x0f, 0xc0, 0xcd, 0x2d, 0xf7, 0xe4, 0xbc, 0xd3, 0x9b, 0x03, 0x18, 0xe4, - 0xf4, 0x4c, 0x7f, 0xed, 0xc0, 0x82, 0xfb, 0xcf, 0x25, 0xea, 0xe8, 0x69, 0x09, 0xc9, 0x04, 0x2e, 0x7e, 0x34, 0xee, - 0x99, 0x06, 0x02, 0x62, 0xcf, 0x85, 0x51, 0x0b, 0x18, 0xfc, 0xd4, 0x11, 0xaf, 0x69, 0xcf, 0x92, 0x0e, 0xa3, 0xd2, - 0xf7, 0x44, 0x3a, 0xd5, 0xb6, 0x47, 0xa6, 0x7b, 0x9d, 0xc6, 0xd4, 0x87, 0xc8, 0x07, 0x53, 0xb8, 0x52, 0x04, 0xc0, - 0xf9, 0x1f, 0x0f, 0x58, 0xee, 0xdf, 0xc4, 0x8a, 0xae, 0x90, 0xe7, 0xda, 0x98, 0x72, 0x58, 0x25, 0x03, 0x5b, 0xc6, - 0x65, 0x47, 0x4c, 0x98, 0x8d, 0x99, 0x56, 0x46, 0x6f, 0xe6, 0xc8, 0x19, 0xa4, 0xb2, 0x31, 0x5c, 0x44, 0x39, 0xb5, - 0x25, 0x20, 0x21, 0xaa, 0x02, 0x18, 0x3c, 0xbd, 0x85, 0x8d, 0x95, 0xd4, 0xa6, 0x74, 0x26, 0x18, 0xaa, 0x21, 0xca, - 0x57, 0x39, 0xb1, 0x9d, 0xca, 0xd1, 0x7c, 0xa0, 0xc9, 0xea, 0x6f, 0x9f, 0x16, 0xee, 0x63, 0x87, 0xe7, 0xbd, 0x4e, - 0x9e, 0x99, 0x14, 0xe8, 0xd3, 0x96, 0xb9, 0x73, 0xe9, 0xc4, 0x65, 0xf1, 0xd2, 0x74, 0xb1, 0x5f, 0x9c, 0xf5, 0x4d, - 0x0a, 0xb2, 0x6c, 0xed, 0xd7, 0x83, 0xb9, 0xc3, 0xb6, 0x98, 0x3a, 0x8f, 0x45, 0x80, 0xcb, 0x12, 0x51, 0xba, 0x96, - 0x09, 0x81, 0x4d, 0xcb, 0xbc, 0x30, 0x9b, 0xd1, 0xe6, 0x0a, 0x2f, 0xcf, 0x47, 0x35, 0xad, 0xc9, 0x15, 0x7a, 0xdd, - 0xa7, 0xd3, 0x77, 0x42, 0xfe, 0x79, 0x39, 0xea, 0x9e, 0x59, 0xca, 0x40, 0x54, 0xed, 0x94, 0x0e, 0x3c, 0xe9, 0xc0, - 0xce, 0xb6, 0xa6, 0x6f, 0xdf, 0x2f, 0xfe, 0xd1, 0x3e, 0x99, 0x3a, 0xb7, 0xa1, 0xb5, 0xe8, 0xf8, 0xfd, 0x1e, 0x51, - 0xbb, 0x64, 0x85, 0x23, 0x04, 0x2a, 0xcf, 0x18, 0xd8, 0xa4, 0xde, 0xcc, 0x59, 0xdc, 0xf8, 0x48, 0xb5, 0xe8, 0x16, - 0x0e, 0xf0, 0x58, 0xdf, 0xfd, 0xea, 0x4f, 0xaa, 0x6e, 0xcf, 0xff, 0xda, 0x9e, 0x84, 0x76, 0x93, 0x7f, 0x6d, 0x6c, - 0xfd, 0xc7, 0xce, 0xc8, 0x72, 0x05, 0xd1, 0xf3, 0xda, 0x7a, 0xb5, 0x24, 0x1c, 0xbc, 0xc3, 0xdc, 0x3d, 0x01, 0xdf, - 0x8e, 0xbf, 0x35, 0xcd, 0x80, 0xa4, 0x65, 0x16, 0xad, 0x8d, 0x5c, 0xe3, 0x25, 0x0c, 0x28, 0x43, 0xd9, 0x15, 0xce, - 0x54, 0x1b, 0x43, 0xf3, 0xeb, 0x1d, 0xb7, 0x6f, 0x1b, 0x6c, 0xdc, 0xa2, 0x5b, 0x45, 0x37, 0x71, 0x61, 0x75, 0x78, - 0xa6, 0xb8, 0xca, 0xe6, 0x54, 0xb9, 0xca, 0xf8, 0xbb, 0x60, 0xa8, 0x0e, 0x03, 0x6e, 0x33, 0xec, 0xc2, 0x75, 0xe7, - 0xa1, 0x0b, 0x15, 0x45, 0x30, 0x2c, 0x48, 0xa5, 0xe5, 0x04, 0x8a, 0x32, 0xb6, 0xc5, 0x86, 0xa2, 0x04, 0xff, 0xfa, - 0xf7, 0x8c, 0x97, 0x51, 0xc0, 0x37, 0x36, 0xb4, 0xc8, 0x6c, 0x85, 0xa6, 0x29, 0xe1, 0x67, 0x98, 0x92, 0xe3, 0xca, - 0x27, 0x8d, 0xdb, 0xe1, 0x7f, 0x15, 0x4d, 0x04, 0x0a, 0xa8, 0x13, 0x0b, 0x09, 0x99, 0x69, 0x33, 0x45, 0xaf, 0x30, - 0x84, 0xae, 0x48, 0xca, 0x07, 0x97, 0x39, 0xf8, 0xae, 0xcd, 0x3d, 0x77, 0x2d, 0x6f, 0x1e, 0x04, 0x5b, 0x92, 0x4d, - 0x90, 0x96, 0x14, 0x32, 0xc9, 0xc2, 0xda, 0x91, 0x81, 0xeb, 0x6b, 0x7b, 0xa1, 0xa0, 0x24, 0x59, 0x10, 0x89, 0xe7, - 0x6b, 0x6d, 0x91, 0x3a, 0x16, 0x7f, 0x61, 0xba, 0x9f, 0xe2, 0xd3, 0x5e, 0xf8, 0x81, 0xfa, 0x3a, 0xdc, 0x75, 0x5f, - 0x45, 0xb3, 0x21, 0x6e, 0xd5, 0x8f, 0x19, 0x15, 0xd7, 0x23, 0xa5, 0xfd, 0x58, 0xfe, 0xf9, 0xfb, 0x0d, 0x8b, 0xc9, - 0x00, 0xc7, 0xc3, 0x9b, 0x9b, 0xa9, 0xc3, 0x8c, 0xbd, 0x86, 0x54, 0x6d, 0xac, 0xbe, 0x01, 0xb9, 0x45, 0x0e, 0xd5, - 0xae, 0x89, 0xa2, 0x0e, 0xb8, 0x99, 0x08, 0x0e, 0xc4, 0x7d, 0x64, 0xce, 0xa8, 0xd7, 0x9e, 0x54, 0x73, 0xba, 0x94, - 0x69, 0xd1, 0x52, 0xcd, 0x18, 0x66, 0xca, 0x74, 0x54, 0x31, 0xa0, 0xae, 0xdc, 0xd9, 0x95, 0xe7, 0x7b, 0x7e, 0x2a, - 0xcb, 0x8b, 0x0d, 0x9b, 0xa1, 0x4b, 0x2d, 0xcc, 0x51, 0xbe, 0xea, 0xf3, 0xb8, 0xbf, 0xf1, 0x8a, 0xf7, 0x7a, 0x87, - 0xa1, 0x43, 0x2d, 0x3d, 0x66, 0x6f, 0xd6, 0xfa, 0x7a, 0x3e, 0xe3, 0x20, 0x2d, 0x6a, 0xa7, 0x82, 0x71, 0xce, 0xa2, - 0x80, 0x05, 0x78, 0x85, 0xdd, 0x11, 0x8c, 0x8f, 0x67, 0xf1, 0x88, 0x7e, 0x64, 0xec, 0xe6, 0x6d, 0xf3, 0x1a, 0x10, - 0xa4, 0xca, 0x8e, 0x7b, 0x4b, 0x17, 0x2a, 0x16, 0xd1, 0xd2, 0x3b, 0xd3, 0x29, 0x94, 0x8f, 0x15, 0x00, 0xa4, 0xee, - 0xf4, 0x66, 0x30, 0x1e, 0xca, 0xcd, 0x01, 0xc2, 0x8d, 0x9c, 0x19, 0x37, 0x26, 0x0a, 0x47, 0x37, 0x06, 0x84, 0x08, - 0x71, 0x35, 0xf0, 0x95, 0x97, 0xb4, 0x4f, 0x54, 0x2c, 0x0d, 0xf1, 0xbd, 0xf2, 0xe0, 0x3e, 0xdc, 0xda, 0x6f, 0x2f, - 0x4e, 0x55, 0xc9, 0xc1, 0x22, 0x14, 0x1b, 0xc5, 0x7b, 0xe3, 0x77, 0x2f, 0xec, 0x7e, 0x62, 0x31, 0xd7, 0x12, 0x01, - 0xe5, 0x96, 0xef, 0x97, 0x56, 0x4d, 0x9c, 0x3f, 0xfd, 0x87, 0x0f, 0xe5, 0x12, 0x72, 0xe4, 0xab, 0x58, 0x76, 0x40, - 0x66, 0xbe, 0xa2, 0x9f, 0x45, 0x59, 0x4d, 0xe1, 0x53, 0xde, 0xc2, 0xdd, 0x75, 0xc7, 0xb5, 0x0e, 0x00, 0x59, 0x38, - 0x44, 0xb3, 0xd1, 0xd3, 0x2e, 0x89, 0xd0, 0x36, 0xda, 0xf8, 0x96, 0x47, 0x9a, 0x51, 0x51, 0x51, 0x34, 0x2e, 0xcd, - 0x46, 0x3d, 0xa4, 0x20, 0x9e, 0xa0, 0x17, 0xdf, 0x86, 0x80, 0x7c, 0x74, 0xed, 0x9d, 0x12, 0xb3, 0x74, 0x6b, 0xe1, - 0xfb, 0xfe, 0x46, 0xa2, 0x9e, 0x02, 0xfd, 0x28, 0xc2, 0x64, 0x41, 0x33, 0xf2, 0x95, 0xff, 0x2e, 0x00, 0x6f, 0x62, - 0xd1, 0x3f, 0xb1, 0xf0, 0x67, 0xb2, 0xee, 0xe1, 0xab, 0x9d, 0xb8, 0xde, 0x2e, 0x9a, 0xd3, 0x41, 0xfb, 0x10, 0x94, - 0xaa, 0xbe, 0xc7, 0xe1, 0x4d, 0xa1, 0xf5, 0xcb, 0x8e, 0x5d, 0xb0, 0x15, 0x05, 0x03, 0x9e, 0x75, 0x4a, 0x34, 0x31, - 0x5d, 0x97, 0x15, 0x01, 0xc6, 0x12, 0x27, 0x90, 0x5b, 0x9d, 0xb3, 0x74, 0x94, 0x9b, 0xb3, 0x9b, 0x3c, 0x6b, 0x27, - 0xd7, 0xd1, 0xbe, 0x9d, 0xcc, 0x4a, 0x59, 0xe5, 0xba, 0x21, 0x34, 0x7b, 0xf9, 0xe4, 0x2c, 0x94, 0x8b, 0x42, 0x1d, - 0x15, 0x37, 0xb4, 0x6a, 0x4d, 0x56, 0xc0, 0xca, 0xc9, 0x45, 0xab, 0xf2, 0xe6, 0xe9, 0xd0, 0xb8, 0xc9, 0x36, 0xfd, - 0x58, 0xd1, 0x76, 0x07, 0x0a, 0xaf, 0x14, 0xd6, 0xf6, 0x1c, 0x6c, 0xc3, 0x89, 0x06, 0xc7, 0x7d, 0xbb, 0x6d, 0x40, - 0x54, 0x20, 0xbb, 0x98, 0x50, 0x62, 0x6b, 0xf9, 0x2f, 0x0e, 0x28, 0xbe, 0xbd, 0x9a, 0x5e, 0x47, 0x31, 0x32, 0x7c, - 0x53, 0xff, 0x1e, 0x08, 0xf0, 0x2f, 0x7c, 0x60, 0x6a, 0x67, 0x54, 0x45, 0x28, 0x6b, 0x37, 0xab, 0xd4, 0x1f, 0x15, - 0xb9, 0xa5, 0x49, 0x6a, 0xb6, 0x79, 0x7a, 0x82, 0x29, 0x2b, 0xda, 0x9b, 0xc3, 0x06, 0x7b, 0x74, 0x6d, 0x44, 0xd8, - 0x60, 0x42, 0x1c, 0xff, 0x83, 0x9d, 0x00, 0x9d, 0x48, 0xf1, 0x82, 0xcb, 0x71, 0x65, 0x29, 0x9a, 0x12, 0xcd, 0x4b, - 0x51, 0xfb, 0x14, 0xe6, 0x3d, 0xaf, 0x02, 0xae, 0x9b, 0x93, 0xa3, 0x6e, 0x87, 0x3e, 0x71, 0x8a, 0x38, 0xcf, 0x81, - 0x08, 0x7b, 0x2a, 0xa7, 0x5d, 0xbd, 0x59, 0x5b, 0x86, 0xb8, 0x6e, 0x56, 0x88, 0x90, 0x7c, 0x18, 0xa7, 0x62, 0x88, - 0xb1, 0x7d, 0x23, 0x73, 0xde, 0xe3, 0xab, 0x85, 0x28, 0xac, 0x70, 0x11, 0x8f, 0x91, 0xa5, 0x3f, 0x79, 0x05, 0xd1, - 0x9d, 0x51, 0x93, 0x60, 0xd6, 0xea, 0x64, 0xa4, 0x38, 0x53, 0xff, 0x02, 0x02, 0x43, 0x6d, 0x90, 0xd1, 0x41, 0x4d, - 0x95, 0xf9, 0xd1, 0xd3, 0x88, 0x1b, 0x1f, 0x38, 0xaa, 0x46, 0x9b, 0x90, 0x71, 0xa6, 0xd8, 0x16, 0xbf, 0x35, 0x77, - 0x4b, 0x00, 0x66, 0x77, 0x1d, 0xa8, 0x15, 0x71, 0x24, 0xa1, 0x55, 0x7f, 0xae, 0xfe, 0x7a, 0x89, 0x44, 0x71, 0x4e, - 0xe8, 0x63, 0xa0, 0xc5, 0x47, 0x98, 0xae, 0xe6, 0x62, 0x1b, 0x87, 0x1d, 0x47, 0xa2, 0x8a, 0xd3, 0xbb, 0xe8, 0x72, - 0x3f, 0x93, 0x60, 0xf7, 0x13, 0x22, 0x9e, 0xef, 0xad, 0x0b, 0x35, 0x0b, 0x47, 0x1f, 0xb6, 0x3f, 0x09, 0x12, 0x32, - 0xd4, 0x5f, 0x0b, 0x37, 0x47, 0xed, 0xd5, 0x4b, 0x2d, 0xa3, 0x0d, 0x3f, 0x2d, 0xa2, 0xc5, 0xa9, 0x00, 0x94, 0x0c, - 0xa3, 0xf3, 0xcf, 0x5f, 0xec, 0xb0, 0x9f, 0x83, 0x73, 0x0c, 0x26, 0x0f, 0x78, 0xc0, 0xcd, 0xdd, 0x4f, 0xe8, 0xda, - 0x52, 0xce, 0x08, 0x67, 0xac, 0x0d, 0x09, 0x56, 0xc6, 0xb9, 0x66, 0x6b, 0xe3, 0x45, 0xc3, 0x09, 0xe1, 0x08, 0x3a, - 0x68, 0x8c, 0x7a, 0x9e, 0x33, 0x9a, 0xa7, 0x58, 0xfd, 0xca, 0x19, 0x6f, 0xf9, 0x81, 0x9d, 0xcb, 0x15, 0x04, 0x55, - 0x17, 0x55, 0x62, 0x4d, 0x27, 0xda, 0x81, 0xcb, 0xfd, 0x25, 0x7b, 0xca, 0x22, 0x7f, 0xbb, 0xc0, 0xa4, 0xa6, 0x42, - 0x21, 0x67, 0x85, 0x1b, 0xda, 0x15, 0x82, 0xd5, 0x6a, 0xdc, 0xf0, 0x3b, 0x6f, 0x59, 0x96, 0xaa, 0x4e, 0xed, 0x46, - 0x35, 0x34, 0xc3, 0x84, 0x29, 0x6e, 0x68, 0x19, 0xdf, 0x91, 0x94, 0xd8, 0x59, 0xd7, 0x06, 0x73, 0xfa, 0x1f, 0x52, - 0x9c, 0x0a, 0x2d, 0x44, 0xa9, 0xfa, 0x1c, 0x34, 0x3a, 0x31, 0x4d, 0xd5, 0x79, 0x23, 0x77, 0x26, 0x07, 0x83, 0x9a, - 0xb2, 0xb1, 0x53, 0xf3, 0x8e, 0xe9, 0xc8, 0x0c, 0xfe, 0x8e, 0xfc, 0xe4, 0x21, 0xab, 0x65, 0x72, 0x99, 0xe8, 0x67, - 0xbd, 0xf1, 0xaa, 0x00, 0x14, 0xd6, 0x31, 0xa8, 0xd0, 0x3c, 0x6b, 0x2a, 0x6b, 0x55, 0x65, 0xba, 0x09, 0x5f, 0x75, - 0x4b, 0x03, 0x05, 0xcf, 0x94, 0xa7, 0x10, 0x51, 0x53, 0xe2, 0xa4, 0xd5, 0x43, 0xa8, 0x01, 0xe5, 0xe8, 0xbc, 0x88, - 0xb9, 0x8e, 0x95, 0x2a, 0x1b, 0xff, 0x72, 0xef, 0xa3, 0x75, 0xeb, 0x20, 0xef, 0x67, 0x36, 0xea, 0xfd, 0x22, 0x55, - 0x4e, 0xa1, 0xcf, 0x8f, 0x34, 0x8d, 0x35, 0x09, 0xb6, 0x71, 0x32, 0x90, 0x0a, 0x3a, 0xa9, 0xc0, 0xff, 0xcb, 0x94, - 0x33, 0x56, 0x4c, 0x2a, 0x40, 0xc5, 0x62, 0xed, 0x9a, 0x7f, 0xdd, 0x87, 0x05, 0x93, 0xa0, 0xee, 0x1f, 0x80, 0x5e, - 0x0b, 0xb9, 0x90, 0x5f, 0xad, 0xb7, 0xa1, 0xec, 0x7c, 0xc3, 0x49, 0xeb, 0x73, 0xf5, 0x93, 0x23, 0x17, 0xb1, 0x9a, - 0xa2, 0xcd, 0xb4, 0x9e, 0x3a, 0x4b, 0x98, 0xf0, 0xd3, 0x72, 0x6e, 0xba, 0x41, 0xe6, 0x1a, 0x47, 0xca, 0xcb, 0xec, - 0xe3, 0xa8, 0x55, 0x66, 0xe9, 0xd8, 0x86, 0x2a, 0x6a, 0x73, 0x3c, 0x73, 0xc6, 0xf8, 0x62, 0x4f, 0x9a, 0x6a, 0x57, - 0x56, 0xf2, 0xcd, 0xb5, 0x98, 0x37, 0x87, 0x32, 0x45, 0x3d, 0x32, 0xad, 0x93, 0x8b, 0x13, 0x2a, 0xb3, 0x02, 0xc0, - 0xdb, 0x90, 0x6c, 0x84, 0xc0, 0x2e, 0xd8, 0x8f, 0xb5, 0x78, 0xe9, 0x2e, 0xa5, 0x51, 0x82, 0x97, 0x10, 0x2b, 0xfa, - 0x87, 0xd2, 0x42, 0x83, 0x54, 0x57, 0x94, 0x2c, 0x0d, 0xf5, 0xdf, 0x4a, 0x0f, 0x27, 0x39, 0x6a, 0x70, 0x0e, 0xb5, - 0xc7, 0xc2, 0xa8, 0xf7, 0x63, 0xd2, 0xa3, 0x3c, 0xd6, 0x4b, 0x81, 0x4d, 0x96, 0xc0, 0xca, 0x09, 0x76, 0x17, 0x20, - 0xe5, 0xb5, 0x87, 0xbe, 0x56, 0x64, 0xc2, 0xe3, 0xf3, 0xe4, 0xd6, 0xa5, 0x65, 0xe0, 0x15, 0xf4, 0xae, 0xbd, 0xa1, - 0xd2, 0x02, 0x77, 0xbf, 0xc8, 0x95, 0xff, 0xe8, 0x50, 0x24, 0x1d, 0xf1, 0x54, 0x12, 0x78, 0x2b, 0xa9, 0xc1, 0xc0, - 0xad, 0x65, 0xc3, 0xb5, 0x69, 0x1b, 0x7d, 0xa8, 0x8f, 0xe3, 0x3b, 0x46, 0xab, 0xe0, 0x3f, 0x9f, 0x7e, 0xc3, 0x38, - 0xb4, 0xe0, 0xd9, 0xaa, 0x54, 0x59, 0xd7, 0x53, 0x47, 0xb2, 0xfd, 0xd5, 0xce, 0x5b, 0x04, 0xb3, 0x70, 0x25, 0x0b, - 0x4d, 0x02, 0x3a, 0xb6, 0x49, 0x16, 0xb8, 0x4d, 0x81, 0x99, 0x47, 0x3f, 0x45, 0x6f, 0x23, 0xd5, 0x38, 0x52, 0xb5, - 0x68, 0x12, 0xe3, 0x70, 0x41, 0x34, 0x79, 0x73, 0xb7, 0x2a, 0x02, 0x19, 0x1c, 0xc0, 0x2d, 0xbf, 0x33, 0xce, 0x3d, - 0xf5, 0x91, 0xd6, 0x3a, 0xf0, 0xbb, 0x6e, 0xb2, 0x5d, 0xda, 0xa1, 0x51, 0x4b, 0xf4, 0xb6, 0x1d, 0x35, 0x1a, 0x64, - 0xd8, 0x23, 0xc5, 0xd8, 0xbd, 0x8f, 0xcf, 0xea, 0x31, 0x83, 0x2c, 0xd1, 0x01, 0x5f, 0x77, 0x0d, 0x54, 0x2c, 0x32, - 0x90, 0xbb, 0x0b, 0x21, 0x51, 0x87, 0x6d, 0xb4, 0x00, 0x50, 0xfa, 0x04, 0xab, 0xef, 0xc4, 0x2d, 0xf5, 0x06, 0x94, - 0xf9, 0x3e, 0xa4, 0x94, 0x42, 0x7d, 0x51, 0x91, 0x29, 0x67, 0x8b, 0xc5, 0x8c, 0x22, 0x8c, 0x3c, 0x11, 0x19, 0x6a, - 0x13, 0xc4, 0x08, 0x9c, 0xde, 0x32, 0xaa, 0x7e, 0x6c, 0x2f, 0x03, 0x2d, 0xed, 0xb5, 0x88, 0xa9, 0xca, 0x19, 0xcf, - 0x01, 0x94, 0x80, 0xc1, 0x55, 0x00, 0x67, 0xa6, 0x7a, 0x57, 0xc6, 0x9c, 0x58, 0x66, 0x05, 0x0f, 0x94, 0x4e, 0x2e, - 0xc6, 0xd7, 0xc0, 0xf9, 0x8f, 0xad, 0x89, 0xab, 0xf8, 0xeb, 0xd7, 0x2d, 0x9f, 0x67, 0xff, 0x97, 0x89, 0x76, 0x75, - 0x06, 0xac, 0x9c, 0xb0, 0xcf, 0x13, 0xc4, 0xeb, 0x06, 0xdb, 0xcb, 0xd6, 0x62, 0xc5, 0x93, 0x5e, 0x7f, 0x6c, 0xb5, - 0xa4, 0x2c, 0xab, 0xe4, 0x57, 0x1b, 0x08, 0xa4, 0xf1, 0x9d, 0x49, 0x64, 0x90, 0x0a, 0x92, 0x62, 0xba, 0x11, 0xfc, - 0xee, 0x5b, 0xef, 0x61, 0x47, 0x1a, 0x78, 0xd9, 0xea, 0xc2, 0xf0, 0x99, 0xba, 0x5d, 0xd3, 0x49, 0xce, 0xe0, 0xcc, - 0x9b, 0x09, 0x47, 0x5b, 0xef, 0xf2, 0xe5, 0x0a, 0x2d, 0xfa, 0x3c, 0xf4, 0x2b, 0xba, 0x4d, 0x5a, 0x96, 0xc7, 0x3d, - 0xcc, 0xa0, 0xfe, 0xaf, 0x62, 0xcd, 0x69, 0xf4, 0x55, 0x51, 0x5f, 0x7a, 0x41, 0x2b, 0xcd, 0x6d, 0xad, 0x2d, 0xe4, - 0x74, 0x6e, 0x91, 0x7b, 0x30, 0x34, 0xed, 0xfb, 0x8f, 0x2a, 0xc2, 0x92, 0x3d, 0xa5, 0xad, 0xf7, 0xc9, 0x45, 0x2f, - 0xd5, 0xb9, 0x11, 0xff, 0x96, 0x53, 0x79, 0xd3, 0x3a, 0x6a, 0x64, 0x27, 0xfe, 0x0f, 0xd6, 0x4d, 0x94, 0x71, 0xb9, - 0x4e, 0xee, 0xb4, 0x83, 0xe2, 0xa8, 0x4b, 0x8e, 0x87, 0x38, 0xd7, 0x8c, 0x46, 0x7a, 0x25, 0xcc, 0x33, 0xa7, 0x55, - 0x85, 0x1e, 0x8b, 0x06, 0xc9, 0x1a, 0x1a, 0x90, 0x04, 0xa8, 0xc9, 0x09, 0x71, 0xea, 0x4e, 0x70, 0x6b, 0x40, 0x72, - 0x72, 0x89, 0x90, 0x9c, 0x16, 0xde, 0xe5, 0xe7, 0x0d, 0x19, 0xa2, 0x9c, 0xd7, 0x37, 0xb1, 0x23, 0x2a, 0x3e, 0x8b, - 0x6e, 0xb9, 0x6f, 0x11, 0x1a, 0x6d, 0x1f, 0x34, 0x9a, 0x8e, 0x39, 0xb0, 0xcb, 0x9b, 0x35, 0x68, 0x39, 0x33, 0x08, - 0xf9, 0xe9, 0x19, 0x34, 0x61, 0xc0, 0x6c, 0x85, 0x10, 0x73, 0x94, 0x6c, 0x95, 0x9a, 0x34, 0x06, 0xf5, 0xc4, 0x4e, - 0x1c, 0xa8, 0xcf, 0xcf, 0xba, 0x59, 0x29, 0xd9, 0x9c, 0x9a, 0xda, 0xf4, 0x03, 0xd8, 0xe2, 0x89, 0x36, 0x1f, 0x28, - 0xc3, 0x20, 0x0d, 0x57, 0x25, 0xc2, 0xdf, 0xa8, 0xa8, 0xf3, 0x65, 0x3e, 0x6f, 0xd8, 0x46, 0xd0, 0x88, 0x21, 0x03, - 0xb3, 0x13, 0xac, 0x81, 0x20, 0x58, 0x16, 0x67, 0x72, 0x96, 0xd2, 0xd9, 0x38, 0x96, 0x58, 0x0b, 0x05, 0xb4, 0xbc, - 0x4d, 0xce, 0x1d, 0x04, 0x50, 0x46, 0xa2, 0xc4, 0xb2, 0x8d, 0x88, 0x3e, 0x30, 0x09, 0xde, 0x10, 0x2b, 0xf8, 0x05, - 0xdf, 0x50, 0x3a, 0xe9, 0x40, 0x6d, 0x92, 0x3b, 0x85, 0xaa, 0x0c, 0x0e, 0xc6, 0xe1, 0x15, 0xff, 0xed, 0xca, 0x0f, - 0x0e, 0x6d, 0x22, 0xee, 0x2a, 0xe0, 0x92, 0xd1, 0x73, 0x08, 0xea, 0xe4, 0xda, 0xb2, 0x89, 0xef, 0x34, 0xda, 0xde, - 0x55, 0xb5, 0x2b, 0x8e, 0xf8, 0xfc, 0x51, 0x60, 0x14, 0xa4, 0x5c, 0xce, 0xfe, 0x8d, 0x27, 0x69, 0xa2, 0x39, 0xb7, - 0xef, 0x1d, 0x2e, 0x16, 0x69, 0xa6, 0x3a, 0x35, 0xbd, 0xb9, 0x58, 0xb5, 0x0f, 0x46, 0xae, 0xb5, 0x67, 0x67, 0x1c, - 0x47, 0x20, 0x05, 0xe5, 0x03, 0xfe, 0x0b, 0xa9, 0x1a, 0xf2, 0xf9, 0xd0, 0xcf, 0x01, 0xd5, 0x4c, 0xf1, 0x69, 0xd5, - 0xd6, 0xbe, 0x49, 0xb5, 0xe0, 0x7f, 0x73, 0x58, 0xa4, 0x75, 0xfd, 0xfd, 0xf9, 0x8b, 0xde, 0x36, 0xd2, 0xf1, 0x23, - 0xb1, 0x92, 0xfa, 0x13, 0xa0, 0xac, 0xbd, 0x19, 0xb3, 0x76, 0x10, 0x4e, 0x63, 0x4a, 0x21, 0xfa, 0x4f, 0xe2, 0x53, - 0x4f, 0x65, 0xf0, 0x0d, 0xd4, 0x0f, 0xde, 0xc4, 0x68, 0xe9, 0x67, 0x6d, 0x6a, 0x21, 0xfc, 0x2d, 0xe6, 0x8b, 0x5a, - 0x3d, 0xe8, 0x38, 0x0f, 0xb9, 0x78, 0xc5, 0xea, 0x3f, 0x7d, 0xf1, 0xe5, 0x6c, 0x61, 0xb0, 0x96, 0xb5, 0x07, 0xff, - 0x8f, 0xf3, 0x00, 0x20, 0x5b, 0x61, 0x58, 0x4b, 0x34, 0xd3, 0x2f, 0xad, 0xa7, 0x00, 0xdf, 0x9d, 0xa7, 0x52, 0x6c, - 0x4d, 0x8b, 0x95, 0xa9, 0x67, 0x3a, 0xa8, 0x57, 0x6a, 0x6b, 0xdc, 0x37, 0xd6, 0x87, 0xd1, 0xd0, 0x77, 0x30, 0x57, - 0xbc, 0x7e, 0x8c, 0xe9, 0xee, 0x9f, 0x26, 0x26, 0x86, 0xfd, 0x4e, 0x75, 0x4a, 0x9a, 0xa5, 0xbe, 0x6d, 0xc8, 0x99, - 0xdd, 0x26, 0x70, 0xdf, 0x64, 0x8a, 0x90, 0xe3, 0x7d, 0x72, 0x94, 0xa2, 0xa6, 0x7d, 0x4b, 0x25, 0xab, 0x5b, 0x0f, - 0x69, 0xc4, 0x2c, 0x35, 0xd0, 0xfb, 0xe2, 0x55, 0x81, 0x81, 0x87, 0xea, 0xbc, 0x7e, 0x8b, 0x02, 0x9e, 0xc1, 0x47, - 0xcb, 0xac, 0xda, 0xba, 0x04, 0x8e, 0x51, 0xeb, 0xc0, 0xfd, 0xf2, 0xc0, 0x1f, 0x29, 0xfa, 0xe2, 0x6d, 0xe4, 0x60, - 0x83, 0xd7, 0x53, 0x83, 0x53, 0x1e, 0x9e, 0x8d, 0xf5, 0x31, 0xe3, 0xa7, 0x95, 0xa3, 0xb0, 0x67, 0x5c, 0x3c, 0x99, - 0x5d, 0x8c, 0xc3, 0xa6, 0xdb, 0x2a, 0x27, 0x4a, 0xa6, 0x4c, 0xd7, 0x64, 0x7e, 0xc6, 0x85, 0x9e, 0x37, 0x6b, 0xb5, - 0x84, 0xcd, 0x0f, 0xfe, 0x70, 0x53, 0x5c, 0x19, 0x27, 0xa3, 0xd0, 0xfe, 0x1f, 0xd9, 0x99, 0xa6, 0x77, 0xa1, 0x46, - 0xe0, 0x52, 0x70, 0xb5, 0x54, 0x96, 0x46, 0xda, 0xcf, 0xf6, 0xe9, 0xfb, 0x24, 0x5f, 0x41, 0x9e, 0xfe, 0x92, 0x15, - 0x1b, 0x73, 0x92, 0x64, 0xff, 0xa8, 0x14, 0x32, 0x87, 0xaa, 0x45, 0x3b, 0x46, 0x5b, 0xf9, 0x09, 0x41, 0x7d, 0xd9, - 0x21, 0xea, 0x00, 0xcc, 0xb6, 0x4a, 0x79, 0xbf, 0x18, 0x68, 0x46, 0x51, 0xb6, 0x1c, 0xf4, 0xb5, 0x61, 0x06, 0x07, - 0xaf, 0x1a, 0xd6, 0xef, 0xbd, 0xac, 0x55, 0x32, 0x52, 0x69, 0xb3, 0xcc, 0x51, 0x6a, 0xf2, 0x74, 0xbf, 0xd4, 0xb9, - 0xe8, 0x9a, 0x38, 0xf8, 0xd9, 0xda, 0xf7, 0x60, 0xd7, 0x4e, 0xcb, 0xae, 0x14, 0xe6, 0x06, 0xc7, 0x79, 0xcc, 0x71, - 0x65, 0x03, 0x11, 0x6b, 0x16, 0x5a, 0xde, 0x14, 0x2d, 0x52, 0x77, 0xea, 0xbb, 0xb3, 0xec, 0x26, 0x80, 0xad, 0x62, - 0xef, 0xa1, 0xe5, 0xdb, 0x67, 0xe9, 0x8d, 0x0e, 0x6c, 0x6b, 0xe3, 0x5e, 0xc7, 0x37, 0x16, 0x84, 0x9e, 0x2c, 0xaf, - 0xce, 0xa8, 0x8e, 0x3b, 0xa7, 0xf9, 0xfc, 0x50, 0x31, 0x96, 0x6e, 0x93, 0xe8, 0x9c, 0x8f, 0xe4, 0x09, 0xb2, 0x0c, - 0x15, 0xcb, 0x69, 0x60, 0x2d, 0x23, 0x68, 0xec, 0x24, 0x7d, 0xe5, 0x91, 0xac, 0xc6, 0x8a, 0xf9, 0x47, 0xa0, 0x76, - 0xae, 0xec, 0xb8, 0x6d, 0x86, 0xa4, 0x5a, 0xae, 0xb4, 0x46, 0x30, 0x0c, 0x8d, 0x7f, 0x2d, 0x44, 0xa2, 0xda, 0x4a, - 0x40, 0x02, 0x0e, 0x67, 0x29, 0xa8, 0xdd, 0x6d, 0x79, 0xf3, 0x6e, 0x94, 0x1e, 0x51, 0xa4, 0xa2, 0x56, 0x54, 0x4e, - 0xf1, 0x86, 0xb2, 0xf5, 0x4c, 0x34, 0x01, 0x13, 0x8d, 0x62, 0x23, 0x33, 0x28, 0x6f, 0xb7, 0x2a, 0xe4, 0x5e, 0xae, - 0xfb, 0xb7, 0x57, 0xef, 0x28, 0x0d, 0x9b, 0xbe, 0x12, 0x92, 0x06, 0xad, 0x50, 0x44, 0x7c, 0xc0, 0x8e, 0x31, 0x8e, - 0xae, 0xc9, 0xf4, 0x99, 0x3a, 0x30, 0x46, 0x75, 0x89, 0x94, 0x2f, 0xcd, 0x9f, 0xbd, 0xf1, 0xea, 0x25, 0xb0, 0xf5, - 0x3b, 0x5d, 0x6b, 0x4d, 0x66, 0xde, 0x96, 0x52, 0x2b, 0x91, 0x6e, 0x32, 0x22, 0x8d, 0xff, 0x4c, 0xb3, 0x6f, 0x26, - 0xf2, 0x87, 0x1d, 0xed, 0xc0, 0x40, 0x86, 0xf4, 0x66, 0xb3, 0x39, 0xa7, 0x6a, 0x16, 0x00, 0x0a, 0xff, 0xd5, 0xba, - 0x0f, 0x66, 0x6b, 0xa6, 0xa9, 0x88, 0xe0, 0xb3, 0x30, 0x34, 0x6f, 0xe1, 0x90, 0xd5, 0x69, 0x04, 0xe0, 0x20, 0x09, - 0x81, 0xcc, 0xd9, 0x5c, 0x6f, 0x08, 0xaa, 0xd8, 0xdb, 0xb0, 0x46, 0x9f, 0x42, 0xe8, 0x7f, 0xe4, 0xd3, 0xcf, 0xf9, - 0x5e, 0x45, 0x51, 0x0c, 0x5d, 0x1d, 0x0a, 0x87, 0xd6, 0xdf, 0x64, 0xd2, 0x78, 0x97, 0x2c, 0x14, 0x83, 0xfa, 0x8b, - 0xbd, 0x43, 0xcb, 0xdc, 0x74, 0x67, 0x03, 0x0b, 0x97, 0x0a, 0x06, 0x52, 0x2c, 0x42, 0x48, 0x73, 0x83, 0xb3, 0x7e, - 0xeb, 0xb1, 0x7c, 0xe9, 0x02, 0x4d, 0xdf, 0xca, 0xe3, 0x31, 0x3e, 0xfb, 0x76, 0xbc, 0xe3, 0x13, 0x66, 0x5a, 0x66, - 0x89, 0x4a, 0x0a, 0xe9, 0x93, 0xff, 0x0e, 0xa3, 0x96, 0xc7, 0x84, 0x05, 0xd3, 0xea, 0xee, 0xa9, 0x14, 0xc5, 0xce, - 0x73, 0x58, 0x53, 0x2f, 0xa0, 0x0e, 0x85, 0x9b, 0xea, 0x03, 0xbb, 0x12, 0x41, 0x6a, 0x53, 0x00, 0x30, 0xfe, 0x08, - 0x80, 0x88, 0x07, 0x99, 0x57, 0xaa, 0x25, 0x64, 0xb8, 0x59, 0x4e, 0xa4, 0xbb, 0x8b, 0x51, 0xe2, 0x9b, 0x23, 0x02, - 0xb4, 0xa5, 0x66, 0x18, 0x9e, 0xc9, 0x6f, 0x73, 0x79, 0x13, 0x2e, 0x81, 0xed, 0x1a, 0xc1, 0x1b, 0x21, 0x6d, 0xd6, - 0x7e, 0x38, 0x02, 0xaa, 0xb6, 0x01, 0x51, 0xfa, 0x4d, 0x79, 0x63, 0xde, 0x88, 0x14, 0xaa, 0xd5, 0xce, 0xee, 0x4d, - 0x5a, 0xa7, 0x0d, 0xab, 0xe1, 0x29, 0xdc, 0x54, 0xa9, 0x6d, 0x23, 0xd7, 0xf6, 0x7f, 0x92, 0x82, 0x9c, 0x4d, 0xdd, - 0xd5, 0x6d, 0xf7, 0xfb, 0xa7, 0x09, 0x38, 0xfc, 0x24, 0x31, 0xbe, 0xfb, 0xd5, 0x32, 0xfb, 0x3f, 0xb6, 0xf2, 0xa0, - 0x04, 0x0f, 0xa7, 0x20, 0x9f, 0x62, 0x0d, 0xd7, 0x90, 0x7a, 0xf2, 0xae, 0xaf, 0xbb, 0x80, 0xc0, 0xfa, 0x2d, 0xb9, - 0x13, 0xef, 0x32, 0x82, 0x53, 0x00, 0xdb, 0xd6, 0x11, 0x58, 0xeb, 0xe6, 0x3b, 0x90, 0x82, 0x18, 0xf9, 0x2d, 0x92, - 0xff, 0xb3, 0x32, 0x37, 0xfc, 0x48, 0x51, 0xdc, 0x9c, 0x4b, 0x17, 0xd1, 0x93, 0x55, 0xd8, 0x0e, 0x1b, 0x55, 0x80, - 0x23, 0xb0, 0xf0, 0x7e, 0x6e, 0x26, 0xff, 0x0c, 0xa1, 0x9d, 0xab, 0x33, 0xc5, 0xa1, 0x18, 0xd5, 0x4f, 0x75, 0x01, - 0xca, 0xc3, 0x64, 0xc4, 0xa6, 0x26, 0xb4, 0x18, 0x0b, 0x4b, 0x97, 0x24, 0x80, 0x40, 0x7b, 0xa8, 0x25, 0x32, 0x97, - 0x6b, 0x91, 0x5d, 0x32, 0xee, 0xd9, 0x56, 0x2c, 0x5d, 0xfb, 0x98, 0xd7, 0xd9, 0x33, 0x70, 0xe3, 0x3c, 0x06, 0x5f, - 0xdc, 0xd9, 0x52, 0x58, 0xe9, 0x19, 0xb2, 0x3a, 0x3b, 0x57, 0xe2, 0xb0, 0x4d, 0xb6, 0x1f, 0x15, 0xec, 0xee, 0xdb, - 0x5b, 0x22, 0x0b, 0xc4, 0xe0, 0x3f, 0xad, 0x35, 0x59, 0xeb, 0x6f, 0xe4, 0x00, 0xbe, 0x85, 0x95, 0x7c, 0x41, 0x33, - 0xe0, 0x72, 0x77, 0x73, 0x40, 0xea, 0x81, 0x4f, 0x26, 0xac, 0xaa, 0x72, 0xcd, 0xcd, 0x46, 0xa6, 0x09, 0x9a, 0x10, - 0xff, 0xbf, 0xb2, 0xd5, 0x10, 0x1b, 0x80, 0x27, 0x63, 0xdf, 0x7c, 0xd9, 0x85, 0xc1, 0x66, 0xa1, 0xc5, 0x16, 0xf6, - 0xe1, 0x2d, 0xa7, 0xe2, 0x75, 0x73, 0x03, 0x35, 0xfc, 0x20, 0x81, 0x95, 0xef, 0x12, 0xaa, 0xf9, 0x9e, 0x38, 0xf6, - 0xbd, 0x57, 0xbe, 0x7a, 0x4e, 0x8f, 0x40, 0xd3, 0xe8, 0xac, 0x99, 0xf4, 0xe4, 0x70, 0x6e, 0x0c, 0x55, 0x23, 0xaf, - 0x95, 0xb7, 0x07, 0x57, 0xab, 0xbf, 0x3e, 0x9b, 0xf3, 0x36, 0x3f, 0xa2, 0x1f, 0x5d, 0x63, 0x23, 0x66, 0x71, 0xc2, - 0x57, 0xd7, 0x47, 0x91, 0x50, 0x51, 0xc4, 0xc5, 0x87, 0x75, 0x9f, 0x36, 0xae, 0xb7, 0x8e, 0x6e, 0xf1, 0x2e, 0xc0, - 0x9c, 0x92, 0x54, 0x9d, 0x6d, 0x67, 0xe8, 0x0a, 0xbe, 0x97, 0xb5, 0xc5, 0xf1, 0xa5, 0xb5, 0x6e, 0xcb, 0xcb, 0xae, - 0xbc, 0x37, 0x46, 0x5d, 0xb4, 0x60, 0xd7, 0x77, 0x9c, 0xbc, 0xd5, 0xc8, 0xfd, 0xea, 0xa9, 0x2d, 0x96, 0x50, 0x40, - 0x1b, 0x5a, 0xbe, 0x20, 0x3b, 0xc6, 0x9e, 0x8d, 0x4e, 0xa5, 0xc9, 0x53, 0xf4, 0xba, 0xfb, 0xcc, 0x23, 0x1e, 0xd6, - 0x81, 0xae, 0x9c, 0x06, 0x1d, 0xff, 0xc2, 0x7f, 0x79, 0x59, 0xaa, 0xb7, 0x2a, 0xae, 0xbd, 0x12, 0x00, 0x93, 0x2a, - 0x9f, 0xf4, 0xf2, 0xf7, 0x41, 0x10, 0x19, 0xd9, 0x08, 0xf1, 0x4c, 0x54, 0x96, 0x00, 0x3a, 0xae, 0x72, 0xf1, 0xce, - 0x74, 0xd0, 0x2f, 0x67, 0x22, 0x11, 0x39, 0x03, 0x6d, 0x1b, 0x14, 0x0a, 0x91, 0x7a, 0xbb, 0x08, 0xe2, 0x1e, 0x45, - 0x4c, 0x34, 0xd7, 0x5d, 0xdf, 0xaf, 0xd1, 0x71, 0x34, 0x36, 0xa3, 0x76, 0xfb, 0x5b, 0xc1, 0x14, 0x48, 0x89, 0x83, - 0x81, 0xba, 0xa2, 0x22, 0x1e, 0xff, 0xf1, 0x40, 0xfb, 0x25, 0x35, 0x9c, 0xb2, 0xc3, 0x78, 0x15, 0x5f, 0x59, 0x55, - 0xb5, 0xe2, 0x97, 0x88, 0x99, 0x21, 0x88, 0x37, 0x1a, 0xe9, 0x95, 0xcd, 0x5e, 0xcd, 0x64, 0xa2, 0x38, 0x29, 0x2c, - 0x8f, 0x6b, 0xd7, 0x84, 0x75, 0x00, 0x6b, 0xf5, 0xd1, 0xa1, 0xa5, 0xf8, 0xfb, 0xec, 0x8f, 0x4b, 0x8e, 0x99, 0xe7, - 0xcf, 0xf0, 0xbf, 0xcd, 0x2e, 0x97, 0xfc, 0xd1, 0x3d, 0xc9, 0xf6, 0x3d, 0x76, 0x00, 0xcd, 0x32, 0xa5, 0x8e, 0x32, - 0x86, 0x00, 0xc0, 0x41, 0xe2, 0x7b, 0x8b, 0xdb, 0xff, 0xee, 0x18, 0x44, 0xce, 0xf2, 0xa6, 0xc5, 0x83, 0xff, 0x18, - 0x51, 0x5a, 0x1a, 0x6b, 0xe1, 0x08, 0x82, 0x71, 0x6d, 0xac, 0x1b, 0xc9, 0x3c, 0xd0, 0x75, 0x04, 0xb2, 0x96, 0x9c, - 0x60, 0xa2, 0x44, 0xee, 0x55, 0xcd, 0xeb, 0x10, 0x6a, 0x25, 0x96, 0xa9, 0xcd, 0x23, 0xea, 0xa8, 0xb1, 0xef, 0x40, - 0xf0, 0x32, 0x3b, 0x44, 0x6d, 0xfe, 0x63, 0x4b, 0x81, 0x5f, 0x4a, 0x79, 0x32, 0x70, 0x78, 0x23, 0x14, 0x15, 0x1f, - 0x05, 0x30, 0x9c, 0x11, 0xbc, 0xa8, 0xd5, 0x57, 0x8e, 0x63, 0xa0, 0x1f, 0x4a, 0x2a, 0x5e, 0xec, 0x3e, 0x6f, 0xbc, - 0x01, 0x77, 0xa1, 0xfc, 0x03, 0xe5, 0x3a, 0x52, 0x2d, 0x7b, 0xf9, 0xc8, 0x4e, 0x6d, 0xc7, 0xd9, 0x50, 0x15, 0x54, - 0x45, 0xef, 0xd0, 0x2f, 0x85, 0x70, 0x60, 0x79, 0xb2, 0xda, 0x1b, 0xee, 0x0c, 0x7c, 0x6c, 0xc4, 0x47, 0x7d, 0x25, - 0x7b, 0x43, 0xa2, 0x8c, 0x85, 0xe4, 0x38, 0x2a, 0x40, 0xf4, 0xe4, 0xd3, 0x75, 0x36, 0x0d, 0x7b, 0x75, 0xb6, 0x14, - 0x48, 0x23, 0x46, 0x3a, 0x97, 0x4a, 0x67, 0xf6, 0xf4, 0x48, 0x19, 0x3f, 0xef, 0xfc, 0x6a, 0xd9, 0xa0, 0xcc, 0x36, - 0xa4, 0xf2, 0xa7, 0xbc, 0x2f, 0x25, 0x65, 0xb2, 0xad, 0xd8, 0xf4, 0xc6, 0xe6, 0x14, 0xc0, 0x64, 0x05, 0x61, 0xee, - 0xbe, 0x41, 0x39, 0x18, 0x63, 0x5d, 0xa9, 0x22, 0xdf, 0xf8, 0x3c, 0x76, 0x7a, 0x7a, 0xc1, 0x33, 0x8a, 0x2c, 0xfa, - 0x53, 0x04, 0x36, 0xcb, 0x6b, 0x85, 0x09, 0xdf, 0xe7, 0xb8, 0x46, 0xbf, 0xd0, 0x14, 0x4d, 0x42, 0xf4, 0xe3, 0x8d, - 0x48, 0x35, 0x2b, 0xe0, 0xcd, 0xfb, 0xa6, 0x1b, 0xc1, 0xb3, 0x32, 0xda, 0x48, 0x24, 0xda, 0xba, 0x29, 0xf0, 0xef, - 0x11, 0x7d, 0x23, 0x66, 0xfa, 0x83, 0x34, 0x5f, 0xfd, 0x20, 0xcc, 0x37, 0xdb, 0x03, 0xaa, 0xda, 0x87, 0xdc, 0xf8, - 0xe4, 0x42, 0x01, 0x16, 0x10, 0x46, 0x2f, 0x95, 0x36, 0xd6, 0x04, 0xa5, 0x84, 0x4b, 0x51, 0x93, 0x51, 0x5e, 0x4f, - 0xf5, 0x09, 0xad, 0xeb, 0x25, 0x19, 0x60, 0x12, 0xba, 0xb1, 0x8d, 0xbe, 0x8d, 0xb9, 0x4d, 0x97, 0xfd, 0x87, 0x0a, - 0xed, 0x81, 0x2b, 0x1b, 0x2c, 0xe0, 0x73, 0xb5, 0xe7, 0xce, 0x45, 0x04, 0x5a, 0x83, 0xf8, 0x8f, 0xe3, 0x7a, 0xb1, - 0x77, 0x4b, 0x25, 0x25, 0x56, 0x59, 0x08, 0x19, 0x2a, 0x17, 0x76, 0x73, 0xc3, 0x3c, 0xeb, 0x71, 0xf0, 0x8c, 0x04, - 0x01, 0xc1, 0xa9, 0x82, 0x49, 0x5c, 0x4d, 0x69, 0x58, 0xd9, 0x73, 0x74, 0xc3, 0x69, 0xf9, 0x35, 0x53, 0x65, 0xbb, - 0x40, 0xa7, 0x6f, 0x5c, 0x31, 0x98, 0x9f, 0xd8, 0x17, 0x8e, 0x1e, 0x5a, 0x46, 0xd7, 0x67, 0x07, 0x46, 0x80, 0x1c, - 0x56, 0x96, 0x81, 0x84, 0x2d, 0x49, 0xab, 0x37, 0x79, 0x78, 0xcf, 0x14, 0x22, 0xc9, 0x02, 0x55, 0x8e, 0x5f, 0x60, - 0x6b, 0x69, 0x49, 0x39, 0x2b, 0xd1, 0x5a, 0x85, 0x32, 0x44, 0x6b, 0xbd, 0x6f, 0x57, 0x9d, 0xde, 0x7b, 0x5f, 0xd0, - 0x79, 0x69, 0x24, 0x87, 0x18, 0x02, 0x43, 0x2c, 0x8d, 0xef, 0x14, 0x36, 0x5a, 0x6f, 0x96, 0xd9, 0x7d, 0x35, 0xb6, - 0x5f, 0xc3, 0x75, 0x3d, 0xf1, 0xa6, 0xfc, 0xb6, 0xce, 0x1e, 0xe6, 0xbc, 0x72, 0xa2, 0x1b, 0xba, 0x86, 0xcd, 0xda, - 0x4e, 0x7f, 0x55, 0xdf, 0x32, 0x19, 0x16, 0x1f, 0x7b, 0x08, 0x21, 0x17, 0xaa, 0x54, 0x88, 0xf4, 0x76, 0x27, 0x90, - 0x2a, 0xf7, 0x94, 0x2b, 0x9d, 0xe3, 0x44, 0xd6, 0xb1, 0x9d, 0x1c, 0x2e, 0x4d, 0x2a, 0x88, 0x63, 0x7b, 0xf7, 0x9d, - 0x58, 0xf0, 0xc9, 0x17, 0xd2, 0x9c, 0xa7, 0xeb, 0x97, 0x7e, 0x78, 0x65, 0xac, 0x94, 0x9c, 0x6e, 0x66, 0x51, 0xd3, - 0xdd, 0x2c, 0xb2, 0xf3, 0xaf, 0x71, 0xeb, 0x92, 0xf0, 0x3a, 0x69, 0xff, 0x6a, 0x84, 0x97, 0x5c, 0xeb, 0x52, 0x44, - 0x53, 0x94, 0xba, 0x7f, 0x9d, 0xa0, 0x20, 0x12, 0xfc, 0xb9, 0x68, 0x18, 0x6b, 0x9f, 0x56, 0xcd, 0x47, 0x63, 0xc5, - 0xd6, 0xde, 0xb7, 0x92, 0x1a, 0x17, 0x05, 0xd7, 0x8c, 0x5c, 0x69, 0xa5, 0xc4, 0xe0, 0x38, 0xd0, 0x94, 0x3f, 0x50, - 0xe5, 0x0f, 0x53, 0xd2, 0x79, 0x8b, 0xd9, 0xea, 0xfb, 0xd4, 0x6e, 0x1d, 0x53, 0x45, 0x23, 0x9d, 0x19, 0xb3, 0x51, - 0x2b, 0x38, 0xda, 0xe3, 0x7a, 0x59, 0x48, 0xe7, 0xb4, 0xcd, 0xe0, 0x93, 0xf4, 0xf1, 0xad, 0x7c, 0xb6, 0xca, 0x5f, - 0xea, 0xfd, 0x5e, 0xda, 0xdb, 0xe4, 0xc5, 0x06, 0xde, 0x0a, 0x13, 0x60, 0x20, 0xa2, 0x52, 0x05, 0xb5, 0x84, 0x24, - 0xec, 0xb4, 0xd3, 0x39, 0x43, 0x55, 0x5a, 0x4c, 0x81, 0x1f, 0x97, 0xf5, 0xf1, 0xf8, 0x5a, 0x34, 0xa6, 0xd6, 0x51, - 0x23, 0x3e, 0x2e, 0xe7, 0x19, 0x20, 0x2f, 0x54, 0x3c, 0x53, 0x11, 0x7d, 0x46, 0xce, 0xf0, 0xa0, 0xcc, 0x82, 0x91, - 0x76, 0x18, 0x8a, 0x2d, 0x37, 0xa6, 0x3a, 0x03, 0xba, 0xf0, 0x67, 0x8d, 0x94, 0x69, 0x84, 0x52, 0xc8, 0xb5, 0x49, - 0xbb, 0xcc, 0x37, 0x08, 0xd3, 0x0b, 0x1a, 0x7f, 0x3d, 0xf9, 0x5e, 0x0a, 0x99, 0x02, 0xee, 0x23, 0x85, 0xd7, 0xf4, - 0x42, 0x66, 0xc0, 0x9b, 0x1a, 0x20, 0x09, 0x40, 0x9a, 0x55, 0x27, 0xbc, 0x0d, 0x0f, 0x49, 0xb3, 0xdf, 0xca, 0x52, - 0xb9, 0x27, 0x57, 0x5a, 0xf2, 0xad, 0x6e, 0x2b, 0xe6, 0x4b, 0xd6, 0x36, 0xad, 0x9d, 0x9d, 0xd0, 0xeb, 0x34, 0x4d, - 0xba, 0x44, 0x38, 0xa8, 0x24, 0xbd, 0xdf, 0x01, 0x06, 0x53, 0x5f, 0xbe, 0x45, 0xcd, 0xfc, 0x5e, 0x82, 0x9d, 0x0c, - 0xd8, 0x50, 0x65, 0xe5, 0x32, 0x0b, 0x00, 0x01, 0xba, 0x6d, 0xa3, 0x9b, 0x26, 0x8b, 0x37, 0x22, 0xf7, 0x80, 0xce, - 0x05, 0x77, 0x64, 0x6f, 0x29, 0xdd, 0x99, 0x8e, 0x95, 0x6c, 0xbc, 0x2b, 0x6b, 0xb2, 0x0b, 0x95, 0xf8, 0x26, 0x06, - 0x66, 0x3b, 0x2b, 0x09, 0x80, 0xeb, 0xc6, 0x2e, 0xf3, 0x42, 0x9d, 0xc9, 0x6c, 0xcd, 0xaa, 0x3c, 0x55, 0xc3, 0x54, - 0x3a, 0x74, 0xd5, 0x44, 0x0d, 0x41, 0x36, 0x20, 0x6c, 0x5e, 0xdb, 0x5c, 0xc7, 0x67, 0x01, 0x20, 0xe8, 0x41, 0x29, - 0x4b, 0xc6, 0x8e, 0x1b, 0x69, 0x77, 0xbd, 0xac, 0x00, 0x61, 0xbc, 0xb3, 0x26, 0x39, 0x39, 0x2d, 0xfd, 0xc9, 0x78, - 0xdb, 0x6a, 0xa6, 0xdd, 0xf1, 0x43, 0x42, 0xdb, 0xe2, 0xd0, 0x82, 0x1f, 0xa9, 0xdd, 0xb9, 0x5a, 0xc4, 0xaa, 0xbd, - 0x2c, 0x60, 0xb0, 0x8d, 0xd6, 0xba, 0x6d, 0xee, 0xe6, 0x98, 0x08, 0x27, 0xcb, 0xc6, 0x74, 0x27, 0x96, 0x17, 0x89, - 0x35, 0x06, 0x6a, 0x6b, 0xde, 0xf8, 0xa5, 0xc0, 0xd4, 0x04, 0xdf, 0xa8, 0x5c, 0x2c, 0x8d, 0xfe, 0xf4, 0x03, 0x11, - 0xa1, 0x59, 0x6c, 0xae, 0xd6, 0x4d, 0x68, 0xbc, 0xc6, 0xf5, 0x06, 0xdc, 0x0d, 0x2c, 0x1a, 0x4e, 0xf4, 0x60, 0xce, - 0xee, 0x48, 0xcd, 0x8a, 0x65, 0xf8, 0xd1, 0xa3, 0xa3, 0x02, 0xbb, 0xb3, 0x39, 0x96, 0x14, 0x48, 0x30, 0xe2, 0xd7, - 0xd7, 0x58, 0x2c, 0x6a, 0x97, 0x46, 0x07, 0x63, 0x2e, 0xf9, 0x0f, 0xaa, 0x9b, 0x69, 0x5f, 0x01, 0x9b, 0x7b, 0x86, - 0x23, 0x49, 0x99, 0x19, 0xbd, 0xbc, 0x36, 0x0d, 0xec, 0x55, 0x1e, 0x75, 0x1c, 0x46, 0x4f, 0x4a, 0xc2, 0xde, 0x6c, - 0x4d, 0xa9, 0x5c, 0x8a, 0x51, 0xe8, 0x79, 0x83, 0x58, 0xf4, 0xb8, 0x07, 0x38, 0xc9, 0x98, 0x22, 0x7d, 0xb5, 0x51, - 0x90, 0xb7, 0xda, 0x5f, 0xba, 0xec, 0xa0, 0x49, 0x87, 0xce, 0x02, 0x9c, 0x8c, 0x92, 0x82, 0x10, 0xa0, 0x0d, 0xa1, - 0xd7, 0x06, 0x2f, 0xa5, 0x08, 0x4d, 0x4d, 0x66, 0xd4, 0x85, 0xf9, 0x9c, 0x71, 0x46, 0xa1, 0xa0, 0xa7, 0x5d, 0x9a, - 0x77, 0xab, 0xdb, 0x5c, 0x38, 0xde, 0x5d, 0x54, 0x2b, 0x02, 0x29, 0x5a, 0xf1, 0xd3, 0x43, 0xe1, 0x22, 0xb7, 0x20, - 0xa2, 0xd6, 0x1c, 0xde, 0x1a, 0x9c, 0x5c, 0x4c, 0x68, 0x95, 0xea, 0xae, 0xf7, 0xf0, 0x85, 0x88, 0xef, 0xda, 0x3c, - 0x21, 0x8e, 0x5a, 0x6f, 0xe8, 0xe6, 0x2c, 0xcd, 0x53, 0x09, 0xf5, 0xcc, 0x16, 0x02, 0x97, 0x8d, 0x8c, 0x2a, 0x7c, - 0x33, 0x3e, 0xc7, 0xc8, 0x92, 0x80, 0x32, 0x38, 0x9d, 0xc5, 0x08, 0x2c, 0x32, 0xe6, 0xe3, 0xd8, 0x1f, 0xcf, 0x6c, - 0x82, 0x7c, 0xd7, 0x98, 0x11, 0x89, 0xb7, 0xbd, 0x37, 0xd4, 0x28, 0x94, 0x8c, 0x44, 0x5c, 0x1e, 0x39, 0xb4, 0x7b, - 0x50, 0x7d, 0x37, 0x20, 0x36, 0x8c, 0x29, 0xd3, 0x09, 0xa1, 0x4f, 0x1e, 0xc4, 0x9a, 0x5c, 0x98, 0xb0, 0xd2, 0x49, - 0x0c, 0xc4, 0xe8, 0x6c, 0x40, 0xf5, 0x8d, 0xd0, 0x22, 0x91, 0x05, 0x25, 0x12, 0xf9, 0x6c, 0x4e, 0x88, 0xc3, 0x56, - 0x64, 0xfc, 0x60, 0xb5, 0x77, 0x11, 0x95, 0x3e, 0xe3, 0xa4, 0xb0, 0x2e, 0x0b, 0xa3, 0x3f, 0x46, 0x09, 0x61, 0xc0, - 0xd9, 0xed, 0x49, 0x51, 0xde, 0x0d, 0x8b, 0x47, 0x17, 0xa8, 0xca, 0xb7, 0x5c, 0x01, 0xec, 0xd1, 0x42, 0x0d, 0x54, - 0x59, 0xb2, 0x9c, 0xeb, 0x47, 0x21, 0xc2, 0x53, 0x66, 0x8e, 0xaa, 0x10, 0x06, 0x84, 0x88, 0x4a, 0xed, 0xc2, 0xae, - 0x95, 0x02, 0x74, 0x30, 0xa6, 0x8d, 0x46, 0x88, 0x0b, 0x78, 0x9e, 0xb7, 0x3f, 0x0e, 0x98, 0xe6, 0x89, 0x3f, 0xa8, - 0x06, 0xfd, 0x77, 0x24, 0x9b, 0xac, 0x9f, 0xdc, 0xf7, 0xc3, 0x27, 0x7d, 0x87, 0xce, 0xde, 0xef, 0xab, 0xbf, 0x7f, - 0xec, 0xd1, 0x40, 0x16, 0xf2, 0x0b, 0xdd, 0x84, 0x56, 0xcf, 0xde, 0x18, 0xee, 0x88, 0x56, 0xcf, 0x4e, 0x2f, 0x0a, - 0xd4, 0x3b, 0xd7, 0x4e, 0x6d, 0x1b, 0x36, 0x32, 0x89, 0xc7, 0x9a, 0x27, 0x63, 0xb0, 0x22, 0x83, 0x6a, 0x05, 0x2b, - 0x9b, 0x2c, 0xd1, 0x5d, 0x9f, 0x99, 0x83, 0x7b, 0xe2, 0x46, 0xbe, 0x93, 0x67, 0x1f, 0x80, 0x9b, 0x10, 0xf9, 0x4b, - 0x0e, 0xab, 0xfa, 0x1d, 0xd5, 0xa6, 0x3b, 0x28, 0x18, 0x4a, 0x2d, 0x31, 0x5b, 0x15, 0x8d, 0x25, 0xd8, 0x1b, 0x04, - 0x5a, 0x53, 0xab, 0x0f, 0xeb, 0x70, 0xc8, 0x1f, 0x5b, 0xfb, 0x07, 0x95, 0x89, 0xba, 0x68, 0x40, 0x9e, 0x86, 0x5f, - 0xba, 0x44, 0xb8, 0x6c, 0x53, 0xff, 0xaf, 0x6e, 0x2f, 0x76, 0x46, 0xc1, 0x24, 0xe4, 0x6d, 0xc8, 0xc3, 0xdd, 0xc1, - 0x00, 0x05, 0x4a, 0xe7, 0x1b, 0x6d, 0x78, 0x12, 0x3d, 0xc9, 0xc3, 0xf6, 0x79, 0x69, 0xaf, 0x46, 0x7d, 0xae, 0x63, - 0x9b, 0xda, 0xb6, 0x49, 0x4d, 0x49, 0x73, 0x70, 0x05, 0x96, 0x18, 0x17, 0x34, 0xad, 0xe4, 0x11, 0x4b, 0x2c, 0xc7, - 0xa4, 0xca, 0xad, 0xe4, 0x29, 0xa7, 0x8c, 0xff, 0x10, 0xb4, 0x97, 0x59, 0x1e, 0x0d, 0x97, 0xe5, 0xd1, 0x65, 0xb0, - 0x36, 0xa1, 0xba, 0xb7, 0xa1, 0xfa, 0x62, 0xd6, 0xb4, 0xd4, 0x6a, 0x93, 0x24, 0x91, 0xc6, 0x7b, 0xba, 0x58, 0xd7, - 0x03, 0xe8, 0xce, 0xd4, 0x2e, 0x65, 0x12, 0xc7, 0x38, 0xd9, 0x86, 0xb9, 0xfa, 0xd8, 0x2a, 0xad, 0xcf, 0x5f, 0xd0, - 0xf8, 0xdc, 0x7d, 0x2b, 0x8f, 0x18, 0xb5, 0x18, 0x78, 0x7f, 0x78, 0x2a, 0xc1, 0xc5, 0xa1, 0xb1, 0xb3, 0x3d, 0x4c, - 0x1c, 0x76, 0xec, 0xec, 0xd7, 0x14, 0x4c, 0xcf, 0x81, 0x36, 0xf4, 0xd5, 0xe0, 0xf8, 0xda, 0x3d, 0x77, 0xf0, 0x62, - 0x40, 0x4b, 0xa4, 0xbc, 0x53, 0xe4, 0x88, 0x01, 0x26, 0x5a, 0xf9, 0x9b, 0x5f, 0xe7, 0xf5, 0x87, 0xf8, 0x7a, 0x3c, - 0x10, 0x3b, 0x51, 0x1e, 0x3d, 0x2b, 0x14, 0xa5, 0x44, 0x45, 0x4f, 0xe1, 0x2f, 0x6e, 0xa1, 0x0c, 0xa7, 0x89, 0x4e, - 0x47, 0x45, 0xb7, 0x77, 0x4f, 0x7c, 0x67, 0xff, 0xa6, 0x3a, 0x97, 0xf3, 0x0a, 0x03, 0x5d, 0x08, 0x6c, 0xa0, 0x8c, - 0x8c, 0x05, 0x4a, 0xf1, 0x63, 0xcc, 0x2e, 0x43, 0x94, 0xdc, 0xea, 0x13, 0x3e, 0x70, 0x11, 0x98, 0x3b, 0xa4, 0x49, - 0xc2, 0xe8, 0x51, 0x7b, 0x6e, 0x5a, 0x9e, 0x84, 0x99, 0x9d, 0x27, 0x99, 0x9d, 0x53, 0xc5, 0x85, 0x09, 0x53, 0x35, - 0x28, 0x16, 0x8f, 0xe5, 0xa4, 0xb6, 0x5a, 0x4d, 0x33, 0x27, 0x9a, 0xe9, 0x91, 0x3b, 0x0c, 0x81, 0x6e, 0xd2, 0x0d, - 0x35, 0xfa, 0x4d, 0x54, 0xf1, 0xd1, 0x7a, 0x11, 0x0c, 0xd1, 0xfa, 0x74, 0xd6, 0x46, 0xb9, 0x63, 0x14, 0x25, 0xdf, - 0x17, 0x80, 0xb8, 0xb7, 0xae, 0x28, 0x5d, 0x7d, 0xf2, 0xc7, 0x3f, 0x4c, 0xb5, 0x9e, 0x07, 0x10, 0x23, 0xbe, 0x66, - 0x93, 0x33, 0xa3, 0xf2, 0x48, 0xfc, 0x43, 0x98, 0xb4, 0x80, 0x3b, 0x42, 0x58, 0xb5, 0x71, 0x30, 0x49, 0x4e, 0xe7, - 0x62, 0xa8, 0xef, 0xa2, 0x91, 0x24, 0x94, 0x49, 0x7d, 0x0a, 0x9e, 0x4d, 0x4e, 0xad, 0x8f, 0x0e, 0x09, 0x77, 0xeb, - 0x20, 0x14, 0x62, 0xa6, 0x5a, 0x03, 0xdc, 0x3d, 0xa5, 0xfb, 0x7a, 0xed, 0x8d, 0xd7, 0x2a, 0xe2, 0xfe, 0xfb, 0xc5, - 0xc1, 0xfb, 0xef, 0x78, 0xa9, 0xa9, 0xdf, 0x6f, 0x9c, 0x0d, 0xdb, 0xb7, 0x3c, 0x00, 0x2f, 0x06, 0x78, 0x08, 0x70, - 0x11, 0xf5, 0x56, 0xa7, 0xfd, 0x61, 0x74, 0xe3, 0xeb, 0xca, 0xec, 0x59, 0xd1, 0xf5, 0x3b, 0x3f, 0x78, 0xb7, 0x6f, - 0x21, 0x60, 0x17, 0xdd, 0xff, 0x1f, 0x81, 0x0a, 0x08, 0x86, 0x82, 0xbf, 0x3f, 0x6e, 0x87, 0xb3, 0x23, 0x78, 0x0e, - 0xbd, 0x3e, 0x8e, 0x62, 0xa5, 0x7b, 0x27, 0x4d, 0xb1, 0x57, 0x11, 0x54, 0x99, 0x57, 0xc4, 0xa6, 0x8c, 0xcd, 0x2e, - 0xeb, 0x52, 0xaf, 0xcd, 0x37, 0x18, 0xd0, 0x97, 0x00, 0xc8, 0x48, 0xf5, 0xa6, 0x0c, 0x20, 0xfc, 0xfa, 0x52, 0x2c, - 0x46, 0xf3, 0x7c, 0xa7, 0xb5, 0x6b, 0xf7, 0x29, 0xf4, 0xc3, 0x76, 0x1d, 0x1e, 0x0c, 0xed, 0x09, 0x79, 0x9e, 0x37, - 0xbc, 0xcb, 0xf0, 0x6d, 0x5e, 0x14, 0x9c, 0x06, 0x2f, 0xa3, 0x5a, 0x1a, 0xf2, 0x49, 0x34, 0x06, 0xfa, 0xb4, 0x6f, - 0x29, 0x01, 0xb7, 0x21, 0x31, 0xd8, 0x41, 0x56, 0x7a, 0x7d, 0x24, 0xed, 0x9d, 0xeb, 0x31, 0xbc, 0xd9, 0x6e, 0x71, - 0x91, 0x32, 0x22, 0xb1, 0x63, 0xa0, 0xc9, 0x8d, 0x50, 0xed, 0xed, 0xce, 0x9e, 0x0f, 0xdf, 0xdc, 0x5c, 0xde, 0xdc, - 0xae, 0x8f, 0x43, 0xaa, 0xb1, 0x4e, 0xa7, 0xd6, 0x6a, 0x6c, 0x27, 0x6d, 0x91, 0xef, 0x2d, 0x0b, 0x9b, 0x84, 0x16, - 0xe9, 0x06, 0x96, 0x96, 0x0f, 0x93, 0xaa, 0x55, 0x06, 0x38, 0x91, 0x9a, 0xba, 0x9f, 0x9e, 0x9e, 0x33, 0x25, 0xcb, - 0x03, 0x7a, 0x71, 0xd0, 0x55, 0x21, 0xb6, 0x4b, 0xd7, 0x6f, 0x2f, 0x97, 0x9e, 0xeb, 0x06, 0x60, 0x13, 0x39, 0x30, - 0x90, 0xf2, 0x7f, 0xc7, 0xa2, 0x5e, 0x0e, 0xcb, 0x93, 0x25, 0x82, 0xc2, 0x25, 0xde, 0x75, 0x49, 0x4a, 0xb4, 0x29, - 0x45, 0x68, 0x2e, 0x5e, 0x1f, 0x15, 0xe3, 0x49, 0xdd, 0x59, 0xf3, 0xec, 0x20, 0x12, 0x19, 0x5b, 0x19, 0x1b, 0xcc, - 0x4d, 0x5a, 0x86, 0x00, 0x07, 0x85, 0x64, 0xcb, 0xf5, 0xa6, 0x0b, 0xb0, 0x5d, 0xf2, 0x57, 0xa3, 0x71, 0x9e, 0x2c, - 0xd1, 0x1d, 0x1a, 0xf6, 0xe5, 0x40, 0xc1, 0xe4, 0xe6, 0xca, 0xe9, 0x91, 0x1f, 0xc5, 0x6c, 0xb1, 0x46, 0x86, 0xc1, - 0x82, 0xe9, 0x04, 0x1c, 0x08, 0xb9, 0x57, 0x0e, 0x10, 0x5b, 0x16, 0xf8, 0x30, 0x98, 0x5b, 0x22, 0x9b, 0x3c, 0xda, - 0xd9, 0x3d, 0x55, 0x28, 0xf8, 0xe4, 0xd6, 0x6d, 0x59, 0xf2, 0xca, 0x0f, 0x82, 0x5e, 0xc5, 0xe5, 0x69, 0xbb, 0x68, - 0x8e, 0xc9, 0xd1, 0x77, 0xd9, 0x94, 0xfd, 0x30, 0x8d, 0xc8, 0xc3, 0x43, 0x92, 0xe1, 0x30, 0x0b, 0x82, 0xc5, 0x4e, - 0x78, 0x29, 0x6c, 0x60, 0xac, 0x6d, 0xc2, 0x8e, 0xd4, 0x10, 0xde, 0x21, 0x26, 0xac, 0x99, 0xb3, 0x16, 0x2c, 0x10, - 0x71, 0x39, 0xe8, 0x3e, 0x72, 0xa0, 0x5f, 0xd9, 0x0a, 0x1d, 0xed, 0xe2, 0x6e, 0xf6, 0x23, 0x16, 0xc8, 0xd8, 0x92, - 0x39, 0xe9, 0x1a, 0x7e, 0xcb, 0x50, 0xad, 0xad, 0x67, 0xa3, 0xb3, 0x7b, 0xc3, 0x34, 0xd1, 0x96, 0x25, 0x3b, 0xa2, - 0x64, 0xfd, 0x42, 0x02, 0xb7, 0x50, 0xe5, 0x46, 0xee, 0xad, 0x44, 0x11, 0xc4, 0x14, 0xba, 0x78, 0xdd, 0x2d, 0x8c, - 0x88, 0x37, 0xd3, 0xa9, 0x39, 0x2a, 0x7c, 0x22, 0x63, 0x50, 0x52, 0x92, 0x82, 0xff, 0x67, 0xbd, 0x5f, 0x80, 0x82, - 0xf8, 0xc4, 0xaf, 0x7f, 0x17, 0x44, 0x38, 0xb0, 0xdb, 0x5e, 0xb6, 0xaa, 0x1d, 0x4b, 0x50, 0x1e, 0x15, 0xe6, 0xdc, - 0x40, 0x6a, 0xfd, 0x7b, 0x6e, 0xe3, 0xcd, 0x9f, 0xbf, 0xcb, 0x5c, 0xad, 0xdb, 0xe5, 0xe6, 0xb5, 0x3b, 0xe4, 0x9a, - 0xb1, 0x03, 0xf6, 0xe5, 0xe0, 0xc3, 0x6a, 0x26, 0xdd, 0x02, 0x92, 0x86, 0x4c, 0x2f, 0xdc, 0xae, 0xe8, 0x86, 0x13, - 0x72, 0x07, 0xe4, 0x10, 0x20, 0xd0, 0x66, 0x50, 0xd6, 0xe8, 0x58, 0xef, 0xc3, 0x79, 0x7b, 0x7d, 0xf9, 0xf7, 0xba, - 0x5e, 0xa2, 0x43, 0x9a, 0x9d, 0xc5, 0xa0, 0xff, 0x7e, 0x2b, 0x19, 0xc9, 0xf6, 0xcd, 0xf6, 0xfe, 0x5d, 0x0b, 0x8a, - 0x6b, 0x9a, 0xf6, 0x0f, 0x7e, 0xf9, 0xa2, 0xb7, 0xf0, 0x7a, 0xe7, 0x23, 0xa9, 0x49, 0x53, 0x6e, 0xf8, 0x71, 0xb5, - 0x95, 0xef, 0x4a, 0x66, 0x7c, 0x40, 0x60, 0xc4, 0xc9, 0xea, 0xe2, 0xe9, 0x61, 0xc4, 0x64, 0x3d, 0x6a, 0x18, 0x4e, - 0x6e, 0x6d, 0xc6, 0xb4, 0x6a, 0x21, 0x32, 0xc0, 0x25, 0x1a, 0x95, 0x28, 0x12, 0x25, 0x31, 0x40, 0x70, 0x6f, 0x7d, - 0x9e, 0xa0, 0x2d, 0x6a, 0xd6, 0x0e, 0xd4, 0x76, 0x56, 0x36, 0x27, 0x01, 0xa3, 0xcd, 0x1c, 0xd3, 0x6a, 0x2e, 0x42, - 0xe7, 0xee, 0x34, 0x88, 0x0e, 0xbd, 0x25, 0xba, 0x94, 0xb9, 0x62, 0xdf, 0xb4, 0xac, 0x2d, 0x03, 0xf2, 0x49, 0xd4, - 0x46, 0x1d, 0x24, 0x58, 0xe5, 0x54, 0x6c, 0x26, 0xf6, 0x8d, 0xa1, 0x2d, 0xdc, 0x81, 0xbe, 0x81, 0x1e, 0xac, 0xf1, - 0x92, 0xdd, 0xe4, 0xed, 0x53, 0xca, 0x0b, 0x8b, 0x49, 0xf7, 0x3b, 0xa9, 0x1e, 0xdb, 0x5b, 0x03, 0xa2, 0x50, 0x8c, - 0x77, 0x0f, 0x09, 0x56, 0x1e, 0xbd, 0x0d, 0x38, 0xb6, 0x4a, 0xaf, 0x71, 0x55, 0x3d, 0x31, 0x26, 0x78, 0x58, 0xca, - 0x27, 0xdf, 0x3f, 0x79, 0x35, 0xee, 0x1a, 0xc6, 0x4b, 0x8b, 0x5b, 0x10, 0x54, 0x30, 0x7b, 0x8b, 0x59, 0xfc, 0xd2, - 0xfc, 0xbe, 0x7b, 0xe0, 0xc6, 0xce, 0x21, 0x37, 0x6f, 0x70, 0xf7, 0x5a, 0xdc, 0xa7, 0xce, 0x67, 0xf5, 0xec, 0xd3, - 0xe9, 0x6a, 0x6b, 0x14, 0x7d, 0x3b, 0x03, 0xed, 0x11, 0xe9, 0xac, 0x01, 0x98, 0x04, 0x28, 0x4b, 0x32, 0xa0, 0x86, - 0x05, 0x5e, 0x2e, 0xad, 0xba, 0x13, 0xd4, 0x54, 0x7b, 0xb6, 0x29, 0x9f, 0x0b, 0x6b, 0x2c, 0xbe, 0x58, 0xba, 0x4e, - 0x53, 0xc3, 0x14, 0xb5, 0xae, 0x5d, 0xf3, 0xf7, 0x6f, 0x65, 0x09, 0x34, 0x4c, 0xe5, 0x8a, 0xfd, 0x1a, 0x55, 0x43, - 0xf0, 0x29, 0x2c, 0xa2, 0x84, 0x00, 0xcf, 0x62, 0x12, 0xa8, 0x5a, 0x3f, 0xb4, 0xbd, 0xdf, 0xbb, 0x63, 0xeb, 0x64, - 0x3a, 0xb8, 0x6b, 0x40, 0x96, 0x99, 0xf3, 0xce, 0x99, 0x96, 0xa1, 0x9b, 0xc6, 0x45, 0x48, 0xd9, 0x4f, 0x5f, 0xa0, - 0x4e, 0x96, 0xdb, 0xec, 0x51, 0xd0, 0x58, 0x0e, 0x91, 0x14, 0xb9, 0x20, 0xc5, 0xbf, 0x0b, 0x47, 0x3c, 0x46, 0x6a, - 0x9d, 0xa9, 0x65, 0x8c, 0xa6, 0xff, 0x16, 0xd6, 0x82, 0xa5, 0xdd, 0x7b, 0x96, 0xc1, 0x8f, 0x93, 0x01, 0xd5, 0x3a, - 0x77, 0x52, 0x26, 0x9b, 0x25, 0x8c, 0x0c, 0xed, 0x8e, 0x5a, 0xfd, 0xf4, 0x6b, 0xbd, 0x5d, 0x9a, 0xbd, 0x34, 0xcd, - 0x2f, 0xa2, 0x85, 0x81, 0x2c, 0x01, 0x17, 0x0b, 0x4a, 0x3b, 0x27, 0xd5, 0xbf, 0xf7, 0xcd, 0xf7, 0xc4, 0xf7, 0xc2, - 0x5f, 0x66, 0x3e, 0x8f, 0x7c, 0xca, 0x2b, 0x3f, 0x40, 0x9e, 0x4f, 0xee, 0xad, 0x16, 0x0c, 0x23, 0x98, 0x88, 0xac, - 0x5c, 0x81, 0x80, 0x45, 0x91, 0x3c, 0x50, 0x01, 0x89, 0x88, 0x2b, 0xdb, 0x21, 0xad, 0x66, 0xbd, 0x9b, 0x01, 0x85, - 0x01, 0xd7, 0xfe, 0x42, 0xe3, 0x9c, 0x2e, 0xf6, 0xd6, 0x51, 0x51, 0xe9, 0x58, 0x1a, 0xfd, 0x11, 0x98, 0x18, 0x51, - 0xc9, 0xe9, 0xa8, 0x38, 0xb3, 0x18, 0xed, 0x2b, 0x3a, 0x8b, 0x19, 0xc8, 0x58, 0xa9, 0x29, 0x5b, 0xf9, 0x0d, 0x30, - 0xbb, 0x3d, 0x97, 0x34, 0xf5, 0x18, 0x0e, 0xe4, 0x05, 0x44, 0x0d, 0xac, 0x68, 0x03, 0x9d, 0xda, 0x6f, 0x08, 0xcf, - 0x1b, 0x96, 0x47, 0x80, 0x20, 0x28, 0xdf, 0x41, 0xd8, 0x9f, 0xd8, 0xbe, 0x72, 0x35, 0xc3, 0x29, 0xc3, 0xf4, 0x19, - 0x87, 0x86, 0xfa, 0x14, 0xfc, 0x04, 0x6c, 0xa2, 0xab, 0x11, 0x20, 0xdf, 0x24, 0x84, 0x1e, 0x04, 0xfd, 0x2b, 0x8f, - 0x48, 0x7f, 0xdd, 0xd4, 0xea, 0x2b, 0x98, 0xe2, 0xa8, 0x4c, 0xd6, 0x6d, 0x6a, 0x5b, 0xbd, 0xb2, 0x65, 0x5c, 0xd7, - 0x80, 0x3a, 0x2d, 0x9d, 0xe3, 0x0c, 0x27, 0x0d, 0xf1, 0xbf, 0x06, 0x86, 0x3f, 0xa8, 0xdd, 0x0e, 0xa3, 0x0f, 0xfd, - 0xc6, 0x8c, 0x79, 0x87, 0x70, 0x78, 0x3c, 0x31, 0x8d, 0xdc, 0x9f, 0x0b, 0x4c, 0x87, 0x96, 0xf8, 0x23, 0x8d, 0x38, - 0xe9, 0x83, 0xd2, 0x8b, 0xd5, 0xa1, 0x32, 0xfe, 0xdb, 0xb8, 0x1f, 0xbe, 0x6d, 0xb3, 0x8a, 0xe1, 0xc9, 0x88, 0x02, - 0xb6, 0x1a, 0xb3, 0x8e, 0x4f, 0x8e, 0xd6, 0xe3, 0x98, 0xdb, 0x80, 0xa8, 0x71, 0xbd, 0xa9, 0xda, 0x2c, 0x52, 0xb1, - 0xe5, 0x96, 0x3d, 0x1f, 0xcc, 0xa8, 0x7c, 0xfc, 0xf3, 0x32, 0x15, 0x82, 0x00, 0x55, 0xe2, 0x43, 0x34, 0xd0, 0xc5, - 0x6e, 0x27, 0x68, 0xe1, 0xb7, 0x96, 0xd2, 0x4a, 0xe6, 0xc1, 0x6a, 0xee, 0x90, 0x80, 0x8e, 0xaa, 0x01, 0xc3, 0xa7, - 0x68, 0xb2, 0xab, 0xc9, 0x31, 0x42, 0x01, 0x4d, 0xce, 0x92, 0x86, 0x93, 0x61, 0xbf, 0x2d, 0x4e, 0x7f, 0x9d, 0xf3, - 0x51, 0xb3, 0x21, 0x52, 0xdf, 0x8e, 0x89, 0x98, 0x7e, 0xc7, 0x57, 0x59, 0x19, 0x1b, 0xa1, 0x78, 0x33, 0x88, 0x8d, - 0x21, 0xc9, 0x1b, 0x05, 0x25, 0x42, 0x24, 0xbb, 0x38, 0x11, 0x66, 0xf3, 0x7e, 0xa5, 0xf0, 0xf4, 0x15, 0xa1, 0xd4, - 0x1c, 0x23, 0x8d, 0x0e, 0xb6, 0x74, 0xc2, 0xda, 0xb4, 0x7d, 0x5c, 0x7d, 0x81, 0x41, 0x87, 0xcf, 0x1c, 0xf0, 0x02, - 0xe0, 0xc6, 0xb0, 0x0a, 0x60, 0xad, 0x31, 0x77, 0x0c, 0xb7, 0x65, 0x7c, 0x62, 0x2d, 0x73, 0x40, 0xff, 0x98, 0xc8, - 0x72, 0x43, 0x7b, 0x0e, 0x41, 0xc1, 0xb4, 0x1d, 0x58, 0xa2, 0xf2, 0xef, 0xb4, 0x29, 0x76, 0x55, 0x31, 0x31, 0x0f, - 0x84, 0xcb, 0x12, 0x09, 0x95, 0xaf, 0x7b, 0xd7, 0x63, 0x06, 0xf8, 0x88, 0xa8, 0x19, 0x54, 0xbc, 0xce, 0x4d, 0x7e, - 0x55, 0x3f, 0xbf, 0x04, 0xec, 0x75, 0xf6, 0xba, 0xfe, 0xf0, 0xba, 0x7a, 0xfa, 0x93, 0x52, 0x00, 0xf4, 0x5c, 0xd8, - 0x95, 0x61, 0x26, 0x0b, 0x9b, 0xc8, 0xf0, 0x73, 0xbd, 0x84, 0xf2, 0xb4, 0x99, 0x03, 0x42, 0x38, 0xc7, 0xf9, 0xe4, - 0xfa, 0x74, 0x95, 0xb9, 0x09, 0xa4, 0x08, 0xb8, 0x09, 0x20, 0xf3, 0xfe, 0x08, 0x67, 0xce, 0x07, 0x04, 0xe2, 0x5d, - 0x5c, 0x9b, 0x1c, 0x3d, 0x0e, 0x92, 0x98, 0xdd, 0x4f, 0x3d, 0x2a, 0x88, 0xcb, 0x68, 0x01, 0x0d, 0x5b, 0x53, 0x76, - 0x2d, 0x58, 0xee, 0x08, 0x1d, 0x36, 0x84, 0x99, 0x42, 0x57, 0x89, 0xfc, 0x87, 0x47, 0x4b, 0xaa, 0xe8, 0xb1, 0x3b, - 0x7a, 0xb6, 0x22, 0xca, 0x70, 0x52, 0x47, 0x42, 0x82, 0xf0, 0x85, 0xa8, 0x81, 0x7e, 0xc0, 0xc6, 0xa8, 0x52, 0xe2, - 0x12, 0xdb, 0x12, 0xe8, 0x3b, 0x09, 0xc2, 0xb2, 0x53, 0x1a, 0x86, 0xe6, 0x90, 0xc3, 0x48, 0x14, 0x41, 0x29, 0xfc, - 0x02, 0x25, 0xcf, 0x34, 0x94, 0x80, 0x32, 0x75, 0x60, 0x47, 0x0d, 0x55, 0x89, 0x09, 0x75, 0x7a, 0x7a, 0x10, 0xdd, - 0xbb, 0x0c, 0x34, 0x4d, 0x07, 0xa7, 0x1d, 0x2a, 0xc6, 0xd2, 0x98, 0xea, 0x60, 0x3b, 0x2a, 0x04, 0x47, 0x3a, 0x1e, - 0x32, 0x0a, 0x4e, 0x6e, 0xdf, 0xe1, 0xb2, 0xe1, 0xd3, 0xed, 0xa7, 0x4a, 0x8c, 0x8e, 0x9e, 0xac, 0xce, 0xa5, 0xd5, - 0xf3, 0x6c, 0xcc, 0x24, 0x48, 0x9f, 0xc0, 0xa1, 0x52, 0xf8, 0x32, 0x03, 0xd3, 0x22, 0x8f, 0xb7, 0x65, 0xb4, 0x38, - 0x85, 0x92, 0xab, 0x6e, 0x1f, 0xe9, 0x36, 0xdf, 0xce, 0xa4, 0xdb, 0x6f, 0xa7, 0xc1, 0x51, 0xd6, 0xcc, 0xfa, 0x42, - 0xf9, 0xbc, 0x52, 0xaa, 0xed, 0x5b, 0xf9, 0x49, 0xa2, 0x83, 0x63, 0x0d, 0xd5, 0x2a, 0x2c, 0xf1, 0x93, 0x81, 0xd5, - 0x6b, 0x48, 0xb5, 0x91, 0x8a, 0x61, 0x07, 0x9e, 0x8f, 0x3c, 0x9e, 0xbb, 0xae, 0x34, 0xe3, 0xca, 0x30, 0xb3, 0x49, - 0x25, 0xc6, 0xf7, 0xc3, 0x63, 0x0f, 0xed, 0x99, 0xf6, 0xf9, 0x74, 0xf8, 0x12, 0xe8, 0x74, 0x20, 0x9a, 0x80, 0x81, - 0x39, 0x84, 0x32, 0x16, 0x68, 0x6c, 0x2c, 0x66, 0x51, 0x1e, 0x95, 0x29, 0x4d, 0x95, 0xc6, 0x30, 0x86, 0xda, 0x00, - 0xae, 0x6e, 0xd7, 0x4c, 0x4a, 0x46, 0x49, 0x77, 0x29, 0x0d, 0x14, 0xd3, 0x31, 0x8c, 0x15, 0x9e, 0x29, 0x19, 0x2e, - 0x0a, 0x71, 0x1a, 0xe0, 0xcb, 0x8b, 0xff, 0xf7, 0xaf, 0xc0, 0xa8, 0xb9, 0xed, 0x91, 0xac, 0xd9, 0xec, 0x68, 0x4b, - 0x2b, 0x3c, 0x4f, 0xe7, 0xcb, 0x17, 0x29, 0xeb, 0x52, 0x2d, 0x8a, 0xd3, 0xe8, 0x28, 0x23, 0x4a, 0xfb, 0x76, 0xf7, - 0x97, 0xba, 0x33, 0x8c, 0x98, 0x2b, 0xdf, 0xf8, 0x3d, 0xe5, 0x5a, 0xf2, 0x6e, 0xb7, 0x8c, 0xac, 0x4a, 0x31, 0xe1, - 0x43, 0xe5, 0x1a, 0x5e, 0x69, 0xfd, 0x07, 0xf9, 0x4f, 0xb9, 0xaa, 0x6d, 0x7f, 0x0c, 0xeb, 0x95, 0x6c, 0x4e, 0xb4, - 0xde, 0x3c, 0xe3, 0x88, 0xb7, 0x3d, 0xc6, 0xfd, 0x25, 0x85, 0x63, 0x69, 0xfc, 0xae, 0xea, 0x64, 0x37, 0x3f, 0xb9, - 0x5c, 0x90, 0xb4, 0x98, 0x74, 0xeb, 0xad, 0xca, 0x7e, 0xe6, 0xab, 0xf7, 0xfb, 0xb3, 0x87, 0x3b, 0x26, 0x41, 0xc2, - 0x6d, 0x43, 0x3e, 0x0d, 0x22, 0xbd, 0x6d, 0x46, 0x47, 0x69, 0xf2, 0xca, 0x99, 0x4d, 0x08, 0x84, 0xe3, 0x8d, 0xe9, - 0x01, 0x26, 0x3b, 0x93, 0xd2, 0xcb, 0xfe, 0x67, 0x76, 0xe5, 0xda, 0xd4, 0xc5, 0x5d, 0xb1, 0xc5, 0x83, 0xe4, 0xd7, - 0x43, 0x7c, 0x38, 0x86, 0x37, 0x9f, 0xe3, 0x77, 0xc8, 0x3f, 0xea, 0xb8, 0x0c, 0x0c, 0x4c, 0xac, 0x1c, 0xfb, 0x4e, - 0x78, 0xd9, 0xdf, 0x12, 0x6b, 0x50, 0x56, 0x69, 0x8a, 0x21, 0x18, 0xc4, 0x79, 0x1d, 0x00, 0xc8, 0x95, 0x0d, 0x62, - 0x9b, 0x27, 0xb2, 0xe5, 0xab, 0x60, 0xf1, 0xce, 0xf1, 0xd1, 0x0b, 0x6e, 0x4a, 0x7c, 0xaa, 0xbc, 0x3d, 0x63, 0x0c, - 0x70, 0x0b, 0xca, 0xd3, 0xb1, 0x83, 0x19, 0x31, 0x47, 0x42, 0xed, 0x8a, 0x4a, 0x2c, 0x49, 0x1d, 0x2a, 0x14, 0xcd, - 0xea, 0x82, 0x91, 0x89, 0xe4, 0xb3, 0x35, 0x55, 0x82, 0x81, 0xd4, 0x41, 0x7b, 0xf6, 0x2c, 0x4a, 0x9a, 0x7d, 0x1e, - 0x9a, 0x6c, 0x92, 0x3b, 0x7e, 0x09, 0xa6, 0x3f, 0xf8, 0x59, 0x28, 0xe9, 0x73, 0x6f, 0x62, 0x21, 0x7f, 0xb7, 0x95, - 0xf5, 0x27, 0xec, 0x1d, 0xfe, 0x26, 0x21, 0x7c, 0x39, 0x85, 0xd5, 0x24, 0x61, 0x59, 0xb8, 0xf0, 0x76, 0x49, 0x80, - 0x3c, 0x65, 0x69, 0x57, 0x83, 0x03, 0x85, 0x3e, 0x14, 0x94, 0x2c, 0x96, 0xb1, 0x12, 0x33, 0xc3, 0x22, 0xa6, 0xe4, - 0x5e, 0xf4, 0x35, 0xf3, 0xbe, 0xf9, 0x3a, 0x85, 0x47, 0x06, 0x4f, 0xe5, 0xa6, 0x6d, 0x5b, 0x88, 0x0e, 0x18, 0x9a, - 0xe9, 0x4f, 0x70, 0x40, 0xbb, 0x7f, 0xdd, 0xa5, 0xa7, 0x1c, 0xf8, 0xec, 0x39, 0x0e, 0xd6, 0x56, 0x9e, 0xa5, 0x9c, - 0x35, 0x54, 0xf7, 0x39, 0x05, 0x3f, 0x17, 0xef, 0x10, 0x57, 0x26, 0xc1, 0xd3, 0x5d, 0x4c, 0x12, 0x54, 0x9f, 0x82, - 0x21, 0xe9, 0x04, 0x74, 0xb1, 0xc2, 0xea, 0x5a, 0xb3, 0xe5, 0x09, 0xba, 0x98, 0x60, 0x05, 0x63, 0x38, 0x14, 0xf4, - 0xf2, 0x30, 0xb3, 0x1e, 0x56, 0xd3, 0xd3, 0x22, 0x48, 0x22, 0x9d, 0xec, 0xf6, 0x53, 0x92, 0xbd, 0x26, 0x12, 0x40, - 0x3f, 0x37, 0x2b, 0x69, 0x03, 0xe0, 0x41, 0xad, 0x10, 0xb1, 0xef, 0x45, 0xcc, 0x49, 0x2a, 0x55, 0x73, 0x46, 0xb7, - 0x15, 0x02, 0x62, 0x5d, 0xf8, 0x5b, 0x5e, 0xdd, 0x94, 0xfa, 0x53, 0xb0, 0x80, 0xbe, 0xe1, 0x42, 0x02, 0xaf, 0x8d, - 0x8d, 0xf7, 0x8a, 0xc6, 0x1a, 0x5f, 0x02, 0x58, 0x1c, 0x0c, 0xf0, 0xa4, 0xc6, 0x32, 0x2c, 0x01, 0x69, 0x15, 0x0f, - 0x9d, 0x98, 0xb0, 0xf2, 0xb4, 0xe0, 0x98, 0xe5, 0xbb, 0x7f, 0x98, 0xdf, 0xe9, 0xb4, 0x4e, 0x20, 0x31, 0xd3, 0xa9, - 0x76, 0x4b, 0x2f, 0x1f, 0x58, 0xbf, 0xd6, 0x98, 0x25, 0xe2, 0x9e, 0xe4, 0x65, 0xb7, 0x63, 0x15, 0xda, 0x58, 0xc4, - 0x32, 0x9e, 0x29, 0x87, 0x57, 0x53, 0x6f, 0xf3, 0xf0, 0x00, 0x0e, 0xcf, 0xa7, 0x96, 0xfb, 0xeb, 0x00, 0x13, 0x87, - 0x9b, 0x52, 0x28, 0x15, 0xf1, 0x7a, 0x10, 0x20, 0x12, 0xc4, 0x44, 0xbb, 0xc8, 0x50, 0x7a, 0xca, 0x0d, 0x62, 0xb3, - 0x01, 0x25, 0x62, 0x87, 0xb6, 0x8e, 0xd2, 0x1f, 0xc2, 0x57, 0x47, 0xf9, 0x54, 0x99, 0xea, 0xa4, 0xb7, 0x30, 0xcb, - 0xe5, 0x48, 0x35, 0x34, 0x60, 0xd9, 0x71, 0xfb, 0xc9, 0x63, 0x5b, 0x61, 0x78, 0x6e, 0xab, 0xfe, 0x6e, 0x1b, 0xfe, - 0xfe, 0x02, 0x5e, 0x3c, 0xfd, 0xbe, 0xee, 0x6b, 0x6e, 0xd9, 0x90, 0x43, 0x5d, 0xda, 0x8d, 0x88, 0xb8, 0x17, 0x2f, - 0xaf, 0x52, 0x48, 0x01, 0xd2, 0xfc, 0x01, 0x3c, 0x3b, 0xbe, 0x3d, 0xd2, 0x7d, 0x2a, 0x32, 0x41, 0x24, 0xe4, 0xed, - 0x82, 0xb0, 0xe2, 0xb1, 0xa7, 0xb0, 0x69, 0x64, 0x41, 0x9f, 0x4a, 0xe8, 0x12, 0x7e, 0x8a, 0x7c, 0x79, 0x39, 0x17, - 0xfc, 0x18, 0xd2, 0x09, 0x68, 0xb0, 0x3b, 0xeb, 0x45, 0x50, 0x06, 0x39, 0xed, 0x2d, 0xa5, 0x79, 0x27, 0x97, 0x8d, - 0x02, 0xd3, 0x96, 0x85, 0xf6, 0x4b, 0xa3, 0x6e, 0xba, 0x78, 0x6a, 0xa2, 0x10, 0xf0, 0xf0, 0xb0, 0xd9, 0xed, 0xa4, - 0xa1, 0x9c, 0x55, 0x73, 0xef, 0xab, 0x55, 0xe3, 0x8a, 0xe4, 0xe3, 0x61, 0x86, 0x20, 0xa4, 0xdd, 0x8e, 0x9c, 0x1a, - 0xc3, 0x51, 0xd1, 0xbe, 0x48, 0xd6, 0x79, 0xe2, 0x70, 0xdc, 0xcb, 0x27, 0x71, 0xb2, 0x71, 0xac, 0x8b, 0x93, 0x48, - 0x05, 0xbe, 0x58, 0x7d, 0xd5, 0x10, 0x6d, 0xa6, 0xc5, 0xe9, 0x5d, 0x55, 0xa5, 0x6a, 0x0a, 0xb4, 0x93, 0x22, 0x47, - 0x76, 0x33, 0xbb, 0x2b, 0xb6, 0xa1, 0xd0, 0x0c, 0x38, 0x7f, 0xd6, 0x5e, 0xac, 0x47, 0x78, 0xa8, 0xbc, 0xf8, 0x47, - 0xd1, 0x3f, 0x56, 0x3d, 0x91, 0x65, 0x2b, 0xfc, 0xd5, 0x78, 0xbd, 0xb4, 0xf8, 0x37, 0x0f, 0xdc, 0x67, 0xd7, 0xd9, - 0x91, 0xb7, 0xde, 0x9c, 0x8f, 0x57, 0x15, 0x4f, 0x17, 0x89, 0x6f, 0x18, 0x06, 0x70, 0x39, 0xa4, 0x79, 0xb9, 0xdb, - 0x7b, 0x0c, 0x9f, 0x86, 0x80, 0x90, 0x6c, 0xe7, 0xdc, 0x3e, 0x9f, 0x3f, 0x1c, 0x69, 0x33, 0x9c, 0xc9, 0x4b, 0x21, - 0xd9, 0x57, 0x08, 0x00, 0x64, 0xd5, 0x66, 0xa4, 0x63, 0x5d, 0x4d, 0x02, 0x69, 0x32, 0x49, 0xdd, 0x6e, 0x03, 0x5c, - 0x80, 0x54, 0x94, 0x2f, 0xd7, 0x83, 0x15, 0x35, 0xf5, 0xc2, 0x14, 0x5f, 0xee, 0xe5, 0x0b, 0x34, 0xad, 0x69, 0xda, - 0xcb, 0xb9, 0x0c, 0x05, 0xd6, 0xcb, 0x0e, 0x11, 0x1e, 0x64, 0x2b, 0xc6, 0xe3, 0x71, 0xe4, 0xbb, 0xc9, 0x07, 0x94, - 0x1b, 0x2c, 0x2e, 0xf7, 0xea, 0xcb, 0xa9, 0xdd, 0x14, 0xb6, 0x42, 0x9f, 0x61, 0x15, 0x05, 0x73, 0xc0, 0x9b, 0x6b, - 0x7a, 0x3b, 0x9b, 0x0b, 0xb2, 0xd9, 0xc5, 0x67, 0x0b, 0xdb, 0x20, 0x81, 0x78, 0x1c, 0x06, 0x6b, 0x72, 0x88, 0x94, - 0x78, 0x74, 0x4a, 0x53, 0x42, 0x01, 0xc8, 0x00, 0x5e, 0x4c, 0xe2, 0x2d, 0x24, 0xfd, 0xf7, 0xe0, 0x13, 0xec, 0xad, - 0x71, 0xc5, 0xc8, 0x79, 0xfe, 0xe1, 0x74, 0xc0, 0xe9, 0xcf, 0xed, 0x9d, 0xcf, 0x3d, 0x23, 0xa0, 0x46, 0xa9, 0x0f, - 0xe5, 0xc1, 0x7f, 0xd2, 0x15, 0x9d, 0xd6, 0x62, 0xbe, 0x13, 0xb1, 0x4a, 0x85, 0x2d, 0xf7, 0x32, 0xd8, 0xdf, 0xef, - 0x87, 0xe9, 0xff, 0xab, 0x6b, 0x43, 0x55, 0x7e, 0xfe, 0xb7, 0x35, 0xfc, 0x27, 0xbd, 0x0e, 0x4b, 0xcd, 0xfd, 0x6f, - 0x0d, 0x36, 0xfd, 0xf6, 0x1a, 0xea, 0xa1, 0x6d, 0xff, 0xd6, 0x03, 0x88, 0x3a, 0x28, 0x72, 0xb3, 0x27, 0xb2, 0xd2, - 0xaa, 0x73, 0x0f, 0x06, 0xda, 0xc2, 0xff, 0x9f, 0xe5, 0x3d, 0xcb, 0x9e, 0xad, 0x30, 0xb5, 0xf0, 0xf1, 0xfd, 0x8c, - 0x49, 0x00, 0xcb, 0x49, 0x84, 0x36, 0x0e, 0x39, 0xad, 0xfc, 0xb4, 0x46, 0xae, 0x43, 0x5a, 0xb1, 0x56, 0x01, 0xfd, - 0xb2, 0xa4, 0x4f, 0x10, 0xcf, 0x3d, 0x8c, 0xbd, 0x86, 0x92, 0xe0, 0x81, 0x7a, 0xbe, 0x75, 0x94, 0x1f, 0x49, 0xd3, - 0x62, 0x57, 0x4a, 0x7e, 0xe9, 0x9f, 0x3f, 0x66, 0xd9, 0x57, 0x96, 0x1f, 0x88, 0xa1, 0x26, 0xb7, 0xff, 0xc1, 0x42, - 0xda, 0x17, 0x24, 0x31, 0x16, 0xa6, 0x6e, 0x5d, 0x38, 0x9e, 0x38, 0xbd, 0x63, 0x5b, 0xb5, 0x19, 0x84, 0x17, 0x55, - 0x2d, 0x14, 0x67, 0xd7, 0x82, 0x32, 0xa6, 0xf7, 0xe9, 0x4c, 0x13, 0x0c, 0xa8, 0xa5, 0xe4, 0x9d, 0xdf, 0xf0, 0xef, - 0x6c, 0x85, 0x79, 0x57, 0x63, 0xee, 0xde, 0xc0, 0x3e, 0x1a, 0x39, 0x8c, 0xe3, 0x3e, 0x42, 0xa1, 0x6e, 0x70, 0x83, - 0x2f, 0x35, 0x12, 0xdd, 0xb3, 0x65, 0x1a, 0x46, 0x54, 0xf6, 0xbc, 0x05, 0x47, 0xe2, 0x9c, 0x71, 0x09, 0x32, 0xf4, - 0x08, 0x0d, 0xcb, 0x69, 0x78, 0x8b, 0x29, 0x6c, 0x2f, 0xef, 0x18, 0x77, 0x96, 0xad, 0xed, 0x55, 0x9a, 0x21, 0x90, - 0xce, 0x8b, 0xe0, 0xad, 0xe2, 0x49, 0xb8, 0x31, 0x6d, 0xcf, 0xd4, 0x83, 0x5d, 0x7b, 0x49, 0x2f, 0x6a, 0xf3, 0x37, - 0xb2, 0xdb, 0x7b, 0xe9, 0x98, 0x29, 0xcd, 0xeb, 0x9a, 0x2d, 0x5e, 0xbc, 0x20, 0x13, 0x7e, 0x1c, 0x5c, 0x1b, 0xb3, - 0x6e, 0xb7, 0x12, 0x80, 0xcc, 0x89, 0xc6, 0xd5, 0x5c, 0xec, 0x7f, 0xda, 0x1f, 0xa4, 0xf5, 0x60, 0xde, 0x3d, 0xb8, - 0x92, 0x11, 0x9b, 0xbf, 0x33, 0x37, 0x92, 0x7d, 0x93, 0x49, 0x0e, 0xb5, 0xa8, 0xaa, 0xe2, 0xc1, 0xbb, 0x17, 0xc9, - 0xdd, 0xd5, 0xa5, 0x25, 0xa3, 0xde, 0x20, 0x9f, 0xef, 0xd0, 0xcd, 0x3e, 0xac, 0xdb, 0x5a, 0xe3, 0xd4, 0xe2, 0x24, - 0x36, 0x4d, 0xac, 0xc2, 0xac, 0xa6, 0x13, 0xc1, 0xf6, 0xbf, 0xd6, 0xe0, 0x9a, 0x89, 0x3a, 0x14, 0xd6, 0x56, 0x28, - 0x94, 0x82, 0x1f, 0x25, 0x20, 0x61, 0xc6, 0x98, 0x13, 0x70, 0x82, 0x64, 0x4c, 0x27, 0x53, 0xa2, 0x69, 0x28, 0x37, - 0x3f, 0x88, 0x19, 0xbe, 0xcd, 0x28, 0x46, 0x40, 0x72, 0x3f, 0x32, 0x72, 0xc3, 0xc9, 0x92, 0x50, 0x23, 0xee, 0xf6, - 0xc9, 0x2f, 0x70, 0xcc, 0x78, 0x8e, 0xa5, 0xd4, 0xf8, 0x69, 0x7d, 0x7e, 0xcc, 0x7a, 0x3f, 0x5d, 0xff, 0xb0, 0xba, - 0xe7, 0xce, 0x3f, 0x28, 0xe9, 0xd4, 0x5c, 0x43, 0x66, 0x55, 0x00, 0xc8, 0x9b, 0xf2, 0xce, 0xb8, 0x8e, 0xd3, 0x7b, - 0xab, 0x44, 0x04, 0x2e, 0x55, 0xb4, 0xaa, 0x31, 0x82, 0xf9, 0x5e, 0x88, 0x18, 0x27, 0x2b, 0x07, 0xbe, 0xf7, 0x2b, - 0x54, 0x24, 0xe7, 0xe1, 0x73, 0xf6, 0x46, 0x9a, 0x3e, 0x16, 0x4d, 0x26, 0xcf, 0x1d, 0xf1, 0x55, 0x7c, 0x7e, 0x37, - 0x4b, 0x17, 0x9b, 0x6c, 0x0e, 0x52, 0xc1, 0x92, 0x86, 0xba, 0x80, 0xda, 0xd6, 0x62, 0x28, 0xd1, 0x8e, 0xd4, 0xea, - 0x84, 0x2f, 0xa5, 0x80, 0xa5, 0x32, 0x22, 0x67, 0xa8, 0xad, 0xc1, 0xa9, 0xa3, 0x34, 0x71, 0xdd, 0xab, 0x0a, 0xbe, - 0x28, 0xf3, 0xc8, 0x9d, 0x31, 0xfc, 0xd2, 0xc7, 0xeb, 0x90, 0x8c, 0x91, 0x69, 0x36, 0x70, 0x7e, 0x9a, 0x15, 0xeb, - 0x1d, 0x7c, 0x21, 0x74, 0xea, 0xd4, 0x4c, 0xbe, 0x40, 0xdd, 0x0a, 0x4a, 0x32, 0x1c, 0x7c, 0xad, 0x8a, 0x5b, 0xb4, - 0x12, 0xf7, 0x1f, 0x90, 0xf5, 0x49, 0x2b, 0x69, 0xd1, 0x9e, 0x56, 0x56, 0x04, 0xa5, 0x65, 0x52, 0xb5, 0x29, 0x4c, - 0xbf, 0x14, 0x1d, 0xd5, 0xd3, 0xba, 0x7b, 0x3f, 0xe4, 0x76, 0xc9, 0x25, 0xdb, 0x7d, 0x8b, 0x34, 0x34, 0xba, 0xda, - 0x15, 0x80, 0xb4, 0xeb, 0x4d, 0x5f, 0x85, 0xcc, 0x53, 0xd2, 0x94, 0x92, 0x1e, 0x1c, 0xb2, 0x23, 0x34, 0xbf, 0xef, - 0xc6, 0x56, 0x1d, 0xe9, 0x4e, 0x05, 0xfb, 0xce, 0x2f, 0x73, 0xbb, 0x19, 0x9c, 0xc4, 0xe7, 0x36, 0x7e, 0xed, 0x11, - 0x40, 0xb6, 0xa8, 0x84, 0xaf, 0x4d, 0x39, 0x68, 0x97, 0x5f, 0xe2, 0x99, 0x9a, 0x1d, 0x0a, 0xef, 0xf3, 0xd6, 0x69, - 0xba, 0x75, 0x6c, 0x94, 0x4a, 0x1e, 0x7e, 0xa3, 0x42, 0xb6, 0x62, 0x77, 0x56, 0xb8, 0x00, 0x73, 0xfe, 0xaa, 0x20, - 0xea, 0x4a, 0x56, 0xdb, 0x45, 0x8d, 0xc1, 0x06, 0xda, 0x38, 0xd4, 0x2b, 0x44, 0xcc, 0x3b, 0x46, 0x39, 0x42, 0x87, - 0xa4, 0x43, 0x49, 0x27, 0xd3, 0x40, 0x4e, 0xac, 0x3a, 0x24, 0xd8, 0x9f, 0x8e, 0x94, 0x03, 0xf8, 0x9f, 0x4c, 0x91, - 0xe5, 0x9f, 0xea, 0x55, 0xce, 0xd4, 0x29, 0xfe, 0x5c, 0xb2, 0x6b, 0x76, 0x94, 0x5a, 0x4d, 0x35, 0xee, 0x17, 0x4d, - 0x01, 0xa3, 0x52, 0x5e, 0xcb, 0x8e, 0xdc, 0xcc, 0x91, 0x14, 0xff, 0x60, 0xb2, 0xf4, 0xa4, 0x7f, 0x7c, 0xc8, 0xa5, - 0xaf, 0x9c, 0x7b, 0xf5, 0xce, 0x22, 0xa7, 0x2a, 0xdd, 0xfd, 0x34, 0x77, 0x9e, 0xfe, 0xfe, 0x92, 0x9d, 0x1f, 0xfd, - 0xc5, 0x43, 0x74, 0x86, 0xbf, 0x60, 0x43, 0xec, 0xc1, 0xda, 0x65, 0xe1, 0xc9, 0xeb, 0xf3, 0x43, 0xa3, 0x4f, 0x19, - 0x58, 0xf2, 0xee, 0x82, 0x96, 0x40, 0x99, 0xd7, 0x94, 0xa5, 0x5a, 0xdf, 0x17, 0xd3, 0xa7, 0x2b, 0x76, 0xbe, 0x98, - 0x55, 0x5b, 0x6d, 0xdf, 0x97, 0xd5, 0x6d, 0x75, 0xff, 0x72, 0xf6, 0xe1, 0xaf, 0xdb, 0x7b, 0x3e, 0x31, 0x01, 0x08, - 0xec, 0xf4, 0x50, 0xf5, 0x8b, 0x9f, 0xab, 0xb2, 0x98, 0xaa, 0xba, 0x38, 0xab, 0xc6, 0xc5, 0x79, 0x35, 0x3d, 0xfc, - 0x74, 0xc4, 0x0f, 0x3c, 0x12, 0x86, 0xd5, 0x89, 0x06, 0x59, 0x5b, 0xfc, 0xd2, 0xd4, 0x32, 0xcb, 0x27, 0x8a, 0xdd, - 0x4a, 0xad, 0x3f, 0xed, 0xd2, 0xf8, 0xd3, 0x64, 0x79, 0x23, 0x05, 0xbd, 0x52, 0xd1, 0x2e, 0x27, 0xb6, 0xd3, 0x4c, - 0x2c, 0x48, 0x2c, 0x65, 0xa7, 0xbd, 0xb5, 0x0e, 0x39, 0x83, 0x41, 0x6f, 0xbf, 0xe4, 0x1a, 0xcf, 0x22, 0x8c, 0x99, - 0xbc, 0xa1, 0xb7, 0x4c, 0x05, 0x5f, 0xa1, 0x1a, 0x33, 0xeb, 0x3b, 0x51, 0x47, 0x12, 0x0b, 0x82, 0x18, 0xba, 0xd4, - 0x49, 0xed, 0xed, 0xd2, 0xd5, 0xad, 0xab, 0xbe, 0x04, 0x70, 0x2d, 0xd6, 0x94, 0x9e, 0xfa, 0xa2, 0x46, 0x31, 0x3a, - 0x2a, 0x4b, 0x66, 0xaa, 0x84, 0x8a, 0x1e, 0x62, 0x7d, 0xcb, 0xbc, 0xce, 0xca, 0x73, 0x33, 0x4c, 0xd3, 0x2d, 0xcd, - 0x00, 0x5f, 0xd1, 0x85, 0xac, 0xcc, 0x05, 0x6f, 0x29, 0x99, 0xd6, 0x23, 0xe3, 0x54, 0xd3, 0xba, 0x7a, 0x44, 0xf6, - 0xf2, 0x97, 0xb7, 0x40, 0x64, 0x1f, 0xfa, 0xa2, 0xf6, 0x59, 0x94, 0xad, 0x30, 0x89, 0x41, 0xa6, 0x21, 0xe4, 0x28, - 0x0d, 0xd1, 0x88, 0xb3, 0x78, 0xb4, 0xab, 0x20, 0xb1, 0xf1, 0x59, 0x7e, 0xcd, 0x8c, 0xbd, 0x0e, 0x20, 0x16, 0xa8, - 0xb8, 0x2c, 0xbd, 0xe0, 0xff, 0x41, 0x0d, 0xe5, 0xbe, 0xe9, 0x7f, 0xa0, 0x98, 0x14, 0xca, 0xcd, 0xd0, 0x8f, 0x4b, - 0xae, 0x60, 0x13, 0x62, 0xd0, 0x83, 0x15, 0x51, 0x9d, 0xc5, 0xbe, 0x45, 0x9d, 0x40, 0x0a, 0x38, 0x50, 0x9c, 0x41, - 0xe3, 0x44, 0x01, 0x8e, 0x06, 0xad, 0xb5, 0x48, 0x85, 0x50, 0x78, 0x3f, 0xea, 0xaa, 0x75, 0x39, 0xd2, 0xd0, 0x4d, - 0xa4, 0xdf, 0xea, 0xd7, 0x56, 0x94, 0xc1, 0x9c, 0x5f, 0xae, 0xbc, 0xf9, 0xa0, 0xe4, 0xef, 0xdb, 0x3f, 0xa9, 0x0b, - 0x54, 0xf4, 0x0e, 0x1c, 0x46, 0xb4, 0x39, 0x62, 0x6c, 0x61, 0x71, 0x18, 0x5b, 0xea, 0x09, 0xb1, 0xfe, 0x0e, 0x3d, - 0xc2, 0xd9, 0x37, 0x49, 0xad, 0x79, 0x39, 0x99, 0xe5, 0x76, 0x3b, 0xba, 0xdd, 0xf9, 0x99, 0x29, 0xfc, 0xa4, 0xe6, - 0x60, 0x51, 0xef, 0x49, 0xa4, 0x01, 0xba, 0x5e, 0x38, 0x8f, 0xc0, 0xf5, 0x28, 0x49, 0xc1, 0x64, 0x40, 0x13, 0x1a, - 0x3b, 0x62, 0x65, 0xc5, 0x59, 0x1a, 0x8d, 0xce, 0x85, 0xab, 0xa2, 0xfa, 0xfb, 0xcb, 0x62, 0x2e, 0x00, 0x8c, 0x20, - 0xf4, 0xc1, 0x1b, 0xbb, 0x9d, 0x36, 0xbd, 0xda, 0x96, 0x34, 0xc4, 0x11, 0x44, 0x65, 0x41, 0xc5, 0x2e, 0xa8, 0x3a, - 0xda, 0x2f, 0xa8, 0x1c, 0x27, 0xd5, 0x90, 0x9f, 0x7a, 0x65, 0xb9, 0x0b, 0xfe, 0xdc, 0xa3, 0x5a, 0xfd, 0xf3, 0x43, - 0xc3, 0x53, 0xfd, 0x43, 0x98, 0xf7, 0x95, 0xf2, 0x3c, 0x97, 0x7c, 0x6c, 0x12, 0xc9, 0xd5, 0x56, 0x05, 0x1f, 0x1e, - 0x4a, 0x7a, 0x2b, 0x6a, 0x16, 0x58, 0x6f, 0x0f, 0xcf, 0x6b, 0xcf, 0x61, 0xc6, 0x8e, 0xfa, 0x25, 0x51, 0x37, 0x67, - 0xff, 0x0d, 0x06, 0xf6, 0x9b, 0x56, 0x72, 0xae, 0x9b, 0xf5, 0x9e, 0x27, 0xc5, 0x7a, 0x3d, 0xbf, 0xa2, 0x81, 0x8d, - 0x7d, 0xf6, 0x99, 0x3f, 0xa0, 0x61, 0x90, 0x3d, 0x5d, 0x37, 0xe7, 0xb4, 0xce, 0xce, 0xb9, 0x72, 0xd8, 0x69, 0x33, - 0x7e, 0xd2, 0xbd, 0xe5, 0xa0, 0xda, 0x02, 0xf9, 0x9d, 0xfd, 0x84, 0x38, 0x69, 0xf9, 0xf9, 0x69, 0xb4, 0x33, 0x0b, - 0x21, 0x0f, 0xce, 0x76, 0x2b, 0x20, 0xe5, 0x65, 0x76, 0x01, 0x49, 0x73, 0xa1, 0xe7, 0x38, 0x2a, 0x45, 0x82, 0x2f, - 0x03, 0x66, 0xdd, 0x35, 0x02, 0xd3, 0xf5, 0x6e, 0x65, 0xde, 0xc5, 0xaa, 0x06, 0x9d, 0xd7, 0x36, 0x6d, 0xdf, 0x7c, - 0xa5, 0x3b, 0x9e, 0xbe, 0x28, 0x16, 0x3b, 0xac, 0xdc, 0xe5, 0x20, 0x7f, 0xaf, 0x04, 0x1e, 0x05, 0xf0, 0x5e, 0x4c, - 0xd2, 0x4f, 0xf0, 0x74, 0x27, 0x13, 0x98, 0xa8, 0x86, 0xa4, 0x6c, 0x75, 0x77, 0x23, 0x9b, 0x51, 0x35, 0xd0, 0x29, - 0x47, 0x8e, 0x78, 0xf5, 0xb3, 0xf6, 0x98, 0x07, 0x3b, 0xf7, 0xad, 0x17, 0x7e, 0x94, 0x0d, 0x15, 0x96, 0x67, 0x0c, - 0x0d, 0x38, 0x65, 0x58, 0x5c, 0xc6, 0x60, 0x40, 0x6e, 0xae, 0xe3, 0x46, 0x0a, 0xcd, 0x3f, 0x47, 0x3f, 0xa6, 0xa0, - 0x06, 0xea, 0x8d, 0xeb, 0xf1, 0xa1, 0x19, 0xec, 0x97, 0xbf, 0x01, 0x8f, 0x0f, 0x32, 0xa0, 0x9a, 0x85, 0xce, 0x68, - 0xe3, 0x69, 0x9e, 0x7f, 0xd2, 0xb7, 0xb9, 0xe4, 0xfc, 0x47, 0xff, 0x34, 0x1b, 0xa7, 0xce, 0xc9, 0x99, 0x26, 0xc1, - 0x79, 0x0a, 0x5d, 0x9d, 0xfd, 0x7f, 0x97, 0x6c, 0x64, 0x15, 0x2f, 0x9a, 0x47, 0x71, 0x75, 0x81, 0x28, 0xaa, 0xf5, - 0x91, 0x67, 0xed, 0xce, 0x5e, 0xec, 0x7b, 0x38, 0x0c, 0x7a, 0x83, 0x0f, 0x7e, 0xaa, 0xf2, 0x24, 0x66, 0xfd, 0xca, - 0x44, 0xca, 0x25, 0x7e, 0x4a, 0x5d, 0xd9, 0xd7, 0x49, 0xb3, 0x0f, 0x97, 0xa6, 0x34, 0x1c, 0xd8, 0x94, 0x62, 0x8d, - 0x0a, 0xb0, 0x5f, 0x89, 0xd2, 0xb7, 0x76, 0xce, 0xd0, 0x07, 0xff, 0xac, 0x0a, 0x2c, 0x4e, 0xeb, 0x32, 0x40, 0x52, - 0xd7, 0xe3, 0xca, 0x7e, 0x3d, 0x09, 0x88, 0x8b, 0x7c, 0x85, 0x36, 0x47, 0x8c, 0x51, 0x91, 0x0b, 0xd1, 0x41, 0xe6, - 0xaa, 0x62, 0xa2, 0xd6, 0xa7, 0x17, 0xb4, 0xfb, 0x6e, 0x22, 0x2e, 0xd4, 0xd0, 0xf9, 0x57, 0x27, 0x16, 0x94, 0x36, - 0xc7, 0xf6, 0x8e, 0xd0, 0x23, 0x97, 0xf1, 0x11, 0x41, 0x12, 0x5f, 0x4f, 0x61, 0xde, 0x7e, 0xc7, 0x8f, 0xab, 0x08, - 0x20, 0x81, 0x77, 0x8b, 0xb8, 0x19, 0x18, 0x4a, 0x12, 0xa8, 0x9a, 0x5a, 0xeb, 0x01, 0x13, 0xf3, 0x4e, 0x47, 0xe1, - 0x56, 0x54, 0x20, 0xf0, 0x10, 0x99, 0xd8, 0x83, 0x44, 0x56, 0x8f, 0xa2, 0x87, 0x3b, 0xda, 0xe9, 0x4a, 0xa6, 0x68, - 0x04, 0x25, 0xda, 0xf4, 0x90, 0xa4, 0x87, 0x2f, 0x9b, 0x89, 0xde, 0x89, 0x73, 0xd3, 0x1f, 0xf5, 0x5e, 0xcb, 0xfe, - 0x77, 0x5d, 0x47, 0xf6, 0x2e, 0x63, 0x44, 0xcc, 0xe1, 0x51, 0xb6, 0x9e, 0xac, 0x8e, 0xdb, 0x3e, 0xe4, 0xdc, 0x0b, - 0x8a, 0x01, 0x68, 0x6f, 0x0e, 0xdd, 0x77, 0xa5, 0x44, 0xad, 0xeb, 0xd6, 0x43, 0xca, 0x35, 0x12, 0xfd, 0xc5, 0xf7, - 0xe7, 0x77, 0xb5, 0xc9, 0xc9, 0x26, 0x0a, 0x15, 0x4d, 0xf2, 0x18, 0x44, 0x87, 0x97, 0xc6, 0x30, 0xea, 0xc5, 0xc5, - 0x18, 0xb1, 0xa7, 0xd3, 0x28, 0x6e, 0x61, 0x31, 0x5a, 0x65, 0x6f, 0x11, 0x62, 0x5d, 0x3a, 0x35, 0x4c, 0x51, 0xf5, - 0xdf, 0x9f, 0x46, 0xb5, 0x3b, 0x05, 0x11, 0xf8, 0x7a, 0xee, 0x58, 0xb2, 0x0b, 0xa8, 0x97, 0xf3, 0x77, 0xac, 0x68, - 0xd3, 0x69, 0x1f, 0x84, 0x71, 0x8c, 0xcc, 0x7b, 0xf9, 0xb6, 0x08, 0x31, 0x94, 0x12, 0xa4, 0xe0, 0x6b, 0xc7, 0x30, - 0x08, 0x0e, 0xf3, 0xf2, 0x31, 0xb4, 0xff, 0x10, 0xee, 0xc8, 0x8c, 0x31, 0x99, 0xe2, 0xde, 0x00, 0xeb, 0x0d, 0x77, - 0xd8, 0x47, 0x47, 0xbd, 0xd2, 0xe4, 0x4e, 0x12, 0x7b, 0x9a, 0x49, 0x8e, 0xde, 0xed, 0xd2, 0x28, 0x53, 0x3a, 0x7c, - 0x33, 0x89, 0xf8, 0x56, 0x9c, 0x10, 0xa9, 0xba, 0xac, 0xad, 0xae, 0xfd, 0xbe, 0x74, 0x1c, 0xdd, 0xb3, 0x6b, 0xbd, - 0x8f, 0x62, 0x6c, 0xd5, 0x9b, 0x9a, 0x6d, 0xea, 0xa7, 0xa1, 0x40, 0x8e, 0x0e, 0x77, 0xba, 0x95, 0x4c, 0xc7, 0xea, - 0xf2, 0x17, 0x6d, 0x5b, 0xe4, 0x0b, 0x03, 0x98, 0x9e, 0xba, 0xb7, 0x59, 0xed, 0x27, 0x44, 0x89, 0xf4, 0x81, 0x98, - 0x25, 0x3e, 0x4a, 0x01, 0xe3, 0x2b, 0xa7, 0x89, 0x6c, 0xf0, 0xb3, 0xfc, 0x5c, 0xc4, 0xed, 0xae, 0xf1, 0x9c, 0x4f, - 0x00, 0xbd, 0x1f, 0x8f, 0xb3, 0x33, 0x68, 0xe7, 0xdb, 0x74, 0xa6, 0x53, 0x79, 0x31, 0xfd, 0xb3, 0xff, 0xcf, 0xf4, - 0x40, 0xfd, 0x01, 0x24, 0x1a, 0xff, 0xf7, 0x22, 0x93, 0xd7, 0x6a, 0x24, 0x26, 0x07, 0x31, 0xea, 0x1e, 0x14, 0x8b, - 0x68, 0x08, 0xe0, 0x2b, 0x2f, 0x88, 0x1b, 0x1c, 0x1e, 0x15, 0x3e, 0x4d, 0xef, 0x0e, 0xe4, 0x70, 0xa7, 0xe3, 0x49, - 0x5b, 0xdc, 0x57, 0xc9, 0xcd, 0x8c, 0xfd, 0x3e, 0x83, 0x68, 0x18, 0x14, 0x7d, 0x81, 0x41, 0x29, 0xe4, 0xe7, 0x4b, - 0xf1, 0xa5, 0x99, 0xab, 0x2b, 0xa3, 0xa4, 0xb5, 0x82, 0xf5, 0x2a, 0xa4, 0x06, 0x12, 0xef, 0xa5, 0xf0, 0x19, 0xf4, - 0x14, 0x8a, 0xfd, 0xfe, 0xd4, 0x29, 0x27, 0x68, 0x2f, 0xab, 0xd2, 0xa4, 0x57, 0x92, 0xdb, 0x7b, 0x67, 0x1d, 0xfd, - 0x04, 0x28, 0xc7, 0x0f, 0xa2, 0xc5, 0xd7, 0x0e, 0x8b, 0x72, 0xbb, 0x54, 0x75, 0x1c, 0x43, 0xf0, 0xfc, 0xc9, 0xb3, - 0xb0, 0x5d, 0x91, 0x9e, 0xfe, 0x6d, 0xb1, 0xe9, 0xbb, 0x73, 0xab, 0xe1, 0xff, 0xe4, 0xb3, 0x3f, 0xf0, 0x36, 0x3d, - 0xeb, 0xcf, 0xd8, 0x48, 0xe5, 0x5d, 0xc2, 0xe5, 0x36, 0xb1, 0xf9, 0x02, 0x86, 0xe1, 0x71, 0x7b, 0x9e, 0x08, 0x89, - 0xfd, 0xa6, 0x30, 0xb3, 0xc7, 0xb1, 0x68, 0x25, 0xc2, 0xdf, 0xee, 0x46, 0xde, 0xf9, 0x4f, 0x87, 0x25, 0x08, 0xc3, - 0xb9, 0x71, 0xa6, 0xdf, 0x33, 0xda, 0x7f, 0x9a, 0xa7, 0x4f, 0x7f, 0x77, 0xc9, 0xe9, 0x8f, 0xfe, 0x69, 0xf6, 0xbd, - 0x7d, 0x55, 0xa2, 0x77, 0xc0, 0x66, 0xdf, 0x44, 0x8c, 0x9a, 0xbc, 0x9e, 0x53, 0x0e, 0x7a, 0x44, 0x57, 0x33, 0xe1, - 0xe5, 0x09, 0x5c, 0xa0, 0x61, 0x54, 0xe7, 0x3d, 0xcf, 0xc1, 0x0b, 0x65, 0xbb, 0xa3, 0x58, 0x92, 0x68, 0xb3, 0x90, - 0x3b, 0xf4, 0x53, 0x83, 0x28, 0xc1, 0xac, 0xfb, 0x49, 0xb2, 0x47, 0x6d, 0x35, 0x4c, 0xac, 0x52, 0x5d, 0x7c, 0xe7, - 0x5a, 0x26, 0x29, 0xe5, 0x55, 0xbc, 0x53, 0x89, 0xbc, 0xf9, 0x21, 0xcc, 0x98, 0x0d, 0x46, 0x2f, 0x84, 0xb0, 0xdf, - 0x29, 0x02, 0x23, 0x47, 0x15, 0x2c, 0x24, 0x7e, 0xbb, 0x03, 0x24, 0xde, 0xbe, 0x0b, 0xd2, 0x57, 0x12, 0x20, 0x5f, - 0xcb, 0x96, 0x53, 0x9b, 0x9d, 0x1b, 0xe1, 0xb0, 0x47, 0xe9, 0x1b, 0xef, 0x91, 0x6f, 0x64, 0xd2, 0x56, 0xa9, 0x1f, - 0x03, 0xcc, 0xce, 0xd6, 0x61, 0x64, 0xc4, 0x0e, 0xe4, 0x10, 0x53, 0xb1, 0x03, 0x04, 0xb3, 0x0e, 0xfd, 0x1c, 0xf8, - 0x63, 0xd7, 0x0d, 0x40, 0x34, 0x6b, 0x2e, 0x7d, 0x92, 0xb1, 0x9d, 0x1c, 0x8e, 0x4d, 0x04, 0xe3, 0x7d, 0xa9, 0xfb, - 0xac, 0x79, 0x8a, 0x94, 0x6a, 0x89, 0x14, 0x34, 0x20, 0xbd, 0x8a, 0x3b, 0xf7, 0x6c, 0x0e, 0x46, 0x9c, 0xec, 0xef, - 0x4a, 0xa9, 0x3e, 0xdc, 0xb8, 0xcb, 0xa1, 0x71, 0x5e, 0x1e, 0xb0, 0x8b, 0xcd, 0xa0, 0x04, 0xda, 0xe9, 0x34, 0x4f, - 0xd6, 0x1a, 0xcc, 0xb9, 0x26, 0x25, 0x29, 0x0b, 0x9f, 0x90, 0x19, 0xb9, 0xf9, 0xbe, 0xbc, 0xbe, 0xe5, 0xc3, 0x68, - 0x4e, 0x29, 0xd8, 0x2b, 0x7d, 0xd3, 0xa7, 0xfb, 0xba, 0xfc, 0xdc, 0x05, 0xdd, 0xda, 0x41, 0x2b, 0x17, 0x0f, 0xfb, - 0x93, 0x47, 0x02, 0xc8, 0x04, 0xf1, 0xc3, 0x0d, 0xcb, 0xee, 0xbe, 0x4f, 0x60, 0xf6, 0x8d, 0x5f, 0xec, 0xa7, 0x0c, - 0x83, 0x6f, 0xec, 0x66, 0x95, 0x60, 0x39, 0xfc, 0x3f, 0xf7, 0xcf, 0xb6, 0x5e, 0xec, 0x26, 0x87, 0xab, 0xfd, 0xba, - 0x7d, 0x06, 0x18, 0x7b, 0xbf, 0x5c, 0x27, 0x54, 0xc2, 0x48, 0x6d, 0xd1, 0xe4, 0xab, 0xc2, 0x99, 0x3d, 0x9c, 0x4c, - 0xd9, 0x4e, 0xa1, 0x16, 0x69, 0x1c, 0xd7, 0x39, 0x47, 0x5a, 0xa0, 0x8d, 0x65, 0xb1, 0x68, 0x14, 0x09, 0x9d, 0x60, - 0x8b, 0x8d, 0x1c, 0xf7, 0xc3, 0xfa, 0x6c, 0x98, 0xf1, 0x96, 0x28, 0xb4, 0xe0, 0x6c, 0xc4, 0x44, 0x90, 0x51, 0x35, - 0x06, 0xa1, 0x1d, 0x72, 0xb0, 0x00, 0xd5, 0xd0, 0x29, 0x82, 0xe7, 0xc6, 0x9f, 0x16, 0x3f, 0x2e, 0x0c, 0x5e, 0x42, - 0x32, 0x0c, 0x12, 0x40, 0x8a, 0xc9, 0x4a, 0xba, 0x71, 0x6f, 0xb7, 0x70, 0xbc, 0x2f, 0x98, 0x6a, 0xec, 0xa7, 0xdd, - 0xa3, 0x9b, 0x0e, 0xd4, 0x8b, 0x8f, 0x06, 0x86, 0xed, 0x8e, 0x21, 0xf3, 0xca, 0x88, 0xce, 0x44, 0xcf, 0xfb, 0x38, - 0xe9, 0xb1, 0x55, 0x98, 0x23, 0xcc, 0x08, 0xbe, 0x31, 0x99, 0x8d, 0x3c, 0xc2, 0xdd, 0x6e, 0x3f, 0x9a, 0xe3, 0xd8, - 0x1a, 0x7b, 0x85, 0x50, 0xa8, 0x78, 0xcb, 0x74, 0x37, 0xa1, 0x59, 0x87, 0xcd, 0x3d, 0xd4, 0xd9, 0x55, 0x06, 0xfa, - 0x2c, 0xab, 0x04, 0x27, 0xf2, 0xf6, 0xdb, 0xe8, 0x42, 0x03, 0x27, 0x68, 0x6b, 0xa3, 0x87, 0x7f, 0x88, 0xd0, 0xb7, - 0xa0, 0x4e, 0x38, 0x29, 0xdf, 0x19, 0x8f, 0x89, 0x41, 0xd4, 0x38, 0x4e, 0x95, 0x59, 0x4e, 0x4f, 0x76, 0x23, 0x57, - 0x4a, 0xae, 0xb0, 0x9c, 0x59, 0x5a, 0x36, 0x4b, 0x05, 0x78, 0xff, 0x51, 0x17, 0xc7, 0x84, 0x94, 0xab, 0x46, 0x6d, - 0xea, 0x81, 0x86, 0x4f, 0xa3, 0x95, 0x54, 0x56, 0x36, 0xf1, 0x87, 0x1e, 0xee, 0xf4, 0x07, 0xd1, 0xdd, 0x8a, 0x6a, - 0x93, 0xdb, 0xd0, 0x78, 0x42, 0x8f, 0x29, 0xec, 0x83, 0x45, 0xa0, 0xce, 0xa3, 0xf0, 0xf0, 0xf8, 0x3b, 0x26, 0x6f, - 0x24, 0xd1, 0xad, 0xc0, 0xcd, 0xe2, 0x07, 0x2e, 0x58, 0x24, 0x39, 0x5a, 0xc5, 0xd2, 0xbb, 0xd3, 0xb2, 0x35, 0xa9, - 0xfc, 0x84, 0xb6, 0xaf, 0xaf, 0xe5, 0x55, 0x0b, 0xac, 0xc4, 0xec, 0x55, 0x23, 0xf9, 0x45, 0x29, 0x0e, 0xec, 0x80, - 0x69, 0x91, 0x6b, 0x34, 0xcc, 0xd4, 0xb2, 0x79, 0x30, 0xee, 0xe9, 0x36, 0x1c, 0x4a, 0x67, 0x77, 0x7f, 0xa1, 0x09, - 0x0e, 0xa1, 0x29, 0xa9, 0x09, 0x93, 0x7c, 0x3c, 0xb5, 0x71, 0x62, 0x15, 0xb5, 0x60, 0xb2, 0xe5, 0xb8, 0xe5, 0xb5, - 0x3a, 0xa6, 0xea, 0xa5, 0xf7, 0x31, 0x90, 0x24, 0xd3, 0x38, 0xa1, 0x72, 0x70, 0x43, 0xbc, 0x42, 0xc1, 0x69, 0x7b, - 0x1a, 0x27, 0x76, 0x28, 0x6f, 0xff, 0x2a, 0xde, 0x56, 0x68, 0xfe, 0x15, 0x4e, 0xde, 0xcb, 0xf5, 0xbb, 0x6e, 0xb8, - 0x99, 0xd8, 0x0d, 0xbb, 0xfd, 0xab, 0x69, 0xab, 0x54, 0xec, 0xe9, 0xa4, 0xe7, 0x23, 0x1f, 0x00, 0xf8, 0xf3, 0xca, - 0x04, 0xf9, 0x64, 0x98, 0x11, 0xb5, 0x09, 0xc2, 0x4c, 0x65, 0xc4, 0xf8, 0xa6, 0x2a, 0x37, 0xb5, 0x68, 0x45, 0x62, - 0x49, 0x69, 0x1a, 0x67, 0xe7, 0x8e, 0x34, 0x3b, 0xee, 0x8e, 0xd8, 0x6d, 0x89, 0xb9, 0x7e, 0x9a, 0xf4, 0x34, 0x58, - 0x85, 0x22, 0x54, 0x9e, 0x50, 0xae, 0x29, 0x47, 0x7b, 0xd0, 0x8d, 0xba, 0x86, 0x0c, 0x86, 0x54, 0xa1, 0x8c, 0x5e, - 0xec, 0x3c, 0x22, 0x70, 0x54, 0xa1, 0x87, 0x0c, 0xa4, 0xa8, 0x88, 0x66, 0x33, 0x7e, 0x7c, 0xfe, 0x95, 0xa2, 0x2d, - 0xea, 0x06, 0xe1, 0x10, 0x80, 0xac, 0x77, 0x87, 0x43, 0x08, 0x5c, 0xff, 0x0e, 0xcb, 0xd6, 0xa8, 0x51, 0x46, 0x06, - 0x36, 0x64, 0x3d, 0x45, 0xfa, 0x8f, 0x51, 0x5d, 0x91, 0x49, 0xdd, 0xac, 0x50, 0x46, 0x90, 0x41, 0xcc, 0x3b, 0x4a, - 0x9b, 0x6f, 0x86, 0xd1, 0x91, 0x35, 0x8a, 0x30, 0x15, 0xbb, 0x41, 0xe1, 0xaa, 0x3f, 0x48, 0x91, 0x5d, 0x88, 0x38, - 0x05, 0x78, 0x77, 0x6a, 0x48, 0xd4, 0xac, 0xa9, 0x68, 0xf8, 0x18, 0x7a, 0xee, 0xcc, 0xbb, 0x0d, 0x07, 0x12, 0xc2, - 0x22, 0x35, 0xd8, 0x81, 0x68, 0x0b, 0x32, 0x16, 0xe1, 0x8d, 0x48, 0x34, 0xd4, 0x7b, 0x02, 0xf0, 0x6e, 0xdd, 0xa7, - 0xbc, 0x03, 0x80, 0x3e, 0x59, 0x39, 0x91, 0xee, 0x8f, 0x07, 0x72, 0x88, 0xb9, 0xd9, 0x91, 0xba, 0x43, 0x5c, 0x8a, - 0xf3, 0x89, 0x62, 0xbd, 0x20, 0x07, 0x91, 0xa0, 0x15, 0xaf, 0xc9, 0x45, 0x99, 0xb4, 0xf3, 0xae, 0x33, 0xd7, 0xb9, - 0x26, 0x9e, 0xe4, 0xa8, 0x33, 0x51, 0x4c, 0xee, 0x99, 0x7c, 0xad, 0xdb, 0xb0, 0xda, 0x41, 0x9f, 0x10, 0xe3, 0xc9, - 0x58, 0xa6, 0x1e, 0xd9, 0xd9, 0x78, 0x36, 0xe2, 0x50, 0x01, 0x2d, 0x1d, 0xdc, 0x72, 0xd9, 0xac, 0xf9, 0x19, 0x77, - 0xfc, 0xb0, 0x09, 0x1f, 0xad, 0xe2, 0xda, 0xf4, 0xe9, 0x65, 0x90, 0x06, 0xf3, 0xa1, 0xa4, 0xe0, 0x4a, 0xaa, 0xb1, - 0xef, 0x4d, 0x25, 0xb5, 0x7f, 0xb7, 0x99, 0x9a, 0xb5, 0x58, 0xf1, 0x64, 0x5c, 0x04, 0x91, 0xf9, 0xfa, 0xdd, 0xd4, - 0x8c, 0xa3, 0xdd, 0xb4, 0x20, 0x42, 0x5f, 0xe5, 0x62, 0x64, 0x39, 0xfd, 0xa6, 0x89, 0x37, 0x37, 0x84, 0x3e, 0x62, - 0xfa, 0xb3, 0x8d, 0x39, 0x3e, 0x3b, 0xbc, 0x50, 0x43, 0x0f, 0xda, 0x20, 0x22, 0x35, 0x4e, 0x77, 0xb0, 0x48, 0x64, - 0x4b, 0x78, 0x45, 0xd1, 0x8a, 0xb9, 0xfa, 0xe1, 0x90, 0xb1, 0x44, 0x26, 0x88, 0x34, 0xfa, 0xf1, 0xc3, 0x2e, 0x1d, - 0xb6, 0x1e, 0x86, 0xb1, 0x02, 0x5c, 0xe6, 0x25, 0x25, 0x6f, 0xac, 0xe0, 0xb7, 0x9f, 0x03, 0xd3, 0xbc, 0xdf, 0xde, - 0x35, 0xbd, 0x11, 0x2f, 0xd5, 0x8d, 0xd3, 0x3b, 0x14, 0x4a, 0x42, 0x94, 0xd3, 0xc6, 0xc5, 0xc5, 0x9c, 0x3d, 0x0d, - 0x2c, 0xf2, 0x72, 0xc5, 0xd2, 0x2e, 0x7e, 0x0d, 0xa2, 0x61, 0xc5, 0x3b, 0x08, 0xe9, 0x22, 0xbb, 0xce, 0xf0, 0x00, - 0x8d, 0xea, 0xe1, 0x1e, 0x6d, 0xd1, 0x05, 0x04, 0x99, 0x63, 0xf4, 0x68, 0xa0, 0x04, 0x14, 0x7c, 0xc5, 0x09, 0x74, - 0x95, 0xd6, 0xcc, 0xb3, 0x35, 0x32, 0x63, 0x02, 0x84, 0xd3, 0xfa, 0x93, 0x08, 0x2e, 0x21, 0x73, 0xb8, 0x54, 0xd8, - 0x82, 0x8c, 0x5a, 0x29, 0x4e, 0x46, 0x01, 0x4d, 0x9f, 0x88, 0xe3, 0x17, 0xbd, 0x4b, 0x01, 0x38, 0x7a, 0x2c, 0xac, - 0x24, 0xf0, 0x99, 0xc6, 0x15, 0xb3, 0xcb, 0xa0, 0x39, 0xd0, 0xb8, 0xf6, 0xb5, 0xd5, 0x18, 0x8b, 0x8d, 0xd7, 0xdf, - 0x43, 0x84, 0x0d, 0xf6, 0x94, 0x42, 0xac, 0x48, 0x74, 0x80, 0xac, 0x5c, 0x43, 0x27, 0xef, 0xd9, 0xd3, 0xb1, 0xb5, - 0x5c, 0x41, 0x17, 0x3a, 0x92, 0x70, 0xad, 0xc1, 0x66, 0xff, 0x11, 0xe0, 0x4c, 0x43, 0x5a, 0xcf, 0x0c, 0x2b, 0x72, - 0x99, 0x82, 0x1a, 0xf1, 0xaf, 0x53, 0x07, 0x8b, 0x7a, 0x48, 0x17, 0x71, 0x2a, 0xea, 0x99, 0x56, 0x16, 0xe8, 0x84, - 0x3a, 0x52, 0x43, 0x6c, 0x00, 0x05, 0x6f, 0x94, 0x9e, 0x70, 0xfa, 0xdd, 0xa5, 0xe7, 0xa8, 0x2c, 0xb8, 0x0e, 0xcd, - 0xe2, 0x0f, 0x51, 0x6d, 0x3c, 0xfd, 0xf8, 0x60, 0x06, 0x0f, 0xe2, 0xed, 0x59, 0xc0, 0x87, 0x89, 0xb7, 0x63, 0xe7, - 0x79, 0x67, 0x37, 0x01, 0xc1, 0xac, 0x34, 0x11, 0x92, 0x11, 0xe6, 0xce, 0xbd, 0xc3, 0xd6, 0xf8, 0x2b, 0x76, 0x7f, - 0x29, 0x14, 0x06, 0xdb, 0x91, 0x08, 0xf3, 0xb1, 0x18, 0x45, 0xa8, 0xed, 0xe5, 0xd7, 0x2c, 0x19, 0xc9, 0xef, 0xce, - 0x9b, 0x8b, 0xb8, 0x1d, 0xd8, 0xaa, 0x54, 0xa9, 0x1f, 0x10, 0x55, 0xed, 0xf7, 0xb2, 0x61, 0x9b, 0x85, 0x8f, 0x17, - 0x3d, 0x3b, 0xf1, 0xc1, 0x72, 0x3d, 0xc7, 0x92, 0xdf, 0x3f, 0x43, 0x40, 0xcd, 0x66, 0xfb, 0xd5, 0xe2, 0xa0, 0xcf, - 0xb5, 0xf5, 0x1b, 0xb5, 0x81, 0x7e, 0x42, 0x58, 0xe0, 0xfb, 0x79, 0x8d, 0x5c, 0x3c, 0xca, 0xe6, 0xfa, 0x81, 0xdf, - 0x78, 0xb5, 0xc0, 0x3e, 0xbb, 0x33, 0x37, 0x9c, 0x1b, 0xc2, 0xd0, 0xf6, 0x44, 0xe3, 0xfe, 0x89, 0x49, 0x08, 0xaf, - 0xb3, 0x8a, 0x29, 0x9d, 0xc8, 0xac, 0xf2, 0x4f, 0xfa, 0x9d, 0xbb, 0x9b, 0xf9, 0x08, 0x25, 0xda, 0xdf, 0x80, 0xf3, - 0x72, 0xd5, 0x7e, 0x4d, 0xf2, 0x8c, 0x96, 0x1e, 0xb0, 0xa9, 0xa5, 0x9f, 0xeb, 0x95, 0xea, 0x40, 0xe9, 0xbe, 0x03, - 0x09, 0x30, 0x50, 0x87, 0x19, 0xbf, 0x8f, 0xcd, 0x10, 0x6e, 0x4a, 0x30, 0x06, 0x9e, 0xe9, 0x3f, 0x7c, 0x81, 0x83, - 0xb3, 0x92, 0x81, 0x39, 0xa2, 0xe6, 0x15, 0x41, 0xc0, 0xe7, 0x12, 0x54, 0xc8, 0x6e, 0x05, 0xf2, 0xf3, 0xbc, 0x72, - 0xe4, 0x06, 0x90, 0x5b, 0x21, 0xa8, 0xb8, 0x27, 0xcf, 0x5c, 0x1a, 0xd0, 0x03, 0x50, 0xfe, 0xe1, 0x9c, 0x93, 0x84, - 0xfe, 0x26, 0xa0, 0xa8, 0xd1, 0x49, 0x7f, 0xfe, 0xb5, 0x66, 0x64, 0xf2, 0xe7, 0xb1, 0x5f, 0x79, 0xbc, 0xec, 0xe6, - 0x2d, 0xc8, 0x48, 0x1b, 0xdf, 0x86, 0x19, 0x99, 0x81, 0x8e, 0x55, 0x50, 0x5b, 0xf8, 0x42, 0xaa, 0x55, 0x40, 0xae, - 0x2e, 0x42, 0x8b, 0x14, 0xb7, 0x90, 0xd3, 0x9f, 0xb6, 0xb3, 0x90, 0x7f, 0x9a, 0x01, 0x8e, 0x59, 0xf9, 0xcf, 0xc6, - 0x15, 0x45, 0xf6, 0x10, 0x18, 0xcd, 0x8f, 0x2e, 0x15, 0xd4, 0xb4, 0x72, 0x12, 0x7f, 0x02, 0x72, 0x09, 0x12, 0x30, - 0x3e, 0xbf, 0x51, 0x7b, 0xff, 0x9d, 0xce, 0x52, 0x8b, 0xaa, 0x63, 0xa4, 0x9f, 0xfc, 0x1a, 0xf2, 0x1f, 0xe0, 0x47, - 0x1f, 0x91, 0xd2, 0xd9, 0x3c, 0x5b, 0xfd, 0x89, 0xab, 0xd8, 0x65, 0x41, 0x75, 0x02, 0x2a, 0x48, 0x58, 0x05, 0xb5, - 0x06, 0x23, 0xfb, 0x1f, 0x16, 0xae, 0x46, 0x4c, 0xf3, 0xa7, 0x5b, 0xb4, 0x1a, 0xba, 0x57, 0xa0, 0xea, 0x70, 0x03, - 0x44, 0x0e, 0xdd, 0xa3, 0xea, 0x62, 0xc7, 0x99, 0xfe, 0x5b, 0x09, 0xd8, 0x38, 0x73, 0x82, 0xd3, 0xfd, 0x87, 0x97, - 0x2f, 0xd6, 0xf6, 0xa4, 0x5f, 0x32, 0xc3, 0xf8, 0x92, 0xba, 0x78, 0x70, 0x5f, 0xd3, 0xe2, 0x5b, 0xc2, 0xe4, 0xd3, - 0xfc, 0xf3, 0x49, 0xff, 0xea, 0x4b, 0xfe, 0xfc, 0xe8, 0x17, 0xbe, 0x95, 0xaf, 0x79, 0xf6, 0x4d, 0x5a, 0xa3, 0x1d, - 0xf6, 0x7a, 0x88, 0xbb, 0x37, 0xfd, 0xa1, 0x0e, 0xf9, 0x5a, 0xc5, 0xf8, 0xaf, 0x9e, 0xe9, 0xd3, 0x1f, 0x1e, 0x1f, - 0xdc, 0xa4, 0x77, 0x09, 0x39, 0xcd, 0x94, 0x57, 0xe7, 0xd6, 0xbe, 0xc1, 0x12, 0xb6, 0xf5, 0x26, 0xc1, 0xde, 0xa0, - 0x20, 0xd2, 0x48, 0xbb, 0x13, 0x21, 0x02, 0x95, 0x41, 0xae, 0x60, 0xc8, 0xcd, 0x71, 0xd4, 0xf0, 0x3f, 0x71, 0xc0, - 0x28, 0x97, 0x11, 0x55, 0xa5, 0x8a, 0xd3, 0xd1, 0xc1, 0x4c, 0xc0, 0x29, 0x44, 0x18, 0x21, 0xf9, 0x5e, 0xcd, 0x62, - 0x81, 0xce, 0x24, 0x0d, 0x3e, 0x7e, 0x27, 0x1d, 0x4b, 0x56, 0x5c, 0x5b, 0xe6, 0xeb, 0xfd, 0x27, 0xd9, 0x58, 0xf9, - 0x28, 0x90, 0x59, 0x79, 0x87, 0x02, 0xd5, 0x21, 0x05, 0x93, 0x8b, 0xd4, 0xf9, 0x88, 0x99, 0xf3, 0x91, 0x4a, 0x2f, - 0xd8, 0xaf, 0xe6, 0x06, 0xda, 0x8d, 0x3d, 0x1c, 0xec, 0x5b, 0x65, 0x6c, 0xc2, 0x90, 0xe4, 0x26, 0xbf, 0x46, 0x06, - 0xe5, 0xe4, 0xa6, 0x0d, 0x5b, 0xe0, 0x9b, 0x5f, 0x9f, 0xa1, 0x49, 0x0a, 0x9d, 0x8d, 0x7c, 0xcf, 0xc8, 0x83, 0xeb, - 0xfb, 0xb3, 0xd7, 0xfe, 0xd1, 0x94, 0x45, 0x13, 0xd6, 0x6e, 0xa9, 0x7d, 0x42, 0x28, 0x05, 0x2a, 0x08, 0x10, 0xa6, - 0xc2, 0x1a, 0x58, 0xd6, 0x21, 0x35, 0x87, 0x9a, 0xae, 0x3f, 0x67, 0x90, 0x23, 0xb5, 0xc3, 0xc4, 0xbe, 0x0d, 0x03, - 0x5f, 0x2b, 0xa5, 0xb7, 0x37, 0x50, 0xa5, 0x16, 0xf6, 0x59, 0x64, 0xa8, 0x33, 0x39, 0x57, 0x1c, 0x81, 0xd7, 0x2d, - 0x35, 0x33, 0x51, 0xe8, 0x2c, 0x1b, 0x69, 0x7e, 0x4a, 0x78, 0x45, 0x7f, 0x55, 0x04, 0x4c, 0x74, 0xd0, 0x99, 0xdc, - 0x9a, 0x8a, 0x02, 0x93, 0x90, 0xaa, 0xba, 0x62, 0xeb, 0x78, 0x0a, 0x84, 0x9f, 0xa7, 0x88, 0xed, 0x1a, 0x9f, 0x87, - 0xa2, 0x3c, 0xc9, 0xfb, 0x34, 0x77, 0x7d, 0xe8, 0x9c, 0x6b, 0x03, 0x91, 0x6c, 0x46, 0x74, 0xe1, 0x87, 0xd7, 0x54, - 0xa7, 0xc5, 0x6d, 0x4b, 0xf7, 0x69, 0x5e, 0x7c, 0xd2, 0xac, 0x4b, 0x2e, 0x7e, 0xf4, 0x97, 0x6c, 0x9b, 0x65, 0x08, - 0x45, 0x2a, 0x53, 0xf0, 0x6a, 0x9f, 0x2f, 0x8a, 0xc9, 0xf6, 0x7b, 0x58, 0xf2, 0xc4, 0x97, 0x41, 0x83, 0x89, 0x7e, - 0x71, 0xe7, 0x11, 0x1c, 0xaf, 0xba, 0xc8, 0x6a, 0x0e, 0x9c, 0xeb, 0x7a, 0x36, 0xe6, 0xb2, 0x35, 0x2e, 0x34, 0x42, - 0xa2, 0xae, 0x1a, 0x79, 0xd9, 0xbb, 0x80, 0x0c, 0x23, 0x29, 0x7b, 0x20, 0xc0, 0x9c, 0x5f, 0x5b, 0x46, 0xc3, 0xb3, - 0x90, 0x7c, 0xdd, 0x74, 0xba, 0xa0, 0x21, 0x54, 0x40, 0x83, 0x9f, 0xbf, 0x97, 0xd0, 0x9e, 0x0a, 0x7b, 0x7d, 0xfa, - 0x0b, 0xcf, 0x4c, 0x5a, 0x51, 0xc6, 0x33, 0x7d, 0x16, 0x4b, 0x9a, 0x27, 0x9d, 0xb1, 0x25, 0xcf, 0xfb, 0x58, 0xbe, - 0x4f, 0xe5, 0x58, 0xee, 0xee, 0x69, 0xba, 0xe4, 0x24, 0x35, 0xc7, 0x4a, 0x67, 0x42, 0x6d, 0x7c, 0x99, 0x4f, 0x22, - 0x12, 0x37, 0x78, 0x8a, 0x81, 0x58, 0xcf, 0x7d, 0x3a, 0x18, 0x4e, 0x15, 0xcd, 0xb7, 0xa7, 0xbb, 0x55, 0xe9, 0x9b, - 0x4d, 0xb5, 0x08, 0x71, 0x79, 0xc8, 0x62, 0xe2, 0xc3, 0x40, 0xd9, 0xd9, 0xa6, 0x8d, 0x9b, 0x04, 0x0f, 0xa4, 0xce, - 0xe5, 0xf4, 0x60, 0xb8, 0x88, 0xbd, 0xce, 0x3c, 0xa4, 0x57, 0x5c, 0xdc, 0x05, 0xe2, 0xbc, 0x42, 0x38, 0xa8, 0x57, - 0x8c, 0x6b, 0xf9, 0xa6, 0xd9, 0xbf, 0x9c, 0x4a, 0xe2, 0x92, 0x87, 0x6b, 0xd0, 0x4a, 0x35, 0x6b, 0x9d, 0x62, 0xab, - 0xa3, 0xf5, 0xf0, 0xdf, 0x37, 0x88, 0xac, 0xd8, 0x7c, 0xe1, 0x5b, 0xf9, 0xca, 0x76, 0x41, 0xc8, 0xec, 0x2f, 0xc7, - 0x17, 0x68, 0x3f, 0xcb, 0xd6, 0xda, 0x8b, 0xd3, 0xee, 0x74, 0xe3, 0x2e, 0xaf, 0x0f, 0xdb, 0x60, 0x7c, 0x85, 0x0e, - 0xdb, 0x05, 0x99, 0x7e, 0x62, 0xbd, 0xbe, 0xa7, 0x12, 0xfe, 0xe1, 0xfa, 0x87, 0xdf, 0xf4, 0xb9, 0x3f, 0xe6, 0x2a, - 0xe2, 0x00, 0x99, 0x97, 0xd4, 0x86, 0x71, 0xcd, 0x62, 0x2f, 0xe8, 0x56, 0x42, 0x7d, 0x6e, 0x9f, 0x01, 0x07, 0x37, - 0x37, 0xbd, 0xa7, 0x56, 0x03, 0x80, 0x45, 0x1c, 0x5d, 0xc3, 0x8e, 0x27, 0xe0, 0x13, 0x4a, 0x05, 0x61, 0x8f, 0x63, - 0x54, 0x29, 0x5d, 0xaa, 0x47, 0x1d, 0x3f, 0x0f, 0xa3, 0x3a, 0x10, 0x20, 0xe0, 0xf1, 0x98, 0xc7, 0x82, 0x44, 0x0d, - 0xea, 0x3c, 0x9a, 0xf2, 0x0a, 0x3e, 0x44, 0x02, 0xf6, 0x5d, 0xaf, 0xef, 0xc6, 0x37, 0xc3, 0x2b, 0x02, 0x5b, 0xf8, - 0x25, 0x8d, 0x6c, 0x23, 0x34, 0x8a, 0x47, 0xb9, 0x75, 0x4d, 0xf4, 0x45, 0x6d, 0xc7, 0xcc, 0x0b, 0x41, 0x56, 0x4f, - 0x78, 0x06, 0x0b, 0xe5, 0x82, 0xe0, 0x0b, 0xab, 0x80, 0xfb, 0x73, 0xa2, 0x1f, 0x83, 0x94, 0x1e, 0x8a, 0xe8, 0x88, - 0xd6, 0x91, 0xa9, 0xc1, 0x71, 0x8f, 0x65, 0x89, 0xe1, 0x3c, 0x42, 0xb0, 0xdb, 0x96, 0x35, 0x22, 0xab, 0xd5, 0x08, - 0x7e, 0xf3, 0x52, 0xd1, 0x3a, 0xa4, 0x24, 0x85, 0x0a, 0xd6, 0xd4, 0xf4, 0x5a, 0x10, 0xa9, 0x45, 0xe7, 0x7f, 0x02, - 0xc4, 0x69, 0x4f, 0x34, 0xad, 0xf6, 0x9c, 0x5a, 0x54, 0x1c, 0xda, 0x46, 0xc2, 0xdc, 0xa5, 0xc0, 0x95, 0x38, 0x70, - 0x00, 0xb1, 0xf4, 0xae, 0x48, 0xe4, 0x3d, 0xb4, 0x3f, 0xb8, 0x42, 0x9a, 0x4e, 0x8d, 0x77, 0x72, 0xca, 0x0d, 0x52, - 0x75, 0x61, 0xe4, 0x34, 0x12, 0x93, 0x2a, 0x27, 0x8c, 0x50, 0xc5, 0xed, 0x5a, 0x2d, 0xe1, 0xd4, 0x1b, 0xb7, 0x03, - 0x4f, 0x01, 0xef, 0x92, 0x21, 0x6c, 0xaf, 0x35, 0xe2, 0xcc, 0x18, 0xba, 0x7c, 0xf3, 0x9f, 0xba, 0x9d, 0x53, 0xfb, - 0x65, 0x70, 0x45, 0x87, 0x81, 0xaf, 0xc6, 0xab, 0x30, 0x79, 0x4a, 0x61, 0x5a, 0xfd, 0xa5, 0xeb, 0x33, 0x18, 0xf2, - 0x27, 0xf9, 0x4c, 0x43, 0x22, 0x48, 0xf1, 0x36, 0x7c, 0x78, 0x3f, 0xda, 0x06, 0xe4, 0x21, 0x70, 0x98, 0x8f, 0xc1, - 0xef, 0x44, 0xf6, 0x41, 0x6b, 0x44, 0x77, 0x8a, 0xb0, 0x20, 0x35, 0x77, 0xf8, 0xe8, 0x90, 0x6f, 0x1e, 0xea, 0x91, - 0x5c, 0x5e, 0x83, 0x00, 0x0a, 0x56, 0xd3, 0xc2, 0x9e, 0x3e, 0xb7, 0x79, 0xc6, 0x7b, 0xd0, 0x44, 0x47, 0xe1, 0x10, - 0x13, 0x3c, 0xe7, 0x0c, 0xed, 0x68, 0x27, 0x87, 0xe1, 0x31, 0xf4, 0x4a, 0x61, 0xee, 0x3f, 0x23, 0x72, 0xc3, 0xf9, - 0xb9, 0x9e, 0x31, 0x8d, 0x72, 0x9e, 0xb2, 0xaf, 0x57, 0x8d, 0x1e, 0xff, 0xb1, 0x03, 0x70, 0xff, 0xf4, 0xd7, 0x84, - 0xe4, 0x4f, 0x75, 0x0a, 0xdf, 0x57, 0x96, 0x84, 0xb7, 0x02, 0xff, 0x06, 0xaf, 0x59, 0x62, 0x70, 0x98, 0x82, 0x42, - 0xf9, 0x6b, 0x0b, 0x42, 0x6e, 0x73, 0x72, 0x6d, 0x0e, 0x97, 0xcf, 0x99, 0xe4, 0x0b, 0xb6, 0x09, 0xb5, 0x3e, 0x2b, - 0x70, 0xf0, 0xa6, 0xc9, 0x72, 0x3a, 0x8e, 0x9c, 0xf9, 0xad, 0xd8, 0x5c, 0x37, 0x26, 0x79, 0x14, 0x29, 0xfa, 0xcd, - 0xf4, 0x46, 0xde, 0x78, 0xb3, 0x10, 0x6d, 0x87, 0x5e, 0x9a, 0xd6, 0x8f, 0x2f, 0x08, 0x3f, 0x0d, 0xcb, 0x89, 0xd9, - 0x1f, 0x7c, 0x2f, 0xb0, 0xba, 0xc4, 0xc5, 0x80, 0x0c, 0xc3, 0xee, 0x58, 0xb0, 0x0e, 0x57, 0xd7, 0x68, 0xca, 0xb8, - 0x1c, 0xa4, 0x8a, 0x96, 0xee, 0x08, 0xa1, 0x9b, 0xb8, 0x28, 0xed, 0x4c, 0xd9, 0x7b, 0xf9, 0x3b, 0xb4, 0xfa, 0xb5, - 0x2a, 0xde, 0x5d, 0x12, 0x3e, 0xf8, 0xee, 0x5d, 0xd0, 0xdf, 0x74, 0xc8, 0xc6, 0xba, 0x5f, 0x3e, 0xbe, 0x54, 0x4d, - 0x16, 0x46, 0x83, 0x99, 0x4f, 0x79, 0x73, 0x76, 0x57, 0x65, 0x94, 0xd4, 0x35, 0x14, 0x46, 0x62, 0x8f, 0x1c, 0xe7, - 0xbd, 0x33, 0x59, 0xd7, 0xbb, 0x8e, 0x55, 0xe9, 0xf2, 0xb3, 0x04, 0x8b, 0xd6, 0x72, 0xef, 0xfe, 0x2c, 0xd5, 0xa7, - 0x50, 0x03, 0x69, 0xb3, 0x81, 0x0e, 0xdd, 0x46, 0x9b, 0x68, 0x9c, 0x49, 0xa0, 0xb4, 0x87, 0x2b, 0x2f, 0x6a, 0xfa, - 0x2c, 0x26, 0xd0, 0xba, 0x9d, 0x2d, 0x74, 0xb6, 0x0b, 0x4a, 0x83, 0xdb, 0x3f, 0xee, 0x76, 0xe9, 0xcc, 0xe0, 0xe3, - 0xfd, 0x83, 0x0c, 0xcb, 0xff, 0x1b, 0x55, 0xec, 0x9e, 0x1c, 0x80, 0x86, 0x35, 0x6f, 0x9b, 0x44, 0x44, 0x48, 0x58, - 0xdc, 0x7c, 0x72, 0xec, 0xfb, 0xc6, 0x97, 0xe8, 0xb9, 0xa1, 0x27, 0xe3, 0xc4, 0xf5, 0x52, 0x9d, 0xb2, 0x1e, 0x89, - 0x01, 0x7f, 0xd2, 0x39, 0x90, 0x68, 0x6b, 0x9a, 0xdd, 0x0e, 0xca, 0x81, 0xdd, 0x9b, 0x03, 0xeb, 0x8f, 0xf9, 0x06, - 0x23, 0x07, 0x2b, 0x9b, 0x3f, 0xb5, 0xb9, 0xed, 0xb4, 0x0e, 0x9f, 0x4d, 0xc6, 0xd2, 0xe3, 0xe1, 0x2b, 0xab, 0x23, - 0xb4, 0x35, 0x92, 0x15, 0x83, 0x6a, 0x6f, 0xf7, 0x63, 0x0f, 0x22, 0x7e, 0xa6, 0xee, 0xde, 0x45, 0xdd, 0xa1, 0xa5, - 0x67, 0xf6, 0xf6, 0xe0, 0xb1, 0x7f, 0x60, 0x8d, 0x43, 0xdd, 0xcb, 0x05, 0x08, 0x4b, 0xdc, 0x51, 0xd6, 0x56, 0x71, - 0x71, 0xfb, 0xe7, 0xd7, 0x0f, 0x9a, 0x83, 0x40, 0xe5, 0x70, 0x30, 0xd1, 0x8b, 0x11, 0xeb, 0xc8, 0xb1, 0x63, 0x18, - 0x23, 0x76, 0x73, 0x80, 0x94, 0x11, 0x23, 0xcd, 0x29, 0xdf, 0x07, 0x63, 0x5c, 0xf4, 0x46, 0xed, 0xc2, 0x86, 0x79, - 0x80, 0x15, 0xee, 0xa4, 0xaa, 0xc3, 0xc2, 0xc4, 0xfc, 0xba, 0xb5, 0x49, 0x72, 0xde, 0x91, 0xf5, 0xa9, 0xd9, 0xbb, - 0x12, 0x84, 0x3e, 0x1f, 0xfe, 0x8d, 0x8a, 0x78, 0xae, 0xb3, 0x87, 0x60, 0x02, 0x7e, 0xac, 0x3a, 0xec, 0x6f, 0xc1, - 0xa7, 0x0d, 0x27, 0xea, 0xe8, 0x93, 0xd1, 0x59, 0xe1, 0x80, 0x5d, 0x6b, 0xfa, 0x50, 0xc6, 0x43, 0x8f, 0x59, 0x18, - 0x2b, 0xd3, 0x5b, 0x15, 0x94, 0x0d, 0x9b, 0xa9, 0x2e, 0xa9, 0x06, 0xaa, 0x4c, 0x26, 0x99, 0x4c, 0xd9, 0x42, 0xce, - 0x00, 0xb6, 0xf7, 0x41, 0x72, 0x85, 0x88, 0x7a, 0x5f, 0x5a, 0x8f, 0xcc, 0x22, 0xae, 0x91, 0x23, 0xda, 0x63, 0x50, - 0x8b, 0x88, 0x77, 0x6a, 0x75, 0x94, 0xe4, 0xa3, 0x2f, 0x1f, 0x82, 0xd0, 0xb5, 0xa4, 0x3f, 0x9b, 0xa1, 0x84, 0x65, - 0x46, 0x2e, 0xdb, 0x4f, 0xdc, 0xbd, 0x3f, 0x8d, 0x7f, 0x9a, 0x08, 0x6d, 0x97, 0x67, 0xeb, 0xc1, 0xc8, 0xb5, 0x34, - 0x95, 0xd7, 0xb8, 0xa5, 0xc6, 0xb8, 0xe0, 0xa7, 0x38, 0xd2, 0xe6, 0x6b, 0xcd, 0xd3, 0x43, 0xdd, 0x7a, 0x1e, 0xb5, - 0x0f, 0xb2, 0xb6, 0x0e, 0xec, 0xc5, 0x42, 0x7b, 0x0a, 0x7b, 0xe7, 0xf8, 0xd0, 0xfd, 0xc5, 0xad, 0xcb, 0x4d, 0x95, - 0x8f, 0xce, 0x5c, 0x48, 0x64, 0x8e, 0x8a, 0xb7, 0x38, 0xc8, 0x07, 0xa0, 0x22, 0x92, 0xe1, 0xbd, 0x5b, 0x1e, 0x36, - 0xcf, 0xba, 0x47, 0x3d, 0xf6, 0xa0, 0x8c, 0x84, 0x8f, 0x77, 0x08, 0x89, 0x52, 0x21, 0xf6, 0xfc, 0x67, 0x92, 0x72, - 0x16, 0x0d, 0x95, 0xb7, 0x65, 0xe5, 0xf4, 0xf5, 0x3c, 0x92, 0x6a, 0x19, 0x0f, 0x78, 0x4f, 0x6e, 0xb6, 0x96, 0x13, - 0xc5, 0xad, 0xbe, 0xda, 0x5c, 0x82, 0xa0, 0x6c, 0xf4, 0x86, 0xdb, 0xb7, 0x11, 0x3b, 0x4e, 0xa0, 0x6d, 0xdb, 0x9f, - 0x5c, 0x2c, 0x45, 0xa9, 0x70, 0xc2, 0x58, 0x37, 0x39, 0x8a, 0xe6, 0x10, 0x86, 0x37, 0x6b, 0xab, 0x09, 0x1f, 0x70, - 0xc3, 0x31, 0x6f, 0x6f, 0x29, 0x87, 0x55, 0x2d, 0x9c, 0xa3, 0x48, 0xc6, 0xc4, 0xde, 0x2e, 0xa3, 0xdb, 0x5b, 0x85, - 0xfe, 0x13, 0xb2, 0xeb, 0xac, 0x56, 0xde, 0x04, 0x5f, 0x29, 0x88, 0x6c, 0xee, 0xc7, 0x67, 0xc6, 0x01, 0xd2, 0x0d, - 0xf0, 0xd7, 0x0a, 0x92, 0x55, 0x9e, 0xa8, 0xbc, 0x0a, 0x4c, 0xd3, 0x90, 0x82, 0xe1, 0x53, 0x7a, 0x0f, 0x96, 0xbc, - 0xe6, 0xcb, 0x66, 0xd7, 0x37, 0x17, 0x3f, 0xac, 0xf5, 0x10, 0x2f, 0x3b, 0xbd, 0xb5, 0x2a, 0x9c, 0xe0, 0x31, 0x49, - 0xfc, 0xba, 0xf4, 0xb3, 0xfd, 0x60, 0xe3, 0x96, 0x42, 0xed, 0x07, 0x9c, 0xd9, 0xba, 0xe7, 0x30, 0xb3, 0x49, 0x9f, - 0x01, 0x12, 0x16, 0x68, 0xdd, 0xc7, 0x22, 0x53, 0x60, 0xab, 0x01, 0x6e, 0x00, 0x23, 0xb6, 0x7d, 0xc8, 0x1e, 0xbd, - 0x29, 0x92, 0x2d, 0xe4, 0x7b, 0x3a, 0x72, 0xfb, 0x53, 0x4c, 0xef, 0x17, 0x75, 0x20, 0x9a, 0xaf, 0x03, 0x6e, 0xeb, - 0x81, 0x77, 0x1c, 0xa4, 0x48, 0x5c, 0x21, 0xa6, 0x49, 0xf7, 0x15, 0x5a, 0xb5, 0xba, 0x9b, 0x5c, 0xf6, 0xe7, 0x8e, - 0x93, 0xb5, 0xde, 0x86, 0xbb, 0xd8, 0xcf, 0xaa, 0x1d, 0xd2, 0x51, 0x03, 0xf8, 0xd2, 0xaf, 0x0c, 0x74, 0x7a, 0x9a, - 0xc2, 0x77, 0x25, 0x96, 0x4d, 0x08, 0x98, 0x3b, 0x28, 0xec, 0x2c, 0x90, 0x04, 0x2b, 0x9c, 0x38, 0x96, 0x77, 0x58, - 0x93, 0x17, 0xfa, 0x7a, 0x1c, 0x19, 0x18, 0x98, 0xb2, 0x27, 0x11, 0x61, 0xef, 0x2c, 0x52, 0x34, 0x6b, 0x19, 0xde, - 0x32, 0xd1, 0x93, 0x0f}; + 0x5b, 0x7b, 0x7b, 0x53, 0xc1, 0x6e, 0x19, 0x03, 0xf5, 0x04, 0xe0, 0xf8, 0xbb, 0x25, 0x3d, 0x34, 0x51, 0x94, 0xb1, + 0xe6, 0x22, 0x2f, 0x61, 0xbb, 0xc2, 0x70, 0x9e, 0x80, 0x65, 0x6b, 0x78, 0xad, 0x47, 0x01, 0x40, 0x55, 0x13, 0x0e, + 0xd8, 0x18, 0x92, 0x41, 0xc7, 0x81, 0x6a, 0xd1, 0xee, 0xdb, 0xf0, 0xa6, 0x22, 0xc8, 0x31, 0x97, 0xd9, 0x3c, 0xcc, + 0xe5, 0xa4, 0xd5, 0x13, 0x8c, 0x3a, 0x44, 0xf9, 0x1a, 0x8c, 0x4b, 0x4c, 0x51, 0x7e, 0x10, 0x4b, 0xea, 0xba, 0xe6, + 0x24, 0x45, 0x6e, 0xf3, 0x72, 0x99, 0x36, 0xac, 0xdb, 0x79, 0x37, 0x0a, 0x4f, 0x92, 0xc8, 0x21, 0xb3, 0xaa, 0x10, + 0x8e, 0x77, 0x8e, 0xb5, 0x29, 0xe5, 0xbf, 0x10, 0x83, 0x6d, 0xc3, 0xba, 0x2d, 0x5d, 0x36, 0x49, 0x12, 0x9a, 0x5b, + 0x46, 0xa5, 0x0a, 0x42, 0x62, 0xf0, 0x33, 0xd7, 0x94, 0x37, 0x3f, 0x57, 0x6b, 0x9c, 0x30, 0x61, 0xbd, 0xf1, 0x5b, + 0x28, 0xf2, 0xda, 0x5a, 0xe3, 0x0b, 0x44, 0xe0, 0x2e, 0xa4, 0xbe, 0x68, 0xed, 0xa7, 0x5b, 0x5f, 0x57, 0x34, 0xde, + 0x43, 0xba, 0xff, 0x8d, 0xbf, 0x8b, 0x43, 0x72, 0x99, 0xdb, 0x59, 0x36, 0xac, 0xdb, 0xf2, 0xa6, 0xee, 0x77, 0xca, + 0x1c, 0x5e, 0x9c, 0x1b, 0x62, 0x68, 0x1d, 0x10, 0x8f, 0xcd, 0xa1, 0x4a, 0x44, 0x8b, 0x11, 0x2b, 0xf6, 0xda, 0x13, + 0xf3, 0x5f, 0x65, 0xcd, 0x7a, 0x7d, 0x47, 0xb5, 0x8c, 0x9c, 0x1d, 0x4d, 0x37, 0x55, 0x02, 0x7c, 0x97, 0xc6, 0xd7, + 0x09, 0x1e, 0xf0, 0xb1, 0x63, 0x19, 0x43, 0x51, 0x9d, 0xdd, 0x4a, 0x10, 0x59, 0x4d, 0x65, 0x8a, 0x4b, 0xf2, 0xff, + 0xa6, 0xaa, 0xe7, 0xf8, 0x72, 0xa2, 0xbf, 0xfa, 0x1c, 0xb2, 0x16, 0x10, 0x7c, 0x80, 0xe0, 0x90, 0xca, 0x4c, 0xc9, + 0x59, 0xb2, 0xbb, 0x24, 0x27, 0x5b, 0x06, 0x49, 0x70, 0xa4, 0x1c, 0x29, 0x01, 0x6a, 0x44, 0xce, 0xfd, 0x92, 0x7d, + 0x55, 0xed, 0xa7, 0x3d, 0x4f, 0xd7, 0xa6, 0xdf, 0xb6, 0xdf, 0xdd, 0x7b, 0xe2, 0x85, 0x47, 0x51, 0x90, 0x08, 0x99, + 0x06, 0x14, 0x02, 0x6a, 0xf6, 0xb5, 0xb7, 0xb4, 0xff, 0xaf, 0xdf, 0x91, 0x10, 0xe0, 0x4a, 0x70, 0x16, 0x75, 0xe6, + 0x2d, 0x5b, 0xcf, 0x85, 0xb3, 0x2c, 0x5b, 0x5f, 0xc3, 0x61, 0x58, 0x47, 0x5d, 0x35, 0xa1, 0xc9, 0x7e, 0x24, 0x15, + 0xbb, 0x23, 0xd8, 0xcf, 0x7d, 0x96, 0x7d, 0x7d, 0x2f, 0x5a, 0x3f, 0xd7, 0x0a, 0xcf, 0x78, 0x8a, 0x0b, 0xb1, 0xfe, + 0x2e, 0xa4, 0x84, 0xe9, 0x5a, 0x35, 0xa7, 0x16, 0xc8, 0xc0, 0x38, 0x3e, 0xfb, 0x6c, 0x5f, 0x55, 0xa7, 0x6b, 0x47, + 0x9a, 0x25, 0x26, 0x47, 0xae, 0x17, 0x3d, 0x73, 0x14, 0x38, 0x9a, 0xfd, 0x5e, 0x2e, 0x1a, 0x1d, 0xe8, 0x0c, 0xe5, + 0x04, 0xb6, 0x31, 0x50, 0x0b, 0x44, 0x55, 0xb7, 0x19, 0xb2, 0xea, 0x7d, 0xf5, 0xfd, 0xaf, 0x5f, 0x72, 0xa3, 0x40, + 0x3d, 0xc6, 0x2c, 0x69, 0x29, 0xef, 0x81, 0x47, 0x88, 0x2c, 0x47, 0xc7, 0x8a, 0x1f, 0xf2, 0x95, 0x74, 0x1e, 0x2e, + 0x86, 0x45, 0x43, 0xc4, 0x92, 0x42, 0x0e, 0xb4, 0xe0, 0xc5, 0x2e, 0x2d, 0xd0, 0xc4, 0x06, 0xfe, 0xbf, 0xaa, 0x59, + 0xfd, 0xbe, 0x37, 0x2b, 0x09, 0x4b, 0x21, 0x2e, 0x71, 0x82, 0x40, 0xdd, 0xf3, 0x7b, 0x38, 0xde, 0xf3, 0x9f, 0x87, + 0xec, 0x30, 0x05, 0xad, 0xa2, 0x32, 0xc9, 0x41, 0xa4, 0x01, 0x35, 0x7a, 0x5c, 0x4b, 0xd3, 0xca, 0xd7, 0x57, 0x10, + 0xb0, 0x03, 0x97, 0xa6, 0xd8, 0xd3, 0x0c, 0x4d, 0x51, 0x24, 0x6c, 0x3a, 0xa5, 0xb7, 0xb3, 0x2e, 0x6b, 0x2f, 0x27, + 0xf3, 0xd0, 0xa9, 0x4d, 0xbb, 0x87, 0x49, 0x14, 0xd1, 0x36, 0x97, 0xb4, 0x86, 0x49, 0xc1, 0xdb, 0x49, 0x77, 0xc2, + 0x8a, 0x51, 0x79, 0x24, 0x4c, 0xf8, 0x87, 0x87, 0xe4, 0x03, 0x6a, 0xf5, 0x0d, 0xff, 0x69, 0x6a, 0xf6, 0xfa, 0xc6, + 0x0b, 0x3d, 0xe4, 0x54, 0x2e, 0xf2, 0x76, 0x80, 0xac, 0xee, 0xba, 0xb5, 0x69, 0x4e, 0x72, 0x9d, 0x98, 0x61, 0x93, + 0x02, 0xb6, 0x1b, 0x0e, 0x25, 0xd2, 0x46, 0x2c, 0x2d, 0xd5, 0xd7, 0x3b, 0x79, 0x1d, 0x25, 0x4a, 0x86, 0xf2, 0x0a, + 0x16, 0xd9, 0xb4, 0x5f, 0x29, 0x6d, 0xe0, 0xdb, 0xf8, 0xc6, 0x85, 0x03, 0x50, 0x4b, 0xf7, 0x84, 0x48, 0xea, 0xa0, + 0x10, 0x15, 0x28, 0x6c, 0xb0, 0xfc, 0xff, 0xbd, 0x95, 0x96, 0xdb, 0x1f, 0x91, 0xae, 0x08, 0x91, 0x3d, 0x00, 0x39, + 0xce, 0x70, 0x64, 0xfd, 0xbe, 0x33, 0xb3, 0x0a, 0x9c, 0x06, 0x48, 0xf6, 0x38, 0xb3, 0x92, 0xd6, 0x5a, 0x6c, 0x2a, + 0xee, 0xbd, 0xef, 0x5d, 0xe6, 0x77, 0xd1, 0x19, 0x3f, 0x0c, 0x2b, 0x4c, 0x66, 0x23, 0xed, 0xb0, 0x6c, 0x57, 0x96, + 0xd3, 0x00, 0x20, 0x78, 0xdf, 0x7b, 0x3f, 0x0a, 0xff, 0xff, 0xc8, 0xe2, 0xfc, 0x88, 0x2c, 0x50, 0x91, 0x59, 0xc5, + 0x39, 0x99, 0x05, 0xcc, 0xa8, 0x0a, 0xe0, 0x8c, 0x0a, 0x60, 0x1f, 0x1d, 0x90, 0x63, 0x41, 0xd0, 0x68, 0x9a, 0x6c, + 0xf6, 0xd1, 0x90, 0x2d, 0xef, 0x57, 0x5a, 0xac, 0x20, 0xca, 0xf5, 0x8c, 0x6c, 0x4b, 0x7e, 0xb7, 0x03, 0x65, 0xcd, + 0x52, 0x5a, 0xe9, 0xe8, 0xff, 0xe6, 0xd4, 0x66, 0x40, 0x8e, 0x40, 0x75, 0x53, 0x57, 0x21, 0xdc, 0xf2, 0xff, 0x5d, + 0xf2, 0x7a, 0x97, 0x52, 0xd2, 0xd1, 0xa5, 0x78, 0x19, 0x26, 0x1d, 0x95, 0x60, 0xe0, 0x2a, 0x27, 0xa7, 0xe7, 0x66, + 0x2c, 0xb2, 0x71, 0x53, 0x7f, 0x0c, 0xbf, 0x87, 0xd2, 0xc2, 0x60, 0xea, 0x4c, 0xd3, 0xf4, 0xdf, 0x6d, 0xa2, 0xab, + 0xae, 0x2c, 0x2c, 0xbf, 0xed, 0x66, 0x12, 0x57, 0x59, 0x96, 0x25, 0xb9, 0x24, 0x31, 0x01, 0xfe, 0x21, 0xba, 0xef, + 0x6f, 0xa8, 0xcf, 0x1b, 0x4b, 0xdb, 0x0c, 0x42, 0x26, 0x04, 0x48, 0xdb, 0xff, 0x37, 0x99, 0xeb, 0x9c, 0xb8, 0x78, + 0x7c, 0x49, 0xd3, 0x34, 0xb3, 0x6c, 0x7f, 0xae, 0xa3, 0xb4, 0x94, 0x6e, 0x9b, 0xed, 0x36, 0x7d, 0xa4, 0x0e, 0xdf, + 0xc0, 0x6f, 0x0c, 0x18, 0xc3, 0xe4, 0x6e, 0x15, 0x53, 0x43, 0x76, 0x69, 0xb6, 0xa5, 0xcd, 0x02, 0xd4, 0xb2, 0xfe, + 0x87, 0xa4, 0xdc, 0x5b, 0x42, 0x27, 0xf6, 0x34, 0xac, 0x62, 0x92, 0x7c, 0x83, 0x6f, 0x4c, 0xdf, 0x5a, 0x48, 0x2c, + 0x43, 0xd3, 0xda, 0xfe, 0x65, 0xb6, 0xf9, 0x75, 0x18, 0xb3, 0xcc, 0x14, 0x5b, 0x92, 0x63, 0x5b, 0x09, 0x76, 0xef, + 0x8a, 0x4c, 0xac, 0x3d, 0x0e, 0x00, 0xa7, 0xe5, 0x88, 0xb6, 0xe0, 0x33, 0x10, 0x8e, 0x73, 0x17, 0xbf, 0xfc, 0x55, + 0x09, 0xa6, 0xa3, 0x3d, 0x14, 0x7c, 0x75, 0x8c, 0x42, 0x2c, 0x41, 0x14, 0x79, 0xee, 0xe2, 0xde, 0x07, 0x26, 0xdf, + 0x0e, 0xaa, 0xe8, 0x1f, 0xba, 0xa6, 0x27, 0x9c, 0x21, 0x50, 0x8f, 0x5e, 0xf2, 0x0b, 0x07, 0xde, 0xfd, 0x7b, 0x00, + 0xa2, 0x12, 0x51, 0xea, 0xdb, 0x6f, 0xb8, 0x4e, 0x30, 0x7d, 0xdf, 0x4d, 0xdb, 0x03, 0xee, 0x0e, 0x1e, 0x12, 0x78, + 0x52, 0x0a, 0xcb, 0xfd, 0x97, 0xaa, 0x2b, 0x6e, 0x96, 0xa1, 0xd7, 0x31, 0x9d, 0xef, 0x26, 0xd8, 0x14, 0x2d, 0x6b, + 0x29, 0x18, 0x7a, 0xe6, 0xf1, 0xd6, 0x58, 0xfd, 0x0c, 0x56, 0xc9, 0xc0, 0x2d, 0x2d, 0xcc, 0xe4, 0xd4, 0xcf, 0xd7, + 0x54, 0xf5, 0xc1, 0x48, 0x12, 0x01, 0x90, 0xbc, 0xf9, 0x10, 0x27, 0x44, 0xe2, 0xfa, 0x7a, 0x3e, 0x5f, 0x95, 0x97, + 0xd9, 0x7e, 0x98, 0x60, 0x20, 0xd9, 0x20, 0x03, 0x98, 0xed, 0x3d, 0x5c, 0x7d, 0xb8, 0x57, 0xf3, 0x32, 0x6a, 0xfa, + 0xd7, 0x79, 0xb4, 0xa1, 0x33, 0x6d, 0x40, 0x1e, 0xb7, 0x69, 0x59, 0x9a, 0x92, 0x82, 0xc4, 0x86, 0x43, 0x06, 0x77, + 0x83, 0x39, 0xad, 0xc7, 0xa4, 0xe6, 0x9c, 0xac, 0xc9, 0x15, 0x97, 0x06, 0x37, 0xeb, 0xa3, 0x0f, 0xf7, 0xbe, 0xa4, + 0xc3, 0x2d, 0x3e, 0x6c, 0xfa, 0x24, 0x93, 0x7b, 0xde, 0x84, 0xcf, 0x4d, 0xb9, 0xbe, 0x1c, 0x02, 0x7b, 0xf3, 0x13, + 0x76, 0x85, 0xa0, 0x59, 0xdf, 0xea, 0xc8, 0x37, 0xde, 0xb5, 0xeb, 0xa1, 0x44, 0x32, 0x1a, 0x7d, 0xef, 0x41, 0xf3, + 0xa2, 0xdc, 0x88, 0x47, 0xd8, 0x2b, 0xd4, 0xb7, 0x3f, 0xb1, 0xc2, 0xb2, 0xbb, 0x99, 0x3f, 0x6c, 0x74, 0x7b, 0xf6, + 0xdd, 0xcb, 0xc1, 0xa3, 0x2f, 0xc2, 0x5c, 0x7d, 0xb8, 0xbf, 0xdd, 0x3a, 0xc1, 0x63, 0x42, 0x29, 0x76, 0x43, 0xc2, + 0xa1, 0xe6, 0xf7, 0x6e, 0xf6, 0x6e, 0xf2, 0x73, 0x59, 0x8b, 0x59, 0x4d, 0xfe, 0x93, 0xdf, 0xfe, 0xea, 0x77, 0x6a, + 0x9b, 0x8f, 0xf0, 0xed, 0x09, 0xc2, 0xd3, 0xbb, 0xa3, 0x8c, 0xb0, 0xe6, 0x30, 0xfe, 0xac, 0xa7, 0xca, 0x3f, 0xdb, + 0x2c, 0x6c, 0x73, 0x98, 0xaf, 0x4b, 0xda, 0x9e, 0xc3, 0xa4, 0x75, 0x57, 0xa2, 0xde, 0x4d, 0x94, 0xf2, 0xa0, 0x09, + 0xf2, 0xf2, 0xb9, 0x03, 0x7d, 0xe3, 0x7c, 0xcd, 0xa0, 0xc8, 0x6e, 0xa9, 0xe5, 0xd2, 0x9a, 0xc7, 0x9b, 0xf9, 0x60, + 0x59, 0xa2, 0x40, 0xbf, 0x4a, 0xbd, 0x77, 0xad, 0xbb, 0x7e, 0x21, 0xaa, 0x1f, 0x6d, 0xe6, 0x6a, 0x04, 0x42, 0xa4, + 0x5c, 0x37, 0x01, 0x22, 0x4b, 0xa4, 0xc8, 0x33, 0xf1, 0x5c, 0xa7, 0x4d, 0x86, 0x1e, 0xb9, 0xbf, 0xf2, 0x6b, 0xa4, + 0xe1, 0xf9, 0x84, 0x76, 0xf8, 0xd1, 0x66, 0x25, 0xd4, 0x2b, 0x54, 0xc8, 0x1b, 0x67, 0xc5, 0x7f, 0xee, 0x43, 0xa9, + 0xd6, 0x44, 0x0c, 0xcf, 0xcd, 0x64, 0x90, 0xf7, 0x2c, 0xbb, 0x92, 0xea, 0x58, 0x5b, 0x5b, 0x55, 0xd7, 0xb7, 0x50, + 0xde, 0xcc, 0x50, 0xee, 0x45, 0x95, 0x22, 0xf9, 0x60, 0x18, 0xd2, 0x73, 0xfc, 0xdb, 0xd6, 0x37, 0x3f, 0x15, 0x88, + 0x73, 0x91, 0x37, 0x28, 0x75, 0x43, 0x4d, 0x96, 0x12, 0xfb, 0x59, 0x9d, 0xb2, 0xdd, 0x23, 0xed, 0xa0, 0x23, 0x57, + 0x03, 0x98, 0xc2, 0x54, 0xb0, 0xe7, 0xd5, 0x4b, 0x56, 0x9d, 0xe7, 0x05, 0xf9, 0xb6, 0xe2, 0x47, 0x04, 0x40, 0xa3, + 0x78, 0x43, 0x34, 0x2b, 0xa0, 0x2a, 0x91, 0x26, 0x0b, 0xc7, 0x4e, 0xf3, 0x4f, 0x68, 0x43, 0xcd, 0x7e, 0xbf, 0xed, + 0x64, 0x50, 0xc2, 0xc5, 0x37, 0x9f, 0x7d, 0xa0, 0x09, 0x7e, 0xfb, 0x99, 0x8c, 0xac, 0x95, 0xa0, 0xa3, 0x9c, 0xbc, + 0xee, 0x40, 0xca, 0x2c, 0x53, 0x61, 0xa1, 0x8b, 0xa4, 0x84, 0x6e, 0x98, 0x9c, 0x2f, 0x78, 0x7b, 0x83, 0xf3, 0xb5, + 0xac, 0x56, 0x5a, 0xbe, 0x99, 0xaa, 0x85, 0x79, 0x07, 0x54, 0x7d, 0xec, 0x04, 0x9e, 0xd0, 0x6d, 0x32, 0xef, 0x96, + 0xd2, 0x23, 0x5a, 0xf9, 0xde, 0x4b, 0x91, 0x66, 0xb7, 0xfe, 0x84, 0xe8, 0xd5, 0x11, 0x81, 0xfb, 0x22, 0xd9, 0x8a, + 0xbe, 0x37, 0x8c, 0x88, 0xe2, 0xfe, 0x1e, 0xfd, 0x12, 0x3f, 0xcb, 0xaf, 0x5d, 0x21, 0x34, 0x56, 0xc0, 0x23, 0x69, + 0x7d, 0xef, 0x6a, 0x8f, 0x21, 0x80, 0x4e, 0xaf, 0x42, 0x31, 0xec, 0xb6, 0x22, 0x66, 0xc7, 0x99, 0x38, 0xee, 0xf3, + 0x19, 0x96, 0xf9, 0xda, 0x34, 0xa1, 0x1b, 0xaa, 0x4f, 0x71, 0x21, 0x65, 0x92, 0x36, 0x45, 0x55, 0x17, 0x8d, 0xbf, + 0x35, 0xc8, 0x98, 0x62, 0xde, 0x7a, 0x34, 0xe8, 0x2f, 0xf6, 0x05, 0xf1, 0xe0, 0x48, 0xd0, 0xcb, 0x74, 0xf4, 0xc6, + 0xb1, 0x6a, 0x2c, 0x6f, 0x2c, 0x3b, 0x30, 0x13, 0x36, 0x09, 0xd1, 0xd8, 0x60, 0xeb, 0xc8, 0x82, 0x55, 0xcf, 0x18, + 0x9b, 0x77, 0xe9, 0x2d, 0x12, 0xde, 0x95, 0x2d, 0x1c, 0xa6, 0xfa, 0x42, 0xc6, 0x59, 0x2f, 0xd7, 0xf2, 0xe9, 0x3a, + 0x01, 0x0e, 0x12, 0x86, 0x17, 0xc4, 0x18, 0xfe, 0xe2, 0xbc, 0x49, 0x55, 0xb0, 0x28, 0xb4, 0x6d, 0x7c, 0x51, 0x7b, + 0x10, 0xcf, 0x4a, 0x10, 0xdf, 0xca, 0xb8, 0xea, 0xa0, 0x1b, 0x8e, 0x30, 0x57, 0xc3, 0x26, 0x84, 0x56, 0x10, 0x81, + 0x9a, 0xfa, 0x33, 0x0d, 0xd5, 0xb5, 0xae, 0xf2, 0x42, 0xa2, 0xe4, 0xb3, 0x68, 0x1c, 0x41, 0x21, 0x07, 0x83, 0xc2, + 0x09, 0x3d, 0xd8, 0x3d, 0xf8, 0x8d, 0x83, 0x71, 0xc1, 0x71, 0x43, 0xfe, 0xda, 0x2d, 0x6b, 0xdc, 0x33, 0x30, 0x95, + 0x97, 0x2b, 0xcd, 0xe6, 0x00, 0x2a, 0x83, 0x5d, 0x6c, 0x48, 0x3e, 0x5b, 0xf4, 0x84, 0xbe, 0xbb, 0xa1, 0x01, 0x0c, + 0x0f, 0x8f, 0xbc, 0x99, 0x7f, 0x23, 0x01, 0x0f, 0x0e, 0x66, 0xe5, 0x97, 0x0b, 0xa1, 0x58, 0x7d, 0x11, 0x20, 0x40, + 0x7c, 0x8d, 0xee, 0x07, 0x41, 0x74, 0x84, 0x60, 0x45, 0x1d, 0x0b, 0xe0, 0x44, 0xc5, 0x29, 0x39, 0x22, 0xc0, 0x38, + 0x41, 0x7d, 0x13, 0x34, 0xf3, 0x7b, 0xa3, 0xfc, 0x0b, 0xb7, 0x9b, 0x79, 0xe2, 0x59, 0x3f, 0x9b, 0xd7, 0x8b, 0x24, + 0x4f, 0xe0, 0x51, 0xd3, 0x81, 0x12, 0x85, 0xd2, 0x0d, 0xee, 0xa6, 0x14, 0x71, 0x9a, 0x88, 0xd5, 0x42, 0x00, 0xb6, + 0xb5, 0xb2, 0x96, 0x7e, 0xa3, 0x74, 0x8e, 0x3a, 0x87, 0x3d, 0x0b, 0xbe, 0x50, 0x7e, 0x6f, 0x09, 0x5d, 0xd5, 0x68, + 0x3b, 0x97, 0x9a, 0x1f, 0xae, 0x36, 0xb2, 0xa1, 0x75, 0xcd, 0xde, 0x42, 0x50, 0x53, 0x54, 0x86, 0x9a, 0xf2, 0x22, + 0x19, 0xdb, 0x9d, 0x98, 0xfd, 0xd0, 0x48, 0xf2, 0x1a, 0x79, 0x65, 0x7f, 0x43, 0x3b, 0xd3, 0x26, 0x1e, 0xbb, 0x12, + 0x0c, 0xbf, 0x68, 0x29, 0x7d, 0x2d, 0x9c, 0x5d, 0xcb, 0xcf, 0x97, 0xb0, 0x36, 0xa6, 0x00, 0x82, 0x90, 0x7e, 0x36, + 0xda, 0xaa, 0x31, 0xba, 0xd5, 0x63, 0xca, 0x3e, 0xea, 0x31, 0xdf, 0xfd, 0x1e, 0xa9, 0x92, 0x85, 0x20, 0x39, 0x34, + 0xf4, 0xd7, 0x63, 0x64, 0x18, 0xa0, 0x48, 0x22, 0xe4, 0x5b, 0x29, 0x03, 0xf7, 0xef, 0x57, 0x8c, 0x0e, 0xb6, 0xd4, + 0x9c, 0x49, 0xb3, 0xab, 0x67, 0x34, 0x20, 0x6c, 0xb4, 0x1e, 0x26, 0xce, 0x08, 0xe1, 0xa4, 0xb1, 0x7d, 0xaa, 0x22, + 0x12, 0xe9, 0xbd, 0x14, 0x31, 0xd8, 0xb8, 0x52, 0xba, 0xc4, 0x08, 0x6b, 0x66, 0x2c, 0xc7, 0x06, 0x50, 0x39, 0x73, + 0x5b, 0x94, 0xc6, 0x37, 0xad, 0xa0, 0x04, 0xb8, 0x47, 0x0c, 0xf6, 0x41, 0x23, 0x40, 0xae, 0x0b, 0x2a, 0x48, 0x68, + 0x9f, 0x0b, 0xc8, 0x84, 0x06, 0x19, 0x19, 0x13, 0xeb, 0x46, 0x20, 0xb9, 0x7b, 0x7a, 0xd3, 0x2e, 0x01, 0xa6, 0x72, + 0xb2, 0x9a, 0x21, 0x62, 0xe2, 0x78, 0x5d, 0x2d, 0x9c, 0xc0, 0x58, 0x0a, 0xd8, 0x31, 0x76, 0x54, 0x72, 0x2e, 0x76, + 0x68, 0xb4, 0x69, 0xe6, 0x17, 0xba, 0x3e, 0x43, 0xe1, 0x87, 0xb5, 0x0b, 0xc8, 0xc8, 0xa9, 0xdb, 0x4b, 0x0f, 0x46, + 0x06, 0x12, 0x57, 0xeb, 0x4e, 0x8b, 0xa4, 0x15, 0x91, 0xcf, 0x8a, 0x7e, 0x75, 0x6c, 0x72, 0x25, 0x2e, 0xd6, 0x8a, + 0x1a, 0x43, 0x91, 0x07, 0xb7, 0xc1, 0x3f, 0x76, 0xf4, 0xb8, 0x75, 0xc2, 0x02, 0x80, 0xf5, 0x58, 0x4e, 0x06, 0x9c, + 0xab, 0xee, 0xe0, 0xd7, 0x40, 0x95, 0xc0, 0x2b, 0x47, 0x9d, 0x45, 0x1c, 0x5f, 0x58, 0xa0, 0x18, 0xfc, 0xeb, 0x14, + 0x79, 0x0c, 0x76, 0x83, 0x2c, 0xe9, 0xa6, 0x59, 0x04, 0x7b, 0x4a, 0x79, 0x26, 0x62, 0xfe, 0xaa, 0x91, 0x34, 0x2a, + 0xac, 0x78, 0x9a, 0x6a, 0xa9, 0x13, 0x3e, 0x55, 0x09, 0x05, 0xc2, 0x1e, 0x82, 0xa6, 0x00, 0xde, 0x9b, 0x12, 0xf3, + 0xf8, 0xa6, 0x85, 0xc4, 0xf9, 0xc9, 0x3a, 0x9b, 0x35, 0x63, 0x06, 0xba, 0x92, 0x80, 0x6e, 0x4e, 0x35, 0x0d, 0xb7, + 0xe8, 0xba, 0x2c, 0x85, 0xa5, 0x64, 0x85, 0x5a, 0x82, 0x89, 0x30, 0x19, 0xde, 0x06, 0x17, 0x90, 0xbc, 0x37, 0x69, + 0x66, 0xdc, 0x3c, 0xbd, 0xaa, 0xf2, 0x04, 0x9a, 0xc7, 0x7d, 0x99, 0x2f, 0x34, 0xa5, 0xb9, 0xc2, 0x01, 0x48, 0x7b, + 0xc1, 0x3c, 0x16, 0x1a, 0x67, 0x52, 0x32, 0xfd, 0x8e, 0x8b, 0x99, 0xd4, 0x54, 0x71, 0x17, 0xd6, 0x09, 0x2b, 0x40, + 0x22, 0x59, 0x32, 0x18, 0x3c, 0x03, 0x8a, 0xf7, 0x05, 0xe0, 0x88, 0x68, 0x14, 0xbe, 0xb3, 0xa3, 0x1c, 0xad, 0x4a, + 0x42, 0x88, 0xcc, 0x56, 0xec, 0xbc, 0x78, 0xa3, 0x1c, 0x45, 0xce, 0x38, 0xda, 0x01, 0x6c, 0x5e, 0x7f, 0xc8, 0x7c, + 0x06, 0x81, 0xac, 0x1f, 0x27, 0x3a, 0x9b, 0x9b, 0xa6, 0x4b, 0x91, 0xce, 0x46, 0x73, 0x96, 0x17, 0x78, 0xc6, 0x29, + 0x13, 0x3c, 0x96, 0x8d, 0xe2, 0x86, 0xa8, 0xf3, 0x4f, 0xd4, 0x01, 0xa7, 0xda, 0x66, 0x7b, 0x33, 0x58, 0x3d, 0x2d, + 0x4e, 0x0e, 0x18, 0x95, 0x9c, 0xcd, 0xa3, 0xd5, 0xeb, 0xfd, 0x5f, 0x4e, 0xbe, 0x2a, 0x63, 0x81, 0xc6, 0xab, 0x9c, + 0xaa, 0xc8, 0xc8, 0x74, 0xc0, 0x89, 0x97, 0x9a, 0xcf, 0xc5, 0x00, 0x2d, 0x32, 0xaf, 0x4a, 0x32, 0x14, 0x92, 0xd5, + 0xb0, 0xf2, 0x06, 0x1a, 0x64, 0xd3, 0xd5, 0x50, 0xa3, 0xe0, 0x08, 0x59, 0xd2, 0x62, 0x63, 0xb6, 0x58, 0xac, 0x79, + 0xad, 0x99, 0x36, 0xc7, 0x08, 0x22, 0xb0, 0x38, 0x20, 0xae, 0x3f, 0xab, 0x35, 0x36, 0x30, 0x89, 0x57, 0xbb, 0x11, + 0x06, 0xdd, 0x0d, 0x2d, 0xa9, 0x4e, 0x8c, 0xa5, 0x12, 0x44, 0x4e, 0x1d, 0x69, 0xec, 0x47, 0x9e, 0xaf, 0xf9, 0xe3, + 0x9e, 0x05, 0xc6, 0xb2, 0x3c, 0x18, 0x19, 0xaa, 0x18, 0x52, 0xda, 0x0d, 0x66, 0x1e, 0xa2, 0x7e, 0x7a, 0xa4, 0x56, + 0xe5, 0x4c, 0xed, 0x31, 0x3c, 0x16, 0x9d, 0x4b, 0xf2, 0xe1, 0x51, 0x37, 0x04, 0x64, 0x5b, 0x81, 0xd5, 0xa7, 0x0e, + 0x22, 0x8a, 0x40, 0xd8, 0xcf, 0xe9, 0x1f, 0xbb, 0x91, 0xff, 0x14, 0xb0, 0x54, 0xaf, 0x87, 0xba, 0xa5, 0xcc, 0x31, + 0x25, 0x6b, 0x79, 0xcb, 0x29, 0xa8, 0xb8, 0x74, 0x55, 0x3a, 0x79, 0x20, 0xb6, 0x10, 0x29, 0x58, 0xcc, 0x3e, 0x9f, + 0x2e, 0x1c, 0xa8, 0xa4, 0x50, 0x7d, 0xdf, 0x05, 0x79, 0x7e, 0xb8, 0x71, 0x50, 0x8b, 0x31, 0xc6, 0x43, 0xaa, 0xad, + 0xaf, 0x1d, 0xdc, 0x8a, 0xbd, 0x0d, 0x3c, 0x3f, 0xb1, 0xdf, 0xef, 0xb7, 0x6c, 0x94, 0x91, 0x95, 0xc2, 0x8a, 0xdc, + 0xd4, 0xa2, 0xf3, 0xc3, 0x49, 0x38, 0x79, 0x42, 0x19, 0x90, 0x97, 0x33, 0xd8, 0x02, 0x59, 0x74, 0x53, 0xf4, 0xc2, + 0x68, 0xb3, 0xbe, 0xe5, 0xe2, 0x5b, 0xbf, 0xc3, 0x21, 0x93, 0x94, 0x2e, 0x69, 0x3a, 0xdf, 0xeb, 0x52, 0x7d, 0x17, + 0x59, 0xb4, 0x48, 0x67, 0xd5, 0xb6, 0xfb, 0x45, 0xaa, 0xb5, 0x17, 0x22, 0x59, 0x32, 0x1c, 0xc5, 0xde, 0xb9, 0x20, + 0xb5, 0x74, 0x06, 0xd5, 0xf4, 0x63, 0x32, 0x8e, 0x5d, 0x02, 0xad, 0x15, 0x4c, 0x6f, 0xe1, 0xc5, 0x20, 0xdb, 0x48, + 0x61, 0x91, 0x16, 0x82, 0x35, 0x9a, 0x39, 0xd2, 0x7e, 0x90, 0x28, 0x2c, 0x03, 0x74, 0x96, 0xf4, 0x39, 0xb3, 0x87, + 0xa3, 0x78, 0x84, 0x2a, 0xa2, 0xd4, 0xfd, 0x61, 0x42, 0x85, 0x54, 0xa7, 0x79, 0x82, 0xa2, 0x3d, 0x1f, 0xb9, 0x63, + 0x03, 0xe6, 0xa7, 0x33, 0xd1, 0xae, 0xbf, 0x5a, 0x02, 0x16, 0x5e, 0x7e, 0x48, 0x71, 0x9b, 0xd2, 0xdb, 0xf9, 0x1f, + 0xf3, 0x39, 0xa5, 0x3c, 0x33, 0x74, 0x4a, 0xa9, 0xd0, 0xcc, 0xe6, 0xc2, 0x0a, 0x49, 0xa5, 0xf9, 0x70, 0x67, 0x0d, + 0xba, 0x19, 0x82, 0x12, 0x09, 0xc5, 0x8d, 0x60, 0x16, 0xa3, 0x18, 0x6b, 0xa0, 0x72, 0x37, 0x6f, 0xd5, 0x49, 0xa5, + 0xb9, 0x53, 0x95, 0x5c, 0xf1, 0xdd, 0xcf, 0x8d, 0x82, 0x61, 0x08, 0xb1, 0xe9, 0xf8, 0x22, 0x25, 0xcb, 0x4b, 0x39, + 0xac, 0xc6, 0x95, 0x21, 0x82, 0x96, 0x41, 0x9c, 0x10, 0xac, 0xe4, 0x12, 0xd4, 0x56, 0x98, 0xee, 0x54, 0x89, 0x52, + 0x41, 0x1f, 0x28, 0xbd, 0xba, 0x83, 0xe6, 0xc4, 0x36, 0x84, 0xb7, 0xa6, 0xa1, 0x80, 0x98, 0xf5, 0xdf, 0x07, 0x19, + 0x1d, 0x3a, 0x7e, 0x2b, 0x19, 0x53, 0x21, 0x50, 0x33, 0x47, 0xcb, 0xcb, 0x80, 0x4d, 0x0a, 0x71, 0xa5, 0x28, 0x4e, + 0x04, 0x71, 0xd8, 0xc7, 0xa6, 0xe6, 0xd3, 0xc7, 0x41, 0x62, 0x1a, 0xd6, 0x65, 0x03, 0xa4, 0xd6, 0x0b, 0x91, 0xf8, + 0x35, 0xf5, 0xe6, 0xa0, 0x09, 0xe3, 0x75, 0xa8, 0x20, 0x66, 0xc5, 0x69, 0x23, 0x05, 0x63, 0x95, 0x86, 0x43, 0x50, + 0x8e, 0x0c, 0xcb, 0xc4, 0xfa, 0xd2, 0x2c, 0xd1, 0x1b, 0x26, 0x06, 0xb6, 0x63, 0xa5, 0x84, 0x00, 0x38, 0x33, 0x66, + 0xdd, 0x8e, 0x49, 0xab, 0x0a, 0xea, 0x81, 0xea, 0xb3, 0xbe, 0xe8, 0x78, 0x86, 0x98, 0xf0, 0x21, 0x01, 0x47, 0xf3, + 0x45, 0xd2, 0x73, 0x6b, 0xa2, 0x25, 0x6d, 0x6a, 0x88, 0x3f, 0x31, 0xd2, 0x5a, 0xb4, 0xd2, 0x81, 0xaf, 0x00, 0x54, + 0x90, 0xa9, 0xe0, 0x12, 0x55, 0x52, 0x36, 0x15, 0x95, 0x07, 0x93, 0x72, 0x6d, 0x99, 0x95, 0x55, 0xee, 0x5d, 0x1f, + 0xe1, 0xcf, 0xb4, 0x50, 0xd2, 0xba, 0x43, 0x7c, 0xa9, 0xe0, 0xaf, 0x51, 0x48, 0x11, 0xf5, 0x99, 0x91, 0x5d, 0x1d, + 0xf3, 0xec, 0x91, 0x95, 0xff, 0xda, 0xc6, 0xaf, 0x5d, 0x28, 0x31, 0xca, 0xdd, 0x7b, 0x64, 0x32, 0xb2, 0x85, 0x80, + 0xa8, 0xdb, 0xd8, 0x0f, 0x47, 0xea, 0xf8, 0xe3, 0x90, 0xe2, 0x3f, 0x5d, 0x05, 0x51, 0x7b, 0xd2, 0x42, 0xaa, 0x83, + 0x9e, 0x03, 0x6b, 0xd0, 0x9a, 0x34, 0x7a, 0xd0, 0xbd, 0x07, 0x2a, 0x57, 0x04, 0xe7, 0x8f, 0x6e, 0xc2, 0x44, 0x05, + 0x9e, 0x02, 0xfe, 0xc2, 0x14, 0x84, 0x59, 0x23, 0x50, 0xdd, 0x2e, 0xda, 0x3e, 0x6f, 0x33, 0x66, 0x90, 0xf7, 0x6e, + 0xdf, 0x08, 0x3f, 0xa2, 0x1e, 0x36, 0x5b, 0xfd, 0x9b, 0x6f, 0x79, 0x94, 0xa8, 0x2c, 0x84, 0xa9, 0x91, 0x50, 0x53, + 0x67, 0x49, 0xe0, 0x47, 0x37, 0xb1, 0x86, 0xd9, 0x7e, 0xb2, 0x56, 0xb8, 0x54, 0x08, 0x99, 0x22, 0x10, 0x9d, 0x21, + 0xcc, 0xa8, 0xf3, 0x44, 0x01, 0xbc, 0xad, 0x00, 0xb4, 0x04, 0xfd, 0x18, 0x6c, 0x73, 0xfb, 0x84, 0xd0, 0x5c, 0xcc, + 0xf3, 0x47, 0x4c, 0x42, 0x41, 0x8a, 0x9f, 0xe5, 0xd3, 0xac, 0x79, 0xa1, 0x12, 0x15, 0xd7, 0x50, 0xb4, 0x15, 0xd7, + 0xc1, 0x03, 0x63, 0xd6, 0x47, 0xd1, 0xa9, 0x8d, 0x29, 0xcd, 0xe2, 0x66, 0x97, 0x68, 0x47, 0xea, 0x6e, 0x3c, 0x9f, + 0x34, 0xdc, 0x89, 0x24, 0xa1, 0x2c, 0x43, 0xab, 0xdb, 0xa6, 0x4b, 0x71, 0x0a, 0xe7, 0x74, 0x5e, 0x7e, 0xcb, 0x10, + 0xef, 0xbf, 0xe6, 0xf8, 0xf4, 0x39, 0xab, 0x66, 0x9e, 0x1f, 0x3d, 0x12, 0x5a, 0x60, 0x66, 0x2d, 0x76, 0xf3, 0x28, + 0x8b, 0x35, 0x24, 0xb0, 0xc3, 0x86, 0xa1, 0x13, 0x5e, 0x6f, 0x59, 0x3c, 0x54, 0x5b, 0x0f, 0x36, 0xde, 0x53, 0x18, + 0xba, 0x5b, 0xd2, 0x46, 0xd6, 0x35, 0x51, 0xd1, 0xcf, 0x6f, 0x91, 0xcd, 0xdd, 0xfe, 0xb8, 0x4c, 0x9a, 0x14, 0x15, + 0xa7, 0xa3, 0xdd, 0xe9, 0xc5, 0xdf, 0x18, 0x33, 0xf3, 0x18, 0x39, 0x65, 0x32, 0xd6, 0xd6, 0x19, 0x6d, 0xb9, 0xb5, + 0x73, 0x95, 0xf5, 0x64, 0xe0, 0x77, 0x82, 0xe4, 0xe7, 0x47, 0x39, 0x68, 0x44, 0x20, 0xa8, 0xdd, 0xae, 0x51, 0xc8, + 0x86, 0x03, 0x33, 0xc7, 0x7f, 0x67, 0x6d, 0xfb, 0x3e, 0x79, 0x9b, 0xf5, 0x62, 0x76, 0xc0, 0xf5, 0xc6, 0x46, 0x73, + 0x64, 0x6e, 0x57, 0x23, 0x1b, 0x3a, 0xdc, 0x91, 0x50, 0xdf, 0x1c, 0x99, 0x67, 0xc7, 0x7c, 0x69, 0x44, 0x70, 0x36, + 0x3a, 0x02, 0xc3, 0x41, 0x9b, 0xeb, 0xbf, 0x49, 0xf2, 0x3d, 0x93, 0x18, 0xe0, 0x80, 0x0d, 0xb2, 0x7a, 0x27, 0x0b, + 0x42, 0xc5, 0x2d, 0x1d, 0xf0, 0x72, 0x9f, 0x07, 0x14, 0x6f, 0xa2, 0x52, 0x72, 0xbd, 0x39, 0x13, 0x15, 0x9a, 0xbb, + 0x7b, 0xe3, 0x6d, 0xab, 0xf1, 0x42, 0xfd, 0xfb, 0x7a, 0x97, 0xda, 0xdf, 0xfc, 0xb1, 0xe9, 0xbb, 0x2c, 0x3f, 0x6b, + 0x51, 0xa7, 0xe7, 0x22, 0xe3, 0x79, 0xd3, 0x2e, 0xd1, 0x1e, 0x46, 0xaa, 0xc9, 0x6a, 0x09, 0xcd, 0x8d, 0xd8, 0xe6, + 0x1d, 0xb7, 0x3a, 0xe0, 0x55, 0x98, 0x55, 0xcd, 0x89, 0x78, 0xef, 0x49, 0xe0, 0x7a, 0xea, 0xc9, 0xda, 0x84, 0xdb, + 0xa1, 0x57, 0x76, 0xb3, 0x33, 0x86, 0xce, 0xa7, 0xd5, 0x3f, 0xd9, 0x47, 0xf0, 0x9b, 0x93, 0xcd, 0xbf, 0x37, 0x35, + 0xa5, 0xc9, 0xdb, 0x93, 0x69, 0xd6, 0x93, 0xa7, 0x1b, 0xc3, 0xd9, 0x96, 0xf6, 0xd3, 0xe6, 0xc3, 0x6c, 0xda, 0x7f, + 0x29, 0xdf, 0x54, 0x85, 0x49, 0xa9, 0xff, 0x04, 0x96, 0x7a, 0x64, 0xa1, 0xf7, 0x1a, 0x43, 0xfc, 0xaa, 0x0a, 0x07, + 0x17, 0x35, 0xdd, 0x0f, 0xe3, 0xdd, 0x68, 0xe2, 0xd6, 0xe5, 0x65, 0x69, 0xce, 0x6a, 0xc4, 0x49, 0xee, 0x49, 0xab, + 0xeb, 0x5d, 0xe6, 0x39, 0xb4, 0xcb, 0xbf, 0x17, 0x08, 0xb7, 0x26, 0x28, 0x68, 0x5d, 0x6a, 0x9b, 0x75, 0x7e, 0x16, + 0x58, 0xfe, 0x1b, 0x59, 0x4f, 0xd7, 0x57, 0xb1, 0xeb, 0x97, 0x2a, 0x3f, 0xff, 0x14, 0xfe, 0x5e, 0xf4, 0xa4, 0xf9, + 0xeb, 0xc1, 0xd9, 0xe7, 0xf9, 0x9f, 0xa3, 0x4c, 0x9b, 0x5a, 0x75, 0xeb, 0x29, 0xfc, 0xf3, 0x78, 0x28, 0x66, 0xb3, + 0xf1, 0xd7, 0x56, 0xf3, 0x3b, 0x84, 0x57, 0xff, 0xf1, 0xe2, 0xe7, 0x2f, 0xcd, 0xc0, 0x7c, 0xe8, 0x9f, 0xe6, 0x6c, + 0xea, 0x62, 0xfd, 0x17, 0x29, 0xeb, 0xeb, 0x3b, 0x6f, 0x4c, 0x34, 0xac, 0xf8, 0xb6, 0xe9, 0xd6, 0x6c, 0x56, 0x47, + 0x95, 0x7f, 0xe1, 0xda, 0xbf, 0x3d, 0xf5, 0x19, 0x04, 0xf9, 0xbc, 0x93, 0x7a, 0xde, 0x18, 0xee, 0x96, 0x14, 0xf6, + 0xd9, 0x72, 0xef, 0xd7, 0x0b, 0x3f, 0x1e, 0x2f, 0xca, 0xd6, 0x51, 0x37, 0x59, 0x59, 0x35, 0xd7, 0x7e, 0xb1, 0x26, + 0x39, 0xdb, 0x15, 0x38, 0xff, 0xb4, 0x7c, 0x3c, 0xfe, 0xe7, 0xe4, 0x69, 0x5d, 0x8e, 0x66, 0x30, 0xe3, 0x3d, 0x9a, + 0x27, 0x9a, 0x37, 0x26, 0xd3, 0x66, 0xbf, 0xfd, 0x10, 0xdf, 0x9a, 0x6e, 0xdd, 0x9b, 0xaf, 0xf8, 0x01, 0x57, 0xcc, + 0xa9, 0xef, 0x5a, 0xf9, 0x5d, 0x4f, 0x0d, 0x71, 0xc1, 0xd8, 0x04, 0x12, 0x8f, 0xfd, 0xdf, 0xc1, 0xd8, 0x0f, 0xbd, + 0x97, 0xde, 0x6c, 0x11, 0xdf, 0x09, 0x61, 0xd7, 0xac, 0x94, 0x73, 0x91, 0x8e, 0xd8, 0xc6, 0x70, 0x9c, 0xf9, 0x40, + 0x45, 0xdb, 0x27, 0xef, 0x37, 0x3e, 0xea, 0x77, 0xa1, 0xf6, 0xe0, 0xfa, 0xe1, 0xf2, 0xb5, 0x78, 0xef, 0x9f, 0x09, + 0xe1, 0x65, 0x03, 0x62, 0x5e, 0xf1, 0xee, 0xf6, 0x3f, 0x46, 0xc5, 0x29, 0x14, 0x75, 0xa2, 0xb2, 0x66, 0xdb, 0x66, + 0xf6, 0x7d, 0xb4, 0x78, 0xe8, 0x40, 0xcd, 0xc7, 0xfb, 0x84, 0xc8, 0xa3, 0x8b, 0x74, 0x97, 0xef, 0xa7, 0x12, 0x08, + 0xec, 0x11, 0x05, 0x76, 0x0d, 0xf2, 0x69, 0xd7, 0x83, 0xbf, 0xc0, 0x9b, 0xb0, 0xb1, 0xb9, 0x7a, 0xb7, 0x73, 0xc8, + 0x1e, 0xae, 0xe6, 0xc5, 0xfa, 0x14, 0x40, 0x12, 0x9b, 0x84, 0x80, 0xf9, 0x3f, 0xb9, 0xa0, 0x86, 0xad, 0xd7, 0xe9, + 0xe7, 0x17, 0xa3, 0xee, 0xd4, 0xa4, 0x59, 0x47, 0x18, 0xe9, 0x5e, 0x78, 0xb5, 0xa1, 0x13, 0x1e, 0x72, 0x8c, 0xf5, + 0x8a, 0xd2, 0x4a, 0x0b, 0xee, 0xd4, 0x47, 0x9d, 0x95, 0x9f, 0x1b, 0x90, 0x88, 0x6c, 0x95, 0x0d, 0xee, 0x32, 0xb3, + 0xf7, 0x0a, 0x6e, 0x58, 0xa5, 0x36, 0x2f, 0xa1, 0xce, 0xf8, 0x3d, 0x57, 0x53, 0x6a, 0xeb, 0xab, 0x6e, 0xde, 0xc4, + 0xcf, 0xed, 0xc5, 0x42, 0x7a, 0xc3, 0x2e, 0xad, 0x0d, 0x48, 0xe0, 0xea, 0x1b, 0xda, 0xed, 0xd4, 0x68, 0xc4, 0x40, + 0x3e, 0x4e, 0x82, 0xf3, 0x5c, 0x33, 0x53, 0x18, 0xef, 0x1a, 0x6a, 0x65, 0xd1, 0x2d, 0xc7, 0x45, 0x93, 0xb7, 0xed, + 0xff, 0x5d, 0x46, 0x8e, 0xeb, 0xe1, 0x0c, 0xe0, 0x36, 0x0f, 0xa0, 0x9f, 0x55, 0x17, 0x56, 0xb9, 0x99, 0x3f, 0xdd, + 0x1a, 0x0c, 0x2a, 0x1f, 0x7a, 0x98, 0x72, 0xfc, 0x46, 0x4e, 0xff, 0x11, 0xb0, 0x6b, 0xeb, 0xb9, 0x96, 0x7b, 0xde, + 0xec, 0xc5, 0x7b, 0x33, 0x9d, 0xcd, 0x4c, 0x99, 0xf5, 0x58, 0xc5, 0x94, 0x13, 0x5f, 0xdb, 0xd5, 0xb7, 0x8b, 0xef, + 0xf6, 0xe1, 0x93, 0x0d, 0x4e, 0x01, 0x2b, 0x68, 0x28, 0x88, 0x83, 0xca, 0xcf, 0xf7, 0x6f, 0xee, 0x98, 0xdc, 0x06, + 0xc9, 0xf4, 0x6a, 0xe1, 0x3a, 0x9e, 0xf4, 0x17, 0x16, 0xfd, 0x59, 0x2b, 0xa1, 0xbf, 0x00, 0xc3, 0x07, 0x6e, 0xcf, + 0x99, 0xe9, 0xdc, 0xc5, 0xa2, 0x7c, 0x9a, 0x71, 0x2b, 0xb9, 0x9b, 0x33, 0x6a, 0x4d, 0x73, 0x00, 0x90, 0x16, 0x4a, + 0x8d, 0x3f, 0x51, 0xd5, 0xa1, 0xa4, 0xc6, 0xb7, 0x91, 0x2a, 0x74, 0x74, 0x59, 0xe4, 0xa7, 0x8b, 0x2b, 0x62, 0xe1, + 0x75, 0x70, 0x7b, 0x8a, 0xe1, 0x8b, 0xef, 0x99, 0xd3, 0xf2, 0x83, 0xca, 0x37, 0x85, 0xe2, 0x6c, 0xd8, 0x18, 0x79, + 0xeb, 0xfe, 0xd4, 0x12, 0x34, 0x7c, 0x8b, 0xde, 0x9a, 0x57, 0xff, 0xed, 0x69, 0x15, 0xa0, 0x5b, 0x1c, 0xe1, 0xe9, + 0x0e, 0x8a, 0x66, 0xee, 0xa9, 0x78, 0x51, 0x06, 0xca, 0xe4, 0x75, 0x3f, 0xe3, 0x45, 0x70, 0x52, 0x68, 0x9f, 0xd7, + 0xcf, 0xeb, 0x05, 0x55, 0x33, 0xc9, 0xe9, 0xed, 0xc2, 0xbc, 0xe1, 0xfb, 0xeb, 0x16, 0x5f, 0x67, 0x29, 0x53, 0xb1, + 0x1d, 0x94, 0xd1, 0x6f, 0x0b, 0x60, 0xbe, 0xfa, 0x92, 0x89, 0x05, 0x9d, 0xac, 0xb9, 0x59, 0xcd, 0xf7, 0xb6, 0x67, + 0x7e, 0x8f, 0xe0, 0xe5, 0x5b, 0xa5, 0x68, 0xeb, 0x67, 0x95, 0x67, 0x2d, 0x30, 0x77, 0x08, 0x31, 0x37, 0x91, 0x06, + 0x8b, 0x02, 0xf2, 0xdd, 0xcd, 0x4b, 0xcb, 0x28, 0xf3, 0x68, 0xde, 0xfc, 0xb3, 0x5e, 0x50, 0x07, 0xa4, 0x17, 0xdf, + 0xb9, 0xdc, 0x40, 0x42, 0x8b, 0x7b, 0xa1, 0xc6, 0xdb, 0xe8, 0x71, 0xca, 0xac, 0x3c, 0x40, 0xb2, 0x86, 0x0e, 0x5a, + 0xdd, 0x87, 0x74, 0x5c, 0x1c, 0x5f, 0xa3, 0xe9, 0xfb, 0x26, 0xde, 0x4e, 0x74, 0x35, 0x79, 0x4f, 0xd9, 0x6d, 0x96, + 0x81, 0x12, 0xcb, 0xcb, 0x0b, 0x79, 0x27, 0xac, 0xa5, 0xa4, 0xb9, 0x0e, 0x13, 0x67, 0x83, 0xfa, 0xeb, 0x99, 0x97, + 0x5e, 0xd6, 0x3e, 0xe0, 0x1b, 0x85, 0x0a, 0xee, 0xe7, 0x09, 0x35, 0x82, 0xfd, 0x20, 0x45, 0xea, 0x41, 0x9d, 0x30, + 0xa3, 0x06, 0x23, 0x69, 0xba, 0xb4, 0x14, 0x67, 0x4e, 0xfa, 0x8b, 0x8a, 0xd2, 0x85, 0x5d, 0xbe, 0xad, 0x62, 0xa9, + 0x4e, 0x6f, 0x63, 0xa4, 0x3c, 0x6a, 0xde, 0x9b, 0xb7, 0x45, 0xde, 0x4e, 0x1b, 0x12, 0x22, 0xe9, 0x05, 0x72, 0xd9, + 0x3a, 0x3f, 0x84, 0xee, 0xe7, 0x2c, 0x1e, 0xc1, 0xa6, 0x84, 0x51, 0x90, 0xeb, 0x32, 0xd7, 0x7b, 0x43, 0x03, 0x13, + 0xf2, 0x63, 0x7e, 0x96, 0x80, 0xa5, 0x6d, 0xdd, 0x7a, 0x67, 0x7c, 0x68, 0x99, 0x43, 0xef, 0x96, 0x00, 0x32, 0xb7, + 0x6b, 0xf6, 0xae, 0xd6, 0x39, 0x99, 0x38, 0x24, 0x35, 0xa0, 0xef, 0x19, 0x75, 0xfa, 0xc6, 0x32, 0xb1, 0x48, 0xa4, + 0xa6, 0x37, 0x89, 0x95, 0xe6, 0xb1, 0xa7, 0xaf, 0x4e, 0x3d, 0x03, 0xbe, 0x36, 0xf7, 0x9a, 0xdd, 0xc7, 0x06, 0xec, + 0xb0, 0xd0, 0xc6, 0xee, 0x02, 0xe6, 0xf2, 0xa6, 0xdd, 0x4e, 0xa8, 0x3c, 0xba, 0x71, 0xcc, 0x0d, 0xc1, 0x40, 0x7a, + 0x11, 0x8d, 0xa2, 0xfc, 0xbe, 0xea, 0x49, 0xec, 0x75, 0x07, 0x76, 0xb7, 0xfb, 0xb3, 0x23, 0x55, 0xb8, 0xbd, 0x4c, + 0x06, 0x7e, 0xb2, 0x3d, 0x23, 0x79, 0x68, 0x2a, 0xf6, 0x80, 0x26, 0x1b, 0xde, 0x05, 0xe2, 0x86, 0xf1, 0xbe, 0x0f, + 0xfb, 0xfb, 0x92, 0xaf, 0x09, 0xa8, 0x71, 0x20, 0x41, 0xb5, 0x64, 0xcf, 0x5f, 0xae, 0xcc, 0xe6, 0x32, 0xcc, 0x26, + 0x5e, 0xb9, 0xa8, 0xf3, 0xfe, 0xe9, 0xb5, 0x83, 0xfb, 0x2d, 0x35, 0x94, 0x9b, 0xf1, 0xcc, 0xff, 0x47, 0x4d, 0x61, + 0x43, 0xe0, 0x01, 0x59, 0x69, 0x21, 0xb9, 0xb2, 0xc0, 0xa7, 0x6f, 0x0e, 0x75, 0x3e, 0x8c, 0xe7, 0x2d, 0x66, 0x65, + 0x46, 0xe4, 0x62, 0x7c, 0x80, 0x48, 0x36, 0x50, 0x0c, 0x13, 0x2e, 0x60, 0xf4, 0xd1, 0x65, 0xda, 0xa2, 0x79, 0x20, + 0xed, 0xca, 0xd6, 0x1f, 0xcf, 0x0c, 0xbc, 0x92, 0xff, 0xc6, 0x79, 0x5c, 0x86, 0x39, 0xbe, 0xd2, 0xd8, 0x9e, 0x92, + 0xe7, 0xc2, 0x15, 0x19, 0xe5, 0xa1, 0xaa, 0x3c, 0xe9, 0x9c, 0xbb, 0xbb, 0x7a, 0x32, 0x1d, 0xd9, 0x0c, 0x60, 0x6e, + 0x69, 0xda, 0xd8, 0xb1, 0x52, 0x5d, 0xf2, 0x10, 0x6f, 0x30, 0x18, 0xec, 0xcb, 0xd6, 0xad, 0x3f, 0xdb, 0x28, 0x68, + 0xb8, 0x42, 0x10, 0x58, 0x82, 0x81, 0xab, 0x92, 0x04, 0xe9, 0x0f, 0x45, 0xde, 0xb9, 0x29, 0x79, 0x4f, 0x3d, 0xb9, + 0x78, 0x25, 0x79, 0x70, 0x68, 0x09, 0x70, 0xd1, 0x7f, 0xd6, 0x5a, 0xc9, 0xda, 0x52, 0xbe, 0x3b, 0xce, 0x3e, 0x76, + 0xba, 0x99, 0x05, 0xd9, 0xd2, 0x87, 0x51, 0x6c, 0xcf, 0xbd, 0x1e, 0xe6, 0xa1, 0x25, 0xb0, 0x90, 0xb9, 0x59, 0xda, + 0x01, 0xf1, 0x2d, 0x9a, 0xd4, 0x66, 0xc9, 0xff, 0xc4, 0x2d, 0x6f, 0x20, 0x44, 0xd4, 0xb6, 0xbe, 0x6b, 0x68, 0x74, + 0x12, 0x27, 0xb9, 0x41, 0xde, 0x7f, 0x53, 0x0a, 0x28, 0x50, 0xb6, 0x54, 0x76, 0x92, 0xdf, 0x7f, 0xe2, 0x21, 0x84, + 0x66, 0x36, 0x5e, 0x5a, 0xb5, 0x6e, 0x33, 0x6b, 0x09, 0xa7, 0x91, 0x30, 0xb3, 0x9b, 0x83, 0xae, 0x2a, 0x12, 0x8e, + 0x92, 0x34, 0xa6, 0x48, 0x47, 0x38, 0xdc, 0x69, 0xbe, 0xbb, 0x93, 0xba, 0x63, 0x01, 0x6b, 0x9b, 0x39, 0x6e, 0x01, + 0x02, 0x8c, 0xfa, 0x5d, 0x03, 0xd1, 0x44, 0x93, 0x53, 0xa8, 0xe5, 0x8d, 0xdc, 0xd5, 0xa3, 0x5b, 0xf3, 0x58, 0x83, + 0xf6, 0x59, 0xfd, 0x29, 0x21, 0xe0, 0xb6, 0xa2, 0xde, 0x93, 0x81, 0x15, 0xa9, 0x0b, 0xc1, 0x35, 0x10, 0x58, 0xef, + 0x8c, 0xd6, 0x3e, 0x35, 0x26, 0xd2, 0xfe, 0xa2, 0xc1, 0x05, 0x24, 0x04, 0x02, 0x98, 0x97, 0x65, 0xb3, 0x84, 0x4f, + 0x22, 0x39, 0x80, 0xaa, 0xc7, 0xa5, 0xb7, 0x5a, 0x4a, 0x44, 0xc3, 0xa3, 0x1a, 0x01, 0xd7, 0xed, 0x02, 0xe5, 0x03, + 0x46, 0x58, 0x39, 0x85, 0x79, 0x26, 0xa4, 0x6a, 0x52, 0x8c, 0xba, 0x99, 0x4d, 0xa4, 0x3c, 0x33, 0xce, 0x53, 0x49, + 0xd4, 0x69, 0xfd, 0x6b, 0xe5, 0x4b, 0x1b, 0x44, 0xdb, 0xf0, 0xd9, 0x70, 0x7d, 0xac, 0xb9, 0x1e, 0x6d, 0x06, 0xa6, + 0xb5, 0xab, 0x59, 0x04, 0x88, 0x7a, 0x2a, 0xbb, 0xab, 0xcf, 0x5c, 0x90, 0x87, 0x1a, 0x3f, 0xf2, 0xe2, 0x14, 0xec, + 0x4a, 0x3f, 0xbf, 0x69, 0x28, 0x40, 0x18, 0x2f, 0x1d, 0xf1, 0x92, 0x55, 0x5e, 0x6c, 0x8a, 0x36, 0xee, 0x30, 0xf6, + 0x7a, 0xb4, 0x02, 0x52, 0x8f, 0x4d, 0xdd, 0x49, 0x96, 0xac, 0x8b, 0x73, 0xca, 0xab, 0xb8, 0x67, 0xba, 0x34, 0x7d, + 0x4c, 0xfd, 0x87, 0x4a, 0xe7, 0xc4, 0x0a, 0xe1, 0x7f, 0x4b, 0xca, 0xce, 0x2a, 0x65, 0x5a, 0x90, 0x88, 0xb5, 0x20, + 0x0a, 0x9c, 0xef, 0x04, 0xc9, 0xc2, 0xb2, 0x88, 0x24, 0x4f, 0x63, 0x79, 0xad, 0x4b, 0xf0, 0x24, 0x7b, 0xa0, 0xc8, + 0x87, 0x5d, 0xd9, 0x25, 0xc1, 0xdc, 0xf3, 0x83, 0xb4, 0x61, 0xa2, 0xb0, 0x0f, 0x5a, 0xf2, 0xb8, 0x66, 0x01, 0x38, + 0x3d, 0xf4, 0x6b, 0xef, 0xf9, 0xd8, 0x36, 0x7e, 0x8b, 0xe0, 0x5d, 0x4e, 0x84, 0xfb, 0x39, 0x97, 0x04, 0xcb, 0xaf, + 0xae, 0x53, 0x66, 0xb1, 0x5a, 0x83, 0x8a, 0x97, 0x3b, 0xbc, 0x6d, 0xdd, 0x5f, 0x96, 0xf0, 0xbe, 0x93, 0xcd, 0x70, + 0x37, 0x1d, 0x91, 0xcd, 0xc4, 0x39, 0x92, 0x8a, 0x44, 0x5c, 0x75, 0x32, 0x8d, 0xc5, 0x87, 0x39, 0x01, 0x04, 0x93, + 0xfa, 0x37, 0x2a, 0x84, 0x36, 0x24, 0x74, 0x7c, 0xec, 0xf2, 0xb5, 0x61, 0xed, 0xd6, 0xd7, 0xca, 0xd6, 0xbe, 0x75, + 0x23, 0x8a, 0x0a, 0xed, 0x58, 0x2c, 0x86, 0x64, 0x8c, 0x5e, 0xe9, 0x37, 0xd6, 0x34, 0xc9, 0xe2, 0xe1, 0xab, 0xdb, + 0x68, 0x31, 0x0e, 0x62, 0x17, 0x78, 0xfb, 0xd1, 0xec, 0x6d, 0x2d, 0x29, 0x7e, 0xff, 0xea, 0x8c, 0xa2, 0x56, 0xfc, + 0x43, 0xe9, 0xcf, 0xba, 0xc0, 0x25, 0x2a, 0x03, 0x2d, 0x66, 0xf8, 0x83, 0x48, 0xab, 0x57, 0xc8, 0xb9, 0xcf, 0xb9, + 0x3e, 0x24, 0xff, 0xc5, 0x03, 0x6f, 0x28, 0x8b, 0x42, 0xa8, 0xeb, 0x11, 0x37, 0x52, 0xc4, 0x62, 0xdd, 0x7d, 0x79, + 0xd0, 0x16, 0x39, 0x0b, 0x66, 0xcd, 0x6e, 0xca, 0x34, 0xdc, 0x85, 0x4b, 0x8b, 0x6e, 0xd3, 0x6c, 0x13, 0xbc, 0x0c, + 0x3b, 0xe9, 0x38, 0x7a, 0x67, 0x03, 0xa1, 0x28, 0x08, 0x10, 0x4a, 0x1a, 0xfa, 0x67, 0x28, 0x6d, 0xa5, 0x98, 0x87, + 0x96, 0x72, 0xca, 0x65, 0x21, 0xe6, 0x7e, 0x42, 0x86, 0x81, 0xfb, 0xc5, 0x8d, 0xdc, 0xb4, 0x16, 0x48, 0x16, 0x89, + 0x1e, 0xf5, 0xbc, 0x7b, 0x72, 0x95, 0xc5, 0xa0, 0x07, 0x44, 0x0e, 0x70, 0xbd, 0x9b, 0xaa, 0x67, 0x25, 0xc1, 0xc0, + 0xd1, 0x7d, 0xc0, 0x5a, 0x5f, 0x5b, 0xc3, 0x44, 0x2b, 0x04, 0x5e, 0x42, 0x8d, 0x19, 0x12, 0xed, 0x03, 0xf5, 0x90, + 0x98, 0x00, 0x34, 0x05, 0xaf, 0xb1, 0x25, 0xd0, 0xb6, 0x6b, 0x4c, 0x09, 0x14, 0xb0, 0x32, 0xd5, 0x88, 0xc6, 0xcc, + 0x43, 0x47, 0x8c, 0xc4, 0x71, 0xee, 0x47, 0xe4, 0xc1, 0x86, 0xd4, 0x21, 0xda, 0xfe, 0xa6, 0x7e, 0xb0, 0xc6, 0x99, + 0x31, 0x8d, 0x5c, 0x20, 0x1c, 0xaf, 0x41, 0xe1, 0x86, 0xb1, 0x61, 0xfb, 0xaa, 0x26, 0xab, 0x3a, 0x23, 0x32, 0xab, + 0x9e, 0x39, 0xec, 0x57, 0xf1, 0x47, 0x97, 0x58, 0x49, 0xb3, 0xe1, 0x9b, 0xa4, 0xd4, 0xb3, 0xe5, 0xd5, 0x37, 0x46, + 0x22, 0x3d, 0xdd, 0x07, 0x5c, 0x70, 0x0d, 0xa2, 0x9b, 0x92, 0x9f, 0x7d, 0x32, 0x6a, 0x00, 0x8f, 0xda, 0xb4, 0x43, + 0x15, 0x14, 0x83, 0x81, 0x91, 0xa6, 0xd3, 0xd2, 0x98, 0x2e, 0xd1, 0x6c, 0xa0, 0x99, 0xc7, 0x78, 0x22, 0xd2, 0x89, + 0xed, 0x1d, 0xcf, 0x57, 0x2d, 0x1a, 0x59, 0xad, 0xda, 0x20, 0xcb, 0x6f, 0xd3, 0x7a, 0xad, 0x32, 0x32, 0xde, 0x96, + 0x01, 0xf1, 0x47, 0x28, 0x0b, 0x86, 0x8a, 0x8a, 0x24, 0xc5, 0x14, 0x15, 0x97, 0xc6, 0x47, 0xae, 0x02, 0x74, 0x19, + 0x56, 0xad, 0xcd, 0xab, 0xf0, 0xf6, 0x49, 0x0c, 0xf7, 0x41, 0xa9, 0xc2, 0xe9, 0xe5, 0x62, 0xb6, 0x3c, 0x56, 0xe1, + 0x8f, 0x5d, 0x75, 0x12, 0x3c, 0x6d, 0xcf, 0xde, 0x39, 0xd5, 0xe8, 0x54, 0x5f, 0x1c, 0xb2, 0x63, 0x2f, 0xce, 0x18, + 0x88, 0x90, 0x93, 0xd9, 0x6a, 0x17, 0x7d, 0x92, 0xee, 0x35, 0x02, 0x7d, 0x39, 0xc2, 0x55, 0xcf, 0x9b, 0x13, 0xca, + 0x6c, 0x35, 0xd2, 0x51, 0x50, 0x9a, 0x21, 0x8a, 0xe1, 0x29, 0x12, 0x07, 0x9e, 0xe6, 0xc4, 0x61, 0xc2, 0x00, 0x25, + 0x6c, 0x73, 0xa2, 0x8b, 0xf6, 0x9f, 0x61, 0x96, 0xef, 0x59, 0xc6, 0x96, 0xe6, 0xd1, 0x80, 0x14, 0x01, 0x26, 0x95, + 0x62, 0x15, 0xff, 0x60, 0x2e, 0x1c, 0x0f, 0x13, 0x83, 0xc9, 0xcf, 0xb0, 0x0f, 0xe5, 0x4d, 0x0f, 0x2f, 0x8f, 0xca, + 0x81, 0x34, 0xb1, 0x4a, 0x3d, 0x45, 0x6b, 0xa4, 0x76, 0xdb, 0x0d, 0x6c, 0xb9, 0xd2, 0x0d, 0xd5, 0xf8, 0xa2, 0x08, + 0x46, 0xff, 0x52, 0x03, 0xe1, 0xe3, 0x93, 0x18, 0x63, 0x30, 0x29, 0x7a, 0x53, 0x3b, 0x30, 0xed, 0x9b, 0x52, 0x75, + 0x2d, 0x80, 0x8f, 0x4d, 0x15, 0xf8, 0xcf, 0xc1, 0x29, 0x22, 0xe6, 0xce, 0x58, 0x4c, 0x56, 0x67, 0x50, 0x97, 0xfb, + 0xdf, 0x0f, 0x1d, 0x41, 0xd8, 0xbf, 0x4e, 0xe7, 0xe8, 0x2c, 0x40, 0x26, 0x7b, 0xe0, 0x82, 0x58, 0x2a, 0xc6, 0x31, + 0x8f, 0x46, 0x84, 0xa5, 0x22, 0x6b, 0xbc, 0x8f, 0x4b, 0x49, 0xf3, 0xb5, 0x0e, 0x1c, 0x10, 0x85, 0x83, 0xf9, 0xad, + 0x41, 0xdf, 0x42, 0xc8, 0xbc, 0xaa, 0x72, 0x00, 0xa8, 0x8b, 0x71, 0x31, 0xae, 0x25, 0x24, 0x23, 0x3f, 0xee, 0xa8, + 0x1d, 0xa3, 0xa1, 0xc9, 0xc7, 0xa7, 0xeb, 0x54, 0xd3, 0xbd, 0xfa, 0x87, 0x1a, 0x8a, 0xf9, 0x7b, 0x99, 0x18, 0x24, + 0x6a, 0x96, 0xec, 0xbd, 0xf8, 0xe9, 0x3c, 0x72, 0x9e, 0x9a, 0x9e, 0x1a, 0xc6, 0xac, 0x56, 0x37, 0x26, 0x5b, 0xa6, + 0x76, 0xe4, 0x0e, 0xb4, 0x3a, 0xe3, 0xeb, 0xf4, 0x06, 0xe2, 0x78, 0x2f, 0x24, 0x6e, 0x45, 0x47, 0x8a, 0xd2, 0x8f, + 0x2b, 0x23, 0xa0, 0x46, 0xd1, 0xa1, 0x2a, 0x99, 0xe6, 0x6f, 0x86, 0x5c, 0x55, 0x41, 0x87, 0x55, 0x50, 0x4d, 0x31, + 0x33, 0xcd, 0xca, 0xa1, 0x91, 0x06, 0x14, 0x4a, 0x69, 0x0c, 0x8a, 0x5a, 0xaa, 0x90, 0xec, 0x79, 0x89, 0xa5, 0xe7, + 0x38, 0x09, 0x1d, 0xca, 0xa6, 0x83, 0xe7, 0x51, 0xb8, 0x24, 0xec, 0x79, 0xcd, 0x0c, 0xd3, 0x64, 0x2b, 0x2d, 0xab, + 0x5a, 0x54, 0x42, 0x21, 0xd7, 0xe7, 0xa5, 0x52, 0x9e, 0x46, 0xb8, 0x8d, 0xa7, 0x34, 0x5a, 0x45, 0xf9, 0x0a, 0xfb, + 0x38, 0xf9, 0x14, 0xf9, 0x77, 0xa0, 0xac, 0xbe, 0x14, 0x40, 0x06, 0x22, 0x09, 0x56, 0x02, 0xf9, 0x7e, 0xf1, 0x82, + 0x8b, 0xf0, 0x8b, 0x00, 0x5e, 0x45, 0xbc, 0xce, 0x74, 0x43, 0x9e, 0xaf, 0x7f, 0xfd, 0x9f, 0xea, 0xf5, 0x9f, 0x29, + 0x1c, 0x6e, 0x80, 0xf4, 0x06, 0xd2, 0x2c, 0xe8, 0x1f, 0xad, 0x57, 0x5f, 0xa9, 0x4b, 0x99, 0xbd, 0x8e, 0xc2, 0x77, + 0xb7, 0x74, 0x6d, 0xf4, 0x6c, 0x24, 0x42, 0xb3, 0x52, 0xfa, 0x5e, 0x48, 0x5a, 0x06, 0x6a, 0xe4, 0x8b, 0xbd, 0xd9, + 0x80, 0x69, 0x6b, 0x9c, 0xc2, 0xed, 0xbd, 0xa4, 0xc6, 0x5b, 0x8b, 0x13, 0xa0, 0xca, 0x62, 0x8a, 0xef, 0xd8, 0x79, + 0x20, 0xf7, 0xc1, 0xa3, 0x36, 0x7e, 0xbb, 0x73, 0x7b, 0x3e, 0x0d, 0xec, 0x12, 0x51, 0x0e, 0xa2, 0x6d, 0x58, 0x65, + 0xec, 0xf5, 0x45, 0x84, 0xcd, 0x65, 0x49, 0x83, 0x92, 0x0a, 0xbc, 0xf1, 0xca, 0x5d, 0xb8, 0xb9, 0x3d, 0x82, 0x00, + 0xfa, 0x4d, 0x13, 0xe6, 0x76, 0x88, 0x54, 0x18, 0x77, 0xe9, 0x71, 0x52, 0xe6, 0xf9, 0x77, 0x7a, 0x1c, 0x33, 0xc6, + 0xce, 0xcc, 0x33, 0xab, 0xd0, 0xd0, 0xb2, 0xa1, 0xf1, 0x53, 0xb0, 0x5b, 0x64, 0x14, 0x6b, 0x45, 0x01, 0xfb, 0xa0, + 0x14, 0x68, 0x79, 0x10, 0x8a, 0xea, 0x22, 0x3e, 0xc1, 0xf1, 0xe1, 0x8f, 0x86, 0x03, 0x25, 0x86, 0x16, 0x09, 0xb6, + 0xd8, 0x23, 0x1d, 0x36, 0xe5, 0xa6, 0xde, 0xa9, 0xb3, 0x0a, 0xe7, 0x4d, 0x63, 0x59, 0x07, 0xa5, 0xdf, 0xd5, 0xe3, + 0x75, 0xfd, 0x84, 0x37, 0xf8, 0x5b, 0x29, 0xd5, 0xe3, 0x17, 0xf5, 0x7e, 0x8d, 0x5d, 0xa5, 0x3a, 0x8c, 0xd1, 0xe2, + 0x4f, 0x26, 0xa4, 0x31, 0x2e, 0xec, 0xa1, 0x7e, 0x25, 0x1d, 0x7c, 0x41, 0xd9, 0xf5, 0xc8, 0xc6, 0x64, 0x3d, 0x28, + 0x80, 0xfb, 0xbc, 0x7f, 0xfb, 0xa8, 0x9f, 0x05, 0x39, 0x34, 0x22, 0x45, 0x4d, 0xfc, 0x6e, 0xc8, 0x4d, 0xaa, 0x51, + 0x10, 0xbb, 0x36, 0xa5, 0x76, 0x78, 0x0f, 0xb5, 0xf7, 0x6f, 0x32, 0xa8, 0x00, 0x6a, 0x7b, 0xd3, 0x8f, 0x65, 0x70, + 0x5a, 0x3d, 0x4d, 0x4e, 0x18, 0xa9, 0x01, 0x52, 0x53, 0xc4, 0x66, 0xc2, 0xca, 0xc5, 0xe7, 0xc0, 0x6c, 0xd6, 0xa4, + 0xb3, 0xf6, 0x16, 0x5c, 0x5a, 0x46, 0xdd, 0xef, 0x59, 0xb8, 0xfb, 0x58, 0x06, 0x9f, 0x17, 0x6e, 0xa9, 0x3b, 0x68, + 0x85, 0x2c, 0x46, 0xad, 0xdc, 0x84, 0x43, 0x7b, 0x55, 0x25, 0x30, 0xd6, 0x6f, 0xd3, 0xc6, 0x59, 0x2f, 0x70, 0x60, + 0xe8, 0xbd, 0x1f, 0xb8, 0xac, 0xfc, 0x14, 0x88, 0x61, 0x78, 0xd5, 0xbc, 0x39, 0xe6, 0x8c, 0x17, 0xef, 0x79, 0x7b, + 0x86, 0x73, 0xfb, 0x5c, 0xf1, 0x47, 0xcf, 0x37, 0x65, 0xa3, 0x7a, 0x92, 0x38, 0x33, 0xeb, 0x58, 0x52, 0xf5, 0xc8, + 0x50, 0x2e, 0xee, 0x01, 0xa0, 0x42, 0x32, 0x2a, 0x82, 0x48, 0x23, 0x8d, 0xf2, 0x53, 0xe5, 0x95, 0xea, 0x7d, 0xc2, + 0x44, 0x89, 0x80, 0x19, 0x7c, 0xff, 0xa4, 0xd2, 0x15, 0xbb, 0x1e, 0xe0, 0x1f, 0x11, 0x2b, 0x88, 0x68, 0x16, 0x49, + 0x28, 0x0a, 0x48, 0xc6, 0xef, 0x8e, 0xe5, 0x91, 0x9d, 0x49, 0x88, 0xe0, 0xa0, 0xee, 0x06, 0x08, 0x10, 0xf3, 0x35, + 0x42, 0xbb, 0xfc, 0x2b, 0x3d, 0xae, 0xd7, 0xac, 0x50, 0x87, 0x59, 0x76, 0xa1, 0x01, 0x6f, 0xb3, 0xe8, 0x97, 0xca, + 0x85, 0xef, 0xb5, 0x76, 0xb2, 0xbe, 0xbc, 0xfd, 0xb8, 0x5c, 0x93, 0xd2, 0xc1, 0xd2, 0x02, 0x50, 0xb2, 0xb1, 0xcc, + 0xc6, 0xa9, 0x5c, 0xb5, 0x5e, 0x59, 0x8a, 0xd2, 0x09, 0xc3, 0x76, 0x08, 0x29, 0x1e, 0x8c, 0x6a, 0xc4, 0xcc, 0xb1, + 0xa6, 0xc7, 0xbd, 0xf4, 0x60, 0x8f, 0x7b, 0x3f, 0x84, 0xce, 0x05, 0x3d, 0x62, 0x1e, 0x01, 0xe7, 0x65, 0xe5, 0xa9, + 0x90, 0x69, 0x42, 0x85, 0x38, 0x08, 0x20, 0x33, 0xae, 0x7b, 0x60, 0x4c, 0x99, 0x16, 0x3b, 0x2c, 0x26, 0xb3, 0x81, + 0x82, 0x90, 0x1b, 0x9b, 0x44, 0x0a, 0x39, 0x32, 0x89, 0xa5, 0x07, 0xf6, 0x33, 0x20, 0x6b, 0x3d, 0x8a, 0xd3, 0x9a, + 0x56, 0x44, 0x97, 0x22, 0x70, 0xb9, 0x91, 0xf2, 0x4d, 0x9f, 0xd0, 0x2b, 0x33, 0x47, 0xc3, 0xf7, 0xdb, 0x59, 0x09, + 0xc3, 0x72, 0x7c, 0xec, 0xec, 0x65, 0xfd, 0xe3, 0x39, 0x85, 0x6a, 0x6e, 0x67, 0x2e, 0x5f, 0x32, 0xf9, 0xef, 0x75, + 0x18, 0x48, 0x5e, 0x28, 0x7c, 0x56, 0x13, 0x88, 0xb4, 0x24, 0xa5, 0xe0, 0xad, 0xe1, 0xef, 0x45, 0x15, 0xc6, 0xfd, + 0x87, 0xef, 0xc2, 0xc5, 0x8d, 0xef, 0xaf, 0xea, 0xbe, 0x8a, 0xae, 0xbd, 0x11, 0x90, 0x74, 0xce, 0x96, 0x3b, 0x6c, + 0xa0, 0xd6, 0x5b, 0x84, 0xb2, 0xce, 0xeb, 0x8b, 0xfb, 0x9a, 0x3c, 0xbb, 0x6e, 0x3f, 0xee, 0x02, 0x8f, 0x98, 0xac, + 0xcd, 0xda, 0x42, 0xf3, 0xc8, 0x1a, 0xdc, 0xfd, 0x0c, 0xc3, 0x3d, 0x80, 0x1d, 0x4d, 0xdd, 0xe2, 0x17, 0xde, 0x8b, + 0xf4, 0x3e, 0x65, 0xab, 0xb7, 0xfa, 0xa7, 0xcd, 0x2f, 0x7f, 0x6e, 0x1c, 0x53, 0xa8, 0x61, 0xed, 0xa6, 0xba, 0x27, + 0x33, 0x7b, 0x30, 0x2d, 0x83, 0x14, 0xae, 0x74, 0xf5, 0x55, 0xc0, 0x51, 0xd0, 0x73, 0x42, 0x07, 0x9b, 0x28, 0x34, + 0x8f, 0x5f, 0x10, 0xaa, 0x64, 0xfe, 0xf1, 0x72, 0x65, 0x0c, 0x82, 0xf0, 0xb7, 0x23, 0xd6, 0x8a, 0xa8, 0xb3, 0x63, + 0x7f, 0xcc, 0xd5, 0x04, 0xbf, 0xa4, 0x1e, 0x8e, 0x16, 0xe1, 0x5f, 0xea, 0xb0, 0xdd, 0x61, 0x96, 0x1e, 0x68, 0xdc, + 0xec, 0x37, 0xf0, 0x8d, 0xe8, 0xcc, 0xc2, 0x8e, 0x2f, 0x4b, 0xb5, 0x43, 0x87, 0x43, 0xcd, 0xb0, 0x04, 0x7a, 0x1e, + 0x06, 0xe8, 0xa1, 0x1b, 0x7b, 0xbb, 0x54, 0x07, 0xe5, 0x20, 0x11, 0xbd, 0x87, 0x42, 0xe8, 0xd1, 0x5c, 0x9d, 0xf6, + 0xa8, 0x07, 0x6e, 0x2b, 0x0c, 0xc8, 0x83, 0xfe, 0xe0, 0x63, 0x56, 0x48, 0xd5, 0x45, 0x75, 0x1d, 0x35, 0xad, 0x31, + 0x23, 0x1f, 0xd3, 0x77, 0xbf, 0xbc, 0x21, 0xa2, 0x1d, 0x59, 0xaf, 0x31, 0xce, 0xb0, 0xf2, 0xa1, 0x4c, 0x85, 0x29, + 0xd5, 0x05, 0xdb, 0x63, 0x43, 0x7f, 0xd6, 0x76, 0x19, 0x59, 0xa1, 0x88, 0x8e, 0x60, 0xe1, 0x7f, 0x7c, 0x59, 0xdc, + 0x0a, 0x32, 0xb2, 0xe6, 0xb7, 0x25, 0x39, 0x63, 0x1c, 0xfa, 0xba, 0x5c, 0xce, 0xbb, 0x58, 0x3d, 0xfa, 0xf0, 0x24, + 0xa4, 0x48, 0xd6, 0x3e, 0x86, 0x56, 0x03, 0x43, 0x04, 0x21, 0xf9, 0x66, 0xad, 0xf5, 0x1c, 0x70, 0x12, 0xf3, 0xbb, + 0x0e, 0xec, 0xb7, 0xf3, 0x3c, 0xef, 0x10, 0x10, 0x20, 0xff, 0x1a, 0x62, 0x9c, 0x55, 0xd4, 0x3b, 0xd3, 0xa2, 0xaa, + 0x97, 0x8b, 0x59, 0x61, 0x4d, 0xc7, 0x98, 0x34, 0x54, 0x5e, 0xca, 0xa6, 0x52, 0x17, 0x32, 0x9a, 0xc7, 0x82, 0x7e, + 0x74, 0x79, 0x9d, 0xe1, 0xac, 0xa1, 0x3d, 0x4d, 0xbf, 0x19, 0x00, 0x23, 0x6d, 0x17, 0x61, 0xa2, 0x72, 0x58, 0x95, + 0x23, 0x23, 0x57, 0x59, 0x81, 0x8f, 0x32, 0x3e, 0x6f, 0xa0, 0x05, 0x2e, 0xac, 0x2e, 0x39, 0x92, 0x15, 0xa2, 0xa3, + 0xb8, 0xf1, 0x7e, 0x42, 0x0c, 0x1f, 0xc5, 0x4c, 0x74, 0xd2, 0x8c, 0x63, 0xde, 0xfd, 0x39, 0x08, 0xad, 0x39, 0xa2, + 0xc1, 0xc2, 0x5b, 0x1a, 0x72, 0x98, 0x25, 0xaf, 0xac, 0x48, 0x25, 0xc3, 0x6f, 0x05, 0x2a, 0xd3, 0x29, 0x44, 0x6b, + 0x5c, 0x02, 0xa7, 0xed, 0x27, 0xf3, 0x2e, 0x78, 0x66, 0x0a, 0xe7, 0xc2, 0xf1, 0x62, 0xc6, 0x9a, 0x12, 0x43, 0xb1, + 0x1c, 0x95, 0x0e, 0x79, 0xaa, 0x50, 0x77, 0xab, 0x88, 0x5a, 0x5b, 0xf7, 0x93, 0x7e, 0x52, 0x10, 0x4f, 0x5b, 0x82, + 0x8c, 0x9a, 0x1c, 0xef, 0x7a, 0x34, 0x7a, 0x62, 0x51, 0x6a, 0xa4, 0xb8, 0xf9, 0xee, 0x13, 0x96, 0x31, 0x02, 0xcf, + 0x55, 0x4a, 0x8e, 0x0d, 0x55, 0x99, 0xfd, 0x81, 0xfa, 0x66, 0x82, 0x83, 0xbd, 0x84, 0x22, 0xb5, 0x55, 0x72, 0x82, + 0xe9, 0x83, 0x2e, 0xe5, 0x78, 0x14, 0xf6, 0x8d, 0xda, 0xfc, 0x25, 0x82, 0x0b, 0x2c, 0xb9, 0xcb, 0xa7, 0x33, 0xb5, + 0x45, 0x79, 0x26, 0xb7, 0x88, 0xb8, 0x58, 0x87, 0xda, 0xa3, 0x86, 0x0c, 0xe2, 0x4d, 0xd7, 0x56, 0x0c, 0xc3, 0x27, + 0x29, 0x45, 0x38, 0xef, 0x8a, 0xc1, 0x7d, 0xdb, 0x35, 0xe6, 0x12, 0x8a, 0xc9, 0xdf, 0xdb, 0xfd, 0x2c, 0x2d, 0x15, + 0x5f, 0xb5, 0xdd, 0x1c, 0xe5, 0xf9, 0x23, 0x81, 0xee, 0x71, 0x2c, 0xb7, 0x37, 0x69, 0xe2, 0xb3, 0x3c, 0x7d, 0x9b, + 0x8d, 0xc1, 0x42, 0xfe, 0x7f, 0xb3, 0x14, 0x2f, 0xb0, 0x7a, 0x60, 0x52, 0x90, 0x3b, 0x1a, 0x53, 0xb9, 0x76, 0x6c, + 0x6c, 0x2b, 0xdf, 0x5d, 0x8c, 0x75, 0x32, 0xb5, 0xf2, 0x6d, 0xec, 0xd8, 0xf0, 0xab, 0x68, 0xbe, 0xbb, 0xd8, 0xac, + 0x2b, 0x5e, 0xdb, 0xea, 0x17, 0xdc, 0xf1, 0x9f, 0xc3, 0x71, 0xeb, 0x3c, 0x6f, 0x1e, 0x47, 0x1f, 0xf7, 0x6c, 0xdf, + 0xa5, 0x45, 0x88, 0xf5, 0x97, 0x8c, 0x3d, 0x52, 0xe7, 0xc7, 0xc4, 0xdb, 0xf1, 0xb5, 0xdf, 0xae, 0xe3, 0x88, 0x3a, + 0x55, 0xfe, 0x87, 0x85, 0xe9, 0x53, 0xb3, 0x1e, 0xed, 0xf9, 0x34, 0x4d, 0xdf, 0x09, 0xd2, 0x6d, 0x9a, 0xa6, 0xbf, + 0x15, 0x1d, 0x6d, 0xbc, 0x58, 0xd7, 0x80, 0xd9, 0x3b, 0xa0, 0x6e, 0xf6, 0x41, 0xac, 0xe4, 0x58, 0x62, 0x31, 0xac, + 0xf5, 0x38, 0x6c, 0x44, 0xde, 0x34, 0xfa, 0xe0, 0x62, 0x61, 0x62, 0x07, 0x8c, 0xfc, 0x18, 0x16, 0x86, 0x0e, 0x49, + 0x55, 0xdb, 0x35, 0x7e, 0x38, 0xa9, 0x8f, 0xb0, 0x30, 0x56, 0x13, 0xd9, 0xff, 0x2c, 0xc8, 0x7b, 0x50, 0x60, 0x8b, + 0xeb, 0x4e, 0xe3, 0x52, 0x3a, 0xf0, 0xe5, 0x2b, 0x41, 0x33, 0x39, 0xa0, 0x49, 0x6f, 0x31, 0xb6, 0x73, 0x9e, 0x44, + 0x2f, 0x0e, 0x29, 0x4d, 0xa1, 0x88, 0xae, 0xaa, 0xa4, 0xa9, 0x2d, 0xfb, 0x38, 0x1a, 0xac, 0x7d, 0xe9, 0x70, 0xf4, + 0x58, 0x01, 0xc3, 0xca, 0x7f, 0xa7, 0x29, 0x07, 0xea, 0x2e, 0xd8, 0x7c, 0xf4, 0x15, 0x0e, 0x13, 0x7c, 0x1d, 0x34, + 0x59, 0x59, 0xa2, 0x9b, 0xda, 0x50, 0x78, 0x4c, 0xfb, 0x6d, 0x0c, 0x38, 0x54, 0xe1, 0x25, 0x37, 0x61, 0xd5, 0x2d, + 0xc7, 0xfd, 0xad, 0x4c, 0x78, 0xb9, 0x1d, 0x26, 0x5b, 0xc3, 0xd6, 0x40, 0x3c, 0x63, 0x18, 0x0c, 0xa2, 0xa1, 0xc5, + 0x25, 0x89, 0x57, 0x30, 0x6b, 0x64, 0xcf, 0x45, 0xa3, 0x64, 0x58, 0x63, 0xdc, 0x98, 0x50, 0xf1, 0x7a, 0x21, 0x86, + 0xf3, 0x69, 0x9a, 0xa6, 0x28, 0x1f, 0x58, 0x70, 0x83, 0x05, 0xad, 0x0a, 0x87, 0x03, 0x9a, 0x6d, 0x8b, 0x46, 0x8b, + 0xd2, 0xa4, 0x4d, 0x25, 0x9d, 0xc4, 0x57, 0xfa, 0xb9, 0x8c, 0x75, 0xb6, 0xaa, 0x26, 0x8c, 0x38, 0xda, 0x0f, 0x8d, + 0x52, 0x75, 0x10, 0xa1, 0x3a, 0x00, 0xce, 0x26, 0x18, 0xf0, 0xd0, 0x46, 0xf7, 0x03, 0x54, 0x17, 0x32, 0xb4, 0x6b, + 0x58, 0xe4, 0xba, 0x99, 0x38, 0xe2, 0x95, 0x7e, 0xa6, 0x6f, 0xe7, 0x68, 0x68, 0x23, 0x49, 0x9b, 0x20, 0x46, 0x1c, + 0xcd, 0x98, 0xfe, 0x60, 0xd3, 0x46, 0xfb, 0xd8, 0xc4, 0x83, 0x1d, 0xf4, 0x72, 0x5c, 0x90, 0x46, 0x9f, 0x55, 0x72, + 0x50, 0xb8, 0x0c, 0xac, 0x79, 0x25, 0xe5, 0xde, 0x2f, 0xf6, 0x65, 0xac, 0xf1, 0xad, 0x5a, 0x99, 0xad, 0x9e, 0x31, + 0x12, 0x23, 0x7b, 0x21, 0x0c, 0x7e, 0x25, 0x7b, 0x3d, 0x6f, 0x79, 0x4d, 0x71, 0xdf, 0xcf, 0x21, 0x3b, 0x26, 0x0c, + 0x18, 0xe8, 0xa2, 0x4c, 0x4e, 0xbb, 0xfa, 0xe8, 0xd5, 0xe7, 0x77, 0xc3, 0xe5, 0x05, 0xe9, 0xf2, 0xc9, 0x5e, 0x47, + 0xae, 0xfb, 0xe1, 0xcf, 0xbc, 0x22, 0xd8, 0x8f, 0x95, 0xff, 0x1c, 0xa2, 0x88, 0x00, 0x60, 0x05, 0x89, 0x8d, 0x66, + 0x73, 0xb0, 0xaf, 0x8b, 0x8e, 0x76, 0x9d, 0xc8, 0x14, 0x95, 0xe1, 0x25, 0x7b, 0x11, 0x61, 0x17, 0xd1, 0x70, 0xb0, + 0x21, 0x6c, 0x62, 0x8b, 0x69, 0xe8, 0x62, 0xf9, 0x66, 0x7e, 0x5a, 0xe3, 0x76, 0xcc, 0xad, 0x43, 0xa1, 0x93, 0xd4, + 0xe8, 0x36, 0x03, 0x9f, 0xe3, 0x4f, 0xe1, 0x84, 0x63, 0x57, 0x69, 0x83, 0x0a, 0xcb, 0xb1, 0x59, 0xcd, 0xa3, 0x28, + 0x78, 0x3e, 0x5b, 0xe7, 0x50, 0xcc, 0x6d, 0x59, 0x2d, 0x58, 0x91, 0x23, 0xde, 0x71, 0xbd, 0x6e, 0xdb, 0x66, 0x17, + 0x9a, 0x1c, 0x51, 0x45, 0x0e, 0xcc, 0xb2, 0xa5, 0x02, 0x6a, 0xb1, 0xf0, 0xa4, 0x1d, 0x06, 0x13, 0xca, 0x22, 0x9e, + 0x5e, 0x74, 0x99, 0x2f, 0x4a, 0x93, 0xb2, 0x30, 0x17, 0x5b, 0x61, 0x0e, 0x6c, 0xed, 0x23, 0x6b, 0x18, 0x2c, 0x25, + 0x20, 0x8d, 0x7d, 0x58, 0xde, 0xa2, 0x4d, 0xb9, 0x63, 0xb4, 0xff, 0x99, 0xa5, 0xf6, 0xb1, 0x4b, 0x9b, 0x94, 0xbe, + 0xea, 0x0f, 0x2b, 0x13, 0x3e, 0x74, 0xfd, 0xaa, 0xdf, 0x6c, 0x8e, 0x4d, 0x50, 0x3f, 0x84, 0x2f, 0x49, 0x26, 0x35, + 0x38, 0x58, 0x18, 0xe8, 0xad, 0x0a, 0x1b, 0x83, 0x35, 0x01, 0x8f, 0xd2, 0x25, 0xd2, 0x44, 0x5c, 0x1b, 0x15, 0x55, + 0xf9, 0x22, 0x6b, 0xaf, 0xf4, 0x92, 0x7d, 0x40, 0xf0, 0xc6, 0x77, 0xb7, 0xd5, 0x68, 0xf8, 0x8e, 0x35, 0x89, 0x72, + 0x10, 0x1f, 0x56, 0xc3, 0x93, 0x66, 0x70, 0xf9, 0x8b, 0x76, 0xe2, 0x53, 0xb2, 0x5b, 0x5c, 0xa0, 0x81, 0xb3, 0xe0, + 0xe8, 0x9f, 0x11, 0xac, 0xaa, 0xab, 0xc8, 0x6a, 0xb3, 0x21, 0x41, 0x34, 0x0d, 0x96, 0x31, 0xb3, 0x36, 0x47, 0xd5, + 0x26, 0xb1, 0xc6, 0x38, 0x1a, 0xaf, 0xff, 0xce, 0x26, 0xf0, 0xf2, 0xac, 0x41, 0x7b, 0xe2, 0xba, 0xed, 0x52, 0x8b, + 0xc7, 0xe3, 0x3f, 0x1f, 0x79, 0x4c, 0xe0, 0xa0, 0xc5, 0x50, 0xcc, 0x0e, 0xc7, 0x7a, 0xd5, 0xe9, 0x55, 0x7c, 0x15, + 0x7a, 0x68, 0x7d, 0xbd, 0x99, 0xa0, 0xc8, 0xd1, 0x16, 0x66, 0xd9, 0xcc, 0x8d, 0x64, 0x6b, 0x8e, 0xbe, 0x59, 0x5b, + 0x24, 0x7b, 0x07, 0x0d, 0x96, 0x33, 0xf1, 0xc5, 0xa7, 0xd8, 0xbc, 0xd3, 0xd6, 0x31, 0x79, 0xc2, 0xb0, 0x23, 0xf8, + 0xa2, 0xa3, 0x2d, 0xc6, 0xe0, 0x7a, 0x8b, 0x75, 0xec, 0x61, 0x82, 0x94, 0x60, 0xb6, 0x00, 0x17, 0x1d, 0x3b, 0x9f, + 0x0c, 0x5f, 0xc5, 0xde, 0x19, 0xb0, 0x0d, 0xb4, 0x90, 0x3d, 0xf9, 0x45, 0x29, 0x4d, 0xe3, 0xe5, 0x53, 0x8b, 0xe0, + 0xc7, 0x0d, 0x75, 0x4e, 0xe5, 0xff, 0x8c, 0x46, 0x29, 0xe5, 0x49, 0x3a, 0xa1, 0x86, 0xc7, 0x56, 0xc0, 0x00, 0xb5, + 0xec, 0x59, 0xa5, 0xcf, 0x3e, 0xa4, 0x09, 0x5b, 0x88, 0x2d, 0xa9, 0xba, 0x79, 0x82, 0xf8, 0x3b, 0xbc, 0xe2, 0x22, + 0x06, 0x18, 0x39, 0xac, 0x1b, 0xfd, 0xe3, 0x16, 0xe9, 0x3c, 0x9e, 0xae, 0x0f, 0xe6, 0x84, 0xa3, 0xe1, 0xd7, 0x23, + 0x55, 0x26, 0x36, 0x1f, 0xaf, 0xdb, 0xd7, 0xa4, 0xb0, 0x83, 0x97, 0x4a, 0xb6, 0x7f, 0x1d, 0xbd, 0xf5, 0x66, 0x2b, + 0x23, 0x56, 0x24, 0xaf, 0x9c, 0xa2, 0x0a, 0xfa, 0xd5, 0xa7, 0xac, 0x92, 0x41, 0x0d, 0x18, 0xd6, 0x90, 0x51, 0x8d, + 0x18, 0xd7, 0x98, 0xcf, 0x04, 0x95, 0xcf, 0x0d, 0x3d, 0x5f, 0x18, 0x06, 0x2e, 0x0c, 0x23, 0x97, 0x82, 0x89, 0x57, + 0x86, 0x46, 0x85, 0x51, 0x4d, 0xab, 0x59, 0x35, 0xaf, 0xea, 0x4a, 0xd1, 0x1f, 0xf4, 0x6f, 0x81, 0xf1, 0x2f, 0x08, + 0x30, 0x1f, 0x62, 0xb2, 0xbc, 0x96, 0xed, 0x9b, 0xe7, 0x2f, 0x16, 0xcb, 0x2b, 0x18, 0x39, 0x42, 0x49, 0xeb, 0xb3, + 0x5f, 0xd4, 0x19, 0xda, 0xce, 0x01, 0xf4, 0x2d, 0x5d, 0xca, 0x78, 0x52, 0xec, 0xf7, 0x3f, 0xdf, 0xbb, 0xfd, 0x13, + 0xcf, 0x43, 0x5c, 0x36, 0xbe, 0x2c, 0x89, 0x7b, 0xec, 0x83, 0xdd, 0x06, 0x2d, 0x5e, 0x5c, 0x98, 0x51, 0x59, 0x5e, + 0xf4, 0xcc, 0xbb, 0x79, 0xcc, 0x82, 0xa1, 0x4e, 0xed, 0xa1, 0xe6, 0x5a, 0xf1, 0xf6, 0x07, 0x1d, 0xd6, 0x53, 0x71, + 0x6a, 0x25, 0xfb, 0xe6, 0x04, 0x56, 0xa2, 0x69, 0x26, 0xfe, 0x8c, 0xaa, 0x7f, 0xb0, 0xb2, 0x6b, 0x1d, 0xb2, 0x71, + 0xb3, 0xd3, 0xdb, 0x1f, 0xf5, 0x02, 0xf7, 0x71, 0x8d, 0x2b, 0x4b, 0xe0, 0x2c, 0x5f, 0x48, 0x67, 0x45, 0x53, 0x09, + 0x05, 0x68, 0x67, 0x7c, 0xc0, 0x4a, 0x46, 0xd0, 0x9f, 0x0d, 0xfd, 0x78, 0xed, 0x2e, 0xec, 0x14, 0xf9, 0xed, 0xdd, + 0xd3, 0x9d, 0xff, 0x09, 0x27, 0x94, 0x09, 0x8b, 0x44, 0xc5, 0x9f, 0x49, 0x17, 0x49, 0x2f, 0x50, 0xc5, 0xcd, 0xc4, + 0x99, 0x30, 0xd9, 0x8b, 0xb0, 0xd8, 0xed, 0x63, 0x53, 0x02, 0x2e, 0x50, 0x7f, 0xcc, 0x4f, 0x59, 0x3d, 0x8d, 0xa7, + 0xdf, 0xbd, 0x6c, 0x2a, 0x7a, 0xa3, 0xa7, 0xc5, 0x27, 0xff, 0xaa, 0xff, 0x96, 0x7a, 0x3b, 0xcb, 0xcd, 0x7c, 0xbf, + 0x26, 0x85, 0x3f, 0x98, 0x5c, 0xf5, 0x8e, 0x2f, 0x67, 0x9d, 0x87, 0x8d, 0xf3, 0x59, 0xe5, 0x6d, 0xea, 0xe6, 0x52, + 0xaa, 0xd4, 0xc6, 0x06, 0x9b, 0xdc, 0xfc, 0x53, 0xd5, 0x1e, 0x6d, 0x55, 0xb2, 0xfd, 0xf5, 0x38, 0xee, 0xc7, 0x77, + 0xfa, 0x0b, 0xfc, 0x92, 0x5e, 0x9a, 0xd9, 0x74, 0x7e, 0xfc, 0x73, 0x2b, 0xdd, 0x64, 0xf5, 0xcf, 0xe3, 0x52, 0xb7, + 0x54, 0x9b, 0xd4, 0x34, 0x8f, 0xba, 0x66, 0xc4, 0x03, 0xb4, 0xa6, 0xb7, 0x77, 0x3f, 0x65, 0xf5, 0x37, 0xea, 0xa4, + 0xda, 0xc3, 0xfd, 0x5f, 0x93, 0x37, 0x5b, 0x73, 0x31, 0x22, 0x85, 0xb1, 0x78, 0x3b, 0xa0, 0x7a, 0xbf, 0x7b, 0x0e, + 0xe9, 0xdc, 0xf8, 0x4f, 0x4f, 0x08, 0x12, 0xb3, 0x20, 0xf9, 0x7a, 0x7f, 0x43, 0xf1, 0xe0, 0x03, 0x4a, 0x7d, 0x0c, + 0xad, 0x0f, 0xfc, 0x6f, 0x9e, 0xc3, 0x1b, 0x8c, 0x5d, 0xa6, 0x03, 0xb7, 0xdc, 0x5c, 0xe8, 0xe7, 0x2f, 0xc4, 0x59, + 0x10, 0xee, 0xe1, 0x8b, 0xa9, 0x1d, 0x8c, 0x41, 0x39, 0x71, 0x04, 0x0e, 0xbe, 0x1d, 0x08, 0x93, 0x40, 0x7c, 0xbd, + 0xbf, 0xad, 0x78, 0xc8, 0x85, 0xdd, 0xcb, 0xfb, 0xd5, 0x9c, 0x4f, 0xdc, 0x71, 0x69, 0x57, 0x9f, 0x8e, 0x4f, 0xb2, + 0xdb, 0x3d, 0x0b, 0xaa, 0xdb, 0x39, 0xb7, 0x5b, 0x3e, 0x41, 0xd9, 0x2f, 0x3a, 0x52, 0xc3, 0x66, 0x35, 0xb4, 0x8c, + 0x7a, 0xd3, 0xfb, 0xf4, 0xb4, 0x70, 0xad, 0xe1, 0x2e, 0x80, 0x7f, 0x66, 0x40, 0xf6, 0x26, 0xc4, 0xde, 0x04, 0x84, + 0x6c, 0x33, 0xe3, 0x76, 0x33, 0x3e, 0x4e, 0x5e, 0xb3, 0x94, 0xb5, 0x77, 0x4e, 0x83, 0xf3, 0xb8, 0xde, 0x79, 0x5d, + 0xf9, 0x58, 0x94, 0x5c, 0xdd, 0xf1, 0x3a, 0x7d, 0xda, 0x43, 0xbe, 0x6f, 0x79, 0x2f, 0x49, 0x34, 0x38, 0x06, 0xf6, + 0xa2, 0x23, 0xa6, 0xb7, 0x2b, 0x43, 0x64, 0xda, 0x87, 0x31, 0xd4, 0x3d, 0xa9, 0xba, 0x14, 0x56, 0x5f, 0xf6, 0x4d, + 0x8d, 0x79, 0x2d, 0x8b, 0xad, 0x83, 0xae, 0xa6, 0x7b, 0x32, 0x63, 0x77, 0xcc, 0xb8, 0x8a, 0x99, 0xc1, 0x4e, 0x2f, + 0x9d, 0x11, 0x07, 0x2d, 0x1c, 0xfa, 0x23, 0x8b, 0xf7, 0xc9, 0xa8, 0x3b, 0x03, 0x43, 0xb5, 0x98, 0xbe, 0xcd, 0x56, + 0x0e, 0x98, 0x2d, 0x11, 0xe4, 0x35, 0x34, 0xbf, 0xd7, 0x14, 0x06, 0x3b, 0x85, 0x69, 0x63, 0xbd, 0xbd, 0x4b, 0xe5, + 0x52, 0x18, 0x88, 0x7a, 0xcf, 0x7d, 0x11, 0xd6, 0x3e, 0x28, 0x6e, 0xb0, 0x65, 0x82, 0xfd, 0xa2, 0xc4, 0xfe, 0x6d, + 0x3d, 0xcf, 0x0d, 0xec, 0xe2, 0x75, 0x61, 0x73, 0xd1, 0x52, 0x99, 0x22, 0x56, 0xa5, 0xe8, 0xb3, 0xfd, 0xbd, 0x72, + 0x36, 0x2a, 0x39, 0x5d, 0x4f, 0xe0, 0x2c, 0xa8, 0xba, 0xeb, 0xaa, 0x5d, 0xd1, 0x5c, 0xb6, 0x40, 0xef, 0x16, 0x38, + 0xbd, 0x3c, 0x49, 0xcb, 0xb3, 0x4d, 0x91, 0xc4, 0x52, 0x7a, 0xff, 0x89, 0xaf, 0x12, 0xf5, 0xe3, 0xec, 0xf1, 0xec, + 0x1b, 0xe1, 0x74, 0x83, 0xd3, 0xbc, 0x2c, 0x7f, 0xa6, 0x39, 0x7f, 0x57, 0xd1, 0x67, 0x96, 0xf5, 0xfc, 0xf6, 0xd1, + 0x23, 0x10, 0x4b, 0x13, 0xd8, 0x6b, 0x4b, 0xfd, 0x67, 0x6c, 0xfb, 0x10, 0xd3, 0x46, 0xd8, 0x68, 0x3c, 0xda, 0x04, + 0x9c, 0xb7, 0xf7, 0x6e, 0xe4, 0xdd, 0x75, 0x4b, 0x02, 0xae, 0xf1, 0x9e, 0xaf, 0xf9, 0xfe, 0x5e, 0xdb, 0x8a, 0x69, + 0xca, 0xb6, 0x62, 0x3e, 0xfb, 0xea, 0x5a, 0xc4, 0xdc, 0xb8, 0xdc, 0xc0, 0x5e, 0x55, 0x6b, 0xb5, 0x20, 0xd9, 0xde, + 0x87, 0x79, 0xfe, 0xd0, 0xcd, 0xec, 0xf0, 0x0c, 0x1e, 0xb5, 0x81, 0xc4, 0xcf, 0xfd, 0xf4, 0xeb, 0xd1, 0x54, 0xd6, + 0x17, 0x40, 0x98, 0x98, 0x11, 0x89, 0x4f, 0x1c, 0xdf, 0x17, 0x9e, 0x6f, 0xab, 0xf6, 0xdb, 0xe1, 0x3c, 0x6b, 0x8e, + 0x6c, 0x93, 0xee, 0x3e, 0x72, 0xb3, 0xf2, 0x03, 0x7a, 0xd5, 0x34, 0x45, 0x5c, 0xab, 0xfe, 0x89, 0x05, 0xd4, 0x52, + 0xe0, 0x40, 0x9e, 0xba, 0x9a, 0x28, 0x04, 0x3e, 0xe1, 0xf5, 0xf9, 0x4e, 0x01, 0xe8, 0xee, 0x45, 0xd0, 0x8c, 0x04, + 0xaf, 0x05, 0x15, 0x57, 0x75, 0x15, 0xcc, 0x56, 0xae, 0x12, 0x8c, 0xf5, 0x07, 0x0a, 0x9a, 0x27, 0xa5, 0x4c, 0x2a, + 0xa0, 0x07, 0xe4, 0x27, 0x1f, 0x55, 0xf1, 0x01, 0xcf, 0x35, 0x89, 0x5e, 0xaf, 0xe2, 0x9a, 0x38, 0x31, 0xa8, 0xc1, + 0xfd, 0x93, 0xaa, 0xf5, 0xa7, 0x62, 0x63, 0xc0, 0xc6, 0x1f, 0xa8, 0xcb, 0xed, 0xe1, 0x34, 0x2b, 0x49, 0x3a, 0x87, + 0x80, 0x1b, 0xd6, 0xf4, 0x18, 0xd5, 0x75, 0x1c, 0x60, 0xfa, 0xa3, 0xf4, 0x3d, 0xa2, 0xc3, 0x4d, 0x34, 0xdf, 0x0e, + 0xe4, 0x26, 0xdf, 0x0c, 0xbe, 0xd1, 0xc6, 0x7f, 0x1c, 0x7c, 0x35, 0xe8, 0xab, 0xe1, 0x8b, 0xc1, 0xe3, 0x6b, 0x6f, + 0xf8, 0x6c, 0xb0, 0xdd, 0x0d, 0x9f, 0x0c, 0xfe, 0x43, 0x7d, 0xaf, 0xfe, 0x68, 0x10, 0x5e, 0x55, 0xfc, 0x7a, 0xa7, + 0x2b, 0x0e, 0x7a, 0x1f, 0x4e, 0x75, 0xaf, 0xca, 0x7b, 0x6f, 0xf7, 0xee, 0x9d, 0xea, 0x67, 0xef, 0x3e, 0xdc, 0x3f, + 0xb5, 0x35, 0xef, 0x01, 0x50, 0xff, 0x54, 0x30, 0xef, 0xdd, 0xa9, 0x66, 0xf5, 0xde, 0x5e, 0x1d, 0xdf, 0xfd, 0xff, + 0xef, 0xbb, 0x86, 0xf7, 0x1e, 0x9e, 0x7a, 0x5a, 0x56, 0xde, 0x54, 0x7a, 0x9b, 0xf7, 0xfa, 0x5f, 0xcb, 0xea, 0x65, + 0x78, 0x65, 0xd0, 0x56, 0xc3, 0x4b, 0x83, 0xba, 0x1a, 0x5e, 0x18, 0x1c, 0x6a, 0xdb, 0x76, 0x86, 0x47, 0x06, 0x6e, + 0x35, 0x3c, 0x37, 0x58, 0x3f, 0x0d, 0x8f, 0x0d, 0xaa, 0xfd, 0xf7, 0x60, 0x78, 0x62, 0xe0, 0x66, 0xc3, 0xd3, 0xc2, + 0xbc, 0xad, 0xf7, 0xec, 0x9f, 0x89, 0x97, 0xa7, 0x31, 0x76, 0xe8, 0xb0, 0xcf, 0xb5, 0xfb, 0x2d, 0xc4, 0xbc, 0x5d, + 0x8e, 0x5d, 0x75, 0x6a, 0x03, 0x36, 0xea, 0x7f, 0x33, 0x2d, 0x37, 0x9c, 0xf0, 0x1b, 0x81, 0x04, 0x96, 0x67, 0xe7, + 0x0a, 0x30, 0xb5, 0x1f, 0x7a, 0x3c, 0x67, 0x60, 0x6a, 0x25, 0x2b, 0x46, 0xae, 0x62, 0xde, 0x9e, 0xfa, 0x3f, 0xf7, + 0x6d, 0x16, 0x6b, 0x94, 0x20, 0x3d, 0xe4, 0x0f, 0xf1, 0xe3, 0x23, 0x37, 0x84, 0x0e, 0xa3, 0x9f, 0x36, 0x29, 0xef, + 0x02, 0xfc, 0xad, 0x25, 0x39, 0xd0, 0xd7, 0x6e, 0x9f, 0x08, 0xdf, 0x82, 0xb3, 0x88, 0x33, 0x2e, 0x24, 0x22, 0x43, + 0x5c, 0xbd, 0xfe, 0x97, 0xab, 0xee, 0x28, 0x36, 0x9e, 0x69, 0x51, 0xfa, 0xc4, 0xfb, 0x62, 0x9b, 0xe4, 0x98, 0x69, + 0x6e, 0xc4, 0x3c, 0x8d, 0xeb, 0xe2, 0x1c, 0x36, 0x43, 0xa2, 0x72, 0x7b, 0x12, 0x5e, 0x22, 0x85, 0x8f, 0xe4, 0xea, + 0x05, 0xf6, 0x9e, 0x60, 0x8a, 0xfd, 0x1c, 0x98, 0xe5, 0x0c, 0xaa, 0x9c, 0xec, 0x40, 0x38, 0x62, 0x52, 0x8d, 0xbf, + 0x52, 0x1e, 0xdf, 0x8e, 0xaa, 0x3c, 0x0e, 0x80, 0xa8, 0xfd, 0x06, 0xde, 0x81, 0x50, 0x99, 0x72, 0xc8, 0x62, 0x82, + 0x17, 0xf4, 0x78, 0xd1, 0x00, 0xaf, 0x64, 0xc2, 0x6f, 0x6b, 0xe5, 0x96, 0xe0, 0x6c, 0x33, 0x32, 0x61, 0x02, 0x66, + 0x57, 0xb0, 0x0a, 0xe2, 0x7f, 0xd9, 0x93, 0x5e, 0x01, 0xa4, 0x40, 0x8b, 0x4d, 0xc3, 0xd0, 0xbd, 0xc4, 0x77, 0x6c, + 0x4c, 0xba, 0xc2, 0xb5, 0xf4, 0x1b, 0xd6, 0x26, 0xeb, 0x67, 0x40, 0xb1, 0xfb, 0xdb, 0x42, 0x1d, 0x80, 0xfe, 0x0b, + 0xa9, 0xfb, 0x97, 0x33, 0x5c, 0x74, 0xed, 0x22, 0x8a, 0x52, 0x4b, 0x0c, 0x0c, 0xb7, 0x11, 0x68, 0x3b, 0x0c, 0x1a, + 0xaf, 0xd3, 0x57, 0x22, 0xe1, 0x8b, 0x68, 0xa5, 0x5c, 0x78, 0x47, 0xb0, 0x83, 0x1a, 0x9d, 0xaa, 0x89, 0xe6, 0x8f, + 0xf2, 0x46, 0x5b, 0x58, 0x04, 0x61, 0xcb, 0x54, 0x8f, 0x14, 0x30, 0x9d, 0x07, 0xfd, 0x6f, 0x34, 0x7b, 0x49, 0xb5, + 0x84, 0x89, 0x7b, 0x7a, 0xcb, 0x7e, 0x42, 0x56, 0xfc, 0x53, 0x24, 0x8f, 0x9d, 0xa6, 0x3c, 0xf1, 0xc9, 0x79, 0x80, + 0x97, 0x5f, 0x8e, 0x80, 0xec, 0x9a, 0xa0, 0xc8, 0x87, 0xbc, 0xd0, 0x84, 0x89, 0x33, 0xe3, 0x11, 0xc1, 0x00, 0x93, + 0x05, 0xb8, 0xcd, 0xbe, 0xd5, 0x62, 0x3a, 0xe1, 0x80, 0xc9, 0x70, 0x59, 0xc9, 0x8b, 0x52, 0x9c, 0x8b, 0xda, 0xdc, + 0x6c, 0x8d, 0x67, 0x84, 0x21, 0x79, 0x73, 0x97, 0x76, 0x38, 0x18, 0x46, 0xfd, 0xad, 0x21, 0x57, 0x89, 0xd2, 0xbd, + 0x98, 0xb4, 0x2b, 0xd9, 0x95, 0x3e, 0xe9, 0x6c, 0x66, 0x3c, 0xbe, 0xf9, 0x6d, 0x48, 0x29, 0x50, 0xec, 0x0d, 0xe5, + 0xfa, 0x10, 0xbf, 0xb7, 0xda, 0x20, 0x7a, 0xe1, 0xf9, 0xf3, 0x53, 0xd0, 0x70, 0x16, 0x8c, 0x52, 0xe9, 0x00, 0x6d, + 0x10, 0x47, 0x67, 0x4d, 0x78, 0xd6, 0xc9, 0xed, 0xb3, 0x0b, 0xf1, 0x60, 0x55, 0x21, 0xe1, 0x0c, 0x9d, 0x7b, 0xda, + 0x58, 0xea, 0xcc, 0x30, 0x25, 0x31, 0x00, 0x1c, 0x01, 0x8f, 0xb6, 0xc3, 0x73, 0xea, 0x69, 0x2f, 0x05, 0xd0, 0x9b, + 0x3c, 0xef, 0xa7, 0x8f, 0x4a, 0xa5, 0x07, 0x3a, 0x8f, 0x5a, 0x7d, 0x76, 0x96, 0xd6, 0x97, 0x25, 0x64, 0x10, 0x18, + 0x8d, 0x1a, 0x9a, 0x2f, 0xa2, 0x72, 0xbf, 0x45, 0x0d, 0xd6, 0x52, 0x65, 0x8d, 0xbc, 0x79, 0xef, 0xfd, 0xc1, 0x35, + 0x6c, 0x76, 0x0d, 0x95, 0xbe, 0x1e, 0xd7, 0x1c, 0x94, 0x02, 0x73, 0x12, 0xe2, 0xd8, 0x16, 0xde, 0x9f, 0x8c, 0x70, + 0xbd, 0x7d, 0xa1, 0x66, 0x8b, 0x2d, 0x0e, 0x60, 0xee, 0x4f, 0x38, 0xe7, 0x92, 0xec, 0x78, 0xe7, 0x2c, 0x96, 0x5f, + 0xd7, 0x5a, 0x79, 0x49, 0xfe, 0xd5, 0x38, 0x3b, 0xb6, 0xac, 0x72, 0x56, 0x81, 0x10, 0x88, 0xfc, 0x74, 0x3a, 0x91, + 0x88, 0xd5, 0x16, 0x04, 0x8d, 0x14, 0x26, 0x84, 0x75, 0xf2, 0xee, 0xe8, 0x46, 0xbc, 0x75, 0x6a, 0x41, 0x66, 0xe2, + 0x40, 0x01, 0xa6, 0xe2, 0xd4, 0xda, 0x93, 0x3d, 0x03, 0x12, 0xec, 0xcb, 0x02, 0x96, 0x6a, 0x80, 0x5c, 0x48, 0x67, + 0xd2, 0x17, 0x44, 0xd1, 0x49, 0x63, 0x2e, 0xb9, 0x78, 0x0a, 0xd8, 0xd0, 0x29, 0x40, 0xa8, 0x34, 0x61, 0xd4, 0x73, + 0x7c, 0xb7, 0x26, 0xb5, 0xa3, 0x4e, 0x49, 0x98, 0xb9, 0x47, 0x0c, 0x9e, 0xcc, 0x9b, 0x0a, 0x71, 0xeb, 0xb3, 0x84, + 0x15, 0xeb, 0x7c, 0x08, 0xf8, 0x04, 0xc6, 0xdb, 0x88, 0xbd, 0x51, 0x1b, 0xf2, 0x06, 0xd6, 0x8f, 0x85, 0x11, 0x84, + 0x8d, 0x19, 0x26, 0xc7, 0x76, 0x83, 0x27, 0x81, 0x06, 0x58, 0xd8, 0x99, 0x9e, 0x13, 0x18, 0x78, 0x77, 0xd6, 0xda, + 0xd8, 0xf4, 0x7e, 0xd5, 0x89, 0x4a, 0xb5, 0x91, 0x59, 0xfe, 0x75, 0x01, 0x55, 0x5a, 0x5f, 0x01, 0xa8, 0x0a, 0xb8, + 0x88, 0xfc, 0xf1, 0x97, 0x9f, 0x27, 0xff, 0xda, 0x04, 0x19, 0x8c, 0xd8, 0x7c, 0x09, 0xb9, 0x41, 0x2d, 0xd8, 0xc8, + 0x77, 0x8c, 0xb9, 0x12, 0xab, 0xc2, 0x97, 0x30, 0x3c, 0x3f, 0xb5, 0xc3, 0x55, 0x1e, 0xd4, 0xa4, 0xc5, 0x47, 0x44, + 0x16, 0x26, 0x69, 0x79, 0x62, 0xa0, 0xa1, 0xaf, 0x84, 0xca, 0x2f, 0x2e, 0xae, 0xd1, 0xf8, 0x56, 0xf1, 0x18, 0x2c, + 0x3c, 0xbe, 0xe5, 0xda, 0x36, 0xd3, 0x46, 0xd9, 0x83, 0xa9, 0x91, 0xb9, 0xd2, 0x5b, 0xb5, 0xd1, 0x21, 0xae, 0xef, + 0xa1, 0x4d, 0x6e, 0xc2, 0x5e, 0xfc, 0x31, 0xa3, 0xac, 0xf6, 0x38, 0x5a, 0xbc, 0xc6, 0xc2, 0x15, 0x7e, 0x5d, 0x40, + 0xc1, 0xdb, 0xe9, 0x63, 0x87, 0x7e, 0x5c, 0xfa, 0x3a, 0x1c, 0x41, 0xa6, 0x4a, 0x54, 0x5c, 0x45, 0x50, 0x09, 0x51, + 0x0f, 0xd7, 0x00, 0x21, 0x4f, 0xe3, 0x4e, 0x34, 0x5a, 0xd5, 0xa6, 0xf4, 0x6a, 0xa4, 0x51, 0xe0, 0xec, 0x2e, 0xfa, + 0xb0, 0x12, 0x79, 0x4b, 0x95, 0x44, 0x0c, 0x94, 0x30, 0x45, 0xd6, 0xbf, 0x99, 0x38, 0x2b, 0x5b, 0xa2, 0x2a, 0x01, + 0x4c, 0x9d, 0x68, 0xc3, 0x4f, 0xbc, 0x11, 0x06, 0xaa, 0x48, 0xa6, 0x12, 0x09, 0x3a, 0x53, 0x65, 0x00, 0x25, 0x4d, + 0x40, 0x1d, 0xd3, 0xee, 0xc1, 0xc3, 0x0a, 0xcb, 0x4d, 0x96, 0x6b, 0x4c, 0x61, 0xb9, 0xbf, 0x7f, 0xca, 0xb3, 0x52, + 0x97, 0x71, 0x10, 0xb5, 0xf2, 0x34, 0xcd, 0x76, 0xaa, 0xaa, 0x84, 0x6e, 0xe3, 0x8a, 0xf3, 0x92, 0xb5, 0xc8, 0xfb, + 0x71, 0x36, 0x6d, 0x7c, 0x10, 0x34, 0x2c, 0x7a, 0xb7, 0xbc, 0x4c, 0xae, 0x24, 0xd6, 0x27, 0x98, 0x1d, 0x41, 0x66, + 0xd0, 0x49, 0x55, 0x2f, 0x48, 0x4a, 0x48, 0x50, 0xaa, 0x44, 0xfe, 0x47, 0xa5, 0xa4, 0x4e, 0xe2, 0xbe, 0x87, 0xf5, + 0x57, 0x95, 0xc5, 0x2b, 0x56, 0x68, 0xdc, 0xf7, 0xf5, 0xed, 0x24, 0xbf, 0x86, 0x11, 0x8a, 0x01, 0x10, 0x5f, 0x07, + 0x70, 0x84, 0x57, 0x2e, 0x9f, 0x8c, 0x60, 0x18, 0x85, 0x8a, 0x23, 0xd6, 0xb4, 0xc5, 0x95, 0xb8, 0x3c, 0x73, 0x05, + 0x23, 0x3c, 0xfc, 0xad, 0x8a, 0x1b, 0x88, 0x87, 0xaf, 0xdb, 0x80, 0x3e, 0x3e, 0xce, 0x97, 0xde, 0x0b, 0xfa, 0xd6, + 0x42, 0x93, 0x4c, 0x10, 0x67, 0xf3, 0x37, 0x8f, 0x97, 0xcd, 0x9e, 0x2f, 0xbf, 0x68, 0x1a, 0x25, 0x81, 0xbe, 0xe7, + 0x6a, 0xf2, 0xf8, 0x67, 0x91, 0x25, 0xc1, 0x21, 0x68, 0xf1, 0x66, 0x42, 0xe0, 0x8b, 0x5e, 0xb0, 0x6a, 0x56, 0x03, + 0xd3, 0x49, 0x71, 0x30, 0xba, 0xb6, 0x89, 0x3a, 0xc5, 0xea, 0x58, 0x9d, 0xd9, 0x11, 0x06, 0x95, 0x7a, 0x08, 0xd5, + 0x53, 0x3a, 0xd2, 0x9b, 0xaf, 0xe8, 0x47, 0xe1, 0xa6, 0xc4, 0xd7, 0xec, 0x52, 0x55, 0x0a, 0xab, 0xc0, 0x19, 0x88, + 0xae, 0x16, 0x1c, 0x27, 0x36, 0x74, 0xf5, 0x10, 0x2c, 0x1b, 0xc6, 0x06, 0x27, 0x6a, 0xa9, 0x42, 0xd1, 0x36, 0x1f, + 0xef, 0xf9, 0x5e, 0xe0, 0x43, 0xc2, 0xac, 0xf3, 0xe1, 0x81, 0x90, 0xed, 0x60, 0xdc, 0x65, 0xf4, 0x03, 0xaa, 0x3b, + 0x23, 0xe8, 0x35, 0xa6, 0xc7, 0xd4, 0x95, 0x44, 0x86, 0xf9, 0xf9, 0xa5, 0xc3, 0x5a, 0x67, 0xe0, 0xea, 0xa1, 0xfb, + 0x21, 0x35, 0x06, 0x35, 0xfc, 0xc1, 0xe8, 0x2a, 0x5c, 0xed, 0xee, 0x9b, 0xe9, 0x20, 0xb0, 0x55, 0x13, 0xa6, 0x66, + 0xc0, 0x34, 0x49, 0x91, 0x98, 0xac, 0x67, 0xd9, 0xd6, 0x8d, 0x7a, 0x5c, 0x50, 0x3e, 0xfb, 0x38, 0x69, 0xfb, 0xba, + 0xb2, 0x82, 0x34, 0x73, 0x21, 0x28, 0x63, 0xe8, 0xa8, 0x4f, 0xac, 0xb3, 0x1a, 0x41, 0x8e, 0x14, 0x96, 0xb6, 0x90, + 0x89, 0x62, 0xcd, 0x69, 0x57, 0x69, 0x5a, 0x59, 0xe2, 0x8f, 0xe9, 0x58, 0xe4, 0xc2, 0x26, 0x83, 0x96, 0x43, 0x29, + 0x4d, 0x9a, 0xf6, 0x4f, 0xf9, 0x44, 0xf8, 0xad, 0x44, 0xd6, 0xaf, 0x6f, 0xf0, 0xec, 0xd9, 0xed, 0x68, 0x03, 0x8c, + 0x97, 0xae, 0x91, 0x4e, 0xb1, 0x1e, 0x63, 0xb7, 0x7c, 0x8f, 0x91, 0xf0, 0x3d, 0x34, 0xd5, 0x57, 0xf9, 0x14, 0xe7, + 0x8e, 0xe8, 0x69, 0x63, 0xf9, 0x77, 0xcf, 0x6e, 0x41, 0xf9, 0x9a, 0xef, 0xb1, 0x20, 0x6d, 0xef, 0x73, 0x26, 0x95, + 0x2b, 0x4a, 0x0c, 0x39, 0x2a, 0xa9, 0xe0, 0x41, 0x03, 0x80, 0x59, 0x9d, 0x55, 0x8d, 0x06, 0x60, 0x17, 0xf9, 0x9d, + 0x52, 0x41, 0x86, 0x4b, 0x64, 0x81, 0x1b, 0x60, 0x7d, 0x00, 0x87, 0x32, 0x53, 0x32, 0x3c, 0x98, 0x5f, 0x61, 0x32, + 0x31, 0xd2, 0xef, 0x50, 0x1c, 0x8f, 0x3b, 0xde, 0xba, 0xe7, 0xa7, 0xa4, 0xd9, 0x69, 0x0f, 0x30, 0x37, 0x91, 0x3c, + 0x0b, 0x0b, 0xfb, 0x20, 0x67, 0xbf, 0x33, 0x0f, 0x84, 0xd1, 0x3a, 0x7f, 0xba, 0xd9, 0x4f, 0x4a, 0x24, 0x78, 0x48, + 0xa9, 0xed, 0xcd, 0x88, 0x72, 0x22, 0x73, 0x29, 0xf5, 0x8d, 0x6d, 0xab, 0x06, 0x53, 0xa4, 0x84, 0x41, 0xa7, 0x11, + 0xbd, 0xb6, 0xb1, 0xbb, 0xa3, 0xd1, 0xf9, 0x27, 0xaa, 0x05, 0x03, 0x99, 0xe1, 0x88, 0x03, 0x58, 0x13, 0xe1, 0x64, + 0x66, 0x67, 0x46, 0x16, 0x64, 0xde, 0x66, 0xee, 0xcf, 0xa4, 0xb9, 0x44, 0x74, 0x5b, 0x6d, 0xae, 0xc8, 0x0c, 0xd3, + 0x53, 0xdc, 0xbd, 0xad, 0xe4, 0xe8, 0xae, 0x77, 0x00, 0x5a, 0xe9, 0xc3, 0xf9, 0x5f, 0x8f, 0xe7, 0xc8, 0x68, 0xc0, + 0xeb, 0x39, 0x57, 0x41, 0xf3, 0x17, 0x38, 0x4f, 0x73, 0x6b, 0x6b, 0x62, 0xa4, 0x26, 0x73, 0x5a, 0xe5, 0xf9, 0x5e, + 0x46, 0x3f, 0x57, 0x8d, 0x3e, 0x6a, 0xe9, 0xd4, 0x6b, 0x90, 0x08, 0x95, 0x19, 0xf1, 0xe7, 0x92, 0xb7, 0x17, 0x10, + 0xdd, 0xa5, 0x12, 0xc6, 0xda, 0x09, 0x98, 0xb9, 0x17, 0xeb, 0x7c, 0x9e, 0x5e, 0x7f, 0x32, 0x69, 0x32, 0x5f, 0xee, + 0xde, 0x05, 0xf2, 0x8e, 0x13, 0x0c, 0x9f, 0x7d, 0x86, 0x21, 0xb2, 0xb8, 0xf8, 0xc5, 0xeb, 0xe9, 0xbd, 0x48, 0x40, + 0xef, 0x13, 0x66, 0x79, 0x4b, 0xc5, 0x2d, 0x98, 0x87, 0x5a, 0x1a, 0xcb, 0xcf, 0xe4, 0xf6, 0x8b, 0xde, 0x11, 0xec, + 0xbd, 0x17, 0x37, 0xbe, 0xfa, 0xbf, 0xb1, 0x67, 0x48, 0xec, 0x7f, 0x2e, 0x91, 0x8a, 0xab, 0xca, 0xdc, 0x8f, 0x25, + 0xa9, 0x82, 0xd5, 0x74, 0x9e, 0x22, 0x19, 0xec, 0xdd, 0x54, 0x83, 0x80, 0x4d, 0x91, 0x31, 0xed, 0x79, 0x80, 0xde, + 0xa0, 0xef, 0x2c, 0xc2, 0x46, 0x45, 0x11, 0xd3, 0x4f, 0x6a, 0x56, 0xe6, 0xe8, 0x74, 0x2c, 0x59, 0x39, 0xb0, 0xd3, + 0xef, 0x5e, 0x7c, 0xfb, 0x35, 0x52, 0xe5, 0xbd, 0xed, 0xdb, 0x59, 0x2b, 0x42, 0xd0, 0xf0, 0x21, 0xd3, 0xdb, 0xf3, + 0x3c, 0xcf, 0x55, 0x16, 0xf7, 0xf1, 0x77, 0x89, 0xc3, 0xc4, 0x28, 0x5b, 0xa3, 0x84, 0x27, 0x5a, 0xd0, 0xcb, 0x5f, + 0x34, 0x45, 0x83, 0xaf, 0x52, 0x14, 0x16, 0xe8, 0x55, 0x43, 0x8e, 0x96, 0xe5, 0xbb, 0x92, 0x06, 0xaa, 0x82, 0xeb, + 0x96, 0xc1, 0xc2, 0xdd, 0xa9, 0x90, 0xd6, 0xa9, 0xb9, 0x50, 0xb6, 0x4f, 0x25, 0xf8, 0x0f, 0xa9, 0xdd, 0x98, 0xa5, + 0x0a, 0xa9, 0x80, 0xea, 0x78, 0xc0, 0xdb, 0x1e, 0x03, 0x2d, 0x4f, 0x30, 0x7b, 0xaf, 0x95, 0x14, 0x83, 0x0a, 0x72, + 0x1b, 0x00, 0x5b, 0x6e, 0x08, 0xd7, 0xe0, 0xe9, 0x18, 0x44, 0xc2, 0x9d, 0x2f, 0x8b, 0xfe, 0xb7, 0x37, 0xf5, 0xac, + 0xfa, 0x4b, 0x86, 0x45, 0xf1, 0xde, 0xf4, 0x1f, 0xb5, 0x69, 0x08, 0x82, 0x6f, 0xa3, 0x44, 0xc4, 0x9f, 0xf9, 0x40, + 0xd5, 0x1a, 0x18, 0xeb, 0x3a, 0x0c, 0x1e, 0x48, 0x61, 0xb2, 0x65, 0x5a, 0x36, 0xa5, 0x4e, 0xdd, 0xc2, 0xee, 0x13, + 0x94, 0xb7, 0x41, 0xf5, 0x5e, 0x2a, 0x2b, 0x1f, 0x50, 0x04, 0x64, 0x45, 0x19, 0x94, 0x8a, 0x7b, 0xba, 0x9e, 0x55, + 0x6c, 0xc2, 0x4f, 0x2f, 0x2b, 0x67, 0xac, 0x83, 0x78, 0x29, 0xff, 0xeb, 0x51, 0xf9, 0x3d, 0xda, 0x1a, 0xea, 0x6b, + 0x51, 0x48, 0x98, 0xe3, 0x16, 0xe3, 0x07, 0x3b, 0x43, 0x27, 0x50, 0x4b, 0x29, 0x9f, 0x10, 0x5f, 0x1c, 0xa2, 0xb0, + 0x73, 0xa8, 0x50, 0x9b, 0x49, 0x08, 0x0b, 0xaf, 0x7e, 0x21, 0xbd, 0xec, 0x87, 0xe0, 0x5e, 0x71, 0x44, 0xaa, 0x4c, + 0xee, 0x58, 0xa7, 0xca, 0x6f, 0x10, 0x0b, 0xb3, 0xb7, 0xef, 0xfb, 0x7d, 0x1d, 0xfc, 0x9d, 0xfe, 0xc7, 0x4f, 0xf8, + 0x68, 0x4f, 0xfb, 0xd1, 0xce, 0xe7, 0x65, 0x40, 0xfd, 0xf1, 0xd4, 0xb4, 0x6d, 0x58, 0xd3, 0x6e, 0xb0, 0x48, 0x5f, + 0x93, 0x85, 0x99, 0x78, 0x68, 0xc6, 0xbf, 0x2d, 0xca, 0xfb, 0x94, 0xce, 0x56, 0x35, 0x83, 0xaa, 0x25, 0xff, 0xfa, + 0x57, 0x85, 0x0d, 0xc2, 0x34, 0x60, 0x27, 0x80, 0xd0, 0x17, 0x79, 0x3f, 0x73, 0x3d, 0x44, 0x08, 0xbe, 0x60, 0x00, + 0x77, 0x0e, 0x7d, 0x81, 0x3a, 0x87, 0xa1, 0x6a, 0xbd, 0x9c, 0xeb, 0xc8, 0x46, 0xcd, 0xf1, 0x6a, 0xd7, 0x47, 0x7f, + 0xa0, 0xef, 0xfd, 0x34, 0xf2, 0x67, 0x4b, 0x2d, 0xb8, 0x19, 0x37, 0xeb, 0x16, 0x70, 0x06, 0x67, 0xf1, 0x1c, 0x28, + 0xd3, 0x57, 0x83, 0x17, 0xe7, 0x32, 0x5a, 0x1b, 0x98, 0x82, 0x69, 0xe5, 0x86, 0x8b, 0xa2, 0x74, 0xec, 0xa8, 0x17, + 0xbb, 0xb6, 0x8a, 0x2e, 0xdd, 0x46, 0x8e, 0x72, 0xbe, 0x65, 0xef, 0x50, 0x95, 0xb0, 0xbe, 0x64, 0x13, 0x79, 0x17, + 0xd3, 0xcb, 0xab, 0xf3, 0x8a, 0x66, 0xbc, 0x6a, 0xcb, 0xda, 0x03, 0x11, 0x67, 0x42, 0xbe, 0xe8, 0x9e, 0xa2, 0x51, + 0xe0, 0xd0, 0x54, 0xed, 0xe2, 0xdf, 0x8f, 0xb8, 0xaa, 0x77, 0xbd, 0xf8, 0x37, 0xbb, 0x66, 0x5d, 0xcf, 0xc4, 0x80, + 0x51, 0x4e, 0xbe, 0x60, 0xe5, 0x30, 0xbc, 0xe2, 0x9e, 0xfa, 0xbe, 0x48, 0xcf, 0x33, 0xea, 0x55, 0x34, 0xb7, 0xef, + 0xd4, 0x9f, 0xe3, 0x59, 0xcd, 0xf5, 0x67, 0xdb, 0xb0, 0x87, 0x25, 0xef, 0xcb, 0xed, 0x93, 0x73, 0xd2, 0xaa, 0x53, + 0x4e, 0xa9, 0x5d, 0x78, 0x09, 0x8f, 0x6c, 0x6f, 0x68, 0x50, 0xe6, 0xce, 0xfa, 0xb4, 0x3b, 0xdc, 0x4f, 0x8e, 0x8a, + 0x32, 0x76, 0xc5, 0x61, 0x9f, 0x51, 0xd2, 0xfb, 0x8a, 0x9b, 0xc3, 0x10, 0x83, 0x53, 0x27, 0x50, 0x94, 0xf5, 0x08, + 0x2b, 0xcf, 0x03, 0xfb, 0xed, 0x8a, 0x9f, 0x81, 0x73, 0x98, 0xda, 0x6d, 0x4c, 0xee, 0xfa, 0x94, 0x4a, 0xee, 0xab, + 0x8a, 0xee, 0x23, 0xe3, 0x82, 0xbd, 0xc3, 0xfa, 0x83, 0x83, 0x3e, 0xe2, 0xb2, 0xc5, 0xc7, 0x8f, 0x59, 0x80, 0xbf, + 0xaa, 0xce, 0xfb, 0x86, 0x21, 0x14, 0x60, 0xb2, 0x4a, 0x4d, 0x1b, 0xc5, 0x4b, 0x86, 0xcd, 0xbd, 0x93, 0x8f, 0x4b, + 0xd4, 0x09, 0xee, 0xaf, 0xd1, 0xb2, 0xda, 0x0d, 0xf0, 0x79, 0x12, 0x4b, 0xcc, 0x89, 0xf6, 0xd8, 0x3f, 0xde, 0xac, + 0x66, 0xf2, 0x27, 0x66, 0xe8, 0x33, 0x54, 0x0b, 0xeb, 0x58, 0xfe, 0x20, 0xce, 0x4f, 0x7d, 0x7e, 0xbb, 0x24, 0xf9, + 0x9b, 0xa1, 0xc2, 0xc2, 0xa6, 0xb0, 0x82, 0xb0, 0x95, 0xaf, 0x2f, 0xec, 0x00, 0xea, 0xbd, 0xc9, 0xec, 0xfe, 0x0d, + 0xe3, 0xcb, 0x2e, 0xe1, 0xcb, 0xed, 0x12, 0xc5, 0xb2, 0x8b, 0xc3, 0x45, 0x2e, 0x23, 0x0a, 0x27, 0x1e, 0x8c, 0x80, + 0x17, 0x95, 0x75, 0xe0, 0x87, 0x75, 0xc4, 0xc7, 0xe7, 0x71, 0xb9, 0x20, 0x5a, 0x94, 0xe6, 0xcf, 0x83, 0x96, 0x25, + 0x1d, 0xd7, 0xf4, 0x4d, 0x74, 0x98, 0xd2, 0x04, 0x84, 0xec, 0xb1, 0x29, 0xf4, 0x63, 0x95, 0xa2, 0xba, 0x59, 0x3a, + 0x70, 0xe7, 0xc6, 0x76, 0xd5, 0x48, 0xf9, 0x5d, 0xbf, 0x4e, 0x77, 0xb2, 0x6b, 0xd9, 0x3f, 0x65, 0xc8, 0x7c, 0xd4, + 0x05, 0xf3, 0xc7, 0x99, 0x2a, 0x1d, 0x72, 0xed, 0xf5, 0x69, 0x57, 0x45, 0xd0, 0x14, 0xfb, 0x9f, 0x76, 0xf5, 0x92, + 0xee, 0x8b, 0x1f, 0x15, 0xd0, 0xea, 0xa2, 0x43, 0x8a, 0x1c, 0x18, 0xc3, 0x21, 0x61, 0xb8, 0x11, 0xb1, 0x6d, 0x48, + 0x82, 0xc7, 0xca, 0x29, 0xbc, 0x10, 0xf7, 0xc7, 0x91, 0x8a, 0x51, 0x15, 0xdd, 0xd8, 0xda, 0xd8, 0xc6, 0x66, 0x62, + 0x1e, 0xd7, 0x43, 0xf9, 0xab, 0x28, 0x93, 0x26, 0xb8, 0x1b, 0x0c, 0xea, 0xec, 0x79, 0xa2, 0x14, 0xb4, 0x99, 0xe9, + 0xb1, 0x15, 0x4e, 0x93, 0x5b, 0xee, 0x76, 0x91, 0x44, 0x97, 0x85, 0xa1, 0xd9, 0x1a, 0x4c, 0x1c, 0x23, 0xf5, 0x16, + 0x24, 0xb2, 0x2d, 0x85, 0xcb, 0x2e, 0x7e, 0xa3, 0x28, 0x61, 0xd0, 0xf9, 0x4c, 0x30, 0xde, 0x44, 0xc0, 0x94, 0x23, + 0x4f, 0x13, 0xda, 0x4a, 0x1e, 0x8d, 0x91, 0x57, 0x32, 0x4d, 0x65, 0x7b, 0x2c, 0x7f, 0x24, 0xc9, 0x94, 0x9b, 0xe9, + 0x62, 0xa1, 0x17, 0x13, 0x04, 0xaa, 0xb0, 0xea, 0xad, 0x58, 0x49, 0x04, 0x60, 0xb9, 0x82, 0xb2, 0xec, 0xd2, 0xfd, + 0xbc, 0x02, 0x47, 0x1e, 0xa6, 0x53, 0xc4, 0x86, 0x27, 0x8d, 0x8c, 0xc4, 0x89, 0xaf, 0x2f, 0xc9, 0x96, 0x53, 0x33, + 0x38, 0x8b, 0x78, 0x60, 0xaa, 0xdb, 0xdc, 0x78, 0x79, 0xa4, 0xd8, 0xba, 0x97, 0xde, 0x89, 0xb8, 0x74, 0x9d, 0x95, + 0xa2, 0x1c, 0x55, 0x52, 0xa8, 0xe7, 0x4c, 0xa3, 0xa9, 0xbc, 0xb5, 0x85, 0x12, 0x59, 0x05, 0xad, 0x92, 0xd3, 0xff, + 0xef, 0x88, 0x24, 0x24, 0x5c, 0x08, 0x2c, 0xfe, 0x32, 0x15, 0xd2, 0xec, 0xad, 0xb6, 0x63, 0x18, 0x44, 0xba, 0xce, + 0x0b, 0x6e, 0x19, 0xbf, 0xfa, 0x05, 0x00, 0x7a, 0x2b, 0xda, 0x06, 0xa6, 0x8b, 0x05, 0x9c, 0xd9, 0xd9, 0x8c, 0xde, + 0xe6, 0xc2, 0xac, 0x8e, 0x2b, 0xfa, 0x89, 0xd5, 0xbf, 0x86, 0x85, 0xdd, 0xb3, 0xfd, 0x78, 0xb0, 0x63, 0x46, 0x53, + 0x57, 0x09, 0x61, 0x98, 0x20, 0x8b, 0x5e, 0x06, 0x77, 0xc8, 0x22, 0x8c, 0xc0, 0xae, 0x1c, 0xda, 0xc8, 0x84, 0xf3, + 0x15, 0x84, 0x7f, 0x8e, 0xf9, 0x7a, 0x0a, 0x2c, 0xcb, 0xfd, 0xc9, 0x50, 0x0f, 0x03, 0xc2, 0x44, 0x46, 0x38, 0x82, + 0x24, 0x64, 0x53, 0x21, 0x98, 0x78, 0x0a, 0xea, 0x26, 0x38, 0xb0, 0xc5, 0xd1, 0x8d, 0x8d, 0x52, 0x98, 0x11, 0x7f, + 0xc5, 0x82, 0x91, 0xdb, 0xc7, 0xf8, 0xf6, 0x80, 0xc2, 0x2b, 0xd8, 0x29, 0x84, 0xea, 0xe5, 0xa5, 0x36, 0xbd, 0xd8, + 0x8f, 0x7c, 0x07, 0x7d, 0x3c, 0x9b, 0xe9, 0xc8, 0x0b, 0x32, 0x4c, 0xa7, 0x21, 0x0d, 0x40, 0x42, 0x78, 0xe1, 0xa6, + 0x6e, 0x7f, 0x72, 0x68, 0x9d, 0x4c, 0x15, 0x58, 0xde, 0xe5, 0x4d, 0x27, 0x23, 0x20, 0x2f, 0xec, 0xb2, 0x52, 0xcc, + 0xa7, 0xff, 0x54, 0x8d, 0xed, 0x30, 0x9d, 0x76, 0x38, 0xbb, 0x98, 0xbb, 0x42, 0x63, 0x26, 0x22, 0x2f, 0xca, 0x15, + 0xb6, 0x5e, 0x9c, 0xe6, 0x70, 0x80, 0xf7, 0xb8, 0x7c, 0x43, 0x42, 0xc8, 0x07, 0x2f, 0x48, 0x87, 0xe8, 0x59, 0x9a, + 0x8f, 0x19, 0xf5, 0xc2, 0x5b, 0x5f, 0x64, 0x0a, 0x02, 0xfe, 0x74, 0xeb, 0x23, 0x51, 0x8d, 0xf4, 0x14, 0x2d, 0x4e, + 0xa8, 0x2c, 0xd9, 0x16, 0xc8, 0xe9, 0xbf, 0x20, 0x3a, 0x18, 0x63, 0xf9, 0x36, 0xe1, 0xcd, 0xcb, 0x2d, 0x6b, 0xbc, + 0xfd, 0xc8, 0x76, 0x86, 0x52, 0xfe, 0xc6, 0x71, 0x88, 0xe9, 0x4c, 0x26, 0x76, 0x66, 0x02, 0x46, 0x0f, 0x0b, 0x68, + 0x1d, 0xb8, 0x19, 0x79, 0xfc, 0xe4, 0xd5, 0x9b, 0x90, 0x9b, 0xcf, 0xd5, 0xff, 0xfc, 0xb2, 0x75, 0x16, 0xf7, 0x6e, + 0x2f, 0x25, 0x0e, 0x9d, 0x99, 0xcd, 0x32, 0x18, 0xaf, 0x68, 0x80, 0xe0, 0xe4, 0x1a, 0x30, 0x0c, 0xca, 0xd2, 0x0f, + 0x04, 0x8c, 0x5d, 0x1e, 0xa9, 0xba, 0x19, 0x3f, 0x42, 0xcc, 0x76, 0x59, 0x3e, 0x44, 0x5a, 0x18, 0xed, 0x5b, 0xa0, + 0xb0, 0x03, 0x66, 0x2e, 0x8e, 0x40, 0xde, 0x73, 0x99, 0x79, 0x0d, 0x44, 0xeb, 0xf3, 0xcd, 0x79, 0x7c, 0x9d, 0x94, + 0xff, 0x28, 0x9a, 0x43, 0x5a, 0xd1, 0x8c, 0xfc, 0x3e, 0x1a, 0x3d, 0xd6, 0xdb, 0xbc, 0xd9, 0x8e, 0xab, 0x4c, 0xd9, + 0x12, 0x8c, 0x28, 0xb9, 0xb1, 0xc3, 0x7c, 0x50, 0x71, 0x15, 0xd8, 0x92, 0xaf, 0xd1, 0xad, 0x1d, 0xe2, 0x70, 0xee, + 0x37, 0x2d, 0xf2, 0xb6, 0xe5, 0xe8, 0xa2, 0xb0, 0x5b, 0x81, 0xf3, 0xab, 0x86, 0xb6, 0x12, 0xdf, 0xc8, 0x9f, 0x8c, + 0x89, 0x2a, 0x24, 0x88, 0x09, 0x7a, 0x34, 0x9c, 0x7f, 0x10, 0xa2, 0xa1, 0xcb, 0x64, 0xb7, 0x6c, 0xd2, 0x97, 0xda, + 0xc2, 0x55, 0x60, 0x16, 0xd8, 0x6d, 0xec, 0x77, 0x7d, 0x3c, 0x6f, 0xc7, 0x65, 0x66, 0xcd, 0x87, 0x5a, 0xf1, 0x15, + 0xce, 0x05, 0x41, 0xa5, 0x35, 0xdc, 0x92, 0xfc, 0xdf, 0xcf, 0xfb, 0x67, 0xdc, 0x7a, 0x5a, 0xf6, 0xea, 0x7b, 0xe8, + 0xf7, 0xf5, 0x5e, 0x2d, 0x17, 0xbd, 0x48, 0x2d, 0xfa, 0x6a, 0x34, 0x6d, 0x3c, 0xbf, 0x7f, 0x7d, 0x7d, 0x21, 0x9d, + 0xde, 0xf1, 0x2b, 0xbf, 0x85, 0xee, 0x1d, 0xb8, 0xa2, 0xdc, 0xe0, 0xe7, 0x2a, 0x1e, 0xce, 0xfe, 0x2b, 0x77, 0x58, + 0x1d, 0xd7, 0xaf, 0xaa, 0xcb, 0x36, 0xc7, 0x33, 0xd8, 0x1b, 0xfd, 0xb6, 0x3d, 0x03, 0xfe, 0xbf, 0x05, 0x48, 0x7c, + 0x91, 0x92, 0x49, 0x05, 0x0a, 0x40, 0xa0, 0xbb, 0x1e, 0xfc, 0x11, 0x84, 0x51, 0x4a, 0x3b, 0x7c, 0xfc, 0x98, 0x4c, + 0x54, 0x70, 0x78, 0x75, 0x6e, 0xa1, 0x59, 0x8f, 0xf4, 0xfb, 0x3c, 0xdd, 0xf5, 0xf8, 0x53, 0x1b, 0x55, 0x27, 0x02, + 0x99, 0xd9, 0x38, 0xd3, 0x4e, 0xb9, 0xfe, 0x6d, 0xa3, 0x3f, 0xab, 0xf0, 0xad, 0x42, 0x45, 0x77, 0x5f, 0xfc, 0xe3, + 0xaa, 0xd1, 0xbb, 0xee, 0x2a, 0xfc, 0x70, 0xd5, 0xab, 0xb7, 0xdd, 0xed, 0xbb, 0x15, 0x15, 0x6b, 0x58, 0x9e, 0x31, + 0xc3, 0xa0, 0x39, 0x22, 0x9a, 0x9d, 0xf2, 0xff, 0x7d, 0x64, 0xeb, 0x45, 0xc4, 0x92, 0xad, 0xb8, 0x00, 0x79, 0xb1, + 0x8d, 0xd3, 0x67, 0xf1, 0x46, 0x35, 0x17, 0xae, 0x3c, 0xea, 0xdd, 0x49, 0xba, 0x37, 0x18, 0xaa, 0xf9, 0xfd, 0x80, + 0xd7, 0x05, 0x5d, 0x39, 0xf1, 0xd1, 0xf1, 0x4e, 0xd9, 0xfa, 0x68, 0x6c, 0xff, 0x2b, 0x5f, 0x43, 0xc7, 0xe6, 0xc5, + 0xb6, 0x03, 0xbb, 0xe1, 0xc7, 0x6c, 0xe2, 0xcd, 0xa7, 0xf5, 0xf8, 0x8c, 0xcf, 0xd3, 0xb8, 0xc7, 0x18, 0xde, 0x19, + 0xb7, 0xe6, 0x01, 0x9f, 0x19, 0x65, 0x06, 0x72, 0x19, 0xb2, 0xf7, 0x1e, 0xd6, 0xe8, 0xa9, 0x03, 0xfa, 0x35, 0x15, + 0x0a, 0x80, 0x45, 0xb9, 0x98, 0x21, 0xad, 0x99, 0xd1, 0xbf, 0x81, 0x46, 0x94, 0x8c, 0xf2, 0xf9, 0xdc, 0x59, 0x74, + 0x43, 0xa7, 0x4f, 0x40, 0x06, 0xd6, 0xd6, 0x01, 0x6b, 0x89, 0x45, 0x85, 0x68, 0x13, 0x9a, 0x4c, 0x00, 0xee, 0x93, + 0x60, 0x43, 0xe1, 0xd7, 0x5a, 0x4e, 0x82, 0x9f, 0xbb, 0x57, 0x82, 0xa4, 0x97, 0xe2, 0x28, 0x9d, 0x4c, 0x18, 0xb4, + 0x7b, 0xcd, 0xcb, 0x97, 0xbd, 0xcf, 0xed, 0xfa, 0x90, 0xf9, 0xc8, 0x9e, 0xb5, 0xe6, 0x64, 0xe4, 0x6b, 0xcd, 0x51, + 0x77, 0xf2, 0x06, 0x52, 0x36, 0xfb, 0x85, 0x61, 0x81, 0xc5, 0x6f, 0x35, 0x4c, 0x6e, 0xbd, 0x39, 0xa5, 0xf6, 0x11, + 0x4f, 0x12, 0x38, 0x1b, 0x5e, 0x37, 0xd4, 0x5a, 0x68, 0xaf, 0x57, 0x38, 0xaa, 0xf4, 0xe9, 0x4e, 0x29, 0x37, 0xd7, + 0x63, 0xef, 0xbe, 0xf5, 0xad, 0xf4, 0x84, 0xbc, 0xf3, 0x02, 0x9c, 0x95, 0x3f, 0x5f, 0xfb, 0x8f, 0x05, 0xa4, 0xae, + 0x1a, 0x67, 0x73, 0x5b, 0xf6, 0xc6, 0x77, 0x4b, 0xde, 0xbe, 0x17, 0xd6, 0xb0, 0x6e, 0x5b, 0x27, 0x89, 0xd7, 0x6e, + 0x31, 0x2b, 0x2d, 0xe4, 0x33, 0x72, 0xc9, 0x4c, 0x22, 0xe4, 0x1a, 0xa1, 0xe1, 0x5a, 0xaf, 0xd0, 0x6d, 0xd7, 0x10, + 0xe6, 0x2a, 0x4c, 0x8f, 0x2d, 0x11, 0x1c, 0x54, 0xcd, 0xb7, 0xf5, 0xbf, 0x81, 0x1e, 0xfe, 0xd8, 0xec, 0x95, 0x05, + 0x53, 0x3c, 0xe9, 0xdc, 0xd7, 0xfa, 0xbb, 0x46, 0x3c, 0x4a, 0x4f, 0x1a, 0xa2, 0xe8, 0x11, 0x09, 0xf8, 0x5a, 0xc5, + 0xa0, 0x97, 0x15, 0xf7, 0x50, 0xa1, 0x4f, 0x5b, 0x98, 0xa3, 0xc2, 0x55, 0xaf, 0xc8, 0x93, 0x11, 0xfa, 0x4c, 0xad, + 0x0f, 0x84, 0x5c, 0x14, 0xef, 0x7d, 0xd2, 0x7a, 0xbb, 0x3e, 0x5f, 0xe4, 0x0e, 0xe9, 0xdd, 0xdb, 0x84, 0xe9, 0xa5, + 0x43, 0x37, 0xb6, 0xf1, 0x4f, 0xc4, 0xb3, 0x8d, 0xe1, 0x42, 0x95, 0xa5, 0x78, 0x5a, 0x8e, 0x52, 0xdd, 0xd1, 0x98, + 0x24, 0x15, 0xc8, 0xde, 0xd9, 0x76, 0x58, 0x73, 0xe1, 0xab, 0xec, 0xea, 0xd8, 0x03, 0x95, 0xb8, 0x87, 0xe4, 0x0e, + 0xfb, 0xb6, 0xbf, 0xcc, 0x54, 0xa6, 0x21, 0xfe, 0xc7, 0xf7, 0xdc, 0x81, 0x46, 0x7f, 0x3b, 0x8e, 0xe8, 0x58, 0x72, + 0x8b, 0x65, 0xca, 0x70, 0xe4, 0x04, 0x8b, 0xed, 0xde, 0x70, 0xca, 0xb9, 0xec, 0xb4, 0x45, 0x31, 0x4c, 0x72, 0x0f, + 0x8c, 0x6c, 0x45, 0xfb, 0x27, 0xf6, 0x44, 0xc3, 0x9c, 0x9e, 0x9a, 0x77, 0x96, 0xf8, 0x36, 0xed, 0x9f, 0xa8, 0x5d, + 0x42, 0x15, 0xa5, 0xc8, 0x4a, 0xdc, 0xe5, 0x97, 0x76, 0x9b, 0x08, 0xdb, 0x45, 0x98, 0xd6, 0x5e, 0x4f, 0x52, 0x39, + 0xd2, 0x28, 0x75, 0xec, 0xf0, 0xb6, 0x93, 0xa6, 0x02, 0x22, 0x54, 0x54, 0x4f, 0x4a, 0x5a, 0x4a, 0x5f, 0x88, 0x5a, + 0x77, 0x3e, 0xda, 0x8a, 0xf6, 0x04, 0x1c, 0xc0, 0xa6, 0xd5, 0x16, 0x95, 0xca, 0xc3, 0x0d, 0x3b, 0x04, 0xed, 0x2b, + 0x78, 0xf9, 0x00, 0x47, 0x55, 0x9e, 0xde, 0x17, 0xa4, 0xe2, 0xc7, 0x29, 0x36, 0x1e, 0x66, 0x93, 0xa1, 0x12, 0xb8, + 0x31, 0x4a, 0x87, 0xcf, 0xd7, 0xef, 0x74, 0x98, 0xbc, 0xfa, 0xb8, 0xa7, 0x17, 0xd3, 0x2b, 0x20, 0x5e, 0xb8, 0x79, + 0x7f, 0x1c, 0x26, 0xd7, 0x70, 0x82, 0xf4, 0x49, 0xaa, 0xb7, 0x6d, 0x19, 0x03, 0x0a, 0xcb, 0xbe, 0x9c, 0xc6, 0x5e, + 0x4c, 0x7c, 0x9e, 0xbf, 0x4b, 0x1b, 0xd3, 0xb2, 0xc2, 0x58, 0x7b, 0x75, 0xdb, 0x21, 0x5c, 0xe6, 0x0e, 0x7e, 0xf9, + 0x3f, 0x7c, 0xd4, 0x76, 0x73, 0xbf, 0x6e, 0xce, 0x8c, 0x00, 0xcf, 0x48, 0x88, 0xbe, 0x3c, 0x90, 0x2b, 0xd7, 0xaf, + 0xfe, 0x37, 0x50, 0xfc, 0xa4, 0x2b, 0xcd, 0xbf, 0xe6, 0xfa, 0xb0, 0x18, 0x9b, 0x82, 0x6c, 0x1f, 0x49, 0x61, 0x74, + 0x8d, 0x68, 0xbc, 0xdf, 0xb7, 0x61, 0x5d, 0x0d, 0x32, 0x72, 0x8b, 0x90, 0xd7, 0x87, 0x58, 0x60, 0xf4, 0xfd, 0x65, + 0xdb, 0xe2, 0x9b, 0x56, 0x24, 0xde, 0x30, 0xab, 0xb4, 0xfe, 0x17, 0x59, 0xb8, 0xbe, 0xfb, 0xd2, 0x80, 0x80, 0xd6, + 0xda, 0x57, 0xc2, 0x72, 0xe7, 0x08, 0x02, 0x18, 0x94, 0x30, 0x16, 0x4f, 0x22, 0xfa, 0x97, 0xcc, 0x88, 0xd4, 0x53, + 0xc5, 0x74, 0xe2, 0x84, 0xe1, 0xac, 0x04, 0x35, 0x56, 0x7a, 0x80, 0xcd, 0x5c, 0x94, 0xab, 0x61, 0x2b, 0xc6, 0x43, + 0x8a, 0xb8, 0x63, 0xd6, 0xc8, 0x7b, 0x42, 0x25, 0x0d, 0xaa, 0x88, 0x0a, 0x29, 0x8f, 0x42, 0x1c, 0x86, 0x67, 0x10, + 0x02, 0xa4, 0x52, 0xc4, 0x3a, 0x73, 0x49, 0x86, 0x71, 0xe0, 0xb5, 0x53, 0xc9, 0xab, 0xd1, 0xdd, 0x2a, 0x74, 0x0a, + 0x22, 0x3a, 0x30, 0xbb, 0x05, 0x5d, 0x6f, 0x16, 0xb0, 0x5b, 0x31, 0xb5, 0x52, 0xdc, 0xcd, 0x98, 0xad, 0x58, 0x6c, + 0x61, 0x40, 0x24, 0xb4, 0x65, 0xfe, 0x1a, 0x1d, 0xf0, 0xa2, 0x8b, 0xa2, 0x27, 0xa5, 0xf1, 0xdf, 0x94, 0x7a, 0x5f, + 0x50, 0xc3, 0xc8, 0x82, 0x82, 0xeb, 0x6c, 0xdc, 0x4a, 0xfc, 0xf0, 0x96, 0x3a, 0xdc, 0x42, 0xf0, 0x55, 0x48, 0x37, + 0xd5, 0xc2, 0x5c, 0x61, 0x0f, 0xb2, 0xe5, 0xda, 0x72, 0xa3, 0xe3, 0xbb, 0x5e, 0xbb, 0xf0, 0xfc, 0xa5, 0x36, 0xcf, + 0x95, 0x53, 0x3c, 0x96, 0x82, 0x5c, 0xe2, 0xa9, 0x95, 0x75, 0x27, 0xf5, 0x61, 0x58, 0xd3, 0x51, 0x8d, 0x3b, 0xe3, + 0xc9, 0x13, 0x32, 0xc9, 0x97, 0x56, 0xea, 0x9c, 0x50, 0x47, 0xa0, 0xb6, 0x1e, 0x94, 0xa9, 0x5f, 0x8a, 0x2d, 0x60, + 0x1e, 0x1e, 0xf8, 0x8f, 0x61, 0x91, 0x3c, 0x99, 0x44, 0x4e, 0x13, 0x4f, 0xe5, 0xf8, 0x15, 0x9f, 0x33, 0x9e, 0x0c, + 0x27, 0x7b, 0x2c, 0x49, 0x7a, 0xb6, 0x8c, 0xf9, 0x61, 0x00, 0x88, 0x13, 0x61, 0xcc, 0x45, 0x1e, 0x51, 0x28, 0x5a, + 0x9c, 0x5c, 0x57, 0x40, 0x6a, 0xaa, 0x6d, 0xbf, 0xa6, 0xe8, 0x08, 0xcc, 0xd2, 0x65, 0x1a, 0xd5, 0x2c, 0x55, 0x26, + 0x08, 0xe1, 0x73, 0x6e, 0xad, 0x1d, 0x17, 0x30, 0xd3, 0x8e, 0x9e, 0xdb, 0xe4, 0x75, 0xf6, 0x47, 0x46, 0x66, 0xea, + 0xce, 0xaa, 0xc6, 0x04, 0x63, 0x57, 0xed, 0xd2, 0x50, 0x79, 0xe3, 0x64, 0xf7, 0xd5, 0xa9, 0xdd, 0x86, 0x32, 0xb8, + 0x88, 0x89, 0x87, 0x6c, 0x04, 0x20, 0xba, 0x96, 0xab, 0x95, 0x27, 0xc7, 0xc6, 0x10, 0xe6, 0xa6, 0x38, 0xcf, 0x81, + 0xb6, 0x7f, 0xdc, 0xb5, 0x50, 0x2b, 0x44, 0x56, 0x36, 0xfb, 0x67, 0x13, 0x78, 0xbd, 0x58, 0xbc, 0x08, 0x2f, 0xe6, + 0x41, 0x2a, 0x2f, 0x16, 0xbf, 0xb2, 0x94, 0x86, 0x14, 0x61, 0x2d, 0xb0, 0xb9, 0xb4, 0x92, 0x67, 0xcb, 0xe9, 0x85, + 0xeb, 0x99, 0xcc, 0xbc, 0x10, 0x30, 0x66, 0xe9, 0x57, 0x5e, 0xa2, 0xb3, 0x03, 0xfb, 0x9f, 0xfd, 0x86, 0x3a, 0x22, + 0x53, 0xb0, 0xe9, 0x36, 0x46, 0x6a, 0x91, 0xac, 0x24, 0xea, 0x47, 0x56, 0x3e, 0x7b, 0xd7, 0xea, 0xb7, 0xda, 0xb9, + 0x21, 0x50, 0xf8, 0xde, 0x88, 0x09, 0x0d, 0x2a, 0xb1, 0xa4, 0x6e, 0xdc, 0x07, 0xe7, 0x41, 0x59, 0xd3, 0xaf, 0x04, + 0x82, 0xff, 0xc4, 0x6e, 0xda, 0x25, 0x57, 0x90, 0x2e, 0x06, 0x77, 0x2a, 0x54, 0x37, 0x44, 0x78, 0x7d, 0x76, 0x2f, + 0xd1, 0xc4, 0x61, 0xb6, 0x22, 0x0b, 0x3d, 0xbc, 0xf6, 0xe0, 0xf6, 0x79, 0x66, 0x2d, 0xee, 0x54, 0x82, 0xf6, 0xb5, + 0xd9, 0xab, 0x7e, 0xf2, 0x78, 0xf0, 0xab, 0xc1, 0x73, 0x41, 0x06, 0x37, 0xbb, 0x41, 0xd4, 0x0f, 0xa1, 0xf3, 0x2c, + 0xf8, 0x1e, 0xc1, 0x94, 0xfe, 0x95, 0x17, 0xe2, 0x57, 0x83, 0x8f, 0x32, 0x33, 0xa8, 0x1e, 0xab, 0x08, 0x52, 0x7e, + 0x92, 0x61, 0x84, 0x91, 0x61, 0xe8, 0xba, 0x0a, 0x51, 0xc2, 0x1b, 0x2c, 0x36, 0xb3, 0x7b, 0x53, 0xf3, 0x7f, 0x81, + 0xd4, 0x21, 0xfc, 0x90, 0xd8, 0x13, 0xf3, 0x10, 0xf6, 0x6a, 0xe6, 0x71, 0xb6, 0xaf, 0xa2, 0x8e, 0xf5, 0x66, 0x8b, + 0x27, 0x16, 0x54, 0x1f, 0xc2, 0xda, 0x54, 0x81, 0x4b, 0xc4, 0xdc, 0xae, 0xfd, 0x7f, 0xfc, 0x75, 0xda, 0xb1, 0x8d, + 0x98, 0x99, 0x1e, 0x8e, 0xfb, 0xc6, 0x15, 0x51, 0x17, 0xa0, 0x60, 0x0e, 0x5a, 0x57, 0xb0, 0x12, 0x8f, 0xda, 0xd3, + 0xdb, 0xae, 0xbf, 0x1f, 0x20, 0xc4, 0x0f, 0xcd, 0xf2, 0xbe, 0x42, 0x6c, 0x34, 0x69, 0xbb, 0xb1, 0x73, 0x6c, 0xab, + 0x0e, 0x2b, 0x0a, 0x25, 0x74, 0x43, 0x03, 0xe7, 0x6e, 0x20, 0xc0, 0xfa, 0x29, 0xce, 0xa2, 0x5d, 0xd8, 0x43, 0xd7, + 0x6e, 0x6b, 0x3c, 0x35, 0x7a, 0x62, 0xa4, 0x95, 0x80, 0x2d, 0x53, 0xdf, 0x79, 0x45, 0x77, 0x9b, 0x1b, 0x76, 0xae, + 0xcf, 0x6d, 0xa9, 0xf6, 0xe3, 0x78, 0x6c, 0x1b, 0x66, 0x99, 0xda, 0xbd, 0xbb, 0x66, 0xae, 0x7e, 0xb9, 0xce, 0x54, + 0x84, 0x6c, 0x38, 0x85, 0xe4, 0x84, 0xe4, 0xb6, 0xd7, 0x92, 0x18, 0xc5, 0x7a, 0xc7, 0x06, 0x8e, 0x90, 0x73, 0xb6, + 0x62, 0x06, 0x6b, 0xb3, 0xdd, 0xc7, 0xc2, 0x64, 0xc3, 0x69, 0xed, 0x1e, 0x5a, 0x68, 0x04, 0x97, 0x8c, 0xe7, 0x2a, + 0x93, 0xc5, 0xe3, 0x0e, 0xf3, 0xcb, 0xf6, 0x19, 0x8d, 0x17, 0x0d, 0xa7, 0x1a, 0x7b, 0x53, 0x52, 0x46, 0xb3, 0xef, + 0xdc, 0xd2, 0x5a, 0x24, 0xde, 0xbc, 0xa7, 0x77, 0x82, 0xa1, 0xb5, 0xf7, 0xaa, 0x2d, 0x80, 0xfa, 0x9f, 0xed, 0xac, + 0x58, 0xd0, 0x38, 0xec, 0x0c, 0x70, 0xe3, 0xe2, 0x79, 0x87, 0xe2, 0x31, 0x99, 0xe9, 0xbd, 0x15, 0x59, 0xef, 0xf2, + 0xbf, 0xda, 0xae, 0x13, 0x9f, 0x3d, 0xba, 0xdb, 0xea, 0xa0, 0xb5, 0xae, 0x8b, 0x94, 0xf8, 0x38, 0xad, 0x5d, 0x4c, + 0xdc, 0x92, 0x85, 0x97, 0x39, 0x9a, 0xff, 0x15, 0x8b, 0x1c, 0x36, 0x68, 0x9c, 0x9b, 0xf8, 0xd6, 0x52, 0x1a, 0x7d, + 0x6a, 0x50, 0x17, 0x26, 0x51, 0x89, 0x20, 0xb4, 0xd2, 0xbf, 0x62, 0xef, 0x6b, 0x6f, 0x33, 0x15, 0xd7, 0x29, 0xce, + 0x60, 0xf2, 0xa8, 0xe7, 0x1c, 0x49, 0xc7, 0x2c, 0x6b, 0x7c, 0x03, 0x4d, 0xdb, 0x4a, 0xd3, 0x64, 0x54, 0xc3, 0x46, + 0xac, 0x33, 0x1b, 0xf1, 0xc2, 0x48, 0xd3, 0xb6, 0x2b, 0xa1, 0xd3, 0xa9, 0xfa, 0xc5, 0x13, 0xe7, 0xd6, 0xc2, 0x7f, + 0xcb, 0x8b, 0x03, 0xc4, 0xb9, 0xae, 0x46, 0x1a, 0x19, 0x74, 0xe1, 0x2e, 0x3e, 0xe5, 0x8e, 0x5b, 0x39, 0x86, 0x60, + 0xd5, 0x6a, 0xe3, 0xe2, 0x50, 0xd6, 0xd7, 0x20, 0xf5, 0x3e, 0x18, 0x69, 0x32, 0x66, 0x57, 0xce, 0x9f, 0xe6, 0xe9, + 0xa1, 0x44, 0x99, 0x1a, 0x99, 0x36, 0x7c, 0xcf, 0xaf, 0xe6, 0x24, 0x76, 0x6d, 0x3c, 0x1f, 0x9c, 0x98, 0x7a, 0x2b, + 0x67, 0x25, 0x45, 0x01, 0xd0, 0x86, 0xb9, 0xb6, 0x64, 0x23, 0x65, 0xda, 0xb3, 0xce, 0xfb, 0x76, 0xe7, 0x8a, 0x93, + 0xd9, 0x69, 0x02, 0x5d, 0xa1, 0xa9, 0xea, 0xd4, 0x0c, 0x8d, 0x10, 0x98, 0xf1, 0x61, 0x0a, 0xfd, 0xa2, 0x48, 0x30, + 0x74, 0xd3, 0x0b, 0x8a, 0x15, 0x27, 0x9a, 0xe7, 0x4b, 0x5d, 0x25, 0xe1, 0xa6, 0xf6, 0x7e, 0xed, 0xfe, 0x97, 0x9e, + 0xdc, 0x45, 0x9d, 0x09, 0x41, 0x29, 0x60, 0xd2, 0x71, 0xf0, 0x61, 0x28, 0xc3, 0x1f, 0x57, 0x30, 0x7a, 0x91, 0x59, + 0x7f, 0x20, 0x92, 0x43, 0xc5, 0x77, 0x96, 0x5f, 0x5a, 0xa1, 0xf8, 0x89, 0xc8, 0x0e, 0x8a, 0xaf, 0x41, 0xc0, 0x23, + 0xa8, 0xd9, 0x4e, 0x57, 0x82, 0x27, 0x78, 0xc7, 0x8b, 0x7c, 0xc5, 0xc8, 0xeb, 0x69, 0xb5, 0xa4, 0x61, 0x68, 0x8e, + 0x25, 0x41, 0x63, 0x53, 0xc7, 0x12, 0x82, 0x79, 0x5f, 0x1f, 0xeb, 0xb9, 0xd5, 0x8e, 0x02, 0x27, 0x58, 0xfb, 0x81, + 0xb4, 0x8e, 0x74, 0x3c, 0xb5, 0x68, 0xd6, 0x36, 0x32, 0xd1, 0xd9, 0xc4, 0x40, 0x3a, 0x0b, 0x0e, 0x36, 0xe6, 0xd3, + 0x68, 0xae, 0xbc, 0x61, 0x04, 0xff, 0xbd, 0x0a, 0xcb, 0x59, 0x7a, 0xb5, 0xe5, 0x62, 0x1c, 0x55, 0xf8, 0x3f, 0x0d, + 0x13, 0xbe, 0xc9, 0xf9, 0xb8, 0x5c, 0x24, 0x44, 0xa8, 0x80, 0x07, 0x3a, 0x26, 0x7c, 0x1d, 0xad, 0x86, 0x11, 0x5a, + 0x75, 0x2b, 0xc8, 0x11, 0xd2, 0x7e, 0xdf, 0x54, 0x5b, 0xdf, 0x34, 0x67, 0x6f, 0xcf, 0x0d, 0x9b, 0x06, 0xf3, 0xe3, + 0x73, 0x8f, 0x4d, 0x37, 0x12, 0x55, 0x2c, 0xbf, 0x83, 0x8f, 0xda, 0x98, 0xe1, 0x83, 0xfe, 0xf0, 0xa6, 0x71, 0xcc, + 0x78, 0x95, 0x4d, 0x9a, 0xf4, 0xc3, 0x99, 0x6b, 0x81, 0xda, 0xa7, 0xa6, 0xee, 0x48, 0xd1, 0x81, 0xa3, 0xab, 0xf9, + 0x16, 0x5f, 0x89, 0xf0, 0xf0, 0x6b, 0x12, 0x95, 0x35, 0xcd, 0xa0, 0x4e, 0xa5, 0x34, 0x51, 0x75, 0xdb, 0x54, 0x00, + 0x7b, 0xcf, 0xb0, 0x32, 0x50, 0xa3, 0x27, 0xba, 0x13, 0x34, 0x42, 0x1a, 0xc7, 0x9f, 0x42, 0xfb, 0x91, 0xc6, 0x6f, + 0xc5, 0x94, 0x63, 0x3b, 0x86, 0x79, 0xd5, 0x00, 0x55, 0x0b, 0x7d, 0xfc, 0xeb, 0x9b, 0xad, 0xdb, 0xb5, 0xed, 0x76, + 0x87, 0xb0, 0x54, 0x2f, 0x8f, 0x5a, 0x34, 0x93, 0x98, 0xa6, 0x14, 0x16, 0x5d, 0xb4, 0x8e, 0x97, 0xd3, 0xc6, 0x41, + 0xad, 0x30, 0xd8, 0x16, 0xaa, 0x74, 0x19, 0x31, 0xdc, 0x4e, 0x61, 0x84, 0x4c, 0xa1, 0x42, 0x1f, 0xb1, 0x66, 0xba, + 0x75, 0xf7, 0x50, 0x5a, 0xcb, 0xf2, 0xad, 0x17, 0x6b, 0xd4, 0xb7, 0xde, 0x66, 0x35, 0x8a, 0x5a, 0x4c, 0xbc, 0x12, + 0x8c, 0xae, 0x2f, 0x13, 0x5a, 0xb9, 0x45, 0x5b, 0xa5, 0x20, 0x48, 0xec, 0xd6, 0xe2, 0x2b, 0xd1, 0x8e, 0xcd, 0x1c, + 0x89, 0xc9, 0xfc, 0xf4, 0xda, 0x54, 0x86, 0xca, 0x87, 0x0f, 0x3e, 0x67, 0x68, 0x8a, 0x27, 0xef, 0xc0, 0x4f, 0xba, + 0xfc, 0x49, 0xea, 0x03, 0xef, 0xb6, 0x0c, 0x4e, 0x51, 0x3b, 0xb7, 0x74, 0x18, 0xc0, 0x75, 0x52, 0xf0, 0x82, 0x2b, + 0x4c, 0x92, 0x46, 0x3e, 0x3a, 0x41, 0x4c, 0x8a, 0xce, 0x94, 0x35, 0x18, 0x94, 0xb5, 0x0c, 0x80, 0x35, 0xda, 0x84, + 0xe1, 0x23, 0x90, 0x19, 0x63, 0x06, 0x69, 0x1b, 0xe6, 0x94, 0xcf, 0xba, 0x3f, 0xbe, 0x10, 0xba, 0x3d, 0xd8, 0x13, + 0x51, 0x96, 0x0f, 0xc8, 0x07, 0x1d, 0xd2, 0xbf, 0x22, 0x31, 0xca, 0xe1, 0xb9, 0xdc, 0x7f, 0x12, 0x58, 0x38, 0x80, + 0x9b, 0xb5, 0x77, 0xec, 0x80, 0x64, 0xde, 0x2a, 0x2c, 0xbf, 0x1f, 0x02, 0x0c, 0x5b, 0x3b, 0xb1, 0x9c, 0x15, 0xa3, + 0x65, 0x39, 0x59, 0x41, 0xc3, 0xf2, 0x37, 0x80, 0xaf, 0x03, 0x56, 0xbd, 0x5f, 0xe2, 0x32, 0x53, 0x14, 0xf8, 0x67, + 0xe3, 0xb4, 0x4a, 0x5b, 0x10, 0x1f, 0x04, 0x22, 0x0f, 0xb0, 0x07, 0x57, 0x8f, 0x85, 0xb7, 0x53, 0xbe, 0x8b, 0xca, + 0xd2, 0x35, 0x1a, 0x39, 0xa5, 0x7a, 0xbf, 0xc5, 0x76, 0x83, 0x3d, 0x08, 0xa9, 0x2d, 0x94, 0x7f, 0x85, 0xaa, 0x4a, + 0x51, 0xeb, 0xcd, 0x08, 0x83, 0x16, 0x9c, 0x9b, 0x23, 0x50, 0x43, 0x60, 0xd4, 0xda, 0x5c, 0x4b, 0xa0, 0x35, 0x3f, + 0x80, 0x5d, 0xe7, 0xe3, 0x97, 0x51, 0x4c, 0x78, 0xbc, 0x6f, 0x1a, 0x93, 0x93, 0x1f, 0x3d, 0xee, 0xfa, 0x66, 0xdd, + 0x64, 0x88, 0x59, 0x24, 0xf5, 0x3c, 0xc2, 0x6c, 0xe7, 0xb5, 0x70, 0xb1, 0x3a, 0x41, 0xcf, 0xe5, 0x8a, 0x14, 0xf7, + 0xa8, 0xbb, 0x65, 0xf7, 0x7c, 0xaa, 0x9e, 0xc4, 0x58, 0x4b, 0x11, 0x3f, 0xc5, 0xb5, 0x99, 0x50, 0xa5, 0xc8, 0xcd, + 0x26, 0xb0, 0x95, 0x23, 0xed, 0xf1, 0x48, 0x96, 0x13, 0x75, 0xac, 0x41, 0xd4, 0x3c, 0xbe, 0xb3, 0x72, 0xe8, 0x46, + 0x77, 0xd8, 0x37, 0xff, 0x1f, 0xbb, 0xe9, 0xe9, 0x38, 0x93, 0x65, 0xf0, 0x32, 0x06, 0x67, 0xbc, 0xf3, 0xc2, 0xb4, + 0x4a, 0x45, 0x8c, 0x46, 0x3f, 0x16, 0x7d, 0x7f, 0xaa, 0x77, 0x5d, 0x82, 0x20, 0xd5, 0xe5, 0xbf, 0x81, 0xa3, 0xba, + 0x3a, 0x5c, 0x7a, 0x7a, 0xe6, 0x96, 0x46, 0x97, 0xef, 0x98, 0xc1, 0x5d, 0x05, 0x13, 0x60, 0x0d, 0xbc, 0x45, 0xef, + 0xdc, 0x12, 0xc2, 0x65, 0xd4, 0xbb, 0xee, 0x95, 0x53, 0x28, 0x3a, 0x47, 0x77, 0x83, 0x84, 0x1a, 0xae, 0xf3, 0xdc, + 0x3e, 0x5a, 0x29, 0x2a, 0x1f, 0xe7, 0xc3, 0x85, 0xb3, 0x44, 0x12, 0x05, 0xc7, 0x4b, 0x08, 0xd7, 0x7d, 0x3b, 0x66, + 0x84, 0x91, 0x6d, 0x4b, 0xa5, 0xba, 0xe1, 0x5d, 0xe8, 0x51, 0xcc, 0x5a, 0x36, 0xe0, 0xfc, 0x7f, 0xe9, 0xf5, 0x48, + 0xba, 0xb7, 0x29, 0xf1, 0xb8, 0xf0, 0xef, 0xe2, 0xc8, 0x29, 0x28, 0x89, 0x4a, 0xb4, 0x7d, 0x57, 0x76, 0xe0, 0x78, + 0x68, 0x0f, 0xe9, 0xb4, 0x29, 0xcb, 0x2a, 0x00, 0xad, 0x7d, 0xe6, 0x65, 0xe4, 0x64, 0xf4, 0xa4, 0xbd, 0x43, 0xd1, + 0x1b, 0x54, 0x26, 0x21, 0x87, 0x41, 0x22, 0xe6, 0x3a, 0xe0, 0xee, 0xaa, 0xdb, 0x5d, 0x73, 0x15, 0xba, 0x6b, 0x76, + 0xe5, 0x80, 0x8e, 0xe4, 0x90, 0x64, 0xe6, 0xac, 0xf6, 0x41, 0x11, 0x45, 0xde, 0x23, 0xf6, 0xc5, 0x9d, 0x4a, 0xba, + 0x99, 0x77, 0x51, 0x48, 0x14, 0x10, 0xc6, 0x29, 0x88, 0xf7, 0x04, 0x08, 0xa5, 0x75, 0x77, 0xd4, 0x26, 0x5c, 0xf5, + 0x4c, 0x5b, 0x19, 0xc3, 0x9d, 0xce, 0x9d, 0x91, 0x5d, 0xe0, 0x52, 0xf7, 0x62, 0x08, 0xa2, 0x40, 0x4e, 0x41, 0x0c, + 0x27, 0x41, 0xf1, 0xa1, 0x38, 0x90, 0x80, 0x43, 0xe4, 0x41, 0xa9, 0x71, 0xc9, 0xdc, 0x78, 0xa3, 0x10, 0x62, 0x31, + 0x12, 0x31, 0x21, 0xd9, 0x30, 0x70, 0x4c, 0x05, 0xda, 0xfd, 0x72, 0xdf, 0x7b, 0xe1, 0xf7, 0x43, 0x4d, 0x2d, 0xe6, + 0x42, 0x16, 0x46, 0xab, 0x93, 0x7b, 0x81, 0x63, 0xbe, 0x57, 0x2f, 0xb7, 0x91, 0xbd, 0xf0, 0x8d, 0x4b, 0x72, 0x95, + 0x12, 0x10, 0xf6, 0x1f, 0x8c, 0x03, 0x01, 0x30, 0x97, 0x56, 0xb5, 0x96, 0xc8, 0xc3, 0x1b, 0x69, 0xd6, 0xb4, 0x14, + 0xeb, 0x66, 0x1e, 0x2a, 0xc0, 0x92, 0x5a, 0xdc, 0x30, 0x97, 0x15, 0xce, 0x68, 0x0e, 0x4a, 0x78, 0xd3, 0x42, 0xd7, + 0xe6, 0x73, 0x78, 0x92, 0xe6, 0xe8, 0xf7, 0xf0, 0x56, 0x75, 0xcb, 0x92, 0xea, 0x4c, 0x32, 0x98, 0xc8, 0x54, 0xea, + 0x69, 0x38, 0xee, 0xa4, 0xef, 0x04, 0x63, 0xb2, 0xd0, 0x78, 0x27, 0xeb, 0x6c, 0xec, 0x0c, 0x7d, 0x60, 0x7f, 0xc0, + 0x05, 0xc5, 0x77, 0x49, 0xc7, 0xb7, 0x49, 0x84, 0x45, 0x56, 0x76, 0xed, 0xf2, 0xd2, 0xf7, 0x5d, 0x6f, 0xe6, 0xa5, + 0xfb, 0xec, 0xbb, 0xdf, 0xbd, 0x25, 0x6b, 0x45, 0xc9, 0x49, 0xf2, 0x84, 0xe5, 0x6d, 0xda, 0x1e, 0xf2, 0x74, 0x60, + 0xc8, 0xdc, 0x38, 0xae, 0x7f, 0x51, 0x8c, 0x34, 0x75, 0xd8, 0x51, 0x7a, 0x53, 0x81, 0xa7, 0xf6, 0x39, 0x8b, 0x0e, + 0x14, 0xcf, 0x30, 0x5d, 0x13, 0xe1, 0xcd, 0xfe, 0xc5, 0xfc, 0xdf, 0x03, 0xa2, 0xe3, 0xc3, 0x98, 0x36, 0xe4, 0xc3, + 0x2a, 0xbc, 0x14, 0xc7, 0xe2, 0x07, 0x8b, 0x49, 0xe4, 0x49, 0x1c, 0xe0, 0x7d, 0x60, 0x91, 0x0a, 0x23, 0x83, 0x3a, + 0x56, 0x76, 0xc7, 0xf1, 0x02, 0x30, 0xe2, 0x21, 0xe3, 0xfc, 0xe2, 0x33, 0x10, 0x38, 0x5e, 0xa8, 0x66, 0x3b, 0x87, + 0x15, 0x08, 0x80, 0x8c, 0x59, 0xa9, 0xb8, 0x18, 0xcd, 0xa2, 0x14, 0x83, 0x67, 0x7c, 0x68, 0x57, 0x0d, 0xb1, 0xcc, + 0xfc, 0x60, 0x50, 0xce, 0xad, 0x85, 0x14, 0xdc, 0xae, 0x2f, 0x8c, 0x09, 0x6e, 0xdb, 0x48, 0xb0, 0x45, 0xfd, 0x18, + 0x10, 0x8b, 0x0b, 0xea, 0x1a, 0xbf, 0xd7, 0x99, 0x3b, 0x69, 0x9f, 0xb8, 0x8e, 0xd2, 0xb2, 0x94, 0xc4, 0x75, 0x1e, + 0x46, 0x02, 0xc1, 0xf4, 0x9a, 0x10, 0x95, 0x18, 0x62, 0x1f, 0xcb, 0xbd, 0x01, 0xf0, 0x18, 0xa2, 0x23, 0xc7, 0xec, + 0xbc, 0x43, 0x78, 0xba, 0x81, 0x5f, 0x16, 0xbf, 0x95, 0xf1, 0xeb, 0xe3, 0x51, 0x76, 0x44, 0x3e, 0xbc, 0x91, 0xb8, + 0x53, 0x31, 0x07, 0xd2, 0xc8, 0x15, 0xb0, 0xb4, 0x05, 0x72, 0x91, 0x71, 0x0c, 0x5b, 0x3f, 0xb5, 0x3e, 0x06, 0x3f, + 0xf6, 0xb1, 0xe8, 0xf8, 0x75, 0xa0, 0xaf, 0x52, 0x22, 0x7f, 0x2b, 0xa5, 0x38, 0x7b, 0x6f, 0x46, 0xbb, 0x3b, 0x71, + 0x53, 0xaf, 0xec, 0x6d, 0x43, 0x7d, 0x93, 0xb8, 0x7d, 0x6b, 0x1e, 0x03, 0xee, 0xeb, 0xc4, 0x8d, 0xa1, 0xd0, 0x27, + 0xcb, 0xe3, 0x46, 0x53, 0x13, 0x43, 0x77, 0x1e, 0xe1, 0x57, 0xa7, 0x3d, 0x9c, 0xdd, 0x97, 0x26, 0xdd, 0x08, 0xb3, + 0xb8, 0xd8, 0x25, 0x19, 0x1c, 0x06, 0x2c, 0x0e, 0x45, 0x8a, 0x16, 0xb9, 0x6c, 0x0c, 0x91, 0xc3, 0x0e, 0xee, 0x26, + 0x8d, 0x53, 0xde, 0x31, 0x78, 0x69, 0x52, 0x9f, 0xb7, 0xd5, 0x62, 0x42, 0x4d, 0x98, 0x6a, 0xf0, 0xd6, 0xb6, 0x7c, + 0x2c, 0x94, 0x72, 0x12, 0x48, 0xa7, 0x2c, 0x54, 0x0a, 0x7e, 0xe2, 0x0f, 0xf7, 0x7f, 0x50, 0x94, 0x3b, 0x02, 0x6e, + 0x05, 0x1d, 0xfe, 0x7c, 0x10, 0x2f, 0x63, 0x88, 0x47, 0x46, 0xc6, 0xf4, 0x2f, 0x29, 0xab, 0x7e, 0x05, 0x99, 0x98, + 0xaf, 0xb3, 0x07, 0xb9, 0x1a, 0xdf, 0xa9, 0xb5, 0x30, 0xae, 0x23, 0x0d, 0x4d, 0xcc, 0x4f, 0xa1, 0xb0, 0xe9, 0x2a, + 0x03, 0x0b, 0xa5, 0x0c, 0xf9, 0xbe, 0xd4, 0xed, 0xa3, 0xe1, 0x27, 0xa1, 0xe7, 0xd3, 0x0c, 0x93, 0x90, 0x16, 0x40, + 0xf5, 0xe1, 0x68, 0xd2, 0x0d, 0x76, 0xf3, 0x51, 0x07, 0x2a, 0x9d, 0x1d, 0x73, 0x4a, 0x90, 0xf3, 0xfc, 0x64, 0x1b, + 0x7b, 0xc7, 0x5f, 0x1b, 0x7f, 0x83, 0xc0, 0x67, 0xfe, 0xa3, 0x37, 0x55, 0x1b, 0x88, 0xf5, 0x72, 0x46, 0xd0, 0xb6, + 0x0c, 0xb8, 0xa5, 0xca, 0xa1, 0xd9, 0x52, 0x31, 0x2c, 0xcc, 0xd4, 0xc2, 0x14, 0x2f, 0x3a, 0x41, 0xee, 0x0f, 0x21, + 0x16, 0x28, 0x37, 0x20, 0x65, 0xc9, 0x31, 0x1d, 0x44, 0x8a, 0xde, 0x06, 0x0a, 0x22, 0x94, 0x5f, 0xbf, 0xd4, 0xff, + 0x45, 0x04, 0x58, 0x8e, 0xb4, 0xca, 0x40, 0x32, 0xb5, 0xb1, 0x9c, 0xd4, 0xe2, 0x54, 0x9c, 0x55, 0xca, 0x30, 0xf9, + 0xdd, 0x78, 0xd9, 0x9a, 0xa0, 0x66, 0x08, 0x4f, 0xc9, 0xc1, 0x1a, 0x4d, 0x4c, 0x4f, 0x99, 0xfd, 0x05, 0x17, 0xa2, + 0x41, 0x7e, 0x23, 0xb8, 0x75, 0x2c, 0x6d, 0x14, 0x78, 0xd4, 0xbe, 0x89, 0x15, 0x95, 0x56, 0xe1, 0x9c, 0xa8, 0x99, + 0x6c, 0xcb, 0x5e, 0xee, 0xca, 0x3d, 0x06, 0x2e, 0x33, 0x23, 0xd0, 0x4b, 0xeb, 0x7b, 0xef, 0x00, 0xff, 0xd1, 0xa2, + 0xc8, 0x0d, 0xdb, 0x22, 0x85, 0x8c, 0x6d, 0xbd, 0xf1, 0x5b, 0x7d, 0x8a, 0x83, 0x3c, 0xf6, 0x42, 0x2b, 0x3b, 0xe1, + 0x9d, 0xef, 0x4e, 0x19, 0xe6, 0x45, 0x1c, 0xe7, 0x59, 0x54, 0xe8, 0xc3, 0xa2, 0xaa, 0x44, 0xff, 0x09, 0x00, 0x46, + 0xee, 0x72, 0x2a, 0xfc, 0x5b, 0xc2, 0x6d, 0x7c, 0xd0, 0x4e, 0x0d, 0xe7, 0x73, 0xaa, 0xcf, 0xbb, 0xee, 0x3b, 0xfc, + 0x30, 0x7c, 0xad, 0x71, 0x44, 0x05, 0xa6, 0x69, 0x9e, 0x98, 0xad, 0xe1, 0x77, 0x0a, 0xf8, 0xfe, 0xa1, 0x14, 0xdb, + 0xb0, 0x99, 0x56, 0xed, 0xcd, 0xbc, 0xde, 0xc1, 0x67, 0xce, 0x6a, 0x96, 0xaf, 0x3f, 0xf8, 0x3e, 0xa1, 0x2c, 0xc2, + 0x6f, 0xcb, 0x44, 0x3d, 0xe2, 0x2c, 0x1d, 0x5c, 0xc0, 0xe3, 0x1e, 0xc9, 0xd0, 0xf3, 0x75, 0x36, 0x22, 0x7f, 0xb4, + 0x71, 0x01, 0x69, 0xab, 0x09, 0x25, 0xea, 0x44, 0x8f, 0x48, 0xca, 0x58, 0x58, 0x68, 0x5b, 0x1d, 0x90, 0x45, 0xc1, + 0x72, 0x1b, 0x38, 0x4f, 0x4c, 0x11, 0x0e, 0xdf, 0xb5, 0xa7, 0x8b, 0xa8, 0xff, 0x31, 0x03, 0xf8, 0x0f, 0x98, 0x18, + 0x15, 0xca, 0xff, 0x0e, 0xc3, 0x1f, 0x84, 0x11, 0x71, 0x3a, 0x31, 0x3b, 0x30, 0x60, 0xe4, 0x45, 0x65, 0x46, 0x52, + 0x62, 0xad, 0x95, 0x3c, 0xf8, 0x3e, 0x14, 0x8d, 0xeb, 0x1a, 0x84, 0x60, 0x83, 0x69, 0x05, 0xf1, 0x70, 0x1a, 0x51, + 0xd6, 0x78, 0x34, 0x7e, 0x4f, 0xa5, 0x26, 0xf4, 0xf8, 0x36, 0x4a, 0x16, 0x8f, 0xaa, 0x27, 0xca, 0x47, 0x12, 0x43, + 0xda, 0xc8, 0x49, 0xf1, 0x26, 0xe3, 0xfd, 0xb4, 0x31, 0x22, 0x39, 0x39, 0x9d, 0x1d, 0x91, 0xf2, 0x0b, 0x19, 0x66, + 0xd7, 0x7f, 0xf1, 0xf2, 0x8b, 0x2f, 0xbe, 0x96, 0x4a, 0x54, 0xd7, 0x22, 0x86, 0x6e, 0xd7, 0xd1, 0xfb, 0xae, 0x84, + 0x21, 0x1d, 0x52, 0x1e, 0x14, 0x12, 0x53, 0x59, 0x20, 0x0d, 0xf9, 0x49, 0x54, 0xfe, 0x1e, 0xe6, 0xb3, 0x77, 0xaf, + 0x52, 0x97, 0xa4, 0xac, 0x24, 0x2e, 0x0f, 0x58, 0x9a, 0x4c, 0xbc, 0x39, 0x0f, 0xbb, 0x3f, 0x27, 0x6f, 0xfe, 0xaf, + 0x28, 0x63, 0xaa, 0x29, 0x47, 0x16, 0xea, 0xa0, 0x94, 0xd5, 0x70, 0xda, 0xe2, 0x8b, 0x20, 0xda, 0x2a, 0x74, 0xa9, + 0x79, 0xe0, 0xb2, 0xb0, 0x26, 0x82, 0x2d, 0xe8, 0xe9, 0x30, 0xb2, 0x25, 0xb5, 0x89, 0x4d, 0xaf, 0x23, 0xcf, 0xf2, + 0xa9, 0xda, 0x5d, 0xea, 0x63, 0xef, 0xa0, 0x1e, 0x8b, 0xab, 0xfd, 0xd4, 0x64, 0x1a, 0x70, 0x81, 0xa0, 0x7e, 0x05, + 0xb9, 0x55, 0x8c, 0xb8, 0xd1, 0xcd, 0xfd, 0x63, 0xb5, 0x75, 0x2b, 0xff, 0xb4, 0x0b, 0x22, 0x23, 0x81, 0x81, 0x66, + 0xd1, 0x6a, 0x42, 0x3f, 0x36, 0x2c, 0x85, 0x21, 0x67, 0x4b, 0x66, 0x39, 0xaf, 0x0a, 0xda, 0x95, 0xb6, 0x82, 0x03, + 0x12, 0x46, 0xeb, 0x18, 0x33, 0x83, 0xcf, 0xa1, 0x20, 0x5f, 0xb5, 0xc9, 0x05, 0xfb, 0xe2, 0x9e, 0x26, 0x98, 0x0a, + 0xc2, 0xbc, 0x52, 0x30, 0x9d, 0xf5, 0xcd, 0xc2, 0x1c, 0x0b, 0x85, 0xfc, 0xf8, 0x0b, 0x8a, 0x83, 0xa9, 0x40, 0x17, + 0xf9, 0x2b, 0x0d, 0xdb, 0xce, 0x2c, 0xfa, 0xee, 0x83, 0x02, 0xbc, 0x51, 0x47, 0xe6, 0x25, 0x8b, 0xbf, 0x7a, 0xe7, + 0xe3, 0xe4, 0x1b, 0x2d, 0xb2, 0x8b, 0x89, 0xfe, 0x52, 0x49, 0x33, 0xbf, 0x2e, 0xf5, 0x50, 0xb6, 0xa7, 0x3c, 0xae, + 0x98, 0xe6, 0x3d, 0x4a, 0x7f, 0x1a, 0xf3, 0x84, 0x4c, 0x68, 0x2f, 0xa7, 0xbf, 0x25, 0x6a, 0x76, 0x9f, 0x59, 0xaa, + 0xfe, 0x0d, 0x2f, 0x95, 0x26, 0xe5, 0x58, 0xc6, 0xb4, 0x9e, 0x12, 0xeb, 0x96, 0x05, 0x0c, 0xb2, 0x28, 0x4e, 0x6c, + 0xb4, 0xd9, 0x3b, 0xa2, 0xf9, 0x4e, 0xed, 0x65, 0x72, 0xc2, 0xc2, 0x5c, 0x5d, 0xc9, 0x76, 0x1a, 0x61, 0xb7, 0xde, + 0x13, 0xa9, 0x21, 0x68, 0x46, 0xc9, 0xae, 0x76, 0x7b, 0x41, 0xc3, 0xc4, 0x9a, 0x49, 0x91, 0x2d, 0x9a, 0xe5, 0x4e, + 0xd0, 0x43, 0x3e, 0x95, 0xfc, 0xea, 0x3f, 0x5b, 0x88, 0x9b, 0xcd, 0xf9, 0x3d, 0x23, 0x32, 0x08, 0x83, 0xdc, 0xad, + 0x22, 0x5e, 0xce, 0x04, 0x0a, 0x63, 0x67, 0x82, 0xcd, 0xbb, 0x58, 0x47, 0x58, 0x24, 0xaa, 0x23, 0x69, 0x48, 0x57, + 0x79, 0x08, 0x54, 0xb1, 0xef, 0xc9, 0xd3, 0xca, 0x28, 0x5a, 0xbf, 0x3a, 0xf6, 0x19, 0x10, 0x52, 0x25, 0xcb, 0x8a, + 0xb4, 0x72, 0x85, 0x99, 0x81, 0x91, 0x84, 0x83, 0x23, 0xd0, 0x4d, 0x13, 0xc2, 0xcb, 0x43, 0x7a, 0x69, 0x2d, 0x35, + 0xaa, 0xc5, 0x35, 0x78, 0x25, 0x80, 0xd8, 0x64, 0x8c, 0x5f, 0xef, 0xf6, 0xf4, 0xb0, 0xbe, 0x68, 0xb1, 0xfe, 0x88, + 0x80, 0x63, 0xa4, 0xfb, 0xa2, 0x1c, 0x7a, 0x03, 0x96, 0xb5, 0xc4, 0xb7, 0x8f, 0x61, 0xa8, 0x74, 0xa0, 0x5e, 0x8e, + 0xdc, 0x22, 0xaa, 0x37, 0xc0, 0xb5, 0xdb, 0x15, 0x11, 0xbe, 0x9d, 0x1f, 0xd3, 0xa4, 0x96, 0x10, 0xc4, 0xba, 0x8f, + 0x68, 0x96, 0x89, 0xb0, 0xd9, 0xb8, 0xeb, 0x70, 0x71, 0x0c, 0x45, 0x1f, 0x9e, 0xe2, 0x22, 0x96, 0x9c, 0x2d, 0xbd, + 0xb4, 0x31, 0x4f, 0x87, 0xf4, 0x53, 0xdb, 0x51, 0xe1, 0xd1, 0x0b, 0xcb, 0x85, 0xc6, 0x9d, 0xa4, 0xe0, 0xea, 0x3d, + 0x10, 0x26, 0xe9, 0x73, 0xf7, 0x98, 0xc7, 0xd5, 0xe8, 0x2d, 0x38, 0x7d, 0x0b, 0x68, 0x6f, 0x8a, 0xe0, 0x72, 0xd5, + 0x5e, 0x9a, 0x30, 0xa3, 0x3d, 0xcf, 0x74, 0xb6, 0x24, 0x55, 0x23, 0xde, 0x8b, 0x16, 0xbc, 0x86, 0x72, 0x4f, 0x2c, + 0x61, 0xcc, 0xe0, 0xb6, 0x4b, 0x48, 0xb2, 0xaf, 0xa5, 0x82, 0x95, 0xa0, 0x07, 0xf2, 0xa8, 0x48, 0x46, 0x49, 0xa6, + 0xdb, 0xfe, 0x6c, 0xe6, 0xb6, 0x37, 0x95, 0xdf, 0xb6, 0xce, 0x44, 0x95, 0xa4, 0xaf, 0x57, 0x7d, 0xda, 0x3d, 0xa3, + 0x2b, 0x0f, 0x02, 0xfa, 0x96, 0xd1, 0x5b, 0x2e, 0xb0, 0x6e, 0xc9, 0x0d, 0xa9, 0x20, 0xf6, 0x2e, 0x2b, 0x70, 0xe1, + 0xad, 0x3d, 0x98, 0xb0, 0x06, 0xef, 0x33, 0x3d, 0x69, 0xad, 0xbe, 0x7d, 0xa9, 0xeb, 0xf8, 0xec, 0xbb, 0xed, 0x86, + 0x68, 0xf0, 0x5b, 0x2e, 0xbe, 0x17, 0x9f, 0x99, 0x69, 0x15, 0x0e, 0x66, 0x51, 0xfa, 0x2a, 0xfd, 0x8b, 0x93, 0xd6, + 0x91, 0x0b, 0x70, 0x00, 0xf2, 0x6e, 0xb8, 0x2e, 0xc6, 0x61, 0xbc, 0xe6, 0x84, 0xf3, 0xd4, 0x7b, 0xb0, 0x6b, 0xa7, + 0x14, 0xfc, 0x73, 0x86, 0x8d, 0x1c, 0x32, 0x3b, 0x5e, 0x84, 0x6f, 0x6a, 0x1b, 0x7e, 0x4e, 0xfc, 0x80, 0xbf, 0xce, + 0x0c, 0xef, 0x67, 0x71, 0xf6, 0xb6, 0xc0, 0x1f, 0xa6, 0x78, 0xe1, 0xcf, 0x95, 0x30, 0xe3, 0x2b, 0xfe, 0x95, 0xf8, + 0x6f, 0x04, 0x6f, 0x98, 0x70, 0x99, 0xad, 0x35, 0x5a, 0x64, 0xf3, 0x9b, 0x7c, 0x7a, 0x77, 0xf7, 0x70, 0x76, 0xb3, + 0x5a, 0x56, 0xb4, 0x61, 0x25, 0x7b, 0x8e, 0xea, 0x3a, 0xae, 0xca, 0xfe, 0x99, 0xc2, 0x6a, 0x69, 0xdf, 0x51, 0x24, + 0xf7, 0x26, 0xe9, 0xb3, 0xb7, 0x9b, 0x53, 0x93, 0x87, 0xa2, 0x09, 0x1d, 0xe9, 0xcb, 0xa3, 0xb5, 0x04, 0x9e, 0x96, + 0x5d, 0xa9, 0x8b, 0x60, 0xf2, 0xc3, 0xcc, 0xcb, 0x5e, 0xa4, 0x34, 0x55, 0x87, 0xdd, 0xb5, 0x8a, 0x24, 0x54, 0x69, + 0x47, 0xee, 0x94, 0x62, 0xd2, 0xaa, 0x03, 0x67, 0xa0, 0x20, 0xfb, 0x4a, 0x24, 0x4e, 0xf8, 0x21, 0x7a, 0xf0, 0x81, + 0xf1, 0xa4, 0x88, 0xe6, 0xc1, 0x14, 0xe1, 0xff, 0x9f, 0xae, 0x67, 0xd5, 0x33, 0x1b, 0xa0, 0xd6, 0xff, 0x05, 0x62, + 0x0e, 0x4d, 0x55, 0x42, 0xf2, 0xc0, 0x84, 0x3b, 0x7f, 0x7a, 0xd6, 0x0c, 0x16, 0x96, 0x1f, 0xd5, 0x61, 0x90, 0xa7, + 0xd9, 0x39, 0x99, 0xc8, 0x38, 0x4e, 0xce, 0x94, 0x42, 0x3d, 0xe3, 0xea, 0xcb, 0x35, 0x88, 0xde, 0x6b, 0xaa, 0xd5, + 0xa1, 0x93, 0x74, 0x92, 0x77, 0xc6, 0x52, 0x2c, 0xa2, 0x65, 0xbb, 0x6a, 0xe3, 0x62, 0x3b, 0x82, 0x33, 0x28, 0x38, + 0xcb, 0x1c, 0x7a, 0x0f, 0x16, 0xda, 0xee, 0xdc, 0xd8, 0x61, 0xba, 0x37, 0x37, 0x0e, 0x47, 0x04, 0x8d, 0xdd, 0x36, + 0xdd, 0xb6, 0x22, 0x2a, 0xb1, 0xd9, 0xa2, 0x8b, 0x78, 0x63, 0x40, 0x40, 0x2f, 0x3e, 0x4e, 0xf5, 0xa9, 0xcf, 0xdb, + 0x4e, 0xbe, 0xd2, 0x09, 0xcb, 0x5a, 0xce, 0xbd, 0xc3, 0x78, 0xd5, 0x8d, 0x82, 0xd0, 0x2c, 0x84, 0x54, 0xf6, 0x42, + 0xe7, 0x09, 0xd8, 0xc4, 0x88, 0x3f, 0x62, 0x2b, 0xa1, 0x4c, 0x0e, 0xac, 0x0a, 0x4a, 0xc7, 0x87, 0x9a, 0x1d, 0x08, + 0x42, 0x57, 0xfb, 0xc8, 0x06, 0x72, 0x2c, 0xb4, 0x13, 0x19, 0x88, 0x26, 0x0e, 0x81, 0x6b, 0xac, 0x11, 0xd6, 0x47, + 0x32, 0x5e, 0x0e, 0x6d, 0xbd, 0x59, 0x03, 0x9b, 0xa8, 0xcd, 0x41, 0xef, 0xfe, 0x53, 0xde, 0xa1, 0x8b, 0x22, 0x6b, + 0x3d, 0x67, 0x4c, 0xcf, 0x22, 0xe6, 0x41, 0xb1, 0x2c, 0x81, 0x46, 0x11, 0x8a, 0xd0, 0xbf, 0x27, 0xf6, 0x50, 0x4f, + 0x2a, 0xa3, 0x3c, 0x61, 0x3e, 0xac, 0x50, 0x4d, 0xab, 0xa5, 0xa4, 0x53, 0x16, 0x1e, 0xc3, 0xdd, 0xc1, 0x8f, 0xaf, + 0xfd, 0xd1, 0xb8, 0xfe, 0x6a, 0xf4, 0xb1, 0xed, 0xf9, 0x9a, 0x96, 0xa9, 0x1f, 0x67, 0x4d, 0x7e, 0x1d, 0x9c, 0x37, + 0x16, 0x9b, 0xed, 0x95, 0x1e, 0xb8, 0x64, 0x22, 0x50, 0xaa, 0x5f, 0x66, 0xfb, 0x01, 0x9d, 0x2d, 0x14, 0x9f, 0x1a, + 0x15, 0xe5, 0xde, 0x5a, 0x8d, 0x65, 0x80, 0x70, 0x0c, 0x69, 0xa4, 0x5d, 0x62, 0x0a, 0x22, 0xad, 0xcf, 0xd2, 0x53, + 0xc0, 0x6b, 0x6b, 0x34, 0x8f, 0xe0, 0x90, 0x21, 0xd3, 0x24, 0xb1, 0x22, 0xfd, 0x0c, 0x10, 0xb7, 0x11, 0xd2, 0x8b, + 0xf4, 0x0f, 0x28, 0x01, 0x78, 0x15, 0xd1, 0xde, 0xa8, 0x8c, 0x45, 0xb2, 0x2a, 0xab, 0x95, 0xc2, 0x72, 0x7c, 0xc0, + 0xbf, 0xf4, 0x0f, 0x0b, 0x16, 0xa8, 0x1a, 0xe9, 0xd8, 0xc2, 0x4d, 0x04, 0x6a, 0x85, 0xed, 0x46, 0x88, 0xe2, 0xfb, + 0x75, 0x7d, 0xa4, 0x6c, 0x2e, 0x16, 0xc7, 0x18, 0x5b, 0xed, 0xcd, 0xd7, 0x5c, 0x6e, 0x3b, 0x43, 0xb7, 0x6d, 0xee, + 0xc0, 0xde, 0xa4, 0x7b, 0x1a, 0xbf, 0x7d, 0xab, 0xe1, 0x29, 0x7e, 0xf3, 0x6a, 0xa4, 0xf4, 0xf2, 0x78, 0x25, 0x74, + 0xef, 0xc9, 0x82, 0xe6, 0xc6, 0x65, 0x5c, 0xfa, 0xdd, 0x7b, 0x8a, 0xf0, 0x57, 0xfd, 0x57, 0x6a, 0x3c, 0xa9, 0xca, + 0xca, 0xdf, 0xac, 0x14, 0xf6, 0x07, 0x86, 0xaa, 0xca, 0x90, 0x21, 0x90, 0xa7, 0x4a, 0x0f, 0xb6, 0x27, 0x51, 0x6e, + 0xbf, 0x29, 0x69, 0x6c, 0x01, 0x17, 0x8a, 0x4e, 0x8d, 0xac, 0x72, 0xc2, 0xb3, 0x9e, 0xad, 0xf7, 0x90, 0x44, 0x5c, + 0xb9, 0xce, 0xc4, 0x11, 0x77, 0x3f, 0xe7, 0xf3, 0x8f, 0x2a, 0x02, 0x3a, 0x64, 0xf1, 0x51, 0x77, 0x19, 0x05, 0x41, + 0xc3, 0x0b, 0x69, 0x98, 0x20, 0x33, 0xa1, 0xac, 0xa9, 0x76, 0x5f, 0xa6, 0x69, 0xbd, 0x3e, 0x7f, 0x2e, 0x8e, 0xbd, + 0x1d, 0x90, 0xcc, 0xc6, 0x9c, 0x69, 0x56, 0x12, 0x37, 0xf2, 0xe0, 0x54, 0xd1, 0xe6, 0x2c, 0xc8, 0xd6, 0xea, 0xad, + 0x9e, 0x93, 0x39, 0xe0, 0x24, 0xd2, 0x32, 0xcc, 0x8f, 0x3c, 0xe7, 0xcf, 0x15, 0x27, 0xd3, 0xe8, 0xbe, 0x57, 0x63, + 0xdb, 0x66, 0x98, 0xf4, 0x24, 0x03, 0x40, 0x9d, 0x08, 0xe0, 0x9b, 0x12, 0xd4, 0x01, 0x8a, 0xaf, 0x2d, 0x8b, 0xc9, + 0x4c, 0x0c, 0x08, 0x26, 0x93, 0xfd, 0xda, 0x93, 0x03, 0xd3, 0x99, 0x08, 0x6c, 0x18, 0xf2, 0xcf, 0x40, 0x54, 0xe3, + 0xdb, 0x14, 0x24, 0xa1, 0x68, 0x4d, 0xa6, 0xb8, 0xf9, 0x04, 0x05, 0x9e, 0x7d, 0x11, 0xb2, 0xa5, 0x7e, 0x53, 0xc6, + 0x29, 0x84, 0xcd, 0x69, 0x3d, 0x98, 0xb2, 0x32, 0x9e, 0x49, 0x78, 0x8d, 0xe3, 0x9f, 0x2d, 0x82, 0xc7, 0xa8, 0xbc, + 0x8c, 0xc1, 0x1a, 0x8d, 0x5f, 0xc0, 0xe0, 0xb2, 0xb7, 0xa2, 0xc3, 0x28, 0xae, 0x3f, 0x6c, 0x8d, 0x71, 0x92, 0xd5, + 0x7e, 0x71, 0xf6, 0x37, 0xdb, 0x6f, 0x3d, 0x72, 0xf3, 0xd5, 0xcd, 0xce, 0x0e, 0x4d, 0x6b, 0x05, 0x83, 0x1e, 0xc6, + 0x60, 0x03, 0x70, 0xc5, 0x38, 0xb3, 0x91, 0x62, 0x47, 0xcd, 0x33, 0xe0, 0xa8, 0x57, 0x37, 0x3f, 0xd2, 0xee, 0xf8, + 0x79, 0x86, 0xf8, 0x44, 0x22, 0x4c, 0xde, 0x89, 0x60, 0x83, 0x5a, 0xba, 0xa3, 0x3f, 0xf2, 0x54, 0x86, 0x16, 0x15, + 0x6f, 0x26, 0x79, 0x4e, 0x31, 0xd1, 0xb2, 0xdc, 0x1e, 0x3d, 0xf1, 0x16, 0xd3, 0x52, 0x87, 0xda, 0x65, 0xed, 0x34, + 0x8d, 0x1d, 0x53, 0xa8, 0x27, 0xec, 0xf8, 0x3b, 0x8c, 0xf2, 0xaf, 0x85, 0x97, 0x7f, 0x3e, 0xfe, 0xd7, 0x1c, 0xff, + 0x22, 0xdc, 0xd5, 0xc9, 0xea, 0xf0, 0xe7, 0xe8, 0xff, 0x1e, 0x1f, 0x50, 0x9d, 0xbc, 0xd9, 0xda, 0x7c, 0xa3, 0x5a, + 0x6f, 0x92, 0x47, 0x6b, 0x3d, 0x4e, 0xd0, 0x57, 0x1f, 0x77, 0x4e, 0x27, 0x18, 0xe7, 0x9c, 0x3a, 0x3f, 0x8a, 0xfd, + 0xd1, 0x12, 0x17, 0x7e, 0x31, 0xfe, 0xb0, 0xb5, 0x99, 0xdc, 0xa3, 0xfd, 0x37, 0xa4, 0x72, 0x9f, 0x9f, 0x50, 0x6d, + 0x2b, 0x1a, 0x24, 0x7f, 0x7f, 0xe8, 0xdc, 0x11, 0x81, 0x0d, 0xe7, 0xfe, 0xeb, 0x35, 0x8d, 0x3f, 0x19, 0x5c, 0x44, + 0x8a, 0xd6, 0x0a, 0x8b, 0xcf, 0xf4, 0x99, 0x56, 0x56, 0xdf, 0xb8, 0x55, 0x65, 0x1b, 0x3a, 0xa1, 0x36, 0xdd, 0x00, + 0xc4, 0xa4, 0x82, 0x06, 0x65, 0xed, 0x7e, 0xf9, 0xd3, 0x44, 0x1b, 0x12, 0x8a, 0x7e, 0xf6, 0x83, 0x91, 0x76, 0x37, + 0xa7, 0x0a, 0x20, 0xb1, 0x61, 0x7a, 0xfc, 0x52, 0x30, 0x19, 0xd0, 0xf0, 0x50, 0x47, 0x17, 0xad, 0x55, 0x9f, 0x45, + 0x7a, 0x63, 0xbd, 0x26, 0xc5, 0xf5, 0x15, 0x90, 0x20, 0xa4, 0x69, 0xb8, 0xfc, 0x3f, 0xfe, 0x44, 0x24, 0x4d, 0xaf, + 0xd1, 0x50, 0x39, 0x0d, 0xfd, 0xf5, 0x3b, 0xb4, 0xec, 0x85, 0x96, 0x33, 0x7b, 0x19, 0x15, 0x03, 0xcf, 0x82, 0xa0, + 0xba, 0x22, 0xc7, 0x26, 0x57, 0xe3, 0x39, 0x29, 0xc7, 0xfc, 0x1f, 0x67, 0x79, 0xbd, 0x86, 0x39, 0xc7, 0x88, 0xef, + 0xec, 0x02, 0x61, 0x49, 0x56, 0xc3, 0xc6, 0xec, 0x41, 0x7f, 0xf8, 0xe8, 0x0d, 0x34, 0xe8, 0x87, 0x8f, 0xbf, 0x20, + 0x01, 0x5f, 0xf8, 0x61, 0x74, 0x35, 0x2a, 0x1e, 0x9b, 0xd3, 0xda, 0xf5, 0x71, 0x63, 0x60, 0xe8, 0x23, 0x11, 0x1c, + 0x72, 0x0a, 0x10, 0x5f, 0x24, 0x9d, 0x1d, 0x4d, 0x1c, 0x73, 0x39, 0x24, 0x07, 0xed, 0x38, 0x97, 0x2a, 0x53, 0x0e, + 0x35, 0xa7, 0x8e, 0x72, 0x40, 0x8e, 0xf3, 0xe8, 0x40, 0xb3, 0x6e, 0x2f, 0x27, 0xe3, 0xcb, 0x9c, 0xb4, 0xcb, 0x66, + 0xb3, 0xd7, 0xd4, 0x30, 0x93, 0x88, 0x91, 0x0a, 0xde, 0xe4, 0xac, 0x8a, 0xa0, 0x5f, 0x74, 0x8a, 0xa9, 0x8d, 0x78, + 0xb8, 0xb7, 0x9e, 0x9d, 0x3a, 0x0f, 0x34, 0xb8, 0x32, 0x20, 0xaf, 0x78, 0x0d, 0xb8, 0x51, 0xc8, 0x84, 0x59, 0xc2, + 0x02, 0x2e, 0xcc, 0xdf, 0x7d, 0xe2, 0x3e, 0xd8, 0x2f, 0xa2, 0xe0, 0x3c, 0x7b, 0x6f, 0x06, 0xcb, 0x12, 0x76, 0x41, + 0xf5, 0xc6, 0x7d, 0xee, 0x3d, 0xfe, 0x71, 0xc3, 0x14, 0x14, 0x58, 0x06, 0xf9, 0x74, 0xe7, 0x0b, 0x22, 0xf0, 0x03, + 0xfb, 0xc3, 0x3c, 0xe6, 0xec, 0x1f, 0x9a, 0x53, 0x73, 0x4b, 0x28, 0x1b, 0x48, 0x75, 0x69, 0xcb, 0x82, 0xb3, 0xd3, + 0x61, 0x8b, 0xf3, 0x9e, 0xa3, 0x46, 0xa9, 0xee, 0xa9, 0x83, 0x32, 0x21, 0x5a, 0xe6, 0x14, 0xd8, 0x22, 0x80, 0x96, + 0xad, 0x08, 0xaf, 0x03, 0xe5, 0xa5, 0x66, 0x46, 0x43, 0x7f, 0x88, 0xb3, 0x49, 0xf8, 0x06, 0x74, 0x72, 0xd1, 0xe1, + 0xa2, 0xcb, 0xa5, 0x53, 0x7a, 0x7c, 0x3c, 0x40, 0x34, 0x76, 0xce, 0xc2, 0x60, 0x5e, 0x4f, 0x52, 0xbe, 0xf4, 0xec, + 0xd7, 0xe3, 0xa2, 0xbd, 0x36, 0xfa, 0x70, 0x32, 0x4d, 0x98, 0xd8, 0x80, 0x9a, 0x56, 0xc7, 0x21, 0x1e, 0x3c, 0xa4, + 0x80, 0x1e, 0x94, 0x66, 0x79, 0xdf, 0x04, 0x52, 0x48, 0x45, 0xc8, 0x44, 0x5e, 0x16, 0x7a, 0xb6, 0x0e, 0x06, 0x82, + 0x9a, 0xed, 0x8c, 0x4f, 0x75, 0xd2, 0x68, 0xa9, 0x78, 0x81, 0x98, 0x12, 0x46, 0x48, 0xd3, 0xfa, 0x27, 0xa0, 0x1b, + 0xbe, 0x06, 0x28, 0x7f, 0x52, 0x2e, 0x3b, 0x9e, 0x59, 0xc6, 0x0e, 0xe1, 0x80, 0x9f, 0xaa, 0x02, 0x77, 0x17, 0x15, + 0xfa, 0xc7, 0xf3, 0xd1, 0x90, 0x1c, 0x22, 0x34, 0x0c, 0x95, 0x70, 0x01, 0x91, 0x51, 0xea, 0x63, 0x87, 0xd0, 0xeb, + 0x7e, 0x40, 0xbe, 0xf8, 0x23, 0x9a, 0xf0, 0x88, 0x3b, 0xe5, 0xad, 0xae, 0x5a, 0x68, 0xe2, 0x23, 0xee, 0x82, 0x06, + 0xdf, 0x7c, 0x70, 0x9a, 0xee, 0x1e, 0x55, 0x96, 0x56, 0xe8, 0x13, 0x0d, 0x64, 0x4a, 0xf5, 0xf4, 0x7a, 0xa6, 0x9a, + 0xde, 0x2c, 0xa1, 0x95, 0x40, 0xd9, 0xc6, 0x74, 0x9e, 0xc6, 0x96, 0xed, 0xb5, 0x8b, 0x14, 0xf9, 0xf3, 0x34, 0x62, + 0x0d, 0x5b, 0x02, 0x76, 0xe3, 0x8e, 0xbe, 0xed, 0x64, 0xc7, 0xd0, 0x10, 0x25, 0xbd, 0xa8, 0x38, 0x1d, 0x63, 0xe4, + 0xe6, 0x75, 0x0f, 0xd8, 0x2e, 0xa3, 0xb7, 0xd5, 0xc0, 0x70, 0xee, 0x9b, 0xd4, 0x9c, 0x14, 0x9c, 0xf3, 0xd6, 0xfd, + 0x75, 0x82, 0x34, 0x9e, 0xe7, 0xad, 0x8b, 0xf7, 0x22, 0x9e, 0x69, 0xf3, 0xaf, 0x17, 0xe5, 0xf9, 0xaa, 0xc6, 0x65, + 0xeb, 0xaf, 0x49, 0xb0, 0x85, 0xec, 0x67, 0x15, 0x52, 0xfd, 0x47, 0xc5, 0x8e, 0x78, 0x7b, 0x3e, 0xa7, 0x02, 0x67, + 0xae, 0x3a, 0x3e, 0x2a, 0xbe, 0x41, 0x2f, 0x0e, 0x07, 0x38, 0x07, 0x01, 0xf2, 0xc0, 0x49, 0xa8, 0xc9, 0x3c, 0x60, + 0xcc, 0xa9, 0x56, 0xf3, 0x15, 0xeb, 0x31, 0xeb, 0x0d, 0x33, 0x3c, 0x57, 0xff, 0x03, 0xd4, 0x80, 0x0b, 0xe8, 0x0f, + 0x3b, 0xbc, 0xaf, 0x31, 0x84, 0x46, 0xdc, 0x8d, 0x7c, 0x62, 0xf0, 0xbb, 0xfc, 0x37, 0x83, 0x99, 0x6c, 0x24, 0xc8, + 0xcc, 0x3a, 0xd5, 0x3e, 0x31, 0x59, 0x19, 0x82, 0x7a, 0x2d, 0xed, 0xa6, 0xf4, 0x10, 0x19, 0x8a, 0x70, 0x02, 0x0c, + 0x14, 0xb4, 0x31, 0x81, 0x57, 0x57, 0x68, 0xa6, 0x1b, 0xcc, 0xd5, 0x47, 0x4d, 0x9d, 0x43, 0xdc, 0x2b, 0x2d, 0x95, + 0xc1, 0xa0, 0x36, 0x08, 0xbc, 0x6b, 0xbf, 0xfc, 0xc3, 0x32, 0x9e, 0x27, 0x87, 0xaa, 0x9f, 0x0e, 0x1b, 0xc3, 0x35, + 0x75, 0xac, 0x7a, 0xfd, 0xcf, 0xd4, 0x24, 0xc6, 0xa7, 0x46, 0x82, 0xc1, 0xba, 0x8a, 0x13, 0x2d, 0x88, 0xd3, 0x46, + 0x69, 0x17, 0x8a, 0x3a, 0xd4, 0x02, 0x2e, 0x0d, 0xa9, 0x71, 0xc0, 0x2a, 0x37, 0x2f, 0xcf, 0x0d, 0x74, 0xe2, 0x39, + 0x7f, 0x9d, 0x99, 0xf0, 0xa1, 0x9e, 0xe6, 0x50, 0xd7, 0x26, 0xcf, 0xe5, 0xfd, 0xf8, 0xc5, 0xca, 0x43, 0x22, 0x27, + 0xb1, 0xd0, 0x26, 0x9b, 0xeb, 0x7c, 0xbe, 0xc0, 0x62, 0x23, 0x88, 0xfa, 0x7c, 0x85, 0x0a, 0xa2, 0xc3, 0x61, 0x53, + 0x4c, 0x75, 0xc4, 0x33, 0xc6, 0x44, 0xa5, 0xed, 0x62, 0x33, 0x1c, 0xc0, 0x00, 0x9c, 0x8b, 0xb2, 0x96, 0x8f, 0xdf, + 0xa6, 0xd1, 0x9f, 0xe4, 0xec, 0x4c, 0x4a, 0x19, 0xbf, 0x21, 0xfb, 0x33, 0xbe, 0x3f, 0x62, 0x74, 0xef, 0xdf, 0xc9, + 0x3e, 0xed, 0x5f, 0x33, 0xb6, 0x31, 0xb6, 0x24, 0x6f, 0xcc, 0xec, 0xab, 0xcd, 0xcb, 0xb8, 0x24, 0x0a, 0xc8, 0xfe, + 0x46, 0xe3, 0x61, 0x9a, 0x87, 0x38, 0x3c, 0xac, 0x1a, 0x45, 0x7e, 0x47, 0x41, 0x96, 0x18, 0xe0, 0x6d, 0xa6, 0x45, + 0xba, 0x99, 0xc0, 0xdb, 0xa0, 0x94, 0x74, 0x68, 0x77, 0xa6, 0x2c, 0x31, 0xa8, 0xc2, 0xc0, 0x20, 0x22, 0x77, 0xba, + 0x04, 0xa2, 0xdd, 0x4a, 0x66, 0x4f, 0xf0, 0x3e, 0xa6, 0xa1, 0x13, 0xb7, 0x6c, 0x79, 0x8b, 0x6d, 0x4d, 0xcd, 0xec, + 0xe8, 0x85, 0x9a, 0xa1, 0x30, 0x32, 0x3a, 0x7d, 0xa1, 0xd6, 0x8f, 0x26, 0x64, 0xa9, 0x10, 0xbf, 0x2a, 0xf1, 0x55, + 0xeb, 0x6b, 0xa9, 0x10, 0x57, 0x67, 0x17, 0x39, 0x86, 0x9f, 0x65, 0x88, 0xc7, 0xd8, 0x8e, 0x7b, 0xeb, 0x6b, 0x0f, + 0x27, 0x80, 0x8a, 0xa4, 0x65, 0x48, 0x6e, 0xe5, 0xd8, 0x90, 0x86, 0x96, 0xfe, 0xf0, 0x74, 0x86, 0x99, 0x22, 0x40, + 0x67, 0xcd, 0x13, 0x4f, 0x5d, 0x4c, 0xd5, 0x7f, 0xa7, 0xa0, 0x62, 0xfb, 0x83, 0xca, 0x00, 0x38, 0x49, 0x1d, 0x44, + 0x23, 0xb3, 0xcf, 0x3a, 0x8d, 0x3e, 0xe4, 0xe2, 0x29, 0x38, 0x02, 0x96, 0x53, 0xe4, 0x9a, 0x33, 0x5a, 0xd7, 0x32, + 0xa4, 0x49, 0xb6, 0x6f, 0x97, 0xe3, 0xde, 0x05, 0x77, 0x68, 0xd2, 0x48, 0x68, 0xa9, 0xba, 0x42, 0xae, 0x94, 0xa5, + 0xa3, 0xee, 0xb4, 0x1b, 0x53, 0x6e, 0xac, 0x70, 0x2b, 0x73, 0xd1, 0xb1, 0x8c, 0x55, 0x39, 0xc2, 0x22, 0x5d, 0x1c, + 0x05, 0x96, 0x05, 0xf8, 0x1e, 0x18, 0x44, 0xa5, 0x2a, 0xcb, 0x44, 0x11, 0x92, 0xea, 0x84, 0x05, 0xc6, 0xb2, 0xf9, + 0x7e, 0x13, 0x09, 0x1e, 0x7c, 0xfd, 0x37, 0x8c, 0x24, 0xb1, 0x11, 0x10, 0x40, 0x83, 0x86, 0x16, 0x50, 0xcd, 0xfc, + 0x5e, 0xd9, 0x2d, 0x84, 0xce, 0x93, 0xf8, 0xa0, 0x92, 0x64, 0xd0, 0x9f, 0xff, 0xc7, 0x04, 0x31, 0x68, 0x1d, 0x52, + 0xce, 0x82, 0x03, 0x6e, 0x98, 0x9b, 0x4e, 0xa2, 0xba, 0x6c, 0x51, 0x2c, 0xb6, 0xd8, 0xf3, 0xb9, 0x0d, 0x6a, 0x05, + 0x2b, 0x2f, 0x21, 0xa5, 0x1d, 0xcd, 0x57, 0x5e, 0x87, 0x2a, 0x6f, 0x79, 0x8d, 0x3b, 0x4c, 0xf4, 0x0b, 0x27, 0xba, + 0x26, 0xab, 0xd1, 0xad, 0x23, 0x00, 0x99, 0x8d, 0x03, 0xd5, 0x1b, 0x84, 0x4b, 0x48, 0xd9, 0xe8, 0x2d, 0x73, 0x6e, + 0xf0, 0xdb, 0xf9, 0x9c, 0x90, 0xc4, 0xc8, 0x85, 0x26, 0x80, 0x93, 0x38, 0x25, 0xb4, 0xa9, 0x8b, 0x9c, 0xa9, 0xd3, + 0x13, 0xde, 0x3a, 0x68, 0x6e, 0x6d, 0x36, 0x42, 0xb1, 0x97, 0xf5, 0x49, 0x11, 0x25, 0x55, 0x97, 0x83, 0x72, 0x53, + 0x82, 0x5d, 0xfb, 0x31, 0xde, 0xca, 0x30, 0x64, 0x37, 0x2b, 0x60, 0x24, 0x66, 0x42, 0x72, 0x26, 0x48, 0x92, 0x65, + 0xd2, 0x65, 0x2d, 0xcd, 0xea, 0xda, 0x7f, 0xb4, 0x10, 0x1e, 0x91, 0x8c, 0xf3, 0xb3, 0x3c, 0x94, 0x1d, 0x57, 0xd6, + 0x29, 0xb2, 0x3c, 0x3d, 0x11, 0xae, 0xbb, 0x55, 0x35, 0x35, 0xbc, 0x07, 0x44, 0x64, 0x72, 0xcb, 0x56, 0xf5, 0xb1, + 0x33, 0xc1, 0xcf, 0x5c, 0x1e, 0x88, 0x8b, 0x07, 0x15, 0x49, 0xe8, 0xe7, 0xdb, 0x3c, 0x4f, 0x14, 0x1a, 0xbd, 0x43, + 0xce, 0xad, 0xe4, 0xe2, 0x5c, 0x0b, 0x94, 0x58, 0xf0, 0xe5, 0xf6, 0xa4, 0x3a, 0x47, 0x1e, 0xf8, 0x4e, 0x9c, 0x09, + 0x5d, 0x64, 0x5e, 0xe9, 0x1a, 0x79, 0x2b, 0xbd, 0x57, 0xd5, 0xc8, 0x1f, 0xfc, 0xea, 0x7f, 0x59, 0xe9, 0x35, 0x7a, + 0x11, 0x89, 0x33, 0x5f, 0xe2, 0x12, 0xed, 0x0c, 0xec, 0x30, 0x4e, 0xea, 0x9a, 0xbb, 0x2f, 0x80, 0x56, 0x17, 0xde, + 0x74, 0xb4, 0x16, 0x09, 0x3c, 0xd7, 0xdd, 0x25, 0xae, 0x84, 0x1d, 0x6e, 0xa0, 0xd8, 0xc3, 0x0c, 0x06, 0x42, 0xa3, + 0xc8, 0x86, 0x03, 0xc0, 0xcf, 0x21, 0xfe, 0x1a, 0xf3, 0xa3, 0x6e, 0xd9, 0x46, 0x0b, 0x9c, 0x53, 0x64, 0x06, 0xd9, + 0x8b, 0xc8, 0x80, 0x1c, 0xea, 0x84, 0x2c, 0xc8, 0x35, 0x6a, 0xec, 0x80, 0xb5, 0xc2, 0x0a, 0x65, 0x35, 0xc0, 0xb1, + 0xc1, 0x66, 0xed, 0xa5, 0xb9, 0xa9, 0xc0, 0xa7, 0x4b, 0x44, 0xae, 0xe9, 0x91, 0x50, 0xbe, 0x82, 0x14, 0x54, 0xa4, + 0x9f, 0x57, 0xff, 0x0a, 0x4c, 0x7a, 0x3b, 0x27, 0x68, 0x17, 0x91, 0x71, 0xbf, 0xd0, 0x11, 0x28, 0x2d, 0x62, 0xfb, + 0x87, 0xc9, 0xf1, 0x75, 0x30, 0xa6, 0x6b, 0xe4, 0x73, 0x6b, 0xcd, 0x3f, 0x41, 0xf5, 0x3c, 0x19, 0x0f, 0x14, 0xa9, + 0x30, 0x00, 0xfc, 0xde, 0x08, 0x1a, 0xef, 0xfd, 0xdf, 0x33, 0x1c, 0x67, 0x74, 0x4b, 0x28, 0x3c, 0x02, 0xf2, 0x4d, + 0xfe, 0x17, 0xc3, 0x78, 0x54, 0x00, 0x3b, 0x2b, 0xf2, 0xde, 0xd0, 0xde, 0xad, 0x43, 0xc0, 0xd0, 0x37, 0x60, 0xcc, + 0xfc, 0x0d, 0x47, 0xd9, 0x40, 0x6e, 0xdb, 0x19, 0xae, 0xab, 0x92, 0x66, 0x26, 0x19, 0x1e, 0x49, 0x0c, 0x52, 0x69, + 0xe4, 0x47, 0x5d, 0x59, 0x9c, 0x66, 0xee, 0x2a, 0x38, 0xf2, 0xb3, 0xc7, 0x33, 0x6c, 0xde, 0xd8, 0x88, 0x3b, 0x5e, + 0x80, 0x34, 0x37, 0x34, 0x00, 0xe0, 0x85, 0x4b, 0x45, 0x87, 0x3b, 0xe6, 0x2a, 0x5b, 0x81, 0xfa, 0x69, 0xa2, 0x39, + 0x38, 0xce, 0x46, 0x15, 0xf2, 0x09, 0xb7, 0x1b, 0xf1, 0x79, 0x0e, 0x10, 0x8f, 0x63, 0xa5, 0x32, 0x18, 0x12, 0x05, + 0x3f, 0x11, 0x61, 0x47, 0xd3, 0x89, 0xb3, 0xe4, 0xae, 0x52, 0x7b, 0x0c, 0x50, 0x0d, 0x09, 0x58, 0x65, 0x6c, 0xc3, + 0xfa, 0x45, 0x90, 0xb8, 0xac, 0xef, 0x18, 0x2d, 0xeb, 0xb0, 0x50, 0x0b, 0x1f, 0x39, 0xa7, 0x1f, 0xe2, 0xa0, 0x10, + 0x67, 0x23, 0x9c, 0x67, 0x20, 0x79, 0xda, 0x40, 0x66, 0xe4, 0xc5, 0xf8, 0xbd, 0x74, 0x67, 0xbb, 0x61, 0x65, 0x48, + 0xba, 0xc5, 0x5b, 0x6d, 0x3d, 0x93, 0xfc, 0x88, 0x1c, 0x38, 0x29, 0x02, 0xc9, 0x24, 0x52, 0x41, 0x95, 0xd2, 0x60, + 0xe5, 0xaf, 0x00, 0x28, 0x98, 0x6b, 0x5e, 0xd3, 0x54, 0x4f, 0xcb, 0x84, 0xdd, 0xe6, 0x68, 0xb0, 0x4e, 0x1c, 0xaa, + 0x1f, 0x0c, 0x3a, 0x85, 0x38, 0x43, 0xbb, 0xc0, 0x03, 0x8d, 0x4c, 0xec, 0xf1, 0xe7, 0xf9, 0x49, 0xc1, 0x3b, 0xab, + 0x34, 0x4b, 0xc1, 0x33, 0x95, 0x32, 0x78, 0x0c, 0x56, 0xe7, 0xdf, 0xee, 0x6b, 0xa2, 0xd2, 0x80, 0x00, 0xd0, 0x51, + 0xcc, 0xe1, 0xbc, 0x9b, 0xa2, 0x49, 0x77, 0x6a, 0xb2, 0xff, 0xd6, 0xab, 0xdb, 0x9b, 0x71, 0x94, 0x17, 0xdd, 0x61, + 0x35, 0xf1, 0x71, 0xd2, 0x84, 0xed, 0x8c, 0xad, 0xd4, 0xf5, 0x0b, 0xb0, 0x00, 0x76, 0x99, 0xf1, 0x6c, 0x0c, 0xaf, + 0xeb, 0xc8, 0x4e, 0x17, 0xe4, 0xea, 0xe1, 0xa3, 0x9a, 0xc3, 0x47, 0xdc, 0x72, 0x72, 0xca, 0x11, 0x9c, 0x59, 0x04, + 0xcd, 0x0c, 0xa0, 0x02, 0xf2, 0x12, 0x9a, 0x92, 0x2e, 0x08, 0x7e, 0x6d, 0x90, 0x34, 0x1f, 0x30, 0x06, 0xe0, 0xa3, + 0xbe, 0xd3, 0x9c, 0xbf, 0x19, 0x9c, 0xee, 0x44, 0xbc, 0xb7, 0xa8, 0xe2, 0x97, 0x56, 0xca, 0x90, 0x29, 0x4f, 0x2e, + 0xd9, 0x2a, 0xac, 0x42, 0xd5, 0xda, 0xae, 0x43, 0x09, 0xf1, 0x19, 0xed, 0x0f, 0x2e, 0x28, 0xde, 0xc1, 0x40, 0x7d, + 0xe1, 0x47, 0xde, 0x69, 0xbd, 0x8a, 0x66, 0x2d, 0x6c, 0xbd, 0xf8, 0xbe, 0x6a, 0x5a, 0x03, 0x47, 0x76, 0xb6, 0x57, + 0xfa, 0x67, 0x75, 0x18, 0xad, 0x43, 0x54, 0xfe, 0xac, 0xfe, 0x4a, 0x37, 0x75, 0xcb, 0x9a, 0xc6, 0xaf, 0x23, 0xf1, + 0x9b, 0x24, 0x4c, 0xea, 0xb5, 0x5b, 0xd3, 0xe3, 0xf4, 0x38, 0xd1, 0x38, 0x75, 0x72, 0xf7, 0xfc, 0xd7, 0x68, 0x75, + 0xd4, 0xa0, 0xed, 0x64, 0xda, 0xa6, 0xdf, 0x35, 0x96, 0x28, 0x4d, 0xaa, 0xa7, 0xb1, 0x73, 0x6d, 0x17, 0x2f, 0x16, + 0x1d, 0x12, 0xdd, 0x9f, 0x75, 0x5f, 0x91, 0xb9, 0x16, 0x26, 0x7e, 0x66, 0x52, 0x43, 0x5c, 0x6b, 0x35, 0xf1, 0xce, + 0x5e, 0x6c, 0x4b, 0x8e, 0xdd, 0x74, 0x95, 0x64, 0x30, 0xa8, 0x8e, 0x4c, 0x0d, 0x89, 0x64, 0x88, 0xa8, 0x5f, 0x3e, + 0x08, 0x98, 0x75, 0x8d, 0x77, 0xcf, 0xd7, 0xa4, 0x71, 0xfa, 0xd6, 0x63, 0xae, 0x3f, 0x2f, 0xc3, 0xed, 0x7b, 0x04, + 0xce, 0xb6, 0x29, 0xfd, 0xe8, 0x8d, 0x52, 0xa7, 0x8d, 0x92, 0x58, 0x4e, 0xd3, 0x13, 0x28, 0xff, 0x80, 0x48, 0x22, + 0xfc, 0xa4, 0x29, 0x3b, 0x49, 0x25, 0xd3, 0x6f, 0xd4, 0xdd, 0x7e, 0xaf, 0x84, 0x40, 0x7a, 0xfb, 0x47, 0x1d, 0x55, + 0xd3, 0xcb, 0x44, 0x12, 0xab, 0x0e, 0xc4, 0x6b, 0x0a, 0x43, 0xee, 0xf3, 0x2f, 0xb6, 0x77, 0xca, 0x28, 0x14, 0x51, + 0xd6, 0x92, 0xde, 0x01, 0x4c, 0x43, 0x0d, 0x23, 0xa3, 0x68, 0xd8, 0x26, 0xe5, 0xef, 0xf1, 0xc7, 0xd9, 0x30, 0xa0, + 0x4d, 0x47, 0xa5, 0x0d, 0x5d, 0xb0, 0xaa, 0xde, 0xc2, 0xef, 0xd3, 0x53, 0x5f, 0xb0, 0xe6, 0x15, 0xf6, 0x4e, 0xdf, + 0xde, 0xe6, 0xcf, 0xe7, 0xfc, 0xfc, 0xf9, 0xac, 0x37, 0xbc, 0x61, 0x66, 0x65, 0xdc, 0xab, 0xe0, 0xe5, 0x82, 0xae, + 0x71, 0x28, 0xc1, 0x53, 0x5b, 0xfe, 0xa3, 0x13, 0x30, 0xe5, 0x01, 0xce, 0x68, 0x03, 0x7d, 0x2a, 0x03, 0xa7, 0x9b, + 0x1b, 0x66, 0x34, 0x5d, 0x99, 0x19, 0x69, 0x66, 0x3c, 0x29, 0xa2, 0xcf, 0x49, 0xcc, 0xc1, 0x1e, 0xc9, 0x59, 0xfa, + 0x58, 0xcc, 0xf8, 0x51, 0x69, 0x0b, 0xda, 0x0e, 0x85, 0x9f, 0x82, 0x4c, 0x05, 0xe8, 0x45, 0xe7, 0xdb, 0x38, 0x8d, + 0xb3, 0xf4, 0x77, 0x0e, 0xe9, 0x48, 0x4f, 0x4f, 0x44, 0xf6, 0xa0, 0xbb, 0xee, 0xbd, 0x17, 0xf0, 0x4b, 0x42, 0x53, + 0x32, 0x7e, 0x27, 0x06, 0xed, 0x8b, 0xf4, 0x51, 0x8d, 0xc0, 0xa9, 0x00, 0x79, 0x35, 0xc2, 0x38, 0x90, 0x37, 0xb4, + 0xd7, 0xc8, 0x0f, 0x4a, 0x95, 0xee, 0xb9, 0xa7, 0x25, 0xad, 0xc8, 0x42, 0xa6, 0x9f, 0x8c, 0x31, 0x66, 0x55, 0xe4, + 0xd8, 0xd2, 0xbc, 0x6f, 0x90, 0x49, 0xbe, 0x70, 0x91, 0xd1, 0x62, 0x4e, 0x8d, 0x05, 0xba, 0x55, 0xa8, 0xb5, 0x0b, + 0xaf, 0x7f, 0xa1, 0x72, 0xa0, 0xa9, 0x28, 0xfb, 0x7e, 0x88, 0x2d, 0xe2, 0x03, 0xfd, 0x8a, 0x8f, 0x90, 0x71, 0xdb, + 0x73, 0x9c, 0x10, 0x52, 0xf5, 0xae, 0x28, 0xee, 0x6d, 0x93, 0x0a, 0xc9, 0x0d, 0x55, 0x0c, 0x65, 0xd4, 0xc2, 0xf9, + 0x19, 0x9c, 0x2f, 0x9c, 0x9f, 0xe6, 0xdc, 0xa0, 0x2d, 0x99, 0xaa, 0x67, 0x24, 0x96, 0xae, 0xb0, 0xa3, 0x96, 0xdf, + 0xe4, 0x27, 0xec, 0x42, 0x06, 0x68, 0x6a, 0xa5, 0x57, 0x45, 0x82, 0x2e, 0x83, 0x0d, 0xa8, 0x51, 0x1d, 0x88, 0xbc, + 0xc4, 0x37, 0x13, 0x10, 0x80, 0xd1, 0x83, 0x4f, 0xaa, 0x29, 0x9d, 0x36, 0x7c, 0xb7, 0xcb, 0x31, 0x81, 0xa2, 0x6b, + 0x36, 0x98, 0x84, 0xbc, 0x29, 0xb8, 0xa6, 0x9a, 0x3d, 0x15, 0xc2, 0x18, 0xbc, 0x3c, 0x35, 0xb6, 0x58, 0xbd, 0x7f, + 0x2b, 0xd6, 0x57, 0x86, 0x90, 0xd8, 0x72, 0xc8, 0xbe, 0xd0, 0xbc, 0xd2, 0x83, 0x68, 0x9a, 0xe6, 0xe4, 0xd2, 0x43, + 0x5f, 0xc8, 0xeb, 0xd1, 0xd9, 0x27, 0xc8, 0xeb, 0xdb, 0x6c, 0x5b, 0x73, 0x13, 0x36, 0xf1, 0x25, 0x7d, 0xa6, 0xfb, + 0xe7, 0x6a, 0x21, 0x7b, 0x56, 0xea, 0xbc, 0x73, 0x25, 0x76, 0x4d, 0xa7, 0x88, 0x1a, 0x83, 0x4e, 0xc1, 0xdb, 0x0e, + 0x11, 0xb4, 0x05, 0x27, 0x49, 0x86, 0x48, 0x54, 0x06, 0xea, 0xb3, 0xa9, 0x48, 0x82, 0xd9, 0x00, 0x4b, 0x25, 0xaf, + 0xb9, 0xd8, 0x35, 0xbf, 0x64, 0x4d, 0x32, 0xab, 0x80, 0x8b, 0xe4, 0x99, 0x4e, 0x4e, 0xd7, 0x91, 0xd5, 0x1e, 0xa6, + 0xc6, 0x5d, 0x2c, 0x5e, 0x25, 0x5c, 0xce, 0xca, 0x4d, 0xac, 0xc4, 0x9b, 0x40, 0xcd, 0x78, 0x4f, 0x2a, 0x7f, 0x6c, + 0xb2, 0xa3, 0x36, 0x52, 0x02, 0x6d, 0x0f, 0xa9, 0xb6, 0x36, 0x8d, 0x70, 0x1b, 0xd2, 0x6f, 0x57, 0xb7, 0x2d, 0x50, + 0xe9, 0xb7, 0xb4, 0x30, 0xa4, 0xff, 0x1b, 0x15, 0xaa, 0x46, 0x85, 0x11, 0xc2, 0xfd, 0x24, 0x40, 0xb8, 0x2f, 0x9c, + 0xbc, 0x20, 0x16, 0xd5, 0x79, 0x14, 0xf6, 0x5e, 0x67, 0xcd, 0xd5, 0xb8, 0xf8, 0xfb, 0xa0, 0xfe, 0x3e, 0x0a, 0x8d, + 0x63, 0xbd, 0xc6, 0xef, 0x8c, 0x1f, 0x7f, 0x64, 0xdf, 0xd0, 0xc0, 0x08, 0x37, 0x11, 0xb4, 0x12, 0x34, 0xdb, 0x12, + 0xd6, 0xb6, 0x2a, 0xa0, 0x08, 0x61, 0x36, 0x52, 0xd5, 0x82, 0x09, 0x6d, 0xa5, 0x27, 0x58, 0xbc, 0xeb, 0x38, 0xfd, + 0x6f, 0x68, 0xbd, 0x4e, 0x08, 0x29, 0x58, 0x93, 0x23, 0x4f, 0x9e, 0x44, 0xab, 0x7d, 0xe6, 0xdf, 0x18, 0xb7, 0xbe, + 0xfa, 0x8c, 0x57, 0x23, 0x75, 0xa4, 0x98, 0x41, 0xe1, 0xb5, 0x9b, 0xd3, 0x9b, 0xf1, 0x39, 0xc9, 0x7b, 0xd1, 0x3c, + 0xda, 0xa9, 0xa0, 0x54, 0x53, 0xd7, 0xac, 0xce, 0xb5, 0x79, 0x9d, 0xd1, 0xb9, 0xc6, 0xde, 0x58, 0xd6, 0xd3, 0x35, + 0xce, 0xf8, 0x8d, 0xb6, 0x62, 0xa0, 0xd4, 0xf1, 0xb0, 0xd1, 0x73, 0xac, 0x40, 0x06, 0xe8, 0x85, 0xe3, 0x26, 0x82, + 0xf4, 0x97, 0xc0, 0xa1, 0x53, 0x1b, 0x2e, 0xb0, 0xd6, 0x72, 0xc4, 0x90, 0x67, 0x58, 0x62, 0x4a, 0xbf, 0x71, 0x1d, + 0x48, 0xbb, 0xf5, 0x9b, 0x05, 0x8f, 0x82, 0xaf, 0xec, 0xe9, 0x30, 0x8f, 0x68, 0x9c, 0x5b, 0x04, 0x2f, 0x12, 0xe5, + 0x61, 0xbb, 0xf0, 0x9c, 0x5f, 0x89, 0x74, 0x50, 0x90, 0x65, 0x3c, 0x9f, 0x79, 0x01, 0x42, 0x48, 0x77, 0x2d, 0xa1, + 0xed, 0x73, 0xc1, 0x9e, 0x18, 0xd7, 0x8e, 0x49, 0x52, 0x53, 0x82, 0xfd, 0xdf, 0x36, 0x5d, 0x96, 0x56, 0xe7, 0x2f, + 0xef, 0x2b, 0x66, 0x62, 0x3b, 0xae, 0xce, 0x52, 0x21, 0x7b, 0xef, 0x57, 0x91, 0x78, 0x8c, 0xcc, 0x1f, 0xdb, 0x20, + 0x7e, 0xef, 0x9c, 0x72, 0xfc, 0x5f, 0xd8, 0x6f, 0x7a, 0xf4, 0xca, 0xc9, 0x6c, 0x23, 0x01, 0x93, 0x23, 0xf7, 0xaa, + 0xbe, 0x1f, 0x01, 0x7b, 0xc3, 0x03, 0x81, 0xb2, 0x8a, 0xfe, 0x83, 0x7a, 0xd3, 0x00, 0x60, 0x0a, 0xc3, 0x6d, 0xb8, + 0xe7, 0x8f, 0xc6, 0x6f, 0x75, 0xc0, 0xe5, 0x8a, 0xe5, 0xbf, 0x81, 0xc1, 0xf5, 0x3a, 0x22, 0xd8, 0x6f, 0x9d, 0xf5, + 0x40, 0xd0, 0x9d, 0xc7, 0x9c, 0x62, 0x10, 0xd7, 0x92, 0x2f, 0x58, 0xaf, 0x22, 0xf3, 0x18, 0xc5, 0xe6, 0x17, 0x6b, + 0x2b, 0xf8, 0x2a, 0x93, 0xfa, 0x45, 0x1e, 0xfc, 0x17, 0xa4, 0x76, 0x08, 0x87, 0xe7, 0x89, 0x45, 0xfe, 0x4d, 0xe2, + 0x70, 0x84, 0x05, 0xb6, 0x62, 0xa5, 0xa1, 0x39, 0x33, 0x7e, 0x4c, 0xc9, 0xa1, 0x4d, 0x30, 0x0e, 0x45, 0xce, 0xd6, + 0x1c, 0x2c, 0x47, 0xa9, 0x66, 0x9e, 0x7f, 0x6f, 0xf0, 0x41, 0x98, 0xb4, 0xb4, 0xf2, 0x7c, 0x80, 0xf6, 0x31, 0xfa, + 0xf3, 0x7f, 0x16, 0x87, 0x0d, 0xc3, 0xb2, 0xf7, 0x6e, 0xe2, 0x27, 0x1b, 0x38, 0xaa, 0x79, 0x52, 0xc2, 0xd5, 0x5b, + 0xab, 0xaf, 0xda, 0x96, 0x1e, 0x3f, 0x09, 0x85, 0xc6, 0x30, 0x46, 0x0b, 0x83, 0x81, 0x3b, 0x17, 0xfb, 0x39, 0x98, + 0xb9, 0x61, 0x1b, 0x7d, 0x23, 0xe1, 0x4b, 0x3e, 0x7f, 0x07, 0xea, 0x10, 0xa3, 0xa6, 0x4b, 0x23, 0x2a, 0xfd, 0x0e, + 0x45, 0xb7, 0x06, 0x14, 0x68, 0x9e, 0xf9, 0x1c, 0x0a, 0xa7, 0xa3, 0x48, 0x24, 0x39, 0xc0, 0xda, 0x99, 0x7e, 0xd6, + 0xb2, 0xc7, 0xef, 0xb3, 0xa5, 0xc3, 0xf0, 0xba, 0xb6, 0x3d, 0x1e, 0x73, 0xe5, 0x56, 0x56, 0x1d, 0x17, 0x50, 0x5f, + 0x96, 0x6d, 0x36, 0xf6, 0x8e, 0x50, 0x67, 0xab, 0x87, 0x22, 0x72, 0x86, 0x78, 0x90, 0x58, 0xdd, 0xa0, 0x8f, 0x54, + 0xb0, 0xce, 0x67, 0x1b, 0x34, 0xf9, 0x56, 0xd1, 0x8b, 0xab, 0x85, 0xcd, 0x69, 0x48, 0x88, 0x69, 0xc4, 0x70, 0xf0, + 0x49, 0x84, 0xce, 0xa4, 0x7d, 0xdc, 0x50, 0x9d, 0x38, 0x43, 0xd2, 0x70, 0x1d, 0x71, 0x5a, 0x55, 0xc2, 0xac, 0xb2, + 0x85, 0xc5, 0x53, 0xda, 0xe1, 0xea, 0xae, 0x70, 0x3b, 0x67, 0xc2, 0x51, 0xcb, 0x35, 0xb4, 0x4d, 0x44, 0x0a, 0xd9, + 0x61, 0xcb, 0x35, 0xfa, 0xea, 0xb0, 0x62, 0x85, 0x8c, 0xb7, 0xf3, 0xe2, 0x55, 0xcc, 0x38, 0x6c, 0x09, 0x4b, 0x71, + 0x80, 0x81, 0x0f, 0x6d, 0xe5, 0x7d, 0xd5, 0xc9, 0xa9, 0x70, 0x4e, 0x79, 0x97, 0x52, 0x82, 0x2d, 0x63, 0xff, 0xdc, + 0xd5, 0xab, 0xf3, 0xcb, 0xb9, 0xab, 0xce, 0x78, 0x73, 0x61, 0xea, 0xb4, 0xbe, 0x84, 0xae, 0xed, 0x10, 0x51, 0xe5, + 0x3e, 0x57, 0xd3, 0x71, 0x6f, 0xb1, 0x86, 0x9e, 0x74, 0x8e, 0x89, 0xfe, 0xbf, 0x42, 0x94, 0x8f, 0x08, 0x9d, 0xdc, + 0xdd, 0x29, 0x5f, 0x95, 0x3c, 0x55, 0x49, 0xec, 0x63, 0xb5, 0x0d, 0x23, 0x83, 0x56, 0xda, 0x89, 0x6a, 0xdf, 0x5e, + 0xee, 0x09, 0x62, 0xc8, 0x5b, 0x62, 0x59, 0xb8, 0x5d, 0x5e, 0x96, 0xdc, 0x21, 0xce, 0xed, 0x64, 0x68, 0xa7, 0x63, + 0x34, 0x42, 0x3f, 0xb4, 0xa5, 0x98, 0x04, 0x44, 0x52, 0xfb, 0x09, 0xe9, 0x1c, 0xfe, 0x2e, 0x7b, 0x7f, 0x16, 0xef, + 0x09, 0x61, 0x3e, 0x7a, 0xd1, 0x31, 0xa8, 0x4b, 0xa8, 0x73, 0xbc, 0xce, 0xab, 0x06, 0x4c, 0x12, 0x4d, 0xaf, 0xad, + 0x38, 0xd5, 0x39, 0xf5, 0xb6, 0x08, 0xc5, 0x2e, 0xfd, 0xa2, 0x25, 0xb9, 0xd9, 0x2c, 0x33, 0x66, 0x0c, 0x02, 0x75, + 0xa8, 0xe8, 0x66, 0x80, 0x62, 0x4c, 0x89, 0xb0, 0xd3, 0xf9, 0x87, 0x4c, 0xaa, 0x29, 0x2d, 0xaa, 0x76, 0xf4, 0xfb, + 0xc6, 0x60, 0x87, 0x47, 0xd3, 0x97, 0x3f, 0xbf, 0x3d, 0xd2, 0x83, 0x2a, 0xe8, 0x10, 0x3e, 0xee, 0xee, 0x8e, 0xa1, + 0x50, 0x80, 0xac, 0x6c, 0x5f, 0xcc, 0x00, 0x6a, 0x4c, 0x45, 0x48, 0x77, 0x6d, 0xdd, 0x5f, 0x4a, 0x72, 0x5b, 0x53, + 0xe5, 0xfb, 0x40, 0x83, 0xef, 0x0d, 0xb5, 0xd3, 0x1d, 0x3e, 0x87, 0xd9, 0x88, 0xa7, 0x40, 0xc7, 0xc2, 0xe0, 0x6f, + 0x48, 0x71, 0x13, 0x06, 0x19, 0xaa, 0x64, 0x9a, 0x3d, 0xa5, 0x2d, 0xab, 0xe6, 0x5a, 0x4a, 0x3a, 0xc7, 0x84, 0xbd, + 0x2a, 0xfc, 0x91, 0xf7, 0x24, 0xb5, 0xa5, 0x1a, 0x0c, 0x70, 0x82, 0xd2, 0x86, 0xe5, 0x58, 0xc5, 0x8d, 0x7c, 0xa7, + 0xf0, 0x22, 0x02, 0x3d, 0x1d, 0xdc, 0xdb, 0xf9, 0xfd, 0xde, 0x18, 0x21, 0x48, 0x05, 0xdf, 0x4a, 0xa9, 0xc9, 0x1a, + 0x9e, 0xfb, 0x47, 0xaf, 0x6c, 0x87, 0x47, 0xba, 0x9b, 0x24, 0x6a, 0x8b, 0x4e, 0x54, 0x80, 0x15, 0x88, 0xa6, 0x80, + 0x0b, 0xd5, 0x31, 0xa6, 0x71, 0xe7, 0x77, 0x3f, 0xb1, 0xd6, 0xdd, 0xea, 0xf5, 0xac, 0x97, 0x4e, 0x1e, 0x93, 0x05, + 0x6a, 0x3c, 0x8a, 0x7d, 0x79, 0x15, 0xbe, 0x5b, 0xf6, 0x9b, 0x95, 0x2d, 0xc8, 0x0c, 0x02, 0xf4, 0x9b, 0xb5, 0x39, + 0x13, 0xbd, 0x46, 0xb8, 0x93, 0x4a, 0xf3, 0xbc, 0x92, 0x33, 0x95, 0x5f, 0x5f, 0x39, 0x8b, 0x21, 0x59, 0xed, 0xac, + 0xdd, 0xa8, 0x48, 0x8f, 0xad, 0x41, 0xd6, 0xaf, 0x99, 0x64, 0xa9, 0xff, 0x35, 0x7c, 0xd4, 0x37, 0xaf, 0xd7, 0x60, + 0xda, 0x76, 0xb5, 0xd3, 0xcb, 0x53, 0x8e, 0x8a, 0x39, 0x2f, 0x7e, 0x61, 0x8d, 0x2d, 0x3c, 0x1e, 0x6c, 0xf4, 0x84, + 0xc9, 0x54, 0xb2, 0x7a, 0x56, 0xc9, 0xca, 0x59, 0xe2, 0x72, 0xb3, 0x17, 0x5d, 0x40, 0xc7, 0x1f, 0x0e, 0x5a, 0x95, + 0x3f, 0x6c, 0xcc, 0xaa, 0x7c, 0xd8, 0x49, 0xd5, 0xfa, 0x24, 0x91, 0xd9, 0x33, 0x6b, 0xe4, 0x61, 0x61, 0xad, 0x98, + 0x4c, 0xf2, 0x7d, 0x42, 0xae, 0xd0, 0x0c, 0xab, 0x6a, 0xd5, 0xe1, 0xc9, 0x0d, 0x37, 0xb8, 0x58, 0xf8, 0xb9, 0x19, + 0xd7, 0x7f, 0x46, 0xdc, 0x59, 0x0e, 0x3a, 0x0b, 0xad, 0xbf, 0xbd, 0x0e, 0x75, 0x3f, 0x82, 0x2f, 0x4d, 0x70, 0x65, + 0xfa, 0x16, 0x5c, 0xfd, 0x4a, 0x92, 0xd9, 0x16, 0x78, 0xad, 0x00, 0xb9, 0xd8, 0x1b, 0x1b, 0xb1, 0xd6, 0x92, 0x44, + 0x63, 0x43, 0x90, 0x3a, 0x8b, 0xb4, 0x1b, 0x52, 0x3b, 0x9a, 0xed, 0xb4, 0x8e, 0xe6, 0x27, 0xfc, 0x8d, 0x3f, 0x55, + 0x43, 0x15, 0xe6, 0x5b, 0x85, 0xea, 0x15, 0x0f, 0x4e, 0x5b, 0x6f, 0x35, 0x8b, 0xf3, 0x4d, 0xb0, 0xd2, 0x8a, 0xa8, + 0x08, 0x8d, 0xc1, 0x17, 0x19, 0x1c, 0xc4, 0xfd, 0x8a, 0xb5, 0x82, 0x74, 0x53, 0xd6, 0xed, 0x7f, 0x0d, 0xb5, 0xd2, + 0xee, 0x40, 0xec, 0x1b, 0x74, 0x81, 0x95, 0xb5, 0x02, 0xb9, 0x87, 0xf5, 0xfe, 0x82, 0xd2, 0x0a, 0x71, 0xe1, 0xcc, + 0x11, 0x35, 0x61, 0xad, 0xf7, 0x88, 0xb7, 0xc8, 0xfa, 0xcb, 0x3f, 0xd3, 0x8b, 0x26, 0xce, 0xe2, 0x61, 0x19, 0xe7, + 0x0e, 0xd9, 0x91, 0xcb, 0x2c, 0x9f, 0xae, 0xbc, 0xd5, 0x22, 0x82, 0x86, 0x3c, 0x99, 0xf6, 0xf8, 0x14, 0x4e, 0x9b, + 0x35, 0x9c, 0x9e, 0xc8, 0xa7, 0xd6, 0x5a, 0xd3, 0xc9, 0xaa, 0xe1, 0x1f, 0x70, 0xc1, 0x05, 0x86, 0x1d, 0x0c, 0x4e, + 0xaf, 0x9c, 0xaf, 0xba, 0xa0, 0x49, 0x4f, 0x58, 0x70, 0x06, 0xcd, 0x6d, 0xc0, 0x93, 0x0f, 0xe9, 0x29, 0x75, 0x77, + 0x76, 0x9b, 0xd7, 0x40, 0x6e, 0x13, 0x7d, 0x6a, 0x31, 0xcf, 0x0a, 0x5b, 0x70, 0xa6, 0xce, 0x6e, 0x63, 0x7a, 0xae, + 0xae, 0xdb, 0x56, 0x82, 0xa4, 0x4d, 0x9e, 0xcf, 0x06, 0xd7, 0x8c, 0x14, 0x86, 0xc1, 0xff, 0x97, 0x90, 0x92, 0xb7, + 0xa2, 0x20, 0x98, 0x3a, 0x27, 0x7d, 0xad, 0x17, 0x57, 0xb8, 0x11, 0xb1, 0xcc, 0xaa, 0x23, 0xa8, 0x52, 0xf6, 0x04, + 0x5d, 0xfa, 0xdc, 0xc1, 0x25, 0x27, 0x62, 0xbb, 0x67, 0xa5, 0x33, 0x29, 0xa1, 0xfd, 0x79, 0xc1, 0xbb, 0x6b, 0xbc, + 0x72, 0x47, 0xf6, 0xc7, 0xca, 0x3d, 0xe3, 0x1d, 0xb8, 0x7a, 0xf6, 0xe7, 0x38, 0x6b, 0xe1, 0xa0, 0xcb, 0x30, 0x8f, + 0x27, 0x3d, 0x3c, 0xcb, 0x3f, 0xe1, 0x59, 0x39, 0xcf, 0x18, 0x82, 0xd6, 0x61, 0x85, 0x6f, 0xbe, 0x06, 0x28, 0xef, + 0x64, 0xf8, 0xf8, 0x58, 0xfc, 0xd6, 0xd8, 0x8b, 0x4e, 0xca, 0x21, 0x9a, 0xa9, 0x1d, 0x34, 0xcf, 0x5b, 0x30, 0xe4, + 0xa9, 0xdd, 0x20, 0x90, 0x46, 0xeb, 0x3c, 0x57, 0x3f, 0xc5, 0x41, 0x35, 0x7f, 0x9b, 0x79, 0x09, 0x73, 0x5b, 0xa1, + 0x88, 0xfc, 0x33, 0x21, 0x9a, 0xfd, 0x48, 0xa5, 0x81, 0x3a, 0xf9, 0x55, 0x4b, 0xf2, 0x95, 0xb7, 0x23, 0x06, 0x9d, + 0xb9, 0x09, 0xbb, 0xd8, 0x08, 0xf3, 0xd3, 0x98, 0x7c, 0xa6, 0x3a, 0x9b, 0xc9, 0x32, 0xcb, 0x6a, 0x1f, 0x13, 0x0f, + 0x8f, 0xd6, 0x4b, 0xaa, 0x5b, 0x14, 0x6a, 0xb3, 0x3c, 0x5f, 0x94, 0x59, 0xa9, 0x7d, 0x4e, 0xbd, 0x10, 0x47, 0x93, + 0xf5, 0xc2, 0xe3, 0x5e, 0x62, 0x46, 0x26, 0xd5, 0xbc, 0xcc, 0x1c, 0x22, 0x0f, 0xcf, 0x1f, 0x7c, 0xcb, 0x2e, 0x79, + 0xa2, 0xa0, 0xb4, 0x1d, 0x32, 0x0f, 0xdc, 0x37, 0x98, 0xae, 0x9c, 0x7a, 0xcc, 0xd3, 0x15, 0x70, 0x6b, 0xc0, 0x6c, + 0x69, 0x14, 0x47, 0x56, 0x59, 0x85, 0xac, 0xeb, 0xf5, 0xba, 0xf2, 0xb9, 0x65, 0x9a, 0x09, 0x37, 0xf6, 0x14, 0x64, + 0x9a, 0xae, 0x4a, 0xd7, 0xd2, 0x67, 0xfe, 0xcd, 0x9c, 0x67, 0x1f, 0xf0, 0xd3, 0x4f, 0xc1, 0x2d, 0xfa, 0xcb, 0xa9, + 0x6b, 0x5c, 0xf9, 0x36, 0xa3, 0x51, 0xe3, 0x14, 0x8d, 0x37, 0x48, 0x4c, 0x54, 0x54, 0x85, 0xd5, 0x98, 0xf2, 0x73, + 0xec, 0xdd, 0x48, 0x4e, 0xa6, 0x43, 0x3e, 0xd7, 0x76, 0x3f, 0xb3, 0x66, 0xf5, 0x19, 0x75, 0x68, 0x95, 0xd5, 0x71, + 0xc4, 0x97, 0xce, 0x6e, 0x57, 0x06, 0xa1, 0x00, 0x04, 0xd8, 0xc3, 0xe4, 0x73, 0xca, 0x5a, 0x4d, 0xfe, 0xfc, 0xfb, + 0xfb, 0x47, 0x15, 0x9c, 0x62, 0x95, 0xf7, 0xdd, 0xd8, 0x04, 0x8b, 0x64, 0x46, 0x18, 0x59, 0x23, 0xbb, 0x39, 0x46, + 0x92, 0x22, 0x44, 0xe3, 0x1e, 0x4b, 0x11, 0x7a, 0xab, 0xfb, 0x01, 0xe0, 0x1c, 0x79, 0x52, 0x9c, 0x26, 0x47, 0xa7, + 0xc8, 0xa6, 0xd9, 0x56, 0x6c, 0x91, 0x85, 0x03, 0x7c, 0x2d, 0x6a, 0x25, 0xdb, 0xc6, 0x58, 0x41, 0x83, 0x62, 0x0e, + 0x64, 0x3a, 0xf3, 0x01, 0x5f, 0x31, 0xe2, 0x9c, 0x3f, 0x4c, 0x1b, 0x93, 0x27, 0xd3, 0x5e, 0x5f, 0x25, 0xcc, 0x6c, + 0xb7, 0x5e, 0x30, 0x9c, 0xd3, 0x0c, 0x0c, 0xc8, 0xc7, 0x15, 0xaa, 0xf9, 0x13, 0x2c, 0x51, 0xf0, 0xb7, 0x36, 0xb2, + 0xf3, 0xe7, 0xa4, 0x36, 0x62, 0xc8, 0x98, 0x68, 0x6c, 0x2f, 0x8c, 0x94, 0x82, 0x17, 0x35, 0x74, 0x46, 0x58, 0x04, + 0x1f, 0xec, 0x9e, 0xc2, 0xf5, 0x59, 0xd9, 0xeb, 0x74, 0x12, 0x3d, 0x30, 0x4f, 0x94, 0xe0, 0xd2, 0x7c, 0x5f, 0xdb, + 0x20, 0xa0, 0x3e, 0x6f, 0x79, 0x26, 0x07, 0x24, 0x25, 0x26, 0xb0, 0xf0, 0xb8, 0x29, 0x5f, 0xe3, 0xd4, 0x5b, 0xef, + 0xb2, 0x1a, 0x75, 0xc5, 0x25, 0x8d, 0x36, 0xce, 0x18, 0x34, 0x18, 0x1d, 0x11, 0x89, 0xe7, 0x42, 0x30, 0x46, 0xc3, + 0xdf, 0x7a, 0x24, 0x69, 0x08, 0xce, 0x63, 0x4f, 0x10, 0x37, 0x39, 0x99, 0xde, 0x40, 0x88, 0xb2, 0x6d, 0xb9, 0xf9, + 0x79, 0x5f, 0xa0, 0xd1, 0x9c, 0x8f, 0x4d, 0xcc, 0x9c, 0xf7, 0x00, 0x65, 0x26, 0x5a, 0x04, 0xe4, 0xd0, 0xe3, 0x1e, + 0xe2, 0x2a, 0x3d, 0x58, 0xec, 0x25, 0x2e, 0xd3, 0x31, 0x10, 0x5f, 0xaf, 0x95, 0x82, 0x34, 0x3b, 0x8b, 0x14, 0x78, + 0x31, 0xdf, 0xfc, 0xc9, 0x95, 0x62, 0x95, 0x7c, 0xd3, 0x60, 0x72, 0xfe, 0xe4, 0xc7, 0xe6, 0x97, 0xe0, 0xe5, 0x5b, + 0x2d, 0xb5, 0xc8, 0x7d, 0xe0, 0x9d, 0xaf, 0x49, 0x41, 0xbb, 0xff, 0xd9, 0x92, 0x91, 0xf7, 0x31, 0xad, 0x96, 0xc5, + 0x5b, 0xed, 0xa2, 0x5b, 0x14, 0xf2, 0x26, 0x0f, 0xf7, 0xb0, 0x08, 0xa9, 0xb5, 0x96, 0x61, 0x56, 0xdb, 0xa3, 0xdc, + 0xd8, 0x7b, 0xbd, 0x16, 0xa4, 0x45, 0xcc, 0x2e, 0x51, 0xe5, 0xc6, 0x0b, 0x4c, 0xd6, 0x9f, 0x5c, 0x08, 0x96, 0xf9, + 0x05, 0x55, 0x69, 0xef, 0xb2, 0x8e, 0xa7, 0x6c, 0x66, 0xad, 0x8b, 0x9a, 0x4d, 0x01, 0xa7, 0x28, 0x2b, 0x55, 0xdc, + 0xc8, 0xe0, 0xbb, 0x46, 0xa0, 0x35, 0xf0, 0x13, 0x18, 0xa5, 0xc8, 0x6a, 0xaa, 0x8d, 0xa4, 0xff, 0xce, 0xe4, 0xdf, + 0x39, 0xe6, 0xbf, 0x41, 0xe6, 0xdf, 0x87, 0x56, 0x7e, 0xdf, 0x18, 0x6b, 0x02, 0x5c, 0xe1, 0xa4, 0x10, 0x5f, 0xa9, + 0x9c, 0x25, 0x80, 0x1a, 0x4d, 0x99, 0xec, 0xc6, 0x0b, 0x81, 0x15, 0x91, 0xe7, 0x36, 0x4e, 0xb3, 0xb4, 0x47, 0xb6, + 0xe8, 0xfe, 0xce, 0x0b, 0x70, 0x42, 0x2e, 0x0a, 0xee, 0x88, 0xed, 0xab, 0x31, 0xe7, 0x50, 0xc4, 0xd9, 0xe4, 0xa2, + 0x00, 0x31, 0x82, 0x01, 0x21, 0x1b, 0x49, 0xa0, 0xa3, 0xa4, 0x99, 0x68, 0xc4, 0x14, 0x80, 0x06, 0xd8, 0xdd, 0x03, + 0x04, 0x16, 0xc1, 0x0c, 0x13, 0x04, 0x23, 0x79, 0x25, 0xc0, 0x72, 0x4c, 0xf6, 0x8e, 0x55, 0xb0, 0xb0, 0x52, 0x07, + 0x3b, 0xd0, 0x20, 0x4e, 0x60, 0x8a, 0x66, 0x79, 0x24, 0x28, 0xaa, 0x60, 0x11, 0x25, 0xcb, 0x36, 0x17, 0x2f, 0x32, + 0xb7, 0xf5, 0x2a, 0x49, 0xa1, 0x8b, 0xa7, 0x4f, 0x33, 0x4b, 0x28, 0xfd, 0x03, 0xf0, 0xaf, 0x41, 0x1d, 0xd8, 0xb3, + 0x0e, 0xa0, 0x63, 0x2b, 0x4e, 0x4e, 0xa5, 0xca, 0x9f, 0x5d, 0x03, 0x40, 0x49, 0x4f, 0x1b, 0xc4, 0x5c, 0xa0, 0x75, + 0x0d, 0x71, 0x0d, 0x2a, 0x80, 0x61, 0x93, 0xf1, 0x52, 0x53, 0xdb, 0x7a, 0x66, 0xf1, 0x52, 0xef, 0x91, 0x99, 0xa3, + 0x43, 0x12, 0x2f, 0xa2, 0xc4, 0x5d, 0x14, 0x96, 0x23, 0xa5, 0xd6, 0xdc, 0x28, 0xd6, 0x98, 0xf2, 0xd2, 0x6e, 0x0e, + 0xf1, 0x1d, 0xa2, 0xd3, 0x45, 0x50, 0xf5, 0x79, 0x8b, 0xa7, 0xb5, 0x11, 0xf8, 0x91, 0xd3, 0xa2, 0x40, 0x79, 0xbb, + 0xe2, 0xa4, 0xa6, 0x27, 0x3b, 0x56, 0xd8, 0x34, 0x2d, 0xbd, 0x83, 0x5b, 0x4f, 0xdf, 0x96, 0x64, 0x90, 0x71, 0x20, + 0xb0, 0x23, 0x20, 0x6c, 0x8a, 0x3b, 0x33, 0xd1, 0x16, 0x47, 0x70, 0x82, 0x50, 0x46, 0x66, 0x87, 0x6f, 0x05, 0xcf, + 0x2a, 0x02, 0x9f, 0xf7, 0xa3, 0xf7, 0x9c, 0xeb, 0x6a, 0x28, 0xad, 0x8e, 0x3d, 0x6a, 0x24, 0x38, 0xca, 0xb3, 0xa6, + 0x6f, 0x38, 0xa7, 0x16, 0x21, 0x55, 0x71, 0xbf, 0x00, 0x2b, 0xb7, 0xf7, 0x49, 0x83, 0x15, 0x9f, 0xb1, 0x6c, 0x0f, + 0xb2, 0x95, 0x32, 0xa2, 0x91, 0xf2, 0xba, 0xc7, 0xcc, 0x68, 0x7b, 0xc1, 0xc8, 0x8d, 0xb9, 0xe1, 0xfd, 0xec, 0x31, + 0x8a, 0xea, 0x15, 0x46, 0xac, 0x16, 0xdb, 0x09, 0x30, 0xf7, 0xc6, 0xbd, 0x55, 0x33, 0x67, 0x3e, 0xe5, 0x42, 0x4a, + 0xa9, 0x60, 0xbe, 0x53, 0x79, 0x06, 0x27, 0x9f, 0x42, 0x30, 0xe4, 0x87, 0xef, 0x33, 0xbf, 0x5e, 0x73, 0x6b, 0x96, + 0xf1, 0xa2, 0xbe, 0xa7, 0x7d, 0x36, 0x43, 0x6d, 0x78, 0xb5, 0x94, 0x10, 0x57, 0x67, 0xd9, 0xb9, 0x78, 0x0d, 0xac, + 0xa9, 0x0c, 0xf0, 0x15, 0xab, 0xa2, 0x2e, 0xc1, 0x57, 0xc4, 0xbc, 0x91, 0x30, 0x7f, 0xc3, 0x2a, 0x06, 0xf3, 0xa6, + 0x4a, 0xca, 0x27, 0xee, 0x8f, 0xd8, 0x94, 0x71, 0x89, 0xb2, 0xa5, 0x0f, 0xe9, 0x77, 0xb0, 0x37, 0xaa, 0x78, 0xb3, + 0x12, 0xbe, 0x96, 0xec, 0xb7, 0x7d, 0x6c, 0x4d, 0xc2, 0x14, 0x00, 0x2d, 0x32, 0x16, 0x01, 0xdd, 0x7a, 0xf5, 0xb6, + 0x90, 0xad, 0x09, 0x8d, 0x34, 0x34, 0x84, 0xa2, 0xee, 0xbd, 0x60, 0x62, 0x52, 0xdc, 0x1d, 0x28, 0x31, 0x31, 0x9e, + 0x35, 0x96, 0x5f, 0x90, 0x9f, 0x57, 0x75, 0xda, 0x1a, 0x73, 0xa1, 0x63, 0x46, 0x30, 0xa9, 0x41, 0x33, 0x01, 0x92, + 0x00, 0x5e, 0x2e, 0xa3, 0xc1, 0x38, 0x4f, 0x38, 0x36, 0xf7, 0x3a, 0x4b, 0xc8, 0x00, 0x81, 0x4e, 0x31, 0xa5, 0x52, + 0xbc, 0x5a, 0x1f, 0xa4, 0x94, 0x17, 0x80, 0xb2, 0x63, 0x36, 0x58, 0x52, 0x50, 0x1f, 0x6d, 0xda, 0x4c, 0xae, 0x6d, + 0x0d, 0x7b, 0xca, 0x64, 0xd6, 0x42, 0x99, 0xe6, 0x0f, 0x97, 0xf9, 0x45, 0xc4, 0xb8, 0xa8, 0xf9, 0x84, 0x7d, 0xd5, + 0x61, 0x04, 0x5a, 0x8f, 0x41, 0x5e, 0x0f, 0x27, 0xbc, 0x9f, 0xd7, 0xfb, 0xe6, 0xd6, 0xc4, 0x93, 0x17, 0x05, 0x4e, + 0x7d, 0xa9, 0xfc, 0x4b, 0xfb, 0x13, 0xd8, 0xc4, 0x03, 0x99, 0xf8, 0x54, 0xb2, 0x95, 0x89, 0xa2, 0x04, 0xa2, 0x5a, + 0x84, 0x67, 0x92, 0x0b, 0x82, 0x94, 0x8c, 0x97, 0x81, 0x50, 0xdb, 0x8c, 0x06, 0x24, 0xef, 0x6b, 0x4b, 0x78, 0x2d, + 0xf9, 0x74, 0x11, 0xf2, 0x66, 0x33, 0xac, 0xed, 0xf9, 0xb4, 0xdb, 0xde, 0x4a, 0xa1, 0x6a, 0x80, 0x92, 0xc9, 0x70, + 0x19, 0xf4, 0x0d, 0xcd, 0x0e, 0xe5, 0x09, 0xed, 0xf6, 0x6d, 0x56, 0xca, 0x24, 0xcc, 0x4e, 0xd7, 0xe4, 0xa8, 0xf8, + 0x85, 0xd2, 0xee, 0x6c, 0x74, 0x05, 0xaf, 0x75, 0x07, 0xe3, 0xa2, 0x50, 0x0e, 0x30, 0xa6, 0x46, 0xe6, 0x0f, 0xdc, + 0xc8, 0x91, 0xa5, 0x0f, 0xcb, 0xe4, 0xa2, 0x56, 0x54, 0x26, 0x43, 0xda, 0xb4, 0xb6, 0xea, 0x36, 0x1b, 0x25, 0xe9, + 0xb2, 0x44, 0xce, 0xb7, 0x56, 0xf1, 0xb2, 0xea, 0xe1, 0x5d, 0x28, 0xa5, 0xef, 0x4b, 0x5c, 0xbc, 0x74, 0xa0, 0xee, + 0x6d, 0x25, 0x96, 0xf0, 0xa9, 0x69, 0xe2, 0x14, 0xdc, 0x01, 0x63, 0x95, 0xad, 0x88, 0x5a, 0x20, 0xa9, 0xff, 0xc2, + 0x8b, 0xfb, 0x42, 0x84, 0x78, 0xe7, 0xaa, 0x57, 0x33, 0x24, 0x66, 0x92, 0xc7, 0x68, 0xf5, 0x3b, 0x88, 0x82, 0x6e, + 0x39, 0x8d, 0x03, 0x02, 0x4f, 0x4d, 0x7a, 0xf9, 0xed, 0x48, 0xe2, 0xec, 0x36, 0x2b, 0x34, 0xd0, 0xe3, 0x59, 0x76, + 0xb0, 0xc6, 0xb6, 0x6a, 0x8f, 0x67, 0xa6, 0x2f, 0x2e, 0xb4, 0x4c, 0xc2, 0x98, 0xdf, 0x36, 0xf4, 0x03, 0xd8, 0xa5, + 0xe9, 0xc6, 0x41, 0x63, 0x76, 0x57, 0xab, 0x2f, 0xf1, 0xbc, 0xa8, 0x82, 0x24, 0x2e, 0xb1, 0x31, 0x0a, 0xeb, 0xb7, + 0x2a, 0x1f, 0x15, 0x05, 0xcb, 0xb9, 0xe5, 0xaa, 0xca, 0x6b, 0xd7, 0x91, 0x17, 0xaf, 0x45, 0x4e, 0x82, 0xca, 0x3d, + 0x32, 0xe3, 0x18, 0x5c, 0x44, 0x0b, 0xfd, 0x9c, 0x5e, 0x54, 0x15, 0x1d, 0xaf, 0x2c, 0x6b, 0x88, 0x20, 0x70, 0xab, + 0xea, 0x15, 0x52, 0x62, 0x91, 0x98, 0x67, 0x11, 0xb2, 0xbd, 0x0e, 0x72, 0x9b, 0xb3, 0x81, 0x70, 0x93, 0x4e, 0x09, + 0x9c, 0x92, 0xf0, 0x0f, 0xe5, 0xd9, 0x86, 0x11, 0xf5, 0x4c, 0x6b, 0xa4, 0x8b, 0xaa, 0x35, 0xe7, 0xb5, 0x28, 0xd4, + 0x0e, 0x94, 0xb8, 0x5a, 0xaf, 0x6e, 0x84, 0x42, 0x80, 0x70, 0x61, 0xfe, 0x1c, 0xc0, 0xfd, 0x6d, 0xcd, 0x8a, 0x07, + 0x9b, 0xca, 0xa1, 0x5a, 0x35, 0x6d, 0x1c, 0x80, 0x03, 0xf2, 0x16, 0x2b, 0x83, 0x0b, 0x24, 0xc3, 0x0c, 0xf5, 0x32, + 0xd1, 0x06, 0x43, 0xc5, 0x38, 0xb5, 0xf8, 0x5c, 0xea, 0x5c, 0xa7, 0x4f, 0xc3, 0x8a, 0x99, 0xc5, 0x1d, 0xfa, 0x6c, + 0x95, 0x39, 0xf8, 0xda, 0x11, 0xec, 0xf2, 0x93, 0x69, 0xdb, 0x07, 0x25, 0xbf, 0x0d, 0x65, 0x1a, 0xde, 0xc4, 0xb9, + 0x4d, 0xd9, 0xe9, 0x63, 0x65, 0xe1, 0xab, 0xf7, 0x9d, 0x5b, 0xf2, 0xc1, 0xcc, 0x16, 0x91, 0x7e, 0x05, 0x18, 0xf2, + 0xc7, 0xf8, 0x79, 0x32, 0x88, 0xb6, 0x9d, 0xae, 0x73, 0xcd, 0x3b, 0x54, 0x49, 0x45, 0x45, 0xae, 0x84, 0x21, 0x72, + 0x28, 0xe4, 0x32, 0x52, 0xfa, 0x5a, 0x22, 0x6b, 0x33, 0x72, 0x27, 0xd3, 0x8f, 0x96, 0xd3, 0x29, 0x0e, 0x79, 0x69, + 0xad, 0x0b, 0xeb, 0xf2, 0x37, 0xba, 0xb2, 0x4d, 0xfa, 0x4b, 0x3d, 0x91, 0x8b, 0x86, 0xf0, 0xf3, 0xb5, 0xcd, 0x01, + 0x4a, 0xfd, 0xaf, 0xd6, 0x2f, 0xe2, 0xa8, 0xa0, 0x0b, 0x5d, 0x19, 0x88, 0x0f, 0x8a, 0x52, 0x82, 0xed, 0x73, 0x96, + 0x50, 0xd7, 0x3d, 0x30, 0x4e, 0xba, 0xe2, 0xa4, 0xe8, 0x17, 0xef, 0x45, 0x78, 0x6f, 0x9f, 0x1c, 0x56, 0xee, 0x10, + 0xa7, 0xa7, 0x5a, 0xf5, 0x31, 0x32, 0x59, 0x49, 0x4c, 0x34, 0x61, 0x95, 0x37, 0x34, 0x87, 0xad, 0x32, 0x9a, 0xd5, + 0x74, 0x9d, 0x7c, 0x7f, 0xa0, 0x30, 0x12, 0x19, 0xfe, 0x6e, 0x6e, 0x22, 0x03, 0x0d, 0x1c, 0xd5, 0x19, 0xa8, 0xe4, + 0xb8, 0x9f, 0x6b, 0xd6, 0x87, 0xca, 0x4b, 0x00, 0x64, 0xf6, 0x78, 0xa3, 0xac, 0x5b, 0x7e, 0x37, 0xaf, 0x41, 0x40, + 0xaf, 0xff, 0x15, 0x6d, 0xb2, 0x80, 0x68, 0x33, 0xb8, 0x56, 0x53, 0x50, 0x3e, 0x65, 0xa2, 0x3f, 0xda, 0xa0, 0x67, + 0xbf, 0xdb, 0xe6, 0x0c, 0xd5, 0x85, 0xa5, 0xc4, 0xee, 0x5b, 0x94, 0x15, 0x0b, 0xd8, 0xcf, 0x6a, 0x84, 0xee, 0x94, + 0xf1, 0xf3, 0x47, 0xdd, 0xcc, 0x66, 0x61, 0xab, 0x08, 0xe8, 0xd1, 0x57, 0x57, 0x1c, 0x00, 0x0b, 0xe8, 0x12, 0x16, + 0x46, 0xec, 0x58, 0xca, 0x33, 0xcb, 0x54, 0xf6, 0x99, 0x47, 0x74, 0x7d, 0x33, 0xe4, 0x1e, 0x3e, 0xdd, 0x7e, 0x8b, + 0x55, 0x31, 0x8e, 0x27, 0xd6, 0xd5, 0x45, 0x67, 0x50, 0x34, 0x21, 0xe9, 0xf4, 0xcb, 0x19, 0x90, 0xaa, 0x95, 0x9d, + 0x98, 0xab, 0x36, 0x01, 0xf4, 0xf6, 0x5d, 0x49, 0xe0, 0x31, 0x39, 0xbc, 0x1b, 0xcc, 0x2c, 0x30, 0x45, 0xcb, 0x52, + 0x08, 0x7d, 0xb7, 0x14, 0xe5, 0xbc, 0x15, 0x0a, 0x06, 0xb4, 0x0b, 0xc2, 0xdf, 0x38, 0x2e, 0xb1, 0x05, 0x2d, 0xa3, + 0xf5, 0x22, 0x88, 0x8e, 0x40, 0x24, 0x37, 0x46, 0x8e, 0x0f, 0x67, 0xeb, 0x1a, 0x14, 0x43, 0x96, 0xba, 0xc0, 0xa1, + 0x9b, 0x17, 0x6c, 0x97, 0x0a, 0xc9, 0x44, 0xbe, 0x43, 0x43, 0x60, 0x79, 0xee, 0xc4, 0xe9, 0x80, 0xe8, 0xde, 0xdf, + 0x27, 0x4b, 0x56, 0x54, 0xfc, 0x50, 0x86, 0xdb, 0x17, 0x66, 0x70, 0xa8, 0x27, 0xde, 0x0c, 0x3a, 0xe0, 0x4a, 0xef, + 0x53, 0x25, 0x46, 0x32, 0xeb, 0x1d, 0x20, 0x8a, 0x88, 0x32, 0xf3, 0x4c, 0x76, 0x8b, 0xdb, 0xc3, 0x29, 0x60, 0x20, + 0x63, 0xda, 0xa4, 0x27, 0xc3, 0x44, 0x60, 0x88, 0xf9, 0x6a, 0x7c, 0xde, 0x83, 0x1f, 0xdb, 0x7d, 0x44, 0xce, 0x45, + 0xb9, 0x86, 0xc2, 0x36, 0x66, 0x33, 0x5b, 0xf4, 0x04, 0xdf, 0x48, 0xa4, 0xa3, 0x97, 0x31, 0x94, 0x0b, 0x84, 0x83, + 0x95, 0xce, 0x89, 0xe9, 0xc1, 0x8a, 0x2a, 0x40, 0x5c, 0xb9, 0x71, 0xca, 0xa8, 0x01, 0xb3, 0xe4, 0x06, 0x57, 0xd0, + 0x64, 0xd4, 0xe1, 0x57, 0x77, 0xf4, 0xec, 0x63, 0x16, 0xdc, 0x93, 0x97, 0xc1, 0xa1, 0x6e, 0xad, 0xa7, 0x75, 0xf7, + 0x06, 0x12, 0x62, 0x41, 0x59, 0x60, 0xce, 0x4e, 0x87, 0x85, 0x15, 0x6c, 0x6b, 0x6a, 0x85, 0x57, 0xeb, 0x87, 0x16, + 0x56, 0x92, 0xe1, 0x34, 0x88, 0x24, 0xce, 0xc0, 0x34, 0x0a, 0xf1, 0x87, 0xfa, 0x8b, 0x45, 0x5f, 0x9e, 0xf8, 0xad, + 0xfb, 0x6b, 0xa9, 0xb4, 0xfa, 0xfc, 0xb3, 0x58, 0xb8, 0x20, 0x13, 0xfb, 0x8d, 0x5e, 0x58, 0x98, 0x14, 0x56, 0xe0, + 0xaa, 0x7a, 0xc1, 0xb3, 0x64, 0xa5, 0xf0, 0xe4, 0xbb, 0x11, 0x5a, 0x7a, 0xc2, 0xcf, 0xa3, 0xac, 0x1a, 0x7b, 0x33, + 0xa2, 0x51, 0x2d, 0x9f, 0x82, 0xda, 0x1d, 0x1d, 0x08, 0x97, 0xc9, 0xc0, 0xaa, 0xb2, 0x00, 0xf5, 0xe7, 0x97, 0xb9, + 0x47, 0xc2, 0xba, 0x54, 0x4c, 0xd9, 0x07, 0xcf, 0x89, 0xa0, 0xb7, 0x10, 0x85, 0x18, 0x1e, 0x49, 0xdf, 0xa0, 0xfc, + 0xea, 0x8f, 0xfc, 0xbe, 0xd7, 0x93, 0xbf, 0x33, 0x76, 0xbe, 0x69, 0x96, 0x3b, 0xb3, 0xd7, 0xe8, 0xf5, 0xcf, 0x21, + 0x6b, 0x11, 0x06, 0x39, 0x4d, 0x17, 0x82, 0x26, 0x28, 0x5e, 0x18, 0x0d, 0xac, 0xe7, 0x74, 0xad, 0x37, 0x41, 0xee, + 0x85, 0xc4, 0xf8, 0x7f, 0x91, 0xf0, 0x32, 0xa0, 0x72, 0x32, 0x8a, 0x5a, 0xf0, 0x00, 0x5c, 0x55, 0x43, 0x2d, 0x50, + 0x26, 0x0f, 0x4f, 0xa0, 0x25, 0x63, 0x11, 0x9e, 0x65, 0x1f, 0xeb, 0xd4, 0xc1, 0x78, 0x24, 0xf3, 0xb0, 0xa6, 0xc2, + 0xd5, 0x72, 0x36, 0x39, 0x66, 0x76, 0xcc, 0xea, 0x6a, 0x1f, 0xbb, 0x13, 0x26, 0xf1, 0xcc, 0x79, 0xc8, 0x67, 0xdb, + 0xe3, 0x40, 0x53, 0x6f, 0x1e, 0x38, 0xac, 0x69, 0x36, 0x11, 0xe4, 0x9a, 0x06, 0xb6, 0x00, 0x83, 0x9d, 0xac, 0x55, + 0xa3, 0x84, 0x64, 0xcd, 0x0d, 0x80, 0x38, 0x92, 0x51, 0x08, 0xa9, 0x6c, 0xf8, 0x81, 0xb5, 0x54, 0x5f, 0x81, 0x1e, + 0xab, 0x2f, 0x35, 0x0c, 0x84, 0xa8, 0x6d, 0x84, 0x2a, 0x60, 0x0c, 0x5c, 0x99, 0x7f, 0x29, 0x10, 0x5c, 0xd0, 0x5f, + 0xf6, 0x1a, 0xbe, 0xdc, 0xac, 0xdb, 0x8e, 0x21, 0xea, 0x3a, 0x58, 0x8b, 0xc8, 0x78, 0xd5, 0x15, 0xfe, 0x1b, 0x6e, + 0x22, 0x45, 0x0a, 0xc5, 0x12, 0x91, 0xfc, 0x88, 0xf2, 0x1e, 0xe3, 0x1e, 0xea, 0xbd, 0x1d, 0xbc, 0x8e, 0x84, 0x41, + 0x73, 0xa8, 0xd1, 0x4a, 0x52, 0xbc, 0xc7, 0x56, 0x3d, 0xf6, 0x28, 0xb8, 0x9f, 0x2c, 0x35, 0x7c, 0x87, 0x28, 0x5d, + 0xfd, 0x14, 0x50, 0x4f, 0xfe, 0xa3, 0x67, 0x9b, 0xa7, 0x66, 0x1f, 0x11, 0x7d, 0x93, 0xd1, 0x38, 0xb2, 0x50, 0x51, + 0x14, 0x5e, 0x08, 0x81, 0xe7, 0x1c, 0xf1, 0x54, 0x1f, 0x20, 0xe6, 0x21, 0xd3, 0x64, 0xe4, 0x7a, 0x40, 0x0f, 0x34, + 0x39, 0x7a, 0x76, 0x39, 0xa6, 0x8b, 0xf6, 0x61, 0x74, 0x6c, 0x47, 0x88, 0x4b, 0xb5, 0x89, 0x68, 0x4e, 0xab, 0x2e, + 0x5b, 0x48, 0x62, 0x9d, 0xa7, 0x7c, 0xa4, 0x20, 0x07, 0x6e, 0xc2, 0xea, 0x77, 0x8e, 0x43, 0xbb, 0x28, 0xb8, 0x7d, + 0x4d, 0x25, 0x9c, 0x8d, 0x2a, 0xba, 0x2f, 0x83, 0x4f, 0xa2, 0x59, 0x34, 0x80, 0x6c, 0xc0, 0xd7, 0xfb, 0xdb, 0x09, + 0x96, 0x25, 0xd8, 0x45, 0x6d, 0xa6, 0x6c, 0x5e, 0x9e, 0xc3, 0x6c, 0x6b, 0xb8, 0x2f, 0xd0, 0xfa, 0x12, 0xea, 0x5d, + 0xea, 0x33, 0xc2, 0xb7, 0xf2, 0x60, 0x88, 0xc9, 0xca, 0xcd, 0x46, 0x16, 0x83, 0x75, 0x98, 0x75, 0x8f, 0x91, 0x39, + 0x89, 0x7f, 0xa1, 0xce, 0x5c, 0x10, 0x9e, 0x59, 0xc9, 0x82, 0x4f, 0xe8, 0x66, 0xb0, 0x61, 0x3c, 0xc6, 0xcf, 0x51, + 0xf6, 0xe0, 0xfd, 0x4e, 0x92, 0x56, 0x30, 0x1b, 0x92, 0xda, 0x71, 0xb5, 0xd6, 0xf1, 0x8b, 0x0b, 0xf4, 0x20, 0x35, + 0xf1, 0x54, 0x54, 0x76, 0xc4, 0x2c, 0x90, 0xea, 0x25, 0xf6, 0xbe, 0xf9, 0x49, 0x7c, 0xa4, 0x0d, 0x9e, 0xcb, 0x10, + 0x06, 0xf4, 0x46, 0x62, 0x7d, 0xaf, 0x94, 0xa6, 0x47, 0x65, 0x63, 0xd0, 0xda, 0x98, 0xc9, 0x1c, 0x26, 0xd6, 0x5d, + 0xa2, 0x5e, 0x2c, 0x4f, 0xf2, 0x6b, 0x5b, 0xd3, 0x8a, 0xe3, 0x91, 0xf4, 0x55, 0x95, 0x62, 0xfe, 0x18, 0xd0, 0xf8, + 0xd7, 0x14, 0xc9, 0x23, 0x03, 0x0d, 0x06, 0xa9, 0xb1, 0x62, 0x19, 0x80, 0x43, 0x0c, 0x4d, 0x44, 0x6d, 0xa0, 0x1d, + 0xc3, 0x1d, 0x8d, 0x0c, 0xa9, 0x8f, 0x68, 0x86, 0x24, 0xc0, 0x23, 0x9b, 0x98, 0xac, 0x8c, 0x5d, 0x80, 0x2b, 0x70, + 0xfb, 0x78, 0x06, 0x8d, 0xdf, 0x6e, 0xdd, 0x20, 0xa5, 0xa6, 0x9c, 0x2e, 0x02, 0xd6, 0x98, 0x00, 0x9e, 0x52, 0x4d, + 0xb4, 0x6c, 0x48, 0xf5, 0x53, 0x27, 0x60, 0xbf, 0x38, 0xa8, 0x8f, 0xad, 0x69, 0x4a, 0x59, 0x36, 0x0d, 0xbc, 0x94, + 0x34, 0x42, 0x8c, 0xd0, 0x57, 0x38, 0xe5, 0x08, 0xc4, 0x3b, 0xfc, 0xfa, 0xf4, 0x7a, 0x92, 0xde, 0x26, 0xda, 0xd8, + 0x64, 0x80, 0x61, 0xf8, 0x18, 0xe1, 0x17, 0x3d, 0xec, 0x6c, 0xcd, 0xf8, 0x6b, 0x82, 0x64, 0x3c, 0x29, 0x7c, 0x56, + 0x78, 0x36, 0xb5, 0x45, 0x93, 0x10, 0xff, 0x40, 0x74, 0x28, 0x30, 0x3a, 0x15, 0x94, 0xd9, 0x97, 0x8b, 0xea, 0x45, + 0x4e, 0x41, 0xa3, 0x7d, 0x66, 0xb9, 0xb2, 0x2c, 0x5f, 0x5f, 0xfe, 0xe3, 0x5c, 0x77, 0x5c, 0x62, 0xcf, 0x9d, 0x94, + 0xb8, 0x68, 0x65, 0xcd, 0x1f, 0x5a, 0x5b, 0x6f, 0xc9, 0x61, 0x23, 0x17, 0x9d, 0x42, 0x09, 0xff, 0xc4, 0x5f, 0x0a, + 0x82, 0x95, 0x7b, 0xb0, 0x64, 0x2a, 0xe5, 0x82, 0x8b, 0x19, 0xdd, 0x76, 0xfa, 0x5e, 0xb0, 0xd0, 0xd9, 0xd9, 0xc5, + 0x71, 0x82, 0x24, 0xe5, 0x87, 0xfc, 0x33, 0xef, 0xe2, 0x6c, 0x3b, 0xab, 0xe9, 0x68, 0x45, 0xef, 0xd8, 0xbb, 0x1c, + 0x4e, 0x6c, 0x11, 0xa5, 0xd3, 0x07, 0xe7, 0x67, 0x33, 0xf8, 0xe0, 0x28, 0x6a, 0xe9, 0x4c, 0xcd, 0x58, 0xc0, 0xb9, + 0xb9, 0x7b, 0x88, 0xa0, 0xa7, 0x90, 0x88, 0xd1, 0xf7, 0x2e, 0xa8, 0xf7, 0x8a, 0x6d, 0xce, 0x37, 0x89, 0xa0, 0xcd, + 0x0a, 0x9a, 0x45, 0xf4, 0x62, 0x78, 0x2a, 0xbc, 0x76, 0xe7, 0x5a, 0xae, 0x78, 0x5e, 0x42, 0xa3, 0x21, 0x6b, 0x90, + 0x6c, 0xbf, 0xd3, 0xc4, 0x0f, 0xfa, 0xb9, 0xd5, 0x42, 0x6d, 0x65, 0x4a, 0xfd, 0x98, 0x31, 0x4b, 0x9d, 0xb3, 0x92, + 0xfe, 0x9c, 0xfa, 0x0c, 0x6a, 0x9e, 0x6c, 0x75, 0xfa, 0x35, 0x9f, 0x5f, 0x0e, 0xd5, 0xb3, 0x99, 0xf2, 0x0e, 0x61, + 0x09, 0xf3, 0x7d, 0xa2, 0x54, 0x8f, 0xac, 0xbb, 0x25, 0xce, 0x52, 0x54, 0xc7, 0x22, 0x89, 0x22, 0x63, 0x3b, 0xc3, + 0x11, 0x7a, 0x21, 0xf1, 0x6c, 0x56, 0x67, 0xc2, 0xe4, 0x6a, 0x16, 0x6f, 0x07, 0x73, 0x25, 0x9c, 0xc4, 0x22, 0x89, + 0x50, 0xa4, 0x7d, 0x23, 0x5d, 0x4c, 0xf9, 0xa9, 0xce, 0xed, 0x48, 0xa8, 0xf4, 0x16, 0xff, 0x34, 0xb8, 0xc4, 0x44, + 0x2a, 0x50, 0x89, 0xcf, 0xef, 0x96, 0x58, 0x22, 0x49, 0x15, 0x39, 0x14, 0xd4, 0xca, 0xe4, 0x0f, 0x9b, 0xe7, 0x52, + 0x5a, 0x77, 0x47, 0xe0, 0xfa, 0x32, 0x56, 0x12, 0x77, 0xff, 0x32, 0x99, 0x47, 0x01, 0xd8, 0x2f, 0xcb, 0x75, 0x3e, + 0xc4, 0x80, 0xcb, 0xa3, 0x53, 0x8d, 0x20, 0xd8, 0xf1, 0x06, 0xde, 0x0c, 0x24, 0x08, 0x4e, 0x33, 0x12, 0x11, 0x0b, + 0xce, 0x90, 0xc5, 0x93, 0x37, 0x00, 0x24, 0xe7, 0x0f, 0xf1, 0xf3, 0x82, 0x94, 0x1d, 0xa0, 0x0a, 0x47, 0x05, 0x20, + 0x76, 0x48, 0xd0, 0xe8, 0xc2, 0xbb, 0xd9, 0x67, 0xad, 0xd9, 0xf2, 0x7a, 0x55, 0x3c, 0x07, 0x55, 0x43, 0x72, 0x52, + 0x12, 0x46, 0x9c, 0x61, 0xf6, 0x83, 0xa0, 0x44, 0xf9, 0xf6, 0x30, 0x21, 0x8c, 0xcc, 0x96, 0x78, 0xa1, 0xd1, 0x20, + 0xc0, 0xed, 0x23, 0xc4, 0x4c, 0xb6, 0x4d, 0x39, 0x26, 0x5f, 0x73, 0xc6, 0x39, 0x63, 0xce, 0x10, 0x8a, 0x06, 0x66, + 0x6b, 0x09, 0xc4, 0x3a, 0x8b, 0x32, 0x1a, 0x4a, 0x53, 0xfc, 0x4e, 0x8e, 0xa0, 0xd6, 0x91, 0xb7, 0x26, 0x43, 0xbb, + 0x0d, 0xee, 0x44, 0x80, 0x43, 0x0a, 0xf7, 0x4b, 0x60, 0x41, 0x79, 0xe5, 0xb6, 0x64, 0x96, 0xda, 0x7e, 0x48, 0xb6, + 0x92, 0xde, 0x9b, 0x81, 0xc1, 0xbb, 0x58, 0xc3, 0xc5, 0x2c, 0x1d, 0x25, 0x64, 0x15, 0x6c, 0x16, 0xeb, 0xfe, 0xe5, + 0xd7, 0x5d, 0x37, 0x19, 0xb9, 0xad, 0x92, 0xb1, 0xa2, 0x1c, 0x8f, 0xab, 0x39, 0x1b, 0x70, 0x7d, 0x19, 0xa4, 0xe1, + 0x52, 0x21, 0x74, 0xa6, 0x7d, 0xb7, 0xbf, 0x8b, 0x6b, 0xb7, 0x5c, 0x1e, 0x2d, 0xc0, 0xa0, 0x8d, 0x3d, 0x70, 0x8a, + 0x0a, 0x2c, 0x89, 0x0a, 0x49, 0xd8, 0x7c, 0x00, 0x4c, 0xb5, 0x7e, 0x10, 0xe5, 0xf8, 0x77, 0x49, 0x5f, 0x0b, 0x32, + 0x3d, 0xd7, 0x79, 0x7e, 0x96, 0xfa, 0x83, 0x69, 0xf7, 0x71, 0x8c, 0xe1, 0x8c, 0xc3, 0x1c, 0x21, 0x2a, 0x73, 0xf4, + 0xeb, 0xcf, 0xf0, 0xd8, 0xdb, 0x4a, 0xf5, 0x9f, 0x50, 0x9c, 0xdf, 0x2b, 0xa3, 0x79, 0xb6, 0x4c, 0xfa, 0x6c, 0x41, + 0xbf, 0xcf, 0x24, 0x2d, 0xdd, 0x76, 0xf9, 0xc4, 0xff, 0xa6, 0x3a, 0x3c, 0xdd, 0xed, 0x11, 0xe3, 0x22, 0x92, 0x04, + 0x9f, 0x98, 0x13, 0x9e, 0xee, 0x9a, 0x89, 0xba, 0x3c, 0x43, 0x6a, 0xf7, 0xc6, 0x68, 0x9b, 0x4a, 0xf5, 0xb6, 0xac, + 0xd8, 0xf4, 0xa2, 0x22, 0xd8, 0xd5, 0x85, 0x75, 0x79, 0xf7, 0xbb, 0x4f, 0xa9, 0x77, 0x73, 0x10, 0x6e, 0x5c, 0x6d, + 0x57, 0x35, 0x5a, 0xcc, 0x69, 0x01, 0xa5, 0x24, 0x52, 0x12, 0xcd, 0xa6, 0x71, 0xa4, 0x54, 0xf8, 0x79, 0x8e, 0x92, + 0x5b, 0x49, 0x9b, 0x5f, 0x5b, 0xc3, 0x89, 0x2a, 0xa9, 0x8e, 0xd4, 0xd4, 0x61, 0x4d, 0x7a, 0x0a, 0xcc, 0xff, 0xd9, + 0x31, 0x12, 0x82, 0xc2, 0x85, 0x33, 0x0f, 0x28, 0xf5, 0x57, 0x43, 0xb5, 0x93, 0x3e, 0x1e, 0x79, 0x7d, 0x6f, 0x1d, + 0xe7, 0x3a, 0x17, 0xce, 0x38, 0x74, 0xd3, 0xcd, 0x03, 0x3d, 0xfd, 0xae, 0xc7, 0x57, 0xf1, 0xd7, 0x86, 0x64, 0x49, + 0x22, 0x35, 0x73, 0x67, 0x7b, 0x65, 0x4b, 0xfb, 0xea, 0xa1, 0x42, 0x8b, 0xe3, 0xd2, 0x58, 0xed, 0x2b, 0xcc, 0xd3, + 0x1b, 0x35, 0x58, 0x44, 0x94, 0xa6, 0x7e, 0x38, 0x1e, 0xd2, 0x79, 0x0e, 0xd4, 0xd4, 0xe2, 0xe6, 0x29, 0xa7, 0xf5, + 0x13, 0xc6, 0xa9, 0x00, 0x3b, 0x13, 0x45, 0x2e, 0x5e, 0xab, 0xbf, 0x29, 0xfd, 0x0a, 0xf6, 0xd7, 0x2b, 0xa9, 0xfa, + 0x99, 0xc5, 0x2a, 0x9d, 0x19, 0x56, 0xe5, 0xcc, 0x9a, 0xe9, 0x0a, 0xfb, 0x39, 0x17, 0xbb, 0x1c, 0x58, 0x94, 0x24, + 0x79, 0x3a, 0xae, 0xcc, 0x22, 0x9c, 0xdb, 0x4b, 0xe7, 0x91, 0x4e, 0x9d, 0x6c, 0x30, 0x29, 0x13, 0x5a, 0x3d, 0x32, + 0x2d, 0x31, 0x32, 0x4d, 0x20, 0xd8, 0xa5, 0xb7, 0xc8, 0xd2, 0xf6, 0x8b, 0x3b, 0x16, 0x85, 0xda, 0x5c, 0x6d, 0x7a, + 0x1c, 0x85, 0x8c, 0xf9, 0xa5, 0xb5, 0xa7, 0xc4, 0xa5, 0xf3, 0x63, 0x11, 0xed, 0xa7, 0x4b, 0x75, 0xac, 0xd9, 0x89, + 0x40, 0x95, 0x6b, 0x03, 0xf9, 0x79, 0x9b, 0x1e, 0xd2, 0xe7, 0x2d, 0x9c, 0x95, 0x3f, 0x94, 0x61, 0x7d, 0x40, 0x08, + 0x13, 0x81, 0x91, 0xb1, 0x50, 0x5a, 0x49, 0x60, 0x15, 0x78, 0xc5, 0xa8, 0xd9, 0x6c, 0x57, 0x7c, 0x1f, 0x40, 0x3a, + 0xc7, 0x4d, 0x08, 0x07, 0x80, 0xbc, 0x9e, 0x42, 0x75, 0x16, 0xa2, 0x40, 0x33, 0x05, 0x48, 0xf8, 0x21, 0x3d, 0x7f, + 0x01, 0xf3, 0xc7, 0x74, 0xf4, 0x56, 0xad, 0xdc, 0x46, 0x3b, 0x1c, 0xcb, 0x53, 0xe5, 0xa6, 0x1a, 0x87, 0x8b, 0x92, + 0xa8, 0x24, 0x16, 0x35, 0xbc, 0x72, 0x45, 0x9b, 0x33, 0x1f, 0xf9, 0x0d, 0xdb, 0xc4, 0xe3, 0x5f, 0x57, 0x63, 0x5c, + 0x81, 0xaa, 0x51, 0x05, 0x5b, 0xf2, 0x05, 0x98, 0xea, 0x2e, 0x12, 0xd8, 0x62, 0xd3, 0xd8, 0x9c, 0x81, 0x0e, 0xed, + 0xa3, 0xec, 0x49, 0xa9, 0x4a, 0x16, 0xa8, 0xe4, 0x6a, 0x29, 0xac, 0xb6, 0xa6, 0x51, 0x9b, 0x90, 0xf7, 0xbf, 0xa1, + 0x79, 0xeb, 0x4b, 0x3e, 0x61, 0x7b, 0x88, 0xe8, 0x33, 0x7c, 0xee, 0xa3, 0x5a, 0x7c, 0x0f, 0x28, 0x9c, 0x2d, 0x05, + 0x23, 0x53, 0x1c, 0xda, 0xe3, 0x05, 0x4a, 0x93, 0x79, 0x78, 0xa8, 0xa3, 0x0a, 0x1b, 0xf2, 0x11, 0x0e, 0xd8, 0x7e, + 0x4c, 0x61, 0x89, 0x0a, 0x25, 0xfa, 0x2e, 0xda, 0xcd, 0xc1, 0x77, 0xa5, 0x03, 0xde, 0x96, 0x21, 0x2e, 0xa6, 0x9b, + 0x9d, 0x78, 0x8b, 0x96, 0xe5, 0xab, 0x38, 0xd8, 0x66, 0x84, 0xa1, 0x6c, 0x0a, 0x70, 0xe7, 0xbd, 0xaa, 0x50, 0xe4, + 0xf8, 0xd6, 0x0c, 0x8e, 0xea, 0x0d, 0xd2, 0x45, 0x13, 0xa0, 0x0e, 0x46, 0x3d, 0xf0, 0x13, 0x82, 0x1c, 0x50, 0x19, + 0xbd, 0xdb, 0xa2, 0x2d, 0xae, 0x05, 0xcf, 0x84, 0x80, 0x34, 0xad, 0x48, 0xb5, 0x1b, 0xa5, 0x51, 0x1f, 0x0d, 0xcd, + 0xbe, 0x89, 0x45, 0x02, 0x90, 0xcc, 0xe2, 0x55, 0x49, 0xa4, 0x02, 0xd8, 0x02, 0x3b, 0x36, 0x8b, 0x6e, 0xf8, 0x66, + 0x7d, 0x32, 0x60, 0x68, 0xe9, 0xb5, 0xef, 0xc9, 0xea, 0xa3, 0xf6, 0xb9, 0x86, 0x78, 0xc5, 0x71, 0x8e, 0x34, 0x99, + 0x2a, 0xea, 0x7c, 0xb2, 0x8e, 0xf2, 0x58, 0x9b, 0xcb, 0xe5, 0x8d, 0x0d, 0x65, 0xd0, 0x63, 0x83, 0x45, 0x4a, 0x5c, + 0x3b, 0x66, 0xbf, 0xbe, 0xb8, 0xc8, 0xa0, 0xe3, 0x9c, 0x3e, 0x90, 0x30, 0x4d, 0x27, 0x11, 0xea, 0x8e, 0x95, 0xaf, + 0xab, 0xd0, 0x2c, 0x08, 0xfb, 0xfe, 0x22, 0x19, 0x6b, 0xd8, 0x78, 0x37, 0x64, 0x73, 0x7d, 0xd5, 0xde, 0x0f, 0x50, + 0x07, 0xe2, 0x62, 0xc0, 0xc5, 0x5b, 0x50, 0xc6, 0xcc, 0xbf, 0xa3, 0x5e, 0x2b, 0xa5, 0x34, 0x6a, 0x79, 0x18, 0x6a, + 0x78, 0xab, 0xbd, 0xcc, 0x7f, 0x3c, 0xfb, 0x90, 0x0f, 0x05, 0x2a, 0x54, 0x21, 0x35, 0x4d, 0xa2, 0x6e, 0xd7, 0x41, + 0x6c, 0x6b, 0x27, 0x99, 0x5a, 0xb1, 0x88, 0x94, 0x47, 0x80, 0xbb, 0x70, 0x78, 0xb7, 0xfa, 0x85, 0x11, 0xdf, 0xec, + 0x73, 0x2d, 0xb4, 0x25, 0x9a, 0xb3, 0x23, 0xde, 0x45, 0x2b, 0x3b, 0x9c, 0x5a, 0x20, 0x1d, 0x3b, 0x15, 0xdb, 0x25, + 0x8a, 0xde, 0x63, 0x81, 0xad, 0x66, 0x6b, 0xeb, 0xb7, 0x56, 0xf4, 0x21, 0xac, 0x16, 0xb4, 0xb6, 0xe7, 0x32, 0x8d, + 0xcd, 0xc4, 0x09, 0x62, 0x01, 0x34, 0x7b, 0xfb, 0xaa, 0x24, 0xef, 0x33, 0x0b, 0x2e, 0x4b, 0xb1, 0x44, 0x8a, 0xb0, + 0x03, 0x3a, 0x89, 0x06, 0x4c, 0x54, 0x05, 0xc7, 0x46, 0xec, 0xf9, 0xa2, 0xde, 0x37, 0xae, 0x4a, 0x32, 0x28, 0x93, + 0xd6, 0x6d, 0xd5, 0x8b, 0xc9, 0xf7, 0x7e, 0x16, 0x48, 0x3e, 0x14, 0x0e, 0x60, 0xc7, 0x25, 0x5c, 0x7c, 0x16, 0x8c, + 0xdc, 0x2a, 0x65, 0x2d, 0xc0, 0x9c, 0xce, 0x99, 0xbf, 0x5a, 0x7a, 0x34, 0x2d, 0x29, 0x27, 0x0e, 0xd3, 0xf7, 0xe7, + 0x10, 0xc9, 0x15, 0x48, 0x3f, 0xef, 0x3d, 0xef, 0x15, 0x7d, 0xe3, 0x8f, 0x57, 0xfb, 0x94, 0x19, 0xcd, 0xa6, 0x2c, + 0xf5, 0x64, 0xc9, 0xd3, 0x2d, 0x15, 0x1c, 0xa3, 0x8b, 0x56, 0x37, 0x6c, 0xcd, 0x8a, 0x35, 0x23, 0xcb, 0xf0, 0x8f, + 0x60, 0x85, 0x6f, 0x60, 0x5d, 0x2c, 0x01, 0xcd, 0xdf, 0x18, 0x1f, 0x85, 0x3c, 0x2e, 0x3e, 0xd0, 0xf9, 0x19, 0x21, + 0xae, 0xc2, 0x54, 0x91, 0x70, 0xbe, 0x55, 0x6a, 0xa5, 0x04, 0x15, 0xd3, 0xf2, 0x99, 0x16, 0xdf, 0xa8, 0x6d, 0x95, + 0xd9, 0x5b, 0x7e, 0x99, 0xe4, 0xca, 0x74, 0x7e, 0x9e, 0x9c, 0x49, 0xf1, 0xf2, 0xc3, 0x12, 0x55, 0xe6, 0x9f, 0x46, + 0x68, 0xa3, 0xef, 0xe1, 0xc7, 0x0e, 0x3f, 0xc8, 0xbc, 0x40, 0x24, 0xd5, 0xb8, 0xc0, 0x38, 0x2a, 0x3f, 0x4d, 0xab, + 0x11, 0x33, 0x45, 0xf8, 0xc6, 0xa9, 0x03, 0xcb, 0xf7, 0xb9, 0x54, 0x73, 0x2e, 0x42, 0x05, 0x10, 0x7b, 0x1a, 0x3b, + 0xef, 0xc2, 0x9c, 0x31, 0x15, 0x09, 0x84, 0x71, 0x85, 0x76, 0x49, 0x30, 0x76, 0x4b, 0xa9, 0xb6, 0xd5, 0xbb, 0x05, + 0xf3, 0x9a, 0x8a, 0x08, 0x98, 0xc2, 0x3b, 0xd0, 0xbc, 0x99, 0x2d, 0x6d, 0xd0, 0x39, 0xb1, 0xa3, 0x02, 0xfb, 0x31, + 0xa6, 0xbc, 0xc3, 0xde, 0x6f, 0xa6, 0xcf, 0x19, 0xe7, 0xd0, 0x3d, 0x0f, 0xf5, 0xa6, 0x33, 0x5c, 0xf9, 0x86, 0x3e, + 0x9b, 0x11, 0x67, 0x0b, 0x24, 0x5f, 0x23, 0x5b, 0xb1, 0xae, 0x5a, 0x82, 0xba, 0x07, 0x92, 0xbd, 0x7d, 0x75, 0xdd, + 0x5b, 0x7d, 0x2e, 0x08, 0x1a, 0xdd, 0xad, 0x00, 0xbb, 0x83, 0x05, 0xef, 0x56, 0x67, 0xe2, 0x89, 0x03, 0x80, 0xec, + 0xd2, 0x7f, 0x12, 0x36, 0xd0, 0x9d, 0x76, 0x7f, 0xed, 0x84, 0xb2, 0xa0, 0x75, 0x36, 0xe5, 0x31, 0xb4, 0x65, 0x17, + 0x11, 0x43, 0x76, 0x1d, 0xf6, 0xac, 0x9b, 0xfb, 0x42, 0x58, 0x81, 0xc7, 0x3d, 0xb0, 0xbe, 0x08, 0x7c, 0x4a, 0x04, + 0x24, 0xe4, 0x5c, 0x88, 0xbf, 0x75, 0xa1, 0x66, 0x19, 0x77, 0x9b, 0x0e, 0xb1, 0x9b, 0x24, 0xf4, 0x07, 0x55, 0xe1, + 0xad, 0xa5, 0x95, 0xcf, 0x02, 0xca, 0x7c, 0x24, 0x23, 0x03, 0xe7, 0xdc, 0xd8, 0x9e, 0x76, 0x5e, 0x9a, 0x31, 0x2f, + 0x15, 0x5a, 0x66, 0xf2, 0x6e, 0xd5, 0xc0, 0xb3, 0xf6, 0xbf, 0x9b, 0xe3, 0xc4, 0x86, 0xe6, 0xb1, 0x1d, 0x73, 0xb4, + 0xbd, 0x18, 0xf7, 0x2d, 0xfb, 0xea, 0xe5, 0x32, 0x2e, 0x9b, 0x67, 0xbd, 0x5b, 0xbb, 0x55, 0xec, 0xa7, 0x88, 0x0a, + 0x9b, 0xc2, 0x64, 0xaa, 0x49, 0x0c, 0x83, 0xc0, 0x68, 0x01, 0xec, 0x4d, 0x34, 0xc3, 0x2e, 0xe6, 0xa0, 0xb9, 0x34, + 0xeb, 0x6e, 0xf6, 0x38, 0x7d, 0x9b, 0xf9, 0x4a, 0xd5, 0x5e, 0x55, 0xa3, 0x44, 0xce, 0xe9, 0xb0, 0x7f, 0x29, 0xed, + 0x3f, 0x8a, 0xbc, 0xa9, 0x61, 0x2c, 0x0e, 0x44, 0x63, 0x01, 0xc1, 0x65, 0x7a, 0xab, 0xcd, 0xb2, 0x08, 0xc9, 0xa9, + 0x15, 0xe5, 0x1f, 0x34, 0x80, 0x54, 0x5c, 0xad, 0x16, 0x37, 0xe3, 0x58, 0x70, 0x8c, 0x4a, 0x6d, 0x0c, 0x4f, 0xff, + 0x24, 0x1e, 0x52, 0xd1, 0x56, 0x97, 0x13, 0xcd, 0x4b, 0xb5, 0xe5, 0x10, 0x40, 0x20, 0x57, 0x1b, 0xd6, 0x38, 0xf4, + 0x57, 0x27, 0x73, 0x23, 0xd3, 0x61, 0x66, 0xaa, 0xc0, 0xf8, 0x5b, 0x45, 0x53, 0x30, 0x39, 0x17, 0x49, 0xcc, 0xdc, + 0xce, 0xc0, 0xb2, 0x06, 0xe8, 0x20, 0x7a, 0xc3, 0xb7, 0x93, 0x1f, 0xea, 0x4f, 0x2b, 0x8b, 0x22, 0x4e, 0x1d, 0x93, + 0xd3, 0xd7, 0x76, 0x50, 0x50, 0xab, 0xed, 0x5c, 0xc4, 0x6b, 0x9e, 0x13, 0x68, 0x5f, 0xf9, 0xd5, 0xec, 0xf4, 0xfa, + 0x85, 0xd3, 0xef, 0x90, 0x15, 0x48, 0x9d, 0xe2, 0x5f, 0xba, 0x32, 0xca, 0xd5, 0xce, 0x79, 0x36, 0xfd, 0xf2, 0x98, + 0x24, 0xdb, 0xc6, 0xbf, 0x46, 0x2e, 0x39, 0x20, 0xf9, 0x13, 0xe7, 0xc0, 0xc8, 0x16, 0xd3, 0x24, 0x61, 0xaa, 0xd7, + 0x24, 0xcd, 0x59, 0x58, 0xc7, 0x6e, 0x3a, 0xfe, 0x73, 0xec, 0xa2, 0x27, 0x91, 0x90, 0x5a, 0x6f, 0x69, 0xa4, 0x85, + 0x75, 0xef, 0x8c, 0x5c, 0xc8, 0xe6, 0xa1, 0x4c, 0x01, 0x19, 0xd3, 0xcd, 0xba, 0x4b, 0x25, 0x12, 0xb5, 0x60, 0x69, + 0x68, 0xb7, 0x93, 0xe1, 0x10, 0xb5, 0xf6, 0x91, 0xec, 0x54, 0xf4, 0x2e, 0x54, 0x85, 0xa1, 0x8e, 0xe4, 0x4b, 0x61, + 0x25, 0x16, 0x58, 0x7b, 0x29, 0xd7, 0x92, 0x05, 0x5d, 0x79, 0x79, 0x24, 0x14, 0xeb, 0x00, 0xb6, 0xd6, 0xa5, 0xd1, + 0x0d, 0xa0, 0x13, 0xc5, 0xc0, 0x75, 0xc8, 0x00, 0x94, 0x31, 0x85, 0xca, 0x2d, 0x2d, 0x2e, 0xb9, 0x16, 0xa5, 0x98, + 0x03, 0x52, 0xbf, 0xc6, 0xe0, 0x8c, 0xf9, 0xbd, 0x8f, 0x29, 0xc4, 0x91, 0x31, 0xbc, 0x6a, 0x49, 0xda, 0x32, 0xd7, + 0xd6, 0x8a, 0x69, 0x9d, 0x30, 0x75, 0x96, 0xfd, 0x34, 0xf8, 0xce, 0xbf, 0xa3, 0x8e, 0xb4, 0xbc, 0xc5, 0x91, 0x8a, + 0x70, 0x68, 0x7b, 0x62, 0x2e, 0x4c, 0x29, 0x3c, 0x66, 0xb7, 0x77, 0x84, 0x6e, 0x7a, 0x29, 0xe0, 0xb1, 0x70, 0x63, + 0x2a, 0x30, 0x8e, 0x1e, 0x3f, 0x14, 0x4e, 0x84, 0xe1, 0xd0, 0x54, 0x9d, 0xf0, 0x6e, 0x9a, 0x32, 0x0b, 0x72, 0x6a, + 0x24, 0x6c, 0x78, 0xb0, 0xee, 0x07, 0x50, 0x14, 0x09, 0x69, 0x16, 0x57, 0x8d, 0x26, 0x8a, 0xeb, 0x8a, 0x0b, 0xbb, + 0x2f, 0xc7, 0xf9, 0x45, 0x25, 0x0e, 0xdd, 0xb3, 0xaa, 0x63, 0x8b, 0xc4, 0x67, 0x53, 0x55, 0x46, 0x44, 0xd5, 0x7b, + 0x09, 0x81, 0xb9, 0xad, 0xa5, 0x1b, 0x7f, 0xec, 0x0a, 0x57, 0x06, 0x0f, 0x0c, 0x21, 0xd2, 0xf4, 0x6a, 0x5d, 0xa2, + 0xe4, 0xed, 0xea, 0x0f, 0xfb, 0x61, 0xfd, 0xc1, 0xd8, 0x64, 0x07, 0xb7, 0x0a, 0xa4, 0xcd, 0x39, 0xbf, 0x66, 0xa6, + 0xb5, 0x6c, 0xb5, 0x0f, 0x6a, 0x94, 0x07, 0x9b, 0xcb, 0x34, 0x14, 0xf3, 0x4f, 0xef, 0x0c, 0x1f, 0x9c, 0x70, 0x91, + 0xf8, 0x02, 0x12, 0x71, 0xd8, 0x9e, 0x3e, 0x3e, 0x52, 0xf9, 0x5b, 0x27, 0x54, 0xd8, 0x8d, 0x52, 0xb6, 0x83, 0xf2, + 0xbe, 0x3a, 0xdc, 0x13, 0x13, 0x35, 0xd8, 0x67, 0x97, 0xa5, 0xa3, 0x01, 0x92, 0x94, 0x26, 0xf6, 0x25, 0x8e, 0xf7, + 0xc5, 0x0c, 0xeb, 0x05, 0x22, 0x5e, 0x75, 0xb2, 0x14, 0x4a, 0xa6, 0xec, 0xf9, 0xec, 0x78, 0x1d, 0x64, 0xf2, 0x11, + 0x55, 0x1d, 0xd2, 0xdc, 0xd4, 0x72, 0x97, 0x13, 0x03, 0xdd, 0x6b, 0xd3, 0x9f, 0xdf, 0x37, 0x86, 0x6c, 0x2b, 0x91, + 0x6f, 0x7c, 0x7b, 0xd4, 0x3f, 0xbd, 0x7e, 0xa1, 0x21, 0xd9, 0x9b, 0x65, 0xec, 0x6e, 0x7f, 0xb8, 0x2c, 0xea, 0xa8, + 0xea, 0x07, 0x55, 0x30, 0x4b, 0xea, 0xa9, 0xe9, 0x2c, 0xa4, 0x04, 0x13, 0x0e, 0x04, 0x9c, 0xb5, 0x1e, 0x84, 0xaa, + 0xcb, 0xbf, 0xb6, 0x57, 0x57, 0xbb, 0xf1, 0x62, 0xe1, 0x69, 0x64, 0x23, 0x31, 0xd4, 0x61, 0xe9, 0x3b, 0xb3, 0x85, + 0xf0, 0x0c, 0xbf, 0xef, 0x6a, 0x24, 0x2e, 0x35, 0x00, 0x5f, 0x2f, 0xdf, 0x9d, 0xfb, 0xe1, 0xf0, 0x21, 0xb0, 0x17, + 0xcc, 0x8c, 0xf7, 0x59, 0x69, 0x8a, 0x25, 0x0d, 0x3f, 0x46, 0x36, 0xb3, 0xae, 0x7d, 0x12, 0x82, 0x08, 0xac, 0x21, + 0x42, 0x95, 0x87, 0x66, 0x0e, 0x65, 0xac, 0x1c, 0xab, 0x68, 0xed, 0xd9, 0x6f, 0x30, 0x25, 0xb2, 0xd9, 0x22, 0xa0, + 0x23, 0xfb, 0x7e, 0x79, 0x51, 0xcb, 0xf0, 0xba, 0x7f, 0x79, 0xf8, 0x22, 0x17, 0xb5, 0x59, 0x03, 0xf8, 0x3b, 0x92, + 0xd5, 0xb2, 0x37, 0x96, 0x5f, 0xe8, 0x14, 0x6c, 0xb5, 0x39, 0x30, 0x22, 0x92, 0x36, 0x8c, 0xb8, 0x20, 0x99, 0x33, + 0x31, 0x15, 0x42, 0x96, 0x1e, 0xf7, 0xf1, 0x32, 0x05, 0xc0, 0xe9, 0x72, 0x65, 0xc4, 0x05, 0x81, 0x90, 0x8e, 0xc3, + 0x98, 0x16, 0xd2, 0xb2, 0x9e, 0xed, 0x42, 0xb3, 0x51, 0xa3, 0xd0, 0x35, 0x87, 0x44, 0x8d, 0x99, 0x75, 0x8f, 0x43, + 0x5c, 0x6a, 0x3b, 0x21, 0x2b, 0xbf, 0xb9, 0x9a, 0x01, 0xd0, 0x98, 0x48, 0x2e, 0x97, 0xc3, 0x44, 0x96, 0x98, 0xcf, + 0x98, 0xb4, 0xe9, 0xeb, 0xc3, 0x37, 0x31, 0x3d, 0x43, 0xec, 0x1a, 0xeb, 0x0f, 0xd1, 0xf2, 0xdc, 0x8b, 0x10, 0xd4, + 0xba, 0x6c, 0xd9, 0xa3, 0x68, 0x2b, 0x64, 0xa2, 0x6d, 0x49, 0xd8, 0x02, 0x0d, 0xec, 0x33, 0x9e, 0x0d, 0x97, 0x83, + 0x28, 0x4b, 0x40, 0x6a, 0x29, 0x87, 0xfc, 0x1a, 0xed, 0x11, 0x62, 0x0c, 0x16, 0xac, 0x81, 0xe5, 0xbe, 0xe1, 0x30, + 0x0a, 0x12, 0xec, 0x81, 0xff, 0xbf, 0x20, 0x96, 0xab, 0x6f, 0x27, 0x7b, 0x5e, 0x57, 0x25, 0xda, 0x06, 0x03, 0xe0, + 0xa0, 0xe3, 0x11, 0x06, 0x8d, 0x6b, 0x1a, 0xa8, 0xae, 0x27, 0x97, 0x0b, 0x33, 0x36, 0x55, 0x90, 0x7a, 0x06, 0xdc, + 0x12, 0x6e, 0xfb, 0x59, 0xc6, 0x1c, 0x0c, 0x6c, 0x9c, 0xdd, 0x8d, 0xed, 0x1a, 0x43, 0xf0, 0xe8, 0x04, 0xed, 0x74, + 0xa7, 0x84, 0x3c, 0xaf, 0x1f, 0xad, 0xd5, 0xb0, 0xc3, 0xe7, 0xad, 0x69, 0xcf, 0x23, 0xcc, 0x88, 0xb8, 0x69, 0xba, + 0x60, 0x63, 0x29, 0xc1, 0x52, 0xa4, 0x88, 0x01, 0x6c, 0x47, 0xd9, 0x0d, 0x80, 0x16, 0xd8, 0x1f, 0xca, 0x6b, 0x8d, + 0x1e, 0x3d, 0x1b, 0x3e, 0xc7, 0xa8, 0xea, 0x32, 0x87, 0x91, 0x7a, 0xee, 0x50, 0x37, 0x1e, 0x78, 0x7e, 0xaa, 0xd6, + 0x28, 0x14, 0x8a, 0x25, 0x70, 0xf4, 0xf3, 0x7d, 0x1a, 0x89, 0x67, 0x99, 0x21, 0xec, 0xe4, 0x66, 0xf3, 0x04, 0xc4, + 0x3e, 0x34, 0x32, 0x21, 0x80, 0x10, 0x2c, 0x84, 0xd5, 0x1e, 0x50, 0xce, 0xdf, 0x13, 0xf6, 0x7d, 0x44, 0xc7, 0x4d, + 0x80, 0x07, 0x53, 0x50, 0x9c, 0xac, 0x7d, 0x2a, 0x22, 0x52, 0xf9, 0x49, 0x92, 0x6c, 0xc6, 0x49, 0x9d, 0x04, 0x66, + 0x47, 0x9c, 0x92, 0xa5, 0x58, 0x38, 0x2f, 0x9e, 0x70, 0x60, 0xd3, 0x35, 0x05, 0x4c, 0x27, 0xbe, 0xc8, 0x49, 0xd9, + 0x0c, 0x5a, 0x38, 0x1f, 0xe7, 0xb6, 0x8d, 0x05, 0x47, 0x65, 0x19, 0x3b, 0x7b, 0xab, 0xc6, 0x08, 0x1d, 0xf6, 0x4d, + 0x82, 0x7a, 0x3f, 0xa6, 0xb0, 0x76, 0xda, 0xe3, 0x23, 0x26, 0xc1, 0xa1, 0x42, 0xe8, 0x26, 0xa8, 0x59, 0xa5, 0x3f, + 0xea, 0x8e, 0x39, 0x35, 0x92, 0xa4, 0x3c, 0x2e, 0x37, 0x24, 0xa9, 0x93, 0x7d, 0xf6, 0x68, 0x4f, 0x1e, 0x28, 0x9c, + 0x26, 0x3c, 0xd1, 0x95, 0x02, 0x06, 0xc1, 0x8b, 0x04, 0xbb, 0xba, 0x2c, 0x14, 0xc9, 0x40, 0x16, 0x43, 0xbb, 0x01, + 0x67, 0x57, 0xe6, 0x94, 0x84, 0x7c, 0xe6, 0x0b, 0x9e, 0xd9, 0x6e, 0x86, 0xe8, 0x26, 0x5b, 0xd4, 0x90, 0x51, 0x30, + 0xb4, 0x5b, 0x28, 0x22, 0x74, 0xeb, 0xc2, 0xdf, 0xe1, 0x0f, 0xcf, 0x52, 0xd9, 0x5c, 0x70, 0x9d, 0x2e, 0xbc, 0xc6, + 0x5f, 0x7a, 0xd6, 0x8a, 0x9d, 0x6f, 0xad, 0x9d, 0x4b, 0x96, 0x8b, 0x5e, 0xf3, 0x1f, 0xb9, 0xc7, 0x05, 0x3a, 0xb1, + 0x05, 0xd1, 0x86, 0x26, 0xa8, 0x0c, 0xa7, 0x81, 0x0b, 0x0f, 0x14, 0x52, 0x7b, 0x1c, 0x96, 0xb2, 0x45, 0xf4, 0x93, + 0x79, 0xae, 0xae, 0xc1, 0x22, 0x31, 0x6b, 0xa5, 0xe8, 0x45, 0x53, 0xa1, 0x88, 0x8c, 0xae, 0x06, 0xa2, 0x54, 0x97, + 0x43, 0x9a, 0x02, 0x91, 0x53, 0x92, 0x78, 0x25, 0x73, 0x06, 0x45, 0x3e, 0xe8, 0x45, 0xff, 0x8b, 0x13, 0x51, 0x0f, + 0xf9, 0xfc, 0x27, 0x55, 0x3e, 0xcb, 0xa2, 0x7e, 0x14, 0x76, 0x7d, 0x19, 0x9b, 0x6c, 0x18, 0x03, 0x18, 0x34, 0xcc, + 0x21, 0xbb, 0x18, 0xd9, 0xaa, 0x76, 0xdd, 0x0c, 0x92, 0x73, 0x43, 0x7e, 0x36, 0x73, 0xc0, 0xfc, 0xfe, 0x5b, 0x28, + 0x1b, 0xbc, 0xc4, 0x8c, 0xc3, 0x7d, 0xe4, 0x27, 0x6f, 0x22, 0x0b, 0xfe, 0x70, 0x1a, 0x3a, 0x40, 0xd3, 0x21, 0xd4, + 0xe6, 0x8a, 0x09, 0x33, 0x03, 0x9b, 0xb2, 0x20, 0xa6, 0x45, 0x4f, 0x89, 0x1a, 0xff, 0xbd, 0x7f, 0xd6, 0x00, 0x34, + 0x7b, 0xe4, 0xcf, 0xd6, 0xe8, 0x40, 0xb7, 0xea, 0xd2, 0x47, 0xf7, 0x26, 0x99, 0x06, 0x00, 0x97, 0xdb, 0xeb, 0xb5, + 0xd8, 0x6e, 0xa7, 0x55, 0xc8, 0x3e, 0x98, 0xe1, 0xc6, 0xf1, 0x94, 0x9c, 0xb7, 0x29, 0x1b, 0x0b, 0x84, 0xa7, 0xcc, + 0x0a, 0x12, 0xbb, 0x6f, 0xdd, 0xb3, 0xb2, 0x7f, 0x8c, 0xff, 0xa5, 0xf1, 0xcb, 0x22, 0x3f, 0xdf, 0x6e, 0xa5, 0x12, + 0x78, 0xa5, 0x9f, 0xd1, 0x7b, 0x17, 0xc0, 0x72, 0x07, 0x91, 0x8c, 0x96, 0xf7, 0xd4, 0xa2, 0xea, 0xa9, 0x5f, 0x64, + 0xab, 0x71, 0xe3, 0xc4, 0x8e, 0xf2, 0xe6, 0xf3, 0x82, 0x8d, 0x40, 0xc5, 0xc3, 0x6b, 0x46, 0x98, 0xfe, 0x7d, 0x32, + 0x71, 0xea, 0x1d, 0x3b, 0x7b, 0x8f, 0x20, 0xeb, 0x89, 0xed, 0xdb, 0xb3, 0x2c, 0xfe, 0x1f, 0x8b, 0x93, 0x75, 0x02, + 0x4f, 0x0d, 0x82, 0xac, 0xfb, 0xcc, 0x0b, 0x2b, 0x40, 0x65, 0xf7, 0x28, 0xe3, 0xcb, 0xc3, 0xd0, 0x7f, 0xfd, 0xcc, + 0x19, 0x35, 0xba, 0x70, 0x8a, 0xe1, 0x9c, 0xa2, 0x31, 0x84, 0xe3, 0x8f, 0x4f, 0x27, 0xbd, 0xb8, 0x67, 0xfc, 0xa7, + 0x49, 0x2f, 0xac, 0xea, 0x35, 0x6d, 0x48, 0x1c, 0xff, 0xb0, 0xf9, 0x9b, 0x45, 0x1e, 0xec, 0x7c, 0xb5, 0x42, 0x8a, + 0xac, 0x0b, 0xa9, 0x4e, 0xab, 0x56, 0x55, 0x17, 0x03, 0xce, 0xd9, 0x1f, 0x8b, 0x97, 0x3a, 0xbb, 0x5f, 0xf4, 0x3f, + 0x9a, 0x79, 0x4d, 0xeb, 0xa3, 0x0f, 0xee, 0xa6, 0x50, 0x35, 0xfb, 0x19, 0xdd, 0x3b, 0xbd, 0xa3, 0x9c, 0xb2, 0x99, + 0x4b, 0x7c, 0xee, 0xab, 0xa5, 0xe7, 0x09, 0xb7, 0x16, 0x1a, 0x99, 0xa1, 0x3b, 0x75, 0x8f, 0xe0, 0x52, 0x24, 0x4d, + 0xcb, 0xde, 0xc2, 0x35, 0x13, 0xe9, 0x4c, 0x7f, 0x76, 0x92, 0xd2, 0x9b, 0xce, 0x67, 0x35, 0x45, 0xcc, 0xaf, 0x88, + 0x99, 0x71, 0x96, 0x04, 0x4f, 0x21, 0x22, 0xd0, 0xda, 0x8a, 0xf2, 0xa9, 0xa2, 0xba, 0xe2, 0x57, 0xbf, 0x9e, 0x65, + 0x81, 0x9f, 0x99, 0x4d, 0x75, 0x2b, 0x57, 0xf4, 0xd1, 0x69, 0x9e, 0xe5, 0x3a, 0x76, 0x20, 0x67, 0x1b, 0xe0, 0xc0, + 0xfe, 0x4d, 0x47, 0x30, 0xac, 0xad, 0xb9, 0x3f, 0x12, 0xbd, 0x31, 0x0a, 0xfe, 0x42, 0x00, 0x46, 0xa4, 0x68, 0xc3, + 0x3e, 0xda, 0x42, 0x17, 0x32, 0xaa, 0xf7, 0x27, 0x6e, 0xff, 0xbc, 0x71, 0xbd, 0xf3, 0x6b, 0xa7, 0x35, 0xa7, 0x54, + 0xe6, 0xe9, 0x74, 0xb4, 0x91, 0xdd, 0xf5, 0xb0, 0x0c, 0xf2, 0x5b, 0xbe, 0xd0, 0xe8, 0xc5, 0x2f, 0x1d, 0x6c, 0x69, + 0xf9, 0x11, 0xa9, 0x7a, 0x92, 0x08, 0xe4, 0x58, 0xcb, 0xc3, 0xab, 0xb9, 0x23, 0x95, 0x0a, 0x1c, 0xd5, 0x3d, 0x19, + 0xf9, 0x66, 0x4e, 0xd9, 0xb5, 0xa4, 0x1d, 0xc1, 0xc6, 0xb0, 0x6c, 0xbe, 0xe6, 0xd2, 0x2c, 0xb5, 0x5e, 0xd9, 0xb3, + 0x13, 0xe1, 0x05, 0x8b, 0x57, 0x62, 0x9b, 0x82, 0xcb, 0xaf, 0xc6, 0x92, 0xb9, 0x79, 0x3d, 0x91, 0x80, 0x59, 0xe6, + 0xd2, 0x6e, 0xf2, 0x19, 0xe9, 0x4a, 0xfd, 0x39, 0x2c, 0x4c, 0x9f, 0x7c, 0x63, 0x31, 0x41, 0xdb, 0xaa, 0x55, 0xb9, + 0xf2, 0x1c, 0xdf, 0xd0, 0xa4, 0xd8, 0x3b, 0xda, 0x33, 0xe9, 0x21, 0x1c, 0x89, 0xc1, 0xcd, 0xbc, 0xa5, 0x92, 0x32, + 0x8d, 0x63, 0x27, 0x49, 0xff, 0x55, 0x5f, 0x86, 0x49, 0x82, 0x83, 0x58, 0xfd, 0x07, 0xd5, 0x98, 0x01, 0x87, 0xd4, + 0x47, 0x27, 0x2a, 0x82, 0xd1, 0x4c, 0x21, 0xba, 0x41, 0xfd, 0x4a, 0x9d, 0x88, 0x67, 0x2f, 0x56, 0x38, 0xe9, 0xcb, + 0x1c, 0x69, 0x5e, 0xf8, 0x8e, 0xdd, 0x3e, 0x32, 0x80, 0x46, 0x61, 0x6e, 0x8c, 0x81, 0x5d, 0xd6, 0xa4, 0x2d, 0x05, + 0x37, 0x7a, 0x03, 0x4d, 0xe0, 0xe6, 0x3d, 0x9d, 0x85, 0x3e, 0x17, 0xe9, 0xc4, 0xe2, 0x8e, 0x76, 0x31, 0xb9, 0xd6, + 0x7c, 0x5d, 0xb0, 0x0b, 0xf9, 0xbb, 0xb9, 0x56, 0xde, 0xb6, 0x69, 0x2e, 0x54, 0x20, 0xc8, 0x51, 0xe0, 0x94, 0xcb, + 0x7b, 0xa2, 0x46, 0xc7, 0xc1, 0xeb, 0xd4, 0x86, 0xd2, 0x1f, 0xf8, 0x75, 0x10, 0x88, 0xce, 0x7e, 0xd0, 0xa6, 0xdf, + 0xb7, 0x54, 0x85, 0x59, 0xd4, 0x43, 0x2c, 0x89, 0x49, 0x77, 0x77, 0xeb, 0xa3, 0x8e, 0xcf, 0xea, 0x1a, 0xb7, 0xf0, + 0x12, 0x83, 0x2b, 0x38, 0x42, 0xab, 0x58, 0x48, 0x9e, 0x81, 0x4f, 0xb7, 0xb0, 0xf1, 0x63, 0xe6, 0x6e, 0x47, 0xe4, + 0xfe, 0xea, 0x7d, 0xc5, 0x91, 0xdd, 0x62, 0xac, 0x9e, 0x3c, 0x45, 0xec, 0x1d, 0xad, 0x32, 0xc3, 0x95, 0x6b, 0xde, + 0x2b, 0xdc, 0xf6, 0x9e, 0x4f, 0xf1, 0xc0, 0x0c, 0x02, 0x7b, 0x46, 0xcc, 0x8e, 0xb1, 0x7e, 0x6d, 0xd8, 0xdb, 0xbe, + 0x73, 0x5d, 0x0a, 0x18, 0xb5, 0x2e, 0xe8, 0x83, 0x20, 0xbe, 0xcf, 0x0c, 0x58, 0x7b, 0x0e, 0xcc, 0xde, 0xe8, 0x8e, + 0xdb, 0x24, 0xec, 0x4a, 0x7d, 0x3c, 0x3e, 0x64, 0xbd, 0x2b, 0x3d, 0x2a, 0x45, 0x1f, 0x05, 0x2e, 0x9a, 0x00, 0x31, + 0x07, 0x47, 0xb2, 0x17, 0x7b, 0xf2, 0x89, 0x98, 0x0b, 0x91, 0x8b, 0x66, 0xb8, 0x07, 0x04, 0x23, 0x87, 0x15, 0xb6, + 0xff, 0x88, 0xd2, 0x86, 0x87, 0x5b, 0x2c, 0x64, 0x98, 0xf3, 0x1a, 0xd7, 0xdd, 0xfd, 0x3b, 0x60, 0xce, 0x5d, 0xbd, + 0x45, 0xdf, 0xe9, 0x31, 0x28, 0xbd, 0x4f, 0x83, 0xa8, 0x55, 0xe4, 0x1e, 0x5e, 0x84, 0xf0, 0xba, 0xc8, 0x8b, 0x46, + 0x20, 0xdd, 0x1d, 0x86, 0xe1, 0x57, 0x10, 0x31, 0x7d, 0x2d, 0x01, 0x7f, 0xa2, 0x30, 0x10, 0x0b, 0x5e, 0x6e, 0xaa, + 0x4a, 0x5d, 0xd9, 0x7a, 0x0c, 0xb5, 0xf0, 0x0c, 0xac, 0xaa, 0x93, 0x8c, 0xe0, 0x6e, 0x73, 0x96, 0x32, 0xbf, 0xad, + 0xc8, 0x8f, 0x65, 0x5d, 0x1c, 0xd2, 0xa6, 0xbd, 0x8a, 0xdf, 0x32, 0xec, 0x05, 0x10, 0xa3, 0x2a, 0x33, 0x53, 0x25, + 0x22, 0x5f, 0x17, 0xa4, 0x8a, 0x94, 0x3d, 0x4b, 0xb6, 0x57, 0xf4, 0x57, 0xaf, 0xd8, 0x12, 0x67, 0xb6, 0x25, 0x27, + 0xfc, 0x54, 0x4d, 0xe2, 0xf9, 0xaf, 0xf2, 0xce, 0xfd, 0x6d, 0xfa, 0xfe, 0x7c, 0x98, 0xc4, 0x59, 0x2e, 0xe9, 0xba, + 0xb5, 0xb8, 0xf8, 0xa4, 0xf5, 0xb7, 0xab, 0x3d, 0x6a, 0xdf, 0xad, 0xe5, 0xf4, 0x76, 0xe4, 0x9a, 0xf9, 0x12, 0xd2, + 0xac, 0xf5, 0xe1, 0x24, 0x7f, 0x95, 0x61, 0x97, 0x37, 0x7a, 0xd0, 0xb4, 0x64, 0xfa, 0xe2, 0xe7, 0x8a, 0x6d, 0x19, + 0xba, 0x12, 0xbd, 0xf3, 0xd3, 0x17, 0xe3, 0xae, 0x11, 0xb3, 0x35, 0x90, 0x3c, 0x61, 0x5e, 0x44, 0x63, 0xcf, 0x8d, + 0x05, 0x02, 0xbd, 0x4f, 0xfb, 0x16, 0xcc, 0xd2, 0x6f, 0x9c, 0x28, 0xb9, 0x4f, 0xb0, 0x3f, 0xd2, 0x22, 0x18, 0xb8, + 0x73, 0x57, 0xbd, 0xe0, 0x38, 0x0b, 0x7d, 0xd4, 0xb5, 0xdc, 0x17, 0x31, 0x72, 0x9b, 0xe3, 0xf4, 0x6e, 0x29, 0x99, + 0x08, 0xfb, 0xc5, 0x53, 0xce, 0xac, 0xef, 0x7e, 0x99, 0x25, 0xad, 0xd5, 0x02, 0xfd, 0x8a, 0xab, 0xe7, 0x6e, 0xfd, + 0x27, 0x10, 0xbd, 0x9f, 0x76, 0x58, 0x2c, 0xad, 0xd4, 0x9d, 0xaa, 0xd2, 0x37, 0x78, 0x52, 0x06, 0xc8, 0x59, 0x40, + 0x67, 0xda, 0x5a, 0xee, 0x16, 0x46, 0xfd, 0xa5, 0xc7, 0xb9, 0xfe, 0xde, 0xca, 0x18, 0x1c, 0x42, 0xb4, 0xfd, 0x0a, + 0xe7, 0x71, 0x7b, 0x25, 0x5e, 0x0b, 0xaf, 0x28, 0x34, 0x5b, 0x1e, 0xbf, 0x54, 0x30, 0x89, 0x7e, 0x12, 0x91, 0x3b, + 0x3f, 0x5b, 0xb3, 0x30, 0x31, 0x9f, 0xce, 0x2d, 0xbf, 0x47, 0xa7, 0xe6, 0x02, 0x5a, 0xee, 0xf9, 0x81, 0x8b, 0xf9, + 0x3f, 0xcb, 0x2c, 0x4b, 0x6a, 0x85, 0x66, 0xd9, 0x36, 0xc0, 0xd1, 0x0d, 0x4f, 0x71, 0xe3, 0x39, 0x0e, 0x28, 0xb4, + 0x83, 0x52, 0x6f, 0xb5, 0x40, 0x8d, 0x14, 0x61, 0xa1, 0xa0, 0x90, 0x7e, 0x44, 0xf3, 0x28, 0x3b, 0x62, 0xc0, 0x48, + 0xb7, 0xfa, 0x9b, 0x5c, 0x5b, 0x64, 0x45, 0xab, 0xfd, 0xb2, 0x7c, 0xbf, 0x2f, 0x82, 0xe8, 0xbf, 0x5d, 0x80, 0x22, + 0xd6, 0x86, 0xec, 0x4d, 0xc0, 0x34, 0xa2, 0x98, 0xa2, 0xe0, 0xdb, 0x80, 0xa4, 0x50, 0x29, 0x7b, 0x17, 0xb6, 0x08, + 0x33, 0x97, 0x5a, 0x52, 0xc6, 0x98, 0x78, 0xde, 0x00, 0x74, 0xa4, 0xff, 0xda, 0xf8, 0x2e, 0x3b, 0x33, 0x1e, 0x26, + 0xe5, 0x1e, 0x11, 0x91, 0xa0, 0x9e, 0xca, 0x4a, 0xc0, 0x7e, 0xb3, 0x29, 0xbe, 0x15, 0x94, 0xa4, 0x49, 0xed, 0x45, + 0xb0, 0xdb, 0x86, 0x0c, 0x2e, 0xa3, 0xb5, 0x86, 0x82, 0x86, 0xef, 0x0d, 0xe3, 0x01, 0xab, 0x5c, 0xf4, 0x12, 0x9b, + 0xfc, 0x08, 0x9e, 0xa9, 0xe8, 0x2e, 0xdf, 0xa2, 0x8f, 0x77, 0x54, 0xe6, 0x65, 0xa7, 0x75, 0xed, 0xdd, 0x81, 0x41, + 0x18, 0x36, 0x3e, 0x35, 0xd0, 0x91, 0xbe, 0x1e, 0xb0, 0x41, 0xf3, 0x78, 0x86, 0x0d, 0x38, 0xa5, 0x2b, 0x32, 0x5a, + 0xe7, 0x23, 0xcb, 0x17, 0x7b, 0xfc, 0x3e, 0x1a, 0x21, 0x63, 0xe2, 0x08, 0xec, 0xa8, 0x01, 0x1e, 0x12, 0x66, 0x08, + 0x3f, 0xf6, 0x0e, 0xf6, 0xb5, 0x81, 0xff, 0x4a, 0x13, 0x50, 0x40, 0x8e, 0xf6, 0xb8, 0x90, 0x54, 0x3c, 0x86, 0x19, + 0x83, 0xc2, 0x87, 0x64, 0x28, 0x73, 0xfc, 0xef, 0xbb, 0x92, 0x62, 0xcd, 0x70, 0x57, 0x8c, 0x4c, 0x1b, 0xee, 0xbe, + 0x6b, 0xcc, 0x6f, 0xe9, 0xde, 0x51, 0x14, 0x3d, 0x1d, 0x03, 0x0f, 0xa1, 0x14, 0xa1, 0xec, 0xcc, 0x84, 0x2a, 0x00, + 0xfd, 0xa2, 0x19, 0x6d, 0x40, 0xeb, 0xc7, 0xc8, 0x1d, 0xdf, 0x5e, 0xc1, 0xc9, 0x45, 0xa2, 0xc0, 0xba, 0xf8, 0xfa, + 0x97, 0x4a, 0x7a, 0xef, 0xde, 0x25, 0x5b, 0xe5, 0xca, 0x9c, 0xda, 0xe2, 0xa1, 0x0b, 0xbe, 0x4c, 0xd7, 0xc7, 0xde, + 0xcb, 0x13, 0xa4, 0xa6, 0x61, 0xb5, 0x8e, 0x6d, 0xc2, 0x93, 0x16, 0xbb, 0xe4, 0xed, 0xfc, 0xe5, 0x49, 0x36, 0xf1, + 0x8a, 0xa5, 0x40, 0xa7, 0x67, 0x56, 0xc5, 0x36, 0xd2, 0xd3, 0x65, 0xc3, 0x67, 0x06, 0xf8, 0x3c, 0x1b, 0xc8, 0x3d, + 0xcf, 0xf5, 0xe7, 0xfa, 0xed, 0x92, 0x87, 0x84, 0x92, 0xdd, 0xd6, 0x38, 0xbd, 0x6b, 0x6c, 0x33, 0x1f, 0xcd, 0xdc, + 0x3e, 0xb6, 0x3e, 0xf3, 0x91, 0xc9, 0xd2, 0x05, 0x25, 0x61, 0x7b, 0x3c, 0x24, 0x9d, 0x6c, 0xb2, 0xe0, 0xcc, 0xa9, + 0x2f, 0x91, 0xcb, 0xe2, 0xbc, 0xae, 0x34, 0x17, 0x36, 0x2b, 0xe8, 0x32, 0x80, 0x53, 0x9d, 0x3a, 0x09, 0xae, 0x2a, + 0x02, 0xa7, 0xa6, 0x66, 0xaa, 0x28, 0x9e, 0xb2, 0x66, 0xbb, 0x39, 0x51, 0xfd, 0x14, 0x2d, 0x2e, 0x75, 0x2a, 0x4a, + 0xd4, 0x4c, 0xb6, 0xcc, 0x14, 0xc8, 0x64, 0x51, 0xa4, 0x39, 0x89, 0x15, 0x0e, 0xfa, 0x9e, 0x53, 0x24, 0x7b, 0xd1, + 0x6e, 0x3e, 0x5e, 0xd9, 0x5a, 0xb2, 0xc2, 0x68, 0x66, 0xab, 0x79, 0x76, 0x22, 0x15, 0xdb, 0x07, 0xca, 0xa1, 0x70, + 0xdf, 0x26, 0xb0, 0x52, 0x23, 0xe5, 0xa5, 0xa8, 0x23, 0x35, 0x3c, 0xc5, 0x5f, 0x9b, 0x6e, 0x88, 0xd1, 0x6c, 0xd8, + 0xd1, 0x46, 0xb3, 0xd9, 0x0c, 0x8a, 0x4d, 0x8d, 0x43, 0xab, 0xd4, 0x74, 0x1b, 0x91, 0xaf, 0x50, 0x35, 0xb2, 0x6f, + 0xac, 0x2c, 0x88, 0x25, 0x73, 0x88, 0xd7, 0x50, 0x98, 0x24, 0xf7, 0x28, 0xb6, 0xe8, 0xf5, 0xa2, 0xcd, 0xcd, 0x91, + 0x63, 0x43, 0x76, 0xae, 0xe2, 0x5c, 0xa6, 0x2b, 0x91, 0x47, 0x81, 0x50, 0x58, 0x89, 0xa4, 0x04, 0x93, 0x31, 0x4f, + 0xdf, 0xf8, 0x29, 0xe9, 0xb9, 0x47, 0x40, 0x34, 0xfb, 0x82, 0x6a, 0x45, 0x7d, 0x11, 0x23, 0x3e, 0x92, 0x90, 0x63, + 0xf8, 0x8a, 0x61, 0xf8, 0xde, 0xa6, 0xa2, 0xff, 0x6a, 0xe7, 0x53, 0x13, 0x65, 0x72, 0x54, 0xed, 0x10, 0x69, 0x03, + 0xb1, 0x35, 0x40, 0x3c, 0x4d, 0xc7, 0x12, 0x94, 0x46, 0x8f, 0xc1, 0xce, 0xe7, 0xe5, 0x69, 0x27, 0xd4, 0xe2, 0x48, + 0x77, 0x99, 0x9b, 0x00, 0x67, 0xfd, 0x30, 0xbd, 0x4d, 0xcc, 0xee, 0xfe, 0xcc, 0x01, 0xdd, 0x89, 0x71, 0x84, 0x8f, + 0x66, 0x97, 0x55, 0x08, 0x4f, 0xfc, 0x3b, 0xaf, 0xda, 0x94, 0x84, 0x13, 0xe2, 0x8d, 0x63, 0x03, 0x98, 0xce, 0xb4, + 0xa7, 0x6a, 0x39, 0x10, 0x29, 0x7e, 0x0d, 0xbe, 0xc1, 0x95, 0xd0, 0xa0, 0x20, 0x51, 0x3f, 0x8f, 0x5c, 0x13, 0x53, + 0x3d, 0xce, 0x7f, 0x44, 0x28, 0x03, 0x83, 0x04, 0x32, 0x2a, 0xd8, 0x3d, 0x6f, 0x8d, 0x28, 0xd6, 0x7a, 0xd2, 0xb2, + 0xcb, 0x99, 0xeb, 0x36, 0xb5, 0x33, 0x7b, 0xdf, 0x0a, 0x0e, 0x04, 0xfd, 0xe5, 0x56, 0xa6, 0x1f, 0x01, 0x06, 0xc3, + 0xac, 0x30, 0xff, 0x89, 0x0c, 0x9a, 0x2b, 0x64, 0xd4, 0x5d, 0x77, 0xd5, 0x3b, 0xc1, 0xd8, 0x99, 0x8c, 0x23, 0x9f, + 0xfc, 0x3c, 0x70, 0xf7, 0xad, 0x48, 0x35, 0x9e, 0xb9, 0x8d, 0x91, 0x4f, 0x26, 0x81, 0xd9, 0xb6, 0x6e, 0x54, 0x53, + 0x26, 0x38, 0x12, 0x31, 0x95, 0x7e, 0x73, 0x1f, 0xb7, 0xe1, 0x59, 0x7e, 0xf0, 0xdf, 0x6f, 0xd3, 0xc4, 0xb9, 0x17, + 0x76, 0x61, 0xba, 0x89, 0x37, 0x0e, 0xba, 0xdf, 0xb5, 0x8f, 0xe6, 0x1a, 0x0f, 0x53, 0x91, 0xd4, 0x76, 0xa2, 0xce, + 0x47, 0xea, 0xe1, 0x35, 0x9d, 0x9f, 0x49, 0xb3, 0xce, 0xf5, 0x9f, 0xaa, 0x0e, 0x06, 0xfd, 0x15, 0x73, 0xb6, 0x45, + 0xbc, 0xd7, 0x9e, 0x6b, 0x29, 0xbc, 0x83, 0xaf, 0xcc, 0xb9, 0x15, 0xf4, 0x2b, 0x17, 0x95, 0x67, 0xaf, 0x49, 0xd7, + 0x78, 0x52, 0x56, 0x13, 0x36, 0xf5, 0x20, 0x4e, 0xf9, 0xab, 0xe0, 0x18, 0xa0, 0x37, 0x54, 0x8d, 0x91, 0xb2, 0x8b, + 0xf7, 0xd5, 0xc0, 0x99, 0x0a, 0xf1, 0x8f, 0x82, 0xa1, 0x51, 0xda, 0x96, 0xea, 0x18, 0x5b, 0xef, 0x31, 0x8f, 0x47, + 0x95, 0xcb, 0xea, 0x09, 0x0b, 0x4e, 0x9d, 0x9d, 0xdf, 0xfd, 0x88, 0x6b, 0x1e, 0x60, 0x9d, 0xd5, 0xfe, 0x0a, 0x9c, + 0xd7, 0xfe, 0x33, 0xdd, 0x7c, 0x28, 0xba, 0x27, 0x5a, 0x6f, 0xe6, 0xde, 0xf3, 0x6c, 0xd6, 0x9f, 0xef, 0x45, 0x68, + 0x35, 0x5c, 0x67, 0x7c, 0x7a, 0xcb, 0xef, 0x40, 0x67, 0x3b, 0xe8, 0x1a, 0xef, 0x2b, 0xcd, 0x7b, 0x3b, 0x0b, 0x56, + 0xaa, 0xa8, 0x75, 0x8e, 0x1d, 0xba, 0xd6, 0x78, 0x3c, 0xb8, 0xc8, 0xa4, 0xb1, 0x3a, 0x59, 0x79, 0x68, 0x85, 0xca, + 0xd7, 0x8b, 0xb8, 0x63, 0x27, 0xd1, 0xcd, 0xb2, 0x11, 0x25, 0x12, 0xe4, 0x6f, 0x83, 0x42, 0x31, 0x1c, 0x32, 0xe1, + 0x61, 0xdc, 0x9b, 0x08, 0x61, 0x5e, 0x4b, 0xb9, 0x10, 0xab, 0x1d, 0x5e, 0xaf, 0xd0, 0x23, 0xe0, 0x60, 0x49, 0x95, + 0xb4, 0x91, 0x88, 0xba, 0x94, 0x7d, 0x58, 0xdd, 0xfe, 0x50, 0x2f, 0xee, 0xca, 0x5f, 0xd5, 0xb6, 0x66, 0xd1, 0xfc, + 0x8b, 0x12, 0x8e, 0x95, 0x08, 0x9b, 0x29, 0xb6, 0x75, 0xf4, 0x7f, 0x44, 0x85, 0x0e, 0x9d, 0x0b, 0x80, 0xda, 0x0f, + 0x95, 0x05, 0x8a, 0x62, 0x04, 0x68, 0x3f, 0xa9, 0xb2, 0x90, 0x7a, 0xc7, 0x1f, 0xcc, 0xae, 0x5b, 0x86, 0x2c, 0x17, + 0xc1, 0x58, 0x9d, 0x6d, 0x00, 0x08, 0xab, 0x4e, 0x60, 0x02, 0x51, 0x34, 0x8a, 0xb2, 0x29, 0x37, 0xd8, 0x2d, 0x5e, + 0x41, 0xb4, 0xfa, 0xfa, 0x4c, 0xf4, 0x8c, 0xac, 0xa4, 0x2a, 0x59, 0xe6, 0xfb, 0x57, 0x16, 0xcc, 0x95, 0x34, 0x7c, + 0x6b, 0xcf, 0xed, 0x6c, 0xd1, 0x79, 0x7f, 0x57, 0xd3, 0xbf, 0xb0, 0x9b, 0xe1, 0x6f, 0xba, 0x01, 0x33, 0xcc, 0x27, + 0xb7, 0xdf, 0x4f, 0xb1, 0x26, 0x1c, 0xff, 0xc8, 0x2a, 0x86, 0x85, 0x2b, 0x08, 0x16, 0x35, 0x46, 0x9c, 0x92, 0x7f, + 0xec, 0x03, 0x05, 0xda, 0xc3, 0x86, 0x02, 0x83, 0x51, 0xe5, 0xa1, 0x12, 0xe9, 0x53, 0xf1, 0xcb, 0x36, 0x90, 0x41, + 0x27, 0x1c, 0x4a, 0x06, 0x76, 0x6a, 0xd7, 0x2a, 0x31, 0x5b, 0x73, 0xeb, 0x3f, 0x66, 0x05, 0x9b, 0x61, 0xc0, 0x12, + 0xf5, 0x90, 0x46, 0x7a, 0x59, 0xb5, 0x08, 0xef, 0x0d, 0x4d, 0xdd, 0x43, 0x90, 0x5a, 0x16, 0x09, 0x7f, 0x60, 0x1e, + 0xa0, 0x46, 0x30, 0x66, 0x9a, 0x67, 0xa5, 0x1c, 0x42, 0x2e, 0xd3, 0xe3, 0x54, 0x14, 0xa3, 0x96, 0xe5, 0x3a, 0x63, + 0x15, 0x47, 0x5e, 0xb3, 0x38, 0x6f, 0x66, 0x51, 0xae, 0x51, 0x36, 0x2c, 0xb8, 0xfe, 0x0c, 0x89, 0x46, 0xb1, 0x41, + 0x43, 0xec, 0x8e, 0x73, 0x52, 0xa6, 0x39, 0x47, 0x1d, 0x92, 0x5b, 0x72, 0x8f, 0x58, 0xcd, 0x6c, 0x25, 0x4c, 0x8e, + 0x56, 0x6d, 0x46, 0xd8, 0xee, 0x68, 0x1c, 0x33, 0x4d, 0x1c, 0x4f, 0x21, 0xf4, 0x40, 0x9b, 0x3d, 0x2d, 0xd9, 0x71, + 0xf1, 0x7f, 0x90, 0x02, 0xba, 0x79, 0xb4, 0x42, 0x30, 0x17, 0xfb, 0x18, 0xa5, 0x86, 0x9b, 0x63, 0x17, 0xd8, 0xb0, + 0xfd, 0xe7, 0x26, 0xba, 0xa2, 0xe3, 0xb9, 0x5e, 0xa9, 0x91, 0x83, 0x38, 0xb1, 0x3e, 0xdb, 0x83, 0xd0, 0x7a, 0x44, + 0xc2, 0x81, 0xb2, 0xce, 0x7a, 0x65, 0x1e, 0xeb, 0xd2, 0x7f, 0xfd, 0x4b, 0x6d, 0x09, 0x41, 0x60, 0x58, 0x3d, 0xd8, + 0xfe, 0x04, 0x56, 0x5c, 0xc8, 0x12, 0x99, 0xf1, 0xc2, 0xbf, 0x62, 0x87, 0xaf, 0x69, 0x56, 0x56, 0x3a, 0xc7, 0xe5, + 0xcc, 0x42, 0xa7, 0xa1, 0x6a, 0x8e, 0x79, 0x1e, 0x32, 0x16, 0xd3, 0x0b, 0x83, 0x9c, 0x0b, 0x02, 0x1a, 0x9a, 0x73, + 0xee, 0xca, 0x7a, 0x93, 0xe0, 0x36, 0x82, 0x62, 0x29, 0x40, 0x57, 0xe8, 0x32, 0xbd, 0xf3, 0xcd, 0x30, 0x0e, 0x86, + 0xdc, 0xcc, 0x00, 0x84, 0x2d, 0x11, 0x54, 0x32, 0xf0, 0xac, 0xd8, 0xb3, 0x92, 0x73, 0x30, 0xe7, 0x15, 0xea, 0xbd, + 0x46, 0xfa, 0x1b, 0x24, 0x5c, 0xa0, 0x5a, 0x29, 0x70, 0x32, 0xa0, 0xcb, 0x52, 0x2b, 0x34, 0x2f, 0x11, 0x62, 0xac, + 0x01, 0x49, 0x6d, 0xe2, 0x97, 0xf3, 0x02, 0xf7, 0xbc, 0x9f, 0x0d, 0x67, 0x5d, 0x97, 0x00, 0xf2, 0x30, 0x2f, 0xbf, + 0xbd, 0xcc, 0x70, 0x90, 0x13, 0x90, 0xb8, 0x18, 0x98, 0x39, 0xa1, 0x9d, 0x5d, 0xc1, 0x96, 0xba, 0x18, 0x55, 0xb8, + 0xad, 0x61, 0xb2, 0x14, 0x95, 0x6d, 0xb8, 0x3e, 0x86, 0xce, 0x48, 0xfa, 0xce, 0x4f, 0x33, 0x09, 0x33, 0x74, 0xcd, + 0xc9, 0x54, 0xee, 0x04, 0x9b, 0x4f, 0x9a, 0x81, 0xbe, 0xd8, 0xfa, 0x73, 0xe8, 0x7f, 0xda, 0xd8, 0x04, 0xd3, 0xf7, + 0x8c, 0x64, 0xc4, 0x54, 0xa2, 0xcf, 0x1b, 0xcc, 0x3e, 0xed, 0xf7, 0xf9, 0x0e, 0x16, 0xeb, 0xcb, 0xd8, 0xcb, 0x8a, + 0x8d, 0xfa, 0xd8, 0x5a, 0xc6, 0x24, 0x71, 0x2c, 0xb9, 0x3d, 0x28, 0x29, 0xa8, 0xcc, 0x9b, 0xa8, 0x21, 0x23, 0xa6, + 0x35, 0x27, 0x3b, 0xf1, 0xbf, 0x73, 0xc5, 0xcc, 0xc4, 0xc0, 0x8f, 0xb1, 0xc7, 0x3e, 0xbe, 0x7a, 0xe2, 0xad, 0xf6, + 0x23, 0x67, 0xe8, 0x98, 0x3c, 0x40, 0x20, 0x17, 0x98, 0x97, 0x2e, 0x30, 0xe7, 0xd6, 0x8a, 0x35, 0x6b, 0x6a, 0xe5, + 0x3f, 0xbb, 0x2b, 0x7d, 0x60, 0xec, 0x13, 0x41, 0x7f, 0x36, 0xed, 0x66, 0xec, 0x1b, 0xb3, 0x57, 0x03, 0x4e, 0x1d, + 0xcc, 0x6c, 0xbc, 0xa9, 0xf4, 0x1f, 0x6a, 0x73, 0xc5, 0x02, 0x14, 0x39, 0x1b, 0xf9, 0xa4, 0xa9, 0x08, 0xfe, 0xb8, + 0x3a, 0x7b, 0xb1, 0xdd, 0xa2, 0x50, 0x70, 0x65, 0x34, 0xe1, 0x5d, 0x46, 0x3e, 0xd1, 0xd0, 0x06, 0x6f, 0xe4, 0x8d, + 0x6d, 0x5c, 0x46, 0xfb, 0x68, 0x3f, 0x07, 0xb1, 0x0b, 0x82, 0xb6, 0x26, 0x16, 0x04, 0x59, 0x53, 0xe7, 0x0d, 0x23, + 0x12, 0xfc, 0xd6, 0x5a, 0xe9, 0xbc, 0x8e, 0xbd, 0xd2, 0x1d, 0xe7, 0x43, 0x22, 0x46, 0xe0, 0xb6, 0xe8, 0x7a, 0x4b, + 0x42, 0x19, 0x97, 0x8e, 0x4e, 0x26, 0x78, 0xd4, 0x26, 0x4e, 0xaa, 0x6d, 0xaf, 0x47, 0x1d, 0x1e, 0xf5, 0xdd, 0xbc, + 0x18, 0x94, 0xb6, 0x3b, 0xfa, 0x6f, 0xe1, 0xad, 0xcc, 0x91, 0xc7, 0xb5, 0xbe, 0xd3, 0xdc, 0x02, 0xbd, 0x89, 0xe8, + 0x44, 0x51, 0x27, 0x9c, 0xbc, 0x52, 0x8e, 0xff, 0x0b, 0x85, 0x15, 0x0c, 0x81, 0xc9, 0x4c, 0x24, 0xaa, 0x2d, 0x48, + 0x67, 0xa1, 0xbf, 0xf5, 0xf1, 0xb5, 0x42, 0x16, 0xd8, 0x62, 0x06, 0x71, 0xa8, 0x07, 0x8d, 0xe0, 0x25, 0x14, 0x88, + 0xe2, 0xde, 0x19, 0x1a, 0x83, 0x1e, 0x94, 0x3b, 0xa4, 0x81, 0x62, 0xd0, 0xb2, 0x14, 0x1a, 0xda, 0x84, 0x54, 0xbb, + 0xdf, 0x1b, 0xca, 0xfa, 0x25, 0x37, 0xd4, 0x28, 0xa2, 0x51, 0x6f, 0x1d, 0x24, 0x20, 0xe8, 0x15, 0x07, 0x69, 0xa0, + 0xbc, 0x5e, 0x12, 0x23, 0x96, 0xf1, 0x38, 0xc8, 0xd5, 0xc2, 0xe3, 0x95, 0x90, 0x53, 0xb3, 0x42, 0xc8, 0x31, 0x80, + 0x61, 0xec, 0x81, 0x7b, 0x39, 0xec, 0x60, 0x11, 0xf0, 0xbc, 0x5c, 0x51, 0xcf, 0x46, 0xb1, 0xb0, 0xfd, 0xbb, 0xbc, + 0x98, 0x5f, 0xd2, 0xde, 0x26, 0x29, 0x8f, 0x55, 0x9a, 0x4a, 0xf0, 0xdd, 0x9f, 0xde, 0xc5, 0x7c, 0x2c, 0x59, 0xb3, + 0xa5, 0x32, 0x07, 0x13, 0xa2, 0xeb, 0x90, 0x91, 0x3e, 0x55, 0xc5, 0xb1, 0x49, 0x01, 0x35, 0x1c, 0x87, 0x9d, 0x0b, + 0xc2, 0xe3, 0x84, 0x35, 0x9c, 0x4b, 0xcc, 0x61, 0x87, 0x0a, 0x36, 0xc2, 0xe8, 0x86, 0x12, 0x62, 0x49, 0x6d, 0xc4, + 0xb7, 0x03, 0x5c, 0x82, 0xef, 0x17, 0x5a, 0x79, 0x1f, 0x20, 0xfe, 0xd8, 0xa4, 0x33, 0x40, 0x2e, 0xb1, 0xb2, 0x98, + 0xb0, 0xed, 0xdf, 0x2a, 0x6d, 0x2b, 0x0f, 0xd3, 0xcd, 0xbd, 0x39, 0xbb, 0x03, 0x85, 0x33, 0x27, 0x19, 0xf9, 0x31, + 0xe9, 0x51, 0x39, 0x93, 0xff, 0xdc, 0x30, 0x06, 0x64, 0xe6, 0x0e, 0xf6, 0x95, 0xc0, 0x98, 0xbe, 0xd2, 0xd1, 0x84, + 0x7f, 0x89, 0x94, 0x9f, 0x8d, 0x46, 0x4c, 0x5e, 0x61, 0xc8, 0x55, 0xfa, 0x4a, 0xbf, 0xcf, 0x5c, 0xf4, 0x52, 0xde, + 0x38, 0xc6, 0xa8, 0xb8, 0xc9, 0xf8, 0xc5, 0xc8, 0x16, 0x22, 0xf5, 0x66, 0xcc, 0xb6, 0x3f, 0x5b, 0xa2, 0x7b, 0x86, + 0x07, 0x92, 0xa0, 0x71, 0xa3, 0x40, 0x01, 0x76, 0x31, 0xc1, 0x90, 0xdc, 0x01, 0x93, 0xa6, 0x69, 0x9e, 0xa7, 0x50, + 0xd7, 0x6a, 0x38, 0xa9, 0x6c, 0xab, 0xbb, 0xac, 0x4c, 0x65, 0xdb, 0xc1, 0x70, 0x8d, 0x82, 0xc4, 0x51, 0xe3, 0x14, + 0x15, 0xb3, 0xea, 0x69, 0x52, 0x86, 0x05, 0x44, 0x5a, 0x71, 0x8e, 0xdf, 0x5c, 0x9a, 0x4c, 0x67, 0xa7, 0xd8, 0x2b, + 0x3c, 0x4f, 0x85, 0x08, 0x76, 0x67, 0x15, 0x09, 0xbb, 0xb6, 0x65, 0x1d, 0x2d, 0x64, 0xee, 0x5b, 0x17, 0xe8, 0x12, + 0xe2, 0x07, 0x6f, 0xf5, 0xdb, 0xfd, 0x04, 0xec, 0x20, 0x8c, 0xf5, 0x11, 0x5d, 0x7c, 0xd4, 0x0b, 0x4a, 0x2b, 0x3f, + 0x09, 0xce, 0xd9, 0x66, 0xe9, 0xfd, 0x2f, 0x58, 0xdf, 0x94, 0x17, 0x0b, 0x0a, 0x85, 0x15, 0xcb, 0x52, 0x5c, 0xb5, + 0x8c, 0xcf, 0x51, 0x85, 0x55, 0xc8, 0xb1, 0x87, 0x1e, 0x37, 0x10, 0xa9, 0x65, 0x91, 0x34, 0x69, 0xee, 0xac, 0x44, + 0xa6, 0x6b, 0xb0, 0xf3, 0x4a, 0x00, 0x76, 0x6c, 0x52, 0xd5, 0x8b, 0x85, 0xa7, 0x24, 0xc1, 0xd1, 0xad, 0x90, 0xbb, + 0x50, 0x65, 0x0f, 0x14, 0x62, 0x58, 0x07, 0x58, 0x38, 0x2b, 0x58, 0x12, 0xb6, 0x0f, 0xab, 0xf1, 0x63, 0x54, 0x5b, + 0xc0, 0xf8, 0x10, 0x42, 0x7d, 0xb7, 0x83, 0x8e, 0xa2, 0xa3, 0x35, 0x9a, 0xdc, 0xe3, 0x00, 0x19, 0xf4, 0x73, 0x3f, + 0x15, 0x5c, 0xf2, 0x80, 0xbc, 0x18, 0x39, 0x89, 0xab, 0xf1, 0xa6, 0x65, 0xea, 0x5c, 0xf9, 0xee, 0x4b, 0x1b, 0x61, + 0x5d, 0x20, 0x2e, 0xe4, 0x7d, 0xec, 0x90, 0x7d, 0x77, 0x18, 0xad, 0xae, 0x9b, 0x27, 0x8b, 0xfc, 0x59, 0x56, 0x4d, + 0x45, 0xf8, 0xd3, 0xf7, 0x1b, 0x6a, 0x73, 0x16, 0x50, 0xee, 0xbd, 0x5e, 0x70, 0x8a, 0x7a, 0x47, 0x05, 0x22, 0x98, + 0x64, 0xf8, 0xed, 0x23, 0xd2, 0x16, 0x24, 0x62, 0xcd, 0x87, 0x4b, 0xaf, 0x59, 0x7f, 0x0b, 0x82, 0x55, 0x13, 0xe1, + 0xec, 0x57, 0x1a, 0xc4, 0xc1, 0x4b, 0x11, 0x92, 0xae, 0x08, 0x06, 0x3a, 0x2a, 0x88, 0xad, 0xd8, 0xca, 0x5e, 0x56, + 0x6b, 0x08, 0x44, 0x9c, 0x83, 0xcd, 0x67, 0x96, 0xe1, 0x39, 0xf1, 0xea, 0x97, 0x07, 0x29, 0x5c, 0x8c, 0x41, 0xff, + 0xab, 0x65, 0xe1, 0x07, 0x07, 0x07, 0x56, 0x46, 0x56, 0x8e, 0x7a, 0xd7, 0x4b, 0xe5, 0xb6, 0xac, 0xe3, 0xd6, 0xaa, + 0xf7, 0xe4, 0x05, 0x28, 0x8d, 0x36, 0x83, 0x64, 0xb7, 0x8e, 0x99, 0x1a, 0xc3, 0x43, 0x56, 0x8b, 0xfa, 0x98, 0x70, + 0x87, 0xbd, 0x91, 0x86, 0xbd, 0x83, 0x89, 0x68, 0xbc, 0x6f, 0xff, 0xc4, 0x48, 0x43, 0xc2, 0x74, 0xcc, 0x21, 0x77, + 0x50, 0x66, 0x4c, 0x4f, 0x05, 0x6d, 0xc7, 0x11, 0xcf, 0x45, 0x92, 0xce, 0xfd, 0x2b, 0xc3, 0xfb, 0x0b, 0x19, 0x5b, + 0x42, 0x46, 0x77, 0x24, 0xa5, 0x08, 0xd7, 0xd2, 0x60, 0x60, 0x8c, 0x60, 0x3e, 0x25, 0x9a, 0x88, 0x65, 0xb7, 0xb9, + 0x20, 0xb1, 0xcf, 0xd5, 0x92, 0xbd, 0x55, 0x2c, 0xa6, 0x04, 0x2d, 0x8a, 0x5e, 0xbc, 0x5c, 0x99, 0x31, 0xe1, 0xd1, + 0xb5, 0x71, 0x13, 0x23, 0x76, 0x67, 0x56, 0x7b, 0x1b, 0x3c, 0x68, 0x9f, 0x7f, 0xbd, 0x51, 0xbc, 0xb8, 0x5d, 0xbe, + 0x84, 0xe0, 0x07, 0x4f, 0x93, 0xc5, 0x50, 0x06, 0xb9, 0xd8, 0x70, 0xc1, 0x03, 0x59, 0x44, 0x6d, 0xb7, 0x1e, 0x23, + 0x36, 0xcf, 0x27, 0x9f, 0xb6, 0x30, 0x3c, 0x93, 0x93, 0xc1, 0xfe, 0x45, 0x07, 0xbf, 0x01, 0x5a, 0x37, 0x29, 0xf2, + 0xef, 0x4a, 0xd5, 0x41, 0x46, 0xf0, 0xf1, 0xcb, 0xed, 0x2f, 0xca, 0x50, 0xd3, 0x33, 0x9a, 0x86, 0xdd, 0xf2, 0xf7, + 0xc9, 0x29, 0xd8, 0x77, 0x65, 0x00, 0xa8, 0xd3, 0xa5, 0x8c, 0xf8, 0x9c, 0x7c, 0x83, 0x30, 0x00, 0x22, 0xbf, 0xf9, + 0x55, 0x3b, 0x3e, 0x36, 0xc7, 0xe5, 0x0f, 0x6d, 0x7b, 0x96, 0x88, 0xfe, 0xae, 0x0d, 0xb3, 0x1d, 0xfb, 0x80, 0x15, + 0x0f, 0xa3, 0x44, 0xb4, 0xac, 0xf9, 0x90, 0xb9, 0x4f, 0xf1, 0xb0, 0x79, 0xb4, 0x6a, 0x23, 0x8a, 0x6c, 0xb0, 0x5d, + 0xb2, 0xbf, 0xd0, 0xd2, 0xf9, 0x66, 0x87, 0x66, 0x50, 0xb7, 0x47, 0xc8, 0xab, 0x08, 0x20, 0x1e, 0x83, 0xc1, 0x7f, + 0x6d, 0xe6, 0x3d, 0x5b, 0xac, 0x00, 0x3f, 0x3b, 0x76, 0xfe, 0xf2, 0x7c, 0x6a, 0x11, 0x04, 0x7d, 0xd6, 0x3c, 0xaa, + 0x47, 0x44, 0xd2, 0x4f, 0x67, 0x5b, 0xbd, 0x4f, 0x87, 0x51, 0x89, 0x47, 0x6c, 0xda, 0xfe, 0x1d, 0x8b, 0xba, 0xd8, + 0xde, 0xb3, 0xe9, 0xfc, 0xb9, 0x29, 0x74, 0x06, 0x91, 0xda, 0xc6, 0x99, 0x8c, 0x64, 0x47, 0xa6, 0x01, 0x0d, 0xd1, + 0x5e, 0x28, 0xaf, 0x1f, 0x50, 0x32, 0x0a, 0xe4, 0x18, 0x41, 0xae, 0x8d, 0x8c, 0x2d, 0x27, 0x4b, 0x10, 0x86, 0x25, + 0xce, 0xef, 0xa9, 0x43, 0xd0, 0x4b, 0x85, 0xe4, 0xec, 0x22, 0x5c, 0x6f, 0x87, 0x34, 0x1a, 0x00, 0x4a, 0x8d, 0xb7, + 0x09, 0x1e, 0xb6, 0x20, 0x46, 0x2a, 0xb2, 0x22, 0xf1, 0xa7, 0xc4, 0x45, 0xe5, 0x18, 0x8f, 0x00, 0x24, 0xc6, 0xf1, + 0x50, 0xea, 0x3c, 0xa8, 0x43, 0xf2, 0x8a, 0x89, 0x39, 0xd2, 0xb3, 0x0a, 0x3d, 0x98, 0x69, 0x68, 0x73, 0x35, 0x9a, + 0x2a, 0x68, 0x0e, 0x4a, 0xff, 0x81, 0xea, 0x2a, 0x1f, 0x92, 0x47, 0x06, 0x41, 0x18, 0xae, 0xd6, 0x5b, 0xea, 0xf7, + 0x15, 0x42, 0x8b, 0x03, 0x33, 0xc9, 0x20, 0xce, 0x8d, 0x0f, 0x5b, 0x5d, 0xe3, 0x8b, 0x7a, 0x02, 0x34, 0x27, 0xae, + 0x7c, 0xf8, 0x78, 0x32, 0x50, 0x38, 0x41, 0xc9, 0xe8, 0x4f, 0x50, 0x53, 0x2d, 0xe9, 0x76, 0x1e, 0x37, 0xdd, 0x94, + 0xaf, 0x92, 0x5b, 0x6a, 0x66, 0x29, 0x7a, 0x2d, 0xe5, 0x81, 0x66, 0xbb, 0x95, 0xf5, 0xd7, 0x7f, 0x6a, 0xf8, 0x04, + 0xd0, 0x45, 0xc2, 0xca, 0xc4, 0xb7, 0xa8, 0xc1, 0x2f, 0x3e, 0x1c, 0x9c, 0x8c, 0x61, 0x7b, 0xa8, 0xc5, 0xdc, 0xe1, + 0x38, 0xc7, 0xfe, 0x3d, 0x90, 0x1b, 0xdc, 0x4a, 0xa0, 0xe4, 0x6b, 0x59, 0x84, 0x99, 0xcc, 0x62, 0xa0, 0x72, 0x35, + 0xe8, 0x3a, 0xb0, 0x90, 0x35, 0xb5, 0xe6, 0x87, 0xfe, 0xa7, 0x0a, 0x32, 0xf7, 0x6c, 0x95, 0x02, 0x24, 0xc8, 0xb7, + 0xd2, 0x36, 0xed, 0x7d, 0x8b, 0xdc, 0x41, 0xf7, 0x08, 0x16, 0xb5, 0xdd, 0x61, 0x02, 0x68, 0xa1, 0x83, 0x10, 0x52, + 0xe7, 0x53, 0x28, 0x7a, 0xb9, 0x49, 0x26, 0x74, 0xae, 0x05, 0x9e, 0x2f, 0x1d, 0x1c, 0xfd, 0xfb, 0xe3, 0x81, 0x72, + 0x45, 0x02, 0x97, 0x13, 0x7c, 0x0a, 0x9b, 0xda, 0x9c, 0x01, 0x65, 0xa4, 0x7d, 0x75, 0xb8, 0x62, 0x1f, 0x05, 0xac, + 0x0b, 0x9d, 0x59, 0x08, 0x15, 0x99, 0xec, 0x48, 0xd8, 0x17, 0x45, 0x33, 0xc4, 0x79, 0xc1, 0x55, 0x6c, 0x03, 0x9f, + 0xdb, 0xa4, 0x83, 0x98, 0x8b, 0xb6, 0x05, 0x1f, 0x0b, 0xaa, 0xcc, 0x09, 0x8b, 0x6e, 0x80, 0xd1, 0x5e, 0x7b, 0xa9, + 0xf5, 0x83, 0x76, 0x42, 0x67, 0xc5, 0xbd, 0xeb, 0x2a, 0xc2, 0xc0, 0x27, 0xd8, 0xa9, 0xfd, 0x2b, 0x8a, 0xe3, 0x6f, + 0x9b, 0x71, 0xb4, 0xe0, 0x53, 0x04, 0x06, 0x90, 0x90, 0x6e, 0x98, 0x6d, 0xcd, 0x08, 0x3a, 0x7e, 0x08, 0x35, 0x4a, + 0x01, 0x29, 0x8d, 0x30, 0x38, 0xca, 0xe4, 0x37, 0x41, 0x86, 0xe4, 0xbc, 0x9c, 0xa3, 0x87, 0x21, 0x46, 0x0e, 0x48, + 0x65, 0xae, 0x6c, 0xc7, 0x5e, 0x55, 0x4f, 0x85, 0x3c, 0x71, 0x0e, 0x62, 0x31, 0xf4, 0xc8, 0x88, 0x3f, 0xc8, 0x54, + 0x67, 0xa0, 0x89, 0x01, 0x33, 0x82, 0x03, 0xb1, 0x29, 0x68, 0x84, 0xc0, 0x09, 0x59, 0xb6, 0x7c, 0x29, 0x56, 0x01, + 0x89, 0x50, 0xc4, 0xa2, 0x25, 0x92, 0x1f, 0x31, 0x32, 0x30, 0x43, 0x12, 0xe8, 0x31, 0x7b, 0x4d, 0x07, 0xc6, 0x05, + 0x18, 0x53, 0xa9, 0x1e, 0x40, 0x3e, 0x05, 0xa3, 0xb0, 0x88, 0x50, 0xcb, 0x5d, 0x79, 0x91, 0x34, 0x34, 0x58, 0xc3, + 0xb1, 0x68, 0x2e, 0xe6, 0x28, 0xbd, 0x67, 0xca, 0x10, 0x24, 0x57, 0xad, 0x8c, 0xb0, 0xd3, 0x9f, 0xc7, 0x21, 0xe4, + 0xab, 0x0e, 0x42, 0x9b, 0x1b, 0x67, 0x11, 0x20, 0xf4, 0x48, 0x6c, 0x63, 0x8c, 0x80, 0xa4, 0xa1, 0x03, 0xa9, 0x0b, + 0x10, 0x21, 0x21, 0x44, 0x92, 0x80, 0xe6, 0x7c, 0x8b, 0x44, 0x7c, 0x06, 0x61, 0xae, 0x0b, 0xd2, 0x64, 0x89, 0x4a, + 0xbf, 0x6f, 0x96, 0x61, 0xb9, 0xc3, 0xc9, 0x2c, 0xc8, 0x55, 0x95, 0xb3, 0x00, 0x89, 0x84, 0xd9, 0xea, 0x84, 0xa1, + 0xf3, 0x46, 0xfb, 0x49, 0xc0, 0xd9, 0xc2, 0x84, 0x0c, 0x04, 0xa3, 0x58, 0x14, 0x85, 0x4a, 0xf5, 0x49, 0x81, 0xc3, + 0x08, 0x0d, 0xef, 0x2e, 0x0a, 0x37, 0xf3, 0x64, 0x2d, 0xab, 0xe2, 0x11, 0x93, 0xfb, 0xa1, 0x96, 0x38, 0xa7, 0x40, + 0x72, 0x82, 0xa2, 0xd1, 0xfd, 0xd7, 0xcf, 0x1d, 0x95, 0x44, 0x78, 0xd1, 0xa2, 0xf4, 0x6b, 0x8b, 0xdb, 0x5c, 0xcd, + 0x09, 0x34, 0x69, 0x66, 0xc8, 0x37, 0x9d, 0x8a, 0xf9, 0x95, 0xc1, 0xe5, 0x2e, 0xd8, 0x10, 0x40, 0x9b, 0x41, 0xef, + 0x4b, 0xeb, 0x53, 0xfa, 0x01, 0x46, 0xdf, 0xb8, 0xf3, 0xc2, 0x68, 0x27, 0xeb, 0xbd, 0xa1, 0x0b, 0xeb, 0x67, 0x57, + 0xb5, 0xd3, 0x71, 0x44, 0x02, 0x67, 0x2d, 0x74, 0xc8, 0xe6, 0x95, 0xb0, 0x9c, 0xd9, 0xe2, 0xec, 0xd1, 0xaa, 0xb5, + 0x1c, 0x91, 0x8e, 0x34, 0x1c, 0x90, 0xe3, 0xd9, 0x07, 0xa8, 0xf3, 0x08, 0x18, 0x49, 0x39, 0xf3, 0x5e, 0x71, 0x9c, + 0x37, 0x44, 0x1a, 0xea, 0x39, 0x2f, 0x00, 0xec, 0xca, 0x22, 0x29, 0x79, 0x1d, 0x72, 0x2d, 0xfd, 0xe9, 0x98, 0x47, + 0x8c, 0xb1, 0x73, 0x2a, 0x23, 0x8c, 0x4e, 0xae, 0x6b, 0x8e, 0x8c, 0xb2, 0x0b, 0x26, 0x54, 0xf3, 0xae, 0x34, 0xe5, + 0x81, 0x2c, 0xb2, 0xe9, 0x4a, 0x0b, 0x4e, 0x47, 0x62, 0xae, 0x6e, 0x56, 0x51, 0x3d, 0x4c, 0x10, 0xb1, 0xde, 0xbe, + 0xc1, 0xe4, 0x11, 0xcf, 0x27, 0x82, 0x54, 0xa4, 0xcd, 0xe9, 0x59, 0xc9, 0x07, 0xcc, 0x16, 0x68, 0xb4, 0xf2, 0x5e, + 0x00, 0x94, 0xdf, 0x94, 0xa8, 0x48, 0xb9, 0x6c, 0xd1, 0x41, 0x34, 0xe2, 0xd7, 0x41, 0x36, 0xeb, 0x3d, 0x39, 0x9e, + 0x6f, 0x8d, 0xac, 0x86, 0xc8, 0xd0, 0xea, 0xe8, 0x37, 0x74, 0xe8, 0x2b, 0xc2, 0xa4, 0xd3, 0xf3, 0xd8, 0xd6, 0x02, + 0x2d, 0x86, 0x8a, 0xa7, 0x62, 0x8c, 0x93, 0xea, 0x1a, 0xb1, 0x4c, 0xa9, 0x6f, 0x31, 0xd1, 0x15, 0xf4, 0x93, 0x2d, + 0x05, 0x9b, 0x6f, 0x59, 0xc9, 0x8b, 0x8c, 0x08, 0x7b, 0x8d, 0xf0, 0x62, 0x18, 0x03, 0xf4, 0xaa, 0xa5, 0x74, 0x1e, + 0xe8, 0xad, 0xe8, 0x8a, 0x79, 0xec, 0xc3, 0xeb, 0x2e, 0x49, 0x5e, 0xe0, 0xd6, 0x3c, 0x66, 0x35, 0x96, 0xdf, 0xbc, + 0xfe, 0xc6, 0x54, 0x25, 0xd6, 0xca, 0xca, 0x4f, 0xba, 0x6c, 0xdf, 0x0f, 0x49, 0x83, 0xbc, 0x4d, 0x6b, 0xfb, 0xbd, + 0xc9, 0x37, 0x10, 0x1b, 0x8c, 0xa2, 0x99, 0x2e, 0x16, 0x87, 0x05, 0xd2, 0xaf, 0x97, 0xa0, 0x2b, 0xd3, 0x0c, 0xd2, + 0xbe, 0xaf, 0x2f, 0x7f, 0x03, 0x98, 0x11, 0x63, 0x1f, 0x72, 0x22, 0x5a, 0x89, 0x66, 0xcb, 0xfc, 0xec, 0xec, 0x2d, + 0x08, 0x01, 0x33, 0xd9, 0xcf, 0x0f, 0x33, 0x43, 0xc2, 0x5e, 0x33, 0x13, 0xa1, 0xc0, 0x9a, 0x66, 0x9e, 0x5d, 0xcd, + 0xed, 0xd3, 0x52, 0xb4, 0x78, 0xac, 0x75, 0x95, 0xfa, 0x5e, 0xc6, 0x93, 0x8b, 0xd8, 0x9e, 0x67, 0x68, 0x3d, 0x63, + 0xa4, 0x41, 0x87, 0x17, 0x22, 0x62, 0x8b, 0x67, 0xff, 0x81, 0x99, 0x19, 0x85, 0x80, 0x6a, 0x0a, 0x7d, 0x7b, 0x8b, + 0x78, 0x2c, 0x4d, 0x9e, 0x91, 0xd9, 0xf7, 0x24, 0xdf, 0xac, 0x93, 0xf7, 0x5e, 0xaf, 0x5c, 0xad, 0x70, 0x6a, 0x85, + 0x1b, 0xe8, 0x51, 0xbf, 0xd5, 0x90, 0x28, 0x42, 0x0e, 0xe3, 0xd2, 0x2f, 0xea, 0x08, 0xe7, 0x02, 0xaf, 0xa7, 0x6e, + 0xeb, 0x7a, 0x48, 0x35, 0x05, 0x71, 0xee, 0xb6, 0x70, 0x46, 0x6f, 0xcd, 0x91, 0xa1, 0x3b, 0xce, 0xf2, 0x42, 0x5d, + 0xdd, 0x1d, 0x98, 0x76, 0x68, 0x68, 0x78, 0x5c, 0xd7, 0xa3, 0xc9, 0x23, 0x11, 0x4d, 0xdc, 0x5a, 0xac, 0xbf, 0x23, + 0xca, 0x3c, 0x0d, 0x60, 0xa7, 0x31, 0xea, 0xbf, 0x4b, 0xf6, 0x68, 0x74, 0xc7, 0x24, 0x91, 0x0d, 0x99, 0x6d, 0x40, + 0x9b, 0x83, 0x23, 0x3d, 0xf5, 0x15, 0x95, 0xdf, 0x4b, 0x14, 0x1c, 0x2f, 0xc5, 0x2d, 0x97, 0xf8, 0xab, 0x78, 0xe8, + 0xe9, 0x24, 0xa6, 0xc1, 0x0d, 0x59, 0x5c, 0x19, 0xe0, 0x32, 0x69, 0x0b, 0x0b, 0x68, 0xd8, 0xc0, 0x02, 0x0a, 0xa3, + 0xcf, 0x61, 0x92, 0x88, 0x7b, 0x38, 0x64, 0xbb, 0xc9, 0x7b, 0x71, 0x4c, 0x14, 0xcf, 0xd5, 0xe4, 0xe8, 0x82, 0x17, + 0xd3, 0x41, 0xd4, 0xec, 0x34, 0xd2, 0xcf, 0x30, 0xbd, 0x97, 0xad, 0xeb, 0xc8, 0x00, 0x61, 0x06, 0x15, 0xea, 0x17, + 0xd2, 0x3e, 0x7b, 0x39, 0x64, 0x40, 0xd1, 0xa0, 0xce, 0x86, 0x1d, 0x62, 0x51, 0xc8, 0x6b, 0x17, 0x4f, 0xb8, 0x96, + 0x78, 0x8f, 0x1e, 0x65, 0x58, 0x5c, 0xe6, 0x63, 0xb4, 0xf3, 0x56, 0x96, 0xa6, 0x0b, 0xcb, 0xf9, 0x5d, 0x8c, 0x16, + 0xe8, 0x70, 0xf5, 0xb8, 0x48, 0xf7, 0x53, 0x7b, 0x5e, 0xf8, 0x9f, 0x43, 0x17, 0x5d, 0xfb, 0x4c, 0x26, 0x75, 0x25, + 0x8f, 0x11, 0xf5, 0x55, 0x2f, 0xac, 0xe2, 0xde, 0x6b, 0xcd, 0xf4, 0x51, 0x8e, 0x32, 0x0f, 0x55, 0x66, 0x0d, 0xc6, + 0xd3, 0x92, 0x0c, 0x1f, 0x1d, 0x01, 0x0e, 0x41, 0x13, 0x82, 0x99, 0xfb, 0x92, 0x18, 0xa3, 0x12, 0x30, 0xee, 0x2c, + 0xb0, 0xbc, 0x9e, 0xdd, 0xd3, 0xd0, 0x16, 0x5a, 0x3e, 0xe5, 0xfc, 0x83, 0x2d, 0x96, 0xf9, 0xa9, 0xb0, 0x59, 0xe2, + 0xe2, 0x8e, 0x85, 0x3c, 0xea, 0x45, 0x55, 0xda, 0x5a, 0xf9, 0x8a, 0x54, 0x76, 0x43, 0x16, 0x5e, 0xd6, 0x2d, 0x2f, + 0x45, 0xe7, 0x55, 0x8c, 0x72, 0x92, 0x63, 0x0c, 0xc5, 0x10, 0xe0, 0xcd, 0x1c, 0x74, 0xf7, 0x02, 0x67, 0x72, 0x03, + 0x99, 0xe9, 0xeb, 0xd8, 0x52, 0x41, 0x1e, 0xec, 0xea, 0x99, 0x85, 0x07, 0x90, 0xc8, 0xf2, 0xf1, 0x9c, 0x8c, 0x2d, + 0xcb, 0x93, 0xef, 0xe5, 0x93, 0x60, 0x06, 0xaf, 0x02, 0x64, 0xd9, 0x79, 0xcd, 0xc1, 0x9f, 0x75, 0x87, 0x73, 0x4b, + 0x6b, 0x83, 0x6a, 0x1f, 0x7a, 0xce, 0x96, 0x0c, 0xbe, 0x12, 0x60, 0x34, 0x13, 0xa8, 0x2c, 0x41, 0x30, 0x4b, 0x8b, + 0xf9, 0x82, 0x60, 0x8e, 0xa3, 0x50, 0xb0, 0x3a, 0xe5, 0xe7, 0x61, 0x53, 0x14, 0x45, 0x3c, 0xfc, 0x3c, 0x0e, 0x95, + 0x67, 0x84, 0x55, 0x7c, 0xad, 0x88, 0xf2, 0xa1, 0xc6, 0x93, 0x81, 0x14, 0x40, 0xff, 0xa6, 0x2b, 0xa2, 0xfd, 0x15, + 0x69, 0x14, 0x14, 0xf6, 0x99, 0xbb, 0xd0, 0xce, 0x1a, 0x71, 0x91, 0x7e, 0x93, 0x61, 0x5e, 0x89, 0x67, 0x7e, 0x65, + 0x5d, 0xd6, 0x3a, 0xdf, 0x83, 0x6a, 0x3f, 0x52, 0xda, 0x59, 0xce, 0x2c, 0x39, 0x40, 0xbb, 0xa6, 0x69, 0x33, 0x9f, + 0x90, 0xb3, 0xb8, 0xda, 0x61, 0x0a, 0x52, 0x81, 0x57, 0x4d, 0x23, 0x95, 0xe2, 0xbc, 0x13, 0x05, 0x1c, 0x2e, 0xa7, + 0xf8, 0xbf, 0x39, 0x51, 0xbb, 0xf9, 0x05, 0x79, 0x6c, 0xef, 0xea, 0x97, 0x83, 0xac, 0x2d, 0x1c, 0x1d, 0x5c, 0xe7, + 0xb8, 0x89, 0x1a, 0xa2, 0x2a, 0x78, 0x6b, 0xc8, 0x97, 0xe6, 0x21, 0x05, 0x96, 0x23, 0x2d, 0x5a, 0x7d, 0x1e, 0xf7, + 0x89, 0x68, 0x9f, 0xba, 0x70, 0x3a, 0x2e, 0x33, 0x36, 0x87, 0xba, 0xc8, 0x8f, 0x49, 0xdb, 0x03, 0x06, 0x96, 0x7a, + 0xa2, 0x8d, 0x0f, 0x5d, 0xc4, 0x6d, 0x77, 0x06, 0xd2, 0xf5, 0x72, 0x1a, 0x4a, 0x66, 0x31, 0x70, 0xe1, 0x68, 0xcc, + 0xe3, 0x06, 0x9d, 0x76, 0xc5, 0x46, 0x64, 0x77, 0x30, 0x5c, 0x89, 0x51, 0xd5, 0x61, 0xec, 0x2e, 0x6a, 0x4e, 0xb0, + 0x52, 0x3d, 0xf6, 0x59, 0x74, 0x40, 0x82, 0x27, 0x14, 0x1c, 0x79, 0xe0, 0x11, 0x3e, 0xab, 0x83, 0x0e, 0x8f, 0x3a, + 0x03, 0xab, 0xea, 0x06, 0xdb, 0xea, 0x30, 0x06, 0xca, 0x11, 0x84, 0x22, 0xf2, 0xdd, 0x82, 0x3a, 0x85, 0xc7, 0xfc, + 0x86, 0x30, 0xa5, 0xf4, 0x7c, 0xce, 0xf6, 0xe2, 0xdb, 0x01, 0xfb, 0xdd, 0x27, 0x5e, 0xd2, 0x35, 0x8c, 0xc3, 0x0f, + 0xff, 0xaa, 0xc5, 0xf2, 0xeb, 0x01, 0xe6, 0xf7, 0x41, 0xaa, 0x4b, 0x58, 0xcb, 0x19, 0xc0, 0x1f, 0x6d, 0x19, 0x77, + 0x0d, 0x86, 0xf5, 0x11, 0x2a, 0x22, 0x3c, 0xe2, 0xa0, 0x7f, 0xaa, 0x05, 0x80, 0xe2, 0x38, 0xad, 0x80, 0xc8, 0x42, + 0x34, 0x3f, 0x2f, 0x67, 0x5f, 0x96, 0x65, 0x68, 0x4b, 0x4b, 0x56, 0x8f, 0x13, 0x69, 0xd8, 0x4c, 0x82, 0x4a, 0x88, + 0x5e, 0x11, 0x31, 0x22, 0x66, 0x86, 0xd6, 0x4b, 0xfb, 0x3d, 0x75, 0x57, 0x10, 0x46, 0xad, 0xdb, 0x70, 0xaf, 0xeb, + 0x51, 0x6f, 0xa4, 0xd9, 0xaf, 0xb5, 0x32, 0x80, 0x7d, 0x4b, 0xbe, 0xc0, 0x91, 0x84, 0x2d, 0xed, 0xf8, 0xef, 0x03, + 0xb1, 0xe8, 0x1f, 0x42, 0xd8, 0xc4, 0x26, 0xc8, 0x19, 0xbc, 0xd4, 0x3a, 0x7b, 0x1b, 0x24, 0xc2, 0x24, 0xd6, 0x6a, + 0x3d, 0x85, 0x24, 0x9a, 0x00, 0x52, 0xa1, 0x7d, 0xc6, 0xf4, 0x8a, 0x54, 0x9c, 0x3f, 0xdf, 0xb5, 0x6c, 0xae, 0x9a, + 0xf2, 0x89, 0x95, 0x23, 0xce, 0xd6, 0x4f, 0x96, 0x24, 0x9b, 0xf0, 0x5d, 0x22, 0xc1, 0x37, 0x16, 0xbb, 0xca, 0xab, + 0x7c, 0x0d, 0x9a, 0x14, 0x02, 0x1d, 0x5c, 0xee, 0x1c, 0x32, 0xd4, 0x62, 0x19, 0xd5, 0xd1, 0x16, 0x8b, 0x4c, 0xef, + 0x77, 0xca, 0xea, 0xb3, 0x08, 0x0d, 0x27, 0x16, 0xc3, 0x28, 0x95, 0x5e, 0x6c, 0xd1, 0xca, 0x9f, 0xf4, 0x7f, 0xc8, + 0x02, 0xa5, 0xea, 0x78, 0x89, 0x5b, 0x35, 0x74, 0x87, 0xae, 0xa8, 0x37, 0xa2, 0xb5, 0x63, 0xff, 0xf2, 0xc6, 0xa4, + 0x8e, 0x35, 0x6d, 0x10, 0xbc, 0x0e, 0xfa, 0x99, 0x29, 0x38, 0xd9, 0x78, 0x15, 0xe9, 0x14, 0x06, 0x04, 0x0a, 0x61, + 0x08, 0xf6, 0x19, 0xc9, 0xa6, 0xa5, 0x74, 0x67, 0x17, 0x27, 0xea, 0xd8, 0x38, 0x33, 0xca, 0xda, 0x45, 0xbc, 0xb4, + 0xf1, 0xd6, 0x13, 0x7a, 0xf1, 0xbd, 0x78, 0xb6, 0xe2, 0xa4, 0xb6, 0x8c, 0x88, 0x17, 0x1c, 0x0f, 0x97, 0x31, 0x87, + 0x6a, 0xe3, 0xd6, 0x82, 0x1e, 0x13, 0x5a, 0x0d, 0x9b, 0x9d, 0xb5, 0x9c, 0xf2, 0xb5, 0x18, 0x17, 0xe5, 0x8b, 0x37, + 0x0b, 0x28, 0x03, 0x42, 0x47, 0x8b, 0x48, 0x02, 0x9f, 0x15, 0x76, 0x63, 0x8e, 0x27, 0xc9, 0x92, 0xf9, 0xb5, 0x92, + 0x47, 0x80, 0x99, 0x18, 0x2e, 0xde, 0x86, 0xac, 0x9e, 0xa0, 0x4b, 0x76, 0xb0, 0x52, 0x37, 0x08, 0xb2, 0x04, 0x3b, + 0xc0, 0x5f, 0x78, 0x3f, 0xc6, 0xde, 0x39, 0xbf, 0xd9, 0x3a, 0xfc, 0x3f, 0xc1, 0x83, 0x79, 0x58, 0xdb, 0xee, 0x17, + 0x1b, 0xf5, 0xe5, 0xff, 0x4f, 0x75, 0x0d, 0xad, 0x03, 0x1f, 0x3e, 0x80, 0xf0, 0x78, 0x79, 0xa8, 0x45, 0xab, 0xad, + 0xbd, 0xc3, 0x90, 0x4c, 0x9c, 0x28, 0x2b, 0x76, 0x54, 0xef, 0x50, 0xb4, 0x9b, 0xf9, 0xb3, 0x23, 0x03, 0xd4, 0x3f, + 0x98, 0x78, 0x1f, 0x34, 0xd2, 0xdd, 0x2f, 0x20, 0x13, 0xeb, 0x51, 0x87, 0x5c, 0xa5, 0xf4, 0xf3, 0x73, 0xf7, 0xd6, + 0x7d, 0x94, 0xae, 0xd2, 0xc1, 0xfd, 0x45, 0x57, 0xed, 0xc1, 0x06, 0x17, 0x3b, 0xc5, 0xad, 0x5a, 0xfb, 0xa4, 0x74, + 0x95, 0x25, 0x3e, 0x04, 0x20, 0xc0, 0x56, 0x99, 0xc9, 0xca, 0x53, 0xbe, 0x85, 0x84, 0x77, 0xad, 0x4f, 0x67, 0x7f, + 0xbd, 0x0e, 0x6f, 0x14, 0x6b, 0xbb, 0x8b, 0x47, 0x6b, 0x07, 0x04, 0xe5, 0xdc, 0x6b, 0x28, 0x27, 0x10, 0xe2, 0x25, + 0x62, 0xae, 0x00, 0x97, 0xc3, 0xc8, 0x78, 0x8a, 0x1c, 0x39, 0x44, 0xb7, 0x11, 0xc1, 0xba, 0x4a, 0x5b, 0x15, 0xc7, + 0x5e, 0xcb, 0x23, 0xb3, 0x85, 0x71, 0x13, 0x11, 0x87, 0x45, 0x05, 0x46, 0x9e, 0x86, 0x1d, 0xce, 0x76, 0x86, 0x5e, + 0xcd, 0x42, 0x16, 0xa4, 0x09, 0xdb, 0xa5, 0x7e, 0x1f, 0x4e, 0x4e, 0x58, 0x7d, 0xd5, 0x42, 0xec, 0x05, 0x70, 0x9a, + 0xbc, 0x35, 0xe4, 0x57, 0x67, 0x7a, 0x46, 0xb8, 0x2c, 0x92, 0x7b, 0x2c, 0x04, 0xa1, 0xb2, 0xb5, 0x5d, 0x26, 0xcb, + 0xd2, 0x31, 0xc4, 0xfb, 0x8c, 0x21, 0xcc, 0xf0, 0x82, 0x40, 0xa6, 0x09, 0x4a, 0x19, 0x7e, 0x0b, 0xf7, 0x5c, 0x60, + 0x6c, 0x90, 0x9b, 0xe9, 0x30, 0x12, 0xae, 0xe8, 0x76, 0x80, 0xc8, 0xd2, 0x7c, 0xa2, 0x58, 0x4d, 0x55, 0x87, 0x7d, + 0x67, 0x12, 0xa2, 0xf6, 0x88, 0xf5, 0x78, 0x4a, 0xb7, 0xdb, 0x49, 0xbe, 0xca, 0x5c, 0x8a, 0x21, 0xa2, 0x4a, 0x47, + 0xee, 0x92, 0x6b, 0xe2, 0x94, 0x58, 0x5a, 0x65, 0x1c, 0x24, 0xb4, 0x63, 0xa1, 0x6d, 0x3c, 0xa5, 0x07, 0x91, 0xb6, + 0x8b, 0x5d, 0x52, 0xa5, 0x93, 0xc7, 0xfc, 0x88, 0x18, 0x32, 0xd3, 0x2f, 0xb0, 0xb6, 0xbf, 0xdc, 0x7c, 0x0a, 0x47, + 0x45, 0x62, 0xe7, 0x8e, 0xc0, 0x1f, 0x03, 0x6c, 0x5e, 0x4a, 0x4b, 0x61, 0x54, 0xa1, 0x73, 0xd5, 0x56, 0x2f, 0x0c, + 0x65, 0x43, 0x88, 0x40, 0x32, 0xcb, 0x12, 0x3e, 0xca, 0x1a, 0x06, 0x39, 0xf5, 0xbd, 0x06, 0x64, 0xdb, 0x83, 0x60, + 0xf9, 0x48, 0x95, 0xa5, 0xbe, 0xbf, 0x7c, 0x36, 0x09, 0x1f, 0xeb, 0x10, 0x66, 0x19, 0x70, 0xcd, 0x7a, 0xef, 0x86, + 0xc6, 0xfd, 0x61, 0x06, 0xf5, 0x2f, 0x5c, 0xe9, 0x1b, 0x7c, 0x8d, 0x3c, 0x16, 0x2e, 0xf5, 0xc8, 0x7b, 0x4b, 0x9e, + 0x6d, 0x53, 0xf2, 0x99, 0x16, 0x2b, 0xde, 0xc0, 0x67, 0x11, 0xef, 0x5a, 0xf1, 0x7d, 0x59, 0xdd, 0xd9, 0x76, 0xe6, + 0x04, 0xd3, 0x0c, 0xf6, 0x60, 0x86, 0xee, 0xfa, 0xa0, 0x95, 0x4a, 0x53, 0x47, 0xfa, 0xf6, 0xc1, 0xc7, 0xad, 0xf7, + 0x7f, 0x21, 0x4d, 0x74, 0x03, 0x84, 0xa2, 0xd2, 0xd7, 0x21, 0xca, 0x0e, 0x69, 0x62, 0xda, 0xa1, 0x4a, 0x14, 0x1d, + 0x3a, 0x65, 0x96, 0xa5, 0x00, 0xc3, 0x37, 0x96, 0x1f, 0x29, 0x5c, 0x2b, 0xc9, 0x0d, 0x84, 0x5a, 0x83, 0xf8, 0x6c, + 0x32, 0xbd, 0x2f, 0xd3, 0x82, 0x02, 0x16, 0x4c, 0xbe, 0x8e, 0x61, 0x17, 0xe9, 0xef, 0xe6, 0x0d, 0x09, 0xce, 0x09, + 0x87, 0x23, 0x1b, 0x08, 0xa0, 0x4c, 0xdb, 0x05, 0x17, 0xf7, 0x1b, 0xca, 0x9f, 0x5b, 0x69, 0xcf, 0x90, 0x5a, 0x70, + 0x18, 0xe8, 0x25, 0xfa, 0xbf, 0xee, 0x0c, 0x1f, 0xca, 0xe3, 0x85, 0x83, 0x39, 0x11, 0x6e, 0x71, 0xf6, 0x95, 0x65, + 0x56, 0xb9, 0xe2, 0xfe, 0xc0, 0xc8, 0x44, 0x6b, 0xd7, 0xd7, 0x07, 0xab, 0x15, 0xb5, 0x0a, 0x35, 0xf4, 0x95, 0xfb, + 0x9f, 0xe9, 0x5e, 0xee, 0x99, 0x31, 0x0f, 0xc5, 0xdc, 0x61, 0x5e, 0x34, 0x34, 0x3e, 0x43, 0x34, 0x44, 0xa9, 0xb1, + 0x1a, 0x70, 0x32, 0x26, 0xf5, 0xf1, 0xa0, 0xc3, 0x52, 0x3a, 0x27, 0x46, 0x95, 0x5a, 0x64, 0x90, 0x60, 0x72, 0x3c, + 0x97, 0x36, 0x87, 0x02, 0x11, 0x34, 0xf3, 0x1a, 0x1a, 0xfd, 0x28, 0x87, 0x15, 0x6e, 0x2c, 0xcb, 0x25, 0x86, 0x8c, + 0x20, 0xa8, 0x2c, 0x1b, 0x37, 0x75, 0x93, 0xa0, 0x28, 0x9c, 0xfa, 0xb1, 0x41, 0x41, 0xf1, 0xdb, 0x99, 0x2f, 0x4d, + 0x76, 0xdc, 0x3d, 0x1a, 0xc0, 0xa2, 0x58, 0x97, 0x78, 0xd9, 0xc5, 0x44, 0x6e, 0x72, 0x83, 0x55, 0x46, 0x20, 0xe6, + 0xf0, 0x27, 0xa8, 0x92, 0x22, 0xa6, 0x8b, 0xb8, 0xb9, 0x34, 0x17, 0x47, 0x32, 0xb5, 0xab, 0x07, 0x6e, 0x43, 0xa3, + 0x5a, 0x4d, 0xf4, 0xda, 0x32, 0x3f, 0x91, 0x88, 0x4e, 0x58, 0x3c, 0x91, 0x57, 0x4c, 0x44, 0x12, 0x0c, 0x0c, 0x28, + 0xda, 0x16, 0x42, 0x51, 0xe8, 0x35, 0x9f, 0xae, 0x96, 0xf3, 0x73, 0xb9, 0x05, 0x49, 0xa1, 0xd1, 0xef, 0x13, 0x48, + 0xf5, 0xd3, 0xa6, 0x3f, 0x61, 0xf1, 0x3f, 0x89, 0x09, 0xb7, 0x3d, 0xf4, 0x0c, 0xc4, 0xa7, 0x1e, 0xe0, 0xd3, 0x53, + 0x07, 0x0a, 0xd3, 0xcb, 0x17, 0xc1, 0x83, 0x22, 0xea, 0xc6, 0x9c, 0x58, 0xf2, 0x18, 0x4a, 0x7c, 0x5f, 0x95, 0x4f, + 0x31, 0xa3, 0xda, 0x4a, 0xe1, 0x9e, 0x04, 0x8a, 0x26, 0xae, 0x64, 0xf3, 0x39, 0x65, 0x5c, 0x86, 0xe2, 0xe3, 0x84, + 0xf3, 0x86, 0xe5, 0x52, 0x16, 0x4a, 0x5e, 0xe1, 0xfd, 0x60, 0x0e, 0x21, 0xcb, 0x15, 0xa9, 0x21, 0xbf, 0x2a, 0x61, + 0x7f, 0x0f, 0xa4, 0x71, 0x05, 0x63, 0xb6, 0xf6, 0x0a, 0xeb, 0xc7, 0x62, 0xa5, 0x1f, 0x90, 0x6b, 0xc4, 0x3d, 0x1c, + 0x32, 0x00, 0xc3, 0x7e, 0x77, 0x44, 0xcd, 0x48, 0x85, 0x0b, 0x73, 0xf7, 0x92, 0x40, 0xc2, 0x36, 0x08, 0x9b, 0xed, + 0x8b, 0x79, 0xf8, 0xf8, 0x57, 0x6b, 0xce, 0x0e, 0xd6, 0x4a, 0xb8, 0x74, 0x74, 0x95, 0x09, 0xf2, 0xf2, 0x31, 0x12, + 0x67, 0x6e, 0xa7, 0xa9, 0x65, 0x41, 0x54, 0x5a, 0x8c, 0x67, 0x2b, 0x71, 0xb3, 0x4c, 0xe1, 0xb1, 0xc7, 0x04, 0xed, + 0xcc, 0x4b, 0x70, 0x09, 0x88, 0x3e, 0xc8, 0xf8, 0xca, 0x3a, 0x89, 0x5e, 0x79, 0x36, 0xfe, 0x2c, 0xbb, 0xf7, 0xa8, + 0xff, 0xaa, 0x48, 0xed, 0x7a, 0xd6, 0xdd, 0xa1, 0x24, 0x15, 0x4c, 0xbb, 0x1b, 0xf0, 0x71, 0xd2, 0x4f, 0x4c, 0xbe, + 0x51, 0x10, 0x37, 0xc0, 0xd9, 0x77, 0xe3, 0x40, 0xb7, 0x80, 0xf5, 0xe6, 0x83, 0x44, 0x03, 0x57, 0x23, 0xd2, 0xb9, + 0x59, 0xaf, 0xaf, 0x4d, 0x0b, 0x05, 0x20, 0x05, 0xb3, 0x92, 0x90, 0xbc, 0x2b, 0x17, 0x6d, 0x7d, 0x22, 0xb6, 0x00, + 0x62, 0xba, 0x81, 0xc4, 0x71, 0x44, 0xb9, 0xc6, 0xa3, 0x6f, 0x96, 0x1e, 0x3d, 0xeb, 0x88, 0xdd, 0x3f, 0x85, 0xd6, + 0xf4, 0xb2, 0x83, 0xed, 0x9c, 0x22, 0xa8, 0x50, 0x86, 0x8e, 0xea, 0xd9, 0x0d, 0x9b, 0x5b, 0xc7, 0xb2, 0xd0, 0xa3, + 0x87, 0x20, 0x96, 0xcc, 0x7b, 0xdb, 0x08, 0x8d, 0x10, 0xdf, 0xfd, 0x42, 0xc0, 0x38, 0x5a, 0xff, 0x42, 0xab, 0x6c, + 0xa8, 0xe3, 0xd4, 0xc6, 0x83, 0x8f, 0x9b, 0x55, 0x61, 0xe5, 0x92, 0xf9, 0xdc, 0xbb, 0x63, 0x8a, 0x7a, 0x2a, 0xdf, + 0x7a, 0x2d, 0x7b, 0x32, 0x3a, 0x6a, 0x68, 0x8f, 0x7c, 0xd2, 0xd6, 0xb7, 0x86, 0xad, 0x48, 0x1a, 0xc9, 0xa4, 0xb9, + 0xf3, 0xc1, 0x09, 0xb5, 0x79, 0xd8, 0x21, 0x71, 0xc2, 0xdc, 0xfa, 0xdd, 0x3c, 0x92, 0xb2, 0x78, 0x04, 0x5b, 0xf8, + 0x66, 0x68, 0xd3, 0x30, 0x26, 0x1d, 0x27, 0xe0, 0xba, 0xd2, 0x3f, 0xcd, 0xa0, 0xc4, 0x6a, 0x61, 0x61, 0x3c, 0x03, + 0x98, 0x8a, 0x29, 0xe2, 0xa5, 0x0a, 0x86, 0x1a, 0x24, 0xe7, 0x6a, 0x10, 0xcc, 0x74, 0xcc, 0xd8, 0x99, 0x97, 0x79, + 0x0f, 0x6d, 0x6d, 0xcc, 0xc2, 0x42, 0xcf, 0xc6, 0xd4, 0x3c, 0xaa, 0x14, 0x30, 0x35, 0x82, 0x6e, 0x87, 0x71, 0x71, + 0xb7, 0x47, 0x7e, 0x5a, 0x8e, 0x9c, 0x5d, 0x0c, 0x8e, 0xc7, 0x5e, 0x66, 0x8b, 0x53, 0x0f, 0x9e, 0x07, 0x98, 0x11, + 0x2a, 0x6c, 0x15, 0x2f, 0xd0, 0x9e, 0x35, 0xfd, 0x07, 0xbe, 0x89, 0x8d, 0x31, 0x98, 0x37, 0xc6, 0xd1, 0x9a, 0xa5, + 0x2b, 0xde, 0xd3, 0x30, 0x42, 0x16, 0x31, 0x22, 0xcb, 0x59, 0x53, 0xcc, 0xad, 0x54, 0x31, 0x9e, 0x41, 0x22, 0x58, + 0xbe, 0xc2, 0x54, 0x00, 0xe1, 0x60, 0x76, 0xa3, 0xc1, 0x6e, 0xd6, 0xc7, 0xb5, 0x7e, 0x04, 0x44, 0x60, 0x00, 0xd5, + 0xc5, 0x39, 0xd7, 0x26, 0x3a, 0x00, 0x96, 0xdf, 0x47, 0x00, 0x20, 0x09, 0xcc, 0x50, 0x24, 0xa0, 0xe8, 0x55, 0x4b, + 0x5f, 0xf3, 0x62, 0x0e, 0x9d, 0x1e, 0x0a, 0x82, 0x60, 0x2b, 0xf7, 0xe8, 0x34, 0x48, 0xb3, 0xb9, 0x41, 0x1f, 0xf1, + 0xed, 0x59, 0x51, 0x89, 0x83, 0xcb, 0xaf, 0x8a, 0xa0, 0xf8, 0x27, 0x43, 0xf6, 0x26, 0x63, 0xa6, 0x23, 0xde, 0xea, + 0xc8, 0xa3, 0x85, 0x7c, 0x31, 0x4e, 0x17, 0x9f, 0xa1, 0xd8, 0x43, 0x36, 0x28, 0xab, 0x64, 0xec, 0xc4, 0x93, 0xa1, + 0x11, 0x49, 0xfd, 0xe3, 0x30, 0xf7, 0x45, 0x3d, 0x8a, 0xd2, 0x3c, 0xad, 0x27, 0xd4, 0x8a, 0xa9, 0x76, 0x23, 0xb0, + 0x26, 0xe5, 0x99, 0xd0, 0x19, 0x5b, 0xea, 0x97, 0x0a, 0x52, 0x76, 0x6a, 0x4c, 0xc5, 0x4e, 0xce, 0x8b, 0x9c, 0xa3, + 0xa7, 0x3c, 0x08, 0xe3, 0xc0, 0xd8, 0x9f, 0x4e, 0x97, 0xd5, 0xee, 0xd9, 0x09, 0xe2, 0xf1, 0x6a, 0xa8, 0xf6, 0x21, + 0x5d, 0xab, 0x26, 0xa6, 0x40, 0xd3, 0x9e, 0xa6, 0xff, 0x25, 0x81, 0x3e, 0x0f, 0xc1, 0x9e, 0xe9, 0xb3, 0x91, 0x6a, + 0x07, 0xd1, 0xfe, 0xa0, 0x85, 0x77, 0xf8, 0x1a, 0x25, 0x54, 0xbf, 0xe7, 0x04, 0xe8, 0xf8, 0x06, 0x6b, 0xc4, 0x96, + 0x24, 0xce, 0xe7, 0x22, 0x95, 0x9d, 0x63, 0x46, 0x2d, 0x20, 0x17, 0x44, 0x81, 0xe7, 0x3a, 0x8d, 0xca, 0x42, 0x96, + 0xbc, 0xc1, 0x8d, 0x9f, 0xfd, 0x9a, 0x29, 0x14, 0xfe, 0x69, 0x38, 0x08, 0x58, 0x06, 0xb0, 0x30, 0x9f, 0x5e, 0x61, + 0xce, 0x99, 0x9d, 0x25, 0x0c, 0x59, 0x80, 0x96, 0x3a, 0x7a, 0x0b, 0x9d, 0x04, 0x00, 0x44, 0x47, 0xc5, 0x18, 0xc8, + 0xab, 0x1d, 0x55, 0x9f, 0xc0, 0xa1, 0x77, 0xd2, 0x73, 0x69, 0xee, 0x26, 0x10, 0x45, 0x08, 0x08, 0x90, 0xd8, 0x1a, + 0x0a, 0x22, 0x6f, 0x39, 0x88, 0xa8, 0x4a, 0xec, 0x04, 0xb7, 0x42, 0xb3, 0xe0, 0x46, 0x32, 0x22, 0x8d, 0x00, 0x7a, + 0x05, 0x08, 0x31, 0x23, 0x50, 0xe6, 0x3c, 0xd2, 0xf8, 0x05, 0x1e, 0x26, 0x2f, 0x44, 0xc1, 0xe7, 0x14, 0xb5, 0xde, + 0x83, 0xe8, 0x9e, 0x9b, 0xb3, 0xf6, 0xc7, 0x84, 0x10, 0x3d, 0x02, 0x6b, 0x28, 0xab, 0x7f, 0x45, 0x29, 0x60, 0x34, + 0xc0, 0xd9, 0xde, 0xe1, 0xdc, 0x63, 0xfe, 0x51, 0xf2, 0xa0, 0x0a, 0x1d, 0xf3, 0x88, 0x5c, 0x3a, 0x9f, 0x74, 0xab, + 0xb0, 0x5e, 0xd4, 0x0e, 0x6c, 0xb7, 0x1e, 0x8f, 0xd5, 0x4b, 0x75, 0xad, 0x41, 0x1a, 0x8a, 0xff, 0xa2, 0xfc, 0x68, + 0x0c, 0x95, 0xf3, 0x8b, 0xf1, 0xa0, 0x7b, 0xd1, 0x61, 0xbd, 0x8b, 0x5c, 0x40, 0x45, 0x09, 0x00, 0xb4, 0xdb, 0xa1, + 0x9d, 0x33, 0x9b, 0x7f, 0xbb, 0xfd, 0x85, 0xaf, 0x2c, 0x55, 0x8b, 0x3a, 0xcf, 0x1a, 0x0a, 0xce, 0xcb, 0x71, 0xfe, + 0x2f, 0x3c, 0xd8, 0xcb, 0x93, 0xce, 0x98, 0x2a, 0x42, 0x9c, 0xba, 0x33, 0xfb, 0x26, 0x1f, 0x87, 0x2d, 0x21, 0x76, + 0xaa, 0x9b, 0xbf, 0xd9, 0xcc, 0x83, 0xa9, 0xaf, 0x76, 0x80, 0x1b, 0x37, 0xb7, 0xcc, 0xd8, 0xab, 0xc7, 0xd0, 0x31, + 0x01, 0xe0, 0xad, 0x25, 0x8a, 0x22, 0xe2, 0x25, 0xe1, 0xdf, 0x1f, 0x8f, 0x0f, 0x55, 0xc3, 0x07, 0x7d, 0x1b, 0xef, + 0x44, 0xa1, 0x29, 0x30, 0xc1, 0x3a, 0x60, 0x98, 0x0f, 0xe8, 0x7b, 0x85, 0xcd, 0x8c, 0x1a, 0xdf, 0x76, 0xba, 0x28, + 0x40, 0x4c, 0x61, 0x70, 0xa5, 0xf1, 0x49, 0x5e, 0x64, 0x3c, 0xa8, 0x02, 0x6d, 0xde, 0x26, 0xfb, 0xaa, 0x30, 0x34, + 0x3c, 0xed, 0xd6, 0x43, 0x8f, 0x1d, 0x34, 0x8b, 0x5b, 0xc3, 0xf8, 0x85, 0x74, 0x90, 0xbf, 0xb1, 0xc9, 0x2c, 0x51, + 0xfc, 0xfe, 0x47, 0xe7, 0x24, 0xf7, 0x7c, 0xd0, 0x4e, 0x8a, 0x9a, 0x0a, 0x9d, 0x3f, 0x2b, 0x1f, 0x97, 0xf3, 0xb3, + 0xf0, 0xee, 0x2c, 0xd4, 0x1d, 0x59, 0x0a, 0x12, 0x39, 0x0d, 0x4d, 0xae, 0xd5, 0x62, 0xcd, 0x89, 0x8b, 0xb7, 0xb6, + 0xc5, 0x27, 0x70, 0xb3, 0xe4, 0x0c, 0x61, 0x2a, 0xde, 0xc4, 0x84, 0xe0, 0x30, 0x10, 0x14, 0x86, 0x8b, 0xe2, 0x10, + 0x09, 0x83, 0x37, 0x3b, 0x3c, 0xb1, 0x5b, 0x06, 0x1b, 0x5f, 0xcd, 0x1b, 0x65, 0x9e, 0xb1, 0x9e, 0x98, 0x81, 0x6a, + 0x16, 0x55, 0xd7, 0x8b, 0x01, 0x56, 0xff, 0x84, 0xd7, 0xd2, 0x89, 0xd9, 0x7a, 0x90, 0x25, 0xa9, 0x61, 0x53, 0x2e, + 0x51, 0x4d, 0x19, 0xdb, 0x58, 0x43, 0xc1, 0xb5, 0xc3, 0x23, 0xfd, 0xe1, 0xfa, 0x4f, 0xce, 0x67, 0x89, 0x67, 0xa1, + 0xe7, 0x2b, 0x87, 0xc0, 0x5a, 0xec, 0xb2, 0x76, 0x7d, 0xe8, 0x6b, 0x36, 0x47, 0x61, 0x1b, 0x0d, 0xa5, 0x74, 0x16, + 0x2f, 0x88, 0xae, 0x83, 0x32, 0x90, 0x2e, 0x1d, 0x26, 0x3a, 0x7b, 0x5f, 0x35, 0xeb, 0x0e, 0x34, 0xde, 0xf4, 0x88, + 0x44, 0x1b, 0xbb, 0x6a, 0x30, 0xaf, 0xe8, 0x9c, 0xa2, 0x9b, 0x63, 0x4b, 0xa0, 0xbf, 0xda, 0x1c, 0x6e, 0x4c, 0x5f, + 0x02, 0x31, 0xa5, 0x80, 0x7c, 0xcb, 0xa6, 0xe6, 0x9e, 0xf3, 0x40, 0x3e, 0x61, 0x2a, 0x34, 0x64, 0xed, 0x3a, 0xec, + 0xc6, 0x1a, 0x2f, 0x39, 0x22, 0xf5, 0xcf, 0xb5, 0x08, 0x0b, 0xaf, 0x2e, 0x58, 0xb6, 0xc5, 0x47, 0x27, 0xac, 0x49, + 0xd2, 0xb6, 0x87, 0x05, 0xb4, 0xd8, 0x61, 0x51, 0x9e, 0x5a, 0xcf, 0x25, 0x2e, 0x66, 0x62, 0x7c, 0x4d, 0x97, 0x2e, + 0x39, 0xb0, 0xec, 0x1c, 0x01, 0x8d, 0x07, 0x2b, 0xbd, 0x15, 0xbe, 0x55, 0x74, 0xbf, 0x6a, 0x46, 0x25, 0xce, 0x34, + 0x90, 0xd6, 0x0b, 0x58, 0x23, 0xd4, 0xb5, 0xfc, 0xc0, 0x19, 0xc7, 0x02, 0x6c, 0xcb, 0xf4, 0xfe, 0x76, 0x29, 0x2d, + 0xc4, 0x0e, 0x01, 0x9e, 0x71, 0x17, 0xfd, 0x03, 0xcd, 0x0a, 0x60, 0x4c, 0x4e, 0x4d, 0xc8, 0xc5, 0x7b, 0xdd, 0x10, + 0x32, 0xa6, 0x7f, 0xd2, 0x3e, 0xb6, 0x6c, 0x47, 0x87, 0x04, 0x1c, 0x19, 0x06, 0xc6, 0xad, 0x57, 0x29, 0x6b, 0x77, + 0x33, 0x1c, 0x23, 0xaa, 0xa5, 0x15, 0xf7, 0xcb, 0x44, 0x81, 0x67, 0xc0, 0x6e, 0x5c, 0x34, 0xed, 0xb5, 0x41, 0x2e, + 0x91, 0x9d, 0xc1, 0xab, 0x53, 0x45, 0x66, 0x61, 0x8c, 0x5d, 0x25, 0x0b, 0x3c, 0x3e, 0xf6, 0x84, 0x31, 0xfe, 0x27, + 0x29, 0x41, 0xf9, 0xfe, 0xbb, 0xa4, 0x93, 0x0a, 0x95, 0xc2, 0x1e, 0x4e, 0xaf, 0xe3, 0x2b, 0xfa, 0x2a, 0x11, 0x58, + 0xf3, 0xa8, 0x7e, 0xdc, 0x00, 0x83, 0xaa, 0x0d, 0x78, 0x74, 0x43, 0x29, 0xde, 0x54, 0xf8, 0x26, 0x77, 0xa1, 0x55, + 0x51, 0x8e, 0xca, 0x01, 0x6b, 0x8e, 0xdc, 0x1c, 0x59, 0x22, 0xd8, 0xb2, 0x76, 0x90, 0xa2, 0x02, 0xc3, 0x9e, 0x55, + 0x83, 0xb4, 0x2a, 0x3d, 0x1c, 0x19, 0x7f, 0x4d, 0x80, 0x16, 0x40, 0x18, 0x96, 0x3f, 0x33, 0x93, 0x8c, 0x97, 0x29, + 0x2b, 0xb9, 0xa9, 0xe6, 0x28, 0x9a, 0x98, 0x86, 0x4e, 0xee, 0xe9, 0x84, 0x1f, 0x6a, 0x8e, 0x38, 0x1b, 0x04, 0xb5, + 0x55, 0xd5, 0x3a, 0x83, 0x61, 0x50, 0x27, 0x1d, 0x01, 0xf2, 0x51, 0xd2, 0x60, 0xc2, 0x73, 0x73, 0x8e, 0x9e, 0xc7, + 0x79, 0x19, 0x96, 0x93, 0x76, 0x36, 0x4b, 0x00, 0x3e, 0xb5, 0x14, 0xb6, 0x90, 0x81, 0x31, 0x8c, 0x3f, 0x02, 0x72, + 0xc7, 0xa7, 0xcf, 0x4b, 0xcb, 0x1e, 0x95, 0x5e, 0xde, 0xfc, 0xf0, 0xf1, 0x07, 0x83, 0x37, 0x18, 0x2a, 0x1a, 0xbc, + 0x7b, 0xaf, 0x2f, 0xe9, 0x3b, 0x99, 0x60, 0xac, 0x41, 0xe7, 0x20, 0x8a, 0x55, 0x68, 0x47, 0xb6, 0x2a, 0xeb, 0x22, + 0x27, 0xdb, 0xd7, 0x27, 0xe5, 0xe7, 0x97, 0x22, 0x94, 0x6a, 0x41, 0x21, 0x6f, 0xb1, 0x8a, 0x0d, 0x42, 0x28, 0x54, + 0xe0, 0xa0, 0x08, 0x01, 0x8e, 0x22, 0xee, 0xee, 0x34, 0x14, 0x00, 0x52, 0x52, 0x14, 0xcc, 0xa9, 0xcb, 0xda, 0xdb, + 0x5c, 0x60, 0xb3, 0x73, 0xa6, 0xee, 0x23, 0x3e, 0xc7, 0x84, 0xd5, 0x39, 0x47, 0x8a, 0x04, 0xb2, 0xb6, 0xec, 0xd6, + 0x22, 0x4b, 0x75, 0x77, 0x34, 0x64, 0xc8, 0xac, 0x20, 0xe7, 0x5e, 0x3e, 0x2b, 0x10, 0x5a, 0x41, 0xfe, 0x93, 0x26, + 0x36, 0x60, 0x8c, 0x63, 0xfb, 0xc7, 0xef, 0x54, 0xf0, 0x37, 0x5f, 0xc3, 0x3d, 0xf9, 0x6d, 0x3a, 0xc1, 0x2a, 0xc5, + 0x60, 0x50, 0xf3, 0x2b, 0xe7, 0x4c, 0xaf, 0xcd, 0x18, 0x88, 0x89, 0x63, 0x56, 0xbe, 0x87, 0x57, 0xe9, 0x8b, 0x52, + 0xb4, 0x19, 0x54, 0xa4, 0x4c, 0x2a, 0x80, 0x84, 0x26, 0xed, 0x21, 0xf5, 0x1a, 0x4c, 0xca, 0xb2, 0x29, 0xb6, 0x69, + 0xae, 0xd4, 0xf6, 0xb1, 0xa3, 0xa6, 0xd6, 0x83, 0x32, 0x89, 0x87, 0x38, 0x7d, 0x16, 0x78, 0x1c, 0x63, 0x42, 0x88, + 0x14, 0x12, 0x7f, 0x71, 0xa6, 0xd5, 0xe3, 0x2b, 0x2a, 0xee, 0xb9, 0x8f, 0xa0, 0x63, 0x0c, 0x8d, 0xe9, 0x54, 0xb0, + 0x1b, 0xd2, 0x19, 0x12, 0x7b, 0x9d, 0x1b, 0x99, 0xee, 0xd6, 0xab, 0x0e, 0x1f, 0x8c, 0xcc, 0x4f, 0x79, 0xc7, 0xae, + 0xf7, 0x46, 0x06, 0x6b, 0x9d, 0xd2, 0xd3, 0x9a, 0xf2, 0xf4, 0x7f, 0xc3, 0x15, 0xee, 0xa8, 0x2e, 0x2d, 0x12, 0x5d, + 0x9e, 0x21, 0xc1, 0xb8, 0x48, 0x8a, 0xb4, 0xde, 0x25, 0x4c, 0x36, 0xbd, 0x62, 0xed, 0x9a, 0xd1, 0x65, 0x61, 0x7e, + 0xc8, 0xe6, 0x17, 0x5d, 0x8b, 0xf1, 0x0e, 0xac, 0xb3, 0xaf, 0xf2, 0xcc, 0x39, 0x46, 0x9e, 0xc1, 0x8c, 0x85, 0xbd, + 0x2a, 0xa8, 0x43, 0x5a, 0x58, 0x07, 0xa8, 0x1e, 0xa3, 0x28, 0xe3, 0xd1, 0x4b, 0x9b, 0x42, 0x7a, 0xa0, 0xdb, 0xee, + 0x95, 0x5f, 0x5e, 0x45, 0x85, 0x02, 0x20, 0x2e, 0x44, 0x58, 0x78, 0x34, 0x83, 0xc1, 0x05, 0x0a, 0x85, 0xb7, 0x39, + 0xe8, 0xc5, 0x35, 0x9c, 0xb7, 0x1f, 0xa4, 0xd4, 0x70, 0x8a, 0x29, 0x1d, 0x27, 0x5f, 0x70, 0x67, 0xbd, 0xac, 0x40, + 0x7e, 0x38, 0xb3, 0x16, 0xbb, 0x66, 0x97, 0x42, 0x36, 0xa4, 0xe8, 0xaa, 0xdd, 0xed, 0x9d, 0xb2, 0xb6, 0x67, 0xe6, + 0xc3, 0xb2, 0xa6, 0x68, 0x56, 0x12, 0x85, 0x9e, 0x43, 0x14, 0x43, 0xc5, 0xd0, 0xcc, 0xb5, 0x65, 0x5d, 0xd4, 0x52, + 0x0d, 0x95, 0xba, 0x46, 0x50, 0xd5, 0xcd, 0x51, 0xfd, 0x73, 0xd6, 0xe3, 0xdc, 0xb5, 0xc1, 0xd0, 0x7a, 0xf2, 0x30, + 0x5e, 0xc6, 0xea, 0x1c, 0x1f, 0x2f, 0x7c, 0x8e, 0x73, 0xdb, 0xbe, 0x57, 0xf7, 0x3b, 0x05, 0x6d, 0x59, 0x7c, 0x13, + 0xff, 0x83, 0xea, 0xff, 0xb2, 0x01, 0x23, 0x93, 0x8f, 0x0f, 0xcb, 0x99, 0xd6, 0x17, 0x59, 0x4c, 0x76, 0xe4, 0xb1, + 0x33, 0x4d, 0x9e, 0xb1, 0xb0, 0x57, 0x77, 0x6f, 0x23, 0x67, 0xc1, 0x61, 0x73, 0xe6, 0x10, 0x06, 0xb2, 0x32, 0xfe, + 0xb0, 0x65, 0xb4, 0x6e, 0x9d, 0x36, 0x75, 0xf8, 0x30, 0x34, 0x31, 0xd9, 0x6b, 0x3c, 0xc5, 0x10, 0xe6, 0xd9, 0x94, + 0xb1, 0x2d, 0xe0, 0x45, 0x65, 0x28, 0xe2, 0x32, 0xae, 0x39, 0x82, 0x29, 0xad, 0x06, 0xf6, 0x59, 0x45, 0xf1, 0x1c, + 0x55, 0xba, 0xa8, 0x9e, 0xdb, 0x37, 0x3d, 0x60, 0x48, 0x46, 0xce, 0x7e, 0xb9, 0xfa, 0x18, 0x1a, 0x58, 0xb7, 0xa3, + 0xaf, 0x06, 0x3c, 0x43, 0x24, 0xfa, 0xbc, 0x33, 0x36, 0x20, 0xb6, 0x58, 0x99, 0xe5, 0x50, 0x48, 0xfe, 0x71, 0x3b, + 0x5c, 0xc6, 0xea, 0x53, 0x7e, 0xa4, 0x2f, 0x59, 0xec, 0x86, 0xa6, 0xd6, 0xc1, 0x5f, 0xa9, 0x0a, 0x22, 0xe5, 0x5d, + 0x4b, 0x75, 0x97, 0x21, 0x6d, 0x4a, 0x3d, 0xfa, 0x7b, 0xa0, 0x2c, 0x8d, 0x58, 0x89, 0xa5, 0x51, 0x35, 0x26, 0xfe, + 0xef, 0xf4, 0x29, 0x3a, 0x23, 0x3f, 0xb5, 0xb0, 0xe2, 0xbe, 0x22, 0x16, 0x2e, 0xe1, 0x98, 0xe9, 0xd5, 0x16, 0x1d, + 0x15, 0x22, 0x28, 0xe0, 0xb3, 0x45, 0xef, 0xcd, 0x86, 0x4c, 0x04, 0x8d, 0xb7, 0x79, 0x7a, 0x1d, 0x4f, 0xf7, 0xf3, + 0x19, 0xd9, 0x11, 0x9a, 0x2e, 0xac, 0x4d, 0x41, 0xe1, 0x20, 0x70, 0x6e, 0x21, 0xd0, 0x5c, 0x95, 0x81, 0x09, 0x8e, + 0xf3, 0x62, 0xcb, 0x27, 0x50, 0x9d, 0xee, 0x81, 0x34, 0xa8, 0x5a, 0x9e, 0x6a, 0x95, 0xba, 0x8f, 0xe9, 0xb4, 0xd5, + 0x3a, 0x6b, 0x83, 0x52, 0xfc, 0x00, 0xbb, 0xa0, 0x80, 0x56, 0x2f, 0x51, 0x82, 0xb8, 0x39, 0x34, 0x5f, 0xca, 0x5e, + 0x33, 0xe7, 0x68, 0xef, 0xd0, 0x92, 0x71, 0x41, 0xfb, 0xfb, 0xfb, 0x03, 0x21, 0x73, 0x14, 0xad, 0x83, 0xa6, 0x64, + 0x2e, 0xf7, 0x88, 0xab, 0x48, 0xe5, 0x9f, 0x17, 0x6c, 0xa8, 0xe0, 0xe5, 0xf6, 0x77, 0xa8, 0x1f, 0x16, 0x75, 0xd1, + 0x7e, 0x0b, 0xf1, 0x1a, 0xf9, 0x47, 0xf0, 0xfe, 0x28, 0x20, 0x1a, 0x7e, 0x9a, 0xf0, 0x3b, 0x68, 0xb3, 0x57, 0xf7, + 0x0b, 0xdf, 0xf7, 0x7d, 0x8b, 0xdd, 0xe0, 0xad, 0xef, 0x9f, 0x3a, 0x58, 0x85, 0xc3, 0x1e, 0xb8, 0x9e, 0x18, 0xdd, + 0xfe, 0xfc, 0xfc, 0xbe, 0x86, 0x8a, 0x2f, 0xce, 0xb0, 0x9b, 0xa9, 0x7c, 0xa0, 0xee, 0x9d, 0xdc, 0xd2, 0x7e, 0xa1, + 0xe6, 0x35, 0x04, 0xa4, 0x5c, 0x38, 0x27, 0xae, 0x4f, 0x0a, 0x5c, 0x81, 0x16, 0x52, 0x3a, 0xba, 0x2d, 0xf1, 0x9e, + 0x35, 0xa4, 0xfd, 0xb0, 0x01, 0x36, 0x9d, 0xf6, 0x1d, 0x52, 0x71, 0x98, 0xc9, 0xd2, 0x6c, 0x42, 0xfe, 0x6b, 0x8e, + 0x3a, 0x55, 0x07, 0xf7, 0x79, 0xb1, 0x2e, 0x0c, 0xeb, 0x6e, 0x3c, 0xce, 0x9f, 0xaa, 0x3d, 0x61, 0xc4, 0x0d, 0x63, + 0x75, 0xc8, 0x6f, 0x90, 0x06, 0xf4, 0x76, 0x34, 0x93, 0x22, 0xfb, 0x81, 0x00, 0x80, 0xaf, 0xd6, 0x8c, 0xa5, 0x41, + 0xd9, 0x37, 0xfd, 0x1c, 0x2a, 0x34, 0x41, 0x8c, 0xca, 0x5e, 0x03, 0x24, 0xe0, 0x22, 0x5b, 0x97, 0xc5, 0x7b, 0xa1, + 0x22, 0xa1, 0x5b, 0x97, 0xd0, 0xa9, 0xde, 0xc9, 0x10, 0x56, 0x5d, 0x22, 0xc2, 0x9c, 0xf6, 0x84, 0xaf, 0xeb, 0x7c, + 0xf8, 0x3c, 0x16, 0x7b, 0xce, 0xd3, 0xcf, 0xb0, 0xb9, 0x30, 0x0d, 0x0d, 0x44, 0x33, 0x0e, 0xdd, 0x8f, 0xd4, 0x96, + 0xe2, 0xd6, 0xac, 0x62, 0x3c, 0xfe, 0x72, 0x5e, 0x55, 0x64, 0xfd, 0xe5, 0x22, 0xc3, 0x14, 0xe1, 0x66, 0x16, 0xf5, + 0xf2, 0xa2, 0x10, 0x66, 0xa7, 0x8b, 0x06, 0x82, 0x66, 0xb4, 0x6d, 0x3d, 0xb8, 0xa1, 0xb4, 0x11, 0xfa, 0x45, 0x95, + 0x68, 0x6d, 0xd5, 0xf7, 0xfd, 0x06, 0xd9, 0xe5, 0x1c, 0x07, 0x6d, 0x5e, 0xc0, 0xf1, 0xbd, 0x7f, 0xea, 0x97, 0xab, + 0xbd, 0x75, 0x9a, 0xbf, 0xe0, 0x16, 0x5f, 0x90, 0xb0, 0xfc, 0x30, 0xc3, 0x41, 0x29, 0x21, 0xc3, 0xc9, 0x47, 0x38, + 0x17, 0xd6, 0xe8, 0x92, 0xcf, 0xf6, 0x5c, 0x18, 0xe8, 0x60, 0x45, 0xb4, 0x23, 0xbe, 0xe1, 0xa7, 0xba, 0x2d, 0x44, + 0x10, 0x3b, 0x58, 0xc6, 0x80, 0x67, 0x64, 0x72, 0x22, 0xa3, 0x3a, 0x4c, 0x60, 0x9a, 0x4d, 0x98, 0x06, 0x76, 0x9b, + 0x00, 0x9a, 0x3a, 0x18, 0xa7, 0x38, 0x03, 0x7d, 0x18, 0xaa, 0xad, 0x67, 0x25, 0x19, 0xf3, 0x81, 0xa0, 0x9d, 0xed, + 0x8f, 0x1a, 0x65, 0x5e, 0x6c, 0x37, 0xdb, 0x48, 0xf3, 0xaa, 0x14, 0x43, 0x3b, 0x90, 0xd9, 0x91, 0x34, 0x64, 0xea, + 0x1e, 0xd4, 0xb8, 0x50, 0xa8, 0x36, 0x0c, 0xc2, 0x01, 0x4a, 0x91, 0xa6, 0x39, 0xf5, 0x08, 0xb3, 0xe8, 0xd6, 0x14, + 0xde, 0x59, 0x66, 0xb8, 0x5a, 0x22, 0xa0, 0x04, 0x11, 0xc7, 0x5d, 0x74, 0x18, 0xc5, 0x83, 0xbd, 0x51, 0x77, 0x4a, + 0xa8, 0xaf, 0x5c, 0x2c, 0xd6, 0xa3, 0xad, 0x16, 0x7b, 0x82, 0x69, 0x5a, 0xd7, 0xfb, 0x81, 0x18, 0xed, 0xf9, 0x66, + 0x22, 0x55, 0xea, 0x12, 0x54, 0x95, 0xde, 0xb7, 0x1f, 0xb2, 0x8a, 0x3d, 0x86, 0xc7, 0x4a, 0xa5, 0x44, 0xb1, 0x53, + 0xd3, 0xce, 0xe2, 0x34, 0x45, 0xda, 0x65, 0x99, 0x78, 0x13, 0xfa, 0x1d, 0x49, 0xbb, 0x2d, 0xb3, 0xb6, 0x17, 0x8b, + 0x9b, 0x93, 0x48, 0xb1, 0x1c, 0xac, 0x35, 0xbc, 0x2d, 0x73, 0xec, 0x82, 0xb7, 0x39, 0xb7, 0x7e, 0xc1, 0x58, 0x43, + 0xeb, 0x33, 0xd6, 0xdf, 0xa4, 0x47, 0x46, 0x14, 0xa0, 0xfa, 0x37, 0x59, 0x08, 0x12, 0x37, 0xcc, 0xf8, 0x1d, 0xb5, + 0x61, 0x51, 0x5d, 0xd4, 0x3d, 0x4b, 0xac, 0x88, 0x58, 0x38, 0x7f, 0x5f, 0x9d, 0x05, 0x72, 0xe9, 0x6c, 0xc5, 0x35, + 0x0f, 0x47, 0x5d, 0x76, 0x3d, 0xb8, 0x53, 0x18, 0x53, 0xf3, 0xc9, 0x42, 0xf5, 0x86, 0x7b, 0x2e, 0x3e, 0xd7, 0x12, + 0x5e, 0x57, 0xfb, 0xdc, 0x9c, 0xe6, 0xf2, 0x2d, 0x2e, 0xab, 0x2a, 0xb5, 0x99, 0xc0, 0xa4, 0x6b, 0xad, 0xfe, 0x38, + 0x82, 0x35, 0x14, 0x91, 0xb8, 0x49, 0xd4, 0xc1, 0x66, 0x59, 0x87, 0x72, 0x9b, 0x09, 0x56, 0x92, 0x0d, 0xf6, 0x80, + 0x70, 0x6a, 0xb1, 0x99, 0x63, 0xa7, 0x0d, 0xe1, 0xf0, 0x1d, 0xb7, 0xa6, 0x88, 0x8a, 0x53, 0x77, 0xe1, 0xa9, 0x65, + 0xf9, 0xc3, 0xec, 0x6a, 0x4d, 0xd3, 0xf5, 0x1d, 0x6a, 0x64, 0x49, 0xb8, 0x72, 0x2f, 0x63, 0x98, 0x0f, 0x2d, 0xe4, + 0x59, 0xaa, 0x8e, 0x60, 0xd0, 0xd2, 0x2d, 0x37, 0xfc, 0x7d, 0xf8, 0x74, 0x5c, 0x6b, 0x22, 0xda, 0x38, 0xbe, 0xdc, + 0x43, 0x2a, 0x27, 0xfb, 0x49, 0xcc, 0x0b, 0x95, 0xd3, 0xe9, 0x49, 0x91, 0x80, 0x87, 0x9b, 0xb8, 0x70, 0x89, 0x72, + 0x2d, 0xcb, 0x74, 0x35, 0xc9, 0xa9, 0xa1, 0x42, 0xce, 0x8c, 0xa1, 0xc5, 0xfb, 0x59, 0xc4, 0x30, 0x63, 0x13, 0x66, + 0x66, 0x53, 0x53, 0xd3, 0x0e, 0x45, 0xee, 0x43, 0x25, 0x8f, 0xc4, 0x64, 0xe5, 0xd0, 0x38, 0x3a, 0x35, 0xdd, 0x63, + 0x70, 0x5d, 0x21, 0x9c, 0x6a, 0x54, 0xfb, 0x01, 0x74, 0x71, 0xfe, 0x85, 0xdb, 0x51, 0xbf, 0x1c, 0x8c, 0x7e, 0x6b, + 0x54, 0x13, 0x95, 0xf9, 0xd0, 0x0c, 0x5d, 0x3b, 0x32, 0x98, 0x1c, 0x03, 0xe0, 0x26, 0x13, 0x84, 0x0d, 0x1f, 0x57, + 0x60, 0x16, 0x7b, 0xaa, 0xaf, 0x7f, 0x0e, 0x52, 0x38, 0x97, 0xa9, 0x67, 0x61, 0xd4, 0x72, 0x80, 0x4b, 0x03, 0x0b, + 0xe3, 0x4a, 0x43, 0x0c, 0x9b, 0xdf, 0x8f, 0xb6, 0x89, 0x4c, 0xd2, 0x3d, 0xab, 0x29, 0x00, 0x9a, 0x4e, 0x41, 0xe4, + 0xdf, 0xa3, 0xe4, 0x05, 0xc7, 0xd1, 0x29, 0x3d, 0xfd, 0xa2, 0xd4, 0xa3, 0x19, 0xb4, 0xf7, 0x78, 0x75, 0xc1, 0xac, + 0x27, 0x23, 0xed, 0x88, 0x87, 0xd9, 0x09, 0xe4, 0x07, 0x48, 0x4d, 0xe9, 0x5a, 0x73, 0x63, 0xf7, 0x35, 0xc8, 0x96, + 0xed, 0x68, 0x90, 0xc3, 0x1a, 0xf9, 0x1a, 0x54, 0xca, 0xa1, 0x7c, 0x93, 0xcc, 0xe3, 0x24, 0xd8, 0xd7, 0xc8, 0xed, + 0x3b, 0xee, 0x6b, 0xb6, 0xb7, 0x43, 0x52, 0x1d, 0x92, 0xb0, 0xef, 0xb6, 0x69, 0x92, 0xe0, 0x70, 0x83, 0x0c, 0xc2, + 0x05, 0x6c, 0x64, 0xe8, 0xdb, 0xeb, 0x46, 0x21, 0x9a, 0xef, 0x1a, 0x7c, 0xaf, 0xee, 0x8b, 0x37, 0x66, 0x30, 0x49, + 0x92, 0x44, 0x60, 0x36, 0x53, 0x1a, 0x13, 0xe5, 0x1b, 0xc3, 0x73, 0xb5, 0xe7, 0x07, 0xe5, 0x5c, 0x4b, 0xd8, 0x33, + 0x1d, 0xbf, 0x1d, 0x8d, 0x57, 0xa5, 0xdf, 0xe0, 0x55, 0x52, 0x12, 0xdd, 0xf9, 0xfb, 0x00, 0x8e, 0xbc, 0x29, 0xeb, + 0x17, 0xf3, 0x1d, 0xa7, 0xc7, 0xd2, 0xe6, 0xed, 0x26, 0x2e, 0xf0, 0x37, 0x4f, 0xa4, 0x5e, 0xf1, 0xa5, 0xa6, 0x49, + 0xbf, 0x6e, 0xf1, 0x60, 0x17, 0x30, 0x79, 0xcb, 0x0d, 0xb3, 0x06, 0x7d, 0xb3, 0xca, 0x4d, 0xdf, 0x42, 0x79, 0x58, + 0xce, 0x63, 0x9e, 0x3a, 0x84, 0x5f, 0x3c, 0xaa, 0x43, 0x65, 0x34, 0xb7, 0x66, 0x27, 0xf4, 0x37, 0x98, 0xd7, 0xdc, + 0xc1, 0x0c, 0x27, 0xb2, 0x24, 0x0d, 0x6f, 0x7a, 0x7a, 0x3b, 0xca, 0x3c, 0x08, 0x42, 0x92, 0x22, 0xda, 0x06, 0x76, + 0xd0, 0x82, 0x0a, 0xb8, 0x41, 0xd4, 0xec, 0x3d, 0x62, 0xb6, 0x97, 0x76, 0x1f, 0xe7, 0xbd, 0x77, 0x3c, 0x59, 0x13, + 0x21, 0x67, 0x08, 0xa1, 0xf8, 0x7b, 0xda, 0xcf, 0x61, 0xcf, 0x08, 0x57, 0x5a, 0xa1, 0x60, 0xc4, 0x0d, 0xaa, 0x7e, + 0xcc, 0x16, 0x10, 0x2d, 0x12, 0x90, 0xb3, 0x5d, 0x0b, 0x6b, 0x26, 0x33, 0xf9, 0x49, 0x0c, 0x95, 0xd4, 0xb6, 0x7c, + 0xc3, 0x7f, 0xae, 0x0a, 0x49, 0x60, 0x31, 0x27, 0x75, 0xdf, 0x47, 0x12, 0x8b, 0x9b, 0x35, 0x9b, 0x87, 0x72, 0xed, + 0xf3, 0x72, 0xac, 0xbd, 0x83, 0xbe, 0x50, 0x71, 0x59, 0x2e, 0xaf, 0x4a, 0xbb, 0x44, 0x5d, 0xeb, 0x30, 0xb4, 0xa4, + 0xb4, 0x62, 0xd8, 0x87, 0x56, 0xf5, 0xc8, 0x91, 0xc3, 0xdf, 0x03, 0x69, 0xb8, 0xbb, 0xcc, 0xf0, 0xe6, 0xa5, 0xeb, + 0x5d, 0x34, 0x6d, 0xa5, 0x22, 0xe1, 0x4e, 0x6e, 0xbb, 0xa2, 0x33, 0x24, 0x88, 0x58, 0x0f, 0x1f, 0xe5, 0x87, 0x0b, + 0x86, 0x55, 0x8a, 0x36, 0xa4, 0xdb, 0x6c, 0x2e, 0x33, 0x37, 0x92, 0xb2, 0xdd, 0x9f, 0x56, 0xbd, 0x09, 0xaa, 0x75, + 0xa2, 0x36, 0xcf, 0xed, 0xb6, 0xd8, 0xba, 0x67, 0x00, 0xf5, 0x93, 0x33, 0x85, 0x23, 0x26, 0x88, 0x89, 0x56, 0x29, + 0x17, 0x61, 0xe6, 0x11, 0x0c, 0xf7, 0xd6, 0xfc, 0x84, 0xd8, 0xc7, 0x8b, 0x1c, 0x3f, 0xa6, 0x07, 0xb8, 0xe7, 0x13, + 0xb7, 0xcf, 0x69, 0x92, 0x83, 0xec, 0x88, 0xed, 0x46, 0xf1, 0x90, 0x8b, 0xee, 0x86, 0x4d, 0x25, 0x2c, 0x13, 0xe7, + 0xaa, 0xe5, 0xda, 0x18, 0x94, 0x0a, 0x45, 0x45, 0xee, 0x23, 0x65, 0xf1, 0xfb, 0x49, 0x55, 0xbe, 0x07, 0x91, 0xd8, + 0xf6, 0x49, 0x04, 0x52, 0xfd, 0xa3, 0xa0, 0x94, 0x12, 0xe6, 0xa5, 0x91, 0x67, 0xea, 0x4f, 0x28, 0x65, 0xc1, 0x43, + 0xc0, 0x17, 0x07, 0x9c, 0x0b, 0x6d, 0xfd, 0xf7, 0xb9, 0xee, 0x79, 0x3a, 0xf4, 0x92, 0xc2, 0x9d, 0xa3, 0xba, 0x4b, + 0xe4, 0x4e, 0xc9, 0xf8, 0x14, 0xa7, 0xe8, 0x41, 0xae, 0xd5, 0xb7, 0xdd, 0xbe, 0xa1, 0x6b, 0xbc, 0x7c, 0xa2, 0xf8, + 0xd6, 0xa6, 0xf2, 0x47, 0x51, 0xa7, 0xd3, 0x18, 0x9b, 0xec, 0x99, 0x72, 0x26, 0x17, 0x67, 0xb9, 0x9f, 0x1a, 0x0c, + 0x8d, 0x78, 0xc4, 0xd5, 0x12, 0xeb, 0xec, 0x3d, 0x66, 0x15, 0x27, 0xbc, 0x21, 0x0d, 0x04, 0xa8, 0xa4, 0x17, 0x1c, + 0xd1, 0x17, 0x68, 0xcb, 0xfa, 0xd2, 0xdd, 0xed, 0x47, 0x7a, 0xdc, 0xc1, 0xd1, 0x68, 0x55, 0x45, 0xbe, 0x4e, 0x0e, + 0x2a, 0xb9, 0x10, 0xa2, 0xd6, 0xf3, 0x1b, 0xd8, 0x42, 0xf3, 0x8b, 0xc9, 0x82, 0xfe, 0x2e, 0x6b, 0x4e, 0xd9, 0x7f, + 0xd6, 0xca, 0xb5, 0x21, 0x40, 0x1e, 0x17, 0xe4, 0xee, 0x15, 0xb8, 0x4c, 0x88, 0xfa, 0xc3, 0x7d, 0xcf, 0x76, 0x22, + 0xf2, 0xa1, 0x46, 0x8b, 0x45, 0xaf, 0x2a, 0x64, 0xbf, 0x3d, 0x1b, 0x77, 0xce, 0x1c, 0xf8, 0x3d, 0x2f, 0xbc, 0x92, + 0x4f, 0xfc, 0x86, 0x86, 0xf4, 0x1e, 0xd6, 0xb3, 0xa2, 0x6b, 0x16, 0x80, 0x52, 0x43, 0x0a, 0x7d, 0x0d, 0xdb, 0x73, + 0x50, 0x69, 0x9f, 0x79, 0x51, 0x8a, 0x80, 0xf1, 0x8d, 0xdd, 0x33, 0xf9, 0x54, 0x56, 0xc4, 0x25, 0x62, 0x96, 0x0e, + 0x18, 0x60, 0x64, 0x8e, 0x91, 0x51, 0xad, 0x1e, 0xaf, 0x70, 0x07, 0x8e, 0x94, 0x60, 0xab, 0xfd, 0xf3, 0xb8, 0x49, + 0xe6, 0xcf, 0x1c, 0x94, 0xfa, 0x84, 0xbc, 0xe7, 0x12, 0x82, 0xf1, 0xfc, 0xe4, 0x40, 0x75, 0xae, 0xc5, 0x06, 0x7b, + 0x3d, 0x67, 0x39, 0xce, 0xbc, 0x07, 0x46, 0xb0, 0xf5, 0xaf, 0xe2, 0x1f, 0xcb, 0x13, 0x77, 0x8b, 0x07, 0x31, 0xa9, + 0x95, 0xd3, 0xb5, 0x36, 0x5b, 0xe8, 0x6e, 0x20, 0xc3, 0x99, 0xe6, 0xcd, 0x9a, 0xba, 0xdc, 0x54, 0xc3, 0xc0, 0x6a, + 0xe6, 0x84, 0x5c, 0xcc, 0x91, 0xf8, 0x2f, 0x18, 0xe7, 0x66, 0x0d, 0x65, 0x2e, 0xc7, 0x66, 0x72, 0x09, 0xe4, 0xea, + 0x14, 0xfb, 0xcd, 0x3f, 0xfc, 0x06, 0x54, 0xc2, 0xf2, 0xa3, 0x7f, 0x88, 0xf2, 0x03, 0xcb, 0xd4, 0x1c, 0x7e, 0xe4, + 0xa8, 0x87, 0x32, 0x97, 0xc7, 0xff, 0x80, 0xac, 0xcf, 0xfa, 0xca, 0xf7, 0x93, 0xbb, 0xef, 0x9b, 0xe4, 0xcf, 0x6c, + 0x35, 0x27, 0x9b, 0x5d, 0x6b, 0xef, 0xe7, 0x7b, 0xf0, 0xbd, 0xf9, 0x3d, 0x32, 0xab, 0x85, 0xde, 0x28, 0xd4, 0x55, + 0x0f, 0x58, 0xe8, 0xd5, 0x4f, 0x7d, 0x8b, 0xd0, 0xec, 0x43, 0xac, 0xc1, 0x39, 0x44, 0x54, 0xba, 0xa7, 0x5e, 0xc3, + 0xb6, 0xbe, 0x77, 0x27, 0x06, 0xba, 0x66, 0x38, 0xef, 0x69, 0x93, 0xc8, 0x6d, 0xd4, 0xc3, 0xe6, 0xfd, 0xd9, 0x15, + 0xd6, 0x44, 0xb7, 0xba, 0x61, 0xe7, 0x52, 0xb2, 0x7c, 0x6b, 0x0f, 0xe0, 0xb1, 0x7a, 0x10, 0xf6, 0xae, 0x99, 0x3f, + 0x96, 0x03, 0x7f, 0x96, 0xf2, 0x4e, 0xb5, 0xb4, 0xfa, 0x8d, 0x6f, 0x55, 0x1f, 0xfb, 0x80, 0x37, 0xc2, 0x53, 0x41, + 0x75, 0xf6, 0x9c, 0x3d, 0x79, 0x71, 0x21, 0xbe, 0xd1, 0x0d, 0x2e, 0xa1, 0x5b, 0x15, 0x79, 0x03, 0x5f, 0xda, 0xbc, + 0xaa, 0xe0, 0x79, 0x68, 0xc9, 0x28, 0x4f, 0x9a, 0x72, 0x6c, 0xe6, 0x76, 0x31, 0x49, 0xb7, 0x32, 0x3f, 0xba, 0x51, + 0x81, 0x0b, 0x04, 0x92, 0x74, 0x65, 0x08, 0xff, 0x04, 0x27, 0x5e, 0x2b, 0xe1, 0xd3, 0x8d, 0x66, 0xbd, 0xd7, 0x55, + 0x3d, 0xee, 0x1a, 0xf4, 0x22, 0x3e, 0xb5, 0xd3, 0x5e, 0x7b, 0x84, 0xbf, 0xbf, 0x7f, 0x9e, 0x69, 0xe4, 0xbf, 0xce, + 0xec, 0xe4, 0x3f, 0xcf, 0xcd, 0xe4, 0xbf, 0xce, 0x0d, 0x9c, 0x5a, 0x7d, 0xcf, 0xbe, 0x7a, 0x61, 0x5f, 0xbd, 0xb2, + 0xc7, 0x4c, 0xed, 0xa1, 0x75, 0xad, 0x73, 0xd0, 0x8e, 0x5d, 0xcf, 0xf5, 0x96, 0x1c, 0xf0, 0xad, 0xae, 0xb2, 0x64, + 0xfd, 0xdb, 0xc9, 0xee, 0xde, 0x15, 0x53, 0xf9, 0xfe, 0x00, 0xc1, 0x93, 0xef, 0x87, 0x65, 0xad, 0xa2, 0x6c, 0xce, + 0xb4, 0x8c, 0xad, 0x74, 0xb6, 0xf7, 0x50, 0x3c, 0x9d, 0x3e, 0x42, 0xb2, 0xad, 0xe1, 0x0c, 0x55, 0x26, 0xf0, 0x1f, + 0x49, 0x3f, 0x36, 0x2a, 0xbd, 0x68, 0xbc, 0x74, 0xef, 0x48, 0xca, 0xf3, 0x17, 0x43, 0xc4, 0xc8, 0xb4, 0x9c, 0xda, + 0x3b, 0x98, 0xba, 0xc7, 0xac, 0xc5, 0xcb, 0x0e, 0xc8, 0x6c, 0xe9, 0x56, 0x52, 0x81, 0x10, 0xc6, 0xb6, 0x85, 0xff, + 0x2c, 0xc0, 0xaa, 0xfa, 0x96, 0x59, 0x3a, 0xcd, 0x9e, 0xa2, 0xa5, 0xd3, 0x0b, 0xd0, 0x20, 0x0e, 0x43, 0x99, 0xee, + 0x0a, 0x99, 0xc3, 0xf3, 0x2a, 0xae, 0x20, 0xab, 0x5f, 0x28, 0xf9, 0xef, 0x73, 0xf6, 0x70, 0xfd, 0x41, 0x40, 0x83, + 0xff, 0xdb, 0x64, 0x3b, 0xe8, 0x4f, 0x68, 0x6b, 0x9c, 0x72, 0x49, 0xa4, 0xfd, 0x5c, 0xc9, 0xdb, 0x33, 0xdf, 0x67, + 0xd7, 0xb7, 0xcf, 0x18, 0xce, 0xcf, 0x55, 0x08, 0x64, 0xce, 0xda, 0x4f, 0xf7, 0xf5, 0x31, 0x15, 0xb9, 0xeb, 0xbc, + 0xe7, 0x04, 0xab, 0xdc, 0x99, 0x52, 0x6b, 0x66, 0x72, 0x7e, 0xfe, 0xf2, 0x3f, 0xcc, 0xaf, 0x25, 0xe5, 0xa0, 0xef, + 0xf5, 0x92, 0xdd, 0xdc, 0x17, 0xca, 0xd2, 0xf3, 0x4c, 0xf9, 0xe8, 0x83, 0x4a, 0x3e, 0x1f, 0xd0, 0x74, 0x3f, 0xdd, + 0xf9, 0x8f, 0xea, 0x01, 0xdd, 0xa6, 0xf9, 0xac, 0xfb, 0x65, 0x49, 0x39, 0xe0, 0x07, 0xbd, 0x7c, 0x7e, 0x7b, 0x8b, + 0x7f, 0x6c, 0x3a, 0xdf, 0xd3, 0x05, 0x80, 0xf0, 0xfc, 0x28, 0xd9, 0x1c, 0x87, 0x9c, 0xc9, 0x9d, 0xeb, 0x0a, 0xcf, + 0xa8, 0x5a, 0x0e, 0x85, 0x5c, 0x2c, 0xf1, 0x19, 0xf9, 0x98, 0x27, 0xb2, 0xd1, 0x27, 0xb0, 0x4b, 0x99, 0xbd, 0x87, + 0x25, 0x64, 0xb7, 0xcd, 0xa7, 0x70, 0x94, 0xcf, 0x3d, 0xa2, 0x6d, 0x76, 0x1d, 0x16, 0x26, 0x6d, 0x69, 0x2a, 0x2e, + 0x3c, 0x60, 0xdf, 0x09, 0x0a, 0x83, 0xd5, 0x48, 0xed, 0x63, 0x46, 0x4e, 0x6f, 0x21, 0xba, 0xce, 0x38, 0x95, 0xbd, + 0xdf, 0xc1, 0x80, 0xa5, 0xf0, 0xf0, 0xd0, 0x7b, 0x0d, 0x68, 0x87, 0xcf, 0xb9, 0xe8, 0xa3, 0x9b, 0x50, 0xaf, 0x06, + 0xe0, 0xc4, 0x59, 0x36, 0xdd, 0x78, 0xb9, 0x9f, 0xf3, 0x87, 0xce, 0xe5, 0xca, 0xea, 0x63, 0x0d, 0x6d, 0x9b, 0xa3, + 0x33, 0xce, 0x57, 0x09, 0x2a, 0x8c, 0x30, 0x67, 0x78, 0xfe, 0xf5, 0xd4, 0x7d, 0xa0, 0x04, 0x7d, 0xa2, 0xd7, 0x9c, + 0x90, 0xd1, 0x7f, 0x22, 0x50, 0xa7, 0x93, 0xb4, 0x67, 0xf5, 0x47, 0xff, 0x1e, 0x3d, 0xb4, 0x4d, 0x8f, 0x7a, 0xab, + 0xe0, 0x3e, 0x85, 0x06, 0xa5, 0x52, 0x69, 0xac, 0x6d, 0x8e, 0x7f, 0x75, 0x72, 0x9d, 0x46, 0x6d, 0x8f, 0x70, 0x76, + 0xa6, 0xcd, 0x79, 0xdc, 0xde, 0xcc, 0xdc, 0xab, 0x17, 0x0f, 0xfd, 0x17, 0xff, 0x65, 0x18, 0x97, 0x8c, 0xb4, 0x20, + 0x37, 0xa9, 0x3d, 0xab, 0x1e, 0x1b, 0xf3, 0xaa, 0x7f, 0xab, 0x7e, 0x64, 0x54, 0xc0, 0xc6, 0x58, 0xcf, 0xe1, 0x32, + 0x3e, 0xcd, 0xeb, 0xa8, 0x28, 0x0b, 0x36, 0xc4, 0xf9, 0x70, 0xbb, 0xd7, 0xde, 0x23, 0x3b, 0xd0, 0xe4, 0xd7, 0x5f, + 0x66, 0xd3, 0x8f, 0xb6, 0xf3, 0x3b, 0x50, 0xcc, 0xfa, 0xfe, 0x94, 0x62, 0x83, 0xba, 0x02, 0xb7, 0x01, 0x97, 0xef, + 0xd8, 0x34, 0xf3, 0xaa, 0xf1, 0xbe, 0x7f, 0xc0, 0x5a, 0x12, 0x8a, 0x56, 0x0a, 0x0e, 0x8b, 0x75, 0x19, 0x45, 0x69, + 0xb1, 0x26, 0xfa, 0x55, 0xa7, 0xaa, 0xd3, 0xb6, 0x1b, 0x38, 0x37, 0x11, 0xa6, 0xaa, 0xb7, 0xa2, 0x1f, 0x22, 0xf2, + 0x36, 0x9e, 0xea, 0xab, 0x6d, 0x20, 0x86, 0xa7, 0xb8, 0x6e, 0xad, 0x7a, 0x0d, 0x67, 0x30, 0xa0, 0x27, 0x7d, 0x71, + 0x0c, 0xc1, 0xc3, 0x97, 0x01, 0x4b, 0xbd, 0xe9, 0xd2, 0xe1, 0xed, 0x63, 0xad, 0xd6, 0x9b, 0x3a, 0xaf, 0x3e, 0x55, + 0x6a, 0xd3, 0xf2, 0x74, 0x8f, 0x92, 0x21, 0xd1, 0xfe, 0xaa, 0x7c, 0xf6, 0xd3, 0x21, 0x63, 0x7b, 0x26, 0x9e, 0x2a, + 0x5e, 0x28, 0x69, 0x79, 0x57, 0xf1, 0x30, 0x8e, 0x3b, 0x29, 0x6a, 0x08, 0x56, 0xfc, 0x63, 0x18, 0x16, 0xe9, 0x9c, + 0xad, 0x0f, 0x75, 0xf0, 0x4a, 0x28, 0xe9, 0xca, 0xb5, 0xd6, 0x0a, 0x74, 0x6c, 0xe3, 0x99, 0x9f, 0x39, 0x9d, 0x09, + 0x50, 0xf9, 0x55, 0x98, 0x04, 0x14, 0x44, 0x22, 0x3c, 0x51, 0x2d, 0xbc, 0x28, 0xfa, 0x0c, 0xe6, 0xd0, 0x0c, 0xab, + 0xc1, 0x34, 0x15, 0xfd, 0x8d, 0x32, 0x30, 0xd7, 0x21, 0x82, 0x17, 0x99, 0x6b, 0xf3, 0x31, 0x0f, 0x1d, 0x8a, 0x9c, + 0x91, 0x53, 0x7f, 0xb0, 0xa4, 0xbc, 0x81, 0x3c, 0x56, 0xa1, 0xf8, 0x57, 0x30, 0x88, 0x73, 0x36, 0x00, 0x85, 0x8c, + 0x3d, 0x8f, 0x00, 0x60, 0x49, 0x3e, 0x49, 0x02, 0x6f, 0xfa, 0xbb, 0xb3, 0xf1, 0x59, 0x51, 0xb0, 0x5f, 0xed, 0x9b, + 0x49, 0xd3, 0x2c, 0xdc, 0xdd, 0xb3, 0x65, 0xf7, 0x14, 0x41, 0x04, 0x48, 0x32, 0x9b, 0x56, 0xec, 0x3d, 0xc4, 0xaf, + 0x14, 0x30, 0x03, 0x93, 0x0c, 0xe0, 0x84, 0x69, 0x49, 0xeb, 0x8a, 0x9f, 0x5c, 0x1d, 0xb6, 0x72, 0x5b, 0x28, 0xc1, + 0x22, 0x32, 0x8f, 0x6e, 0x89, 0x34, 0x4b, 0xe9, 0x9e, 0x5b, 0xeb, 0x3b, 0x19, 0xc7, 0x0f, 0x23, 0xe7, 0x89, 0xe3, + 0xf8, 0x35, 0x89, 0x68, 0x45, 0x44, 0x71, 0xba, 0x75, 0x0e, 0xd9, 0x15, 0x94, 0x8a, 0x15, 0x80, 0xaa, 0x07, 0x4c, + 0x35, 0xc1, 0x9a, 0x5f, 0xdc, 0x05, 0x7b, 0xf9, 0x40, 0x7b, 0x42, 0x71, 0x92, 0xac, 0x8c, 0xf5, 0xd0, 0x17, 0x7c, + 0x85, 0x5d, 0x2e, 0x46, 0x9b, 0x1d, 0x93, 0x24, 0xb5, 0xa2, 0x09, 0x06, 0xd4, 0x35, 0xc3, 0x69, 0xd7, 0xce, 0x3f, + 0x72, 0x9a, 0xd9, 0x74, 0x40, 0x8e, 0x71, 0x29, 0x74, 0x1b, 0xf7, 0xa4, 0x10, 0x47, 0x43, 0xe8, 0xe3, 0x30, 0x14, + 0x46, 0x3f, 0xc3, 0x66, 0x56, 0x9f, 0xf6, 0x31, 0x17, 0xb4, 0x35, 0xa6, 0xa8, 0xaa, 0xcb, 0xae, 0x29, 0x00, 0x1b, + 0x29, 0x67, 0xb0, 0x02, 0xfe, 0x78, 0xd9, 0x4e, 0x57, 0x0f, 0x37, 0x36, 0xf9, 0x0f, 0x6e, 0xf6, 0x1b, 0xe9, 0x27, + 0xf0, 0x47, 0x48, 0x66, 0xd6, 0x04, 0xd6, 0x10, 0xce, 0x4b, 0x62, 0x81, 0xe8, 0x71, 0xbe, 0x1f, 0x04, 0x7f, 0x5c, + 0x2d, 0x1e, 0x14, 0x5b, 0x98, 0xb4, 0x92, 0x73, 0xa2, 0x5e, 0x53, 0xa7, 0x8e, 0x7c, 0x90, 0x98, 0x44, 0x4c, 0x28, + 0xcf, 0xa3, 0x9f, 0x66, 0xb5, 0x9a, 0x05, 0xb5, 0x4d, 0x54, 0xec, 0x15, 0xba, 0x73, 0x3b, 0x67, 0x48, 0xb2, 0x23, + 0x38, 0xd5, 0x65, 0xd9, 0x70, 0x7b, 0xdb, 0x9a, 0x79, 0xd3, 0xf0, 0x35, 0x9d, 0xc3, 0x32, 0xee, 0x82, 0x8e, 0xb5, + 0xf1, 0x9a, 0xd8, 0x1e, 0x0c, 0x1e, 0x16, 0x4f, 0x94, 0x4e, 0xa3, 0xe9, 0xa6, 0x9e, 0x99, 0x9b, 0x7d, 0x4d, 0x5d, + 0x4d, 0xb4, 0xb3, 0x04, 0x9a, 0xcf, 0x46, 0xf1, 0x1a, 0x5b, 0xe6, 0x1a, 0x39, 0xb6, 0x96, 0xb8, 0x5b, 0xe6, 0x1d, + 0x8b, 0x91, 0xbb, 0x81, 0x51, 0x62, 0xee, 0x22, 0x86, 0x9a, 0x9f, 0xc3, 0xdc, 0x9e, 0x98, 0x40, 0xa8, 0x7f, 0x5d, + 0x4f, 0x66, 0x70, 0x31, 0x4d, 0x23, 0x19, 0xd6, 0x83, 0xd2, 0xf7, 0x44, 0x73, 0x8f, 0x78, 0xce, 0x09, 0xb6, 0x6d, + 0x2b, 0x5f, 0x7c, 0xcd, 0x18, 0xf8, 0xc0, 0x54, 0x77, 0x10, 0x5c, 0xd1, 0x5b, 0xd0, 0x3c, 0x83, 0xeb, 0x01, 0xb3, + 0x6f, 0x84, 0xf9, 0xbc, 0x10, 0x75, 0xfb, 0x44, 0x26, 0xff, 0x05, 0x84, 0x62, 0x7a, 0xab, 0xf3, 0x47, 0xfb, 0x1c, + 0xee, 0x3c, 0x64, 0x81, 0xc7, 0x92, 0x38, 0x64, 0xf8, 0xc7, 0x8d, 0xb6, 0x8c, 0x45, 0xcf, 0x9c, 0xc7, 0x2d, 0x89, + 0x09, 0xa5, 0xda, 0x5d, 0x4b, 0xa2, 0xbc, 0x16, 0x61, 0x51, 0x85, 0xd8, 0x6d, 0x15, 0x52, 0x19, 0x75, 0x45, 0xa4, + 0x8a, 0xc7, 0x59, 0x37, 0x3b, 0x43, 0x69, 0x04, 0x19, 0x0a, 0x26, 0xa8, 0x6a, 0x9f, 0x44, 0xb5, 0x14, 0xf3, 0xa0, + 0x4d, 0x13, 0xf5, 0xf0, 0xba, 0x2a, 0x63, 0xe1, 0x71, 0xd6, 0xbd, 0xed, 0x88, 0x75, 0xeb, 0x3a, 0xce, 0xb3, 0x75, + 0xe4, 0xad, 0x1c, 0x99, 0xd7, 0x15, 0x61, 0x2b, 0xc2, 0xf6, 0x41, 0x2d, 0x22, 0xca, 0x50, 0x22, 0xe1, 0xc0, 0x16, + 0xd4, 0xdb, 0x0b, 0x65, 0x36, 0x10, 0xee, 0x95, 0xf5, 0x51, 0xc9, 0x56, 0xd2, 0xb6, 0x95, 0x52, 0xb0, 0x80, 0x42, + 0x58, 0x68, 0xec, 0x39, 0xeb, 0xfe, 0xf6, 0xb9, 0x8e, 0xad, 0xff, 0xdb, 0x40, 0x6c, 0xf6, 0xef, 0xde, 0xdf, 0x8f, + 0x31, 0xc0, 0xa8, 0x7b, 0xd6, 0x15, 0xe9, 0x5b, 0x5d, 0xdf, 0x22, 0x7d, 0xf3, 0xf5, 0x4d, 0x6d, 0x4e, 0x78, 0x96, + 0xb1, 0x36, 0x6a, 0xe3, 0xce, 0x0d, 0xb4, 0x0e, 0xfb, 0x92, 0x92, 0xda, 0xef, 0xdb, 0xe5, 0xa7, 0xb1, 0x2a, 0xf3, + 0xa5, 0x99, 0x94, 0xb2, 0xe9, 0xc1, 0xa9, 0x5a, 0xd3, 0x65, 0x84, 0xd4, 0xbd, 0x18, 0x6a, 0x2b, 0xd5, 0xa9, 0xab, + 0xdb, 0x7c, 0x7c, 0x31, 0x26, 0xc6, 0x2f, 0xff, 0x0a, 0x17, 0xcf, 0x77, 0x4c, 0x87, 0xb6, 0xbc, 0xf3, 0xbe, 0xad, + 0xc4, 0xb8, 0xdc, 0x94, 0x70, 0x8e, 0x66, 0x16, 0x32, 0x46, 0x5c, 0x56, 0x9d, 0xbb, 0xe0, 0x32, 0x82, 0xc0, 0x17, + 0x74, 0x55, 0x29, 0x99, 0xa5, 0xbe, 0xad, 0xa3, 0xcf, 0xf7, 0x44, 0x95, 0xc3, 0x9f, 0x0b, 0x4c, 0xe8, 0x42, 0x57, + 0x95, 0xeb, 0x7b, 0x45, 0xc4, 0x50, 0x14, 0x71, 0xce, 0xa9, 0xf4, 0x2e, 0x2c, 0x7c, 0x53, 0x8f, 0xa7, 0x44, 0x6d, + 0x1b, 0xa4, 0x98, 0xc5, 0x98, 0x4b, 0x4b, 0x31, 0x97, 0xf2, 0x88, 0xed, 0xf3, 0x18, 0x08, 0x8b, 0x49, 0x20, 0xf2, + 0xe1, 0xca, 0x85, 0x63, 0xf9, 0x22, 0x60, 0xb0, 0x8a, 0x3e, 0x10, 0x9c, 0xdf, 0x99, 0x65, 0x17, 0x7f, 0x9b, 0x0f, + 0x47, 0x26, 0xe3, 0x2a, 0x0c, 0x81, 0x3b, 0xe2, 0xb7, 0x4e, 0x3b, 0x94, 0x01, 0xce, 0x19, 0x4d, 0x0c, 0x98, 0x75, + 0xd3, 0x34, 0x38, 0x55, 0x4d, 0x5b, 0xe5, 0x6e, 0x5e, 0x61, 0x26, 0x24, 0x31, 0x10, 0xe5, 0x66, 0xf8, 0x95, 0x1a, + 0x09, 0xc8, 0xf9, 0xfb, 0x2e, 0xce, 0xc9, 0x29, 0x85, 0x13, 0x95, 0x4c, 0x82, 0xaf, 0x1d, 0x78, 0x87, 0xba, 0x15, + 0x2f, 0xc4, 0x71, 0x9a, 0xf2, 0xc8, 0x04, 0xf4, 0x40, 0xed, 0x40, 0x94, 0x55, 0x4b, 0x8e, 0xc2, 0x44, 0x42, 0x28, + 0x85, 0x8f, 0xf8, 0x4c, 0xe6, 0xa2, 0xaa, 0x35, 0xaf, 0xfa, 0x82, 0x6e, 0x41, 0x62, 0x40, 0x54, 0x11, 0x22, 0xc9, + 0xa4, 0x5a, 0x37, 0x54, 0x58, 0x2c, 0x5d, 0x5a, 0x0c, 0xe2, 0x04, 0xc9, 0x3c, 0x2e, 0x04, 0xff, 0x32, 0xb0, 0xb7, + 0x1c, 0x6f, 0x7a, 0xef, 0x06, 0x75, 0x35, 0x32, 0x93, 0x9d, 0xf7, 0xe6, 0x45, 0xaf, 0xa4, 0x25, 0x97, 0x0f, 0x89, + 0x42, 0x7f, 0x5f, 0xb7, 0x9d, 0x65, 0x35, 0x91, 0x82, 0x79, 0x59, 0x54, 0x17, 0x95, 0xed, 0xa5, 0x95, 0x0b, 0x3c, + 0xee, 0x1e, 0x26, 0x48, 0xf0, 0xdd, 0x66, 0xf2, 0x14, 0xb8, 0x48, 0xd6, 0xd8, 0x72, 0x9f, 0x48, 0xa3, 0xa3, 0xdb, + 0x28, 0x59, 0x1d, 0xd9, 0xda, 0x3f, 0x41, 0x94, 0xe4, 0xcc, 0x5a, 0x89, 0xae, 0xff, 0x59, 0xea, 0x26, 0x17, 0x85, + 0xb5, 0x38, 0xe4, 0x20, 0x6e, 0x3a, 0x0b, 0x61, 0x4a, 0xf6, 0x56, 0x60, 0x23, 0x44, 0x86, 0x8b, 0x49, 0x16, 0xe4, + 0xdc, 0x8b, 0x1f, 0x1c, 0x29, 0xf8, 0x8f, 0x48, 0x0d, 0x2d, 0x99, 0xd2, 0xff, 0x70, 0x1d, 0xe1, 0x5b, 0x19, 0x0e, + 0x92, 0xd9, 0x8b, 0x17, 0xdc, 0x96, 0x9e, 0x77, 0xcc, 0x06, 0x49, 0xf8, 0xfd, 0xec, 0xf2, 0x59, 0x6f, 0x0f, 0xe2, + 0x0f, 0x65, 0x42, 0xf0, 0x45, 0x47, 0xb5, 0x8b, 0xa7, 0x51, 0x71, 0x3a, 0x94, 0x5f, 0x8f, 0x4f, 0xcd, 0xef, 0xed, + 0xf2, 0x02, 0x7e, 0xfa, 0xe5, 0x9c, 0x03, 0x33, 0xf0, 0x85, 0xb6, 0x1a, 0x6b, 0xd8, 0x0b, 0x83, 0x3d, 0x86, 0x92, + 0x45, 0x3a, 0xb4, 0x9f, 0x8d, 0x30, 0x1f, 0xba, 0xde, 0x66, 0xfd, 0x1d, 0xc3, 0xac, 0xce, 0x30, 0xbe, 0xb1, 0xaf, + 0x6a, 0x65, 0x76, 0xdb, 0xb0, 0xa7, 0x92, 0x9d, 0xf6, 0xe5, 0x06, 0x53, 0x37, 0x67, 0x6f, 0x43, 0xcd, 0xe5, 0x9b, + 0x51, 0x5c, 0x79, 0x33, 0x0f, 0x4b, 0x08, 0x18, 0x33, 0xcc, 0xb9, 0x22, 0xe7, 0x5a, 0xd9, 0x0f, 0x96, 0xd8, 0x1f, + 0xb6, 0x42, 0xda, 0x54, 0x45, 0x32, 0xb3, 0x81, 0x8f, 0xb5, 0x5a, 0x7b, 0x5a, 0x0f, 0xcc, 0xd2, 0x89, 0xe9, 0x58, + 0xb3, 0xb4, 0x82, 0xa1, 0x54, 0x68, 0xb5, 0xd4, 0x1d, 0xae, 0xd2, 0x97, 0x5a, 0x5e, 0xf2, 0x84, 0x84, 0xfd, 0x04, + 0xb2, 0x13, 0xdf, 0xc3, 0x3d, 0x69, 0xfb, 0xce, 0xac, 0xb1, 0x31, 0x95, 0x25, 0xca, 0x93, 0x72, 0x05, 0x65, 0xea, + 0x1d, 0x60, 0xa8, 0xa8, 0x31, 0x36, 0x74, 0x87, 0x06, 0x6d, 0x34, 0x0e, 0xf7, 0x85, 0xeb, 0x6d, 0x41, 0xfe, 0xa3, + 0xbe, 0xcf, 0xc9, 0x57, 0x67, 0xb3, 0xa8, 0xa7, 0xf5, 0x56, 0x63, 0xe4, 0xc8, 0x78, 0x80, 0xd7, 0x9b, 0x93, 0x2a, + 0x5b, 0x30, 0x64, 0xaf, 0xa1, 0xfe, 0xa9, 0x99, 0xba, 0x90, 0x76, 0x62, 0x46, 0x94, 0xf1, 0x20, 0x92, 0x04, 0x3d, + 0x59, 0x0f, 0x82, 0x6b, 0x96, 0x85, 0xb5, 0xc9, 0xc8, 0x3d, 0x18, 0xce, 0x91, 0x8a, 0xe8, 0x12, 0x8a, 0xe2, 0x9c, + 0xcd, 0xe3, 0x13, 0x86, 0x1c, 0xe5, 0xb1, 0x58, 0x96, 0x2c, 0xa8, 0xf7, 0x2d, 0x8c, 0xd4, 0x64, 0x9b, 0x8e, 0xa5, + 0xe4, 0xb2, 0x03, 0x38, 0xb1, 0xa3, 0xed, 0x3c, 0x61, 0x4e, 0x6d, 0x5d, 0x82, 0x9d, 0xec, 0xd4, 0xdc, 0xad, 0xc8, + 0x00, 0xc9, 0x03, 0x21, 0x0a, 0x03, 0x3e, 0xdf, 0xaf, 0x08, 0x50, 0xcd, 0x71, 0x8a, 0xc4, 0x1f, 0x84, 0xf2, 0xc7, + 0x13, 0x49, 0xa7, 0xc2, 0x72, 0xd7, 0x33, 0xbc, 0x39, 0x0e, 0xa0, 0x95, 0x7a, 0xb2, 0xf9, 0x41, 0x89, 0xb2, 0x91, + 0xbf, 0x8a, 0xb5, 0x8e, 0x18, 0x22, 0x1c, 0xf8, 0xcd, 0x6a, 0x43, 0xd2, 0x78, 0xb3, 0xba, 0x38, 0x1a, 0x85, 0x42, + 0x57, 0x07, 0xdc, 0x47, 0x2a, 0x00, 0xfb, 0x66, 0xc3, 0x53, 0x37, 0x4e, 0x77, 0x51, 0x96, 0x25, 0x9c, 0x06, 0x13, + 0xf8, 0x67, 0xd3, 0xb5, 0xba, 0x85, 0x8b, 0x35, 0xcd, 0xc4, 0x47, 0x71, 0x3a, 0xdd, 0xd7, 0xbd, 0x0e, 0x01, 0xff, + 0x72, 0x89, 0x1d, 0xd2, 0x27, 0xa4, 0x8a, 0x83, 0x11, 0x73, 0x74, 0x8c, 0x4b, 0x9a, 0xe9, 0xa9, 0x21, 0x77, 0x97, + 0xca, 0x47, 0x28, 0x07, 0xaa, 0x73, 0x3c, 0x3d, 0x64, 0x37, 0xc3, 0x31, 0x42, 0x6d, 0x67, 0x88, 0x2b, 0x03, 0xf5, + 0x04, 0xc8, 0x95, 0x04, 0xc2, 0x32, 0xcf, 0x67, 0x48, 0xdf, 0x33, 0x66, 0x02, 0x1a, 0x3a, 0x50, 0x6e, 0x7a, 0x52, + 0xe6, 0x90, 0x7a, 0xa8, 0x83, 0x10, 0x13, 0x1e, 0xf4, 0xb2, 0xa9, 0x69, 0x65, 0x1d, 0x8d, 0x50, 0x69, 0x42, 0x41, + 0xfc, 0x02, 0xa7, 0xe8, 0xab, 0x21, 0xf2, 0x97, 0x91, 0xf2, 0x3a, 0x2b, 0xf3, 0x86, 0xf4, 0x12, 0x2d, 0xb2, 0xfa, + 0xc6, 0xc8, 0xec, 0x48, 0x5d, 0x56, 0x7a, 0xed, 0x05, 0x60, 0x1e, 0x0e, 0xc1, 0x89, 0x44, 0xc4, 0x3c, 0x89, 0x26, + 0xb2, 0xa9, 0x50, 0xfe, 0xcc, 0xee, 0x49, 0x01, 0x5c, 0xce, 0x23, 0x41, 0x13, 0x81, 0x8f, 0x1d, 0x00, 0x67, 0x66, + 0x10, 0xe0, 0x6c, 0x35, 0x69, 0x04, 0xc6, 0x5c, 0x2b, 0x6f, 0x35, 0xfb, 0x98, 0x11, 0xe5, 0xb8, 0x98, 0x1b, 0xd9, + 0x5d, 0x93, 0xfb, 0x53, 0xcc, 0x13, 0x1b, 0x73, 0xf8, 0xb9, 0xf6, 0x2a, 0x99, 0xfe, 0x65, 0x06, 0x3e, 0x29, 0x51, + 0x7d, 0x69, 0x50, 0xbc, 0x6e, 0xe3, 0x82, 0x36, 0xda, 0x35, 0xe4, 0xb2, 0xe8, 0x30, 0x58, 0xae, 0xfd, 0xbf, 0x7e, + 0x7b, 0x3e, 0xef, 0x2b, 0xe7, 0x63, 0x76, 0xc5, 0x7d, 0x70, 0x58, 0x33, 0xe4, 0xfc, 0xba, 0x2e, 0x9e, 0xe3, 0xfb, + 0xf5, 0xb7, 0xb9, 0xf1, 0x74, 0x77, 0x10, 0x64, 0x2e, 0xa4, 0x3e, 0xb3, 0x84, 0xe8, 0xc3, 0xd0, 0xe2, 0xd9, 0x18, + 0x55, 0xa2, 0xf1, 0xa5, 0x43, 0x8a, 0x65, 0x8b, 0xa7, 0x27, 0x81, 0x78, 0x39, 0xdc, 0x93, 0x2d, 0x10, 0x2b, 0x4a, + 0x84, 0x39, 0x9d, 0x88, 0x34, 0x8e, 0x80, 0xf1, 0x4a, 0xdc, 0x33, 0x04, 0x46, 0x1a, 0x65, 0xd6, 0xb4, 0xff, 0xd8, + 0x88, 0xec, 0x73, 0x48, 0x34, 0x19, 0x36, 0xe5, 0x93, 0xcd, 0xa8, 0xbd, 0x12, 0x09, 0x45, 0xc3, 0xba, 0x9f, 0xa6, + 0x19, 0x95, 0xf7, 0x62, 0x1c, 0x12, 0x87, 0x70, 0xd2, 0xbb, 0xdf, 0xaf, 0xbf, 0x95, 0x3c, 0xfc, 0x1e, 0xf6, 0x1f, + 0xbf, 0xf8, 0x1f, 0xbf, 0x87, 0x7b, 0xf2, 0x8b, 0x9f, 0xfc, 0x1e, 0xf2, 0xc9, 0x2f, 0xe2, 0xa5, 0xd2, 0xf4, 0x95, + 0xdd, 0x79, 0x30, 0x16, 0x0c, 0xe5, 0xb2, 0x8c, 0x6c, 0xa5, 0x0a, 0x7e, 0xf1, 0x21, 0xe1, 0x3e, 0x17, 0x48, 0xc9, + 0xa9, 0x64, 0x82, 0x95, 0xa8, 0x64, 0x65, 0xe8, 0x14, 0xd4, 0xa7, 0x01, 0x3e, 0x4a, 0xbd, 0xfd, 0x9c, 0x7f, 0xba, + 0x35, 0x92, 0xc6, 0x40, 0x3c, 0x19, 0x82, 0xae, 0xdc, 0x99, 0x5b, 0xcf, 0x4d, 0x49, 0x18, 0x65, 0x39, 0x62, 0xb4, + 0xa2, 0xd2, 0x8e, 0xb3, 0x44, 0xef, 0x3c, 0x18, 0x34, 0x13, 0xf4, 0xed, 0x7b, 0xe8, 0xa4, 0xb0, 0x3b, 0x43, 0x01, + 0x72, 0x96, 0x95, 0x02, 0x1e, 0xd8, 0xc7, 0x5e, 0x3c, 0x47, 0x5a, 0x79, 0x35, 0xa9, 0xa2, 0x06, 0xd7, 0xe4, 0x60, + 0x8c, 0x11, 0x12, 0xf7, 0xf4, 0x2f, 0xf9, 0x98, 0x9c, 0xb9, 0x79, 0xab, 0x59, 0xb8, 0xc7, 0xd4, 0x72, 0x40, 0x73, + 0x62, 0x54, 0xcd, 0x0c, 0x5b, 0x44, 0xad, 0x59, 0xcd, 0x99, 0x45, 0x9c, 0x2c, 0xc5, 0xd6, 0x55, 0xd8, 0xf3, 0x1e, + 0x3f, 0xe5, 0x1f, 0xe6, 0x34, 0x57, 0x8f, 0x34, 0xd8, 0x17, 0x19, 0xbb, 0x0f, 0xae, 0x70, 0x5a, 0x6b, 0x30, 0x3d, + 0xe1, 0x6c, 0x2d, 0xae, 0xaf, 0xa6, 0xf0, 0x05, 0x69, 0x75, 0xcf, 0xa5, 0x88, 0x46, 0x37, 0xc9, 0xc4, 0x86, 0xa1, + 0xb5, 0xd9, 0x7d, 0x6d, 0xa1, 0xd1, 0x66, 0x05, 0xad, 0x59, 0xd9, 0xfd, 0xe6, 0x8d, 0x36, 0xb1, 0xc9, 0x9c, 0x05, + 0x99, 0xa8, 0xba, 0x09, 0xd2, 0xa6, 0xc0, 0x27, 0x27, 0x2b, 0x8c, 0x47, 0x20, 0x8b, 0xdc, 0xe6, 0x64, 0x7f, 0xe9, + 0xa8, 0x65, 0x54, 0x95, 0x10, 0x89, 0xcf, 0xca, 0x2d, 0xe4, 0x12, 0x74, 0xbc, 0x38, 0x10, 0xc1, 0xe5, 0x30, 0x2e, + 0x95, 0x9a, 0x46, 0xdb, 0x35, 0xda, 0x5b, 0xc8, 0x73, 0xa8, 0xcb, 0x4f, 0x83, 0x0d, 0x61, 0x88, 0x6a, 0xf4, 0xa1, + 0xcd, 0x3c, 0xbd, 0xa6, 0x4b, 0xfb, 0xf5, 0xf7, 0x01, 0x38, 0x7a, 0xb1, 0xbd, 0x90, 0xcc, 0x5d, 0x9f, 0x92, 0x48, + 0x20, 0x51, 0xf2, 0x05, 0xa0, 0x07, 0x80, 0x5e, 0xf5, 0x12, 0x56, 0x03, 0x06, 0xad, 0x54, 0x81, 0x9e, 0x29, 0x78, + 0x00, 0x32, 0x43, 0xcb, 0x41, 0xe5, 0x8f, 0x48, 0xf0, 0xb5, 0x43, 0xb2, 0x98, 0xf0, 0xd2, 0x50, 0xbc, 0x8e, 0x09, + 0xed, 0x7c, 0x98, 0x9a, 0x5e, 0x22, 0xf7, 0x14, 0x29, 0x1d, 0xb1, 0x45, 0x3f, 0xfd, 0xf4, 0xaa, 0xa7, 0x85, 0x93, + 0x3c, 0xb2, 0x7c, 0xac, 0xfd, 0x5b, 0xd6, 0xb6, 0xab, 0xea, 0x8f, 0x4c, 0x49, 0x1d, 0x68, 0x43, 0x28, 0xd7, 0x33, + 0x65, 0x4f, 0xe9, 0x2b, 0xd8, 0x59, 0x0c, 0x8b, 0x5e, 0xbb, 0xcf, 0x6a, 0x73, 0xf8, 0xd0, 0x45, 0x0f, 0x44, 0x13, + 0x6e, 0x5f, 0x23, 0x81, 0xe6, 0x12, 0xc1, 0x62, 0x78, 0x46, 0x97, 0x76, 0xe3, 0x43, 0x4e, 0x51, 0x10, 0xab, 0xc0, + 0x87, 0x74, 0xfd, 0x84, 0x86, 0x0c, 0x65, 0xbb, 0x8d, 0x02, 0x67, 0x35, 0xd0, 0x7c, 0x5f, 0xe3, 0xb0, 0x57, 0x27, + 0x60, 0x6d, 0xc9, 0x7c, 0xb5, 0x69, 0xa3, 0xd8, 0x6b, 0x2e, 0xaf, 0xf6, 0xda, 0x0a, 0x81, 0x3f, 0x17, 0x9f, 0xfd, + 0xed, 0x79, 0x52, 0x7d, 0x9f, 0x9f, 0x94, 0xde, 0xdb, 0xac, 0xfa, 0xa0, 0x35, 0xd8, 0xfb, 0xe3, 0x94, 0xf7, 0x91, + 0xe5, 0x30, 0x29, 0x3d, 0x1f, 0x8d, 0x6a, 0xb1, 0x7b, 0x4d, 0xe6, 0xf1, 0x61, 0x25, 0x54, 0xb3, 0xa9, 0x91, 0x07, + 0xf7, 0x5a, 0x73, 0xa1, 0xef, 0x51, 0xa0, 0xba, 0xd7, 0xc2, 0xa9, 0xba, 0x2a, 0x25, 0x88, 0xc9, 0xc8, 0x68, 0xa6, + 0xd9, 0x58, 0x6f, 0x03, 0xf3, 0x71, 0xaa, 0x5f, 0xf0, 0x27, 0x52, 0x72, 0xd8, 0xed, 0xac, 0x2c, 0x4a, 0xc5, 0x24, + 0x25, 0xa0, 0xc5, 0xf6, 0x6f, 0x71, 0x70, 0x60, 0x50, 0xb5, 0xea, 0x3c, 0x60, 0x24, 0xf6, 0xc5, 0xe2, 0x23, 0x50, + 0xf1, 0x5b, 0x3b, 0xc8, 0xec, 0x86, 0x8f, 0x65, 0x29, 0x2c, 0xfc, 0x20, 0x4a, 0xa5, 0x9e, 0x80, 0x40, 0x4d, 0x9d, + 0xbc, 0x29, 0x41, 0xb0, 0x7c, 0x33, 0xa7, 0x8d, 0xbd, 0x30, 0x5d, 0x1d, 0xc8, 0xb5, 0x69, 0x24, 0x86, 0x22, 0xfe, + 0xc9, 0xb1, 0xe1, 0x3a, 0x9a, 0xb0, 0xea, 0x89, 0xe5, 0x5e, 0x94, 0x07, 0xa1, 0x41, 0xe8, 0x90, 0xa7, 0xca, 0x6d, + 0x19, 0xd6, 0xe7, 0x2d, 0x2f, 0x4f, 0xfa, 0x17, 0x1e, 0x1f, 0x2c, 0x3a, 0x7f, 0x42, 0x33, 0x17, 0x02, 0x29, 0xa8, + 0x62, 0x93, 0xc2, 0x1d, 0xa1, 0x2a, 0xcb, 0x9d, 0x97, 0x15, 0xcd, 0x6b, 0x33, 0x0f, 0xd2, 0xd5, 0x47, 0x05, 0x99, + 0x4b, 0x28, 0x09, 0xa5, 0x2e, 0x60, 0x0a, 0xa3, 0x2c, 0xde, 0xe8, 0xbb, 0xf5, 0x0f, 0xbb, 0x94, 0x84, 0x03, 0x3e, + 0x86, 0xc1, 0x4c, 0xe0, 0xdf, 0x0f, 0x29, 0x0d, 0xdc, 0xd4, 0xba, 0x16, 0xca, 0x18, 0xd2, 0x0a, 0xc1, 0x7c, 0x24, + 0xd1, 0x60, 0x82, 0xef, 0x3b, 0x83, 0x22, 0x27, 0x05, 0x2b, 0x8d, 0xdf, 0x8c, 0x7b, 0x0c, 0x1d, 0x67, 0xc6, 0x3b, + 0x3b, 0x5d, 0xb1, 0xb7, 0xe6, 0xb8, 0x3a, 0x84, 0x80, 0xcb, 0xb1, 0xdc, 0xca, 0xba, 0x20, 0xeb, 0x18, 0xf2, 0x2c, + 0xdc, 0x22, 0x71, 0xc9, 0x08, 0x3d, 0xa5, 0x43, 0x23, 0x95, 0x61, 0x09, 0x4e, 0x9b, 0xe1, 0x03, 0xdb, 0xb8, 0x82, + 0xba, 0x9d, 0x9d, 0x06, 0xea, 0xf6, 0x0a, 0x78, 0xb0, 0x6b, 0x42, 0x89, 0xd2, 0xc8, 0xaa, 0x80, 0x06, 0x23, 0xa0, + 0x2d, 0x0b, 0x94, 0x6a, 0x22, 0x26, 0x1a, 0x85, 0x51, 0x22, 0xb5, 0x94, 0xb2, 0xa3, 0xe9, 0x77, 0x5d, 0x24, 0x93, + 0x64, 0x1d, 0x8a, 0x83, 0x9e, 0x98, 0x24, 0xb5, 0x5a, 0x97, 0x2d, 0x3e, 0x1c, 0x88, 0xfd, 0x22, 0x95, 0x9e, 0xd8, + 0xdb, 0x69, 0x81, 0xdc, 0xec, 0x7b, 0x1a, 0x52, 0x43, 0xa3, 0xb3, 0xad, 0xd1, 0x79, 0x79, 0x2a, 0x9b, 0x1f, 0x74, + 0xd4, 0x72, 0xeb, 0xc6, 0x98, 0xa2, 0x0a, 0xa8, 0x3f, 0xd6, 0x82, 0xf4, 0xfd, 0x4b, 0xa1, 0x4e, 0x50, 0x34, 0x4c, + 0xed, 0x7b, 0x2c, 0x46, 0xba, 0x4e, 0xf3, 0x48, 0x48, 0x70, 0xef, 0x09, 0x02, 0x3c, 0x22, 0x4f, 0x23, 0x19, 0xd3, + 0x09, 0xc2, 0x10, 0x91, 0x75, 0xb2, 0xe6, 0x7d, 0x6e, 0xfd, 0xfe, 0x92, 0xbc, 0xef, 0xe2, 0x06, 0x93, 0xab, 0xfd, + 0x94, 0xde, 0xfb, 0xed, 0x76, 0x68, 0xed, 0x71, 0x12, 0x37, 0xe3, 0x85, 0xa5, 0xf6, 0x58, 0xd8, 0xff, 0x66, 0xf3, + 0xa9, 0x53, 0xa5, 0xb7, 0x6b, 0x0d, 0x69, 0x3c, 0xb3, 0xc6, 0x66, 0x3f, 0x09, 0xda, 0x91, 0x0b, 0xb4, 0x13, 0x3b, + 0x39, 0xab, 0x20, 0xa1, 0x21, 0x31, 0xa6, 0xb6, 0x73, 0x08, 0xd0, 0x8c, 0x75, 0xe6, 0xf6, 0xad, 0xf6, 0xed, 0x29, + 0x27, 0x65, 0x80, 0xf2, 0x52, 0xf8, 0x67, 0xdb, 0x49, 0x89, 0x7d, 0x1c, 0x63, 0x6c, 0x05, 0xf1, 0x21, 0x81, 0x54, + 0x05, 0x13, 0x5a, 0x4d, 0x1e, 0xd0, 0xc5, 0x29, 0x1d, 0x7f, 0xa6, 0x1f, 0x3e, 0xc0, 0xea, 0x6b, 0x1e, 0xd9, 0x66, + 0x0f, 0x1c, 0x63, 0x4a, 0xbd, 0xce, 0x0e, 0x58, 0x3f, 0xa5, 0xf7, 0xba, 0x58, 0x1b, 0x43, 0xca, 0x96, 0x5c, 0xbb, + 0xb6, 0x08, 0x99, 0x30, 0x64, 0x5d, 0x47, 0x28, 0xac, 0xe0, 0xfc, 0x86, 0x9c, 0xc0, 0xea, 0xfd, 0x9c, 0x2b, 0xf5, + 0x2c, 0x52, 0xb3, 0x4c, 0xd0, 0xce, 0x8e, 0x1c, 0xe9, 0x3c, 0xa9, 0xff, 0x6f, 0x25, 0x84, 0xe0, 0xd2, 0x9a, 0x6e, + 0x4b, 0xa8, 0x93, 0xfc, 0xe4, 0x2a, 0x5a, 0xc0, 0x73, 0x37, 0xca, 0x1f, 0xc9, 0xea, 0x6d, 0x82, 0x67, 0x83, 0x48, + 0x60, 0xc3, 0x72, 0x4a, 0x54, 0xc3, 0x6a, 0xab, 0x5b, 0xf8, 0xee, 0xd1, 0xed, 0x8d, 0x62, 0x0c, 0x15, 0x4e, 0x7e, + 0x0e, 0x94, 0x54, 0xdc, 0xeb, 0x92, 0x5a, 0x47, 0xe5, 0x7f, 0xa3, 0xb8, 0xc2, 0x49, 0x7c, 0x73, 0x93, 0xb3, 0x81, + 0x47, 0xdd, 0x53, 0x43, 0xb2, 0xbf, 0x5f, 0xa8, 0x10, 0x6d, 0xb4, 0x8e, 0x19, 0xa0, 0x0a, 0x1f, 0x41, 0x2e, 0x47, + 0xbe, 0x9f, 0x75, 0xe5, 0x17, 0xf9, 0xa5, 0x6f, 0xcf, 0x0d, 0x62, 0xcd, 0x5c, 0xa8, 0x59, 0xca, 0x28, 0xbf, 0x0c, + 0x6f, 0xe2, 0xb6, 0xc8, 0x20, 0xab, 0xcf, 0x6b, 0xec, 0x1d, 0x62, 0xe5, 0xd8, 0x6d, 0x4f, 0x58, 0x41, 0x4c, 0x90, + 0x2e, 0xc1, 0x53, 0x5d, 0x50, 0xc4, 0x28, 0x35, 0x67, 0x38, 0xd5, 0xa2, 0xba, 0x50, 0xce, 0xd5, 0x7a, 0x49, 0x05, + 0x84, 0xea, 0x7b, 0x2a, 0xe7, 0x25, 0x30, 0xec, 0x9d, 0xc7, 0x7e, 0xb0, 0x3c, 0x6f, 0xea, 0x5a, 0x99, 0x9d, 0xa6, + 0xeb, 0x1e, 0x2a, 0x1c, 0x68, 0x53, 0x7a, 0x4b, 0x57, 0xf3, 0x7c, 0xad, 0x16, 0xf8, 0x6d, 0x68, 0xc1, 0x33, 0xe7, + 0x13, 0xd0, 0x57, 0xc9, 0x23, 0x89, 0x3b, 0x4b, 0xd7, 0xae, 0x80, 0x16, 0x26, 0x93, 0xc0, 0x83, 0xd3, 0x7d, 0xad, + 0x92, 0xb5, 0x91, 0x70, 0x4c, 0x08, 0x03, 0x72, 0xd6, 0x07, 0xdb, 0x6e, 0x8c, 0x5c, 0xa2, 0xf6, 0xfa, 0x91, 0x86, + 0x16, 0x59, 0x3f, 0x68, 0xd2, 0xf3, 0x40, 0x51, 0x39, 0xaa, 0xde, 0xdc, 0x29, 0xa3, 0x87, 0x98, 0x27, 0x8c, 0xda, + 0xc4, 0xa0, 0x91, 0x1e, 0xa8, 0x33, 0x42, 0xce, 0x4f, 0x6c, 0x52, 0x7d, 0x8d, 0x0f, 0x9f, 0x09, 0x61, 0xac, 0x36, + 0x0d, 0xf9, 0x3c, 0x81, 0xf6, 0x6c, 0xe9, 0xb8, 0x53, 0x43, 0x86, 0xd7, 0xa6, 0xcb, 0x21, 0x19, 0x0b, 0x2e, 0x9b, + 0x21, 0x0c, 0x6a, 0x25, 0xe3, 0x34, 0xb1, 0xcf, 0xa9, 0x1b, 0x49, 0x57, 0xe5, 0x1a, 0x02, 0x1c, 0x77, 0x9c, 0x49, + 0xb3, 0xd8, 0x72, 0x8b, 0x92, 0xab, 0x4b, 0x4d, 0x88, 0x2d, 0x9a, 0x88, 0x12, 0x00, 0x7a, 0x39, 0xec, 0x23, 0x20, + 0xe1, 0xdb, 0x0a, 0xe7, 0xe6, 0x89, 0x2d, 0xad, 0x5c, 0x73, 0x41, 0x61, 0xb8, 0xa3, 0xaf, 0xf7, 0x62, 0x53, 0x11, + 0x7b, 0x06, 0xf3, 0xd0, 0x6c, 0x2c, 0xb3, 0xf9, 0x23, 0xdf, 0x9f, 0x87, 0x66, 0x20, 0xfd, 0x03, 0x16, 0xc4, 0x7f, + 0x0d, 0x15, 0xe2, 0x19, 0x17, 0xe4, 0x0f, 0xb4, 0x92, 0x86, 0x2f, 0x58, 0xb7, 0xd3, 0x95, 0x9f, 0x4d, 0x9f, 0xaa, + 0x05, 0x04, 0xe5, 0x81, 0x5c, 0x48, 0x73, 0x03, 0x6b, 0xbc, 0xc1, 0x8a, 0xf5, 0xc6, 0x0e, 0x49, 0x60, 0xeb, 0xe9, + 0x48, 0x26, 0x8d, 0x74, 0x8a, 0x07, 0xbe, 0xd5, 0xb1, 0xfd, 0xad, 0xce, 0x29, 0xbd, 0x29, 0x4f, 0x9b, 0xe6, 0xad, + 0x78, 0xe8, 0x59, 0x5b, 0x45, 0x98, 0x30, 0x78, 0x2a, 0x9c, 0xf0, 0x7a, 0x2f, 0x57, 0xd9, 0x35, 0x7c, 0x06, 0x3f, + 0xf4, 0x6c, 0x30, 0x17, 0x36, 0xd7, 0x22, 0x41, 0x07, 0x61, 0xbc, 0xf1, 0xf9, 0x11, 0x46, 0xa6, 0x4b, 0xe9, 0x15, + 0xfd, 0x68, 0x90, 0x28, 0xde, 0xae, 0xbf, 0xdd, 0x7d, 0x8f, 0xe0, 0xe0, 0xde, 0x82, 0x6c, 0x4c, 0x9b, 0xbd, 0x61, + 0x0f, 0x69, 0x51, 0xd5, 0x18, 0x23, 0xa4, 0x42, 0x1c, 0x43, 0xc4, 0xe5, 0xf6, 0x55, 0x5b, 0x1e, 0xdc, 0xf2, 0x4b, + 0x9e, 0x51, 0xf8, 0x28, 0xfe, 0xce, 0x7c, 0xd7, 0x47, 0xe8, 0x8a, 0xeb, 0x3c, 0x87, 0xf8, 0xda, 0x6f, 0xaf, 0x91, + 0x10, 0x25, 0xe1, 0x7f, 0x06, 0x0f, 0x30, 0x33, 0x5e, 0xac, 0x01, 0x7b, 0x5e, 0xdd, 0xc8, 0x49, 0x70, 0x5f, 0x30, + 0xf4, 0xb6, 0xf9, 0x42, 0x3f, 0x9e, 0x92, 0x78, 0x8b, 0xb6, 0x88, 0x5d, 0xa9, 0x83, 0x19, 0x3b, 0x71, 0xcd, 0x87, + 0xc9, 0xec, 0x3f, 0x46, 0x58, 0x00, 0x84, 0x82, 0x5a, 0x0b, 0x3f, 0x6d, 0x05, 0x70, 0xab, 0xff, 0x60, 0xa4, 0xc0, + 0x4d, 0xf4, 0xc4, 0xcf, 0x76, 0x4f, 0xb0, 0x09, 0x4e, 0xc4, 0x5e, 0x91, 0xb6, 0xe7, 0x40, 0xaf, 0x56, 0x35, 0x84, + 0xea, 0xd6, 0xe9, 0x20, 0x74, 0xb1, 0x28, 0x8c, 0xf5, 0x3a, 0x0a, 0x6c, 0x56, 0x2d, 0xab, 0x0e, 0x43, 0x6d, 0x57, + 0xa1, 0xf6, 0x24, 0x1b, 0x16, 0x25, 0x2a, 0x72, 0xe3, 0x78, 0x53, 0xac, 0x03, 0xea, 0xd7, 0x7e, 0x6d, 0x82, 0x5b, + 0x2f, 0x78, 0x74, 0x2c, 0xc8, 0xd5, 0x14, 0x31, 0x78, 0x81, 0xc8, 0xe0, 0x55, 0x59, 0xa0, 0x93, 0x5e, 0xb8, 0xef, + 0x9b, 0x4f, 0x75, 0x61, 0xe9, 0x6e, 0x1a, 0x3e, 0xfb, 0x79, 0xf4, 0xab, 0xe1, 0xeb, 0x25, 0x63, 0x64, 0x5c, 0x24, + 0x2d, 0x7a, 0xea, 0x1c, 0x97, 0x6b, 0x30, 0x7b, 0x68, 0x75, 0xcc, 0xb0, 0xfb, 0x74, 0xa5, 0xc5, 0x18, 0xbf, 0x13, + 0xc5, 0xb4, 0x07, 0xcb, 0x32, 0x13, 0xf7, 0xf4, 0x82, 0x00, 0x69, 0x2d, 0xf1, 0xa6, 0xd5, 0x5b, 0x6d, 0x7d, 0x36, + 0x2d, 0x83, 0xe8, 0x1b, 0x8b, 0x4c, 0xdd, 0x2c, 0x64, 0xb9, 0x4c, 0xb1, 0x46, 0xab, 0xb0, 0x2f, 0x97, 0x47, 0x37, + 0x7d, 0x5d, 0x1a, 0xff, 0x16, 0x55, 0x4f, 0x86, 0x44, 0xd2, 0x12, 0xa5, 0x52, 0x81, 0x93, 0x2e, 0xec, 0x62, 0x4d, + 0x47, 0x2d, 0xd7, 0x89, 0x33, 0xde, 0x8f, 0x97, 0x0e, 0xcb, 0x1f, 0x9f, 0x0b, 0x42, 0xad, 0xfc, 0x3f, 0x10, 0xfb, + 0xec, 0x70, 0x32, 0xa0, 0x9c, 0xc2, 0x19, 0xd9, 0xfd, 0x0f, 0xba, 0xda, 0x15, 0x40, 0xcd, 0x30, 0x7a, 0xb9, 0x54, + 0x38, 0x54, 0x94, 0x7e, 0x3a, 0xe9, 0xc6, 0x50, 0x58, 0x5f, 0xad, 0x85, 0xd7, 0x5e, 0x52, 0xd1, 0x25, 0xfe, 0x4a, + 0xfa, 0x98, 0x70, 0x2a, 0x65, 0x87, 0xfa, 0xaa, 0x21, 0x01, 0xa0, 0x43, 0xbc, 0x12, 0x01, 0x37, 0xf3, 0x16, 0x34, + 0x99, 0xc8, 0xb8, 0xf8, 0xe0, 0x02, 0xb8, 0x30, 0xde, 0x3e, 0xcd, 0x40, 0xb2, 0xd6, 0x12, 0x3b, 0x09, 0xdd, 0xf4, + 0x31, 0x61, 0x04, 0x48, 0xb0, 0xe3, 0x01, 0x34, 0x79, 0x27, 0xbc, 0xc7, 0x7a, 0x35, 0x31, 0x05, 0x41, 0x44, 0xf7, + 0x9e, 0x83, 0xdd, 0x5c, 0xcb, 0x6a, 0x85, 0x4d, 0x88, 0xcd, 0x8e, 0xaa, 0xef, 0xa7, 0x0a, 0xbc, 0x5e, 0x98, 0x54, + 0x6c, 0x14, 0xba, 0x4e, 0x1e, 0x68, 0x1c, 0x60, 0x3a, 0x4b, 0x0e, 0x35, 0x5c, 0xf9, 0x50, 0x96, 0x93, 0x94, 0xd0, + 0x52, 0x38, 0xe0, 0x0c, 0x24, 0x07, 0xff, 0x63, 0x41, 0x03, 0x59, 0x87, 0x9f, 0x18, 0xd7, 0xe0, 0x5f, 0x48, 0x6b, + 0x9a, 0x16, 0xd1, 0x6a, 0xaf, 0x61, 0x0d, 0x9a, 0x97, 0xc9, 0x97, 0x13, 0x03, 0xd8, 0xac, 0x16, 0xb2, 0xfa, 0xb1, + 0xe7, 0x9a, 0x3f, 0x52, 0x7e, 0xca, 0x42, 0xed, 0xa9, 0x9e, 0xb6, 0x42, 0xb2, 0xd3, 0xb4, 0xa8, 0x88, 0xe2, 0x7a, + 0xb2, 0x5d, 0x17, 0x2f, 0xbe, 0x88, 0x04, 0x7e, 0x31, 0x81, 0x18, 0x12, 0x40, 0x60, 0x70, 0x04, 0x35, 0x24, 0x74, + 0xd4, 0xd7, 0x9b, 0xc7, 0x57, 0x15, 0x04, 0xcd, 0x63, 0xa6, 0x80, 0x98, 0xae, 0x98, 0x9d, 0xbf, 0x04, 0x5a, 0xf1, + 0xfe, 0x0d, 0xd6, 0x55, 0xcd, 0x9f, 0x37, 0x69, 0xe3, 0x17, 0xd6, 0x7f, 0xd4, 0xb1, 0x2a, 0xb0, 0x21, 0x36, 0xa8, + 0x52, 0x24, 0xac, 0x32, 0x06, 0x88, 0x46, 0xcf, 0x5c, 0x45, 0x9a, 0xc2, 0xfe, 0xee, 0x3c, 0x1e, 0xd4, 0x3a, 0xb5, + 0xf9, 0xa6, 0xe7, 0x52, 0x62, 0x09, 0x97, 0x99, 0xe9, 0x73, 0x39, 0x00, 0x32, 0xd3, 0x83, 0xdc, 0x40, 0x83, 0xaf, + 0xc1, 0xab, 0x2b, 0xe6, 0x2c, 0x3d, 0xbb, 0x1f, 0x36, 0x7e, 0x7f, 0x95, 0x5e, 0xd1, 0x3b, 0x18, 0x99, 0x6f, 0xee, + 0xf5, 0xee, 0x5a, 0x5d, 0xbf, 0xb0, 0x98, 0x51, 0x97, 0xaa, 0xe5, 0xe9, 0xe7, 0xed, 0xbe, 0x2f, 0x1e, 0xac, 0xfd, + 0x29, 0x28, 0x63, 0x7b, 0x92, 0x77, 0xad, 0xe4, 0xc6, 0xbf, 0x40, 0xd3, 0xaa, 0xa0, 0x96, 0x91, 0x29, 0x6f, 0x6b, + 0xbf, 0xe5, 0xba, 0xbc, 0x3d, 0x91, 0x71, 0xc4, 0xb9, 0x63, 0xc8, 0xfb, 0xd2, 0x36, 0x3e, 0xf7, 0x1a, 0x02, 0x85, + 0x5f, 0x9e, 0x4e, 0x29, 0x68, 0x6b, 0xc2, 0x25, 0xe2, 0x0c, 0x2d, 0xaf, 0x4b, 0x37, 0xc5, 0x20, 0x72, 0xf4, 0x81, + 0xdd, 0xd2, 0x86, 0xe0, 0xdb, 0x22, 0xfc, 0x6c, 0x26, 0xd4, 0x93, 0xad, 0x40, 0xad, 0x88, 0x2a, 0x7b, 0x88, 0x16, + 0x02, 0xcb, 0x89, 0xe4, 0xa4, 0x37, 0x75, 0x26, 0x90, 0x60, 0xea, 0x15, 0x6f, 0xbb, 0x60, 0xc8, 0x62, 0x97, 0x2b, + 0x0c, 0x2c, 0xa2, 0x64, 0x2a, 0x7e, 0xbd, 0x3c, 0x95, 0x46, 0x0b, 0x0c, 0x01, 0x4c, 0x73, 0x2f, 0x2f, 0x1a, 0x03, + 0xee, 0xfe, 0xee, 0x46, 0x9a, 0x6e, 0x48, 0xe0, 0x9b, 0x67, 0xf3, 0x5e, 0x4a, 0x06, 0x7a, 0x6e, 0xf2, 0xeb, 0x49, + 0xda, 0x89, 0x9c, 0x93, 0xda, 0x9c, 0xe1, 0x10, 0xa0, 0xaa, 0xd9, 0x43, 0x9a, 0x56, 0xa5, 0xec, 0xc4, 0x25, 0x90, + 0xe5, 0x37, 0x11, 0xf8, 0xf2, 0xcb, 0x63, 0xec, 0x9d, 0x8a, 0xcc, 0x14, 0x61, 0x4f, 0x94, 0x4f, 0x1b, 0x56, 0x77, + 0xf3, 0xf0, 0x34, 0x47, 0xb0, 0xf3, 0x87, 0x69, 0xdc, 0xd7, 0x0d, 0xcf, 0x00, 0x30, 0x03, 0xe1, 0x13, 0x82, 0x4f, + 0x30, 0x44, 0x33, 0xdd, 0xdc, 0x76, 0x1f, 0x55, 0xa5, 0xaa, 0x78, 0x0a, 0x70, 0x7c, 0x82, 0xe1, 0x9d, 0xa9, 0xc7, + 0x66, 0x09, 0x36, 0xcf, 0x23, 0x30, 0x84, 0xdc, 0x34, 0xa7, 0x9a, 0x72, 0x03, 0xe4, 0xbb, 0x88, 0x61, 0x8a, 0x67, + 0xb1, 0x47, 0xc3, 0x07, 0xd4, 0x2b, 0x6f, 0xee, 0xbc, 0xc0, 0x6f, 0xb3, 0x88, 0x65, 0xcf, 0x93, 0x51, 0x06, 0x9f, + 0x88, 0x7c, 0x8b, 0x14, 0x32, 0xf7, 0x83, 0xa6, 0xb0, 0xda, 0xa6, 0xf5, 0x33, 0x20, 0x72, 0x73, 0x75, 0x63, 0xa2, + 0x35, 0x70, 0xa1, 0x37, 0x51, 0x5d, 0x40, 0x6b, 0x9b, 0xf5, 0xe1, 0x66, 0x57, 0x22, 0x19, 0x3c, 0x10, 0xe6, 0xdf, + 0x78, 0xf1, 0x60, 0xf2, 0x2d, 0xe4, 0xc9, 0xf0, 0x91, 0x87, 0xd3, 0xbd, 0xb5, 0xe7, 0xad, 0xfb, 0x96, 0xbb, 0x6a, + 0x4d, 0x9e, 0xd3, 0x22, 0x94, 0xd8, 0x49, 0x06, 0x70, 0x04, 0x1f, 0x9b, 0xb1, 0xee, 0x03, 0xd4, 0x89, 0x0c, 0x2e, + 0x54, 0x31, 0xe3, 0xcc, 0x38, 0xca, 0xf2, 0x2b, 0xae, 0x39, 0xb8, 0xfd, 0xbc, 0x72, 0x31, 0x10, 0xb0, 0xd0, 0x81, + 0x32, 0xf5, 0x47, 0x32, 0xb5, 0x35, 0x4d, 0x8e, 0xf9, 0x19, 0x2c, 0x10, 0x19, 0x05, 0x01, 0xc8, 0xc2, 0xd3, 0xb6, + 0x4a, 0xf7, 0xf1, 0xa0, 0x1b, 0x50, 0xde, 0x08, 0xcc, 0xc8, 0xa0, 0x43, 0x30, 0x63, 0x6d, 0x67, 0x22, 0x11, 0x61, + 0x12, 0xae, 0x2c, 0x6a, 0xf8, 0x17, 0x4f, 0x49, 0xf9, 0x98, 0x87, 0xbe, 0x20, 0x8c, 0x8b, 0x79, 0x45, 0xe1, 0x90, + 0x82, 0x74, 0x2e, 0xae, 0xbe, 0x65, 0x99, 0x9c, 0x53, 0x2f, 0x43, 0xa1, 0x8b, 0x84, 0x51, 0x66, 0x93, 0x7a, 0x22, + 0x03, 0x48, 0xc6, 0x2a, 0x33, 0x94, 0x2b, 0xbc, 0x1e, 0x55, 0x72, 0x51, 0xf3, 0x6f, 0xcc, 0xca, 0xb8, 0x1c, 0x5b, + 0xd6, 0x0d, 0xeb, 0x0c, 0x8e, 0x57, 0xaa, 0x65, 0xf2, 0x4d, 0x51, 0x9c, 0x78, 0xf1, 0x19, 0x03, 0xf1, 0x7e, 0x56, + 0x6f, 0xb3, 0x9b, 0x43, 0x5c, 0xee, 0xda, 0xc2, 0x95, 0x49, 0xc5, 0x20, 0x96, 0x30, 0x11, 0xb4, 0x28, 0x8d, 0x3f, + 0x72, 0x30, 0xc5, 0x29, 0x40, 0x1b, 0x0b, 0x3f, 0x19, 0x49, 0x55, 0xe5, 0xb0, 0x5c, 0x46, 0x6f, 0xa5, 0xa8, 0xb1, + 0x59, 0x5e, 0x46, 0x9b, 0x79, 0x12, 0x10, 0xe0, 0xea, 0x4a, 0x59, 0xcd, 0xae, 0x4f, 0x1d, 0xb6, 0x67, 0x5c, 0x59, + 0xca, 0x09, 0x53, 0x34, 0x6b, 0x2c, 0x25, 0xc2, 0xb8, 0xcd, 0xc5, 0xb6, 0x38, 0x7e, 0x57, 0xf3, 0x97, 0xd2, 0x6f, + 0xe0, 0x2e, 0x77, 0x4d, 0x01, 0x6e, 0x91, 0x47, 0xf4, 0x8e, 0x5c, 0x06, 0x7c, 0x67, 0x54, 0x6f, 0xd0, 0x80, 0x2d, + 0x5a, 0x6e, 0xcd, 0xc7, 0xb2, 0x3c, 0xf4, 0x55, 0x74, 0xe1, 0x62, 0x11, 0xd1, 0xea, 0x50, 0xeb, 0xfd, 0xde, 0xfe, + 0xd3, 0x5e, 0xb5, 0xd3, 0x80, 0x0e, 0x28, 0x7d, 0xad, 0xd3, 0xdb, 0x2e, 0xff, 0xab, 0x1f, 0x6e, 0x8b, 0x44, 0x9f, + 0x97, 0xd4, 0x0d, 0x74, 0x08, 0x72, 0x07, 0x82, 0xad, 0x74, 0x3d, 0x67, 0x8e, 0x83, 0x5e, 0x58, 0x12, 0x6a, 0xe1, + 0x75, 0x79, 0x1b, 0x04, 0x0f, 0xa6, 0x94, 0xc4, 0x1a, 0x8f, 0xaa, 0x39, 0x0c, 0xe8, 0xc3, 0x2d, 0xd6, 0x6a, 0x62, + 0xfa, 0x13, 0xa2, 0xca, 0x44, 0x7a, 0x60, 0x7b, 0xd1, 0xc4, 0x84, 0x87, 0xfd, 0xa0, 0x24, 0x25, 0x54, 0x07, 0x82, + 0x36, 0x50, 0x26, 0xd6, 0xf1, 0x65, 0x87, 0x82, 0xe7, 0x42, 0x0b, 0x6c, 0x62, 0xb0, 0xef, 0xb8, 0x18, 0x12, 0x15, + 0x3b, 0xa4, 0xd4, 0x63, 0xa4, 0x76, 0x87, 0x2d, 0x62, 0x7f, 0x52, 0x0d, 0x94, 0xfe, 0x6e, 0xdc, 0xf7, 0xad, 0x15, + 0x40, 0xa9, 0x6b, 0x7e, 0xdc, 0xf7, 0x28, 0xf6, 0x60, 0x11, 0xbf, 0x0e, 0xc1, 0x99, 0x6c, 0xd7, 0x54, 0xc4, 0x9a, + 0xcf, 0x92, 0x3d, 0x37, 0x6c, 0xf8, 0xfb, 0x8a, 0x40, 0xc6, 0x48, 0xd3, 0xa1, 0x8c, 0xcd, 0xf8, 0x59, 0x46, 0x31, + 0x45, 0xd8, 0x17, 0x7e, 0x27, 0x09, 0x11, 0x22, 0x64, 0x0c, 0xd3, 0x1c, 0x41, 0x3b, 0xf3, 0x79, 0x52, 0x0b, 0x54, + 0xd7, 0x24, 0xf4, 0x3d, 0xdd, 0x1d, 0x88, 0x07, 0x39, 0x7a, 0x54, 0x02, 0xa0, 0xff, 0x5b, 0x3c, 0x7b, 0x72, 0xce, + 0x18, 0xc1, 0x5a, 0x71, 0x22, 0x8d, 0x2b, 0x70, 0x9c, 0xe3, 0x93, 0x16, 0x12, 0xc4, 0x4b, 0x75, 0x27, 0xa1, 0x4f, + 0xda, 0x38, 0x35, 0x78, 0x82, 0x5c, 0x14, 0x2b, 0x15, 0x80, 0xda, 0x2d, 0x78, 0xb3, 0x84, 0x19, 0x33, 0xa4, 0x47, + 0xde, 0x83, 0x35, 0x0f, 0x75, 0x29, 0x97, 0xc7, 0x9c, 0x9c, 0x21, 0x6a, 0x2e, 0xf2, 0xa4, 0xc6, 0x5c, 0x41, 0x5f, + 0x83, 0xe2, 0x14, 0xda, 0x18, 0x13, 0xab, 0xcd, 0x53, 0x9f, 0xaa, 0xa1, 0x28, 0x3d, 0x9b, 0xe5, 0xc5, 0x3a, 0xe2, + 0x12, 0xd8, 0x85, 0x66, 0xf4, 0xc1, 0xaf, 0x64, 0x92, 0xc3, 0x41, 0x9a, 0x27, 0x82, 0x8e, 0xf2, 0xc1, 0xd0, 0xc9, + 0x8c, 0xf6, 0x2e, 0x3d, 0x62, 0x47, 0x0f, 0x25, 0xa7, 0x2f, 0x50, 0x7a, 0x08, 0x01, 0xfa, 0xab, 0xe1, 0x4d, 0xdb, + 0x5f, 0xd1, 0x49, 0xf1, 0x62, 0xc2, 0x3b, 0x49, 0x14, 0xe1, 0x21, 0x9c, 0x11, 0x85, 0x8c, 0x44, 0xfb, 0x60, 0x30, + 0xf3, 0xce, 0xb6, 0x35, 0xe5, 0x7d, 0x51, 0xa7, 0x4e, 0x73, 0xf0, 0xf4, 0xbd, 0x78, 0x2d, 0x37, 0x0f, 0x02, 0x7a, + 0xec, 0xcb, 0x96, 0x90, 0x9d, 0x27, 0x03, 0x08, 0x90, 0x2f, 0x76, 0xc8, 0x98, 0x20, 0x0d, 0x6b, 0x5a, 0x92, 0x35, + 0xfd, 0x68, 0x11, 0xfa, 0xa7, 0xea, 0xe3, 0x34, 0xcb, 0x84, 0x50, 0x5b, 0x18, 0x03, 0x22, 0xf4, 0x94, 0x93, 0x82, + 0x15, 0xb9, 0x0f, 0x5e, 0x52, 0x38, 0x1c, 0xac, 0xd7, 0xc5, 0xf0, 0xa4, 0x39, 0x1b, 0x02, 0xdb, 0x31, 0x01, 0x9d, + 0x66, 0x48, 0x14, 0x62, 0xc3, 0x7d, 0x8c, 0x66, 0x92, 0x0a, 0xc6, 0x34, 0x51, 0xf9, 0xd0, 0x3f, 0xa8, 0x8d, 0xb8, + 0x49, 0x3d, 0x8a, 0x87, 0x11, 0xf6, 0x1c, 0x87, 0xae, 0x13, 0xcb, 0x80, 0xa8, 0xb2, 0xa4, 0xb2, 0xe6, 0x7a, 0xd4, + 0x34, 0x23, 0x83, 0x2a, 0x91, 0xfa, 0x45, 0x5b, 0x07, 0x97, 0x06, 0xd4, 0xb3, 0xf8, 0x66, 0xe0, 0xb9, 0x25, 0xb4, + 0xdc, 0x9f, 0x23, 0x89, 0x27, 0x83, 0x51, 0x8f, 0xe6, 0x08, 0x2f, 0xdd, 0x1d, 0x02, 0xe0, 0xad, 0xf2, 0x76, 0xd5, + 0xf3, 0xef, 0x28, 0x63, 0x27, 0x6e, 0xaa, 0xad, 0x52, 0x92, 0x5a, 0x83, 0x12, 0xf3, 0xef, 0xf2, 0xc7, 0x38, 0x77, + 0x15, 0x0b, 0xee, 0xbd, 0xa7, 0x6b, 0x85, 0xfa, 0xd3, 0x27, 0xb2, 0x93, 0xc2, 0x8d, 0xd3, 0x1b, 0x44, 0xe6, 0xe1, + 0x23, 0x6a, 0xc1, 0x5c, 0xe0, 0xee, 0xb8, 0xa8, 0x7b, 0xf3, 0x37, 0x84, 0x9b, 0xa2, 0xa6, 0xd0, 0x85, 0x92, 0x8d, + 0x16, 0x5f, 0xc9, 0xcc, 0x00, 0xcd, 0xe5, 0x4a, 0x2d, 0x3c, 0x67, 0x3d, 0x50, 0xfb, 0x15, 0x89, 0x5b, 0xeb, 0xf5, + 0xb5, 0x5b, 0xdb, 0x43, 0xb8, 0x9a, 0x2c, 0xa8, 0x63, 0x24, 0x79, 0xcc, 0x1c, 0x5a, 0x2b, 0x32, 0x5d, 0x93, 0x84, + 0xe6, 0x92, 0x5a, 0xaf, 0x2e, 0x1a, 0x7e, 0xfe, 0xda, 0x44, 0x10, 0x13, 0x46, 0x56, 0x2b, 0xe8, 0x1d, 0xb6, 0x9b, + 0x5f, 0x2c, 0x5c, 0x6d, 0x52, 0xa6, 0xc2, 0x21, 0x50, 0x9b, 0x2c, 0x3f, 0xc7, 0xd2, 0x53, 0x14, 0x44, 0xea, 0xb4, + 0xd5, 0x55, 0x42, 0x42, 0xb0, 0x52, 0xa9, 0x7f, 0x1d, 0x98, 0x90, 0x23, 0x2a, 0x47, 0x64, 0xf7, 0xba, 0x9c, 0xf3, + 0x53, 0x03, 0xd2, 0xdd, 0x88, 0x48, 0xc8, 0xe9, 0x8d, 0x01, 0x5d, 0x16, 0x1a, 0xfb, 0xdb, 0x80, 0x2b, 0x7c, 0x88, + 0xd0, 0xe9, 0xd8, 0x95, 0x72, 0x5d, 0x84, 0xfb, 0xbe, 0x40, 0x8a, 0xaa, 0x22, 0x82, 0x05, 0xd5, 0x8e, 0x6c, 0xce, + 0x8e, 0xfc, 0xc6, 0x1a, 0x1c, 0xce, 0xcd, 0xf1, 0xae, 0x51, 0x84, 0xd2, 0xc5, 0xce, 0xe3, 0x40, 0x4f, 0x94, 0x24, + 0x7c, 0x77, 0x8c, 0xd0, 0x5a, 0xeb, 0xfc, 0xac, 0xfb, 0x01, 0xcf, 0x92, 0x70, 0xfe, 0x81, 0x4d, 0xde, 0x97, 0xe4, + 0xbc, 0xbc, 0xda, 0xd4, 0x6d, 0xc1, 0x08, 0x40, 0x7d, 0xe3, 0x79, 0x5b, 0x79, 0x70, 0x83, 0x91, 0x41, 0x9e, 0xcc, + 0x09, 0xc6, 0x33, 0x57, 0x83, 0x79, 0x76, 0xec, 0x2c, 0xef, 0xb1, 0x10, 0xc8, 0x53, 0x4d, 0x6d, 0x5a, 0x2b, 0xb1, + 0x45, 0x3b, 0x66, 0xbf, 0x65, 0x03, 0x9c, 0x00, 0xa7, 0xc3, 0xf1, 0xd2, 0x36, 0xf8, 0x40, 0x2e, 0xe9, 0xad, 0x65, + 0x14, 0x64, 0x17, 0xfe, 0x6d, 0xa8, 0x8f, 0x28, 0xaf, 0x40, 0xa8, 0x48, 0xea, 0xd8, 0x28, 0x29, 0x45, 0xa9, 0x11, + 0x5a, 0x66, 0x5b, 0x90, 0x15, 0x67, 0x7b, 0xc4, 0xa3, 0x66, 0x86, 0x87, 0x22, 0xb7, 0x45, 0x3a, 0x6b, 0xb8, 0x2f, + 0x05, 0x2a, 0x36, 0x85, 0x34, 0xd3, 0x1a, 0xd8, 0xc6, 0x3d, 0x59, 0x53, 0x7b, 0xb7, 0x11, 0x35, 0x83, 0x47, 0xf4, + 0x2d, 0x4d, 0x4d, 0xdf, 0xaf, 0x8d, 0xb4, 0x52, 0x0c, 0x94, 0x39, 0xc4, 0x74, 0x4d, 0x8d, 0x99, 0x54, 0xa9, 0xc5, + 0x7e, 0xdd, 0xe6, 0xd3, 0x6f, 0x17, 0xca, 0x21, 0x39, 0x70, 0x42, 0xc9, 0x11, 0x43, 0x76, 0x86, 0x21, 0xb8, 0x95, + 0xb3, 0x89, 0x64, 0xb9, 0x11, 0xb9, 0xcc, 0x3a, 0xa3, 0x3b, 0xfe, 0xc1, 0x04, 0x50, 0xe8, 0x8b, 0x05, 0x0a, 0xfa, + 0xb1, 0xda, 0xfa, 0x44, 0x1d, 0x49, 0x25, 0x29, 0x3e, 0x5d, 0xb8, 0x8a, 0xca, 0xa1, 0xe6, 0xea, 0x55, 0x51, 0x81, + 0x5a, 0x13, 0x3a, 0x70, 0x3d, 0x42, 0x60, 0x03, 0x61, 0xf4, 0x47, 0x53, 0x08, 0xcb, 0x7d, 0x15, 0x37, 0xed, 0x26, + 0xef, 0x9e, 0xce, 0xf6, 0x18, 0xa9, 0x41, 0x16, 0x5a, 0x56, 0x1c, 0xc3, 0xe9, 0x01, 0x4f, 0x06, 0x8f, 0x1d, 0x33, + 0x6c, 0x36, 0x4e, 0x8f, 0x31, 0x06, 0x58, 0xb2, 0xc2, 0x62, 0x9b, 0x4a, 0x6b, 0x45, 0x84, 0xd4, 0x36, 0xab, 0x97, + 0x36, 0x77, 0x8a, 0xfc, 0xf6, 0x67, 0x00, 0x98, 0x57, 0x4d, 0xa6, 0x75, 0x14, 0x53, 0xc4, 0x28, 0x69, 0xb3, 0x38, + 0x5e, 0x88, 0x95, 0x17, 0x1f, 0x0b, 0xdc, 0x1f, 0xa1, 0x72, 0x65, 0xb9, 0xe0, 0xea, 0x4c, 0xee, 0x87, 0x9b, 0xef, + 0x33, 0x27, 0x11, 0x2f, 0x98, 0xe8, 0x33, 0x66, 0xc3, 0xd5, 0x85, 0x77, 0xa4, 0x4e, 0xb3, 0x98, 0xdc, 0xfb, 0xe2, + 0x2d, 0x9f, 0xe7, 0x2e, 0xa0, 0xb2, 0x07, 0xb1, 0xdb, 0xaa, 0x8c, 0xf5, 0x3a, 0x23, 0x83, 0x84, 0x6f, 0x29, 0xd9, + 0x2b, 0x19, 0x3b, 0xf1, 0x19, 0x64, 0x7a, 0xb0, 0x0c, 0x0b, 0x4f, 0x19, 0xc9, 0xed, 0x33, 0x55, 0xd4, 0xae, 0xa7, + 0x54, 0xae, 0x8b, 0xee, 0xbc, 0xe6, 0xde, 0x56, 0xb8, 0x53, 0x33, 0x93, 0x4e, 0xbc, 0x2e, 0x40, 0x9d, 0x0f, 0x2e, + 0x2d, 0xd2, 0x39, 0x2f, 0x60, 0xd1, 0x0c, 0x85, 0xeb, 0xa9, 0x1a, 0x7d, 0xb6, 0xdc, 0x47, 0x16, 0xc3, 0xa6, 0x3b, + 0xbf, 0x2c, 0x7b, 0x34, 0xf9, 0x64, 0x81, 0x40, 0xec, 0x29, 0x3c, 0xbe, 0xa4, 0xc1, 0xad, 0xc5, 0xcf, 0xb4, 0xd5, + 0x56, 0x06, 0xaa, 0x4d, 0x52, 0x0b, 0xfc, 0x64, 0x39, 0xe2, 0xe4, 0x70, 0x6a, 0x79, 0xd7, 0xc0, 0x97, 0xf8, 0x05, + 0xf4, 0x87, 0xb0, 0x2a, 0x52, 0x97, 0x88, 0x6f, 0x09, 0x65, 0xe5, 0x98, 0xfb, 0x0d, 0xc8, 0x7a, 0x98, 0x2d, 0x14, + 0xc7, 0x9b, 0x70, 0x44, 0xa2, 0xb4, 0xfd, 0xdc, 0x1f, 0x1f, 0xf4, 0x2b, 0x7a, 0x0c, 0x86, 0xe3, 0x40, 0x85, 0xc8, + 0x99, 0x12, 0x22, 0x0a, 0xa7, 0x25, 0x5c, 0x86, 0xc6, 0x3c, 0x14, 0x04, 0x64, 0xd4, 0xff, 0x81, 0x70, 0x70, 0x31, + 0x6f, 0x9d, 0xa0, 0x52, 0x55, 0x5a, 0x58, 0x2e, 0x7b, 0xb1, 0x1f, 0x40, 0x95, 0x87, 0x3c, 0x60, 0x7d, 0xde, 0x71, + 0x9d, 0x33, 0x0b, 0x1e, 0x08, 0x46, 0x40, 0x12, 0x33, 0x5b, 0x47, 0xb7, 0x7a, 0xfa, 0x8b, 0xbb, 0x4e, 0x40, 0x3f, + 0x6e, 0x18, 0x7f, 0x84, 0x53, 0x51, 0x5a, 0xc8, 0x5f, 0xb5, 0x24, 0x9b, 0x30, 0xba, 0x0d, 0x8d, 0x75, 0x88, 0xc4, + 0xc5, 0x25, 0x47, 0xcf, 0x79, 0x51, 0xa0, 0x1c, 0xba, 0xee, 0x00, 0x8f, 0x85, 0x77, 0x57, 0x14, 0x68, 0x2e, 0xdc, + 0x35, 0x7d, 0x21, 0x27, 0xd6, 0x3a, 0x3c, 0x62, 0xad, 0x6d, 0x1b, 0xa2, 0x07, 0xcb, 0x29, 0x9e, 0xa1, 0xa1, 0x5c, + 0x2b, 0xd5, 0x92, 0x6c, 0x52, 0xcf, 0x80, 0x8c, 0x95, 0x7a, 0x82, 0x26, 0x65, 0xde, 0x21, 0x9e, 0x3a, 0x18, 0x3b, + 0x74, 0x93, 0x41, 0xf4, 0x5f, 0x47, 0xe6, 0x44, 0xe5, 0x9e, 0xf4, 0x63, 0xdb, 0xa8, 0xe0, 0x00, 0xe8, 0x68, 0x79, + 0xbf, 0xec, 0xbe, 0x77, 0xab, 0xb3, 0x14, 0x6d, 0x78, 0x55, 0x91, 0x84, 0x5a, 0x47, 0xfb, 0xbc, 0x86, 0xe7, 0xdb, + 0x11, 0x61, 0x44, 0xb7, 0x07, 0x66, 0x85, 0xb3, 0x6d, 0x52, 0x8c, 0x5d, 0xb5, 0xe0, 0x84, 0x79, 0x08, 0x88, 0x77, + 0x3d, 0xa9, 0x0e, 0x2b, 0x0d, 0xd1, 0x79, 0x1e, 0x5e, 0x2e, 0xae, 0x58, 0x98, 0xaa, 0x5e, 0x0a, 0x62, 0xbf, 0xf9, + 0xe0, 0x03, 0xf7, 0x79, 0x86, 0x61, 0xe3, 0xcd, 0x28, 0x4f, 0x8f, 0x31, 0x3b, 0x3f, 0xc4, 0x6e, 0xea, 0x48, 0x5f, + 0x71, 0x01, 0x7a, 0xbd, 0x27, 0xa7, 0xef, 0xd0, 0xf7, 0xa2, 0xe3, 0x8c, 0x2f, 0x0c, 0xd7, 0x8e, 0xf3, 0x05, 0x71, + 0x4a, 0x84, 0x28, 0xf5, 0x78, 0x51, 0x8f, 0x59, 0x22, 0x5e, 0x05, 0xc1, 0xb6, 0xe5, 0xcd, 0xf8, 0xef, 0xc2, 0x49, + 0xca, 0x77, 0xc7, 0x90, 0xc0, 0xe3, 0xc1, 0x9f, 0xa3, 0xe3, 0x02, 0x67, 0x22, 0x72, 0x18, 0x87, 0x3b, 0xb7, 0x55, + 0x4a, 0xef, 0xdb, 0xb0, 0x66, 0xea, 0xf5, 0xe7, 0x05, 0x21, 0xe3, 0x86, 0x1c, 0xb8, 0x83, 0x22, 0x9e, 0x96, 0xc0, + 0x5c, 0x9b, 0x42, 0x88, 0x7a, 0xfc, 0x37, 0xdc, 0x3c, 0x45, 0xf8, 0xa8, 0x11, 0x85, 0x89, 0xa6, 0xa6, 0xe4, 0xae, + 0xd8, 0x00, 0xac, 0xc4, 0x09, 0xed, 0x20, 0xf5, 0x43, 0x59, 0x79, 0x85, 0x81, 0xd5, 0xa2, 0xae, 0x04, 0x6a, 0x59, + 0x20, 0x8f, 0x0c, 0x4e, 0xec, 0xbd, 0x08, 0x8b, 0xae, 0x61, 0x14, 0xf4, 0x60, 0xaa, 0xb6, 0x5e, 0xc2, 0xeb, 0x6e, + 0x0b, 0xcb, 0x0f, 0xef, 0x57, 0x53, 0xcb, 0x5d, 0x95, 0x3f, 0x1e, 0x20, 0x67, 0xc9, 0xe9, 0x03, 0x80, 0x15, 0x0f, + 0x53, 0xc0, 0x56, 0xef, 0xcd, 0x61, 0x6b, 0x77, 0x89, 0xc6, 0x6d, 0xe6, 0x4f, 0x77, 0x48, 0x30, 0x4a, 0xfa, 0xd9, + 0xe7, 0x3f, 0xcf, 0x60, 0x71, 0xf4, 0x06, 0xc0, 0x43, 0xac, 0x3b, 0x59, 0xd5, 0xad, 0xec, 0x1e, 0xff, 0xf4, 0xa1, + 0x29, 0x12, 0xe9, 0x89, 0x69, 0xfc, 0xe2, 0xa8, 0x26, 0x7b, 0xab, 0x1d, 0x23, 0x67, 0x77, 0x24, 0xce, 0x4a, 0x09, + 0xc9, 0xe5, 0x88, 0x4a, 0x74, 0xdb, 0x23, 0x8a, 0xe0, 0xb5, 0x77, 0x96, 0x61, 0xa3, 0x5f, 0xc3, 0x08, 0x05, 0xa0, + 0x26, 0x60, 0xf8, 0xa0, 0xb2, 0xde, 0x09, 0x00, 0x8c, 0xd2, 0xaa, 0xa9, 0x53, 0x46, 0x17, 0xbb, 0xe9, 0xf2, 0xe2, + 0x41, 0xa6, 0xb4, 0x13, 0x35, 0x93, 0xdb, 0x13, 0x2a, 0x5b, 0x2d, 0x8c, 0x6d, 0xbf, 0x64, 0xc4, 0xa7, 0x81, 0x44, + 0x2b, 0x2c, 0x30, 0xa3, 0x83, 0x65, 0x29, 0xcb, 0x51, 0x22, 0xb1, 0x4c, 0x90, 0x5d, 0x79, 0x33, 0x8c, 0xbc, 0x0d, + 0xac, 0xc8, 0x8c, 0x48, 0x24, 0x5b, 0xd4, 0x74, 0x44, 0x0c, 0x8f, 0xda, 0x31, 0xab, 0xba, 0xb4, 0xb1, 0x62, 0xe1, + 0xe9, 0xe6, 0xd0, 0x93, 0x2b, 0xa4, 0xe8, 0x72, 0x1f, 0xa4, 0x50, 0x4c, 0x17, 0x6d, 0x5c, 0x9d, 0xdb, 0xec, 0x8b, + 0x28, 0xf3, 0x15, 0x99, 0x17, 0xb1, 0x98, 0xdd, 0x3f, 0xd9, 0xd8, 0x61, 0xb2, 0x3c, 0xce, 0xc9, 0x64, 0xe6, 0x40, + 0x35, 0x6d, 0xc8, 0xb5, 0xe4, 0xb5, 0x64, 0xc5, 0x49, 0x5c, 0xfc, 0xbb, 0xbc, 0x6c, 0xf3, 0x64, 0xaa, 0x10, 0x41, + 0x0f, 0xb3, 0x64, 0x81, 0x59, 0xaa, 0xa5, 0x83, 0x12, 0xce, 0x22, 0xb2, 0xa3, 0x81, 0xe9, 0x4d, 0x49, 0x9b, 0x7c, + 0xd0, 0x49, 0x77, 0x27, 0x6f, 0x0d, 0x09, 0xd7, 0x6b, 0x9c, 0xd8, 0x16, 0x73, 0x31, 0xe2, 0xa9, 0xef, 0xca, 0x24, + 0x5a, 0x91, 0x78, 0x90, 0x25, 0x31, 0x57, 0x9e, 0x8d, 0x45, 0x89, 0x2f, 0x72, 0x7a, 0x5a, 0x2f, 0x66, 0xa3, 0x45, + 0x1a, 0xfb, 0xc3, 0xc8, 0x2f, 0x8b, 0x9f, 0xdd, 0x8e, 0x1c, 0xf5, 0xf6, 0x84, 0xf2, 0xac, 0xa6, 0xb6, 0xae, 0x99, + 0x39, 0x66, 0x94, 0x69, 0xa4, 0x10, 0x4b, 0x48, 0x9f, 0x8c, 0x08, 0x5a, 0x9c, 0x0e, 0x6c, 0xd8, 0xfc, 0x4e, 0x05, + 0x9e, 0xa9, 0xdd, 0x5e, 0x0d, 0x0d, 0xcf, 0x2b, 0x24, 0x82, 0x0b, 0x1a, 0x6f, 0x70, 0xd4, 0x0c, 0xf5, 0x7f, 0x78, + 0x3a, 0x6f, 0xcd, 0x74, 0xf6, 0x44, 0x32, 0xb2, 0xb4, 0xf0, 0x0c, 0x70, 0x3e, 0xa9, 0x4a, 0x73, 0x7b, 0x3f, 0xc8, + 0x23, 0xeb, 0xfe, 0x49, 0x54, 0xbf, 0x22, 0xb0, 0x3b, 0x49, 0x4c, 0x08, 0xd0, 0xf0, 0xba, 0x9e, 0x0d, 0x13, 0x09, + 0xad, 0x04, 0xef, 0xbb, 0x0a, 0xfe, 0x4e, 0xca, 0x24, 0x5d, 0x9a, 0xd0, 0xe4, 0xa2, 0x5c, 0x0d, 0x76, 0xb2, 0x40, + 0xbe, 0x05, 0xd8, 0x40, 0x10, 0x08, 0xac, 0x30, 0xef, 0x98, 0x4a, 0x68, 0x07, 0xd2, 0x40, 0xe6, 0x04, 0x98, 0x64, + 0xe3, 0x5c, 0x19, 0x14, 0xd5, 0x46, 0x3e, 0xad, 0x72, 0x36, 0x24, 0x1a, 0x06, 0x99, 0xf5, 0xc7, 0xd0, 0xd9, 0x2b, + 0x26, 0xc9, 0xbc, 0xbf, 0x73, 0x34, 0x9e, 0x6c, 0xcf, 0x90, 0x28, 0xe4, 0x6a, 0x9f, 0x41, 0x3c, 0xa1, 0x19, 0x2e, + 0x2b, 0x51, 0x5f, 0xd6, 0xb5, 0xfa, 0x4f, 0xaa, 0xf7, 0x1d, 0x3c, 0x39, 0x90, 0x45, 0x6f, 0xe3, 0xc0, 0x72, 0xcb, + 0x16, 0x01, 0xe6, 0xf9, 0x1a, 0x68, 0x46, 0x09, 0x20, 0x43, 0x13, 0x60, 0xae, 0x31, 0x7b, 0x69, 0x68, 0x46, 0x28, + 0xfb, 0x22, 0xd7, 0x26, 0xa1, 0xe8, 0x61, 0xee, 0xcb, 0x2b, 0x71, 0x9b, 0xeb, 0x1d, 0xd2, 0xeb, 0xb6, 0x7a, 0x8f, + 0x5d, 0x8e, 0xc8, 0x72, 0x8a, 0xb8, 0x4d, 0xa8, 0x1e, 0xa0, 0x90, 0x55, 0x13, 0xa6, 0x75, 0xb0, 0x3b, 0xe3, 0x2f, + 0x49, 0x88, 0x30, 0x21, 0x31, 0xaa, 0x8f, 0xd0, 0xb1, 0x1a, 0xfb, 0x44, 0x8f, 0x25, 0x47, 0xa2, 0x37, 0x48, 0x1d, + 0xb9, 0x14, 0x3a, 0x8f, 0x0b, 0x75, 0x6d, 0xad, 0xb6, 0xe0, 0x12, 0x61, 0xc0, 0x89, 0x55, 0x0e, 0x87, 0xcb, 0xa9, + 0x50, 0xd9, 0x12, 0xf7, 0x36, 0x66, 0xd4, 0xcb, 0x9d, 0xb7, 0x59, 0x97, 0x7a, 0x6f, 0x12, 0x16, 0x91, 0xe5, 0xa1, + 0x62, 0x1c, 0x08, 0x05, 0x6b, 0xfb, 0x60, 0x79, 0x8d, 0x33, 0xf2, 0x2c, 0xc3, 0x66, 0x30, 0x7a, 0x1f, 0xa0, 0xac, + 0xa8, 0xc7, 0xe1, 0x02, 0x10, 0xeb, 0x43, 0xf2, 0xa2, 0xc9, 0x0c, 0x01, 0x16, 0xd9, 0xe2, 0x52, 0x93, 0x2c, 0x14, + 0x3a, 0xea, 0xaf, 0x7b, 0x40, 0xbb, 0x16, 0x12, 0x03, 0xe9, 0xf0, 0xa8, 0x93, 0xae, 0x66, 0x89, 0x65, 0x73, 0x0c, + 0x0d, 0x85, 0xc5, 0x69, 0x9e, 0xa7, 0x23, 0xdb, 0xda, 0x3b, 0xc0, 0x89, 0x8e, 0xae, 0x17, 0xe0, 0xb6, 0x83, 0x4b, + 0x21, 0xc7, 0x11, 0xdc, 0x34, 0x47, 0x79, 0x76, 0x2a, 0x6d, 0x0a, 0x46, 0x13, 0x37, 0x2b, 0xcd, 0x85, 0x2e, 0xa7, + 0xf0, 0x3c, 0xdd, 0xfa, 0x13, 0x15, 0xfd, 0xd3, 0x52, 0x3b, 0x83, 0x41, 0x95, 0xd3, 0xae, 0x94, 0xb1, 0xa4, 0x5d, + 0x73, 0xf4, 0x85, 0x40, 0x1e, 0x16, 0xfa, 0x7e, 0xa1, 0x71, 0xe7, 0xd4, 0x41, 0xf1, 0x8e, 0x71, 0x66, 0xa7, 0x07, + 0x0d, 0x7b, 0xa5, 0xf1, 0x68, 0x44, 0x29, 0x2b, 0xf5, 0x03, 0xe3, 0x5a, 0xde, 0x9e, 0x10, 0x6d, 0x32, 0x0a, 0x77, + 0x28, 0xcb, 0xe4, 0xdb, 0x1e, 0x07, 0x9a, 0xb6, 0x67, 0xdc, 0x76, 0x5b, 0xdf, 0xae, 0x93, 0x5b, 0x44, 0xe2, 0xf6, + 0x17, 0x5c, 0xc2, 0x33, 0xf8, 0xc6, 0x90, 0x8a, 0x3d, 0xeb, 0xc4, 0xe5, 0xcb, 0x28, 0xcb, 0xf9, 0x0a, 0x47, 0x57, + 0x4c, 0xc6, 0xc2, 0x0b, 0x2d, 0x22, 0xdc, 0x34, 0x50, 0xc7, 0x95, 0x24, 0xb1, 0x9b, 0x92, 0xf8, 0xb9, 0xe5, 0x9f, + 0xb7, 0xe6, 0x46, 0xc0, 0x54, 0x24, 0xd7, 0x21, 0xfa, 0xcc, 0xa9, 0x5a, 0xdd, 0x6b, 0x95, 0x05, 0xf5, 0x98, 0xa7, + 0x72, 0xc4, 0x9c, 0xba, 0xdd, 0x14, 0x59, 0x26, 0x3d, 0x6c, 0xae, 0x29, 0x4a, 0x14, 0x68, 0xab, 0x0b, 0xbd, 0xcc, + 0x9c, 0xb3, 0xd0, 0xd1, 0x89, 0x94, 0x6d, 0x8d, 0x66, 0x13, 0x73, 0x1c, 0xce, 0x7e, 0x12, 0xd9, 0x13, 0x5c, 0xf5, + 0x9e, 0xb7, 0xf6, 0x61, 0xb3, 0xf1, 0x75, 0xa8, 0xd5, 0x90, 0x1d, 0x10, 0x68, 0xe6, 0xce, 0x14, 0x28, 0xc2, 0xfe, + 0x2b, 0x3b, 0x12, 0xa5, 0x2c, 0xff, 0xd8, 0x69, 0x5d, 0xdf, 0x36, 0xaa, 0x8e, 0xc9, 0x5f, 0xd3, 0xbe, 0x86, 0xab, + 0x0e, 0x8a, 0x9c, 0xc3, 0xf1, 0x49, 0xbb, 0x33, 0xdd, 0x3c, 0x10, 0x9e, 0xb3, 0xc3, 0xa8, 0x2c, 0x67, 0x57, 0xd4, + 0x1b, 0xba, 0x0a, 0x18, 0xa8, 0x51, 0x32, 0x29, 0x7b, 0xa3, 0xb0, 0x8e, 0xfa, 0x9d, 0xb8, 0xd6, 0x57, 0x14, 0xdd, + 0xb2, 0xc6, 0xad, 0x4d, 0x76, 0xe0, 0x8f, 0x18, 0x2b, 0x77, 0x98, 0x21, 0x3f, 0x5c, 0x63, 0xd5, 0x22, 0xf5, 0x46, + 0xe3, 0x62, 0xdb, 0x6a, 0x3a, 0xd3, 0x40, 0xb7, 0xad, 0x99, 0x1b, 0x61, 0x07, 0xd5, 0x70, 0x5b, 0xb7, 0x95, 0xaa, + 0xb6, 0x9d, 0xc7, 0xaf, 0xf6, 0xd5, 0x89, 0x98, 0xd0, 0x86, 0xa1, 0xaf, 0x81, 0xe9, 0x5a, 0x54, 0x73, 0x31, 0xb0, + 0xa9, 0x5e, 0x2d, 0xf6, 0x5d, 0xc8, 0xee, 0xdd, 0x5f, 0x43, 0x12, 0xaa, 0xe2, 0xca, 0x2d, 0x2f, 0xb7, 0x9f, 0x74, + 0xb2, 0x4a, 0x65, 0x6a, 0x1f, 0xf9, 0x1d, 0x66, 0xca, 0x87, 0x99, 0xe2, 0x71, 0xa5, 0x63, 0x2d, 0x20, 0x0a, 0x43, + 0xe1, 0x55, 0x0a, 0x74, 0x6b, 0x16, 0xf1, 0x0f, 0x74, 0xec, 0xca, 0x98, 0x11, 0x32, 0x1a, 0x95, 0x33, 0x74, 0x43, + 0x42, 0x35, 0x34, 0xb1, 0x9c, 0xa4, 0x4b, 0x0d, 0xba, 0xda, 0xe1, 0x3a, 0xb2, 0x3c, 0x10, 0x02, 0x71, 0x22, 0x87, + 0x39, 0x53, 0x23, 0xda, 0xfd, 0x24, 0x30, 0x91, 0x66, 0x5d, 0xb5, 0x5f, 0x74, 0x38, 0xdd, 0x50, 0x7b, 0x4f, 0xbf, + 0x7c, 0x68, 0xb4, 0xa7, 0x5f, 0xae, 0xb4, 0x3e, 0x39, 0x31, 0xe5, 0xd4, 0x4a, 0xc7, 0x0d, 0x8c, 0xc3, 0x45, 0xe9, + 0xc0, 0xf7, 0x48, 0x35, 0xb8, 0x31, 0xdc, 0x8d, 0x4e, 0xe0, 0x8c, 0xdc, 0x36, 0x22, 0x2b, 0x37, 0x81, 0x99, 0x81, + 0x94, 0xd2, 0x8b, 0x63, 0xe0, 0xbe, 0xed, 0xfd, 0x28, 0xc9, 0x78, 0xd3, 0x64, 0xfc, 0x7a, 0x99, 0x15, 0x4a, 0xdf, + 0x33, 0xb3, 0xd0, 0x55, 0xfc, 0xce, 0x24, 0x77, 0xb5, 0xc6, 0x4e, 0xaa, 0xe5, 0x0c, 0x18, 0xe5, 0x6a, 0x85, 0xe5, + 0x8e, 0xf7, 0xe4, 0xb0, 0xb9, 0x9f, 0x65, 0x09, 0x69, 0xb2, 0x15, 0x55, 0x89, 0x31, 0x22, 0x85, 0xf6, 0x17, 0x67, + 0xe7, 0xfe, 0x68, 0xf1, 0x01, 0x1d, 0xf5, 0x1d, 0x33, 0xae, 0xc6, 0xad, 0xd8, 0x2e, 0x56, 0xec, 0x60, 0x1a, 0xae, + 0x0d, 0xa6, 0x79, 0x80, 0xd0, 0x3d, 0x73, 0x07, 0xf5, 0x0b, 0xfc, 0x8f, 0x7c, 0x5c, 0x55, 0x48, 0x87, 0x2e, 0x9b, + 0xa9, 0x28, 0x5f, 0xa2, 0x06, 0x05, 0x2c, 0x5a, 0xb7, 0x4b, 0x13, 0x30, 0x45, 0x16, 0xd2, 0x2d, 0xa4, 0x20, 0x4a, + 0x16, 0x82, 0x19, 0x54, 0x7c, 0xe5, 0x2f, 0x13, 0x5f, 0xeb, 0xab, 0x85, 0x5e, 0xd2, 0x13, 0xb6, 0x0a, 0xb9, 0xba, + 0x61, 0xb4, 0x98, 0x55, 0xa7, 0x1d, 0xa7, 0x89, 0x43, 0x83, 0x1a, 0x75, 0x44, 0xe8, 0x3a, 0x3e, 0xf8, 0x6c, 0x13, + 0x79, 0x83, 0xc9, 0x4f, 0x4e, 0x02, 0xfe, 0x5e, 0x9f, 0xbc, 0xc5, 0xd9, 0x43, 0xac, 0x4a, 0x33, 0x1e, 0x2f, 0x94, + 0x3d, 0x2a, 0x7b, 0x41, 0xad, 0xb1, 0x9f, 0x5d, 0x98, 0xd6, 0x46, 0x25, 0x85, 0xdc, 0x79, 0xb8, 0x90, 0xef, 0x9c, + 0xc2, 0xb9, 0x1b, 0x95, 0x88, 0xf2, 0x00, 0x66, 0xc2, 0xe6, 0xc4, 0x8d, 0x8a, 0x5b, 0x40, 0xe5, 0x4c, 0x4f, 0x9a, + 0xc4, 0x74, 0x56, 0x22, 0xc6, 0x8c, 0x4e, 0xe1, 0x7a, 0x1c, 0xa2, 0x31, 0x34, 0xc3, 0x9c, 0xde, 0xc7, 0xe8, 0x09, + 0x72, 0x80, 0xb3, 0x76, 0xad, 0x21, 0xc4, 0x4c, 0x2a, 0x7c, 0xef, 0x56, 0xc4, 0x96, 0xd9, 0x17, 0x82, 0xda, 0x36, + 0xef, 0xbb, 0x11, 0x51, 0x5e, 0x29, 0x7c, 0x9f, 0xfb, 0xcb, 0x2f, 0x18, 0xaf, 0x64, 0x68, 0x0d, 0xcf, 0x92, 0x9f, + 0xc3, 0xfc, 0xec, 0x37, 0x76, 0x60, 0x02, 0x12, 0xa7, 0x15, 0x8d, 0x7a, 0x4a, 0x96, 0xe6, 0x3a, 0xeb, 0x7d, 0x13, + 0xce, 0x28, 0x99, 0x06, 0x4c, 0xac, 0x65, 0x16, 0x40, 0x27, 0x52, 0x09, 0x9c, 0x25, 0x95, 0x75, 0x34, 0x93, 0x47, + 0x0b, 0xbd, 0x37, 0xf1, 0xf4, 0x45, 0x49, 0x7a, 0x05, 0xfe, 0xd8, 0x52, 0x63, 0x51, 0xa6, 0x6d, 0x5e, 0x04, 0xaa, + 0x66, 0x2d, 0x8f, 0x83, 0x5c, 0x7a, 0xbd, 0xac, 0x7a, 0xe5, 0x69, 0x2d, 0xd5, 0x05, 0xda, 0x4e, 0xc8, 0x31, 0x6a, + 0x51, 0x5e, 0x41, 0x1a, 0x8a, 0xf6, 0x40, 0xe9, 0x6b, 0x98, 0xd0, 0x03, 0x7e, 0xa9, 0x06, 0x65, 0x34, 0x78, 0x67, + 0xcd, 0x16, 0x17, 0x93, 0x23, 0x67, 0xcd, 0x00, 0x02, 0x6e, 0xd7, 0xdb, 0x52, 0x13, 0x21, 0x15, 0x6e, 0x30, 0x4c, + 0x8b, 0x44, 0xfd, 0x44, 0x73, 0x58, 0xbb, 0x42, 0x52, 0x87, 0x58, 0x87, 0x16, 0x26, 0xa0, 0x35, 0xe3, 0x62, 0x43, + 0x8b, 0xb2, 0x13, 0x39, 0xb0, 0x36, 0x8b, 0x24, 0xe3, 0xb0, 0x47, 0x33, 0x6d, 0x06, 0x72, 0x2d, 0xc1, 0x65, 0x89, + 0xe8, 0x2d, 0x8a, 0xee, 0x9e, 0xc8, 0xb0, 0xb9, 0xc9, 0x4a, 0xa6, 0xcc, 0xf4, 0x68, 0x08, 0xb4, 0x6b, 0x0f, 0x06, + 0xdb, 0xa1, 0x82, 0xbf, 0x84, 0x77, 0x49, 0xd2, 0xfd, 0x3e, 0x7b, 0xdc, 0x81, 0x0f, 0xe1, 0xd4, 0x69, 0xbf, 0x09, + 0xb0, 0xce, 0x81, 0x53, 0xac, 0x13, 0x63, 0x9c, 0x71, 0x54, 0xef, 0x66, 0xb4, 0xb1, 0x9f, 0x10, 0x43, 0xa0, 0x70, + 0xf8, 0xb6, 0x47, 0x2b, 0xaf, 0xda, 0xb1, 0x36, 0xd3, 0x4b, 0xda, 0x91, 0x8f, 0xc8, 0x11, 0x4c, 0x82, 0x48, 0x5a, + 0x26, 0x10, 0x9a, 0x31, 0x78, 0x0b, 0x57, 0xb0, 0x36, 0x67, 0x40, 0x4b, 0x5d, 0x2f, 0x14, 0x5a, 0xe0, 0xe9, 0x19, + 0x03, 0x93, 0xc2, 0xbc, 0x83, 0x4b, 0xda, 0x7f, 0x34, 0xc2, 0xac, 0xa1, 0x5a, 0xad, 0xed, 0x36, 0x2d, 0x1f, 0x12, + 0x05, 0xc2, 0xf6, 0x53, 0xbd, 0xe9, 0x7e, 0xe4, 0x67, 0xd7, 0x02, 0xd4, 0x55, 0x6c, 0xbb, 0xc6, 0x8b, 0x7a, 0xef, + 0x6d, 0x6b, 0xf4, 0xb1, 0xbf, 0xd2, 0xf0, 0x2d, 0xc4, 0xb0, 0x2c, 0x99, 0x30, 0x5d, 0x99, 0x0f, 0x7e, 0xce, 0x14, + 0xf7, 0x79, 0x1a, 0x93, 0xee, 0x0e, 0x25, 0x26, 0xf1, 0x75, 0x67, 0x77, 0xd8, 0xb6, 0x8c, 0xe8, 0x65, 0xfd, 0x56, + 0xaf, 0xb0, 0xd3, 0xe7, 0xdf, 0x41, 0x4c, 0xbd, 0xa2, 0x64, 0x3c, 0x4c, 0xb4, 0xc5, 0x43, 0x50, 0x18, 0xbf, 0xca, + 0x9c, 0x0c, 0x3e, 0xb9, 0xb7, 0x2d, 0x24, 0xc2, 0x6f, 0xe3, 0x55, 0x9c, 0xcc, 0x5a, 0x34, 0x9c, 0x76, 0x3d, 0x29, + 0x0e, 0x8c, 0x84, 0xd6, 0xcc, 0xb7, 0x49, 0x5a, 0x73, 0x29, 0x0c, 0xbf, 0x58, 0x88, 0x8d, 0x66, 0xe3, 0x28, 0x5a, + 0x0a, 0xa0, 0xa5, 0x3d, 0x72, 0xc9, 0x62, 0xe0, 0x61, 0xc1, 0x43, 0xf9, 0xd2, 0x12, 0x96, 0x3d, 0x7f, 0x9d, 0x4e, + 0xe4, 0x9b, 0x9b, 0x9c, 0x6e, 0xb7, 0x73, 0x75, 0xf9, 0xfc, 0x4b, 0x1a, 0x51, 0x56, 0xbf, 0xe8, 0x91, 0x44, 0x35, + 0xd6, 0xc7, 0xd6, 0xf3, 0x2f, 0xb9, 0x57, 0x27, 0x92, 0xd3, 0xce, 0x76, 0xc0, 0x70, 0x4d, 0x01, 0x5b, 0xa6, 0xed, + 0x61, 0x53, 0xf6, 0xf7, 0x5b, 0x17, 0x07, 0x75, 0x41, 0xe2, 0x13, 0xe6, 0x14, 0x49, 0x8a, 0xc7, 0x06, 0x1d, 0x08, + 0xb5, 0x0c, 0xa8, 0x47, 0xb0, 0x2f, 0x27, 0x76, 0xe4, 0x9b, 0xa7, 0xd1, 0x2f, 0xca, 0x74, 0xe8, 0x90, 0xa6, 0x43, + 0x1e, 0x02, 0x1b, 0xb7, 0xb9, 0xcb, 0x81, 0x22, 0x71, 0xa0, 0x22, 0x66, 0xda, 0x2f, 0x52, 0x7b, 0x39, 0x2f, 0xc2, + 0x9c, 0xa3, 0xea, 0xca, 0xe9, 0x53, 0x62, 0xdf, 0x85, 0x18, 0x7d, 0x88, 0x5b, 0x79, 0x67, 0x87, 0xbd, 0x91, 0x7e, + 0x88, 0x73, 0xf3, 0x25, 0x0e, 0x8c, 0xa8, 0xd2, 0x1c, 0xcd, 0x42, 0xa4, 0x14, 0xb9, 0xa6, 0x95, 0x7d, 0x47, 0x91, + 0xe9, 0x7a, 0x16, 0x7d, 0x79, 0x96, 0xc8, 0xec, 0x89, 0x30, 0x99, 0x43, 0xbd, 0x83, 0x97, 0x94, 0x68, 0xd6, 0xb6, + 0x5b, 0x07, 0x04, 0x76, 0x02, 0xe6, 0x69, 0x89, 0xbc, 0x4e, 0xc9, 0xc9, 0x7f, 0x7c, 0xfb, 0x2f, 0x2a, 0x79, 0x04, + 0x0f, 0x35, 0x75, 0x61, 0x19, 0x2d, 0x44, 0x1c, 0xc7, 0xf9, 0xdd, 0xba, 0x4e, 0x40, 0x8c, 0xf5, 0xe7, 0x67, 0x6b, + 0xcc, 0xd6, 0x41, 0xad, 0xa4, 0xa1, 0x48, 0xcc, 0xcd, 0x8e, 0x99, 0x95, 0xc9, 0x95, 0x71, 0xc5, 0x6e, 0x83, 0x7e, + 0x12, 0x59, 0x28, 0xd1, 0x8c, 0xe2, 0xe1, 0x14, 0x8b, 0xa4, 0xa4, 0x15, 0x16, 0xb5, 0xe4, 0x33, 0x43, 0x39, 0x4c, + 0x96, 0xa5, 0x6d, 0x67, 0x2e, 0x85, 0x64, 0x2d, 0x4b, 0x80, 0xec, 0x62, 0x89, 0x9a, 0xf3, 0x8a, 0x5c, 0x86, 0x15, + 0x91, 0x13, 0xc0, 0x38, 0x30, 0x85, 0x9f, 0xfc, 0x49, 0x68, 0x7f, 0x27, 0x0f, 0x3e, 0x85, 0xf0, 0x32, 0x4e, 0xd0, + 0x83, 0x71, 0x2b, 0x98, 0xc1, 0xc1, 0x10, 0xbd, 0x50, 0xc2, 0xba, 0xdc, 0x89, 0x17, 0x24, 0xcb, 0x52, 0x37, 0x40, + 0x68, 0xd6, 0xcd, 0x5a, 0xdd, 0xb7, 0xb0, 0x2a, 0x59, 0x42, 0x68, 0xc4, 0x4a, 0x2b, 0xb6, 0x62, 0x9b, 0x82, 0x8e, + 0x28, 0xc9, 0x09, 0x60, 0x66, 0x00, 0xce, 0x4e, 0x22, 0x2a, 0x35, 0xb0, 0x8e, 0x61, 0xc5, 0x62, 0xa6, 0x31, 0x29, + 0x80, 0xd5, 0xae, 0xf1, 0x51, 0x36, 0x4d, 0x17, 0x28, 0x54, 0x5f, 0x3b, 0x27, 0xe8, 0xa3, 0x4b, 0x2b, 0xf5, 0xd8, + 0x27, 0x60, 0xff, 0xe3, 0x0e, 0xea, 0x60, 0xd1, 0xa8, 0xfb, 0xd6, 0xbf, 0xc4, 0x90, 0xe7, 0x35, 0x62, 0xdc, 0xdc, + 0x1f, 0x38, 0xd5, 0x01, 0x9b, 0x64, 0x35, 0x1b, 0x49, 0x9c, 0x04, 0x3d, 0x87, 0xea, 0x4d, 0x28, 0xc1, 0x50, 0x5d, + 0xba, 0xca, 0x9e, 0x47, 0x46, 0xbc, 0x35, 0x96, 0x95, 0x2c, 0xf9, 0x19, 0xd0, 0x05, 0xe5, 0x29, 0x21, 0x38, 0xdb, + 0xce, 0x4a, 0xa2, 0x30, 0xd6, 0xa2, 0x38, 0xc6, 0x09, 0xbf, 0x23, 0x59, 0x19, 0x97, 0x4c, 0x51, 0x98, 0xf2, 0x39, + 0x38, 0x57, 0xe6, 0xc3, 0xdf, 0x9e, 0xfc, 0xf2, 0x9c, 0xae, 0x2e, 0x45, 0xec, 0xf3, 0xe3, 0x9c, 0x5e, 0x7f, 0x9b, + 0xfe, 0x25, 0xf3, 0x59, 0xf8, 0x27, 0xbc, 0xb3, 0x84, 0x9c, 0x77, 0x3f, 0x3e, 0x15, 0x2d, 0x0e, 0x8a, 0x85, 0xae, + 0x62, 0x8b, 0x5a, 0x70, 0xfe, 0xfc, 0xca, 0x66, 0xaa, 0x3c, 0x26, 0x68, 0xa6, 0x92, 0xb2, 0xfa, 0x4d, 0x91, 0x02, + 0x69, 0x1b, 0x95, 0x84, 0x8d, 0xff, 0x31, 0x05, 0xc5, 0xff, 0x47, 0x19, 0x0a, 0x0d, 0x59, 0xfb, 0xeb, 0x2d, 0x93, + 0xfc, 0x0a, 0x9e, 0xff, 0x31, 0x29, 0x50, 0xab, 0x9f, 0x08, 0x50, 0x49, 0x5b, 0x49, 0xa5, 0x0f, 0x0e, 0x3c, 0xd6, + 0xd1, 0xe4, 0x8c, 0x69, 0x18, 0xcf, 0x3c, 0x61, 0x3f, 0x03, 0x86, 0xb6, 0x59, 0x97, 0xbc, 0xdb, 0x36, 0xf1, 0x1f, + 0x28, 0xbc, 0x29, 0x53, 0x1b, 0x8d, 0x41, 0x72, 0xaa, 0x00, 0x69, 0x8e, 0xb3, 0x55, 0xe8, 0x8a, 0x36, 0x9c, 0x73, + 0xb3, 0xa5, 0x05, 0x67, 0xc3, 0xd8, 0x6a, 0xf8, 0xf2, 0x17, 0xc4, 0x56, 0xd8, 0x35, 0xa9, 0x83, 0xaa, 0xac, 0x79, + 0x71, 0x13, 0xfe, 0x09, 0xdb, 0x4b, 0x0c, 0x66, 0xf2, 0x92, 0xe6, 0x93, 0xe9, 0x08, 0x69, 0x9e, 0x21, 0x67, 0x36, + 0xff, 0xa3, 0x98, 0xc9, 0xf2, 0x52, 0x46, 0x33, 0x5f, 0x26, 0xc6, 0xbf, 0xf9, 0x33, 0x09, 0xec, 0x57, 0xce, 0x87, + 0x51, 0x64, 0x62, 0x79, 0x6c, 0x1b, 0x2f, 0xc8, 0x7d, 0x0c, 0xdd, 0x68, 0xb1, 0xca, 0xb2, 0x8c, 0x7d, 0xa5, 0xcc, + 0xd2, 0x18, 0x83, 0xc3, 0xd3, 0xf5, 0x88, 0x2a, 0x74, 0xd6, 0x87, 0x3c, 0x97, 0xfe, 0x65, 0x95, 0x0a, 0xd3, 0x87, + 0x32, 0x53, 0x5a, 0x6f, 0x81, 0xd8, 0xeb, 0x89, 0xe2, 0xc3, 0x57, 0x12, 0x6d, 0x72, 0x24, 0xe7, 0x83, 0x53, 0x58, + 0x4d, 0xf2, 0xda, 0x23, 0x13, 0xf1, 0x0c, 0x3f, 0xd9, 0xf6, 0xf3, 0x5c, 0x49, 0xcf, 0x2f, 0x3e, 0xc3, 0x6e, 0x97, + 0xc6, 0xde, 0x4b, 0x7e, 0x27, 0x3f, 0x47, 0x1f, 0x06, 0x77, 0xe4, 0xa4, 0xa4, 0xb6, 0xbf, 0xf4, 0x39, 0xae, 0x03, + 0x65, 0xf7, 0x3f, 0xa8, 0xbe, 0x86, 0x2c, 0x2a, 0x1e, 0x4d, 0xd2, 0x15, 0xe6, 0x60, 0xa9, 0x1f, 0x66, 0x2e, 0xfc, + 0x45, 0x9a, 0xe0, 0x2c, 0xba, 0xd1, 0xcb, 0x83, 0x69, 0x3d, 0xf9, 0x47, 0x64, 0xe9, 0x4f, 0xb3, 0x6c, 0x72, 0x38, + 0x0d, 0x17, 0xfc, 0x48, 0x46, 0x3f, 0xde, 0xab, 0xdb, 0x93, 0x7a, 0xad, 0x97, 0x7b, 0x08, 0x98, 0x7e, 0xa4, 0x21, + 0x92, 0x37, 0xcb, 0x54, 0x61, 0x40, 0xf2, 0x06, 0x17, 0xb4, 0x06, 0x5d, 0x6a, 0x9a, 0xa5, 0x55, 0xe0, 0x8c, 0xee, + 0x09, 0x3a, 0xa8, 0xe0, 0x68, 0xb9, 0xf2, 0xf5, 0x59, 0xc4, 0xe2, 0xa4, 0x62, 0xbb, 0x2d, 0x8a, 0x68, 0xcf, 0xe0, + 0x38, 0x5a, 0x44, 0x45, 0x66, 0xf4, 0xbb, 0xd4, 0x56, 0x28, 0xfb, 0x82, 0x15, 0xdc, 0xd1, 0x17, 0xb2, 0x52, 0xae, + 0xa5, 0x21, 0xdf, 0x4a, 0xc9, 0x16, 0x1a, 0x50, 0x29, 0xc5, 0x96, 0xaa, 0x71, 0x19, 0x07, 0x57, 0xc6, 0xe6, 0x58, + 0xc2, 0x92, 0x56, 0xc5, 0xab, 0xc8, 0x90, 0x8e, 0xaf, 0x13, 0x41, 0xca, 0x65, 0x19, 0x38, 0x3c, 0x9c, 0xa3, 0x0c, + 0x79, 0xb2, 0x0d, 0x25, 0x79, 0x26, 0x60, 0x0e, 0x66, 0x5c, 0xab, 0x27, 0xd5, 0xaa, 0x01, 0x8d, 0x14, 0xd5, 0x55, + 0x4c, 0x67, 0xab, 0x03, 0xea, 0xf8, 0x15, 0x81, 0x59, 0x58, 0xc6, 0xf3, 0x28, 0xc4, 0x5d, 0x29, 0xc3, 0x2e, 0xdc, + 0x4e, 0x12, 0xac, 0xc7, 0xc9, 0x70, 0xb8, 0xa3, 0x8d, 0x9d, 0x8b, 0x5e, 0xe3, 0x47, 0x21, 0x5c, 0x4a, 0xf7, 0x18, + 0x19, 0x81, 0xc9, 0xc5, 0xce, 0xa5, 0xf3, 0x49, 0x13, 0xee, 0x64, 0x41, 0x00, 0x44, 0x1e, 0xf6, 0x7d, 0xb0, 0xb8, + 0x3c, 0xea, 0x2c, 0x60, 0x62, 0x9e, 0x2b, 0x3b, 0x2a, 0x6f, 0xe0, 0xab, 0x75, 0x28, 0x2b, 0x7b, 0x47, 0x5f, 0x26, + 0x31, 0x56, 0xda, 0x8c, 0xdf, 0x96, 0xe5, 0x51, 0x7a, 0x63, 0x59, 0x4d, 0x5b, 0x54, 0x0f, 0x1e, 0xdd, 0xe1, 0xda, + 0x11, 0x63, 0x63, 0x99, 0x75, 0x62, 0x11, 0x98, 0xff, 0x3e, 0xb3, 0x08, 0x1b, 0x55, 0x2d, 0xdf, 0x04, 0xd2, 0x11, + 0xa3, 0x59, 0xd4, 0xf0, 0x80, 0x4f, 0x47, 0xcb, 0x18, 0x16, 0x33, 0x82, 0x59, 0xf6, 0xa0, 0xe5, 0x6a, 0x08, 0xd2, + 0x8c, 0x47, 0x89, 0x20, 0xdd, 0x88, 0xa1, 0x19, 0xc9, 0x19, 0x01, 0x9b, 0xa4, 0x10, 0x83, 0x67, 0xc0, 0xfe, 0xd8, + 0x39, 0x22, 0x15, 0x1c, 0xd1, 0x03, 0xc2, 0xaa, 0x8a, 0xcb, 0x0f, 0x0b, 0x1b, 0x06, 0x62, 0x48, 0xc5, 0x8b, 0x59, + 0xf9, 0xb4, 0x00, 0x18, 0x59, 0xa3, 0x8a, 0x87, 0x64, 0x88, 0x8c, 0xbc, 0x69, 0x91, 0x51, 0x87, 0x64, 0x0c, 0xbf, + 0x11, 0x31, 0x90, 0x94, 0x9c, 0x41, 0x1e, 0x73, 0xb2, 0x55, 0x2e, 0x5f, 0xe6, 0x2e, 0xfd, 0xd3, 0xfe, 0x54, 0x8e, + 0xf7, 0xa9, 0xd4, 0xd0, 0xa6, 0x97, 0x71, 0x39, 0x17, 0x15, 0x07, 0xd7, 0xcb, 0x76, 0xd3, 0xd3, 0x8e, 0xe6, 0x0b, + 0xd7, 0xe6, 0x66, 0xbb, 0x30, 0xde, 0x1d, 0xab, 0xec, 0xc3, 0x27, 0x94, 0x71, 0x41, 0x33, 0x3c, 0xec, 0xd4, 0x6d, + 0x23, 0x63, 0x18, 0x41, 0xff, 0x36, 0xbe, 0x9e, 0xc8, 0x2e, 0x5d, 0xe6, 0x82, 0xe4, 0x30, 0x6f, 0xf0, 0x6d, 0x61, + 0xfc, 0x25, 0xd9, 0x8d, 0xd6, 0xc9, 0xba, 0xa7, 0x35, 0xba, 0x7b, 0x69, 0xc3, 0x17, 0x1c, 0xa0, 0xf3, 0x4b, 0x1c, + 0xea, 0xd1, 0x14, 0x58, 0xee, 0xf3, 0xa6, 0x3e, 0x41, 0xa6, 0xf1, 0xb0, 0xb6, 0x03, 0x72, 0x8d, 0xe7, 0xba, 0x8d, + 0x1a, 0xf5, 0x1d, 0x5b, 0xa6, 0xb7, 0xc4, 0x56, 0xde, 0xdb, 0x6c, 0x83, 0x39, 0x50, 0xf5, 0xdf, 0x3e, 0x44, 0x22, + 0x18, 0x49, 0xd3, 0x3e, 0x47, 0xeb, 0x77, 0x2e, 0xcf, 0xfc, 0xeb, 0xcc, 0xd1, 0x86, 0x95, 0x61, 0x46, 0x83, 0x19, + 0x5f, 0xe9, 0xce, 0xd0, 0xcc, 0x6b, 0xe6, 0x1e, 0xb8, 0xdd, 0x4b, 0xef, 0xc6, 0x9a, 0x35, 0xfa, 0x61, 0xba, 0x53, + 0x92, 0x59, 0xe0, 0x74, 0xfc, 0x9b, 0xa0, 0xa7, 0x82, 0xf4, 0xa3, 0x3a, 0xb0, 0xf8, 0x8e, 0x93, 0x98, 0x90, 0x0c, + 0x39, 0x58, 0x90, 0xab, 0xe6, 0xbd, 0xa7, 0xdb, 0x5e, 0x9b, 0xb2, 0x46, 0x5c, 0x3a, 0x5d, 0x7d, 0x79, 0xbd, 0xf0, + 0x02, 0xed, 0xf1, 0xde, 0x8f, 0x36, 0xde, 0xd0, 0xc9, 0xe3, 0x0d, 0x54, 0x44, 0xfc, 0x86, 0xdc, 0xd0, 0x18, 0x5f, + 0x85, 0x29, 0x03, 0xc7, 0x7c, 0xef, 0xae, 0xbd, 0x69, 0xee, 0xf1, 0x8b, 0xb9, 0x56, 0x67, 0x4e, 0xb4, 0x57, 0x66, + 0xbd, 0x32, 0x71, 0xb1, 0xa0, 0x24, 0x1f, 0x1e, 0x10, 0x5c, 0xc7, 0x3f, 0xad, 0x56, 0xe1, 0xae, 0xc7, 0x0f, 0x72, + 0xb0, 0x14, 0x03, 0xd3, 0x0d, 0x5c, 0x07, 0x62, 0x1d, 0xc6, 0x16, 0x69, 0x60, 0xa9, 0x1f, 0xca, 0x88, 0x51, 0x30, + 0x7e, 0x7e, 0xbc, 0x8c, 0x7a, 0xc7, 0x7f, 0x58, 0x02, 0x58, 0xb7, 0x11, 0x8e, 0x40, 0x33, 0x2b, 0x4e, 0x39, 0x1f, + 0x17, 0xfa, 0x08, 0xae, 0x6c, 0xca, 0xbe, 0x61, 0xe0, 0x90, 0x15, 0x98, 0xf6, 0x47, 0x43, 0xe5, 0xf7, 0x4f, 0xe4, + 0xc7, 0xb5, 0xbb, 0xdf, 0x6b, 0xd3, 0xc6, 0x0c, 0x47, 0x8f, 0x90, 0x89, 0x0e, 0xe6, 0x40, 0x87, 0x47, 0xc3, 0x62, + 0xca, 0x8e, 0x9b, 0xda, 0xb3, 0x1a, 0x6f, 0xc9, 0xf1, 0x18, 0x7e, 0xad, 0xa2, 0xd9, 0x78, 0x90, 0x6e, 0xab, 0x5c, + 0xcf, 0x76, 0x94, 0x6f, 0x7e, 0xe8, 0x34, 0xd9, 0xc2, 0x37, 0xfa, 0xd7, 0x39, 0xb4, 0x68, 0xbe, 0x46, 0xb4, 0xc8, + 0x1a, 0xea, 0x03, 0xf0, 0xe3, 0x42, 0x63, 0xcd, 0x63, 0x28, 0x08, 0x9b, 0x9b, 0xd6, 0xb5, 0x0d, 0x0d, 0x9a, 0x39, + 0x79, 0x27, 0x48, 0x51, 0x00, 0x89, 0x3b, 0x56, 0xa1, 0xa7, 0x73, 0x10, 0x18, 0x3c, 0xf6, 0x3e, 0xb5, 0x6e, 0x4c, + 0x51, 0x97, 0x7b, 0x4c, 0x34, 0x76, 0xb3, 0x6f, 0x8b, 0xf6, 0xe9, 0x57, 0xfa, 0x8f, 0xc8, 0x85, 0x08, 0x0c, 0x9e, + 0x1f, 0x00, 0xfb, 0x38, 0xb0, 0x15, 0xcd, 0x26, 0x95, 0x37, 0x7c, 0x6e, 0x5f, 0x7f, 0xee, 0xcb, 0xa7, 0xd9, 0x5c, + 0x20, 0xd1, 0xf7, 0xe7, 0xa6, 0x4e, 0xa6, 0x2a, 0xd7, 0x72, 0x07, 0xbb, 0x38, 0x9a, 0x86, 0x18, 0x2d, 0x00, 0x1a, + 0x65, 0x20, 0xf8, 0x09, 0x3e, 0x52, 0x67, 0xfc, 0xf3, 0x79, 0x97, 0xe7, 0x74, 0xff, 0xe1, 0x2d, 0x99, 0xde, 0xd2, + 0x1c, 0xf0, 0x6d, 0xc8, 0xff, 0xed, 0xbf, 0xd1, 0xad, 0x63, 0xac, 0x08, 0xcc, 0x0e, 0xae, 0xcd, 0xa2, 0x5c, 0x7a, + 0x5b, 0x9b, 0xb8, 0xf2, 0x71, 0xf6, 0x03, 0xdc, 0xe6, 0xbe, 0x11, 0x18, 0x4d, 0xe1, 0x63, 0x16, 0x93, 0xb6, 0xca, + 0x75, 0xd3, 0x13, 0x66, 0xdb, 0xe8, 0x12, 0xa9, 0x21, 0xb8, 0xde, 0xc7, 0xb2, 0xd8, 0x78, 0x32, 0x92, 0xd5, 0xf6, + 0xc5, 0x53, 0x01, 0x2e, 0x34, 0x96, 0x7f, 0xa2, 0xce, 0xdb, 0x3d, 0x6a, 0x93, 0xd3, 0xfe, 0x87, 0xd6, 0xee, 0xb9, + 0x54, 0x74, 0x6d, 0x8f, 0x4d, 0x9f, 0x5a, 0x0b, 0x86, 0x60, 0xdf, 0x92, 0x15, 0x7b, 0x01, 0xd0, 0x0e, 0xf0, 0x42, + 0xb5, 0x89, 0x6e, 0xab, 0xfe, 0xb1, 0x07, 0xa4, 0x31, 0xbe, 0xc7, 0x24, 0x55, 0x6e, 0x64, 0x42, 0xcd, 0x22, 0x41, + 0xd1, 0x71, 0x7c, 0x7c, 0x47, 0x5b, 0xad, 0x87, 0x17, 0x62, 0x55, 0x0a, 0x63, 0xcb, 0xdc, 0x9b, 0x32, 0xc8, 0x69, + 0xaa, 0x0f, 0x49, 0x0b, 0xb7, 0x0d, 0x5d, 0x0a, 0x1f, 0x8b, 0x47, 0xad, 0x76, 0x20, 0x27, 0x1b, 0x08, 0xe1, 0x88, + 0xce, 0x5f, 0x4a, 0x9d, 0x02, 0xbc, 0x0e, 0xdc, 0x15, 0xc7, 0xb0, 0x6c, 0xc7, 0xdd, 0xa8, 0xd5, 0x16, 0xfe, 0xec, + 0x00, 0xd4, 0xb0, 0xae, 0xda, 0xed, 0x1d, 0xf5, 0xba, 0x4c, 0x61, 0x94, 0x0a, 0x09, 0x08, 0x87, 0xcb, 0xd9, 0xa4, + 0x20, 0x94, 0x04, 0x8c, 0x55, 0x51, 0xfd, 0xa1, 0xcc, 0x6d, 0xb7, 0x1b, 0x35, 0xe7, 0x91, 0x78, 0x18, 0xa8, 0x58, + 0x8f, 0x69, 0x6d, 0xe6, 0xe0, 0x80, 0x42, 0xd4, 0x6c, 0x7a, 0x2c, 0x7f, 0x58, 0x8f, 0xe4, 0x52, 0xf0, 0x48, 0xc4, + 0xe2, 0x6d, 0x8f, 0xd1, 0xe4, 0x8f, 0x67, 0xc8, 0xec, 0x2d, 0x17, 0x3f, 0xcc, 0xe1, 0x76, 0x62, 0x97, 0x01, 0x4f, + 0x30, 0x31, 0x35, 0xea, 0xc9, 0x56, 0xf4, 0x14, 0x90, 0x0e, 0xb3, 0x82, 0x01, 0xc2, 0x29, 0xf5, 0xcb, 0x68, 0xcc, + 0x9b, 0xcb, 0x95, 0x5b, 0x89, 0x46, 0xb4, 0x94, 0x85, 0xb6, 0xdc, 0x96, 0x1f, 0x26, 0x94, 0xac, 0xb8, 0xa6, 0xb6, + 0x99, 0xad, 0xa2, 0x45, 0x2b, 0x08, 0x7f, 0x5c, 0xcd, 0x8c, 0xa8, 0xbf, 0x90, 0x6e, 0xd6, 0x74, 0x77, 0x06, 0x69, + 0x35, 0xa7, 0x76, 0x76, 0x8e, 0xe6, 0x82, 0x06, 0xea, 0x35, 0x82, 0x8c, 0xc5, 0xa5, 0x26, 0xe5, 0xac, 0x73, 0xa1, + 0xc6, 0x1b, 0x86, 0xaf, 0x9b, 0xa4, 0x5e, 0x94, 0x36, 0xae, 0x6e, 0x74, 0xea, 0x4b, 0xd0, 0xc1, 0xa0, 0x83, 0x84, + 0x94, 0x5a, 0x85, 0x8a, 0xec, 0xd3, 0xc5, 0xba, 0x70, 0x9a, 0x90, 0x74, 0xba, 0xe2, 0xe5, 0xa4, 0x78, 0xcf, 0x08, + 0x71, 0xf4, 0x03, 0x52, 0x26, 0x8f, 0x50, 0x93, 0xbc, 0xf6, 0x01, 0x65, 0xf2, 0x34, 0x6a, 0x71, 0xd8, 0xd0, 0x06, + 0x11, 0x0f, 0x06, 0xc7, 0xe3, 0x08, 0x52, 0xc1, 0x7a, 0x4a, 0x46, 0x97, 0x00, 0x49, 0x2f, 0xc9, 0xd3, 0x03, 0x0b, + 0xa6, 0xe6, 0x4e, 0x29, 0x28, 0x9e, 0x0c, 0x30, 0xb4, 0x95, 0x46, 0x65, 0xc9, 0x0c, 0x45, 0x0f, 0x74, 0xeb, 0xf7, + 0x14, 0x0a, 0x18, 0x23, 0xce, 0x1e, 0xfb, 0xdc, 0x04, 0x10, 0x14, 0x87, 0x35, 0x08, 0xdd, 0x67, 0x04, 0x1b, 0x79, + 0x46, 0xc1, 0x22, 0xcf, 0x07, 0xe4, 0xa8, 0xec, 0x65, 0x35, 0xf7, 0x5f, 0xce, 0x90, 0x0d, 0x0c, 0x1e, 0xd5, 0x93, + 0x4e, 0xae, 0xf5, 0xeb, 0x70, 0x82, 0x9c, 0xd1, 0xa7, 0xac, 0x9e, 0xb4, 0x73, 0x53, 0x4f, 0xd1, 0xac, 0x50, 0x7f, + 0xe6, 0x1e, 0x5e, 0xe1, 0x5b, 0x39, 0x33, 0xca, 0x22, 0x15, 0xf1, 0xc2, 0x0f, 0x60, 0xe3, 0xe7, 0x59, 0xc7, 0xe0, + 0xf0, 0xc4, 0xd9, 0xea, 0x84, 0x38, 0xc4, 0x35, 0x39, 0xf8, 0xb8, 0x45, 0x8c, 0x1a, 0x34, 0x26, 0xb7, 0xa8, 0xd6, + 0x94, 0x78, 0x0b, 0xf5, 0xa9, 0xc1, 0x50, 0x1b, 0x27, 0x5d, 0x59, 0x09, 0x26, 0x34, 0xbc, 0xe4, 0x53, 0x25, 0xeb, + 0x28, 0x56, 0xf8, 0xe5, 0x0a, 0x30, 0x1b, 0x98, 0xe6, 0xae, 0x13, 0x0c, 0x56, 0x9a, 0x53, 0x33, 0xf2, 0xea, 0xdc, + 0x21, 0x94, 0xba, 0xd1, 0x0b, 0x98, 0x00, 0x86, 0x43, 0x46, 0x1b, 0xf4, 0xf2, 0xc2, 0x97, 0x0b, 0x52, 0xb5, 0x23, + 0x87, 0x0c, 0x16, 0x39, 0x91, 0x06, 0x87, 0xf8, 0x9f, 0x09, 0x41, 0xd2, 0x66, 0x07, 0xe2, 0xcd, 0xb1, 0x9b, 0x3a, + 0x56, 0x3d, 0x07, 0xf9, 0xdd, 0x0d, 0xf6, 0x5a, 0xf1, 0xda, 0x34, 0xa9, 0xa1, 0x57, 0xa3, 0x71, 0x28, 0x48, 0xcb, + 0x8b, 0xd9, 0x95, 0x27, 0x4d, 0xa2, 0xdb, 0xd2, 0x55, 0x83, 0x1e, 0xc2, 0x3b, 0xf3, 0x90, 0xdf, 0xf0, 0xbe, 0x9e, + 0xcc, 0x05, 0x45, 0x87, 0x70, 0x0d, 0xb9, 0x89, 0x44, 0xfd, 0x44, 0x57, 0x6c, 0x41, 0x59, 0xec, 0x67, 0xa8, 0x03, + 0xbc, 0xb4, 0x38, 0x41, 0x61, 0x8f, 0xd4, 0xb8, 0xe0, 0xb6, 0x27, 0x0c, 0x53, 0xeb, 0xb2, 0x70, 0xd9, 0xe9, 0xb6, + 0x68, 0x72, 0x2d, 0x50, 0x0c, 0x02, 0xcd, 0x79, 0xfe, 0x7a, 0x7b, 0xea, 0x1a, 0xcf, 0xe0, 0x74, 0xec, 0x60, 0x74, + 0x32, 0xe3, 0x2a, 0x61, 0x83, 0xa8, 0xc3, 0x5d, 0xba, 0x69, 0x20, 0x97, 0x3d, 0xa8, 0x6e, 0x9e, 0xf7, 0xa7, 0xb3, + 0x6b, 0xe3, 0xad, 0x06, 0xd0, 0x1e, 0x00, 0xca, 0x8b, 0x5d, 0xfa, 0xc0, 0x89, 0x9b, 0x76, 0xf7, 0x25, 0xd6, 0x1b, + 0xa8, 0x91, 0x88, 0x20, 0x0a, 0x48, 0x98, 0xfa, 0xe7, 0x4e, 0xd9, 0xf4, 0xf1, 0x1d, 0xaf, 0x3a, 0x51, 0xa8, 0x90, + 0x34, 0x70, 0x8d, 0xa3, 0x87, 0x43, 0x1b, 0x73, 0xc0, 0x1a, 0xe3, 0x44, 0xb8, 0xdf, 0x62, 0xdf, 0xb5, 0x56, 0x1c, + 0xd7, 0x65, 0xb8, 0xe8, 0x3b, 0x45, 0x35, 0x07, 0xc3, 0xab, 0xc3, 0xe3, 0x3c, 0xf8, 0x15, 0xaa, 0xa8, 0xe4, 0xdb, + 0x2e, 0x47, 0x1e, 0x57, 0xa0, 0xcb, 0xf9, 0xb6, 0xbd, 0xbf, 0xc1, 0x30, 0x80, 0x28, 0xf0, 0x41, 0x15, 0xbb, 0x54, + 0x39, 0xb1, 0x3e, 0x70, 0xd6, 0x08, 0x32, 0xaf, 0x22, 0xc4, 0x2b, 0x2e, 0xf9, 0x7d, 0x07, 0x80, 0x5d, 0xb9, 0xca, + 0xb2, 0xae, 0x2b, 0xff, 0x6f, 0x86, 0x11, 0x42, 0xc6, 0xd0, 0xb1, 0x6f, 0xb7, 0xe4, 0x34, 0x06, 0xf5, 0x74, 0xdc, + 0xec, 0x4d, 0x3c, 0x37, 0x0e, 0x5c, 0x00, 0x14, 0xb1, 0x7c, 0xcd, 0x13, 0xde, 0x45, 0x9c, 0x05, 0x88, 0x0d, 0x92, + 0xcf, 0x60, 0xca, 0x71, 0xbf, 0xbe, 0x96, 0x2c, 0xab, 0x38, 0x73, 0x50, 0x1f, 0x9c, 0xfb, 0xa7, 0xe6, 0xf0, 0xb2, + 0x4d, 0x31, 0x0e, 0xc7, 0x8f, 0x3f, 0xd0, 0x55, 0x0c, 0xac, 0x54, 0x7b, 0x20, 0x2d, 0x98, 0xf7, 0x5a, 0xa1, 0x61, + 0xa1, 0xf5, 0xa1, 0x4f, 0x4d, 0xe6, 0x7d, 0xfc, 0x78, 0x55, 0x3d, 0xd0, 0x01, 0x3a, 0xb9, 0x43, 0x69, 0x7f, 0x68, + 0xa9, 0x6f, 0x56, 0xbf, 0x44, 0x05, 0x76, 0x99, 0x83, 0xdd, 0x1e, 0xc7, 0x39, 0x9b, 0x15, 0xd9, 0xd1, 0x2f, 0x44, + 0x97, 0x09, 0x3b, 0x7c, 0x9c, 0x9a, 0xe6, 0x0f, 0xb0, 0x2b, 0x5f, 0x6e, 0xfe, 0x44, 0x09, 0x4c, 0xd4, 0xd9, 0x60, + 0x1f, 0x01, 0xd0, 0x7d, 0xf0, 0x79, 0x82, 0xe4, 0xe3, 0xfa, 0x71, 0xf7, 0x5f, 0xfb, 0x03, 0xd4, 0x79, 0x57, 0x62, + 0xd9, 0x40, 0x9c, 0xb8, 0x42, 0x02, 0xda, 0x14, 0x42, 0x7f, 0x2a, 0xe5, 0x65, 0x1c, 0x8a, 0x67, 0x4d, 0x07, 0x95, + 0xbb, 0xb9, 0x4a, 0x26, 0xa0, 0xc1, 0x9b, 0x64, 0x96, 0xfd, 0x98, 0x0e, 0x7b, 0xa9, 0x69, 0xea, 0x27, 0x73, 0x5d, + 0x59, 0xad, 0xa6, 0x7c, 0xbb, 0x7d, 0x57, 0x7e, 0xba, 0xe9, 0x09, 0xd2, 0x78, 0xcf, 0x03, 0xb7, 0x75, 0xdf, 0xc8, + 0x1a, 0x0c, 0xf0, 0xcd, 0xc2, 0xa8, 0xca, 0xe9, 0x08, 0x85, 0xa8, 0x98, 0x07, 0x7f, 0x01, 0x62, 0x3c, 0xac, 0xc6, + 0xf1, 0x93, 0x4e, 0x27, 0xc0, 0x32, 0xfb, 0xf2, 0x66, 0x63, 0x1d, 0xb1, 0x27, 0x30, 0xbc, 0xa8, 0xcc, 0x15, 0x2f, + 0xd1, 0x31, 0x70, 0xdb, 0xbb, 0xb2, 0x4a, 0xa6, 0xcb, 0xe7, 0xbe, 0x0d, 0x0a, 0x5f, 0x1f, 0x90, 0x20, 0x05, 0x2a, + 0x05, 0xf6, 0xc1, 0xe6, 0xfb, 0x08, 0x68, 0x1e, 0xe7, 0xaa, 0x9e, 0xae, 0xdb, 0xab, 0x2d, 0xda, 0x6f, 0xe1, 0x88, + 0xad, 0xad, 0x82, 0x3d, 0xec, 0xe5, 0xbc, 0x77, 0x7a, 0xf3, 0xe0, 0x17, 0xa6, 0x61, 0x16, 0x12, 0xef, 0x36, 0xea, + 0x1b, 0xd6, 0x6b, 0xb6, 0xf4, 0x99, 0xcc, 0x9a, 0x78, 0x98, 0xac, 0xa7, 0x91, 0x87, 0x93, 0x53, 0x79, 0x8e, 0xcd, + 0x63, 0x61, 0x81, 0x37, 0x74, 0xf5, 0xf4, 0x9a, 0x29, 0x3e, 0x9a, 0x8a, 0xe4, 0x25, 0x3e, 0xb9, 0x8a, 0x16, 0x80, + 0x63, 0xa2, 0x72, 0x7a, 0xed, 0x02, 0x27, 0xd8, 0xeb, 0x45, 0x09, 0x0d, 0x8e, 0x91, 0x63, 0x5b, 0x82, 0xa7, 0xa3, + 0x33, 0x31, 0x6b, 0x5c, 0x40, 0xfa, 0x9a, 0xac, 0xbf, 0xae, 0x42, 0x9a, 0x91, 0x49, 0x06, 0x1f, 0x3d, 0x4b, 0x53, + 0x37, 0x2f, 0x37, 0x80, 0xc0, 0x51, 0xf1, 0xbe, 0x0b, 0x64, 0x79, 0xc3, 0x90, 0x3c, 0xc9, 0xc1, 0x4a, 0xb7, 0x27, + 0xb8, 0x09, 0xc1, 0xff, 0xf9, 0xdd, 0xc2, 0x4a, 0xa6, 0x22, 0x97, 0x63, 0x14, 0xa2, 0xd8, 0x3d, 0xe7, 0x06, 0x73, + 0x53, 0xc9, 0x55, 0x02, 0xb5, 0xfc, 0x83, 0xed, 0xcf, 0x6a, 0x48, 0x72, 0xe6, 0x0b, 0xc8, 0x8b, 0xd9, 0x45, 0x28, + 0x70, 0x56, 0x6f, 0x51, 0xc4, 0x06, 0x82, 0x3d, 0xe6, 0x5a, 0xd3, 0xc3, 0x1c, 0x48, 0x66, 0x35, 0xc0, 0x68, 0x4b, + 0x04, 0xa9, 0x17, 0xec, 0xec, 0x52, 0xd1, 0x7d, 0x5d, 0x50, 0xa4, 0xbb, 0x2c, 0x11, 0x53, 0x69, 0x25, 0xc7, 0xe7, + 0x2d, 0xf6, 0xd7, 0x9a, 0xaa, 0xa5, 0xbe, 0xca, 0xce, 0x31, 0xa6, 0xa7, 0xe3, 0x4f, 0x1b, 0x3f, 0x12, 0x7e, 0x9f, + 0x2b, 0x66, 0x30, 0x1b, 0x86, 0xd1, 0x2e, 0x61, 0xd2, 0x50, 0x7d, 0xa6, 0x38, 0x6e, 0x2c, 0x37, 0x5e, 0x6e, 0x5f, + 0x74, 0xc5, 0x56, 0xe9, 0x9f, 0xbb, 0x05, 0xbe, 0x26, 0xdd, 0x6b, 0x32, 0x2f, 0x48, 0x6c, 0xf0, 0x44, 0xf7, 0x60, + 0x9d, 0xa8, 0xae, 0xfd, 0xcb, 0xf3, 0xd3, 0x84, 0x10, 0xb3, 0x6d, 0x2b, 0xf2, 0xca, 0x0a, 0x50, 0x0e, 0x69, 0x37, + 0x01, 0xf5, 0xa5, 0x1b, 0xce, 0x83, 0xba, 0xb1, 0x81, 0x97, 0x90, 0x5a, 0x03, 0xc5, 0x2e, 0x8c, 0x7d, 0x75, 0x3a, + 0x0a, 0x69, 0x72, 0x26, 0x7b, 0x48, 0x28, 0x26, 0x0c, 0xd0, 0x3f, 0x2d, 0x8e, 0x66, 0x54, 0xd0, 0x7a, 0x77, 0x45, + 0x75, 0x2c, 0x3b, 0xd7, 0x40, 0x94, 0x99, 0x8d, 0x66, 0xda, 0x41, 0x86, 0x37, 0x0e, 0x91, 0xef, 0x32, 0xd3, 0xd1, + 0x81, 0x1d, 0x53, 0xee, 0xa4, 0x0e, 0x1b, 0x57, 0xd9, 0x91, 0x04, 0xf6, 0xbd, 0xcc, 0x89, 0x50, 0xf8, 0x66, 0xb6, + 0x3c, 0x90, 0xaf, 0x75, 0xe5, 0x7f, 0xcd, 0xa8, 0xcf, 0x0a, 0x77, 0xb4, 0x2d, 0x57, 0x33, 0x0e, 0x63, 0xc3, 0x81, + 0xcc, 0xc7, 0x07, 0x26, 0x78, 0xe5, 0xa9, 0x2a, 0xfb, 0x4d, 0xd8, 0x65, 0x0f, 0xec, 0xd9, 0xe4, 0x28, 0x2d, 0x1d, + 0xb5, 0xff, 0xb5, 0xcb, 0xa2, 0x43, 0xd1, 0xb0, 0x68, 0x5d, 0x24, 0x88, 0x5a, 0x6d, 0xf1, 0xc3, 0x3c, 0x22, 0x41, + 0xed, 0x8b, 0xc5, 0x4b, 0x7b, 0xe0, 0xa3, 0x29, 0x06, 0xbe, 0xcf, 0x58, 0x3c, 0x89, 0xbe, 0x3f, 0xc2, 0x49, 0x19, + 0x28, 0x1d, 0x3a, 0x03, 0xd2, 0xc4, 0x2a, 0x1e, 0x93, 0x3c, 0x67, 0xb1, 0xc2, 0x5e, 0xf2, 0x3a, 0x2a, 0x83, 0x16, + 0xc9, 0x3f, 0x47, 0x7c, 0xd0, 0xe0, 0x18, 0x3c, 0x8a, 0xbc, 0xf4, 0x4b, 0x70, 0xcb, 0x7d, 0x7f, 0xc0, 0x08, 0x26, + 0x54, 0x6f, 0xd2, 0x62, 0xf4, 0x42, 0x44, 0xe6, 0x23, 0x34, 0x1e, 0xbf, 0x6f, 0x0d, 0x5e, 0x50, 0xfa, 0xd2, 0xce, + 0x40, 0x72, 0x13, 0xe8, 0xd2, 0x6e, 0x6a, 0x9c, 0x06, 0x72, 0x22, 0x53, 0xd7, 0x76, 0xdc, 0x77, 0xc3, 0x63, 0x41, + 0x5b, 0x82, 0x8c, 0xe9, 0x2e, 0x34, 0x73, 0x14, 0x18, 0xfe, 0xbd, 0xd5, 0x38, 0x02, 0x06, 0xec, 0x1a, 0xeb, 0xe1, + 0x97, 0x62, 0xdc, 0xa4, 0x4a, 0x3f, 0x5c, 0xe1, 0x9c, 0x5d, 0xd2, 0xe9, 0xcd, 0xef, 0x07, 0x4a, 0x20, 0x2e, 0xde, + 0x88, 0x55, 0xdf, 0x06, 0xf3, 0xcb, 0xa0, 0x00, 0x8c, 0xa9, 0x34, 0x64, 0xfa, 0xbf, 0x58, 0x17, 0xf4, 0x4e, 0x0c, + 0xd6, 0x0c, 0x0e, 0x0c, 0x22, 0x3e, 0xee, 0xe0, 0x1e, 0x7f, 0x1d, 0xfe, 0x37, 0x25, 0xa8, 0x2b, 0x77, 0x3f, 0x51, + 0xd6, 0x7c, 0x9f, 0x94, 0x22, 0xd3, 0x97, 0xef, 0x5e, 0xb6, 0x42, 0x1d, 0xd4, 0xd8, 0xe6, 0x16, 0x35, 0xaf, 0x2d, + 0x7e, 0x3d, 0x8d, 0xc5, 0xdc, 0xe4, 0x37, 0xbd, 0x5d, 0x75, 0xf5, 0xd4, 0xa8, 0x51, 0x4f, 0x08, 0x46, 0x6f, 0x6e, + 0x86, 0xdd, 0x1a, 0x3f, 0xcf, 0x4a, 0x40, 0x23, 0x9b, 0xbd, 0x7a, 0x03, 0x05, 0xb9, 0xae, 0xd6, 0xcf, 0x63, 0x59, + 0x65, 0x5c, 0x7c, 0x47, 0x00, 0x5e, 0x1a, 0x1f, 0x12, 0x55, 0xaa, 0x65, 0x65, 0x88, 0x9a, 0x04, 0x10, 0x1c, 0xfe, + 0xa0, 0x7b, 0x73, 0x69, 0x3f, 0xc5, 0x6d, 0x56, 0xe4, 0xb5, 0x15, 0x41, 0x07, 0x19, 0x6a, 0xba, 0x32, 0xb8, 0x81, + 0x0e, 0x0f, 0xa7, 0xe8, 0x7f, 0x15, 0x7f, 0x58, 0xb1, 0x7f, 0xd2, 0x4d, 0x09, 0xe5, 0x53, 0x33, 0x3b, 0xf1, 0x64, + 0xcf, 0x14, 0xa9, 0x59, 0x84, 0x9a, 0x55, 0x6b, 0x06, 0xcb, 0x86, 0xda, 0x7d, 0x0d, 0x09, 0x5b, 0x04, 0x29, 0xa6, + 0x60, 0xdc, 0xd8, 0x9d, 0x11, 0x70, 0xc4, 0x39, 0x83, 0x72, 0xe8, 0x14, 0x65, 0x7e, 0x33, 0x5c, 0x36, 0x4e, 0xdd, + 0xf4, 0x06, 0x05, 0x7e, 0x18, 0xf0, 0xb9, 0xbc, 0xb5, 0x20, 0xcf, 0x1e, 0x65, 0xc5, 0x74, 0x16, 0xfb, 0x56, 0x02, + 0x31, 0x51, 0xb4, 0x03, 0x5b, 0x5e, 0xf1, 0xf2, 0x74, 0x66, 0xb5, 0x4f, 0x3a, 0xd7, 0x1d, 0xc2, 0xfd, 0x21, 0x71, + 0x1d, 0x84, 0x5e, 0xa7, 0x1c, 0x36, 0x79, 0x3d, 0x29, 0x61, 0xb7, 0x28, 0xbb, 0x2e, 0x16, 0xd3, 0x19, 0x0a, 0xbd, + 0x05, 0xf6, 0xbb, 0xdf, 0x7a, 0xfe, 0xa4, 0x72, 0x8c, 0xeb, 0xc3, 0xe5, 0x24, 0x86, 0xf1, 0xb5, 0xd4, 0x10, 0x2d, + 0x5b, 0x4a, 0xf7, 0x58, 0xdb, 0xb0, 0x80, 0xad, 0xd9, 0xfb, 0x47, 0x22, 0xa5, 0x89, 0x32, 0x15, 0xa7, 0x7d, 0xa1, + 0x32, 0x6e, 0xac, 0x3b, 0xab, 0x77, 0xb5, 0x16, 0x1f, 0xad, 0xce, 0x46, 0x1b, 0xa7, 0x12, 0xec, 0xbd, 0xa1, 0xbb, + 0xe8, 0x0b, 0xa6, 0x6c, 0xa1, 0xef, 0xe0, 0xdd, 0x06, 0x6d, 0x31, 0x3e, 0x63, 0x68, 0x9a, 0xdd, 0x79, 0xe0, 0xc5, + 0x67, 0x59, 0x74, 0xb9, 0x68, 0x3e, 0xcd, 0x1c, 0x69, 0xd4, 0xfd, 0x7f, 0x79, 0x6b, 0xa5, 0x0c, 0x77, 0x79, 0x42, + 0x86, 0x9d, 0xdc, 0xaf, 0x4b, 0x56, 0x01, 0xf9, 0x18, 0x5b, 0xe9, 0x79, 0x65, 0x97, 0x44, 0xa1, 0xa3, 0x38, 0xd3, + 0x7f, 0xf8, 0xca, 0x5d, 0xed, 0x3b, 0x6d, 0xfa, 0xd1, 0x65, 0xc9, 0x5f, 0x59, 0x4e, 0x8a, 0x36, 0x4f, 0x88, 0x4c, + 0xfe, 0x4f, 0x24, 0x25, 0x47, 0x06, 0xe2, 0xd1, 0x01, 0x14, 0x30, 0x53, 0x27, 0x93, 0xd3, 0x62, 0x70, 0x02, 0x22, + 0x4b, 0x34, 0x87, 0x33, 0x80, 0x49, 0x5a, 0x80, 0x09, 0xcf, 0x6b, 0xb5, 0xef, 0x31, 0x35, 0x8f, 0xbf, 0xcc, 0xa3, + 0x19, 0x8a, 0x33, 0x87, 0x16, 0x4d, 0x40, 0x32, 0x92, 0x30, 0xac, 0xb5, 0xed, 0x9c, 0x9f, 0x6c, 0x27, 0x78, 0x42, + 0xbd, 0x3f, 0xe0, 0x96, 0x43, 0x70, 0xb9, 0x13, 0xa5, 0xa8, 0xee, 0x93, 0x2f, 0x5b, 0xbd, 0x39, 0xe4, 0x3a, 0xeb, + 0xa1, 0x1e, 0x19, 0x28, 0x6e, 0xdb, 0xd9, 0x24, 0xfd, 0xf5, 0x8a, 0x7f, 0xfc, 0x65, 0xa2, 0x8b, 0x8a, 0x66, 0x0d, + 0x1a, 0x28, 0x00, 0xb7, 0x31, 0xe7, 0x7b, 0x1d, 0xb7, 0xb6, 0x83, 0xb9, 0x0d, 0x70, 0xb7, 0x51, 0x28, 0x06, 0x73, + 0x3f, 0x4f, 0x18, 0x10, 0xcc, 0x6b, 0x4f, 0x14, 0x20, 0xd2, 0x83, 0xfb, 0xe4, 0x54, 0x72, 0x99, 0x8d, 0x20, 0x58, + 0xc3, 0x2c, 0xe8, 0x76, 0xd7, 0xac, 0xcb, 0x8c, 0x3f, 0xf9, 0x21, 0xc3, 0x35, 0xd0, 0x3f, 0x99, 0x28, 0xe9, 0xdc, + 0x90, 0x50, 0xd1, 0x83, 0x78, 0x99, 0x43, 0xe5, 0x79, 0xcf, 0x50, 0x4f, 0xaf, 0x3f, 0xfa, 0xfb, 0xd6, 0xcc, 0xa1, + 0xbc, 0x64, 0x4d, 0xfe, 0xee, 0x31, 0xaf, 0x67, 0x79, 0x45, 0x67, 0xbe, 0x9a, 0x75, 0x56, 0x5c, 0x64, 0x9c, 0x1d, + 0x91, 0x0a, 0x4e, 0xad, 0x68, 0x7d, 0xe2, 0x29, 0x36, 0x8d, 0xdf, 0x1b, 0xa4, 0xce, 0x1e, 0x99, 0x7b, 0x76, 0x50, + 0x51, 0x5a, 0x42, 0x81, 0xf5, 0x22, 0x6a, 0xe0, 0xdb, 0x23, 0x9b, 0x31, 0xd3, 0xe7, 0xa4, 0xc0, 0x8b, 0x96, 0x60, + 0xb3, 0xbc, 0xd4, 0x41, 0x13, 0x2f, 0x4b, 0xe6, 0x8a, 0x13, 0xfe, 0x74, 0x99, 0x29, 0xf6, 0x43, 0x46, 0xea, 0x60, + 0xcf, 0x8b, 0x15, 0x7b, 0x96, 0xcb, 0xa7, 0xcb, 0x87, 0x68, 0x93, 0x7b, 0x8f, 0x88, 0x19, 0xaf, 0x1f, 0x2f, 0xda, + 0xa4, 0x04, 0x94, 0xc8, 0xc8, 0x86, 0x71, 0x1b, 0x09, 0x35, 0x8a, 0xf2, 0xd1, 0x15, 0x28, 0x39, 0xd6, 0xa9, 0x08, + 0x00, 0xf8, 0x63, 0x3a, 0x14, 0x36, 0xf0, 0x60, 0x3e, 0x91, 0x80, 0x32, 0xf2, 0xf4, 0x9d, 0xc9, 0x90, 0x10, 0x1d, + 0x35, 0x33, 0x7c, 0x4f, 0x18, 0xab, 0x67, 0x1e, 0x1d, 0x1f, 0x45, 0x1d, 0x6e, 0x84, 0x81, 0xc4, 0xb2, 0x6c, 0xb2, + 0x9b, 0xb7, 0x6e, 0x2b, 0x7c, 0x57, 0xac, 0x40, 0x9a, 0x02, 0x34, 0x2f, 0xe3, 0x46, 0xc0, 0x69, 0x18, 0xb3, 0x2f, + 0x03, 0xd4, 0x58, 0xc1, 0x58, 0x7e, 0xb5, 0xb2, 0xe1, 0xd9, 0x24, 0xef, 0x7e, 0x74, 0x99, 0x0b, 0x84, 0xbc, 0x58, + 0x60, 0x5b, 0x12, 0x75, 0xe2, 0x37, 0x83, 0xdf, 0xd3, 0xef, 0xd5, 0xf4, 0xd1, 0xc6, 0x88, 0x36, 0x3a, 0xcb, 0x4d, + 0x0f, 0x7a, 0xb4, 0x5b, 0xb0, 0x6a, 0x21, 0x52, 0xcd, 0xf1, 0x30, 0x03, 0x1b, 0xd1, 0x97, 0xd8, 0x60, 0xf5, 0x83, + 0x8d, 0x02, 0xc9, 0xc2, 0x90, 0x6d, 0x9b, 0x3d, 0x36, 0x30, 0x04, 0xe5, 0x59, 0x35, 0x05, 0x58, 0x23, 0xb6, 0xab, + 0x14, 0x46, 0x93, 0x7f, 0xd5, 0x16, 0xfd, 0x27, 0xff, 0x53, 0xac, 0xf7, 0x4c, 0x80, 0x64, 0x7b, 0x38, 0x9f, 0x9d, + 0xa6, 0x05, 0x33, 0x78, 0x14, 0x84, 0xf6, 0x60, 0x4a, 0xcd, 0x49, 0x24, 0x06, 0x25, 0x17, 0x22, 0xfb, 0x93, 0xea, + 0x2d, 0xc7, 0x67, 0x1e, 0x2a, 0xbf, 0xb9, 0x93, 0xe2, 0xa4, 0xd3, 0xea, 0x52, 0x19, 0xc1, 0x5d, 0x81, 0x13, 0x94, + 0x60, 0x36, 0xa0, 0x7f, 0xf2, 0xdb, 0x4d, 0x48, 0xa2, 0x4f, 0x5d, 0x60, 0x28, 0x63, 0xf6, 0x8c, 0xc8, 0xcc, 0xc2, + 0x23, 0x5a, 0x85, 0x28, 0xc6, 0x05, 0x72, 0xc0, 0x6c, 0x3f, 0x1b, 0x59, 0xb0, 0xd5, 0xb0, 0x9f, 0xfb, 0x46, 0xb4, + 0x0f, 0x61, 0x32, 0x62, 0x73, 0xe2, 0x2d, 0xc9, 0x03, 0x68, 0x88, 0x1e, 0xe6, 0x42, 0xe3, 0x82, 0x97, 0xae, 0x52, + 0xa3, 0x14, 0xe8, 0x26, 0x1e, 0xf5, 0x76, 0x68, 0xd4, 0x6a, 0x79, 0x33, 0x46, 0x17, 0xc0, 0x21, 0xaf, 0xf7, 0x4f, + 0xf0, 0xd4, 0x63, 0x86, 0xd8, 0x8b, 0x37, 0x1c, 0x58, 0xad, 0x71, 0xb1, 0x9d, 0x13, 0x37, 0x45, 0xc1, 0xc5, 0x99, + 0x4a, 0x7f, 0xb7, 0x85, 0xff, 0xad, 0xbc, 0xbb, 0x2a, 0xb2, 0x26, 0x28, 0x3f, 0x08, 0xce, 0xdc, 0xf3, 0x02, 0x3e, + 0x59, 0xe9, 0x74, 0xf8, 0x8d, 0xd2, 0x7c, 0x70, 0xf3, 0x84, 0xd1, 0x16, 0x6e, 0xaf, 0x30, 0x57, 0xe1, 0x0a, 0x96, + 0x11, 0xda, 0x67, 0xdc, 0x7a, 0xfc, 0xb9, 0x68, 0x8c, 0x29, 0x47, 0xe7, 0x1c, 0xe4, 0x67, 0x84, 0x04, 0xd3, 0xc0, + 0x26, 0x3d, 0xda, 0x61, 0x99, 0x16, 0x48, 0x09, 0x42, 0x4e, 0x2a, 0xba, 0x1f, 0xc3, 0x50, 0x89, 0xcd, 0x24, 0x24, + 0xad, 0x2a, 0x76, 0xe8, 0xc4, 0x29, 0x37, 0x1b, 0xa6, 0x58, 0x23, 0x7c, 0xba, 0xe9, 0x67, 0x88, 0x92, 0xc8, 0x7b, + 0x2e, 0x6e, 0x46, 0x1d, 0xbc, 0x22, 0x53, 0xc5, 0xd2, 0x57, 0x9e, 0x70, 0xeb, 0xaf, 0xb5, 0x0f, 0x90, 0xef, 0x10, + 0x0a, 0x7a, 0x5c, 0xe5, 0x5f, 0xce, 0x61, 0x56, 0xf2, 0x12, 0xae, 0xf0, 0x53, 0x1c, 0xca, 0x5c, 0x54, 0xd0, 0xe3, + 0xb9, 0x08, 0xf1, 0x96, 0xc3, 0x5b, 0x05, 0x9f, 0x44, 0x5f, 0x24, 0xc2, 0x7d, 0xcb, 0xce, 0xa6, 0xcf, 0x4a, 0x78, + 0xfd, 0xb9, 0x39, 0x29, 0x05, 0xd7, 0x81, 0x46, 0xcf, 0x61, 0xe0, 0x65, 0xd0, 0x62, 0xec, 0xd4, 0xc0, 0x3d, 0x91, + 0xec, 0x5b, 0x7f, 0x60, 0x49, 0xf5, 0xd3, 0x0f, 0x1a, 0x10, 0xcf, 0xd4, 0x7f, 0x3b, 0x30, 0xf1, 0x58, 0xfe, 0x91, + 0xe7, 0x3f, 0x93, 0x44, 0xd5, 0xc5, 0x03, 0x6c, 0x9d, 0x64, 0x0b, 0x05, 0x14, 0x1d, 0x1e, 0x10, 0xb0, 0x68, 0x6f, + 0x57, 0x69, 0x99, 0x9d, 0x30, 0x87, 0x3c, 0xdd, 0x55, 0xaf, 0xb3, 0x04, 0xa7, 0xaf, 0xd6, 0xb3, 0x15, 0xe8, 0xb4, + 0xb0, 0x00, 0x94, 0x38, 0xb3, 0x44, 0x75, 0xc6, 0xc1, 0xa9, 0xc5, 0x67, 0xfc, 0xaf, 0x57, 0x2a, 0x61, 0xec, 0xc1, + 0xc3, 0x41, 0x75, 0xa1, 0x82, 0xfc, 0xec, 0x85, 0xa6, 0x34, 0x0c, 0x20, 0xe1, 0x9c, 0xc6, 0x21, 0x59, 0xc6, 0x16, + 0x8f, 0xbc, 0x32, 0x15, 0x3a, 0x81, 0x75, 0xa7, 0x4f, 0xa7, 0x83, 0x60, 0x5c, 0x62, 0x85, 0xc1, 0x6b, 0x2e, 0x0c, + 0x47, 0x5a, 0x2e, 0xa7, 0xf8, 0x2b, 0x4d, 0xd4, 0xb5, 0xc8, 0x26, 0xf3, 0x1a, 0x57, 0x0d, 0xc4, 0x99, 0x76, 0x41, + 0x86, 0xe5, 0x53, 0xe4, 0xd6, 0x62, 0xb9, 0xf6, 0x5b, 0x9f, 0x57, 0x18, 0x86, 0xca, 0xcd, 0xaf, 0xf7, 0xf4, 0xcd, + 0x1d, 0x89, 0x53, 0x2f, 0xde, 0xa2, 0x40, 0xd3, 0x89, 0x5e, 0x0c, 0x35, 0xc2, 0xd3, 0x71, 0x17, 0x91, 0x61, 0x34, + 0xe0, 0xf4, 0x6d, 0x55, 0x33, 0x66, 0xd2, 0x0e, 0xa0, 0x9f, 0x0b, 0xea, 0x1c, 0x00, 0x9a, 0x22, 0x94, 0x1d, 0x08, + 0x57, 0xa1, 0x5a, 0xaf, 0x97, 0x95, 0x36, 0x36, 0x96, 0x07, 0x0a, 0x21, 0x30, 0x2b, 0x5e, 0x52, 0x28, 0xb9, 0x42, + 0x20, 0x2f, 0xb6, 0xa9, 0x4a, 0x65, 0xa6, 0x65, 0xb3, 0x76, 0xd7, 0x15, 0xed, 0x00, 0xa2, 0x26, 0x6d, 0x64, 0x32, + 0x81, 0x0d, 0x15, 0xd2, 0x14, 0x17, 0x49, 0xad, 0x04, 0x5c, 0xf3, 0x61, 0x0a, 0xa6, 0x11, 0x38, 0x3b, 0x80, 0x16, + 0xcc, 0xe1, 0x5e, 0x33, 0x64, 0x9a, 0x3c, 0xa7, 0x7d, 0x46, 0x8f, 0xb6, 0x5a, 0x63, 0xab, 0x5a, 0xb5, 0x8b, 0xfb, + 0xc9, 0x3a, 0x60, 0x62, 0xc0, 0x6a, 0x7b, 0xfc, 0x6f, 0x85, 0x74, 0xe6, 0x63, 0x21, 0x95, 0xfe, 0x6f, 0x46, 0xe7, + 0x62, 0xde, 0x3c, 0x3f, 0x8c, 0x5c, 0x61, 0x4c, 0x85, 0x3c, 0xc6, 0x49, 0x78, 0xb1, 0x1d, 0x5e, 0x34, 0x06, 0xb5, + 0x1f, 0x30, 0x18, 0x72, 0xaa, 0x63, 0xef, 0x7d, 0x10, 0x92, 0x7d, 0x31, 0xb7, 0x68, 0xac, 0x4e, 0x69, 0x51, 0xac, + 0xfb, 0x00, 0x32, 0x28, 0x8a, 0xfd, 0xff, 0xb8, 0x75, 0x91, 0x85, 0xe6, 0x0f, 0xe4, 0x25, 0x2e, 0x79, 0x98, 0xfe, + 0xf8, 0x5d, 0x50, 0xac, 0x4f, 0x1b, 0xf1, 0x12, 0xcd, 0x95, 0x83, 0x7f, 0xd3, 0x65, 0x8b, 0xea, 0x2e, 0xe5, 0xe1, + 0xde, 0x81, 0x31, 0x8d, 0x6f, 0x6e, 0xbe, 0x8c, 0x0b, 0x6b, 0x9c, 0xbb, 0x19, 0xef, 0x70, 0x13, 0xbb, 0xde, 0x56, + 0x56, 0x6c, 0x17, 0x99, 0xa2, 0xa2, 0xa9, 0xd1, 0x47, 0x33, 0x30, 0x76, 0x68, 0x40, 0xfb, 0xb7, 0x18, 0x32, 0x58, + 0x3c, 0xac, 0xcd, 0x85, 0x68, 0x79, 0x9d, 0xcb, 0x1d, 0x05, 0xe7, 0x64, 0xc4, 0x91, 0x04, 0x69, 0xd2, 0x7d, 0xc7, + 0xc9, 0x83, 0x3a, 0xa8, 0x1a, 0x71, 0xa7, 0x9a, 0xec, 0x57, 0xc2, 0xff, 0x21, 0x1f, 0xd7, 0x9d, 0xb6, 0x72, 0x0e, + 0x08, 0xf1, 0x59, 0xe7, 0xcd, 0x09, 0x91, 0x51, 0xdb, 0x46, 0x6d, 0x25, 0xcd, 0xc8, 0xaf, 0x10, 0x89, 0xfa, 0x57, + 0x8c, 0x02, 0x53, 0x7c, 0x06, 0x30, 0xb0, 0x4d, 0x82, 0xd5, 0x6f, 0xd6, 0x0d, 0xd9, 0x52, 0x40, 0xe3, 0x97, 0xb3, + 0x6d, 0x3e, 0xb1, 0x71, 0x3b, 0xfa, 0x05, 0x51, 0xdb, 0x5a, 0xd1, 0x04, 0xd7, 0xdd, 0x0b, 0xab, 0x37, 0xe2, 0xf7, + 0xd4, 0xdb, 0x23, 0xc8, 0x0d, 0xe4, 0x93, 0x74, 0xbf, 0x73, 0xa6, 0x0f, 0xd8, 0x83, 0x31, 0x8e, 0x31, 0xd8, 0x15, + 0xf3, 0xcc, 0xe8, 0x4d, 0x55, 0xd9, 0x04, 0x7a, 0x77, 0xcb, 0x51, 0x71, 0x8f, 0xdf, 0xd2, 0x2f, 0xde, 0x30, 0xc3, + 0xe8, 0x3e, 0x5f, 0x40, 0xd9, 0xa2, 0x1d, 0x57, 0x1a, 0xc9, 0x65, 0xb4, 0x4d, 0xe5, 0x88, 0x12, 0x58, 0x50, 0x92, + 0x1a, 0x5d, 0xde, 0xdc, 0xb2, 0x79, 0x71, 0x1d, 0x4d, 0x28, 0xb7, 0xfe, 0x74, 0xe4, 0x73, 0x3d, 0x38, 0x2a, 0x6f, + 0x43, 0x04, 0xa6, 0x89, 0x36, 0x2c, 0xe0, 0x30, 0xd3, 0xe6, 0xa5, 0x08, 0x02, 0xf0, 0x6e, 0xf0, 0x67, 0x9b, 0x81, + 0x22, 0x17, 0x10, 0x79, 0xe7, 0x2d, 0x58, 0xa0, 0x1b, 0x3c, 0x05, 0xfa, 0x38, 0x36, 0xfc, 0x77, 0xc1, 0xca, 0xd8, + 0x90, 0x2c, 0x61, 0x7c, 0xaf, 0x73, 0x22, 0x39, 0x49, 0x5d, 0x24, 0xad, 0x9f, 0xc2, 0x33, 0xb5, 0x8d, 0x5b, 0xf3, + 0x17, 0xe9, 0x27, 0xd1, 0x50, 0x79, 0x01, 0xf3, 0x35, 0xaa, 0xb3, 0xcb, 0xfc, 0x85, 0x79, 0x4e, 0x7a, 0x66, 0x5e, + 0xa3, 0xd5, 0x1a, 0xf0, 0xc0, 0xd2, 0x8a, 0xb0, 0x94, 0x59, 0x32, 0xe7, 0x32, 0x00, 0xf0, 0xb5, 0xf1, 0x79, 0x6d, + 0x08, 0xf1, 0x89, 0x5d, 0xdf, 0x15, 0x84, 0xca, 0x54, 0xd3, 0xae, 0x33, 0xf7, 0xc9, 0x2a, 0x84, 0xa5, 0xda, 0x76, + 0xc5, 0x6d, 0xa6, 0xb9, 0xad, 0x0d, 0xcf, 0x3d, 0x5f, 0x37, 0x05, 0xa6, 0xe8, 0x0c, 0xfa, 0x3b, 0xdb, 0x88, 0x53, + 0x04, 0x21, 0x62, 0x06, 0x1f, 0xb0, 0x36, 0x82, 0x6c, 0xca, 0x89, 0xfe, 0x6c, 0x17, 0xd4, 0x34, 0xbd, 0x4c, 0x55, + 0x85, 0xcb, 0x39, 0x26, 0x13, 0x9b, 0xb3, 0x01, 0x8b, 0x39, 0x78, 0xf0, 0xf0, 0x36, 0xb7, 0x65, 0xd9, 0x1b, 0x11, + 0xac, 0x06, 0x2d, 0x9c, 0x3b, 0x58, 0x2a, 0xf4, 0x9d, 0xcc, 0x7a, 0x57, 0x07, 0x37, 0xb3, 0xdf, 0xa4, 0xdd, 0x1f, + 0x39, 0xfa, 0xaa, 0xd2, 0xb8, 0x03, 0xdb, 0x58, 0x02, 0x1b, 0x1e, 0x23, 0x52, 0x0e, 0x89, 0xea, 0x53, 0x1f, 0x54, + 0x8f, 0x6a, 0x4c, 0x72, 0x1c, 0x48, 0x87, 0x89, 0x2b, 0x12, 0x7b, 0x93, 0x16, 0x62, 0x57, 0x2a, 0xa4, 0xa7, 0xb3, + 0x90, 0xaf, 0x25, 0x37, 0x5d, 0x27, 0x89, 0x6c, 0x51, 0xfb, 0x90, 0x57, 0x2d, 0xa9, 0x53, 0x83, 0xf2, 0x78, 0xcc, + 0xd1, 0x8f, 0x77, 0x5b, 0xf9, 0x4a, 0x6d, 0x1d, 0xe7, 0x24, 0xf8, 0x1c, 0xc7, 0x8b, 0x86, 0x7f, 0x2e, 0xca, 0x1b, + 0x2d, 0x3c, 0x8f, 0x2b, 0x3f, 0xec, 0xe4, 0xf5, 0x2b, 0x34, 0x4c, 0xc3, 0x51, 0xeb, 0xb6, 0xbc, 0xe2, 0x70, 0xef, + 0x76, 0x62, 0xb1, 0x84, 0xf5, 0x31, 0x2e, 0x97, 0x3c, 0x8d, 0xaa, 0xa5, 0xa3, 0x3f, 0xdd, 0x01, 0xb7, 0xe4, 0x9d, + 0x00, 0x98, 0xe8, 0xd0, 0x47, 0x58, 0xd0, 0x5e, 0x46, 0x8c, 0x10, 0x7b, 0xc1, 0xe4, 0x30, 0x64, 0xef, 0xfe, 0x0f, + 0xbb, 0x1e, 0x86, 0x6c, 0x49, 0xb2, 0xbb, 0x37, 0x23, 0x7c, 0xa1, 0x9e, 0x1e, 0x58, 0x8d, 0xc3, 0x35, 0x79, 0xb1, + 0x0d, 0x51, 0xec, 0x25, 0xdc, 0x30, 0x6a, 0x4b, 0x31, 0xb7, 0x60, 0x8d, 0x71, 0x48, 0xb1, 0x35, 0xca, 0xa8, 0x61, + 0x73, 0x68, 0x73, 0x28, 0xed, 0xbd, 0xe2, 0xbb, 0xfc, 0x1d, 0xe2, 0x83, 0x6f, 0x6d, 0x8f, 0xa2, 0xee, 0x9d, 0x7b, + 0xcb, 0xbc, 0x48, 0x57, 0xb2, 0xfe, 0xb9, 0x9d, 0xd8, 0x50, 0xdc, 0x4d, 0x37, 0x63, 0x3d, 0x71, 0x90, 0x5d, 0x9a, + 0x7c, 0x20, 0xa8, 0xa2, 0x64, 0xa5, 0xd5, 0xff, 0xec, 0xf6, 0xdf, 0x72, 0x1e, 0x9a, 0x68, 0x74, 0x6c, 0x3b, 0xb4, + 0x46, 0xef, 0xe1, 0xd7, 0xf8, 0x18, 0xab, 0x05, 0x24, 0x87, 0xb9, 0x4e, 0x94, 0xba, 0x19, 0x11, 0x3a, 0x71, 0xe3, + 0x05, 0xa2, 0xde, 0x76, 0x3d, 0xd3, 0xb9, 0xf4, 0xfe, 0x2e, 0x03, 0x34, 0x35, 0x84, 0xe0, 0x21, 0x24, 0xe7, 0x37, + 0xe1, 0xcd, 0xe8, 0x44, 0x7c, 0xc3, 0x74, 0x39, 0x43, 0xee, 0xe1, 0x0b, 0xb4, 0xee, 0x24, 0x58, 0x38, 0xdc, 0x10, + 0x52, 0xa4, 0x82, 0x00, 0xd9, 0x3e, 0x06, 0xb0, 0x30, 0xc9, 0x5e, 0x34, 0x19, 0x0d, 0x88, 0x6c, 0xd6, 0xb6, 0x84, + 0x39, 0x36, 0x53, 0x80, 0x16, 0x6c, 0xcd, 0x2f, 0x81, 0xb3, 0xa1, 0x2d, 0xde, 0xd2, 0xff, 0xe4, 0x35, 0x11, 0x60, + 0x4c, 0x53, 0x9b, 0x66, 0xd6, 0x2b, 0xab, 0x85, 0xa3, 0x28, 0x59, 0x2c, 0x90, 0x03, 0xd7, 0x0d, 0xa5, 0xb1, 0x35, + 0x56, 0x97, 0x34, 0xa0, 0xe5, 0xa2, 0xba, 0x20, 0x10, 0x12, 0x43, 0xcc, 0xab, 0x86, 0x42, 0x4a, 0x12, 0xaa, 0xb9, + 0x75, 0x27, 0xb6, 0x09, 0x0a, 0xb3, 0xe3, 0xce, 0xe4, 0xa1, 0x9f, 0xe1, 0xf8, 0xe3, 0x8d, 0xd9, 0x41, 0xa0, 0x70, + 0xc5, 0x4b, 0x19, 0x0d, 0x2a, 0xcb, 0x66, 0x3d, 0xf4, 0xca, 0xcd, 0x02, 0xda, 0x9d, 0xca, 0x32, 0xa3, 0xda, 0xa9, + 0x9e, 0x09, 0x4e, 0x6f, 0x0d, 0xd0, 0x88, 0x48, 0x80, 0x09, 0xfc, 0xa8, 0xbf, 0x34, 0x2a, 0x16, 0x18, 0x6b, 0x2b, + 0x8f, 0x7a, 0x7d, 0x8f, 0x33, 0x99, 0xce, 0x03, 0x6c, 0x9c, 0xb3, 0x68, 0x55, 0x23, 0x9e, 0x90, 0xa0, 0x4f, 0x72, + 0xb2, 0x73, 0x56, 0x2d, 0xe3, 0xeb, 0xe4, 0x82, 0x2f, 0xd8, 0x1d, 0x7f, 0xad, 0x11, 0x94, 0xe3, 0x5f, 0x5c, 0xbc, + 0xc5, 0x6b, 0xe1, 0x14, 0xd7, 0x23, 0xe6, 0x8b, 0x32, 0x2f, 0x7f, 0x78, 0x61, 0xe6, 0xf4, 0xef, 0xaf, 0x30, 0x01, + 0x55, 0xfe, 0x62, 0x89, 0x04, 0x52, 0x79, 0x78, 0xeb, 0x8d, 0xe0, 0x4a, 0x66, 0x14, 0x8d, 0x59, 0x3b, 0x6e, 0x09, + 0x3b, 0x58, 0x14, 0x47, 0x10, 0x2a, 0xfe, 0xf9, 0x0c, 0x20, 0x71, 0x16, 0xb4, 0xcc, 0x68, 0xd0, 0x88, 0xf6, 0xc0, + 0x9d, 0x15, 0x36, 0xe6, 0x85, 0x5c, 0x97, 0x6f, 0x1f, 0x56, 0x70, 0x90, 0x25, 0x24, 0xc1, 0xc3, 0x7a, 0xfb, 0xa6, + 0xca, 0x74, 0xe9, 0x61, 0xea, 0x75, 0xc7, 0xef, 0x99, 0x09, 0x08, 0x69, 0xf6, 0x10, 0xd9, 0xdc, 0x8d, 0xc4, 0xf4, + 0xc6, 0x53, 0xdb, 0x8e, 0x98, 0x8f, 0xed, 0x44, 0xe4, 0x4a, 0x1d, 0xdb, 0xe6, 0x21, 0x32, 0xc2, 0x0a, 0x23, 0x09, + 0x2e, 0xbf, 0x8c, 0xc8, 0x4d, 0x16, 0x34, 0xf6, 0x31, 0xba, 0x94, 0xc5, 0x24, 0xfb, 0x08, 0xfe, 0x52, 0xd6, 0xfa, + 0x97, 0xa8, 0x75, 0xf6, 0x04, 0x7e, 0xc5, 0xd0, 0xde, 0x43, 0x68, 0xac, 0xb3, 0xe0, 0x5d, 0x0b, 0x1e, 0x29, 0xa0, + 0xdc, 0x87, 0x89, 0x84, 0x50, 0x5c, 0x1f, 0x87, 0x5d, 0xb9, 0x6b, 0x89, 0x11, 0xe1, 0xa3, 0xa4, 0x57, 0x6a, 0x93, + 0x31, 0x5c, 0x81, 0x00, 0x2e, 0xcf, 0xf5, 0x78, 0x3e, 0xc3, 0x6c, 0xaf, 0x34, 0x92, 0xd0, 0x77, 0xc3, 0x8c, 0x97, + 0x9b, 0x6e, 0x51, 0x59, 0xb4, 0x79, 0x2b, 0x85, 0xbd, 0x2e, 0x10, 0x99, 0x11, 0x22, 0xe6, 0x96, 0xdf, 0x14, 0xa4, + 0x93, 0xed, 0x7c, 0x83, 0x3e, 0x36, 0x30, 0x9c, 0xc1, 0x4a, 0x57, 0xb5, 0xb5, 0x73, 0x2b, 0xb1, 0xfe, 0x9d, 0x15, + 0x13, 0xf8, 0xf9, 0x62, 0x41, 0x42, 0x40, 0xc2, 0x42, 0xcf, 0x3c, 0x98, 0xf5, 0x70, 0x92, 0x4e, 0x79, 0xf6, 0x12, + 0x13, 0x2e, 0x64, 0xe8, 0x70, 0xfc, 0xa0, 0xa5, 0xb9, 0xa0, 0x39, 0x7e, 0x3e, 0xd3, 0x52, 0xf9, 0x5a, 0x49, 0x93, + 0x2c, 0x58, 0xe5, 0x85, 0xd3, 0xe5, 0x23, 0x43, 0x14, 0x9f, 0x6a, 0xd7, 0x7d, 0x87, 0x9b, 0xcf, 0xa4, 0x68, 0x24, + 0x95, 0x76, 0x22, 0x50, 0x69, 0xc8, 0xe4, 0xed, 0x5e, 0x00, 0x62, 0x1b, 0xa2, 0x2f, 0x9a, 0x8d, 0xcc, 0x54, 0xa6, + 0xa3, 0xab, 0xe5, 0x21, 0x1c, 0xdb, 0xc3, 0x9b, 0xa1, 0x61, 0x08, 0x78, 0x7d, 0x5a, 0xb3, 0x7f, 0x1d, 0x75, 0xa8, + 0x68, 0x62, 0x54, 0xc4, 0xcd, 0x05, 0x93, 0x25, 0x2b, 0xa6, 0x21, 0x41, 0x38, 0x69, 0xc0, 0xe9, 0x6c, 0xc6, 0xd8, + 0x20, 0x79, 0x81, 0x49, 0x26, 0xf6, 0x04, 0x5a, 0x9a, 0x80, 0x79, 0x45, 0xd9, 0x79, 0xb4, 0x19, 0xdb, 0x19, 0xa1, + 0x9c, 0x39, 0x89, 0x8a, 0xf8, 0x67, 0xee, 0x49, 0x2b, 0xe0, 0x3e, 0x63, 0xba, 0xeb, 0x35, 0x9e, 0x71, 0x04, 0x45, + 0xbf, 0x6d, 0x9b, 0xff, 0x65, 0x18, 0x84, 0xa7, 0xcb, 0x76, 0x0e, 0x50, 0x41, 0x96, 0x10, 0xf0, 0x27, 0x2f, 0xe8, + 0x4b, 0xc0, 0x43, 0x0c, 0xf8, 0x81, 0xbd, 0x7a, 0x6d, 0x05, 0x3a, 0xb8, 0xfa, 0xea, 0xec, 0xf7, 0xdf, 0x32, 0x38, + 0xfc, 0x07, 0x57, 0xda, 0xf7, 0x8f, 0x4f, 0x09, 0x9b, 0x93, 0xa7, 0x78, 0x3a, 0x3a, 0xc7, 0xe1, 0xb6, 0x1e, 0xe5, + 0x74, 0x3b, 0x25, 0x14, 0x5e, 0xf8, 0x40, 0xfc, 0x31, 0x8b, 0xbb, 0x81, 0xa6, 0xf2, 0x71, 0xce, 0x1f, 0xe4, 0x27, + 0xc7, 0x27, 0xe9, 0x3e, 0xcd, 0x73, 0x38, 0xe6, 0x82, 0x6d, 0x0c, 0x83, 0xab, 0xce, 0xce, 0x2e, 0xe5, 0x66, 0x58, + 0x4a, 0x7a, 0xab, 0xdb, 0xbd, 0x8e, 0x51, 0xe9, 0xff, 0x2b, 0x7b, 0x4b, 0x47, 0x38, 0x8c, 0x7f, 0xf8, 0x0c, 0x05, + 0x41, 0xee, 0x14, 0xeb, 0xf4, 0xa2, 0x70, 0x8d, 0x3b, 0x94, 0x6f, 0xad, 0xb6, 0xbe, 0xaa, 0x52, 0x8f, 0xcc, 0x45, + 0x8c, 0xf3, 0x15, 0xf1, 0xb2, 0x9a, 0xbc, 0x6e, 0xd0, 0x6f, 0x4f, 0x94, 0xf9, 0xcf, 0xaf, 0x21, 0xc1, 0x76, 0x74, + 0xbf, 0x86, 0xfb, 0x1d, 0x71, 0x0d, 0x6b, 0xce, 0x91, 0x17, 0x9c, 0x71, 0x5d, 0x3d, 0x6d, 0x93, 0x75, 0x2d, 0x1c, + 0xdb, 0x2e, 0x07, 0x5e, 0xeb, 0x52, 0xe7, 0x10, 0xa5, 0x95, 0x71, 0xcf, 0xe9, 0x5d, 0x97, 0xdf, 0x99, 0xea, 0x18, + 0x76, 0x03, 0x9c, 0x8a, 0x60, 0x40, 0x81, 0x79, 0x1f, 0xd4, 0x9d, 0x0c, 0x21, 0x27, 0xf6, 0xac, 0x81, 0x5c, 0x82, + 0x28, 0x9a, 0x2f, 0x41, 0x00, 0x5a, 0xda, 0x81, 0x97, 0xb5, 0x8a, 0x46, 0x96, 0xac, 0x81, 0xb3, 0xd7, 0xff, 0x23, + 0x06, 0x43, 0x9c, 0x7c, 0x93, 0x80, 0x38, 0xc9, 0x14, 0x89, 0x39, 0x8d, 0x45, 0x9f, 0xb3, 0x8f, 0x72, 0x09, 0xd2, + 0xec, 0x67, 0x60, 0x80, 0x60, 0x1a, 0x8e, 0x63, 0x41, 0xa1, 0x64, 0xbe, 0x2a, 0xfa, 0x69, 0xb3, 0xf8, 0xfc, 0x09, + 0xc6, 0xf6, 0x6f, 0x74, 0xdb, 0xa8, 0xfc, 0x5e, 0x53, 0xc9, 0xed, 0xaf, 0x3c, 0x9f, 0xfe, 0xb6, 0x3a, 0x3c, 0xfd, + 0x44, 0xfd, 0xf8, 0x75, 0xd3, 0x02, 0xef, 0xe4, 0xee, 0xa5, 0x0c, 0x35, 0x3f, 0x5f, 0x67, 0x40, 0x58, 0x18, 0x80, + 0xfa, 0xd1, 0xf1, 0xa1, 0xa4, 0xdd, 0xd6, 0xb3, 0x41, 0x34, 0xb1, 0x8f, 0x71, 0x8b, 0xea, 0xe5, 0xbc, 0xc0, 0x66, + 0x35, 0xae, 0xa1, 0x7b, 0x5e, 0x68, 0xcd, 0x33, 0x61, 0x96, 0x0a, 0x4a, 0xe1, 0x64, 0x0a, 0xb8, 0x01, 0x5c, 0x57, + 0x4e, 0x9b, 0x85, 0x17, 0xbd, 0x09, 0x4f, 0x12, 0xcc, 0xe8, 0xc0, 0x45, 0xd3, 0x57, 0x4f, 0xed, 0x8b, 0x8e, 0xe1, + 0xcf, 0x44, 0x5d, 0x8d, 0x21, 0xa9, 0x51, 0x8e, 0x49, 0x8b, 0x95, 0x56, 0x68, 0x2d, 0xaf, 0x96, 0xba, 0xdb, 0x39, + 0x42, 0xaf, 0xbc, 0xa0, 0x0c, 0xc0, 0x03, 0x98, 0xf5, 0x92, 0xde, 0xd2, 0x2a, 0xb2, 0x29, 0xfb, 0x84, 0x5c, 0x9b, + 0xc7, 0x13, 0x9c, 0x96, 0x3e, 0xaa, 0x5b, 0xa4, 0x49, 0x6c, 0x85, 0x6b, 0x38, 0x37, 0x59, 0x55, 0xf5, 0xa2, 0xf9, + 0xda, 0x0f, 0x30, 0xa7, 0x05, 0xfb, 0x37, 0xf6, 0x45, 0xd3, 0x72, 0x12, 0x68, 0xbb, 0x68, 0x64, 0x0b, 0xca, 0x00, + 0x88, 0xd2, 0x3d, 0xbd, 0x01, 0x07, 0xa2, 0x5d, 0xd3, 0x89, 0xf8, 0x36, 0xb1, 0x1d, 0xce, 0x4d, 0x56, 0xa8, 0x85, + 0x0b, 0x73, 0x34, 0x9b, 0x2e, 0x9c, 0xa8, 0xbd, 0x4b, 0x7b, 0x9e, 0x0d, 0x34, 0x6e, 0xf3, 0x40, 0x21, 0x7d, 0x2f, + 0xf0, 0xa8, 0x41, 0xdc, 0x50, 0xa1, 0x17, 0x21, 0x53, 0x81, 0x6b, 0x0a, 0xb6, 0x21, 0x33, 0xd3, 0x38, 0x00, 0xc8, + 0xde, 0x45, 0xdc, 0x80, 0x83, 0x2b, 0x35, 0x86, 0x8e, 0xad, 0xd7, 0xe4, 0x95, 0x64, 0x82, 0xa0, 0xf2, 0x66, 0x89, + 0xcd, 0x58, 0x72, 0x10, 0x95, 0x6f, 0x70, 0xb3, 0x73, 0x27, 0x64, 0xf6, 0x3b, 0x9d, 0x21, 0x4c, 0x59, 0x59, 0xed, + 0x90, 0x9b, 0x11, 0x2f, 0x14, 0x98, 0x5a, 0xb4, 0x20, 0x22, 0x19, 0xb1, 0xaa, 0x1b, 0xbf, 0xf3, 0x76, 0x94, 0x9b, + 0x89, 0x6d, 0xb1, 0x5e, 0xf1, 0x8c, 0x60, 0xbd, 0x83, 0xb5, 0x73, 0xf4, 0x6a, 0x67, 0x64, 0xae, 0xf0, 0x62, 0x98, + 0xdc, 0xae, 0xe7, 0x83, 0x61, 0x44, 0x7d, 0xf9, 0x3f, 0xdb, 0x98, 0x55, 0xe5, 0x34, 0x1a, 0x43, 0x42, 0x24, 0xc3, + 0x9b, 0x00, 0xc4, 0xf3, 0xac, 0xc9, 0x18, 0xcd, 0xc4, 0x6a, 0xdb, 0x3a, 0x4d, 0xb3, 0x9f, 0x4f, 0x39, 0xfd, 0xde, + 0x48, 0x38, 0xc0, 0xf3, 0xaa, 0x73, 0x23, 0xbb, 0x7e, 0xa0, 0x8b, 0x39, 0xf4, 0x65, 0x26, 0x57, 0xf5, 0x8d, 0xec, + 0x54, 0x23, 0xcc, 0xcc, 0xa0, 0xef, 0x06, 0x25, 0x0f, 0x00, 0xd0, 0x1f, 0xe7, 0xe5, 0xd5, 0xff, 0x35, 0x9a, 0x3b, + 0x61, 0x04, 0x1b, 0x2b, 0x96, 0xe6, 0x38, 0x5e, 0x0e, 0xed, 0x40, 0x45, 0xcf, 0x89, 0xda, 0xd3, 0x88, 0xa4, 0x4b, + 0x6a, 0x0c, 0xe3, 0x89, 0x59, 0x1a, 0x1c, 0xd6, 0x50, 0x82, 0xfd, 0x32, 0xfa, 0xed, 0xda, 0xfb, 0x06, 0x52, 0xfc, + 0x1b, 0xd7, 0xd5, 0xf1, 0xec, 0xa8, 0x32, 0x93, 0x5a, 0xe6, 0x89, 0xdb, 0xe2, 0xaa, 0xae, 0x9a, 0xf9, 0xb4, 0x5d, + 0x32, 0x4d, 0x3b, 0x8f, 0xd9, 0x65, 0xfc, 0x19, 0x4d, 0x24, 0x23, 0x3f, 0xac, 0xc3, 0x00, 0x0d, 0x0c, 0xb4, 0x97, + 0xf8, 0xe9, 0x49, 0xa6, 0xab, 0xb7, 0xba, 0x49, 0xd0, 0xba, 0x5c, 0xa7, 0x1f, 0x48, 0xbd, 0xa0, 0x65, 0xd8, 0x59, + 0x33, 0x78, 0xe6, 0x84, 0xe8, 0x02, 0xe7, 0x27, 0xe6, 0x21, 0x67, 0xd4, 0x34, 0xa0, 0x5f, 0xe7, 0xe5, 0x55, 0x97, + 0xbb, 0xc8, 0xc0, 0xcd, 0x04, 0x76, 0xc8, 0x6e, 0x68, 0x7d, 0xac, 0x89, 0xa1, 0x07, 0xe9, 0xc2, 0xb4, 0x35, 0x8f, + 0x83, 0xd0, 0x14, 0xca, 0xc2, 0x95, 0x29, 0xd9, 0x28, 0x7c, 0x4f, 0x8e, 0xae, 0xe1, 0x82, 0x96, 0xd0, 0xde, 0xfd, + 0xdb, 0x05, 0x74, 0xf7, 0x98, 0x40, 0x95, 0x78, 0x92, 0x16, 0xca, 0xcd, 0x42, 0x79, 0x4e, 0x81, 0x15, 0x2c, 0x32, + 0xcf, 0xaa, 0xe9, 0x48, 0xb3, 0xd6, 0x8f, 0x4e, 0xe7, 0xba, 0xd5, 0x1a, 0xf6, 0x31, 0x65, 0x41, 0xf1, 0x8e, 0x16, + 0xe6, 0x5f, 0x89, 0x92, 0x23, 0x0d, 0xfe, 0x2f, 0x12, 0xab, 0xa6, 0x19, 0x7c, 0x85, 0xf9, 0x7f, 0x54, 0xb7, 0x26, + 0xde, 0x27, 0x70, 0x05, 0xc2, 0x5d, 0xa9, 0xb6, 0x33, 0xee, 0x18, 0x75, 0xb4, 0x0e, 0x3c, 0x75, 0x62, 0xc6, 0xc3, + 0xe3, 0x62, 0x8b, 0xe1, 0xb7, 0xa7, 0x37, 0xe0, 0xee, 0xb3, 0x63, 0xdd, 0xdd, 0xeb, 0x20, 0xa4, 0x57, 0x66, 0x91, + 0xee, 0xaf, 0x5a, 0x4d, 0x35, 0x21, 0xd6, 0xb5, 0x32, 0xf7, 0xc4, 0x98, 0x0d, 0x86, 0x33, 0x62, 0x7c, 0x76, 0x70, + 0xb3, 0x35, 0x72, 0x77, 0xa4, 0x24, 0x8a, 0x1d, 0x5d, 0x4a, 0x78, 0x02, 0x43, 0x36, 0xac, 0xca, 0xcd, 0xaf, 0xb5, + 0x7a, 0xb5, 0xaf, 0x3e, 0xfb, 0x12, 0x93, 0xf4, 0x8b, 0x1f, 0x52, 0xd8, 0xf1, 0x44, 0x64, 0xab, 0xc3, 0x58, 0x07, + 0x74, 0x1f, 0x6a, 0xfd, 0xf2, 0xba, 0xa1, 0xda, 0x0f, 0xf8, 0x6e, 0x9d, 0x95, 0xe5, 0x57, 0x8b, 0xdf, 0xd6, 0xb7, + 0x07, 0xee, 0x25, 0x83, 0xe2, 0x17, 0xf8, 0x2a, 0x22, 0x03, 0xee, 0x97, 0xd5, 0x9a, 0x4c, 0x8a, 0xe3, 0x27, 0x74, + 0x8c, 0x65, 0x8a, 0xf2, 0x48, 0xd3, 0x76, 0xb7, 0xde, 0xa8, 0xd1, 0xb1, 0xe1, 0x93, 0x9d, 0xa9, 0xcb, 0x07, 0x64, + 0x64, 0x08, 0xdb, 0xff, 0x54, 0x5e, 0x9c, 0x0e, 0x88, 0x36, 0xd8, 0xbf, 0x65, 0x86, 0xd0, 0x3a, 0x6c, 0x26, 0xb5, + 0x1a, 0xc2, 0xb1, 0x1f, 0x56, 0x57, 0xff, 0xbf, 0xf8, 0x52, 0x1a, 0x0d, 0x44, 0x6f, 0xd5, 0x5b, 0x02, 0x25, 0xb0, + 0x5e, 0xed, 0x52, 0xea, 0xaf, 0x4e, 0x61, 0x13, 0xe3, 0xb2, 0xe4, 0x75, 0xed, 0xce, 0xd0, 0xfa, 0x49, 0xab, 0x0d, + 0xb9, 0x7f, 0xda, 0xd0, 0xe3, 0x10, 0x23, 0x29, 0x6b, 0x13, 0x63, 0x86, 0x86, 0x10, 0xb3, 0x45, 0x19, 0x83, 0xbb, + 0xfe, 0x89, 0x44, 0x6d, 0x9c, 0x44, 0x68, 0x38, 0xcf, 0xdb, 0x60, 0xed, 0xd5, 0xdd, 0xd2, 0x14, 0x37, 0xc3, 0x95, + 0xa9, 0x4b, 0xc0, 0x7c, 0x62, 0xf0, 0xc5, 0x0e, 0x16, 0x14, 0xf0, 0x12, 0x74, 0x93, 0x71, 0xd3, 0x10, 0x7d, 0xb0, + 0xf1, 0xe6, 0xcf, 0x3d, 0xc7, 0xfc, 0xcc, 0xb7, 0x83, 0x35, 0xa8, 0x9d, 0x00, 0x27, 0x3a, 0xd0, 0xf5, 0x59, 0xb5, + 0xa4, 0xfa, 0xe6, 0xf0, 0xaf, 0x4d, 0xe5, 0x77, 0xc7, 0x86, 0x6f, 0xb5, 0xb9, 0x00, 0xbc, 0x9e, 0x19, 0x76, 0x08, + 0xb4, 0x06, 0x75, 0x4e, 0x61, 0xdc, 0x5d, 0x40, 0xad, 0x7b, 0x8d, 0xeb, 0x9b, 0x22, 0x42, 0x18, 0xb8, 0x2c, 0xa8, + 0xec, 0xf6, 0x1b, 0xcc, 0x5b, 0xdf, 0x17, 0xa8, 0x01, 0xc2, 0x43, 0x19, 0xda, 0x16, 0x19, 0x77, 0xee, 0x0d, 0x36, + 0x4b, 0x58, 0xe7, 0x52, 0x4e, 0xb9, 0xa6, 0x74, 0x1d, 0xaa, 0x8f, 0x9b, 0xa2, 0x97, 0x18, 0x90, 0x23, 0x88, 0xa5, + 0x9e, 0x01, 0xab, 0x86, 0x8b, 0xf4, 0x32, 0x4d, 0xd2, 0x29, 0x5f, 0x06, 0x88, 0xad, 0xeb, 0x44, 0xa3, 0xfb, 0x6c, + 0x29, 0x0f, 0x3d, 0x88, 0x21, 0x24, 0x24, 0x92, 0x52, 0x50, 0x3f, 0x90, 0x49, 0xb9, 0xfc, 0x0f, 0x2b, 0xf1, 0x2a, + 0x4f, 0xc7, 0x5f, 0x9e, 0x4e, 0x56, 0xd5, 0x83, 0x0f, 0x84, 0x1f, 0xe8, 0xbe, 0x75, 0xbc, 0x56, 0x6b, 0xcf, 0x57, + 0x75, 0x93, 0x1c, 0xfd, 0xc4, 0xbe, 0xe4, 0x1f, 0xb4, 0xa5, 0xce, 0x4d, 0x78, 0x16, 0x57, 0xc2, 0x9a, 0xe9, 0xf2, + 0xe5, 0x3d, 0x54, 0x79, 0x24, 0x69, 0x3c, 0x4d, 0x59, 0x6d, 0x1a, 0xef, 0x66, 0x8a, 0x40, 0x1b, 0x75, 0xf4, 0x0a, + 0x4e, 0x39, 0x70, 0x51, 0x87, 0x45, 0x27, 0xcb, 0x3f, 0x0b, 0x96, 0x85, 0x6e, 0x7f, 0x4b, 0x66, 0x1f, 0x27, 0x5f, + 0x6f, 0xa8, 0x5c, 0x38, 0x91, 0x43, 0x13, 0x4b, 0x5b, 0x6d, 0xc7, 0xe0, 0x4c, 0xdd, 0x79, 0x5c, 0x92, 0xe8, 0x3a, + 0x96, 0xe5, 0x79, 0x45, 0xac, 0xe3, 0xd4, 0x7b, 0x3d, 0x88, 0x90, 0x35, 0x2b, 0x7c, 0xd9, 0x7b, 0xfd, 0xd5, 0xad, + 0xd0, 0x99, 0x82, 0xac, 0x65, 0xcf, 0xa2, 0x18, 0xde, 0x85, 0xbc, 0x8a, 0xe8, 0xcb, 0xa5, 0x90, 0x15, 0x42, 0x59, + 0xc0, 0x56, 0xe9, 0x8f, 0xa3, 0x90, 0x3c, 0x3c, 0x4e, 0xf1, 0x62, 0xe6, 0x1c, 0x29, 0x77, 0x09, 0x61, 0x77, 0xc8, + 0xf2, 0x24, 0x92, 0x7a, 0xed, 0x46, 0xb0, 0x29, 0x31, 0xc5, 0xa6, 0x28, 0x72, 0x83, 0x5d, 0x10, 0x1c, 0x75, 0xab, + 0x6f, 0x34, 0x6d, 0x24, 0x0c, 0x12, 0xf9, 0xce, 0x08, 0xe9, 0x53, 0xdf, 0xdc, 0xbd, 0xe9, 0x07, 0x53, 0xc6, 0x20, + 0x02, 0x1e, 0x45, 0xcb, 0x00, 0xda, 0x9e, 0xaf, 0xd2, 0x2e, 0x19, 0x0f, 0x33, 0x18, 0x71, 0x5b, 0x01, 0xb9, 0x2e, + 0x1a, 0xb7, 0xe1, 0x97, 0xf0, 0x24, 0x51, 0x3c, 0x4d, 0x0b, 0x45, 0x23, 0x52, 0x79, 0x36, 0x24, 0x6b, 0x9e, 0x04, + 0x0b, 0x52, 0x4f, 0x1a, 0xcc, 0x86, 0xc1, 0x62, 0x34, 0x92, 0xb0, 0x4f, 0x4d, 0x86, 0xb1, 0x32, 0xec, 0x1c, 0xfd, + 0x4b, 0x9b, 0xd3, 0x16, 0x6b, 0x53, 0x0b, 0xb5, 0x99, 0xd1, 0x83, 0x19, 0x6f, 0x8c, 0xd4, 0xb0, 0x6a, 0x86, 0xf1, + 0x45, 0xa6, 0x76, 0x3a, 0x65, 0x14, 0x25, 0xc6, 0x69, 0x30, 0x77, 0x0c, 0x39, 0x54, 0x3f, 0x60, 0xb3, 0x82, 0xdc, + 0x55, 0x9d, 0xcd, 0xbd, 0x66, 0xdc, 0x5e, 0xd7, 0x8c, 0x3e, 0xf5, 0x4f, 0xb7, 0xfe, 0x73, 0x99, 0xae, 0xdb, 0xb1, + 0xca, 0x5f, 0xfa, 0x79, 0x37, 0x7d, 0x68, 0x31, 0x6f, 0xca, 0xce, 0x30, 0xc3, 0xeb, 0xcf, 0xa7, 0xc5, 0x83, 0xa2, + 0x81, 0xcd, 0x97, 0x6a, 0xe3, 0x70, 0xfd, 0xfb, 0x81, 0xad, 0xb7, 0xbb, 0xb9, 0x93, 0xa4, 0x21, 0xb6, 0x1c, 0x21, + 0x37, 0x82, 0x63, 0x02, 0xfe, 0xe3, 0x04, 0xf9, 0xdf, 0x3b, 0xf4, 0x6d, 0x7b, 0x10, 0x3e, 0xc6, 0xeb, 0x1e, 0x46, + 0x01, 0x73, 0xd6, 0xb2, 0x5e, 0x7d, 0x1a, 0x57, 0x45, 0xfa, 0x2b, 0x82, 0xfa, 0x8d, 0x23, 0xf8, 0x47, 0x57, 0x25, + 0xbf, 0xd3, 0x65, 0xd4, 0xbe, 0xfb, 0xdc, 0x0f, 0xd6, 0xa8, 0x32, 0x8e, 0xee, 0xcd, 0x69, 0x4b, 0x4a, 0x7b, 0x52, + 0xbe, 0xd5, 0x1e, 0x9e, 0xb6, 0x42, 0x9a, 0xb3, 0x79, 0x4f, 0x2e, 0xe7, 0x51, 0x82, 0x6d, 0x39, 0x8e, 0x70, 0x07, + 0xf9, 0xfa, 0x94, 0x51, 0x3a, 0x7a, 0x97, 0xe5, 0xed, 0xde, 0x04, 0x36, 0xf3, 0xf4, 0x04, 0xcc, 0x68, 0xda, 0x95, + 0x7e, 0xbf, 0x15, 0x27, 0xe6, 0xc3, 0xf6, 0x2e, 0xfb, 0x35, 0xae, 0xb4, 0x00, 0x8f, 0x7b, 0x5f, 0xb5, 0xfd, 0x6b, + 0xdb, 0x43, 0xdc, 0x8c, 0x14, 0x83, 0xb7, 0xf9, 0x2a, 0x4b, 0xa2, 0x02, 0x59, 0xf0, 0x1a, 0xf9, 0x20, 0xb6, 0x05, + 0x20, 0x67, 0xb4, 0x46, 0x2d, 0xfd, 0x8e, 0x25, 0xf1, 0x7c, 0x5b, 0x81, 0x9a, 0xf3, 0xec, 0xac, 0xa2, 0x55, 0x77, + 0xc2, 0x57, 0xa7, 0x9c, 0xa5, 0xd9, 0x85, 0xe8, 0x7a, 0xf8, 0xcc, 0x52, 0x54, 0xb2, 0x6c, 0x78, 0x37, 0xc6, 0xaf, + 0xd8, 0x2b, 0xcf, 0x50, 0xf2, 0xae, 0x94, 0x86, 0x42, 0x41, 0xb6, 0x06, 0xf5, 0xad, 0xb3, 0x97, 0x58, 0xdc, 0x68, + 0x79, 0x94, 0xab, 0xf0, 0xc5, 0xdc, 0xc7, 0xed, 0x71, 0x54, 0x15, 0x73, 0x0e, 0x61, 0x4f, 0x02, 0x3a, 0x69, 0x90, + 0x03, 0xa4, 0xd5, 0x65, 0x11, 0x36, 0x48, 0xa1, 0x5e, 0x8e, 0x7b, 0x94, 0x2b, 0xda, 0x8e, 0x05, 0x64, 0x2c, 0xba, + 0xcb, 0x8c, 0x4c, 0xe7, 0xb1, 0x13, 0xdd, 0x87, 0x2e, 0x17, 0x28, 0x30, 0x58, 0x9f, 0xb5, 0xe4, 0x92, 0xc7, 0x8a, + 0xa3, 0xec, 0x4a, 0x0c, 0x94, 0x67, 0x43, 0xd6, 0x6b, 0x7c, 0xc5, 0x02, 0xac, 0xe9, 0x76, 0x8e, 0x85, 0x0a, 0x96, + 0x7d, 0xff, 0x0b, 0x9f, 0x96, 0x8c, 0x9c, 0xca, 0x24, 0x96, 0xa5, 0x0f, 0x73, 0xe3, 0x86, 0xe0, 0x09, 0x41, 0x33, + 0x49, 0xe6, 0x29, 0xa7, 0x14, 0x4a, 0xeb, 0x7f, 0xae, 0x3c, 0x42, 0xd5, 0x6c, 0xdd, 0xf4, 0x96, 0x71, 0x77, 0x09, + 0x8d, 0xff, 0x21, 0x3a, 0x56, 0x71, 0xc1, 0xfb, 0xf3, 0x44, 0x92, 0x9c, 0x0a, 0x65, 0x2d, 0x9b, 0x17, 0x5b, 0xc8, + 0xa0, 0xe3, 0x96, 0x72, 0x08, 0xe4, 0x00, 0x60, 0x7a, 0xd5, 0x86, 0xba, 0xc6, 0x3e, 0x77, 0xbd, 0x21, 0x21, 0x56, + 0x04, 0xbb, 0xa1, 0x13, 0x24, 0xd4, 0x54, 0x21, 0xf1, 0x59, 0xaf, 0xf2, 0x6e, 0x14, 0x85, 0x1e, 0xf0, 0x8f, 0x7f, + 0x93, 0x88, 0xf3, 0x37, 0x58, 0xaa, 0xdf, 0xb0, 0x4a, 0x1b, 0xfa, 0xe4, 0x5f, 0x24, 0x5e, 0x75, 0xfe, 0x29, 0x66, + 0x9a, 0x6d, 0x87, 0xee, 0x67, 0x7e, 0x3e, 0xe1, 0x51, 0xf6, 0xc2, 0x21, 0x63, 0x0d, 0x19, 0x3a, 0x86, 0x2e, 0x12, + 0x6c, 0xf2, 0x97, 0x14, 0xfa, 0x64, 0x5a, 0xfa, 0x8a, 0xdf, 0x69, 0xdd, 0x9d, 0xad, 0x42, 0x21, 0x16, 0xcc, 0x50, + 0x4a, 0xa3, 0xee, 0x98, 0xea, 0x98, 0x59, 0x98, 0xe3, 0x90, 0x24, 0xa2, 0x45, 0x0e, 0x67, 0xb8, 0xbf, 0x01, 0x08, + 0x81, 0x06, 0x2b, 0x11, 0x2a, 0xca, 0xc5, 0x1e, 0xc1, 0x13, 0x6e, 0xb6, 0xb9, 0xdf, 0xc9, 0x3c, 0x9c, 0x48, 0xa3, + 0x5c, 0xc1, 0x02, 0x30, 0xd5, 0xb3, 0x1b, 0x49, 0xc9, 0xe1, 0x5e, 0xb4, 0xc6, 0xf9, 0x0c, 0x25, 0x94, 0xc5, 0xce, + 0x83, 0x60, 0x5d, 0x65, 0x53, 0xd9, 0x19, 0xcc, 0xaa, 0xee, 0x1c, 0xa8, 0xe2, 0x02, 0x89, 0xba, 0x31, 0x26, 0x53, + 0xcc, 0xb2, 0x19, 0x7e, 0x02, 0x31, 0x6f, 0xc8, 0x54, 0x70, 0xf7, 0x5a, 0x9d, 0x2d, 0xef, 0x1a, 0x26, 0x94, 0xa1, + 0x81, 0xd5, 0x49, 0x8c, 0x1a, 0x96, 0x70, 0x71, 0xc1, 0x67, 0xd0, 0x9f, 0x06, 0x42, 0x33, 0x3a, 0xbd, 0x19, 0xa3, + 0x7e, 0xcb, 0xc6, 0x93, 0xef, 0x15, 0xe7, 0xbd, 0xee, 0xf0, 0x4a, 0x25, 0x54, 0x25, 0x5f, 0x46, 0x88, 0xe8, 0x56, + 0x5f, 0x2a, 0x9e, 0x53, 0xf7, 0xde, 0x2f, 0x24, 0x9e, 0xf4, 0x99, 0x91, 0xc7, 0xfb, 0x5d, 0x28, 0x28, 0x80, 0xde, + 0xb2, 0x08, 0x99, 0x7e, 0x50, 0x56, 0xd5, 0x1d, 0x9e, 0x5c, 0xda, 0x95, 0x50, 0xf1, 0xba, 0x7e, 0xb3, 0x3c, 0x81, + 0x2a, 0x4c, 0x66, 0x28, 0xe6, 0xd8, 0x54, 0x8e, 0xc6, 0x1b, 0x4c, 0x23, 0x18, 0xe7, 0x39, 0xa1, 0x02, 0xfd, 0x50, + 0x25, 0x9a, 0x3a, 0x33, 0x73, 0xc6, 0xf2, 0xba, 0x0f, 0x7b, 0x3e, 0x77, 0x8a, 0xd9, 0x85, 0x57, 0xfb, 0x96, 0x3a, + 0x6e, 0x9f, 0x05, 0x97, 0xe5, 0xee, 0x16, 0x85, 0xec, 0x29, 0x95, 0xc4, 0x38, 0x80, 0x75, 0x1e, 0x5d, 0xd9, 0x9a, + 0x2e, 0x65, 0xb0, 0xfb, 0x13, 0xa4, 0x00, 0x8e, 0x96, 0x0c, 0x24, 0x60, 0x37, 0xf2, 0x6b, 0xd7, 0x64, 0xe6, 0x9b, + 0x8f, 0x03, 0x0b, 0x82, 0xc8, 0x04, 0xce, 0x10, 0x31, 0x91, 0x86, 0xf0, 0xf3, 0x3e, 0xce, 0xbe, 0xda, 0x4c, 0x34, + 0x51, 0x7b, 0x23, 0xe4, 0xf3, 0xf0, 0x1a, 0x76, 0xf3, 0xc0, 0x94, 0xf7, 0x5b, 0x3a, 0x45, 0x1c, 0x34, 0x89, 0xa9, + 0xd5, 0x33, 0xf6, 0x5b, 0xe6, 0x72, 0xc3, 0x2f, 0xc4, 0x14, 0x77, 0x77, 0x71, 0x2a, 0x0c, 0x2c, 0x99, 0xf0, 0xcb, + 0x83, 0xa9, 0x89, 0x29, 0x7b, 0x88, 0xef, 0xfb, 0xf0, 0xe1, 0x71, 0x63, 0xf6, 0xc9, 0x5d, 0x71, 0x9d, 0x58, 0xaa, + 0xb0, 0xaf, 0xe9, 0xeb, 0x21, 0x63, 0x4e, 0x44, 0xd2, 0x52, 0x99, 0xae, 0x0f, 0x36, 0xfe, 0xac, 0x62, 0xc9, 0xca, + 0x11, 0xb6, 0x46, 0x80, 0xf4, 0x4b, 0x83, 0xa6, 0xe1, 0x90, 0x7a, 0x18, 0xfa, 0x40, 0x8a, 0x39, 0xc1, 0xc0, 0xd1, + 0x25, 0x71, 0x6d, 0xeb, 0x70, 0x58, 0x24, 0x3d, 0x96, 0x68, 0xe9, 0xe7, 0x6e, 0x73, 0x7e, 0xb6, 0x07, 0xc7, 0xc2, + 0x65, 0xe5, 0x65, 0x65, 0x5e, 0x78, 0xc6, 0xc9, 0x62, 0xaf, 0x5a, 0x35, 0x7e, 0xe7, 0xf7, 0x7d, 0x2d, 0x99, 0x83, + 0x91, 0x1b, 0x99, 0x2b, 0x5a, 0x78, 0x30, 0xef, 0xe4, 0x15, 0x34, 0x6e, 0xb6, 0x12, 0x87, 0x12, 0xda, 0x7a, 0x70, + 0xea, 0xfd, 0x99, 0x02, 0x57, 0x10, 0x28, 0xbc, 0x7e, 0x3f, 0x1e, 0x6f, 0xc8, 0x68, 0x73, 0x85, 0x0c, 0x7a, 0x6e, + 0xf5, 0x02, 0xd5, 0x79, 0xdf, 0x7c, 0x3e, 0x67, 0x6f, 0xcc, 0xb3, 0xee, 0x63, 0x48, 0x7d, 0x64, 0x88, 0x1a, 0xb2, + 0xbc, 0x16, 0x0a, 0x93, 0x05, 0xf4, 0x38, 0xaa, 0x2a, 0x44, 0x56, 0x87, 0xb2, 0x71, 0x33, 0x54, 0xd8, 0x4f, 0xaf, + 0x7f, 0x80, 0x11, 0x72, 0x94, 0x52, 0x68, 0x4f, 0x4c, 0x55, 0x46, 0x08, 0x81, 0xb1, 0x21, 0x1a, 0x96, 0x91, 0x29, + 0x6c, 0xb3, 0x8a, 0x76, 0x9c, 0xae, 0xec, 0xd6, 0x37, 0xab, 0x14, 0x73, 0xde, 0x0d, 0x9e, 0x25, 0x68, 0xf7, 0xf6, + 0xb6, 0xc7, 0x31, 0xf4, 0x53, 0xf1, 0x3f, 0x82, 0x1d, 0x9d, 0xc3, 0x12, 0x15, 0x9c, 0x12, 0xfb, 0x9c, 0xf9, 0xab, + 0x63, 0x25, 0x8e, 0x7b, 0xda, 0xe2, 0xde, 0x8e, 0x1d, 0x33, 0x2b, 0x3f, 0x36, 0x59, 0x72, 0x2d, 0x43, 0x12, 0xd5, + 0x35, 0x97, 0x8e, 0x41, 0x53, 0x22, 0x37, 0x6f, 0x66, 0x96, 0xf6, 0x06, 0xcc, 0x8f, 0xf6, 0x41, 0xfb, 0x25, 0x21, + 0xc2, 0x6a, 0xa9, 0x99, 0x8b, 0x2f, 0x71, 0xca, 0x38, 0xc9, 0x7d, 0x03, 0xe6, 0xef, 0xc4, 0xf5, 0xef, 0xa2, 0x07, + 0x87, 0x39, 0x02, 0x18, 0x88, 0xb7, 0x52, 0x6d, 0xe5, 0x4d, 0x44, 0x69, 0x05, 0x86, 0x5d, 0x9b, 0xca, 0x86, 0xa3, + 0x21, 0x7f, 0xc3, 0x23, 0xfb, 0x32, 0x5f, 0x6f, 0x6c, 0xa1, 0x38, 0xf1, 0x2e, 0xff, 0xfc, 0xd3, 0x87, 0xe7, 0xc7, + 0x9c, 0x0b, 0x76, 0x73, 0xe3, 0xbc, 0x6a, 0x13, 0xc8, 0xb6, 0x5d, 0x0c, 0x12, 0x9c, 0x42, 0x23, 0x27, 0x40, 0xfa, + 0xe1, 0xc2, 0x22, 0xc4, 0xcf, 0xdf, 0x3d, 0xd9, 0xf5, 0xf6, 0x3a, 0x6c, 0x46, 0xb3, 0xbd, 0x23, 0x1a, 0x41, 0x4e, + 0x57, 0xc7, 0xec, 0xfb, 0xe4, 0x60, 0xfc, 0x4b, 0xe2, 0x7e, 0xa6, 0xca, 0xcf, 0x35, 0xd7, 0x37, 0x55, 0x7e, 0xea, + 0xe0, 0xc6, 0x27, 0xb0, 0x6a, 0x53, 0x72, 0x13, 0xe6, 0xca, 0xad, 0x3e, 0x79, 0x4c, 0x0c, 0xa6, 0xd5, 0x3f, 0x7d, + 0x7f, 0x1a, 0x06, 0x06, 0x17, 0xbb, 0x3b, 0x4f, 0xbe, 0xce, 0xf4, 0xc7, 0x79, 0xdf, 0xb1, 0xfb, 0x3a, 0xf8, 0x71, + 0x5c, 0x5d, 0xd6, 0x23, 0x49, 0x23, 0x07, 0xc4, 0x7b, 0xca, 0xa8, 0x61, 0x2f, 0x77, 0x95, 0x87, 0x55, 0xfd, 0x7d, + 0xd6, 0xbb, 0x44, 0xef, 0x8e, 0x9d, 0x65, 0xad, 0x26, 0x94, 0x17, 0x98, 0xd3, 0x79, 0x4c, 0xb3, 0x42, 0x47, 0x85, + 0x9a, 0x89, 0xf6, 0x32, 0xb2, 0x4a, 0x7f, 0xf3, 0x4b, 0xda, 0xaf, 0x16, 0xc1, 0xb0, 0xac, 0xc2, 0xe5, 0x3c, 0x6a, + 0xb0, 0x59, 0xbb, 0x36, 0x7f, 0xfd, 0xef, 0x69, 0xc3, 0xce, 0x04, 0x51, 0x7d, 0x52, 0x2b, 0x79, 0xd6, 0x77, 0xb8, + 0xea, 0xf6, 0x7c, 0xbe, 0x91, 0x79, 0xaf, 0x9e, 0x2f, 0x9a, 0x8f, 0xb7, 0x5f, 0xbb, 0x07, 0xe0, 0x97, 0x5d, 0x59, + 0xab, 0x37, 0x2b, 0x8b, 0x21, 0xf5, 0x9e, 0xf5, 0x7e, 0x21, 0x53, 0x02, 0x03, 0x52, 0x5f, 0xf8, 0xbc, 0x76, 0x1d, + 0xf4, 0x3a, 0x2a, 0xdd, 0x7e, 0x59, 0xb4, 0x16, 0x85, 0x94, 0x27, 0x92, 0x52, 0x92, 0x4d, 0x5c, 0xc5, 0xcc, 0x30, + 0xcd, 0x3b, 0xbf, 0xa9, 0x27, 0xfd, 0x55, 0x6d, 0xf6, 0xf5, 0xd6, 0x66, 0x6f, 0x08, 0xaf, 0x79, 0x8a, 0xb0, 0x7a, + 0xb7, 0x4e, 0x39, 0x5e, 0xbd, 0xed, 0xf4, 0x2f, 0x5a, 0xfb, 0xf4, 0xbd, 0x5b, 0xc3, 0xd8, 0xa8, 0x9c, 0x15, 0xca, + 0x6f, 0x72, 0x4a, 0xc3, 0x35, 0xa3, 0x0d, 0x1b, 0x61, 0x8a, 0x7d, 0xb9, 0x7a, 0xb7, 0x3a, 0x61, 0x85, 0x48, 0xef, + 0xc1, 0x33, 0xc2, 0xe3, 0xcd, 0x1f, 0x24, 0x54, 0xfd, 0x82, 0x8c, 0xe5, 0x8d, 0xba, 0xb5, 0x68, 0x3c, 0xda, 0x46, + 0xce, 0x24, 0xcc, 0x37, 0xe8, 0xa6, 0xc9, 0x6c, 0x6d, 0xc2, 0xa9, 0x23, 0xb7, 0x49, 0xb1, 0x19, 0xa9, 0x6a, 0xef, + 0x32, 0x98, 0xd2, 0x7d, 0xd2, 0x3e, 0xb1, 0xa7, 0xd4, 0x63, 0xd9, 0x19, 0x62, 0x5a, 0x10, 0xa0, 0xa6, 0x5c, 0xb5, + 0x57, 0x88, 0x65, 0x70, 0x4a, 0x5b, 0x4f, 0xb6, 0xcf, 0x30, 0x5b, 0x34, 0x93, 0x10, 0x9c, 0x15, 0x5a, 0x36, 0xdd, + 0xa4, 0xad, 0x04, 0x2f, 0x23, 0xd5, 0x68, 0xb4, 0x99, 0xe0, 0xb1, 0xf3, 0x5e, 0x34, 0xf3, 0x43, 0x87, 0xb4, 0xb0, + 0x16, 0x25, 0xfc, 0xc2, 0x91, 0x9c, 0xa5, 0x8d, 0xe0, 0xb4, 0x86, 0xee, 0xde, 0x35, 0xaf, 0xb7, 0xf7, 0x23, 0x1f, + 0xd8, 0x78, 0xd3, 0x88, 0x34, 0xc7, 0x7a, 0xc3, 0xbe, 0x09, 0xc6, 0x04, 0x1c, 0x78, 0xe6, 0xe5, 0x2f, 0x9e, 0x00, + 0xe7, 0x07, 0xd8, 0x90, 0x5e, 0xe6, 0xab, 0x8a, 0x60, 0x25, 0xaa, 0x34, 0xe3, 0xc2, 0xec, 0x31, 0xe8, 0xbb, 0x6d, + 0xe9, 0x37, 0xe3, 0xcf, 0x4c, 0x1c, 0xa5, 0x70, 0xf2, 0x9c, 0x6e, 0x2c, 0xdc, 0x43, 0x02, 0xbe, 0x21, 0xab, 0x9e, + 0x78, 0x93, 0xd3, 0xb8, 0xc1, 0xf5, 0x9b, 0x57, 0xb3, 0x13, 0x3e, 0x28, 0xcd, 0x0a, 0x10, 0xb2, 0xeb, 0x50, 0xd9, + 0xf0, 0x32, 0x53, 0x55, 0x7b, 0xad, 0x9c, 0xdc, 0x2f, 0xc4, 0x88, 0x82, 0x52, 0x31, 0x1f, 0x1f, 0xc8, 0x28, 0x8d, + 0xe2, 0xa2, 0xe4, 0xde, 0x43, 0x8a, 0x5d, 0x73, 0xde, 0xd0, 0x29, 0x3f, 0xa7, 0x81, 0xb6, 0x7e, 0x37, 0x14, 0x5e, + 0xfd, 0xee, 0x1e, 0x8d, 0x1d, 0xdc, 0x7a, 0x7a, 0xeb, 0x64, 0xbd, 0xb1, 0xb5, 0xf9, 0x08, 0x39, 0x35, 0x20, 0x7e, + 0x63, 0xc2, 0x1f, 0x7d, 0x7b, 0xa9, 0x29, 0xac, 0xa1, 0xb1, 0x8f, 0x6c, 0x6e, 0xc4, 0x56, 0x78, 0xe3, 0xd4, 0x0a, + 0x5f, 0x82, 0x28, 0x16, 0xe3, 0x17, 0x3f, 0x6b, 0x34, 0xb8, 0xa6, 0x12, 0x1a, 0x0e, 0x09, 0xee, 0x45, 0x91, 0xa7, + 0x9f, 0xba, 0xf8, 0x59, 0x5c, 0xbc, 0x98, 0xaf, 0x86, 0xc4, 0xcc, 0xd3, 0xb6, 0xd2, 0x62, 0xd9, 0xb4, 0x15, 0x3f, + 0x5b, 0x13, 0x0d, 0x77, 0xd1, 0x1a, 0x9f, 0xd5, 0xd8, 0x56, 0x55, 0xaa, 0x1f, 0xca, 0xef, 0x7b, 0x1b, 0x9b, 0x4c, + 0x9d, 0x81, 0x0e, 0x92, 0x86, 0xa4, 0x97, 0x8a, 0x6e, 0x81, 0x8c, 0x3d, 0x3d, 0x26, 0x0d, 0x4b, 0xc4, 0x58, 0x05, + 0xa1, 0x9c, 0x61, 0xd6, 0x8e, 0x72, 0xf3, 0xb0, 0xde, 0x42, 0xaf, 0xd8, 0x6d, 0x4c, 0x7a, 0xea, 0x8d, 0x65, 0x79, + 0xd6, 0xaa, 0xfb, 0x1c, 0x05, 0x14, 0xff, 0x1c, 0xee, 0xc0, 0x1f, 0x6e, 0x0d, 0x9a, 0xbd, 0x51, 0xb9, 0xd8, 0xd4, + 0xeb, 0x10, 0x6f, 0xd2, 0x1d, 0x8f, 0x25, 0x64, 0x21, 0xa2, 0xf1, 0x4d, 0x37, 0x05, 0x0c, 0xcd, 0x54, 0x46, 0x1d, + 0x4b, 0xe3, 0x28, 0xa8, 0x88, 0x15, 0xd9, 0x3b, 0x47, 0xe4, 0x55, 0x41, 0x85, 0x20, 0xad, 0x59, 0x36, 0x09, 0x99, + 0x7f, 0x1a, 0x64, 0x40, 0x49, 0x61, 0x13, 0xfd, 0x69, 0x13, 0x27, 0x85, 0x04, 0xdc, 0xad, 0xec, 0xa2, 0x8b, 0xad, + 0xa9, 0x15, 0xfa, 0x0c, 0x46, 0x5b, 0x70, 0x14, 0xb2, 0x2a, 0x44, 0x0b, 0xcd, 0x7c, 0xc3, 0xbf, 0x45, 0x9e, 0x02, + 0x12, 0x44, 0x41, 0x13, 0x0e, 0x65, 0x37, 0xdd, 0x41, 0x8a, 0x74, 0xf4, 0x10, 0xc1, 0x07, 0xa4, 0x84, 0x0a, 0xd0, + 0x79, 0x1e, 0xd7, 0xdd, 0x4b, 0x4d, 0x23, 0x2a, 0x23, 0x1b, 0x7c, 0x4f, 0x07, 0x45, 0xae, 0x57, 0xac, 0xf4, 0xff, + 0x1f, 0x79, 0x2c, 0xe5, 0x05, 0xec, 0x50, 0xc0, 0x9b, 0x0f, 0xd8, 0x42, 0x8a, 0x43, 0xad, 0x9e, 0x2f, 0x28, 0x51, + 0x1c, 0x49, 0x34, 0xbd, 0xaf, 0x68, 0xa7, 0x47, 0x8c, 0x32, 0xf1, 0xe1, 0x24, 0x50, 0xb7, 0xcd, 0xad, 0xba, 0x64, + 0xb8, 0xba, 0xca, 0xda, 0x7f, 0x3a, 0xec, 0xab, 0xa9, 0x82, 0x0b, 0x3b, 0xbd, 0xb8, 0x83, 0x60, 0x0a, 0x85, 0x1e, + 0x3b, 0x7f, 0x87, 0x89, 0xca, 0xea, 0x2f, 0x24, 0x52, 0xac, 0x5a, 0x2e, 0x43, 0x03, 0x1c, 0xc4, 0x4d, 0x81, 0xda, + 0x11, 0x0c, 0x7a, 0xc6, 0x2e, 0x41, 0x1a, 0xcb, 0x72, 0x49, 0x65, 0x38, 0x69, 0xa5, 0xd5, 0xe7, 0x93, 0x23, 0xe4, + 0x31, 0xde, 0x49, 0xad, 0x12, 0x15, 0x9c, 0x3d, 0x96, 0x65, 0x6d, 0xd4, 0x73, 0xd8, 0x8c, 0xce, 0xb2, 0x8a, 0x5a, + 0xe7, 0xda, 0x6a, 0xa7, 0x14, 0x4a, 0x75, 0x2c, 0x08, 0x36, 0x2d, 0x1c, 0x0f, 0xd2, 0xc2, 0x0e, 0xf4, 0x74, 0x42, + 0x8d, 0x4b, 0x9a, 0x1d, 0x52, 0x91, 0x77, 0x8b, 0x8e, 0xd0, 0x4c, 0xa7, 0x1b, 0x74, 0x53, 0x1e, 0x2b, 0x82, 0x3a, + 0x98, 0xd9, 0x11, 0x86, 0x57, 0x87, 0x61, 0x3c, 0x47, 0x5f, 0x52, 0x36, 0xc4, 0xca, 0x15, 0xb7, 0xb3, 0x36, 0xa5, + 0x83, 0xb7, 0x0a, 0x3f, 0x34, 0x8f, 0xca, 0xc4, 0xe2, 0xa8, 0x58, 0xa1, 0xc4, 0x35, 0xcd, 0x68, 0x8b, 0xab, 0xa5, + 0x9b, 0x18, 0x23, 0xe4, 0x2b, 0xe6, 0xaf, 0x91, 0x32, 0xcc, 0x0d, 0x64, 0x0d, 0xd2, 0x45, 0x32, 0xc5, 0x2c, 0x4c, + 0x90, 0x71, 0xc0, 0x98, 0xf8, 0x3e, 0x5b, 0x5c, 0xfa, 0x33, 0x70, 0x85, 0x63, 0x36, 0xad, 0xa4, 0xfb, 0x10, 0x14, + 0xba, 0xef, 0xf1, 0xa0, 0x41, 0x3d, 0x76, 0x10, 0x3d, 0x53, 0x70, 0xf9, 0x5c, 0x62, 0xa3, 0x7e, 0x6f, 0xd8, 0xd9, + 0x11, 0x8a, 0xcd, 0x86, 0x04, 0x03, 0xcc, 0x27, 0x4a, 0xf7, 0x3e, 0xf8, 0xd0, 0xd2, 0x2f, 0x5f, 0xdf, 0x22, 0x84, + 0x0c, 0xe5, 0x4e, 0x94, 0x9a, 0x29, 0x81, 0x8a, 0xa6, 0xe6, 0xa0, 0x99, 0x0f, 0x4e, 0xdc, 0xed, 0xf0, 0x7a, 0x42, + 0x2f, 0x57, 0xbb, 0x75, 0x4f, 0xe5, 0xe5, 0x8a, 0x34, 0x42, 0xab, 0x4f, 0x1b, 0x95, 0x0f, 0xe6, 0xb1, 0xb8, 0x5e, + 0xe9, 0xae, 0x1f, 0xb8, 0x43, 0xab, 0xdd, 0xc5, 0x87, 0xd2, 0x32, 0x3e, 0xd4, 0x93, 0xac, 0xd7, 0x60, 0x11, 0x78, + 0xa8, 0xb1, 0x14, 0xcd, 0xf1, 0x26, 0xeb, 0xd9, 0x60, 0x53, 0xa3, 0xd9, 0x58, 0x4a, 0xcb, 0xdf, 0xdb, 0xb8, 0x5f, + 0x67, 0x53, 0x85, 0xd2, 0x87, 0x81, 0xf4, 0x3e, 0x81, 0x92, 0x39, 0x0a, 0xdd, 0xe9, 0x0c, 0x95, 0x8f, 0xe6, 0x09, + 0x30, 0x56, 0xf0, 0x8b, 0x4b, 0x1a, 0xd3, 0x59, 0x73, 0x9c, 0xaf, 0xe0, 0xd4, 0xe2, 0xa8, 0xb1, 0x75, 0xe9, 0x89, + 0x20, 0xbc, 0x5a, 0xdc, 0x12, 0xbd, 0x24, 0xe9, 0xa8, 0x23, 0x25, 0xfe, 0x53, 0x8c, 0x73, 0xaa, 0xa9, 0xa3, 0x9d, + 0x4d, 0xe3, 0x0d, 0x15, 0x9c, 0x01, 0x35, 0x39, 0x6c, 0xfb, 0x12, 0x0a, 0xe0, 0xff, 0x9d, 0xa6, 0x12, 0xf1, 0x32, + 0x11, 0x37, 0xab, 0x0a, 0x65, 0x40, 0x19, 0x01, 0x94, 0x5f, 0xab, 0x91, 0xd1, 0x37, 0x7e, 0x34, 0x51, 0x9f, 0xc7, + 0x98, 0x03, 0x1d, 0xb4, 0x34, 0xf9, 0x1b, 0x38, 0x22, 0xd9, 0x36, 0xc7, 0x66, 0x06, 0x55, 0x5b, 0x3c, 0x09, 0xbc, + 0x44, 0x34, 0x56, 0xb3, 0x7e, 0x8c, 0xdf, 0xa6, 0x73, 0x3f, 0x78, 0xeb, 0x00, 0xfc, 0xc2, 0x82, 0x6a, 0x67, 0xd6, + 0xea, 0x7b, 0x09, 0x1a, 0xf0, 0xa3, 0xd8, 0xe8, 0x2c, 0x75, 0x42, 0xcd, 0xc9, 0x0e, 0xbd, 0xa9, 0xc9, 0x39, 0x27, + 0xca, 0x9e, 0x4e, 0x66, 0x1c, 0x85, 0x43, 0xd4, 0x19, 0x71, 0xf2, 0xa9, 0xf7, 0xcd, 0x8c, 0x78, 0xf4, 0x89, 0x8b, + 0x84, 0x43, 0x70, 0x4e, 0x15, 0x5b, 0x36, 0x9b, 0x8b, 0xd8, 0xfc, 0x4c, 0x8a, 0x4d, 0xc6, 0x04, 0xab, 0x05, 0xbd, + 0xbf, 0x21, 0x12, 0xc2, 0xb4, 0x21, 0x24, 0x4b, 0x53, 0x93, 0x1a, 0x8f, 0x9b, 0xab, 0x60, 0x33, 0xba, 0xc4, 0xfc, + 0x73, 0x75, 0x41, 0xa1, 0xf0, 0x8a, 0x82, 0x9f, 0xd7, 0x70, 0xec, 0x35, 0xc2, 0xd8, 0x33, 0x24, 0xfa, 0xd0, 0x0e, + 0x9a, 0x19, 0xc9, 0x2d, 0xa6, 0xf6, 0x64, 0x47, 0xe0, 0x95, 0x97, 0x21, 0xdd, 0xb0, 0xdf, 0x04, 0x94, 0xc0, 0x6b, + 0xea, 0x9b, 0xe5, 0x50, 0xe6, 0x70, 0x39, 0xdd, 0xd5, 0x6f, 0x80, 0xc6, 0xce, 0x16, 0x1e, 0xa6, 0x68, 0x1d, 0xaa, + 0x7d, 0x48, 0x5d, 0x3d, 0xb3, 0x57, 0x31, 0x47, 0x39, 0x08, 0xea, 0xc6, 0x6c, 0x13, 0xfb, 0x3a, 0x75, 0x5d, 0x03, + 0xf5, 0x7b, 0xec, 0x67, 0xa0, 0xf5, 0x30, 0xd2, 0x6c, 0x1d, 0x4f, 0xe9, 0x2d, 0x12, 0x46, 0x5b, 0x4a, 0x24, 0x0d, + 0x93, 0x66, 0x4d, 0x6a, 0x00, 0xd3, 0x22, 0xcc, 0x41, 0xfd, 0x46, 0xef, 0xd8, 0x53, 0xc2, 0x74, 0x96, 0x6d, 0xd7, + 0xa8, 0x6c, 0x92, 0x3b, 0xce, 0x15, 0x3a, 0x09, 0x29, 0x15, 0x65, 0x5f, 0x32, 0x05, 0xa9, 0x3c, 0x26, 0x9c, 0xe3, + 0x6a, 0x40, 0x32, 0x4c, 0xa9, 0xa0, 0xf6, 0xd6, 0x59, 0x4a, 0x5d, 0x72, 0xe6, 0x08, 0x9f, 0x62, 0xf9, 0xb3, 0xaa, + 0x79, 0xd8, 0x54, 0x63, 0x38, 0xed, 0xd5, 0xcb, 0xc5, 0x82, 0x07, 0xf3, 0x27, 0x70, 0x01, 0x45, 0xbe, 0xa2, 0xa6, + 0x3c, 0x97, 0x87, 0x72, 0x12, 0x7d, 0x32, 0xfe, 0x3d, 0xd5, 0x2d, 0x88, 0x5c, 0xe6, 0x33, 0x44, 0x66, 0xfa, 0x8d, + 0xfb, 0xea, 0xc3, 0x72, 0xb0, 0xa9, 0x24, 0xa6, 0x7f, 0x3f, 0x79, 0xb7, 0x42, 0x19, 0x7e, 0xe8, 0x89, 0x6d, 0xd1, + 0x52, 0xd0, 0xb3, 0xc8, 0xa8, 0xc2, 0x1c, 0x91, 0x13, 0x25, 0x70, 0x96, 0x3d, 0x4a, 0xf0, 0x92, 0x1a, 0x3a, 0x42, + 0x5b, 0x10, 0x27, 0xac, 0xa8, 0x1c, 0xc9, 0xb3, 0xbf, 0x55, 0xf5, 0x92, 0xea, 0x94, 0xc7, 0x80, 0xd5, 0x5f, 0x7b, + 0xa8, 0xbe, 0xbe, 0xb7, 0x41, 0x04, 0x5b, 0xd0, 0xea, 0x71, 0xf8, 0x12, 0xc0, 0x41, 0x30, 0x59, 0x20, 0x53, 0x1e, + 0x53, 0x43, 0xf5, 0xaa, 0x6f, 0x4f, 0x8e, 0x42, 0xde, 0x80, 0x22, 0xdc, 0x12, 0x4f, 0xa7, 0xa7, 0x71, 0x29, 0x6e, + 0x3f, 0x95, 0xfe, 0x81, 0x2f, 0xc0, 0xae, 0x55, 0x0a, 0x1e, 0x60, 0x4a, 0xc2, 0x1b, 0x99, 0x0a, 0x6b, 0xf9, 0xa6, + 0xd3, 0x9b, 0x97, 0x3c, 0xc6, 0x43, 0xb8, 0xb7, 0x3b, 0x70, 0xd6, 0x57, 0xef, 0x35, 0x9a, 0xca, 0xc0, 0xc5, 0x14, + 0x6d, 0x38, 0x3a, 0xc0, 0xe5, 0x1c, 0x61, 0x59, 0xdd, 0x7d, 0x38, 0xa3, 0x54, 0x06, 0x91, 0x32, 0x3a, 0xec, 0xaa, + 0xb4, 0x98, 0xf4, 0xd8, 0x62, 0x16, 0xf2, 0x3e, 0xc5, 0x19, 0x61, 0x13, 0x51, 0x4c, 0x13, 0x7d, 0x58, 0x04, 0xe2, + 0x19, 0x18, 0xdd, 0xae, 0x5d, 0x3f, 0x90, 0xec, 0x0d, 0x43, 0x28, 0xbf, 0x54, 0xee, 0x52, 0xbb, 0xae, 0xba, 0x00, + 0x96, 0xef, 0xc2, 0x7c, 0x42, 0x90, 0xa7, 0xaf, 0x38, 0xac, 0xab, 0xdf, 0xca, 0xcc, 0x46, 0x6e, 0xac, 0x6e, 0x85, + 0x4e, 0x2e, 0xdc, 0xd6, 0x2b, 0x1d, 0xe8, 0x4c, 0x40, 0xc2, 0x07, 0xcf, 0xbe, 0x8f, 0xb6, 0x91, 0x4a, 0x32, 0x57, + 0xdc, 0x73, 0xde, 0x5b, 0x10, 0x56, 0x0f, 0x64, 0x3d, 0x22, 0x17, 0x24, 0xe8, 0x05, 0x1c, 0xcf, 0xe7, 0xd1, 0xa9, + 0x79, 0xc5, 0xde, 0x28, 0x9f, 0x18, 0x96, 0xb8, 0x99, 0x9b, 0x4f, 0x87, 0xa7, 0x88, 0xf4, 0x7d, 0x8c, 0x84, 0x83, + 0x30, 0x24, 0x55, 0xde, 0xbc, 0x91, 0x50, 0xc4, 0x53, 0xc9, 0xce, 0xb8, 0xdc, 0x9c, 0xd5, 0x88, 0xc5, 0xa5, 0x28, + 0xaf, 0x5e, 0x3b, 0x8a, 0xf2, 0x8e, 0xad, 0x5a, 0xd2, 0xce, 0xf6, 0x95, 0x32, 0xb6, 0x2a, 0x71, 0x44, 0x23, 0xa3, + 0x13, 0xb7, 0x7a, 0x51, 0x2a, 0x87, 0xac, 0x91, 0xb2, 0x81, 0x75, 0x65, 0x32, 0x0b, 0xca, 0xc3, 0xca, 0xf9, 0x22, + 0xee, 0xd8, 0x2e, 0x82, 0x56, 0xa1, 0xb0, 0x74, 0x50, 0x2f, 0x28, 0x7a, 0x3f, 0x00, 0x5b, 0x27, 0xf9, 0xdb, 0x52, + 0x74, 0xf1, 0x3d, 0x74, 0x95, 0x5d, 0xd0, 0x81, 0x30, 0x1e, 0x25, 0x69, 0x18, 0x18, 0xc8, 0x37, 0x0f, 0xb6, 0x23, + 0xd0, 0xe5, 0xf5, 0xf6, 0xa8, 0xa4, 0xd3, 0x29, 0xc8, 0x29, 0x03, 0xc0, 0x34, 0x51, 0x58, 0x5d, 0xad, 0x31, 0xfb, + 0xbb, 0xe3, 0xe2, 0x57, 0xc7, 0xae, 0x15, 0xcc, 0xf0, 0x41, 0xd8, 0xcd, 0xbd, 0xab, 0xad, 0xc1, 0x17, 0xa7, 0x8e, + 0x99, 0x58, 0x98, 0x95, 0x96, 0x3f, 0xaf, 0x37, 0xb3, 0x7f, 0x74, 0x7d, 0x4c, 0xe1, 0x03, 0x3d, 0x8e, 0x5d, 0x8b, + 0xda, 0x47, 0xf1, 0xbc, 0x94, 0xf8, 0xc2, 0xef, 0x25, 0x8f, 0x9b, 0xa1, 0xaf, 0xbe, 0x86, 0x39, 0x0c, 0xad, 0x58, + 0x1b, 0xa9, 0xfc, 0x02, 0x9b, 0xde, 0x84, 0x3b, 0x71, 0xc4, 0x80, 0x73, 0x3d, 0x47, 0x9a, 0xf9, 0xcc, 0x64, 0xeb, + 0x99, 0xa4, 0xd1, 0x30, 0x1d, 0x89, 0x38, 0x6a, 0x01, 0x2a, 0x3e, 0x71, 0x70, 0x7f, 0x78, 0xc0, 0x48, 0x71, 0x18, + 0xa3, 0x1f, 0x8b, 0x7c, 0x9b, 0x12, 0xcb, 0x86, 0xaf, 0xe0, 0xb9, 0x66, 0x97, 0x3f, 0xd8, 0x6f, 0x8d, 0x8b, 0x55, + 0x8f, 0x95, 0x41, 0xbc, 0x9c, 0xae, 0xd9, 0x1b, 0x94, 0xb1, 0x3a, 0x8f, 0xb0, 0xdc, 0x9b, 0xcc, 0xe6, 0xcc, 0x05, + 0x72, 0x12, 0xa8, 0xde, 0xe8, 0xe6, 0x97, 0x22, 0xba, 0xd5, 0xae, 0xc1, 0x39, 0x3f, 0x33, 0xdf, 0xa3, 0x48, 0xfc, + 0xe4, 0x47, 0x5d, 0x32, 0xcb, 0xd6, 0x09, 0x74, 0xb2, 0xec, 0xca, 0x8d, 0xef, 0xb6, 0xbe, 0x78, 0xb7, 0xbe, 0xb0, + 0xfe, 0x44, 0x86, 0xf3, 0x4b, 0x72, 0xf7, 0xf8, 0x27, 0xd6, 0x41, 0x6c, 0xfd, 0xe7, 0x2f, 0x94, 0xfc, 0x4f, 0xa9, + 0x71, 0xe7, 0x8f, 0x9d, 0x21, 0x54, 0x8c, 0x50, 0xe3, 0x0d, 0xc6, 0x82, 0x73, 0x77, 0xa4, 0x64, 0xdd, 0x8c, 0x71, + 0x5a, 0x49, 0x59, 0x03, 0xf7, 0x95, 0x26, 0xa7, 0x55, 0x6e, 0xaf, 0x05, 0x31, 0xbb, 0x34, 0xb5, 0x43, 0x81, 0x4f, + 0x7d, 0x26, 0x65, 0x49, 0x72, 0x9d, 0xf2, 0xec, 0x1f, 0xa9, 0xed, 0x08, 0x60, 0xae, 0x7e, 0x05, 0x10, 0x8c, 0xf3, + 0xe5, 0xee, 0x5a, 0xe9, 0xa9, 0xcf, 0x60, 0x54, 0x8f, 0xbc, 0xf4, 0x2a, 0x5f, 0x16, 0x71, 0xa5, 0x1b, 0xe5, 0x3b, + 0x5c, 0xc2, 0x46, 0xb4, 0x78, 0xf7, 0xd2, 0x6b, 0x85, 0xbc, 0xa3, 0x7c, 0x50, 0x55, 0x39, 0x8c, 0x8a, 0xe6, 0xab, + 0x9b, 0x12, 0x2e, 0xb3, 0x77, 0xb0, 0xc9, 0x26, 0x1d, 0x74, 0x16, 0x6c, 0xc9, 0x04, 0x89, 0xc4, 0xb0, 0xec, 0x90, + 0x90, 0xab, 0xb4, 0x6f, 0xf0, 0x32, 0x54, 0xa7, 0x3a, 0xa7, 0xe8, 0xa7, 0x1d, 0x2f, 0xe9, 0x88, 0x69, 0xa1, 0x2d, + 0xd2, 0x11, 0xa5, 0x03, 0x63, 0xcc, 0x78, 0x85, 0x54, 0x35, 0x30, 0xbc, 0x56, 0x13, 0x4f, 0xb1, 0xbc, 0xf6, 0xe0, + 0x31, 0x91, 0x09, 0x62, 0xdf, 0xc2, 0xd5, 0x46, 0x1c, 0xdc, 0xc8, 0xf2, 0x5a, 0xb9, 0x0a, 0xaf, 0xee, 0xe1, 0x59, + 0xdb, 0x99, 0x7c, 0x48, 0x28, 0x90, 0x58, 0xe6, 0x17, 0x3a, 0x7e, 0xad, 0xa7, 0xbb, 0xe2, 0x25, 0xda, 0x65, 0x07, + 0x48, 0x53, 0x4b, 0xbc, 0x9c, 0xbe, 0xcb, 0x5e, 0x99, 0xd5, 0x4b, 0x4d, 0x1a, 0xdd, 0x42, 0xba, 0xf6, 0x08, 0xf1, + 0x30, 0x1f, 0x0a, 0x76, 0x5f, 0x92, 0x06, 0xe8, 0x0a, 0xb3, 0x5b, 0xfc, 0xa5, 0x8d, 0x0b, 0xf7, 0xe1, 0xa3, 0x88, + 0x48, 0xf4, 0x25, 0xbf, 0x16, 0x2f, 0xfe, 0x93, 0xef, 0x48, 0xc0, 0xe8, 0x22, 0x3e, 0xc9, 0xed, 0x07, 0x56, 0x45, + 0x97, 0x09, 0xcd, 0xfd, 0xe2, 0x34, 0x81, 0xd9, 0x0d, 0xf4, 0xe2, 0x26, 0xf3, 0x35, 0xdd, 0x5d, 0x69, 0xea, 0xde, + 0x1f, 0x8e, 0x55, 0x1d, 0xb5, 0xf9, 0xd2, 0x53, 0x77, 0x93, 0xed, 0x75, 0x8d, 0x4b, 0xdd, 0x42, 0x4d, 0xba, 0x62, + 0x6b, 0x6d, 0x51, 0x9f, 0xa6, 0x79, 0x6f, 0x56, 0xfe, 0x55, 0xb6, 0x75, 0x03, 0xb8, 0x5e, 0x88, 0x75, 0xcd, 0x46, + 0x4d, 0x90, 0xb2, 0xd4, 0x85, 0x68, 0xdf, 0xb8, 0x01, 0x68, 0xb1, 0x86, 0x75, 0x9d, 0x2a, 0xd3, 0x79, 0x9a, 0x8f, + 0x26, 0x04, 0x7d, 0x1f, 0x07, 0x97, 0xf2, 0x46, 0xff, 0x8a, 0x46, 0xad, 0xea, 0xf3, 0xcd, 0x73, 0x1e, 0x9d, 0x08, + 0x6f, 0x1c, 0xd0, 0x2e, 0x8d, 0x11, 0x2f, 0x3d, 0xfd, 0x4c, 0xf9, 0xd2, 0x8d, 0x51, 0x60, 0xbc, 0x00, 0x79, 0x3d, + 0xf4, 0xcb, 0x8d, 0x74, 0x81, 0x5e, 0x55, 0x46, 0x19, 0x5f, 0xdc, 0xcf, 0xbc, 0x7e, 0x25, 0x3e, 0xa0, 0x7c, 0x83, + 0x20, 0x54, 0x58, 0x56, 0x71, 0xc8, 0x20, 0x03, 0x7c, 0xdd, 0xa2, 0x5f, 0x46, 0xbf, 0x68, 0x73, 0x1e, 0xe6, 0x45, + 0xac, 0xbc, 0x39, 0xfc, 0x66, 0x78, 0x79, 0xf5, 0xbc, 0x1e, 0xf3, 0x03, 0xd9, 0xdb, 0xb5, 0xd2, 0x8e, 0x51, 0x3a, + 0x38, 0xc4, 0xae, 0x70, 0x9d, 0x02, 0x30, 0x2a, 0x41, 0xc7, 0x41, 0x54, 0x40, 0x5b, 0xfb, 0x81, 0xd4, 0x8a, 0x32, + 0x52, 0x90, 0x56, 0x6b, 0x38, 0x83, 0xb4, 0x03, 0x4a, 0xb6, 0x4d, 0xb3, 0x18, 0x99, 0x9d, 0x47, 0xba, 0xab, 0x8e, + 0xd3, 0xa2, 0xc1, 0x8b, 0xb3, 0x32, 0x31, 0xee, 0x51, 0x92, 0xab, 0xa4, 0x71, 0x63, 0x78, 0x79, 0xf3, 0x46, 0xa4, + 0x1b, 0xf7, 0x87, 0x4b, 0xae, 0x6e, 0xd9, 0xb9, 0x04, 0xa7, 0x37, 0xbf, 0x04, 0xe2, 0xe3, 0xa6, 0xf9, 0xda, 0x47, + 0x02, 0xee, 0x3f, 0x97, 0x80, 0xbb, 0x62, 0xf2, 0x32, 0x9b, 0xc0, 0xe0, 0x47, 0xe3, 0x9c, 0x69, 0xf0, 0x03, 0x63, + 0xcf, 0x85, 0x5e, 0x0b, 0x18, 0xfc, 0xa8, 0x23, 0x5e, 0xa3, 0x96, 0x90, 0x0e, 0xa3, 0xd2, 0xf7, 0xc4, 0xd8, 0xa0, + 0x3d, 0x32, 0xdd, 0xeb, 0xe0, 0xc6, 0x10, 0x22, 0x2f, 0x4c, 0xa1, 0x70, 0x04, 0xc0, 0xf9, 0xff, 0x0a, 0x92, 0xe6, + 0x9b, 0x43, 0xe1, 0x02, 0xe4, 0xb9, 0xd6, 0x8d, 0x48, 0x87, 0x4e, 0x2c, 0x63, 0xd8, 0x11, 0x33, 0x66, 0x63, 0xa6, + 0xaa, 0xe8, 0x31, 0x27, 0xce, 0x00, 0xca, 0x86, 0xaf, 0xc1, 0xd7, 0x36, 0x03, 0x13, 0xa2, 0x2a, 0x82, 0xc1, 0xbb, + 0xb7, 0xb0, 0x11, 0x74, 0x36, 0x25, 0x46, 0x34, 0x54, 0x43, 0x93, 0xaf, 0xf2, 0x62, 0x3b, 0xa1, 0xb1, 0x3f, 0x06, + 0x99, 0xac, 0x7e, 0xfa, 0xcc, 0x70, 0x1f, 0xeb, 0xf7, 0x3b, 0xd1, 0x56, 0x98, 0x14, 0xe4, 0xd3, 0x96, 0xa5, 0x73, + 0xe9, 0xc5, 0x25, 0x78, 0x69, 0xfa, 0xa6, 0xe6, 0x60, 0x7d, 0x99, 0x83, 0x2c, 0xa7, 0xfe, 0x3c, 0x98, 0x3b, 0x48, + 0x30, 0x75, 0x9e, 0x16, 0x01, 0x2e, 0x21, 0xa2, 0xf4, 0x5c, 0x66, 0x04, 0x36, 0x93, 0x87, 0x99, 0x6c, 0xae, 0xb0, + 0x78, 0x7e, 0xa4, 0x99, 0x9b, 0x51, 0xa1, 0xd7, 0xfd, 0xfc, 0xee, 0xa3, 0x34, 0xfb, 0xba, 0x3c, 0x8e, 0xbb, 0x5b, + 0xcd, 0x19, 0x88, 0xaa, 0x9d, 0xd2, 0x83, 0x5f, 0x64, 0x1d, 0xd8, 0xdb, 0xd6, 0xf4, 0xed, 0xe3, 0x9f, 0x7e, 0xe9, + 0x90, 0x4c, 0x9d, 0xdb, 0xd0, 0x59, 0x74, 0xfa, 0x7e, 0x8f, 0x91, 0x36, 0x5b, 0xe1, 0x88, 0x81, 0xca, 0x53, 0x43, + 0x36, 0xa9, 0x37, 0x71, 0x82, 0x1b, 0x1f, 0x91, 0xaa, 0x4d, 0x7f, 0x03, 0x8f, 0xf5, 0xc3, 0x8f, 0xe6, 0x4e, 0xd5, + 0xed, 0x85, 0xef, 0xdb, 0x3b, 0xa1, 0xdd, 0x3c, 0xbe, 0x56, 0xaf, 0xcd, 0xfb, 0xce, 0x48, 0x5d, 0x50, 0xf4, 0xbc, + 0xf6, 0xbf, 0x52, 0x33, 0x0e, 0xde, 0x36, 0xf7, 0x89, 0x81, 0x6f, 0xc7, 0xe7, 0x31, 0xcf, 0x80, 0xac, 0x65, 0x16, + 0x2d, 0x8d, 0x5c, 0xe3, 0x1a, 0x07, 0x94, 0x15, 0xe2, 0x8a, 0x66, 0xaa, 0x8d, 0x87, 0xa8, 0xeb, 0x1d, 0x2f, 0x67, + 0x2b, 0x5c, 0xdc, 0x62, 0x5a, 0xc5, 0x37, 0x71, 0xe1, 0xec, 0xe6, 0x99, 0xe2, 0x2a, 0x9b, 0x53, 0x75, 0x91, 0xe9, + 0x77, 0x41, 0x57, 0x1d, 0x06, 0xc1, 0x66, 0xd2, 0x87, 0xeb, 0xce, 0x43, 0x17, 0x6e, 0x5c, 0x0c, 0x0f, 0x01, 0xa9, + 0xb4, 0x9c, 0x40, 0x01, 0x63, 0x5b, 0xdc, 0x50, 0x96, 0x38, 0xbe, 0xfe, 0xf9, 0xc0, 0xc3, 0x00, 0xf0, 0x8d, 0x3d, + 0xc4, 0xc4, 0x6c, 0x65, 0x33, 0xcd, 0x09, 0x3f, 0xc3, 0x40, 0x8e, 0x2b, 0xef, 0x34, 0x6e, 0x87, 0xff, 0x33, 0x36, + 0x11, 0x29, 0xa0, 0x49, 0x2c, 0x2c, 0x64, 0xa6, 0xed, 0x14, 0x7d, 0xa2, 0x10, 0xba, 0x62, 0x29, 0x1f, 0x5c, 0xe6, + 0xe0, 0xbb, 0xd6, 0x7b, 0x5f, 0x57, 0x7e, 0x7d, 0x10, 0xb4, 0x54, 0x4d, 0xb0, 0x96, 0x14, 0x0a, 0x49, 0x60, 0xed, + 0x48, 0xa7, 0xf5, 0xb5, 0x1d, 0x28, 0x28, 0x59, 0x16, 0x44, 0xd2, 0xf9, 0x5a, 0x3b, 0xa4, 0x4e, 0xc5, 0x5f, 0xf8, + 0x6f, 0x3f, 0x4d, 0xe0, 0xd7, 0x56, 0xc4, 0x40, 0x7d, 0x1d, 0x5f, 0x77, 0x5f, 0x45, 0xbb, 0x21, 0x6d, 0xd5, 0x8f, + 0xa9, 0xb2, 0x99, 0x91, 0xf2, 0x7e, 0xac, 0xfe, 0xfc, 0xd9, 0x86, 0xa1, 0x69, 0xe2, 0x78, 0x78, 0x73, 0x33, 0x77, + 0x98, 0x29, 0x9f, 0x43, 0xaf, 0x36, 0x56, 0xdf, 0x00, 0x6c, 0x91, 0x93, 0xda, 0x35, 0x51, 0x30, 0xc1, 0x34, 0xd9, + 0x44, 0xdf, 0x3d, 0x52, 0x92, 0x98, 0xb5, 0x47, 0xa1, 0x77, 0x97, 0x32, 0x2d, 0x5a, 0xaa, 0xb9, 0x1a, 0x0b, 0x65, + 0x3a, 0xa9, 0x18, 0x6c, 0x6a, 0xfc, 0xd9, 0x95, 0xf3, 0x99, 0x53, 0x10, 0x79, 0xb1, 0xe1, 0x91, 0xeb, 0x73, 0xc8, + 0xc3, 0xad, 0x7c, 0xd5, 0xe7, 0xe7, 0xf6, 0xc2, 0x2b, 0xde, 0xeb, 0xbd, 0x72, 0x1d, 0x6a, 0xe9, 0x31, 0xcf, 0x8b, + 0xba, 0x5f, 0x96, 0x6b, 0x1c, 0x18, 0x50, 0x3b, 0x01, 0xc6, 0xb9, 0x88, 0x02, 0x0c, 0xf0, 0x4a, 0xba, 0x67, 0x24, + 0x3d, 0x9e, 0xc5, 0x25, 0xfa, 0x91, 0xa1, 0x9a, 0xa7, 0xcd, 0x4b, 0x40, 0x94, 0x2a, 0x3b, 0xce, 0x2d, 0x9d, 0x4c, + 0xb3, 0xa8, 0x2d, 0xbd, 0x33, 0x9d, 0x46, 0xf9, 0xbe, 0x02, 0x80, 0xf4, 0x9d, 0x7e, 0xe4, 0x4c, 0x87, 0x72, 0x73, + 0x80, 0x70, 0xa3, 0x64, 0xc6, 0x8d, 0x89, 0xc2, 0xf3, 0x13, 0x03, 0x22, 0x84, 0xb8, 0x1a, 0xf8, 0xca, 0x4b, 0xda, + 0x27, 0x2a, 0x42, 0x43, 0xfc, 0x80, 0x1e, 0xdc, 0x87, 0x5b, 0xfb, 0xf7, 0x7e, 0x50, 0x55, 0x72, 0xb0, 0x0c, 0x25, + 0x46, 0xe9, 0xde, 0xf8, 0x55, 0x81, 0xdd, 0x4f, 0xcc, 0x4a, 0x2d, 0x11, 0x50, 0x69, 0xf9, 0x7e, 0x71, 0x51, 0xe6, + 0xfc, 0xe9, 0x0f, 0xd7, 0x71, 0x48, 0xa8, 0x91, 0x2f, 0x53, 0xd9, 0x01, 0xf9, 0xf0, 0x1d, 0xfd, 0x2c, 0xca, 0x6a, + 0x0a, 0xbf, 0x8d, 0x2d, 0xdc, 0x5d, 0x16, 0x59, 0xea, 0x00, 0x50, 0x84, 0x63, 0x34, 0x1b, 0x3f, 0xed, 0x92, 0x0c, + 0xed, 0xa2, 0x8d, 0xdf, 0x69, 0x49, 0x33, 0x2a, 0x2a, 0x8a, 0x86, 0xd0, 0x6c, 0x34, 0x43, 0x0a, 0xe6, 0x09, 0x7a, + 0xf1, 0x31, 0x3b, 0xf0, 0xe7, 0x46, 0x49, 0x59, 0xba, 0x35, 0x7f, 0xbd, 0xbd, 0x90, 0xac, 0xa7, 0xac, 0x6e, 0x8a, + 0x30, 0x59, 0xd0, 0x0c, 0x7d, 0xe5, 0xff, 0x30, 0x80, 0xa7, 0x90, 0x97, 0x2b, 0x16, 0xfe, 0x5e, 0xd5, 0x3d, 0x7c, + 0xb9, 0x11, 0xc7, 0xf5, 0xa2, 0x29, 0x1f, 0xb4, 0x0f, 0x21, 0xa9, 0xea, 0x7b, 0x1c, 0xf6, 0x9c, 0xfa, 0x8f, 0x85, + 0x4d, 0xb8, 0x15, 0x05, 0x02, 0xcf, 0x66, 0x2d, 0x9a, 0x88, 0xa9, 0xcb, 0x8c, 0x08, 0x63, 0x49, 0x10, 0xc4, 0xad, + 0xce, 0x79, 0x3e, 0xca, 0xcd, 0xc9, 0x49, 0x9e, 0xb7, 0xb3, 0xeb, 0x68, 0xdf, 0x9b, 0x5b, 0x29, 0xab, 0x5c, 0x37, + 0x84, 0x16, 0x2f, 0x5d, 0x5c, 0xa5, 0x32, 0x4c, 0xcb, 0x55, 0x71, 0x43, 0xab, 0xd6, 0xb4, 0x6a, 0xc0, 0x07, 0x19, + 0xb4, 0x2a, 0x4f, 0x9e, 0x76, 0x95, 0x9b, 0x6c, 0xd3, 0x97, 0x15, 0x5d, 0x77, 0xc0, 0xf0, 0x4a, 0x61, 0x6d, 0xd7, + 0xc1, 0x36, 0x9c, 0x68, 0x70, 0xde, 0xb7, 0xdb, 0x06, 0x90, 0xbc, 0xdd, 0xc5, 0x0a, 0x1e, 0x4e, 0x8e, 0xff, 0x62, + 0x87, 0xe2, 0xf7, 0xbe, 0x68, 0x65, 0x14, 0x23, 0x23, 0x34, 0xf5, 0xaf, 0x8e, 0x08, 0xff, 0xc2, 0x77, 0xa5, 0xf6, + 0x98, 0xab, 0x08, 0x65, 0xed, 0x66, 0x15, 0xfb, 0x83, 0x24, 0xbf, 0x34, 0x49, 0xf5, 0x36, 0x4f, 0x4f, 0xb0, 0x4a, + 0x41, 0x7b, 0x73, 0xd8, 0x60, 0x6b, 0xae, 0x8d, 0x14, 0x37, 0x98, 0xd0, 0xc6, 0xff, 0x60, 0x23, 0xc0, 0x27, 0x52, + 0xbc, 0xe0, 0x72, 0x5c, 0x59, 0x8a, 0xe6, 0x44, 0xf3, 0xd2, 0xc8, 0x3e, 0x85, 0x79, 0x3e, 0xaa, 0x90, 0xeb, 0xe6, + 0x3c, 0x50, 0x2f, 0x87, 0x3e, 0x71, 0xca, 0x38, 0xcf, 0x8e, 0x70, 0x3e, 0x95, 0xd3, 0xae, 0xde, 0xac, 0x2d, 0x43, + 0x5c, 0x27, 0x2b, 0x42, 0x48, 0x3e, 0x8c, 0x53, 0x51, 0xa4, 0xd8, 0xbe, 0xda, 0x39, 0xcf, 0xf1, 0x95, 0x21, 0x0a, + 0x27, 0x5c, 0x44, 0x63, 0x4a, 0xe8, 0x4f, 0x5e, 0x50, 0x74, 0x67, 0xd4, 0x24, 0x98, 0xb5, 0x3a, 0x99, 0x04, 0xce, + 0xd4, 0x7f, 0xc0, 0xc2, 0xd0, 0x1b, 0x20, 0x3a, 0xa8, 0xa9, 0x32, 0x3f, 0xba, 0x5b, 0x71, 0xe3, 0x93, 0x8e, 0xcc, + 0x68, 0x13, 0x33, 0xce, 0x94, 0xda, 0xe2, 0x6b, 0xb3, 0x7b, 0x8e, 0xc0, 0xec, 0x6e, 0x01, 0xc1, 0x22, 0x8e, 0x54, + 0x68, 0xd5, 0x9f, 0xab, 0x77, 0xbb, 0x48, 0x80, 0x73, 0x42, 0x1b, 0x03, 0x2d, 0x3e, 0xe3, 0x74, 0x35, 0xe7, 0xdb, + 0x38, 0xec, 0x18, 0x32, 0x55, 0x9c, 0xdf, 0x45, 0x9f, 0xfb, 0x99, 0x00, 0xdd, 0x2d, 0x44, 0x3a, 0xdf, 0x5b, 0x17, + 0x6a, 0x16, 0x0e, 0x21, 0x6c, 0x7f, 0x12, 0x25, 0x64, 0xa8, 0xbf, 0x16, 0x7e, 0x8e, 0xda, 0xab, 0x97, 0x5a, 0x26, + 0x1b, 0x7e, 0x30, 0xa2, 0xc5, 0xa3, 0x00, 0x92, 0x0c, 0xa3, 0xf7, 0xcf, 0xdf, 0xdc, 0xb0, 0x9f, 0xa1, 0xf0, 0x0c, + 0xe6, 0x11, 0x50, 0xc0, 0xcd, 0xdd, 0x4f, 0xe8, 0xda, 0x52, 0x2e, 0x08, 0x67, 0xb2, 0x0d, 0x09, 0x56, 0xc6, 0xb9, + 0x66, 0x6b, 0xe3, 0x45, 0xc3, 0x09, 0xe9, 0x88, 0x3a, 0x68, 0x4c, 0x7a, 0x9e, 0x33, 0x9a, 0xc7, 0x58, 0xfd, 0xc9, + 0x99, 0x60, 0xf9, 0x81, 0x8d, 0xc9, 0x15, 0x04, 0x55, 0x8b, 0x82, 0x58, 0xd3, 0x1d, 0xed, 0xc0, 0x70, 0x7f, 0x29, + 0x9e, 0x12, 0xe4, 0x6f, 0x97, 0x98, 0x38, 0x2a, 0x14, 0x72, 0xd6, 0xb8, 0xa1, 0x6f, 0x44, 0xb0, 0x5e, 0x8d, 0x07, + 0xbd, 0xe7, 0x4b, 0x91, 0xa5, 0xaa, 0x73, 0xbb, 0x51, 0x0e, 0xcd, 0x30, 0x61, 0x8c, 0x13, 0x5a, 0xca, 0x37, 0x64, + 0x25, 0x76, 0x36, 0xb5, 0x14, 0x4e, 0xff, 0x69, 0xc8, 0x53, 0xb1, 0x85, 0x80, 0xaa, 0xcf, 0x41, 0x93, 0x13, 0xd3, + 0xd4, 0x9d, 0x37, 0x72, 0x67, 0x1e, 0x60, 0x54, 0x53, 0x36, 0x3a, 0xa1, 0x77, 0xcc, 0x47, 0x66, 0xf0, 0x33, 0xb2, + 0x3b, 0x0f, 0x59, 0x2d, 0x93, 0xcb, 0x24, 0x3f, 0xeb, 0x8d, 0xef, 0x1c, 0x20, 0xb1, 0x8e, 0x41, 0xc5, 0xe6, 0x59, + 0x57, 0x59, 0xab, 0x2a, 0xd3, 0x4d, 0xfc, 0xaa, 0x5b, 0x1a, 0x28, 0x78, 0xa2, 0x02, 0x85, 0x48, 0x9a, 0x92, 0xa0, + 0x56, 0x0f, 0x21, 0x47, 0x94, 0xa3, 0xbb, 0x45, 0xcc, 0x75, 0xbc, 0xaa, 0x6c, 0xfc, 0x1b, 0xd3, 0x47, 0x8b, 0xda, + 0xa1, 0xdb, 0xcf, 0x6c, 0x54, 0xc3, 0x22, 0x55, 0x4e, 0x61, 0xc8, 0x8f, 0x38, 0x8f, 0x35, 0x09, 0xb2, 0x71, 0x32, + 0x00, 0x05, 0xbd, 0x54, 0xe0, 0x7f, 0x33, 0xe7, 0x8c, 0x15, 0x2b, 0x17, 0xa0, 0x22, 0x58, 0xbb, 0xe6, 0x5f, 0xf7, + 0x69, 0xc4, 0x28, 0x54, 0x67, 0x0f, 0xc0, 0xac, 0x85, 0x0c, 0xe4, 0x57, 0xeb, 0x6d, 0x28, 0x17, 0xb6, 0xe1, 0xa4, + 0xf5, 0xba, 0xfa, 0x2c, 0xe4, 0x22, 0xad, 0xa6, 0x68, 0xb3, 0x3a, 0x4f, 0x9d, 0x15, 0x4c, 0xf8, 0x25, 0x9c, 0x9b, + 0x4e, 0x90, 0xa5, 0xc6, 0x91, 0xf2, 0x30, 0xfb, 0x38, 0x6a, 0x9d, 0x59, 0x39, 0x76, 0xa1, 0x0a, 0xdb, 0x3c, 0xcf, + 0x9c, 0x30, 0xbd, 0xd8, 0x93, 0xaa, 0xda, 0x95, 0x95, 0xee, 0xe6, 0x5a, 0xcc, 0x9b, 0x5d, 0x1d, 0x49, 0x2d, 0x31, + 0xad, 0x93, 0xfd, 0x89, 0x95, 0x59, 0x81, 0xe0, 0x6d, 0xe8, 0x36, 0x42, 0x64, 0x17, 0xec, 0x47, 0x5a, 0xbc, 0x74, + 0x4b, 0xae, 0x8e, 0x60, 0x11, 0x5a, 0x45, 0xff, 0x50, 0x5a, 0x18, 0x90, 0xea, 0x8a, 0x92, 0xd2, 0x48, 0xff, 0xad, + 0xcc, 0x70, 0x92, 0x59, 0xbd, 0x77, 0xa8, 0x3d, 0x16, 0x41, 0xbd, 0x1f, 0x93, 0x1e, 0xe5, 0x5c, 0x2f, 0x05, 0x9c, + 0x2c, 0x81, 0xd9, 0x0b, 0x76, 0x0b, 0x00, 0x79, 0xed, 0x6d, 0x2d, 0x15, 0x99, 0x70, 0xf9, 0x3c, 0x99, 0x73, 0x69, + 0x15, 0x78, 0x05, 0xbd, 0x6b, 0x6f, 0xb0, 0xb2, 0x10, 0xdc, 0x2f, 0x72, 0xa6, 0xcf, 0x0a, 0x92, 0x4a, 0x43, 0xbc, + 0xb4, 0x04, 0xde, 0x4a, 0xaa, 0x29, 0x70, 0x6b, 0xd9, 0x70, 0x6d, 0xda, 0x46, 0x1f, 0xea, 0xfd, 0x78, 0xc7, 0x68, + 0x15, 0xfc, 0xe7, 0xd3, 0xdf, 0x2a, 0x76, 0x47, 0xf0, 0x6c, 0x15, 0xaa, 0xac, 0xeb, 0x61, 0x22, 0xd9, 0xfe, 0x6a, + 0xe7, 0x0b, 0xa0, 0x45, 0xb8, 0x52, 0xba, 0x26, 0x01, 0x9d, 0xd4, 0x14, 0x0b, 0xdc, 0xa6, 0xc0, 0x2c, 0xa3, 0x9f, + 0xc2, 0xb7, 0x91, 0x6b, 0x1c, 0xa9, 0x46, 0x34, 0x99, 0x71, 0xb8, 0x20, 0x9a, 0xbc, 0xb9, 0x5b, 0x15, 0x01, 0x04, + 0x07, 0x68, 0x2b, 0xef, 0x8c, 0xd3, 0x3b, 0xf7, 0x91, 0xd6, 0x39, 0xf0, 0x43, 0x37, 0xd9, 0x2e, 0x75, 0x68, 0xd5, + 0x12, 0xbd, 0x5d, 0x47, 0x8d, 0x06, 0x19, 0xb6, 0x44, 0x31, 0xb6, 0xe0, 0xe3, 0x13, 0x3e, 0x66, 0x90, 0x55, 0x72, + 0xc0, 0xd7, 0x8b, 0x06, 0x2a, 0x16, 0x15, 0xc8, 0xdf, 0x85, 0x50, 0xa8, 0xa3, 0x6d, 0xb4, 0x00, 0x40, 0x7d, 0x82, + 0x12, 0x3a, 0x71, 0x4b, 0xbd, 0x01, 0x55, 0xbe, 0x0f, 0x29, 0x95, 0x50, 0xdf, 0x54, 0x64, 0xca, 0xd1, 0x52, 0x31, + 0x03, 0x84, 0x91, 0x47, 0x26, 0x43, 0x6d, 0xe2, 0x2c, 0x62, 0xee, 0xde, 0x32, 0xaa, 0x7e, 0x6c, 0xcf, 0x3b, 0x59, + 0xda, 0x6b, 0x11, 0x73, 0x95, 0x33, 0xde, 0x07, 0x50, 0x02, 0x07, 0x57, 0x81, 0xb9, 0x67, 0xaa, 0x77, 0x55, 0xbc, + 0xcf, 0x2c, 0xb3, 0x86, 0x07, 0x4a, 0xcf, 0x2e, 0xc6, 0xd7, 0x98, 0xeb, 0xcf, 0xad, 0x89, 0x67, 0xf1, 0x5f, 0x1f, + 0xb7, 0x7c, 0x9e, 0xc3, 0xef, 0x26, 0xda, 0xd5, 0x19, 0xb8, 0x72, 0xc2, 0x3e, 0x4f, 0xd0, 0xae, 0x1b, 0xbc, 0x5b, + 0xb6, 0x16, 0x6b, 0x9e, 0xbc, 0x09, 0xef, 0x5b, 0x33, 0x87, 0xaa, 0xaa, 0x3c, 0xae, 0x36, 0x10, 0x48, 0xe3, 0x3b, + 0x93, 0xcc, 0xa0, 0x6b, 0x48, 0x9a, 0xe9, 0x46, 0xf0, 0xbb, 0x6f, 0xdd, 0x82, 0x8e, 0x34, 0xb0, 0xd8, 0xda, 0x3b, + 0x81, 0xcf, 0x4c, 0x86, 0x15, 0xb3, 0xe4, 0x0c, 0x7e, 0x7b, 0x1b, 0xc2, 0xd3, 0xd6, 0x9b, 0x72, 0xb9, 0x22, 0x8b, + 0x3e, 0x0f, 0xfd, 0x8a, 0x7e, 0x93, 0x96, 0xe5, 0x71, 0x0f, 0x55, 0x72, 0xff, 0x57, 0xb1, 0xe6, 0x34, 0xfa, 0x2a, + 0xa8, 0x5f, 0xbd, 0x63, 0xc0, 0xe6, 0xb6, 0xf6, 0x16, 0x72, 0xba, 0xb4, 0xc8, 0x3d, 0x18, 0x9a, 0xe9, 0xfd, 0x8f, + 0x02, 0x61, 0xc9, 0x9e, 0xd2, 0xd6, 0xf3, 0xe4, 0xa2, 0x97, 0xea, 0xdc, 0x88, 0x7f, 0xcb, 0x95, 0xdf, 0xbc, 0x8e, + 0x1a, 0xa5, 0x89, 0xff, 0x83, 0xff, 0xb5, 0x51, 0x26, 0x97, 0x3a, 0xb9, 0xd3, 0x0e, 0xca, 0xa3, 0x2e, 0x39, 0x1e, + 0xc5, 0x52, 0x33, 0x1a, 0xc5, 0x33, 0x61, 0x9f, 0xb9, 0xa0, 0x2a, 0xf4, 0x58, 0x36, 0x00, 0x6b, 0x18, 0x40, 0x32, + 0xa0, 0x26, 0x67, 0xc4, 0xa9, 0x3b, 0xc1, 0xad, 0x86, 0xd2, 0x55, 0x64, 0x46, 0x72, 0x5a, 0x78, 0x97, 0xf7, 0x2b, + 0x31, 0x44, 0xb9, 0xac, 0x6f, 0x52, 0x47, 0x54, 0x7c, 0x15, 0x5d, 0x4a, 0xdf, 0x22, 0x36, 0xda, 0x7e, 0xd8, 0xd0, + 0x8e, 0x39, 0x60, 0xe4, 0xbd, 0xd1, 0xa8, 0xe5, 0xcc, 0x20, 0xe6, 0xa7, 0x67, 0xd0, 0xc4, 0x01, 0xb3, 0x15, 0x43, + 0xcc, 0x51, 0x72, 0x55, 0x6a, 0xd2, 0x18, 0x14, 0x13, 0x3b, 0x71, 0xa4, 0x3e, 0xbf, 0xee, 0x4e, 0x0a, 0x3f, 0xcc, + 0xa9, 0xa9, 0x75, 0x3f, 0x80, 0x2d, 0x3e, 0xd5, 0xfa, 0x1d, 0x55, 0x18, 0x98, 0xed, 0x1a, 0x22, 0xfc, 0x8d, 0x8a, + 0x8b, 0xf4, 0x24, 0xfd, 0x3b, 0xf5, 0x55, 0x75, 0x1b, 0x31, 0x64, 0xcc, 0xec, 0x04, 0x6b, 0x26, 0x07, 0xb4, 0x2c, + 0xce, 0xcc, 0x2c, 0xe5, 0xb3, 0x71, 0x2c, 0xb1, 0x16, 0x58, 0x6c, 0x79, 0x9b, 0x07, 0x77, 0x68, 0x41, 0xa8, 0x48, + 0x9c, 0x58, 0xb6, 0x31, 0x73, 0x13, 0xda, 0xe0, 0x09, 0xb1, 0xa2, 0x5f, 0xf0, 0x8d, 0x10, 0x3f, 0x3a, 0xe8, 0x4d, + 0x6a, 0xa7, 0xd1, 0x95, 0xd1, 0xc1, 0x38, 0xbc, 0xe6, 0xbf, 0x5d, 0x37, 0x11, 0x74, 0x89, 0xb8, 0xa9, 0x80, 0x4b, + 0x8e, 0x9f, 0x62, 0x50, 0x27, 0x37, 0x83, 0x4d, 0x7c, 0xa7, 0xe3, 0xad, 0x1d, 0xac, 0x77, 0xc0, 0xb9, 0x3f, 0xfe, + 0x3b, 0x71, 0x1b, 0xa5, 0x5c, 0x9e, 0xfc, 0x16, 0x3b, 0x19, 0xa2, 0x39, 0x4f, 0x6f, 0x1d, 0x5e, 0x2d, 0xd2, 0x4c, + 0x75, 0x6a, 0x7a, 0x73, 0x3c, 0xd2, 0x09, 0xfc, 0x95, 0xf1, 0xec, 0x82, 0xe3, 0xb4, 0x60, 0x05, 0xe5, 0x03, 0x7e, + 0x0f, 0xa5, 0x1a, 0xae, 0x5c, 0xf4, 0x75, 0x40, 0x3d, 0x53, 0x7c, 0x59, 0x8d, 0xb5, 0x6f, 0xd2, 0x2d, 0xf8, 0xc3, + 0x1e, 0x16, 0x65, 0x5d, 0x3f, 0x3f, 0x7f, 0xb3, 0x97, 0x8d, 0xf4, 0xfc, 0x77, 0x60, 0x49, 0xfd, 0x53, 0x09, 0xaa, + 0xf6, 0xa6, 0xe6, 0x8d, 0x83, 0x78, 0x1a, 0x53, 0x1a, 0xd1, 0xff, 0xd2, 0x31, 0x75, 0x55, 0x06, 0x57, 0xc0, 0x3c, + 0x78, 0x12, 0x93, 0xa5, 0x9f, 0x8d, 0xa9, 0xa5, 0xf0, 0x6b, 0xcc, 0x4f, 0x6a, 0xf5, 0x90, 0xe3, 0x3c, 0xe4, 0xe2, + 0x95, 0xa4, 0x7b, 0x6f, 0x56, 0xdf, 0xce, 0x16, 0x06, 0xa7, 0xf9, 0x2a, 0x80, 0xff, 0xc7, 0x39, 0x01, 0x74, 0xf7, + 0xcc, 0xc5, 0x63, 0x9e, 0x7c, 0x78, 0xb3, 0xb5, 0x9a, 0x16, 0xe4, 0xdd, 0x79, 0x2a, 0xcd, 0xd6, 0x82, 0x58, 0x9b, + 0x7a, 0x34, 0x41, 0xbd, 0xd3, 0x5b, 0xd3, 0xbe, 0xb1, 0x3e, 0x8c, 0x86, 0xbe, 0x23, 0x0b, 0x85, 0xe7, 0x8f, 0x09, + 0x67, 0xc7, 0xb3, 0x89, 0x89, 0x61, 0xbf, 0x53, 0xed, 0x62, 0x60, 0xab, 0xab, 0x15, 0x0b, 0xc6, 0xfb, 0x81, 0xee, + 0x9b, 0x4c, 0x96, 0x72, 0x3c, 0xc6, 0x4c, 0x25, 0x6a, 0xda, 0xb7, 0xd4, 0xb2, 0xbb, 0x17, 0x28, 0x23, 0x66, 0xa9, + 0x81, 0xd9, 0x17, 0xaf, 0x0a, 0x0c, 0x14, 0xaa, 0xf3, 0xe1, 0x8d, 0x15, 0x94, 0xc1, 0x47, 0xf3, 0xba, 0x94, 0x15, + 0x04, 0x8e, 0x49, 0xeb, 0xc0, 0xfd, 0xf2, 0x40, 0x8f, 0x14, 0x7d, 0xf1, 0x36, 0x0a, 0x58, 0x5e, 0xd7, 0x53, 0x83, + 0xb7, 0x1a, 0xae, 0x8d, 0xf5, 0x32, 0xe3, 0x97, 0xf5, 0x40, 0x61, 0x14, 0x5c, 0xdc, 0x99, 0x5d, 0x8c, 0xc3, 0xbe, + 0xdb, 0x2a, 0x67, 0x4a, 0xa6, 0x5c, 0xaf, 0x6c, 0x7e, 0xc6, 0x40, 0xcf, 0x9b, 0xb5, 0xac, 0x71, 0xfd, 0xc4, 0xef, + 0x6e, 0x8e, 0x2b, 0xe3, 0x6c, 0x14, 0xba, 0xff, 0x23, 0x1b, 0x6a, 0x7c, 0x03, 0x35, 0x82, 0x90, 0x83, 0xab, 0xa5, + 0xb2, 0x34, 0xd2, 0x7e, 0xb6, 0x9f, 0xbe, 0x4f, 0x1e, 0x2b, 0xc8, 0xf2, 0x5f, 0xb2, 0x62, 0x63, 0x0e, 0x93, 0xc9, + 0xaf, 0x3a, 0x85, 0x74, 0x40, 0xd5, 0xa2, 0x1d, 0xa3, 0x57, 0xd9, 0x09, 0x41, 0x7d, 0x31, 0x10, 0x75, 0x00, 0x66, + 0x5b, 0xa5, 0xbc, 0x2c, 0x06, 0x9a, 0x49, 0x94, 0x2d, 0x07, 0x7d, 0x6d, 0xf8, 0xf0, 0x1a, 0xbc, 0x6a, 0x94, 0xd5, + 0xf4, 0xb2, 0x9a, 0x42, 0xa5, 0xd3, 0xa6, 0x95, 0xe0, 0x35, 0x79, 0xba, 0x5f, 0xea, 0x5c, 0x77, 0x4d, 0x1c, 0xfc, + 0x6c, 0xf5, 0x7b, 0xb0, 0xa3, 0xc9, 0xb1, 0x2b, 0xb9, 0xb9, 0xc1, 0x71, 0x1e, 0x73, 0x5c, 0xb9, 0x40, 0x44, 0xcd, + 0x42, 0x2b, 0x18, 0xd0, 0x22, 0x75, 0xa7, 0xbe, 0xbb, 0xc4, 0x6e, 0x02, 0xd8, 0x2a, 0xf6, 0x1e, 0x24, 0xdb, 0x3e, + 0x4b, 0x6f, 0x74, 0x60, 0x3b, 0x78, 0x8b, 0x26, 0xbe, 0x31, 0x57, 0xaa, 0xa9, 0xc8, 0xea, 0x8c, 0xea, 0xb0, 0x73, + 0x9a, 0xcf, 0x0f, 0x9a, 0xb1, 0x72, 0x9b, 0x84, 0xdb, 0x31, 0x52, 0x27, 0x88, 0x05, 0x2a, 0x56, 0xd3, 0xa0, 0x5a, + 0x46, 0x50, 0xb9, 0x49, 0xfa, 0xca, 0x23, 0x59, 0x8d, 0x15, 0xeb, 0x67, 0xa0, 0x6e, 0xae, 0xdc, 0xb8, 0x6d, 0x86, + 0xac, 0x5a, 0xae, 0x70, 0x46, 0x20, 0x86, 0xc6, 0x67, 0xd6, 0x48, 0x54, 0x5b, 0x09, 0xe8, 0xc0, 0xe1, 0x22, 0x05, + 0xb5, 0xbb, 0x2d, 0xaf, 0xdf, 0x8d, 0xd2, 0x23, 0x4a, 0x54, 0xd4, 0x8a, 0xca, 0x29, 0xdd, 0x50, 0xae, 0x9e, 0x89, + 0x26, 0x60, 0xa2, 0x51, 0x6c, 0xa4, 0x16, 0xe5, 0xed, 0x56, 0x85, 0xec, 0xe5, 0xba, 0x7f, 0x79, 0xff, 0x91, 0xd3, + 0xb0, 0xe9, 0x3b, 0x21, 0x69, 0x30, 0x48, 0x45, 0xc2, 0x07, 0xec, 0xa8, 0xb7, 0xe4, 0x9b, 0xcc, 0x90, 0xa9, 0x23, + 0x63, 0xd4, 0x97, 0x58, 0xf9, 0xd2, 0xfc, 0xdd, 0xab, 0x7b, 0xa3, 0x80, 0xad, 0xdf, 0xe9, 0xda, 0xdc, 0x94, 0xc2, + 0xdb, 0x0e, 0x61, 0x0a, 0xe9, 0x26, 0x23, 0xd2, 0xfa, 0xcf, 0x54, 0xfd, 0x66, 0xe2, 0x77, 0x35, 0xb6, 0x6b, 0x82, + 0x3c, 0xd1, 0x9b, 0xcd, 0xe6, 0x9c, 0xaa, 0x59, 0x00, 0x20, 0xfe, 0xab, 0xcd, 0x37, 0xf3, 0x95, 0x2a, 0x1a, 0x88, + 0xe0, 0xb3, 0xd0, 0xf5, 0x6f, 0x64, 0x54, 0x7d, 0x1a, 0xd1, 0xbf, 0x06, 0x49, 0x08, 0x65, 0xce, 0xe6, 0x7a, 0x43, + 0x50, 0xc7, 0x9e, 0x67, 0x6f, 0xf5, 0x29, 0x4c, 0xfc, 0x8f, 0xbc, 0xfa, 0x39, 0xee, 0x55, 0x14, 0xa5, 0xd8, 0xd5, + 0xa1, 0x71, 0x98, 0xc2, 0x4d, 0xa6, 0x5b, 0xef, 0x92, 0x21, 0xe0, 0xf4, 0x5f, 0x1c, 0x0e, 0x23, 0x73, 0xd3, 0x9d, + 0x0d, 0x0c, 0x06, 0x05, 0x23, 0x29, 0x96, 0x21, 0x94, 0xb9, 0xc1, 0x5c, 0xbc, 0x75, 0x80, 0x2f, 0x5d, 0x90, 0xe5, + 0x9b, 0x85, 0x8e, 0xf1, 0xd9, 0xb7, 0xe7, 0x1d, 0x1f, 0xa9, 0xd0, 0x32, 0x4b, 0x04, 0x29, 0xa4, 0x2f, 0xfe, 0x19, + 0x46, 0x2d, 0x8f, 0x89, 0x0b, 0xa6, 0xd5, 0xc3, 0x4b, 0x29, 0xc0, 0xce, 0x73, 0x50, 0x53, 0x2f, 0xa0, 0x8e, 0x85, + 0x9b, 0xca, 0x03, 0xbb, 0x12, 0x43, 0x6a, 0x53, 0x04, 0x30, 0x7e, 0xeb, 0x08, 0x11, 0x0f, 0xd2, 0xa0, 0x54, 0x4b, + 0xc8, 0x78, 0xb3, 0x9c, 0x58, 0x77, 0x17, 0x03, 0xe2, 0x9b, 0x23, 0x06, 0xb4, 0xa5, 0x66, 0x18, 0x1e, 0xe7, 0x5f, + 0x4b, 0x79, 0x13, 0x32, 0x88, 0x5d, 0x03, 0x5d, 0x49, 0xb9, 0x59, 0xfb, 0xe1, 0x18, 0xa8, 0xda, 0x86, 0x44, 0xe9, + 0x37, 0xd5, 0x95, 0x75, 0x25, 0x56, 0xa8, 0x56, 0x3b, 0xbb, 0x37, 0x79, 0x9d, 0x36, 0x34, 0xc3, 0x53, 0xb8, 0xb9, + 0x52, 0xdb, 0xc6, 0xae, 0xed, 0xff, 0x24, 0x73, 0xd0, 0x14, 0xac, 0x95, 0x1f, 0xec, 0x78, 0x36, 0xd1, 0xbf, 0x9e, + 0xd5, 0x99, 0x74, 0xfd, 0x51, 0x79, 0x96, 0x9f, 0x5b, 0x75, 0x50, 0x81, 0x87, 0xd3, 0x22, 0xff, 0xd1, 0xd7, 0x70, + 0x0d, 0xbd, 0x27, 0xef, 0x7a, 0xbb, 0xc1, 0x18, 0xbe, 0x78, 0x13, 0x4f, 0xfb, 0x9b, 0x4c, 0xe0, 0x14, 0xc2, 0xb6, + 0x75, 0x02, 0xd6, 0x3a, 0x7d, 0x47, 0x52, 0xd0, 0x22, 0xbf, 0x45, 0xb3, 0x5f, 0x2b, 0x73, 0xc3, 0x2f, 0x1c, 0xc5, + 0xcd, 0xa5, 0x74, 0x91, 0x3c, 0x59, 0xa5, 0xed, 0x30, 0xcb, 0x20, 0x8e, 0xc0, 0x72, 0xf4, 0x73, 0x27, 0x72, 0xeb, + 0x63, 0x35, 0xcc, 0xee, 0x38, 0x0e, 0xc5, 0xa8, 0x7e, 0xaa, 0x23, 0x52, 0x1e, 0x26, 0x03, 0x36, 0x35, 0xa1, 0xc5, + 0x58, 0x58, 0xba, 0x24, 0x41, 0x0a, 0x74, 0x80, 0x5a, 0x22, 0x73, 0x52, 0x8b, 0xec, 0x8a, 0x71, 0xcf, 0xb6, 0x62, + 0xe9, 0xda, 0xc7, 0x47, 0x9d, 0x3d, 0x03, 0x37, 0x8e, 0x93, 0x93, 0xcd, 0x9d, 0x2d, 0xc0, 0x4a, 0x8f, 0xc9, 0xe9, + 0xec, 0x87, 0x12, 0xcb, 0x35, 0xd9, 0x7d, 0x54, 0xb4, 0xbb, 0xef, 0xe0, 0x88, 0x2c, 0x11, 0xa3, 0xff, 0xb4, 0xce, + 0x64, 0xad, 0xbf, 0x91, 0x03, 0xf8, 0x16, 0x1a, 0xf5, 0x82, 0xc5, 0x80, 0xcb, 0xdd, 0xe5, 0x5d, 0x8d, 0x0f, 0xbc, + 0x32, 0xe1, 0xac, 0x2a, 0xd7, 0xdc, 0x6c, 0x64, 0x9a, 0xa8, 0x09, 0xe9, 0xff, 0x2b, 0x5b, 0x0d, 0xb1, 0x05, 0x78, + 0x32, 0xf6, 0xcd, 0x9b, 0x0d, 0x4c, 0xcd, 0x42, 0x8b, 0x2b, 0xec, 0x43, 0x1c, 0xa7, 0x22, 0xba, 0xb9, 0x81, 0x1a, + 0x7e, 0x90, 0xd0, 0xca, 0x77, 0x09, 0x55, 0xff, 0x41, 0x34, 0xf6, 0xbd, 0x57, 0x59, 0xc2, 0x41, 0xcf, 0x41, 0xa6, + 0xd1, 0xbd, 0x66, 0xd2, 0x93, 0xbd, 0xb9, 0x31, 0x54, 0x8d, 0xbc, 0x56, 0xee, 0x1e, 0xdc, 0x2d, 0xe1, 0xf9, 0xd9, + 0x9c, 0xf7, 0xe6, 0x23, 0xe1, 0x51, 0x37, 0x5e, 0xf5, 0x0f, 0x71, 0x87, 0xaf, 0xae, 0x1f, 0x27, 0x62, 0x45, 0x11, + 0x17, 0x1f, 0xd6, 0xbb, 0x5a, 0x79, 0xdc, 0x3a, 0x3c, 0xc5, 0xfb, 0x06, 0x74, 0x4a, 0x4a, 0x75, 0xde, 0x35, 0x81, + 0xae, 0xe0, 0xfb, 0x73, 0xed, 0xf2, 0xfd, 0x8d, 0xb3, 0x6e, 0xcb, 0xcd, 0xc6, 0xc1, 0x1b, 0x93, 0x2e, 0x5a, 0xb0, + 0xeb, 0x3b, 0x9e, 0xbe, 0xf9, 0x38, 0xfc, 0x68, 0x64, 0x58, 0xd5, 0x58, 0x40, 0x1b, 0x5a, 0xbe, 0x20, 0xef, 0xc9, + 0x22, 0x46, 0x77, 0xa5, 0xc9, 0x53, 0x72, 0xbb, 0xf9, 0x3e, 0x44, 0xbc, 0x59, 0x07, 0xba, 0x72, 0xd0, 0xdd, 0xf8, + 0xd7, 0xfa, 0xe5, 0x65, 0xe9, 0xde, 0xbc, 0x7a, 0xee, 0xb5, 0x90, 0x30, 0xa9, 0xf3, 0xc9, 0x20, 0x97, 0x0f, 0x86, + 0xc8, 0xc8, 0xe6, 0x18, 0xcf, 0x24, 0x65, 0x09, 0xbc, 0x1c, 0x57, 0x19, 0xbc, 0x33, 0x6d, 0xe4, 0x1f, 0xf7, 0x44, + 0x22, 0x1e, 0x0c, 0xb4, 0x6d, 0x50, 0x28, 0x4c, 0xea, 0xed, 0x62, 0x88, 0x7b, 0x94, 0x31, 0xd1, 0x3c, 0x76, 0x7d, + 0xbf, 0x46, 0x27, 0x47, 0x6f, 0x66, 0xd4, 0x6e, 0xff, 0x61, 0x35, 0x05, 0x7a, 0xe2, 0xe0, 0x89, 0xba, 0xa2, 0x12, + 0x1e, 0xff, 0xf4, 0x89, 0xf6, 0x4b, 0x7a, 0x38, 0x55, 0x87, 0xe7, 0xab, 0xf8, 0xca, 0x45, 0x55, 0x2b, 0x7e, 0x09, + 0xfa, 0x70, 0xb1, 0xc8, 0xc9, 0xf3, 0x48, 0xaf, 0x6c, 0xf6, 0x6a, 0x66, 0x13, 0xc5, 0x9d, 0xc2, 0xf2, 0xb8, 0xf9, + 0x8a, 0xe6, 0xd4, 0x90, 0x68, 0xf5, 0xef, 0x43, 0x7f, 0x0c, 0xf6, 0x36, 0xfb, 0xbf, 0x25, 0x71, 0xe6, 0xe9, 0x33, + 0xe2, 0x77, 0xb3, 0xf5, 0x92, 0x1f, 0xba, 0xbf, 0xc4, 0xbf, 0x8f, 0x4d, 0xa0, 0x59, 0xa6, 0x34, 0x51, 0xc6, 0x30, + 0x00, 0x38, 0x00, 0x7e, 0x6d, 0xfe, 0xe2, 0xdf, 0x2d, 0x9b, 0xdc, 0xcc, 0xe2, 0xa4, 0xc5, 0x9d, 0x7f, 0xfa, 0x42, + 0x69, 0x69, 0x9c, 0xe6, 0x01, 0x41, 0x35, 0xae, 0x4d, 0x8f, 0x8d, 0x64, 0x1e, 0xc8, 0x3a, 0x18, 0xb6, 0x96, 0x9c, + 0x60, 0x02, 0x22, 0xf7, 0xaa, 0xe6, 0x4b, 0x97, 0x6a, 0x65, 0x96, 0xa9, 0xcd, 0xd7, 0xd2, 0xc1, 0x60, 0xdf, 0x41, + 0xcc, 0xf7, 0xb9, 0xc7, 0x6c, 0x26, 0x3f, 0xb7, 0xb4, 0xe0, 0x6f, 0xa5, 0x3c, 0x19, 0x73, 0xf3, 0x46, 0x28, 0x2e, + 0x3e, 0x0a, 0xcc, 0x70, 0x46, 0xb0, 0x50, 0xab, 0xaf, 0xbc, 0x89, 0x0d, 0xff, 0x50, 0x12, 0x78, 0xb1, 0x7b, 0xb9, + 0xf2, 0x0a, 0xbc, 0x09, 0xed, 0x1f, 0x28, 0xff, 0xef, 0xa9, 0x96, 0xbd, 0xbc, 0x57, 0xa7, 0xb6, 0xe3, 0x5a, 0x50, + 0x91, 0x54, 0x05, 0x6f, 0xd7, 0xbf, 0x65, 0xa2, 0x81, 0xe5, 0xc9, 0x52, 0xf6, 0xb5, 0x33, 0xf0, 0xb1, 0x81, 0x2e, + 0xf5, 0x95, 0x54, 0xbd, 0x10, 0x67, 0x2c, 0x24, 0xcd, 0x0c, 0x80, 0xe8, 0x75, 0x9f, 0x9e, 0x54, 0xd3, 0xb0, 0x57, + 0x67, 0x2b, 0x7a, 0xd6, 0x88, 0x91, 0xde, 0xa5, 0xd2, 0x98, 0x3d, 0x3d, 0x52, 0xa6, 0xcf, 0x3b, 0x3f, 0x2a, 0x6f, + 0x48, 0x66, 0x1b, 0x12, 0xfc, 0x29, 0x2f, 0x50, 0x52, 0x66, 0xdb, 0x8a, 0x4d, 0xf1, 0x66, 0xee, 0x02, 0x98, 0xac, + 0x27, 0x98, 0xbb, 0x6f, 0x5e, 0x72, 0x30, 0xc6, 0xba, 0x52, 0x45, 0xb9, 0xf1, 0x79, 0x9c, 0x75, 0xb9, 0x43, 0xd8, + 0x44, 0x16, 0x3d, 0x07, 0x81, 0xcd, 0xea, 0x5a, 0x1e, 0xcc, 0xc7, 0x9c, 0x64, 0x97, 0x35, 0xfa, 0x85, 0x49, 0x90, + 0x6e, 0xde, 0xf0, 0x5c, 0xb3, 0x42, 0xde, 0xbc, 0x2f, 0xb9, 0x11, 0xcc, 0x60, 0xb4, 0x11, 0x29, 0xb4, 0x75, 0xca, + 0xb0, 0x8f, 0x88, 0x5e, 0x49, 0x98, 0xfe, 0x41, 0x9e, 0xaf, 0x7e, 0x10, 0xa6, 0xe7, 0xeb, 0x05, 0xaa, 0xfa, 0x87, + 0x02, 0x5e, 0x4c, 0x38, 0xc0, 0x02, 0xea, 0xe8, 0xa5, 0x5c, 0xc7, 0x9a, 0xa0, 0x9c, 0x70, 0xa9, 0xaf, 0xd9, 0x28, + 0xaf, 0xa5, 0xfa, 0x84, 0xd6, 0xb1, 0x66, 0x03, 0x4c, 0x46, 0x37, 0xb6, 0xf1, 0xb7, 0x31, 0xb7, 0xe9, 0xb2, 0x7f, + 0xaa, 0xd8, 0x1e, 0x82, 0xb2, 0xe1, 0x02, 0x3e, 0xf7, 0x08, 0xdc, 0xb9, 0x9e, 0x80, 0xd6, 0x10, 0xff, 0xe3, 0x38, + 0xd6, 0xf2, 0x65, 0x9d, 0x29, 0x89, 0x55, 0x16, 0x42, 0x85, 0xca, 0x89, 0xfd, 0xdc, 0x30, 0xd7, 0x7a, 0x1c, 0x5c, + 0x23, 0xc1, 0x40, 0x70, 0x0a, 0x30, 0x89, 0xab, 0x29, 0x0d, 0x8d, 0x3b, 0x47, 0x7f, 0x78, 0x2d, 0xbf, 0xf0, 0xaa, + 0x5c, 0x17, 0xdc, 0xf4, 0xbd, 0x19, 0x01, 0xf3, 0x0b, 0xfb, 0xc2, 0xd1, 0x45, 0xcb, 0xe8, 0xfa, 0xec, 0x80, 0x04, + 0xc8, 0x63, 0x65, 0x19, 0x49, 0xd8, 0x92, 0xb5, 0x7a, 0x93, 0x9f, 0xef, 0x99, 0x42, 0x24, 0x5b, 0xa0, 0xca, 0xf1, + 0x0b, 0x6c, 0x2d, 0x2d, 0xa9, 0x64, 0x25, 0x5a, 0xab, 0x50, 0x81, 0x68, 0xad, 0x09, 0xd5, 0xaa, 0xd3, 0x7b, 0xdf, + 0x22, 0x3a, 0x2f, 0x8d, 0xd4, 0x21, 0x86, 0x80, 0x88, 0xa5, 0xf5, 0x9d, 0xd2, 0x46, 0xeb, 0xc9, 0xb2, 0xb8, 0xaf, + 0xc6, 0xf6, 0x6b, 0xb8, 0x7a, 0x26, 0xde, 0x54, 0xde, 0xd6, 0xc5, 0xc3, 0x9c, 0x55, 0x4e, 0x74, 0x5d, 0x87, 0x69, + 0xb3, 0xb6, 0xd3, 0x5f, 0xd5, 0x55, 0x26, 0x43, 0xf0, 0xb1, 0x87, 0x50, 0x73, 0xa1, 0x4a, 0x85, 0x48, 0x2f, 0x77, + 0x62, 0x73, 0xe5, 0x1e, 0x73, 0xa5, 0x73, 0x1c, 0xd9, 0x3a, 0xb6, 0x93, 0xe1, 0xa9, 0xc9, 0x05, 0x71, 0xec, 0xee, + 0x7e, 0x88, 0x0b, 0xfe, 0xcf, 0x17, 0xd2, 0x9c, 0xc7, 0xe7, 0x2f, 0xfd, 0xf4, 0x93, 0xb1, 0x92, 0xd2, 0x38, 0x99, + 0x65, 0x4d, 0x2f, 0xcb, 0x20, 0xce, 0x7f, 0xc6, 0xcb, 0x9c, 0x85, 0xd7, 0x59, 0xfb, 0x57, 0xc3, 0xad, 0x38, 0xb4, + 0x2e, 0x45, 0x32, 0x45, 0xb9, 0xfb, 0xd7, 0x71, 0x12, 0x22, 0xc3, 0x9f, 0xf3, 0x86, 0xb1, 0xf6, 0x69, 0xd5, 0x7c, + 0x24, 0x2b, 0x76, 0xf6, 0x7e, 0xe9, 0xb1, 0x71, 0x51, 0x70, 0x27, 0xc8, 0x95, 0x56, 0x4a, 0x0e, 0x8e, 0x03, 0x4d, + 0xe5, 0x03, 0x05, 0x7f, 0x98, 0x92, 0xc6, 0x53, 0xcc, 0x56, 0xdf, 0xa7, 0x36, 0xcb, 0x98, 0x0c, 0x8f, 0x74, 0x66, + 0xcc, 0x46, 0xad, 0xa0, 0xb4, 0xc7, 0xf9, 0xb0, 0xb0, 0xce, 0x69, 0x9b, 0x71, 0x4c, 0xf2, 0xc7, 0xb7, 0x0a, 0xd9, + 0xaa, 0x7c, 0xa9, 0xf7, 0x7b, 0x69, 0x6f, 0x93, 0x17, 0x2b, 0x7a, 0x2b, 0x4c, 0x84, 0x81, 0x88, 0x4a, 0x15, 0x34, + 0x12, 0xb2, 0xb0, 0xd3, 0x4e, 0xed, 0x0c, 0x55, 0x69, 0x31, 0x00, 0x3f, 0x86, 0xf5, 0xf1, 0xf8, 0x5a, 0x34, 0xa6, + 0xd6, 0x51, 0x23, 0x36, 0x2e, 0xe7, 0x19, 0x00, 0x2f, 0x54, 0x3c, 0xb3, 0x62, 0xfa, 0x8c, 0x9c, 0x39, 0x82, 0x2a, + 0x0b, 0x41, 0xda, 0x61, 0x28, 0xb6, 0xdc, 0x98, 0xaa, 0x0d, 0xe4, 0xc2, 0x9f, 0x75, 0x52, 0xa5, 0x11, 0xca, 0x21, + 0xd7, 0x26, 0xef, 0x32, 0xdf, 0x20, 0x44, 0x1f, 0xda, 0xf8, 0xeb, 0xc9, 0x8d, 0x04, 0x64, 0x0a, 0x38, 0x8f, 0x34, + 0x5e, 0xd3, 0xf7, 0x3c, 0x03, 0xde, 0x54, 0x6f, 0x92, 0x04, 0xe4, 0x59, 0x75, 0xa2, 0xdb, 0xf0, 0x90, 0x3c, 0xfb, + 0xad, 0x1c, 0x95, 0x7b, 0x72, 0xa5, 0x65, 0xdf, 0xea, 0x36, 0x63, 0xbe, 0x64, 0xed, 0xd2, 0xda, 0xdb, 0x09, 0xb3, + 0x4e, 0x53, 0x65, 0x4a, 0xc4, 0x83, 0x4a, 0xd2, 0xda, 0x19, 0x40, 0x98, 0xfa, 0xe9, 0x5b, 0xd4, 0x8e, 0x37, 0x92, + 0x73, 0x93, 0x01, 0x0b, 0xaa, 0xac, 0x5c, 0x76, 0x81, 0x44, 0x40, 0x6e, 0xdb, 0xf8, 0xa6, 0xc9, 0x12, 0x8c, 0xc8, + 0x3f, 0xa0, 0x77, 0xc1, 0x1d, 0xd9, 0x5b, 0xa0, 0x3b, 0xd3, 0xc7, 0x9e, 0x1a, 0xef, 0xca, 0x9a, 0xec, 0x42, 0x66, + 0xbe, 0x89, 0x81, 0x6b, 0x57, 0x2d, 0x21, 0xe1, 0xba, 0xb1, 0xcb, 0xbc, 0xa8, 0x33, 0x99, 0xad, 0x59, 0x95, 0xc7, + 0x6a, 0x98, 0x4a, 0x87, 0xa9, 0x9a, 0xb0, 0x25, 0xc8, 0x05, 0x84, 0xcb, 0x6b, 0x97, 0xeb, 0xf8, 0x2a, 0x01, 0x22, + 0x3d, 0x88, 0x93, 0x62, 0xec, 0xb9, 0x91, 0x77, 0xd7, 0xcb, 0x0a, 0x14, 0xc6, 0x3b, 0x6b, 0x92, 0x93, 0x4b, 0xed, + 0x4f, 0xc6, 0xdb, 0x56, 0x33, 0xdd, 0x8e, 0x2f, 0x12, 0xba, 0x16, 0xc7, 0x16, 0x7c, 0x49, 0xed, 0xde, 0xd5, 0x22, + 0x57, 0xed, 0x65, 0x01, 0xa3, 0x6d, 0x74, 0xd6, 0x6d, 0xb1, 0x30, 0xa7, 0x44, 0x38, 0x59, 0x36, 0xe6, 0x3b, 0x11, + 0x5e, 0x24, 0xd6, 0x18, 0xa8, 0x9d, 0x79, 0xe3, 0x4f, 0x0c, 0xc1, 0x09, 0xbe, 0x10, 0x5c, 0x2c, 0x8d, 0xf9, 0xf4, + 0x05, 0x11, 0xb1, 0x59, 0x1c, 0x9e, 0xad, 0x9b, 0xe0, 0x74, 0x8d, 0xeb, 0x0d, 0xb8, 0x1b, 0x58, 0xd4, 0xdf, 0xd1, + 0x83, 0x79, 0xfb, 0xa3, 0xb0, 0x69, 0x20, 0xc3, 0xe8, 0xd1, 0x23, 0x41, 0xdc, 0xd9, 0x1c, 0x4b, 0x4a, 0x24, 0x1c, + 0xf1, 0xeb, 0xe7, 0x08, 0x16, 0xb5, 0x2b, 0xa3, 0xa3, 0x31, 0x97, 0xfa, 0x07, 0xb9, 0xb4, 0xed, 0x2b, 0x60, 0xf1, + 0xcf, 0x50, 0x92, 0x94, 0x9d, 0x31, 0xc8, 0x6b, 0xdb, 0x80, 0xa9, 0x0a, 0xa8, 0xe3, 0x10, 0x7e, 0x52, 0x12, 0xee, + 0x66, 0x6b, 0x4a, 0xe5, 0xd2, 0x8c, 0x62, 0xcf, 0x1b, 0x44, 0xd1, 0xc5, 0x16, 0xe1, 0x24, 0x03, 0x27, 0xfa, 0x6a, + 0xa3, 0x20, 0x6f, 0xb5, 0xbd, 0xf8, 0x3c, 0x03, 0x67, 0x1d, 0x3a, 0x05, 0x34, 0x19, 0x25, 0x0d, 0xa1, 0x42, 0x1b, + 0xc2, 0xac, 0x0d, 0x2e, 0x5b, 0x11, 0x9a, 0x86, 0xcc, 0xb0, 0x0f, 0xf3, 0x79, 0xe0, 0x8c, 0x22, 0x41, 0x4f, 0xbb, + 0xd4, 0x6f, 0x56, 0xbf, 0xb9, 0x30, 0xdf, 0xdd, 0x48, 0x27, 0x02, 0x10, 0xad, 0xf4, 0xe9, 0xa1, 0x78, 0x91, 0x5b, + 0x10, 0x51, 0x6b, 0x0e, 0x6f, 0x09, 0x0e, 0x3e, 0x26, 0x2c, 0xb5, 0xea, 0xae, 0xb6, 0xf8, 0x17, 0x09, 0xdf, 0xb5, + 0x79, 0x40, 0xcc, 0x46, 0x6f, 0xe8, 0xfa, 0x5e, 0x9a, 0xa7, 0x92, 0xea, 0x89, 0x2d, 0x06, 0x2e, 0x0b, 0x05, 0x55, + 0xfc, 0x66, 0x7c, 0x8d, 0x91, 0x15, 0x01, 0x34, 0x38, 0xbd, 0xc5, 0x08, 0x1c, 0x32, 0xe6, 0xe5, 0xd8, 0x1f, 0xd7, + 0x6c, 0x82, 0x7c, 0xd6, 0x98, 0x90, 0x88, 0xb7, 0xbd, 0x37, 0xd8, 0x2a, 0x94, 0x8d, 0x44, 0x5a, 0x1e, 0x39, 0x8c, + 0x7b, 0x50, 0xf1, 0x30, 0x22, 0x36, 0xac, 0x29, 0xf3, 0x09, 0xa1, 0xcd, 0x1e, 0xc4, 0x9c, 0x5d, 0x98, 0xb0, 0xd0, + 0x4b, 0x0c, 0x44, 0xe8, 0x6d, 0x00, 0xfb, 0x46, 0x6c, 0x91, 0x48, 0x21, 0x89, 0x44, 0x3e, 0x9a, 0x13, 0xe2, 0xb0, + 0x15, 0x19, 0x1e, 0xac, 0xf6, 0x2e, 0x46, 0xf2, 0x67, 0x9c, 0x94, 0xd6, 0x65, 0x62, 0xf3, 0xc7, 0x28, 0x61, 0x0c, + 0x38, 0xbb, 0x3b, 0x29, 0xce, 0xbb, 0x61, 0xf9, 0xe8, 0x03, 0x15, 0x7c, 0xcb, 0x15, 0xc1, 0x1e, 0x4d, 0xe4, 0x48, + 0x95, 0x15, 0xcb, 0xb9, 0x7e, 0x14, 0x1a, 0x3c, 0x65, 0xe1, 0xa8, 0x6a, 0xc3, 0x48, 0x10, 0x51, 0x69, 0x5c, 0x30, + 0x5a, 0xc9, 0x40, 0x47, 0x63, 0xda, 0x6a, 0x44, 0xb8, 0x80, 0xe7, 0x59, 0xfb, 0xa7, 0x05, 0xe3, 0x3c, 0x5e, 0x86, + 0xe3, 0x0f, 0x9a, 0x41, 0xff, 0x1d, 0x99, 0x8c, 0x96, 0x4f, 0xee, 0x46, 0xff, 0x49, 0x3f, 0x68, 0x67, 0xef, 0xf7, + 0xd5, 0xe9, 0xc7, 0xbe, 0x5c, 0x48, 0x43, 0x7e, 0xa1, 0x2b, 0x57, 0x73, 0xbb, 0x35, 0x3c, 0x30, 0x35, 0xb7, 0xd3, + 0xeb, 0x04, 0xf5, 0xce, 0xb9, 0x41, 0xdb, 0x86, 0x0d, 0x4c, 0xe2, 0x31, 0xe7, 0xc9, 0x68, 0xac, 0xc8, 0x80, 0x5a, + 0xc1, 0xca, 0x3c, 0x4b, 0x70, 0xd7, 0x67, 0xc6, 0xe0, 0x9e, 0xb8, 0x28, 0xb3, 0xe4, 0xde, 0x07, 0xe0, 0x24, 0x68, + 0xfe, 0x92, 0xdd, 0xa2, 0x7e, 0xa2, 0x5a, 0x74, 0x07, 0x29, 0x43, 0xad, 0x25, 0xde, 0x57, 0xb5, 0xc6, 0x10, 0xec, + 0x0d, 0x00, 0xad, 0xa9, 0xd5, 0x87, 0x89, 0x1c, 0xf2, 0xc7, 0x56, 0xf5, 0x41, 0x69, 0xa2, 0x2e, 0x18, 0x90, 0xa7, + 0xe6, 0x97, 0x2e, 0x11, 0x26, 0x9d, 0xd4, 0xff, 0xab, 0x97, 0xff, 0x6d, 0x0c, 0x94, 0x89, 0xca, 0xdb, 0x90, 0x87, + 0x93, 0xc7, 0xbd, 0x29, 0xde, 0xd2, 0xf9, 0x46, 0x1b, 0xee, 0x04, 0x4f, 0xf2, 0xf0, 0xfa, 0xbc, 0xb5, 0x37, 0x43, + 0xdc, 0xd7, 0xd1, 0xa6, 0xb2, 0x6d, 0x52, 0x52, 0x52, 0x1d, 0x9c, 0x81, 0x25, 0xda, 0x05, 0x4d, 0xcb, 0x79, 0xa4, + 0x1c, 0xcb, 0x36, 0xa9, 0x72, 0x0b, 0x78, 0xca, 0x29, 0xe5, 0x3f, 0x04, 0x1d, 0xa5, 0x9a, 0x47, 0xcd, 0x65, 0x79, + 0xea, 0x52, 0x58, 0x5b, 0x21, 0xba, 0x37, 0xa7, 0xfc, 0x62, 0x96, 0xb4, 0x94, 0x6a, 0x93, 0x00, 0x91, 0xc6, 0x7b, + 0x9a, 0x58, 0xd6, 0x03, 0xe8, 0x44, 0xd5, 0x2e, 0x61, 0x12, 0x43, 0x3b, 0xd9, 0x86, 0xba, 0xfa, 0x68, 0x15, 0xd6, + 0xe7, 0x2f, 0x68, 0x78, 0xb5, 0xdf, 0xd2, 0x23, 0x46, 0xcd, 0x1a, 0xde, 0x1f, 0x1e, 0x4a, 0x70, 0xb1, 0x69, 0xec, + 0x6c, 0xb3, 0x26, 0x0e, 0x3b, 0x7e, 0x0e, 0x2b, 0x08, 0xa6, 0x67, 0x47, 0x1b, 0xc6, 0x6a, 0x70, 0x7c, 0x95, 0x5f, + 0xed, 0x7a, 0x31, 0xa0, 0x26, 0x52, 0xdc, 0x29, 0x72, 0xc0, 0x00, 0x13, 0x2d, 0xe4, 0xcd, 0xd3, 0x79, 0xfc, 0x21, + 0xbe, 0x1e, 0x0f, 0xb4, 0x9f, 0x20, 0x8f, 0x9e, 0x05, 0x8a, 0x0c, 0x50, 0xd1, 0x93, 0xfb, 0x8b, 0x53, 0x28, 0xc3, + 0x6e, 0xa2, 0xd3, 0x41, 0xd1, 0xed, 0xdd, 0x23, 0x6f, 0x7c, 0xbc, 0xa9, 0xca, 0xe5, 0x3c, 0xc2, 0x40, 0xd7, 0x1b, + 0xd8, 0x40, 0x11, 0x19, 0xcb, 0x2a, 0xc5, 0x8f, 0x31, 0xaa, 0x0c, 0x51, 0x70, 0xab, 0x4f, 0x58, 0xc3, 0x45, 0x60, + 0xef, 0x10, 0x26, 0x09, 0xa3, 0x47, 0xee, 0xb9, 0xa9, 0x79, 0x72, 0xcd, 0xec, 0x3c, 0xca, 0x1c, 0xac, 0x2a, 0x0e, + 0x4c, 0x98, 0xb2, 0x41, 0x31, 0x79, 0x2c, 0x97, 0x72, 0xab, 0x55, 0x37, 0x73, 0xa2, 0x98, 0x1e, 0xd9, 0xc3, 0xd0, + 0xc2, 0x4d, 0xba, 0x21, 0x46, 0x7f, 0xe1, 0x85, 0x7e, 0xb4, 0x1a, 0x04, 0x43, 0xb4, 0xc2, 0xce, 0xda, 0x28, 0x67, + 0x8c, 0xa2, 0xf8, 0xfb, 0x02, 0x10, 0x6c, 0xeb, 0xfa, 0x96, 0xae, 0x3e, 0x79, 0x6b, 0x77, 0xab, 0x4a, 0xcf, 0x83, + 0x12, 0x23, 0x7e, 0xcd, 0x2a, 0xe7, 0x9d, 0xea, 0x40, 0xe2, 0x87, 0x50, 0x69, 0x01, 0x57, 0x84, 0xb0, 0x4a, 0xe3, + 0x60, 0x02, 0x9c, 0xce, 0x45, 0x53, 0xdf, 0x45, 0x03, 0x48, 0x28, 0x93, 0xf8, 0xe4, 0x3c, 0x9b, 0x84, 0x5a, 0x1e, + 0x1d, 0xd2, 0x7b, 0xb7, 0x0e, 0x42, 0xe1, 0x3b, 0x53, 0xad, 0x17, 0xdc, 0x3d, 0xa5, 0xfd, 0x7a, 0xed, 0x0b, 0x2b, + 0x95, 0xc6, 0xfd, 0x77, 0xd3, 0xc7, 0xb7, 0xdf, 0xf1, 0xe2, 0xa8, 0xef, 0x26, 0xce, 0x86, 0xe5, 0x5b, 0x1e, 0x80, + 0x37, 0x0b, 0x0e, 0x08, 0xf0, 0x11, 0xf5, 0x54, 0xa7, 0xfd, 0x1e, 0xba, 0xf1, 0x75, 0x66, 0xf6, 0x2c, 0xe9, 0xfc, + 0x9d, 0x1f, 0x7c, 0xd8, 0xb6, 0x20, 0xd0, 0x05, 0xe3, 0xff, 0xa3, 0xa5, 0x02, 0x02, 0x50, 0xf0, 0xf7, 0xe1, 0x75, + 0x38, 0x45, 0xc1, 0x73, 0x18, 0xf5, 0x71, 0x44, 0x99, 0xee, 0x9d, 0x34, 0xf9, 0x5e, 0x45, 0x36, 0xcb, 0xbc, 0x42, + 0x36, 0x61, 0x6c, 0x7a, 0x59, 0xa7, 0x7c, 0x6d, 0x66, 0x60, 0xac, 0xbe, 0x04, 0xa8, 0x8c, 0x44, 0x6f, 0x4a, 0xbf, + 0x84, 0x5f, 0x5f, 0x8a, 0xc5, 0x90, 0x07, 0xdf, 0x69, 0xf5, 0xda, 0xad, 0x8f, 0x8d, 0xdf, 0xae, 0xdc, 0x83, 0xa1, + 0x0f, 0x42, 0xee, 0xe7, 0x0d, 0x59, 0x19, 0x47, 0x9b, 0xe7, 0x05, 0x97, 0xc6, 0xcb, 0x28, 0x97, 0x86, 0x8e, 0x24, + 0x6a, 0x03, 0x7d, 0x5a, 0x5a, 0x72, 0xc0, 0x65, 0x48, 0x8c, 0xfd, 0x20, 0x2b, 0x3d, 0x3e, 0x92, 0xf6, 0xc1, 0xe4, + 0x18, 0x3e, 0x9f, 0x6e, 0x71, 0x11, 0xef, 0x44, 0x60, 0xc7, 0x40, 0x95, 0x1b, 0xae, 0xda, 0xdb, 0xbd, 0xbd, 0xfd, + 0xc3, 0xf6, 0xe1, 0x66, 0xfd, 0x75, 0x85, 0x0e, 0xa9, 0xc6, 0x38, 0x9d, 0x5a, 0xab, 0xb5, 0x9c, 0xb4, 0x85, 0xbf, + 0xb7, 0x2c, 0xda, 0x24, 0xa4, 0x48, 0x0c, 0x98, 0x5b, 0x46, 0x26, 0x55, 0x2b, 0x0f, 0x30, 0x91, 0x9a, 0xba, 0x4d, + 0x4f, 0xf7, 0x99, 0x92, 0xa5, 0x06, 0xbd, 0xd8, 0xe9, 0xaa, 0x10, 0xeb, 0xa5, 0xeb, 0xc7, 0x8b, 0xa5, 0xd7, 0xba, + 0x2e, 0xb0, 0x89, 0x6c, 0x18, 0x48, 0x1d, 0x7f, 0xc7, 0x46, 0xee, 0xd7, 0xc3, 0x93, 0x25, 0x80, 0xc2, 0x25, 0xd2, + 0x75, 0x09, 0x72, 0xb4, 0x29, 0x49, 0x48, 0x2e, 0x5e, 0xa1, 0x8a, 0xf1, 0xa4, 0x66, 0x7b, 0xf3, 0x6c, 0x21, 0x12, + 0x19, 0x4a, 0x19, 0x1b, 0xbb, 0x9b, 0x74, 0xef, 0x02, 0x1c, 0xd4, 0xa2, 0x2e, 0xd7, 0x17, 0x55, 0x80, 0xed, 0x9c, + 0xbf, 0x1a, 0x8d, 0xf3, 0xa8, 0x89, 0x6e, 0xd7, 0xb0, 0x2f, 0xbb, 0xe6, 0x4c, 0x6e, 0x2e, 0x9d, 0xe6, 0xf9, 0x91, + 0xcf, 0x16, 0xab, 0x67, 0x18, 0x5c, 0xee, 0x3a, 0x01, 0x03, 0x54, 0xee, 0x95, 0x01, 0x7c, 0xcb, 0x02, 0xeb, 0x06, + 0x73, 0x49, 0x64, 0x93, 0x44, 0x5b, 0xbb, 0xa7, 0x9c, 0x84, 0x26, 0xb7, 0xee, 0x59, 0xe2, 0xca, 0x0f, 0x82, 0xaa, + 0x6c, 0xf3, 0xb4, 0x5e, 0x34, 0xf7, 0x68, 0xe9, 0x7f, 0x7a, 0x58, 0x04, 0x45, 0x81, 0xe6, 0xe1, 0x2d, 0x52, 0x73, + 0x98, 0x05, 0x51, 0x63, 0x27, 0xbc, 0xa1, 0x7d, 0x60, 0xad, 0x6d, 0xd4, 0x8e, 0x54, 0xef, 0x6f, 0x90, 0x12, 0xd6, + 0xec, 0x92, 0x14, 0x2c, 0x2b, 0xe2, 0x72, 0xd0, 0x8e, 0x08, 0xf0, 0x58, 0xd9, 0x0a, 0x1e, 0xe5, 0xc5, 0xdd, 0x6c, + 0xec, 0x0b, 0x64, 0xac, 0xc9, 0x1c, 0x74, 0x0d, 0xbf, 0x45, 0xa8, 0xd6, 0x56, 0xb7, 0x83, 0xb5, 0x7b, 0xc3, 0x34, + 0xd1, 0x3a, 0x09, 0x76, 0x44, 0x49, 0xfb, 0x05, 0x07, 0x6e, 0xaa, 0xca, 0x8e, 0xdc, 0x5b, 0x89, 0x34, 0x68, 0x57, + 0xe8, 0xfc, 0x75, 0x37, 0x35, 0x02, 0xde, 0x4c, 0xa7, 0xe4, 0x28, 0xf1, 0x89, 0x94, 0x41, 0x41, 0x49, 0x72, 0xfe, + 0x9f, 0xf5, 0xb1, 0x03, 0x05, 0xf1, 0x8d, 0x9f, 0x7f, 0x17, 0x04, 0x38, 0xb0, 0xdb, 0x41, 0xd6, 0xbe, 0x1c, 0x4b, + 0x60, 0x51, 0x85, 0x39, 0xd7, 0x83, 0x5a, 0xff, 0x9e, 0x17, 0xe1, 0xf9, 0xaf, 0x17, 0x5b, 0xaa, 0x75, 0xdb, 0x5e, + 0xf7, 0x16, 0xc9, 0x35, 0x63, 0x3b, 0xec, 0xcb, 0xc1, 0x87, 0xd3, 0x4c, 0xb2, 0x05, 0x24, 0x0d, 0x99, 0xbe, 0x94, + 0x36, 0xe9, 0x86, 0x03, 0x72, 0x07, 0x64, 0x70, 0x10, 0x68, 0x32, 0x28, 0x6b, 0x78, 0xac, 0xe6, 0xe1, 0xbc, 0xbd, + 0x7a, 0xf2, 0xd7, 0x2a, 0x5f, 0xa2, 0x43, 0xea, 0x9d, 0xc5, 0x80, 0xff, 0x7e, 0x2b, 0x18, 0xc9, 0xf6, 0xcd, 0x7e, + 0x77, 0xd3, 0x94, 0xe2, 0x0a, 0xa6, 0xfd, 0x83, 0xff, 0x3f, 0xf4, 0x16, 0x5e, 0xef, 0x64, 0x68, 0xaa, 0xc3, 0x94, + 0x1b, 0xd6, 0x8b, 0x0b, 0xf9, 0xae, 0x4c, 0x8c, 0x11, 0x04, 0x46, 0x60, 0x56, 0x97, 0xe8, 0x1e, 0x86, 0x3b, 0xeb, + 0x51, 0xcd, 0x70, 0x72, 0x69, 0x33, 0x86, 0x55, 0x0b, 0x11, 0x01, 0x2e, 0x51, 0xa0, 0x44, 0x91, 0x20, 0x89, 0x01, + 0xa2, 0x7b, 0xeb, 0xf3, 0x08, 0x65, 0x51, 0xb3, 0xbe, 0xa1, 0xb6, 0xb3, 0xb2, 0x39, 0x09, 0x68, 0x6d, 0xe6, 0x98, + 0x56, 0xa3, 0x00, 0x9d, 0xbb, 0xd3, 0x00, 0x3a, 0xf4, 0x16, 0xe9, 0xa5, 0x8c, 0x15, 0xfb, 0xae, 0x67, 0x6d, 0xe9, + 0x90, 0x4f, 0xa2, 0xd6, 0xea, 0x20, 0xad, 0x55, 0x4e, 0x45, 0x66, 0x42, 0x5f, 0xe8, 0xd2, 0xc2, 0x19, 0xe8, 0x1b, + 0x6f, 0x0f, 0xd6, 0x78, 0x4a, 0x6f, 0xf2, 0xa5, 0x29, 0xe5, 0x65, 0x8f, 0x09, 0xf7, 0x3b, 0xa9, 0x8c, 0xed, 0xad, + 0x01, 0x91, 0x4b, 0xfa, 0xbb, 0x87, 0x84, 0x66, 0x1e, 0xbd, 0x0d, 0x38, 0xec, 0x82, 0x56, 0xfc, 0xaa, 0x7a, 0xbc, + 0x63, 0x82, 0x87, 0xa5, 0x34, 0xf9, 0xfe, 0xc5, 0x9b, 0x61, 0xd6, 0x30, 0x5e, 0x58, 0xec, 0x82, 0x80, 0x82, 0xd9, + 0x5b, 0xcc, 0xdd, 0xff, 0xe5, 0x8f, 0xd6, 0xc0, 0x8d, 0x99, 0x43, 0x6e, 0x3e, 0xe0, 0xf1, 0x3d, 0xbd, 0x4f, 0xbd, + 0x9b, 0xd5, 0xab, 0x4f, 0xa7, 0xc5, 0x85, 0x91, 0xf7, 0xed, 0x74, 0xb4, 0x47, 0x24, 0x5c, 0x03, 0x30, 0x01, 0x50, + 0x96, 0x78, 0x40, 0x09, 0x8b, 0xf7, 0xe5, 0xd2, 0x2a, 0x3b, 0x01, 0x4d, 0xb5, 0x67, 0x9b, 0x3a, 0x72, 0xe1, 0x19, + 0xdb, 0x51, 0x2c, 0x6d, 0xa7, 0x29, 0x61, 0xf2, 0x5a, 0xd7, 0xee, 0xf4, 0xf2, 0xa3, 0x34, 0x81, 0x9a, 0xa9, 0x5c, + 0x29, 0xbf, 0x46, 0xd6, 0x10, 0x7c, 0x0a, 0x8b, 0x28, 0x2a, 0xc0, 0xb3, 0xe8, 0x04, 0xaa, 0xd6, 0x0f, 0xed, 0x77, + 0x77, 0x58, 0x6c, 0x5d, 0x4c, 0x8f, 0x1f, 0x2a, 0x90, 0x79, 0xe6, 0xb8, 0x73, 0xa6, 0xd9, 0xd1, 0x4d, 0xe3, 0x5d, + 0x4c, 0xd9, 0x4f, 0x5f, 0xa0, 0x4f, 0x16, 0x66, 0x76, 0x2f, 0x68, 0x2c, 0x83, 0x27, 0x45, 0x36, 0x48, 0x91, 0xef, + 0xc2, 0x10, 0xc6, 0x48, 0xa5, 0x33, 0x35, 0x8f, 0xd1, 0xf4, 0xb7, 0xd0, 0x16, 0x4c, 0xed, 0xde, 0x53, 0x7d, 0xe8, + 0x7a, 0xa3, 0x54, 0x6b, 0xdf, 0x49, 0x99, 0x49, 0x2f, 0x61, 0xa4, 0x68, 0xb7, 0xd7, 0xea, 0xa7, 0x5f, 0x2b, 0x73, + 0xa9, 0xf6, 0xd2, 0x34, 0x79, 0x11, 0xdd, 0x29, 0xc8, 0xe2, 0x70, 0x31, 0xa5, 0xb4, 0x7d, 0x52, 0xfd, 0x7b, 0xbf, + 0xb8, 0x41, 0xfc, 0x6c, 0xfc, 0x63, 0xe6, 0xf3, 0xc0, 0x97, 0xba, 0xb4, 0x01, 0x72, 0x7f, 0x72, 0x6f, 0x95, 0x18, + 0x86, 0x21, 0x05, 0x64, 0xe5, 0x6a, 0x09, 0x58, 0x14, 0xc8, 0x03, 0x15, 0x10, 0x8d, 0x38, 0xa3, 0x1d, 0x52, 0x6b, + 0xd6, 0x97, 0x25, 0x40, 0x18, 0x70, 0xed, 0x2f, 0x34, 0xce, 0x7e, 0xb1, 0xb7, 0x20, 0xa8, 0x65, 0xc3, 0x4b, 0x9e, + 0x3f, 0x02, 0x23, 0x03, 0x84, 0x9c, 0x1e, 0x89, 0x3d, 0x8b, 0xd1, 0xbc, 0xa2, 0xb3, 0xe8, 0x81, 0x8c, 0x85, 0x9a, + 0x2a, 0x6f, 0xec, 0x04, 0x98, 0xdd, 0x07, 0x97, 0x54, 0xf5, 0x18, 0x0c, 0xe0, 0x05, 0x44, 0x05, 0xac, 0x68, 0x02, + 0x9d, 0xfa, 0xd8, 0x10, 0x07, 0x6f, 0x68, 0x51, 0x80, 0x20, 0xb0, 0x37, 0x10, 0xf6, 0x27, 0xd6, 0x1f, 0x5c, 0xcd, + 0xb0, 0xcb, 0x30, 0x8d, 0xe3, 0xd0, 0xd0, 0x9e, 0x82, 0x9f, 0x0a, 0x9b, 0x68, 0xaa, 0x04, 0x28, 0x37, 0x09, 0xb1, + 0x07, 0x01, 0xff, 0xca, 0x23, 0xf2, 0xb8, 0x6e, 0x6a, 0xff, 0x09, 0xa6, 0x38, 0x2a, 0x83, 0x75, 0x9b, 0xba, 0xeb, + 0xef, 0x75, 0x19, 0xc7, 0x35, 0xa0, 0xb0, 0xa5, 0x73, 0x9c, 0x1e, 0xd3, 0x10, 0xff, 0x6b, 0xa0, 0x7f, 0xd7, 0xaa, + 0xad, 0xef, 0x42, 0x6c, 0xd6, 0x66, 0xcc, 0x07, 0x0d, 0xbb, 0x8b, 0x13, 0xe3, 0xc8, 0xe3, 0xbe, 0xc0, 0xb4, 0x6b, + 0x89, 0x8f, 0x34, 0xf4, 0xe4, 0x11, 0x94, 0x9e, 0xae, 0x76, 0x95, 0xf1, 0xab, 0xf1, 0x78, 0x7b, 0xb3, 0xf5, 0x2a, + 0x86, 0x98, 0x11, 0x05, 0x6c, 0xf5, 0x3b, 0xeb, 0xf8, 0xe4, 0x60, 0x39, 0x8e, 0xb9, 0xf5, 0x12, 0x35, 0xae, 0x2f, + 0xb2, 0x14, 0x8b, 0x54, 0xfb, 0x72, 0xf7, 0x35, 0x1f, 0x4c, 0xaf, 0x7c, 0xfc, 0xfb, 0xf3, 0x50, 0x08, 0x2e, 0xa8, + 0x12, 0x23, 0xd1, 0x40, 0x77, 0x6e, 0x5b, 0x41, 0x0b, 0xbf, 0x95, 0x94, 0x56, 0x3c, 0x0f, 0x56, 0xa3, 0x5d, 0x02, + 0x42, 0x55, 0x03, 0x5e, 0x9f, 0xa2, 0xc9, 0x85, 0x03, 0xc7, 0x08, 0xb5, 0x68, 0x72, 0x96, 0x30, 0x9c, 0x74, 0xfb, + 0x6d, 0x7e, 0xfa, 0xeb, 0x9c, 0x0c, 0x91, 0x02, 0x90, 0xfa, 0x76, 0x4c, 0xf8, 0xf4, 0x3b, 0x5e, 0x4c, 0xfe, 0xf3, + 0x8d, 0x90, 0xbe, 0xe9, 0xc4, 0xc6, 0x43, 0x90, 0x37, 0x8a, 0x42, 0x84, 0x08, 0x76, 0x71, 0x20, 0xcc, 0x76, 0xf8, + 0x95, 0xdc, 0xc2, 0x57, 0xf4, 0x96, 0x9a, 0xa3, 0xa7, 0xd1, 0x41, 0x0b, 0x27, 0xac, 0x4d, 0x7f, 0x9e, 0x47, 0x5f, + 0x60, 0xc0, 0xe1, 0x33, 0x2b, 0xc0, 0x8d, 0x61, 0x15, 0xc0, 0x5a, 0x63, 0xee, 0x18, 0xbe, 0x96, 0xe9, 0x89, 0xb5, + 0xcc, 0x01, 0xf8, 0xb8, 0x92, 0xe3, 0x86, 0xee, 0x1c, 0x2a, 0x05, 0xf3, 0x76, 0x60, 0x8b, 0xfc, 0x9f, 0x69, 0x47, + 0x59, 0x55, 0x4c, 0x2c, 0x03, 0xe1, 0x72, 0x44, 0x42, 0xe6, 0xeb, 0xde, 0xc5, 0x20, 0x0a, 0x3e, 0x62, 0x64, 0xa7, + 0x54, 0x5c, 0xe7, 0x26, 0xbf, 0xea, 0x9f, 0x5f, 0x22, 0xf6, 0xba, 0x78, 0x5d, 0xbf, 0x7f, 0xe8, 0xef, 0xfe, 0xa4, + 0x15, 0xa0, 0x7a, 0xae, 0xec, 0xca, 0x6a, 0x26, 0x07, 0x9b, 0xc8, 0xf0, 0x73, 0xbd, 0x84, 0xca, 0xb4, 0x99, 0x00, + 0x21, 0x9c, 0xe3, 0x72, 0x72, 0x3d, 0x5a, 0x4c, 0xfc, 0x04, 0xd2, 0x18, 0x7a, 0x09, 0x4a, 0xe6, 0xfd, 0x11, 0x1e, + 0x5c, 0x0e, 0x08, 0xc4, 0xbb, 0xb8, 0x0a, 0x39, 0x5a, 0x1a, 0x24, 0x31, 0xbb, 0x9f, 0x62, 0x08, 0x25, 0x2e, 0x23, + 0x05, 0x6a, 0xd9, 0x9a, 0xb2, 0x6f, 0xc1, 0x72, 0x47, 0xd5, 0x61, 0x47, 0x98, 0x29, 0x4c, 0x95, 0xc8, 0x7f, 0x78, + 0x8c, 0xa4, 0x0a, 0x4f, 0xdd, 0xc9, 0xb3, 0x15, 0x52, 0x96, 0x93, 0x06, 0x12, 0x12, 0x78, 0x28, 0x44, 0x01, 0xfa, + 0x01, 0x5b, 0xa3, 0x8a, 0xc7, 0xff, 0x61, 0x5b, 0x02, 0xdd, 0x12, 0x9f, 0x58, 0x76, 0xbc, 0x61, 0x68, 0x0e, 0x79, + 0x8c, 0x44, 0x11, 0xb4, 0xc2, 0xcf, 0xaa, 0xe4, 0x07, 0x81, 0x12, 0x50, 0xc6, 0x45, 0x76, 0x14, 0xa8, 0x4a, 0x4c, + 0x70, 0x35, 0xd0, 0x83, 0xe8, 0xde, 0x65, 0xa0, 0x69, 0x3a, 0x78, 0xed, 0xd0, 0x30, 0x96, 0xc6, 0x54, 0x07, 0xdb, + 0x51, 0x21, 0x38, 0xd2, 0xe9, 0x90, 0x51, 0x70, 0x72, 0xfb, 0x0e, 0x97, 0x0d, 0x39, 0xdd, 0xee, 0x5a, 0xa1, 0xe8, + 0x19, 0xc8, 0xea, 0x5c, 0x6c, 0x9e, 0x67, 0x63, 0x22, 0x40, 0xfa, 0xc4, 0x3c, 0x54, 0x9c, 0x97, 0x19, 0x98, 0x36, + 0x79, 0xbc, 0x2d, 0x13, 0xc5, 0x1c, 0x4b, 0xae, 0x86, 0x7d, 0xc4, 0xd3, 0x7c, 0x3d, 0x93, 0xb2, 0xdf, 0x85, 0x01, + 0x47, 0x62, 0x98, 0xf5, 0x3b, 0xed, 0xf3, 0xda, 0x68, 0x73, 0x09, 0xf5, 0xa6, 0xc2, 0x24, 0xd1, 0xec, 0x58, 0x43, + 0xbd, 0x0a, 0x2d, 0x7e, 0x32, 0xb0, 0x7e, 0x0d, 0xa9, 0x37, 0x52, 0x33, 0xec, 0x8a, 0xe7, 0x23, 0x8f, 0x1f, 0xdd, + 0xa6, 0x56, 0xd6, 0x95, 0xd5, 0xcc, 0x36, 0x95, 0x18, 0xdf, 0x0f, 0xbb, 0xad, 0x6a, 0xcf, 0xb4, 0xca, 0xc7, 0xc3, + 0x97, 0x94, 0x4e, 0x07, 0xa2, 0xa9, 0x30, 0xb0, 0x87, 0x50, 0xc7, 0x02, 0xad, 0x8d, 0xc5, 0x2e, 0xca, 0xa3, 0x32, + 0xa5, 0xad, 0xd2, 0x18, 0xc6, 0x50, 0x1b, 0xc0, 0xd5, 0xed, 0x7a, 0x90, 0x96, 0x51, 0xd6, 0x5d, 0x4a, 0x0b, 0xc5, + 0x74, 0x0c, 0x6b, 0x85, 0x33, 0x25, 0xc3, 0x4d, 0x21, 0x4e, 0x03, 0x7c, 0x79, 0xe1, 0xff, 0xfe, 0x00, 0x56, 0xcd, + 0xed, 0x8e, 0x64, 0x1b, 0x97, 0x1d, 0x5d, 0x69, 0x85, 0xe7, 0xe9, 0xbc, 0x7c, 0x91, 0xb2, 0x2d, 0xd5, 0xa2, 0x61, + 0x1a, 0x1d, 0x65, 0x0c, 0xb5, 0x7d, 0xbb, 0x98, 0x31, 0x9c, 0x61, 0xc4, 0x5c, 0xf9, 0x06, 0x67, 0xbd, 0x96, 0xbc, + 0xfb, 0x2d, 0x23, 0xa7, 0x52, 0x5c, 0xf1, 0xa2, 0x4a, 0x0d, 0xaf, 0x7c, 0xf2, 0x1f, 0xe4, 0x6d, 0x52, 0xfc, 0x6a, + 0xd5, 0x18, 0x4a, 0x92, 0xcb, 0x89, 0xce, 0x9b, 0xd7, 0x70, 0xc2, 0xdb, 0x9e, 0xa6, 0x62, 0x86, 0xe2, 0xb1, 0x04, + 0xbf, 0xab, 0x3a, 0xdb, 0xcd, 0x1f, 0x7c, 0x2e, 0xc8, 0x5a, 0x4c, 0x96, 0xe5, 0xab, 0x0a, 0xce, 0xa4, 0x1e, 0x3f, + 0x7b, 0xb8, 0x53, 0x12, 0xa4, 0xba, 0x6d, 0xc8, 0xa7, 0x41, 0xa4, 0xb7, 0xcd, 0xea, 0x28, 0x43, 0x5e, 0x99, 0xd8, + 0x84, 0xa9, 0x53, 0xc7, 0x1b, 0x77, 0x5b, 0x2a, 0x26, 0x3b, 0x13, 0xe7, 0xe1, 0xff, 0xcc, 0x16, 0xbe, 0x4d, 0x3d, + 0xf9, 0x2b, 0xb6, 0x74, 0x90, 0xfc, 0x0a, 0xc4, 0x87, 0x63, 0x04, 0xf3, 0x39, 0x7d, 0x87, 0xc2, 0xa3, 0x8e, 0x65, + 0x60, 0x60, 0x62, 0xe5, 0xd9, 0x77, 0xfc, 0xdb, 0xf1, 0x96, 0x58, 0xa3, 0xb2, 0xca, 0x50, 0x0c, 0xc1, 0x20, 0xcd, + 0xeb, 0x00, 0x40, 0xae, 0x6c, 0x2a, 0xb6, 0x05, 0x22, 0x5b, 0x5e, 0x44, 0x8b, 0x77, 0xda, 0xb9, 0x11, 0xdc, 0x94, + 0xf8, 0x94, 0xbd, 0x3d, 0x65, 0x0c, 0x70, 0x0b, 0xec, 0x74, 0xec, 0xe0, 0x81, 0x98, 0x23, 0xa1, 0x76, 0x45, 0x16, + 0x4b, 0x52, 0x87, 0x8a, 0x45, 0xb3, 0xbe, 0x50, 0x62, 0x22, 0x86, 0x6c, 0x4d, 0x9d, 0x60, 0x45, 0xea, 0xa8, 0x3d, + 0x07, 0x16, 0x25, 0xcd, 0x3e, 0x43, 0x5e, 0x4c, 0x72, 0xc7, 0x44, 0x34, 0xe3, 0xc1, 0xcf, 0x42, 0x49, 0xcf, 0xbd, + 0x89, 0x85, 0xfc, 0xdd, 0x66, 0xf9, 0x0c, 0x7b, 0x87, 0x3f, 0x49, 0x15, 0xbe, 0x9c, 0xc2, 0x6a, 0x92, 0xd0, 0x56, + 0x2e, 0xbc, 0x5d, 0x12, 0xa0, 0x40, 0x59, 0xda, 0xa7, 0xc1, 0x81, 0x42, 0x1f, 0x0a, 0xca, 0x16, 0xcb, 0x94, 0x12, + 0x33, 0xe3, 0x22, 0xa6, 0xe4, 0x5e, 0xf4, 0x79, 0x3c, 0x5f, 0xd3, 0x77, 0x40, 0xa0, 0x72, 0xb3, 0xdf, 0x6c, 0x4c, + 0x72, 0xc0, 0xd0, 0x4c, 0x7f, 0xc2, 0x27, 0xb4, 0x7b, 0xbd, 0x64, 0x3f, 0x72, 0xe0, 0xfb, 0xc0, 0x71, 0x30, 0x7b, + 0xf2, 0x43, 0xca, 0x59, 0xab, 0xea, 0x3e, 0x0b, 0xf8, 0xfb, 0xe2, 0x05, 0xe2, 0xca, 0x24, 0x04, 0xba, 0x8b, 0x49, + 0x82, 0xd5, 0xa7, 0x60, 0x48, 0x3a, 0x01, 0x5d, 0xac, 0xb0, 0xb9, 0xd6, 0x6c, 0x39, 0x41, 0x17, 0x53, 0x59, 0xc1, + 0x9d, 0x3a, 0x94, 0xea, 0xe5, 0x61, 0x66, 0x3d, 0xac, 0xa6, 0xa7, 0x29, 0x48, 0x22, 0x9d, 0xec, 0xf6, 0x53, 0x92, + 0xbd, 0x26, 0x61, 0x64, 0xdf, 0x37, 0x33, 0x22, 0x00, 0xbe, 0xe8, 0x15, 0x22, 0xf6, 0xbd, 0x48, 0x39, 0x49, 0xa5, + 0x6b, 0xce, 0xe4, 0xb6, 0x42, 0x83, 0x58, 0x17, 0xfe, 0x55, 0x50, 0x37, 0xa5, 0xf9, 0x14, 0xdc, 0xa9, 0xbe, 0x81, + 0x5d, 0x02, 0xaf, 0xcd, 0xbb, 0x10, 0x34, 0x8d, 0x0d, 0xbe, 0x04, 0xb0, 0xb8, 0x0b, 0x03, 0x4f, 0xe0, 0x17, 0x5e, + 0x07, 0x70, 0xb3, 0x59, 0xa1, 0x56, 0x31, 0xd1, 0x9b, 0xf9, 0xa3, 0x5e, 0xd9, 0x78, 0xde, 0x9d, 0x04, 0x0b, 0xcb, + 0x49, 0x90, 0x7d, 0x86, 0x01, 0x2d, 0x5d, 0xbf, 0xe3, 0x62, 0x55, 0x0a, 0x2a, 0x21, 0xa4, 0xf4, 0xdd, 0xbf, 0x99, + 0xef, 0xe9, 0xb4, 0x5e, 0x8c, 0xf4, 0xcc, 0x00, 0x82, 0x5b, 0x92, 0x79, 0xd7, 0xd1, 0xb7, 0xa6, 0x67, 0x11, 0xf7, + 0x24, 0x2f, 0xbb, 0xcb, 0xae, 0x90, 0xd5, 0x22, 0xa6, 0xd4, 0xad, 0x9c, 0x5e, 0xc3, 0xbd, 0xcd, 0x3b, 0x09, 0xdc, + 0xcc, 0xe2, 0x96, 0x47, 0x09, 0x01, 0x57, 0x8e, 0xad, 0xa5, 0xd0, 0x30, 0xe2, 0xf5, 0x20, 0x83, 0x48, 0x90, 0xfe, + 0xed, 0x22, 0x43, 0xe9, 0x29, 0x9f, 0x8f, 0x6d, 0x24, 0xd4, 0xc3, 0x4d, 0xed, 0x08, 0x0e, 0xef, 0xde, 0x5c, 0x7d, + 0xc4, 0x1f, 0xa5, 0xd7, 0xf1, 0xa1, 0x37, 0x4e, 0xcb, 0xe5, 0x35, 0x36, 0x12, 0xc0, 0xed, 0xe3, 0xf6, 0x72, 0xe1, + 0x16, 0x0d, 0xcf, 0x6d, 0x35, 0xde, 0xed, 0xe8, 0x6f, 0x5f, 0xc0, 0xcd, 0xe7, 0xdb, 0x75, 0xe7, 0x7e, 0xf3, 0x33, + 0xe5, 0xe2, 0xa5, 0x8b, 0x8c, 0xe8, 0x82, 0xf1, 0xf2, 0x6a, 0x85, 0x14, 0x20, 0xcd, 0x0f, 0x60, 0xf7, 0xf1, 0xed, + 0x91, 0xee, 0x53, 0xd9, 0x2b, 0x24, 0x7d, 0xde, 0x2e, 0x15, 0x56, 0x22, 0x8e, 0x4f, 0x36, 0x8d, 0x2c, 0xe8, 0xb3, + 0x10, 0x5d, 0xaa, 0x9f, 0x92, 0x7c, 0x5e, 0xce, 0x0d, 0x3f, 0xfc, 0x74, 0x02, 0xba, 0x09, 0xcf, 0x06, 0x11, 0x94, + 0x45, 0x4e, 0x7b, 0x4a, 0x69, 0xdf, 0xc9, 0x3f, 0xa5, 0x28, 0xbc, 0x65, 0xa3, 0xfd, 0xd2, 0xaa, 0x9b, 0xfe, 0xac, + 0xba, 0x52, 0xbc, 0x7b, 0x78, 0xb5, 0xd9, 0x5d, 0xa6, 0xa1, 0x3c, 0x73, 0x73, 0xef, 0xab, 0x05, 0xfa, 0x15, 0xc9, + 0xc7, 0xc3, 0x00, 0x11, 0x57, 0xbb, 0xcb, 0x3c, 0x55, 0xbb, 0x67, 0x4d, 0xfb, 0x22, 0x6d, 0x0c, 0x57, 0x8e, 0x3d, + 0xbe, 0x7c, 0x12, 0x27, 0x17, 0xc7, 0xba, 0x39, 0x89, 0x54, 0x94, 0x8f, 0xf5, 0x57, 0x01, 0x86, 0x33, 0x6d, 0xce, + 0x40, 0xb2, 0xaa, 0xcb, 0x53, 0xa0, 0x1e, 0x99, 0x84, 0x27, 0x67, 0xf6, 0xcd, 0x6c, 0x00, 0xd8, 0x0c, 0x98, 0x86, + 0xd6, 0xbe, 0x9b, 0x27, 0xb3, 0xa8, 0x1a, 0x00, 0x47, 0xc9, 0x2f, 0x4e, 0x3d, 0x91, 0x65, 0x57, 0x58, 0xb3, 0xf1, + 0x7a, 0xe9, 0xee, 0xd7, 0x29, 0x49, 0x21, 0x3b, 0x67, 0x47, 0x91, 0x09, 0xf3, 0x71, 0x7c, 0xd5, 0xe8, 0x65, 0x69, + 0xfa, 0x86, 0x61, 0x00, 0x8b, 0x30, 0xcd, 0xdb, 0xdd, 0x76, 0xab, 0x3e, 0xad, 0x02, 0x42, 0xed, 0x9d, 0x73, 0x2b, + 0xed, 0x3f, 0x9c, 0x54, 0x34, 0x9c, 0xcd, 0x4b, 0x21, 0xd9, 0x57, 0x68, 0x50, 0x90, 0xd5, 0x98, 0x91, 0x8e, 0xf5, + 0x29, 0x09, 0x4c, 0x9b, 0x49, 0xfa, 0x76, 0x1b, 0xd4, 0x05, 0xa8, 0x4c, 0xf9, 0x72, 0x5d, 0x58, 0x53, 0x53, 0x6f, + 0x4c, 0xf1, 0xe5, 0xde, 0xbe, 0x40, 0xd3, 0xcc, 0xd0, 0x5e, 0xce, 0x6d, 0x28, 0x65, 0xbd, 0xec, 0x2a, 0xc2, 0x83, + 0x6c, 0xa5, 0xf3, 0xf8, 0x2e, 0xc9, 0xdf, 0xe4, 0x03, 0x6a, 0x2b, 0x16, 0x97, 0x7b, 0xf5, 0x22, 0x6e, 0x37, 0x19, + 0x9a, 0x11, 0x1a, 0x56, 0x53, 0xb0, 0xdc, 0xbd, 0xf9, 0x4c, 0xef, 0x66, 0x73, 0xf5, 0x39, 0xbb, 0xf8, 0xec, 0x60, + 0x1b, 0x24, 0x90, 0x7a, 0xc4, 0xca, 0x9a, 0xec, 0x21, 0x25, 0x86, 0x89, 0x69, 0xca, 0x9e, 0x00, 0x19, 0xc0, 0x1f, + 0x93, 0xf8, 0x7f, 0xfc, 0xfd, 0xef, 0xc1, 0x1d, 0xda, 0xef, 0xce, 0x17, 0x23, 0xef, 0xf9, 0x87, 0xd3, 0x03, 0xa7, + 0x9f, 0xdb, 0xfb, 0x38, 0xb7, 0x47, 0x44, 0x8d, 0x2a, 0x2e, 0x2a, 0x5a, 0xf1, 0xe4, 0x50, 0x55, 0x5a, 0x87, 0xf9, + 0x4e, 0xdc, 0x29, 0x15, 0xae, 0xdc, 0xcb, 0xe0, 0x7e, 0xbf, 0x1f, 0xae, 0xff, 0x5f, 0x9d, 0x2d, 0x59, 0x7f, 0xff, + 0x6f, 0x6b, 0xfa, 0x7f, 0xe9, 0x4d, 0x58, 0x1a, 0xee, 0x7f, 0x6b, 0x70, 0xe9, 0xb7, 0x67, 0x5a, 0x5f, 0xbb, 0xf6, + 0x6f, 0x1d, 0x20, 0x28, 0x64, 0x3f, 0xd9, 0xb3, 0x76, 0xe9, 0xa9, 0xcb, 0x2c, 0x06, 0xca, 0xc1, 0xff, 0x9f, 0x65, + 0x77, 0xec, 0xd9, 0x09, 0x53, 0x1b, 0x1f, 0xdf, 0xcf, 0x30, 0x0e, 0xb8, 0x55, 0x22, 0x8c, 0x71, 0xc8, 0xeb, 0xca, + 0xef, 0x6a, 0xe4, 0x73, 0x48, 0x27, 0xd6, 0x2a, 0xa0, 0x5f, 0xd6, 0x2f, 0x0a, 0xe2, 0xbe, 0x87, 0x3b, 0x13, 0xb1, + 0x24, 0x78, 0xa0, 0x6e, 0x9c, 0x0a, 0xca, 0x8f, 0xa4, 0x69, 0x7a, 0x8e, 0x92, 0x5f, 0xda, 0xff, 0x31, 0x5b, 0xc3, + 0xaa, 0xf7, 0x17, 0xc4, 0x8b, 0x93, 0xdb, 0x7f, 0x61, 0x21, 0xed, 0x1b, 0x92, 0x18, 0x1b, 0x53, 0xb7, 0x6e, 0x9c, + 0x3a, 0x9d, 0xde, 0xb3, 0xad, 0xea, 0x0c, 0xc2, 0x1f, 0x55, 0x29, 0x4c, 0xde, 0xae, 0x05, 0x51, 0x4d, 0xef, 0xb3, + 0x77, 0x75, 0x34, 0xa0, 0x96, 0x92, 0x67, 0x7e, 0x9b, 0xc1, 0xb3, 0x2b, 0x7c, 0xbf, 0x1a, 0xeb, 0xa7, 0xe0, 0x84, + 0x34, 0x72, 0x99, 0xb2, 0x7e, 0x04, 0x6b, 0xed, 0xe6, 0x83, 0x17, 0x38, 0x89, 0xce, 0xd9, 0x2a, 0xe7, 0x24, 0xaa, + 0xc6, 0xfb, 0x82, 0xf0, 0x3f, 0x67, 0x2c, 0x7c, 0x86, 0x86, 0x0b, 0xb1, 0x9c, 0x80, 0x6a, 0x4c, 0xe1, 0x98, 0x79, + 0xc7, 0xf5, 0x73, 0x7b, 0x6d, 0xbf, 0xf2, 0x8b, 0x21, 0xd2, 0x6c, 0x0c, 0xde, 0xaa, 0x7e, 0xc1, 0x50, 0xb2, 0x1f, + 0x0f, 0x7b, 0x70, 0xe8, 0xa5, 0xe9, 0x45, 0xd6, 0xfe, 0x29, 0x7c, 0x91, 0xaf, 0x7c, 0x48, 0x2d, 0xcd, 0x6b, 0xa5, + 0x18, 0x2f, 0x6e, 0xd8, 0xc5, 0xbf, 0x83, 0xf4, 0xc6, 0xec, 0xb0, 0xdb, 0xb8, 0x81, 0x22, 0x91, 0xc6, 0x1a, 0x32, + 0xf6, 0x3f, 0xad, 0x93, 0x1c, 0x26, 0x2c, 0x31, 0x08, 0xeb, 0x27, 0xb1, 0x79, 0xd5, 0xe7, 0x4e, 0xb2, 0x6f, 0x92, + 0x66, 0x57, 0xa1, 0x69, 0x00, 0x08, 0xcf, 0x1e, 0x91, 0xbb, 0xab, 0x8f, 0x96, 0x6c, 0x7b, 0xc9, 0xe5, 0x6f, 0xc3, + 0xc8, 0xd9, 0x87, 0x4d, 0x5b, 0x1b, 0x9c, 0xda, 0x9c, 0xc4, 0xa6, 0x8d, 0x55, 0xf8, 0xdc, 0x74, 0xc2, 0x7d, 0x7f, + 0xed, 0x59, 0x5c, 0x33, 0x2b, 0x89, 0xe2, 0xda, 0x0a, 0x71, 0x53, 0xf0, 0x03, 0x0c, 0x24, 0xcc, 0x18, 0x73, 0xb6, + 0x51, 0x20, 0x20, 0x49, 0x99, 0xb2, 0x6a, 0x43, 0x7c, 0xf9, 0x41, 0x0c, 0x70, 0x33, 0x13, 0x36, 0x01, 0xb5, 0xfe, + 0xc8, 0xca, 0x0d, 0x27, 0x4b, 0x42, 0xc8, 0xb8, 0xdb, 0x27, 0xbf, 0x60, 0x60, 0xc6, 0x8f, 0x18, 0xa5, 0xc6, 0x77, + 0xeb, 0xfd, 0x63, 0x26, 0x7f, 0xba, 0xfe, 0x93, 0x6d, 0xe3, 0xb7, 0xe1, 0x42, 0x19, 0xb6, 0xe6, 0x33, 0xb4, 0xac, + 0x0a, 0x0c, 0xca, 0xa8, 0xbc, 0xb3, 0x9e, 0xb9, 0xed, 0x93, 0x58, 0x55, 0x49, 0x7c, 0x43, 0xab, 0x32, 0x47, 0xf0, + 0xb8, 0x17, 0xa5, 0x34, 0x25, 0x58, 0x82, 0xdb, 0xf7, 0x2b, 0xe4, 0x2a, 0xe7, 0xe1, 0xcb, 0x13, 0x47, 0x92, 0x2b, + 0x17, 0xa5, 0x57, 0x6f, 0x38, 0xe2, 0xd4, 0xa5, 0x94, 0x9d, 0x65, 0x60, 0x4f, 0x36, 0x0f, 0xa9, 0x20, 0xa5, 0xa1, + 0x96, 0x6d, 0xdb, 0x5a, 0xf9, 0x25, 0x7a, 0x2d, 0xb5, 0xba, 0x60, 0x69, 0x29, 0xe0, 0xc6, 0x8c, 0x28, 0x8f, 0x6a, + 0xeb, 0xe6, 0xea, 0x28, 0xa5, 0x79, 0x50, 0x57, 0xc1, 0x43, 0x6d, 0x1e, 0xb9, 0xb0, 0x86, 0x5f, 0xfa, 0xf8, 0xe8, + 0x91, 0x31, 0x32, 0xed, 0x06, 0x3e, 0x9e, 0x66, 0xc3, 0x66, 0x07, 0x5f, 0xa8, 0x3a, 0x35, 0x21, 0x94, 0x2f, 0xd0, + 0x79, 0xa3, 0x4a, 0xb2, 0x1c, 0xbc, 0x42, 0xc6, 0x2d, 0x4e, 0x12, 0xf7, 0x6f, 0xc8, 0xfa, 0xa2, 0x58, 0x5a, 0xb4, + 0xa7, 0x95, 0x55, 0x41, 0x69, 0x9b, 0xd4, 0xfc, 0xd7, 0x98, 0x7e, 0xe5, 0x21, 0xa9, 0xa7, 0x35, 0xde, 0x1f, 0x72, + 0xbb, 0xe4, 0x1e, 0x77, 0xdf, 0x82, 0x33, 0xa3, 0x76, 0xbb, 0x02, 0x90, 0x76, 0x7d, 0x1c, 0x21, 0x91, 0x39, 0x11, + 0x4e, 0x29, 0xe9, 0xc1, 0x8d, 0x1c, 0xa1, 0xf9, 0xdd, 0x3e, 0xb6, 0x9a, 0x48, 0xb7, 0x70, 0x1c, 0xb1, 0xbf, 0x2c, + 0x63, 0x67, 0x70, 0x12, 0xaf, 0x5d, 0xfc, 0xda, 0x23, 0x14, 0xd9, 0x92, 0x4a, 0x7d, 0x6d, 0xc9, 0x95, 0x76, 0xf9, + 0x4e, 0xed, 0x65, 0xdc, 0xa1, 0xb0, 0x4d, 0x6f, 0x5d, 0x8a, 0xff, 0xc3, 0x29, 0xa5, 0xfa, 0x8e, 0xdf, 0xa8, 0xf4, + 0xb7, 0xdd, 0xfd, 0x5e, 0x6d, 0x05, 0xcb, 0xf9, 0xab, 0x1a, 0xd1, 0x36, 0xed, 0xda, 0x2e, 0x5a, 0xbc, 0x39, 0xd0, + 0xd6, 0xa1, 0xbe, 0x42, 0xff, 0xbc, 0x63, 0x54, 0x05, 0x3a, 0x24, 0x1d, 0xca, 0xb0, 0x99, 0x36, 0xe4, 0xc4, 0x6a, + 0x18, 0x84, 0xfd, 0xa2, 0x50, 0xfb, 0xe0, 0x7f, 0x32, 0x65, 0x45, 0x03, 0x6a, 0xcd, 0x39, 0xd3, 0x96, 0x33, 0xe0, + 0xfa, 0x64, 0xb3, 0xdb, 0xd4, 0x7a, 0xaa, 0x31, 0xce, 0x68, 0xca, 0xb0, 0xad, 0x5b, 0xb6, 0xec, 0xd6, 0xcd, 0x1c, + 0x49, 0xf1, 0x07, 0x33, 0xc3, 0x27, 0xfd, 0xe7, 0xd7, 0xba, 0x01, 0xca, 0xbb, 0x57, 0xef, 0x67, 0x72, 0xaa, 0x3a, + 0xe5, 0x4f, 0xf3, 0xf5, 0xd3, 0x5f, 0x2d, 0x79, 0xfd, 0xa3, 0xbf, 0x78, 0x89, 0xde, 0xf0, 0x17, 0x6c, 0x19, 0xe3, + 0x66, 0xbb, 0x4c, 0x7a, 0x09, 0x3a, 0x2b, 0x35, 0xfa, 0x6c, 0x83, 0xa5, 0xe0, 0x2e, 0x18, 0x09, 0xd4, 0xb4, 0x4d, + 0x59, 0x97, 0xf6, 0x7d, 0x71, 0xfd, 0x74, 0xa3, 0xad, 0x2f, 0xb6, 0xaa, 0x87, 0xb8, 0xef, 0xab, 0xd7, 0xc1, 0x7c, + 0x3e, 0xec, 0xbe, 0xfd, 0x84, 0x4d, 0xf8, 0xa7, 0x10, 0xa0, 0x0d, 0x3b, 0x3d, 0x56, 0x8d, 0x8b, 0xf7, 0xd5, 0xb0, + 0xb8, 0xae, 0xda, 0xe2, 0xac, 0x9a, 0x17, 0xe7, 0xd5, 0xf5, 0xe1, 0xdd, 0x5d, 0xbf, 0x65, 0xf8, 0x1b, 0x56, 0xd3, + 0x1b, 0xb2, 0xf6, 0x33, 0xa6, 0xa9, 0x65, 0xc2, 0xe9, 0x69, 0xb7, 0x7c, 0x84, 0xd3, 0x2e, 0xdd, 0x9d, 0xdd, 0x79, + 0xbc, 0x7d, 0x83, 0x5e, 0xa5, 0x68, 0x97, 0x05, 0x86, 0xea, 0xc4, 0x82, 0xc4, 0xbc, 0xc6, 0xb6, 0x37, 0xeb, 0x90, + 0x33, 0x18, 0xc8, 0x73, 0xc5, 0x35, 0xce, 0x5d, 0x8c, 0x99, 0xbc, 0xa1, 0x00, 0x85, 0x63, 0x49, 0x54, 0xc3, 0xaa, + 0x95, 0x15, 0x75, 0x24, 0xb1, 0x20, 0x88, 0x17, 0x4c, 0x9d, 0x54, 0xc1, 0x2e, 0xdd, 0xc8, 0xbb, 0x1a, 0xc1, 0x00, + 0xb7, 0x9d, 0x4d, 0xb9, 0xb8, 0x2f, 0x1a, 0xd9, 0x62, 0x2b, 0x55, 0x2d, 0xc2, 0x95, 0x48, 0x39, 0x2e, 0xad, 0x6f, + 0x99, 0xdb, 0xf7, 0xba, 0x5f, 0x9c, 0x97, 0xe2, 0x7f, 0xda, 0x01, 0x5e, 0x47, 0x86, 0xac, 0xec, 0x05, 0xbf, 0x52, + 0x32, 0xad, 0x13, 0xeb, 0x54, 0xd3, 0xba, 0xc6, 0x61, 0xf6, 0xf2, 0xd7, 0xf2, 0x40, 0x14, 0x23, 0xfa, 0xa2, 0x56, + 0x2a, 0x6b, 0x74, 0x98, 0xc4, 0x20, 0xd3, 0xd0, 0x94, 0x63, 0x0d, 0xad, 0x15, 0x67, 0xf1, 0x68, 0x57, 0x41, 0x62, + 0xe3, 0x5b, 0xf9, 0x35, 0x27, 0x36, 0xe8, 0x00, 0x62, 0x81, 0x8e, 0xcb, 0x3a, 0x13, 0xfe, 0x3f, 0xea, 0xa1, 0xdc, + 0x37, 0xfd, 0x9f, 0x28, 0xaf, 0x0a, 0xd1, 0x67, 0xe8, 0xdb, 0x25, 0x57, 0x70, 0x09, 0x31, 0xea, 0xc1, 0x9a, 0xa8, + 0xe6, 0xce, 0x6f, 0xd1, 0x27, 0x90, 0x02, 0x82, 0xa7, 0x33, 0x18, 0x9c, 0xa8, 0x36, 0xd2, 0xa0, 0x99, 0x11, 0xa9, + 0x18, 0x0a, 0xef, 0x47, 0x53, 0xb5, 0x6e, 0x47, 0x32, 0xb6, 0x57, 0x32, 0x6f, 0xf5, 0x6b, 0xab, 0x40, 0x61, 0x3e, + 0x5e, 0xae, 0x1a, 0x01, 0xa0, 0xe5, 0xef, 0xdb, 0x9f, 0xd4, 0xd5, 0x38, 0x7a, 0xd7, 0x6d, 0x0a, 0x47, 0xe7, 0x88, + 0x27, 0x86, 0xc5, 0x16, 0xa2, 0xd5, 0x13, 0xa8, 0xf9, 0x0e, 0x0d, 0x57, 0xed, 0x9b, 0xcc, 0x60, 0x5e, 0x4e, 0x4e, + 0x72, 0x7e, 0x87, 0xa9, 0x77, 0xbe, 0x67, 0x8a, 0x30, 0xa9, 0x09, 0xa2, 0xea, 0x3d, 0x14, 0x04, 0x0b, 0xf6, 0x42, + 0x0b, 0xf7, 0xeb, 0x51, 0x92, 0x82, 0xc9, 0x80, 0xae, 0x68, 0xed, 0x88, 0x95, 0x15, 0x53, 0x6a, 0x34, 0x12, 0x19, + 0xae, 0x72, 0xd3, 0xdf, 0xc7, 0x84, 0x4a, 0x01, 0x68, 0xb7, 0x7f, 0x21, 0x63, 0xe4, 0xe0, 0x82, 0x35, 0xd1, 0x6e, + 0x48, 0x43, 0x0b, 0xb7, 0x54, 0x16, 0x04, 0xf0, 0x82, 0x06, 0xab, 0xfd, 0x82, 0xca, 0x71, 0xe1, 0x13, 0x0b, 0x53, + 0xaf, 0x84, 0x5d, 0xf0, 0xe7, 0x86, 0xa5, 0xf5, 0xcf, 0x0f, 0x03, 0x8a, 0xf5, 0x0f, 0x61, 0xd8, 0x97, 0xcf, 0xf3, + 0x9c, 0xf8, 0xd8, 0x08, 0xc9, 0xd5, 0x56, 0x83, 0x10, 0x2f, 0x4a, 0x7a, 0x2b, 0x66, 0x16, 0xb5, 0xde, 0x1e, 0x9e, + 0xd7, 0xbe, 0x74, 0x07, 0xb1, 0xea, 0x97, 0xd8, 0xd8, 0xec, 0x6e, 0x40, 0x90, 0xfd, 0xa6, 0xa8, 0x94, 0xb1, 0xc9, + 0xf7, 0x3c, 0xc9, 0xee, 0xe5, 0xf3, 0x19, 0x81, 0x53, 0xf6, 0xd9, 0x67, 0xbe, 0x26, 0xe0, 0xcb, 0x9e, 0x1e, 0x9b, + 0x3d, 0xad, 0xb3, 0x73, 0x4e, 0x1f, 0x1e, 0xa2, 0x86, 0xda, 0x74, 0x2f, 0x0c, 0x86, 0x2b, 0x90, 0x5f, 0xb8, 0x4f, + 0x88, 0x09, 0x97, 0x9f, 0x9f, 0x46, 0x3b, 0x73, 0x27, 0xe4, 0xc1, 0xd9, 0xe1, 0x13, 0x50, 0x01, 0x33, 0x7b, 0xa7, + 0x92, 0xe6, 0x6d, 0xf5, 0x88, 0x8f, 0x5a, 0x91, 0xd8, 0x03, 0x98, 0xae, 0xbb, 0xe0, 0x3e, 0x5d, 0xef, 0x56, 0xf6, + 0x5d, 0x3c, 0x15, 0xa8, 0x7b, 0x6d, 0xab, 0x6d, 0xea, 0xaf, 0x74, 0xc7, 0xd3, 0x17, 0x85, 0x01, 0xc0, 0xec, 0x2e, + 0x41, 0x0b, 0xbe, 0x92, 0x18, 0xf6, 0xe0, 0xbd, 0x9c, 0xa4, 0xdf, 0x62, 0x07, 0x4f, 0xc6, 0xb5, 0x51, 0x0d, 0xd4, + 0xc2, 0x7c, 0x77, 0x43, 0xcd, 0xaa, 0x1a, 0x48, 0x9c, 0x23, 0xe1, 0x6c, 0xfd, 0xac, 0x3d, 0xe6, 0x8b, 0x9d, 0x2b, + 0x8e, 0xfd, 0x8f, 0x0a, 0xbf, 0xc2, 0xf6, 0x8c, 0xa5, 0x03, 0xaf, 0x0c, 0x2b, 0xe9, 0x18, 0x0c, 0xc8, 0xcf, 0x75, + 0x9c, 0x48, 0xa3, 0xf9, 0xfb, 0xe8, 0x8b, 0x04, 0x35, 0xd0, 0x6f, 0x7c, 0x1e, 0x5f, 0xba, 0xe4, 0x53, 0xad, 0x1f, + 0x08, 0xf8, 0x20, 0x03, 0x6a, 0xcf, 0xe8, 0x8c, 0x16, 0x4f, 0x73, 0xfd, 0x49, 0x7f, 0xcc, 0x25, 0xeb, 0x1f, 0xfd, + 0xd3, 0x2c, 0x4e, 0xad, 0xc5, 0x45, 0x35, 0xc1, 0x7b, 0x0a, 0xfb, 0x9e, 0x02, 0xfe, 0x2e, 0x59, 0x64, 0xc3, 0x32, + 0x9a, 0x47, 0xb1, 0xa6, 0x41, 0x94, 0xd4, 0xfa, 0xc8, 0xad, 0x4d, 0x3e, 0xf6, 0x7d, 0x0f, 0xab, 0x42, 0x5f, 0xe9, + 0xc2, 0x77, 0x55, 0x8b, 0xc5, 0x6c, 0xd5, 0x99, 0x48, 0xb9, 0x9e, 0x51, 0xa9, 0xc0, 0x11, 0x56, 0x9a, 0x23, 0xc7, + 0x34, 0xa5, 0xe1, 0xc0, 0xe1, 0x14, 0x6b, 0x52, 0x80, 0x7d, 0xfd, 0x4b, 0xdf, 0xda, 0x5a, 0x9e, 0x4f, 0xe1, 0xb6, + 0xe1, 0x2d, 0xce, 0xeb, 0x32, 0x94, 0xa4, 0x56, 0x01, 0xcb, 0xbe, 0x8a, 0x05, 0xc4, 0x45, 0xbe, 0xaa, 0x36, 0x27, + 0x8c, 0x51, 0x93, 0x0b, 0xb5, 0x87, 0xcc, 0x0d, 0xd4, 0x44, 0xa7, 0x90, 0x5e, 0x70, 0xda, 0x77, 0x93, 0xd8, 0x5a, + 0xd7, 0x32, 0xeb, 0xeb, 0xc4, 0x52, 0xa5, 0xcd, 0xb3, 0xbd, 0x23, 0x1d, 0x90, 0xcb, 0x98, 0x84, 0x20, 0x89, 0x25, + 0xa8, 0xf0, 0xd8, 0xfe, 0xaa, 0x9f, 0x8b, 0x04, 0x20, 0x81, 0xed, 0x8b, 0xf8, 0x32, 0x70, 0x94, 0xa4, 0xa2, 0x6a, + 0x6a, 0x6d, 0x06, 0x4c, 0xcc, 0x3b, 0x1d, 0x55, 0x6a, 0x51, 0x83, 0x20, 0x40, 0x64, 0xe2, 0x2c, 0x12, 0x39, 0x3d, + 0x8a, 0x1e, 0xee, 0x68, 0xa7, 0x85, 0x4c, 0xd1, 0x0a, 0x4a, 0x64, 0xed, 0x21, 0x49, 0x0f, 0x5f, 0x23, 0x14, 0x83, + 0x13, 0xe7, 0xcc, 0x05, 0xbf, 0xd7, 0x26, 0xbf, 0x9f, 0x5a, 0xe6, 0xde, 0xb5, 0xd8, 0x59, 0x7c, 0xe5, 0x51, 0xae, + 0x9e, 0x6c, 0x04, 0xdc, 0x0e, 0xe8, 0xee, 0x05, 0x05, 0xd8, 0xdb, 0x9b, 0x00, 0x03, 0xaf, 0xb4, 0xa8, 0xb5, 0x6c, + 0xe3, 0xb2, 0x5c, 0x13, 0xd6, 0x96, 0xfc, 0x9f, 0xdf, 0x4b, 0x27, 0x27, 0x9b, 0x28, 0x74, 0x34, 0xc9, 0xa9, 0x12, + 0x1d, 0x41, 0x1a, 0xc3, 0xaa, 0x17, 0x17, 0x90, 0x69, 0x4f, 0x93, 0x37, 0x6e, 0xd9, 0x12, 0x46, 0x66, 0x6f, 0x01, + 0xbb, 0xa7, 0xb7, 0x0c, 0x1c, 0xa9, 0xfa, 0xbf, 0x9f, 0xa6, 0x12, 0x3b, 0x05, 0x11, 0x84, 0x7a, 0xee, 0x58, 0xb2, + 0x0b, 0x64, 0x6c, 0xf5, 0x77, 0xcc, 0xb4, 0x69, 0xb2, 0x09, 0xe1, 0x11, 0x32, 0xe7, 0xbd, 0x72, 0x5b, 0x84, 0x18, + 0x4a, 0x0b, 0x52, 0xf0, 0xb5, 0xd3, 0x29, 0x82, 0xc3, 0x3c, 0x5d, 0x86, 0x0e, 0x1f, 0xc2, 0x19, 0x99, 0x31, 0xfe, + 0x54, 0xdc, 0x1b, 0x60, 0xde, 0x5d, 0x88, 0x1d, 0x26, 0xeb, 0x95, 0x21, 0x77, 0x44, 0x1e, 0xdf, 0x26, 0x79, 0x7a, + 0xb7, 0xcb, 0xa0, 0x4c, 0xe9, 0xf0, 0xc9, 0x24, 0xe2, 0x53, 0x71, 0xaa, 0x48, 0xb5, 0xa0, 0x6d, 0xf5, 0xed, 0xf7, + 0x65, 0xd0, 0x7b, 0xcf, 0xbe, 0xf5, 0x3e, 0x0a, 0x88, 0xae, 0x37, 0x0d, 0xdb, 0x34, 0x4f, 0x43, 0x83, 0x1c, 0xc3, + 0xfc, 0x74, 0x6b, 0x99, 0x4e, 0xd5, 0xe5, 0x2f, 0x7a, 0x6d, 0x91, 0x2f, 0x80, 0x4d, 0x3d, 0x0d, 0xaa, 0xb3, 0xda, + 0x26, 0x10, 0x21, 0x7d, 0x20, 0x66, 0x89, 0x8f, 0x62, 0xc5, 0xf8, 0xec, 0x35, 0x91, 0x0b, 0x7e, 0x96, 0x9f, 0x43, + 0xee, 0xed, 0x8d, 0x1f, 0xf9, 0xa4, 0xa0, 0xf7, 0xe3, 0x71, 0x76, 0x06, 0xf1, 0x7c, 0x9c, 0xce, 0x76, 0xaa, 0x20, + 0xa6, 0xbf, 0xfb, 0xff, 0x4c, 0x53, 0xd4, 0x1f, 0x20, 0x6c, 0x12, 0x2f, 0x0e, 0x13, 0xbc, 0x56, 0x09, 0x37, 0x09, + 0x3a, 0xa9, 0x7b, 0x28, 0x07, 0x6c, 0x22, 0x80, 0xaf, 0x3c, 0x23, 0x6e, 0x60, 0xba, 0x54, 0xf0, 0x34, 0xf2, 0x0e, + 0xc2, 0xe1, 0x4e, 0xc7, 0x93, 0x76, 0xb8, 0xaf, 0xa2, 0x8d, 0xc5, 0xe3, 0x63, 0x06, 0x91, 0x3f, 0x28, 0xfa, 0x9f, + 0x1a, 0x94, 0x46, 0x7e, 0xbe, 0x98, 0x2f, 0xcd, 0x5c, 0xad, 0xc7, 0x92, 0x36, 0x0a, 0x36, 0xab, 0x50, 0xba, 0x65, + 0xbc, 0x17, 0x17, 0xb6, 0xe8, 0x29, 0x34, 0xfb, 0xfd, 0x69, 0x52, 0x4e, 0xa5, 0xbd, 0xac, 0x5a, 0x93, 0x5e, 0x4b, + 0x6e, 0xef, 0x99, 0x4d, 0xf4, 0x13, 0x60, 0x25, 0x7e, 0x2b, 0x5a, 0xbc, 0xf4, 0x58, 0x94, 0xdf, 0xa5, 0x1a, 0x01, + 0x19, 0x82, 0xe7, 0x4f, 0x1e, 0x03, 0xbb, 0x15, 0xe9, 0xe9, 0xdf, 0x16, 0x97, 0xbe, 0x3b, 0x89, 0xd3, 0xff, 0x53, + 0xc8, 0xfe, 0xc0, 0x8f, 0x19, 0x58, 0x7f, 0xc6, 0x22, 0x55, 0x70, 0x09, 0xb7, 0xdb, 0xc4, 0xe6, 0x0b, 0xa8, 0x8a, + 0xcb, 0xed, 0xb9, 0xa3, 0x4a, 0xec, 0x27, 0x85, 0x0f, 0x3e, 0x8e, 0x4d, 0x6b, 0x11, 0xfe, 0x76, 0x17, 0x99, 0xfc, + 0xab, 0xe3, 0x12, 0x84, 0x57, 0xdd, 0xf8, 0xa0, 0xdf, 0x33, 0x5a, 0x3d, 0xcd, 0x7f, 0x9e, 0xfe, 0x9b, 0x25, 0xff, + 0xfc, 0xe8, 0x9f, 0x66, 0xe5, 0xad, 0x54, 0x3d, 0xe2, 0x01, 0x57, 0xe3, 0x25, 0xe2, 0xf1, 0xe4, 0xf5, 0xfc, 0xa3, + 0x64, 0x57, 0x75, 0x0d, 0x15, 0x5e, 0x9e, 0xc8, 0x05, 0x5a, 0x46, 0x35, 0xdb, 0x7a, 0x8e, 0x5e, 0x28, 0xd7, 0x1d, + 0xc5, 0x92, 0x44, 0x9b, 0x5e, 0x7e, 0x8b, 0xf4, 0x6a, 0x90, 0x24, 0x98, 0xed, 0xbf, 0x93, 0x35, 0x20, 0xd4, 0x1a, + 0x66, 0x56, 0xa9, 0x81, 0xc5, 0x73, 0xdb, 0x96, 0x94, 0xf3, 0x2a, 0xde, 0x1f, 0x45, 0x7e, 0xf9, 0x21, 0x0c, 0x58, + 0x0c, 0x46, 0x6f, 0x84, 0x26, 0xe0, 0x29, 0x22, 0x23, 0x47, 0x55, 0x5d, 0x48, 0xfc, 0x76, 0x47, 0x48, 0xbc, 0x95, + 0x4b, 0xa5, 0xaf, 0x04, 0x90, 0xaf, 0x65, 0xf5, 0xa9, 0xab, 0xc1, 0x5d, 0x7f, 0xd8, 0x93, 0xf4, 0x8d, 0x77, 0xe6, + 0x37, 0xea, 0xf2, 0x56, 0x69, 0xf4, 0x04, 0xcc, 0xce, 0x36, 0x4c, 0x65, 0xc4, 0x49, 0xe4, 0xd0, 0xe6, 0x62, 0x07, + 0x56, 0x99, 0x75, 0x33, 0xba, 0x82, 0x3f, 0x76, 0xe7, 0x2e, 0x24, 0x65, 0xcd, 0xb5, 0x4f, 0x32, 0xfd, 0xd0, 0x8a, + 0xe3, 0x2e, 0x81, 0xf1, 0xbe, 0xb4, 0xbc, 0x30, 0x3c, 0x45, 0x4a, 0x6d, 0x53, 0x0a, 0x1a, 0x90, 0x5f, 0xc5, 0x43, + 0x8a, 0x36, 0x41, 0x20, 0x27, 0x7b, 0xa5, 0x95, 0xea, 0x23, 0x95, 0xbb, 0xec, 0x19, 0xd3, 0xe6, 0x01, 0xa7, 0xd9, + 0x0c, 0x4a, 0x60, 0x9c, 0x4e, 0xfb, 0x64, 0x6d, 0x37, 0x9d, 0xdb, 0x6f, 0x92, 0xb2, 0xf0, 0x6b, 0x14, 0x4a, 0x6e, + 0xfe, 0x36, 0xfd, 0xfb, 0x96, 0xaf, 0x9e, 0xf9, 0x47, 0x82, 0xbd, 0xd2, 0x9f, 0xfd, 0xf5, 0xbe, 0xb2, 0x8b, 0x73, + 0xa5, 0x5b, 0x87, 0x85, 0xe5, 0xe2, 0x61, 0x7f, 0x74, 0x24, 0x80, 0x4c, 0x10, 0x2b, 0xdd, 0xb0, 0xc6, 0xf0, 0xfb, + 0x44, 0xcd, 0x3e, 0xf3, 0x8b, 0xa3, 0xa3, 0x61, 0xe5, 0x1b, 0xbb, 0x59, 0x27, 0x58, 0x0e, 0xff, 0xcf, 0xfd, 0x97, + 0xcd, 0x37, 0xbb, 0xcd, 0xe1, 0xc6, 0xc6, 0x6e, 0x9f, 0x05, 0xc6, 0x31, 0x37, 0xd7, 0x6b, 0x04, 0xc6, 0x48, 0xed, + 0xd0, 0xe4, 0x87, 0xc6, 0x99, 0xe3, 0xaa, 0x4c, 0xd9, 0x3b, 0xa2, 0x16, 0x69, 0x5c, 0xcf, 0x4a, 0x8e, 0xb4, 0xd0, + 0x2e, 0x96, 0xc5, 0xa1, 0x51, 0x24, 0x34, 0xad, 0x17, 0x1b, 0x39, 0xee, 0x87, 0xe7, 0xb3, 0x61, 0xc0, 0x53, 0xc2, + 0xda, 0x81, 0xb3, 0x11, 0x13, 0x41, 0x86, 0xdb, 0x29, 0x42, 0x37, 0xe4, 0x60, 0x80, 0x6e, 0xe8, 0x1c, 0xc1, 0x73, + 0x27, 0x67, 0xce, 0x8f, 0x0b, 0x6f, 0x98, 0x90, 0x0c, 0xa3, 0x04, 0x90, 0x63, 0xb2, 0x92, 0x6e, 0xdc, 0xdb, 0xbd, + 0x69, 0x77, 0x5e, 0x50, 0xd5, 0xc5, 0x50, 0x5b, 0xea, 0x49, 0x47, 0xea, 0xc5, 0x07, 0x12, 0xc3, 0xb6, 0xd3, 0xc9, + 0xf3, 0xca, 0xe8, 0xd5, 0x44, 0xf7, 0xfb, 0x98, 0xe6, 0xba, 0x2f, 0x9a, 0x23, 0xba, 0x02, 0x96, 0x33, 0x99, 0x5d, + 0x4b, 0xc2, 0xd9, 0xee, 0x3e, 0x9a, 0xd0, 0x73, 0x8d, 0x63, 0x51, 0x28, 0x14, 0x6c, 0x69, 0xba, 0x1b, 0xcf, 0xac, + 0xc3, 0xc5, 0x3f, 0xd4, 0xc5, 0x55, 0x06, 0x8a, 0xb3, 0xa6, 0x77, 0x22, 0x71, 0xdf, 0x46, 0x17, 0x06, 0x38, 0x41, + 0x93, 0x8b, 0x1e, 0xf6, 0x44, 0x18, 0x5a, 0x50, 0xd3, 0x5c, 0xca, 0x9f, 0x5b, 0x8f, 0x89, 0x6e, 0x30, 0x38, 0xce, + 0x95, 0x59, 0x4e, 0x4d, 0x1e, 0x0a, 0x57, 0x4a, 0xae, 0xb0, 0x9d, 0x59, 0x5c, 0x36, 0x4b, 0xa5, 0xf0, 0xfe, 0x7f, + 0x71, 0xf0, 0x4c, 0x48, 0xbb, 0x6a, 0xd4, 0xa6, 0xfa, 0x04, 0x3e, 0x03, 0x57, 0x52, 0x39, 0xd9, 0xc4, 0x1f, 0x06, + 0xb8, 0xd3, 0x1f, 0x44, 0x77, 0xcb, 0x86, 0x4b, 0x6e, 0x43, 0x1e, 0x0a, 0x0d, 0xc9, 0xd8, 0x07, 0xc3, 0xd5, 0xe7, + 0x51, 0xf6, 0xf0, 0xf8, 0x3b, 0x46, 0x6b, 0x54, 0xbd, 0xb8, 0x6e, 0x16, 0x3f, 0x70, 0x61, 0xdd, 0xa9, 0xab, 0x5f, + 0x51, 0xde, 0xfc, 0x69, 0xd9, 0x87, 0x55, 0x7e, 0x42, 0x16, 0xd8, 0xd7, 0xf2, 0xe6, 0x04, 0xac, 0xc5, 0x1c, 0x54, + 0x23, 0xf9, 0x45, 0x29, 0x0d, 0xec, 0x80, 0x69, 0xca, 0x35, 0x5a, 0x66, 0xea, 0x4f, 0x3d, 0x38, 0x19, 0x5f, 0x37, + 0x1c, 0x4a, 0x67, 0x77, 0xff, 0xd2, 0x71, 0x0f, 0xa1, 0x29, 0xd2, 0x84, 0xbf, 0x3e, 0x9e, 0xd8, 0x38, 0xb1, 0x8a, + 0x5a, 0x60, 0x5c, 0x39, 0xee, 0xef, 0xad, 0xae, 0x73, 0xf5, 0xd2, 0x87, 0x18, 0x48, 0x92, 0x69, 0xbc, 0x50, 0x09, + 0x52, 0x11, 0xaf, 0x50, 0x70, 0xda, 0xde, 0xef, 0xae, 0xec, 0x51, 0xde, 0xfe, 0xa7, 0x78, 0x33, 0xa3, 0xf9, 0x57, + 0x78, 0x79, 0x2f, 0xd7, 0xef, 0xba, 0xf3, 0xf5, 0x95, 0xfd, 0xb0, 0xdb, 0xff, 0x34, 0x03, 0xc9, 0x55, 0x2a, 0xfd, + 0xe9, 0x52, 0xcf, 0x67, 0x9f, 0x00, 0xf0, 0xeb, 0x95, 0xa1, 0x86, 0x64, 0x58, 0x13, 0xcd, 0x44, 0xc2, 0x5a, 0x25, + 0x62, 0x7c, 0xb3, 0x84, 0xaf, 0x69, 0x77, 0x45, 0x78, 0xa4, 0x8c, 0x8d, 0xb3, 0xb6, 0x43, 0xd6, 0xc7, 0x4c, 0x90, + 0xdd, 0x16, 0xcc, 0xf5, 0xd3, 0xac, 0x9f, 0x86, 0x55, 0xb5, 0x08, 0xd5, 0x27, 0x94, 0xe9, 0xf3, 0x68, 0x00, 0xdd, + 0xa0, 0x70, 0x64, 0x68, 0x24, 0x32, 0x36, 0xfa, 0x61, 0x37, 0x11, 0x1d, 0x47, 0x64, 0x44, 0x64, 0x25, 0x45, 0x21, + 0x9a, 0x4d, 0xfc, 0xf8, 0xfc, 0x27, 0xa5, 0x5a, 0x50, 0x24, 0xe1, 0x1a, 0x80, 0xa4, 0xf6, 0xc3, 0x35, 0x04, 0xa6, + 0xfa, 0xc3, 0xb6, 0x35, 0xe8, 0xd8, 0xc8, 0xca, 0x86, 0xa4, 0x8e, 0xa4, 0xbf, 0x0d, 0x22, 0x49, 0xa6, 0x72, 0x93, + 0x8c, 0x8d, 0x90, 0x03, 0xcc, 0x3b, 0x5a, 0x9b, 0x6f, 0x46, 0xd4, 0x91, 0x74, 0x4c, 0x58, 0x89, 0x3d, 0xa2, 0x30, + 0xc1, 0x11, 0xc2, 0xfd, 0x9a, 0xec, 0x42, 0xff, 0x29, 0xc0, 0xf6, 0x53, 0x43, 0xa2, 0x66, 0xfb, 0x48, 0xc3, 0xa7, + 0xd0, 0xf3, 0x10, 0xe2, 0x6d, 0x98, 0x97, 0x10, 0x16, 0xb9, 0xc1, 0x0e, 0xf4, 0x5e, 0x90, 0xa9, 0x08, 0x6f, 0x24, + 0x6c, 0x62, 0x2d, 0x10, 0x80, 0x67, 0xeb, 0x3e, 0x15, 0x1c, 0x00, 0xa4, 0xcd, 0xca, 0xb1, 0x7c, 0x7f, 0x3c, 0x90, + 0x43, 0x5b, 0x9a, 0x1d, 0xa9, 0x3b, 0xc4, 0xa5, 0x34, 0x9f, 0xe8, 0xd8, 0x1a, 0xc9, 0x41, 0xc2, 0x68, 0xc5, 0x33, + 0xb9, 0x28, 0x9b, 0x76, 0x7e, 0x18, 0xde, 0x57, 0xa5, 0x26, 0x9e, 0xb4, 0xbd, 0xca, 0x1c, 0xc5, 0xe4, 0xf1, 0xd0, + 0xd7, 0xba, 0x0d, 0x97, 0x1e, 0xf4, 0x34, 0x1c, 0x4f, 0x52, 0xfe, 0x7a, 0x8e, 0x62, 0x6d, 0xfc, 0x30, 0xd2, 0x50, + 0x01, 0x19, 0x1e, 0xdc, 0x72, 0xd9, 0x6c, 0xf5, 0xc3, 0xee, 0xf8, 0x61, 0x13, 0x3e, 0xda, 0x8b, 0x6b, 0x33, 0xa7, + 0x97, 0x41, 0x1a, 0xcc, 0x87, 0x92, 0x82, 0x2b, 0xab, 0xc6, 0xbe, 0x37, 0x95, 0xd4, 0xfe, 0xdd, 0xa6, 0x60, 0xdb, + 0xda, 0x46, 0x2f, 0xae, 0x3f, 0x2a, 0x91, 0xf9, 0xfa, 0xdd, 0x34, 0xee, 0x76, 0x76, 0xdb, 0x82, 0x68, 0x84, 0x95, + 0x3b, 0x26, 0x96, 0xd3, 0x6f, 0x9a, 0x74, 0x73, 0x43, 0xe8, 0x23, 0x8a, 0x7f, 0x9b, 0x94, 0xe3, 0xb3, 0xc3, 0xf3, + 0x6b, 0xe8, 0x41, 0x13, 0xa6, 0xaa, 0xc7, 0xe9, 0x0e, 0x16, 0x89, 0xe2, 0x09, 0xaf, 0x88, 0x44, 0xf6, 0xea, 0x87, + 0x43, 0xc6, 0x12, 0x85, 0x21, 0xd2, 0x98, 0xc7, 0x0f, 0xbb, 0x74, 0xd8, 0x79, 0x18, 0xc6, 0x09, 0x70, 0xd9, 0x97, + 0x94, 0xbc, 0xb1, 0x86, 0xdf, 0x7e, 0x0e, 0x4c, 0xfb, 0x7e, 0x7b, 0x9f, 0xe9, 0xad, 0x78, 0x69, 0x6c, 0xbc, 0xde, + 0xa1, 0x10, 0x21, 0xa2, 0x9c, 0x36, 0x3e, 0xae, 0x7f, 0xa4, 0xd8, 0xb0, 0x65, 0x59, 0xae, 0x18, 0xdd, 0xe2, 0xd7, + 0xc0, 0x26, 0x34, 0x6c, 0x87, 0x90, 0x3e, 0xb2, 0x6b, 0x5e, 0x09, 0x68, 0x55, 0x0f, 0x4b, 0xbd, 0xa2, 0x0b, 0x68, + 0x39, 0xc7, 0x48, 0xd9, 0x40, 0x19, 0x28, 0xf8, 0x17, 0x67, 0xd0, 0x55, 0x36, 0xb3, 0xcc, 0xd6, 0xc8, 0x82, 0x7f, + 0x10, 0x4e, 0xe7, 0x4f, 0xa2, 0xd5, 0x84, 0x2c, 0xe1, 0x52, 0xf1, 0x16, 0x14, 0xd8, 0x4a, 0x31, 0x05, 0x06, 0xb4, + 0x7d, 0x22, 0x8d, 0x5f, 0x8c, 0x69, 0x05, 0xd4, 0xd1, 0xe3, 0x32, 0xca, 0xe0, 0x33, 0xad, 0x2b, 0x16, 0x97, 0x41, + 0x7b, 0xa0, 0x31, 0xfc, 0x6b, 0x6b, 0xec, 0x5b, 0xdb, 0x65, 0xfe, 0x3d, 0xe0, 0x35, 0xb5, 0xa7, 0x14, 0x62, 0x05, + 0xd1, 0x01, 0xb2, 0x76, 0x0d, 0x9d, 0xbd, 0x67, 0xcf, 0xc7, 0xd6, 0x72, 0x05, 0x53, 0xe8, 0xa0, 0x62, 0x78, 0x83, + 0xcd, 0xfd, 0x23, 0x85, 0x33, 0x0d, 0xe9, 0x3c, 0xb3, 0x5a, 0x91, 0xcb, 0x14, 0xd4, 0x88, 0x7f, 0x9d, 0x3b, 0x58, + 0x24, 0x51, 0x3d, 0xe2, 0x14, 0x91, 0xa6, 0x93, 0x05, 0x26, 0xa1, 0x8e, 0xd4, 0xd0, 0x76, 0xbb, 0x82, 0x27, 0xca, + 0x4f, 0x38, 0xfd, 0x9b, 0xa5, 0x6b, 0xd4, 0x16, 0x7c, 0x0e, 0xcd, 0xe2, 0x0f, 0x51, 0x4b, 0x7f, 0xfd, 0xf1, 0xc1, + 0x00, 0x01, 0xc4, 0xdb, 0xb3, 0x41, 0x08, 0x13, 0x4f, 0xc7, 0xd6, 0x99, 0x7c, 0xc8, 0x40, 0x30, 0x9b, 0x6a, 0x84, + 0x6c, 0x84, 0xb9, 0xb5, 0x77, 0xd3, 0x3a, 0xf9, 0x03, 0xa7, 0xc0, 0x14, 0xe2, 0x84, 0xed, 0xa0, 0xc0, 0xfc, 0x61, + 0x1c, 0x45, 0x08, 0xf5, 0xe5, 0xd7, 0x22, 0x19, 0xc9, 0xf9, 0x36, 0x98, 0x8b, 0x18, 0x25, 0xd8, 0x5a, 0xf1, 0x5a, + 0x3f, 0x20, 0xaa, 0xda, 0xef, 0x4d, 0x86, 0xed, 0x8c, 0x3e, 0xbe, 0x1b, 0x4f, 0x8a, 0x6f, 0x1c, 0xd7, 0x73, 0x18, + 0xca, 0xfb, 0x67, 0x48, 0xa2, 0x65, 0xde, 0xff, 0xc4, 0xb9, 0xdb, 0xd5, 0xb1, 0x09, 0x2f, 0x6a, 0x03, 0xc3, 0x84, + 0xb0, 0xc1, 0xed, 0x79, 0x9b, 0xec, 0x34, 0x58, 0x9c, 0x4e, 0x17, 0xbc, 0xe1, 0x1a, 0x85, 0x7d, 0xb6, 0x33, 0x29, + 0xee, 0x5d, 0xfb, 0xd7, 0x71, 0x23, 0x1a, 0xf7, 0x19, 0x93, 0x90, 0x7f, 0x67, 0x39, 0x53, 0x9a, 0x3e, 0xad, 0x0a, + 0x4f, 0xfa, 0xce, 0xd9, 0xcd, 0x7c, 0x04, 0x17, 0xed, 0x6f, 0x80, 0xe5, 0x4e, 0xb6, 0x39, 0x27, 0x79, 0x46, 0xf3, + 0x0b, 0xbc, 0xd4, 0xd2, 0xcf, 0xed, 0xb4, 0xea, 0x40, 0x74, 0xb7, 0x00, 0x15, 0x0c, 0xd4, 0xe1, 0x81, 0xb7, 0x63, + 0x3b, 0xc4, 0xa7, 0x1a, 0x8c, 0x41, 0x60, 0xfa, 0x0f, 0xdf, 0xcd, 0xf5, 0x2e, 0x14, 0xa2, 0xcf, 0xa2, 0xe5, 0x2e, + 0xa3, 0x2f, 0xec, 0x84, 0x28, 0x23, 0x17, 0x31, 0xfa, 0x39, 0xba, 0x4b, 0xc8, 0x0d, 0x32, 0x17, 0x11, 0x54, 0xdc, + 0x93, 0xef, 0x88, 0x1f, 0xb0, 0x0b, 0x20, 0x1a, 0xc4, 0x39, 0x87, 0x8a, 0xfe, 0x26, 0x94, 0xa2, 0xd9, 0x61, 0x3c, + 0xff, 0xbb, 0x2c, 0x42, 0xe4, 0xcf, 0xa3, 0x78, 0x57, 0xc8, 0xf7, 0xee, 0xb1, 0xc5, 0x48, 0xf0, 0xc5, 0xb7, 0x41, + 0x2f, 0xe4, 0xc9, 0x9e, 0xc8, 0x20, 0xba, 0xf1, 0x0b, 0xa9, 0x56, 0x89, 0x5c, 0x5d, 0x64, 0x2c, 0x98, 0x5f, 0x21, + 0xa7, 0x3f, 0x6d, 0xef, 0xfc, 0xf2, 0x0f, 0x0c, 0xea, 0x98, 0xc5, 0x7f, 0x36, 0xee, 0x9b, 0x50, 0xa4, 0xef, 0xc5, + 0xe3, 0x03, 0xe2, 0x07, 0xd1, 0xf5, 0x2e, 0x41, 0xb1, 0x95, 0xcc, 0x09, 0x41, 0xa2, 0x70, 0x7c, 0x51, 0x7b, 0xff, + 0x9d, 0xde, 0x85, 0x9b, 0xa8, 0x3d, 0x08, 0xe8, 0x27, 0xff, 0xf0, 0xcb, 0x1f, 0x10, 0x1f, 0x88, 0x2c, 0xb8, 0xbe, + 0x9b, 0x67, 0xab, 0x3f, 0x71, 0x9e, 0xbb, 0x18, 0x44, 0x9f, 0x80, 0x0a, 0x12, 0x56, 0xa9, 0x9e, 0xc1, 0x03, 0xf6, + 0x3f, 0x2c, 0x5c, 0x8d, 0x78, 0xfd, 0xf8, 0xf4, 0x26, 0x5e, 0x43, 0xe7, 0x0a, 0xab, 0x0e, 0x5f, 0x80, 0xc8, 0x21, + 0xb9, 0x54, 0x5d, 0xec, 0x38, 0xd3, 0xff, 0x55, 0x02, 0x36, 0xde, 0x11, 0xc1, 0xe9, 0xfc, 0xc3, 0xcb, 0x17, 0x1b, + 0x7b, 0xb2, 0x9b, 0xdb, 0x61, 0xfc, 0x93, 0x06, 0x96, 0x70, 0x5f, 0xd3, 0xf4, 0x47, 0xc6, 0xe4, 0xd3, 0xfc, 0xf6, + 0x49, 0x3f, 0x1f, 0x4b, 0xbe, 0xfd, 0xe8, 0x17, 0xfe, 0xa8, 0x5f, 0xf3, 0xec, 0x57, 0xb2, 0x26, 0x3b, 0xec, 0x35, + 0xc0, 0xa7, 0xbd, 0xf1, 0xa5, 0xe5, 0xfa, 0x5a, 0xc5, 0xf8, 0x8b, 0x51, 0xe8, 0xd3, 0xef, 0x2e, 0x1f, 0xbc, 0x92, + 0x77, 0x0b, 0x25, 0xcd, 0x54, 0x50, 0xe7, 0xd6, 0xa6, 0xb6, 0x15, 0xda, 0x4d, 0x30, 0x09, 0xf6, 0x06, 0x05, 0x91, + 0x46, 0x15, 0x9e, 0xc8, 0xa2, 0x6d, 0x19, 0x94, 0x0a, 0x86, 0xd2, 0x1c, 0x47, 0x5d, 0x0f, 0x89, 0x03, 0x46, 0xf4, + 0x8c, 0x68, 0x55, 0xab, 0x38, 0x1d, 0x1d, 0x2c, 0x04, 0x9c, 0x42, 0x84, 0x11, 0xc8, 0xf7, 0xea, 0x84, 0x0a, 0x74, + 0x21, 0x69, 0x08, 0xf1, 0x3b, 0xe9, 0x58, 0x8a, 0xe2, 0xda, 0x0a, 0x5f, 0xef, 0x3f, 0xc9, 0xc6, 0xca, 0x47, 0x01, + 0x16, 0xe5, 0x1d, 0x4a, 0xa9, 0x0e, 0x29, 0x98, 0x5c, 0xa4, 0x2e, 0x47, 0xcc, 0x9c, 0x8f, 0x64, 0xb3, 0xe0, 0xb0, + 0x9a, 0x5b, 0xd1, 0x6e, 0x9c, 0xe5, 0xe0, 0xd0, 0x2a, 0xe3, 0x30, 0x86, 0x24, 0x37, 0xf9, 0x35, 0x0a, 0x28, 0x27, + 0xeb, 0x53, 0xdc, 0x02, 0xdf, 0x72, 0xfb, 0x8c, 0x5c, 0xa5, 0xd0, 0xd9, 0x23, 0xdf, 0x33, 0xfc, 0xc1, 0xe3, 0xfd, + 0xee, 0x73, 0x78, 0x34, 0x65, 0xd5, 0x84, 0xb5, 0x7f, 0xb4, 0x21, 0x21, 0x94, 0x02, 0x55, 0x04, 0x08, 0x53, 0x65, + 0x0d, 0xac, 0xeb, 0x90, 0x9a, 0x43, 0x4d, 0xd7, 0x9f, 0x58, 0xe4, 0x88, 0x77, 0x98, 0x38, 0xbf, 0x61, 0x60, 0x89, + 0xa5, 0x0c, 0xf6, 0x06, 0xbc, 0xd6, 0xc2, 0x3e, 0x8b, 0x02, 0x75, 0x26, 0xe7, 0x8a, 0x23, 0x08, 0xba, 0xa5, 0x66, + 0x26, 0x2a, 0x9d, 0x65, 0x8f, 0x34, 0x3f, 0xc5, 0xbc, 0x62, 0xbf, 0x2a, 0x93, 0x86, 0x74, 0xd0, 0x99, 0xdc, 0x9a, + 0x9a, 0x02, 0x57, 0x21, 0x55, 0xb5, 0x4e, 0xec, 0x38, 0xf1, 0xc2, 0xcf, 0xd3, 0x11, 0xc7, 0x36, 0x3e, 0x0f, 0x45, + 0x7d, 0x92, 0xf7, 0x69, 0xe9, 0xfa, 0xd0, 0x25, 0xd7, 0x06, 0x69, 0x7a, 0x3b, 0xa2, 0x2b, 0x3f, 0xbc, 0xa6, 0x31, + 0x4d, 0x5f, 0x39, 0xba, 0x4f, 0x73, 0xf3, 0x49, 0xdb, 0x58, 0xb2, 0xf9, 0xd1, 0x5f, 0xf2, 0xca, 0xac, 0x43, 0x28, + 0x72, 0x99, 0x82, 0xfb, 0x7d, 0x3e, 0xaa, 0xc9, 0xf6, 0x3b, 0x78, 0x14, 0x88, 0x2f, 0x1b, 0x81, 0x30, 0xfd, 0xe2, + 0xc1, 0x70, 0x23, 0xaf, 0x06, 0xe6, 0x6a, 0x82, 0xeb, 0x75, 0x7d, 0x02, 0xe9, 0xd9, 0x1a, 0x03, 0x1b, 0x21, 0x53, + 0x57, 0xc1, 0x7b, 0xf6, 0x2e, 0x68, 0x6f, 0xe0, 0x37, 0x7b, 0xc0, 0x08, 0xb3, 0x7a, 0xcb, 0x08, 0x28, 0x0c, 0x29, + 0xd4, 0x4d, 0x93, 0x14, 0x0d, 0xa1, 0x02, 0x06, 0xfc, 0xfc, 0x45, 0xe8, 0xc2, 0xd3, 0x12, 0xe8, 0x7f, 0x70, 0x3e, + 0xd4, 0x8a, 0x32, 0xce, 0x2f, 0x5a, 0x6c, 0x69, 0xee, 0x34, 0x4f, 0x4c, 0x7e, 0xec, 0x23, 0x3c, 0x4f, 0xe5, 0x38, + 0x9c, 0xdd, 0xd7, 0xe9, 0x4a, 0x7b, 0xa9, 0x39, 0x9e, 0x34, 0xff, 0x6a, 0xe3, 0xcb, 0xbc, 0x13, 0x91, 0x38, 0xc1, + 0x5d, 0x80, 0x7f, 0x3d, 0x8f, 0x24, 0x61, 0x38, 0x5d, 0x34, 0xdf, 0x14, 0xef, 0x56, 0x13, 0x6f, 0x71, 0xd5, 0x22, + 0x8d, 0xdb, 0x43, 0xdc, 0xf7, 0x7e, 0x0d, 0x9e, 0x3b, 0xdb, 0xf5, 0x7c, 0xd8, 0x0a, 0x1e, 0x90, 0xc9, 0xe5, 0x14, + 0x08, 0x5e, 0xc4, 0x62, 0x32, 0x0f, 0xa9, 0x57, 0xb6, 0x0e, 0xcb, 0xd2, 0x79, 0xa5, 0xb6, 0x89, 0x7a, 0xc5, 0xb8, + 0x96, 0x6f, 0x76, 0xeb, 0x45, 0x5c, 0x12, 0x0b, 0x2d, 0xae, 0x95, 0x56, 0xaa, 0x59, 0x9f, 0x18, 0x5b, 0x8e, 0xda, + 0x76, 0xff, 0x7e, 0x83, 0xc8, 0x6a, 0x9f, 0x5f, 0x3e, 0x2e, 0x88, 0x81, 0x0f, 0x99, 0xa3, 0xf4, 0xf8, 0x02, 0xad, + 0xb2, 0x6e, 0xad, 0xbd, 0x3a, 0xed, 0x4e, 0xa6, 0xdf, 0x96, 0xf5, 0x61, 0x17, 0x8c, 0xd7, 0x05, 0xb1, 0xed, 0x51, + 0xe8, 0x27, 0xd6, 0xe7, 0x7b, 0xaa, 0x10, 0xfe, 0x6d, 0x7a, 0xff, 0xb3, 0xb7, 0xcd, 0x73, 0xa9, 0x22, 0x8e, 0x90, + 0x79, 0xa2, 0x36, 0x8c, 0x95, 0x92, 0xbd, 0xa0, 0x43, 0x8a, 0xd5, 0x8c, 0x42, 0x03, 0x81, 0x54, 0x37, 0xbd, 0x93, + 0x57, 0x03, 0x80, 0x29, 0x66, 0xb0, 0xe1, 0x70, 0x17, 0xf0, 0x0e, 0xb5, 0x82, 0x70, 0x9c, 0x33, 0xaa, 0x96, 0x2e, + 0xb5, 0xde, 0x8e, 0x9f, 0xc2, 0x51, 0x1d, 0x70, 0xd1, 0xfe, 0x78, 0x2c, 0xd8, 0x8a, 0x44, 0x0d, 0x71, 0x2e, 0x4d, + 0x5a, 0xa8, 0x0f, 0x51, 0x8f, 0x7d, 0x37, 0x68, 0x33, 0xbc, 0x05, 0x5f, 0x11, 0xb8, 0xc2, 0x2f, 0x71, 0x70, 0xcb, + 0x74, 0xb8, 0x87, 0xad, 0xeb, 0x9a, 0xe8, 0x8b, 0xfa, 0x33, 0x66, 0x59, 0x08, 0x72, 0x7a, 0xc2, 0x3f, 0xa8, 0x85, + 0x4a, 0x41, 0xf0, 0x72, 0x2e, 0xe0, 0xfe, 0x1c, 0x46, 0x4f, 0x48, 0xf9, 0xa1, 0x88, 0x04, 0x69, 0x1d, 0x99, 0x1a, + 0x1c, 0xf7, 0x58, 0x97, 0x18, 0x66, 0x2f, 0x82, 0x83, 0xc5, 0xac, 0x11, 0x59, 0xd5, 0x23, 0xf8, 0xcd, 0x93, 0xa6, + 0x75, 0x88, 0x25, 0x85, 0x1a, 0xd6, 0x54, 0xfa, 0x5b, 0x10, 0xa9, 0x4d, 0x97, 0x7f, 0x02, 0x74, 0x6d, 0x4f, 0x94, + 0x9e, 0xf6, 0x92, 0x5a, 0x54, 0x1d, 0xda, 0x46, 0xc2, 0xdc, 0xa5, 0xc0, 0xd0, 0x38, 0xf0, 0x00, 0xb1, 0xf6, 0xae, + 0xc8, 0xe4, 0x3d, 0x74, 0x99, 0x3c, 0x44, 0xd5, 0x4e, 0x8d, 0xed, 0x72, 0xca, 0x0f, 0x52, 0x6d, 0x61, 0xe4, 0x14, + 0x75, 0x4a, 0x95, 0x17, 0x46, 0x08, 0xea, 0xd6, 0x57, 0xc7, 0xba, 0x08, 0xcd, 0xc3, 0x6a, 0xed, 0x44, 0x2f, 0xb1, + 0xcc, 0xfe, 0xd1, 0x20, 0xce, 0xcc, 0xc2, 0x40, 0x73, 0xfe, 0x53, 0xb7, 0x18, 0xa2, 0xfd, 0x5f, 0xf2, 0xb0, 0x5e, + 0x77, 0xfe, 0x74, 0x5c, 0x78, 0x69, 0xb7, 0x4b, 0x77, 0x1b, 0xbd, 0x37, 0xe0, 0x1a, 0x8c, 0xf9, 0x93, 0x7c, 0xa6, + 0x8d, 0x08, 0xa8, 0xf8, 0x36, 0x7c, 0x7c, 0x3f, 0xda, 0x47, 0xe4, 0x21, 0x72, 0x98, 0x3f, 0x46, 0xbf, 0x13, 0x6c, + 0x51, 0x6b, 0x44, 0xb2, 0x8a, 0xb0, 0x20, 0x35, 0x77, 0xf8, 0xd6, 0x23, 0xdf, 0x5c, 0x57, 0x3b, 0xf1, 0x79, 0x0d, + 0x02, 0xa8, 0x58, 0x4d, 0x1b, 0x07, 0xfa, 0xdc, 0xf6, 0x19, 0xcf, 0x41, 0x13, 0x1d, 0x85, 0x43, 0xfc, 0xf3, 0x9c, + 0x73, 0xb4, 0xa3, 0x9d, 0x1c, 0x87, 0xc7, 0xd8, 0x2b, 0xc5, 0xb9, 0xff, 0x8c, 0x42, 0x13, 0x96, 0x9f, 0xe5, 0x3b, + 0xd4, 0x07, 0xfc, 0x3c, 0xe5, 0x7f, 0x5c, 0xf5, 0x40, 0x88, 0x3d, 0x21, 0x00, 0xce, 0x9f, 0xfe, 0x23, 0x14, 0xf2, + 0xa7, 0x12, 0x2e, 0xfc, 0x07, 0x86, 0x84, 0x17, 0xc1, 0x3f, 0xc1, 0xef, 0x2c, 0x31, 0x3a, 0x4c, 0x51, 0xa1, 0xfc, + 0xa3, 0x03, 0x21, 0x5f, 0x73, 0x76, 0x6d, 0x0e, 0x9f, 0xcf, 0x99, 0xe5, 0x0b, 0xae, 0x09, 0xf5, 0x79, 0x2b, 0x30, + 0xff, 0xa6, 0xc9, 0x3e, 0x09, 0x48, 0x2e, 0xfc, 0x56, 0xdc, 0xad, 0x56, 0x93, 0x3c, 0x8a, 0x14, 0xfd, 0x66, 0x1a, + 0x2b, 0x6f, 0xbc, 0x45, 0x89, 0xb6, 0x43, 0x2f, 0x4d, 0x9f, 0xcb, 0x17, 0x84, 0xd9, 0x56, 0xc7, 0x89, 0xd9, 0x1f, + 0xdc, 0x5d, 0xa7, 0x4b, 0x2c, 0x41, 0x64, 0x18, 0x77, 0xc7, 0x60, 0x1d, 0xbe, 0x5a, 0x19, 0x2a, 0x63, 0x11, 0x4a, + 0x15, 0x2d, 0x3d, 0xfc, 0x42, 0x37, 0x71, 0x51, 0xba, 0x99, 0x72, 0xcc, 0xf4, 0x77, 0x68, 0xfd, 0x6b, 0x35, 0x3a, + 0xbb, 0x24, 0x7c, 0xf0, 0x78, 0x2f, 0xe8, 0x6f, 0x3a, 0x64, 0x17, 0xe1, 0x2f, 0x1f, 0x5f, 0xaa, 0x25, 0x0b, 0xa3, + 0x9b, 0xce, 0xa7, 0x34, 0x7b, 0xbb, 0xaf, 0x32, 0x0a, 0x4d, 0x0d, 0x85, 0x91, 0x38, 0x2b, 0xc7, 0x65, 0xef, 0x4c, + 0xd6, 0xf5, 0x73, 0xcf, 0xaa, 0x94, 0x5d, 0x48, 0xb0, 0xa8, 0x97, 0x7b, 0xf7, 0x0d, 0x5a, 0x48, 0xa1, 0x06, 0xd2, + 0x16, 0x03, 0x1d, 0xba, 0x67, 0x38, 0xd1, 0x25, 0x94, 0x40, 0xa4, 0x0f, 0x57, 0x59, 0xd4, 0xf4, 0x45, 0x4c, 0xa0, + 0x4f, 0x3d, 0x5b, 0xec, 0x6c, 0xd7, 0x28, 0x3b, 0x8c, 0x02, 0x72, 0xf7, 0x86, 0x67, 0x46, 0x1f, 0xef, 0xdf, 0xc8, + 0x6a, 0xf9, 0x7f, 0xa3, 0x46, 0xdb, 0x3b, 0x47, 0xa0, 0xe1, 0x99, 0xb7, 0x4b, 0x22, 0x12, 0x24, 0x2c, 0x7e, 0x3e, + 0x79, 0xf6, 0x7d, 0x17, 0x4a, 0xa4, 0xe0, 0xd0, 0x57, 0x63, 0xba, 0x7c, 0xa9, 0x26, 0xca, 0x47, 0x62, 0xc0, 0x4f, + 0x3a, 0x0f, 0x12, 0x5d, 0x4d, 0x73, 0xb0, 0x43, 0x39, 0x70, 0x7b, 0x73, 0xc6, 0xf9, 0x63, 0xbe, 0xc1, 0xca, 0xc1, + 0x93, 0xed, 0x9f, 0x7a, 0xb9, 0x8d, 0x51, 0xc5, 0x4f, 0x44, 0x63, 0x19, 0xf0, 0xf0, 0xd9, 0xe9, 0x08, 0xed, 0x8c, + 0x64, 0x01, 0xca, 0x7b, 0xbb, 0x3f, 0x86, 0x4b, 0xf8, 0x99, 0x1a, 0xef, 0x59, 0xdb, 0xa1, 0xa5, 0xdb, 0xf8, 0xa6, + 0xe4, 0x71, 0x78, 0x60, 0x2d, 0xc5, 0x6a, 0x6c, 0x0d, 0x10, 0x97, 0xb8, 0xa3, 0x6c, 0xad, 0xe2, 0xe2, 0xfe, 0x5f, + 0x1e, 0x9e, 0x39, 0x07, 0x81, 0x2a, 0xe1, 0x60, 0x22, 0x35, 0x23, 0xb6, 0x91, 0x63, 0xc7, 0x6b, 0x46, 0x1c, 0x5c, + 0x01, 0x69, 0x23, 0x26, 0x9a, 0x53, 0xb9, 0x0f, 0xc6, 0xf3, 0xe8, 0x8d, 0xaa, 0x8f, 0x73, 0xe6, 0x81, 0x6d, 0x70, + 0x27, 0x55, 0x1b, 0x16, 0x26, 0xbe, 0xd9, 0xad, 0xa5, 0xe9, 0xcb, 0x8e, 0xac, 0x17, 0x6c, 0xcf, 0x4a, 0x10, 0xfa, + 0x54, 0xfa, 0x37, 0x1a, 0xe2, 0xb9, 0xae, 0x5f, 0x47, 0x17, 0xed, 0x87, 0xb9, 0xc3, 0xfe, 0xee, 0xf8, 0xb4, 0x61, + 0x62, 0x1d, 0x7d, 0x1e, 0x3b, 0x2b, 0xcc, 0xb3, 0x6b, 0x4d, 0x3f, 0xb5, 0xf1, 0xd0, 0xc7, 0xbe, 0xb4, 0x56, 0x66, + 0xb0, 0x2a, 0x28, 0xbb, 0x53, 0x53, 0x03, 0x61, 0x0d, 0xea, 0x3a, 0x99, 0x64, 0x33, 0x65, 0xbf, 0x3c, 0x03, 0xb3, + 0xdf, 0x45, 0xc9, 0x15, 0xfa, 0xeb, 0x7d, 0x69, 0x52, 0xe7, 0x3b, 0xda, 0x22, 0x47, 0xb4, 0xc5, 0xa0, 0x16, 0x11, + 0xef, 0xd4, 0xd7, 0x29, 0xc9, 0x47, 0x2f, 0x5a, 0x82, 0x30, 0xb5, 0xa4, 0xdd, 0x15, 0x28, 0x61, 0x99, 0x91, 0xcf, + 0xf6, 0x13, 0xe3, 0xfd, 0xd3, 0xf8, 0xa5, 0x63, 0xd2, 0x76, 0xb5, 0x6b, 0x07, 0x23, 0xd7, 0xd0, 0x54, 0x41, 0xe3, + 0x16, 0xdf, 0x61, 0xa0, 0x9f, 0xe2, 0x48, 0xdb, 0xaf, 0x35, 0x4f, 0x5f, 0xda, 0xd6, 0xf3, 0xea, 0xf6, 0x89, 0x5a, + 0xeb, 0xc0, 0xb1, 0x33, 0xb4, 0x27, 0x6f, 0x4c, 0x90, 0x0f, 0x7d, 0x3e, 0x3c, 0x0d, 0xa7, 0x26, 0x1f, 0x9d, 0xa5, + 0x90, 0xc8, 0x1e, 0x15, 0x5f, 0x60, 0x3e, 0x1f, 0x28, 0x15, 0x51, 0x1b, 0xef, 0xdd, 0xd6, 0x6e, 0xbe, 0x8f, 0x47, + 0xab, 0x76, 0x8d, 0xd1, 0x46, 0xc2, 0x02, 0x3c, 0x54, 0x89, 0xd2, 0x21, 0x0e, 0xfc, 0x67, 0x92, 0x76, 0x16, 0x75, + 0x8d, 0xb7, 0x65, 0xc3, 0xa4, 0xf9, 0x3c, 0x95, 0x7a, 0x19, 0x77, 0xd8, 0x56, 0x6e, 0xf6, 0xd1, 0x13, 0xd1, 0xbe, + 0x66, 0x6d, 0x3e, 0x41, 0x50, 0x76, 0xb5, 0xc3, 0xbd, 0xea, 0x88, 0x1d, 0x27, 0x6c, 0xbf, 0xd9, 0x7c, 0xe7, 0xa8, + 0x14, 0xa5, 0xc6, 0x09, 0x6b, 0xdd, 0xd4, 0x4e, 0x34, 0x87, 0x30, 0xfc, 0xd2, 0x37, 0xf1, 0x12, 0x52, 0x37, 0x1c, + 0xf3, 0xf6, 0xfe, 0x79, 0x58, 0xd7, 0xc2, 0x09, 0x45, 0xb2, 0x26, 0xf6, 0xde, 0x20, 0xdd, 0xc1, 0x2a, 0x0c, 0x9f, + 0x90, 0x5b, 0x67, 0x75, 0xf2, 0x26, 0x78, 0xa1, 0x21, 0xb2, 0x93, 0x21, 0xdf, 0x32, 0x0e, 0x2c, 0xdd, 0xc0, 0xfe, + 0x5a, 0x95, 0x64, 0x95, 0x27, 0x6a, 0xaf, 0x52, 0xa6, 0x69, 0x49, 0xc1, 0xf2, 0x29, 0xb3, 0x07, 0x47, 0x5e, 0xf3, + 0x65, 0x73, 0xeb, 0x9b, 0x77, 0x4f, 0x9d, 0xf5, 0xd0, 0x2e, 0x76, 0xbd, 0xb5, 0x29, 0x9c, 0xe0, 0x23, 0x49, 0xfc, + 0x50, 0xfb, 0xd9, 0x7e, 0xb0, 0x71, 0xff, 0xa4, 0xf6, 0x03, 0xce, 0xec, 0x53, 0x74, 0x98, 0x87, 0x49, 0x9f, 0x15, + 0x24, 0x1c, 0xd0, 0xba, 0x8f, 0x45, 0xa6, 0xc0, 0x4e, 0x03, 0x9c, 0x40, 0x8d, 0xd8, 0xe3, 0x22, 0x07, 0xf4, 0xa6, + 0x6a, 0x6a, 0x31, 0xdf, 0xd3, 0x91, 0x3b, 0x9c, 0x62, 0x06, 0xbf, 0x68, 0xd8, 0xd2, 0xbc, 0xfa, 0xb8, 0xad, 0x1b, + 0xf4, 0x1c, 0xa4, 0x48, 0xdc, 0x20, 0xa6, 0x49, 0xf7, 0x15, 0x7a, 0xea, 0xeb, 0x37, 0xb9, 0x1d, 0xf7, 0x1d, 0xa7, + 0x8d, 0x76, 0x1b, 0xee, 0x62, 0x95, 0x4d, 0x3b, 0xa4, 0xa3, 0x06, 0xea, 0x4b, 0xff, 0x64, 0x45, 0xa7, 0xa7, 0x29, + 0x42, 0x57, 0x62, 0xdb, 0x04, 0x60, 0x72, 0x50, 0xd8, 0x59, 0x20, 0x09, 0x36, 0x38, 0x71, 0x2c, 0x13, 0x8d, 0xec, + 0x85, 0xbe, 0xda, 0xed, 0x18, 0x18, 0xf8, 0xb9, 0x27, 0xd1, 0x6f, 0xef, 0x2c, 0x52, 0x34, 0x6b, 0x19, 0x7e, 0x65, + 0x22, 0x45, 0x1f}; // Backwards compatibility alias #define INDEX_GZ INDEX_BR From 4f3db4c15a9bcf31128a6f2b3bfc0344875f3aec Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:32:52 +1200 Subject: [PATCH 1121/1815] Bump version to 2026.7.3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 0dd5e208d4..81bd29ec1b 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.7.2 +PROJECT_NUMBER = 2026.7.3 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index d351ce286f..7e90790405 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.7.2" +__version__ = "2026.7.3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 95786459c19f84b8302a2ab6daefa72ebc6dda22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Tue, 28 Jul 2026 12:54:58 +0300 Subject: [PATCH 1122/1815] [ble_device_base] Replace raw-advertisement std::function with a lightweight callback slot (#17902) --- .../bk72xx_ble_tracker/bk72xx_ble_tracker.cpp | 10 +- .../bk72xx_ble_tracker/bk72xx_ble_tracker.h | 7 +- esphome/components/ble_device_base/ble_hub.h | 35 ++++-- .../esp32_ble_tracker/esp32_ble_tracker.h | 6 +- .../ble_device_base/test_raw_callback.cpp | 101 ++++++++++++++++++ 5 files changed, 143 insertions(+), 16 deletions(-) create mode 100644 tests/components/ble_device_base/test_raw_callback.cpp diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp index 8d7199bd0a..634285d530 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp @@ -123,8 +123,14 @@ void BK72xxBLETracker::dump_config() { void BK72xxBLETracker::on_scan_report(const bk72xx_ble::BLEScanReport &report) { // Raw callback (the raw-advertisement path). - if (this->raw_advertisement_callback_) - this->raw_advertisement_callback_(report.mac, report.rssi, report.addr_type, report.data, report.data_len); + if (this->raw_advertisement_callback_.is_set()) { + const ble_device_base::RawAdvertisement adv{.mac = report.mac, + .data = report.data, + .data_len = report.data_len, + .rssi = report.rssi, + .addr_type = report.addr_type}; + this->raw_advertisement_callback_.invoke(adv); + } #ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT ble_device_base::ESPBTDevice device; diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h index b8d0b31e6a..9c70246f05 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h @@ -33,7 +33,6 @@ #include "esphome/core/helpers.h" #include -#include #ifdef USE_OTA_STATE_LISTENER #include "esphome/components/ota/ota_backend.h" @@ -84,8 +83,8 @@ class BK72xxBLETracker : public Component, this->listeners_.push_back(listener); #endif } - void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback cb) override { - this->raw_advertisement_callback_ = std::move(cb); + void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override { + this->raw_advertisement_callback_ = callback; } ble_device_base::HubCapabilities get_capabilities() const override { // The Beken BDK exposes no active-scan path (passive scanning only), so the @@ -131,7 +130,7 @@ class BK72xxBLETracker : public Component, uint32_t scan_period_start_{0}; // millis() at start of current scan period; used to rate-limit on_scan_end() bool scan_started_once_{false}; // true after first successful scan start; gates the period timer - ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{nullptr}; + ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{}; #ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT // Parsed-advertisement consumers registered through ble_device_base. // Codegen-sized: no heap allocation, no std::vector template instantiations. diff --git a/esphome/components/ble_device_base/ble_hub.h b/esphome/components/ble_device_base/ble_hub.h index 0f3f4de88d..c02b491237 100644 --- a/esphome/components/ble_device_base/ble_hub.h +++ b/esphome/components/ble_device_base/ble_hub.h @@ -17,15 +17,36 @@ #include "ble_device.h" #include -#include namespace esphome::ble_device_base { -/// Callback for raw advertisements (the bluetooth_proxy path). -/// mac[] is least-significant octet first (BLE controller convention); -/// the hub delivers on the ESPHome main loop. -using RawAdvertisementCallback = - std::function; +/// One raw advertisement as delivered by the controller — a borrowed view, +/// valid only for the duration of the invoke() callback. +struct RawAdvertisement { + /// Least-significant octet first (BLE controller convention). + const uint8_t *mac; + const uint8_t *data; + uint16_t data_len; + int8_t rssi; // signed dBm + uint8_t addr_type; +}; + +/// Subscriber slot for the raw-advertisement stream (the bluetooth_proxy +/// path). The hub delivers on the ESPHome main loop. Same shape as +/// logger.h's LogCallback: an instance pointer plus a plain function +/// pointer — no virtuals, no std::function. +/// +/// Usage: +/// hub->set_raw_advertisement_callback({this, [](void *self, const RawAdvertisement &adv) { +/// static_cast(self)->on_raw_advertisement(adv); +/// }}); +struct RawAdvertisementCallback { + void *instance{nullptr}; + void (*fn)(void *instance, const RawAdvertisement &adv){nullptr}; + /// A default-constructed slot is "no subscriber"; hubs must guard on this. + bool is_set() const { return this->fn != nullptr; } + void invoke(const RawAdvertisement &adv) const { this->fn(this->instance, adv); } +}; /// What a tracker's controller/SDK can do — consumers branch on data, not #ifdefs. struct HubCapabilities { @@ -48,7 +69,7 @@ class BLEHub { virtual void register_listener(ESPBTDeviceListener *listener) = 0; /// Wire the raw-advertisement stream (bluetooth_proxy). One consumer at a time. - virtual void set_raw_advertisement_callback(RawAdvertisementCallback cb) = 0; + virtual void set_raw_advertisement_callback(RawAdvertisementCallback callback) = 0; virtual HubCapabilities get_capabilities() const = 0; diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 01ae22e710..b0357289f1 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -239,8 +239,8 @@ class ESP32BLETracker final : public Component, // ---- ble_device_base::BLEHub (the platform-neutral tracker contract) ---- void register_listener(ble_device_base::ESPBTDeviceListener *listener) override; - void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback cb) override { - this->raw_advertisement_callback_ = std::move(cb); + void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override { + this->raw_advertisement_callback_ = callback; } ble_device_base::HubCapabilities get_capabilities() const override { return {/* active_scan = */ true, /* merges_scan_response = */ true, /* gatt = */ true}; @@ -341,7 +341,7 @@ class ESP32BLETracker final : public Component, #ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT StaticVector neutral_listeners_; #endif - ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{nullptr}; + ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{}; #ifdef USE_ESP32_BLE_DEVICE /// Per-period "Found device" DEBUG log with MAC dedup (shared ble_device_base impl) ble_device_base::DiscoveredDeviceLog discovered_log_; diff --git a/tests/components/ble_device_base/test_raw_callback.cpp b/tests/components/ble_device_base/test_raw_callback.cpp new file mode 100644 index 0000000000..cd18c3db59 --- /dev/null +++ b/tests/components/ble_device_base/test_raw_callback.cpp @@ -0,0 +1,101 @@ +#include + +#include + +#include "esphome/components/ble_device_base/ble_hub.h" + +namespace esphome::ble_device_base::testing { + +// Exercises the hub contract around RawAdvertisementCallback, not just the +// struct: a hub stores one slot via set_raw_advertisement_callback(), fires it +// only when set ("no subscriber" is the default-constructed slot), and a new +// registration replaces the old ("one consumer at a time"). +// +// The in-tree emit site (BK72xxBLETracker::on_scan_report) compiles against +// the Beken SDK and cannot run host-side, so the guard-and-fire semantics are +// pinned here through a minimal host BLEHub implementation instead. +namespace { + +class FakeHub : public BLEHub { + public: + void register_listener(ESPBTDeviceListener *listener) override {} + void set_raw_advertisement_callback(RawAdvertisementCallback callback) override { this->callback_ = callback; } + HubCapabilities get_capabilities() const override { return {false, false, false}; } + void get_adapter_mac(uint8_t out[6]) override {} + bool scan_running() override { return false; } + bool scan_active() override { return false; } + + /// The emit path every tracker implements: fire only when a subscriber is set. + void emit(const RawAdvertisement &adv) { + if (this->callback_.is_set()) + this->callback_.invoke(adv); + } + + protected: + RawAdvertisementCallback callback_; // default-constructed: no subscriber +}; + +struct CapturingSubscriber { + RawAdvertisement last{}; + int calls{0}; + + static void trampoline(void *self, const RawAdvertisement &adv) { + auto *sub = static_cast(self); + sub->last = adv; + sub->calls++; + } +}; + +// Device AA:BB:CC:DD:EE:FF — controller order delivers FF first. +const uint8_t MAC_LSB_FIRST[6] = {0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa}; +const uint8_t ADV_DATA[4] = {0x02, 0x01, 0x06, 0x00}; + +RawAdvertisement make_test_adv() { + return RawAdvertisement{ + .mac = MAC_LSB_FIRST, .data = ADV_DATA, .data_len = sizeof(ADV_DATA), .rssi = -63, .addr_type = 1}; +} + +} // namespace + +TEST(RawAdvertisementCallback, DefaultConstructedSlotIsNotSet) { + const RawAdvertisementCallback callback{}; + EXPECT_FALSE(callback.is_set()); +} + +TEST(RawAdvertisementCallback, SubscriberSeesFieldsUnchanged) { + FakeHub hub; + CapturingSubscriber subscriber; + hub.set_raw_advertisement_callback({&subscriber, CapturingSubscriber::trampoline}); + + hub.emit(make_test_adv()); + + ASSERT_EQ(subscriber.calls, 1); + EXPECT_EQ(subscriber.last.mac, MAC_LSB_FIRST); + EXPECT_EQ(subscriber.last.data, ADV_DATA); + EXPECT_EQ(subscriber.last.data_len, sizeof(ADV_DATA)); + EXPECT_EQ(subscriber.last.rssi, -63); + EXPECT_EQ(subscriber.last.addr_type, 1); +} + +TEST(RawAdvertisementCallback, NoSubscriberDoesNotFire) { + FakeHub hub; + // No set_raw_advertisement_callback(): emitting must be a guarded no-op, + // not a jump through a garbage pointer. + hub.emit(make_test_adv()); +} + +TEST(RawAdvertisementCallback, NewSubscriberReplacesOld) { + FakeHub hub; + CapturingSubscriber first; + CapturingSubscriber second; + hub.set_raw_advertisement_callback({&first, CapturingSubscriber::trampoline}); + hub.set_raw_advertisement_callback({&second, CapturingSubscriber::trampoline}); + + hub.emit(make_test_adv()); + + EXPECT_EQ(first.calls, 0); // one consumer at a time + ASSERT_EQ(second.calls, 1); + EXPECT_EQ(second.last.rssi, -63); +} + +} // namespace esphome::ble_device_base::testing From a4611907a9f740cb4f5f43ac6d0fccc281bcaa1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Tue, 28 Jul 2026 15:23:01 +0300 Subject: [PATCH 1123/1815] [captive_portal] Re-apply json_escape move lost in release merge (#17906) --- .../captive_portal/captive_portal.cpp | 1 - .../components/captive_portal/json_escape.h | 85 -------------- tests/components/captive_portal/__init__.py | 23 ---- .../captive_portal/json_escape_test.cpp | 107 ------------------ 4 files changed, 216 deletions(-) delete mode 100644 esphome/components/captive_portal/json_escape.h delete mode 100644 tests/components/captive_portal/__init__.py delete mode 100644 tests/components/captive_portal/json_escape_test.cpp diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 5716346d07..8094903008 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -6,7 +6,6 @@ #include "esphome/core/string_ref.h" #include "esphome/components/wifi/wifi_component.h" #include "captive_index.h" -#include "json_escape.h" namespace esphome::captive_portal { diff --git a/esphome/components/captive_portal/json_escape.h b/esphome/components/captive_portal/json_escape.h deleted file mode 100644 index 0b3c71cd74..0000000000 --- a/esphome/components/captive_portal/json_escape.h +++ /dev/null @@ -1,85 +0,0 @@ -#pragma once -#include -#include -#include - -#include "esphome/core/helpers.h" -#include "esphome/core/string_ref.h" - -namespace esphome::captive_portal { - -/// Largest number of output bytes a single input byte can expand to (a \u00XX sequence). -static constexpr size_t JSON_ESCAPE_MAX_EXPANSION = 6; - -/// Copy value into buf, escaping the characters that cannot appear raw inside a JSON string literal. -/// -/// Escapes " and \ along with the control characters below 0x20, using the short forms where JSON defines one and -/// \u00XX otherwise. Bytes >= 0x20 are copied verbatim, so text containing valid UTF-8 survives intact. The result is -/// always null terminated; anything that would not fit is dropped rather than written partially. Returns buf so the -/// call can be used directly as an argument. -/// -/// To size buf so that no input is ever dropped, allow JSON_ESCAPE_MAX_EXPANSION bytes per input byte plus one for -/// the null terminator. -inline const char *json_escape_into_buffer(std::span buf, StringRef value) { - if (buf.empty()) - return ""; - // Reserve one byte for the null terminator. - const size_t limit = buf.size() - 1; - size_t pos = 0; - for (char ch : value) { - auto c = static_cast(ch); - // Every short form is a backslash followed by a single character, so only that character is needed here. Keeping - // it a char rather than a string avoids putting the sequences in read only data, which is RAM on the ESP8266. - char escape = '\0'; - switch (c) { - case '"': - escape = '"'; - break; - case '\\': - escape = '\\'; - break; - case '\n': - escape = 'n'; - break; - case '\r': - escape = 'r'; - break; - case '\t': - escape = 't'; - break; - case '\b': - escape = 'b'; - break; - case '\f': - escape = 'f'; - break; - default: - break; - } - if (escape != '\0') { - if (pos + 2 > limit) - break; - buf[pos++] = '\\'; - buf[pos++] = escape; - } else if (c < 0x20) { - // Remaining control characters have no short form and must be written as \u00XX. The value is below 0x20, so - // the two high hex digits are always zero. - if (pos + JSON_ESCAPE_MAX_EXPANSION > limit) - break; - buf[pos++] = '\\'; - buf[pos++] = 'u'; - buf[pos++] = '0'; - buf[pos++] = '0'; - buf[pos++] = format_hex_char(static_cast(c >> 4)); - buf[pos++] = format_hex_char(static_cast(c & 0x0F)); - } else { - if (pos + 1 > limit) - break; - buf[pos++] = static_cast(c); - } - } - buf[pos] = '\0'; - return buf.data(); -} - -} // namespace esphome::captive_portal diff --git a/tests/components/captive_portal/__init__.py b/tests/components/captive_portal/__init__.py deleted file mode 100644 index b13c81912c..0000000000 --- a/tests/components/captive_portal/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Test-manifest overrides for the captive_portal C++ unit tests. - -``json_escape`` lives in a standalone, dependency-free header -(``esphome/components/captive_portal/json_escape.h``). The rest of the -captive_portal component and its auto-loaded dependencies (``web_server_base``, -``ota.web_server``) do not build for the ``host`` platform that the C++ unit -test harness targets. Strip those away and replace the real schema -- which is -restricted to non-host platforms via ``cv.only_on`` and requires a -``web_server_base`` instance via ``use_id`` -- with an empty one so the host -test config validates. ``to_code`` stays suppressed (the default), so -``USE_CAPTIVE_PORTAL`` is never defined and ``captive_portal.cpp`` compiles to an -empty translation unit; only ``json_escape.h`` is exercised by the test. -""" - -import esphome.config_validation as cv -from tests.testing_helpers import ComponentManifestOverride - - -def override_manifest(manifest: ComponentManifestOverride) -> None: - manifest.auto_load = [] - manifest.dependencies = [] - manifest.config_schema = cv.Schema({}) - manifest.final_validate_schema = None diff --git a/tests/components/captive_portal/json_escape_test.cpp b/tests/components/captive_portal/json_escape_test.cpp deleted file mode 100644 index 98b5ce4ff7..0000000000 --- a/tests/components/captive_portal/json_escape_test.cpp +++ /dev/null @@ -1,107 +0,0 @@ -#include - -#include - -#include "esphome/components/captive_portal/json_escape.h" - -namespace esphome::captive_portal::testing { - -namespace { - -// Large enough that none of the inputs below are ever dropped. -constexpr size_t TEST_BUFFER_SIZE = 64 * JSON_ESCAPE_MAX_EXPANSION + 1; - -// Escape into a stack buffer and return the result as a string so the expectations stay readable. -std::string escape(const std::string &value) { - char buf[TEST_BUFFER_SIZE]; - return json_escape_into_buffer(buf, StringRef(value.c_str(), value.size())); -} - -} // namespace - -// Plain ASCII with no special characters is passed through unchanged. -TEST(CaptivePortalJsonEscape, PlainStringUnchanged) { - EXPECT_EQ(escape("MyNetwork"), "MyNetwork"); - EXPECT_EQ(escape(""), ""); -} - -// A double quote is escaped so it does not terminate the surrounding JSON string. -TEST(CaptivePortalJsonEscape, EscapesDoubleQuote) { - EXPECT_EQ(escape("a\"b"), "a\\\"b"); - // A double quote followed by other characters stays inside the JSON string. - EXPECT_EQ(escape("\">end"), "\\\">end"); -} - -// A backslash is doubled so it does not start an escape sequence in the output. -TEST(CaptivePortalJsonEscape, EscapesBackslash) { - EXPECT_EQ(escape("a\\b"), "a\\\\b"); - // A trailing backslash must not escape the closing quote of the JSON string. - EXPECT_EQ(escape("net\\"), "net\\\\"); -} - -// The control characters with short JSON forms use those forms. -TEST(CaptivePortalJsonEscape, EscapesShortFormControls) { - EXPECT_EQ(escape("\n"), "\\n"); - EXPECT_EQ(escape("\r"), "\\r"); - EXPECT_EQ(escape("\t"), "\\t"); - EXPECT_EQ(escape("\b"), "\\b"); - EXPECT_EQ(escape("\f"), "\\f"); -} - -// Other control characters (< 0x20) without a short form become \u00XX with lowercase hex. -TEST(CaptivePortalJsonEscape, EscapesOtherControlsAsUnicode) { - EXPECT_EQ(escape(std::string("\x00", 1)), "\\u0000"); - EXPECT_EQ(escape("\x01"), "\\u0001"); - EXPECT_EQ(escape("\x10"), "\\u0010"); - EXPECT_EQ(escape("\x1f"), "\\u001f"); - // 0x7f (DEL) is >= 0x20, so it is NOT escaped by this helper. - EXPECT_EQ(escape("\x7f"), "\x7f"); -} - -// Bytes >= 0x20, including multi-byte UTF-8 sequences, are passed through verbatim. -TEST(CaptivePortalJsonEscape, PassesThroughUtf8) { - // "café" in UTF-8 (é == 0xC3 0xA9). - EXPECT_EQ(escape("caf\xc3\xa9"), "caf\xc3\xa9"); - // Emoji (📶, 4-byte UTF-8) survives unchanged. - EXPECT_EQ(escape("\xf0\x9f\x93\xb6"), "\xf0\x9f\x93\xb6"); -} - -// A mix of special and normal characters is escaped in place without disturbing the rest. -TEST(CaptivePortalJsonEscape, MixedContent) { EXPECT_EQ(escape("a\"b\\c\nd"), "a\\\"b\\\\c\\nd"); } - -// A buffer sized at JSON_ESCAPE_MAX_EXPANSION bytes per input byte holds the worst case exactly. -TEST(CaptivePortalJsonEscape, WorstCaseInputFitsExactly) { - constexpr size_t input_len = 8; - char buf[input_len * JSON_ESCAPE_MAX_EXPANSION + 1]; - const std::string input(input_len, '\x01'); - std::string expected; - for (size_t i = 0; i < input_len; i++) - expected += "\\u0001"; - EXPECT_EQ(json_escape_into_buffer(buf, StringRef(input.c_str(), input.size())), expected); -} - -// An escape sequence that would not fit is dropped whole rather than written partially, and the result stays null -// terminated. -TEST(CaptivePortalJsonEscape, DropsEscapeThatWouldNotFit) { - // Room for one \u00XX sequence plus the null terminator, but two are requested. - char buf[JSON_ESCAPE_MAX_EXPANSION + 1]; - const std::string input(2, '\x01'); - const std::string result = json_escape_into_buffer(buf, StringRef(input.c_str(), input.size())); - EXPECT_EQ(result, "\\u0001"); - EXPECT_EQ(buf[JSON_ESCAPE_MAX_EXPANSION], '\0'); -} - -// Plain characters are truncated at the buffer size, leaving room for the null terminator. -TEST(CaptivePortalJsonEscape, TruncatesPlainInput) { - char buf[5]; - const std::string input(20, 'a'); - EXPECT_STREQ(json_escape_into_buffer(buf, StringRef(input.c_str(), input.size())), "aaaa"); -} - -// A zero length buffer cannot even hold a null terminator, so an empty string is returned instead of writing. -TEST(CaptivePortalJsonEscape, EmptyBufferIsSafe) { - const std::string input("test"); - EXPECT_STREQ(json_escape_into_buffer(std::span(), StringRef(input.c_str(), input.size())), ""); -} - -} // namespace esphome::captive_portal::testing From b80fa4ae19d0cd855ad4fa68215a84d0a41e2ba7 Mon Sep 17 00:00:00 2001 From: Kobi Hikri Date: Tue, 28 Jul 2026 19:17:50 +0300 Subject: [PATCH 1124/1815] [ci] Bind the branch name to env before sanitising it into a docker tag (#17910) --- .github/workflows/ci-docker.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index da2d86f041..96bf952965 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -71,11 +71,13 @@ jobs: - name: Determine tag and whether to push id: tag + env: + HEAD_REF: ${{ github.head_ref || github.ref_name }} run: | # Sanitize the branch name into a valid docker tag: replace invalid # characters, ensure the first character is valid (tags must start # with [A-Za-z0-9_]), and cap the length at 128 characters. - branch="${{ github.head_ref || github.ref_name }}" + branch="$HEAD_REF" tag="${branch//[^a-zA-Z0-9_.-]/-}" case "$tag" in [a-zA-Z0-9_]*) ;; From 7b1d70c2c0a806f2c9f99a61d484c94b61e5b1df Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:28:01 -0400 Subject: [PATCH 1125/1815] Bump actions/stale from 10.4.0 to 11.0.0 (#17913) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index ef79b2705a..6c90c2ee97 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Stale - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 + uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 with: debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch remove-stale-when-updated: true From 324e222bd2bbbcfa75f740fe1c2c662bc15c07f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Jul 2026 09:38:29 -1000 Subject: [PATCH 1126/1815] [improv_serial] Reduce per-loop overhead (#16019) --- .../improv_serial/improv_serial_component.cpp | 62 +++---------------- .../improv_serial/improv_serial_component.h | 49 ++++++++++++++- 2 files changed, 57 insertions(+), 54 deletions(-) diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index 4ee703f363..a191889138 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -16,6 +16,7 @@ void ImprovSerialComponent::setup() { global_improv_serial_component = this; #ifdef USE_ESP32 this->uart_num_ = logger::global_logger->get_uart_num(); + this->uart_selection_ = logger::global_logger->get_uart(); #elif defined(USE_ARDUINO) this->hw_serial_ = logger::global_logger->get_hw_serial(); #endif @@ -30,21 +31,23 @@ void ImprovSerialComponent::setup() { } void ImprovSerialComponent::loop() { - if (this->last_read_byte_ && (millis() - this->last_read_byte_ > IMPROV_SERIAL_TIMEOUT)) { + const uint32_t now = App.get_loop_component_start_time(); + if (this->last_read_byte_ && (now - this->last_read_byte_ > IMPROV_SERIAL_TIMEOUT)) { this->last_read_byte_ = 0; this->rx_buffer_.clear(); ESP_LOGV(TAG, "Timeout"); } - auto byte = this->read_byte_(); - while (byte.has_value()) { + while (true) { + auto byte = this->read_byte_(); + if (!byte.has_value()) + break; if (this->parse_improv_serial_byte_(byte.value())) { - this->last_read_byte_ = millis(); + this->last_read_byte_ = now; } else { this->last_read_byte_ = 0; this->rx_buffer_.clear(); } - byte = this->read_byte_(); } if (this->state_ == improv::STATE_PROVISIONING) { @@ -63,53 +66,6 @@ void ImprovSerialComponent::loop() { void ImprovSerialComponent::dump_config() { ESP_LOGCONFIG(TAG, "Improv Serial:"); } -optional ImprovSerialComponent::read_byte_() { - optional byte; - uint8_t data = 0; -#ifdef USE_ESP32 - switch (logger::global_logger->get_uart()) { - case logger::UART_SELECTION_UART0: - case logger::UART_SELECTION_UART1: -#if defined(USE_ESP32_VARIANT_ESP32) - case logger::UART_SELECTION_UART2: -#endif - if (this->uart_num_ >= 0) { - size_t available; - uart_get_buffered_data_len(this->uart_num_, &available); - if (available) { - uart_read_bytes(this->uart_num_, &data, 1, 0); - byte = data; - } - } - break; -#if defined(USE_LOGGER_USB_CDC) && defined(CONFIG_ESP_CONSOLE_USB_CDC) - case logger::UART_SELECTION_USB_CDC: - if (esp_usb_console_available_for_read()) { - esp_usb_console_read_buf((char *) &data, 1); - byte = data; - } - break; -#endif // USE_LOGGER_USB_CDC -#ifdef USE_LOGGER_USB_SERIAL_JTAG - case logger::UART_SELECTION_USB_SERIAL_JTAG: { - if (usb_serial_jtag_read_bytes((char *) &data, 1, 0)) { - byte = data; - } - break; - } -#endif // USE_LOGGER_USB_SERIAL_JTAG - default: - break; - } -#elif defined(USE_ARDUINO) - if (this->hw_serial_->available()) { - this->hw_serial_->readBytes(&data, 1); - byte = data; - } -#endif - return byte; -} - void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size) { // First, set length field this->tx_header_[TX_LENGTH_IDX] = this->tx_header_[TX_TYPE_IDX] == TYPE_RPC_RESPONSE ? size : 1; @@ -133,7 +89,7 @@ void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size) this->tx_header_[TX_CHECKSUM_IDX] = checksum; #ifdef USE_ESP32 - switch (logger::global_logger->get_uart()) { + switch (this->uart_selection_) { case logger::UART_SELECTION_UART0: case logger::UART_SELECTION_UART1: #if defined(USE_ESP32_VARIANT_ESP32) diff --git a/esphome/components/improv_serial/improv_serial_component.h b/esphome/components/improv_serial/improv_serial_component.h index 4df6f6df2d..00c40c4c7e 100644 --- a/esphome/components/improv_serial/improv_serial_component.h +++ b/esphome/components/improv_serial/improv_serial_component.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/components/improv_base/improv_base.h" +#include "esphome/components/logger/logger.h" #include "esphome/components/wifi/wifi_component.h" #include "esphome/core/component.h" #include "esphome/core/defines.h" @@ -65,7 +66,52 @@ class ImprovSerialComponent final : public Component, public improv_base::Improv std::vector build_rpc_settings_response_(improv::Command command); std::vector build_version_info_(); - optional read_byte_(); + ESPHOME_ALWAYS_INLINE optional read_byte_() { + optional byte; + uint8_t data = 0; +#ifdef USE_ESP32 + switch (this->uart_selection_) { + case logger::UART_SELECTION_UART0: + case logger::UART_SELECTION_UART1: +#if defined(USE_ESP32_VARIANT_ESP32) + case logger::UART_SELECTION_UART2: +#endif + if (this->uart_num_ >= 0) { + size_t available; + uart_get_buffered_data_len(this->uart_num_, &available); + if (available) { + uart_read_bytes(this->uart_num_, &data, 1, 0); + byte = data; + } + } + break; +#if defined(USE_LOGGER_USB_CDC) && defined(CONFIG_ESP_CONSOLE_USB_CDC) + case logger::UART_SELECTION_USB_CDC: + if (esp_usb_console_available_for_read()) { + esp_usb_console_read_buf((char *) &data, 1); + byte = data; + } + break; +#endif +#ifdef USE_LOGGER_USB_SERIAL_JTAG + case logger::UART_SELECTION_USB_SERIAL_JTAG: { + if (usb_serial_jtag_read_bytes((char *) &data, 1, 0)) { + byte = data; + } + break; + } +#endif + default: + break; + } +#elif defined(USE_ARDUINO) + if (this->hw_serial_->available()) { + this->hw_serial_->readBytes(&data, 1); + byte = data; + } +#endif + return byte; + } void write_data_(const uint8_t *data = nullptr, size_t size = 0); uint8_t tx_header_[TX_BUFFER_SIZE] = { @@ -85,6 +131,7 @@ class ImprovSerialComponent final : public Component, public improv_base::Improv #ifdef USE_ESP32 uart_port_t uart_num_; + logger::UARTSelection uart_selection_{logger::UART_SELECTION_UART0}; #elif defined(USE_ARDUINO) Stream *hw_serial_{nullptr}; #endif From 72f904dcfbb119c9454f440e313416f828f8ee35 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:41:50 -0400 Subject: [PATCH 1127/1815] Bump docker/login-action from 4.5.1 to 4.5.2 in the docker-actions group (#17912) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-docker.yml | 4 ++-- .github/workflows/release.yml | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 96bf952965..f127b43c70 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -98,7 +98,7 @@ jobs: - name: Log in to the GitHub container registry if: steps.tag.outputs.push == 'true' - uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 + uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 with: registry: ghcr.io username: ${{ github.actor }} @@ -156,7 +156,7 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to the GitHub container registry - uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 + uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d7326588f..3f2f4b56e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,12 +102,12 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to docker hub - uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 + uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry - uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 + uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 with: registry: ghcr.io username: ${{ github.actor }} @@ -182,13 +182,13 @@ jobs: - name: Log in to docker hub if: matrix.registry == 'dockerhub' - uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 + uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry if: matrix.registry == 'ghcr' - uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 + uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 with: registry: ghcr.io username: ${{ github.actor }} From 227f1d384278a4038f3ed2ceff6f83ded13edc8a Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:32:59 -0500 Subject: [PATCH 1128/1815] [core] Reject configs that set both keys in cv.rename_key (#17916) --- esphome/config_validation.py | 4 ++++ tests/unit_tests/test_config_validation.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index ef250927f3..a62c8f2675 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -2723,6 +2723,9 @@ def rename_key( ): """Rename a config key from ``old_key`` to ``new_key``. + Specifying both keys is an error; otherwise only one of the two would + survive the rename and the other would be dropped silently. + When ``removed_in`` is set, a deprecation warning is logged if the old key is present. Pass ``component`` (the platform/component name) alongside ``removed_in`` so the warning identifies where it originates. @@ -2731,6 +2734,7 @@ def rename_key( def validator(config: dict) -> dict: config = config.copy() if old_key in config: + has_at_most_one_key(old_key, new_key)(config) if removed_in is not None: prefix = f"[{component}] " if component else "" _LOGGER.warning( diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 2ad22bc59c..864fbe6475 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -2956,6 +2956,22 @@ def test_rename_key_removed_in_with_component_prefixes_warning( ) +def test_rename_key_both_keys_rejected() -> None: + with pytest.raises(Invalid, match="Cannot specify more than one of"): + cv.rename_key("old", "new")({"old": 5, "new": 6}) + + +def test_rename_key_both_keys_rejected_with_removed_in( + caplog: pytest.LogCaptureFixture, +) -> None: + with ( + caplog.at_level(logging.WARNING, logger="esphome.config_validation"), + pytest.raises(Invalid, match="Cannot specify more than one of"), + ): + cv.rename_key("old", "new", removed_in="2026.8.0")({"old": 5, "new": 6}) + assert not caplog.records + + def test_file__existing_relative_path(setup_core: Path) -> None: (setup_core / "partitions.csv").write_text("csv\n") From 6ad804fbc57c2505bf684836e83e9006eab0aacd Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:35:07 -0500 Subject: [PATCH 1129/1815] [sgp4x] Rename voc/nox sensor keys to voc_index/nox_index (#17723) --- esphome/components/const/__init__.py | 2 ++ esphome/components/sgp4x/sensor.py | 27 +++++++++++++++------------ tests/components/sgp4x/common.yaml | 6 +++--- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 7e46e81c69..7476385563 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -24,6 +24,7 @@ CONF_IAQ = "iaq" CONF_IGNORE_NOT_FOUND = "ignore_not_found" CONF_LIBRETINY = "libretiny" CONF_LOOP = "loop" +CONF_NOX_INDEX = "nox_index" CONF_ON_PACKET = "on_packet" CONF_ON_RECEIVE = "on_receive" CONF_ON_STATE_CHANGE = "on_state_change" @@ -36,6 +37,7 @@ CONF_SHA256 = "sha256" CONF_STATE_SAVE_INTERVAL = "state_save_interval" CONF_STOP_BITS = "stop_bits" CONF_USE_PSRAM = "use_psram" +CONF_VOC_INDEX = "voc_index" CONF_VOLUME_INCREMENT = "volume_increment" CONF_VOLUME_INITIAL = "volume_initial" CONF_VOLUME_MAX = "volume_max" diff --git a/esphome/components/sgp4x/sensor.py b/esphome/components/sgp4x/sensor.py index d407f20a4e..87ef050bc1 100644 --- a/esphome/components/sgp4x/sensor.py +++ b/esphome/components/sgp4x/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensirion_common, sensor +from esphome.components.const import CONF_NOX_INDEX, CONF_VOC_INDEX import esphome.config_validation as cv from esphome.const import ( CONF_ALGORITHM_TUNING, @@ -35,9 +36,9 @@ CONF_HUMIDITY_SOURCE = "humidity_source" def validate_sensors(config): - if CONF_VOC not in config and CONF_NOX not in config: + if CONF_VOC_INDEX not in config and CONF_NOX_INDEX not in config: raise cv.Invalid( - f"At least one sensor is required. Define {CONF_VOC} and/or {CONF_NOX}" + f"At least one sensor is required. Define {CONF_VOC_INDEX} and/or {CONF_NOX_INDEX}" ) return config @@ -65,15 +66,17 @@ VOC_SENSOR = _gas_sensor_schema(100) NOX_SENSOR = _gas_sensor_schema(1) CONFIG_SCHEMA = cv.All( + cv.rename_key(CONF_VOC, CONF_VOC_INDEX, removed_in="2027.2.0", component="sgp4x"), + cv.rename_key(CONF_NOX, CONF_NOX_INDEX, removed_in="2027.2.0", component="sgp4x"), cv.Schema( { cv.GenerateID(): cv.declare_id(SGP4xComponent), - cv.Optional(CONF_VOC): sensor.sensor_schema( + cv.Optional(CONF_VOC_INDEX): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, state_class=STATE_CLASS_MEASUREMENT, ).extend(VOC_SENSOR), - cv.Optional(CONF_NOX): sensor.sensor_schema( + cv.Optional(CONF_NOX_INDEX): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, state_class=STATE_CLASS_MEASUREMENT, @@ -107,11 +110,11 @@ async def to_code(config): cg.add(var.set_store_baseline(config[CONF_STORE_BASELINE])) - if CONF_VOC in config: - sens = await sensor.new_sensor(config[CONF_VOC]) + if CONF_VOC_INDEX in config: + sens = await sensor.new_sensor(config[CONF_VOC_INDEX]) cg.add(var.set_voc_sensor(sens)) - if CONF_ALGORITHM_TUNING in config[CONF_VOC]: - cfg = config[CONF_VOC][CONF_ALGORITHM_TUNING] + if CONF_ALGORITHM_TUNING in config[CONF_VOC_INDEX]: + cfg = config[CONF_VOC_INDEX][CONF_ALGORITHM_TUNING] cg.add( var.set_voc_algorithm_tuning( cfg[CONF_INDEX_OFFSET], @@ -123,11 +126,11 @@ async def to_code(config): ) ) - if CONF_NOX in config: - sens = await sensor.new_sensor(config[CONF_NOX]) + if CONF_NOX_INDEX in config: + sens = await sensor.new_sensor(config[CONF_NOX_INDEX]) cg.add(var.set_nox_sensor(sens)) - if CONF_ALGORITHM_TUNING in config[CONF_NOX]: - cfg = config[CONF_NOX][CONF_ALGORITHM_TUNING] + if CONF_ALGORITHM_TUNING in config[CONF_NOX_INDEX]: + cfg = config[CONF_NOX_INDEX][CONF_ALGORITHM_TUNING] cg.add( var.set_nox_algorithm_tuning( cfg[CONF_INDEX_OFFSET], diff --git a/tests/components/sgp4x/common.yaml b/tests/components/sgp4x/common.yaml index 4edda8fd1b..88b8166876 100644 --- a/tests/components/sgp4x/common.yaml +++ b/tests/components/sgp4x/common.yaml @@ -1,7 +1,7 @@ sensor: - platform: sgp4x i2c_id: i2c_bus - voc: + voc_index: name: VOC Index id: sgp40_voc_index algorithm_tuning: @@ -11,8 +11,8 @@ sensor: gating_max_duration_minutes: 180 std_initial: 50 gain_factor: 230 - nox: - name: NOx + nox_index: + name: NOx Index algorithm_tuning: index_offset: 100 learning_time_offset_hours: 12 From a9df82fa0a1680dab39d8ce0d110cc9a50d5b94c Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:35:34 -0500 Subject: [PATCH 1130/1815] [sen5x] Rename voc/nox sensor keys to voc_index/nox_index (#17724) --- esphome/components/sen5x/sensor.py | 19 +++++++++++-------- tests/components/sen5x/common.yaml | 8 ++++---- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index 3ea526d931..761a1885ea 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -2,6 +2,7 @@ from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import i2c, sensirion_common, sensor +from esphome.components.const import CONF_NOX_INDEX, CONF_VOC_INDEX import esphome.config_validation as cv from esphome.const import ( CONF_ALGORITHM_TUNING, @@ -122,7 +123,9 @@ def float_previously_pct(value): return value -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + cv.rename_key(CONF_VOC, CONF_VOC_INDEX, removed_in="2027.2.0", component="sen5x"), + cv.rename_key(CONF_NOX, CONF_NOX_INDEX, removed_in="2027.2.0", component="sen5x"), cv.Schema( { cv.GenerateID(): cv.declare_id(SEN5XComponent), @@ -154,7 +157,7 @@ CONFIG_SCHEMA = ( state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_AUTO_CLEANING_INTERVAL): cv.update_interval, - cv.Optional(CONF_VOC): _gas_sensor( + cv.Optional(CONF_VOC_INDEX): _gas_sensor( index_offset=100, learning_time_offset=12, learning_time_gain=12, @@ -162,7 +165,7 @@ CONFIG_SCHEMA = ( std_initial=50, gain_factor=230, ), - cv.Optional(CONF_NOX): _gas_sensor( + cv.Optional(CONF_NOX_INDEX): _gas_sensor( index_offset=1, learning_time_offset=12, learning_time_gain=12, @@ -199,7 +202,7 @@ CONFIG_SCHEMA = ( } ) .extend(cv.polling_component_schema("60s")) - .extend(i2c.i2c_device_schema(0x69)) + .extend(i2c.i2c_device_schema(0x69)), ) SENSOR_MAP = { @@ -207,8 +210,8 @@ SENSOR_MAP = { CONF_PM_2_5: "set_pm_2_5_sensor", CONF_PM_4_0: "set_pm_4_0_sensor", CONF_PM_10_0: "set_pm_10_0_sensor", - CONF_VOC: "set_voc_sensor", - CONF_NOX: "set_nox_sensor", + CONF_VOC_INDEX: "set_voc_sensor", + CONF_NOX_INDEX: "set_nox_sensor", CONF_TEMPERATURE: "set_temperature_sensor", CONF_HUMIDITY: "set_humidity_sensor", } @@ -237,7 +240,7 @@ async def to_code(config: ConfigType) -> None: sens = await sensor.new_sensor(cfg) cg.add(getattr(var, funcName)(sens)) - if cfg := config.get(CONF_VOC, {}).get(CONF_ALGORITHM_TUNING): + if cfg := config.get(CONF_VOC_INDEX, {}).get(CONF_ALGORITHM_TUNING): cg.add( var.set_voc_algorithm_tuning( cfg[CONF_INDEX_OFFSET], @@ -248,7 +251,7 @@ async def to_code(config: ConfigType) -> None: cfg[CONF_GAIN_FACTOR], ) ) - if cfg := config.get(CONF_NOX, {}).get(CONF_ALGORITHM_TUNING): + if cfg := config.get(CONF_NOX_INDEX, {}).get(CONF_ALGORITHM_TUNING): cg.add( var.set_nox_algorithm_tuning( cfg[CONF_INDEX_OFFSET], diff --git a/tests/components/sen5x/common.yaml b/tests/components/sen5x/common.yaml index 20f3a1dfd8..5cfff15243 100644 --- a/tests/components/sen5x/common.yaml +++ b/tests/components/sen5x/common.yaml @@ -24,10 +24,10 @@ sensor: name: PM <10µm Weight concentration id: pm_10_0 accuracy_decimals: 1 - nox: - name: NOx - voc: - name: VOC + nox_index: + name: NOx Index + voc_index: + name: VOC Index algorithm_tuning: index_offset: 100 learning_time_offset_hours: 12 From 4c571c3ec735bb018ee4ab5c000ba80f8ddbfbb0 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:35:53 -0500 Subject: [PATCH 1131/1815] [sen6x] Rename voc/nox sensor keys to voc_index/nox_index (#17725) --- esphome/components/sen6x/sensor.py | 16 ++++++++++------ tests/components/sen6x/common.yaml | 8 ++++---- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index 5eb34add65..832a2188ee 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensirion_common, sensor +from esphome.components.const import CONF_NOX_INDEX, CONF_VOC_INDEX import esphome.config_validation as cv from esphome.const import ( CONF_CO2, @@ -41,7 +42,10 @@ SEN6XComponent = sen6x_ns.class_( "SEN6XComponent", cg.PollingComponent, sensirion_common.SensirionI2CDevice ) -CONFIG_SCHEMA = ( + +CONFIG_SCHEMA = cv.All( + cv.rename_key(CONF_VOC, CONF_VOC_INDEX, removed_in="2027.2.0", component="sen6x"), + cv.rename_key(CONF_NOX, CONF_NOX_INDEX, removed_in="2027.2.0", component="sen6x"), cv.Schema( { cv.GenerateID(): cv.declare_id(SEN6XComponent), @@ -89,12 +93,12 @@ CONFIG_SCHEMA = ( device_class=DEVICE_CLASS_HUMIDITY, state_class=STATE_CLASS_MEASUREMENT, ), - cv.Optional(CONF_VOC): sensor.sensor_schema( + cv.Optional(CONF_VOC_INDEX): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, state_class=STATE_CLASS_MEASUREMENT, ), - cv.Optional(CONF_NOX): sensor.sensor_schema( + cv.Optional(CONF_NOX_INDEX): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, state_class=STATE_CLASS_MEASUREMENT, @@ -115,7 +119,7 @@ CONFIG_SCHEMA = ( } ) .extend(cv.polling_component_schema("60s")) - .extend(i2c.i2c_device_schema(0x6B)) + .extend(i2c.i2c_device_schema(0x6B)), ) SENSOR_MAP = { @@ -125,8 +129,8 @@ SENSOR_MAP = { CONF_PM_10_0: "set_pm_10_0_sensor", CONF_TEMPERATURE: "set_temperature_sensor", CONF_HUMIDITY: "set_humidity_sensor", - CONF_VOC: "set_voc_sensor", - CONF_NOX: "set_nox_sensor", + CONF_VOC_INDEX: "set_voc_sensor", + CONF_NOX_INDEX: "set_nox_sensor", CONF_CO2: "set_co2_sensor", CONF_FORMALDEHYDE: "set_hcho_sensor", } diff --git a/tests/components/sen6x/common.yaml b/tests/components/sen6x/common.yaml index 61ff9f1e0c..859e012c4a 100644 --- a/tests/components/sen6x/common.yaml +++ b/tests/components/sen6x/common.yaml @@ -26,10 +26,10 @@ sensor: name: PM <10µm Weight concentration id: sen6x_pm_10_0 accuracy_decimals: 1 - nox: - name: NOx - voc: - name: VOC + nox_index: + name: NOx Index + voc_index: + name: VOC Index co2: name: Carbon Dioxide formaldehyde: From 9af0302bb9b9cc796957f78889062dd27f047bdb Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:47:43 -0500 Subject: [PATCH 1132/1815] [core] Expand AGENTS.md AI contributor guidance (#17741) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> --- AGENTS.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 0c98e5fe8e..b067482d18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -388,7 +388,10 @@ This document provides essential context for AI models interacting with this pro ``` Register with `@automation.register_condition("my_component.is_active", MyCondition, schema)`. +* **Type Hints:** Type-hint all function signatures, including test functions and config validators (e.g. `def validate_x(config: ConfigType) -> ConfigType:`, `def test_x() -> None:`). Import `ConfigType` from `esphome.types`. + * **Configuration Validation:** + * **Reuse existing validators:** Before writing a custom validator, check for an existing one in `config_validation.py` and compose it in `cv.All(...)` rather than duplicating logic across components. For example, rename a config key with `cv.rename_key(CONF_OLD, CONF_NEW, removed_in="2026.6.0")`, and reject mutually-exclusive keys with `cv.has_at_most_one_key(...)` / `cv.has_exactly_one_key(...)`. See how `api` composes `cv.has_exactly_one_key` + `cv.rename_key`. * **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`. * **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`. * **Platform-Specific:** `cv.only_on(["esp32", "esp8266"])`, `esp32.only_on_variant(...)`, `cv.only_on_esp32`, `cv.only_on_esp8266`, `cv.only_on_rp2040`. @@ -401,6 +404,7 @@ This document provides essential context for AI models interacting with this pro .extend(i2c.i2c_device_schema(0x48)) .extend(spi.spi_device_schema(cs_pin_required=True)) ``` + * **Constants:** `esphome/const.py` is frozen — do not add new `CONF_` constants there. Define a component-local constant in the component's own `.py` (as with `CONF_PARAM` above); for a constant shared by multiple components, add it to `esphome/components/const/__init__.py`. CI (`lint_constants_usage`) fails if the same constant is defined in three or more component files. Constants used in core files (i.e. those not under `esphome/components`) may be added to `esphome/const.py` but will require adjustment to the CI validation check. ## 5. Key Files & Entrypoints @@ -491,7 +495,7 @@ This document provides essential context for AI models interacting with this pro 3. **Test:** Create component tests for all supported platforms and run the full test suite locally. 4. **Lint:** Run `pre-commit` to ensure code is compliant. 5. **Commit:** Commit your changes. There is no strict format for commit messages. - 6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title should have a prefix of the component being worked on (e.g., `[display] Fix bug`, `[abc123] Add new component`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template. + 6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title must start with a `[tag]` prefix. For component work, use the component name (e.g., `[display] Fix bug`, `[abc123] Add new component`); for changes to shared/core code that isn't tied to a single component, use `[core]` (e.g., `[core] Add validator`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template. * **Documentation Contributions:** * Documentation is hosted in the separate `esphome/esphome.io` repository. @@ -729,6 +733,16 @@ This document provides essential context for AI models interacting with this pro ``` * **Deprecation Pattern (Python):** + For a renamed config key, use the shared `cv.rename_key` validator with `removed_in` (and `component` for context) — it warns and auto-migrates: + ```python + CONFIG_SCHEMA = cv.All( + cv.rename_key( + CONF_OLD_KEY, CONF_NEW_KEY, removed_in="2026.6.0", component="my_component" + ), + cv.Schema({ ... }), + ) + ``` + For other deprecations, warn manually during validation: ```python # Remove before 2026.6.0 if CONF_OLD_KEY in config: From 4209f242e5bf7dfc837cd7b69946730ea36168e4 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:18:49 -0500 Subject: [PATCH 1133/1815] [mipi_spi] Add Waveshare ESP32-S3-Touch-LCD-3.5B (#17513) --- .../components/mipi_spi/models/waveshare.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/esphome/components/mipi_spi/models/waveshare.py b/esphome/components/mipi_spi/models/waveshare.py index 0caae5b939..bdd0c3c90b 100644 --- a/esphome/components/mipi_spi/models/waveshare.py +++ b/esphome/components/mipi_spi/models/waveshare.py @@ -203,6 +203,54 @@ AXS15231.extend( requires={"psram"}, ) +# Init sequence and pins taken from the vendor demo: +# "ESP32-S3-Touch-LCD-3.5B-Demo/Arduino/examples/09_lvgl_arduino_v8" +# The panel has no reset line (demo passes RST = -1); the sleep-out, display-on +# and first RAM write are issued by the framework, so they are omitted here. +# fmt: off +AXS15231.extend( + "WAVESHARE-ESP32-S3-TOUCH-LCD-3.5B", + width=320, + height=480, + # Vendor demo runs the AXS15231B at 32MHz; the ESP32 SPI clock cannot hit + # that exactly, data sheet says max 50MHz, so use 40MHz (proven on the same controller, JC3248W535) + data_rate="40MHz", + cs_pin=12, + requires={"psram"}, + initsequence=( + (0xBB, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5A, 0xA5), + (0xA0, 0xC0, 0x10, 0x00, 0x02, 0x00, 0x00, 0x04, 0x3F, 0x20, 0x05, 0x3F, 0x3F, 0x00, 0x00, 0x00, 0x00, 0x00), + (0xA2, 0x30, 0x3C, 0x24, 0x14, 0xD0, 0x20, 0xFF, 0xE0, 0x40, 0x19, 0x80, 0x80, 0x80, 0x20, 0xF9, 0x10, 0x02, 0xFF, 0xFF, 0xF0, 0x90, 0x01, 0x32, 0xA0, 0x91, 0xE0, 0x20, 0x7F, 0xFF, 0x00, 0x5A), + (0xD0, 0xE0, 0x40, 0x51, 0x24, 0x08, 0x05, 0x10, 0x01, 0x20, 0x15, 0xC2, 0x42, 0x22, 0x22, 0xAA, 0x03, 0x10, 0x12, 0x60, 0x14, 0x1E, 0x51, 0x15, 0x00, 0x8A, 0x20, 0x00, 0x03, 0x3A, 0x12), + (0xA3, 0xA0, 0x06, 0xAA, 0x00, 0x08, 0x02, 0x0A, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x00, 0x55, 0x55), + (0xC1, 0x31, 0x04, 0x02, 0x02, 0x71, 0x05, 0x24, 0x55, 0x02, 0x00, 0x41, 0x00, 0x53, 0xFF, 0xFF, 0xFF, 0x4F, 0x52, 0x00, 0x4F, 0x52, 0x00, 0x45, 0x3B, 0x0B, 0x02, 0x0D, 0x00, 0xFF, 0x40), + (0xC3, 0x00, 0x00, 0x00, 0x50, 0x03, 0x00, 0x00, 0x00, 0x01, 0x80, 0x01), + (0xC4, 0x00, 0x24, 0x33, 0x80, 0x00, 0xEA, 0x64, 0x32, 0xC8, 0x64, 0xC8, 0x32, 0x90, 0x90, 0x11, 0x06, 0xDC, 0xFA, 0x00, 0x00, 0x80, 0xFE, 0x10, 0x10, 0x00, 0x0A, 0x0A, 0x44, 0x50), + (0xC5, 0x18, 0x00, 0x00, 0x03, 0xFE, 0x3A, 0x4A, 0x20, 0x30, 0x10, 0x88, 0xDE, 0x0D, 0x08, 0x0F, 0x0F, 0x01, 0x3A, 0x4A, 0x20, 0x10, 0x10, 0x00), + (0xC6, 0x05, 0x0A, 0x05, 0x0A, 0x00, 0xE0, 0x2E, 0x0B, 0x12, 0x22, 0x12, 0x22, 0x01, 0x03, 0x00, 0x3F, 0x6A, 0x18, 0xC8, 0x22), + (0xC7, 0x50, 0x32, 0x28, 0x00, 0xA2, 0x80, 0x8F, 0x00, 0x80, 0xFF, 0x07, 0x11, 0x9C, 0x67, 0xFF, 0x24, 0x0C, 0x0D, 0x0E, 0x0F), + (0xC9, 0x33, 0x44, 0x44, 0x01), + (0xCF, 0x2C, 0x1E, 0x88, 0x58, 0x13, 0x18, 0x56, 0x18, 0x1E, 0x68, 0x88, 0x00, 0x65, 0x09, 0x22, 0xC4, 0x0C, 0x77, 0x22, 0x44, 0xAA, 0x55, 0x08, 0x08, 0x12, 0xA0, 0x08), + (0xD5, 0x40, 0x8E, 0x8D, 0x01, 0x35, 0x04, 0x92, 0x74, 0x04, 0x92, 0x74, 0x04, 0x08, 0x6A, 0x04, 0x46, 0x03, 0x03, 0x03, 0x03, 0x82, 0x01, 0x03, 0x00, 0xE0, 0x51, 0xA1, 0x00, 0x00, 0x00), + (0xD6, 0x10, 0x32, 0x54, 0x76, 0x98, 0xBA, 0xDC, 0xFE, 0x93, 0x00, 0x01, 0x83, 0x07, 0x07, 0x00, 0x07, 0x07, 0x00, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x00, 0x84, 0x00, 0x20, 0x01, 0x00), + (0xD7, 0x03, 0x01, 0x0B, 0x09, 0x0F, 0x0D, 0x1E, 0x1F, 0x18, 0x1D, 0x1F, 0x19, 0x40, 0x8E, 0x04, 0x00, 0x20, 0xA0, 0x1F), + (0xD8, 0x02, 0x00, 0x0A, 0x08, 0x0E, 0x0C, 0x1E, 0x1F, 0x18, 0x1D, 0x1F, 0x19), + (0xD9, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F), + (0xDD, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F), + (0xDF, 0x44, 0x73, 0x4B, 0x69, 0x00, 0x0A, 0x02, 0x90), + (0xE0, 0x3B, 0x28, 0x10, 0x16, 0x0C, 0x06, 0x11, 0x28, 0x5C, 0x21, 0x0D, 0x35, 0x13, 0x2C, 0x33, 0x28, 0x0D), + (0xE1, 0x37, 0x28, 0x10, 0x16, 0x0B, 0x06, 0x11, 0x28, 0x5C, 0x21, 0x0D, 0x35, 0x14, 0x2C, 0x33, 0x28, 0x0F), + (0xE2, 0x3B, 0x07, 0x12, 0x18, 0x0E, 0x0D, 0x17, 0x35, 0x44, 0x32, 0x0C, 0x14, 0x14, 0x36, 0x3A, 0x2F, 0x0D), + (0xE3, 0x37, 0x07, 0x12, 0x18, 0x0E, 0x0D, 0x17, 0x35, 0x44, 0x32, 0x0C, 0x14, 0x14, 0x36, 0x32, 0x2F, 0x0F), + (0xE4, 0x3B, 0x07, 0x12, 0x18, 0x0E, 0x0D, 0x17, 0x39, 0x44, 0x2E, 0x0C, 0x14, 0x14, 0x36, 0x3A, 0x2F, 0x0D), + (0xE5, 0x37, 0x07, 0x12, 0x18, 0x0E, 0x0D, 0x17, 0x39, 0x44, 0x2E, 0x0C, 0x14, 0x14, 0x36, 0x3A, 0x2F, 0x0F), + (0xA4, 0x85, 0x85, 0x95, 0x82, 0xAF, 0xAA, 0xAA, 0x80, 0x10, 0x30, 0x40, 0x40, 0x20, 0xFF, 0x60, 0x30), + (0xA4, 0x85, 0x85, 0x95, 0x85), + (0xBB, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00), + ), +) +# fmt: on + # Waveshare 1.83-v2 # # Do not use on 1.83-v1: Vendor warning on different chip! From 363a91c185e2967bb6dba4fd8015ac47bbf59e68 Mon Sep 17 00:00:00 2001 From: Fran Fodor <49796718+franFodor@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:31:22 +0200 Subject: [PATCH 1134/1815] [epaper_spi] Add Inkplate 6COLOR support (#17717) --- esphome/components/epaper_spi/colorconv.h | 130 ++++++++++++++++ .../components/epaper_spi/epaper_spi_4bpp.cpp | 82 ++++++++++ .../components/epaper_spi/epaper_spi_4bpp.h | 35 +++++ .../epaper_spi/epaper_spi_inkplate6color.cpp | 47 ++++++ .../epaper_spi/epaper_spi_inkplate6color.h | 22 +++ .../epaper_spi/epaper_spi_spectra_e6.cpp | 142 ++---------------- .../epaper_spi/epaper_spi_spectra_e6.h | 18 +-- .../epaper_spi/models/inkplate6color.py | 57 +++++++ .../epaper_spi/test.esp32-s3-idf.yaml | 23 +++ 9 files changed, 412 insertions(+), 144 deletions(-) create mode 100644 esphome/components/epaper_spi/epaper_spi_4bpp.cpp create mode 100644 esphome/components/epaper_spi/epaper_spi_4bpp.h create mode 100644 esphome/components/epaper_spi/epaper_spi_inkplate6color.cpp create mode 100644 esphome/components/epaper_spi/epaper_spi_inkplate6color.h create mode 100644 esphome/components/epaper_spi/models/inkplate6color.py diff --git a/esphome/components/epaper_spi/colorconv.h b/esphome/components/epaper_spi/colorconv.h index d4ffd034a1..7b7c48c0b0 100644 --- a/esphome/components/epaper_spi/colorconv.h +++ b/esphome/components/epaper_spi/colorconv.h @@ -81,4 +81,134 @@ constexpr NATIVE_COLOR color_to_bwr(Color color, NATIVE_COLOR hw_black, NATIVE_C return color_to_bwyr(color, hw_black, hw_white, /*hw_yellow=*/hw_white, hw_red); } +/** Map RGB color to discrete BWYRGB hex 6 color key + * + * Divides the RGB cube into 8 corners by which components are "on" (over 128), same as + * color_to_bwyr, but also resolves the green and blue corners instead of folding them into + * white/black. + * + * @tparam NATIVE_COLOR Type of native hardware color values + * @param color RGB color to convert from + * @param hw_black Native value for black + * @param hw_white Native value for white + * @param hw_yellow Native value for yellow + * @param hw_red Native value for red + * @param hw_green Native value for green + * @param hw_blue Native value for blue + * @return Converted native hardware color value + * @internal Constexpr. Does not depend on side effects ("pure"). + */ +template +constexpr NATIVE_COLOR color_to_bwyrgb(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white, + NATIVE_COLOR hw_yellow, NATIVE_COLOR hw_red, NATIVE_COLOR hw_green, + NATIVE_COLOR hw_blue) { + const auto [min_rgb, max_rgb] = std::minmax({color.r, color.g, color.b}); + + if ((max_rgb - min_rgb) < COLORCONV_GRAY_THRESHOLD) { + if ((static_cast(color.r) + color.g + color.b) > 382) { + return hw_white; + } + return hw_black; + } + + const bool r_on = (color.r > 128); + const bool g_on = (color.g > 128); + const bool b_on = (color.b > 128); + + if (r_on && g_on && !b_on) { + return hw_yellow; + } + if (r_on && !g_on && !b_on) { + return hw_red; + } + if (!r_on && g_on && !b_on) { + return hw_green; + } + if (!r_on && !g_on && b_on) { + return hw_blue; + } + // Handle "impure" colors (cyan, magenta) by folding into the closest primary. + if (!r_on && g_on && b_on) { + return hw_green; // cyan + } + if (r_on && !g_on) { + return hw_red; // magenta + } + if (r_on) { + // All high (but not gray) -> white + return hw_white; + } + // !r_on && !g_on && !b_on + // All low (but not gray) -> black + return hw_black; +} + +/** Map RGB color to discrete BWYRGBO hex 7 color key + * + * Same corner logic as color_to_bwyrgb, except the red/yellow corner is split three ways + * instead of two, for panels with a dedicated orange ink. + * + * @tparam NATIVE_COLOR Type of native hardware color values + * @param color RGB color to convert from + * @param hw_black Native value for black + * @param hw_white Native value for white + * @param hw_yellow Native value for yellow + * @param hw_red Native value for red + * @param hw_green Native value for green + * @param hw_blue Native value for blue + * @param hw_orange Native value for orange + * @return Converted native hardware color value + * @internal Constexpr. Does not depend on side effects ("pure"). + */ +template +constexpr NATIVE_COLOR color_to_bwyrgbo(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white, + NATIVE_COLOR hw_yellow, NATIVE_COLOR hw_red, NATIVE_COLOR hw_green, + NATIVE_COLOR hw_blue, NATIVE_COLOR hw_orange) { + const auto [min_rgb, max_rgb] = std::minmax({color.r, color.g, color.b}); + + if ((max_rgb - min_rgb) < COLORCONV_GRAY_THRESHOLD) { + if ((static_cast(color.r) + color.g + color.b) > 382) { + return hw_white; + } + return hw_black; + } + + const bool r_on = (color.r > 128); + const bool g_on = (color.g > 128); + const bool b_on = (color.b > 128); + + if (r_on && !b_on) { + // Between red and yellow: split the gradient in three instead of two, since this panel has a + // dedicated orange ink. Named orange (e.g. 0xFFA500) has g close to the midpoint, so the + // plain g_on (>128) threshold used by color_to_bwyrgb can't tell it apart from yellow. + if (color.g > 170) { + return hw_yellow; + } + if (color.g > 85) { + return hw_orange; + } + return hw_red; + } + if (!r_on && g_on && !b_on) { + return hw_green; + } + if (!r_on && !g_on && b_on) { + return hw_blue; + } + // Handle "impure" colors (cyan, magenta) by folding into the closest primary. + if (!r_on && g_on && b_on) { + return hw_green; // cyan + } + if (r_on && !g_on) { + return hw_red; // magenta + } + if (r_on) { + // All high (but not gray) -> white + return hw_white; + } + // !r_on && !g_on && !b_on + // All low (but not gray) -> black + return hw_black; +} + } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_4bpp.cpp b/esphome/components/epaper_spi/epaper_spi_4bpp.cpp new file mode 100644 index 0000000000..820dc3c77a --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_4bpp.cpp @@ -0,0 +1,82 @@ +#include "epaper_spi_4bpp.h" + +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { + +static constexpr const char *const TAG = "epaper_spi.4bpp"; + +void EPaper4bpp::fill(Color color) { + // If clipping is active, fall back to base implementation + if (this->get_clipping().is_set()) { + EPaperBase::fill(color); + return; + } + + auto pixel_color = this->color_to_native(color); + + // We store 2 pixels per byte + this->buffer_.fill(pixel_color + (pixel_color << 4)); + + // Whole buffer just changed; mark the entire canvas dirty. + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; +} + +void EPaper4bpp::clear() { + // clear buffer to white, just like real paper. + this->fill(COLOR_ON); +} + +void HOT EPaper4bpp::draw_pixel_at(int x, int y, Color color) { + if (!this->rotate_coordinates_(x, y)) + return; + auto pixel_bits = this->color_to_native(color); + uint32_t pixel_position = x + y * this->get_width_internal(); + uint32_t byte_position = pixel_position / 2; + auto original = this->buffer_[byte_position]; + if ((pixel_position & 1) != 0) { + this->buffer_[byte_position] = (original & 0xF0) | pixel_bits; + } else { + this->buffer_[byte_position] = (original & 0x0F) | (pixel_bits << 4); + } +} + +bool HOT EPaper4bpp::transfer_data() { + const uint32_t start_time = App.get_loop_component_start_time(); + const size_t buffer_length = this->buffer_length_; + if (this->current_data_index_ == 0) { + this->command(CMD_TRANSFER_DATA); + } + + size_t buf_idx = 0; + uint8_t bytes_to_send[MAX_TRANSFER_SIZE]; + while (this->current_data_index_ != buffer_length) { + bytes_to_send[buf_idx++] = this->buffer_[this->current_data_index_++]; + + if (buf_idx == sizeof bytes_to_send) { + this->start_data_(); + this->write_array(bytes_to_send, buf_idx); + this->disable(); + ESP_LOGV(TAG, "Wrote %d bytes at %ums", buf_idx, (unsigned) millis()); + buf_idx = 0; + + if (millis() - start_time > MAX_TRANSFER_TIME) { + // Let the main loop run and come back next loop + return false; + } + } + } + // Finished the entire dataset + if (buf_idx != 0) { + this->start_data_(); + this->write_array(bytes_to_send, buf_idx); + this->disable(); + } + this->current_data_index_ = 0; + return true; +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_4bpp.h b/esphome/components/epaper_spi/epaper_spi_4bpp.h new file mode 100644 index 0000000000..6eebe24c91 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_4bpp.h @@ -0,0 +1,35 @@ +#pragma once + +#include "epaper_spi.h" + +namespace esphome::epaper_spi { + +/** + * Intermediate base for panels with a 4-bit-per-pixel native buffer layout (2 pixels per byte). + * + * Owns buffer sizing, fill()/clear()/draw_pixel_at() and the chunked SPI transfer loop shared by + * this family of controllers. Concrete subclasses supply only their RGB -> 4-bit color mapping via + * color_to_native() plus their IC-specific power/refresh/sleep command sequences. + */ +class EPaper4bpp : public EPaperBase { + public: + EPaper4bpp(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length) + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR) { + this->buffer_length_ = width * height / 2; // 2 pixels per byte + } + + void fill(Color color) override; + void clear() override; + + protected: + void draw_pixel_at(int x, int y, Color color) override; + bool transfer_data() override; + + /// Map an RGB color to this panel's native 4-bit color key (low nibble). + virtual uint8_t color_to_native(Color color) = 0; + + static constexpr uint8_t CMD_TRANSFER_DATA = 0x10; +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_inkplate6color.cpp b/esphome/components/epaper_spi/epaper_spi_inkplate6color.cpp new file mode 100644 index 0000000000..b81542b6c1 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_inkplate6color.cpp @@ -0,0 +1,47 @@ +// Reference: https://github.com/SolderedElectronics/Inkplate-Arduino-library (src/boards/Inkplate6COLOR) + +#include "epaper_spi_inkplate6color.h" +#include "colorconv.h" + +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { + +static constexpr const char *const TAG = "epaper_spi.inkplate6color"; + +// Native hardware color codes for this panel's 4-bit color values. +enum Inkplate6ColorHex : uint8_t { + BLACK = 0, + WHITE = 1, + GREEN = 2, + BLUE = 3, + RED = 4, + YELLOW = 5, + ORANGE = 6, +}; + +uint8_t EPaperInkplate6Color::color_to_native(Color color) { + return color_to_bwyrgbo(color, BLACK, WHITE, YELLOW, RED, GREEN, BLUE, ORANGE); +} + +void EPaperInkplate6Color::power_on() { + ESP_LOGV(TAG, "Power on"); + this->command(0x04); +} + +void EPaperInkplate6Color::power_off() { + ESP_LOGV(TAG, "Power off"); + this->command(0x02); +} + +void EPaperInkplate6Color::refresh_screen(bool partial) { + ESP_LOGV(TAG, "Refresh"); // full refresh only; partial is unused + this->cmd_data(0x12, {0x00}); +} + +void EPaperInkplate6Color::deep_sleep() { + ESP_LOGV(TAG, "Deep sleep"); + this->cmd_data(0x07, {0xA5}); +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_inkplate6color.h b/esphome/components/epaper_spi/epaper_spi_inkplate6color.h new file mode 100644 index 0000000000..8b00552ff8 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_inkplate6color.h @@ -0,0 +1,22 @@ +#pragma once + +#include "epaper_spi_4bpp.h" + +namespace esphome::epaper_spi { + +// Soldered Inkplate 6COLOR: 600x448 7-color (black/white/green/blue/red/yellow/orange) e-paper, +// UC8159-family controller. +class EPaperInkplate6Color final : public EPaper4bpp { + public: + using EPaper4bpp::EPaper4bpp; + + protected: + uint8_t color_to_native(Color color) override; + + void refresh_screen(bool partial) override; + void power_on() override; + void power_off() override; + void deep_sleep() override; +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp b/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp index 1ef2dd12c3..f47d37d550 100644 --- a/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp +++ b/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp @@ -1,76 +1,23 @@ #include "epaper_spi_spectra_e6.h" - -#include +#include "colorconv.h" #include "esphome/core/log.h" namespace esphome::epaper_spi { static constexpr const char *const TAG = "epaper_spi.6c"; -static constexpr unsigned char GRAY_THRESHOLD = 50; -enum E6Color { - BLACK, - WHITE, - YELLOW, - RED, - SKIP_1, - BLUE, - GREEN, - CYAN, - SKIP_2, +// Native hardware color codes for this panel's 4-bit color values. +enum E6Color : uint8_t { + BLACK = 0, + WHITE = 1, + YELLOW = 2, + RED = 3, + BLUE = 5, + GREEN = 6, }; -static uint8_t color_to_hex(Color color) { - // --- Step 1: Check for Grayscale (Black or White) --- - // We define "grayscale" as a color where the min and max components - // are close to each other. - unsigned char max_rgb = std::max({color.r, color.g, color.b}); - unsigned char min_rgb = std::min({color.r, color.g, color.b}); - - if ((max_rgb - min_rgb) < GRAY_THRESHOLD) { - // It's a shade of gray. Map to BLACK or WHITE. - // We split the luminance at the halfway point (382 = (255*3)/2) - if ((static_cast(color.r) + color.g + color.b) > 382) { - return WHITE; - } - return BLACK; - } - // --- Step 2: Check for Primary/Secondary Colors --- - // If it's not gray, it's a color. We check which components are - // "on" (over 128) vs "off". This divides the RGB cube into 8 corners. - bool r_on = (color.r > 128); - bool g_on = (color.g > 128); - bool b_on = (color.b > 128); - - if (r_on && g_on && !b_on) { - return YELLOW; - } - if (r_on && !g_on && !b_on) { - return RED; - } - if (!r_on && g_on && !b_on) { - return GREEN; - } - if (!r_on && !g_on && b_on) { - return BLUE; - } - // Handle "impure" colors (Cyan, Magenta) - if (!r_on && g_on && b_on) { - // Cyan (G+B) -> Closest is Green or Blue. Pick Green. - return GREEN; - } - if (r_on && !g_on) { - // Magenta (R+B) -> Closest is Red or Blue. Pick Red. - return RED; - } - // Handle the remaining corners (White-ish, Black-ish) - if (r_on) { - // All high (but not gray) -> White - return WHITE; - } - // !r_on && !g_on && !b_on - // All low (but not gray) -> Black - return BLACK; +uint8_t EPaperSpectraE6::color_to_native(Color color) { + return color_to_bwyrgb(color, BLACK, WHITE, YELLOW, RED, GREEN, BLUE); } void EPaperSpectraE6::power_on() { @@ -92,71 +39,4 @@ void EPaperSpectraE6::deep_sleep() { ESP_LOGV(TAG, "Deep sleep"); this->cmd_data(0x07, {0xA5}); } - -void EPaperSpectraE6::fill(Color color) { - // If clipping is active, fall back to base implementation - if (this->get_clipping().is_set()) { - EPaperBase::fill(color); - return; - } - - auto pixel_color = color_to_hex(color); - - // We store 2 pixels per byte - this->buffer_.fill(pixel_color + (pixel_color << 4)); -} - -void EPaperSpectraE6::clear() { - // clear buffer to white, just like real paper. - this->fill(COLOR_ON); -} - -void HOT EPaperSpectraE6::draw_pixel_at(int x, int y, Color color) { - if (!this->rotate_coordinates_(x, y)) - return; - auto pixel_bits = color_to_hex(color); - uint32_t pixel_position = x + y * this->get_width_internal(); - uint32_t byte_position = pixel_position / 2; - auto original = this->buffer_[byte_position]; - if ((pixel_position & 1) != 0) { - this->buffer_[byte_position] = (original & 0xF0) | pixel_bits; - } else { - this->buffer_[byte_position] = (original & 0x0F) | (pixel_bits << 4); - } -} - -bool HOT EPaperSpectraE6::transfer_data() { - const uint32_t start_time = App.get_loop_component_start_time(); - const size_t buffer_length = this->buffer_length_; - if (this->current_data_index_ == 0) { - this->command(0x10); - } - - size_t buf_idx = 0; - uint8_t bytes_to_send[MAX_TRANSFER_SIZE]; - while (this->current_data_index_ != buffer_length) { - bytes_to_send[buf_idx++] = this->buffer_[this->current_data_index_++]; - - if (buf_idx == sizeof bytes_to_send) { - this->start_data_(); - this->write_array(bytes_to_send, buf_idx); - this->disable(); - ESP_LOGV(TAG, "Wrote %d bytes at %ums", buf_idx, (unsigned) millis()); - buf_idx = 0; - - if (millis() - start_time > MAX_TRANSFER_TIME) { - // Let the main loop run and come back next loop - return false; - } - } - } - // Finished the entire dataset - if (buf_idx != 0) { - this->start_data_(); - this->write_array(bytes_to_send, buf_idx); - this->disable(); - } - this->current_data_index_ = 0; - return true; -} } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_spectra_e6.h b/esphome/components/epaper_spi/epaper_spi_spectra_e6.h index 9c251068af..e1e3d3d763 100644 --- a/esphome/components/epaper_spi/epaper_spi_spectra_e6.h +++ b/esphome/components/epaper_spi/epaper_spi_spectra_e6.h @@ -1,28 +1,20 @@ #pragma once -#include "epaper_spi.h" +#include "epaper_spi_4bpp.h" namespace esphome::epaper_spi { -class EPaperSpectraE6 final : public EPaperBase { +class EPaperSpectraE6 final : public EPaper4bpp { public: - EPaperSpectraE6(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, - size_t init_sequence_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR) { - this->buffer_length_ = width * height / 2; // 2 pixels per byte - } - - void fill(Color color) override; - void clear() override; + using EPaper4bpp::EPaper4bpp; protected: + uint8_t color_to_native(Color color) override; + void refresh_screen(bool partial) override; void power_on() override; void power_off() override; void deep_sleep() override; - void draw_pixel_at(int x, int y, Color color) override; - - bool transfer_data() override; }; } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/inkplate6color.py b/esphome/components/epaper_spi/models/inkplate6color.py new file mode 100644 index 0000000000..33fecab848 --- /dev/null +++ b/esphome/components/epaper_spi/models/inkplate6color.py @@ -0,0 +1,57 @@ +# Reference: https://github.com/SolderedElectronics/Inkplate-Arduino-library (src/boards/Inkplate6COLOR) + +from esphome.components.mipi import delay + +from . import EpaperModel + + +class Inkplate6ColorModel(EpaperModel): + def __init__(self, name, class_name="EPaperInkplate6Color", **kwargs): + super().__init__(name, class_name, **kwargs) + + # fmt: off + def get_init_sequence(self, config: dict): + width, height = self.get_dimensions(config) + return ( + (0x00, 0xEF, 0x08,), # panel setting + (0x01, 0x37, 0x00, 0x05, 0x05,), # power setting + (0x03, 0x00,), # power off sequence setting + (0x06, 0xC7, 0xC7, 0x1D,), # booster soft start + (0x41, 0x00,), # temperature sensor enable + (0x50, 0x37,), # VCOM and data interval + (0x60, 0x20,), # TCON setting + (0x61, width // 256, width % 256, height // 256, height % 256,), # resolution set + (0xE3, 0xAA,), # power saving + delay(100), + (0x50, 0x37,), # VCOM and data interval, resent once the power-saving setting settles + ) + + +# Native orientation is landscape (600x448). +inkplate6color = Inkplate6ColorModel( + "inkplate6color", + width=600, + height=448, + # Vendor library drives the panel at 2MHz; the controller doesn't reliably support faster rates. + data_rate="2MHz", + # Vendor library waits 200ms after releasing reset before talking to the panel. + reset_duration="200ms", + # A full 7-color refresh takes tens of seconds; disallow faster updates to avoid FSM update loops. + minimum_update_interval="30s", + # Panel's native buffer orientation is rotated 180 degrees relative to the logical + # rotation=0 orientation; confirmed on real hardware. + mirror_x=True, + mirror_y=True, + # Default GPIO pins for the on-board Inkplate 6COLOR wiring. + reset_pin=19, + dc_pin=33, + cs_pin=27, + busy_pin={ + "number": 32, + "inverted": True, # hardware: LOW=busy, HIGH=idle + "mode": { + "input": True, + "pullup": True, + }, + }, +) diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index fb43b06567..9cca528744 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -212,6 +212,29 @@ display: it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK); it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0)); + # Soldered Inkplate 6COLOR 7-color e-paper (600x448, UC8159-family) + - platform: epaper_spi + spi_id: spi_bus + model: inkplate6color + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + inverted: true + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 30, Color::BLACK); + it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color(255, 0, 0)); + it.circle(it.get_width() / 2, it.get_height() / 2, 10, Color(255, 165, 0)); + # Waveshare 7.5" V2 BWR (800x480, UC8179 controller, EDP_7in5b_V2) - platform: epaper_spi spi_id: spi_bus From 33dacb06ff7fa4755acde1efcca1172a33d8724a Mon Sep 17 00:00:00 2001 From: Jeroen Date: Wed, 29 Jul 2026 18:57:03 +0200 Subject: [PATCH 1135/1815] [opentherm] Restore idle output when stopped (#17834) Co-authored-by: jeroen85 <26403565+jeroen85@users.noreply.github.com> --- esphome/components/opentherm/opentherm.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/opentherm/opentherm.cpp b/esphome/components/opentherm/opentherm.cpp index e05dbf8d82..3343871cd0 100644 --- a/esphome/components/opentherm/opentherm.cpp +++ b/esphome/components/opentherm/opentherm.cpp @@ -114,6 +114,7 @@ bool OpenTherm::get_protocol_error(OpenThermError &error) { void OpenTherm::stop() { this->stop_timer_(); this->mode_ = OperationMode::IDLE; + this->out_pin_->digital_write(true); } void IRAM_ATTR OpenTherm::read_() { From ad3e2f83b8586e9ee4ca989d94d378af91ab1cd7 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 29 Jul 2026 11:57:54 -0500 Subject: [PATCH 1136/1815] [sgp4x] Fix datasheet conformance issues (#17828) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/components/sgp4x/sgp4x.cpp | 56 +++++++++++++++++------------- esphome/components/sgp4x/sgp4x.h | 7 ++-- 2 files changed, 33 insertions(+), 30 deletions(-) diff --git a/esphome/components/sgp4x/sgp4x.cpp b/esphome/components/sgp4x/sgp4x.cpp index 4e14833c16..bc6fe794a0 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -18,7 +18,7 @@ void SGP4xComponent::setup() { this->mark_failed(); return; } - this->serial_number_ = (uint64_t(raw_serial_number[0]) << 24) | (uint64_t(raw_serial_number[1]) << 16) | + this->serial_number_ = (uint64_t(raw_serial_number[0]) << 32) | (uint64_t(raw_serial_number[1]) << 16) | (uint64_t(raw_serial_number[2])); ESP_LOGD(TAG, "Serial number: %" PRIu64, this->serial_number_); @@ -32,7 +32,6 @@ void SGP4xComponent::setup() { featureset &= 0x1FF; if (featureset == SGP40_FEATURESET) { this->sgp_type_ = SGP40; - this->self_test_time_ = SPG40_SELFTEST_TIME; this->measure_time_ = SGP40_MEASURE_TIME; if (this->nox_sensor_) { ESP_LOGE(TAG, "SGP41 required for NOx, disabling NOx sensor"); @@ -42,7 +41,6 @@ void SGP4xComponent::setup() { } } else if (featureset == SGP41_FEATURESET) { this->sgp_type_ = SGP41; - this->self_test_time_ = SPG41_SELFTEST_TIME; this->measure_time_ = SGP41_MEASURE_TIME; } else { ESP_LOGD(TAG, "Unknown feature set 0x%0X", featureset); @@ -86,6 +84,8 @@ void SGP4xComponent::setup() { if (std::isnormal(this->voc_baselines_storage_.state0) && std::isnormal(this->voc_baselines_storage_.state1)) { ESP_LOGV(TAG, "Setting VOC baseline from save state0: %f, state1: %f", this->voc_baselines_storage_.state0, this->voc_baselines_storage_.state1); + // Sensirion advises restoring states only after interruptions shorter than 10 minutes; with no way to know + // how long the device was off, restoring a stale state still beats a fresh 12-hour learning phase voc_algorithm_.set_states(this->voc_baselines_storage_.state0, this->voc_baselines_storage_.state1); } } @@ -114,11 +114,15 @@ void SGP4xComponent::self_test_() { this->error_code_ = COMMUNICATION_FAILED; ESP_LOGD(TAG, ESP_LOG_MSG_COMM_FAIL); this->mark_failed(); + return; } - this->set_timeout(this->self_test_time_, [this]() { + this->set_timeout(SGP4X_SELF_TEST_TIME, [this]() { uint16_t reply = 0; - if (!this->read_data(reply) || (reply != 0xD400)) { + // SGP40: MSB is 0xD4 on success, LSB is undefined; SGP41: MSB is undefined, LSB bits 0/1 flag VOC/NOx pixel + // failures + bool passed = this->read_data(reply) && (this->sgp_type_ == SGP41 ? (reply & 0x0003) == 0 : (reply >> 8) == 0xD4); + if (!passed) { this->error_code_ = SELF_TEST_FAILED; ESP_LOGW(TAG, "Self-test failed (0x%X)", reply); this->mark_failed(); @@ -136,8 +140,8 @@ void SGP4xComponent::update_gas_indices_() { if (this->nox_sensor_ != nullptr) this->nox_index_ = this->nox_algorithm_.process(this->nox_sraw_); ESP_LOGV(TAG, "VOC: %" PRId32 ", NOx: %" PRId32, this->voc_index_, this->nox_index_); - // Store baselines after defined interval or if the difference between current and stored baseline becomes too - // much + // Store baselines once the minimum interval has passed and the state has drifted from the stored copy; + // both conditions limit flash wear if (this->store_baseline_ && this->seconds_since_last_store_ > SHORTEST_BASELINE_STORE_INTERVAL) { this->voc_algorithm_.get_states(this->voc_state0_, this->voc_state1_); if (std::abs(this->voc_baselines_storage_.state0 - this->voc_state0_) > MAXIMUM_STORAGE_DIFF_STATE0 || @@ -187,27 +191,28 @@ void SGP4xComponent::measure_raw_() { uint16_t command; uint16_t data[2]; size_t response_words; - // Use SGP40 measure command if we don't care about NOx - if (nox_sensor_ == nullptr) { + if (this->sgp_type_ == SGP40) { command = SGP40_CMD_MEASURE_RAW; response_words = 1; + } else if (this->nox_conditioning_start_.has_value() && millis() - *this->nox_conditioning_start_ < 10000) { + // SGP41 must run the NOx conditioning command for the first 10 seconds + command = SGP41_CMD_NOX_CONDITIONING; + response_words = 1; } else { - // SGP41 sensor must use NOx conditioning command for the first 10 seconds - if (this->nox_conditioning_start_.has_value() && millis() - *this->nox_conditioning_start_ < 10000) { - command = SGP41_CMD_NOX_CONDITIONING; - response_words = 1; - } else { - this->nox_conditioning_start_.reset(); - command = SGP41_CMD_MEASURE_RAW; - response_words = 2; - } + this->nox_conditioning_start_.reset(); + command = SGP41_CMD_MEASURE_RAW; + response_words = 2; + } + if (command == SGP41_CMD_NOX_CONDITIONING) { + // Conditioning requires the default parameters (compensation disabled) + data[0] = 0x8000; + data[1] = 0x6666; + } else { + // first parameter are the relative humidity ticks + data[0] = (uint16_t) std::llround((humidity * 65535) / 100); + // second parameter are the temperature ticks + data[1] = (uint16_t) (((temperature + 45) * 65535) / 175); } - uint16_t rhticks = (uint16_t) std::llround((humidity * 65535) / 100); - uint16_t tempticks = (uint16_t) (((temperature + 45) * 65535) / 175); - // first parameter are the relative humidity ticks - data[0] = rhticks; - // secomd parameter are the temperature ticks - data[1] = tempticks; if (!this->write_command(command, data, 2)) { ESP_LOGD(TAG, "write error (%d)", this->last_error_); @@ -279,7 +284,8 @@ void SGP4xComponent::dump_config() { " Type: %s\n" " Serial number: %" PRIu64 "\n" " Minimum Samples: %f", - sgp_type_ == SGP41 ? "SGP41" : "SPG40", this->serial_number_, GasIndexAlgorithm_INITIAL_BLACKOUT); + this->sgp_type_ == SGP41 ? "SGP41" : "SGP40", this->serial_number_, + GasIndexAlgorithm_INITIAL_BLACKOUT); } LOG_UPDATE_INTERVAL(this); diff --git a/esphome/components/sgp4x/sgp4x.h b/esphome/components/sgp4x/sgp4x.h index 4504c25448..2aaf06601b 100644 --- a/esphome/components/sgp4x/sgp4x.h +++ b/esphome/components/sgp4x/sgp4x.h @@ -39,16 +39,14 @@ static const uint16_t SGP4X_CMD_SELF_TEST = 0x280e; static const uint16_t SGP40_CMD_MEASURE_RAW = 0x260F; static const uint16_t SGP41_CMD_MEASURE_RAW = 0x2619; static const uint16_t SGP41_CMD_NOX_CONDITIONING = 0x2612; -static const uint8_t SGP41_SUBCMD_NOX_CONDITIONING = 0x12; // Shortest time interval of 3H for storing baseline values. // Prevents wear of the flash because of too many write operations const uint32_t SHORTEST_BASELINE_STORE_INTERVAL = 10800; -static const uint16_t SPG40_SELFTEST_TIME = 250; // 250 ms for self test -static const uint16_t SPG41_SELFTEST_TIME = 320; // 320 ms for self test +static const uint16_t SGP4X_SELF_TEST_TIME = 320; // maximum self-test duration for both SGP40 and SGP41 static const uint16_t SGP40_MEASURE_TIME = 30; static const uint16_t SGP41_MEASURE_TIME = 55; -// Store anyway if the baseline difference exceeds the max storage diff value +// Once the store interval has passed, store only if the baseline drifted from the stored copy by more than these // state0 is mean of variance estimator, hence can have larger absolute values and a larger diff threshold const float MAXIMUM_STORAGE_DIFF_STATE0 = 50.0f; // state1 is std of variance estimator, so it typically has smaller absolute values than state0, hence we use a smaller @@ -115,7 +113,6 @@ class SGP4xComponent final : public PollingComponent, uint64_t serial_number_; bool self_test_complete_; - uint16_t self_test_time_; sensor::Sensor *voc_sensor_{nullptr}; VOCGasIndexAlgorithm voc_algorithm_; From 98ee7e0f82cd81afb71ddd6f78d6705fbcb72283 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:08:52 -0500 Subject: [PATCH 1137/1815] [lvgl] Add lvgl.theme.update action (#17678) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 --- esphome/components/lvgl/__init__.py | 45 +++----- esphome/components/lvgl/defines.py | 9 ++ esphome/components/lvgl/schemas.py | 94 +++++++++++++++- esphome/components/lvgl/styles.py | 103 +++++++++++++++--- .../lvgl/test_multi_conf_validate.py | 55 ++++++++++ .../lvgl/test_schema_dict_helpers.py | 71 ++++++++++-- tests/components/lvgl/lvgl-package.yaml | 16 +++ 7 files changed, 335 insertions(+), 58 deletions(-) create mode 100644 tests/component_tests/lvgl/test_multi_conf_validate.py diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 256bf4bb3a..bfe91eedd5 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -1,4 +1,3 @@ -import functools import importlib from pathlib import Path import pkgutil @@ -86,7 +85,7 @@ from .schemas import ( any_widget_schema, container_schema, container_schema_value, - obj_dict, + theme_schema, ) from .styles import styles_to_code, theme_to_code from .touchscreens import touchscreen_schema, touchscreens_to_code @@ -215,6 +214,18 @@ def multi_conf_validate(configs: list[dict]): raise cv.Invalid( f"'{item}' must have an explicit group set when using multiple LVGL instances" ) + # The hidden styles a `theme:` block creates are tracked in a single map shared + # by all LVGL instances (keyed only by widget type, not by instance), so a + # second instance's `theme:` would silently lose to whichever instance is + # processed first instead of doing what its config implies. + themed_configs = sum( + 1 for config in configs if config.get(df.CONF_THEME) is not None + ) + if themed_configs > 1: + raise cv.Invalid( + "'theme' may only be set on one LVGL instance when using multiple LVGL " + "instances -- combine both themes into a single instance's 'theme:' block" + ) base_config = configs[0] for config in configs[1:]: for item in ( @@ -552,34 +563,6 @@ def add_hello_world(config): return config -@functools.cache -def _build_theme_schema( - widget_types: tuple[tuple[str, widgets.WidgetType], ...], -) -> cv.Schema: - # The theme schema is value-independent: it depends only on the set of - # registered widget types. Key the cache on a snapshot of WIDGET_TYPES so - # that an external component registering a new widget after the first - # validation (legal per any_widget_schema's lazy-evaluation contract) - # produces a fresh tuple, a cache miss, and a rebuilt schema -- the cache - # self-heals instead of stale-rejecting valid themes. See obj_dict() in - # schemas.py for why chained .extend() is avoided here. - return cv.Schema( - { - cv.Optional(df.CONF_DARK_MODE, default=False): cv.boolean, - **{ - cv.Optional(name): cv.Schema( - {**obj_dict(w), **FULL_STYLE_SCHEMA.schema} - ) - for name, w in widget_types - }, - } - ) - - -def _theme_schema(value: dict) -> dict: - return _build_theme_schema(tuple(WIDGET_TYPES.items()))(value) - - FINAL_VALIDATE_SCHEMA = final_validation # The options accepted at the top level of an `lvgl:` block, on top of the base @@ -647,7 +630,7 @@ LVGL_TOP_LEVEL_SCHEMA = ( cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec), cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec), cv.Optional(df.CONF_TRANSPARENCY_KEY, default=0x000400): lvalid.lv_color, - cv.Optional(df.CONF_THEME): _theme_schema, + cv.Optional(df.CONF_THEME): theme_schema, cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA, cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema, cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG, diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 4f734fe20c..65e975ad6d 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -33,6 +33,7 @@ KEY_NAMED_STYLES = "named_styles" KEY_REFRESHED_WIDGETS = "refreshed_widgets" KEY_REMAPPED_USES = "remapped_uses" KEY_STYLES_USED = "styles_used" +KEY_THEME_UPDATE_REQUESTS = "theme_update_requests" KEY_THEME_WIDGET_MAP = "theme_widget_map" KEY_UPDATED_WIDGETS = "updated_widgets" KEY_WIDGET_MAP = "widget_map" @@ -118,6 +119,14 @@ def get_theme_widget_map() -> dict[str, Any]: return _get_data(KEY_THEME_WIDGET_MAP, {}) +def get_theme_update_requests() -> dict[str, dict[tuple[str, str], None]]: + # Values are dicts used as ordered sets (insertion order is deterministic, + # unlike a plain `set` of strings/tuples, whose iteration order depends on + # per-process string hash randomization) so codegen output doesn't churn + # between builds of the same config. + return _get_data(KEY_THEME_UPDATE_REQUESTS, {}) + + def get_styles_used() -> set[str]: return _get_data(KEY_STYLES_USED, set()) diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index dd4f71a346..e400dae50f 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -1,4 +1,5 @@ from collections.abc import Callable +import functools from typing import Any from esphome import config_validation as cv @@ -8,6 +9,7 @@ from esphome.components.time import RealTimeClock from esphome.config_validation import prepend_path from esphome.const import ( CONF_ARGS, + CONF_DEFAULT, CONF_FORMAT, CONF_GROUP, CONF_ID, @@ -69,7 +71,7 @@ from .types import ( lv_pseudo_button_t, lv_style_t, ) -from .widgets import WidgetType +from .widgets import WidgetType, collect_parts # this will be populated later, in __init__.py to avoid circular imports. WIDGET_TYPES: dict = {} @@ -591,6 +593,96 @@ def obj_schema(widget_type: WidgetType) -> cv.Schema: return schema +@functools.cache +def _build_theme_schema( + widget_types: tuple[tuple[str, WidgetType], ...], + include_dark_mode: bool = True, +) -> cv.Schema: + # The theme schema is value-independent: it depends only on the set of + # registered widget types. Key the cache on a snapshot of WIDGET_TYPES so + # that an external component registering a new widget after the first + # validation (legal per any_widget_schema's lazy-evaluation contract) + # produces a fresh tuple, a cache miss, and a rebuilt schema -- the cache + # self-heals instead of stale-rejecting valid themes. See obj_dict() above + # for why chained .extend() is avoided here. + return cv.Schema( + { + **( + {cv.Optional(df.CONF_DARK_MODE, default=False): cv.boolean} + if include_dark_mode + else {} + ), + **{ + cv.Optional(name): cv.Schema( + {**obj_dict(w), **FULL_STYLE_SCHEMA.schema} + ) + for name, w in widget_types + }, + } + ) + + +def _reject_theme_styles_key(validated: dict) -> dict: + """ + `styles:` (a list of already-declared named styles) is not allowed inside + `theme:` -- the hidden style objects theme: creates only carry direct + style properties, so a `styles:` reference there would be silently + dropped by `style_set`, which only walks `ALL_STYLES`. + """ + for w_name, style in validated.items(): + if w_name not in WIDGET_TYPES: + continue + for part, states in collect_parts(style).items(): + for state, props in states.items(): + if df.CONF_STYLES not in props: + continue + path = [w_name] + if part != df.CONF_MAIN: + path.append(part) + if state != CONF_DEFAULT: + path.append(state) + path.append(df.CONF_STYLES) + raise cv.Invalid( + "'styles:' is not allowed in LVGL theme styles. " + "Set style properties directly instead.", + path, + ) + return validated + + +def theme_schema(value: dict) -> dict: + return _reject_theme_styles_key( + _build_theme_schema(tuple(WIDGET_TYPES.items()))(value) + ) + + +def theme_update_schema(value: dict) -> dict: + """ + Schema for `lvgl.theme.update`: same shape as `theme:` minus `dark_mode`. + As a validation side effect, records which (widget type, part, state) + combos are targeted so `theme_to_code` can make sure a hidden style + exists for each -- even ones never mentioned under `theme:` -- and gets + it attached to widgets at the same point real theme styles are. + """ + validated = _reject_theme_styles_key( + _build_theme_schema(tuple(WIDGET_TYPES.items()), include_dark_mode=False)(value) + ) + for w_name, style in validated.items(): + for part, states in collect_parts(style).items(): + for state, props in states.items(): + # collect_parts() unconditionally seeds a main/default entry + # even when nothing was set for it (e.g. `{pressed: {...}}` + # alone) -- skip combos with no properties so a request for + # one state doesn't also create an unused, empty main/default + # style that gets attached to every widget of this type. + if not props: + continue + df.get_theme_update_requests().setdefault(w_name, {})[(part, state)] = ( + None + ) + return validated + + ALIGN_TO_SCHEMA = { cv.Optional(df.CONF_ALIGN_TO): cv.Schema( { diff --git a/esphome/components/lvgl/styles.py b/esphome/components/lvgl/styles.py index 5911505555..ad42028327 100644 --- a/esphome/components/lvgl/styles.py +++ b/esphome/components/lvgl/styles.py @@ -10,11 +10,18 @@ from .defines import ( LValidator, add_lv_use, get_styles_used, + get_theme_update_requests, get_theme_widget_map, literal, ) from .lvcode import LambdaContext, lv -from .schemas import ALL_STYLES, FULL_STYLE_SCHEMA, WIDGET_TYPES, remap_property +from .schemas import ( + ALL_STYLES, + FULL_STYLE_SCHEMA, + WIDGET_TYPES, + remap_property, + theme_update_schema, +) from .types import ObjUpdateAction, lv_style_t from .widgets import collect_parts, wait_for_widgets @@ -86,23 +93,91 @@ async def style_update_to_code(config, action_id, template_arg, args): style = await cg.get_variable(config[CONF_ID]) async with LambdaContext(parameters=args, where=action_id) as context: await style_set(style, config) + # Refresh and redraw every widget using this style -- otherwise the + # updated properties would sit unused until something else happens to + # invalidate the affected widgets. + lv.obj_report_style_change(style) return cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) async def theme_to_code(config): - if theme := config.get(CONF_THEME): - add_lv_use(CONF_THEME) - for w_name, style in ((k, v) for k, v in theme.items() if k in WIDGET_TYPES): - # Work around Python 3.10 bug with nested async comprehensions - # With Python 3.11 this could be simplified - # TODO: Now that we require Python 3.11+, this can be updated to use nested comprehensions - styles = {} - for part, states in collect_parts(style).items(): - styles[part] = { - state: await create_style( + theme = config.get(CONF_THEME) or {} + requests = get_theme_update_requests() + # Iterate in WIDGET_TYPES' (deterministic, registration-order) sequence rather + # than a set -- a set of strings/tuples iterates in an order that depends on + # per-process hash randomization, which would otherwise churn the order hidden + # style variables are declared in main.cpp between builds of the same config. + widget_names = [ + w_name for w_name in WIDGET_TYPES if w_name in theme or w_name in requests + ] + if not widget_names: + return + add_lv_use(CONF_THEME) + theme_map = get_theme_widget_map() + for w_name in widget_names: + declared_parts = collect_parts(theme[w_name]) if w_name in theme else {} + parts = {part: dict(states) for part, states in declared_parts.items()} + for part, state in requests.get(w_name, {}): + parts.setdefault(part, {}).setdefault(state, {}) + widget_styles = theme_map.setdefault(w_name, {}) + for part, states in parts.items(): + part_styles = widget_styles.setdefault(part, {}) + declared_states = declared_parts.get(part, {}) + for state, props in states.items(): + if state not in part_styles: + part_styles[state] = await create_style( "_lv_theme_style_" + w_name + "_" + part + "_" + state, props ) - for state, props in states.items() - } - get_theme_widget_map()[w_name] = styles + elif state in declared_states: + # A `theme.update` request for this combo (possibly from + # another LVGL instance) already created the style as an + # empty placeholder before this instance's real `theme:` + # declaration was reached -- apply the real values now + # instead of silently leaving it empty. + await style_set(part_styles[state], props) + + +@automation.register_action( + "lvgl.theme.update", + ObjUpdateAction, + theme_update_schema, + synchronous=True, +) +async def theme_update_to_code(config, action_id, template_arg, args): + await wait_for_widgets() + theme_map = get_theme_widget_map() + # Invariant this relies on: theme_update_schema() records every (widget + # type, part, state) combo this action targets as a request during config + # validation (which completes for the whole config tree before any + # to_code runs), and theme_to_code() -- which runs for every LVGL + # instance before any action's own to_code -- materialises a style for + # each recorded request. If that handshake is ever broken by a future + # change, fail with a diagnosable message rather than a bare KeyError. + to_update = [] + for w_name, style in config.items(): + for part, states in collect_parts(style).items(): + for state, props in states.items(): + # collect_parts() unconditionally seeds an (empty) main/default + # entry even when this action didn't target it -- skip it, both + # because there's nothing to update and because + # theme_update_schema no longer pre-creates a placeholder style + # for combos with no properties. + if not props: + continue + style_var = theme_map.get(w_name, {}).get(part, {}).get(state) + if style_var is None: + raise cv.Invalid( + f"No theme style exists for '{w_name}' {part}/{state}. " + "This is an internal error -- please report it." + ) + to_update.append((style_var, props)) + async with LambdaContext(parameters=args, where=action_id) as context: + for style_var, props in to_update: + await style_set(style_var, props) + # Refresh and redraw every widget using this style -- otherwise the + # updated properties would sit unused until something else happens + # to invalidate the affected widgets. + lv.obj_report_style_change(style_var) + + return cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) diff --git a/tests/component_tests/lvgl/test_multi_conf_validate.py b/tests/component_tests/lvgl/test_multi_conf_validate.py new file mode 100644 index 0000000000..b63b7618e7 --- /dev/null +++ b/tests/component_tests/lvgl/test_multi_conf_validate.py @@ -0,0 +1,55 @@ +"""Tests for LVGL's multi-instance config cross-checks.""" + +from __future__ import annotations + +import pytest + +from esphome.components.lvgl import defines as df, multi_conf_validate +from esphome.components.lvgl.schemas import theme_schema +from esphome.config_validation import Invalid + + +def _config(displays: list[str], theme: dict | None = None) -> dict: + config = { + df.CONF_DISPLAYS: displays, + "log_level": "WARN", + "color_depth": 16, + "byte_order": "big_endian", + df.CONF_TRANSPARENCY_KEY: 0x000400, + } + if theme is not None: + config[df.CONF_THEME] = theme + return config + + +class TestThemeOnMultipleInstances: + def test_raises_when_two_instances_have_theme(self) -> None: + configs = [ + _config(["disp_a"], theme={df.CONF_DARK_MODE: True}), + _config(["disp_b"], theme={df.CONF_DARK_MODE: False}), + ] + with pytest.raises(Invalid, match="'theme' may only be set on one"): + multi_conf_validate(configs) + + def test_raises_even_with_an_empty_theme_block(self) -> None: + # `theme: {}` still creates a CONF_THEME key (with dark_mode defaulted + # by the schema), so it should be treated the same as a populated one. + # Run it through the real schema rather than hand-building the dict, + # so this actually pins that defaulting behaviour. + configs = [ + _config(["disp_a"], theme=theme_schema({})), + _config(["disp_b"], theme=theme_schema({})), + ] + with pytest.raises(Invalid, match="'theme' may only be set on one"): + multi_conf_validate(configs) + + def test_passes_when_only_one_instance_has_theme(self) -> None: + configs = [ + _config(["disp_a"], theme={df.CONF_DARK_MODE: True}), + _config(["disp_b"]), + ] + multi_conf_validate(configs) + + def test_passes_when_no_instance_has_theme(self) -> None: + configs = [_config(["disp_a"]), _config(["disp_b"])] + multi_conf_validate(configs) diff --git a/tests/component_tests/lvgl/test_schema_dict_helpers.py b/tests/component_tests/lvgl/test_schema_dict_helpers.py index 16714f54d7..c8b3a76bf9 100644 --- a/tests/component_tests/lvgl/test_schema_dict_helpers.py +++ b/tests/component_tests/lvgl/test_schema_dict_helpers.py @@ -13,12 +13,7 @@ import pytest import voluptuous as vol from esphome import config_validation as cv -import esphome.components.lvgl -from esphome.components.lvgl import ( - _theme_schema, - defines as df, - schemas as lvgl_schemas, -) +from esphome.components.lvgl import defines as df, schemas as lvgl_schemas from esphome.components.lvgl.schemas import ( ALIGN_TO_SCHEMA, FLAG_SCHEMA, @@ -31,6 +26,8 @@ from esphome.components.lvgl.schemas import ( obj_schema, part_dict, part_schema, + theme_schema, + theme_update_schema, ) from esphome.components.lvgl.types import LvType from esphome.components.lvgl.widgets import WidgetType @@ -43,7 +40,7 @@ def _clear_obj_dict_cache() -> Generator[None]: cache.clear() # The lazily-built theme schema is cached on _build_theme_schema; clear it # too so each test starts from a clean slate. - build_theme = getattr(esphome.components.lvgl, "_build_theme_schema", None) + build_theme = getattr(lvgl_schemas, "_build_theme_schema", None) if build_theme is not None and hasattr(build_theme, "cache_clear"): build_theme.cache_clear() yield @@ -173,12 +170,12 @@ def test_spread_sources_carry_no_extra_schemas(schema: cv.Schema) -> None: def test_theme_schema_merges_obj_dict_and_full_style_props() -> None: - # _theme_schema is the riskiest merge: obj_dict(w) and FULL_STYLE_SCHEMA.schema + # theme_schema is the riskiest merge: obj_dict(w) and FULL_STYLE_SCHEMA.schema # share many STYLE_SCHEMA marker instances. Exercise the merged schema # end-to-end with one key from each side (a STATE_SCHEMA part from obj_dict # and a FULL_STYLE-only property) to lock the behaviour against future # regressions in either source. - out = _theme_schema( + out = theme_schema( { df.CONF_DARK_MODE: True, "obj": { @@ -202,7 +199,7 @@ def test_theme_schema_self_heals_when_a_widget_type_is_registered_later() -> Non # any_widget_schema explicitly supports external components registering # widgets lazily, and the device builder revalidates in-process, so a # widget registered after first use must invalidate the cached snapshot. - _theme_schema({df.CONF_DARK_MODE: True}) # populate the cache + theme_schema({df.CONF_DARK_MODE: True}) # populate the cache name = "test_self_heal_widget" assert name not in WIDGET_TYPES @@ -210,18 +207,68 @@ def test_theme_schema_self_heals_when_a_widget_type_is_registered_later() -> Non # manually so the next theme call sees the new entry. WIDGET_TYPES[name] = WidgetType(name, LvType("test_fake_t"), (), is_mock=True) try: - out = _theme_schema({df.CONF_DARK_MODE: False, name: {"bg_color": 0x010203}}) + out = theme_schema({df.CONF_DARK_MODE: False, name: {"bg_color": 0x010203}}) assert out[name]["bg_color"] == 0x010203 finally: WIDGET_TYPES.pop(name, None) +@pytest.mark.parametrize( + ("config", "expected_path"), + [ + ({"button": {"styles": ["foo"]}}, ["button", "styles"]), + ( + {"button": {"pressed": {"styles": ["foo"]}}}, + ["button", "pressed", "styles"], + ), + ( + {"arc": {"indicator": {"styles": ["foo"]}}}, + ["arc", "indicator", "styles"], + ), + ( + {"arc": {"indicator": {"pressed": {"styles": ["foo"]}}}}, + ["arc", "indicator", "pressed", "styles"], + ), + ], +) +def test_theme_schema_rejects_styles_key( + config: dict, expected_path: list[str] +) -> None: + # `styles:` (references to named styles) is accepted by FULL_STYLE_SCHEMA + # but silently dropped by style_set when building a theme's hidden style + # -- it only walks ALL_STYLES. Reject it instead of quietly doing nothing, + # at the top level and when nested under a part and/or state. + with pytest.raises(vol.Invalid, match="'styles:' is not allowed") as exc_info: + theme_schema(config) + assert exc_info.value.path == expected_path + + +def test_theme_update_schema_rejects_styles_key() -> None: + with pytest.raises(vol.Invalid, match="'styles:' is not allowed") as exc_info: + theme_update_schema({"label": {"styles": ["foo"]}}) + assert exc_info.value.path == ["label", "styles"] + + +def test_theme_update_schema_does_not_request_untargeted_main_default() -> None: + # collect_parts() unconditionally seeds a main/default entry even when + # only a specific state (here "pressed") was targeted -- registering a + # request for that spurious entry would make theme_to_code create an + # unused, empty style and attach it to every widget of this type. + theme_update_schema({"label": {"pressed": {"text_color": 0x010203}}}) + assert df.get_theme_update_requests()["label"] == {("main", "pressed"): None} + + +def test_theme_update_schema_requests_explicit_main_default() -> None: + theme_update_schema({"label": {"text_color": 0x010203}}) + assert df.get_theme_update_requests()["label"] == {("main", "default"): None} + + @pytest.mark.parametrize( "schema", [STATE_SCHEMA, FLAG_SCHEMA, STYLE_SCHEMA, FULL_STYLE_SCHEMA], ) def test_spread_sources_have_no_top_level_marker_defaults(schema: cv.Schema) -> None: - # _theme_schema merges obj_dict(w) with FULL_STYLE_SCHEMA.schema; on a key + # theme_schema merges obj_dict(w) with FULL_STYLE_SCHEMA.schema; on a key # collision, dict-spread keeps the first source's marker (and its default) # but the last source's value, whereas .extend() would take both from the # later source. The two are equivalent today because the overlapping diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index f085b62cb6..ef8ba13e42 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -295,6 +295,22 @@ lvgl: id: style_test bg_color: blue bg_opa: !lambda return 0.5; + # `obj` is already themed above -- exercises updating an existing hidden style. + - lvgl.theme.update: + obj: + border_width: 2 + # `label` is never mentioned under `theme:` -- exercises lazily creating the + # hidden style and getting it attached to already-built label widgets. + - lvgl.theme.update: + label: + text_color: red + # `button` is never mentioned under `theme:`, and only a non-default state is + # targeted here -- exercises that no spurious, empty main/default style is + # created (and attached to every button) alongside the requested one. + - lvgl.theme.update: + button: + pressed: + bg_color: red - lvgl.image.update: id: lv_image src: From 306d200b026b7bc8a5132993896d729534eb96e5 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:27:06 -0400 Subject: [PATCH 1138/1815] [api] Stop advertising external wake words the device cannot load (#17926) --- esphome/components/api/api_connection.cpp | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index c61daf539a..9aac7bd7d1 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1329,7 +1329,8 @@ void APIConnection::on_voice_assistant_announce_request(const VoiceAssistantAnno } } -bool APIConnection::send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg) { +bool APIConnection::send_voice_assistant_get_configuration_response_( + const VoiceAssistantConfigurationRequest & /*msg*/) { VoiceAssistantConfigurationResponse resp; if (!this->check_voice_assistant_api_connection_()) { // send_message encodes synchronously, so this stack local outlives the encode @@ -1349,22 +1350,6 @@ bool APIConnection::send_voice_assistant_get_configuration_response_(const Voice } } - // Filter external wake words - for (auto &wake_word : msg.external_wake_words) { - if (wake_word.model_type != "micro") { - // microWakeWord only - continue; - } - - resp.available_wake_words.emplace_back(); - auto &resp_wake_word = resp.available_wake_words.back(); - resp_wake_word.id = StringRef(wake_word.id); - resp_wake_word.wake_word = StringRef(wake_word.wake_word); - for (const auto &lang : wake_word.trained_languages) { - resp_wake_word.trained_languages.push_back(lang); - } - } - resp.active_wake_words = &config.active_wake_words; resp.max_active_wake_words = config.max_active_wake_words; return this->send_message(resp); From ca1f89e500b47020fb8ee4ee7d40d2fdc9880d01 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Jul 2026 07:44:39 -1000 Subject: [PATCH 1139/1815] [ci] Enable the ruff rule requiring explicit encoding on text file I/O (#17897) --- esphome/components/wifi/wpa2_eap.py | 2 +- esphome/espidf/extra_script.py | 2 +- esphome/espidf/size_summary.py | 4 ++-- esphome/mqtt.py | 8 ++++++-- pyproject.toml | 5 +++++ script/check_import_time.py | 2 +- script/ci_memory_impact_extract.py | 2 +- script/setup_codspeed_lib.py | 2 +- script/test_build_components.py | 4 ++-- tests/unit_tests/analyze_memory/test_build_artifacts.py | 2 +- tests/unit_tests/test_compiled_config.py | 4 ++-- 11 files changed, 23 insertions(+), 14 deletions(-) diff --git a/esphome/components/wifi/wpa2_eap.py b/esphome/components/wifi/wpa2_eap.py index 51971a1220..089a4fa99a 100644 --- a/esphome/components/wifi/wpa2_eap.py +++ b/esphome/components/wifi/wpa2_eap.py @@ -58,7 +58,7 @@ def wrapped_load_pem_private_key(value, password): def read_relative_config_path(value): # pylint: disable=unspecified-encoding - return Path(CORE.relative_config_path(value)).read_text() + return Path(CORE.relative_config_path(value)).read_text(encoding="utf-8") def _validate_load_certificate(value): diff --git a/esphome/espidf/extra_script.py b/esphome/espidf/extra_script.py index 4d06fb842a..487fef7cc1 100644 --- a/esphome/espidf/extra_script.py +++ b/esphome/espidf/extra_script.py @@ -107,7 +107,7 @@ def run_extra_script( script shouldn't block the build. """ env = _FakeSConsEnv(board_mcu=idf_target, pio_env=f"esphome_{idf_target}") - code = compile(script_path.read_text(), str(script_path), "exec") + code = compile(script_path.read_text(encoding="utf-8"), str(script_path), "exec") old_cwd = Path.cwd() try: os.chdir(library_dir) diff --git a/esphome/espidf/size_summary.py b/esphome/espidf/size_summary.py index 3ba0bf3b4d..7a5305ff0c 100644 --- a/esphome/espidf/size_summary.py +++ b/esphome/espidf/size_summary.py @@ -57,7 +57,7 @@ def _find_app_partition_size(partitions_csv: Path) -> int: """ if not partitions_csv.is_file(): raise ValueError(f"partitions.csv not found at {partitions_csv}") - for row in csv.reader(partitions_csv.read_text().splitlines()): + for row in csv.reader(partitions_csv.read_text(encoding="utf-8").splitlines()): cells = [c.strip() for c in row] if not cells or cells[0].startswith("#") or len(cells) < 5: continue @@ -89,7 +89,7 @@ def print_summary(size_json: Path, partitions_csv: Path | None) -> None: _LOGGER.debug("Skipping size summary: %s not found", size_json) return try: - data = json.loads(size_json.read_text()) + data = json.loads(size_json.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as e: _LOGGER.debug("Skipping size summary: %s", e) return diff --git a/esphome/mqtt.py b/esphome/mqtt.py index c6a7a7558b..3198de9d21 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -110,8 +110,12 @@ def prepare( CONF_CLIENT_CERTIFICATE_KEY ): with ( - tempfile.NamedTemporaryFile(mode="w+", delete=False) as cert_file, - tempfile.NamedTemporaryFile(mode="w+", delete=False) as key_file, + tempfile.NamedTemporaryFile( + encoding="utf-8", mode="w+", delete=False + ) as cert_file, + tempfile.NamedTemporaryFile( + encoding="utf-8", mode="w+", delete=False + ) as key_file, ): try: cert_file.write(config[CONF_MQTT].get(CONF_CLIENT_CERTIFICATE)) diff --git a/pyproject.toml b/pyproject.toml index d38918a0a1..eda3c4cf7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -110,6 +110,10 @@ target-version = "py312" exclude = ['generated'] [tool.ruff.lint] +# Preview mode is scoped: with explicit-preview-rules only rules named in +# select run in preview, prefixes like "PL" keep their stable set. +preview = true +explicit-preview-rules = true select = [ "B", # flake8-bugbear "BLE", # flake8-blind-except @@ -131,6 +135,7 @@ select = [ "PGH", # pygrep-hooks "PIE", # flake8-pie "PL", # pylint + "PLW1514", # require explicit encoding on text file I/O (Windows defaults to cp1252) "PTH", # flake8-use-pathlib "PYI", # flake8-pyi "Q", # flake8-quotes diff --git a/script/check_import_time.py b/script/check_import_time.py index 0d5362c968..0f2b395902 100755 --- a/script/check_import_time.py +++ b/script/check_import_time.py @@ -194,7 +194,7 @@ def cmd_update(args: argparse.Namespace) -> int: def cmd_har_only(args: argparse.Namespace) -> int: - Path(args.har).write_text(run_waterfall(TARGET_MODULE)) + Path(args.har).write_text(run_waterfall(TARGET_MODULE), encoding="utf-8") print(f"Wrote waterfall HAR to {args.har}") return 0 diff --git a/script/ci_memory_impact_extract.py b/script/ci_memory_impact_extract.py index 6e999a29d6..2d74362169 100755 --- a/script/ci_memory_impact_extract.py +++ b/script/ci_memory_impact_extract.py @@ -269,7 +269,7 @@ def main() -> int: if args.output_build_dir and build_dir: build_dir_path = Path(args.output_build_dir) build_dir_path.parent.mkdir(parents=True, exist_ok=True) - build_dir_path.write_text(build_dir) + build_dir_path.write_text(build_dir, encoding="utf-8") print(f"Wrote build directory to {args.output_build_dir}", file=sys.stderr) # Run detailed analysis if build directory available diff --git a/script/setup_codspeed_lib.py b/script/setup_codspeed_lib.py index 4f5d1bff24..9ddfee27cc 100755 --- a/script/setup_codspeed_lib.py +++ b/script/setup_codspeed_lib.py @@ -84,7 +84,7 @@ def _read_codspeed_version(cmake_path: Path) -> str: """Extract CODSPEED_VERSION from core/CMakeLists.txt.""" if not cmake_path.exists(): return "0.0.0" - for line in cmake_path.read_text().splitlines(): + for line in cmake_path.read_text(encoding="utf-8").splitlines(): if line.startswith("set(CODSPEED_VERSION"): return line.split()[1].rstrip(")") return "0.0.0" diff --git a/script/test_build_components.py b/script/test_build_components.py index c733e2fa3d..ddd8a6a67d 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -367,7 +367,7 @@ def run_esphome_test( output_file = build_dir / f"{component}.{test_name}.{platform_with_version}.yaml" # Copy base file and substitute component test file reference - base_content = base_file.read_text() + base_content = base_file.read_text(encoding="utf-8") # Get relative path from build dir to test file repo_root = Path(__file__).parent.parent component_test_ref = f"../../{test_file.relative_to(repo_root / 'tests')}" @@ -524,7 +524,7 @@ def run_grouped_test( # Create test file that includes merged config output_file = build_dir / f"test_{group_name}.{platform_with_version}.yaml" - base_content = base_file.read_text() + base_content = base_file.read_text(encoding="utf-8") merged_ref = merged_config_file.name output_content = base_content.replace("$component_test_file", merged_ref) output_file.write_text(output_content) diff --git a/tests/unit_tests/analyze_memory/test_build_artifacts.py b/tests/unit_tests/analyze_memory/test_build_artifacts.py index 734f21d852..ad2f8c2210 100644 --- a/tests/unit_tests/analyze_memory/test_build_artifacts.py +++ b/tests/unit_tests/analyze_memory/test_build_artifacts.py @@ -22,7 +22,7 @@ def _make_build_dir(tmp_path: Path, name: str = "mydevice") -> Path: def _touch(path: Path) -> Path: path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("") + path.write_text("", encoding="utf-8") return path diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index f5e045077e..e17271e2b4 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -74,13 +74,13 @@ def _write_storage( "framework": "arduino", "core_platform": core_platform, } - storage_path.write_text(json.dumps(data)) + storage_path.write_text(json.dumps(data), encoding="utf-8") def _write_cache(cache_path: Path, body: str = _VALIDATED_CONFIG_YAML) -> Path: """Write the cache file and return it.""" cache_path.parent.mkdir(parents=True, exist_ok=True) - cache_path.write_text(body) + cache_path.write_text(body, encoding="utf-8") return cache_path From 41902d75380f8190c83eaa39fede0dcd363eb8da Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:30:49 -0400 Subject: [PATCH 1140/1815] Bump pypa/gh-action-pypi-publish from 1.14.1 to 1.14.2 (#17945) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3f2f4b56e5..7f11b292e4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,7 +70,7 @@ jobs: pip3 install build python3 -m build - name: Publish - uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: skip-existing: true From be7748ccb6785a000b11b335c101a7600b5f6c51 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:35:07 -0400 Subject: [PATCH 1141/1815] Bump CodSpeedHQ/action from 4.19.1 to 5.0.1 (#17946) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8b39182f8..30deee01f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -496,7 +496,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@f22792bfac16f3e14eb9fbea76f4a48e9cc22b93 # v4.19.1 + uses: CodSpeedHQ/action@88472375d0a4572cf70a9f1fe3a4e0ab8da1b924 # v5.0.1 with: run: | . venv/bin/activate From c7d16dde15a4d3e52037009b80445ef7b818380f Mon Sep 17 00:00:00 2001 From: matt123p Date: Wed, 29 Jul 2026 21:48:43 +0200 Subject: [PATCH 1142/1815] [voice_assistant] Fix playback when CPU is loaded (#17043) Co-authored-by: Kevin Ahrendt --- esphome/components/voice_assistant/voice_assistant.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index f13ea39fa2..76ae145b16 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -978,11 +978,12 @@ void VoiceAssistant::on_event(const api::VoiceAssistantEventResponse &msg) { void VoiceAssistant::on_audio(const api::VoiceAssistantAudio &msg) { #ifdef USE_SPEAKER // We should never get to this function if there is no speaker anyway if ((this->speaker_ != nullptr) && (this->speaker_buffer_ != nullptr)) { - if (this->speaker_buffer_index_ + msg.data_len < SPEAKER_BUFFER_SIZE) { + if (this->speaker_buffer_index_ + msg.data_len <= SPEAKER_BUFFER_SIZE) { memcpy(this->speaker_buffer_ + this->speaker_buffer_index_, msg.data, msg.data_len); this->speaker_buffer_index_ += msg.data_len; this->speaker_buffer_size_ += msg.data_len; this->speaker_bytes_received_ += msg.data_len; + this->write_speaker_(); ESP_LOGV(TAG, "Received audio: %u bytes from API", msg.data_len); } else { ESP_LOGE(TAG, "Cannot receive audio, buffer is full"); From a99d03e17232d6272a10d3ff09d97da27579de6b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:49:19 -0400 Subject: [PATCH 1143/1815] Bump docker/login-action from 4.5.2 to 4.6.0 in the docker-actions group (#17944) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-docker.yml | 4 ++-- .github/workflows/release.yml | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index f127b43c70..30aa511e29 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -98,7 +98,7 @@ jobs: - name: Log in to the GitHub container registry if: steps.tag.outputs.push == 'true' - uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -156,7 +156,7 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to the GitHub container registry - uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7f11b292e4..839b805237 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,12 +102,12 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to docker hub - uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry - uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -182,13 +182,13 @@ jobs: - name: Log in to docker hub if: matrix.registry == 'dockerhub' - uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry if: matrix.registry == 'ghcr' - uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} From be78b8011d214435f47cb5b406694aa5d310bdfd Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:17:39 -0400 Subject: [PATCH 1144/1815] [esp32] Add platformio toolchain deprecation warning (#17947) --- esphome/components/esp32/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 9f3d7f1dc9..67d4b76e44 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1237,6 +1237,13 @@ def final_validate(config): from .gpio import final_validate_pins + # Remove before 2027.2.0 + if CORE.using_toolchain_platformio: + _LOGGER.warning( + "The 'platformio' toolchain for ESP32 is deprecated and will be removed " + "in ESPHome 2027.2.0. Please use 'toolchain: esp-idf' instead." + ) + errs = [] conf_fw = config[CONF_FRAMEWORK] advanced = conf_fw[CONF_ADVANCED] From fb1b1db87f4b17125279b2c311a0ded7254badbc Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:04:36 -0400 Subject: [PATCH 1145/1815] [esp32][mdns][runtime_image] Bump recommended Arduino to 3.3.11 and platform to 55.03.311 (#17875) --- esphome/components/esp32/__init__.py | 14 ++++++++------ esphome/components/mdns/__init__.py | 2 +- esphome/components/runtime_image/__init__.py | 2 +- esphome/idf_component.yml | 4 ++-- platformio.ini | 6 +++--- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 67d4b76e44..8219b0c6d8 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -814,14 +814,15 @@ def _is_framework_url(source: str) -> bool: # The default/recommended arduino framework version # - https://github.com/espressif/arduino-esp32/releases ARDUINO_FRAMEWORK_VERSION_LOOKUP = { - "recommended": cv.Version(3, 3, 10), - "latest": cv.Version(3, 3, 10), - "dev": cv.Version(3, 3, 10), + "recommended": cv.Version(3, 3, 11), + "latest": cv.Version(3, 3, 11), + "dev": cv.Version(3, 3, 11), } ARDUINO_PLATFORM_VERSION_LOOKUP = { cv.Version( 4, 0, 0, "alpha1" ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", + cv.Version(3, 3, 11): cv.Version(55, 3, 311), cv.Version(3, 3, 10): cv.Version(55, 3, 39), cv.Version(3, 3, 9): cv.Version(55, 3, 39), cv.Version(3, 3, 8): cv.Version(55, 3, 38, "1"), @@ -845,6 +846,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = { # See: https://github.com/pioarduino/esp-idf/releases ARDUINO_IDF_VERSION_LOOKUP = { cv.Version(4, 0, 0, "alpha1"): cv.Version(6, 0, 1), + cv.Version(3, 3, 11): cv.Version(5, 5, 5), cv.Version(3, 3, 10): cv.Version(5, 5, 5), cv.Version(3, 3, 9): cv.Version(5, 5, 4), cv.Version(3, 3, 8): cv.Version(5, 5, 4), @@ -879,7 +881,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { cv.Version( 6, 0, 0 ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", - cv.Version(5, 5, 5): cv.Version(55, 3, 39), + cv.Version(5, 5, 5): cv.Version(55, 3, 311), cv.Version(5, 5, 4): cv.Version(55, 3, 39), cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37), cv.Version(5, 5, 3): cv.Version(55, 3, 37), @@ -900,8 +902,8 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { # The platform-espressif32 version # - https://github.com/pioarduino/platform-espressif32/releases PLATFORM_VERSION_LOOKUP = { - "recommended": cv.Version(55, 3, 39), - "latest": cv.Version(55, 3, 39), + "recommended": cv.Version(55, 3, 311), + "latest": cv.Version(55, 3, 311), "dev": "https://github.com/pioarduino/platform-espressif32.git#develop", } diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 3670098bcf..2d4f6085e5 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -209,7 +209,7 @@ async def to_code(config): ethernet.request_ethernet_ip_state_listener() if CORE.is_esp32: - add_idf_component(name="espressif/mdns", ref="1.11.0") + add_idf_component(name="espressif/mdns", ref="1.11.3") cg.add_define("USE_MDNS") diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index 8db69aa53e..d8517d4493 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -82,7 +82,7 @@ class JPEGFormat(Format): # JPEGDEC uses ESP32-S3 SIMD optimizations (guarded by board-level # ARDUINO_ESP32S3_DEV define) that require esp-dsp headers. # On Arduino this overwrites the stub; on IDF it adds the component. - add_idf_component(name="espressif/esp-dsp", ref="1.7.1") + add_idf_component(name="espressif/esp-dsp", ref="1.8.2") class PNGFormat(Format): diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 45efd20bf6..e88c0649ea 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -18,13 +18,13 @@ dependencies: esphome/micro-wav: version: 0.2.0 espressif/esp-dsp: - version: "1.7.1" + version: "1.8.2" espressif/esp-tflite-micro: version: 1.3.3~1 espressif/esp32-camera: version: 2.1.7 espressif/mdns: - version: 1.11.0 + version: 1.11.3 espressif/esp_wifi_remote: version: 1.5.1 rules: diff --git a/platformio.ini b/platformio.ini index 255a900367..9f2ac74ac0 100644 --- a/platformio.ini +++ b/platformio.ini @@ -141,9 +141,9 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script ; This are common settings for the ESP32 (all variants) using Arduino. [common:esp32-arduino] extends = common:arduino -platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip platform_packages = - pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.10/esp32-core-3.3.10.tar.xz + pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.11/esp32-core-3.3.11.tar.xz pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz framework = arduino, espidf ; Arduino as an ESP-IDF component @@ -178,7 +178,7 @@ extra_scripts = ; This are common settings for the ESP32 (all variants) using IDF. [common:esp32-idf] extends = common:idf -platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip platform_packages = pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz From 4224d905674b27e38852a28ea1bc23b4237acb00 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:32:21 -0400 Subject: [PATCH 1146/1815] [runtime_image] Allow decoding into a caller-owned buffer (#17936) --- .../runtime_image/runtime_image.cpp | 53 +++++++++++++++---- .../components/runtime_image/runtime_image.h | 46 ++++++++++++++-- 2 files changed, 84 insertions(+), 15 deletions(-) diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index 4b12478e4f..8fe9be4c8c 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -248,9 +248,15 @@ void RuntimeImage::release() { void RuntimeImage::release_buffer_() { if (this->buffer_) { - ESP_LOGV(TAG, "Releasing buffer of size %zu", this->get_buffer_size_(this->buffer_width_, this->buffer_height_)); - RAMAllocator allocator; - allocator.deallocate(this->buffer_, this->get_buffer_size_(this->buffer_width_, this->buffer_height_)); + if (this->external_buffer_) { + // The caller owns this memory and goes on using it after the image lets go of it. + ESP_LOGV(TAG, "Letting go of the external %dx%d buffer", this->buffer_width_, this->buffer_height_); + this->external_buffer_ = false; + } else { + ESP_LOGV(TAG, "Releasing buffer of size %zu", this->get_buffer_size(this->buffer_width_, this->buffer_height_)); + RAMAllocator allocator; + allocator.deallocate(this->buffer_, this->get_buffer_size(this->buffer_width_, this->buffer_height_)); + } this->buffer_ = nullptr; this->data_start_ = nullptr; this->width_ = 0; @@ -263,19 +269,46 @@ void RuntimeImage::release_buffer_() { } } +bool RuntimeImage::set_external_buffer(uint8_t *buffer, int width, int height) { + this->release_buffer_(); + if (buffer == nullptr || this->get_buffer_size(width, height) == 0) { + // Keep the released state rather than remembering a buffer that cannot be decoded into: an + // external buffer that is never handed back would otherwise block every later allocation. + ESP_LOGE(TAG, "Refusing an invalid external buffer for %dx%d", width, height); + return false; + } + this->buffer_ = buffer; + this->external_buffer_ = true; + this->buffer_width_ = width; + this->buffer_height_ = height; + return true; +} + size_t RuntimeImage::resize_buffer_(int width, int height) { - size_t new_size = this->get_buffer_size_(width, height); + size_t new_size = this->get_buffer_size(width, height); + + // A buffer only ever exists with dimensions the image can decode at, so a match here means + // new_size is non-zero. Checking it before the invalid dimension case below lets the external + // buffer be let go of for every decode it cannot serve, not just for valid other dimensions. + if (this->buffer_ && this->buffer_width_ == width && this->buffer_height_ == height) { + // Buffer already allocated with correct size + return new_size; + } + + if (this->external_buffer_) { + ESP_LOGE(TAG, "Image decoded to %dx%d, but the external buffer is %dx%d", width, height, this->buffer_width_, + this->buffer_height_); + // Let the buffer go rather than free memory that belongs to the caller. Dropping it also stops + // a decoder that ignores this failure from publishing a picture it never painted. + this->release_buffer_(); + return 0; + } if (new_size == 0) { ESP_LOGE(TAG, "Refusing to allocate buffer for invalid image dimensions %dx%d", width, height); return 0; } - if (this->buffer_ && this->buffer_width_ == width && this->buffer_height_ == height) { - // Buffer already allocated with correct size - return new_size; - } - // Release old buffer if dimensions changed if (this->buffer_) { this->release_buffer_(); @@ -300,7 +333,7 @@ size_t RuntimeImage::resize_buffer_(int width, int height) { return new_size; } -size_t RuntimeImage::get_buffer_size_(int width, int height) const { +size_t RuntimeImage::get_buffer_size(int width, int height) const { // Dimensions come from a remote image header; reject absurd values so the size math cannot overflow if (width <= 0 || height <= 0 || width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION) { return 0; diff --git a/esphome/components/runtime_image/runtime_image.h b/esphome/components/runtime_image/runtime_image.h index 4bdcdcac9e..10ce980be2 100644 --- a/esphome/components/runtime_image/runtime_image.h +++ b/esphome/components/runtime_image/runtime_image.h @@ -121,9 +121,48 @@ class RuntimeImage : public image::Image { /** * @brief Release the image buffer and free memory. + * + * An external buffer is let go of rather than freed. */ void release(); + /** + * @brief Decode into a buffer the caller owns, instead of one allocated here. + * + * The image never frees an external buffer and never resizes it: a decode that needs other + * dimensions fails as if the allocation had failed, and the buffer is let go of so a decoder + * that ignores that failure cannot publish a picture it did not paint. The caller keeps the + * buffer alive for as long as anything can draw the image, and calls release() (or hands over + * another buffer) before reusing it. + * + * Hand a buffer over before every decode. The image lets go of one whenever a decode fails and + * whenever release() is called, and it does not remember that it ever had one: a decode that + * starts without a buffer allocates its own, which is the runtime allocation this method exists + * to avoid. + * + * The buffer is decoded into as it is handed over, so the caller owns its initial contents. + * Zero it first if anything can draw the image before a decode has painted every pixel. + * + * Do not hand a buffer over while is_decoding() is true. A running decoder keeps scaling values + * for the buffer it started with. + * + * A null buffer or dimensions the image cannot decode at are refused, leaving the image with + * no buffer at all. + * + * @param buffer Memory for a picture of the given size, at least get_buffer_size() bytes. + * @param width Width of the buffer in pixels. + * @param height Height of the buffer in pixels. + * @return true if the image took the buffer, false if it was refused. + */ + bool set_external_buffer(uint8_t *buffer, int width, int height); + + /** + * @brief Get the buffer size in bytes needed for a picture of the given dimensions. + * + * Returns 0 for dimensions the image cannot decode at. + */ + size_t get_buffer_size(int width, int height) const; + /** * @brief Set whether to allow progressive display during decode. * @@ -149,11 +188,6 @@ class RuntimeImage : public image::Image { */ void release_buffer_(); - /** - * @brief Get the buffer size in bytes for given dimensions. - */ - size_t get_buffer_size_(int width, int height) const; - /** * @brief Get the position in the buffer for a pixel. */ @@ -208,6 +242,8 @@ class RuntimeImage : public image::Image { * This is used to determine how to store 16 bit colors in the buffer. */ bool is_big_endian_{false}; + /** Whether buffer_ belongs to the caller, so it must not be freed or resized here. */ + bool external_buffer_{false}; }; } // namespace esphome::runtime_image From c23a107a87f291b066202f680ff0a7b74c69e969 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:36:28 +1200 Subject: [PATCH 1147/1815] [config_validation] Add underscore/hyphen normalization to one_of (#17792) --- esphome/config_validation.py | 7 ++- tests/unit_tests/test_config_validation.py | 66 ++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index a62c8f2675..8289f56df3 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1815,6 +1815,8 @@ def one_of(*values, **kwargs): - *int* (``bool``, default=False): Whether to convert the incoming values to integers. - *float* (``bool``, default=False): Whether to convert the incoming values to floats. - *space* (``str``, default=' '): What to convert spaces in the input string to. + - *underscore* (``str``, default='_'): What to convert underscores in the input string to. + - *hyphen* (``str``, default='-'): What to convert hyphens in the input string to. """ options = ", ".join(f"'{x}'" for x in values) lower = kwargs.pop("lower", False) @@ -1823,8 +1825,11 @@ def one_of(*values, **kwargs): to_int = kwargs.pop("int", False) to_float = kwargs.pop("float", False) space = kwargs.pop("space", " ") + underscore = kwargs.pop("underscore", "_") + hyphen = kwargs.pop("hyphen", "-") if kwargs: raise ValueError + separators = str.maketrans({" ": space, "_": underscore, "-": hyphen}) @schema_extractor("one_of") def validator(value): @@ -1833,7 +1838,7 @@ def one_of(*values, **kwargs): if string_: value = string(value) - value = value.replace(" ", space) + value = value.translate(separators) if to_int: value = int_(value) if to_float: diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 864fbe6475..79bfc303b7 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -2428,6 +2428,58 @@ def test_one_of_string_and_space() -> None: assert cv.one_of("a_b", string=True, space="_")("a b") == "a_b" +def test_one_of_string_and_underscore() -> None: + assert cv.one_of("a-b", string=True, underscore="-")("a_b") == "a-b" + assert cv.one_of("a-b", string=True, underscore="-")("a-b") == "a-b" + + +def test_one_of_string_lower_space_and_underscore() -> None: + validator = cv.one_of("output-mode", lower=True, space="-", underscore="-") + assert validator("output_mode") == "output-mode" + assert validator("OUTPUT_MODE") == "output-mode" + assert validator("output mode") == "output-mode" + assert validator("output-mode") == "output-mode" + + +def test_one_of_string_underscore_unknown() -> None: + with pytest.raises(Invalid): + cv.one_of("a-b", string=True, underscore="-")("c_d") + + +def test_one_of_string_underscore_default_unchanged() -> None: + with pytest.raises(Invalid): + cv.one_of("a-b", string=True)("a_b") + + +def test_one_of_string_and_hyphen() -> None: + assert cv.one_of("a_b", string=True, hyphen="_")("a-b") == "a_b" + assert cv.one_of("a_b", string=True, hyphen="_")("a_b") == "a_b" + + +def test_one_of_string_lower_space_and_hyphen() -> None: + validator = cv.one_of("output_mode", lower=True, space="_", hyphen="_") + assert validator("output-mode") == "output_mode" + assert validator("OUTPUT-MODE") == "output_mode" + assert validator("output mode") == "output_mode" + assert validator("output_mode") == "output_mode" + + +def test_one_of_string_hyphen_unknown() -> None: + with pytest.raises(Invalid): + cv.one_of("a_b", string=True, hyphen="_")("c-d") + + +def test_one_of_string_hyphen_default_unchanged() -> None: + with pytest.raises(Invalid): + cv.one_of("a_b", string=True)("a-b") + + +def test_one_of_string_underscore_hyphen_swap_no_cascade() -> None: + validator = cv.one_of("a-b", "a_b", string=True, underscore="-", hyphen="_") + assert validator("a_b") == "a-b" + assert validator("a-b") == "a_b" + + def test_one_of_int() -> None: assert cv.one_of(1, 2, int=True)("2") == 2 @@ -2466,6 +2518,20 @@ def test_enum_valid() -> None: assert result.enum_value == 10 +def test_enum_valid_with_underscore() -> None: + mapping = {"a-b": 1} + result = cv.enum(mapping, string=True, underscore="-")("a_b") + assert result == "a-b" + assert result.enum_value == 1 + + +def test_enum_valid_with_hyphen() -> None: + mapping = {"a_b": 1} + result = cv.enum(mapping, string=True, hyphen="_")("a-b") + assert result == "a_b" + assert result.enum_value == 1 + + # --------------------------------------------------------------------------- # lambda_ / returning_lambda # --------------------------------------------------------------------------- From 6733e930eb165f3b9ef752d5a7d8a94f5cda2dfc Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:01:17 -0400 Subject: [PATCH 1148/1815] Bump bundled esphome-device-builder to 1.8.0 (#17953) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 4a9edb1b64..1f6ab8fcf7 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.7.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.8.0 RUN \ platformio settings set enable_telemetry No \ From d64e1ebba38b76d7788ecb78ff291c1366649ae8 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 30 Jul 2026 11:31:32 -0700 Subject: [PATCH 1149/1815] [ble_client] Do not walk the GATT cache after releasing services (#17919) --- esphome/components/ble_client/ble_client.cpp | 4 ++- esphome/components/ble_client/ble_client.h | 5 ++++ .../ble_client/sensor/ble_sensor.cpp | 3 +-- .../text_sensor/ble_text_sensor.cpp | 3 +-- .../esp32_ble_client/ble_client_base.cpp | 27 +++++++++++++++++++ .../esp32_ble_client/ble_client_base.h | 14 +++++++++- 6 files changed, 50 insertions(+), 6 deletions(-) diff --git a/esphome/components/ble_client/ble_client.cpp b/esphome/components/ble_client/ble_client.cpp index d41fb17961..25001c8f74 100644 --- a/esphome/components/ble_client/ble_client.cpp +++ b/esphome/components/ble_client/ble_client.cpp @@ -51,7 +51,9 @@ bool BLEClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t es for (auto *node : this->nodes_) node->gattc_event_handler(event, esp_gattc_if, param); - if (!this->services_.empty() && this->all_nodes_established_()) { + // The release frees the GATT cache that BLEClientBase's CCCD lookup still needs. + // The last REG_FOR_NOTIFY event clears the counter before node dispatch, so the release still runs here. + if (!this->services_.empty() && !this->notify_registration_pending() && this->all_nodes_established_()) { this->release_services(); ESP_LOGD(TAG, "All clients established, services released"); } diff --git a/esphome/components/ble_client/ble_client.h b/esphome/components/ble_client/ble_client.h index f27bef332b..f20df31816 100644 --- a/esphome/components/ble_client/ble_client.h +++ b/esphome/components/ble_client/ble_client.h @@ -34,6 +34,11 @@ class BLEClientNode { // This should be transitioned to Established once the node no longer needs // the services/descriptors/characteristics of the parent client. This will // allow some memory to be freed. + // The parent frees the peer's GATT cache once every node reports Established. + // Never report Established while an operation that reads that cache is outstanding. + // - esp_ble_gattc_register_for_notify() completes asynchronously. + // - Register from ESP_GATTC_SEARCH_CMPL_EVT, then set this from ESP_GATTC_REG_FOR_NOTIFY_EVT. + // - BLEClientBase::register_for_notify() holds the release until the registration completes. espbt::ClientState node_state; BLEClient *parent() { return this->parent_; } diff --git a/esphome/components/ble_client/sensor/ble_sensor.cpp b/esphome/components/ble_client/sensor/ble_sensor.cpp index 4bd871dc81..60992f282e 100644 --- a/esphome/components/ble_client/sensor/ble_sensor.cpp +++ b/esphome/components/ble_client/sensor/ble_sensor.cpp @@ -77,8 +77,7 @@ void BLESensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga this->handle = descr->handle; } if (this->notify_) { - auto status = esp_ble_gattc_register_for_notify(this->parent()->get_gattc_if(), - this->parent()->get_remote_bda(), chr->handle); + auto status = this->parent()->register_for_notify(chr->handle); if (status) { ESP_LOGW(TAG, "esp_ble_gattc_register_for_notify failed, status=%d", status); } diff --git a/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp b/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp index 7eaa6af076..6f09281922 100644 --- a/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp +++ b/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp @@ -77,8 +77,7 @@ void BLETextSensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->handle = descr->handle; } if (this->notify_) { - auto status = esp_ble_gattc_register_for_notify(this->parent()->get_gattc_if(), - this->parent()->get_remote_bda(), chr->handle); + auto status = this->parent()->register_for_notify(chr->handle); if (status) { ESP_LOGW(TAG, "esp_ble_gattc_register_for_notify failed, status=%d", status); } diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 3fb9632e9a..bd80f71a49 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -125,6 +125,9 @@ void BLEClientBase::connect() { } ESP_LOGI(TAG, "[%d] [%s] 0x%02x Connecting", this->connection_index_, this->address_str_, this->remote_addr_type_); this->paired_ = false; + // A registration whose event never arrived must not block this connection's release. + this->services_released_ = false; + this->pending_notify_regs_ = 0; // Enable loop for state processing this->enable_loop(); // Immediately transition to CONNECTING to prevent duplicate connection attempts @@ -200,10 +203,26 @@ void BLEClientBase::release_services() { this->services_.clear(); #endif #ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH + // Only the cache clean makes the stack's database unsafe to walk. + this->services_released_ = true; esp_ble_gattc_cache_clean(this->remote_bda_); #endif } +esp_err_t BLEClientBase::register_for_notify(uint16_t char_handle) { + esp_err_t err = esp_ble_gattc_register_for_notify(this->gattc_if_, this->remote_bda_, char_handle); + if (err != ESP_OK) + return err; + if (this->pending_notify_regs_ == UINT8_MAX) { + // Saturating undercounts, so the release can run before the last registration completes. + // Wrapping to zero would undercount by the full range instead, which is worse. + this->log_warning_("Too many outstanding notify registrations to track"); + return err; + } + this->pending_notify_regs_++; + return err; +} + void BLEClientBase::log_event_(const char *name) { ESP_LOGD(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_, name); } @@ -498,12 +517,20 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ } case ESP_GATTC_REG_FOR_NOTIFY_EVT: { this->log_gattc_data_event_("REG_FOR_NOTIFY"); + // The event carries no conn_id, so this is the only place the request can be retired. + if (this->pending_notify_regs_ > 0) + this->pending_notify_regs_--; if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { // Client is responsible for flipping the descriptor value // when using the cache break; } + if (this->services_released_) { + // The lookup below walks the freed GATT cache, and Bluedroid asserts on it rather than erroring. + this->log_warning_("REG_FOR_NOTIFY after services released, notifications not enabled"); + break; + } esp_gattc_descr_elem_t desc_result; uint16_t count = 1; esp_gatt_status_t descr_status = esp_ble_gattc_get_descr_by_char_handle( diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 0291a4b993..0902aad924 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -44,6 +44,12 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void unconditional_disconnect(); void release_services(); + /// Register for notifications, holding the service release until the registration completes. + esp_err_t register_for_notify(uint16_t char_handle); + + /// True while a register_for_notify() request has not completed. + bool notify_registration_pending() const { return this->pending_notify_regs_ > 0; } + bool connected() { return this->state() == espbt::ClientState::ESTABLISHED; } void set_auto_connect(bool auto_connect) { this->auto_connect_ = auto_connect; } @@ -125,9 +131,15 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { espbt::ConnectionType connection_type_{espbt::ConnectionType::V1}; uint8_t connection_index_; uint8_t service_count_{0}; // ESP32 has max handles < 255, typical devices have < 50 services + // Outstanding register_for_notify() requests + // A count, not per-request state, so a raw esp_ble_gattc_register_for_notify() on the same client can retire one + // services_released_ is the backstop if that ever lets the release run early + uint8_t pending_notify_regs_{0}; bool auto_connect_{false}; bool paired_{false}; - // 6 bytes used, 2 bytes padding + // Set only when release_services() cleans the stack's GATT cache, which no API may then walk + bool services_released_{false}; + // 8 bytes used, no padding void log_event_(const char *name); void log_gattc_lifecycle_event_(const char *name); From 33ccaf8a8551f8126ecc04804f0faba9e7be663f Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:54:39 -1000 Subject: [PATCH 1150/1815] Bump bundled esphome-device-builder to 1.8.1 (#17964) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 1f6ab8fcf7..4aa589fc5e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.8.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.8.1 RUN \ platformio settings set enable_telemetry No \ From 60662830dc08e3a5bbec5452a2f3cac10c753cfc Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:41:10 -0400 Subject: [PATCH 1151/1815] [ci] Switch stale to the shared real-activity workflow (#17960) --- .github/workflows/stale.yml | 83 +++++++++++++++---------------------- 1 file changed, 33 insertions(+), 50 deletions(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 6c90c2ee97..477f05ba34 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -6,61 +6,44 @@ on: - cron: "30 0 * * *" workflow_dispatch: -permissions: - issues: write # actions/stale labels, comments on, and closes stale issues - pull-requests: write # actions/stale labels, comments on, and closes stale pull requests - -concurrency: - group: lock +# Deny by default; the stale job opts in to exactly what the reusable workflow needs. +permissions: {} jobs: stale: if: github.repository_owner == 'esphome' - runs-on: ubuntu-latest - steps: - - name: Stale - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 - with: - debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch - remove-stale-when-updated: true - operations-per-run: 400 + permissions: + contents: read # head-commit dates for the PR activity check + issues: write # label, comment on and close stale issues + pull-requests: write # label, comment on and close stale pull requests + uses: esphome/workflows/.github/workflows/stale.yml@c87be24a6fd0320ecd32e40b27fe98d25c3af851 # 2026.7.0 + with: + # Live only on dev: a workflow_dispatch from any other branch is a dry run + dry-run: ${{ github.ref != 'refs/heads/dev' }} + days-before-stale: 90 + days-before-close: 7 + stale-label: stale + exempt-label: not-stale + ignored-users: esphbot,codecov-commenter + stale-pr-message: > + There hasn't been any activity on this pull request recently. This + pull request has been automatically marked as stale because of that + and will be closed if no further activity occurs within 7 days. - # The 90 day stale policy for PRs - # - PRs - # - No PRs marked as "not-stale" - # - No Issues (see below) - days-before-pr-stale: 90 - days-before-pr-close: 7 - stale-pr-label: "stale" - exempt-pr-labels: "not-stale" - stale-pr-message: > - There hasn't been any activity on this pull request recently. This - pull request has been automatically marked as stale because of that - and will be closed if no further activity occurs within 7 days. + If you are the author of this PR, please leave a comment if you want + to keep it open. Also, please rebase your PR onto the latest dev + branch to ensure that it's up to date with the latest changes. - If you are the author of this PR, please leave a comment if you want - to keep it open. Also, please rebase your PR onto the latest dev - branch to ensure that it's up to date with the latest changes. + Thank you for your contribution! + stale-issue-message: > + There hasn't been any activity on this issue recently. Due to the + high number of incoming GitHub notifications, we have to clean some + of the old issues, as many of them have already been resolved with + the latest updates. - Thank you for your contribution! + Please make sure to update to the latest ESPHome version and + check if that solves the issue. Let us know if that works for you by + adding a comment 👍 - # The 90 day stale policy for Issues - # - Issues - # - No Issues marked as "not-stale" - # - No PRs (see above) - days-before-issue-stale: 90 - days-before-issue-close: 7 - stale-issue-label: "stale" - exempt-issue-labels: "not-stale" - stale-issue-message: > - There hasn't been any activity on this issue recently. Due to the - high number of incoming GitHub notifications, we have to clean some - of the old issues, as many of them have already been resolved with - the latest updates. - - Please make sure to update to the latest ESPHome version and - check if that solves the issue. Let us know if that works for you by - adding a comment 👍 - - This issue has now been marked as stale and will be closed if no - further activity occurs. Thank you for your contributions. + This issue has now been marked as stale and will be closed if no + further activity occurs. Thank you for your contributions. From a87ad66746bb8e4ff754b99bede01806dfb1e79f Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:25:17 -0500 Subject: [PATCH 1152/1815] [epaper_spi] Default init sequence to empty not None (#17966) Co-authored-by: Claude Sonnet 5 --- .../components/epaper_spi/models/__init__.py | 2 +- .../config/t133a01_no_init_sequence.yaml | 26 +++++++++++++++++++ tests/component_tests/epaper_spi/test_init.py | 21 +++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 tests/component_tests/epaper_spi/config/t133a01_no_init_sequence.yaml diff --git a/esphome/components/epaper_spi/models/__init__.py b/esphome/components/epaper_spi/models/__init__.py index 2360b090ff..34e65061f3 100644 --- a/esphome/components/epaper_spi/models/__init__.py +++ b/esphome/components/epaper_spi/models/__init__.py @@ -15,7 +15,7 @@ class EpaperModel: self, name: str, class_name: str, - initsequence=None, + initsequence=(), **defaults, ): name = name.upper() diff --git a/tests/component_tests/epaper_spi/config/t133a01_no_init_sequence.yaml b/tests/component_tests/epaper_spi/config/t133a01_no_init_sequence.yaml new file mode 100644 index 0000000000..1032927146 --- /dev/null +++ b/tests/component_tests/epaper_spi/config/t133a01_no_init_sequence.yaml @@ -0,0 +1,26 @@ +esphome: + name: test + +esp32: + board: esp32-s3-devkitc-1 + variant: esp32s3 + framework: + type: esp-idf + +spi: + clk_pin: GPIO18 + mosi_pin: GPIO19 + +display: + - platform: epaper_spi + id: epaper_display + model: t133a01 + dc_pin: GPIO21 + reset_pin: GPIO38 + cs_pin: GPIO10 + cs1_pin: GPIO2 + busy_pin: GPIO13 + update_interval: never + dimensions: + width: 200 + height: 200 diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index 1396c18e3b..7a0507542e 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -462,3 +462,24 @@ def test_enable_pin_code_generation( # Both pin objects must be passed to the display via set_enable_pins() as a # std::vector initializer list, in the configured order. assert f"set_enable_pins({{{pin_25}, {pin_26}}});" in main_cpp + + +def test_model_with_no_default_init_sequence_generates( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Test that code generation succeeds for a model with no default init sequence. + + The base "t133a01" model (used directly, not via one of its `.extend()` + variants) doesn't override `get_init_sequence()` or pass `initsequence` to + its constructor, and the user didn't supply `init_sequence:` either. + `EpaperModel.get_init_sequence()` used to default to `None` in this case, + which made `flatten_sequence()` raise a `TypeError` during code + generation. Regression test for that crash. + """ + main_cpp = generate_main(component_config_path("t133a01_no_init_sequence.yaml")) + + # The generated constructor call takes (name, width, height, init_sequence, + # init_sequence_length, ...); a length of 0 confirms the empty init + # sequence array was generated instead of raising during code generation. + assert re.search(r"epaper_spi::EPaperT133A01\([^;]*,\s*\w+,\s*0\);", main_cpp) From 4951f4fc2e5353b2dc813006917ea344cbf83f89 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Jul 2026 11:44:12 -1000 Subject: [PATCH 1153/1815] [git] Fix device adoption failing on first attempt: lock the clone cache against concurrent resolutions (#17923) --- esphome/components/packages/__init__.py | 22 +- esphome/git.py | 420 ++++++++++-- requirements.txt | 2 +- .../component_tests/packages/test_packages.py | 69 ++ tests/unit_tests/test_git.py | 603 +++++++++++++++++- 5 files changed, 1068 insertions(+), 48 deletions(-) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 44a1ebf36e..6cb9d5f03a 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -1,6 +1,7 @@ from collections import UserDict from collections.abc import Callable from functools import reduce +import logging from pathlib import Path from typing import Any @@ -35,6 +36,8 @@ from esphome.const import ( ) from esphome.core import EsphomeError +_LOGGER = logging.getLogger(__name__) + DOMAIN = CONF_PACKAGES # Guard against infinite include chains (e.g. A includes B includes A). MAX_INCLUDE_DEPTH = 20 @@ -267,8 +270,23 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]: # If loading fails, the cached checkout may be stale — revert and retry once. try: return {CONF_PACKAGES: get_packages(files)} - except cv.Invalid: - revert() + except cv.Invalid as err: + if not revert(): + # The pre-update content is out of reach (lock timeout, the + # checkout moved, or the reset failed; see the log), so a + # retry could not see it. + raise cv.Invalid( + f"Failed to load packages and could not revert the cached " + f"checkout to retry. {err}", + path=err.path, + ) from err + # If the retry succeeds this is the only trace that the + # refreshed upstream content was broken. + _LOGGER.warning( + "Loading packages failed (%s), reverted the cached checkout " + "and retrying", + err, + ) try: return {CONF_PACKAGES: get_packages(files)} except cv.Invalid as err: diff --git a/esphome/git.py b/esphome/git.py index 46cce50d9d..b5abf39a24 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -1,5 +1,8 @@ -from collections.abc import Callable +from collections.abc import Callable, Iterator +from contextlib import contextmanager from dataclasses import dataclass +from enum import Enum, auto +import errno import hashlib import logging import os @@ -8,23 +11,42 @@ import re import subprocess import sys import time +from typing import TYPE_CHECKING import urllib.parse import esphome.config_validation as cv from esphome.core import CORE, EsphomeError, TimePeriodSeconds from esphome.helpers import add_git_ceiling_directory, rmtree, write_file +if TYPE_CHECKING: + from filelock import FileLock + _LOGGER = logging.getLogger(__name__) # Special value to indicate never refresh NEVER_REFRESH = TimePeriodSeconds(seconds=-1) -# Written inside .git only after every clone step (clone, ref fetch, reset, -# submodule init) has completed. A directory without it is an interrupted -# clone (e.g. the process was killed mid-clone) and must be re-cloned; without -# this check such a directory would be trusted forever when the caller uses -# NEVER_REFRESH. Lives in .git so stash/reset/checkout can never touch it and -# it does not pollute the worktree. +# revert() runs on an already-failing path; bound its wait for the cache +# entry lock so that recovery cannot hang forever behind another process. +_REVERT_LOCK_TIMEOUT_SECONDS = 60 + +# When a complete cache entry already exists, a caller does not wait forever +# behind another process's stalled clone or update (git sets no network +# timeouts): after this bound it uses the existing clone without refreshing +# it. With no complete entry there is nothing to fall back to, so the wait +# is unbounded. +_COMPLETE_ENTRY_LOCK_TIMEOUT_SECONDS = 60 + +# Written inside .git only while the entry is a complete, quiescent +# checkout: after every clone step (clone, ref fetch, reset, submodule init) +# has finished, and removed for the duration of a refresh's rewrite +# (stash/fetch/reset). A directory without it is an interrupted clone or +# update (e.g. the process was killed mid-clone) and must be re-cloned; +# without this check such a directory would be trusted forever when the +# caller uses NEVER_REFRESH, and the bounded-wait fallback would hand a +# mid-rewrite tree to a timed-out peer. Lives in .git so +# stash/reset/checkout can never touch it and it does not pollute the +# worktree. _CLONE_COMPLETE_MARKER = "esphome_clone_complete" # Environment variables that scope git to a specific repository. Git hooks and @@ -149,6 +171,16 @@ def run_git_command( return ret.stdout.decode("utf-8").strip() +def _cache_key(url: str, ref: str | None) -> str: + """Cache key identifying one repository checkout. + + The lock path and the entry directory both hash this, keeping them in + agreement. (micro_wake_word still rebuilds the format by hand to locate + manifests; fold it in here if the format ever changes.) + """ + return f"{url}@{ref}" + + def _compute_destination_path(key: str, domain: str) -> Path: base_dir = Path(CORE.data_dir) / domain h = hashlib.new("sha256") @@ -156,22 +188,195 @@ def _compute_destination_path(key: str, domain: str) -> Path: return base_dir / h.hexdigest()[:8] +def _repo_entry_dir(key: str, domain: str, subpath: Path | None) -> Path: + """Worktree directory of one cache entry: the hash dir plus optional subpath.""" + repo_dir = _compute_destination_path(key, domain) + if subpath: + repo_dir = repo_dir / subpath + return repo_dir + + +def _repo_lock_path(key: str, domain: str) -> Path: + """Path of the lock file serializing all work on one cache entry. + + Lives next to the hash directory, never inside it, so the removal of a + broken or incomplete clone can never delete a lock another process holds. + """ + repo_dir = _compute_destination_path(key, domain) + return repo_dir.parent / f"{repo_dir.name}.lock" + + +class _LockStatus(Enum): + ACQUIRED = auto() + # A bounded wait expired while another process held the lock. + TIMEOUT = auto() + # The lock could not be taken at all; callers proceed unlocked, + # matching the behavior before the lock existed. + UNAVAILABLE = auto() + + +# Errnos that mean the filesystem genuinely cannot take file locks (NFS +# without a lock daemon, some FUSE mounts). Any other OSError (permissions, +# read-only volume, full disk) is a cache directory problem, which the git +# commands themselves report clearly when it actually matters. EPERM is +# deliberately absent: it usually means a permissions problem, so it takes +# the generic message that names no cause. On Linux ENOTSUP and EOPNOTSUPP +# are the same value; the set folds them. +_NO_LOCK_SUPPORT_ERRNOS = frozenset( + {errno.ENOLCK, errno.ENOSYS, errno.EOPNOTSUPP, errno.ENOTSUP} +) + + +def _acquire_repo_lock( + lock: "FileLock", + safe_key: str, + timeout: float, + wait_message: str = "Waiting for another process to finish updating %s", +) -> _LockStatus: + """Acquire ``lock``, logging ``wait_message`` when a wait actually begins. + + ``timeout`` of -1 waits forever; a positive value bounds the wait and + can yield ``TIMEOUT``. + """ + from filelock import Timeout + + try: + try: + lock.acquire(blocking=False) + except Timeout: + # Waiting on another process's clone or update can take + # minutes; say so instead of appearing hung. + _LOGGER.info(wait_message, safe_key) + lock.acquire(timeout=timeout) + except Timeout: + return _LockStatus.TIMEOUT + except OSError as err: + if err.errno in _NO_LOCK_SUPPORT_ERRNOS: + _LOGGER.warning( + "The filesystem does not support locking the cache entry for " + "%s (%s), continuing without a lock", + safe_key, + err, + ) + else: + # Not a locking problem (permissions, read-only volume, full + # disk). Still continue unlocked: a pre-seeded read-only cache + # with refresh disabled only reads and must keep working, and + # in every other case the git commands fail with the real error. + _LOGGER.warning( + "Could not take the cache entry lock for %s (%s), " + "continuing without a lock", + safe_key, + err, + ) + return _LockStatus.UNAVAILABLE + return _LockStatus.ACQUIRED + + +@contextmanager +def _repo_cache_lock( + key: str, domain: str, repo_dir: Path +) -> Iterator[tuple[bool, "FileLock | None"]]: + """Hold the cache entry lock for ``key`` over the with block. + + Yields ``(use_existing, lock)``. ``use_existing`` is True when the lock + could not be acquired within the bounded wait but ``repo_dir`` is a + complete cache entry; the caller should use it as-is and do nothing + else. Otherwise ``lock`` is the held lock, released when the block + exits, or ``None`` when the lock could not be taken at all and the + caller proceeds unlocked. + """ + # Lazy import: keeps filelock off the CLI startup import path. + from filelock import FileLock + + safe_key = _redact_url_credentials(key) + # acquire() creates the lock file's directory itself; git clone later + # creates the hash directory next to it. fallback_to_soft would silently + # downgrade ENOSYS to a SoftFileLock, whose stale existence marker from + # another host on a shared cache could hang the unbounded wait forever; + # routing it through the OSError handler runs unlocked instead. + lock: FileLock | None = FileLock( + str(_repo_lock_path(key, domain)), fallback_to_soft=False + ) + status = _acquire_repo_lock(lock, safe_key, _COMPLETE_ENTRY_LOCK_TIMEOUT_SECONDS) + if status is _LockStatus.TIMEOUT: + if _clone_complete_marker_path(repo_dir).is_file(): + # Mutual exclusion matters most while no complete entry exists + # (initial clone, recovery re-clone); with one on disk, reading + # it beats hanging behind a stalled holder. + _LOGGER.warning( + "Timed out waiting for another process updating %s, proceeding " + "with the existing clone, which that process may still be " + "changing", + safe_key, + ) + yield True, None + return + # Nothing to fall back to; the holder is producing the clone this + # caller needs. + status = _acquire_repo_lock( + lock, + safe_key, + timeout=-1, + wait_message="Still waiting for the clone of %s, " + "there is no existing clone to fall back on", + ) + if status is not _LockStatus.ACQUIRED: + lock = None + try: + yield False, lock + finally: + if lock is not None: + lock.release() + + def _clone_complete_marker_path(repo_dir: Path) -> Path: return repo_dir / ".git" / _CLONE_COMPLETE_MARKER +def _clear_clone_complete_marker(repo_dir: Path) -> None: + """Best-effort removal of the completion marker. + + If the unlink fails (e.g. a file lock on Windows), the marker stays and + the entry keeps its previous trust level; every consumer of the marker + tolerates that. + """ + try: + _clone_complete_marker_path(repo_dir).unlink(missing_ok=True) + except OSError as err: + _LOGGER.debug("Could not delete clone completion marker: %s", err) + + +def _write_clone_complete_marker( + repo_dir: Path, key: str, hash_dir_name: str, safe_key: str +) -> None: + """Mark the entry as a complete, quiescent checkout. + + The key and hash dir name are recorded purely to make cache debugging + easier. The marker is only a validity signal, so a failed write must not + fail an otherwise complete clone or update: the only cost is a re-clone + on the next run. + """ + try: + write_file( + _clone_complete_marker_path(repo_dir), + f"key={key}\nhash={hash_dir_name}\n", + ) + except EsphomeError as err: + _LOGGER.warning( + "Could not write clone completion marker for %s: %s", safe_key, err + ) + + def _remove_repo_dir(repo_dir: Path) -> None: """Remove a repo directory, deleting the completion marker first. Marker-first ordering guarantees an interrupted removal can never leave a marker behind next to a partially deleted worktree. The unlink is best - effort: if it fails (e.g. a file lock on Windows), rmtree below still - gets the chance to remove the directory, marker included. + effort: if it fails, rmtree below still gets the chance to remove the + directory, marker included. """ - try: - _clone_complete_marker_path(repo_dir).unlink(missing_ok=True) - except OSError as err: - _LOGGER.debug("Could not delete clone completion marker first: %s", err) + _clear_clone_complete_marker(repo_dir) if repo_dir.is_dir(): rmtree(repo_dir) @@ -286,16 +491,79 @@ def resolve_symlink_stub(repo_dir: Path, file_path: Path) -> Path | None: def clone_or_update( *, url: str, - ref: str = None, + ref: str | None = None, refresh: TimePeriodSeconds | None, domain: str, - username: str = None, - password: str = None, + username: str | None = None, + password: str | None = None, init_submodules: bool = False, subpath: Path | None = None, +) -> tuple[Path, Callable[[], bool] | None]: + """Clone a repository into the cache, or refresh an existing clone. + + All work runs under a per-cache-entry inter-process file lock, so + concurrent resolutions of the same repository (two esphome processes, or + a subprocess plus an in-process load) serialize instead of interleaving. + Without the lock, ``repo_dir.is_dir()`` is true from the instant + ``git clone`` creates the directory: a second caller could read a half + populated worktree, or see the missing completion marker and delete the + clone in progress out from under the first caller. + + The lock guards mutation of the cache entry only; it is released when + this function returns, so a caller still reading the worktree can + overlap a later refresh by another process. That residual window is + narrow (the refresh interval is re-checked under the lock) and predates + the lock. + + Locking is best effort: on a filesystem that cannot take file locks a + warning is logged and the work proceeds unlocked, matching the behavior + before the lock existed. A complete cache entry also caps the wait: if + the holder is still busy after a bounded time (e.g. stalled on the + network), the existing clone is used without refreshing it, so a stuck + process cannot hang every peer that already has a good entry. + """ + key = _cache_key(url, ref) + repo_dir = _repo_entry_dir(key, domain, subpath) + with _repo_cache_lock(key, domain, repo_dir) as (use_existing, lock): + if use_existing: + return repo_dir, None + return _clone_or_update_locked( + url=url, + ref=ref, + refresh=refresh, + domain=domain, + username=username, + password=password, + init_submodules=init_submodules, + subpath=subpath, + lock=lock, + ) + + +def _clone_or_update_locked( + *, + url: str, + ref: str | None, + refresh: TimePeriodSeconds | None, + domain: str, + username: str | None, + password: str | None, + init_submodules: bool, + subpath: Path | None, + lock: "FileLock | None", _recover_broken: bool = True, -) -> tuple[Path, Callable[[], None] | None]: - key = f"{url}@{ref}" +) -> tuple[Path, Callable[[], bool] | None]: + """Body of ``clone_or_update``; the caller holds ``lock``. + + Split out because the broken-repository recovery below re-enters this + function: re-acquiring the already-held lock would deadlock, since OS + file locks taken on separate file descriptors conflict even within one + process. ``lock`` is only re-acquired by the returned ``revert`` + callback, which runs after the wrapper's ``finally`` has released it. + ``lock`` is ``None`` when the filesystem cannot take file locks and the + wrapper fell back to running unlocked. + """ + key = _cache_key(url, ref) # The user may have embedded credentials in the URL itself; log this # instead of key. safe_key = _redact_url_credentials(key) @@ -309,10 +577,8 @@ def clone_or_update( "://", f"://{urllib.parse.quote(username)}:{urllib.parse.quote(password)}@" ) - repo_dir = _compute_destination_path(key, domain) - hash_dir_name = repo_dir.name - if subpath: - repo_dir = repo_dir / subpath + hash_dir_name = _compute_destination_path(key, domain).name + repo_dir = _repo_entry_dir(key, domain, subpath) if repo_dir.is_dir() and not _clone_complete_marker_path(repo_dir).is_file(): # The last clone never finished (killed process, container stop) or @@ -353,19 +619,8 @@ def clone_or_update( _remove_repo_dir(repo_dir) raise - # Every git step succeeded; the key and hash dir name are recorded - # purely to make cache debugging easier. The marker is only a - # validity signal, so a failed write must not fail an otherwise - # complete clone: the only cost is a re-clone on the next run. - try: - write_file( - _clone_complete_marker_path(repo_dir), - f"key={key}\nhash={hash_dir_name}\n", - ) - except EsphomeError as err: - _LOGGER.warning( - "Could not write clone completion marker for %s: %s", safe_key, err - ) + # Every git step succeeded. + _write_clone_complete_marker(repo_dir, key, hash_dir_name, safe_key) else: if refresh == NEVER_REFRESH or CORE.skip_external_update: @@ -396,6 +651,13 @@ def clone_or_update( _LOGGER.info("Updating %s", safe_key) _LOGGER.debug("Location: %s", repo_dir) + # The entry is about to be rewritten; drop the marker so a + # timed-out peer's fallback and the incomplete-entry check + # can tell a quiescent complete entry from one mid-rewrite, + # and so an update interrupted by a crash re-clones instead + # of being trusted. + _clear_clone_complete_marker(repo_dir) + # Stash local changes (if any) # Use git_dir to ensure this only affects the specific repo run_git_command( @@ -425,6 +687,15 @@ def clone_or_update( # refresh window would silently accept on the next run. if init_submodules: update_submodules(repo_dir, key) + + # Recorded so revert() can tell whether the checkout is + # still the one this update produced. + new_sha = run_git_command( + ["git", "rev-parse", "HEAD"], git_dir=repo_dir + ) + + # The rewrite finished; the entry is trustworthy again. + _write_clone_complete_marker(repo_dir, key, hash_dir_name, safe_key) except GitException as err: # Repository is in a broken state or update failed # Only attempt recovery once to prevent infinite recursion @@ -444,9 +715,10 @@ def clone_or_update( _remove_repo_dir(repo_dir) _LOGGER.info("Successfully removed broken repository, re-cloning...") - # Recursively call clone_or_update to re-clone - # Set _recover_broken=False to prevent infinite recursion - result = clone_or_update( + # Re-clone while still holding the lock; going through the + # public wrapper would try to re-acquire it and deadlock. + # Set _recover_broken=False to prevent infinite recursion. + result = _clone_or_update_locked( url=original_url, ref=ref, refresh=refresh, @@ -455,14 +727,80 @@ def clone_or_update( password=password, init_submodules=init_submodules, subpath=subpath, + lock=lock, _recover_broken=False, ) _LOGGER.info("Repository %s successfully recovered", safe_key) return result - def revert(): - _LOGGER.info("Reverting changes to %s -> %s", safe_key, old_sha) - run_git_command(["git", "reset", "--hard", old_sha], git_dir=repo_dir) + def revert() -> bool: + """Reset the checkout to the pre-update SHA. + + Returns False when the revert did not happen: the cache + entry lock could not be acquired in time, the checkout + moved since this update (another process refreshed it), or + the reset itself failed. A retry cannot reach the + pre-update content then. + """ + if lock is None: + # The wrapper already warned about the unlockable + # filesystem; revert unlocked like everything else. + status = _LockStatus.UNAVAILABLE + else: + status = _acquire_repo_lock( + lock, safe_key, _REVERT_LOCK_TIMEOUT_SECONDS + ) + if status is _LockStatus.TIMEOUT: + # revert() only runs on an already-failing path; skip + # rather than hang so the original error can surface. + _LOGGER.warning( + "Could not lock %s to revert to %s, skipping revert; " + "the cached checkout keeps the un-reverted content " + "until its next refresh", + safe_key, + old_sha, + ) + return False + try: + # Anything can happen between the wrapper releasing the + # lock and revert() re-acquiring it; only undo this + # process's own update, never a peer's newer refresh. + head = run_git_command( + ["git", "rev-parse", "HEAD"], git_dir=repo_dir + ) + if head != new_sha: + _LOGGER.warning( + "Not reverting %s: the checkout moved since this " + "update (another process refreshed it)", + safe_key, + ) + return False + # Announced only once every skip check has passed, so + # the log says exactly one thing per outcome. + _LOGGER.info("Reverting changes to %s -> %s", safe_key, old_sha) + run_git_command( + ["git", "reset", "--hard", old_sha], git_dir=repo_dir + ) + except GitException as err: + # GitException is a cv.Invalid; letting it escape would + # replace the caller's original error with a bare git + # message. Report the failed reset like the skip above, + # and drop the marker: an entry whose reset fails cannot + # be trusted, so the next use re-clones it instead of + # the refresh window silently accepting it. + _LOGGER.warning( + "Could not revert %s to %s (%s), the entry will be " + "re-cloned on next use", + safe_key, + old_sha, + err, + ) + _clear_clone_complete_marker(repo_dir) + return False + finally: + if status is _LockStatus.ACQUIRED: + lock.release() + return True return repo_dir, revert diff --git a/requirements.txt b/requirements.txt index ddec22f080..6260e4a44a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 platformdirs==4.11.0 # native esp-idf toolchain global cache dir -filelock==3.32.0 # lock guarding the PlatformIO python-version cache heal +filelock==3.32.0 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 6990c1c051..39bffd31b7 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -1264,6 +1264,75 @@ def test_remote_packages_no_revert( ] +@patch("esphome.yaml_util.load_yaml") +@patch("pathlib.Path.is_file") +@patch("esphome.git.clone_or_update") +def test_remote_packages_skipped_revert_does_not_retry( + mock_clone_or_update, mock_is_file, mock_load_yaml +) -> None: + """When revert() reports the rollback was skipped, the load is not + retried (the checkout is unchanged) and the error says so.""" + mock_revert = MagicMock(return_value=False) + mock_clone_or_update.return_value = (Path("/tmp/noexists"), mock_revert) + mock_is_file.return_value = True + mock_load_yaml.side_effect = cv.Invalid("bad yaml") + + config = { + CONF_PACKAGES: { + "pkg": { + CONF_URL: "https://github.com/esphome/repo", + CONF_REF: "main", + CONF_FILES: [{CONF_PATH: "file.yaml"}], + CONF_REFRESH: "1d", + } + } + } + with pytest.raises(cv.Invalid, match="could not revert the cached checkout"): + packages_pass(config) + + assert mock_revert.call_count == 1 + assert mock_load_yaml.call_count == 1 + + +@patch("esphome.yaml_util.load_yaml") +@patch("pathlib.Path.is_file") +@patch("esphome.git.clone_or_update") +def test_remote_packages_successful_revert_retries( + mock_clone_or_update, mock_is_file, mock_load_yaml, caplog: pytest.LogCaptureFixture +) -> None: + """A successful revert retries the load against the reverted checkout and + logs the original error, the only trace that upstream was broken.""" + mock_revert = MagicMock(return_value=True) + mock_clone_or_update.return_value = (Path("/tmp/noexists"), mock_revert) + mock_is_file.return_value = True + mock_load_yaml.side_effect = [ + cv.Invalid("bad yaml"), + OrderedDict( + {CONF_SENSOR: [{CONF_PLATFORM: TEST_SENSOR_PLATFORM_1, CONF_NAME: "test"}]} + ), + ] + + config = { + CONF_PACKAGES: { + "pkg": { + CONF_URL: "https://github.com/esphome/repo", + CONF_REF: "main", + CONF_FILES: [{CONF_PATH: "file.yaml"}], + CONF_REFRESH: "1d", + } + } + } + with caplog.at_level(logging.WARNING): + actual = packages_pass(config) + + assert actual[CONF_SENSOR] == [ + {CONF_PLATFORM: TEST_SENSOR_PLATFORM_1, CONF_NAME: "test"} + ] + assert mock_revert.call_count == 1 + assert mock_load_yaml.call_count == 2 + assert any("reverted the cached checkout" in r.getMessage() for r in caplog.records) + + def test_raw_config_contains_merged_esphome_from_package(tmp_path) -> None: """Test that CORE.raw_config contains esphome section from merged package. diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index 858eee5e9f..13283fc067 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -1,14 +1,17 @@ """Tests for git.py module.""" from collections.abc import Callable +import errno import logging import os from pathlib import Path import subprocess +import threading import time from typing import Any from unittest.mock import Mock, patch +from filelock import FileLock import pytest from esphome import git @@ -74,17 +77,23 @@ def _simulate_cloned_repo(repo_dir: Path) -> None: def _make_clone_side_effect( - repo_dir: Path, gitmodules: bool = False + repo_dir: Path, + gitmodules: bool = False, + on_clone: Callable[[], None] | None = None, ) -> Callable[..., str]: """Return a run_git_command side effect whose clone creates the repo dir. - With ``gitmodules`` the cloned repo also declares submodules. + With ``gitmodules`` the cloned repo also declares submodules. ``on_clone`` + runs at clone time before the repo dir appears, so a test can probe or + block mid-clone. """ def git_command_side_effect( cmd: list[str], cwd: str | None = None, **kwargs: Any ) -> str: if _get_git_command_type(cmd) == "clone": + if on_clone is not None: + on_clone() _simulate_cloned_repo(repo_dir) if gitmodules: (repo_dir / ".gitmodules").write_text("test") @@ -1491,6 +1500,591 @@ def test_refresh_submodule_failure_recovers_then_raises( ) +def _lock_path(url: str, ref: str | None, domain: str) -> Path: + """The lock file the implementation uses for one cache entry.""" + return git._repo_lock_path(git._cache_key(url, ref), domain) + + +class _SetEventOnWaitLog(logging.Handler): + """Set an event when the 'Waiting for another process' record is emitted, + so tests can react to a caller observably blocking on the lock instead of + racing a wall-clock timer.""" + + def __init__(self, event: threading.Event) -> None: + super().__init__() + self._event = event + + def emit(self, record: logging.LogRecord) -> None: + if "Waiting for another process" in record.getMessage(): + self._event.set() + + +def test_clone_or_update_serializes_concurrent_clones( + tmp_path: Path, mock_run_git_command: Mock, caplog: pytest.LogCaptureFixture +) -> None: + """Two concurrent callers for the same uncached repo must not both clone. + + Without the per-entry lock both callers pass the is_dir() check before + either clone finishes, so the second one either clones on top of the + first or reads a half populated worktree (device-builder issue 2425). + """ + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + + start_together = threading.Barrier(2) + other_caller_waiting = threading.Event() + + def on_clone() -> None: + # Hold the lock until the other caller is observably blocked on it, + # so the interleaving is guaranteed rather than raced on a timer. + assert other_caller_waiting.wait(timeout=30) + + mock_run_git_command.side_effect = _make_clone_side_effect( + repo_dir, on_clone=on_clone + ) + + results: list[Path] = [] + errors: list[BaseException] = [] + + def call() -> None: + try: + start_together.wait() + result_dir, _ = git.clone_or_update( + url=url, ref=None, refresh=git.NEVER_REFRESH, domain=domain + ) + results.append(result_dir) + except BaseException as err: # noqa: BLE001 - re-raised via errors below + errors.append(err) + + handler = _SetEventOnWaitLog(other_caller_waiting) + git_logger = logging.getLogger("esphome.git") + git_logger.addHandler(handler) + try: + with caplog.at_level(logging.INFO): + threads = [threading.Thread(target=call) for _ in range(2)] + for t in threads: + t.start() + for t in threads: + t.join() + finally: + git_logger.removeHandler(handler) + + assert not errors + assert results == [repo_dir, repo_dir] + clone_calls = [ + c + for c in mock_run_git_command.call_args_list + if _get_git_command_type(c[0][0]) == "clone" + ] + # The second caller waited for the lock, then saw the completed clone. + assert len(clone_calls) == 1 + assert _marker_path(repo_dir).is_file() + + +def test_clone_or_update_creates_lock_file_next_to_hash_dir( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """The lock file lives beside the hash dir, never inside it. + + Checked while the clone runs (the lock is held): filelock's Windows + backend deletes the lock file on release, so probing after the call + would only work on Unix. + """ + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + lock_path = _lock_path(url, None, domain) + lock_held_during_clone: list[bool] = [] + + mock_run_git_command.side_effect = _make_clone_side_effect( + repo_dir, on_clone=lambda: lock_held_during_clone.append(lock_path.is_file()) + ) + + result_dir, _ = git.clone_or_update( + url=url, ref=None, refresh=git.NEVER_REFRESH, domain=domain + ) + + assert result_dir == repo_dir + assert lock_path.parent == repo_dir.parent + assert lock_held_during_clone == [True] + + +def test_clone_or_update_subpath_locks_at_hash_dir_level( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """With a subpath the lock still guards the whole hash dir cache entry.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + hash_dir = _compute_repo_dir(url, None, domain) + repo_dir = hash_dir / "lib" + lock_path = _lock_path(url, None, domain) + lock_held_during_clone: list[bool] = [] + + mock_run_git_command.side_effect = _make_clone_side_effect( + repo_dir, on_clone=lambda: lock_held_during_clone.append(lock_path.is_file()) + ) + + result_dir, _ = git.clone_or_update( + url=url, + ref=None, + refresh=git.NEVER_REFRESH, + domain=domain, + subpath=Path("lib"), + ) + + assert result_dir == repo_dir + assert lock_path == hash_dir.parent / f"{hash_dir.name}.lock" + assert lock_held_during_clone == [True] + + +def test_clone_or_update_recovery_holds_lock_without_deadlock( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Recovery re-clones while holding the lock and must not re-acquire it. + + A naive re-acquisition would deadlock here, since OS file locks taken on + separate descriptors conflict even within one process. The lock file + itself must survive the recovery rmtree of the broken repo dir: the + re-clone runs after the rmtree, so probing the lock file there proves + it. Probing after the call would only work on Unix, since filelock's + Windows backend deletes the lock file on release. + """ + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + lock_path = _lock_path(url, None, domain) + lock_held_during_reclone: list[bool] = [] + + _setup_old_repo(repo_dir) + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + if _get_git_command_type(cmd) == "stash": + raise git.GitCommandError("broken repository") + if _get_git_command_type(cmd) == "clone": + lock_held_during_reclone.append(lock_path.is_file()) + _simulate_cloned_repo(repo_dir) + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + recovered_dir, _ = git.clone_or_update( + url=url, ref=None, refresh=TimePeriodSeconds(days=1), domain=domain + ) + + assert recovered_dir == repo_dir + assert lock_held_during_reclone == [True] + + +def _hold_lock_in_thread(lock_path: Path) -> tuple[threading.Thread, threading.Event]: + """Hold the lock from another thread; returns the thread and its release event. + + OS file locks taken on separate descriptors conflict even within one + process, so a second FileLock instance in a thread contends the same + way another process would. + """ + held = threading.Event() + release = threading.Event() + + def hold() -> None: + with FileLock(str(lock_path)): + held.set() + release.wait(timeout=30) + + holder = threading.Thread(target=hold) + holder.start() + assert held.wait(timeout=30) + return holder, release + + +def test_clone_or_update_logs_wait_on_contended_lock( + tmp_path: Path, mock_run_git_command: Mock, caplog: pytest.LogCaptureFixture +) -> None: + """A contended acquire logs a redacted waiting message instead of + silently blocking.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://user:hunter2@github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + mock_run_git_command.side_effect = _make_clone_side_effect(repo_dir) + + holder, release = _hold_lock_in_thread(_lock_path(url, None, domain)) + + # Free the lock only once the waiting message has been emitted, so the + # release is caused by the thing being asserted instead of racing it. + handler = _SetEventOnWaitLog(release) + git_logger = logging.getLogger("esphome.git") + git_logger.addHandler(handler) + try: + with caplog.at_level(logging.INFO): + result_dir, _ = git.clone_or_update( + url=url, ref=None, refresh=git.NEVER_REFRESH, domain=domain + ) + finally: + git_logger.removeHandler(handler) + release.set() + holder.join() + + assert result_dir == repo_dir + waiting = [ + r.getMessage() + for r in caplog.records + if "Waiting for another process" in r.getMessage() + ] + assert len(waiting) == 1 + assert "hunter2" not in waiting[0] + assert "://***@" in waiting[0] + + +def test_revert_skips_on_contended_lock( + tmp_path: Path, + mock_run_git_command: Mock, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """revert() only runs on an already-failing path; when it cannot get the + lock within the bounded timeout it warns and skips instead of hanging.""" + CORE.config_path = tmp_path / "test.yaml" + monkeypatch.setattr(git, "_REVERT_LOCK_TIMEOUT_SECONDS", 0.05) + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + _setup_old_repo(repo_dir) + + # A bare return value satisfies the rev-parse; the other commands' + # outputs are unused. + mock_run_git_command.return_value = "old_sha" + + _, revert = git.clone_or_update( + url=url, ref=None, refresh=TimePeriodSeconds(days=1), domain=domain + ) + assert revert is not None + + holder, release = _hold_lock_in_thread(_lock_path(url, None, domain)) + calls_before = len(mock_run_git_command.call_args_list) + with caplog.at_level(logging.INFO): + assert revert() is False + release.set() + holder.join() + + # No git reset was issued; the wait and the skip were both logged. + assert len(mock_run_git_command.call_args_list) == calls_before + assert any("Waiting for another process" in r.getMessage() for r in caplog.records) + assert any("skipping revert" in r.getMessage() for r in caplog.records) + + +def test_update_clears_marker_while_rewriting( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """The completion marker is absent while a refresh rewrites the entry + and restored once the rewrite finishes, so a timed-out peer's fallback + never trusts a mid-rewrite tree and a crashed update re-clones.""" + 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) + + marker_during_rewrite: list[bool] = [] + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + if _get_git_command_type(cmd) in ("stash", "fetch", "reset"): + marker_during_rewrite.append(_marker_path(repo_dir).is_file()) + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + git.clone_or_update( + url=url, ref=None, refresh=TimePeriodSeconds(days=1), domain=domain + ) + + assert marker_during_rewrite == [False, False, False] + assert _marker_path(repo_dir).is_file() + + +def test_revert_skips_when_checkout_moved( + tmp_path: Path, mock_run_git_command: Mock, caplog: pytest.LogCaptureFixture +) -> None: + """revert() only undoes this process's own update; when another process + refreshed the entry in the meantime it skips instead of rolling the + peer's newer checkout backwards.""" + 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) + + # Pre-update SHA, post-update SHA, then a peer's SHA at revert time. + shas = iter(["old_sha", "new_sha", "peer_sha"]) + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + if _get_git_command_type(cmd) == "rev-parse": + return next(shas) + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + _, revert = git.clone_or_update( + url=url, ref=None, refresh=TimePeriodSeconds(days=1), domain=domain + ) + assert revert is not None + + with caplog.at_level(logging.WARNING): + assert revert() is False + + resets = [ + c[0][0] + for c in mock_run_git_command.call_args_list + if _get_git_command_type(c[0][0]) == "reset" and c[0][0][-1] == "old_sha" + ] + assert resets == [] + assert any("checkout moved" in r.getMessage() for r in caplog.records) + + +def test_revert_returns_false_when_reset_fails( + tmp_path: Path, mock_run_git_command: Mock, caplog: pytest.LogCaptureFixture +) -> None: + """A failed git reset inside revert() is reported through the bool + contract instead of raising a cv.Invalid that would replace the + caller's original error.""" + 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) + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + if _get_git_command_type(cmd) == "rev-parse": + return "old_sha" + # Only revert's reset targets the recorded SHA; the update path's + # reset targets FETCH_HEAD and must succeed. + if cmd[-1] == "old_sha": + raise git.GitCommandError("object not found") + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + _, revert = git.clone_or_update( + url=url, ref=None, refresh=TimePeriodSeconds(days=1), domain=domain + ) + assert revert is not None + + with caplog.at_level(logging.WARNING): + assert revert() is False + + assert any("Could not revert" in r.getMessage() for r in caplog.records) + # The entry cannot be trusted after a failed reset; the dropped marker + # forces a re-clone on the next use. + assert not _marker_path(repo_dir).is_file() + + +def _raise_oserror_on_acquire( + monkeypatch: pytest.MonkeyPatch, code: int = errno.ENOLCK +) -> None: + """Make every FileLock acquire fail with the given errno.""" + + def broken_acquire(self: FileLock, *args: Any, **kwargs: Any) -> None: + raise OSError(code, os.strerror(code)) + + monkeypatch.setattr(FileLock, "acquire", broken_acquire) + + +@pytest.mark.parametrize( + ("code", "expected_fragment"), + [ + # Genuinely missing lock support is reported as such. + (errno.ENOLCK, "does not support locking"), + # A cache directory problem is not blamed on lock support; git + # reports the real error when it actually matters. + (errno.EROFS, "Could not take the cache entry lock"), + ], +) +def test_clone_or_update_continues_unlocked_when_filesystem_cannot_lock( + tmp_path: Path, + mock_run_git_command: Mock, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, + code: int, + expected_fragment: str, +) -> None: + """A filesystem where taking the lock fails (e.g. NFS without a lock + daemon) degrades to the old unlocked behavior instead of failing.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + mock_run_git_command.side_effect = _make_clone_side_effect(repo_dir) + _raise_oserror_on_acquire(monkeypatch, code) + + with caplog.at_level(logging.WARNING): + result_dir, _ = git.clone_or_update( + url=url, ref=None, refresh=git.NEVER_REFRESH, domain=domain + ) + + assert result_dir == repo_dir + assert _marker_path(repo_dir).is_file() + warnings = [ + r.getMessage() + for r in caplog.records + if "continuing without a lock" in r.getMessage() + ] + assert warnings + assert expected_fragment in warnings[0] + + +@pytest.mark.parametrize("broken_from_start", [True, False]) +def test_revert_continues_unlocked_when_filesystem_cannot_lock( + tmp_path: Path, + mock_run_git_command: Mock, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, + broken_from_start: bool, +) -> None: + """A revert still resets when locking is unavailable, whether the wrapper + already fell back to unlocked (revert sees no lock at all) or the + filesystem stops locking between the update and the revert.""" + 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) + + # A bare return value satisfies the rev-parse; the other commands' + # outputs are unused. + mock_run_git_command.return_value = "old_sha" + + if broken_from_start: + _raise_oserror_on_acquire(monkeypatch) + + with caplog.at_level(logging.WARNING): + _, revert = git.clone_or_update( + url=url, ref=None, refresh=TimePeriodSeconds(days=1), domain=domain + ) + assert revert is not None + if not broken_from_start: + _raise_oserror_on_acquire(monkeypatch) + # The reset ran (unlocked), so the revert reports success. + assert revert() is True + + assert mock_run_git_command.call_args_list[-1][0][0] == [ + "git", + "reset", + "--hard", + "old_sha", + ] + assert any("continuing without a lock" in r.getMessage() for r in caplog.records) + + +def _script_acquire_statuses( + monkeypatch: pytest.MonkeyPatch, statuses: list["git._LockStatus"] +) -> list[float]: + """Replace _acquire_repo_lock with a scripted sequence; returns the + timeouts it was called with.""" + timeouts: list[float] = [] + status_iter = iter(statuses) + + def fake_acquire( + lock: FileLock, safe_key: str, timeout: float, **kwargs: Any + ) -> "git._LockStatus": + timeouts.append(timeout) + return next(status_iter) + + monkeypatch.setattr(git, "_acquire_repo_lock", fake_acquire) + return timeouts + + +@pytest.mark.parametrize("subpath", [None, Path("lib")]) +def test_clone_or_update_uses_complete_entry_when_lock_wait_times_out( + tmp_path: Path, + mock_run_git_command: Mock, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, + subpath: Path | None, +) -> None: + """A bounded wait behind a stalled holder falls back to an existing + complete cache entry instead of hanging every peer.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + if subpath is not None: + repo_dir = repo_dir / subpath + _simulate_cloned_repo(repo_dir) + _mark_clone_complete(repo_dir) + + timeouts = _script_acquire_statuses(monkeypatch, [git._LockStatus.TIMEOUT]) + + with caplog.at_level(logging.WARNING): + result_dir, revert = git.clone_or_update( + url=url, + ref=None, + refresh=TimePeriodSeconds(days=1), + domain=domain, + subpath=subpath, + ) + + assert result_dir == repo_dir + assert revert is None + # Nothing was cloned or refreshed; the existing entry was used as-is. + assert mock_run_git_command.call_args_list == [] + assert timeouts == [git._COMPLETE_ENTRY_LOCK_TIMEOUT_SECONDS] + assert any( + "proceeding with the existing clone" in r.getMessage() for r in caplog.records + ) + + +def test_clone_or_update_waits_unbounded_without_complete_entry( + tmp_path: Path, + mock_run_git_command: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """With no complete entry there is nothing to fall back to, so after the + bounded wait expires the caller keeps waiting for the holder's clone.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + mock_run_git_command.side_effect = _make_clone_side_effect(repo_dir) + + timeouts = _script_acquire_statuses( + monkeypatch, [git._LockStatus.TIMEOUT, git._LockStatus.ACQUIRED] + ) + + result_dir, _ = git.clone_or_update( + url=url, ref=None, refresh=git.NEVER_REFRESH, domain=domain + ) + + assert result_dir == repo_dir + assert timeouts == [git._COMPLETE_ENTRY_LOCK_TIMEOUT_SECONDS, -1] + # The clone proceeded normally once the lock was finally acquired. + assert _marker_path(repo_dir).is_file() + + def _real_git(*args: str, cwd: Path) -> None: """Run real git to build a test fixture repository.""" subprocess.run( @@ -1674,7 +2268,8 @@ def test_refresh_picks_up_new_remote_commits( # 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"] + # The trailing rev-parse records the post-update SHA for revert(). + assert cmd_sequence == ["rev-parse", "stash", "fetch", "reset", "rev-parse"] fetch_cmd = call_list[2][0][0] assert "--depth=1" in fetch_cmd @@ -1685,7 +2280,7 @@ def test_refresh_picks_up_new_remote_commits( # revert callback should reset back to the recorded pre-update SHA. assert revert is not None - revert() + assert revert() is True assert mock_run_git_command.call_args_list[-1][0][0] == [ "git", "reset", From 963a3379a6bcf5990a714844a6b7707e601847b1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Jul 2026 13:06:32 -1000 Subject: [PATCH 1154/1815] [core] Log when a git source update is skipped and when it will next refresh (#17943) --- esphome/config_validation.py | 7 +++- esphome/git.py | 23 ++++++++++- esphome/helpers.py | 15 +++++++ tests/unit_tests/test_git.py | 69 ++++++++++++++++++++++++++++---- tests/unit_tests/test_helpers.py | 18 +++++++++ 5 files changed, 123 insertions(+), 9 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 8289f56df3..ff9170813c 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -2515,11 +2515,16 @@ def git_ref(value): return value +# What `refresh: never` validates to; also used to recognize a disabled +# refresh when logging (see esphome/git.py) +SOURCE_REFRESH_NEVER = "365250d" + + def source_refresh(value: str): if value.lower() == "always": return source_refresh("0s") if value.lower() == "never": - return source_refresh("365250d") + return source_refresh(SOURCE_REFRESH_NEVER) return positive_time_period_seconds(value) diff --git a/esphome/git.py b/esphome/git.py index b5abf39a24..d1dca3b3ae 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -16,7 +16,12 @@ import urllib.parse import esphome.config_validation as cv from esphome.core import CORE, EsphomeError, TimePeriodSeconds -from esphome.helpers import add_git_ceiling_directory, rmtree, write_file +from esphome.helpers import ( + add_git_ceiling_directory, + format_duration, + rmtree, + write_file, +) if TYPE_CHECKING: from filelock import FileLock @@ -26,6 +31,11 @@ _LOGGER = logging.getLogger(__name__) # Special value to indicate never refresh NEVER_REFRESH = TimePeriodSeconds(seconds=-1) +# `refresh: never` validates to a huge interval rather than the NEVER_REFRESH +# sentinel; treat any interval at least that long as refresh disabled instead +# of logging a countdown of hundreds of years +_REFRESH_DISABLED_SECONDS = cv.source_refresh(cv.SOURCE_REFRESH_NEVER).total_seconds + # revert() runs on an already-failing path; bound its wait for the cache # entry lock so that recovery cannot hang forever behind another process. _REVERT_LOCK_TIMEOUT_SECONDS = 60 @@ -803,6 +813,17 @@ def _clone_or_update_locked( return True return repo_dir, revert + if refresh.total_seconds >= _REFRESH_DISABLED_SECONDS: + # refresh: never + _LOGGER.debug("Skipping update for %s (refresh disabled)", safe_key) + else: + _LOGGER.info( + "Skipping update for %s, will refresh on the next run after %s " + "(refresh: %s); use refresh: always to update now", + safe_key, + format_duration(refresh.total_seconds - age_seconds), + format_duration(refresh.total_seconds), + ) return repo_dir, None diff --git a/esphome/helpers.py b/esphome/helpers.py index 631bcb6f39..683aaedcf5 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -148,6 +148,21 @@ def indent(text, padding=" "): return "\n".join(indent_list(text, padding)) +def format_duration(seconds: float) -> str: + """Format a duration in seconds as a short string like "1d 2h" or "42s". + + Uses the two largest non-zero units, with unit suffixes matching the YAML + time period shorthand (d, h, min, s). + """ + remainder = max(0, int(seconds)) + parts = [] + for suffix, length in (("d", 86400), ("h", 3600), ("min", 60), ("s", 1)): + value, remainder = divmod(remainder, length) + if value: + parts.append(f"{value}{suffix}") + return " ".join(parts[:2]) if parts else "0s" + + # From https://stackoverflow.com/a/14945195/8924614 def cpp_string_escape(string, encoding="utf-8"): def _should_escape(byte: int) -> bool: diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index 13283fc067..ec1becf3e8 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -15,6 +15,7 @@ from filelock import FileLock import pytest from esphome import git +import esphome.config_validation as cv from esphome.core import CORE, EsphomeError, TimePeriodSeconds from esphome.git import GitCommandError @@ -488,7 +489,7 @@ def test_clone_or_update_with_refresh_updates_old_repo( def test_clone_or_update_with_refresh_skips_fresh_repo( - tmp_path: Path, mock_run_git_command: Mock + tmp_path: Path, mock_run_git_command: Mock, caplog: pytest.LogCaptureFixture ) -> None: """Test that refresh doesn't update fresh repos.""" # Set up CORE.config_path so data_dir uses tmp_path @@ -513,20 +514,74 @@ def test_clone_or_update_with_refresh_skips_fresh_repo( # Set modification time to 1 hour ago os.utime(fetch_head, (recent_time, recent_time)) + # Freeze the clock at 1 hour (plus a margin larger than any filesystem + # mtime rounding) after the mtime so the logged countdown is deterministic + frozen_now = fetch_head.stat().st_mtime + 3600.5 + # Call with refresh=1d (1 day) refresh = TimePeriodSeconds(days=1) - result_dir, revert = git.clone_or_update( - url=url, - ref=ref, - refresh=refresh, - domain=domain, - ) + with ( + patch("esphome.git.time.time", return_value=frozen_now), + caplog.at_level(logging.INFO, logger="esphome.git"), + ): + result_dir, revert = git.clone_or_update( + url=url, + ref=ref, + refresh=refresh, + domain=domain, + ) # Should NOT call git fetch since repo is fresh mock_run_git_command.assert_not_called() assert result_dir == repo_dir assert revert is None + # Should tell the user the update was skipped and when the next refresh is + assert f"Skipping update for {url}@{ref}" in caplog.text + assert "will refresh on the next run after 22h 59min" in caplog.text + assert "(refresh: 1d)" in caplog.text + + +def test_clone_or_update_with_refresh_never_logs_refresh_disabled( + tmp_path: Path, mock_run_git_command: Mock, caplog: pytest.LogCaptureFixture +) -> None: + """Test that a config-level refresh: never skips without a countdown log.""" + # Set up CORE.config_path so data_dir uses tmp_path + 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) + + # Create the git repo directory structure + repo_dir.mkdir(parents=True) + git_dir = repo_dir / ".git" + git_dir.mkdir() + _mark_clone_complete(repo_dir) + + # Create FETCH_HEAD file with current timestamp + fetch_head = git_dir / "FETCH_HEAD" + fetch_head.write_text("test") + + # refresh: never validates to 365250 days, not the NEVER_REFRESH sentinel + refresh = cv.source_refresh("never") + with caplog.at_level(logging.DEBUG, logger="esphome.git"): + result_dir, revert = git.clone_or_update( + url=url, + ref=ref, + refresh=refresh, + domain=domain, + ) + + mock_run_git_command.assert_not_called() + assert result_dir == repo_dir + assert revert is None + + # Should log refresh disabled at debug level, not a countdown + assert f"Skipping update for {url}@{ref} (refresh disabled)" in caplog.text + assert "will refresh on the next run" not in caplog.text + def test_clone_or_update_clones_missing_repo( tmp_path: Path, mock_run_git_command: Mock diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index fad249b0bb..211fbf5112 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -1074,3 +1074,21 @@ def test_progressbar_enabled_on_pipe_with_dashboard(monkeypatch) -> None: bar = ProgressBar("Uploading", stream=stream) assert bar.enabled is True + + +@pytest.mark.parametrize( + ("seconds", "expected"), + [ + (0, "0s"), + (42, "42s"), + (60, "1min"), + (3661, "1h 1min"), + (86400, "1d"), + (90000, "1d 1h"), + (86700, "1d 5min"), + (-5, "0s"), + ], +) +def test_format_duration(seconds: float, expected: str) -> None: + """Test that durations are rendered as short human-readable strings.""" + assert helpers.format_duration(seconds) == expected From 44f08d15dba69a46b2b783479796150d327d07a6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:34:04 -0400 Subject: [PATCH 1155/1815] [espidf] Use forward slashes in generated component CMakeLists (#17965) Co-authored-by: J. Nick Koston --- esphome/espidf/component.py | 14 ++++++-- tests/unit_tests/test_espidf_component.py | 43 ++++++++++++++++++----- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 182f29c92d..cad5bbf665 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -93,6 +93,14 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: # In CMakeLists.txt, backslashes need to be escaped return f'"{str(p)}"'.replace("\\", "\\\\") + def escape_path(p: PathType) -> str: + # CMake uses forward slashes for paths on every platform and treats + # backslashes as escape characters. On Windows os.path.relpath yields + # backslash paths, which break CMake's list re-parsing (e.g. "\b" in + # "src\backend" is an invalid character escape). Emit forward slashes, + # which Windows accepts too, so the generated CMakeLists is portable. + return f'"{str(p).replace(os.sep, "/")}"' + # Extract the values build_src_dir = component.data.get("build", {}).get("srcDir", None) if not build_src_dir: @@ -174,10 +182,10 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: # Generate the component content = "idf_component_register(\n" if build_src_files: - str_srcs = " ".join([escape_entry(p) for p in sorted(build_src_files)]) + str_srcs = " ".join([escape_path(p) for p in sorted(build_src_files)]) content += f" SRCS {str_srcs}\n" if build_include_dirs: - str_include_dirs = " ".join([escape_entry(p) for p in build_include_dirs]) + str_include_dirs = " ".join([escape_path(p) for p in build_include_dirs]) content += f" INCLUDE_DIRS {str_include_dirs}\n" # Project-managed and built-in component lists are set per-project # via idf_build_set_property in the top-level CMakeLists; expanded @@ -212,7 +220,7 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: if link_directories: content += "target_link_directories(${COMPONENT_LIB} INTERFACE\n" for link_directory in link_directories: - str_build_flag = escape_entry(link_directory) + str_build_flag = escape_path(link_directory) content += f" {str_build_flag}\n" content += ")\n" diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index f9ed44b8d2..879d98c0a7 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -169,30 +169,57 @@ def test_generate_cmakelists_txt_with_flags(tmp_component, tmp_path): } content = generate_cmakelists_txt(tmp_component) - sep = "\\\\" if os.name == "nt" else "/" + # Paths are always emitted with forward slashes so the CMakeLists is + # portable; on Windows os.path.relpath would otherwise yield backslashes + # that break CMake's list re-parsing. assert ( content - == f"""idf_component_register( - SRCS "src{sep}main.c" + == """idf_component_register( + SRCS "src/main.c" INCLUDE_DIRS "src" - REQUIRES dep ${{ESPHOME_PROJECT_MANAGED_COMPONENTS}} ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}} + REQUIRES dep ${ESPHOME_PROJECT_MANAGED_COMPONENTS} ${ESPHOME_PROJECT_BUILTIN_COMPONENTS} ) -target_compile_options(${{COMPONENT_LIB}} PUBLIC +target_compile_options(${COMPONENT_LIB} PUBLIC "-DTEST" ) -target_compile_options(${{COMPONENT_LIB}} PRIVATE +target_compile_options(${COMPONENT_LIB} PRIVATE "-Wall" ) -target_link_directories(${{COMPONENT_LIB}} INTERFACE +target_link_directories(${COMPONENT_LIB} INTERFACE "lib" ) -target_link_libraries(${{COMPONENT_LIB}} INTERFACE +target_link_libraries(${COMPONENT_LIB} INTERFACE "mylib" ) """ ) +def test_generate_cmakelists_txt_uses_forward_slashes_on_windows( + tmp_component, monkeypatch: pytest.MonkeyPatch +) -> None: + # os.path.relpath yields backslash paths on Windows, which CMake rejects + # when it re-parses the SRCS list (e.g. "\b" in "src\backend" is an invalid + # character escape). Simulate that output and confirm the generated + # CMakeLists normalizes the separators to forward slashes. + src_dir = tmp_component.path / "src" / "backend" + src_dir.mkdir(parents=True) + (src_dir / "cipher.c").write_text("int f() {}") + + tmp_component.data = {} + + monkeypatch.setattr("esphome.espidf.component.os.sep", "\\") + monkeypatch.setattr( + "esphome.espidf.component.os.path.relpath", + lambda *args, **kwargs: "src\\backend\\cipher.c", + ) + + content = generate_cmakelists_txt(tmp_component) + + assert 'SRCS "src/backend/cipher.c"' in content + assert "\\" not in content + + def test_generate_cmakelists_txt_multi_token_flag(tmp_component): # PlatformIO shell-lexes each build.flags entry, so a single entry can # carry a flag and its argument. The generated CMakeLists must emit them From 3899429cfa5af617c2239b807b8c50e2d126a3da Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:34:44 +1200 Subject: [PATCH 1156/1815] [esp32] Restrict toolchain validation to supported values (#17972) --- esphome/components/esp32/__init__.py | 4 +++- tests/component_tests/esp32/test_esp32.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 8219b0c6d8..6491b9a5e6 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1031,7 +1031,9 @@ def _check_esp_idf_versions(config: ConfigType) -> ConfigType: def _validate_toolchain(value) -> Toolchain: - return Toolchain(cv.one_of(*(t.value for t in Toolchain), lower=True)(value)) + return Toolchain( + cv.one_of(Toolchain.PLATFORMIO, Toolchain.ESP_IDF, lower=True)(value) + ) def _resolve_toolchain(value: ConfigType) -> ConfigType: diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 4d18bbf6e4..c374e7c964 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -108,6 +108,24 @@ def test_esp32_default_toolchain_is_esp_idf( assert CORE.toolchain == expected +@pytest.mark.parametrize( + "config_toolchain", + [Toolchain.SDK_NRF.value, "nonsense"], +) +def test_esp32_rejects_unsupported_toolchains( + set_core_config: SetCoreConfigCallable, + config_toolchain: str, +) -> None: + """Toolchains esp32 does not support are rejected at validation time.""" + set_core_config(PlatformFramework.ESP32_IDF) + + from esphome.components.esp32 import CONFIG_SCHEMA + + CORE.toolchain = None + with pytest.raises(cv.Invalid, match="Unknown value"): + CONFIG_SCHEMA({"variant": VARIANT_ESP32, "toolchain": config_toolchain}) + + @pytest.mark.parametrize( ("config", "error_match"), [ From fb213208261ea2c90093881fa00e920980def8f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Jul 2026 14:48:17 -1000 Subject: [PATCH 1157/1815] [api] Fix double free when overflow buffer drain is re-entered (#17969) --- .../components/api/api_overflow_buffer.cpp | 21 +++++++++++++++++-- esphome/components/api/api_overflow_buffer.h | 4 ++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api_overflow_buffer.cpp b/esphome/components/api/api_overflow_buffer.cpp index e242d4553e..a57a2fb1bb 100644 --- a/esphome/components/api/api_overflow_buffer.cpp +++ b/esphome/components/api/api_overflow_buffer.cpp @@ -12,6 +12,22 @@ APIOverflowBuffer::~APIOverflowBuffer() { } ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) { + // socket->write() can re-enter this function: a log message emitted from an + // lwip callback during the write goes out over the API and lands back in the + // frame helper's write/drain path. If a nested drain ran here it would send + // and free the entry the outer drain is still holding, causing a double free. + // Report "no progress" instead; the outer drain keeps draining, and the + // nested send is enqueued behind the existing backlog. + if (this->draining_) + return 0; + + // RAII so the flag is cleared on every return path + struct DrainGuard { + explicit DrainGuard(bool &flag) : flag_(flag) { flag_ = true; } + ~DrainGuard() { this->flag_ = false; } + bool &flag_; + } guard(this->draining_); + while (this->count_ > 0) { Entry *front = this->queue_[this->head_]; @@ -29,11 +45,12 @@ ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) { return sent; } - // Entry fully sent — free it and advance - Entry::destroy(front); + // Entry fully sent — unlink it before freeing so a freed pointer is never + // reachable from the queue this->queue_[this->head_] = nullptr; this->head_ = (this->head_ + 1) % API_MAX_SEND_QUEUE; this->count_--; + Entry::destroy(front); } return 0; // All drained diff --git a/esphome/components/api/api_overflow_buffer.h b/esphome/components/api/api_overflow_buffer.h index 19aae680f0..1227e83126 100644 --- a/esphome/components/api/api_overflow_buffer.h +++ b/esphome/components/api/api_overflow_buffer.h @@ -69,6 +69,10 @@ class APIOverflowBuffer { uint8_t head_{0}; uint8_t tail_{0}; uint8_t count_{0}; + // Guards against re-entrant drains: socket->write() can re-enter the API + // send path (e.g. a log message emitted from an lwip callback), and a nested + // drain would free the entry the outer drain is still holding. + bool draining_{false}; }; } // namespace esphome::api From 80c17fb2cd092f565efd224ef237bdf6a306176c Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:52:01 +1200 Subject: [PATCH 1158/1815] [ci] Authenticate the stale workflow as esphome[bot] (#17974) --- .github/workflows/stale.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 477f05ba34..8ec88238a9 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -6,17 +6,19 @@ on: - cron: "30 0 * * *" workflow_dispatch: -# Deny by default; the stale job opts in to exactly what the reusable workflow needs. +# The reusable workflow authenticates as the ESPHome GitHub App, so GITHUB_TOKEN +# needs no permissions at all. permissions: {} jobs: stale: if: github.repository_owner == 'esphome' - permissions: - contents: read # head-commit dates for the PR activity check - issues: write # label, comment on and close stale issues - pull-requests: write # label, comment on and close stale pull requests - uses: esphome/workflows/.github/workflows/stale.yml@c87be24a6fd0320ecd32e40b27fe98d25c3af851 # 2026.7.0 + # No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome + # GitHub App token so the labels, comments and closures come from + # esphome[bot] instead of github-actions[bot]. + uses: esphome/workflows/.github/workflows/stale.yml@203cea60ebfd18e2b966e57750750e0417a9feec # main + secrets: + ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} with: # Live only on dev: a workflow_dispatch from any other branch is a dry run dry-run: ${{ github.ref != 'refs/heads/dev' }} From f9cd789f7cccef58bd3f204e48e62e0cfbe6edee Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:39:36 +1200 Subject: [PATCH 1159/1815] [ci] Tell stacked pull requests apart from hand-built chains (#17980) --- .github/scripts/auto-label-pr/constants.js | 1 + .github/scripts/auto-label-pr/detectors.js | 39 ++++++- .github/scripts/auto-label-pr/index.js | 4 +- .../auto-label-pr/tests/detectors.test.js | 102 ++++++++++++++++++ .github/workflows/auto-label-pr.yml | 2 +- 5 files changed, 144 insertions(+), 4 deletions(-) diff --git a/.github/scripts/auto-label-pr/constants.js b/.github/scripts/auto-label-pr/constants.js index b95bc17518..5e8acb09c9 100644 --- a/.github/scripts/auto-label-pr/constants.js +++ b/.github/scripts/auto-label-pr/constants.js @@ -13,6 +13,7 @@ module.exports = { 'merging-to-release', 'merging-to-beta', 'chained-pr', + 'stacked-pr', 'core', 'small-pr', 'medium-pr', diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index 2478ccf959..bb85ccd681 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -33,8 +33,41 @@ async function fetchPrFileContent(github, context, path) { } } +// Check whether a pull request is part of a GitHub stack. +// +// GitHub's stacked pull request feature adds a `stack` object to the pull +// request resource. It is present on every pull request in the stack - +// including the bottom one, whose base is already `dev` - and is absent +// entirely on standalone pull requests. +// +// The `pull_request_target` webhook payload is not guaranteed to carry this +// field, so fall back to asking the API when it is missing. Guessing wrong +// here is costly: a stacked pull request mistaken for a manually chained one +// gets a label that blocks merging. +async function isStackedPr(github, context) { + const pr = context.payload.pull_request; + if (pr.stack != null) { + return true; + } + + try { + const { owner, repo } = context.repo; + const { data } = await github.rest.pulls.get({ + owner, + repo, + pull_number: pr.number, + }); + return data.stack != null; + } catch (error) { + // Treat an API failure as "not stacked" so a chained pull request still + // gets its blocking label rather than silently slipping through. + console.log('Failed to check stack membership:', error.message); + return false; + } +} + // Strategy: Merge branch detection -async function detectMergeBranch(context) { +async function detectMergeBranch(github, context) { const labels = new Set(); const baseRef = context.payload.pull_request.base.ref; @@ -42,7 +75,11 @@ async function detectMergeBranch(context) { labels.add('merging-to-release'); } else if (baseRef === 'beta') { labels.add('merging-to-beta'); + } else if (await isStackedPr(github, context)) { + // GitHub manages the merge order for a stack, so these are not blocked. + labels.add('stacked-pr'); } else if (baseRef !== 'dev') { + // A chain built by hand: it must not merge until its base branch does. labels.add('chained-pr'); } diff --git a/.github/scripts/auto-label-pr/index.js b/.github/scripts/auto-label-pr/index.js index c8bdcfb2f3..8b0c821503 100644 --- a/.github/scripts/auto-label-pr/index.js +++ b/.github/scripts/auto-label-pr/index.js @@ -88,7 +88,7 @@ module.exports = async ({ github, context }) => { // Early exit for release and beta branches only if (baseRef === 'release' || baseRef === 'beta') { - const branchLabels = await detectMergeBranch(context); + const branchLabels = await detectMergeBranch(github, context); const finalLabels = Array.from(branchLabels); console.log('Computed labels (merge branch only):', finalLabels.join(', ')); @@ -118,7 +118,7 @@ module.exports = async ({ github, context }) => { deprecatedResult, maintainerAccess ] = await Promise.all([ - detectMergeBranch(context), + detectMergeBranch(github, context), detectComponentPlatforms(changedFiles, apiData), detectNewComponents(github, context, prFiles), detectNewPlatforms(github, context, prFiles, apiData), diff --git a/.github/scripts/auto-label-pr/tests/detectors.test.js b/.github/scripts/auto-label-pr/tests/detectors.test.js index 413fdb3f94..f30ceff8c1 100644 --- a/.github/scripts/auto-label-pr/tests/detectors.test.js +++ b/.github/scripts/auto-label-pr/tests/detectors.test.js @@ -1,6 +1,7 @@ const { describe, it } = require('node:test'); const assert = require('node:assert/strict'); const { + detectMergeBranch, detectNewPlatforms, detectNewComponents, detectPRSize, @@ -36,6 +37,107 @@ const API_DATA = { const WITH_SCHEMA = 'CONFIG_SCHEMA = cv.Schema({})'; const WITHOUT_SCHEMA = 'CODEOWNERS = ["@esphome/core"]'; +// --------------------------------------------------------------------------- +// detectMergeBranch +// --------------------------------------------------------------------------- + +// Builds a fresh context for detectMergeBranch tests instead of mutating the +// shared CONTEXT fixture above (which other describe blocks rely on). +function makeMergeContext(baseRef, { stack } = {}) { + const pull_request = { number: 1, base: { ref: baseRef } }; + if (stack !== undefined) { + pull_request.stack = stack; + } + return { + repo: { owner: 'esphome', repo: 'esphome' }, + payload: { pull_request } + }; +} + +// A GitHub API mock exposing only rest.pulls.get, with a call counter so +// tests can assert whether the API fallback was actually invoked. +function makeStackGithub({ stack = null, error = null } = {}) { + const state = { calls: 0 }; + const github = { + rest: { + pulls: { + get: async () => { + state.calls++; + if (error) throw error; + return { data: { stack } }; + } + } + } + }; + return { github, state }; +} + +const STACK_INFO = { base: { ref: 'dev' }, id: 71540, number: 17978, position: 3, size: 3 }; + +describe('detectMergeBranch', () => { + it('base ref release adds merging-to-release only and never checks the stack', async () => { + const { github, state } = makeStackGithub({ stack: STACK_INFO }); + const context = makeMergeContext('release', { stack: STACK_INFO }); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), ['merging-to-release']); + assert.equal(state.calls, 0); + }); + + it('base ref beta adds merging-to-beta only and never checks the stack', async () => { + const { github, state } = makeStackGithub({ stack: STACK_INFO }); + const context = makeMergeContext('beta', { stack: STACK_INFO }); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), ['merging-to-beta']); + assert.equal(state.calls, 0); + }); + + it('stack present on the webhook payload adds stacked-pr without calling the API', async () => { + const { github, state } = makeStackGithub(); + const context = makeMergeContext('feature-branch', { stack: STACK_INFO }); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), ['stacked-pr']); + assert.equal(state.calls, 0); + }); + + it('stack absent from payload falls back to the API and adds stacked-pr', async () => { + const { github, state } = makeStackGithub({ stack: STACK_INFO }); + const context = makeMergeContext('feature-branch'); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), ['stacked-pr']); + assert.equal(state.calls, 1); + }); + + it('bottom of a stack (base ref dev, stack present) still adds stacked-pr', async () => { + const { github, state } = makeStackGithub(); + const context = makeMergeContext('dev', { stack: STACK_INFO }); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), ['stacked-pr']); + assert.equal(state.calls, 0); + }); + + it('not stacked, base ref not dev adds chained-pr', async () => { + const { github } = makeStackGithub({ stack: null }); + const context = makeMergeContext('feature-branch'); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), ['chained-pr']); + }); + + it('not stacked, base ref dev adds no labels', async () => { + const { github } = makeStackGithub({ stack: null }); + const context = makeMergeContext('dev'); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), []); + }); + + it('a failed stack lookup falls back to not-stacked, so a feature-branch base adds chained-pr', async () => { + const { github, state } = makeStackGithub({ error: new Error('API unavailable') }); + const context = makeMergeContext('feature-branch'); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), ['chained-pr']); + assert.equal(state.calls, 1); + }); +}); + // --------------------------------------------------------------------------- // detectNewPlatforms // --------------------------------------------------------------------------- diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 30915b68c7..b49c66b976 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -35,7 +35,7 @@ jobs: # Scope the minted App token to the minimum needed by auto-label-pr/*.js. permission-contents: read # repos.getContent for CODEOWNERS and file lookups in detectors.js permission-issues: write # listLabelsOnIssue, addLabels, removeLabel, list/createComment - permission-pull-requests: write # pulls.listFiles, list/create/update/dismissReview + permission-pull-requests: write # pulls.get, pulls.listFiles, list/create/update/dismissReview - name: Auto Label PR uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 From e11a2956807bda2c94eeee4a09ecee549de9da52 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:27:05 +1200 Subject: [PATCH 1160/1815] [ci] Report an empty Codecov upload when pytest is skipped (#17976) --- .github/workflows/ci.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30deee01f3..b6fba50625 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -262,6 +262,34 @@ jobs: path: venv key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} + codecov-empty-upload: + name: Report no coverage to Codecov + runs-on: ubuntu-24.04 + needs: + - determine-jobs + # ``pytest`` is the only job that uploads coverage, and it is skipped when + # every changed file is CI-irrelevant (see ``should_run_core_ci`` in + # ``script/determine-jobs.py``). With no upload Codecov never reports a + # result, so the required ``codecov/patch`` status stays pending forever and + # the pull request can never be merged. Tell Codecov up front that this + # commit has nothing to cover so it publishes a passing status instead. + # + # ``force`` skips Codecov's own check that every changed file is ignorable; + # ``determine-jobs`` has already decided none of these files can affect + # coverage, and Codecov would otherwise fail the status for paths it does + # not recognise as non-testable (``docker/**``, ``.yamllint``). + if: needs.determine-jobs.outputs.core-ci == 'false' + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Report empty upload to Codecov + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + run_command: empty-upload + force: true + fail_ci_if_error: true + determine-jobs: name: Determine which jobs to run runs-on: ubuntu-24.04 @@ -1436,6 +1464,7 @@ jobs: - ci-custom - pylint - pytest + - codecov-empty-upload - integration-tests - clang-tidy-single - clang-tidy-nosplit From 40b4cd8e9b4ca29dd943780ec913f56f76a05262 Mon Sep 17 00:00:00 2001 From: Jesse Hills <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:32:33 +1200 Subject: [PATCH 1161/1815] [ci] Drop the unused Codecov upload token (#17977) --- .github/workflows/ci.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6fba50625..ee50f7d2f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -253,8 +253,6 @@ jobs: pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/ - name: Upload coverage to Codecov uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 - with: - token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache if: github.ref == 'refs/heads/dev' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -285,7 +283,6 @@ jobs: - name: Report empty upload to Codecov uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: - token: ${{ secrets.CODECOV_TOKEN }} run_command: empty-upload force: true fail_ci_if_error: true From 2d1530adf7db712415348abb076067b8a2c01bee Mon Sep 17 00:00:00 2001 From: TesseractTimmee <163826037+TesseractTimmee@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:16:07 +0800 Subject: [PATCH 1162/1815] [zigbee] Add more units (#17982) Co-authored-by: root Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/zigbee/const.py | 12 ++++++++++++ esphome/const.py | 1 + 2 files changed, 13 insertions(+) diff --git a/esphome/components/zigbee/const.py b/esphome/components/zigbee/const.py index cfd23b9eb2..d922ae372f 100644 --- a/esphome/components/zigbee/const.py +++ b/esphome/components/zigbee/const.py @@ -14,12 +14,17 @@ from esphome.const import ( UNIT_AMPERE, UNIT_CELSIUS, UNIT_CENTIMETER, + UNIT_CUBIC_METER, + UNIT_CUBIC_METER_PER_HOUR, UNIT_DECIBEL, UNIT_HECTOPASCAL, UNIT_HERTZ, UNIT_HOUR, UNIT_KELVIN, UNIT_KILOMETER, + UNIT_KILOPASCAL, + UNIT_KILOVOLT_AMPS, + UNIT_KILOVOLT_AMPS_REACTIVE, UNIT_KILOWATT, UNIT_KILOWATT_HOURS, UNIT_LITRE_PER_SECOND, @@ -37,6 +42,7 @@ from esphome.const import ( UNIT_PARTS_PER_MILLION, UNIT_PASCAL, UNIT_PERCENT, + UNIT_PH, UNIT_SECOND, UNIT_VOLT, UNIT_WATT, @@ -90,10 +96,13 @@ BACNET_UNITS = { UNIT_OHM: 4, UNIT_WATT: 47, UNIT_KILOWATT: 48, + UNIT_KILOVOLT_AMPS: 9, + UNIT_KILOVOLT_AMPS_REACTIVE: 12, UNIT_WATT_HOURS: 18, UNIT_KILOWATT_HOURS: 19, UNIT_PASCAL: 53, UNIT_HECTOPASCAL: 133, + UNIT_KILOPASCAL: 54, UNIT_HERTZ: 27, UNIT_MILLIMETER: 30, UNIT_CENTIMETER: 118, @@ -110,6 +119,9 @@ BACNET_UNITS = { UNIT_LUX: 37, UNIT_DECIBEL: 199, UNIT_PERCENT: 98, + UNIT_CUBIC_METER: 80, + UNIT_CUBIC_METER_PER_HOUR: 135, + UNIT_PH: 234, } BACNET_UNIT_NO_UNITS = 95 diff --git a/esphome/const.py b/esphome/const.py index 9dfa5cb835..a7306edeb0 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1248,6 +1248,7 @@ UNIT_KELVIN = "K" UNIT_KILOGRAM = "kg" UNIT_KILOMETER = "km" UNIT_KILOMETER_PER_HOUR = "km/h" +UNIT_KILOPASCAL = "kPa" UNIT_KILOVOLT_AMPS = "kVA" UNIT_KILOVOLT_AMPS_HOURS = "kVAh" UNIT_KILOVOLT_AMPS_REACTIVE = "kvar" From 7ad662781d415c9df77082a69c5b96453ed6205b Mon Sep 17 00:00:00 2001 From: Jesse Hills <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:53:59 +1200 Subject: [PATCH 1163/1815] [ci] Order jobs to follow the CI flow and close ci-status gaps (#17979) --- .github/workflows/ci.yml | 584 ++++++++++++++++++++------------------- 1 file changed, 295 insertions(+), 289 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee50f7d2f6..e020e90018 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,225 +68,6 @@ jobs: uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit uv pip install -e . - pylint: - name: Check pylint - runs-on: ubuntu-24.04 - needs: - - common - - determine-jobs - if: needs.determine-jobs.outputs.python-linters == 'true' - steps: - - name: Check out code from GitHub - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Restore Python - uses: ./.github/actions/restore-python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - cache-key: ${{ needs.common.outputs.cache-key }} - - name: Run pylint - run: | - . venv/bin/activate - pylint -f parseable --persistent=n esphome - - name: Suggested changes - run: script/ci-suggest-changes - if: always() - - ci-custom: - name: Run script/ci-custom - runs-on: ubuntu-24.04 - needs: - - common - - determine-jobs - if: needs.determine-jobs.outputs.core-ci == 'true' - steps: - - name: Check out code from GitHub - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Restore Python - uses: ./.github/actions/restore-python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - cache-key: ${{ needs.common.outputs.cache-key }} - - name: Register matcher - run: echo "::add-matcher::.github/workflows/matchers/ci-custom.json" - - name: Run script/ci-custom - run: | - . venv/bin/activate - script/ci-custom.py - script/build_codeowners.py --check - script/build_language_schema.py --check - script/generate-esp32-boards.py --check - script/generate-rp2-boards.py --check - script/ci_check_duplicate_test_ids.py - script/ci_check_test_fixture_list_form.py - - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - 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 - - device-builder: - name: Test downstream esphome/device-builder - runs-on: ubuntu-24.04 - needs: - - common - - determine-jobs - if: needs.determine-jobs.outputs.device-builder == 'true' - steps: - - name: Check out esphome (this PR) - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - path: esphome - - name: Check out esphome/device-builder - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: esphome/device-builder - ref: main - path: device-builder - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.13" - - name: Set up uv - # Mirrors the install shape device-builder's own CI uses - # (esphome/device-builder#192): uv replaces pip for the - # install step (order-of-magnitude faster on cold boots, - # with its own wheel cache). actions/setup-python still - # provides the interpreter. - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - with: - enable-cache: true - # Pull request saves land in per-PR scopes nothing else can - # reuse; dev pushes seed the shared copy instead. - save-cache: ${{ github.event_name != 'pull_request' }} - # Pin uv version so the action does not have to fetch the - # manifest from raw.githubusercontent.com on every cache - # miss; that fetch flakes on Windows runners. - version: "0.11.15" - - name: Install device-builder + esphome from PR - # Install device-builder with its esphome + test extras - # first so its pinned versions of pytest/etc. land, then - # overlay the PR's esphome so the downstream tests run - # against this PR's Python code. ``--system`` installs into - # the runner's Python instead of a venv. - run: | - uv pip install --system -e './device-builder[esphome,test]' - uv pip install --system -e ./esphome - - name: Run device-builder pytest - # ``-n auto`` runs under pytest-xdist (matches device-builder's - # own CI). No ``--cov`` here -- this is purely a downstream - # smoke check against this PR's esphome code. ``tests/e2e/slow`` - # is excluded: those are real multi-minute toolchain compiles - # (LibreTiny SDK clone, native ESP-IDF install) that device-builder - # runs in its own dedicated jobs, not this smoke check. - working-directory: device-builder - run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks --ignore=tests/e2e/slow - - pytest: - name: Run pytest - strategy: - fail-fast: false - matrix: - python-version: - - "3.12" - - "3.13" - - "3.14" - os: - - ubuntu-latest - - macOS-latest - - windows-latest - exclude: - # Minimize CI resource usage - # by only running the Python version - # version used for docker images on Windows and macOS - - python-version: "3.13" - os: windows-latest - - python-version: "3.13" - os: macOS-latest - runs-on: ${{ matrix.os }} - needs: - - common - - determine-jobs - if: needs.determine-jobs.outputs.core-ci == 'true' - steps: - - name: Check out code from GitHub - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Restore Python - id: restore-python - uses: ./.github/actions/restore-python - with: - python-version: ${{ matrix.python-version }} - cache-key: ${{ needs.common.outputs.cache-key }} - - name: Register matcher - run: echo "::add-matcher::.github/workflows/matchers/pytest.json" - - name: Run pytest - if: matrix.os == 'windows-latest' - run: | - . ./venv/Scripts/activate.ps1 - pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/ - - name: Run pytest - if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest' - run: | - . venv/bin/activate - pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/ - - name: Upload coverage to Codecov - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 - - name: Save Python virtual environment cache - if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: venv - key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} - - codecov-empty-upload: - name: Report no coverage to Codecov - runs-on: ubuntu-24.04 - needs: - - determine-jobs - # ``pytest`` is the only job that uploads coverage, and it is skipped when - # every changed file is CI-irrelevant (see ``should_run_core_ci`` in - # ``script/determine-jobs.py``). With no upload Codecov never reports a - # result, so the required ``codecov/patch`` status stays pending forever and - # the pull request can never be merged. Tell Codecov up front that this - # commit has nothing to cover so it publishes a passing status instead. - # - # ``force`` skips Codecov's own check that every changed file is ignorable; - # ``determine-jobs`` has already decided none of these files can affect - # coverage, and Codecov would otherwise fail the status for paths it does - # not recognise as non-testable (``docker/**``, ``.yamllint``). - if: needs.determine-jobs.outputs.core-ci == 'false' - steps: - - name: Check out code from GitHub - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Report empty upload to Codecov - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 - with: - run_command: empty-upload - force: true - fail_ci_if_error: true - determine-jobs: name: Determine which jobs to run runs-on: ubuntu-24.04 @@ -376,6 +157,204 @@ jobs: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} + ci-custom: + name: Run script/ci-custom + runs-on: ubuntu-24.04 + needs: + - common + - determine-jobs + if: needs.determine-jobs.outputs.core-ci == 'true' + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + - name: Register matcher + run: echo "::add-matcher::.github/workflows/matchers/ci-custom.json" + - name: Run script/ci-custom + run: | + . venv/bin/activate + script/ci-custom.py + script/build_codeowners.py --check + script/build_language_schema.py --check + script/generate-esp32-boards.py --check + script/generate-rp2-boards.py --check + script/ci_check_duplicate_test_ids.py + script/ci_check_test_fixture_list_form.py + + pylint: + name: Check pylint + runs-on: ubuntu-24.04 + needs: + - common + - determine-jobs + if: needs.determine-jobs.outputs.python-linters == 'true' + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + - name: Run pylint + run: | + . venv/bin/activate + pylint -f parseable --persistent=n esphome + - name: Suggested changes + run: script/ci-suggest-changes + if: always() + + pre-commit-ci-lite: + name: pre-commit.ci lite + runs-on: ubuntu-latest + needs: + - common + - determine-jobs + if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true' + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + # Inlined from esphome/pre-commit-action with a restore-only cache + # step: the pre-commit-seed-cache job owns saving this cache, so + # pull request runs never write per-PR copies. + - name: Restore pre-commit cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/pre-commit + # Must match the key pre-commit-seed-cache saves + # yamllint disable-line rule:line-length + key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} + - name: Run pre-commit + env: + SKIP: pylint,ci-custom + run: | + python -m pip install pre-commit + pre-commit run --show-diff-on-failure --color=always --all-files + - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 + if: always() + + pre-commit-seed-cache: + name: Seed pre-commit cache + runs-on: ubuntu-latest + needs: + - common + # Saves a dev-scoped pre-commit cache that pull request runs can + # restore, since pre-commit.ci lite itself never runs on dev pushes. + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + - name: Cache pre-commit environments + id: cache-pre-commit + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/pre-commit + # Must match the restore key in pre-commit-ci-lite + # yamllint disable-line rule:line-length + key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} + - name: Install pre-commit hook environments + if: steps.cache-pre-commit.outputs.cache-hit != 'true' + run: | + python -m pip install pre-commit + pre-commit install-hooks + + pytest: + name: Run pytest + strategy: + fail-fast: false + matrix: + python-version: + - "3.12" + - "3.13" + - "3.14" + os: + - ubuntu-latest + - macOS-latest + - windows-latest + exclude: + # Minimize CI resource usage + # by only running the Python version + # version used for docker images on Windows and macOS + - python-version: "3.13" + os: windows-latest + - python-version: "3.13" + os: macOS-latest + runs-on: ${{ matrix.os }} + needs: + - common + - determine-jobs + if: needs.determine-jobs.outputs.core-ci == 'true' + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Restore Python + id: restore-python + uses: ./.github/actions/restore-python + with: + python-version: ${{ matrix.python-version }} + cache-key: ${{ needs.common.outputs.cache-key }} + - name: Register matcher + run: echo "::add-matcher::.github/workflows/matchers/pytest.json" + - name: Run pytest + if: matrix.os == 'windows-latest' + run: | + . ./venv/Scripts/activate.ps1 + pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/ + - name: Run pytest + if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest' + run: | + . venv/bin/activate + pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/ + - name: Upload coverage to Codecov + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + - name: Save Python virtual environment cache + if: github.ref == 'refs/heads/dev' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: venv + key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} + + codecov-empty-upload: + name: Report no coverage to Codecov + runs-on: ubuntu-24.04 + needs: + - determine-jobs + # ``pytest`` is the only job that uploads coverage, and it is skipped when + # every changed file is CI-irrelevant (see ``should_run_core_ci`` in + # ``script/determine-jobs.py``). With no upload Codecov never reports a + # result, so the required ``codecov/patch`` status stays pending forever and + # the pull request can never be merged. Tell Codecov up front that this + # commit has nothing to cover so it publishes a passing status instead. + # + # ``force`` skips Codecov's own check that every changed file is ignorable; + # ``determine-jobs`` has already decided none of these files can affect + # coverage, and Codecov would otherwise fail the status for paths it does + # not recognise as non-testable (``docker/**``, ``.yamllint``). + if: needs.determine-jobs.outputs.core-ci == 'false' + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Report empty upload to Codecov + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + run_command: empty-upload + force: true + fail_ci_if_error: true + integration-tests: name: Run integration tests (${{ matrix.bucket.name }}) runs-on: ubuntu-latest @@ -465,32 +444,33 @@ jobs: path: ~/.cache/esphome/platformio-ccache key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }} - cpp-unit-tests: - name: Run C++ unit tests + import-time: + name: Check import esphome.__main__ time runs-on: ubuntu-24.04 needs: - common - determine-jobs - if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]') + if: needs.determine-jobs.outputs.import-time == 'true' steps: - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Restore Python uses: ./.github/actions/restore-python with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - - name: Run cpp_unit_test.py + - name: Check import time against budget and write waterfall HAR run: | . venv/bin/activate - if [ "${{ needs.determine-jobs.outputs.cpp-unit-tests-run-all }}" = "true" ]; then - script/cpp_unit_test.py --all - else - ARGS=$(echo '${{ needs.determine-jobs.outputs.cpp-unit-tests-components }}' | jq -r '.[] | @sh' | xargs) - script/cpp_unit_test.py $ARGS - fi + 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 benchmarks: name: Run CodSpeed benchmarks @@ -529,6 +509,33 @@ jobs: pytest tests/benchmarks/python/ --codspeed --no-cov mode: simulation + cpp-unit-tests: + name: Run C++ unit tests + runs-on: ubuntu-24.04 + needs: + - common + - determine-jobs + if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]') + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + + - name: Run cpp_unit_test.py + run: | + . venv/bin/activate + if [ "${{ needs.determine-jobs.outputs.cpp-unit-tests-run-all }}" = "true" ]; then + script/cpp_unit_test.py --all + else + ARGS=$(echo '${{ needs.determine-jobs.outputs.cpp-unit-tests-components }}' | jq -r '.[] | @sh' | xargs) + script/cpp_unit_test.py $ARGS + fi + clang-tidy-single: name: ${{ matrix.name }} runs-on: ubuntu-24.04 @@ -1093,69 +1100,62 @@ jobs: # Arduino framework via PlatformIO (only components with an esp32-ard test are built): python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio - pre-commit-seed-cache: - name: Seed pre-commit cache - runs-on: ubuntu-latest - needs: - - common - # Saves a dev-scoped pre-commit cache that pull request runs can - # restore, since pre-commit.ci lite itself never runs on dev pushes. - if: github.event_name == 'push' && github.ref == 'refs/heads/dev' - steps: - - name: Check out code from GitHub - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Restore Python - uses: ./.github/actions/restore-python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - cache-key: ${{ needs.common.outputs.cache-key }} - - name: Cache pre-commit environments - id: cache-pre-commit - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.cache/pre-commit - # Must match the restore key in pre-commit-ci-lite - # yamllint disable-line rule:line-length - key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} - - name: Install pre-commit hook environments - if: steps.cache-pre-commit.outputs.cache-hit != 'true' - run: | - python -m pip install pre-commit - pre-commit install-hooks - - pre-commit-ci-lite: - name: pre-commit.ci lite - runs-on: ubuntu-latest + device-builder: + name: Test downstream esphome/device-builder + runs-on: ubuntu-24.04 needs: - common - determine-jobs - if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true' + if: needs.determine-jobs.outputs.device-builder == 'true' steps: - - name: Check out code from GitHub + - name: Check out esphome (this PR) uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Restore Python - uses: ./.github/actions/restore-python with: - python-version: ${{ env.DEFAULT_PYTHON }} - cache-key: ${{ needs.common.outputs.cache-key }} - # Inlined from esphome/pre-commit-action with a restore-only cache - # step: the pre-commit-seed-cache job owns saving this cache, so - # pull request runs never write per-PR copies. - - name: Restore pre-commit cache - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + path: esphome + - name: Check out esphome/device-builder + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - path: ~/.cache/pre-commit - # Must match the key pre-commit-seed-cache saves - # yamllint disable-line rule:line-length - key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} - - name: Run pre-commit - env: - SKIP: pylint,ci-custom + repository: esphome/device-builder + ref: main + path: device-builder + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + - name: Set up uv + # Mirrors the install shape device-builder's own CI uses + # (esphome/device-builder#192): uv replaces pip for the + # install step (order-of-magnitude faster on cold boots, + # with its own wheel cache). actions/setup-python still + # provides the interpreter. + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + enable-cache: true + # Pull request saves land in per-PR scopes nothing else can + # reuse; dev pushes seed the shared copy instead. + save-cache: ${{ github.event_name != 'pull_request' }} + # Pin uv version so the action does not have to fetch the + # manifest from raw.githubusercontent.com on every cache + # miss; that fetch flakes on Windows runners. + version: "0.11.15" + - name: Install device-builder + esphome from PR + # Install device-builder with its esphome + test extras + # first so its pinned versions of pytest/etc. land, then + # overlay the PR's esphome so the downstream tests run + # against this PR's Python code. ``--system`` installs into + # the runner's Python instead of a venv. run: | - python -m pip install pre-commit - pre-commit run --show-diff-on-failure --color=always --all-files - - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 - if: always() + uv pip install --system -e './device-builder[esphome,test]' + uv pip install --system -e ./esphome + - name: Run device-builder pytest + # ``-n auto`` runs under pytest-xdist (matches device-builder's + # own CI). No ``--cov`` here -- this is purely a downstream + # smoke check against this PR's esphome code. ``tests/e2e/slow`` + # is excluded: those are real multi-minute toolchain compiles + # (LibreTiny SDK clone, native ESP-IDF install) that device-builder + # runs in its own dedicated jobs, not this smoke check. + working-directory: device-builder + run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks --ignore=tests/e2e/slow memory-impact-target-branch: name: Build target branch for memory impact @@ -1456,22 +1456,28 @@ jobs: ci-status: name: CI Status runs-on: ubuntu-24.04 + # Listed in the same order the jobs are defined above. Two jobs are + # deliberately left out: "benchmarks" reports through CodSpeed rather than + # this check, and "pre-commit-seed-cache" only populates a cache on pushes + # to dev. needs: - common + - determine-jobs - ci-custom - pylint + - pre-commit-ci-lite - pytest - codecov-empty-upload - integration-tests + - import-time + - cpp-unit-tests - clang-tidy-single - clang-tidy-nosplit - clang-tidy-split - clang-tidy-esp32-variants - - determine-jobs - - device-builder - test-build-components-split - test-esp32-platformio - - pre-commit-ci-lite + - device-builder - memory-impact-target-branch - memory-impact-pr-branch - memory-impact-comment From 15711f8112be28fb10626f21182f20a3d6ec8b22 Mon Sep 17 00:00:00 2001 From: Ardumine <61353807+Ardumine@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:13:44 +0100 Subject: [PATCH 1164/1815] [openthread_info] Add more sensors for Openthread info (#17276) --- .../openthread_info_sensor.cpp | 24 +++ .../openthread_info/openthread_info_sensor.h | 131 ++++++++++++ esphome/components/openthread_info/sensor.py | 188 ++++++++++++++++++ .../openthread_info/test.esp32-c6-idf.yaml | 29 +++ 4 files changed, 372 insertions(+) create mode 100644 esphome/components/openthread_info/openthread_info_sensor.cpp create mode 100644 esphome/components/openthread_info/openthread_info_sensor.h create mode 100644 esphome/components/openthread_info/sensor.py diff --git a/esphome/components/openthread_info/openthread_info_sensor.cpp b/esphome/components/openthread_info/openthread_info_sensor.cpp new file mode 100644 index 0000000000..9beb52d64b --- /dev/null +++ b/esphome/components/openthread_info/openthread_info_sensor.cpp @@ -0,0 +1,24 @@ +#include "openthread_info_sensor.h" +#if defined(USE_OPENTHREAD) && defined(USE_SENSOR) +#include "esphome/core/log.h" + +namespace esphome::openthread_info { + +static const char *const TAG = "openthread_info"; + +void ParentAverageRssiOpenThreadInfo::dump_config() { LOG_SENSOR("", "Parent Average RSSI", this); } +void ParentLastRssiOpenThreadInfo::dump_config() { LOG_SENSOR("", "Parent Last RSSI", this); } +void ParentLinkQualityInOpenThreadInfo::dump_config() { LOG_SENSOR("", "Parent Link Quality In", this); } +void ParentLinkQualityOutOpenThreadInfo::dump_config() { LOG_SENSOR("", "Parent Link Quality Out", this); } +void TxTotalOpenThreadInfo::dump_config() { LOG_SENSOR("", "TX Total", this); } +void TxRetriesOpenThreadInfo::dump_config() { LOG_SENSOR("", "TX Retries", this); } +void TxErrCcaOpenThreadInfo::dump_config() { LOG_SENSOR("", "TX CCA Errors", this); } +void TxErrAbortOpenThreadInfo::dump_config() { LOG_SENSOR("", "TX Abort Errors", this); } +void RxTotalOpenThreadInfo::dump_config() { LOG_SENSOR("", "RX Total", this); } +void RxErrFcsOpenThreadInfo::dump_config() { LOG_SENSOR("", "RX FCS Errors", this); } +void AttachAttemptsOpenThreadInfo::dump_config() { LOG_SENSOR("", "Attach Attempts", this); } +void ParentChangesOpenThreadInfo::dump_config() { LOG_SENSOR("", "Parent Changes", this); } +void PartitionIdChangesOpenThreadInfo::dump_config() { LOG_SENSOR("", "Partition ID Changes", this); } + +} // namespace esphome::openthread_info +#endif diff --git a/esphome/components/openthread_info/openthread_info_sensor.h b/esphome/components/openthread_info/openthread_info_sensor.h new file mode 100644 index 0000000000..dcc90da0c0 --- /dev/null +++ b/esphome/components/openthread_info/openthread_info_sensor.h @@ -0,0 +1,131 @@ +#pragma once + +#include "esphome/core/defines.h" +#if defined(USE_OPENTHREAD) && defined(USE_SENSOR) + +#include "openthread_info_text_sensor.h" +#include "esphome/components/sensor/sensor.h" + +#include +#include + +namespace esphome::openthread_info { + +// Parent RSSI (average) in dBm. Only valid when device is a child; skips publish otherwise. +class ParentAverageRssiOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { + int8_t rssi; + if (otThreadGetParentAverageRssi(instance, &rssi) != OT_ERROR_NONE) { + return; + } + this->publish_state(rssi); + } + void dump_config() override; +}; + +// Parent RSSI (last received frame) in dBm. Only valid when device is a child. +class ParentLastRssiOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { + int8_t rssi; + if (otThreadGetParentLastRssi(instance, &rssi) != OT_ERROR_NONE) { + return; + } + this->publish_state(rssi); + } + void dump_config() override; +}; + +// Incoming link quality from parent (0-3). Only valid when device is a child. +class ParentLinkQualityInOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { + otRouterInfo parent_info; + if (otThreadGetParentInfo(instance, &parent_info) != OT_ERROR_NONE) { + return; + } + this->publish_state(parent_info.mLinkQualityIn); + } + void dump_config() override; +}; + +// Outgoing link quality to parent (0-3). Only valid when device is a child. +class ParentLinkQualityOutOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { + otRouterInfo parent_info; + if (otThreadGetParentInfo(instance, &parent_info) != OT_ERROR_NONE) { + return; + } + this->publish_state(parent_info.mLinkQualityOut); + } + void dump_config() override; +}; + +// --- MAC counters (otLinkGetCounters) — cumulative since boot --- + +class TxTotalOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { this->publish_state(otLinkGetCounters(instance)->mTxTotal); } + void dump_config() override; +}; + +class TxRetriesOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { this->publish_state(otLinkGetCounters(instance)->mTxRetry); } + void dump_config() override; +}; + +class TxErrCcaOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { this->publish_state(otLinkGetCounters(instance)->mTxErrCca); } + void dump_config() override; +}; + +class TxErrAbortOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { this->publish_state(otLinkGetCounters(instance)->mTxErrAbort); } + void dump_config() override; +}; + +class RxTotalOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { this->publish_state(otLinkGetCounters(instance)->mRxTotal); } + void dump_config() override; +}; + +class RxErrFcsOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { this->publish_state(otLinkGetCounters(instance)->mRxErrFcs); } + void dump_config() override; +}; + +// --- MLE stability counters (otThreadGetMleCounters) — cumulative since boot --- + +class AttachAttemptsOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { + this->publish_state(otThreadGetMleCounters(instance)->mAttachAttempts); + } + void dump_config() override; +}; + +class ParentChangesOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { + this->publish_state(otThreadGetMleCounters(instance)->mParentChanges); + } + void dump_config() override; +}; + +class PartitionIdChangesOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { + this->publish_state(otThreadGetMleCounters(instance)->mPartitionIdChanges); + } + void dump_config() override; +}; + +} // namespace esphome::openthread_info +#endif diff --git a/esphome/components/openthread_info/sensor.py b/esphome/components/openthread_info/sensor.py new file mode 100644 index 0000000000..4d5b3d54f4 --- /dev/null +++ b/esphome/components/openthread_info/sensor.py @@ -0,0 +1,188 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_SIGNAL_STRENGTH, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_DECIBEL_MILLIWATT, + UNIT_EMPTY, +) + +CONF_PARENT_AVERAGE_RSSI = "parent_average_rssi" +CONF_PARENT_LAST_RSSI = "parent_last_rssi" +CONF_PARENT_LINK_QUALITY_IN = "parent_link_quality_in" +CONF_PARENT_LINK_QUALITY_OUT = "parent_link_quality_out" +CONF_TX_TOTAL = "tx_total" +CONF_TX_RETRIES = "tx_retries" +CONF_TX_ERR_CCA = "tx_err_cca" +CONF_TX_ERR_ABORT = "tx_err_abort" +CONF_RX_TOTAL = "rx_total" +CONF_RX_ERR_FCS = "rx_err_fcs" +CONF_ATTACH_ATTEMPTS = "attach_attempts" +CONF_PARENT_CHANGES = "parent_changes" +CONF_PARTITION_ID_CHANGES = "partition_id_changes" + +DEPENDENCIES = ["openthread"] + +openthread_info_ns = cg.esphome_ns.namespace("openthread_info") +ParentAverageRssiOpenThreadInfo = openthread_info_ns.class_( + "ParentAverageRssiOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +ParentLastRssiOpenThreadInfo = openthread_info_ns.class_( + "ParentLastRssiOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +ParentLinkQualityInOpenThreadInfo = openthread_info_ns.class_( + "ParentLinkQualityInOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +ParentLinkQualityOutOpenThreadInfo = openthread_info_ns.class_( + "ParentLinkQualityOutOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +TxTotalOpenThreadInfo = openthread_info_ns.class_( + "TxTotalOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +TxRetriesOpenThreadInfo = openthread_info_ns.class_( + "TxRetriesOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +TxErrCcaOpenThreadInfo = openthread_info_ns.class_( + "TxErrCcaOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +TxErrAbortOpenThreadInfo = openthread_info_ns.class_( + "TxErrAbortOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +RxTotalOpenThreadInfo = openthread_info_ns.class_( + "RxTotalOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +RxErrFcsOpenThreadInfo = openthread_info_ns.class_( + "RxErrFcsOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +AttachAttemptsOpenThreadInfo = openthread_info_ns.class_( + "AttachAttemptsOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +ParentChangesOpenThreadInfo = openthread_info_ns.class_( + "ParentChangesOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +PartitionIdChangesOpenThreadInfo = openthread_info_ns.class_( + "PartitionIdChangesOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.Optional(CONF_PARENT_AVERAGE_RSSI): sensor.sensor_schema( + ParentAverageRssiOpenThreadInfo, + unit_of_measurement=UNIT_DECIBEL_MILLIWATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("5s")), + cv.Optional(CONF_PARENT_LAST_RSSI): sensor.sensor_schema( + ParentLastRssiOpenThreadInfo, + unit_of_measurement=UNIT_DECIBEL_MILLIWATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("5s")), + cv.Optional(CONF_PARENT_LINK_QUALITY_IN): sensor.sensor_schema( + ParentLinkQualityInOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("5s")), + cv.Optional(CONF_PARENT_LINK_QUALITY_OUT): sensor.sensor_schema( + ParentLinkQualityOutOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("5s")), + cv.Optional(CONF_TX_TOTAL): sensor.sensor_schema( + TxTotalOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("30s")), + cv.Optional(CONF_TX_RETRIES): sensor.sensor_schema( + TxRetriesOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("30s")), + cv.Optional(CONF_TX_ERR_CCA): sensor.sensor_schema( + TxErrCcaOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("30s")), + cv.Optional(CONF_TX_ERR_ABORT): sensor.sensor_schema( + TxErrAbortOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("30s")), + cv.Optional(CONF_RX_TOTAL): sensor.sensor_schema( + RxTotalOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("30s")), + cv.Optional(CONF_RX_ERR_FCS): sensor.sensor_schema( + RxErrFcsOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("30s")), + cv.Optional(CONF_ATTACH_ATTEMPTS): sensor.sensor_schema( + AttachAttemptsOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("30s")), + cv.Optional(CONF_PARENT_CHANGES): sensor.sensor_schema( + ParentChangesOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("30s")), + cv.Optional(CONF_PARTITION_ID_CHANGES): sensor.sensor_schema( + PartitionIdChangesOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("30s")), + } +) + + +async def setup_conf(config: dict, key: str): + if conf := config.get(key): + var = await sensor.new_sensor(conf) + await cg.register_component(var, conf) + + +async def to_code(config): + await setup_conf(config, CONF_PARENT_AVERAGE_RSSI) + await setup_conf(config, CONF_PARENT_LAST_RSSI) + await setup_conf(config, CONF_PARENT_LINK_QUALITY_IN) + await setup_conf(config, CONF_PARENT_LINK_QUALITY_OUT) + await setup_conf(config, CONF_TX_TOTAL) + await setup_conf(config, CONF_TX_RETRIES) + await setup_conf(config, CONF_TX_ERR_CCA) + await setup_conf(config, CONF_TX_ERR_ABORT) + await setup_conf(config, CONF_RX_TOTAL) + await setup_conf(config, CONF_RX_ERR_FCS) + await setup_conf(config, CONF_ATTACH_ATTEMPTS) + await setup_conf(config, CONF_PARENT_CHANGES) + await setup_conf(config, CONF_PARTITION_ID_CHANGES) diff --git a/tests/components/openthread_info/test.esp32-c6-idf.yaml b/tests/components/openthread_info/test.esp32-c6-idf.yaml index ded0f17611..8c55546ce6 100644 --- a/tests/components/openthread_info/test.esp32-c6-idf.yaml +++ b/tests/components/openthread_info/test.esp32-c6-idf.yaml @@ -28,3 +28,32 @@ text_sensor: name: "PAN ID" ext_pan_id: name: "Extended PAN ID" + +sensor: + - platform: openthread_info + parent_average_rssi: + name: "Parent Average RSSI" + parent_last_rssi: + name: "Parent Last RSSI" + parent_link_quality_in: + name: "Parent Link Quality In" + parent_link_quality_out: + name: "Parent Link Quality Out" + tx_total: + name: "TX Total" + tx_retries: + name: "TX Retries" + tx_err_cca: + name: "TX CCA Errors" + tx_err_abort: + name: "TX Abort Errors" + rx_total: + name: "RX Total" + rx_err_fcs: + name: "RX FCS Errors" + attach_attempts: + name: "Attach Attempts" + parent_changes: + name: "Parent Changes" + partition_id_changes: + name: "Partition ID Changes" From e50fae3f4693d01031d687e911d6749e9dbec338 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Sat, 1 Aug 2026 01:40:57 +0300 Subject: [PATCH 1165/1815] [ln882h_ble] BLE controller support for LN882H (#17777) --- CODEOWNERS | 1 + esphome/components/ln882h_ble/__init__.py | 48 ++++ esphome/components/ln882h_ble/ln882h_ble.cpp | 251 ++++++++++++++++++ esphome/components/ln882h_ble/ln882h_ble.h | 50 ++++ esphome/core/defines.h | 1 + tests/components/ln882h_ble/common.yaml | 2 + .../ln882h_ble/test.ln882x-ard.yaml | 2 + 7 files changed, 355 insertions(+) create mode 100644 esphome/components/ln882h_ble/__init__.py create mode 100644 esphome/components/ln882h_ble/ln882h_ble.cpp create mode 100644 esphome/components/ln882h_ble/ln882h_ble.h create mode 100644 tests/components/ln882h_ble/common.yaml create mode 100644 tests/components/ln882h_ble/test.ln882x-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 1a963d8ea6..491371f9f4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -295,6 +295,7 @@ esphome/components/light/* @esphome/core esphome/components/lightwaverf/* @max246 esphome/components/lilygo_t5_47/touchscreen/* @jesserockz esphome/components/lm75b/* @beormund +esphome/components/ln882h_ble/* @Bl00d-B0b esphome/components/ln882x/* @lamauny esphome/components/lock/* @esphome/core esphome/components/logger/* @esphome/core diff --git a/esphome/components/ln882h_ble/__init__.py b/esphome/components/ln882h_ble/__init__.py new file mode 100644 index 0000000000..f499dbc6fd --- /dev/null +++ b/esphome/components/ln882h_ble/__init__.py @@ -0,0 +1,48 @@ +"""LN882H BLE — BLE controller support for the LN882H LibreTiny chips. + +The platform analog of esp32_ble / rp2040_ble: owns the LN882H BLE stack +bring-up and the controller BLE address. Consumers (ln882h_ble_tracker) build +on this component and contain no SDK calls of their own. + +The BLE stack is compiled and linked by the LibreTiny lightning-ln882h builder +when CFG_SUPPORT_BLE=1 is set via custom_options.proj_config#h, which this +component does (LibreTiny v1.13.0+). +""" + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID +from esphome.types import ConfigType + +DEPENDENCIES = ["ln882x"] +CODEOWNERS = ["@Bl00d-B0b"] + +ln882h_ble_ns = cg.esphome_ns.namespace("ln882h_ble") +LN882HBLE = ln882h_ble_ns.class_("LN882HBLE", cg.Component) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(LN882HBLE), + # Default off: on the single-core LN882H, bringing the BLE stack up during + # boot competes with the WiFi connection handshake. Consumers enable the + # stack lazily on first use (e.g. the tracker's first scan start). + cv.Optional(CONF_ENABLE_ON_BOOT, default=False): cv.boolean, + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT])) + + # Enable the BLE stack in the build. The LibreTiny lightning-ln882h builder + # gates the BLE stack sources and libraries on CFG_SUPPORT_BLE, read from + # the custom option key "proj_config#h" (the '#h' maps to proj_config.h) + # before the stock header's default of 0. A list is used so any other + # component adding to this key concatenates with it rather than replacing + # it (add_platformio_option only merges when both values are lists). + cg.add_platformio_option("custom_options.proj_config#h", ["CFG_SUPPORT_BLE=1"]) + + cg.add_define("USE_LN882H_BLE") diff --git a/esphome/components/ln882h_ble/ln882h_ble.cpp b/esphome/components/ln882h_ble/ln882h_ble.cpp new file mode 100644 index 0000000000..dd8fee390d --- /dev/null +++ b/esphome/components/ln882h_ble/ln882h_ble.cpp @@ -0,0 +1,251 @@ +// ln882h_ble.cpp +// +// BLE controller support for the LN882H (LibreTiny lightning-ln882h family) — +// the platform analog of esp32_ble / rp2040_ble. Owns the one-time stack +// bring-up (rw_init + the ln_* app init sequence) and the controller BLE +// address (persistent KV entry, WiFi-MAC-derived once). Consumers +// (ln882h_ble_tracker) build on this component and contain no SDK calls of +// their own. +// +// BLE stack init and scan lifecycle mirror the SDK's ble_app usage. The BLE +// stack itself is compiled and linked by the LibreTiny lightning-ln882h builder +// (CFG_SUPPORT_BLE=1 via custom_options.proj_config#h; prebuilt +// libln882h_ble_full_stack.a). + +#include "ln882h_ble.h" // pulls esphome/core/defines.h for USE_LN882H_BLE + +#ifdef USE_LN882H_BLE + +#include +#include +#include + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" // get_mac_address_raw() +#include "esphome/core/log.h" + +// --------------------------------------------------------------------------- +// LN882H BLE SDK — forward declarations +// --------------------------------------------------------------------------- +extern "C" { + +struct ln_bd_addr_v_t { // NOLINT(readability-identifier-naming) - mirrors the SDK type name + uint8_t addr[6]; +}; // ABI-identical to ln_bd_addr_t +void ln_kv_ble_app_init(void); +struct ln_bd_addr_v_t *ln_kv_ble_pub_addr_get(void); +int ln_kv_ble_addr_store(struct ln_bd_addr_v_t addr); +void soc_module_clk_gate_enable(uint32_t clk); + +void rw_init(uint8_t mac[6]); +void ln_gap_app_init(void); +void ln_gatt_app_init(void); +void ln_ble_conn_mgr_init(void); +void ln_ble_evt_mgr_init(void); +void ln_ble_smp_init(void); +void ln_ble_scan_mgr_init(void); +void ln_rw_app_task_init(void); +void ln_gap_reset(void); + +void ln_ble_scan_actv_creat(void); +void ln_ble_scan_start(void *scan_param); +void ln_ble_scan_stop(void); + +} // extern "C" + +// ln_bd_addr_v_t mirrors the SDK's ln_bd_addr_t (ln_ble_app_defines.h) and is +// passed to ln_kv_ble_addr_store() by value, so its size and alignment are part +// of the calling convention. +static_assert(sizeof(struct ln_bd_addr_v_t) == 6, "ln_bd_addr_v_t must match the SDK's ln_bd_addr_t layout"); +static_assert(alignof(struct ln_bd_addr_v_t) == 1, "ln_bd_addr_v_t must stay byte-aligned like the SDK type"); + +// --------------------------------------------------------------------------- +// LN882H SDK constants +// CLK_G_BLE — hal/hal_clock.h clock gate bit for the BLE block +// BLE_EVT_ID_SCAN_REPORT — ble/ble_evt.h event id for scan reports +// GAPM_* — ble/mac/ble/hl/api/gapm_task.h, enums gapm_scan_type / +// gapm_dup_filter_pol / gapm_scan_prop +// --------------------------------------------------------------------------- +static constexpr uint32_t CLK_G_BLE = 1u << 0; + +// WiFi/BLE packet-traffic-indication (PTI) arbitration register. The LN882H SDK +// exposes no symbolic name for this register; the address and value replicate +// the SDK reference bring-up. 0x003F sets all six PTI priority bits so the +// arbiter can pre-empt WiFi for BLE traffic. +static constexpr uint32_t BLE_COEX_PTI_REG_ADDR = 0x400121F8; +static constexpr uint32_t BLE_COEX_PTI_ENABLE_ALL = 0x003F; + +// ble_app_default_cfg.h BLE_DEFAULT_PUBLIC_ADDR, in the SDK's ln_bd_addr_t +// array order — least-significant octet first, the BLE/HCI convention (the +// SDK's own AT commands print addr[5]..addr[0]). Printable form: +// 00:FF:03:12:34:56. +static constexpr uint8_t BLE_DEFAULT_ADDR[6] = {0x56, 0x34, 0x12, 0x03, 0xFF, 0x00}; + +// The SDK's KV loader treats an all-zero address as unset and substitutes the +// default; resolve_mac_() applies the same rule to a stored entry. +static bool is_unset_addr(const uint8_t (&addr)[6]) { + return std::all_of(std::begin(addr), std::end(addr), [](uint8_t b) { return b == 0; }); +} + +// gapm_scan_type: GEN_DISC = 0, LIM_DISC = 1, OBSERVER = 2. Observer reports every +// advertisement without filtering — what a tracker wants. +static constexpr uint8_t GAPM_SCAN_TYPE_OBSERVER = 2; +static constexpr uint8_t GAPM_DUP_FILT_DIS = 0; +// gapm_scan_prop bits: PHY_1M = 1<<0, PHY_CODED = 1<<1, ACTIVE_1M = 1<<2, ACTIVE_CODED = 1<<3. +static constexpr uint8_t GAPM_SCAN_PROP_PHY_1M_BIT = 1 << 0; + +// Scan parameter block passed to ln_ble_scan_start(); mirrors the SDK layout, +// with the pad byte explicit so the whole block zero-initialises. +struct le_scan_parameters_t { // NOLINT(readability-identifier-naming) - mirrors the SDK type name + uint8_t type; + uint8_t prop; + uint8_t dup_filt_pol; + uint8_t pad; + uint16_t scan_intv; + uint16_t scan_wd; +}; +// Pin the compiler's layout decisions for the hand-mirrored SDK struct: it is +// passed to ln_ble_scan_start() as void*, so a padding drift would silently +// feed garbage scan parameters to the controller. +static_assert(sizeof(le_scan_parameters_t) == 8, "le_scan_parameters_t must match the SDK layout"); +static_assert(offsetof(le_scan_parameters_t, scan_intv) == 4, "unexpected padding in le_scan_parameters_t"); + +// --------------------------------------------------------------------------- +// __sprintf weak stub +// +// The LN882H BLE SDK objects reference __sprintf (a Beken/LN libc alias) that +// LibreTiny's newlib does not provide. Supply a weak fallback so linking +// succeeds; a real definition, if one is ever provided, takes precedence. +// --------------------------------------------------------------------------- +#include +#include +extern "C" __attribute__((weak)) int +__sprintf( // NOLINT(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + char *str, const char *format, ...) { + va_list args; + va_start(args, format); + int ret = vsprintf(str, format, args); // NOLINT + va_end(args); + return ret; +} + +namespace esphome::ln882h_ble { + +static const char *const TAG = "ln882h_ble"; + +// --------------------------------------------------------------------------- +// Component lifecycle +// --------------------------------------------------------------------------- + +void LN882HBLE::setup() { + // Resolve the MAC early so get_mac_lsb_first() is valid for consumers before + // the stack is up. The KV load also happens here (no stack dependency). + this->resolve_mac_(); + if (this->enable_on_boot_) { + this->enable(); + } +} + +// AFTER_WIFI, not BLUETOOTH: replicates the proven pre-split timing — the LN +// SDK's KV subsystem is first touched only once WiFi is up; earlier access +// destabilized the device in hardware testing. +float LN882HBLE::get_setup_priority() const { return setup_priority::AFTER_WIFI; } + +void LN882HBLE::enable() { + if (this->state_ != BLEComponentState::STATE_OFF) + return; + this->state_ = BLEComponentState::ENABLING; + + *reinterpret_cast(BLE_COEX_PTI_REG_ADDR) = BLE_COEX_PTI_ENABLE_ALL; + soc_module_clk_gate_enable(CLK_G_BLE); + + rw_init(this->ble_mac_); + ln_gap_app_init(); + ln_gatt_app_init(); + ln_ble_conn_mgr_init(); + ln_ble_evt_mgr_init(); + ln_ble_smp_init(); + ln_ble_scan_mgr_init(); + ln_rw_app_task_init(); + ln_gap_reset(); + + delay(100); // NOLINT — one-time BLE stack init; SDK requires this settle time + + ln_ble_scan_actv_creat(); + delay(10); + + // Prime the scan activity with a short probe start/stop — the SDK's scan + // manager completes activity creation on the first start. + // static: its address is handed to ln_ble_scan_start(void *), which may + // retain it past this call. + static le_scan_parameters_t probe_p{}; + probe_p.type = GAPM_SCAN_TYPE_OBSERVER; + probe_p.prop = GAPM_SCAN_PROP_PHY_1M_BIT; + probe_p.dup_filt_pol = GAPM_DUP_FILT_DIS; + probe_p.scan_intv = 160; + probe_p.scan_wd = 16; + ln_ble_scan_start(&probe_p); + delay(10); + ln_ble_scan_stop(); + + this->state_ = BLEComponentState::ACTIVE; + ESP_LOGD(TAG, "BLE stack initialised"); +} + +void LN882HBLE::get_mac_lsb_first(uint8_t out[6]) const { memcpy(out, this->ble_mac_, sizeof(this->ble_mac_)); } + +void LN882HBLE::dump_config() { + ESP_LOGCONFIG(TAG, + "LN882H BLE:\n" + " MAC address: %02X:%02X:%02X:%02X:%02X:%02X\n" + " Active: %s", + this->ble_mac_[5], this->ble_mac_[4], this->ble_mac_[3], this->ble_mac_[2], this->ble_mac_[1], + this->ble_mac_[0], YESNO(this->is_active())); +} + +// --------------------------------------------------------------------------- +// MAC resolution +// --------------------------------------------------------------------------- + +void LN882HBLE::resolve_mac_() { + ln_kv_ble_app_init(); + + // ln_kv_ble_app_init() loads the persistent address from the "2_ble_addr" KV + // entry, falling back to BLE_DEFAULT_ADDR when nothing is stored. A stored + // address is preferred so boot does not write flash. Order is LSB-first + // throughout (BLE/HCI convention); consumers reverse for printable form. + ln_bd_addr_v_t bt_addr{}; + bool have_unique_addr = false; + if (const ln_bd_addr_v_t *stored = ln_kv_ble_pub_addr_get(); stored != nullptr) { + bt_addr = *stored; + // All-zero is "unset", not a unique address: ln_kv_ble_addr_load() itself + // substitutes the default for it, so programming it verbatim would give the + // controller a null address. Treat it like the default and derive instead. + have_unique_addr = + memcmp(bt_addr.addr, BLE_DEFAULT_ADDR, sizeof(bt_addr.addr)) != 0 && !is_unset_addr(bt_addr.addr); + } else { + // KV subsystem down (wrong partition layout, corrupted region): derive + // below instead of dereferencing null and boot-looping. + ESP_LOGW(TAG, "BLE address KV unavailable; deriving address from WiFi MAC"); + } + if (!have_unique_addr) { + uint8_t wifi_mac[6] = {0}; + get_mac_address_raw(wifi_mac); // MSB-first + // Reverse into controller (LSB-first) order, then BLE = WiFi + 1: increment + // the NIC low byte (addr[0] once reversed), no carry, OUI unchanged — the + // Beken/Tuya factory pairing the bk72xx sibling also uses. + for (int i = 0; i < 6; i++) + bt_addr.addr[i] = wifi_mac[5 - i]; + bt_addr.addr[0] = static_cast(bt_addr.addr[0] + 1); + if (int err = ln_kv_ble_addr_store(bt_addr); err != 0) { + ESP_LOGW(TAG, "Failed to persist derived BLE address (err %d); will re-derive next boot", err); + } else { + ESP_LOGD(TAG, "MAC derived (WiFi+1) and stored"); + } + } + memcpy(this->ble_mac_, bt_addr.addr, 6); +} + +} // namespace esphome::ln882h_ble + +#endif // USE_LN882H_BLE diff --git a/esphome/components/ln882h_ble/ln882h_ble.h b/esphome/components/ln882h_ble/ln882h_ble.h new file mode 100644 index 0000000000..0e3e311341 --- /dev/null +++ b/esphome/components/ln882h_ble/ln882h_ble.h @@ -0,0 +1,50 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_LN882H_BLE + +#include "esphome/core/component.h" + +#include + +namespace esphome::ln882h_ble { + +enum class BLEComponentState : uint8_t { + STATE_OFF = 0, + ENABLING, + ACTIVE, +}; + +class LN882HBLE final : public Component { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override; + + /// Bring up the LN882H BLE stack (one-time; the SDK has no teardown path). + /// Requires setup() to have run: rw_init() is handed the address resolved + /// there. Blocks ~120 ms across the SDK's settle points, so calling it from + /// loop() (enable_on_boot: false) trips the loop-blocking warning once. + void enable(); + bool is_active() const { return this->state_ == BLEComponentState::ACTIVE; } + + void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } + + /// Controller BLE address in the SDK's ln_bd_addr_t order: least-significant + /// octet first (the BLE/HCI convention). Reverse for printable form — the + /// byte order is in the name so platform analogs cannot be confused + /// (the bk72xx sibling exposes the same accessor). + void get_mac_lsb_first(uint8_t out[6]) const; + + protected: + void resolve_mac_(); + + uint8_t ble_mac_[6]{0}; // controller (LSB-first) order, as ln_bd_addr_t stores it + BLEComponentState state_{BLEComponentState::STATE_OFF}; + bool enable_on_boot_{false}; +}; + +} // namespace esphome::ln882h_ble + +#endif // USE_LN882H_BLE diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 2b84c72a3c..65726bdb1d 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -450,6 +450,7 @@ #ifdef USE_LIBRETINY #define USE_BK72XX_BLE +#define USE_LN882H_BLE #define USE_CAPTIVE_PORTAL #define USE_WIFI_SCAN_RESULTS_LOCK #define USE_SOCKET_IMPL_LWIP_SOCKETS diff --git a/tests/components/ln882h_ble/common.yaml b/tests/components/ln882h_ble/common.yaml new file mode 100644 index 0000000000..c466c2a4de --- /dev/null +++ b/tests/components/ln882h_ble/common.yaml @@ -0,0 +1,2 @@ +ln882h_ble: + enable_on_boot: true diff --git a/tests/components/ln882h_ble/test.ln882x-ard.yaml b/tests/components/ln882h_ble/test.ln882x-ard.yaml new file mode 100644 index 0000000000..08cb35b3d8 --- /dev/null +++ b/tests/components/ln882h_ble/test.ln882x-ard.yaml @@ -0,0 +1,2 @@ +packages: + ln882h_ble: !include common.yaml From ce9b221be4337d66077025236a6623f464e2b7da Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 31 Jul 2026 23:05:06 -0500 Subject: [PATCH 1166/1815] [serial_proxy] Restrict port operations to the subscriber and harden subscription handling (#17796) --- esphome/components/api/api_connection.cpp | 8 ++-- .../components/serial_proxy/serial_proxy.cpp | 47 ++++++++++++++++--- .../components/serial_proxy/serial_proxy.h | 12 +++-- .../components/serial_proxy/serial_proxy.h | 7 +-- 4 files changed, 58 insertions(+), 16 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 9aac7bd7d1..3dc2a06c85 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1543,8 +1543,8 @@ void APIConnection::on_serial_proxy_configure_request(const SerialProxyConfigure static_cast(proxies.size())); return; } - proxies[msg.instance]->configure(msg.baudrate, msg.flow_control, static_cast(msg.parity), msg.stop_bits, - msg.data_size); + proxies[msg.instance]->configure(this, msg.baudrate, msg.flow_control, static_cast(msg.parity), + msg.stop_bits, msg.data_size); } void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest &msg) { @@ -1553,7 +1553,7 @@ void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance); return; } - proxies[msg.instance]->write_from_client(msg.data, msg.data_len); + proxies[msg.instance]->write_from_client(this, msg.data, msg.data_len); } void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg) { @@ -1562,7 +1562,7 @@ void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetM ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance); return; } - proxies[msg.instance]->set_modem_pins(msg.line_states); + proxies[msg.instance]->set_modem_pins(this, msg.line_states); } void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg) { diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index 04c94e9292..4b3a907416 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -89,8 +89,14 @@ void SerialProxy::dump_config() { this->dtr_pin_ != nullptr ? "configured" : "not configured"); } -void SerialProxy::configure(uint32_t baudrate, bool flow_control, uint8_t parity, uint8_t stop_bits, - uint8_t data_size) { +void SerialProxy::configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity, + uint8_t stop_bits, uint8_t data_size) { +#ifdef USE_API + if (this->port_claimed_by_other_(api_connection)) { + ESP_LOGW(TAG, "Ignoring configure request from client without port access [%" PRIu32 "]", this->instance_index_); + return; + } +#endif ESP_LOGD(TAG, "Configuring serial proxy [%" PRIu32 "]: baud=%" PRIu32 ", flow_ctrl=%s, parity=%" PRIu8 ", stop=%" PRIu8 ", data=%" PRIu8, @@ -143,13 +149,27 @@ void SerialProxy::configure(uint32_t baudrate, bool flow_control, uint8_t parity } } -void SerialProxy::write_from_client(const uint8_t *data, size_t len) { +void SerialProxy::write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) { +#ifdef USE_API + // Bytes from a client other than the live subscriber would interleave with the + // subscriber's traffic on the wire + if (this->port_claimed_by_other_(api_connection)) { + ESP_LOGW(TAG, "Ignoring write from client without port access [%" PRIu32 "]", this->instance_index_); + return; + } +#endif if (data == nullptr || len == 0) return; this->write_array(data, len); } -void SerialProxy::set_modem_pins(uint32_t line_states) { +void SerialProxy::set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) { +#ifdef USE_API + if (this->port_claimed_by_other_(api_connection)) { + ESP_LOGW(TAG, "Ignoring modem pin request from client without port access [%" PRIu32 "]", this->instance_index_); + return; + } +#endif const bool rts = (line_states & SERIAL_PROXY_LINE_STATE_FLAG_RTS) != 0; const bool dtr = (line_states & SERIAL_PROXY_LINE_STATE_FLAG_DTR) != 0; ESP_LOGV(TAG, "Setting modem pins [%" PRIu32 "]: RTS=%s, DTR=%s", this->instance_index_, ONOFF(rts), ONOFF(dtr)); @@ -175,13 +195,28 @@ uart::UARTFlushResult SerialProxy::flush_port() { } #ifdef USE_API +bool SerialProxy::port_claimed_by_other_(api::APIConnection *api_connection) const { + return this->api_connection_ != nullptr && this->api_connection_ != api_connection && + this->api_connection_->is_connection_setup(); +} + void SerialProxy::serial_proxy_request(api::APIConnection *api_connection, api::enums::SerialProxyRequestType type) { switch (type) { case api::enums::SERIAL_PROXY_REQUEST_TYPE_SUBSCRIBE: - if (this->api_connection_ != nullptr) { - ESP_LOGE(TAG, "Only one API subscription is allowed at a time"); + if (this->api_connection_ == api_connection) { + ESP_LOGV(TAG, "API connection is already subscribed to serial proxy [%" PRIu32 "]", this->instance_index_); return; } + if (this->api_connection_ != nullptr) { + // A living subscriber keeps exclusive access. Its connection may be dead without + // loop() having noticed yet (e.g. the client crashed and reconnected quickly); + // in that case let the new client take over instead of locking it out. + if (this->api_connection_->is_connection_setup()) { + ESP_LOGE(TAG, "Only one API subscription is allowed at a time"); + return; + } + ESP_LOGW(TAG, "Previous subscriber disconnected; taking over subscription"); + } this->api_connection_ = api_connection; this->enable_loop(); ESP_LOGV(TAG, "API connection subscribed to serial proxy [%" PRIu32 "]", this->instance_index_); diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index e35fab3d42..268c1b52be 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -67,12 +67,14 @@ class SerialProxy final : public uart::UARTDevice, public Component { api::enums::SerialProxyPortType get_port_type() const { return this->port_type_; } /// Configure UART parameters and apply them + /// @param api_connection The API connection requesting the change /// @param baudrate Baud rate in bits per second /// @param flow_control True to enable hardware flow control /// @param parity Parity setting (0=none, 1=even, 2=odd) /// @param stop_bits Number of stop bits (1 or 2) /// @param data_size Number of data bits (5-8) - void configure(uint32_t baudrate, bool flow_control, uint8_t parity, uint8_t stop_bits, uint8_t data_size); + void configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity, + uint8_t stop_bits, uint8_t data_size); /// Get the currently subscribed API connection (nullptr if none) api::APIConnection *get_api_connection() { return this->api_connection_; } @@ -81,12 +83,13 @@ class SerialProxy final : public uart::UARTDevice, public Component { void serial_proxy_request(api::APIConnection *api_connection, api::enums::SerialProxyRequestType type); /// Write data received from an API client to the serial device + /// @param api_connection The API connection sending the data /// @param data Pointer to data buffer /// @param len Number of bytes to write - void write_from_client(const uint8_t *data, size_t len); + void write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len); /// Set modem pin states from a bitmask of SerialProxyLineStateFlag values - void set_modem_pins(uint32_t line_states); + void set_modem_pins(api::APIConnection *api_connection, uint32_t line_states); /// Get current modem pin states as a bitmask of SerialProxyLineStateFlag values uint32_t get_modem_pins() const; @@ -104,6 +107,9 @@ class SerialProxy final : public uart::UARTDevice, public Component { #ifdef USE_API /// Read from UART and send to API client (slow path with 256-byte stack buffer) void read_and_send_(size_t available); + + /// True when a live subscriber other than the given connection holds the port + bool port_claimed_by_other_(api::APIConnection *api_connection) const; #endif /// Instance index for identifying this proxy in API messages diff --git a/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h b/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h index bab27549e7..d8b068fb36 100644 --- a/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h +++ b/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h @@ -32,9 +32,10 @@ class SerialProxy { api::enums::SerialProxyPortType get_port_type() const { return {}; } api::APIConnection *get_api_connection() { return nullptr; } void serial_proxy_request(api::APIConnection *conn, api::enums::SerialProxyRequestType type) {} - void configure(uint32_t baudrate, bool flow_control, uint8_t parity, uint32_t stop_bits, uint32_t data_size) {} - void write_from_client(const uint8_t *data, size_t len) {} - void set_modem_pins(uint32_t line_states) {} + void configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity, + uint32_t stop_bits, uint32_t data_size) {} + void write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) {} + void set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) {} uint32_t get_modem_pins() const { return 0; } uart::UARTFlushResult flush_port() { return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; } From 1df2759db642aff8fd431128e2581d8695b97cc3 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 31 Jul 2026 23:05:45 -0500 Subject: [PATCH 1167/1815] [nextion] Reconnect stale HTTP connection during TFT upload (#17824) --- .../nextion/nextion_upload_esp32.cpp | 64 ++++++++++++++++--- 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/esphome/components/nextion/nextion_upload_esp32.cpp b/esphome/components/nextion/nextion_upload_esp32.cpp index cd8feab84f..e2d5ae8ad7 100644 --- a/esphome/components/nextion/nextion_upload_esp32.cpp +++ b/esphome/components/nextion/nextion_upload_esp32.cpp @@ -44,17 +44,60 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r ESP_LOGV(TAG, "Range: %s", range_header); esp_http_client_set_header(http_client, "Range", range_header); ESP_LOGV(TAG, "Open HTTP"); - esp_err_t err = esp_http_client_open(http_client, 0); - if (err != ESP_OK) { - ESP_LOGE(TAG, "HTTP open failed: %s", esp_err_to_name(err)); - return -1; + int chunk_size = -1; + int status_code = -1; + esp_err_t last_err = ESP_FAIL; + for (uint8_t attempt = 0; attempt < this->tft_upload_http_retries_; attempt++) { + status_code = -1; + last_err = esp_http_client_open(http_client, 0); + if (last_err == ESP_OK) { + ESP_LOGV(TAG, "Fetch length"); + chunk_size = esp_http_client_fetch_headers(http_client); + ESP_LOGV(TAG, "Length: %d", chunk_size); + if (chunk_size >= 0) { + status_code = esp_http_client_get_status_code(http_client); + // Accept the requested range (206 with exact length) or a full-body 200 + // only from offset 0; elsewhere a 200 replays the file and corrupts the display. + if (chunk_size > 0 && + ((status_code == 206 && chunk_size == static_cast(range_end - range_start + 1)) || + (status_code == 200 && range_start == 0 && chunk_size == static_cast(this->tft_size_)))) { + break; + } + if (status_code == 200) { + if (range_start == 0) { + ESP_LOGE(TAG, "Unexpected length for 200 response: %d (expected %d)", chunk_size, + static_cast(this->tft_size_)); + } else { + // A server that ignored the range once will ignore it again + ESP_LOGE(TAG, "Server does not support range requests (got 200 at offset %" PRIu32 ")", range_start); + } + chunk_size = -1; + last_err = ESP_FAIL; + break; + } + ESP_LOGW(TAG, "Bad response: status %d, length %d (expected %" PRIu32 ")", status_code, chunk_size, + range_end - range_start + 1); + chunk_size = -1; + last_err = ESP_FAIL; + // A 4xx (except timeout/rate-limit) won't improve on retry + if (status_code >= 400 && status_code < 500 && status_code != 408 && status_code != 429) { + break; + } + } else { + ESP_LOGW(TAG, "Get length failed: %d", chunk_size); + last_err = ESP_FAIL; + } + } else { + ESP_LOGW(TAG, "HTTP open failed: %s", esp_err_to_name(last_err)); + } + // The server may have dropped the keep-alive connection while the display + // was busy processing a chunk; close so the next attempt reconnects. + esp_http_client_close(http_client); + vTaskDelay(pdMS_TO_TICKS(2)); // NOLINT + App.feed_wdt(); } - - ESP_LOGV(TAG, "Fetch length"); - const int chunk_size = esp_http_client_fetch_headers(http_client); - ESP_LOGV(TAG, "Length: %d", chunk_size); if (chunk_size <= 0) { - ESP_LOGE(TAG, "Get length failed: %d", chunk_size); + ESP_LOGE(TAG, "HTTP request failed, last status: %d, last error: %s", status_code, esp_err_to_name(last_err)); return -1; } @@ -164,6 +207,9 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r } else { range_start = range_end + 1; } + // The response body may be only partially read; close so the next + // range request starts on a clean connection. + esp_http_client_close(http_client); // Deallocate buffer allocator.deallocate(buffer, 4096); buffer = nullptr; From fae2f7a11dbb9129c1579ce1d937b35266b712f8 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 08:52:42 -0500 Subject: [PATCH 1168/1815] Bump bundled esphome-device-builder to 1.8.2 (#17995) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 4aa589fc5e..e58efd1ee0 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.8.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.8.2 RUN \ platformio settings set enable_telemetry No \ From 5821915aadd003759492023bf40d21a159647d3a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:37:44 -0400 Subject: [PATCH 1169/1815] [heatpumpir] Expose configured min/max as the visual temperature range (#17985) --- esphome/components/heatpumpir/climate.py | 21 ++++++++++----- tests/component_tests/heatpumpir/__init__.py | 0 tests/component_tests/heatpumpir/test_init.py | 26 +++++++++++++++++++ 3 files changed, 40 insertions(+), 7 deletions(-) create mode 100644 tests/component_tests/heatpumpir/__init__.py create mode 100644 tests/component_tests/heatpumpir/test_init.py diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index cd1b7d2bb0..21f7ea6393 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -8,6 +8,7 @@ from esphome.const import ( CONF_VISUAL, ) from esphome.core import CORE +from esphome.types import ConfigType CODEOWNERS = ["@rob-deutsch"] @@ -97,6 +98,18 @@ VERTICAL_DIRECTIONS = { "down": VerticalDirections.VERTICAL_DIRECTION_DOWN, } + +def _default_visual(config: ConfigType) -> ConfigType: + # Seed the visual min/max from the required min/max_temperature so the entity + # reports the configured range in Home Assistant instead of the ClimateIR + # 0-100 default. Done during validation so the effective range is visible in + # the dumped config and set before new_climate_ir() reads CONF_VISUAL. + visual = config.setdefault(CONF_VISUAL, {}) + visual.setdefault(CONF_MAX_TEMPERATURE, config[CONF_MAX_TEMPERATURE]) + visual.setdefault(CONF_MIN_TEMPERATURE, config[CONF_MIN_TEMPERATURE]) + return config + + CONFIG_SCHEMA = cv.All( climate_ir.climate_ir_with_receiver_schema(HeatpumpIRClimate).extend( { @@ -108,18 +121,12 @@ CONFIG_SCHEMA = cv.All( } ), cv.Any(cv.only_with_arduino, cv.only_on_esp32), + _default_visual, ) async def to_code(config): var = await climate_ir.new_climate_ir(config) - if CONF_VISUAL not in config: - config[CONF_VISUAL] = {} - visual = config[CONF_VISUAL] - if CONF_MAX_TEMPERATURE not in visual: - visual[CONF_MAX_TEMPERATURE] = config[CONF_MAX_TEMPERATURE] - if CONF_MIN_TEMPERATURE not in visual: - visual[CONF_MIN_TEMPERATURE] = config[CONF_MIN_TEMPERATURE] cg.add(var.set_protocol(config[CONF_PROTOCOL])) cg.add(var.set_horizontal_default(config[CONF_HORIZONTAL_DEFAULT])) cg.add(var.set_vertical_default(config[CONF_VERTICAL_DEFAULT])) diff --git a/tests/component_tests/heatpumpir/__init__.py b/tests/component_tests/heatpumpir/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/heatpumpir/test_init.py b/tests/component_tests/heatpumpir/test_init.py new file mode 100644 index 0000000000..85da0c8bf5 --- /dev/null +++ b/tests/component_tests/heatpumpir/test_init.py @@ -0,0 +1,26 @@ +"""Tests for the heatpumpir climate config validation.""" + +from esphome.components.heatpumpir.climate import _default_visual +from esphome.const import CONF_MAX_TEMPERATURE, CONF_MIN_TEMPERATURE, CONF_VISUAL +from esphome.types import ConfigType + + +def test_default_visual_seeds_from_required_min_max() -> None: + """Without a visual block, the required min/max_temperature seed the visual + range so the entity reports it in Home Assistant instead of 0-100 (#17983).""" + config: ConfigType = {CONF_MIN_TEMPERATURE: 18, CONF_MAX_TEMPERATURE: 30} + _default_visual(config) + assert config[CONF_VISUAL][CONF_MIN_TEMPERATURE] == 18 + assert config[CONF_VISUAL][CONF_MAX_TEMPERATURE] == 30 + + +def test_default_visual_keeps_explicit() -> None: + """An explicit visual min/max is not overwritten by the required temps.""" + config: ConfigType = { + CONF_MIN_TEMPERATURE: 16, + CONF_MAX_TEMPERATURE: 32, + CONF_VISUAL: {CONF_MIN_TEMPERATURE: 18, CONF_MAX_TEMPERATURE: 30}, + } + _default_visual(config) + assert config[CONF_VISUAL][CONF_MIN_TEMPERATURE] == 18 + assert config[CONF_VISUAL][CONF_MAX_TEMPERATURE] == 30 From d38a458de9eaebec31f6edcc9ad38467365a0c03 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sat, 1 Aug 2026 19:34:36 -0700 Subject: [PATCH 1170/1815] [modbus] Rework the client queue as a per-frame state machine (#17922) Co-authored-by: Claude Fable 5 Co-authored-by: J. Nick Koston --- esphome/components/modbus/modbus.cpp | 543 +++--- esphome/components/modbus/modbus.h | 339 ++-- .../components/modbus/modbus_definitions.h | 11 +- esphome/components/modbus/modbus_helpers.cpp | 20 +- .../modbus_controller/modbus_controller.h | 7 +- tests/components/modbus/heap_probe_test.cpp | 32 +- .../modbus/modbus_client_hub_test.cpp | 1489 ++++++++++++++--- 7 files changed, 1908 insertions(+), 533 deletions(-) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 2561ee9069..57371b9e79 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -45,26 +45,46 @@ void Modbus::loop() { } void ModbusClientHub::loop() { - // Call base class to receive bytes and parse frames - this->Modbus::loop(); + // Drain anything owed since the last loop (e.g. an external clear) before the watchdog runs, so it + // never times out an entry whose pending count has not been drained. No-op when nothing is owed. + this->sweep_(); - // If we're past the send_wait_time timeout and response buffer doesn't have the start of the expected response - if (this->waiting_for_response_.has_value()) { - ModbusDeviceCommand &wfr = this->waiting_for_response_.value(); - uint8_t expected_address = wfr.frame.address(); - if (this->last_receive_check_ - this->last_send_ > this->last_send_tx_offset_ + this->send_wait_time_ && - (this->rx_buffer_.empty() || this->rx_buffer_[0] != expected_address)) { - ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send", expected_address, - this->last_receive_check_ - this->last_send_); - this->notify_no_response_(wfr); - this->waiting_for_response_.reset(); - } + this->Modbus::loop(); // receive bytes and parse frames + + // Send-wait watchdog: only the cheap time check runs at loop rate; expire_waiting_() looks the + // entry up and holds off if the response has started arriving. + if (this->waiting_for_response_ && + this->last_receive_check_ - this->last_send_ > this->last_send_tx_offset_ + this->send_wait_time_) { + this->expire_waiting_(); } - // If there's no response pending and there's commands in the buffer + this->sweep_(); // deliver owed callbacks with the hub quiescent this->send_next_frame_(); } +void ModbusClientHub::expire_waiting_() { + ModbusDeviceCommand *cmd = this->find_waiting_(); + if (cmd == nullptr) { + this->waiting_for_response_ = false; + return; + } + if (!this->rx_buffer_.empty() && this->rx_buffer_[0] == cmd->frame.address()) { + // The start of the response is in the buffer: let the frame finish arriving. + return; + } + // Only a genuine WAITING entry warrants the log (a cleared or interrupted shell timing out is expected). + if (cmd->state == FrameState::WAITING) { + ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send", cmd->frame.address(), + this->last_receive_check_ - this->last_send_); + } + // Deliver on_no_response directly, the way the parse path delivers response()/error(): the entry + // lands in TIMED_OUT and the following sweep reschedules a retry or erases it. Free the + // wire first so a resend from inside the callback sees it available. + this->waiting_for_response_ = false; + this->sweep_needed_ = true; + cmd->timed_out(); +} + bool Modbus::timeout_() { // If the response frame is finished (including interframe delay) - we timeout. // The long_rx_buffer_delay accounts for long responses (larger than the UART rx_full_threshold) to avoid timeouts @@ -110,12 +130,21 @@ bool Modbus::tx_blocked() { bool ModbusClientHub::tx_blocked() { // We block transmission in any of these case: - // 1. We're waiting for a response + // 1. We're waiting for a response (a waiting entry: WAITING/INTERRUPTED/WAITING_RETIRED/INTERRUPTED_RETIRED) // 2. Any of the base class tx_blocked conditions - return (this->waiting_for_response_.has_value()) || this->Modbus::tx_blocked(); + return this->waiting_for_response_ || this->Modbus::tx_blocked(); } -bool ModbusClientHub::tx_buffer_empty() { return this->tx_buffer_.empty(); } +bool ModbusClientHub::tx_buffer_empty() { + // "Empty" for ready_for_immediate_send(): no one-shot is queued ahead of the caller. Entries in + // other states are mid-transaction or owed bookkeeping, not queued sends - and a READY continuous + // poll does not count either, since it ranks below every one-shot, so a new send goes out first. + for (const auto &cmd : this->tx_buffer_) { + if (cmd.state == FrameState::READY && !cmd.continuous) + return false; + } + return true; +} void Modbus::receive_bytes_() { this->last_receive_check_ = millis(); @@ -257,69 +286,57 @@ bool ModbusServerHub::parse_modbus_client_frame_() { return true; } -// Bounds contract, enforced by the parser (parse_modbus_server_frame_) rather than locally: -// - pdu is never empty: helpers::server_pdu_length() returns at least MIN_PDU_SIZE (1) on every -// branch, and find_custom_frame_end_() only ever lengthens the frame, so the PDU always holds -// at least the function code. -// - When the exception bit is set, pdu has at least 2 bytes: server_pdu_length() checks the -// exception bit before anything else and pins those PDUs to 2 bytes, so the exception code -// read below is always present. -// Keep those guarantees in mind when changing server_pdu_length() or adding callers. +// The parser (parse_modbus_server_frame_) guarantees the bounds relied on here: pdu is never empty, +// and an exception-flagged pdu is at least 2 bytes. Keep that in mind when changing server_pdu_length(). void ModbusClientHub::process_modbus_server_frame(uint8_t address, std::span pdu) { const uint8_t function_code = pdu[0]; - if (!this->waiting_for_response_.has_value()) { + ModbusDeviceCommand *cmd = this->waiting_for_response_ ? this->find_waiting_() : nullptr; + if (cmd == nullptr) { ESP_LOGW(TAG, "Received unexpected frame from address %" PRIu8 ", function code 0x%X, %" PRIu32 "ms after last send", address, function_code, this->last_modbus_byte_ - this->last_send_); return; - } else { // We are waiting for a response - // Check if the response matches the expected address and function code + } - ModbusDeviceCommand &wfr = this->waiting_for_response_.value(); - uint8_t expected_address = wfr.frame.address(); - uint8_t expected_function_code = wfr.frame.pdu()[0]; - if (expected_address != address || expected_function_code != (function_code & FUNCTION_CODE_MASK)) { - ESP_LOGW(TAG, - "Received incorrect frame address %" PRIu8 " <> %" PRIu8 " or function code 0x%X <> 0x%X, %" PRIu32 - "ms after last send", - address, expected_address, (function_code & FUNCTION_CODE_MASK), expected_function_code, - this->last_modbus_byte_ - this->last_send_); - // Invalidate the device; the entry survives as an interrupted shell so the late response is ignored. - // A retry requested here stays queued behind the shell until the send-wait timeout clears it. - this->notify_no_response_(wfr); - wfr.interrupted = true; - return; - } + // Check if the response matches the expected address and function code + const uint8_t expected_address = cmd->frame.address(); + const uint8_t expected_function_code = cmd->frame.pdu()[0]; + if (expected_address != address || expected_function_code != (function_code & FUNCTION_CODE_MASK)) { + ESP_LOGW(TAG, + "Received incorrect frame address %" PRIu8 " <> %" PRIu8 " or function code 0x%X <> 0x%X, %" PRIu32 + "ms after last send", + address, expected_address, (function_code & FUNCTION_CODE_MASK), expected_function_code, + this->last_modbus_byte_ - this->last_send_); + // Unexpected frame: flip a WAITING entry to an INTERRUPTED shell that ignores the rest of this + // transaction and blocks tx until the send-wait timeout, where it gets its on_no_response. + cmd->interrupt(); + return; + } - if (wfr.interrupted) { - ESP_LOGW(TAG, - "Ignoring response from %" PRIu8 " - transmission interrupted by previous unexpected response, %" PRIu32 - "ms after last send", - address, this->last_modbus_byte_ - this->last_send_); - return; - } else { // We have a valid device waiting for this response + if (cmd->state == FrameState::INTERRUPTED || cmd->state == FrameState::INTERRUPTED_RETIRED) { + // An interrupted shell keeps blocking until the send-wait timeout; a late response for it is + // ignored and does NOT free the wire. The distrust survives a clear (INTERRUPTED_RETIRED), so a + // cleared-interrupted frame still ends in on_no_response rather than delivering a late response. + ESP_LOGW(TAG, + "Ignoring response from %" PRIu8 " - transmission interrupted by previous unexpected response, %" PRIu32 + "ms after last send", + address, this->last_modbus_byte_ - this->last_send_); + return; + } - // Move the command out of the waiting slot so the request PDU stays alive for the callback. - ModbusDeviceCommand command = std::move(this->waiting_for_response_.value()); - this->waiting_for_response_.reset(); - ModbusClientDevice *device = command.device; - // The request PDU is the sent frame without the leading address and the trailing CRC. - std::span request_pdu = command.frame.pdu(); - // Is it an error response? - if (helpers::is_function_code_exception(function_code)) { - uint8_t exception = pdu[1]; // exception frames are fixed-length, so the code is always present - ESP_LOGW(TAG, - "Error function code: 0x%X exception: %" PRIu8 ", address: %" PRIu8 ", %" PRIu32 "ms after last send", - function_code, exception, address, this->last_modbus_byte_ - this->last_send_); - if (device) - device->on_error(request_pdu, static_cast(exception)); - } else if (device) { // Not an error response - device->on_response(request_pdu, pdu); - } else { // Not an error response, but no device to respond to - ESP_LOGV(TAG, "Ignoring response from %" PRIu8 " - no callback device set, %" PRIu32 "ms after last send", - address, this->last_modbus_byte_ - this->last_send_); - } - } + // Deliver at parse time so the response span can point into the rx buffer (zero copy). error()/ + // response() set the state and consume the request BEFORE the callback, so a clear from inside it + // ("stop polling now") wins. A device-less shell runs no callback and the sweep erases it. + this->waiting_for_response_ = false; + this->sweep_needed_ = true; + if (helpers::is_function_code_exception(function_code)) { + uint8_t exception = pdu[1]; // exception frames are fixed-length, so the code is always present + ESP_LOGW(TAG, "Error function code: 0x%X exception: %" PRIu8 ", address: %" PRIu8 ", %" PRIu32 "ms after last send", + function_code, exception, address, this->last_modbus_byte_ - this->last_send_); + cmd->error(static_cast(exception)); + } else if (!cmd->response(pdu)) { + ESP_LOGV(TAG, "Ignoring response from %" PRIu8 " - no callback device set, %" PRIu32 "ms after last send", address, + this->last_modbus_byte_ - this->last_send_); } } @@ -460,21 +477,20 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func } } +// Callers gate on tx_blocked() first, but the pre-send delay below can span several ms, so re-check +// after it and refuse (return false) if a byte arrived in that window rather than transmit over it. bool Modbus::send_frame_(const ModbusFrame &frame) { - if (this->tx_blocked()) { - ESP_LOGE(TAG, "Attempted to send while transmission blocked"); - return false; - } - if (frame.size() > MAX_FRAME_SIZE) { - ESP_LOGE(TAG, "Attempted to send frame larger than max frame size of %" PRIu16 " bytes", MAX_FRAME_SIZE); - return false; - } - const int32_t tx_delay_remaining = this->tx_delay_remaining(); if (tx_delay_remaining > 0) { delay(tx_delay_remaining); } + // The delay above can span several ms; a byte arriving in that window blocks transmission after the + // caller's gate already passed. Don't collide with the incoming frame - leave the entry to retry. + if (this->tx_blocked()) { + return false; + } + if (this->flow_control_pin_ != nullptr) { this->flow_control_pin_->digital_write(true); this->write_array(frame.data.data(), frame.size()); @@ -498,37 +514,21 @@ bool Modbus::send_frame_(const ModbusFrame &frame) { } void ModbusClientHub::send_next_frame_() { - if (this->tx_buffer_.empty()) { + if (this->tx_blocked()) + return; + + ModbusDeviceCommand *cmd = this->select_next_ready_(); + if (cmd == nullptr) + return; + + if (!this->send_frame_(cmd->frame)) { + ESP_LOGV(TAG, "Send deferred for %" PRIu8 ": a frame arrived during the send delay, will retry", + cmd->frame.address()); return; } - if (this->tx_blocked()) { - return; - } - - // Move the command out and pop BEFORE attempting the send: no callback may run while the frame still - // sits in the queue (the same principle as the clear sweep). A failure callback that sends would - // otherwise queue a new frame and pop_front() could discard the wrong one - and the deque - // reference / PDU span could be invalidated mid-callback. - ModbusDeviceCommand command = std::move(this->tx_buffer_.front()); - this->tx_buffer_.pop_front(); - ModbusClientDevice *device = command.device; - const bool sent = this->send_frame_(command.frame); - - if (sent) { - // The frame now lives in the waiting slot; its PDU is the frame without the leading address and - // trailing CRC. - ModbusDeviceCommand &wfr = this->waiting_for_response_.emplace(std::move(command)); - if (device != nullptr) - device->on_sent(wfr.frame.pdu()); - } else { - if (device != nullptr) - device->trigger_not_sent(command.frame.pdu()); - } - - if (!this->tx_buffer_.empty()) { - ESP_LOGV(TAG, "Write queue contains %zu items.", this->tx_buffer_.size()); - } + cmd->sent(); + this->waiting_for_response_ = true; } void ModbusClientHub::dump_config() { @@ -579,118 +579,258 @@ void ModbusServerHub::send_exception_(uint8_t address, uint8_t function_code, Ex this->send_raw_(raw_frame, 3); } -void ModbusClientHub::notify_no_response_(ModbusDeviceCommand &wfr) { - if (wfr.device == nullptr) - return; - const bool retry = wfr.device->on_no_response(wfr.frame.pdu()); - // The callback may have detached the device (e.g. clear_tx_queue_for_device()); honor the detach - // over the retry request rather than re-queueing a frame that can no longer be routed. - if (retry && wfr.device != nullptr) - this->requeue_waiting_frame_(wfr); - // The old transaction is over either way; never deliver anything else to the device through it. - wfr.device = nullptr; +ModbusDeviceCommand *ModbusClientHub::find_waiting_() { + for (auto &cmd : this->tx_buffer_) { + if (cmd.waiting_state()) + return &cmd; + } + return nullptr; } -void ModbusClientHub::requeue_waiting_frame_(ModbusDeviceCommand &wfr) { - const ModbusFrame &frame = wfr.frame; - if (this->tx_buffer_.size() >= MODBUS_TX_BUFFER_SIZE) { - ESP_LOGE(TAG, "Write buffer full, dropped retry for address %" PRIu8, frame.address()); - if (wfr.device != nullptr) - wfr.device->trigger_not_sent(frame.pdu()); +ModbusDeviceCommand *ModbusClientHub::select_next_ready_() { + // Class first (WRITE, then one-shot READ, then CONTINUOUS), oldest within a class. seq is a + // free-running counter, so compare each entry's AGE against it (correct across the full range). + const uint16_t now = this->next_seq_; + const auto age = [now](const ModbusDeviceCommand &cmd) -> uint16_t { return now - cmd.seq; }; + const auto older = [&age](const ModbusDeviceCommand &a, const ModbusDeviceCommand &b) { return age(a) > age(b); }; + ModbusDeviceCommand *best = nullptr; + for (auto &cmd : this->tx_buffer_) { + if (cmd.state != FrameState::READY) + continue; + if (best == nullptr || cmd.priority() > best->priority() || + (cmd.priority() == best->priority() && older(cmd, *best))) { + best = &cmd; + } + } + return best; +} + +bool ModbusDeviceCommand::sent() { + this->state = FrameState::WAITING; + // on_sent() is not a terminal, so nothing is consumed. + if (this->device == nullptr) + return false; + this->device->on_sent(this->frame.pdu()); + return true; +} + +bool ModbusDeviceCommand::notify_retired() { + if (!this->decrement_pending()) + return false; // nothing owed - stop the sweep draining this entry + if (this->device != nullptr) + this->device->on_not_sent(this->frame.pdu()); + return true; // consumed one debt (delivered, or silent when device-less) - keep draining to zero +} + +bool ModbusDeviceCommand::response(std::span response_pdu) { + this->state = this->state == FrameState::WAITING_RETIRED ? FrameState::RETIRED : FrameState::RECEIVED_RESPONSE; + // A continuous poll is never consumed by its own response; a one-shot consumes one request here. + if (!this->continuous) + this->decrement_pending(); + if (this->device == nullptr) + return false; + this->device->on_response(this->frame.pdu(), response_pdu); + return true; +} + +bool ModbusDeviceCommand::error(ExceptionCode exception_code) { + this->state = this->state == FrameState::WAITING_RETIRED ? FrameState::RETIRED : FrameState::RECEIVED_EXCEPTION; + // An exception ends a continuous poll too, so decrement unconditionally. + this->decrement_pending(); + if (this->device == nullptr) + return false; + this->device->on_error(this->frame.pdu(), exception_code); + return true; +} + +bool ModbusDeviceCommand::interrupt() { + // An unexpected frame distrusts the transaction. A cleared-but-still-waiting shell distrusts too, so + // the interrupt survives the clear in either order (WAITING_RETIRED -> INTERRUPTED_RETIRED). + if (this->state == FrameState::WAITING) { + this->state = FrameState::INTERRUPTED; + return true; + } + if (this->state == FrameState::WAITING_RETIRED) { + this->state = FrameState::INTERRUPTED_RETIRED; + return true; + } + return false; +} + +bool ModbusDeviceCommand::timed_out() { + this->state = FrameState::TIMED_OUT; // advance BEFORE the callback so a clear from inside it wins + this->decrement_pending(); // resolve this request (WAITING-origin, so pending >= 1) + if (this->device == nullptr) + return false; // resolved, no one to tell + if (this->device->on_no_response(this->frame.pdu())) + this->increment_pending(); // granted retry = re-request (capped) + return true; +} + +void ModbusClientHub::sweep_() { + if (!this->sweep_needed_) return; + this->sweep_needed_ = false; + // Serve only the entries present now: a callback may append (a re-send), but those sit beyond + // work_set and are left for the next sweep, which bounds the work and is the termination argument. + // Entries leave the container only in the erase pass below, so indices/references stay valid. + const size_t work_set = this->tx_buffer_.size(); + // Restart the walk after every callback: a handler may have moved any entry to any state. + bool callback_ran = true; + while (callback_ran) { + callback_ran = false; + for (size_t i = 0; i != work_set && !callback_ran; i++) { + ModbusDeviceCommand &cmd = this->tx_buffer_[i]; + switch (cmd.state) { + case FrameState::RECEIVED_RESPONSE: + case FrameState::RECEIVED_EXCEPTION: + case FrameState::TIMED_OUT: + // Off the wire, callback already delivered: reschedule what is still pending, else erase. + if (cmd.pending) + cmd.requeue(this->next_seq_++); + break; + case FrameState::RETIRED: + // Owes one on_not_sent() per accepted request; notify_retired() consumes one and reports + // whether a debt remained, so the restart loop drains the entry to zero - even a device-less + // shell with pending > 1 (no callback fires, but it still drains rather than stranding). + callback_ran = cmd.notify_retired(); + break; + case FrameState::WAITING_RETIRED: + case FrameState::INTERRUPTED_RETIRED: + // Cleared shell: drain only the un-run duplicates; the request in flight keeps pending 1 + // and gets its usual callback when it resolves. + if (cmd.pending > 1) + callback_ran = cmd.notify_retired(); + break; + default: // READY / WAITING / INTERRUPTED: idle or waiting for a response, nothing owed until the timeout + break; + } + } + } + // Erase pass: the only place entries leave the container. Storage order carries no meaning, so a + // finished entry is swap-and-popped; walking backwards means a moved-down entry is already seen. + for (size_t i = this->tx_buffer_.size(); i-- > 0;) { + const ModbusDeviceCommand &cmd = this->tx_buffer_[i]; + // pending == 0 is erasable, but shells still waiting for a response are exempt until it resolves. + if (cmd.pending != 0 || cmd.waiting_state()) + continue; + if (i + 1 != this->tx_buffer_.size()) + this->tx_buffer_[i] = std::move(this->tx_buffer_.back()); + this->tx_buffer_.pop_back(); } - // Re-queue a copy (not a move): the waiting entry may have to survive as an interrupted shell. - this->tx_buffer_.emplace_back(wfr.device, frame.address(), frame.pdu()); } // Raw send for client: pushes to tx queue. Everything except the CRC must be contained in payload. -void ModbusClientHub::send_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device) { +bool ModbusClientHub::send_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device, + CommandOptions options) { + // Requests refused here never enter the machine and get no callback - the false return is it. if (pdu.empty()) { - if (device != nullptr) - device->trigger_not_sent(pdu); - return; + ESP_LOGW(TAG, "Empty PDU refused for address %" PRIu8, address); + return false; } - // Bound the PDU so the wire frame (address + pdu + CRC) stays within the Modbus RTU 256-byte limit. if (pdu.size() > MAX_PDU_SIZE) { - ESP_LOGE(TAG, "Frame too large, dropped: %" PRIu8 ":%zu bytes", address, pdu.size()); - if (device != nullptr) - device->trigger_not_sent(pdu); - return; + ESP_LOGE(TAG, "Frame too large, refused: %" PRIu8 ":%zu bytes", address, pdu.size()); + return false; } - if (this->tx_buffer_.size() < MODBUS_TX_BUFFER_SIZE) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "Adding frame to tx queue: %" PRIu8 ":%s", address, - format_hex_pretty_to(hex_buf, pdu.data(), pdu.size())); - this->tx_buffer_.emplace_back(device, address, pdu); - } else { + // continuous is ignored for every mutating code (re-writing a value forever is never intended). + const bool mutates = ModbusDeviceCommand::classify(pdu[0]) == CommandPriority::WRITE; + bool continuous = false; + if (options.continuous) { + if (mutates) { + ESP_LOGV(TAG, "continuous is ignored for a mutating function (0x%X, address %" PRIu8 ")", pdu[0], address); + } else { + continuous = true; + } + } + + // A duplicate of a live entry with the same owner is not queued twice; it resolves against that + // entry: anonymous -> dropped; continuous incoming -> convert the entry to a poll; one-shot onto a + // poll -> downgrade the poll to one-shot; both one-shots -> pending++ below the cap, else refused. + for (auto &item : this->tx_buffer_) { + if (item.state == FrameState::RETIRED || item.state == FrameState::WAITING_RETIRED || + item.state == FrameState::INTERRUPTED_RETIRED) + continue; // cleared, on their way out: a new identical send queues fresh, never absorbs + if (item.device != device || !item.same_frame(address, pdu)) + continue; + if (device == nullptr) { + // A dropped read is routine (DEBUG); a dropped write/custom warns (unobservable without a device). + const bool requeueable = !helpers::is_function_code_exception(pdu[0]) && helpers::is_function_code_read(pdu[0]); + if (requeueable) { + ESP_LOGD(TAG, "Anonymous duplicate of active frame for %" PRIu8 " (function 0x%X), dropped", address, pdu[0]); + } else { + ESP_LOGW(TAG, + "Anonymous duplicate of active frame for %" PRIu8 " (function 0x%X), dropped - register a " + "device for delivery accounting", + address, pdu[0]); + } + return false; // dropped: no entry, no callbacks - the refusal is the return value + } + if (continuous) { + item.make_continuous(true); + ESP_LOGV(TAG, "Frame already active for %" PRIu8 ", now polled continuously", address); + } else if (item.continuous) { + // A one-shot duplicate downgrades the poll to a one-shot: it runs one more cycle to serve this + // request, then stops (mirrors continuous incoming converting a one-shot the other way). + item.make_continuous(false); + ESP_LOGV(TAG, "Frame already active for %" PRIu8 ", downgraded from continuous to one-shot", address); + } else if (!item.increment_pending()) { + // At the servable cap, so refused. (An absorbed duplicate leaves seq alone - the entry keeps + // its place in line, held by its oldest outstanding request.) + ESP_LOGD(TAG, "Frame already active for %" PRIu8 " with %" PRIu8 " requests pending, refused", address, + item.pending); + return false; + } else { + ESP_LOGV(TAG, "Frame already active for %" PRIu8 ", request absorbed (pending %" PRIu8 ")", address, + item.pending); + } + return true; + } + + // Backstop counts every entry; dead ones are gone by the sweep's end, so at worst they cost one + // refusal at the very cap for one loop. + if (this->tx_buffer_.size() >= MODBUS_TX_BUFFER_SIZE) { #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_ERROR char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; #endif - ESP_LOGE(TAG, "Write buffer full, dropped: %" PRIu8 ":%s", address, + ESP_LOGE(TAG, "Write buffer full, refused: %" PRIu8 ":%s", address, format_hex_pretty_to(hex_buf, pdu.data(), pdu.size())); - if (device != nullptr) - device->trigger_not_sent(pdu); + return false; } +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; +#endif + ESP_LOGV(TAG, "Adding frame to tx queue: %" PRIu8 ":%s", address, + format_hex_pretty_to(hex_buf, pdu.data(), pdu.size())); + this->tx_buffer_.emplace_back(device, address, pdu, continuous, this->next_seq_++); + return true; } -void ModbusClientHub::clear_tx_queue_for_address(uint8_t address, bool clear_sent) { - // Drop the queued frames for this address, delivering on_not_sent() to each frame's owner: other - // devices talking to the same physical device (e.g. a modbus_client action alongside a controller that - // just went offline) must observe the drop, or their command never resolves. Mark first, then sweep - // only marked frames: anything a callback re-queues is unmarked, so it - // is never swept - or re-notified - by the clear that triggered it. Each marked frame is moved out and erased BEFORE - // its callback runs, so handlers see a consistent queue; termination is guaranteed because only the initially-marked - // frames are ever swept. +void ModbusClientHub::clear_tx_queue_for_address(uint8_t address) { + // A clear is a pure state flip; the sweep delivers every owed on_not_sent() from a quiescent hub. for (auto &cmd : this->tx_buffer_) { - if (cmd.frame.address() == address) - cmd.marked_for_deletion = true; - } - for (;;) { - auto it = std::find_if(this->tx_buffer_.begin(), this->tx_buffer_.end(), - [](const ModbusDeviceCommand &cmd) { return cmd.marked_for_deletion; }); - if (it == this->tx_buffer_.end()) - break; - ModbusDeviceCommand dropped = std::move(*it); - this->tx_buffer_.erase(it); - // The sweep delivers through the same per-device guard as refusals: a device clearing from inside - // its own on_not_sent() gets its remaining frames resolved silently (documented in the lifecycle - // contract), other owners are notified normally, and every nested clear stays bounded. - if (dropped.device != nullptr) - dropped.device->trigger_not_sent(dropped.frame.pdu()); - } - - if (clear_sent && this->waiting_for_response_.has_value() && this->waiting_for_response_.value().device) { - if (this->waiting_for_response_.value().frame.address() == address) { - ESP_LOGV(TAG, "Clearing waiting for response for address %" PRIu8, address); - // Invalidate the waiting device so it won't process a response. - this->waiting_for_response_.value().device = nullptr; - } + if (cmd.frame.address() != address) + continue; + cmd.retire(); + this->sweep_needed_ = true; } } -void ModbusClientHub::clear_tx_queue_for_device(ModbusClientDevice *device) { - // Remove any pending commands for this address from the tx buffer - auto &tx_buffer = this->tx_buffer_; - tx_buffer.erase(std::remove_if(tx_buffer.begin(), tx_buffer.end(), - [device](const ModbusDeviceCommand &cmd) { return cmd.device == device; }), - tx_buffer.end()); - if (this->waiting_for_response_.has_value() && this->waiting_for_response_.value().device) { - if (this->waiting_for_response_.value().device == device) { - ESP_LOGV(TAG, "Clearing waiting for response"); - // Invalidate the waiting device so it won't process a response. - this->waiting_for_response_.value().device = nullptr; - } +void ModbusClientHub::clear_tx_queue_for_device(ModbusClientDevice *device) { + // Silent teardown (supersede semantics): the caller's own frames vanish without callbacks; see + // the lifecycle note on ModbusClientDevice. + for (auto &cmd : this->tx_buffer_) { + if (cmd.device != device) + continue; + cmd.silent_retire(); + this->sweep_needed_ = true; } } void ModbusClientHub::send_raw(const std::vector &payload, ModbusClientDevice *device) { if (payload.size() < 2) { - if (device != nullptr) - device->trigger_not_sent({}); // too short to contain a PDU + ESP_LOGW(TAG, "send_raw() payload too short to contain a PDU, refused"); return; } this->send_pdu(payload[0], std::span(payload).subspan(1), device); @@ -706,23 +846,26 @@ void ModbusServerHub::send_raw_(const uint8_t *payload, uint16_t len) { return; } - // In the rare case that the server is blocked (frame delay has not elapsed), we delay the send. - // This should only happen at low baud rates with long frame delays. + // If blocked now (frame delay not elapsed at low baud, or a frame arriving), defer rather than + // busy-waiting the loop; send_frame_ itself re-checks after its delay, so the deferred callback + // just reports whatever it returns. if (this->tx_blocked()) { // Stash the raw payload in a single member buffer so the deferred callback can rebuild the frame - // without a heap allocation. Only one server reply is ever in flight, and the named timeout ensures - // only one deferred send is pending, so a single buffer is sufficient. + // without a heap allocation. Only one server reply is ever waiting, so a single buffer suffices. std::memcpy(this->deferred_payload_.data(), payload, len); this->deferred_payload_len_ = len; this->set_timeout("deferred_send", this->tx_delay_remaining(), [this]() { ModbusFrame frame(this->deferred_payload_[0], this->deferred_payload_.data() + 1, this->deferred_payload_len_ - 1); - this->send_frame_(frame); + if (!this->send_frame_(frame)) + ESP_LOGE(TAG, "Deferred server reply dropped: transmission still blocked"); }); - } else { - ModbusFrame frame(payload[0], payload + 1, len - 1); - this->send_frame_(frame); + return; } + + ModbusFrame frame(payload[0], payload + 1, len - 1); + if (!this->send_frame_(frame)) + ESP_LOGE(TAG, "Server reply dropped: a frame arrived during the send delay"); } void Modbus::clear_rx_buffer_(const LogString *reason, bool warn, size_t bytes_to_clear) { @@ -778,7 +921,7 @@ void ModbusClientDevice::dispatch_response_(std::span request_pdu const bool bits = function_code == FunctionCode::READ_COILS || function_code == FunctionCode::READ_DISCRETE_INPUTS; const size_t expected_data_size = - bits ? (static_cast(count_or_value) + 7) / 8 : static_cast(count_or_value) * 2; + bits ? packed_bit_bytes(count_or_value) : static_cast(count_or_value) * 2; if (response_pdu.size() != expected_data_size + 2) { ESP_LOGD(TAG, "Response length %zu does not match request (expected %zu) for function code 0x%X", response_pdu.size(), expected_data_size + 2, static_cast(function_code)); diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 2204c0f82b..c49f52df55 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -16,7 +16,13 @@ namespace esphome::modbus { -static constexpr uint16_t MODBUS_TX_BUFFER_SIZE = 15; +// Tx queue backstop. Duplicate frames dedup into one entry, so reads can never approach this in a +// sane config - it exists to stop a runaway generator of distinct frames (e.g. a loop writing a +// changing value) from growing the heap unboundedly. The deque grows on demand; this reserves nothing. +// Worst case the cap permits: 128 distinct max-size frames = ~32 kB of spilled frame data plus +// ~3 kB of deque node storage (typical 8-byte frames stay inline; large PDUs spill to one +// allocation each) - pathological configs only, but the numbers matter when tuning for ESP8266. +static constexpr uint16_t MODBUS_TX_BUFFER_SIZE = 128; static constexpr uint16_t MODBUS_TX_MAX_DELAY_MS = 5; // Typical frames -- reads and single-register/coil writes -- are exactly 8 bytes @@ -70,6 +76,9 @@ class Modbus : public uart::UARTDevice, public Component { // pdu is the whole PDU (function code + payload, no address/CRC); pdu[0] is the (standard or custom) function code. virtual void process_modbus_server_frame(uint8_t address, std::span pdu) = 0; void clear_rx_buffer_(const LogString *reason, bool warn = false, size_t bytes_to_clear = 0); + // Transmit a frame. Callers gate on tx_blocked() first, but the pre-send delay can span several ms, + // so this re-checks after the delay and returns false without transmitting if a byte arrived in that + // window (the caller then leaves its entry to retry). Returns true once the frame has been transmitted. bool send_frame_(const ModbusFrame &frame); // Scans forward from min_length to find a frame boundary by CRC match for custom function codes. // Returns the matched frame length, or 0 if no valid CRC was found within MAX_FRAME_SIZE. @@ -90,20 +99,158 @@ class Modbus : public uart::UARTDevice, public Component { class ModbusClientDevice; class ModbusServerDevice; +// Transmit ordering, highest first: writes before one-shot reads before continuous polls. Derived +// at selection time, never caller-chosen or stored. +enum class CommandPriority : uint8_t { CONTINUOUS = 0, READ, WRITE }; + +// Per-entry lifecycle state. Waiting states (see waiting_state()) hold the bus; the sweep delivers owed +// callbacks from a quiescent hub, and an entry is erased once pending == 0 && !waiting_state(). +enum class FrameState : uint8_t { + READY = 0, + WAITING, + RECEIVED_RESPONSE, + RECEIVED_EXCEPTION, + TIMED_OUT, // on_no_response delivered at the send-wait timeout; awaiting reschedule/erase + INTERRUPTED, // unexpected frame arrived; ignores this transaction, waits out the timeout + WAITING_RETIRED, // cleared while WAITING: a late response is still delivered as its usual terminal + INTERRUPTED_RETIRED, // cleared while INTERRUPTED: still distrusts late frames, ends in on_no_response + RETIRED, // cleared, off the wire +}; + +// Per-command send options. Append-only; pass via designated initializers ({.continuous = true}). +struct CommandOptions { + // A continuous poll lives in the queue until cancelled or failed; ignored for mutating codes. + bool continuous{false}; +}; + struct ModbusDeviceCommand { ModbusClientDevice *device; ModbusFrame frame; - bool interrupted{false}; - /// Marked by clear_tx_queue_for_address() before it starts notifying, so frames re-queued by an - /// on_not_sent() callback (which are unmarked) are never swept by the clear that triggered them. - bool marked_for_deletion{false}; + FrameState state{FrameState::READY}; + // A continuous poll is a subscription: pending fixed at 1, removed only by cancellation or failure. + bool continuous{false}; + // Accepted requests this entry stands for, capped at max_pending(); drains one terminal each. + uint8_t pending{1}; + // Place-in-line stamp (hub's free-running counter); selection takes the oldest for round-robin + // fairness within a class. Meant to wrap. + uint16_t seq{0}; - ModbusDeviceCommand(ModbusClientDevice *device, uint8_t address, const uint8_t *src, uint16_t len) - : device(device), frame(address, src, len) {} - /// Build a command from a PDU span: a caller-supplied PDU, or an existing frame's own pdu() when re-queueing - /// Callers must bound the PDU to MAX_PDU_SIZE - ModbusDeviceCommand(ModbusClientDevice *device, uint8_t address, std::span pdu) - : device(device), frame(address, pdu.data(), static_cast(pdu.size())) {} + // Build a command from a PDU span (caller bounds it to MAX_PDU_SIZE); fully initialized here. + ModbusDeviceCommand(ModbusClientDevice *device, uint8_t address, std::span pdu, + bool continuous = false, uint16_t seq = 0) + : device(device), + frame(address, pdu.data(), static_cast(pdu.size())), + continuous(continuous), + seq(seq) {} + + // Transmit ordering class, derived (never stored): a continuous poll ranks below every one-shot. + CommandPriority priority() const { + return this->continuous ? CommandPriority::CONTINUOUS : classify(this->frame.pdu()[0]); + } + // Wire-derived class: mutating codes rank WRITE; exception-flagged codes are excluded. + static CommandPriority classify(uint8_t function_code) { + if (helpers::is_function_code_exception(function_code)) + return CommandPriority::READ; + const auto code = static_cast(function_code); + if (helpers::is_function_code_write(function_code) || code == FunctionCode::MASK_WRITE_REGISTER || + code == FunctionCode::READ_WRITE_MULTIPLE_REGISTERS) { + return CommandPriority::WRITE; + } + return CommandPriority::READ; + } + + // Requests this entry can serve: a standard read twice (run plus one re-run), everything else once. + uint8_t max_pending() const { + const uint8_t fc = this->frame.pdu()[0]; + const bool requeueable = !helpers::is_function_code_exception(fc) && helpers::is_function_code_read(fc); + return (requeueable && !this->continuous) ? 2 : 1; + } + // Device-scoped clear: detach with no callback (device-less, pending 0). An entry still waiting for + // a response keeps its state as a reply-ignoring shell that resolves silently; any other goes RETIRED. + void silent_retire() { + if (!this->waiting_state()) + this->state = FrameState::RETIRED; + this->pending = 0; + this->device = nullptr; + } + // Re-ready for another transmission, restamped to the tail of its class (hub passes next_seq_++). + void requeue(uint16_t seq) { + this->state = FrameState::READY; + this->seq = seq; + } + // Re-task a frame that lives on: upgrade a one-shot to a continuous poll, or downgrade a poll back to + // a one-shot. Either way the entry keeps running and owes a request, so this is not a plain setter - + // to tear an entry down instead, use retire()/silent_retire(), which leave pending as the count owed. + // On: the entry becomes a continuous poll, superseding any absorbed requests (pending resets to the + // single subscription). Off: a one-shot duplicate has cancelled the poll, but the entry must still run + // once to serve that request - so restore one first. While the flag is still set max_pending() is 1, + // so the restore lifts a terminated poll (pending 0, after an error/timeout) back to 1 and is a no-op + // on a live poll already at 1; the flag drops afterwards, when a read's cap can widen to 2 without + // retroactively inflating that no-op. + void make_continuous(bool continuous) { + if (continuous) { + this->continuous = true; + this->pending = 1; + } else { + this->increment_pending(); + this->continuous = false; + } + } + // Address-scoped clear: keep pending and device so the sweep delivers one on_not_sent() per un-run + // request. An entry still waiting for a response keeps its in-flight request (whose usual terminal is + // still coming) and drains only its duplicates: WAITING -> WAITING_RETIRED, and INTERRUPTED -> + // INTERRUPTED_RETIRED which keeps distrusting late frames (they were already interrupted). Any other + // state -> RETIRED, draining everything. A cleared frame that then times out still honors a retry: + // the clear is address-scoped (any device may call it) while the retry is the owning device's call + // via on_no_response - the bus obeys the owner. + void retire() { + if (this->state == FrameState::WAITING) { + this->state = FrameState::WAITING_RETIRED; + } else if (this->state == FrameState::INTERRUPTED) { + this->state = FrameState::INTERRUPTED_RETIRED; + } else if (!this->waiting_state()) { // an already-retired shell stays put; off the wire -> RETIRED + this->state = FrameState::RETIRED; + } + this->continuous = false; + } + + // True while the entry is still waiting for a response; the erase pass exempts these even at pending 0. + bool waiting_state() const { + return this->state == FrameState::WAITING || this->state == FrameState::INTERRUPTED || + this->state == FrameState::WAITING_RETIRED || this->state == FrameState::INTERRUPTED_RETIRED; + } + + bool decrement_pending() { + if (this->pending > 0) { + this->pending--; + return true; + } + return false; + } + // Add one request, honouring the cap; false = already at cap (absorb a duplicate, restore a retry). + bool increment_pending() { + if (this->pending < this->max_pending()) { + this->pending++; + return true; + } + return false; + } + + // Terminal/lifecycle methods: each owns its transition, callback, and pending accounting and + // returns whether a callback ran. Out-of-line: ModbusClientDevice is incomplete here. + bool sent(); + bool response(std::span response_pdu); + bool error(ExceptionCode exception_code); + bool interrupt(); + bool timed_out(); + bool notify_retired(); + + /// True if this command carries the same wire frame (address + PDU) as the given one. + bool same_frame(uint8_t address, std::span pdu) const { + const auto own_pdu = this->frame.pdu(); + return own_pdu.size() == pdu.size() && this->frame.address() == address && + memcmp(own_pdu.data(), pdu.data(), pdu.size()) == 0; + } }; class ModbusClientHub : public Modbus { @@ -123,14 +270,15 @@ class ModbusClientHub : public Modbus { payload_len), device); }; - void send_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device = nullptr); + // Queue a request; true once it is a live entry (resolving in one terminal), false if it never + // entered the machine (empty/oversize PDU, full queue, anonymous or over-cap duplicate) - no callback. + bool send_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device = nullptr, + CommandOptions options = {}); ESPDEPRECATED("Use send_pdu(payload[0], , device) instead. Removed in 2027.2.0", "2026.8.0") void send_raw(const std::vector &payload, ModbusClientDevice *device = nullptr); - // Drop the queued commands for an address; every dropped frame resolves via its owner's on_not_sent(), - // so other devices sharing the address observe the drop. The in-flight frame is only detached (silently) - // when clear_sent is set. clear_tx_queue_for_device() SILENTLY discards the caller's own frames - // (supersede/teardown semantics); see the lifecycle note on ModbusClientDevice. - void clear_tx_queue_for_address(uint8_t address, bool clear_sent = true); + // Clear an address's commands; each un-run request resolves via on_not_sent(), but a frame on the + // wire still runs to its usual terminal. clear_tx_queue_for_device() instead discards silently. + void clear_tx_queue_for_address(uint8_t address); void clear_tx_queue_for_device(ModbusClientDevice *device); protected: @@ -138,18 +286,29 @@ class ModbusClientHub : public Modbus { void parse_modbus_frames() override; void process_modbus_server_frame(uint8_t address, std::span pdu) override; void send_next_frame_(); - // Notify the waiting device of no response; re-queues the frame if on_no_response() returns true. - // wfr is the caller's checked reference to waiting_for_response_. - void notify_no_response_(ModbusDeviceCommand &wfr); - void requeue_waiting_frame_(ModbusDeviceCommand &wfr); + // Deliver owed callbacks from a quiescent hub and apply lifecycle bookkeeping; see FrameState. + void sweep_(); + // The selection function: best READY entry (WRITE class first, then one-shot reads, then the + // least-recently-served continuous; FIFO by seq within each group), or nullptr. + ModbusDeviceCommand *select_next_ready_(); + // Locate the single entry waiting for a response (WAITING/INTERRUPTED/WAITING_RETIRED/INTERRUPTED_RETIRED). + ModbusDeviceCommand *find_waiting_(); + // End the wait for a response on send-wait timeout (the loop() watchdog body); see FrameState. + void expire_waiting_(); uint16_t send_wait_time_{2000}; uint16_t turnaround_delay_ms_{0}; - std::optional waiting_for_response_; - // std::deque is appropriate here since we need a FIFO buffer, and we can't know ahead of time how many - // requests will be queued. Each modbus component may queue multiple requests, and the sequence of scheduling - // may change at run time. + // Set on transmit, cleared on the transaction-ending transition; send_next_frame_ won't select + // while it is set, so at most one frame is awaiting a response. + bool waiting_for_response_{false}; + + // Set whenever a transition leaves owed callbacks behind; quiet loop() passes skip the sweep. + bool sweep_needed_{false}; + // Monotonic stamp source for ModbusDeviceCommand::seq. + uint16_t next_seq_{0}; + + // Plain append-order container; ordering lives in select_next_ready_(), lifecycle in FrameState. std::deque tx_buffer_; }; @@ -176,7 +335,7 @@ class ModbusServerHub : public Modbus { std::vector devices_; // Holds the raw payload of a single reply deferred for sending when tx was blocked at send time. - // Only one server reply can be in flight at once, so a single fixed buffer avoids heap allocation. + // Only one server reply can be waiting at once, so a single fixed buffer avoids heap allocation. std::array deferred_payload_; uint16_t deferred_payload_len_{0}; }; @@ -184,25 +343,24 @@ class ModbusServerHub : public Modbus { // Transaction status: std::nullopt on success, otherwise a Modbus exception code using ResponseStatus = std::optional; -/// Command lifecycle: each accepted command (a send_pdu()/typed-helper call, or a hub re-queue from -/// a retry) ends in exactly ONE terminal callback: on_response() (valid response), on_error() -/// (exception response), on_no_response() (timeout or interrupted transaction), or on_not_sent() -/// (never transmitted: send failure or full queue). on_sent() is additional, not -/// terminal: it fires once per wire transmission, before whichever of data/error/no_response follows, -/// and never for a command that ends in on_not_sent(). -/// The exceptions to "exactly one terminal": -/// - clear_tx_queue_for_device() drops the caller's OWN queued commands SILENTLY (supersede/teardown -/// semantics), and both clear variants detach the in-flight frame silently. -/// clear_tx_queue_for_address() DOES resolve every queued frame it drops via the owner's -/// on_not_sent() (delivered one at a time, after that frame leaves the queue). -/// - while a device's own on_not_sent() is on the stack, further on_not_sent() deliveries to THAT -/// device are dropped (see trigger_not_sent()). In particular, a clear issued from inside your own -/// on_not_sent() resolves your remaining frames silently - treat it like -/// clear_tx_queue_for_device(): you cleared them, you know. Other owners are still notified. -/// Sending from inside on_not_sent() is hazardous: the notification may itself mean the queue is full -/// or refusing, and this device's retry that is refused again is dropped WITHOUT a callback (the -/// guard above, which bounds what would otherwise be unbounded re-entry) - prefer re-sending from a -/// later trigger or the component's update()/loop(). +/// Callback contract. Each accepted request ends in exactly ONE terminal: on_response() (data), +/// on_error() (exception), on_no_response() (timeout/interruption), or on_not_sent() (dropped by +/// clear_tx_queue_for_address before transmission). A request refused at send_pdu() (false return) +/// gets none. on_sent() is additional, once per transmission, never for an on_not_sent() request. +/// on_response()/on_error() fire at parse time and on_no_response() at the send-wait watchdog, all +/// from a quiescent hub; only on_not_sent() is delivered by the sweep. Sending or clearing from +/// inside a callback is safe (picked up by the next sweep). Exceptions to "exactly one terminal": +/// clear_tx_queue_for_device() drops the caller's own frames silently; a continuous poll's cycles are +/// its own accounting (a one-shot duplicate downgrades the poll to a one-shot; a continuous duplicate +/// merges into it). +/// +/// Invariants: +/// - Public entry points (send_pdu/clear_tx_queue_*) only append to the queue or mutate an existing +/// entry through its callback-free transition methods. +/// - Public entry points can never trigger a callback synchronously. +/// - Callbacks are delivered only from within loop(). +/// - At most one callback is ever issued between calls to sweep_(): +/// sweep_ -> parse (response OR error) OR timeout (no_response) -> sweep_ -> send (sent) -> sweep_ (next loop) class ModbusClientDevice { public: ModbusClientDevice() = default; @@ -230,29 +388,14 @@ class ModbusClientDevice { virtual void on_error(std::span request_pdu, ExceptionCode exception_code) { this->dispatch_response_(request_pdu, {}, exception_code); } - /// Called when no request could be sent (e.g. queue full, transmission blocked). - /// Do not attempt to queue a command in this callback. - /// (The on_modbus_* names below are deprecated pre-rename spellings; the defaults forward so - /// external devices overriding them keep working through the deprecation window.) + /// Called when an accepted request was dropped before transmission by clear_tx_queue_for_address(). + /// (on_modbus_* below are deprecated pre-rename spellings; the defaults forward during deprecation.) virtual void on_not_sent(std::span request_pdu) { #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wdeprecated-declarations" this->on_modbus_not_sent(); #pragma GCC diagnostic pop } - /// Non-virtual entry point the hub uses for EVERY on_not_sent() delivery (refusals and clear-queue - /// sweeps alike). While this device's on_not_sent() is on the stack, further deliveries to it are - /// dropped: this bounds every send->refuse and clear->sweep recursion, including cycles through - /// multiple devices (each device can appear on the stack at most once). The documented cost: a clear - /// issued from inside your own on_not_sent() resolves your remaining frames SILENTLY, while other - /// owners are still notified (their guards are not set) - see the lifecycle contract above. - void trigger_not_sent(std::span request_pdu) { - if (this->notifying_not_sent_) - return; - this->notifying_not_sent_ = true; - this->on_not_sent(request_pdu); - this->notifying_not_sent_ = false; - } /// Called when this device's frame is actually written to the wire virtual void on_sent(std::span request_pdu) {} /// Called when no matching, uninterrupted response arrived; return true to have the hub re-queue the frame for a @@ -322,56 +465,60 @@ class ModbusClientDevice { helpers::create_client_pdu((FunctionCode) function, start_address, number_of_entities, payload, payload_len), this); } - void send_pdu(std::span pdu) { this->parent_->send_pdu(this->address_, pdu, this); } + /// See ModbusClientHub::send_pdu(): true = accepted (a terminal callback will follow), + /// false = refused at the door (no callback). + bool send_pdu(std::span pdu, CommandOptions options = {}) { + return this->parent_->send_pdu(this->address_, pdu, this, options); + } ESPDEPRECATED("Use send_pdu() instead (the device address is prepended for you). Removed in 2027.2.0", "2026.8.0") - void send_raw(const std::vector &payload) { - if (payload.empty()) { - // Through the guard like every other delivery, so a handler calling send_raw({}) cannot recurse. - this->trigger_not_sent({}); - return; - } - this->parent_->send_pdu(payload[0], std::span(payload).subspan(1), this); + bool send_raw(const std::vector &payload) { + if (payload.empty()) + return false; // too short to contain a PDU; refused at the door like any invalid send + return this->parent_->send_pdu(payload[0], std::span(payload).subspan(1), this); } // Reads via the table-appropriate function code; an unreadable entity type maps to INVALID, which - // create_read_pdu() rejects into an empty PDU and send_pdu() signals via on_not_sent(). - void read_entities(EntityType entity_type, uint16_t start_address, uint16_t number_of_entities) { - this->send_pdu(helpers::create_read_pdu(helpers::modbus_register_read_function(entity_type), start_address, - number_of_entities)); + // create_read_pdu() rejects into an empty PDU and send_pdu() refuses with a false return. + bool read_entities(EntityType entity_type, uint16_t start_address, uint16_t number_of_entities, + CommandOptions options = {}) { + return this->send_pdu(helpers::create_read_pdu(helpers::modbus_register_read_function(entity_type), start_address, + number_of_entities), + options); } - void read_input_registers(uint16_t start_address, uint16_t number_of_registers) { - this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_INPUT_REGISTERS, start_address, number_of_registers)); + bool read_input_registers(uint16_t start_address, uint16_t number_of_registers, CommandOptions options = {}) { + return this->send_pdu( + helpers::create_read_pdu(FunctionCode::READ_INPUT_REGISTERS, start_address, number_of_registers), options); } - void read_holding_registers(uint16_t start_address, uint16_t number_of_registers) { - this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_HOLDING_REGISTERS, start_address, number_of_registers)); + bool read_holding_registers(uint16_t start_address, uint16_t number_of_registers, CommandOptions options = {}) { + return this->send_pdu( + helpers::create_read_pdu(FunctionCode::READ_HOLDING_REGISTERS, start_address, number_of_registers), options); } - void read_coils(uint16_t start_address, uint16_t number_of_coils) { - this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_COILS, start_address, number_of_coils)); + bool read_coils(uint16_t start_address, uint16_t number_of_coils, CommandOptions options = {}) { + return this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_COILS, start_address, number_of_coils), options); } - void read_discrete_inputs(uint16_t start_address, uint16_t number_of_inputs) { - this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_DISCRETE_INPUTS, start_address, number_of_inputs)); + bool read_discrete_inputs(uint16_t start_address, uint16_t number_of_inputs, CommandOptions options = {}) { + return this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_DISCRETE_INPUTS, start_address, number_of_inputs), + options); } - void write_single_register(uint16_t start_address, uint16_t value) { - this->send_pdu(helpers::create_write_single_register_pdu(start_address, value)); + bool write_single_register(uint16_t start_address, uint16_t value) { + return this->send_pdu(helpers::create_write_single_register_pdu(start_address, value)); } - void write_single_coil(uint16_t address, bool value) { - this->send_pdu(helpers::create_write_single_coil_pdu(address, value)); + bool write_single_coil(uint16_t address, bool value) { + return this->send_pdu(helpers::create_write_single_coil_pdu(address, value)); } - void write_multiple_registers(uint16_t start_address, std::span values) { - this->send_pdu(helpers::create_write_registers_pdu(start_address, values)); + bool write_multiple_registers(uint16_t start_address, std::span values) { + return this->send_pdu(helpers::create_write_registers_pdu(start_address, values)); } /// Note: std::vector cannot bind to std::span; use a contiguous bool container or the packed /// overload. - void write_multiple_coils(uint16_t start_address, std::span values) { - this->send_pdu(helpers::create_write_coils_pdu(start_address, values)); + bool write_multiple_coils(uint16_t start_address, std::span values) { + return this->send_pdu(helpers::create_write_coils_pdu(start_address, values)); } /// Packed variant: a PackedBits view (the same layout on_read_coils() delivers), so /// read-modify-write needs no unpack/repack. - void write_multiple_coils(uint16_t start_address, PackedBits bits) { - this->send_pdu(helpers::create_write_coils_pdu(start_address, bits)); - } - inline void clear_tx_queue_for_address(bool clear_sent = true) { - this->parent_->clear_tx_queue_for_address(this->address_, clear_sent); + bool write_multiple_coils(uint16_t start_address, PackedBits bits) { + return this->send_pdu(helpers::create_write_coils_pdu(start_address, bits)); } + inline void clear_tx_queue_for_address() { this->parent_->clear_tx_queue_for_address(this->address_); } inline void clear_tx_queue_for_device() { this->parent_->clear_tx_queue_for_device(this); } // If more than one device is connected block sending a new command before a response is received @@ -385,8 +532,6 @@ class ModbusClientDevice { ResponseStatus status); ModbusClientHub *parent_{nullptr}; - /// True while this device's on_not_sent() is on the stack (see trigger_not_sent()). - bool notifying_not_sent_{false}; uint8_t address_{0}; bool custom_response_warned_{false}; // first unhandled custom response warns; repeats log at VERBOSE }; diff --git a/esphome/components/modbus/modbus_definitions.h b/esphome/components/modbus/modbus_definitions.h index fd99055ca2..f883bfff30 100644 --- a/esphome/components/modbus/modbus_definitions.h +++ b/esphome/components/modbus/modbus_definitions.h @@ -116,6 +116,14 @@ static constexpr uint16_t READ_PDU_SIZE = 5; // A single-write PDU is always function code(1) + address(2) + value(2) static constexpr uint16_t WRITE_SINGLE_PDU_SIZE = 5; static constexpr uint16_t MAX_FRAME_SIZE = 256; +// Both send paths bound their payload so the framed result lands exactly on the RTU limit: a client +// PDU gains an address byte and a CRC, a raw server frame gains a CRC. send_frame_() therefore never +// has to check the framed size - it cannot be exceeded. +static_assert(MAX_PDU_SIZE + 3 == MAX_FRAME_SIZE, "a framed client PDU must fill the RTU frame limit"); +static_assert(MAX_RAW_SIZE + 2 == MAX_FRAME_SIZE, "a framed raw server payload must fill the RTU frame limit"); +/// Bits pack 8 per data byte, rounded up to whole bytes. +constexpr size_t packed_bit_bytes(size_t bits) { return (bits + 7) / 8; } + /** Read-only view of Modbus-packed bits: bit 0 of byte 0 is the first bit (LSB first), the layout * coil/discrete-input values use on the wire. Bundles the bit count with the packed bytes so the * two cannot desynchronize. The view does not own the bytes - it is only valid while they are. @@ -123,9 +131,6 @@ static constexpr uint16_t MAX_FRAME_SIZE = 256; * with any subscript. Writes and forwarding are defensive: set() drops out-of-range bits and * bytes() clamps to the real span, because those paths touch buffers and the wire directly. */ -/// Bits pack 8 per data byte, rounded up to whole bytes. -constexpr size_t packed_bit_bytes(size_t bits) { return (bits + 7) / 8; } - class PackedBits { public: PackedBits(std::span data, uint16_t count) : data_(data), count_(count) {} diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index 85c5d3e882..8428ea27ea 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -114,8 +114,10 @@ bool is_server_pdu_standard(const uint8_t *pdu, size_t size) { case FunctionCode::WRITE_MULTIPLE_REGISTERS: { // The response echoes start address and quantity: bound them like the request side does. const bool bits = function_code == FunctionCode::WRITE_MULTIPLE_COILS; + const uint16_t start_address = get_data(pdu, 1); + const uint16_t quantity = get_data(pdu, 3); const uint16_t max_quantity = bits ? MAX_NUM_OF_COILS_TO_WRITE : MAX_NUM_OF_REGISTERS_TO_WRITE; - return quantity_in_range(get_data(pdu, 1), get_data(pdu, 3), max_quantity); + return quantity_in_range(start_address, quantity, max_quantity); } case FunctionCode::WRITE_SINGLE_COIL: // The response echoes the request, so the same ON/OFF constraint applies. @@ -137,25 +139,31 @@ bool is_client_pdu_standard(const uint8_t *pdu, size_t size) { case FunctionCode::READ_INPUT_REGISTERS: { const bool bits = function_code == FunctionCode::READ_COILS || function_code == FunctionCode::READ_DISCRETE_INPUTS; + const uint16_t start_address = get_data(pdu, 1); + const uint16_t quantity = get_data(pdu, 3); const uint16_t max_quantity = bits ? MAX_NUM_OF_COILS_TO_READ : MAX_NUM_OF_REGISTERS_TO_READ; - return quantity_in_range(get_data(pdu, 1), get_data(pdu, 3), max_quantity); + return quantity_in_range(start_address, quantity, max_quantity); } case FunctionCode::WRITE_MULTIPLE_COILS: case FunctionCode::WRITE_MULTIPLE_REGISTERS: { const bool bits = function_code == FunctionCode::WRITE_MULTIPLE_COILS; + const uint16_t start_address = get_data(pdu, 1); const uint16_t quantity = get_data(pdu, 3); const uint16_t max_quantity = bits ? MAX_NUM_OF_COILS_TO_WRITE : MAX_NUM_OF_REGISTERS_TO_WRITE; // Coils are packed 8 per data byte; registers are 2 bytes each. const size_t expected_data_bytes = bits ? packed_bit_bytes(quantity) : quantity * 2; - return quantity_in_range(get_data(pdu, 1), quantity, max_quantity) && pdu[5] == expected_data_bytes; + return quantity_in_range(start_address, quantity, max_quantity) && pdu[5] == expected_data_bytes; } case FunctionCode::READ_FILE_RECORD: case FunctionCode::WRITE_FILE_RECORD: return pdu[1] <= MAX_PDU_SIZE - 2; case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: { + const uint16_t start_address_read = get_data(pdu, 1); + const uint16_t quantity_read = get_data(pdu, 3); + const uint16_t start_address_write = get_data(pdu, 5); const uint16_t quantity_write = get_data(pdu, 7); - return quantity_in_range(get_data(pdu, 1), get_data(pdu, 3), MAX_NUM_OF_REGISTERS_TO_READ) && - quantity_in_range(get_data(pdu, 5), quantity_write, MAX_NUM_OF_REGISTERS_TO_WRITE_RW) && + return quantity_in_range(start_address_read, quantity_read, MAX_NUM_OF_REGISTERS_TO_READ) && + quantity_in_range(start_address_write, quantity_write, MAX_NUM_OF_REGISTERS_TO_WRITE_RW) && pdu[9] == quantity_write * 2; } case FunctionCode::WRITE_SINGLE_COIL: @@ -299,6 +307,8 @@ static void append_pdu_header(StaticVector &pdu, FunctionCode func // Zero the unused bits of a multi-coil write's final data byte, as the spec requires. Kept in one // place so the generic and typed coil builders produce identical wire bytes for the same write. +// The caller must pass a span whose LAST byte is the final packed-bit byte - both builders pass the +// whole PDU, which qualifies because the coil data is always the PDU's tail. static void mask_trailing_pad_bits(std::span data, uint16_t bit_count) { if (data.empty() || bit_count % 8 == 0) return; diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 928054f1ab..3c789936af 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -305,11 +305,8 @@ class ModbusController final : public PollingComponent, public modbus::ModbusCli /// Deliberately shadows the deprecated ModbusClientDevice::send_raw() with identical semantics: /// controller-level raw sends stay supported until the command machinery is replaced. void send_raw(const std::vector &payload) { - if (payload.empty()) { - // Through the guard like every other delivery, so a handler calling send_raw({}) cannot recurse. - this->trigger_not_sent({}); - return; - } + if (payload.empty()) + return; // refused at the door, like every invalid send; no callback follows this->parent_->send_pdu(payload[0], std::span(payload).subspan(1), this); } /// Registers a sensor with the controller. Called by esphomes code generator diff --git a/tests/components/modbus/heap_probe_test.cpp b/tests/components/modbus/heap_probe_test.cpp index 2c7d9747bd..ddf905a8df 100644 --- a/tests/components/modbus/heap_probe_test.cpp +++ b/tests/components/modbus/heap_probe_test.cpp @@ -117,9 +117,10 @@ TEST(HeapProbe, TypicalFrameConstructionIsAllocationFree) { EXPECT_EQ(large.count, 1u); } -// Queueing typical commands is fully allocation-free: the frame fits the inline buffer and the tx -// deque's first block is already allocated when the hub is constructed. (A queue deeper than one -// deque block - roughly a dozen commands - would allocate further blocks.) +// Queueing typical commands is allocation-free within the deque's first block: the frame fits the +// inline buffer, every entry is a plain append (ordering lives in selection, not storage), and the +// first block is already allocated when the hub is constructed. A 512-byte deque block holds +// 512 / sizeof(ModbusDeviceCommand) entries (16 on the 64-bit host); a deeper queue allocates more. TEST(HeapProbe, QueueingTypicalCommandsIsAllocationFree) { ModbusClientHub hub; ModbusClientDevice device(&hub, 0x02); @@ -129,14 +130,36 @@ TEST(HeapProbe, QueueingTypicalCommandsIsAllocationFree) { req.assign(read_pdu, read_pdu + sizeof(read_pdu)); constexpr int n = 12; + static_assert(n * sizeof(ModbusDeviceCommand) < 512, "keep n within one deque block so the probe stays meaningful"); size_t total = 0; for (int i = 0; i != n; i++) { + req[2] = static_cast(i); // distinct start addresses: identical frames would dedup, not enqueue total += sample([&] { device.send_pdu(req); }).count; } printf("HEAPPROBE queue_%d_typical_commands total_allocs=%zu\n", n, total); EXPECT_EQ(total, 0u); } +// A WRITE arriving behind queued reads is a plain append too - the old priority front-insert (and +// its possible front-block allocation) is gone; the write wins transmit SELECTION instead. +TEST(HeapProbe, WriteBehindQueuedReadsAppendsAllocationFree) { + ModbusClientHub hub; + ModbusClientDevice device(&hub, 0x02); + + StaticVector req; + const uint8_t read_pdu[] = {0x03, 0x01, 0x00, 0x00, 0x02}; + req.assign(read_pdu, read_pdu + sizeof(read_pdu)); + for (int i = 0; i != 3; i++) { + req[2] = static_cast(i); // distinct start addresses: identical frames would dedup, not enqueue + device.send_pdu(req); + } + + const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; + Sample append = sample([&] { device.send_pdu(write_pdu); }); + printf("HEAPPROBE write_append count=%zu bytes=%zu\n", append.count, append.bytes); + EXPECT_EQ(append.count, 0u); +} + // End to end: bytes injected at the UART travel through receive, frame parsing, response matching and // device dispatch. The first response may grow the hub's rx buffer once; after that warm-up, handling a // response performs zero heap allocations all the way to the device callback. @@ -189,6 +212,9 @@ TEST(HeapProbe, TypicalFrameConstructionIsAllocationFree) { TEST(HeapProbe, QueueingTypicalCommandsIsAllocationFree) { GTEST_SKIP() << "allocation counting requires an AddressSanitizer build"; } +TEST(HeapProbe, WriteBehindQueuedReadsAppendsAllocationFree) { + GTEST_SKIP() << "allocation counting requires an AddressSanitizer build"; +} TEST(HeapProbe, ResponseHandlingIsAllocationFreeAfterWarmup) { GTEST_SKIP() << "allocation counting requires an AddressSanitizer build"; } diff --git a/tests/components/modbus/modbus_client_hub_test.cpp b/tests/components/modbus/modbus_client_hub_test.cpp index 11bc10200d..4d5b4e7ee8 100644 --- a/tests/components/modbus/modbus_client_hub_test.cpp +++ b/tests/components/modbus/modbus_client_hub_test.cpp @@ -13,33 +13,64 @@ namespace esphome::modbus::testing { namespace { -// Exposes the protected tx queue and waiting-for-response slot so tests can drive the -// no-response path without a UART: force_send_front() mimics send_next_frame_() moving the -// front frame in flight, timeout_waiting() mimics the loop() no-response timeout handling. +// Exposes the frame state machine so tests can drive it without a UART (force_send_next(), +// timeout_waiting(), sweep_for_test() stand in for the loop() transmit/watchdog/sweep steps). class NoResponseProbeHub : public ModbusClientHub { public: - size_t queued_frames() const { return this->tx_buffer_.size(); } - const ModbusDeviceCommand &front() const { return this->tx_buffer_.front(); } - const ModbusDeviceCommand &queued(size_t i) const { return this->tx_buffer_[i]; } - bool waiting() const { return this->waiting_for_response_.has_value(); } - const ModbusDeviceCommand &waiting_command() const { - EXPECT_TRUE(this->waiting_for_response_.has_value()); - return *this->waiting_for_response_; // NOLINT(bugprone-unchecked-optional-access) + // The old "queue" view: entries awaiting transmission, in STORAGE order (selection order is + // what the engine transmits by; use next_ready() for that). + size_t queued_frames() const { + size_t count = 0; + for (const auto &cmd : this->tx_buffer_) { + if (cmd.state == FrameState::READY) + count++; + } + return count; + } + // A never-null placeholder to return when a lookup fails, so a tripped EXPECT/ADD_FAILURE reports + // the assertion instead of dereferencing null / an empty deque and segfaulting the whole suite. + static const ModbusDeviceCommand &dummy_command() { + static const uint8_t DUMMY_PDU[1] = {0x00}; + static ModbusDeviceCommand cmd(nullptr, 0, std::span(DUMMY_PDU, 1)); + return cmd; + } + const ModbusDeviceCommand &queued(size_t i) const { + for (const auto &cmd : this->tx_buffer_) { + if (cmd.state == FrameState::READY && i-- == 0) + return cmd; + } + ADD_FAILURE() << "no READY entry at that index"; + return dummy_command(); + } + size_t entries() const { return this->tx_buffer_.size(); } + const ModbusDeviceCommand *next_ready() { return this->select_next_ready_(); } + bool waiting() const { return this->waiting_for_response_; } + const ModbusDeviceCommand &waiting_command() { + ModbusDeviceCommand *cmd = this->find_waiting_(); + EXPECT_NE(cmd, nullptr); + return cmd != nullptr ? *cmd : dummy_command(); } - void send_next_for_test() { this->send_next_frame_(); } - void force_send_front() { - this->waiting_for_response_ = std::move(this->tx_buffer_.front()); - this->tx_buffer_.pop_front(); + void sweep_for_test() { this->sweep_(); } + void send_next_for_test() { + this->send_next_frame_(); + this->sweep_(); // a transmit failure's on_not_sent() is delivered by the loop's sweep } - // Drives the real unexpected-frame branch in process_modbus_server_frame(). + void force_send_next() { + ModbusDeviceCommand *cmd = this->select_next_ready_(); + ASSERT_NE(cmd, nullptr) << "no READY entry to send"; + cmd->state = FrameState::WAITING; + this->waiting_for_response_ = true; + } + // Drives the real response/interruption branches, followed by the loop's sweep. void receive_frame_for_test(uint8_t address, std::span pdu) { this->process_modbus_server_frame(address, pdu); + this->sweep_(); } void timeout_waiting() { - if (this->waiting_for_response_.has_value()) - this->notify_no_response_(*this->waiting_for_response_); - this->waiting_for_response_.reset(); + this->sweep_(); // deliver anything already owed (e.g. an interruption's on_no_response) + this->expire_waiting_(); + this->sweep_(); } }; @@ -87,7 +118,7 @@ TEST(ModbusClientHubNoResponse, RetryRequeuesWaitingFrame) { device.send_pdu(read_pdu()); ASSERT_EQ(hub.queued_frames(), 1u); - hub.force_send_front(); + hub.force_send_next(); ASSERT_EQ(hub.queued_frames(), 0u); ASSERT_TRUE(hub.waiting()); @@ -96,7 +127,7 @@ TEST(ModbusClientHubNoResponse, RetryRequeuesWaitingFrame) { EXPECT_EQ(device.no_response_count_, 1); EXPECT_FALSE(hub.waiting()); ASSERT_EQ(hub.queued_frames(), 1u); - const ModbusDeviceCommand &requeued = hub.front(); + const ModbusDeviceCommand &requeued = hub.queued(0); EXPECT_EQ(requeued.device, &device); // address + PDU + CRC ASSERT_EQ(requeued.frame.size(), sizeof(READ_PDU) + 3); @@ -111,7 +142,7 @@ TEST(ModbusClientHubNoResponse, NoRetryDropsWaitingFrame) { RetryingDevice device(&hub, 0x02, /*retry=*/false); device.send_pdu(read_pdu()); - hub.force_send_front(); + hub.force_send_next(); hub.timeout_waiting(); @@ -127,7 +158,7 @@ TEST(ModbusClientHubNoResponse, DetachedDeviceIsNotNotified) { { RetryingDevice device(&hub, 0x02, /*retry=*/true); device.send_pdu(read_pdu()); - hub.force_send_front(); + hub.force_send_next(); // device destructor clears its queue entries, including the waiting frame's device pointer } ASSERT_TRUE(hub.waiting()); @@ -139,32 +170,57 @@ TEST(ModbusClientHubNoResponse, DetachedDeviceIsNotNotified) { EXPECT_EQ(hub.queued_frames(), 0u); } -// An unexpected frame interrupts the transaction: the retry is re-queued immediately, but the -// waiting entry survives as an interrupted shell (device detached) that keeps tx blocked until the -// send-wait timeout clears it - without a second no-response callback or a duplicate requeue. +// An unexpected frame interrupts the transaction: the entry becomes an INTERRUPTED shell that +// ignores this transaction and blocks tx until the send-wait timeout, where it gets its single +// on_no_response() - a granted retry is requeued there, like any other timeout. TEST(ModbusClientHubNoResponse, RetryBehindInterruptedShell) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/true); device.send_pdu(read_pdu()); - hub.force_send_front(); + hub.force_send_next(); // A frame from the wrong address (0x07, expected 0x02) hits the unexpected-frame branch. const uint8_t stray_pdu[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; hub.receive_frame_for_test(0x07, stray_pdu); + hub.sweep_for_test(); - EXPECT_EQ(device.no_response_count_, 1); - ASSERT_EQ(hub.queued_frames(), 1u); // exactly one requeue... - EXPECT_EQ(hub.front().device, &device); - ASSERT_TRUE(hub.waiting()); // ...while the shell stays in the waiting slot - EXPECT_TRUE(hub.waiting_command().interrupted); - EXPECT_EQ(hub.waiting_command().device, nullptr); + EXPECT_EQ(device.no_response_count_, 0); // not notified early: it waits out the timeout + ASSERT_TRUE(hub.waiting()); // and keeps blocking the bus + EXPECT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED); + EXPECT_EQ(hub.waiting_command().pending, 1u); - // The send-wait timeout clears the shell without a second callback or another requeue. + // The send-wait timeout delivers on_no_response and requeues the granted retry. hub.timeout_waiting(); EXPECT_FALSE(hub.waiting()); EXPECT_EQ(device.no_response_count_, 1); - EXPECT_EQ(hub.queued_frames(), 1u); + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_EQ(hub.queued(0).device, &device); +} + +// The declined-retry interrupted shell blocks until the send-wait timeout, then gets its single +// on_no_response() there and retires with nothing left to send. +TEST(ModbusClientHubNoResponse, InterruptedShellDeclinedRetryRetiresOnRelease) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + device.send_pdu(read_pdu()); + hub.force_send_next(); + + const uint8_t stray_pdu[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x07, stray_pdu); // wrong address: interrupts the transaction + hub.sweep_for_test(); + + EXPECT_EQ(device.no_response_count_, 0); // not notified early + ASSERT_TRUE(hub.waiting()); // the shell still blocks the wire + EXPECT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED); + EXPECT_EQ(hub.waiting_command().pending, 1u); + + hub.timeout_waiting(); // on_no_response (declined), then the shell retires + + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.queued_frames(), 0u); + EXPECT_EQ(device.no_response_count_, 1); } // A callback that detaches the device (clear_tx_queue_for_device()) wins over its own retry request: @@ -174,7 +230,7 @@ TEST(ModbusClientHubNoResponse, MidCallbackClearCancelsRetry) { ClearingRetryDevice device(&hub, 0x02); device.send_pdu(read_pdu()); - hub.force_send_front(); + hub.force_send_next(); hub.timeout_waiting(); EXPECT_EQ(device.no_response_count_, 1); @@ -182,6 +238,271 @@ TEST(ModbusClientHubNoResponse, MidCallbackClearCancelsRetry) { EXPECT_FALSE(hub.waiting()); } +// Writes jump ahead of queued reads; reads keep FIFO order among themselves. +TEST(ModbusClientHubPriority, WritesSendBeforeQueuedReads) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + const uint8_t read_a[] = {0x03, 0x01, 0x00, 0x00, 0x02}; + const uint8_t read_b[] = {0x03, 0x02, 0x00, 0x00, 0x02}; + const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; + device.send_pdu(read_a); + device.send_pdu(read_b); + device.send_pdu(write_pdu); + + ASSERT_EQ(hub.queued_frames(), 3u); + hub.force_send_next(); + EXPECT_EQ(hub.waiting_command().frame.pdu()[0], 0x06); // the write transmits first + hub.timeout_waiting(); + hub.force_send_next(); + EXPECT_EQ(hub.waiting_command().frame.pdu()[1], 0x01); // reads follow in FIFO order + hub.timeout_waiting(); + hub.force_send_next(); + EXPECT_EQ(hub.waiting_command().frame.pdu()[1], 0x02); +} + +// Re-requesting a queued frame is absorbed into the existing entry instead of queueing a duplicate. +TEST(ModbusClientHubPriority, DuplicateQueuedFrameAbsorbedNotDuplicated) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + device.send_pdu(read_pdu()); + device.send_pdu(read_pdu()); + + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_EQ(hub.queued(0).pending, 2u); // one entry standing for two accepted requests +} + +// Re-requesting the frame currently waiting is absorbed into the waiting entry; after a +// no-response timeout the absorbed request still gets its run even though the device declines a +// retry, and a second timeout does not run it again. +TEST(ModbusClientHubPriority, InFlightDuplicateRunsOnceMore) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + device.send_pdu(read_pdu()); + hub.force_send_next(); + device.send_pdu(read_pdu()); // duplicate of the waiting frame + + EXPECT_EQ(hub.queued_frames(), 0u); // not queued twice + EXPECT_EQ(hub.waiting_command().pending, 2u); + + hub.timeout_waiting(); + ASSERT_EQ(hub.queued_frames(), 1u); // the timeout resolved one request; the absorbed one runs + EXPECT_EQ(hub.queued(0).pending, 1u); + + hub.force_send_next(); + hub.timeout_waiting(); + EXPECT_EQ(hub.queued_frames(), 0u); // the last request resolved; nothing left to run +} + +// An entry with an absorbed extra request that times out while the device asks to retry: the +// retry is not a resolution, so BOTH requests remain pending rather than one being dropped - +// which would leave that caller without a resolution. +TEST(ModbusClientHubPriority, AbsorbedRequestSurvivesDeviceRetry) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/true); + + device.send_pdu(read_pdu()); + hub.force_send_next(); + device.send_pdu(read_pdu()); // duplicate of the waiting frame -> absorbed + ASSERT_EQ(hub.waiting_command().pending, 2u); + + hub.timeout_waiting(); // no response; the device requests a retry + + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_EQ(hub.queued(0).pending, 2u); // preserved: the retry resolved nothing +} + +// A continuous read re-queues itself (at the lowest priority) after each successful response, +// but not after an exception response. +TEST(ModbusClientHubPriority, ContinuousReadRequeuesOnSuccessOnly) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + device.read_holding_registers(0x100, 2, {.continuous = true}); + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_TRUE(hub.queued(0).continuous); + hub.force_send_next(); + + // A matching successful response cycles the continuous entry back to READY. + const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x02, ok_response); + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_TRUE(hub.queued(0).continuous); + + // An exception response ends the poll. + hub.force_send_next(); + const uint8_t exception_response[] = {0x83, 0x02}; + hub.receive_frame_for_test(0x02, exception_response); + EXPECT_EQ(hub.queued_frames(), 0u); +} + +// A continuous read that gets no response and is retried stays continuous: an explicit retry of a +// continuous poll is assumed to still want continuous polling (the entry stays continuous). +TEST(ModbusClientHubPriority, RetriedContinuousReadStaysContinuous) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/true); + + device.read_holding_registers(0x100, 2, {.continuous = true}); + ASSERT_EQ(hub.queued_frames(), 1u); + ASSERT_TRUE(hub.queued(0).continuous); + hub.force_send_next(); + + hub.timeout_waiting(); // no response -> device requests retry + + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_TRUE(hub.queued(0).continuous); // the retried poll stays continuous +} + +// A one-shot duplicate downgrades a continuous poll to a one-shot (the mirror of a continuous +// duplicate upgrading a one-shot): the entry runs one more cycle to serve the request, then stops. +TEST(ModbusClientHubPriority, DuplicateSendDowngradesContinuous) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + device.read_holding_registers(0x100, 2, {.continuous = true}); + ASSERT_EQ(hub.queued_frames(), 1u); + ASSERT_TRUE(hub.queued(0).continuous); + + device.read_holding_registers(0x100, 2); // one-shot duplicate downgrades the poll + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_FALSE(hub.queued(0).continuous); + EXPECT_EQ(hub.queued(0).pending, 1u); + + // It runs one more cycle to serve the request, then stops - not re-queued as a poll. + hub.force_send_next(); + EXPECT_FALSE(hub.waiting_command().continuous); + const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x02, ok_response); + EXPECT_EQ(hub.queued_frames(), 0u); + EXPECT_EQ(hub.entries(), 0u); +} + +namespace { +// Re-sends its frame once as a one-shot from inside on_error(), to exercise the downgrade branch +// when the poll it duplicates has already reached a terminal (pending drained to 0). +class ResendOnErrorDevice : public ModbusClientDevice { + public: + ResendOnErrorDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_error(std::span request_pdu, ExceptionCode exception_code) override { + this->error_count_++; + if (this->resend_) { + this->resend_ = false; + this->read_holding_registers(0x100, 2); // one-shot re-send from inside the failure callback + } + } + int error_count_{0}; + bool resend_{true}; +}; +} // namespace + +// A one-shot re-send issued from inside a continuous poll's failure callback must still run. The +// poll's exception terminal has already drained pending to 0, so the re-send absorbs into that entry +// via the downgrade branch - which must restore the debt, or the sweep erases the entry with the +// request never sent and no callback delivered. +TEST(ModbusClientHubPriority, DowngradeAfterTerminalKeepsRequestAlive) { + NoResponseProbeHub hub; + ResendOnErrorDevice device(&hub, 0x02); + + device.read_holding_registers(0x100, 2, {.continuous = true}); + ASSERT_EQ(hub.queued_frames(), 1u); + ASSERT_TRUE(hub.queued(0).continuous); + + hub.force_send_next(); + const uint8_t exception_response[] = {0x83, 0x02}; + hub.receive_frame_for_test(0x02, exception_response); // exception ends the poll; on_error re-sends + + EXPECT_EQ(device.error_count_, 1); // one terminal delivered so far + ASSERT_EQ(hub.queued_frames(), 1u); // the re-send survived the sweep instead of being erased + EXPECT_FALSE(hub.queued(0).continuous); // downgraded to a one-shot + EXPECT_EQ(hub.queued(0).pending, 1u); // debt restored so the request runs + + // And it runs to its own terminal - a good response this time - then the entry is gone. + hub.force_send_next(); + const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x02, ok_response); + EXPECT_EQ(hub.queued_frames(), 0u); + EXPECT_EQ(hub.entries(), 0u); +} + +// Requesting continuous polling for a frame that is already queued as a one-shot turns that entry +// into the continuous poll instead of leaving a promotion that never polls. +TEST(ModbusClientHubPriority, ContinuousRequestUpgradesQueuedDuplicate) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + device.read_holding_registers(0x100, 2); + ASSERT_EQ(hub.queued_frames(), 1u); + ASSERT_FALSE(hub.queued(0).continuous); + + device.read_holding_registers(0x100, 2, {.continuous = true}); + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_TRUE(hub.queued(0).continuous); + + // And it behaves as a poll from here: success cycles it back to READY. + hub.force_send_next(); + const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x02, ok_response); + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_TRUE(hub.queued(0).continuous); +} + +// The transmit order is one key with three levels: writes, then one-shot reads, then continuous +// polls - a poll only gets the bus when nothing else wants it. +TEST(ModbusClientHubPriority, WritesThenOneShotReadsThenContinuousPolls) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + // Queued oldest-first in the opposite order to the one they must transmit in, so age cannot be + // what produces the expected sequence. + device.read_holding_registers(0x100, 2, {.continuous = true}); + const uint8_t one_shot[] = {0x03, 0x02, 0x00, 0x00, 0x01}; + const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; + device.send_pdu(one_shot); + device.send_pdu(write_pdu); + ASSERT_EQ(hub.queued_frames(), 3u); + EXPECT_EQ(hub.queued(0).priority(), CommandPriority::CONTINUOUS); + EXPECT_EQ(hub.queued(1).priority(), CommandPriority::READ); + EXPECT_EQ(hub.queued(2).priority(), CommandPriority::WRITE); + + hub.force_send_next(); + EXPECT_EQ(hub.waiting_command().frame.pdu()[0], 0x06); // the write goes first + hub.timeout_waiting(); + hub.force_send_next(); + EXPECT_EQ(hub.waiting_command().frame.pdu()[1], 0x02); // then the one-shot read + hub.timeout_waiting(); + hub.force_send_next(); + EXPECT_TRUE(hub.waiting_command().continuous); // and the poll takes what is left +} + +// continuous is ignored for writes: the frame still sends at WRITE priority, once. +TEST(ModbusClientHubPriority, ContinuousIgnoredForWrites) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; + device.send_pdu(write_pdu, {.continuous = true}); + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE); + EXPECT_FALSE(hub.queued(0).continuous); +} + +// A queued continuous poll does not count against immediate-send readiness: it ranks below every +// one-shot, so a new one-shot goes out ahead of it. A queued one-shot does count. +TEST(ModbusClientHubPriority, ContinuousPollDoesNotBlockImmediateSend) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + EXPECT_TRUE(hub.tx_buffer_empty()); // nothing queued + device.read_holding_registers(0x100, 2, {.continuous = true}); + ASSERT_TRUE(hub.queued(0).continuous); + EXPECT_TRUE(hub.tx_buffer_empty()); // a READY continuous poll still leaves room to send now + + device.read_holding_registers(0x200, 2); // a one-shot does count + EXPECT_FALSE(hub.tx_buffer_empty()); +} + // A device whose sent/not-sent callbacks are counted. namespace { class SentCountingDevice : public ModbusClientDevice { @@ -202,6 +523,148 @@ class SentCountingDevice : public ModbusClientDevice { }; } // namespace +// A write is never requeueable, so its entry can serve exactly one request: a duplicate of a +// queued write is refused at the door rather than earning the write an extra transmission. +TEST(ModbusClientHubPriority, DuplicateQueuedWriteRefused) { + NoResponseProbeHub hub; + SentCountingDevice device(&hub, 0x02); + + const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; + EXPECT_TRUE(device.send_pdu(write_pdu)); + EXPECT_FALSE(device.send_pdu(write_pdu)); // duplicate write: refused + hub.sweep_for_test(); + + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE); + EXPECT_EQ(hub.queued(0).pending, 1u); // a write's cap + EXPECT_EQ(device.not_sent_count_, 0); // refusals are returned, never delivered +} + +// Requeueability is an allow-list of the standard reads: a custom function code's idempotency is +// unknown, so its duplicate is refused like a write's instead of earning a silent re-send. +TEST(ModbusClientHubPriority, DuplicateCustomFunctionCodeRefused) { + NoResponseProbeHub hub; + SentCountingDevice device(&hub, 0x02); + + const uint8_t custom_pdu[] = {0x41, 0x01, 0x02}; // user-defined function code + EXPECT_TRUE(device.send_pdu(custom_pdu)); + EXPECT_FALSE(device.send_pdu(custom_pdu)); // duplicate custom command: refused + hub.sweep_for_test(); + + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_EQ(hub.queued(0).pending, 1u); // the non-requeueable cap of one run + EXPECT_EQ(device.not_sent_count_, 0); +} + +// An anonymous duplicate (no device - the YAML-lambda path) is always dropped, never promoted: +// with no callback there is no lifecycle to absorb into and no owner to route a re-run to. +TEST(ModbusClientHubPriority, AnonymousDuplicateDroppedNotPromoted) { + NoResponseProbeHub hub; + + const uint8_t read[] = {0x03, 0x01, 0x00, 0x00, 0x02}; + hub.send_pdu(0x02, read); + hub.send_pdu(0x02, read); // anonymous duplicate: dropped + + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_EQ(hub.queued(0).pending, 1u); // never absorbed for a null owner +} + +// A retried entry is re-stamped to the queue tail: reads that arrived while it was waiting get +// their turn before the retry, so a frame that keeps timing out cannot starve the rest of the bus. +TEST(ModbusClientHubPriority, RetriedReadGoesBehindFreshReads) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/true); + + device.send_pdu(read_pdu()); + hub.force_send_next(); // the frame that will time out and retry + device.send_pdu(read_pdu()); // waiting duplicate: absorbed into the waiting entry + const uint8_t fresh_a[] = {0x03, 0x00, 0x10, 0x00, 0x01}; + const uint8_t fresh_b[] = {0x03, 0x00, 0x20, 0x00, 0x01}; + device.send_pdu(fresh_a); + device.send_pdu(fresh_b); + ASSERT_EQ(hub.queued_frames(), 2u); + + hub.timeout_waiting(); // device retries; the entry returns to READY behind the fresh reads + + ASSERT_EQ(hub.queued_frames(), 3u); + const ModbusDeviceCommand *next = hub.next_ready(); + ASSERT_NE(next, nullptr); + EXPECT_EQ(next->frame.pdu()[2], 0x10); // fresh reads keep FIFO order ahead of the retry + hub.force_send_next(); + hub.timeout_waiting(); + hub.force_send_next(); + EXPECT_EQ(hub.waiting_command().frame.pdu()[2], 0x20); + hub.timeout_waiting(); + hub.force_send_next(); // the retry gets its turn last, both requests still on the entry + EXPECT_TRUE(std::equal(hub.waiting_command().frame.pdu().begin(), hub.waiting_command().frame.pdu().end(), READ_PDU)); + EXPECT_EQ(hub.waiting_command().pending, 2u); +} + +// An absorbed duplicate does not move the entry back in line: seq belongs to the entry, and only +// re-entering the line (retry, resolved request, continuous cycle) re-stamps it. +TEST(ModbusClientHubPriority, AbsorbedDuplicateKeepsPlaceInLine) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + const uint8_t read_a[] = {0x03, 0x00, 0x10, 0x00, 0x01}; + const uint8_t read_b[] = {0x03, 0x00, 0x20, 0x00, 0x01}; + device.send_pdu(read_a); + device.send_pdu(read_b); + device.send_pdu(read_a); // duplicate of the older entry: absorbed, place unchanged + ASSERT_EQ(hub.queued_frames(), 2u); + + const ModbusDeviceCommand *next = hub.next_ready(); + ASSERT_NE(next, nullptr); + EXPECT_EQ(next->frame.pdu()[2], 0x10); // read_a still transmits first + EXPECT_EQ(next->pending, 2u); +} + +// A write that is retried after a no-response keeps the WRITE class, so it stays ahead of reads, +// and a later duplicate still resolves against it instead of queueing twice. +TEST(ModbusClientHubPriority, RetriedWriteKeepsWritePriorityAndStaysNonRequeueable) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/true); + + const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; + device.send_pdu(write_pdu); + hub.force_send_next(); + hub.timeout_waiting(); // no response -> device requests retry -> back to READY + + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE); // retry preserves the WRITE class + + device.send_pdu(write_pdu); // duplicate of the retried write + ASSERT_EQ(hub.queued_frames(), 1u); // still not queued twice... + EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE); + hub.sweep_for_test(); + EXPECT_EQ(hub.queued(0).pending, 1u); // ...the duplicate was refused at the door (write cap is 1) +} + +namespace { +// A hub that is never free to transmit. +class AlwaysBlockedHub : public NoResponseProbeHub { + public: + bool tx_blocked() override { return true; } +}; +} // namespace + +// Transmitting cannot fail, so a hub that is busy simply does not transmit: the frame keeps its +// place in the queue and goes out on a later loop, with no callback and no lifecycle change. (The +// caller owns the tx_blocked() check; send_frame_() has no gate of its own to refuse at.) +TEST(ModbusClientHubSent, BlockedHubDefersInsteadOfFailing) { + AlwaysBlockedHub hub; + SentCountingDevice device(&hub, 0x02); + + EXPECT_TRUE(device.send_pdu(read_pdu())); + hub.send_next_for_test(); + + EXPECT_EQ(device.sent_count_, 0); + EXPECT_EQ(device.not_sent_count_, 0); // nothing failed - it has not been attempted + ASSERT_EQ(hub.queued_frames(), 1u); // still queued, still owed exactly one terminal + EXPECT_EQ(hub.queued(0).pending, 1u); + EXPECT_FALSE(hub.waiting()); +} + // on_sent() fires when the frame goes onto the wire, not when it is queued. TEST(ModbusClientHubSent, FiresOnWireNotOnQueue) { NullUART uart; @@ -211,7 +674,7 @@ TEST(ModbusClientHubSent, FiresOnWireNotOnQueue) { SentCountingDevice device(&hub, 0x02); device.send_pdu(read_pdu()); - EXPECT_EQ(device.sent_count_, 0); // queued only - nothing on the wire yet + EXPECT_EQ(device.sent_count_, 0); // queued only - nothing sent yet hub.send_next_for_test(); EXPECT_EQ(device.sent_count_, 1); @@ -221,6 +684,34 @@ TEST(ModbusClientHubSent, FiresOnWireNotOnQueue) { EXPECT_TRUE(hub.waiting()); } +namespace { +// tx_blocked() clear for send_next_frame_'s gate, then blocked for send_frame_'s post-delay re-check. +class RejectPostDelayHub : public NoResponseProbeHub { + public: + bool tx_blocked() override { return this->tx_blocked_calls_++ > 0; } + int tx_blocked_calls_{0}; +}; +} // namespace + +// A byte arriving during send_frame_'s pre-send delay blocks transmission after the caller's gate +// already passed. send_frame_ rejects, and send_next_frame_ leaves the frame READY to retry - it is +// not marked WAITING and the bus is not claimed. +TEST(ModbusClientHubSent, SendRejectedAfterDelayLeavesFrameReady) { + NullUART uart; + RejectPostDelayHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + SentCountingDevice device(&hub, 0x02); + + device.send_pdu(read_pdu()); + hub.send_next_for_test(); // gate passes, send_frame_ rejects on the post-delay re-check + + EXPECT_EQ(device.sent_count_, 0); // nothing transmitted + EXPECT_FALSE(hub.waiting()); // the frame was left untouched, bus not claimed + ASSERT_EQ(hub.entries(), 1u); + EXPECT_EQ(hub.queued(0).state, FrameState::READY); // still selectable next loop +} + // Counts response deliveries so requeue semantics can be pinned end to end. namespace { class DataCountingDevice : public ModbusClientDevice { @@ -260,7 +751,7 @@ class DataCountingDevice : public ModbusClientDevice { int drain_with_responses(NoResponseProbeHub &hub, std::span response_pdu, int max_cycles = 10) { int cycles = 0; while (hub.queued_frames() != 0 && cycles < max_cycles) { - hub.force_send_front(); + hub.force_send_next(); hub.receive_frame_for_test(0x02, response_pdu); cycles++; } @@ -284,6 +775,89 @@ TEST(ModbusClientHubCallbackCount, SingleReadSingleCallback) { EXPECT_FALSE(hub.waiting()); } +// Requesting the same read twice while queued yields exactly two callbacks: +// the promoted entry completes, re-queues once (demoted), completes again, and stops. +TEST(ModbusClientHubCallbackCount, DuplicateReadExactlyTwoCallbacks) { + NoResponseProbeHub hub; + DataCountingDevice device(&hub, 0x02); + + device.send_pdu(read_pdu()); + device.send_pdu(read_pdu()); + int cycles = drain_with_responses(hub, OK_RESPONSE); + + EXPECT_EQ(cycles, 2); + EXPECT_EQ(device.data_count_, 2); + EXPECT_EQ(device.not_sent_count_, 0); // both requests were served + EXPECT_EQ(hub.queued_frames(), 0u); +} + +namespace { +// Clears the address queue from inside its first response callback, so a duplicate still owed on the +// same entry has to be resolved (or, as things stand, is dropped) by that clear. +class ClearOnFirstResponseDevice : public DataCountingDevice { + public: + ClearOnFirstResponseDevice(ModbusClientHub *hub, uint8_t address) : DataCountingDevice(hub, address) {} + void on_response(std::span request_pdu, std::span response_pdu) override { + this->data_count_++; + if (this->data_count_ == 1) + this->clear_tx_queue_for_address(); // clear mid-completion, from inside the first response + } +}; +} // namespace + +// A duplicate read absorbs into one entry (pending 2). The first response resolves one request, and +// its callback clears the address queue mid-completion. The still-owed duplicate is a second accepted +// request, so it must get its own terminal - on_not_sent() - not be dropped silently. +TEST(ModbusClientHubCallbackCount, ClearFromResponseResolvesDuplicateWithNotSent) { + NoResponseProbeHub hub; + ClearOnFirstResponseDevice device(&hub, 0x02); + + EXPECT_TRUE(device.send_pdu(read_pdu())); + EXPECT_TRUE(device.send_pdu(read_pdu())); // absorbed: one entry, pending 2 + hub.force_send_next(); + hub.receive_frame_for_test(0x02, OK_RESPONSE); // response -> on_response -> clear, then sweep + + EXPECT_EQ(device.data_count_, 1); // exactly one response delivered + EXPECT_EQ(device.not_sent_count_, 1); // the duplicate resolved with a terminal, not dropped + EXPECT_EQ(device.terminals(), 2); // one terminal per accepted request + EXPECT_EQ(hub.queued_frames(), 0u); +} + +// A read entry serves two requests (this run plus one re-run), so the third identical request is +// refused at the door: two data callbacks, and no terminal for the request that was never taken. +TEST(ModbusClientHubCallbackCount, TripleReadRefusesTheThird) { + NoResponseProbeHub hub; + DataCountingDevice device(&hub, 0x02); + + EXPECT_TRUE(device.send_pdu(read_pdu())); + EXPECT_TRUE(device.send_pdu(read_pdu())); + EXPECT_FALSE(device.send_pdu(read_pdu())); // the entry is already at its cap + hub.sweep_for_test(); + EXPECT_EQ(device.not_sent_count_, 0); // refused synchronously, nothing owed + int cycles = drain_with_responses(hub, OK_RESPONSE); + + EXPECT_EQ(cycles, 2); + EXPECT_EQ(device.data_count_, 2); + EXPECT_EQ(device.terminals(), 2); // exactly one per accepted request + EXPECT_EQ(hub.queued_frames(), 0u); +} + +// A duplicate write is refused at the door and the original write sends once - the caller learns +// immediately, and no lifecycle is created for the request that was never taken. +TEST(ModbusClientHubCallbackCount, DuplicateWriteRefusedWithoutLifecycle) { + NoResponseProbeHub hub; + DataCountingDevice device(&hub, 0x02); + + const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; + EXPECT_TRUE(device.send_pdu(write_pdu)); + EXPECT_FALSE(device.send_pdu(write_pdu)); + hub.sweep_for_test(); + + EXPECT_EQ(device.terminals(), 0); // the accepted write has not resolved; the other never existed + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE); +} + // An exception response is a terminal on its own: exactly one on_error(), no others, // preceded by exactly one on_sent(). TEST(ModbusClientHubCallbackCount, ErrorResponseIsSoleTerminal) { @@ -319,20 +893,22 @@ TEST(ModbusClientHubCallbackCount, NoResponseIsSoleTerminalAndNotSentHasNoSent) EXPECT_EQ(device.terminals(), 1); EXPECT_EQ(device.sent_count_, 1); - // A refused send (empty PDU) is a not_sent terminal, never sent. + // Unabsorbable duplicate: the second identical write is refused at the door - no lifecycle, no + // terminal, nothing sent. const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - device.send_pdu(write_pdu); - device.send_pdu(std::span{}); - EXPECT_EQ(device.not_sent_count_, 1); - EXPECT_EQ(device.terminals(), 2); // the accepted write is still queued - no terminal for it yet - EXPECT_EQ(device.sent_count_, 1); // and it has not transmitted yet + EXPECT_TRUE(device.send_pdu(write_pdu)); + EXPECT_FALSE(device.send_pdu(write_pdu)); + hub.sweep_for_test(); + EXPECT_EQ(device.not_sent_count_, 0); + EXPECT_EQ(device.terminals(), 1); // still just the read's timeout + EXPECT_EQ(device.sent_count_, 1); - // Drain it: the write echo response is its data terminal, and the books balance. + // Drain the accepted write: its echo response is the data terminal, and the books balance. hub.send_next_for_test(); hub.receive_frame_for_test(0x02, write_pdu); EXPECT_EQ(device.data_count_, 1); - EXPECT_EQ(device.terminals(), 3); // 3 accepted lifecycles, 3 terminals - EXPECT_EQ(device.sent_count_, 2); // 2 transmissions (read + write); the refused send never sent + EXPECT_EQ(device.terminals(), 2); // 2 accepted lifecycles, 2 terminals + EXPECT_EQ(device.sent_count_, 2); // 2 transmissions; the refused duplicate never sent } // A device-requested retry starts a new lifecycle: each transmission gets its own sent + terminal. @@ -359,9 +935,9 @@ TEST(ModbusClientHubCallbackCount, RetryLifecyclesEachGetSentAndTerminal) { EXPECT_EQ(device.last_no_response_pdu_, std::vector(READ_PDU, READ_PDU + sizeof(READ_PDU))); } -// A retry re-queue that finds the buffer full is refused like any other send: the device gets -// on_not_sent() carrying the request PDU (the previously uncovered requeue_waiting_frame_ branch). -TEST(ModbusClientHubCallbackCount, FullQueueRetryRefusalDeliversNotSentWithPdu) { +// A retry is a state flip on an existing entry, never a new insertion, so a full queue can't refuse +// it: fill the queue, time out the waiting frame with a retry, and it survives as READY. +TEST(ModbusClientHubCallbackCount, RetryIsNeverRefusedByFullQueue) { NullUART uart; NoResponseProbeHub hub; hub.set_uart_parent(&uart); @@ -371,50 +947,91 @@ TEST(ModbusClientHubCallbackCount, FullQueueRetryRefusalDeliversNotSentWithPdu) SentCountingDevice filler(&hub, 0x05); device.send_pdu(read_pdu()); - hub.force_send_front(); // in flight - // Fill the queue with distinct frames. - for (uint16_t i = 0; i < MODBUS_TX_BUFFER_SIZE; i++) { + hub.force_send_next(); // waiting + device.send_pdu(read_pdu()); // absorbed: two requests pending + // Fill the remaining live capacity with distinct frames. + for (uint16_t i = 0; hub.entries() < MODBUS_TX_BUFFER_SIZE; i++) { const uint8_t fill[] = {0x03, static_cast(i >> 8), static_cast(i & 0xFF), 0x00, 0x01}; filler.send_pdu(fill); } - ASSERT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE); - hub.timeout_waiting(); // retry requested, but the re-queue is refused: not_sent terminal instead + hub.timeout_waiting(); // retry requested; the entry flips back to READY regardless of capacity EXPECT_EQ(device.no_response_count_, 1); - EXPECT_EQ(device.not_sent_count_, 1); - EXPECT_EQ(device.last_not_sent_pdu_, std::vector(READ_PDU, READ_PDU + sizeof(READ_PDU))); - EXPECT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE); + EXPECT_EQ(device.not_sent_count_, 0); // nothing was refused + ASSERT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE); + const ModbusDeviceCommand *next = hub.next_ready(); + ASSERT_NE(next, nullptr); + EXPECT_EQ(next->device, &filler); // round-robin: the retry re-stamped behind the fillers + // The retried entry survives as READY with both absorbed requests intact. + bool found = false; + for (size_t i = 0; i < hub.queued_frames(); i++) { + const ModbusDeviceCommand &cmd = hub.queued(i); + if (cmd.device == &device) { + EXPECT_EQ(cmd.pending, 2u); + found = true; + } + } + EXPECT_TRUE(found); } -// The deprecated device-side send_raw() refusal delivers through the same guard as every other -// path: a handler that reacts to its own refusal with another empty send_raw() stays bounded. +// The deprecated device-side send_raw() reports an unusable payload the same way every other +// refused send does: false at the call site, with no queue entry and no callback. namespace { -class SendRawOnNotSentDevice : public ModbusClientDevice { +class NotSentCountingRawDevice : public ModbusClientDevice { public: - SendRawOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} - void on_not_sent(std::span request_pdu) override { - this->not_sent_count_++; -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - this->send_raw({}); // refused again; the guard must suppress the nested delivery -#pragma GCC diagnostic pop - } + NotSentCountingRawDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_not_sent(std::span request_pdu) override { this->not_sent_count_++; } int not_sent_count_{0}; }; } // namespace -TEST(ModbusClientHubQueue, SendRawRefusalIsGuardedAgainstRecursion) { +TEST(ModbusClientHubQueue, SendRawTooShortIsRefusedAtTheDoor) { NoResponseProbeHub hub; - SendRawOnNotSentDevice device(&hub, 0x02); + NotSentCountingRawDevice device(&hub, 0x02); #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wdeprecated-declarations" - device.send_raw({}); // empty payload refused -> on_not_sent -> nested send_raw({}) suppressed + EXPECT_FALSE(device.send_raw({})); // too short to contain a PDU #pragma GCC diagnostic pop - EXPECT_EQ(device.not_sent_count_, 1); + EXPECT_EQ(device.not_sent_count_, 0); // refusals are returned, never delivered + EXPECT_TRUE(hub.tx_buffer_empty()); +} + +// A continuous read: every wire transmission pairs one sent with one terminal, ending on the error. +TEST(ModbusClientHubCallbackCount, ContinuousLifecyclesBalance) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + DataCountingDevice device(&hub, 0x02); + + device.read_holding_registers(0x100, 2, {.continuous = true}); + const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + const uint8_t exception_response[] = {0x83, 0x02}; + hub.send_next_for_test(); + hub.receive_frame_for_test(0x02, ok_response); // lifecycle 1 -> requeued + hub.send_next_for_test(); + hub.receive_frame_for_test(0x02, ok_response); // lifecycle 2 -> requeued + hub.send_next_for_test(); + hub.receive_frame_for_test(0x02, exception_response); // lifecycle 3 -> stops + + EXPECT_EQ(device.data_count_, 2); + EXPECT_EQ(device.error_count_, 1); + EXPECT_EQ(device.terminals(), 3); + EXPECT_EQ(device.sent_count_, 3); + EXPECT_EQ(hub.queued_frames(), 0u); } namespace { +// A device that stops itself (clears its own queue) from inside on_response(). +class ClearOnDataDevice : public ModbusClientDevice { + public: + ClearOnDataDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_response(std::span request_pdu, std::span response_pdu) override { + this->clear_tx_queue_for_device(); + } +}; + // A device that chains a follow-up send from inside on_sent(). class ChainOnSentDevice : public ModbusClientDevice { public: @@ -447,10 +1064,11 @@ TEST(ModbusClientHubQueue, ClearAddressQueueNotifiesEveryOwner) { bystander_other.send_pdu(read_c); ASSERT_EQ(hub.queued_frames(), 3u); - controller_like.clear_tx_queue_for_address(false); + controller_like.clear_tx_queue_for_address(); + hub.sweep_for_test(); // the loop's sweep delivers the owed terminals and erases the entries ASSERT_EQ(hub.queued_frames(), 1u); // only the other-address frame remains - EXPECT_EQ(hub.front().frame.address(), 0x03); + EXPECT_EQ(hub.queued(0).frame.address(), 0x03); EXPECT_EQ(controller_like.not_sent_count_, 1); EXPECT_EQ(bystander_same.not_sent_count_, 1); EXPECT_EQ(bystander_other.not_sent_count_, 0); @@ -458,6 +1076,72 @@ TEST(ModbusClientHubQueue, ClearAddressQueueNotifiesEveryOwner) { EXPECT_EQ(bystander_same.last_not_sent_pdu_, std::vector(std::begin(read_b), std::end(read_b))); } +// A cleared entry resolves with one on_not_sent() per accepted request it stood for, so the +// books balance for owners counting outstanding requests - all within the one sweep. +TEST(ModbusClientHubQueue, ClearAddressDeliversOneTerminalPerAcceptedRequest) { + NoResponseProbeHub hub; + SentCountingDevice device(&hub, 0x02); + + const uint8_t read[] = {0x03, 0x01, 0x00, 0x00, 0x02}; + device.send_pdu(read); + device.send_pdu(read); // duplicate: absorbed into the queued entry + ASSERT_EQ(hub.queued_frames(), 1u); + ASSERT_EQ(hub.queued(0).pending, 2u); + + hub.clear_tx_queue_for_address(0x02); + hub.sweep_for_test(); + + EXPECT_EQ(device.not_sent_count_, 2); // one terminal per accepted request + EXPECT_EQ(hub.queued_frames(), 0u); + EXPECT_EQ(hub.entries(), 0u); // fully drained and erased +} + +// A duplicate read absorbs into one waiting entry (pending 2 once the first is sent). A clear with +// clear_sent detaches the in-flight frame as a silent shell, but the duplicate - a second accepted +// request that would have re-run - was never transmitted, so it must still get its on_not_sent(). +TEST(ModbusClientHubQueue, ClearSentOnInFlightDuplicateStillNotifiesTheDuplicate) { + NoResponseProbeHub hub; + DataCountingDevice device(&hub, 0x02); + + device.send_pdu(read_pdu()); + device.send_pdu(read_pdu()); // absorbed: one entry, pending 2 + ASSERT_EQ(hub.queued(0).pending, 2u); + hub.force_send_next(); // the frame is sent (WAITING); pending still 2 + ASSERT_TRUE(hub.waiting()); + + hub.clear_tx_queue_for_address(0x02); + hub.sweep_for_test(); + + EXPECT_EQ(device.not_sent_count_, 1); // the un-transmitted duplicate is resolved, not dropped +} + +// A clear does not abandon the in-flight frame: it becomes a WAITING_RETIRED shell that keeps the +// bus and still delivers the in-flight request's usual callback (here on_response) when the reply +// arrives. Only un-run duplicates are turned into on_not_sent(); a lone in-flight frame has none. +TEST(ModbusClientHubQueue, ClearWhileInFlightStillDeliversTheResponse) { + NoResponseProbeHub hub; + DataCountingDevice device(&hub, 0x02); + + device.send_pdu(read_pdu()); + hub.force_send_next(); // sent, now WAITING + ASSERT_TRUE(hub.waiting()); + + hub.clear_tx_queue_for_address(0x02); + hub.sweep_for_test(); + + EXPECT_EQ(device.not_sent_count_, 0); // no un-run duplicate to resolve + ASSERT_TRUE(hub.waiting()); // still waiting for a response, holding the bus + ASSERT_EQ(hub.entries(), 1u); // entry preserved as a cleared shell + EXPECT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED); + + // the in-flight request still gets its usual callback when the response finally arrives + hub.receive_frame_for_test(0x02, OK_RESPONSE); + EXPECT_EQ(device.data_count_, 1); + EXPECT_EQ(device.not_sent_count_, 0); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + namespace { // Re-sends its frame once from inside on_not_sent - the re-queued frame must survive the sweep. class ResendOnNotSentDevice : public ModbusClientDevice { @@ -466,7 +1150,7 @@ class ResendOnNotSentDevice : public ModbusClientDevice { void on_not_sent(std::span request_pdu) override { this->not_sent_count_++; if (this->not_sent_count_ == 1) { - const uint8_t again[] = {0x06, 0x00, 0x40, 0x00, 0x01}; + const uint8_t again[] = {0x06, 0x00, 0x40, 0x00, 0x01}; // a write: ranked first at selection, not by position this->send_pdu(again); } } @@ -474,8 +1158,8 @@ class ResendOnNotSentDevice : public ModbusClientDevice { }; } // namespace -// A handler that re-sends to the same address from inside on_not_sent() neither corrupts the sweep nor -// loops it: only initially-marked frames are swept, so the re-queued frame stays queued. +// A handler that re-sends to the same address from inside on_not_sent() neither corrupts the sweep +// nor loops it: the fresh entry starts within its cap, so the sweep never touches it. TEST(ModbusClientHubQueue, ClearAddressReentrantResendSurvives) { NoResponseProbeHub hub; ResendOnNotSentDevice device(&hub, 0x02); @@ -484,19 +1168,47 @@ TEST(ModbusClientHubQueue, ClearAddressReentrantResendSurvives) { device.send_pdu(read); ASSERT_EQ(hub.queued_frames(), 1u); - hub.clear_tx_queue_for_address(0x02, false); + hub.clear_tx_queue_for_address(0x02); + hub.sweep_for_test(); // The original frame resolved via on_not_sent; the re-send from inside that callback remains queued. EXPECT_EQ(device.not_sent_count_, 1); ASSERT_EQ(hub.queued_frames(), 1u); - EXPECT_EQ(hub.front().frame.address(), 0x02); + EXPECT_EQ(hub.queued(0).frame.address(), 0x02); +} + +// The hard case for the sweep: the notified handler re-queues a WRITE to the cleared address. The +// fresh entry must be neither dropped nor re-notified - and the bystander's frame at the other +// address survives untouched, while the write still wins transmit selection. +TEST(ModbusClientHubQueue, ClearAddressReentrantResendNotSwept) { + NoResponseProbeHub hub; + ResendOnNotSentDevice resender(&hub, 0x02); + SentCountingDevice bystander_other(&hub, 0x03); + + const uint8_t read_victim[] = {0x03, 0x00, 0x10, 0x00, 0x01}; + const uint8_t read_other[] = {0x03, 0x00, 0x20, 0x00, 0x01}; + resender.send_pdu(read_victim); + bystander_other.send_pdu(read_other); + ASSERT_EQ(hub.queued_frames(), 2u); + + hub.clear_tx_queue_for_address(0x02); + hub.sweep_for_test(); + + EXPECT_EQ(resender.not_sent_count_, 1); // notified once, never re-notified for the re-send + ASSERT_EQ(hub.queued_frames(), 2u); // the re-queued write AND the other-address read survive + const ModbusDeviceCommand *next = hub.next_ready(); + ASSERT_NE(next, nullptr); + EXPECT_EQ(next->frame.address(), 0x02); // the WRITE class wins selection over the older read + EXPECT_EQ(next->frame.pdu()[0], 0x06); } namespace { -// Retries from EVERY on_not_sent - against a full queue this recursed without bound before the guard. -class AlwaysRetryDevice : public ModbusClientDevice { +// Re-sends its own frame from EVERY on_not_sent. There is no serve/absorb treadmill: a duplicate at +// the servable cap is refused at the door, and a re-send issued while the entry is retiring queues a +// fresh entry beyond the sweep's captured work_set (served next sweep), never re-absorbing the one draining. +class AlwaysResendDevice : public ModbusClientDevice { public: - AlwaysRetryDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + AlwaysResendDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} void on_not_sent(std::span request_pdu) override { this->not_sent_count_++; const uint8_t again[] = {0x03, 0x00, 0x50, 0x00, 0x01}; @@ -512,20 +1224,40 @@ class ClearOtherOnNotSentDevice : public ModbusClientDevice { ClearOtherOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} void on_not_sent(std::span request_pdu) override { this->not_sent_count_++; - this->parent_->clear_tx_queue_for_address(0x03, false); + this->parent_->clear_tx_queue_for_address(0x03); } int not_sent_count_{0}; }; } // namespace -// A handler that retries from every on_not_sent() against a FULL queue must not recurse: the first -// refusal notifies once, the nested refusal is dropped without a callback (the documented guard). -TEST(ModbusClientHubQueue, FullQueueRetryFromNotSentDoesNotRecurse) { +// pending can never exceed what the entry can serve, so the old serve/absorb treadmill is +// impossible by construction: the surplus request is refused at the door instead of being absorbed +// and resolved later, and a handler that re-sends gets false rather than another lifecycle. +TEST(ModbusClientHubQueue, PendingNeverExceedsTheServableCap) { + NoResponseProbeHub hub; + AlwaysResendDevice device(&hub, 0x02); + + const uint8_t read[] = {0x03, 0x00, 0x50, 0x00, 0x01}; + EXPECT_TRUE(device.send_pdu(read)); + EXPECT_TRUE(device.send_pdu(read)); + EXPECT_FALSE(device.send_pdu(read)); // at the cap: refused + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_EQ(hub.queued(0).pending, 2u); + + hub.sweep_for_test(); // nothing is owed, so the handler never runs + + EXPECT_EQ(device.not_sent_count_, 0); + EXPECT_EQ(hub.queued(0).pending, 2u); +} + +// A full queue refuses at the door: false at the call site, no entry, no callback - so the +// refusal cannot re-enter the hub at all and needs no recursion bound of its own. +TEST(ModbusClientHubQueue, FullQueueRefusesWithoutCallbacks) { NoResponseProbeHub hub; SentCountingDevice filler(&hub, 0x05); - AlwaysRetryDevice retrier(&hub, 0x02); + SentCountingDevice device(&hub, 0x02); - // Fill the queue with distinct frames. + // Fill the queue with distinct frames (distinct start addresses keep the dedup from absorbing them). for (uint16_t i = 0; i < MODBUS_TX_BUFFER_SIZE; i++) { const uint8_t fill[] = {0x03, static_cast(i >> 8), static_cast(i & 0xFF), 0x00, 0x01}; filler.send_pdu(fill); @@ -533,76 +1265,12 @@ TEST(ModbusClientHubQueue, FullQueueRetryFromNotSentDoesNotRecurse) { ASSERT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE); const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; - retrier.send_pdu(read); // refused (full) -> on_not_sent -> retry -> refused under the guard, silently + EXPECT_FALSE(device.send_pdu(read)); // refused synchronously + hub.sweep_for_test(); - EXPECT_EQ(retrier.not_sent_count_, 1); + EXPECT_EQ(device.not_sent_count_, 0); // nothing was accepted, so nothing is owed EXPECT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE); -} - -namespace { -// From inside on_not_sent, triggers ANOTHER device's send (which will be refused too). -class SendOtherOnNotSentDevice : public ModbusClientDevice { - public: - SendOtherOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} - void on_not_sent(std::span request_pdu) override { - this->not_sent_count_++; - if (this->other_ != nullptr) { - const uint8_t read[] = {0x03, 0x00, 0x60, 0x00, 0x01}; - this->other_->send_pdu(read); - } - } - ModbusClientDevice *other_{nullptr}; - int not_sent_count_{0}; -}; -} // namespace - -// The refusal recursion guard is per-device: a refusal that lands on a DIFFERENT device while one -// device's notification is on the stack must still deliver - that device did not cause the recursion -// and would otherwise silently lose its terminal callback. -TEST(ModbusClientHubQueue, RefusalForOtherDeviceDeliversDuringNotification) { - NoResponseProbeHub hub; - SentCountingDevice filler(&hub, 0x05); - SendOtherOnNotSentDevice first(&hub, 0x02); - SentCountingDevice second(&hub, 0x03); - first.other_ = &second; - - // Fill the queue with distinct frames. - for (uint16_t i = 0; i < MODBUS_TX_BUFFER_SIZE; i++) { - const uint8_t fill[] = {0x03, static_cast(i >> 8), static_cast(i & 0xFF), 0x00, 0x01}; - filler.send_pdu(fill); - } - ASSERT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE); - - const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; - first.send_pdu(read); // refused -> first.on_not_sent -> second's send refused -> second notified - - EXPECT_EQ(first.not_sent_count_, 1); - EXPECT_EQ(second.not_sent_count_, 1); -} - -// Two devices whose handlers each trigger the other's send cannot recurse without bound: each device -// can be on the notification stack at most once, so the cycle dies as soon as it returns to a device -// whose own on_not_sent() is still running. -TEST(ModbusClientHubQueue, TwoDeviceRefusalCycleTerminates) { - NoResponseProbeHub hub; - SentCountingDevice filler(&hub, 0x05); - SendOtherOnNotSentDevice first(&hub, 0x02); - SendOtherOnNotSentDevice second(&hub, 0x03); - first.other_ = &second; - second.other_ = &first; - - // Fill the queue with distinct frames. - for (uint16_t i = 0; i < MODBUS_TX_BUFFER_SIZE; i++) { - const uint8_t fill[] = {0x03, static_cast(i >> 8), static_cast(i & 0xFF), 0x00, 0x01}; - filler.send_pdu(fill); - } - ASSERT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE); - - const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; - first.send_pdu(read); // refuse -> first -> second refused -> second -> first suppressed -> unwind - - EXPECT_EQ(first.not_sent_count_, 1); - EXPECT_EQ(second.not_sent_count_, 1); + EXPECT_EQ(hub.entries(), MODBUS_TX_BUFFER_SIZE); // and no refusal bookkeeping was stored } namespace { @@ -613,16 +1281,16 @@ class ClearOwnAddressOnNotSentDevice : public ModbusClientDevice { ClearOwnAddressOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} void on_not_sent(std::span request_pdu) override { this->not_sent_count_++; - this->clear_tx_queue_for_address(/*clear_sent=*/false); + this->clear_tx_queue_for_address(); } int not_sent_count_{0}; }; } // namespace -// The documented cost of the per-device guard: a clear issued from inside your own on_not_sent() -// resolves your remaining frames silently (like clear_tx_queue_for_device() - you cleared them, you -// know), while other owners sharing the address are still notified. -TEST(ModbusClientHubQueue, SelfClearFromNotSentSilentForClearerNotifiesOthers) { +// An address clear issued from inside on_not_sent() resolves EVERY dropped request with its own +// terminal at the sweep - including the clearer's (the sweep delivers from a quiescent hub, so the +// old stack-nesting silence no longer applies; use clear_tx_queue_for_device() for silent teardown). +TEST(ModbusClientHubQueue, SelfClearFromNotSentResolvesEveryRequest) { NoResponseProbeHub hub; ClearOwnAddressOnNotSentDevice clearer(&hub, 0x02); SentCountingDevice bystander(&hub, 0x02); @@ -635,15 +1303,19 @@ TEST(ModbusClientHubQueue, SelfClearFromNotSentSilentForClearerNotifiesOthers) { bystander.send_pdu(read_c); ASSERT_EQ(hub.queued_frames(), 3u); - clearer.send_pdu(std::span{}); // refused (empty) -> the handler clears the shared address + EXPECT_FALSE(clearer.send_pdu(std::span{})); // empty: refused, no callback + clearer.clear_tx_queue_for_address(); // the clear the handler used to make - EXPECT_EQ(clearer.not_sent_count_, 1); // only the refusal; the two swept frames resolve silently - EXPECT_EQ(bystander.not_sent_count_, 1); // the bystander's swept frame is still notified + hub.sweep_for_test(); + + EXPECT_EQ(clearer.not_sent_count_, 2); // one per cleared request of its own + EXPECT_EQ(bystander.not_sent_count_, 1); // the bystander's cleared frame is notified too EXPECT_EQ(hub.queued_frames(), 0u); + EXPECT_EQ(hub.entries(), 0u); } -// The guard must not over-suppress: a sweep started from inside on_not_sent() still delivers its -// victims' notifications (only nested refusals are silenced). +// A clear issued from inside on_not_sent() still delivers its victims' notifications in the same sweep: +// the newly-retired entries set sweep_needed_ and the sweep's restart loop drains them before it ends. TEST(ModbusClientHubQueue, NestedClearFromNotSentStillNotifiesVictims) { NoResponseProbeHub hub; ClearOtherOnNotSentDevice clearer(&hub, 0x02); @@ -655,55 +1327,90 @@ TEST(ModbusClientHubQueue, NestedClearFromNotSentStillNotifiesVictims) { victim.send_pdu(read_b); ASSERT_EQ(hub.queued_frames(), 2u); - hub.clear_tx_queue_for_address(0x02, false); // clearer's on_not_sent clears address 0x03 in turn + hub.clear_tx_queue_for_address(0x02); // clearer's on_not_sent clears address 0x03 in turn + hub.sweep_for_test(); EXPECT_EQ(clearer.not_sent_count_, 1); - EXPECT_EQ(victim.not_sent_count_, 1); // delivered despite arriving from a nested sweep + EXPECT_EQ(victim.not_sent_count_, 1); // the nested clear's victim resolves in the same sweep EXPECT_EQ(hub.queued_frames(), 0u); } namespace { -// tx_blocked() flips to blocked after the first check, so send_next_frame_() passes its own gate but -// send_frame_() refuses - a deterministic transmit failure. -class FlakyBlockHub : public NoResponseProbeHub { +// From on_not_sent (delivered by the sweep), re-sends a frame identical to ANOTHER doomed queued +// frame; the dedup must not absorb into the doomed entry. +class ResendSecondFrameDevice : public ModbusClientDevice { public: - bool tx_blocked() override { - this->tx_blocked_calls_++; - return this->tx_blocked_calls_ > 1; - } - int tx_blocked_calls_{0}; -}; - -// Reacts to a transmit failure by sending another frame from inside the failure callback. -class WriteOnNotSentDevice : public ModbusClientDevice { - public: - WriteOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + ResendSecondFrameDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} void on_not_sent(std::span request_pdu) override { this->not_sent_count_++; - const uint8_t write[] = {0x06, 0x00, 0x40, 0x01, 0x02}; - this->send_pdu(write); + if (this->not_sent_count_ == 1) { + const uint8_t same_as_r2[] = {0x03, 0x00, 0x22, 0x00, 0x01}; + this->send_pdu(same_as_r2); + } } int not_sent_count_{0}; }; - } // namespace -// A transmit failure must resolve with the failed frame OUT of the queue before its on_not_sent runs: a -// handler that reacts by sending a new frame must not have that frame discarded by the pop that -// follows - the failed frame is popped first, the new frame survives. -TEST(ModbusClientHubQueue, TransmitFailurePopsBeforeNotify) { - FlakyBlockHub hub; - WriteOnNotSentDevice device(&hub, 0x02); +// A send during a sweep that matches a DELETED (doomed) frame must queue fresh, not absorb into +// the doomed entry - absorption would tie the new request to a frame the sweep is draining. +TEST(ModbusClientHubQueue, SweepDedupSkipsDeletedFrames) { + NoResponseProbeHub hub; + ResendSecondFrameDevice device(&hub, 0x02); - const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; + const uint8_t r1[] = {0x03, 0x00, 0x21, 0x00, 0x01}; + const uint8_t r2[] = {0x03, 0x00, 0x22, 0x00, 0x01}; + device.send_pdu(r1); + device.send_pdu(r2); + ASSERT_EQ(hub.queued_frames(), 2u); + + hub.clear_tx_queue_for_address(0x02); + hub.sweep_for_test(); // r1 and r2 both resolve; r1's handler re-sends a frame identical to r2 + // Without the dedup's dead-state skip the re-send would be absorbed into r2 and drained with it; + // with the skip it queues fresh and survives. + + EXPECT_EQ(device.not_sent_count_, 2); // r1 and r2 both resolved + ASSERT_EQ(hub.queued_frames(), 1u); // the re-send survives + EXPECT_EQ(hub.queued(0).frame.pdu()[2], 0x22); +} + +namespace { +// The worst-case handler: from every on_not_sent() it both re-sends and clears its own address, so +// each delivery manufactures a fresh entry AND a fresh terminal debt. +class ResendAndClearOnNotSentDevice : public ModbusClientDevice { + public: + ResendAndClearOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_not_sent(std::span request_pdu) override { + this->not_sent_count_++; + const uint8_t again[] = {0x03, 0x00, 0x70, 0x00, 0x01}; + this->send_pdu(again); + this->clear_tx_queue_for_address(); + } + int not_sent_count_{0}; +}; +} // namespace + +// Sweep-termination worst case: a handler re-sending AND clearing from every on_not_sent() still +// can't extend the sweep, since it serves only the entries it started with (new debt waits). +TEST(ModbusClientHubQueue, ResendAndClearFromNotSentCannotExtendTheSweep) { + NoResponseProbeHub hub; + ResendAndClearOnNotSentDevice device(&hub, 0x02); + + const uint8_t read[] = {0x03, 0x00, 0x70, 0x00, 0x01}; device.send_pdu(read); - ASSERT_EQ(hub.queued_frames(), 1u); + hub.clear_tx_queue_for_address(0x02); - hub.send_next_for_test(); // tx_blocked gate passes, send_frame_ refuses -> failure path + hub.sweep_for_test(); + EXPECT_EQ(device.not_sent_count_, 1); // exactly the one terminal that was owed on entry + EXPECT_EQ(hub.entries(), 1u); // the frame the handler queued (and then cleared itself) - EXPECT_EQ(device.not_sent_count_, 1); - ASSERT_EQ(hub.queued_frames(), 1u); // the handler's write survives... - EXPECT_EQ(hub.front().frame.pdu()[0], 0x06); // ...and it is the write, not the failed read + hub.sweep_for_test(); + EXPECT_EQ(device.not_sent_count_, 2); // its terminal comes on the next loop, not this sweep + EXPECT_EQ(hub.entries(), 1u); // and the container is still not growing + + hub.sweep_for_test(); + EXPECT_EQ(device.not_sent_count_, 3); + EXPECT_EQ(hub.entries(), 1u); } // clear_tx_queue_for_device() drops queued frames SILENTLY - no terminal callback (the documented @@ -724,8 +1431,8 @@ TEST(ModbusClientHubQueue, ClearDeviceQueueDropsSilently) { EXPECT_EQ(device.not_sent_count_, 0); // silent drop: no terminal callback } -// A send_pdu() from inside on_sent() enqueues behind the in-flight frame rather than sending -// immediately or corrupting the in-flight transaction. +// A send_pdu() from inside on_sent() enqueues behind the waiting frame rather than sending +// immediately or corrupting the waiting transaction. TEST(ModbusClientHubSent, ReentrantSendFromOnSentQueues) { NullUART uart; NoResponseProbeHub hub; @@ -734,13 +1441,53 @@ TEST(ModbusClientHubSent, ReentrantSendFromOnSentQueues) { ChainOnSentDevice device(&hub, 0x02); device.send_pdu(read_pdu()); - hub.send_next_for_test(); // first frame goes on the wire -> on_sent chains a follow-up + hub.send_next_for_test(); // first frame is sent -> on_sent chains a follow-up - EXPECT_TRUE(hub.waiting()); // first frame is in flight + EXPECT_TRUE(hub.waiting()); // first frame is waiting ASSERT_EQ(hub.queued_frames(), 1u); // the follow-up queued behind it, not sent EXPECT_EQ(hub.queued(0).frame.pdu()[2], 0x09); // it is the chained read (start address 0x0009) } +// "Stop polling now" from inside on_response() works: the completing command is exposed to the +// clear routines, which detach it, cancelling the pending continuous re-queue. +TEST(ModbusClientHubPriority, ClearDeviceDuringDataCancelsContinuousRequeue) { + NoResponseProbeHub hub; + ClearOnDataDevice device(&hub, 0x02); + + device.read_holding_registers(0x100, 2, {.continuous = true}); + hub.force_send_next(); + const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x02, ok_response); + + // "Stop polling now" from inside on_response() works: the completing command is detached, so the + // continuous re-queue is cancelled. + EXPECT_EQ(hub.queued_frames(), 0u); +} + +namespace { +// A device that stops polling for its address (clear by address) from inside on_response(). +class ClearAddressOnDataDevice : public ModbusClientDevice { + public: + ClearAddressOnDataDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_response(std::span request_pdu, std::span response_pdu) override { + this->clear_tx_queue_for_address(); + } +}; +} // namespace + +// The address-scoped clear cancels the mid-completion re-queue the same way the device-scoped one does. +TEST(ModbusClientHubPriority, ClearAddressDuringDataCancelsContinuousRequeue) { + NoResponseProbeHub hub; + ClearAddressOnDataDevice device(&hub, 0x02); + + device.read_holding_registers(0x100, 2, {.continuous = true}); + hub.force_send_next(); + const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x02, ok_response); + + EXPECT_EQ(hub.queued_frames(), 0u); +} + namespace { // Overrides only the DEPRECATED on_modbus_* names: the new-name default implementations must forward, so // external devices written against the old names keep working through the deprecation window. @@ -766,23 +1513,31 @@ TEST(ModbusClientHubCompat, LegacyCallbackNamesStillForward) { const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; device.send_pdu(read); - hub.force_send_front(); + hub.force_send_next(); hub.timeout_waiting(); // no reply -> on_no_response -> forwards to on_modbus_no_response EXPECT_EQ(device.legacy_no_response_, 1); - device.send_pdu(std::span()); // empty PDU refused -> on_not_sent -> forwards + // A refused send returns false with no callback, so exercise the forward through an accepted + // request instead: a cleared queue entry delivers on_not_sent(), which forwards to the old name. + EXPECT_FALSE(device.send_pdu(std::span())); // empty PDU: refused at the door + EXPECT_EQ(device.legacy_not_sent_, 0); + const uint8_t queued[] = {0x03, 0x00, 0x11, 0x00, 0x01}; + EXPECT_TRUE(device.send_pdu(queued)); + hub.clear_tx_queue_for_address(0x02); + hub.sweep_for_test(); EXPECT_EQ(device.legacy_not_sent_, 1); } // The send_pdu() capacity bound: a PDU larger than MAX_PDU_SIZE would build a frame past the RTU -// 256-byte limit, so it is refused up front and signalled like any other failed send. -TEST(ModbusClientHub, OversizedPduIsRefusedWithNotSent) { +// 256-byte limit, so it is refused up front - false at the call site, no entry, no callback. +TEST(ModbusClientHub, OversizedPduIsRefusedAtTheDoor) { NoResponseProbeHub hub; LegacyNameDevice device(&hub, 0x02); std::vector big(MAX_PDU_SIZE + 1, 0x41); - device.send_pdu(big); - EXPECT_EQ(device.legacy_not_sent_, 1); // on_not_sent, observed via the legacy forward + EXPECT_FALSE(device.send_pdu(big)); + EXPECT_EQ(device.legacy_not_sent_, 0); // refusals are returned, never delivered EXPECT_TRUE(hub.tx_buffer_empty()); + EXPECT_EQ(hub.entries(), 0u); } // --- ModbusDevice compatibility shim ------------------------------------------------------------ @@ -814,7 +1569,7 @@ TEST(ModbusDeviceShim, LegacyCallbacksReceiveTheOldShapes) { // the byte-count byte, as an owning vector. const uint8_t read_req[] = {0x03, 0x00, 0x10, 0x00, 0x02}; device.send_pdu(read_req); - hub.force_send_front(); + hub.force_send_next(); const uint8_t response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; hub.receive_frame_for_test(0x02, response); const std::vector expected{0x00, 0x2A, 0x01, 0x00}; @@ -823,14 +1578,14 @@ TEST(ModbusDeviceShim, LegacyCallbacksReceiveTheOldShapes) { // Write echo: no byte-count byte, so the payload is everything after the function code. const uint8_t write_req[] = {0x06, 0x00, 0x10, 0x00, 0x2A}; device.send_pdu(write_req); - hub.force_send_front(); + hub.force_send_next(); hub.receive_frame_for_test(0x02, write_req); // single-write responses echo the request const std::vector expected_echo{0x00, 0x10, 0x00, 0x2A}; EXPECT_EQ(device.last_data_, expected_echo); // Exception response: on_modbus_error() received the masked function code and the exception code. device.send_pdu(read_req); - hub.force_send_front(); + hub.force_send_next(); const uint8_t error[] = {0x83, 0x02}; hub.receive_frame_for_test(0x02, error); EXPECT_EQ(device.last_error_fc_, 0x03); @@ -845,9 +1600,9 @@ TEST(ModbusTypedSendHelpers, HelpersQueueExpectedPdus) { ModbusClientDevice device(&hub, 0x02); auto check = [&](const std::vector &expected) { ASSERT_EQ(hub.queued_frames(), 1u); - auto pdu = hub.front().frame.pdu(); + auto pdu = hub.queued(0).frame.pdu(); EXPECT_EQ(std::vector(pdu.begin(), pdu.end()), expected); - hub.force_send_front(); + hub.force_send_next(); hub.timeout_waiting(); // default on_no_response() declines the retry, dropping the frame }; @@ -882,21 +1637,21 @@ TEST(ModbusTypedSendHelpers, ReadEntitiesDispatchesByTypeAndRejectsInvalid) { device.read_entities(EntityType::HOLDING, 0x0001, 1); ASSERT_EQ(hub.queued_frames(), 1u); - EXPECT_EQ(hub.front().frame.pdu()[0], 0x03); - hub.force_send_front(); + EXPECT_EQ(hub.queued(0).frame.pdu()[0], 0x03); + hub.force_send_next(); hub.timeout_waiting(); device.read_entities(EntityType::DISCRETE_INPUT, 0x0001, 1); ASSERT_EQ(hub.queued_frames(), 1u); - EXPECT_EQ(hub.front().frame.pdu()[0], 0x02); - hub.force_send_front(); + EXPECT_EQ(hub.queued(0).frame.pdu()[0], 0x02); + hub.force_send_next(); hub.timeout_waiting(); device.read_entities(EntityType::CUSTOM, 0x0001, 1); // no read function: logged and not queued EXPECT_EQ(hub.queued_frames(), 0u); } -// A rejected read_entities() signals on_not_sent() like every other refused send. +// A rejected read_entities() returns false like every other refused send. namespace { class NotSentCountingDevice : public ModbusClientDevice { public: @@ -906,12 +1661,306 @@ class NotSentCountingDevice : public ModbusClientDevice { }; } // namespace -TEST(ModbusTypedSendHelpers, InvalidReadEntitiesSignalsNotSent) { +TEST(ModbusTypedSendHelpers, InvalidReadEntitiesIsRefusedAtTheDoor) { NoResponseProbeHub hub; NotSentCountingDevice device(&hub, 0x02); - device.read_entities(EntityType::CUSTOM, 0x0001, 1); - EXPECT_EQ(device.not_sent_, 1); + EXPECT_FALSE(device.read_entities(EntityType::CUSTOM, 0x0001, 1)); + EXPECT_EQ(device.not_sent_, 0); // refused sends report through the return value EXPECT_EQ(hub.queued_frames(), 0u); } +namespace { +// Re-sends its own frame from inside on_response() - matching the command mid-completion. +class ResendOnDataDevice : public ModbusClientDevice { + public: + ResendOnDataDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_response(std::span request_pdu, std::span response_pdu) override { + this->send_pdu(std::vector(request_pdu.begin(), request_pdu.end())); + } + void send_pdu(const std::vector &pdu) { ModbusClientDevice::send_pdu(pdu); } +}; +} // namespace + +// A send from inside on_response() that matches the RECEIVED (completing) entry is absorbed into it, +// never a fresh twin. Here it is a one-shot re-send of a continuous poll, so it also downgrades the +// poll to a one-shot: one entry on the queue afterwards, now non-continuous. +TEST(ModbusClientHubPriority, ResendFromOnResponseAbsorbsIntoCompletingCommand) { + NoResponseProbeHub hub; + ResendOnDataDevice device(&hub, 0x02); + + device.read_holding_registers(0x100, 2, {.continuous = true}); + hub.force_send_next(); + const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x02, ok_response); // handler re-sends the identical frame mid-completion + + ASSERT_EQ(hub.queued_frames(), 1u); // absorbed into the same entry, not a fresh twin + EXPECT_FALSE(hub.queued(0).continuous); // the one-shot re-send downgraded the poll +} + +// An exception-flagged function code is never silently re-sendable, even though the read check +// masks the exception bit: its duplicate takes the drop path like any other non-read. +TEST(ModbusClientHubPriority, ExceptionFlaggedDuplicateDroppedNotPromoted) { + NoResponseProbeHub hub; + SentCountingDevice device(&hub, 0x02); + + const uint8_t weird[] = {0x83, 0x01, 0x00, 0x00, 0x02}; // read-shaped but exception-flagged + EXPECT_TRUE(device.send_pdu(weird)); + EXPECT_FALSE(device.send_pdu(weird)); // non-requeueable: cap of one, so the duplicate is refused + hub.sweep_for_test(); + + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_EQ(hub.queued(0).pending, 1u); + EXPECT_EQ(device.not_sent_count_, 0); + + // The write-shaped twin (0x86 masks to WRITE_SINGLE_REGISTER) must not take WRITE-class + // ordering either: exception-flagged codes are excluded from the mutates classification. + const uint8_t weird_write[] = {0x86, 0x00, 0x10, 0xBE, 0xEF}; + device.send_pdu(weird_write); + ASSERT_EQ(hub.queued_frames(), 2u); + EXPECT_EQ(hub.queued(1).priority(), CommandPriority::READ); // not WRITE + const ModbusDeviceCommand *next = hub.next_ready(); + ASSERT_NE(next, nullptr); + EXPECT_EQ(next->frame.pdu()[0], 0x83); // FIFO by age: it did not jump the older entry +} + +namespace { +// From inside the sweep's on_not_sent, re-sends the frame that is currently WAITING. +class ResendInFlightOnNotSentDevice : public ModbusClientDevice { + public: + ResendInFlightOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_not_sent(std::span request_pdu) override { + this->not_sent_count_++; + if (this->not_sent_count_ == 1) { + const uint8_t same_as_waiting[] = {0x03, 0x01, 0x00, 0x00, 0x02}; // == READ_PDU + this->send_pdu(same_as_waiting); + } + } + int not_sent_count_{0}; +}; +} // namespace + +// A clear turns the waiting entry into a WAITING_RETIRED shell. The shell keeps its device (so the +// in-flight request still gets its callback), but the dedup skips it, so a sweep handler re-sending +// that frame queues fresh instead of being absorbed into the cleared shell and drained as on_not_sent. +TEST(ModbusClientHubQueue, SweepResendAfterClearQueuesFreshNotAbsorbedIntoShell) { + NoResponseProbeHub hub; + ResendInFlightOnNotSentDevice device(&hub, 0x02); + + device.send_pdu(read_pdu()); + hub.force_send_next(); // READ_PDU now waiting + const uint8_t queued_read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; + device.send_pdu(queued_read); // a queued frame for the sweep to notify + ASSERT_EQ(hub.queued_frames(), 1u); + + hub.clear_tx_queue_for_address(0x02); + hub.sweep_for_test(); + + EXPECT_EQ(device.not_sent_count_, 1); // only the cleared queued frame, not the re-send + ASSERT_EQ(hub.queued_frames(), 1u); // the handler's re-send queued fresh... + EXPECT_EQ(hub.queued(0).pending, 1u); // ...not absorbed into the cleared shell + EXPECT_TRUE(std::equal(hub.queued(0).frame.pdu().begin(), hub.queued(0).frame.pdu().end(), READ_PDU)); + EXPECT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED); // in-flight one still awaiting a reply +} + +namespace { +// Gives up after a timeout by clearing its address from inside on_no_response() - the natural +// "device is dead, drop my traffic" pattern, and the reentrant case the address clear must handle. +class ClearAddressOnNoResponseDevice : public ModbusClientDevice { + public: + ClearAddressOnNoResponseDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + bool on_no_response(std::span request_pdu) override { + this->no_response_count_++; + this->clear_tx_queue_for_address(); + return false; // gave up + } + void on_not_sent(std::span request_pdu) override { this->not_sent_count_++; } + int terminals() const { return this->no_response_count_ + this->not_sent_count_; } + int no_response_count_{0}; + int not_sent_count_{0}; +}; + +} // namespace + +// A clear issued from inside on_no_response() must not cause the request to be resolved twice: +// that callback already was its terminal, so the entry it hijacks owes nothing more. +TEST(ModbusClientHubNoResponse, SelfClearFromNoResponseDoesNotDoubleResolve) { + NoResponseProbeHub hub; + ClearAddressOnNoResponseDevice device(&hub, 0x02); + + device.send_pdu(read_pdu()); + hub.force_send_next(); + hub.timeout_waiting(); + + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_EQ(device.terminals(), 1); // exactly one terminal for the one accepted request + EXPECT_EQ(hub.entries(), 0u); +} + +// The same entry standing for two accepted requests: the timeout resolves one, and the clear that +// cancels the re-run must resolve exactly the other. +TEST(ModbusClientHubNoResponse, SelfClearFromNoResponseResolvesTheAbsorbedRequestOnce) { + NoResponseProbeHub hub; + ClearAddressOnNoResponseDevice device(&hub, 0x02); + + EXPECT_TRUE(device.send_pdu(read_pdu())); + EXPECT_TRUE(device.send_pdu(read_pdu())); // absorbed: one entry, two requests + hub.force_send_next(); + hub.timeout_waiting(); + + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_EQ(device.terminals(), 2); // one per accepted request, no more + EXPECT_EQ(hub.entries(), 0u); +} + +// A cleared in-flight frame must release the bus by both exits and still deliver the in-flight +// request's usual callback (on_response here, on_no_response on timeout); no on_not_sent, no duplicate. +TEST(ModbusClientHubQueue, ClearedShellReleasesTheBusOnLateResponse) { + NoResponseProbeHub hub; + DataCountingDevice device(&hub, 0x02); + + device.send_pdu(read_pdu()); + hub.force_send_next(); + hub.clear_tx_queue_for_address(0x02); + ASSERT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED); + + const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x02, ok_response); // the late reply for the cleared frame + + EXPECT_FALSE(hub.waiting()); // the bus is free again + EXPECT_EQ(hub.entries(), 0u); // the shell is gone + EXPECT_EQ(device.data_count_, 1); // the in-flight request still got its response callback + EXPECT_EQ(device.not_sent_count_, 0); // no un-run duplicate +} + +TEST(ModbusClientHubQueue, ClearedShellReleasesTheBusOnTimeout) { + NoResponseProbeHub hub; + DataCountingDevice device(&hub, 0x02); + + device.send_pdu(read_pdu()); + hub.force_send_next(); + hub.clear_tx_queue_for_address(0x02); + ASSERT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED); + + hub.timeout_waiting(); // no reply ever arrives; the watchdog releases the shell + + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); + EXPECT_EQ(device.no_response_count_, 1); // the in-flight request got its on_no_response + EXPECT_EQ(device.not_sent_count_, 0); // no un-run duplicate +} + +// Clearing an interrupted (not-yet-notified) frame keeps its distrust: it becomes an +// INTERRUPTED_RETIRED shell that still ends in on_no_response at the timeout - never delivering a +// late response as on_response. No duplicate here, so no on_not_sent. +TEST(ModbusClientHubQueue, ClearInterruptedFrameGetsNoResponseAtTimeout) { + NoResponseProbeHub hub; + DataCountingDevice device(&hub, 0x02); // declines the retry (retries_ == 0) + + device.send_pdu(read_pdu()); + hub.force_send_next(); + const uint8_t stray_pdu[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x07, stray_pdu); // wrong address: interrupts the transaction + hub.sweep_for_test(); + ASSERT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED); + + hub.clear_tx_queue_for_address(0x02); + ASSERT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED_RETIRED); + + // A late MATCHING response is ignored (distrust survives the clear), not delivered as on_response. + const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x02, ok_response); + EXPECT_EQ(device.data_count_, 0); + ASSERT_TRUE(hub.waiting()); // still held; the ignored response did not free the wire + + hub.timeout_waiting(); + + EXPECT_EQ(device.no_response_count_, 1); // the interrupted request's usual terminal, at the timeout + EXPECT_EQ(device.not_sent_count_, 0); // no un-run duplicate + EXPECT_EQ(hub.queued_frames(), 0u); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// The other order: clear a WAITING frame, THEN an unexpected frame arrives. The distrust must still +// take hold - the cleared shell becomes INTERRUPTED_RETIRED and a later matching frame is ignored. +TEST(ModbusClientHubQueue, InterruptAfterClearStillDistrusts) { + NoResponseProbeHub hub; + DataCountingDevice device(&hub, 0x02); + + device.send_pdu(read_pdu()); + hub.force_send_next(); + hub.clear_tx_queue_for_address(0x02); + ASSERT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED); + + const uint8_t stray_pdu[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x07, stray_pdu); // unexpected frame interrupts the cleared shell + ASSERT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED_RETIRED); + + const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x02, ok_response); // now-distrusted late response is ignored + EXPECT_EQ(device.data_count_, 0); + + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// A cleared waiting duplicate (pending 2) that times out: the duplicate drains to on_not_sent and +// the in-flight request gets on_no_response, with nothing re-transmitted (sweep runs before timeout). +TEST(ModbusClientHubQueue, ClearedInFlightDuplicateTimesOutWithoutRerunning) { + NoResponseProbeHub hub; + DataCountingDevice device(&hub, 0x02); + + device.send_pdu(read_pdu()); + device.send_pdu(read_pdu()); // absorbed: one entry, pending 2 + ASSERT_EQ(hub.queued(0).pending, 2u); + hub.force_send_next(); // sent, pending still 2 + hub.clear_tx_queue_for_address(0x02); + + hub.timeout_waiting(); + + EXPECT_EQ(device.not_sent_count_, 1); // the un-run duplicate + EXPECT_EQ(device.no_response_count_, 1); // the in-flight request's usual terminal + EXPECT_EQ(hub.queued_frames(), 0u); // nothing re-transmitted + EXPECT_EQ(hub.entries(), 0u); // fully drained and erased + EXPECT_FALSE(hub.waiting()); +} + +// An absorbed extra request also gets its run after an error response - the re-request was +// explicit, so it runs once more whether this attempt succeeded or not. +TEST(ModbusClientHubCallbackCount, AbsorbedRequestRunsAfterErrorResponse) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + DataCountingDevice device(&hub, 0x02); + + device.send_pdu(read_pdu()); + hub.force_send_next(); + device.send_pdu(read_pdu()); // waiting duplicate: absorbed + const uint8_t exception_response[] = {0x83, 0x02}; + hub.receive_frame_for_test(0x02, exception_response); // error terminal for request 1 + + EXPECT_EQ(device.error_count_, 1); + ASSERT_EQ(hub.queued_frames(), 1u); // request 2's run still queued + EXPECT_EQ(hub.queued(0).pending, 1u); +} + +// Read-modify-write function codes mutate registers, so they rank as WRITE for transmit ordering. +TEST(ModbusClientHubPriority, ReadModifyWritesRankAsWrites) { + NoResponseProbeHub hub; + SentCountingDevice device(&hub, 0x02); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; + const uint8_t mask_write[] = {0x16, 0x00, 0x10, 0x00, 0xFF, 0x00, 0x01}; + device.send_pdu(read); + device.send_pdu(mask_write); + + ASSERT_EQ(hub.queued_frames(), 2u); + const ModbusDeviceCommand *next = hub.next_ready(); + ASSERT_NE(next, nullptr); + EXPECT_EQ(next->priority(), CommandPriority::WRITE); // 0x16 wins selection over the queued read + EXPECT_EQ(next->frame.pdu()[0], 0x16); +} } // namespace esphome::modbus::testing From e36d7fe82b39add661c17870b3b0a766c257a1af Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:08:57 -0500 Subject: [PATCH 1171/1815] Bump bundled esphome-device-builder to 1.9.0 (#18017) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index e58efd1ee0..a7aaea6b60 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.8.2 +RUN uv pip install --no-cache-dir esphome-device-builder==1.9.0 RUN \ platformio settings set enable_telemetry No \ From fcc2c26367dd2f877bd443ebf4d1f99606442ab2 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 2 Aug 2026 09:40:34 -0700 Subject: [PATCH 1172/1815] [modbus] Restore function code byte for custom codes in deprecated on_modbus_data (#18006) --- esphome/components/modbus/modbus.h | 7 +++- .../modbus/modbus_client_device_test.cpp | 41 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index c49f52df55..c73aa6878d 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -550,7 +550,12 @@ class ESPDEPRECATED("Subclass ModbusClientDevice and override on_response()/on_e virtual void on_modbus_error(uint8_t function_code, uint8_t exception_code) {} void on_response(std::span request_pdu, std::span response_pdu) override { - auto payload = helpers::server_pdu_payload(response_pdu); + // Custom (user-defined) function codes historically delivered the payload starting AT the function + // code byte (frame data_offset 1). server_pdu_payload() drops that byte, so pass the whole PDU for + // them - external components match the first byte against the code they sent (issue #17994). + auto payload = !response_pdu.empty() && helpers::is_function_code_custom(response_pdu[0]) + ? response_pdu + : helpers::server_pdu_payload(response_pdu); this->on_modbus_data(std::vector(payload.begin(), payload.end())); } void on_error(std::span request_pdu, ExceptionCode exception_code) override { diff --git a/tests/components/modbus/modbus_client_device_test.cpp b/tests/components/modbus/modbus_client_device_test.cpp index 8638c37688..38c28ce2df 100644 --- a/tests/components/modbus/modbus_client_device_test.cpp +++ b/tests/components/modbus/modbus_client_device_test.cpp @@ -341,4 +341,45 @@ TEST(ModbusTypedDispatch, SingleWriteAckPrefersTheResponseEcho) { EXPECT_EQ(device.write_single_register_calls.back().value, 0x002A); // exception: request copy } +// Deprecated on_modbus_data() compatibility shim (pre-2026.8 API). Records the vectors delivered to +// the old callback so we can pin its payload framing against the pre-2026.7 behavior. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +namespace { +class LegacyDevice : public ModbusDevice { + public: + void on_modbus_data(const std::vector &data) override { this->data_calls.push_back(data); } + void on_modbus_error(uint8_t function_code, uint8_t exception_code) override { + this->error_calls.emplace_back(function_code, exception_code); + } + std::vector> data_calls; + std::vector> error_calls; +}; +} // namespace + +// Custom (user-defined) function codes historically delivered the payload INCLUDING the function code +// byte (frame data_offset 1). External components such as the Century VS pump match that first byte +// against the code they sent, so dropping it (issue #17994) makes every response get ignored. +TEST(ModbusLegacyShim, CustomFunctionCodeKeepsFunctionCodeByte) { + LegacyDevice device; + const uint8_t request[] = {0x45, 0x01, 0x02}; // custom function 0x45 + const uint8_t response[] = {0x45, 0xAA, 0xBB, 0xCC}; // echo of the custom code + data + device.on_response(request, response); + + ASSERT_EQ(device.data_calls.size(), 1u); + EXPECT_EQ(device.data_calls.front(), (std::vector{0x45, 0xAA, 0xBB, 0xCC})); +} + +// Standard reads still strip the function code and byte-count header, matching the pre-2026.7 shim. +TEST(ModbusLegacyShim, StandardReadStripsHeader) { + LegacyDevice device; + const uint8_t request[] = {0x03, 0x01, 0x00, 0x00, 0x02}; + const uint8_t response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + device.on_response(request, response); + + ASSERT_EQ(device.data_calls.size(), 1u); + EXPECT_EQ(device.data_calls.front(), (std::vector{0x00, 0x2A, 0x01, 0x00})); +} +#pragma GCC diagnostic pop + } // namespace esphome::modbus::testing From d6ad1560faeb2981c67df9626fe377e9885f8623 Mon Sep 17 00:00:00 2001 From: Christoph Walcher Date: Sun, 2 Aug 2026 23:16:21 +0200 Subject: [PATCH 1173/1815] [nrf52] feat: add support for zephyr pwm (#16483) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: tomaszduda23 --- CODEOWNERS | 1 + esphome/components/adc/sensor.py | 22 ++- esphome/components/zephyr/__init__.py | 37 ++-- esphome/components/zephyr/const.py | 2 +- esphome/components/zephyr_pwm/__init__.py | 1 + esphome/components/zephyr_pwm/output.py | 177 ++++++++++++++++++ esphome/components/zephyr_pwm/zephyr_pwm.cpp | 39 ++++ esphome/components/zephyr_pwm/zephyr_pwm.h | 31 +++ .../components/light/test.nrf52-adafruit.yaml | 34 ++-- tests/components/light/test.nrf52-mcumgr.yaml | 34 ++-- tests/components/zephyr_pwm/common.yaml | 9 + .../zephyr_pwm/test.nrf52-adafruit.yaml | 1 + tests/unit_tests/test_nrf52_upload.py | 4 +- 13 files changed, 329 insertions(+), 63 deletions(-) create mode 100644 esphome/components/zephyr_pwm/__init__.py create mode 100644 esphome/components/zephyr_pwm/output.py create mode 100644 esphome/components/zephyr_pwm/zephyr_pwm.cpp create mode 100644 esphome/components/zephyr_pwm/zephyr_pwm.h create mode 100644 tests/components/zephyr_pwm/common.yaml create mode 100644 tests/components/zephyr_pwm/test.nrf52-adafruit.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 491371f9f4..cb1be26a61 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -628,6 +628,7 @@ esphome/components/xpt2046/touchscreen/* @nielsnl68 @numo68 esphome/components/xxtea/* @clydebarrow esphome/components/zephyr/* @tomaszduda23 esphome/components/zephyr_mcumgr/ota/* @tomaszduda23 +esphome/components/zephyr_pwm/* @wiomoc esphome/components/zhlt01/* @cfeenstra1024 esphome/components/zigbee/* @luar123 @tomaszduda23 esphome/components/zio_ultrasonic/* @kahrendt diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index 86e2b771ab..c5a4288c07 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -10,8 +10,8 @@ from esphome.components.esp32 import ( from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC from esphome.components.zephyr import ( zephyr_add_overlay, + zephyr_add_overlay_builder, zephyr_add_prj_conf, - zephyr_add_user, ) from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv @@ -113,6 +113,18 @@ CONFIG_SCHEMA = cv.All( CONF_ADC_CHANNEL_ID = "adc_channel_id" +def _overlay_io_channels(): + channel_count = CORE.data[CONF_ADC_CHANNEL_ID] + entries = ", ".join(f"<&adc {channel_id}>" for channel_id in range(channel_count)) + return f""" + / {{ + zephyr,user {{ + io-channels = {entries}; + }}; + }}; + """ + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -173,9 +185,8 @@ async def to_code(config): if isinstance(pin_number, int): GPIO_TO_AIN = {v: k for k, v in AIN_TO_GPIO.items()} pin_number = GPIO_TO_AIN[pin_number] - zephyr_add_user("io-channels", f"<&adc {channel_id}>") - zephyr_add_overlay( - f""" + zephyr_add_overlay_builder(_overlay_io_channels) + zephyr_add_overlay(f""" &adc {{ #address-cells = <1>; #size-cells = <0>; @@ -190,8 +201,7 @@ async def to_code(config): zephyr,oversampling = <8>; }}; }}; - """ - ) + """) FILTER_SOURCE_FILES = filter_source_files_from_platform( diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index 524dc55a13..9f755a6eea 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from pathlib import Path import textwrap from typing import TypedDict @@ -16,10 +17,10 @@ from .const import ( KEY_EXTRA_BUILD_FILES, KEY_KCONFIG, KEY_OVERLAY, + KEY_OVERLAY_BUILDER, KEY_PM_STATIC, KEY_PRJ_CONF, KEY_SYSBUILD, - KEY_USER, KEY_ZEPHYR, zephyr_ns, ) @@ -73,9 +74,9 @@ class ZephyrData(TypedDict): overlay: dict[str, str] extra_build_files: dict[str, Path] pm_static: list[Section] - user: dict[str, list[str]] kconfig: str sysbuild: bool + overlay_builder: list[Callable[[], str]] def zephyr_set_core_data(config: ConfigType) -> None: @@ -86,9 +87,9 @@ def zephyr_set_core_data(config: ConfigType) -> None: overlay={ "": "", }, # set empty to make sure that overlay is cleared after config change + overlay_builder=[], extra_build_files={}, pm_static=[], - user={}, kconfig="", # When OTA is disabled, the image is built without a bootloader even if the # config says `bootloader: mcuboot`, so the image can be smaller. This was @@ -132,6 +133,12 @@ def zephyr_add_overlay(content: str, image: str = "") -> None: data[KEY_OVERLAY][image] += textwrap.dedent(content) +def zephyr_add_overlay_builder(func: Callable[[], str]) -> None: + data = zephyr_data() + if func not in data[KEY_OVERLAY_BUILDER]: + data[KEY_OVERLAY_BUILDER].append(func) + + def add_extra_build_file(filename: str, path: Path) -> bool: """Add an extra build file to the project.""" extra_build_files = zephyr_data()[KEY_EXTRA_BUILD_FILES] @@ -222,13 +229,6 @@ def zephyr_add_pm_static(sections: list[Section]) -> None: zephyr_data()[KEY_PM_STATIC].extend(sections) -def zephyr_add_user(key, value): - user = zephyr_data()[KEY_USER] - if key not in user: - user[key] = [] - user[key] += [value] - - def _write_file_if_changed_or_remove_when_empty(path: Path, content: str) -> bool: """Write content to path, or remove a stale file when content is empty. @@ -243,20 +243,9 @@ def _write_file_if_changed_or_remove_when_empty(path: Path, content: str) -> boo def copy_files() -> None: - user = zephyr_data()[KEY_USER] - if user: - entries = " ".join( - f"{key} = {', '.join(value)};" for key, value in user.items() - ) - zephyr_add_overlay( - f""" - / {{ - zephyr,user {{ - {entries} - }}; - }}; - """ - ) + for builder_func in zephyr_data()[KEY_OVERLAY_BUILDER]: + overlay_contents = builder_func() + zephyr_add_overlay(overlay_contents) changed = False diff --git a/esphome/components/zephyr/const.py b/esphome/components/zephyr/const.py index 497e5f3ce5..0bb8d33a1f 100644 --- a/esphome/components/zephyr/const.py +++ b/esphome/components/zephyr/const.py @@ -7,12 +7,12 @@ BOOTLOADER_MCUBOOT = "mcuboot" KEY_BOOTLOADER: Final = "bootloader" KEY_EXTRA_BUILD_FILES: Final = "extra_build_files" KEY_OVERLAY: Final = "overlay" +KEY_OVERLAY_BUILDER: Final = "overlay_builder" KEY_PM_STATIC: Final = "pm_static" KEY_KCONFIG: Final = "kconfig" KEY_PRJ_CONF: Final = "prj_conf" KEY_ZEPHYR = "zephyr" KEY_BOARD: Final = "board" -KEY_USER: Final = "user" KEY_SYSBUILD: Final = "sysbuild" zephyr_ns = cg.esphome_ns.namespace("zephyr") diff --git a/esphome/components/zephyr_pwm/__init__.py b/esphome/components/zephyr_pwm/__init__.py new file mode 100644 index 0000000000..4bcce84845 --- /dev/null +++ b/esphome/components/zephyr_pwm/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@wiomoc"] diff --git a/esphome/components/zephyr_pwm/output.py b/esphome/components/zephyr_pwm/output.py new file mode 100644 index 0000000000..54c04473e3 --- /dev/null +++ b/esphome/components/zephyr_pwm/output.py @@ -0,0 +1,177 @@ +from dataclasses import dataclass, field + +from esphome import pins +import esphome.codegen as cg +from esphome.components import output +from esphome.components.zephyr import zephyr_add_overlay_builder, zephyr_add_prj_conf +import esphome.config_validation as cv +from esphome.const import ( + CONF_ALLOW_OTHER_USES, + CONF_FREQUENCY, + CONF_ID, + CONF_INVERTED, + CONF_NUMBER, + CONF_OUTPUT, + CONF_PIN, + CONF_PLATFORM, +) +from esphome.core import CORE +import esphome.final_validate as fv +from esphome.types import ConfigType + +DEPENDENCIES = ["zephyr"] +DOMAIN = "zephyr_pwm" + +zephyr_pwm_ns = cg.esphome_ns.namespace("zephyr_pwm") +ZephyrPWMChannel = zephyr_pwm_ns.class_( + "ZephyrPWMChannel", output.FloatOutput, cg.Component +) +validate_frequency = cv.All(cv.frequency, cv.float_range(min=3.815, max=1e7)) + + +def _pin_schema(value): + value = pins.internal_gpio_output_pin_schema(value) + if value.get(CONF_ALLOW_OTHER_USES, False): + raise cv.Invalid("allow_other_uses is not supported for zephyr_pwm pins") + return value + + +CONFIG_SCHEMA = cv.All( + output.FLOAT_OUTPUT_SCHEMA.extend( + { + cv.Required(CONF_ID): cv.declare_id(ZephyrPWMChannel), + cv.Required(CONF_PIN): _pin_schema, + cv.Optional(CONF_FREQUENCY, default="1kHz"): validate_frequency, + } + ).extend(cv.COMPONENT_SCHEMA), + cv.only_on_nrf52, +) + +PWM_BLOCK_COUNT = 4 +PWM_CHANNELS_PER_BLOCK = 4 + + +@dataclass +class PWMBlock: + id: int + period_ns: int + pins: list[int] + + +@dataclass +class ZephyrPWMData: + pwm_blocks: list[PWMBlock] = field(default_factory=list) + + +def _get_data() -> ZephyrPWMData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = ZephyrPWMData() + return CORE.data[DOMAIN] + + +def _allocate_blocks() -> None: + full_config = fv.full_config.get() + zephyr_pwm_conf = [ + cfg + for cfg in full_config.get(CONF_OUTPUT, []) + if cfg.get(CONF_PLATFORM) == DOMAIN + ] + + pwm_blocks: list[PWMBlock] = [] + for cfg in zephyr_pwm_conf: + pin_number = cfg[CONF_PIN][CONF_NUMBER] + period_ns = int(1e9 / cfg[CONF_FREQUENCY]) + pwm_block = next( + ( + block + for block in pwm_blocks + if block.period_ns == period_ns + and len(block.pins) < PWM_CHANNELS_PER_BLOCK + ), + None, + ) + if pwm_block is None: + if len(pwm_blocks) >= PWM_BLOCK_COUNT: + raise cv.Invalid( + f"Only {PWM_BLOCK_COUNT} PWM blocks with a distinct frequency and {PWM_CHANNELS_PER_BLOCK} channels each are supported by nrf52" + ) + pwm_block = PWMBlock(id=len(pwm_blocks), period_ns=period_ns, pins=[]) + pwm_blocks.append(pwm_block) + pwm_block.pins.append(pin_number) + + _get_data().pwm_blocks = pwm_blocks + + +def _final_validate(config: ConfigType) -> ConfigType: + _allocate_blocks() + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +def _overlay_pwm(): + pwm_blocks: list[PWMBlock] = _get_data().pwm_blocks + + assert CORE.is_nrf52 + + overlay_parts = [] + + overlay_parts.extend( + f""" + &pwm{block.id} {{ + status = "okay"; + pinctrl-0 = <&pwm{block.id}_default_custom>; + pinctrl-1 = <&pwm{block.id}_sleep_custom>; + pinctrl-names = "default", "sleep"; + }};""" + for block in pwm_blocks + ) + + pinctls = [] + for block in pwm_blocks: + psels = ", ".join( + f"" + for channel_id, pin in enumerate(block.pins) + ) + pinctls.append(f""" + pwm{block.id}_default_custom: pwm{block.id}_default_custom {{ + group1 {{ + psels = {psels}; + }}; + }}; + pwm{block.id}_sleep_custom: pwm{block.id}_sleep_custom {{ + group1 {{ + psels = {psels}; + low-power-enable; + }}; + }};""") + + overlay_parts.append(f""" + &pinctrl {{ + {"\n".join(pinctls)} + }};""") + return "\n".join(overlay_parts) + + +async def to_code(config): + zephyr_add_prj_conf("PWM", True) + pin = config[CONF_PIN] + pwm_blocks: list[PWMBlock] = _get_data().pwm_blocks + pwm_block = next( + (block for block in pwm_blocks if pin[CONF_NUMBER] in block.pins), None + ) + channel_id = pwm_block.pins.index(pin[CONF_NUMBER]) + + zephyr_add_overlay_builder(_overlay_pwm) + + pin_inverted = pin.get(CONF_INVERTED, False) + var = cg.new_Pvariable( + config[CONF_ID], + cg.RawExpression(f"DEVICE_DT_GET_OR_NULL(DT_NODELABEL(pwm{pwm_block.id}))"), + channel_id, + pin_inverted, + pwm_block.period_ns, + ) + await cg.register_component(var, config) + await output.register_output(var, config) diff --git a/esphome/components/zephyr_pwm/zephyr_pwm.cpp b/esphome/components/zephyr_pwm/zephyr_pwm.cpp new file mode 100644 index 0000000000..aa1393388a --- /dev/null +++ b/esphome/components/zephyr_pwm/zephyr_pwm.cpp @@ -0,0 +1,39 @@ +#ifdef USE_ZEPHYR + +#include "zephyr_pwm.h" + +#include + +namespace esphome::zephyr_pwm { + +static const char *const TAG = "zephyr_pwm"; + +void ZephyrPWMChannel::setup() { + if (!device_is_ready(this->device_)) { + ESP_LOGE(TAG, "PWM is not ready."); + this->mark_failed(); + return; + } +} + +void ZephyrPWMChannel::dump_config() { + ESP_LOGCONFIG(TAG, + "Zephyr PWM:\n" + " Channel: %u\n" + " Period: %u ns", + this->channel_, this->period_ns_); + LOG_FLOAT_OUTPUT(this); +} +void HOT ZephyrPWMChannel::write_state(float state) { + uint32_t pulse_width_ns = state * this->period_ns_; + pwm_flags_t flags = this->pin_inverted_ ? PWM_POLARITY_INVERTED : PWM_POLARITY_NORMAL; + int err = pwm_set(this->device_, this->channel_, this->period_ns_, pulse_width_ns, flags); + if (err != 0) { + ESP_LOGE(TAG, "Failed to set PWM output: channel=%u, period=%u ns, pulse_width=%u ns, error=%d", this->channel_, + this->period_ns_, pulse_width_ns, err); + } +} + +} // namespace esphome::zephyr_pwm + +#endif // USE_ZEPHYR diff --git a/esphome/components/zephyr_pwm/zephyr_pwm.h b/esphome/components/zephyr_pwm/zephyr_pwm.h new file mode 100644 index 0000000000..cfec0049a5 --- /dev/null +++ b/esphome/components/zephyr_pwm/zephyr_pwm.h @@ -0,0 +1,31 @@ +#pragma once + +#ifdef USE_ZEPHYR +#include "esphome/core/defines.h" +#include "esphome/components/output/float_output.h" + +#include + +namespace esphome::zephyr_pwm { + +class ZephyrPWMChannel : public output::FloatOutput, public Component { + public: + explicit ZephyrPWMChannel(const struct device *device, uint8_t channel, bool pin_inverted, uint32_t period_ns) + : device_(device), channel_(channel), pin_inverted_(pin_inverted), period_ns_(period_ns) {} + + void setup() override; + void dump_config() override; + /// HARDWARE setup_priority + float get_setup_priority() const override { return setup_priority::HARDWARE; } + + protected: + void write_state(float state) override; + + const struct device *device_; + uint8_t channel_; + bool pin_inverted_; + uint32_t period_ns_; +}; +} // namespace esphome::zephyr_pwm + +#endif // USE_ZEPHYR diff --git a/tests/components/light/test.nrf52-adafruit.yaml b/tests/components/light/test.nrf52-adafruit.yaml index 60521b8088..08f5f39810 100644 --- a/tests/components/light/test.nrf52-adafruit.yaml +++ b/tests/components/light/test.nrf52-adafruit.yaml @@ -1,19 +1,23 @@ -esphome: - on_boot: - then: - - light.toggle: test_binary_light - output: - platform: gpio id: light_test_binary - pin: 0 + pin: 12 + - platform: zephyr_pwm + id: test_ledc_1 + pin: 13 + - platform: zephyr_pwm + id: test_ledc_2 + pin: + number: 14 + inverted: true + - platform: zephyr_pwm + id: test_ledc_3 + pin: 15 + - platform: zephyr_pwm + id: test_ledc_4 + pin: 16 + - platform: zephyr_pwm + id: test_ledc_5 + pin: 17 -light: - - platform: binary - id: test_binary_light - name: Binary Light - output: light_test_binary - effects: - - strobe: - on_state: - - logger.log: Binary light state changed +<<: !include common.yaml diff --git a/tests/components/light/test.nrf52-mcumgr.yaml b/tests/components/light/test.nrf52-mcumgr.yaml index 60521b8088..08f5f39810 100644 --- a/tests/components/light/test.nrf52-mcumgr.yaml +++ b/tests/components/light/test.nrf52-mcumgr.yaml @@ -1,19 +1,23 @@ -esphome: - on_boot: - then: - - light.toggle: test_binary_light - output: - platform: gpio id: light_test_binary - pin: 0 + pin: 12 + - platform: zephyr_pwm + id: test_ledc_1 + pin: 13 + - platform: zephyr_pwm + id: test_ledc_2 + pin: + number: 14 + inverted: true + - platform: zephyr_pwm + id: test_ledc_3 + pin: 15 + - platform: zephyr_pwm + id: test_ledc_4 + pin: 16 + - platform: zephyr_pwm + id: test_ledc_5 + pin: 17 -light: - - platform: binary - id: test_binary_light - name: Binary Light - output: light_test_binary - effects: - - strobe: - on_state: - - logger.log: Binary light state changed +<<: !include common.yaml diff --git a/tests/components/zephyr_pwm/common.yaml b/tests/components/zephyr_pwm/common.yaml new file mode 100644 index 0000000000..248499951e --- /dev/null +++ b/tests/components/zephyr_pwm/common.yaml @@ -0,0 +1,9 @@ +output: + - platform: zephyr_pwm + id: pwm_output_1 + pin: P0.02 + - platform: zephyr_pwm + id: pwm_output_2 + pin: + number: 10 + inverted: true diff --git a/tests/components/zephyr_pwm/test.nrf52-adafruit.yaml b/tests/components/zephyr_pwm/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/zephyr_pwm/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/unit_tests/test_nrf52_upload.py b/tests/unit_tests/test_nrf52_upload.py index 9b738ebc81..9091b429f6 100644 --- a/tests/unit_tests/test_nrf52_upload.py +++ b/tests/unit_tests/test_nrf52_upload.py @@ -13,9 +13,9 @@ from esphome.components.zephyr.const import ( KEY_EXTRA_BUILD_FILES, KEY_KCONFIG, KEY_OVERLAY, + KEY_OVERLAY_BUILDER, KEY_PM_STATIC, KEY_PRJ_CONF, - KEY_USER, KEY_ZEPHYR, ) import esphome.config_validation as cv @@ -53,9 +53,9 @@ def _setup_nrf52_core( KEY_BOOTLOADER: bootloader, KEY_PRJ_CONF: {}, KEY_OVERLAY: {"": ""}, + KEY_OVERLAY_BUILDER: [], KEY_EXTRA_BUILD_FILES: {}, KEY_PM_STATIC: [], - KEY_USER: {}, KEY_KCONFIG: "", } From 74fbee7d8d24f8ed66d1dcdcbfb9f921d309f02f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Aug 2026 19:28:22 -0500 Subject: [PATCH 1174/1815] [ble_device_base] Share scan parameter validation between trackers (#18003) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/bk72xx_ble_tracker/__init__.py | 81 ++--------------- .../components/ble_device_base/__init__.py | 88 +++++++++++++++++++ .../components/esp32_ble_tracker/__init__.py | 49 +++-------- .../ble_device_base/__init__.py | 0 .../test_scan_parameter_validation.py | 49 ++++++++--- 5 files changed, 140 insertions(+), 127 deletions(-) create mode 100644 tests/component_tests/ble_device_base/__init__.py rename tests/component_tests/{bk72xx_ble_tracker => ble_device_base}/test_scan_parameter_validation.py (70%) diff --git a/esphome/components/bk72xx_ble_tracker/__init__.py b/esphome/components/bk72xx_ble_tracker/__init__.py index 39c9ada731..e53e8e13d7 100644 --- a/esphome/components/bk72xx_ble_tracker/__init__.py +++ b/esphome/components/bk72xx_ble_tracker/__init__.py @@ -39,79 +39,10 @@ BK72xxBLETracker = bk72xx_ble_tracker_ns.class_( ) -def to_ble_units(value: cv.TimePeriod) -> int: - """Convert a scan time to the controller's 0.625 ms units. - - Used by both validation and codegen so what is validated is exactly what is - programmed — the truncation here is what makes the duty-cycle check below - meaningful. - """ - return value.total_microseconds // 625 - - -def validate_scan_parameters(config: ConfigType) -> ConfigType: - """Reject impossible window/interval/duration combinations at config time. - - Mirrors esp32_ble_tracker: the controller cannot scan for longer than the - interval, and a too-short duration would end the scan period almost - immediately. Catching it here gives a clear error instead of a runtime - controller failure and the 1/sec retry loop. - """ - duration = config[CONF_DURATION] - interval = config[CONF_INTERVAL] - window = config[CONF_WINDOW] - - if window > interval: - raise cv.Invalid( - f"Scan window ({window}) needs to be smaller than scan interval ({interval})" - ) - - # BLE scan interval/window are programmed in 0.625 ms units as a 16-bit value; the - # controller only accepts 2.5 ms .. 10240 ms (0x0004 .. 0x4000). Reject out-of-range - # values here instead of letting the unit conversion silently overflow. - for name, value in (("interval", interval), ("window", window)): - if value.total_microseconds < 2500 or value.total_microseconds > 10_240_000: - raise cv.Invalid( - f"Scan {name} ({value}) must be between 2.5 ms and 10240 ms" - ) - - # Validate what actually reaches the controller: both values are truncated to - # whole 0.625 ms units, so a window/interval pair that differs by less than one - # unit collapses to the same value — silently programming a 100 % duty cycle - # (radio permanently on) from a config that asked for less. - interval_units = to_ble_units(interval) - window_units = to_ble_units(window) - if window_units == interval_units and window < interval: - raise cv.Invalid( - f"Scan window ({window}) and interval ({interval}) both round to " - f"{interval_units} x 0.625 ms, which the controller scans at a 100 % duty " - f"cycle. Separate them by at least 0.625 ms." - ) - - if interval.total_microseconds * 3 > duration.total_microseconds: - raise cv.Invalid( - f"Scan duration ({duration}) must cover at least three scan intervals " - f"({interval}): the scanner listens on one of the three BLE advertising " - f"channels per interval, so a shorter duration can miss devices entirely." - ) - - return config - - -SCAN_PARAMETERS_SCHEMA = cv.All( - cv.Schema( - { - cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds, - # interval/window default to the BK reference scan rate — 100 ms / 30 ms, - # a 30 % duty cycle. Converted to the controller's 0.625 ms BLE units in - # to_code(). (LN882H's SDK recommends a different 100 / 50 ms = 50 %.) - cv.Optional(CONF_INTERVAL, default="100ms"): cv.positive_time_period, - cv.Optional(CONF_WINDOW, default="30ms"): cv.positive_time_period, - cv.Optional(CONF_CONTINUOUS, default=True): cv.boolean, - } - ), - validate_scan_parameters, -) +# interval defaults to the BK reference scan rate — 100 ms with the shared 30 ms +# window, a 30 % duty cycle. Converted to the controller's 0.625 ms BLE units in +# to_code(). (LN882H's SDK recommends a different 100 / 50 ms = 50 %.) +SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("100ms") CONFIG_SCHEMA = cv.Schema( { @@ -143,8 +74,8 @@ async def to_code(config: ConfigType) -> None: ota.request_ota_state_listeners() scan = config[CONF_SCAN_PARAMETERS] - cg.add(var.set_scan_interval(to_ble_units(scan[CONF_INTERVAL]))) - cg.add(var.set_scan_window(to_ble_units(scan[CONF_WINDOW]))) + cg.add(var.set_scan_interval(ble_device_base.to_ble_units(scan[CONF_INTERVAL]))) + cg.add(var.set_scan_window(ble_device_base.to_ble_units(scan[CONF_WINDOW]))) cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds)) cg.add(var.set_scan_continuous(scan[CONF_CONTINUOUS])) diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index d9789b0e9f..8100b41d99 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -20,7 +20,9 @@ ble_aes_ccm.h. import re import esphome.codegen as cg +from esphome.components.const import CONF_WINDOW import esphome.config_validation as cv +from esphome.const import CONF_ACTIVE, CONF_CONTINUOUS, CONF_DURATION, CONF_INTERVAL from esphome.core import CORE from esphome.types import ConfigType @@ -76,6 +78,92 @@ async def register_ble_device(var: cg.MockObj, config: ConfigType) -> cg.MockObj # ---- shared validation / codegen helpers (platform-neutral) ---- + + +def to_ble_units(value: cv.TimePeriod) -> int: + """Convert a scan time to the controller's 0.625 ms units. + + Used by both validation and codegen so what is validated is exactly what is + programmed — the truncation here is what makes the duty-cycle check below + meaningful. + """ + return value.total_microseconds // 625 + + +def validate_scan_parameters(config: ConfigType) -> ConfigType: + """Reject impossible window/interval/duration combinations at config time. + + The controller cannot scan for longer than the interval, and a too-short + duration would end the scan period almost immediately. Catching it here + gives a clear error instead of a runtime controller failure and a retry + loop. + """ + duration = config[CONF_DURATION] + interval = config[CONF_INTERVAL] + window = config[CONF_WINDOW] + + if window > interval: + raise cv.Invalid( + f"Scan window ({window}) needs to be smaller than scan interval ({interval})" + ) + + # BLE scan interval/window are programmed in 0.625 ms units as a 16-bit value; the + # controller only accepts 2.5 ms .. 10240 ms (0x0004 .. 0x4000). Reject out-of-range + # values here instead of letting the unit conversion silently overflow. + for name, value in (("interval", interval), ("window", window)): + if value.total_microseconds < 2500 or value.total_microseconds > 10_240_000: + raise cv.Invalid( + f"Scan {name} ({value}) must be between 2.5 ms and 10240 ms" + ) + + # Validate what actually reaches the controller: both values are truncated to + # whole 0.625 ms units, so a window/interval pair that differs by less than one + # unit collapses to the same value — silently programming a 100 % duty cycle + # (radio permanently on) from a config that asked for less. + interval_units = to_ble_units(interval) + window_units = to_ble_units(window) + if window_units == interval_units and window < interval: + raise cv.Invalid( + f"Scan window ({window}) and interval ({interval}) both truncate to " + f"{interval_units} x 0.625 ms, which the controller scans at a 100 % duty " + f"cycle. Separate them by at least 0.625 ms." + ) + + if interval.total_microseconds * 3 > duration.total_microseconds: + raise cv.Invalid( + f"Scan duration ({duration}) must cover at least three scan intervals " + f"({interval}): the scanner listens on one of the three BLE advertising " + f"channels per interval, so a shorter duration can miss devices entirely." + ) + + return config + + +def scan_parameters_schema( + interval_default: str, + *, + window_default: str = "30ms", + supports_active: bool = False, +) -> cv.All: + """Build the scan_parameters value schema shared by all BLE trackers. + + interval_default and window_default are per chip (e.g. esp32 320/30 ms, + bk72xx/rp2 100/30 ms — the reference scan rates of the respective stacks; + LN882H's SDK recommends 100/50 ms). Pass supports_active=True only when + the tracker supports active scanning; it exposes the `active` option + (whose own default is on, esp32_ble_tracker behavior). + """ + schema = { + cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds, + cv.Optional(CONF_INTERVAL, default=interval_default): cv.positive_time_period, + cv.Optional(CONF_WINDOW, default=window_default): cv.positive_time_period, + cv.Optional(CONF_CONTINUOUS, default=True): cv.boolean, + } + if supports_active: + schema[cv.Optional(CONF_ACTIVE, default=True)] = cv.boolean + return cv.All(cv.Schema(schema), validate_scan_parameters) + + BT_UUID16_FORMAT = "XXXX" BT_UUID32_FORMAT = "XXXXXXXX" BT_UUID128_FORMAT = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index e462da1a49..7ffde76429 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -125,25 +125,6 @@ ESP32BLEStopScanAction = esp32_ble_tracker_ns.class_( ) -def validate_scan_parameters(config): - duration = config[CONF_DURATION] - interval = config[CONF_INTERVAL] - window = config[CONF_WINDOW] - - if window > interval: - raise cv.Invalid( - f"Scan window ({window}) needs to be smaller than scan interval ({interval})" - ) - - if interval.total_milliseconds * 3 > duration.total_milliseconds: - raise cv.Invalid( - "Scan duration needs to be at least three times the scan interval to" - "cover all BLE channels." - ) - - return config - - def validate_max_connections_deprecated(config: ConfigType) -> ConfigType: if CONF_MAX_CONNECTIONS in config: _LOGGER.warning( @@ -153,6 +134,13 @@ def validate_max_connections_deprecated(config: ConfigType) -> ConfigType: return config +# 320 ms is the ESP-IDF reference scan interval; the shared schema also +# tightens validation to the controller's 2.5 ms .. 10240 ms range and rejects +# window/interval pairs that collapse to the same 0.625 ms unit count. +SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema( + "320ms", supports_active=True +) + # Codegen helpers are owned by ble_device_base; kept under the historical names # here for the components that import them from this module. as_hex = ble_device_base.as_hex @@ -168,24 +156,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_MAX_CONNECTIONS): cv.All( cv.positive_int, cv.Range(min=0, max=IDF_MAX_CONNECTIONS) ), - cv.Optional(CONF_SCAN_PARAMETERS, default={}): cv.All( - cv.Schema( - { - cv.Optional( - CONF_DURATION, default="5min" - ): cv.positive_time_period_seconds, - cv.Optional( - CONF_INTERVAL, default="320ms" - ): cv.positive_time_period_milliseconds, - cv.Optional( - CONF_WINDOW, default="30ms" - ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_ACTIVE, default=True): cv.boolean, - cv.Optional(CONF_CONTINUOUS, default=True): cv.boolean, - } - ), - validate_scan_parameters, - ), + cv.Optional(CONF_SCAN_PARAMETERS, default={}): SCAN_PARAMETERS_SCHEMA, cv.Optional(CONF_ON_BLE_ADVERTISE): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( @@ -255,8 +226,8 @@ async def to_code(config): params = config[CONF_SCAN_PARAMETERS] cg.add(var.set_scan_duration(params[CONF_DURATION])) - cg.add(var.set_scan_interval(int(params[CONF_INTERVAL].total_milliseconds / 0.625))) - cg.add(var.set_scan_window(int(params[CONF_WINDOW].total_milliseconds / 0.625))) + cg.add(var.set_scan_interval(ble_device_base.to_ble_units(params[CONF_INTERVAL]))) + cg.add(var.set_scan_window(ble_device_base.to_ble_units(params[CONF_WINDOW]))) cg.add(var.set_scan_active(params[CONF_ACTIVE])) cg.add(var.set_scan_continuous(params[CONF_CONTINUOUS])) diff --git a/tests/component_tests/ble_device_base/__init__.py b/tests/component_tests/ble_device_base/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/bk72xx_ble_tracker/test_scan_parameter_validation.py b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py similarity index 70% rename from tests/component_tests/bk72xx_ble_tracker/test_scan_parameter_validation.py rename to tests/component_tests/ble_device_base/test_scan_parameter_validation.py index 968a24dad5..363c129f4b 100644 --- a/tests/component_tests/bk72xx_ble_tracker/test_scan_parameter_validation.py +++ b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py @@ -1,16 +1,20 @@ -"""Tests for bk72xx_ble_tracker scan parameter validation.""" +"""Tests for the shared BLE tracker scan parameter validation.""" from __future__ import annotations import pytest from esphome import config_validation as cv -from esphome.components.bk72xx_ble_tracker import SCAN_PARAMETERS_SCHEMA, to_ble_units +from esphome.components.bk72xx_ble_tracker import ( + SCAN_PARAMETERS_SCHEMA as BK72XX_SCHEMA, +) +from esphome.components.ble_device_base import to_ble_units +from esphome.components.esp32_ble_tracker import SCAN_PARAMETERS_SCHEMA as ESP32_SCHEMA def _validate(**kwargs: str) -> dict: - """Run a scan_parameters config through the schema, applying defaults.""" - return SCAN_PARAMETERS_SCHEMA(dict(kwargs)) + """Run a scan_parameters config through a passive tracker's real schema.""" + return BK72XX_SCHEMA(kwargs) # --- to_ble_units --- @@ -36,14 +40,37 @@ def test_to_ble_units_truncates() -> None: assert to_ble_units(cv.positive_time_period("2500us")) == 4 -# --- accepted configurations --- +# --- the real per-chip schemas --- -def test_defaults_are_valid() -> None: - """The documented default 100 ms / 30 ms pair validates.""" +def test_bk72xx_defaults_are_valid() -> None: + """bk72xx pins the BK reference rate: 100 ms interval, shared 30 ms window.""" config = _validate() assert to_ble_units(config["interval"]) == 160 assert to_ble_units(config["window"]) == 48 + assert "active" not in config + + +def test_esp32_defaults_are_valid() -> None: + """esp32 pins the ESP-IDF reference rate and exposes active (default on).""" + config = ESP32_SCHEMA({}) + assert to_ble_units(config["interval"]) == 512 + assert to_ble_units(config["window"]) == 48 + assert config["active"] is True + + +def test_esp32_active_can_disable() -> None: + config = ESP32_SCHEMA({"active": False}) + assert config["active"] is False + + +def test_passive_schema_rejects_active_key() -> None: + """Trackers without active scan support must not silently accept the option.""" + with pytest.raises(cv.Invalid): + _validate(active="true") + + +# --- accepted configurations --- def test_minimum_separation_accepted() -> None: @@ -100,12 +127,8 @@ def test_out_of_range_rejected(interval: str, window: str, offender: str) -> Non def test_unit_collapse_rejected() -> None: - """Regression: 3000us/2500us both floor to 4 units — a hidden 100 % duty cycle. - - This is the configuration that previously validated and programmed the radio - permanently on despite asking for roughly 83 %. - """ - with pytest.raises(cv.Invalid, match="both round to 4 x 0.625 ms"): + """3000us/2500us both floor to 4 units — a hidden 100 % duty cycle.""" + with pytest.raises(cv.Invalid, match="both truncate to 4 x 0.625 ms"): _validate(interval="3000us", window="2500us") From 2604169080b20841674fc0da2891849bf11bf610 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:46:03 -0500 Subject: [PATCH 1175/1815] Bump bundled esphome-device-builder to 1.9.1 (#18025) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index a7aaea6b60..5f84226b87 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.9.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.9.1 RUN \ platformio settings set enable_telemetry No \ From 67654fd1e1e16dee1d39c9c022db0d9de501472f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Mon, 3 Aug 2026 05:55:20 +0300 Subject: [PATCH 1176/1815] [bk72xx_ble_tracker] Fix scan start retry and stale-millis underflow (#17992) --- .../bk72xx_ble_tracker/bk72xx_ble_tracker.cpp | 103 +++++++++++++----- .../bk72xx_ble_tracker/bk72xx_ble_tracker.h | 8 ++ 2 files changed, 86 insertions(+), 25 deletions(-) diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp index 634285d530..eba440d84d 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp @@ -48,12 +48,21 @@ void BK72xxBLETracker::on_ota_global_state(ota::OTAState state, float progress, ota::OTAComponent *comp) { if (state == ota::OTA_STARTED) { this->scan_continuous_before_ota_ = this->scan_continuous_; + this->scan_requested_before_ota_ = this->scan_requested_; this->stop_scan(); - } else if ((state == ota::OTA_ERROR || state == ota::OTA_ABORT) && this->scan_continuous_before_ota_) { + } else if (state == ota::OTA_ERROR || state == ota::OTA_ABORT) { // On success the device reboots, so restore only on a failed/aborted update; // loop() restarts the scan on its next iteration (continuous idle branch). - this->scan_continuous_before_ota_ = false; - this->scan_continuous_ = true; + if (this->scan_continuous_before_ota_) { + this->scan_continuous_before_ota_ = false; + this->scan_continuous_ = true; + } + // A one-shot request that was still pending (latched, retrying) when the + // OTA paused scanning is re-latched, not dropped — loop() resumes the retry. + if (this->scan_requested_before_ota_) { + this->scan_requested_before_ota_ = false; + this->scan_requested_ = true; + } } } #endif // USE_OTA_STATE_LISTENER @@ -62,25 +71,11 @@ void BK72xxBLETracker::loop() { const uint32_t now = millis(); if (this->scan_continuous_) { if (!this->scan_running_) { - // Rate-limit (re)start attempts. The controller start can fail (no idle activity - // handle, WiFi/BLE coexistence) and leave scan_running_ false; retrying every - // main-loop iteration would spin the single-core CPU and starve WiFi (device - // becomes unresponsive). The interval backs off with consecutive failures so a - // controller that never comes up polls slowly and quietly. - const uint8_t doublings = std::min(this->failed_start_count_, SCAN_START_RETRY_MAX_DOUBLINGS); - if (now - this->last_scan_start_attempt_ >= (SCAN_START_RETRY_MS << doublings)) { - this->last_scan_start_attempt_ = now; - this->start_scan_(); - if (this->scan_running_) { - this->failed_start_count_ = 0; - } else if (this->failed_start_count_ < SCAN_START_RETRY_MAX_DOUBLINGS) { - ++this->failed_start_count_; - if (this->failed_start_count_ == SCAN_START_RETRY_MAX_DOUBLINGS) { - ESP_LOGW(TAG, "Scan start keeps failing; retrying every %" PRIu32 " s", - (SCAN_START_RETRY_MS << SCAN_START_RETRY_MAX_DOUBLINGS) / 1000); - } - } - } + // A start that succeeded re-anchored the period timer from a later millis(), + // so the stale `now` below would underflow the comparison and fire + // on_scan_end() for a scan that just began. Resume next iteration. + if (this->try_start_with_backoff_(now)) + return; } // Period timer: fire on_scan_end() once per scan_duration_ window, mirroring // esp32_ble_tracker::cleanup_scan_state_(). Gated on scan_started_once_ so a scan @@ -98,11 +93,51 @@ void BK72xxBLETracker::loop() { // Non-continuous mode: run for scan_duration_ ms, then stop and fire on_scan_end. // Restart is driven externally (e.g. api: on_client_connected:). + // + // A requested start that failed (same controller failures the continuous branch + // absorbs) is retried with the same backoff — otherwise a failed one-shot start + // would be silent: the scan never runs, stop_scan_() is never reached and + // on_scan_end() never fires, leaving period-keyed consumers waiting forever. + if (this->scan_requested_ && !this->scan_running_) { + // Same stale-`now` hazard as the continuous branch: start_scan_() stamps + // scan_start_time_ from a later millis(), so the duration check below would + // underflow and stop the scan in the iteration that started it. + if (this->try_start_with_backoff_(now)) + return; + } if (this->scan_running_ && now - this->scan_start_time_ >= this->scan_duration_) { this->stop_scan_(); } } +bool BK72xxBLETracker::try_start_with_backoff_(uint32_t now, bool force) { + // Rate-limit (re)start attempts. The controller start can fail (no idle activity + // handle, WiFi/BLE coexistence) and leave scan_running_ false; retrying every + // main-loop iteration would spin the single-core CPU and starve WiFi (device + // becomes unresponsive). The interval backs off with consecutive failures so a + // controller that never comes up polls slowly and quietly. + // + // force bypasses the gate for an explicit user start (start_scan()) — but + // only while the failure streak is clean. Once the controller is failing, + // even user-initiated attempts respect the backoff, so a start_scan() action + // on a short cadence cannot hammer a failing controller; the attempt stays + // inside the failure accounting below either way. + const uint8_t doublings = std::min(this->failed_start_count_, SCAN_START_RETRY_MAX_DOUBLINGS); + if ((!force || this->failed_start_count_ != 0) && + now - this->last_scan_start_attempt_ < (SCAN_START_RETRY_MS << doublings)) + return false; + this->last_scan_start_attempt_ = now; + this->start_scan_(); + if (!this->scan_running_ && this->failed_start_count_ < SCAN_START_RETRY_MAX_DOUBLINGS) { + ++this->failed_start_count_; + if (this->failed_start_count_ == SCAN_START_RETRY_MAX_DOUBLINGS) { + ESP_LOGW(TAG, "Scan start keeps failing; retrying every %" PRIu32 " s", + (SCAN_START_RETRY_MS << SCAN_START_RETRY_MAX_DOUBLINGS) / 1000); + } + } + return this->scan_running_; +} + void BK72xxBLETracker::dump_config() { ESP_LOGCONFIG(TAG, "BK72xx BLE Tracker:\n" @@ -153,13 +188,29 @@ void BK72xxBLETracker::on_scan_report(const bk72xx_ble::BLEScanReport &report) { void BK72xxBLETracker::start_scan() { // Mirrors esp32_ble_tracker::start_scan(): caller sets scan_continuous_ via // set_scan_continuous() first, then calls start_scan() to begin scanning. - if (!this->scan_running_) { - this->start_scan_(); - } + // + // Nothing to do while a scan is already running: latching here would leave + // scan_requested_ set after that scan ends and silently restart a one-shot + // scan nobody asked for. + if (this->scan_running_) + return; + + // The request is latched: if this immediate attempt fails (controller busy, + // WiFi/BLE coexistence), loop() keeps retrying it with backoff even in + // non-continuous mode, so a one-shot start cannot fail silently. + // + // Routed through the backoff helper (forced: the user asked for an immediate + // attempt) so a failure here still counts toward the backoff escalation and + // its WARN. The force bypass only applies while the failure streak is clean — + // against a failing controller, repeated start_scan() calls are rate-limited + // like any other attempt. + this->scan_requested_ = true; + this->try_start_with_backoff_(millis(), /* force= */ true); } void BK72xxBLETracker::stop_scan() { this->scan_continuous_ = false; + this->scan_requested_ = false; // also cancels a pending (not yet successful) start this->stop_scan_(); } @@ -177,6 +228,8 @@ void BK72xxBLETracker::start_scan_() { const uint32_t now = millis(); this->scan_running_ = true; + this->scan_requested_ = false; // the latched one-shot request is satisfied + this->failed_start_count_ = 0; // reset here so direct starts clear the backoff too this->scan_start_time_ = now; // Log every explicit start at DEBUG — stop_scan_() logs every stop at DEBUG, and // in non-continuous mode each period is an explicit start, so asymmetric logging diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h index 9c70246f05..3d32798622 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h @@ -112,8 +112,15 @@ class BK72xxBLETracker : public Component, protected: void start_scan_(); void stop_scan_(); + /// Attempt a rate-limited (re)start; returns true when the scan is running, + /// which means the caller must not compare its cached millis() against the + /// timestamps start_scan_() just refreshed. force bypasses the rate gate for + /// an explicit user start only while the failure streak is clean; a failing + /// controller rate-limits forced attempts too. Failure accounting always runs. + bool try_start_with_backoff_(uint32_t now, bool force = false); bool scan_running_{false}; + bool scan_requested_{false}; // latched start_scan() request not yet running; loop() retries with backoff // Defaults: the BK reference — 30 % duty cycle // (interval 100 ms / window 30 ms), in 0.625 ms BLE units. uint32_t scan_interval_{160}; // 160 × 0.625 ms = 100 ms @@ -122,6 +129,7 @@ class BK72xxBLETracker : public Component, bool scan_continuous_{true}; #ifdef USE_OTA_STATE_LISTENER bool scan_continuous_before_ota_{false}; // continuous mode saved at OTA start, restored on OTA failure + bool scan_requested_before_ota_{false}; // pending one-shot latch saved at OTA start, re-latched on OTA failure #endif uint32_t scan_start_time_{0}; From 6b10e8bd15aa409012455de3d1922b5e82e33aab Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:58:47 -0400 Subject: [PATCH 1177/1815] [esp32_hosted] Bump esp_hosted to 2.12.12, esp_wifi_remote to 1.6.3 (#18030) --- esphome/components/esp32_hosted/__init__.py | 6 +++--- esphome/idf_component.yml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 16e9d49782..b15ae53711 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -256,10 +256,10 @@ async def to_code(config): idf_ver = esp32.idf_version() os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}" if idf_ver >= cv.Version(5, 5, 0): - esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.5.1") - esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.2") + esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.3") + esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.3") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.9") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.12") else: esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index e88c0649ea..b4b20cf221 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -26,11 +26,11 @@ dependencies: espressif/mdns: version: 1.11.3 espressif/esp_wifi_remote: - version: 1.5.1 + version: 1.6.3 rules: - if: "target in [esp32h2, esp32p4]" espressif/wifi_remote_over_eppp: - version: 0.3.2 + version: 0.3.3 rules: - if: "target in [esp32h2, esp32p4]" espressif/eppp_link: @@ -38,7 +38,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.12.9 + version: 2.12.12 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: From 4d9c514a1a7046fce53ea1d6f13d2d36f9c136c2 Mon Sep 17 00:00:00 2001 From: Egor Vorontsov Date: Mon, 3 Aug 2026 16:22:03 +0300 Subject: [PATCH 1178/1815] [i2s_audio] Implemented I2S PDM Microphone DSR selection (#17751) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/i2s_audio/__init__.py | 1 + .../i2s_audio/microphone/__init__.py | 12 +++++++++ .../microphone/i2s_audio_microphone.cpp | 2 +- .../microphone/i2s_audio_microphone.h | 7 ++++++ tests/components/microphone/common-pdm.yaml | 25 +++++++++++++++++++ tests/components/microphone/common.yaml | 10 -------- .../components/microphone/test.esp32-idf.yaml | 8 +++--- .../microphone/test.esp32-s2-idf.yaml | 8 ++++++ .../common/i2s_audio/esp32-s2-idf.yaml | 14 +++++++++++ 9 files changed, 72 insertions(+), 15 deletions(-) create mode 100644 tests/components/microphone/common-pdm.yaml create mode 100644 tests/components/microphone/test.esp32-s2-idf.yaml create mode 100644 tests/test_build_components/common/i2s_audio/esp32-s2-idf.yaml diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index 8e432695a1..4809bf5a92 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -29,6 +29,7 @@ DEPENDENCIES = ["esp32"] MULTI_CONF = True CONF_PDM = "pdm" +CONF_PDM_DSR = "pdm_dsr" CONF_ADC_TYPE = "adc_type" CONF_I2S_DOUT_PIN = "i2s_dout_pin" diff --git a/esphome/components/i2s_audio/microphone/__init__.py b/esphome/components/i2s_audio/microphone/__init__.py index 1392d1d4ec..9c6228087c 100644 --- a/esphome/components/i2s_audio/microphone/__init__.py +++ b/esphome/components/i2s_audio/microphone/__init__.py @@ -17,6 +17,7 @@ from .. import ( CONF_LEFT, CONF_MONO, CONF_PDM, + CONF_PDM_DSR, CONF_RIGHT, I2SAudioIn, i2s_audio_component_schema, @@ -38,6 +39,12 @@ I2SAudioMicrophone = i2s_audio_ns.class_( INTERNAL_ADC_VARIANTS = [esp32.VARIANT_ESP32] PDM_VARIANTS = [esp32.VARIANT_ESP32, esp32.VARIANT_ESP32S3, esp32.VARIANT_ESP32P4] +i2s_pdm_dsr_t = cg.global_ns.enum("i2s_pdm_dsr_t") +I2S_PDM_DSR = { + 8: i2s_pdm_dsr_t.I2S_PDM_DSR_8S, + 16: i2s_pdm_dsr_t.I2S_PDM_DSR_16S, +} + def _validate_esp32_variant(config): variant = esp32.get_esp32_variant() @@ -111,6 +118,9 @@ CONFIG_SCHEMA = cv.All( { cv.Required(CONF_I2S_DIN_PIN): pins.internal_gpio_input_pin_number, cv.Optional(CONF_PDM, default=False): cv.boolean, + cv.Optional(CONF_PDM_DSR, default=8): cv.enum( + I2S_PDM_DSR, int=True + ), } ), }, @@ -142,5 +152,7 @@ async def to_code(config): cg.add(var.set_din_pin(config[CONF_I2S_DIN_PIN])) cg.add(var.set_pdm(config[CONF_PDM])) + if esp32.get_esp32_variant() in PDM_VARIANTS: + cg.add(var.set_pdm_dsr(config[CONF_PDM_DSR])) cg.add(var.set_correct_dc_offset(config[CONF_CORRECT_DC_OFFSET])) diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp index 7b074b2e8f..c577ed092e 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp @@ -128,7 +128,7 @@ bool I2SAudioMicrophone::start_driver_() { .sample_rate_hz = this->sample_rate_, .clk_src = clk_src, .mclk_multiple = this->mclk_multiple_, - .dn_sample_mode = I2S_PDM_DSR_8S, + .dn_sample_mode = this->pdm_dsr_, }; i2s_pdm_rx_slot_config_t slot_cfg = I2S_PDM_RX_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, this->slot_mode_); diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h index 2c6528d8bf..37895ac4e7 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h @@ -29,6 +29,10 @@ class I2SAudioMicrophone final : public I2SAudioIn, public microphone::Microphon void set_pdm(bool pdm) { this->pdm_ = pdm; } +#if SOC_I2S_SUPPORTS_PDM_RX + void set_pdm_dsr(i2s_pdm_dsr_t pdm_dsr) { this->pdm_dsr_ = pdm_dsr; } +#endif + protected: /// @brief Starts the I2S driver. Updates the ``audio_stream_info_`` member variable with the current setttings. /// @return True if succesful, false otherwise @@ -57,6 +61,9 @@ class I2SAudioMicrophone final : public I2SAudioIn, public microphone::Microphon gpio_num_t din_pin_{I2S_GPIO_UNUSED}; i2s_chan_handle_t rx_handle_; bool pdm_{false}; +#if SOC_I2S_SUPPORTS_PDM_RX + i2s_pdm_dsr_t pdm_dsr_{I2S_PDM_DSR_8S}; +#endif bool correct_dc_offset_; bool locked_driver_{false}; diff --git a/tests/components/microphone/common-pdm.yaml b/tests/components/microphone/common-pdm.yaml new file mode 100644 index 0000000000..093dcb24f8 --- /dev/null +++ b/tests/components/microphone/common-pdm.yaml @@ -0,0 +1,25 @@ +microphone: + - platform: i2s_audio + id: mic_id_external + i2s_din_pin: ${i2s_din_pin1} + adc_type: external + pdm: false + mclk_multiple: 384 + correct_dc_offset: true + on_data: + - if: + condition: + - microphone.is_muted: + id: mic_id_external + then: + - microphone.unmute: + id: mic_id_external + else: + - microphone.mute: + id: mic_id_external + - platform: i2s_audio + id: mic_id_pdm + i2s_din_pin: ${i2s_din_pin2} + adc_type: external + pdm: true + pdm_dsr: 16 diff --git a/tests/components/microphone/common.yaml b/tests/components/microphone/common.yaml index 39ab06da61..281bda1ce0 100644 --- a/tests/components/microphone/common.yaml +++ b/tests/components/microphone/common.yaml @@ -1,8 +1,3 @@ -i2s_audio: - i2s_bclk_pin: ${i2s_bclk_pin} - i2s_lrclk_pin: ${i2s_lrclk_pin} - i2s_mclk_pin: ${i2s_mclk_pin} - microphone: - platform: i2s_audio id: mic_id_external @@ -22,8 +17,3 @@ microphone: else: - microphone.mute: id: mic_id_external - - platform: i2s_audio - id: mic_id_pdm - i2s_din_pin: ${i2s_din_pin2} - adc_type: external - pdm: true diff --git a/tests/components/microphone/test.esp32-idf.yaml b/tests/components/microphone/test.esp32-idf.yaml index 2f39263a43..65d7081bcd 100644 --- a/tests/components/microphone/test.esp32-idf.yaml +++ b/tests/components/microphone/test.esp32-idf.yaml @@ -1,8 +1,8 @@ substitutions: - i2s_bclk_pin: GPIO15 - i2s_lrclk_pin: GPIO4 - i2s_mclk_pin: GPIO5 i2s_din_pin1: GPIO33 i2s_din_pin2: GPIO34 -<<: !include common.yaml +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml + +<<: !include common-pdm.yaml diff --git a/tests/components/microphone/test.esp32-s2-idf.yaml b/tests/components/microphone/test.esp32-s2-idf.yaml new file mode 100644 index 0000000000..47b9283720 --- /dev/null +++ b/tests/components/microphone/test.esp32-s2-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + i2s_din_pin1: GPIO33 + i2s_din_pin2: GPIO34 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-s2-idf.yaml + +<<: !include common.yaml diff --git a/tests/test_build_components/common/i2s_audio/esp32-s2-idf.yaml b/tests/test_build_components/common/i2s_audio/esp32-s2-idf.yaml new file mode 100644 index 0000000000..386389ed77 --- /dev/null +++ b/tests/test_build_components/common/i2s_audio/esp32-s2-idf.yaml @@ -0,0 +1,14 @@ +# Common I2S audio bus configuration for ESP32-S2 IDF tests +# Provides a shared i2s_audio bus that speaker/microphone components can use +# Each consumer must give its speaker/microphone a unique data pin + +substitutions: + i2s_bclk_pin: GPIO5 + i2s_lrclk_pin: GPIO4 + i2s_mclk_pin: GPIO15 + +i2s_audio: + - id: i2s_audio_bus + i2s_bclk_pin: ${i2s_bclk_pin} + i2s_lrclk_pin: ${i2s_lrclk_pin} + i2s_mclk_pin: ${i2s_mclk_pin} From 4f058585bb60f30811d7e98187785ce8b5016535 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 Aug 2026 11:14:34 -0500 Subject: [PATCH 1179/1815] [rp2040_ble] Add controller scan primitives (#18001) --- esphome/components/rp2040_ble/rp2040_ble.cpp | 124 +++++++++++++++++- esphome/components/rp2040_ble/rp2040_ble.h | 75 +++++++++++ esphome/core/event_pool.h | 18 ++- esphome/core/lock_free_queue.h | 32 +++-- tests/components/core/test_event_pool.cpp | 73 +++++++++++ .../core/test_lock_free_queue_single.cpp | 115 ++++++++++++++++ .../rp2040_ble/test-scan.rp2040-ard.yaml | 14 ++ 7 files changed, 434 insertions(+), 17 deletions(-) create mode 100644 tests/components/core/test_event_pool.cpp create mode 100644 tests/components/core/test_lock_free_queue_single.cpp create mode 100644 tests/components/rp2040_ble/test-scan.rp2040-ard.yaml diff --git a/esphome/components/rp2040_ble/rp2040_ble.cpp b/esphome/components/rp2040_ble/rp2040_ble.cpp index f3896f7b9c..e10e85f3c3 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.cpp +++ b/esphome/components/rp2040_ble/rp2040_ble.cpp @@ -4,6 +4,10 @@ #include "esphome/core/log.h" +#include + +#include + namespace esphome::rp2040_ble { static const char *const TAG = "rp2040_ble"; @@ -11,15 +15,41 @@ static const char *const TAG = "rp2040_ble"; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) RP2040BLE *global_ble = nullptr; +// The analyzer cannot see that release() always retains the pointer here: the +// pool's free list is sized SIZE + 1, so its push cannot hit the ring-full +// drop branch for at most SIZE releases. +// NOLINTBEGIN(clang-analyzer-unix.Malloc) void RP2040BLE::setup() { global_ble = this; + // Pre-create every pool entry so the packet handler's allocate() is always a + // free-list pop — the IRQ path must never reach malloc() (heap allocation + // after setup is forbidden, and the newlib malloc lock is not IRQ-safe). + // Deliberately unconditional: warming lazily on the first scan would move + // the allocations after setup, and doing it here keeps the pool's RAM cost + // visible at startup instead of appearing once scanning begins. + BLEScanReport *warm[MAX_SCAN_REPORT_QUEUE_SIZE - 1]; + size_t warmed = 0; + while (warmed < MAX_SCAN_REPORT_QUEUE_SIZE - 1 && (warm[warmed] = this->report_pool_.allocate()) != nullptr) + warmed++; + for (size_t i = 0; i < warmed; i++) + this->report_pool_.release(warm[i]); + if (warmed != MAX_SCAN_REPORT_QUEUE_SIZE - 1) { + // An incomplete warm would silently put malloc() back on the IRQ path once + // the free list runs dry; refuse to run instead (the stack is never + // enabled, so the packet handler cannot fire). + ESP_LOGE(TAG, "Scan report pool warm-up failed"); + this->mark_failed(); + return; + } + if (this->enable_on_boot_) { this->enable(); } else { this->state_ = BLEComponentState::DISABLED; } } +// NOLINTEND(clang-analyzer-unix.Malloc) void RP2040BLE::enable() { if (this->state_ == BLEComponentState::ACTIVE || this->state_ == BLEComponentState::ENABLING) { @@ -31,6 +61,10 @@ void RP2040BLE::enable() { this->active_logged_ = false; if (!this->btstack_initialized_) { + // Serialize with the BTstack background worker while wiring the stack up + // (arduino-pico's BluetoothHCI::install() takes the same lock here). + BluetoothLock lock; + // BTstack init functions are not idempotent — only call once l2cap_init(); sm_init(); @@ -44,6 +78,7 @@ void RP2040BLE::enable() { this->btstack_initialized_ = true; } + BluetoothLock lock; hci_power_control(HCI_POWER_ON); } @@ -55,7 +90,10 @@ void RP2040BLE::disable() { ESP_LOGD(TAG, "Disabling BLE..."); this->state_ = BLEComponentState::DISABLING; - hci_power_control(HCI_POWER_OFF); + { + BluetoothLock lock; + hci_power_control(HCI_POWER_OFF); + } this->state_ = BLEComponentState::DISABLED; ESP_LOGD(TAG, "BLE disabled"); @@ -64,7 +102,29 @@ void RP2040BLE::disable() { void RP2040BLE::loop() { if (this->state_ == BLEComponentState::ACTIVE && !this->active_logged_) { this->active_logged_ = true; - ESP_LOGI(TAG, "BLE active"); + // The controller address becomes readable once HCI reaches WORKING. + // bd_addr_to_str() formats into a BTstack-internal static buffer, so both + // calls stay under the lock like every other BTstack call from the loop. + BluetoothLock lock; + gap_local_bd_addr(this->ble_mac_); + ESP_LOGI(TAG, "BLE active (MAC %s)", bd_addr_to_str(this->ble_mac_)); + } + + // Drain the lock-free ring filled by the BTstack packet handler; all + // per-report work runs here on the main loop, then the report returns to + // the pool. + BLEScanReport *report = this->report_queue_.pop(); + if (report == nullptr) + return; + do { + for (auto *listener : this->scan_listeners_) + listener->on_scan_report(*report); + this->report_pool_.release(report); + } while ((report = this->report_queue_.pop()) != nullptr); + + uint16_t dropped = this->report_queue_.get_and_reset_dropped_count(); + if (dropped > 0) { + ESP_LOGW(TAG, "Dropped %u scan reports (queue full)", dropped); } } @@ -114,11 +174,71 @@ void RP2040BLE::packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, } break; } + case GAP_EVENT_ADVERTISING_REPORT: { + // Runs in the CYW43 async-context worker (low-priority IRQ), NOT the + // ESPHome main loop: bounded copy into the lock-free queue only. + bd_addr_t addr; // accessor returns printable (MSB-first) order + gap_event_advertising_report_get_address(packet, addr); + uint8_t mac_lsb[6]; + reverse_bd_addr(addr, mac_lsb); // LSB-first, the BLE convention consumers expect + global_ble->enqueue_scan_report_(mac_lsb, static_cast(gap_event_advertising_report_get_rssi(packet)), + gap_event_advertising_report_get_address_type(packet), + gap_event_advertising_report_get_data(packet), + gap_event_advertising_report_get_data_length(packet)); + break; + } default: break; } } +// The analyzer traces a leak on the failed-push path, which cannot happen: the +// pool is sized to the queue capacity (SIZE-1), so allocate() returns nullptr +// before push() can find the ring full. +// NOLINTBEGIN(clang-analyzer-unix.Malloc) +void RP2040BLE::enqueue_scan_report_(const uint8_t *mac_lsb_first, int8_t rssi, uint8_t addr_type, const uint8_t *data, + uint16_t data_len) { + BLEScanReport *report = this->report_pool_.allocate(); + if (report == nullptr) { + // Pool exhausted — the queue is full; count and drop. + this->report_queue_.increment_dropped_count(); + return; + } + memcpy(report->mac, mac_lsb_first, 6); + report->rssi = rssi; + report->addr_type = addr_type; + report->data_len = + (data_len <= sizeof(report->data)) ? static_cast(data_len) : static_cast(sizeof(report->data)); + memcpy(report->data, data, report->data_len); + this->report_queue_.push(report); +} +// NOLINTEND(clang-analyzer-unix.Malloc) + +void RP2040BLE::get_mac_msb_first(uint8_t out[6]) const { memcpy(out, this->ble_mac_, 6); } + +bool RP2040BLE::scan_start(uint16_t interval, uint16_t window) { + if (!this->is_active()) { + // Power control stays with the user (enable_on_boot or an explicit + // enable() call) — auto-enabling here would defeat enable_on_boot: false + // the moment a tracker retries. Callers retry until the stack is up. + return false; + } + // Serialize with the BTstack background worker (arduino-pico's BluetoothHCI + // takes the same lock around its gap_* calls). + BluetoothLock lock; + gap_set_scan_params(0 /* passive */, interval, window, 0 /* accept all */); + gap_start_scan(); + return true; +} + +void RP2040BLE::scan_stop() { + if (!this->is_active()) { + return; // nothing can be scanning on a stack that is not up + } + BluetoothLock lock; + gap_stop_scan(); +} + } // namespace esphome::rp2040_ble #endif // USE_RP2040_BLE diff --git a/esphome/components/rp2040_ble/rp2040_ble.h b/esphome/components/rp2040_ble/rp2040_ble.h index a77b5fc26c..9685b9294e 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.h +++ b/esphome/components/rp2040_ble/rp2040_ble.h @@ -5,9 +5,14 @@ #ifdef USE_RP2040_BLE #include "esphome/core/component.h" +#include "esphome/core/event_pool.h" +#include "esphome/core/lock_free_queue.h" #include +#include +#include + namespace esphome::rp2040_ble { enum class BLEComponentState : uint8_t { @@ -18,6 +23,39 @@ enum class BLEComponentState : uint8_t { DISABLED, }; +/// One advertisement report from the controller. +struct BLEScanReport { + uint8_t mac[6]; // LSB-first, as the controller delivers it + int8_t rssi; // signed dBm + uint8_t addr_type; + uint8_t data_len; // bytes valid in data[] + // Legacy advertisement (31) + scan response (31): passive scans fill at most + // 31 bytes today, but bluetooth_proxy support will flip to active scanning + // in a future PR and the API raw-advertisement contract carries 62. + uint8_t data[62]; + + // EventPool contract: nothing is heap-allocated inside a report. + void release() {} +}; + +/// Consumer interface for controller scan reports. on_scan_report() always +/// runs on the ESPHome main loop: reports are queued from the BTstack packet +/// handler (CYW43 async-context IRQ) and drained by the controller's loop(), +/// so consumers never deal with cross-context state (the esp32_ble +/// event-queue pattern). +class BLEScanListener { + public: + virtual void on_scan_report(const BLEScanReport &report) = 0; + + protected: + ~BLEScanListener() = default; // deletion via this interface is not part of the contract +}; + +// Maximum reports buffered between the packet handler and loop(). The producer +// is a same-core IRQ and loop() drains the ring every iteration, so only the +// advertisements of a single loop period can accumulate. +static constexpr uint8_t MAX_SCAN_REPORT_QUEUE_SIZE = 32; + class RP2040BLE final : public Component { public: void setup() override; @@ -31,12 +69,49 @@ class RP2040BLE final : public Component { void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } + /// Controller BLE address in printable (MSB-first) order, as + /// gap_local_bd_addr() delivers it — note BLEScanReport::mac is the opposite + /// (LSB-first) order, hence the explicit names. All zeros until the stack + /// reports ACTIVE (BTstack reads the address from the controller during + /// power-up). + void get_mac_msb_first(uint8_t out[6]) const; + + /// Register a consumer for scan reports (delivered on the main loop via loop()). + void register_scan_listener(BLEScanListener *listener) { this->scan_listeners_.push_back(listener); } + + /// Start a passive controller scan. Interval/window are in BLE units + /// (0.625 ms). Returns false until the stack is ACTIVE (callers retry — the + /// tracker's rate-limited retry loop); powering the stack on stays with the + /// user (enable_on_boot or an explicit enable() call). The controller keeps + /// no scan state: a disable()/enable() power cycle ends the scan, and the + /// caller must call scan_start() again once the stack is back to ACTIVE + /// (the tracker's loop() reconciliation does exactly that). + bool scan_start(uint16_t interval, uint16_t window); + /// Stop the controller scan (no-op when not scanning). + void scan_stop(); + protected: static void packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); + /// Buffer one controller report (BTstack packet handler, CYW43 async-context + /// IRQ — bounded copy into the lock-free queue, nothing else). + void enqueue_scan_report_(const uint8_t *mac_lsb_first, int8_t rssi, uint8_t addr_type, const uint8_t *data, + uint16_t data_len); + + std::vector scan_listeners_; + // Report ring: the BTstack packet handler (async-context IRQ) allocates a + // report from the pool, fills it and pushes the pointer; loop() pops, + // dispatches and releases. Lock-free SPSC — the esp32_ble/bk72xx_ble pattern. + esphome::LockFreeQueue report_queue_; + // Pool sized to queue capacity (SIZE-1): the ring reserves one slot, so + // allocate() returns nullptr before push() can fail. This prevents leaking a + // pool slot on a failed push and keeps release() off the producer path. + esphome::EventPool report_pool_; + btstack_packet_callback_registration_t hci_event_callback_registration_{}; btstack_packet_callback_registration_t sm_event_callback_registration_{}; + uint8_t ble_mac_[6]{0}; // printable (MSB-first) order; zeros until ACTIVE BLEComponentState state_{BLEComponentState::STATE_OFF}; bool enable_on_boot_{true}; bool btstack_initialized_{false}; diff --git a/esphome/core/event_pool.h b/esphome/core/event_pool.h index 55c9254327..fe207d04bf 100644 --- a/esphome/core/event_pool.h +++ b/esphome/core/event_pool.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ESP32) || defined(USE_ZEPHYR) || defined(USE_LIBRETINY) +#if defined(USE_ESP32) || defined(USE_ZEPHYR) || defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_HOST) #include #include @@ -12,7 +12,7 @@ namespace esphome { // Event Pool - On-demand pool of objects to avoid heap fragmentation // Events are allocated on first use and reused thereafter, growing to peak usage // @tparam T The type of objects managed by the pool (must have a release() method) -// @tparam SIZE The maximum number of objects in the pool (1-255, limited by uint8_t) +// @tparam SIZE The maximum number of objects in the pool (1-254, limited by uint8_t and the +1 free-list slot) // // SIZING: When paired with a LockFreeQueue, the pool SIZE should be // Q_SIZE - 1 (the queue's actual capacity, since the ring buffer reserves one slot). @@ -22,6 +22,11 @@ namespace esphome { // - Avoids needing release() on the producer path after a failed push(), // preserving the SPSC contract on the internal free list template class EventPool { + // The free list ring must hold all SIZE objects at once (a fully drained + // pool), and LockFreeQueue reserves one slot — so it is sized SIZE + 1, + // which caps SIZE at 254. + static_assert(SIZE < 255, "EventPool SIZE must be at most 254"); + public: EventPool() : total_created_(0) {} @@ -80,10 +85,13 @@ template class EventPool { } private: - LockFreeQueue free_list_; // Free events ready for reuse - uint8_t total_created_; // Total events created (high water mark, max 255) + // SIZE + 1 slots so all SIZE objects fit when the pool is fully drained + // (the ring reserves one slot); otherwise the last release() of a + // completely returned pool would drop, permanently orphaning one object. + LockFreeQueue(SIZE + 1)> free_list_; // Free events ready for reuse + uint8_t total_created_; // Total events created (high water mark, max 254) }; } // namespace esphome -#endif // defined(USE_ESP32) || defined(USE_ZEPHYR) || defined(USE_LIBRETINY) +#endif // defined(USE_ESP32) || defined(USE_ZEPHYR) || defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_HOST) diff --git a/esphome/core/lock_free_queue.h b/esphome/core/lock_free_queue.h index ce54231137..316d9c7928 100644 --- a/esphome/core/lock_free_queue.h +++ b/esphome/core/lock_free_queue.h @@ -16,7 +16,9 @@ * blocking each other. * * This is a Single-Producer Single-Consumer (SPSC) lock-free ring buffer. - * Available on platforms with FreeRTOS support (ESP32, LibreTiny). + * Available on multi-threaded platforms (ESP32, LibreTiny) where another task + * produces or consumes, and on single-threaded platforms (RP2) where the + * producer runs in interrupt context. * * Common use cases: * - BLE events: BLE task produces, main loop consumes @@ -29,15 +31,25 @@ namespace esphome { namespace lockfree_internal { -#ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS -// Platforms whose cores lack atomic read-modify-write instructions (currently -// the ARMv5TE BK72xx SoCs — no LDREX/STREX, no libatomic; other LibreTiny -// chips such as LN882x/RTL87xx are ARMv7-M and keep std::atomic). For this -// queue's SPSC contract RMW atomics are not needed: aligned 8/16-bit loads and -// stores are single instructions on these cores, so torn reads cannot occur, -// and on a single in-order core a compiler barrier supplies all the -// acquire/release ordering the algorithm requires. Each index has exactly one -// writer (head_: consumer, tail_: producer). The dropped counter's +#if defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) || defined(ESPHOME_THREAD_SINGLE) +// Platforms where std::atomic RMW operations are unavailable or unnecessary: +// - ESPHOME_THREAD_MULTI_NO_ATOMICS: cores lacking atomic read-modify-write +// instructions (currently the ARMv5TE BK72xx SoCs — no LDREX/STREX, no +// libatomic; other LibreTiny chips such as LN882x/RTL87xx are ARMv7-M and +// keep std::atomic). +// - ESPHOME_THREAD_SINGLE: every platform on this model (ESP8266, RP2, +// nRF52) runs everything on one core (the chip may have more — RP2 is +// dual-core, but ESPHome and its interrupt producers stay on core 0), so +// the only possible concurrency is same-core interrupt preemption (on RP2 +// the BTstack packet handler runs in the CYW43 async-context low-priority +// IRQ on the core that initialized it, core 0). Using plain accesses here +// also avoids __atomic_* library calls on RP2040 (Cortex-M0+, no +// LDREX/STREX). +// For this queue's SPSC contract RMW atomics are not needed: aligned 8/16-bit +// loads and stores are single instructions on these cores, so torn reads +// cannot occur, and on a single in-order core a compiler barrier supplies all +// the acquire/release ordering the algorithm requires. Each index has exactly +// one writer (head_: consumer, tail_: producer). The dropped counter's // increment/exchange pair is not atomic here — a concurrent reset can lose // counts — which is acceptable for a diagnostic drop counter. #define ESPHOME_LFQ_COMPILER_BARRIER() __asm__ __volatile__("" ::: "memory") diff --git a/tests/components/core/test_event_pool.cpp b/tests/components/core/test_event_pool.cpp new file mode 100644 index 0000000000..af54ac3e14 --- /dev/null +++ b/tests/components/core/test_event_pool.cpp @@ -0,0 +1,73 @@ +#include "esphome/core/event_pool.h" + +#include + +#include + +namespace esphome::core::testing { + +struct PoolItem { + int value{0}; + // EventPool contract: release() cleans up per-object state; nothing here. + void release() {} +}; + +TEST(EventPool, AllocateUpToCapacityThenNull) { + esphome::EventPool pool; + PoolItem *items[4]; + for (auto *&item : items) { + item = pool.allocate(); + ASSERT_NE(item, nullptr); + } + // At capacity: the pool refuses rather than growing past SIZE. + EXPECT_EQ(pool.allocate(), nullptr); +} + +TEST(EventPool, FullDrainRetainsEveryObject) { + // Pins the SIZE + 1 free-list sizing: a fully returned pool must hold all + // SIZE objects. With a SIZE-slot ring (capacity SIZE - 1) the last release() + // of a full drain was dropped, permanently orphaning one object. + esphome::EventPool pool; + PoolItem *items[4]; + for (auto *&item : items) + item = pool.allocate(); + for (auto *item : items) + pool.release(item); + + // Every object must be allocatable again — no orphan, no new creation + // (total_created_ is already at SIZE, so a lost object would surface as a + // nullptr on the fourth allocation). + std::set seen; + for (int i = 0; i < 4; i++) { + PoolItem *item = pool.allocate(); + ASSERT_NE(item, nullptr); + seen.insert(item); + } + // And they are the same four objects, recycled rather than re-created. + for (auto *item : items) + EXPECT_TRUE(seen.count(item) == 1); + EXPECT_EQ(pool.allocate(), nullptr); +} + +TEST(EventPool, RepeatedDrainCyclesAreStable) { + esphome::EventPool pool; + // Several full allocate/release cycles: capacity must not shrink over time. + for (int cycle = 0; cycle < 10; cycle++) { + PoolItem *items[3]; + for (auto *&item : items) { + item = pool.allocate(); + ASSERT_NE(item, nullptr); + } + EXPECT_EQ(pool.allocate(), nullptr); + for (auto *item : items) + pool.release(item); + } +} + +TEST(EventPool, ReleaseNullptrIsSafe) { + esphome::EventPool pool; + pool.release(nullptr); + EXPECT_NE(pool.allocate(), nullptr); +} + +} // namespace esphome::core::testing diff --git a/tests/components/core/test_lock_free_queue_single.cpp b/tests/components/core/test_lock_free_queue_single.cpp new file mode 100644 index 0000000000..911674da65 --- /dev/null +++ b/tests/components/core/test_lock_free_queue_single.cpp @@ -0,0 +1,115 @@ +// Exercises the LockFreeQueue PlainAtomic path under ESPHOME_THREAD_SINGLE — +// the gate added for single-threaded platforms whose only concurrency is +// same-core interrupt preemption (RP2: BTstack packet handler in the CYW43 +// async-context IRQ). The define is forced before the include so this TU +// deterministically compiles that path regardless of the host's default +// thread model. The instantiations here deliberately differ from +// test_lock_free_queue.cpp's (uint32_t elements, non-power-of-2 sizes): no +// template instantiation is shared between the two TUs, so the differing +// AtomicIndex definitions can never collide under the one-definition rule, +// and the non-power-of-2 sizes cover next_index()'s comparison branch, which +// the other TU's power-of-2 sizes never reach. +#define ESPHOME_THREAD_SINGLE +#include "esphome/core/lock_free_queue.h" + +#include + +#include +#include + +namespace esphome::core::testing { + +// Pin the gate itself: under ESPHOME_THREAD_SINGLE the index type must be the +// PlainAtomic fallback, not std::atomic — otherwise the RP2040 build silently +// pulls __atomic_* library calls back in. +static_assert(!std::is_same_v, std::atomic>, + "ESPHOME_THREAD_SINGLE must select the PlainAtomic index path"); + +TEST(LockFreeQueueThreadSingle, EmptyPopReturnsNull) { + esphome::LockFreeQueue q; + EXPECT_EQ(q.pop(), nullptr); + EXPECT_TRUE(q.empty()); + EXPECT_FALSE(q.full()); + EXPECT_EQ(q.size(), 0u); +} + +TEST(LockFreeQueueThreadSingle, FifoOrder) { + esphome::LockFreeQueue q; + uint32_t a = 1, b = 2, c = 3, d = 4; + EXPECT_TRUE(q.push(&a)); + EXPECT_TRUE(q.push(&b)); + EXPECT_TRUE(q.push(&c)); + EXPECT_TRUE(q.push(&d)); + EXPECT_EQ(q.size(), 4u); + EXPECT_EQ(q.pop(), &a); + EXPECT_EQ(q.pop(), &b); + EXPECT_EQ(q.pop(), &c); + EXPECT_EQ(q.pop(), &d); + EXPECT_EQ(q.pop(), nullptr); +} + +TEST(LockFreeQueueThreadSingle, CapacityIsSizeMinusOne) { + esphome::LockFreeQueue q; + uint32_t v[5] = {0, 1, 2, 3, 4}; + EXPECT_TRUE(q.push(&v[0])); + EXPECT_TRUE(q.push(&v[1])); + EXPECT_TRUE(q.push(&v[2])); + EXPECT_TRUE(q.push(&v[3])); + EXPECT_TRUE(q.full()); + // Ring reserves one slot: the SIZEth push fails and is counted as dropped. + EXPECT_FALSE(q.push(&v[4])); + EXPECT_EQ(q.get_and_reset_dropped_count(), 1u); + EXPECT_EQ(q.get_and_reset_dropped_count(), 0u); // reset is sticky +} + +TEST(LockFreeQueueThreadSingle, NullPushRejected) { + esphome::LockFreeQueue q; + EXPECT_FALSE(q.push(nullptr)); + EXPECT_TRUE(q.empty()); +} + +TEST(LockFreeQueueThreadSingle, WrapAround) { + // Non-power-of-2 SIZE: next_index() wraps via the comparison branch here. + esphome::LockFreeQueue q; + uint32_t v[4] = {10, 20, 30, 40}; + // Cycle several times the ring size to cross the wrap boundary repeatedly. + for (int cycle = 0; cycle < 10; cycle++) { + for (auto &value : v) + ASSERT_TRUE(q.push(&value)); + EXPECT_TRUE(q.full()); + for (auto &value : v) + ASSERT_EQ(q.pop(), &value); + EXPECT_TRUE(q.empty()); + } + EXPECT_EQ(q.get_and_reset_dropped_count(), 0u); +} + +TEST(LockFreeQueueThreadSingle, IncrementDroppedCount) { + esphome::LockFreeQueue q; + // Producer-side external drop accounting (pool exhausted before push). + q.increment_dropped_count(); + q.increment_dropped_count(); + EXPECT_EQ(q.get_and_reset_dropped_count(), 2u); +} + +TEST(LockFreeQueueThreadSingle, InterleavedPushPop) { + esphome::LockFreeQueue q; + uint32_t v[64]; + uint32_t popped = 0; + for (uint32_t i = 0; i < 64; i++) { + v[i] = i; + ASSERT_TRUE(q.push(&v[i])); + if (i % 2 == 1) { + uint32_t *first = q.pop(); + ASSERT_NE(first, nullptr); + EXPECT_EQ(*first, popped++); + uint32_t *second = q.pop(); + ASSERT_NE(second, nullptr); + EXPECT_EQ(*second, popped++); + } + } + EXPECT_TRUE(q.empty()); + EXPECT_EQ(popped, 64u); +} + +} // namespace esphome::core::testing diff --git a/tests/components/rp2040_ble/test-scan.rp2040-ard.yaml b/tests/components/rp2040_ble/test-scan.rp2040-ard.yaml new file mode 100644 index 0000000000..251c83a92f --- /dev/null +++ b/tests/components/rp2040_ble/test-scan.rp2040-ard.yaml @@ -0,0 +1,14 @@ +# Exercises the controller scan API from a lambda: passive scan start with +# interval/window in 0.625 ms BLE units, stop, and the adapter MAC accessor. +esphome: + on_boot: + then: + - lambda: |- + uint8_t mac[6]; + id(ble).get_mac_msb_first(mac); + if (id(ble).scan_start(160, 48)) { + id(ble).scan_stop(); + } + +rp2040_ble: + id: ble From f8b83cc8a21cc27e027a12cc4537d05757832e16 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 Aug 2026 11:39:14 -0500 Subject: [PATCH 1180/1815] [wifi] Notify connect state listeners after driver initiated roams (#18034) --- esphome/components/wifi/wifi_component.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 650b06cae1..182e86daed 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -824,6 +824,15 @@ void WiFiComponent::loop() { this->status_clear_warning(); this->last_connected_ = now; +#ifdef USE_WIFI_CONNECT_STATE_LISTENERS + // A driver-initiated roam (e.g. 802.11v BTM) re-associates without the + // state machine ever leaving STA_CONNECTED, so the notification the + // connected event marked pending would never be flushed by + // check_connecting_finished(). Cheap when nothing is pending: the + // method returns immediately on a single flag test. + this->notify_connect_state_listeners_(); +#endif + // Post-connect roaming: check for better AP if (this->post_connect_roaming_) { if (this->roaming_state_ == RoamingState::SCANNING) { From 3be32846e42835dade1c23ad8b37a63fb8db6a75 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:13:02 -0500 Subject: [PATCH 1181/1815] Bump filelock from 3.32.0 to 3.32.2 (#18039) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6260e4a44a..a77bdc2f42 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 platformdirs==4.11.0 # native esp-idf toolchain global cache dir -filelock==3.32.0 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg +filelock==3.32.2 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 From 050b4c3c94046ea1e4c6a6eff7f08aa4c338105b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:13:26 -0500 Subject: [PATCH 1182/1815] Bump cryptography from 48.0.1 to 50.0.0 (#18037) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a77bdc2f42..afdb921f7e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ # cryptography 49+ ships no Intel macOS wheels (arm64 only); esptool caps <49 there. # Keep 48.0.1, the last universal2 release, so esphome stays installable on Intel Macs. -cryptography==49.0.0; platform_system != "Darwin" or platform_machine != "x86_64" +cryptography==50.0.0; platform_system != "Darwin" or platform_machine != "x86_64" cryptography==48.0.1; platform_system == "Darwin" and platform_machine == "x86_64" voluptuous==0.16.0 PyYAML==6.0.3 From 77546b6111998e29acd2d3ebb0c0866060f1baf8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:13:39 -0500 Subject: [PATCH 1183/1815] Bump github/codeql-action/init from 4.37.3 to 4.37.4 (#18040) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 3ffb4a633f..efd75825e0 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} From 9931647a8c236f5124f984f5ece0c5daacdfa9c7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:13:51 -0500 Subject: [PATCH 1184/1815] Bump ruff from 0.16.0 to 0.16.1 (#18038) Signed-off-by: dependabot[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index e7de8eb5e7..389bf6dbf0 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.6 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.16.0 # also change in .pre-commit-config.yaml when updating +ruff==0.16.1 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From 66db4fdd6150338b6f0f1b24b832bffd6f3008d5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:14:07 -0500 Subject: [PATCH 1185/1815] Bump github/codeql-action/analyze from 4.37.3 to 4.37.4 (#18041) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index efd75825e0..1586ead2e6 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 with: category: "/language:${{matrix.language}}" From 0b34c977425a417869fec5c5dfbd8520ec2d4663 Mon Sep 17 00:00:00 2001 From: ShaTie <52978334+ShaTie@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:21:27 +0300 Subject: [PATCH 1186/1815] [midea] Add ESP-IDF framework support (#12646) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 --- esphome/components/midea/ac_adapter.cpp | 4 ++-- esphome/components/midea/ac_adapter.h | 4 ++-- esphome/components/midea/ac_automations.h | 4 ++-- esphome/components/midea/air_conditioner.cpp | 4 ++-- esphome/components/midea/air_conditioner.h | 4 ++-- esphome/components/midea/appliance_base.h | 5 +++-- esphome/components/midea/climate.py | 14 ++++++++++---- esphome/components/midea/ir_transmitter.h | 6 +++--- platformio.ini | 2 +- tests/components/midea/common.yaml | 4 ---- tests/components/midea/test.esp32-ard.yaml | 5 ++++- tests/components/midea/test.esp32-h2-idf.yaml | 5 +++++ tests/components/midea/test.esp32-idf.yaml | 8 ++++++++ tests/components/midea/test.esp8266-ard.yaml | 5 ++++- .../common/uart/esp32-h2-idf.yaml | 13 +++++++++++++ 15 files changed, 61 insertions(+), 26 deletions(-) create mode 100644 tests/components/midea/test.esp32-h2-idf.yaml create mode 100644 tests/components/midea/test.esp32-idf.yaml create mode 100644 tests/test_build_components/common/uart/esp32-h2-idf.yaml diff --git a/esphome/components/midea/ac_adapter.cpp b/esphome/components/midea/ac_adapter.cpp index 77bb9bbe86..30771d25ca 100644 --- a/esphome/components/midea/ac_adapter.cpp +++ b/esphome/components/midea/ac_adapter.cpp @@ -1,4 +1,4 @@ -#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) +#if (defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY)) || defined(USE_ESP_IDF) #include "esphome/core/log.h" #include "ac_adapter.h" @@ -172,4 +172,4 @@ void Converters::to_climate_traits(ClimateTraits &traits, const dudanov::midea:: } // namespace esphome::midea::ac -#endif // USE_ARDUINO +#endif // USE_ARDUINO || USE_ESP_IDF diff --git a/esphome/components/midea/ac_adapter.h b/esphome/components/midea/ac_adapter.h index 4545743564..4a888ee8ff 100644 --- a/esphome/components/midea/ac_adapter.h +++ b/esphome/components/midea/ac_adapter.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) +#if (defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY)) || defined(USE_ESP_IDF) // MideaUART #include @@ -44,4 +44,4 @@ class Converters { } // namespace esphome::midea::ac -#endif // USE_ARDUINO +#endif // USE_ARDUINO || USE_ESP_IDF diff --git a/esphome/components/midea/ac_automations.h b/esphome/components/midea/ac_automations.h index b595a018b3..9572ec6c65 100644 --- a/esphome/components/midea/ac_automations.h +++ b/esphome/components/midea/ac_automations.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) +#if (defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY)) || defined(USE_ESP_IDF) #include "esphome/core/automation.h" #include "air_conditioner.h" @@ -63,4 +63,4 @@ template class PowerToggleAction : public MideaActionBase } // namespace esphome::midea::ac -#endif // USE_ARDUINO +#endif // USE_ARDUINO || USE_ESP_IDF diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index e55afedd8a..24bbfe76b0 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -1,4 +1,4 @@ -#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) +#if (defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY)) || defined(USE_ESP_IDF) #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -197,4 +197,4 @@ void AirConditioner::do_display_toggle() { } // namespace esphome::midea::ac -#endif // USE_ARDUINO +#endif // USE_ARDUINO || USE_ESP_IDF diff --git a/esphome/components/midea/air_conditioner.h b/esphome/components/midea/air_conditioner.h index 089928902e..9977d2088f 100644 --- a/esphome/components/midea/air_conditioner.h +++ b/esphome/components/midea/air_conditioner.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) +#if (defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY)) || defined(USE_ESP_IDF) // MideaUART #include @@ -61,4 +61,4 @@ class AirConditioner final : public ApplianceBase #include +#include // Include global defines #include "esphome/core/defines.h" @@ -99,4 +100,4 @@ template class ApplianceBase : public Component { } // namespace esphome::midea -#endif // USE_ARDUINO +#endif // USE_ARDUINO || USE_ESP_IDF diff --git a/esphome/components/midea/climate.py b/esphome/components/midea/climate.py index b0c102af6d..292e7215a9 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -153,7 +153,6 @@ CONFIG_SCHEMA = cv.All( ) .extend(uart.UART_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA), - cv.only_with_arduino, cv.only_on( [ PLATFORM_ESP32, @@ -305,7 +304,14 @@ async def to_code(config): if CONF_HUMIDITY_SETPOINT in config: sens = await sensor.new_sensor(config[CONF_HUMIDITY_SETPOINT]) cg.add(var.set_humidity_setpoint_sensor(sens)) - # MideaUART library requires WiFi (WiFi auto-enables Network via dependency mapping) - if CORE.is_esp32: + # MideaUART uses the Arduino WiFi API for the network-notify frame + # (WiFi auto-enables Network via dependency mapping). On ESP-IDF the + # library talks to esp_wifi directly, so no library entry is needed. + if CORE.is_esp32 and CORE.using_arduino: cg.add_library("WiFi", None) - cg.add_library("dudanov/MideaUART", "1.1.9") + # Using the repository until a release containing ESP-IDF support is published + cg.add_library( + name="MideaUART", + version=None, + repository="https://github.com/dudanov/MideaUART.git#7a4d1e9a4b6f07a3464c2453ee828c0c7b7e1bcf", + ) diff --git a/esphome/components/midea/ir_transmitter.h b/esphome/components/midea/ir_transmitter.h index ecf3fa1c1a..e54df1fd70 100644 --- a/esphome/components/midea/ir_transmitter.h +++ b/esphome/components/midea/ir_transmitter.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) +#if (defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY)) || defined(USE_ESP_IDF) #ifdef USE_REMOTE_TRANSMITTER #include "esphome/components/remote_base/midea_protocol.h" @@ -85,5 +85,5 @@ class IrTransmitter { } // namespace esphome::midea -#endif -#endif // USE_ARDUINO +#endif // USE_REMOTE_TRANSMITTER +#endif // USE_ARDUINO || USE_ESP_IDF diff --git a/platformio.ini b/platformio.ini index 9f2ac74ac0..df5c22492c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -44,6 +44,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} + https://github.com/dudanov/MideaUART.git#7a4d1e9a4b6f07a3464c2453ee828c0c7b7e1bcf ; midea esphome/noise-c@0.1.11 ; api improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image @@ -80,7 +81,6 @@ lib_deps = Wire ; i2c (Arduino built-int) heman/AsyncMqttClient-esphome@1.0.0 ; mqtt freekode/TM1651@1.0.1 ; tm1651 - dudanov/MideaUART@1.1.9 ; midea tonia/HeatpumpIR@1.0.42 ; heatpumpir build_flags = ${common.build_flags} diff --git a/tests/components/midea/common.yaml b/tests/components/midea/common.yaml index c7b18a6701..25fc2debcd 100644 --- a/tests/components/midea/common.yaml +++ b/tests/components/midea/common.yaml @@ -1,7 +1,3 @@ -wifi: - ssid: MySSID - password: password1 - climate: - platform: midea id: midea_unit diff --git a/tests/components/midea/test.esp32-ard.yaml b/tests/components/midea/test.esp32-ard.yaml index 1e3fe0ff51..17ced80477 100644 --- a/tests/components/midea/test.esp32-ard.yaml +++ b/tests/components/midea/test.esp32-ard.yaml @@ -1,5 +1,8 @@ packages: remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-ard.yaml uart: !include ../../test_build_components/common/uart/esp32-ard.yaml + midea: !include common.yaml -<<: !include common.yaml +wifi: + ssid: MySSID + password: password1 diff --git a/tests/components/midea/test.esp32-h2-idf.yaml b/tests/components/midea/test.esp32-h2-idf.yaml new file mode 100644 index 0000000000..45b73dc6c7 --- /dev/null +++ b/tests/components/midea/test.esp32-h2-idf.yaml @@ -0,0 +1,5 @@ +# ESP32-H2 has no WiFi PHY; this verifies the component builds without wifi +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + uart: !include ../../test_build_components/common/uart/esp32-h2-idf.yaml + midea: !include common.yaml diff --git a/tests/components/midea/test.esp32-idf.yaml b/tests/components/midea/test.esp32-idf.yaml new file mode 100644 index 0000000000..ae12002b31 --- /dev/null +++ b/tests/components/midea/test.esp32-idf.yaml @@ -0,0 +1,8 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + midea: !include common.yaml + +wifi: + ssid: MySSID + password: password1 diff --git a/tests/components/midea/test.esp8266-ard.yaml b/tests/components/midea/test.esp8266-ard.yaml index 9825ff85a1..70a0b00105 100644 --- a/tests/components/midea/test.esp8266-ard.yaml +++ b/tests/components/midea/test.esp8266-ard.yaml @@ -1,5 +1,8 @@ packages: remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + midea: !include common.yaml -<<: !include common.yaml +wifi: + ssid: MySSID + password: password1 diff --git a/tests/test_build_components/common/uart/esp32-h2-idf.yaml b/tests/test_build_components/common/uart/esp32-h2-idf.yaml new file mode 100644 index 0000000000..51d45fe6d5 --- /dev/null +++ b/tests/test_build_components/common/uart/esp32-h2-idf.yaml @@ -0,0 +1,13 @@ +# Common UART configuration for ESP32-H2 IDF tests +# Provides a shared UART bus that components can use +# Components will auto-use this bus if they don't specify uart_id + +substitutions: + tx_pin: GPIO12 + rx_pin: GPIO13 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 9600 From 5d89e432da50309532de5e9edfdf31ba319491b7 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Mon, 3 Aug 2026 17:15:00 -0700 Subject: [PATCH 1187/1815] [modbus_controller] Span response path; keep sensor addresses as configured (#17677) Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/modbus_controller/__init__.py | 5 +- .../binary_sensor/modbus_binarysensor.cpp | 9 +- .../binary_sensor/modbus_binarysensor.h | 8 +- .../modbus_controller/modbus_controller.cpp | 184 ++++++++------ .../modbus_controller/modbus_controller.h | 87 ++++++- .../number/modbus_number.cpp | 12 +- .../modbus_controller/number/modbus_number.h | 8 +- .../modbus_controller/output/modbus_output.h | 18 +- .../modbus_controller/select/__init__.py | 5 +- .../select/modbus_select.cpp | 9 +- .../modbus_controller/select/modbus_select.h | 10 +- .../sensor/modbus_sensor.cpp | 4 +- .../modbus_controller/sensor/modbus_sensor.h | 8 +- .../switch/modbus_switch.cpp | 23 +- .../modbus_controller/switch/modbus_switch.h | 12 +- .../text_sensor/modbus_textsensor.cpp | 7 +- .../text_sensor/modbus_textsensor.h | 8 +- .../components/modbus_controller/common.yaml | 73 ++++++ .../sensor_item_position_test.cpp | 89 +++++++ .../fixtures/uart_mock_modbus_grouping.yaml | 234 ++++++++++++++++++ .../uart_mock_modbus_shared_address.yaml | 160 ++++++++++++ tests/integration/test_uart_mock_modbus.py | 116 +++++++++ 22 files changed, 923 insertions(+), 166 deletions(-) create mode 100644 tests/components/modbus_controller/sensor_item_position_test.cpp create mode 100644 tests/integration/fixtures/uart_mock_modbus_grouping.yaml create mode 100644 tests/integration/fixtures/uart_mock_modbus_shared_address.yaml diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index 35a5479ecb..ea01331be3 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -170,10 +170,7 @@ async def add_modbus_base_properties( [ (sensor_type.operator("ptr"), "item"), (lambda_param_type, "x"), - ( - cg.std_vector.template(cg.uint8).operator("const").operator("ref"), - "data", - ), + (cg.std_span.template(cg.uint8.operator("const")), "data"), ], return_type=cg.optional.template(lambda_return_type), ) diff --git a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp index d3caaaa3d9..b0c927cf84 100644 --- a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp +++ b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp @@ -7,17 +7,18 @@ static const char *const TAG = "modbus_controller.binary_sensor"; void ModbusBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "Modbus Controller Binary Sensor", this); } -void ModbusBinarySensor::parse_and_publish(const std::vector &data) { +void ModbusBinarySensor::parse_and_publish(std::span data) { bool value; + // For coils/discrete inputs this is the bit index; for registers it is the byte offset. + const size_t offset = this->offset; switch (this->register_type) { case modbus::EntityType::DISCRETE_INPUT: case modbus::EntityType::COIL: - // offset for coil is the actual number of the coil not the byte offset - value = modbus::helpers::bit_from_packed(this->offset, data); + value = modbus::helpers::bit_from_packed(offset, data); break; default: - value = modbus::helpers::get_data(data, this->offset) & this->bitmask; + value = modbus::helpers::get_data(data.data(), offset) & this->bitmask; break; } // Is there a lambda registered diff --git a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h index f56a32a5ec..902a3ba8dd 100644 --- a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h +++ b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h @@ -13,8 +13,8 @@ class ModbusBinarySensor final : public Component, public binary_sensor::BinaryS ModbusBinarySensor(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, uint16_t skip_updates, bool force_new_range) { this->register_type = register_type; - this->start_address = start_address; - this->offset = offset; + this->set_address(start_address); + this->set_offset_from_start_address(offset); this->bitmask = bitmask; this->sensor_value_type = SensorValueType::BIT; this->skip_updates = skip_updates; @@ -27,12 +27,12 @@ class ModbusBinarySensor final : public Component, public binary_sensor::BinaryS } } - void parse_and_publish(const std::vector &data) override; + void parse_and_publish(std::span data) override; void set_state(bool state) { this->state = state; } void dump_config() override; - using transform_func_t = optional (*)(ModbusBinarySensor *, bool, const std::vector &); + using transform_func_t = optional (*)(ModbusBinarySensor *, bool, std::span); void set_template(transform_func_t f) { this->transform_func_ = f; } protected: diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 8822b7b40a..15f36ce89b 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -134,7 +134,7 @@ void ModbusController::on_register_data(modbus::EntityType register_type, uint16 const std::vector &data) { ESP_LOGV(TAG, "data for register address : 0x%X : ", start_address); - // loop through all sensors with the same start address + // loop through all sensors in this range; each reads its own bytes from the position resolved for it. auto sensors = find_sensors_(register_type, start_address); for (auto *sensor : sensors) { sensor->parse_and_publish(data); @@ -211,100 +211,122 @@ size_t ModbusController::create_register_ranges_() { return 0; } - // iterator is sorted see SensorItemsComparator for details - auto ix = this->sensorset_.begin(); + // Sensors are walked in the sensor set's order (see SensorItemsComparator): register type, then + // force_new_range ahead of the rest, then address - so the walk is not purely address-ordered. + // Each keeps the address it was configured with; what is resolved here is its `offset`, the position + // of its data within the response of whichever range it ends up in. RegisterRange r = {}; - uint8_t buffer_offset = 0; + bool have_range = false; + // Set while the open range belongs to a force_new_range sensor: a range the user asked to keep + // separate must not quietly absorb other sensors. + bool range_forced = false; + // Set once a sensor has joined by sharing the range's start address, which widens the read. Only a + // widened range can absorb a later sensor by coverage: ranges that were kept apart before stay apart, + // so their frames and polling rates are untouched. + bool range_shared = false; + // Bytes the range's registers have consumed so far. An extending sensor starts after them, so a + // register that returns more bytes than its count implies pushes the sensors after it along. + // range_custom_size records whether any of them returns something other than two bytes per register, + // which is what makes a position inside the range impossible to work out from addresses alone. Coils + // count as such: they carry one bit per address, so bit ranges never take the coverage join. + size_t range_bytes = 0; + bool range_custom_size = false; SensorItem *prev = nullptr; - while (ix != this->sensorset_.end()) { - SensorItem *curr = *ix; + for (SensorItem *curr : this->sensorset_) { + ESP_LOGV(TAG, "Register: 0x%X count=%d size=%zu offset=%u skip=%u addr=%p", curr->start_address, + curr->register_count, curr->get_register_size(), curr->offset, curr->skip_updates, curr); - ESP_LOGV(TAG, "Register: 0x%X %d %d %zu offset=%u skip=%u addr=%p", curr->start_address, curr->register_count, - curr->offset, curr->get_register_size(), curr->offset, curr->skip_updates, curr); + const bool custom_size = curr->get_register_size() != static_cast(curr->register_count) * 2; - if (r.register_count == 0) { - // this is the first register in range + bool join = false; + if (have_range && !curr->force_new_range && r.register_type == curr->register_type && + curr->register_type != modbus::EntityType::CUSTOM) { + if (curr->start_address == (r.start_address + r.register_count - prev->register_count) && + prev->start_address + prev->register_count == r.start_address + r.register_count && + curr->register_count == prev->register_count && curr->get_register_size() == prev->get_register_size()) { + // A second sensor on the register(s) the previous one covers: it reads those same bytes, + // starting where that sensor's offset pointed, so a chain configured 0/2/4 resolves to 0/2/6. + // Both address tests matter. The first identifies the previous sensor's register by working back + // from the range's end, which only describes it while it actually sits there - hence the second. + // A sensor that joined mid-range must never anchor this, or the next one inherits its offset. + curr->offset = static_cast(prev->offset + curr->offset_from_start_address); + join = true; + ESP_LOGV(TAG, "Re-use previous register 0x%X", curr->start_address); + } else if (curr->start_address == (r.start_address + r.register_count)) { + // The next contiguous register(s): the data begins after what the range has consumed so far - + // the byte cursor for registers, the distance in bits for coils. + curr->offset = + static_cast((curr->addresses_bits() ? curr->start_address - r.start_address : range_bytes) + + curr->offset_from_start_address); + range_bytes += curr->get_register_size(); + range_custom_size = range_custom_size || custom_size; + r.register_count += curr->register_count; + join = true; + ESP_LOGV(TAG, "Extend range to include 0x%X", curr->start_address); + } else if (range_shared && !range_forced && curr->start_address >= r.start_address && + curr->start_address + curr->register_count <= r.start_address + r.register_count && + !range_custom_size && !custom_size && curr->skip_updates == r.skip_updates) { + // The registers already fall inside a range that a shared-address join widened, so this sensor + // reads its slice of that response instead of adding an overlapping second poll. The guards keep + // it narrow: only a widened range, never a force-isolated one; only where every register in the + // range returns two bytes, so interior positions follow from the addresses; only sensors genuinely + // inside it, which is why the lower bound is needed given the walk is not address-ordered; and + // only where the polling rates already match, since joining runs this sensor through the rate + // merge below and would otherwise change one of them. + const uint16_t addr_delta = curr->start_address - r.start_address; + curr->offset = static_cast((curr->addresses_bits() ? addr_delta : addr_delta * 2) + + curr->offset_from_start_address); + join = true; + ESP_LOGV(TAG, "Register 0x%X already covered by range 0x%X", curr->start_address, r.start_address); + } + } + + // Sensors on the same start address have to share one range: a response is dispatched to a single + // range per (start_address, register_type), so a second range with that key would never receive + // data. This holds for force_new_range and custom entities too. The read widens to cover whichever + // sensor needs the most registers, which also fixes a short read for coils that use offset. + if (!join && have_range && r.register_type == curr->register_type && r.start_address == curr->start_address) { + curr->offset = curr->offset_from_start_address; // shares the range start + r.register_count = std::max(r.register_count, curr->register_count); + range_bytes = std::max(range_bytes, curr->get_register_size()); + range_custom_size = range_custom_size || custom_size; + range_shared = true; + range_forced = range_forced || curr->force_new_range; + join = true; + ESP_LOGV(TAG, "Share range start 0x%X", curr->start_address); + } + + if (!join) { + if (have_range) { + ESP_LOGV(TAG, "Add range 0x%X %d skip:%d", r.start_address, r.register_count, r.skip_updates); + this->register_ranges_.push_back(std::move(r)); + } + r = {}; + range_bytes = curr->get_register_size(); + range_custom_size = custom_size; + range_forced = curr->force_new_range; + range_shared = false; + curr->offset = curr->offset_from_start_address; r.start_address = curr->start_address; r.register_count = curr->register_count; r.register_type = curr->register_type; - r.sensors.insert(curr); r.skip_updates = curr->skip_updates; r.skip_updates_counter = 0; - buffer_offset = curr->get_register_size(); - - ESP_LOGV(TAG, "Started new range"); - } else { - // this is not the first register in range so it might be possible - // to reuse the last register or extend the current range - if (!curr->force_new_range && r.register_type == curr->register_type && - curr->register_type != modbus::EntityType::CUSTOM) { - if (curr->start_address == (r.start_address + r.register_count - prev->register_count) && - curr->register_count == prev->register_count && curr->get_register_size() == prev->get_register_size()) { - // this register can re-use the data from the previous register - - // remove this sensore because start_address is changed (sort-order) - ix = this->sensorset_.erase(ix); - - curr->start_address = r.start_address; - curr->offset += prev->offset; - - this->sensorset_.insert(curr); - // move iterator backwards because it will be incremented later - ix--; - - ESP_LOGV(TAG, "Re-use previous register - change to register: 0x%X %d offset=%u", curr->start_address, - curr->register_count, curr->offset); - } else if (curr->start_address == (r.start_address + r.register_count)) { - // this register can extend the current range - - // remove this sensore because start_address is changed (sort-order) - ix = this->sensorset_.erase(ix); - - curr->start_address = r.start_address; - curr->offset += buffer_offset; - buffer_offset += curr->get_register_size(); - r.register_count += curr->register_count; - - this->sensorset_.insert(curr); - // move iterator backwards because it will be incremented later - ix--; - - ESP_LOGV(TAG, "Extend range - change to register: 0x%X %d offset=%u", curr->start_address, - curr->register_count, curr->offset); - } - } - } - - if (curr->start_address == r.start_address && curr->register_type == r.register_type) { - // use the lowest non zero value for the whole range - // Because zero is the default value for skip_updates it is excluded from getting the min value. - if (curr->skip_updates != 0) { - if (r.skip_updates != 0) { - r.skip_updates = std::min(r.skip_updates, curr->skip_updates); - } else { - r.skip_updates = curr->skip_updates; - } - } - - // add sensor to this range - r.sensors.insert(curr); - - ix++; - } else { - ESP_LOGV(TAG, "Add range 0x%X %d skip:%d", r.start_address, r.register_count, r.skip_updates); - this->register_ranges_.push_back(r); - r = {}; - buffer_offset = 0; - // do not increment the iterator here because the current sensor has to be re-evaluated + have_range = true; + } else if (curr->skip_updates != 0) { + // use the lowest non-zero skip_updates for the whole range (0 is the default and is excluded) + r.skip_updates = (r.skip_updates != 0) ? std::min(r.skip_updates, curr->skip_updates) : curr->skip_updates; } + // Every member records its range's first register. The resolved offset is relative to it, so the + // two together give the sensor's real position, and the address a write entity targets. + curr->range_start_address = r.start_address; + r.sensors.insert(curr); prev = curr; } - - if (r.register_count > 0) { - // Add the last range + if (have_range) { ESP_LOGV(TAG, "Add last range 0x%X %d skip:%d", r.start_address, r.register_count, r.skip_updates); - this->register_ranges_.push_back(r); + this->register_ranges_.push_back(std::move(r)); } return this->register_ranges_.size(); diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 3c789936af..b5ef707a74 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -72,12 +72,32 @@ T get_data(const std::vector &data, size_t buffer_offset) { return modbus::helpers::get_data(data, buffer_offset); } +// Span overloads of the deprecated helpers below: read lambdas receive their payload as a +// std::span (previously a const std::vector &), and a span does not convert to +// a vector, so existing lambdas calling these by name need an overload that accepts one. These carry +// this release's deprecation window, since the span forms only exist from it. +// payload_to_number() deliberately has no such overload: one of its arguments is a modbus::helpers +// type, so a span call already reaches the helper by argument-dependent lookup, and a forwarder here +// would only make that call ambiguous. +// Remove before 2027.2.0. +template +ESPDEPRECATED("Use modbus::helpers::get_data() instead. Removed in 2027.2.0", "2026.8.0") +T get_data(std::span data, size_t buffer_offset) { + return modbus::helpers::get_data(data.data(), buffer_offset); +} + // Remove before 2027.2.0 (window restarted when the migration target changed to bit_from_packed()) ESPDEPRECATED("Use modbus::helpers::bit_from_packed() instead. Removed in 2027.2.0", "2026.4.0") inline bool coil_from_vector(int coil, const std::vector &data) { return modbus::helpers::bit_from_packed(coil, data); } +// Remove before 2027.2.0 +ESPDEPRECATED("Use modbus::helpers::bit_from_packed() instead. Removed in 2027.2.0", "2026.8.0") +inline bool coil_from_vector(int coil, std::span data) { + return modbus::helpers::bit_from_packed(coil, data); +} + template ESPDEPRECATED("Use modbus::helpers::mask_and_shift_by_rightbit() instead. Removed in 2026.10.0", "2026.4.0") N mask_and_shift_by_rightbit(N data, uint32_t mask) { @@ -107,11 +127,41 @@ class ModbusController; class SensorItem { public: - virtual void parse_and_publish(const std::vector &data) = 0; + /// Parse this sensor's slice out of its range's response and publish it. The span points into the + /// response buffer and is only valid for the duration of the call. Read the sensor's data from + /// `offset` within it. + virtual void parse_and_publish(std::span data) = 0; + + /// Coils and discrete inputs address individual bits; every other type addresses 16-bit registers. + bool addresses_bits() const { + return this->register_type == modbus::EntityType::COIL || this->register_type == modbus::EntityType::DISCRETE_INPUT; + } + + /// Address a write entity (switch/number/select) targets, derived from its resolved position within + /// the range so that a write lands on the register the sensor reads from. + uint16_t write_address() const { + return this->range_start_address + (this->addresses_bits() ? this->offset : this->offset / 2); + } + + /// Records the offset as configured, and seeds the resolved position with it. Building the ranges + /// overwrites `offset` with the position within the range; an item that is never polled keeps this + /// value, which is what its own address arithmetic expects. + void set_offset_from_start_address(uint8_t offset) { + this->offset_from_start_address = offset; + this->offset = offset; + } + + /// Sets the configured address, and points the range base at it. Building the ranges moves the base + /// to the range's first register; an item that is never polled (an output, or a switch with + /// assumed_state) keeps its own address, so write_address() stays correct for it. + void set_address(uint16_t address) { + this->start_address = address; + this->range_start_address = address; + } void set_custom_data(const std::vector &data) { custom_data = data; } size_t virtual get_register_size() const { - if (register_type == modbus::EntityType::COIL || register_type == modbus::EntityType::DISCRETE_INPUT) { + if (this->addresses_bits()) { return 1; } else { // if CONF_RESPONSE_BYTES is used override the default return response_bytes > 0 ? response_bytes : register_count * 2; @@ -123,9 +173,21 @@ class SensorItem { SensorValueType sensor_value_type{SensorValueType::RAW}; uint16_t start_address{0}; uint32_t bitmask{0}; + /// Position of this sensor's data within its range's response - a byte offset for registers, a bit + /// index for coils and discrete inputs. Resolved while the ranges are built, so it already accounts + /// for the registers ahead of it (including wide response_size ones) and for any offset inherited + /// from an earlier sensor sharing the same register. uint8_t offset{0}; uint8_t register_count{0}; uint8_t response_bytes{0}; + /// The offset exactly as configured: measured from this sensor's own start_address, where `offset` + /// is measured from the first register of the range it ends up polled in. Same units as `offset` - + /// bytes for registers, bits for coils and discrete inputs. Kept so the resolution can be recomputed, + /// and so the sort order of the sensor set never depends on the resolved value. + /// Declared before range_start_address so it lands in the padding after response_bytes. + uint8_t offset_from_start_address{0}; + /// First register of the range this sensor is polled in; equals start_address for an unpolled item. + uint16_t range_start_address{0}; uint16_t skip_updates{0}; std::vector custom_data{}; bool force_new_range{false}; @@ -151,9 +213,11 @@ class SensorItemsComparator { return lhs->start_address < rhs->start_address; } - // sort by offset (ensures update of sensors in ascending order) - if (lhs->offset != rhs->offset) { - return lhs->offset < rhs->offset; + // sort by the offset as configured (ensures update of sensors in ascending order). The resolved + // `offset` is deliberately not used: ranges are built while iterating this set and assign it, and + // a sort key that changed under the iteration would corrupt the set's ordering. + if (lhs->offset_from_start_address != rhs->offset_from_start_address) { + return lhs->offset_from_start_address < rhs->offset_from_start_address; } // The pointer to the sensor is used last to ensure that @@ -398,9 +462,8 @@ class ModbusController final : public PollingComponent, public modbus::ModbusCli * @param item SensorItem object * @return float value of data */ -inline float payload_to_float(std::span data, const SensorItem &item) { - int64_t number = - modbus::helpers::payload_to_number(data, item.sensor_value_type, item.offset, item.bitmask).value_or(0); +inline float payload_to_float(std::span data, const SensorItem &item, size_t offset) { + int64_t number = modbus::helpers::payload_to_number(data, item.sensor_value_type, offset, item.bitmask).value_or(0); float float_value; if (modbus::helpers::value_type_is_float(item.sensor_value_type)) { @@ -412,4 +475,12 @@ inline float payload_to_float(std::span data, const SensorItem &i return float_value; } +// Remove before 2027.2.0 (window opened when this helper gained an explicit offset). item.offset is +// the item's resolved position within its range's response, so this decodes the same bytes as passing +// that offset explicitly. +ESPDEPRECATED("Pass the offset explicitly: payload_to_float(data, item, item.offset). Removed in 2027.2.0", "2026.8.0") +inline float payload_to_float(std::span data, const SensorItem &item) { + return payload_to_float(data, item, item.offset); +} + } // namespace esphome::modbus_controller diff --git a/esphome/components/modbus_controller/number/modbus_number.cpp b/esphome/components/modbus_controller/number/modbus_number.cpp index 7b18b9e9fc..a2a49dcaf0 100644 --- a/esphome/components/modbus_controller/number/modbus_number.cpp +++ b/esphome/components/modbus_controller/number/modbus_number.cpp @@ -10,8 +10,8 @@ static const char *const TAG = "modbus.number"; // Maximum uint16_t registers to log in verbose hex output static constexpr size_t MODBUS_NUMBER_MAX_LOG_REGISTERS = 32; -void ModbusNumber::parse_and_publish(const std::vector &data) { - float result = payload_to_float(data, *this) / this->multiply_by_; +void ModbusNumber::parse_and_publish(std::span data) { + float result = payload_to_float(data, *this, this->offset) / this->multiply_by_; // Is there a lambda registered // call it with the pre converted value and the raw data array @@ -70,12 +70,10 @@ void ModbusNumber::control(float value) { // Create and send the write command if (this->register_count == 1 && !this->use_write_multiple_) { - // since offset is in bytes and a register is 16 bits we get the start by adding offset/2 - write_cmd = ModbusCommandItem::create_write_single_command(this->parent_, this->start_address + this->offset / 2, - payload[0]); + write_cmd = ModbusCommandItem::create_write_single_command(this->parent_, this->write_address(), payload[0]); } else { - write_cmd = ModbusCommandItem::create_write_multiple_command( - this->parent_, this->start_address + this->offset / 2, this->register_count, payload); + write_cmd = ModbusCommandItem::create_write_multiple_command(this->parent_, this->write_address(), + this->register_count, payload); } // publish new value write_cmd.on_data_func = [this, write_cmd, value](modbus::EntityType register_type, uint16_t start_address, diff --git a/esphome/components/modbus_controller/number/modbus_number.h b/esphome/components/modbus_controller/number/modbus_number.h index 582b042caf..1f0d0581eb 100644 --- a/esphome/components/modbus_controller/number/modbus_number.h +++ b/esphome/components/modbus_controller/number/modbus_number.h @@ -15,8 +15,8 @@ class ModbusNumber final : public number::Number, public Component, public Senso ModbusNumber(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, SensorValueType value_type, int register_count, uint16_t skip_updates, bool force_new_range) { this->register_type = register_type; - this->start_address = start_address; - this->offset = offset; + this->set_address(start_address); + this->set_offset_from_start_address(offset); this->bitmask = bitmask; this->sensor_value_type = value_type; this->register_count = register_count; @@ -25,12 +25,12 @@ class ModbusNumber final : public number::Number, public Component, public Senso }; void dump_config() override; - void parse_and_publish(const std::vector &data) override; + void parse_and_publish(std::span data) override; float get_setup_priority() const override { return setup_priority::HARDWARE; } void set_parent(ModbusController *parent) { this->parent_ = parent; } void set_write_multiply(float factor) { this->multiply_by_ = factor; } - using transform_func_t = optional (*)(ModbusNumber *, float, const std::vector &); + using transform_func_t = optional (*)(ModbusNumber *, float, std::span); using write_transform_func_t = optional (*)(ModbusNumber *, float, std::vector &); void set_template(transform_func_t f) { this->transform_func_ = f; } void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; } diff --git a/esphome/components/modbus_controller/output/modbus_output.h b/esphome/components/modbus_controller/output/modbus_output.h index c9efd42224..17eb8e3a8f 100644 --- a/esphome/components/modbus_controller/output/modbus_output.h +++ b/esphome/components/modbus_controller/output/modbus_output.h @@ -12,21 +12,21 @@ class ModbusFloatOutput final : public output::FloatOutput, public Component, pu public: ModbusFloatOutput(uint16_t start_address, uint8_t offset, SensorValueType value_type, int register_count) { this->register_type = modbus::EntityType::HOLDING; - this->start_address = start_address; - this->offset = offset; + this->set_address(start_address); + this->set_offset_from_start_address(offset); this->bitmask = 0xFFFFFFFF; this->register_count = register_count; this->sensor_value_type = value_type; this->skip_updates = 0; - this->start_address += offset; - this->offset = 0; + this->set_address(this->start_address + offset); + this->set_offset_from_start_address(0); } void dump_config() override; void set_parent(ModbusController *parent) { this->parent_ = parent; } void set_write_multiply(float factor) { this->multiply_by_ = factor; } // Do nothing - void parse_and_publish(const std::vector &data) override{}; + void parse_and_publish(std::span data) override{}; using write_transform_func_t = optional (*)(ModbusFloatOutput *, float, std::vector &); void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; } @@ -45,19 +45,19 @@ class ModbusBinaryOutput final : public output::BinaryOutput, public Component, public: ModbusBinaryOutput(uint16_t start_address, uint8_t offset) { this->register_type = modbus::EntityType::COIL; - this->start_address = start_address; + this->set_address(start_address); this->bitmask = 0xFFFFFFFF; this->sensor_value_type = SensorValueType::BIT; this->skip_updates = 0; this->register_count = 1; - this->start_address += offset; - this->offset = 0; + this->set_address(this->start_address + offset); + this->set_offset_from_start_address(0); } void dump_config() override; void set_parent(ModbusController *parent) { this->parent_ = parent; } // Do nothing - void parse_and_publish(const std::vector &data) override{}; + void parse_and_publish(std::span data) override{}; using write_transform_func_t = optional (*)(ModbusBinaryOutput *, bool, std::vector &); void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; } diff --git a/esphome/components/modbus_controller/select/__init__.py b/esphome/components/modbus_controller/select/__init__.py index 334a4dfd76..5127360770 100644 --- a/esphome/components/modbus_controller/select/__init__.py +++ b/esphome/components/modbus_controller/select/__init__.py @@ -115,10 +115,7 @@ async def to_code(config): [ (ModbusSelect.operator("const_ptr"), "item"), (cg.int64, "x"), - ( - cg.std_vector.template(cg.uint8).operator("const").operator("ref"), - "data", - ), + (cg.std_span.template(cg.uint8.operator("const")), "data"), ], return_type=cg.optional.template(cg.std_string), ) diff --git a/esphome/components/modbus_controller/select/modbus_select.cpp b/esphome/components/modbus_controller/select/modbus_select.cpp index daa6b10da4..c2d87619a1 100644 --- a/esphome/components/modbus_controller/select/modbus_select.cpp +++ b/esphome/components/modbus_controller/select/modbus_select.cpp @@ -7,10 +7,9 @@ static const char *const TAG = "modbus_controller.select"; void ModbusSelect::dump_config() { LOG_SELECT(TAG, "Modbus Controller Select", this); } -void ModbusSelect::parse_and_publish(const std::vector &data) { - int64_t value = modbus::helpers::payload_to_number(std::span(data), this->sensor_value_type, - this->offset, this->bitmask) - .value_or(0); +void ModbusSelect::parse_and_publish(std::span data) { + int64_t value = + modbus::helpers::payload_to_number(data, this->sensor_value_type, this->offset, this->bitmask).value_or(0); ESP_LOGD(TAG, "New select value %lld from payload", value); @@ -86,7 +85,7 @@ void ModbusSelect::control(size_t index) { return; } - const uint16_t write_address = this->start_address + this->offset / 2; + const uint16_t write_address = this->write_address(); ModbusCommandItem write_cmd; if ((this->register_count == 1) && (!this->use_write_multiple_)) { write_cmd = ModbusCommandItem::create_write_single_command(this->parent_, write_address, data[0]); diff --git a/esphome/components/modbus_controller/select/modbus_select.h b/esphome/components/modbus_controller/select/modbus_select.h index b4834ba4c6..e1ae578ddf 100644 --- a/esphome/components/modbus_controller/select/modbus_select.h +++ b/esphome/components/modbus_controller/select/modbus_select.h @@ -15,9 +15,9 @@ class ModbusSelect final : public Component, public select::Select, public Senso bool force_new_range, std::vector mapping) { this->register_type = modbus::EntityType::HOLDING; // not configurable this->sensor_value_type = sensor_value_type; - this->start_address = start_address; - this->offset = 0; // not configurable - this->bitmask = 0xFFFFFFFF; // not configurable + this->set_address(start_address); + this->set_offset_from_start_address(0); // not configurable + this->bitmask = 0xFFFFFFFF; // not configurable this->register_count = register_count; this->response_bytes = 0; // not configurable this->skip_updates = skip_updates; @@ -25,7 +25,7 @@ class ModbusSelect final : public Component, public select::Select, public Senso this->mapping_ = std::move(mapping); } - using transform_func_t = optional (*)(ModbusSelect *const, int64_t, const std::vector &); + using transform_func_t = optional (*)(ModbusSelect *const, int64_t, std::span); using write_transform_func_t = optional (*)(ModbusSelect *const, const std::string &, int64_t, std::vector &); @@ -36,7 +36,7 @@ class ModbusSelect final : public Component, public select::Select, public Senso void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; } void dump_config() override; - void parse_and_publish(const std::vector &data) override; + void parse_and_publish(std::span data) override; void control(size_t index) override; protected: diff --git a/esphome/components/modbus_controller/sensor/modbus_sensor.cpp b/esphome/components/modbus_controller/sensor/modbus_sensor.cpp index 559724057a..b2bc2b5fd0 100644 --- a/esphome/components/modbus_controller/sensor/modbus_sensor.cpp +++ b/esphome/components/modbus_controller/sensor/modbus_sensor.cpp @@ -8,8 +8,8 @@ static const char *const TAG = "modbus_controller.sensor"; void ModbusSensor::dump_config() { LOG_SENSOR(TAG, "Modbus Controller Sensor", this); } -void ModbusSensor::parse_and_publish(const std::vector &data) { - float result = payload_to_float(data, *this); +void ModbusSensor::parse_and_publish(std::span data) { + float result = payload_to_float(data, *this, this->offset); // Is there a lambda registered // call it with the pre converted value and the raw data array diff --git a/esphome/components/modbus_controller/sensor/modbus_sensor.h b/esphome/components/modbus_controller/sensor/modbus_sensor.h index 1d11aa4d66..61fdaacd10 100644 --- a/esphome/components/modbus_controller/sensor/modbus_sensor.h +++ b/esphome/components/modbus_controller/sensor/modbus_sensor.h @@ -13,8 +13,8 @@ class ModbusSensor final : public Component, public sensor::Sensor, public Senso ModbusSensor(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, SensorValueType value_type, int register_count, uint16_t skip_updates, bool force_new_range) { this->register_type = register_type; - this->start_address = start_address; - this->offset = offset; + this->set_address(start_address); + this->set_offset_from_start_address(offset); this->bitmask = bitmask; this->sensor_value_type = value_type; this->register_count = register_count; @@ -22,9 +22,9 @@ class ModbusSensor final : public Component, public sensor::Sensor, public Senso this->force_new_range = force_new_range; } - void parse_and_publish(const std::vector &data) override; + void parse_and_publish(std::span data) override; void dump_config() override; - using transform_func_t = optional (*)(ModbusSensor *, float, const std::vector &); + using transform_func_t = optional (*)(ModbusSensor *, float, std::span); void set_template(transform_func_t f) { this->transform_func_ = f; } diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index b8cdbf018d..adbd812348 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -27,16 +27,17 @@ void ModbusSwitch::set_assumed_state(bool assumed_state) { this->assumed_state_ bool ModbusSwitch::assumed_state() { return this->assumed_state_; } -void ModbusSwitch::parse_and_publish(const std::vector &data) { +void ModbusSwitch::parse_and_publish(std::span data) { bool value = false; + // For coils/discrete inputs this is the bit index; for registers it is the byte offset. + const size_t offset = this->offset; switch (this->register_type) { case modbus::EntityType::DISCRETE_INPUT: case modbus::EntityType::COIL: - // offset for coil is the actual number of the coil not the byte offset - value = modbus::helpers::bit_from_packed(this->offset, data); + value = modbus::helpers::bit_from_packed(offset, data); break; default: - value = modbus::helpers::get_data(data, this->offset) & this->bitmask; + value = modbus::helpers::get_data(data.data(), offset) & this->bitmask; break; } @@ -51,8 +52,8 @@ void ModbusSwitch::parse_and_publish(const std::vector &data) { } } - ESP_LOGV(TAG, "Publish '%s': new value = %s type = %d address = %X offset = %x", this->get_name().c_str(), - ONOFF(value), (int) this->register_type, this->start_address, this->offset); + ESP_LOGV(TAG, "Publish '%s': new value = %s type = %d address = %X offset = %zx", this->get_name().c_str(), + ONOFF(value), (int) this->register_type, this->start_address, offset); this->publish_state(value); } @@ -92,18 +93,16 @@ void ModbusSwitch::write_state(bool state) { // offset for coil and discrete inputs is the coil/register number not bytes if (this->use_write_multiple_) { std::vector states{state}; - cmd = ModbusCommandItem::create_write_multiple_coils(this->parent_, this->start_address + this->offset, states); + cmd = ModbusCommandItem::create_write_multiple_coils(this->parent_, this->write_address(), states); } else { - cmd = ModbusCommandItem::create_write_single_coil(this->parent_, this->start_address + this->offset, state); + cmd = ModbusCommandItem::create_write_single_coil(this->parent_, this->write_address(), state); } } else { - // since offset is in bytes and a register is 16 bits we get the start by adding offset/2 if (this->use_write_multiple_) { std::vector bool_states(1, state ? (0xFFFF & this->bitmask) : 0); - cmd = ModbusCommandItem::create_write_multiple_command(this->parent_, this->start_address + this->offset / 2, 1, - bool_states); + cmd = ModbusCommandItem::create_write_multiple_command(this->parent_, this->write_address(), 1, bool_states); } else { - cmd = ModbusCommandItem::create_write_single_command(this->parent_, this->start_address + this->offset / 2, + cmd = ModbusCommandItem::create_write_single_command(this->parent_, this->write_address(), state ? 0xFFFF & this->bitmask : 0u); } } diff --git a/esphome/components/modbus_controller/switch/modbus_switch.h b/esphome/components/modbus_controller/switch/modbus_switch.h index 0d5456aa63..e5b8cf5c21 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.h +++ b/esphome/components/modbus_controller/switch/modbus_switch.h @@ -13,15 +13,15 @@ class ModbusSwitch final : public Component, public switch_::Switch, public Sens ModbusSwitch(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, uint16_t skip_updates, bool force_new_range) { this->register_type = register_type; - this->start_address = start_address; - this->offset = offset; + this->set_address(start_address); + this->set_offset_from_start_address(offset); this->bitmask = bitmask; this->sensor_value_type = SensorValueType::BIT; this->skip_updates = skip_updates; this->register_count = 1; if (register_type == modbus::EntityType::HOLDING || register_type == modbus::EntityType::COIL) { - this->start_address += offset; - this->offset = 0; + this->set_address(this->start_address + offset); + this->set_offset_from_start_address(0); } this->force_new_range = force_new_range; }; @@ -30,10 +30,10 @@ class ModbusSwitch final : public Component, public switch_::Switch, public Sens void dump_config() override; void set_assumed_state(bool assumed_state); void set_state(bool state) { this->state = state; } - void parse_and_publish(const std::vector &data) override; + void parse_and_publish(std::span data) override; void set_parent(ModbusController *parent) { this->parent_ = parent; } - using transform_func_t = optional (*)(ModbusSwitch *, bool, const std::vector &); + using transform_func_t = optional (*)(ModbusSwitch *, bool, std::span); using write_transform_func_t = optional (*)(ModbusSwitch *, bool, std::vector &); void set_template(transform_func_t f) { this->publish_transform_func_ = f; } void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; } diff --git a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.cpp b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.cpp index 5626515638..31b3fb3e55 100644 --- a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.cpp +++ b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.cpp @@ -8,10 +8,11 @@ static const char *const TAG = "modbus_controller.text_sensor"; void ModbusTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Modbus Controller Text Sensor", this); } -void ModbusTextSensor::parse_and_publish(const std::vector &data) { +void ModbusTextSensor::parse_and_publish(std::span data) { std::string output_str{}; uint8_t items_left = this->response_bytes; - uint8_t index = this->offset; + const size_t start_offset = this->offset; + size_t index = start_offset; while ((items_left > 0) && index < data.size()) { uint8_t b = data[index]; switch (this->encode_) { @@ -25,7 +26,7 @@ void ModbusTextSensor::parse_and_publish(const std::vector &data) { case RawEncoding::COMMA: { // max 5: optional ','(1) + uint8(3) + null, for both ",%d" and "%d" char dec_buf[5]; - snprintf(dec_buf, sizeof(dec_buf), index != this->offset ? ",%d" : "%d", b); + snprintf(dec_buf, sizeof(dec_buf), index != start_offset ? ",%d" : "%d", b); output_str += dec_buf; break; } diff --git a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h index c7381d7ddd..e8de46b55a 100644 --- a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h +++ b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h @@ -15,8 +15,8 @@ class ModbusTextSensor final : public Component, public text_sensor::TextSensor, ModbusTextSensor(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint8_t register_count, uint16_t response_bytes, RawEncoding encode, uint16_t skip_updates, bool force_new_range) { this->register_type = register_type; - this->start_address = start_address; - this->offset = offset; + this->set_address(start_address); + this->set_offset_from_start_address(offset); this->response_bytes = response_bytes; this->register_count = register_count; this->encode_ = encode; @@ -28,8 +28,8 @@ class ModbusTextSensor final : public Component, public text_sensor::TextSensor, void dump_config() override; - void parse_and_publish(const std::vector &data) override; - using transform_func_t = optional (*)(ModbusTextSensor *, std::string, const std::vector &); + void parse_and_publish(std::span data) override; + using transform_func_t = optional (*)(ModbusTextSensor *, std::string, std::span); void set_template(transform_func_t f) { this->transform_func_ = f; } protected: diff --git a/tests/components/modbus_controller/common.yaml b/tests/components/modbus_controller/common.yaml index aa2855c2b0..a0db1e7888 100644 --- a/tests/components/modbus_controller/common.yaml +++ b/tests/components/modbus_controller/common.yaml @@ -118,6 +118,60 @@ sensor: value_type: U_WORD lambda: |- return x / 10.0; + # Non-mergeable sensor sharing the start address of modbus_sensor1 (different register_count): + # must join the same range, never open a second range keyed on the same (address, type). + - platform: modbus_controller + modbus_controller_id: modbus_controller1 + id: modbus_sensor_shared_addr + name: Test Sensor Shared Address + register_type: holding + address: 0x9001 + value_type: U_DWORD + # Sensors sharing one start address with distinct byte offsets (mixed register counts, so they take + # the shared-start path: each resolves to exactly its configured offset, no accumulation). + - platform: modbus_controller + modbus_controller_id: modbus_controller1 + id: modbus_sensor_shared_offs0 + name: Test Sensor Shared Offset Base + register_type: holding + address: 0x9020 + value_type: U_DWORD + - platform: modbus_controller + modbus_controller_id: modbus_controller1 + id: modbus_sensor_shared_offs1 + name: Test Sensor Shared Offset Low Word + register_type: holding + address: 0x9020 + value_type: U_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller1 + id: modbus_sensor_shared_offs2 + name: Test Sensor Shared Offset High Word + register_type: holding + address: 0x9020 + value_type: U_WORD + offset: 2 + # Raw-decode lambda in the documented style: `item->offset` locates this sensor's data in the range + # response, and the compatibility helpers accept the span the lambda is handed. + - platform: modbus_controller + modbus_controller_id: modbus_controller1 + id: modbus_sensor_raw_lambda + name: Test Sensor Raw Lambda + register_type: holding + address: 0x9050 + value_type: U_WORD + lambda: |- + return modbus_controller::get_data(data, item->offset) * 0.1f; + # force_new_range sensors sort before plain ones, so this high-address forced sensor is grouped + # first and the lower-address plain sensors above must still get their own ranges. + - platform: modbus_controller + modbus_controller_id: modbus_controller1 + id: modbus_sensor_forced_high + name: Test Sensor Forced High Address + register_type: holding + address: 0x9040 + value_type: U_WORD + force_new_range: true switch: - platform: modbus_controller @@ -158,3 +212,22 @@ text_sensor: response_size: 4 lambda: |- return "Modified: " + x; + # A register reporting FEWER bytes than 2*register_count (response_size: 3 for 2 registers), followed + # by a contiguous sensor: the follower's byte position must track the actual 3 bytes, not underflow. + - platform: modbus_controller + modbus_controller_id: modbus_controller1 + id: modbus_text_sensor_narrow + name: Test Text Sensor Narrow Response + register_type: holding + address: 0x9030 + register_count: 2 + response_size: 3 + raw_encode: HEXBYTES + - platform: modbus_controller + modbus_controller_id: modbus_controller1 + id: modbus_text_sensor_after_narrow + name: Test Text Sensor After Narrow + register_type: holding + address: 0x9032 + register_count: 1 + raw_encode: HEXBYTES diff --git a/tests/components/modbus_controller/sensor_item_position_test.cpp b/tests/components/modbus_controller/sensor_item_position_test.cpp new file mode 100644 index 0000000000..2fb679ee07 --- /dev/null +++ b/tests/components/modbus_controller/sensor_item_position_test.cpp @@ -0,0 +1,89 @@ +#include + +#include +#include + +#include "esphome/components/modbus_controller/modbus_controller.h" + +namespace esphome::modbus_controller::testing { + +namespace { + +// Minimal concrete SensorItem so the position/address accessors can be exercised directly. +class TestSensorItem : public SensorItem { + public: + void parse_and_publish(std::span /*data*/) override {} +}; + +// Builds an item the way a platform constructor does, before ranges are built. +TestSensorItem make_item(modbus::EntityType type, uint16_t address, uint8_t offset) { + TestSensorItem item; + item.register_type = type; + item.set_address(address); + item.set_offset_from_start_address(offset); + return item; +} + +} // namespace + +// A freshly constructed item is already usable: its resolved position is the offset as configured and +// its range base is its own address, which is what an item that never gets polled relies on. +TEST(SensorItemPosition, ConstructionSeedsResolvedPositionAndRangeBase) { + auto item = make_item(modbus::EntityType::HOLDING, 0x9001, 4); + EXPECT_EQ(item.offset_from_start_address, 4); + EXPECT_EQ(item.offset, 4); + EXPECT_EQ(item.range_start_address, 0x9001); +} + +// A write lands on the register the sensor reads from. The resolved position is relative to the range's +// first register, which may be earlier than the sensor's own address, so both are needed to get there. +TEST(SensorItemPosition, WriteAddressForRegisters) { + auto item = make_item(modbus::EntityType::HOLDING, 0x9003, 0); + item.range_start_address = 0x9001; + item.offset = 4; + EXPECT_EQ(item.write_address(), 0x9003); +} + +// Coils index bits, so the resolved offset is a bit count and is added to the range base directly. +TEST(SensorItemPosition, WriteAddressForCoils) { + auto item = make_item(modbus::EntityType::COIL, 0x15, 0); + item.range_start_address = 0x10; + item.offset = 5; + EXPECT_EQ(item.write_address(), 0x15); + EXPECT_TRUE(item.addresses_bits()); +} + +// An item that is never polled keeps the range base its constructor set, so its write address is still +// its own address plus its configured offset - a switch with assumed_state, or an output. +TEST(SensorItemPosition, WriteAddressWithoutAGroupedRange) { + auto item = make_item(modbus::EntityType::HOLDING, 0x9010, 2); + EXPECT_EQ(item.write_address(), 0x9011); +} + +// A sensor re-using a register after one with a non-zero offset resolves past that offset, and its +// write address follows the same position - the behaviour releases before the range rework had. +TEST(SensorItemPosition, ReUseChainWriteAddressFollowsResolvedPosition) { + auto item = make_item(modbus::EntityType::HOLDING, 0x9001, 4); + item.range_start_address = 0x9001; + item.offset = 6; // 4 configured, plus the 2 the previous sensor on this register resolved to + EXPECT_EQ(item.write_address(), 0x9004); +} + +// Registers address 16-bit words; only coils and discrete inputs address bits. +TEST(SensorItemPosition, AddressesBitsOnlyForCoilAndDiscreteInput) { + EXPECT_FALSE(make_item(modbus::EntityType::HOLDING, 0, 0).addresses_bits()); + EXPECT_FALSE(make_item(modbus::EntityType::INPUT_REGISTER, 0, 0).addresses_bits()); + EXPECT_TRUE(make_item(modbus::EntityType::COIL, 0, 0).addresses_bits()); + EXPECT_TRUE(make_item(modbus::EntityType::DISCRETE_INPUT, 0, 0).addresses_bits()); +} + +// A span payload reaches payload_to_number() unqualified from inside this namespace: SensorValueType +// lives in modbus::helpers, so argument-dependent lookup finds the helper. Declaring a same-signature +// forwarder here would make the call ambiguous rather than convenient, which is why none exists. +TEST(SensorItemPosition, UnqualifiedPayloadToNumberResolvesToTheHelper) { + const uint8_t bytes[] = {0x01, 0x02}; + auto value = payload_to_number(std::span(bytes), SensorValueType::U_WORD, 0, 0xFFFFFFFF); + EXPECT_EQ(value, 0x0102); +} + +} // namespace esphome::modbus_controller::testing diff --git a/tests/integration/fixtures/uart_mock_modbus_grouping.yaml b/tests/integration/fixtures/uart_mock_modbus_grouping.yaml new file mode 100644 index 0000000000..a5394f1d05 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_grouping.yaml @@ -0,0 +1,234 @@ +esphome: + name: uart-mock-modbus-group + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_dev + baud_rate: 9600 + rx_full_threshold: 120 + rx_timeout: 2 + auto_start: false + debug: + responses: + # One entry per range the controller polls. A frame the controller does not send goes unanswered, + # so these also pin the grouping: an extra or differently shaped read fails the test. + - expect_tx: [0x01, 0x01, 0x00, 0x10, 0x00, 0x02, 0xBC, 0x0E] # coils 0x10 count 2 + inject_rx: [0x01, 0x01, 0x01, 0x01, 0x90, 0x48] # bit0 set, bit1 clear + - expect_tx: [0x01, 0x03, 0x01, 0x60, 0x00, 0x01, 0x85, 0xE8] # holding 0x160 count 1 + inject_rx: [0x01, 0x03, 0x02, 0x01, 0x60, 0xB9, 0xFC] # 352 + - expect_tx: [0x01, 0x03, 0x01, 0x00, 0x00, 0x01, 0x85, 0xF6] # holding 0x100 count 1 + inject_rx: [0x01, 0x03, 0x04, 0x01, 0x11, 0x02, 0x22, 0x2A, 0xB3] # 4 bytes: 273 then 546 + - expect_tx: [0x01, 0x03, 0x01, 0x20, 0x00, 0x04, 0x44, 0x3F] # holding 0x120 count 4 + inject_rx: [0x01, 0x03, 0x08, 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, 0x7A, 0x25] + - expect_tx: [0x01, 0x03, 0x01, 0x30, 0x00, 0x02, 0xC5, 0xF8] # holding 0x130 count 2 + inject_rx: [0x01, 0x03, 0x06, 0x0A, 0xAA, 0xFF, 0xFF, 0x0B, 0xBB, 0x7E, 0xA0] # 6 bytes + - expect_tx: [0x01, 0x03, 0x01, 0x40, 0x00, 0x01, 0x84, 0x22] # holding 0x140 count 1 + inject_rx: [0x01, 0x03, 0x02, 0x01, 0x40, 0xB8, 0x24] # 320 + - expect_tx: [0x01, 0x03, 0x01, 0x45, 0x00, 0x01, 0x94, 0x23] # holding 0x145 count 1 + inject_rx: [0x01, 0x03, 0x02, 0x01, 0x45, 0x78, 0x27] # 325 + - expect_tx: [0x01, 0x03, 0x01, 0x50, 0x00, 0x02, 0xC5, 0xE6] # holding 0x150 count 2 + inject_rx: [0x01, 0x03, 0x04, 0x01, 0x50, 0x01, 0x51, 0x3B, 0xB2] # 336, 337 + - expect_tx: [0x01, 0x03, 0x01, 0x80, 0x00, 0x02, 0xC4, 0x1F] # holding 0x180 count 2 + inject_rx: [0x01, 0x03, 0x06, 0x11, 0x11, 0x22, 0x22, 0x33, 0x33, 0x20, 0xA0] # 6 bytes + # 0x181 answers with the same value whether it is read on its own or as part of the block above, + # so the sensor there is pinned to one value regardless of which range it lands in. + - expect_tx: [0x01, 0x03, 0x01, 0x81, 0x00, 0x01, 0xD5, 0xDE] # holding 0x181 count 1 + inject_rx: [0x01, 0x03, 0x02, 0x33, 0x33, 0xEC, 0xA1] # 13107 + - expect_tx: [0x01, 0x03, 0x01, 0x70, 0x00, 0x03, 0x05, 0xEC] # holding 0x170 count 3 + inject_rx: [0x01, 0x03, 0x06, 0x00, 0x2A, 0x1B, 0x2C, 0x03, 0x0D, 0x3E, 0xAB] # 6 bytes + - expect_tx: [0x01, 0x03, 0x01, 0x61, 0x00, 0x01, 0xD4, 0x28] # holding 0x161 count 1 + inject_rx: [0x01, 0x03, 0x02, 0x01, 0x61, 0x78, 0x3C] # 353 + +modbus: + uart_id: virtual_uart_dev + send_wait_time: 200ms + turnaround_time: 10ms + +modbus_controller: + - address: 1 + id: modbus_controller_ok + max_cmd_retries: 2 + update_interval: never + +# Each block below is a distinct address range exercising one grouping relationship. The blocks are far +# enough apart that they never merge into each other. +sensor: + # A - two sensors on one register that returns more bytes than its count implies (response_size), + # reading different halves of it. + - platform: modbus_controller + name: "reuse_lo" + address: 0x100 + register_type: holding + value_type: U_WORD + response_size: 4 + modbus_controller_id: modbus_controller_ok + - platform: modbus_controller + name: "reuse_hi" + address: 0x100 + register_type: holding + value_type: U_WORD + offset: 2 + response_size: 4 + modbus_controller_id: modbus_controller_ok + + # C - plain contiguous registers of differing widths. + - platform: modbus_controller + name: "ext_word" + address: 0x120 + register_type: holding + value_type: U_WORD + modbus_controller_id: modbus_controller_ok + - platform: modbus_controller + name: "ext_next" + address: 0x121 + register_type: holding + value_type: U_WORD + modbus_controller_id: modbus_controller_ok + - platform: modbus_controller + name: "ext_dword" + address: 0x122 + register_type: holding + value_type: U_DWORD + modbus_controller_id: modbus_controller_ok + + # D - a wide (response_size) register followed by a contiguous one: the follower must start after the + # bytes the wide register actually returned, not after 2 * register_count. + - platform: modbus_controller + name: "wide_first" + address: 0x130 + register_type: holding + value_type: U_WORD + response_size: 4 + modbus_controller_id: modbus_controller_ok + - platform: modbus_controller + name: "wide_next" + address: 0x131 + register_type: holding + value_type: U_WORD + modbus_controller_id: modbus_controller_ok + + # E - a gap: these must never share a range. + - platform: modbus_controller + name: "gap_low" + address: 0x140 + register_type: holding + value_type: U_WORD + modbus_controller_id: modbus_controller_ok + - platform: modbus_controller + name: "gap_high" + address: 0x145 + register_type: holding + value_type: U_WORD + modbus_controller_id: modbus_controller_ok + + # F - contiguous registers where the second asks for a slower rate. + - platform: modbus_controller + name: "rate_first" + address: 0x150 + register_type: holding + value_type: U_WORD + modbus_controller_id: modbus_controller_ok + - platform: modbus_controller + name: "rate_slow" + address: 0x151 + register_type: holding + value_type: U_WORD + skip_updates: 5 + modbus_controller_id: modbus_controller_ok + + # B - a wide value and one of its halves share a start address, with a contiguous sensor after them. + # The differing offsets give these a defined order, unlike two sensors that differ only in width. + - platform: modbus_controller + name: "shared_dword" + address: 0x170 + register_type: holding + value_type: U_DWORD + modbus_controller_id: modbus_controller_ok + - platform: modbus_controller + name: "shared_high" + address: 0x170 + register_type: holding + value_type: U_WORD + offset: 2 + modbus_controller_id: modbus_controller_ok + - platform: modbus_controller + name: "shared_after" + address: 0x172 + register_type: holding + value_type: U_WORD + modbus_controller_id: modbus_controller_ok + + # I - a register that returns more bytes than its count implies, sharing its address with a plain + # wider sensor. Whether the sensor after them is read as part of that block or on its own, it must + # decode 0x181 - never the bytes that lie two into the block, which is where the widened register + # count alone would put it. + - platform: modbus_controller + name: "masked_wide" + address: 0x180 + register_type: holding + value_type: U_WORD + response_size: 4 + modbus_controller_id: modbus_controller_ok + - platform: modbus_controller + name: "masked_pair" + address: 0x180 + register_type: holding + value_type: U_DWORD + modbus_controller_id: modbus_controller_ok + - platform: modbus_controller + name: "masked_after" + address: 0x181 + register_type: holding + value_type: U_WORD + modbus_controller_id: modbus_controller_ok + + # H - a sensor pinned to its own range, followed by a contiguous one. + - platform: modbus_controller + name: "forced_first" + address: 0x160 + register_type: holding + value_type: U_WORD + force_new_range: true + modbus_controller_id: modbus_controller_ok + - platform: modbus_controller + name: "forced_next" + address: 0x161 + register_type: holding + value_type: U_WORD + modbus_controller_id: modbus_controller_ok + +binary_sensor: + # G - contiguous coils, addressed by bit. + - platform: modbus_controller + name: "coil_first" + address: 0x10 + register_type: coil + modbus_controller_id: modbus_controller_ok + - platform: modbus_controller + name: "coil_next" + address: 0x11 + register_type: coil + modbus_controller_id: modbus_controller_ok + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: |- + id(virtual_uart_dev).start_scenario(); + id(modbus_controller_ok).set_update_interval(1000); + id(modbus_controller_ok).start_poller(); diff --git a/tests/integration/fixtures/uart_mock_modbus_shared_address.yaml b/tests/integration/fixtures/uart_mock_modbus_shared_address.yaml new file mode 100644 index 0000000000..25574d0c42 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_shared_address.yaml @@ -0,0 +1,160 @@ +esphome: + name: uart-mock-modbus-shared + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_dev + baud_rate: 9600 + rx_full_threshold: 120 + rx_timeout: 2 + auto_start: false + debug: + responses: + # Three sensors, one frame. At 0x9001 a U_WORD (1 register) and a U_DWORD (2 registers) share the + # start address but cannot merge, so the range widens to count 2. A third sensor at 0x9002 falls + # inside the widened range and must read its slice of the same response rather than splitting into + # a second overlapping poll. The single expect_tx pins the "one frame on the wire" contract - any + # duplicate or overlapping range would put an extra frame on the bus and fail to match. + - expect_tx: [0x01, 0x03, 0x90, 0x01, 0x00, 0x02, 0xB8, 0xCB] # Read holding 0x9001 count 2 on device 1 + inject_rx: [0x01, 0x03, 0x04, 0x03, 0x97, 0x02, 0x91, 0x8B, 0x57] # 0x9001=0x0397, 0x9002=0x0291 + # A force_new_range sensor at a HIGH address (0x30) sorts before the plain sensor at a LOW address + # (0x10). The two must poll as separate ranges: the covered branch's lower-bound check prevents the + # 0x10 sensor from being absorbed into the forced 0x30 range with a wrapped byte offset. + - expect_tx: [0x01, 0x03, 0x00, 0x30, 0x00, 0x01, 0x84, 0x05] # Read holding 0x30 count 1 (forced range) + inject_rx: [0x01, 0x03, 0x02, 0x01, 0x11, 0x79, 0xD8] # 0x30 = 0x0111 = 273 + - expect_tx: [0x01, 0x03, 0x00, 0x10, 0x00, 0x01, 0x85, 0xCF] # Read holding 0x10 count 1 (own range) + inject_rx: [0x01, 0x03, 0x02, 0x02, 0x22, 0x39, 0x3D] # 0x10 = 0x0222 = 546 + # A wide sensor (U_QWORD at 0x100, 4 registers) followed by plain sensors at 0x101 and 0x103. + # None of them merge, so all three poll separately - exactly as before the range refactor. The + # 0x103 sensor sits at the wide range's tail address, so it must not anchor a re-use join on a + # mid-range predecessor and inherit its byte offset. + - expect_tx: [0x01, 0x03, 0x01, 0x00, 0x00, 0x04, 0x45, 0xF5] # Read holding 0x100 count 4 + inject_rx: [0x01, 0x03, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x64, 0x94, 0x3C] # = 100 + - expect_tx: [0x01, 0x03, 0x01, 0x01, 0x00, 0x01, 0xD4, 0x36] # Read holding 0x101 count 1 + inject_rx: [0x01, 0x03, 0x02, 0x01, 0x41, 0x79, 0xE4] # 0x101 = 0x0141 = 321 + - expect_tx: [0x01, 0x03, 0x01, 0x03, 0x00, 0x01, 0x75, 0xF6] # Read holding 0x103 count 1 + inject_rx: [0x01, 0x03, 0x02, 0x01, 0xA5, 0x79, 0xAF] # 0x103 = 0x01A5 = 421 + # A widened shared-address range at 0x200 plus a sensor at 0x201 carrying its own skip_updates. + # The sensor must keep its own range so the polling rates stay independent; if it were folded into + # the widened range it would decode 0x201 from THAT response (2, not 777) and drag the range's + # rate down to its own. + - expect_tx: [0x01, 0x03, 0x02, 0x00, 0x00, 0x02, 0xC5, 0xB3] # Read holding 0x200 count 2 + inject_rx: [0x01, 0x03, 0x04, 0x01, 0x41, 0x00, 0x02, 0x2A, 0x1A] # 0x200=0x0141, 0x201=0x0002 + - expect_tx: [0x01, 0x03, 0x02, 0x01, 0x00, 0x01, 0xD4, 0x72] # Read holding 0x201 count 1 + inject_rx: [0x01, 0x03, 0x02, 0x03, 0x09, 0x78, 0xB2] # 0x201 = 0x0309 = 777 + +modbus: + uart_id: virtual_uart_dev + send_wait_time: 200ms + turnaround_time: 10ms + +modbus_controller: + - address: 1 + id: modbus_controller_ok + max_cmd_retries: 2 + update_interval: never + +sensor: + # Word sensor at 0x9001 (1 register) + - platform: modbus_controller + name: "shared_word" + address: 0x9001 + register_type: holding + value_type: U_WORD + modbus_controller_id: modbus_controller_ok + # Dword sensor at the SAME address 0x9001 (2 registers) - non-mergeable, shares the range start + - platform: modbus_controller + name: "shared_dword" + address: 0x9001 + register_type: holding + value_type: U_DWORD + modbus_controller_id: modbus_controller_ok + # Word sensor at 0x9002 - inside the widened range, reads bytes 2-3 of the same response + - platform: modbus_controller + name: "covered_word" + address: 0x9002 + register_type: holding + value_type: U_WORD + modbus_controller_id: modbus_controller_ok + # Forced sensor at a high address: sorts first, opens its own isolated range + - platform: modbus_controller + name: "forced_high" + address: 0x30 + register_type: holding + value_type: U_WORD + force_new_range: true + modbus_controller_id: modbus_controller_ok + # Plain sensor at a lower address: must get its own range, never absorbed into the forced one + - platform: modbus_controller + name: "plain_low" + address: 0x10 + register_type: holding + value_type: U_WORD + modbus_controller_id: modbus_controller_ok + # Wide sensor spanning 0x100-0x103; the two sensors below sit inside its span but do not merge + - platform: modbus_controller + name: "wide_qword" + address: 0x100 + register_type: holding + value_type: U_QWORD + modbus_controller_id: modbus_controller_ok + - platform: modbus_controller + name: "inside_wide" + address: 0x101 + register_type: holding + value_type: U_WORD + modbus_controller_id: modbus_controller_ok + # At the wide range's tail address: must decode its own poll, not inherit a mid-range byte offset + - platform: modbus_controller + name: "tail_of_wide" + address: 0x103 + register_type: holding + value_type: U_WORD + modbus_controller_id: modbus_controller_ok + # Shared address 0x200: the dword widens the range the word opened (or vice versa) + - platform: modbus_controller + name: "rate_word" + address: 0x200 + register_type: holding + value_type: U_WORD + modbus_controller_id: modbus_controller_ok + - platform: modbus_controller + name: "rate_dword" + address: 0x200 + register_type: holding + value_type: U_DWORD + modbus_controller_id: modbus_controller_ok + # Inside the widened range but with its own skip_updates: must NOT be folded in, or the two sensors + # above would silently drop to this sensor's polling rate + - platform: modbus_controller + name: "own_rate" + address: 0x201 + register_type: holding + value_type: U_WORD + skip_updates: 100 + modbus_controller_id: modbus_controller_ok + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: |- + id(virtual_uart_dev).start_scenario(); + id(modbus_controller_ok).set_update_interval(1000); + id(modbus_controller_ok).start_poller(); diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 2c437341c6..ce707fb0e0 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -330,3 +330,119 @@ async def test_uart_mock_modbus_server_controller_multiple( await tracker.setup_and_start_scenario(client) await tracker.await_all(futures) _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_grouping( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Pins how sensors are grouped into polled ranges across the combinations that matter. + + Each block in the fixture covers one relationship between neighbouring sensors - sharing a wide + register, contiguous, separated by a gap, differing polling rates, coils, and a pinned range - so + that the frames on the wire and the byte each sensor decodes from are locked down. + """ + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + # Values are those the component produced before the range rework, captured from it directly. + expected_values = { + # one register returning 4 bytes, read as two halves + "reuse_lo": 273, + "reuse_hi": 546, + # contiguous registers, mixed widths + "ext_word": 4660, + "ext_next": 22136, + "ext_dword": pytest.approx(2596069120), + # a wide register pushes its neighbour past the bytes it actually returned + "wide_first": 2730, + "wide_next": 3003, + # a gap keeps them apart + "gap_low": 320, + "gap_high": 325, + # contiguous, second one polling more slowly + "rate_first": 336, + "rate_slow": 337, + # a wide value, one of its halves, and the register after it + "shared_dword": pytest.approx(2759468), + "shared_high": 6956, + "shared_after": 781, + # a wide register hidden behind a wider plain sibling, and the sensor after them + "masked_wide": 4369, + "masked_pair": pytest.approx(286335522), + "masked_after": 13107, + # pinned range, and the contiguous sensor after it + "forced_first": 352, + "forced_next": 353, + } + tracker = SensorTracker(list(expected_values.keys())) + futures = tracker.expect_all(expected_values) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + await tracker.await_all(futures) + # Every frame sent must match one the mock answers, so an unexpected read (a range that split, + # merged or changed length) shows up here as an unanswered request. This is what pins the coil + # grouping too, since binary sensors carry no numeric state to compare. + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_shared_address( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Sensors sharing and overlapping one register range must all decode from a single read. + + A U_WORD and a U_DWORD share start address 0x9001 (non-mergeable, so the range widens to 2 + registers) and a third U_WORD at 0x9002 falls inside the widened range. A regression guard for the + range-grouping rewrite: without the same-address fallback the shared sensors land in duplicate + ranges and one never publishes; without the in-range join the 0x9002 sensor splits into a second + overlapping frame that the mock (which expects exactly one read) never answers. + + A force_new_range sensor at 0x30 plus a plain sensor at 0x10 pin the covered branch's lower-bound + check: the forced sensor sorts first, and without the bound the lower-address sensor is absorbed + into the forced range with a wrapped byte offset and never polls its own register. + + A U_QWORD at 0x100 with plain sensors at 0x101 and 0x103 pins that non-merging sensors inside a + wide sensor's span keep polling separately, and that the sensor at the span's tail address does not + anchor a re-use join on a mid-range predecessor (which would make it decode that sensor's bytes). + + A sensor at 0x201 carrying skip_updates sits inside a widened shared-address range at 0x200 but + keeps its own range, so polling rates stay independent; folding it in would also make it decode + 0x201 out of the shared response (2) instead of its own poll (777). + """ + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + # 0x9001 = 0x0397 (919); 0x9001..0x9002 = 0x03970291 (60228241, approx: not exact in float32); + # 0x9002 = 0x0291 (657); 0x30 = 0x0111 (273); 0x10 = 0x0222 (546) + expected_values = { + "shared_word": 919, + "shared_dword": pytest.approx(60228241), + "covered_word": 657, + "forced_high": 273, + "plain_low": 546, + "wide_qword": 100, + "inside_wide": 321, + "tail_of_wide": 421, + "rate_word": 321, + "rate_dword": pytest.approx(21037058), + "own_rate": 777, + } + tracker = SensorTracker(list(expected_values.keys())) + futures = tracker.expect_all(expected_values) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + await tracker.await_all(futures) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) From a50369fc0e5762c8cc05d4dbe4a494037d0b0ab2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 Aug 2026 19:53:07 -0500 Subject: [PATCH 1188/1815] [rp2_ble_tracker] BLE tracker for Raspberry Pi Pico W (#18002) --- CODEOWNERS | 1 + esphome/components/rp2040_ble/__init__.py | 14 ++ .../components/rp2_ble_tracker/__init__.py | 73 ++++++ .../rp2_ble_tracker/rp2_ble_tracker.cpp | 214 ++++++++++++++++++ .../rp2_ble_tracker/rp2_ble_tracker.h | 114 ++++++++++ esphome/core/defines.h | 1 + .../test_scan_parameter_validation.py | 15 ++ .../rp2_ble_tracker/common-boundary.yaml | 11 + tests/components/rp2_ble_tracker/common.yaml | 16 ++ .../rp2_ble_tracker/test.rp2040-ard.yaml | 2 + .../validate-boundary.rp2040-ard.yaml | 2 + 11 files changed, 463 insertions(+) create mode 100644 esphome/components/rp2_ble_tracker/__init__.py create mode 100644 esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp create mode 100644 esphome/components/rp2_ble_tracker/rp2_ble_tracker.h create mode 100644 tests/components/rp2_ble_tracker/common-boundary.yaml create mode 100644 tests/components/rp2_ble_tracker/common.yaml create mode 100644 tests/components/rp2_ble_tracker/test.rp2040-ard.yaml create mode 100644 tests/components/rp2_ble_tracker/validate-boundary.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index cb1be26a61..03172a5b1b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -438,6 +438,7 @@ esphome/components/rp2/* @jesserockz esphome/components/rp2040_ble/* @bdraco esphome/components/rp2040_pio_led_strip/* @Papa-DMan esphome/components/rp2040_pwm/* @jesserockz +esphome/components/rp2_ble_tracker/* @bdraco esphome/components/rpi_dpi_rgb/* @clydebarrow esphome/components/rtl87xx/* @kuba2k2 esphome/components/rtttl/* @glmnet @ximex diff --git a/esphome/components/rp2040_ble/__init__.py b/esphome/components/rp2040_ble/__init__.py index ac012b5e85..8e50c8e1ef 100644 --- a/esphome/components/rp2040_ble/__init__.py +++ b/esphome/components/rp2040_ble/__init__.py @@ -17,6 +17,20 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) +def _validate_board(config: ConfigType) -> ConfigType: + from esphome.components.rp2 import board_has_wifi, get_board + + if not board_has_wifi(): + raise cv.Invalid( + f"Board '{get_board()}' does not have Bluetooth support (no CYW43 wireless " + f"chip). Use a board like 'rpipicow' or 'rpipico2w'." + ) + return config + + +FINAL_VALIDATE_SCHEMA = _validate_board + + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/rp2_ble_tracker/__init__.py b/esphome/components/rp2_ble_tracker/__init__.py new file mode 100644 index 0000000000..2ae53cfe30 --- /dev/null +++ b/esphome/components/rp2_ble_tracker/__init__.py @@ -0,0 +1,73 @@ +"""BLE scanner for the Raspberry Pi Pico W / Pico 2 W (BLEHub on rp2040_ble). + +Scan modes: + continuous: true — scan runs forever; never stops automatically. + continuous: false — a started scan runs for `duration`, then stops. The first + start is external too; nothing starts a non-continuous + scan on boot. Until start/stop automation actions land + (follow-up PR), starting means a lambda: + `id(my_tracker).start_scan();`. +""" + +import esphome.codegen as cg +from esphome.components import ble_device_base, ota, rp2040_ble +from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW +import esphome.config_validation as cv +from esphome.const import CONF_CONTINUOUS, CONF_DURATION, CONF_ID, CONF_INTERVAL +from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.types import ConfigType + +CONF_RP2040_BLE_ID = "rp2040_ble_id" + +DEPENDENCIES = ["rp2"] +AUTO_LOAD = ["ble_device_base", "rp2040_ble"] +CODEOWNERS = ["@bdraco"] + +rp2_ble_tracker_ns = cg.esphome_ns.namespace("rp2_ble_tracker") +RP2BLETracker = rp2_ble_tracker_ns.class_( + "RP2BLETracker", ble_device_base.BLEHub, cg.Component +) + + +# interval defaults to 100 ms with the shared 30 ms window, a 30 % duty cycle — +# the same defaults as bk72xx_ble_tracker, leaving the radio mostly free for +# WiFi on the shared CYW43. Converted to the controller's 0.625 ms BLE units in +# to_code(). +SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("100ms") + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(RP2BLETracker), + cv.GenerateID(CONF_RP2040_BLE_ID): cv.use_id(rp2040_ble.RP2040BLE), + cv.Optional(CONF_SCAN_PARAMETERS, default={}): SCAN_PARAMETERS_SCHEMA, + } +).extend(cv.COMPONENT_SCHEMA) + + +# Runs at FINAL priority so every BLE sensor has registered through +# ble_device_base (and any tracker-owned listeners have been counted) before +# the StaticVector size is emitted. Same pattern as esp32_ble_tracker. +@coroutine_with_priority(CoroPriority.FINAL) +async def _emit_listener_count() -> None: + count = ble_device_base.get_listener_count() + if count > 0: + cg.add_define("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT", count) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + parent = await cg.get_variable(config[CONF_RP2040_BLE_ID]) + cg.add(var.set_parent(parent)) + + # Get notified when an OTA update starts, to pause scanning (esp32_ble_tracker parity) + ota.request_ota_state_listeners() + + scan = config[CONF_SCAN_PARAMETERS] + cg.add(var.set_scan_interval(ble_device_base.to_ble_units(scan[CONF_INTERVAL]))) + cg.add(var.set_scan_window(ble_device_base.to_ble_units(scan[CONF_WINDOW]))) + cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds)) + cg.add(var.set_scan_continuous(scan[CONF_CONTINUOUS])) + + CORE.add_job(_emit_listener_count) diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp new file mode 100644 index 0000000000..b2c25a8d0e --- /dev/null +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp @@ -0,0 +1,214 @@ +#ifdef USE_RP2 + +#include "rp2_ble_tracker.h" + +#include + +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::rp2_ble_tracker { + +static const char *const TAG = "rp2_ble_tracker"; + +// Minimum interval between scan start attempts on an active stack. The +// controller start has no failure mode once HCI is WORKING, so this fires at +// most once per enable cycle today; the floor is insurance against a future +// scan_start() failure being retried every main-loop iteration. +static constexpr uint32_t SCAN_START_RETRY_MS = 1000; + +// One BLE scan unit in milliseconds; the controller programs interval/window in these units. +static constexpr float BLE_SCAN_UNIT_MS = 0.625f; + +void RP2BLETracker::setup() { + // Receive the controller's scan reports; the controller queues them from the + // BTstack packet handler (IRQ) and delivers here on the main loop. + this->parent_->register_scan_listener(this); +#ifdef USE_OTA_STATE_LISTENER + // Pause scanning while an OTA update is in flight — the BLE scan competes with + // the OTA download on the shared CYW43 radio. Mirrors esp32_ble_tracker. + ota::get_global_ota_callback()->add_global_state_listener(this); +#endif + if (!this->scan_continuous_) { + // Nothing to do until an external start_scan(); the loop is re-enabled there. + this->disable_loop(); + } +} + +#ifdef USE_OTA_STATE_LISTENER +void RP2BLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) { + if (state == ota::OTA_STARTED) { + this->scan_continuous_before_ota_ = this->scan_continuous_; + // A one-shot scan counts as pending when it is running or still retrying + // its start (loop enabled); captured before stop_scan() disables the loop. + this->scan_pending_before_ota_ = !this->scan_continuous_ && (this->scan_running_ || this->is_in_loop_state()); + this->stop_scan(); + } else if (state == ota::OTA_ERROR || state == ota::OTA_ABORT) { + // On success the device reboots, so restore only on a failed/aborted update; + // loop()'s retry branch restarts the scan on its next iteration. + if (this->scan_continuous_before_ota_) { + this->scan_continuous_before_ota_ = false; + this->scan_continuous_ = true; + this->enable_loop(); + } + // A one-shot scan interrupted by the OTA resumes for a fresh duration + // rather than silently staying idle — an OTA failure does not reboot, so + // nothing external would restart it. + if (this->scan_pending_before_ota_) { + this->scan_pending_before_ota_ = false; + this->enable_loop(); + } + } +} +#endif // USE_OTA_STATE_LISTENER + +void RP2BLETracker::loop() { + const uint32_t now = App.get_loop_component_start_time(); + if (this->scan_running_ && !this->parent_->is_active()) { + // The controller was disabled underneath us (e.g. a lambda calling + // rp2040_ble's disable()); the scan died with the stack. Reconcile so the + // retry branch below takes over once the user re-enables the stack. + this->scan_running_ = false; + this->fire_scan_end_(); + } + if (!this->scan_running_) { + // A scan should be running but is not: continuous mode is always in this + // state until the start succeeds, and non-continuous mode only reaches + // here between start_scan() and a successful controller start, because + // stop_scan_() disables the loop otherwise. + if (!this->parent_->is_active()) { + // Stack not up (still booting, or the user called disable()) — + // scan_start() cannot succeed, so there is nothing to attempt; scanning + // starts on the first iteration after HCI reaches WORKING. + return; + } + if (now - this->last_scan_start_attempt_ >= SCAN_START_RETRY_MS) { + this->start_scan_(); + } + return; + } + + if (this->scan_continuous_) { + // Period timer: fire on_scan_end() once per scan_duration_ window, mirroring + // esp32_ble_tracker::cleanup_scan_state_(). + if (now - this->scan_period_start_ >= this->scan_duration_) { + this->fire_scan_end_(); + this->scan_period_start_ = now; + } + return; + } + + // Non-continuous mode: run for scan_duration_ ms, then stop and fire on_scan_end. + // Restart is driven externally (e.g. api: on_client_connected:). + if (now - this->scan_period_start_ >= this->scan_duration_) { + this->stop_scan_(); + } +} + +void RP2BLETracker::dump_config() { + ESP_LOGCONFIG(TAG, + "RP2 BLE Tracker:\n" + " Scan Duration: %" PRIu32 " s\n" + " Scan Interval: %.0f ms (%" PRIu32 " BLE units)\n" + " Scan Window: %.0f ms (%" PRIu32 " BLE units)\n" + " Scan Type: PASSIVE\n" + " Continuous Scanning: %s", + this->scan_duration_ / 1000, this->scan_interval_ * BLE_SCAN_UNIT_MS, this->scan_interval_, + this->scan_window_ * BLE_SCAN_UNIT_MS, this->scan_window_, YESNO(this->scan_continuous_)); +} + +void RP2BLETracker::on_scan_report(const rp2040_ble::BLEScanReport &report) { + // Raw callback (the raw-advertisement path). + if (this->raw_advertisement_callback_.is_set()) { + const ble_device_base::RawAdvertisement adv{.mac = report.mac, + .data = report.data, + .data_len = report.data_len, + .rssi = report.rssi, + .addr_type = report.addr_type}; + this->raw_advertisement_callback_.invoke(adv); + } + +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + ble_device_base::ESPBTDevice device; + device.from_scan_result(report.mac, report.rssi, report.addr_type, report.data, report.data_len); + bool found = false; + for (auto *listener : this->listeners_) { + if (listener->parse_device(device)) + found = true; + } + // Mirror esp32_ble_tracker: log a newly-seen device only when nothing claimed + // it and the scan is one-shot (continuous scans would spam). + if (!found && !this->scan_continuous_) + this->discovered_log_.log_device(TAG, device); +#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT +} + +void RP2BLETracker::start_scan() { + // Mirrors esp32_ble_tracker::start_scan(): caller sets scan_continuous_ via + // set_scan_continuous() first, then calls start_scan() to begin scanning. + this->enable_loop(); + this->start_scan_(); +} + +void RP2BLETracker::stop_scan() { + this->scan_continuous_ = false; + this->stop_scan_(); + // stop_scan_() early-returns when no scan is running, so disable the loop + // here too: a scan that never came up (stack still powering on at OTA start) + // must not keep attempting scan_start() from the loop's retry branch. + this->disable_loop(); +} + +void RP2BLETracker::start_scan_() { + if (this->scan_running_) + return; + + // Stamp every attempt regardless of caller so the loop's rate limit also + // covers a failed start that came through the public start_scan(). + this->last_scan_start_attempt_ = App.get_loop_component_start_time(); + + if (!this->parent_->scan_start(static_cast(this->scan_interval_), + static_cast(this->scan_window_))) + return; + + this->scan_running_ = true; + // Log every explicit start at DEBUG — stop_scan_() logs every stop at DEBUG, and + // in non-continuous mode each period is an explicit start, so asymmetric logging + // would read as the scanner failing to come back up. + ESP_LOGD(TAG, "Scan started (passive, window=%.0fms, interval=%.0fms)", this->scan_window_ * BLE_SCAN_UNIT_MS, + this->scan_interval_ * BLE_SCAN_UNIT_MS); + // Re-anchor the scan period to every successful start — first start (so the + // period counts from the scan, not from boot) and every restart after a stop (so + // resuming after longer than scan_duration, e.g. a failed OTA restoring continuous + // mode 10 minutes later, does not fire on_scan_end before an advertisement can + // arrive). Same clock as loop()'s `now`: a fresh millis() here would be ahead of + // the cached loop time and make the same-iteration period check underflow. + this->scan_period_start_ = App.get_loop_component_start_time(); +} + +void RP2BLETracker::stop_scan_() { + if (!this->scan_running_) + return; + this->parent_->scan_stop(); + this->scan_running_ = false; + ESP_LOGD(TAG, "Scan stopped"); + this->fire_scan_end_(); + // Reset the period clock so on_scan_end does not double-fire; same clock as loop(). + this->scan_period_start_ = App.get_loop_component_start_time(); + if (!this->scan_continuous_) { + // Nothing left to time; start_scan() re-enables the loop. + this->disable_loop(); + } +} + +void RP2BLETracker::fire_scan_end_() { +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + for (auto *listener : this->listeners_) + listener->on_scan_end(); + this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity) +#endif +} + +} // namespace esphome::rp2_ble_tracker + +#endif // USE_RP2 diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h new file mode 100644 index 0000000000..808e84a70f --- /dev/null +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h @@ -0,0 +1,114 @@ +#pragma once + +#ifdef USE_RP2 + +#include "esphome/components/ble_device_base/ble_device.h" +#include "esphome/components/ble_device_base/ble_hub.h" +#include "esphome/components/rp2040_ble/rp2040_ble.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include + +#ifdef USE_OTA_STATE_LISTENER +#include "esphome/components/ota/ota_backend.h" +#endif + +namespace esphome::rp2_ble_tracker { + +class RP2BLETracker : public Component, + public ble_device_base::BLEHub, + public rp2040_ble::BLEScanListener, + public Parented +#ifdef USE_OTA_STATE_LISTENER + , + public ota::OTAGlobalStateListener +#endif +{ + public: + // ---- ESPHome Component ---- + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + +#ifdef USE_OTA_STATE_LISTENER + // Pause scanning while an OTA update runs (the BLE scan competes with the OTA + // download on the shared CYW43 radio); mirrors esp32_ble_tracker. + void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override; +#endif + + // ---- YAML configuration setters ---- + void set_scan_interval(uint32_t scan_interval) { this->scan_interval_ = scan_interval; } + void set_scan_window(uint32_t scan_window) { this->scan_window_ = scan_window; } + void set_scan_duration(uint32_t scan_duration) { this->scan_duration_ = scan_duration; } + void set_scan_continuous(bool scan_continuous) { this->scan_continuous_ = scan_continuous; } + + // ---- Public scan control ---- + // Mirrors esp32_ble_tracker: set_scan_continuous() + start_scan() / stop_scan(). + void start_scan(); + void stop_scan(); + + // ---- ble_device_base::BLEHub contract ---- + void register_listener(ble_device_base::ESPBTDeviceListener *listener) override { +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + this->listeners_.push_back(listener); +#endif + } + void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override { + this->raw_advertisement_callback_ = callback; + } + ble_device_base::HubCapabilities get_capabilities() const override { + // BTstack on the CYW43 supports active scanning and GATT, but this tracker + // drives the controller passively (scan_type 0) and exposes no GATT path + // yet — capabilities describe what this component delivers, so all three + // stay false until those paths are implemented. Consumers relying on + // scan-response fields (device names) get them only where the receiver + // merges per address (Home Assistant does). + return {.active_scan = false, .merges_scan_response = false, .gatt = false}; + } + // The controller stores the address in printable (MSB-first) order, which is + // exactly what the contract wants. + void get_adapter_mac(uint8_t out[6]) override { this->parent_->get_mac_msb_first(out); } + bool scan_running() override { return this->scan_running_; } + bool scan_active() override { return false; } // passive-only (initial implementation) + + // ---- rp2040_ble::BLEScanListener ---- + // Delivered by the controller's loop() on the ESPHome main loop — the + // IRQ → main-loop handoff already happened in the controller's queue. + void on_scan_report(const rp2040_ble::BLEScanReport &report) override; + + protected: + void start_scan_(); + void stop_scan_(); + void fire_scan_end_(); + + // Defaults: 30 % duty cycle (interval 100 ms / window 30 ms), in 0.625 ms + // BLE units — same defaults as bk72xx_ble_tracker. + uint32_t scan_interval_{160}; // 160 × 0.625 ms = 100 ms + uint32_t scan_window_{48}; // 48 × 0.625 ms = 30 ms (30/100 = 30 %) + uint32_t scan_duration_{300000}; + uint32_t last_scan_start_attempt_{0}; // loop time of last start_scan_() attempt; rate-limits retries + uint32_t scan_period_start_{0}; // loop time at start of current scan period; rate-limits on_scan_end() + bool scan_running_{false}; + bool scan_continuous_{true}; +#ifdef USE_OTA_STATE_LISTENER + bool scan_continuous_before_ota_{false}; // continuous mode saved at OTA start, restored on OTA failure + bool scan_pending_before_ota_{false}; // one-shot scan in flight at OTA start, resumed on OTA failure +#endif + + ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{}; +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + // Parsed-advertisement consumers registered through ble_device_base. + // Codegen-sized: no heap allocation, no std::vector template instantiations. + StaticVector listeners_; + // Per-period "Found device" DEBUG log with MAC dedup — shared implementation + // in ble_device_base, identical output on every tracker backend. Guarded like + // its only writer so a no-listener build does not carry an unused vector. + ble_device_base::DiscoveredDeviceLog discovered_log_{}; +#endif +}; + +} // namespace esphome::rp2_ble_tracker + +#endif // USE_RP2 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 65726bdb1d..1d4cbcfc37 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -433,6 +433,7 @@ #define USE_LOGGER_USB_CDC #define USE_SOCKET_IMPL_LWIP_TCP #define USE_RP2040_BLE +#define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 #define USE_RP2040_VARIANT_RP2040 #define USE_SPI #ifndef USE_ETHERNET diff --git a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py index 363c129f4b..e3226eaa41 100644 --- a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py +++ b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py @@ -10,6 +10,7 @@ from esphome.components.bk72xx_ble_tracker import ( ) from esphome.components.ble_device_base import to_ble_units from esphome.components.esp32_ble_tracker import SCAN_PARAMETERS_SCHEMA as ESP32_SCHEMA +from esphome.components.rp2_ble_tracker import SCAN_PARAMETERS_SCHEMA as RP2_SCHEMA def _validate(**kwargs: str) -> dict: @@ -59,6 +60,15 @@ def test_esp32_defaults_are_valid() -> None: assert config["active"] is True +def test_rp2_defaults_are_valid() -> None: + """rp2 pins 100 ms interval / 30 ms window — a 30 % duty cycle leaving the + shared CYW43 radio mostly free for WiFi.""" + config = RP2_SCHEMA({}) + assert to_ble_units(config["interval"]) == 160 + assert to_ble_units(config["window"]) == 48 + assert "active" not in config + + def test_esp32_active_can_disable() -> None: config = ESP32_SCHEMA({"active": False}) assert config["active"] is False @@ -102,6 +112,11 @@ def test_window_equal_to_interval_accepted() -> None: assert to_ble_units(config["interval"]) == to_ble_units(config["window"]) +def test_duration_equal_to_three_intervals_accepted() -> None: + """The three-interval floor is inclusive, mirroring the ceilings above.""" + _validate(duration="3s", interval="1s", window="500ms") + + # --- rejected configurations --- diff --git a/tests/components/rp2_ble_tracker/common-boundary.yaml b/tests/components/rp2_ble_tracker/common-boundary.yaml new file mode 100644 index 0000000000..c8b558e226 --- /dev/null +++ b/tests/components/rp2_ble_tracker/common-boundary.yaml @@ -0,0 +1,11 @@ +rp2_ble_tracker: + id: ble_tracker + scan_parameters: + # Boundary coverage: the documented 2.5 ms floor on window (expressible only + # via the microsecond-accurate validation), a non-round interval exercising the + # 0.625 ms unit conversion without collapsing onto the window's unit count, + # and the non-continuous config path. + interval: 5000us + window: 2500us + duration: 5min + continuous: false diff --git a/tests/components/rp2_ble_tracker/common.yaml b/tests/components/rp2_ble_tracker/common.yaml new file mode 100644 index 0000000000..12482a6124 --- /dev/null +++ b/tests/components/rp2_ble_tracker/common.yaml @@ -0,0 +1,16 @@ +rp2_ble_tracker: + id: ble_tracker + scan_parameters: + interval: 100ms + window: 30ms + duration: 5min + continuous: true + +# Pulls in USE_OTA_STATE_LISTENER so the OTA scan-pause path compiles in CI +# (same coverage arrangement as the esp32_ble_tracker tests). +wifi: + ssid: MySSID + password: password1 + +ota: + - platform: esphome diff --git a/tests/components/rp2_ble_tracker/test.rp2040-ard.yaml b/tests/components/rp2_ble_tracker/test.rp2040-ard.yaml new file mode 100644 index 0000000000..8b94f3cade --- /dev/null +++ b/tests/components/rp2_ble_tracker/test.rp2040-ard.yaml @@ -0,0 +1,2 @@ +packages: + rp2_ble_tracker: !include common.yaml diff --git a/tests/components/rp2_ble_tracker/validate-boundary.rp2040-ard.yaml b/tests/components/rp2_ble_tracker/validate-boundary.rp2040-ard.yaml new file mode 100644 index 0000000000..b62a401320 --- /dev/null +++ b/tests/components/rp2_ble_tracker/validate-boundary.rp2040-ard.yaml @@ -0,0 +1,2 @@ +packages: + rp2_ble_tracker: !include common-boundary.yaml From 43deec306318ddea4adf4a03ba42bdaf44d61283 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 Aug 2026 19:53:37 -0500 Subject: [PATCH 1189/1815] [espnow] Stop polling the Wi-Fi driver every loop and disable loop when idle (#18027) --- esphome/components/espnow/__init__.py | 6 ++- .../components/espnow/espnow_component.cpp | 51 ++++++++++++++----- esphome/components/espnow/espnow_component.h | 14 +++++ tests/components/espnow/common-wifi.yaml | 9 ++++ .../espnow/test-wifi.esp32-idf.yaml | 2 + 5 files changed, 67 insertions(+), 15 deletions(-) create mode 100644 tests/components/espnow/common-wifi.yaml create mode 100644 tests/components/espnow/test-wifi.esp32-idf.yaml diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index c6c90ed67a..373ef345d1 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -13,7 +13,7 @@ from esphome.const import ( CONF_TRIGGER_ID, CONF_WIFI, ) -from esphome.core import HexInt +from esphome.core import CORE, HexInt from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] @@ -151,6 +151,10 @@ async def to_code(config): cg.add_define("USE_ESPNOW") cg.add_define("USE_ESPNOW_MAX_PAYLOAD_SIZE", config[CONF_MAX_PAYLOAD_SIZE]) + + if CONF_WIFI in CORE.config: + # Track the Wi-Fi channel via connect events instead of polling every loop + wifi.request_wifi_connect_state_listener() if wifi_channel := config.get(CONF_CHANNEL): cg.add(var.set_wifi_channel(wifi_channel)) diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index f28d7f3354..df9a1b8668 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -7,7 +7,6 @@ #include #include -#include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -75,6 +74,7 @@ void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status) if (packet == nullptr) { // No events available - queue is full or we're out of memory global_esp_now->receive_packet_queue_.increment_dropped_count(); + global_esp_now->enable_loop_soon_any_context(); return; } @@ -90,8 +90,8 @@ void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status) // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if // allocate() returned non-null, the queue cannot be full. - // Wake main loop immediately to process ESP-NOW send event - App.wake_loop_threadsafe(); + // Re-enable and wake the main loop to process the ESP-NOW send event + global_esp_now->enable_loop_soon_any_context(); } void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size) { @@ -101,6 +101,7 @@ void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int // larger frame would overflow packet_.receive.data. if (size < 0 || size > ESPNOW_MAX_DATA_LEN) { global_esp_now->receive_packet_queue_.increment_dropped_count(); + global_esp_now->enable_loop_soon_any_context(); return; } @@ -109,6 +110,7 @@ void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int if (packet == nullptr) { // No events available - queue is full or we're out of memory global_esp_now->receive_packet_queue_.increment_dropped_count(); + global_esp_now->enable_loop_soon_any_context(); return; } @@ -120,8 +122,8 @@ void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if // allocate() returned non-null, the queue cannot be full. - // Wake main loop immediately to process ESP-NOW receive event - App.wake_loop_threadsafe(); + // Re-enable and wake the main loop to process the ESP-NOW receive event + global_esp_now->enable_loop_soon_any_context(); } ESPNowComponent::ESPNowComponent() { global_esp_now = this; } @@ -156,6 +158,11 @@ bool ESPNowComponent::is_wifi_enabled() { } void ESPNowComponent::setup() { +#if defined(USE_WIFI) && defined(USE_WIFI_CONNECT_STATE_LISTENERS) + if (wifi::global_wifi_component != nullptr) { + wifi::global_wifi_component->add_connect_state_listener(this); + } +#endif if (this->enable_on_boot_) { this->enable_(); } else { @@ -163,6 +170,19 @@ void ESPNowComponent::setup() { } } +#if defined(USE_WIFI) && defined(USE_WIFI_CONNECT_STATE_LISTENERS) +void ESPNowComponent::on_wifi_connect_state(StringRef ssid, std::span bssid) { + if (ssid.empty()) { + return; // Disconnected; the channel is only meaningful while associated + } + uint8_t old_channel = this->wifi_channel_; + this->get_wifi_channel(); + if (this->wifi_channel_ != old_channel) { + ESP_LOGI(TAG, "WiFi channel changed from %d to %d", old_channel, this->wifi_channel_); + } +} +#endif + void ESPNowComponent::enable() { if (this->state_ == ESPNOW_STATE_ENABLED) return; @@ -254,15 +274,6 @@ void ESPNowComponent::apply_wifi_channel() { } void ESPNowComponent::loop() { -#ifdef USE_WIFI - if (wifi::global_wifi_component != nullptr && wifi::global_wifi_component->is_connected()) { - int32_t new_channel = wifi::global_wifi_component->get_wifi_channel(); - if (new_channel != this->wifi_channel_) { - ESP_LOGI(TAG, "Wifi Channel is changed from %d to %" PRId32 ".", this->wifi_channel_, new_channel); - this->wifi_channel_ = new_channel; - } - } -#endif // Process received packets ESPNowPacket *packet = this->receive_packet_queue_.pop(); while (packet != nullptr) { @@ -348,6 +359,15 @@ void ESPNowComponent::loop() { if (send_dropped > 0) { ESP_LOGW(TAG, "Dropped %u send packets (queue full)", send_dropped); } + + // Nothing left to do; sleep until a callback or send() re-enables the loop. + // A packet in flight (current_send_packet_) needs no loop time even when more + // packets are queued behind it: the send callback re-enables the loop when + // the result arrives, and the SENT event handler above starts the next send. + if (this->receive_packet_queue_.empty() && + (this->current_send_packet_ != nullptr || this->send_packet_queue_.empty())) { + this->disable_loop(); + } } uint8_t ESPNowComponent::get_wifi_channel() { @@ -390,6 +410,9 @@ esp_err_t ESPNowComponent::send(const uint8_t *peer_address, const uint8_t *payl packet->load_data(peer_address, payload, size, callback); // Push the packet to the send queue this->send_packet_queue_.push(packet); + // Loop may be disabled while idle; re-enable it to send the packet + // (any-context variant so callers off the main loop are safe too) + this->enable_loop_soon_any_context(); return ESP_OK; } diff --git a/esphome/components/espnow/espnow_component.h b/esphome/components/espnow/espnow_component.h index d95255c5df..af693b47cf 100644 --- a/esphome/components/espnow/espnow_component.h +++ b/esphome/components/espnow/espnow_component.h @@ -2,6 +2,7 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/defines.h" #ifdef USE_ESP32 @@ -9,6 +10,10 @@ #include "esphome/core/lock_free_queue.h" #include "espnow_packet.h" +#if defined(USE_WIFI) && defined(USE_WIFI_CONNECT_STATE_LISTENERS) +#include "esphome/components/wifi/wifi_component.h" +#endif + #include #include @@ -88,7 +93,11 @@ class ESPNowBroadcastHandler { virtual bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) = 0; }; +#if defined(USE_WIFI) && defined(USE_WIFI_CONNECT_STATE_LISTENERS) +class ESPNowComponent final : public Component, public wifi::WiFiConnectStateListener { +#else class ESPNowComponent final : public Component { +#endif public: ESPNowComponent(); void setup() override; @@ -114,6 +123,11 @@ class ESPNowComponent final : public Component { void set_auto_add_peer(bool value) { this->auto_add_peer_ = value; } +#if defined(USE_WIFI) && defined(USE_WIFI_CONNECT_STATE_LISTENERS) + // WiFiConnectStateListener interface: refresh the cached channel after each (re)connect + void on_wifi_connect_state(StringRef ssid, std::span bssid) override; +#endif + void enable(); void disable(); bool is_disabled() const { return this->state_ == ESPNOW_STATE_DISABLED; }; diff --git a/tests/components/espnow/common-wifi.yaml b/tests/components/espnow/common-wifi.yaml new file mode 100644 index 0000000000..5ffa9dd44b --- /dev/null +++ b/tests/components/espnow/common-wifi.yaml @@ -0,0 +1,9 @@ +wifi: + ssid: MySSID + password: password1 + +espnow: + id: espnow_component + auto_add_peer: true + peers: + - 11:22:33:44:55:66 diff --git a/tests/components/espnow/test-wifi.esp32-idf.yaml b/tests/components/espnow/test-wifi.esp32-idf.yaml new file mode 100644 index 0000000000..c45547cd53 --- /dev/null +++ b/tests/components/espnow/test-wifi.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + espnow: !include common-wifi.yaml From 644f27997265126d050be035da20ed07ea379a3e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 Aug 2026 19:53:51 -0500 Subject: [PATCH 1190/1815] [logger] Stub out ROM ets_putc on ESP8266 when serial logging is disabled (#17970) --- esphome/components/logger/__init__.py | 10 ++++++++-- esphome/components/logger/logger_esp8266.cpp | 13 +++++++++++++ .../logger/test-uart0_no_logging.esp8266-ard.yaml | 1 + 3 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 tests/components/logger/test-uart0_no_logging.esp8266-ard.yaml diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 77a875dd8f..f307f5d5d1 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -410,10 +410,16 @@ async def _late_logger_init(config: ConfigType) -> None: from esphome.components.esp8266.const import enable_serial, enable_serial1 hw_uart = config.get(CONF_HARDWARE_UART, UART0) - if has_serial_logging and hw_uart in (UART0, UART0_SWAP): + if not has_serial_logging: + # No serial logging: stub out ROM ets_putc so stray output (newlib + # stdout, lwIP diagnostics) cannot block on a slow or shared UART0. + # ets_putc always writes to the physical UART and cannot be disabled + # through uart_set_debug(); see __wrap_ets_putc in logger_esp8266.cpp. + cg.add_build_flag("-Wl,--wrap=ets_putc") + elif hw_uart in (UART0, UART0_SWAP): cg.add_define("USE_ESP8266_LOGGER_SERIAL") enable_serial() - elif has_serial_logging and hw_uart == UART1: + elif hw_uart == UART1: cg.add_define("USE_ESP8266_LOGGER_SERIAL1") enable_serial1() diff --git a/esphome/components/logger/logger_esp8266.cpp b/esphome/components/logger/logger_esp8266.cpp index 5797b03ba7..ac71ba8e3b 100644 --- a/esphome/components/logger/logger_esp8266.cpp +++ b/esphome/components/logger/logger_esp8266.cpp @@ -49,4 +49,17 @@ const LogString *Logger::get_uart_selection_() { } } // namespace esphome::logger + +#if !defined(USE_ESP8266_LOGGER_SERIAL) && !defined(USE_ESP8266_LOGGER_SERIAL1) +// With serial logging disabled, ROM ets_putc still writes to the physical UART0 +// at whatever baud rate a uart bus configured there; uart_set_debug(UART_NO) +// only silences the installable putc1 hook, not ets_putc itself. Blocking +// writes at a low baud rate (for example 4800 for a power monitoring chip) can +// starve the soft watchdog. All linked callers (newlib stdout, lwIP +// diagnostics, postmortem dumps) are redirected here by -Wl,--wrap=ets_putc. +// IRAM_ATTR because the ROM original is callable with the flash cache +// disabled (for example from newlib's _write_r, which is placed in IRAM). +extern "C" void IRAM_ATTR __wrap_ets_putc(char) {} +#endif + #endif diff --git a/tests/components/logger/test-uart0_no_logging.esp8266-ard.yaml b/tests/components/logger/test-uart0_no_logging.esp8266-ard.yaml new file mode 100644 index 0000000000..76444a2e89 --- /dev/null +++ b/tests/components/logger/test-uart0_no_logging.esp8266-ard.yaml @@ -0,0 +1 @@ +<<: !include common-uart0_no_logging.yaml From 2b6bf1f0fe15c828794c2840e1e6cfe3016e4fe9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 Aug 2026 19:54:43 -0500 Subject: [PATCH 1191/1815] [esp8266] Move wifi rate tables to DRAM to fix beacon parse crash (#17968) --- esphome/components/esp8266/__init__.py | 2 + .../esp8266/relocate_ratetable.py.script | 70 +++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 esphome/components/esp8266/relocate_ratetable.py.script diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 618bf775a0..1f7159919d 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -299,6 +299,7 @@ async def to_code(config): "pre:testing_mode.py", "pre:exclude_updater.py", "pre:exclude_waveform.py", + "pre:relocate_ratetable.py", ] if not enable_scanf_float: extra_scripts.append("pre:remove_float_scanf.py") @@ -451,6 +452,7 @@ def copy_files() -> None: "exclude_updater", "exclude_waveform", "remove_float_scanf", + "relocate_ratetable", ): copy_file_if_changed( dir / f"{script}.py.script", diff --git a/esphome/components/esp8266/relocate_ratetable.py.script b/esphome/components/esp8266/relocate_ratetable.py.script new file mode 100644 index 0000000000..c9d0ba166d --- /dev/null +++ b/esphome/components/esp8266/relocate_ratetable.py.script @@ -0,0 +1,70 @@ +# pylint: disable=E0602 +Import("env") # noqa + +# Move the NONOS SDK wifi rate tables from flash to DRAM +# +# libnet80211.a ships its 802.11b/11g rate tables in the .irom.text section +# of ieee80211_phy.o (440 bytes of pure data, no relocations). The Arduino +# core linker script places .irom.text in flash, but the SDK reads these +# tables with byte loads and ets_memcpy from the wifi RX path while parsing +# beacons. Byte access to flash-mapped memory from that context misbehaves +# and crashes with StoreProhibited in ROM memcpy (PC 0x4000df64): +# +# scan_parse_beacon -> cnx_update_bss_more -> ieee80211_phy_init +# -> ieee80211_setup_ratetable -> ets_memcpy -> crash +# +# See https://github.com/espressif/ESP8266_NONOS_SDK/issues/320 (1000+ +# reports). The SDK is abandoned so the fix from +# https://github.com/espressif/ESP8266_NONOS_SDK/pull/345 was never merged; +# we apply the same linker rule here: place ieee80211_phy.o's .irom.text +# inside the DRAM .data output section so the tables are copied to RAM at +# boot. Costs 440 bytes of DRAM. +# +# The rule is inserted into the working linker script that PlatformIO +# generates in the build directory (local.eagle.app.v6.common.ld). SDK +# package files are never modified. + +import re +from os.path import join + +RULE = "*libnet80211.a:ieee80211_phy.o(.irom.text .irom.text.*)" +# Match the whole line: "_data_start" is also a substring of the +# "_dport0_data_start" line in the earlier .dport0.data section +ANCHOR = re.compile(r"^\s*_data_start = ABSOLUTE\(\.\);", re.MULTILINE) + + +def relocate_ratetable(source, target, env): + """Insert the rate table DRAM rule into the generated linker script. + + Runs as a pre-action of the link step; the linker script is a declared + dependency of the elf, so it has already been generated at this point. + """ + ld_path = join(env.subst("$BUILD_DIR"), "ld", "local.eagle.app.v6.common.ld") + with open(ld_path, encoding="utf-8") as f: + contents = f.read() + + if RULE in contents: + return # Already patched (incremental build) + + match = ANCHOR.search(contents) + if match is None: + raise RuntimeError( + f"ESPHome: '_data_start' anchor not found in {ld_path}; " + "cannot apply wifi rate table DRAM relocation " + "(has the Arduino core linker script changed?)" + ) + + insert_pos = match.end() + patched = ( + contents[:insert_pos] + + "\n /* ESPHome: wifi rate tables must live in DRAM, see NONOS SDK issue 320 */" + + f"\n {RULE}" + + contents[insert_pos:] + ) + with open(ld_path, "w", encoding="utf-8") as f: + f.write(patched) + print("ESPHome: Relocated wifi rate tables to DRAM (fixes beacon parse crash)") + + +# Register the callback to run before the link step +env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", relocate_ratetable) From e8dadf28528b481a0f724e48c1c4b14dffef29a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 Aug 2026 19:56:20 -0500 Subject: [PATCH 1192/1815] [esp32] Flag crash records captured by a different firmware build (#17770) --- esphome/components/esp32/crash_handler.cpp | 109 ++++++++++++++---- .../components/test_esp_stacktrace.py | 32 +++++ 2 files changed, 121 insertions(+), 20 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 4c0f430daf..1b054dcc49 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -4,6 +4,7 @@ #ifdef USE_ESP32_CRASH_HANDLER #include "crash_handler.h" +#include "esphome/core/build_info_data.h" #include "esphome/core/log.h" #include @@ -122,7 +123,7 @@ static uint8_t IRAM_ATTR capture_riscv_backtrace(RvExcFrame *frame, uint32_t *ou // Magic is second to validate the data. Remaining fields can change between versions. // Version is uint32_t because it would be padded to 4 bytes anyway before the next // uint32_t field, so we use the full width rather than wasting 3 bytes of padding. -static constexpr uint32_t CRASH_DATA_VERSION = 3; +static constexpr uint32_t CRASH_DATA_VERSION = 4; struct RawCrashData { uint32_t version; uint32_t magic; @@ -134,6 +135,7 @@ struct RawCrashData { uint32_t backtrace[MAX_BACKTRACE]; uint32_t cause; // Architecture-specific: exccause (Xtensa) or mcause (RISC-V) uint32_t fault_addr; // Faulting memory address: excvaddr (Xtensa) or mtval (RISC-V) + uint32_t build_time; // ESPHOME_BUILD_TIME of the firmware that captured this record uint8_t crashed_core; #if SOC_CPU_CORES_NUM > 1 static_assert(SOC_CPU_CORES_NUM == 2, "Dual-core logic assumes exactly 2 cores"); @@ -152,6 +154,16 @@ namespace esphome::esp32 { static const char *const TAG = "esp32.crash"; +// RAM copy of the build timestamp. The generated constant lives in flash, +// which the panic handler must not read (cache may be disabled during +// cache-error panics), so the wrapper stamps the record from this mirror +// instead. Filled during C++ dynamic initialization, well before arch_init(); +// ESPHOME_BUILD_TIME itself is constant-initialized, so the read is ordered. +// Unqualified name on purpose: the runtime header declares it in namespace +// esphome, while the static-analysis stub defines it as a macro. +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +static uint32_t s_current_build_time = static_cast(ESPHOME_BUILD_TIME); + void crash_handler_read_and_clear() { if (s_raw_crash_data.magic == CRASH_MAGIC && s_raw_crash_data.version == CRASH_DATA_VERSION) { s_crash_data_valid = true; @@ -331,6 +343,66 @@ static int append_addrs_to_hint(char *buf, int size, int pos, const uint32_t *ad return pos; } +// Register holding the faulting memory address, named as in ESP-IDF's live +// register dump. The lowercase form is for old-build reports, where the +// stacktrace decoders must not match the line. +#if CONFIG_IDF_TARGET_ARCH_XTENSA +static const char *const FAULT_ADDR_REG = "EXCVADDR"; +static const char *const FAULT_ADDR_REG_LOWER = "excvaddr"; +#elif CONFIG_IDF_TARGET_ARCH_RISCV +static const char *const FAULT_ADDR_REG = "MTVAL"; +static const char *const FAULT_ADDR_REG_LOWER = "mtval"; +#endif + +// Whether the fault address is meaningful — real CPU faults only, not +// aborts/watchdogs or SoC-level pseudo exceptions. +static bool has_fault_addr() { + return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause; +} + +// Append both cores' backtrace addresses to buf; returns the new position. +static int append_all_backtraces(char *buf, int size, int pos) { + pos = append_addrs_to_hint(buf, size, pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, + s_raw_crash_data.reg_frame_count); +#if SOC_CPU_CORES_NUM > 1 + pos = append_addrs_to_hint(buf, size, pos, s_raw_crash_data.other_backtrace, s_raw_crash_data.other_backtrace_count, + s_raw_crash_data.other_reg_frame_count); +#endif + return pos; +} + +// The record was captured by a different firmware build (it survives soft +// resets, including the OTA reboot), so symbolizing its addresses against the +// current ELF would produce misleading symbols. Print them with lowercase +// labels the stacktrace decoders deliberately do not match, and skip the +// addr2line hint. One line per address so nothing is lost to a shared buffer. +// No is_return_addr() filtering here: it would inspect the current build's +// code bytes, which say nothing about addresses captured by the old build. +static uint8_t log_foreign_backtrace(const uint32_t *addrs, uint8_t count, uint8_t bt_num) { + for (uint8_t i = 0; i < count; i++) { + ESP_LOGE(TAG, " bt%d: 0x%08" PRIX32, bt_num++, addrs[i]); + } + return bt_num; +} + +static void log_foreign_addresses() { + ESP_LOGE(TAG, " Captured by a different firmware build; addresses belong to that build's ELF"); + ESP_LOGE(TAG, " pc: 0x%08" PRIX32, s_raw_crash_data.pc); + if (has_fault_addr()) { + ESP_LOGE(TAG, " %s: 0x%08" PRIX32, FAULT_ADDR_REG_LOWER, s_raw_crash_data.fault_addr); + } + uint8_t bt_num = log_foreign_backtrace(s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, 0); +#if SOC_CPU_CORES_NUM > 1 + if (s_raw_crash_data.other_backtrace_count > 0) { + // Lowercase like the address labels: carries no address, matches no decoder. + ESP_LOGE(TAG, " other core (%d):", 1 - s_raw_crash_data.crashed_core); + log_foreign_backtrace(s_raw_crash_data.other_backtrace, s_raw_crash_data.other_backtrace_count, bt_num); + } +#else + (void) bt_num; // Single-core targets have no second list to continue numbering into. +#endif +} + // Intentionally uses separate ESP_LOGE calls per line instead of combining into // one multi-line log message. This ensures each address appears as its own line // on the serial console, making it possible to see partial output if the device @@ -348,18 +420,17 @@ void crash_handler_log() { ESP_LOGE(TAG, " Reason: %s", get_exception_type()); } ESP_LOGE(TAG, " Crashed core: %d", s_raw_crash_data.crashed_core); + if (s_raw_crash_data.build_time != s_current_build_time) { + // Captured by a different firmware build: the record survives soft resets + // including the OTA reboot, so its addresses belong to a previous ELF. + log_foreign_addresses(); + return; + } ESP_LOGE(TAG, " PC: 0x%08" PRIX32 " (fault location)", s_raw_crash_data.pc); - // Faulting memory address — only meaningful for real CPU faults, not - // aborts/watchdogs or SoC-level pseudo exceptions. Uses the same register - // name as ESP-IDF's live register dump for the architecture (EXCVADDR on - // Xtensa, MTVAL on RISC-V) so the CLI decodes it when it happens to be a - // code address. - if (s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause) { -#if CONFIG_IDF_TARGET_ARCH_XTENSA - ESP_LOGE(TAG, " EXCVADDR: 0x%08" PRIX32 " (faulting address)", s_raw_crash_data.fault_addr); -#elif CONFIG_IDF_TARGET_ARCH_RISCV - ESP_LOGE(TAG, " MTVAL: 0x%08" PRIX32 " (faulting address)", s_raw_crash_data.fault_addr); -#endif + // Uses the same register name as ESP-IDF's live register dump so the CLI + // decodes the address when it happens to be a code address. + if (has_fault_addr()) { + ESP_LOGE(TAG, " %s: 0x%08" PRIX32 " (faulting address)", FAULT_ADDR_REG, s_raw_crash_data.fault_addr); } log_backtrace(s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, s_raw_crash_data.reg_frame_count); @@ -375,14 +446,7 @@ void crash_handler_log() { // Build addr2line hint with all captured addresses for easy copy-paste char hint[256]; int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e firmware.elf 0x%08" PRIX32, s_raw_crash_data.pc); - pos = append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, - s_raw_crash_data.reg_frame_count); -#if SOC_CPU_CORES_NUM > 1 - append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.other_backtrace, - s_raw_crash_data.other_backtrace_count, s_raw_crash_data.other_reg_frame_count); -#else - (void) pos; // There is no second-core append on single-core targets, so pos would otherwise be unread. -#endif + append_all_backtraces(hint, sizeof(hint), pos); ESP_LOGE(TAG, "%s", hint); } @@ -408,6 +472,11 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { // Zero unconditionally so a null frame doesn't leave stale .noinit data from a previous boot s_raw_crash_data.cause = 0; s_raw_crash_data.fault_addr = 0; + // Record which build's ELF the captured addresses belong to (RAM read, panic-safe). + // Still 0 if the panic precedes C++ dynamic initialization, so such a crash + // reports as a foreign build — conservative: addresses are shown raw instead + // of decoded. + s_raw_crash_data.build_time = esphome::esp32::s_current_build_time; #if SOC_CPU_CORES_NUM > 1 s_raw_crash_data.other_backtrace_count = 0; s_raw_crash_data.other_reg_frame_count = 0; diff --git a/tests/unit_tests/components/test_esp_stacktrace.py b/tests/unit_tests/components/test_esp_stacktrace.py index eb7e63fc4d..ed1b12029e 100644 --- a/tests/unit_tests/components/test_esp_stacktrace.py +++ b/tests/unit_tests/components/test_esp_stacktrace.py @@ -179,3 +179,35 @@ def test_process_stacktrace_esp32_crash_handler( state = process_stacktrace(config, line_mtval_data, False) mock_esp32_decode_pc.assert_not_called() assert state is False + + +def test_process_stacktrace_esp32_foreign_crash( + setup_core: Path, mock_esp32_decode_pc: Mock +) -> None: + """Crash records from a different firmware build must not be decoded.""" + from esphome.components.esp32 import process_stacktrace + + config = {"name": "test"} + + line_note = ( + "[E][esp32.crash:390]: Captured by a different firmware build; " + "addresses belong to that build's ELF" + ) + state = process_stacktrace(config, line_note, False) + mock_esp32_decode_pc.assert_not_called() + assert state is False + + # Lowercase labels are deliberately not matched by any decoder regex, + # since symbols would come from the wrong ELF + lines_addrs = [ + "[E][esp32.crash:391]: pc: 0x400D1234", + "[E][esp32.crash:392]: excvaddr: 0x400D5678", + "[E][esp32.crash:392]: mtval: 0x42001234", + "[E][esp32.crash:393]: bt0: 0x400F19A6", + "[E][esp32.crash:394]: other core (0):", + "[E][esp32.crash:395]: bt15: 0x42005ABC", + ] + for line in lines_addrs: + state = process_stacktrace(config, line, False) + mock_esp32_decode_pc.assert_not_called() + assert state is False From 4424af8c5bd8b75090f5cd4d040e9c4d2deee136 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 Aug 2026 19:57:52 -0500 Subject: [PATCH 1193/1815] [api] Move runtime log client out of the component package (#18043) --- esphome/__main__.py | 2 +- esphome/api_client.py | 173 +++++++++++++++++ esphome/components/api/__init__.py | 2 +- esphome/components/api/client.py | 181 +----------------- .../components/packet_transport/__init__.py | 2 +- .../udp/packet_transport/__init__.py | 3 +- esphome/const.py | 1 + script/ci-custom.py | 2 +- tests/unit_tests/components/api/__init__.py | 0 .../api/test_client.py => test_api_client.py} | 95 ++++++++- tests/unit_tests/test_lazy_imports.py | 33 +++- tests/unit_tests/test_main.py | 18 +- 12 files changed, 315 insertions(+), 197 deletions(-) create mode 100644 esphome/api_client.py delete mode 100644 tests/unit_tests/components/api/__init__.py rename tests/unit_tests/{components/api/test_client.py => test_api_client.py} (64%) diff --git a/esphome/__main__.py b/esphome/__main__.py index e56b504398..3893c678e1 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1429,7 +1429,7 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int if has_api() and ( network_devices := _resolve_network_devices(devices, config, args) ): - from esphome.components.api.client import run_logs + from esphome.api_client import run_logs return run_logs( config, diff --git a/esphome/api_client.py b/esphome/api_client.py new file mode 100644 index 0000000000..0ee2a7bed3 --- /dev/null +++ b/esphome/api_client.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import asyncio +from contextlib import suppress +from datetime import datetime +import importlib +import logging +from typing import TYPE_CHECKING, Any +import warnings + +# Suppress protobuf version warnings +with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", category=UserWarning, message=".*Protobuf gencode version.*" + ) + from aioesphomeapi import APIClient, parse_log_message + from aioesphomeapi.log_runner import async_run + +from esphome.const import CONF_ENCRYPTION, CONF_KEY, CONF_PORT, __version__ +from esphome.core import CORE +from esphome.util import safe_print + +if TYPE_CHECKING: + from aioesphomeapi.api_pb2 import ( + SubscribeLogsResponse, # pylint: disable=no-name-in-module + ) + + +_LOGGER = logging.getLogger(__name__) + + +class _LogLineProcessor: + """Feeds incoming log lines to the stack-trace decoder. + + Two responsibilities beyond just calling the decoder: + 1. Catch everything the decoder can raise. aioesphomeapi isolates + exceptions raised by log handlers, so an escaping one no longer + kills the session, but it does log a full traceback per line. A + crash dump carries a PC line plus one per backtrace frame, so the + tracebacks bury the dump the user is trying to read. Decoding is a + diagnostic nicety; nothing it raises is worth that noise. + 2. Disable decoding after the first failure. _decode_pc shells out to + the toolchain to resolve addr2line, which is expensive; a single + crash dump can contain many PC/BT lines and we don't want to retry + the failing subprocess for each one. This only works if every + failure is caught, which is why 1 is not narrowed to EsphomeError. + """ + + def __init__(self, config: dict[str, Any], platform_handler: Any | None) -> None: + self._config = config + self._platform_handler = platform_handler + self._decode_enabled = platform_handler is not None + self.backtrace_state = False + + def process_line(self, raw_line: str) -> None: + if not self._decode_enabled: + return + try: + self.backtrace_state = self._platform_handler( + self._config, raw_line, self.backtrace_state + ) + except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except + self._decode_enabled = False + self.backtrace_state = False + # _run_idedata raises EsphomeError with no message; fall back + # to a generic explanation when str(exc) is empty. + detail = str(exc) or "build artifacts not found locally" + _LOGGER.debug("Stack-trace decoding failed", exc_info=True) + _LOGGER.warning( + "Crash trace decoding unavailable: %s. " + "Run 'esphome compile' for this device to enable PC decoding.", + detail, + ) + + +async def async_run_logs( + config: dict[str, Any], + addresses: list[str], + subscribe_states: bool = True, +) -> None: + """Run the logs command in the event loop.""" + conf = config["api"] + name = config["esphome"]["name"] + port: int = int(conf[CONF_PORT]) + noise_psk: str | None = None + if (encryption := conf.get(CONF_ENCRYPTION)) and (key := encryption.get(CONF_KEY)): + noise_psk = key + + _LOGGER.info( + "Starting log output from %s using esphome API", " or ".join(addresses) + ) + + cli = APIClient( + addresses[0], # Primary address for compatibility + port, + "", # Password auth removed in 2026.1.0 + client_info=f"ESPHome Logs {__version__}", + noise_psk=noise_psk, + addresses=addresses, # Pass all addresses for automatic retry + provide_time=False, + ) + + # Try platform-specific stacktrace handler first, fall back to generic + platform_process_stacktrace = None + try: + module = importlib.import_module("esphome.components." + CORE.target_platform) + platform_process_stacktrace = module.process_stacktrace + except (AttributeError, ImportError): + # Distinguish "platform has no analyzer" from a genuinely broken + # platform package when debugging. + _LOGGER.debug("Stacktrace analyzer lookup failed", exc_info=True) + _LOGGER.info( + 'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".', + CORE.target_platform, + ) + + processor = _LogLineProcessor(config, platform_process_stacktrace) + + def on_log(msg: SubscribeLogsResponse) -> None: + """Handle a new log message.""" + time_ = datetime.now().astimezone() + message: bytes = msg.message + text = message.decode("utf8", "backslashreplace") + nanoseconds = time_.microsecond // 1000 + timestamp = ( + f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{nanoseconds:03}]" + ) + for parsed_msg in parse_log_message(text, timestamp): + # safe_print handles the dashboard \033 escaping and falls back + # to backslashreplace encoding on stdouts that can't represent + # the wifi signal-bar block characters (Windows redirected + # cp1252 pipe). + safe_print(parsed_msg) + for raw_line in text.splitlines(): + processor.process_line(raw_line) + + # Safe to fall back to plaintext here only for this diagnostics use + # case: the stream is one-way from device to client, and this code + # never accepts commands or acts on any message the device sends. + # An on-path attacker could still both inject fabricated log lines + # and passively read the device's log output (and any state data + # delivered when subscribe_states is enabled), so this does lose + # confidentiality as well as authentication/integrity. That tradeoff + # is acceptable for operator-visible logs, which aioesphomeapi also + # warns may come from an unverified device. Never mirror this opt-in + # for any connection that sends data to the device or uses Home + # Assistant actions. + stop = await async_run( + cli, + on_log, + name=name, + subscribe_states=subscribe_states, + allow_plaintext_fallback=True, + # A top-level ``deep_sleep:`` block means the device is only awake + # briefly; cap the reconnect backoff so a wake window is not missed. + deep_sleep="deep_sleep" in config, + ) + try: + await asyncio.Event().wait() + finally: + await stop() + + +def run_logs( + config: dict[str, Any], + addresses: list[str], + subscribe_states: bool = True, +) -> None: + """Run the logs command.""" + with suppress(KeyboardInterrupt): + asyncio.run( + async_run_logs(config, addresses, subscribe_states=subscribe_states) + ) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 0719cee352..8ec94df1db 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( CONF_CAPTURE_RESPONSE, CONF_DATA, CONF_DATA_TEMPLATE, + CONF_ENCRYPTION, CONF_EVENT, CONF_ID, CONF_KEY, @@ -102,7 +103,6 @@ SERVICE_ARG_FALLBACK_TYPES: dict[str, MockObj] = { for name, t in _SERVICE_ARG_SCALAR_TYPES.items() }, } -CONF_ENCRYPTION = "encryption" CONF_BATCH_DELAY = "batch_delay" CONF_CUSTOM_SERVICES = "custom_services" CONF_HOMEASSISTANT_SERVICES = "homeassistant_services" diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 7f07146dba..5e1c88b2ca 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -1,177 +1,10 @@ -from __future__ import annotations +"""Backward-compatibility shim; the log client lives in esphome.api_client. -import asyncio -from datetime import datetime -import importlib -import logging -from typing import TYPE_CHECKING, Any -import warnings +Importing this module executes the whole api component package, which pulls +in the validation stack. CLI code paths should import esphome.api_client +directly so the logs fast path stays light. +""" -# Suppress protobuf version warnings -with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", category=UserWarning, message=".*Protobuf gencode version.*" - ) - from aioesphomeapi import APIClient, parse_log_message - from aioesphomeapi.log_runner import async_run +from esphome.api_client import async_run_logs, run_logs -import contextlib - -from esphome.const import CONF_KEY, CONF_PORT, __version__ -from esphome.core import CORE -from esphome.util import safe_print - -from . import CONF_ENCRYPTION - -if TYPE_CHECKING: - from aioesphomeapi.api_pb2 import ( - SubscribeLogsResponse, # pylint: disable=no-name-in-module - ) - - -_LOGGER = logging.getLogger(__name__) - - -class _LogLineProcessor: - """Feeds incoming log lines to the stack-trace decoder. - - Two responsibilities beyond just calling the decoder: - 1. Catch everything the decoder can raise. aioesphomeapi isolates - exceptions raised by log handlers, so an escaping one no longer - kills the session, but it does log a full traceback per line. A - crash dump carries a PC line plus one per backtrace frame, so the - tracebacks bury the dump the user is trying to read. Decoding is a - diagnostic nicety; nothing it raises is worth that noise. - 2. Disable decoding after the first failure. _decode_pc shells out to - the toolchain to resolve addr2line, which is expensive; a single - crash dump can contain many PC/BT lines and we don't want to retry - the failing subprocess for each one. This only works if every - failure is caught, which is why 1 is not narrowed to EsphomeError. - """ - - def __init__(self, config: dict[str, Any], platform_handler: Any | None) -> None: - self._config = config - self._platform_handler = platform_handler - self._decode_enabled = True - self.backtrace_state = False - - def process_line(self, raw_line: str) -> None: - if not self._decode_enabled: - return - try: - if self._platform_handler is not None: - self.backtrace_state = self._platform_handler( - self._config, raw_line, self.backtrace_state - ) - except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except - self._decode_enabled = False - self.backtrace_state = False - # _run_idedata raises EsphomeError with no message; fall back - # to a generic explanation when str(exc) is empty. - detail = str(exc) or "build artifacts not found locally" - _LOGGER.debug("Stack-trace decoding failed", exc_info=True) - _LOGGER.warning( - "Crash trace decoding unavailable: %s. " - "Run 'esphome compile' for this device to enable PC decoding.", - detail, - ) - - -async def async_run_logs( - config: dict[str, Any], - addresses: list[str], - subscribe_states: bool = True, -) -> None: - """Run the logs command in the event loop.""" - conf = config["api"] - name = config["esphome"]["name"] - port: int = int(conf[CONF_PORT]) - noise_psk: str | None = None - if (encryption := conf.get(CONF_ENCRYPTION)) and (key := encryption.get(CONF_KEY)): - noise_psk = key - - if len(addresses) == 1: - _LOGGER.info("Starting log output from %s using esphome API", addresses[0]) - else: - _LOGGER.info( - "Starting log output from %s using esphome API", " or ".join(addresses) - ) - - cli = APIClient( - addresses[0], # Primary address for compatibility - port, - "", # Password auth removed in 2026.1.0 - client_info=f"ESPHome Logs {__version__}", - noise_psk=noise_psk, - addresses=addresses, # Pass all addresses for automatic retry - provide_time=False, - ) - - # Try platform-specific stacktrace handler first, fall back to generic - platform_process_stacktrace = None - try: - module = importlib.import_module("esphome.components." + CORE.target_platform) - platform_process_stacktrace = module.process_stacktrace - except (AttributeError, ImportError): - _LOGGER.info( - 'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".', - CORE.target_platform, - ) - - processor = _LogLineProcessor(config, platform_process_stacktrace) - - def on_log(msg: SubscribeLogsResponse) -> None: - """Handle a new log message.""" - time_ = datetime.now().astimezone() - message: bytes = msg.message - text = message.decode("utf8", "backslashreplace") - nanoseconds = time_.microsecond // 1000 - timestamp = ( - f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{nanoseconds:03}]" - ) - for parsed_msg in parse_log_message(text, timestamp): - # safe_print handles the dashboard \033 escaping and falls back - # to backslashreplace encoding on stdouts that can't represent - # the wifi signal-bar block characters (Windows redirected - # cp1252 pipe). - safe_print(parsed_msg) - for raw_line in text.splitlines(): - processor.process_line(raw_line) - - # Safe to fall back to plaintext here only for this diagnostics use - # case: the stream is one-way from device to client, and this code - # never accepts commands or acts on any message the device sends. - # An on-path attacker could still both inject fabricated log lines - # and passively read the device's log output (and any state data - # delivered when subscribe_states is enabled), so this does lose - # confidentiality as well as authentication/integrity. That tradeoff - # is acceptable for operator-visible logs, which aioesphomeapi also - # warns may come from an unverified device. Never mirror this opt-in - # for any connection that sends data to the device or uses Home - # Assistant actions. - stop = await async_run( - cli, - on_log, - name=name, - subscribe_states=subscribe_states, - allow_plaintext_fallback=True, - # A top-level ``deep_sleep:`` block means the device is only awake - # briefly; cap the reconnect backoff so a wake window is not missed. - deep_sleep="deep_sleep" in config, - ) - try: - await asyncio.Event().wait() - finally: - await stop() - - -def run_logs( - config: dict[str, Any], - addresses: list[str], - subscribe_states: bool = True, -) -> None: - """Run the logs command.""" - with contextlib.suppress(KeyboardInterrupt): - asyncio.run( - async_run_logs(config, addresses, subscribe_states=subscribe_states) - ) +__all__ = ["async_run_logs", "run_logs"] diff --git a/esphome/components/packet_transport/__init__.py b/esphome/components/packet_transport/__init__.py index 4293dffb15..7beb13ca31 100644 --- a/esphome/components/packet_transport/__init__.py +++ b/esphome/components/packet_transport/__init__.py @@ -4,12 +4,12 @@ import hashlib import logging import esphome.codegen as cg -from esphome.components.api import CONF_ENCRYPTION from esphome.components.binary_sensor import BinarySensor from esphome.components.sensor import Sensor import esphome.config_validation as cv from esphome.const import ( CONF_BINARY_SENSORS, + CONF_ENCRYPTION, CONF_ID, CONF_INTERNAL, CONF_KEY, diff --git a/esphome/components/udp/packet_transport/__init__.py b/esphome/components/udp/packet_transport/__init__.py index b6957a372b..e725276717 100644 --- a/esphome/components/udp/packet_transport/__init__.py +++ b/esphome/components/udp/packet_transport/__init__.py @@ -1,12 +1,11 @@ import esphome.codegen as cg -from esphome.components.api import CONF_ENCRYPTION from esphome.components.packet_transport import ( CONF_PING_PONG_ENABLE, PacketTransport, new_packet_transport, transport_schema, ) -from esphome.const import CONF_BINARY_SENSORS, CONF_SENSORS +from esphome.const import CONF_BINARY_SENSORS, CONF_ENCRYPTION, CONF_SENSORS from esphome.cpp_types import PollingComponent from .. import UDP_SCHEMA, register_udp_client, udp_ns diff --git a/esphome/const.py b/esphome/const.py index a7306edeb0..f2d305dced 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -384,6 +384,7 @@ CONF_ENABLE_PIN = "enable_pin" CONF_ENABLE_PRIVATE_NETWORK_ACCESS = "enable_private_network_access" CONF_ENABLE_RRM = "enable_rrm" CONF_ENABLE_TIME = "enable_time" +CONF_ENCRYPTION = "encryption" CONF_ENERGY = "energy" CONF_ENTITY_CATEGORY = "entity_category" CONF_ENTITY_ID = "entity_id" diff --git a/script/ci-custom.py b/script/ci-custom.py index 90748a13b9..9f3d836f65 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -557,7 +557,7 @@ def lint_constants_usage(): # Maximum allowed CONF_ constants in esphome/const.py. # This file is frozen — new constants go in esphome/components/const/__init__.py. # Decrease this number when constants are moved out of const.py. -CONST_PY_MAX_CONF = 1015 +CONST_PY_MAX_CONF = 1016 @lint_content_check(include=["esphome/const.py"]) diff --git a/tests/unit_tests/components/api/__init__.py b/tests/unit_tests/components/api/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/unit_tests/components/api/test_client.py b/tests/unit_tests/test_api_client.py similarity index 64% rename from tests/unit_tests/components/api/test_client.py rename to tests/unit_tests/test_api_client.py index 4ebcecbfff..55907f282c 100644 --- a/tests/unit_tests/components/api/test_client.py +++ b/tests/unit_tests/test_api_client.py @@ -1,17 +1,34 @@ -"""Tests for esphome.components.api.client.""" +"""Tests for esphome.api_client.""" from __future__ import annotations -from unittest.mock import AsyncMock, patch +import asyncio +from unittest.mock import AsyncMock, Mock, patch import pytest +from esphome import api_client from esphome.components import esp32 -from esphome.components.api import client as api_client -from esphome.const import CONF_PORT, KEY_CORE, KEY_TARGET_PLATFORM +from esphome.const import ( + CONF_ENCRYPTION, + CONF_KEY, + CONF_PORT, + KEY_CORE, + KEY_TARGET_PLATFORM, +) from esphome.core import CORE, EsphomeError +def test_component_shim_reexports_runtime_client() -> None: + """The old import paths must keep working for external code.""" + from esphome.components import api + from esphome.components.api import client as shim + + assert shim.run_logs is api_client.run_logs + assert shim.async_run_logs is api_client.async_run_logs + assert api.CONF_ENCRYPTION is CONF_ENCRYPTION + + def test_decoder_swallows_esphome_error() -> None: """A failing stack-trace decode must not propagate. @@ -166,3 +183,73 @@ async def test_async_run_logs_passes_deep_sleep( await api_client.async_run_logs(config, ["1.2.3.4"]) assert mock_run.call_args.kwargs["deep_sleep"] is expected_deep_sleep + + +@pytest.mark.asyncio +async def test_async_run_logs_full_flow(caplog) -> None: + """Drive async_run_logs end to end with a fake connection. + + Covers the encryption key extraction, the multi-address banner, the + missing-stacktrace-analyzer fallback, the on_log handler, and the + stop() cleanup in the finally block. + """ + caplog.set_level("INFO", logger="esphome.api_client") + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "host"} + config = { + "esphome": {"name": "test"}, + "api": {CONF_PORT: 6053, CONF_ENCRYPTION: {CONF_KEY: "psk123"}}, + } + + stop = AsyncMock() + run_started = asyncio.Event() + + async def fake_async_run(*args, **kwargs): + run_started.set() + return stop + + mock_run = AsyncMock(side_effect=fake_async_run) + printed: list[str] = [] + + with ( + patch.object(api_client, "async_run", mock_run), + patch.object(api_client, "APIClient") as mock_client, + patch.object(api_client, "safe_print", printed.append), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4", "5.6.7.8"]) + ) + # Let the task run up to the forever-wait; the timeout fails the + # test instead of hanging it if the task dies early. + async with asyncio.timeout(1): + await run_started.wait() + on_log = mock_run.call_args.args[1] + on_log(Mock(message=b"[I][main:001] hello world\nPC: 0x40104960")) + # Cancellation is the real termination path; stop() must still run. + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + # Both addresses reach APIClient, along with the noise key. + assert mock_client.call_args.kwargs["noise_psk"] == "psk123" + assert mock_client.call_args.kwargs["addresses"] == ["1.2.3.4", "5.6.7.8"] + assert "1.2.3.4 or 5.6.7.8" in caplog.text + # host has no stacktrace analyzer; the fallback message is logged. + assert "Stacktrace analysis is unavailable" in caplog.text + # The log message was printed with a timestamp prefix. + assert any("hello world" in line for line in printed) + # stop() ran in the finally block despite the cancellation. + stop.assert_awaited_once() + + +def test_run_logs_suppresses_keyboard_interrupt() -> None: + """Ctrl-C during log streaming exits cleanly instead of tracebacking.""" + with patch.object( + api_client, + "async_run_logs", + AsyncMock(side_effect=KeyboardInterrupt), + ) as mock_run: + api_client.run_logs( + {"esphome": {"name": "test"}}, ["1.2.3.4"], subscribe_states=False + ) + + assert mock_run.call_args.kwargs["subscribe_states"] is False diff --git a/tests/unit_tests/test_lazy_imports.py b/tests/unit_tests/test_lazy_imports.py index ee570a84f6..72ba73c9dc 100644 --- a/tests/unit_tests/test_lazy_imports.py +++ b/tests/unit_tests/test_lazy_imports.py @@ -31,11 +31,16 @@ HEAVY_MODULES = ( ) -def test_main_module_does_not_import_heavy_modules() -> None: - """A bare ``import esphome.__main__`` must not drag in validation/codegen.""" +def _leaked_heavy_modules(module: str) -> str: + """Import ``module`` in a subprocess and report the heavy modules it pulled. + + Any ``esphome.components.*`` package counts as heavy: executing a + component package drags in codegen/validation machinery by design. + """ check = ( - "import sys; import esphome.__main__; " + f"import sys; import {module}; " f"leaked = [m for m in {HEAVY_MODULES!r} if m in sys.modules]; " + "leaked += [m for m in sys.modules if m.startswith('esphome.components.')]; " "print(','.join(leaked))" ) result = subprocess.run( @@ -44,10 +49,30 @@ def test_main_module_does_not_import_heavy_modules() -> None: text=True, check=True, ) - leaked = result.stdout.strip() + return result.stdout.strip() + + +def test_main_module_does_not_import_heavy_modules() -> None: + """A bare ``import esphome.__main__`` must not drag in validation/codegen.""" + leaked = _leaked_heavy_modules("esphome.__main__") assert not leaked, ( f"esphome.__main__ imports heavy modules at top level: {leaked}. " "Import them lazily inside the command that needs them instead; " "every esphome invocation (including each parallel dashboard " "upload subprocess) pays for top-level imports." ) + + +def test_api_client_does_not_import_heavy_modules() -> None: + """``esphome.api_client`` is on the logs fast path and must stay light. + + Importing it must not execute any component package (the api package + pulls the whole validation stack: logger, esp32, writer, config, + jinja2, voluptuous). + """ + leaked = _leaked_heavy_modules("esphome.api_client") + assert not leaked, ( + f"esphome.api_client imports heavy modules at top level: {leaked}. " + "The logs fast path skips validation; importing the validation " + "stack anyway defeats the validated-config cache." + ) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index e575934870..289497057f 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -2918,7 +2918,7 @@ def test_show_logs_no_logger() -> None: show_logs(CORE.config, args, devices) -@patch("esphome.components.api.client.run_logs") +@patch("esphome.api_client.run_logs") def test_show_logs_api( mock_run_logs: Mock, ) -> None: @@ -2944,7 +2944,7 @@ def test_show_logs_api( ) -@patch("esphome.components.api.client.run_logs") +@patch("esphome.api_client.run_logs") def test_show_logs_api_no_states( mock_run_logs: Mock, ) -> None: @@ -2971,7 +2971,7 @@ def test_show_logs_api_no_states( ) -@patch("esphome.components.api.client.run_logs") +@patch("esphome.api_client.run_logs") def test_show_logs_api_with_fqdn_mdns_disabled( mock_run_logs: Mock, ) -> None: @@ -2998,7 +2998,7 @@ def test_show_logs_api_with_fqdn_mdns_disabled( ) -@patch("esphome.components.api.client.run_logs") +@patch("esphome.api_client.run_logs") def test_show_logs_api_with_mqtt_fallback( mock_run_logs: Mock, mock_mqtt_get_ip: Mock, @@ -4974,7 +4974,7 @@ def test_upload_program_ota_mqttip_deduplication( assert "192.168.1.100" in call_args[0] -@patch("esphome.components.api.client.run_logs") +@patch("esphome.api_client.run_logs") def test_show_logs_api_static_ip_with_mqttip( mock_run_logs: Mock, mock_mqtt_get_ip: Mock, @@ -5013,7 +5013,7 @@ def test_show_logs_api_static_ip_with_mqttip( ) -@patch("esphome.components.api.client.run_logs") +@patch("esphome.api_client.run_logs") def test_show_logs_api_multiple_mqttip_resolves_once( mock_run_logs: Mock, mock_mqtt_get_ip: Mock, @@ -5096,7 +5096,7 @@ def test_upload_program_ota_mqtt_timeout_fallback( ) -@patch("esphome.components.api.client.run_logs") +@patch("esphome.api_client.run_logs") def test_show_logs_api_mqtt_timeout_fallback( mock_run_logs: Mock, mock_mqtt_get_ip: Mock, @@ -6468,7 +6468,7 @@ def test_should_subscribe_states_no_flag_overrides_env() -> None: assert _should_subscribe_states(args) is False -@patch("esphome.components.api.client.run_logs") +@patch("esphome.api_client.run_logs") def test_command_run_passes_no_states_to_show_logs( mock_run_logs: Mock, ) -> None: @@ -6506,7 +6506,7 @@ def test_command_run_passes_no_states_to_show_logs( ) -@patch("esphome.components.api.client.run_logs") +@patch("esphome.api_client.run_logs") def test_command_run_defaults_subscribe_states_true( mock_run_logs: Mock, ) -> None: From a1c298339451505f682180851ee0736e4f8e0eb9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 Aug 2026 20:05:25 -0500 Subject: [PATCH 1194/1815] [core] Hash entity keys from the raw name to fix collisions (#17949) --- esphome/components/api/api_connection.cpp | 6 +- esphome/components/esp32/__init__.py | 2 + esphome/components/esp32/preferences.cpp | 19 +- esphome/components/esp32/preferences.h | 3 + esphome/components/host/__init__.py | 2 + esphome/components/host/preferences.h | 3 + esphome/components/infrared/infrared.cpp | 8 +- esphome/components/libretiny/__init__.py | 2 + esphome/components/libretiny/preferences.cpp | 19 +- esphome/components/libretiny/preferences.h | 3 + esphome/components/mqtt/__init__.py | 63 +++ esphome/components/prometheus/__init__.py | 6 + .../radio_frequency/radio_frequency.cpp | 8 +- .../template/text/template_text.cpp | 20 +- .../components/template/text/template_text.h | 15 +- esphome/components/zephyr/__init__.py | 2 + esphome/components/zephyr/preferences.cpp | 18 +- esphome/components/zephyr/preferences.h | 3 + esphome/core/__init__.py | 4 +- esphome/core/application.h | 8 +- esphome/core/defines.h | 3 + esphome/core/entity_base.cpp | 79 ++-- esphome/core/entity_base.h | 76 ++-- esphome/core/entity_helpers.py | 176 +++++--- esphome/core/helpers.h | 29 +- esphome/core/preference_backend.h | 20 +- esphome/core/preferences.cpp | 25 ++ esphome/core/preferences.h | 17 + esphome/helpers.py | 38 +- tests/integration/entity_utils.py | 27 +- .../fixtures/fnv1_hash_object_id.yaml | 32 ++ .../fixtures/multi_device_preferences.yaml | 21 +- .../fixtures/preference_key_migration.yaml | 35 ++ tests/integration/host_prefs.py | 24 +- tests/integration/test_fnv1_hash_object_id.py | 4 + .../test_object_id_api_verification.py | 10 +- ...t_object_id_friendly_name_no_mac_suffix.py | 4 +- .../test_object_id_no_friendly_name.py | 6 +- .../test_preference_key_migration.py | 165 ++++++++ tests/unit_tests/components/mqtt/__init__.py | 0 .../mqtt/test_object_id_conflicts.py | 239 +++++++++++ tests/unit_tests/core/common.py | 2 +- tests/unit_tests/core/conftest.py | 2 +- tests/unit_tests/core/test_entity_helpers.py | 400 ++++++++---------- .../object_id_conflict_mqtt.yaml | 22 + .../object_id_conflict_no_mqtt.yaml | 15 + .../test_preference_hash_stability.py | 239 +++++++++++ 47 files changed, 1458 insertions(+), 466 deletions(-) create mode 100644 esphome/core/preferences.cpp create mode 100644 tests/integration/fixtures/preference_key_migration.yaml create mode 100644 tests/integration/test_preference_key_migration.py create mode 100644 tests/unit_tests/components/mqtt/__init__.py create mode 100644 tests/unit_tests/components/mqtt/test_object_id_conflicts.py create mode 100644 tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml create mode 100644 tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml create mode 100644 tests/unit_tests/test_preference_hash_stability.py diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 3dc2a06c85..cb57db9ce8 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -440,7 +440,7 @@ void APIConnection::on_disconnect_response() { uint16_t APIConnection::fill_and_encode_entity_state(EntityBase *entity, StateResponseProtoMessage &msg, CalculateSizeFn size_fn, MessageEncodeFn encode_fn, APIConnection *conn, uint32_t remaining_size) { - msg.key = entity->get_object_id_hash(); + msg.key = entity->get_entity_key(); #ifdef USE_DEVICES msg.device_id = entity->get_device_id(); #endif @@ -451,7 +451,7 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp CalculateSizeFn size_fn, MessageEncodeFn encode_fn, APIConnection *conn, uint32_t remaining_size) { // Set common fields that are shared by all entity types - msg.key = entity->get_object_id_hash(); + msg.key = entity->get_entity_key(); if (entity->has_own_name()) { msg.name = entity->get_name(); @@ -1141,7 +1141,7 @@ void APIConnection::try_send_camera_image_() { bool done = this->image_reader_->available() == to_send; CameraImageResponse msg; - msg.key = camera::Camera::instance()->get_object_id_hash(); + msg.key = camera::Camera::instance()->get_entity_key(); msg.set_data(this->image_reader_->peek_data_buffer(), to_send); msg.done = done; #ifdef USE_DEVICES diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 6491b9a5e6..40a03c97b8 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2193,6 +2193,8 @@ async def to_code(config): cg.set_cpp_standard("gnu++20") cg.add_build_flag("-DUSE_ESP32") cg.add_define("USE_NATIVE_64BIT_TIME") + # NVS finds stored preferences by key, so preference key migration is possible + cg.add_define("USE_PREFERENCE_KEY_LOOKUP") cg.add_build_flag("-Wl,-z,noexecstack") # Deferred so KEY_COMPONENTS is fully populated -- see the coroutine. CORE.add_job(_finalize_arduino_aware_flags) diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index dc2b40455c..f3d5844cd7 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -176,12 +176,21 @@ ESPPreferenceObject ESP32Preferences::make_preference(size_t length, uint32_t ty } s_open_err = ESP_OK; } - auto *pref = new ESP32PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) - pref->nvs_handle = this->nvs_handle; - pref->key = type; - pref->in_flash = true; + // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) + return ESPPreferenceObject(new ESP32PreferenceBackend(this->make_backend_(type))); +} - return ESPPreferenceObject(pref); +ESP32PreferenceBackend ESP32Preferences::make_backend_(uint32_t type) const { + // in_flash keeps its default of true, selecting the NVS path + ESP32PreferenceBackend backend; + backend.nvs_handle = this->nvs_handle; + backend.key = type; + return backend; +} + +bool ESP32Preferences::load_from_key(uint32_t type, uint8_t *data, size_t len) { + ESP32PreferenceBackend backend = this->make_backend_(type); + return backend.load(data, len); } #ifdef USE_ESP32_RTC_PREFERENCES_STORAGE diff --git a/esphome/components/esp32/preferences.h b/esphome/components/esp32/preferences.h index 864d22312b..9125843958 100644 --- a/esphome/components/esp32/preferences.h +++ b/esphome/components/esp32/preferences.h @@ -23,12 +23,15 @@ class ESP32Preferences final : public PreferencesMixin { ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash); // Two-argument form defaults to NVS (flash) storage, preserving historic ESP32 behavior. ESPPreferenceObject make_preference(size_t length, uint32_t type); + /// One-shot read of a stored preference by key, without allocating a backend + bool load_from_key(uint32_t type, uint8_t *data, size_t len); bool sync(); bool reset(); uint32_t nvs_handle; protected: + ESP32PreferenceBackend make_backend_(uint32_t type) const; bool is_changed_(uint32_t nvs_handle, const NVSData &to_save, const char *key_str); #ifdef USE_ESP32_RTC_PREFERENCES_STORAGE diff --git a/esphome/components/host/__init__.py b/esphome/components/host/__init__.py index 795c1a556d..b6a3b8b615 100644 --- a/esphome/components/host/__init__.py +++ b/esphome/components/host/__init__.py @@ -43,6 +43,8 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): cg.add_build_flag("-DUSE_HOST") cg.add_define("USE_NATIVE_64BIT_TIME") + # The prefs file finds stored preferences by key, so key migration is possible + cg.add_define("USE_PREFERENCE_KEY_LOOKUP") cg.add_define("USE_ESPHOME_HOST_MAC_ADDRESS", config[CONF_MAC_ADDRESS].parts) cg.add_build_flag("-std=gnu++20") cg.add_define("ESPHOME_BOARD", "host") diff --git a/esphome/components/host/preferences.h b/esphome/components/host/preferences.h index 5f723e0675..b591fa0aab 100644 --- a/esphome/components/host/preferences.h +++ b/esphome/components/host/preferences.h @@ -27,6 +27,9 @@ class HostPreferences final : public PreferencesMixin { return true; } + /// One-shot read of a stored preference by key, without allocating a backend + bool load_from_key(uint32_t type, uint8_t *data, size_t len) { return this->load(type, data, len); } + bool load(uint32_t key, uint8_t *data, size_t len) { if (len > 255) return false; diff --git a/esphome/components/infrared/infrared.cpp b/esphome/components/infrared/infrared.cpp index 9b97995a96..288b1e5c40 100644 --- a/esphome/components/infrared/infrared.cpp +++ b/esphome/components/infrared/infrared.cpp @@ -154,12 +154,8 @@ bool Infrared::on_receive(remote_base::RemoteReceiveData data) { // Forward received IR data to API server #if defined(USE_API) && defined(USE_IR_RF) if (api::global_api_server != nullptr) { -#ifdef USE_DEVICES - uint32_t device_id = this->get_device_id(); -#else - uint32_t device_id = 0; -#endif - api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data()); + api::global_api_server->send_infrared_rf_receive_event(this->get_device_id_or_zero(), this->get_entity_key(), + &data.get_raw_data()); } #endif return false; // Don't consume the event, allow other listeners to process it diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 7dbce7a07c..c51af373b3 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -461,6 +461,8 @@ async def component_to_code(config): # setup board config cg.add_platformio_option("board", config[CONF_BOARD]) cg.add_build_flag("-DUSE_LIBRETINY") + # FlashDB finds stored preferences by key, so preference key migration is possible + cg.add_define("USE_PREFERENCE_KEY_LOOKUP") cg.add_build_flag(f"-DUSE_{config[CONF_COMPONENT_ID].upper()}") cg.add_build_flag(f"-DUSE_LIBRETINY_VARIANT_{config[CONF_FAMILY]}") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) diff --git a/esphome/components/libretiny/preferences.cpp b/esphome/components/libretiny/preferences.cpp index 313b36d31e..d0bd3bf26b 100644 --- a/esphome/components/libretiny/preferences.cpp +++ b/esphome/components/libretiny/preferences.cpp @@ -70,12 +70,21 @@ void LibreTinyPreferences::open() { } ESPPreferenceObject LibreTinyPreferences::make_preference(size_t length, uint32_t type) { - auto *pref = new LibreTinyPreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) - pref->db = &this->db; - pref->blob = &this->blob; - pref->key = type; + // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) + return ESPPreferenceObject(new LibreTinyPreferenceBackend(this->make_backend_(type))); +} - return ESPPreferenceObject(pref); +LibreTinyPreferenceBackend LibreTinyPreferences::make_backend_(uint32_t type) { + LibreTinyPreferenceBackend backend; + backend.key = type; + backend.db = &this->db; + backend.blob = &this->blob; + return backend; +} + +bool LibreTinyPreferences::load_from_key(uint32_t type, uint8_t *data, size_t len) { + LibreTinyPreferenceBackend backend = this->make_backend_(type); + return backend.load(data, len); } bool LibreTinyPreferences::sync() { diff --git a/esphome/components/libretiny/preferences.h b/esphome/components/libretiny/preferences.h index 8365d590c2..fd86c48b20 100644 --- a/esphome/components/libretiny/preferences.h +++ b/esphome/components/libretiny/preferences.h @@ -16,6 +16,8 @@ class LibreTinyPreferences final : public PreferencesMixin return this->make_preference(length, type); } ESPPreferenceObject make_preference(size_t length, uint32_t type); + /// One-shot read of a stored preference by key, without allocating a backend + bool load_from_key(uint32_t type, uint8_t *data, size_t len); bool sync(); bool reset(); @@ -23,6 +25,7 @@ class LibreTinyPreferences final : public PreferencesMixin struct fdb_blob blob; protected: + LibreTinyPreferenceBackend make_backend_(uint32_t type); bool is_changed_(fdb_kvdb_t db, const NVSData &to_save, const char *key_str); }; diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 4a5eacf449..35d496adeb 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -62,6 +62,7 @@ from esphome.const import ( PlatformFramework, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core.entity_helpers import ObjectIdEntity, validate_no_object_id_conflicts from esphome.types import ConfigType DEPENDENCIES = ["network"] @@ -332,6 +333,68 @@ CONFIG_SCHEMA = cv.All( ) +# Platforms whose MQTT components subscribe to an object_id-derived command topic. +# Keep in sync with the platforms extending cv.MQTT_COMMAND_COMPONENT_SCHEMA, plus +# text, whose MQTT component subscribes a command topic that cannot be overridden. +_COMMAND_TOPIC_PLATFORMS = frozenset( + { + "alarm_control_panel", + "button", + "climate", + "cover", + "datetime", + "fan", + "light", + "lock", + "number", + "select", + "switch", + "text", + "update", + "valve", + } +) + + +# Platforms whose MQTT components derive extra sub-topics (position/command, +# mode/command, speed/command, ...) from the object_id, each with its own config +# key; custom state and command topics cannot exempt them from conflicting. +_SUB_TOPIC_PLATFORMS = frozenset({"climate", "cover", "fan", "valve"}) + + +def _topics_conflict(entities: list[ObjectIdEntity], config: ConfigType) -> bool: + """Check whether more than one entity actually uses an object_id-derived topic. + + An empty topic_prefix disables default topics entirely, custom state and + command topics avoid the default topics, and disabling discovery (globally + or per entity) avoids the discovery config topic. + """ + if config[CONF_TOPIC_PREFIX]: + platform = entities[0].platform + if platform in _SUB_TOPIC_PLATFORMS: + return True + if sum(CONF_STATE_TOPIC not in entity.config for entity in entities) > 1: + return True + if ( + platform in _COMMAND_TOPIC_PLATFORMS + and sum(CONF_COMMAND_TOPIC not in entity.config for entity in entities) > 1 + ): + return True + if not config[CONF_DISCOVERY]: + return False + discovery_entities = sum( + entity.config.get(CONF_DISCOVERY, True) for entity in entities + ) + return discovery_entities > 1 + + +FINAL_VALIDATE_SCHEMA = validate_no_object_id_conflicts( + "mqtt builds default topics and discovery topics from the entity object_id, " + "which is the name converted to ASCII", + conflict_filter=_topics_conflict, +) + + def exp_mqtt_message(config): if config is None: return cg.optional(cg.TemplateArguments(MQTTMessage)) diff --git a/esphome/components/prometheus/__init__.py b/esphome/components/prometheus/__init__.py index cc1541ce80..0a69160fc1 100644 --- a/esphome/components/prometheus/__init__.py +++ b/esphome/components/prometheus/__init__.py @@ -3,6 +3,7 @@ from esphome.components import web_server_base from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_INCLUDE_INTERNAL, CONF_NAME, CONF_RELABEL +from esphome.core.entity_helpers import validate_no_object_id_conflicts from esphome.cpp_types import EntityBase AUTO_LOAD = ["web_server_base"] @@ -35,6 +36,11 @@ CONFIG_SCHEMA = cv.Schema( }, ).extend(cv.COMPONENT_SCHEMA) +FINAL_VALIDATE_SCHEMA = validate_no_object_id_conflicts( + "prometheus builds metric labels from the entity object_id, " + "which is the name converted to ASCII" +) + async def to_code(config): paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID]) diff --git a/esphome/components/radio_frequency/radio_frequency.cpp b/esphome/components/radio_frequency/radio_frequency.cpp index 3e0a905737..fe6c6a9cb5 100644 --- a/esphome/components/radio_frequency/radio_frequency.cpp +++ b/esphome/components/radio_frequency/radio_frequency.cpp @@ -99,12 +99,8 @@ bool RadioFrequency::on_receive(remote_base::RemoteReceiveData data) { // Forward received RF data to API server #if defined(USE_API) && defined(USE_RADIO_FREQUENCY) if (api::global_api_server != nullptr) { -#ifdef USE_DEVICES - uint32_t device_id = this->get_device_id(); -#else - uint32_t device_id = 0; -#endif - api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data()); + api::global_api_server->send_infrared_rf_receive_event(this->get_device_id_or_zero(), this->get_entity_key(), + &data.get_raw_data()); } #endif return false; // Don't consume the event, allow other listeners to process it diff --git a/esphome/components/template/text/template_text.cpp b/esphome/components/template/text/template_text.cpp index af134e6ed4..ffe11cf229 100644 --- a/esphome/components/template/text/template_text.cpp +++ b/esphome/components/template/text/template_text.cpp @@ -20,18 +20,14 @@ void TemplateText::setup() { // Need std::string for pref_->setup() to fill from flash std::string value{this->initial_value_ != nullptr ? this->initial_value_ : ""}; - // For future hash migration: use migrate_entity_preference_() with: - // old_key = get_preference_hash() + extra - // new_key = get_preference_hash_v2() + extra - // See: https://github.com/esphome/backlog/issues/85 -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - uint32_t key = this->get_preference_hash(); -#pragma GCC diagnostic pop - key += this->traits.get_min_length() << 2; - key += this->traits.get_max_length() << 4; - key += fnv1_hash(this->traits.get_pattern_c_str()) << 6; - this->pref_->setup(key, value); + uint32_t extra = 0; + extra += this->traits.get_min_length() << 2; + extra += this->traits.get_max_length() << 4; + extra += fnv1_hash(this->traits.get_pattern_c_str()) << 6; + // TextSaver::setup() picks the key for the platform and migrates old data once + uint32_t key = this->preference_key_base_() + extra; + uint32_t old_key = this->old_preference_key_base_() + extra; + this->pref_->setup(key, old_key, value); if (!value.empty()) this->publish_state(value); } diff --git a/esphome/components/template/text/template_text.h b/esphome/components/template/text/template_text.h index 229a61d9b8..beeea4396a 100644 --- a/esphome/components/template/text/template_text.h +++ b/esphome/components/template/text/template_text.h @@ -14,7 +14,9 @@ class TemplateTextSaverBase { public: virtual bool save(const std::string &value) { return true; } - virtual void setup(uint32_t id, std::string &value) {} + /// old_id is the pre-2026.8.0 preference key; data stored under it is moved to id once. + /// See: https://github.com/esphome/backlog/issues/85 + virtual void setup(uint32_t id, uint32_t old_id, std::string &value) {} protected: ESPPreferenceObject pref_; @@ -45,11 +47,16 @@ template class TextSaver : public TemplateTextSaverBase { // Make the preference object. Fill the provided location with the saved data // If it is available, else leave it alone - void setup(uint32_t id, std::string &value) override { - this->pref_ = global_preferences->make_preference(id); - + void setup(uint32_t id, uint32_t old_id, std::string &value) override { char temp[SZ + 1]; +#ifdef USE_PREFERENCE_KEY_LOOKUP + this->pref_ = global_preferences->make_preference(id); + bool hasdata = migrate_preference(this->pref_, reinterpret_cast(temp), SZ + 1, old_id, id); +#else + // Slot-based backends keep the old key; it is only a validity tag on a positional slot + this->pref_ = global_preferences->make_preference(old_id); bool hasdata = this->pref_.load(&temp); +#endif if (hasdata) { size_t len = static_cast(temp[0]); diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index 9f755a6eea..338d1986ea 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -158,6 +158,8 @@ def add_extra_script(stage: str, filename: str, path: Path) -> None: def zephyr_to_code(config: ConfigType) -> None: cg.add_build_flag("-DUSE_ZEPHYR") cg.add_define("USE_NATIVE_64BIT_TIME") + # The settings subsystem finds stored preferences by key, so key migration is possible + cg.add_define("USE_PREFERENCE_KEY_LOOKUP") cg.set_cpp_standard("gnu++20") # c++ support zephyr_add_prj_conf("FPU", True) diff --git a/esphome/components/zephyr/preferences.cpp b/esphome/components/zephyr/preferences.cpp index c26a1d6d53..ed22613625 100644 --- a/esphome/components/zephyr/preferences.cpp +++ b/esphome/components/zephyr/preferences.cpp @@ -58,12 +58,19 @@ void ZephyrPreferences::open() { ESP_LOGD(TAG, "Loaded %zu settings.", this->backends_.size()); } -ESPPreferenceObject ZephyrPreferences::make_preference(size_t length, uint32_t type) { +ZephyrPreferenceBackend *ZephyrPreferences::find_backend_(uint32_t type) { for (auto *backend : this->backends_) { if (backend->get_type() == type) { - return ESPPreferenceObject(backend); + return backend; } } + return nullptr; +} + +ESPPreferenceObject ZephyrPreferences::make_preference(size_t length, uint32_t type) { + if (auto *backend = this->find_backend_(type)) { + return ESPPreferenceObject(backend); + } auto *pref = new ZephyrPreferenceBackend(type); // NOLINT(cppcoreguidelines-owning-memory) char key_buf[KEY_BUFFER_SIZE]; pref->format_key(key_buf, sizeof(key_buf)); @@ -72,6 +79,13 @@ ESPPreferenceObject ZephyrPreferences::make_preference(size_t length, uint32_t t return ESPPreferenceObject(pref); } +bool ZephyrPreferences::load_from_key(uint32_t type, uint8_t *data, size_t len) { + // Stored settings are preloaded into backends_ at boot by settings_load_subtree(), + // so a key with no registered backend has no stored data. + auto *backend = this->find_backend_(type); + return backend != nullptr && backend->load(data, len); +} + bool ZephyrPreferences::sync() { ESP_LOGD(TAG, "Save settings"); int err = settings_save(); diff --git a/esphome/components/zephyr/preferences.h b/esphome/components/zephyr/preferences.h index 9e2555f910..b1ad95fd74 100644 --- a/esphome/components/zephyr/preferences.h +++ b/esphome/components/zephyr/preferences.h @@ -16,10 +16,13 @@ class ZephyrPreferences final : public PreferencesMixin { return this->make_preference(length, type); } ESPPreferenceObject make_preference(size_t length, uint32_t type); + /// One-shot read of a stored preference by key, without allocating or registering a backend + bool load_from_key(uint32_t type, uint8_t *data, size_t len); bool sync(); bool reset(); protected: + ZephyrPreferenceBackend *find_backend_(uint32_t type); std::vector backends_; static int load_setting(const char *name, size_t len, settings_read_cb read_cb, void *cb_arg); diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index bf637d4c1f..deee127f49 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -621,8 +621,8 @@ class EsphomeCore: # Key: platform name (e.g. "sensor", "binary_sensor"), Value: count self.platform_counts: defaultdict[str, int] = defaultdict(int) # Track entity unique IDs to handle duplicates - # Dict mapping (device_id, platform, sanitized_name) -> entity metadata - self.unique_ids: dict[tuple[str, str, str], EntityMetadata] = {} + # Dict mapping (device_id, platform, name_hash) -> entity metadata + self.unique_ids: dict[tuple[str, str, int], EntityMetadata] = {} # Whether ESPHome was started in verbose mode self.verbose = False # Whether ESPHome was started in quiet mode diff --git a/esphome/core/application.h b/esphome/core/application.h index a12cdc4ac8..a18a6b31c8 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -120,8 +120,8 @@ class Application { // 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, const char *name, uint32_t object_id_hash, uint32_t entity_fields) { \ - obj->configure_entity_(name, object_id_hash, entity_fields); \ + void register_##singular(type *obj, const char *name, uint32_t entity_key, uint32_t entity_fields) { \ + obj->configure_entity_(name, entity_key, entity_fields); \ this->plural##_.push_back(obj); \ } #define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ @@ -329,7 +329,7 @@ class Application { #define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \ entity_type *get_##entity_name##_by_key(uint32_t key, uint32_t device_id, bool include_internal = false) { \ for (auto *obj : this->entities_member##_) { \ - if (obj->get_object_id_hash() == key && obj->get_device_id() == device_id && \ + if (obj->get_entity_key() == key && obj->get_device_id() == device_id && \ (include_internal || !obj->is_internal())) \ return obj; \ } \ @@ -340,7 +340,7 @@ class Application { #define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \ entity_type *get_##entity_name##_by_key(uint32_t key, bool include_internal = false) { \ for (auto *obj : this->entities_member##_) { \ - if (obj->get_object_id_hash() == key && (include_internal || !obj->is_internal())) \ + if (obj->get_entity_key() == key && (include_internal || !obj->is_internal())) \ return obj; \ } \ return nullptr; \ diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 1d4cbcfc37..b184300681 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -157,6 +157,9 @@ #define USE_OUTPUT_FLOAT_POWER_SCALING #define USE_POWER_SUPPLY #define USE_PREFERENCES_SYNC_EVERY_LOOP +// Only defined by key-lookup preference backends (esp32, libretiny, host, zephyr); +// slot-based platforms (esp8266, rp2040) never set it in generated builds +#define USE_PREFERENCE_KEY_LOOKUP #define USE_PROVISIONING #define USE_QR_CODE #define USE_SAFE_MODE_BOOT_IS_GOOD_ON_SHUTDOWN diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 32135860bb..328de05302 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -8,7 +8,7 @@ namespace esphome { static const char *const TAG = "entity_base"; -void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields) { +void EntityBase::configure_entity_(const char *name, uint32_t entity_key, uint32_t entity_fields) { this->name_ = StringRef(name); if (this->name_.empty()) { #ifdef USE_DEVICES @@ -30,15 +30,15 @@ void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, ui } } this->flags_.has_own_name = false; - // Dynamic name - must calculate hash at runtime - this->calc_object_id_(); + // Dynamic name - must calculate key at runtime + this->calc_entity_key_(); } else { this->flags_.has_own_name = true; - // Static name - use pre-computed hash if provided - if (object_id_hash != 0) { - this->object_id_hash_ = object_id_hash; + // Static name - use pre-computed key if provided + if (entity_key != 0) { + this->entity_key_ = entity_key; } else { - this->calc_object_id_(); + this->calc_entity_key_(); } } // Unpack entity string table indices and flags from entity_fields. @@ -147,9 +147,15 @@ std::string EntityBase::get_icon() const { } #endif // !USE_ESP8266 -// Calculate Object ID Hash directly from name using snake_case + sanitize -void EntityBase::calc_object_id_() { - this->object_id_hash_ = fnv1_hash_object_id(this->name_.c_str(), this->name_.size()); +// Calculate the entity key directly from the raw name (no transformations) +void EntityBase::calc_entity_key_() { this->entity_key_ = fnv1_hash_bytes(this->name_.c_str(), this->name_.size()); } + +// Reconstruct the OLD (pre-2026.8.0) object_id-based hash for preference key compatibility. +// Named entities historically used the hash pre-computed by Python code generation, which +// sanitized per UTF-8 code point; entities without their own name computed the hash at +// runtime per byte. See https://github.com/esphome/backlog/issues/85 +uint32_t EntityBase::calc_old_object_id_hash_() const { + return fnv1_hash_object_id(this->name_.c_str(), this->name_.size(), this->flags_.has_own_name); } size_t EntityBase::write_object_id_to(char *buf, size_t buf_size) const { @@ -166,46 +172,23 @@ StringRef EntityBase::get_object_id_to(std::span buf) c return StringRef(buf.data(), len); } -// Migrate preference data from old_key to new_key if they differ. -// This helper is exposed so callers with custom key computation (like TextPrefs) -// can use it for manual migration. See: https://github.com/esphome/backlog/issues/85 -// -// FUTURE IMPLEMENTATION: -// This will require raw load/save methods on ESPPreferenceObject that take uint8_t* and size. -// void EntityBase::migrate_entity_preference_(size_t size, uint32_t old_key, uint32_t new_key) { -// if (old_key == new_key) -// return; -// auto old_pref = global_preferences->make_preference(size, old_key); -// auto new_pref = global_preferences->make_preference(size, new_key); -// SmallBufferWithHeapFallback<64> buffer(size); -// if (old_pref.load(buffer.data(), size)) { -// new_pref.save(buffer.data(), size); -// } -// } - ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t version) { - // This helper centralizes preference creation to enable fixing hash collisions. + // The old key hashed the sanitized object_id, so multiple entity names could collide on + // one key and overwrite each other's stored preferences; the new key hashes the raw name. // See: https://github.com/esphome/backlog/issues/85 - // - // COLLISION PROBLEM: get_preference_hash() uses fnv1_hash on sanitized object_id. - // Multiple entity names can sanitize to the same object_id: - // - "Living Room" and "living_room" both become "living_room" - // - UTF-8 names like "温度" and "湿度" both become "__" (underscores) - // This causes entities to overwrite each other's stored preferences. - // - // FUTURE MIGRATION: When implementing get_preference_hash_v2() that hashes - // the original entity name (not sanitized object_id): - // - // uint32_t old_key = this->get_preference_hash() ^ version; - // uint32_t new_key = this->get_preference_hash_v2() ^ version; - // this->migrate_entity_preference_(size, old_key, new_key); - // return global_preferences->make_preference(size, new_key); - // -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - uint32_t key = this->get_preference_hash() ^ version; -#pragma GCC diagnostic pop - return global_preferences->make_preference(size, key); + uint32_t old_key = this->old_preference_key_base_() ^ version; +#ifdef USE_PREFERENCE_KEY_LOOKUP + uint32_t new_key = this->preference_key_base_() ^ version; + auto pref = global_preferences->make_preference(size, new_key); + // All in-tree entity preferences fit the stack buffer, so migration never hits the heap + SmallBufferWithHeapFallback<64> buffer(size); + migrate_preference(pref, buffer.get(), size, old_key, new_key); + return pref; +#else + // Slot-based backends keep the old key: it is only a validity tag on a positional slot, + // so collisions cannot corrupt data there and keeping it preserves stored state. + return global_preferences->make_preference(size, old_key); +#endif } #ifdef USE_ENTITY_ICON diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 4f708209d4..7f8e5f2630 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -73,8 +73,17 @@ class EntityBase { // Get whether this Entity has its own name or it should use the device friendly_name. bool has_own_name() const { return this->flags_.has_own_name; } - // Get the unique Object ID of this Entity - uint32_t get_object_id_hash() const { return this->object_id_hash_; } + // Get the unique key of this Entity: FNV-1 hash of the raw entity name. + // This is the key sent to API clients and used to route entity state. + uint32_t get_entity_key() const { return this->entity_key_; } + + /// Returns the LEGACY object_id hash, unchanged from previous releases, so existing + /// callers keep getting stable values (for example preference keys). This is no longer + /// the key sent to API clients; that is get_entity_key(). + ESPDEPRECATED("Use get_entity_key() for the entity key sent to API clients, or " + "make_entity_preference() for preference storage. Will be removed in 2027.1.0.", + "2026.8.0") + uint32_t get_object_id_hash() const { return this->calc_old_object_id_hash_(); } /// Get object_id with zero heap allocation /// For static case: returns StringRef to internal storage (buffer unused) @@ -181,40 +190,24 @@ class EntityBase { // Set has_state - for components that need to manually set this void set_has_state(bool state) { this->flags_.has_state = state; } - /** - * @brief Get a unique hash for storing preferences/settings for this entity. - * - * This method returns a hash that uniquely identifies the entity for the purpose of - * storing preferences (such as calibration, state, etc.). Unlike get_object_id_hash(), - * this hash also incorporates the device_id (if devices are enabled), ensuring uniqueness - * across multiple devices that may have entities with the same object_id. - * - * Use this method when storing or retrieving preferences/settings that should be unique - * per device-entity pair. Use get_object_id_hash() when you need a hash that identifies - * the entity regardless of the device it belongs to. - * - * For backward compatibility, if device_id is 0 (the main device), the hash is unchanged - * from previous versions, so existing single-device configurations will continue to work. - * - * @return uint32_t The unique hash for preferences, including device_id if available. - * @deprecated Use make_entity_preference() instead, or preferences won't be migrated. - * See https://github.com/esphome/backlog/issues/85 - */ - ESPDEPRECATED("Use make_entity_preference() instead, or preferences won't be migrated. " - "See https://github.com/esphome/backlog/issues/85. Will be removed in 2027.1.0.", - "2026.7.0") - uint32_t get_preference_hash() { + /// Get this entity's device id, or 0 when devices are not compiled in (main device). + uint32_t get_device_id_or_zero() const { #ifdef USE_DEVICES - // Combine object_id_hash with device_id to ensure uniqueness across devices - // Note: device_id is 0 for the main device, so XORing with 0 preserves the original hash - // This ensures backward compatibility for existing single-device configurations - return this->get_object_id_hash() ^ this->get_device_id(); + return this->get_device_id(); #else - // Without devices, just use object_id_hash as before - return this->get_object_id_hash(); + return 0; #endif } + /// Get the LEGACY preference key: FNV-1 hash of the sanitized object_id, XOR device_id. + /// Intentionally keeps the old algorithm so external callers that store preferences under + /// this key keep stable keys; make_entity_preference() migrates to the new raw-name key, + /// this method never will. + ESPDEPRECATED("Use make_entity_preference() instead, or preferences won't be migrated. " + "See https://github.com/esphome/backlog/issues/85. Will be removed in 2027.1.0.", + "2026.8.0") + uint32_t get_preference_hash() { return this->old_preference_key_base_(); } + /// Create a preference object for storing this entity's state/settings. /// @tparam T The type of data to store (must be trivially copyable) /// @param version Optional version hash XORed with preference key (change when struct layout changes) @@ -230,9 +223,9 @@ class EntityBase { // 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. + /// Combined entity setup from codegen: set name, entity key, entity string indices, and flags. /// Bit layout of entity_fields is defined by the ENTITY_FIELD_*_SHIFT constants above. - void configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields); + void configure_entity_(const char *name, uint32_t entity_key, uint32_t entity_fields); #ifdef USE_DEVICES // Codegen-only setter — only accessible from setup() via friend declaration. @@ -240,13 +233,24 @@ class EntityBase { #endif /// Non-template helper for make_entity_preference() to avoid code bloat. - /// When preference hash algorithm changes, migration logic goes here. + /// Migrates preferences from the old sanitized-object_id key to the raw-name key + /// on key-lookup platforms. See: https://github.com/esphome/backlog/issues/85 ESPPreferenceObject make_entity_preference_(size_t size, uint32_t version); - void calc_object_id_(); + void calc_entity_key_(); + + /// Reconstruct the OLD (pre-2026.8.0) sanitized-object_id hash for preference keys. + uint32_t calc_old_object_id_hash_() const; + + /// Preference key base for this entity: raw-name entity key XOR device_id. + uint32_t preference_key_base_() const { return this->entity_key_ ^ this->get_device_id_or_zero(); } + + /// Legacy preference key base: sanitized-object_id hash XOR device_id. + /// Note: device_id is 0 for the main device, so XORing with 0 preserves the original hash. + uint32_t old_preference_key_base_() const { return this->calc_old_object_id_hash_() ^ this->get_device_id_or_zero(); } StringRef name_; - uint32_t object_id_hash_{}; + uint32_t entity_key_{}; #ifdef USE_DEVICES Device *device_{}; #endif diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 38c7f3ca43..5060e32a2d 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -25,19 +25,86 @@ from esphome.core.config import ( 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.helpers import cpp_string_escape, fnv1_hash_name, sanitize, snake_case from esphome.types import ConfigType, EntityMetadata _LOGGER = logging.getLogger(__name__) DOMAIN = "entity_string_pool" +_OBJECT_ID_DOMAIN = "entity_object_ids" + + +@dataclass +class ObjectIdEntity: + """An entity tracked by the sanitized object_id its name resolves to.""" + + name: str + platform: str + config: ConfigType + + +def _get_object_id_registry() -> dict[tuple[str, str, str], list[ObjectIdEntity]]: + """(device_id, platform, sanitized object_id) -> entities resolving to it.""" + return CORE.data.setdefault(_OBJECT_ID_DOMAIN, {}) + + +def validate_no_object_id_conflicts( + reason: str, + conflict_filter: Callable[[list[ObjectIdEntity], ConfigType], bool] | None = None, +) -> Callable[[ConfigType], ConfigType]: + """Create a final-validate step that rejects entities with colliding object_ids. + + Entity keys are hashed from the raw name, so names that only differ in characters + lost during sanitizing (for example two UTF-8 names) validate fine in general. + Components that still address entities by the sanitized object_id string must + reject those configs until they are migrated to raw names. + + Args: + reason: One sentence stating what the component builds from the object_id, + e.g. "mqtt builds default topics from the entity object_id" + conflict_filter: Optional predicate receiving the colliding entities and the + component config; return False when the component is not affected + + Returns: + A validator function for use as (or within) FINAL_VALIDATE_SCHEMA + """ + + def validator(config: ConfigType) -> ConfigType: + # Skip in testing_mode, which is used for grouped component testing + if CORE.testing_mode: + return config + conflicts = { + key: entities + for key, entities in _get_object_id_registry().items() + if len(entities) > 1 + and (conflict_filter is None or conflict_filter(entities, config)) + } + if not conflicts: + return config + lines = [f"{reason}, so these entities would conflict:"] + lines.extend( + f" - {platform} entities " + + ", ".join(f"'{e.name}'" for e in entities) + + (f" on device '{device_id}'" if device_id else "") + + f" share the object_id '{object_id}'" + for (device_id, platform, object_id), entities in conflicts.items() + ) + lines.append( + "To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B') " + "to distinguish the names" + ) + raise cv.Invalid("\n".join(lines)) + + return validator + + # Private config keys for storing registered string indices _KEY_DC_IDX = "_entity_dc_idx" _KEY_UOM_IDX = "_entity_uom_idx" _KEY_ICON_IDX = "_entity_icon_idx" _KEY_ENTITY_NAME = "_entity_name" -_KEY_OBJECT_ID_HASH = "_entity_object_id_hash" +_KEY_ENTITY_KEY = "_entity_key" # Bit layout for entity_fields in configure_entity_(). # Keep in sync with ENTITY_FIELD_*_SHIFT constants in esphome/core/entity_base.h @@ -300,7 +367,7 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: standalone ``var->configure_entity_(name, hash, packed)``. """ entity_name = config[_KEY_ENTITY_NAME] - object_id_hash = config[_KEY_OBJECT_ID_HASH] + entity_key = config[_KEY_ENTITY_KEY] dc_idx = config.get(_KEY_DC_IDX, 0) uom_idx = config.get(_KEY_UOM_IDX, 0) icon_idx = config.get(_KEY_ICON_IDX, 0) @@ -320,57 +387,30 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: 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 + var, entity_name, entity_key, packed ) else: - expr = var.configure_entity_(entity_name, object_id_hash, packed) + expr = var.configure_entity_(entity_name, entity_key, packed) if comment: add(RawStatement(f"{expr}; // {comment}")) else: add(expr) -def get_base_entity_object_id( +def get_base_entity_name( name: str, friendly_name: str | None, device_name: str | None = None ) -> str: - """Calculate the base object ID for an entity that will be set via set_object_id(). + """Return the base name whose hash becomes this entity's key on the device. - This function calculates what object_id_c_str_ should be set to in C++. + Follows the name selection in C++ EntityBase::configure_entity_() (entity_base.cpp): + entity name, then sub-device name, then friendly name, then the device name. - The C++ EntityBase::write_object_id_to() (entity_base.cpp) works as: - - If !has_own_name && is_name_add_mac_suffix_enabled(): - return str_sanitize(str_snake_case(App.get_friendly_name())) // Dynamic - - Else: - return object_id_c_str_ ?? "" // What we set via set_object_id() - - Since we're calculating what to pass to set_object_id(), we always need to - generate the object_id the same way, regardless of name_add_mac_suffix setting. - - Args: - name: The entity name (empty string if no name) - friendly_name: The friendly name from CORE.friendly_name - device_name: The device name if entity is on a sub-device - - Returns: - The base object ID to use for duplicate checking and to pass to set_object_id() + This is a config-time approximation for duplicate checking: when + name_add_mac_suffix is enabled the device appends the MAC suffix at runtime, + which is unknown here and identical for every entity on the device, so + ignoring it cannot change whether two entities collide with each other. """ - - if name: - # Entity has its own name (has_own_name will be true) - base_str = name - elif device_name: - # Entity has empty name and is on a sub-device - # C++ EntityBase::set_name() uses device->get_name() when device is set - base_str = device_name - elif friendly_name: - # Entity has empty name (has_own_name will be false) - # C++ uses App.get_friendly_name() which returns friendly_name or device name - base_str = friendly_name - else: - # Fallback to device name - base_str = CORE.name - - return sanitize(snake_case(base_str)) + return name or device_name or friendly_name or CORE.name def setup_entity(var_or_platform, config=None, platform=None): @@ -429,15 +469,15 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) -> device: MockObj = await get_variable(device_id_obj) add(var.set_device_(device)) - # Pre-compute entity name and object_id hash for configure_entity_() + # Pre-compute entity name and entity key for configure_entity_() # which is emitted later by finalize_entity_strings(). - # For named entities: pre-compute hash from entity name - # For empty-name entities: pass 0, C++ calculates hash at runtime from - # device name, friendly_name, or app name (bug-for-bug compatibility) + # For named entities: pre-compute the key from the raw entity name + # For empty-name entities: pass 0, C++ calculates the key at runtime from + # device name, friendly_name, or app name entity_name = config[CONF_NAME] - object_id_hash = fnv1_hash_object_id(entity_name) if entity_name else 0 + entity_key = fnv1_hash_name(entity_name) if entity_name else 0 config[_KEY_ENTITY_NAME] = entity_name - config[_KEY_OBJECT_ID_HASH] = object_id_hash + config[_KEY_ENTITY_KEY] = entity_key # Store flags for packing into configure_entity_() config[_KEY_DISABLED_BY_DEFAULT] = int(config[CONF_DISABLED_BY_DEFAULT]) if CONF_INTERNAL in config: @@ -550,14 +590,14 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy # Use the device ID string directly for uniqueness device_id = device_id_obj.id - # Calculate what object_id will actually be used - # This handles empty names correctly by using device/friendly names - name_key = get_base_entity_object_id( - entity_name, CORE.friendly_name, device_name - ) + # Hash the same raw name the device hashes into the entity key at runtime. + # This handles empty names correctly by using device/friendly names. + base_name = get_base_entity_name(entity_name, CORE.friendly_name, device_name) + name_hash = fnv1_hash_name(base_name) - # Check for duplicates - unique_key = (device_id, platform, name_key) + # Check for duplicates: two entities on the same device and platform must not + # share an entity key, since the key is what routes state to API clients + unique_key = (device_id, platform, name_hash) if unique_key in CORE.unique_ids: # Get the existing entity metadata existing = CORE.unique_ids[unique_key] @@ -581,14 +621,13 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy if existing_component != "unknown": conflict_msg += f" from component '{existing_component}'" - # Show both original names and their ASCII-only versions if they differ - sanitized_msg = "" + # Different names can only clash here through a genuine hash collision + collision_msg = "" if entity_name != existing_name: - sanitized_msg = ( - f"\n Original names: '{entity_name}' and '{existing_name}'" - f"\n Both convert to ASCII ID: '{name_key}'" - "\n To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B')" - "\n to distinguish them" + collision_msg = ( + f"\n The names '{entity_name}' and '{existing_name}' produce the" + f"\n same entity key hash ({name_hash:#010x})." + "\n To fix: Rename one of the entities" ) # Skip duplicate entity name validation when testing_mode is enabled @@ -598,9 +637,22 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy f"Duplicate {platform} entity with name '{entity_name}' found{device_prefix}. " f"{conflict_msg}. " "Each entity on a device must have a unique name within its platform." - f"{sanitized_msg}" + f"{collision_msg}" ) + # Components that still address entities by the sanitized object_id reject + # colliding names in final validation via validate_no_object_id_conflicts(), + # so track every entity by the object_id its name resolves to. Scoped per + # device and platform to match the strictness configs had before entity keys + # moved to raw names: same-named entities on different sub-devices were + # already accepted then, internal entities were already skipped (above), and + # overlaps between platforms that share an MQTT component type (sensor and + # text_sensor both publish under "sensor") were already possible. + object_id = sanitize(snake_case(base_name)) + _get_object_id_registry().setdefault( + (device_id, platform, object_id), [] + ).append(ObjectIdEntity(base_name, platform, config)) + # Store metadata about this entity entity_metadata: EntityMetadata = { "name": entity_name, diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 7940df8780..3a243289ae 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -799,6 +799,19 @@ constexpr uint32_t FNV1_OFFSET_BASIS = 2166136261UL; /// FNV-1 32-bit prime constexpr uint32_t FNV1_PRIME = 16777619UL; +/// Calculate a FNV-1 hash over raw bytes with an explicit length. Unlike fnv1_hash(const char *), +/// each byte is hashed as an unsigned value, so results are platform-independent for bytes >= 0x80. +/// IMPORTANT: Must match Python fnv1_hash_name() in esphome/helpers.py, which hashes the UTF-8 +/// encoded bytes of the name. Used to compute entity keys from raw names. +inline uint32_t fnv1_hash_bytes(const char *str, size_t len) { + uint32_t hash = FNV1_OFFSET_BASIS; + for (size_t i = 0; i < len; i++) { + hash *= FNV1_PRIME; + hash ^= static_cast(str[i]); + } + return hash; +} + /// Extend a FNV-1 hash with an integer (hashes each byte). template constexpr uint32_t fnv1_hash_extend(uint32_t hash, T value) { using UnsignedT = std::make_unsigned_t; @@ -1003,12 +1016,20 @@ template inline char *str_sanitize_to(char (&buffer)[N], const char *s // str_sanitize moved to alloc_helpers.h - remove this comment before 2026.11.0 /// Calculate FNV-1 hash of a string while applying snake_case + sanitize transformations. -/// This computes object_id hashes directly from names without creating an intermediate buffer. -/// IMPORTANT: Must match Python fnv1_hash_object_id() in esphome/helpers.py. -/// If you modify this function, update the Python version and tests in both places. -inline uint32_t fnv1_hash_object_id(const char *str, size_t len) { +/// This is the LEGACY entity hash, kept only to reconstruct preference keys that existing +/// devices already have stored; see https://github.com/esphome/backlog/issues/85. +/// With per_code_point set, UTF-8 continuation bytes are skipped so each multi-byte character +/// contributes one underscore — this matches Python fnv1_hash_object_id() in esphome/helpers.py, +/// which produced the hash for named entities. The per-byte form (default) matches the old +/// runtime hash for entities without their own name. Do not change either behavior. +/// Known limitation: Python's lower() is Unicode aware, so the rare code points it maps to a +/// different number of characters or to ASCII (e.g. 'İ', the Kelvin sign) reconstruct wrong; +/// such names skip migration once and fall back to their defaults. +inline uint32_t fnv1_hash_object_id(const char *str, size_t len, bool per_code_point = false) { uint32_t hash = FNV1_OFFSET_BASIS; for (size_t i = 0; i < len; i++) { + if (per_code_point && (static_cast(str[i]) & 0xC0) == 0x80) + continue; // UTF-8 continuation byte, already counted via its lead byte hash *= FNV1_PRIME; // Apply snake_case (space->underscore, uppercase->lowercase) then sanitize hash ^= static_cast(to_sanitized_char(to_snake_case_char(str[i]))); diff --git a/esphome/core/preference_backend.h b/esphome/core/preference_backend.h index 34bf84409d..b9bb9a0252 100644 --- a/esphome/core/preference_backend.h +++ b/esphome/core/preference_backend.h @@ -22,6 +22,12 @@ #include "esphome/components/zephyr/preference_backend.h" #endif +// Key-lookup preference backends find stored data by key; their platforms add the +// USE_PREFERENCE_KEY_LOOKUP define from Python codegen, which enables preference key +// migration. Slot-based backends (ESP8266, RP2040) instead allocate a storage slot for +// every make_preference() call and use the key only as a validity tag on that slot; +// migration is not possible there, and key collisions cannot corrupt data. + namespace esphome { #if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2) && !defined(USE_LIBRETINY) && \ @@ -40,16 +46,22 @@ class ESPPreferenceObject { ESPPreferenceObject() = default; explicit ESPPreferenceObject(PreferenceBackend *backend) : backend_(backend) {} - template bool save(const T *src) { + template bool save(const T *src) { return this->save(reinterpret_cast(src), sizeof(T)); } + + template bool load(T *dest) { return this->load(reinterpret_cast(dest), sizeof(T)); } + + /// Raw save with explicit length, for callers that only know the size at runtime. + bool save(const uint8_t *src, size_t len) { if (this->backend_ == nullptr) return false; - return this->backend_->save(reinterpret_cast(src), sizeof(T)); + return this->backend_->save(src, len); } - template bool load(T *dest) { + /// Raw load with explicit length, for callers that only know the size at runtime. + bool load(uint8_t *dest, size_t len) { if (this->backend_ == nullptr) return false; - return this->backend_->load(reinterpret_cast(dest), sizeof(T)); + return this->backend_->load(dest, len); } protected: diff --git a/esphome/core/preferences.cpp b/esphome/core/preferences.cpp new file mode 100644 index 0000000000..8508647255 --- /dev/null +++ b/esphome/core/preferences.cpp @@ -0,0 +1,25 @@ +#include "esphome/core/preferences.h" +#include "esphome/core/log.h" +#include + +namespace esphome { + +#ifdef USE_PREFERENCE_KEY_LOOKUP +static const char *const TAG = "preferences"; + +bool migrate_preference(ESPPreferenceObject &new_pref, uint8_t *scratch, size_t size, uint32_t old_key, + uint32_t new_key) { + if (new_pref.load(scratch, size)) + return true; // Current data present - never overwrite newer data with the old copy + // One-shot read by key: no backend is allocated for the old key, so boots with + // nothing to migrate (for example fresh installs) cost no heap + if (old_key == new_key || !global_preferences->load_from_key(old_key, scratch, size)) + return false; // No data stored under the old key, nothing to migrate + if (!new_pref.save(scratch, size)) { + ESP_LOGW(TAG, "Pref migration %" PRIx32 " -> %" PRIx32 " failed", old_key, new_key); + } + return true; +} +#endif // USE_PREFERENCE_KEY_LOOKUP + +} // namespace esphome diff --git a/esphome/core/preferences.h b/esphome/core/preferences.h index 1efce5af51..d24d51164a 100644 --- a/esphome/core/preferences.h +++ b/esphome/core/preferences.h @@ -23,6 +23,7 @@ struct Preferences : public PreferencesMixin { using PreferencesMixin::make_preference; ESPPreferenceObject make_preference(size_t, uint32_t, bool) { return {}; } ESPPreferenceObject make_preference(size_t, uint32_t) { return {}; } + bool load_from_key(uint32_t, uint8_t *, size_t) { return false; } /** * Commit pending writes to flash. @@ -43,3 +44,19 @@ using ESPPreferences = Preferences; extern ESPPreferences *global_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esphome #endif + +#ifdef USE_PREFERENCE_KEY_LOOKUP +namespace esphome { +/// Copy preference data stored under old_key into new_pref (created for new_key) if the keys +/// differ and new_pref has no data yet. scratch must hold at least size bytes. +/// Returns true when scratch holds the entity's current data (loaded or just migrated). +/// The old entry is intentionally left in place so a firmware downgrade still finds its data. +/// If saving under the new key fails, callers that consume scratch (like TextSaver) still get +/// valid data for this boot, callers that reload from the preference fall back to their +/// defaults, and the migration simply runs again on the next boot. +/// Only available on key-lookup preference backends; slot-based backends keep their old +/// keys instead. See: https://github.com/esphome/backlog/issues/85 +bool migrate_preference(ESPPreferenceObject &new_pref, uint8_t *scratch, size_t size, uint32_t old_key, + uint32_t new_key); +} // namespace esphome +#endif // USE_PREFERENCE_KEY_LOOKUP diff --git a/esphome/helpers.py b/esphome/helpers.py index 683aaedcf5..5c57a2823b 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import MutableMapping +from collections.abc import Iterable, MutableMapping from contextlib import suppress import ipaddress import logging @@ -56,15 +56,20 @@ def ensure_unique_string(preferred_string, current_strings): return test_string -def fnv1_hash(string: str) -> int: - """FNV-1 32-bit hash function (multiply then XOR).""" +def _fnv1_hash(values: Iterable[int]) -> int: + """FNV-1 32-bit hash (multiply then XOR) over a sequence of integer values.""" hash_value = FNV1_OFFSET_BASIS - for char in string: + for value in values: hash_value = (hash_value * FNV1_PRIME) & 0xFFFFFFFF - hash_value ^= ord(char) + hash_value ^= value return hash_value +def fnv1_hash(string: str) -> int: + """FNV-1 32-bit hash function (multiply then XOR) over code points.""" + return _fnv1_hash(map(ord, string)) + + def fnv1a_32bit_hash(string: str) -> int: """FNV-1a 32-bit hash function (XOR then multiply). @@ -89,12 +94,27 @@ def fnv1a_32bit_hash(string: str) -> int: def fnv1_hash_object_id(name: str) -> int: """Compute FNV-1 hash of name with snake_case + sanitize transformations. - IMPORTANT: Must produce same result as C++ fnv1_hash_object_id() in helpers.h. - Used for pre-computing entity object_id hashes at code generation time. + IMPORTANT: Must produce same result as C++ fnv1_hash_object_id() in helpers.h + with per_code_point set. This is the OLD entity hash; it computes preference + keys that existing devices already have stored (see + https://github.com/esphome/backlog/issues/85) and is also still used for live + keys derived from config IDs (see the motion component's calibration key). + Note: lower() here is Unicode aware while the C++ reconstruction is not; see + the known limitation note on the C++ function. """ return fnv1_hash(sanitize(snake_case(name))) +def fnv1_hash_name(name: str) -> int: + """Compute FNV-1 hash of the raw entity name (UTF-8 bytes, no transformations). + + IMPORTANT: Must produce same result as C++ fnv1_hash_bytes() in helpers.h, + which hashes the name bytes as stored on the device. + Used for pre-computing entity keys at code generation time. + """ + return _fnv1_hash(name.encode("utf-8")) + + def strip_accents(value: str) -> str: """Remove accents from a string.""" import unicodedata @@ -627,7 +647,7 @@ def add_class_to_obj(value, cls): raise -def snake_case(value): +def snake_case(value: str) -> str: """Same behaviour as `helpers.cpp` method `str_snake_case`.""" return value.replace(" ", "_").lower() @@ -635,7 +655,7 @@ def snake_case(value): _DISALLOWED_CHARS = re.compile(r"[^a-zA-Z0-9-_]") -def sanitize(value): +def sanitize(value: str) -> str: """Same behaviour as `helpers.cpp` method `str_sanitize`.""" return _DISALLOWED_CHARS.sub("_", value) diff --git a/tests/integration/entity_utils.py b/tests/integration/entity_utils.py index 7596983ee2..95f6a0321e 100644 --- a/tests/integration/entity_utils.py +++ b/tests/integration/entity_utils.py @@ -8,7 +8,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case +from esphome.helpers import fnv1_hash_name, sanitize, snake_case if TYPE_CHECKING: from aioesphomeapi import DeviceInfo, EntityInfo @@ -25,15 +25,16 @@ def infer_name_add_mac_suffix(device_info: DeviceInfo) -> bool: return device_info.name.endswith(f"-{mac_suffix}") -def _get_name_for_object_id( +def _resolve_entity_name( entity: EntityInfo, device_info: DeviceInfo, device_id_to_name: dict[int, str], ) -> str: - """Get the name used for object_id computation. + """Resolve the effective name for an entity. This is the algorithm that aioesphomeapi will use to determine which - name to use for computing object_id client-side from API data. + name to use for computing object_id client-side from API data; the same + name is what the device hashes into the entity key. Args: entity: The entity to get name for @@ -72,27 +73,27 @@ def compute_entity_object_id( Returns: The computed object_id string """ - name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name) - return compute_object_id(name_for_id) + name = _resolve_entity_name(entity, device_info, device_id_to_name) + return compute_object_id(name) -def compute_entity_hash( +def compute_entity_key( entity: EntityInfo, device_info: DeviceInfo, device_id_to_name: dict[int, str], ) -> int: - """Compute expected object_id hash for an entity. + """Compute expected entity key for an entity. Args: - entity: The entity to compute hash for + entity: The entity to compute the key for device_info: Device info from the API device_id_to_name: Mapping of device_id to device name for sub-devices Returns: - The computed FNV-1 hash + The computed FNV-1 hash of the raw name """ - name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name) - return fnv1_hash_object_id(name_for_id) + name = _resolve_entity_name(entity, device_info, device_id_to_name) + return fnv1_hash_name(name) def verify_entity_object_id( @@ -118,7 +119,7 @@ def verify_entity_object_id( f"expected '{expected_object_id}', got '{entity.object_id}'" ) - expected_hash = compute_entity_hash(entity, device_info, device_id_to_name) + expected_hash = compute_entity_key(entity, device_info, device_id_to_name) assert entity.key == expected_hash, ( f"hash mismatch for entity '{entity.name}': " f"expected {expected_hash:#x}, got {entity.key:#x}" diff --git a/tests/integration/fixtures/fnv1_hash_object_id.yaml b/tests/integration/fixtures/fnv1_hash_object_id.yaml index 2097b2fbf9..d4511bb8c6 100644 --- a/tests/integration/fixtures/fnv1_hash_object_id.yaml +++ b/tests/integration/fixtures/fnv1_hash_object_id.yaml @@ -71,6 +71,38 @@ esphome: ESP_LOGE("FNV1_OID", "empty FAILED: 0x%08x != 0x811c9dc5", hash_empty); } + // Raw name hash: matches Python fnv1_hash_name("My Sensor Name") + uint32_t hash_raw = esphome::fnv1_hash_bytes("My Sensor Name", 14); + if (hash_raw == 0x8cec6fb0) { + ESP_LOGI("FNV1_OID", "raw PASSED"); + } else { + ESP_LOGE("FNV1_OID", "raw FAILED: 0x%08x != 0x8cec6fb0", hash_raw); + } + + // Raw name hash over UTF-8 bytes: matches Python fnv1_hash_name("Température") + uint32_t hash_raw_utf8 = esphome::fnv1_hash_bytes("Temp\xc3\xa9rature", 12); + if (hash_raw_utf8 == 0x531a74aa) { + ESP_LOGI("FNV1_OID", "raw_utf8 PASSED"); + } else { + ESP_LOGE("FNV1_OID", "raw_utf8 FAILED: 0x%08x != 0x531a74aa", hash_raw_utf8); + } + + // Old-key UTF-8 variant: matches Python fnv1_hash_object_id("Température") + uint32_t hash_old_utf8 = esphome::fnv1_hash_object_id("Temp\xc3\xa9rature", 12, true); + if (hash_old_utf8 == 0x965698f3) { + ESP_LOGI("FNV1_OID", "old_utf8 PASSED"); + } else { + ESP_LOGE("FNV1_OID", "old_utf8 FAILED: 0x%08x != 0x965698f3", hash_old_utf8); + } + + // Old-key UTF-8 variant with multi-byte only name: Python fnv1_hash_object_id("温度") + uint32_t hash_old_cjk = esphome::fnv1_hash_object_id("\xe6\xb8\xa9\xe5\xba\xa6", 6, true); + if (hash_old_cjk == 0x3276cb9f) { + ESP_LOGI("FNV1_OID", "old_cjk PASSED"); + } else { + ESP_LOGE("FNV1_OID", "old_cjk FAILED: 0x%08x != 0x3276cb9f", hash_old_cjk); + } + host: api: logger: diff --git a/tests/integration/fixtures/multi_device_preferences.yaml b/tests/integration/fixtures/multi_device_preferences.yaml index 01e4394559..582add90a8 100644 --- a/tests/integration/fixtures/multi_device_preferences.yaml +++ b/tests/integration/fixtures/multi_device_preferences.yaml @@ -156,10 +156,17 @@ button: ESP_LOGI("test", "Device A Mode: %s", id(mode_device_a).current_option().c_str()); ESP_LOGI("test", "Device B Mode: %s", id(mode_device_b).current_option().c_str()); ESP_LOGI("test", "Main Mode: %s", id(mode_main).current_option().c_str()); - // Log preference hashes for entities that actually store preferences - ESP_LOGI("test", "Device A Switch Pref Hash: %u", id(light_device_a).get_preference_hash()); - ESP_LOGI("test", "Device B Switch Pref Hash: %u", id(light_device_b).get_preference_hash()); - ESP_LOGI("test", "Main Switch Pref Hash: %u", id(light_main).get_preference_hash()); - ESP_LOGI("test", "Device A Number Pref Hash: %u", id(setpoint_device_a).get_preference_hash()); - ESP_LOGI("test", "Device B Number Pref Hash: %u", id(setpoint_device_b).get_preference_hash()); - ESP_LOGI("test", "Main Number Pref Hash: %u", id(setpoint_main).get_preference_hash()); + // Log preference key bases for entities that actually store preferences. + // This is the key base make_entity_preference() uses: entity key XOR device id. + ESP_LOGI("test", "Device A Switch Pref Hash: %u", + id(light_device_a).get_entity_key() ^ id(light_device_a).get_device_id_or_zero()); + ESP_LOGI("test", "Device B Switch Pref Hash: %u", + id(light_device_b).get_entity_key() ^ id(light_device_b).get_device_id_or_zero()); + ESP_LOGI("test", "Main Switch Pref Hash: %u", + id(light_main).get_entity_key() ^ id(light_main).get_device_id_or_zero()); + ESP_LOGI("test", "Device A Number Pref Hash: %u", + id(setpoint_device_a).get_entity_key() ^ id(setpoint_device_a).get_device_id_or_zero()); + ESP_LOGI("test", "Device B Number Pref Hash: %u", + id(setpoint_device_b).get_entity_key() ^ id(setpoint_device_b).get_device_id_or_zero()); + ESP_LOGI("test", "Main Number Pref Hash: %u", + id(setpoint_main).get_entity_key() ^ id(setpoint_main).get_device_id_or_zero()); diff --git a/tests/integration/fixtures/preference_key_migration.yaml b/tests/integration/fixtures/preference_key_migration.yaml new file mode 100644 index 0000000000..a9b01fc2d2 --- /dev/null +++ b/tests/integration/fixtures/preference_key_migration.yaml @@ -0,0 +1,35 @@ +esphome: + name: host-pref-key-migration + +host: +api: +logger: + +switch: + - platform: template + id: test_switch_restore + name: Test Switch + optimistic: true + restore_mode: RESTORE_DEFAULT_OFF + +number: + - platform: template + id: test_number_restore + name: Test Number + optimistic: true + restore_value: true + initial_value: 1.0 + min_value: 0 + max_value: 100 + step: 0.5 + +text: + - platform: template + id: test_text_restore + name: Test Text + mode: text + optimistic: true + restore_value: true + initial_value: fallback + min_length: 0 + max_length: 20 diff --git a/tests/integration/host_prefs.py b/tests/integration/host_prefs.py index f835bee3bc..c7f21d8a01 100644 --- a/tests/integration/host_prefs.py +++ b/tests/integration/host_prefs.py @@ -25,15 +25,25 @@ def clear_host_prefs(device_name: str) -> None: host_prefs_path(device_name).unlink(missing_ok=True) +def write_host_prefs(device_name: str, entries: dict[int, bytes]) -> Path: + """Write preference entries, replacing the file's contents. + + Returns the path that was written. + """ + payload = b"" + for key, data in entries.items(): + if len(data) > 255: + raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)") + payload += struct.pack(" Path: """Write a single preference entry, replacing the file's contents. Returns the path that was written. """ - if len(data) > 255: - raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)") - path = host_prefs_path(device_name) - path.parent.mkdir(parents=True, exist_ok=True) - payload = struct.pack(" None: diff --git a/tests/integration/test_object_id_api_verification.py b/tests/integration/test_object_id_api_verification.py index c8603e0682..8dafb37c64 100644 --- a/tests/integration/test_object_id_api_verification.py +++ b/tests/integration/test_object_id_api_verification.py @@ -2,8 +2,8 @@ This test verifies a three-way match between: 1. C++ object_id generation (get_object_id_to using to_sanitized_char/to_snake_case_char) -2. C++ hash generation (fnv1_hash_object_id in helpers.h) -3. Python computation (sanitize/snake_case in helpers.py, fnv1_hash_object_id) +2. C++ entity key generation (fnv1_hash of the raw name in helpers.h) +3. Python computation (sanitize/snake_case and fnv1_hash_name in helpers.py) The API response contains C++ computed values, so verifying API == Python implicitly verifies C++ == Python == API for both object_id and hash. @@ -25,7 +25,7 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_object_id +from esphome.helpers import fnv1_hash_name from .entity_utils import compute_object_id, verify_all_entities from .types import APIClientConnectedFactory, RunCompiledFunction @@ -123,7 +123,7 @@ async def test_object_id_api_verification( ) # Verify hash can be computed from the name - hash_from_name = fnv1_hash_object_id(entity_name) + hash_from_name = fnv1_hash_name(entity_name) assert hash_from_name == entity.key, ( f"Entity '{entity_name}': hash mismatch. " f"Python hash {hash_from_name:#x}, API key {entity.key:#x}" @@ -164,7 +164,7 @@ async def test_object_id_api_verification( ) # Verify hash matches - expected_hash = fnv1_hash_object_id(expected_name) + expected_hash = fnv1_hash_name(expected_name) assert entity.key == expected_hash, ( f"Empty-name entity (device_id={entity.device_id}): hash mismatch. " f"API key: {entity.key:#x}, expected: {expected_hash:#x}" diff --git a/tests/integration/test_object_id_friendly_name_no_mac_suffix.py b/tests/integration/test_object_id_friendly_name_no_mac_suffix.py index 7199a2b371..b58593f2ef 100644 --- a/tests/integration/test_object_id_friendly_name_no_mac_suffix.py +++ b/tests/integration/test_object_id_friendly_name_no_mac_suffix.py @@ -11,7 +11,7 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_object_id +from esphome.helpers import fnv1_hash_name from .entity_utils import ( compute_object_id, @@ -62,7 +62,7 @@ async def test_object_id_friendly_name_no_mac_suffix( ) # Hash should match friendly_name - expected_hash = fnv1_hash_object_id("My Friendly Device") + expected_hash = fnv1_hash_name("My Friendly Device") assert entity.key == expected_hash, ( f"Expected hash {expected_hash:#x}, got {entity.key:#x}" ) diff --git a/tests/integration/test_object_id_no_friendly_name.py b/tests/integration/test_object_id_no_friendly_name.py index b548f02fde..45b5f730a6 100644 --- a/tests/integration/test_object_id_no_friendly_name.py +++ b/tests/integration/test_object_id_no_friendly_name.py @@ -17,7 +17,7 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_object_id +from esphome.helpers import fnv1_hash_name from .entity_utils import compute_object_id, verify_all_entities from .types import APIClientConnectedFactory, RunCompiledFunction @@ -96,7 +96,7 @@ async def test_object_id_no_friendly_name_no_mac_suffix( OLD behavior: - is_object_id_dynamic_() returned false (mac suffix not enabled) - Used object_id_c_str_ which was pre-computed in Python - - Python used get_base_entity_object_id() with fallback to CORE.name + - Python used get_base_entity_name() with fallback to CORE.name Result: object_id = sanitize(snake_case(device_name)) """ @@ -126,7 +126,7 @@ async def test_object_id_no_friendly_name_no_mac_suffix( ) # Hash should match device name - expected_hash = fnv1_hash_object_id("test-device") + expected_hash = fnv1_hash_name("test-device") assert entity.key == expected_hash, ( f"Expected hash {expected_hash:#x}, got {entity.key:#x}" ) diff --git a/tests/integration/test_preference_key_migration.py b/tests/integration/test_preference_key_migration.py new file mode 100644 index 0000000000..e7f699bb12 --- /dev/null +++ b/tests/integration/test_preference_key_migration.py @@ -0,0 +1,165 @@ +"""Integration test for entity preference key migration. + +Entity keys are now the FNV-1 hash of the raw name instead of the sanitized +object_id (https://github.com/esphome/backlog/issues/85). On key-lookup +preference backends, make_entity_preference() must move data stored under the +old key to the new key, so devices keep their restored state after upgrading. + +This test seeds the host preferences file the way a pre-migration firmware +would have written it and verifies: +1. Data stored under the OLD key is restored (migration happened, no data loss) +2. Data already stored under the NEW key is never overwritten by old data +""" + +from __future__ import annotations + +import socket +import struct + +from aioesphomeapi import ( + NumberInfo, + NumberState, + SwitchInfo, + SwitchState, + TextInfo, + TextState, +) +import pytest + +from esphome.helpers import fnv1_hash, fnv1_hash_name, fnv1_hash_object_id + +from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client +from .host_prefs import clear_host_prefs, write_host_prefs +from .state_utils import InitialStateHelper, require_entity +from .types import CompileFunction, ConfigWriter + +DEVICE_NAME = "host-pref-key-migration" + +# The pre-migration preference key was the sanitized object_id hash; the new +# key is the raw-name hash. All entities are on the main device (device_id 0) +# and their preferences use no version salt, so the key is just the hash. +SWITCH_OLD_KEY = fnv1_hash_object_id("Test Switch") +SWITCH_NEW_KEY = fnv1_hash_name("Test Switch") +NUMBER_OLD_KEY = fnv1_hash_object_id("Test Number") +NUMBER_NEW_KEY = fnv1_hash_name("Test Number") + +# template_text salts its key with the length limits and pattern hash; this must +# match TemplateText::setup() in template_text.cpp (min_length 0, max_length 20, +# no pattern configured) +TEXT_KEY_EXTRA = (0 << 2) + (20 << 4) + (fnv1_hash("") << 6) +TEXT_OLD_KEY = (fnv1_hash_object_id("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF +TEXT_NEW_KEY = (fnv1_hash_name("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF + +# TextSaver<20> stores a length-prefixed buffer of max_length + 1 bytes +TEXT_MAX_LENGTH = 20 + + +def text_pref_payload(value: str) -> bytes: + """Build the length-prefixed buffer TextSaver stores for a value.""" + data = value.encode("utf-8") + assert len(data) <= TEXT_MAX_LENGTH + return bytes([len(data)]) + data + b"\x00" * (TEXT_MAX_LENGTH - len(data)) + + +@pytest.mark.asyncio +async def test_preference_key_migration( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], +) -> None: + """Test that preferences stored under the old key survive the upgrade.""" + port, port_socket = reserved_tcp_port + + assert SWITCH_OLD_KEY != SWITCH_NEW_KEY + assert NUMBER_OLD_KEY != NUMBER_NEW_KEY + assert TEXT_OLD_KEY != TEXT_NEW_KEY + + # Write and compile once + config_path = await write_yaml_config(yaml_config) + binary_path = await compile_esphome(config_path) + + # Release the reserved port so the binary can bind to it + port_socket.close() + + async def boot_and_get_initial_states() -> tuple[ + SwitchState, NumberState, TextState + ]: + """Boot the binary and return the restored entity states.""" + async with ( + run_binary_and_wait_for_port(binary_path, "127.0.0.1", port), + wait_and_connect_api_client(port=port) as client, + ): + device_info = await client.device_info() + assert device_info.name == DEVICE_NAME + + entities, _ = await client.list_entities_services() + switch_entity = require_entity( + entities, "test_switch", SwitchInfo, "Test Switch" + ) + number_entity = require_entity( + entities, "test_number", NumberInfo, "Test Number" + ) + text_entity = require_entity(entities, "test_text", TextInfo, "Test Text") + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states( + initial_state_helper.on_state_wrapper(lambda s: None) + ) + await initial_state_helper.wait_for_initial_states() + + switch_state = initial_state_helper.initial_states[switch_entity.key] + number_state = initial_state_helper.initial_states[number_entity.key] + text_state = initial_state_helper.initial_states[text_entity.key] + assert isinstance(switch_state, SwitchState) + assert isinstance(number_state, NumberState) + assert isinstance(text_state, TextState) + return switch_state, number_state, text_state + + try: + # --- Run 1: only OLD keys present, as written by pre-migration firmware. + # The restored states prove the data was migrated to the new keys. + write_host_prefs( + DEVICE_NAME, + { + SWITCH_OLD_KEY: b"\x01", # bool: switch was ON + NUMBER_OLD_KEY: struct.pack(" None: + """Verify _COMMAND_TOPIC_PLATFORMS matches the MQTT components that subscribe. + + Drift silently reintroduces shared subscribe topics, so this derives the set + from the C++ components that actually call subscribe(); that also catches + platforms like text that subscribe a command topic without exposing a + command_topic key in their schema. + """ + expected: set[str] = set() + for path in (COMPONENTS_DIR / "mqtt").glob("mqtt_*.cpp"): + if path.stem in _NON_ENTITY_MQTT_SOURCES: + continue + if "this->subscribe" not in path.read_text(encoding="utf-8"): + continue + stem = path.stem.removeprefix("mqtt_") + expected.add("datetime" if stem in _DATETIME_STEMS else stem) + assert expected == _COMMAND_TOPIC_PLATFORMS + + +def test_sub_topic_platforms_in_sync() -> None: + """Verify _SUB_TOPIC_PLATFORMS matches the MQTT components with sub-topics. + + Platforms whose MQTT headers use MQTT_COMPONENT_CUSTOM_TOPIC derive extra + topics such as position/command from the object_id. + """ + expected = { + path.stem.removeprefix("mqtt_") + for path in (COMPONENTS_DIR / "mqtt").glob("mqtt_*.h") + if path.stem != "mqtt_component" + and "MQTT_COMPONENT_CUSTOM_TOPIC" in path.read_text(encoding="utf-8") + } + assert expected == _SUB_TOPIC_PLATFORMS + + +def test_conflict_filter_exempts_custom_topics() -> None: + """Test that custom state topics with discovery off avoid the conflict.""" + validator = entity_duplicate_validator("sensor") + # Both entities have custom state topics and discovery disabled per entity, + # so no object_id-derived MQTT topic is used + validator( + { + CONF_NAME: "Датчик открытия", + CONF_STATE_TOPIC: "custom/topic/a", + CONF_DISCOVERY: False, + } + ) + validator( + { + CONF_NAME: "Датчик закрытия", + CONF_STATE_TOPIC: "custom/topic/b", + CONF_DISCOVERY: False, + } + ) + + component_validator = validate_no_object_id_conflicts( + REASON, conflict_filter=_topics_conflict + ) + config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"} + assert component_validator(config) is config + + # Without the filter the same conflicts are fatal + with pytest.raises(Invalid, match=r"mqtt builds default topics"): + validate_no_object_id_conflicts(REASON)({}) + + +def test_conflict_on_default_command_topic() -> None: + """Test that commandable platforms conflict through their default command topic. + + Custom state topics with discovery off are not enough for platforms that also + subscribe to an object_id-derived command topic. + """ + validator = entity_duplicate_validator("switch") + validator( + { + CONF_NAME: "Датчик открытия", + CONF_STATE_TOPIC: "custom/topic/a", + CONF_DISCOVERY: False, + } + ) + validator( + { + CONF_NAME: "Датчик закрытия", + CONF_STATE_TOPIC: "custom/topic/b", + CONF_DISCOVERY: False, + } + ) + + component_validator = validate_no_object_id_conflicts( + REASON, conflict_filter=_topics_conflict + ) + mqtt_config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"} + # Both switches share the default command topic: rejected + with pytest.raises(Invalid, match=r"mqtt builds default topics"): + component_validator(mqtt_config) + + # With custom command topics as well, nothing derives from the object_id + CORE.reset() + validator = entity_duplicate_validator("switch") + validator( + { + CONF_NAME: "Датчик открытия", + CONF_STATE_TOPIC: "custom/topic/a", + CONF_COMMAND_TOPIC: "custom/cmd/a", + CONF_DISCOVERY: False, + } + ) + validator( + { + CONF_NAME: "Датчик закрытия", + CONF_STATE_TOPIC: "custom/topic/b", + CONF_COMMAND_TOPIC: "custom/cmd/b", + CONF_DISCOVERY: False, + } + ) + assert component_validator(mqtt_config) is mqtt_config + + +def test_conflict_on_sub_topic_platforms() -> None: + """Test that platforms with extra object_id sub-topics always conflict. + + Covers derive topics like position/command from the object_id through their + own config keys, so custom state and command topics cannot exempt them. + """ + validator = entity_duplicate_validator("cover") + validator( + { + CONF_NAME: "Датчик открытия", + CONF_STATE_TOPIC: "custom/topic/a", + CONF_COMMAND_TOPIC: "custom/cmd/a", + CONF_DISCOVERY: False, + } + ) + validator( + { + CONF_NAME: "Датчик закрытия", + CONF_STATE_TOPIC: "custom/topic/b", + CONF_COMMAND_TOPIC: "custom/cmd/b", + CONF_DISCOVERY: False, + } + ) + + component_validator = validate_no_object_id_conflicts( + REASON, conflict_filter=_topics_conflict + ) + with pytest.raises(Invalid, match=r"mqtt builds default topics"): + component_validator({CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"}) + + +def test_no_conflict_on_disjoint_default_topics() -> None: + """Test that entities whose default topics are disjoint do not conflict. + + One entity uses only the default command topic and the other only the default + state topic, so they never share a topic. + """ + validator = entity_duplicate_validator("switch") + validator( + { + CONF_NAME: "Датчик открытия", + CONF_STATE_TOPIC: "custom/topic/a", + CONF_DISCOVERY: False, + } + ) + validator( + { + CONF_NAME: "Датчик закрытия", + CONF_COMMAND_TOPIC: "custom/cmd/b", + CONF_DISCOVERY: False, + } + ) + + component_validator = validate_no_object_id_conflicts( + REASON, conflict_filter=_topics_conflict + ) + config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"} + assert component_validator(config) is config + + +def test_no_conflict_on_empty_topic_prefix() -> None: + """Test that an empty topic_prefix disables the default topic conflict. + + With topic_prefix set to null no default topics exist at runtime, so entities + without custom state topics cannot conflict; only discovery still matters. + """ + validator = entity_duplicate_validator("sensor") + validator({CONF_NAME: "Датчик открытия"}) + validator({CONF_NAME: "Датчик закрытия"}) + + component_validator = validate_no_object_id_conflicts( + REASON, conflict_filter=_topics_conflict + ) + # No default topics and no discovery: valid + config: dict = {CONF_DISCOVERY: False, CONF_TOPIC_PREFIX: ""} + assert component_validator(config) is config + + # Discovery still uses object_id-derived config topics: rejected + with pytest.raises(Invalid, match=r"mqtt builds default topics"): + component_validator({CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: ""}) diff --git a/tests/unit_tests/core/common.py b/tests/unit_tests/core/common.py index daa429dc96..96fcc5b1c6 100644 --- a/tests/unit_tests/core/common.py +++ b/tests/unit_tests/core/common.py @@ -29,5 +29,5 @@ def load_config_from_fixture( ) -> Config | None: """Load configuration from a fixture file.""" fixture_path = fixtures_dir / fixture_name - yaml_content = fixture_path.read_text() + yaml_content = fixture_path.read_text(encoding="utf-8") return load_config_from_yaml(yaml_file, yaml_content) diff --git a/tests/unit_tests/core/conftest.py b/tests/unit_tests/core/conftest.py index 42e59c15e6..9ef31a82b9 100644 --- a/tests/unit_tests/core/conftest.py +++ b/tests/unit_tests/core/conftest.py @@ -12,7 +12,7 @@ def yaml_file(tmp_path: Path) -> Callable[[str], Path]: def _yaml_file(content: str) -> Path: yaml_path = tmp_path / "test.yaml" - yaml_path.write_text(content) + yaml_path.write_text(content, encoding="utf-8") return yaml_path return _yaml_file diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 3ac4ce27af..64400c4fd4 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -1,4 +1,4 @@ -"""Test get_base_entity_object_id function matches C++ behavior.""" +"""Tests for entity helpers: name selection, entity key hashing, duplicate checks.""" from collections.abc import Callable, Generator from pathlib import Path @@ -25,16 +25,17 @@ from esphome.core.entity_helpers import ( _setup_entity_impl, entity_duplicate_validator, finalize_entity_strings, - get_base_entity_object_id, + get_base_entity_name, register_device_class, register_icon, register_unit_of_measurement, setup_device_class, setup_entity, setup_unit_of_measurement, + validate_no_object_id_conflicts, ) from esphome.cpp_generator import MockObj -from esphome.helpers import sanitize, snake_case +from esphome.helpers import fnv1_hash_name, sanitize, snake_case from .common import load_config_from_fixture @@ -57,206 +58,26 @@ def restore_core_state() -> Generator[None, None, None]: CORE.friendly_name = original_friendly_name -def test_with_entity_name() -> None: - """Test when entity has its own name - should use entity name.""" - # Simple name - assert get_base_entity_object_id("Temperature Sensor", None) == "temperature_sensor" - assert ( - get_base_entity_object_id("Temperature Sensor", "Device Name") - == "temperature_sensor" - ) - # Even with device name, entity name takes precedence - assert ( - get_base_entity_object_id("Temperature Sensor", "Device Name", "Sub Device") - == "temperature_sensor" - ) - - # Name with special characters - assert ( - get_base_entity_object_id("Temp!@#$%^&*()Sensor", None) - == "temp__________sensor" - ) - assert get_base_entity_object_id("Temp-Sensor_123", None) == "temp-sensor_123" - - # Already snake_case - assert get_base_entity_object_id("temperature_sensor", None) == "temperature_sensor" - - # Mixed case - assert get_base_entity_object_id("TemperatureSensor", None) == "temperaturesensor" - assert get_base_entity_object_id("TEMPERATURE SENSOR", None) == "temperature_sensor" - - -def test_empty_name_with_device_name() -> None: - """Test when entity has empty name and is on a sub-device - should use device name.""" - # C++ behavior: when has_own_name is false and device is set, uses device->get_name() - assert ( - get_base_entity_object_id("", "Friendly Device", "Sub Device 1") - == "sub_device_1" - ) - assert ( - get_base_entity_object_id("", "Kitchen Controller", "controller_1") - == "controller_1" - ) - assert get_base_entity_object_id("", None, "Test-Device_123") == "test-device_123" - - -def test_empty_name_with_friendly_name() -> None: - """Test when entity has empty name and no device - should use friendly name.""" - # C++ behavior: when has_own_name is false, uses App.get_friendly_name() - assert get_base_entity_object_id("", "Friendly Device") == "friendly_device" - assert get_base_entity_object_id("", "Kitchen Controller") == "kitchen_controller" - assert get_base_entity_object_id("", "Test-Device_123") == "test-device_123" - - # Special characters in friendly name - assert get_base_entity_object_id("", "Device!@#$%") == "device_____" - - -def test_empty_name_no_friendly_name() -> None: - """Test when entity has empty name and no friendly name - should use device name.""" - # Test with CORE.name set - CORE.name = "device-name" - assert get_base_entity_object_id("", None) == "device-name" - - CORE.name = "Test Device" - assert get_base_entity_object_id("", None) == "test_device" - - -def test_edge_cases() -> None: - """Test edge cases.""" - # Only spaces - assert get_base_entity_object_id(" ", None) == "___" - - # Unicode characters (should be replaced) - assert get_base_entity_object_id("Température", None) == "temp_rature" - assert get_base_entity_object_id("测试", None) == "__" - - # Empty string with empty friendly name (empty friendly name is treated as None) - # Falls back to CORE.name - CORE.name = "device" - assert get_base_entity_object_id("", "") == "device" - - # Very long name (should work fine) - long_name = "a" * 100 + " " + "b" * 100 - expected = "a" * 100 + "_" + "b" * 100 - assert get_base_entity_object_id(long_name, None) == expected - - -@pytest.mark.parametrize( - ("name", "expected"), - [ - ("Temperature Sensor", "temperature_sensor"), - ("Living Room Light", "living_room_light"), - ("Test-Device_123", "test-device_123"), - ("Special!@#Chars", "special___chars"), - ("UPPERCASE NAME", "uppercase_name"), - ("lowercase name", "lowercase_name"), - ("Mixed Case Name", "mixed_case_name"), - (" Spaces ", "___spaces___"), - ], -) -def test_matches_cpp_helpers(name: str, expected: str) -> None: - """Test that the logic matches using snake_case and sanitize directly.""" - # For non-empty names, verify our function produces same result as direct snake_case + sanitize - assert get_base_entity_object_id(name, None) == sanitize(snake_case(name)) - assert get_base_entity_object_id(name, None) == expected - - -def test_empty_name_fallback() -> None: - """Test empty name handling which falls back to friendly_name or CORE.name.""" - # Empty name is handled specially - it doesn't just use sanitize(snake_case("")) - # Instead it falls back to friendly_name or CORE.name - assert sanitize(snake_case("")) == "" # Direct conversion gives empty string - # But our function returns a fallback - CORE.name = "device" - assert get_base_entity_object_id("", None) == "device" # Uses device name - - -def test_name_add_mac_suffix_behavior() -> None: - """Test behavior related to name_add_mac_suffix. - - In C++, an entity's object_id is computed from its name_ via - write_object_id_to() (sanitized snake_case). When an entity has no name, - configure_entity_() sets name_ from the friendly name, with the MAC suffix - appended when name_add_mac_suffix is enabled. Our function always returns - the same result since we're calculating the base for duplicate tracking. - """ - # The function should always return the same result regardless of - # name_add_mac_suffix setting, as we're calculating the base object_id - assert get_base_entity_object_id("", "Test Device") == "test_device" - assert get_base_entity_object_id("Entity Name", "Test Device") == "entity_name" - - -def test_priority_order() -> None: +def test_get_base_entity_name_priority_order() -> None: """Test the priority order: entity name > device name > friendly name > CORE.name.""" CORE.name = "core-device" - # 1. Entity name has highest priority + # 1. Entity name has highest priority and is used as-is, no transformations assert ( - get_base_entity_object_id("Entity Name", "Friendly Name", "Device Name") - == "entity_name" + get_base_entity_name("Entity Name", "Friendly Name", "Device Name") + == "Entity Name" ) + assert get_base_entity_name("Température", None) == "Température" # 2. Device name is next priority (when entity name is empty) - assert ( - get_base_entity_object_id("", "Friendly Name", "Device Name") == "device_name" - ) + assert get_base_entity_name("", "Friendly Name", "Device Name") == "Device Name" # 3. Friendly name is next (when entity and device names are empty) - assert get_base_entity_object_id("", "Friendly Name", None) == "friendly_name" + assert get_base_entity_name("", "Friendly Name", None) == "Friendly Name" - # 4. CORE.name is last resort - assert get_base_entity_object_id("", None, None) == "core-device" - - -@pytest.mark.parametrize( - ("name", "friendly_name", "device_name", "expected"), - [ - # name, friendly_name, device_name, expected - ("Living Room Light", None, None, "living_room_light"), - ("", "Kitchen Controller", None, "kitchen_controller"), - ( - "", - "ESP32 Device", - "controller_1", - "controller_1", - ), # Device name takes precedence - ("GPIO2 Button", None, None, "gpio2_button"), - ("WiFi Signal", "My Device", None, "wifi_signal"), - ("", None, "esp32_node", "esp32_node"), - ("Front Door Sensor", "Home Assistant", "door_controller", "front_door_sensor"), - ], -) -def test_real_world_examples( - name: str, friendly_name: str | None, device_name: str | None, expected: str -) -> None: - """Test real-world entity naming scenarios.""" - result = get_base_entity_object_id(name, friendly_name, device_name) - assert result == expected - - -def test_issue_6953_scenarios() -> None: - """Test specific scenarios from issue #6953.""" - # Scenario 1: Multiple empty names on main device with name_add_mac_suffix - # The Python code calculates the base, C++ might append MAC suffix dynamically - CORE.name = "device-name" - CORE.friendly_name = "Friendly Device" - - # All empty names should resolve to same base - assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" - assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" - assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" - - # Scenario 2: Empty names on sub-devices - assert ( - get_base_entity_object_id("", "Main Device", "controller_1") == "controller_1" - ) - assert ( - get_base_entity_object_id("", "Main Device", "controller_2") == "controller_2" - ) - - # Scenario 3: xyz duplicates - assert get_base_entity_object_id("xyz", None) == "xyz" - assert get_base_entity_object_id("xyz", "Device") == "xyz" + # 4. CORE.name is last resort; an empty friendly name falls through to it + assert get_base_entity_name("", None, None) == "core-device" + assert get_base_entity_name("", "") == "core-device" # Tests for setup_entity function @@ -515,9 +336,10 @@ def test_entity_duplicate_validator() -> None: config1 = {CONF_NAME: "Temperature"} validated1 = validator(config1) assert validated1 == config1 - assert ("", "sensor", "temperature") in CORE.unique_ids + temperature_key = ("", "sensor", fnv1_hash_name("Temperature")) + assert temperature_key in CORE.unique_ids # Check metadata was stored - metadata = CORE.unique_ids[("", "sensor", "temperature")] + metadata = CORE.unique_ids[temperature_key] assert metadata["name"] == "Temperature" assert metadata["platform"] == "sensor" @@ -525,8 +347,9 @@ def test_entity_duplicate_validator() -> None: config2 = {CONF_NAME: "Humidity"} validated2 = validator(config2) assert validated2 == config2 - assert ("", "sensor", "humidity") in CORE.unique_ids - metadata2 = CORE.unique_ids[("", "sensor", "humidity")] + humidity_key = ("", "sensor", fnv1_hash_name("Humidity")) + assert humidity_key in CORE.unique_ids + metadata2 = CORE.unique_ids[humidity_key] assert metadata2["name"] == "Humidity" # Duplicate entity should fail @@ -547,18 +370,19 @@ def test_entity_duplicate_validator_with_devices() -> None: device2 = ID("device2", type="Device") # Same name on different devices should pass + name_hash = fnv1_hash_name("Temperature") config1 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1} validated1 = validator(config1) assert validated1 == config1 - assert ("device1", "sensor", "temperature") in CORE.unique_ids - metadata1 = CORE.unique_ids[("device1", "sensor", "temperature")] + assert ("device1", "sensor", name_hash) in CORE.unique_ids + metadata1 = CORE.unique_ids[("device1", "sensor", name_hash)] assert metadata1["device_id"] == "device1" config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device2} validated2 = validator(config2) assert validated2 == config2 - assert ("device2", "sensor", "temperature") in CORE.unique_ids - metadata2 = CORE.unique_ids[("device2", "sensor", "temperature")] + assert ("device2", "sensor", name_hash) in CORE.unique_ids + metadata2 = CORE.unique_ids[("device2", "sensor", name_hash)] assert metadata2["device_id"] == "device2" # Duplicate on same device should fail @@ -610,6 +434,33 @@ def test_entity_different_platforms_yaml_validation( assert result is not None +def test_object_id_conflict_mqtt_yaml_validation( + yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] +) -> None: + """Test that names sanitizing to the same object_id fail when mqtt is configured.""" + result = load_config_from_fixture( + yaml_file, "object_id_conflict_mqtt.yaml", FIXTURES_DIR + ) + assert result is None + + captured = capsys.readouterr() + assert ( + "mqtt builds default topics and discovery topics from the entity object_id" + in captured.out + ) + + +def test_object_id_conflict_without_mqtt_yaml_validation( + yaml_file: Callable[[str], str], +) -> None: + """Test that names sanitizing to the same object_id pass without mqtt/prometheus.""" + result = load_config_from_fixture( + yaml_file, "object_id_conflict_no_mqtt.yaml", FIXTURES_DIR + ) + # This should succeed + assert result is not None + + def test_entity_duplicate_validator_error_message() -> None: """Test that duplicate entity error messages include helpful metadata.""" # Create validator for sensor platform @@ -668,7 +519,8 @@ def test_entity_duplicate_validator_internal_entities() -> None: validated1 = validator(config1) assert validated1 == config1 # New format includes device_id (empty string for main device) - assert ("", "sensor", "temperature") in CORE.unique_ids + temperature_key = ("", "sensor", fnv1_hash_name("Temperature")) + assert temperature_key in CORE.unique_ids # Internal entity with same name should pass (not added to unique_ids) config2 = {CONF_NAME: "Temperature", CONF_INTERNAL: True} @@ -676,7 +528,7 @@ def test_entity_duplicate_validator_internal_entities() -> None: assert validated2 == config2 # Internal entity should not be added to unique_ids # Count how many times the key appears (should still be 1) - count = sum(1 for k in CORE.unique_ids if k == ("", "sensor", "temperature")) + count = sum(1 for k in CORE.unique_ids if k == temperature_key) assert count == 1 # Another internal entity with same name should also pass @@ -684,7 +536,7 @@ def test_entity_duplicate_validator_internal_entities() -> None: validated3 = validator(config3) assert validated3 == config3 # Still only one entry in unique_ids (from the non-internal entity) - count = sum(1 for k in CORE.unique_ids if k == ("", "sensor", "temperature")) + count = sum(1 for k in CORE.unique_ids if k == temperature_key) assert count == 1 # Non-internal entity with same name should fail @@ -712,30 +564,148 @@ def test_empty_or_null_device_id_on_entity() -> None: def test_entity_duplicate_validator_non_ascii_names() -> None: - """Test that non-ASCII names show helpful error messages.""" + """Test that distinct non-ASCII names no longer collide. + + These names used to be rejected because both sanitize to only underscores; + the entity key now hashes the raw name so they stay distinct. + """ # Create validator for binary_sensor platform validator = entity_duplicate_validator("binary_sensor") - # First Russian sensor should pass + # Both Russian sensors should pass even though they sanitize identically config1 = {CONF_NAME: "Датчик открытия основного крана"} validated1 = validator(config1) assert validated1 == config1 - # Second Russian sensor with different text but same ASCII conversion should fail config2 = {CONF_NAME: "Датчик закрытия основного крана"} + validated2 = validator(config2) + assert validated2 == config2 + + # An exact duplicate still fails + config3 = {CONF_NAME: "Датчик открытия основного крана"} + with pytest.raises( + Invalid, + match=r"Duplicate binary_sensor entity with name 'Датчик открытия основного крана' found", + ): + validator(config3) + + +def test_entity_duplicate_validator_hash_collision() -> None: + """Test that two different names with the same FNV-1 hash are rejected.""" + # Brute-forced FNV-1 32-bit collision pair; both hash to 0x0ee5ff7b + name_a = "Sensor m2CZ" + name_b = "Sensor qCaa" + assert name_a != name_b + assert fnv1_hash_name(name_a) == fnv1_hash_name(name_b) + + validator = entity_duplicate_validator("sensor") + + config1 = {CONF_NAME: name_a} + validated1 = validator(config1) + assert validated1 == config1 + + config2 = {CONF_NAME: name_b} with pytest.raises( Invalid, match=re.compile( - r"Duplicate binary_sensor entity with name 'Датчик закрытия основного крана' found.*" - r"Original names: 'Датчик закрытия основного крана' and 'Датчик открытия основного крана'.*" - r"Both convert to ASCII ID: '_______________________________'.*" - r"To fix: Add unique ASCII characters \(e\.g\., '1', '2', or 'A', 'B'\)", + rf"Duplicate sensor entity with name '{name_b}' found.*" + rf"The names '{name_b}' and '{name_a}' produce the.*" + r"same entity key hash \(0x0ee5ff7b\).*" + r"To fix: Rename one of the entities", re.DOTALL, ), ): validator(config2) +def test_object_id_conflicts_rejected_by_component_validator() -> None: + """Test that object_id conflicts pass entity validation but fail for mqtt/prometheus.""" + validator = entity_duplicate_validator("sensor") + + # Both names validate fine in general (distinct raw names, distinct keys) + validator({CONF_NAME: "Датчик открытия"}) + validator({CONF_NAME: "Датчик закрытия"}) + + # A component that addresses entities by object_id must reject the config + component_validator = validate_no_object_id_conflicts( + "mqtt builds default topics from the entity object_id" + ) + with pytest.raises( + Invalid, + match=re.compile( + r"mqtt builds default topics from the entity object_id.*" + r"sensor entities 'Датчик открытия', 'Датчик закрытия' " + r"share the object_id '_______________'.*" + r"To fix: Add unique ASCII characters", + re.DOTALL, + ), + ): + component_validator({}) + + +def test_object_id_conflicts_skipped_in_testing_mode() -> None: + """Test that testing_mode skips the conflict check, as used for grouped testing.""" + validator = entity_duplicate_validator("sensor") + validator({CONF_NAME: "Датчик открытия"}) + validator({CONF_NAME: "Датчик закрытия"}) + + component_validator = validate_no_object_id_conflicts( + "mqtt builds default topics from the entity object_id" + ) + CORE.testing_mode = True + try: + config: dict = {} + assert component_validator(config) is config + finally: + CORE.testing_mode = False + + +def test_object_id_conflicts_none_recorded() -> None: + """Test that distinct object_ids produce no conflicts.""" + validator = entity_duplicate_validator("sensor") + validator({CONF_NAME: "Temperature"}) + validator({CONF_NAME: "Humidity"}) + + component_validator = validate_no_object_id_conflicts( + "mqtt builds default topics from the entity object_id" + ) + config: dict = {} + assert component_validator(config) is config + + +def test_object_id_conflicts_device_scoped() -> None: + """Test that the object_id conflict check is scoped per device. + + Same-named entities on different sub-devices were accepted before entity keys + moved to raw names, so the check keeps that scope; conflicts within one device + are still reported with the device named in the message. + """ + validator = entity_duplicate_validator("sensor") + validator({CONF_NAME: "Temperature", CONF_DEVICE_ID: ID("device1", type="Device")}) + validator({CONF_NAME: "Temperature", CONF_DEVICE_ID: ID("device2", type="Device")}) + + component_validator = validate_no_object_id_conflicts( + "prometheus builds metric labels from the entity object_id" + ) + config: dict = {} + assert component_validator(config) is config + + # Two names sanitizing identically on the same sub-device still conflict + validator( + {CONF_NAME: "Датчик открытия", CONF_DEVICE_ID: ID("device1", type="Device")} + ) + validator( + {CONF_NAME: "Датчик закрытия", CONF_DEVICE_ID: ID("device1", type="Device")} + ) + with pytest.raises( + Invalid, + match=re.compile( + r"prometheus builds metric labels.*on device 'device1'", re.DOTALL + ), + ): + component_validator({}) + + def test_entity_duplicate_validator_same_name_no_enhanced_message() -> None: """Test that identical names don't show the enhanced message.""" # Create validator for sensor platform @@ -793,7 +763,7 @@ async def test_setup_entity_empty_name_with_device( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_object_id_hash") == 0 + assert config.get("_entity_key") == 0 @pytest.mark.asyncio @@ -822,7 +792,7 @@ async def test_setup_entity_empty_name_with_mac_suffix( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_object_id_hash") == 0 + assert config.get("_entity_key") == 0 @pytest.mark.asyncio @@ -852,7 +822,7 @@ async def test_setup_entity_empty_name_with_mac_suffix_no_friendly_name( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_object_id_hash") == 0 + assert config.get("_entity_key") == 0 @pytest.mark.asyncio @@ -883,7 +853,7 @@ async def test_setup_entity_empty_name_no_mac_suffix_no_friendly_name( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_object_id_hash") == 0 + assert config.get("_entity_key") == 0 def test_register_string_overflow() -> None: diff --git a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml b/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml new file mode 100644 index 0000000000..4a6f56f473 --- /dev/null +++ b/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml @@ -0,0 +1,22 @@ +esphome: + name: test-object-id-conflict + +esp32: + board: esp32dev + +wifi: + ssid: MySSID + password: password1 + +mqtt: + broker: test.mosquitto.org + +sensor: + # Distinct raw names are fine in general, but both sanitize to the same + # object_id, which MQTT still uses to build default topics - should fail + - platform: template + name: "Датчик открытия" + lambda: return 21.0; + - platform: template + name: "Датчик закрытия" + lambda: return 22.0; diff --git a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml b/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml new file mode 100644 index 0000000000..c0fbd5cbba --- /dev/null +++ b/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml @@ -0,0 +1,15 @@ +esphome: + name: test-object-id-ok + +esp32: + board: esp32dev + +sensor: + # Distinct raw names that sanitize to the same object_id are allowed when no + # component addresses entities by object_id (no mqtt or prometheus configured) + - platform: template + name: "Датчик открытия" + lambda: return 21.0; + - platform: template + name: "Датчик закрытия" + lambda: return 22.0; diff --git a/tests/unit_tests/test_preference_hash_stability.py b/tests/unit_tests/test_preference_hash_stability.py new file mode 100644 index 0000000000..d3e5fac36a --- /dev/null +++ b/tests/unit_tests/test_preference_hash_stability.py @@ -0,0 +1,239 @@ +"""Tests to verify preference and entity key hash values remain stable. + +These tests ensure the hash algorithms do NOT change, as any change would cause +users to lose stored preferences (calibration values, restore states, etc.) on +firmware upgrades, or break entity state routing to API clients. + +Two algorithms are locked here (see https://github.com/esphome/backlog/issues/85): +1. `fnv1_hash_object_id(name)` - the LEGACY hash (snake_case + sanitize, then FNV-1). + Existing devices have preferences stored under keys derived from it; slot-based + backends (ESP8266, RP2040) keep using it, and key-lookup backends migrate FROM it. +2. `fnv1_hash_name(name)` - the entity key (FNV-1 over the raw UTF-8 name bytes). + Sent to API clients and used as the preference key base on key-lookup backends. + +DO NOT CHANGE THE EXPECTED VALUES - if tests fail after modifying a hash algorithm, +the change breaks backward compatibility and will cause data loss. +""" + +import pytest + +from esphome.helpers import ( + FNV1_OFFSET_BASIS, + FNV1_PRIME, + fnv1_hash_name, + fnv1_hash_object_id, +) + +# ============================================================================= +# Test: fnv1_hash_object_id produces stable hashes for entity names +# ============================================================================= + + +@pytest.mark.parametrize( + ("entity_name", "expected_object_id_hash"), + [ + # ===================================================================== + # Core entity types - these names appear in many ESPHome configurations + # ===================================================================== + # Basic single-word names + ("Light", 0x735CF023), + ("Switch", 0xBEDF78E5), + ("Sensor", 0x75E61B1B), + ("Fan", 0x468F6780), + ("Climate", 0xAA22FD4A), + ("Cover", 0xA630D0A2), + ("Lock", 0x1D2FD708), + ("Valve", 0x25ED5F65), + ("Button", 0x3A42C455), + ("Number", 0xB900E22A), + ("Select", 0x556391B5), + ("Text", 0xB12BFA38), + # Multi-word names (spaces become underscores, lowercase) + ("Living Room Light", 0xC6F81EC9), + ("Kitchen Switch", 0xC63C0F6E), + ("Temperature Sensor", 0x16AF55B6), + ("Garage Door Cover", 0x685E5281), + ("Bedroom Fan", 0x21AB1DED), + ("Front Door Lock", 0xB9BEF8E1), + # Already snake_case names (should hash same as space-separated) + ("living_room_light", 0xC6F81EC9), # Same as "Living Room Light" + ("kitchen_switch", 0xC63C0F6E), # Same as "Kitchen Switch" + # Names with numbers + ("Sensor 1", 0x99828E4B), + ("Relay 2", 0x6FFEF2FB), + ("Zone 10", 0xFD83AA95), + # Names with special characters (become underscores) + ("AC Unit", 0x336C6886), + ("WiFi Signal", 0x2FA52175), + ("CO2 Level", 0x31049870), + # Mixed case handling + ("mySwitch", 0x9AA10553), + ("MySwitch", 0x9AA10553), # Same as lowercase + ("MYSWITCH", 0x9AA10553), # Same as lowercase + # ===================================================================== + # Edge cases + # ===================================================================== + # Empty name (hashes to the FNV-1 offset basis since no chars processed) + ("", 0x811C9DC5), + # Single character + ("a", 0x050C5D7E), + ("A", 0x050C5D7E), # Same after lowercase + ("1", 0x050C5D2E), + ("_", 0x050C5D40), + # Names that differ only in case (should hash identically) + ("test", 0xBC2C0BE9), + ("Test", 0xBC2C0BE9), + ("TEST", 0xBC2C0BE9), + # Names that differ only in spaces vs underscores (should hash identically) + ("foo bar", 0x3AE35AA1), + ("foo_bar", 0x3AE35AA1), + ("Foo Bar", 0x3AE35AA1), + ("FOO_BAR", 0x3AE35AA1), + # Non-ASCII names (sanitized per code point, one underscore per character) + ("äöü", 0x10028B12), + ("温度", 0x3276CB9F), + ("Température", 0x965698F3), + # ===================================================================== + # Real-world component entity names from ESPHome codebase + # ===================================================================== + # From fan.cpp - FanRestoreState + ("Ceiling Fan", 0x640DEF00), + # From climate.cpp - ClimateRestoreState + ("HVAC", 0xDD68438B), + ("Thermostat", 0x30A5B7C6), + # From light/light_state.cpp + ("LED Strip", 0x2A068423), + ("Dimmable Light", 0xD70393F3), + # From cover/cover.cpp + ("Garage Door", 0x53987A5D), + ("Window Blind", 0x851291A5), + # From switch/switch.cpp + ("Relay", 0xD3A92FE4), + ("Power Switch", 0x5C4A47B3), + # From number/automation.cpp + ("Brightness", 0xF46E252C), + ("Volume", 0x8FFEBE43), + # From template datetime entities + ("Wake Time", 0xEE612B53), + ("Schedule Date", 0xF538C8DD), + ], +) +def test_entity_object_id_hash_stability( + entity_name: str, expected_object_id_hash: int +) -> None: + """Verify fnv1_hash_object_id produces stable hashes for entity names. + + CRITICAL: These expected values MUST NOT CHANGE. Existing devices have + preferences stored under keys derived from this legacy hash; changing it + breaks the old-to-new key migration and loses stored preferences. + """ + actual = fnv1_hash_object_id(entity_name) + assert actual == expected_object_id_hash, ( + f"Hash for '{entity_name}' changed from {expected_object_id_hash:#010x} to {actual:#010x}. " + f"This will cause users to lose stored preferences!" + ) + + +# ============================================================================= +# Test: Legacy preference key computation formula +# ============================================================================= + + +def compute_legacy_preference_key( + entity_name: str, version: int = 0, device_id: int = 0 +) -> int: + """Compute the legacy preference key: (object_id_hash ^ device_id) ^ version. + + This is the key existing devices have data stored under. Slot-based backends + (ESP8266, RP2040) still use it directly; key-lookup backends compute it as the + migration source in EntityBase::make_entity_preference_() (entity_base.cpp). + """ + object_id_hash = fnv1_hash_object_id(entity_name) + preference_hash = object_id_hash ^ device_id + key = preference_hash ^ version + return key & 0xFFFFFFFF + + +# Restore state version constants from ESPHome components +# These MUST match the RESTORE_STATE_VERSION values in the C++ code +FAN_RESTORE_STATE_VERSION = 0x71700ABA # From fan/fan.cpp +CLIMATE_RESTORE_STATE_VERSION = 0x848EA6AD # From climate/climate.cpp + + +@pytest.mark.parametrize( + ("entity_name", "version", "device_id", "expected_key"), + [ + # No version, main device (key equals the plain object_id hash) + ("Test Sensor", 0, 0, 0x5D74FA46), + ("Light", 0, 0, 0x735CF023), + # Restore state versions on the main device + ("Ceiling Fan", FAN_RESTORE_STATE_VERSION, 0, 0x157DE5BA), + ("HVAC", CLIMATE_RESTORE_STATE_VERSION, 0, 0x59E6E526), + # Sub-devices: same entity name on different devices gets different keys + ("Light", 0, 1, 0x735CF022), + ("Fan", FAN_RESTORE_STATE_VERSION, 0xABCD, 0x37FFC6F7), + ], +) +def test_legacy_preference_key_computation( + entity_name: str, version: int, device_id: int, expected_key: int +) -> None: + """Verify legacy preference key computation matches expected values. + + This test ensures the formula doesn't change, which would break both slot-based + preference storage and the migration source keys on key-lookup backends. + """ + actual_key = compute_legacy_preference_key(entity_name, version, device_id) + + assert actual_key == expected_key, ( + f"Preference key for '{entity_name}' (version={version:#x}, device_id={device_id}) " + f"changed from {expected_key:#010x} to {actual_key:#010x}. " + f"This will cause users to lose stored preferences!" + ) + + +# ============================================================================= +# Test: fnv1_hash_name produces stable entity keys (raw name, UTF-8 bytes) +# ============================================================================= + + +@pytest.mark.parametrize( + ("entity_name", "expected_key"), + [ + # ASCII names + ("Temperature Sensor", 0x801C3665), + ("LED Strip", 0xD5C7B082), + ("Garage Door", 0x2D70E086), + ("Relay", 0x565177C4), + # Raw names are case and space sensitive, unlike the old object_id hash + ("temperature sensor", 0xF9F431E5), + # Non-ASCII names hash their UTF-8 bytes and stay distinct + ("Датчик открытия", 0x001861C1), + ("温度", 0x8EDF61C9), + ("Température", 0x531A74AA), + # Empty name hashes to the FNV-1 offset basis + ("", 0x811C9DC5), + ], +) +def test_entity_key_hash_stability(entity_name: str, expected_key: int) -> None: + """Verify fnv1_hash_name produces stable entity keys. + + CRITICAL: These expected values MUST NOT CHANGE. The entity key is sent to + API clients and is the new preference key base; changing the algorithm + would break state routing and lose stored preferences. + Must match C++ fnv1_hash_bytes() in esphome/core/helpers.h. + """ + actual = fnv1_hash_name(entity_name) + assert actual == expected_key, ( + f"Entity key for '{entity_name}' changed from {expected_key:#010x} to {actual:#010x}. " + f"This breaks state routing and stored preferences!" + ) + + +def test_fnv1_hash_name_matches_utf8_byte_hash() -> None: + """Verify fnv1_hash_name hashes the UTF-8 encoded bytes of the name.""" + name = "Température 温度" + hash_value = FNV1_OFFSET_BASIS + for byte in name.encode("utf-8"): + hash_value = (hash_value * FNV1_PRIME) & 0xFFFFFFFF + hash_value ^= byte + assert fnv1_hash_name(name) == hash_value From 25d59857755119a4033206406d83b6643079abe3 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:29:21 +0000 Subject: [PATCH 1195/1815] Bump bundled esphome-device-builder to 1.9.2 (#18051) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 5f84226b87..ebce522454 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.9.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.9.2 RUN \ platformio settings set enable_telemetry No \ From 4f67932e0d73f5254a6e2591e587baf92e27c469 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 Aug 2026 20:48:11 -0500 Subject: [PATCH 1196/1815] [substitutions][core] Expand templated !include paths to on-disk candidates during bundle discovery (#17647) --- esphome/components/substitutions/__init__.py | 54 +++- esphome/expression.py | 6 +- esphome/yaml_util.py | 174 +++++++++++-- tests/unit_tests/test_bundle.py | 59 ++++- tests/unit_tests/test_substitutions.py | 133 ++++++++++ tests/unit_tests/test_yaml_util.py | 245 ++++++++++++++++++- 6 files changed, 648 insertions(+), 23 deletions(-) diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index ea79054c88..b4fcf36c9e 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -1,5 +1,7 @@ from collections import ChainMap +from itertools import product import logging +import re from typing import Any import esphome @@ -7,6 +9,7 @@ from esphome import core from esphome.config_helpers import Extend, Remove, merge_config, merge_dicts_ordered import esphome.config_validation as cv from esphome.const import CONF_SUBSTITUTIONS, VALID_SUBSTITUTIONS_CHARACTERS +from esphome.expression import JINJA_PROG from esphome.types import ConfigType from esphome.util import OrderedDict from esphome.yaml_util import ( @@ -27,6 +30,14 @@ _LOGGER = logging.getLogger(__name__) ContextVars = ChainMap[str, Any] ErrList = list[tuple[UndefinedError, DocumentPath, Any]] +# Candidate-pattern shaping for include_candidate_patterns. +_ADJACENT_WILDCARDS_RE = re.compile(r"\*+") +# Dots are included so a variant like `../*` counts as fully dynamic too; +# it would otherwise glob everything in the parent directory. +_WILDCARDS_ONLY_RE = re.compile(r"[*./\\]+") +_GLOB_META_RE = re.compile(r"[?\[]") +_STRING_LITERAL_RE = re.compile(r"'([^']*)'|\"([^\"]*)\"") + # Module-level instance is safe: context_vars is passed per-call, and context_trace # is stack-saved/restored within expand(). Not thread-safe — only use from one thread. jinja = Jinja() @@ -360,9 +371,7 @@ def resolve_include( ) substituted = filename != original_str if substituted: - include = IncludeFile( - include.parent_file, filename, include.vars, include.yaml_loader - ) + include = include.with_file(filename) try: return include.load() except esphome.core.EsphomeError as err: @@ -374,6 +383,45 @@ def resolve_include( ) from err +def include_candidate_patterns(value: str) -> list[str]: + """Expand a substitution/Jinja-templated path into glob-style candidate patterns. + + Mirrors the two phases of :func:`_expand_substitutions` without variable + values: ``$var`` / ``${var}`` references become ``*`` and each remaining + Jinja expression contributes one pattern per quoted string literal it + holds (``*`` when it holds none), so every conditional branch is a + candidate — deliberately over-inclusive. Emitted wildcard patterns are + glob-safe: adjacent wildcards collapse (no recursive ``**``), ``[`` / + ``?`` from the filename text are escaped, and variants reduced to + nothing but wildcards, dots and separators are dropped so a fully + dynamic filename never expands to "everything in the directory", + including via a ``../*`` parent traversal. + """ + # Replacing $var / ${var} first also keeps JINJA_PROG's first-} span + # matching correct for references nested inside string literals, the + # same ordering _expand_substitutions relies on. + value = cv.VARIABLE_PROG.sub("*", value) + options = [ + [a or b for a, b in _STRING_LITERAL_RE.findall(expr)] or ["*"] + for expr in JINJA_PROG.findall(value) + ] + + variants: list[str] = [] + for combination in product(*options): + replacements = iter(combination) + spliced = JINJA_PROG.sub(lambda _, _next=replacements: next(_next), value) + variants.append(_ADJACENT_WILDCARDS_RE.sub("*", spliced)) + + patterns: list[str] = [] + for variant in dict.fromkeys(variants): + if not variant or _WILDCARDS_ONLY_RE.fullmatch(variant): + continue + if "*" in variant: + variant = _GLOB_META_RE.sub(r"[\g<0>]", variant) + patterns.append(variant) + return patterns + + def _substitute_include( include: IncludeFile, path: DocumentPath, diff --git a/esphome/expression.py b/esphome/expression.py index d425d822a4..13da3b6a06 100644 --- a/esphome/expression.py +++ b/esphome/expression.py @@ -1,4 +1,4 @@ -"""Helpers for detecting substitution variables and Jinja expressions.""" +"""Helpers for detecting and matching substitution variables and Jinja expressions.""" import re @@ -8,7 +8,7 @@ SUBSTITUTION_VARIABLE_PROG = re.compile( rf"\$([{VALID_SUBSTITUTIONS_CHARACTERS}]+|\{{[{VALID_SUBSTITUTIONS_CHARACTERS}]*\}})" ) -_JINJA_RE = re.compile( +JINJA_PROG = re.compile( r"<%.+?%>" # Block: <% ... %> r"|\$\{[^}]+\}", # Braced: ${ ... } flags=re.MULTILINE, @@ -17,7 +17,7 @@ _JINJA_RE = re.compile( def has_jinja(value: str) -> bool: """Check if a string contains Jinja expressions.""" - return _JINJA_RE.search(value) is not None + return JINJA_PROG.search(value) is not None def has_substitution_or_expression(value: str) -> bool: diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index c2db9b97ed..833d6f1dbf 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -14,6 +14,7 @@ from pathlib import Path from typing import Any import uuid +from voluptuous import Invalid import yaml from yaml import SafeLoader as PurePythonLoader import yaml.constructor @@ -253,8 +254,6 @@ class IncludeFile: if self._content is not _UNSET: return self._content if self.has_unresolved_expressions(): - from esphome.config_validation import Invalid - raise Invalid( f"Cannot load include with unresolved substitutions: {self.file}" ) @@ -266,12 +265,133 @@ class IncludeFile: """Check if the filename contains substitution variables or Jinja expressions.""" return has_substitution_or_expression(str(self.file)) + def with_file(self, file: Path | str) -> IncludeFile: + """Clone this include with *file* as the filename.""" + return IncludeFile(self.parent_file, file, self.vars, self.yaml_loader) + + +def _is_visible_path(rel: Path) -> bool: + """Report whether no component of *rel* is hidden (``..`` stays valid).""" + return all(part == ".." or _is_file_valid(part) for part in rel.parts) + + +def _glob_include_candidates(parent_dir: Path, pattern: str) -> list[Path]: + """ + Expand a candidate glob under *parent_dir*, keeping hidden files out. + + An un-globbable pattern (absolute, or one the filesystem rejects) is + skipped instead of crashing discovery. + """ + try: + found_paths = parent_dir.glob(pattern) + return [ + rel + for found in found_paths + if _is_visible_path(rel := found.relative_to(parent_dir)) + ] + except (NotImplementedError, ValueError) as err: + _LOGGER.debug("Cannot glob include pattern %r: %s", pattern, err) + return [] + except OSError as err: + _LOGGER.warning("I/O error globbing include pattern %r: %s", pattern, err) + return [] + + +def _candidate_include_paths(include: IncludeFile) -> list[Path]: + """Enumerate resolved files an expression-templated ``!include`` could select. + + Wildcard patterns from ``substitutions.include_candidate_patterns`` glob + under the including file's directory with hidden files excluded (like + ``!include_dir_*``); literal branch patterns are tried verbatim. Matches + still carrying expression markers or pointing back at the including file + are skipped. + """ + # Deferred import — the substitutions component imports this module. + from esphome.components.substitutions import include_candidate_patterns + + parent_dir = include.parent_file.parent + parent_resolved = include.parent_file.resolve() + candidates: list[Path] = [] + for pattern in include_candidate_patterns(str(include.file)): + if "*" in pattern: + matches = sorted(_glob_include_candidates(parent_dir, pattern)) + else: + matches = [Path(pattern)] + for match in matches: + if has_substitution_or_expression(str(match)): + continue + candidate = parent_dir / match + if not candidate.is_file(): + continue + resolved = candidate.resolve() + if resolved == parent_resolved: + continue + candidates.append(resolved) + return candidates + + +def _load_include_candidates( + include: IncludeFile, + *, + warn_on_unresolved: bool, + seen: set[int], + expanded_paths: set[Path], + keepalive: list[Any], +) -> None: + """Load every filesystem candidate for an unresolved ``IncludeFile``.""" + log = _LOGGER.warning if warn_on_unresolved else _LOGGER.debug + candidates = _candidate_include_paths(include) + if not candidates: + log( + "Cannot resolve !include %s (referenced from %s) with substitutions in path", + include.file, + include.parent_file, + ) + return + _LOGGER.debug( + "Expanding !include %s (referenced from %s) to %d candidate file(s)", + include.file, + include.parent_file, + len(candidates), + ) + for candidate in candidates: + if candidate in expanded_paths: + continue + expanded_paths.add(candidate) + try: + loaded = include.with_file(candidate).load() + except (EsphomeError, Invalid) as err: + # Unlike an unresolved pattern (expected during the discovery + # re-parse), a matched on-disk candidate that fails to load is a + # genuine user error; warn in every mode. The file itself is + # still tracked (the load listener fires before parsing), only + # its nested includes go undiscovered. + _LOGGER.warning( + "Failed to load candidate %s for !include %s: %s", + candidate, + include.file, + err, + ) + continue + # The throwaway IncludeFile is this tree's only owner; keep the tree + # alive so ids recorded in ``seen`` stay unique for the traversal. + keepalive.append(loaded) + force_load_include_files( + loaded, + warn_on_unresolved=warn_on_unresolved, + _seen=seen, + _expanded_paths=expanded_paths, + _keepalive=keepalive, + ) + def force_load_include_files( obj: Any, *, warn_on_unresolved: bool = True, _seen: set[int] | None = None, + _expanded_paths: set[Path] | None = None, + _keepalive: list[Any] | None = None, ) -> None: """Recursively resolve any deferred ``IncludeFile`` instances in a YAML tree. @@ -282,29 +402,41 @@ def force_load_include_files( loader fires and records every reachable file. ``IncludeFile`` instances whose path contains unresolved substitution - variables cannot be loaded. By default a warning is logged for each one; - pass ``warn_on_unresolved=False`` (used by discovery paths that run on a - fresh re-parse where substitutions haven't been applied yet) to demote it - to a debug log. + variables or Jinja expressions are expanded against the filesystem and + every existing candidate file is loaded, so bundles ship all branches the + expression could select. By default a warning is logged when no candidate + exists; pass ``warn_on_unresolved=False`` (used by discovery paths that + run on a fresh re-parse where substitutions haven't been applied yet) to + demote it to a debug log. """ if _seen is None: _seen = set() + if _expanded_paths is None: + _expanded_paths = set() + if _keepalive is None: + # ``_seen`` tracks ids, which is only safe while every traversed + # object stays alive; candidate trees are otherwise freed between + # loop iterations and CPython recycles their addresses, making a + # fresh tree look already seen. Discovery is a one-shot operation, + # so holding the parsed trees costs nothing. + _keepalive = [] if isinstance(obj, IncludeFile): if id(obj) in _seen: return _seen.add(id(obj)) if obj.has_unresolved_expressions(): - log = _LOGGER.warning if warn_on_unresolved else _LOGGER.debug - log( - "Cannot resolve !include %s (referenced from %s) with substitutions in path", - obj.file, - obj.parent_file, + _load_include_candidates( + obj, + warn_on_unresolved=warn_on_unresolved, + seen=_seen, + expanded_paths=_expanded_paths, + keepalive=_keepalive, ) return try: loaded = obj.load() - except EsphomeError as err: + except (EsphomeError, Invalid) as err: _LOGGER.warning( "Failed to load !include %s (referenced from %s): %s", obj.file, @@ -313,7 +445,11 @@ def force_load_include_files( ) return force_load_include_files( - loaded, warn_on_unresolved=warn_on_unresolved, _seen=_seen + loaded, + warn_on_unresolved=warn_on_unresolved, + _seen=_seen, + _expanded_paths=_expanded_paths, + _keepalive=_keepalive, ) elif isinstance(obj, dict): if id(obj) in _seen: @@ -321,7 +457,11 @@ def force_load_include_files( _seen.add(id(obj)) for value in obj.values(): force_load_include_files( - value, warn_on_unresolved=warn_on_unresolved, _seen=_seen + value, + warn_on_unresolved=warn_on_unresolved, + _seen=_seen, + _expanded_paths=_expanded_paths, + _keepalive=_keepalive, ) elif isinstance(obj, (list, tuple)): if id(obj) in _seen: @@ -329,7 +469,11 @@ def force_load_include_files( _seen.add(id(obj)) for item in obj: force_load_include_files( - item, warn_on_unresolved=warn_on_unresolved, _seen=_seen + item, + warn_on_unresolved=warn_on_unresolved, + _seen=_seen, + _expanded_paths=_expanded_paths, + _keepalive=_keepalive, ) diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index f0abcc74c6..5c71b72d86 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -1248,7 +1248,8 @@ def test_discover_files_deeply_nested_include(tmp_path: Path) -> None: def test_discover_files_nested_include_unresolved_substitution( tmp_path: Path, ) -> None: - """!include with substitution vars in path cannot be resolved; skipped gracefully.""" + """!include with substitution vars in path but no candidate files on disk + (the glob's only match is the config itself) is skipped gracefully.""" config_dir = _setup_config_dir(tmp_path) (config_dir / "test.yaml").write_text( "esphome:\n name: test\nwifi: !include ${platform}.yaml\n" @@ -1262,6 +1263,62 @@ def test_discover_files_nested_include_unresolved_substitution( assert "test.yaml" in paths +def test_discover_files_bundles_all_include_candidates(tmp_path: Path) -> None: + """The issue-17650 layout: templated package includes chain through a glob + candidate into a Jinja conditional whose ``../`` branch is bundled.""" + config_dir = _setup_config_dir( + tmp_path, + files={ + "includes/esp-basics.yaml": ( + "packages:\n" + " - !include boards/${board}.yaml\n" + " - !include keys/${system_name}.yaml\n" + ), + "includes/boards/wemos-d1-mini.yaml": ( + 'packages:\n - !include ${ "NO BT.yaml" if bt else "../empty.yaml" }\n' + ), + "includes/keys/device-a.yaml": "api:\n", + "includes/keys/device-b.yaml": "api:\n", + "includes/empty.yaml": "{}\n", + }, + ) + (config_dir / "test.yaml").write_text( + "esphome:\n name: test\npackages:\n - !include includes/esp-basics.yaml\n" + ) + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "includes/esp-basics.yaml" in paths + assert "includes/boards/wemos-d1-mini.yaml" in paths + assert "includes/keys/device-a.yaml" in paths + assert "includes/keys/device-b.yaml" in paths + assert "includes/empty.yaml" in paths + + +def test_discover_files_candidate_outside_config_dir_skipped( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A candidate branch resolving above the config dir is not bundled.""" + config_dir = _setup_config_dir(tmp_path) + (tmp_path / "outside.yaml").write_text("api:\n") + (config_dir / "test.yaml").write_text( + "esphome:\n name: test\n" + 'wifi: !include ${ "a.yaml" if x else "../outside.yaml" }\n' + ) + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + paths = [f.path for f in files] + assert not any("outside" in p for p in paths) + assert any( + "outside config directory" in r.message and "outside.yaml" in r.message + for r in caplog.records + ) + + def test_discover_files_nested_include_load_failure( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index baaa99f2a7..bcaf3fb354 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -1,3 +1,5 @@ +from collections import ChainMap +from fnmatch import fnmatchcase import logging from pathlib import Path from typing import Any @@ -961,3 +963,134 @@ def test_remote_package_scalar_yaml_raises_helpful_error( msg = str(exc_info.value) assert "mapping at the top level" in msg assert "file1.yaml" in msg + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + pytest.param("wifi.yaml", ["wifi.yaml"], id="literal_passthrough"), + pytest.param( + "keys/${system_name}.yaml", ["keys/*.yaml"], id="embedded_substitution" + ), + pytest.param( + "network/${eth_model}/config.yaml", + ["network/*/config.yaml"], + id="directory_substitution", + ), + pytest.param( + "device-$platform.yaml", ["device-*.yaml"], id="unbraced_substitution" + ), + pytest.param("${a}${b}.yaml", ["*.yaml"], id="adjacent_wildcards_collapse"), + pytest.param( + '${ "a.yaml" if x else "../empty.yaml" }', + ["a.yaml", "../empty.yaml"], + id="conditional_literals", + ), + pytest.param( + 'pre-${ "a" if c else "b" }.yaml', + ["pre-a.yaml", "pre-b.yaml"], + id="conditional_spliced", + ), + pytest.param( + '${ "x.yaml" if a else ("y.yaml" if b else "z.yaml") }', + ["x.yaml", "y.yaml", "z.yaml"], + id="nested_conditional", + ), + pytest.param( + '${ "same.yaml" if x else "same.yaml" }', + ["same.yaml"], + id="duplicate_literals_dedupe", + ), + pytest.param('${ "a.yaml" if x }', ["a.yaml"], id="conditional_no_else"), + pytest.param( + '${ "NO BLUETOOTH SUPPORT ON ESP8266.yaml"' + ' if enable_bluetooth_proxy else "../empty.yaml" }', + ["NO BLUETOOTH SUPPORT ON ESP8266.yaml", "../empty.yaml"], + id="issue_17650_verbatim", + ), + pytest.param( + '${ "" if x else "b.yaml" }', ["b.yaml"], id="empty_literal_dropped" + ), + pytest.param( + "keys\\${system_name}.yaml", + ["keys\\*.yaml"], + id="backslash_separator", + ), + pytest.param( + '${ "it\'s.yaml" if x else "b.yaml" }', + ["it's.yaml", "b.yaml"], + id="apostrophe_in_literal", + ), + pytest.param( + '${ "a-${x}.yaml" if c else "b.yaml" }', + ["a-*.yaml", "b.yaml"], + id="substitution_inside_literal", + ), + pytest.param("sensor [${x}].yaml", ["sensor [[]*].yaml"], id="bracket_escaped"), + pytest.param( + "config?${x}.yaml", ["config[?]*.yaml"], id="question_mark_escaped" + ), + pytest.param( + "../${x}/config.yaml", ["../*/config.yaml"], id="ascending_directory" + ), + pytest.param("${file}", [], id="bare_variable_dropped"), + pytest.param("../${file}", [], id="ascending_bare_variable_dropped"), + pytest.param( + '${ name ~ ".yaml" }', [".yaml"], id="dynamic_concat_extracts_literal" + ), + pytest.param("${ if }", [], id="no_literal_expression_dropped"), + pytest.param( + "<% if x %>a.yaml<% endif %>", ["*a.yaml*"], id="block_statement_globs" + ), + ], +) +def test_include_candidate_patterns(value: str, expected: list[str]) -> None: + """Templated include paths expand to glob patterns and branch literals.""" + assert substitutions.include_candidate_patterns(value) == expected + + +@pytest.mark.parametrize( + ("template", "variables"), + [ + pytest.param( + "keys/${system_name}.yaml", {"system_name": "esp-buero"}, id="embedded" + ), + pytest.param("device-$platform.yaml", {"platform": "esp32"}, id="unbraced"), + pytest.param( + "network/${eth_model}/config.yaml", {"eth_model": "eth01"}, id="directory" + ), + pytest.param( + '${ "NO BT.yaml" if bt else "../empty.yaml" }', + {"bt": True}, + id="conditional_true", + ), + pytest.param( + '${ "NO BT.yaml" if bt else "../empty.yaml" }', + {"bt": False}, + id="conditional_false", + ), + pytest.param('pre-${ "a" if c else "b" }.yaml', {"c": True}, id="spliced"), + pytest.param("${a}${b}.yaml", {"a": "x", "b": "y"}, id="adjacent"), + pytest.param("sensor [${x}].yaml", {"x": "a"}, id="bracket"), + ], +) +def test_include_candidate_patterns_cover_real_expansion( + template: str, variables: dict[str, Any] +) -> None: + """ + Lockstep pin against the real substitution machinery. + + include_candidate_patterns mirrors _expand_substitutions without + variable values (the evaluator returns the one selected branch, so it + cannot enumerate candidates itself); this asserts every filename the + real pass resolves is covered by a candidate pattern, so a change to + reference syntax or expansion order breaks here instead of silently + dropping files from bundles. + """ + resolved = str( + substitutions._expand_substitutions( + template, [], ChainMap(variables), True, None + ) + ) + patterns = substitutions.include_candidate_patterns(template) + assert any(fnmatchcase(resolved, p) or resolved == p for p in patterns) diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 5c38fce105..7a08ad2eb4 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -1003,8 +1003,10 @@ class _StubInclude: load_result: object = None, raise_on_load: EsphomeError | None = None, ) -> None: + # Default parent lives in a nonexistent directory so unresolved + # stubs never glob real files during candidate expansion. self.file = Path(file) - self.parent_file = parent_file or Path("/tmp/parent.yaml") + self.parent_file = parent_file or Path("/nonexistent/parent.yaml") self._unresolved = unresolved self._load_result = load_result if load_result is not None else {} self._raise = raise_on_load @@ -1182,6 +1184,247 @@ def test_discover_user_yaml_files_deduplicates(tmp_path: Path) -> None: assert discovered.files.count(wifi_resolved) == 1 +def test_discover_user_yaml_files_expands_directory_substitution( + tmp_path: Path, +) -> None: + """A substitution spanning a directory segment globs across directories.""" + _write(tmp_path, "network/eth01/config.yaml", "ethernet:\n") + _write(tmp_path, "network/eth02/config.yaml", "ethernet:\n") + discovered = discover_user_yaml_files( + _write_entry_including(tmp_path, "network/${eth_model}/config.yaml") + ) + resolved = set(discovered.files) + assert (tmp_path / "network/eth01/config.yaml").resolve() in resolved + assert (tmp_path / "network/eth02/config.yaml").resolve() in resolved + + +def test_discover_user_yaml_files_loads_both_branches_of_issue_conditional( + tmp_path: Path, +) -> None: + """Both branch files of the issue-17650 conditional load when present, + including the filename with spaces.""" + _write(tmp_path, "empty.yaml", "{}\n") + _write(tmp_path, "boards/NO BLUETOOTH SUPPORT ON ESP8266.yaml", "api:\n") + _write( + tmp_path, + "boards/esp8266.yaml", + "packages:\n" + ' - !include ${ "NO BLUETOOTH SUPPORT ON ESP8266.yaml"' + ' if enable_bluetooth_proxy else "../empty.yaml" }\n', + ) + discovered = discover_user_yaml_files( + _write_entry_including(tmp_path, "boards/esp8266.yaml") + ) + resolved = set(discovered.files) + assert (tmp_path / "boards/NO BLUETOOTH SUPPORT ON ESP8266.yaml").resolve() in ( + resolved + ) + assert (tmp_path / "empty.yaml").resolve() in resolved + + +def test_discover_user_yaml_files_glob_matches_bracket_filenames( + tmp_path: Path, +) -> None: + """Glob metacharacters in the literal filename text stay literal.""" + _write(tmp_path, "sensor [a].yaml", "api:\n") + discovered = discover_user_yaml_files( + _write_entry_including(tmp_path, "sensor [${x}].yaml") + ) + assert "sensor [a].yaml" in {p.name for p in discovered.files} + + +def test_discover_user_yaml_files_ascending_glob(tmp_path: Path) -> None: + """A templated include reaching into a sibling directory via ``..`` globs.""" + _write(tmp_path, "shared/common.yaml", "api:\n") + _write(tmp_path, "nodes/dev.yaml", "p: !include ../shared/${x}.yaml\n") + discovered = discover_user_yaml_files( + _write_entry_including(tmp_path, "nodes/dev.yaml") + ) + assert (tmp_path / "shared/common.yaml").resolve() in discovered.files + + +def test_discover_user_yaml_files_mapping_include_with_vars(tmp_path: Path) -> None: + """The mapping !include form (file + vars) expands a templated filename.""" + _write(tmp_path, "keys/a.yaml", "pin: ${num}\n") + entry = _write( + tmp_path, + "entry.yaml", + "wifi: !include\n file: keys/${n}.yaml\n vars:\n num: 4\n", + ) + discovered = discover_user_yaml_files(entry) + assert (tmp_path / "keys/a.yaml").resolve() in discovered.files + + +def test_discover_user_yaml_files_absolute_templated_include_skipped( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """An absolute templated include is skipped gracefully instead of crashing.""" + shared = tmp_path / "shared" + _write(tmp_path, "shared/common.yaml", "api:\n") + with caplog.at_level("DEBUG", logger="esphome.yaml_util"): + discovered = discover_user_yaml_files( + _write_entry_including(tmp_path, f"{shared}/${{x}}.yaml") + ) + assert (shared / "common.yaml").resolve() not in discovered.files + assert any("Cannot glob include pattern" in r.message for r in caplog.records) + + +def test_discover_user_yaml_files_glob_skips_dollar_named_files( + tmp_path: Path, +) -> None: + """An on-disk filename containing ``$`` can't load; the glob skips it.""" + _write(tmp_path, "keys/a.yaml", "api:\n") + _write(tmp_path, "keys/b$roken.yaml", "api:\n") + discovered = discover_user_yaml_files( + _write_entry_including(tmp_path, "keys/${n}.yaml") + ) + names = {p.name for p in discovered.files} + assert "a.yaml" in names + assert "b$roken.yaml" not in names + + +def test_discover_user_yaml_files_glob_error_skips_include( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A filesystem error during candidate globbing warns and skips the include.""" + entry = _write_entry_including(tmp_path, "keys/${n}.yaml") + with ( + patch.object(Path, "glob", side_effect=OSError("boom")), + caplog.at_level("DEBUG", logger="esphome.yaml_util"), + ): + discovered = discover_user_yaml_files(entry) + assert [p.name for p in discovered.files] == ["entry.yaml"] + matching = [ + r.levelname + for r in caplog.records + if "I/O error globbing include pattern" in r.message + ] + assert matching == ["WARNING"] + + +def test_force_load_candidate_failure_warns_by_default( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A broken candidate logs at WARNING outside the discovery re-parse.""" + _write(tmp_path, "keys/bad.yaml", "esphome: [unterminated\n") + entry = _write_entry_including(tmp_path, "keys/${n}.yaml") + with caplog.at_level("DEBUG", logger="esphome.yaml_util"): + force_load_include_files(yaml_util.load_yaml(entry)) + matching = [ + r.levelname for r in caplog.records if "Failed to load candidate" in r.message + ] + assert matching == ["WARNING"] + + +def test_discover_user_yaml_files_glob_skips_hidden_files(tmp_path: Path) -> None: + """Candidate globs exclude hidden files, matching ``!include_dir_*``.""" + _write(tmp_path, "keys/device-a.yaml", "api:\n") + _write(tmp_path, "keys/.hidden.yaml", "api:\n") + discovered = discover_user_yaml_files( + _write_entry_including(tmp_path, "keys/${name}.yaml") + ) + names = {p.name for p in discovered.files} + assert "device-a.yaml" in names + assert ".hidden.yaml" not in names + + +def test_discover_user_yaml_files_bare_expression_not_expanded( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A fully dynamic filename never globs the whole directory.""" + _write(tmp_path, "sibling.yaml", "api:\n") + with caplog.at_level("DEBUG", logger="esphome.yaml_util"): + discovered = discover_user_yaml_files( + _write_entry_including(tmp_path, "${file}") + ) + assert (tmp_path / "sibling.yaml").resolve() not in discovered.files + assert any( + "Cannot resolve !include" in r.message and r.levelname == "DEBUG" + for r in caplog.records + ) + + +def test_discover_user_yaml_files_self_glob_match_skipped( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A glob whose only match is the including file itself claims nothing.""" + entry = _write_entry_including(tmp_path, "${platform}.yaml") + with caplog.at_level("DEBUG", logger="esphome.yaml_util"): + discovered = discover_user_yaml_files(entry) + assert [p.name for p in discovered.files] == ["entry.yaml"] + assert any("Cannot resolve !include" in r.message for r in caplog.records) + + +def test_discover_user_yaml_files_candidate_cycle_terminates(tmp_path: Path) -> None: + """Mutually glob-matching includes expand finitely and capture both files.""" + _write(tmp_path, "sub/a.yaml", "p: !include ${x}.yaml\n") + _write(tmp_path, "sub/b.yaml", "p: !include ${y}.yaml\n") + entry = _write(tmp_path, "entry.yaml", "wifi: !include sub/a.yaml\n") + discovered = discover_user_yaml_files(entry) + names = {p.name for p in discovered.files} + assert names == {"entry.yaml", "a.yaml", "b.yaml"} + + +def test_discover_user_yaml_files_many_candidates_keep_nested_includes( + tmp_path: Path, +) -> None: + """Every candidate's nested includes are discovered. + + Regression test: the id()-based cycle guard is only safe while every + traversed tree stays alive. Candidate trees used to be freed between + loop iterations, so CPython recycled their addresses and later + candidates' fresh trees were skipped as already seen, silently dropping + their nested includes. Needs several candidates to manifest; two were + not enough to trigger the reuse.""" + count = 12 + for i in range(count): + _write( + tmp_path, f"keys/k{i}.yaml", f"sensor{i}: !include ../nested/n{i}.yaml\n" + ) + _write(tmp_path, f"nested/n{i}.yaml", f"api{i}: true\n") + discovered = discover_user_yaml_files( + _write_entry_including(tmp_path, "keys/${x}.yaml") + ) + names = {p.name for p in discovered.files} + expected = {f"n{i}.yaml" for i in range(count)} + expected |= {f"k{i}.yaml" for i in range(count)} + expected.add("entry.yaml") + assert names == expected + + +def test_discover_user_yaml_files_bad_candidate_still_tracked( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A matched candidate that fails to parse warns even during discovery, + stays tracked (the load listener fires before parsing), and doesn't block + other candidates.""" + _write(tmp_path, "keys/good.yaml", "api:\n") + _write(tmp_path, "keys/bad.yaml", "esphome: [unterminated\n") + with caplog.at_level("DEBUG", logger="esphome.yaml_util"): + discovered = discover_user_yaml_files( + _write_entry_including(tmp_path, "keys/${name}.yaml") + ) + resolved = set(discovered.files) + assert (tmp_path / "keys/good.yaml").resolve() in resolved + assert (tmp_path / "keys/bad.yaml").resolve() in resolved + matching = [ + r.levelname for r in caplog.records if "Failed to load candidate" in r.message + ] + assert matching == ["WARNING"] + + +def test_discover_user_yaml_files_tolerates_templated_top_level_include( + tmp_path: Path, +) -> None: + """A literal include whose entire content is a templated ``!include`` is + tracked and skipped instead of aborting discovery.""" + _write(tmp_path, "wrapper.yaml", "!include ${x}_settings.yaml\n") + discovered = discover_user_yaml_files( + _write_entry_including(tmp_path, "wrapper.yaml") + ) + assert (tmp_path / "wrapper.yaml").resolve() in discovered.files + + def test_track_yaml_loads_records_resolved_paths(tmp_path: Path) -> None: """`track_yaml_loads` is the building block — sanity-check it resolves symlinks so callers can dedupe by identity.""" From 735c64d1386a4240e5adc53ff55edd1b5d180680 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:48:57 -0400 Subject: [PATCH 1197/1815] [core] Support local library directories via file:// on the native toolchain (#18005) --- esphome/components/zephyr/library.py | 18 +- esphome/core/config.py | 6 +- esphome/espidf/component.py | 44 +++- esphome/platformio/library.py | 242 +++++++++++++++++--- tests/unit_tests/core/test_config.py | 9 + tests/unit_tests/test_espidf_component.py | 176 +++++++++++--- tests/unit_tests/test_platformio_library.py | 199 +++++++++++++++- tests/unit_tests/test_zephyr_library.py | 5 +- 8 files changed, 608 insertions(+), 91 deletions(-) diff --git a/esphome/components/zephyr/library.py b/esphome/components/zephyr/library.py index 7654e63700..0e6551ccf1 100644 --- a/esphome/components/zephyr/library.py +++ b/esphome/components/zephyr/library.py @@ -65,10 +65,16 @@ def generate_cmakelists_txt(component: ConvertedLibrary) -> str: """ build = component.data.get("build", {}) + # The library's own files live in source_path (the user's directory for a + # local library, the downloaded dir otherwise); the generated zephyr/ files + # go under component.path. Sources are already emitted as absolute paths, so + # they resolve correctly wherever source_path points. + read_path = component.source_dir + build_src_dir = build.get("srcDir") if not build_src_dir: for d in ["src", "Src", "."]: - if (component.path / Path(d)).is_dir(): + if (read_path / Path(d)).is_dir(): build_src_dir = d break @@ -77,7 +83,7 @@ def generate_cmakelists_txt(component: ConvertedLibrary) -> str: build_flags = ensure_list(build.get("flags", DEFAULT_BUILD_FLAGS)) src_files = collect_filtered_files( - component.path / Path(build_src_dir), build_src_filter + read_path / Path(build_src_dir), build_src_filter ) src_files = sorted( str(Path(p).resolve()) @@ -91,15 +97,19 @@ def generate_cmakelists_txt(component: ConvertedLibrary) -> str: link_directories, build_flags = split_list_by_condition( build_flags, lambda a: a[2:].strip() if a.startswith("-L") else None ) + # The zephyr/CMakeLists lives in a subdir, so a relative -L would resolve + # from there rather than the library root; make link dirs absolute against + # the library's own directory (source_dir), matching src/include handling. + link_directories = [str((read_path / Path(d)).resolve()) for d in link_directories] link_libraries, build_flags = split_list_by_condition( build_flags, lambda a: a[2:].strip() if a.startswith("-l") else None ) include_dirs = [build_include_dir, build_src_dir, *include_dir_flags] include_dirs = [ - str((component.path / Path(d)).resolve()) + str((read_path / Path(d)).resolve()) for d in include_dirs - if (component.path / Path(d)).is_dir() + if (read_path / Path(d)).is_dir() ] lines = [f"zephyr_library_named({component.get_require_name()})"] diff --git a/esphome/core/config.py b/esphome/core/config.py index 6b24a55487..1095a4886e 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -542,8 +542,10 @@ def _add_library_str(lib: str) -> None: if "@" in lib: name, vers = lib.split("@", 1) cg.add_library(name, vers) - elif "://" in lib: - # Repository... + elif "://" in lib or lib.split("=", 1)[-1].startswith("file:"): + # A repository or URL source. Also catch a ``file:`` source spelled with + # fewer than two slashes (e.g. ``file:lib_dev``) so it reaches the + # file:// handling and its clear error, rather than a registry lookup. if "=" in lib: name, repo = lib.split("=", 1) cg.add_library(name, None, repo) diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index cad5bbf665..b22b39bf6d 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -46,10 +46,11 @@ def _apply_extra_script(component: IDFComponent) -> None: extra_script = component.data.get("build", {}).get("extraScript") if not extra_script: return - # Resolve and confine to the component dir so a malicious library.json - # can't escape (e.g. ``"extraScript": "../../etc/passwd"``). - library_root = component.path.resolve() - script_path = (component.path / extra_script).resolve() + # Resolve and confine to the library's source dir so a malicious + # library.json can't escape (e.g. ``"extraScript": "../../etc/passwd"``). + source_path = component.source_dir + library_root = source_path.resolve() + script_path = (source_path / extra_script).resolve() if not script_path.is_relative_to(library_root) or not script_path.is_file(): return from esphome.components.esp32 import get_esp32_variant @@ -58,9 +59,9 @@ def _apply_extra_script(component: IDFComponent) -> None: idf_target = variant_to_idf_target(get_esp32_variant()) result = run_extra_script( - script_path, library_dir=component.path, idf_target=idf_target + script_path, library_dir=source_path, idf_target=idf_target ) - extra_flags = captured_as_build_flags(result, library_dir=component.path) + extra_flags = captured_as_build_flags(result, library_dir=source_path) if not extra_flags: return flags = component.data.setdefault("build", {}).setdefault("flags", []) @@ -101,11 +102,17 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: # which Windows accepts too, so the generated CMakeLists is portable. return f'"{str(p).replace(os.sep, "/")}"' + # The library's own files live in source_path (the user's directory for a + # local library, the downloaded dir otherwise). When it differs from the + # component dir the CMakeLists must reference sources by absolute path. + read_path = component.source_dir + external = read_path.resolve() != component.path.resolve() + # Extract the values build_src_dir = component.data.get("build", {}).get("srcDir", None) if not build_src_dir: for d in ["src", "Src", "."]: - if (component.path / Path(d)).is_dir(): + if (read_path / Path(d)).is_dir(): build_src_dir = d break @@ -138,7 +145,7 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: # List all sources files build_src_files = collect_filtered_files( - component.path / Path(build_src_dir), build_src_filter + read_path / Path(build_src_dir), build_src_filter ) # Only bake library.json-declared deps here. Project-managed and @@ -150,8 +157,12 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: dependency.get_require_name() for dependency in component.dependencies } - # Only keep sources - build_src_files = [os.path.relpath(p, component.path) for p in build_src_files] + # Only keep sources. Reference them absolutely when they live outside the + # component dir (a local library), relative otherwise. + if external: + build_src_files = [str(Path(p).resolve()) for p in build_src_files] + else: + build_src_files = [os.path.relpath(p, component.path) for p in build_src_files] build_src_files = [ f for f in build_src_files if Path(f).suffix in SRC_FILE_EXTENSIONS ] @@ -166,13 +177,24 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: link_libraries, build_flags = split_list_by_condition( build_flags, lambda a: a[2:].strip() if a.startswith("-l") else None ) + # A local library's relative -L paths are relative to its own directory; + # resolve them against it so they still work from the component cache dir. + # (read_path / d yields d unchanged when d is already absolute.) + if external: + link_directories = [ + str((read_path / Path(d)).resolve()) for d in link_directories + ] # Split include directories from build_flags # Only keep an include directory if it exists build_include_dirs = [build_include_dir, build_src_dir] + include_dir_flags build_include_dirs = [ - d for d in build_include_dirs if (component.path / Path(d)).is_dir() + d for d in build_include_dirs if (read_path / Path(d)).is_dir() ] + if external: + build_include_dirs = [ + str((read_path / Path(d)).resolve()) for d in build_include_dirs + ] # Split build_flags list into private and public lists private_build_flags, public_build_flags = split_list_by_condition( diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 1a523ce0ab..ee0a758a31 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -21,14 +21,15 @@ import itertools import json import logging import os -from pathlib import Path +from pathlib import Path, PurePosixPath import re import tempfile from typing import Any from urllib.parse import urlsplit, urlunsplit +from urllib.request import url2pathname from esphome import git -from esphome.core import CORE, Library +from esphome.core import CORE, EsphomeError, Library from esphome.framework_helpers import archive_extract_all, download_from_mirrors, rmdir _LOGGER = logging.getLogger(__name__) @@ -73,6 +74,14 @@ class Source: ) -> Path: raise NotImplementedError + def source_root(self, build_path: Path) -> Path: + """Directory holding the library's own files (manifest + sources). + + Defaults to the downloaded build directory; a source that references its + files in place (:class:`LocalSource`) overrides this to point elsewhere. + """ + return build_path + class URLSource(Source): def __init__(self, url: str): @@ -143,6 +152,53 @@ class GitSource(Source): return f"{self.url}#{self.ref}" if self.ref else self.url +class LocalSource(Source): + """A library that already exists as a directory on the local filesystem. + + Referenced with a ``file://`` URL (PlatformIO's spelling for a local library + folder). Nothing is copied: the backend generates its build files into an + otherwise empty cache directory and references the library's own sources in + place by absolute path (via :meth:`source_root`). So the user's source tree + stays untouched and edits are picked up on the next build without syncing. + """ + + def __init__(self, path: str): + self.local_path = path + + def download( + self, dir_suffix: str, force: bool = False, salt: str = "", namespace: str = "" + ) -> Path: + src = Path(self.local_path) + if not src.is_dir(): + # EsphomeError (not InvalidLibrary) so the CLI prints a clean message + # instead of a traceback -- pointing a file:// at a missing folder is + # the most common first mistake with a local library. + raise EsphomeError( + f"Local library directory does not exist: {self.local_path}" + ) + base_dir = Path(CORE.data_dir) / DOMAIN + if namespace: + base_dir = base_dir / namespace + h = hashlib.new("sha256") + h.update(str(src.resolve()).encode()) + if salt: + h.update(salt.encode()) + # Only the generated build files live here; the library's own sources + # are referenced in place from source_root(). + path = base_dir / h.hexdigest()[:8] / dir_suffix + path.mkdir(parents=True, exist_ok=True) + return path + + def source_root(self, build_path: Path) -> Path: + return Path(self.local_path) + + def __str__(self): + path = Path(self.local_path) + # as_uri() needs an absolute path; _node_key rejects relative file:// + # URLs, but guard anyway so a diagnostic can't itself raise. + return path.as_uri() if path.is_absolute() else f"file://{self.local_path}" + + class InvalidLibrary(Exception): pass @@ -162,6 +218,9 @@ class ConvertedLibrary: self.data = {} self.dependencies: list[ConvertedLibrary] = [] self._path: Path | None = None + # Where the library's own files live (manifest + sources). Set by + # download(); equals path for registry/git, the user's dir for local. + self.source_path: Path | None = None def __str__(self): return f"{self.name}@{self.version}={self.source}" @@ -176,6 +235,16 @@ class ConvertedLibrary: def path(self, value: Path) -> None: self._path = value + @property + def source_dir(self) -> Path: + """Directory the library's own files (manifest + sources) are read from. + + The build dir for a registry/git source; the user's directory for a + local library. Backends read sources from here and emit their build + files into ``path``. + """ + return self.source_path or self.path + def get_sanitized_name(self): return re.sub(r"[^a-zA-Z0-9_.\-/]", "_", self.name) @@ -193,6 +262,7 @@ class ConvertedLibrary: self.path = self.source.download( self.get_sanitized_name(), force=force, salt=salt, namespace=namespace ) + self.source_path = self.source.source_root(self.path) @dataclass @@ -515,11 +585,14 @@ class _LibNode: key: str is_git: bool + is_local: bool = False + is_registry: bool = False owner: str | None = None pkgname: str | None = None requirements: set[str] = field(default_factory=set) url: str | None = None ref: str | None = None + local_path: str | None = None edges: set[str] = field(default_factory=set) @@ -536,40 +609,83 @@ def _url_or_none(value: Any) -> str | None: def _node_key( name: str | None, version: str | None, repository: str | None -) -> tuple[str, bool, tuple[str | None, str | None]]: - """Return ``(key, is_git, locator)`` for a library or dependency spec. +) -> tuple[str, str, tuple[str | None, str | None]]: + """Return ``(key, kind, locator)`` for a library or dependency spec. - The key is derived from the *input* spec (the registry name as written, or - the git URL path), not the resolved canonical name. So a package referenced - inconsistently -- bare ``name`` vs ``owner/name``, or git vs registry -- maps - to distinct keys and isn't deduplicated; ``convert_libraries`` warns about - that after resolution rather than merging the nodes. + ``kind`` is one of: - PlatformIO's Library Manager also accepted a git URL in the *name* - position (``add_library("https://github.com/x/y", None)``), including the - ``git+`` VCS prefix and the ``CustomName=URL`` form; recognize those here - so such specs resolve as git sources instead of failing a registry lookup. + - ``"registry"`` -- ``locator`` is ``(owner, pkgname)``. + - ``"git"`` -- ``locator`` is ``(url, ref)``. + - ``"local"`` -- a ``file://`` directory; ``locator`` is ``(path, None)``. + + The key is derived from the *input* spec (the registry name as written, the + git URL path, or the custom name / directory name for a local folder), not + the resolved canonical name. So a package referenced inconsistently -- bare + ``name`` vs ``owner/name``, or git vs registry -- maps to distinct keys and + isn't deduplicated; ``convert_libraries`` warns about that after resolution + rather than merging the nodes. + + PlatformIO's Library Manager also accepted a URL in the *name* position + (``add_library("https://github.com/x/y", None)``), including the ``git+`` + VCS prefix and the ``CustomName=URL`` form; recognize those here so such + specs resolve as git (or local) sources instead of failing a registry + lookup. A plain ``file://`` URL is PlatformIO's spelling for a local library + folder, so it resolves as a local directory; ``git+file://`` stays a git + source. """ if not repository and name and "://" in name: - # Try the whole name first so a bare URL whose query contains ``=`` - # stays intact; fall back to the ``CustomName=URL`` form, where the - # key derives from the URL path and the custom name is irrelevant. - repository = _url_or_none(name) or _url_or_none(name.split("=", 1)[-1]) - if repository is None: + # Split a ``CustomName=URL`` name, but only when the whole string isn't + # itself a valid URL (a bare URL whose query contains ``=`` must stay + # intact). + custom_name, candidate = None, name + if "=" in name and _url_or_none(name) is None: + custom_name, candidate = name.split("=", 1) + try: + scheme = urlsplit(candidate).scheme + except ValueError: + scheme = "" + if scheme == "file" or _url_or_none(candidate): + name, repository = custom_name, candidate + else: # Anything with ``://`` was meant to be a URL; failing it fast # beats a confusing registry "package not found" error. raise RuntimeError(f"Invalid PIO library URL: {name}") if repository: + is_git_prefixed = repository.startswith("git+") split_result = urlsplit(repository.removeprefix("git+")) + if split_result.scheme == "file" and not is_git_prefixed: + # A plain file:// URL points at a local library directory. A local + # file URL is written file:///absolute/path (empty host) or, less + # commonly, file://localhost/path. Anything else -- a real host, or + # a relative path whose first segment parses as the host -- is + # rejected rather than silently resolved to the wrong directory. + if split_result.netloc not in ("", "localhost"): + raise RuntimeError( + f"Unsupported host in file:// library URL '{repository}'; " + "use an absolute path, e.g. file:///path/to/lib" + ) + # Validate the URL path itself (always POSIX-style, leading slash), + # not the OS path: on Windows a "/foo" path is not is_absolute() + # without a drive, which would wrongly reject a valid file:/// URL. + # Reject a relative path (``file:lib_dev``) or a bare root + # (``file:///``, which has no final segment). + url_path = split_result.path + if not url_path.startswith("/") or not PurePosixPath(url_path).name: + raise RuntimeError( + f"file:// library URL '{repository}' must be an absolute " + "directory path, e.g. file:///path/to/lib" + ) + path = url2pathname(url_path) + return (name or PurePosixPath(url_path).name), "local", (path, None) key = str(split_result.path).strip("/").removesuffix(".git") ref = split_result.fragment.strip() or None url = urlunsplit(split_result._replace(fragment="")) - return key, True, (url, ref) + return key, "git", (url, ref) if name and "/" in name: owner, pkgname = name.split("/", 1) else: owner, pkgname = None, name - return name, False, (owner, pkgname) + return name, "registry", (owner, pkgname) def convert_libraries( @@ -618,13 +734,45 @@ def convert_libraries( return name.split("/")[-1].lower() in lib_ignore def add_spec(name: str | None, version: str | None, repository: str | None) -> str: - key, is_git, locator = _node_key(name, version, repository) - node = nodes.get(key) or _LibNode(key=key, is_git=is_git) + key, kind, locator = _node_key(name, version, repository) + node = nodes.get(key) or _LibNode(key=key, is_git=kind == "git") nodes[key] = node - if is_git: + # The same key requested from two different kinds of source (or two + # different local paths) is a config mistake: one silently wins. Warn so + # it isn't a surprise. (git-vs-registry is reported separately below.) + if kind == "git": + if node.is_local: + _LOGGER.warning( + "Library %s is requested as both a local directory and a git " + "source; using the git source.", + key, + ) node.is_git = True node.url, node.ref = locator + elif kind == "local": + new_path = locator[0] + if node.is_git: + # git wins (checked first when building the source); leave the + # node as a git source. + _LOGGER.warning( + "Library %s is requested as both a local directory and a git " + "source; using the git source.", + key, + ) + else: + if node.is_local and node.local_path != new_path: + _LOGGER.warning( + "Library %s is requested from two local directories (%s " + "and %s); using %s.", + key, + node.local_path, + new_path, + new_path, + ) + node.is_local = True + node.local_path = new_path else: + node.is_registry = True node.owner, node.pkgname = locator if version: node.requirements.add(version) @@ -658,6 +806,8 @@ def convert_libraries( if node.is_git: component = ConvertedLibrary(key, "*", GitSource(node.url, node.ref)) + elif node.is_local: + component = ConvertedLibrary(key, "*", LocalSource(node.local_path)) else: owner, name, version, url = _resolve_registry_version( node.owner, node.pkgname, node.requirements @@ -667,20 +817,22 @@ def convert_libraries( ) component.download(salt=salt, namespace=backend.cache_key) - library_json_path = component.path / "library.json" - library_properties_path = component.path / "library.properties" + source_dir = component.source_dir + library_json_path = source_dir / "library.json" + library_properties_path = source_dir / "library.properties" has_json = library_json_path.is_file() has_properties = library_properties_path.is_file() - if not has_json and not has_properties: + if not has_json and not has_properties and not node.is_local: # The shared cache can hold a broken copy (e.g. a clone or an # extraction interrupted by a killed process). Force one # re-download so a bad cache entry self-heals instead of failing - # every build until the user runs a full clean. + # every build until the user runs a full clean. A local source is + # read in place, so there is nothing to re-download. _LOGGER.warning( "Library %s at %s is missing library.json and library.properties; " "re-downloading", key, - component.path, + source_dir, ) component.download(force=True, salt=salt, namespace=backend.cache_key) has_json = library_json_path.is_file() @@ -690,9 +842,14 @@ def convert_libraries( elif has_properties: component.data = _parse_library_properties(library_properties_path) else: - raise RuntimeError( + # For a local library a missing manifest is user input, so raise + # EsphomeError (clean CLI message) like the missing-directory case; + # for registry/git a missing manifest means a corrupt cache, which + # is not user error, so keep RuntimeError. + error_cls = EsphomeError if node.is_local else RuntimeError + raise error_cls( f"Invalid PIO library {key}: missing library.json and " - f"library.properties in {component.path}" + f"library.properties in {source_dir}" ) try: @@ -735,17 +892,26 @@ def convert_libraries( node.edges.add(dep_key) worklist.append(dep_key) - # A git source wins over any registry version requested for the same - # component. That's intentional, but warn so a dropped registry pin isn't a - # silent surprise. + # A git or local source wins over the same component requested from the + # registry. That's intentional, but warn so the dropped registry spec isn't + # a silent surprise -- including when it carried no version pin (a bare + # cg.add_library("Foo"), which is how most components add libraries). for node in nodes.values(): - if node.is_git and node.requirements: + if (node.is_git or node.is_local) and (node.is_registry or node.requirements): + source = "git" if node.is_git else "local" + registry = ( + f"registry version(s) {sorted(node.requirements)}" + if node.requirements + else "a registry package" + ) _LOGGER.warning( - "Library %s is requested both from a git source (%s) and as " - "registry version(s) %s; using the git source.", + "Library %s is requested both from a %s source (%s) and as %s; " + "using the %s source.", node.key, - node.url, - sorted(node.requirements), + source, + node.url if node.is_git else node.local_path, + registry, + source, ) # Two graph nodes that resolve to the same component name (e.g. a package diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 0362c40bce..e09edd7f26 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -1242,6 +1242,15 @@ def test_make_app_name_cpp_special_chars_escaped() -> None: None, "https://github.com/esphome/noise-c.git", ), + # A local file:// source is routed to the repository, not a registry name + # -- including the fewer-than-two-slashes spelling. + ( + "TeslaBLE=file:///config/esphome/lib_dev", + "TeslaBLE", + None, + "file:///config/esphome/lib_dev", + ), + ("MyLib=file:lib_dev", "MyLib", None, "file:lib_dev"), ], ) def test_add_library_str( diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 879d98c0a7..f9e048f6f4 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -155,6 +155,62 @@ def test_generate_cmakelists_txt_basic(tmp_component): assert "main.c" in content +def test_generate_cmakelists_txt_external_source_uses_absolute_paths( + tmp_component, tmp_path +): + # A local library's sources live outside the component dir (source_path), + # so SRCS and INCLUDE_DIRS must be emitted as absolute paths into it. + source = tmp_path / "user_lib" + (source / "src").mkdir(parents=True) + (source / "include").mkdir() + (source / "src" / "thing.cpp").write_text("int t;") + tmp_component.source_path = source + tmp_component.data = {} + + content = generate_cmakelists_txt(tmp_component) + + abs_src = str((source / "src" / "thing.cpp").resolve()).replace("\\", "/") + abs_inc = str((source / "include").resolve()).replace("\\", "/") + assert abs_src in content + assert abs_inc in content + # Nothing was copied into the component dir. + assert not (tmp_component.path / "src").exists() + + +def test_generate_cmakelists_txt_external_source_absolutises_link_dirs( + tmp_component, tmp_path +): + # A local library's relative -L path must be made absolute against its own + # directory so it resolves from the component cache dir. + source = tmp_path / "user_lib" + (source / "src").mkdir(parents=True) + (source / "src" / "thing.cpp").write_text("int t;") + (source / "libs").mkdir() + tmp_component.source_path = source + tmp_component.data = {"build": {"flags": ["-Llibs"]}} + + content = generate_cmakelists_txt(tmp_component) + + abs_lib = str((source / "libs").resolve()).replace("\\", "/") + assert "target_link_directories" in content + assert abs_lib in content + + +def test_generate_cmakelists_txt_external_source_root_srcdir(tmp_component, tmp_path): + # An external source with files at its root (no src/ or include/ dir): + # the src-dir search falls through to "." and the missing include dirs are + # filtered out. + source = tmp_path / "flat_lib" + source.mkdir() + (source / "thing.cpp").write_text("int t;") + tmp_component.source_path = source + tmp_component.data = {} + + content = generate_cmakelists_txt(tmp_component) + + assert str((source / "thing.cpp").resolve()).replace("\\", "/") in content + + def test_generate_cmakelists_txt_with_flags(tmp_component, tmp_path): src_dir = tmp_component.path / "src" src_dir.mkdir() @@ -462,70 +518,66 @@ empty= def test_node_key_git_with_ref(): - key, is_git, locator = _node_key( + key, kind, locator = _node_key( "name", None, "https://github.com/foo/bar.git#v1.2.3" ) assert key == "foo/bar" - assert is_git is True + assert kind == "git" assert locator == ("https://github.com/foo/bar.git", "v1.2.3") def test_node_key_git_branch_ref(): - key, is_git, locator = _node_key( + key, kind, locator = _node_key( "name", None, "https://github.com/foo/bar.git#some-branch" ) - assert (key, is_git, locator[1]) == ("foo/bar", True, "some-branch") + assert (key, kind, locator[1]) == ("foo/bar", "git", "some-branch") def test_node_key_git_no_ref(): - _key, is_git, locator = _node_key("name", None, "https://github.com/foo/bar.git") - assert is_git is True + _key, kind, locator = _node_key("name", None, "https://github.com/foo/bar.git") + assert kind == "git" assert locator == ("https://github.com/foo/bar.git", None) def test_node_key_url_in_name_is_git(): # add_library("https://github.com/x/y", None): PlatformIO accepted a bare # git URL as the library name, so the converter must too. - key, is_git, locator = _node_key( - "https://github.com/pstolarz/OneWireNg", None, None - ) + key, kind, locator = _node_key("https://github.com/pstolarz/OneWireNg", None, None) assert key == "pstolarz/OneWireNg" - assert is_git is True + assert kind == "git" assert locator == ("https://github.com/pstolarz/OneWireNg", None) def test_node_key_url_in_name_with_ref(): - key, is_git, locator = _node_key( - "https://github.com/foo/bar.git#v1.2.3", None, None - ) - assert (key, is_git, locator) == ( + key, kind, locator = _node_key("https://github.com/foo/bar.git#v1.2.3", None, None) + assert (key, kind, locator) == ( "foo/bar", - True, + "git", ("https://github.com/foo/bar.git", "v1.2.3"), ) def test_node_key_url_in_name_git_plus_prefix(): - key, is_git, locator = _node_key("git+https://github.com/foo/bar", None, None) - assert (key, is_git, locator) == ( + key, kind, locator = _node_key("git+https://github.com/foo/bar", None, None) + assert (key, kind, locator) == ( "foo/bar", - True, + "git", ("https://github.com/foo/bar", None), ) def test_node_key_git_plus_prefix_in_repository(): - _key, is_git, locator = _node_key("name", None, "git+https://github.com/foo/bar") - assert (is_git, locator) == (True, ("https://github.com/foo/bar", None)) + _key, kind, locator = _node_key("name", None, "git+https://github.com/foo/bar") + assert (kind, locator) == ("git", ("https://github.com/foo/bar", None)) def test_node_key_custom_name_equals_url_is_git(): - key, is_git, locator = _node_key( + key, kind, locator = _node_key( "OneWireNg=https://github.com/pstolarz/OneWireNg", None, None ) - assert (key, is_git, locator) == ( + assert (key, kind, locator) == ( "pstolarz/OneWireNg", - True, + "git", ("https://github.com/pstolarz/OneWireNg", None), ) @@ -533,14 +585,70 @@ def test_node_key_custom_name_equals_url_is_git(): def test_node_key_url_in_name_with_query_containing_equals(): # A bare URL whose query string contains ``=`` must not be split by the # CustomName=URL handling. - key, is_git, locator = _node_key("https://host/x/y.git?ref=main", None, None) - assert (key, is_git, locator) == ( + key, kind, locator = _node_key("https://host/x/y.git?ref=main", None, None) + assert (key, kind, locator) == ( "x/y", - True, + "git", ("https://host/x/y.git?ref=main", None), ) +def test_node_key_file_url_in_repository_is_local(): + # A plain file:// entry (PlatformIO's spelling for a local library folder) + # resolves as a local directory, keeping the custom name as the key. The + # path is the OS-native form of the URL (backslashes on Windows). + key, kind, (path, ref) = _node_key( + "TeslaBLE", None, "file:///config/esphome/lib_dev" + ) + assert (key, kind, ref) == ("TeslaBLE", "local", None) + assert Path(path) == Path("/config/esphome/lib_dev") + + +def test_node_key_bare_file_url_is_local_named_for_dir(): + # Without a custom name the directory's own name becomes the key. + key, kind, (path, ref) = _node_key(None, None, "file:///opt/mylib") + assert (key, kind, ref) == ("mylib", "local", None) + assert Path(path) == Path("/opt/mylib") + + +def test_node_key_custom_name_equals_file_url_is_local(): + key, kind, (path, ref) = _node_key("Foo=file:///opt/mylib", None, None) + assert (key, kind, ref) == ("Foo", "local", None) + assert Path(path) == Path("/opt/mylib") + + +def test_node_key_file_url_localhost_host_is_local(): + # A localhost host is ignored; only the path identifies the directory. + key, kind, (path, ref) = _node_key(None, None, "file://localhost/opt/mylib") + assert (key, kind, ref) == ("mylib", "local", None) + assert Path(path) == Path("/opt/mylib") + + +@pytest.mark.parametrize( + "url", ["file://server/share/lib", "file://lib_dev", "file://../mylib"] +) +def test_node_key_file_url_with_host_rejected(url: str) -> None: + # A real host, or a relative path whose first segment parses as the host, + # is rejected rather than silently resolved to the wrong directory. + with pytest.raises(RuntimeError, match="Unsupported host in file://"): + _node_key(None, None, url) + + +@pytest.mark.parametrize("url", ["file:lib_dev", "file:./lib", "file:///"]) +def test_node_key_file_url_must_be_absolute(url: str) -> None: + # A relative path (no host, e.g. file:lib_dev) or a bare root (file:///) + # is rejected rather than resolved against the cwd or yielding an empty name. + with pytest.raises(RuntimeError, match="must be an absolute"): + _node_key(None, None, url) + + +def test_node_key_git_plus_file_url_stays_git(): + # git+file:// is an explicit local git repo, not a plain directory. + _key, kind, locator = _node_key("X", None, "git+file:///srv/foo.git") + assert kind == "git" + assert locator == ("file:///srv/foo.git", None) + + @pytest.mark.parametrize("name", ["http://[::1", "CustomName=http://[::1"]) def test_node_key_malformed_url_in_name_raises(name: str) -> None: # A name that was clearly meant to be a URL but does not parse must fail @@ -550,25 +658,25 @@ def test_node_key_malformed_url_in_name_raises(name: str) -> None: def test_node_key_name_with_equals_but_no_url_is_registry(): - key, is_git, locator = _node_key("FOO=BAR", "1.0", None) - assert (key, is_git, locator) == ("FOO=BAR", False, (None, "FOO=BAR")) + key, kind, locator = _node_key("FOO=BAR", "1.0", None) + assert (key, kind, locator) == ("FOO=BAR", "registry", (None, "FOO=BAR")) def test_node_key_version_url_still_ignored_when_name_plain(): # A version that is a URL is handled by the dependency walk, not here; # a plain name must stay a registry spec regardless of version shape. - key, is_git, _locator = _node_key("bar", "https://github.com/foo/bar", None) - assert (key, is_git) == ("bar", False) + key, kind, _locator = _node_key("bar", "https://github.com/foo/bar", None) + assert (key, kind) == ("bar", "registry") def test_node_key_registry_owner_name(): - key, is_git, locator = _node_key("foo/bar", "^1.0.0", None) - assert (key, is_git, locator) == ("foo/bar", False, ("foo", "bar")) + key, kind, locator = _node_key("foo/bar", "^1.0.0", None) + assert (key, kind, locator) == ("foo/bar", "registry", ("foo", "bar")) def test_node_key_registry_bare_name(): - key, is_git, locator = _node_key("bar", "1.0", None) - assert (key, is_git, locator) == ("bar", False, (None, "bar")) + key, kind, locator = _node_key("bar", "1.0", None) + assert (key, kind, locator) == ("bar", "registry", (None, "bar")) def test_normalize_dependencies_none(): diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index c0a0c678db..0eede78656 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -10,13 +10,14 @@ from pathlib import Path import pytest -from esphome.core import Library +from esphome.core import EsphomeError, Library import esphome.platformio.library as lib from esphome.platformio.library import ( ConvertedLibrary, GitSource, InvalidLibrary, LibraryBackend, + LocalSource, Source, URLSource, _resolve_registry_version, @@ -87,6 +88,68 @@ def test_gitsource_str_includes_ref_when_present(): assert str(GitSource("http://git/repo.git", None)) == "http://git/repo.git" +def test_source_root_defaults_to_build_dir() -> None: + # Registry/git sources are read from where they were downloaded. + build = Path("/some/build/dir") + assert URLSource("http://x/y.tar.gz").source_root(build) == build + assert GitSource("http://x/y.git", None).source_root(build) == build + + +def test_converted_library_source_dir_defaults_to_path() -> None: + c = ConvertedLibrary("x", "1.0", source=None) + c.path = Path("/build") + assert c.source_dir == Path("/build") # no source_path set -> build dir + c.source_path = Path("/user/lib") + assert c.source_dir == Path("/user/lib") + + +def test_convert_libraries_local_missing_manifest_is_esphome_error( + setup_core: Path, +) -> None: + # A local directory that has no library.json/library.properties is user + # input, so it must surface as a clean EsphomeError (named at the user's dir). + src = setup_core / "not_a_lib" + src.mkdir() # exists, but no manifest + # match= is a regex; a Windows path has backslashes, so match a literal + # fragment and check the directory is named separately. + with pytest.raises(EsphomeError, match="missing library.json") as excinfo: + convert_libraries([Library("Foo", None, src.as_uri())], _backend()) + assert str(src) in str(excinfo.value) + + +def test_localsource_download_missing_dir_raises(tmp_path: Path) -> None: + # EsphomeError so the CLI prints it cleanly instead of a traceback. + with pytest.raises(EsphomeError, match="does not exist"): + LocalSource(str(tmp_path / "nope")).download("mylib") + + +def test_localsource_str() -> None: + assert str(LocalSource("/tmp/lib")) == "file:///tmp/lib" + # A relative path can't form a file:// URI; fall back rather than raise. + assert str(LocalSource("rel/lib")) == "file://rel/lib" + + +def test_localsource_download_returns_empty_build_dir(setup_core: Path) -> None: + # Nothing is copied: download() returns an empty build dir (for generated + # files), and source_root() points back at the user's directory. + src = setup_core / "lib_dev" + (src / "src").mkdir(parents=True) + (src / "library.json").write_text("{}") + (src / "src" / "a.cpp").write_text("int a;") + + source = LocalSource(str(src)) + out = source.download("mylib", salt="s", namespace="ns") + + assert out.is_dir() + assert list(out.iterdir()) == [] # no sources copied in + assert out != src + assert source.source_root(out) == src + + # salt/namespace change the cache path. + plain = LocalSource(str(src)).download("mylib") + assert plain != out + + def test_urlsource_download_extracts_then_reuses_marker(setup_core, monkeypatch): monkeypatch.setattr(lib, "rmdir", lambda path, msg="": None) dl_calls: list[list[str]] = [] @@ -317,6 +380,140 @@ def test_convert_libraries_url_in_name_resolves_as_git( assert source.ref is None +def test_convert_libraries_file_url_resolves_as_local( + setup_core: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # A "Name=file://" library points at an on-disk folder: it resolves as a + # local source read in place (no copy), and the registry is never consulted. + src = setup_core / "lib_dev" + (src / "src").mkdir(parents=True) + (src / "library.json").write_text(json.dumps({"name": "TeslaBLE"})) + (src / "src" / "tesla.cpp").write_text("int foo() { return 1; }") + + def fail_registry(owner: str, pkgname: str, requirements: set[str]) -> None: + raise AssertionError(f"registry consulted for {owner}/{pkgname}") + + monkeypatch.setattr(lib, "_resolve_registry_version", fail_registry) + + # as_uri() produces a valid file:// URL on every platform (file:///tmp/... on + # POSIX, file:///C:/... on Windows). + top = convert_libraries([Library("TeslaBLE", None, src.as_uri())], _backend()) + + assert [c.name for c in top] == ["TeslaBLE"] + assert top[0].data["name"] == "TeslaBLE" + assert isinstance(top[0].source, LocalSource) + # Sources are read in place from the user's dir; the build dir stays separate + # and holds no copied sources. + assert top[0].source_path == src + assert top[0].path != src + assert not (top[0].path / "src").exists() + + +def test_convert_libraries_local_overrides_registry_version( + setup_core: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + # The same library requested both from the registry (with a version) and as + # a local directory resolves to the local source, with a warning that the + # registry version was dropped. + src = setup_core / "lib_dev" + (src / "src").mkdir(parents=True) + (src / "library.json").write_text(json.dumps({"name": "TeslaBLE"})) + + def fail_registry(owner: str, pkgname: str, requirements: set[str]) -> None: + raise AssertionError(f"registry consulted for {owner}/{pkgname}") + + monkeypatch.setattr(lib, "_resolve_registry_version", fail_registry) + + with caplog.at_level(logging.WARNING, logger="esphome.platformio.library"): + top = convert_libraries( + [ + Library("TeslaBLE", "1.0.0", None), + Library("TeslaBLE", None, src.as_uri()), + ], + _backend(), + ) + + assert isinstance(top[0].source, LocalSource) + assert "local source" in caplog.text + + +def test_convert_libraries_versionless_registry_and_local_warns( + setup_core: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + # A bare cg.add_library("Foo") (versionless registry, the common case) that + # collides with a local directory of the same key must still warn -- the + # registry spec is dropped and the local folder silently takes over. + src = setup_core / "foo" + src.mkdir() + (src / "library.json").write_text(json.dumps({"name": "Foo"})) + + def fail_registry(owner: str, pkgname: str, requirements: set[str]) -> None: + raise AssertionError(f"registry consulted for {owner}/{pkgname}") + + monkeypatch.setattr(lib, "_resolve_registry_version", fail_registry) + + with caplog.at_level(logging.WARNING, logger="esphome.platformio.library"): + top = convert_libraries( + [Library("Foo", None, None), Library("Foo", None, src.as_uri())], + _backend(), + ) + + assert isinstance(top[0].source, LocalSource) + assert "a registry package" in caplog.text + + +def test_convert_libraries_two_local_dirs_warns( + setup_core: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + # The same key pointed at two local directories warns and uses the last one. + dir_a = setup_core / "a" + dir_b = setup_core / "b" + for d in (dir_a, dir_b): + d.mkdir() + (d / "library.json").write_text(json.dumps({"name": "Foo"})) + + with caplog.at_level(logging.WARNING, logger="esphome.platformio.library"): + top = convert_libraries( + [ + Library("Foo", None, dir_a.as_uri()), + Library("Foo", None, dir_b.as_uri()), + ], + _backend(), + ) + + assert isinstance(top[0].source, LocalSource) + assert top[0].source_path == dir_b # the last one wins + assert "two local directories" in caplog.text + + +@pytest.mark.parametrize("local_first", [True, False]) +def test_convert_libraries_git_and_local_same_key_warns( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + local_first: bool, +) -> None: + # A key requested as both a git source and a local directory warns and uses + # git, whichever order they appear in. The git URL basename matches the local + # custom name so both map to the key "Foo". + _patch_download_with_manifests(monkeypatch, tmp_path, {"Foo": {"name": "Foo"}}) + git = Library("X", None, "https://host/Foo") + local = Library("Foo", None, "file:///abs/foo") + libs = [local, git] if local_first else [git, local] + + with caplog.at_level(logging.WARNING, logger="esphome.platformio.library"): + top = convert_libraries(libs, _backend()) + + assert isinstance(top[0].source, GitSource) + assert "using the git source" in caplog.text + + def test_convert_libraries_skips_incompatible_dependency(tmp_path, monkeypatch): # A dependency that declares an incompatible platform is skipped (the # top-level library still builds). diff --git a/tests/unit_tests/test_zephyr_library.py b/tests/unit_tests/test_zephyr_library.py index 0ba3577fa7..b370fe0c47 100644 --- a/tests/unit_tests/test_zephyr_library.py +++ b/tests/unit_tests/test_zephyr_library.py @@ -59,7 +59,10 @@ def test_generate_cmakelists_txt_flags_and_includes(tmp_path): assert "-DFOO" in out assert "-Wall" in out assert "zephyr_link_libraries(" in out - assert "-Llibdir" in out + # -L paths are absolutised against the library dir (the CMakeLists lives in a + # zephyr/ subdir, so a relative path would resolve from the wrong place). + abs_libdir = str((tmp_path / "libdir").resolve()).replace("\\", "\\\\") + assert f"-L{abs_libdir}" in out assert "-lm" in out From 11c3c06b1299f7e3c6594bc1a035873f5f41b85a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 Aug 2026 20:51:17 -0500 Subject: [PATCH 1198/1815] [rp2_ble_tracker] Add active scanning support (#18035) --- esphome/components/rp2040_ble/rp2040_ble.cpp | 10 ++++++---- esphome/components/rp2040_ble/rp2040_ble.h | 20 +++++++++++-------- .../components/rp2_ble_tracker/__init__.py | 17 +++++++++++++--- .../rp2_ble_tracker/rp2_ble_tracker.cpp | 15 ++++++++------ .../rp2_ble_tracker/rp2_ble_tracker.h | 14 ++++++------- .../test_scan_parameter_validation.py | 4 ++-- .../rp2040_ble/test-scan.rp2040-ard.yaml | 10 +++++++--- .../rp2_ble_tracker/common-boundary.yaml | 1 + tests/components/rp2_ble_tracker/common.yaml | 1 + 9 files changed, 59 insertions(+), 33 deletions(-) diff --git a/esphome/components/rp2040_ble/rp2040_ble.cpp b/esphome/components/rp2040_ble/rp2040_ble.cpp index e10e85f3c3..8280c19d56 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.cpp +++ b/esphome/components/rp2040_ble/rp2040_ble.cpp @@ -183,6 +183,7 @@ void RP2040BLE::packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, reverse_bd_addr(addr, mac_lsb); // LSB-first, the BLE convention consumers expect global_ble->enqueue_scan_report_(mac_lsb, static_cast(gap_event_advertising_report_get_rssi(packet)), gap_event_advertising_report_get_address_type(packet), + gap_event_advertising_report_get_advertising_event_type(packet), gap_event_advertising_report_get_data(packet), gap_event_advertising_report_get_data_length(packet)); break; @@ -196,8 +197,8 @@ void RP2040BLE::packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, // pool is sized to the queue capacity (SIZE-1), so allocate() returns nullptr // before push() can find the ring full. // NOLINTBEGIN(clang-analyzer-unix.Malloc) -void RP2040BLE::enqueue_scan_report_(const uint8_t *mac_lsb_first, int8_t rssi, uint8_t addr_type, const uint8_t *data, - uint16_t data_len) { +void RP2040BLE::enqueue_scan_report_(const uint8_t *mac_lsb_first, int8_t rssi, uint8_t addr_type, + uint8_t adv_event_type, const uint8_t *data, uint16_t data_len) { BLEScanReport *report = this->report_pool_.allocate(); if (report == nullptr) { // Pool exhausted — the queue is full; count and drop. @@ -207,6 +208,7 @@ void RP2040BLE::enqueue_scan_report_(const uint8_t *mac_lsb_first, int8_t rssi, memcpy(report->mac, mac_lsb_first, 6); report->rssi = rssi; report->addr_type = addr_type; + report->adv_event_type = adv_event_type; report->data_len = (data_len <= sizeof(report->data)) ? static_cast(data_len) : static_cast(sizeof(report->data)); memcpy(report->data, data, report->data_len); @@ -216,7 +218,7 @@ void RP2040BLE::enqueue_scan_report_(const uint8_t *mac_lsb_first, int8_t rssi, void RP2040BLE::get_mac_msb_first(uint8_t out[6]) const { memcpy(out, this->ble_mac_, 6); } -bool RP2040BLE::scan_start(uint16_t interval, uint16_t window) { +bool RP2040BLE::scan_start(uint16_t interval, uint16_t window, bool active) { if (!this->is_active()) { // Power control stays with the user (enable_on_boot or an explicit // enable() call) — auto-enabling here would defeat enable_on_boot: false @@ -226,7 +228,7 @@ bool RP2040BLE::scan_start(uint16_t interval, uint16_t window) { // Serialize with the BTstack background worker (arduino-pico's BluetoothHCI // takes the same lock around its gap_* calls). BluetoothLock lock; - gap_set_scan_params(0 /* passive */, interval, window, 0 /* accept all */); + gap_set_scan_params(active ? 1 : 0, interval, window, 0 /* accept all */); gap_start_scan(); return true; } diff --git a/esphome/components/rp2040_ble/rp2040_ble.h b/esphome/components/rp2040_ble/rp2040_ble.h index 9685b9294e..af7feddd26 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.h +++ b/esphome/components/rp2040_ble/rp2040_ble.h @@ -28,10 +28,13 @@ struct BLEScanReport { uint8_t mac[6]; // LSB-first, as the controller delivers it int8_t rssi; // signed dBm uint8_t addr_type; - uint8_t data_len; // bytes valid in data[] - // Legacy advertisement (31) + scan response (31): passive scans fill at most - // 31 bytes today, but bluetooth_proxy support will flip to active scanning - // in a future PR and the API raw-advertisement contract carries 62. + uint8_t adv_event_type; // GAP advertising event type (ADV_IND .. SCAN_RSP); lets a merger tell the two apart + uint8_t data_len; // bytes valid in data[] + // Legacy advertisement (31) + scan response (31). BTstack delivers the two + // as separate reports, so each report fills at most 31 bytes today; the 62 + // matches the API raw-advertisement contract. adv_event_type is what lets a + // future merge point tell the two frames apart — carrying it beyond this + // struct (RawAdvertisement) is deferred until a consumer needs the merge. uint8_t data[62]; // EventPool contract: nothing is heap-allocated inside a report. @@ -79,14 +82,15 @@ class RP2040BLE final : public Component { /// Register a consumer for scan reports (delivered on the main loop via loop()). void register_scan_listener(BLEScanListener *listener) { this->scan_listeners_.push_back(listener); } - /// Start a passive controller scan. Interval/window are in BLE units + /// Start a controller scan; active sends scan requests and receives scan + /// responses as separate reports. Interval/window are in BLE units /// (0.625 ms). Returns false until the stack is ACTIVE (callers retry — the /// tracker's rate-limited retry loop); powering the stack on stays with the /// user (enable_on_boot or an explicit enable() call). The controller keeps /// no scan state: a disable()/enable() power cycle ends the scan, and the /// caller must call scan_start() again once the stack is back to ACTIVE /// (the tracker's loop() reconciliation does exactly that). - bool scan_start(uint16_t interval, uint16_t window); + bool scan_start(uint16_t interval, uint16_t window, bool active); /// Stop the controller scan (no-op when not scanning). void scan_stop(); @@ -95,8 +99,8 @@ class RP2040BLE final : public Component { /// Buffer one controller report (BTstack packet handler, CYW43 async-context /// IRQ — bounded copy into the lock-free queue, nothing else). - void enqueue_scan_report_(const uint8_t *mac_lsb_first, int8_t rssi, uint8_t addr_type, const uint8_t *data, - uint16_t data_len); + void enqueue_scan_report_(const uint8_t *mac_lsb_first, int8_t rssi, uint8_t addr_type, uint8_t adv_event_type, + const uint8_t *data, uint16_t data_len); std::vector scan_listeners_; // Report ring: the BTstack packet handler (async-context IRQ) allocates a diff --git a/esphome/components/rp2_ble_tracker/__init__.py b/esphome/components/rp2_ble_tracker/__init__.py index 2ae53cfe30..5f29fece8a 100644 --- a/esphome/components/rp2_ble_tracker/__init__.py +++ b/esphome/components/rp2_ble_tracker/__init__.py @@ -13,7 +13,13 @@ import esphome.codegen as cg from esphome.components import ble_device_base, ota, rp2040_ble from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW import esphome.config_validation as cv -from esphome.const import CONF_CONTINUOUS, CONF_DURATION, CONF_ID, CONF_INTERVAL +from esphome.const import ( + CONF_ACTIVE, + CONF_CONTINUOUS, + CONF_DURATION, + CONF_ID, + CONF_INTERVAL, +) from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType @@ -32,8 +38,12 @@ RP2BLETracker = rp2_ble_tracker_ns.class_( # interval defaults to 100 ms with the shared 30 ms window, a 30 % duty cycle — # the same defaults as bk72xx_ble_tracker, leaving the radio mostly free for # WiFi on the shared CYW43. Converted to the controller's 0.625 ms BLE units in -# to_code(). -SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("100ms") +# to_code(). `active` defaults on for esp32_ble_tracker parity; it adds scan +# request TX and roughly doubles the reports through the queue, so +# `active: false` is the lighter choice when scan response data is not needed. +SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema( + "100ms", supports_active=True +) CONFIG_SCHEMA = cv.Schema( { @@ -68,6 +78,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_scan_interval(ble_device_base.to_ble_units(scan[CONF_INTERVAL]))) cg.add(var.set_scan_window(ble_device_base.to_ble_units(scan[CONF_WINDOW]))) cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds)) + cg.add(var.set_scan_active(scan[CONF_ACTIVE])) cg.add(var.set_scan_continuous(scan[CONF_CONTINUOUS])) CORE.add_job(_emit_listener_count) diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp index b2c25a8d0e..28c5c927ea 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp @@ -111,10 +111,12 @@ void RP2BLETracker::dump_config() { " Scan Duration: %" PRIu32 " s\n" " Scan Interval: %.0f ms (%" PRIu32 " BLE units)\n" " Scan Window: %.0f ms (%" PRIu32 " BLE units)\n" - " Scan Type: PASSIVE\n" + " Scan Type: %s\n" " Continuous Scanning: %s", this->scan_duration_ / 1000, this->scan_interval_ * BLE_SCAN_UNIT_MS, this->scan_interval_, - this->scan_window_ * BLE_SCAN_UNIT_MS, this->scan_window_, YESNO(this->scan_continuous_)); + this->scan_window_ * BLE_SCAN_UNIT_MS, this->scan_window_, + this->scan_active_ ? LOG_STR_LITERAL("ACTIVE") : LOG_STR_LITERAL("PASSIVE"), + YESNO(this->scan_continuous_)); } void RP2BLETracker::on_scan_report(const rp2040_ble::BLEScanReport &report) { @@ -167,16 +169,17 @@ void RP2BLETracker::start_scan_() { // covers a failed start that came through the public start_scan(). this->last_scan_start_attempt_ = App.get_loop_component_start_time(); - if (!this->parent_->scan_start(static_cast(this->scan_interval_), - static_cast(this->scan_window_))) + if (!this->parent_->scan_start(static_cast(this->scan_interval_), static_cast(this->scan_window_), + this->scan_active_)) return; this->scan_running_ = true; // Log every explicit start at DEBUG — stop_scan_() logs every stop at DEBUG, and // in non-continuous mode each period is an explicit start, so asymmetric logging // would read as the scanner failing to come back up. - ESP_LOGD(TAG, "Scan started (passive, window=%.0fms, interval=%.0fms)", this->scan_window_ * BLE_SCAN_UNIT_MS, - this->scan_interval_ * BLE_SCAN_UNIT_MS); + ESP_LOGD(TAG, "Scan started (%s, window=%.0fms, interval=%.0fms)", + this->scan_active_ ? LOG_STR_LITERAL("active") : LOG_STR_LITERAL("passive"), + this->scan_window_ * BLE_SCAN_UNIT_MS, this->scan_interval_ * BLE_SCAN_UNIT_MS); // Re-anchor the scan period to every successful start — first start (so the // period counts from the scan, not from boot) and every restart after a stop (so // resuming after longer than scan_duration, e.g. a failed OTA restoring continuous diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h index 808e84a70f..bb2a6862af 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h @@ -42,6 +42,7 @@ class RP2BLETracker : public Component, void set_scan_interval(uint32_t scan_interval) { this->scan_interval_ = scan_interval; } void set_scan_window(uint32_t scan_window) { this->scan_window_ = scan_window; } void set_scan_duration(uint32_t scan_duration) { this->scan_duration_ = scan_duration; } + void set_scan_active(bool scan_active) { this->scan_active_ = scan_active; } void set_scan_continuous(bool scan_continuous) { this->scan_continuous_ = scan_continuous; } // ---- Public scan control ---- @@ -59,19 +60,17 @@ class RP2BLETracker : public Component, this->raw_advertisement_callback_ = callback; } ble_device_base::HubCapabilities get_capabilities() const override { - // BTstack on the CYW43 supports active scanning and GATT, but this tracker - // drives the controller passively (scan_type 0) and exposes no GATT path - // yet — capabilities describe what this component delivers, so all three - // stay false until those paths are implemented. Consumers relying on + // BTstack delivers scan responses as separate advertisement reports rather + // than merging them into the advertisement — consumers relying on // scan-response fields (device names) get them only where the receiver - // merges per address (Home Assistant does). - return {.active_scan = false, .merges_scan_response = false, .gatt = false}; + // merges per address (Home Assistant does). No GATT path yet. + return {.active_scan = true, .merges_scan_response = false, .gatt = false}; } // The controller stores the address in printable (MSB-first) order, which is // exactly what the contract wants. void get_adapter_mac(uint8_t out[6]) override { this->parent_->get_mac_msb_first(out); } bool scan_running() override { return this->scan_running_; } - bool scan_active() override { return false; } // passive-only (initial implementation) + bool scan_active() override { return this->scan_active_; } // ---- rp2040_ble::BLEScanListener ---- // Delivered by the controller's loop() on the ESPHome main loop — the @@ -91,6 +90,7 @@ class RP2BLETracker : public Component, uint32_t last_scan_start_attempt_{0}; // loop time of last start_scan_() attempt; rate-limits retries uint32_t scan_period_start_{0}; // loop time at start of current scan period; rate-limits on_scan_end() bool scan_running_{false}; + bool scan_active_{true}; bool scan_continuous_{true}; #ifdef USE_OTA_STATE_LISTENER bool scan_continuous_before_ota_{false}; // continuous mode saved at OTA start, restored on OTA failure diff --git a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py index e3226eaa41..bbf4953953 100644 --- a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py +++ b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py @@ -62,11 +62,11 @@ def test_esp32_defaults_are_valid() -> None: def test_rp2_defaults_are_valid() -> None: """rp2 pins 100 ms interval / 30 ms window — a 30 % duty cycle leaving the - shared CYW43 radio mostly free for WiFi.""" + shared CYW43 radio mostly free for WiFi — and exposes active (default on).""" config = RP2_SCHEMA({}) assert to_ble_units(config["interval"]) == 160 assert to_ble_units(config["window"]) == 48 - assert "active" not in config + assert config["active"] is True def test_esp32_active_can_disable() -> None: diff --git a/tests/components/rp2040_ble/test-scan.rp2040-ard.yaml b/tests/components/rp2040_ble/test-scan.rp2040-ard.yaml index 251c83a92f..401a18c0de 100644 --- a/tests/components/rp2040_ble/test-scan.rp2040-ard.yaml +++ b/tests/components/rp2040_ble/test-scan.rp2040-ard.yaml @@ -1,12 +1,16 @@ -# Exercises the controller scan API from a lambda: passive scan start with -# interval/window in 0.625 ms BLE units, stop, and the adapter MAC accessor. +# Exercises the controller scan API from a lambda: scan start with +# interval/window in 0.625 ms BLE units and the active flag, stop, and the +# adapter MAC accessor. esphome: on_boot: then: - lambda: |- uint8_t mac[6]; id(ble).get_mac_msb_first(mac); - if (id(ble).scan_start(160, 48)) { + if (id(ble).scan_start(160, 48, false)) { + id(ble).scan_stop(); + } + if (id(ble).scan_start(160, 48, true)) { id(ble).scan_stop(); } diff --git a/tests/components/rp2_ble_tracker/common-boundary.yaml b/tests/components/rp2_ble_tracker/common-boundary.yaml index c8b558e226..91e010b121 100644 --- a/tests/components/rp2_ble_tracker/common-boundary.yaml +++ b/tests/components/rp2_ble_tracker/common-boundary.yaml @@ -8,4 +8,5 @@ rp2_ble_tracker: interval: 5000us window: 2500us duration: 5min + active: false continuous: false diff --git a/tests/components/rp2_ble_tracker/common.yaml b/tests/components/rp2_ble_tracker/common.yaml index 12482a6124..633a4e1d0f 100644 --- a/tests/components/rp2_ble_tracker/common.yaml +++ b/tests/components/rp2_ble_tracker/common.yaml @@ -4,6 +4,7 @@ rp2_ble_tracker: interval: 100ms window: 30ms duration: 5min + active: true continuous: true # Pulls in USE_OTA_STATE_LISTENER so the OTA scan-pause path compiles in CI From 1b3891866c87b03f9c79ecd09644b60cac863473 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 Aug 2026 20:59:38 -0500 Subject: [PATCH 1199/1815] [core] Parse stored framework versions without importing the validation stack (#18045) --- esphome/components/esp32/const.py | 11 ++-- esphome/config_validation.py | 38 +------------- esphome/const.py | 5 ++ esphome/core/__init__.py | 37 +++++++++++++ esphome/espidf/clang_tidy.py | 15 ++++-- esphome/espidf/framework.py | 3 +- esphome/helpers.py | 2 +- esphome/storage_json.py | 49 ++++++++--------- .../lazy_imports/storage_json_fast_path.py | 52 +++++++++++++++++++ tests/unit_tests/test_compiled_config.py | 7 +-- tests/unit_tests/test_lazy_imports.py | 45 ++++++++++++++++ 11 files changed, 182 insertions(+), 82 deletions(-) create mode 100644 tests/unit_tests/fixtures/lazy_imports/storage_json_fast_path.py diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index 248f84c6bc..af386b618a 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -1,9 +1,15 @@ import esphome.codegen as cg -KEY_ESP32 = "esp32" +# Re-exported for the many esp32-side users; defined in esphome.const so +# the upload/logs fast path can read them without importing this package. +from esphome.const import ( # noqa: F401 # pylint: disable=unused-import + KEY_ESP32, + KEY_IDF_VERSION, + KEY_VARIANT, +) + KEY_BOARD = "board" KEY_FLASH_SIZE = "flash_size" -KEY_VARIANT = "variant" KEY_SDKCONFIG_OPTIONS = "sdkconfig_options" KEY_COMPONENTS = "components" KEY_EXCLUDE_COMPONENTS = "exclude_components" @@ -15,7 +21,6 @@ KEY_PATH = "path" KEY_SUBMODULES = "submodules" KEY_EXTRA_BUILD_FILES = "extra_build_files" KEY_FULL_CERT_BUNDLE = "full_cert_bundle" -KEY_IDF_VERSION = "idf_version" KEY_NETWORK_SDKCONFIG = "network_sdkconfig" VARIANT_ESP32 = "ESP32" diff --git a/esphome/config_validation.py b/esphome/config_validation.py index ff9170813c..2de6898177 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Callable from contextlib import contextmanager, suppress -from dataclasses import dataclass from datetime import datetime from ipaddress import ( AddressValueError, @@ -88,6 +87,7 @@ from esphome.core import ( TimePeriodMinutes, TimePeriodNanoseconds, TimePeriodSeconds, + Version, ) from esphome.enum import StrEnum from esphome.expression import SUBSTITUTION_VARIABLE_PROG as VARIABLE_PROG @@ -408,42 +408,6 @@ class FinalExternalInvalid(Invalid): """Represents an invalid value in the final validation phase where the path should not be prepended.""" -@dataclass(frozen=True, order=True) -class Version: - major: int - minor: int - patch: int - extra: str = "" - - def __str__(self): - if self.extra: - return f"{self.major}.{self.minor}.{self.patch}-{self.extra}" - return f"{self.major}.{self.minor}.{self.patch}" - - @classmethod - def parse(cls, value: str) -> Version: - # The patch component is optional and defaults to 0, so "6.0" and - # "6.0-rc1" parse as 6.0.0 and 6.0.0-rc1. - match = re.match(r"^(\d+)\.(\d+)(?:\.(\d+))?[-.]?(\w*)$", value) - if match is None: - raise ValueError(f"Not a valid version number {value}") - major = int(match[1]) - minor = int(match[2]) - patch = int(match[3] or 0) - extra = match[4] or "" - return Version(major=major, minor=minor, patch=patch, extra=extra) - - @property - def is_beta(self) -> bool: - """Check if this version is a beta version.""" - return self.extra.startswith("b") - - @property - def is_dev(self) -> bool: - """Check if this version is a development version.""" - return self.extra.startswith("dev") - - def check_not_templatable(value): if isinstance(value, Lambda): raise Invalid("This option is not templatable!") diff --git a/esphome/const.py b/esphome/const.py index f2d305dced..b1302af922 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1422,6 +1422,11 @@ KEY_FRAMEWORK_VERSION = "framework_version" KEY_NAME = "name" KEY_VARIANT = "variant" KEY_PAST_SAFE_MODE = "past_safe_mode" +# esp32 storage keys; defined here so the upload/logs fast path +# (storage_json.apply_to_core) can use them without importing the +# esp32 component package. +KEY_ESP32 = "esp32" +KEY_IDF_VERSION = "idf_version" # Entity categories ENTITY_CATEGORY_NONE = "" diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index deee127f49..e5b3ebb84d 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -1,5 +1,6 @@ from collections import defaultdict from contextlib import contextmanager +from dataclasses import dataclass import logging import math import os @@ -279,6 +280,42 @@ class TimePeriodMinutes(TimePeriod): pass +@dataclass(frozen=True, order=True) +class Version: + major: int + minor: int + patch: int + extra: str = "" + + def __str__(self): + if self.extra: + return f"{self.major}.{self.minor}.{self.patch}-{self.extra}" + return f"{self.major}.{self.minor}.{self.patch}" + + @classmethod + def parse(cls, value: str) -> "Version": + # The patch component is optional and defaults to 0, so "6.0" and + # "6.0-rc1" parse as 6.0.0 and 6.0.0-rc1. + match = re.match(r"^(\d+)\.(\d+)(?:\.(\d+))?[-.]?(\w*)$", value) + if match is None: + raise ValueError(f"Not a valid version number {value}") + major = int(match[1]) + minor = int(match[2]) + patch = int(match[3] or 0) + extra = match[4] or "" + return Version(major=major, minor=minor, patch=patch, extra=extra) + + @property + def is_beta(self) -> bool: + """Check if this version is a beta version.""" + return self.extra.startswith("b") + + @property + def is_dev(self) -> bool: + """Check if this version is a development version.""" + return self.extra.startswith("dev") + + LAMBDA_PROG = re.compile(r"\bid\(\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\)(\.?)") diff --git a/esphome/espidf/clang_tidy.py b/esphome/espidf/clang_tidy.py index 88ecda60b9..c91db775a3 100644 --- a/esphome/espidf/clang_tidy.py +++ b/esphome/espidf/clang_tidy.py @@ -141,10 +141,15 @@ idf_component_register( def _setup_core(work_dir: Path, settings: _Settings) -> None: """Point CORE at the tidy project + IDF version, without any YAML config.""" - from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION, KEY_VARIANT - import esphome.config_validation as cv - from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM - from esphome.core import CORE + from esphome.const import ( + KEY_CORE, + KEY_ESP32, + KEY_IDF_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + KEY_VARIANT, + ) + from esphome.core import CORE, Version CORE.name = TIDY_PROJECT_NAME # config_path's parent is the data dir root for per-run artifacts (idedata, @@ -153,7 +158,7 @@ def _setup_core(work_dir: Path, settings: _Settings) -> None: CORE.config_path = work_dir.parent / "tidy.yaml" CORE.build_path = work_dir esp32 = CORE.data.setdefault(KEY_ESP32, {}) - esp32[KEY_IDF_VERSION] = cv.Version.parse(settings.idf_version) + esp32[KEY_IDF_VERSION] = Version.parse(settings.idf_version) esp32[KEY_VARIANT] = settings.variant # The target framework drives the PlatformIO-library -> IDF-component # converter and ESPHome's CORE.using_arduino / using_esp_idf helpers. diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 0ca7a9d14b..39bf0465d5 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -13,8 +13,7 @@ from typing import Any, NoReturn import platformdirs -from esphome.config_validation import Version -from esphome.core import CORE +from esphome.core import CORE, Version from esphome.framework_helpers import ( PathType, archive_extract_all, diff --git a/esphome/helpers.py b/esphome/helpers.py index 5c57a2823b..5458f5edfc 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -705,7 +705,7 @@ class ProgressBar: def docs_url(path: str) -> str: """Return the URL to the documentation for a given path.""" # Local import to avoid circular import - from esphome.config_validation import Version + from esphome.core import Version version = Version.parse(ESPHOME_VERSION) if version.is_beta: diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 6376e573c4..2aa76aabaa 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -12,12 +12,15 @@ from esphome.const import ( CONF_DISABLED, CONF_MDNS, KEY_CORE, + KEY_ESP32, KEY_FRAMEWORK_VERSION, + KEY_IDF_VERSION, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, + KEY_VARIANT, Toolchain, ) -from esphome.core import CORE, EsphomeError +from esphome.core import CORE, EsphomeError, Version from esphome.helpers import write_file_if_changed from esphome.types import CoreType @@ -69,6 +72,17 @@ def _to_path_if_not_none(value: str | None) -> Path | None: return Path(value) if value is not None else None +def _parse_framework_version(framework_version: str) -> Version: + try: + return Version.parse(framework_version) + except ValueError as err: + raise EsphomeError( + f"Could not parse the framework version " + f"{framework_version!r} from {storage_path()}. " + f"Please clean the build files and recompile." + ) from err + + class StorageJSON: """Persisted device metadata sidecar. @@ -319,37 +333,16 @@ class StorageJSON: # esp32.get_esp32_variant(). target_platform on disk is the variant # (e.g. "ESP32S3"); core_platform is the family (e.g. "esp32"). if target_platform == const.PLATFORM_ESP32: - from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION - from esphome.const import KEY_VARIANT - esp32_data = {KEY_VARIANT: self.target_platform} if self.framework_version: - import esphome.config_validation as cv - - try: - esp32_data[KEY_IDF_VERSION] = cv.Version.parse( - self.framework_version - ) - except ValueError as err: - raise EsphomeError( - f"Could not parse the framework version " - f"{self.framework_version!r} from {storage_path()}. " - f"Please clean the build files and recompile." - ) from err - CORE.data[KEY_ESP32] = esp32_data - elif target_platform == const.PLATFORM_NRF52 and self.framework_version: - import esphome.config_validation as cv - - try: - CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version.parse( + esp32_data[KEY_IDF_VERSION] = _parse_framework_version( self.framework_version ) - except ValueError as err: - raise EsphomeError( - f"Could not parse the framework version " - f"{self.framework_version!r} from {storage_path()}. " - f"Please clean the build files and recompile." - ) from err + CORE.data[KEY_ESP32] = esp32_data + elif target_platform == const.PLATFORM_NRF52 and self.framework_version: + CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = _parse_framework_version( + self.framework_version + ) def __eq__(self, o) -> bool: return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict() diff --git a/tests/unit_tests/fixtures/lazy_imports/storage_json_fast_path.py b/tests/unit_tests/fixtures/lazy_imports/storage_json_fast_path.py new file mode 100644 index 0000000000..01b23b8f04 --- /dev/null +++ b/tests/unit_tests/fixtures/lazy_imports/storage_json_fast_path.py @@ -0,0 +1,52 @@ +"""Run the esp32 storage fast path and report which heavy modules loaded. + +Executed as a subprocess by test_lazy_imports.py: heavy module names come +in on argv, the ones found in sys.modules afterwards go out on stdout. +""" + +import sys + +from esphome.const import KEY_ESP32, KEY_IDF_VERSION, KEY_VARIANT +from esphome.core import CORE, Version +from esphome.storage_json import StorageJSON + +storage = StorageJSON( + storage_version=1, + name="test", + friendly_name="Test", + comment=None, + esphome_version="2026.1.0", + src_version=1, + address="1.2.3.4", + web_port=None, + target_platform="ESP32S3", + build_path=None, + firmware_bin_path=None, + loaded_integrations=set(), + loaded_platforms=set(), + no_mdns=False, + framework="esp-idf", + core_platform="esp32", + area=None, + framework_version="5.3.1", +) +storage.apply_to_core() + +# Fail loudly if the esp32 fast path stopped doing its work; otherwise an +# empty leak list could just mean nothing ran. Explicit exits rather than +# asserts so PYTHONOPTIMIZE in the ambient environment can't strip them. +esp32_data = CORE.data.get(KEY_ESP32, {}) +if esp32_data.get(KEY_VARIANT) != "ESP32S3": + sys.exit(f"apply_to_core did not record the variant: {esp32_data!r}") +if esp32_data.get(KEY_IDF_VERSION) != Version(5, 3, 1): + sys.exit(f"apply_to_core did not parse the framework version: {esp32_data!r}") + +# Any component package counts as a leak, not just the ones on the watch +# list: executing one drags in codegen/validation machinery by design. +leaked = [module for module in sys.argv[1:] if module in sys.modules] +leaked += [ + module + for module in sys.modules + if module.startswith("esphome.components.") and module not in leaked +] +print(",".join(leaked)) diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index e17271e2b4..4219424aa1 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -20,6 +20,7 @@ from esphome.const import ( CONF_ESPHOME, CONF_NAME, KEY_CORE, + KEY_ESP32, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, KEY_VARIANT, @@ -130,15 +131,11 @@ def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None: assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == "esp32" assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "arduino" # upload_using_esptool reads get_esp32_variant() off CORE.data[KEY_ESP32]. - from esphome.components.esp32.const import KEY_ESP32 - assert CORE.data[KEY_ESP32][KEY_VARIANT] == "ESP32" def test_load_compiled_config_populates_esp32_variant(tmp_path: Path) -> None: """ESP32 variants survive the cache fast path so esptool gets the right --chip.""" - from esphome.components.esp32.const import KEY_ESP32 - yaml_path = tmp_path / "lite_test.yaml" yaml_path.write_text("esphome:\n name: lite_test\n") CORE.config_path = yaml_path @@ -156,8 +153,6 @@ def test_load_compiled_config_skips_esp32_block_for_other_platforms( tmp_path: Path, ) -> None: """Non-esp32 targets shouldn't fabricate an esp32 data block.""" - from esphome.components.esp32.const import KEY_ESP32 - yaml_path = tmp_path / "lite_test.yaml" yaml_path.write_text("esphome:\n name: lite_test\n") CORE.config_path = yaml_path diff --git a/tests/unit_tests/test_lazy_imports.py b/tests/unit_tests/test_lazy_imports.py index 72ba73c9dc..7eff5c9ef5 100644 --- a/tests/unit_tests/test_lazy_imports.py +++ b/tests/unit_tests/test_lazy_imports.py @@ -14,6 +14,9 @@ test pins down *which* heavy modules must stay out entirely. from __future__ import annotations +import importlib.util +import os +from pathlib import Path import subprocess import sys @@ -30,6 +33,10 @@ HEAVY_MODULES = ( "voluptuous", ) +# Everything the storage fast path must keep out of sys.modules; the +# existence guard and the leak check must watch the same list. +FAST_PATH_HEAVY_MODULES = HEAVY_MODULES + ("esphome.components.esp32",) + def _leaked_heavy_modules(module: str) -> str: """Import ``module`` in a subprocess and report the heavy modules it pulled. @@ -63,6 +70,44 @@ def test_main_module_does_not_import_heavy_modules() -> None: ) +def test_watched_heavy_modules_exist() -> None: + """A renamed heavy module would silently disable the leak checks.""" + for module in FAST_PATH_HEAVY_MODULES: + assert importlib.util.find_spec(module) is not None, ( + f"{module} no longer resolves; update the heavy-module lists" + ) + + +def test_storage_json_fast_path_does_not_import_heavy_modules( + fixture_path: Path, +) -> None: + """``apply_to_core`` runs on the upload/logs fast path for every + platform; parsing the stored framework version must not drag in the + validation stack or the esp32 component package. + """ + script = fixture_path / "lazy_imports" / "storage_json_fast_path.py" + # Running a script file drops the cwd from sys.path, so prepend the + # repo root for the child; check=False keeps its stderr visible. + python_path = str(Path(__file__).parents[2]) + if ambient := os.environ.get("PYTHONPATH"): + python_path = os.pathsep.join((python_path, ambient)) + env = os.environ | {"PYTHONPATH": python_path} + result = subprocess.run( + [sys.executable, str(script), *FAST_PATH_HEAVY_MODULES], + capture_output=True, + text=True, + env=env, + check=False, + ) + assert result.returncode == 0, result.stderr + leaked = result.stdout.strip() + assert not leaked, ( + f"storage_json.apply_to_core pulls in heavy modules: {leaked}. " + "The upload/logs fast path skips validation; importing the " + "validation stack anyway defeats the validated-config cache." + ) + + def test_api_client_does_not_import_heavy_modules() -> None: """``esphome.api_client`` is on the logs fast path and must stay light. From 56b1c59eb2dc8dda54d2446ecc13a2cf27d8e053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Tue, 4 Aug 2026 05:54:07 +0300 Subject: [PATCH 1200/1815] [ln882h_ble] Scan primitives and main-task scan report queue (#17835) --- esphome/components/ln882h_ble/__init__.py | 20 ++ esphome/components/ln882h_ble/ln882h_ble.cpp | 223 +++++++++++++++++-- esphome/components/ln882h_ble/ln882h_ble.h | 87 ++++++++ esphome/core/defines.h | 1 + 4 files changed, 315 insertions(+), 16 deletions(-) diff --git a/esphome/components/ln882h_ble/__init__.py b/esphome/components/ln882h_ble/__init__.py index f499dbc6fd..4299c7f006 100644 --- a/esphome/components/ln882h_ble/__init__.py +++ b/esphome/components/ln882h_ble/__init__.py @@ -12,6 +12,7 @@ component does (LibreTiny v1.13.0+). import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType DEPENDENCIES = ["ln882x"] @@ -31,6 +32,23 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) +KEY_SCAN_LISTENER_COUNT = "ln882h_ble_scan_listener_count" + + +def request_scan_listener_slot() -> None: + """Called from a consumer's codegen once per registered scan listener; sizes + the controller's StaticVector listener storage (heap-free, mirrors the + tracker's ble_device_base listener storage).""" + CORE.data[KEY_SCAN_LISTENER_COUNT] = CORE.data.get(KEY_SCAN_LISTENER_COUNT, 0) + 1 + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _add_listener_count() -> None: + # FINAL: every consumer's to_code has requested its slot by now. + if count := CORE.data.get(KEY_SCAN_LISTENER_COUNT, 0): + cg.add_define("LN882H_BLE_SCAN_LISTENER_COUNT", count) + + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -46,3 +64,5 @@ async def to_code(config: ConfigType) -> None: cg.add_platformio_option("custom_options.proj_config#h", ["CFG_SUPPORT_BLE=1"]) cg.add_define("USE_LN882H_BLE") + + CORE.add_job(_add_listener_count) diff --git a/esphome/components/ln882h_ble/ln882h_ble.cpp b/esphome/components/ln882h_ble/ln882h_ble.cpp index dd8fee390d..c3b8982a92 100644 --- a/esphome/components/ln882h_ble/ln882h_ble.cpp +++ b/esphome/components/ln882h_ble/ln882h_ble.cpp @@ -1,11 +1,17 @@ // ln882h_ble.cpp // // BLE controller support for the LN882H (LibreTiny lightning-ln882h family) — -// the platform analog of esp32_ble / rp2040_ble. Owns the one-time stack -// bring-up (rw_init + the ln_* app init sequence) and the controller BLE -// address (persistent KV entry, WiFi-MAC-derived once). Consumers -// (ln882h_ble_tracker) build on this component and contain no SDK calls of -// their own. +// the platform analog of esp32_ble / rp2040_ble. Owns everything that talks to +// the LN882H BLE SDK: +// - one-time stack bring-up (rw_init + the ln_* app init sequence), +// - the controller BLE address (persistent KV entry, WiFi-MAC-derived once), +// - the raw controller scan primitives (ln_ble_scan_start/stop), +// - the scan-report ring: the SDK's rw-task event callback decodes each +// report (including the controller's RSSI sign quirk) into a fixed pool +// and pushes it on a lock-free SPSC queue; loop() drains, dispatches on +// the main task and returns reports to the pool — the same EventPool + +// LockFreeQueue handoff esp32_ble uses, zero allocation at steady state. +// Consumers contain no SDK calls of their own. // // BLE stack init and scan lifecycle mirror the SDK's ble_app usage. The BLE // stack itself is compiled and linked by the LibreTiny lightning-ln882h builder @@ -51,6 +57,9 @@ void ln_ble_scan_actv_creat(void); void ln_ble_scan_start(void *scan_param); void ln_ble_scan_stop(void); +using ble_evt_cb_t = void (*)(void *param); +void ln_ble_evt_mgr_reg_evt(int evt_id, ble_evt_cb_t cb); + } // extern "C" // ln_bd_addr_v_t mirrors the SDK's ln_bd_addr_t (ln_ble_app_defines.h) and is @@ -64,9 +73,10 @@ static_assert(alignof(struct ln_bd_addr_v_t) == 1, "ln_bd_addr_v_t must stay byt // CLK_G_BLE — hal/hal_clock.h clock gate bit for the BLE block // BLE_EVT_ID_SCAN_REPORT — ble/ble_evt.h event id for scan reports // GAPM_* — ble/mac/ble/hl/api/gapm_task.h, enums gapm_scan_type / -// gapm_dup_filter_pol / gapm_scan_prop +// gapm_dup_filter_pol / gapm_scan_prop / gapm_adv_report_info // --------------------------------------------------------------------------- static constexpr uint32_t CLK_G_BLE = 1u << 0; +static constexpr int BLE_EVT_ID_SCAN_REPORT = 3; // WiFi/BLE packet-traffic-indication (PTI) arbitration register. The LN882H SDK // exposes no symbolic name for this register; the address and value replicate @@ -93,6 +103,20 @@ static constexpr uint8_t GAPM_SCAN_TYPE_OBSERVER = 2; static constexpr uint8_t GAPM_DUP_FILT_DIS = 0; // gapm_scan_prop bits: PHY_1M = 1<<0, PHY_CODED = 1<<1, ACTIVE_1M = 1<<2, ACTIVE_CODED = 1<<3. static constexpr uint8_t GAPM_SCAN_PROP_PHY_1M_BIT = 1 << 0; +static constexpr uint8_t GAPM_SCAN_PROP_ACTIVE_1M_BIT = 1 << 2; + +// GAPM extended-advertising report types (bits 2:0 of ble_scan_report_t::info). +// 0 = ADV_EXT (extended advertisement), 1 = ADV_LEG (legacy advertisement), +// 2 = SCAN_RSP_EXT (scan response to extended adv), 3 = SCAN_RSP_LEG (scan response to legacy adv). +static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_EXT = 2; +static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_LEG = 3; +// Bit 5 of ble_scan_report_t::info: the advertisement is scannable, i.e. a scan +// response may follow (enum gapm_adv_report_info, GAPM_REPORT_INFO_SCAN_ADV_BIT). +static constexpr uint8_t GAPM_REPORT_INFO_SCAN_ADV_BIT = 1u << 5; + +// --------------------------------------------------------------------------- +// SDK struct layouts +// --------------------------------------------------------------------------- // Scan parameter block passed to ln_ble_scan_start(); mirrors the SDK layout, // with the pad byte explicit so the whole block zero-initialises. @@ -110,6 +134,29 @@ struct le_scan_parameters_t { // NOLINT(readability-identifier-naming) - mirror static_assert(sizeof(le_scan_parameters_t) == 8, "le_scan_parameters_t must match the SDK layout"); static_assert(offsetof(le_scan_parameters_t, scan_intv) == 4, "unexpected padding in le_scan_parameters_t"); +// Scan report delivered by the BLE_EVT_ID_SCAN_REPORT event. Layout verified on +// hardware against the prebuilt BLE stack LibreTiny links: its report carries no +// PHY fields and stores the advertisement data inline (flexible array), unlike +// the newer upstream SDK header (which adds phy_prim/phy_second and a data pointer). +struct ble_scan_report_t { // NOLINT(readability-identifier-naming) - mirrors the SDK type name + uint8_t actv_idx; + uint8_t info; + uint8_t trans_addr_type; + uint8_t trans_addr[6]; + uint8_t target_addr_type; + uint8_t target_addr[6]; + int8_t tx_pwr; + int8_t rssi; // signed dBm, range -127..+20 (ble_evt_scan_report_t from ln_ble_event_manager.h) + uint16_t length; + uint8_t data[0]; +}; +// Pin the layout of the hand-mirrored report struct too: the comment above +// notes a newer SDK header uses a different layout (PHY fields + data pointer), +// so silent drift here would corrupt every decoded advertisement. +static_assert(sizeof(ble_scan_report_t) == 20, "ble_scan_report_t must match the linked BLE stack's layout"); +static_assert(offsetof(ble_scan_report_t, length) == 18, "unexpected padding in ble_scan_report_t"); +static_assert(offsetof(ble_scan_report_t, data) == 20, "advertisement data must follow the header inline"); + // --------------------------------------------------------------------------- // __sprintf weak stub // @@ -133,11 +180,86 @@ namespace esphome::ln882h_ble { static const char *const TAG = "ln882h_ble"; +// The SDK event callback is a plain C function pointer with no user argument, +// so it reaches the (single) component instance through a file-static pointer. +static LN882HBLE *s_ble = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +// Scan parameter blocks handed to ln_ble_scan_start(void *). static storage: +// the SDK may retain the pointer past the call (the block travels into a GAPM +// message consumed later by the rw task), so a stack-local would leave the +// controller reading a dead frame. Double-buffered: consecutive starts (the +// enable() probe followed by the first real scan, or a parameter restart) +// alternate blocks, so a rewrite can never race a previous block that is still +// in flight — correct under either reading of SDK retention. All writers run +// on the main task. +static le_scan_parameters_t s_scan_params[2]{}; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static uint8_t s_scan_params_idx = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +static le_scan_parameters_t *next_scan_params() { + s_scan_params_idx ^= 1; + return &s_scan_params[s_scan_params_idx]; +} + +// --------------------------------------------------------------------------- +// Scan-report event callback — runs in the SDK's rw task context. +// Decode the report (hardware-verified struct layout + the RSSI sign fix), +// copy it into the queue and return; all dispatch happens in loop() on the +// main task. +// --------------------------------------------------------------------------- +static void ble_scan_callback(void *param) { + if (s_ble == nullptr || param == nullptr) + return; + const auto *info = reinterpret_cast(param); + + // Fill the pool slot in place (the bk72xx_ble shape): no report on the rw + // task's stack — its size is fixed by the prebuilt stack — one copy of the + // payload instead of two, and only data_len bytes ever leave this frame. + BLEScanReport *slot = s_ble->allocate_scan_report(); + if (slot == nullptr) + return; // pool exhausted — counted as dropped in allocate_scan_report() + + const uint8_t report_type = info->info & 0x07; + + // BLE RSSI sign fix. The LN882H controller intermittently reports the RSSI with + // a flipped sign: a real -58 dBm arrives as +58, above the SDK's documented + // -127..+20 dBm maximum. Recover it by negating any value above +20 (verified + // on-device: the out-of-range positives cluster at the magnitude of each + // device's real readings). This is the ONLY LN882H-specific RSSI handling — + // downstream the value is used exactly like on ESP32. + const int8_t raw = info->rssi; + + memcpy(slot->mac, info->trans_addr, 6); + slot->rssi = (raw > 20) ? static_cast(-raw) : raw; + slot->addr_type = info->trans_addr_type; + slot->is_scan_response = report_type == GAPM_REPORT_TYPE_SCAN_RSP_EXT || report_type == GAPM_REPORT_TYPE_SCAN_RSP_LEG; + slot->scannable = (info->info & GAPM_REPORT_INFO_SCAN_ADV_BIT) != 0; + slot->data_len = (info->length <= sizeof(slot->data)) ? static_cast(info->length) + : static_cast(sizeof(slot->data)); + memcpy(slot->data, info->data, slot->data_len); + + s_ble->push_scan_report(slot); +} + +BLEScanReport *LN882HBLE::allocate_scan_report() { + BLEScanReport *slot = this->report_pool_.allocate(); + if (slot == nullptr) { + // Pool exhausted — the queue is full; count and drop. + this->report_queue_.increment_dropped_count(); + } + return slot; +} + +void LN882HBLE::push_scan_report(BLEScanReport *report) { + // Cannot fail: the pool is sized to the queue capacity. + this->report_queue_.push(report); +} + // --------------------------------------------------------------------------- // Component lifecycle // --------------------------------------------------------------------------- void LN882HBLE::setup() { + s_ble = this; // Resolve the MAC early so get_mac_lsb_first() is valid for consumers before // the stack is up. The KV load also happens here (no stack dependency). this->resolve_mac_(); @@ -175,23 +297,48 @@ void LN882HBLE::enable() { delay(10); // Prime the scan activity with a short probe start/stop — the SDK's scan - // manager completes activity creation on the first start. - // static: its address is handed to ln_ble_scan_start(void *), which may - // retain it past this call. - static le_scan_parameters_t probe_p{}; - probe_p.type = GAPM_SCAN_TYPE_OBSERVER; - probe_p.prop = GAPM_SCAN_PROP_PHY_1M_BIT; - probe_p.dup_filt_pol = GAPM_DUP_FILT_DIS; - probe_p.scan_intv = 160; - probe_p.scan_wd = 16; - ln_ble_scan_start(&probe_p); + // manager completes activity creation on the first start. Uses the shared + // static parameter block (see s_scan_params for the lifetime rationale). + le_scan_parameters_t *probe = next_scan_params(); + probe->type = GAPM_SCAN_TYPE_OBSERVER; + probe->prop = GAPM_SCAN_PROP_PHY_1M_BIT; + probe->dup_filt_pol = GAPM_DUP_FILT_DIS; + probe->scan_intv = 160; + probe->scan_wd = 16; + ln_ble_scan_start(probe); delay(10); ln_ble_scan_stop(); + // Register the scan-report event exactly once, after the event manager is up. + // Repeated registration corrupts the SDK's event registry (verified on + // hardware), which is why this lives here and not in scan_start(). + ln_ble_evt_mgr_reg_evt(BLE_EVT_ID_SCAN_REPORT, ble_scan_callback); + this->state_ = BLEComponentState::ACTIVE; ESP_LOGD(TAG, "BLE stack initialised"); } +void LN882HBLE::loop() { + // Drain the lock-free ring filled by the rw task; all per-report work runs + // here on the main task, then the report returns to the pool. + BLEScanReport *report = this->report_queue_.pop(); + if (report == nullptr) + return; + do { +#ifdef LN882H_BLE_SCAN_LISTENER_COUNT + for (auto *listener : this->scan_listeners_) + listener->on_scan_report(*report); +#endif + this->report_pool_.release(report); + } while ((report = this->report_queue_.pop()) != nullptr); + + // Log dropped reports — only reachable when reports were processed; drops can + // only occur while the queue is full, and only this loop drains it. + uint16_t dropped = this->report_queue_.get_and_reset_dropped_count(); + if (dropped > 0) + ESP_LOGW(TAG, "Dropped %u scan reports due to queue overflow", dropped); +} + void LN882HBLE::get_mac_lsb_first(uint8_t out[6]) const { memcpy(out, this->ble_mac_, sizeof(this->ble_mac_)); } void LN882HBLE::dump_config() { @@ -246,6 +393,50 @@ void LN882HBLE::resolve_mac_() { memcpy(this->ble_mac_, bt_addr.addr, 6); } +// --------------------------------------------------------------------------- +// Controller scan primitives +// --------------------------------------------------------------------------- + +void LN882HBLE::scan_start(uint16_t interval, uint16_t window, bool active) { + if (!this->is_active()) + this->enable(); + + if (this->scanning_) { + // Already scanning - stop first so this call cleanly restarts with the new + // parameters (re-entry guard). Give the GAPM stop the same settle time + // enable() grants between consecutive GAPM operations before restarting. + this->scan_stop(); + delay(10); // NOLINT — restart-only, mirrors enable()'s inter-operation settle + } + + // Double-buffered static block — see s_scan_params for the lifetime rationale. + le_scan_parameters_t *p = next_scan_params(); + p->dup_filt_pol = GAPM_DUP_FILT_DIS; + p->type = GAPM_SCAN_TYPE_OBSERVER; + p->scan_intv = interval; + p->scan_wd = window; + // Legacy 1M PHY only: consumers size their buffers for legacy advertisements + // (62 B); coded/extended PHY (up to 255 B) would be silently truncated. + p->prop = GAPM_SCAN_PROP_PHY_1M_BIT; + if (active) + p->prop |= GAPM_SCAN_PROP_ACTIVE_1M_BIT; + + ln_ble_scan_start(p); + // ln_ble_scan_start() returns void, so this tracks the requested state, not a + // confirmed one — a controller-side failure surfaces as an idle scanner (no + // reports), which the consumer's start retry/backoff owns. + this->scanning_ = true; +} + +void LN882HBLE::scan_stop() { + // No-op when idle, as documented: the guard keeps a redundant SDK stop off + // the GAPM path (scan_start()'s re-entry guard calls this while scanning). + if (!this->scanning_) + return; + ln_ble_scan_stop(); + this->scanning_ = false; +} + } // namespace esphome::ln882h_ble #endif // USE_LN882H_BLE diff --git a/esphome/components/ln882h_ble/ln882h_ble.h b/esphome/components/ln882h_ble/ln882h_ble.h index 0e3e311341..e957cdd41e 100644 --- a/esphome/components/ln882h_ble/ln882h_ble.h +++ b/esphome/components/ln882h_ble/ln882h_ble.h @@ -5,6 +5,9 @@ #ifdef USE_LN882H_BLE #include "esphome/core/component.h" +#include "esphome/core/event_pool.h" +#include "esphome/core/helpers.h" +#include "esphome/core/lock_free_queue.h" #include @@ -16,9 +19,52 @@ enum class BLEComponentState : uint8_t { ACTIVE, }; +/// One scan report from the controller, decoded from the SDK's rw-task event +/// (RSSI already sign-corrected). +struct BLEScanReport { + uint8_t mac[6]; // as the controller delivers it (LSB-first) + int8_t rssi; // signed dBm (-127..+20) + uint8_t addr_type; + bool is_scan_response; // report is a scan response (active scan) + bool scannable; // advertisement may be followed by a scan response + uint8_t data_len; // bytes valid in data[] (<= 62) + // Each report carries ONE frame — a legacy advertisement (<=31 B) or a scan + // response (<=31 B) — delivered split, exactly as the SDK reports them. The + // TRACKER merges the pair into a single frame before any consumer sees it + // (Bluedroid semantics, HubCapabilities::merges_scan_response). 62 is twice + // the legacy maximum: defensive headroom for the data_len clamp, and the + // same width as the merged framing downstream. + uint8_t data[62]; + + // EventPool contract: nothing is heap-allocated inside a report. + void release() {} +}; + +/// Consumer interface for controller scan reports. on_scan_report() always runs +/// on the ESPHome main task: reports are queued from the SDK's rw task and +/// drained by the controller's loop(), so consumers never deal with cross-task +/// state (the esp32_ble event-queue pattern). +class BLEScanListener { + public: + virtual void on_scan_report(const BLEScanReport &report) = 0; + + protected: + ~BLEScanListener() = default; // deletion via this interface is not part of the contract +}; + +// Maximum reports buffered between the rw task and loop(). Sized from the +// measured worst case, not copied: WiFi/BLE coexistence delays rw-task report +// delivery by up to ~136 ms on this device (see the tracker's pending-adv +// timeout rationale), and a busy 2.4 GHz environment delivers ~200-400 +// reports/s — a stall plus one loop() interval buffers ~30-60 reports, so 63 +// usable slots absorb it with margin. ~4.7 KB at high water, reached only +// during such stalls. +static constexpr uint8_t MAX_SCAN_REPORT_QUEUE_SIZE = 64; + class LN882HBLE final : public Component { public: void setup() override; + void loop() override; void dump_config() override; float get_setup_priority() const override; @@ -37,12 +83,53 @@ class LN882HBLE final : public Component { /// (the bk72xx sibling exposes the same accessor). void get_mac_lsb_first(uint8_t out[6]) const; +#ifdef LN882H_BLE_SCAN_LISTENER_COUNT + /// Register a consumer for scan reports (delivered on the main task via loop()). + /// Storage is codegen-sized: the consumer's codegen requests a slot via + /// request_scan_listener_slot(), which emits LN882H_BLE_SCAN_LISTENER_COUNT. + void register_scan_listener(BLEScanListener *listener) { this->scan_listeners_.push_back(listener); } +#endif + + /// Start the controller scan. Interval/window are in BLE units (0.625 ms); + /// active enables scan requests on the 1M PHY. Enables the stack first if + /// needed. Scans the legacy 1M PHY only (extended/coded PHY advertisements + /// exceed the legacy 62-byte framing consumers are sized for). + void scan_start(uint16_t interval, uint16_t window, bool active); + /// Stop the controller scan (no-op when not scanning). + void scan_stop(); + + /// Internal, SDK rw-task event-callback context: allocate a pool slot for a + /// scan report. Returns nullptr (and counts the drop) when the queue is full; + /// the callback fills the slot in place — no intermediate copy. + BLEScanReport *allocate_scan_report(); + /// Internal: hand a filled slot to the main-task queue (cannot fail — the + /// pool is sized to the queue capacity). + void push_scan_report(BLEScanReport *report); + protected: void resolve_mac_(); +#ifdef LN882H_BLE_SCAN_LISTENER_COUNT + // Codegen-sized: no heap allocation, no std::vector template instantiation — + // the same StaticVector pattern as the tracker's ble_device_base listeners. + StaticVector scan_listeners_; +#endif + // Report ring: the SDK event callback (rw task) allocates a report from the + // pool, fills it and pushes the pointer; loop() pops, dispatches and releases. + // Lock-free SPSC, zero allocation at steady state — the esp32_ble pattern. + // Overflow drops the NEWEST report (allocate fails, producer counts and + // returns) — under a coexistence stall the freshest advertisements are lost + // while queued ones drain. Deliberate: matches esp32_ble, and dropping from + // the head would need consumer-side locking this design exists to avoid. + esphome::LockFreeQueue report_queue_; + // Pool sized to queue capacity (SIZE-1): the ring reserves one slot, so + // allocate() returns nullptr before push() can fail. This prevents leaking a + // pool slot on a failed push and keeps release() off the producer path. + esphome::EventPool report_pool_; uint8_t ble_mac_[6]{0}; // controller (LSB-first) order, as ln_bd_addr_t stores it BLEComponentState state_{BLEComponentState::STATE_OFF}; bool enable_on_boot_{false}; + bool scanning_{false}; // controller scan running (re-entry guard for scan_start) }; } // namespace esphome::ln882h_ble diff --git a/esphome/core/defines.h b/esphome/core/defines.h index b184300681..ed6a117622 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -455,6 +455,7 @@ #ifdef USE_LIBRETINY #define USE_BK72XX_BLE #define USE_LN882H_BLE +#define LN882H_BLE_SCAN_LISTENER_COUNT 1 #define USE_CAPTIVE_PORTAL #define USE_WIFI_SCAN_RESULTS_LOCK #define USE_SOCKET_IMPL_LWIP_SOCKETS From dafc86d960695e8971e8510422365ac7d067d653 Mon Sep 17 00:00:00 2001 From: Samuel Sieb Date: Mon, 3 Aug 2026 20:36:52 -0700 Subject: [PATCH 1201/1815] [esp32] explicitly disable BLE 5.0 (#18047) Co-authored-by: Samuel Sieb --- esphome/components/esp32/__init__.py | 15 ++++++--------- esphome/components/esp32_ble/__init__.py | 2 +- esphome/components/esp32_ble_beacon/__init__.py | 2 +- tests/component_tests/esp32/test_esp32.py | 13 +++---------- 4 files changed, 11 insertions(+), 21 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 40a03c97b8..8e30d21776 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -628,7 +628,6 @@ class NetworkSdkconfigData: wifi_ap: bool = False # WiFi AP mode configured ethernet: bool = False # Ethernet component active bluetooth: bool = False # any BLE component active - ble_42: bool = False # BLE 4.2 features needed software_coexistence: bool = False # WiFi/BT software coexistence requested # esp32 advanced enable_lwip_dhcp_server option (True/False/None=unset) enable_lwip_dhcp_server: bool | None = None @@ -654,12 +653,10 @@ def request_ethernet() -> None: _network_sdkconfig().ethernet = True -def request_bluetooth(ble_42: bool = False) -> None: - """Request the Bluetooth controller. Pass ble_42=True for 4.2 features.""" +def request_bluetooth() -> None: + """Request the Bluetooth controller.""" net = _network_sdkconfig() net.bluetooth = True - if ble_42: - net.ble_42 = True def request_software_coexistence() -> None: @@ -2055,12 +2052,12 @@ async def _reconcile_network_sdkconfig() -> None: if name not in opts: add_idf_sdkconfig_option(name, value) - # Bluetooth: only ever enable when requested. The IDF default is off and - # nothing sets these False today, so never write False here. + # Bluetooth: only ever enable when requested. The IDF default is off. + # According to the IDF docs, only one of 4.2 or 5.0 should be enabled. if net.bluetooth: set_opt("CONFIG_BT_ENABLED", True) - if net.ble_42: - set_opt("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) + set_opt("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) + set_opt("CONFIG_BT_BLE_50_FEATURES_SUPPORTED", False) # WiFi stack: disable only when Ethernet is present and WiFi is not. WiFi # relies on the IDF default (enabled), so it is never written True here. diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index c8613963b9..72cb10caac 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -576,7 +576,7 @@ async def to_code(config): max_connections = config.get(CONF_MAX_CONNECTIONS, DEFAULT_MAX_CONNECTIONS) cg.add_define("USE_ESP32_BLE_MAX_CONNECTIONS", max_connections) - request_bluetooth(ble_42=True) + request_bluetooth() # When PSRAM and BT are used together, Bluedroid should prefer SPIRAM for # heap allocations and use dynamic (heap-based) environment memory tables diff --git a/esphome/components/esp32_ble_beacon/__init__.py b/esphome/components/esp32_ble_beacon/__init__.py index 7a59cce19b..d762255040 100644 --- a/esphome/components/esp32_ble_beacon/__init__.py +++ b/esphome/components/esp32_ble_beacon/__init__.py @@ -86,4 +86,4 @@ async def to_code(config): cg.add_define("USE_ESP32_BLE_ADVERTISING") - request_bluetooth(ble_42=True) + request_bluetooth() diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index c374e7c964..3f4d71ef2a 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -472,26 +472,18 @@ def test_flash_mode_unset_leaves_defaults( ), pytest.param( PlatformFramework.ESP32_IDF, - NetworkSdkconfigData( - wifi=True, bluetooth=True, ble_42=True, software_coexistence=True - ), + NetworkSdkconfigData(wifi=True, bluetooth=True, software_coexistence=True), {}, { "CONFIG_BT_ENABLED": True, "CONFIG_BT_BLE_42_FEATURES_SUPPORTED": True, + "CONFIG_BT_BLE_50_FEATURES_SUPPORTED": False, "CONFIG_SW_COEXIST_ENABLE": True, "CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False, "CONFIG_LWIP_DHCPS": False, }, id="idf_wifi_ble_tracker_coexistence", ), - pytest.param( - PlatformFramework.ESP32_IDF, - NetworkSdkconfigData(bluetooth=True), - {}, - {"CONFIG_BT_ENABLED": True}, - id="idf_ble_server_only_no_ble42", - ), # --- IDF: user sdkconfig_options always win --- pytest.param( PlatformFramework.ESP32_IDF, @@ -612,6 +604,7 @@ def test_network_wifi_ble_coexistence_reconciles_end_to_end( sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] assert sdkconfig.get("CONFIG_BT_ENABLED") is True assert sdkconfig.get("CONFIG_BT_BLE_42_FEATURES_SUPPORTED") is True + assert sdkconfig.get("CONFIG_BT_BLE_50_FEATURES_SUPPORTED") is False assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is True assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False From 4ceae9cf85c8364c48dc758505dfe7aa51a63757 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:18:46 +1200 Subject: [PATCH 1202/1815] [ci] Replace pre-commit with prek (#18029) --- .github/workflows/ci.yml | 76 ++++++----------------- .github/workflows/sync-device-classes.yml | 22 +++---- AGENTS.md | 6 +- requirements_test.txt | 2 +- script/determine-jobs.py | 8 +-- script/setup | 6 +- script/setup.bat | 6 +- 7 files changed, 49 insertions(+), 77 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e020e90018..e37cde5a47 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,7 +65,7 @@ jobs: python -m venv venv . venv/bin/activate python --version - uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit + uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt uv pip install -e . determine-jobs: @@ -208,69 +208,34 @@ jobs: run: script/ci-suggest-changes if: always() - pre-commit-ci-lite: - name: pre-commit.ci lite + lint-format: + name: Check lint and formatting runs-on: ubuntu-latest needs: - - common - determine-jobs if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Restore Python - uses: ./.github/actions/restore-python + - name: Run prek + uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0 with: - python-version: ${{ env.DEFAULT_PYTHON }} - cache-key: ${{ needs.common.outputs.cache-key }} - # Inlined from esphome/pre-commit-action with a restore-only cache - # step: the pre-commit-seed-cache job owns saving this cache, so - # pull request runs never write per-PR copies. - - name: Restore pre-commit cache - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.cache/pre-commit - # Must match the key pre-commit-seed-cache saves - # yamllint disable-line rule:line-length - key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} - - name: Run pre-commit + # Keep in sync with requirements_test.txt. + prek-version: "0.4.11" + # This job only runs on pull requests, so nothing ever populates + # the cache on dev. Every run would miss and then write a per-pull + # request copy, which is what the old seed-cache job existed to + # avoid. Building the hooks from scratch takes seconds, so skip it. + cache: false env: - SKIP: pylint,ci-custom - run: | - python -m pip install pre-commit - pre-commit run --show-diff-on-failure --color=always --all-files + PREK_SKIP: pylint,ci-custom + # Pushes any fixes the hooks made back to the pull request. This step + # must keep its default name: the GitHub App that performs the push + # locates the workflow run by that name. - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 if: always() - - pre-commit-seed-cache: - name: Seed pre-commit cache - runs-on: ubuntu-latest - needs: - - common - # Saves a dev-scoped pre-commit cache that pull request runs can - # restore, since pre-commit.ci lite itself never runs on dev pushes. - if: github.event_name == 'push' && github.ref == 'refs/heads/dev' - steps: - - name: Check out code from GitHub - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Restore Python - uses: ./.github/actions/restore-python with: - python-version: ${{ env.DEFAULT_PYTHON }} - cache-key: ${{ needs.common.outputs.cache-key }} - - name: Cache pre-commit environments - id: cache-pre-commit - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.cache/pre-commit - # Must match the restore key in pre-commit-ci-lite - # yamllint disable-line rule:line-length - key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} - - name: Install pre-commit hook environments - if: steps.cache-pre-commit.outputs.cache-hit != 'true' - run: | - python -m pip install pre-commit - pre-commit install-hooks + msg: apply automatic formatting fixes pytest: name: Run pytest @@ -1456,16 +1421,15 @@ jobs: ci-status: name: CI Status runs-on: ubuntu-24.04 - # Listed in the same order the jobs are defined above. Two jobs are + # Listed in the same order the jobs are defined above. One job is # deliberately left out: "benchmarks" reports through CodSpeed rather than - # this check, and "pre-commit-seed-cache" only populates a cache on pushes - # to dev. + # this check. needs: - common - determine-jobs - ci-custom - pylint - - pre-commit-ci-lite + - lint-format - pytest - codecov-empty-upload - integration-tests diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 5d250b97eb..a299e76584 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -44,7 +44,7 @@ jobs: - name: Set up uv # An order of magnitude faster than pip on cold boots, with its # own wheel cache. ``--system`` (below) installs into the - # setup-python interpreter so subsequent ``pre-commit`` / + # setup-python interpreter so subsequent ``prek`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 @@ -58,19 +58,19 @@ jobs: - name: Install Home Assistant run: | uv pip install --system -e lib/home-assistant - uv pip install --system -r requirements.txt -r requirements_test.txt pre-commit + uv pip install --system -r requirements.txt -r requirements_test.txt - name: Sync run: | python ./script/sync-device_class.py - - name: Apply pre-commit auto-fixes + - name: Apply prek auto-fixes # First pass: let formatters (ruff, end-of-file-fixer, etc.) modify - # files. pre-commit exits non-zero whenever a hook touches anything, + # files. prek exits non-zero whenever a hook touches anything, # which would otherwise abort the workflow before the auto-fixes # can flow into the sync PR. # - # SKIP: + # PREK_SKIP: # - no-commit-to-branch is a local guard against committing on # dev/release/beta; CI runs on dev by definition, and # peter-evans/create-pull-request creates the branch itself. @@ -79,18 +79,18 @@ jobs: # the runtime deps (HA + requirements*.txt); main CI already # gates pylint on real PRs. env: - SKIP: pylint,no-commit-to-branch - run: python script/run-in-env.py pre-commit run --all-files || true + PREK_SKIP: pylint,no-commit-to-branch + run: python script/run-in-env.py prek run --all-files || true - - name: Verify pre-commit clean + - name: Verify prek clean # Second pass: re-run all hooks against the now-fixed tree. # Auto-fixers exit 0 (nothing to change); any remaining failure # from a check-only hook (flake8 / yamllint / ci-custom) is a - # real issue and fails the workflow loudly. Same SKIP list as + # real issue and fails the workflow loudly. Same PREK_SKIP list as # above for the same reasons. env: - SKIP: pylint,no-commit-to-branch - run: python script/run-in-env.py pre-commit run --all-files + PREK_SKIP: pylint,no-commit-to-branch + run: python script/run-in-env.py prek run --all-files - name: Commit changes uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 diff --git a/AGENTS.md b/AGENTS.md index b067482d18..40381030cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -412,7 +412,7 @@ This document provides essential context for AI models interacting with this pro * **Configuration:** * `pyproject.toml`: Defines the Python project metadata and dependencies. * `platformio.ini`: Configures the PlatformIO build environments for different microcontrollers. - * `.pre-commit-config.yaml`: Configures the pre-commit hooks for linting and formatting. + * `.pre-commit-config.yaml`: Configures the lint and format hooks, run by `prek`. * **CI/CD Pipeline:** Defined in `.github/workflows`. * **Static Analysis & Development:** * `esphome/core/defines.h`: A comprehensive header file containing all `#define` directives that can be added by components using `cg.add_define()` in Python. This file is used exclusively for development, static analysis tools, and CI testing - it is not used during runtime compilation. When developing components that add new defines, they must be added to this file to ensure proper IDE support and static analysis coverage. The file includes feature flags, build configurations, and platform-specific defines that help static analyzers understand the complete codebase without needing to compile for specific platforms. @@ -420,7 +420,7 @@ This document provides essential context for AI models interacting with this pro ## 6. Development & Testing Workflow * **Local Development Environment:** Use the provided Docker container or create a Python virtual environment and install dependencies from `requirements_dev.txt`. -* **Running Commands:** Use the `script/run-in-env.py` script to execute commands within the project's virtual environment. For example, to run the linter: `python3 script/run-in-env.py pre-commit run`. +* **Running Commands:** Use the `script/run-in-env.py` script to execute commands within the project's virtual environment. For example, to run the linter: `python3 script/run-in-env.py prek run`. * **Testing:** * **Python:** Run unit tests with `pytest`. * **C++:** Use `clang-tidy` for static analysis. @@ -493,7 +493,7 @@ This document provides essential context for AI models interacting with this pro 1. **Fork & Branch:** Create a new branch based on the `dev` branch (always use `git checkout -b dev` to ensure you're branching from `dev`, not the currently checked out branch). 2. **Make Changes:** Adhere to all coding conventions and patterns. 3. **Test:** Create component tests for all supported platforms and run the full test suite locally. - 4. **Lint:** Run `pre-commit` to ensure code is compliant. + 4. **Lint:** Run `prek` to ensure code is compliant. 5. **Commit:** Commit your changes. There is no strict format for commit messages. 6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title must start with a `[tag]` prefix. For component work, use the component name (e.g., `[display] Fix bug`, `[abc123] Add new component`); for changes to shared/core code that isn't tied to a single component, use `[core]` (e.g., `[core] Add validator`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template. diff --git a/requirements_test.txt b/requirements_test.txt index 389bf6dbf0..943d6b597b 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -2,7 +2,7 @@ pylint==4.0.6 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating ruff==0.16.1 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating -pre-commit +prek==0.4.11 # also change in .github/workflows/ci.yml when updating # Unit tests pytest==9.1.1 diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 061485c76c..8039aff83f 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -23,7 +23,7 @@ what files have changed. It outputs JSON with the following structure: } The CI workflow uses this information to: -- Gate the unconditional jobs (ci-custom, pytest, pre-commit-ci-lite) via core_ci; +- Gate the unconditional jobs (ci-custom, pytest, lint-format) via core_ci; false when a pull_request only touches CI-irrelevant meta paths (other workflow files, .github/actions/build-image/*, .yamllint, .github/dependabot.yml, docker/**) so workflow-only PRs satisfy the required CI Status check without running the @@ -708,7 +708,7 @@ def should_run_benchmarks(branch: str | None = None) -> bool: # Files / path patterns whose changes alone don't warrant running the -# unconditional CI jobs (`ci-custom`, `pytest`, `pre-commit-ci-lite`). +# unconditional CI jobs (`ci-custom`, `pytest`, `lint-format`). # Single source of truth for what we treat as "CI-irrelevant" on # pull_request events; ci.yml used to encode this in its own # `pull_request.paths` filter, but that hid the required `CI Status` @@ -752,7 +752,7 @@ def _is_ci_irrelevant_path(path: str) -> bool: def should_run_core_ci(branch: str | None = None) -> bool: - """Determine if the unconditional CI jobs (ci-custom/pytest/pre-commit-ci-lite) should run. + """Determine if the unconditional CI jobs (ci-custom/pytest/lint-format) should run. Returns False only when every changed file is in the CI-irrelevant set above (see ``_is_ci_irrelevant_path``). Empty diffs return True so we @@ -1177,7 +1177,7 @@ def main() -> None: # Determine what should run # core_ci gates the unconditional jobs in ci.yml (ci-custom, pytest, - # pre-commit-ci-lite). Non-pull_request events (push to dev/beta/release + # lint-format). Non-pull_request events (push to dev/beta/release # and merge_group) always run them so behavior like venv-cache saves on # push to dev is preserved. event_name = os.environ.get("GITHUB_EVENT_NAME", "") diff --git a/script/setup b/script/setup index 709eaee0f3..5dfc0efe5d 100755 --- a/script/setup +++ b/script/setup @@ -25,7 +25,11 @@ fi uv pip install setuptools wheel uv pip install -e ".[dev,test]" --config-settings editable_mode=compat -pre-commit install +# --overwrite replaces any hook already in place. Without it, prek finds a +# previously installed pre-commit hook, moves it aside to +# .git/hooks/pre-commit.legacy and keeps calling it, so every commit would +# run both tools. +prek install --overwrite mkdir -p .temp diff --git a/script/setup.bat b/script/setup.bat index 003ea31b36..809d05ae93 100644 --- a/script/setup.bat +++ b/script/setup.bat @@ -17,7 +17,11 @@ pip3 install -r requirements.txt -r requirements_test.txt -r requirements_dev.tx pip3 install setuptools wheel pip3 install -e ".[dev,test]" --config-settings editable_mode=compat -pre-commit install +rem --overwrite replaces any hook already in place. Without it, prek finds a +rem previously installed pre-commit hook, moves it aside to +rem .git/hooks/pre-commit.legacy and keeps calling it, so every commit would +rem run both tools. +prek install --overwrite echo . echo . From dbf7167622ff777547de32f6e509cc1406fbac8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Tue, 4 Aug 2026 07:31:43 +0300 Subject: [PATCH 1203/1815] [bk72xx_ble] Codegen-sized StaticVector for scan listeners (#18055) --- esphome/components/bk72xx_ble/__init__.py | 20 +++++++++++++++++++ esphome/components/bk72xx_ble/bk72xx_ble.cpp | 2 ++ esphome/components/bk72xx_ble/bk72xx_ble.h | 12 +++++++++-- .../components/bk72xx_ble_tracker/__init__.py | 3 +++ esphome/core/defines.h | 1 + 5 files changed, 36 insertions(+), 2 deletions(-) diff --git a/esphome/components/bk72xx_ble/__init__.py b/esphome/components/bk72xx_ble/__init__.py index 29e2a6b13d..b5b4691eea 100644 --- a/esphome/components/bk72xx_ble/__init__.py +++ b/esphome/components/bk72xx_ble/__init__.py @@ -24,6 +24,7 @@ from esphome.components import libretiny from esphome.components.libretiny.const import FAMILY_BK7231N, FAMILY_BK7238 import esphome.config_validation as cv from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType DEPENDENCIES = ["bk72xx"] @@ -45,6 +46,23 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) +KEY_SCAN_LISTENER_COUNT = "bk72xx_ble_scan_listener_count" + + +def request_scan_listener_slot() -> None: + """Called from a consumer's codegen once per registered scan listener; sizes + the controller's StaticVector listener storage (heap-free, mirrors the + tracker's ble_device_base listener storage).""" + CORE.data[KEY_SCAN_LISTENER_COUNT] = CORE.data.get(KEY_SCAN_LISTENER_COUNT, 0) + 1 + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _add_listener_count() -> None: + # FINAL: every consumer's to_code has requested its slot by now. + if count := CORE.data.get(KEY_SCAN_LISTENER_COUNT, 0): + cg.add_define("BK72XX_BLE_SCAN_LISTENER_COUNT", count) + + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -92,3 +110,5 @@ async def to_code(config: ConfigType) -> None: ) cg.add_define("USE_BK72XX_BLE") + + CORE.add_job(_add_listener_count) diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.cpp b/esphome/components/bk72xx_ble/bk72xx_ble.cpp index a5ecaf4abb..69c7df0b96 100644 --- a/esphome/components/bk72xx_ble/bk72xx_ble.cpp +++ b/esphome/components/bk72xx_ble/bk72xx_ble.cpp @@ -179,8 +179,10 @@ void BK72xxBLE::loop() { if (report == nullptr) return; do { +#ifdef BK72XX_BLE_SCAN_LISTENER_COUNT for (auto *listener : this->scan_listeners_) listener->on_scan_report(*report); +#endif this->report_pool_.release(report); } while ((report = this->report_queue_.pop()) != nullptr); diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.h b/esphome/components/bk72xx_ble/bk72xx_ble.h index 2654f4e68e..4e615af159 100644 --- a/esphome/components/bk72xx_ble/bk72xx_ble.h +++ b/esphome/components/bk72xx_ble/bk72xx_ble.h @@ -6,10 +6,10 @@ #include "esphome/core/component.h" #include "esphome/core/event_pool.h" +#include "esphome/core/helpers.h" #include "esphome/core/lock_free_queue.h" #include -#include namespace esphome::bk72xx_ble { @@ -62,8 +62,12 @@ class BK72xxBLE final : public Component { /// Controller BLE address, least-significant octet first (BLE convention). void get_mac_lsb_first(uint8_t out[6]) const; +#ifdef BK72XX_BLE_SCAN_LISTENER_COUNT /// Register a consumer for scan reports (delivered on the main task via loop()). + /// Storage is codegen-sized: the consumer's codegen requests a slot via + /// request_scan_listener_slot(), which emits BK72XX_BLE_SCAN_LISTENER_COUNT. void register_scan_listener(BLEScanListener *listener) { this->scan_listeners_.push_back(listener); } +#endif /// Start the controller scan. Interval/window are in BLE units (0.625 ms). /// Enables the stack first if needed. Returns false on controller failure. @@ -78,7 +82,11 @@ class BK72xxBLE final : public Component { protected: void resolve_mac_(); - std::vector scan_listeners_; +#ifdef BK72XX_BLE_SCAN_LISTENER_COUNT + // Codegen-sized: no heap allocation, no std::vector template instantiation — + // the same StaticVector pattern as the tracker's ble_device_base listeners. + StaticVector scan_listeners_; +#endif // Report ring: the BDK notice callback (BLE task) allocates a report from the // pool, fills it and pushes the pointer; loop() pops, dispatches and releases. // Lock-free SPSC, zero allocation at steady state — the esp32_ble pattern. diff --git a/esphome/components/bk72xx_ble_tracker/__init__.py b/esphome/components/bk72xx_ble_tracker/__init__.py index e53e8e13d7..05dd4a7a3c 100644 --- a/esphome/components/bk72xx_ble_tracker/__init__.py +++ b/esphome/components/bk72xx_ble_tracker/__init__.py @@ -69,6 +69,9 @@ async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_BK72XX_BLE_ID]) cg.add(var.set_parent(parent)) + # The tracker registers itself as a controller scan listener in setup(); + # request the codegen-sized StaticVector slot for it. + bk72xx_ble.request_scan_listener_slot() # Get notified when an OTA update starts, to pause scanning (esp32_ble_tracker parity) ota.request_ota_state_listeners() diff --git a/esphome/core/defines.h b/esphome/core/defines.h index ed6a117622..d946b106d1 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -454,6 +454,7 @@ #ifdef USE_LIBRETINY #define USE_BK72XX_BLE +#define BK72XX_BLE_SCAN_LISTENER_COUNT 1 #define USE_LN882H_BLE #define LN882H_BLE_SCAN_LISTENER_COUNT 1 #define USE_CAPTIVE_PORTAL From 61a4722c9370d1bc527d9a70a1cab961d6e5f360 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 4 Aug 2026 07:15:31 -0700 Subject: [PATCH 1204/1815] [modbus_controller] Add bus-fairness integration test (xfail until #11781) (#17345) Co-authored-by: Claude Opus 4.8 --- .../fixtures/uart_mock_modbus_fairness.yaml | 125 ++++++++++++++++++ tests/integration/test_uart_mock_modbus.py | 67 +++++++++- 2 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 tests/integration/fixtures/uart_mock_modbus_fairness.yaml diff --git a/tests/integration/fixtures/uart_mock_modbus_fairness.yaml b/tests/integration/fixtures/uart_mock_modbus_fairness.yaml new file mode 100644 index 0000000000..e2918c82dd --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_fairness.yaml @@ -0,0 +1,125 @@ +esphome: + name: uart-mock-modbus-fairness + +host: +api: +logger: + # DEBUG (not VERBOSE) keeps the log volume manageable while both controllers + # hammer the bus at a high rate. + level: DEBUG + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +# Counters for the number of requests seen on the bus for each device address. +globals: + - id: req_count_1 + type: int + initial_value: "0" + - id: req_count_2 + type: int + initial_value: "0" + +uart_mock: + - id: virtual_uart + baud_rate: 9600 + # auto_start so the mock is ready to deliver injected responses. Polling + # itself is gated by the Start button below. + auto_start: true + debug: + on_tx: + - then: + # Count each outgoing request by device address (byte 0 of the frame). + - lambda: |- + if (data.empty()) + return; + if (data[0] == 0x01) { + id(req_count_1) += 1; + id(requests_1).publish_state(id(req_count_1)); + } else if (data[0] == 0x02) { + id(req_count_2) += 1; + id(requests_2).publish_state(id(req_count_2)); + } + # Reply directly with a canned, CRC-correct "read holding register" + # response for whichever device was addressed (both controllers only + # ever issue this one fixed request, so the responses are constant). + - uart_mock.inject_rx: + id: virtual_uart + data: !lambda |- + if (!data.empty() && data[0] == 0x01) + return {0x01, 0x03, 0x02, 0x00, 0x6F, 0xF8, 0x68}; // value 111 + if (!data.empty() && data[0] == 0x02) + return {0x02, 0x03, 0x02, 0x00, 0xDE, 0x7C, 0x1C}; // value 222 + return {}; + +modbus: + - uart_id: virtual_uart + id: virtual_modbus_client + role: client + turnaround_time: 15ms #This is longer than the polling interval to cause contention + +# Two controllers sharing one client bus, each polling a different device. +# Polling is started by the test (update_interval: never until then) so counting +# only begins once the API client has subscribed. +modbus_controller: + - address: 1 + modbus_id: virtual_modbus_client + id: modbus_controller_1 + update_interval: never + - address: 2 + modbus_id: virtual_modbus_client + id: modbus_controller_2 + update_interval: never + +sensor: + # These sensors define the register range each controller polls (and so drive + # the requests). Their values are not checked by the test. + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: reg_1 + address: 0x01 + register_type: holding + value_type: U_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_2 + name: reg_2 + address: 0x01 + register_type: holding + value_type: U_WORD + # Request counters exposed to the test. Updated manually from the on_tx hook. + - platform: template + name: requests_1 + id: requests_1 + update_interval: never + - platform: template + name: requests_2 + id: requests_2 + update_interval: never + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: |- + // Poll much faster than the bus can service so both controllers always + // have a request pending and must contend for the bus. + id(modbus_controller_1).set_update_interval(10); + id(modbus_controller_1).start_poller(); + id(modbus_controller_2).set_update_interval(10); + id(modbus_controller_2).start_poller(); + - platform: template + name: "Stop Scenario" + id: stop_scenario_btn + on_press: + - lambda: |- + id(modbus_controller_1).stop_poller(); + id(modbus_controller_2).stop_poller(); diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index ce707fb0e0..b7103b62dd 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -9,6 +9,10 @@ test_uart_mock_modbus_no_threshold : Test modbus with no rx_full_threshold set (simulating USB UART / non-hardware UART). Verifies the 50ms fallback timeout handles chunked data with USB packet gaps. +test_uart_mock_modbus_fairness : + Two controllers sharing one client bus, both polling far faster than the bus + can service. Verifies the hub schedules them fairly (request counts within 1). + """ from __future__ import annotations @@ -17,7 +21,7 @@ import asyncio from collections.abc import Callable from dataclasses import dataclass -from aioesphomeapi import NumberInfo +from aioesphomeapi import ButtonInfo, NumberInfo import pytest from .state_utils import SensorTracker, find_entity @@ -446,3 +450,64 @@ async def test_uart_mock_modbus_shared_address( await tracker.setup_and_start_scenario(client) await tracker.await_all(futures) _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + +@pytest.mark.xfail( + strict=True, + reason="Fair bus scheduling across controllers sharing one client hub " + "requires the modbus_controller refactor in esphome#11781. On dev the " + "controllers each queue independently and contend for the bus, so the " + "request counts diverge. Expected to XPASS (and this marker removed) once " + "that refactor lands.", +) +@pytest.mark.asyncio +async def test_uart_mock_modbus_fairness( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Two controllers sharing one bus should get a fair share of it. + + Both controllers poll different devices (addresses 1 and 2) on the same + client hub, far faster than the bus can service, so they continually + contend for it. The on_tx hook in the fixture counts the requests issued + for each address. With fair scheduling in the modbus hub, neither + controller should starve the other: the two request counts must end up + within 1 of each other. + """ + + tracker = SensorTracker(["requests_1", "requests_2"]) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities = await tracker.setup_and_start_scenario(client) + + # Let both controllers hammer the bus for a while. + await asyncio.sleep(2.0) + + # Stop polling so the counters settle to a final, stable value (state + # coalescing means intermediate values may be skipped, but the final + # value is always delivered once changes stop). + stop_btn = find_entity(entities, "stop_scenario", ButtonInfo) + assert stop_btn is not None, "Stop Scenario button not found" + client.button_command(stop_btn.key) + await asyncio.sleep(0.5) + + assert tracker.sensor_states["requests_1"], "controller 1 issued no requests" + assert tracker.sensor_states["requests_2"], "controller 2 issued no requests" + count_1 = tracker.sensor_states["requests_1"][-1] + count_2 = tracker.sensor_states["requests_2"][-1] + + # Both must have polled repeatedly, otherwise "fairness" is meaningless. + assert count_1 >= 5 and count_2 >= 5, ( + f"expected both controllers to poll repeatedly, " + f"got controller 1={count_1}, controller 2={count_2}" + ) + # Fair scheduling: the bus alternates between the two pending requests, + # so the counts can differ by at most one in-flight request. + assert abs(count_1 - count_2) <= 1, ( + f"controllers did not get a fair share of the bus: " + f"controller 1 issued {count_1}, controller 2 issued {count_2}" + ) From c6a37847e6efcdcec33c4e842b2545605ca592b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Tue, 4 Aug 2026 18:18:31 +0300 Subject: [PATCH 1205/1815] [ble_device_base] Scan-mode request contract on BLEHub (#18061) --- .../bk72xx_ble_tracker/bk72xx_ble_tracker.h | 5 ++ esphome/components/ble_device_base/ble_hub.h | 11 ++++ .../rp2_ble_tracker/rp2_ble_tracker.cpp | 25 ++++++++ .../rp2_ble_tracker/rp2_ble_tracker.h | 1 + .../test_scan_mode_request.cpp | 59 +++++++++++++++++++ 5 files changed, 101 insertions(+) create mode 100644 tests/components/ble_device_base/test_scan_mode_request.cpp diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h index 3d32798622..435e953655 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h @@ -93,6 +93,11 @@ class BK72xxBLETracker : public Component, // receiver merges per address (Home Assistant does). No GATT client either. return {.active_scan = false, .merges_scan_response = false, .gatt = false}; } + bool request_scan_mode(bool active) override { + // Passive-only controller: a passive request is already honored, an active + // one cannot be. + return !active; + } // The controller stores the address LSB-first (BLE convention); the contract // wants printable (MSB-first) order. void get_adapter_mac(uint8_t out[6]) override { diff --git a/esphome/components/ble_device_base/ble_hub.h b/esphome/components/ble_device_base/ble_hub.h index c02b491237..6f7e580975 100644 --- a/esphome/components/ble_device_base/ble_hub.h +++ b/esphome/components/ble_device_base/ble_hub.h @@ -79,6 +79,17 @@ class BLEHub { virtual bool scan_running() = 0; /// True when the current/configured scan mode is active (scan requests sent). virtual bool scan_active() = 0; + /// Request a scan-mode change (active = send scan requests). Returns false + /// when the hub cannot honor the request; the caller reports the real state + /// back to its subscriber. A hub that returns true applies the mode + /// immediately: a running scan is restarted with the new mode, an idle one + /// picks it up on its next start. The default cannot-change keeps hubs + /// without a mode switch (and out-of-tree trackers) building unchanged. + /// Independent of HubCapabilities::active_scan: that bit describes what the + /// CONTROLLER can do, this method describes whether the hub exposes a + /// runtime switch — a hub may support active scanning and still refuse + /// (esp32_ble_tracker drives its mode through its own tracker API). + virtual bool request_scan_mode(bool active) { return false; } }; } // namespace esphome::ble_device_base diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp index 28c5c927ea..08a954239f 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp @@ -152,6 +152,31 @@ void RP2BLETracker::start_scan() { this->start_scan_(); } +bool RP2BLETracker::request_scan_mode(bool active) { + if (this->scan_active_ == active) + return true; + this->scan_active_ = active; + ESP_LOGD(TAG, "Scan mode %s", active ? "active" : "passive"); + // Apply to a running scan by restarting the CONTROLLER scan with the new + // mode, bypassing the tracker's stop/start bookkeeping: no on_scan_end (the + // scan logically continues, only the request mode changes), no period reset. + // An idle scanner picks the mode up on its next start. + if (this->scan_running_) { + this->parent_->scan_stop(); + // Stamp the attempt so the SCAN_START_RETRY_MS floor covers this start + // like every other one. + this->last_scan_start_attempt_ = App.get_loop_component_start_time(); + if (!this->parent_->scan_start(static_cast(this->scan_interval_), + static_cast(this->scan_window_), this->scan_active_)) { + // The controller really stopped: behave exactly like loop()'s + // reconciliation branch - notify listeners and let its retry recover. + this->scan_running_ = false; + this->fire_scan_end_(); + } + } + return true; +} + void RP2BLETracker::stop_scan() { this->scan_continuous_ = false; this->stop_scan_(); diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h index bb2a6862af..dd000645c5 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h @@ -71,6 +71,7 @@ class RP2BLETracker : public Component, void get_adapter_mac(uint8_t out[6]) override { this->parent_->get_mac_msb_first(out); } bool scan_running() override { return this->scan_running_; } bool scan_active() override { return this->scan_active_; } + bool request_scan_mode(bool active) override; // ---- rp2040_ble::BLEScanListener ---- // Delivered by the controller's loop() on the ESPHome main loop — the diff --git a/tests/components/ble_device_base/test_scan_mode_request.cpp b/tests/components/ble_device_base/test_scan_mode_request.cpp new file mode 100644 index 0000000000..e98f25b10d --- /dev/null +++ b/tests/components/ble_device_base/test_scan_mode_request.cpp @@ -0,0 +1,59 @@ +#include + +#include + +#include "esphome/components/ble_device_base/ble_hub.h" + +namespace esphome::ble_device_base::testing { + +// Pins the request_scan_mode() contract: the base default refuses (so hubs +// without a mode switch — and out-of-tree trackers — keep building and +// callers report the real state), while an overriding hub both honors the +// request and applies it. +namespace { + +class DefaultHub : public BLEHub { + public: + void register_listener(ESPBTDeviceListener *listener) override {} + void set_raw_advertisement_callback(RawAdvertisementCallback callback) override {} + HubCapabilities get_capabilities() const override { return {false, false, false}; } + void get_adapter_mac(uint8_t out[6]) override {} + bool scan_running() override { return false; } + // Backed by real state so "changes nothing" is observable: a base default + // that silently mutated the hub would flip this and fail the assertion. + bool scan_active() override { return this->active_; } + + protected: + bool active_{true}; +}; + +class SwitchingHub : public DefaultHub { + public: + HubCapabilities get_capabilities() const override { return {true, false, false}; } + bool request_scan_mode(bool active) override { + this->active_ = active; + return true; + } +}; + +} // namespace + +TEST(BLEHubScanModeRequest, DefaultRefusesAndChangesNothing) { + DefaultHub hub; + EXPECT_TRUE(hub.scan_active()); + EXPECT_FALSE(hub.request_scan_mode(false)); + // Refused, not applied-and-reported-false: the state is untouched. + EXPECT_TRUE(hub.scan_active()); + EXPECT_FALSE(hub.request_scan_mode(true)); + EXPECT_TRUE(hub.scan_active()); +} + +TEST(BLEHubScanModeRequest, OverrideHonorsAndApplies) { + SwitchingHub hub; + EXPECT_TRUE(hub.request_scan_mode(true)); + EXPECT_TRUE(hub.scan_active()); + EXPECT_TRUE(hub.request_scan_mode(false)); + EXPECT_FALSE(hub.scan_active()); +} + +} // namespace esphome::ble_device_base::testing From 37b65782590340409a9e83bf60838edbcc3b620a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Tue, 4 Aug 2026 18:57:42 +0300 Subject: [PATCH 1206/1815] [bk72xx_ble_tracker] Brace single-statement loop flagged by clang-tidy (#18070) --- .../components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp index eba440d84d..2bc91cbc6f 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp @@ -171,9 +171,11 @@ void BK72xxBLETracker::on_scan_report(const bk72xx_ble::BLEScanReport &report) { ble_device_base::ESPBTDevice device; device.from_scan_result(report.mac, report.rssi, report.addr_type, report.data, report.data_len); bool found = false; - for (auto *listener : this->listeners_) - if (listener->parse_device(device)) + for (auto *listener : this->listeners_) { + if (listener->parse_device(device)) { found = true; + } + } // Mirror esp32_ble_tracker: log a newly-seen device only when nothing claimed // it and the scan is one-shot (continuous scans would spam). if (!found && !this->scan_continuous_) From c2ddc170638deb1ff2474d3e67ff434c8f4f7001 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Aug 2026 11:05:04 -0500 Subject: [PATCH 1207/1815] [libretiny] Give each platform its own CONFIG_SCHEMA instance (#18056) --- esphome/components/bk72xx/__init__.py | 6 ++- .../libretiny/generate_components.py | 6 ++- esphome/components/ln882x/__init__.py | 6 ++- esphome/components/rtl87xx/__init__.py | 6 ++- tests/unit_tests/components/test_libretiny.py | 38 ++++++++++++++++++- 5 files changed, 56 insertions(+), 6 deletions(-) diff --git a/esphome/components/bk72xx/__init__.py b/esphome/components/bk72xx/__init__.py index 3ffab0f3a5..ee9bf1e0d4 100644 --- a/esphome/components/bk72xx/__init__.py +++ b/esphome/components/bk72xx/__init__.py @@ -51,7 +51,11 @@ def _set_core_data(config): return config -CONFIG_SCHEMA = libretiny.BASE_SCHEMA +# extend({}) makes this platform's own schema instance: BASE_SCHEMA is shared +# by every LibreTiny platform, and prepending this platform's _set_core_data +# onto the shared object would run it for every platform's validation once two +# platform modules are imported in one process (device-builder, tests). +CONFIG_SCHEMA = libretiny.BASE_SCHEMA.extend({}) PIN_SCHEMA = libretiny.gpio.BASE_PIN_SCHEMA diff --git a/esphome/components/libretiny/generate_components.py b/esphome/components/libretiny/generate_components.py index 791a2659a9..4997878657 100644 --- a/esphome/components/libretiny/generate_components.py +++ b/esphome/components/libretiny/generate_components.py @@ -65,6 +65,10 @@ def _set_core_data(config): return config +# extend({}) makes this platform's own schema instance: BASE_SCHEMA is shared +# by every LibreTiny platform, and prepending this platform's _set_core_data +# onto the shared object would run it for every platform's validation once two +# platform modules are imported in one process (device-builder, tests). CONFIG_SCHEMA = {SCHEMA} PIN_SCHEMA = {PIN_SCHEMA} @@ -117,7 +121,7 @@ VAR_GPIO_PIN = "validate_pin" VAR_GPIO_USAGE = "validate_usage" # lines for code snippets -SCHEMA_BASE = "libretiny.BASE_SCHEMA" +SCHEMA_BASE = "libretiny.BASE_SCHEMA.extend({})" SCHEMA_EXTRA = f"libretiny.BASE_SCHEMA.extend({VAR_SCHEMA})" PIN_SCHEMA_BASE = "libretiny.gpio.BASE_PIN_SCHEMA" PIN_SCHEMA_EXTRA = f"libretiny.BASE_PIN_SCHEMA.extend({VAR_PIN_SCHEMA})" diff --git a/esphome/components/ln882x/__init__.py b/esphome/components/ln882x/__init__.py index 9c91827522..6da5a4969c 100644 --- a/esphome/components/ln882x/__init__.py +++ b/esphome/components/ln882x/__init__.py @@ -51,7 +51,11 @@ def _set_core_data(config): return config -CONFIG_SCHEMA = libretiny.BASE_SCHEMA +# extend({}) makes this platform's own schema instance: BASE_SCHEMA is shared +# by every LibreTiny platform, and prepending this platform's _set_core_data +# onto the shared object would run it for every platform's validation once two +# platform modules are imported in one process (device-builder, tests). +CONFIG_SCHEMA = libretiny.BASE_SCHEMA.extend({}) PIN_SCHEMA = libretiny.gpio.BASE_PIN_SCHEMA diff --git a/esphome/components/rtl87xx/__init__.py b/esphome/components/rtl87xx/__init__.py index a3b1dba4f2..a8eabae9a0 100644 --- a/esphome/components/rtl87xx/__init__.py +++ b/esphome/components/rtl87xx/__init__.py @@ -51,7 +51,11 @@ def _set_core_data(config): return config -CONFIG_SCHEMA = libretiny.BASE_SCHEMA +# extend({}) makes this platform's own schema instance: BASE_SCHEMA is shared +# by every LibreTiny platform, and prepending this platform's _set_core_data +# onto the shared object would run it for every platform's validation once two +# platform modules are imported in one process (device-builder, tests). +CONFIG_SCHEMA = libretiny.BASE_SCHEMA.extend({}) PIN_SCHEMA = libretiny.gpio.BASE_PIN_SCHEMA diff --git a/tests/unit_tests/components/test_libretiny.py b/tests/unit_tests/components/test_libretiny.py index ee00bdc180..de54fcc6ca 100644 --- a/tests/unit_tests/components/test_libretiny.py +++ b/tests/unit_tests/components/test_libretiny.py @@ -2,7 +2,8 @@ import pytest -from esphome.components.libretiny import _detect_variant +from esphome.components import bk72xx, ln882x, rtl87xx +from esphome.components.libretiny import BASE_SCHEMA, _detect_variant from esphome.components.libretiny.const import ( FAMILY_LN882H, KEY_COMPONENT_DATA, @@ -11,7 +12,7 @@ from esphome.components.libretiny.const import ( from esphome.components.ln882x import COMPONENT_DATA import esphome.config_validation as cv from esphome.const import CONF_BOARD, CONF_FAMILY -from esphome.core import CORE +from esphome.core import CORE, KEY_CORE @pytest.fixture @@ -50,3 +51,36 @@ def test_detect_variant_unknown_board_still_raises(ln882x_core_data: None) -> No """Ids outside the rename map keep the family-override error.""" with pytest.raises(cv.Invalid, match="This board is unknown"): _detect_variant({CONF_BOARD: "not-a-real-board"}) + + +def test_platform_schemas_are_isolated_instances() -> None: + """Each LibreTiny platform must own its CONFIG_SCHEMA instance. + + BASE_SCHEMA is shared; every platform prepends its own _set_core_data + extra. On the shared object, importing two platform modules in one process + made either platform's validation run both extras, so the wrong platform's + component data won and known boards failed to resolve. + """ + platforms = (bk72xx, ln882x, rtl87xx) + schemas = [platform.CONFIG_SCHEMA for platform in platforms] + assert len({id(schema) for schema in (BASE_SCHEMA, *schemas)}) == 4 + # The shared base must not have accumulated any platform's extra. + # prepend_extra wraps validators in _Schema, so unwrap before comparing. + base_extras = [extra.schema for extra in BASE_SCHEMA._extra_schemas] + for platform in platforms: + assert platform._set_core_data not in base_extras + + +def test_each_platform_resolves_its_own_boards() -> None: + """Validating one platform's config must leave that platform's component + data in CORE.data. On the shared schema, the last-imported platform's + _set_core_data won for every platform, so known boards failed to resolve + with "This board is unknown".""" + CORE.data[KEY_CORE] = {} # written by the schema's _update_core_data extra + for platform, board in ( + (ln882x, "generic-ln882h"), + (bk72xx, "generic-bk7252"), + (rtl87xx, "generic-rtl8720cf-2mb-896k"), + ): + platform.CONFIG_SCHEMA({CONF_BOARD: board}) + assert CORE.data[KEY_LIBRETINY][KEY_COMPONENT_DATA] is platform.COMPONENT_DATA From adb86a052c615f36d52dca63858f6f4d3bd8869d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Aug 2026 11:05:52 -0500 Subject: [PATCH 1208/1815] [core] Ship secrets referenced by remote package files in config bundles (#18053) --- esphome/bundle.py | 36 ++++++++++++++ esphome/components/packages/__init__.py | 8 ++++ esphome/util.py | 4 +- esphome/yaml_util.py | 14 +++--- .../component_tests/packages/test_packages.py | 32 +++++++++++++ tests/unit_tests/test_bundle.py | 39 +++++++++++++++ tests/unit_tests/test_yaml_util.py | 48 ++++++++++++++++++- 7 files changed, 171 insertions(+), 10 deletions(-) diff --git a/esphome/bundle.py b/esphome/bundle.py index dcaea03646..7045e850c7 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -29,6 +29,7 @@ from esphome.const import ( CONF_TYPE, ) from esphome.core import CORE, EsphomeError +from esphome.util import filter_yaml_files _LOGGER = logging.getLogger(__name__) @@ -128,6 +129,9 @@ class BundleData: """Files components asked to include, keyed under DOMAIN in CORE.data.""" extra_files: list[Path] = field(default_factory=list) + # Directories whose YAML files are scanned for !secret references but + # never bundled, e.g. git package checkouts the builder re-fetches. + secret_scan_dirs: set[Path] = field(default_factory=set) # Original config dir parsed from an extracted bundle's manifest.json, # kept in the path flavor of the machine the bundle was created on. # The checked flag makes the manifest lookup happen at most once per run; @@ -155,6 +159,30 @@ def add_bundle_file(path: Path) -> None: _get_data().extra_files.append(CORE.relative_config_path(path)) +def add_secret_scan_dir(path: Path) -> None: + """Register a directory to scan for ``!secret`` references when bundling. + + The directory's files are not added to the bundle. Components call this + for YAML the build consumes without bundling it — such as git-fetched + packages, which the builder re-fetches — so the secrets those files + reference are still shipped in the filtered secrets file. + + A relative path is taken as relative to the config directory. + """ + if not path.is_absolute(): + path = CORE.relative_config_path(path) + _get_data().secret_scan_dirs.add(path) + + +def _secret_scan_yaml_files() -> list[Path]: + """Return the YAML files inside registered secret-scan directories.""" + return filter_yaml_files( + f + for scan_dir in _get_data().secret_scan_dirs + for f in yaml_util.find_files(scan_dir, "*") + ) + + # Windows paths start with a drive letter or contain backslashes; POSIX # paths do neither in practice, so this is how the flavor of a recorded # path string is recognized on any host. @@ -310,6 +338,7 @@ class ConfigBundleCreator: yaml_sources = [ bf.source for bf in files if bf.source.suffix in (".yaml", ".yml") ] + yaml_sources.extend(_secret_scan_yaml_files()) used_secret_keys = _find_used_secret_keys(yaml_sources) filtered_secrets = self._build_filtered_secrets(used_secret_keys) @@ -394,6 +423,13 @@ class ConfigBundleCreator: """ discovered = yaml_util.discover_user_yaml_files(self._config_path) self._secrets_paths.update(discovered.secrets) + # A !secret inside a file this re-parse does not reach (for example + # a git-fetched package the builder re-fetches) still resolves + # against the config-dir secrets.yaml at build time, so always + # consider that file; filtering no-ops when no key matches. + default_secrets = self._config_dir / yaml_util.SECRET_YAML + if default_secrets.is_file(): + self._secrets_paths.add(default_secrets.resolve()) config_resolved = self._config_path.resolve() for fpath in discovered.files: if fpath == config_resolved: diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 6cb9d5f03a..4d1814ac7a 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -201,6 +201,14 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]: if base_path := config.get(CONF_PATH): repo_dir = repo_dir / base_path + # Deferred import: keeps esphome.bundle off the device builder's + # startup path, since packages is loaded on every config parse. + from esphome.bundle import add_secret_scan_dir + + # Register the path-narrowed dir, not repo_root, so example configs + # elsewhere in the repo do not widen the shipped secrets. + add_secret_scan_dir(repo_dir) + for file in config[CONF_FILES]: if isinstance(file, str): files.append({CONF_PATH: file, CONF_VARS: {}}) diff --git a/esphome/util.py b/esphome/util.py index f7d33bd2a9..ed95ea24d2 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -1,5 +1,5 @@ import collections -from collections.abc import Callable +from collections.abc import Callable, Iterable from dataclasses import dataclass import io import logging @@ -356,7 +356,7 @@ def list_yaml_files(configs: list[str | Path]) -> list[Path]: return sorted(files) -def filter_yaml_files(files: list[Path]) -> list[Path]: +def filter_yaml_files(files: Iterable[Path]) -> list[Path]: return [ f for f in files diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 833d6f1dbf..03d1e81073 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Callable, Generator +from collections.abc import Callable, Generator, Iterator from contextlib import contextmanager, suppress from dataclasses import dataclass, field import functools @@ -791,12 +791,12 @@ class ESPHomeLoaderMixin: @_add_data_ref def construct_include_dir_list(self, node: yaml.Node) -> list[dict[str, Any]]: - files = filter_yaml_files(_find_files(self._rel_path(node.value), "*.yaml")) + files = filter_yaml_files(find_files(self._rel_path(node.value), "*.yaml")) return [self.yaml_loader(f) for f in files] @_add_data_ref def construct_include_dir_merge_list(self, node: yaml.Node) -> list[dict[str, Any]]: - files = filter_yaml_files(_find_files(self._rel_path(node.value), "*.yaml")) + files = filter_yaml_files(find_files(self._rel_path(node.value), "*.yaml")) merged_list = [] for fname in files: loaded_yaml = self.yaml_loader(fname) @@ -808,7 +808,7 @@ class ESPHomeLoaderMixin: def construct_include_dir_named( self, node: yaml.Node ) -> OrderedDict[str, dict[str, Any]]: - files = filter_yaml_files(_find_files(self._rel_path(node.value), "*.yaml")) + files = filter_yaml_files(find_files(self._rel_path(node.value), "*.yaml")) mapping = OrderedDict() for fname in files: filename = fname.stem @@ -819,7 +819,7 @@ class ESPHomeLoaderMixin: def construct_include_dir_merge_named( self, node: yaml.Node ) -> OrderedDict[str, dict[str, Any]]: - files = filter_yaml_files(_find_files(self._rel_path(node.value), "*.yaml")) + files = filter_yaml_files(find_files(self._rel_path(node.value), "*.yaml")) mapping = OrderedDict() for fname in files: loaded_yaml = self.yaml_loader(fname) @@ -1015,8 +1015,8 @@ def _is_file_valid(name: str) -> bool: return not name.startswith(".") -def _find_files(directory: Path, pattern): - """Recursively load files in a directory.""" +def find_files(directory: Path, pattern: str) -> Iterator[Path]: + """Recursively find files in a directory matching *pattern*, skipping hidden entries.""" for root, dirs, files in os.walk(directory): dirs[:] = [d for d in dirs if _is_file_valid(d)] for f in files: diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 39bffd31b7..418cab5ea1 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch import pytest +from esphome import bundle from esphome.components.packages import ( CONFIG_SCHEMA, _substitute_package_definition, @@ -1694,3 +1695,34 @@ def test_resolve_packages_does_not_apply_extend_remove() -> None: # over the package value during merge), and the marker is not # resolved by this wrapper. assert isinstance(result[CONF_WIFI], Remove) + + +@patch("esphome.git.clone_or_update") +def test_remote_package_registers_checkout_for_secret_scan( + mock_clone_or_update, tmp_path: Path +) -> None: + """Loading a remote package registers its path-narrowed checkout dir + as a bundle secret-scan dir (issue 18023).""" + repo_root = tmp_path / "repo" + package_dir = repo_root / "packages" + package_dir.mkdir(parents=True) + (package_dir / "base.yml").write_text( + f"sensor:\n - platform: {TEST_SENSOR_PLATFORM_1}\n name: {TEST_SENSOR_NAME_1}\n" + ) + mock_clone_or_update.return_value = (repo_root, None) + + config = { + CONF_PACKAGES: { + "package1": { + CONF_URL: "https://github.com/esphome/non-existant-repo", + CONF_REF: "main", + CONF_PATH: "packages", + CONF_FILES: ["base.yml"], + CONF_REFRESH: "1d", + } + } + } + packages_pass(config) + + assert package_dir in bundle._get_data().secret_scan_dirs + assert repo_root not in bundle._get_data().secret_scan_dirs diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index 5c71b72d86..785db4086c 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -23,6 +23,7 @@ from esphome.bundle import ( _default_target_dir, _find_used_secret_keys, add_bundle_file, + add_secret_scan_dir, extract_bundle, is_bundle_path, prepare_bundle_for_compile, @@ -1651,6 +1652,44 @@ def test_create_bundle_filters_secrets_quoted(tmp_path: Path) -> None: assert "unused" not in secrets_data +def test_create_bundle_scans_remote_package_files_for_secrets(tmp_path: Path) -> None: + """Secrets referenced only by git-fetched package files must be shipped + in the filtered secrets.yaml (regression test for issue 18023).""" + config_dir = _setup_config_dir(tmp_path) + + secrets = config_dir / "secrets.yaml" + secrets.write_text("ota_password: hunter2\nunused: should_not_appear\n") + + # Simulate a git-fetched package checkout referencing a secret + repo_dir = config_dir / ".esphome" / "packages" / "6bcd6aa8" + package_dir = repo_dir / "packages" + package_dir.mkdir(parents=True) + (package_dir / "base.yml").write_text( + "ota:\n - platform: esphome\n password: !secret ota_password\n" + ) + # References inside hidden directories such as .git must not be scanned + hidden_dir = repo_dir / ".git" + hidden_dir.mkdir() + (hidden_dir / "leak.yaml").write_text("password: !secret unused\n") + add_secret_scan_dir(repo_dir) + + creator = ConfigBundleCreator({}) + result = creator.create_bundle() + + assert result.manifest[ManifestKey.HAS_SECRETS] is True + + buf = io.BytesIO(result.data) + with tarfile.open(fileobj=buf, mode="r:gz") as tar: + secrets_data = tar.extractfile("secrets.yaml").read().decode() + names = tar.getnames() + + assert "ota_password" in secrets_data + assert "hunter2" in secrets_data + assert "unused" not in secrets_data + # The package checkout itself must not be bundled + assert not any("base.yml" in name for name in names) + + def test_create_bundle_no_secrets(tmp_path: Path) -> None: _setup_config_dir(tmp_path) diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 7a08ad2eb4..1bb70864a3 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -282,8 +282,54 @@ test: !include_dir_named test_dir assert ".hidden_dir" not in actual["test"] +def test_include_dir_list(tmp_path: Path) -> None: + """!include_dir_list loads every .yaml file in the directory as a list.""" + test_dir = tmp_path / "test_dir" + test_dir.mkdir() + (test_dir / "a.yaml").write_text("key: value_a") + (test_dir / "b.yaml").write_text("key: value_b") + + test_yaml = tmp_path / "test.yaml" + test_yaml.write_text("test: !include_dir_list test_dir\n") + + actual = yaml_util.load_yaml(test_yaml) + + assert len(actual["test"]) == 2 + assert {entry["key"] for entry in actual["test"]} == {"value_a", "value_b"} + + +def test_include_dir_merge_list(tmp_path: Path) -> None: + """!include_dir_merge_list concatenates the lists from every .yaml file.""" + test_dir = tmp_path / "test_dir" + test_dir.mkdir() + (test_dir / "a.yaml").write_text("- item_a1\n- item_a2\n") + (test_dir / "b.yaml").write_text("- item_b1\n") + + test_yaml = tmp_path / "test.yaml" + test_yaml.write_text("test: !include_dir_merge_list test_dir\n") + + actual = yaml_util.load_yaml(test_yaml) + + assert sorted(actual["test"]) == ["item_a1", "item_a2", "item_b1"] + + +def test_include_dir_merge_named(tmp_path: Path) -> None: + """!include_dir_merge_named merges the mappings from every .yaml file.""" + test_dir = tmp_path / "test_dir" + test_dir.mkdir() + (test_dir / "a.yaml").write_text("key_a: value_a") + (test_dir / "b.yaml").write_text("key_b: value_b") + + test_yaml = tmp_path / "test.yaml" + test_yaml.write_text("test: !include_dir_merge_named test_dir\n") + + actual = yaml_util.load_yaml(test_yaml) + + assert actual["test"] == {"key_a": "value_a", "key_b": "value_b"} + + def test_find_files_recursive(fixture_path: Path, tmp_path: Path) -> None: - """Test that _find_files works recursively through include_dir_named.""" + """Test that find_files works recursively through include_dir_named.""" # Copy fixture directory to temporary location src_dir = fixture_path / "yaml_util" dst_dir = tmp_path / "yaml_util" From 5abd100b53cc84a9b16063e246ba58d2704391eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Aug 2026 11:08:56 -0500 Subject: [PATCH 1209/1815] [core] Resolve platform CLI hooks from a registry instead of importing the platform package (#18044) --- esphome/__main__.py | 51 ++++---- esphome/platform_hooks.py | 113 +++++++++++++++++ tests/unit_tests/test_main.py | 86 ++++++++++--- tests/unit_tests/test_platform_hooks.py | 153 ++++++++++++++++++++++++ 4 files changed, 366 insertions(+), 37 deletions(-) create mode 100644 esphome/platform_hooks.py create mode 100644 tests/unit_tests/test_platform_hooks.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 3893c678e1..fb51fdaf15 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -19,7 +19,7 @@ from typing import Protocol # Note: Do not import modules from esphome.components here, as this would # cause them to be loaded before external components are processed, resulting # in the built-in version being used instead of the external component one. -from esphome import const +from esphome import const, platform_hooks from esphome.const import ( ALLOWED_NAME_CHARS, ARGUMENT_HELP_DEVICE, @@ -630,16 +630,27 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: return 1 _LOGGER.info("Starting log output from %s with baud rate %s", port, baud_rate) - process_stacktrace = None - + # Stacktrace analysis is optional; a broken platform import must not + # stop serial log streaming, but it is a real breakage and must not + # masquerade as an ordinary capability gap. try: - module = importlib.import_module("esphome.components." + CORE.target_platform) - process_stacktrace = module.process_stacktrace - except (AttributeError, ImportError): - _LOGGER.info( - 'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".', - CORE.target_platform, + process_stacktrace = platform_hooks.get_platform_hook( + CORE.target_platform, "process_stacktrace" ) + except ImportError as err: + _LOGGER.debug("Stacktrace analyzer import failed", exc_info=True) + _LOGGER.warning( + 'Stacktrace analysis is unavailable: analyzer for target platform "%s" failed to import: %s', + CORE.target_platform, + err, + ) + process_stacktrace = None + else: + if process_stacktrace is None: + _LOGGER.info( + 'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".', + CORE.target_platform, + ) backtrace_state = False ser = serial.Serial() @@ -1141,12 +1152,11 @@ def upload_program( config: ConfigType, args: ArgsProtocol, devices: list[str] ) -> tuple[int, str | None]: host = devices[0] - try: - module = importlib.import_module("esphome.components." + CORE.target_platform) - if module.upload_program(config, args, host): - return 0, host - except AttributeError: - pass + platform_upload = platform_hooks.get_platform_hook( + CORE.target_platform, "upload_program" + ) + if platform_upload is not None and platform_upload(config, args, host): + return 0, host port_type = get_port_type(host) @@ -1406,12 +1416,11 @@ def _should_subscribe_states(args: ArgsProtocol) -> bool: def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: - try: - module = importlib.import_module("esphome.components." + CORE.target_platform) - if module.show_logs(config, args, devices): - return 0 - except AttributeError: - pass + platform_show_logs = platform_hooks.get_platform_hook( + CORE.target_platform, "show_logs" + ) + if platform_show_logs is not None and platform_show_logs(config, args, devices): + return 0 if "logger" not in config: raise EsphomeError("Logger is not configured!") diff --git a/esphome/platform_hooks.py b/esphome/platform_hooks.py new file mode 100644 index 0000000000..2106f50319 --- /dev/null +++ b/esphome/platform_hooks.py @@ -0,0 +1,113 @@ +"""Registry of platform packages that provide optional CLI hooks. + +The logs/upload fast path must know whether a target platform overrides +``show_logs``/``upload_program`` or provides ``process_stacktrace`` +without importing the platform package to find out; importing one pulls +in the whole validation stack (config_validation, voluptuous, boards), +which costs seconds on slow hardware. Keep the mapping in sync with the +hook definitions in ``esphome/components/*/__init__.py``; a unit test +imports each platform package and fails when they drift. + +The compile-path ``run_compile`` hook is deliberately not registered: +compiling imports the platform package regardless, so its probe in +``__main__.py`` stays eager. The network log client's +``process_stacktrace`` probe in ``esphome/api_client.py`` still uses the +old importlib pattern; converting it is a separate change. +""" + +from __future__ import annotations + +from collections.abc import Callable +from importlib import import_module +import logging +from typing import Any, Final + +from esphome.const import ( + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_NRF52, + PLATFORM_RP2, + Platform, +) + +_LOGGER = logging.getLogger(__name__) + +# Hooks whose loss only degrades diagnostics; skipping one of these is +# logged at debug, while skipping a hook that changes what the CLI does +# (upload method, log transport) warns. A new hook is loud by default. +COSMETIC_HOOKS: Final = frozenset({"process_stacktrace"}) + +PLATFORM_HOOKS: Final[dict[str, frozenset[str]]] = { + "show_logs": frozenset({PLATFORM_NRF52}), + "upload_program": frozenset({PLATFORM_NRF52}), + "process_stacktrace": frozenset( + {PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_NRF52, PLATFORM_RP2} + ), +} + + +# The registry only speaks for in-tree platforms; a target platform +# supplied via external_components is normally not in Platform and falls +# back to probing the imported package, as the CLI did before the +# registry. Deliberate trade: an external component that shadows an +# in-tree platform name (the meta finder allows it) is treated as the +# in-tree platform here, so its own hooks are not probed. +_IN_TREE_PLATFORMS: Final = frozenset(Platform) + + +def get_platform_hook(platform: str, hook: str) -> Callable[..., Any] | None: + """Return ``esphome.components..`` or None. + + In-tree platforms not registered for the hook return None without + being imported. A registered platform that no longer defines the + hook also returns None, so a stale registry degrades to the generic + path instead of raising. + """ + registered = platform in PLATFORM_HOOKS[hook] + if not registered and platform in _IN_TREE_PLATFORMS: + return None + # For external platforms this probes the imported package like the + # CLI used to; the package can be missing entirely on the warm-cache + # path, where the external_components meta finder never registered. + # Degrade to the generic path then, but let a failure deeper in the + # package (missing dependency) surface. + module_name = f"esphome.components.{platform}" + try: + module = import_module(module_name) + except ModuleNotFoundError as err: + if registered or err.name != module_name: + raise + if hook in COSMETIC_HOOKS: + _LOGGER.debug( + "External platform %s is not importable; using the generic %s path", + platform, + hook, + ) + else: + # Deliberately loud even though the warm-cache path makes + # this expected: the user's platform hooks are not in effect + # for this run, and a silently substituted upload method is + # worse than a routine warning. + _LOGGER.warning( + "External platform %s is not importable; using the generic %s path", + platform, + hook, + ) + return None + handler = getattr(module, hook, None) + if handler is None: + if registered: + _LOGGER.warning( + "%s is registered for %s but no longer exposes it; using the generic path", + platform, + hook, + ) + else: + # The common case for external platforms; debug so a typoed + # hook name is still diagnosable without being noisy. + _LOGGER.debug( + "External platform %s does not expose %s; using the generic path", + platform, + hook, + ) + return handler diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 289497057f..2c9a88afb6 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -94,6 +94,7 @@ from esphome.const import ( PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, + PLATFORM_NRF52, PLATFORM_RP2, Toolchain, ) @@ -2862,18 +2863,17 @@ def test_upload_program_ota_with_mqtt_empty_broker( assert "MQTT IP discovery failed" in caplog.text -@patch("esphome.__main__.importlib.import_module") +@patch("esphome.platform_hooks.get_platform_hook") def test_upload_program_platform_specific_handler( - mock_import: Mock, + mock_get_hook: Mock, mock_get_port_type: Mock, ) -> None: """Test upload_program with platform-specific upload handler.""" - setup_core(platform="custom_platform") + setup_core(platform=PLATFORM_NRF52) mock_get_port_type.return_value = "CUSTOM" - mock_module = MagicMock() - mock_module.upload_program.return_value = True - mock_import.return_value = mock_module + platform_upload = MagicMock(return_value=True) + mock_get_hook.return_value = platform_upload config = {} args = MockArgs() @@ -2883,8 +2883,8 @@ def test_upload_program_platform_specific_handler( assert exit_code == 0 assert host == "custom_device" - mock_import.assert_called_once_with("esphome.components.custom_platform") - mock_module.upload_program.assert_called_once_with(config, args, "custom_device") + mock_get_hook.assert_called_once_with(PLATFORM_NRF52, "upload_program") + platform_upload.assert_called_once_with(config, args, "custom_device") def test_show_logs_serial( @@ -3108,16 +3108,15 @@ def test_show_logs_no_method_configured() -> None: show_logs(CORE.config, args, devices) -@patch("esphome.__main__.importlib.import_module") +@patch("esphome.platform_hooks.get_platform_hook") def test_show_logs_platform_specific_handler( - mock_import: Mock, + mock_get_hook: Mock, ) -> None: """Test show_logs with platform-specific logs handler.""" - setup_core(platform="custom_platform", config={"logger": {}}) + setup_core(platform=PLATFORM_NRF52, config={"logger": {}}) - mock_module = MagicMock() - mock_module.show_logs.return_value = True - mock_import.return_value = mock_module + platform_show_logs = MagicMock(return_value=True) + mock_get_hook.return_value = platform_show_logs config = {"logger": {}} args = MockArgs() @@ -3126,8 +3125,8 @@ def test_show_logs_platform_specific_handler( result = show_logs(config, args, devices) assert result == 0 - mock_import.assert_called_once_with("esphome.components.custom_platform") - mock_module.show_logs.assert_called_once_with(config, args, devices) + mock_get_hook.assert_called_once_with(PLATFORM_NRF52, "show_logs") + platform_show_logs.assert_called_once_with(config, args, devices) def test_has_mqtt_logging_no_log_topic() -> None: @@ -5717,6 +5716,61 @@ def test_run_miniterm_batches_lines_with_same_timestamp( ) +def test_run_miniterm_analyzer_import_failure_keeps_streaming( + caplog: pytest.LogCaptureFixture, +) -> None: + """A broken platform import must not stop serial log streaming.""" + mock_serial = MockSerial([b"[I][app:100]: Line 1\r\n", MOCK_SERIAL_END]) + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP32} + config = { + CONF_LOGGER: { + CONF_BAUD_RATE: 115200, + "deassert_rts_dtr": False, + } + } + args = MockArgs() + + with ( + caplog.at_level("INFO", logger="esphome.__main__"), + patch("serial.Serial", return_value=mock_serial), + patch( + "esphome.platform_hooks.get_platform_hook", + side_effect=ImportError("broken platform package"), + ), + ): + result = run_miniterm(config, "/dev/ttyUSB0", args) + + assert result == 0 + # A broken package is distinguishable from a plain capability gap. + assert "failed to import: broken platform package" in caplog.text + + +def test_run_miniterm_no_stacktrace_analyzer( + caplog: pytest.LogCaptureFixture, +) -> None: + """Platforms without a stacktrace analyzer log an info and stream anyway.""" + mock_serial = MockSerial([b"[I][app:100]: Line 1\r\n", MOCK_SERIAL_END]) + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_BK72XX} + config = { + CONF_LOGGER: { + CONF_BAUD_RATE: 115200, + "deassert_rts_dtr": False, + } + } + args = MockArgs() + + with ( + caplog.at_level("INFO", logger="esphome.__main__"), + patch("serial.Serial", return_value=mock_serial), + ): + result = run_miniterm(config, "/dev/ttyUSB0", args) + + assert result == 0 + assert "Stacktrace analysis is unavailable" in caplog.text + + def test_run_miniterm_different_chunks_different_timestamps( capfd: CaptureFixture[str], ) -> None: diff --git a/tests/unit_tests/test_platform_hooks.py b/tests/unit_tests/test_platform_hooks.py new file mode 100644 index 0000000000..27d37e1d76 --- /dev/null +++ b/tests/unit_tests/test_platform_hooks.py @@ -0,0 +1,153 @@ +"""Guard the platform CLI-hook registry in ``esphome.platform_hooks``. + +The registry lets the logs/upload fast path skip importing platform +packages that don't provide a hook; these tests fail when a platform +gains or loses a hook without the registry being updated, and pin down +that the fast path really avoids the import. +""" + +from __future__ import annotations + +import importlib +from unittest.mock import Mock + +import pytest + +from esphome import platform_hooks +from esphome.const import PLATFORM_ESP32, Platform + + +def test_no_unregistered_platform_exposes_a_hook() -> None: + """Every platform hook the packages expose must be registered. + + Behavioural on purpose: a hook added as a re-export, an assignment, + or an ``async def`` is invisible to source scanning but very visible + to ``hasattr``, and an unregistered hook is silently never called. + The registered direction is covered by + test_every_registered_pair_resolves below. + """ + for platform in frozenset(Platform): + module = importlib.import_module(f"esphome.components.{platform}") + for hook, registered in platform_hooks.PLATFORM_HOOKS.items(): + if hasattr(module, hook): + assert platform in registered, ( + f"{platform} exposes {hook} but is not registered for it. " + "Update esphome/platform_hooks.py." + ) + + +def test_registered_platform_resolves_hook() -> None: + hook = platform_hooks.get_platform_hook(PLATFORM_ESP32, "process_stacktrace") + from esphome.components import esp32 + + assert hook is esp32.process_stacktrace + + +def test_every_registered_pair_resolves() -> None: + """Each registered platform must actually expose the hook at runtime. + + Text scanning can miss re-exports or decorated definitions; this is + the behavioural check for the direction that matters when the CLI + runs. + """ + for hook, platforms in platform_hooks.PLATFORM_HOOKS.items(): + for platform in platforms: + assert callable(platform_hooks.get_platform_hook(platform, hook)), ( + f"{platform} is registered for {hook} but does not expose it" + ) + + +def test_external_platform_falls_back_to_probe( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Out-of-tree target platforms keep working via the dynamic probe.""" + module = type("FakePlatform", (), {"show_logs": staticmethod(lambda *a: True)}) + imported: list[str] = [] + + def fake_import(name: str): + imported.append(name) + return module + + monkeypatch.setattr(platform_hooks, "import_module", fake_import) + hook = platform_hooks.get_platform_hook("my_external_chip", "show_logs") + assert hook is module.show_logs + assert imported == ["esphome.components.my_external_chip"] + + +def test_external_platform_missing_module_degrades( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """A warm-cache run may not have the external package importable. + + Skipping a behavior-changing hook is visible at warning; losing + stacktrace decoding is cosmetic and stays at debug. + """ + monkeypatch.setattr( + platform_hooks, + "import_module", + Mock( + side_effect=ModuleNotFoundError( + "not found", name="esphome.components.my_external_chip" + ) + ), + ) + assert platform_hooks.get_platform_hook("my_external_chip", "show_logs") is None + assert "not importable" in caplog.text + assert any(r.levelname == "WARNING" for r in caplog.records) + + caplog.clear() + assert ( + platform_hooks.get_platform_hook("my_external_chip", "process_stacktrace") + is None + ) + assert not any(r.levelname == "WARNING" for r in caplog.records) + + +def test_external_platform_without_hook_logs_debug( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """The common no-hook case stays quiet but diagnosable.""" + caplog.set_level("DEBUG", logger="esphome.platform_hooks") + module = type("ExternalPlatform", (), {}) # imports fine, no hook + monkeypatch.setattr(platform_hooks, "import_module", Mock(return_value=module)) + assert platform_hooks.get_platform_hook("my_external_chip", "show_logs") is None + assert "does not expose" in caplog.text + assert not any(r.levelname == "WARNING" for r in caplog.records) + + +def test_stale_registry_entry_warns( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """A vendored tree where a registered hook vanished must say so.""" + module = type("StalePlatform", (), {}) # registered but no hook + monkeypatch.setattr(platform_hooks, "import_module", Mock(return_value=module)) + assert platform_hooks.get_platform_hook("nrf52", "show_logs") is None + assert "no longer exposes it" in caplog.text + + +def test_external_platform_broken_dependency_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A missing dependency inside the external package must surface.""" + monkeypatch.setattr( + platform_hooks, + "import_module", + Mock(side_effect=ModuleNotFoundError("not found", name="some_missing_dep")), + ) + with pytest.raises(ModuleNotFoundError, match="not found"): + platform_hooks.get_platform_hook("my_external_chip", "show_logs") + + +def test_lookup_miss_does_not_import_platform_package( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The whole point: probing a platform without hooks must not import it.""" + monkeypatch.setattr( + platform_hooks, + "import_module", + Mock(side_effect=AssertionError("platform package imported on registry miss")), + ) + assert platform_hooks.get_platform_hook(PLATFORM_ESP32, "show_logs") is None From 8bd9f213e5d0d37ea045ed2e1996f7c36ce36e92 Mon Sep 17 00:00:00 2001 From: Marek Pilch <47844572+marpi82@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:17:27 +0200 Subject: [PATCH 1210/1815] [modbus] Add unit tests for U_WORD_S and S_WORD_S (#17831) --- esphome/components/modbus/helpers.py | 6 +++ esphome/components/modbus/modbus_helpers.cpp | 12 +++++ esphome/components/modbus/modbus_helpers.h | 8 ++- .../components/modbus/modbus_helpers_test.cpp | 51 +++++++++++++++++++ .../components/modbus_controller/common.yaml | 7 +++ 5 files changed, 83 insertions(+), 1 deletion(-) diff --git a/esphome/components/modbus/helpers.py b/esphome/components/modbus/helpers.py index e3029b2648..e7eaacee0c 100644 --- a/esphome/components/modbus/helpers.py +++ b/esphome/components/modbus/helpers.py @@ -38,7 +38,9 @@ SensorValueType = SensorValueType_ns.enum("SensorValueType") SENSOR_VALUE_TYPE = { "RAW": SensorValueType.RAW, "U_WORD": SensorValueType.U_WORD, + "U_WORD_S": SensorValueType.U_WORD_S, "S_WORD": SensorValueType.S_WORD, + "S_WORD_S": SensorValueType.S_WORD_S, "U_DWORD": SensorValueType.U_DWORD, "U_DWORD_R": SensorValueType.U_DWORD_R, "S_DWORD": SensorValueType.S_DWORD, @@ -54,7 +56,9 @@ SENSOR_VALUE_TYPE = { TYPE_REGISTER_MAP = { "RAW": 1, "U_WORD": 1, + "U_WORD_S": 1, "S_WORD": 1, + "S_WORD_S": 1, "U_DWORD": 2, "U_DWORD_R": 2, "S_DWORD": 2, @@ -70,7 +74,9 @@ TYPE_REGISTER_MAP = { CPP_TYPE_REGISTER_MAP = { "RAW": cg.uint16, "U_WORD": cg.uint16, + "U_WORD_S": cg.uint16, "S_WORD": cg.int16, + "S_WORD_S": cg.int16, "U_DWORD": cg.uint32, "U_DWORD_R": cg.uint32, "S_DWORD": cg.int32, diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index 8428ea27ea..2c87928e9f 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -177,7 +177,9 @@ bool is_client_pdu_standard(const uint8_t *pdu, size_t size) { static size_t required_payload_size(SensorValueType sensor_value_type) { switch (sensor_value_type) { case SensorValueType::U_WORD: + case SensorValueType::U_WORD_S: case SensorValueType::S_WORD: + case SensorValueType::S_WORD_S: return 2; case SensorValueType::U_DWORD: case SensorValueType::FP32: @@ -228,6 +230,11 @@ std::optional payload_to_number(const uint8_t *data, size_t size, Senso case SensorValueType::U_WORD: value = mask_and_shift_by_rightbit(get_data(data, offset), bitmask); // default is 0xFFFF ; break; + case SensorValueType::U_WORD_S: { + uint16_t word = byteswap(get_data(data, offset)); + value = mask_and_shift_by_rightbit(word, bitmask); + break; + } case SensorValueType::U_DWORD: case SensorValueType::FP32: value = get_data(data, offset); @@ -242,6 +249,11 @@ std::optional payload_to_number(const uint8_t *data, size_t size, Senso case SensorValueType::S_WORD: value = mask_and_shift_by_rightbit(get_data(data, offset), bitmask); // default is 0xFFFF ; break; + case SensorValueType::S_WORD_S: { + uint16_t word = byteswap(get_data(data, offset)); + value = mask_and_shift_by_rightbit(static_cast(word), bitmask); + break; + } case SensorValueType::S_DWORD: value = mask_and_shift_by_rightbit(get_data(data, offset), bitmask); break; diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index 3dd933c4d7..89e9a2b8ea 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -119,7 +119,9 @@ enum class SensorValueType : uint8_t { U_QWORD_R = 0xA, S_QWORD_R = 0xB, FP32 = 0xC, - FP32_R = 0xD + FP32_R = 0xD, + U_WORD_S = 0xE, // 1 Register unsigned, bytes swapped + S_WORD_S = 0xF, // 1 Register signed, bytes swapped }; inline bool value_type_is_float(SensorValueType v) { @@ -284,6 +286,10 @@ template void number_to_payload(Container &data, int64_t val case SensorValueType::S_WORD: data.push_back(value & 0xFFFF); break; + case SensorValueType::U_WORD_S: + case SensorValueType::S_WORD_S: + data.push_back(byteswap(static_cast(value & 0xFFFF))); + break; case SensorValueType::U_DWORD: case SensorValueType::S_DWORD: case SensorValueType::FP32: diff --git a/tests/components/modbus/modbus_helpers_test.cpp b/tests/components/modbus/modbus_helpers_test.cpp index 49de4f9d14..553ec163b2 100644 --- a/tests/components/modbus/modbus_helpers_test.cpp +++ b/tests/components/modbus/modbus_helpers_test.cpp @@ -331,6 +331,7 @@ TEST(ModbusCreateClientPdu, WriteCoilsUseTheCoilLimitNotTheRegisterLimit) { EXPECT_TRUE(create_client_pdu(FC::WRITE_MULTIPLE_COILS, 0x0000, 1969, big.data(), big.size()).empty()); } +// --- payload_to_number ----------------------------------------------------- TEST(ModbusHelpersTest, PayloadToNumberRejectsOffsetAtEndOfBuffer) { const std::vector data{0x12, 0x34}; EXPECT_FALSE(payload_to_number(std::span(data), SensorValueType::U_WORD, 2, 0xFFFFFFFF).has_value()); @@ -346,6 +347,28 @@ TEST(ModbusHelpersTest, PayloadToNumberDecodesValidWord) { EXPECT_EQ(payload_to_number(std::span(data), SensorValueType::U_WORD, 0, 0xFFFFFFFF), 0x1234); } +TEST(ModbusHelpersTest, PayloadToNumberDecodesSwappedUnsignedWord) { + const std::vector data{0x34, 0x12}; + EXPECT_EQ(payload_to_number(std::span(data), SensorValueType::U_WORD_S, 0, 0xFFFFFFFF), 0x1234); +} + +TEST(ModbusHelpersTest, PayloadToNumberDecodesSwappedSignedWord) { + const std::vector data{0xFE, 0xFF}; + EXPECT_EQ(payload_to_number(std::span(data), SensorValueType::S_WORD_S, 0, 0xFFFFFFFF), -2); +} + +TEST(ModbusHelpersTest, PayloadToNumberAppliesBitmaskAfterSwap) { + // Bytes {0x34,0x12} decode as U_WORD_S to 0x1234; mask 0xFF00 then right-shift by bit 8 -> 0x12 + const std::vector data{0x34, 0x12}; + EXPECT_EQ(payload_to_number(std::span(data), SensorValueType::U_WORD_S, 0, 0xFF00), 0x12); +} + +TEST(ModbusHelpersTest, PayloadToNumberAppliesBitmaskAfterSwapSigned) { + // Bytes {0x34,0xFE} decode as S_WORD_S to 0xFE34 (negative); mask 0x00F0 then right-shift by bit 4 -> 0x3 + const std::vector data{0x34, 0xFE}; + EXPECT_EQ(payload_to_number(std::span(data), SensorValueType::S_WORD_S, 0, 0x00F0), 0x3); +} + // --- registers_to_number --------------------------------------------------- // Register words are host byte order; results must match the byte-based payload_to_number. @@ -354,6 +377,16 @@ TEST(ModbusHelpersTest, RegistersToNumberDecodesWord) { EXPECT_EQ(registers_to_number(registers, 1, SensorValueType::U_WORD), 0x1234); } +TEST(ModbusHelpersTest, RegistersToNumberDecodesSwappedUnsignedWord) { + const uint16_t registers[] = {0x3412}; + EXPECT_EQ(registers_to_number(registers, 1, SensorValueType::U_WORD_S), 0x1234); +} + +TEST(ModbusHelpersTest, RegistersToNumberDecodesSwappedSignedWord) { + const uint16_t registers[] = {0xFEFF}; + EXPECT_EQ(registers_to_number(registers, 1, SensorValueType::S_WORD_S), -2); +} + TEST(ModbusHelpersTest, RegistersToNumberDecodesDwordHighWordFirst) { const uint16_t registers[] = {0x1234, 0x5678}; EXPECT_EQ(registers_to_number(registers, 2, SensorValueType::U_DWORD), 0x12345678); @@ -434,6 +467,24 @@ TEST(ModbusTypedBuilders, FloatToPayloadAppendsToExistingContent) { EXPECT_EQ(data[1], 0x0001); } +// --- number_to_payload ----------------------------------------------------- + +TEST(ModbusHelpersTest, NumberToPayloadRoundTripsSwappedUnsignedWord) { + std::vector regs; + number_to_payload(regs, 0x1234, SensorValueType::U_WORD_S); + ASSERT_EQ(regs.size(), 1u); + EXPECT_EQ(regs[0], 0x3412); + EXPECT_EQ(registers_to_number(regs.data(), regs.size(), SensorValueType::U_WORD_S), 0x1234); +} + +TEST(ModbusHelpersTest, NumberToPayloadRoundTripsSwappedSignedWord) { + std::vector regs; + number_to_payload(regs, -2, SensorValueType::S_WORD_S); + ASSERT_EQ(regs.size(), 1u); + EXPECT_EQ(regs[0], 0xFEFF); + EXPECT_EQ(registers_to_number(regs.data(), regs.size(), SensorValueType::S_WORD_S), -2); +} + TEST(ModbusCreateClientPdu, ExceptionFlaggedWriteCodesRejected) { // is_function_code_write() masks the exception bit; the builder must not. const uint8_t values[] = {0x00, 0x0B, 0x00, 0x16}; diff --git a/tests/components/modbus_controller/common.yaml b/tests/components/modbus_controller/common.yaml index a0db1e7888..986d807dfb 100644 --- a/tests/components/modbus_controller/common.yaml +++ b/tests/components/modbus_controller/common.yaml @@ -41,6 +41,13 @@ number: return x * 2.0; write_lambda: |- return x / 2.0; + # Covers Python value-type maps + read/write path for byte-swapped words + - platform: modbus_controller + modbus_controller_id: modbus_controller1 + id: modbus_number3 + name: Test Number Swapped Word + address: 0x9003 + value_type: U_WORD_S output: - platform: modbus_controller From 914cab301f66e51ba23a4d17f0f2c349b3eb4a3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Tue, 4 Aug 2026 21:20:57 +0300 Subject: [PATCH 1211/1815] [rp2_ble_tracker] Extract stamp-and-start helper; pin capability/switch independence (#18072) --- .../rp2_ble_tracker/rp2_ble_tracker.cpp | 21 +++++++++---------- .../rp2_ble_tracker/rp2_ble_tracker.h | 1 + .../test_scan_mode_request.cpp | 14 +++++++++++++ 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp index 08a954239f..ed036328ae 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp @@ -163,11 +163,7 @@ bool RP2BLETracker::request_scan_mode(bool active) { // An idle scanner picks the mode up on its next start. if (this->scan_running_) { this->parent_->scan_stop(); - // Stamp the attempt so the SCAN_START_RETRY_MS floor covers this start - // like every other one. - this->last_scan_start_attempt_ = App.get_loop_component_start_time(); - if (!this->parent_->scan_start(static_cast(this->scan_interval_), - static_cast(this->scan_window_), this->scan_active_)) { + if (!this->controller_scan_start_()) { // The controller really stopped: behave exactly like loop()'s // reconciliation branch - notify listeners and let its retry recover. this->scan_running_ = false; @@ -186,16 +182,19 @@ void RP2BLETracker::stop_scan() { this->disable_loop(); } +// Stamp-and-start for every controller scan attempt: the stamp keeps the +// SCAN_START_RETRY_MS floor covering all callers, not only loop()'s retry. +bool RP2BLETracker::controller_scan_start_() { + this->last_scan_start_attempt_ = App.get_loop_component_start_time(); + return this->parent_->scan_start(static_cast(this->scan_interval_), + static_cast(this->scan_window_), this->scan_active_); +} + void RP2BLETracker::start_scan_() { if (this->scan_running_) return; - // Stamp every attempt regardless of caller so the loop's rate limit also - // covers a failed start that came through the public start_scan(). - this->last_scan_start_attempt_ = App.get_loop_component_start_time(); - - if (!this->parent_->scan_start(static_cast(this->scan_interval_), static_cast(this->scan_window_), - this->scan_active_)) + if (!this->controller_scan_start_()) return; this->scan_running_ = true; diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h index dd000645c5..4806f599bd 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h @@ -80,6 +80,7 @@ class RP2BLETracker : public Component, protected: void start_scan_(); + bool controller_scan_start_(); void stop_scan_(); void fire_scan_end_(); diff --git a/tests/components/ble_device_base/test_scan_mode_request.cpp b/tests/components/ble_device_base/test_scan_mode_request.cpp index e98f25b10d..2eeb602999 100644 --- a/tests/components/ble_device_base/test_scan_mode_request.cpp +++ b/tests/components/ble_device_base/test_scan_mode_request.cpp @@ -36,6 +36,13 @@ class SwitchingHub : public DefaultHub { } }; +// The esp32 shape: the controller supports active scanning but the hub keeps +// the refusing default (mode is driven through its own tracker API). +class CapableRefusingHub : public DefaultHub { + public: + HubCapabilities get_capabilities() const override { return {true, false, false}; } +}; + } // namespace TEST(BLEHubScanModeRequest, DefaultRefusesAndChangesNothing) { @@ -56,4 +63,11 @@ TEST(BLEHubScanModeRequest, OverrideHonorsAndApplies) { EXPECT_FALSE(hub.scan_active()); } +TEST(BLEHubScanModeRequest, CapabilityAndSwitchAreIndependent) { + CapableRefusingHub hub; + EXPECT_TRUE(hub.get_capabilities().active_scan); + EXPECT_FALSE(hub.request_scan_mode(false)); + EXPECT_TRUE(hub.scan_active()); +} + } // namespace esphome::ble_device_base::testing From b00f32db8a6fecaac3a92d6fd08c834a2f3055e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Tue, 4 Aug 2026 21:59:11 +0300 Subject: [PATCH 1212/1815] [bthome_mithermometer] Gate log-only MAC helper by log level (#18064) --- esphome/components/bthome_mithermometer/bthome_ble.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/bthome_mithermometer/bthome_ble.cpp b/esphome/components/bthome_mithermometer/bthome_ble.cpp index ff38ab1740..66f147c266 100644 --- a/esphome/components/bthome_mithermometer/bthome_ble.cpp +++ b/esphome/components/bthome_mithermometer/bthome_ble.cpp @@ -25,6 +25,9 @@ static constexpr size_t BTHOME_NONCE_SIZE = 13; static constexpr size_t BTHOME_MIC_SIZE = 4; static constexpr size_t BTHOME_COUNTER_SIZE = 4; +// Both callers are log macros (LOGCONFIG / LOGVV); below CONFIG level they +// compile away and an ungated helper trips -Wunused-function. +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_CONFIG static const char *format_mac_address(std::span buffer, uint64_t address) { std::array mac{}; for (size_t i = 0; i < MAC_ADDRESS_SIZE; i++) { @@ -34,6 +37,7 @@ static const char *format_mac_address(std::span= ESPHOME_LOG_LEVEL_CONFIG static bool get_bthome_value_length(uint8_t obj_type, size_t &value_length) { switch (obj_type) { From a9044264313ea36906ed9b809ad1cee3fe6121ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Aug 2026 14:38:10 -0500 Subject: [PATCH 1213/1815] [core] Unbind the esp-idf toolchain from the esp32 component package (#18068) --- esphome/components/esp32/const.py | 18 ++++++++++-------- esphome/components/esp8266/const.py | 5 ++++- esphome/const.py | 7 +++++-- esphome/espidf/__init__.py | 11 +++++++++++ esphome/espidf/component.py | 2 +- esphome/espidf/toolchain.py | 12 +++++------- tests/unit_tests/test_lazy_imports.py | 15 +++++++++++++++ 7 files changed, 51 insertions(+), 19 deletions(-) diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index af386b618a..09f458c64b 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -1,15 +1,22 @@ import esphome.codegen as cg -# Re-exported for the many esp32-side users; defined in esphome.const so -# the upload/logs fast path can read them without importing this package. +# Re-exported for the many esp32-side users; defined in esphome.const +# and esphome.espidf so the upload/logs fast path can use them without +# importing this package. from esphome.const import ( # noqa: F401 # pylint: disable=unused-import KEY_ESP32, + KEY_FLASH_SIZE, KEY_IDF_VERSION, KEY_VARIANT, ) +# Back compat for external components only; in-tree callers import it +# from esphome.espidf directly. +from esphome.espidf import ( # noqa: F401 # pylint: disable=unused-import + variant_to_idf_target, +) + KEY_BOARD = "board" -KEY_FLASH_SIZE = "flash_size" KEY_SDKCONFIG_OPTIONS = "sdkconfig_options" KEY_COMPONENTS = "components" KEY_EXCLUDE_COMPONENTS = "exclude_components" @@ -69,9 +76,4 @@ VARIANT_FRIENDLY = { } -def variant_to_idf_target(variant: str) -> str: - """Map an esp32 variant name (e.g. "ESP32S3") to its ESP-IDF target name.""" - return variant.lower().replace("-", "") - - esp32_ns = cg.esphome_ns.namespace("esp32") diff --git a/esphome/components/esp8266/const.py b/esphome/components/esp8266/const.py index 229ac61f24..3e89ab989f 100644 --- a/esphome/components/esp8266/const.py +++ b/esphome/components/esp8266/const.py @@ -1,4 +1,8 @@ import esphome.codegen as cg + +# Re-exported from the shared definition; here it indexes the BOARDS +# metadata dicts, whose entries in boards.py spell the literal. +from esphome.const import KEY_FLASH_SIZE # noqa: F401 # pylint: disable=unused-import from esphome.core import CORE KEY_ESP8266 = "esp8266" @@ -8,7 +12,6 @@ CONF_RESTORE_FROM_FLASH = "restore_from_flash" CONF_EARLY_PIN_INIT = "early_pin_init" CONF_ENABLE_SERIAL = "enable_serial" CONF_ENABLE_SERIAL1 = "enable_serial1" -KEY_FLASH_SIZE = "flash_size" KEY_WAVEFORM_REQUIRED = "waveform_required" KEY_SERIAL_REQUIRED = "serial_required" KEY_SERIAL1_REQUIRED = "serial1_required" diff --git a/esphome/const.py b/esphome/const.py index b1302af922..167176cf03 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1423,9 +1423,12 @@ KEY_NAME = "name" KEY_VARIANT = "variant" KEY_PAST_SAFE_MODE = "past_safe_mode" # esp32 storage keys; defined here so the upload/logs fast path -# (storage_json.apply_to_core) can use them without importing the -# esp32 component package. +# (storage_json.apply_to_core, espidf.toolchain) can use them without +# importing the esp32 component package. KEY_ESP32 = "esp32" +# Also used by esp8266 to index its BOARDS metadata dicts, whose +# entries in boards.py spell the literal; do not change the value. +KEY_FLASH_SIZE = "flash_size" KEY_IDF_VERSION = "idf_version" # Entity categories diff --git a/esphome/espidf/__init__.py b/esphome/espidf/__init__.py index e69de29bb2..079eede1f1 100644 --- a/esphome/espidf/__init__.py +++ b/esphome/espidf/__init__.py @@ -0,0 +1,11 @@ +"""ESP-IDF direct build support. + +Deliberately light: the upload fast path imports submodules of this +package without the esp32 component package, so nothing here may pull +in codegen or validation. +""" + + +def variant_to_idf_target(variant: str) -> str: + """Map an esp32 variant name (e.g. "ESP32S3") to its ESP-IDF target name.""" + return variant.lower().replace("-", "") diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index b22b39bf6d..aa6f10c261 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -12,6 +12,7 @@ import os from pathlib import Path from esphome.core import CORE, Library +from esphome.espidf import variant_to_idf_target from esphome.helpers import write_file_if_changed from esphome.platformio.library import ( DEFAULT_BUILD_FLAGS, @@ -54,7 +55,6 @@ def _apply_extra_script(component: IDFComponent) -> None: if not script_path.is_relative_to(library_root) or not script_path.is_file(): return from esphome.components.esp32 import get_esp32_variant - from esphome.components.esp32.const import variant_to_idf_target from esphome.espidf.extra_script import captured_as_build_flags, run_extra_script idf_target = variant_to_idf_target(get_esp32_variant()) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index fd95805c6c..e1688f4170 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -9,20 +9,18 @@ import re import shutil import subprocess -from esphome.components.esp32.const import ( - KEY_ESP32, - KEY_FLASH_SIZE, - KEY_IDF_VERSION, - KEY_VARIANT, - variant_to_idf_target, -) from esphome.const import ( CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, CONF_FRAMEWORK, CONF_SOURCE, + KEY_ESP32, + KEY_FLASH_SIZE, + KEY_IDF_VERSION, + KEY_VARIANT, ) from esphome.core import CORE, EsphomeError +from esphome.espidf import variant_to_idf_target from esphome.espidf.framework import check_esp_idf_install, get_framework_env from esphome.espidf.size_summary import print_summary from esphome.helpers import add_git_ceiling_directory diff --git a/tests/unit_tests/test_lazy_imports.py b/tests/unit_tests/test_lazy_imports.py index 7eff5c9ef5..e9015e129f 100644 --- a/tests/unit_tests/test_lazy_imports.py +++ b/tests/unit_tests/test_lazy_imports.py @@ -121,3 +121,18 @@ def test_api_client_does_not_import_heavy_modules() -> None: "The logs fast path skips validation; importing the validation " "stack anyway defeats the validated-config cache." ) + + +def test_espidf_toolchain_does_not_import_heavy_modules() -> None: + """The esp-idf upload path must not pull the esp32 package back in. + + upload_using_esptool reaches espidf.toolchain for esp-idf builds; + its keys and the variant mapping live in esphome.const and + esphome.espidf precisely so this import stays light. + """ + leaked = _leaked_heavy_modules("esphome.espidf.toolchain") + assert not leaked, ( + f"esphome.espidf.toolchain imports heavy modules: {leaked}. " + "The upload fast path skips validation; importing the validation " + "stack anyway defeats the validated-config cache." + ) From 342f3d69940548b8f7d8a210d60ba5daa1dbe878 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Aug 2026 14:38:28 -0500 Subject: [PATCH 1214/1815] [core] Share the stacktrace analyzer resolution in platform_hooks (#18075) --- esphome/__main__.py | 24 +++-------------- esphome/platform_hooks.py | 34 +++++++++++++++++++++--- tests/unit_tests/test_main.py | 4 +-- tests/unit_tests/test_platform_hooks.py | 35 ++++++++++++++++++++++++- 4 files changed, 70 insertions(+), 27 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index fb51fdaf15..2b7e1ac85c 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -630,27 +630,9 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: return 1 _LOGGER.info("Starting log output from %s with baud rate %s", port, baud_rate) - # Stacktrace analysis is optional; a broken platform import must not - # stop serial log streaming, but it is a real breakage and must not - # masquerade as an ordinary capability gap. - try: - process_stacktrace = platform_hooks.get_platform_hook( - CORE.target_platform, "process_stacktrace" - ) - except ImportError as err: - _LOGGER.debug("Stacktrace analyzer import failed", exc_info=True) - _LOGGER.warning( - 'Stacktrace analysis is unavailable: analyzer for target platform "%s" failed to import: %s', - CORE.target_platform, - err, - ) - process_stacktrace = None - else: - if process_stacktrace is None: - _LOGGER.info( - 'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".', - CORE.target_platform, - ) + # Stacktrace analysis is optional; platform_hooks owns resolution + # and the user-facing messages. + process_stacktrace = platform_hooks.get_stacktrace_handler(CORE.target_platform) backtrace_state = False ser = serial.Serial() diff --git a/esphome/platform_hooks.py b/esphome/platform_hooks.py index 2106f50319..184644f29c 100644 --- a/esphome/platform_hooks.py +++ b/esphome/platform_hooks.py @@ -10,9 +10,10 @@ imports each platform package and fails when they drift. The compile-path ``run_compile`` hook is deliberately not registered: compiling imports the platform package regardless, so its probe in -``__main__.py`` stays eager. The network log client's -``process_stacktrace`` probe in ``esphome/api_client.py`` still uses the -old importlib pattern; converting it is a separate change. +``__main__.py`` stays eager. The serial log path resolves +``process_stacktrace`` through get_stacktrace_handler below; the network +log client's probe in ``esphome/api_client.py`` still uses the old +importlib pattern and is converted separately. """ from __future__ import annotations @@ -111,3 +112,30 @@ def get_platform_hook(platform: str, hook: str) -> Callable[..., Any] | None: hook, ) return handler + + +def get_stacktrace_handler(platform: str) -> Callable[..., Any] | None: + """Resolve ``process_stacktrace`` for *platform*, degrading with a log. + + Stacktrace decoding is a diagnostic nicety. This only distinguishes + an import failure from an ordinary capability gap so the message is + accurate; it returns None for both, and callers own any further + containment. Shared so the user-facing message lives in one place. + """ + try: + handler = get_platform_hook(platform, "process_stacktrace") + except ImportError as err: + # A real breakage, not an ordinary capability gap; say so louder. + _LOGGER.debug("Stacktrace analyzer import failed", exc_info=True) + _LOGGER.warning( + 'Stacktrace analysis is unavailable: analyzer for target platform "%s" failed to import: %s', + platform, + err, + ) + return None + if handler is None: + _LOGGER.info( + 'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".', + platform, + ) + return handler diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 2c9a88afb6..1ca78e1924 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -5732,7 +5732,7 @@ def test_run_miniterm_analyzer_import_failure_keeps_streaming( args = MockArgs() with ( - caplog.at_level("INFO", logger="esphome.__main__"), + caplog.at_level("INFO", logger="esphome.platform_hooks"), patch("serial.Serial", return_value=mock_serial), patch( "esphome.platform_hooks.get_platform_hook", @@ -5762,7 +5762,7 @@ def test_run_miniterm_no_stacktrace_analyzer( args = MockArgs() with ( - caplog.at_level("INFO", logger="esphome.__main__"), + caplog.at_level("INFO", logger="esphome.platform_hooks"), patch("serial.Serial", return_value=mock_serial), ): result = run_miniterm(config, "/dev/ttyUSB0", args) diff --git a/tests/unit_tests/test_platform_hooks.py b/tests/unit_tests/test_platform_hooks.py index 27d37e1d76..97b25e7c0f 100644 --- a/tests/unit_tests/test_platform_hooks.py +++ b/tests/unit_tests/test_platform_hooks.py @@ -9,12 +9,13 @@ that the fast path really avoids the import. from __future__ import annotations import importlib +import logging from unittest.mock import Mock import pytest from esphome import platform_hooks -from esphome.const import PLATFORM_ESP32, Platform +from esphome.const import PLATFORM_BK72XX, PLATFORM_ESP32, Platform def test_no_unregistered_platform_exposes_a_hook() -> None: @@ -151,3 +152,35 @@ def test_lookup_miss_does_not_import_platform_package( Mock(side_effect=AssertionError("platform package imported on registry miss")), ) assert platform_hooks.get_platform_hook(PLATFORM_ESP32, "show_logs") is None + + +def test_get_stacktrace_handler_resolves_registered_platform() -> None: + hook = platform_hooks.get_stacktrace_handler(PLATFORM_ESP32) + from esphome.components import esp32 + + assert hook is esp32.process_stacktrace + + +def test_get_stacktrace_handler_reports_missing_analyzer( + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level("INFO", logger="esphome.platform_hooks") + assert platform_hooks.get_stacktrace_handler(PLATFORM_BK72XX) is None + assert "no compatible analyzer" in caplog.text + # A capability gap is ordinary; it must not warn. + assert not any(r.levelno >= logging.WARNING for r in caplog.records) + + +def test_get_stacktrace_handler_reports_import_failure( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + monkeypatch.setattr( + platform_hooks, + "import_module", + Mock(side_effect=ImportError("broken install")), + ) + assert platform_hooks.get_stacktrace_handler(PLATFORM_ESP32) is None + assert "failed to import: broken install" in caplog.text + # A broken install is a real breakage; it must warn, not inform. + assert any(r.levelno == logging.WARNING for r in caplog.records) From 68640f8074e18efeee2e497e646724174d949220 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Aug 2026 14:46:44 -0500 Subject: [PATCH 1215/1815] [core] Shared slot count factory for codegen sized listener storage (#18057) --- esphome/codegen.py | 2 + esphome/components/bk72xx_ble/__init__.py | 21 +------ .../components/bk72xx_ble_tracker/__init__.py | 13 ---- .../components/ble_device_base/__init__.py | 15 ++--- .../components/esp32_ble_tracker/__init__.py | 8 +-- esphome/components/ln882h_ble/__init__.py | 21 +------ .../components/rp2_ble_tracker/__init__.py | 13 ---- esphome/cpp_helpers.py | 62 ++++++++++++++++++ .../config/bk72xx_controller_only.yaml | 7 +++ .../config/bk72xx_tracker.yaml | 7 +++ .../ble_device_base/test_slot_counter.py | 63 +++++++++++++++++++ tests/component_tests/helpers.py | 16 +++++ tests/unit_tests/test_cpp_helpers.py | 51 +++++++++++++++ 13 files changed, 223 insertions(+), 76 deletions(-) create mode 100644 tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml create mode 100644 tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml create mode 100644 tests/component_tests/ble_device_base/test_slot_counter.py diff --git a/esphome/codegen.py b/esphome/codegen.py index 0694eb4d84..2430f17f3a 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -49,11 +49,13 @@ from esphome.cpp_helpers import ( # noqa: F401 build_registry_entry, build_registry_list, extract_registry_entry_config, + get_slot_count, gpio_pin_expression, past_safe_mode, register_component, register_parented, set_setup_priority, + slot_counter, ) from esphome.cpp_types import ( # noqa: F401 NAN, diff --git a/esphome/components/bk72xx_ble/__init__.py b/esphome/components/bk72xx_ble/__init__.py index b5b4691eea..23f3d06184 100644 --- a/esphome/components/bk72xx_ble/__init__.py +++ b/esphome/components/bk72xx_ble/__init__.py @@ -24,7 +24,6 @@ from esphome.components import libretiny from esphome.components.libretiny.const import FAMILY_BK7231N, FAMILY_BK7238 import esphome.config_validation as cv from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID -from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType DEPENDENCIES = ["bk72xx"] @@ -46,21 +45,9 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -KEY_SCAN_LISTENER_COUNT = "bk72xx_ble_scan_listener_count" - - -def request_scan_listener_slot() -> None: - """Called from a consumer's codegen once per registered scan listener; sizes - the controller's StaticVector listener storage (heap-free, mirrors the - tracker's ble_device_base listener storage).""" - CORE.data[KEY_SCAN_LISTENER_COUNT] = CORE.data.get(KEY_SCAN_LISTENER_COUNT, 0) + 1 - - -@coroutine_with_priority(CoroPriority.FINAL) -async def _add_listener_count() -> None: - # FINAL: every consumer's to_code has requested its slot by now. - if count := CORE.data.get(KEY_SCAN_LISTENER_COUNT, 0): - cg.add_define("BK72XX_BLE_SCAN_LISTENER_COUNT", count) +# Once per registered scan listener; sizes the controller's StaticVector +# listener storage. +request_scan_listener_slot = cg.slot_counter("BK72XX_BLE_SCAN_LISTENER_COUNT") async def to_code(config: ConfigType) -> None: @@ -110,5 +97,3 @@ async def to_code(config: ConfigType) -> None: ) cg.add_define("USE_BK72XX_BLE") - - CORE.add_job(_add_listener_count) diff --git a/esphome/components/bk72xx_ble_tracker/__init__.py b/esphome/components/bk72xx_ble_tracker/__init__.py index 05dd4a7a3c..c000d6e5a3 100644 --- a/esphome/components/bk72xx_ble_tracker/__init__.py +++ b/esphome/components/bk72xx_ble_tracker/__init__.py @@ -24,7 +24,6 @@ from esphome.components import bk72xx_ble, ble_device_base, ota from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW import esphome.config_validation as cv from esphome.const import CONF_CONTINUOUS, CONF_DURATION, CONF_ID, CONF_INTERVAL -from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType CONF_BK72XX_BLE_ID = "bk72xx_ble_id" @@ -53,16 +52,6 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -# Runs at FINAL priority so every BLE sensor has registered through -# ble_device_base (and any tracker-owned listeners have been counted) before -# the StaticVector size is emitted. Same pattern as esp32_ble_tracker. -@coroutine_with_priority(CoroPriority.FINAL) -async def _emit_listener_count() -> None: - count = ble_device_base.get_listener_count() - if count > 0: - cg.add_define("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT", count) - - async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -81,5 +70,3 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_scan_window(ble_device_base.to_ble_units(scan[CONF_WINDOW]))) cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds)) cg.add(var.set_scan_continuous(scan[CONF_CONTINUOUS])) - - CORE.add_job(_emit_listener_count) diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index 8100b41d99..b10e535e7d 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -23,17 +23,16 @@ import esphome.codegen as cg from esphome.components.const import CONF_WINDOW import esphome.config_validation as cv from esphome.const import CONF_ACTIVE, CONF_CONTINUOUS, CONF_DURATION, CONF_INTERVAL -from esphome.core import CORE from esphome.types import ConfigType CODEOWNERS = ["@Bl00d-B0b"] CONF_BLE_HUB_ID = "ble_hub_id" -# CORE.data key: number of parsed-advertisement listeners registered in this -# build. Trackers whose codegen sizes storage at compile time (esp32's -# StaticVector count define) read it in their final coroutine. -KEY_BLE_LISTENER_COUNT = "ble_device_base_listener_count" +# Number of parsed-advertisement listeners registered in this build; read via +# cg.get_slot_count() by esp32_ble_tracker's feature coupling. +LISTENER_COUNT_DEFINE = "ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT" + ble_device_base_ns = cg.esphome_ns.namespace("ble_device_base") @@ -64,16 +63,14 @@ def request_irk_support() -> None: cg.add_define("USE_BLE_DEVICE_IRK") -def get_listener_count() -> int: - """Number of parsed listeners registered so far (for tracker codegen).""" - return CORE.data.get(KEY_BLE_LISTENER_COUNT, 0) +_request_listener_slot = cg.slot_counter(LISTENER_COUNT_DEFINE) async def register_ble_device(var: cg.MockObj, config: ConfigType) -> cg.MockObj: """Register `var` as a parsed-advertisement listener on the configured hub.""" hub = await cg.get_variable(config[CONF_BLE_HUB_ID]) cg.add(hub.register_listener(var)) - CORE.data[KEY_BLE_LISTENER_COUNT] = CORE.data.get(KEY_BLE_LISTENER_COUNT, 0) + 1 + _request_listener_slot() return var diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 7ffde76429..1e0716cb20 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -308,12 +308,10 @@ async def _add_ble_features(): required_features = _get_required_features() # Sensors registered through the neutral ble_device_base path (BLEHub) need # the parsed-device pipeline compiled in, exactly like esp32-path listeners. - neutral_listener_count = ble_device_base.get_listener_count() - if neutral_listener_count > 0: + if cg.get_slot_count(ble_device_base.LISTENER_COUNT_DEFINE): + # The neutral (BLEHub) listener count define itself is emitted by + # ble_device_base's own job; only the feature coupling lives here. required_features.add(BLEFeatures.ESP_BT_DEVICE) - # StaticVector sizing for the neutral (BLEHub) listener list — same - # pattern as the esp32-path registration counts below. - cg.add_define("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT", neutral_listener_count) if BLEFeatures.ESP_BT_DEVICE in required_features: cg.add_define("USE_ESP32_BLE_DEVICE") cg.add_define("USE_ESP32_BLE_UUID") diff --git a/esphome/components/ln882h_ble/__init__.py b/esphome/components/ln882h_ble/__init__.py index 4299c7f006..aadc1f3b2f 100644 --- a/esphome/components/ln882h_ble/__init__.py +++ b/esphome/components/ln882h_ble/__init__.py @@ -12,7 +12,6 @@ component does (LibreTiny v1.13.0+). import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID -from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType DEPENDENCIES = ["ln882x"] @@ -32,21 +31,9 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -KEY_SCAN_LISTENER_COUNT = "ln882h_ble_scan_listener_count" - - -def request_scan_listener_slot() -> None: - """Called from a consumer's codegen once per registered scan listener; sizes - the controller's StaticVector listener storage (heap-free, mirrors the - tracker's ble_device_base listener storage).""" - CORE.data[KEY_SCAN_LISTENER_COUNT] = CORE.data.get(KEY_SCAN_LISTENER_COUNT, 0) + 1 - - -@coroutine_with_priority(CoroPriority.FINAL) -async def _add_listener_count() -> None: - # FINAL: every consumer's to_code has requested its slot by now. - if count := CORE.data.get(KEY_SCAN_LISTENER_COUNT, 0): - cg.add_define("LN882H_BLE_SCAN_LISTENER_COUNT", count) +# Once per registered scan listener; sizes the controller's StaticVector +# listener storage. +request_scan_listener_slot = cg.slot_counter("LN882H_BLE_SCAN_LISTENER_COUNT") async def to_code(config: ConfigType) -> None: @@ -64,5 +51,3 @@ async def to_code(config: ConfigType) -> None: cg.add_platformio_option("custom_options.proj_config#h", ["CFG_SUPPORT_BLE=1"]) cg.add_define("USE_LN882H_BLE") - - CORE.add_job(_add_listener_count) diff --git a/esphome/components/rp2_ble_tracker/__init__.py b/esphome/components/rp2_ble_tracker/__init__.py index 5f29fece8a..cce6adabe6 100644 --- a/esphome/components/rp2_ble_tracker/__init__.py +++ b/esphome/components/rp2_ble_tracker/__init__.py @@ -20,7 +20,6 @@ from esphome.const import ( CONF_ID, CONF_INTERVAL, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType CONF_RP2040_BLE_ID = "rp2040_ble_id" @@ -54,16 +53,6 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -# Runs at FINAL priority so every BLE sensor has registered through -# ble_device_base (and any tracker-owned listeners have been counted) before -# the StaticVector size is emitted. Same pattern as esp32_ble_tracker. -@coroutine_with_priority(CoroPriority.FINAL) -async def _emit_listener_count() -> None: - count = ble_device_base.get_listener_count() - if count > 0: - cg.add_define("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT", count) - - async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -80,5 +69,3 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds)) cg.add(var.set_scan_active(scan[CONF_ACTIVE])) cg.add(var.set_scan_continuous(scan[CONF_CONTINUOUS])) - - CORE.add_job(_emit_listener_count) diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index b2338e5bc1..53b59cb124 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from dataclasses import dataclass, field import logging @@ -136,6 +137,67 @@ async def _generate_component_source_table() -> None: ) +_SLOT_COUNTER_DOMAIN = "slot_counter" + + +@dataclass +class _SlotCounterState: + """Per-run slot counter state: requested counts and already-emitted defines.""" + + counts: dict[str, int] = field(default_factory=dict) + emitted: set[str] = field(default_factory=set) + + +def _get_slot_counter_state() -> _SlotCounterState: + """Get or create the slot counter state from CORE.data.""" + if _SLOT_COUNTER_DOMAIN not in CORE.data: + CORE.data[_SLOT_COUNTER_DOMAIN] = _SlotCounterState() + return CORE.data[_SLOT_COUNTER_DOMAIN] + + +def get_slot_count(define: str) -> int: + """Number of slots requested so far for `define`.""" + return _get_slot_counter_state().counts.get(define, 0) + + +def slot_counter(define: str) -> Callable[[], None]: + """Create a request_slot function for codegen-sized storage. + + The pattern behind a StaticVector listener array: a consumer's to_code + calls the returned function once per slot it will occupy at runtime, and + at FINAL priority — after every consumer's to_code has run — `define` is + emitted with the requested count. No requests, no define: the guarded + storage and its registration method compile out entirely. + + The counts live in a table under CORE.data, which clears between runs. + A request arriving after the define was already emitted raises instead of + silently undercounting: the define would keep the stale smaller value and + StaticVector::push_back would drop the extra listener at runtime. + """ + + @coroutine_with_priority(CoroPriority.FINAL) + async def emit_job() -> None: + state = _get_slot_counter_state() + state.emitted.add(define) + # Scheduled only by the first request, so the count is always >= 1 here. + add_define(define, state.counts[define]) + + def request_slot() -> None: + state = _get_slot_counter_state() + if define in state.emitted: + raise ValueError( + f"slot_counter('{define}'): slot requested after the count " + f"define was emitted; request slots from to_code, not from a " + f"job running after FINAL emission" + ) + counts = state.counts + counts[define] = (count := counts.get(define, 0) + 1) + if count == 1: + CORE.add_job(emit_job) + + return request_slot + + async def gpio_pin_expression(conf): """Generate an expression for the given pin option. diff --git a/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml b/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml new file mode 100644 index 0000000000..4d4dab0198 --- /dev/null +++ b/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml @@ -0,0 +1,7 @@ +esphome: + name: slotcount-controller + +bk72xx: + board: generic-bk7252 + +bk72xx_ble: diff --git a/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml b/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml new file mode 100644 index 0000000000..79e9644006 --- /dev/null +++ b/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml @@ -0,0 +1,7 @@ +esphome: + name: slotcount-tracker + +bk72xx: + board: generic-bk7252 + +bk72xx_ble_tracker: diff --git a/tests/component_tests/ble_device_base/test_slot_counter.py b/tests/component_tests/ble_device_base/test_slot_counter.py new file mode 100644 index 0000000000..852d749e51 --- /dev/null +++ b/tests/component_tests/ble_device_base/test_slot_counter.py @@ -0,0 +1,63 @@ +"""Tests for the shared slot_counter codegen factory. + +The factory is exercised end to end through the real controllers: a tracker +config must emit the platform's scan listener count define, and a +controller-only config must emit nothing so the guarded StaticVector storage +compiles out. +""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +from esphome.core import CORE + +from ..helpers import get_define_value + + +def test_tracker_requests_one_slot( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The tracker's to_code requests a slot; the FINAL job emits the count. + + The neutral listener count must stay absent from the same build: no BLE + consumer registered through register_ble_device(). + """ + generate_main(component_config_path("bk72xx_tracker.yaml")) + assert get_define_value("BK72XX_BLE_SCAN_LISTENER_COUNT") == "1" + assert get_define_value("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT") is None + + +def test_controller_only_emits_no_count( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """No consumer, no define — the guarded listener storage compiles out.""" + generate_main(component_config_path("bk72xx_controller_only.yaml")) + assert get_define_value("BK72XX_BLE_SCAN_LISTENER_COUNT") is None + + +def test_neutral_listener_count_emitted_when_requested() -> None: + """Registering through register_ble_device() emits the neutral count. + + No in-tree sensor registers through ble_device_base.register_ble_device() + yet (consumer migration is a follow-up), so the coroutine is driven with a + mock hub instead of a config; every tracker's #ifdef-guarded listener + storage keys on this define, and a broken emit path would compile the + storage out silently. + """ + import esphome.codegen as cg + from esphome.components import ble_device_base + from esphome.core import ID + + hub_id = ID("hub", type=ble_device_base.BLEHub) + CORE.register_variable(hub_id, cg.MockObj("hub")) + CORE.add_job( + ble_device_base.register_ble_device, + cg.MockObj("listener"), + {ble_device_base.CONF_BLE_HUB_ID: hub_id}, + ) + CORE.flush_tasks() + assert get_define_value(ble_device_base.LISTENER_COUNT_DEFINE) == "1" diff --git a/tests/component_tests/helpers.py b/tests/component_tests/helpers.py index 2eb588c0ca..3b5e5bbd6e 100644 --- a/tests/component_tests/helpers.py +++ b/tests/component_tests/helpers.py @@ -27,3 +27,19 @@ def extract_packed_value(main_cpp: str, var_name: str) -> int: 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)) + + +def get_define_value(name: str) -> str | None: + """Rendered value of a CORE define, or None when absent. + + Values are codegen expressions (IntLiteral); they are compared rendered. + A value-less define (e.g. USE_BK72XX_BLE) is present but renders as the + string "None", while an absent define returns the None object — easy to + conflate in assertions, so use this helper for valued defines only. + """ + from esphome.core import CORE + + for define in CORE.defines: + if define.name == name: + return str(define.value) + return None diff --git a/tests/unit_tests/test_cpp_helpers.py b/tests/unit_tests/test_cpp_helpers.py index e389b56ada..1c0e0d0a93 100644 --- a/tests/unit_tests/test_cpp_helpers.py +++ b/tests/unit_tests/test_cpp_helpers.py @@ -4,6 +4,7 @@ from unittest.mock import Mock import pytest from esphome import const, cpp_helpers as ch +from esphome.core import CoroPriority, coroutine_with_priority from esphome.cpp_helpers import ComponentSourcePool, register_component_source @@ -167,3 +168,53 @@ def test_register_component_source_overflow_suppressed_in_testing_mode( idx = register_component_source("overflow_component") assert idx == 0 assert "Too many unique component source names" not in caplog.text + + +def _define_value(name: str) -> str | None: + for define in ch.CORE.defines: + if define.name == name: + # Values are codegen expressions (IntLiteral); compare rendered. + return str(define.value) + return None + + +def test_slot_counter_emits_requested_count() -> None: + """Each request bumps the count; the self-scheduled FINAL job emits it.""" + request = ch.slot_counter("TEST_SLOT_COUNT") + request() + request() + ch.CORE.flush_tasks() + assert _define_value("TEST_SLOT_COUNT") == "2" + + +def test_slot_counter_without_requests_emits_nothing() -> None: + """No requests, no job, no define — the guarded storage compiles out.""" + ch.slot_counter("TEST_SLOT_COUNT_UNUSED") + ch.CORE.flush_tasks() + assert _define_value("TEST_SLOT_COUNT_UNUSED") is None + + +def test_slot_counter_request_from_final_job_still_emits() -> None: + """The FIRST request for a define may come from a FINAL job: its emit job + is scheduled mid-drain and flush_tasks() loops until the heap is empty. + Later requests do not get this guarantee — see the companion test.""" + request = ch.slot_counter("TEST_SLOT_COUNT_LATE") + + @coroutine_with_priority(CoroPriority.FINAL) + async def late_requester() -> None: + request() + + ch.CORE.add_job(late_requester) + ch.CORE.flush_tasks() + assert _define_value("TEST_SLOT_COUNT_LATE") == "1" + + +def test_slot_counter_request_after_emit_raises() -> None: + """The boundary of FINAL-time requests: once the define was emitted, a + further request would silently undersize the storage, so it fails loudly.""" + request = ch.slot_counter("TEST_SLOT_COUNT_TOO_LATE") + request() + ch.CORE.flush_tasks() + assert _define_value("TEST_SLOT_COUNT_TOO_LATE") == "1" + with pytest.raises(ValueError, match="TEST_SLOT_COUNT_TOO_LATE"): + request() From d344fd74d1c8ce7c3242d93e483ec030b084548a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Aug 2026 14:47:07 -0500 Subject: [PATCH 1216/1815] [core] Read the esp32 variant for esptool without importing the esp32 package (#18067) --- esphome/__main__.py | 9 ++-- esphome/storage_json.py | 6 +-- .../fixtures/lazy_imports/_leak_report.py | 19 ++++++++ .../lazy_imports/esptool_upload_fast_path.py | 45 +++++++++++++++++++ .../lazy_imports/storage_json_fast_path.py | 12 ++--- tests/unit_tests/test_lazy_imports.py | 42 ++++++++++++----- tests/unit_tests/test_main.py | 13 +++++- 7 files changed, 120 insertions(+), 26 deletions(-) create mode 100644 tests/unit_tests/fixtures/lazy_imports/_leak_report.py create mode 100644 tests/unit_tests/fixtures/lazy_imports/esptool_upload_fast_path.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 2b7e1ac85c..d31d3e9399 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -48,6 +48,8 @@ from esphome.const import ( CONF_WEB_SERVER, CONF_WIFI, ENV_NOGITIGNORE, + KEY_ESP32, + KEY_VARIANT, SECRETS_FILES, Toolchain, ) @@ -923,9 +925,10 @@ def upload_using_esptool( mcu = "esp8266" if CORE.is_esp32: - from esphome.components.esp32 import get_esp32_variant - - mcu = get_esp32_variant().lower() + # Same lookup as esp32.get_esp32_variant(), read directly so the + # serial upload path does not import the esp32 package; both the + # validator and the warm-cache apply_to_core populate this key. + mcu = CORE.data[KEY_ESP32][KEY_VARIANT].lower() line_callbacks: list[Callable[[str], str | None]] = [] if ( diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 2aa76aabaa..2ba26ec711 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -329,9 +329,9 @@ class StorageJSON: } # The compile pipeline populates CORE.data[KEY_ESP32] when esp32's # validator runs; on the cache fast path that validator is skipped, - # so populate the variant upload_using_esptool reads via - # esp32.get_esp32_variant(). target_platform on disk is the variant - # (e.g. "ESP32S3"); core_platform is the family (e.g. "esp32"). + # so populate the variant upload_using_esptool reads from + # CORE.data[KEY_ESP32][KEY_VARIANT]. target_platform on disk is the + # variant (e.g. "ESP32S3"); core_platform is the family (e.g. "esp32"). if target_platform == const.PLATFORM_ESP32: esp32_data = {KEY_VARIANT: self.target_platform} if self.framework_version: diff --git a/tests/unit_tests/fixtures/lazy_imports/_leak_report.py b/tests/unit_tests/fixtures/lazy_imports/_leak_report.py new file mode 100644 index 0000000000..00d387cd04 --- /dev/null +++ b/tests/unit_tests/fixtures/lazy_imports/_leak_report.py @@ -0,0 +1,19 @@ +"""Shared tail for the lazy-import fixture scripts.""" + +import sys + + +def print_leaked_modules() -> None: + """Report argv-listed heavy modules (plus any component package) loaded. + + Any component package counts as a leak, not just the ones on the + watch list: executing one drags in codegen/validation machinery by + design. + """ + leaked = [module for module in sys.argv[1:] if module in sys.modules] + leaked += [ + module + for module in sys.modules + if module.startswith("esphome.components.") and module not in leaked + ] + print(",".join(leaked)) diff --git a/tests/unit_tests/fixtures/lazy_imports/esptool_upload_fast_path.py b/tests/unit_tests/fixtures/lazy_imports/esptool_upload_fast_path.py new file mode 100644 index 0000000000..e622948aed --- /dev/null +++ b/tests/unit_tests/fixtures/lazy_imports/esptool_upload_fast_path.py @@ -0,0 +1,45 @@ +"""Run the esptool serial-upload path and report which heavy modules loaded. + +Executed as a subprocess by test_lazy_imports.py: heavy module names come +in on argv, the ones found in sys.modules afterwards go out on stdout. +The variant reaches the esptool command line from CORE.data directly; if +someone re-adds the esp32 package import for it, this reports the leak. +""" + +import os +import sys +from unittest.mock import patch + +from _leak_report import print_leaked_modules + +from esphome.__main__ import upload_using_esptool +from esphome.const import ( + CONF_ESPHOME, + KEY_CORE, + KEY_ESP32, + KEY_TARGET_PLATFORM, + KEY_VARIANT, +) +from esphome.core import CORE + +# An ambient ESPHOME_USE_SUBPROCESS would route past the patched +# run_external_command into run_external_process and confuse the checks. +os.environ.pop("ESPHOME_USE_SUBPROCESS", None) + +CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} +CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32S3"} + +with patch("esphome.__main__.run_external_command", return_value=0) as mock_run: + rc = upload_using_esptool( + {CONF_ESPHOME: {"platformio_options": {}}}, "/dev/ttyUSB0", "firmware.bin", None + ) + +# Fail loudly if the upload path stopped doing its work; otherwise an +# empty leak list could just mean nothing ran. +if rc != 0: + sys.exit(f"upload_using_esptool returned {rc}") +cmd = list(mock_run.call_args[0][1:]) +if cmd[cmd.index("--chip") + 1] != "esp32s3": + sys.exit(f"variant did not reach the esptool command line: {cmd}") + +print_leaked_modules() diff --git a/tests/unit_tests/fixtures/lazy_imports/storage_json_fast_path.py b/tests/unit_tests/fixtures/lazy_imports/storage_json_fast_path.py index 01b23b8f04..f83a398cda 100644 --- a/tests/unit_tests/fixtures/lazy_imports/storage_json_fast_path.py +++ b/tests/unit_tests/fixtures/lazy_imports/storage_json_fast_path.py @@ -6,6 +6,8 @@ in on argv, the ones found in sys.modules afterwards go out on stdout. import sys +from _leak_report import print_leaked_modules + from esphome.const import KEY_ESP32, KEY_IDF_VERSION, KEY_VARIANT from esphome.core import CORE, Version from esphome.storage_json import StorageJSON @@ -41,12 +43,4 @@ if esp32_data.get(KEY_VARIANT) != "ESP32S3": if esp32_data.get(KEY_IDF_VERSION) != Version(5, 3, 1): sys.exit(f"apply_to_core did not parse the framework version: {esp32_data!r}") -# Any component package counts as a leak, not just the ones on the watch -# list: executing one drags in codegen/validation machinery by design. -leaked = [module for module in sys.argv[1:] if module in sys.modules] -leaked += [ - module - for module in sys.modules - if module.startswith("esphome.components.") and module not in leaked -] -print(",".join(leaked)) +print_leaked_modules() diff --git a/tests/unit_tests/test_lazy_imports.py b/tests/unit_tests/test_lazy_imports.py index e9015e129f..27e102a1f9 100644 --- a/tests/unit_tests/test_lazy_imports.py +++ b/tests/unit_tests/test_lazy_imports.py @@ -78,16 +78,13 @@ def test_watched_heavy_modules_exist() -> None: ) -def test_storage_json_fast_path_does_not_import_heavy_modules( - fixture_path: Path, -) -> None: - """``apply_to_core`` runs on the upload/logs fast path for every - platform; parsing the stored framework version must not drag in the - validation stack or the esp32 component package. +def _leaked_from_fixture(fixture_path: Path, script_name: str) -> str: + """Run a fixture script with the watched modules on argv. + + Running a script file drops the cwd from sys.path, so prepend the + repo root for the child; a non-zero exit surfaces the child's stderr. """ - script = fixture_path / "lazy_imports" / "storage_json_fast_path.py" - # Running a script file drops the cwd from sys.path, so prepend the - # repo root for the child; check=False keeps its stderr visible. + script = fixture_path / "lazy_imports" / script_name python_path = str(Path(__file__).parents[2]) if ambient := os.environ.get("PYTHONPATH"): python_path = os.pathsep.join((python_path, ambient)) @@ -100,7 +97,17 @@ def test_storage_json_fast_path_does_not_import_heavy_modules( check=False, ) assert result.returncode == 0, result.stderr - leaked = result.stdout.strip() + return result.stdout.strip() + + +def test_storage_json_fast_path_does_not_import_heavy_modules( + fixture_path: Path, +) -> None: + """``apply_to_core`` runs on the upload/logs fast path for every + platform; parsing the stored framework version must not drag in the + validation stack or the esp32 component package. + """ + leaked = _leaked_from_fixture(fixture_path, "storage_json_fast_path.py") assert not leaked, ( f"storage_json.apply_to_core pulls in heavy modules: {leaked}. " "The upload/logs fast path skips validation; importing the " @@ -108,6 +115,21 @@ def test_storage_json_fast_path_does_not_import_heavy_modules( ) +def test_esptool_upload_fast_path_does_not_import_heavy_modules( + fixture_path: Path, +) -> None: + """The esptool serial upload reads the esp32 variant from CORE.data; + resolving it must not drag in the esp32 component package or the + validation stack. + """ + leaked = _leaked_from_fixture(fixture_path, "esptool_upload_fast_path.py") + assert not leaked, ( + f"upload_using_esptool pulls in heavy modules: {leaked}. " + "The upload fast path skips validation; importing the validation " + "stack anyway defeats the validated-config cache." + ) + + def test_api_client_does_not_import_heavy_modules() -> None: """``esphome.api_client`` is on the logs fast path and must stay light. diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 1ca78e1924..9bd09eed32 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -64,7 +64,12 @@ from esphome.__main__ import ( from esphome.address_cache import AddressCache from esphome.bundle import BUNDLE_EXTENSION, BundleFile, BundleResult from esphome.components import esp32 -from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.esp32 import ( + KEY_ESP32, + KEY_VARIANT, + VARIANT_ESP32, + get_esp32_variant, +) from esphome.const import ( CONF_API, CONF_AUTH, @@ -1622,6 +1627,12 @@ def test_upload_using_esptool_path_conversion( assert isinstance(partitions_path, str) assert partitions_path.endswith("partitions.bin") + # The chip argument must track get_esp32_variant: upload_using_esptool + # reads CORE.data directly to avoid the esp32 package import, and the + # two resolutions must not drift. + chip = cmd_list[cmd_list.index("--chip") + 1] + assert chip == get_esp32_variant().lower() + def test_upload_using_esptool_skips_missing_extra_flash_images( tmp_path: Path, From 79a305d69a95bc84311ba98ee7922d26217efe68 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Aug 2026 14:48:39 -0500 Subject: [PATCH 1217/1815] [core] Drop the duplicate ESPHome version parser (#18065) --- esphome/config_validation.py | 28 +++++++++++++---- esphome/util.py | 7 ----- tests/unit_tests/test_config_validation.py | 36 ++++++++++++++++++++++ 3 files changed, 58 insertions(+), 13 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 2de6898177..c04d43bbee 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -99,7 +99,6 @@ from esphome.schema_extractors import ( schema_extractor_registry, schema_extractor_typed, ) -from esphome.util import parse_esphome_version from esphome.voluptuous_schema import _Schema from esphome.yaml_util import SensitiveStr, make_data_base @@ -2612,13 +2611,30 @@ def require_framework_version( return validator -def require_esphome_version(year, month, patch): +def require_esphome_version( + year: Version | int, month: int | None = None, patch: int | None = None +): + """Validator requiring at least the given ESPHome version. + + Accepts a single ``Version`` like the sibling + ``require_framework_version``, or the legacy ``(year, month, patch)`` + ints external components already pass. + """ + if isinstance(year, Version): + required = year + elif month is None or patch is None: + raise ValueError( + "require_esphome_version needs a Version or (year, month, patch)" + ) + else: + required = Version(year, month, patch) + def validator(value): - esphome_version = parse_esphome_version() - if esphome_version < (year, month, patch): - requires_version = f"{year}.{month}.{patch}" + # A dev or beta build of the required version still satisfies it, + # matching the old tuple comparison that dropped the suffix. + if Version.parse(ESPHOME_VERSION) < required: raise Invalid( - f"This component requires at least ESPHome version {requires_version}" + f"This component requires at least ESPHome version {required}" ) return value diff --git a/esphome/util.py b/esphome/util.py index ed95ea24d2..4a5986a90d 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -329,13 +329,6 @@ def is_dev_esphome_version(): return "dev" in const.__version__ -def parse_esphome_version() -> tuple[int, int, int]: - match = re.match(r"^(\d+).(\d+).(\d+)(-dev\d*|b\d*)?$", const.__version__) - if match is None: - raise ValueError(f"Failed to parse ESPHome version '{const.__version__}'") - return int(match.group(1)), int(match.group(2)), int(match.group(3)) - - # Custom OrderedDict with nicer repr method for debugging class OrderedDict(collections.OrderedDict): def __repr__(self): diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 79bfc303b7..4a4e37e5c4 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -2,6 +2,7 @@ import json import logging from pathlib import Path import string +from unittest.mock import patch from hypothesis import example, given, settings from hypothesis.strategies import builds, integers, ip_addresses, one_of, text @@ -2926,11 +2927,46 @@ def test_require_esphome_version_ok() -> None: assert cv.require_esphome_version(1, 0, 0)("test") == "test" +def test_require_esphome_version_accepts_version_object() -> None: + """The Version form matches require_framework_version's style.""" + assert cv.require_esphome_version(cv.Version(1, 0, 0))("test") == "test" + with pytest.raises(Invalid, match="at least ESPHome version 9999.0.0"): + cv.require_esphome_version(cv.Version(9999, 0, 0))("test") + + +def test_require_esphome_version_partial_ints_fail_at_call_site() -> None: + """Missing ints raise immediately instead of a TypeError inside the validator.""" + with pytest.raises(ValueError, match="needs a Version or"): + cv.require_esphome_version(2026, 8) + with pytest.raises(ValueError, match="needs a Version or"): + cv.require_esphome_version(2026) + + def test_require_esphome_version_too_old() -> None: with pytest.raises(Invalid, match="at least ESPHome version 9999.0.0"): cv.require_esphome_version(9999, 0, 0)("test") +@pytest.mark.parametrize("current", ["2026.8.0", "2026.8.0b1", "2026.8.0-dev20260801"]) +def test_require_esphome_version_prerelease_of_required_passes(current: str) -> None: + """A dev or beta build of the required version satisfies it. + + Pins the behavior of the old tuple comparison that dropped the + suffix, now expressed through Version ordering where the extra field + only breaks ties upward. + """ + with patch.object(cv, "ESPHOME_VERSION", current): + assert cv.require_esphome_version(2026, 8, 0)("test") == "test" + + +def test_require_esphome_version_older_prerelease_fails() -> None: + with ( + patch.object(cv, "ESPHOME_VERSION", "2026.7.0-dev20260701"), + pytest.raises(Invalid, match="at least ESPHome version 2026.8.0"), + ): + cv.require_esphome_version(2026, 8, 0)("test") + + # --------------------------------------------------------------------------- # suppress_invalid / validate_source_shorthand / rename_key # --------------------------------------------------------------------------- From d47acf5d0b2b359cc13ad56a8cc07eecf7ef33f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Aug 2026 15:12:57 -0500 Subject: [PATCH 1218/1815] [core] Read the mqtt IP discovery flag without importing the mqtt component (#18066) --- esphome/__main__.py | 3 +-- esphome/components/mqtt/__init__.py | 2 +- esphome/const.py | 1 + script/ci-custom.py | 2 +- tests/unit_tests/test_lazy_imports.py | 34 +++++++++++++++++++++++++++ tests/unit_tests/test_main.py | 8 +++++++ 6 files changed, 46 insertions(+), 4 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index d31d3e9399..4b380f1335 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -29,6 +29,7 @@ from esphome.const import ( CONF_BROKER, CONF_DEASSERT_RTS_DTR, CONF_DISABLED, + CONF_DISCOVER_IP, CONF_ESPHOME, CONF_LEVEL, CONF_LOG_TOPIC, @@ -486,8 +487,6 @@ def has_web_server_ota() -> bool: def has_mqtt_ip_lookup() -> bool: """Check if MQTT is available and IP lookup is supported.""" - from esphome.components.mqtt import CONF_DISCOVER_IP - if CONF_MQTT not in CORE.config: return False # Default Enabled diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 35d496adeb..713969ab88 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -21,6 +21,7 @@ from esphome.const import ( CONF_CLIENT_ID, CONF_COMMAND_RETAIN, CONF_COMMAND_TOPIC, + CONF_DISCOVER_IP, CONF_DISCOVERY, CONF_DISCOVERY_OBJECT_ID_GENERATOR, CONF_DISCOVERY_PREFIX, @@ -74,7 +75,6 @@ def AUTO_LOAD(): return ["json"] -CONF_DISCOVER_IP = "discover_ip" CONF_IDF_SEND_ASYNC = "idf_send_async" CONF_WAIT_FOR_CONNECTION = "wait_for_connection" diff --git a/esphome/const.py b/esphome/const.py index 167176cf03..636cc39943 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -347,6 +347,7 @@ CONF_DISABLE_CRC = "disable_crc" CONF_DISABLED = "disabled" CONF_DISABLED_BY_DEFAULT = "disabled_by_default" CONF_DISCONNECT_DELAY = "disconnect_delay" +CONF_DISCOVER_IP = "discover_ip" CONF_DISCOVERY = "discovery" CONF_DISCOVERY_OBJECT_ID_GENERATOR = "discovery_object_id_generator" CONF_DISCOVERY_PREFIX = "discovery_prefix" diff --git a/script/ci-custom.py b/script/ci-custom.py index 9f3d836f65..2d2da20995 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -557,7 +557,7 @@ def lint_constants_usage(): # Maximum allowed CONF_ constants in esphome/const.py. # This file is frozen — new constants go in esphome/components/const/__init__.py. # Decrease this number when constants are moved out of const.py. -CONST_PY_MAX_CONF = 1016 +CONST_PY_MAX_CONF = 1017 @lint_content_check(include=["esphome/const.py"]) diff --git a/tests/unit_tests/test_lazy_imports.py b/tests/unit_tests/test_lazy_imports.py index 27e102a1f9..69202b1a72 100644 --- a/tests/unit_tests/test_lazy_imports.py +++ b/tests/unit_tests/test_lazy_imports.py @@ -158,3 +158,37 @@ def test_espidf_toolchain_does_not_import_heavy_modules() -> None: "The upload fast path skips validation; importing the validation " "stack anyway defeats the validated-config cache." ) + + +def test_has_mqtt_ip_lookup_does_not_import_mqtt() -> None: + """``has_mqtt_ip_lookup`` runs on the upload/logs fast path for mqtt + configs; reading ``CONF_DISCOVER_IP`` must not drag in the mqtt + component and, with it, the validation stack. + + Runs in a subprocess because this session's other tests import the + mqtt component; the fast path itself must not. + """ + check = ( + "import sys; from esphome.__main__ import has_mqtt_ip_lookup; " + "from esphome.core import CORE; from esphome.const import CONF_MQTT; " + "CORE.config = {CONF_MQTT: {}}; " + "assert has_mqtt_ip_lookup() is True, 'mqtt IP lookup default broke'; " + f"leaked = [m for m in {HEAVY_MODULES!r} if m in sys.modules]; " + "leaked += [m for m in sys.modules if m.startswith('esphome.components.')]; " + "print(','.join(leaked))" + ) + # check=False keeps the child's stderr (its assertion message or an + # import traceback) visible on failure. + result = subprocess.run( + [sys.executable, "-c", check], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + leaked = result.stdout.strip() + assert not leaked, ( + f"has_mqtt_ip_lookup pulls in heavy modules: {leaked}. " + "The upload/logs fast path skips validation; importing the " + "validation stack anyway defeats the validated-config cache." + ) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 9bd09eed32..09c8d25249 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -3257,6 +3257,14 @@ def test_get_port_type() -> None: assert get_port_type("BOOTSEL") == "BOOTSEL" +def test_mqtt_reexports_discover_ip() -> None: + """The old import path must keep working for external code.""" + from esphome.components import mqtt + from esphome.const import CONF_DISCOVER_IP + + assert mqtt.CONF_DISCOVER_IP is CONF_DISCOVER_IP + + def test_has_mqtt_ip_lookup() -> None: """Test has_mqtt_ip_lookup function.""" From 1f5df20cb2261214498cb37c12c285bad05114e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Aug 2026 16:04:22 -0500 Subject: [PATCH 1219/1815] [rp2040_ble] Codegen sized StaticVector for scan listeners (#18058) --- esphome/components/rp2040_ble/__init__.py | 5 ++++ esphome/components/rp2040_ble/rp2040_ble.cpp | 2 ++ esphome/components/rp2040_ble/rp2040_ble.h | 12 ++++++-- .../components/rp2_ble_tracker/__init__.py | 3 ++ esphome/core/defines.h | 1 + .../config/rp2_controller_only.yaml | 7 +++++ .../ble_device_base/config/rp2_tracker.yaml | 7 +++++ .../ble_device_base/test_slot_counter.py | 28 ++++++++++++++++--- 8 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 tests/component_tests/ble_device_base/config/rp2_controller_only.yaml create mode 100644 tests/component_tests/ble_device_base/config/rp2_tracker.yaml diff --git a/esphome/components/rp2040_ble/__init__.py b/esphome/components/rp2040_ble/__init__.py index 8e50c8e1ef..4baee7e234 100644 --- a/esphome/components/rp2040_ble/__init__.py +++ b/esphome/components/rp2040_ble/__init__.py @@ -31,6 +31,11 @@ def _validate_board(config: ConfigType) -> ConfigType: FINAL_VALIDATE_SCHEMA = _validate_board +# Once per registered scan listener; sizes the controller's StaticVector +# listener storage. +request_scan_listener_slot = cg.slot_counter("RP2040_BLE_SCAN_LISTENER_COUNT") + + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/rp2040_ble/rp2040_ble.cpp b/esphome/components/rp2040_ble/rp2040_ble.cpp index 8280c19d56..405710f3e8 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.cpp +++ b/esphome/components/rp2040_ble/rp2040_ble.cpp @@ -117,8 +117,10 @@ void RP2040BLE::loop() { if (report == nullptr) return; do { +#ifdef RP2040_BLE_SCAN_LISTENER_COUNT for (auto *listener : this->scan_listeners_) listener->on_scan_report(*report); +#endif this->report_pool_.release(report); } while ((report = this->report_queue_.pop()) != nullptr); diff --git a/esphome/components/rp2040_ble/rp2040_ble.h b/esphome/components/rp2040_ble/rp2040_ble.h index af7feddd26..cc015c0503 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.h +++ b/esphome/components/rp2040_ble/rp2040_ble.h @@ -6,12 +6,12 @@ #include "esphome/core/component.h" #include "esphome/core/event_pool.h" +#include "esphome/core/helpers.h" #include "esphome/core/lock_free_queue.h" #include #include -#include namespace esphome::rp2040_ble { @@ -79,8 +79,12 @@ class RP2040BLE final : public Component { /// power-up). void get_mac_msb_first(uint8_t out[6]) const; +#ifdef RP2040_BLE_SCAN_LISTENER_COUNT /// Register a consumer for scan reports (delivered on the main loop via loop()). + /// Storage is codegen-sized: the consumer's codegen requests a slot via + /// request_scan_listener_slot(), which emits RP2040_BLE_SCAN_LISTENER_COUNT. void register_scan_listener(BLEScanListener *listener) { this->scan_listeners_.push_back(listener); } +#endif /// Start a controller scan; active sends scan requests and receives scan /// responses as separate reports. Interval/window are in BLE units @@ -102,7 +106,11 @@ class RP2040BLE final : public Component { void enqueue_scan_report_(const uint8_t *mac_lsb_first, int8_t rssi, uint8_t addr_type, uint8_t adv_event_type, const uint8_t *data, uint16_t data_len); - std::vector scan_listeners_; +#ifdef RP2040_BLE_SCAN_LISTENER_COUNT + // Codegen-sized: no heap allocation, no std::vector template instantiation — + // the same StaticVector pattern as the tracker's ble_device_base listeners. + StaticVector scan_listeners_; +#endif // Report ring: the BTstack packet handler (async-context IRQ) allocates a // report from the pool, fills it and pushes the pointer; loop() pops, // dispatches and releases. Lock-free SPSC — the esp32_ble/bk72xx_ble pattern. diff --git a/esphome/components/rp2_ble_tracker/__init__.py b/esphome/components/rp2_ble_tracker/__init__.py index cce6adabe6..651307fc5b 100644 --- a/esphome/components/rp2_ble_tracker/__init__.py +++ b/esphome/components/rp2_ble_tracker/__init__.py @@ -59,6 +59,9 @@ async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_RP2040_BLE_ID]) cg.add(var.set_parent(parent)) + # The tracker registers itself as a controller scan listener in setup(); + # request the codegen-sized StaticVector slot for it. + rp2040_ble.request_scan_listener_slot() # Get notified when an OTA update starts, to pause scanning (esp32_ble_tracker parity) ota.request_ota_state_listeners() diff --git a/esphome/core/defines.h b/esphome/core/defines.h index d946b106d1..06a0ea5c46 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -436,6 +436,7 @@ #define USE_LOGGER_USB_CDC #define USE_SOCKET_IMPL_LWIP_TCP #define USE_RP2040_BLE +#define RP2040_BLE_SCAN_LISTENER_COUNT 1 #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 #define USE_RP2040_VARIANT_RP2040 #define USE_SPI diff --git a/tests/component_tests/ble_device_base/config/rp2_controller_only.yaml b/tests/component_tests/ble_device_base/config/rp2_controller_only.yaml new file mode 100644 index 0000000000..e64b328051 --- /dev/null +++ b/tests/component_tests/ble_device_base/config/rp2_controller_only.yaml @@ -0,0 +1,7 @@ +esphome: + name: slotcount-rp2-controller + +rp2: + board: rpipicow + +rp2040_ble: diff --git a/tests/component_tests/ble_device_base/config/rp2_tracker.yaml b/tests/component_tests/ble_device_base/config/rp2_tracker.yaml new file mode 100644 index 0000000000..31686dd236 --- /dev/null +++ b/tests/component_tests/ble_device_base/config/rp2_tracker.yaml @@ -0,0 +1,7 @@ +esphome: + name: slotcount-rp2-tracker + +rp2: + board: rpipicow + +rp2_ble_tracker: diff --git a/tests/component_tests/ble_device_base/test_slot_counter.py b/tests/component_tests/ble_device_base/test_slot_counter.py index 852d749e51..b36a5f1a8a 100644 --- a/tests/component_tests/ble_device_base/test_slot_counter.py +++ b/tests/component_tests/ble_device_base/test_slot_counter.py @@ -11,12 +11,23 @@ from __future__ import annotations from collections.abc import Callable from pathlib import Path +import pytest + from esphome.core import CORE from ..helpers import get_define_value +@pytest.mark.parametrize( + ("config", "define"), + [ + ("bk72xx_tracker.yaml", "BK72XX_BLE_SCAN_LISTENER_COUNT"), + ("rp2_tracker.yaml", "RP2040_BLE_SCAN_LISTENER_COUNT"), + ], +) def test_tracker_requests_one_slot( + config: str, + define: str, generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: @@ -25,18 +36,27 @@ def test_tracker_requests_one_slot( The neutral listener count must stay absent from the same build: no BLE consumer registered through register_ble_device(). """ - generate_main(component_config_path("bk72xx_tracker.yaml")) - assert get_define_value("BK72XX_BLE_SCAN_LISTENER_COUNT") == "1" + generate_main(component_config_path(config)) + assert get_define_value(define) == "1" assert get_define_value("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT") is None +@pytest.mark.parametrize( + ("config", "define"), + [ + ("bk72xx_controller_only.yaml", "BK72XX_BLE_SCAN_LISTENER_COUNT"), + ("rp2_controller_only.yaml", "RP2040_BLE_SCAN_LISTENER_COUNT"), + ], +) def test_controller_only_emits_no_count( + config: str, + define: str, generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: """No consumer, no define — the guarded listener storage compiles out.""" - generate_main(component_config_path("bk72xx_controller_only.yaml")) - assert get_define_value("BK72XX_BLE_SCAN_LISTENER_COUNT") is None + generate_main(component_config_path(config)) + assert get_define_value(define) is None def test_neutral_listener_count_emitted_when_requested() -> None: From 1bae91abaad037c0a1f06512175e7363b41fc9a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Wed, 5 Aug 2026 01:45:47 +0300 Subject: [PATCH 1220/1815] [ln882h_ble_tracker] BLE 5.x scanner for LN882H (#16691) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/ln882h_ble/ln882h_ble.cpp | 67 +++- esphome/components/ln882h_ble/ln882h_ble.h | 16 + .../components/ln882h_ble_tracker/__init__.py | 63 ++++ .../ln882h_ble_tracker/ln882h_ble_tracker.cpp | 345 ++++++++++++++++++ .../ln882h_ble_tracker/ln882h_ble_tracker.h | 176 +++++++++ esphome/core/defines.h | 1 + .../test_scan_parameter_validation.py | 12 + .../ln882h_ble_tracker/common-boundary.yaml | 12 + .../components/ln882h_ble_tracker/common.yaml | 16 + .../ln882h_ble_tracker/test.ln882x-ard.yaml | 2 + .../validate-boundary.ln882x-ard.yaml | 2 + 12 files changed, 695 insertions(+), 18 deletions(-) create mode 100644 esphome/components/ln882h_ble_tracker/__init__.py create mode 100644 esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp create mode 100644 esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h create mode 100644 tests/components/ln882h_ble_tracker/common-boundary.yaml create mode 100644 tests/components/ln882h_ble_tracker/common.yaml create mode 100644 tests/components/ln882h_ble_tracker/test.ln882x-ard.yaml create mode 100644 tests/components/ln882h_ble_tracker/validate-boundary.ln882x-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 03172a5b1b..f9f8712768 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -296,6 +296,7 @@ esphome/components/lightwaverf/* @max246 esphome/components/lilygo_t5_47/touchscreen/* @jesserockz esphome/components/lm75b/* @beormund esphome/components/ln882h_ble/* @Bl00d-B0b +esphome/components/ln882h_ble_tracker/* @Bl00d-B0b esphome/components/ln882x/* @lamauny esphome/components/lock/* @esphome/core esphome/components/logger/* @esphome/core diff --git a/esphome/components/ln882h_ble/ln882h_ble.cpp b/esphome/components/ln882h_ble/ln882h_ble.cpp index c3b8982a92..152ca571e9 100644 --- a/esphome/components/ln882h_ble/ln882h_ble.cpp +++ b/esphome/components/ln882h_ble/ln882h_ble.cpp @@ -108,7 +108,7 @@ static constexpr uint8_t GAPM_SCAN_PROP_ACTIVE_1M_BIT = 1 << 2; // GAPM extended-advertising report types (bits 2:0 of ble_scan_report_t::info). // 0 = ADV_EXT (extended advertisement), 1 = ADV_LEG (legacy advertisement), // 2 = SCAN_RSP_EXT (scan response to extended adv), 3 = SCAN_RSP_LEG (scan response to legacy adv). -static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_EXT = 2; +static constexpr uint8_t GAPM_REPORT_TYPE_ADV_LEG = 1; static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_LEG = 3; // Bit 5 of ble_scan_report_t::info: the advertisement is scannable, i.e. a scan // response may follow (enum gapm_adv_report_info, GAPM_REPORT_INFO_SCAN_ADV_BIT). @@ -211,14 +211,22 @@ static void ble_scan_callback(void *param) { return; const auto *info = reinterpret_cast(param); + // Only legacy framing is supported (see scan_start(): legacy 1M PHY only): + // an extended report does not fit BLEScanReport::data and would reach + // consumers as a truncated legacy frame. Reject before allocating so these + // do not burn pool slots either. + const uint8_t report_type = info->info & 0x07; + if (report_type != GAPM_REPORT_TYPE_ADV_LEG && report_type != GAPM_REPORT_TYPE_SCAN_RSP_LEG) { + s_ble->count_rejected_report(); + return; + } + // Fill the pool slot in place (the bk72xx_ble shape): no report on the rw // task's stack — its size is fixed by the prebuilt stack — one copy of the // payload instead of two, and only data_len bytes ever leave this frame. BLEScanReport *slot = s_ble->allocate_scan_report(); if (slot == nullptr) - return; // pool exhausted — counted as dropped in allocate_scan_report() - - const uint8_t report_type = info->info & 0x07; + return; // no slot — counted as dropped in allocate_scan_report() // BLE RSSI sign fix. The LN882H controller intermittently reports the RSSI with // a flipped sign: a real -58 dBm arrives as +58, above the SDK's documented @@ -231,7 +239,7 @@ static void ble_scan_callback(void *param) { memcpy(slot->mac, info->trans_addr, 6); slot->rssi = (raw > 20) ? static_cast(-raw) : raw; slot->addr_type = info->trans_addr_type; - slot->is_scan_response = report_type == GAPM_REPORT_TYPE_SCAN_RSP_EXT || report_type == GAPM_REPORT_TYPE_SCAN_RSP_LEG; + slot->is_scan_response = report_type == GAPM_REPORT_TYPE_SCAN_RSP_LEG; slot->scannable = (info->info & GAPM_REPORT_INFO_SCAN_ADV_BIT) != 0; slot->data_len = (info->length <= sizeof(slot->data)) ? static_cast(info->length) : static_cast(sizeof(slot->data)); @@ -243,7 +251,8 @@ static void ble_scan_callback(void *param) { BLEScanReport *LN882HBLE::allocate_scan_report() { BLEScanReport *slot = this->report_pool_.allocate(); if (slot == nullptr) { - // Pool exhausted — the queue is full; count and drop. + // No slot: pool exhausted (queue full) or the pool's on-demand RAM + // allocation failed; count and drop either way. this->report_queue_.increment_dropped_count(); } return slot; @@ -319,24 +328,46 @@ void LN882HBLE::enable() { } void LN882HBLE::loop() { + // Log dropped reports before the empty-queue return: a drop can also mean + // EventPool::allocate() failed on heap exhaustion, and that can happen with + // the queue empty — from the very first report on. Checking here keeps that + // failure visible instead of producing a scanner that is silently dead. + uint16_t dropped = this->report_queue_.get_and_reset_dropped_count(); + if (dropped > 0) + ESP_LOGW(TAG, "Dropped %u scan reports (queue full or out of memory for a report slot)", dropped); // Drain the lock-free ring filled by the rw task; all per-report work runs // here on the main task, then the report returns to the pool. BLEScanReport *report = this->report_queue_.pop(); - if (report == nullptr) - return; - do { + if (report != nullptr) { + this->reject_diagnosis_done_ = true; + do { #ifdef LN882H_BLE_SCAN_LISTENER_COUNT - for (auto *listener : this->scan_listeners_) - listener->on_scan_report(*report); + for (auto *listener : this->scan_listeners_) + listener->on_scan_report(*report); #endif - this->report_pool_.release(report); - } while ((report = this->report_queue_.pop()) != nullptr); + this->report_pool_.release(report); + } while ((report = this->report_queue_.pop()) != nullptr); + } - // Log dropped reports — only reachable when reports were processed; drops can - // only occur while the queue is full, and only this loop drains it. - uint16_t dropped = this->report_queue_.get_and_reset_dropped_count(); - if (dropped > 0) - ESP_LOGW(TAG, "Dropped %u scan reports due to queue overflow", dropped); + // Rejected-report accounting AFTER the drain: a stray non-legacy frame + // arriving ahead of the first good one must not latch the dead-scanner + // warning; the threshold keeps one-off boot noise below it while a truly + // dead scanner (~200 reports/s all rejected) crosses it within a second. + // Avoid the sub-word CAS in the common case (LockFreeQueue's dropped-count + // pattern): rejects are rare, the load is cheap. + uint16_t rejected = this->rejected_reports_.load(std::memory_order_relaxed); + if (rejected > 0) { + rejected = this->rejected_reports_.exchange(0, std::memory_order_relaxed); + if (!this->reject_diagnosis_done_) { + this->rejected_before_delivery_ += rejected; + if (this->rejected_before_delivery_ >= REJECTED_DEAD_SCANNER_THRESHOLD) { + this->reject_diagnosis_done_ = true; + ESP_LOGW(TAG, "Rejected %u scan reports before any was delivered - unexpected report encoding?", + static_cast(this->rejected_before_delivery_)); + } + } + ESP_LOGV(TAG, "Rejected %u non-legacy scan reports", rejected); + } } void LN882HBLE::get_mac_lsb_first(uint8_t out[6]) const { memcpy(out, this->ble_mac_, sizeof(this->ble_mac_)); } diff --git a/esphome/components/ln882h_ble/ln882h_ble.h b/esphome/components/ln882h_ble/ln882h_ble.h index e957cdd41e..2186822208 100644 --- a/esphome/components/ln882h_ble/ln882h_ble.h +++ b/esphome/components/ln882h_ble/ln882h_ble.h @@ -9,6 +9,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/lock_free_queue.h" +#include #include namespace esphome::ln882h_ble { @@ -61,6 +62,10 @@ class BLEScanListener { // during such stalls. static constexpr uint8_t MAX_SCAN_REPORT_QUEUE_SIZE = 64; +// Rejected frames tolerated before the first delivered report without +// declaring the scanner dead (boot-time stray extended frames are normal). +static constexpr uint16_t REJECTED_DEAD_SCANNER_THRESHOLD = 16; + class LN882HBLE final : public Component { public: void setup() override; @@ -105,6 +110,10 @@ class LN882HBLE final : public Component { /// Internal: hand a filled slot to the main-task queue (cannot fail — the /// pool is sized to the queue capacity). void push_scan_report(BLEScanReport *report); + /// Internal, rw-task context: count a report rejected by the legacy-only + /// filter, so a wrong assumption about the stack's report encoding shows up + /// in verbose logs instead of as a scanner that silently reports nothing. + void count_rejected_report() { this->rejected_reports_.fetch_add(1, std::memory_order_relaxed); } protected: void resolve_mac_(); @@ -126,10 +135,17 @@ class LN882HBLE final : public Component { // allocate() returns nullptr before push() can fail. This prevents leaking a // pool slot on a failed push and keeps release() off the producer path. esphome::EventPool report_pool_; + // Reports rejected by the legacy-only filter (rw-task producer, main-task + // consumer via exchange in loop()). + std::atomic rejected_reports_{0}; uint8_t ble_mac_[6]{0}; // controller (LSB-first) order, as ln_bd_addr_t stores it BLEComponentState state_{BLEComponentState::STATE_OFF}; bool enable_on_boot_{false}; bool scanning_{false}; // controller scan running (re-entry guard for scan_start) + // Dead-scanner diagnosis: done once a report is delivered or the one-shot + // warning has fired, whichever comes first. + bool reject_diagnosis_done_{false}; + uint32_t rejected_before_delivery_{0}; // drives the dead-scanner warning }; } // namespace esphome::ln882h_ble diff --git a/esphome/components/ln882h_ble_tracker/__init__.py b/esphome/components/ln882h_ble_tracker/__init__.py new file mode 100644 index 0000000000..30646dca9a --- /dev/null +++ b/esphome/components/ln882h_ble_tracker/__init__.py @@ -0,0 +1,63 @@ +"""LN882H BLE scanner implementing the ble_device_base BLEHub contract on +top of the ln882h_ble controller. With continuous: false nothing scans until +an explicit start_scan() call.""" + +import esphome.codegen as cg +from esphome.components import ble_device_base, ln882h_ble, ota +from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW +import esphome.config_validation as cv +from esphome.const import ( + CONF_ACTIVE, + CONF_CONTINUOUS, + CONF_DURATION, + CONF_ID, + CONF_INTERVAL, +) +from esphome.types import ConfigType + +CONF_LN882H_BLE_ID = "ln882h_ble_id" + +DEPENDENCIES = ["ln882x"] +AUTO_LOAD = ["ble_device_base", "ln882h_ble"] +CODEOWNERS = ["@Bl00d-B0b"] + +ln882h_ble_tracker_ns = cg.esphome_ns.namespace("ln882h_ble_tracker") +LN882HBLETracker = ln882h_ble_tracker_ns.class_( + "LN882HBLETracker", ble_device_base.BLEHub, cg.Component +) + + +# LN882H SDK reference scan rate: 100 ms interval / 50 ms window (50 % duty). +SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema( + "100ms", window_default="50ms", supports_active=True +) + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(LN882HBLETracker), + cv.GenerateID(CONF_LN882H_BLE_ID): cv.use_id(ln882h_ble.LN882HBLE), + cv.Optional(CONF_SCAN_PARAMETERS, default={}): SCAN_PARAMETERS_SCHEMA, + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + parent = await cg.get_variable(config[CONF_LN882H_BLE_ID]) + cg.add(var.set_parent(parent)) + # The tracker registers itself as a controller scan listener in setup(); + # request the codegen-sized StaticVector slot for it. + ln882h_ble.request_scan_listener_slot() + + # Get notified when an OTA update starts, to pause scanning (esp32_ble_tracker parity) + ota.request_ota_state_listeners() + + scan = config[CONF_SCAN_PARAMETERS] + cg.add(var.set_scan_interval(ble_device_base.to_ble_units(scan[CONF_INTERVAL]))) + cg.add(var.set_scan_window(ble_device_base.to_ble_units(scan[CONF_WINDOW]))) + cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds)) + cg.add(var.set_scan_active(scan[CONF_ACTIVE])) + cg.add(var.set_scan_continuous(scan[CONF_CONTINUOUS])) diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp new file mode 100644 index 0000000000..b98ad228a8 --- /dev/null +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp @@ -0,0 +1,345 @@ +#ifdef USE_LIBRETINY + +#include "ln882h_ble_tracker.h" + +#include +#include + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::ln882h_ble_tracker { + +static const char *const TAG = "ln882h_ble_tracker"; + +static constexpr float BLE_SCAN_UNIT_MS = 0.625f; + +// --------------------------------------------------------------------------- +// Component lifecycle +// --------------------------------------------------------------------------- + +void LN882HBLETracker::setup() { + // Receive the controller's scan reports; the controller queues them from the + // rw task and delivers here on the main task. + this->parent_->register_scan_listener(this); + if (!this->scan_continuous_) { + // Say so once: with continuous: false nothing scans until an explicit + // start_scan() — silence here reads as a broken scanner. + ESP_LOGD(TAG, "Scanning not started (continuous: false) - waiting for an explicit start_scan()"); + // Nothing to time until then; start_scan_() re-enables the loop. + this->disable_loop(); + } +#ifdef USE_OTA_STATE_LISTENER + // Pause scanning while an OTA update is in flight — on the single-core LN882H the + // BLE scan competes with the OTA flash writes. Mirrors esp32_ble_tracker. + ota::get_global_ota_callback()->add_global_state_listener(this); +#endif +} + +#ifdef USE_OTA_STATE_LISTENER +void LN882HBLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, + ota::OTAComponent *comp) { + if (state == ota::OTA_STARTED) { + this->scan_continuous_before_ota_ = this->scan_continuous_; + this->scan_running_before_ota_ = this->scan_running_; + this->stop_scan(); + } else if (state == ota::OTA_ERROR || state == ota::OTA_ABORT) { + // On success the device reboots, so restore only on a failed/aborted + // update. Continuous mode resumes via loop()'s idle branch; a one-shot + // scan that was running is restarted explicitly (bk72xx sibling parity — + // stop_scan() cleared it and nothing else would bring it back). + if (this->scan_continuous_before_ota_) { + this->scan_continuous_ = true; + this->enable_loop(); // stop_scan() disabled it; loop()'s idle branch restarts the scan + } else if (this->scan_running_before_ota_) { + this->start_scan(); + } + this->scan_continuous_before_ota_ = false; + this->scan_running_before_ota_ = false; + } +} +#endif // USE_OTA_STATE_LISTENER + +void LN882HBLETracker::loop() { + // Flush pending scannable advertisements whose scan response never arrived + // (device didn't answer / frame lost) — delivered unmerged after the timeout. + // Main-task only, like every consumer of pending_adv_. + const uint32_t now = millis(); + if (this->pending_count_ != 0) { + for (auto &p : this->pending_adv_) { + if (p.used && now - p.stored_ms > PENDING_ADV_TIMEOUT_MS) { + p.used = false; + this->pending_count_--; + this->process_adv_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); + } + } + } + + if (this->scan_continuous_) { + if (!this->scan_running_) { + this->start_scan_(); + // start_scan_() re-anchors scan_period_start_ from a later millis() than + // the cached `now`; resume the period timer next iteration. + return; + } + // Period timer: once per scan_duration_ window, restart the controller scan + // and fire on_scan_end(), mirroring esp32_ble_tracker::cleanup_scan_state_(). + // The restart is the recovery path for the coexistence failure documented in + // the header. scan_start() re-enters cleanly on its own: it stops an + // in-flight scan and grants the controller's 10 ms GAPM settle before + // restarting — an explicit scan_stop() first would clear the controller's + // re-entry guard and skip that settle. + if (now - this->scan_period_start_ >= this->scan_duration_) { + ESP_LOGD(TAG, "Scan period elapsed - restarting scan"); + this->parent_->scan_start(this->scan_interval_, this->scan_window_, this->scan_active_); + // Keep both clocks anchored to the restart: a runtime switch to + // non-continuous then times out the current period, not the whole run. + this->scan_start_time_ = now; + this->end_scan_period_(now); + } + return; + } + + // Non-continuous mode: run for scan_duration_ ms, then stop and fire on_scan_end. + // Restart is driven externally (e.g. wifi: on_connect:). + if (this->scan_running_ && now - this->scan_start_time_ >= this->scan_duration_) { + this->stop_scan_(); + } +} + +bool LN882HBLETracker::request_scan_mode(bool active) { + if (this->scan_active_ == active) + return true; + this->scan_active_ = active; + ESP_LOGD(TAG, "Scan mode %s", active ? "active" : "passive"); + // scan_start() re-enters cleanly (stops + GAPM settle). No on_scan_end and + // no period reset: the scan logically continues, only the mode changes. + if (this->scan_running_) { + this->parent_->scan_start(this->scan_interval_, this->scan_window_, this->scan_active_); + } + return true; +} + +void LN882HBLETracker::dump_config() { + ESP_LOGCONFIG(TAG, + "LN882H BLE Tracker:\n" + " Scan Duration: %" PRIu32 " s\n" + " Scan Interval: %.0f ms (%" PRIu16 " BLE units)\n" + " Scan Window: %.0f ms (%" PRIu16 " BLE units)\n" + " Scan Type: %s\n" + " Continuous Scanning: %s", + this->scan_duration_ / 1000, this->scan_interval_ * BLE_SCAN_UNIT_MS, this->scan_interval_, + this->scan_window_ * BLE_SCAN_UNIT_MS, this->scan_window_, this->scan_active_ ? "ACTIVE" : "PASSIVE", + YESNO(this->scan_continuous_)); +} + +// --------------------------------------------------------------------------- +// Adv/scan-response demux with Bluedroid-style merge: the LN controller +// delivers the pair as separate reports; a scannable advertisement is held +// until its scan response arrives and delivered as one merged frame. +// --------------------------------------------------------------------------- + +void LN882HBLETracker::on_scan_report(const ln882h_ble::BLEScanReport &report) { + if (report.is_scan_response) { + this->deliver_scan_rsp_(report); + return; + } + // Stash only while the scan runs: after a one-shot stop the loop is + // disabled and nothing would sweep the table, so a late report would + // surface minutes later as a fresh advertisement. + if (this->scan_running_ && this->scan_active_ && report.scannable) { + this->stash_adv_(report); + return; + } + this->process_adv_(report.mac, report.rssi, report.addr_type, report.data, report.data_len, /*raw_only=*/false); +} + +// Hold a scannable advertisement, waiting (≤ PENDING_ADV_TIMEOUT_MS) for its +// scan response. +void LN882HBLETracker::stash_adv_(const ln882h_ble::BLEScanReport &report) { + // One pass: find a same-device entry (deliver + reuse) while remembering the + // first free slot as the fallback. + PendingAdv *slot = nullptr; + PendingAdv *free_slot = nullptr; + for (auto &p : this->pending_adv_) { + if (!p.used) { + if (free_slot == nullptr) + free_slot = &p; + continue; + } + if (p.addr_type == report.addr_type && memcmp(p.mac, report.mac, 6) == 0) { + // Same device advertised again before its scan response arrived — deliver + // the previous advertisement (its scan response is not coming) and reuse + // the slot, so no frame is ever lost. + p.used = false; + this->pending_count_--; + this->process_adv_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); + slot = &p; + break; + } + } + if (slot == nullptr) + slot = free_slot; + if (slot == nullptr) { + // Table full — degrade gracefully: deliver the advertisement unmerged. + this->process_adv_(report.mac, report.rssi, report.addr_type, report.data, report.data_len, /*raw_only=*/false); + return; + } + slot->used = true; + this->pending_count_++; + memcpy(slot->mac, report.mac, 6); + slot->addr_type = report.addr_type; + slot->rssi = report.rssi; + slot->data_len = (report.data_len <= sizeof(slot->data)) ? report.data_len : sizeof(slot->data); + memcpy(slot->data, report.data, slot->data_len); + slot->stored_ms = millis(); +} + +// Scan response arrived: merge it with the pending advertisement from the same +// device into ONE frame (ESP-IDF/Bluedroid semantics). +void LN882HBLETracker::deliver_scan_rsp_(const ln882h_ble::BLEScanReport &report) { + // Fast-out on the empty table (loop()/flush use the same guard); this is + // the hottest caller. + if (this->pending_count_ != 0) { + for (auto &p : this->pending_adv_) { + if (p.used && p.addr_type == report.addr_type && memcmp(p.mac, report.mac, 6) == 0) { + // Append in place: the slot is released on delivery, so its 62-byte + // buffer (legacy adv + scan response) holds the merged frame directly. + const uint8_t room = sizeof(p.data) - p.data_len; + const uint8_t add = (report.data_len <= room) ? report.data_len : room; + memcpy(p.data + p.data_len, report.data, add); + p.used = false; + this->pending_count_--; + // The advertisement's RSSI, not the scan response's: every unmerged path + // reports the advertisement's measurement, so a device's RSSI must not + // jump between two measurements depending on merge timing. + this->process_adv_(report.mac, p.rssi, report.addr_type, p.data, p.data_len + add, /*raw_only=*/false); + return; + } + } + } + // Unmatched scan-response: goes out on the raw callback only (HA merges per + // address); local listeners/triggers receive each advertisement exactly once + // via the merged/plain path above. + this->process_adv_(report.mac, report.rssi, report.addr_type, report.data, report.data_len, /*raw_only=*/true); +} + +void LN882HBLETracker::process_adv_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, + uint8_t data_len, bool raw_only) { + // Raw callback (the raw-advertisement path). Both full advertisements and + // unmatched scan responses (raw_only) are forwarded. + if (this->raw_advertisement_callback_.is_set()) { + const ble_device_base::RawAdvertisement adv{ + .mac = mac, .data = data, .data_len = data_len, .rssi = rssi, .addr_type = addr_type}; + this->raw_advertisement_callback_.invoke(adv); + } + +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + // Scan-response-only frames are never parsed for local sensors/triggers. + if (raw_only) + return; + ble_device_base::ESPBTDevice device; + device.from_scan_result(mac, rssi, addr_type, data, data_len); + // The listener list holds sensors AND this tracker's automation triggers + // (the triggers are listeners, exactly like esp32_ble_tracker), so one + // loop feeds both and ORs into `found`. + bool found = false; + for (auto *listener : this->listeners_) { + if (listener->parse_device(device)) { + found = true; + } + } + // Mirror esp32_ble_tracker: log a newly-seen device only when nothing claimed + // it and the scan is one-shot (continuous scans would spam). + if (!found && !this->scan_continuous_) + this->discovered_log_.log_device(TAG, device); +#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT +} + +// --------------------------------------------------------------------------- +// Public scan actions +// --------------------------------------------------------------------------- + +void LN882HBLETracker::start_scan() { + // Mirrors esp32_ble_tracker::start_scan(): caller sets scan_continuous_ via + // set_scan_continuous() first, then calls start_scan() to begin scanning. + if (!this->scan_running_) { + this->start_scan_(); + } +} + +void LN882HBLETracker::stop_scan() { + this->scan_continuous_ = false; + this->stop_scan_(); +} + +// --------------------------------------------------------------------------- +// Internal scan start / stop +// --------------------------------------------------------------------------- + +void LN882HBLETracker::start_scan_() { + if (this->scan_running_) + return; + + // The controller enables the stack on first use and owns the report queue; + // this call is all the SDK interaction the tracker ever needs. + this->parent_->scan_start(this->scan_interval_, this->scan_window_, this->scan_active_); + const uint32_t now = millis(); + this->scan_running_ = true; + this->scan_start_time_ = now; + this->enable_loop(); // an idle non-continuous tracker disabled it in stop_scan_() + // Log every explicit start at DEBUG — stop_scan_() logs every stop at DEBUG, and + // in non-continuous mode each period is an explicit start, so asymmetric logging + // would read as the scanner failing to come back up. + ESP_LOGD(TAG, "BLE scan started (%s, window=%.0fms, interval=%.0fms)", this->scan_active_ ? "active" : "passive", + this->scan_window_ * BLE_SCAN_UNIT_MS, this->scan_interval_ * BLE_SCAN_UNIT_MS); + // Re-anchor the on_scan_end period to every successful start, so a restart + // later than scan_duration (e.g. a failed OTA restoring continuous mode) + // does not fire on_scan_end before an advertisement can arrive. + this->scan_period_start_ = now; +} + +void LN882HBLETracker::stop_scan_() { + if (!this->scan_running_) + return; + this->parent_->scan_stop(); + this->scan_running_ = false; + // DEBUG like start_scan_() — a per-period stop at INFO would read as the + // scanner failing to come back up. + ESP_LOGD(TAG, "BLE scan stopped"); + this->end_scan_period_(millis()); // also resets the period clock so on_scan_end does not double-fire + if (!this->scan_continuous_) { + // Nothing left to time; start_scan_() re-enables the loop. + this->disable_loop(); + } +} + +// Close a scan period: deliver held advertisements whose scan response never +// came (unmerged) BEFORE on_scan_end fires, then re-anchor the period clock. +void LN882HBLETracker::end_scan_period_(uint32_t now) { + this->flush_pending_adv_(); +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + for (auto *listener : this->listeners_) + listener->on_scan_end(); + this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity) +#endif + this->scan_period_start_ = now; +} + +// Deliver every held advertisement now (scan period/scan is ending): unmerged +// delivery, same as the timeout path in loop(). Main-task only. +void LN882HBLETracker::flush_pending_adv_() { + if (this->pending_count_ == 0) + return; + for (auto &p : this->pending_adv_) { + if (p.used) { + p.used = false; + this->process_adv_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); + } + } + this->pending_count_ = 0; +} + +} // namespace esphome::ln882h_ble_tracker + +#endif // USE_LIBRETINY diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h new file mode 100644 index 0000000000..00f1af16a4 --- /dev/null +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h @@ -0,0 +1,176 @@ +// BLE scanner for LN882H: implements ble_device_base::BLEHub on top of the +// ln882h_ble controller (which owns all SDK calls and delivers scan reports on +// the main task). Scan policy lives here: parameters, period timers with +// per-period restart, and the adv+scan-response merge. + +#pragma once + +#ifdef USE_LIBRETINY + +#include "esphome/components/ble_device_base/ble_device.h" +#include "esphome/components/ble_device_base/ble_hub.h" +#include "esphome/components/ln882h_ble/ln882h_ble.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include + +#ifdef USE_OTA_STATE_LISTENER +#include "esphome/components/ota/ota_backend.h" +#endif + +namespace esphome::ln882h_ble_tracker { + +// --------------------------------------------------------------------------- +// LN882HBLETracker +// --------------------------------------------------------------------------- + +class LN882HBLETracker : public Component, + public ble_device_base::BLEHub, + public Parented, + public ln882h_ble::BLEScanListener +#ifdef USE_OTA_STATE_LISTENER + , + public ota::OTAGlobalStateListener +#endif +{ + public: + // ---- ESPHome Component ---- + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + +#ifdef USE_OTA_STATE_LISTENER + // Pause scanning while an OTA update runs (single-core WiFi/BLE/flash contention); + // mirrors esp32_ble_tracker. + void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override; +#endif + + // ---- YAML configuration setters ---- + void set_scan_active(bool scan_active) { this->scan_active_ = scan_active; } + void set_scan_interval(uint16_t scan_interval) { this->scan_interval_ = scan_interval; } + void set_scan_window(uint16_t scan_window) { this->scan_window_ = scan_window; } + void set_scan_duration(uint32_t scan_duration) { this->scan_duration_ = scan_duration; } + void set_scan_continuous(bool scan_continuous) { this->scan_continuous_ = scan_continuous; } + + // ---- Public scan control ---- + // Mirrors esp32_ble_tracker: set_scan_continuous() + start_scan() / stop_scan(). + void start_scan(); + void stop_scan(); + + // ---- ble_device_base::BLEHub contract ---- + void register_listener(ble_device_base::ESPBTDeviceListener *listener) override { +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + this->listeners_.push_back(listener); +#endif + } + void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override { + this->raw_advertisement_callback_ = callback; + } + ble_device_base::HubCapabilities get_capabilities() const override { + // The LN882H controller supports active scanning; adv + scan response arrive + // as separate reports and are merged by this tracker (Bluedroid semantics). + // The SDK's GATT client is not exposed. + return {.active_scan = true, .merges_scan_response = true, .gatt = false}; + } + // The controller stores the address LSB-first (BLE convention); the contract + // wants printable (MSB-first) order. + void get_adapter_mac(uint8_t out[6]) override { + uint8_t mac[6]; + this->parent_->get_mac_lsb_first(mac); + for (int i = 0; i < 6; i++) + out[i] = mac[5 - i]; + } + bool scan_running() override { return this->scan_running_; } + bool scan_active() override { return this->scan_active_; } + bool request_scan_mode(bool active) override; + + // ---- ln882h_ble::BLEScanListener ---- + // Delivered by the controller's loop() on the ESPHome main task — the + // rw-task → main-task handoff already happened in the controller's queue. + // Demultiplexes advertisements vs scan responses and drives the merge. + void on_scan_report(const ln882h_ble::BLEScanReport &report) override; + + protected: + // Bluedroid-style adv + scan-response merging (ESP-IDF concatenates both into + // one result before ESPHome sees it; the LN controller reports them separately): + // a scannable advertisement is held here briefly, its scan response is appended + // on arrival and the pair is delivered as ONE merged frame. Held entries whose + // scan response never arrives are flushed by loop() after PENDING_ADV_TIMEOUT_MS. + // All of this runs on the main task (the controller queue already crossed tasks), + // so no locking is involved. + void stash_adv_(const ln882h_ble::BLEScanReport &report); + void deliver_scan_rsp_(const ln882h_ble::BLEScanReport &report); + // Dispatch one (possibly merged) advertisement: the raw + // callback, and — unless raw_only — parsing for listeners/triggers. raw_only + // marks unmatched scan-response frames: forwarded on the raw callback only, + // never to local sensors/triggers (HA merges per address). + void process_adv_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, + bool raw_only); + void start_scan_(); + void stop_scan_(); + // Close a scan period: flush held advertisements (unmerged) BEFORE + // on_scan_end fires, then re-anchor the period clock to `now`. + void end_scan_period_(uint32_t now); + void flush_pending_adv_(); + + bool scan_running_{false}; + bool scan_active_{false}; + // Defaults are the LN882H SDK's recommended scan parameters + // (ln_ble_scan.h: SCAN_INTERVAL_DEF 0xA0, SCAN_WINDOW_DEF 0x50 → 50 % duty). + // uint16_t matches the controller's scan_start() parameters. + uint16_t scan_interval_{160}; // 160 × 0.625 ms = 100 ms (SDK SCAN_INTERVAL_DEF) + uint16_t scan_window_{80}; // 80 × 0.625 ms = 50 ms (SDK SCAN_WINDOW_DEF; 50/100 = 50 %) + uint32_t scan_duration_{300000}; + bool scan_continuous_{true}; +#ifdef USE_OTA_STATE_LISTENER + bool scan_continuous_before_ota_{false}; // continuous mode saved at OTA start, restored on OTA failure + bool scan_running_before_ota_{false}; // one-shot scan running at OTA start, restarted on OTA failure +#endif + uint32_t scan_start_time_{0}; + + // Pending scannable advertisements awaiting their scan response (active scan). + // 62 bytes = legacy adv (31) + scan response (31), the same merged maximum as + // ESP-IDF delivers on ESP32. Main-task only. + struct PendingAdv { + bool used{false}; + uint8_t mac[6]; + uint8_t addr_type; + int8_t rssi; + uint8_t data_len; // <= sizeof(data) + uint8_t data[62]; + uint32_t stored_ms; + }; + // Sized for the unanswered case: a pair that IS answered normally matches + // within one queue drain, so a slot is held for the full timeout only by + // scannable devices that never reply. 8 concurrent such advertisers before + // the merge degrades (frames still delivered, just unmerged) at ~80 B each. + static constexpr size_t MAX_PENDING_ADV = 8; + // On air a scan response follows its advertisement by T_IFS (150 µs) — the + // timeout only covers HOST-side report queuing in rw_task under WiFi/BLE + // coexistence, measured on-device at up to ~136 ms. 300 ms = >2x that margin, + // while staying below any device's re-advertising period. + static constexpr uint32_t PENDING_ADV_TIMEOUT_MS = 300; + PendingAdv pending_adv_[MAX_PENDING_ADV]; + // Occupied pending_adv_ slots — lets loop()'s timeout sweep skip the table + // in the common case (empty: passive scan, or every pair already matched). + uint8_t pending_count_{0}; + + uint32_t scan_period_start_{0}; // millis() at start of current scan period; used to rate-limit on_scan_end() + + ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{}; +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + // Parsed-advertisement consumers registered through ble_device_base. + // Codegen-sized: no heap allocation, no std::vector template instantiations. + StaticVector listeners_; + // Per-period "Found device" DEBUG log with MAC dedup — shared implementation + // in ble_device_base, identical output on every tracker backend. Guarded like + // its only writer so a no-listener build does not carry an unused vector. + ble_device_base::DiscoveredDeviceLog discovered_log_{}; +#endif +}; + +} // namespace esphome::ln882h_ble_tracker + +#endif // USE_LIBRETINY diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 06a0ea5c46..231ba50dd7 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -458,6 +458,7 @@ #define BK72XX_BLE_SCAN_LISTENER_COUNT 1 #define USE_LN882H_BLE #define LN882H_BLE_SCAN_LISTENER_COUNT 1 +#define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 #define USE_CAPTIVE_PORTAL #define USE_WIFI_SCAN_RESULTS_LOCK #define USE_SOCKET_IMPL_LWIP_SOCKETS diff --git a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py index bbf4953953..bd41a9476a 100644 --- a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py +++ b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py @@ -10,6 +10,9 @@ from esphome.components.bk72xx_ble_tracker import ( ) from esphome.components.ble_device_base import to_ble_units from esphome.components.esp32_ble_tracker import SCAN_PARAMETERS_SCHEMA as ESP32_SCHEMA +from esphome.components.ln882h_ble_tracker import ( + SCAN_PARAMETERS_SCHEMA as LN882H_SCHEMA, +) from esphome.components.rp2_ble_tracker import SCAN_PARAMETERS_SCHEMA as RP2_SCHEMA @@ -69,6 +72,15 @@ def test_rp2_defaults_are_valid() -> None: assert config["active"] is True +def test_ln882h_defaults_are_valid() -> None: + """ln882h pins the LN SDK reference rate — 100 ms interval / 50 ms window + (50 % duty) — and exposes active (default on).""" + config = LN882H_SCHEMA({}) + assert to_ble_units(config["interval"]) == 160 + assert to_ble_units(config["window"]) == 80 + assert config["active"] is True + + def test_esp32_active_can_disable() -> None: config = ESP32_SCHEMA({"active": False}) assert config["active"] is False diff --git a/tests/components/ln882h_ble_tracker/common-boundary.yaml b/tests/components/ln882h_ble_tracker/common-boundary.yaml new file mode 100644 index 0000000000..b6df6f6f39 --- /dev/null +++ b/tests/components/ln882h_ble_tracker/common-boundary.yaml @@ -0,0 +1,12 @@ +ln882h_ble_tracker: + id: ble_tracker + scan_parameters: + # Boundary coverage: the documented 2.5 ms floor on window (expressible only + # via the microsecond-accurate validation), a non-round interval exercising the + # 0.625 ms unit conversion without collapsing onto the window's unit count, + # and the non-continuous config path. + interval: 5000us + window: 2500us + duration: 5min + active: false + continuous: false diff --git a/tests/components/ln882h_ble_tracker/common.yaml b/tests/components/ln882h_ble_tracker/common.yaml new file mode 100644 index 0000000000..aba02147c8 --- /dev/null +++ b/tests/components/ln882h_ble_tracker/common.yaml @@ -0,0 +1,16 @@ +ln882h_ble_tracker: + id: ble_tracker + scan_parameters: + interval: 100ms + window: 50ms + duration: 5min + continuous: true + +# Pulls in USE_OTA_STATE_LISTENER so the OTA scan-pause path compiles in CI +# (same coverage arrangement as the rp2_ble_tracker tests). +wifi: + ssid: MySSID + password: password1 + +ota: + - platform: esphome diff --git a/tests/components/ln882h_ble_tracker/test.ln882x-ard.yaml b/tests/components/ln882h_ble_tracker/test.ln882x-ard.yaml new file mode 100644 index 0000000000..6a9efad314 --- /dev/null +++ b/tests/components/ln882h_ble_tracker/test.ln882x-ard.yaml @@ -0,0 +1,2 @@ +packages: + ln882h_ble_tracker: !include common.yaml diff --git a/tests/components/ln882h_ble_tracker/validate-boundary.ln882x-ard.yaml b/tests/components/ln882h_ble_tracker/validate-boundary.ln882x-ard.yaml new file mode 100644 index 0000000000..fc5790e3b2 --- /dev/null +++ b/tests/components/ln882h_ble_tracker/validate-boundary.ln882x-ard.yaml @@ -0,0 +1,2 @@ +packages: + ln882h_ble_tracker: !include common-boundary.yaml From ad18bbc6445eb035d9d9e7cdc3a5d01fad786333 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Aug 2026 19:54:07 -0500 Subject: [PATCH 1221/1815] [esp32_ble] Migrate the BLE count machinery to the shared slot counter factory (#18059) --- .../components/bluetooth_proxy/__init__.py | 1 + .../bluetooth_proxy/bluetooth_proxy.cpp | 2 - esphome/components/esp32_ble/__init__.py | 83 ++++++------------- .../components/esp32_ble_tracker/__init__.py | 67 +++++++-------- .../components/esp32_ble_tracker/automation.h | 2 + .../esp32_ble_tracker/esp32_ble_tracker.cpp | 2 + .../esp32_ble_tracker/esp32_ble_tracker.h | 13 ++- esphome/core/defines.h | 1 + .../config/esp32_bluetooth_proxy.yaml | 16 ++++ .../config/esp32_tracker_only.yaml | 9 ++ .../ble_device_base/test_slot_counter.py | 52 ++++++++++++ 11 files changed, 149 insertions(+), 99 deletions(-) create mode 100644 tests/component_tests/ble_device_base/config/esp32_bluetooth_proxy.yaml create mode 100644 tests/component_tests/ble_device_base/config/esp32_tracker_only.yaml diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index ad7528c156..4792736dd9 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -87,6 +87,7 @@ async def to_code(config): cg.add(var.set_active(config[CONF_ACTIVE])) await esp32_ble_tracker.register_raw_ble_device(var, config) + await esp32_ble_tracker.register_scanner_state_listener(var, config) # Define max connections for protobuf fixed array connection_count = len(config.get(CONF_CONNECTIONS, [])) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index f1a30cdfa2..dbe9c1e30d 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -29,8 +29,6 @@ void BluetoothProxy::setup() { // Capture the configured scan mode from YAML before any API changes this->configured_scan_active_ = this->parent_->get_scan_active(); - - this->parent_->add_scanner_state_listener(this); } void BluetoothProxy::on_scanner_state(esp32_ble_tracker::ScannerState state) { diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 72cb10caac..935d8b1b7e 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -1,5 +1,4 @@ from collections.abc import Callable, MutableMapping -from dataclasses import dataclass from enum import Enum import logging from typing import Any @@ -32,7 +31,7 @@ from esphome.const import ( CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, ) -from esphome.core import CORE, CoroPriority, TimePeriod, coroutine_with_priority +from esphome.core import CORE, TimePeriod import esphome.final_validate as fv from esphome.types import ConfigType @@ -134,18 +133,21 @@ def _get_required_loggers() -> set[BTLoggers]: return CORE.data.setdefault(ESP32_BLE_REQUIRED_LOGGERS_KEY, set()) -# Dataclass for handler registration counts -@dataclass -class HandlerCounts: - gap_event: int = 0 - gap_scan_event: int = 0 - gattc_event: int = 0 - gatts_event: int = 0 - ble_status_event: int = 0 - - -# Track handler registration counts for StaticVector sizing -_handler_counts = HandlerCounts() +# Handler slot counters sizing the StaticCallbackManager storage in ble.h; +# one request per register_* call below. +_request_gap_event_slot = cg.slot_counter("ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT") +_request_gap_scan_event_slot = cg.slot_counter( + "ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT" +) +_request_gattc_event_slot = cg.slot_counter( + "ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT" +) +_request_gatts_event_slot = cg.slot_counter( + "ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT" +) +_request_ble_status_event_slot = cg.slot_counter( + "ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT" +) def _add_callback( @@ -171,8 +173,8 @@ def _add_callback( def register_gap_event_handler(parent_var: cg.MockObj, handler_var: cg.MockObj) -> None: - """Register a GAP event handler and track the count.""" - _handler_counts.gap_event += 1 + """Register a GAP event handler and request a handler slot.""" + _request_gap_event_slot() _add_callback( parent_var, "add_gap_event_callback", @@ -185,8 +187,8 @@ def register_gap_event_handler(parent_var: cg.MockObj, handler_var: cg.MockObj) def register_gap_scan_event_handler( parent_var: cg.MockObj, handler_var: cg.MockObj ) -> None: - """Register a GAP scan event handler and track the count.""" - _handler_counts.gap_scan_event += 1 + """Register a GAP scan event handler and request a handler slot.""" + _request_gap_scan_event_slot() _add_callback( parent_var, "add_gap_scan_event_callback", @@ -199,8 +201,8 @@ def register_gap_scan_event_handler( def register_gattc_event_handler( parent_var: cg.MockObj, handler_var: cg.MockObj ) -> None: - """Register a GATTc event handler and track the count.""" - _handler_counts.gattc_event += 1 + """Register a GATTc event handler and request a handler slot.""" + _request_gattc_event_slot() _add_callback( parent_var, "add_gattc_event_callback", @@ -213,8 +215,8 @@ def register_gattc_event_handler( def register_gatts_event_handler( parent_var: cg.MockObj, handler_var: cg.MockObj ) -> None: - """Register a GATTs event handler and track the count.""" - _handler_counts.gatts_event += 1 + """Register a GATTs event handler and request a handler slot.""" + _request_gatts_event_slot() _add_callback( parent_var, "add_gatts_event_callback", @@ -227,8 +229,8 @@ def register_gatts_event_handler( def register_ble_status_event_handler( parent_var: cg.MockObj, handler_var: cg.MockObj ) -> None: - """Register a BLE status event handler and track the count.""" - _handler_counts.ble_status_event += 1 + """Register a BLE status event handler and request a handler slot.""" + _request_ble_status_event_slot() _add_callback( parent_var, "add_ble_status_event_callback", @@ -518,36 +520,6 @@ def final_validation(config): FINAL_VALIDATE_SCHEMA = final_validation -# This needs to be run as a job with CoroPriority.FINAL priority so that all components have -# a chance to register their handlers before the counts are added to defines. -@coroutine_with_priority(CoroPriority.FINAL) -async def _add_ble_handler_defines(): - # Add defines for StaticVector sizing based on handler registration counts - # Only define if count > 0 to avoid allocating unnecessary memory - if _handler_counts.gap_event > 0: - cg.add_define( - "ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT", _handler_counts.gap_event - ) - if _handler_counts.gap_scan_event > 0: - cg.add_define( - "ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT", - _handler_counts.gap_scan_event, - ) - if _handler_counts.gattc_event > 0: - cg.add_define( - "ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT", _handler_counts.gattc_event - ) - if _handler_counts.gatts_event > 0: - cg.add_define( - "ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT", _handler_counts.gatts_event - ) - if _handler_counts.ble_status_event > 0: - cg.add_define( - "ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT", - _handler_counts.ble_status_event, - ) - - async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT])) @@ -633,9 +605,6 @@ async def to_code(config): cg.add_define("USE_ESP32_BLE_ADVERTISING") cg.add_define("USE_ESP32_BLE_UUID") - # Schedule the handler defines to be added after all components register - CORE.add_job(_add_ble_handler_defines) - @automation.register_condition("ble.enabled", BLEEnabledCondition, cv.Schema({})) async def ble_enabled_to_code(config, condition_id, template_arg, args): diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 1e0716cb20..102e59bfb2 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -1,6 +1,5 @@ from __future__ import annotations -from dataclasses import dataclass import logging from esphome import automation @@ -56,16 +55,8 @@ class BLEFeatures(StrEnum): ESP_BT_DEVICE = "ESP_BT_DEVICE" -# Dataclass for registration counts -@dataclass -class RegistrationCounts: - listeners: int = 0 - clients: int = 0 - - -# CORE.data keys for state management +# CORE.data key for state management ESP32_BLE_TRACKER_REQUIRED_FEATURES_KEY = "esp32_ble_tracker_required_features" -ESP32_BLE_TRACKER_REGISTRATION_COUNTS_KEY = "esp32_ble_tracker_registration_counts" def _get_required_features() -> set[BLEFeatures]: @@ -73,11 +64,13 @@ def _get_required_features() -> set[BLEFeatures]: return CORE.data.setdefault(ESP32_BLE_TRACKER_REQUIRED_FEATURES_KEY, set()) -def _get_registration_counts() -> RegistrationCounts: - """Get the registration counts from CORE.data.""" - return CORE.data.setdefault( - ESP32_BLE_TRACKER_REGISTRATION_COUNTS_KEY, RegistrationCounts() - ) +# Slot counters sizing the tracker's StaticVector storage; one request per +# registered listener, client, or scanner state listener. +_request_listener_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") +_request_client_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") +_request_scanner_state_listener_slot = cg.slot_counter( + "ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT" +) def register_ble_features(features: set[BLEFeatures]) -> None: @@ -239,17 +232,15 @@ async def to_code(config): ): register_ble_features({BLEFeatures.ESP_BT_DEVICE}) - registration_counts = _get_registration_counts() - for conf in config.get(CONF_ON_BLE_ADVERTISE, []): - registration_counts.listeners += 1 + _request_listener_slot() trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) if CONF_MAC_ADDRESS in conf: addr_list = [it.as_hex for it in conf[CONF_MAC_ADDRESS]] cg.add(trigger.set_addresses(addr_list)) await automation.build_automation(trigger, [(ESPBTDeviceConstRef, "x")], conf) for conf in config.get(CONF_ON_BLE_SERVICE_DATA_ADVERTISE, []): - registration_counts.listeners += 1 + _request_listener_slot() trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) if len(conf[CONF_SERVICE_UUID]) == len(bt_uuid16_format): cg.add(trigger.set_service_uuid16(as_hex(conf[CONF_SERVICE_UUID]))) @@ -262,7 +253,7 @@ async def to_code(config): cg.add(trigger.set_address(conf[CONF_MAC_ADDRESS].as_hex)) await automation.build_automation(trigger, [(adv_data_t_const_ref, "x")], conf) for conf in config.get(CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE, []): - registration_counts.listeners += 1 + _request_listener_slot() trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) if len(conf[CONF_MANUFACTURER_ID]) == len(bt_uuid16_format): cg.add(trigger.set_manufacturer_uuid16(as_hex(conf[CONF_MANUFACTURER_ID]))) @@ -275,7 +266,7 @@ async def to_code(config): cg.add(trigger.set_address(conf[CONF_MAC_ADDRESS].as_hex)) await automation.build_automation(trigger, [(adv_data_t_const_ref, "x")], conf) for conf in config.get(CONF_ON_SCAN_END, []): - registration_counts.listeners += 1 + _request_listener_slot() trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [], conf) @@ -316,18 +307,6 @@ async def _add_ble_features(): cg.add_define("USE_ESP32_BLE_DEVICE") cg.add_define("USE_ESP32_BLE_UUID") - # Add defines for StaticVector sizing based on registration counts - # Only define if count > 0 to avoid allocating unnecessary memory - registration_counts = _get_registration_counts() - if registration_counts.listeners > 0: - cg.add_define( - "ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT", registration_counts.listeners - ) - if registration_counts.clients > 0: - cg.add_define( - "ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT", registration_counts.clients - ) - ESP32_BLE_START_SCAN_ACTION_SCHEMA = cv.Schema( { @@ -380,7 +359,7 @@ async def register_ble_device( var: cg.SafeExpType, config: ConfigType ) -> cg.SafeExpType: register_ble_features({BLEFeatures.ESP_BT_DEVICE}) - _get_registration_counts().listeners += 1 + _request_listener_slot() paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) cg.add(paren.register_listener(var)) return var @@ -388,7 +367,7 @@ async def register_ble_device( async def register_client(var: cg.SafeExpType, config: ConfigType) -> cg.SafeExpType: register_ble_features({BLEFeatures.ESP_BT_DEVICE}) - _get_registration_counts().clients += 1 + _request_client_slot() paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) cg.add(paren.register_client(var)) return var @@ -402,7 +381,7 @@ async def register_raw_ble_device( This does NOT register the ESP_BT_DEVICE feature, meaning ESPBTDevice will not be compiled in if this is the only registration method used. """ - _get_registration_counts().listeners += 1 + _request_listener_slot() paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) cg.add(paren.register_listener(var)) return var @@ -416,7 +395,21 @@ async def register_raw_client( This does NOT register the ESP_BT_DEVICE feature, meaning ESPBTDevice will not be compiled in if this is the only registration method used. """ - _get_registration_counts().clients += 1 + _request_client_slot() paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) cg.add(paren.register_client(var)) return var + + +async def register_scanner_state_listener( + var: cg.SafeExpType, config: ConfigType +) -> cg.SafeExpType: + """Register a listener for scanner state changes. + + The slot request here is what sizes the tracker's listener storage; a + build with no registrations compiles the storage out entirely. + """ + _request_scanner_state_listener_slot() + paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) + cg.add(paren.add_scanner_state_listener(var)) + return var diff --git a/esphome/components/esp32_ble_tracker/automation.h b/esphome/components/esp32_ble_tracker/automation.h index b653325f56..541b63b2fd 100644 --- a/esphome/components/esp32_ble_tracker/automation.h +++ b/esphome/components/esp32_ble_tracker/automation.h @@ -3,6 +3,8 @@ #include "esphome/core/automation.h" #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include + #ifdef USE_ESP32 namespace esphome::esp32_ble_tracker { diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 0c1a98c4be..372af5e89a 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -443,9 +443,11 @@ void ESP32BLETracker::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_i void ESP32BLETracker::set_scanner_state_(ScannerState state) { this->scanner_state_ = state; this->state_version_++; +#ifdef ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT for (auto *listener : this->scanner_state_listeners_) { listener->on_scanner_state(state); } +#endif } void ESP32BLETracker::dump_config() { diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index b0357289f1..b2db092eb0 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -8,7 +8,6 @@ #include #include #include -#include #ifdef USE_ESP32 @@ -265,10 +264,15 @@ class ESP32BLETracker final : public Component, void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override; #endif - /// Add a listener for scanner state changes +#ifdef ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT + /// Add a listener for scanner state changes. Only compiled when a consumer + /// requested a slot in codegen: register through + /// esp32_ble_tracker.register_scanner_state_listener() in your component's + /// to_code, which requests the slot and emits this call. void add_scanner_state_listener(BLEScannerStateListener *listener) { this->scanner_state_listeners_.push_back(listener); } +#endif ScannerState get_scanner_state() const { return this->scanner_state_; } protected: @@ -335,7 +339,10 @@ class ESP32BLETracker final : public Component, #ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT StaticVector clients_; #endif - std::vector scanner_state_listeners_; +#ifdef ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT + StaticVector + scanner_state_listeners_; +#endif // Parsed listeners registered through the neutral BLEHub contract (migrated // sensors); dispatched alongside listeners_. #ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 231ba50dd7..731e6188df 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -278,6 +278,7 @@ #define USE_ESP32_BLE_SERVER_ON_DISCONNECT #define ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT 1 #define ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT 1 +#define ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT 1 #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 #define ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT 2 #define ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT 1 diff --git a/tests/component_tests/ble_device_base/config/esp32_bluetooth_proxy.yaml b/tests/component_tests/ble_device_base/config/esp32_bluetooth_proxy.yaml new file mode 100644 index 0000000000..7500f2133b --- /dev/null +++ b/tests/component_tests/ble_device_base/config/esp32_bluetooth_proxy.yaml @@ -0,0 +1,16 @@ +esphome: + name: slotcount-esp32-proxy + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: MySSID + password: password1 + +api: + +bluetooth_proxy: + active: true diff --git a/tests/component_tests/ble_device_base/config/esp32_tracker_only.yaml b/tests/component_tests/ble_device_base/config/esp32_tracker_only.yaml new file mode 100644 index 0000000000..46a76cfec8 --- /dev/null +++ b/tests/component_tests/ble_device_base/config/esp32_tracker_only.yaml @@ -0,0 +1,9 @@ +esphome: + name: slotcount-esp32-tracker + +esp32: + board: esp32dev + framework: + type: esp-idf + +esp32_ble_tracker: diff --git a/tests/component_tests/ble_device_base/test_slot_counter.py b/tests/component_tests/ble_device_base/test_slot_counter.py index b36a5f1a8a..0fa5577a0b 100644 --- a/tests/component_tests/ble_device_base/test_slot_counter.py +++ b/tests/component_tests/ble_device_base/test_slot_counter.py @@ -81,3 +81,55 @@ def test_neutral_listener_count_emitted_when_requested() -> None: ) CORE.flush_tasks() assert get_define_value(ble_device_base.LISTENER_COUNT_DEFINE) == "1" + + +def test_esp32_tracker_handler_counts( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A bare tracker registers its four esp32_ble handlers and nothing else.""" + generate_main(component_config_path("esp32_tracker_only.yaml")) + assert get_define_value("ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT") == "1" + assert get_define_value("ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT") == "1" + assert get_define_value("ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT") == "1" + assert get_define_value("ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT") == "1" + assert get_define_value("ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT") is None + # No consumer subscribed to scanner state, so the storage compiles out. + assert ( + get_define_value("ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT") + is None + ) + assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") is None + assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") is None + + +def test_esp32_bluetooth_proxy_requests_scanner_state_slot( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The proxy requests one scanner state slot, one raw listener slot and a + client slot per connection (three by default with active: true).""" + generate_main(component_config_path("esp32_bluetooth_proxy.yaml")) + assert ( + get_define_value("ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT") + == "1" + ) + assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") == "1" + assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") == "3" + + +def test_counts_reset_between_compiles( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A second compile in the same process starts from zero. + + The module level counters this change removes leaked across compiles in a + long lived host process (dashboard, device-builder), growing the handler + counts by one per compile and oversizing the StaticCallbackManager storage. + """ + generate_main(component_config_path("esp32_tracker_only.yaml")) + assert get_define_value("ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT") == "1" + CORE.reset() + generate_main(component_config_path("esp32_tracker_only.yaml")) + assert get_define_value("ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT") == "1" From 7d1317ad53e0bac7a539215b22381f8c979db8fb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:39:43 -1000 Subject: [PATCH 1222/1815] Bump filelock from 3.29.0 to 3.32.0 (#17759) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b1f7bc197e..25e91dba6d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 platformdirs==4.10.0 # native esp-idf toolchain global cache dir -filelock==3.29.0 # lock guarding the PlatformIO python-version cache heal +filelock==3.32.0 # lock guarding the PlatformIO python-version cache heal # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 From cbdddc80205e00fbb7b61790530645a2d129b8e4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:33:49 -1000 Subject: [PATCH 1223/1815] Bump platformdirs from 4.10.0 to 4.11.0 (#17756) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 25e91dba6d..541b374b15 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,7 +26,7 @@ bleak==2.1.1 smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 -platformdirs==4.10.0 # native esp-idf toolchain global cache dir +platformdirs==4.11.0 # native esp-idf toolchain global cache dir filelock==3.32.0 # lock guarding the PlatformIO python-version cache heal # esp-idf >= 5.0 requires this From 7862520450e708b7a8c7560a66de39ba645bc139 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:01:17 -0400 Subject: [PATCH 1224/1815] Bump bundled esphome-device-builder to 1.8.0 (#17953) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 4a9edb1b64..1f6ab8fcf7 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.7.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.8.0 RUN \ platformio settings set enable_telemetry No \ From d0f68802b9b4562c831569d3d4be47a15ffa2cf1 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:54:39 -1000 Subject: [PATCH 1225/1815] Bump bundled esphome-device-builder to 1.8.1 (#17964) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 1f6ab8fcf7..4aa589fc5e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.8.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.8.1 RUN \ platformio settings set enable_telemetry No \ From 6b6903e568a1bf5e297ba4468514bdb3247600fd Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:25:17 -0500 Subject: [PATCH 1226/1815] [epaper_spi] Default init sequence to empty not None (#17966) Co-authored-by: Claude Sonnet 5 --- .../components/epaper_spi/models/__init__.py | 2 +- .../config/t133a01_no_init_sequence.yaml | 26 +++++++++++++++++++ tests/component_tests/epaper_spi/test_init.py | 21 +++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 tests/component_tests/epaper_spi/config/t133a01_no_init_sequence.yaml diff --git a/esphome/components/epaper_spi/models/__init__.py b/esphome/components/epaper_spi/models/__init__.py index 2360b090ff..34e65061f3 100644 --- a/esphome/components/epaper_spi/models/__init__.py +++ b/esphome/components/epaper_spi/models/__init__.py @@ -15,7 +15,7 @@ class EpaperModel: self, name: str, class_name: str, - initsequence=None, + initsequence=(), **defaults, ): name = name.upper() diff --git a/tests/component_tests/epaper_spi/config/t133a01_no_init_sequence.yaml b/tests/component_tests/epaper_spi/config/t133a01_no_init_sequence.yaml new file mode 100644 index 0000000000..1032927146 --- /dev/null +++ b/tests/component_tests/epaper_spi/config/t133a01_no_init_sequence.yaml @@ -0,0 +1,26 @@ +esphome: + name: test + +esp32: + board: esp32-s3-devkitc-1 + variant: esp32s3 + framework: + type: esp-idf + +spi: + clk_pin: GPIO18 + mosi_pin: GPIO19 + +display: + - platform: epaper_spi + id: epaper_display + model: t133a01 + dc_pin: GPIO21 + reset_pin: GPIO38 + cs_pin: GPIO10 + cs1_pin: GPIO2 + busy_pin: GPIO13 + update_interval: never + dimensions: + width: 200 + height: 200 diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index 1396c18e3b..7a0507542e 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -462,3 +462,24 @@ def test_enable_pin_code_generation( # Both pin objects must be passed to the display via set_enable_pins() as a # std::vector initializer list, in the configured order. assert f"set_enable_pins({{{pin_25}, {pin_26}}});" in main_cpp + + +def test_model_with_no_default_init_sequence_generates( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Test that code generation succeeds for a model with no default init sequence. + + The base "t133a01" model (used directly, not via one of its `.extend()` + variants) doesn't override `get_init_sequence()` or pass `initsequence` to + its constructor, and the user didn't supply `init_sequence:` either. + `EpaperModel.get_init_sequence()` used to default to `None` in this case, + which made `flatten_sequence()` raise a `TypeError` during code + generation. Regression test for that crash. + """ + main_cpp = generate_main(component_config_path("t133a01_no_init_sequence.yaml")) + + # The generated constructor call takes (name, width, height, init_sequence, + # init_sequence_length, ...); a length of 0 confirms the empty init + # sequence array was generated instead of raising during code generation. + assert re.search(r"epaper_spi::EPaperT133A01\([^;]*,\s*\w+,\s*0\);", main_cpp) From c0f494450df1eb26ad2dd77cf1df61746feec265 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Jul 2026 11:44:12 -1000 Subject: [PATCH 1227/1815] [git] Fix device adoption failing on first attempt: lock the clone cache against concurrent resolutions (#17923) --- esphome/components/packages/__init__.py | 22 +- esphome/git.py | 420 ++++++++++-- requirements.txt | 2 +- .../component_tests/packages/test_packages.py | 69 ++ tests/unit_tests/test_git.py | 603 +++++++++++++++++- 5 files changed, 1068 insertions(+), 48 deletions(-) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 44a1ebf36e..6cb9d5f03a 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -1,6 +1,7 @@ from collections import UserDict from collections.abc import Callable from functools import reduce +import logging from pathlib import Path from typing import Any @@ -35,6 +36,8 @@ from esphome.const import ( ) from esphome.core import EsphomeError +_LOGGER = logging.getLogger(__name__) + DOMAIN = CONF_PACKAGES # Guard against infinite include chains (e.g. A includes B includes A). MAX_INCLUDE_DEPTH = 20 @@ -267,8 +270,23 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]: # If loading fails, the cached checkout may be stale — revert and retry once. try: return {CONF_PACKAGES: get_packages(files)} - except cv.Invalid: - revert() + except cv.Invalid as err: + if not revert(): + # The pre-update content is out of reach (lock timeout, the + # checkout moved, or the reset failed; see the log), so a + # retry could not see it. + raise cv.Invalid( + f"Failed to load packages and could not revert the cached " + f"checkout to retry. {err}", + path=err.path, + ) from err + # If the retry succeeds this is the only trace that the + # refreshed upstream content was broken. + _LOGGER.warning( + "Loading packages failed (%s), reverted the cached checkout " + "and retrying", + err, + ) try: return {CONF_PACKAGES: get_packages(files)} except cv.Invalid as err: diff --git a/esphome/git.py b/esphome/git.py index 46cce50d9d..b5abf39a24 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -1,5 +1,8 @@ -from collections.abc import Callable +from collections.abc import Callable, Iterator +from contextlib import contextmanager from dataclasses import dataclass +from enum import Enum, auto +import errno import hashlib import logging import os @@ -8,23 +11,42 @@ import re import subprocess import sys import time +from typing import TYPE_CHECKING import urllib.parse import esphome.config_validation as cv from esphome.core import CORE, EsphomeError, TimePeriodSeconds from esphome.helpers import add_git_ceiling_directory, rmtree, write_file +if TYPE_CHECKING: + from filelock import FileLock + _LOGGER = logging.getLogger(__name__) # Special value to indicate never refresh NEVER_REFRESH = TimePeriodSeconds(seconds=-1) -# Written inside .git only after every clone step (clone, ref fetch, reset, -# submodule init) has completed. A directory without it is an interrupted -# clone (e.g. the process was killed mid-clone) and must be re-cloned; without -# this check such a directory would be trusted forever when the caller uses -# NEVER_REFRESH. Lives in .git so stash/reset/checkout can never touch it and -# it does not pollute the worktree. +# revert() runs on an already-failing path; bound its wait for the cache +# entry lock so that recovery cannot hang forever behind another process. +_REVERT_LOCK_TIMEOUT_SECONDS = 60 + +# When a complete cache entry already exists, a caller does not wait forever +# behind another process's stalled clone or update (git sets no network +# timeouts): after this bound it uses the existing clone without refreshing +# it. With no complete entry there is nothing to fall back to, so the wait +# is unbounded. +_COMPLETE_ENTRY_LOCK_TIMEOUT_SECONDS = 60 + +# Written inside .git only while the entry is a complete, quiescent +# checkout: after every clone step (clone, ref fetch, reset, submodule init) +# has finished, and removed for the duration of a refresh's rewrite +# (stash/fetch/reset). A directory without it is an interrupted clone or +# update (e.g. the process was killed mid-clone) and must be re-cloned; +# without this check such a directory would be trusted forever when the +# caller uses NEVER_REFRESH, and the bounded-wait fallback would hand a +# mid-rewrite tree to a timed-out peer. Lives in .git so +# stash/reset/checkout can never touch it and it does not pollute the +# worktree. _CLONE_COMPLETE_MARKER = "esphome_clone_complete" # Environment variables that scope git to a specific repository. Git hooks and @@ -149,6 +171,16 @@ def run_git_command( return ret.stdout.decode("utf-8").strip() +def _cache_key(url: str, ref: str | None) -> str: + """Cache key identifying one repository checkout. + + The lock path and the entry directory both hash this, keeping them in + agreement. (micro_wake_word still rebuilds the format by hand to locate + manifests; fold it in here if the format ever changes.) + """ + return f"{url}@{ref}" + + def _compute_destination_path(key: str, domain: str) -> Path: base_dir = Path(CORE.data_dir) / domain h = hashlib.new("sha256") @@ -156,22 +188,195 @@ def _compute_destination_path(key: str, domain: str) -> Path: return base_dir / h.hexdigest()[:8] +def _repo_entry_dir(key: str, domain: str, subpath: Path | None) -> Path: + """Worktree directory of one cache entry: the hash dir plus optional subpath.""" + repo_dir = _compute_destination_path(key, domain) + if subpath: + repo_dir = repo_dir / subpath + return repo_dir + + +def _repo_lock_path(key: str, domain: str) -> Path: + """Path of the lock file serializing all work on one cache entry. + + Lives next to the hash directory, never inside it, so the removal of a + broken or incomplete clone can never delete a lock another process holds. + """ + repo_dir = _compute_destination_path(key, domain) + return repo_dir.parent / f"{repo_dir.name}.lock" + + +class _LockStatus(Enum): + ACQUIRED = auto() + # A bounded wait expired while another process held the lock. + TIMEOUT = auto() + # The lock could not be taken at all; callers proceed unlocked, + # matching the behavior before the lock existed. + UNAVAILABLE = auto() + + +# Errnos that mean the filesystem genuinely cannot take file locks (NFS +# without a lock daemon, some FUSE mounts). Any other OSError (permissions, +# read-only volume, full disk) is a cache directory problem, which the git +# commands themselves report clearly when it actually matters. EPERM is +# deliberately absent: it usually means a permissions problem, so it takes +# the generic message that names no cause. On Linux ENOTSUP and EOPNOTSUPP +# are the same value; the set folds them. +_NO_LOCK_SUPPORT_ERRNOS = frozenset( + {errno.ENOLCK, errno.ENOSYS, errno.EOPNOTSUPP, errno.ENOTSUP} +) + + +def _acquire_repo_lock( + lock: "FileLock", + safe_key: str, + timeout: float, + wait_message: str = "Waiting for another process to finish updating %s", +) -> _LockStatus: + """Acquire ``lock``, logging ``wait_message`` when a wait actually begins. + + ``timeout`` of -1 waits forever; a positive value bounds the wait and + can yield ``TIMEOUT``. + """ + from filelock import Timeout + + try: + try: + lock.acquire(blocking=False) + except Timeout: + # Waiting on another process's clone or update can take + # minutes; say so instead of appearing hung. + _LOGGER.info(wait_message, safe_key) + lock.acquire(timeout=timeout) + except Timeout: + return _LockStatus.TIMEOUT + except OSError as err: + if err.errno in _NO_LOCK_SUPPORT_ERRNOS: + _LOGGER.warning( + "The filesystem does not support locking the cache entry for " + "%s (%s), continuing without a lock", + safe_key, + err, + ) + else: + # Not a locking problem (permissions, read-only volume, full + # disk). Still continue unlocked: a pre-seeded read-only cache + # with refresh disabled only reads and must keep working, and + # in every other case the git commands fail with the real error. + _LOGGER.warning( + "Could not take the cache entry lock for %s (%s), " + "continuing without a lock", + safe_key, + err, + ) + return _LockStatus.UNAVAILABLE + return _LockStatus.ACQUIRED + + +@contextmanager +def _repo_cache_lock( + key: str, domain: str, repo_dir: Path +) -> Iterator[tuple[bool, "FileLock | None"]]: + """Hold the cache entry lock for ``key`` over the with block. + + Yields ``(use_existing, lock)``. ``use_existing`` is True when the lock + could not be acquired within the bounded wait but ``repo_dir`` is a + complete cache entry; the caller should use it as-is and do nothing + else. Otherwise ``lock`` is the held lock, released when the block + exits, or ``None`` when the lock could not be taken at all and the + caller proceeds unlocked. + """ + # Lazy import: keeps filelock off the CLI startup import path. + from filelock import FileLock + + safe_key = _redact_url_credentials(key) + # acquire() creates the lock file's directory itself; git clone later + # creates the hash directory next to it. fallback_to_soft would silently + # downgrade ENOSYS to a SoftFileLock, whose stale existence marker from + # another host on a shared cache could hang the unbounded wait forever; + # routing it through the OSError handler runs unlocked instead. + lock: FileLock | None = FileLock( + str(_repo_lock_path(key, domain)), fallback_to_soft=False + ) + status = _acquire_repo_lock(lock, safe_key, _COMPLETE_ENTRY_LOCK_TIMEOUT_SECONDS) + if status is _LockStatus.TIMEOUT: + if _clone_complete_marker_path(repo_dir).is_file(): + # Mutual exclusion matters most while no complete entry exists + # (initial clone, recovery re-clone); with one on disk, reading + # it beats hanging behind a stalled holder. + _LOGGER.warning( + "Timed out waiting for another process updating %s, proceeding " + "with the existing clone, which that process may still be " + "changing", + safe_key, + ) + yield True, None + return + # Nothing to fall back to; the holder is producing the clone this + # caller needs. + status = _acquire_repo_lock( + lock, + safe_key, + timeout=-1, + wait_message="Still waiting for the clone of %s, " + "there is no existing clone to fall back on", + ) + if status is not _LockStatus.ACQUIRED: + lock = None + try: + yield False, lock + finally: + if lock is not None: + lock.release() + + def _clone_complete_marker_path(repo_dir: Path) -> Path: return repo_dir / ".git" / _CLONE_COMPLETE_MARKER +def _clear_clone_complete_marker(repo_dir: Path) -> None: + """Best-effort removal of the completion marker. + + If the unlink fails (e.g. a file lock on Windows), the marker stays and + the entry keeps its previous trust level; every consumer of the marker + tolerates that. + """ + try: + _clone_complete_marker_path(repo_dir).unlink(missing_ok=True) + except OSError as err: + _LOGGER.debug("Could not delete clone completion marker: %s", err) + + +def _write_clone_complete_marker( + repo_dir: Path, key: str, hash_dir_name: str, safe_key: str +) -> None: + """Mark the entry as a complete, quiescent checkout. + + The key and hash dir name are recorded purely to make cache debugging + easier. The marker is only a validity signal, so a failed write must not + fail an otherwise complete clone or update: the only cost is a re-clone + on the next run. + """ + try: + write_file( + _clone_complete_marker_path(repo_dir), + f"key={key}\nhash={hash_dir_name}\n", + ) + except EsphomeError as err: + _LOGGER.warning( + "Could not write clone completion marker for %s: %s", safe_key, err + ) + + def _remove_repo_dir(repo_dir: Path) -> None: """Remove a repo directory, deleting the completion marker first. Marker-first ordering guarantees an interrupted removal can never leave a marker behind next to a partially deleted worktree. The unlink is best - effort: if it fails (e.g. a file lock on Windows), rmtree below still - gets the chance to remove the directory, marker included. + effort: if it fails, rmtree below still gets the chance to remove the + directory, marker included. """ - try: - _clone_complete_marker_path(repo_dir).unlink(missing_ok=True) - except OSError as err: - _LOGGER.debug("Could not delete clone completion marker first: %s", err) + _clear_clone_complete_marker(repo_dir) if repo_dir.is_dir(): rmtree(repo_dir) @@ -286,16 +491,79 @@ def resolve_symlink_stub(repo_dir: Path, file_path: Path) -> Path | None: def clone_or_update( *, url: str, - ref: str = None, + ref: str | None = None, refresh: TimePeriodSeconds | None, domain: str, - username: str = None, - password: str = None, + username: str | None = None, + password: str | None = None, init_submodules: bool = False, subpath: Path | None = None, +) -> tuple[Path, Callable[[], bool] | None]: + """Clone a repository into the cache, or refresh an existing clone. + + All work runs under a per-cache-entry inter-process file lock, so + concurrent resolutions of the same repository (two esphome processes, or + a subprocess plus an in-process load) serialize instead of interleaving. + Without the lock, ``repo_dir.is_dir()`` is true from the instant + ``git clone`` creates the directory: a second caller could read a half + populated worktree, or see the missing completion marker and delete the + clone in progress out from under the first caller. + + The lock guards mutation of the cache entry only; it is released when + this function returns, so a caller still reading the worktree can + overlap a later refresh by another process. That residual window is + narrow (the refresh interval is re-checked under the lock) and predates + the lock. + + Locking is best effort: on a filesystem that cannot take file locks a + warning is logged and the work proceeds unlocked, matching the behavior + before the lock existed. A complete cache entry also caps the wait: if + the holder is still busy after a bounded time (e.g. stalled on the + network), the existing clone is used without refreshing it, so a stuck + process cannot hang every peer that already has a good entry. + """ + key = _cache_key(url, ref) + repo_dir = _repo_entry_dir(key, domain, subpath) + with _repo_cache_lock(key, domain, repo_dir) as (use_existing, lock): + if use_existing: + return repo_dir, None + return _clone_or_update_locked( + url=url, + ref=ref, + refresh=refresh, + domain=domain, + username=username, + password=password, + init_submodules=init_submodules, + subpath=subpath, + lock=lock, + ) + + +def _clone_or_update_locked( + *, + url: str, + ref: str | None, + refresh: TimePeriodSeconds | None, + domain: str, + username: str | None, + password: str | None, + init_submodules: bool, + subpath: Path | None, + lock: "FileLock | None", _recover_broken: bool = True, -) -> tuple[Path, Callable[[], None] | None]: - key = f"{url}@{ref}" +) -> tuple[Path, Callable[[], bool] | None]: + """Body of ``clone_or_update``; the caller holds ``lock``. + + Split out because the broken-repository recovery below re-enters this + function: re-acquiring the already-held lock would deadlock, since OS + file locks taken on separate file descriptors conflict even within one + process. ``lock`` is only re-acquired by the returned ``revert`` + callback, which runs after the wrapper's ``finally`` has released it. + ``lock`` is ``None`` when the filesystem cannot take file locks and the + wrapper fell back to running unlocked. + """ + key = _cache_key(url, ref) # The user may have embedded credentials in the URL itself; log this # instead of key. safe_key = _redact_url_credentials(key) @@ -309,10 +577,8 @@ def clone_or_update( "://", f"://{urllib.parse.quote(username)}:{urllib.parse.quote(password)}@" ) - repo_dir = _compute_destination_path(key, domain) - hash_dir_name = repo_dir.name - if subpath: - repo_dir = repo_dir / subpath + hash_dir_name = _compute_destination_path(key, domain).name + repo_dir = _repo_entry_dir(key, domain, subpath) if repo_dir.is_dir() and not _clone_complete_marker_path(repo_dir).is_file(): # The last clone never finished (killed process, container stop) or @@ -353,19 +619,8 @@ def clone_or_update( _remove_repo_dir(repo_dir) raise - # Every git step succeeded; the key and hash dir name are recorded - # purely to make cache debugging easier. The marker is only a - # validity signal, so a failed write must not fail an otherwise - # complete clone: the only cost is a re-clone on the next run. - try: - write_file( - _clone_complete_marker_path(repo_dir), - f"key={key}\nhash={hash_dir_name}\n", - ) - except EsphomeError as err: - _LOGGER.warning( - "Could not write clone completion marker for %s: %s", safe_key, err - ) + # Every git step succeeded. + _write_clone_complete_marker(repo_dir, key, hash_dir_name, safe_key) else: if refresh == NEVER_REFRESH or CORE.skip_external_update: @@ -396,6 +651,13 @@ def clone_or_update( _LOGGER.info("Updating %s", safe_key) _LOGGER.debug("Location: %s", repo_dir) + # The entry is about to be rewritten; drop the marker so a + # timed-out peer's fallback and the incomplete-entry check + # can tell a quiescent complete entry from one mid-rewrite, + # and so an update interrupted by a crash re-clones instead + # of being trusted. + _clear_clone_complete_marker(repo_dir) + # Stash local changes (if any) # Use git_dir to ensure this only affects the specific repo run_git_command( @@ -425,6 +687,15 @@ def clone_or_update( # refresh window would silently accept on the next run. if init_submodules: update_submodules(repo_dir, key) + + # Recorded so revert() can tell whether the checkout is + # still the one this update produced. + new_sha = run_git_command( + ["git", "rev-parse", "HEAD"], git_dir=repo_dir + ) + + # The rewrite finished; the entry is trustworthy again. + _write_clone_complete_marker(repo_dir, key, hash_dir_name, safe_key) except GitException as err: # Repository is in a broken state or update failed # Only attempt recovery once to prevent infinite recursion @@ -444,9 +715,10 @@ def clone_or_update( _remove_repo_dir(repo_dir) _LOGGER.info("Successfully removed broken repository, re-cloning...") - # Recursively call clone_or_update to re-clone - # Set _recover_broken=False to prevent infinite recursion - result = clone_or_update( + # Re-clone while still holding the lock; going through the + # public wrapper would try to re-acquire it and deadlock. + # Set _recover_broken=False to prevent infinite recursion. + result = _clone_or_update_locked( url=original_url, ref=ref, refresh=refresh, @@ -455,14 +727,80 @@ def clone_or_update( password=password, init_submodules=init_submodules, subpath=subpath, + lock=lock, _recover_broken=False, ) _LOGGER.info("Repository %s successfully recovered", safe_key) return result - def revert(): - _LOGGER.info("Reverting changes to %s -> %s", safe_key, old_sha) - run_git_command(["git", "reset", "--hard", old_sha], git_dir=repo_dir) + def revert() -> bool: + """Reset the checkout to the pre-update SHA. + + Returns False when the revert did not happen: the cache + entry lock could not be acquired in time, the checkout + moved since this update (another process refreshed it), or + the reset itself failed. A retry cannot reach the + pre-update content then. + """ + if lock is None: + # The wrapper already warned about the unlockable + # filesystem; revert unlocked like everything else. + status = _LockStatus.UNAVAILABLE + else: + status = _acquire_repo_lock( + lock, safe_key, _REVERT_LOCK_TIMEOUT_SECONDS + ) + if status is _LockStatus.TIMEOUT: + # revert() only runs on an already-failing path; skip + # rather than hang so the original error can surface. + _LOGGER.warning( + "Could not lock %s to revert to %s, skipping revert; " + "the cached checkout keeps the un-reverted content " + "until its next refresh", + safe_key, + old_sha, + ) + return False + try: + # Anything can happen between the wrapper releasing the + # lock and revert() re-acquiring it; only undo this + # process's own update, never a peer's newer refresh. + head = run_git_command( + ["git", "rev-parse", "HEAD"], git_dir=repo_dir + ) + if head != new_sha: + _LOGGER.warning( + "Not reverting %s: the checkout moved since this " + "update (another process refreshed it)", + safe_key, + ) + return False + # Announced only once every skip check has passed, so + # the log says exactly one thing per outcome. + _LOGGER.info("Reverting changes to %s -> %s", safe_key, old_sha) + run_git_command( + ["git", "reset", "--hard", old_sha], git_dir=repo_dir + ) + except GitException as err: + # GitException is a cv.Invalid; letting it escape would + # replace the caller's original error with a bare git + # message. Report the failed reset like the skip above, + # and drop the marker: an entry whose reset fails cannot + # be trusted, so the next use re-clones it instead of + # the refresh window silently accepting it. + _LOGGER.warning( + "Could not revert %s to %s (%s), the entry will be " + "re-cloned on next use", + safe_key, + old_sha, + err, + ) + _clear_clone_complete_marker(repo_dir) + return False + finally: + if status is _LockStatus.ACQUIRED: + lock.release() + return True return repo_dir, revert diff --git a/requirements.txt b/requirements.txt index 541b374b15..8ba908d8b4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 platformdirs==4.11.0 # native esp-idf toolchain global cache dir -filelock==3.32.0 # lock guarding the PlatformIO python-version cache heal +filelock==3.32.0 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 6990c1c051..39bffd31b7 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -1264,6 +1264,75 @@ def test_remote_packages_no_revert( ] +@patch("esphome.yaml_util.load_yaml") +@patch("pathlib.Path.is_file") +@patch("esphome.git.clone_or_update") +def test_remote_packages_skipped_revert_does_not_retry( + mock_clone_or_update, mock_is_file, mock_load_yaml +) -> None: + """When revert() reports the rollback was skipped, the load is not + retried (the checkout is unchanged) and the error says so.""" + mock_revert = MagicMock(return_value=False) + mock_clone_or_update.return_value = (Path("/tmp/noexists"), mock_revert) + mock_is_file.return_value = True + mock_load_yaml.side_effect = cv.Invalid("bad yaml") + + config = { + CONF_PACKAGES: { + "pkg": { + CONF_URL: "https://github.com/esphome/repo", + CONF_REF: "main", + CONF_FILES: [{CONF_PATH: "file.yaml"}], + CONF_REFRESH: "1d", + } + } + } + with pytest.raises(cv.Invalid, match="could not revert the cached checkout"): + packages_pass(config) + + assert mock_revert.call_count == 1 + assert mock_load_yaml.call_count == 1 + + +@patch("esphome.yaml_util.load_yaml") +@patch("pathlib.Path.is_file") +@patch("esphome.git.clone_or_update") +def test_remote_packages_successful_revert_retries( + mock_clone_or_update, mock_is_file, mock_load_yaml, caplog: pytest.LogCaptureFixture +) -> None: + """A successful revert retries the load against the reverted checkout and + logs the original error, the only trace that upstream was broken.""" + mock_revert = MagicMock(return_value=True) + mock_clone_or_update.return_value = (Path("/tmp/noexists"), mock_revert) + mock_is_file.return_value = True + mock_load_yaml.side_effect = [ + cv.Invalid("bad yaml"), + OrderedDict( + {CONF_SENSOR: [{CONF_PLATFORM: TEST_SENSOR_PLATFORM_1, CONF_NAME: "test"}]} + ), + ] + + config = { + CONF_PACKAGES: { + "pkg": { + CONF_URL: "https://github.com/esphome/repo", + CONF_REF: "main", + CONF_FILES: [{CONF_PATH: "file.yaml"}], + CONF_REFRESH: "1d", + } + } + } + with caplog.at_level(logging.WARNING): + actual = packages_pass(config) + + assert actual[CONF_SENSOR] == [ + {CONF_PLATFORM: TEST_SENSOR_PLATFORM_1, CONF_NAME: "test"} + ] + assert mock_revert.call_count == 1 + assert mock_load_yaml.call_count == 2 + assert any("reverted the cached checkout" in r.getMessage() for r in caplog.records) + + def test_raw_config_contains_merged_esphome_from_package(tmp_path) -> None: """Test that CORE.raw_config contains esphome section from merged package. diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index 858eee5e9f..13283fc067 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -1,14 +1,17 @@ """Tests for git.py module.""" from collections.abc import Callable +import errno import logging import os from pathlib import Path import subprocess +import threading import time from typing import Any from unittest.mock import Mock, patch +from filelock import FileLock import pytest from esphome import git @@ -74,17 +77,23 @@ def _simulate_cloned_repo(repo_dir: Path) -> None: def _make_clone_side_effect( - repo_dir: Path, gitmodules: bool = False + repo_dir: Path, + gitmodules: bool = False, + on_clone: Callable[[], None] | None = None, ) -> Callable[..., str]: """Return a run_git_command side effect whose clone creates the repo dir. - With ``gitmodules`` the cloned repo also declares submodules. + With ``gitmodules`` the cloned repo also declares submodules. ``on_clone`` + runs at clone time before the repo dir appears, so a test can probe or + block mid-clone. """ def git_command_side_effect( cmd: list[str], cwd: str | None = None, **kwargs: Any ) -> str: if _get_git_command_type(cmd) == "clone": + if on_clone is not None: + on_clone() _simulate_cloned_repo(repo_dir) if gitmodules: (repo_dir / ".gitmodules").write_text("test") @@ -1491,6 +1500,591 @@ def test_refresh_submodule_failure_recovers_then_raises( ) +def _lock_path(url: str, ref: str | None, domain: str) -> Path: + """The lock file the implementation uses for one cache entry.""" + return git._repo_lock_path(git._cache_key(url, ref), domain) + + +class _SetEventOnWaitLog(logging.Handler): + """Set an event when the 'Waiting for another process' record is emitted, + so tests can react to a caller observably blocking on the lock instead of + racing a wall-clock timer.""" + + def __init__(self, event: threading.Event) -> None: + super().__init__() + self._event = event + + def emit(self, record: logging.LogRecord) -> None: + if "Waiting for another process" in record.getMessage(): + self._event.set() + + +def test_clone_or_update_serializes_concurrent_clones( + tmp_path: Path, mock_run_git_command: Mock, caplog: pytest.LogCaptureFixture +) -> None: + """Two concurrent callers for the same uncached repo must not both clone. + + Without the per-entry lock both callers pass the is_dir() check before + either clone finishes, so the second one either clones on top of the + first or reads a half populated worktree (device-builder issue 2425). + """ + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + + start_together = threading.Barrier(2) + other_caller_waiting = threading.Event() + + def on_clone() -> None: + # Hold the lock until the other caller is observably blocked on it, + # so the interleaving is guaranteed rather than raced on a timer. + assert other_caller_waiting.wait(timeout=30) + + mock_run_git_command.side_effect = _make_clone_side_effect( + repo_dir, on_clone=on_clone + ) + + results: list[Path] = [] + errors: list[BaseException] = [] + + def call() -> None: + try: + start_together.wait() + result_dir, _ = git.clone_or_update( + url=url, ref=None, refresh=git.NEVER_REFRESH, domain=domain + ) + results.append(result_dir) + except BaseException as err: # noqa: BLE001 - re-raised via errors below + errors.append(err) + + handler = _SetEventOnWaitLog(other_caller_waiting) + git_logger = logging.getLogger("esphome.git") + git_logger.addHandler(handler) + try: + with caplog.at_level(logging.INFO): + threads = [threading.Thread(target=call) for _ in range(2)] + for t in threads: + t.start() + for t in threads: + t.join() + finally: + git_logger.removeHandler(handler) + + assert not errors + assert results == [repo_dir, repo_dir] + clone_calls = [ + c + for c in mock_run_git_command.call_args_list + if _get_git_command_type(c[0][0]) == "clone" + ] + # The second caller waited for the lock, then saw the completed clone. + assert len(clone_calls) == 1 + assert _marker_path(repo_dir).is_file() + + +def test_clone_or_update_creates_lock_file_next_to_hash_dir( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """The lock file lives beside the hash dir, never inside it. + + Checked while the clone runs (the lock is held): filelock's Windows + backend deletes the lock file on release, so probing after the call + would only work on Unix. + """ + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + lock_path = _lock_path(url, None, domain) + lock_held_during_clone: list[bool] = [] + + mock_run_git_command.side_effect = _make_clone_side_effect( + repo_dir, on_clone=lambda: lock_held_during_clone.append(lock_path.is_file()) + ) + + result_dir, _ = git.clone_or_update( + url=url, ref=None, refresh=git.NEVER_REFRESH, domain=domain + ) + + assert result_dir == repo_dir + assert lock_path.parent == repo_dir.parent + assert lock_held_during_clone == [True] + + +def test_clone_or_update_subpath_locks_at_hash_dir_level( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """With a subpath the lock still guards the whole hash dir cache entry.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + hash_dir = _compute_repo_dir(url, None, domain) + repo_dir = hash_dir / "lib" + lock_path = _lock_path(url, None, domain) + lock_held_during_clone: list[bool] = [] + + mock_run_git_command.side_effect = _make_clone_side_effect( + repo_dir, on_clone=lambda: lock_held_during_clone.append(lock_path.is_file()) + ) + + result_dir, _ = git.clone_or_update( + url=url, + ref=None, + refresh=git.NEVER_REFRESH, + domain=domain, + subpath=Path("lib"), + ) + + assert result_dir == repo_dir + assert lock_path == hash_dir.parent / f"{hash_dir.name}.lock" + assert lock_held_during_clone == [True] + + +def test_clone_or_update_recovery_holds_lock_without_deadlock( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Recovery re-clones while holding the lock and must not re-acquire it. + + A naive re-acquisition would deadlock here, since OS file locks taken on + separate descriptors conflict even within one process. The lock file + itself must survive the recovery rmtree of the broken repo dir: the + re-clone runs after the rmtree, so probing the lock file there proves + it. Probing after the call would only work on Unix, since filelock's + Windows backend deletes the lock file on release. + """ + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + lock_path = _lock_path(url, None, domain) + lock_held_during_reclone: list[bool] = [] + + _setup_old_repo(repo_dir) + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + if _get_git_command_type(cmd) == "stash": + raise git.GitCommandError("broken repository") + if _get_git_command_type(cmd) == "clone": + lock_held_during_reclone.append(lock_path.is_file()) + _simulate_cloned_repo(repo_dir) + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + recovered_dir, _ = git.clone_or_update( + url=url, ref=None, refresh=TimePeriodSeconds(days=1), domain=domain + ) + + assert recovered_dir == repo_dir + assert lock_held_during_reclone == [True] + + +def _hold_lock_in_thread(lock_path: Path) -> tuple[threading.Thread, threading.Event]: + """Hold the lock from another thread; returns the thread and its release event. + + OS file locks taken on separate descriptors conflict even within one + process, so a second FileLock instance in a thread contends the same + way another process would. + """ + held = threading.Event() + release = threading.Event() + + def hold() -> None: + with FileLock(str(lock_path)): + held.set() + release.wait(timeout=30) + + holder = threading.Thread(target=hold) + holder.start() + assert held.wait(timeout=30) + return holder, release + + +def test_clone_or_update_logs_wait_on_contended_lock( + tmp_path: Path, mock_run_git_command: Mock, caplog: pytest.LogCaptureFixture +) -> None: + """A contended acquire logs a redacted waiting message instead of + silently blocking.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://user:hunter2@github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + mock_run_git_command.side_effect = _make_clone_side_effect(repo_dir) + + holder, release = _hold_lock_in_thread(_lock_path(url, None, domain)) + + # Free the lock only once the waiting message has been emitted, so the + # release is caused by the thing being asserted instead of racing it. + handler = _SetEventOnWaitLog(release) + git_logger = logging.getLogger("esphome.git") + git_logger.addHandler(handler) + try: + with caplog.at_level(logging.INFO): + result_dir, _ = git.clone_or_update( + url=url, ref=None, refresh=git.NEVER_REFRESH, domain=domain + ) + finally: + git_logger.removeHandler(handler) + release.set() + holder.join() + + assert result_dir == repo_dir + waiting = [ + r.getMessage() + for r in caplog.records + if "Waiting for another process" in r.getMessage() + ] + assert len(waiting) == 1 + assert "hunter2" not in waiting[0] + assert "://***@" in waiting[0] + + +def test_revert_skips_on_contended_lock( + tmp_path: Path, + mock_run_git_command: Mock, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """revert() only runs on an already-failing path; when it cannot get the + lock within the bounded timeout it warns and skips instead of hanging.""" + CORE.config_path = tmp_path / "test.yaml" + monkeypatch.setattr(git, "_REVERT_LOCK_TIMEOUT_SECONDS", 0.05) + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + _setup_old_repo(repo_dir) + + # A bare return value satisfies the rev-parse; the other commands' + # outputs are unused. + mock_run_git_command.return_value = "old_sha" + + _, revert = git.clone_or_update( + url=url, ref=None, refresh=TimePeriodSeconds(days=1), domain=domain + ) + assert revert is not None + + holder, release = _hold_lock_in_thread(_lock_path(url, None, domain)) + calls_before = len(mock_run_git_command.call_args_list) + with caplog.at_level(logging.INFO): + assert revert() is False + release.set() + holder.join() + + # No git reset was issued; the wait and the skip were both logged. + assert len(mock_run_git_command.call_args_list) == calls_before + assert any("Waiting for another process" in r.getMessage() for r in caplog.records) + assert any("skipping revert" in r.getMessage() for r in caplog.records) + + +def test_update_clears_marker_while_rewriting( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """The completion marker is absent while a refresh rewrites the entry + and restored once the rewrite finishes, so a timed-out peer's fallback + never trusts a mid-rewrite tree and a crashed update re-clones.""" + 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) + + marker_during_rewrite: list[bool] = [] + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + if _get_git_command_type(cmd) in ("stash", "fetch", "reset"): + marker_during_rewrite.append(_marker_path(repo_dir).is_file()) + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + git.clone_or_update( + url=url, ref=None, refresh=TimePeriodSeconds(days=1), domain=domain + ) + + assert marker_during_rewrite == [False, False, False] + assert _marker_path(repo_dir).is_file() + + +def test_revert_skips_when_checkout_moved( + tmp_path: Path, mock_run_git_command: Mock, caplog: pytest.LogCaptureFixture +) -> None: + """revert() only undoes this process's own update; when another process + refreshed the entry in the meantime it skips instead of rolling the + peer's newer checkout backwards.""" + 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) + + # Pre-update SHA, post-update SHA, then a peer's SHA at revert time. + shas = iter(["old_sha", "new_sha", "peer_sha"]) + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + if _get_git_command_type(cmd) == "rev-parse": + return next(shas) + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + _, revert = git.clone_or_update( + url=url, ref=None, refresh=TimePeriodSeconds(days=1), domain=domain + ) + assert revert is not None + + with caplog.at_level(logging.WARNING): + assert revert() is False + + resets = [ + c[0][0] + for c in mock_run_git_command.call_args_list + if _get_git_command_type(c[0][0]) == "reset" and c[0][0][-1] == "old_sha" + ] + assert resets == [] + assert any("checkout moved" in r.getMessage() for r in caplog.records) + + +def test_revert_returns_false_when_reset_fails( + tmp_path: Path, mock_run_git_command: Mock, caplog: pytest.LogCaptureFixture +) -> None: + """A failed git reset inside revert() is reported through the bool + contract instead of raising a cv.Invalid that would replace the + caller's original error.""" + 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) + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + if _get_git_command_type(cmd) == "rev-parse": + return "old_sha" + # Only revert's reset targets the recorded SHA; the update path's + # reset targets FETCH_HEAD and must succeed. + if cmd[-1] == "old_sha": + raise git.GitCommandError("object not found") + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + _, revert = git.clone_or_update( + url=url, ref=None, refresh=TimePeriodSeconds(days=1), domain=domain + ) + assert revert is not None + + with caplog.at_level(logging.WARNING): + assert revert() is False + + assert any("Could not revert" in r.getMessage() for r in caplog.records) + # The entry cannot be trusted after a failed reset; the dropped marker + # forces a re-clone on the next use. + assert not _marker_path(repo_dir).is_file() + + +def _raise_oserror_on_acquire( + monkeypatch: pytest.MonkeyPatch, code: int = errno.ENOLCK +) -> None: + """Make every FileLock acquire fail with the given errno.""" + + def broken_acquire(self: FileLock, *args: Any, **kwargs: Any) -> None: + raise OSError(code, os.strerror(code)) + + monkeypatch.setattr(FileLock, "acquire", broken_acquire) + + +@pytest.mark.parametrize( + ("code", "expected_fragment"), + [ + # Genuinely missing lock support is reported as such. + (errno.ENOLCK, "does not support locking"), + # A cache directory problem is not blamed on lock support; git + # reports the real error when it actually matters. + (errno.EROFS, "Could not take the cache entry lock"), + ], +) +def test_clone_or_update_continues_unlocked_when_filesystem_cannot_lock( + tmp_path: Path, + mock_run_git_command: Mock, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, + code: int, + expected_fragment: str, +) -> None: + """A filesystem where taking the lock fails (e.g. NFS without a lock + daemon) degrades to the old unlocked behavior instead of failing.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + mock_run_git_command.side_effect = _make_clone_side_effect(repo_dir) + _raise_oserror_on_acquire(monkeypatch, code) + + with caplog.at_level(logging.WARNING): + result_dir, _ = git.clone_or_update( + url=url, ref=None, refresh=git.NEVER_REFRESH, domain=domain + ) + + assert result_dir == repo_dir + assert _marker_path(repo_dir).is_file() + warnings = [ + r.getMessage() + for r in caplog.records + if "continuing without a lock" in r.getMessage() + ] + assert warnings + assert expected_fragment in warnings[0] + + +@pytest.mark.parametrize("broken_from_start", [True, False]) +def test_revert_continues_unlocked_when_filesystem_cannot_lock( + tmp_path: Path, + mock_run_git_command: Mock, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, + broken_from_start: bool, +) -> None: + """A revert still resets when locking is unavailable, whether the wrapper + already fell back to unlocked (revert sees no lock at all) or the + filesystem stops locking between the update and the revert.""" + 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) + + # A bare return value satisfies the rev-parse; the other commands' + # outputs are unused. + mock_run_git_command.return_value = "old_sha" + + if broken_from_start: + _raise_oserror_on_acquire(monkeypatch) + + with caplog.at_level(logging.WARNING): + _, revert = git.clone_or_update( + url=url, ref=None, refresh=TimePeriodSeconds(days=1), domain=domain + ) + assert revert is not None + if not broken_from_start: + _raise_oserror_on_acquire(monkeypatch) + # The reset ran (unlocked), so the revert reports success. + assert revert() is True + + assert mock_run_git_command.call_args_list[-1][0][0] == [ + "git", + "reset", + "--hard", + "old_sha", + ] + assert any("continuing without a lock" in r.getMessage() for r in caplog.records) + + +def _script_acquire_statuses( + monkeypatch: pytest.MonkeyPatch, statuses: list["git._LockStatus"] +) -> list[float]: + """Replace _acquire_repo_lock with a scripted sequence; returns the + timeouts it was called with.""" + timeouts: list[float] = [] + status_iter = iter(statuses) + + def fake_acquire( + lock: FileLock, safe_key: str, timeout: float, **kwargs: Any + ) -> "git._LockStatus": + timeouts.append(timeout) + return next(status_iter) + + monkeypatch.setattr(git, "_acquire_repo_lock", fake_acquire) + return timeouts + + +@pytest.mark.parametrize("subpath", [None, Path("lib")]) +def test_clone_or_update_uses_complete_entry_when_lock_wait_times_out( + tmp_path: Path, + mock_run_git_command: Mock, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, + subpath: Path | None, +) -> None: + """A bounded wait behind a stalled holder falls back to an existing + complete cache entry instead of hanging every peer.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + if subpath is not None: + repo_dir = repo_dir / subpath + _simulate_cloned_repo(repo_dir) + _mark_clone_complete(repo_dir) + + timeouts = _script_acquire_statuses(monkeypatch, [git._LockStatus.TIMEOUT]) + + with caplog.at_level(logging.WARNING): + result_dir, revert = git.clone_or_update( + url=url, + ref=None, + refresh=TimePeriodSeconds(days=1), + domain=domain, + subpath=subpath, + ) + + assert result_dir == repo_dir + assert revert is None + # Nothing was cloned or refreshed; the existing entry was used as-is. + assert mock_run_git_command.call_args_list == [] + assert timeouts == [git._COMPLETE_ENTRY_LOCK_TIMEOUT_SECONDS] + assert any( + "proceeding with the existing clone" in r.getMessage() for r in caplog.records + ) + + +def test_clone_or_update_waits_unbounded_without_complete_entry( + tmp_path: Path, + mock_run_git_command: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """With no complete entry there is nothing to fall back to, so after the + bounded wait expires the caller keeps waiting for the holder's clone.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + mock_run_git_command.side_effect = _make_clone_side_effect(repo_dir) + + timeouts = _script_acquire_statuses( + monkeypatch, [git._LockStatus.TIMEOUT, git._LockStatus.ACQUIRED] + ) + + result_dir, _ = git.clone_or_update( + url=url, ref=None, refresh=git.NEVER_REFRESH, domain=domain + ) + + assert result_dir == repo_dir + assert timeouts == [git._COMPLETE_ENTRY_LOCK_TIMEOUT_SECONDS, -1] + # The clone proceeded normally once the lock was finally acquired. + assert _marker_path(repo_dir).is_file() + + def _real_git(*args: str, cwd: Path) -> None: """Run real git to build a test fixture repository.""" subprocess.run( @@ -1674,7 +2268,8 @@ def test_refresh_picks_up_new_remote_commits( # 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"] + # The trailing rev-parse records the post-update SHA for revert(). + assert cmd_sequence == ["rev-parse", "stash", "fetch", "reset", "rev-parse"] fetch_cmd = call_list[2][0][0] assert "--depth=1" in fetch_cmd @@ -1685,7 +2280,7 @@ def test_refresh_picks_up_new_remote_commits( # revert callback should reset back to the recorded pre-update SHA. assert revert is not None - revert() + assert revert() is True assert mock_run_git_command.call_args_list[-1][0][0] == [ "git", "reset", From a199ac41ee1c44dec6a581f65332b06e04f3d93e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:34:04 -0400 Subject: [PATCH 1228/1815] [espidf] Use forward slashes in generated component CMakeLists (#17965) Co-authored-by: J. Nick Koston --- esphome/espidf/component.py | 14 ++++++-- tests/unit_tests/test_espidf_component.py | 43 ++++++++++++++++++----- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 51d023099e..09213b14e3 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -92,6 +92,14 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: # In CMakeLists.txt, backslashes need to be escaped return f'"{str(p)}"'.replace("\\", "\\\\") + def escape_path(p: PathType) -> str: + # CMake uses forward slashes for paths on every platform and treats + # backslashes as escape characters. On Windows os.path.relpath yields + # backslash paths, which break CMake's list re-parsing (e.g. "\b" in + # "src\backend" is an invalid character escape). Emit forward slashes, + # which Windows accepts too, so the generated CMakeLists is portable. + return f'"{str(p).replace(os.sep, "/")}"' + # Extract the values build_src_dir = component.data.get("build", {}).get("srcDir", None) if not build_src_dir: @@ -173,10 +181,10 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: # Generate the component content = "idf_component_register(\n" if build_src_files: - str_srcs = " ".join([escape_entry(p) for p in sorted(build_src_files)]) + str_srcs = " ".join([escape_path(p) for p in sorted(build_src_files)]) content += f" SRCS {str_srcs}\n" if build_include_dirs: - str_include_dirs = " ".join([escape_entry(p) for p in build_include_dirs]) + str_include_dirs = " ".join([escape_path(p) for p in build_include_dirs]) content += f" INCLUDE_DIRS {str_include_dirs}\n" # Project-managed and built-in component lists are set per-project # via idf_build_set_property in the top-level CMakeLists; expanded @@ -211,7 +219,7 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: if link_directories: content += "target_link_directories(${COMPONENT_LIB} INTERFACE\n" for link_directory in link_directories: - str_build_flag = escape_entry(link_directory) + str_build_flag = escape_path(link_directory) content += f" {str_build_flag}\n" content += ")\n" diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index f9ed44b8d2..879d98c0a7 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -169,30 +169,57 @@ def test_generate_cmakelists_txt_with_flags(tmp_component, tmp_path): } content = generate_cmakelists_txt(tmp_component) - sep = "\\\\" if os.name == "nt" else "/" + # Paths are always emitted with forward slashes so the CMakeLists is + # portable; on Windows os.path.relpath would otherwise yield backslashes + # that break CMake's list re-parsing. assert ( content - == f"""idf_component_register( - SRCS "src{sep}main.c" + == """idf_component_register( + SRCS "src/main.c" INCLUDE_DIRS "src" - REQUIRES dep ${{ESPHOME_PROJECT_MANAGED_COMPONENTS}} ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}} + REQUIRES dep ${ESPHOME_PROJECT_MANAGED_COMPONENTS} ${ESPHOME_PROJECT_BUILTIN_COMPONENTS} ) -target_compile_options(${{COMPONENT_LIB}} PUBLIC +target_compile_options(${COMPONENT_LIB} PUBLIC "-DTEST" ) -target_compile_options(${{COMPONENT_LIB}} PRIVATE +target_compile_options(${COMPONENT_LIB} PRIVATE "-Wall" ) -target_link_directories(${{COMPONENT_LIB}} INTERFACE +target_link_directories(${COMPONENT_LIB} INTERFACE "lib" ) -target_link_libraries(${{COMPONENT_LIB}} INTERFACE +target_link_libraries(${COMPONENT_LIB} INTERFACE "mylib" ) """ ) +def test_generate_cmakelists_txt_uses_forward_slashes_on_windows( + tmp_component, monkeypatch: pytest.MonkeyPatch +) -> None: + # os.path.relpath yields backslash paths on Windows, which CMake rejects + # when it re-parses the SRCS list (e.g. "\b" in "src\backend" is an invalid + # character escape). Simulate that output and confirm the generated + # CMakeLists normalizes the separators to forward slashes. + src_dir = tmp_component.path / "src" / "backend" + src_dir.mkdir(parents=True) + (src_dir / "cipher.c").write_text("int f() {}") + + tmp_component.data = {} + + monkeypatch.setattr("esphome.espidf.component.os.sep", "\\") + monkeypatch.setattr( + "esphome.espidf.component.os.path.relpath", + lambda *args, **kwargs: "src\\backend\\cipher.c", + ) + + content = generate_cmakelists_txt(tmp_component) + + assert 'SRCS "src/backend/cipher.c"' in content + assert "\\" not in content + + def test_generate_cmakelists_txt_multi_token_flag(tmp_component): # PlatformIO shell-lexes each build.flags entry, so a single entry can # carry a flag and its argument. The generated CMakeLists must emit them From d4372ed008bd7d1f9cce4ff136afd50cc2fd0692 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:34:44 +1200 Subject: [PATCH 1229/1815] [esp32] Restrict toolchain validation to supported values (#17972) --- esphome/components/esp32/__init__.py | 4 +++- tests/component_tests/esp32/test_esp32.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 7911b172d3..8e43140b49 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1029,7 +1029,9 @@ def _check_esp_idf_versions(config: ConfigType) -> ConfigType: def _validate_toolchain(value) -> Toolchain: - return Toolchain(cv.one_of(*(t.value for t in Toolchain), lower=True)(value)) + return Toolchain( + cv.one_of(Toolchain.PLATFORMIO, Toolchain.ESP_IDF, lower=True)(value) + ) def _resolve_toolchain(value: ConfigType) -> ConfigType: diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 8a116ccc27..8b6fcf2e9c 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -108,6 +108,24 @@ def test_esp32_default_toolchain_is_esp_idf( assert CORE.toolchain == expected +@pytest.mark.parametrize( + "config_toolchain", + [Toolchain.SDK_NRF.value, "nonsense"], +) +def test_esp32_rejects_unsupported_toolchains( + set_core_config: SetCoreConfigCallable, + config_toolchain: str, +) -> None: + """Toolchains esp32 does not support are rejected at validation time.""" + set_core_config(PlatformFramework.ESP32_IDF) + + from esphome.components.esp32 import CONFIG_SCHEMA + + CORE.toolchain = None + with pytest.raises(cv.Invalid, match="Unknown value"): + CONFIG_SCHEMA({"variant": VARIANT_ESP32, "toolchain": config_toolchain}) + + @pytest.mark.parametrize( ("config", "error_match"), [ From de93815c6b4f8cb72af77ebf46a27b85e4b1c62a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Jul 2026 14:48:17 -1000 Subject: [PATCH 1230/1815] [api] Fix double free when overflow buffer drain is re-entered (#17969) --- .../components/api/api_overflow_buffer.cpp | 21 +++++++++++++++++-- esphome/components/api/api_overflow_buffer.h | 4 ++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api_overflow_buffer.cpp b/esphome/components/api/api_overflow_buffer.cpp index e242d4553e..a57a2fb1bb 100644 --- a/esphome/components/api/api_overflow_buffer.cpp +++ b/esphome/components/api/api_overflow_buffer.cpp @@ -12,6 +12,22 @@ APIOverflowBuffer::~APIOverflowBuffer() { } ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) { + // socket->write() can re-enter this function: a log message emitted from an + // lwip callback during the write goes out over the API and lands back in the + // frame helper's write/drain path. If a nested drain ran here it would send + // and free the entry the outer drain is still holding, causing a double free. + // Report "no progress" instead; the outer drain keeps draining, and the + // nested send is enqueued behind the existing backlog. + if (this->draining_) + return 0; + + // RAII so the flag is cleared on every return path + struct DrainGuard { + explicit DrainGuard(bool &flag) : flag_(flag) { flag_ = true; } + ~DrainGuard() { this->flag_ = false; } + bool &flag_; + } guard(this->draining_); + while (this->count_ > 0) { Entry *front = this->queue_[this->head_]; @@ -29,11 +45,12 @@ ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) { return sent; } - // Entry fully sent — free it and advance - Entry::destroy(front); + // Entry fully sent — unlink it before freeing so a freed pointer is never + // reachable from the queue this->queue_[this->head_] = nullptr; this->head_ = (this->head_ + 1) % API_MAX_SEND_QUEUE; this->count_--; + Entry::destroy(front); } return 0; // All drained diff --git a/esphome/components/api/api_overflow_buffer.h b/esphome/components/api/api_overflow_buffer.h index 19aae680f0..1227e83126 100644 --- a/esphome/components/api/api_overflow_buffer.h +++ b/esphome/components/api/api_overflow_buffer.h @@ -69,6 +69,10 @@ class APIOverflowBuffer { uint8_t head_{0}; uint8_t tail_{0}; uint8_t count_{0}; + // Guards against re-entrant drains: socket->write() can re-enter the API + // send path (e.g. a log message emitted from an lwip callback), and a nested + // drain would free the entry the outer drain is still holding. + bool draining_{false}; }; } // namespace esphome::api From 57bb4e4e7793269f873c7b14505ae8a95a77cb73 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 08:52:42 -0500 Subject: [PATCH 1231/1815] Bump bundled esphome-device-builder to 1.8.2 (#17995) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 4aa589fc5e..e58efd1ee0 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.8.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.8.2 RUN \ platformio settings set enable_telemetry No \ From b8703a8a1b8a0a15a38c5d862fa2aa50b1af5649 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:08:57 -0500 Subject: [PATCH 1232/1815] Bump bundled esphome-device-builder to 1.9.0 (#18017) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index e58efd1ee0..a7aaea6b60 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.8.2 +RUN uv pip install --no-cache-dir esphome-device-builder==1.9.0 RUN \ platformio settings set enable_telemetry No \ From b4166a883ea272f7cf7846a63c6b0f576c50afd1 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:46:03 -0500 Subject: [PATCH 1233/1815] Bump bundled esphome-device-builder to 1.9.1 (#18025) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index a7aaea6b60..5f84226b87 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.9.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.9.1 RUN \ platformio settings set enable_telemetry No \ From 56a90d2b258a91a0d26b5a113c77bc99ca5a2015 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:29:21 +0000 Subject: [PATCH 1234/1815] Bump bundled esphome-device-builder to 1.9.2 (#18051) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 5f84226b87..ebce522454 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.9.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.9.2 RUN \ platformio settings set enable_telemetry No \ From 920ff9c25f14de8ceb3d9253d7dfabcf3d904104 Mon Sep 17 00:00:00 2001 From: Samuel Sieb Date: Mon, 3 Aug 2026 20:36:52 -0700 Subject: [PATCH 1235/1815] [esp32] explicitly disable BLE 5.0 (#18047) Co-authored-by: Samuel Sieb --- esphome/components/esp32/__init__.py | 15 ++++++--------- esphome/components/esp32_ble/__init__.py | 2 +- esphome/components/esp32_ble_beacon/__init__.py | 2 +- tests/component_tests/esp32/test_esp32.py | 13 +++---------- 4 files changed, 11 insertions(+), 21 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 8e43140b49..57837eb9c1 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -628,7 +628,6 @@ class NetworkSdkconfigData: wifi_ap: bool = False # WiFi AP mode configured ethernet: bool = False # Ethernet component active bluetooth: bool = False # any BLE component active - ble_42: bool = False # BLE 4.2 features needed software_coexistence: bool = False # WiFi/BT software coexistence requested # esp32 advanced enable_lwip_dhcp_server option (True/False/None=unset) enable_lwip_dhcp_server: bool | None = None @@ -654,12 +653,10 @@ def request_ethernet() -> None: _network_sdkconfig().ethernet = True -def request_bluetooth(ble_42: bool = False) -> None: - """Request the Bluetooth controller. Pass ble_42=True for 4.2 features.""" +def request_bluetooth() -> None: + """Request the Bluetooth controller.""" net = _network_sdkconfig() net.bluetooth = True - if ble_42: - net.ble_42 = True def request_software_coexistence() -> None: @@ -2046,12 +2043,12 @@ async def _reconcile_network_sdkconfig() -> None: if name not in opts: add_idf_sdkconfig_option(name, value) - # Bluetooth: only ever enable when requested. The IDF default is off and - # nothing sets these False today, so never write False here. + # Bluetooth: only ever enable when requested. The IDF default is off. + # According to the IDF docs, only one of 4.2 or 5.0 should be enabled. if net.bluetooth: set_opt("CONFIG_BT_ENABLED", True) - if net.ble_42: - set_opt("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) + set_opt("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) + set_opt("CONFIG_BT_BLE_50_FEATURES_SUPPORTED", False) # WiFi stack: disable only when Ethernet is present and WiFi is not. WiFi # relies on the IDF default (enabled), so it is never written True here. diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index c9fb42fde4..2df2c3f90d 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -604,7 +604,7 @@ async def to_code(config): max_connections = config.get(CONF_MAX_CONNECTIONS, DEFAULT_MAX_CONNECTIONS) cg.add_define("USE_ESP32_BLE_MAX_CONNECTIONS", max_connections) - request_bluetooth(ble_42=True) + request_bluetooth() # When PSRAM and BT are used together, Bluedroid should prefer SPIRAM for # heap allocations and use dynamic (heap-based) environment memory tables diff --git a/esphome/components/esp32_ble_beacon/__init__.py b/esphome/components/esp32_ble_beacon/__init__.py index 7a59cce19b..d762255040 100644 --- a/esphome/components/esp32_ble_beacon/__init__.py +++ b/esphome/components/esp32_ble_beacon/__init__.py @@ -86,4 +86,4 @@ async def to_code(config): cg.add_define("USE_ESP32_BLE_ADVERTISING") - request_bluetooth(ble_42=True) + request_bluetooth() diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 8b6fcf2e9c..3feaea0c88 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -472,26 +472,18 @@ def test_flash_mode_unset_leaves_defaults( ), pytest.param( PlatformFramework.ESP32_IDF, - NetworkSdkconfigData( - wifi=True, bluetooth=True, ble_42=True, software_coexistence=True - ), + NetworkSdkconfigData(wifi=True, bluetooth=True, software_coexistence=True), {}, { "CONFIG_BT_ENABLED": True, "CONFIG_BT_BLE_42_FEATURES_SUPPORTED": True, + "CONFIG_BT_BLE_50_FEATURES_SUPPORTED": False, "CONFIG_SW_COEXIST_ENABLE": True, "CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False, "CONFIG_LWIP_DHCPS": False, }, id="idf_wifi_ble_tracker_coexistence", ), - pytest.param( - PlatformFramework.ESP32_IDF, - NetworkSdkconfigData(bluetooth=True), - {}, - {"CONFIG_BT_ENABLED": True}, - id="idf_ble_server_only_no_ble42", - ), # --- IDF: user sdkconfig_options always win --- pytest.param( PlatformFramework.ESP32_IDF, @@ -612,6 +604,7 @@ def test_network_wifi_ble_coexistence_reconciles_end_to_end( sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] assert sdkconfig.get("CONFIG_BT_ENABLED") is True assert sdkconfig.get("CONFIG_BT_BLE_42_FEATURES_SUPPORTED") is True + assert sdkconfig.get("CONFIG_BT_BLE_50_FEATURES_SUPPORTED") is False assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is True assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False From e2ca80ec416d308327733d4f1d20895b554455ce Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:19:43 +1200 Subject: [PATCH 1236/1815] Bump version to 2026.7.4 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 81bd29ec1b..d4dd0a8d26 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.7.3 +PROJECT_NUMBER = 2026.7.4 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 7e90790405..6e21b10df8 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.7.3" +__version__ = "2026.7.4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 0a99b007a9dc4749517dcee029df1e0e37656cfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Wed, 5 Aug 2026 04:56:32 +0300 Subject: [PATCH 1237/1815] [ble_device_base] Advertise runtime scan-mode switching in HubCapabilities (#18079) --- .../bk72xx_ble_tracker/bk72xx_ble_tracker.h | 4 +++- esphome/components/ble_device_base/ble_hub.h | 11 ++++++++--- .../components/esp32_ble_tracker/esp32_ble_tracker.h | 5 ++++- .../ln882h_ble_tracker/ln882h_ble_tracker.h | 3 ++- esphome/components/rp2_ble_tracker/rp2_ble_tracker.h | 2 +- .../ble_device_base/test_scan_mode_request.cpp | 5 ++++- 6 files changed, 22 insertions(+), 8 deletions(-) diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h index 435e953655..1905e36f94 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h @@ -91,7 +91,9 @@ class BK72xxBLETracker : public Component, // controller never solicits scan responses and never merges them; consumers // relying on scan-response fields (device names) get them only where the // receiver merges per address (Home Assistant does). No GATT client either. - return {.active_scan = false, .merges_scan_response = false, .gatt = false}; + // scan_mode_switch stays false for the same reason: with no active-scan + // path there is no mode to switch to. + return {.active_scan = false, .merges_scan_response = false, .gatt = false, .scan_mode_switch = false}; } bool request_scan_mode(bool active) override { // Passive-only controller: a passive request is already honored, an active diff --git a/esphome/components/ble_device_base/ble_hub.h b/esphome/components/ble_device_base/ble_hub.h index 6f7e580975..454478e0eb 100644 --- a/esphome/components/ble_device_base/ble_hub.h +++ b/esphome/components/ble_device_base/ble_hub.h @@ -59,6 +59,11 @@ struct HubCapabilities { /// GATT client connections are available (today: esp32 only, but a chip SDK /// gaining GATT support only has to flip this bit). bool gatt; + /// request_scan_mode() is honored at runtime. Distinct from active_scan: + /// a passive-only controller (bk72xx) can never switch, and a hub may + /// support active scanning yet still refuse the runtime switch + /// (esp32_ble_tracker drives its mode through its own tracker API). + bool scan_mode_switch; }; class BLEHub { @@ -86,9 +91,9 @@ class BLEHub { /// picks it up on its next start. The default cannot-change keeps hubs /// without a mode switch (and out-of-tree trackers) building unchanged. /// Independent of HubCapabilities::active_scan: that bit describes what the - /// CONTROLLER can do, this method describes whether the hub exposes a - /// runtime switch — a hub may support active scanning and still refuse - /// (esp32_ble_tracker drives its mode through its own tracker API). + /// CONTROLLER can do; whether this method honors requests is advertised by + /// HubCapabilities::scan_mode_switch, so consumers can gate features on the + /// switch without probing. virtual bool request_scan_mode(bool active) { return false; } }; diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index b2db092eb0..fe2a5d8599 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -242,7 +242,10 @@ class ESP32BLETracker final : public Component, this->raw_advertisement_callback_ = callback; } ble_device_base::HubCapabilities get_capabilities() const override { - return {/* active_scan = */ true, /* merges_scan_response = */ true, /* gatt = */ true}; + // scan_mode_switch is false: the mode is driven through this tracker's own + // API (set_scan_active + restart), not the neutral request_scan_mode(). + return {/* active_scan = */ true, /* merges_scan_response = */ true, /* gatt = */ true, + /* scan_mode_switch = */ false}; } void get_adapter_mac(uint8_t out[6]) override; bool scan_running() override { return this->scanner_state_ == ScannerState::RUNNING; } diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h index 00f1af16a4..43328a288d 100644 --- a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h @@ -72,7 +72,8 @@ class LN882HBLETracker : public Component, // The LN882H controller supports active scanning; adv + scan response arrive // as separate reports and are merged by this tracker (Bluedroid semantics). // The SDK's GATT client is not exposed. - return {.active_scan = true, .merges_scan_response = true, .gatt = false}; + // scan_mode_switch: request_scan_mode() is implemented (restart-if-running). + return {.active_scan = true, .merges_scan_response = true, .gatt = false, .scan_mode_switch = true}; } // The controller stores the address LSB-first (BLE convention); the contract // wants printable (MSB-first) order. diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h index 4806f599bd..70ececb528 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h @@ -64,7 +64,7 @@ class RP2BLETracker : public Component, // than merging them into the advertisement — consumers relying on // scan-response fields (device names) get them only where the receiver // merges per address (Home Assistant does). No GATT path yet. - return {.active_scan = true, .merges_scan_response = false, .gatt = false}; + return {.active_scan = true, .merges_scan_response = false, .gatt = false, .scan_mode_switch = true}; } // The controller stores the address in printable (MSB-first) order, which is // exactly what the contract wants. diff --git a/tests/components/ble_device_base/test_scan_mode_request.cpp b/tests/components/ble_device_base/test_scan_mode_request.cpp index 2eeb602999..9125eb6f2f 100644 --- a/tests/components/ble_device_base/test_scan_mode_request.cpp +++ b/tests/components/ble_device_base/test_scan_mode_request.cpp @@ -29,7 +29,7 @@ class DefaultHub : public BLEHub { class SwitchingHub : public DefaultHub { public: - HubCapabilities get_capabilities() const override { return {true, false, false}; } + HubCapabilities get_capabilities() const override { return {true, false, false, /* scan_mode_switch = */ true}; } bool request_scan_mode(bool active) override { this->active_ = active; return true; @@ -57,6 +57,7 @@ TEST(BLEHubScanModeRequest, DefaultRefusesAndChangesNothing) { TEST(BLEHubScanModeRequest, OverrideHonorsAndApplies) { SwitchingHub hub; + EXPECT_TRUE(hub.get_capabilities().scan_mode_switch); EXPECT_TRUE(hub.request_scan_mode(true)); EXPECT_TRUE(hub.scan_active()); EXPECT_TRUE(hub.request_scan_mode(false)); @@ -66,6 +67,8 @@ TEST(BLEHubScanModeRequest, OverrideHonorsAndApplies) { TEST(BLEHubScanModeRequest, CapabilityAndSwitchAreIndependent) { CapableRefusingHub hub; EXPECT_TRUE(hub.get_capabilities().active_scan); + // The esp32 shape advertises no runtime switch, and the request refuses. + EXPECT_FALSE(hub.get_capabilities().scan_mode_switch); EXPECT_FALSE(hub.request_scan_mode(false)); EXPECT_TRUE(hub.scan_active()); } From 97d33a5679c429d381c3b8f5f4fbc87886edcc4b Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:14:32 -0700 Subject: [PATCH 1238/1815] [lvgl] Add lvgl.widget.set_z_index action (#17993) Co-authored-by: Claude Sonnet 5 --- esphome/components/lvgl/automation.py | 53 +++++++- .../lvgl/config/set_z_index_test.yaml | 60 ++++++++ .../component_tests/lvgl/test_set_z_index.py | 128 ++++++++++++++++++ tests/components/lvgl/lvgl-package.yaml | 20 ++- 4 files changed, 259 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/lvgl/config/set_z_index_test.yaml create mode 100644 tests/component_tests/lvgl/test_set_z_index.py diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index b7c90a5c51..cad065adee 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -5,7 +5,14 @@ from esphome import automation from esphome.automation import StatelessLambdaAction import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_ACTION, CONF_GROUP, CONF_ID, CONF_ROTATION, CONF_TIMEOUT +from esphome.const import ( + CONF_ACTION, + CONF_GROUP, + CONF_ID, + CONF_POSITION, + CONF_ROTATION, + CONF_TIMEOUT, +) from esphome.core import Lambda from esphome.cpp_generator import TemplateArguments, get_variable from esphome.cpp_types import nullptr @@ -28,6 +35,7 @@ from .defines import ( get_focused_widgets, get_options, get_refreshed_widgets, + literal, ) from .layout import layout_validator from .lv_validation import lv_bool, lv_milliseconds, lv_rotation @@ -36,6 +44,7 @@ from .lvcode import ( UPDATE_EVENT, LambdaContext, LocalVariable, + LvConditional, LvglComponent, ReturnStatement, add_line_marks, @@ -376,6 +385,48 @@ async def obj_show_to_code(config, action_id, template_arg, args): return await action_to_code(widgets, do_show, action_id, template_arg, args) +SET_Z_INDEX_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.ensure_list( + cv.maybe_simple_value( + {cv.Required(CONF_ID): cv.use_id(lv_obj_t)}, + key=CONF_ID, + ) + ), + cv.Required(CONF_POSITION): cv.Any( + cv.one_of("TOP", "BOTTOM", "UP", "DOWN", upper=True), cv.int_ + ), + } +) + + +@automation.register_action( + "lvgl.widget.set_z_index", ObjUpdateAction, SET_Z_INDEX_SCHEMA, synchronous=True +) +async def obj_set_z_index_to_code(config, action_id, template_arg, args): + position = config[CONF_POSITION] + + async def do_set_z_index(widget: Widget): + if position == "TOP": + lv_obj.move_foreground(widget.obj) + elif position == "BOTTOM": + lv_obj.move_background(widget.obj) + elif position == "UP": + lv_obj.move_to_index( + widget.obj, literal(f"{lv_expr.obj_get_index(widget.obj)} + 1") + ) + elif position == "DOWN": + with LvConditional(f"{lv_expr.obj_get_index(widget.obj)} > 0"): + lv_obj.move_to_index( + widget.obj, literal(f"{lv_expr.obj_get_index(widget.obj)} - 1") + ) + else: + lv_obj.move_to_index(widget.obj, position) + + widgets = [widget.outer or widget for widget in await get_widgets(config[CONF_ID])] + return await action_to_code(widgets, do_set_z_index, action_id, template_arg, args) + + def focused_id(value): value = cv.use_id(lv_pseudo_button_t)(value) get_focused_widgets().add(value) diff --git a/tests/component_tests/lvgl/config/set_z_index_test.yaml b/tests/component_tests/lvgl/config/set_z_index_test.yaml new file mode 100644 index 0000000000..61a248ff99 --- /dev/null +++ b/tests/component_tests/lvgl/config/set_z_index_test.yaml @@ -0,0 +1,60 @@ +esphome: + name: test-set-z-index + +esp32: + board: esp32dev + framework: + type: esp-idf + +spi: + - id: spi_bus + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + invert_colors: false + update_interval: never + +lvgl: + displays: tft_display + widgets: + - label: + id: label_a + text: "A" + - label: + id: label_b + text: "B" + - button: + id: trigger_btn + on_click: + - lvgl.widget.set_z_index: + id: label_a + position: top + - lvgl.widget.set_z_index: + id: label_a + position: bottom + - lvgl.widget.set_z_index: + id: label_a + position: up + - lvgl.widget.set_z_index: + id: label_a + position: down + - lvgl.widget.set_z_index: + id: label_a + position: 3 + - lvgl.widget.set_z_index: + id: label_a + position: -2 + - lvgl.widget.set_z_index: + id: [label_a, label_b] + position: up diff --git a/tests/component_tests/lvgl/test_set_z_index.py b/tests/component_tests/lvgl/test_set_z_index.py new file mode 100644 index 0000000000..cf2a072ca0 --- /dev/null +++ b/tests/component_tests/lvgl/test_set_z_index.py @@ -0,0 +1,128 @@ +"""Tests for the ``lvgl.widget.set_z_index`` action: schema validation and +code generation. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from esphome.components.lvgl.automation import SET_Z_INDEX_SCHEMA +from esphome.config_validation import Invalid + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + + +class TestSetZIndexSchemaValidation: + """Test that SET_Z_INDEX_SCHEMA accepts the documented forms and rejects + everything else. + """ + + @pytest.mark.parametrize("position", ["top", "bottom", "up", "down"]) + def test_keyword_position_accepted(self, position: str) -> None: + config = SET_Z_INDEX_SCHEMA({"id": "my_widget", "position": position}) + assert config["position"] == position.upper() + + @pytest.mark.parametrize("position", ["Top", "BOTTOM", "Up", "dOwN"]) + def test_keyword_position_case_insensitive(self, position: str) -> None: + config = SET_Z_INDEX_SCHEMA({"id": "my_widget", "position": position}) + assert config["position"] == position.upper() + + @pytest.mark.parametrize("position", [0, 1, 5, -1, -5]) + def test_integer_position_accepted(self, position: int) -> None: + config = SET_Z_INDEX_SCHEMA({"id": "my_widget", "position": position}) + assert config["position"] == position + + def test_unknown_keyword_rejected(self) -> None: + with pytest.raises(Invalid): + SET_Z_INDEX_SCHEMA({"id": "my_widget", "position": "sideways"}) + + def test_float_position_rejected(self) -> None: + with pytest.raises(Invalid): + SET_Z_INDEX_SCHEMA({"id": "my_widget", "position": 1.5}) + + def test_missing_id_rejected(self) -> None: + with pytest.raises(Invalid): + SET_Z_INDEX_SCHEMA({"position": "top"}) + + def test_missing_position_rejected(self) -> None: + with pytest.raises(Invalid): + SET_Z_INDEX_SCHEMA({"id": "my_widget"}) + + def test_single_id_is_wrapped_in_list(self) -> None: + config = SET_Z_INDEX_SCHEMA({"id": "my_widget", "position": "top"}) + assert len(config["id"]) == 1 + assert config["id"][0]["id"].id == "my_widget" + + def test_list_of_ids_accepted(self) -> None: + config = SET_Z_INDEX_SCHEMA({"id": ["widget_a", "widget_b"], "position": "top"}) + assert [entry["id"].id for entry in config["id"]] == ["widget_a", "widget_b"] + + +# --------------------------------------------------------------------------- +# Code generation +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def main_cpp(request: pytest.FixtureRequest) -> str: + """Generate the C++ output for the shared set_z_index YAML config once + per module. See ``test_widget_state.py`` for why this is module-scoped + and self-contained rather than using the function-scoped ``generate_main`` + fixture from ``conftest.py``. + """ + from esphome.__main__ import generate_cpp_contents + from esphome.config import read_config + from esphome.core import CORE + + config_path = Path(request.fspath).parent / "config" / "set_z_index_test.yaml" + original_path = CORE.config_path + try: + CORE.config_path = config_path + CORE.config = read_config({}) + generate_cpp_contents(CORE.config) + return CORE.cpp_global_section + CORE.cpp_main_section + finally: + CORE.config_path = original_path + CORE.reset() + + +def test_top_emits_move_foreground(main_cpp: str) -> None: + assert "lv_obj_move_foreground(label_a);" in main_cpp + + +def test_bottom_emits_move_background(main_cpp: str) -> None: + assert "lv_obj_move_background(label_a);" in main_cpp + + +def test_up_emits_unguarded_index_increment(main_cpp: str) -> None: + assert "lv_obj_move_to_index(label_a, lv_obj_get_index(label_a) + 1);" in main_cpp + + +def test_down_emits_guarded_index_decrement(main_cpp: str) -> None: + """``down`` must be guarded so that a widget already at index 0 isn't + reinterpreted by LVGL as "move to the top" (LVGL treats a negative + index as "count from the back"). + """ + assert "if (lv_obj_get_index(label_a) > 0) {" in main_cpp + assert "lv_obj_move_to_index(label_a, lv_obj_get_index(label_a) - 1);" in main_cpp + + +def test_positive_integer_emits_direct_index(main_cpp: str) -> None: + assert "lv_obj_move_to_index(label_a, 3);" in main_cpp + + +def test_negative_integer_emits_direct_index(main_cpp: str) -> None: + assert "lv_obj_move_to_index(label_a, -2);" in main_cpp + + +def test_list_of_ids_applies_to_each_widget(main_cpp: str) -> None: + """``id: [label_a, label_b]`` must emit the move call once per widget.""" + assert ( + main_cpp.count("lv_obj_move_to_index(label_a, lv_obj_get_index(label_a) + 1);") + == 2 + ) + assert "lv_obj_move_to_index(label_b, lv_obj_get_index(label_b) + 1);" in main_cpp diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index ef8ba13e42..903480dde1 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -756,7 +756,25 @@ lvgl: on_defocus: lvgl.widget.hide: hello_label on_focus: - logger.log: Button clicked + - logger.log: Button clicked + - lvgl.widget.set_z_index: + id: hello_label + position: top + - lvgl.widget.set_z_index: + id: hello_label + position: bottom + - lvgl.widget.set_z_index: + id: hello_label + position: up + - lvgl.widget.set_z_index: + id: hello_label + position: down + - lvgl.widget.set_z_index: + id: hello_label + position: 1 + - lvgl.widget.set_z_index: + id: hello_label + position: -1 on_scroll: logger.log: Button clicked on_scroll_end: From 27dcf64b45e9161b983366474d9de5f17af3166a Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:16:22 -0700 Subject: [PATCH 1239/1815] [lvgl] Add pause option to `round_trip` animation timing (#17574) --- esphome/components/lvgl/animation.h | 18 ++++++++++++++---- esphome/components/lvgl/animation.py | 15 +++++++++++++-- tests/component_tests/lvgl/test_animation.py | 19 ++++++++++++++++--- tests/components/lvgl/lvgl-package.yaml | 6 +++++- tests/components/lvgl/test.host.yaml | 2 +- 5 files changed, 49 insertions(+), 11 deletions(-) diff --git a/esphome/components/lvgl/animation.h b/esphome/components/lvgl/animation.h index 1e0abce358..26bb433f87 100644 --- a/esphome/components/lvgl/animation.h +++ b/esphome/components/lvgl/animation.h @@ -21,12 +21,22 @@ class LvAnimationTiming { class LvAnimationTimingRoundTrip : public LvAnimationTiming { public: + // moving_length_ is the fraction of progress spent moving in each direction, in (0, 0.5]. + // Callers must pass pause in [0, 1) -- pause == 1.0 would make moving_length_ zero and divide by zero below. + LvAnimationTimingRoundTrip(float pause) : moving_length_((1.0f - pause) / 2.0f) {} float map_progress(float value) override { - value *= 2.0f; - if (value > 1.0f) - return 2.0f - value; - return value; + if (value < this->moving_length_) { + return value / this->moving_length_; + } + if (value > 1.0f - this->moving_length_) { + return (1.0f - value) / this->moving_length_; + } + // pause in the middle + return 1.0f; } + + protected: + float moving_length_{}; }; class LvAnimationTimingGravity : public LvAnimationTiming { diff --git a/esphome/components/lvgl/animation.py b/esphome/components/lvgl/animation.py index 2b1500f2c4..95d45de5ea 100644 --- a/esphome/components/lvgl/animation.py +++ b/esphome/components/lvgl/animation.py @@ -42,6 +42,7 @@ LvAnimationTimingRoundTrip = lvgl_ns.class_("LvAnimationTimingRoundTrip") LvAnimationTimingEaseInOut = lvgl_ns.class_("LvAnimationTimingEaseInOut") CONF_BOUNCE = "bounce" +CONF_PAUSE = "pause" def timing_class(name, extras=None): @@ -60,10 +61,20 @@ TIMING_SCHEMA = cv.maybe_simple_value( cv.typed_schema( dict( [ - timing_class("round_trip"), + timing_class( + "round_trip", + { + cv.Optional(CONF_PAUSE, default=0.0): cv.All( + cv.percentage, + cv.float_range( + min=0.0, max=1.0, min_included=True, max_included=False + ), + ) + }, + ), timing_class( "ease_in_out", - {cv.Optional(CONF_WEIGHT, default=2.0): lv_positive_float}, + {cv.Optional(CONF_WEIGHT, default=1.0): cv.zero_to_one_float}, ), timing_class( "gravity", diff --git a/tests/component_tests/lvgl/test_animation.py b/tests/component_tests/lvgl/test_animation.py index 1a2cde632c..ce9a162d99 100644 --- a/tests/component_tests/lvgl/test_animation.py +++ b/tests/component_tests/lvgl/test_animation.py @@ -169,14 +169,27 @@ class TestTimingSchema: def test_round_trip_string(self) -> None: assert TIMING_SCHEMA("round_trip")["type"] == "round_trip" + def test_round_trip_default_pause(self) -> None: + # Back-compat default: no pause, matching the pre-existing round_trip behavior. + assert TIMING_SCHEMA("round_trip")["pause"] == pytest.approx(0.0) + + def test_round_trip_pause_percentage_string(self) -> None: + result = TIMING_SCHEMA({"type": "round_trip", "pause": "50%"}) + assert result["pause"] == pytest.approx(0.5) + + def test_round_trip_pause_rejects_one(self) -> None: + # pause == 1.0 would make moving_length_ zero and divide by zero in map_progress. + with pytest.raises((Invalid, MultipleInvalid)): + TIMING_SCHEMA({"type": "round_trip", "pause": 1.0}) + def test_ease_in_out_default_weight(self) -> None: result = TIMING_SCHEMA("ease_in_out") assert result["type"] == "ease_in_out" - assert result["weight"] == pytest.approx(2.0) + assert result["weight"] == pytest.approx(1.0) def test_ease_in_out_custom_weight(self) -> None: - result = TIMING_SCHEMA({"type": "ease_in_out", "weight": 3}) - assert result["weight"] == pytest.approx(3.0) + result = TIMING_SCHEMA({"type": "ease_in_out", "weight": 0.5}) + assert result["weight"] == pytest.approx(0.5) def test_gravity_defaults(self) -> None: result = TIMING_SCHEMA("gravity") diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 903480dde1..46c1fd362a 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -174,7 +174,8 @@ lvgl: - id: anim_color duration: 2s timing: - - round_trip + - type: round_trip + pause: 0.5 - type: gravity bounce: 0.3 acceleration: 0.8 @@ -1557,17 +1558,20 @@ font: image: - id: cat_image + platform: file resize: 256x48 file: $component_dir/logo-text.svg type: RGB565 transparency: alpha_channel - id: dog_image + platform: file file: $component_dir/logo-text.svg resize: 256x48 type: BINARY transparency: chroma_key - id: alert + platform: file file: $component_dir/logo-text.svg type: grayscale resize: 100x100 diff --git a/tests/components/lvgl/test.host.yaml b/tests/components/lvgl/test.host.yaml index 90cbb3c0a5..3fa54fa3d6 100644 --- a/tests/components/lvgl/test.host.yaml +++ b/tests/components/lvgl/test.host.yaml @@ -39,7 +39,7 @@ lvgl: timing: - round_trip - type: ease_in_out - weight: 3 + weight: 0.5 on_start: - logger.log: anim started on_stop: From 112d8b26ee817edf243967412cf869e61763da6d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Aug 2026 21:24:38 -0500 Subject: [PATCH 1240/1815] [core] Skip source range tracking when loading the validated config cache (#18046) --- esphome/compiled_config.py | 9 +- esphome/yaml_util.py | 96 ++++++++++++++---- tests/unit_tests/test_compiled_config.py | 5 + tests/unit_tests/test_yaml_util.py | 119 +++++++++++++++++++++++ 4 files changed, 210 insertions(+), 19 deletions(-) diff --git a/esphome/compiled_config.py b/esphome/compiled_config.py index f4fd205285..1bcd567b84 100644 --- a/esphome/compiled_config.py +++ b/esphome/compiled_config.py @@ -61,7 +61,14 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None: from esphome import yaml_util try: - config = yaml_util.load_yaml(cache_path, clear_secrets=False) + # Fast path never validates or generates code - no source ranges + # needed (see load_yaml). Callers must not feed this config into + # read_config/write_cpp: the esp_range consumers in config.py and + # cpp_generator.py are isinstance-guarded and would degrade + # silently (wrong error/lambda locations) instead of raising. + config = yaml_util.load_yaml( + cache_path, clear_secrets=False, track_document_range=False + ) except Exception: # noqa: BLE001 # pylint: disable=broad-except return None diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 03d1e81073..ca993b2ca5 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -546,6 +546,13 @@ def _add_data_ref(fn): # Let generator finish for _ in generator: pass + # Fast mode keeps this per-node attribute check instead of a second + # constructor table: measured, fast mode already parses within ~8% + # of a raw CSafeLoader, so a parallel table isn't worth the + # duplication (and undecorated constructors return generators with + # different resolution ordering). + if not loader.track_document_range: + return res res = make_data_base(res) if isinstance(res, ESPHomeDataBase): res.from_node(node) @@ -585,14 +592,19 @@ def _resolve_merge_include(value: Any, node: yaml.Node, value_node: yaml.Node) - class ESPHomeLoaderMixin: - """Loader class that keeps track of line numbers.""" + """Loader that tracks line numbers unless track_document_range is off.""" def __init__( - self, name: Path, yaml_loader: Callable[[Path], dict[str, Any]] + self, + name: Path, + yaml_loader: Callable[[Path], dict[str, Any]], + *, + track_document_range: bool, ) -> None: - """Initialize the loader.""" + """Initialize the loader. See load_yaml for track_document_range.""" self.name = name self.yaml_loader = yaml_loader + self.track_document_range = track_document_range @_add_data_ref def construct_yaml_int(self, node): @@ -655,8 +667,10 @@ class ESPHomeLoaderMixin: f'Invalid key "{key}" (not hashable)', key_node.start_mark ) from None - key = make_data_base(str(key)) - key.from_node(key_node) + key = str(key) + if self.track_document_range: + key = make_data_base(key) + key.from_node(key_node) # Check if it is a duplicate key if key in seen_keys: @@ -852,29 +866,37 @@ class ESPHomeLoaderMixin: class ESPHomeLoader(ESPHomeLoaderMixin, FastestAvailableSafeLoader): - """Loader class that keeps track of line numbers.""" + """C-accelerated loader; see ESPHomeLoaderMixin.""" def __init__( self, stream: TextIOBase | BytesIO, name: Path, yaml_loader: Callable[[Path], dict[str, Any]], + *, + track_document_range: bool, ) -> None: FastestAvailableSafeLoader.__init__(self, stream) - ESPHomeLoaderMixin.__init__(self, name, yaml_loader) + ESPHomeLoaderMixin.__init__( + self, name, yaml_loader, track_document_range=track_document_range + ) class ESPHomePurePythonLoader(ESPHomeLoaderMixin, PurePythonLoader): - """Loader class that keeps track of line numbers.""" + """Pure-Python loader with readable errors; see ESPHomeLoaderMixin.""" def __init__( self, stream: TextIOBase | BytesIO, name: Path, yaml_loader: Callable[[Path], dict[str, Any]], + *, + track_document_range: bool, ) -> None: PurePythonLoader.__init__(self, stream) - ESPHomeLoaderMixin.__init__(self, name, yaml_loader) + ESPHomeLoaderMixin.__init__( + self, name, yaml_loader, track_document_range=track_document_range + ) for _loader in (ESPHomeLoader, ESPHomePurePythonLoader): @@ -902,20 +924,31 @@ for _loader in (ESPHomeLoader, ESPHomePurePythonLoader): _loader.add_constructor("!remove", _loader.construct_remove) -def load_yaml(fname: Path, clear_secrets: bool = True) -> Any: +def load_yaml( + fname: Path, clear_secrets: bool = True, *, track_document_range: bool = True +) -> Any: + """Load a YAML file. + + track_document_range=False skips wrapping every node in an + ESPHomeDataBase subclass carrying its source range. That metadata + serves validation error messages and lambda source locations in + generated code; callers that neither validate nor generate code (the + upload/logs fast path re-reading the validated config cache) can skip + it, roughly halving parse time. + """ if clear_secrets: _SECRET_VALUES.clear() _SECRET_CACHE.clear() - return _load_yaml_internal(fname) + return _load_yaml_internal(fname, track_document_range=track_document_range) -def _load_yaml_internal(fname: Path) -> Any: +def _load_yaml_internal(fname: Path, *, track_document_range: bool = True) -> Any: """Load a YAML file.""" for listener in _load_listeners: listener(fname) try: with fname.open(encoding="utf-8") as f_handle: - res = parse_yaml(fname, f_handle) + res = parse_yaml(fname, f_handle, track_document_range=track_document_range) except (UnicodeDecodeError, OSError) as err: raise EsphomeError(f"Error reading file {fname}: {err}") from err # Top-level !include returns a deferred IncludeFile; resolve it so @@ -925,13 +958,32 @@ def _load_yaml_internal(fname: Path) -> Any: return res -def parse_yaml(file_name: Path, file_handle: TextIOWrapper, yaml_loader=None) -> Any: +_FAST_YAML_LOADER = functools.partial(_load_yaml_internal, track_document_range=False) + + +def parse_yaml( + file_name: Path, + file_handle: TextIOWrapper, + yaml_loader=None, + *, + track_document_range: bool = True, +) -> Any: """Parse a YAML file.""" if yaml_loader is None: - yaml_loader = _load_yaml_internal + # Nested loads (!include, !secret, !include_dir_*) inherit the + # same tracking mode. + yaml_loader = _load_yaml_internal if track_document_range else _FAST_YAML_LOADER + elif not track_document_range: + # A caller-supplied loader would silently revert nested loads to + # tracked mode; reject the combination instead of half-applying it. + raise ValueError("track_document_range=False requires the default yaml_loader") try: return _load_yaml_internal_with_type( - ESPHomeLoader, file_name, file_handle, yaml_loader + ESPHomeLoader, + file_name, + file_handle, + yaml_loader, + track_document_range=track_document_range, ) except EsphomeError: # Loading failed, so we now load with the Python loader which has more @@ -939,7 +991,11 @@ def parse_yaml(file_name: Path, file_handle: TextIOWrapper, yaml_loader=None) -> # Rewind the stream so we can try again file_handle.seek(0, 0) return _load_yaml_internal_with_type( - ESPHomePurePythonLoader, file_name, file_handle, yaml_loader + ESPHomePurePythonLoader, + file_name, + file_handle, + yaml_loader, + track_document_range=track_document_range, ) @@ -948,6 +1004,8 @@ def _load_yaml_internal_with_type( fname: Path, content: TextIOWrapper, yaml_loader: Callable[[Path], dict[str, Any]], + *, + track_document_range: bool, ) -> Any: """Load a YAML file. @@ -958,7 +1016,9 @@ def _load_yaml_internal_with_type( configuration. Frontmatter is ignored by config validation and code generation. """ - loader = loader_type(content, fname, yaml_loader) + loader = loader_type( + content, fname, yaml_loader, track_document_range=track_document_range + ) try: documents: list[Any] = [] while loader.check_data(): diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index 4219424aa1..b852d2d596 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -26,6 +26,7 @@ from esphome.const import ( KEY_VARIANT, ) from esphome.core import CORE +from esphome.yaml_util import ESPHomeDataBase _VALIDATED_CONFIG_YAML = """\ esphome: @@ -125,6 +126,10 @@ def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None: assert config[CONF_API]["encryption"]["key"] == "6dGhpcyBpcyBhIHRlc3Q=" assert config["ota"][0]["password"] == "secret" + # The fast path loads without per-node source ranges (the full + # contract lives in test_yaml_util; this checks the flag is wired up). + assert not isinstance(config[CONF_ESPHOME][CONF_NAME], ESPHomeDataBase) + # apply_to_core populated exactly what upload/logs read off CORE. assert CORE.name == "lite_test" assert CORE.build_path == Path("/build/lite_test") diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 1bb70864a3..e0a81652e3 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -1780,3 +1780,122 @@ def test_merge_include_no_overlap_records_nothing(tmp_path: Path) -> None: assert result["api"] == {"reboot_timeout": "5min"} assert result["logger"] == {"level": "DEBUG"} assert yaml_util.take_dropped_merge_keys() == [] + + +# --------------------------------------------------------------------------- +# track_document_range=False (validated-config-cache fast path) +# --------------------------------------------------------------------------- + +FAST_MODE_MAIN_YAML = """\ +defaults: &defaults + port: 6053 + reboot_timeout: 15min + +esphome: + name: !secret devname + +api: + <<: *defaults + port: 6054 + +number_value: 42 +float_value: 3.5 +lambda_value: !lambda 'return x * 2;' +extend_value: !extend some_id +remove_value: !remove some_id +literal_value: !literal keep_me_verbatim +included: !include included.yaml +""" + + +@pytest.fixture +def fast_mode_config_dir(tmp_path: Path) -> Path: + _write(tmp_path, "main.yaml", FAST_MODE_MAIN_YAML) + _write(tmp_path, "included.yaml", "inner_key: inner_value\ninner_num: 7\n") + _write(tmp_path, "secrets.yaml", "devname: livingroom\n") + return tmp_path + + +def _resolve_includes(config: dict) -> dict: + return { + key: value.load() if isinstance(value, yaml_util.IncludeFile) else value + for key, value in config.items() + } + + +def test_load_yaml_fast_mode_matches_default(fast_mode_config_dir: Path) -> None: + """Both modes produce equal values; only the metadata wrapping differs.""" + yaml_file = fast_mode_config_dir / "main.yaml" + + normal = _resolve_includes(yaml_util.load_yaml(yaml_file)) + fast = _resolve_includes(yaml_util.load_yaml(yaml_file, track_document_range=False)) + + # Lambda has no __eq__; compare it by value and the rest structurally. + fast_lambda = fast.pop("lambda_value") + normal_lambda = normal.pop("lambda_value") + assert fast == normal + assert isinstance(fast_lambda, core.Lambda) + assert fast_lambda.value == normal_lambda.value == "return x * 2;" + assert fast["esphome"]["name"] == "livingroom" + assert fast["api"]["port"] == 6054 + assert fast["api"]["reboot_timeout"] == "15min" + assert fast["extend_value"] == Extend("some_id") + assert fast["remove_value"] == Remove("some_id") + # !literal wraps via make_literal, independent of range tracking. + assert isinstance(fast["literal_value"], ESPLiteralValue) + assert fast["literal_value"] == "keep_me_verbatim" + + # Fast mode returns plain values; default mode keeps the range metadata. + assert not isinstance(fast["number_value"], ESPHomeDataBase) + assert not isinstance(fast["float_value"], ESPHomeDataBase) + assert all(type(key) is str for key in fast) + assert isinstance(normal["number_value"], ESPHomeDataBase) + assert normal["number_value"].esp_range is not None + assert all(isinstance(key, ESPHomeDataBase) for key in normal) + + # Nested includes inherit fast mode through the recursive loader. + included = fast["included"] + assert not isinstance(included["inner_num"], ESPHomeDataBase) + assert all(type(key) is str for key in included) + + +def test_load_yaml_fast_mode_survives_pure_python_fallback( + fast_mode_config_dir: Path, +) -> None: + """The ESPHomePurePythonLoader retry must honour fast mode too.""" + yaml_file = fast_mode_config_dir / "main.yaml" + + class _AlwaysFailingLoader(yaml_util.ESPHomeLoader): + def __init__(self, *args, **kwargs) -> None: + raise EsphomeError("forced fallback to the pure-Python loader") + + with patch.object(yaml_util, "ESPHomeLoader", _AlwaysFailingLoader): + fast = yaml_util.load_yaml(yaml_file, track_document_range=False) + + assert not isinstance(fast["number_value"], ESPHomeDataBase) + assert all(type(key) is str for key in fast) + + +def test_load_yaml_fast_mode_rejects_custom_loader() -> None: + """A caller-supplied yaml_loader cannot combine with fast mode.""" + with pytest.raises(ValueError, match="default yaml_loader"): + yaml_util.parse_yaml( + Path("x.yaml"), + io.StringIO("a: 1"), + lambda f: {}, + track_document_range=False, + ) + + +def test_load_yaml_fast_mode_records_dropped_merge_keys( + fast_mode_config_dir: Path, +) -> None: + """The duplicate-merge-key bookkeeping must not crash on plain str keys. + + With plain keys there is no esp_range, so the recorded location falls + back to the parent file name. + """ + yaml_file = fast_mode_config_dir / "main.yaml" + + yaml_util.load_yaml(yaml_file, track_document_range=False) + assert yaml_util.take_dropped_merge_keys() == [("port", str(yaml_file))] From 450232964b009acca16b2f22d7299a531faa0674 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Wed, 5 Aug 2026 05:58:31 +0300 Subject: [PATCH 1241/1815] [tests] Unignore component-test config fixture directories (#18084) --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index de3e4fa68e..fdb75824fb 100644 --- a/.gitignore +++ b/.gitignore @@ -133,6 +133,8 @@ CTestTestfile.cmake .gcc-flags.json config/ +# Test fixture config/ directories are tracked (the rule above is the dashboard dir) +!tests/component_tests/**/config/ tests/build/ tests/.esphome/ /.temp-clang-tidy.cpp From 3d093c0ae814a38187976e398ee6270770fe5f9b Mon Sep 17 00:00:00 2001 From: Pete Keen Date: Wed, 5 Aug 2026 07:57:59 -0400 Subject: [PATCH 1242/1815] [esp32_rmt_led_strip] Add RGBW channel ordering (#18028) --- .../esp32_rmt_led_strip/led_strip.cpp | 23 ++++++-- .../esp32_rmt_led_strip/led_strip.h | 7 +++ .../components/esp32_rmt_led_strip/light.py | 38 +++++++++++-- .../esp32_rmt_led_strip/common.yaml | 2 +- .../components/test_esp32_rmt_led_strip.py | 57 +++++++++++++++++++ 5 files changed, 116 insertions(+), 11 deletions(-) create mode 100644 tests/unit_tests/components/test_esp32_rmt_led_strip.py diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.cpp b/esphome/components/esp32_rmt_led_strip/led_strip.cpp index ed2a8c5a68..95391ef100 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.cpp +++ b/esphome/components/esp32_rmt_led_strip/led_strip.cpp @@ -255,11 +255,11 @@ light::ESPColorView ESP32RMTLEDStripLightOutput::get_view_internal(int32_t index break; } uint8_t multiplier = this->is_rgbw_ || this->is_wrgb_ ? 4 : 3; - uint8_t white = this->is_wrgb_ ? 0 : 3; + uint8_t white = this->is_wrgb_ ? 0 : this->white_index_; - return {this->buf_ + (index * multiplier) + r + this->is_wrgb_, - this->buf_ + (index * multiplier) + g + this->is_wrgb_, - this->buf_ + (index * multiplier) + b + this->is_wrgb_, + return {this->buf_ + (index * multiplier) + r + (white <= r), + this->buf_ + (index * multiplier) + g + (white <= g), + this->buf_ + (index * multiplier) + b + (white <= b), this->is_rgbw_ || this->is_wrgb_ ? this->buf_ + (index * multiplier) + white : nullptr, &this->effect_data_[index], &this->correction_}; @@ -295,11 +295,22 @@ void ESP32RMTLEDStripLightOutput::dump_config() { rgb_order = "UNKNOWN"; break; } + if (this->is_rgbw_ || this->is_wrgb_) { + char rgbw_order[5]; + uint8_t white = this->is_wrgb_ ? 0 : this->white_index_; + uint8_t rgb_index = 0; + for (uint8_t i = 0; i < 4; i++) { + rgbw_order[i] = i == white ? 'W' : rgb_order[rgb_index++]; + } + rgbw_order[4] = '\0'; + ESP_LOGCONFIG(TAG, " RGBW Order: %s", rgbw_order); + } else { + ESP_LOGCONFIG(TAG, " RGB Order: %s", rgb_order); + } ESP_LOGCONFIG(TAG, - " RGB Order: %s\n" " Max refresh rate: %" PRIu32 "\n" " Number of LEDs: %u", - rgb_order, this->max_refresh_rate_.value_or(0), this->num_leds_); + this->max_refresh_rate_.value_or(0), this->num_leds_); } float ESP32RMTLEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.h b/esphome/components/esp32_rmt_led_strip/led_strip.h index d7ba2aafbf..3e31309bff 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.h +++ b/esphome/components/esp32_rmt_led_strip/led_strip.h @@ -52,6 +52,11 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; } void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; } void set_is_wrgb(bool is_wrgb) { this->is_wrgb_ = is_wrgb; } + void set_rgbw_order(uint8_t white_index) { + this->is_rgbw_ = true; + this->is_wrgb_ = false; + this->white_index_ = white_index; + } void set_use_dma(bool use_dma) { this->use_dma_ = use_dma; } void set_use_psram(bool use_psram) { this->use_psram_ = use_psram; } @@ -91,6 +96,8 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { uint16_t num_leds_; bool is_rgbw_{false}; bool is_wrgb_{false}; + // An index after the RGB channels makes offset adjustment a no-op for three-channel strips. + uint8_t white_index_{3}; bool use_dma_{false}; bool use_psram_{false}; bool invert_out_{false}; diff --git a/esphome/components/esp32_rmt_led_strip/light.py b/esphome/components/esp32_rmt_led_strip/light.py index 1c6943b003..2722a9b656 100644 --- a/esphome/components/esp32_rmt_led_strip/light.py +++ b/esphome/components/esp32_rmt_led_strip/light.py @@ -20,6 +20,7 @@ from esphome.const import ( CONF_RMT_SYMBOLS, CONF_USE_DMA, ) +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -62,6 +63,7 @@ CHIPSETS = { } CONF_IS_WRGB = "is_wrgb" +CONF_RGBW_ORDER = "rgbw_order" CONF_BIT0_HIGH = "bit0_high" CONF_BIT0_LOW = "bit0_low" CONF_BIT1_HIGH = "bit1_high" @@ -70,6 +72,26 @@ CONF_RESET_HIGH = "reset_high" CONF_RESET_LOW = "reset_low" +def _validate_rgbw_order(value: str) -> str: + value = cv.string(value).upper() + if len(value) != 4 or set(value) != set("RGBW"): + raise cv.Invalid("RGBW order must be a permutation of RGBW") + return value + + +def _split_rgbw_order(rgbw_order: str) -> tuple[str, int]: + return rgbw_order.replace("W", ""), rgbw_order.index("W") + + +def _validate_rgbw_order_exclusivity(config: ConfigType) -> ConfigType: + if CONF_RGBW_ORDER in config and (config[CONF_IS_RGBW] or config[CONF_IS_WRGB]): + raise cv.Invalid( + f"'{CONF_RGBW_ORDER}' cannot be used with '{CONF_IS_RGBW}' or " + f"'{CONF_IS_WRGB}'" + ) + return config + + CONFIG_SCHEMA = cv.All( esp32.only_on_variant( unsupported=list(esp32_rmt.VARIANTS_NO_RMT), @@ -80,7 +102,8 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(ESP32RMTLEDStripLightOutput), cv.Required(CONF_PIN): pins.internal_gpio_output_pin_schema, cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int, - cv.Required(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True), + cv.Optional(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True), + cv.Optional(CONF_RGBW_ORDER): _validate_rgbw_order, cv.SplitDefault( CONF_RMT_SYMBOLS, esp32=192, @@ -130,6 +153,8 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), cv.has_exactly_one_key(CONF_CHIPSET, CONF_BIT0_HIGH), + cv.has_exactly_one_key(CONF_RGB_ORDER, CONF_RGBW_ORDER), + _validate_rgbw_order_exclusivity, ) @@ -173,9 +198,14 @@ async def to_code(config): ) ) - cg.add(var.set_rgb_order(config[CONF_RGB_ORDER])) - cg.add(var.set_is_rgbw(config[CONF_IS_RGBW])) - cg.add(var.set_is_wrgb(config[CONF_IS_WRGB])) + if (rgbw_order := config.get(CONF_RGBW_ORDER)) is not None: + rgb_order, white_index = _split_rgbw_order(rgbw_order) + cg.add(var.set_rgb_order(RGB_ORDERS[rgb_order])) + cg.add(var.set_rgbw_order(white_index)) + else: + cg.add(var.set_rgb_order(config[CONF_RGB_ORDER])) + cg.add(var.set_is_rgbw(config[CONF_IS_RGBW])) + cg.add(var.set_is_wrgb(config[CONF_IS_WRGB])) cg.add(var.set_use_psram(config[CONF_USE_PSRAM])) cg.add(var.set_rmt_symbols(config[CONF_RMT_SYMBOLS])) if CONF_USE_DMA in config: diff --git a/tests/components/esp32_rmt_led_strip/common.yaml b/tests/components/esp32_rmt_led_strip/common.yaml index f3ee86bcce..701e513ebd 100644 --- a/tests/components/esp32_rmt_led_strip/common.yaml +++ b/tests/components/esp32_rmt_led_strip/common.yaml @@ -9,7 +9,7 @@ light: id: led_strip2 pin: ${pin2} num_leds: 60 - rgb_order: RGB + rgbw_order: RWGB bit0_high: 100us bit0_low: 100us bit1_high: 100us diff --git a/tests/unit_tests/components/test_esp32_rmt_led_strip.py b/tests/unit_tests/components/test_esp32_rmt_led_strip.py new file mode 100644 index 0000000000..e2cb513e3b --- /dev/null +++ b/tests/unit_tests/components/test_esp32_rmt_led_strip.py @@ -0,0 +1,57 @@ +import pytest + +from esphome.components.esp32_rmt_led_strip.light import ( + CONF_IS_WRGB, + CONF_RGBW_ORDER, + _split_rgbw_order, + _validate_rgbw_order, + _validate_rgbw_order_exclusivity, +) +import esphome.config_validation as cv +from esphome.const import CONF_IS_RGBW + + +def test_validate_rgbw_order() -> None: + assert _validate_rgbw_order("rwgb") == "RWGB" + + +@pytest.mark.parametrize("rgbw_order", ["RGB", "RRGB", "RGBWW"]) +def test_validate_rgbw_order_rejects_invalid_order(rgbw_order: str) -> None: + with pytest.raises(cv.Invalid, match="permutation of RGBW"): + _validate_rgbw_order(rgbw_order) + + +@pytest.mark.parametrize( + ("rgbw_order", "expected"), + [ + ("WRGB", ("RGB", 0)), + ("RWGB", ("RGB", 1)), + ("GWRB", ("GRB", 1)), + ("RGBW", ("RGB", 3)), + ], +) +def test_split_rgbw_order(rgbw_order: str, expected: tuple[str, int]) -> None: + assert _split_rgbw_order(rgbw_order) == expected + + +@pytest.mark.parametrize("conflict", [CONF_IS_RGBW, CONF_IS_WRGB]) +def test_rgbw_order_is_mutually_exclusive(conflict: str) -> None: + with pytest.raises(cv.Invalid, match="cannot be used with"): + _validate_rgbw_order_exclusivity( + { + CONF_RGBW_ORDER: "RGBW", + CONF_IS_RGBW: conflict == CONF_IS_RGBW, + CONF_IS_WRGB: conflict == CONF_IS_WRGB, + } + ) + + +@pytest.mark.parametrize("legacy_option", [CONF_IS_RGBW, CONF_IS_WRGB]) +def test_rgbw_order_allows_disabled_legacy_options(legacy_option: str) -> None: + config = { + CONF_RGBW_ORDER: "RGBW", + CONF_IS_RGBW: False, + CONF_IS_WRGB: False, + } + config[legacy_option] = False + assert _validate_rgbw_order_exclusivity(config) is config From 6bcbdd79c36f2e5a0e04e396c82c5c66c423fb93 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Aug 2026 08:43:14 -0500 Subject: [PATCH 1243/1815] [core] Lift the log line processor into esphome/stacktrace.py (#18076) --- esphome/__main__.py | 15 +- esphome/api_client.py | 64 +------- esphome/platform_hooks.py | 7 +- esphome/platformio/toolchain.py | 53 ++++-- esphome/stacktrace.py | 105 ++++++++++++ .../analyze_memory/test_build_artifacts.py | 4 +- tests/unit_tests/test_api_client.py | 135 +--------------- tests/unit_tests/test_lazy_imports.py | 26 ++- tests/unit_tests/test_main.py | 32 ++++ tests/unit_tests/test_platformio_toolchain.py | 47 +++++- tests/unit_tests/test_stacktrace.py | 151 ++++++++++++++++++ 11 files changed, 414 insertions(+), 225 deletions(-) create mode 100644 esphome/stacktrace.py create mode 100644 tests/unit_tests/test_stacktrace.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 4b380f1335..178546de68 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -58,6 +58,7 @@ from esphome.core import CORE, EsphomeError, coroutine from esphome.enum import StrEnum from esphome.helpers import get_bool_env, indent, is_ip_address from esphome.log import AnsiFore, color, setup_log +from esphome.stacktrace import LogLineProcessor from esphome.types import ConfigType from esphome.upload_targets import PortType, get_port_type from esphome.util import ( @@ -631,11 +632,9 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: return 1 _LOGGER.info("Starting log output from %s with baud rate %s", port, baud_rate) - # Stacktrace analysis is optional; platform_hooks owns resolution - # and the user-facing messages. - process_stacktrace = platform_hooks.get_stacktrace_handler(CORE.target_platform) - - backtrace_state = False + # Decoder resolution, crash isolation, and disable-after-failure + # all live in LogLineProcessor, shared with the API log path. + processor = LogLineProcessor(config, CORE.target_platform) ser = serial.Serial() ser.baudrate = baud_rate ser.port = port @@ -675,11 +674,7 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: "utf8", "backslashreplace" ) safe_print(parser.parse_line(line, time_str)) - - if process_stacktrace is not None: - backtrace_state = process_stacktrace( - config, line, backtrace_state - ) + processor.process_line(line) except serial.SerialException: _LOGGER.error("Serial port closed!") return 0 diff --git a/esphome/api_client.py b/esphome/api_client.py index 0ee2a7bed3..cd80a64fb9 100644 --- a/esphome/api_client.py +++ b/esphome/api_client.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio from contextlib import suppress from datetime import datetime -import importlib import logging from typing import TYPE_CHECKING, Any import warnings @@ -18,6 +17,7 @@ with warnings.catch_warnings(): from esphome.const import CONF_ENCRYPTION, CONF_KEY, CONF_PORT, __version__ from esphome.core import CORE +from esphome.stacktrace import LogLineProcessor from esphome.util import safe_print if TYPE_CHECKING: @@ -29,50 +29,6 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -class _LogLineProcessor: - """Feeds incoming log lines to the stack-trace decoder. - - Two responsibilities beyond just calling the decoder: - 1. Catch everything the decoder can raise. aioesphomeapi isolates - exceptions raised by log handlers, so an escaping one no longer - kills the session, but it does log a full traceback per line. A - crash dump carries a PC line plus one per backtrace frame, so the - tracebacks bury the dump the user is trying to read. Decoding is a - diagnostic nicety; nothing it raises is worth that noise. - 2. Disable decoding after the first failure. _decode_pc shells out to - the toolchain to resolve addr2line, which is expensive; a single - crash dump can contain many PC/BT lines and we don't want to retry - the failing subprocess for each one. This only works if every - failure is caught, which is why 1 is not narrowed to EsphomeError. - """ - - def __init__(self, config: dict[str, Any], platform_handler: Any | None) -> None: - self._config = config - self._platform_handler = platform_handler - self._decode_enabled = platform_handler is not None - self.backtrace_state = False - - def process_line(self, raw_line: str) -> None: - if not self._decode_enabled: - return - try: - self.backtrace_state = self._platform_handler( - self._config, raw_line, self.backtrace_state - ) - except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except - self._decode_enabled = False - self.backtrace_state = False - # _run_idedata raises EsphomeError with no message; fall back - # to a generic explanation when str(exc) is empty. - detail = str(exc) or "build artifacts not found locally" - _LOGGER.debug("Stack-trace decoding failed", exc_info=True) - _LOGGER.warning( - "Crash trace decoding unavailable: %s. " - "Run 'esphome compile' for this device to enable PC decoding.", - detail, - ) - - async def async_run_logs( config: dict[str, Any], addresses: list[str], @@ -100,21 +56,9 @@ async def async_run_logs( provide_time=False, ) - # Try platform-specific stacktrace handler first, fall back to generic - platform_process_stacktrace = None - try: - module = importlib.import_module("esphome.components." + CORE.target_platform) - platform_process_stacktrace = module.process_stacktrace - except (AttributeError, ImportError): - # Distinguish "platform has no analyzer" from a genuinely broken - # platform package when debugging. - _LOGGER.debug("Stacktrace analyzer lookup failed", exc_info=True) - _LOGGER.info( - 'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".', - CORE.target_platform, - ) - - processor = _LogLineProcessor(config, platform_process_stacktrace) + # Decoder resolution, crash isolation, and disable-after-failure + # all live in LogLineProcessor, shared with the serial log path. + processor = LogLineProcessor(config, CORE.target_platform) def on_log(msg: SubscribeLogsResponse) -> None: """Handle a new log message.""" diff --git a/esphome/platform_hooks.py b/esphome/platform_hooks.py index 184644f29c..893500290b 100644 --- a/esphome/platform_hooks.py +++ b/esphome/platform_hooks.py @@ -10,10 +10,9 @@ imports each platform package and fails when they drift. The compile-path ``run_compile`` hook is deliberately not registered: compiling imports the platform package regardless, so its probe in -``__main__.py`` stays eager. The serial log path resolves -``process_stacktrace`` through get_stacktrace_handler below; the network -log client's probe in ``esphome/api_client.py`` still uses the old -importlib pattern and is converted separately. +``__main__.py`` stays eager. Both log paths resolve +``process_stacktrace`` through ``esphome.stacktrace.LogLineProcessor``, +which uses get_stacktrace_handler below. """ from __future__ import annotations diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index 0959cbfffb..32e30290ac 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -6,7 +6,7 @@ from pathlib import Path import re import shutil import sys -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import platformdirs @@ -364,18 +364,24 @@ def run_compile(config, verbose): def _run_idedata(config): args = ["-t", "idedata"] stdout = run_platformio_cli_run(config, False, *args, capture_stdout=True) + if not isinstance(stdout, str): + # run_external_process returns 1 instead of captured output when + # launching platformio raised; see the error it logged above. + raise EsphomeError("Could not launch platformio to get idedata") match = re.search(r'{\s*".*}', stdout) if match is None: - _LOGGER.error("Could not match idedata, please report this error") + # A run that launches but fails emits its build error instead of + # idedata; the logged stdout is the useful part, not a bug report. + _LOGGER.error("Could not find idedata in the platformio output") _LOGGER.error("Stdout: %s", stdout) - raise EsphomeError + raise EsphomeError("PlatformIO did not report idedata") try: return json.loads(match.group()) - except ValueError: + except ValueError as err: _LOGGER.exception("Could not parse idedata") _LOGGER.error("Stdout: %s", stdout) - raise + raise EsphomeError("Could not parse idedata from platformio") from err def _load_idedata(config): @@ -419,9 +425,27 @@ class IDEData: def __init__(self, raw): self.raw = raw + def _require(self, *keys: str) -> Any: + """Read a nested key, classifying a miss as an environment error. + + A stale or truncated cached idedata JSON is the user's build + tree, not a bug; recompiling regenerates it. The message names + the key so a platformio schema change stays diagnosable. + """ + value = self.raw + # TypeError covers a key that is null instead of absent. + try: + for key in keys: + value = value[key] + except (KeyError, TypeError) as err: + raise EsphomeError( + f"Cached idedata is incomplete (missing {'.'.join(keys)})" + ) from err + return value + @property def firmware_elf_path(self) -> Path: - return Path(self.raw["prog_path"]) + return Path(self._require("prog_path")) @property def firmware_bin_path(self) -> Path: @@ -429,15 +453,22 @@ class IDEData: @property def extra_flash_images(self) -> list[FlashImage]: - return [ - FlashImage(path=Path(entry["path"]), offset=entry["offset"]) - for entry in self.raw["extra"]["flash_images"] - ] + try: + return [ + FlashImage(path=Path(entry["path"]), offset=entry["offset"]) + for entry in self._require("extra", "flash_images") + ] + except (KeyError, TypeError) as err: + # Covers entries missing path/offset and a null or non-list + # flash_images value alike. + raise EsphomeError( + "Cached idedata is incomplete (malformed extra.flash_images)" + ) from err @property def cc_path(self) -> str: # For example /Users//.platformio/packages/toolchain-xtensa32/bin/xtensa-esp32-elf-gcc - return self.raw["cc_path"] + return self._require("cc_path") @property def addr2line_path(self) -> str: diff --git a/esphome/stacktrace.py b/esphome/stacktrace.py new file mode 100644 index 0000000000..3fe3ef3cfe --- /dev/null +++ b/esphome/stacktrace.py @@ -0,0 +1,105 @@ +"""Stack-trace decoding for streamed device log lines. + +Shared by the serial (run_miniterm) and network (api_client) log paths. +Deliberately light: importing this module must not pull in aioesphomeapi +or any platform package. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from esphome import platform_hooks +from esphome.core import EsphomeError +from esphome.types import ConfigType + +if TYPE_CHECKING: + from collections.abc import Callable + + # The contract every platform's process_stacktrace implements. + StacktraceHandler = Callable[[ConfigType, str, bool], bool] + +_LOGGER = logging.getLogger(__name__) + + +class LogLineProcessor: + """Feeds incoming log lines to the stack-trace decoder. + + Two responsibilities beyond just calling the decoder: + 1. Catch everything the decoder can raise. aioesphomeapi isolates + exceptions raised by log handlers, so an escaping one no longer + kills the session, but it does log a full traceback per line. A + crash dump carries a PC line plus one per backtrace frame, so the + tracebacks bury the dump the user is trying to read. Decoding is a + diagnostic nicety; nothing it raises is worth that noise. + 2. Disable decoding for the rest of the session after a failure. + _decode_pc shells out to the toolchain to resolve addr2line, + which is expensive; a single crash dump can contain many PC/BT + lines and we don't want to retry the failing subprocess for each + one. This only works if every failure is caught, which is why 1 + is not narrowed to EsphomeError. The latch is deliberately one + way: nothing a decode failure depends on heals by itself within + a session, the warning names the fix, and a fresh ``esphome + logs`` run picks it up; retrying mid-session would block the + stream with a failing subprocess instead. + """ + + def __init__(self, config: ConfigType, platform: str) -> None: + self._config = config + self._platform = platform + self._platform_handler: StacktraceHandler | None + try: + self._platform_handler = platform_hooks.get_stacktrace_handler(platform) + except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except + # Total containment includes resolution: a platform package + # broken in an unanticipated way must not kill the session. + # Name the cause; the full traceback only exists at debug. + _LOGGER.debug("Stacktrace analyzer resolution failed", exc_info=True) + _LOGGER.warning( + 'Stacktrace analysis is unavailable: analyzer for target platform "%s" could not be loaded: %s', + platform, + f"{type(exc).__name__}: {exc}", + ) + self._platform_handler = None + self._decode_enabled = self._platform_handler is not None + self.backtrace_state = False + + def process_line(self, raw_line: str) -> None: + if not self._decode_enabled: + return + self._feed(raw_line) + + def _feed(self, raw_line: str) -> None: + try: + self.backtrace_state = self._platform_handler( + self._config, raw_line, self.backtrace_state + ) + except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except + self._decode_enabled = False + self.backtrace_state = False + _LOGGER.debug("Stack-trace decoding failed", exc_info=True) + if isinstance(exc, (EsphomeError, OSError)): + # The environment branch: idedata and build tree failures + # get the remediation hint. The fallback string is + # defensive; the in-tree raise sites all carry a message + # now, but a bare EsphomeError must not render as parens. + _LOGGER.warning( + "Crash trace decoding unavailable: %s. " + "Run 'esphome compile' for this device to enable PC decoding.", + str(exc) or "build artifacts not found locally", + ) + else: + # A decoder bug is ESPHome's problem, not the user's; + # don't send them to recompile a healthy build. Always + # name the type: a bare KeyError message reads like a + # raised string in the paste a bug report needs. + detail = type(exc).__name__ + if msg := str(exc): + detail = f"{detail}: {msg}" + _LOGGER.warning( + 'Crash trace decoding disabled: decoder for "%s" raised %s ' + "(this is a bug; run with -v for the traceback)", + self._platform, + detail, + ) diff --git a/tests/unit_tests/analyze_memory/test_build_artifacts.py b/tests/unit_tests/analyze_memory/test_build_artifacts.py index ad2f8c2210..d97ee94e1c 100644 --- a/tests/unit_tests/analyze_memory/test_build_artifacts.py +++ b/tests/unit_tests/analyze_memory/test_build_artifacts.py @@ -143,8 +143,8 @@ def test_cc_path_from_cxx(cxx_path: str, expected: str) -> None: def test_native_idedata_resolves_toolchain_tools() -> None: """The binutils paths are derived from the native ESP-IDF cc_path. - Without cc_path, IDEData.objdump_path raises KeyError and the memory - analysis silently degrades to no component or symbol detail. + Without cc_path, IDEData.objdump_path raises EsphomeError and the + memory analysis silently degrades to no component or symbol detail. """ idedata = IDEData( { diff --git a/tests/unit_tests/test_api_client.py b/tests/unit_tests/test_api_client.py index 55907f282c..670557c16f 100644 --- a/tests/unit_tests/test_api_client.py +++ b/tests/unit_tests/test_api_client.py @@ -8,7 +8,6 @@ from unittest.mock import AsyncMock, Mock, patch import pytest from esphome import api_client -from esphome.components import esp32 from esphome.const import ( CONF_ENCRYPTION, CONF_KEY, @@ -16,7 +15,7 @@ from esphome.const import ( KEY_CORE, KEY_TARGET_PLATFORM, ) -from esphome.core import CORE, EsphomeError +from esphome.core import CORE def test_component_shim_reexports_runtime_client() -> None: @@ -29,135 +28,6 @@ def test_component_shim_reexports_runtime_client() -> None: assert api.CONF_ENCRYPTION is CONF_ENCRYPTION -def test_decoder_swallows_esphome_error() -> None: - """A failing stack-trace decode must not propagate. - - aioesphomeapi isolates exceptions raised by log handlers, so an - escaping one logs a full traceback for every line it fires on rather - than being reported once as an unavailable decoder. - """ - config = {"esphome": {"name": "test"}} - - with patch.object( - esp32, "process_stacktrace", side_effect=EsphomeError("no idedata") - ) as mock_process: - processor = api_client._LogLineProcessor(config, esp32.process_stacktrace) - processor.process_line("PC: 0x4010496e") - - assert mock_process.called - assert processor.backtrace_state is False - - -def test_decoder_swallows_platform_handler_error() -> None: - """The same protection must apply to the platform-specific handler.""" - config = {"esphome": {"name": "test"}} - - def platform_handler(_config, _line, _state): - raise EsphomeError("no idedata") - - processor = api_client._LogLineProcessor(config, platform_handler) - processor.process_line("PC: 0x4010496e") - - assert processor.backtrace_state is False - - -def test_decoder_swallows_non_esphome_error() -> None: - """Decoding failures that aren't EsphomeError must be contained too. - - A missing build directory surfaces as FileNotFoundError from the toolchain - subprocess. aioesphomeapi isolates it, so the session survives, but it logs - a traceback for every PC/BT line and decoding is never disabled, which - buries the crash dump the user is trying to read. - """ - config = {"esphome": {"name": "test"}} - - with patch.object( - esp32, - "process_stacktrace", - side_effect=FileNotFoundError( - 2, "No such file or directory", "/build/ol/build" - ), - ) as mock_process: - processor = api_client._LogLineProcessor(config, esp32.process_stacktrace) - processor.process_line("PC: 0x4010496e") - processor.process_line("BT0: 0x4010496e") - - # Disabled after the first failure rather than retried per backtrace line. - assert mock_process.call_count == 1 - assert processor.backtrace_state is False - - -def test_decoder_warning_uses_fallback_for_empty_error(caplog) -> None: - """_run_idedata raises EsphomeError with no message; the warning - must show a useful explanation rather than empty parens. - """ - config = {"esphome": {"name": "test"}} - - with patch.object(esp32, "process_stacktrace", side_effect=EsphomeError()): - processor = api_client._LogLineProcessor(config, esp32.process_stacktrace) - processor.process_line("PC: 0x4010496e") - - warnings = [r.message for r in caplog.records if r.levelname == "WARNING"] - assert any("build artifacts not found locally" in m for m in warnings) - assert not any("()" in m for m in warnings) - - -def test_decoder_short_circuits_after_failure() -> None: - """After one failure, subsequent lines must not retry the decoder. - - _decode_pc shells out to the toolchain; a crash dump can contain many - PC/BT lines and retrying the failing subprocess for each one would - stall log streaming. - """ - config = {"esphome": {"name": "test"}} - - with patch.object( - esp32, "process_stacktrace", side_effect=EsphomeError("no idedata") - ) as mock_process: - processor = api_client._LogLineProcessor(config, esp32.process_stacktrace) - processor.process_line("PC: 0x4010496e") - processor.process_line("BT0: 0x4010496e") - processor.process_line("BT1: 0x401049aa") - - assert mock_process.call_count == 1 - - -def test_decoder_threads_backtrace_state() -> None: - """When decoding succeeds, backtrace_state is threaded across calls.""" - config = {"esphome": {"name": "test"}} - - with patch.object( - esp32, "process_stacktrace", side_effect=[True, False] - ) as mock_process: - processor = api_client._LogLineProcessor(config, esp32.process_stacktrace) - processor.process_line(">>>stack>>>") - assert processor.backtrace_state is True - processor.process_line("<< None: - """The platform handler is preferred over the generic one.""" - config = {"esphome": {"name": "test"}} - calls: list[tuple[object, str, bool]] = [] - - def platform_handler(cfg, line, state): - calls.append((cfg, line, state)) - return True - - processor = api_client._LogLineProcessor(config, platform_handler) - - with patch.object(esp32, "process_stacktrace") as mock_generic: - processor.process_line("BT0: 0x4010496e") - - assert calls == [(config, "BT0: 0x4010496e", False)] - assert mock_generic.called is False - assert processor.backtrace_state is True - - @pytest.mark.asyncio @pytest.mark.parametrize( ("extra_config", "expected_deep_sleep"), @@ -194,6 +64,7 @@ async def test_async_run_logs_full_flow(caplog) -> None: stop() cleanup in the finally block. """ caplog.set_level("INFO", logger="esphome.api_client") + caplog.set_level("INFO", logger="esphome.platform_hooks") CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "host"} config = { "esphome": {"name": "test"}, @@ -233,7 +104,7 @@ async def test_async_run_logs_full_flow(caplog) -> None: assert mock_client.call_args.kwargs["noise_psk"] == "psk123" assert mock_client.call_args.kwargs["addresses"] == ["1.2.3.4", "5.6.7.8"] assert "1.2.3.4 or 5.6.7.8" in caplog.text - # host has no stacktrace analyzer; the fallback message is logged. + # host has no stacktrace analyzer; the notice fires at session start. assert "Stacktrace analysis is unavailable" in caplog.text # The log message was printed with a timestamp prefix. assert any("hello world" in line for line in printed) diff --git a/tests/unit_tests/test_lazy_imports.py b/tests/unit_tests/test_lazy_imports.py index 69202b1a72..74b2362949 100644 --- a/tests/unit_tests/test_lazy_imports.py +++ b/tests/unit_tests/test_lazy_imports.py @@ -37,16 +37,21 @@ HEAVY_MODULES = ( # existence guard and the leak check must watch the same list. FAST_PATH_HEAVY_MODULES = HEAVY_MODULES + ("esphome.components.esp32",) +# Heavy only for modules that must not know about the API transport; +# in the existence guard so a rename can't silently no-op its check. +API_HEAVY_MODULES = ("aioesphomeapi",) -def _leaked_heavy_modules(module: str) -> str: + +def _leaked_heavy_modules(module: str, extra: tuple[str, ...] = ()) -> str: """Import ``module`` in a subprocess and report the heavy modules it pulled. Any ``esphome.components.*`` package counts as heavy: executing a component package drags in codegen/validation machinery by design. + ``extra`` adds modules that are heavy for this caller specifically. """ check = ( f"import sys; import {module}; " - f"leaked = [m for m in {HEAVY_MODULES!r} if m in sys.modules]; " + f"leaked = [m for m in {HEAVY_MODULES + extra!r} if m in sys.modules]; " "leaked += [m for m in sys.modules if m.startswith('esphome.components.')]; " "print(','.join(leaked))" ) @@ -72,7 +77,7 @@ def test_main_module_does_not_import_heavy_modules() -> None: def test_watched_heavy_modules_exist() -> None: """A renamed heavy module would silently disable the leak checks.""" - for module in FAST_PATH_HEAVY_MODULES: + for module in FAST_PATH_HEAVY_MODULES + API_HEAVY_MODULES: assert importlib.util.find_spec(module) is not None, ( f"{module} no longer resolves; update the heavy-module lists" ) @@ -145,6 +150,21 @@ def test_api_client_does_not_import_heavy_modules() -> None: ) +def test_stacktrace_does_not_import_heavy_modules() -> None: + """``esphome.stacktrace`` guards its own docstring's contract. + + Both log paths construct a LogLineProcessor before streaming + starts; importing the module must not pull in aioesphomeapi or + any platform package. + """ + leaked = _leaked_heavy_modules("esphome.stacktrace", extra=API_HEAVY_MODULES) + assert not leaked, ( + f"esphome.stacktrace imports heavy modules at top level: {leaked}. " + "The logs fast path skips validation; importing the validation " + "stack anyway defeats the validated-config cache." + ) + + def test_espidf_toolchain_does_not_import_heavy_modules() -> None: """The esp-idf upload path must not pull the esp32 package back in. diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 09c8d25249..16ab677ba6 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -5923,6 +5923,38 @@ def test_run_miniterm_backtrace_state_maintained() -> None: assert backtrace_states[3][1] is True +def test_run_miniterm_decoder_failure_keeps_streaming( + caplog: pytest.LogCaptureFixture, +) -> None: + """A decoder exception must not kill serial streaming. + + This is the serial path's gain from sharing LogLineProcessor: before + the lift a decoder exception propagated out of the read loop. + """ + chunk = b"PC: 0x4010496e\r\nBT0: 0x4010496e\r\nstill streaming\r\n" + mock_serial = MockSerial([chunk, MOCK_SERIAL_END]) + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP32} + config = { + CONF_LOGGER: { + CONF_BAUD_RATE: 115200, + "deassert_rts_dtr": False, + } + } + args = MockArgs() + + decoder = Mock(side_effect=EsphomeError("no idedata")) + with ( + patch("serial.Serial", return_value=mock_serial), + patch.object(esp32, "process_stacktrace", decoder), + ): + run_miniterm(config, "/dev/ttyUSB0", args) + + # The failure is contained and latched; streaming continued to EOF. + assert decoder.call_count == 1 + assert "Crash trace decoding unavailable" in caplog.text + + def test_run_miniterm_handles_empty_reads( capfd: CaptureFixture[str], ) -> None: diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index 723646fbd6..9450e8e0e1 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -278,15 +278,56 @@ def test_run_idedata_raises_on_no_json( def test_run_idedata_raises_on_invalid_json( setup_core: Path, mock_run_platformio_cli_run: Mock ) -> None: - """Test _run_idedata raises on malformed JSON.""" + """Malformed JSON is the environment (garbage stdout), so it must + surface as EsphomeError and get the recompile hint downstream. + """ config = {"name": "test"} mock_run_platformio_cli_run.return_value = '{"invalid": json"}' - # The ValueError from json.loads is re-raised - with pytest.raises(ValueError): + with pytest.raises(EsphomeError): toolchain._run_idedata(config) +def test_run_idedata_raises_on_launch_failure( + setup_core: Path, mock_run_platformio_cli_run: Mock +) -> None: + """A failed platformio launch returns its exit code as an int; that + must surface as EsphomeError, not a TypeError from re.search. + """ + config = {"name": "test"} + mock_run_platformio_cli_run.return_value = 1 + + with pytest.raises(EsphomeError): + toolchain._run_idedata(config) + + +def test_idedata_missing_prog_path_raises_esphome_error(setup_core: Path) -> None: + """A stale cached idedata JSON without prog_path is the build tree's + fault; it must surface as EsphomeError, not a KeyError. + """ + with pytest.raises(EsphomeError): + _ = toolchain.IDEData({}).firmware_elf_path + + +def test_idedata_missing_flash_image_field_raises_esphome_error( + setup_core: Path, +) -> None: + """A cached idedata whose flash image entries lost a field must + classify as an environment error too, not a raw KeyError. + """ + idedata = toolchain.IDEData({"extra": {"flash_images": [{"offset": "0x1000"}]}}) + with pytest.raises(EsphomeError): + _ = idedata.extra_flash_images + + +def test_idedata_null_section_raises_esphome_error(setup_core: Path) -> None: + """A section that is null instead of absent must classify the same + as a missing key instead of escaping as TypeError. + """ + with pytest.raises(EsphomeError): + _ = toolchain.IDEData({"extra": None}).extra_flash_images + + def test_run_platformio_cli_sets_environment_variables( setup_core: Path, mock_run_external_process: Mock ) -> None: diff --git a/tests/unit_tests/test_stacktrace.py b/tests/unit_tests/test_stacktrace.py new file mode 100644 index 0000000000..fcc99ab587 --- /dev/null +++ b/tests/unit_tests/test_stacktrace.py @@ -0,0 +1,151 @@ +"""Tests for esphome.stacktrace.""" + +from __future__ import annotations + +from unittest.mock import Mock, patch + +from esphome import stacktrace +from esphome.const import PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266 +from esphome.core import EsphomeError + +CONFIG = {"esphome": {"name": "test"}} + + +def _run( + handler, + platform: str = PLATFORM_ESP32, + lines: tuple[str, ...] = ("PC: 0x4010496e",), +) -> stacktrace.LogLineProcessor: + """Processor with the resolver stubbed, fed the given lines.""" + with patch.object( + stacktrace.platform_hooks, "get_stacktrace_handler", return_value=handler + ): + processor = stacktrace.LogLineProcessor(CONFIG, platform) + for line in lines: + processor.process_line(line) + return processor + + +def _fed(handler) -> list[str]: + return [call.args[1] for call in handler.call_args_list] + + +def _warnings(caplog) -> list[str]: + return [r.message for r in caplog.records if r.levelname == "WARNING"] + + +def test_decoder_contains_failures_and_short_circuits() -> None: + """One decode failure is contained and never retried. + + aioesphomeapi isolates exceptions raised by log handlers, so an + escaping one logs a full traceback for every line it fires on; and + _decode_pc shells out to the toolchain, so retrying it per backtrace + line would stall streaming. + """ + handler = Mock(side_effect=EsphomeError("no idedata")) + processor = _run( + handler, lines=("PC: 0x4010496e", "BT0: 0x4010496e", "BT1: 0x401049aa") + ) + + assert handler.call_count == 1 + assert processor.backtrace_state is False + + +def test_resolution_failure_is_contained(caplog) -> None: + """A platform package broken in an unanticipated way must not kill + the session; decoding degrades with a warning like any other failure. + """ + with patch.object( + stacktrace.platform_hooks, + "get_stacktrace_handler", + side_effect=RuntimeError("boom"), + ): + processor = stacktrace.LogLineProcessor(CONFIG, PLATFORM_ESP32) + processor.process_line("PC: 0x4010496e") + + assert processor.backtrace_state is False + assert any("could not be loaded" in m for m in _warnings(caplog)) + + +def test_decoder_swallows_os_error_with_remediation_hint(caplog) -> None: + """Decoding failures that aren't EsphomeError must be contained too. + + A missing build directory surfaces as an OSError; that is the + user's environment, not a decoder bug, so it disables decoding + like an EsphomeError does and keeps the recompile hint. + """ + handler = Mock( + side_effect=FileNotFoundError(2, "No such file or directory", "/build") + ) + processor = _run(handler, lines=("PC: 0x4010496e", "BT0: 0x4010496e")) + + assert handler.call_count == 1 + assert processor.backtrace_state is False + warnings = _warnings(caplog) + assert any("esphome compile" in m for m in warnings) + assert not any("this is a bug" in m for m in warnings) + + +def test_decoder_warning_uses_fallback_for_empty_error(caplog) -> None: + """A message-less EsphomeError must show a useful explanation. + + Defensive: the in-tree idedata raise sites all carry a message now, + but a bare EsphomeError from elsewhere must not render as parens. + """ + _run(Mock(side_effect=EsphomeError())) + + warnings = _warnings(caplog) + assert any("build artifacts not found locally" in m for m in warnings) + assert not any("()" in m for m in warnings) + + +def test_decoder_bug_with_empty_message_names_the_type(caplog) -> None: + """A zero-message decoder bug must not masquerade as missing artifacts. + + The recompile hint is only right for EsphomeError from _run_idedata; + anything else is ESPHome's own bug and says so instead of sending + the user down a dead-end remediation path. + """ + _run(Mock(side_effect=IndexError())) + + warnings = _warnings(caplog) + assert any("IndexError" in m and "this is a bug" in m for m in warnings) + assert not any("esphome compile" in m for m in warnings) + + +def test_decoder_bug_warning_keeps_the_type_with_a_message(caplog) -> None: + """The type must survive a non-empty message; a bare KeyError message + like 'prog_path' reads as a raised string in a bug report paste. + """ + _run(Mock(side_effect=KeyError("prog_path"))) + + warnings = _warnings(caplog) + assert any("KeyError: 'prog_path'" in m for m in warnings) + + +def test_state_threads_between_lines() -> None: + """backtrace_state carries from one decoded line to the next.""" + handler = Mock(side_effect=[True, True]) + processor = _run( + handler, + platform=PLATFORM_ESP8266, + lines=(">>>stack>>>", "3ffffe10: 40201234 3ffe8410 00000000 40201000"), + ) + + assert _fed(handler) == [ + ">>>stack>>>", + "3ffffe10: 40201234 3ffe8410 00000000 40201000", + ] + assert handler.call_args_list[0].args[2] is False + assert handler.call_args_list[1].args[2] is True + assert processor.backtrace_state is True + + +def test_no_analyzer_disables_decoding(caplog) -> None: + """Platforms without an analyzer report at session start and stay quiet.""" + caplog.set_level("INFO", logger="esphome.platform_hooks") + processor = stacktrace.LogLineProcessor(CONFIG, PLATFORM_BK72XX) + processor.process_line("PC: 0x40104960") + + assert "Stacktrace analysis is unavailable" in caplog.text + assert processor.backtrace_state is False From 2d5d34d33f40a05bf4d97d4fb21793f44490f02b Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:19:57 +0200 Subject: [PATCH 1244/1815] [zigbee] cleanup, docstrings, refactor connected state on esp32 (1/3) (#18007) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../zigbee/zigbee_attribute_esp32.cpp | 27 +++++++++++-- .../zigbee/zigbee_attribute_esp32.h | 1 + esphome/components/zigbee/zigbee_ep_esp32.py | 39 ++++++++++++++----- esphome/components/zigbee/zigbee_esp32.cpp | 7 ++-- esphome/components/zigbee/zigbee_esp32.h | 9 ++++- 5 files changed, 65 insertions(+), 18 deletions(-) diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.cpp b/esphome/components/zigbee/zigbee_attribute_esp32.cpp index d7176e6ca5..1fb8d1abe4 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.cpp +++ b/esphome/components/zigbee/zigbee_attribute_esp32.cpp @@ -9,16 +9,18 @@ namespace esphome::zigbee { static const char *const TAG = "zigbee.attribute"; void ZigbeeAttribute::set_attr_() { - if (!this->zb_->is_connected()) { + if (!this->zb_->is_started()) { return; } if (esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { ezb_zcl_status_t state = ezb_zcl_set_attr_value(this->endpoint_id_, this->cluster_id_, this->role_, this->attr_id_, EZB_ZCL_STD_MANUF_CODE, this->value_p_, false); + // cleared before report_() so it can disable the loop + // when the report has to wait for join + this->set_attr_requested_ = false; if (this->force_report_) { this->report_(true); } - this->set_attr_requested_ = false; // Check for error if (state != EZB_ZCL_STATUS_SUCCESS) { ESP_LOGE(TAG, "Setting attribute failed, ZCL status: %u", static_cast(state)); @@ -28,7 +30,14 @@ void ZigbeeAttribute::set_attr_() { } void ZigbeeAttribute::report_(bool has_lock) { - if (!this->zb_->is_connected() || !this->report_enabled) { + if (!this->report_enabled) { + return; + } + if (!this->zb_->is_joined()) { + this->report_requested_ = true; + if (!this->set_attr_requested_) { + this->disable_loop(); + } return; } if (has_lock or esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { @@ -44,6 +53,7 @@ void ZigbeeAttribute::report_(bool has_lock) { cmd.payload.attr_id = this->attr_id_; ezb_zcl_report_attr_cmd_req(&cmd); + this->report_requested_ = false; if (!has_lock) { esp_zigbee_lock_release(); } @@ -55,6 +65,11 @@ void ZigbeeAttribute::set_report(ZigbeeReportT report) { if (report == ZigbeeReportT::ZIGBEE_REPORT_FORCE) { this->force_report_ = true; } + this->zb_->add_on_join_callback([this](bool) { + if (this->report_requested_) { + this->enable_loop(); + } + }); } void ZigbeeAttribute::loop() { @@ -62,7 +77,11 @@ void ZigbeeAttribute::loop() { this->set_attr_(); } - if (!this->set_attr_requested_) { + if (this->report_requested_) { + this->report_(false); + } + + if (!this->report_requested_ && !this->set_attr_requested_) { this->disable_loop(); } } diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.h b/esphome/components/zigbee/zigbee_attribute_esp32.h index e5f8c8b1cf..fc229b4e95 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.h +++ b/esphome/components/zigbee/zigbee_attribute_esp32.h @@ -66,6 +66,7 @@ class ZigbeeAttribute final : public Component { float scale_; void *value_p_{nullptr}; bool set_attr_requested_{false}; + bool report_requested_{false}; bool force_report_{false}; }; diff --git a/esphome/components/zigbee/zigbee_ep_esp32.py b/esphome/components/zigbee/zigbee_ep_esp32.py index 2ed3dddb67..c2001c66d6 100644 --- a/esphome/components/zigbee/zigbee_ep_esp32.py +++ b/esphome/components/zigbee/zigbee_ep_esp32.py @@ -83,7 +83,7 @@ ep_configs: dict[str, dict[str, Any]] = { } -def get_next_ep_num(eps: list[int]) -> int: +def _get_next_ep_num(eps: list[int]) -> int: try: ep_num = [i for i in range(1, CONF_MAX_EP_NUMBER + 1) if i not in eps][0] eps.append(ep_num) @@ -94,7 +94,7 @@ def get_next_ep_num(eps: list[int]) -> int: return ep_num -def compare_clusters( +def _compare_clusters( existing_ep: dict[str, Any], ep: dict[str, Any], ) -> tuple[str | int, str] | None: @@ -105,12 +105,12 @@ def compare_clusters( return None -def merge_endpoints( +def _merge_endpoints( existing_ep: dict[str, Any], ep: dict[str, Any], use_type: bool | None, ) -> bool: - if compare_clusters(existing_ep, ep): + if _compare_clusters(existing_ep, ep): return False if ( ep.get(DEVICE_TYPE) @@ -134,7 +134,12 @@ def merge_endpoints( return True -def validate_endpoints(ep_dict: dict[int, dict]) -> None: +def _validate_endpoints(ep_dict: dict[int, dict]) -> None: + """Validate endpoint device type selection before endpoint creation. + + This resolves any deferred device type selections stored in CONF_USE_DEVICE_TYPE, + ensuring each endpoint has at most one active device type. + """ for num, ep in ep_dict.items(): types_dict = ep.get(CONF_USE_DEVICE_TYPE) if not types_dict: @@ -157,10 +162,18 @@ def validate_endpoints(ep_dict: dict[int, dict]) -> None: def create_ep(router: bool) -> None: + """Finalize Zigbee endpoint creation and normalize endpoint storage. + + Validate endpoints, merge endpoints, and assign numbers to endpoints without an explicit number. + This is called from final_validate. + + Args: + router: Whether the device is acting as a Zigbee router. + """ zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) ep_dict: dict[int, dict] = zb_data.setdefault(KEY_ZIGBEE_EP, {}) ep_list: list[dict] = zb_data.setdefault(KEY_ZIGBEE_EP_NO_NUM, []) - validate_endpoints(ep_dict) + _validate_endpoints(ep_dict) # create dummy endpoint if list is empty if not ep_dict and not ep_list: ep_type = "CUSTOM_ATTR" @@ -173,7 +186,7 @@ def create_ep(router: bool) -> None: for ep in ep_list: added = False for existing_ep in ep_list_new: - if merge_endpoints(existing_ep, ep, ep.get(CONF_USE_DEVICE_TYPE)): + if _merge_endpoints(existing_ep, ep, ep.get(CONF_USE_DEVICE_TYPE)): added = True break if not added: @@ -182,7 +195,7 @@ def create_ep(router: bool) -> None: # Add endpoints with no number to the endpoint dict with a new number eps = list(ep_dict.keys()) for ep in ep_list_new: - ep_num = get_next_ep_num(eps) + ep_num = _get_next_ep_num(eps) ep_dict[ep_num] = ep # clear list so that it is not processed again @@ -195,6 +208,14 @@ def create_ep(router: bool) -> None: def add_ep(ep: dict[str, Any], ep_num: int | None, use_type: bool | None) -> None: + """Add a Zigbee endpoint configuration to CORE.data. + + Args: + ep: Endpoint configuration dictionary. + ep_num: Optional explicit endpoint number. + use_type: Optional boolean indicating whether this component's device type should be + used for the endpoint (True claims it, False drops it, None leaves it as a candidate). + """ zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) if use_type is False: ep.pop(DEVICE_TYPE, None) @@ -208,7 +229,7 @@ def add_ep(ep: dict[str, Any], ep_num: int | None, use_type: bool | None) -> Non if ep_num in ep_dict: # check if the existing endpoint has same clusters existing_ep = ep_dict[ep_num] - if cl := compare_clusters( + if cl := _compare_clusters( existing_ep, ep, ): diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 3e0f6cd745..dcfdf2f3e1 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -53,6 +53,7 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { switch (signal_type) { case EZB_ZDO_SIGNAL_SKIP_STARTUP: ESP_LOGD(TAG, "Zigbee stack initialized"); + global_zigbee->started = true; ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_INITIALIZATION); break; case EZB_BDB_SIGNAL_DEVICE_FIRST_START: @@ -60,7 +61,6 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { ezb_bdb_comm_status_t status = *((ezb_bdb_comm_status_t *) ezb_app_signal_get_params(app_signal)); if (status == EZB_BDB_STATUS_SUCCESS) { ESP_LOGD(TAG, "Device started up in %sfactory-reset mode", ezb_bdb_is_factory_new() ? "" : "non "); - global_zigbee->started = true; if (ezb_bdb_is_factory_new()) { global_zigbee->factory_new = true; ESP_LOGD(TAG, "Start network steering"); @@ -303,9 +303,10 @@ void ZigbeeComponent::setup() { } void ZigbeeComponent::loop() { - if (this->joined.exchange(false)) { - this->connected_ = true; + if (!this->join_reported_ && this->joined) { + this->join_reported_ = true; this->join_cb_.call(this->factory_new); + this->factory_new = false; } this->disable_loop(); } diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index f4bafac294..986ffea449 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -63,8 +63,13 @@ class ZigbeeComponent final : public Component { template void add_on_join_callback(F &&cb) { this->join_cb_.add(std::forward(cb)); } bool is_battery_powered() { return this->basic_cluster_data_.power_source == EZB_ZCL_BASIC_POWER_SOURCE_BATTERY; } + + // True after the Zigbee stack has been initialized and the device has started up. Is set before the stack started + // network commissioning or has joined a network and won't be reset until the device is rebooted. bool is_started() { return this->started; } - bool is_connected() { return this->connected_; } + + // True if the device has joined a network and is ready to send and receive messages. + bool is_joined() { return this->joined; } std::atomic started = false; std::atomic joined = false; std::atomic factory_new = false; @@ -76,7 +81,6 @@ class ZigbeeComponent final : public Component { uint8_t *date; uint8_t power_source; } basic_cluster_data_; - bool connected_ = false; #ifdef CONFIG_ZB_ZED ezb_nwk_device_type_t device_role_ = EZB_NWK_DEVICE_TYPE_END_DEVICE; #else @@ -91,6 +95,7 @@ class ZigbeeComponent final : public Component { // key tuple could be replaced by single 64 (48) bit int with bit fields for endpoint, cluster, role and attr_id std::map, ZigbeeAttribute *> attributes_; ezb_af_device_desc_t dev_desc_; + bool join_reported_{false}; CallbackManager join_cb_{}; }; From 3964ef61f8d8c3c90465444a3782ac1f9bab0a27 Mon Sep 17 00:00:00 2001 From: TesseractTimmee <163826037+TesseractTimmee@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:23:57 +0800 Subject: [PATCH 1245/1815] [zigbee] Resolution attribute for esp32 sensors (#17973) Co-authored-by: root Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/zigbee/zigbee_esp32.py | 11 +++++++++++ tests/components/zigbee/common.yaml | 1 + 2 files changed, 12 insertions(+) diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 116dce8cc5..8e63c09e67 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -13,6 +13,7 @@ from esphome.components.esp32 import ( ) import esphome.config_validation as cv from esphome.const import ( + CONF_ACCURACY_DECIMALS, CONF_AP, CONF_DEVICE, CONF_DEVICE_CLASS, @@ -185,6 +186,7 @@ def validate_sensor_esp32(config: ConfigType) -> ConfigType: unit = config.get(CONF_UNIT_OF_MEASUREMENT) apptype = ANALOG_INPUT_APPTYPE.get((dev_class, unit)) bacunit = BACNET_UNITS.get(unit, BACNET_UNIT_NO_UNITS) + accuracy = config.get(CONF_ACCURACY_DECIMALS) if apptype is not None: ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append( { @@ -200,6 +202,15 @@ def validate_sensor_esp32(config: ConfigType) -> ConfigType: CONF_TYPE: "ENUM16", }, ) + if accuracy is not None: + # Analog Input Resolution (0x006A): smallest reportable change + ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append( + { + CONF_ATTRIBUTE_ID: 0x6A, + CONF_VALUE: 10**-accuracy, + CONF_TYPE: "SINGLE", + }, + ) setup_attributes(config, ep[CONF_CLUSTERS]) add_ep(ep, config.get(CONF_ENDPOINT), config.get(CONF_USE_DEVICE_TYPE)) return config diff --git a/tests/components/zigbee/common.yaml b/tests/components/zigbee/common.yaml index c689d07f6b..cc0d28ea61 100644 --- a/tests/components/zigbee/common.yaml +++ b/tests/components/zigbee/common.yaml @@ -13,6 +13,7 @@ sensor: - platform: template name: "Analog 1" lambda: return 10.0; + accuracy_decimals: 0 - platform: template name: "Analog 2" lambda: return 11.0; From 64f0e38a518eb9f06438147f5f2b37590b4f7d55 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:24:46 -0400 Subject: [PATCH 1246/1815] [micro_wake_word] Support adding and removing wake word models at runtime (#17927) --- .../components/micro_wake_word/__init__.py | 10 +- .../micro_wake_word/micro_wake_word.cpp | 159 ++++++++++++++++++ .../micro_wake_word/micro_wake_word.h | 34 ++++ .../components/micro_wake_word/model_data.cpp | 100 +++++++++++ .../components/micro_wake_word/model_data.h | 60 +++++++ .../micro_wake_word/streaming_model.cpp | 47 ++++++ .../micro_wake_word/streaming_model.h | 34 +++- .../micro_wake_word/validate.esp32-idf.yaml | 21 +++ 8 files changed, 461 insertions(+), 4 deletions(-) create mode 100644 esphome/components/micro_wake_word/model_data.cpp create mode 100644 esphome/components/micro_wake_word/model_data.h create mode 100644 tests/components/micro_wake_word/validate.esp32-idf.yaml diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index c427f28028..255923f878 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -432,7 +432,7 @@ CONFIG_SCHEMA = cv.All( min_channels=1, max_channels=1, ), - cv.Required(CONF_MODELS): cv.ensure_list( + cv.Optional(CONF_MODELS, default=[]): cv.ensure_list( cv.maybe_simple_value(MODEL_SCHEMA, key=CONF_MODEL) ), cv.Optional(CONF_ON_WAKE_WORD_DETECTED): automation.validate_automation( @@ -555,6 +555,9 @@ async def to_code(config): # Use the general model loading code for the VAD codegen config[CONF_MODELS].append(vad_model) + # Default feature step size for runtime models + feature_step_size = 10 + for i, model_parameters in enumerate(config[CONF_MODELS]): model_config = model_parameters.get(CONF_MODEL) data = [] @@ -573,6 +576,9 @@ async def to_code(config): manifest[KEY_MICRO][CONF_SLIDING_WINDOW_SIZE], ) + # Update feature step size from manifest + feature_step_size = manifest[KEY_MICRO][CONF_FEATURE_STEP_SIZE] + if manifest[KEY_WAKE_WORD] == "vad": cg.add( var.add_vad_model( @@ -602,7 +608,7 @@ async def to_code(config): cg.add(var.add_wake_word_model(wake_word_model)) - cg.add(var.set_features_step_size(manifest[KEY_MICRO][CONF_FEATURE_STEP_SIZE])) + cg.add(var.set_features_step_size(feature_step_size)) cg.add(var.set_stop_after_detection(config[CONF_STOP_AFTER_DETECTION])) if on_wake_word_detection_config := config.get(CONF_ON_WAKE_WORD_DETECTED): diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 237d72229d..3dadb78077 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -9,6 +9,8 @@ #include "esphome/components/audio/audio_transfer_buffer.h" +#include + #ifdef USE_OTA #include "esphome/components/ota/ota_backend.h" #endif @@ -35,21 +37,34 @@ static const UBaseType_t INFERENCE_TASK_PRIORITY = 3; enum EventGroupBits : uint32_t { COMMAND_STOP = (1 << 0), // Signals the inference task should stop COMMAND_RESET_RING_BUFFER = (1 << 1), // Signals the inference task to discard buffered audio + COMMAND_PAUSE_MODELS = (1 << 2), // Asks the inference task to pause at a safe point so the model lists can be + // mutated from the main loop TASK_STARTING = (1 << 3), TASK_RUNNING = (1 << 4), TASK_STOPPING = (1 << 5), TASK_STOPPED = (1 << 6), + MODELS_PAUSED = (1 << 7), // Inference task acknowledges it is paused and holds no iterators + COMMAND_RESUME_MODELS = (1 << 8), // Main loop signals the inference task it may resume iterating + ERROR_MEMORY = (1 << 9), ERROR_INFERENCE = (1 << 10), WARNING_FULL_RING_BUFFER = (1 << 13), + WARNING_MODELS_RESUME_TIMEOUT = (1 << 14), // The paused inference task gave up waiting to be released ERROR_BITS = ERROR_MEMORY | ERROR_INFERENCE, ALL_BITS = 0xfffff, // 24 total bits available in an event group }; +// How long the main loop waits for the inference task to acknowledge a pause request before giving up. +// The task checks for the command at the top of its loop, which runs at least every DATA_TIMEOUT_MS. +static const uint32_t MODELS_PAUSE_TIMEOUT_MS = 500; +// How long the paused inference task waits to be resumed before rechecking on its own. Only reached if +// the main loop abandoned the handshake (e.g. it timed out first), so recovery just needs to be bounded. +static const uint32_t MODELS_RESUME_TIMEOUT_MS = 1000; + float MicroWakeWord::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } static const LogString *micro_wake_word_state_to_string(State state) { @@ -176,6 +191,20 @@ void MicroWakeWord::inference_task(void *params) { xEventGroupSetBits(this_mww->event_group_, EventGroupBits::TASK_RUNNING); while (!(xEventGroupGetBits(this_mww->event_group_) & (COMMAND_STOP | ERROR_BITS))) { + if (xEventGroupGetBits(this_mww->event_group_) & EventGroupBits::COMMAND_PAUSE_MODELS) { + // Safe point: no iterators into wake_word_models_ are held here. Acknowledge the pause and wait for the + // main loop to finish mutating the model lists before resuming. + xEventGroupSetBits(this_mww->event_group_, EventGroupBits::MODELS_PAUSED); + EventBits_t resume_bits = xEventGroupWaitBits(this_mww->event_group_, EventGroupBits::COMMAND_RESUME_MODELS, + pdTRUE, pdTRUE, pdMS_TO_TICKS(MODELS_RESUME_TIMEOUT_MS)); + if (!(resume_bits & EventGroupBits::COMMAND_RESUME_MODELS)) { + // Nobody released us, so the main loop abandoned the handshake and did not mutate the lists. + // Rechecking the pause command below is safe, but the wait cost a second of detection, so report it. + xEventGroupSetBits(this_mww->event_group_, EventGroupBits::WARNING_MODELS_RESUME_TIMEOUT); + } + continue; + } + if (xEventGroupGetBits(this_mww->event_group_) & EventGroupBits::COMMAND_RESET_RING_BUFFER) { // Producer asked us to drain; run the consumer-side reset from this thread. audio_source->clear_buffered_data(); @@ -232,6 +261,130 @@ std::vector MicroWakeWord::get_wake_words() { void MicroWakeWord::add_wake_word_model(WakeWordModel *model) { this->wake_word_models_.push_back(model); } +bool MicroWakeWord::try_lock_models_() { + // When the inference task isn't running it holds no iterators into wake_word_models_, so the lists can be + // mutated without a handshake. The main loop is the only caller, so this state cannot change between here + // and the matching unlock_models_() call. + if (!this->inference_task_.is_created() || this->state_ == State::STOPPED) { + return true; + } + + // The task is running and iterates wake_word_models_. Ask it to pause at a safe point before we mutate. + // Clear any stale acknowledgement from an abandoned handshake first. + xEventGroupClearBits(this->event_group_, EventGroupBits::MODELS_PAUSED); + xEventGroupSetBits(this->event_group_, EventGroupBits::COMMAND_PAUSE_MODELS); + + EventBits_t bits = xEventGroupWaitBits(this->event_group_, EventGroupBits::MODELS_PAUSED, pdFALSE, pdTRUE, + pdMS_TO_TICKS(MODELS_PAUSE_TIMEOUT_MS)); + + if (!(bits & EventGroupBits::MODELS_PAUSED)) { + // The task never acknowledged (e.g. it is busy stopping). Withdraw the request and refuse to mutate a + // list it might be iterating. + xEventGroupClearBits(this->event_group_, EventGroupBits::COMMAND_PAUSE_MODELS); + return false; + } + return true; +} + +void MicroWakeWord::unlock_models_() { + if (!this->inference_task_.is_created() || this->state_ == State::STOPPED) { + return; // Nothing was paused + } + xEventGroupClearBits(this->event_group_, EventGroupBits::MODELS_PAUSED | EventGroupBits::COMMAND_PAUSE_MODELS); + xEventGroupSetBits(this->event_group_, EventGroupBits::COMMAND_RESUME_MODELS); +} + +bool MicroWakeWord::add_runtime_model(std::unique_ptr model) { + if (!model) { + ESP_LOGE(TAG, "Cannot add null runtime model"); + return false; + } + + const std::string model_id = model->get_id(); + + // A model without usable data can never load, so keep it out of the lists entirely. Otherwise it would be + // advertised to Home Assistant as selectable and the inference task would silently disable it again every + // time it was enabled. + if (!model->has_model_data()) { + ESP_LOGE(TAG, "Runtime model '%s' has no valid data", model_id.c_str()); + return false; + } + + // Reject a duplicate id against every model (compiled or runtime). The inference task only ever reads + // wake_word_models_, so scanning it here (on the main loop) needs no synchronization. + for (auto *existing : this->wake_word_models_) { + if (existing->get_id() == model_id) { + ESP_LOGW(TAG, "Wake word model '%s' already exists", model_id.c_str()); + return false; + } + } + + if (!this->try_lock_models_()) { + ESP_LOGE(TAG, "Timed out pausing inference task; not adding runtime model '%s'", model_id.c_str()); + return false; + } + + this->wake_word_models_.push_back(model.get()); + this->runtime_models_.push_back(std::move(model)); + + this->unlock_models_(); + ESP_LOGD(TAG, "Added runtime model '%s'", model_id.c_str()); + return true; +} + +bool MicroWakeWord::remove_runtime_model(const std::string &model_id) { + // Only runtime-downloaded models can be removed; compiled-in models never appear in runtime_models_. + auto runtime_it = + std::find_if(this->runtime_models_.begin(), this->runtime_models_.end(), + [&model_id](const std::unique_ptr &m) { return m->get_id() == model_id; }); + if (runtime_it == this->runtime_models_.end()) { + return false; + } + + if (!this->try_lock_models_()) { + ESP_LOGE(TAG, "Timed out pausing inference task; not removing runtime model '%s'", model_id.c_str()); + return false; + } + + WakeWordModel *raw = runtime_it->get(); + auto models_it = std::find(this->wake_word_models_.begin(), this->wake_word_models_.end(), raw); + if (models_it != this->wake_word_models_.end()) { + this->wake_word_models_.erase(models_it); + } + + // Queued detection events hold a pointer into the model being destroyed, so drop them. The inference task + // is parked, so no new events can be queued concurrently. Losing an undelivered detection from another + // model is acceptable for this rare operation. + xQueueReset(this->detection_queue_); + + // Free the interpreter and arenas (safe: the task is parked, not mid-inference), then destroy the model. + // Its ModelData releases the PSRAM model buffer once the last shared_ptr reference drops. + raw->unload_model(); + this->runtime_models_.erase(runtime_it); + + this->unlock_models_(); + ESP_LOGI(TAG, "Removed runtime model '%s'", model_id.c_str()); + return true; +} + +std::vector MicroWakeWord::get_runtime_model_ids() { + std::vector ids; + ids.reserve(this->runtime_models_.size()); + for (const auto &model : this->runtime_models_) { + ids.push_back(model->get_id()); + } + return ids; +} + +WakeWordModel *MicroWakeWord::get_model_by_id(const std::string &model_id) { + for (auto *model : this->wake_word_models_) { + if (model->get_id() == model_id) { + return model; + } + } + return nullptr; +} + #ifdef USE_MICRO_WAKE_WORD_VAD void MicroWakeWord::add_vad_model(const uint8_t *model_start, uint8_t probability_cutoff, size_t sliding_window_size, size_t tensor_arena_size) { @@ -270,6 +423,12 @@ void MicroWakeWord::loop() { "word detection accuracy will temporarily be reduced."); } + if (event_group_bits & EventGroupBits::WARNING_MODELS_RESUME_TIMEOUT) { + xEventGroupClearBits(this->event_group_, EventGroupBits::WARNING_MODELS_RESUME_TIMEOUT); + ESP_LOGW(TAG, "Inference task paused for %" PRIu32 " ms without being released, so it resumed on its own", + MODELS_RESUME_TIMEOUT_MS); + } + if (event_group_bits & EventGroupBits::TASK_STARTING) { ESP_LOGD(TAG, "Inference task has started, attempting to allocate memory for buffers"); xEventGroupClearBits(this->event_group_, EventGroupBits::TASK_STARTING); diff --git a/esphome/components/micro_wake_word/micro_wake_word.h b/esphome/components/micro_wake_word/micro_wake_word.h index aebb5b2595..03f4a86fd4 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.h +++ b/esphome/components/micro_wake_word/micro_wake_word.h @@ -66,6 +66,32 @@ class MicroWakeWord final : public Component void add_wake_word_model(WakeWordModel *model); + /// @brief Adds a runtime-downloaded wake word model. Must be called from the main loop. + /// If the inference task is running it is paused at a safe point before the model lists are mutated, + /// so the task never observes a half-updated vector. + /// Callers should check get_model_by_id() before constructing the model: a WakeWordModel permanently + /// claims a preference backend that is not released when the model is destroyed, so building one only to + /// have it rejected here costs internal RAM that never comes back. + /// @return True if the model was added, false if it has no valid data, on a duplicate id, or if the task + /// could not be paused + bool add_runtime_model(std::unique_ptr model); + + /// @brief Removes a runtime-downloaded wake word model and frees its interpreter, arenas, and model buffer. + /// Must be called from the main loop. If the inference task is running it is paused at a safe point first, + /// and any queued detection events are dropped (they hold pointers into the model being destroyed). + /// @return True if the model was removed, false if the id is not a runtime model or the task could not be paused + bool remove_runtime_model(const std::string &model_id); + + /// @brief Returns the wake word model with the given id, or nullptr if none matches (compiled or runtime). + /// Must be called from the main loop, as the returned pointer is invalidated by remove_runtime_model(). + WakeWordModel *get_model_by_id(const std::string &model_id); + + /// @brief Returns the ids of all runtime-downloaded models. Must be called from the main loop. + std::vector get_runtime_model_ids(); + + /// @brief Returns the feature step size (ms) the frontend is configured for. Runtime models must match it. + uint8_t get_features_step_size() const { return this->features_step_size_; } + #ifdef USE_MICRO_WAKE_WORD_VAD void add_vad_model(const uint8_t *model_start, uint8_t probability_cutoff, size_t sliding_window_size, size_t tensor_arena_size); @@ -85,6 +111,7 @@ class MicroWakeWord final : public Component std::weak_ptr ring_buffer_; std::vector wake_word_models_; + std::vector> runtime_models_; #ifdef USE_MICRO_WAKE_WORD_VAD std::unique_ptr vad_model_; @@ -119,6 +146,13 @@ class MicroWakeWord final : public Component /// @brief Resumes the inference task void resume_task_(); + /// @brief Parks the inference task at a safe point (or verifies it isn't running) so the model lists may be + /// mutated from the main loop. Every successful call must be paired with unlock_models_(). + /// @return True if the lists may be mutated, false if the running task never acknowledged the pause request + bool try_lock_models_(); + /// @brief Releases the inference task parked by a successful try_lock_models_() call + void unlock_models_(); + void set_state_(State state); /// @brief Generates a spectrogram feature from an input buffer of audio samples. The frontend buffers samples diff --git a/esphome/components/micro_wake_word/model_data.cpp b/esphome/components/micro_wake_word/model_data.cpp new file mode 100644 index 0000000000..a7326ab77a --- /dev/null +++ b/esphome/components/micro_wake_word/model_data.cpp @@ -0,0 +1,100 @@ +#include "model_data.h" + +#ifdef USE_ESP32 + +#include +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::micro_wake_word { + +static const char *const TAG = "micro_wake_word"; + +ModelData::~ModelData() { this->deallocate_(); } + +bool ModelData::allocate(size_t size) { + // Reject up front: reallocating to zero frees the buffer and returns null, which would leave data_ pointing at + // freed memory. A zero-length model is never usable anyway. + if (size == 0) { + ESP_LOGE(TAG, "Refusing to allocate a zero-length model"); + return false; + } + + // Already allocated, so reallocate to the new size + if (this->data_) { + uint8_t *new_allocation = this->allocator_.reallocate(this->data_, size); + if (new_allocation == nullptr) { + ESP_LOGE(TAG, "Failed to reallocate %zu bytes", size); + return false; + } + this->data_ = new_allocation; + this->size_ = size; + this->valid_ = false; // Need to revalidate with new data + return true; + } + + // Try to allocate in PSRAM first + this->data_ = this->allocator_.allocate(size); + if (this->data_ == nullptr) { + ESP_LOGE(TAG, "Failed to allocate %zu bytes", size); + return false; + } + + this->size_ = size; + this->valid_ = false; + return true; +} + +void ModelData::deallocate_() { + if (this->data_ != nullptr) { + this->allocator_.deallocate(this->data_, this->size_); + this->data_ = nullptr; + this->size_ = 0; + this->valid_ = false; + } +} + +const uint8_t *ModelData::get_model_pointer() const { return this->valid_ ? this->data_ : nullptr; } + +uint8_t *ModelData::get_write_pointer() { + this->valid_ = false; // Mark invalid while writing + return this->data_; +} + +bool ModelData::validate_and_mark_ready() { + // The magic number lives in bytes 4-7, so we need at least 8 bytes to read it. + if (!this->data_ || this->size_ < 8) { + ESP_LOGE(TAG, "Model data is null or too small"); + return false; + } + + // Check TFLite magic number "TFL3" in bytes 4-7 + if (memcmp(this->data_ + 4, "TFL3", 4) != 0) { + ESP_LOGE(TAG, "Invalid TFLite model magic number"); + return false; + } + + // Bytes 0-3 hold the offset of the root table. tflite::GetModel only adds that offset to the start of the + // buffer, so check it lands inside the buffer before reading through it. + uint32_t root_offset; + memcpy(&root_offset, this->data_, sizeof(root_offset)); + if (root_offset >= this->size_) { + ESP_LOGE(TAG, "TFLite model root offset is out of bounds"); + return false; + } + + const tflite::Model *model = tflite::GetModel(this->data_); + if (model->version() != TFLITE_SCHEMA_VERSION) { + ESP_LOGE(TAG, "TFLite model version mismatch (expected %d, got %d)", TFLITE_SCHEMA_VERSION, model->version()); + return false; + } + + this->valid_ = true; + return true; +} + +} // namespace esphome::micro_wake_word + +#endif // USE_ESP32 diff --git a/esphome/components/micro_wake_word/model_data.h b/esphome/components/micro_wake_word/model_data.h new file mode 100644 index 0000000000..0f0e08c718 --- /dev/null +++ b/esphome/components/micro_wake_word/model_data.h @@ -0,0 +1,60 @@ +#pragma once + +#ifdef USE_ESP32 + +#include +#include +#include "esphome/core/helpers.h" + +namespace esphome::micro_wake_word { + +// Owns the buffer holding a runtime-downloaded TFLite model. The buffer prefers PSRAM but falls back to +// internal RAM, so a device without PSRAM can still hold a single model. It is filled over HTTP, checked +// for integrity by the caller (SHA256) and for a usable TFLite header here, then kept alive for the +// lifetime of the WakeWordModel that uses it. Only ever held behind a std::shared_ptr, so copies and +// moves are disabled. +class ModelData { + public: + ModelData() = default; + ~ModelData(); + + // Non-copyable, non-movable + ModelData(const ModelData &) = delete; + ModelData &operator=(const ModelData &) = delete; + ModelData(ModelData &&) = delete; + ModelData &operator=(ModelData &&) = delete; + + // Allocate memory for model + bool allocate(size_t size); + + // Get stable pointer for TFLite (only valid after validate_and_mark_ready()) + const uint8_t *get_model_pointer() const; + + // Get writable pointer for downloading (invalidates the model) + uint8_t *get_write_pointer(); + + // Validate TFLite model and mark as ready for use + bool validate_and_mark_ready(); + + // Check if model is valid and ready for use + bool is_valid() const { return this->valid_; } + + // Get size of model data + size_t size() const { return this->size_; } + + // Check if memory is allocated + bool is_allocated() const { return this->data_ != nullptr; } + + protected: + // Deallocate memory + void deallocate_(); + + uint8_t *data_{nullptr}; + size_t size_{0}; + bool valid_{false}; + RAMAllocator allocator_{RAMAllocator::NONE}; +}; + +} // namespace esphome::micro_wake_word + +#endif // USE_ESP32 diff --git a/esphome/components/micro_wake_word/streaming_model.cpp b/esphome/components/micro_wake_word/streaming_model.cpp index 1cdc06b352..72984f04fb 100644 --- a/esphome/components/micro_wake_word/streaming_model.cpp +++ b/esphome/components/micro_wake_word/streaming_model.cpp @@ -26,6 +26,11 @@ void VADModel::log_model_config() { } bool StreamingModel::load_model_() { + if (this->model_start_ == nullptr) { + ESP_LOGE(TAG, "Streaming model has no data to load"); + return false; + } + RAMAllocator arena_allocator; if (this->var_arena_ == nullptr) { @@ -188,6 +193,13 @@ void StreamingModel::unload_model() { } bool StreamingModel::perform_streaming_inference(const int8_t features[PREPROCESSOR_FEATURE_SIZE]) { + if (this->model_start_ == nullptr) { + // No usable model data, and that cannot change for this object. Skip the model instead of reporting a + // failure, because a false return here stops the inference task for every other model too. + this->enabled_ = false; + return true; + } + if (this->enabled_ && !this->loaded_) { // Model is enabled but isn't loaded if (!this->load_model_()) { @@ -269,6 +281,41 @@ WakeWordModel::WakeWordModel(const std::string &id, const uint8_t *model_start, } }; +WakeWordModel::WakeWordModel(const std::string &id, std::shared_ptr model_data, + uint8_t default_probability_cutoff, size_t sliding_window_average_size, + const std::string &wake_word, std::vector trained_languages, + size_t tensor_arena_size) { + this->id_ = id; + this->model_data_ = std::move(model_data); + // Callers are expected to pass a validated buffer, so this is normally the stable model pointer. Tolerate a + // null or unvalidated handle rather than dereferencing it blindly: model_start_ stays null and the model is + // never loaded. + this->model_start_ = this->model_data_ ? this->model_data_->get_model_pointer() : nullptr; + if (this->model_start_ == nullptr) { + ESP_LOGE(TAG, "Model '%s' has no valid data and will not be loaded", id.c_str()); + } + this->default_probability_cutoff_ = default_probability_cutoff; + this->probability_cutoff_ = default_probability_cutoff; + this->sliding_window_size_ = sliding_window_average_size; + this->recent_streaming_probabilities_.resize(sliding_window_average_size, 0); + this->wake_word_ = wake_word; + this->trained_languages_ = std::move(trained_languages); + this->tensor_arena_size_ = tensor_arena_size; + this->register_streaming_ops_(this->streaming_op_resolver_); + this->current_stride_step_ = 0; + this->internal_only_ = false; // Runtime models are always exposed to Home Assistant + + this->pref_ = global_preferences->make_preference(fnv1_hash(id)); + bool enabled; + if (this->pref_.load(&enabled)) { + // Use the enabled state loaded from flash + this->enabled_ = enabled; + } else { + // No saved state: stay disabled. The activation flow calls enable() explicitly after adding. + this->enabled_ = false; + } +}; + void WakeWordModel::enable() { this->enabled_ = true; if (!this->internal_only_) { diff --git a/esphome/components/micro_wake_word/streaming_model.h b/esphome/components/micro_wake_word/streaming_model.h index 07ba78d1f4..1cb9d6eba5 100644 --- a/esphome/components/micro_wake_word/streaming_model.h +++ b/esphome/components/micro_wake_word/streaming_model.h @@ -3,9 +3,11 @@ #ifdef USE_ESP32 #include "preprocessor_settings.h" +#include "model_data.h" #include "esphome/core/preferences.h" +#include #include #include #include @@ -27,6 +29,10 @@ struct DetectionEvent { class StreamingModel { public: + // Runtime models are heap owned and destroyed while the device is running, so freeing the arenas cannot + // depend on the owner calling unload_model() first. unload_model() is not virtual and is safe to repeat. + virtual ~StreamingModel() { this->unload_model(); } + virtual void log_model_config() = 0; virtual DetectionEvent determine_detected() = 0; @@ -51,6 +57,9 @@ class StreamingModel { /// @brief Return true if the model is enabled. bool is_enabled() const { return this->enabled_; } + /// @brief Return true if the model has usable data. A model without it can never be loaded or run. + bool has_model_data() const { return this->model_start_ != nullptr; } + bool get_unprocessed_probability_status() const { return this->unprocessed_probability_status_; } // Quantized probability cutoffs mapping 0.0 - 1.0 to 0 - 255 @@ -86,7 +95,7 @@ class StreamingModel { size_t tensor_arena_size_; std::vector recent_streaming_probabilities_; - const uint8_t *model_start_; + const uint8_t *model_start_{nullptr}; uint8_t *tensor_arena_{nullptr}; uint8_t *var_arena_{nullptr}; std::unique_ptr interpreter_; @@ -96,7 +105,7 @@ class StreamingModel { class WakeWordModel final : public StreamingModel { public: - /// @brief Constructs a wake word model object + /// @brief Constructs a wake word model object with compile-time model data /// @param id (std::string) identifier for this model /// @param model_start (const uint8_t *) pointer to the start of the model's TFLite FlatBuffer /// @param default_probability_cutoff (uint8_t) probability cutoff for acceping the wake word has been said @@ -110,6 +119,23 @@ class WakeWordModel final : public StreamingModel { size_t sliding_window_average_size, const std::string &wake_word, size_t tensor_arena_size, bool default_enabled, bool internal_only); + /// @brief Constructs a wake word model object with a runtime-downloaded model + /// @param id (std::string) identifier for this model + /// @param model_data (std::shared_ptr) owning handle to the downloaded model buffer; must be valid + /// @param default_probability_cutoff (uint8_t) probability cutoff for acceping the wake word has been said + /// @param sliding_window_average_size (size_t) the length of the sliding window computing the mean rolling + /// probability + /// @param wake_word (std::string) Friendly name of the wake word + /// @param trained_languages (std::vector) Languages the model was trained on + /// @param tensor_arena_size (size_t) Size in bytes for allocating the tensor arena + WakeWordModel(const std::string &id, std::shared_ptr model_data, uint8_t default_probability_cutoff, + size_t sliding_window_average_size, const std::string &wake_word, + std::vector trained_languages, size_t tensor_arena_size); + + // model_data_ is a member of this class, so it is destroyed before ~StreamingModel() runs. Unload here, while + // the buffer is still alive, so the interpreter is never torn down over freed model data. + ~WakeWordModel() override { this->unload_model(); } + void log_model_config() override; /// @brief Checks for the wake word by comparing the mean probability in the sliding window with the probability @@ -132,6 +158,10 @@ class WakeWordModel final : public StreamingModel { bool get_internal_only() { return this->internal_only_; } protected: + // Kept for runtime-downloaded models so the model buffer stays alive for the model's lifetime. + // Null for compiled-in models (their data lives in flash). + std::shared_ptr model_data_; + std::string id_; std::string wake_word_; std::vector trained_languages_; diff --git a/tests/components/micro_wake_word/validate.esp32-idf.yaml b/tests/components/micro_wake_word/validate.esp32-idf.yaml new file mode 100644 index 0000000000..d87b19bdcf --- /dev/null +++ b/tests/components/micro_wake_word/validate.esp32-idf.yaml @@ -0,0 +1,21 @@ +# Config-only test: micro_wake_word without any compiled-in models. Covers the optional models +# schema, which validates without a model list. Wake word models are added at runtime instead, +# which voice_assistant wires up. +substitutions: + mic_din_pin: GPIO36 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml + +microphone: + - platform: i2s_audio + id: echo_microphone + i2s_audio_id: i2s_audio_bus + i2s_din_pin: ${mic_din_pin} + adc_type: external + pdm: true + bits_per_sample: 16bit + +micro_wake_word: + microphone: echo_microphone + # models is omitted entirely, so the default empty list applies From d548454dbe2fb94824e8a68e8b68fd44aa82393a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Wed, 5 Aug 2026 17:30:46 +0300 Subject: [PATCH 1247/1815] [ln882h_ble_tracker] Automation triggers and actions (#17778) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .../components/ln882h_ble_tracker/__init__.py | 167 ++++++++++++++++- .../ln882h_ble_tracker/automation.h | 169 ++++++++++++++++++ .../ln882h_ble_tracker/ln882h_ble_tracker.cpp | 40 ++++- .../ln882h_ble_tracker/ln882h_ble_tracker.h | 17 ++ .../config/test_automations.yaml | 42 +++++ .../test_automations_codegen.py | 52 ++++++ .../test-automations.ln882x-ard.yaml | 33 ++++ 7 files changed, 517 insertions(+), 3 deletions(-) create mode 100644 esphome/components/ln882h_ble_tracker/automation.h create mode 100644 tests/component_tests/ln882h_ble_tracker/config/test_automations.yaml create mode 100644 tests/component_tests/ln882h_ble_tracker/test_automations_codegen.py create mode 100644 tests/components/ln882h_ble_tracker/test-automations.ln882x-ard.yaml diff --git a/esphome/components/ln882h_ble_tracker/__init__.py b/esphome/components/ln882h_ble_tracker/__init__.py index 30646dca9a..08d14d0de4 100644 --- a/esphome/components/ln882h_ble_tracker/__init__.py +++ b/esphome/components/ln882h_ble_tracker/__init__.py @@ -2,6 +2,7 @@ top of the ln882h_ble controller. With continuous: false nothing scans until an explicit start_scan() call.""" +from esphome import automation import esphome.codegen as cg from esphome.components import ble_device_base, ln882h_ble, ota from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW @@ -12,10 +13,19 @@ from esphome.const import ( CONF_DURATION, CONF_ID, CONF_INTERVAL, + CONF_MAC_ADDRESS, + CONF_MANUFACTURER_ID, + CONF_ON_BLE_ADVERTISE, + CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE, + CONF_ON_BLE_SERVICE_DATA_ADVERTISE, + CONF_SERVICE_UUID, + CONF_TRIGGER_ID, ) +from esphome.core import ID from esphome.types import ConfigType CONF_LN882H_BLE_ID = "ln882h_ble_id" +CONF_ON_SCAN_END = "on_scan_end" DEPENDENCIES = ["ln882x"] AUTO_LOAD = ["ble_device_base", "ln882h_ble"] @@ -26,6 +36,31 @@ LN882HBLETracker = ln882h_ble_tracker_ns.class_( "LN882HBLETracker", ble_device_base.BLEHub, cg.Component ) +StartScanAction = ln882h_ble_tracker_ns.class_("StartScanAction", automation.Action) +StopScanAction = ln882h_ble_tracker_ns.class_("StopScanAction", automation.Action) + +ESPBTDeviceConstRef = ( + cg.esphome_ns.namespace("ble_device_base") + .class_("ESPBTDevice") + .operator("ref") + .operator("const") +) +ESPBTAdvertiseTrigger = ln882h_ble_tracker_ns.class_( + "ESPBTAdvertiseTrigger", automation.Trigger.template(ESPBTDeviceConstRef) +) +adv_data_t = cg.std_vector.template(cg.uint8) +adv_data_t_const_ref = adv_data_t.operator("ref").operator("const") +BLEServiceDataAdvertiseTrigger = ln882h_ble_tracker_ns.class_( + "BLEServiceDataAdvertiseTrigger", automation.Trigger.template(adv_data_t_const_ref) +) +BLEManufacturerDataAdvertiseTrigger = ln882h_ble_tracker_ns.class_( + "BLEManufacturerDataAdvertiseTrigger", + automation.Trigger.template(adv_data_t_const_ref), +) +BLEEndOfScanTrigger = ln882h_ble_tracker_ns.class_( + "BLEEndOfScanTrigger", automation.Trigger.template() +) + # LN882H SDK reference scan rate: 100 ms interval / 50 ms window (50 % duty). SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema( @@ -33,15 +68,108 @@ SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema( ) +# UUID string length -> setter width. 16/32-bit go out as plain hex literals, +# 128-bit as a reversed byte array (BLE wire order). Keyed exhaustively so an +# impossible length fails as a KeyError instead of silently picking a width +# (bt_uuid validation upstream only ever produces these three). +_UUID_WIDTHS = { + len(ble_device_base.BT_UUID16_FORMAT): "16", + len(ble_device_base.BT_UUID32_FORMAT): "32", + len(ble_device_base.BT_UUID128_FORMAT): "128", +} + CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(LN882HBLETracker), cv.GenerateID(CONF_LN882H_BLE_ID): cv.use_id(ln882h_ble.LN882HBLE), cv.Optional(CONF_SCAN_PARAMETERS, default={}): SCAN_PARAMETERS_SCHEMA, + cv.Optional(CONF_ON_BLE_ADVERTISE): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ESPBTAdvertiseTrigger), + cv.Optional(CONF_MAC_ADDRESS): cv.ensure_list(cv.mac_address), + } + ), + cv.Optional(CONF_ON_BLE_SERVICE_DATA_ADVERTISE): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + BLEServiceDataAdvertiseTrigger + ), + cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, + cv.Required(CONF_SERVICE_UUID): ble_device_base.bt_uuid, + } + ), + cv.Optional( + CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE + ): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + BLEManufacturerDataAdvertiseTrigger + ), + cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, + cv.Required(CONF_MANUFACTURER_ID): ble_device_base.bt_uuid, + } + ), + cv.Optional(CONF_ON_SCAN_END): automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(BLEEndOfScanTrigger)} + ), } ).extend(cv.COMPONENT_SCHEMA) +# Triggers register as ble_device_base listeners in their constructors; count +# them where they are created so the StaticVector cannot be undersized. Shares +# the define with register_ble_device() via the core slot-counter factory. +_count_listener = cg.slot_counter(ble_device_base.LISTENER_COUNT_DEFINE) + + +@automation.register_action( + "ln882h_ble_tracker.start_scan", + StartScanAction, + cv.Schema( + { + cv.GenerateID(): cv.use_id(LN882HBLETracker), + cv.Optional(CONF_CONTINUOUS): cv.templatable(cv.boolean), + } + ), + synchronous=True, +) +async def start_scan_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: list, +) -> cg.MockObj: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + if (continuous := config.get(CONF_CONTINUOUS)) is not None: + template_ = await cg.templatable(continuous, args, cg.bool_) + cg.add(var.set_continuous(template_)) + return var + + +@automation.register_action( + "ln882h_ble_tracker.stop_scan", + StopScanAction, + automation.maybe_simple_id( + cv.Schema( + { + cv.GenerateID(): cv.use_id(LN882HBLETracker), + } + ) + ), + synchronous=True, +) +async def stop_scan_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: list, +) -> cg.MockObj: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -60,4 +188,41 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_scan_window(ble_device_base.to_ble_units(scan[CONF_WINDOW]))) cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds)) cg.add(var.set_scan_active(scan[CONF_ACTIVE])) - cg.add(var.set_scan_continuous(scan[CONF_CONTINUOUS])) + cg.add(var.set_configured_continuous(scan[CONF_CONTINUOUS])) + + for conf in config.get(CONF_ON_BLE_ADVERTISE, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + if (macs := conf.get(CONF_MAC_ADDRESS)) is not None: + cg.add(trigger.set_addresses([it.as_hex for it in macs])) + await automation.build_automation(trigger, [(ESPBTDeviceConstRef, "x")], conf) + _count_listener() + + for trigger_key, uuid_key, setter_prefix in ( + (CONF_ON_BLE_SERVICE_DATA_ADVERTISE, CONF_SERVICE_UUID, "set_service_uuid"), + ( + CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE, + CONF_MANUFACTURER_ID, + "set_manufacturer_uuid", + ), + ): + for conf in config.get(trigger_key, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + uuid = conf[uuid_key] + width = _UUID_WIDTHS[len(uuid)] + value = ( + ble_device_base.as_hex(uuid) + if width != "128" + else ble_device_base.as_reversed_hex_array(uuid) + ) + cg.add(getattr(trigger, f"{setter_prefix}{width}")(value)) + if (mac := conf.get(CONF_MAC_ADDRESS)) is not None: + cg.add(trigger.set_address(mac.as_hex)) + await automation.build_automation( + trigger, [(adv_data_t_const_ref, "x")], conf + ) + _count_listener() + + for conf in config.get(CONF_ON_SCAN_END, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [], conf) + _count_listener() diff --git a/esphome/components/ln882h_ble_tracker/automation.h b/esphome/components/ln882h_ble_tracker/automation.h new file mode 100644 index 0000000000..43f526e064 --- /dev/null +++ b/esphome/components/ln882h_ble_tracker/automation.h @@ -0,0 +1,169 @@ +// Automation triggers and actions for ln882h_ble_tracker: triggers follow the +// esp32_ble_tracker design; only the scan-control actions are +// platform-specific. + +#pragma once + +#ifdef USE_LIBRETINY + +#include "ln882h_ble_tracker.h" + +#include "esphome/core/automation.h" +#include "esphome/core/helpers.h" + +#include +#include + +namespace esphome::ln882h_ble_tracker { + +template class StartScanAction final : public Action, public Parented { + public: + TEMPLATABLE_VALUE(bool, continuous) + void play(const Ts &...x) override { + // With continuous: set, the action wins. Without it, the configured value + // is used - stop_scan() clears the runtime flag permanently, so a bare + // stop_scan/start_scan pair would otherwise never resume continuous mode. + const bool want = + this->continuous_.has_value() ? this->continuous_.value(x...) : this->parent_->configured_continuous(); + if (this->parent_->scan_running()) { + // Same mode on a running scan is a no-op (esp32 parity): re-anchoring + // the duration window here would let a repeated action keep a one-shot + // scan alive forever. A real mode switch re-anchors so a change to + // one-shot runs a full duration from now. + if (want != this->parent_->scan_continuous()) { + this->parent_->set_scan_continuous(want); + this->parent_->restart_scan_duration(); + } + return; + } + this->parent_->set_scan_continuous(want); + this->parent_->start_scan(); + } +}; + +template class StopScanAction final : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->stop_scan(); } +}; + +// --------------------------------------------------------------------------- +// Automation triggers. +// +// Each trigger is a ble_device_base::ESPBTDeviceListener registered on the hub — +// the same design as esp32_ble_tracker, where the triggers sit in the listener +// list and their parse_device() return feeds the "Found device" suppression. +// --------------------------------------------------------------------------- + +// on_ble_advertise: fires on every BLE advertisement, optionally filtered to one or more MACs. +class ESPBTAdvertiseTrigger final : public Trigger, + public ble_device_base::ESPBTDeviceListener { + public: + explicit ESPBTAdvertiseTrigger(LN882HBLETracker *parent) { parent->register_listener(this); } + + void set_addresses(std::initializer_list addresses) { this->addresses_ = addresses; } + + bool parse_device(const ble_device_base::ESPBTDevice &device) override { + if (!this->addresses_.empty() && std::find(this->addresses_.begin(), this->addresses_.end(), + device.address_uint64()) == this->addresses_.end()) { + return false; + } + this->trigger(device); + return true; + } + + protected: + FixedVector addresses_; +}; + +// on_ble_service_data_advertise: fires when an advertisement contains service +// data for the given UUID. Optional single-MAC filter. +class BLEServiceDataAdvertiseTrigger final : public Trigger, + public ble_device_base::ESPBTDeviceListener { + public: + explicit BLEServiceDataAdvertiseTrigger(LN882HBLETracker *parent) { parent->register_listener(this); } + + void set_service_uuid16(uint64_t uuid) { + this->uuid_ = ble_device_base::ESPBTUUID::from_uint16(static_cast(uuid)); + } + void set_service_uuid32(uint64_t uuid) { + this->uuid_ = ble_device_base::ESPBTUUID::from_uint32(static_cast(uuid)); + } + void set_service_uuid128(const uint8_t *uuid) { this->uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); } + + void set_address(uint64_t address) { + this->address_ = address; + this->has_address_ = true; + } + + bool parse_device(const ble_device_base::ESPBTDevice &device) override { + if (this->has_address_ && device.address_uint64() != this->address_) { + return false; + } + for (const auto &sd : device.get_service_datas()) { + if (sd.uuid == this->uuid_) { + this->trigger(sd.data); + return true; + } + } + return false; + } + + protected: + ble_device_base::ESPBTUUID uuid_{}; + uint64_t address_{0}; + bool has_address_{false}; +}; + +// on_ble_manufacturer_data_advertise: fires when an advertisement contains +// manufacturer data for the given ID. Optional single-MAC filter. +class BLEManufacturerDataAdvertiseTrigger final : public Trigger, + public ble_device_base::ESPBTDeviceListener { + public: + explicit BLEManufacturerDataAdvertiseTrigger(LN882HBLETracker *parent) { parent->register_listener(this); } + + void set_manufacturer_uuid16(uint64_t uuid) { + this->uuid_ = ble_device_base::ESPBTUUID::from_uint16(static_cast(uuid)); + } + void set_manufacturer_uuid32(uint64_t uuid) { + this->uuid_ = ble_device_base::ESPBTUUID::from_uint32(static_cast(uuid)); + } + void set_manufacturer_uuid128(const uint8_t *uuid) { this->uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); } + + void set_address(uint64_t address) { + this->address_ = address; + this->has_address_ = true; + } + + bool parse_device(const ble_device_base::ESPBTDevice &device) override { + if (this->has_address_ && device.address_uint64() != this->address_) { + return false; + } + for (const auto &md : device.get_manufacturer_datas()) { + if (md.uuid == this->uuid_) { + this->trigger(md.data); + return true; + } + } + return false; + } + + protected: + ble_device_base::ESPBTUUID uuid_{}; + uint64_t address_{0}; + bool has_address_{false}; +}; + +// on_scan_end: fires whenever a scan period ends (duration elapsed or stop_scan +// called). A listener whose on_scan_end() hook fires the trigger — never claims +// devices (parse_device always returns false). +class BLEEndOfScanTrigger final : public Trigger<>, public ble_device_base::ESPBTDeviceListener { + public: + explicit BLEEndOfScanTrigger(LN882HBLETracker *parent) { parent->register_listener(this); } + + bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; } + void on_scan_end() override { this->trigger(); } +}; + +} // namespace esphome::ln882h_ble_tracker + +#endif // USE_LIBRETINY diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp index b98ad228a8..90be341820 100644 --- a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp @@ -22,7 +22,10 @@ void LN882HBLETracker::setup() { // Receive the controller's scan reports; the controller queues them from the // rw task and delivers here on the main task. this->parent_->register_scan_listener(this); - if (!this->scan_continuous_) { + // scan_running_ check: an on_boot start_scan action (priority 600) runs + // before this setup() (200) and enable_loop() is a no-op pre-setup — parking + // the loop here would strand that already-running scan. + if (!this->scan_continuous_ && !this->scan_running_ && !this->pending_start_) { // Say so once: with continuous: false nothing scans until an explicit // start_scan() — silence here reads as a broken scanner. ESP_LOGD(TAG, "Scanning not started (continuous: false) - waiting for an explicit start_scan()"); @@ -61,6 +64,14 @@ void LN882HBLETracker::on_ota_global_state(ota::OTAState state, float progress, #endif // USE_OTA_STATE_LISTENER void LN882HBLETracker::loop() { + if (this->pending_start_) { + // A start_scan latched before the controller's setup(); safe now — loop() + // only runs after every component set up. + this->pending_start_ = false; + if (!this->scan_running_) { + this->start_scan_(); + } + } // Flush pending scannable advertisements whose scan response never arrived // (device didn't answer / frame lost) — delivered unmerged after the timeout. // Main-task only, like every consumer of pending_adv_. @@ -263,12 +274,34 @@ void LN882HBLETracker::process_adv_(const uint8_t *mac, int8_t rssi, uint8_t add void LN882HBLETracker::start_scan() { // Mirrors esp32_ble_tracker::start_scan(): caller sets scan_continuous_ via // set_scan_continuous() first, then calls start_scan() to begin scanning. + if (!this->parent_->is_ready()) { + // An on_boot automation (priority 600) runs before the controller's + // setup() has resolved the BLE MAC; scan_start() now would rw_init() the + // all-zero address and bring BLE up before WiFi. Latch; loop() applies + // the start once every setup() has run. + this->pending_start_ = true; + return; + } if (!this->scan_running_) { this->start_scan_(); } } +void LN882HBLETracker::restart_scan_duration() { + if (!this->scan_running_) + return; + // Re-anchor only the one-shot duration clock. scan_period_start_ (the + // continuous-mode on_scan_end period) is deliberately left alone: a + // start_scan action fired more often than scan_duration_ would otherwise + // suppress on_scan_end indefinitely — and absence detection (ble_rssi's NAN + // publish) rides on that period. + this->scan_start_time_ = millis(); +} + void LN882HBLETracker::stop_scan() { + // Cancel a start latched before the controller's setup(); without this an + // on_boot start_scan/stop_scan pair would still start at the first loop(). + this->pending_start_ = false; this->scan_continuous_ = false; this->stop_scan_(); } @@ -308,7 +341,10 @@ void LN882HBLETracker::stop_scan_() { // scanner failing to come back up. ESP_LOGD(TAG, "BLE scan stopped"); this->end_scan_period_(millis()); // also resets the period clock so on_scan_end does not double-fire - if (!this->scan_continuous_) { + // scan_running_ re-check: an on_scan_end automation runs synchronously inside + // end_scan_period_() and may have called start_scan() — parking the loop then + // would leave the radio scanning with no period timing or pending-adv sweep. + if (!this->scan_continuous_ && !this->scan_running_) { // Nothing left to time; start_scan_() re-enables the loop. this->disable_loop(); } diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h index 43328a288d..1ad36c40d4 100644 --- a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h @@ -52,7 +52,22 @@ class LN882HBLETracker : public Component, void set_scan_interval(uint16_t scan_interval) { this->scan_interval_ = scan_interval; } void set_scan_window(uint16_t scan_window) { this->scan_window_ = scan_window; } void set_scan_duration(uint32_t scan_duration) { this->scan_duration_ = scan_duration; } + /// Set from YAML (scan_parameters.continuous); also the value + /// configured_continuous() reports and a bare start_scan action restores. + void set_configured_continuous(bool scan_continuous) { + this->scan_continuous_ = scan_continuous; + this->scan_continuous_configured_ = scan_continuous; + } + /// Runtime control (esp32_ble_tracker lambda parity): does not change the + /// configured value, so configured_continuous() still reports what YAML + /// asked for. void set_scan_continuous(bool scan_continuous) { this->scan_continuous_ = scan_continuous; } + bool scan_continuous() const { return this->scan_continuous_; } + bool configured_continuous() const { return this->scan_continuous_configured_; } + /// Re-anchor the one-shot duration clock of a running scan to now — used + /// when an action changes the scan mode without stopping the radio. The + /// continuous-mode on_scan_end period is deliberately not touched. + void restart_scan_duration(); // ---- Public scan control ---- // Mirrors esp32_ble_tracker: set_scan_continuous() + start_scan() / stop_scan(). @@ -125,6 +140,8 @@ class LN882HBLETracker : public Component, uint16_t scan_window_{80}; // 80 × 0.625 ms = 50 ms (SDK SCAN_WINDOW_DEF; 50/100 = 50 %) uint32_t scan_duration_{300000}; bool scan_continuous_{true}; + bool pending_start_{false}; // start_scan() latched before the controller's setup() + bool scan_continuous_configured_{true}; // YAML value; stop_scan() must not lose it #ifdef USE_OTA_STATE_LISTENER bool scan_continuous_before_ota_{false}; // continuous mode saved at OTA start, restored on OTA failure bool scan_running_before_ota_{false}; // one-shot scan running at OTA start, restarted on OTA failure diff --git a/tests/component_tests/ln882h_ble_tracker/config/test_automations.yaml b/tests/component_tests/ln882h_ble_tracker/config/test_automations.yaml new file mode 100644 index 0000000000..d83e6c0883 --- /dev/null +++ b/tests/component_tests/ln882h_ble_tracker/config/test_automations.yaml @@ -0,0 +1,42 @@ +esphome: + name: ln-trigger-codegen + on_boot: + then: + - ln882h_ble_tracker.start_scan: + continuous: true + # Bare form: restores the configured scan_parameters mode — no + # set_continuous emitted (asserted in the codegen test). + - ln882h_ble_tracker.start_scan: + - ln882h_ble_tracker.stop_scan: + +ln882x: + board: generic-ln882h + +ln882h_ble_tracker: + on_ble_advertise: + - mac_address: + - AC:37:43:77:5F:4C + - 11:22:33:44:55:66 + then: + - lambda: 'ESP_LOGD("t", "%s", x.address_str().c_str());' + on_ble_service_data_advertise: + - service_uuid: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD + mac_address: AC:37:43:77:5F:4C + then: + - lambda: 'ESP_LOGD("t", "%zu", x.size());' + - service_uuid: ABCDABCD + then: + - lambda: 'ESP_LOGD("t", "%zu", x.size());' + on_ble_manufacturer_data_advertise: + - manufacturer_id: ABCD + then: + - lambda: 'ESP_LOGD("t", "%zu", x.size());' + - manufacturer_id: ABCDABCD + then: + - lambda: 'ESP_LOGD("t", "%zu", x.size());' + - manufacturer_id: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD + then: + - lambda: 'ESP_LOGD("t", "%zu", x.size());' + on_scan_end: + - then: + - lambda: 'ESP_LOGD("t", "end");' diff --git a/tests/component_tests/ln882h_ble_tracker/test_automations_codegen.py b/tests/component_tests/ln882h_ble_tracker/test_automations_codegen.py new file mode 100644 index 0000000000..43c14c8054 --- /dev/null +++ b/tests/component_tests/ln882h_ble_tracker/test_automations_codegen.py @@ -0,0 +1,52 @@ +"""Codegen tests for the tracker automations: the generated main is the +automated check on the setter calls and the listener accounting (the +test.ln882x-ard.yaml compile fixture proves linkage, not codegen shape).""" + +from collections.abc import Callable +from pathlib import Path +import re + +from esphome.components import ble_device_base +from tests.component_tests.helpers import get_define_value + + +def test_trigger_codegen( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("test_automations.yaml")) + + # on_ble_advertise: multi-mac filter (two addresses in one initializer list) + assert "set_addresses({0xAC3743775F4CULL, 0x112233445566ULL})" in main_cpp + # 128-bit service uuid goes out reversed (BLE wire order); single-mac filter + assert ( + "set_service_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB," + "0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp + ) + assert "set_address(0xAC3743775F4CULL)" in main_cpp + # 32-bit middle branch of the width dispatch + assert "set_service_uuid32(0xABCDABCDULL)" in main_cpp + # All three manufacturer widths: getattr() builds these names as strings, + # so a misspelling only ever fails here. + assert "set_manufacturer_uuid16(0xABCDULL)" in main_cpp + assert "set_manufacturer_uuid32(0xABCDABCDULL)" in main_cpp + assert ( + "set_manufacturer_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB," + "0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp + ) + # scan-control actions: templatable continuous lambda + parented actions. + # Exactly one set_continuous: the bare start_scan emits none, pinning the + # restore-configured-mode divergence from esp32 against a future default=. + assert main_cpp.count("->set_continuous(") == 1 + assert "startscanaction_id->set_continuous(" in main_cpp + assert "stopscanaction_id->set_parent(" in main_cpp + # Constructor call, not just the declaration: the parent argument is what + # registers the trigger as a listener. + assert re.search( + r"new\(\w+\) ln882h_ble_tracker::BLEEndOfScanTrigger\(\w+\)", main_cpp + ) + + # Seven triggers register as listeners; an undercount silently drops the + # last trigger at runtime (StaticVector::push_back past capacity), so the + # define is the assertion that matters most. + assert get_define_value(ble_device_base.LISTENER_COUNT_DEFINE) == "7" diff --git a/tests/components/ln882h_ble_tracker/test-automations.ln882x-ard.yaml b/tests/components/ln882h_ble_tracker/test-automations.ln882x-ard.yaml new file mode 100644 index 0000000000..3291ef9d8b --- /dev/null +++ b/tests/components/ln882h_ble_tracker/test-automations.ln882x-ard.yaml @@ -0,0 +1,33 @@ +packages: + ln882h_ble_tracker: !include common.yaml + +esphome: + on_boot: + then: + - ln882h_ble_tracker.start_scan + - ln882h_ble_tracker.start_scan: + continuous: true + - ln882h_ble_tracker.start_scan: + continuous: !lambda return false; + - ln882h_ble_tracker.stop_scan + +ln882h_ble_tracker: + on_ble_advertise: + - mac_address: AC:37:43:77:5F:4C + then: + - lambda: |- + ESP_LOGD("main", "The device address is %s", x.address_str().c_str()); + on_ble_service_data_advertise: + - service_uuid: ABCD + then: + - lambda: |- + ESP_LOGD("main", "Length of service data is %zu", x.size()); + on_ble_manufacturer_data_advertise: + - manufacturer_id: ABCD + then: + - lambda: |- + ESP_LOGD("main", "Length of manufacturer data is %zu", x.size()); + on_scan_end: + - then: + - lambda: |- + ESP_LOGD("main", "Scan ended"); From 0496627d2b94cd02fcb4e9f26b2d53a0c05e4282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Wed, 5 Aug 2026 17:31:51 +0300 Subject: [PATCH 1248/1815] [bk72xx_ble_tracker] Automation triggers and actions (#17776) --- .../components/bk72xx_ble_tracker/__init__.py | 124 +++++++++++++++-- .../bk72xx_ble_tracker/automation.h | 48 +++++++ .../bk72xx_ble_tracker/bk72xx_ble_tracker.cpp | 11 ++ .../bk72xx_ble_tracker/bk72xx_ble_tracker.h | 16 +++ .../components/ble_device_base/automation.h | 117 ++++++++++++++++ .../components/ble_device_base/automation.py | 128 ++++++++++++++++++ esphome/components/const/__init__.py | 1 + .../components/esp32_ble_tracker/__init__.py | 3 +- .../bk72xx_ble_tracker/__init__.py | 0 .../config/test_automations.yaml | 39 ++++++ .../test_automations_codegen.py | 54 ++++++++ .../validate-automations.bk72xx-ard.yaml | 52 +++++++ 12 files changed, 583 insertions(+), 10 deletions(-) create mode 100644 esphome/components/bk72xx_ble_tracker/automation.h create mode 100644 esphome/components/ble_device_base/automation.h create mode 100644 esphome/components/ble_device_base/automation.py create mode 100644 tests/component_tests/bk72xx_ble_tracker/__init__.py create mode 100644 tests/component_tests/bk72xx_ble_tracker/config/test_automations.yaml create mode 100644 tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py create mode 100644 tests/components/bk72xx_ble_tracker/validate-automations.bk72xx-ard.yaml diff --git a/esphome/components/bk72xx_ble_tracker/__init__.py b/esphome/components/bk72xx_ble_tracker/__init__.py index c000d6e5a3..f10fe8ab6e 100644 --- a/esphome/components/bk72xx_ble_tracker/__init__.py +++ b/esphome/components/bk72xx_ble_tracker/__init__.py @@ -12,18 +12,30 @@ Scan modes: Use this when the radio is dedicated to BLE. continuous: false — a started scan runs for `duration` ms, then stops. The FIRST start is external too: nothing in this component - starts a non-continuous scan on boot, so until the - automation actions land (follow-up PR) the radio stays - idle. start_scan() is called from code (e.g. an api - client-connected automation) so the single-core radio - can service WiFi in between scans. + starts a non-continuous scan on boot — the radio stays + idle until bk72xx_ble_tracker.start_scan fires (e.g. + from an api client-connected automation), so the + single-core radio can service WiFi in between scans. """ +from esphome import automation import esphome.codegen as cg from esphome.components import bk72xx_ble, ble_device_base, ota -from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW +from esphome.components.ble_device_base import automation as ble_automation +from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW import esphome.config_validation as cv -from esphome.const import CONF_CONTINUOUS, CONF_DURATION, CONF_ID, CONF_INTERVAL +from esphome.const import ( + CONF_CONTINUOUS, + CONF_DURATION, + CONF_ID, + CONF_INTERVAL, + CONF_MANUFACTURER_ID, + CONF_ON_BLE_ADVERTISE, + CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE, + CONF_ON_BLE_SERVICE_DATA_ADVERTISE, + CONF_SERVICE_UUID, +) +from esphome.core import ID from esphome.types import ConfigType CONF_BK72XX_BLE_ID = "bk72xx_ble_id" @@ -37,6 +49,14 @@ BK72xxBLETracker = bk72xx_ble_tracker_ns.class_( "BK72xxBLETracker", ble_device_base.BLEHub, cg.Component ) +StartScanAction = bk72xx_ble_tracker_ns.class_("StartScanAction", automation.Action) +StopScanAction = bk72xx_ble_tracker_ns.class_("StopScanAction", automation.Action) + +ESPBTAdvertiseTrigger = ble_automation.ESPBTAdvertiseTrigger +BLEServiceDataAdvertiseTrigger = ble_automation.BLEServiceDataAdvertiseTrigger +BLEManufacturerDataAdvertiseTrigger = ble_automation.BLEManufacturerDataAdvertiseTrigger +BLEEndOfScanTrigger = ble_automation.BLEEndOfScanTrigger + # interval defaults to the BK reference scan rate — 100 ms with the shared 30 ms # window, a 30 % duty cycle. Converted to the controller's 0.625 ms BLE units in @@ -48,10 +68,79 @@ CONFIG_SCHEMA = cv.Schema( cv.GenerateID(): cv.declare_id(BK72xxBLETracker), cv.GenerateID(CONF_BK72XX_BLE_ID): cv.use_id(bk72xx_ble.BK72xxBLE), cv.Optional(CONF_SCAN_PARAMETERS, default={}): SCAN_PARAMETERS_SCHEMA, + cv.Optional(CONF_ON_BLE_ADVERTISE): ble_automation.advertise_trigger_schema( + ESPBTAdvertiseTrigger + ), + cv.Optional( + CONF_ON_BLE_SERVICE_DATA_ADVERTISE + ): ble_automation.uuid_trigger_schema( + BLEServiceDataAdvertiseTrigger, + {cv.Required(CONF_SERVICE_UUID): ble_device_base.bt_uuid}, + ), + cv.Optional( + CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE + ): ble_automation.uuid_trigger_schema( + BLEManufacturerDataAdvertiseTrigger, + {cv.Required(CONF_MANUFACTURER_ID): ble_device_base.bt_uuid}, + ), + cv.Optional(CONF_ON_SCAN_END): ble_automation.scan_end_trigger_schema( + BLEEndOfScanTrigger + ), } ).extend(cv.COMPONENT_SCHEMA) +@automation.register_action( + "bk72xx_ble_tracker.start_scan", + StartScanAction, + cv.Schema( + { + cv.GenerateID(): cv.use_id(BK72xxBLETracker), + # Optional with no default, unlike esp32_ble_tracker: omitting it + # keeps whatever scan_parameters.continuous configured, instead of + # silently forcing one-shot. + cv.Optional(CONF_CONTINUOUS): cv.templatable(cv.boolean), + } + ), + synchronous=True, +) +async def start_scan_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: list, +) -> cg.MockObj: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + if (continuous := config.get(CONF_CONTINUOUS)) is not None: + template_ = await cg.templatable(continuous, args, cg.bool_) + cg.add(var.set_continuous(template_)) + return var + + +@automation.register_action( + "bk72xx_ble_tracker.stop_scan", + StopScanAction, + automation.maybe_simple_id( + cv.Schema( + { + cv.GenerateID(): cv.use_id(BK72xxBLETracker), + } + ) + ), + synchronous=True, +) +async def stop_scan_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: list, +) -> cg.MockObj: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -69,4 +158,23 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_scan_interval(ble_device_base.to_ble_units(scan[CONF_INTERVAL]))) cg.add(var.set_scan_window(ble_device_base.to_ble_units(scan[CONF_WINDOW]))) cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds)) - cg.add(var.set_scan_continuous(scan[CONF_CONTINUOUS])) + cg.add(var.set_configured_continuous(scan[CONF_CONTINUOUS])) + + for conf in config.get(CONF_ON_BLE_ADVERTISE, []): + await ble_automation.advertise_trigger_to_code(conf, var) + + for trigger_key, uuid_key, setter_prefix in ( + (CONF_ON_BLE_SERVICE_DATA_ADVERTISE, CONF_SERVICE_UUID, "set_service_uuid"), + ( + CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE, + CONF_MANUFACTURER_ID, + "set_manufacturer_uuid", + ), + ): + for conf in config.get(trigger_key, []): + await ble_automation.uuid_trigger_to_code( + conf, var, uuid_key, setter_prefix + ) + + for conf in config.get(CONF_ON_SCAN_END, []): + await ble_automation.scan_end_trigger_to_code(conf, var) diff --git a/esphome/components/bk72xx_ble_tracker/automation.h b/esphome/components/bk72xx_ble_tracker/automation.h new file mode 100644 index 0000000000..5a49fed04a --- /dev/null +++ b/esphome/components/bk72xx_ble_tracker/automation.h @@ -0,0 +1,48 @@ +// Automation triggers and actions for bk72xx_ble_tracker: triggers are the +// neutral ble_device_base classes; only the scan-control actions are +// platform-specific. + +#pragma once + +#ifdef USE_LIBRETINY + +#include "bk72xx_ble_tracker.h" + +#include "esphome/components/ble_device_base/automation.h" +#include "esphome/core/automation.h" + +namespace esphome::bk72xx_ble_tracker { + +template class StartScanAction final : public Action, public Parented { + public: + TEMPLATABLE_VALUE(bool, continuous) + void play(const Ts &...x) override { + // With continuous: set, the action wins. Without it, the configured value + // is used - stop_scan() clears the runtime flag permanently, so a bare + // stop_scan/start_scan pair would otherwise never resume continuous mode. + const bool want = + this->continuous_.has_value() ? this->continuous_.value(x...) : this->parent_->configured_continuous(); + if (this->parent_->scan_running()) { + // Same mode on a running scan is a no-op (esp32 parity): re-anchoring + // the duration window here would let a repeated action keep a one-shot + // scan alive forever. A real mode switch re-anchors so a change to + // one-shot runs a full duration from now. + if (want != this->parent_->scan_continuous()) { + this->parent_->set_scan_continuous(want); + this->parent_->restart_scan_duration(); + } + return; + } + this->parent_->set_scan_continuous(want); + this->parent_->start_scan(); + } +}; + +template class StopScanAction final : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->stop_scan(); } +}; + +} // namespace esphome::bk72xx_ble_tracker + +#endif // USE_LIBRETINY diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp index 2bc91cbc6f..c859f22c61 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp @@ -210,6 +210,17 @@ void BK72xxBLETracker::start_scan() { this->try_start_with_backoff_(millis(), /* force= */ true); } +void BK72xxBLETracker::restart_scan_duration() { + if (!this->scan_running_) + return; + // Re-anchor only the one-shot duration clock. scan_period_start_ (the + // continuous-mode on_scan_end period) is deliberately left alone: a + // start_scan action fired more often than scan_duration_ would otherwise + // suppress on_scan_end indefinitely — and absence detection (ble_rssi's NAN + // publish) rides on that period. + this->scan_start_time_ = millis(); +} + void BK72xxBLETracker::stop_scan() { this->scan_continuous_ = false; this->scan_requested_ = false; // also cancels a pending (not yet successful) start diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h index 1905e36f94..67e4467c77 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h @@ -70,7 +70,22 @@ class BK72xxBLETracker : public Component, void set_scan_interval(uint32_t scan_interval) { this->scan_interval_ = scan_interval; } void set_scan_window(uint32_t scan_window) { this->scan_window_ = scan_window; } void set_scan_duration(uint32_t scan_duration) { this->scan_duration_ = scan_duration; } + /// Set from YAML (scan_parameters.continuous); also the value + /// configured_continuous() reports and a bare start_scan action restores. + void set_configured_continuous(bool scan_continuous) { + this->scan_continuous_ = scan_continuous; + this->scan_continuous_configured_ = scan_continuous; + } + /// Runtime control (esp32_ble_tracker lambda parity): does not change the + /// configured value, so configured_continuous() still reports what YAML + /// asked for. void set_scan_continuous(bool scan_continuous) { this->scan_continuous_ = scan_continuous; } + bool scan_continuous() const { return this->scan_continuous_; } + bool configured_continuous() const { return this->scan_continuous_configured_; } + /// Re-anchor the one-shot duration clock of a running scan to now — used + /// when an action changes the scan mode without stopping the radio. The + /// continuous-mode on_scan_end period is deliberately not touched. + void restart_scan_duration(); // ---- Public scan control ---- // Mirrors esp32_ble_tracker: set_scan_continuous() + start_scan() / stop_scan(). @@ -134,6 +149,7 @@ class BK72xxBLETracker : public Component, uint32_t scan_window_{48}; // 48 × 0.625 ms = 30 ms (30/100 = 30 %) uint32_t scan_duration_{300000}; bool scan_continuous_{true}; + bool scan_continuous_configured_{true}; // YAML value; stop_scan() must not lose it #ifdef USE_OTA_STATE_LISTENER bool scan_continuous_before_ota_{false}; // continuous mode saved at OTA start, restored on OTA failure bool scan_requested_before_ota_{false}; // pending one-shot latch saved at OTA start, re-latched on OTA failure diff --git a/esphome/components/ble_device_base/automation.h b/esphome/components/ble_device_base/automation.h new file mode 100644 index 0000000000..507b3278d9 --- /dev/null +++ b/esphome/components/ble_device_base/automation.h @@ -0,0 +1,117 @@ +// Platform-neutral BLE advertisement triggers: ESPBTDeviceListener subclasses +// registered on a BLEHub, exposed by each tracker under its own automation +// names. parse_device()'s return feeds the "Found device" suppression. + +#pragma once + +#include "ble_device.h" +#include "ble_hub.h" + +#include "esphome/core/automation.h" +#include "esphome/core/helpers.h" + +#include +#include + +namespace esphome::ble_device_base { + +// on_ble_advertise: fires on every BLE advertisement, optionally filtered to one or more MACs. +class ESPBTAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { + public: + explicit ESPBTAdvertiseTrigger(BLEHub *parent) { parent->register_listener(this); } + + void set_addresses(std::initializer_list addresses) { this->addresses_ = addresses; } + + bool parse_device(const ESPBTDevice &device) override { + if (!this->addresses_.empty() && std::find(this->addresses_.begin(), this->addresses_.end(), + device.address_uint64()) == this->addresses_.end()) { + return false; + } + this->trigger(device); + return true; + } + + protected: + FixedVector addresses_; +}; + +// on_ble_service_data_advertise: fires when an advertisement contains service +// data for the given UUID. Optional single-MAC filter. +class BLEServiceDataAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { + public: + explicit BLEServiceDataAdvertiseTrigger(BLEHub *parent) { parent->register_listener(this); } + + void set_service_uuid16(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint16(static_cast(uuid)); } + void set_service_uuid32(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint32(static_cast(uuid)); } + void set_service_uuid128(const uint8_t *uuid) { this->uuid_ = ESPBTUUID::from_raw(uuid); } + + void set_address(uint64_t address) { + this->address_ = address; + this->has_address_ = true; + } + + bool parse_device(const ESPBTDevice &device) override { + if (this->has_address_ && device.address_uint64() != this->address_) { + return false; + } + for (const auto &sd : device.get_service_datas()) { + if (sd.uuid == this->uuid_) { + this->trigger(sd.data); + return true; + } + } + return false; + } + + protected: + ESPBTUUID uuid_{}; + uint64_t address_{0}; + bool has_address_{false}; +}; + +// on_ble_manufacturer_data_advertise: fires when an advertisement contains +// manufacturer data for the given ID. Optional single-MAC filter. +class BLEManufacturerDataAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { + public: + explicit BLEManufacturerDataAdvertiseTrigger(BLEHub *parent) { parent->register_listener(this); } + + void set_manufacturer_uuid16(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint16(static_cast(uuid)); } + void set_manufacturer_uuid32(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint32(static_cast(uuid)); } + void set_manufacturer_uuid128(const uint8_t *uuid) { this->uuid_ = ESPBTUUID::from_raw(uuid); } + + void set_address(uint64_t address) { + this->address_ = address; + this->has_address_ = true; + } + + bool parse_device(const ESPBTDevice &device) override { + if (this->has_address_ && device.address_uint64() != this->address_) { + return false; + } + for (const auto &md : device.get_manufacturer_datas()) { + if (md.uuid == this->uuid_) { + this->trigger(md.data); + return true; + } + } + return false; + } + + protected: + ESPBTUUID uuid_{}; + uint64_t address_{0}; + bool has_address_{false}; +}; + +// on_scan_end: fires whenever a scan period ends (duration elapsed or stop +// requested). A listener whose on_scan_end() hook fires the trigger — never +// claims devices (parse_device always returns false). +class BLEEndOfScanTrigger final : public Trigger<>, public ESPBTDeviceListener { + public: + explicit BLEEndOfScanTrigger(BLEHub *parent) { parent->register_listener(this); } + + bool parse_device(const ESPBTDevice &device) override { return false; } + void on_scan_end() override { this->trigger(); } +}; + +} // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/automation.py b/esphome/components/ble_device_base/automation.py new file mode 100644 index 0000000000..eb63061a8d --- /dev/null +++ b/esphome/components/ble_device_base/automation.py @@ -0,0 +1,128 @@ +"""Shared codegen for the neutral BLE advertisement triggers (automation.h).""" + +from typing import Any + +from esphome import automation +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_MAC_ADDRESS, CONF_TRIGGER_ID +from esphome.cpp_generator import MockObjClass +from esphome.types import ConfigType + +from . import ( + BT_UUID16_FORMAT, + BT_UUID32_FORMAT, + BT_UUID128_FORMAT, + LISTENER_COUNT_DEFINE, + as_hex, + as_reversed_hex_array, + ble_device_base_ns, +) + +adv_data_t = cg.std_vector.template(cg.uint8) +adv_data_t_const_ref = adv_data_t.operator("ref").operator("const") +ESPBTDeviceConstRef = ( + ble_device_base_ns.class_("ESPBTDevice").operator("ref").operator("const") +) + +ESPBTAdvertiseTrigger = ble_device_base_ns.class_( + "ESPBTAdvertiseTrigger", automation.Trigger.template(ESPBTDeviceConstRef) +) +BLEServiceDataAdvertiseTrigger = ble_device_base_ns.class_( + "BLEServiceDataAdvertiseTrigger", automation.Trigger.template(adv_data_t_const_ref) +) +BLEManufacturerDataAdvertiseTrigger = ble_device_base_ns.class_( + "BLEManufacturerDataAdvertiseTrigger", + automation.Trigger.template(adv_data_t_const_ref), +) +BLEEndOfScanTrigger = ble_device_base_ns.class_( + "BLEEndOfScanTrigger", automation.Trigger.template() +) + +# UUID string length -> setter width. 16/32-bit go out as plain hex literals, +# 128-bit as a reversed byte array (BLE wire order). Keyed exhaustively so an +# impossible length fails as a KeyError instead of silently picking a width +# (bt_uuid validation upstream only ever produces these three). +_UUID_WIDTHS = { + len(BT_UUID16_FORMAT): "16", + len(BT_UUID32_FORMAT): "32", + len(BT_UUID128_FORMAT): "128", +} + + +def uuid_trigger_schema( + trigger_class: MockObjClass, extra: dict[Any, Any] | None = None +): + """Schema for a UUID-filtered trigger — pairs with uuid_trigger_to_code(). + + `extra` carries the required UUID key (a cv marker, so a dict rather than + **kwargs); the optional single-mac filter is what uuid_trigger_to_code() + reads back. + """ + return automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(trigger_class), + cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, + **(extra or {}), + } + ) + + +def advertise_trigger_schema(trigger_class: MockObjClass): + """on_ble_advertise schema: multi-mac list filter, unlike the single-mac + uuid_trigger_schema() — pairs with advertise_trigger_to_code().""" + return automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(trigger_class), + cv.Optional(CONF_MAC_ADDRESS): cv.ensure_list(cv.mac_address), + } + ) + + +def scan_end_trigger_schema(trigger_class: MockObjClass): + """on_scan_end schema: id only — pairs with scan_end_trigger_to_code().""" + return automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(trigger_class)} + ) + + +# Triggers register as ble_device_base listeners in their constructors; count +# them where they are created so no backend can undercount the StaticVector +# (push_back past capacity drops silently). Shares the define with +# register_ble_device() via the core slot-counter factory. +_count_listener = cg.slot_counter(LISTENER_COUNT_DEFINE) + + +async def advertise_trigger_to_code(conf: ConfigType, var: cg.MockObj) -> None: + """Build an on_ble_advertise trigger (optional multi-mac filter).""" + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + if (macs := conf.get(CONF_MAC_ADDRESS)) is not None: + cg.add(trigger.set_addresses([it.as_hex for it in macs])) + await automation.build_automation(trigger, [(ESPBTDeviceConstRef, "x")], conf) + _count_listener() + + +async def scan_end_trigger_to_code(conf: ConfigType, var: cg.MockObj) -> None: + """Build an on_scan_end trigger.""" + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [], conf) + _count_listener() + + +async def uuid_trigger_to_code( + conf: ConfigType, var: cg.MockObj, key: str, setter_prefix: str +) -> None: + """Build a UUID-filtered advertise trigger. + + The UUID width picks the setter: 16-/32-bit go out as a plain hex literal, + 128-bit as a reversed byte array (BLE wire order). + """ + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + uuid = conf[key] + width = _UUID_WIDTHS[len(uuid)] + value = as_hex(uuid) if width != "128" else as_reversed_hex_array(uuid) + cg.add(getattr(trigger, f"{setter_prefix}{width}")(value)) + if (mac := conf.get(CONF_MAC_ADDRESS)) is not None: + cg.add(trigger.set_address(mac.as_hex)) + await automation.build_automation(trigger, [(adv_data_t_const_ref, "x")], conf) + _count_listener() diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 7476385563..c4b66a8b9a 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -27,6 +27,7 @@ CONF_LOOP = "loop" CONF_NOX_INDEX = "nox_index" CONF_ON_PACKET = "on_packet" CONF_ON_RECEIVE = "on_receive" +CONF_ON_SCAN_END = "on_scan_end" CONF_ON_STATE_CHANGE = "on_state_change" CONF_PARITY = "parity" CONF_RECEIVER_FREQUENCY = "receiver_frequency" diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 102e59bfb2..b45d4a48be 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -5,7 +5,7 @@ import logging from esphome import automation import esphome.codegen as cg from esphome.components import ble_device_base, esp32_ble, ota -from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW +from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW from esphome.components.esp32 import ( add_idf_sdkconfig_option, request_bluetooth, @@ -44,7 +44,6 @@ DEPENDENCIES = ["esp32"] CODEOWNERS = ["@bdraco"] CONF_ESP32_BLE_ID = "esp32_ble_id" -CONF_ON_SCAN_END = "on_scan_end" CONF_SOFTWARE_COEXISTENCE = "software_coexistence" _LOGGER = logging.getLogger(__name__) diff --git a/tests/component_tests/bk72xx_ble_tracker/__init__.py b/tests/component_tests/bk72xx_ble_tracker/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/bk72xx_ble_tracker/config/test_automations.yaml b/tests/component_tests/bk72xx_ble_tracker/config/test_automations.yaml new file mode 100644 index 0000000000..1701807984 --- /dev/null +++ b/tests/component_tests/bk72xx_ble_tracker/config/test_automations.yaml @@ -0,0 +1,39 @@ +esphome: + name: bk-trigger-codegen + on_boot: + then: + - bk72xx_ble_tracker.start_scan: + continuous: true + - bk72xx_ble_tracker.stop_scan + +bk72xx: + board: cb2s + +bk72xx_ble_tracker: + on_ble_advertise: + - mac_address: + - AC:37:43:77:5F:4C + - 11:22:33:44:55:66 + then: + - lambda: 'ESP_LOGD("t", "%s", x.address_str().c_str());' + on_ble_service_data_advertise: + - service_uuid: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD + mac_address: AC:37:43:77:5F:4C + then: + - lambda: 'ESP_LOGD("t", "%zu", x.size());' + - service_uuid: ABCDABCD + then: + - lambda: 'ESP_LOGD("t", "%zu", x.size());' + on_ble_manufacturer_data_advertise: + - manufacturer_id: ABCD + then: + - lambda: 'ESP_LOGD("t", "%zu", x.size());' + - manufacturer_id: ABCDABCD + then: + - lambda: 'ESP_LOGD("t", "%zu", x.size());' + - manufacturer_id: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD + then: + - lambda: 'ESP_LOGD("t", "%zu", x.size());' + on_scan_end: + - then: + - lambda: 'ESP_LOGD("t", "end");' diff --git a/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py b/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py new file mode 100644 index 0000000000..c98bb8a111 --- /dev/null +++ b/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py @@ -0,0 +1,54 @@ +"""Codegen tests for the tracker automations. + +The shared trigger classes (ble_device_base/automation.h) are compiled by every +esp32 BLE compile test via AUTO_LOAD, but the BK-specific side — automation.h's +action templates and restart_scan_duration() — compiles on no CI board (the +bk72xx base board generic-bk7252 is BLE 4.2 and cannot build the tracker), and +validate fixtures never run to_code. The generated main is therefore the only +automated check on the setter spellings and the listener accounting.""" + +from collections.abc import Callable +from pathlib import Path +import re + +from esphome.components import ble_device_base +from tests.component_tests.helpers import get_define_value + + +def test_trigger_codegen( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("test_automations.yaml")) + + # on_ble_advertise: multi-mac filter (two addresses in one initializer list) + assert "set_addresses({0xAC3743775F4CULL, 0x112233445566ULL})" in main_cpp + # 128-bit service uuid goes out reversed (BLE wire order); single-mac filter + assert ( + "set_service_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB," + "0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp + ) + assert "set_address(0xAC3743775F4CULL)" in main_cpp + # 32-bit middle branch of the width dispatch + assert "set_service_uuid32(0xABCDABCDULL)" in main_cpp + # All three manufacturer widths: getattr() builds these names as strings, + # so a misspelling only ever fails here. + assert "set_manufacturer_uuid16(0xABCDULL)" in main_cpp + assert "set_manufacturer_uuid32(0xABCDABCDULL)" in main_cpp + assert ( + "set_manufacturer_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB," + "0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp + ) + # scan-control actions: templatable continuous lambda + parented actions + assert "startscanaction_id->set_continuous(" in main_cpp + assert "stopscanaction_id->set_parent(" in main_cpp + # Constructor call, not just the declaration: the parent argument is what + # registers the trigger as a listener. + assert re.search( + r"new\(\w+\) ble_device_base::BLEEndOfScanTrigger\(\w+\)", main_cpp + ) + + # Seven triggers register as listeners; an undercount silently drops the + # last trigger at runtime (StaticVector::push_back past capacity), so the + # define is the assertion that matters most. + assert get_define_value(ble_device_base.LISTENER_COUNT_DEFINE) == "7" diff --git a/tests/components/bk72xx_ble_tracker/validate-automations.bk72xx-ard.yaml b/tests/components/bk72xx_ble_tracker/validate-automations.bk72xx-ard.yaml new file mode 100644 index 0000000000..407d19e67d --- /dev/null +++ b/tests/components/bk72xx_ble_tracker/validate-automations.bk72xx-ard.yaml @@ -0,0 +1,52 @@ +packages: + bk72xx_ble_tracker: !include common.yaml + +esphome: + on_boot: + then: + - bk72xx_ble_tracker.start_scan + - bk72xx_ble_tracker.start_scan: + continuous: true + - bk72xx_ble_tracker.stop_scan + - bk72xx_ble_tracker.stop_scan: ble_tracker + +bk72xx_ble_tracker: + on_ble_advertise: + - mac_address: AC:37:43:77:5F:4C + then: + - lambda: |- + ESP_LOGD("main", "The device address is %s", x.address_str().c_str()); + - mac_address: + - AC:37:43:77:5F:4C + - AC:37:43:77:5F:4D + then: + - lambda: |- + ESP_LOGD("main", "The device address is %s", x.address_str().c_str()); + on_ble_service_data_advertise: + - service_uuid: ABCD + # mac_address exercises the UUID triggers' set_address() codegen branch. + mac_address: AC:37:43:77:5F:4C + then: + - lambda: |- + ESP_LOGD("main", "Length of service data is %zu", x.size()); + - service_uuid: ABCDABCD + then: + - lambda: |- + ESP_LOGD("main", "32-bit service data is %zu", x.size()); + - service_uuid: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD + then: + - lambda: |- + ESP_LOGD("main", "128-bit service data is %zu", x.size()); + on_ble_manufacturer_data_advertise: + - manufacturer_id: ABCD + then: + - lambda: |- + ESP_LOGD("main", "Length of manufacturer data is %zu", x.size()); + - manufacturer_id: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD + then: + - lambda: |- + ESP_LOGD("main", "128-bit manufacturer data is %zu", x.size()); + on_scan_end: + - then: + - lambda: |- + ESP_LOGD("main", "Scan ended"); From e31a43af171e4822da84f43cb5cb0040b2de5471 Mon Sep 17 00:00:00 2001 From: youkorr <96953496+youkorr@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:40:15 +0200 Subject: [PATCH 1249/1815] [esp32_camera_web_server] Fix MJPEG stream ending on a stale semaphore (#18052) Co-authored-by: Claude --- .../camera_web_server.cpp | 69 ++++++++++++++++--- 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/esphome/components/esp32_camera_web_server/camera_web_server.cpp b/esphome/components/esp32_camera_web_server/camera_web_server.cpp index 7527bbf7e4..88579e9632 100644 --- a/esphome/components/esp32_camera_web_server/camera_web_server.cpp +++ b/esphome/components/esp32_camera_web_server/camera_web_server.cpp @@ -13,7 +13,9 @@ namespace esphome::esp32_camera_web_server { -static const int IMAGE_REQUEST_TIMEOUT = 5000; +static const uint32_t IMAGE_REQUEST_TIMEOUT = 5000; +// How often streaming_handler_ reports its throughput. +static const uint32_t STREAM_STATS_INTERVAL = 5000; static const char *const TAG = "esp32_camera_web_server"; #define PART_BOUNDARY "123456789000000000000987654321" @@ -113,10 +115,31 @@ std::shared_ptr CameraWebServer::wait_for_image_() std::shared_ptr image; image.swap(this->image_); - if (!image) { - // retry as we might still be fetching image - xSemaphoreTake(this->semaphore_, IMAGE_REQUEST_TIMEOUT / portTICK_PERIOD_MS); + if (image) + return image; + + // Keep waiting until a frame really shows up, rather than trusting a single + // take() to mean one is there. + // + // on_camera_image() gives the semaphore for every frame it accepts, but the + // swap above hands frames out without taking it, so as soon as the camera is + // faster than this task for one frame the (binary) semaphore is left + // signalled by a frame that has already been consumed. The next take() then + // returns immediately with nothing to swap in, and the caller reports a lost + // frame and closes the stream -- after an arbitrary number of good frames, + // which is exactly when the camera happens to fall behind for one iteration. + // + // running_ is re-checked on every pass so a shutdown or a client that went + // away is noticed straight away instead of after the full timeout. + const uint32_t start = millis(); + while (this->running_) { + uint32_t elapsed = millis() - start; + if (elapsed >= IMAGE_REQUEST_TIMEOUT) + break; + xSemaphoreTake(this->semaphore_, pdMS_TO_TICKS(IMAGE_REQUEST_TIMEOUT - elapsed)); image.swap(this->image_); + if (image) + break; } return image; @@ -170,8 +193,14 @@ esp_err_t CameraWebServer::streaming_handler_(struct httpd_req *req) { return res; } - uint32_t last_frame = millis(); uint32_t frames = 0; + // Frame statistics are aggregated over STREAM_STATS_INTERVAL rather than + // logged per frame. A line per frame comes out of this (non-main) task tens + // of times a second, and formatting and buffering it costs more than the + // stream it is reporting on. + uint32_t stats_since = millis(); + uint32_t stats_frames = 0; + uint32_t stats_bytes = 0; camera::Camera::instance()->start_stream(esphome::camera::WEB_REQUESTER); @@ -179,7 +208,10 @@ esp_err_t CameraWebServer::streaming_handler_(struct httpd_req *req) { auto image = this->wait_for_image_(); if (!image) { - ESP_LOGW(TAG, "STREAM: failed to acquire frame"); + // A shutdown is not a lost frame: wait_for_image_() returns empty as soon + // as running_ clears, and the loop condition below ends the stream anyway. + if (this->running_) + ESP_LOGW(TAG, "STREAM: failed to acquire frame"); res = ESP_FAIL; } if (res == ESP_OK) { @@ -194,14 +226,29 @@ esp_err_t CameraWebServer::streaming_handler_(struct httpd_req *req) { } if (res == ESP_OK) { frames++; - int64_t frame_time = millis() - last_frame; - last_frame = millis(); - - ESP_LOGD(TAG, "MJPG: %" PRIu32 "B %" PRIu32 "ms (%.1ffps)", (uint32_t) image->get_data_length(), - (uint32_t) frame_time, 1000.0 / (uint32_t) frame_time); + stats_frames++; + stats_bytes += image->get_data_length(); + uint32_t elapsed = millis() - stats_since; + if (elapsed >= STREAM_STATS_INTERVAL) { + ESP_LOGD(TAG, "MJPG: %.1ffps, %" PRIu32 "B/frame (%" PRIu32 " frames)", stats_frames * 1000.0f / elapsed, + stats_bytes / stats_frames, stats_frames); + stats_since = millis(); + stats_frames = 0; + stats_bytes = 0; + } } } + // Report whatever did not fill a whole interval, so a stream that only ran for + // a second or two still says what it managed rather than nothing at all. + if (stats_frames > 0) { + uint32_t elapsed = millis() - stats_since; + if (elapsed == 0) + elapsed = 1; + ESP_LOGD(TAG, "MJPG: %.1ffps, %" PRIu32 "B/frame (%" PRIu32 " frames)", stats_frames * 1000.0f / elapsed, + stats_bytes / stats_frames, stats_frames); + } + if (!frames) { res = httpd_send_all(req, STREAM_ERROR, strlen(STREAM_ERROR)); } From b93b21eab115171bf63c0e9241e1366806d1b168 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Wed, 5 Aug 2026 09:02:56 -0700 Subject: [PATCH 1250/1815] [modbus_controller] Refactor to simplify message handling (#11781) Co-authored-by: Claude Fable 5 --- esphome/components/modbus/modbus_helpers.h | 5 + .../components/modbus_controller/__init__.py | 35 +- .../binary_sensor/modbus_binarysensor.h | 4 +- .../modbus_controller/modbus_controller.cpp | 589 ++++++++---------- .../modbus_controller/modbus_controller.h | 251 +++++--- .../number/modbus_number.cpp | 25 +- .../output/modbus_output.cpp | 30 +- .../select/modbus_select.cpp | 9 +- .../modbus_controller/sensor/modbus_sensor.h | 2 +- .../switch/modbus_switch.cpp | 23 +- .../text_sensor/modbus_textsensor.h | 2 +- esphome/core/helpers.h | 5 + .../components/modbus_controller/common.yaml | 5 +- .../offline_cadence_test.cpp | 56 ++ .../uart_mock_modbus_custom_command.yaml | 87 +++ .../fixtures/uart_mock_modbus_offline.yaml | 95 +++ tests/integration/test_uart_mock_modbus.py | 82 ++- 17 files changed, 824 insertions(+), 481 deletions(-) create mode 100644 tests/components/modbus_controller/offline_cadence_test.cpp create mode 100644 tests/integration/fixtures/uart_mock_modbus_custom_command.yaml create mode 100644 tests/integration/fixtures/uart_mock_modbus_offline.yaml diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index 89e9a2b8ea..36e3b6c7be 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -128,6 +128,11 @@ inline bool value_type_is_float(SensorValueType v) { return v == SensorValueType::FP32 || v == SensorValueType::FP32_R; } +/// Coils and discrete inputs are the bit-addressed entity tables; the other types are 16-bit registers. +inline bool is_entity_type_binary(EntityType type) { + return type == EntityType::COIL || type == EntityType::DISCRETE_INPUT; +} + inline FunctionCode modbus_register_read_function(EntityType reg_type) { switch (reg_type) { case EntityType::COIL: diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index ea01331be3..1ce1e38d16 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -42,22 +42,38 @@ AUTO_LOAD = ["modbus"] MULTI_CONF = True modbus_controller_ns = cg.esphome_ns.namespace("modbus_controller") -ModbusController = modbus_controller_ns.class_( - "ModbusController", cg.PollingComponent, modbus.ModbusClientDevice -) +ModbusController = modbus_controller_ns.class_("ModbusController", cg.PollingComponent) SensorItem = modbus_controller_ns.struct("SensorItem") _LOGGER = logging.getLogger(__name__) +# Remove before 2027.2.0 +_REMOVED_OPTIONS = { + CONF_COMMAND_THROTTLE: "Command spacing is handled by the 'modbus' component - use 'turnaround_time' there instead.", + CONF_ALLOW_DUPLICATE_COMMANDS: "Polling commands are deduplicated by the modbus hub; one-shot commands (writes) are always transmitted.", +} + + +def _warn_removed_options(config: ConfigType) -> ConfigType: + """Warn about options that no longer do anything, but let the config compile.""" + for option, replacement in _REMOVED_OPTIONS.items(): + if option in config: + _LOGGER.warning( + "[modbus_controller] '%s' no longer has any effect and will be removed in 2027.2.0. %s", + option, + replacement, + ) + return config + + CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(ModbusController), - cv.Optional(CONF_ALLOW_DUPLICATE_COMMANDS, default=False): cv.boolean, - cv.Optional( - CONF_COMMAND_THROTTLE, default="0ms" - ): cv.positive_time_period_milliseconds, + # Removed options: accepted (and ignored) until 2027.2.0 so existing configs keep building. + cv.Optional(CONF_ALLOW_DUPLICATE_COMMANDS): cv.boolean, + cv.Optional(CONF_COMMAND_THROTTLE): cv.positive_time_period_milliseconds, cv.Optional(CONF_SERVER_COURTESY_RESPONSE): cv.invalid( "This option has been removed. Use modbus_server component instead: https://esphome.io/components/modbus_server/" ), @@ -74,7 +90,8 @@ CONFIG_SCHEMA = cv.All( } ) .extend(cv.polling_component_schema("60s")) - .extend(modbus.modbus_device_schema(0x01)) + .extend(modbus.modbus_device_schema(0x01)), + _warn_removed_options, ) ModbusItemBaseSchema = cv.Schema( @@ -198,8 +215,6 @@ _CALLBACK_AUTOMATIONS = ( 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])) cg.add(var.set_max_cmd_retries(config[CONF_MAX_CMD_RETRIES])) cg.add(var.set_offline_skip_updates(config[CONF_OFFLINE_SKIP_UPDATES])) await register_modbus_device(var, config) diff --git a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h index 902a3ba8dd..62a7fe93d3 100644 --- a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h +++ b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h @@ -4,7 +4,7 @@ #include "esphome/components/modbus_controller/modbus_controller.h" #include "esphome/core/component.h" -#include +#include namespace esphome::modbus_controller { @@ -20,7 +20,7 @@ class ModbusBinarySensor final : public Component, public binary_sensor::BinaryS this->skip_updates = skip_updates; this->force_new_range = force_new_range; - if (register_type == modbus::EntityType::COIL || register_type == modbus::EntityType::DISCRETE_INPUT) { + if (modbus::helpers::is_entity_type_binary(register_type)) { this->register_count = offset + 1; } else { this->register_count = 1; diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 15f36ce89b..c4161d454f 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -6,209 +6,221 @@ namespace esphome::modbus_controller { static const char *const TAG = "modbus_controller"; -void ModbusController::setup() { this->create_register_ranges_(); } +void ModbusController::setup() { this->create_polling_commands_(); } -/* - To work with the existing modbus class and avoid polling for responses a command queue is used. - send_next_command will submit the command at the top of the queue and set the corresponding callback - to handle the response from the device. - Once the response has been processed it is removed from the queue and the next command is sent -*/ -bool ModbusController::send_next_command_() { - uint32_t last_send = millis() - this->last_command_timestamp_; +ModbusCommandItem::ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address, + RegisterRange &&range) + : modbus::ModbusClientDevice(parent, address), + sensors(std::move(range.sensors)), + skip_updates(range.skip_updates), + register_type_(range.register_type), + start_address_(range.start_address), + register_count_(range.register_count), + function_code_(modbus::helpers::modbus_register_read_function(range.register_type)), + controller_(&controller) {} - if ((last_send > this->command_throttle_) && this->ready_for_immediate_send() && !this->command_queue_.empty()) { - auto &command = this->command_queue_.front(); - - // remove from queue if command was sent too often - if (!command->should_retry(this->max_cmd_retries_)) { - if (!this->module_offline_) { - ESP_LOGW(TAG, "Modbus device=%d set offline", this->address_); - - if (this->offline_skip_updates_ > 0) { - // Update skip_updates_counter to stop flooding channel with timeouts - for (auto &r : this->register_ranges_) { - r.skip_updates_counter = this->offline_skip_updates_; - } - } - - this->module_offline_ = true; - this->offline_callback_.call((int) command->function_code, command->register_address); - } - ESP_LOGD(TAG, "Modbus command to device=%d register=0x%02X no response received - removed from send queue", - this->address_, command->register_address); - this->command_queue_.pop_front(); - } else { - ESP_LOGV(TAG, "Sending next modbus command to device %d register 0x%02X count %d", this->address_, - command->register_address, command->register_count); - command->send(); - - this->last_command_timestamp_ = millis(); - - this->command_sent_callback_.call((int) command->function_code, command->register_address); - - // remove from queue if no handler is defined - if (!command->on_data_func) { - this->command_queue_.pop_front(); - } - } - } - return (!this->command_queue_.empty()); +ModbusCommandItem::ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address, + SensorItem *sensor) + : modbus::ModbusClientDevice(parent, address), + skip_updates(sensor->skip_updates), + start_address_(sensor->start_address), + register_count_(sensor->register_count), + function_code_(FunctionCode::CUSTOM), + custom_data_(&sensor->custom_data), + controller_(&controller) { + this->sensors.insert(sensor); } -// Queue incoming response -void ModbusController::on_response(std::span request_pdu, std::span response_pdu) { - if (this->command_queue_.empty()) { - ESP_LOGW(TAG, "Received modbus data but command queue is empty"); - return; +// The base deletes copy/move; command items re-provide construction. The moved-from device must not +// unregister the hub slot we just took over, so its parent_ is cleared. The copy constructor exists +// only for callers that pass an lvalue to queue_command() (in-tree callers move); remove it when +// queue_command() is removed. +ModbusCommandItem::ModbusCommandItem(const ModbusCommandItem &other) + : modbus::ModbusClientDevice(other.parent_, other.address_), + sensors(other.sensors), + skip_updates(other.skip_updates), + on_data_func(other.on_data_func), + register_type_(other.register_type_), + start_address_(other.start_address_), + register_count_(other.register_count_), + function_code_(other.function_code_), + custom_data_(other.custom_data_), + controller_(other.controller_) { + // SmallInlineBuffer is move-only, so deep-copy the bytes explicitly. + this->payload.set(other.payload.data(), other.payload.size()); +} + +ModbusCommandItem::ModbusCommandItem(ModbusCommandItem &&other) noexcept + : modbus::ModbusClientDevice(other.parent_, other.address_), + sensors(std::move(other.sensors)), + skip_updates(other.skip_updates), + on_data_func(std::move(other.on_data_func)), + payload(std::move(other.payload)), + register_type_(other.register_type_), + start_address_(other.start_address_), + register_count_(other.register_count_), + function_code_(other.function_code_), + custom_data_(other.custom_data_), + controller_(other.controller_) { + other.parent_ = nullptr; +} + +// A valid response: the device is online. Dispatch the payload to the handler or the range's sensors. +void ModbusCommandItem::on_response(std::span request_pdu, std::span response_pdu) { + if (this->controller_ != nullptr) + this->controller_->set_online(true, static_cast(this->function_code_), this->start_address_); + auto data = modbus::helpers::server_pdu_payload(response_pdu); + if (this->on_data_func) { + this->on_data_func(this->register_type_, this->start_address_, data); + } else if (modbus::helpers::is_function_code_write(static_cast(this->function_code_))) { + // write acknowledgement - nothing to publish + } else { + for (auto *sensor : this->sensors) + sensor->parse_and_publish(data); } - auto ¤t_command = this->command_queue_.front(); - if (current_command != nullptr) { + if (this->controller_ != nullptr) + this->controller_->unqueue_command(this); +} + +// An exception response is still a legitimate reply, so the device is considered online. +void ModbusCommandItem::on_error(std::span request_pdu, modbus::ExceptionCode exception_code) { + const uint8_t function_code = request_pdu.empty() ? 0 : request_pdu[0]; + ESP_LOGW(TAG, "Modbus error function code: 0x%X register 0x%X exception: %d", function_code, this->start_address_, + static_cast(exception_code)); + if (this->controller_ != nullptr) { + this->controller_->set_online(true, function_code, this->start_address_); + this->controller_->unqueue_command(this); + } +} + +// Not being sent says nothing about online/offline status; just drop it from the pending list. +void ModbusCommandItem::on_not_sent(std::span request_pdu) { + // A dropped write is lost while the entity has already published optimistically, so surface it. + if (modbus::helpers::is_function_code_write(static_cast(this->function_code_))) { + ESP_LOGW(TAG, "Write not sent: function 0x%X register 0x%X", static_cast(this->function_code_), + this->start_address_); + } + if (this->controller_ != nullptr) + this->controller_->unqueue_command(this); +} + +// Fired once per wire transmission (including hub re-queues from a retry), so the on_command_sent +// trigger reflects when the frame actually went out, not when it was queued. +void ModbusCommandItem::on_sent(std::span request_pdu) { + if (this->controller_ != nullptr) + this->controller_->command_sent(static_cast(this->function_code_), this->start_address_); +} + +bool ModbusCommandItem::on_no_response(std::span request_pdu) { + if (this->controller_ == nullptr) + return false; + this->controller_->increment_non_response_count(); + if (this->controller_->can_send()) { + // Have the hub re-queue the frame it is holding; on_sent fires again when it goes back out. + return true; + } + this->controller_->set_online(false, static_cast(this->function_code_), this->start_address_); + this->controller_->unqueue_command(this); + return false; +} + +void ModbusController::set_online(bool online, int function_code, int register_address) { + if (online) { + this->cmd_non_responses_ = 0; if (this->module_offline_) { ESP_LOGW(TAG, "Modbus device=%d back online", this->address_); - - if (this->offline_skip_updates_ > 0) { - // Restore skip_updates_counter to restore commands updates - for (auto &r : this->register_ranges_) { - r.skip_updates_counter = 0; - } - } - // Restore module online state this->module_offline_ = false; - this->online_callback_.call((int) current_command->function_code, current_command->register_address); + this->online_callback_.call(function_code, register_address); + } + } else { + // Offline is a property of the physical device, so drop every sender's queued frames for its + // address; retired frames get on_not_sent(), which reclaims one-shots through the normal path. + this->hub_->clear_tx_queue_for_address(this->address_); + if (!this->module_offline_) { + ESP_LOGW(TAG, "Modbus device=%d set offline", this->address_); + this->module_offline_ = true; + this->module_offline_at_ = this->update_counter_; + this->offline_callback_.call(function_code, register_address); } - - // Move the commandItem to the response queue. The span points into the hub's receive buffer, so - // copy the payload into the command for deferred processing in loop(). - auto data = modbus::helpers::server_pdu_payload(response_pdu); - current_command->payload.assign(data.begin(), data.end()); - this->incoming_queue_.push(std::move(current_command)); - ESP_LOGV(TAG, "Modbus response queued"); - this->command_queue_.pop_front(); } } -// Dispatch the response to the registered handler -void ModbusController::process_modbus_data_(const ModbusCommandItem *response) { - ESP_LOGV(TAG, "Process modbus response for address 0x%X size: %zu", response->register_address, - response->payload.size()); - response->on_data_func(response->register_type, response->register_address, response->payload); +void ModbusController::queue_command(ModbusCommandItem command) { + this->sweep_completed_one_shots_(); // reclaim finished one-shots before adding a new one + // Duplicates are the caller's to manage; the controller only holds the item until its terminal callback. + this->one_shot_command_items_.push_back(make_unique(std::move(command))); + // A refused frame gets no terminal callback (see the hub contract), so reclaim the item here. + auto &item = this->one_shot_command_items_.back(); + if (!item->send()) { + // The caller (e.g. a write entity) has usually already published optimistically - surface the loss. + ESP_LOGW(TAG, "Command refused by hub: type=0x%X address=0x%X", static_cast(item->register_type()), + item->register_address()); + item->pending_removal = true; + } } -void ModbusController::on_error(std::span request_pdu, modbus::ExceptionCode exception_code) { - // The request function code (request_pdu[0]) already carries what the log needs; the exception bit only - // ever appears on the response, so no masking is needed here. - const uint8_t function_code = request_pdu.empty() ? 0 : request_pdu[0]; - ESP_LOGE(TAG, "Modbus error function code: 0x%X exception: %d ", function_code, static_cast(exception_code)); - if (this->command_queue_.empty()) { +void ModbusController::unqueue_command(const ModbusCommandItem *command) { + // Called as the last action of the command's own callback, and from send() after send_pdu (which may + // synchronously call on_not_sent). Destroying `command` here would leave send() and the hub touching a + // freed object, so we only FLAG it; sweep_completed_one_shots_() erases it later at a safe point. No-op + // for polling commands (they persist and are not in the one-shot list). + for (auto &item : this->one_shot_command_items_) { + if (item.get() == command) { + item->pending_removal = true; + return; + } + } +} + +void ModbusController::sweep_completed_one_shots_() { + this->one_shot_command_items_.remove_if( + [](const std::unique_ptr &item) { return item->pending_removal; }); +} + +void ModbusController::update_range_(ModbusCommandItem &cmd) { + if (this->update_counter_ % (cmd.skip_updates + 1) != 0) { + ESP_LOGVV(TAG, "Skipping update for range 0x%X", cmd.register_address()); return; } - // Remove pending command waiting for a response - auto ¤t_command = this->command_queue_.front(); - if (current_command != nullptr) { - ESP_LOGE(TAG, - "Modbus error - last command: function code=0x%X register address = 0x%X " - "registers count=%d " - "payload size=%zu", - function_code, current_command->register_address, current_command->register_count, - current_command->payload.size()); - this->command_queue_.pop_front(); - } + // A refusal is already logged by the hub; note the affected range for controller-level diagnostics. + if (!cmd.send()) + ESP_LOGD(TAG, "Poll refused by hub for range 0x%X", cmd.register_address()); } -SensorSet ModbusController::find_sensors_(modbus::EntityType register_type, uint16_t start_address) const { - auto reg_it = std::find_if( - std::begin(this->register_ranges_), std::end(this->register_ranges_), - [=](RegisterRange const &r) { return (r.start_address == start_address && r.register_type == register_type); }); - - if (reg_it == this->register_ranges_.end()) { - ESP_LOGE(TAG, "No matching range for sensor found - start_address : 0x%X", start_address); - } else { - return reg_it->sensors; - } - - // not found - return {}; -} -void ModbusController::on_register_data(modbus::EntityType register_type, uint16_t start_address, - const std::vector &data) { - ESP_LOGV(TAG, "data for register address : 0x%X : ", start_address); - - // loop through all sensors in this range; each reads its own bytes from the position resolved for it. - auto sensors = find_sensors_(register_type, start_address); - for (auto *sensor : sensors) { - sensor->parse_and_publish(data); - } -} - -void ModbusController::queue_command(const ModbusCommandItem &command) { - if (!this->allow_duplicate_commands_) { - // check if this command is already qeued. - // not very effective but the queue is never really large - for (auto &item : this->command_queue_) { - if (item->is_equal(command)) { - ESP_LOGW(TAG, "Duplicate modbus command found: type=0x%x address=%u count=%u", - static_cast(command.register_type), command.register_address, command.register_count); - // update the payload of the queued command - // replaces a previous command - item->payload = command.payload; - return; - } - } - } - this->command_queue_.push_back(make_unique(command)); -} - -void ModbusController::update_range_(RegisterRange &r) { - ESP_LOGV(TAG, "Range : %X Size: %x (%d) skip: %d", r.start_address, r.register_count, (int) r.register_type, - r.skip_updates_counter); - if (r.skip_updates_counter == 0) { - // if a custom command is used the user supplied custom_data is only available in the SensorItem. - if (r.register_type == modbus::EntityType::CUSTOM) { - auto sensors = this->find_sensors_(r.register_type, r.start_address); - if (!sensors.empty()) { - auto sensor = sensors.cbegin(); - auto command_item = ModbusCommandItem::create_custom_command( - this, (*sensor)->custom_data, - [this](modbus::EntityType register_type, uint16_t start_address, const std::vector &data) { - this->on_register_data(modbus::EntityType::CUSTOM, start_address, data); - }); - command_item.register_address = (*sensor)->start_address; - command_item.register_count = (*sensor)->register_count; - command_item.function_code = FunctionCode::CUSTOM; - queue_command(command_item); +void ModbusController::update() { + this->sweep_completed_one_shots_(); // reclaim one-shots deferred out of their own callbacks + if (this->module_offline_) { + // Offline probing follows the offline cadence alone; per-range skip_updates resumes once the + // device is back online. Requiring both cadences to coincide would leave phase combinations + // where a probe never goes out. + if (offline_retry_due(this->update_counter_, this->module_offline_at_, this->offline_skip_updates_)) { + ESP_LOGV(TAG, "Module offline - retrying"); + this->cmd_non_responses_ = 0; // allow the probe through can_send() + for (auto &cmd : this->polling_command_items_) { + if (!cmd.send()) + ESP_LOGD(TAG, "Probe refused by hub for range 0x%X", cmd.register_address()); } } else { - queue_command(ModbusCommandItem::create_read_command(this, r.register_type, r.start_address, r.register_count)); + ESP_LOGV(TAG, "Module offline - skipping update"); } - r.skip_updates_counter = r.skip_updates; // reset counter to config value - } else { - r.skip_updates_counter--; - } -} -// -// Queue the modbus requests to be send. -// Once we get a response to the command it is removed from the queue and the next command is send -// -void ModbusController::update() { - if (!this->command_queue_.empty()) { - ESP_LOGV(TAG, "%zu modbus commands already in queue", this->command_queue_.size()); - } else { - ESP_LOGV(TAG, "Updating modbus component"); + this->update_counter_++; + return; } - for (auto &r : this->register_ranges_) { - ESP_LOGVV(TAG, "Updating range 0x%X", r.start_address); - update_range_(r); + if (this->can_send()) { + for (auto &cmd : this->polling_command_items_) { + ESP_LOGVV(TAG, "Updating range 0x%X", cmd.register_address()); + this->update_range_(cmd); + } } + this->update_counter_++; } // walk through the sensors and determine the register ranges to read -size_t ModbusController::create_register_ranges_() { - this->register_ranges_.clear(); +void ModbusController::create_polling_commands_() { if (this->sensorset_.empty()) { ESP_LOGW(TAG, "No sensors registered"); - return 0; + return; } // Sensors are walked in the sensor set's order (see SensorItemsComparator): register type, then @@ -299,7 +311,7 @@ size_t ModbusController::create_register_ranges_() { if (!join) { if (have_range) { ESP_LOGV(TAG, "Add range 0x%X %d skip:%d", r.start_address, r.register_count, r.skip_updates); - this->register_ranges_.push_back(std::move(r)); + this->create_polling_command_(std::move(r)); } r = {}; range_bytes = curr->get_register_size(); @@ -311,7 +323,6 @@ size_t ModbusController::create_register_ranges_() { r.register_count = curr->register_count; r.register_type = curr->register_type; r.skip_updates = curr->skip_updates; - r.skip_updates_counter = 0; have_range = true; } else if (curr->skip_updates != 0) { // use the lowest non-zero skip_updates for the whole range (0 is the default and is excluded) @@ -326,10 +337,11 @@ size_t ModbusController::create_register_ranges_() { } if (have_range) { ESP_LOGV(TAG, "Add last range 0x%X %d skip:%d", r.start_address, r.register_count, r.skip_updates); - this->register_ranges_.push_back(std::move(r)); + this->create_polling_command_(std::move(r)); } - - return this->register_ranges_.size(); + // Reclaim growth slack; safe here because nothing has registered with the hub yet (see the + // lifetime note on polling_command_items_). + this->polling_command_items_.shrink_to_fit(); } void ModbusController::dump_config() { @@ -348,222 +360,163 @@ void ModbusController::dump_config() { it->get_register_size()); } ESP_LOGCONFIG(TAG, "ranges"); - for (auto &it : this->register_ranges_) { - 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); + for (auto &it : this->polling_command_items_) { + ESP_LOGCONFIG(TAG, " Range type=%u start=0x%X count=%d skip_updates=%d", static_cast(it.register_type()), + it.register_address(), it.register_count(), it.skip_updates); } #endif } -void ModbusController::loop() { - // Incoming data to process? - if (!this->incoming_queue_.empty()) { - auto &message = this->incoming_queue_.front(); - if (message != nullptr) - this->process_modbus_data_(message.get()); - this->incoming_queue_.pop(); - +void ModbusController::on_write_register_response(EntityType register_type, uint16_t start_address, + std::span data) { + // A well-formed write ACK echoes address and value, but a truncated PDU yields a short/empty span. + if (data.size() >= 3) { + ESP_LOGV(TAG, "Command ACK 0x%X %d ", modbus::helpers::get_data(data.data(), 0), + modbus::helpers::get_data(data.data(), 1)); } else { - // all messages processed send pending commands - this->send_next_command_(); - } -} - -void ModbusController::on_write_register_response(modbus::EntityType register_type, uint16_t start_address, - const std::vector &data) { - ESP_LOGV(TAG, "Command ACK 0x%X %d ", modbus::helpers::get_data(data, 0), - modbus::helpers::get_data(data, 1)); -} - -void ModbusController::dump_sensors_() { - ESP_LOGV(TAG, "sensors"); - for (auto &it : this->sensorset_) { - ESP_LOGV(TAG, " Sensor start=0x%X count=%d size=%zu offset=%d", it->start_address, it->register_count, - it->get_register_size(), it->offset); + ESP_LOGV(TAG, "Command ACK (short payload, %zu bytes)", data.size()); } } ModbusCommandItem ModbusCommandItem::create_read_command( - ModbusController *modbusdevice, modbus::EntityType register_type, uint16_t start_address, uint16_t register_count, - std::function &data)> - &&handler) { - ModbusCommandItem cmd; - cmd.modbusdevice = modbusdevice; - cmd.register_type = register_type; - cmd.function_code = modbus::helpers::modbus_register_read_function(register_type); - cmd.register_address = start_address; - cmd.register_count = register_count; + ModbusController *modbusdevice, EntityType register_type, uint16_t start_address, uint16_t register_count, + std::function data)> &&handler) { + ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address()); + cmd.set_command_(modbus::helpers::modbus_register_read_function(register_type), register_type, start_address, + register_count); cmd.on_data_func = std::move(handler); return cmd; } -ModbusCommandItem ModbusCommandItem::create_read_command(ModbusController *modbusdevice, - modbus::EntityType register_type, uint16_t start_address, - uint16_t register_count) { - ModbusCommandItem cmd; - cmd.modbusdevice = modbusdevice; - cmd.register_type = register_type; - cmd.function_code = modbus::helpers::modbus_register_read_function(register_type); - cmd.register_address = start_address; - cmd.register_count = register_count; - cmd.on_data_func = [modbusdevice](modbus::EntityType register_type, uint16_t start_address, - const std::vector &data) { - modbusdevice->on_register_data(register_type, start_address, data); - }; - return cmd; -} - ModbusCommandItem ModbusCommandItem::create_write_multiple_command(ModbusController *modbusdevice, uint16_t start_address, uint16_t register_count, const std::vector &values) { - ModbusCommandItem cmd; - cmd.modbusdevice = modbusdevice; - cmd.register_type = modbus::EntityType::HOLDING; - cmd.function_code = FunctionCode::WRITE_MULTIPLE_REGISTERS; - cmd.register_address = start_address; - cmd.register_count = register_count; - cmd.on_data_func = [modbusdevice, cmd](modbus::EntityType register_type, uint16_t start_address, - const std::vector &data) { - modbusdevice->on_write_register_response(cmd.register_type, start_address, data); + ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address()); + cmd.set_command_(FunctionCode::WRITE_MULTIPLE_REGISTERS, EntityType::HOLDING, start_address, register_count); + cmd.on_data_func = [modbusdevice](EntityType register_type, uint16_t start_address, std::span data) { + modbusdevice->on_write_register_response(register_type, start_address, data); }; + uint8_t *p = cmd.payload.init(values.size() * 2); for (auto v : values) { auto decoded_value = decode_value(v); - cmd.payload.push_back(decoded_value[0]); - cmd.payload.push_back(decoded_value[1]); + *p++ = decoded_value[0]; + *p++ = decoded_value[1]; } return cmd; } ModbusCommandItem ModbusCommandItem::create_write_single_coil(ModbusController *modbusdevice, uint16_t address, bool value) { - ModbusCommandItem cmd; - cmd.modbusdevice = modbusdevice; - cmd.register_type = modbus::EntityType::COIL; - cmd.function_code = FunctionCode::WRITE_SINGLE_COIL; - cmd.register_address = address; - cmd.register_count = 1; - cmd.on_data_func = [modbusdevice, cmd](modbus::EntityType register_type, uint16_t start_address, - const std::vector &data) { - modbusdevice->on_write_register_response(cmd.register_type, start_address, data); + ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address()); + cmd.set_command_(FunctionCode::WRITE_SINGLE_COIL, EntityType::COIL, address, 1); + cmd.on_data_func = [modbusdevice](EntityType register_type, uint16_t start_address, std::span data) { + modbusdevice->on_write_register_response(register_type, start_address, data); }; - cmd.payload.push_back(value ? 0xFF : 0); - cmd.payload.push_back(0); + uint8_t *p = cmd.payload.init(2); + p[0] = value ? 0xFF : 0; + p[1] = 0; return cmd; } ModbusCommandItem ModbusCommandItem::create_write_multiple_coils(ModbusController *modbusdevice, uint16_t start_address, const std::vector &values) { - ModbusCommandItem cmd; - cmd.modbusdevice = modbusdevice; - cmd.register_type = modbus::EntityType::COIL; - cmd.function_code = FunctionCode::WRITE_MULTIPLE_COILS; - cmd.register_address = start_address; - cmd.register_count = values.size(); - cmd.on_data_func = [modbusdevice, cmd](modbus::EntityType register_type, uint16_t start_address, - const std::vector &data) { - modbusdevice->on_write_register_response(cmd.register_type, start_address, data); + ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address()); + cmd.set_command_(FunctionCode::WRITE_MULTIPLE_COILS, EntityType::COIL, start_address, values.size()); + cmd.on_data_func = [modbusdevice](EntityType register_type, uint16_t start_address, std::span data) { + modbusdevice->on_write_register_response(register_type, start_address, data); }; - uint8_t bitmask = 0; - int bitcounter = 0; + uint8_t *p = cmd.payload.init((values.size() + 7) / 8); + memset(p, 0, (values.size() + 7) / 8); + size_t bit = 0; for (auto coil : values) { if (coil) { - bitmask |= (1 << bitcounter); + p[bit / 8] |= (1 << (bit % 8)); } - bitcounter++; - if (bitcounter % 8 == 0) { - cmd.payload.push_back(bitmask); - bitmask = 0; - } - } - // add remaining bits - if (bitcounter % 8) { - cmd.payload.push_back(bitmask); + bit++; } return cmd; } ModbusCommandItem ModbusCommandItem::create_write_single_command(ModbusController *modbusdevice, uint16_t start_address, uint16_t value) { - ModbusCommandItem cmd; - cmd.modbusdevice = modbusdevice; - cmd.register_type = modbus::EntityType::HOLDING; - cmd.function_code = FunctionCode::WRITE_SINGLE_REGISTER; - cmd.register_address = start_address; - cmd.register_count = 1; // not used here anyways - cmd.on_data_func = [modbusdevice, cmd](modbus::EntityType register_type, uint16_t start_address, - const std::vector &data) { - modbusdevice->on_write_register_response(cmd.register_type, start_address, data); + ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address()); + cmd.set_command_(FunctionCode::WRITE_SINGLE_REGISTER, EntityType::HOLDING, start_address, 1); + cmd.on_data_func = [modbusdevice](EntityType register_type, uint16_t start_address, std::span data) { + modbusdevice->on_write_register_response(register_type, start_address, data); }; auto decoded_value = decode_value(value); - cmd.payload.push_back(decoded_value[0]); - cmd.payload.push_back(decoded_value[1]); + uint8_t *p = cmd.payload.init(2); + p[0] = decoded_value[0]; + p[1] = decoded_value[1]; return cmd; } ModbusCommandItem ModbusCommandItem::create_custom_command( ModbusController *modbusdevice, const std::vector &values, - std::function &data)> - &&handler) { - ModbusCommandItem cmd; - cmd.modbusdevice = modbusdevice; - cmd.function_code = FunctionCode::CUSTOM; + std::function data)> &&handler) { + ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address()); + cmd.function_code_ = FunctionCode::CUSTOM; if (handler == nullptr) { - cmd.on_data_func = [](modbus::EntityType register_type, uint16_t start_address, const std::vector &data) { + cmd.on_data_func = [](EntityType register_type, uint16_t start_address, std::span data) { ESP_LOGI(TAG, "Custom Command sent"); }; } else { cmd.on_data_func = handler; } - cmd.payload = values; + cmd.payload.set(values.data(), values.size()); return cmd; } ModbusCommandItem ModbusCommandItem::create_custom_command( ModbusController *modbusdevice, const std::vector &values, - std::function &data)> - &&handler) { - ModbusCommandItem cmd = {}; - cmd.modbusdevice = modbusdevice; - cmd.function_code = FunctionCode::CUSTOM; + std::function data)> &&handler) { + ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address()); + cmd.function_code_ = FunctionCode::CUSTOM; if (handler == nullptr) { - cmd.on_data_func = [](modbus::EntityType register_type, uint16_t start_address, const std::vector &data) { + cmd.on_data_func = [](EntityType register_type, uint16_t start_address, std::span data) { ESP_LOGI(TAG, "Custom Command sent"); }; } else { cmd.on_data_func = handler; } + uint8_t *p = cmd.payload.init(values.size() * 2); for (auto v : values) { - cmd.payload.push_back((v >> 8) & 0xFF); - cmd.payload.push_back(v & 0xFF); + *p++ = (v >> 8) & 0xFF; + *p++ = v & 0xFF; } return cmd; } bool ModbusCommandItem::send() { - if (this->function_code != FunctionCode::CUSTOM) { - modbusdevice->send_pdu( - modbus::helpers::create_client_pdu(this->function_code, this->register_address, this->register_count, - this->payload.empty() ? nullptr : &this->payload[0], this->payload.size())); + bool accepted; + if (this->function_code_ != FunctionCode::CUSTOM) { + accepted = this->send_pdu(modbus::helpers::create_client_pdu( + this->function_code_, this->start_address_, this->register_count_, + this->payload.empty() ? nullptr : this->payload.data(), this->payload.size())); } else { - modbusdevice->send_raw(this->payload); + // Custom command: the bytes are a complete raw frame (address + PDU). Send the PDU to the frame's own + // address (which may differ from this controller's); the hub appends the CRC and routes the response + // back to this item by pointer. (send_raw() is deprecated, so send_pdu() is called with the extracted + // address. Raw-frame semantics are kept here; the custom_pdu migration is a later step.) + std::span frame = + this->custom_data_ != nullptr ? std::span(*this->custom_data_) : this->payload; + if (frame.empty()) { + ESP_LOGW(TAG, "Empty custom command frame, not sent"); + accepted = false; + } else { + accepted = this->parent_->send_pdu(frame[0], frame.subspan(1), this); + } } - this->send_count_++; - ESP_LOGV(TAG, "Command sent %d 0x%X %d send_count: %d", uint8_t(this->function_code), this->register_address, - this->register_count, this->send_count_); - return true; -} - -bool ModbusCommandItem::is_equal(const ModbusCommandItem &other) { - // for custom commands we have to check for identical payloads, since - // address/count/type fields will be set to zero - return this->function_code == FunctionCode::CUSTOM - ? this->payload == other.payload - : other.register_address == this->register_address && other.register_count == this->register_count && - other.register_type == this->register_type && other.function_code == this->function_code; + // The on_command_sent trigger fires from on_sent() when the frame actually reaches the wire. + if (accepted) { + ESP_LOGV(TAG, "Command queued %d 0x%X %d", uint8_t(this->function_code_), this->start_address_, + this->register_count_); + } + return accepted; } } // namespace esphome::modbus_controller diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index b5ef707a74..fb0037a0e6 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -7,7 +7,6 @@ #include "esphome/core/automation.h" #include -#include #include #include #include @@ -17,6 +16,7 @@ namespace esphome::modbus_controller { class ModbusController; +using modbus::EntityType; using modbus::ExceptionCode; using modbus::FunctionCode; using modbus::helpers::SensorValueType; @@ -133,9 +133,7 @@ class SensorItem { virtual void parse_and_publish(std::span data) = 0; /// Coils and discrete inputs address individual bits; every other type addresses 16-bit registers. - bool addresses_bits() const { - return this->register_type == modbus::EntityType::COIL || this->register_type == modbus::EntityType::DISCRETE_INPUT; - } + bool addresses_bits() const { return modbus::helpers::is_entity_type_binary(this->register_type); } /// Address a write entity (switch/number/select) targets, derived from its resolved position within /// the range so that a write lands on the register the sensor reads from. @@ -193,7 +191,7 @@ class SensorItem { bool force_new_range{false}; }; -// ModbusController::create_register_ranges_ tries to optimize register range +// ModbusController::create_polling_commands_ tries to optimize register range // for this the sensors must be ordered by register_type, start_address and bitmask class SensorItemsComparator { public: @@ -232,25 +230,64 @@ struct RegisterRange { uint16_t start_address; modbus::EntityType register_type; uint8_t register_count; - uint16_t skip_updates; // the config value - SensorSet sensors; // all sensors of this range - uint16_t skip_updates_counter; // the running value + uint16_t skip_updates; // the config value + SensorSet sensors; // all sensors of this range }; -class ModbusCommandItem { +/// A single modbus command. Each command is its own ModbusClientDevice: it sends its frame to the hub +/// and the hub routes the response back to this object's on_modbus_* callbacks, so the controller no +/// longer has to match responses to a FIFO queue. +class ModbusCommandItem : public modbus::ModbusClientDevice { public: - static const size_t MAX_PAYLOAD_BYTES = 240; - ModbusController *modbusdevice{nullptr}; - uint16_t register_address{0}; - uint16_t register_count{0}; - FunctionCode function_code{FunctionCode::CUSTOM}; - modbus::EntityType register_type{modbus::EntityType::CUSTOM}; - std::function &data)> - on_data_func; - std::vector payload = {}; + /// Empty command with no controller connection (kept for source compatibility with value-type usage). + ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address) + : modbus::ModbusClientDevice(parent, address), controller_(&controller) {} + /// Read command built from a range; the read PDU is rebuilt from these fields at send time. + ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address, + RegisterRange &&range); + /// Custom polling command: the PDU bytes are referenced from the sensor (not copied); responses are + /// dispatched to that sensor. + ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address, SensorItem *sensor); + + // The base deletes copy/move (its destructor unregisters the device from the hub queue), but command + // items are stored in value containers, so copy/move CONSTRUCTION is re-provided (copy only for the + // queue_command() path). Assignment stays deleted: the item's address-in-memory is its hub identity. + ModbusCommandItem(const ModbusCommandItem &other); + ModbusCommandItem(ModbusCommandItem &&other) noexcept; + ModbusCommandItem &operator=(ModbusCommandItem &&) = delete; + + SensorSet sensors; // sensors served by this command (empty for factory/write commands) + uint16_t skip_updates{0}; + std::function data)> on_data_func; + /// Write data bytes for the command (register/coil values), or the raw frame of a one-shot custom + /// command; reads leave it empty. Small-buffer optimized: fixed-size commands (single-register/coil + /// writes) fit in the 8-byte inline buffer with no heap; only large multi-register or custom frames + /// spill to a single one-time heap allocation. This keeps runtime one-shot writes off the heap without + /// reserving a max-size buffer per command item. + SmallInlineBuffer<8> payload; + // Set by unqueue_command() when this one-shot has completed. The controller erases flagged items at a + // safe point (update()/queue_command()), never from inside the command's own callback. + bool pending_removal{false}; + + /// called when a modbus response was parsed without errors + void on_response(std::span request_pdu, std::span response_pdu) override; + /// called when a modbus error (exception) response was received + void on_error(std::span request_pdu, modbus::ExceptionCode exception_code) override; + /// called when the command could not be sent + void on_not_sent(std::span request_pdu) override; + /// called when the command's frame is actually written to the wire; fires the on_command_sent trigger + void on_sent(std::span request_pdu) override; + /// called on timeout; returns true to have the hub re-queue the frame for a retry + bool on_no_response(std::span request_pdu) override; + + uint16_t register_address() const { return this->start_address_; } + uint16_t register_count() const { return this->register_count_; } + EntityType register_type() const { return this->register_type_; } + + /// Queue this command's frame on the hub. Returns false when refused, in which case no callback ever comes. + /// The item is the hub device, so it must stay alive until its terminal callback; a destroyed item's + /// pending frame is silently retired. bool send(); - /// Check if the command should be retried based on the max_retries parameter - bool should_retry(uint8_t max_retries) { return this->send_count_ <= max_retries; }; /// factory methods /** Create modbus read command @@ -263,19 +300,8 @@ class ModbusCommandItem { * @return ModbusCommandItem with the prepared command */ static ModbusCommandItem create_read_command( - ModbusController *modbusdevice, modbus::EntityType register_type, uint16_t start_address, uint16_t register_count, - std::function &data)> - &&handler); - /** Create modbus read command - * Function code 02-04 - * @param modbusdevice pointer to the device to execute the command - * @param function_code modbus function code for the read command - * @param start_address modbus address of the first register to read - * @param register_count number of registers to read - * @return ModbusCommandItem with the prepared command - */ - static ModbusCommandItem create_read_command(ModbusController *modbusdevice, modbus::EntityType register_type, - uint16_t start_address, uint16_t register_count); + ModbusController *modbusdevice, EntityType register_type, uint16_t start_address, uint16_t register_count, + std::function data)> &&handler); /** Create modbus read command * Function code 02-04 * @param modbusdevice pointer to the device to execute the command @@ -324,8 +350,8 @@ class ModbusCommandItem { */ static ModbusCommandItem create_custom_command( ModbusController *modbusdevice, const std::vector &values, - std::function &data)> - &&handler = nullptr); + std::function data)> &&handler = + nullptr); /** Create custom modbus command * @param modbusdevice pointer to the device to execute the command @@ -336,17 +362,33 @@ class ModbusCommandItem { */ static ModbusCommandItem create_custom_command( ModbusController *modbusdevice, const std::vector &values, - std::function &data)> - &&handler = nullptr); - - bool is_equal(const ModbusCommandItem &other); + std::function data)> &&handler = + nullptr); protected: - // wrong commands (esp. custom commands) can block the send queue, limit the number of repeats. - /// How many times this command has been sent - uint8_t send_count_{0}; + void set_command_(FunctionCode function_code, EntityType register_type, uint16_t start_address, + uint16_t register_count) { + this->function_code_ = function_code; + this->register_type_ = register_type; + this->start_address_ = start_address; + this->register_count_ = register_count; + } + EntityType register_type_{EntityType::CUSTOM}; + uint16_t start_address_{0}; + uint16_t register_count_{0}; + FunctionCode function_code_{FunctionCode::CUSTOM}; + /// Custom polling commands reference the PDU bytes owned by their SensorItem instead of copying them. + const std::vector *custom_data_{nullptr}; + ModbusController *controller_{nullptr}; }; +/// Whether an offline probe is due this update cycle: every offline_skip_updates + 1 cycles, +/// anchored at the cycle the device went offline. Pure so the cadence (including update_counter +/// wraparound) can be unit tested; used by ModbusController::update(). +inline bool offline_retry_due(uint16_t update_counter, uint16_t module_offline_at, uint16_t offline_skip_updates) { + return static_cast(update_counter + 1 - module_offline_at) % (offline_skip_updates + 1) == 0; +} + /** Modbus controller class. * Each instance handles the modbus commuinication for all sensors with the same modbus address * @@ -355,48 +397,46 @@ class ModbusCommandItem { * Responses for the commands are dispatched to the modbus sensor items. */ -class ModbusController final : public PollingComponent, public modbus::ModbusClientDevice { +class ModbusController final : public PollingComponent { public: void dump_config() override; - void loop() override; + // No loop() override: the hub owns transmit/receive timing and each command routes its own + // response, so the controller never joins the looping components at all. void setup() override; void update() override; - /// queues a modbus command in the send queue - void queue_command(const ModbusCommandItem &command); - /// Sends a raw payload (address byte + PDU, no CRC) with responses routed back to this controller. - /// The payload carries its own address byte, which may differ from this controller's address. - /// Deliberately shadows the deprecated ModbusClientDevice::send_raw() with identical semantics: - /// controller-level raw sends stay supported until the command machinery is replaced. - void send_raw(const std::vector &payload) { - if (payload.empty()) - return; // refused at the door, like every invalid send; no callback follows - this->parent_->send_pdu(payload[0], std::span(payload).subspan(1), this); - } + // The controller is not itself a modbus device - its commands and writer entities send as their own + // devices. It only owns the hub + address so those senders can be built against them. + void set_parent(modbus::ModbusClientHub *hub) { this->hub_ = hub; } + void set_address(uint8_t address) { this->address_ = address; } + + /// The hub and modbus address this controller talks to. Used to build commands/entities that send as + /// their own device. + modbus::ModbusClientHub *hub() const { return this->hub_; } + uint8_t device_address() const { return this->address_; } + + /// Queues a one-shot modbus command (writes, custom commands); taken by value, so std::move to avoid a copy. + void queue_command(ModbusCommandItem command); + /// Flags a finished one-shot command for removal. Called by the command as the last action of its own + /// callback, so the item is not destroyed here (send() and the hub still touch it) but swept later. + void unqueue_command(const ModbusCommandItem *command); /// Registers a sensor with the controller. Called by esphomes code generator void add_sensor_item(SensorItem *item) { sensorset_.insert(item); } - /// called when a modbus response was parsed without errors - void on_response(std::span request_pdu, std::span response_pdu) override; - /// called when a modbus error response was received - void on_error(std::span request_pdu, modbus::ExceptionCode exception_code) override; - /// default delegate called by process_modbus_data when a response has retrieved from the incoming queue - void on_register_data(modbus::EntityType 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 - /// incoming queue - void on_write_register_response(modbus::EntityType register_type, uint16_t start_address, - const std::vector &data); - /// Allow a duplicate command to be sent - void set_allow_duplicate_commands(bool allow_duplicate_commands) { - this->allow_duplicate_commands_ = allow_duplicate_commands; + /// Handles a write command acknowledgement (used by write command on_data_func handlers). + void on_write_register_response(EntityType register_type, uint16_t start_address, std::span data); + /// Update the online/offline state after a response or a run of timeouts, firing the callbacks. + void set_online(bool online, int function_code, int register_address); + /// Fire the on_command_sent trigger (called when a command's frame reaches the wire). + void command_sent(int function_code, int register_address) { + this->command_sent_callback_.call(function_code, register_address); } - /// get if a duplicate command can be sent - bool get_allow_duplicate_commands() { return this->allow_duplicate_commands_; } - /// called by esphome generated code to set the command_throttle period - void set_command_throttle(uint16_t command_throttle) { this->command_throttle_ = command_throttle; } + /// A command timed out; bump the consecutive-timeout counter used by can_send()/offline detection. + void increment_non_response_count() { this->cmd_non_responses_++; } + /// Whether more retries are allowed before the device is considered offline. Deliberately pooled + /// per device, not per command: online/offline is a property of the physical device. + bool can_send() { return this->cmd_non_responses_ <= this->max_cmd_retries_; } /// called by esphome generated code to set the offline_skip_updates void set_offline_skip_updates(uint16_t offline_skip_updates) { this->offline_skip_updates_ = offline_skip_updates; } - /// get the number of queued modbus commands (should be mostly empty) - size_t get_command_queue_length() { return command_queue_.size(); } /// get if the module is offline, didn't respond the last command bool get_module_offline() { return module_offline_; } /// Set callback for commands @@ -418,33 +458,48 @@ class ModbusController final : public PollingComponent, public modbus::ModbusCli protected: /// parse sensormap_ and create range of sequential addresses - size_t create_register_ranges_(); - // find register in sensormap. Returns iterator with all registers having the same start address - SensorSet find_sensors_(modbus::EntityType register_type, uint16_t start_address) const; - /// submit the read command for the address range to the send queue - void update_range_(RegisterRange &r); - /// parse incoming modbus data - void process_modbus_data_(const ModbusCommandItem *response); - /// send the next modbus command from the send queue - bool send_next_command_(); - /// dump the parsed sensormap for diagnostics - void dump_sensors_(); + /// Group the registered sensors into contiguous ranges and create one polling command per range. + void create_polling_commands_(); + /// build one persistent polling command from a range and add it to polling_command_items_ + void create_polling_command_(RegisterRange &&range) { + // A custom range polls the first sensor's custom_data (a ready-made raw frame); it needs the + // sensor constructor so the command references those bytes and decodes the real function code. + // The response still dispatches to every sensor in the range. + if (range.register_type == EntityType::CUSTOM && !range.sensors.empty()) { + auto &cmd = this->polling_command_items_.emplace_back(*this, this->hub_, this->address_, *range.sensors.begin()); + cmd.sensors = std::move(range.sensors); + cmd.skip_updates = range.skip_updates; // the range's merged rate, not the first sensor's + } else { + this->polling_command_items_.emplace_back(*this, this->hub_, this->address_, std::move(range)); + } + } + /// send a range's polling command if it is due this update + void update_range_(ModbusCommandItem &cmd); + /// The hub this controller's commands/entities send through, and the modbus address they target. + modbus::ModbusClientHub *hub_{nullptr}; + uint8_t address_{0}; /// Collection of all sensors for this component SensorSet sensorset_; - /// Continuous range of modbus registers - std::vector register_ranges_{}; - /// Hold the pending requests to be sent - std::list> command_queue_; - /// modbus response data waiting to get processed - std::queue> incoming_queue_; - /// if duplicate commands can be sent - bool allow_duplicate_commands_{false}; - /// when was the last send operation - uint32_t last_command_timestamp_{0}; - /// min time in ms between sending modbus commands - uint16_t command_throttle_{0}; + /// One persistent command per register range, each its own ModbusClientDevice. Built once in setup() + /// (create_polling_commands_ feeds each range straight in; the vector may reallocate as it grows, which + /// is safe because no command has registered with the hub yet) and never appended to afterward, so the + /// hub's device pointers stay valid once commands start sending. + std::vector polling_command_items_{}; + /// Dynamically queued one-shot commands (writes, custom commands). std::list keeps stable addresses. + std::list> one_shot_command_items_; + /// Erases one-shot commands flagged by unqueue_command(). Safe even when reached from inside a hub + /// callback (via an on_online/on_offline/on_command_sent automation that queues a command): the + /// destructor detaches via clear_tx_queue_for_device(), which the hub allows from callbacks, and the + /// item running its callback is not flagged until that callback returns. + void sweep_completed_one_shots_(); /// if module didn't respond the last command bool module_offline_{false}; + /// update_counter_ value at which the module went offline (for offline_skip_updates timing) + uint16_t module_offline_at_{0}; + /// counts update() cycles; drives skip_updates and offline timing + uint16_t update_counter_{0}; + /// consecutive non-responses; drives can_send() and offline detection + uint8_t cmd_non_responses_{0}; /// how many updates to skip if module is offline uint16_t offline_skip_updates_{0}; /// How many times we will retry a command if we get no response @@ -462,7 +517,7 @@ class ModbusController final : public PollingComponent, public modbus::ModbusCli * @param item SensorItem object * @return float value of data */ -inline float payload_to_float(std::span data, const SensorItem &item, size_t offset) { +inline float payload_to_float(std::span data, const SensorItem &item, uint8_t offset) { int64_t number = modbus::helpers::payload_to_number(data, item.sensor_value_type, offset, item.bitmask).value_or(0); float float_value; diff --git a/esphome/components/modbus_controller/number/modbus_number.cpp b/esphome/components/modbus_controller/number/modbus_number.cpp index a2a49dcaf0..7903b2e317 100644 --- a/esphome/components/modbus_controller/number/modbus_number.cpp +++ b/esphome/components/modbus_controller/number/modbus_number.cpp @@ -29,7 +29,7 @@ void ModbusNumber::parse_and_publish(std::span data) { } void ModbusNumber::control(float value) { - ModbusCommandItem write_cmd; + optional write_cmd; std::vector data; float write_value = value; // Is there are lambda configured? @@ -55,11 +55,11 @@ void ModbusNumber::control(float value) { #endif ESP_LOGV(TAG, "Modbus Number write raw: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); - write_cmd = ModbusCommandItem::create_custom_command( + write_cmd.emplace(ModbusCommandItem::create_custom_command( this->parent_, data, - [this, write_cmd](modbus::EntityType register_type, uint16_t start_address, const std::vector &data) { - this->parent_->on_write_register_response(write_cmd.register_type, this->start_address, data); - }); + [this](modbus::EntityType register_type, uint16_t start_address, std::span data) { + this->parent_->on_write_register_response(register_type, this->start_address, data); + })); } else { std::vector payload; modbus::helpers::float_to_payload(payload, write_value, this->sensor_value_type); @@ -70,20 +70,21 @@ void ModbusNumber::control(float value) { // Create and send the write command if (this->register_count == 1 && !this->use_write_multiple_) { - write_cmd = ModbusCommandItem::create_write_single_command(this->parent_, this->write_address(), payload[0]); + write_cmd.emplace( + ModbusCommandItem::create_write_single_command(this->parent_, this->write_address(), payload[0])); } else { - write_cmd = ModbusCommandItem::create_write_multiple_command(this->parent_, this->write_address(), - this->register_count, payload); + write_cmd.emplace(ModbusCommandItem::create_write_multiple_command(this->parent_, this->write_address(), + this->register_count, payload)); } // publish new value - write_cmd.on_data_func = [this, write_cmd, value](modbus::EntityType register_type, uint16_t start_address, - const std::vector &data) { + write_cmd->on_data_func = [this, value](modbus::EntityType register_type, uint16_t start_address, + std::span data) { // gets called when the write command is ack'd from the device - this->parent_->on_write_register_response(write_cmd.register_type, start_address, data); + this->parent_->on_write_register_response(register_type, start_address, data); this->publish_state(value); }; } - this->parent_->queue_command(write_cmd); + this->parent_->queue_command(std::move(*write_cmd)); this->publish_state(value); } void ModbusNumber::dump_config() { LOG_NUMBER(TAG, "Modbus Number", this); } diff --git a/esphome/components/modbus_controller/output/modbus_output.cpp b/esphome/components/modbus_controller/output/modbus_output.cpp index 95618a7505..48249f4387 100644 --- a/esphome/components/modbus_controller/output/modbus_output.cpp +++ b/esphome/components/modbus_controller/output/modbus_output.cpp @@ -58,15 +58,15 @@ void ModbusFloatOutput::write_state(float value) { } // Create and send the write command - ModbusCommandItem write_cmd; + optional write_cmd; if (this->register_count == 1 && !this->use_write_multiple_) { - write_cmd = - ModbusCommandItem::create_write_single_command(this->parent_, this->start_address + this->offset, data[0]); + write_cmd.emplace( + ModbusCommandItem::create_write_single_command(this->parent_, this->start_address + this->offset, data[0])); } else { - write_cmd = ModbusCommandItem::create_write_multiple_command(this->parent_, this->start_address + this->offset, - data.size(), data); + write_cmd.emplace(ModbusCommandItem::create_write_multiple_command( + this->parent_, this->start_address + this->offset, data.size(), data)); } - this->parent_->queue_command(write_cmd); + this->parent_->queue_command(std::move(*write_cmd)); } void ModbusFloatOutput::dump_config() { @@ -82,7 +82,7 @@ void ModbusFloatOutput::dump_config() { // ModbusBinaryOutput void ModbusBinaryOutput::write_state(bool state) { // This will be called every time the user requests a state change. - ModbusCommandItem cmd; + optional cmd; std::vector data; // Is there are lambda configured? @@ -105,11 +105,11 @@ void ModbusBinaryOutput::write_state(bool state) { #endif ESP_LOGV(TAG, "Modbus binary output write raw: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); - cmd = ModbusCommandItem::create_custom_command( + cmd.emplace(ModbusCommandItem::create_custom_command( this->parent_, data, - [this, cmd](modbus::EntityType register_type, uint16_t start_address, const std::vector &data) { - this->parent_->on_write_register_response(cmd.register_type, this->start_address, data); - }); + [this](modbus::EntityType register_type, uint16_t start_address, std::span data) { + this->parent_->on_write_register_response(register_type, this->start_address, data); + })); } else { ESP_LOGV(TAG, "Write new state: value is %s, type is %d address = %X, offset = %x", ONOFF(state), (int) this->register_type, this->start_address, this->offset); @@ -117,12 +117,14 @@ void ModbusBinaryOutput::write_state(bool state) { // offset for coil and discrete inputs is the coil/register number not bytes if (this->use_write_multiple_) { std::vector states{state}; - cmd = ModbusCommandItem::create_write_multiple_coils(this->parent_, this->start_address + this->offset, states); + cmd.emplace( + ModbusCommandItem::create_write_multiple_coils(this->parent_, this->start_address + this->offset, states)); } else { - cmd = ModbusCommandItem::create_write_single_coil(this->parent_, this->start_address + this->offset, state); + cmd.emplace( + ModbusCommandItem::create_write_single_coil(this->parent_, this->start_address + this->offset, state)); } } - this->parent_->queue_command(cmd); + this->parent_->queue_command(std::move(*cmd)); } void ModbusBinaryOutput::dump_config() { diff --git a/esphome/components/modbus_controller/select/modbus_select.cpp b/esphome/components/modbus_controller/select/modbus_select.cpp index c2d87619a1..0a9383b1b0 100644 --- a/esphome/components/modbus_controller/select/modbus_select.cpp +++ b/esphome/components/modbus_controller/select/modbus_select.cpp @@ -86,14 +86,15 @@ void ModbusSelect::control(size_t index) { } const uint16_t write_address = this->write_address(); - ModbusCommandItem write_cmd; + optional write_cmd; if ((this->register_count == 1) && (!this->use_write_multiple_)) { - write_cmd = ModbusCommandItem::create_write_single_command(this->parent_, write_address, data[0]); + write_cmd.emplace(ModbusCommandItem::create_write_single_command(this->parent_, write_address, data[0])); } else { - write_cmd = ModbusCommandItem::create_write_multiple_command(this->parent_, write_address, data.size(), data); + write_cmd.emplace( + ModbusCommandItem::create_write_multiple_command(this->parent_, write_address, data.size(), data)); } - this->parent_->queue_command(write_cmd); + this->parent_->queue_command(std::move(*write_cmd)); if (this->optimistic_) this->publish_state(index); diff --git a/esphome/components/modbus_controller/sensor/modbus_sensor.h b/esphome/components/modbus_controller/sensor/modbus_sensor.h index 61fdaacd10..9d66b2afa7 100644 --- a/esphome/components/modbus_controller/sensor/modbus_sensor.h +++ b/esphome/components/modbus_controller/sensor/modbus_sensor.h @@ -4,7 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" -#include +#include namespace esphome::modbus_controller { diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index adbd812348..810d904d85 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -59,7 +59,7 @@ void ModbusSwitch::parse_and_publish(std::span data) { void ModbusSwitch::write_state(bool state) { // This will be called every time the user requests a state change. - ModbusCommandItem cmd; + optional cmd; std::vector data; // Is there are lambda configured? if (this->write_transform_func_.has_value()) { @@ -81,11 +81,11 @@ void ModbusSwitch::write_state(bool state) { #endif ESP_LOGV(TAG, "Modbus Switch write raw: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); - cmd = ModbusCommandItem::create_custom_command( + cmd.emplace(ModbusCommandItem::create_custom_command( this->parent_, data, - [this, cmd](modbus::EntityType register_type, uint16_t start_address, const std::vector &data) { - this->parent_->on_write_register_response(cmd.register_type, this->start_address, data); - }); + [this](modbus::EntityType register_type, uint16_t start_address, std::span data) { + this->parent_->on_write_register_response(register_type, this->start_address, data); + })); } else { ESP_LOGV(TAG, "write_state '%s': new value = %s type = %d address = %X offset = %x", this->get_name().c_str(), ONOFF(state), (int) this->register_type, this->start_address, this->offset); @@ -93,21 +93,22 @@ void ModbusSwitch::write_state(bool state) { // offset for coil and discrete inputs is the coil/register number not bytes if (this->use_write_multiple_) { std::vector states{state}; - cmd = ModbusCommandItem::create_write_multiple_coils(this->parent_, this->write_address(), states); + cmd.emplace(ModbusCommandItem::create_write_multiple_coils(this->parent_, this->write_address(), states)); } else { - cmd = ModbusCommandItem::create_write_single_coil(this->parent_, this->write_address(), state); + cmd.emplace(ModbusCommandItem::create_write_single_coil(this->parent_, this->write_address(), state)); } } else { if (this->use_write_multiple_) { std::vector bool_states(1, state ? (0xFFFF & this->bitmask) : 0); - cmd = ModbusCommandItem::create_write_multiple_command(this->parent_, this->write_address(), 1, bool_states); + cmd.emplace( + ModbusCommandItem::create_write_multiple_command(this->parent_, this->write_address(), 1, bool_states)); } else { - cmd = ModbusCommandItem::create_write_single_command(this->parent_, this->write_address(), - state ? 0xFFFF & this->bitmask : 0u); + cmd.emplace(ModbusCommandItem::create_write_single_command(this->parent_, this->write_address(), + state ? 0xFFFF & this->bitmask : 0u)); } } } - this->parent_->queue_command(cmd); + this->parent_->queue_command(std::move(*cmd)); this->publish_state(state); } // ModbusSwitch end diff --git a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h index e8de46b55a..5bb16eb58a 100644 --- a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h +++ b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h @@ -4,7 +4,7 @@ #include "esphome/components/text_sensor/text_sensor.h" #include "esphome/core/component.h" -#include +#include namespace esphome::modbus_controller { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 3a243289ae..155fa2f6ba 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -184,6 +184,11 @@ template class SmallInlineBuffer { SmallInlineBuffer(const SmallInlineBuffer &) = delete; SmallInlineBuffer &operator=(const SmallInlineBuffer &) = delete; + bool empty() const { return this->len_ == 0; } + + // Conversion to std::span for compatibility with span-based APIs + operator std::span() const { return std::span(this->data(), this->len_); } + /// Resize to `size` bytes of (uninitialized) storage and return a writable pointer to fill. /// Allocates heap only when `size` exceeds the inline capacity. Use this when the contents are /// built in place (e.g. assembling a frame and appending a checksum) to avoid a staging copy. diff --git a/tests/components/modbus_controller/common.yaml b/tests/components/modbus_controller/common.yaml index 986d807dfb..67b022cdf5 100644 --- a/tests/components/modbus_controller/common.yaml +++ b/tests/components/modbus_controller/common.yaml @@ -158,8 +158,9 @@ sensor: address: 0x9020 value_type: U_WORD offset: 2 - # Raw-decode lambda in the documented style: `item->offset` locates this sensor's data in the range - # response, and the compatibility helpers accept the span the lambda is handed. + # Raw-decode lambda kept on the deprecated get_data() helper on purpose: `data` is a span now, so this + # pins that the compatibility overload still accepts one. The deprecation warning it raises is the + # point - it is what a user on the old helper sees. `item->offset` locates this sensor's data. - platform: modbus_controller modbus_controller_id: modbus_controller1 id: modbus_sensor_raw_lambda diff --git a/tests/components/modbus_controller/offline_cadence_test.cpp b/tests/components/modbus_controller/offline_cadence_test.cpp new file mode 100644 index 0000000000..1dade5dd98 --- /dev/null +++ b/tests/components/modbus_controller/offline_cadence_test.cpp @@ -0,0 +1,56 @@ +#include + +#include + +#include "esphome/components/modbus_controller/modbus_controller.h" + +namespace esphome::modbus_controller::testing { + +// A probe must come due exactly once per offline_skip_updates + 1 cycles from the trip point, +// for every phase between the trip cycle and the update counter. Pins the regression where a +// probe additionally required a range's skip_updates cadence to coincide, which some phase +// combinations never satisfy - the device then never polled again. +TEST(OfflineRetryCadence, DueOncePerWindowForEveryPhase) { + for (uint16_t skip = 0; skip <= 5; skip++) { + const uint16_t period = skip + 1; + for (uint16_t offline_at = 0; offline_at <= 7; offline_at++) { + uint16_t due_count = 0; + for (uint32_t counter = offline_at; counter < offline_at + 4u * period; counter++) { + if (offline_retry_due(static_cast(counter), offline_at, skip)) + due_count++; + } + EXPECT_EQ(due_count, 4) << "skip=" << skip << " offline_at=" << offline_at; + } + } +} + +// The first probe goes out within one window of going offline: after at most skip skipped cycles. +TEST(OfflineRetryCadence, FirstProbeWithinOneWindow) { + for (uint16_t skip = 0; skip <= 5; skip++) { + for (uint16_t offline_at = 0; offline_at <= 7; offline_at++) { + uint16_t counter = offline_at; + uint16_t skipped = 0; + while (!offline_retry_due(counter, offline_at, skip)) { + counter++; + skipped++; + ASSERT_LE(skipped, skip) << "skip=" << skip << " offline_at=" << offline_at; + } + } + } +} + +// The cadence neither stretches nor collapses when update_counter_ wraps past 65535. +TEST(OfflineRetryCadence, SurvivesCounterWraparound) { + const uint16_t skip = 2; // period 3 + const uint16_t offline_at = 65530; + uint16_t counter = offline_at; + uint16_t due_count = 0; + for (int i = 0; i < 30; i++) { // crosses the wrap mid-run + if (offline_retry_due(counter, offline_at, skip)) + due_count++; + counter++; + } + EXPECT_EQ(due_count, 10); +} + +} // namespace esphome::modbus_controller::testing diff --git a/tests/integration/fixtures/uart_mock_modbus_custom_command.yaml b/tests/integration/fixtures/uart_mock_modbus_custom_command.yaml new file mode 100644 index 0000000000..738e691110 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_custom_command.yaml @@ -0,0 +1,87 @@ +esphome: + name: uart-mock-modbus-custom-command + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - id: virtual_uart_controller + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_controller + id: virtual_modbus_controller + role: client + turnaround_time: 10ms + +modbus_controller: + - address: 1 + modbus_id: virtual_modbus_controller + id: modbus_controller_1 + update_interval: 1s + +modbus_server: + - address: 1 + modbus_id: virtual_modbus_server + id: modbus_server_1 + registers: + - address: 0x01 + value_type: U_WORD + read_lambda: return 259; + +sensor: + # Plain read to confirm the controller <-> server link is up. + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "plain_read" + address: 0x01 + register_type: holding + value_type: U_WORD + # Custom command: a raw frame {device address, function code, address hi, address lo, + # count hi, count lo}; the CRC is appended by the hub. Reads holding register 0x0001, + # count 1; the lambda parses the response payload (the register value, big-endian). + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "custom_read" + custom_command: [0x01, 0x03, 0x00, 0x01, 0x00, 0x01] + lambda: |- + if (data.size() < 2) return {}; + return (float) ((data[0] << 8) | data[1]); + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/fixtures/uart_mock_modbus_offline.yaml b/tests/integration/fixtures/uart_mock_modbus_offline.yaml new file mode 100644 index 0000000000..e4d2dfa294 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_offline.yaml @@ -0,0 +1,95 @@ +esphome: + name: uart-mock-modbus-offline + +host: +api: +logger: + level: DEBUG + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +# Whether the mock device answers requests. Starts false so the controller +# runs through its retries and goes offline; the test flips it via the +# "Serve" button to exercise the offline retry/recovery path. +globals: + - id: serve + type: bool + initial_value: "false" + +uart_mock: + - id: virtual_uart + baud_rate: 9600 + auto_start: true + debug: + on_tx: + # While serve is false every request times out; once true, answer the + # (only) request - read holding register 3 on device 1 - with value 259. + - uart_mock.inject_rx: + id: virtual_uart + data: !lambda |- + if (!id(serve)) + return {}; + return {0x01, 0x03, 0x02, 0x01, 0x03, 0xF9, 0xD5}; + +modbus: + - uart_id: virtual_uart + id: virtual_modbus_client + send_wait_time: 100ms + turnaround_time: 10ms + +modbus_controller: + - address: 1 + modbus_id: virtual_modbus_client + id: ctl + max_cmd_retries: 1 + # offline_skip_updates and the sensor's skip_updates deliberately share a period: offline + # probing must follow the offline cadence alone, or phase combinations like this one can + # leave the device never probing again. + offline_skip_updates: 1 + update_interval: never + on_offline: + then: + - lambda: id(link_state).publish_state(0); + on_online: + then: + - lambda: id(link_state).publish_state(1); + +sensor: + - platform: modbus_controller + modbus_controller_id: ctl + name: reg + id: reg + address: 0x03 + register_type: holding + value_type: U_WORD + skip_updates: 1 + # Mirrors the controller's online state so the test can await the transitions. + - platform: template + name: link_state + id: link_state + update_interval: never + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: |- + id(ctl).set_update_interval(200); + id(ctl).start_poller(); + - platform: template + name: "Serve" + id: serve_btn + on_press: + - globals.set: + id: serve + value: "true" diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index b7103b62dd..f1106f1c77 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -452,14 +452,80 @@ async def test_uart_mock_modbus_shared_address( _assert_no_modbus_errors(error_log_lines, warning_log_lines) -@pytest.mark.xfail( - strict=True, - reason="Fair bus scheduling across controllers sharing one client hub " - "requires the modbus_controller refactor in esphome#11781. On dev the " - "controllers each queue independently and contend for the bus, so the " - "request counts diverge. Expected to XPASS (and this marker removed) once " - "that refactor lands.", -) +@pytest.mark.asyncio +async def test_uart_mock_modbus_custom_command( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test a custom_command sensor polling a register served by the mock server. + + The custom_command is a raw frame (device address + PDU); the hub appends the CRC and + routes the response back to the polling command, whose sensor lambda parses the payload. + Guards the custom polling wiring: the command must reference the sensor's custom_data and + decode the real function code, or nothing is ever transmitted. A plain read on the same + register anchors the bus. + """ + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + expected_values = {"plain_read": 259, "custom_read": 259} + tracker = SensorTracker(list(expected_values.keys())) + futures = tracker.expect_all(expected_values) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + await tracker.await_all(futures) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_offline( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A silent device drives the controller offline; answering again recovers it. + + The mock answers nothing at first, so the controller burns through max_cmd_retries + (1 retry after the first timeout) and fires on_offline. While offline it keeps + retrying every offline_skip_updates+1 cycles. The test then flips the mock to + answering; the next retry gets a response, on_online fires, and the register value + publishes. This pins the pooled non-response counter, can_send() gating, the + offline retry cadence, and recovery - none of which the responding-path tests touch. + + The fixture gives offline_skip_updates and the sensor's skip_updates the same period + on purpose: offline probing must follow the offline cadence alone, since requiring + both cadences to coincide leaves phase combinations where no probe ever goes out. + """ + + tracker = SensorTracker(["link_state", "reg"]) + offline_future = tracker.expect("link_state", 0) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities = await tracker.setup_and_start_scenario(client) + + # The unanswered poll and its retry each time out (~100ms), then on_offline fires. + await tracker.await_change(offline_future, "link_state", timeout=5.0) + + # Register the recovery expectations before waking the device so no update is missed. + online_future = tracker.expect("link_state", 1) + value_future = tracker.expect("reg", 259) + serve_btn = find_entity(entities, "serve", ButtonInfo) + assert serve_btn is not None, "Serve button not found" + client.button_command(serve_btn.key) + + # The next offline-cadence retry gets an answer: back online, value published. + await tracker.await_change(online_future, "link_state", timeout=5.0) + await tracker.await_change(value_future, "reg", timeout=5.0) + + @pytest.mark.asyncio async def test_uart_mock_modbus_fairness( yaml_config: str, From 9bfce75bc57dd77609458aafffbb2b37dba71539 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Wed, 5 Aug 2026 19:46:01 +0300 Subject: [PATCH 1251/1815] [ln882h_ble_tracker] Use the shared ble_device_base automation layer (#18089) --- .../bk72xx_ble_tracker/automation.h | 2 +- .../components/ln882h_ble_tracker/__init__.py | 108 +++------------ .../ln882h_ble_tracker/automation.h | 126 +----------------- .../config/test_automations.yaml | 5 + .../test_automations_codegen.py | 8 +- .../ln882h_ble_tracker/__init__.py | 0 .../config/test_automations.yaml | 2 + .../test_automations_codegen.py | 5 +- 8 files changed, 43 insertions(+), 213 deletions(-) create mode 100644 tests/component_tests/ln882h_ble_tracker/__init__.py diff --git a/esphome/components/bk72xx_ble_tracker/automation.h b/esphome/components/bk72xx_ble_tracker/automation.h index 5a49fed04a..9017d19d71 100644 --- a/esphome/components/bk72xx_ble_tracker/automation.h +++ b/esphome/components/bk72xx_ble_tracker/automation.h @@ -8,8 +8,8 @@ #include "bk72xx_ble_tracker.h" -#include "esphome/components/ble_device_base/automation.h" #include "esphome/core/automation.h" +#include "esphome/core/helpers.h" namespace esphome::bk72xx_ble_tracker { diff --git a/esphome/components/ln882h_ble_tracker/__init__.py b/esphome/components/ln882h_ble_tracker/__init__.py index 08d14d0de4..8e958ff9f0 100644 --- a/esphome/components/ln882h_ble_tracker/__init__.py +++ b/esphome/components/ln882h_ble_tracker/__init__.py @@ -5,7 +5,8 @@ an explicit start_scan() call.""" from esphome import automation import esphome.codegen as cg from esphome.components import ble_device_base, ln882h_ble, ota -from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW +from esphome.components.ble_device_base import automation as ble_automation +from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW import esphome.config_validation as cv from esphome.const import ( CONF_ACTIVE, @@ -13,19 +14,16 @@ from esphome.const import ( CONF_DURATION, CONF_ID, CONF_INTERVAL, - CONF_MAC_ADDRESS, CONF_MANUFACTURER_ID, CONF_ON_BLE_ADVERTISE, CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE, CONF_ON_BLE_SERVICE_DATA_ADVERTISE, CONF_SERVICE_UUID, - CONF_TRIGGER_ID, ) from esphome.core import ID from esphome.types import ConfigType CONF_LN882H_BLE_ID = "ln882h_ble_id" -CONF_ON_SCAN_END = "on_scan_end" DEPENDENCIES = ["ln882x"] AUTO_LOAD = ["ble_device_base", "ln882h_ble"] @@ -39,27 +37,10 @@ LN882HBLETracker = ln882h_ble_tracker_ns.class_( StartScanAction = ln882h_ble_tracker_ns.class_("StartScanAction", automation.Action) StopScanAction = ln882h_ble_tracker_ns.class_("StopScanAction", automation.Action) -ESPBTDeviceConstRef = ( - cg.esphome_ns.namespace("ble_device_base") - .class_("ESPBTDevice") - .operator("ref") - .operator("const") -) -ESPBTAdvertiseTrigger = ln882h_ble_tracker_ns.class_( - "ESPBTAdvertiseTrigger", automation.Trigger.template(ESPBTDeviceConstRef) -) -adv_data_t = cg.std_vector.template(cg.uint8) -adv_data_t_const_ref = adv_data_t.operator("ref").operator("const") -BLEServiceDataAdvertiseTrigger = ln882h_ble_tracker_ns.class_( - "BLEServiceDataAdvertiseTrigger", automation.Trigger.template(adv_data_t_const_ref) -) -BLEManufacturerDataAdvertiseTrigger = ln882h_ble_tracker_ns.class_( - "BLEManufacturerDataAdvertiseTrigger", - automation.Trigger.template(adv_data_t_const_ref), -) -BLEEndOfScanTrigger = ln882h_ble_tracker_ns.class_( - "BLEEndOfScanTrigger", automation.Trigger.template() -) +ESPBTAdvertiseTrigger = ble_automation.ESPBTAdvertiseTrigger +BLEServiceDataAdvertiseTrigger = ble_automation.BLEServiceDataAdvertiseTrigger +BLEManufacturerDataAdvertiseTrigger = ble_automation.BLEManufacturerDataAdvertiseTrigger +BLEEndOfScanTrigger = ble_automation.BLEEndOfScanTrigger # LN882H SDK reference scan rate: 100 ms interval / 50 ms window (50 % duty). @@ -68,60 +49,33 @@ SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema( ) -# UUID string length -> setter width. 16/32-bit go out as plain hex literals, -# 128-bit as a reversed byte array (BLE wire order). Keyed exhaustively so an -# impossible length fails as a KeyError instead of silently picking a width -# (bt_uuid validation upstream only ever produces these three). -_UUID_WIDTHS = { - len(ble_device_base.BT_UUID16_FORMAT): "16", - len(ble_device_base.BT_UUID32_FORMAT): "32", - len(ble_device_base.BT_UUID128_FORMAT): "128", -} - CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(LN882HBLETracker), cv.GenerateID(CONF_LN882H_BLE_ID): cv.use_id(ln882h_ble.LN882HBLE), cv.Optional(CONF_SCAN_PARAMETERS, default={}): SCAN_PARAMETERS_SCHEMA, - cv.Optional(CONF_ON_BLE_ADVERTISE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ESPBTAdvertiseTrigger), - cv.Optional(CONF_MAC_ADDRESS): cv.ensure_list(cv.mac_address), - } + cv.Optional(CONF_ON_BLE_ADVERTISE): ble_automation.advertise_trigger_schema( + ESPBTAdvertiseTrigger ), - cv.Optional(CONF_ON_BLE_SERVICE_DATA_ADVERTISE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - BLEServiceDataAdvertiseTrigger - ), - cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, - cv.Required(CONF_SERVICE_UUID): ble_device_base.bt_uuid, - } + cv.Optional( + CONF_ON_BLE_SERVICE_DATA_ADVERTISE + ): ble_automation.uuid_trigger_schema( + BLEServiceDataAdvertiseTrigger, + {cv.Required(CONF_SERVICE_UUID): ble_device_base.bt_uuid}, ), cv.Optional( CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE - ): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - BLEManufacturerDataAdvertiseTrigger - ), - cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, - cv.Required(CONF_MANUFACTURER_ID): ble_device_base.bt_uuid, - } + ): ble_automation.uuid_trigger_schema( + BLEManufacturerDataAdvertiseTrigger, + {cv.Required(CONF_MANUFACTURER_ID): ble_device_base.bt_uuid}, ), - cv.Optional(CONF_ON_SCAN_END): automation.validate_automation( - {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(BLEEndOfScanTrigger)} + cv.Optional(CONF_ON_SCAN_END): ble_automation.scan_end_trigger_schema( + BLEEndOfScanTrigger ), } ).extend(cv.COMPONENT_SCHEMA) -# Triggers register as ble_device_base listeners in their constructors; count -# them where they are created so the StaticVector cannot be undersized. Shares -# the define with register_ble_device() via the core slot-counter factory. -_count_listener = cg.slot_counter(ble_device_base.LISTENER_COUNT_DEFINE) - - @automation.register_action( "ln882h_ble_tracker.start_scan", StartScanAction, @@ -191,11 +145,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_configured_continuous(scan[CONF_CONTINUOUS])) for conf in config.get(CONF_ON_BLE_ADVERTISE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - if (macs := conf.get(CONF_MAC_ADDRESS)) is not None: - cg.add(trigger.set_addresses([it.as_hex for it in macs])) - await automation.build_automation(trigger, [(ESPBTDeviceConstRef, "x")], conf) - _count_listener() + await ble_automation.advertise_trigger_to_code(conf, var) for trigger_key, uuid_key, setter_prefix in ( (CONF_ON_BLE_SERVICE_DATA_ADVERTISE, CONF_SERVICE_UUID, "set_service_uuid"), @@ -206,23 +156,9 @@ async def to_code(config: ConfigType) -> None: ), ): for conf in config.get(trigger_key, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - uuid = conf[uuid_key] - width = _UUID_WIDTHS[len(uuid)] - value = ( - ble_device_base.as_hex(uuid) - if width != "128" - else ble_device_base.as_reversed_hex_array(uuid) + await ble_automation.uuid_trigger_to_code( + conf, var, uuid_key, setter_prefix ) - cg.add(getattr(trigger, f"{setter_prefix}{width}")(value)) - if (mac := conf.get(CONF_MAC_ADDRESS)) is not None: - cg.add(trigger.set_address(mac.as_hex)) - await automation.build_automation( - trigger, [(adv_data_t_const_ref, "x")], conf - ) - _count_listener() for conf in config.get(CONF_ON_SCAN_END, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - _count_listener() + await ble_automation.scan_end_trigger_to_code(conf, var) diff --git a/esphome/components/ln882h_ble_tracker/automation.h b/esphome/components/ln882h_ble_tracker/automation.h index 43f526e064..8b211384a0 100644 --- a/esphome/components/ln882h_ble_tracker/automation.h +++ b/esphome/components/ln882h_ble_tracker/automation.h @@ -1,6 +1,5 @@ -// Automation triggers and actions for ln882h_ble_tracker: triggers follow the -// esp32_ble_tracker design; only the scan-control actions are -// platform-specific. +// Scan-control actions for ln882h_ble_tracker. The automation triggers are the +// neutral ble_device_base classes (ble_device_base/automation.h). #pragma once @@ -11,9 +10,6 @@ #include "esphome/core/automation.h" #include "esphome/core/helpers.h" -#include -#include - namespace esphome::ln882h_ble_tracker { template class StartScanAction final : public Action, public Parented { @@ -46,124 +42,6 @@ template class StopScanAction final : public Action, publ void play(const Ts &...x) override { this->parent_->stop_scan(); } }; -// --------------------------------------------------------------------------- -// Automation triggers. -// -// Each trigger is a ble_device_base::ESPBTDeviceListener registered on the hub — -// the same design as esp32_ble_tracker, where the triggers sit in the listener -// list and their parse_device() return feeds the "Found device" suppression. -// --------------------------------------------------------------------------- - -// on_ble_advertise: fires on every BLE advertisement, optionally filtered to one or more MACs. -class ESPBTAdvertiseTrigger final : public Trigger, - public ble_device_base::ESPBTDeviceListener { - public: - explicit ESPBTAdvertiseTrigger(LN882HBLETracker *parent) { parent->register_listener(this); } - - void set_addresses(std::initializer_list addresses) { this->addresses_ = addresses; } - - bool parse_device(const ble_device_base::ESPBTDevice &device) override { - if (!this->addresses_.empty() && std::find(this->addresses_.begin(), this->addresses_.end(), - device.address_uint64()) == this->addresses_.end()) { - return false; - } - this->trigger(device); - return true; - } - - protected: - FixedVector addresses_; -}; - -// on_ble_service_data_advertise: fires when an advertisement contains service -// data for the given UUID. Optional single-MAC filter. -class BLEServiceDataAdvertiseTrigger final : public Trigger, - public ble_device_base::ESPBTDeviceListener { - public: - explicit BLEServiceDataAdvertiseTrigger(LN882HBLETracker *parent) { parent->register_listener(this); } - - void set_service_uuid16(uint64_t uuid) { - this->uuid_ = ble_device_base::ESPBTUUID::from_uint16(static_cast(uuid)); - } - void set_service_uuid32(uint64_t uuid) { - this->uuid_ = ble_device_base::ESPBTUUID::from_uint32(static_cast(uuid)); - } - void set_service_uuid128(const uint8_t *uuid) { this->uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); } - - void set_address(uint64_t address) { - this->address_ = address; - this->has_address_ = true; - } - - bool parse_device(const ble_device_base::ESPBTDevice &device) override { - if (this->has_address_ && device.address_uint64() != this->address_) { - return false; - } - for (const auto &sd : device.get_service_datas()) { - if (sd.uuid == this->uuid_) { - this->trigger(sd.data); - return true; - } - } - return false; - } - - protected: - ble_device_base::ESPBTUUID uuid_{}; - uint64_t address_{0}; - bool has_address_{false}; -}; - -// on_ble_manufacturer_data_advertise: fires when an advertisement contains -// manufacturer data for the given ID. Optional single-MAC filter. -class BLEManufacturerDataAdvertiseTrigger final : public Trigger, - public ble_device_base::ESPBTDeviceListener { - public: - explicit BLEManufacturerDataAdvertiseTrigger(LN882HBLETracker *parent) { parent->register_listener(this); } - - void set_manufacturer_uuid16(uint64_t uuid) { - this->uuid_ = ble_device_base::ESPBTUUID::from_uint16(static_cast(uuid)); - } - void set_manufacturer_uuid32(uint64_t uuid) { - this->uuid_ = ble_device_base::ESPBTUUID::from_uint32(static_cast(uuid)); - } - void set_manufacturer_uuid128(const uint8_t *uuid) { this->uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); } - - void set_address(uint64_t address) { - this->address_ = address; - this->has_address_ = true; - } - - bool parse_device(const ble_device_base::ESPBTDevice &device) override { - if (this->has_address_ && device.address_uint64() != this->address_) { - return false; - } - for (const auto &md : device.get_manufacturer_datas()) { - if (md.uuid == this->uuid_) { - this->trigger(md.data); - return true; - } - } - return false; - } - - protected: - ble_device_base::ESPBTUUID uuid_{}; - uint64_t address_{0}; - bool has_address_{false}; -}; - -// on_scan_end: fires whenever a scan period ends (duration elapsed or stop_scan -// called). A listener whose on_scan_end() hook fires the trigger — never claims -// devices (parse_device always returns false). -class BLEEndOfScanTrigger final : public Trigger<>, public ble_device_base::ESPBTDeviceListener { - public: - explicit BLEEndOfScanTrigger(LN882HBLETracker *parent) { parent->register_listener(this); } - - bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; } - void on_scan_end() override { this->trigger(); } -}; - } // namespace esphome::ln882h_ble_tracker #endif // USE_LIBRETINY diff --git a/tests/component_tests/bk72xx_ble_tracker/config/test_automations.yaml b/tests/component_tests/bk72xx_ble_tracker/config/test_automations.yaml index 1701807984..a60e7cca05 100644 --- a/tests/component_tests/bk72xx_ble_tracker/config/test_automations.yaml +++ b/tests/component_tests/bk72xx_ble_tracker/config/test_automations.yaml @@ -4,12 +4,17 @@ esphome: then: - bk72xx_ble_tracker.start_scan: continuous: true + # Bare form: restores the configured scan_parameters mode — no + # set_continuous emitted (asserted in the codegen test). + - bk72xx_ble_tracker.start_scan: - bk72xx_ble_tracker.stop_scan bk72xx: board: cb2s bk72xx_ble_tracker: + scan_parameters: + continuous: false on_ble_advertise: - mac_address: - AC:37:43:77:5F:4C diff --git a/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py b/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py index c98bb8a111..3a03f98adf 100644 --- a/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py +++ b/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py @@ -39,9 +39,15 @@ def test_trigger_codegen( "set_manufacturer_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB," "0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp ) - # scan-control actions: templatable continuous lambda + parented actions + # scan-control actions: templatable continuous lambda + parented actions. + # Exactly one set_continuous: the bare start_scan emits none, pinning the + # restore-configured-mode divergence from esp32 against a future default=. + assert main_cpp.count("->set_continuous(") == 1 assert "startscanaction_id->set_continuous(" in main_cpp assert "stopscanaction_id->set_parent(" in main_cpp + # scan_parameters continuous: false reaches the YAML-mode setter, not the + # runtime override. + assert "->set_configured_continuous(false)" in main_cpp # Constructor call, not just the declaration: the parent argument is what # registers the trigger as a listener. assert re.search( diff --git a/tests/component_tests/ln882h_ble_tracker/__init__.py b/tests/component_tests/ln882h_ble_tracker/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/ln882h_ble_tracker/config/test_automations.yaml b/tests/component_tests/ln882h_ble_tracker/config/test_automations.yaml index d83e6c0883..16a215f026 100644 --- a/tests/component_tests/ln882h_ble_tracker/config/test_automations.yaml +++ b/tests/component_tests/ln882h_ble_tracker/config/test_automations.yaml @@ -13,6 +13,8 @@ ln882x: board: generic-ln882h ln882h_ble_tracker: + scan_parameters: + continuous: false on_ble_advertise: - mac_address: - AC:37:43:77:5F:4C diff --git a/tests/component_tests/ln882h_ble_tracker/test_automations_codegen.py b/tests/component_tests/ln882h_ble_tracker/test_automations_codegen.py index 43c14c8054..608a4c6694 100644 --- a/tests/component_tests/ln882h_ble_tracker/test_automations_codegen.py +++ b/tests/component_tests/ln882h_ble_tracker/test_automations_codegen.py @@ -40,10 +40,13 @@ def test_trigger_codegen( assert main_cpp.count("->set_continuous(") == 1 assert "startscanaction_id->set_continuous(" in main_cpp assert "stopscanaction_id->set_parent(" in main_cpp + # scan_parameters continuous: false reaches the YAML-mode setter, not the + # runtime override. + assert "->set_configured_continuous(false)" in main_cpp # Constructor call, not just the declaration: the parent argument is what # registers the trigger as a listener. assert re.search( - r"new\(\w+\) ln882h_ble_tracker::BLEEndOfScanTrigger\(\w+\)", main_cpp + r"new\(\w+\) ble_device_base::BLEEndOfScanTrigger\(\w+\)", main_cpp ) # Seven triggers register as listeners; an undercount silently drops the From 4627f07a7bd930db087f00826b7b24657b1a9097 Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Wed, 5 Aug 2026 20:11:03 +0200 Subject: [PATCH 1252/1815] [mcp4461] nonvolatile-by-default persistence, TCON boot sync, wiper actions (#17561) Co-authored-by: Claude Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Oliver Kleinecke Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/components/mcp4461/mcp4461.cpp | 153 +++++++++++++++++- esphome/components/mcp4461/mcp4461.h | 28 +++- esphome/components/mcp4461/output/__init__.py | 116 +++++++++++++ .../components/mcp4461/output/automation.h | 56 +++++++ .../mcp4461/output/mcp4461_output.cpp | 6 + .../mcp4461/output/mcp4461_output.h | 3 + tests/components/mcp4461/common.yaml | 71 +++++--- 7 files changed, 404 insertions(+), 29 deletions(-) create mode 100644 esphome/components/mcp4461/output/automation.h diff --git a/esphome/components/mcp4461/mcp4461.cpp b/esphome/components/mcp4461/mcp4461.cpp index e83a6847d6..abc74b9e6d 100644 --- a/esphome/components/mcp4461/mcp4461.cpp +++ b/esphome/components/mcp4461/mcp4461.cpp @@ -21,7 +21,18 @@ void Mcp4461Component::setup() { auto init_val = this->reg_[i].initial_value; if (init_val.has_value()) { uint16_t initial_state = static_cast(*init_val * 256.0f); - this->write_wiper_level_(i, initial_state); + if (i > 3) { + // NV wiper: an unconditional write would cost one EEPROM erase/write cycle on EVERY + // boot. Only write when the stored value actually differs — and always write when + // the read itself failed (a failed read returns 0, which would silently skip the + // write whenever initial_value is 0). + bool read_ok = false; + if (this->read_wiper_level_(i, &read_ok) != initial_state || !read_ok) { + this->write_wiper_level_(i, initial_state); + } + } else { + this->write_wiper_level_(i, initial_state); + } } if (this->reg_[i].enabled) { this->reg_[i].state = this->read_wiper_level_(i); @@ -34,6 +45,23 @@ void Mcp4461Component::setup() { } } } + // Push the YAML terminal configuration to the TCON registers. TCON is volatile — on POR + // the chip restores wiper levels from the NV registers but resets TCON to "all terminals + // connected", so any terminal_a/b/w disables from the config MUST be written here. + for (uint8_t t = 0; t < 2; t++) { + Mcp4461TerminalIdx terminal_connector = static_cast(t); + uint8_t terminal_byte = this->calc_terminal_connector_byte_(terminal_connector); + this->set_terminal_register_(terminal_connector, terminal_byte); + } +} + +void Mcp4461Component::set_nonvolatile(Mcp4461WiperIdx wiper, uint32_t write_delay_ms) { + uint8_t wiper_idx = static_cast(wiper); + if (wiper_idx > 3) { + return; // NV channels E-H are the persistence target themselves + } + this->reg_[wiper_idx].nonvolatile = true; + this->reg_[wiper_idx].nonvolatile_write_delay_ms = write_delay_ms; } void Mcp4461Component::set_initial_value(Mcp4461WiperIdx wiper, float initial_value) { @@ -77,9 +105,12 @@ void Mcp4461Component::dump_config() { // so also invalid for nonvolatile. For these, only print current level. // reworked to be a one-line intentionally, as output would not be in order if (i < 4) { - ESP_LOGCONFIG(TAG, " ├── Volatile wiper [%u] level: %u, Status: %s, HW: %s, A: %s, B: %s, W: %s", i, - this->reg_[i].state, ONOFF(this->reg_[i].enabled), ONOFF(this->reg_[i].terminal_hw), - ONOFF(this->reg_[i].terminal_a), ONOFF(this->reg_[i].terminal_b), ONOFF(this->reg_[i].terminal_w)); + ESP_LOGCONFIG(TAG, + " ├── Volatile wiper [%u] level: %u, Status: %s, HW: %s, " + "A: %s, B: %s, W: %s, NV: %s", + i, this->reg_[i].state, ONOFF(this->reg_[i].enabled), ONOFF(this->reg_[i].terminal_hw), + ONOFF(this->reg_[i].terminal_a), ONOFF(this->reg_[i].terminal_b), ONOFF(this->reg_[i].terminal_w), + ONOFF(this->reg_[i].nonvolatile)); } else { ESP_LOGCONFIG(TAG, " ├── Nonvolatile wiper [%u] level: %u", i, this->reg_[i].state); } @@ -92,8 +123,10 @@ void Mcp4461Component::loop() { } for (uint8_t i = 0; i < 8; i++) { if (this->reg_[i].update_level) { - // set wiper i state if changed - if (this->reg_[i].state != this->read_wiper_level_(i)) { + // set wiper i state if changed — a failed read (returns 0) must not suppress the + // write when the target state is 0, same hardening as the NV read-compare paths + bool read_ok = false; + if (this->reg_[i].state != this->read_wiper_level_(i, &read_ok) || !read_ok) { this->write_wiper_level_(i, this->reg_[i].state); } } @@ -112,6 +145,67 @@ void Mcp4461Component::loop() { } this->reg_[i].update_terminal = false; } + this->process_nonvolatile_dirty_(); +} + +void Mcp4461Component::process_nonvolatile_dirty_() { + const uint32_t now = millis(); + for (uint8_t i = 0; i < 4; i++) { + if (!this->reg_[i].nonvolatile || !this->reg_[i].nonvolatile_dirty) { + continue; + } + if ((now - this->reg_[i].last_level_change_ms) < this->reg_[i].nonvolatile_write_delay_ms) { + continue; // still settling — debounce window not over yet + } + // Never block the loop on a still-running EEPROM cycle (t_WC up to 10 ms); datasheet: + // during an EEPROM write only volatile commands are accepted. Retry on the next loop. + if (this->is_writing_()) { + continue; + } + // Clear the dirty flag on success — and equally when WP or WiperLock block the write + // permanently, instead of retrying forever. + if (this->store_level_nonvolatile_(static_cast(i)) || this->write_protected_ || + this->reg_[i].wiper_lock_active) { + this->reg_[i].nonvolatile_dirty = false; + } else { + // Transient failure (e.g. I2C error): without this, the retry fires on every single + // loop() iteration, spamming a warning each time. Re-arming the timestamp reuses the + // stability delay as a natural retry backoff. + this->reg_[i].last_level_change_ms = now; + } + } +} + +bool Mcp4461Component::store_level_nonvolatile_(Mcp4461WiperIdx wiper) { + if (this->is_failed()) { + ESP_LOGE(TAG, "%s", LOG_STR_ARG(this->get_message_string(this->error_code_))); + return false; + } + uint8_t wiper_idx = static_cast(wiper); + if (wiper_idx > 3) { + // E-H ARE the nonvolatile registers — keep this consistent with the other guards + // instead of failing silently (reachable via the store_nonvolatile action). + ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_PROHIBITED_FOR_NONVOLATILE))); + return false; + } + if (this->reg_[wiper_idx].wiper_lock_active) { + ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_WIPER_LOCKED))); + return false; + } + const uint16_t level = this->reg_[wiper_idx].state; + // Skip the EEPROM cycle entirely when the NV register already holds the value. A failed + // read must NOT count as a match (it returns 0): fall through to the write instead — if + // the bus is really down, the write fails too and the dirty flag stays set for a retry. + bool read_ok = false; + if (this->read_wiper_level_(wiper_idx + 4, &read_ok) == level && read_ok) { + return true; + } + ESP_LOGV(TAG, "Persisting wiper %u level %u to nonvolatile register", wiper_idx, level); + if (!this->mcp4461_write_(this->get_wiper_address_(wiper_idx + 4), level, true)) { + ESP_LOGW(TAG, "Error persisting wiper %u level %u", wiper_idx, level); + return false; + } + return true; } uint8_t Mcp4461Component::get_status_register_() { @@ -210,7 +304,10 @@ uint16_t Mcp4461Component::get_wiper_level_(Mcp4461WiperIdx wiper) { return this->read_wiper_level_(wiper_idx); } -uint16_t Mcp4461Component::read_wiper_level_(uint8_t wiper_idx) { +uint16_t Mcp4461Component::read_wiper_level_(uint8_t wiper_idx, bool *ok) { + if (ok != nullptr) { + *ok = false; + } uint8_t addr = this->get_wiper_address_(wiper_idx); uint8_t reg = addr | static_cast(Mcp4461Commands::READ); if (wiper_idx > 3) { @@ -225,6 +322,9 @@ uint16_t Mcp4461Component::read_wiper_level_(uint8_t wiper_idx) { ESP_LOGW(TAG, "Error fetching %swiper %u value", (wiper_idx > 3) ? "nonvolatile " : "", wiper_idx); return 0; } + if (ok != nullptr) { + *ok = true; + } return buf; } @@ -265,6 +365,10 @@ bool Mcp4461Component::set_wiper_level_(Mcp4461WiperIdx wiper, uint16_t value) { ESP_LOGV(TAG, "Setting MCP4461 wiper %u to %u", wiper_idx, value); this->reg_[wiper_idx].state = value; this->reg_[wiper_idx].update_level = true; + if (this->reg_[wiper_idx].nonvolatile) { + this->reg_[wiper_idx].nonvolatile_dirty = true; + this->reg_[wiper_idx].last_level_change_ms = millis(); + } return true; } @@ -335,6 +439,12 @@ bool Mcp4461Component::increase_wiper_(Mcp4461WiperIdx wiper) { ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_WIPER_LOCKED))); return false; } + if (wiper_idx > 3) { + // Datasheet: increment commands are only valid for the volatile wiper registers — + // the chip NACKs them on nonvolatile addresses. + ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_PROHIBITED_FOR_NONVOLATILE))); + return false; + } if (this->reg_[wiper_idx].state == 256) { ESP_LOGV(TAG, "Maximum wiper level reached, further increase of wiper %u prohibited", wiper_idx); return false; @@ -349,6 +459,10 @@ bool Mcp4461Component::increase_wiper_(Mcp4461WiperIdx wiper) { return false; } this->reg_[wiper_idx].state++; + if (this->reg_[wiper_idx].nonvolatile) { + this->reg_[wiper_idx].nonvolatile_dirty = true; + this->reg_[wiper_idx].last_level_change_ms = millis(); + } return true; } @@ -366,6 +480,12 @@ bool Mcp4461Component::decrease_wiper_(Mcp4461WiperIdx wiper) { ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_WIPER_LOCKED))); return false; } + if (wiper_idx > 3) { + // Datasheet: decrement commands are only valid for the volatile wiper registers — + // the chip NACKs them on nonvolatile addresses. + ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_PROHIBITED_FOR_NONVOLATILE))); + return false; + } if (this->reg_[wiper_idx].state == 0) { ESP_LOGV(TAG, "Minimum wiper level reached, further decrease of wiper %u prohibited", wiper_idx); return false; @@ -380,11 +500,18 @@ bool Mcp4461Component::decrease_wiper_(Mcp4461WiperIdx wiper) { return false; } this->reg_[wiper_idx].state--; + if (this->reg_[wiper_idx].nonvolatile) { + this->reg_[wiper_idx].nonvolatile_dirty = true; + this->reg_[wiper_idx].last_level_change_ms = millis(); + } return true; } uint8_t Mcp4461Component::calc_terminal_connector_byte_(Mcp4461TerminalIdx terminal_connector) { - uint8_t i = static_cast(terminal_connector) <= 1 ? 0 : 2; + // TCON0 covers wipers 0/1 (A/B), TCON1 covers wipers 2/3 (C/D). The enum only holds + // 0 and 1, so the old `<= 1 ? 0 : 2` collapsed to always-0 and built TCON1 from + // channels A/B's flags — mirror the (correct) read path in update_terminal_register_(). + uint8_t i = static_cast(terminal_connector) == 0 ? 0 : 2; uint8_t new_value_byte = 0; new_value_byte += static_cast(this->reg_[i].terminal_b); new_value_byte += static_cast(this->reg_[i].terminal_w) << 1; @@ -471,6 +598,12 @@ void Mcp4461Component::enable_terminal_(Mcp4461WiperIdx wiper, char terminal) { return; } uint8_t wiper_idx = static_cast(wiper); + if (wiper_idx > 3) { + // Terminal control only exists for the volatile wipers; loop() would otherwise emit + // an unrelated TCON write and silently drop the request. + ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_PROHIBITED_FOR_NONVOLATILE))); + return; + } ESP_LOGV(TAG, "Enabling terminal %c of wiper %u", terminal, wiper_idx); switch (terminal) { case 'h': @@ -498,6 +631,10 @@ void Mcp4461Component::disable_terminal_(Mcp4461WiperIdx wiper, char terminal) { return; } uint8_t wiper_idx = static_cast(wiper); + if (wiper_idx > 3) { + ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_PROHIBITED_FOR_NONVOLATILE))); + return; + } ESP_LOGV(TAG, "Disabling terminal %c of wiper %u", terminal, wiper_idx); switch (terminal) { case 'h': diff --git a/esphome/components/mcp4461/mcp4461.h b/esphome/components/mcp4461/mcp4461.h index a577a4b482..933d92c1fa 100644 --- a/esphome/components/mcp4461/mcp4461.h +++ b/esphome/components/mcp4461/mcp4461.h @@ -17,6 +17,16 @@ struct WiperState { bool wiper_lock_active = false; bool update_level = false; bool update_terminal = false; + // Nonvolatile persistence (volatile wipers 0-3 only): when enabled, every level change is + // mirrored into the chip's NV wiper register after nonvolatile_write_delay of stability, so + // the chip restores it on power-on. The delay both debounces bursts (e.g. light transitions + // writing dozens of levels per second) and protects the EEPROM's limited endurance — + // without it, every intermediate step would cost one of the ~1M erase/write cycles and + // stall the bus for up to t_WC (10 ms) each. + bool nonvolatile = false; + uint32_t nonvolatile_write_delay_ms = 1000; + bool nonvolatile_dirty = false; + uint32_t last_level_change_ms = 0; }; // default wiper state is 128 / 0x80h @@ -86,6 +96,11 @@ class Mcp4461Component final : public Component, public i2c::I2CDevice { /// @param[in] wiper - the wiper to set the value for /// @param[in] initial_value - the initial value in range 0-1.0 as float void set_initial_value(Mcp4461WiperIdx wiper, float initial_value); + /// @brief enable nonvolatile persistence for a volatile wiper (0-3): every level change is + /// mirrored to the corresponding NV wiper register after the given stability delay + /// @param[in] wiper - the (volatile) wiper to persist + /// @param[in] write_delay_ms - stability delay before the NV write (debounce / EEPROM wear) + void set_nonvolatile(Mcp4461WiperIdx wiper, uint32_t write_delay_ms); /// @brief public function used to set disable terminal config /// @param[in] wiper - the wiper to set the value for /// @param[in] terminal - the terminal to disable, one of ['a','b','w','h'] @@ -98,7 +113,10 @@ class Mcp4461Component final : public Component, public i2c::I2CDevice { bool read_16_(uint8_t address, uint16_t *buf); void update_write_protection_status_(); uint8_t get_wiper_address_(uint8_t wiper); - uint16_t read_wiper_level_(uint8_t wiper); + /// Read a wiper register. On I2C failure returns 0 — callers that must distinguish + /// a real 0 from a failed read pass `ok` (added for the NV read-compare paths, where + /// acting on a failed read would skip a required write or drop a pending persist). + uint16_t read_wiper_level_(uint8_t wiper, bool *ok = nullptr); uint8_t get_status_register_(); uint16_t get_wiper_level_(Mcp4461WiperIdx wiper); bool set_wiper_level_(Mcp4461WiperIdx wiper, uint16_t value); @@ -110,6 +128,11 @@ class Mcp4461Component final : public Component, public i2c::I2CDevice { void enable_terminal_(Mcp4461WiperIdx wiper, char terminal); void disable_terminal_(Mcp4461WiperIdx, char terminal); bool is_writing_(); + /// Copy the current volatile level of wiper 0-3 into its NV register (immediate, blocking + /// only for a pending previous EEPROM cycle). Returns false while WP is active or on error. + bool store_level_nonvolatile_(Mcp4461WiperIdx wiper); + /// Deferred NV mirroring driven from loop() — see WiperState::nonvolatile. + void process_nonvolatile_dirty_(); bool is_eeprom_ready_for_writing_(bool wait_if_not_ready); void write_wiper_level_(uint8_t wiper, uint16_t value); bool mcp4461_write_(uint8_t addr, uint16_t data, bool nonvolatile = false); @@ -139,6 +162,9 @@ class Mcp4461Component final : public Component, public i2c::I2CDevice { return LOG_STR("MCP4461 Wiper is locked using WiperLock-technology. All actions on this wiper are prohibited."); case MCP4461_STATUS_OK: return LOG_STR("Status OK"); + case MCP4461_PROHIBITED_FOR_NONVOLATILE: + return LOG_STR( + "Increment/decrement, store, and terminal control are prohibited on the nonvolatile wipers (E-H)."); default: return LOG_STR("Unknown"); } diff --git a/esphome/components/mcp4461/output/__init__.py b/esphome/components/mcp4461/output/__init__.py index 0d145d81d3..1642f6149a 100644 --- a/esphome/components/mcp4461/output/__init__.py +++ b/esphome/components/mcp4461/output/__init__.py @@ -1,3 +1,4 @@ +from esphome import automation import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv @@ -26,6 +27,43 @@ CHANNEL_OPTIONS = { CONF_TERMINAL_A = "terminal_a" CONF_TERMINAL_B = "terminal_b" CONF_TERMINAL_W = "terminal_w" +CONF_NONVOLATILE = "nonvolatile" +CONF_NONVOLATILE_WRITE_DELAY = "nonvolatile_write_delay" + +# Volatile wiper channels that have a nonvolatile shadow register on the chip +VOLATILE_CHANNELS = ("A", "B", "C", "D") + + +def _validate_nonvolatile(config): + channel = str(config[CONF_CHANNEL]) + + # Channels E-H address the nonvolatile registers directly — the mirroring options only + # make sense for the volatile channels A-D. + if channel not in VOLATILE_CHANNELS: + # Only reject what the user EXPLICITLY asked for and cannot have: enabling the + # mirroring or tuning its delay on E-H. An explicit `nonvolatile: false` is a + # harmless no-op and stays valid; bare configs (no key at all) must keep working. + # NOTE: FINAL_VALIDATE_SCHEMA intentionally mutates `config` in-place (uses setdefault) to apply defaults for callers. + if config.get(CONF_NONVOLATILE) or CONF_NONVOLATILE_WRITE_DELAY in config: + raise cv.Invalid( + f"enabling '{CONF_NONVOLATILE}' or setting '{CONF_NONVOLATILE_WRITE_DELAY}' is only valid for the " + f"volatile channels A-D; channels E-H are the nonvolatile registers themselves" + ) + return config + + config.setdefault(CONF_NONVOLATILE, True) + if config[CONF_NONVOLATILE]: + config.setdefault( + CONF_NONVOLATILE_WRITE_DELAY, + cv.positive_time_period_milliseconds("1s"), + ) + elif CONF_NONVOLATILE_WRITE_DELAY in config: + # Same consistency as the E-H rejection above: never silently ignore user input. + raise cv.Invalid( + f"'{CONF_NONVOLATILE_WRITE_DELAY}' requires '{CONF_NONVOLATILE}: true'" + ) + return config + CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { @@ -36,9 +74,21 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( cv.Optional(CONF_TERMINAL_B, default=True): cv.boolean, cv.Optional(CONF_TERMINAL_W, default=True): cv.boolean, cv.Optional(CONF_INITIAL_VALUE): cv.float_range(min=0.0, max=1.0), + # No schema defaults here: a default would materialize the keys on EVERY channel, + # making existing bare E-H configs fail final validation. The effective defaults + # (nonvolatile: true, delay 1s) are applied for the volatile channels A-D inside + # _validate_nonvolatile instead. Default-on rationale: the chip restores the + # nonvolatile wiper levels at power-on, so persisting every settled level change is + # the least surprising behavior — the pot simply comes back where it was. The write + # is deferred by nonvolatile_write_delay to debounce transitions and protect the + # EEPROM's endurance. + cv.Optional(CONF_NONVOLATILE): cv.boolean, + cv.Optional(CONF_NONVOLATILE_WRITE_DELAY): cv.positive_time_period_milliseconds, } ) +FINAL_VALIDATE_SCHEMA = _validate_nonvolatile + async def to_code(config): parent = await cg.get_variable(config[CONF_MCP4461_ID]) @@ -57,5 +107,71 @@ async def to_code(config): cg.add( parent.set_initial_value(config[CONF_CHANNEL], config[CONF_INITIAL_VALUE]) ) + if str(config[CONF_CHANNEL]) in VOLATILE_CHANNELS and config[CONF_NONVOLATILE]: + cg.add( + parent.set_nonvolatile( + config[CONF_CHANNEL], + config[CONF_NONVOLATILE_WRITE_DELAY], + ) + ) await output.register_output(var, config) await cg.register_parented(var, config[CONF_MCP4461_ID]) + + +# ---- Actions ---- +WiperIncreaseAction = mcp4461_ns.class_("WiperIncreaseAction", automation.Action) +WiperDecreaseAction = mcp4461_ns.class_("WiperDecreaseAction", automation.Action) +WiperStoreNonvolatileAction = mcp4461_ns.class_( + "WiperStoreNonvolatileAction", automation.Action +) +WiperSetTerminalAction = mcp4461_ns.class_("WiperSetTerminalAction", automation.Action) + +WIPER_ACTION_SCHEMA = automation.maybe_simple_id( + {cv.Required(CONF_ID): cv.use_id(Mcp4461Wiper)} +) + +CONF_TERMINAL = "terminal" +CONF_ENABLE = "enable" + +TERMINAL_ACTION_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(Mcp4461Wiper), + cv.Required(CONF_TERMINAL): cv.one_of("a", "b", "w", "h", lower=True), + cv.Required(CONF_ENABLE): cv.boolean, + } +) + + +@automation.register_action( + "mcp4461.wiper.increase", WiperIncreaseAction, WIPER_ACTION_SCHEMA, synchronous=True +) +@automation.register_action( + "mcp4461.wiper.decrease", WiperDecreaseAction, WIPER_ACTION_SCHEMA, synchronous=True +) +async def mcp4461_wiper_step_to_code(config, action_id, template_arg, args): + wiper = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable(action_id, template_arg, wiper) + + +@automation.register_action( + "mcp4461.wiper.store_nonvolatile", + WiperStoreNonvolatileAction, + WIPER_ACTION_SCHEMA, + synchronous=True, +) +async def mcp4461_wiper_store_to_code(config, action_id, template_arg, args): + wiper = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable(action_id, template_arg, wiper) + + +@automation.register_action( + "mcp4461.wiper.set_terminal", + WiperSetTerminalAction, + TERMINAL_ACTION_SCHEMA, + synchronous=True, +) +async def mcp4461_wiper_terminal_to_code(config, action_id, template_arg, args): + wiper = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable( + action_id, template_arg, wiper, ord(config[CONF_TERMINAL]), config[CONF_ENABLE] + ) diff --git a/esphome/components/mcp4461/output/automation.h b/esphome/components/mcp4461/output/automation.h new file mode 100644 index 0000000000..4be317b2f8 --- /dev/null +++ b/esphome/components/mcp4461/output/automation.h @@ -0,0 +1,56 @@ +#pragma once + +#include "esphome/core/automation.h" +#include "mcp4461_output.h" + +namespace esphome::mcp4461 { + +template class WiperIncreaseAction : public Action { + public: + explicit WiperIncreaseAction(Mcp4461Wiper *wiper) : wiper_(wiper) {} + void play(Ts... x) override { this->wiper_->increase_wiper(); } + + protected: + Mcp4461Wiper *wiper_; +}; + +template class WiperDecreaseAction : public Action { + public: + explicit WiperDecreaseAction(Mcp4461Wiper *wiper) : wiper_(wiper) {} + void play(Ts... x) override { this->wiper_->decrease_wiper(); } + + protected: + Mcp4461Wiper *wiper_; +}; + +// Persist the current level to the chip's nonvolatile register immediately — useful with +// nonvolatile: false to persist only at deliberate moments (e.g. on a button press), or to +// bypass the stability delay of the automatic mirroring. +template class WiperStoreNonvolatileAction : public Action { + public: + explicit WiperStoreNonvolatileAction(Mcp4461Wiper *wiper) : wiper_(wiper) {} + void play(Ts... x) override { this->wiper_->store_nonvolatile(); } + + protected: + Mcp4461Wiper *wiper_; +}; + +template class WiperSetTerminalAction : public Action { + public: + WiperSetTerminalAction(Mcp4461Wiper *wiper, char terminal, bool enable) + : wiper_(wiper), terminal_(terminal), enable_(enable) {} + void play(Ts... x) override { + if (this->enable_) { + this->wiper_->enable_terminal(this->terminal_); + } else { + this->wiper_->disable_terminal(this->terminal_); + } + } + + protected: + Mcp4461Wiper *wiper_; + char terminal_; + bool enable_; +}; + +} // namespace esphome::mcp4461 diff --git a/esphome/components/mcp4461/output/mcp4461_output.cpp b/esphome/components/mcp4461/output/mcp4461_output.cpp index 3892372cab..5c373ddc7d 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.cpp +++ b/esphome/components/mcp4461/output/mcp4461_output.cpp @@ -66,6 +66,12 @@ void Mcp4461Wiper::decrease_wiper() { } } +void Mcp4461Wiper::store_nonvolatile() { + if (this->parent_->store_level_nonvolatile_(this->wiper_)) { + ESP_LOGV(TAG, "Stored wiper %u level to nonvolatile register", static_cast(this->wiper_)); + } +} + void Mcp4461Wiper::enable_terminal(char terminal) { this->parent_->enable_terminal_(this->wiper_, terminal); } void Mcp4461Wiper::disable_terminal(char terminal) { this->parent_->disable_terminal_(this->wiper_, terminal); } diff --git a/esphome/components/mcp4461/output/mcp4461_output.h b/esphome/components/mcp4461/output/mcp4461_output.h index 20d81d825a..c8d1ef1ec5 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.h +++ b/esphome/components/mcp4461/output/mcp4461_output.h @@ -36,6 +36,9 @@ class Mcp4461Wiper final : public output::FloatOutput, public Parented Date: Wed, 5 Aug 2026 20:11:37 +0200 Subject: [PATCH 1253/1815] [modbus_server] Support byte-swapped word types U_WORD_S and S_WORD_S (#17829) --- esphome/components/modbus_server/modbus_server.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/modbus_server/modbus_server.h b/esphome/components/modbus_server/modbus_server.h index 4fddd9854d..f6484d8e6b 100644 --- a/esphome/components/modbus_server/modbus_server.h +++ b/esphome/components/modbus_server/modbus_server.h @@ -61,6 +61,7 @@ class ServerRegister { const char *format_value(int64_t value, char *buf, size_t buf_size) const { switch (this->value_type) { case SensorValueType::U_WORD: + case SensorValueType::U_WORD_S: case SensorValueType::U_DWORD: case SensorValueType::U_DWORD_R: case SensorValueType::U_QWORD: @@ -68,6 +69,7 @@ class ServerRegister { buf_append_printf(buf, buf_size, 0, "%" PRIu64, static_cast(value)); return buf; case SensorValueType::S_WORD: + case SensorValueType::S_WORD_S: case SensorValueType::S_DWORD: case SensorValueType::S_DWORD_R: case SensorValueType::S_QWORD: From 202b31f711653467aa3c0fb3b216ff7be3b8922f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Aug 2026 13:33:32 -0500 Subject: [PATCH 1254/1815] [ble_device_base] Deprecate address_str in favor of address_str_to (#18092) --- esphome/components/ble_device_base/ble_device.cpp | 3 ++- esphome/components/ble_device_base/ble_device.h | 3 ++- .../bk72xx_ble_tracker/config/test_automations.yaml | 2 +- .../ln882h_ble_tracker/config/test_automations.yaml | 2 +- .../validate-automations.bk72xx-ard.yaml | 6 ++++-- tests/components/ble_device_base/test_address.cpp | 9 ++++++++- tests/components/esp32_ble_tracker/common.yaml | 9 ++++++--- .../ln882h_ble_tracker/test-automations.ln882x-ard.yaml | 3 ++- 8 files changed, 26 insertions(+), 11 deletions(-) diff --git a/esphome/components/ble_device_base/ble_device.cpp b/esphome/components/ble_device_base/ble_device.cpp index 9c3e1d4397..2235c08598 100644 --- a/esphome/components/ble_device_base/ble_device.cpp +++ b/esphome/components/ble_device_base/ble_device.cpp @@ -129,7 +129,7 @@ void ESPBTDevice::parse_scan_rst(const esp32_ble::BLEScanResult &scan_result) { this->scan_result_ = &scan_result; // BLEScanResult's bda is most-significant octet first; the neutral ingest // takes the BLE controller (LSB-first) order, so reverse — address_uint64()/ - // address_str() then produce exactly the historical esp32 values. + // address_str_to() then produce exactly the historical esp32 values. uint8_t mac_lsb_first[6]; for (uint8_t i = 0; i < 6; i++) mac_lsb_first[i] = scan_result.bda[5 - i]; @@ -346,6 +346,7 @@ void ESPBTDevice::from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_ty #endif // ESPHOME_LOG_HAS_VERY_VERBOSE } +// Remove before 2027.2.0 std::string ESPBTDevice::address_str() const { char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; return std::string(this->address_str_to(buf)); diff --git a/esphome/components/ble_device_base/ble_device.h b/esphome/components/ble_device_base/ble_device.h index 2d2cb5796b..716bc026f2 100644 --- a/esphome/components/ble_device_base/ble_device.h +++ b/esphome/components/ble_device_base/ble_device.h @@ -172,8 +172,9 @@ class ESPBTDevice { static constexpr size_t MAC_ADDRESS_PRETTY_BUFFER_SIZE = esphome::MAC_ADDRESS_PRETTY_BUFFER_SIZE; /// Return MAC as "XX:XX:XX:XX:XX:XX" string. + ESPDEPRECATED("Use address_str_to() instead. Removed in 2027.2.0.", "2026.8.0") std::string address_str() const; - /// Buffer overload: writes "XX:XX:XX:XX:XX:XX\0" into buf (>= 18 bytes), returns buf. + /// Writes "XX:XX:XX:XX:XX:XX\0" into buf (>= MAC_ADDRESS_PRETTY_BUFFER_SIZE bytes), returns buf. const char *address_str_to(char *buf) const; #if defined(__cpp_lib_span) const char *address_str_to(std::span buf) const { diff --git a/tests/component_tests/bk72xx_ble_tracker/config/test_automations.yaml b/tests/component_tests/bk72xx_ble_tracker/config/test_automations.yaml index a60e7cca05..994855b782 100644 --- a/tests/component_tests/bk72xx_ble_tracker/config/test_automations.yaml +++ b/tests/component_tests/bk72xx_ble_tracker/config/test_automations.yaml @@ -20,7 +20,7 @@ bk72xx_ble_tracker: - AC:37:43:77:5F:4C - 11:22:33:44:55:66 then: - - lambda: 'ESP_LOGD("t", "%s", x.address_str().c_str());' + - lambda: 'char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; ESP_LOGD("t", "%s", x.address_str_to(addr));' on_ble_service_data_advertise: - service_uuid: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD mac_address: AC:37:43:77:5F:4C diff --git a/tests/component_tests/ln882h_ble_tracker/config/test_automations.yaml b/tests/component_tests/ln882h_ble_tracker/config/test_automations.yaml index 16a215f026..883d20b7ce 100644 --- a/tests/component_tests/ln882h_ble_tracker/config/test_automations.yaml +++ b/tests/component_tests/ln882h_ble_tracker/config/test_automations.yaml @@ -20,7 +20,7 @@ ln882h_ble_tracker: - AC:37:43:77:5F:4C - 11:22:33:44:55:66 then: - - lambda: 'ESP_LOGD("t", "%s", x.address_str().c_str());' + - lambda: 'char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; ESP_LOGD("t", "%s", x.address_str_to(addr));' on_ble_service_data_advertise: - service_uuid: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD mac_address: AC:37:43:77:5F:4C diff --git a/tests/components/bk72xx_ble_tracker/validate-automations.bk72xx-ard.yaml b/tests/components/bk72xx_ble_tracker/validate-automations.bk72xx-ard.yaml index 407d19e67d..e110369b0b 100644 --- a/tests/components/bk72xx_ble_tracker/validate-automations.bk72xx-ard.yaml +++ b/tests/components/bk72xx_ble_tracker/validate-automations.bk72xx-ard.yaml @@ -15,13 +15,15 @@ bk72xx_ble_tracker: - mac_address: AC:37:43:77:5F:4C then: - lambda: |- - ESP_LOGD("main", "The device address is %s", x.address_str().c_str()); + char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGD("main", "The device address is %s", x.address_str_to(addr)); - mac_address: - AC:37:43:77:5F:4C - AC:37:43:77:5F:4D then: - lambda: |- - ESP_LOGD("main", "The device address is %s", x.address_str().c_str()); + char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGD("main", "The device address is %s", x.address_str_to(addr)); on_ble_service_data_advertise: - service_uuid: ABCD # mac_address exercises the UUID triggers' set_address() codegen branch. diff --git a/tests/components/ble_device_base/test_address.cpp b/tests/components/ble_device_base/test_address.cpp index 9e4bca4c57..f1eebcbfc1 100644 --- a/tests/components/ble_device_base/test_address.cpp +++ b/tests/components/ble_device_base/test_address.cpp @@ -8,7 +8,7 @@ namespace esphome::ble_device_base::testing { // from_scan_result() ingests BLE controller order (LSB-first); the public // accessors must expose the historical esp32 semantics: address() in printable -// (MSB-first) order, address_uint64() with byte 0 in the LSB, address_str() +// (MSB-first) order, address_uint64() with byte 0 in the LSB, address_str_to() // printed MSB-first. namespace { // Device AA:BB:CC:DD:EE:FF — controller order delivers FF first. @@ -25,7 +25,14 @@ TEST(BleDeviceAddress, AccessorsMatchEsp32Semantics) { EXPECT_EQ(device.address_uint64(), 0xAABBCCDDEEFFULL); + char buf[ESPBTDevice::MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + EXPECT_STREQ(device.address_str_to(buf), "AA:BB:CC:DD:EE:FF"); + + // The deprecated wrapper must keep returning the same string until its 2027.2.0 removal. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" EXPECT_EQ(device.address_str(), "AA:BB:CC:DD:EE:FF"); +#pragma GCC diagnostic pop } // mac_lsb_first_to_uint64() packs the controller-order bytes a raw-advertisement diff --git a/tests/components/esp32_ble_tracker/common.yaml b/tests/components/esp32_ble_tracker/common.yaml index 564cf1f6ea..9c880dbf1a 100644 --- a/tests/components/esp32_ble_tracker/common.yaml +++ b/tests/components/esp32_ble_tracker/common.yaml @@ -12,18 +12,21 @@ esp32_ble_tracker: then: # yamllint disable rule:line-length - lambda: !lambda |- - ESP_LOGD("main", "The device address (%s) exists in list", x.address_str().c_str()); + char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGD("main", "The device address (%s) exists in list", x.address_str_to(addr)); # yamllint enable rule:line-length - mac_address: AC:37:43:77:5F:4C then: # yamllint disable rule:line-length - lambda: !lambda |- - ESP_LOGD("main", "The device address is %s", x.address_str().c_str()); + char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGD("main", "The device address is %s", x.address_str_to(addr)); # yamllint enable rule:line-length - then: # yamllint disable rule:line-length - lambda: !lambda |- - ESP_LOGD("main", "The device address is %s", x.address_str().c_str()); + char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGD("main", "The device address is %s", x.address_str_to(addr)); # yamllint enable rule:line-length on_ble_service_data_advertise: - service_uuid: ABCD diff --git a/tests/components/ln882h_ble_tracker/test-automations.ln882x-ard.yaml b/tests/components/ln882h_ble_tracker/test-automations.ln882x-ard.yaml index 3291ef9d8b..3cd3ce28b2 100644 --- a/tests/components/ln882h_ble_tracker/test-automations.ln882x-ard.yaml +++ b/tests/components/ln882h_ble_tracker/test-automations.ln882x-ard.yaml @@ -16,7 +16,8 @@ ln882h_ble_tracker: - mac_address: AC:37:43:77:5F:4C then: - lambda: |- - ESP_LOGD("main", "The device address is %s", x.address_str().c_str()); + char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGD("main", "The device address is %s", x.address_str_to(addr)); on_ble_service_data_advertise: - service_uuid: ABCD then: From b521b5e1cafb075e361d8e57f2445d6388bb832f Mon Sep 17 00:00:00 2001 From: Marek Pilch <47844572+marpi82@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:47:48 +0200 Subject: [PATCH 1255/1815] [modbus_server] Add tests for U_WORD_S and S_WORD_S (#17832) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .../modbus_server/test_modbus_server.py | 2 + tests/components/modbus_server/common.yaml | 8 ++++ .../modbus_server/modbus_server_test.cpp | 28 +++++++++++++ .../uart_mock_modbus_server_controller.yaml | 24 +++++++++++ ...t_mock_modbus_server_controller_write.yaml | 42 +++++++++++++++++++ tests/integration/test_uart_mock_modbus.py | 7 +++- 6 files changed, 110 insertions(+), 1 deletion(-) diff --git a/tests/component_tests/modbus_server/test_modbus_server.py b/tests/component_tests/modbus_server/test_modbus_server.py index 7c978a5cd5..3e041c6d4a 100644 --- a/tests/component_tests/modbus_server/test_modbus_server.py +++ b/tests/component_tests/modbus_server/test_modbus_server.py @@ -82,3 +82,5 @@ def test_raw_value_type_rejected() -> None: with pytest.raises(cv.Invalid): validator("RAW") assert validator("U_WORD") == "U_WORD" + assert validator("U_WORD_S") == "U_WORD_S" + assert validator("S_WORD_S") == "S_WORD_S" diff --git a/tests/components/modbus_server/common.yaml b/tests/components/modbus_server/common.yaml index 8b2316b6e3..1f3a8f551b 100644 --- a/tests/components/modbus_server/common.yaml +++ b/tests/components/modbus_server/common.yaml @@ -40,3 +40,11 @@ modbus_server: value_type: U_WORD read_lambda: |- return (random_uint32() % 100); + # Covers CPP_TYPE_REGISTER_MAP / signed byte-swapped codegen + - address: 0x6 + value_type: S_WORD_S + read_lambda: |- + return -2; + write_lambda: |- + printf("address=%d, value=%d\n", (int) address, (int) x); + return true; diff --git a/tests/components/modbus_server/modbus_server_test.cpp b/tests/components/modbus_server/modbus_server_test.cpp index 8c2e1d16d9..2137a77f3d 100644 --- a/tests/components/modbus_server/modbus_server_test.cpp +++ b/tests/components/modbus_server/modbus_server_test.cpp @@ -34,6 +34,21 @@ TEST(ModbusServerWrite, SingleWordSucceeds) { EXPECT_EQ(written, 0x1234); } +TEST(ModbusServerWrite, SwappedWordSucceeds) { + ModbusServer server; + int64_t written = -1; + ServerRegister reg(0x0000, SensorValueType::U_WORD_S, 1); + reg.write_lambda = [&written](int64_t value) { + written = value; + return true; + }; + server.add_server_register(®); + + auto status = server.on_write_registers(0x0000, make_registers({0x3412})); + EXPECT_FALSE(status.has_value()); + EXPECT_EQ(written, 0x1234); +} + // A multi-register value is decoded high word first and applied as a single number. TEST(ModbusServerWrite, DwordSucceeds) { ModbusServer server; @@ -136,6 +151,19 @@ TEST(ModbusServerRead, SingleWordSucceeds) { EXPECT_EQ(out[0], 0x1234); } +TEST(ModbusServerRead, SwappedWordReturnsByteSwappedRegister) { + ModbusServer server; + ServerRegister reg(0x0000, SensorValueType::U_WORD_S, 1); + reg.read_lambda = []() -> int64_t { return 0x1234; }; + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_read_registers(0x0000, 1, out); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0], 0x3412); +} + TEST(ModbusServerRead, DwordReturnsTwoWordsHighFirst) { ModbusServer server; ServerRegister reg(0x0000, SensorValueType::U_DWORD, 2); diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml index 20306bd73a..4a5d280a2f 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml @@ -64,9 +64,15 @@ modbus_server: - address: 0x01 value_type: U_WORD read_lambda: return 99; + - address: 0x02 + value_type: U_WORD_S + read_lambda: return 4660; - address: 0x03 value_type: S_WORD read_lambda: return -99; + - address: 0x04 + value_type: S_WORD_S + read_lambda: return -2; - address: 0x05 value_type: U_DWORD read_lambda: return 16909060; @@ -105,12 +111,30 @@ sensor: address: 0x01 register_type: holding value_type: U_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_word_s" + address: 0x02 + register_type: holding + value_type: U_WORD_S + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_word_s_raw" + address: 0x02 + register_type: holding + value_type: U_WORD - platform: modbus_controller modbus_controller_id: modbus_controller_1 name: "reg_s_word" address: 0x03 register_type: holding value_type: S_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_word_s" + address: 0x04 + register_type: holding + value_type: S_WORD_S - platform: modbus_controller modbus_controller_id: modbus_controller_1 name: "reg_u_dword" 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 b3b5e76e31..5ade49bd48 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml @@ -45,9 +45,15 @@ globals: - id: stored_u_word type: uint16_t initial_value: "11" + - id: stored_u_word_s + type: uint16_t + initial_value: "4660" - id: stored_s_word type: int16_t initial_value: "-11" + - id: stored_s_word_s + type: int16_t + initial_value: "-2" - id: stored_u_dword type: uint32_t initial_value: "1001" @@ -103,10 +109,18 @@ modbus_server: value_type: U_WORD read_lambda: return id(stored_u_word); write_lambda: id(stored_u_word) = x; return true; + - address: 0x02 + value_type: U_WORD_S + read_lambda: return id(stored_u_word_s); + write_lambda: id(stored_u_word_s) = x; return true; - address: 0x03 value_type: S_WORD read_lambda: return id(stored_s_word); write_lambda: id(stored_s_word) = x; return true; + - address: 0x04 + value_type: S_WORD_S + read_lambda: return id(stored_s_word_s); + write_lambda: id(stored_s_word_s) = x; return true; - address: 0x05 value_type: U_DWORD read_lambda: return id(stored_u_dword); @@ -155,12 +169,24 @@ sensor: address: 0x01 register_type: holding value_type: U_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_word_s" + address: 0x02 + register_type: holding + value_type: U_WORD_S - platform: modbus_controller modbus_controller_id: modbus_controller_1 name: "reg_s_word" address: 0x03 register_type: holding value_type: S_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_word_s" + address: 0x04 + register_type: holding + value_type: S_WORD_S - platform: modbus_controller modbus_controller_id: modbus_controller_1 name: "reg_u_dword" @@ -231,6 +257,14 @@ number: value_type: U_WORD min_value: 0 max_value: 65535 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_word_s" + address: 0x02 + register_type: holding + value_type: U_WORD_S + min_value: 0 + max_value: 65535 - platform: modbus_controller modbus_controller_id: modbus_controller_1 name: "write_s_word" @@ -239,6 +273,14 @@ number: value_type: S_WORD min_value: -16777215 max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_word_s" + address: 0x04 + register_type: holding + value_type: S_WORD_S + min_value: -16777215 + max_value: 16777215 - platform: modbus_controller modbus_controller_id: modbus_controller_1 name: "write_u_dword" diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index f1106f1c77..202fbe3f9c 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -215,7 +215,10 @@ async def test_uart_mock_modbus_server_controller( expected_values = { "reg_u_word": 99, + "reg_u_word_s": 4660, + "reg_u_word_s_raw": 13330, "reg_s_word": -99, + "reg_s_word_s": -2, "reg_u_dword": 16909060, "reg_s_dword": -16909060, "reg_u_dword_r": pytest.approx(67305985), @@ -249,14 +252,16 @@ async def test_uart_mock_modbus_server_controller_write( Verifies that writing to modbus server registers via the controller updates the server's stored values, which are then read back correctly on the next poll. - All 12 value types are tested: U/S_WORD, U/S_DWORD(_R), U/S_QWORD(_R), FP32(_R). + All 14 value types are tested: U/S_WORD, U/S_WORD_S, U/S_DWORD(_R), U/S_QWORD(_R), FP32(_R). """ line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() register_test_cases: dict[str, RegisterTestCase] = { "reg_u_word": RegisterTestCase(11, "write_u_word", 42, 42), + "reg_u_word_s": RegisterTestCase(4660, "write_u_word_s", 17185, 17185), "reg_s_word": RegisterTestCase(-11, "write_s_word", -42, -42), + "reg_s_word_s": RegisterTestCase(-2, "write_s_word_s", -257, -257), "reg_u_dword": RegisterTestCase(1001, "write_u_dword", 2002, 2002), "reg_s_dword": RegisterTestCase(-1001, "write_s_dword", -2002, -2002), "reg_u_dword_r": RegisterTestCase(3003, "write_u_dword_r", 4004, 4004), From 1cc83172ab87877b445cb3a9cda1c75e8d959ecb Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 5 Aug 2026 14:24:38 -0500 Subject: [PATCH 1256/1815] [ota] Multi-key OTA signature verification for external RSA signing (#17981) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/esp32/__init__.py | 190 +++++++++++++- esphome/components/ota/__init__.py | 18 +- .../components/ota/ota_backend_esp_idf.cpp | 13 + esphome/components/ota/ota_backend_esp_idf.h | 5 + .../components/ota/ota_bootloader_esp_idf.cpp | 12 + .../components/ota/ota_signature_esp_idf.cpp | 232 ++++++++++++++++++ esphome/core/defines.h | 7 + .../esp32/config/signed_ota_ecdsa256_c6.yaml | 10 + .../esp32/config/signed_ota_ecdsa_v1.yaml | 11 + .../config/signed_ota_external_rsa_s3.yaml | 10 + .../config/signed_ota_signing_key_s3.yaml | 11 + .../signed_ota_verification_keys_s3.yaml | 12 + tests/component_tests/esp32/test_esp32.py | 120 +++++++++ ...test-signed_ota_external.esp32-s3-idf.yaml | 23 ++ ...date-signed_ota_external.esp32-s3-idf.yaml | 11 - 15 files changed, 672 insertions(+), 13 deletions(-) create mode 100644 esphome/components/ota/ota_signature_esp_idf.cpp create mode 100644 tests/component_tests/esp32/config/signed_ota_ecdsa256_c6.yaml create mode 100644 tests/component_tests/esp32/config/signed_ota_ecdsa_v1.yaml create mode 100644 tests/component_tests/esp32/config/signed_ota_external_rsa_s3.yaml create mode 100644 tests/component_tests/esp32/config/signed_ota_signing_key_s3.yaml create mode 100644 tests/component_tests/esp32/config/signed_ota_verification_keys_s3.yaml create mode 100644 tests/components/esp32/test-signed_ota_external.esp32-s3-idf.yaml delete mode 100644 tests/components/esp32/validate-signed_ota_external.esp32-s3-idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 8e30d21776..ef997105ba 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -119,6 +119,7 @@ CONF_SIGNING_SCHEME = "signing_scheme" CONF_SRAM1_AS_IRAM = "sram1_as_iram" CONF_SUBTYPE = "subtype" CONF_VERIFICATION_KEY = "verification_key" +CONF_VERIFICATION_KEYS = "verification_keys" ARDUINO_FRAMEWORK_NAME = "framework-arduinoespressif32" ARDUINO_FRAMEWORK_PKG = f"pioarduino/{ARDUINO_FRAMEWORK_NAME}" @@ -147,6 +148,12 @@ SIGNING_SCHEMES = { "ecdsa_v1": "CONFIG_SECURE_SIGNED_APPS_ECDSA_SCHEME", } +# A Secure Boot v2 image carries at most three signature blocks, and hardware +# secure boot exposes three eFuse key slots. The trusted-key list isn't bound by +# the per-image limit (an incoming image need only match one trusted key), but +# cap it at three to mirror those hardware limits. +SIGNED_OTA_MAX_KEYS = 3 + # Chip variants that only support one V2 signing scheme. # Based on SOC_SECURE_BOOT_V2_RSA / SOC_SECURE_BOOT_V2_ECC in soc_caps.h. # Variants not listed in either set support both RSA and ECDSA V2 @@ -1164,10 +1171,98 @@ def _ota_downgrade_protection_errors( return errs +def _sbv2_rsa_key_digest(path: Path) -> bytes: + """SHA-256 of a public key's Secure Boot v2 signature-block key region. + + This hashes the 776-byte {n, e, rinv, m'} region exactly as the ROM lays it + out -- i.e. the value the device computes per signature block and the one + ``espsecure digest-sbv2-public-key`` prints, not a hash of the DER key. + """ + import hashlib + import struct + + from cryptography.exceptions import UnsupportedAlgorithm + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.hazmat.primitives.serialization import ( + load_pem_private_key, + load_pem_public_key, + ) + + data = path.read_bytes() + try: + if b"PUBLIC KEY" in data: + public_key = load_pem_public_key(data) + else: + # verification_keys only needs the public half; warn so the private + # key doesn't end up committed alongside the config. + _LOGGER.warning( + "'%s' is a private key, but '%s' needs only the public key. Use a " + "public-key PEM or the 64-hex digest (espsecure " + "digest-sbv2-public-key) so the private key stays out of your config.", + path, + CONF_VERIFICATION_KEYS, + ) + public_key = load_pem_private_key(data, password=None).public_key() + except (ValueError, TypeError, UnsupportedAlgorithm) as err: + raise cv.Invalid(f"Could not load key '{path}': {err}") from err + if not isinstance(public_key, rsa.RSAPublicKey) or public_key.key_size != 3072: + raise cv.Invalid( + f"'{CONF_VERIFICATION_KEYS}' entries must be RSA-3072 keys; " + f"'{path}' is not." + ) + numbers = public_key.public_numbers() + n, e = numbers.n, numbers.e + m = (-pow(n, -1, 1 << 32)) & 0xFFFFFFFF + rinv = (1 << (public_key.key_size * 2)) % n + blob = struct.pack( + "<384sI384sI", + n.to_bytes(384, "big")[::-1], + e, + rinv.to_bytes(384, "big")[::-1], + m, + ) + return hashlib.sha256(blob).digest() + + +def _validate_trusted_key(value: Any) -> str: + """Normalize a trusted key to its 64-hex-char signature-block digest. + + Accepts either the digest directly (so CI can inject it without shipping a + key file) or a PEM key file whose digest is computed here. Typed ``Any`` + because YAML hands validators the parsed value -- e.g. an unquoted ``0x...`` + digest arrives as an int, which the guard below rejects with advice to quote. + """ + # An unquoted 0x... or all-digit digest is parsed by YAML as an int before it + # reaches here, so it never looks like a string digest -- reject it clearly + # rather than letting it fall through to cv.file_ as a bogus path. + if not isinstance(value, str): + raise cv.Invalid( + f"Expected a key file path or a 64-character hex digest, got {value!r}. " + f"Quote the digest so YAML keeps it as text (an unquoted '0x...' or " + f"all-digit value is parsed as a number)." + ) + stripped = value.strip() + if re.fullmatch(r"[0-9A-Fa-f]{64}", stripped): + return stripped.lower() + # An all-hex value that isn't exactly 64 chars is a mangled digest, not a + # path: a truncated or 0x-prefixed CI variable would otherwise fall through + # and fail as "file not found", pointing at the wrong problem. + if re.fullmatch(r"(?:0x)?[0-9A-Fa-f]+", stripped): + raise cv.Invalid( + f"'{stripped}' looks like a key digest but must be exactly 64 hex " + f"characters (a SHA-256, no '0x' prefix); check for truncation." + ) + return _sbv2_rsa_key_digest(cv.file_(value)).hex() + + _SIGNED_OTA_VERIFICATION_SCHEMA = cv.Schema( { cv.Optional(CONF_SIGNING_KEY): cv.file_, cv.Optional(CONF_VERIFICATION_KEY): cv.file_, + cv.Optional(CONF_VERIFICATION_KEYS): cv.All( + cv.ensure_list(_validate_trusted_key), + cv.Length(min=1, max=SIGNED_OTA_MAX_KEYS), + ), cv.Optional(CONF_SIGNING_SCHEME, default="rsa3072"): cv.one_of( *SIGNING_SCHEMES, lower=True ), @@ -1201,9 +1296,15 @@ def _validate_signed_ota_keys(config: ConfigType) -> ConfigType: block appended to each image, so verifying externally-signed binaries needs no key in the config at all -- omitting both keys selects that external-signing mode. + + For external RSA (rsa3072, no signing key), an optional 'verification_keys' + list names the keys the running app trusts. ESPHome then verifies OTA + signatures against that compiled-in set instead of IDF's single-block + check, which enables key rotation and multi-provider backup keys. """ has_signing_key = CONF_SIGNING_KEY in config has_verification_key = CONF_VERIFICATION_KEY in config + has_verification_keys = CONF_VERIFICATION_KEYS in config scheme = config[CONF_SIGNING_SCHEME] if has_signing_key and has_verification_key: raise cv.Invalid( @@ -1211,6 +1312,34 @@ def _validate_signed_ota_keys(config: ConfigType) -> ConfigType: f"'{CONF_VERIFICATION_KEY}', not both.", path=[CONF_VERIFICATION_KEY], ) + if has_verification_keys: + if scheme != "rsa3072": + raise cv.Invalid( + f"'{CONF_VERIFICATION_KEYS}' is only used with signing scheme " + f"'rsa3072' (externally-signed RSA images). With '{scheme}' the " + f"public key travels in each image's signature block.", + path=[CONF_VERIFICATION_KEYS], + ) + if has_signing_key: + raise cv.Invalid( + f"'{CONF_VERIFICATION_KEYS}' verifies externally-signed images " + f"and cannot be combined with '{CONF_SIGNING_KEY}' (which signs " + f"during the build). Provide one or the other.", + path=[CONF_VERIFICATION_KEYS], + ) + if has_verification_key: + raise cv.Invalid( + f"Provide at most one of '{CONF_VERIFICATION_KEY}' and " + f"'{CONF_VERIFICATION_KEYS}', not both.", + path=[CONF_VERIFICATION_KEYS], + ) + keys = config[CONF_VERIFICATION_KEYS] + if len(set(keys)) != len(keys): + raise cv.Invalid( + f"'{CONF_VERIFICATION_KEYS}' entries must be unique (duplicate " + f"keys add nothing and waste a trusted-set slot).", + path=[CONF_VERIFICATION_KEYS], + ) if scheme == "ecdsa_v1": if not has_signing_key and not has_verification_key: raise cv.Invalid( @@ -2556,9 +2685,68 @@ async def to_code(config): # Enable signed app verification without hardware secure boot if signed_ota := advanced.get(CONF_SIGNED_OTA_VERIFICATION): add_idf_sdkconfig_option("CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT", True) - add_idf_sdkconfig_option("CONFIG_SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT", True) scheme = signed_ota[CONF_SIGNING_SCHEME] + # For externally-signed RSA images with a declared 'verification_keys' + # list, ESPHome verifies the OTA signature itself instead of using IDF's + # on-update check. IDF only matches the incoming image's first signature + # block against the running app's first, which blocks key rotation and + # multi-provider backup keys; ESPHome accepts an image signed by any key + # in the compiled-in trusted set. Without 'verification_keys' there is no + # trust anchor, so fall back to IDF's built-in check. + # The build still produces the padded unsigned image (via SECURE_ + # SIGNED_APPS_NO_SECURE_BOOT above); only the on-update check moves. + # SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT defaults to y under + # SECURE_SIGNED_APPS_NO_SECURE_BOOT, so it must be set explicitly: + # False to hand verification to ESPHome, True to keep IDF's check. + # Setting it False also drives the hidden CONFIG_SECURE_SIGNED_APPS to + # n; the 4 KiB padding and reserved signature sector the verifier + # depends on survive only because --secure-pad-v2 keys off + # CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME (set below), not that symbol. + external_rsa = scheme == "rsa3072" and CONF_SIGNING_KEY not in signed_ota + verification_keys = signed_ota.get(CONF_VERIFICATION_KEYS) + # verification_keys is accepted only for external RSA (rsa3072 with no + # signing_key), enforced in _validate_signed_ota_keys. Assert the + # post-condition so validator/codegen drift fails the build loudly + # instead of silently dropping the declared trust anchor and downgrading + # to IDF's single-block check. + assert not verification_keys or external_rsa + multi_key = external_rsa and verification_keys + # Turning IDF's on-update check off is global -- it also drops the + # signature check from esp_ota_set_boot_partition() on the partition-table + # path and safe_mode's recovery rollback. Both deliberately select an + # already-installed image (or an MD5-checked partition table), not a + # freshly-downloaded one, so ESPHome's verifier only needs to cover the + # app and bootloader OTA paths, where a new image is actually written. + add_idf_sdkconfig_option( + "CONFIG_SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT", not multi_key + ) + if multi_key: + cg.add_define("USE_OTA_SIGNED_VERIFICATION_MULTI_KEY") + # Compile the trusted key digests in as the immutable trust anchor. + # Each is the SHA-256 of a key's signature-block region; the verifier + # accepts an OTA whose signature block matches one of these. + digests = [bytes.fromhex(k) for k in verification_keys] + # Echo the resolved digests so a stale or mistyped key (which builds + # cleanly but leaves the device updatable only by serial reflash) is + # visible in the build log. + _LOGGER.info( + "Signed OTA verification trusts %d key digest(s): %s", + len(digests), + ", ".join(d.hex() for d in digests), + ) + cg.add_define("OTA_TRUSTED_KEY_COUNT", len(digests)) + cg.add_define( + "OTA_TRUSTED_KEY_DIGESTS", + cg.RawExpression( + "{" + + ",".join( + "{" + ",".join(f"0x{b:02x}" for b in d) + "}" for d in digests + ) + + "}" + ), + ) + for key, flag in SIGNING_SCHEMES.items(): add_idf_sdkconfig_option(flag, scheme == key) diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 8296410f2f..2d4de52e8f 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -151,7 +151,7 @@ async def final_step(): cg.add_define("USE_OTA_STATE_LISTENER") -FILTER_SOURCE_FILES = filter_source_files_from_platform( +_filter_backend_source_files = filter_source_files_from_platform( { "ota_backend_esp_idf.cpp": { PlatformFramework.ESP32_ARDUINO, @@ -167,3 +167,19 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( "ota_backend_host.cpp": {PlatformFramework.HOST_NATIVE}, } ) + + +def FILTER_SOURCE_FILES() -> list[str]: + files = _filter_backend_source_files() + # ota_signature_esp_idf.cpp implements multi-key OTA signature verification, + # compiled only when the esp32 component enables it (external RSA signed + # OTA sets USE_OTA_SIGNED_VERIFICATION_MULTI_KEY). The define is set only on + # ESP32/IDF, so this also excludes the file on every other platform. Filter + # it out otherwise so the (otherwise fully #ifdef'd-out) file isn't opened + # and parsed on every build. + if not any( + define.name == "USE_OTA_SIGNED_VERIFICATION_MULTI_KEY" + for define in CORE.defines + ): + files.append("ota_signature_esp_idf.cpp") + return files diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 8fd21f42bd..108605e4c9 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -144,6 +144,9 @@ OTAResponseTypes IDFOTABackend::end() { } } #ifdef USE_OTA_PARTITIONS + // A partition-table update carries an MD5 (checked by IDF), not a Secure Boot + // signature, and only re-points boot at an already-installed app -- so it is + // intentionally not run through the signature verifier below. if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { return this->update_partition_table(); } @@ -162,6 +165,16 @@ OTAResponseTypes IDFOTABackend::end() { } #endif if (err == ESP_OK) { +#ifdef USE_OTA_SIGNED_VERIFICATION_MULTI_KEY + // IDF's built-in on-update check is disabled for this scheme (it only + // matches the incoming image's first signature block against the running + // app's first). Verify here against every key the running app trusts, so + // rotation and backup keys are accepted. Leaving the boot partition + // unchanged means a rejected image never boots. + if (!this->verify_signed_image_(this->partition_)) { + return OTA_RESPONSE_ERROR_SIGNATURE_INVALID; + } +#endif #ifdef USE_OTA_DOWNGRADE_PROTECTION // The image is written and (when signing is enabled) signature-verified by // esp_ota_end(), so its embedded project version can be trusted. Reject the diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index a49a5e34b3..9dffd5429e 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -54,6 +54,11 @@ class IDFOTABackend final { #endif private: +#ifdef USE_OTA_SIGNED_VERIFICATION_MULTI_KEY + // Accept an image signed by any key the running app trusts (up to 3 blocks), + // so rotation and backup keys work. Fails closed. Covers app and bootloader. + bool verify_signed_image_(const esp_partition_t *incoming); +#endif // Keep md5_ first since its digest_ is alignas(32) on DMA-SHA variants; md5_set_ stays last so buf_ packs tightly. md5::MD5Digest md5_{}; esp_ota_handle_t update_handle_{0}; diff --git a/esphome/components/ota/ota_bootloader_esp_idf.cpp b/esphome/components/ota/ota_bootloader_esp_idf.cpp index 062e4d0811..264218a3df 100644 --- a/esphome/components/ota/ota_bootloader_esp_idf.cpp +++ b/esphome/components/ota/ota_bootloader_esp_idf.cpp @@ -94,6 +94,18 @@ OTAResponseTypes IDFOTABackend::finalize_bootloader_update_(esp_err_t ota_end_er if (ota_end_err != ESP_OK) { return OTA_RESPONSE_ERROR_BOOTLOADER_VERIFY; } +#ifdef USE_OTA_SIGNED_VERIFICATION_MULTI_KEY + // The new bootloader is staged in partition_. IDF never signature-checks a + // bootloader image in this software-signed config -- esp_image_verify() skips + // it when is_bootloader() is true -- so without this a bootloader OTA would + // install unverified. Require a trusted signature, which means the bootloader + // must be externally signed and 4 KiB-padded, the same as the app. + if (!this->verify_signed_image_(this->partition_)) { + ESP_LOGE(TAG, "Bootloader image is not signed by a trusted key; a bootloader OTA requires an " + "externally-signed, 4 KiB-padded bootloader.bin"); + return OTA_RESPONSE_ERROR_BOOTLOADER_VERIFY; + } +#endif esp_bootloader_desc_t bootloader_desc; esp_err_t desc_err = esp_ota_get_bootloader_description(this->partition_, &bootloader_desc); #ifdef USE_ESP32_SRAM1_AS_IRAM diff --git a/esphome/components/ota/ota_signature_esp_idf.cpp b/esphome/components/ota/ota_signature_esp_idf.cpp new file mode 100644 index 0000000000..edee594bfe --- /dev/null +++ b/esphome/components/ota/ota_signature_esp_idf.cpp @@ -0,0 +1,232 @@ +#ifdef USE_ESP32 +#include "ota_backend_esp_idf.h" + +#ifdef USE_OTA_SIGNED_VERIFICATION_MULTI_KEY +#include "esphome/components/watchdog/watchdog.h" +#include "esphome/core/log.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace esphome::ota { + +static const char *const TAG = "ota.idf"; + +// Route the "Signature check: " prefix (and its per-block form) through one +// shared format string each, so the prefix is pooled once by the linker instead +// of duplicated at every call site. The level macro is forwarded so compile-time +// log-level stripping still applies. +#define OTA_IDF_SIG_LOG(level, msg) level(TAG, "Signature check: %s", msg) +#define OTA_IDF_SIG_LOG_BLOCK(level, i, msg) level(TAG, "Signature check: block %zu: %s", static_cast(i), msg) + +// Secure Boot v2 RSA-3072 signature block, as written by espsecure and stored +// in the 4 KiB sector following the (4 KiB-padded) app image. All bignum +// fields are byte-reversed to little-endian for the RSA accelerator; software +// verification reverses them back. See the espsecure "; +constexpr uint8_t TRUSTED_KEY_DIGESTS[OTA_TRUSTED_KEY_COUNT][SHA256_BYTES] = OTA_TRUSTED_KEY_DIGESTS; + +// A block is structurally valid if the magic, version, and CRC all check out. +// The CRC covers everything before it and uses the same ROM routine the +// bootloader validates the block with, so the check matches byte-for-byte. +bool block_is_valid(const uint8_t *block) { + if (block[0] != SIG_BLOCK_MAGIC || block[1] != SIG_BLOCK_VERSION_RSA) { + return false; + } + uint32_t stored_crc; + memcpy(&stored_crc, block + OFFSET_CRC, sizeof(stored_crc)); + return esp_rom_crc32_le(0, block, OFFSET_CRC) == stored_crc; +} + +bool key_digest_of(const uint8_t *block, KeyDigest &out) { + return mbedtls_sha256(block + OFFSET_KEY, KEY_REGION_LEN, out.data(), /*is224=*/0) == 0; +} + +// The offset of the signature sector: the app length rounded up to 4 KiB. +bool signature_sector_offset(const esp_partition_t *part, size_t &out_offset) { + esp_partition_pos_t pos{.offset = part->address, .size = part->size}; + esp_image_metadata_t meta{}; + if (esp_image_get_metadata(&pos, &meta) != ESP_OK) { + return false; + } + // Bound the image length before rounding up so a crafted header can't + // overflow the addition; the image plus its signature sector must fit. + if (meta.image_len > part->size) { + return false; + } + out_offset = (meta.image_len + SIG_SECTOR_ALIGN - 1) & ~(SIG_SECTOR_ALIGN - 1); + return out_offset + SIG_BLOCK_SIZE <= part->size; +} + +// SHA-256 over the 4 KiB-padded image, i.e. everything the signature covers. +// Returns false on a read or hash error so a hash failure is not later +// misreported as a signature mismatch. +bool image_digest(const esp_partition_t *part, size_t image_padded_len, uint8_t *out) { + mbedtls_sha256_context ctx; + mbedtls_sha256_init(&ctx); + bool ok = mbedtls_sha256_starts(&ctx, /*is224=*/0) == 0; + uint8_t buf[512]; + for (size_t off = 0; ok && off < image_padded_len; off += sizeof(buf)) { + size_t chunk = std::min(sizeof(buf), image_padded_len - off); + if (esp_partition_read(part, off, buf, chunk) != ESP_OK || mbedtls_sha256_update(&ctx, buf, chunk) != 0) { + ok = false; + } + } + if (ok) { + ok = mbedtls_sha256_finish(&ctx, out) == 0; + } + mbedtls_sha256_free(&ctx); + return ok; +} + +// Verify one RSA-PSS-3072-SHA256 signature block over the image digest. The +// block's modulus and signature are stored little-endian; reverse them in place +// -- block is the caller's scratch buffer, overwritten on the next iteration -- +// rather than stacking a second 384-byte copy of each bignum. +bool rsa_pss_verify(uint8_t *block, const uint8_t *digest) { + std::reverse(block + OFFSET_MODULUS, block + OFFSET_MODULUS + RSA_3072_BYTES); + std::reverse(block + OFFSET_SIGNATURE, block + OFFSET_SIGNATURE + RSA_3072_BYTES); + uint32_t exponent_le; + memcpy(&exponent_le, block + OFFSET_EXPONENT, sizeof(exponent_le)); + uint8_t exponent_be[4] = {static_cast(exponent_le >> 24), static_cast(exponent_le >> 16), + static_cast(exponent_le >> 8), static_cast(exponent_le)}; + + mbedtls_rsa_context rsa; + mbedtls_rsa_init(&rsa); + bool key_ok = mbedtls_rsa_import_raw(&rsa, block + OFFSET_MODULUS, RSA_3072_BYTES, nullptr, 0, nullptr, 0, nullptr, 0, + exponent_be, sizeof(exponent_be)) == 0 && + mbedtls_rsa_complete(&rsa) == 0 && + mbedtls_rsa_set_padding(&rsa, MBEDTLS_RSA_PKCS_V21, MBEDTLS_MD_SHA256) == 0; + bool verified = false; + if (!key_ok) { + // A setup/allocation failure (e.g. OOM right after the download) is not a + // signature mismatch -- log it distinctly so it isn't read as "wrong key". + OTA_IDF_SIG_LOG(ESP_LOGE, "RSA key setup failed"); + } else { + verified = + mbedtls_rsa_rsassa_pss_verify(&rsa, MBEDTLS_MD_SHA256, SHA256_BYTES, digest, block + OFFSET_SIGNATURE) == 0; + } + mbedtls_rsa_free(&rsa); + return verified; +} + +} // namespace + +bool IDFOTABackend::verify_signed_image_(const esp_partition_t *incoming) { + // Verification re-hashes the full image (after esp_ota_end already did one + // pass), which can approach the task WDT budget on a large app. Extend it for + // the duration, mirroring the erase budget in begin(). + const uint32_t verify_budget_ms = 15000 + (incoming->size >> 10) * 10; + watchdog::WatchdogManager watchdog(verify_budget_ms); + + size_t incoming_sector; + if (!signature_sector_offset(incoming, incoming_sector)) { + OTA_IDF_SIG_LOG(ESP_LOGE, "cannot locate incoming signature sector"); + return false; + } + uint8_t digest[SHA256_BYTES]; + if (!image_digest(incoming, incoming_sector, digest)) { + OTA_IDF_SIG_LOG(ESP_LOGE, "cannot hash incoming image"); + return false; + } + + // Accept if any incoming block is signed by a compiled-in trusted key AND its + // signature verifies over the image. Iterating all blocks (not just the + // first) is the whole point -- it lets a bridge/backup key in a later block + // be the match. The trust check is against the immutable compiled-in set, so + // extra (self-signed) blocks an attacker appends carry keys we simply ignore. + // Heap-allocate the 1216-byte block for the duration of verification: this + // runs mid-OTA on the loop task, on top of the caller's live 1 KB OTA buffer + // and mbedtls's own ~1 KB verify scratch, so keeping it off the stack widens + // a thin margin. One short-lived allocation right before reboot is not the + // fragmentation pattern the project guards against. nothrow so an OOM here + // fails closed like every other error path, rather than aborting. + std::unique_ptr block(new (std::nothrow) uint8_t[SIG_BLOCK_SIZE]); + if (!block) { + OTA_IDF_SIG_LOG(ESP_LOGE, "out of memory"); + return false; + } + bool any_valid_block = false; + for (size_t i = 0; i < SIG_BLOCK_MAX_COUNT; i++) { + size_t off = incoming_sector + i * SIG_BLOCK_SIZE; + if (off + SIG_BLOCK_SIZE > incoming->size) { + break; // partition has no room for another block; done scanning + } + // A read fault is not "no trusted key" -- fail closed with a distinct error. + if (esp_partition_read(incoming, off, block.get(), SIG_BLOCK_SIZE) != ESP_OK) { + OTA_IDF_SIG_LOG_BLOCK(ESP_LOGE, i, "unreadable"); + return false; + } + if (!block_is_valid(block.get())) { + OTA_IDF_SIG_LOG_BLOCK(ESP_LOGD, i, "absent or malformed"); + continue; + } + any_valid_block = true; + KeyDigest incoming_key; + if (!key_digest_of(block.get(), incoming_key)) { + OTA_IDF_SIG_LOG_BLOCK(ESP_LOGE, i, "key hash failed"); + return false; + } + bool trusted_key = false; + for (const auto &trusted : TRUSTED_KEY_DIGESTS) { + if (memcmp(incoming_key.data(), trusted, SHA256_BYTES) == 0) { + trusted_key = true; + break; + } + } + if (!trusted_key) { + OTA_IDF_SIG_LOG_BLOCK(ESP_LOGW, i, "signed by an untrusted key"); + continue; + } + if (rsa_pss_verify(block.get(), digest)) { + OTA_IDF_SIG_LOG_BLOCK(ESP_LOGD, i, "verified with a trusted key"); + return true; + } + OTA_IDF_SIG_LOG_BLOCK(ESP_LOGW, i, "trusted key failed to verify"); + } + + // Separate "not signed at all" from "signed by an untrusted key" -- the former + // otherwise reads as the latter on a device that only logs at INFO. + if (!any_valid_block) { + OTA_IDF_SIG_LOG(ESP_LOGE, "image has no signature block"); + } else { + OTA_IDF_SIG_LOG(ESP_LOGE, "no trusted key produced a valid signature"); + } + return false; +} + +} // namespace esphome::ota + +#endif // USE_OTA_SIGNED_VERIFICATION_MULTI_KEY +#endif // USE_ESP32 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 731e6188df..32d30c9d07 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -250,6 +250,13 @@ #define ESPHOME_TASK_LOG_BUFFER_SIZE 768 #define USE_OTA_ROLLBACK #define USE_OTA_SIGNED_VERIFICATION +#define USE_OTA_SIGNED_VERIFICATION_MULTI_KEY +// Stub values for tooling; a real build's codegen emits these from verification_keys. +#define OTA_TRUSTED_KEY_COUNT 1 +#define OTA_TRUSTED_KEY_DIGESTS \ + { \ + { 0 } \ + } #define USE_OTA_DOWNGRADE_PROTECTION #define USE_ESP32_MIN_CHIP_REVISION_SET #define USE_ESP32_RTC_PREFERENCES diff --git a/tests/component_tests/esp32/config/signed_ota_ecdsa256_c6.yaml b/tests/component_tests/esp32/config/signed_ota_ecdsa256_c6.yaml new file mode 100644 index 0000000000..0d504e36a2 --- /dev/null +++ b/tests/component_tests/esp32/config/signed_ota_ecdsa256_c6.yaml @@ -0,0 +1,10 @@ +esphome: + name: test + +esp32: + variant: esp32c6 + framework: + type: esp-idf + advanced: + signed_ota_verification: + signing_scheme: ecdsa256 diff --git a/tests/component_tests/esp32/config/signed_ota_ecdsa_v1.yaml b/tests/component_tests/esp32/config/signed_ota_ecdsa_v1.yaml new file mode 100644 index 0000000000..8d0dad947a --- /dev/null +++ b/tests/component_tests/esp32/config/signed_ota_ecdsa_v1.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp32: + variant: esp32 + framework: + type: esp-idf + advanced: + signed_ota_verification: + signing_scheme: ecdsa_v1 + verification_key: ../../../components/esp32/dummy_signing_key_v1_ecdsa.pem diff --git a/tests/component_tests/esp32/config/signed_ota_external_rsa_s3.yaml b/tests/component_tests/esp32/config/signed_ota_external_rsa_s3.yaml new file mode 100644 index 0000000000..f63f3ab690 --- /dev/null +++ b/tests/component_tests/esp32/config/signed_ota_external_rsa_s3.yaml @@ -0,0 +1,10 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + signed_ota_verification: + signing_scheme: rsa3072 diff --git a/tests/component_tests/esp32/config/signed_ota_signing_key_s3.yaml b/tests/component_tests/esp32/config/signed_ota_signing_key_s3.yaml new file mode 100644 index 0000000000..1a42301e9e --- /dev/null +++ b/tests/component_tests/esp32/config/signed_ota_signing_key_s3.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + signed_ota_verification: + signing_scheme: rsa3072 + signing_key: ../../../components/esp32/dummy_signing_key.pem diff --git a/tests/component_tests/esp32/config/signed_ota_verification_keys_s3.yaml b/tests/component_tests/esp32/config/signed_ota_verification_keys_s3.yaml new file mode 100644 index 0000000000..28966eba40 --- /dev/null +++ b/tests/component_tests/esp32/config/signed_ota_verification_keys_s3.yaml @@ -0,0 +1,12 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + signed_ota_verification: + signing_scheme: rsa3072 + verification_keys: + - ../../../components/esp32/dummy_signing_key.pem diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 3f4d71ef2a..5620a220f8 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -270,6 +270,53 @@ def test_nvs_encryption_sdkconfig( assert "PERMANENT and IRREVERSIBLE" in caplog.text +@pytest.mark.parametrize( + ("fixture", "multi_key", "idf_on_update"), + [ + # Externally-signed RSA with a declared trusted-key list hands + # verification to ESPHome's multi-key verifier, so IDF's single-block + # on-update check must be OFF. It defaults ON under + # SECURE_SIGNED_APPS_NO_SECURE_BOOT, so it has to be set to False + # explicitly -- not merely omitted. + ("signed_ota_verification_keys_s3.yaml", True, False), + # Externally-signed RSA without a trusted-key list has no trust anchor, + # so it falls back to IDF's built-in check. + ("signed_ota_external_rsa_s3.yaml", False, True), + # Build-time signing and the other schemes keep IDF's check. + ("signed_ota_signing_key_s3.yaml", False, True), + ("signed_ota_ecdsa256_c6.yaml", False, True), + ("signed_ota_ecdsa_v1.yaml", False, True), + ], +) +def test_signed_ota_verification_sdkconfig( + fixture: str, + multi_key: bool, + idf_on_update: bool, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Only external RSA disables IDF's on-update check and uses ESPHome's verifier.""" + generate_main(component_config_path(fixture)) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + # The padded, externally-signable image is always produced. + assert sdkconfig.get("CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT") is True + # Explicit value (never left to the Kconfig default) decides who verifies. + assert ( + sdkconfig.get("CONFIG_SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT") is idf_on_update + ) + defines = {define.name for define in CORE.defines} + assert ("USE_OTA_SIGNED_VERIFICATION_MULTI_KEY" in defines) is multi_key + if multi_key: + # The padding / reserved signature sector the verifier depends on keys + # off the RSA scheme symbol, not the hidden CONFIG_SECURE_SIGNED_APPS + # (which the explicit `n` above drives to n). Pin the real dependency. + assert sdkconfig.get("CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME") is True + # The compiled-in trust anchor: the fixture lists one key. + define_values = {define.name: str(define.value) for define in CORE.defines} + assert define_values["OTA_TRUSTED_KEY_COUNT"] == "1" + assert "OTA_TRUSTED_KEY_DIGESTS" in define_values + + @pytest.mark.parametrize( ("fixture", "expect_warning"), [ @@ -707,6 +754,9 @@ def test_downgrade_protection_reports_all_unmet_requirements() -> None: # V1 ECDSA: exactly one of signing key / verification key. {"signing_scheme": "ecdsa_v1", "signing_key": "key.pem"}, {"signing_scheme": "ecdsa_v1", "verification_key": "key.bin"}, + # External RSA with a compiled-in trusted-key list (digests). + {"signing_scheme": "rsa3072", "verification_keys": ["ab" * 32]}, + {"signing_scheme": "rsa3072", "verification_keys": ["ab" * 32, "cd" * 32]}, ], ) def test_signed_ota_keys_valid_combinations(config: dict) -> None: @@ -761,6 +811,34 @@ def test_signed_ota_bare_block_selects_v2_external_signing(value: dict | None) - }, "not both", ), + # A trusted-key list only applies to external RSA. + ( + {"signing_scheme": "ecdsa256", "verification_keys": ["ab" * 32]}, + "only used with signing scheme 'rsa3072'", + ), + # Can't both auto-sign and verify against a fixed trusted set. + ( + { + "signing_scheme": "rsa3072", + "signing_key": "key.pem", + "verification_keys": ["ab" * 32], + }, + "cannot be combined with", + ), + # The singular V1 key and the RSA trusted-key list are mutually exclusive. + ( + { + "signing_scheme": "rsa3072", + "verification_key": "key.bin", + "verification_keys": ["ab" * 32], + }, + "at most one", + ), + # Duplicate trusted keys are rejected. + ( + {"signing_scheme": "rsa3072", "verification_keys": ["ab" * 32, "ab" * 32]}, + "must be unique", + ), ], ) def test_signed_ota_keys_invalid_combinations(config: dict, match: str) -> None: @@ -770,6 +848,48 @@ def test_signed_ota_keys_invalid_combinations(config: dict, match: str) -> None: _validate_signed_ota_keys(config) +def test_sbv2_rsa_key_digest_known_answer() -> None: + """The compiled-in trust anchor is the block-format digest the device + computes per signature block; pin it to espsecure's known output for the + shipped dummy key so a future change to the derivation can't drift silently. + """ + from esphome.components.esp32 import _sbv2_rsa_key_digest + + key = ( + Path(__file__).parent.parent.parent + / "components" + / "esp32" + / "dummy_signing_key.pem" + ) + assert ( + _sbv2_rsa_key_digest(key).hex() + == "957671f5ec1b55b3fb1d32c5525a68d3b8c33847922daddb4feefe64cd679f65" + ) + + +def test_validate_trusted_key_hex_forms() -> None: + """The digest-input branch: the same key as an uppercase 64-hex digest + normalizes to the PEM-derived value (the two forms are interchangeable), and + a mangled digest fails clearly instead of as a missing file. + """ + from esphome.components.esp32 import _sbv2_rsa_key_digest, _validate_trusted_key + + key = ( + Path(__file__).parent.parent.parent + / "components" + / "esp32" + / "dummy_signing_key.pem" + ) + pem_digest = _sbv2_rsa_key_digest(key).hex() + assert _validate_trusted_key(pem_digest.upper()) == pem_digest + for bad in (pem_digest[:-1], "0x" + pem_digest): + with pytest.raises(cv.Invalid, match="64 hex"): + _validate_trusted_key(bad) + # An unquoted 0x.../all-digit digest reaches the validator as a YAML int. + with pytest.raises(cv.Invalid, match="Quote the digest"): + _validate_trusted_key(0x957671F5EC1B55B3) + + @pytest.mark.parametrize( ("value", "expected"), [ diff --git a/tests/components/esp32/test-signed_ota_external.esp32-s3-idf.yaml b/tests/components/esp32/test-signed_ota_external.esp32-s3-idf.yaml new file mode 100644 index 0000000000..fab1d922fc --- /dev/null +++ b/tests/components/esp32/test-signed_ota_external.esp32-s3-idf.yaml @@ -0,0 +1,23 @@ +# External RSA signing mode with a declared trusted-key list enables ESPHome's +# own multi-key OTA signature verifier (USE_OTA_SIGNED_VERIFICATION_MULTI_KEY), +# which accepts an image whose signature block matches one of the compiled-in +# trusted keys. wifi + ota pull in the ota component so CI actually compiles that +# verifier; allow_partition_access exercises the bootloader-update path too. +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + signed_ota_verification: + verification_keys: + - ../../components/esp32/dummy_signing_key.pem + +wifi: + ssid: MySSID + password: password1 + +ota: + - platform: esphome + allow_partition_access: true + +<<: !include common.yaml diff --git a/tests/components/esp32/validate-signed_ota_external.esp32-s3-idf.yaml b/tests/components/esp32/validate-signed_ota_external.esp32-s3-idf.yaml deleted file mode 100644 index 5b57993e87..0000000000 --- a/tests/components/esp32/validate-signed_ota_external.esp32-s3-idf.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# Secure Boot V2 schemes carry the public key inside each image's signature -# block, so verifying externally-signed binaries needs no key in the config: -# a bare block enables verification with the default rsa3072 scheme. -esp32: - variant: esp32s3 - framework: - type: esp-idf - advanced: - signed_ota_verification: - -<<: !include common.yaml From 253ecdd145880aa5e521592a899d0f2d46d9e1d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Aug 2026 14:34:16 -0500 Subject: [PATCH 1257/1815] [core] Resolve the stacktrace decoder lazily behind an address gate (#18048) --- esphome/api_client.py | 3 +- esphome/components/nrf52/__init__.py | 16 +- esphome/platform_hooks.py | 62 +++- esphome/stacktrace.py | 80 +++- tests/unit_tests/test_api_client.py | 97 +++-- tests/unit_tests/test_main.py | 16 +- tests/unit_tests/test_stacktrace.py | 534 +++++++++++++++++++++++++-- 7 files changed, 716 insertions(+), 92 deletions(-) diff --git a/esphome/api_client.py b/esphome/api_client.py index cd80a64fb9..b9a71a3ff7 100644 --- a/esphome/api_client.py +++ b/esphome/api_client.py @@ -56,8 +56,7 @@ async def async_run_logs( provide_time=False, ) - # Decoder resolution, crash isolation, and disable-after-failure - # all live in LogLineProcessor, shared with the serial log path. + # Decoder resolution policy lives in LogLineProcessor. processor = LogLineProcessor(config, CORE.target_platform) def on_log(msg: SubscribeLogsResponse) -> None: diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 4002d1cc04..27d7b5fd35 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -722,11 +722,25 @@ def _addr2line(addr2line: str, elf: Path, addr: str) -> str: return "" +# Module-level so tests can pin the samples that gate lazy decoding +# (tests/unit_tests/test_stacktrace.py) against the real pattern. +# The PC group is bounded to 3+ hex digits to agree with the log gate +# in platform_hooks.STACKTRACE_GATES by construction; the logger prints +# both registers with %08x, so a real PC is always 8 digits and even a +# vector-table address is zero-padded past the bound. The LR bound is +# left wide so a nonsensical LR still satisfies the combined match; the +# regex is all or nothing, so a failed LR half would drop the PC decode +# with it. Widening the PC bound without the gate fails the generative +# superset test in tests/unit_tests/test_stacktrace.py, so the two +# cannot drift. +STACKTRACE_NRF52_PC_LR_RE = re.compile(r"PC=(0x[0-9a-fA-F]{3,})\s+LR=(0x[0-9a-fA-F]+)") + + def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> bool: if "Last crash:" in line: return True if backtrace_state: - match = re.search(r"PC=(0x[0-9a-fA-F]+)\s+LR=(0x[0-9a-fA-F]+)", line) + match = STACKTRACE_NRF52_PC_LR_RE.search(line) if match: pc = match.group(1) lr = match.group(2) diff --git a/esphome/platform_hooks.py b/esphome/platform_hooks.py index 893500290b..b58e3f570c 100644 --- a/esphome/platform_hooks.py +++ b/esphome/platform_hooks.py @@ -37,12 +37,54 @@ _LOGGER = logging.getLogger(__name__) # (upload method, log transport) warns. A new hook is loud by default. COSMETIC_HOOKS: Final = frozenset({"process_stacktrace"}) +# Trigger gates for the stacktrace decoders, keyed by platform. A log +# session knows its target platform, so each session only pays for its +# own platform's trigger language; a line matching the gate is what +# lazily imports the platform package to resolve the decoder, and a +# false trigger costs that import on the event loop mid-stream. That is +# why 8-digit decimals (uptime counters) and ESP-IDF's decimal log +# timestamps must stay out of the esp8266 bare-hex branch. +# +# Declaring a gate here is what registers a platform's +# process_stacktrace hook; deriving the registry entry from the keys +# keeps the two in sync by construction. Each gate must stay a superset +# of its decoder patterns' trigger language; both directions are pinned +# in tests/unit_tests/test_stacktrace.py, including a generative test +# that derives inputs from the decoder regexes themselves. The keyword +# branches exist because (?:0x)? register forms can glue a pointer to +# trailing word characters that defeat the pointer branch's \b, and the +# markers are the address-free lines that open the state-gated +# decoders' dump regions. The esp32/esp8266/rp2 crash handlers all +# announce a stored dump with the CRASH DETECTED banner as its first +# line; their decoders key on the 0x-bearing lines that follow, but +# gating on the banner resolves the decoder at the dump's first line +# and can only be a true positive. +# +# Stored as strings: this module is imported by every CLI invocation, +# and only a log session needs a gate, so the session compiles exactly +# its own platform's entry instead of import time compiling all four. +STACKTRACE_GATES: Final[dict[str, str]] = { + PLATFORM_ESP32: ( + r"0x[0-9a-fA-F]{3,}\b" + r"|(?:PC|RA|MEPC|MTVAL|EXCVADDR|call)\s*[:=]\s*(?:0x)?4[0-9a-fA-F]{7}" + r"|CRASH DETECTED ON PREVIOUS BOOT" + ), + PLATFORM_ESP8266: ( + r"0x[0-9a-fA-F]{3,}\b" + r"|\b(?![0-9]{8}\b)[0-9a-fA-F]{8}\b" + r"|(?:PC|EXCVADDR|call)\s*[:=]\s*(?:0x)?4[0-9a-fA-F]{7}" + r"|[eE]xception \(\d+\):" + r"|>>>stack>>>" + r"|CRASH DETECTED ON PREVIOUS BOOT" + ), + PLATFORM_RP2: r"0x[0-9a-fA-F]{3,}\b|CRASH DETECTED ON PREVIOUS BOOT", + PLATFORM_NRF52: r"0x[0-9a-fA-F]{3,}\b|Last crash:", +} + PLATFORM_HOOKS: Final[dict[str, frozenset[str]]] = { "show_logs": frozenset({PLATFORM_NRF52}), "upload_program": frozenset({PLATFORM_NRF52}), - "process_stacktrace": frozenset( - {PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_NRF52, PLATFORM_RP2} - ), + "process_stacktrace": frozenset(STACKTRACE_GATES), } @@ -55,6 +97,18 @@ PLATFORM_HOOKS: Final[dict[str, frozenset[str]]] = { _IN_TREE_PLATFORMS: Final = frozenset(Platform) +def has_registered_hook(platform: str, hook: str) -> bool: + """True when *platform* declares *hook* in ``PLATFORM_HOOKS``. + + Callers that defer imports key off this: a registered hook is known + to exist, so ``get_platform_hook`` can wait until it is needed; + anything else must be probed up front so availability is reported + at session start. Keeping the predicate here keeps the resolution + rule in one module. + """ + return platform in PLATFORM_HOOKS[hook] + + def get_platform_hook(platform: str, hook: str) -> Callable[..., Any] | None: """Return ``esphome.components..`` or None. @@ -63,7 +117,7 @@ def get_platform_hook(platform: str, hook: str) -> Callable[..., Any] | None: hook also returns None, so a stale registry degrades to the generic path instead of raising. """ - registered = platform in PLATFORM_HOOKS[hook] + registered = has_registered_hook(platform, hook) if not registered and platform in _IN_TREE_PLATFORMS: return None # For external platforms this probes the imported package like the diff --git a/esphome/stacktrace.py b/esphome/stacktrace.py index 3fe3ef3cfe..93b1a73ea9 100644 --- a/esphome/stacktrace.py +++ b/esphome/stacktrace.py @@ -1,4 +1,4 @@ -"""Stack-trace decoding for streamed device log lines. +"""Lazy stack-trace decoding for streamed device log lines. Shared by the serial (run_miniterm) and network (api_client) log paths. Deliberately light: importing this module must not pull in aioesphomeapi @@ -8,6 +8,7 @@ or any platform package. from __future__ import annotations import logging +import re from typing import TYPE_CHECKING from esphome import platform_hooks @@ -26,18 +27,28 @@ _LOGGER = logging.getLogger(__name__) class LogLineProcessor: """Feeds incoming log lines to the stack-trace decoder. - Two responsibilities beyond just calling the decoder: - 1. Catch everything the decoder can raise. aioesphomeapi isolates + Three responsibilities beyond just calling the decoder: + 1. Resolve the platform decoder through the registry: lazily for + in-tree platforms with a registered decoder, where nothing is + imported until a line matches the platform's own gate in + platform_hooks.STACKTRACE_GATES, and eagerly otherwise. A + registry-proven miss reports its unavailable notice at session + start without importing anything; an external platform resolves + up front because the gates' grammar derives from the in-tree + decoders and its import cannot be avoided anyway - resolving + early keeps it out of the streaming callback, where a blocking + import would stall delivery mid-stream. + 2. Catch everything the decoder can raise. aioesphomeapi isolates exceptions raised by log handlers, so an escaping one no longer kills the session, but it does log a full traceback per line. A crash dump carries a PC line plus one per backtrace frame, so the tracebacks bury the dump the user is trying to read. Decoding is a diagnostic nicety; nothing it raises is worth that noise. - 2. Disable decoding for the rest of the session after a failure. + 3. Disable decoding for the rest of the session after a failure. _decode_pc shells out to the toolchain to resolve addr2line, which is expensive; a single crash dump can contain many PC/BT lines and we don't want to retry the failing subprocess for each - one. This only works if every failure is caught, which is why 1 + one. This only works if every failure is caught, which is why 2 is not narrowed to EsphomeError. The latch is deliberately one way: nothing a decode failure depends on heals by itself within a session, the warning names the fix, and a fresh ``esphome @@ -48,28 +59,57 @@ class LogLineProcessor: def __init__(self, config: ConfigType, platform: str) -> None: self._config = config self._platform = platform - self._platform_handler: StacktraceHandler | None - try: - self._platform_handler = platform_hooks.get_stacktrace_handler(platform) - except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except - # Total containment includes resolution: a platform package - # broken in an unanticipated way must not kill the session. - # Name the cause; the full traceback only exists at debug. - _LOGGER.debug("Stacktrace analyzer resolution failed", exc_info=True) - _LOGGER.warning( - 'Stacktrace analysis is unavailable: analyzer for target platform "%s" could not be loaded: %s', - platform, - f"{type(exc).__name__}: {exc}", - ) - self._platform_handler = None - self._decode_enabled = self._platform_handler is not None + self._platform_handler: StacktraceHandler | None = None + self._decode_enabled = True + # None only for platforms resolved eagerly below, which never + # consult the gate: a registered platform always declares one. + # Compiled here rather than in the registry so only a log + # session pays for its own platform's gate. + gate = platform_hooks.STACKTRACE_GATES.get(platform) + self._gate: re.Pattern[str] | None = None if gate is None else re.compile(gate) self.backtrace_state = False + if not platform_hooks.has_registered_hook(platform, "process_stacktrace"): + self._resolve_handler() def process_line(self, raw_line: str) -> None: if not self._decode_enabled: return + if self._platform_handler is None: + if not self._gate.search(raw_line): + return + # Deliberate trade: the platform import (~300 ms, seconds on + # small hosts) blocks the streaming callback here, once per + # session, instead of every session paying it at startup. + if not self._resolve_handler(): + return + # The only runtime breadcrumb for the gate: with -v this + # distinguishes "gate never fired" from "no crash occurred". + _LOGGER.debug( + "Stacktrace gate fired for %s; decoder resolved", self._platform + ) self._feed(raw_line) + def _resolve_handler(self) -> bool: + try: + handler = platform_hooks.get_stacktrace_handler(self._platform) + except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except + # Total containment includes resolution: a platform package + # broken in an unanticipated way must not kill the session or + # retry on every address-bearing line. Name the cause like + # _feed does; the full traceback only exists at debug. + _LOGGER.debug("Stacktrace analyzer resolution failed", exc_info=True) + _LOGGER.warning( + 'Stacktrace analysis is unavailable: analyzer for target platform "%s" could not be loaded: %s', + self._platform, + f"{type(exc).__name__}: {exc}", + ) + handler = None + if handler is None: + self._decode_enabled = False + return False + self._platform_handler = handler + return True + def _feed(self, raw_line: str) -> None: try: self.backtrace_state = self._platform_handler( diff --git a/tests/unit_tests/test_api_client.py b/tests/unit_tests/test_api_client.py index 670557c16f..19ed83abe1 100644 --- a/tests/unit_tests/test_api_client.py +++ b/tests/unit_tests/test_api_client.py @@ -28,40 +28,13 @@ def test_component_shim_reexports_runtime_client() -> None: assert api.CONF_ENCRYPTION is CONF_ENCRYPTION -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("extra_config", "expected_deep_sleep"), - [({"deep_sleep": {}}, True), ({}, False)], -) -async def test_async_run_logs_passes_deep_sleep( - extra_config: dict, expected_deep_sleep: bool -) -> None: - """async_run_logs tells async_run whether the device deep sleeps, from the config.""" - CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} - config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}, **extra_config} - # async_run blocks forever after connecting; raise to unwind async_run_logs - # once we have captured how it was called. - sentinel = RuntimeError("stop the wait") - - with ( - patch.object( - api_client, "async_run", AsyncMock(side_effect=sentinel) - ) as mock_run, - patch.object(api_client, "APIClient"), - pytest.raises(RuntimeError, match="stop the wait"), - ): - await api_client.async_run_logs(config, ["1.2.3.4"]) - - assert mock_run.call_args.kwargs["deep_sleep"] is expected_deep_sleep - - @pytest.mark.asyncio async def test_async_run_logs_full_flow(caplog) -> None: """Drive async_run_logs end to end with a fake connection. Covers the encryption key extraction, the multi-address banner, the - missing-stacktrace-analyzer fallback, the on_log handler, and the - stop() cleanup in the finally block. + registry-miss unavailable notice at session start, the on_log + handler, and the stop() cleanup in the finally block. """ caplog.set_level("INFO", logger="esphome.api_client") caplog.set_level("INFO", logger="esphome.platform_hooks") @@ -112,6 +85,41 @@ async def test_async_run_logs_full_flow(caplog) -> None: stop.assert_awaited_once() +@pytest.mark.asyncio +async def test_async_run_logs_never_resolves_without_crash_lines() -> None: + """The headline claim: an ordinary session imports no platform code.""" + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + run_started = asyncio.Event() + + async def fake_async_run(*args, **kwargs): + run_started.set() + return stop + + mock_run = AsyncMock(side_effect=fake_async_run) + + with ( + patch.object(api_client, "async_run", mock_run), + patch.object(api_client, "APIClient"), + patch.object(api_client, "safe_print"), + patch("esphome.platform_hooks.get_stacktrace_handler") as mock_resolve, + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"]) + ) + async with asyncio.timeout(1): + await run_started.wait() + on_log = mock_run.call_args.args[1] + on_log(Mock(message=b"[I][app:100] hello\n[C][wifi:200] connected")) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + mock_resolve.assert_not_called() + + def test_run_logs_suppresses_keyboard_interrupt() -> None: """Ctrl-C during log streaming exits cleanly instead of tracebacking.""" with patch.object( @@ -124,3 +132,34 @@ def test_run_logs_suppresses_keyboard_interrupt() -> None: ) assert mock_run.call_args.kwargs["subscribe_states"] is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("extra_config", "expected_deep_sleep"), + [({"deep_sleep": {}}, True), ({}, False)], +) +async def test_async_run_logs_passes_deep_sleep( + extra_config: dict, expected_deep_sleep: bool +) -> None: + """async_run_logs tells async_run whether the device deep sleeps. + + That flag is the only thing capping reconnect backoff for a device + that is only briefly awake; dropping it means missed wake windows. + """ + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}, **extra_config} + # async_run blocks forever after connecting; raise to unwind + # async_run_logs once we have captured how it was called. + sentinel = RuntimeError("stop the wait") + + with ( + patch.object( + api_client, "async_run", AsyncMock(side_effect=sentinel) + ) as mock_run, + patch.object(api_client, "APIClient"), + pytest.raises(RuntimeError, match="stop the wait"), + ): + await api_client.async_run_logs(config, ["1.2.3.4"]) + + assert mock_run.call_args.kwargs["deep_sleep"] is expected_deep_sleep diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 16ab677ba6..fc7bb9ada3 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -63,7 +63,7 @@ from esphome.__main__ import ( ) from esphome.address_cache import AddressCache from esphome.bundle import BUNDLE_EXTENSION, BundleFile, BundleResult -from esphome.components import esp32 +from esphome.components import esp32, esp8266 from esphome.components.esp32 import ( KEY_ESP32, KEY_VARIANT, @@ -5738,8 +5738,12 @@ def test_run_miniterm_batches_lines_with_same_timestamp( def test_run_miniterm_analyzer_import_failure_keeps_streaming( caplog: pytest.LogCaptureFixture, ) -> None: - """A broken platform import must not stop serial log streaming.""" - mock_serial = MockSerial([b"[I][app:100]: Line 1\r\n", MOCK_SERIAL_END]) + """A broken platform import must not stop serial log streaming. + + The decoder resolves lazily, so a crash-shaped line has to arrive + before the import is attempted at all. + """ + mock_serial = MockSerial([b"PC: 0x40104960\r\n", MOCK_SERIAL_END]) CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP32} config = { @@ -5870,7 +5874,9 @@ def test_run_miniterm_backtrace_state_maintained() -> None: mock_serial = MockSerial([backtrace_chunk, MOCK_SERIAL_END]) - CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP32} + # An esp8266 dump on an esp8266 session; the platform-scoped gate + # would rightly never resolve esp32's decoder for these lines. + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP8266} config = { CONF_LOGGER: { CONF_BAUD_RATE: 115200, @@ -5896,7 +5902,7 @@ def test_run_miniterm_backtrace_state_maintained() -> None: with ( patch("serial.Serial", return_value=mock_serial), patch.object( - esp32, + esp8266, "process_stacktrace", side_effect=track_backtrace_state, ), diff --git a/tests/unit_tests/test_stacktrace.py b/tests/unit_tests/test_stacktrace.py index fcc99ab587..ff317e55f8 100644 --- a/tests/unit_tests/test_stacktrace.py +++ b/tests/unit_tests/test_stacktrace.py @@ -2,14 +2,381 @@ from __future__ import annotations +import importlib +import inspect +from pathlib import Path +import re from unittest.mock import Mock, patch +from hypothesis import given, settings +from hypothesis.strategies import data as st_data, from_regex +import pytest + from esphome import stacktrace -from esphome.const import PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266 +from esphome.const import ( + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_NRF52, + PLATFORM_RP2, +) from esphome.core import EsphomeError CONFIG = {"esphome": {"name": "test"}} +# Real dump lines per registered platform. "addresses" are gate-firing +# dump lines (registers, backtraces, the exception header); the gate +# must fire on each or that platform's decoding silently never starts. +# "state_markers" are the address-free lines that set backtrace_state; +# the gate matches them directly so their decoders never miss the line +# that opens their dump region. "extra_triggers" are gate-firing lines +# no decoder pattern consumes, like the stored-dump banner that lets +# the decoder resolve at a dump's first line. A new decoder must +# declare its lines here so all are pinned instead of discovered in +# the field. +CRASH_SAMPLES: dict[str, dict[str, list[str]]] = { + PLATFORM_ESP32: { + "state_markers": [], + "extra_triggers": ["*** CRASH DETECTED ON PREVIOUS BOOT ***"], + "addresses": [ + "Backtrace: 0x400d1a2c:0x3ffb1f60 0x400d2a3c:0x3ffb1f80", + "PC : 0x400d1a2c PS : 0x00060330", + "EXCVADDR: 0x40001234", + "MEPC : 0x40380abc RA : 0x40380def", + "MTVAL : 0x40000123", + "last failed alloc call: 40201234(512)", + "BT0: 0x40104960", + ], + }, + PLATFORM_ESP8266: { + "state_markers": [">>>stack>>>"], + "extra_triggers": ["*** CRASH DETECTED ON PREVIOUS BOOT ***"], + "addresses": [ + "epc1=0x40201234 epc2=0x00000000 excvaddr=0x40001234", + "3ffffe10: 40201234 3ffe8410 00000000 40201000", + "PC : 40201234", + "EXCVADDR: 0x40001234", + "BT0: 0x40201234", + "last failed alloc call: 40201234(512)", + "Exception (28):", + ], + }, + PLATFORM_RP2: { + "state_markers": ["CRASH DETECTED ON PREVIOUS BOOT"], + "addresses": ["PC: 0x10001234 (fault location)"], + }, + PLATFORM_NRF52: { + "state_markers": ["Last crash:"], + "addresses": [ + # The zephyr logger prints both registers with %08x, so even + # a vector-table PC pads past the decoder's {3,} bound. + "PC=0x00000050 LR=0x00000000", + # Synthetic short form; keeps the bound's lower edge pinned. + "PC=0x27a1c LR=0x1e33", + ], + }, +} + +BENIGN_LINES = [ + "[I][app:100] hello world", + "[C][wifi:400] BSSID: AA:BB:CC:DD:EE:FF", + "[19:26:11.966][I][main:151]: version 2026.7.0-dev", + "[I][app:102]: Uptime: 12345678 ms", + "[I][app:102]: Uptime: 41234567 ms", + "[V][esp-idf:000]: I (40219876) wifi: connected", + "[D][api:102]: Client connected (40123456)", + "[D][sensor:093]: 'Water meter': Sending state 12345678.00000 L", + # A 32-hex hash has no internal word boundary, so the exactly-8 + # bare-hex branch must not fire anywhere inside it. + "[I][ota:117]: MD5 of binary: d41d8cd98f00b204e9800998ecf8427e", + # Short 0x tokens are everywhere (BLE handles, flags); the pointer + # branch's 3-digit minimum exists to keep them out. + "[D][ble:200]: Connection handle 0x1F, MTU 23", + "[C][network:600]: IPv6: fe80::1a2b:3c4d:5e6f:7a8b", + "[C][ota:097]: Version: 2026.7.0", +] + +GATE_PARAMS = [ + pytest.param(platform, line, True, id=f"{platform}-{kind}-{n}") + for platform, samples in CRASH_SAMPLES.items() + for kind in ("addresses", "state_markers", "extra_triggers") + for n, line in enumerate(samples.get(kind, [])) +] + [ + pytest.param(platform, line, False, id=f"benign-{platform}-{n}") + for platform in CRASH_SAMPLES + for n, line in enumerate(BENIGN_LINES) +] + + +@pytest.mark.parametrize(("platform", "line", "should_fire"), GATE_PARAMS) +def test_platform_gate(platform: str, line: str, should_fire: bool) -> None: + gate = re.compile(stacktrace.platform_hooks.STACKTRACE_GATES[platform]) + assert bool(gate.search(line)) is should_fire + + +def test_gates_are_platform_scoped() -> None: + """A session only pays for its own platform's trigger language. + + Another platform's marker on an esp32 session must not cost the + one-time import; the platform is known when the session starts. + """ + esp32_gate = re.compile(stacktrace.platform_hooks.STACKTRACE_GATES[PLATFORM_ESP32]) + for line in ( + ">>>stack>>>", + "Last crash:", + "Exception (28):", + "3ffffe10: 40201234 3ffe8410 00000000 40201000", + ): + assert not esp32_gate.search(line) + + +def _top_level_branches(pattern: str) -> list[str]: + """Split a regex source on alternations outside groups and classes.""" + branches: list[str] = [] + depth = 0 + in_class = False + esc = False + start = 0 + for i, ch in enumerate(pattern): + if esc: + esc = False + elif ch == "\\": + esc = True + elif in_class: + in_class = ch != "]" + elif ch == "[": + in_class = True + elif ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + elif ch == "|" and depth == 0: + branches.append(pattern[start:i]) + start = i + 1 + branches.append(pattern[start:]) + return branches + + +@pytest.mark.parametrize("platform", sorted(CRASH_SAMPLES)) +def test_every_gate_branch_is_exercised(platform: str) -> None: + """A typoed or dead gate branch cannot hide behind the others. + + The superset checks stay green when a branch matches nothing, so a + broken alternation would silently stop resolving the decoder on the + lines it was added for; every branch must be hit by a sample. + """ + samples = CRASH_SAMPLES[platform] + lines = [line for kind in samples for line in samples[kind]] + branches = _top_level_branches(stacktrace.platform_hooks.STACKTRACE_GATES[platform]) + assert len(branches) > 1 + for branch in branches: + assert any(re.search(branch, line) for line in lines), ( + f"no {platform} sample exercises gate branch {branch!r}; add one " + "or drop the dead branch" + ) + + +# The in-tree sources that print each marker literal the gates key on. +# esp8266's >>>stack>>> comes from the Arduino core's postmortem +# handler, outside this tree; its decoder source is the nearest pin. +FIRMWARE_MARKER_SOURCES = { + "CRASH DETECTED ON PREVIOUS BOOT": ( + "esphome/components/esp32/crash_handler.cpp", + "esphome/components/esp8266/crash_handler.cpp", + "esphome/components/rp2/crash_handler.cpp", + ), + "Last crash:": ("esphome/components/logger/logger_zephyr.cpp",), +} + + +def test_gate_markers_match_firmware_output() -> None: + """The marker literals must stay what the firmware prints. + + A reworded crash banner would keep every regex-level guard green + while the gate silently stops resolving the decoder at a stored + dump's first line; pin the literals to the sources that print them. + """ + root = Path(__file__).parents[2] + for marker, sources in FIRMWARE_MARKER_SOURCES.items(): + for source in sources: + text = (root / source).read_text(encoding="utf-8") + assert marker in text, ( + f"{source} no longer prints {marker!r}; update the gates and " + "samples to the new banner" + ) + + +def test_crash_samples_cover_registry() -> None: + """A newly registered decoder must come with a non-empty gate sample. + + The gate table and the hook registry cannot drift; the registry + entry is derived from the gate table's keys. + """ + assert set(CRASH_SAMPLES) == set(stacktrace.platform_hooks.STACKTRACE_GATES) + assert set(stacktrace.platform_hooks.STACKTRACE_GATES) == set( + stacktrace.platform_hooks.PLATFORM_HOOKS["process_stacktrace"] + ) + assert all(samples["addresses"] for samples in CRASH_SAMPLES.values()) + + +# The stacktrace pattern constants each decoder module exports. The +# samples and these patterns must cover each other, so an edit on either +# side fails the guards below instead of quietly widening the gap +# between the gate and the decoders. +DECODER_PATTERNS: dict[str, list[str]] = { + PLATFORM_ESP32: [ + "STACKTRACE_ESP32_PC_RE", + "STACKTRACE_ESP32_EXCVADDR_RE", + "STACKTRACE_ESP32_C3_PC_RE", + "STACKTRACE_ESP32_C3_RA_RE", + "STACKTRACE_ESP32_C3_MTVAL_RE", + "STACKTRACE_BAD_ALLOC_RE", + "STACKTRACE_ESP32_BACKTRACE_RE", + "STACKTRACE_ESP32_BACKTRACE_PC_RE", + "STACKTRACE_ESP32_CRASH_BT_RE", + ], + PLATFORM_ESP8266: [ + "STACKTRACE_ESP8266_EXCEPTION_TYPE_RE", + "STACKTRACE_ESP8266_PC_RE", + "STACKTRACE_ESP8266_EXCVADDR_RE", + "STACKTRACE_ESP8266_CRASH_PC_RE", + "STACKTRACE_ESP8266_CRASH_EXCVADDR_RE", + "STACKTRACE_ESP8266_CRASH_BT_RE", + "STACKTRACE_BAD_ALLOC_RE", + "STACKTRACE_ESP8266_BACKTRACE_PC_RE", + ], + PLATFORM_RP2: ["_CRASH_RE", "_CRASH_ADDR_RE"], + PLATFORM_NRF52: ["STACKTRACE_NRF52_PC_LR_RE"], +} + +# Declared decoder patterns whose language the gate deliberately does +# not cover: bare stack-dump words, where the gate keys on the dump +# line's 3ff... stack address instead and a lone letter-free word never +# appears outside a dump region whose other lines already fired. +GATE_EXEMPT_PATTERNS = { + "STACKTRACE_ESP32_BACKTRACE_PC_RE", + "STACKTRACE_ESP8266_BACKTRACE_PC_RE", +} + + +@pytest.mark.parametrize("platform", sorted(CRASH_SAMPLES)) +def test_platform_declarations_match_decoder(platform: str) -> None: + r"""Samples, declared patterns, and the decoder must agree. + + Directions checked: every declared pattern exists; every address + sample matches a declared pattern; every declared pattern is + exercised by a sample; no stacktrace pattern exists undeclared; each + declared state marker behaviourally opens the decoder's dump region; + and a decoder that sets state must declare a marker. + + Known blind spots: the esp32/esp8266 catch-all backtrace patterns + can satisfy the sample-matches-a-pattern direction on their own; the + undeclared-pattern sweep keys off naming, so a differently-named + constant or a function-local re.search literal is invisible to it; + the state-gated detection rests on a textual heuristic (every + such decoder today spells it as ``return True`` or + ``backtrace_state = True``, and the declared-markers direction + pins the heuristic against a silent respelling); a decoder that + gains a second opening marker alongside a declared one passes + unnoticed, since the declared marker already satisfies both the + marker-opens-region check and the non-empty ``state_markers`` + requirement; and the generative guard draws full matches only, so + a decoder match glued to trailing word characters that defeat the + pointer branch's ``\b`` is invisible to it (today's crash + handlers always delimit addresses). + """ + module = importlib.import_module(f"esphome.components.{platform}") + patterns: dict[str, re.Pattern] = {} + for name in DECODER_PATTERNS[platform]: + pattern = getattr(module, name, None) + if pattern is None: + pytest.fail( + f"{platform} no longer defines {name}; update DECODER_PATTERNS " + "and CRASH_SAMPLES together" + ) + patterns[name] = pattern + + lines = ( + CRASH_SAMPLES[platform]["state_markers"] + CRASH_SAMPLES[platform]["addresses"] + ) + for line in CRASH_SAMPLES[platform]["addresses"]: + assert any(p.search(line) for p in patterns.values()), ( + f"{line!r} no longer matches any {platform} decoder pattern; " + "update CRASH_SAMPLES and re-derive the gate" + ) + for name, pattern in patterns.items(): + assert any(pattern.search(line) for line in lines), ( + f"no sample exercises {platform}.{name}; add one so the gate " + "provably covers it" + ) + undeclared = [ + name + for name, value in vars(module).items() + if isinstance(value, re.Pattern) + and ("STACKTRACE" in name or name.startswith("_CRASH")) + and name not in DECODER_PATTERNS[platform] + ] + assert not undeclared, ( + f"{platform} gained stacktrace patterns {undeclared}; declare them in " + "DECODER_PATTERNS with samples" + ) + + for marker in CRASH_SAMPLES[platform]["state_markers"]: + assert module.process_stacktrace(CONFIG, marker, False) is True, ( + f"{marker!r} no longer opens {platform}'s dump region; update " + "state_markers to the line the decoder actually keys on" + ) + # Textual heuristic, deliberately one-directional: a state-gated + # decoder must declare a marker. The reverse (a stateless decoder + # declaring none) is not asserted; an unrelated "return True" added + # to a decoder would turn it into a false failure. + source = inspect.getsource(module.process_stacktrace) + sets_state = "return True" in source or "backtrace_state = True" in source + if CRASH_SAMPLES[platform]["state_markers"]: + # The heuristic fails open on a respelling (return bool(...)); + # pinning it against the decoders known to be state-gated today + # turns a silent disarm into a failure that names the fix. + assert sets_state, ( + f"{platform}.process_stacktrace declares state_markers but the " + "state-gating heuristic no longer recognises it; update the " + "spelling list in this test" + ) + if sets_state: + assert CRASH_SAMPLES[platform]["state_markers"], ( + f"{platform}.process_stacktrace is state-gated but declares no " + "state_markers; the gate cannot promise to open its dump region" + ) + + +@pytest.mark.parametrize( + ("platform", "name"), + [ + (platform, name) + for platform, names in DECODER_PATTERNS.items() + for name in names + if name not in GATE_EXEMPT_PATTERNS + ], +) +@given(data=st_data()) +@settings(max_examples=25, deadline=None) +def test_address_gate_covers_decoder_pattern_languages( + platform: str, name: str, data +) -> None: + """Each platform's gate must be a superset of its decoder patterns. + + The sample table only pins finite literals; a decoder regex that + widens would keep every sample green while the gate misses the new + form. Generating inputs from the decoder regex itself closes that + direction. + """ + pattern = getattr(importlib.import_module(f"esphome.components.{platform}"), name) + example = data.draw(from_regex(pattern, fullmatch=True)) + gate = re.compile(stacktrace.platform_hooks.STACKTRACE_GATES[platform]) + assert gate.search(example), ( + f"{platform}.{name} accepts {example!r} but the {platform} gate does " + "not fire; decoding would silently never start on that form" + ) + def _run( handler, @@ -21,8 +388,8 @@ def _run( stacktrace.platform_hooks, "get_stacktrace_handler", return_value=handler ): processor = stacktrace.LogLineProcessor(CONFIG, platform) - for line in lines: - processor.process_line(line) + for line in lines: + processor.process_line(line) return processor @@ -30,7 +397,7 @@ def _fed(handler) -> list[str]: return [call.args[1] for call in handler.call_args_list] -def _warnings(caplog) -> list[str]: +def _warnings(caplog: pytest.LogCaptureFixture) -> list[str]: return [r.message for r in caplog.records if r.levelname == "WARNING"] @@ -51,23 +418,9 @@ def test_decoder_contains_failures_and_short_circuits() -> None: assert processor.backtrace_state is False -def test_resolution_failure_is_contained(caplog) -> None: - """A platform package broken in an unanticipated way must not kill - the session; decoding degrades with a warning like any other failure. - """ - with patch.object( - stacktrace.platform_hooks, - "get_stacktrace_handler", - side_effect=RuntimeError("boom"), - ): - processor = stacktrace.LogLineProcessor(CONFIG, PLATFORM_ESP32) - processor.process_line("PC: 0x4010496e") - - assert processor.backtrace_state is False - assert any("could not be loaded" in m for m in _warnings(caplog)) - - -def test_decoder_swallows_os_error_with_remediation_hint(caplog) -> None: +def test_decoder_swallows_os_error_with_remediation_hint( + caplog: pytest.LogCaptureFixture, +) -> None: """Decoding failures that aren't EsphomeError must be contained too. A missing build directory surfaces as an OSError; that is the @@ -86,7 +439,9 @@ def test_decoder_swallows_os_error_with_remediation_hint(caplog) -> None: assert not any("this is a bug" in m for m in warnings) -def test_decoder_warning_uses_fallback_for_empty_error(caplog) -> None: +def test_decoder_warning_uses_fallback_for_empty_error( + caplog: pytest.LogCaptureFixture, +) -> None: """A message-less EsphomeError must show a useful explanation. Defensive: the in-tree idedata raise sites all carry a message now, @@ -99,7 +454,9 @@ def test_decoder_warning_uses_fallback_for_empty_error(caplog) -> None: assert not any("()" in m for m in warnings) -def test_decoder_bug_with_empty_message_names_the_type(caplog) -> None: +def test_decoder_bug_with_empty_message_names_the_type( + caplog: pytest.LogCaptureFixture, +) -> None: """A zero-message decoder bug must not masquerade as missing artifacts. The recompile hint is only right for EsphomeError from _run_idedata; @@ -113,7 +470,9 @@ def test_decoder_bug_with_empty_message_names_the_type(caplog) -> None: assert not any("esphome compile" in m for m in warnings) -def test_decoder_bug_warning_keeps_the_type_with_a_message(caplog) -> None: +def test_decoder_bug_warning_keeps_the_type_with_a_message( + caplog: pytest.LogCaptureFixture, +) -> None: """The type must survive a non-empty message; a bare KeyError message like 'prog_path' reads as a raised string in a bug report paste. """ @@ -123,8 +482,12 @@ def test_decoder_bug_warning_keeps_the_type_with_a_message(caplog) -> None: assert any("KeyError: 'prog_path'" in m for m in warnings) -def test_state_threads_between_lines() -> None: - """backtrace_state carries from one decoded line to the next.""" +def test_marker_then_address_threads_state() -> None: + """A state marker resolves the decoder and threads state onward. + + esp8266's ``>>>stack>>>`` fires the gate itself, so the decoder sees + it live and the following stack words decode inside the region. + """ handler = Mock(side_effect=[True, True]) processor = _run( handler, @@ -141,11 +504,120 @@ def test_state_threads_between_lines() -> None: assert processor.backtrace_state is True -def test_no_analyzer_disables_decoding(caplog) -> None: - """Platforms without an analyzer report at session start and stay quiet.""" - caplog.set_level("INFO", logger="esphome.platform_hooks") - processor = stacktrace.LogLineProcessor(CONFIG, PLATFORM_BK72XX) - processor.process_line("PC: 0x40104960") +def test_lines_before_the_gate_never_reach_the_decoder() -> None: + """Benign lines are dropped, not buffered: the gate is a superset of + the decoder languages, so a line that fails it cannot decode. + """ + handler = Mock(return_value=False) + quiet = tuple(f"quiet line {n}" for n in range(12)) + _run(handler, lines=quiet + ("PC: 0x4010496e",)) + assert _fed(handler) == ["PC: 0x4010496e"] + + +def test_processor_resolves_lazily_on_address_token() -> None: + """No resolution attempt until a line carries an address token.""" + handler = Mock(return_value=False) + + with patch.object( + stacktrace.platform_hooks, "get_stacktrace_handler", return_value=handler + ) as mock_resolve: + processor = stacktrace.LogLineProcessor(CONFIG, PLATFORM_ESP32) + processor.process_line("[I][app:100] hello world") + mock_resolve.assert_not_called() + + processor.process_line("PC: 0x40104960") + mock_resolve.assert_called_once_with(PLATFORM_ESP32) + + # Later lines feed the resolved handler directly, no re-resolution. + processor.process_line("[I][app:101] back to normal") + mock_resolve.assert_called_once() + + assert _fed(handler) == ["PC: 0x40104960", "[I][app:101] back to normal"] + + +def test_processor_unexpected_resolution_error_disables_decoding( + caplog: pytest.LogCaptureFixture, +) -> None: + """Resolution is inside the containment guarantee like everything else.""" + with patch.object( + stacktrace.platform_hooks, + "get_stacktrace_handler", + side_effect=OSError("filesystem went away"), + ) as mock_resolve: + processor = stacktrace.LogLineProcessor(CONFIG, PLATFORM_ESP32) + processor.process_line("PC: 0x40104960") + processor.process_line("BT0: 0x40104960") + + mock_resolve.assert_called_once() + warnings = _warnings(caplog) + assert len(warnings) == 1 + assert "could not be loaded" in warnings[0] + assert processor.backtrace_state is False + + +def test_processor_import_failure_disables_decoding( + caplog: pytest.LogCaptureFixture, +) -> None: + """A broken platform package degrades once instead of raising.""" + caplog.set_level("INFO", logger="esphome.platform_hooks") + + with patch.object( + stacktrace.platform_hooks, + "import_module", + Mock(side_effect=ImportError("broken install")), + ) as mock_import: + processor = stacktrace.LogLineProcessor(CONFIG, PLATFORM_ESP32) + processor.process_line("PC: 0x40104960") + processor.process_line("BT0: 0x40104960") + + mock_import.assert_called_once() + assert "Stacktrace analysis is unavailable" in caplog.text + assert "broken install" in caplog.text + assert processor.backtrace_state is False + + +def test_processor_registry_miss_disables_at_construction( + caplog: pytest.LogCaptureFixture, +) -> None: + """Platforms the registry proves have no analyzer disable up front. + + The unavailable notice fires at session start (as it always did) and + the per-line gate never runs. + """ + caplog.set_level("INFO", logger="esphome.platform_hooks") + + with patch.object(stacktrace.platform_hooks, "import_module") as mock_import: + processor = stacktrace.LogLineProcessor(CONFIG, PLATFORM_BK72XX) + processor.process_line("PC: 0x40104960") + + mock_import.assert_not_called() assert "Stacktrace analysis is unavailable" in caplog.text assert processor.backtrace_state is False + + +def test_external_platform_resolves_at_construction( + caplog: pytest.LogCaptureFixture, +) -> None: + """External platforms resolve eagerly, like before the registry. + + The address gate's grammar derives from the in-tree decoders, so it + cannot speak for an external decoder; resolving up front keeps the + import off the streaming callback and the notice at session start. + """ + caplog.set_level("INFO", logger="esphome.platform_hooks") + module = type("ExternalPlatform", (), {}) # no process_stacktrace + + with patch.object( + stacktrace.platform_hooks, + "import_module", + Mock(return_value=module), + ) as mock_import: + processor = stacktrace.LogLineProcessor(CONFIG, "my_external_chip") + mock_import.assert_called_once() + assert "Stacktrace analysis is unavailable" in caplog.text + + processor.process_line("PC: 0x40104960") + + mock_import.assert_called_once() + assert processor.backtrace_state is False From 98622bd0298096c4ffd58663139472214e1d4661 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:50:36 -0400 Subject: [PATCH 1258/1815] Bump esphome/workflows/.github/workflows/stale.yml from 203cea60ebfd18e2b966e57750750e0417a9feec to 61fd37a044cad4e9aa4303027b2a61b6a34da855 (#18095) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 8ec88238a9..3c471b6efb 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -16,7 +16,7 @@ jobs: # No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome # GitHub App token so the labels, comments and closures come from # esphome[bot] instead of github-actions[bot]. - uses: esphome/workflows/.github/workflows/stale.yml@203cea60ebfd18e2b966e57750750e0417a9feec # main + uses: esphome/workflows/.github/workflows/stale.yml@61fd37a044cad4e9aa4303027b2a61b6a34da855 # main secrets: ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} with: From 17f3c9d780a9677cce8e66012e79ae18e47e7e6a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:50:57 -0400 Subject: [PATCH 1259/1815] Bump CodSpeedHQ/action from 5.0.1 to 5.0.2 (#18094) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e37cde5a47..388ef3d37b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -466,7 +466,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@88472375d0a4572cf70a9f1fe3a4e0ab8da1b924 # v5.0.1 + uses: CodSpeedHQ/action@0ca9cbbf4623b599a6c3ed4fc8a922942705d9f1 # v5.0.2 with: run: | . venv/bin/activate From 563b6ade0f21744b41d12072abb74b2059af9c22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Wed, 5 Aug 2026 23:06:01 +0300 Subject: [PATCH 1260/1815] [bluetooth_proxy] Platform-neutral advertisement proxy via ble_device_base (#17880) Co-authored-by: J. Nick Koston --- .../components/bluetooth_proxy/__init__.py | 320 ++++++++++++++---- .../bluetooth_proxy/bluetooth_proxy.cpp | 245 ++++++++++++-- .../bluetooth_proxy/bluetooth_proxy.h | 104 +++++- esphome/core/defines.h | 24 +- .../bluetooth_proxy/__init__.py | 0 .../test_idf_max_connections_mirror.py | 19 ++ .../test_outer_schema_mirror.py | 64 ++++ .../bluetooth_proxy/test.ln882x-ard.yaml | 10 + .../bluetooth_proxy/test.rp2040-ard.yaml | 13 + .../bluetooth_proxy/validate.esp32-idf.yaml | 13 + .../bluetooth_proxy/validate.rp2040-ard.yaml | 11 + 11 files changed, 724 insertions(+), 99 deletions(-) create mode 100644 tests/component_tests/bluetooth_proxy/__init__.py create mode 100644 tests/component_tests/bluetooth_proxy/test_idf_max_connections_mirror.py create mode 100644 tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py create mode 100644 tests/components/bluetooth_proxy/test.ln882x-ard.yaml create mode 100644 tests/components/bluetooth_proxy/test.rp2040-ard.yaml create mode 100644 tests/components/bluetooth_proxy/validate.esp32-idf.yaml create mode 100644 tests/components/bluetooth_proxy/validate.rp2040-ard.yaml diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index 4792736dd9..bb05f1b21f 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -1,14 +1,50 @@ +import functools import logging import esphome.codegen as cg -from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker -from esphome.components.esp32 import add_idf_sdkconfig_option -from esphome.components.esp32_ble import BTLoggers +from esphome.components import ble_device_base import esphome.config_validation as cv -from esphome.const import CONF_ACTIVE, CONF_ID +from esphome.const import CONF_ACTIVE, CONF_ID, PLATFORM_LN882X, PLATFORM_RP2 +from esphome.core import CORE +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor +from esphome.types import ConfigType -AUTO_LOAD = ["esp32_ble_client", "esp32_ble_tracker"] -DEPENDENCIES = ["api", "esp32"] +# The esp32 BLE stack (esp32_ble, esp32_ble_client, esp32_ble_tracker) is +# imported lazily inside _esp32_config_schema()/_to_code_esp32(): importing +# those modules registers esp32-only automations (ble.enable, ble.disable, ...) +# as a side effect, and a module-scope import would leak them into every +# platform's registry the moment a config declares `bluetooth_proxy:` — +# degrading "Unable to find action" config errors into C++ compile failures. + + +def AUTO_LOAD(config: ConfigType | None = None) -> list[str]: + """Components to auto-load for the platform being compiled. + + Callable with no argument so tooling that resolves AUTO_LOAD without a + target platform (the device-builder catalog sync does exactly this) gets + the union of every arm instead of an empty list — which is what lets it + keep cross-referencing the esp32 BLE stack. A real build always has a + target platform set, so it takes one of the concrete branches. + """ + if CORE.is_esp32: + return ["esp32_ble_client", "esp32_ble_tracker"] + if CORE.target_platform in _HUB_PLATFORMS: + return ["ble_device_base"] + # No target platform, or one this component does not support: tooling + # resolving the manifest (including the host-pinned dependency resolver) — + # expose every arm so the closure keeps the esp32 BLE stack. + return ["ble_device_base", "esp32_ble_client", "esp32_ble_tracker"] + + +# Platforms with an in-tree ble_device_base BLE tracker hub whose controller +# supports active scanning. Passive-only hubs (bk72xx) are deliberately NOT +# admitted yet: every current client (aioesphomeapi, bleak-esphome, Home +# Assistant) assumes an ESPHome proxy can scan actively, so a passive-only +# proxy would be misdriven — bk72xx follows once the API carries a feature +# flag clients can trust (FEATURE_ACTIVE_SCAN + a version flag, separate PRs). +_HUB_PLATFORMS = (PLATFORM_LN882X, PLATFORM_RP2) + +DEPENDENCIES = ["api"] CODEOWNERS = ["@jesserockz", "@bdraco"] _LOGGER = logging.getLogger(__name__) @@ -20,65 +56,209 @@ DEFAULT_CONNECTION_SLOTS = 3 bluetooth_proxy_ns = cg.esphome_ns.namespace("bluetooth_proxy") -BluetoothProxy = bluetooth_proxy_ns.class_( - "BluetoothProxy", esp32_ble_tracker.ESPBTDeviceListener, cg.Component -) -BluetoothConnection = bluetooth_proxy_ns.class_( - "BluetoothConnection", esp32_ble_client.BLEClientBase -) +BluetoothProxy = bluetooth_proxy_ns.class_("BluetoothProxy", cg.Component) -CONNECTION_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA.extend( - { - cv.GenerateID(): cv.declare_id(BluetoothConnection), - } -).extend(cv.COMPONENT_SCHEMA) +# Mirrors esp32_ble.IDF_MAX_CONNECTIONS as a literal so the statically walkable +# CONFIG_SCHEMA below can state the connection_slots range without importing the +# esp32 BLE stack. tests/component_tests/bluetooth_proxy/ pins the two together. +_IDF_MAX_CONNECTIONS = 9 -def validate_connections(config): - if CONF_CONNECTIONS in config: - if not config[CONF_ACTIVE]: - raise cv.Invalid( - "Connections can only be used if the proxy is set to active" - ) - elif config[CONF_ACTIVE]: - connection_slots: int = config[CONF_CONNECTION_SLOTS] - esp32_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(config) +@functools.cache +def _esp32_config_schema() -> cv.All: + """Build the esp32 schema, importing the esp32 BLE stack only when used.""" + from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker - return { - **config, - CONF_CONNECTIONS: [CONNECTION_SCHEMA({}) for _ in range(connection_slots)], + if esp32_ble.IDF_MAX_CONNECTIONS != _IDF_MAX_CONNECTIONS: + raise cv.Invalid( + f"bluetooth_proxy's connection-slot limit mirror " + f"({_IDF_MAX_CONNECTIONS}) is out of sync with " + f"esp32_ble.IDF_MAX_CONNECTIONS ({esp32_ble.IDF_MAX_CONNECTIONS}); " + f"update _IDF_MAX_CONNECTIONS in bluetooth_proxy/__init__.py" + ) + + BluetoothConnection = bluetooth_proxy_ns.class_( + "BluetoothConnection", esp32_ble_client.BLEClientBase + ) + CONNECTION_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(BluetoothConnection), } + ).extend(cv.COMPONENT_SCHEMA) + + def validate_connections(config): + if CONF_CONNECTIONS in config: + if not config[CONF_ACTIVE]: + raise cv.Invalid( + "Connections can only be used if the proxy is set to active" + ) + elif config[CONF_ACTIVE]: + connection_slots: int = config[CONF_CONNECTION_SLOTS] + esp32_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")( + config + ) + + return { + **config, + CONF_CONNECTIONS: [ + CONNECTION_SCHEMA({}) for _ in range(connection_slots) + ], + } + return config + + return cv.All( + ( + cv.Schema( + { + **_COMMON_SCHEMA_KEYS, + cv.Optional(CONF_ACTIVE, default=True): cv.boolean, + cv.Optional(CONF_CACHE_SERVICES, default=True): cv.boolean, + cv.Optional( + CONF_CONNECTION_SLOTS, + default=DEFAULT_CONNECTION_SLOTS, + ): cv.All( + cv.positive_int, + cv.Range(min=1, max=esp32_ble.IDF_MAX_CONNECTIONS), + ), + cv.Optional(CONF_CONNECTIONS): cv.All( + cv.ensure_list(CONNECTION_SCHEMA), + cv.Length(min=1, max=esp32_ble.IDF_MAX_CONNECTIONS), + ), + } + ) + .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) + ), + validate_connections, + ) + + +def _validate_no_active(config: ConfigType) -> ConfigType: + if config[CONF_ACTIVE]: + raise cv.Invalid( + "Active connections are not supported on this platform; the proxy " + "forwards advertisements only (set active: false)" + ) return config -CONFIG_SCHEMA = cv.All( - ( - cv.Schema( - { - cv.GenerateID(): cv.declare_id(BluetoothProxy), - cv.Optional(CONF_ACTIVE, default=True): cv.boolean, - cv.Optional(CONF_CACHE_SERVICES, default=True): cv.boolean, - cv.Optional( - CONF_CONNECTION_SLOTS, - default=DEFAULT_CONNECTION_SLOTS, - ): cv.All( - cv.positive_int, - cv.Range(min=1, max=esp32_ble.IDF_MAX_CONNECTIONS), - ), - cv.Optional(CONF_CONNECTIONS): cv.All( - cv.ensure_list(CONNECTION_SCHEMA), - cv.Length(min=1, max=esp32_ble.IDF_MAX_CONNECTIONS), - ), - } - ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) - .extend(cv.COMPONENT_SCHEMA) - ), - validate_connections, +# Advertisement-only proxy on a neutral BLE hub: the hub's raw-advertisement +# callback feeds the same API batching. GATT/active connections are excluded at +# compile time — only the esp32 build compiles the connection stack; nothing +# reads HubCapabilities::gatt at runtime for this today. +# Keys both platform schemas must declare identically; each arm spreads this +# dict so the shared surface cannot drift. CONF_ACTIVE deliberately stays +# per-arm: its default differs (esp32 True, hub arms False — no GATT). +_COMMON_SCHEMA_KEYS = { + cv.GenerateID(): cv.declare_id(BluetoothProxy), +} + +_BLE_HUB_CONFIG_SCHEMA = cv.All( + cv.Schema( + { + **_COMMON_SCHEMA_KEYS, + # Declared directly (BLE_DEVICE_SCHEMA-style): appending a validator + # after a strict schema rejects an explicit `ble_hub_id` before it + # runs, and that key is the documented way to disambiguate once a + # platform has two trackers. + cv.GenerateID(ble_device_base.CONF_BLE_HUB_ID): cv.use_id( + ble_device_base.BLEHub + ), + cv.Optional(CONF_ACTIVE, default=False): cv.boolean, + } + ).extend(cv.COMPONENT_SCHEMA), + _validate_no_active, ) -async def to_code(config): +@schema_extractor("schema") +def _validate_platform(config: ConfigType) -> ConfigType: + """Apply the schema for the platform actually being compiled. + + esp32 keeps the full GATT proxy; every other platform gets the + advertisement-only shape, which rejects the connection-oriented options + above because its schema does not define them. + """ + if config is SCHEMA_EXTRACT: + # The language-schema dumper runs without a platform. Expose the esp32 + # shape so `connections`, the ids and every default stay in the + # generated schema the editor and dashboard consume. + return _esp32_config_schema() + if CORE.is_esp32: + return _esp32_config_schema()(config) + if CORE.target_platform not in _HUB_PLATFORMS: + # Fail here with the actual reason. Without this gate the error surfaces + # later as an unresolvable hub ID ("Are you missing a hub declaration?") + # on platforms where no hub component can be declared. + raise cv.Invalid( + f"bluetooth_proxy is not supported on {CORE.target_platform}: no " + "active-scan-capable BLE tracker hub is available for this " + "platform. It runs on esp32 (full proxy), and the ln882x and rp2 " + "families (advertisement-only)." + ) + return _BLE_HUB_CONFIG_SCHEMA(config) + + +def _reject_connection_keys_off_esp32(config: ConfigType) -> ConfigType: + """Reject connection-oriented options by name on hub-only platforms. + + Runs before the walkable schema below so the user gets "this option does + not exist here" instead of the option's esp32 value range (which would + imply a smaller number is accepted). + """ + if not isinstance(config, dict) or CORE.is_esp32 or CORE.target_platform is None: + return config + if CORE.target_platform not in _HUB_PLATFORMS: + # No proxy of any kind exists here: fall through so _validate_platform + # reports "not supported on {platform}" instead of a key-level message + # implying an advertisement-only proxy is available. + return config + for key in (CONF_CONNECTION_SLOTS, CONF_CACHE_SERVICES, CONF_CONNECTIONS): + if key in config: + raise cv.Invalid( + f"'{key}' requires active connection support, which needs the " + "esp32 GATT stack; this platform runs the advertisement-only " + "proxy and has no such option", + path=[key], + ) + return config + + +# CONFIG_SCHEMA stays a statically walkable schema: tooling (the dashboard's +# field-range extractor among others) introspects it to discover options and +# their bounds, which a bare dispatch function would hide. It carries the scalar +# keys with no defaults; _validate_platform then runs the real per-platform +# schema, which applies the defaults and rejects options the platform does not +# support. +# +# It deliberately does NOT declare `connections`: this outer schema runs before +# the per-platform one, so any key it transforms is transformed twice. Running +# CONNECTION_SCHEMA twice re-validates an already-generated ID through +# declare_id(), which (unlike use_id) has no guard for an ID instance and +# rejects it as empty. extra=ALLOW_EXTRA passes `connections` through untouched +# for _ESP32_CONFIG_SCHEMA to validate exactly once. +CONFIG_SCHEMA = cv.All( + _reject_connection_keys_off_esp32, + cv.Schema( + { + cv.Optional(CONF_ACTIVE): cv.boolean, + cv.Optional(CONF_CACHE_SERVICES): cv.boolean, + cv.Optional(CONF_CONNECTION_SLOTS): cv.All( + cv.positive_int, + cv.Range(min=1, max=_IDF_MAX_CONNECTIONS), + ), + }, + extra=cv.ALLOW_EXTRA, + ), + _validate_platform, +) + + +async def _to_code_esp32(config: ConfigType) -> None: + from esphome.components import esp32_ble, esp32_ble_tracker + from esphome.components.esp32 import add_idf_sdkconfig_option + from esphome.components.esp32_ble import BTLoggers + # Register the loggers this component needs esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.L2CAP, BTLoggers.SMP) @@ -93,12 +273,6 @@ async def to_code(config): connection_count = len(config.get(CONF_CONNECTIONS, [])) cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", connection_count) - # Define batch size for BLE advertisements - # Each advertisement is up to 80 bytes when packaged (including protocol overhead) - # 16 advertisements × 80 bytes (worst case) = 1280 bytes out of ~1320 bytes usable payload - # This achieves ~97% WiFi MTU utilization while staying under the limit - cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) - for connection_conf in config.get(CONF_CONNECTIONS, []): connection_var = cg.new_Pvariable(connection_conf[CONF_ID]) await cg.register_component(connection_var, connection_conf) @@ -108,4 +282,30 @@ async def to_code(config): if config.get(CONF_CACHE_SERVICES): add_idf_sdkconfig_option("CONFIG_BT_GATTC_CACHE_NVS_FLASH", True) + +async def _to_code_ble_hub(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + cg.add(var.set_active(config[CONF_ACTIVE])) + hub = await cg.get_variable(config[ble_device_base.CONF_BLE_HUB_ID]) + cg.add(var.set_ble_hub(hub)) + + # The api component sizes BluetoothConnectionsFreeResponse.allocated with + # this define whenever a proxy is present; no connections off-esp32. + cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 0) + + +async def to_code(config: ConfigType) -> None: + if CORE.is_esp32: + await _to_code_esp32(config) + else: + await _to_code_ble_hub(config) + + # Define batch size for BLE advertisements + # Each advertisement is up to 80 bytes when packaged (including protocol overhead) + # 16 advertisements × 80 bytes (worst case) = 1280 bytes out of ~1320 bytes usable payload + # This achieves ~97% WiFi MTU utilization while staying under the limit + cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) + cg.add_define("USE_BLUETOOTH_PROXY") diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index dbe9c1e30d..ad2fc094ae 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -1,6 +1,9 @@ #include "bluetooth_proxy.h" +#ifdef USE_BLUETOOTH_PROXY + #include "esphome/components/api/api_server.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/macros.h" #include "esphome/core/application.h" @@ -8,8 +11,6 @@ #include #include -#ifdef USE_ESP32 - namespace esphome::bluetooth_proxy { static const char *const TAG = "bluetooth_proxy"; @@ -23,6 +24,8 @@ static_assert(sizeof(((api::BluetoothLERawAdvertisement *) nullptr)->data) == 62 BluetoothProxy::BluetoothProxy() { global_bluetooth_proxy = this; } +#ifdef USE_ESP32 + void BluetoothProxy::setup() { this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS; this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS; @@ -48,6 +51,62 @@ void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerSta this->api_connection_->send_message(resp); } +#else // !USE_ESP32 + +void BluetoothProxy::setup() { + this->connections_free_response_.limit = 0; + this->connections_free_response_.free = 0; + + // Capture the configured scan mode from YAML before any API changes + this->configured_scan_active_ = this->hub_->scan_active(); + this->last_scan_running_ = this->hub_->scan_running(); + + // The hub delivers raw advertisements on the ESPHome main loop: + // mac is least-significant octet first (BLE controller convention). + this->hub_->set_raw_advertisement_callback({this, [](void *self, const ble_device_base::RawAdvertisement &adv) { + static_cast(self)->on_raw_advertisement_(adv); + }}); +} + +void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw) { + if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) + return; + + auto &adv = this->response_.advertisements[this->response_.advertisements_len]; + // raw.mac is LSB-first; this yields the same uint64 the esp32 proxy sends. + adv.address = ble_device_base::mac_lsb_first_to_uint64(raw.mac); + adv.rssi = raw.rssi; + adv.address_type = raw.addr_type; + uint8_t length = raw.data_len > sizeof(adv.data) ? sizeof(adv.data) : static_cast(raw.data_len); + adv.data_len = length; + std::memcpy(adv.data, raw.data, length); + + this->response_.advertisements_len++; + + ESP_LOGV(TAG, "Queuing raw packet from %02X:%02X:%02X:%02X:%02X:%02X, length %d. RSSI: %d dB", raw.mac[5], raw.mac[4], + raw.mac[3], raw.mac[2], raw.mac[1], raw.mac[0], length, raw.rssi); + + // Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE + if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) { + this->flush_pending_advertisements_(); + } +} + +void BluetoothProxy::send_bluetooth_scanner_state_() { + api::BluetoothScannerStateResponse resp; + resp.state = this->hub_->scan_running() ? api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_RUNNING + : api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_IDLE; + resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE + : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; + resp.configured_mode = this->configured_scan_active_ + ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE + : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; + this->api_connection_->send_message(resp); +} + +#endif // USE_ESP32 + +#ifdef USE_ESP32 void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state) { ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, state: %s", connection->get_connection_index(), connection->address_str(), espbt::client_state_to_string(state)); @@ -56,6 +115,7 @@ void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connec void BluetoothProxy::log_connection_info_(BluetoothConnection *connection, const char *message) { ESP_LOGI(TAG, "[%d] [%s] Connecting %s", connection->get_connection_index(), connection->address_str(), message); } +#endif // USE_ESP32 void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *type) { ESP_LOGW(TAG, "Cannot %s GATT %s, not connected", action, type); @@ -67,6 +127,8 @@ void BluetoothProxy::handle_gatt_not_connected_(uint64_t address, uint16_t handl this->send_gatt_error(address, handle, ESP_GATT_NOT_CONNECTED); } +#ifdef USE_ESP32 + #ifdef USE_ESP32_BLE_DEVICE bool BluetoothProxy::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { // This method should never be called since bluetooth_proxy always uses raw advertisements @@ -107,18 +169,38 @@ bool BluetoothProxy::parse_devices(const esp32_ble::BLEScanResult *scan_results, return true; } +#endif // USE_ESP32 + void BluetoothProxy::log_advertisement_flush_() { ESP_LOGV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len); } void BluetoothProxy::dump_config() { +#ifdef USE_ESP32 ESP_LOGCONFIG(TAG, "Bluetooth Proxy:\n" " Active: %s\n" " Connections: %d", YESNO(this->active_), this->connection_count_); +#else + // Advertisement-only: print configured facts. dump_config runs right after + // setup, before the radio is up, so live scan state would always read + // "stopped" here — the loop's BluetoothScannerStateResponse carries the + // changing value instead. + char mac_str[18]; + this->get_bluetooth_mac_address_pretty(mac_str); + ESP_LOGCONFIG(TAG, + "Bluetooth Proxy:\n" + " Mode: advertisement-only (no GATT connections)\n" + " Configured scan: %s\n" + " Adapter MAC: %s", + this->configured_scan_active_ ? "active" : "passive", + mac_str[0] != '\0' ? mac_str : "unavailable (adapter not up yet)"); +#endif } +#ifdef USE_ESP32 + void BluetoothProxy::loop() { // Run advertisement flush / connection cleanup every 100ms uint32_t now = App.get_loop_component_start_time(); @@ -252,13 +334,8 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest esp_bd_addr_t address; uint64_to_bd_addr(msg.address, address); esp_err_t ret = esp_ble_gattc_cache_clean(address); - api::BluetoothDeviceClearCacheResponse call; - call.address = msg.address; - call.success = ret == ESP_OK; - call.error = ret; - - this->api_connection_->send_message(call); - + // Shares the sender with the neutral path, which also null-checks api_connection_. + this->send_device_clear_cache(msg.address, ret == ESP_OK, ret); break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT: { @@ -376,6 +453,120 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn this->api_connection_->send_message(resp); } +void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { + if (this->parent_->get_scan_active() == active) { + return; + } + ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive"); + this->parent_->set_scan_active(active); + this->parent_->stop_scan(); + this->parent_->set_scan_continuous( + true); // Set this to true to automatically start scanning again when it has cleaned up. +} + +#else // !USE_ESP32 + +// Advertisement-only proxy. GATT client connections are excluded at compile +// time — this whole arm is selected by #ifdef USE_ESP32, and nothing consults +// HubCapabilities at runtime today — so every connection-oriented request is +// answered with a clean error instead of silence, and Home Assistant treats +// the proxy as passive. + +void BluetoothProxy::loop() { + // Run advertisement flush / scanner-state poll 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) + return; + + // The hub has no scanner-state listener interface; poll and report on change. + bool running = this->hub_->scan_running(); + if (running != this->last_scan_running_) { + this->last_scan_running_ = running; + this->send_bluetooth_scanner_state_(); + } + + this->flush_pending_advertisements_(); +} + +void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest &msg) { + switch (msg.request_type) { + case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE: + case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE: + case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT: + ESP_LOGW(TAG, "Active connections are not supported on this platform"); + this->send_device_connection(msg.address, false, 0, ESP_GATT_NOT_CONNECTED); + break; + case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT: + // Not an error: the device is already disconnected, which is the requested state. + this->send_device_connection(msg.address, false); + this->send_connections_free(); + break; + case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR: + this->send_device_pairing(msg.address, false, ESP_GATT_NOT_CONNECTED); + break; + case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR: + this->send_device_unpairing(msg.address, false, ESP_GATT_NOT_CONNECTED); + break; + case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE: + this->send_device_clear_cache(msg.address, false, ESP_GATT_NOT_CONNECTED); + break; + } +} + +void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg) { + this->handle_gatt_not_connected_(msg.address, msg.handle, "read", "characteristic"); +} + +void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &msg) { + this->handle_gatt_not_connected_(msg.address, msg.handle, "write", "characteristic"); +} + +void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTReadDescriptorRequest &msg) { + this->handle_gatt_not_connected_(msg.address, msg.handle, "read", "descriptor"); +} + +void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWriteDescriptorRequest &msg) { + this->handle_gatt_not_connected_(msg.address, msg.handle, "write", "descriptor"); +} + +void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg) { + this->handle_gatt_not_connected_(msg.address, 0, "get", "services"); +} + +void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg) { + this->handle_gatt_not_connected_(msg.address, msg.handle, "notify", "characteristic"); +} + +void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) { + if (this->api_connection_ == nullptr) + return; + api::BluetoothSetConnectionParamsResponse resp; + resp.address = msg.address; + resp.error = ESP_GATT_NOT_CONNECTED; + this->api_connection_->send_message(resp); +} + +void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { + if (this->hub_->scan_active() != active) { + ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive"); + if (!this->hub_->request_scan_mode(active)) { + // Passive-only controller asked for active scanning; the state report + // below carries the real, unchanged mode so the subscriber does not + // assume the change happened. + ESP_LOGW(TAG, "Scanner mode %s not supported by this tracker", active ? "active" : "passive"); + } + } + if (this->api_connection_ != nullptr) { + this->send_bluetooth_scanner_state_(); + } +} + +#endif // USE_ESP32 + void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) { if (this->api_connection_ != nullptr && this->api_connection_ != api_connection) { // A previous subscriber still holds the slot. This is almost always a stale @@ -390,9 +581,13 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection this->api_connection_->get_peername_to(old_peername)); } this->api_connection_ = api_connection; +#ifdef USE_ESP32 this->parent_->recalculate_advertisement_parser_types(); - this->send_bluetooth_scanner_state_(this->parent_->get_scanner_state()); +#else + this->last_scan_running_ = this->hub_->scan_running(); + this->send_bluetooth_scanner_state_(); +#endif } void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connection) { @@ -401,10 +596,12 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti return; } this->api_connection_ = nullptr; +#ifdef USE_ESP32 this->parent_->recalculate_advertisement_parser_types(); +#endif } -void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, esp_err_t error) { +void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, proxy_err_t error) { if (this->api_connection_ == nullptr) return; api::BluetoothDeviceConnectionResponse call; @@ -432,7 +629,7 @@ void BluetoothProxy::send_gatt_services_done(uint64_t address) { this->api_connection_->send_message(call); } -void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, esp_err_t error) { +void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, proxy_err_t error) { if (this->api_connection_ == nullptr) return; api::BluetoothGATTErrorResponse call; @@ -442,7 +639,7 @@ void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, esp_err_ this->api_connection_->send_message(call); } -void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, esp_err_t error) { +void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, proxy_err_t error) { if (this->api_connection_ == nullptr) return; api::BluetoothDevicePairingResponse call; @@ -453,7 +650,7 @@ void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, esp_err_ this->api_connection_->send_message(call); } -void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, esp_err_t error) { +void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, proxy_err_t error) { if (this->api_connection_ == nullptr) return; api::BluetoothDeviceUnpairingResponse call; @@ -464,19 +661,21 @@ void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, esp_e this->api_connection_->send_message(call); } -void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { - if (this->parent_->get_scan_active() == active) { +// Shared by both platform paths: the neutral bluetooth_device_request() uses it to +// answer a clear-cache request with a clean error, so it must not be esp32-guarded. +void BluetoothProxy::send_device_clear_cache(uint64_t address, bool success, proxy_err_t error) { + if (this->api_connection_ == nullptr) return; - } - ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive"); - this->parent_->set_scan_active(active); - this->parent_->stop_scan(); - this->parent_->set_scan_continuous( - true); // Set this to true to automatically start scanning again when it has cleaned up. + api::BluetoothDeviceClearCacheResponse call; + call.address = address; + call.success = success; + call.error = error; + + this->api_connection_->send_message(call); } BluetoothProxy *global_bluetooth_proxy = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esphome::bluetooth_proxy -#endif // USE_ESP32 +#endif // USE_BLUETOOTH_PROXY diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 2b6d29da43..54236dbc67 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -1,6 +1,8 @@ #pragma once -#ifdef USE_ESP32 +#include "esphome/core/defines.h" + +#ifdef USE_BLUETOOTH_PROXY #include #include @@ -8,11 +10,12 @@ #include "esphome/components/api/api_connection.h" #include "esphome/components/api/api_pb2.h" -#include "esphome/components/esp32_ble_client/ble_client_base.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" -#include "esphome/core/defines.h" + +#ifdef USE_ESP32 +#include "esphome/components/esp32_ble_client/ble_client_base.h" +#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" #include "bluetooth_connection.h" @@ -20,14 +23,31 @@ #include #endif #include +#else +#include "esphome/components/ble_device_base/ble_hub.h" +#endif // USE_ESP32 namespace esphome::bluetooth_proxy { -static constexpr esp_err_t ESP_GATT_NOT_CONNECTED = -1; +// Proxy-owned error type for the API error fields, which are plain integers on +// the wire. Aliases esp_err_t on esp32 (where the values come from IDF calls); +// a bare int elsewhere. Owning the name instead of probing for esp_err_t keeps +// the header independent of how a hub platform's SDK spells its error type. +#ifdef USE_ESP32 +using proxy_err_t = esp_err_t; +static constexpr proxy_err_t PROXY_OK = ESP_OK; +#else +using proxy_err_t = int; +static constexpr proxy_err_t PROXY_OK = 0; +#endif + +static constexpr proxy_err_t ESP_GATT_NOT_CONNECTED = -1; static constexpr int DONE_SENDING_SERVICES = -2; static constexpr int INIT_SENDING_SERVICES = -3; +#ifdef USE_ESP32 using namespace esp32_ble_client; +#endif // Legacy versions: // Version 1: Initial version without active connections @@ -53,21 +73,28 @@ enum BluetoothProxySubscriptionFlag : uint32_t { SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0, }; +#ifdef USE_ESP32 class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, public esp32_ble_tracker::BLEScannerStateListener, public Component { friend class BluetoothConnection; // Allow connection to update connections_free_response_ +#else +class BluetoothProxy final : public Component { +#endif public: BluetoothProxy(); +#ifdef USE_ESP32 #ifdef USE_ESP32_BLE_DEVICE bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; #endif bool parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) override; + esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; +#endif // USE_ESP32 void dump_config() override; void setup() override; void loop() override; - esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; +#ifdef USE_ESP32 // maybe_unused: in a passive proxy (active: false) MAX is 0, the body below is removed, and connection is unused. void register_connection([[maybe_unused]] BluetoothConnection *connection) { // Guard the always-false comparison (-Wtype-limits) in a passive proxy (active: false), where MAX is 0. @@ -78,6 +105,14 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, } #endif } +#else + void set_ble_hub(ble_device_base::BLEHub *hub) { this->hub_ = hub; } + // Run after the hub's setup() (the trackers use AFTER_WIFI): setup() below + // snapshots scan_active()/scan_running() and installs the raw callback, and + // the BLEHub contract does not promise those are settled any earlier than + // the hub's own setup(). + float get_setup_priority() const override { return setup_priority::AFTER_WIFI - 1.0f; } +#endif // USE_ESP32 void bluetooth_device_request(const api::BluetoothDeviceRequest &msg); void bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg); @@ -92,17 +127,18 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, void unsubscribe_api_connection(api::APIConnection *api_connection); api::APIConnection *get_api_connection() { return this->api_connection_; } - void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, esp_err_t error = ESP_OK); + void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, proxy_err_t error = PROXY_OK); void send_connections_free(); void send_connections_free(api::APIConnection *api_connection); void send_gatt_services_done(uint64_t address); - void send_gatt_error(uint64_t address, uint16_t handle, esp_err_t error); - void send_device_pairing(uint64_t address, bool paired, esp_err_t error = ESP_OK); - void send_device_unpairing(uint64_t address, bool success, esp_err_t error = ESP_OK); - void send_device_clear_cache(uint64_t address, bool success, esp_err_t error = ESP_OK); + void send_gatt_error(uint64_t address, uint16_t handle, proxy_err_t error); + void send_device_pairing(uint64_t address, bool paired, proxy_err_t error = PROXY_OK); + void send_device_unpairing(uint64_t address, bool success, proxy_err_t error = PROXY_OK); + void send_device_clear_cache(uint64_t address, bool success, proxy_err_t error = PROXY_OK); void bluetooth_scanner_set_mode(bool active); +#ifdef USE_ESP32 static void uint64_to_bd_addr(uint64_t address, esp_bd_addr_t bd_addr) { bd_addr[0] = (address >> 40) & 0xff; bd_addr[1] = (address >> 32) & 0xff; @@ -111,12 +147,15 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, bd_addr[4] = (address >> 8) & 0xff; bd_addr[5] = (address >> 0) & 0xff; } +#endif void set_active(bool active) { this->active_ = active; } bool has_active() { return this->active_; } +#ifdef USE_ESP32 /// BLEScannerStateListener interface void on_scanner_state(esp32_ble_tracker::ScannerState state) override; +#endif uint32_t get_legacy_version() const { if (this->active_) { @@ -129,7 +168,17 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, uint32_t flags = 0; flags |= BluetoothProxyFeature::FEATURE_PASSIVE_SCAN; flags |= BluetoothProxyFeature::FEATURE_RAW_ADVERTISEMENTS; +#ifdef USE_ESP32 flags |= BluetoothProxyFeature::FEATURE_STATE_AND_MODE; +#else + // Advertise mode switching only where the hub honors request_scan_mode(); + // scan_mode_switch is the capability bit for exactly that (#18079) — + // active_scan alone is not enough, a hub may support active scanning yet + // refuse the runtime switch. + if (this->hub_->get_capabilities().scan_mode_switch) { + flags |= BluetoothProxyFeature::FEATURE_STATE_AND_MODE; + } +#endif if (this->active_) { flags |= BluetoothProxyFeature::FEATURE_ACTIVE_CONNECTIONS; flags |= BluetoothProxyFeature::FEATURE_REMOTE_CACHING; @@ -142,16 +191,37 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, } void get_bluetooth_mac_address_pretty(std::span output) { +#ifdef USE_ESP32 const uint8_t *mac = esp_bt_dev_get_address(); if (mac != nullptr) { format_mac_addr_upper(mac, output.data()); } else { output[0] = '\0'; } +#else + uint8_t mac[6] = {}; + this->hub_->get_adapter_mac(mac); + // Mirror the esp32 arm's unavailable -> empty-string fallback: some hubs + // (rp2040's BTstack) only learn the address once the link layer is up, and + // report all-zero until then. + bool nonzero = false; + for (uint8_t b : mac) + nonzero |= b != 0; + if (nonzero) { + format_mac_addr_upper(mac, output.data()); + } else { + output[0] = '\0'; + } +#endif } protected: +#ifdef USE_ESP32 void send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state); +#else + void send_bluetooth_scanner_state_(); + void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw); +#endif /// Caller must ensure api_connection_ is non-null and API server is connected. void flush_pending_advertisements_() { @@ -165,9 +235,11 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, } void log_advertisement_flush_(); +#ifdef USE_ESP32 BluetoothConnection *get_connection_(uint64_t address, bool reserve); void log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state); void log_connection_info_(BluetoothConnection *connection, const char *message); +#endif void log_not_connected_gatt_(const char *action, const char *type); void handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action, const char *type); @@ -175,8 +247,12 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, // Group 1: Pointers (4 bytes each, naturally aligned) api::APIConnection *api_connection_{nullptr}; +#ifdef USE_ESP32 // Group 2: Fixed-size array of connection pointers std::array connections_{}; +#else + ble_device_base::BLEHub *hub_{nullptr}; +#endif // BLE advertisement batching api::BluetoothLERawAdvertisementsResponse response_; @@ -191,11 +267,13 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, bool active_; uint8_t connection_count_{0}; bool configured_scan_active_{false}; // Configured scan mode from YAML - // 3 bytes used, 1 byte padding +#ifndef USE_ESP32 + bool last_scan_running_{false}; // Last scanner state reported to the subscriber +#endif }; extern BluetoothProxy *global_bluetooth_proxy; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esphome::bluetooth_proxy -#endif // USE_ESP32 +#endif // USE_BLUETOOTH_PROXY diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 32d30c9d07..5ce87f8da5 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -242,6 +242,27 @@ #define USE_NATIVE_64BIT_TIME #endif +// bluetooth_proxy runs on any platform with a BLE hub (advertisement-only off +// esp32). Declared here per analysis ENVIRONMENT, not per hub platform — +// USE_LIBRETINY also covers chips with no hub, e.g. rtl87xx (the authoritative +// gate is _HUB_PLATFORMS in bluetooth_proxy/__init__.py) — so the neutral +// declarations in bluetooth_proxy.h are parsed under LibreTiny static analysis +// (the header is included by api_connection.cpp, which the tidy filter selects; +// the proxy's own .cpp is not a selected translation unit). Not declared for +// platforms whose API/network types the proxy header cannot assume. +#if defined(USE_ESP32) || defined(USE_LIBRETINY) || defined(USE_RP2) +#define USE_BLUETOOTH_PROXY +// Mirror the codegen values per platform: _to_code_esp32() emits the connection +// count (default 3), _to_code_ble_hub() emits 0 — so static analysis checks the +// same std::array instantiation a real build produces. +#ifdef USE_ESP32 +#define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 +#else +#define BLUETOOTH_PROXY_MAX_CONNECTIONS 0 +#endif +#define BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE 16 +#endif + // ESP32-specific feature flags #ifdef USE_ESP32 #define USE_ESP32_CRASH_HANDLER @@ -264,9 +285,6 @@ #define USE_ESPNOW #define USE_ESPNOW_MAX_PAYLOAD_SIZE 1470 -#define USE_BLUETOOTH_PROXY -#define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 -#define BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE 16 #define USE_CAPTIVE_PORTAL #define USE_WIFI_SCAN_RESULTS_LOCK #define USE_ESP32_BLE diff --git a/tests/component_tests/bluetooth_proxy/__init__.py b/tests/component_tests/bluetooth_proxy/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/bluetooth_proxy/test_idf_max_connections_mirror.py b/tests/component_tests/bluetooth_proxy/test_idf_max_connections_mirror.py new file mode 100644 index 0000000000..3042c91f84 --- /dev/null +++ b/tests/component_tests/bluetooth_proxy/test_idf_max_connections_mirror.py @@ -0,0 +1,19 @@ +"""bluetooth_proxy mirrors esp32_ble.IDF_MAX_CONNECTIONS; pin them together. + +The mirror exists so the statically walkable CONFIG_SCHEMA can express the +connection_slots range without importing the esp32 BLE stack (that import +registers esp32-only automations on every platform). The runtime check in +_esp32_config_schema() only fires while validating an esp32 config, so this +test is what actually catches drift when the upstream constant changes. +""" + +from esphome.components import esp32_ble +from esphome.components.bluetooth_proxy import _IDF_MAX_CONNECTIONS + + +def test_mirror_matches_esp32_ble() -> None: + assert _IDF_MAX_CONNECTIONS == esp32_ble.IDF_MAX_CONNECTIONS, ( + "bluetooth_proxy._IDF_MAX_CONNECTIONS is out of sync with " + "esp32_ble.IDF_MAX_CONNECTIONS; update the mirror in " + "esphome/components/bluetooth_proxy/__init__.py" + ) diff --git a/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py b/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py new file mode 100644 index 0000000000..b7e1736f72 --- /dev/null +++ b/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py @@ -0,0 +1,64 @@ +"""The outer CONFIG_SCHEMA re-declares the esp32 scalar keys so tooling can walk +them without importing the esp32 BLE stack; pin the two declarations together. + +The outer schema carries no defaults (the per-platform schema applies them), so +drift cannot surface in validation output — a key renamed or re-bounded in +_esp32_config_schema() but not here would silently vanish from the dashboard's +field extractor. This test is what catches that. +""" + +import voluptuous as vol + +from esphome import config_validation as cv +from esphome.components.bluetooth_proxy import CONFIG_SCHEMA, _esp32_config_schema + +# esp32-schema keys with no place in the outer schema: COMPONENT_SCHEMA +# plumbing (derived, so a future core key does not fail this component's test), +# generated IDs (not user-walkable options), and connections (must validate +# exactly once — see the comment above CONFIG_SCHEMA). +_NOT_MIRRORED = {str(key.schema) for key in cv.COMPONENT_SCHEMA.schema} | { + "connections" +} + + +def _schema_of(validator: cv.All) -> vol.Schema: + """The vol.Schema stage of a cv.All chain, found by type rather than by + position so reordering the chain cannot silently break these tests.""" + schemas = [v for v in validator.validators if isinstance(v, vol.Schema)] + assert len(schemas) == 1, f"expected exactly one vol.Schema stage, got {schemas}" + return schemas[0] + + +def _keys(schema: vol.Schema) -> dict[str, object]: + return {str(key.schema): key for key in schema.schema} + + +def test_outer_scalar_keys_exist_in_esp32_schema() -> None: + outer = _keys(_schema_of(CONFIG_SCHEMA)) + esp32 = _keys(_schema_of(_esp32_config_schema())) + missing = set(outer) - set(esp32) + assert not missing, ( + f"outer CONFIG_SCHEMA declares {sorted(missing)} which the esp32 schema " + "does not; update one of them in " + "esphome/components/bluetooth_proxy/__init__.py" + ) + + +def test_esp32_scalars_all_walkable() -> None: + """Every non-generated esp32 scalar option must appear in the outer schema + (connections is deliberately excluded — it must validate exactly once).""" + outer = _keys(_schema_of(CONFIG_SCHEMA)) + esp32 = _keys(_schema_of(_esp32_config_schema())) + scalar = { + name + for name, key in esp32.items() + if isinstance(key, vol.Optional) + and not isinstance(key, cv.GenerateID) + and name not in _NOT_MIRRORED + } + missing = scalar - set(outer) + assert not missing, ( + f"esp32 scalar options {sorted(missing)} are missing from the outer " + "CONFIG_SCHEMA and invisible to schema tooling; update " + "esphome/components/bluetooth_proxy/__init__.py" + ) diff --git a/tests/components/bluetooth_proxy/test.ln882x-ard.yaml b/tests/components/bluetooth_proxy/test.ln882x-ard.yaml new file mode 100644 index 0000000000..ae1aed7c22 --- /dev/null +++ b/tests/components/bluetooth_proxy/test.ln882x-ard.yaml @@ -0,0 +1,10 @@ +# Advertisement-only proxy on the ln882x BLE hub (active-scan-capable, in-tree +# since #16691) — a target CI fully compiles. Same bare-hub arrangement as +# test.rp2040-ard.yaml: no explicit ble_hub_id so a grouped build cannot +# collide with ln882h_ble_tracker's own fixture id. +packages: + common: !include common.yaml + +ln882h_ble_tracker: + +bluetooth_proxy: diff --git a/tests/components/bluetooth_proxy/test.rp2040-ard.yaml b/tests/components/bluetooth_proxy/test.rp2040-ard.yaml new file mode 100644 index 0000000000..fd327bcc78 --- /dev/null +++ b/tests/components/bluetooth_proxy/test.rp2040-ard.yaml @@ -0,0 +1,13 @@ +# Advertisement-only proxy on the rp2 BLE hub — the one non-esp32 platform the +# proxy admits today (active-scan-capable), and a target CI fully compiles. +# No explicit ble_hub_id: the generated binding resolves the single declared +# hub, and an inline id here would collide with rp2_ble_tracker's own fixture +# once CI merges both components into one grouped rp2040-ard build (grouped +# component dicts collapse; only one id survives). The explicit-key form is +# covered by validate.rp2040-ard.yaml, which never participates in grouping. +packages: + common: !include common.yaml + +rp2_ble_tracker: + +bluetooth_proxy: diff --git a/tests/components/bluetooth_proxy/validate.esp32-idf.yaml b/tests/components/bluetooth_proxy/validate.esp32-idf.yaml new file mode 100644 index 0000000000..a92716ebeb --- /dev/null +++ b/tests/components/bluetooth_proxy/validate.esp32-idf.yaml @@ -0,0 +1,13 @@ +# Connections given as a bare list, with no explicit per-entry id. The ids are +# generated during validation, so this config breaks if the schema validates the +# connections list more than once. +packages: + common: !include common.yaml + +esp32_ble_tracker: + +bluetooth_proxy: + active: true + connections: + - {} + - {} diff --git a/tests/components/bluetooth_proxy/validate.rp2040-ard.yaml b/tests/components/bluetooth_proxy/validate.rp2040-ard.yaml new file mode 100644 index 0000000000..fa385dd5bc --- /dev/null +++ b/tests/components/bluetooth_proxy/validate.rp2040-ard.yaml @@ -0,0 +1,11 @@ +# Explicit ble_hub_id on the rp2 hub — the documented disambiguator once a +# platform has more than one tracker. Validate-only: never merged into grouped +# builds, so the inline id cannot collide with rp2_ble_tracker's own fixture. +packages: + common: !include common.yaml + +rp2_ble_tracker: + id: ble_hub + +bluetooth_proxy: + ble_hub_id: ble_hub From d4b3d3716584283dd29c7d9adf0fb537757eaac1 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 6 Aug 2026 08:07:30 +1200 Subject: [PATCH 1261/1815] Update URL for ESPHome Builder issue reporting (#16250) --- .github/ISSUE_TEMPLATE/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 3b39d519c4..977fe9428d 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -8,7 +8,7 @@ contact_links: url: https://github.com/esphome/esphome-webserver/issues/new/choose about: Report an issue with the ESPHome web server. - name: Report an issue with the ESPHome Builder / Dashboard - url: https://github.com/esphome/dashboard/issues/new/choose + url: https://github.com/esphome/device-builder/issues/new/choose about: Report an issue with the ESPHome Builder / Dashboard. - name: Report an issue with the ESPHome API client url: https://github.com/esphome/aioesphomeapi/issues/new/choose From a2d1f73bcad964bcc0e7dea53e1f84b1d2fd036b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Aug 2026 15:39:19 -0500 Subject: [PATCH 1262/1815] [core] Defer voluptuous and bundle imports out of the upload and logs fast path (#18093) --- esphome/__main__.py | 10 +-- esphome/bundle.py | 7 +- esphome/const.py | 1 + esphome/yaml_util.py | 7 +- .../fixtures/lazy_imports/_storage.py | 27 ++++++++ .../lazy_imports/storage_json_fast_path.py | 24 +------ .../lazy_imports/upload_command_fast_path.py | 66 +++++++++++++++++++ tests/unit_tests/test_bundle.py | 21 ------ tests/unit_tests/test_lazy_imports.py | 44 ++++++++++++- tests/unit_tests/test_main.py | 4 -- 10 files changed, 150 insertions(+), 61 deletions(-) create mode 100644 tests/unit_tests/fixtures/lazy_imports/_storage.py create mode 100644 tests/unit_tests/fixtures/lazy_imports/upload_command_fast_path.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 178546de68..30aa48ddbe 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -23,6 +23,7 @@ from esphome import const, platform_hooks from esphome.const import ( ALLOWED_NAME_CHARS, ARGUMENT_HELP_DEVICE, + BUNDLE_EXTENSION, CONF_API, CONF_AUTH, CONF_BAUD_RATE, @@ -1701,7 +1702,7 @@ def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None: def command_bundle(args: ArgsProtocol, config: ConfigType) -> int | None: - from esphome.bundle import BUNDLE_EXTENSION, ConfigBundleCreator + from esphome.bundle import ConfigBundleCreator creator = ConfigBundleCreator(config) @@ -2551,10 +2552,11 @@ def run_esphome(argv): return 0 # Bundle support: if the configuration is a .esphomebundle, extract it - # and rewrite conf_path to the extracted YAML config. - from esphome.bundle import is_bundle_path, prepare_bundle_for_compile + # and rewrite conf_path to the extracted YAML config. The suffix check + # stays inline so the ordinary run never imports esphome.bundle. + if conf_path.name.lower().endswith(BUNDLE_EXTENSION): + from esphome.bundle import prepare_bundle_for_compile - if is_bundle_path(conf_path): _LOGGER.info("Extracting config bundle %s...", conf_path) conf_path = prepare_bundle_for_compile(conf_path) # Update the argument so downstream code sees the extracted path diff --git a/esphome/bundle.py b/esphome/bundle.py index 7045e850c7..b633c5ca4f 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -20,6 +20,7 @@ from typing import Any from esphome import const, yaml_util from esphome.const import ( + BUNDLE_EXTENSION, CONF_ESPHOME, CONF_EXTERNAL_COMPONENTS, CONF_INCLUDES, @@ -35,7 +36,6 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "bundle" -BUNDLE_EXTENSION = ".esphomebundle.tar.gz" MANIFEST_FILENAME = "manifest.json" CURRENT_MANIFEST_VERSION = 1 MAX_DECOMPRESSED_SIZE = 500 * 1024 * 1024 # 500 MB @@ -755,11 +755,6 @@ def _validate_tar_members(tar: tarfile.TarFile, target_dir: Path) -> None: ) -def is_bundle_path(path: Path) -> bool: - """Check if a path looks like a bundle file.""" - return path.name.lower().endswith(BUNDLE_EXTENSION) - - def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None: """Add in-memory bytes to a tar archive with deterministic metadata.""" info = tarfile.TarInfo(name=name) diff --git a/esphome/const.py b/esphome/const.py index 636cc39943..a3e9f47909 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -121,6 +121,7 @@ PLATFORM_RP2040 = Platform.RP2040 PLATFORM_RTL87XX = Platform.RTL87XX +BUNDLE_EXTENSION = ".esphomebundle.tar.gz" SOURCE_FILE_EXTENSIONS = {".cpp", ".hpp", ".h", ".c", ".tcc", ".ino"} HEADER_FILE_EXTENSIONS = {".h", ".hpp", ".tcc"} SECRETS_FILES = ("secrets.yaml", "secrets.yml") diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index ca993b2ca5..981e508d5d 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -14,7 +14,6 @@ from pathlib import Path from typing import Any import uuid -from voluptuous import Invalid import yaml from yaml import SafeLoader as PurePythonLoader import yaml.constructor @@ -254,6 +253,8 @@ class IncludeFile: if self._content is not _UNSET: return self._content if self.has_unresolved_expressions(): + from voluptuous import Invalid + raise Invalid( f"Cannot load include with unresolved substitutions: {self.file}" ) @@ -339,6 +340,8 @@ def _load_include_candidates( keepalive: list[Any], ) -> None: """Load every filesystem candidate for an unresolved ``IncludeFile``.""" + from voluptuous import Invalid + log = _LOGGER.warning if warn_on_unresolved else _LOGGER.debug candidates = _candidate_include_paths(include) if not candidates: @@ -409,6 +412,8 @@ def force_load_include_files( run on a fresh re-parse where substitutions haven't been applied yet) to demote it to a debug log. """ + from voluptuous import Invalid + if _seen is None: _seen = set() if _expanded_paths is None: diff --git a/tests/unit_tests/fixtures/lazy_imports/_storage.py b/tests/unit_tests/fixtures/lazy_imports/_storage.py new file mode 100644 index 0000000000..969528304b --- /dev/null +++ b/tests/unit_tests/fixtures/lazy_imports/_storage.py @@ -0,0 +1,27 @@ +"""Shared storage-sidecar factory for the lazy-import fixture scripts.""" + +from esphome.storage_json import StorageJSON + + +def make_storage() -> StorageJSON: + """A minimal post-compile esp32 sidecar the upload/logs fast path accepts.""" + return StorageJSON( + storage_version=1, + name="test", + friendly_name="Test", + comment=None, + esphome_version="2026.1.0", + src_version=1, + address="1.2.3.4", + web_port=None, + target_platform="ESP32S3", + build_path=None, + firmware_bin_path=None, + loaded_integrations=set(), + loaded_platforms=set(), + no_mdns=False, + framework="esp-idf", + core_platform="esp32", + area=None, + framework_version="5.3.1", + ) diff --git a/tests/unit_tests/fixtures/lazy_imports/storage_json_fast_path.py b/tests/unit_tests/fixtures/lazy_imports/storage_json_fast_path.py index f83a398cda..1e34bc90a1 100644 --- a/tests/unit_tests/fixtures/lazy_imports/storage_json_fast_path.py +++ b/tests/unit_tests/fixtures/lazy_imports/storage_json_fast_path.py @@ -7,32 +7,12 @@ in on argv, the ones found in sys.modules afterwards go out on stdout. import sys from _leak_report import print_leaked_modules +from _storage import make_storage from esphome.const import KEY_ESP32, KEY_IDF_VERSION, KEY_VARIANT from esphome.core import CORE, Version -from esphome.storage_json import StorageJSON -storage = StorageJSON( - storage_version=1, - name="test", - friendly_name="Test", - comment=None, - esphome_version="2026.1.0", - src_version=1, - address="1.2.3.4", - web_port=None, - target_platform="ESP32S3", - build_path=None, - firmware_bin_path=None, - loaded_integrations=set(), - loaded_platforms=set(), - no_mdns=False, - framework="esp-idf", - core_platform="esp32", - area=None, - framework_version="5.3.1", -) -storage.apply_to_core() +make_storage().apply_to_core() # Fail loudly if the esp32 fast path stopped doing its work; otherwise an # empty leak list could just mean nothing ran. Explicit exits rather than diff --git a/tests/unit_tests/fixtures/lazy_imports/upload_command_fast_path.py b/tests/unit_tests/fixtures/lazy_imports/upload_command_fast_path.py new file mode 100644 index 0000000000..c03b89c33f --- /dev/null +++ b/tests/unit_tests/fixtures/lazy_imports/upload_command_fast_path.py @@ -0,0 +1,66 @@ +"""Run the upload command dispatch path and report which heavy modules loaded. + +Executed as a subprocess by test_lazy_imports.py: heavy module names come +in on argv, the ones found in sys.modules afterwards go out on stdout. +Covers both fast-path claims: the bundle suffix check in run_esphome reads +BUNDLE_EXTENSION from esphome.const without importing esphome.bundle, and +the real validated-config cache parse, include resolution included, stays +voluptuous free. +""" + +import os +from pathlib import Path +import sys +import tempfile +from unittest.mock import patch + +from _leak_report import print_leaked_modules +from _storage import make_storage +import yaml + +from esphome import __main__ as main_mod + +CONFIG_TEXT = "esphome:\n name: t\n" + +# An ambient data-dir override would relocate the storage tree away +# from the tmp config dir this fixture builds. +os.environ.pop("ESPHOME_DATA_DIR", None) +os.environ.pop("ESPHOME_IS_HA_ADDON", None) + +with tempfile.TemporaryDirectory() as _td: + tmp = Path(_td) + conf_path = tmp / "test.yaml" + conf_path.write_text(CONFIG_TEXT) + + storage_dir = tmp / ".esphome" / "storage" + storage_dir.mkdir(parents=True) + # The cache is a top-level !include so loading it resolves an + # IncludeFile for real on the fast path. The sidecar is written to the + # layout ext_storage_path resolves once run_esphome sets + # CORE.config_path; going through CORE here would be circular. + (storage_dir / "inc.yaml").write_text(CONFIG_TEXT) + cache_path = storage_dir / "test.yaml.validated.yaml" + cache_path.write_text("!include inc.yaml\n") + os.utime(cache_path) # keep the cache at least as fresh as the source + make_storage().save(storage_dir / "test.yaml.json") + + dispatched = {} + + def fake_upload(args, config): + dispatched["config"] = config + return 0 + + with patch.dict(main_mod.POST_CONFIG_ACTIONS, {"upload": fake_upload}): + exit_code = main_mod.run_esphome( + ["esphome", "upload", str(conf_path), "--device", "192.0.2.1"] + ) + + # Fail loudly if the fast path didn't do its work; otherwise an empty + # leak list could just mean nothing ran. Explicit exits rather than + # asserts so PYTHONOPTIMIZE in the ambient environment can't strip them. + if exit_code != 0: + sys.exit(f"run_esphome exited {exit_code} before dispatching upload") + if dispatched.get("config") != yaml.safe_load(CONFIG_TEXT): + sys.exit(f"cache include did not resolve through the fast path: {dispatched!r}") + + print_leaked_modules() diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index 785db4086c..29e917fe44 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -25,7 +25,6 @@ from esphome.bundle import ( add_bundle_file, add_secret_scan_dir, extract_bundle, - is_bundle_path, prepare_bundle_for_compile, read_bundle_manifest, remap_bundle_path, @@ -99,26 +98,6 @@ def _setup_config_dir( return config_dir -# --------------------------------------------------------------------------- -# is_bundle_path -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize( - ("filename", "expected"), - [ - (f"my_device{BUNDLE_EXTENSION}", True), - (f"MY_DEVICE{BUNDLE_EXTENSION.upper()}", True), - ("my_device.yaml", False), - ("my_device.tar.gz", False), - ("my_device.zip", False), - ("", False), - ], -) -def test_is_bundle_path(filename: str, expected: bool) -> None: - assert is_bundle_path(Path(filename)) is expected - - # --------------------------------------------------------------------------- # _default_target_dir # --------------------------------------------------------------------------- diff --git a/tests/unit_tests/test_lazy_imports.py b/tests/unit_tests/test_lazy_imports.py index 74b2362949..764f2862da 100644 --- a/tests/unit_tests/test_lazy_imports.py +++ b/tests/unit_tests/test_lazy_imports.py @@ -41,6 +41,11 @@ FAST_PATH_HEAVY_MODULES = HEAVY_MODULES + ("esphome.components.esp32",) # in the existence guard so a rename can't silently no-op its check. API_HEAVY_MODULES = ("aioesphomeapi",) +# Heavy only for the single-config dispatch path: the bundle suffix +# check reads BUNDLE_EXTENSION from esphome.const so an ordinary run +# never pays for the bundle machinery and its tarfile chain. +BUNDLE_HEAVY_MODULES = ("esphome.bundle", "tarfile") + def _leaked_heavy_modules(module: str, extra: tuple[str, ...] = ()) -> str: """Import ``module`` in a subprocess and report the heavy modules it pulled. @@ -77,13 +82,15 @@ def test_main_module_does_not_import_heavy_modules() -> None: def test_watched_heavy_modules_exist() -> None: """A renamed heavy module would silently disable the leak checks.""" - for module in FAST_PATH_HEAVY_MODULES + API_HEAVY_MODULES: + for module in FAST_PATH_HEAVY_MODULES + API_HEAVY_MODULES + BUNDLE_HEAVY_MODULES: assert importlib.util.find_spec(module) is not None, ( f"{module} no longer resolves; update the heavy-module lists" ) -def _leaked_from_fixture(fixture_path: Path, script_name: str) -> str: +def _leaked_from_fixture( + fixture_path: Path, script_name: str, extra: tuple[str, ...] = () +) -> str: """Run a fixture script with the watched modules on argv. Running a script file drops the cwd from sys.path, so prepend the @@ -95,7 +102,7 @@ def _leaked_from_fixture(fixture_path: Path, script_name: str) -> str: python_path = os.pathsep.join((python_path, ambient)) env = os.environ | {"PYTHONPATH": python_path} result = subprocess.run( - [sys.executable, str(script), *FAST_PATH_HEAVY_MODULES], + [sys.executable, str(script), *FAST_PATH_HEAVY_MODULES, *extra], capture_output=True, text=True, env=env, @@ -212,3 +219,34 @@ def test_has_mqtt_ip_lookup_does_not_import_mqtt() -> None: "The upload/logs fast path skips validation; importing the " "validation stack anyway defeats the validated-config cache." ) + + +def test_yaml_util_does_not_import_heavy_modules() -> None: + """``esphome.yaml_util`` parses the validated-config cache on the + upload/logs fast path; importing it must not pull in voluptuous. + """ + leaked = _leaked_heavy_modules("esphome.yaml_util") + assert not leaked, ( + f"esphome.yaml_util imports heavy modules at top level: {leaked}. " + "The upload/logs fast path skips validation; importing the " + "validation stack anyway defeats the validated-config cache." + ) + + +def test_upload_command_path_does_not_import_heavy_modules( + fixture_path: Path, +) -> None: + """The single-config dispatch path checks the bundle suffix on every + run; reading it from esphome.const must not drag in esphome.bundle + and its tarfile chain. + """ + leaked = _leaked_from_fixture( + fixture_path, "upload_command_fast_path.py", extra=BUNDLE_HEAVY_MODULES + ) + assert not leaked, ( + f"the upload dispatch path pulls in heavy modules: {leaked}. " + "An ordinary run only needs the bundle suffix constant, and the " + "cache parse must not resolve voluptuous; keep the esphome.bundle " + "import inside the branch that extracts one and the Invalid import " + "inside the branch that raises it." + ) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index fc7bb9ada3..9badf856bf 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -6307,7 +6307,6 @@ def test_run_esphome_bundle_detection(tmp_path: Path) -> None: extracted_yaml = tmp_path / "extracted" / "device.yaml" with ( - patch("esphome.bundle.is_bundle_path", return_value=True) as mock_is_bundle, patch( "esphome.bundle.prepare_bundle_for_compile", return_value=extracted_yaml, @@ -6316,7 +6315,6 @@ def test_run_esphome_bundle_detection(tmp_path: Path) -> None: ): result = run_esphome(["esphome", "compile", str(bundle_path)]) - mock_is_bundle.assert_called_once() mock_prepare.assert_called_once_with(bundle_path) # read_config returns None → exit code 2 assert result == 2 @@ -6328,13 +6326,11 @@ def test_run_esphome_non_bundle_skips_extraction(tmp_path: Path) -> None: yaml_file.write_text("esphome:\n name: test\n") with ( - patch("esphome.bundle.is_bundle_path", return_value=False) as mock_is_bundle, patch("esphome.bundle.prepare_bundle_for_compile") as mock_prepare, patch("esphome.config.read_config", return_value=None), ): result = run_esphome(["esphome", "compile", str(yaml_file)]) - mock_is_bundle.assert_called_once() mock_prepare.assert_not_called() assert result == 2 From 4fd2ddee5534c9f3c9069f4e433ee53ecee7c29f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Aug 2026 15:50:48 -0500 Subject: [PATCH 1263/1815] [core] Trim the stacktrace gate commentary (#18097) --- esphome/components/nrf52/__init__.py | 14 +-- esphome/platform_hooks.py | 33 ++----- esphome/stacktrace.py | 69 +++++-------- tests/unit_tests/test_stacktrace.py | 140 ++++++++------------------- 4 files changed, 75 insertions(+), 181 deletions(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 27d7b5fd35..386fed5412 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -722,17 +722,9 @@ def _addr2line(addr2line: str, elf: Path, addr: str) -> str: return "" -# Module-level so tests can pin the samples that gate lazy decoding -# (tests/unit_tests/test_stacktrace.py) against the real pattern. -# The PC group is bounded to 3+ hex digits to agree with the log gate -# in platform_hooks.STACKTRACE_GATES by construction; the logger prints -# both registers with %08x, so a real PC is always 8 digits and even a -# vector-table address is zero-padded past the bound. The LR bound is -# left wide so a nonsensical LR still satisfies the combined match; the -# regex is all or nothing, so a failed LR half would drop the PC decode -# with it. Widening the PC bound without the gate fails the generative -# superset test in tests/unit_tests/test_stacktrace.py, so the two -# cannot drift. +# The PC bound matches the gate in platform_hooks.STACKTRACE_GATES; +# the logger prints both registers with %08x, so a real PC is always +# 8 digits. tests/unit_tests/test_stacktrace.py guards against drift. STACKTRACE_NRF52_PC_LR_RE = re.compile(r"PC=(0x[0-9a-fA-F]{3,})\s+LR=(0x[0-9a-fA-F]+)") diff --git a/esphome/platform_hooks.py b/esphome/platform_hooks.py index b58e3f570c..10515723de 100644 --- a/esphome/platform_hooks.py +++ b/esphome/platform_hooks.py @@ -37,32 +37,13 @@ _LOGGER = logging.getLogger(__name__) # (upload method, log transport) warns. A new hook is loud by default. COSMETIC_HOOKS: Final = frozenset({"process_stacktrace"}) -# Trigger gates for the stacktrace decoders, keyed by platform. A log -# session knows its target platform, so each session only pays for its -# own platform's trigger language; a line matching the gate is what -# lazily imports the platform package to resolve the decoder, and a -# false trigger costs that import on the event loop mid-stream. That is -# why 8-digit decimals (uptime counters) and ESP-IDF's decimal log -# timestamps must stay out of the esp8266 bare-hex branch. -# -# Declaring a gate here is what registers a platform's -# process_stacktrace hook; deriving the registry entry from the keys -# keeps the two in sync by construction. Each gate must stay a superset -# of its decoder patterns' trigger language; both directions are pinned -# in tests/unit_tests/test_stacktrace.py, including a generative test -# that derives inputs from the decoder regexes themselves. The keyword -# branches exist because (?:0x)? register forms can glue a pointer to -# trailing word characters that defeat the pointer branch's \b, and the -# markers are the address-free lines that open the state-gated -# decoders' dump regions. The esp32/esp8266/rp2 crash handlers all -# announce a stored dump with the CRASH DETECTED banner as its first -# line; their decoders key on the 0x-bearing lines that follow, but -# gating on the banner resolves the decoder at the dump's first line -# and can only be a true positive. -# -# Stored as strings: this module is imported by every CLI invocation, -# and only a log session needs a gate, so the session compiles exactly -# its own platform's entry instead of import time compiling all four. +# Per-platform trigger languages for lazy stacktrace decoding: a +# matching line is what imports the platform package, so false triggers +# (8-digit uptime counters, ESP-IDF decimal timestamps) must stay out. +# Declaring a gate registers the process_stacktrace hook, and each gate +# must stay a superset of its decoder patterns' trigger language; both +# are enforced by tests/unit_tests/test_stacktrace.py. Stored as +# strings so a log session compiles only its own platform's gate. STACKTRACE_GATES: Final[dict[str, str]] = { PLATFORM_ESP32: ( r"0x[0-9a-fA-F]{3,}\b" diff --git a/esphome/stacktrace.py b/esphome/stacktrace.py index 93b1a73ea9..0adbbf6f2b 100644 --- a/esphome/stacktrace.py +++ b/esphome/stacktrace.py @@ -28,32 +28,20 @@ class LogLineProcessor: """Feeds incoming log lines to the stack-trace decoder. Three responsibilities beyond just calling the decoder: - 1. Resolve the platform decoder through the registry: lazily for - in-tree platforms with a registered decoder, where nothing is - imported until a line matches the platform's own gate in - platform_hooks.STACKTRACE_GATES, and eagerly otherwise. A - registry-proven miss reports its unavailable notice at session - start without importing anything; an external platform resolves - up front because the gates' grammar derives from the in-tree - decoders and its import cannot be avoided anyway - resolving - early keeps it out of the streaming callback, where a blocking - import would stall delivery mid-stream. - 2. Catch everything the decoder can raise. aioesphomeapi isolates - exceptions raised by log handlers, so an escaping one no longer - kills the session, but it does log a full traceback per line. A - crash dump carries a PC line plus one per backtrace frame, so the - tracebacks bury the dump the user is trying to read. Decoding is a - diagnostic nicety; nothing it raises is worth that noise. + 1. Resolve the platform decoder lazily: registered platforms import + nothing until a line matches their gate, registry misses report + at session start without importing, and external platforms + resolve eagerly since their import is unavoidable and belongs + off the streaming callback. + 2. Catch everything the decoder can raise; decoding is a diagnostic + nicety and an escaping exception would log a traceback per dump + line, burying the dump the user is trying to read. 3. Disable decoding for the rest of the session after a failure. - _decode_pc shells out to the toolchain to resolve addr2line, - which is expensive; a single crash dump can contain many PC/BT - lines and we don't want to retry the failing subprocess for each - one. This only works if every failure is caught, which is why 2 - is not narrowed to EsphomeError. The latch is deliberately one - way: nothing a decode failure depends on heals by itself within - a session, the warning names the fix, and a fresh ``esphome - logs`` run picks it up; retrying mid-session would block the - stream with a failing subprocess instead. + Retrying means re-running a failing toolchain subprocess on the + stream, and nothing a decode failure depends on heals by itself; + the warning names the fix and a fresh run picks it up. Working + at all requires catching every failure, which is why 2 is not + narrowed to EsphomeError. """ def __init__(self, config: ConfigType, platform: str) -> None: @@ -61,10 +49,8 @@ class LogLineProcessor: self._platform = platform self._platform_handler: StacktraceHandler | None = None self._decode_enabled = True - # None only for platforms resolved eagerly below, which never - # consult the gate: a registered platform always declares one. - # Compiled here rather than in the registry so only a log - # session pays for its own platform's gate. + # None only for platforms resolved eagerly below; a registered + # platform always declares a gate. gate = platform_hooks.STACKTRACE_GATES.get(platform) self._gate: re.Pattern[str] | None = None if gate is None else re.compile(gate) self.backtrace_state = False @@ -77,13 +63,10 @@ class LogLineProcessor: if self._platform_handler is None: if not self._gate.search(raw_line): return - # Deliberate trade: the platform import (~300 ms, seconds on - # small hosts) blocks the streaming callback here, once per - # session, instead of every session paying it at startup. + # Deliberate trade: the platform import blocks the stream + # here, once per session, instead of at every startup. if not self._resolve_handler(): return - # The only runtime breadcrumb for the gate: with -v this - # distinguishes "gate never fired" from "no crash occurred". _LOGGER.debug( "Stacktrace gate fired for %s; decoder resolved", self._platform ) @@ -93,10 +76,8 @@ class LogLineProcessor: try: handler = platform_hooks.get_stacktrace_handler(self._platform) except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except - # Total containment includes resolution: a platform package - # broken in an unanticipated way must not kill the session or - # retry on every address-bearing line. Name the cause like - # _feed does; the full traceback only exists at debug. + # Containment includes resolution: a broken platform package + # must not kill the session or retry per line. _LOGGER.debug("Stacktrace analyzer resolution failed", exc_info=True) _LOGGER.warning( 'Stacktrace analysis is unavailable: analyzer for target platform "%s" could not be loaded: %s', @@ -120,10 +101,9 @@ class LogLineProcessor: self.backtrace_state = False _LOGGER.debug("Stack-trace decoding failed", exc_info=True) if isinstance(exc, (EsphomeError, OSError)): - # The environment branch: idedata and build tree failures - # get the remediation hint. The fallback string is - # defensive; the in-tree raise sites all carry a message - # now, but a bare EsphomeError must not render as parens. + # Environment failures (idedata, build tree) get the + # remediation hint; the fallback string keeps a bare + # EsphomeError from rendering as empty parens. _LOGGER.warning( "Crash trace decoding unavailable: %s. " "Run 'esphome compile' for this device to enable PC decoding.", @@ -131,9 +111,8 @@ class LogLineProcessor: ) else: # A decoder bug is ESPHome's problem, not the user's; - # don't send them to recompile a healthy build. Always - # name the type: a bare KeyError message reads like a - # raised string in the paste a bug report needs. + # don't send them to recompile a healthy build. Name the + # type so a bare KeyError message reads as an exception. detail = type(exc).__name__ if msg := str(exc): detail = f"{detail}: {msg}" diff --git a/tests/unit_tests/test_stacktrace.py b/tests/unit_tests/test_stacktrace.py index ff317e55f8..0b11ac3f83 100644 --- a/tests/unit_tests/test_stacktrace.py +++ b/tests/unit_tests/test_stacktrace.py @@ -24,16 +24,11 @@ from esphome.core import EsphomeError CONFIG = {"esphome": {"name": "test"}} -# Real dump lines per registered platform. "addresses" are gate-firing -# dump lines (registers, backtraces, the exception header); the gate -# must fire on each or that platform's decoding silently never starts. -# "state_markers" are the address-free lines that set backtrace_state; -# the gate matches them directly so their decoders never miss the line -# that opens their dump region. "extra_triggers" are gate-firing lines -# no decoder pattern consumes, like the stored-dump banner that lets -# the decoder resolve at a dump's first line. A new decoder must -# declare its lines here so all are pinned instead of discovered in -# the field. +# Real dump lines per registered platform; the gate must fire on each. +# "addresses" are decoder-consumed dump lines, "state_markers" open a +# decoder's dump region, and "extra_triggers" fire the gate without a +# decoder pattern (the stored-dump banner). A new decoder declares its +# lines here so drift fails in CI instead of in the field. CRASH_SAMPLES: dict[str, dict[str, list[str]]] = { PLATFORM_ESP32: { "state_markers": [], @@ -68,10 +63,9 @@ CRASH_SAMPLES: dict[str, dict[str, list[str]]] = { PLATFORM_NRF52: { "state_markers": ["Last crash:"], "addresses": [ - # The zephyr logger prints both registers with %08x, so even - # a vector-table PC pads past the decoder's {3,} bound. + # %08x zero-pads even a vector-table PC past the {3,} bound. "PC=0x00000050 LR=0x00000000", - # Synthetic short form; keeps the bound's lower edge pinned. + # Synthetic short form; pins the bound's lower edge. "PC=0x27a1c LR=0x1e33", ], }, @@ -86,11 +80,9 @@ BENIGN_LINES = [ "[V][esp-idf:000]: I (40219876) wifi: connected", "[D][api:102]: Client connected (40123456)", "[D][sensor:093]: 'Water meter': Sending state 12345678.00000 L", - # A 32-hex hash has no internal word boundary, so the exactly-8 - # bare-hex branch must not fire anywhere inside it. + # No internal word boundary; the bare-8-hex branch must not fire. "[I][ota:117]: MD5 of binary: d41d8cd98f00b204e9800998ecf8427e", - # Short 0x tokens are everywhere (BLE handles, flags); the pointer - # branch's 3-digit minimum exists to keep them out. + # Short 0x tokens (BLE handles); the 3-digit minimum keeps them out. "[D][ble:200]: Connection handle 0x1F, MTU 23", "[C][network:600]: IPv6: fe80::1a2b:3c4d:5e6f:7a8b", "[C][ota:097]: Version: 2026.7.0", @@ -115,11 +107,7 @@ def test_platform_gate(platform: str, line: str, should_fire: bool) -> None: def test_gates_are_platform_scoped() -> None: - """A session only pays for its own platform's trigger language. - - Another platform's marker on an esp32 session must not cost the - one-time import; the platform is known when the session starts. - """ + """Another platform's markers must not fire an esp32 session's gate.""" esp32_gate = re.compile(stacktrace.platform_hooks.STACKTRACE_GATES[PLATFORM_ESP32]) for line in ( ">>>stack>>>", @@ -159,11 +147,8 @@ def _top_level_branches(pattern: str) -> list[str]: @pytest.mark.parametrize("platform", sorted(CRASH_SAMPLES)) def test_every_gate_branch_is_exercised(platform: str) -> None: - """A typoed or dead gate branch cannot hide behind the others. - - The superset checks stay green when a branch matches nothing, so a - broken alternation would silently stop resolving the decoder on the - lines it was added for; every branch must be hit by a sample. + """Every gate branch must be hit by a sample; the superset checks + stay green when a typoed alternation matches nothing. """ samples = CRASH_SAMPLES[platform] lines = [line for kind in samples for line in samples[kind]] @@ -176,9 +161,8 @@ def test_every_gate_branch_is_exercised(platform: str) -> None: ) -# The in-tree sources that print each marker literal the gates key on. -# esp8266's >>>stack>>> comes from the Arduino core's postmortem -# handler, outside this tree; its decoder source is the nearest pin. +# In-tree sources that print each marker literal the gates key on; +# esp8266's >>>stack>>> comes from the Arduino core, outside this tree. FIRMWARE_MARKER_SOURCES = { "CRASH DETECTED ON PREVIOUS BOOT": ( "esphome/components/esp32/crash_handler.cpp", @@ -190,11 +174,8 @@ FIRMWARE_MARKER_SOURCES = { def test_gate_markers_match_firmware_output() -> None: - """The marker literals must stay what the firmware prints. - - A reworded crash banner would keep every regex-level guard green - while the gate silently stops resolving the decoder at a stored - dump's first line; pin the literals to the sources that print them. + """A reworded firmware banner must fail here, not in the field; + every regex-level guard stays green when the C++ side drifts. """ root = Path(__file__).parents[2] for marker, sources in FIRMWARE_MARKER_SOURCES.items(): @@ -207,11 +188,7 @@ def test_gate_markers_match_firmware_output() -> None: def test_crash_samples_cover_registry() -> None: - """A newly registered decoder must come with a non-empty gate sample. - - The gate table and the hook registry cannot drift; the registry - entry is derived from the gate table's keys. - """ + """A newly registered decoder must come with a non-empty gate sample.""" assert set(CRASH_SAMPLES) == set(stacktrace.platform_hooks.STACKTRACE_GATES) assert set(stacktrace.platform_hooks.STACKTRACE_GATES) == set( stacktrace.platform_hooks.PLATFORM_HOOKS["process_stacktrace"] @@ -263,27 +240,17 @@ GATE_EXEMPT_PATTERNS = { def test_platform_declarations_match_decoder(platform: str) -> None: r"""Samples, declared patterns, and the decoder must agree. - Directions checked: every declared pattern exists; every address - sample matches a declared pattern; every declared pattern is - exercised by a sample; no stacktrace pattern exists undeclared; each - declared state marker behaviourally opens the decoder's dump region; - and a decoder that sets state must declare a marker. + Checks: declared patterns exist, samples and patterns cover each + other, no stacktrace pattern is undeclared, markers open the dump + region, and a state-setting decoder declares a marker. - Known blind spots: the esp32/esp8266 catch-all backtrace patterns - can satisfy the sample-matches-a-pattern direction on their own; the - undeclared-pattern sweep keys off naming, so a differently-named - constant or a function-local re.search literal is invisible to it; - the state-gated detection rests on a textual heuristic (every - such decoder today spells it as ``return True`` or - ``backtrace_state = True``, and the declared-markers direction - pins the heuristic against a silent respelling); a decoder that - gains a second opening marker alongside a declared one passes - unnoticed, since the declared marker already satisfies both the - marker-opens-region check and the non-empty ``state_markers`` - requirement; and the generative guard draws full matches only, so - a decoder match glued to trailing word characters that defeat the - pointer branch's ``\b`` is invisible to it (today's crash - handlers always delimit addresses). + Known blind spots: the catch-all backtrace patterns can satisfy the + sample direction alone; the undeclared sweep keys off naming; the + state-gating check is a textual heuristic (pinned against + respelling by the declared-markers direction); a second opening + marker beside a declared one passes unnoticed; and the generative + guard draws full matches, so trailing word characters defeating the + pointer branch's ``\b`` are invisible to it. """ module = importlib.import_module(f"esphome.components.{platform}") patterns: dict[str, re.Pattern] = {} @@ -362,12 +329,8 @@ def test_platform_declarations_match_decoder(platform: str) -> None: def test_address_gate_covers_decoder_pattern_languages( platform: str, name: str, data ) -> None: - """Each platform's gate must be a superset of its decoder patterns. - - The sample table only pins finite literals; a decoder regex that - widens would keep every sample green while the gate misses the new - form. Generating inputs from the decoder regex itself closes that - direction. + """Each platform's gate must be a superset of its decoder patterns; + generated inputs catch a widened decoder the finite samples miss. """ pattern = getattr(importlib.import_module(f"esphome.components.{platform}"), name) example = data.draw(from_regex(pattern, fullmatch=True)) @@ -402,12 +365,8 @@ def _warnings(caplog: pytest.LogCaptureFixture) -> list[str]: def test_decoder_contains_failures_and_short_circuits() -> None: - """One decode failure is contained and never retried. - - aioesphomeapi isolates exceptions raised by log handlers, so an - escaping one logs a full traceback for every line it fires on; and - _decode_pc shells out to the toolchain, so retrying it per backtrace - line would stall streaming. + """One decode failure is contained and never retried; a retry per + backtrace line would stall streaming on a failing subprocess. """ handler = Mock(side_effect=EsphomeError("no idedata")) processor = _run( @@ -421,11 +380,8 @@ def test_decoder_contains_failures_and_short_circuits() -> None: def test_decoder_swallows_os_error_with_remediation_hint( caplog: pytest.LogCaptureFixture, ) -> None: - """Decoding failures that aren't EsphomeError must be contained too. - - A missing build directory surfaces as an OSError; that is the - user's environment, not a decoder bug, so it disables decoding - like an EsphomeError does and keeps the recompile hint. + """An OSError (missing build tree) is the user's environment, not a + decoder bug; it must keep the recompile hint. """ handler = Mock( side_effect=FileNotFoundError(2, "No such file or directory", "/build") @@ -442,11 +398,7 @@ def test_decoder_swallows_os_error_with_remediation_hint( def test_decoder_warning_uses_fallback_for_empty_error( caplog: pytest.LogCaptureFixture, ) -> None: - """A message-less EsphomeError must show a useful explanation. - - Defensive: the in-tree idedata raise sites all carry a message now, - but a bare EsphomeError from elsewhere must not render as parens. - """ + """A bare EsphomeError must not render as empty parens.""" _run(Mock(side_effect=EsphomeError())) warnings = _warnings(caplog) @@ -457,11 +409,8 @@ def test_decoder_warning_uses_fallback_for_empty_error( def test_decoder_bug_with_empty_message_names_the_type( caplog: pytest.LogCaptureFixture, ) -> None: - """A zero-message decoder bug must not masquerade as missing artifacts. - - The recompile hint is only right for EsphomeError from _run_idedata; - anything else is ESPHome's own bug and says so instead of sending - the user down a dead-end remediation path. + """A decoder bug says so instead of sending the user down the + dead-end recompile path. """ _run(Mock(side_effect=IndexError())) @@ -483,10 +432,8 @@ def test_decoder_bug_warning_keeps_the_type_with_a_message( def test_marker_then_address_threads_state() -> None: - """A state marker resolves the decoder and threads state onward. - - esp8266's ``>>>stack>>>`` fires the gate itself, so the decoder sees - it live and the following stack words decode inside the region. + """A state marker resolves the decoder live and threads state to + the following stack words. """ handler = Mock(side_effect=[True, True]) processor = _run( @@ -505,9 +452,7 @@ def test_marker_then_address_threads_state() -> None: def test_lines_before_the_gate_never_reach_the_decoder() -> None: - """Benign lines are dropped, not buffered: the gate is a superset of - the decoder languages, so a line that fails it cannot decode. - """ + """Benign lines are dropped, not buffered.""" handler = Mock(return_value=False) quiet = tuple(f"quiet line {n}" for n in range(12)) _run(handler, lines=quiet + ("PC: 0x4010496e",)) @@ -599,11 +544,8 @@ def test_processor_registry_miss_disables_at_construction( def test_external_platform_resolves_at_construction( caplog: pytest.LogCaptureFixture, ) -> None: - """External platforms resolve eagerly, like before the registry. - - The address gate's grammar derives from the in-tree decoders, so it - cannot speak for an external decoder; resolving up front keeps the - import off the streaming callback and the notice at session start. + """External platforms resolve eagerly; the gates cannot speak for an + external decoder and the import belongs off the streaming callback. """ caplog.set_level("INFO", logger="esphome.platform_hooks") module = type("ExternalPlatform", (), {}) # no process_stacktrace From b700193a0ae2b6ffefd63acb31fdc24bb5291c1c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Aug 2026 15:57:08 -0500 Subject: [PATCH 1264/1815] [core] Generalize the resolver's async thread dispatch into run_async (#18088) --- esphome/async_thread.py | 129 +++++++++-- esphome/resolver.py | 36 ++- tests/unit_tests/test_async_thread.py | 316 ++++++++++++++++++++++++++ tests/unit_tests/test_resolver.py | 18 +- 4 files changed, 449 insertions(+), 50 deletions(-) create mode 100644 tests/unit_tests/test_async_thread.py diff --git a/esphome/async_thread.py b/esphome/async_thread.py index 3972d735f5..3296d65af6 100644 --- a/esphome/async_thread.py +++ b/esphome/async_thread.py @@ -11,43 +11,136 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable +from itertools import count +import logging import threading +from typing import cast + +_LOGGER = logging.getLogger(__name__) + +# How long the orphan watcher waits for an abandoned coroutine before giving +# up, so a hung operation does not park a watcher thread forever. +ORPHAN_WAIT_TIMEOUT = 300.0 + + +_runner_ids = count(1) + + +class AsyncDispatchTimeout(TimeoutError): + """The caller stopped waiting; the coroutine was abandoned. + + A subclass so callers can tell the dispatcher's own expiry apart from a + ``TimeoutError`` raised inside the coroutine, while existing + ``except TimeoutError`` handlers keep working. + """ class AsyncThreadRunner[T](threading.Thread): """Run an async coroutine in a daemon thread and expose its result. - The runner catches all exceptions from the coroutine and stores them in - ``exception`` so ``event`` is always set — this prevents callers waiting - on ``event`` from hanging forever when the coroutine crashes. - - Typical usage:: - - runner = AsyncThreadRunner(lambda: my_coro(arg)) - runner.start() - if not runner.event.wait(timeout=5.0): - ... # timed out - if runner.exception is not None: - raise runner.exception - result = runner.result + ``event`` is always set, even when the coroutine crashes, so waiters + never hang; ``completed`` distinguishes a delivered result (even a + legitimate ``None``) from a coroutine that never finished. Prefer + :func:`run_async`; use this class directly only when a failure should + degrade to a default value instead of raising. """ def __init__(self, coro_factory: Callable[[], Awaitable[T]]) -> None: - super().__init__(daemon=True) + super().__init__(daemon=True, name=f"async-thread-runner-{next(_runner_ids)}") self._coro_factory = coro_factory self.result: T | None = None self.exception: BaseException | None = None + self.completed = False self.event = threading.Event() async def _runner(self) -> None: try: self.result = await self._coro_factory() - except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except - # Capture all exceptions so ``event`` is always set — otherwise a - # crash would hang the waiter forever. + # Distinguishes a delivered result from "never ran", since None + # is a valid result value. + self.completed = True + except BaseException as exc: # noqa: BLE001 # pylint: disable=broad-except + # Capture everything, including BaseException — otherwise a + # cancellation or SystemExit would leave ``exception`` unset and + # waiters would mistake the empty ``result`` for success. self.exception = exc finally: self.event.set() def run(self) -> None: - asyncio.run(self._runner()) + try: + asyncio.run(self._runner()) + except BaseException as exc: # noqa: BLE001 # pylint: disable=broad-except + # asyncio.run itself can fail before _runner executes (e.g. loop + # creation under fd exhaustion); record it so waiters never hang. + # A failure during loop cleanup after the coroutine completed + # must not clobber the delivered result, hence the guard. + if self.exception is None and not self.completed: + self.exception = exc + else: + _LOGGER.debug( + "Event loop teardown failed after outcome recorded", + exc_info=True, + ) + finally: + self.event.set() + + +def run_async[T]( + coro_factory: Callable[[], Awaitable[T]], + timeout: float | None = None, + on_orphan: Callable[[T], None] | None = None, +) -> T: + """Run a coroutine in a daemon-thread event loop and return its result. + + Raises :class:`AsyncDispatchTimeout` if the coroutine does not finish + within ``timeout`` seconds; the thread is abandoned and exits with the + interpreter. If the abandoned coroutine later produces a result, + ``on_orphan`` (if given) is called with it so resources such as a + connected socket can be released; delivery is best effort and bounded + by ``ORPHAN_WAIT_TIMEOUT``. + """ + runner: AsyncThreadRunner[T] = AsyncThreadRunner(coro_factory) + runner.start() + if not runner.event.wait(timeout): + + def _cleanup() -> None: + if not runner.event.wait(ORPHAN_WAIT_TIMEOUT): + # The one state where a resource can genuinely leak; leave + # a trace so a recurring hang is attributable. + _LOGGER.info( + "Orphan watcher gave up after %.0fs; a late result may leak", + ORPHAN_WAIT_TIMEOUT, + ) + return + if not runner.completed: + # The only place an abandoned thread's real error surfaces; + # without it a late failure hides behind the TimeoutError. + # INFO, not DEBUG: it fires at most once per abandoned + # operation and the cause may not reproduce on a rerun. + _LOGGER.info( + "Abandoned async operation failed", + exc_info=runner.exception, + ) + return + if (result := runner.result) is None: + return + if on_orphan is None: + _LOGGER.debug("Discarding late result; no on_orphan handler") + return + try: + on_orphan(result) + except Exception: # pylint: disable=broad-except + # INFO, not DEBUG: a failed release means a real leak, and + # it fires at most once per abandoned operation. + _LOGGER.info("Error releasing orphaned result", exc_info=True) + + threading.Thread( + target=_cleanup, daemon=True, name="async-orphan-cleanup" + ).start() + raise AsyncDispatchTimeout("Timed out waiting for async operation") + if (exc := runner.exception) is not None: + raise exc + if not runner.completed: + raise RuntimeError("Async operation finished without a result or an exception") + return cast("T", runner.result) diff --git a/esphome/resolver.py b/esphome/resolver.py index f80a910afe..68bf37eecd 100644 --- a/esphome/resolver.py +++ b/esphome/resolver.py @@ -8,7 +8,7 @@ import os from aioesphomeapi.core import ResolveAPIError, ResolveTimeoutAPIError import aioesphomeapi.host_resolver as hr -from esphome.async_thread import AsyncThreadRunner +from esphome.async_thread import AsyncDispatchTimeout, run_async from esphome.core import EsphomeError _LOGGER = logging.getLogger(__name__) @@ -31,9 +31,9 @@ class AsyncResolver: This resolver uses aioesphomeapi's async_resolve_host to handle DNS resolution, including proper .local domain fallback. Running in a thread - (via :class:`AsyncThreadRunner`) allows us to get the result immediately - without waiting for ``asyncio.run()`` to complete its cleanup cycle, which - can take significant time. + (via :func:`run_async`) allows us to get the result immediately without + waiting for ``asyncio.run()`` to complete its cleanup cycle, which can + take significant time. """ def __init__(self, hosts: list[str], port: int) -> None: @@ -48,21 +48,13 @@ class AsyncResolver: ) def resolve(self) -> list[hr.AddrInfo]: - """Start the thread and wait for the result.""" - runner: AsyncThreadRunner[list[hr.AddrInfo]] = AsyncThreadRunner(self._resolve) - runner.start() - - if not runner.event.wait( - timeout=RESOLVE_TIMEOUT + 1.0 - ): # Give it 1 second more than the resolver timeout - raise EsphomeError("Timeout resolving IP address") - - if exc := runner.exception: - if isinstance(exc, ResolveTimeoutAPIError): - raise EsphomeError(f"Timeout resolving IP address: {exc}") from exc - if isinstance(exc, ResolveAPIError): - raise EsphomeError(f"Error resolving IP address: {exc}") from exc - raise exc - - assert runner.result is not None # guaranteed when event set and no exception - return runner.result + """Resolve and wait for the result.""" + try: + # Give it 1 second more than the resolver timeout + return run_async(self._resolve, timeout=RESOLVE_TIMEOUT + 1.0) + except ResolveTimeoutAPIError as exc: + raise EsphomeError(f"Timeout resolving IP address: {exc}") from exc + except ResolveAPIError as exc: + raise EsphomeError(f"Error resolving IP address: {exc}") from exc + except AsyncDispatchTimeout as exc: + raise EsphomeError("Timeout resolving IP address") from exc diff --git a/tests/unit_tests/test_async_thread.py b/tests/unit_tests/test_async_thread.py new file mode 100644 index 0000000000..a64be2f7c7 --- /dev/null +++ b/tests/unit_tests/test_async_thread.py @@ -0,0 +1,316 @@ +"""Tests for the async thread helpers.""" + +from __future__ import annotations + +import asyncio +import threading +from typing import Any +from unittest.mock import patch + +import pytest + +from esphome.async_thread import AsyncDispatchTimeout, AsyncThreadRunner, run_async + + +def _cleanup_threads() -> set[threading.Thread]: + """Return the currently live orphan-cleanup threads.""" + return {t for t in threading.enumerate() if t.name == "async-orphan-cleanup"} + + +def _join_new_cleanup_threads(before: set[threading.Thread]) -> None: + """Wait for cleanup threads spawned since ``before`` to finish.""" + for thread in _cleanup_threads() - before: + thread.join(5) + assert not thread.is_alive() + + +def test_run_async_returns_result() -> None: + """The coroutine's result is returned to the sync caller.""" + + async def coro() -> int: + await asyncio.sleep(0) + return 42 + + assert run_async(coro) == 42 + + +def test_run_async_propagates_exception() -> None: + """Exceptions raised by the coroutine surface in the caller.""" + + async def coro() -> None: + raise ValueError("boom") + + with pytest.raises(ValueError, match="boom"): + run_async(coro) + + +def test_run_async_propagates_base_exception() -> None: + """A BaseException from the coroutine surfaces instead of a None result.""" + + class Boom(BaseException): + pass + + async def coro() -> None: + raise Boom + + with pytest.raises(Boom): + run_async(coro) + + +def test_run_async_timeout() -> None: + """A coroutine that does not finish in time raises TimeoutError.""" + release = threading.Event() + + async def coro() -> None: + await asyncio.get_running_loop().run_in_executor(None, release.wait) + + before = _cleanup_threads() + with pytest.raises(TimeoutError): + run_async(coro, timeout=0.05) + + # Unblock the abandoned runner so its cleanup thread exits promptly. + release.set() + _join_new_cleanup_threads(before) + + +def test_run_async_surfaces_loop_startup_failure() -> None: + """A failure before the coroutine runs raises instead of hanging.""" + + def failing_run(main: Any) -> None: + # Close the never-awaited coroutine so the test does not leave a + # RuntimeWarning attributed to whatever module GC runs in later. + main.close() + raise OSError("no fds for the event loop") + + with ( + patch("esphome.async_thread.asyncio.run", side_effect=failing_run), + pytest.raises(OSError, match="no fds"), + ): + run_async(lambda: asyncio.sleep(0), timeout=5) + + +def test_run_preserves_result_when_cleanup_fails( + caplog: pytest.LogCaptureFixture, +) -> None: + """A loop-cleanup failure after success is logged, not raised.""" + + async def coro() -> str: + return "ok" + + runner: AsyncThreadRunner[str] = AsyncThreadRunner(coro) + + def fake_run(main: Any) -> None: + main.close() + # Emulate _runner delivering the result before cleanup raised. A + # None result must count as delivered too, hence the completed flag. + runner.result = "ok" + runner.completed = True + raise KeyboardInterrupt + + with ( + caplog.at_level("DEBUG", logger="esphome.async_thread"), + patch("esphome.async_thread.asyncio.run", side_effect=fake_run), + ): + runner.run() + + assert runner.event.is_set() + assert runner.exception is None + assert runner.result == "ok" + assert "teardown failed after outcome recorded" in caplog.text + + +def test_run_async_none_result_is_success() -> None: + """A coroutine legitimately returning None is not treated as a failure.""" + + async def coro() -> None: + return None + + assert run_async(coro) is None + + +def test_run_async_on_orphan_skips_none_result() -> None: + """A late None result completes cleanly without invoking on_orphan.""" + orphaned: list[Any] = [] + finished = threading.Event() + release = threading.Event() + + async def coro() -> None: + await asyncio.get_running_loop().run_in_executor(None, release.wait) + finished.set() + + before = _cleanup_threads() + with pytest.raises(TimeoutError): + run_async(coro, timeout=0.01, on_orphan=orphaned.append) + + release.set() + assert finished.wait(5) + _join_new_cleanup_threads(before) + assert not orphaned + + +def test_late_failure_without_on_orphan_is_logged( + caplog: pytest.LogCaptureFixture, +) -> None: + """An abandoned thread's real error leaves a visible trace.""" + release = threading.Event() + + async def coro() -> str: + await asyncio.get_running_loop().run_in_executor(None, release.wait) + raise ValueError("the real cause") + + before = _cleanup_threads() + with caplog.at_level("DEBUG", logger="esphome.async_thread"): + with pytest.raises(TimeoutError): + run_async(coro, timeout=0.01) + + release.set() + _join_new_cleanup_threads(before) + assert "Abandoned async operation failed" in caplog.text + assert "the real cause" in caplog.text + + +def test_run_async_on_orphan_failure_is_contained( + caplog: pytest.LogCaptureFixture, +) -> None: + """An on_orphan callback that raises is logged, not propagated.""" + released = threading.Event() + release = threading.Event() + + def on_orphan(result: str) -> None: + released.set() + raise OSError("close failed") + + async def coro() -> str: + await asyncio.get_running_loop().run_in_executor(None, release.wait) + return "late result" + + before = _cleanup_threads() + with caplog.at_level("DEBUG", logger="esphome.async_thread"): + with pytest.raises(TimeoutError): + run_async(coro, timeout=0.01, on_orphan=on_orphan) + + release.set() + assert released.wait(5) + _join_new_cleanup_threads(before) + assert "Error releasing orphaned result" in caplog.text + + +def test_run_async_on_orphan_releases_late_result() -> None: + """A result produced after the timeout is handed to on_orphan.""" + orphaned: list[Any] = [] + delivered = threading.Event() + release = threading.Event() + + def on_orphan(result: str) -> None: + orphaned.append(result) + delivered.set() + + async def coro() -> str: + # Block until the test has observed the timeout, so the result is + # guaranteed to arrive late no matter how slowly the runner is + # scheduled. + await asyncio.get_running_loop().run_in_executor(None, release.wait) + return "late result" + + before = _cleanup_threads() + with pytest.raises(TimeoutError): + run_async(coro, timeout=0.01, on_orphan=on_orphan) + + release.set() + assert delivered.wait(5) + _join_new_cleanup_threads(before) + assert orphaned == ["late result"] + + +def test_run_async_on_orphan_skips_late_failure() -> None: + """A late failure after the timeout is not handed to on_orphan.""" + orphaned: list[Any] = [] + failed = threading.Event() + release = threading.Event() + + async def coro() -> str: + # Block until the test has observed the timeout, so the failure is + # guaranteed to arrive late. + await asyncio.get_running_loop().run_in_executor(None, release.wait) + failed.set() + raise ValueError("late failure") + + before = _cleanup_threads() + with pytest.raises(TimeoutError): + run_async(coro, timeout=0.01, on_orphan=orphaned.append) + + release.set() + assert failed.wait(5) + _join_new_cleanup_threads(before) + assert not orphaned + + +def test_run_async_detects_missing_outcome() -> None: + """A run that records neither result nor exception raises loudly.""" + + def fake_run(main: Any) -> None: + # Simulate a loop that silently dropped the coroutine. + main.close() + + with ( + patch("esphome.async_thread.asyncio.run", side_effect=fake_run), + pytest.raises(RuntimeError, match="without a result"), + ): + run_async(lambda: asyncio.sleep(0), timeout=5) + + +def test_run_async_raises_distinguishable_timeout() -> None: + """The dispatcher's own expiry is a distinct TimeoutError subclass.""" + release = threading.Event() + + async def coro() -> None: + await asyncio.get_running_loop().run_in_executor(None, release.wait) + + before = _cleanup_threads() + with pytest.raises(AsyncDispatchTimeout): + run_async(coro, timeout=0.01) + release.set() + _join_new_cleanup_threads(before) + + +def test_orphan_watcher_gives_up_on_a_hung_coroutine( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The watcher exits after its bound instead of parking forever.""" + from esphome import async_thread + + monkeypatch.setattr(async_thread, "ORPHAN_WAIT_TIMEOUT", 0.01) + release = threading.Event() + orphaned: list[Any] = [] + + async def coro() -> str: + await asyncio.get_running_loop().run_in_executor(None, release.wait) + return "too late" + + before = _cleanup_threads() + with pytest.raises(TimeoutError): + run_async(coro, timeout=0.01, on_orphan=orphaned.append) + + _join_new_cleanup_threads(before) + assert not orphaned + release.set() + + +def test_late_real_result_without_handler_is_logged( + caplog: pytest.LogCaptureFixture, +) -> None: + """A genuinely dropped late result leaves the discard trace.""" + release = threading.Event() + + async def coro() -> str: + await asyncio.get_running_loop().run_in_executor(None, release.wait) + return "dropped" + + before = _cleanup_threads() + with caplog.at_level("DEBUG", logger="esphome.async_thread"): + with pytest.raises(TimeoutError): + run_async(coro, timeout=0.01) + + release.set() + _join_new_cleanup_threads(before) + assert "Discarding late result" in caplog.text diff --git a/tests/unit_tests/test_resolver.py b/tests/unit_tests/test_resolver.py index 7862c268ca..16294a3813 100644 --- a/tests/unit_tests/test_resolver.py +++ b/tests/unit_tests/test_resolver.py @@ -4,12 +4,13 @@ from __future__ import annotations import re import socket -from unittest.mock import MagicMock, patch +from unittest.mock import ANY, patch from aioesphomeapi.core import ResolveAPIError, ResolveTimeoutAPIError from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr import pytest +from esphome.async_thread import AsyncDispatchTimeout from esphome.core import EsphomeError from esphome.resolver import RESOLVE_TIMEOUT, AsyncResolver @@ -116,20 +117,17 @@ def test_async_resolver_generic_exception() -> None: def test_async_resolver_thread_timeout() -> None: """Test timeout when the runner thread doesn't complete in time.""" - # Patch AsyncThreadRunner inside esphome.resolver so we never actually - # start a thread and can control the wait return value directly. - fake_runner = MagicMock() - fake_runner.start = MagicMock() - fake_runner.event.wait.return_value = False # simulate timeout - + # Patch run_async inside esphome.resolver so we never actually start a + # thread and can simulate the wait timing out. with ( - patch("esphome.resolver.AsyncThreadRunner", return_value=fake_runner), - patch("esphome.resolver.hr.async_resolve_host"), + patch( + "esphome.resolver.run_async", side_effect=AsyncDispatchTimeout + ) as mock_run, pytest.raises(EsphomeError, match=re.escape("Timeout resolving IP address")), ): AsyncResolver(["test.local"], 6053).resolve() - fake_runner.start.assert_called_once() + mock_run.assert_called_once_with(ANY, timeout=RESOLVE_TIMEOUT + 1.0) def test_async_resolver_ip_addresses(mock_addr_info_ipv4: AddrInfo) -> None: From 28aedc2b1412b34f76bc271cc4d3a262fdac2498 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Aug 2026 16:08:06 -0500 Subject: [PATCH 1265/1815] [core] Drop the duplicated FileLock import from a merge collision (#18103) --- esphome/git.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/git.py b/esphome/git.py index ef3b41d8b9..d1dca3b3ae 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -23,9 +23,6 @@ from esphome.helpers import ( write_file, ) -if TYPE_CHECKING: - from filelock import FileLock - if TYPE_CHECKING: from filelock import FileLock From 1bd94bb805e2febec858bcc0c715f651ab02e64b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Thu, 6 Aug 2026 00:41:07 +0300 Subject: [PATCH 1266/1815] [bluetooth_proxy] Scanner-state sync in set_mode; platform-gate tests (#18100) --- .../bluetooth_proxy/bluetooth_proxy.cpp | 4 ++ .../test_outer_schema_mirror.py | 5 +- .../bluetooth_proxy/test_platform_gates.py | 48 +++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/bluetooth_proxy/test_platform_gates.py diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index ad2fc094ae..e681030611 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -561,6 +561,10 @@ void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { } } if (this->api_connection_ != nullptr) { + // Keep loop()'s change detector in step with the state sent here, so a + // failed restart (scan_running_ dropped by the tracker) is not reported + // twice — once now and again on the next tick. + this->last_scan_running_ = this->hub_->scan_running(); this->send_bluetooth_scanner_state_(); } } diff --git a/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py b/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py index b7e1736f72..17a05a67b9 100644 --- a/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py +++ b/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py @@ -2,9 +2,10 @@ them without importing the esp32 BLE stack; pin the two declarations together. The outer schema carries no defaults (the per-platform schema applies them), so -drift cannot surface in validation output — a key renamed or re-bounded in +drift cannot surface in validation output — a key renamed or removed in _esp32_config_schema() but not here would silently vanish from the dashboard's -field extractor. This test is what catches that. +field extractor. This test is what catches that; validator bounds are pinned +separately only for connection_slots (test_idf_max_connections_mirror). """ import voluptuous as vol diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py new file mode 100644 index 0000000000..4d7997fbce --- /dev/null +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -0,0 +1,48 @@ +"""The three platform-gate branches: BLE-less platforms are rejected with the +real reason, hub platforms reject GATT-only options by name, and the +advertisement-only arm applies its own defaults.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components import bluetooth_proxy +from esphome.const import CONF_ACTIVE, KEY_TARGET_PLATFORM +from esphome.core import CORE, KEY_CORE + + +def _set_platform(platform: str) -> None: + CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = platform + + +def test_ble_less_platform_gets_the_real_reason() -> None: + _set_platform("esp8266") + with pytest.raises(cv.Invalid, match="not supported on esp8266"): + bluetooth_proxy.CONFIG_SCHEMA({}) + + +def test_ble_less_platform_connection_keys_fall_through() -> None: + # The key-level rejection must not fire here — it would imply an + # advertisement-only proxy exists on this platform. + _set_platform("esp8266") + with pytest.raises(cv.Invalid, match="not supported on esp8266"): + bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 2}) + + +def test_hub_platform_rejects_active() -> None: + _set_platform("ln882x") + with pytest.raises(cv.Invalid, match="Active connections are not supported"): + bluetooth_proxy.CONFIG_SCHEMA({"active": True}) + + +def test_hub_platform_rejects_connection_keys_by_name() -> None: + _set_platform("ln882x") + with pytest.raises(cv.Invalid, match="'connection_slots' requires active"): + bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 2}) + with pytest.raises(cv.Invalid, match="'cache_services' requires active"): + bluetooth_proxy.CONFIG_SCHEMA({"cache_services": True}) + + +def test_hub_platform_accepts_the_advertisement_only_shape() -> None: + _set_platform("ln882x") + validated = bluetooth_proxy.CONFIG_SCHEMA({}) + assert validated[CONF_ACTIVE] is False From 26256d3d2ef6700390b985ce2c6359564e592241 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Aug 2026 17:21:24 -0500 Subject: [PATCH 1267/1815] [ble_device_base] [ble_client] Restore unset UUID state so notify sensors set up again (#18098) --- .../ble_client/sensor/ble_sensor.cpp | 2 +- .../text_sensor/ble_text_sensor.cpp | 2 +- .../components/ble_device_base/ble_device.cpp | 20 ++++++- .../components/ble_device_base/ble_device.h | 7 ++- .../ble_device_base/test_ble_uuid.cpp | 54 +++++++++++++++++++ 5 files changed, 80 insertions(+), 5 deletions(-) diff --git a/esphome/components/ble_client/sensor/ble_sensor.cpp b/esphome/components/ble_client/sensor/ble_sensor.cpp index 60992f282e..5dbb7e42ed 100644 --- a/esphome/components/ble_client/sensor/ble_sensor.cpp +++ b/esphome/components/ble_client/sensor/ble_sensor.cpp @@ -61,7 +61,7 @@ void BLESensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga break; } this->handle = chr->handle; - if (this->descr_uuid_.get_uuid().len > 0) { + if (this->descr_uuid_.is_set()) { auto *descr = chr->get_descriptor(this->descr_uuid_); if (descr == nullptr) { this->status_set_warning(); diff --git a/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp b/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp index 6f09281922..ed2b0a63a0 100644 --- a/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp +++ b/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp @@ -61,7 +61,7 @@ void BLETextSensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ break; } this->handle = chr->handle; - if (this->descr_uuid_.get_uuid().len > 0) { + if (this->descr_uuid_.is_set()) { auto *descr = chr->get_descriptor(this->descr_uuid_); if (descr == nullptr) { this->status_set_warning(); diff --git a/esphome/components/ble_device_base/ble_device.cpp b/esphome/components/ble_device_base/ble_device.cpp index 2235c08598..9256270b7a 100644 --- a/esphome/components/ble_device_base/ble_device.cpp +++ b/esphome/components/ble_device_base/ble_device.cpp @@ -98,6 +98,8 @@ ESPBTUUID ESPBTUUID::from_raw(const char *data, size_t length) { #ifdef USE_ESP32 ESPBTUUID ESPBTUUID::from_uuid(esp_bt_uuid_t uuid) { + if (uuid.len == 0) // the unset sentinel get_uuid() emits + return {}; if (uuid.len == ESP_UUID_LEN_16) return ESPBTUUID::from_uint16(uuid.uuid.uuid16); if (uuid.len == ESP_UUID_LEN_32) @@ -108,6 +110,10 @@ ESPBTUUID ESPBTUUID::from_uuid(esp_bt_uuid_t uuid) { esp_bt_uuid_t ESPBTUUID::get_uuid() const { esp_bt_uuid_t ret; switch (this->type_) { + case Type::UNSET: + ret.len = 0; + memset(&ret.uuid, 0, sizeof(ret.uuid)); + break; case Type::UUID16: ret.len = ESP_UUID_LEN_16; ret.uuid.uuid16 = this->uuid_.uuid16; @@ -139,7 +145,8 @@ void ESPBTDevice::parse_scan_rst(const esp32_ble::BLEScanResult &scan_result) { #endif // USE_ESP32 ESPBTUUID ESPBTUUID::as_128bit() const { - if (this->type_ == Type::UUID128) + // Widening an unset UUID stays unset; expanding it would produce a set 0x0000 base UUID. + if (this->type_ == Type::UNSET || this->type_ == Type::UUID128) return *this; uint8_t data[16]; this->to_128bit_(data); @@ -149,6 +156,8 @@ ESPBTUUID ESPBTUUID::as_128bit() const { bool ESPBTUUID::contains(uint8_t data1, uint8_t data2) const { // Adjacent byte-pair search — identical semantics to esp32_ble::ESPBTUUID::contains. switch (this->type_) { + case Type::UNSET: + return false; case Type::UUID16: return (this->uuid_.uuid16 >> 8) == data2 && (this->uuid_.uuid16 & 0xFF) == data1; case Type::UUID32: @@ -173,6 +182,9 @@ const char *ESPBTUUID::to_str(char *buf) const { // Identical output format to esp32_ble::ESPBTUUID::to_str. char *pos = buf; switch (this->type_) { + case Type::UNSET: + memcpy(buf, "None", 5); + return buf; case Type::UUID16: *pos++ = '0'; *pos++ = 'x'; @@ -207,6 +219,7 @@ const char *ESPBTUUID::to_str(char *buf) const { void ESPBTUUID::to_128bit_(uint8_t out[16]) const { // Bluetooth Base UUID 00000000-0000-1000-8000-00805F9B34FB (LSB-first), with the 16/32-bit // value placed at bytes 12..; identical expansion to esp32_ble::ESPBTUUID::as_128bit(). + // Callers screen out UNSET first (operator==, as_128bit); it would expand like 0x0000. static const uint8_t BASE[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; if (this->type_ == Type::UUID128) { @@ -223,6 +236,8 @@ void ESPBTUUID::to_128bit_(uint8_t out[16]) const { bool ESPBTUUID::operator==(const ESPBTUUID &other) const { if (this->type_ == other.type_) { switch (this->type_) { + case Type::UNSET: + return true; case Type::UUID16: return this->uuid_.uuid16 == other.uuid_.uuid16; case Type::UUID32: @@ -232,6 +247,9 @@ bool ESPBTUUID::operator==(const ESPBTUUID &other) const { } return false; } + // Unset never equals a set UUID; 0x0000 is a valid value, distinct from "not configured". + if (this->type_ == Type::UNSET || other.type_ == Type::UNSET) + return false; // Different widths: expand both to the 128-bit Bluetooth Base UUID form and compare, so a // configured 16/32-bit UUID matches the equivalent 128-bit advertisement (esp32 parity). uint8_t a[16]; diff --git a/esphome/components/ble_device_base/ble_device.h b/esphome/components/ble_device_base/ble_device.h index 716bc026f2..ee3256d11f 100644 --- a/esphome/components/ble_device_base/ble_device.h +++ b/esphome/components/ble_device_base/ble_device.h @@ -91,8 +91,11 @@ class ESPBTUUID { #if defined(__cpp_lib_span) const char *to_str(std::span output) const { return this->to_str(output.data()); } #endif - enum class Type : uint8_t { UUID16, UUID32, UUID128 }; + // UNSET is the default-constructed state; get_uuid() reports it as len 0 (the historical sentinel). + enum class Type : uint8_t { UNSET, UUID16, UUID32, UUID128 }; Type type() const { return this->type_; } + /// True if a UUID has been configured (not default-constructed). + bool is_set() const { return this->type_ != Type::UNSET; } uint16_t uuid16() const { return this->uuid_.uuid16; } uint32_t uuid32() const { return this->uuid_.uuid32; } const uint8_t *uuid128() const { return this->uuid_.uuid128; } @@ -101,7 +104,7 @@ class ESPBTUUID { // Expand to the 128-bit Bluetooth Base UUID byte form (out is 16 bytes, little-endian). void to_128bit_(uint8_t out[16]) const; - Type type_{Type::UUID16}; + Type type_{Type::UNSET}; union { uint16_t uuid16; uint32_t uuid32; diff --git a/tests/components/ble_device_base/test_ble_uuid.cpp b/tests/components/ble_device_base/test_ble_uuid.cpp index 45abd48479..7e99ce8a95 100644 --- a/tests/components/ble_device_base/test_ble_uuid.cpp +++ b/tests/components/ble_device_base/test_ble_uuid.cpp @@ -27,6 +27,60 @@ TEST(BleDeviceUuid, ThirtyTwoBitMatchesEquivalentLongForm) { EXPECT_TRUE(u32 == u128); } +// A default-constructed UUID is UNSET, the historical "not configured" sentinel +// (len 0 through the esp32 get_uuid() adapter). +TEST(BleDeviceUuid, DefaultConstructedIsUnset) { + const ESPBTUUID unset; + EXPECT_EQ(unset.type(), ESPBTUUID::Type::UNSET); + EXPECT_TRUE(unset == ESPBTUUID()); + EXPECT_FALSE(unset == ESPBTUUID::from_uint16(0x1234)); + EXPECT_FALSE(unset.contains(0x00, 0x00)); +} + +// Every factory yields a non-UNSET UUID, even for 0x0000: only default construction and a +// failed text parse are unset, keeping type() != UNSET equivalent to the old len > 0 check. +TEST(BleDeviceUuid, AllFactoriesProduceSetUuids) { + const uint8_t raw[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80, + 0x00, 0x10, 0x00, 0x00, 0x34, 0x12, 0x00, 0x00}; + EXPECT_NE(ESPBTUUID::from_uint16(0x0000).type(), ESPBTUUID::Type::UNSET); + EXPECT_NE(ESPBTUUID::from_uint32(0).type(), ESPBTUUID::Type::UNSET); + EXPECT_NE(ESPBTUUID::from_raw(raw).type(), ESPBTUUID::Type::UNSET); + EXPECT_NE(ESPBTUUID::from_raw_reversed(raw).type(), ESPBTUUID::Type::UNSET); + EXPECT_NE(ESPBTUUID::from_raw("180F", 4).type(), ESPBTUUID::Type::UNSET); + EXPECT_NE(ESPBTUUID::from_raw("0000180F", 8).type(), ESPBTUUID::Type::UNSET); + EXPECT_NE(ESPBTUUID::from_raw(reinterpret_cast(raw), 16).type(), ESPBTUUID::Type::UNSET); + EXPECT_NE(ESPBTUUID::from_raw("6E400001-B5A3-F393-E0A9-E50E24DCCA9E").type(), ESPBTUUID::Type::UNSET); +} + +// 0x0000 is a valid short UUID on real devices (esphome/aioesphomeapi#1742); an unset +// UUID must never compare equal to it. Unset equals only unset. +TEST(BleDeviceUuid, UnsetIsNotEqualToZeroUuid) { + EXPECT_FALSE(ESPBTUUID() == ESPBTUUID::from_uint16(0x0000)); + EXPECT_FALSE(ESPBTUUID::from_uint16(0x0000) == ESPBTUUID()); + const uint8_t base[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80, + 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + EXPECT_FALSE(ESPBTUUID() == ESPBTUUID::from_raw(base)); + EXPECT_TRUE(ESPBTUUID() == ESPBTUUID()); + EXPECT_FALSE(ESPBTUUID().as_128bit().is_set()); // widening preserves the unset state + // A configured 0x0000 still matches its own 128-bit base UUID expansion. + EXPECT_TRUE(ESPBTUUID::from_uint16(0x0000) == ESPBTUUID::from_raw(base)); +} + +// is_set() is the sentinel check; an unset UUID prints as "None" instead of a +// valid-looking all-zero 128-bit UUID. +TEST(BleDeviceUuid, IsSetAndUnsetToStr) { + char buf[UUID_STR_LEN]; + EXPECT_FALSE(ESPBTUUID().is_set()); + EXPECT_STREQ(ESPBTUUID().to_str(buf), "None"); + EXPECT_TRUE(ESPBTUUID::from_uint16(0x0000).is_set()); + EXPECT_STREQ(ESPBTUUID::from_uint16(0x0000).to_str(buf), "0x0000"); +} + +// Text parsing of an invalid length historically produced a len-0 (unset) UUID. +TEST(BleDeviceUuid, InvalidTextFormParsesToUnset) { + EXPECT_EQ(ESPBTUUID::from_raw("nope", 3).type(), ESPBTUUID::Type::UNSET); +} + TEST(BleDeviceUuid, DifferentUuidsDoNotMatch) { EXPECT_FALSE(ESPBTUUID::from_uint16(0x1234) == ESPBTUUID::from_uint16(0x1235)); const uint8_t raw128[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80, From 7f80276cf41cde58975564cce786f02dd6e0201d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Aug 2026 18:02:13 -0500 Subject: [PATCH 1268/1815] [ble_device_base] Document the None output for unset UUIDs in to_str() (#18108) --- esphome/components/ble_device_base/ble_device.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/ble_device_base/ble_device.h b/esphome/components/ble_device_base/ble_device.h index ee3256d11f..e340bf673c 100644 --- a/esphome/components/ble_device_base/ble_device.h +++ b/esphome/components/ble_device_base/ble_device.h @@ -85,8 +85,8 @@ class ESPBTUUID { bool operator==(const ESPBTUUID &other) const; bool operator!=(const ESPBTUUID &other) const { return !(*this == other); } - /// Write "0xABCD" / "0xABCDEF01" / the dashed 128-bit form into buf - /// (>= UUID_STR_LEN bytes) and return buf. + /// Write "0xABCD" / "0xABCDEF01" / the dashed 128-bit form, or "None" for an + /// unset UUID, into buf (>= UUID_STR_LEN bytes) and return buf. const char *to_str(char *buf) const; #if defined(__cpp_lib_span) const char *to_str(std::span output) const { return this->to_str(output.data()); } From 524b278811c19bdb2223a5a9e68871c6b0c09f4c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Aug 2026 19:09:00 -0500 Subject: [PATCH 1269/1815] [core] Defer stdlib imports out of the upload and logs fast path (#18105) --- esphome/__main__.py | 12 ++++-- esphome/api_client.py | 3 +- esphome/helpers.py | 15 +++++-- esphome/storage_json.py | 9 +++- esphome/util.py | 6 ++- .../lazy_imports/upload_command_fast_path.py | 18 +++++++- tests/unit_tests/test_lazy_imports.py | 42 ++++++++++++++++--- tests/unit_tests/test_main.py | 25 +++++++++++ tests/unit_tests/test_util.py | 16 +++---- 9 files changed, 121 insertions(+), 25 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 30aa48ddbe..f435b18bb3 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2,16 +2,12 @@ import argparse from collections.abc import Callable from contextlib import suppress -from datetime import datetime import functools -import getpass import importlib import logging import os from pathlib import Path import re -import shutil -import subprocess import sys import time from typing import Protocol @@ -621,6 +617,8 @@ def _resolve_network_devices( def run_miniterm(config: ConfigType, port: str, args) -> int: + from datetime import datetime + from aioesphomeapi import LogParser import serial @@ -977,6 +975,8 @@ def upload_using_esptool( def upload_using_platformio(config: ConfigType, port: str) -> int: + import shutil + from esphome.platformio import toolchain # RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for @@ -1014,6 +1014,8 @@ def upload_using_picotool(config: ConfigType) -> int: the mass storage copy approach that causes "disk not ejected properly" warnings on macOS. """ + import subprocess + from esphome.platformio import toolchain idedata = toolchain.get_idedata(config) @@ -1120,6 +1122,8 @@ def check_permissions(port: str): "the USB cable can be used for data and is not a power-only cable." ) if not (os.access(port, os.R_OK | os.W_OK)): + import getpass + raise EsphomeError( "You do not have read or write permission on the selected serial port. " "To resolve this issue, you can add your user to the dialout group " diff --git a/esphome/api_client.py b/esphome/api_client.py index b9a71a3ff7..a75f219b17 100644 --- a/esphome/api_client.py +++ b/esphome/api_client.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio from contextlib import suppress -from datetime import datetime import logging from typing import TYPE_CHECKING, Any import warnings @@ -35,6 +34,8 @@ async def async_run_logs( subscribe_states: bool = True, ) -> None: """Run the logs command in the event loop.""" + from datetime import datetime + conf = config["api"] name = config["esphome"]["name"] port: int = int(conf[CONF_PORT]) diff --git a/esphome/helpers.py b/esphome/helpers.py index 5458f5edfc..15d9797ce1 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -8,12 +8,9 @@ import os from pathlib import Path import platform import re -import shutil import stat import sys -import tempfile from typing import TYPE_CHECKING, TextIO -from urllib.parse import urlparse from esphome.const import __version__ as ESPHOME_VERSION @@ -281,6 +278,9 @@ def resolve_ip_address( hosts = host else: if not is_ip_address(host): + # Deferred: upload/logs with an IP target never parse a URL. + from urllib.parse import urlparse + url = urlparse(host) if url.scheme != "": host = url.hostname @@ -432,6 +432,8 @@ def rmtree(path: Path | str) -> None: read-only flag and retrying. """ + import shutil + def _onexc(func, path, exc): if os.access(path, os.W_OK): raise exc @@ -469,6 +471,11 @@ def _write_file( Automatically creates all parent directories. """ + # Deferred: a cache-hit upload/logs run never writes a file; keep the + # tempfile/shutil chain (bz2, lzma, random) off that path. + import shutil + import tempfile + data = text if isinstance(text, str): data = text.encode() @@ -544,6 +551,8 @@ def copy_file_if_changed(src: Path, dst: Path) -> bool: Returns True if file was copied, False if files already matched. """ + import shutil + if file_compare(src, dst): return False dst.parent.mkdir(parents=True, exist_ok=True) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 2ba26ec711..a90a36b848 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -1,11 +1,11 @@ from __future__ import annotations import binascii -from datetime import datetime import json import logging import os from pathlib import Path +from typing import TYPE_CHECKING from esphome import const from esphome.const import ( @@ -24,6 +24,9 @@ from esphome.core import CORE, EsphomeError, Version from esphome.helpers import write_file_if_changed from esphome.types import CoreType +if TYPE_CHECKING: + from datetime import datetime + _LOGGER = logging.getLogger(__name__) @@ -372,6 +375,10 @@ class EsphomeStorageJSON: @property def last_update_check(self) -> datetime | None: + # Deferred: this module is on the upload/logs fast path; only the + # dashboard's update check touches these accessors. + from datetime import datetime + try: # Stored format is naive ISO without %z; preserved for backward compat. return datetime.strptime( # noqa: DTZ007 diff --git a/esphome/util.py b/esphome/util.py index 4a5986a90d..136d6362f2 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -5,7 +5,6 @@ import io import logging from pathlib import Path import re -import subprocess import sys from typing import TYPE_CHECKING, Any @@ -289,6 +288,9 @@ def run_external_command( def run_external_process(*cmd: str, **kwargs: Any) -> int | str: + # Deferred: an OTA upload/logs run never spawns an external process. + import subprocess + full_cmd = " ".join(shlex_quote(x) for x in cmd) _LOGGER.debug("Running: %s", full_cmd) filter_lines = kwargs.get("filter_lines") @@ -443,6 +445,8 @@ def detect_rp2040_bootsel(picotool_path: str | Path) -> BootselResult: Returns a BootselResult with the number of devices found (by counting 'type:' lines in output), and whether a permission error was detected. """ + import subprocess + try: result = subprocess.run( [str(picotool_path), "info", "-d"], diff --git a/tests/unit_tests/fixtures/lazy_imports/upload_command_fast_path.py b/tests/unit_tests/fixtures/lazy_imports/upload_command_fast_path.py index c03b89c33f..f0df08aa4e 100644 --- a/tests/unit_tests/fixtures/lazy_imports/upload_command_fast_path.py +++ b/tests/unit_tests/fixtures/lazy_imports/upload_command_fast_path.py @@ -18,7 +18,12 @@ from _leak_report import print_leaked_modules from _storage import make_storage import yaml -from esphome import __main__ as main_mod +# Everything imported past this point is the code under test; the pop +# below must only drop what the setup itself preloaded, or it would +# hide modules the dispatch chain pulls in (tarfile has no other guard). +_FIXTURE_PRELOADED = frozenset(sys.modules) + +from esphome import __main__ as main_mod # noqa: E402 CONFIG_TEXT = "esphome:\n name: t\n" @@ -50,6 +55,17 @@ with tempfile.TemporaryDirectory() as _td: dispatched["config"] = config return 0 + # This setup pre-imports some watched stdlib modules (tempfile above, + # write_file inside make_storage().save(), unittest.mock -> asyncio -> + # subprocess). Drop exactly those so only a genuine dispatch-time + # re-import is reported; live objects keep their references, so + # cleanup still works. Module-level re-imports are out of reach here + # (esphome.__main__ is already loaded) — the bare-import check in + # test_lazy_imports owns that contract. + for module in sys.argv[1:]: + if module in _FIXTURE_PRELOADED: + sys.modules.pop(module, None) + with patch.dict(main_mod.POST_CONFIG_ACTIONS, {"upload": fake_upload}): exit_code = main_mod.run_esphome( ["esphome", "upload", str(conf_path), "--device", "192.0.2.1"] diff --git a/tests/unit_tests/test_lazy_imports.py b/tests/unit_tests/test_lazy_imports.py index 764f2862da..8358f4b781 100644 --- a/tests/unit_tests/test_lazy_imports.py +++ b/tests/unit_tests/test_lazy_imports.py @@ -46,6 +46,22 @@ API_HEAVY_MODULES = ("aioesphomeapi",) # never pays for the bundle machinery and its tarfile chain. BUNDLE_HEAVY_MODULES = ("esphome.bundle", "tarfile") +# Stdlib modules deferred out of the dispatch fast path: a cache-hit +# upload/logs run never writes a file (tempfile), spawns a process +# (subprocess), parses a URL (urllib.parse), or prints a serial +# permission hint (getpass). shutil is deferred too but unwatchable: +# argparse imports it from every add_argument on py3.14. urllib.parse +# is only watchable on 3.13+ where pathlib stopped importing it. +STDLIB_FAST_PATH_MODULES = ( + "tempfile", + "subprocess", + "getpass", + # Pins the module-level contract only: PyYAML's constructor loads + # datetime during the cache parse until the JSON cache lands. + "datetime", + *(("urllib.parse",) if sys.version_info >= (3, 13) else ()), +) + def _leaked_heavy_modules(module: str, extra: tuple[str, ...] = ()) -> str: """Import ``module`` in a subprocess and report the heavy modules it pulled. @@ -70,8 +86,14 @@ def _leaked_heavy_modules(module: str, extra: tuple[str, ...] = ()) -> str: def test_main_module_does_not_import_heavy_modules() -> None: - """A bare ``import esphome.__main__`` must not drag in validation/codegen.""" - leaked = _leaked_heavy_modules("esphome.__main__") + """A bare ``import esphome.__main__`` must not drag in validation/codegen. + + The stdlib watch list rides along here because this check runs in a + clean subprocess: a module-level re-import anywhere on the chain is + caught, which the dispatch fixture (whose setup pre-imports them and + pops before dispatch) structurally cannot do. + """ + leaked = _leaked_heavy_modules("esphome.__main__", extra=STDLIB_FAST_PATH_MODULES) assert not leaked, ( f"esphome.__main__ imports heavy modules at top level: {leaked}. " "Import them lazily inside the command that needs them instead; " @@ -82,7 +104,12 @@ def test_main_module_does_not_import_heavy_modules() -> None: def test_watched_heavy_modules_exist() -> None: """A renamed heavy module would silently disable the leak checks.""" - for module in FAST_PATH_HEAVY_MODULES + API_HEAVY_MODULES + BUNDLE_HEAVY_MODULES: + for module in ( + FAST_PATH_HEAVY_MODULES + + API_HEAVY_MODULES + + BUNDLE_HEAVY_MODULES + + STDLIB_FAST_PATH_MODULES + ): assert importlib.util.find_spec(module) is not None, ( f"{module} no longer resolves; update the heavy-module lists" ) @@ -241,12 +268,15 @@ def test_upload_command_path_does_not_import_heavy_modules( and its tarfile chain. """ leaked = _leaked_from_fixture( - fixture_path, "upload_command_fast_path.py", extra=BUNDLE_HEAVY_MODULES + fixture_path, + "upload_command_fast_path.py", + extra=BUNDLE_HEAVY_MODULES + STDLIB_FAST_PATH_MODULES, ) assert not leaked, ( f"the upload dispatch path pulls in heavy modules: {leaked}. " "An ordinary run only needs the bundle suffix constant, and the " "cache parse must not resolve voluptuous; keep the esphome.bundle " - "import inside the branch that extracts one and the Invalid import " - "inside the branch that raises it." + "import inside the branch that extracts one, the Invalid import " + "inside the branch that raises it, and the deferred stdlib " + "imports inside the write/spawn/serial helpers that use them." ) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 9badf856bf..556bac9ee5 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -27,6 +27,7 @@ from esphome.__main__ import ( _unresolved_default_error, _validate_bootloader_binary, _validate_partition_table_binary, + check_permissions, choose_upload_log_host, command_analyze_memory, command_bundle, @@ -6715,3 +6716,27 @@ def test_command_idedata_esp_idf_no_build_errors() -> None: result = command_idedata(MagicMock(), CORE.config) assert result == 1 + + +@pytest.mark.skipif( + os.name != "posix", reason="serial permission checks are posix-only" +) +def test_check_permissions_missing_port() -> None: + """A nonexistent serial port raises the does-not-exist guidance.""" + with ( + patch("os.access", return_value=False), + pytest.raises(EsphomeError, match="serial port does not exist"), + ): + check_permissions("/dev/ttyUSB99") + + +@pytest.mark.skipif( + os.name != "posix", reason="serial permission checks are posix-only" +) +def test_check_permissions_unreadable_port() -> None: + """An existing but unreadable serial port raises the dialout guidance.""" + with ( + patch("os.access", side_effect=lambda _path, mode: mode == os.F_OK), + pytest.raises(EsphomeError, match="read or write permission"), + ): + check_permissions("/dev/ttyUSB99") diff --git a/tests/unit_tests/test_util.py b/tests/unit_tests/test_util.py index 581b1aca99..02309fbff8 100644 --- a/tests/unit_tests/test_util.py +++ b/tests/unit_tests/test_util.py @@ -561,7 +561,7 @@ def test_run_external_process_line_callbacks() -> None: return "PROCESS CALLBACK\n" return None - with patch("esphome.util.subprocess.run") as mock_run: + with patch("subprocess.run") as mock_run: def run_side_effect(*args: Any, **kwargs: Any) -> MagicMock: # Simulate subprocess writing to the stdout RedirectText @@ -635,7 +635,7 @@ def test_detect_rp2040_bootsel_found() -> None: """Test BOOTSEL device detection when device is present.""" mock_result = MagicMock() mock_result.stdout = b"Device Information\n type: RP2040\n" - with patch("esphome.util.subprocess.run", return_value=mock_result): + with patch("subprocess.run", return_value=mock_result): result = util.detect_rp2040_bootsel("/usr/bin/picotool") assert result.device_count == 1 assert result.permission_error is False @@ -645,7 +645,7 @@ def test_detect_rp2040_bootsel_multiple() -> None: """Test BOOTSEL detection with multiple devices.""" mock_result = MagicMock() mock_result.stdout = b"type: RP2040\ntype: RP2350\n" - with patch("esphome.util.subprocess.run", return_value=mock_result): + with patch("subprocess.run", return_value=mock_result): result = util.detect_rp2040_bootsel("/usr/bin/picotool") assert result.device_count == 2 assert result.permission_error is False @@ -658,7 +658,7 @@ def test_detect_rp2040_bootsel_none() -> None: b"No accessible RP2040/RP2350 devices in BOOTSEL mode were found.\n" ) mock_result.stderr = b"" - with patch("esphome.util.subprocess.run", return_value=mock_result): + with patch("subprocess.run", return_value=mock_result): result = util.detect_rp2040_bootsel("/usr/bin/picotool") assert result.device_count == 0 assert result.permission_error is False @@ -675,7 +675,7 @@ def test_detect_rp2040_bootsel_permission_error() -> None: b"but picotool was unable to connect. " b"Maybe try 'sudo' or check your permissions.\n" ) - with patch("esphome.util.subprocess.run", return_value=mock_result): + with patch("subprocess.run", return_value=mock_result): result = util.detect_rp2040_bootsel("/usr/bin/picotool") assert result.device_count == 0 assert result.permission_error is True @@ -686,7 +686,7 @@ def test_detect_rp2040_bootsel_libusb_access_error() -> None: mock_result = MagicMock() mock_result.stdout = b"" mock_result.stderr = b"LIBUSB_ERROR_ACCESS\n" - with patch("esphome.util.subprocess.run", return_value=mock_result): + with patch("subprocess.run", return_value=mock_result): result = util.detect_rp2040_bootsel("/usr/bin/picotool") assert result.device_count == 0 assert result.permission_error is True @@ -694,7 +694,7 @@ def test_detect_rp2040_bootsel_libusb_access_error() -> None: def test_detect_rp2040_bootsel_oserror() -> None: """Test BOOTSEL detection handles OSError.""" - with patch("esphome.util.subprocess.run", side_effect=OSError("not found")): + with patch("subprocess.run", side_effect=OSError("not found")): result = util.detect_rp2040_bootsel("/usr/bin/picotool") assert result.device_count == 0 assert result.permission_error is False @@ -703,7 +703,7 @@ def test_detect_rp2040_bootsel_oserror() -> None: def test_detect_rp2040_bootsel_timeout() -> None: """Test BOOTSEL detection handles timeout.""" with patch( - "esphome.util.subprocess.run", + "subprocess.run", side_effect=subprocess.TimeoutExpired("picotool", 10), ): result = util.detect_rp2040_bootsel("/usr/bin/picotool") From f971e404c7730f19bbe0576ca9f29ce8b1b3c7ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Aug 2026 19:09:13 -0500 Subject: [PATCH 1270/1815] [core] Resolve GCC multilib include dirs for clang-tidy (#18111) --- script/clang-tidy | 35 +++++++++++++++++++++++++++++++++++ script/clang_tidy_hash.py | 1 + 2 files changed, 36 insertions(+) diff --git a/script/clang-tidy b/script/clang-tidy index 4f1bc6021c..ad6c99d637 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -5,11 +5,13 @@ import os from pathlib import Path import queue import re +import shlex import shutil import subprocess import sys import tempfile import threading +from typing import Any import click import colorama @@ -29,6 +31,36 @@ from helpers import ( ) +def gcc_multilib_directory(idedata: dict[str, Any]) -> str | None: + """The toolchain's active multilib subdirectory (e.g. "thumb"), if any. + + PlatformIO's idedata lists the generic toolchain include directories; GCC + resolves the active multilib subdirectory internally while searching them. + Toolchains without a default multilib (pico-quick-toolchain 5.0.0+) ship + the libstdc++ target config (bits/c++config.h) only inside the multilib + subdirectories, so clang needs the resolved directory spelled out. + """ + machine_flags = [f for f in idedata["cxx_flags"] if f.startswith("-m")] + cmd = [idedata["cxx_path"], *machine_flags, "-print-multi-directory"] + try: + multilib = subprocess.run( + cmd, + capture_output=True, + text=True, + check=True, + ).stdout.strip() + except (OSError, subprocess.CalledProcessError) as err: + # Without the multilib dir, toolchains lacking a default multilib fail + # later with "bits/c++config.h not found"; point at the probe instead. + stderr = getattr(err, "stderr", "") or "" + print( + f"WARNING: multilib probe failed ({shlex.join(cmd)}): {err} {stderr}".strip(), + file=sys.stderr, + ) + return None + return None if multilib in ("", ".") else multilib + + def clang_options(idedata, environment): cmd = [] @@ -203,9 +235,12 @@ def clang_options(idedata, environment): # toolchain include directories, using -isystem to suppress their errors # idedata contains include directories for all toolchains of this platform, only use those from the one in use toolchain_dir = os.path.normpath(f"{idedata['cxx_path']}/../../") + multilib = gcc_multilib_directory(idedata) toolchain_includes = [] for directory in idedata["includes"]["toolchain"]: if directory.startswith(toolchain_dir) and "picolibc" not in directory: + if multilib and (multilib_dir := Path(directory) / multilib).is_dir(): + toolchain_includes.extend(["-isystem", str(multilib_dir)]) toolchain_includes.extend(["-isystem", directory]) # library include directories, using -isystem to suppress their errors diff --git a/script/clang_tidy_hash.py b/script/clang_tidy_hash.py index 57ca90711c..f4fd5a4dff 100644 --- a/script/clang_tidy_hash.py +++ b/script/clang_tidy_hash.py @@ -18,6 +18,7 @@ from pathlib import Path # Root-relative paths whose contents affect clang-tidy results. CLANG_TIDY_GLOBAL_FILES = ( ".clang-tidy", + "script/clang-tidy", "platformio.ini", "requirements_dev.txt", "esphome/idf_component.yml", From 2dfcde477fb0c6ddc201093d8f8b2e8de23abb07 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Aug 2026 19:09:45 -0500 Subject: [PATCH 1271/1815] [ble_client] Reject descriptor_uuid combined with notify at validation time (#18109) --- esphome/components/ble_client/__init__.py | 36 ++++++++ .../components/ble_client/sensor/__init__.py | 13 ++- .../ble_client/text_sensor/__init__.py | 15 ++-- tests/component_tests/ble_client/__init__.py | 0 .../ble_client/test_validation.py | 86 +++++++++++++++++++ 5 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 tests/component_tests/ble_client/__init__.py create mode 100644 tests/component_tests/ble_client/test_validation.py diff --git a/esphome/components/ble_client/__init__.py b/esphome/components/ble_client/__init__.py index 56ac2ea147..1ef7967fa8 100644 --- a/esphome/components/ble_client/__init__.py +++ b/esphome/components/ble_client/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( CONF_ID, CONF_MAC_ADDRESS, CONF_NAME, + CONF_NOTIFY, CONF_ON_CONNECT, CONF_ON_DISCONNECT, CONF_SERVICE_UUID, @@ -16,11 +17,46 @@ from esphome.const import ( CONF_VALUE, ) from esphome.core import ID +from esphome.types import ConfigType AUTO_LOAD = ["esp32_ble_client"] CODEOWNERS = ["@buxtronix", "@clydebarrow"] DEPENDENCIES = ["esp32_ble_tracker"] +CONF_DESCRIPTOR_UUID = "descriptor_uuid" +CONF_ON_NOTIFY = "on_notify" + + +def validate_descriptor_not_notify(config: ConfigType) -> ConfigType: + """Reject descriptor_uuid combined with notify or on_notify. + + BLE descriptors cannot send notifications; only characteristics can, and + ESP-IDF has no descriptor variant of esp_ble_gattc_register_for_notify. + """ + if CONF_DESCRIPTOR_UUID in config and ( + config.get(CONF_NOTIFY) or CONF_ON_NOTIFY in config + ): + raise cv.Invalid( + f"'{CONF_DESCRIPTOR_UUID}' cannot be used with '{CONF_NOTIFY}' or " + f"'{CONF_ON_NOTIFY}': BLE descriptors cannot send notifications; remove " + f"'{CONF_DESCRIPTOR_UUID}' to receive characteristic notifications, or " + f"remove '{CONF_NOTIFY}' and '{CONF_ON_NOTIFY}' to poll the descriptor" + ) + return config + + +def notify_from_on_notify(config: ConfigType) -> ConfigType: + """Enable notifications when an on_notify automation is configured. + + The triggers have no registration path of their own; without notify the + automation would validate but never fire. + """ + if CONF_ON_NOTIFY in config and not config[CONF_NOTIFY]: + config = config.copy() + config[CONF_NOTIFY] = True + return config + + ble_client_ns = cg.esphome_ns.namespace("ble_client") BLEClient = ble_client_ns.class_("BLEClient", esp32_ble_client.BLEClientBase) BLEClientNode = ble_client_ns.class_("BLEClientNode") diff --git a/esphome/components/ble_client/sensor/__init__.py b/esphome/components/ble_client/sensor/__init__.py index 0975640ece..7764955d89 100644 --- a/esphome/components/ble_client/sensor/__init__.py +++ b/esphome/components/ble_client/sensor/__init__.py @@ -14,13 +14,16 @@ from esphome.const import ( UNIT_DECIBEL_MILLIWATT, ) -from .. import ble_client_ns +from .. import ( + CONF_DESCRIPTOR_UUID, + CONF_ON_NOTIFY, + ble_client_ns, + notify_from_on_notify, + validate_descriptor_not_notify, +) DEPENDENCIES = ["ble_client"] -CONF_DESCRIPTOR_UUID = "descriptor_uuid" - -CONF_ON_NOTIFY = "on_notify" TYPE_CHARACTERISTIC = "characteristic" TYPE_RSSI = "rssi" @@ -85,6 +88,8 @@ CONFIG_SCHEMA = cv.All( }, lower=True, ), + validate_descriptor_not_notify, + notify_from_on_notify, ) diff --git a/esphome/components/ble_client/text_sensor/__init__.py b/esphome/components/ble_client/text_sensor/__init__.py index 0f53cccdad..820f60845d 100644 --- a/esphome/components/ble_client/text_sensor/__init__.py +++ b/esphome/components/ble_client/text_sensor/__init__.py @@ -9,13 +9,16 @@ from esphome.const import ( CONF_TRIGGER_ID, ) -from .. import ble_client_ns +from .. import ( + CONF_DESCRIPTOR_UUID, + CONF_ON_NOTIFY, + ble_client_ns, + notify_from_on_notify, + validate_descriptor_not_notify, +) DEPENDENCIES = ["ble_client"] -CONF_DESCRIPTOR_UUID = "descriptor_uuid" - -CONF_ON_NOTIFY = "on_notify" adv_data_t = cg.std_vector.template(cg.uint8) adv_data_t_const_ref = adv_data_t.operator("ref").operator("const") @@ -48,7 +51,9 @@ CONFIG_SCHEMA = cv.All( } ) .extend(cv.polling_component_schema("60s")) - .extend(ble_client.BLE_CLIENT_SCHEMA) + .extend(ble_client.BLE_CLIENT_SCHEMA), + validate_descriptor_not_notify, + notify_from_on_notify, ) diff --git a/tests/component_tests/ble_client/__init__.py b/tests/component_tests/ble_client/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/ble_client/test_validation.py b/tests/component_tests/ble_client/test_validation.py new file mode 100644 index 0000000000..1865812b74 --- /dev/null +++ b/tests/component_tests/ble_client/test_validation.py @@ -0,0 +1,86 @@ +"""Tests for ble_client config validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.ble_client import ( + CONF_DESCRIPTOR_UUID, + CONF_ON_NOTIFY, + notify_from_on_notify, + validate_descriptor_not_notify, +) +from esphome.components.ble_client.sensor import CONFIG_SCHEMA as SENSOR_SCHEMA +from esphome.components.ble_client.text_sensor import ( + CONFIG_SCHEMA as TEXT_SENSOR_SCHEMA, +) +from esphome.const import ( + CONF_CHARACTERISTIC_UUID, + CONF_NAME, + CONF_NOTIFY, + CONF_SERVICE_UUID, + CONF_TYPE, +) +from esphome.types import ConfigType + +DESCRIPTOR_CONFIG: ConfigType = { + CONF_NAME: "test", + CONF_SERVICE_UUID: "6E400001-B5A3-F393-E0A9-E50E24DCCA9E", + CONF_CHARACTERISTIC_UUID: "6E400003-B5A3-F393-E0A9-E50E24DCCA9E", + CONF_DESCRIPTOR_UUID: "2902", +} + + +def test_notify_with_descriptor_uuid_rejected() -> None: + config: ConfigType = {CONF_NOTIFY: True, CONF_DESCRIPTOR_UUID: "2902"} + with pytest.raises(cv.Invalid, match="cannot send notifications"): + validate_descriptor_not_notify(config) + + +def test_on_notify_with_descriptor_uuid_rejected() -> None: + config: ConfigType = { + CONF_NOTIFY: False, + CONF_ON_NOTIFY: [{}], + CONF_DESCRIPTOR_UUID: "2902", + } + with pytest.raises(cv.Invalid, match="cannot send notifications"): + validate_descriptor_not_notify(config) + + +def test_descriptor_uuid_without_notify_allowed() -> None: + config: ConfigType = {CONF_NOTIFY: False, CONF_DESCRIPTOR_UUID: "2902"} + assert validate_descriptor_not_notify(config) is config + + +def test_notify_without_descriptor_uuid_allowed() -> None: + config: ConfigType = {CONF_NOTIFY: True} + assert validate_descriptor_not_notify(config) is config + + +def test_sensor_schema_rejects_notify_with_descriptor() -> None: + config = {**DESCRIPTOR_CONFIG, CONF_TYPE: "characteristic", CONF_NOTIFY: True} + with pytest.raises(cv.Invalid, match="cannot send notifications"): + SENSOR_SCHEMA(config) + + +def test_text_sensor_schema_rejects_notify_with_descriptor() -> None: + config = {**DESCRIPTOR_CONFIG, CONF_NOTIFY: True} + with pytest.raises(cv.Invalid, match="cannot send notifications"): + TEXT_SENSOR_SCHEMA(config) + + +def test_sensor_schema_allows_descriptor_polling() -> None: + assert SENSOR_SCHEMA({**DESCRIPTOR_CONFIG, CONF_TYPE: "characteristic"}) + + +def test_text_sensor_schema_allows_descriptor_polling() -> None: + assert TEXT_SENSOR_SCHEMA(dict(DESCRIPTOR_CONFIG)) + + +def test_on_notify_implies_notify() -> None: + config: ConfigType = {CONF_NOTIFY: False, CONF_ON_NOTIFY: [{}]} + assert notify_from_on_notify(config)[CONF_NOTIFY] is True + + +def test_notify_unchanged_without_on_notify() -> None: + config: ConfigType = {CONF_NOTIFY: False} + assert notify_from_on_notify(config)[CONF_NOTIFY] is False From 0d192e8334c47c7ce47f28d779dc85181ce86175 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Aug 2026 20:21:35 -0500 Subject: [PATCH 1272/1815] [rp2] Bump arduino-pico framework to 6.0.0 (#18101) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- esphome/components/rp2/__init__.py | 20 ++++++--------- esphome/components/rp2/boards.py | 40 ++++++++++++++++++++++++++++-- esphome/core/defines.h | 2 +- platformio.ini | 7 +++--- 4 files changed, 51 insertions(+), 18 deletions(-) diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index 8bba6bc27d..1bf01e6828 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -173,15 +173,10 @@ def get_download_types(storage_json): def _format_framework_arduino_version(ver: cv.Version) -> str: - # The most recent releases have not been uploaded to platformio so grabbing them directly from - # the GitHub release is one path forward for now. + # The framework-arduinopico package is no longer published to the PlatformIO + # registry, so install the framework straight from the GitHub release return f"https://github.com/earlephilhower/arduino-pico/releases/download/{ver}/rp2040-{ver}.zip" - # format the given arduino (https://github.com/earlephilhower/arduino-pico/releases) version to - # a PIO earlephilhower/framework-arduinopico value - # List of package versions: https://api.registry.platformio.org/v3/packages/earlephilhower/tool/framework-arduinopico - # return f"~1.{ver.major}{ver.minor:02d}{ver.patch:02d}.0" - def _parse_platform_version(value): value = cv.string(value) @@ -198,19 +193,20 @@ def _parse_platform_version(value): # The default/recommended arduino framework version # - https://github.com/earlephilhower/arduino-pico/releases -# - https://api.registry.platformio.org/v3/packages/earlephilhower/tool/framework-arduinopico -RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(5, 6, 1) +RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(6, 0, 0) # The raspberrypi platform version to use for arduino frameworks # - https://github.com/maxgerhardt/platform-raspberrypi/tags -RECOMMENDED_ARDUINO_PLATFORM_VERSION = "v1.4.0-gcc14-arduinopico460" +# develop-branch commit carrying the arduino-pico 6.0.0 / pico-quick-toolchain +# 5.0.0 (GCC 16.1) update; replace with a release tag when one is cut +RECOMMENDED_ARDUINO_PLATFORM_VERSION = "9c167c6b8aac4f4cfa6d55a0c4e5b848795150c0" def _arduino_check_versions(value): value = value.copy() lookups = { - "dev": (cv.Version(5, 6, 1), "https://github.com/earlephilhower/arduino-pico"), - "latest": (cv.Version(5, 6, 1), None), + "dev": (cv.Version(6, 0, 0), "https://github.com/earlephilhower/arduino-pico"), + "latest": (cv.Version(6, 0, 0), None), "recommended": (RECOMMENDED_ARDUINO_FRAMEWORK_VERSION, None), } diff --git a/esphome/components/rp2/boards.py b/esphome/components/rp2/boards.py index 94d0ebbb60..149a719121 100644 --- a/esphome/components/rp2/boards.py +++ b/esphome/components/rp2/boards.py @@ -708,6 +708,19 @@ RP2_BOARD_PINS = { "SS": 17, "TX": 0, }, + "ilabs_cpico_2350": { + "LED": 25, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 5, + "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 17, + "TX": 0, + }, "ilabs_rpico32": { "MISO": 24, "MOSI": 23, @@ -1513,6 +1526,19 @@ RP2_BOARD_PINS = { "SS": 17, "TX": 0, }, + "weact_rp2350b": { + "LED": 25, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 5, + "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 17, + "TX": 0, + }, "wiznet_55rp20_evb_pico": { "LED": 19, "MISO": 2, @@ -1877,6 +1903,11 @@ BOARDS = { "mcu": "rp2040", "max_pin": 29, }, + "ilabs_cpico_2350": { + "name": "iLabs CPico 2350", + "mcu": "rp2350", + "max_pin": 47, + }, "ilabs_rpico32": { "name": "iLabs RPICO32", "mcu": "rp2040", @@ -1948,12 +1979,12 @@ BOARDS = { "max_pin": 29, }, "pcbcupid_glyph_2040": { - "name": "PCBCupid Glyph 2040", + "name": "Pcbcupid GLYPH 2040", "mcu": "rp2040", "max_pin": 29, }, "pcbcupid_glyph_mini_2040": { - "name": "PCBCupid Glyph Mini 2040", + "name": "Pcbcupid GLYPH MINI 2040", "mcu": "rp2040", "max_pin": 29, }, @@ -2258,6 +2289,11 @@ BOARDS = { "max_pin": 47, "wifi": True, }, + "weact_rp2350b": { + "name": "WeAct Studio RP2350B Core Board", + "mcu": "rp2350", + "max_pin": 47, + }, "wiznet_5100s_evb_pico": { "name": "WIZnet W5100S-EVB-Pico", "mcu": "rp2040", diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 5ce87f8da5..75a92f8fce 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -455,7 +455,7 @@ // rp2/__init__.py codegen also defines USE_RP2040 as a back-compat alias // for external custom components that may still test for it. #ifdef USE_RP2 -#define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 0) +#define USE_ARDUINO_VERSION_CODE VERSION_CODE(6, 0, 0) #define USE_RP2_CRASH_HANDLER #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C diff --git a/platformio.ini b/platformio.ini index df5c22492c..adc1995440 100644 --- a/platformio.ini +++ b/platformio.ini @@ -203,10 +203,11 @@ extra_scripts = extends = common:arduino board_build.filesystem_size = 0.5m -platform = https://github.com/maxgerhardt/platform-raspberrypi.git#v1.4.0-gcc14-arduinopico460 +platform = https://github.com/maxgerhardt/platform-raspberrypi.git#9c167c6b8aac4f4cfa6d55a0c4e5b848795150c0 platform_packages = - ; earlephilhower/framework-arduinopico@~1.20602.0 ; Cannot use the platformio package until old releases stop getting deleted - earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/5.6.1/rp2040-5.6.1.zip + ; The framework-arduinopico package is no longer published to the PlatformIO + ; registry, so install the framework straight from the GitHub release + earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/6.0.0/rp2040-6.0.0.zip framework = arduino lib_deps = From 0fcc2a6ff1dda003f06e8cbfe0cbee78f97122b8 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:50:00 -0500 Subject: [PATCH 1273/1815] Bump bundled esphome-device-builder to 1.9.3 (#18116) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index ebce522454..e928fd37ca 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.9.2 +RUN uv pip install --no-cache-dir esphome-device-builder==1.9.3 RUN \ platformio settings set enable_telemetry No \ From a7740091ece7521ffccc928365fafefc08905b17 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Aug 2026 20:50:13 -0500 Subject: [PATCH 1274/1815] [ci] Run CodSpeed benchmarks when top-level esphome Python modules change (#18114) --- script/determine-jobs.py | 20 +++++++++++++++----- script/helpers.py | 21 +++++++++++++++++++++ tests/script/test_determine_jobs.py | 29 +++++++++++++++++++++++++++++ tests/script/test_helpers.py | 21 +++++++++++++++++++++ 4 files changed, 86 insertions(+), 5 deletions(-) diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 8039aff83f..e4d002975c 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -63,6 +63,7 @@ from helpers import ( CPP_FILE_EXTENSIONS, ESPHOME_TESTS_COMPONENTS_PATH, PYTHON_FILE_EXTENSIONS, + base_python_changed, changed_files, core_changed, filter_component_and_test_cpp_files, @@ -657,16 +658,20 @@ BENCHMARK_INFRASTRUCTURE_FILES = frozenset( def should_run_benchmarks(branch: str | None = None) -> bool: - """Determine if C++ benchmarks should run based on changed files. + """Determine if benchmarks (C++ and Python) should run based on changed files. Benchmarks run when any of the following conditions are met: - 1. Core C++ files changed (esphome/core/*) - 2. The host platform changed (esphome/components/host/*) — benchmarks + 1. Core files changed (esphome/core/*, C++ or Python) + 2. Top-level Python files changed (esphome/*.py and esphome/*.pyi) — + the Python benchmarks exercise config loading (config.py, + yaml_util.py, ...), so a slowdown there is invisible unless the + benchmarks job runs + 3. 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, + 4. A directly changed component has benchmark files (no dependency expansion) + 5. 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. @@ -683,6 +688,11 @@ def should_run_benchmarks(branch: str | None = None) -> bool: if core_changed(files): return True + # Top-level esphome/*.py modules are what the Python benchmarks in + # tests/benchmarks/python/ exercise + if base_python_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 diff --git a/script/helpers.py b/script/helpers.py index 6ba093b413..7cc001d92f 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -1380,6 +1380,27 @@ def core_changed(files: list[str]) -> bool: ) +def base_python_changed(files: list[str]) -> bool: + """Check if any Python file directly in esphome/ has changed. + + Matches top-level modules and stubs (.py and .pyi) like esphome/config.py + and esphome/yaml_util.py but not files in subdirectories such as + esphome/components/ or esphome/dashboard/. + + Args: + files: List of file paths to check + + Returns: + True if any top-level esphome Python file has changed + """ + return any( + f.startswith("esphome/") + and f.endswith(PYTHON_FILE_EXTENSIONS) + and "/" not in f.removeprefix("esphome/") + for f in files + ) + + def get_cpp_changed_components(files: list[str]) -> list[str]: """Get components that have changed C++ files or tests. diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index a05b683a5f..80f572d9fe 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -2475,6 +2475,35 @@ def test_should_run_benchmarks_core_header_change() -> None: assert determine_jobs.should_run_benchmarks() is True +def test_should_run_benchmarks_top_level_python_change() -> None: + """Test benchmarks trigger on top-level esphome Python module changes. + + The Python benchmarks exercise config loading, so changes to modules + like config.py and yaml_util.py must run them; a regression in #16718 + went unnoticed because these files matched no trigger. + """ + for py_file in [ + "esphome/config.py", + "esphome/yaml_util.py", + "esphome/__main__.py", + "esphome/helpers.py", + ]: + with patch.object(determine_jobs, "changed_files", return_value=[py_file]): + assert determine_jobs.should_run_benchmarks() is True, ( + f"Expected benchmarks to run for {py_file}" + ) + + +def test_should_run_benchmarks_nested_python_change() -> None: + """Test benchmarks do NOT trigger for nested non-core Python changes.""" + with patch.object( + determine_jobs, + "changed_files", + return_value=["esphome/dashboard/web_server.py"], + ): + assert determine_jobs.should_run_benchmarks() is False + + def test_should_run_benchmarks_host_platform_change() -> None: """Test benchmarks trigger on host platform changes. diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 43c4445dcf..077b6ef23e 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -1851,3 +1851,24 @@ def test_get_component_test_files_component_without_tests( ) def test_is_validate_only_file(filename: str, expected: bool, tmp_path: Path) -> None: assert helpers.is_validate_only_file(tmp_path / filename) is expected + + +@pytest.mark.parametrize( + ("files", "expected"), + [ + (["esphome/config.py"], True), + (["esphome/yaml_util.py"], True), + (["esphome/__main__.py"], True), + (["esphome/const.pyi"], True), + (["README.md", "esphome/helpers.py"], True), + (["esphome/core/config.py"], False), + (["esphome/components/sensor/__init__.py"], False), + (["esphome/dashboard/web_server.py"], False), + (["esphome/idf_component.yml"], False), + (["tests/unit_tests/test_config.py"], False), + ([], False), + ], +) +def test_base_python_changed(files: list[str], expected: bool) -> None: + """Only Python modules directly in esphome/ count as base Python changes.""" + assert helpers.base_python_changed(files) is expected From f8cabc9d1fdf6c8a40eecf86fc4cf9315ea5b65c Mon Sep 17 00:00:00 2001 From: Mustafa KURU Date: Thu, 6 Aug 2026 05:18:51 +0300 Subject: [PATCH 1275/1815] [ld6002b] Add LD6002B 60GHz presence radar (1/5) (#17819) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/ld6002b/__init__.py | 73 +++ esphome/components/ld6002b/binary_sensor.py | 40 ++ esphome/components/ld6002b/const.py | 3 + esphome/components/ld6002b/ld6002b.cpp | 485 ++++++++++++++++++ esphome/components/ld6002b/ld6002b.h | 133 +++++ tests/components/ld6002b/common.yaml | 11 + tests/components/ld6002b/test.esp32-idf.yaml | 3 + .../components/ld6002b/test.esp8266-ard.yaml | 3 + tests/components/ld6002b/test.rp2040-ard.yaml | 3 + 10 files changed, 755 insertions(+) create mode 100644 esphome/components/ld6002b/__init__.py create mode 100644 esphome/components/ld6002b/binary_sensor.py create mode 100644 esphome/components/ld6002b/const.py create mode 100644 esphome/components/ld6002b/ld6002b.cpp create mode 100644 esphome/components/ld6002b/ld6002b.h create mode 100644 tests/components/ld6002b/common.yaml create mode 100644 tests/components/ld6002b/test.esp32-idf.yaml create mode 100644 tests/components/ld6002b/test.esp8266-ard.yaml create mode 100644 tests/components/ld6002b/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index f9f8712768..d64e862c39 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -288,6 +288,7 @@ esphome/components/ld2412/* @Rihan9 esphome/components/ld2420/* @descipher esphome/components/ld2450/* @hareeshmu esphome/components/ld24xx/* @kbx81 +esphome/components/ld6002b/* @hepter esphome/components/ledc/* @OttoWinter esphome/components/libretiny/* @kuba2k2 esphome/components/libretiny_pwm/* @kuba2k2 diff --git a/esphome/components/ld6002b/__init__.py b/esphome/components/ld6002b/__init__.py new file mode 100644 index 0000000000..af074fc7ea --- /dev/null +++ b/esphome/components/ld6002b/__init__.py @@ -0,0 +1,73 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_WAKEUP_PIN + +from .const import CONF_AUTO_WAKE, CONF_WAKEUP_PULSE + +CODEOWNERS = ["@hepter"] +DEPENDENCIES = ["uart"] +MULTI_CONF = True + +ld6002b_ns = cg.esphome_ns.namespace("ld6002b") +LD6002BComponent = ld6002b_ns.class_("LD6002BComponent", cg.Component, uart.UARTDevice) + + +def _validate_wakeup_options(config): + """Reject wake options that would silently do nothing. + + Runs before the schema so the defaults for the keys below have not been + filled in yet and an explicit user value is still distinguishable from one. + """ + if not isinstance(config, dict): + return config + if CONF_WAKEUP_PIN in config: + return config + for key in (CONF_AUTO_WAKE, CONF_WAKEUP_PULSE): + if key in config: + raise cv.Invalid( + f"'{key}' requires '{CONF_WAKEUP_PIN}' to be configured", path=[key] + ) + return config + + +CONFIG_SCHEMA = cv.All( + _validate_wakeup_options, + cv.Schema( + { + cv.GenerateID(): cv.declare_id(LD6002BComponent), + cv.Optional(CONF_WAKEUP_PIN): pins.gpio_output_pin_schema, + cv.Optional( + CONF_WAKEUP_PULSE, default="50ms" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_AUTO_WAKE, default=True): cv.boolean, + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA), +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "ld6002b", + baud_rate=115200, + require_tx=True, + require_rx=True, + data_bits=8, + parity="NONE", + stop_bits=1, +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + + if wakeup_pin_config := config.get(CONF_WAKEUP_PIN): + pin = await cg.gpio_pin_expression(wakeup_pin_config) + cg.add(var.set_wakeup_pin(pin)) + + cg.add(var.set_wakeup_pulse_ms(config[CONF_WAKEUP_PULSE].total_milliseconds)) + + cg.add(var.set_auto_wake(config[CONF_AUTO_WAKE])) diff --git a/esphome/components/ld6002b/binary_sensor.py b/esphome/components/ld6002b/binary_sensor.py new file mode 100644 index 0000000000..2e38d35c66 --- /dev/null +++ b/esphome/components/ld6002b/binary_sensor.py @@ -0,0 +1,40 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_TARGET, DEVICE_CLASS_OCCUPANCY + +from . import LD6002BComponent +from .const import CONF_LD6002B_ID + +DEPENDENCIES = ["ld6002b"] + +MAX_TARGETS = 3 + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_TARGET): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + ), + } +).extend( + { + cv.Optional(f"target_{i + 1}"): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + ) + for i in range(MAX_TARGETS) + } +) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_LD6002B_ID]) + + if target_config := config.get(CONF_TARGET): + sens = await binary_sensor.new_binary_sensor(target_config) + cg.add(hub.set_presence_binary_sensor(sens)) + + for i in range(MAX_TARGETS): + if target_config := config.get(f"target_{i + 1}"): + sens = await binary_sensor.new_binary_sensor(target_config) + cg.add(hub.set_target_presence_binary_sensor(i, sens)) diff --git a/esphome/components/ld6002b/const.py b/esphome/components/ld6002b/const.py new file mode 100644 index 0000000000..e7606e2fae --- /dev/null +++ b/esphome/components/ld6002b/const.py @@ -0,0 +1,3 @@ +CONF_AUTO_WAKE = "auto_wake" +CONF_LD6002B_ID = "ld6002b_id" +CONF_WAKEUP_PULSE = "wakeup_pulse" diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp new file mode 100644 index 0000000000..f18b944e12 --- /dev/null +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -0,0 +1,485 @@ +#include "ld6002b.h" +#include "esphome/core/log.h" +#include +#include +#include + +namespace esphome::ld6002b { + +static const char *const TAG = "ld6002b"; + +static constexpr uint8_t TF_SOF = 0x01; +static constexpr uint32_t SETUP_DELAY_MS = 100; + +// Command/message types +static constexpr uint16_t TYPE_CONTROL = 0x0201; + +static constexpr uint16_t TYPE_REPORT_TARGET = 0x0A04; + +// Control command values for TYPE_CONTROL +static constexpr uint32_t CMD_POINT_CLOUD_ON = 0x06; +static constexpr uint32_t CMD_POINT_CLOUD_OFF = 0x07; +static constexpr uint32_t CMD_TARGET_DISPLAY_ON = 0x08; +static constexpr uint32_t CMD_TARGET_DISPLAY_OFF = 0x09; + +static constexpr uint16_t TARGET_DATA_LEN = 20; // x,y,z,dop_idx,cluster_id + +#ifdef ESPHOME_LOG_HAS_VERBOSE +static const char *control_command_name(uint32_t command) { + switch (command) { + case CMD_POINT_CLOUD_ON: + return "point_cloud_on"; + case CMD_POINT_CLOUD_OFF: + return "point_cloud_off"; + case CMD_TARGET_DISPLAY_ON: + return "target_display_on"; + case CMD_TARGET_DISPLAY_OFF: + return "target_display_off"; + default: + return "unknown"; + } +} +#endif + +uint16_t LD6002BComponent::read_u16_be(const uint8_t *data) { return (static_cast(data[0]) << 8) | data[1]; } + +uint32_t LD6002BComponent::read_u32_le(const uint8_t *data) { + return static_cast(data[0]) | (static_cast(data[1]) << 8) | + (static_cast(data[2]) << 16) | (static_cast(data[3]) << 24); +} + +void LD6002BComponent::write_u32_le(uint8_t *data, uint32_t value) { + data[0] = value & 0xFF; + data[1] = (value >> 8) & 0xFF; + data[2] = (value >> 16) & 0xFF; + data[3] = (value >> 24) & 0xFF; +} + +void LD6002BComponent::setup() { + // One allocation for the component lifetime; the parser reuses it for the header and every payload. + RAMAllocator allocator; + this->data_buf_ = allocator.allocate(DEFAULT_MAX_DATA_LEN); + if (this->data_buf_ == nullptr) { + this->mark_failed(LOG_STR("Failed to allocate frame buffer")); + return; + } + if (this->wakeup_pin_ != nullptr) { + this->wakeup_pin_->setup(); + this->wakeup_pin_->digital_write(true); + } + + this->set_timeout(SETUP_DELAY_MS, [this]() { + bool want_target_stream = false; +#ifdef USE_BINARY_SENSOR + want_target_stream = want_target_stream || this->presence_binary_sensor_ != nullptr; + if (!want_target_stream) { + for (auto *sensor : this->target_presence_) { + if (sensor != nullptr) { + want_target_stream = true; + break; + } + } + } +#endif + if (want_target_stream) { + this->send_control_command_(CMD_TARGET_DISPLAY_ON); + } + + // Point-cloud streaming is introduced in a later part; make sure it is off. + this->send_control_command_(CMD_POINT_CLOUD_OFF); + }); +} + +void LD6002BComponent::dump_config() { + ESP_LOGCONFIG(TAG, + "HLK-LD6002B:\n" + " Auto wake: %s", + this->auto_wake_ ? "true" : "false"); + if (this->wakeup_pin_ != nullptr) { + LOG_PIN(" Wake-up Pin: ", this->wakeup_pin_); + ESP_LOGCONFIG(TAG, " Wake Pulse: %ums", this->wakeup_pulse_ms_); + } +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Presence", this->presence_binary_sensor_); + for (uint8_t i = 0; i < MAX_TARGETS; i++) { + LOG_BINARY_SENSOR(" ", "Target Presence", this->target_presence_[i]); + } +#endif +} + +void LD6002BComponent::loop() { + while (this->available()) { + uint8_t byte = this->read(); + this->parse_byte_(byte); + } + this->process_command_queue_(); +} + +void LD6002BComponent::reset_parser_() { + this->parse_state_ = ParseState::SOF; + this->header_pos_ = 0; + this->header_xor_ = 0; + this->data_len_ = 0; + this->data_pos_ = 0; + this->data_xor_ = 0; + this->discard_remaining_ = 0; + this->frame_oversize_ = false; +} + +void LD6002BComponent::parse_byte_(uint8_t byte) { + switch (this->parse_state_) { + case ParseState::DISCARD: + // discard_remaining_ is unsigned: an unguarded decrement at zero would swallow 4 GB of stream. + if (this->discard_remaining_ > 0) { + this->discard_remaining_--; + } + if (this->discard_remaining_ == 0) { + this->reset_parser_(); + } + return; + case ParseState::SOF: + if (byte != TF_SOF) + return; + this->header_pos_ = 0; + this->header_xor_ = 0; + this->header_xor_ ^= byte; + this->parse_state_ = ParseState::HEADER; + return; + case ParseState::HEADER: + if (this->header_pos_ < 6) { + this->data_buf_[this->header_pos_] = byte; + this->header_xor_ ^= byte; + this->header_pos_++; + if (this->header_pos_ == 6) { + this->frame_id_ = read_u16_be(this->data_buf_); + this->data_len_ = read_u16_be(this->data_buf_ + 2); + this->frame_type_ = read_u16_be(this->data_buf_ + 4); + // The length is only trustworthy once the header checksum has been verified, so just + // remember that the frame is oversized and let the HCK state act on it. + this->frame_oversize_ = this->data_len_ > DEFAULT_MAX_DATA_LEN; + this->parse_state_ = ParseState::HCK; + } + } + return; + case ParseState::HCK: { + uint8_t expected = static_cast(~this->header_xor_); + if (byte != expected) { + ESP_LOGV(TAG, "Header checksum mismatch"); + this->reset_parser_(); + return; + } + if (this->frame_oversize_) { + ESP_LOGW(TAG, "Frame too large: %u", this->data_len_); + // The header is verified, so the length can be trusted: skip the payload and its checksum. + this->discard_remaining_ = static_cast(this->data_len_) + 1; + this->parse_state_ = ParseState::DISCARD; + return; + } + if (this->data_len_ == 0) { + this->handle_frame_(this->frame_type_, nullptr, 0); + this->reset_parser_(); + } else { + this->data_pos_ = 0; + this->data_xor_ = 0; + this->parse_state_ = ParseState::DATA; + } + return; + } + case ParseState::DATA: + this->data_buf_[this->data_pos_++] = byte; + this->data_xor_ ^= byte; + if (this->data_pos_ >= this->data_len_) { + this->parse_state_ = ParseState::DCK; + } + return; + case ParseState::DCK: { + uint8_t expected = static_cast(~this->data_xor_); + if (byte == expected) { + this->handle_frame_(this->frame_type_, this->data_buf_, this->data_len_); + } else { + ESP_LOGV(TAG, "Data checksum mismatch"); + } + this->reset_parser_(); + return; + } + } +} + +void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_t len) { + this->last_traffic_ms_ = millis(); + if (this->stale_ack_count_ > 0 && millis() - this->stale_ack_ms_ > STALE_ACK_MAX_AGE_MS) { + this->stale_ack_count_ = 0; + } + // ACKs carry no id and arrive in send order: debt from earlier attempts is paid before the active command. + if (len == 0 && this->stale_ack_count_ > 0 && this->stale_ack_type_ == type) { + this->stale_ack_count_--; + ESP_LOGV(TAG, "Ignoring ACK for command 0x%04X from an earlier attempt (module frame 0x%04X)", type, + this->frame_id_); + return; + } + if (len == 0 && this->command_active_ && this->command_sent_ && type == this->active_command_.type) { + ESP_LOGV(TAG, "ACK for command 0x%04X (module frame 0x%04X)", type, this->frame_id_); + // This settles one expected reply; the rest stay owed and become the debt for the next command. + this->send_generation_++; + this->stale_ack_type_ = type; + this->stale_ack_count_ = this->acks_expected_ > 0 ? static_cast(this->acks_expected_ - 1) : 0; + this->stale_ack_ms_ = millis(); + this->command_active_ = false; + this->command_sent_ = false; + this->last_send_ms_ = 0; + this->process_command_queue_(); + return; + } + + switch (type) { + case TYPE_REPORT_TARGET: + this->handle_target_report_(data, len); + break; + default: + break; + } +} + +void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) { + if (len < 4) + return; + + uint32_t target_num = read_u32_le(data); + uint16_t available = (len - 4) / TARGET_DATA_LEN; + // Un-narrowed: a report of e.g. 256 targets must not truncate to 0 and read as "absent". + const uint32_t reported = std::min(target_num, available); + uint8_t count = static_cast(std::min(reported, MAX_TARGETS)); + + // The module re-sorts its array by cluster id, so slots key on the id to track the person. + std::array wire_cluster{}; + std::array wire_placed{}; + std::array slot_seen{}; + for (uint8_t i = 0; i < count; i++) { + uint16_t cluster_offset = 4 + (i * TARGET_DATA_LEN) + 16; + wire_cluster[i] = static_cast(read_u32_le(data + cluster_offset)); + } + for (uint8_t i = 0; i < count; i++) { + for (uint8_t s = 0; s < MAX_TARGETS; s++) { + if (this->slot_occupied_[s] && !slot_seen[s] && this->slot_cluster_[s] == wire_cluster[i]) { + slot_seen[s] = true; + wire_placed[i] = true; + break; + } + } + } + for (uint8_t s = 0; s < MAX_TARGETS; s++) { + if (!slot_seen[s]) { + this->slot_occupied_[s] = false; + } + } + for (uint8_t i = 0; i < count; i++) { + if (wire_placed[i]) { + continue; + } + for (uint8_t s = 0; s < MAX_TARGETS; s++) { + if (!this->slot_occupied_[s]) { + this->slot_occupied_[s] = true; + this->slot_cluster_[s] = wire_cluster[i]; + break; + } + } + } + + this->target_presence_any_ = (reported > 0); +#ifdef USE_BINARY_SENSOR + if (this->presence_binary_sensor_ != nullptr) { + this->presence_binary_sensor_->publish_state(this->target_presence_any_); + } +#endif + + for (uint8_t i = 0; i < MAX_TARGETS; i++) { +#ifdef USE_BINARY_SENSOR + if (this->target_presence_[i] != nullptr) { + // publish_state() already skips unchanged states, no manual de-dup needed. + this->target_presence_[i]->publish_state(this->slot_occupied_[i]); + } +#endif + } +} + +void LD6002BComponent::queue_command_(uint16_t type, const uint8_t *data, uint8_t len) { + if (len > CMD_MAX_DATA_LEN) { + ESP_LOGW(TAG, "Command data too large: %u", len); + return; + } + if (this->cmd_count_ >= CMD_QUEUE_SIZE) { + ESP_LOGW(TAG, "Command queue full, dropping command 0x%04X", type); + return; + } + + PendingCommand &cmd = this->cmd_queue_[this->cmd_tail_]; + cmd.type = type; + cmd.len = len; + if (len > 0 && data != nullptr) { + std::memcpy(cmd.data.data(), data, len); + } + + this->cmd_tail_ = (this->cmd_tail_ + 1) % CMD_QUEUE_SIZE; + this->cmd_count_++; + this->process_command_queue_(); +} + +void LD6002BComponent::process_command_queue_() { + uint32_t now = millis(); + if (this->command_active_) { + // A sleeping module consumes the opening attempt as its wake-up instead of answering it. + const uint32_t ack_timeout = this->attempts_sent_ <= 1 ? CMD_FIRST_ACK_TIMEOUT_MS : CMD_ACK_TIMEOUT_MS; + if (this->command_sent_ && now - this->last_send_ms_ >= ack_timeout) { + const uint32_t active_control_command = + (this->active_command_.type == TYPE_CONTROL && this->active_command_.len >= 4) + ? read_u32_le(this->active_command_.data.data()) + : 0; + if (this->retries_left_ > 0) { +#ifdef ESPHOME_LOG_HAS_VERBOSE + if (active_control_command != 0) { + ESP_LOGV(TAG, "Retrying %s (0x%02" PRIX32 "), %u attempt(s) remaining", + control_command_name(active_control_command), active_control_command, this->retries_left_); + } else { + // Writes without a control subcommand (hold delay, z-range) had no retry trace at all. + ESP_LOGV(TAG, "Retrying command 0x%04X, %u attempt(s) remaining", this->active_command_.type, + this->retries_left_); + } +#endif + this->command_sent_ = false; + this->last_send_ms_ = 0; + this->send_command_(this->active_command_.type, this->active_command_.data.data(), this->active_command_.len); + this->retries_left_--; + } else { + if (active_control_command != 0) { + ESP_LOGW(TAG, "Command 0x%04X subcommand 0x%02" PRIX32 " timed out", this->active_command_.type, + active_control_command); + } else { + ESP_LOGW(TAG, "Command 0x%04X timed out", this->active_command_.type); + } + // A reply may still be in flight for the attempt we just gave up on, so carry one over as + // debt rather than clearing the ledger, or that late ACK would retire the successor. Only + // one: reaching this point means nothing was answered at all, so the older attempts are + // speculative, and carrying them would swallow the successor's own replies. + const uint16_t owed = (this->stale_ack_type_ == this->active_command_.type ? this->stale_ack_count_ : 0) + + (this->acks_expected_ > 0 ? 1 : 0); + this->stale_ack_type_ = this->active_command_.type; + this->stale_ack_count_ = static_cast(std::min(owed, 255)); + this->stale_ack_ms_ = now; + this->send_generation_++; + this->command_active_ = false; + this->command_sent_ = false; + this->last_send_ms_ = 0; + } + } + return; + } + + if (this->cmd_count_ == 0) + return; + + this->active_command_ = this->cmd_queue_[this->cmd_head_]; + this->cmd_head_ = (this->cmd_head_ + 1) % CMD_QUEUE_SIZE; + this->cmd_count_--; + + this->send_generation_++; + this->retries_left_ = CMD_MAX_RETRIES; + this->command_active_ = true; + this->command_sent_ = false; + this->last_send_ms_ = 0; + this->attempts_sent_ = 0; + this->acks_expected_ = 0; + if (this->stale_ack_type_ != this->active_command_.type) { + this->stale_ack_count_ = 0; + } + this->send_command_(this->active_command_.type, this->active_command_.data.data(), this->active_command_.len); +} + +void LD6002BComponent::send_command_(uint16_t type, const uint8_t *data, uint8_t len) { + this->send_command_internal_(type, data, len, true); +} + +void LD6002BComponent::send_command_internal_(uint16_t type, const uint8_t *data, uint8_t len, bool track) { + if (len > CMD_MAX_DATA_LEN) { + ESP_LOGW(TAG, "Command data too large: %u", len); + if (track) { + // Release the slot: an unwritten command is never acked and never times out. + this->command_active_ = false; + this->command_sent_ = false; + this->last_send_ms_ = 0; + } + return; + } + + // Anonymous timeouts never replace each other; with a pulse already pending the module is waking anyway. + if (this->auto_wake_ && this->wakeup_pin_ != nullptr && !this->wake_pulse_pending_) { + // Snapshot the payload: the deferred write must not depend on state a completing command changes. + if (len > 0 && data != nullptr) { + std::memcpy(this->wake_scratch_.data(), data, len); + } + this->wake_pulse_pending_ = true; + this->wakeup_pin_->digital_write(false); + const uint8_t generation = this->send_generation_; + this->set_timeout(this->wakeup_pulse_ms_, [this, type, len, track, generation]() { + this->wakeup_pin_->digital_write(true); + this->wake_pulse_pending_ = false; + // Anonymous timeouts are never cancelled, so a tracked pulse whose command has since been + // retired must not transmit: the frame would land after its successor and be booked to it. + if (track && generation != this->send_generation_) { + return; + } + this->write_frame_(type, (len > 0) ? this->wake_scratch_.data() : nullptr, len, track); + }); + return; + } + + this->write_frame_(type, data, len, track); +} + +void LD6002BComponent::write_frame_(uint16_t type, const uint8_t *data, uint8_t len, bool track) { + uint16_t frame_id = this->next_frame_id_++ & 0x7FFF; + frame_id |= 0x8000; + + uint8_t header_xor = 0; + auto write_header = [&](uint8_t b) { + this->write_byte(b); + header_xor ^= b; + }; + + write_header(TF_SOF); + write_header((frame_id >> 8) & 0xFF); + write_header(frame_id & 0xFF); + write_header((len >> 8) & 0xFF); + write_header(len & 0xFF); + write_header((type >> 8) & 0xFF); + write_header(type & 0xFF); + + this->write_byte(static_cast(~header_xor)); + + if (len > 0 && data != nullptr) { + uint8_t data_xor = 0; + for (uint8_t i = 0; i < len; i++) { + this->write_byte(data[i]); + data_xor ^= data[i]; + } + this->write_byte(static_cast(~data_xor)); + } + const uint32_t now = millis(); + if (track) { + // A frame sent to a module that has had time to fall asleep is its wake-up, and goes unanswered. + if (this->last_traffic_ms_ != 0 && now - this->last_traffic_ms_ < MODULE_AWAKE_MS) { + this->acks_expected_++; + } + this->last_send_ms_ = now; + this->command_sent_ = true; + this->attempts_sent_++; + } + this->last_traffic_ms_ = now; +} + +void LD6002BComponent::send_control_command_(uint32_t command) { + uint8_t data[4]; + write_u32_le(data, command); + this->queue_command_(TYPE_CONTROL, data, sizeof(data)); +} + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/ld6002b.h b/esphome/components/ld6002b/ld6002b.h new file mode 100644 index 0000000000..2a68507e41 --- /dev/null +++ b/esphome/components/ld6002b/ld6002b.h @@ -0,0 +1,133 @@ +#pragma once + +#include "esphome/core/defines.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/core/gpio.h" +#include "esphome/components/uart/uart.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif + +#include + +namespace esphome::ld6002b { + +static constexpr uint8_t MAX_TARGETS = 3; +static constexpr size_t DEFAULT_MAX_DATA_LEN = 1024; +// Largest protocol payload is TYPE_SET_AREA: int32 area id + 6 floats = 28 bytes. +static constexpr size_t CMD_MAX_DATA_LEN = 28; + +class LD6002BComponent : public Component, public uart::UARTDevice { + public: + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::DATA; } + + void set_wakeup_pin(GPIOPin *pin) { this->wakeup_pin_ = pin; } + void set_wakeup_pulse_ms(uint32_t ms) { this->wakeup_pulse_ms_ = ms; } + void set_auto_wake(bool enable) { this->auto_wake_ = enable; } + +#ifdef USE_BINARY_SENSOR + void set_presence_binary_sensor(binary_sensor::BinarySensor *sensor) { this->presence_binary_sensor_ = sensor; } + void set_target_presence_binary_sensor(uint8_t target, binary_sensor::BinarySensor *sensor) { + if (target >= MAX_TARGETS) + return; + this->target_presence_[target] = sensor; + } +#endif + + protected: + enum class ParseState : uint8_t { SOF, HEADER, HCK, DATA, DCK, DISCARD }; + + struct PendingCommand { + uint16_t type{0}; + uint8_t len{0}; + std::array data{}; + }; + + void parse_byte_(uint8_t byte); + void reset_parser_(); + void handle_frame_(uint16_t type, const uint8_t *data, uint16_t len); + void handle_target_report_(const uint8_t *data, uint16_t len); + + void queue_command_(uint16_t type, const uint8_t *data, uint8_t len); + void process_command_queue_(); + void send_command_(uint16_t type, const uint8_t *data, uint8_t len); + void send_command_internal_(uint16_t type, const uint8_t *data, uint8_t len, bool track); + void write_frame_(uint16_t type, const uint8_t *data, uint8_t len, bool track); + void send_control_command_(uint32_t command); + + static uint16_t read_u16_be(const uint8_t *data); + static uint32_t read_u32_le(const uint8_t *data); + static void write_u32_le(uint8_t *data, uint32_t value); + +#ifdef USE_BINARY_SENSOR + binary_sensor::BinarySensor *presence_binary_sensor_{nullptr}; + std::array target_presence_{}; +#endif + + GPIOPin *wakeup_pin_{nullptr}; + uint32_t wakeup_pulse_ms_{50}; + bool auto_wake_{true}; + + ParseState parse_state_{ParseState::SOF}; + uint8_t header_pos_{0}; + uint8_t header_xor_{0}; + uint16_t data_len_{0}; + uint16_t frame_type_{0}; + uint16_t frame_id_{0}; + uint16_t data_pos_{0}; + uint8_t data_xor_{0}; + uint32_t discard_remaining_{0}; + bool frame_oversize_{false}; + uint8_t *data_buf_{nullptr}; + uint16_t next_frame_id_{0}; + + // Sized for the boot burst: with every platform configured, setup() enqueues + // roughly ten GET/config commands back to back before the first ack lands. + static constexpr uint8_t CMD_QUEUE_SIZE = 16; + static constexpr uint32_t CMD_ACK_TIMEOUT_MS = 300; + // A sleeping module consumes the first frame to wake and answers only the one after it. + static constexpr uint32_t CMD_FIRST_ACK_TIMEOUT_MS = 600; + // How long the module stays awake after any frame, and so still answers the next one. + static constexpr uint32_t MODULE_AWAKE_MS = 10000; + static constexpr uint8_t CMD_MAX_RETRIES = 3; + // A reply cannot trail the frame that earned it for longer than this; the field worst case is ~726ms. + static constexpr uint32_t STALE_ACK_MAX_AGE_MS = 1000; + + std::array cmd_queue_{}; + uint8_t cmd_head_{0}; + uint8_t cmd_tail_{0}; + uint8_t cmd_count_{0}; + bool command_active_{false}; + bool command_sent_{false}; + PendingCommand active_command_{}; + // Frame a pending wake pulse will write, snapshotted because active_command_ may move on first. + std::array wake_scratch_{}; + bool wake_pulse_pending_{false}; + uint8_t retries_left_{0}; + uint32_t last_send_ms_{0}; + // Last frame seen in either direction; any traffic keeps the module awake. + uint32_t last_traffic_ms_{0}; + // Frames transmitted for the command in flight, including retries; drives the retry budget. + uint8_t attempts_sent_{0}; + // Subset of those the module can actually answer: a frame that woke it is consumed, not replied to. + uint8_t acks_expected_{0}; + // ACKs still owed for superseded attempts; they carry no id, only their arrival order. + uint16_t stale_ack_type_{0}; + uint8_t stale_ack_count_{0}; + // When that debt was booked, so a debt no reply can still settle expires instead of eating a live ACK. + uint32_t stale_ack_ms_{0}; + // Bumped whenever the active command changes, so a deferred send can tell it was retired. + uint8_t send_generation_{0}; + + // Which person owns each target_N slot, so a slot survives the module re-sorting its array. + std::array slot_cluster_{}; + std::array slot_occupied_{}; + + bool target_presence_any_{false}; +}; + +} // namespace esphome::ld6002b diff --git a/tests/components/ld6002b/common.yaml b/tests/components/ld6002b/common.yaml new file mode 100644 index 0000000000..da08122608 --- /dev/null +++ b/tests/components/ld6002b/common.yaml @@ -0,0 +1,11 @@ +ld6002b: + id: ld6002b_radar + wakeup_pin: GPIO14 + +binary_sensor: + - platform: ld6002b + ld6002b_id: ld6002b_radar + target: + name: Presence + target_1: + name: Target-1 Presence diff --git a/tests/components/ld6002b/test.esp32-idf.yaml b/tests/components/ld6002b/test.esp32-idf.yaml new file mode 100644 index 0000000000..d26ef5c348 --- /dev/null +++ b/tests/components/ld6002b/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + ld6002b: !include common.yaml diff --git a/tests/components/ld6002b/test.esp8266-ard.yaml b/tests/components/ld6002b/test.esp8266-ard.yaml new file mode 100644 index 0000000000..8846b4ab50 --- /dev/null +++ b/tests/components/ld6002b/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + ld6002b: !include common.yaml diff --git a/tests/components/ld6002b/test.rp2040-ard.yaml b/tests/components/ld6002b/test.rp2040-ard.yaml new file mode 100644 index 0000000000..4edcb6965e --- /dev/null +++ b/tests/components/ld6002b/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml + ld6002b: !include common.yaml From 3b6f45894bccf9d8f64a28b0548177cb51b3cc29 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Wed, 5 Aug 2026 19:20:37 -0700 Subject: [PATCH 1276/1815] [core] StaticVector converting ctor (#18117) --- esphome/core/helpers.h | 5 +++++ tests/components/core/helpers_test.cpp | 28 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 155fa2f6ba..d883ce146e 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -256,6 +256,11 @@ template class StaticVector { } } + // Converting constructor from a smaller StaticVector of the same element type + template StaticVector(const StaticVector &other) : StaticVector(other.begin(), other.end()) { + static_assert(M <= N, "Source StaticVector cannot be larger than the destination"); + } + // Minimal vector-compatible interface - only what we actually use void push_back(const T &value) { if (count_ < N) { diff --git a/tests/components/core/helpers_test.cpp b/tests/components/core/helpers_test.cpp index 468185787f..a9a940392f 100644 --- a/tests/components/core/helpers_test.cpp +++ b/tests/components/core/helpers_test.cpp @@ -55,4 +55,32 @@ TEST(HelpersTest, Ilog10RoundTripMatchesLog10) { } } +TEST(StaticVectorTest, ConvertingConstructorFromSmaller) { + StaticVector small{0x03, 0x00, 0x10, 0x00, 0x01}; + StaticVector big = small; + ASSERT_EQ(big.size(), small.size()); + for (size_t i = 0; i < small.size(); i++) { + EXPECT_EQ(big[i], small[i]) << "mismatch at index " << i; + } +} + +TEST(StaticVectorTest, ConvertingConstructorPartiallyFilledAndEmpty) { + StaticVector partial{0xAA, 0xBB}; + StaticVector from_partial = partial; + ASSERT_EQ(from_partial.size(), 2u); + EXPECT_EQ(from_partial[0], 0xAA); + EXPECT_EQ(from_partial[1], 0xBB); + + StaticVector empty; + StaticVector from_empty = empty; + EXPECT_TRUE(from_empty.empty()); +} + +TEST(StaticVectorTest, ConvertingConstructorSameSize) { + StaticVector src{1, 2, 3}; + StaticVector dst = src; + ASSERT_EQ(dst.size(), 3u); + EXPECT_EQ(dst[2], 3); +} + } // namespace esphome From eb0fac9cbf2c4369af499c2c9885767a1bf511a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Aug 2026 21:20:52 -0500 Subject: [PATCH 1277/1815] [core] Only snapshot the user config when esphome config --no-defaults asks for it (#18113) --- esphome/__main__.py | 2 ++ esphome/config.py | 44 +++++++++++++++++++------- tests/unit_tests/test_main.py | 20 ++++++++++++ tests/unit_tests/test_substitutions.py | 30 ++++++++++++++++-- 4 files changed, 83 insertions(+), 13 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index f435b18bb3..cb45dd7c5f 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2601,6 +2601,8 @@ def run_esphome(argv): config = read_config( command_line_substitutions, skip_external_update=skip_external, + # Snapshot only needed by `esphome config --no-defaults`. + snapshot_user_config=getattr(args, "no_defaults", False), ) # Refresh the cache so the next upload/logs hits the fast path # instead of re-running read_config. Skip when the storage diff --git a/esphome/config.py b/esphome/config.py index 976faed447..b747c69b3a 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -1108,6 +1108,7 @@ def validate_config( config: dict[str, Any], command_line_substitutions: dict[str, Any] | None, skip_external_update: bool = False, + snapshot_user_config: bool = False, ) -> Config: result = Config() @@ -1218,11 +1219,13 @@ def validate_config( # Snapshot the user's config before any schema validation defaults are # applied. preload_core_config and later validation steps rewrite entries # in-place with defaulted values; deep-copying here preserves the - # user-supplied keys for `esphome config --no-defaults`. - result.user_config = copy.deepcopy(config) - if substitutions is not None: - result.user_config[CONF_SUBSTITUTIONS] = copy.deepcopy(substitutions) - result.user_config.move_to_end(CONF_SUBSTITUTIONS, last=False) + # user-supplied keys for `esphome config --no-defaults`. The deep copy is + # expensive, so it is only taken when that command actually asked for it. + if snapshot_user_config: + result.user_config = copy.deepcopy(config) + if substitutions is not None: + result.user_config[CONF_SUBSTITUTIONS] = copy.deepcopy(substitutions) + result.user_config.move_to_end(CONF_SUBSTITUTIONS, last=False) # 2. Load partial core config import esphome.core.config as core_config @@ -1335,7 +1338,9 @@ class InvalidYAMLError(EsphomeError): def _load_config( - command_line_substitutions: dict[str, Any], skip_external_update: bool = False + command_line_substitutions: dict[str, Any], + skip_external_update: bool = False, + snapshot_user_config: bool = False, ) -> Config: """Load the configuration file.""" try: @@ -1344,7 +1349,12 @@ def _load_config( raise InvalidYAMLError(e) from e try: - return validate_config(config, command_line_substitutions, skip_external_update) + return validate_config( + config, + command_line_substitutions, + skip_external_update=skip_external_update, + snapshot_user_config=snapshot_user_config, + ) except EsphomeError: raise except Exception: @@ -1353,10 +1363,16 @@ def _load_config( def load_config( - command_line_substitutions: dict[str, Any], skip_external_update: bool = False + command_line_substitutions: dict[str, Any], + skip_external_update: bool = False, + snapshot_user_config: bool = False, ) -> Config: try: - return _load_config(command_line_substitutions, skip_external_update) + return _load_config( + command_line_substitutions, + skip_external_update=skip_external_update, + snapshot_user_config=snapshot_user_config, + ) except vol.Invalid as err: raise EsphomeError(f"Error while parsing config: {err}") from err @@ -1497,11 +1513,17 @@ def strip_default_ids(config): def read_config( - command_line_substitutions: dict[str, Any], skip_external_update: bool = False + command_line_substitutions: dict[str, Any], + skip_external_update: bool = False, + snapshot_user_config: bool = False, ) -> Config | None: _LOGGER.info("Reading configuration %s...", CORE.config_path) try: - res = load_config(command_line_substitutions, skip_external_update) + res = load_config( + command_line_substitutions, + skip_external_update=skip_external_update, + snapshot_user_config=snapshot_user_config, + ) except EsphomeError as err: _LOGGER.error("Error while reading config: %s", err) return None diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 556bac9ee5..6c13cd5f12 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -6362,6 +6362,26 @@ def test_run_esphome_skip_external_update_per_command( assert mock_read.call_args.kwargs["skip_external_update"] is expected_skip +@pytest.mark.parametrize( + ("argv_extra", "expected"), + [(["--no-defaults"], True), ([], False)], +) +def test_run_esphome_snapshot_user_config_only_for_no_defaults( + tmp_path: Path, argv_extra: list[str], expected: bool +) -> None: + """read_config is invoked with snapshot_user_config=True only when the + config command is run with --no-defaults; otherwise the expensive deep + copy is skipped.""" + yaml_file = tmp_path / "device.yaml" + yaml_file.write_text("esphome:\n name: test\n") + + with patch("esphome.config.read_config", return_value=None) as mock_read: + run_esphome(["esphome", "config", str(yaml_file), *argv_extra]) + + mock_read.assert_called_once() + assert mock_read.call_args.kwargs["snapshot_user_config"] is expected + + def test_get_configured_xtal_freq_reads_sdkconfig(tmp_path: Path) -> None: """Test reading XTAL_FREQ from sdkconfig.""" CORE.name = "test-device" diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index bcaf3fb354..f4063237b1 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -370,7 +370,7 @@ def test_validate_config_captures_user_config_snapshot(tmp_path: Path) -> None: """ test_config = _get_test_minimal_valid_config(tmp_path) - result = config_module.validate_config(test_config, None) + result = config_module.validate_config(test_config, None, snapshot_user_config=True) # Snapshot is populated. assert result.user_config is not None @@ -393,7 +393,7 @@ def test_validate_config_user_config_snapshot_is_deep_copy(tmp_path: Path) -> No """ test_config = _get_test_minimal_valid_config(tmp_path) - result = config_module.validate_config(test_config, None) + result = config_module.validate_config(test_config, None, snapshot_user_config=True) assert result.user_config is not None # preload_core_config injected build_path onto the validated config. @@ -404,6 +404,32 @@ def test_validate_config_user_config_snapshot_is_deep_copy(tmp_path: Path) -> No assert result["esphome"] is not result.user_config["esphome"] +def test_validate_config_snapshot_without_substitutions(tmp_path: Path) -> None: + """The snapshot works for configs that have no substitutions block.""" + test_config = _get_test_minimal_valid_config(tmp_path) + del test_config[CONF_SUBSTITUTIONS] + + result = config_module.validate_config(test_config, None, snapshot_user_config=True) + + assert result.user_config is not None + assert CONF_SUBSTITUTIONS not in result.user_config + assert result.user_config["esphome"] == {"name": "test_device"} + + +def test_validate_config_skips_user_config_snapshot_by_default( + tmp_path: Path, +) -> None: + """Without ``snapshot_user_config`` the deep copy is skipped entirely; + only ``esphome config --no-defaults`` needs the snapshot and the copy is + too expensive to take on every load. + """ + test_config = _get_test_minimal_valid_config(tmp_path) + + result = config_module.validate_config(test_config, None) + + assert result.user_config is None + + def test_merge_config_preserves_ordered_dict() -> None: """Test that merge_config preserves OrderedDict type. From a16c2e2b04da435bafffa2bfa17e684a7bb0b9a4 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Thu, 6 Aug 2026 01:54:21 -0500 Subject: [PATCH 1278/1815] [esp32] Use constants for the signing scheme identifiers (#18118) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/esp32/__init__.py | 47 ++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index ef997105ba..e31d0352e9 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -142,10 +142,14 @@ ASSERTION_LEVELS = { "SILENT": "CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_SILENT", } +SIGNING_SCHEME_RSA3072 = "rsa3072" +SIGNING_SCHEME_ECDSA256 = "ecdsa256" +SIGNING_SCHEME_ECDSA_V1 = "ecdsa_v1" + SIGNING_SCHEMES = { - "rsa3072": "CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME", - "ecdsa256": "CONFIG_SECURE_SIGNED_APPS_ECDSA_V2_SCHEME", - "ecdsa_v1": "CONFIG_SECURE_SIGNED_APPS_ECDSA_SCHEME", + SIGNING_SCHEME_RSA3072: "CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME", + SIGNING_SCHEME_ECDSA256: "CONFIG_SECURE_SIGNED_APPS_ECDSA_V2_SCHEME", + SIGNING_SCHEME_ECDSA_V1: "CONFIG_SECURE_SIGNED_APPS_ECDSA_SCHEME", } # A Secure Boot v2 image carries at most three signature blocks, and hardware @@ -1263,7 +1267,7 @@ _SIGNED_OTA_VERIFICATION_SCHEMA = cv.Schema( cv.ensure_list(_validate_trusted_key), cv.Length(min=1, max=SIGNED_OTA_MAX_KEYS), ), - cv.Optional(CONF_SIGNING_SCHEME, default="rsa3072"): cv.one_of( + cv.Optional(CONF_SIGNING_SCHEME, default=SIGNING_SCHEME_RSA3072): cv.one_of( *SIGNING_SCHEMES, lower=True ), } @@ -1313,7 +1317,7 @@ def _validate_signed_ota_keys(config: ConfigType) -> ConfigType: path=[CONF_VERIFICATION_KEY], ) if has_verification_keys: - if scheme != "rsa3072": + if scheme != SIGNING_SCHEME_RSA3072: raise cv.Invalid( f"'{CONF_VERIFICATION_KEYS}' is only used with signing scheme " f"'rsa3072' (externally-signed RSA images). With '{scheme}' the " @@ -1340,7 +1344,7 @@ def _validate_signed_ota_keys(config: ConfigType) -> ConfigType: f"keys add nothing and waste a trusted-set slot).", path=[CONF_VERIFICATION_KEYS], ) - if scheme == "ecdsa_v1": + if scheme == SIGNING_SCHEME_ECDSA_V1: if not has_signing_key and not has_verification_key: raise cv.Invalid( f"Signing scheme 'ecdsa_v1' requires either '{CONF_SIGNING_KEY}' " @@ -1496,7 +1500,10 @@ def final_validate(config): ] # V1 ECDSA is only available on the original ESP32 - if scheme == "ecdsa_v1" and variant not in SIGNED_OTA_V1_ECDSA_VARIANTS: + if ( + scheme == SIGNING_SCHEME_ECDSA_V1 + and variant not in SIGNED_OTA_V1_ECDSA_VARIANTS + ): errs.append( cv.Invalid( f"Signing scheme 'ecdsa_v1' is only supported on " @@ -1509,7 +1516,9 @@ def final_validate(config): # On ESP32, V2 RSA requires minimum_chip_revision >= 3.0 # Note: string comparison works here because cv.one_of constrains # min_rev to known ESP32_CHIP_REVISIONS values ("0.0".."3.1"). - if scheme == "rsa3072" and (min_rev is None or min_rev < "3.0"): + if scheme == SIGNING_SCHEME_RSA3072 and ( + min_rev is None or min_rev < "3.0" + ): errs.append( cv.Invalid( f"Signing scheme 'rsa3072' on {VARIANT_FRIENDLY[variant]} " @@ -1520,7 +1529,7 @@ def final_validate(config): ) ) # ESP32 does not support V2 ECDSA (no SOC_SECURE_BOOT_V2_ECC) - elif scheme == "ecdsa256": + elif scheme == SIGNING_SCHEME_ECDSA256: errs.append( cv.Invalid( f"Signing scheme 'ecdsa256' is not supported on " @@ -1530,7 +1539,11 @@ def final_validate(config): ) ) # V1 on rev 3.0+ -- suggest V2 RSA for stronger security - elif scheme == "ecdsa_v1" and min_rev is not None and min_rev >= "3.0": + elif ( + scheme == SIGNING_SCHEME_ECDSA_V1 + and min_rev is not None + and min_rev >= "3.0" + ): _LOGGER.info( "Using Secure Boot V1 ECDSA on %s rev %s. " "Consider using 'rsa3072' (Secure Boot V2 RSA) for " @@ -1541,8 +1554,14 @@ def final_validate(config): else: # Non-ESP32 variants: check V2 scheme-variant compatibility scheme_variant_conflicts = { - "ecdsa256": (SIGNED_OTA_V2_RSA_ONLY_VARIANTS, "rsa3072"), - "rsa3072": (SIGNED_OTA_V2_ECC_ONLY_VARIANTS, "ecdsa256"), + SIGNING_SCHEME_ECDSA256: ( + SIGNED_OTA_V2_RSA_ONLY_VARIANTS, + SIGNING_SCHEME_RSA3072, + ), + SIGNING_SCHEME_RSA3072: ( + SIGNED_OTA_V2_ECC_ONLY_VARIANTS, + SIGNING_SCHEME_ECDSA256, + ), } if ( conflict := scheme_variant_conflicts.get(scheme) @@ -2703,7 +2722,9 @@ async def to_code(config): # n; the 4 KiB padding and reserved signature sector the verifier # depends on survive only because --secure-pad-v2 keys off # CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME (set below), not that symbol. - external_rsa = scheme == "rsa3072" and CONF_SIGNING_KEY not in signed_ota + external_rsa = ( + scheme == SIGNING_SCHEME_RSA3072 and CONF_SIGNING_KEY not in signed_ota + ) verification_keys = signed_ota.get(CONF_VERIFICATION_KEYS) # verification_keys is accepted only for external RSA (rsa3072 with no # signing_key), enforced in _validate_signed_ota_keys. Assert the From ba866853618d90a5989c1b84513923c7235b2123 Mon Sep 17 00:00:00 2001 From: Mustafa KURU Date: Thu, 6 Aug 2026 16:37:36 +0300 Subject: [PATCH 1279/1815] [const] Move CONF_TARGET_COUNT to components/const (#18120) --- esphome/components/const/__init__.py | 1 + esphome/components/ld2450/sensor.py | 2 +- esphome/components/rd03d/sensor.py | 3 +-- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index c4b66a8b9a..44878274d6 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -37,6 +37,7 @@ CONF_SCAN_PARAMETERS = "scan_parameters" CONF_SHA256 = "sha256" CONF_STATE_SAVE_INTERVAL = "state_save_interval" CONF_STOP_BITS = "stop_bits" +CONF_TARGET_COUNT = "target_count" CONF_USE_PSRAM = "use_psram" CONF_VOC_INDEX = "voc_index" CONF_VOLUME_INCREMENT = "volume_increment" diff --git a/esphome/components/ld2450/sensor.py b/esphome/components/ld2450/sensor.py index ce58cedf11..ae13900e7a 100644 --- a/esphome/components/ld2450/sensor.py +++ b/esphome/components/ld2450/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.components.const import CONF_TARGET_COUNT import esphome.config_validation as cv from esphome.const import ( CONF_ANGLE, @@ -21,7 +22,6 @@ DEPENDENCIES = ["ld2450"] CONF_MOVING_TARGET_COUNT = "moving_target_count" CONF_STILL_TARGET_COUNT = "still_target_count" -CONF_TARGET_COUNT = "target_count" ICON_ACCOUNT_GROUP = "mdi:account-group" ICON_ACCOUNT_SWITCH = "mdi:account-switch" diff --git a/esphome/components/rd03d/sensor.py b/esphome/components/rd03d/sensor.py index 4b4fcfd4e4..953d99c2da 100644 --- a/esphome/components/rd03d/sensor.py +++ b/esphome/components/rd03d/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.components.const import CONF_TARGET_COUNT import esphome.config_validation as cv from esphome.const import ( CONF_ANGLE, @@ -19,8 +20,6 @@ from . import CONF_RD03D_ID, RD03DComponent DEPENDENCIES = ["rd03d"] -CONF_TARGET_COUNT = "target_count" - MAX_TARGETS = 3 UNIT_MILLIMETER_PER_SECOND = "mm/s" From 73689f8d8b422ec5cf3361b3feb2c8587b876b6c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 6 Aug 2026 08:55:24 -0500 Subject: [PATCH 1280/1815] [rp2] Limit pin validation to GPIO 0-29 on RP2350A boards (#18102) --- esphome/components/rp2/boards.py | 86 +++++--------- esphome/components/rp2/generate_boards.py | 54 ++++++++- .../components/test_rp2_generate_boards.py | 107 +++++++++++++++++- 3 files changed, 183 insertions(+), 64 deletions(-) diff --git a/esphome/components/rp2/boards.py b/esphome/components/rp2/boards.py index 149a719121..d2502b8fb8 100644 --- a/esphome/components/rp2/boards.py +++ b/esphome/components/rp2/boards.py @@ -133,9 +133,7 @@ RP2_BOARD_PINS = { "RX": 1, "SCK": 22, "SCL": 3, - "SCL1": 31, "SDA": 2, - "SDA1": 31, "SS": 13, "TX": 0, }, @@ -146,9 +144,7 @@ RP2_BOARD_PINS = { "RX": 1, "SCK": 22, "SCL": 3, - "SCL1": 31, "SDA": 2, - "SDA1": 31, "SS": 21, "TX": 0, }, @@ -464,9 +460,7 @@ RP2_BOARD_PINS = { "RX": 13, "SCK": 18, "SCL": 21, - "SCL1": 31, "SDA": 20, - "SDA1": 31, "SS": 17, "TX": 12, }, @@ -477,9 +471,7 @@ RP2_BOARD_PINS = { "RX": 13, "SCK": 18, "SCL": 21, - "SCL1": 31, "SDA": 20, - "SDA1": 31, "SS": 17, "TX": 12, }, @@ -509,14 +501,10 @@ RP2_BOARD_PINS = { "LED": 29, "MISO": 20, "MOSI": 19, - "RX": 31, "SCK": 22, "SCL": 17, - "SCL1": 31, "SDA": 16, - "SDA1": 31, "SS": 21, - "TX": 31, }, "cytron_maker_nano_rp2040": { "LED": 2, @@ -954,31 +942,15 @@ RP2_BOARD_PINS = { "TX": 0, }, "pimoroni_plasma2040": {"LED": 16, "SCL": 21, "SDA": 20}, - "pimoroni_plasma2350": { - "LED": 16, - "MISO": 31, - "MOSI": 31, - "RX": 31, - "SCK": 31, - "SCL": 21, - "SCL1": 31, - "SDA": 20, - "SDA1": 31, - "SS": 31, - "TX": 31, - }, + "pimoroni_plasma2350": {"LED": 16, "SCL": 21, "SDA": 20}, "pimoroni_plasma2350w": { "LED": 16, "MISO": 24, "MOSI": 24, - "RX": 31, "SCK": 29, "SCL": 21, - "SCL1": 31, "SDA": 20, - "SDA1": 31, "SS": 25, - "TX": 31, }, "pimoroni_servo2040": {"LED": 18, "SCL": 21, "SDA": 20}, "pimoroni_tiny2040": { @@ -1221,9 +1193,7 @@ RP2_BOARD_PINS = { "RX": 19, "SCK": 14, "SCL": 21, - "SCL1": 31, "SDA": 20, - "SDA1": 31, "SS": 13, "TX": 18, }, @@ -1269,9 +1239,7 @@ RP2_BOARD_PINS = { "RX": 1, "SCK": 22, "SCL": 17, - "SCL1": 31, "SDA": 16, - "SDA1": 31, "SS": 21, "TX": 0, }, @@ -1294,9 +1262,7 @@ RP2_BOARD_PINS = { "RX": 1, "SCK": 2, "SCL": 7, - "SCL1": 31, "SDA": 6, - "SDA1": 31, "SS": 9, "TX": 0, }, @@ -1621,12 +1587,12 @@ BOARDS = { "adafruit_feather_rp2350_adalogger": { "name": "Adafruit Feather RP2350 Adalogger", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, }, "adafruit_feather_rp2350_hstx": { "name": "Adafruit Feather RP2350 HSTX", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, }, "adafruit_feather_scorpio": { "name": "Adafruit Feather RP2040 SCORPIO", @@ -1796,17 +1762,17 @@ BOARDS = { "challenger_2350_bconnect": { "name": "iLabs Challenger 2350 BConnect", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, }, "challenger_2350_nbiot": { "name": "iLabs Challenger 2350 NB-IoT", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, }, "challenger_2350_wifi6_ble5": { "name": "iLabs Challenger 2350 WiFi/BLE", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, }, "challenger_nb_2040_wifi": { "name": "iLabs Challenger NB 2040 WiFi", @@ -1821,7 +1787,7 @@ BOARDS = { "cytron_iriv_io_controller": { "name": "Cytron IRIV IO Controller", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, }, "cytron_maker_nano_rp2040": { "name": "Cytron Maker Nano RP2040", @@ -1841,7 +1807,7 @@ BOARDS = { "cytron_motion_2350_pro": { "name": "Cytron Motion 2350 Pro", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, }, "datanoisetv_picoadk": { "name": "DatanoiseTV PicoADK", @@ -1851,7 +1817,7 @@ BOARDS = { "datanoisetv_picoadk_v2": { "name": "DatanoiseTV PicoADK v2", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, }, "degz_suibo": { "name": "Degz Robotics Suibo RP2040", @@ -1906,7 +1872,7 @@ BOARDS = { "ilabs_cpico_2350": { "name": "iLabs CPico 2350", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, }, "ilabs_rpico32": { "name": "iLabs RPICO32", @@ -2028,12 +1994,12 @@ BOARDS = { "pimoroni_plasma2350": { "name": "Pimoroni Plasma2350", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, }, "pimoroni_plasma2350w": { "name": "Pimoroni Plasma2350W", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, "wifi": True, }, "pimoroni_servo2040": { @@ -2049,7 +2015,7 @@ BOARDS = { "pimoroni_tiny2350": { "name": "Pimoroni Tiny2350", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, }, "pintronix_pinmax": { "name": "Pintronix PinMax", @@ -2079,12 +2045,12 @@ BOARDS = { "rpipico2": { "name": "Raspberry Pi Pico 2", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, }, "rpipico2w": { "name": "Raspberry Pi Pico 2W", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, "wifi": True, "max_virtual_pin": 64, }, @@ -2118,7 +2084,7 @@ BOARDS = { "seeed_xiao_rp2350": { "name": "Seeed XIAO RP2350", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, }, "silicognition_rp2040_shim": { "name": "Silicognition RP2040-Shim", @@ -2144,7 +2110,7 @@ BOARDS = { "solderparty_rp2350_stamp": { "name": "Solder Party RP2350 Stamp", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, }, "solderparty_rp2350_stamp_xl": { "name": "Solder Party RP2350 Stamp XL", @@ -2154,7 +2120,7 @@ BOARDS = { "sparkfun_iotnode_lorawanrp2350": { "name": "SparkFun IoT Node LoRaWAN", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, }, "sparkfun_iotredboard_rp2350": { "name": "SparkFun IoT RedBoard RP2350", @@ -2175,7 +2141,7 @@ BOARDS = { "sparkfun_promicrorp2350": { "name": "SparkFun ProMicro RP2350", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, }, "sparkfun_thingplusrp2040": { "name": "SparkFun Thing Plus RP2040", @@ -2185,7 +2151,7 @@ BOARDS = { "sparkfun_thingplusrp2350": { "name": "SparkFun Thing Plus RP2350", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, "wifi": True, "max_virtual_pin": 64, }, @@ -2266,7 +2232,7 @@ BOARDS = { "waveshare_rp2350_lcd_0_96": { "name": "Waveshare RP2350 LCD 0.96", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, }, "waveshare_rp2350_pizero": { "name": "Waveshare RP2350 PiZero", @@ -2276,12 +2242,12 @@ BOARDS = { "waveshare_rp2350_plus": { "name": "Waveshare RP2350 Plus", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, }, "waveshare_rp2350_zero": { "name": "Waveshare RP2350 Zero", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, }, "waveshare_rp2350b_plus_w": { "name": "Waveshare RP2350B Plus W", @@ -2302,7 +2268,7 @@ BOARDS = { "wiznet_5100s_evb_pico2": { "name": "WIZnet W5100S-EVB-Pico2", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, }, "wiznet_5500_evb_pico": { "name": "WIZnet W5500-EVB-Pico", @@ -2312,7 +2278,7 @@ BOARDS = { "wiznet_5500_evb_pico2": { "name": "WIZnet W5500-EVB-Pico2", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, }, "wiznet_55rp20_evb_pico": { "name": "WIZnet W55RP20-EVB-Pico", @@ -2327,7 +2293,7 @@ BOARDS = { "wiznet_6300_evb_pico2": { "name": "WIZnet W6300-EVB-Pico2", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, }, "wiznet_wizfi360_evb_pico": { "name": "WIZnet WizFi360-EVB-Pico", diff --git a/esphome/components/rp2/generate_boards.py b/esphome/components/rp2/generate_boards.py index 33eb1b3058..5618287cce 100644 --- a/esphome/components/rp2/generate_boards.py +++ b/esphome/components/rp2/generate_boards.py @@ -34,11 +34,17 @@ CYW43_GPIO_COUNT = 3 # Max GPIO pin per MCU (hardware specs from datasheets) MCU_MAX_PIN = { "rp2040": 29, # GPIO 0-29 - "rp2350": 47, # GPIO 0-47 (RP2350A) + "rp2350": 47, # GPIO 0-47 (RP2350B; A-die boards are narrowed to 29 below) } DEFAULT_MAX_PIN = 29 +# The RP2350 comes in two die variants: RP2350A exposes GPIO 0-29, RP2350B +# GPIO 0-47. Variant headers declare the die via PICO_RP2350A (1 = A, 0 = B). +RP2350A_MAX_PIN = 29 PIN_DEFINE_RE = re.compile(r"#define\s+PIN_(\w+)\s+\((\d+)u\)") +# Accepts the literal forms seen in these headers: 1, (1), 1u, (1u) +RP2350A_DEFINE_RE = re.compile(r"#define\s+PICO_RP2350A\s+(\S+)") +RP2350A_MENU_PLACEHOLDER = "__PICO_RP2350A" def parse_variant_pins(variant_dir: Path) -> dict[str, int]: @@ -56,6 +62,40 @@ def parse_variant_pins(variant_dir: Path) -> dict[str, int]: return pins +def parse_variant_is_rp2350a(variant_dir: Path) -> bool: + """Return True if the variant declares an RP2350A die (GPIO 0-29 only). + + Generic boards leave the die a build-time menu choice (PICO_RP2350A is set + to a __PICO_RP2350A placeholder rather than a literal); those return False + so they keep the permissive B-die pin range. + + A missing or unrecognized define raises: silently treating it as B-die + would widen pin validation back to GPIO 47 on A-die boards, so a framework + bump that changes the header format must fail loudly here instead. + """ + header = variant_dir / "pins_arduino.h" + match = ( + RP2350A_DEFINE_RE.search(header.read_text(encoding="utf-8")) + if header.exists() + else None + ) + if match is None: + raise ValueError( + f"{header}: no PICO_RP2350A define found; cannot classify the " + "RP2350 die (A exposes GPIO 0-29, B exposes GPIO 0-47)" + ) + value = match.group(1) + if value == RP2350A_MENU_PLACEHOLDER: + return False + literal = value.strip("()u") + if not literal.isdigit(): + raise ValueError( + f"{header}: unrecognized PICO_RP2350A value {value!r}; cannot " + "classify the RP2350 die (A exposes GPIO 0-29, B exposes GPIO 0-47)" + ) + return int(literal) == 1 + + def load_boards(arduino_pico_path: Path) -> tuple[dict, dict]: """Load all board definitions and return (board_pins, boards) dicts.""" json_dir = arduino_pico_path / "tools" / "json" @@ -64,6 +104,7 @@ def load_boards(arduino_pico_path: Path) -> tuple[dict, dict]: board_pins = {} boards = {} variant_pins_cache: dict[str, dict[str, int]] = {} + variant_rp2350a_cache: dict[str, bool] = {} for json_file in sorted(json_dir.glob("*.json")): board_name = json_file.stem @@ -81,10 +122,19 @@ def load_boards(arduino_pico_path: Path) -> tuple[dict, dict]: extra_flags = build.get("extra_flags", "") has_wifi = "PICO_CYW43_SUPPORTED=1" in extra_flags + max_pin = MCU_MAX_PIN.get(mcu, DEFAULT_MAX_PIN) + if mcu == "rp2350": + if variant not in variant_rp2350a_cache: + variant_rp2350a_cache[variant] = parse_variant_is_rp2350a( + variants_dir / variant + ) + if variant_rp2350a_cache[variant]: + max_pin = RP2350A_MAX_PIN + board_entry: dict = { "name": display_name, "mcu": mcu, - "max_pin": MCU_MAX_PIN.get(mcu, DEFAULT_MAX_PIN), + "max_pin": max_pin, } if has_wifi: board_entry["wifi"] = True diff --git a/tests/unit_tests/components/test_rp2_generate_boards.py b/tests/unit_tests/components/test_rp2_generate_boards.py index 68bbada59b..c5d2214695 100644 --- a/tests/unit_tests/components/test_rp2_generate_boards.py +++ b/tests/unit_tests/components/test_rp2_generate_boards.py @@ -158,19 +158,122 @@ def test_load_basic_board(arduino_pico: Path) -> None: def test_load_rp2350_board(arduino_pico: Path) -> None: + """The Pico 2 uses the RP2350A die, which only exposes GPIO 0-29.""" _add_board( arduino_pico, "rpipico2", mcu="rp2350", vendor="Raspberry Pi", name="Pico 2", - pins_header=PICO_PINS_HEADER, + pins_header="#define PICO_RP2350A 1\n" + PICO_PINS_HEADER, ) _, boards = load_boards(arduino_pico) assert boards["rpipico2"]["mcu"] == "rp2350" - assert boards["rpipico2"]["max_pin"] == 47 + assert boards["rpipico2"]["max_pin"] == 29 + + +def test_rp2350_missing_die_define_raises(arduino_pico: Path) -> None: + """A variant without PICO_RP2350A cannot be classified; fail loudly.""" + _add_board( + arduino_pico, + "no_die_define", + mcu="rp2350", + pins_header=PICO_PINS_HEADER, + ) + + with pytest.raises(ValueError, match="no PICO_RP2350A define"): + load_boards(arduino_pico) + + +def test_rp2350_unrecognized_die_define_raises(arduino_pico: Path) -> None: + """An unparseable PICO_RP2350A value must not silently widen to B-die.""" + _add_board( + arduino_pico, + "hex_die_define", + mcu="rp2350", + pins_header="#define PICO_RP2350A 0x1\n" + PICO_PINS_HEADER, + ) + + with pytest.raises(ValueError, match="unrecognized PICO_RP2350A value"): + load_boards(arduino_pico) + + +def test_rp2350a_parenthesized_die_define(arduino_pico: Path) -> None: + """Literal forms like (1u) classify the same as bare 1.""" + _add_board( + arduino_pico, + "paren_die", + mcu="rp2350", + pins_header="#define PICO_RP2350A (1u)\n" + PICO_PINS_HEADER, + ) + + _, boards = load_boards(arduino_pico) + + assert boards["paren_die"]["max_pin"] == 29 + + +def test_rp2350b_board_keeps_max_pin_47(arduino_pico: Path) -> None: + """A variant declaring the RP2350B die keeps the full GPIO 0-47 range. + + The define uses extra whitespace, matching real variant headers. + """ + _add_board( + arduino_pico, + "weact_rp2350b", + mcu="rp2350", + pins_header="#define PICO_RP2350A 0 // RP2350B\n" + PICO_PINS_HEADER, + ) + + _, boards = load_boards(arduino_pico) + + assert boards["weact_rp2350b"]["max_pin"] == 47 + + +def test_rp2350_menu_selectable_die_keeps_max_pin_47(arduino_pico: Path) -> None: + """Generic boards leave the die a build-time choice; stay permissive.""" + _add_board( + arduino_pico, + "generic_rp2350", + mcu="rp2350", + pins_header="#define PICO_RP2350A __PICO_RP2350A\n" + PICO_PINS_HEADER, + ) + + _, boards = load_boards(arduino_pico) + + assert boards["generic_rp2350"]["max_pin"] == 47 + + +def test_rp2350a_pins_above_29_filtered(arduino_pico: Path) -> None: + """Pin defines beyond the A-die range are dropped from the pin map.""" + header = textwrap.dedent("""\ + #define PICO_RP2350A 1 + #define PIN_LED (25u) + #define PIN_SPI0_MISO (40u) + """) + _add_board(arduino_pico, "a_die", mcu="rp2350", pins_header=header) + + board_pins, _ = load_boards(arduino_pico) + + assert board_pins["a_die"]["LED"] == 25 + assert "MISO" not in board_pins["a_die"] + + +def test_rp2350a_board_keeps_cyw43_virtual_pins(arduino_pico: Path) -> None: + """A-die narrowing must not filter CYW43 virtual pins (64-66).""" + _add_board( + arduino_pico, + "rpipico2w", + mcu="rp2350", + pins_header="#define PICO_RP2350A 1\n" + PICOW_PINS_HEADER, + ) + + board_pins, boards = load_boards(arduino_pico) + + assert boards["rpipico2w"]["max_pin"] == 29 + assert boards["rpipico2w"]["max_virtual_pin"] == 64 + assert board_pins["rpipico2w"]["LED"] == 64 def test_cyw43_board_has_max_virtual_pin(arduino_pico: Path) -> None: From 3353e71f9a92651d1786e5aa4fa1da3fb1bee26d Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:34:32 +0200 Subject: [PATCH 1281/1815] [zigbee] Improve network handling on esp32 (2/3) (#18008) --- esphome/components/zigbee/zigbee_esp32.cpp | 52 ++++++++++++++++++---- esphome/components/zigbee/zigbee_esp32.h | 18 +++----- 2 files changed, 50 insertions(+), 20 deletions(-) diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index dcfdf2f3e1..3500aa3382 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -36,6 +36,17 @@ uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size) { return zcl_str; } +void ZigbeeComponent::factory_reset() { + esp_zigbee_lock_acquire(portMAX_DELAY); + if (this->joined_) { + // send leave request and trigger EZB_ZDO_SIGNAL_LEAVE + ezb_bdb_reset_via_local_action(); + } else { + esp_zigbee_factory_reset(); // triggers a reboot + } + esp_zigbee_lock_release(); +} + void ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_t mode) { if (!esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { global_zigbee->set_timeout("zb_init", 10, [mode]() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(mode); }); @@ -53,7 +64,7 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { switch (signal_type) { case EZB_ZDO_SIGNAL_SKIP_STARTUP: ESP_LOGD(TAG, "Zigbee stack initialized"); - global_zigbee->started = true; + global_zigbee->started_ = true; ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_INITIALIZATION); break; case EZB_BDB_SIGNAL_DEVICE_FIRST_START: @@ -62,12 +73,13 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { if (status == EZB_BDB_STATUS_SUCCESS) { ESP_LOGD(TAG, "Device started up in %sfactory-reset mode", ezb_bdb_is_factory_new() ? "" : "non "); if (ezb_bdb_is_factory_new()) { - global_zigbee->factory_new = true; + global_zigbee->factory_new_ = true; ESP_LOGD(TAG, "Start network steering"); ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_NETWORK_STEERING); } else { ESP_LOGD(TAG, "Device rebooted"); - global_zigbee->joined = true; + global_zigbee->joined_ = true; + global_zigbee->join_pending_ = true; global_zigbee->enable_loop_soon_any_context(); } } else { @@ -85,7 +97,8 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { ezb_nwk_get_extended_panid(&extended_pan_id); ESP_LOGD(TAG, "Joined network successfully: PAN ID(0x%04hx, EXT: 0x%llx), Channel(%d), Short Address(0x%04hx)", ezb_nwk_get_panid(), extended_pan_id.u64, ezb_nwk_get_current_channel(), ezb_nwk_get_short_address()); - global_zigbee->joined = true; + global_zigbee->joined_ = true; + global_zigbee->join_pending_ = true; global_zigbee->enable_loop_soon_any_context(); } else { ESP_LOGD(TAG, "Failed to join network with status(0x%02x)", status); @@ -105,7 +118,29 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { const ezb_zdo_signal_leave_params_t *leave_params = (const ezb_zdo_signal_leave_params_t *) ezb_app_signal_get_params(app_signal); if (leave_params->leave_type == EZB_ZDO_LEAVE_TYPE_RESET) { - esp_zigbee_factory_reset(); + esp_zigbee_factory_reset(); // triggers a reboot + } + global_zigbee->joined_ = false; + } break; + case EZB_NWK_SIGNAL_NETWORK_STATUS: { + const ezb_nwk_signal_network_status_params_t *network_status_params = + (const ezb_nwk_signal_network_status_params_t *) ezb_app_signal_get_params(app_signal); + if (network_status_params->status == EZB_NWK_NETWORK_STATUS_PARENT_LINK_FAILURE) { + global_zigbee->joined_ = false; + ESP_LOGW(TAG, "Parent link failure, attempting rejoin"); + ezb_zdo_nwk_mgmt_leave_req_t leave_req = { + .dst_nwk_addr = ezb_nwk_get_short_address(), + .field = + { + .remove_children = false, + .rejoin = true, + }, + }; + // Send leave request to the network to rejoin + // triggers EZB_ZDO_SIGNAL_LEAVE signal first, then EZB_BDB_SIGNAL_DEVICE_REBOOT + ezb_zdo_nwk_mgmt_leave_req(&leave_req); + } else { + ESP_LOGD(TAG, "Zigbee APP Signal NETWORK_STATUS: 0x%02x", network_status_params->status); } } break; default: @@ -303,10 +338,9 @@ void ZigbeeComponent::setup() { } void ZigbeeComponent::loop() { - if (!this->join_reported_ && this->joined) { - this->join_reported_ = true; - this->join_cb_.call(this->factory_new); - this->factory_new = false; + if (this->join_pending_.exchange(false)) { + this->join_cb_.call(this->factory_new_); + this->factory_new_ = false; } this->disable_loop(); } diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index 986ffea449..412b7ac4da 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -54,11 +54,7 @@ class ZigbeeComponent final : public Component { static bool app_signal_handler(const ezb_app_signal_t *app_signal); static void esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_t mode); - void factory_reset() { - esp_zigbee_lock_acquire(portMAX_DELAY); - esp_zigbee_factory_reset(); // triggers a reboot - esp_zigbee_lock_release(); - } + void factory_reset(); template void add_on_join_callback(F &&cb) { this->join_cb_.add(std::forward(cb)); } @@ -66,13 +62,10 @@ class ZigbeeComponent final : public Component { // True after the Zigbee stack has been initialized and the device has started up. Is set before the stack started // network commissioning or has joined a network and won't be reset until the device is rebooted. - bool is_started() { return this->started; } + bool is_started() { return this->started_; } // True if the device has joined a network and is ready to send and receive messages. - bool is_joined() { return this->joined; } - std::atomic started = false; - std::atomic joined = false; - std::atomic factory_new = false; + bool is_joined() { return this->joined_; } protected: struct { @@ -95,8 +88,11 @@ class ZigbeeComponent final : public Component { // key tuple could be replaced by single 64 (48) bit int with bit fields for endpoint, cluster, role and attr_id std::map, ZigbeeAttribute *> attributes_; ezb_af_device_desc_t dev_desc_; - bool join_reported_{false}; CallbackManager join_cb_{}; + std::atomic started_ = false; + std::atomic joined_ = false; + std::atomic join_pending_ = false; + std::atomic factory_new_ = false; }; template From abc13a7c0a512a52131b728794ff862b9cd6d470 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 6 Aug 2026 10:00:46 -0500 Subject: [PATCH 1282/1815] [script] Disable queued script polling while the queue is empty (#18121) --- esphome/components/script/script.h | 12 +++ tests/integration/fixtures/script_queued.yaml | 36 ++++++++ .../fixtures/script_queued_idle_loop.yaml | 25 ++++++ tests/integration/test_script_queued.py | 57 +++++++++++-- .../test_script_queued_idle_loop.py | 85 +++++++++++++++++++ 5 files changed, 210 insertions(+), 5 deletions(-) create mode 100644 tests/integration/fixtures/script_queued_idle_loop.yaml create mode 100644 tests/integration/test_script_queued_idle_loop.py diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 790ac107c5..63d0ff7cb3 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -142,6 +142,9 @@ template class QueueingScript : public Script, public Com // Use std::make_unique to replace the unique_ptr this->var_queue_[write_pos] = std::make_unique>(x...); this->num_queued_++; + // Enable loop now that there is something to dequeue - don't call loop() + // synchronously! Let the event loop call it to avoid reentrancy issues + this->enable_loop(); return; } @@ -168,6 +171,15 @@ template class QueueingScript : public Script, public Com this->queue_front_ = (this->queue_front_ + 1) % queue_capacity; this->trigger_tuple_(*tuple_ptr, std::make_index_sequence{}); } + if (this->num_queued_ == 0 && !this->is_idle()) { + // Queue is now empty - disable loop until the next execute() queues an + // instance. The inline is_idle() check skips the out-of-line call when + // the loop is already disabled (execute() calls loop() synchronously). + // This can run before this component's setup() (execute() from on_boot), + // which leaves the state machine in LOOP_DONE and skips call_setup(); + // this class therefore must not rely on a setup() override. + this->disable_loop(); + } } void set_max_runs(int max_runs) { max_runs_ = max_runs; } diff --git a/tests/integration/fixtures/script_queued.yaml b/tests/integration/fixtures/script_queued.yaml index 996dd6436f..c8c56113db 100644 --- a/tests/integration/fixtures/script_queued.yaml +++ b/tests/integration/fixtures/script_queued.yaml @@ -1,5 +1,17 @@ esphome: name: test-script-queued + on_boot: + # Default priority (600.0) runs before the script component is set up + # This tests that an instance queued during boot still gets dequeued + # once the main loop starts (the idle-loop disabling must not eat it) + then: + - logger.log: "=== BOOT: Executing queued script twice ===" + - script.execute: + id: boot_script + tag: 1 + - script.execute: + id: boot_script + tag: 2 host: api: @@ -98,6 +110,15 @@ api: - script.execute: no_params_script - script.execute: no_params_script + # Test 6: Re-execute after stop() cleared the queue + # (the idle loop must re-enable on demand) + - action: test_after_stop + then: + - logger.log: "=== TEST 6: Re-execute after stop ===" + - script.execute: + id: stop_script + num: 9 + logger: level: DEBUG @@ -168,3 +189,18 @@ script: - logger.log: "No params: START" - delay: 50ms - logger.log: "No params: END" + + # Boot script: executed twice from on_boot before setup() + - id: boot_script + mode: queued + max_runs: 3 + parameters: + tag: int + then: + - logger.log: + format: "Boot queued: START %d" + args: ['tag'] + - delay: 50ms + - logger.log: + format: "Boot queued: END %d" + args: ['tag'] diff --git a/tests/integration/fixtures/script_queued_idle_loop.yaml b/tests/integration/fixtures/script_queued_idle_loop.yaml new file mode 100644 index 0000000000..7d5d3cb86f --- /dev/null +++ b/tests/integration/fixtures/script_queued_idle_loop.yaml @@ -0,0 +1,25 @@ +esphome: + name: test-script-queued-idle + +host: +api: + actions: + # Execute twice: the first runs immediately, the second gets queued, + # which must re-enable the loop; draining must disable it again + - action: run_twice + then: + - script.execute: idle_script + - script.execute: idle_script + +# VERY_VERBOSE exposes the component framework's "loop disabled" and +# "loop enabled" messages that this test asserts on +logger: + level: VERY_VERBOSE + +script: + - id: idle_script + mode: queued + then: + - logger.log: "idle_script: START" + - delay: 50ms + - logger.log: "idle_script: END" diff --git a/tests/integration/test_script_queued.py b/tests/integration/test_script_queued.py index 84c7f950b6..db4621a507 100644 --- a/tests/integration/test_script_queued.py +++ b/tests/integration/test_script_queued.py @@ -26,6 +26,7 @@ async def test_script_queued( "stop": {"processed": [], "stop_logged": False}, "rejection": {"processed": [], "rejections": 0}, "no_params": {"executions": 0}, + "boot": {"ended": []}, } # Patterns for Test 1: Queue depth @@ -49,12 +50,21 @@ async def test_script_queued( # Patterns for Test 5: No params no_params_end = re.compile(r"No params: END") + # Patterns for boot script (executed twice from on_boot before setup) + boot_end = re.compile(r"Boot queued: END (\d+)") + + # Patterns for Test 6: Re-execute after stop + after_stop_end = re.compile(r"Stop test: END (\d+)") + # Test completion futures + boot_complete = loop.create_future() test1_complete = loop.create_future() test2_complete = loop.create_future() test3_complete = loop.create_future() test4_complete = loop.create_future() test5_complete = loop.create_future() + test5_again_complete = loop.create_future() + test6_complete = loop.create_future() def check_output(line: str) -> None: """Check log output for all test messages.""" @@ -122,11 +132,24 @@ async def test_script_queued( # Test 5: No params if no_params_end.search(line): test_results["no_params"]["executions"] += 1 - if ( - test_results["no_params"]["executions"] == 3 - and not test5_complete.done() - ): - test5_complete.set_result(True) + executions = test_results["no_params"]["executions"] + for count, future in ((3, test5_complete), (6, test5_again_complete)): + if executions == count and not future.done(): + future.set_result(True) + + # Boot script (queued from on_boot before setup) + if match := boot_end.search(line): + test_results["boot"]["ended"].append(int(match.group(1))) + if len(test_results["boot"]["ended"]) == 2 and not boot_complete.done(): + boot_complete.set_result(True) + + # Test 6: Re-execute after stop + if ( + (match := after_stop_end.search(line)) + and int(match.group(1)) == 9 + and not test6_complete.done() + ): + test6_complete.set_result(True) async with ( run_compiled(yaml_config, line_callback=check_output), @@ -135,6 +158,13 @@ async def test_script_queued( # Get services _, services = await client.list_entities_services() + # Boot: both executions from on_boot must complete, including the one + # that was queued before QueueingScript::setup() ran + await asyncio.wait_for(boot_complete, timeout=2.0) + assert sorted(test_results["boot"]["ended"]) == [1, 2], ( + f"Boot: Expected both on_boot executions to complete, got {sorted(test_results['boot']['ended'])}" + ) + # Test 1: Queue depth limit test_service = next((s for s in services if s.name == "test_queue_depth"), None) assert test_service is not None, "test_queue_depth service not found" @@ -203,3 +233,20 @@ async def test_script_queued( assert test_results["no_params"]["executions"] == 3, ( f"Test 5: Expected 3 executions, got {test_results['no_params']['executions']}" ) + + # Test 5 again: after the queue fully drained (loop disabled while + # idle), executing again must still work + test_service = next((s for s in services if s.name == "test_no_params"), None) + assert test_service is not None, "test_no_params service not found" + await client.execute_service(test_service, {}) + await asyncio.wait_for(test5_again_complete, timeout=2.0) + assert test_results["no_params"]["executions"] == 6, ( + f"Test 5 again: Expected 6 executions total, got {test_results['no_params']['executions']}" + ) + + # Test 6: a stopped script (queue cleared, loop disabled) must run + # again on the next execute; the future resolves only on "END 9" + test_service = next((s for s in services if s.name == "test_after_stop"), None) + assert test_service is not None, "test_after_stop service not found" + await client.execute_service(test_service, {}) + await asyncio.wait_for(test6_complete, timeout=2.0) diff --git a/tests/integration/test_script_queued_idle_loop.py b/tests/integration/test_script_queued_idle_loop.py new file mode 100644 index 0000000000..44f0ab7ec6 --- /dev/null +++ b/tests/integration/test_script_queued_idle_loop.py @@ -0,0 +1,85 @@ +"""Test that an idle queued script disables its loop and re-enables on demand.""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_script_queued_idle_loop( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Assert the loop state transitions of a queued script via VV logs. + + Expected sequence: the idle script disables its loop on the first + iteration after boot, re-enables it when an instance gets queued, + and disables it again once the queue drains. + """ + loop = asyncio.get_running_loop() + + loop_state = re.compile(r"\bscript loop (disabled|enabled)\b") + script_end = re.compile(r"idle_script: END") + + transitions: list[str] = [] + end_count = 0 + + boot_disabled = loop.create_future() + enabled_after_queue = loop.create_future() + disabled_after_drain = loop.create_future() + runs_complete = loop.create_future() + + def check_output(line: str) -> None: + nonlocal end_count + if match := loop_state.search(line): + transitions.append(match.group(1)) + if transitions == ["disabled"] and not boot_disabled.done(): + boot_disabled.set_result(True) + elif ( + transitions == ["disabled", "enabled"] + and not enabled_after_queue.done() + ): + enabled_after_queue.set_result(True) + elif ( + transitions + == [ + "disabled", + "enabled", + "disabled", + ] + and not disabled_after_drain.done() + ): + disabled_after_drain.set_result(True) + + if script_end.search(line): + end_count += 1 + if end_count == 2 and not runs_complete.done(): + runs_complete.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # The idle script must disable its loop on the first iteration + await asyncio.wait_for(boot_disabled, timeout=5.0) + + _, services = await client.list_entities_services() + run_twice = next((s for s in services if s.name == "run_twice"), None) + assert run_twice is not None, "run_twice service not found" + await client.execute_service(run_twice, {}) + + # Queueing the second instance must re-enable the loop + await asyncio.wait_for(enabled_after_queue, timeout=2.0) + # Both runs must complete and the drained queue must disable it again + await asyncio.wait_for(runs_complete, timeout=2.0) + await asyncio.wait_for(disabled_after_drain, timeout=2.0) + + assert transitions == ["disabled", "enabled", "disabled"], ( + f"Unexpected loop state sequence: {transitions}" + ) From 3221ed2bad34532905f46d6b5679fa776b4838d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Thu, 6 Aug 2026 20:13:30 +0300 Subject: [PATCH 1283/1815] [ble_device_base] Hub provider registry and shared consumer helpers (#18081) Co-authored-by: J. Nick Koston --- .../components/bk72xx_ble_tracker/__init__.py | 2 + .../components/ble_device_base/__init__.py | 139 ++++++++++-- .../components/ble_device_base/ble_device.cpp | 52 ++++- .../components/ble_device_base/ble_device.h | 18 +- .../components/esp32_ble_tracker/__init__.py | 2 + .../esp32_ble_tracker/esp32_ble_tracker.cpp | 2 + .../components/ln882h_ble_tracker/__init__.py | 2 + .../components/rp2_ble_tracker/__init__.py | 2 + .../ble_device_base/test_hub_binding.py | 204 ++++++++++++++++++ .../ble_device_base/test_ibeacon.cpp | 158 ++++++++++++++ 10 files changed, 553 insertions(+), 28 deletions(-) create mode 100644 tests/component_tests/ble_device_base/test_hub_binding.py create mode 100644 tests/components/ble_device_base/test_ibeacon.cpp diff --git a/esphome/components/bk72xx_ble_tracker/__init__.py b/esphome/components/bk72xx_ble_tracker/__init__.py index f10fe8ab6e..e7f8ed92ba 100644 --- a/esphome/components/bk72xx_ble_tracker/__init__.py +++ b/esphome/components/bk72xx_ble_tracker/__init__.py @@ -44,6 +44,8 @@ DEPENDENCIES = ["bk72xx"] AUTO_LOAD = ["ble_device_base", "bk72xx_ble"] CODEOWNERS = ["@Bl00d-B0b"] +ble_device_base.register_hub_provider("bk72xx_ble_tracker") + bk72xx_ble_tracker_ns = cg.esphome_ns.namespace("bk72xx_ble_tracker") BK72xxBLETracker = bk72xx_ble_tracker_ns.class_( "BK72xxBLETracker", ble_device_base.BLEHub, cg.Component diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index b10e535e7d..ad162c616d 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -8,21 +8,33 @@ ESPBLEiBeacon / ESPBTDeviceListener, in ble_device.h) and the tracker contract BLE consumers (sensor components, bluetooth_proxy) bind to whichever tracker the configuration declares via `cv.use_id(BLEHub)` — ESPHome resolves any declared subclass, so there is no platform table here and no dependency in either -direction. A sensor appends inject_ble_hub to its CONFIG_SCHEMA (via cv.All) and -calls register_ble_device() in to_code; a tracker component subclasses BLEHub -(C++ and codegen class). Adding a new BLE chip requires only a new tracker -component. +direction. A sensor extends BLE_DEVICE_SCHEMA in its CONFIG_SCHEMA (so an +explicit ble_hub_id: is a declared key even on strict schemas) and calls +register_ble_device() in to_code; a tracker component subclasses BLEHub (C++ +and codegen class) and MUST call register_hub_provider() at import time — +without it _require_hub rejects configs that bind through the generated id +(an explicit ble_hub_id: bypasses the registry). Adding a new BLE chip +requires only a new in-tree tracker component; out-of-tree BLE hubs are +not supported. AES-CCM decryption for encrypted advertisements is provided portably in ble_aes_ccm.h. """ +from collections.abc import Callable import re import esphome.codegen as cg from esphome.components.const import CONF_WINDOW import esphome.config_validation as cv -from esphome.const import CONF_ACTIVE, CONF_CONTINUOUS, CONF_DURATION, CONF_INTERVAL +from esphome.const import ( + CONF_ACTIVE, + CONF_CONTINUOUS, + CONF_DURATION, + CONF_INTERVAL, + KEY_TARGET_PLATFORM, +) +from esphome.core import CORE, ID, KEY_CORE from esphome.types import ConfigType CODEOWNERS = ["@Bl00d-B0b"] @@ -44,17 +56,92 @@ BLEHub = ble_device_base_ns.class_("BLEHub") ESPBTDeviceListener = ble_device_base_ns.class_("ESPBTDeviceListener") -def inject_ble_hub(config: ConfigType) -> ConfigType: - """Validator: auto-resolve the configured BLE tracker into the config. +# Config keys that provide a BLEHub, registered by each tracker component at +# import time (a tracker's module is imported iff it can end up in the build). +# Used only to phrase an actionable error when a BLE consumer is configured +# without any tracker — the binding itself resolves any BLEHub subclass and +# needs no platform table. Out-of-tree BLE hubs are not supported; the +# registry and the messages below deal in in-tree trackers only. +_HUB_PROVIDERS: set[str] = set() - Append via cv.All to a BLE consumer's CONFIG_SCHEMA. Uses cv.GenerateID + - cv.use_id(BLEHub): an omitted id resolves to the single declared tracker on - any platform; multiple trackers can be disambiguated with an explicit - ble_hub_id. - """ - return cv.Schema( - {cv.GenerateID(CONF_BLE_HUB_ID): cv.use_id(BLEHub)}, extra=cv.ALLOW_EXTRA - )(config) +# The in-tree trackers per target platform, so the missing-tracker error names +# them even in a fresh process where no tracker module has been imported yet (a +# consumer imports only ble_device_base, so the registry is empty exactly in +# the most common failure: the tracker was simply forgotten). Filtered by the +# current platform so an esp32 config is not told to add a Beken tracker; an +# unknown/absent platform falls back to every in-tree name. +_IN_TREE_HUB_PROVIDERS: dict[str, str] = { + "esp32": "esp32_ble_tracker", + "bk72xx": "bk72xx_ble_tracker", + "rp2": "rp2_ble_tracker", + "ln882x": "ln882h_ble_tracker", +} + + +def register_hub_provider(component: str) -> None: + """Called at import time by every component whose config key declares a BLEHub.""" + _HUB_PROVIDERS.add(component) + + +def _require_hub(value: ID) -> ID: + # Without this check a missing tracker surfaces at ID resolution as + # "Couldn't find any component that can be used for 'ble_device_base::BLEHub'" + # — a C++ class name the user never types. Component final validation cannot + # phrase it better: the ID pass runs first and its error skips all later + # steps. All explicitly configured components are loaded before any schema + # validates, so a registered provider in loaded_integrations is exact here. + if value.id is not None: + # Explicit ble_hub_id: — the user is pointing at a specific hub (the + # multi-hub disambiguation case). Let the ID pass judge it; its error + # names the missing id, which is accurate. + return value + if not _HUB_PROVIDERS & CORE.loaded_integrations: + # Defensive lookup rather than CORE.target_platform: the property + # raises when no platform is registered, and this message must never + # be the thing that crashes. In a real run the platform is always set + # (LoadTargetPlatformValidationStep runs before any other domain), so + # the unfiltered all-platforms fallback is reachable only from tests. + platform = CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) + if platform is not None and platform not in _IN_TREE_HUB_PROVIDERS: + # Known platform with no in-tree hub (esp8266, host, rtl87xx, …): + # listing the other platforms' trackers would misdirect, and + # out-of-tree BLE hubs are not supported. + raise cv.Invalid( + f"No BLE tracker exists for {platform}; BLE components are " + "not supported on this platform" + ) + in_tree = ( + {tracker} + if (tracker := _IN_TREE_HUB_PROVIDERS.get(platform)) + else set(_IN_TREE_HUB_PROVIDERS.values()) + ) + # in_tree only: _HUB_PROVIDERS is import-time state that outlives + # CORE.reset() in a long-lived process (dashboard), so a tracker from + # an earlier build of another platform must not leak into the message. + # The gate above is immune — loaded_integrations resets per run. + names = ", ".join(sorted(in_tree)) + raise cv.Invalid(f"No BLE tracker configured — add one of: {names}") + return value + + +# Schema fragment binding a consumer to the configured BLE tracker: extend a +# consumer's CONFIG_SCHEMA with this so ble_hub_id: is a declared key — a +# trailing validator after a PREVENT_EXTRA schema would reject the explicit +# form before ever running. An omitted id resolves to the single declared +# tracker on any platform; multiple trackers are disambiguated with an +# explicit ble_hub_id. +BLE_DEVICE_SCHEMA = cv.Schema( + {cv.GenerateID(CONF_BLE_HUB_ID): cv.All(cv.use_id(BLEHub), _require_hub)} +) + + +def rename_legacy_hub_id(component: str) -> Callable[[ConfigType], ConfigType]: + """Transitional alias for the pre-migration binding key: esp32_ble_id -> + ble_hub_id. Warns and auto-migrates until removal; every migrated platform + prepends this to its CONFIG_SCHEMA so existing configs keep validating.""" + return cv.rename_key( + "esp32_ble_id", CONF_BLE_HUB_ID, removed_in="2027.2.0", component=component + ) def request_irk_support() -> None: @@ -217,3 +304,25 @@ def as_hex_array(value: str) -> cg.RawExpression: def as_reversed_hex_array(value: str) -> cg.RawExpression: return _hex_array_expression(value, reverse=True) + + +def add_service_uuid(var: cg.MockObj, service_uuid: str) -> None: + """Emit the width-matched service-UUID setter for a consumer. + + 16-/32-bit UUIDs go out as plain hex literals, 128-bit as a reversed byte + array (BLE wire order). Shared here so every sensor platform dispatches the + same way instead of carrying its own if/elif copy. + """ + if len(service_uuid) == len(BT_UUID16_FORMAT): + cg.add(var.set_service_uuid16(as_hex(service_uuid))) + elif len(service_uuid) == len(BT_UUID32_FORMAT): + cg.add(var.set_service_uuid32(as_hex(service_uuid))) + elif len(service_uuid) == len(BT_UUID128_FORMAT): + cg.add(var.set_service_uuid128(as_reversed_hex_array(service_uuid))) + else: + # bt_uuid restricts lengths to exactly these three formats; if that + # ever loosens, fail the build instead of emitting no setter (a + # sensor whose match_by_ is unset silently never matches). ValueError, + # not cv.Invalid: this runs from to_code, after validation, where + # voluptuous errors surface as raw tracebacks. + raise ValueError(f"Unsupported UUID format: {service_uuid}") diff --git a/esphome/components/ble_device_base/ble_device.cpp b/esphome/components/ble_device_base/ble_device.cpp index 9256270b7a..c2a43ee7e9 100644 --- a/esphome/components/ble_device_base/ble_device.cpp +++ b/esphome/components/ble_device_base/ble_device.cpp @@ -8,6 +8,7 @@ #include "ble_aes_ccm.h" #include "esphome/core/defines.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -265,16 +266,21 @@ bool ESPBTUUID::operator==(const ESPBTUUID &other) const { ESPBLEiBeacon::ESPBLEiBeacon(const uint8_t *data) { memcpy(&this->beacon_data_, data, sizeof(this->beacon_data_)); } -optional ESPBLEiBeacon::from_manufacturer_data(const ServiceData &data) { +optional ESPBLEiBeacon::from_manufacturer_data(const ServiceData &data, bool *prefix_rejected) { // iBeacon manufacturer specific data (after company-ID bytes have been stripped): // [0x02][0x15][16-byte UUID][2-byte major][2-byte minor][1-byte power] = exactly 23 bytes - // Parity with esp32_ble_tracker: gate on the Apple company ID and length only. - // (Checking the 0x02/0x15 sub-type prefix would be stricter, but is a behavior - // change; it belongs to a follow-up, not this refactor.) if (!data.uuid.contains(0x4C, 0x00)) // Apple company ID 0x004C return {}; if (data.data.size() != 23) return {}; + // Require the iBeacon sub-type/length prefix — stricter than the legacy + // esp32 parser, which accepted any 23-byte Apple payload and surfaced + // non-iBeacon frames as garbage beacons. + if (data.data[0] != 0x02 || data.data[1] != 0x15) { + if (prefix_rejected != nullptr) + *prefix_rejected = true; + return {}; + } return ESPBLEiBeacon(data.data.data()); } @@ -282,6 +288,44 @@ optional ESPBLEiBeacon::from_manufacturer_data(const ServiceData // ESPBTDevice // --------------------------------------------------------------------------- +optional ESPBTDevice::get_ibeacon() const { + bool prefix_rejected = false; + uint8_t rejected_sub_type = 0; + uint8_t rejected_len = 0; + for (const auto &it : this->manufacturer_datas_) { + bool rejected = false; + auto res = ESPBLEiBeacon::from_manufacturer_data(it, &rejected); + if (res.has_value()) + return res; + if (rejected && !prefix_rejected) { + prefix_rejected = true; + rejected_sub_type = it.data[0]; + rejected_len = it.data[1]; + } + } + if (prefix_rejected) { + // Only when no beacon was found at all: these frames were accepted before + // the prefix check, so their disappearance must be observable at the + // default log level. Throttled so a chatty non-iBeacon Apple advertiser + // cannot flood the log; a different address may bypass the shared window + // so that advertiser cannot mask the device that actually regressed — but + // with a 1 s floor, or two alternating advertisers log every frame. + static uint32_t last_log = 0; + static uint64_t last_addr = 0; + const uint32_t now = millis(); + const uint64_t addr = this->address_uint64(); + const uint32_t since = now - last_log; + if (last_log == 0 || since > 60000 || (addr != last_addr && since > 1000)) { + last_log = now; + last_addr = addr; + char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGD(TAG, "%s: 23-byte Apple frame without iBeacon prefix ignored (sub-type 0x%02X len 0x%02X)", + this->address_str_to(addr_buf), rejected_sub_type, rejected_len); + } + } + return {}; +} + const char *ESPBTDevice::address_type_str() const { switch (this->address_type_) { case BLE_ADDR_TYPE_PUBLIC: diff --git a/esphome/components/ble_device_base/ble_device.h b/esphome/components/ble_device_base/ble_device.h index e340bf673c..0f40c51b29 100644 --- a/esphome/components/ble_device_base/ble_device.h +++ b/esphome/components/ble_device_base/ble_device.h @@ -129,7 +129,12 @@ class ESPBLEiBeacon { public: ESPBLEiBeacon() { memset(&this->beacon_data_, 0, sizeof(this->beacon_data_)); } explicit ESPBLEiBeacon(const uint8_t *data); - static optional from_manufacturer_data(const ServiceData &data); + /// prefix_rejected: caller must initialise to false; set to true ONLY when a + /// 23-byte Apple frame was refused for lacking the 0x02/0x15 iBeacon prefix — + /// the case the legacy esp32 parser accepted. Never written on accept or on + /// the non-Apple/wrong-size rejects. The caller with the device address does + /// the logging (see ESPBTDevice::get_ibeacon()). + static optional from_manufacturer_data(const ServiceData &data, bool *prefix_rejected = nullptr); uint16_t get_major() const { return byteswap(this->beacon_data_.major); } uint16_t get_minor() const { return byteswap(this->beacon_data_.minor); } @@ -193,6 +198,8 @@ class ESPBTDevice { // Historical esp32 signature: consumers assign the result to esp_ble_addr_type_t. esp_ble_addr_type_t get_address_type() const { return static_cast(this->address_type_); } /// Historical esp32 ingest (esp32 builds only): parse an ESP-IDF scan result. + /// Prefer ESPBTDevice::from_scan_result(); deprecation is a follow-up pending + /// consumer feedback on the raw scan-result fields. void parse_scan_rst(const esp32_ble::BLEScanResult &scan_result); // Exposed through a function for use in lambdas const esp32_ble::BLEScanResult &get_scan_result() const { return *scan_result_; } @@ -218,14 +225,7 @@ class ESPBTDevice { /// decryptor; compiled only when a sensor configures irk: (request_irk_support). bool resolve_irk(const uint8_t *irk) const; - optional get_ibeacon() const { - for (const auto &it : this->manufacturer_datas_) { - auto res = ESPBLEiBeacon::from_manufacturer_data(it); - if (res.has_value()) - return res; - } - return {}; - } + optional get_ibeacon() const; protected: void parse_adv_(const uint8_t *payload, uint16_t len); diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index b45d4a48be..b8f49d4fbd 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -43,6 +43,8 @@ AUTO_LOAD = ["ble_device_base", "esp32_ble"] DEPENDENCIES = ["esp32"] CODEOWNERS = ["@bdraco"] +ble_device_base.register_hub_provider("esp32_ble_tracker") + CONF_ESP32_BLE_ID = "esp32_ble_id" CONF_SOFTWARE_COEXISTENCE = "software_coexistence" diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 372af5e89a..bae4e1634d 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -501,6 +501,8 @@ void ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) { if (this->parse_advertisements_) { #ifdef USE_ESP32_BLE_DEVICE ESPBTDevice device; + // The historical ingest keeps the raw scan-result fields populated for + // external components. device.parse_scan_rst(scan_result); bool found = false; diff --git a/esphome/components/ln882h_ble_tracker/__init__.py b/esphome/components/ln882h_ble_tracker/__init__.py index 8e958ff9f0..ceb2aeffec 100644 --- a/esphome/components/ln882h_ble_tracker/__init__.py +++ b/esphome/components/ln882h_ble_tracker/__init__.py @@ -29,6 +29,8 @@ DEPENDENCIES = ["ln882x"] AUTO_LOAD = ["ble_device_base", "ln882h_ble"] CODEOWNERS = ["@Bl00d-B0b"] +ble_device_base.register_hub_provider("ln882h_ble_tracker") + ln882h_ble_tracker_ns = cg.esphome_ns.namespace("ln882h_ble_tracker") LN882HBLETracker = ln882h_ble_tracker_ns.class_( "LN882HBLETracker", ble_device_base.BLEHub, cg.Component diff --git a/esphome/components/rp2_ble_tracker/__init__.py b/esphome/components/rp2_ble_tracker/__init__.py index 651307fc5b..cfdd78f729 100644 --- a/esphome/components/rp2_ble_tracker/__init__.py +++ b/esphome/components/rp2_ble_tracker/__init__.py @@ -28,6 +28,8 @@ DEPENDENCIES = ["rp2"] AUTO_LOAD = ["ble_device_base", "rp2040_ble"] CODEOWNERS = ["@bdraco"] +ble_device_base.register_hub_provider("rp2_ble_tracker") + rp2_ble_tracker_ns = cg.esphome_ns.namespace("rp2_ble_tracker") RP2BLETracker = rp2_ble_tracker_ns.class_( "RP2BLETracker", ble_device_base.BLEHub, cg.Component diff --git a/tests/component_tests/ble_device_base/test_hub_binding.py b/tests/component_tests/ble_device_base/test_hub_binding.py new file mode 100644 index 0000000000..3dc71daf58 --- /dev/null +++ b/tests/component_tests/ble_device_base/test_hub_binding.py @@ -0,0 +1,204 @@ +"""Tests for the BLE hub provider registry and the missing-hub diagnostics.""" + +from collections.abc import Generator +from importlib import import_module +from pathlib import Path + +import pytest + +import esphome.codegen as cg +from esphome.components import ble_device_base +import esphome.config_validation as cv +from esphome.const import KEY_TARGET_PLATFORM, Platform +from esphome.core import CORE, ID, KEY_CORE +from esphome.cpp_generator import MockObjClass + +COMPONENTS_DIR = Path(ble_device_base.__file__).parent.parent + + +@pytest.fixture +def hub_registry() -> Generator[set[str]]: + """Save/restore _HUB_PROVIDERS — a module global with no reset hook. + + CORE state needs no bookkeeping here: conftest's autouse reset_core + fixture reassigns it after every test. + """ + saved = set(ble_device_base._HUB_PROVIDERS) + yield ble_device_base._HUB_PROVIDERS + ble_device_base._HUB_PROVIDERS.clear() + ble_device_base._HUB_PROVIDERS.update(saved) + + +def _generated_id() -> ID: + """An ID as cv.GenerateID leaves it before the ID-assignment pass.""" + return ID(None, is_declaration=False, type="ble_device_base::BLEHub") + + +def _set_platform(platform: str | None) -> None: + core_data = CORE.data.setdefault(KEY_CORE, {}) + if platform is None: + core_data.pop(KEY_TARGET_PLATFORM, None) + else: + core_data[KEY_TARGET_PLATFORM] = platform + + +# The missing-hub diagnostics: one test per path so a regression in one +# scenario cannot mask the others. The hub binding must fail with a +# tracker-naming message, not use_id's C++-class error, regardless of +# config-step ordering internals. + + +def test_empty_registry_names_every_in_tree_tracker(hub_registry: set[str]) -> None: + # The common failure: a fresh CLI process where the tracker was simply + # forgotten, so no tracker module was ever imported and the registry is + # empty. The error must still name the in-tree trackers. + hub_registry.clear() + CORE.loaded_integrations.clear() + _set_platform(None) + with pytest.raises( + cv.Invalid, + match="add one of: bk72xx_ble_tracker, esp32_ble_tracker, ln882h_ble_tracker, rp2_ble_tracker", + ): + ble_device_base._require_hub(_generated_id()) + + +def test_platform_filters_the_suggested_trackers(hub_registry: set[str]) -> None: + hub_registry.clear() + CORE.loaded_integrations.clear() + _set_platform("esp32") + with pytest.raises(cv.Invalid, match="add one of: esp32_ble_tracker$"): + ble_device_base._require_hub(_generated_id()) + + +def test_ble_less_platform_is_not_misdirected(hub_registry: set[str]) -> None: + # A known platform with no in-tree hub must not be pointed at other + # platforms' trackers; out-of-tree BLE hubs are not supported. + hub_registry.clear() + CORE.loaded_integrations.clear() + _set_platform("esp8266") + with pytest.raises( + cv.Invalid, + match="No BLE tracker exists for esp8266; BLE components are not supported", + ): + ble_device_base._require_hub(_generated_id()) + + +def test_explicit_id_bypasses_the_registry(hub_registry: set[str]) -> None: + # Explicit ble_hub_id: is the multi-hub disambiguation case; the ID pass + # owns that diagnosis and its error names the missing id. + hub_registry.clear() + CORE.loaded_integrations.clear() + explicit = ID("my_hub", is_declaration=False, type="ble_device_base::BLEHub") + assert ble_device_base._require_hub(explicit) is explicit + + +def test_registered_and_loaded_provider_passes(hub_registry: set[str]) -> None: + hub_registry.add("esp32_ble_tracker") + CORE.loaded_integrations.add("esp32_ble_tracker") + generated = _generated_id() + assert ble_device_base._require_hub(generated) is generated + + +def _module_name(path: Path) -> str: + """Dotted module name for a file under esphome/components.""" + rel = path.relative_to(COMPONENTS_DIR.parent) + parts = rel.with_suffix("").parts + if parts[-1] == "__init__": + parts = parts[:-1] + return "esphome." + ".".join(parts) + + +def _hub_component_modules() -> list[str]: + """Components whose codegen class inherits ble_device_base.BLEHub. + + The source-text pass only selects import candidates (importing all ~900 + component packages is too slow); membership is decided by the class + hierarchy via MockObjClass.inherits_from on every module whose source + matched — nested declaring modules included — so a comment mentioning + BLEHub in a consumer cannot produce a false positive. + """ + hub_modules = [] + for pkg in sorted(COMPONENTS_DIR.iterdir()): + if pkg.name == "ble_device_base" or not (pkg / "__init__.py").is_file(): + continue + matched = [ + path + for path in pkg.rglob("*.py") + if "BLEHub" in path.read_text(encoding="utf-8") + ] + if not matched: + continue + for path in matched: + mod = import_module(_module_name(path)) + if any( + isinstance(attr, MockObjClass) + and attr is not ble_device_base.BLEHub + and attr.inherits_from(ble_device_base.BLEHub) + for attr in vars(mod).values() + ): + hub_modules.append(pkg.name) + break + return hub_modules + + +def test_every_in_tree_hub_registers_as_provider() -> None: + """A BLEHub subclass that forgets register_hub_provider() makes _require_hub + reject valid configs for that platform — fail CI instead of the user.""" + hub_modules = _hub_component_modules() + assert hub_modules, "hub discovery found no BLEHub subclasses — scan stale?" + for name in hub_modules: + assert name in ble_device_base._HUB_PROVIDERS, ( + f"{name} subclasses ble_device_base.BLEHub but never calls " + "register_hub_provider(); a valid config using it would be rejected" + ) + # The per-platform error table must know every in-tree hub, keyed by real + # platform names — a typo'd key would silently route that platform into + # the no-in-tree-tracker branch. + assert set(ble_device_base._IN_TREE_HUB_PROVIDERS.values()) == set(hub_modules) + platforms = {platform.value for platform in Platform} + assert set(ble_device_base._IN_TREE_HUB_PROVIDERS) <= platforms + + +def test_ble_device_schema_declares_the_binding_key(hub_registry: set[str]) -> None: + """Extending BLE_DEVICE_SCHEMA keeps ble_hub_id a declared key on a strict + schema, for both the generated and the explicit form, and the missing-hub + rejection surfaces through the schema itself.""" + schema = cv.Schema({}).extend(ble_device_base.BLE_DEVICE_SCHEMA) + hub_registry.clear() + CORE.loaded_integrations.discard("esp32_ble_tracker") + with pytest.raises(cv.Invalid, match="No BLE tracker configured"): + schema({}) + hub_registry.add("esp32_ble_tracker") + CORE.loaded_integrations.add("esp32_ble_tracker") + generated = schema({})[ble_device_base.CONF_BLE_HUB_ID] + assert isinstance(generated, ID) and generated.id is None + explicit = schema({"ble_hub_id": "my_hub"})[ble_device_base.CONF_BLE_HUB_ID] + assert explicit.id == "my_hub" + + +def test_rename_legacy_hub_id_migrates_the_old_key() -> None: + validator = ble_device_base.rename_legacy_hub_id("my_sensor") + migrated = validator({"esp32_ble_id": "tracker1"}) + assert migrated == {ble_device_base.CONF_BLE_HUB_ID: "tracker1"} + untouched = validator({"name": "x"}) + assert untouched == {"name": "x"} + + +def test_add_service_uuid_dispatches_by_width(monkeypatch: pytest.MonkeyPatch) -> None: + emitted: list[str] = [] + monkeypatch.setattr( + "esphome.components.ble_device_base.cg.add", lambda e: emitted.append(str(e)) + ) + var = cg.MockObj("trig") + ble_device_base.add_service_uuid(var, "11AA") + ble_device_base.add_service_uuid(var, "11223344") + ble_device_base.add_service_uuid(var, "11223344-5566-7788-99aa-bbccddeeff00") + assert "set_service_uuid16" in emitted[0] + assert "set_service_uuid32" in emitted[1] + assert "set_service_uuid128" in emitted[2] + # BLE wire order: the 128-bit array must be byte-reversed — as_hex_array + # in its place would still emit the right setter name and silently never + # match on-air. + assert "0x00,0xff,0xee,0xdd" in emitted[2] + with pytest.raises(ValueError, match="Unsupported UUID format"): + ble_device_base.add_service_uuid(var, "123") diff --git a/tests/components/ble_device_base/test_ibeacon.cpp b/tests/components/ble_device_base/test_ibeacon.cpp new file mode 100644 index 0000000000..b154742ee2 --- /dev/null +++ b/tests/components/ble_device_base/test_ibeacon.cpp @@ -0,0 +1,158 @@ +#include + +#include +#include + +#include "esphome/components/ble_device_base/ble_device.h" + +namespace esphome::ble_device_base::testing { + +// from_manufacturer_data() accepts exactly the iBeacon frame: Apple company ID, +// 23 payload bytes, and the 0x02/0x15 sub-type/length prefix. The prefix check +// is stricter than the legacy esp32 parser (which surfaced any 23-byte Apple +// payload as a beacon) — a declared behavior change; these tests pin the +// accept/reject boundary. +namespace { + +ServiceData make_apple_payload(uint8_t sub_type, uint8_t length, size_t size = 23) { + ServiceData data; + data.uuid = ESPBTUUID::from_uint16(0x004C); // Apple company ID + data.data.assign(size, 0); + if (size >= 2) { + data.data[0] = sub_type; + data.data[1] = length; + } + // BeaconData layout: sub_type[0], length[1], proximity_uuid[2..17], + // major[18..19], minor[20..21], signal_power[22] — all wire values big-endian. + if (size >= 23) { + data.data[18] = 0x12; // major 0x1234 + data.data[19] = 0x34; + data.data[20] = 0x56; // minor 0x5678 + data.data[21] = 0x78; + data.data[22] = 0xC5; // signal power -59 dBm + } + return data; +} + +} // namespace + +TEST(BleIBeacon, AcceptsWellFormedFrame) { + auto beacon = ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x02, 0x15)); + ASSERT_TRUE(beacon.has_value()); + // Explicit guard: clang-tidy's unchecked-optional-access models neither + // gtest's ASSERT_TRUE nor value() as a check. + if (beacon.has_value()) { + // Pins every scalar accessor's offset and the on-wire big-endian order. + EXPECT_EQ(beacon->get_major(), 0x1234); + EXPECT_EQ(beacon->get_minor(), 0x5678); + EXPECT_EQ(beacon->get_signal_power(), -59); + } +} + +TEST(BleIBeacon, RejectsWrongSubType) { + // Apple "nearby" and other frames of coincidental length must not parse. + EXPECT_FALSE(ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x10, 0x15)).has_value()); +} + +TEST(BleIBeacon, RejectsWrongLengthByte) { + EXPECT_FALSE(ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x02, 0x14)).has_value()); +} + +TEST(BleIBeacon, RejectsWrongPayloadSize) { + EXPECT_FALSE(ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x02, 0x15, 22)).has_value()); + EXPECT_FALSE(ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x02, 0x15, 24)).has_value()); +} + +TEST(BleIBeacon, RejectsNonAppleCompany) { + auto data = make_apple_payload(0x02, 0x15); + data.uuid = ESPBTUUID::from_uint16(0x0059); // Nordic + EXPECT_FALSE(ESPBLEiBeacon::from_manufacturer_data(data).has_value()); +} + +TEST(BleIBeacon, PrefixRejectedFlagsOnlyTheSubTypeCase) { + // The out-param drives the get_ibeacon() diagnostic for frames the legacy + // parser accepted: exactly the 23-byte Apple payload with a wrong prefix. + // Wrong size and non-Apple frames were never accepted and must stay silent. + bool flagged = false; + EXPECT_FALSE(ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x10, 0x15), &flagged).has_value()); + EXPECT_TRUE(flagged); + + flagged = false; + ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x02, 0x15), &flagged); + EXPECT_FALSE(flagged); + + flagged = false; + ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x10, 0x15, 22), &flagged); + EXPECT_FALSE(flagged); + + flagged = false; + auto nordic = make_apple_payload(0x10, 0x15); + nordic.uuid = ESPBTUUID::from_uint16(0x0059); + ESPBLEiBeacon::from_manufacturer_data(nordic, &flagged); + EXPECT_FALSE(flagged); +} + +namespace { + +// One AD manufacturer-data record: [len][0xFF][company LE][payload...]. +void append_mfr_record(std::vector &adv, uint16_t company, const std::vector &payload) { + adv.push_back(static_cast(1 + 2 + payload.size())); + adv.push_back(0xFF); + adv.push_back(static_cast(company & 0xFF)); + adv.push_back(static_cast(company >> 8)); + adv.insert(adv.end(), payload.begin(), payload.end()); +} + +std::vector beacon_payload(uint8_t sub_type, uint8_t length) { + std::vector p(23, 0); + p[0] = sub_type; + p[1] = length; + p[18] = 0x12; + p[19] = 0x34; + p[20] = 0x56; + p[21] = 0x78; + p[22] = 0xC5; + return p; +} + +ESPBTDevice device_from(const std::vector &adv) { + const uint8_t mac[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66}; + ESPBTDevice device; + device.from_scan_result(mac, -59, 0, adv.data(), static_cast(adv.size())); + return device; +} + +} // namespace + +// get_ibeacon() wraps the parser with first-rejection capture and the log +// gate; pin its short circuits so a regression there needs a code change, not +// a review, to surface. +TEST(BleIBeacon, GetIbeaconReturnsBeaconDespitePrecedingRejectedFrame) { + std::vector adv; + append_mfr_record(adv, 0x004C, beacon_payload(0x10, 0x15)); // rejected prefix + append_mfr_record(adv, 0x004C, beacon_payload(0x02, 0x15)); // real iBeacon + auto device = device_from(adv); + auto beacon = device.get_ibeacon(); + ASSERT_TRUE(beacon.has_value()); + if (beacon.has_value()) { + EXPECT_EQ(beacon->get_major(), 0x1234); + } +} + +TEST(BleIBeacon, GetIbeaconEmptyWhenOnlyRejectedFrames) { + std::vector adv; + append_mfr_record(adv, 0x004C, beacon_payload(0x10, 0x15)); + auto device = device_from(adv); + EXPECT_FALSE(device.get_ibeacon().has_value()); +} + +TEST(BleIBeacon, GetIbeaconEmptyWithoutManufacturerData) { + std::vector adv; + adv.push_back(0x02); // flags record only + adv.push_back(0x01); + adv.push_back(0x06); + auto device = device_from(adv); + EXPECT_FALSE(device.get_ibeacon().has_value()); +} + +} // namespace esphome::ble_device_base::testing From 4ccf4210bed755dec14a3d1ca9f26aae2cc8b449 Mon Sep 17 00:00:00 2001 From: zerafachris Date: Thu, 6 Aug 2026 19:34:33 +0200 Subject: [PATCH 1284/1815] [epaper_spi] Do not yield on the final row of a T133A01 transfer phase (#17709) --- .../epaper_spi/epaper_spi_t133a01.cpp | 10 ++- tests/components/epaper_spi/common.h | 50 ++++++++++++ .../display/test_t133a01_transfer.cpp | 77 +++++++++++++++++++ 3 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 tests/components/epaper_spi/common.h create mode 100644 tests/components/epaper_spi/display/test_t133a01_transfer.cpp diff --git a/esphome/components/epaper_spi/epaper_spi_t133a01.cpp b/esphome/components/epaper_spi/epaper_spi_t133a01.cpp index 5735333761..95d1fcb484 100644 --- a/esphome/components/epaper_spi/epaper_spi_t133a01.cpp +++ b/esphome/components/epaper_spi/epaper_spi_t133a01.cpp @@ -311,9 +311,12 @@ bool HOT EPaperT133A01::transfer_data() { this->current_data_index_ = half; if (millis() - start_time > MAX_TRANSFER_TIME) { - return false; + break; } } + if (half < total_rows) { + return false; + } ESP_LOGD(TAG, "CS phase done"); this->disable(); this->cs_pin_->digital_write(true); // deselect CS @@ -346,9 +349,12 @@ bool HOT EPaperT133A01::transfer_data() { this->current_data_index_ = half; if (millis() - start_time > MAX_TRANSFER_TIME) { - return false; + break; } } + if (half < total_rows * 2) { + return false; + } ESP_LOGD(TAG, "CS1 phase done"); this->disable(); this->cs1_pin_->digital_write(true); // deselect CS1 diff --git a/tests/components/epaper_spi/common.h b/tests/components/epaper_spi/common.h new file mode 100644 index 0000000000..5ac12afa6a --- /dev/null +++ b/tests/components/epaper_spi/common.h @@ -0,0 +1,50 @@ +#pragma once + +#include + +#include "esphome/components/spi/spi.h" +#include "esphome/core/hal.h" + +namespace esphome::epaper_spi::testing { + +/// SPI delegate that records transaction boundaries and burns wall-clock time on each +/// row write, so a transfer can be driven past its MAX_TRANSFER_TIME yield deadline. +class TimedSPIDelegate : public spi::SPIDelegate { + public: + explicit TimedSPIDelegate(uint32_t row_transfer_ms) : row_transfer_ms_(row_transfer_ms) {} + + uint8_t transfer(uint8_t data) override { return 0; } + + void write_array(const uint8_t *ptr, size_t length) override { + // A row of pixel data is one "slow" write; single-byte writes are commands. + if (length > 1) { + const uint32_t until = millis() + this->row_transfer_ms_; + while (millis() < until) { + } + } + } + + void begin_transaction() override { this->begin_count++; } + void end_transaction() override { this->end_count++; } + + int begin_count{0}; + int end_count{0}; + + protected: + uint32_t row_transfer_ms_; +}; + +/// GPIO pin that just remembers the last level written to it. +class RecordingPin : public GPIOPin { + public: + void setup() override {} + void pin_mode(gpio::Flags flags) override {} + gpio::Flags get_flags() const override { return gpio::Flags::FLAG_NONE; } + bool digital_read() override { return false; } + void digital_write(bool value) override { this->level = value; } + size_t dump_summary(char *buffer, size_t len) const override { return snprintf(buffer, len, "recording"); } + + bool level{true}; +}; + +} // namespace esphome::epaper_spi::testing diff --git a/tests/components/epaper_spi/display/test_t133a01_transfer.cpp b/tests/components/epaper_spi/display/test_t133a01_transfer.cpp new file mode 100644 index 0000000000..5c7abdc022 --- /dev/null +++ b/tests/components/epaper_spi/display/test_t133a01_transfer.cpp @@ -0,0 +1,77 @@ +#include + +#include "../common.h" +#include "esphome/components/epaper_spi/epaper_spi_t133a01.h" + +namespace esphome::epaper_spi::testing { + +/// Exposes the protected transfer machinery so the yield behaviour can be driven directly. +class TestableT133A01 : public EPaperT133A01 { + public: + TestableT133A01(uint16_t width, uint16_t height) : EPaperT133A01("test", width, height, nullptr, 0) {} + + void install(spi::SPIDelegate *delegate) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + this->set_cs_pins(&this->cs, &this->cs1); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + } + + using EPaperT133A01::transfer_data; + + RecordingPin dc, cs, cs1; +}; + +/// Regression test for the T133A01 transfer deadlock (issue #17668). +/// +/// `transfer_data()` evaluates its yield deadline *after* incrementing the row counter, so the +/// deadline can expire on a phase's final row. The phase is then complete but the function +/// reports "not done"; on the next call the phase guard is false, so the `disable()` / +/// CS-deassert epilogue is skipped permanently. The SPI transaction is never closed and the +/// next `enable()` blocks forever, tripping the task watchdog. +/// +/// Here the CS phase is two rows and every row write overruns the deadline, so the second call +/// completes the phase exactly as the deadline expires, which is the failing alignment. The completed +/// phase must still run its epilogue: end the transaction and deassert CS. +TEST(EPaperT133A01, CompletedPhaseRunsEpilogueWhenDeadlineExpiresOnFinalRow) { + // width 8 -> 4 bytes per row, 2 per half-row; height 2 -> a two-row CS phase + TestableT133A01 display(8, 2); + TimedSPIDelegate delegate(MAX_TRANSFER_TIME + 5); + display.install(&delegate); + + // First call performs the one-off CCSET setup (which opens and closes a transaction of its + // own) and then writes row 0 of the CS phase before yielding on the deadline. + ASSERT_FALSE(display.transfer_data()) << "transfer should have yielded after the first row"; + ASSERT_FALSE(display.cs.level) << "CS must stay asserted across a yield mid-phase"; + const int closed_after_setup = delegate.end_count; + + // Second call writes the final row of the CS phase; the deadline expires as it lands. + display.transfer_data(); + + EXPECT_EQ(delegate.end_count, closed_after_setup + 1) + << "completed CS phase skipped disable() -- SPI transaction left open"; + EXPECT_TRUE(display.cs.level) << "completed CS phase left CS asserted"; +} + +/// The CS1 phase has the same off-by-one, but fails worse: after the skipped epilogue the +/// function falls through to `return true`, reporting the transfer complete while the SPI +/// transaction is still open and CS1 is still asserted. The next command's `enable()` then +/// blocks forever. A transfer that reports done must have released the bus. +TEST(EPaperT133A01, TransferReportsDoneOnlyAfterReleasingTheBus) { + TestableT133A01 display(8, 2); + TimedSPIDelegate delegate(MAX_TRANSFER_TIME + 5); + display.install(&delegate); + + // Both phases are two rows each and every row overruns the deadline, so the transfer needs + // one call per row plus the setup call. Bound the loop so a regression fails rather than hangs. + int calls = 0; + while (!display.transfer_data()) { + ASSERT_LT(++calls, 10) << "transfer never reported completion"; + } + + EXPECT_TRUE(display.cs1.level) << "transfer reported done with CS1 still asserted"; + EXPECT_EQ(delegate.begin_count, delegate.end_count) + << "transfer reported done with an SPI transaction still open -- the next enable() would deadlock"; +} + +} // namespace esphome::epaper_spi::testing From 53b1b3a2532d86ee36ac58fb211f7ab24464a558 Mon Sep 17 00:00:00 2001 From: Mustafa KURU Date: Thu, 6 Aug 2026 22:36:32 +0300 Subject: [PATCH 1285/1815] [ld6002b] Add target sensors (2/5) (#17820) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/ld6002b/binary_sensor.py | 4 +- esphome/components/ld6002b/const.py | 5 + esphome/components/ld6002b/ld6002b.cpp | 109 +++++++++++++++++++- esphome/components/ld6002b/ld6002b.h | 58 +++++++++++ esphome/components/ld6002b/sensor.py | 105 +++++++++++++++++++ tests/components/ld6002b/common.yaml | 39 +++++++ 6 files changed, 315 insertions(+), 5 deletions(-) create mode 100644 esphome/components/ld6002b/sensor.py diff --git a/esphome/components/ld6002b/binary_sensor.py b/esphome/components/ld6002b/binary_sensor.py index 2e38d35c66..319ace6f5d 100644 --- a/esphome/components/ld6002b/binary_sensor.py +++ b/esphome/components/ld6002b/binary_sensor.py @@ -4,12 +4,10 @@ import esphome.config_validation as cv from esphome.const import CONF_TARGET, DEVICE_CLASS_OCCUPANCY from . import LD6002BComponent -from .const import CONF_LD6002B_ID +from .const import CONF_LD6002B_ID, MAX_TARGETS DEPENDENCIES = ["ld6002b"] -MAX_TARGETS = 3 - CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), diff --git a/esphome/components/ld6002b/const.py b/esphome/components/ld6002b/const.py index e7606e2fae..4419a92d23 100644 --- a/esphome/components/ld6002b/const.py +++ b/esphome/components/ld6002b/const.py @@ -1,3 +1,8 @@ CONF_AUTO_WAKE = "auto_wake" +CONF_CLUSTER_ID = "cluster_id" +CONF_DOPPLER_INDEX = "doppler_index" CONF_LD6002B_ID = "ld6002b_id" CONF_WAKEUP_PULSE = "wakeup_pulse" +CONF_Z = "z" + +MAX_TARGETS = 3 diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index f18b944e12..2a09e98c25 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -2,6 +2,7 @@ #include "esphome/core/log.h" #include #include +#include #include namespace esphome::ld6002b { @@ -48,6 +49,20 @@ uint32_t LD6002BComponent::read_u32_le(const uint8_t *data) { (static_cast(data[2]) << 16) | (static_cast(data[3]) << 24); } +int32_t LD6002BComponent::read_int32_le(const uint8_t *data) { + uint32_t raw = read_u32_le(data); + int32_t value; + std::memcpy(&value, &raw, sizeof(value)); + return value; +} + +float LD6002BComponent::read_f32_le(const uint8_t *data) { + uint32_t raw = read_u32_le(data); + float value; + std::memcpy(&value, &raw, sizeof(value)); + return value; +} + void LD6002BComponent::write_u32_le(uint8_t *data, uint32_t value) { data[0] = value & 0xFF; data[1] = (value >> 8) & 0xFF; @@ -70,6 +85,18 @@ void LD6002BComponent::setup() { this->set_timeout(SETUP_DELAY_MS, [this]() { bool want_target_stream = false; +#ifdef USE_SENSOR + want_target_stream = want_target_stream || this->target_count_sensor_ != nullptr; + if (!want_target_stream) { + for (const auto &target : this->targets_) { + if (target.x != nullptr || target.y != nullptr || target.z != nullptr || target.dop_idx != nullptr || + target.cluster_id != nullptr) { + want_target_stream = true; + break; + } + } + } +#endif #ifdef USE_BINARY_SENSOR want_target_stream = want_target_stream || this->presence_binary_sensor_ != nullptr; if (!want_target_stream) { @@ -85,7 +112,6 @@ void LD6002BComponent::setup() { this->send_control_command_(CMD_TARGET_DISPLAY_ON); } - // Point-cloud streaming is introduced in a later part; make sure it is off. this->send_control_command_(CMD_POINT_CLOUD_OFF); }); } @@ -99,6 +125,16 @@ void LD6002BComponent::dump_config() { LOG_PIN(" Wake-up Pin: ", this->wakeup_pin_); ESP_LOGCONFIG(TAG, " Wake Pulse: %ums", this->wakeup_pulse_ms_); } +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Target Count", this->target_count_sensor_); + for (auto &target : this->targets_) { + LOG_SENSOR(" ", "Target X", target.x); + LOG_SENSOR(" ", "Target Y", target.y); + LOG_SENSOR(" ", "Target Z", target.z); + LOG_SENSOR(" ", "Target Doppler Index", target.dop_idx); + LOG_SENSOR(" ", "Target Cluster ID", target.cluster_id); + } +#endif #ifdef USE_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Presence", this->presence_binary_sensor_); for (uint8_t i = 0; i < MAX_TARGETS; i++) { @@ -254,6 +290,7 @@ void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) std::array wire_cluster{}; std::array wire_placed{}; std::array slot_seen{}; + std::array slot_wire{}; for (uint8_t i = 0; i < count; i++) { uint16_t cluster_offset = 4 + (i * TARGET_DATA_LEN) + 16; wire_cluster[i] = static_cast(read_u32_le(data + cluster_offset)); @@ -263,6 +300,7 @@ void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) if (this->slot_occupied_[s] && !slot_seen[s] && this->slot_cluster_[s] == wire_cluster[i]) { slot_seen[s] = true; wire_placed[i] = true; + slot_wire[s] = i; break; } } @@ -280,11 +318,21 @@ void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) if (!this->slot_occupied_[s]) { this->slot_occupied_[s] = true; this->slot_cluster_[s] = wire_cluster[i]; + slot_wire[s] = i; break; } } } +#ifdef USE_SENSOR + if (this->target_count_sensor_ != nullptr) { + if (reported != this->last_target_count_) { + this->target_count_sensor_->publish_state(reported); + this->last_target_count_ = reported; + } + } +#endif + this->target_presence_any_ = (reported > 0); #ifdef USE_BINARY_SENSOR if (this->presence_binary_sensor_ != nullptr) { @@ -293,11 +341,68 @@ void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) #endif for (uint8_t i = 0; i < MAX_TARGETS; i++) { + bool has_target = this->slot_occupied_[i]; + if (has_target) { +#ifdef USE_SENSOR + uint16_t offset = 4 + (slot_wire[i] * TARGET_DATA_LEN); + float x = read_f32_le(data + offset + 0); + float y = read_f32_le(data + offset + 4); + float z = read_f32_le(data + offset + 8); + int32_t dop_idx = read_int32_le(data + offset + 12); + int32_t cluster_id = this->slot_cluster_[i]; + TargetSensors &target = this->targets_[i]; + if (target.x != nullptr) { + target.x->publish_state(x); + } + if (target.y != nullptr) { + target.y->publish_state(y); + } + if (target.z != nullptr) { + target.z->publish_state(z); + } + if (target.dop_idx != nullptr) { + target.dop_idx->publish_state(static_cast(dop_idx)); + } + if (target.cluster_id != nullptr) { + if (!this->last_cluster_id_valid_[i] || cluster_id != this->last_cluster_id_[i]) { + target.cluster_id->publish_state(static_cast(cluster_id)); + this->last_cluster_id_[i] = cluster_id; + this->last_cluster_id_valid_[i] = true; + } + } +#endif + } else { +#ifdef USE_SENSOR + TargetSensors &target = this->targets_[i]; + if (this->last_target_presence_[i]) { + if (target.x != nullptr) { + target.x->publish_state(NAN); + } + if (target.y != nullptr) { + target.y->publish_state(NAN); + } + if (target.z != nullptr) { + target.z->publish_state(NAN); + } + if (target.dop_idx != nullptr) { + target.dop_idx->publish_state(NAN); + } + if (target.cluster_id != nullptr) { + target.cluster_id->publish_state(NAN); + } + // The slot is free: the next person's id is new even when it repeats this one. + this->last_cluster_id_valid_[i] = false; + } +#endif + } #ifdef USE_BINARY_SENSOR if (this->target_presence_[i] != nullptr) { // publish_state() already skips unchanged states, no manual de-dup needed. - this->target_presence_[i]->publish_state(this->slot_occupied_[i]); + this->target_presence_[i]->publish_state(has_target); } +#endif +#ifdef USE_SENSOR + this->last_target_presence_[i] = has_target; #endif } } diff --git a/esphome/components/ld6002b/ld6002b.h b/esphome/components/ld6002b/ld6002b.h index 2a68507e41..8bbfb9f6e4 100644 --- a/esphome/components/ld6002b/ld6002b.h +++ b/esphome/components/ld6002b/ld6002b.h @@ -5,6 +5,9 @@ #include "esphome/core/helpers.h" #include "esphome/core/gpio.h" #include "esphome/components/uart/uart.h" +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif #ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" #endif @@ -18,6 +21,17 @@ static constexpr size_t DEFAULT_MAX_DATA_LEN = 1024; // Largest protocol payload is TYPE_SET_AREA: int32 area id + 6 floats = 28 bytes. static constexpr size_t CMD_MAX_DATA_LEN = 28; +#ifdef USE_SENSOR +struct TargetSensors { + sensor::Sensor *x{nullptr}; + sensor::Sensor *y{nullptr}; + sensor::Sensor *z{nullptr}; + sensor::Sensor *dop_idx{nullptr}; + sensor::Sensor *cluster_id{nullptr}; +}; + +#endif + class LD6002BComponent : public Component, public uart::UARTDevice { public: void setup() override; @@ -29,6 +43,36 @@ class LD6002BComponent : public Component, public uart::UARTDevice { void set_wakeup_pulse_ms(uint32_t ms) { this->wakeup_pulse_ms_ = ms; } void set_auto_wake(bool enable) { this->auto_wake_ = enable; } +#ifdef USE_SENSOR + void set_target_count_sensor(sensor::Sensor *sensor) { this->target_count_sensor_ = sensor; } + + void set_target_x_sensor(uint8_t target, sensor::Sensor *sensor) { + if (target >= MAX_TARGETS) + return; + this->targets_[target].x = sensor; + } + void set_target_y_sensor(uint8_t target, sensor::Sensor *sensor) { + if (target >= MAX_TARGETS) + return; + this->targets_[target].y = sensor; + } + void set_target_z_sensor(uint8_t target, sensor::Sensor *sensor) { + if (target >= MAX_TARGETS) + return; + this->targets_[target].z = sensor; + } + void set_target_dop_idx_sensor(uint8_t target, sensor::Sensor *sensor) { + if (target >= MAX_TARGETS) + return; + this->targets_[target].dop_idx = sensor; + } + void set_target_cluster_id_sensor(uint8_t target, sensor::Sensor *sensor) { + if (target >= MAX_TARGETS) + return; + this->targets_[target].cluster_id = sensor; + } +#endif + #ifdef USE_BINARY_SENSOR void set_presence_binary_sensor(binary_sensor::BinarySensor *sensor) { this->presence_binary_sensor_ = sensor; } void set_target_presence_binary_sensor(uint8_t target, binary_sensor::BinarySensor *sensor) { @@ -61,8 +105,14 @@ class LD6002BComponent : public Component, public uart::UARTDevice { static uint16_t read_u16_be(const uint8_t *data); static uint32_t read_u32_le(const uint8_t *data); + static int32_t read_int32_le(const uint8_t *data); + static float read_f32_le(const uint8_t *data); static void write_u32_le(uint8_t *data, uint32_t value); +#ifdef USE_SENSOR + std::array targets_{}; + sensor::Sensor *target_count_sensor_{nullptr}; +#endif #ifdef USE_BINARY_SENSOR binary_sensor::BinarySensor *presence_binary_sensor_{nullptr}; std::array target_presence_{}; @@ -128,6 +178,14 @@ class LD6002BComponent : public Component, public uart::UARTDevice { std::array slot_occupied_{}; bool target_presence_any_{false}; + +#ifdef USE_SENSOR + std::array last_target_presence_{}; // one-shot NAN clear for target sensors + // A cluster id names a person, so like the counts it is published on change, not per frame. + std::array last_cluster_id_{}; + std::array last_cluster_id_valid_{}; + uint32_t last_target_count_{0xFFFFFFFF}; +#endif }; } // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/sensor.py b/esphome/components/ld6002b/sensor.py new file mode 100644 index 0000000000..3aa9b0f98a --- /dev/null +++ b/esphome/components/ld6002b/sensor.py @@ -0,0 +1,105 @@ +import esphome.codegen as cg +from esphome.components import sensor +from esphome.components.const import CONF_TARGET_COUNT +import esphome.config_validation as cv +from esphome.const import ( + CONF_X, + CONF_Y, + DEVICE_CLASS_DISTANCE, + STATE_CLASS_MEASUREMENT, + UNIT_METER, +) + +from . import LD6002BComponent +from .const import ( + CONF_CLUSTER_ID, + CONF_DOPPLER_INDEX, + CONF_LD6002B_ID, + CONF_Z, + MAX_TARGETS, +) + +DEPENDENCIES = ["ld6002b"] + +# The ld2450 defaults for a streamed value: hold the last reading for a second so a +# dropped frame does not read as absence, then rate-limit what reaches the frontend. +_VALUE_SENSOR_FILTERS = [ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, +] + +TARGET_SCHEMA = cv.Schema( + { + cv.Optional(CONF_X): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + filters=_VALUE_SENSOR_FILTERS, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_Y): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + filters=_VALUE_SENSOR_FILTERS, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_Z): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + filters=_VALUE_SENSOR_FILTERS, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_DOPPLER_INDEX): sensor.sensor_schema( + accuracy_decimals=0, + filters=_VALUE_SENSOR_FILTERS, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_CLUSTER_ID): sensor.sensor_schema( + accuracy_decimals=0, + ), + } +) + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + } +).extend({cv.Optional(f"target_{i + 1}"): TARGET_SCHEMA for i in range(MAX_TARGETS)}) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_LD6002B_ID]) + + if target_count_config := config.get(CONF_TARGET_COUNT): + sens = await sensor.new_sensor(target_count_config) + cg.add(hub.set_target_count_sensor(sens)) + + for i in range(MAX_TARGETS): + if target_config := config.get(f"target_{i + 1}"): + if x_config := target_config.get(CONF_X): + sens = await sensor.new_sensor(x_config) + cg.add(hub.set_target_x_sensor(i, sens)) + if y_config := target_config.get(CONF_Y): + sens = await sensor.new_sensor(y_config) + cg.add(hub.set_target_y_sensor(i, sens)) + if z_config := target_config.get(CONF_Z): + sens = await sensor.new_sensor(z_config) + cg.add(hub.set_target_z_sensor(i, sens)) + if doppler_index_config := target_config.get(CONF_DOPPLER_INDEX): + sens = await sensor.new_sensor(doppler_index_config) + cg.add(hub.set_target_dop_idx_sensor(i, sens)) + if cluster_id_config := target_config.get(CONF_CLUSTER_ID): + sens = await sensor.new_sensor(cluster_id_config) + cg.add(hub.set_target_cluster_id_sensor(i, sens)) diff --git a/tests/components/ld6002b/common.yaml b/tests/components/ld6002b/common.yaml index da08122608..15ab06c394 100644 --- a/tests/components/ld6002b/common.yaml +++ b/tests/components/ld6002b/common.yaml @@ -2,6 +2,45 @@ ld6002b: id: ld6002b_radar wakeup_pin: GPIO14 +sensor: + - platform: ld6002b + ld6002b_id: ld6002b_radar + target_count: + name: Target Count + target_1: + x: + name: Target-1 X + y: + name: Target-1 Y + z: + name: Target-1 Z + doppler_index: + name: Target-1 Dop + cluster_id: + name: Target-1 Cluster + target_2: + x: + name: Target-2 X + y: + name: Target-2 Y + z: + name: Target-2 Z + doppler_index: + name: Target-2 Dop + cluster_id: + name: Target-2 Cluster + target_3: + x: + name: Target-3 X + y: + name: Target-3 Y + z: + name: Target-3 Z + doppler_index: + name: Target-3 Dop + cluster_id: + name: Target-3 Cluster + binary_sensor: - platform: ld6002b ld6002b_id: ld6002b_radar From 56682534f822d062d535e5665e9eae3e97db80c4 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Thu, 6 Aug 2026 14:52:23 -0500 Subject: [PATCH 1286/1815] [modbus_client] Add component for ad-hoc modbus request/response (#17676) Co-authored-by: Claude Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/modbus/__init__.py | 6 + esphome/components/modbus_client/__init__.py | 164 ++++++++++++++++++ .../components/modbus_client/modbus_client.h | 99 +++++++++++ .../modbus_client/test_modbus_client.py | 116 +++++++++++++ tests/components/modbus_client/common.yaml | 53 ++++++ .../modbus_client/test.esp32-idf.yaml | 3 + .../modbus_client/test.esp8266-ard.yaml | 3 + .../modbus_client/test.rp2040-ard.yaml | 3 + .../uart_mock_modbus_client_inline.yaml | 108 ++++++++++++ tests/integration/test_uart_mock_modbus.py | 29 ++++ 11 files changed, 585 insertions(+) create mode 100644 esphome/components/modbus_client/__init__.py create mode 100644 esphome/components/modbus_client/modbus_client.h create mode 100644 tests/component_tests/modbus_client/test_modbus_client.py create mode 100644 tests/components/modbus_client/common.yaml create mode 100644 tests/components/modbus_client/test.esp32-idf.yaml create mode 100644 tests/components/modbus_client/test.esp8266-ard.yaml create mode 100644 tests/components/modbus_client/test.rp2040-ard.yaml create mode 100644 tests/integration/fixtures/uart_mock_modbus_client_inline.yaml diff --git a/CODEOWNERS b/CODEOWNERS index d64e862c39..d2e26edca3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -352,6 +352,7 @@ esphome/components/mlx90393/* @functionpointer esphome/components/mlx90614/* @jesserockz esphome/components/mmc5603/* @benhoff esphome/components/mmc5983/* @agoode +esphome/components/modbus_client/* @exciton esphome/components/modbus_controller/* @martgras esphome/components/modbus_controller/binary_sensor/* @martgras esphome/components/modbus_controller/number/* @martgras diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 9e64540382..bc52263aef 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -14,6 +14,12 @@ import esphome.final_validate as fv _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ["uart"] +# Loading the hub makes the modbus_client.* actions available (they are registry entries only; no code is +# generated unless a config uses one). +AUTO_LOAD = ["modbus_client"] + +# Mirrors modbus::MAX_PDU_SIZE in modbus_definitions.h: 256-byte RTU frame minus address and CRC. +MAX_PDU_SIZE = 253 modbus_ns = cg.esphome_ns.namespace("modbus") Modbus = modbus_ns.class_("Modbus", cg.Component, uart.UARTDevice) diff --git a/esphome/components/modbus_client/__init__.py b/esphome/components/modbus_client/__init__.py new file mode 100644 index 0000000000..e8a75a1b6c --- /dev/null +++ b/esphome/components/modbus_client/__init__.py @@ -0,0 +1,164 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import modbus +import esphome.config_validation as cv +from esphome.const import CONF_ADDRESS, CONF_ON_ERROR, CONF_ON_RESPONSE +from esphome.core import Lambda +from esphome.types import ConfigType, TemplateArgsType + +CODEOWNERS = ["@exciton"] +DEPENDENCIES = ["modbus"] + +CONF_ON_NO_RESPONSE = "on_no_response" +CONF_ON_NOT_SENT = "on_not_sent" +CONF_ON_SENT = "on_sent" +CONF_PDU = "pdu" +CONF_RETRY = "retry" + +modbus_client_ns = cg.esphome_ns.namespace("modbus_client") +ModbusClientSendAction = modbus_client_ns.class_( + "ModbusClientSendAction", automation.Action, modbus.ModbusClientDevice +) + +# The exception code passed to on_error handlers. +ExceptionCode = modbus.modbus_ns.enum("ExceptionCode") + +# Lambda argument types for the reply handlers: the device address the send targeted, and the +# request/response PDUs (function code + data). The spans are only valid for the duration of the handler. +_PDU_SPAN = cg.std_span.template(cg.uint8.operator("const")) + +# The pdu lambda's return type: a stack-allocated StaticVector capped at the Modbus PDU limit +# (modbus.MAX_PDU_SIZE). Lambdas can return a byte list or a modbus::helpers::create_*_pdu() result. +# The list form below is bounded by cv.Length; a lambda cannot be. PduBuffer drops bytes past +# modbus.MAX_PDU_SIZE without reporting it, so an over-long lambda PDU is silently truncated. +_PDU_BUFFER = modbus.modbus_ns.namespace("helpers").class_("PduBuffer") + + +def _synchronous_handler(value: ConfigType) -> ConfigType: + """Reject deferring actions in a handler: its PDU spans point into hub buffers that are reused + once the handler returns, and DelayAction and friends capture the trigger args for later replay.""" + if automation.has_non_synchronous_actions(value): + raise cv.Invalid( + "Deferring actions (delay, wait_until, script.wait, ...) are not allowed in modbus_client " + "handlers: the request/response data is only valid while the handler runs. Copy what you " + "need into globals first, then defer in a separate script or automation." + ) + return value + + +def _handler_schema() -> cv.All: + return cv.All(automation.validate_automation(single=True), _synchronous_handler) + + +# Each action is its own hub device: the modbus hub routes the reply straight back to the action that +# sent it, so the address can even be templatable - the reply is matched by the action's identity, not +# its address. +_ACTION_BASE_SCHEMA = cv.Schema( + { + cv.GenerateID(modbus.CONF_MODBUS_ID): cv.use_id(modbus.ModbusClient), + cv.Required(CONF_ADDRESS): cv.templatable(cv.hex_uint8_t), + # Optional handlers. on_sent fires when the frame reaches the wire; the reply handlers arrive + # later (fire-and-continue), so all run with the request/reply available - not the outer + # automation's variables. + cv.Optional(CONF_ON_SENT): _handler_schema(), + cv.Optional(CONF_ON_ERROR): _handler_schema(), + # on_no_response takes either a returning lambda (`!lambda "return ;"`, gets `request`, + # returns true to have the hub retry the frame) OR a `then:` automation of actions; the automation + # form may also carry an optional `retry:` returning lambda to run actions AND decide the retry. + cv.Optional(CONF_ON_NO_RESPONSE): cv.All( + cv.Any( + cv.returning_lambda, + automation.validate_automation( + {cv.Optional(CONF_RETRY): cv.returning_lambda}, single=True + ), + ), + _synchronous_handler, + ), + cv.Optional(CONF_ON_NOT_SENT): _handler_schema(), + } +) + +MODBUS_CLIENT_SEND_SCHEMA = _ACTION_BASE_SCHEMA.extend( + { + cv.Required(CONF_PDU): cv.templatable( + cv.All( + cv.ensure_list(cv.hex_uint8_t), + cv.Length(min=1, max=modbus.MAX_PDU_SIZE), + ) + ), + cv.Optional(CONF_ON_RESPONSE): _handler_schema(), + } +) + + +async def register_client_action( + var: cg.MockObj, + config: ConfigType, + args: TemplateArgsType, + response_args: TemplateArgsType, +) -> cg.MockObj: + """Wire the shared action plumbing: hub parent, templated device address, outcome triggers. + + response_args are the on_response handler's arguments, which differ per action. + """ + parent = await cg.get_variable(config[modbus.CONF_MODBUS_ID]) + cg.add(var.set_parent(parent)) + cg.add( + var.set_target_address( + await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) + ) + ) + if sent_conf := config.get(CONF_ON_SENT): + await automation.build_automation( + var.get_sent_trigger(), [(_PDU_SPAN, "request")], sent_conf + ) + if response_conf := config.get(CONF_ON_RESPONSE): + await automation.build_automation( + var.get_response_trigger(), response_args, response_conf + ) + if error_conf := config.get(CONF_ON_ERROR): + await automation.build_automation( + var.get_error_trigger(), + [(_PDU_SPAN, "request"), (ExceptionCode, "exception_code")], + error_conf, + ) + if (no_response_conf := config.get(CONF_ON_NO_RESPONSE)) is not None: + # The lambda form IS the retry decision; the automation form runs actions and may carry a nested + # `retry:` lambda. Either way the retry lambda's bool becomes on_no_response()'s return value. + if isinstance(no_response_conf, Lambda): + retry_conf = no_response_conf + else: + await automation.build_automation( + var.get_no_response_trigger(), + [(_PDU_SPAN, "request")], + no_response_conf, + ) + retry_conf = no_response_conf.get(CONF_RETRY) + if retry_conf is not None: + retry_lambda = await cg.process_lambda( + retry_conf, [(_PDU_SPAN, "request")], return_type=cg.bool_ + ) + cg.add(var.set_retry(retry_lambda)) + if not_sent_conf := config.get(CONF_ON_NOT_SENT): + await automation.build_automation( + var.get_not_sent_trigger(), [(_PDU_SPAN, "request")], not_sent_conf + ) + return var + + +@automation.register_action( + "modbus_client.send", + ModbusClientSendAction, + MODBUS_CLIENT_SEND_SCHEMA, + synchronous=True, +) +async def modbus_client_send_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + template_ = await cg.templatable(config[CONF_PDU], args, _PDU_BUFFER) + cg.add(var.set_pdu(template_)) + return await register_client_action( + var, + config, + args, + [(_PDU_SPAN, "request"), (_PDU_SPAN, "response")], + ) diff --git a/esphome/components/modbus_client/modbus_client.h b/esphome/components/modbus_client/modbus_client.h new file mode 100644 index 0000000000..d4b3792a5f --- /dev/null +++ b/esphome/components/modbus_client/modbus_client.h @@ -0,0 +1,99 @@ +#pragma once + +#include "esphome/components/modbus/modbus.h" +#include "esphome/components/modbus/modbus_helpers.h" +#include "esphome/core/automation.h" + +#include + +namespace esphome::modbus_client { + +/// Shared base for the modbus_client actions. Each ACTION INSTANCE is its own modbus::ModbusClientDevice: +/// the hub routes every reply (or its lack) straight back to the action that sent it, so there is no +/// central client object and no request matching. The device address is templatable; it is stamped on the +/// device at play() time; the hub routes each reply by device pointer, so a changed address never +/// mis-routes an earlier reply. (The address is not passed to the reply triggers - under overlapping +/// sends it could misreport, and the handler can recompute the expression it configured.) +template class ClientActionBase : public Action, public modbus::ModbusClientDevice { + public: + TEMPLATABLE_VALUE(uint8_t, target_address) // the modbus device address + + Trigger> *get_sent_trigger() { return &this->sent_trigger_; } + Trigger, modbus::ExceptionCode> *get_error_trigger() { return &this->error_trigger_; } + Trigger> *get_no_response_trigger() { return &this->no_response_trigger_; } + Trigger> *get_not_sent_trigger() { return &this->not_sent_trigger_; } + + /// The retry decision for on_no_response: given the request PDU, return true to have the hub re-queue + /// the frame. Set from the lambda form or a then: automation's nested retry lambda; may coexist with + /// the no_response trigger (actions run, then this decides the retry). + using retry_func_t = bool (*)(std::span); + void set_retry(retry_func_t f) { this->retry_func_ = f; } + + /// The frame was written to the wire: fires once per transmission, before any reply, and never for a + /// send that ended in on_not_sent. request_pdu is the PDU sent (function code + data). + void on_sent(std::span request_pdu) override { this->sent_trigger_.trigger(request_pdu); } + /// Never reached the wire (tx queue full, cleared, or a duplicate write dropped by the hub's dedup). + void on_not_sent(std::span request_pdu) override { this->not_sent_trigger_.trigger(request_pdu); } + /// A Modbus exception reply. Lives here beside its trigger so every action subclass gets the pairing: + /// register_client_action() wires on_error for all of them, so a derived class must not have to + /// remember the override. + void on_error(std::span request_pdu, modbus::ExceptionCode exception_code) override { + this->error_trigger_.trigger(request_pdu, exception_code); + } + /// No reply within send_wait_time. Run the on_no_response actions (empty in the pure-lambda form), + /// then let the retry lambda, if set, decide whether the hub re-queues the frame (true = retry). The + /// two coexist: a then: automation can also carry a retry lambda. No lambda = no retry. + bool on_no_response(std::span request_pdu) override { + this->no_response_trigger_.trigger(request_pdu); + if (this->retry_func_ != nullptr) + return this->retry_func_(request_pdu); + return false; + } + /// Stamp the templated device address before every play(): subclasses cannot forget it, and the hub + /// routes each reply by device pointer, so a changed address never mis-routes earlier replies. + void play_complex(const Ts &...x) override { + this->set_address(this->target_address_.value(x...)); + Action::play_complex(x...); + } + + protected: + Trigger> sent_trigger_; + Trigger, modbus::ExceptionCode> error_trigger_; + Trigger> no_response_trigger_; + Trigger> not_sent_trigger_; + retry_func_t retry_func_{nullptr}; +}; + +/// modbus_client.send: fire a raw PDU (function code + data; the hub adds address and CRC). The reply is +/// delivered raw - on_response(request, response) - deliberately bypassing the typed dispatch, so +/// non-standard/custom transactions pass through untouched. +/// The PDU is a stack-allocated modbus::helpers::PduBuffer, so a pdu lambda can build one with the +/// modbus::helpers::create_*_pdu() builders and return it directly (smaller builder results convert). +/// A PduBuffer drops bytes past modbus::MAX_PDU_SIZE without reporting it (the hub's oversize check +/// cannot fire - that limit is the capacity), so an over-long lambda-built PDU is silently truncated. +template class ModbusClientSendAction : public ClientActionBase { + public: + TEMPLATABLE_VALUE(modbus::helpers::PduBuffer, pdu) + + Trigger, std::span> *get_response_trigger() { + return &this->response_trigger_; + } + + void play(const Ts &...x) override { + auto pdu = this->pdu_.value(x...); + const std::span span(pdu.data(), pdu.size()); + // The hub refuses some sends at the door with no callback (an empty PDU, a duplicate write already + // pending, a full queue). Every send still gets exactly one outcome, so resolve those via on_not_sent. + if (!this->send_pdu(span)) + this->on_not_sent(span); + } + + void on_response(std::span request_pdu, std::span response_pdu) override { + this->response_trigger_.trigger(request_pdu, response_pdu); + } + + protected: + Trigger, std::span> response_trigger_; +}; + +} // namespace esphome::modbus_client diff --git a/tests/component_tests/modbus_client/test_modbus_client.py b/tests/component_tests/modbus_client/test_modbus_client.py new file mode 100644 index 0000000000..10f9bc588e --- /dev/null +++ b/tests/component_tests/modbus_client/test_modbus_client.py @@ -0,0 +1,116 @@ +"""Tests for modbus_client configuration validation. + +Handler PDU spans point into hub buffers reused once the handler returns, so the deferring-actions +guard is a safety property: these tests pin it to every handler slot. +""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.modbus_client import ( + CONF_ON_NO_RESPONSE, + CONF_ON_NOT_SENT, + CONF_ON_SENT, + CONF_PDU, + MODBUS_CLIENT_SEND_SCHEMA, +) +from esphome.const import CONF_ADDRESS, CONF_ON_ERROR, CONF_ON_RESPONSE +from esphome.core import Lambda +from esphome.types import ConfigType + +# Every handler slot on modbus_client.send. All five must reject deferring actions. +HANDLER_KEYS = [ + CONF_ON_SENT, + CONF_ON_RESPONSE, + CONF_ON_ERROR, + CONF_ON_NO_RESPONSE, + CONF_ON_NOT_SENT, +] + +# A deferring action (registered synchronous=False) and a synchronous one, for contrast. +DEFERRING_ACTION = {"delay": "1s"} +SYNCHRONOUS_ACTION = {"lambda": Lambda('ESP_LOGD("test", "ran");')} +TRUE_CONDITION = {"lambda": Lambda("return true;")} + +# The same deferring action buried inside nested control flow, which the guard must still find. +NESTED_ACTIONS = [ + pytest.param( + [{"if": {"condition": TRUE_CONDITION, "then": [DEFERRING_ACTION]}}], + id="if", + ), + pytest.param([{"repeat": {"count": 2, "then": [DEFERRING_ACTION]}}], id="repeat"), + pytest.param( + [ + { + "repeat": { + "count": 2, + "then": [ + { + "if": { + "condition": TRUE_CONDITION, + "then": [DEFERRING_ACTION], + } + } + ], + } + } + ], + id="repeat_if", + ), +] + +DEFER_MESSAGE = "Deferring actions" + + +def _config(handler_key: str, actions: list) -> ConfigType: + """A minimal valid modbus_client.send config with one handler populated.""" + return { + CONF_ADDRESS: 0x01, + CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x01], + handler_key: {"then": actions}, + } + + +@pytest.mark.parametrize("handler_key", HANDLER_KEYS) +def test_synchronous_handler_accepted(handler_key: str) -> None: + # The guard must not get in the way of an ordinary inline handler. + MODBUS_CLIENT_SEND_SCHEMA(_config(handler_key, [SYNCHRONOUS_ACTION])) + + +@pytest.mark.parametrize("handler_key", HANDLER_KEYS) +def test_deferring_action_rejected(handler_key: str) -> None: + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + MODBUS_CLIENT_SEND_SCHEMA(_config(handler_key, [DEFERRING_ACTION])) + + +@pytest.mark.parametrize("handler_key", HANDLER_KEYS) +@pytest.mark.parametrize("actions", NESTED_ACTIONS) +def test_nested_deferring_action_rejected(handler_key: str, actions: list) -> None: + # has_non_synchronous_actions recurses, so a delay buried in if:/repeat: is still caught. + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + MODBUS_CLIENT_SEND_SCHEMA(_config(handler_key, actions)) + + +def test_on_no_response_lambda_form_accepted() -> None: + # The returning-lambda form has no action list; the guard is a no-op on it. + MODBUS_CLIENT_SEND_SCHEMA( + { + CONF_ADDRESS: 0x01, + CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x01], + CONF_ON_NO_RESPONSE: Lambda("return false;"), + } + ) + + +def test_on_no_response_retry_lambda_accepted() -> None: + # The automation form may also carry a nested retry: lambda. + MODBUS_CLIENT_SEND_SCHEMA( + { + CONF_ADDRESS: 0x01, + CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x01], + CONF_ON_NO_RESPONSE: { + "then": [SYNCHRONOUS_ACTION], + "retry": Lambda("return true;"), + }, + } + ) diff --git a/tests/components/modbus_client/common.yaml b/tests/components/modbus_client/common.yaml new file mode 100644 index 0000000000..19e81f4e48 --- /dev/null +++ b/tests/components/modbus_client/common.yaml @@ -0,0 +1,53 @@ +# The modbus_client actions are self-contained hub devices: each takes the hub (auto-resolved when there +# is a single modbus client hub) and a templatable device address; no component block is needed. The +# address is not passed back to reply handlers - recompute the configured expression if needed. +# The hub does not bound retries, so a retry lambda must (here: a counter capped at 3), or a dead +# device is retried forever. Reset the counter before the send or on a terminal outcome (on_response) +# so the cap is per transaction, not per device lifetime. Never reset in on_sent: it fires again on +# every retry, so the cap would never be reached. +globals: + - id: read_retries + type: int + initial_value: "0" + - id: combined_retries + type: int + initial_value: "0" + +button: + - platform: template + name: "Send Read" + on_press: + - lambda: "id(read_retries) = 0;" + - modbus_client.send: + address: 0x01 + pdu: [0x03, 0x00, 0x10, 0x00, 0x01] + # on_no_response lambda form: return true to retry. `request` is the timed-out PDU. + on_no_response: !lambda "return !request.empty() && request[0] == 0x03 && id(read_retries)++ < 3;" + # Per-send inline reply handlers (fire-and-continue): they run when this send's outcome is known; + # the targeted address is not passed back - recompute the configured expression if needed. + # A pdu lambda can hand-assemble bytes or return a modbus::helpers::create_*_pdu() builder result. + - modbus_client.send: + address: 0x01 + pdu: !lambda "return modbus::helpers::create_read_pdu(modbus::FunctionCode::READ_HOLDING_REGISTERS, 0x0010, 1);" + - modbus_client.send: + address: !lambda "return 1;" + pdu: !lambda "return {0x03, 0x00, 0x10, 0x00, 0x01};" + on_sent: + then: + - lambda: 'ESP_LOGI("modbus_client.test", "sent fc 0x%X", request.empty() ? 0 : request[0]);' + on_response: + then: + - lambda: |- + id(combined_retries) = 0; + ESP_LOGI("modbus_client.test", "got %d bytes", (int) response.size()); + on_error: + then: + - lambda: 'ESP_LOGW("modbus_client.test", "fc 0x%X exception %d", request.empty() ? 0 : request[0], (int) exception_code);' + # on_no_response combined form: run actions on timeout AND decide the retry via nested retry:. + on_no_response: + then: + - lambda: 'ESP_LOGW("modbus_client.test", "no reply for fc 0x%X", request.empty() ? 0 : request[0]);' + retry: !lambda "return !request.empty() && request[0] == 0x03 && id(combined_retries)++ < 3;" + on_not_sent: + then: + - lambda: 'ESP_LOGW("modbus_client.test", "not sent fc 0x%X", request.empty() ? 0 : request[0]);' diff --git a/tests/components/modbus_client/test.esp32-idf.yaml b/tests/components/modbus_client/test.esp32-idf.yaml new file mode 100644 index 0000000000..b5882e90d8 --- /dev/null +++ b/tests/components/modbus_client/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + modbus_client: !include common.yaml diff --git a/tests/components/modbus_client/test.esp8266-ard.yaml b/tests/components/modbus_client/test.esp8266-ard.yaml new file mode 100644 index 0000000000..151922b0d5 --- /dev/null +++ b/tests/components/modbus_client/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml + modbus_client: !include common.yaml diff --git a/tests/components/modbus_client/test.rp2040-ard.yaml b/tests/components/modbus_client/test.rp2040-ard.yaml new file mode 100644 index 0000000000..aaf115ae45 --- /dev/null +++ b/tests/components/modbus_client/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + modbus: !include ../../test_build_components/common/modbus/rp2040-ard.yaml + modbus_client: !include common.yaml diff --git a/tests/integration/fixtures/uart_mock_modbus_client_inline.yaml b/tests/integration/fixtures/uart_mock_modbus_client_inline.yaml new file mode 100644 index 0000000000..f85206107f --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_client_inline.yaml @@ -0,0 +1,108 @@ +esphome: + name: uart-mock-modbus-client-inline + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_client + data: !lambda return data; + - id: virtual_uart_client + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_client + id: virtual_modbus_client + role: client + turnaround_time: 10ms + # Short wait so the no-reply cases (address 2 below) time out well within the test window. + send_wait_time: 500ms + +modbus_server: + - address: 1 + modbus_id: virtual_modbus_server + id: modbus_server_1 + registers: + - address: 0x10 + value_type: U_WORD + read_lambda: return 1234; + +sensor: + - platform: template + name: "inline_value" + id: inline_value + - platform: template + name: "timeout_flag" + id: timeout_flag + - platform: template + name: "skipped_flag" + id: skipped_flag + +# The same write action fired twice while its first frame is still awaiting a reply: the hub drops the +# duplicate write (writes are never merged) and the second firing resolves via its own on_not_sent. +# mode: parallel so the second run starts while the first send is pending. +script: + - id: dup_write + mode: parallel + then: + - modbus_client.send: + address: 2 + pdu: [0x06, 0x00, 0x10, 0x01, 0x02] + on_not_sent: + then: + - lambda: "id(skipped_flag).publish_state(1);" + +# Each action is its own hub device: address 1 is served by the mock server, address 2 answers nothing. +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + # Per-send inline on_response: decode this reply where the send was fired (fire-and-continue). + - modbus_client.send: + address: 1 + pdu: [0x03, 0x00, 0x10, 0x00, 0x01] + on_response: + then: + - lambda: |- + if (response.size() >= 4) + id(inline_value).publish_state((response[2] << 8) | response[3]); + # No server answers address 2, so this resolves via on_no_response. + - modbus_client.send: + address: 2 + pdu: [0x03, 0x00, 0x10, 0x00, 0x01] + on_no_response: + then: + - lambda: "id(timeout_flag).publish_state(1);" + - script.execute: dup_write + - script.execute: dup_write diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 202fbe3f9c..bf163665f2 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -341,6 +341,35 @@ async def test_uart_mock_modbus_server_controller_multiple( _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.asyncio +async def test_uart_mock_modbus_client_inline( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test modbus_client.send actions: each action is its own hub device. + + Start Scenario fires: a read of served address 1 decoded in its inline on_response -> inline_value; a + read of address 2, which no server answers, resolving via on_no_response -> timeout_flag. A parallel + script fires the same write action twice while its first frame is pending; the hub drops the duplicate + write, and the second firing resolves via its own on_not_sent -> skipped_flag. This exercises + per-action reply routing, the no-reply path, and the one-outcome guarantee under the hub's write + dedup. + """ + + tracker = SensorTracker(["inline_value", "timeout_flag", "skipped_flag"]) + futures = tracker.expect_all( + {"inline_value": 1234, "timeout_flag": 1, "skipped_flag": 1} + ) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + await tracker.await_all(futures, timeout=5.0) + + @pytest.mark.asyncio async def test_uart_mock_modbus_grouping( yaml_config: str, From 4c47051da984ebcbe0eb0e2a1f862cb49fae6b6f Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:45:44 +0200 Subject: [PATCH 1287/1815] [zigbee] Add on_start automation (3/3) (#18009) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/zigbee/__init__.py | 4 +++- esphome/components/zigbee/zigbee_esp32.cpp | 5 +++++ esphome/components/zigbee/zigbee_esp32.h | 3 +++ esphome/components/zigbee/zigbee_zephyr.cpp | 10 ++++++++++ esphome/components/zigbee/zigbee_zephyr.h | 3 +++ tests/components/zigbee/common_esp32.yaml | 3 +++ tests/components/zigbee/common_nrf52.yaml | 3 +++ 7 files changed, 30 insertions(+), 1 deletion(-) diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 775fb35140..47913b34d7 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -13,7 +13,7 @@ from esphome.components.esp32.const import ( VARIANT_ESP32S31, ) import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INTERNAL, CONF_MODEL, CONF_NAME +from esphome.const import CONF_ID, CONF_INTERNAL, CONF_MODEL, CONF_NAME, CONF_ON_START from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType @@ -108,6 +108,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_ROUTER, default=False): cv.boolean, cv.Optional(CONF_ON_JOIN): automation.validate_automation({}), + cv.Optional(CONF_ON_START): automation.validate_automation({}), cv.OnlyWith(CONF_WIPE_ON_BOOT, "nrf52", default=False): cv.All( cv.Any( cv.boolean, @@ -175,6 +176,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( _CALLBACK_AUTOMATIONS = [ automation.CallbackAutomation(CONF_ON_JOIN, "add_on_join_callback", [(bool, "x")]), + automation.CallbackAutomation(CONF_ON_START, "add_on_start_callback", []), ] diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 3500aa3382..482995e2c5 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -65,6 +65,7 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { case EZB_ZDO_SIGNAL_SKIP_STARTUP: ESP_LOGD(TAG, "Zigbee stack initialized"); global_zigbee->started_ = true; + global_zigbee->enable_loop_soon_any_context(); ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_INITIALIZATION); break; case EZB_BDB_SIGNAL_DEVICE_FIRST_START: @@ -338,6 +339,10 @@ void ZigbeeComponent::setup() { } void ZigbeeComponent::loop() { + if (!this->start_reported_ && this->started_) { + this->start_cb_.call(); + this->start_reported_ = true; + } if (this->join_pending_.exchange(false)) { this->join_cb_.call(this->factory_new_); this->factory_new_ = false; diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index 412b7ac4da..c19fc3ad63 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -57,6 +57,7 @@ class ZigbeeComponent final : public Component { void factory_reset(); template void add_on_join_callback(F &&cb) { this->join_cb_.add(std::forward(cb)); } + template void add_on_start_callback(F &&cb) { this->start_cb_.add(std::forward(cb)); } bool is_battery_powered() { return this->basic_cluster_data_.power_source == EZB_ZCL_BASIC_POWER_SOURCE_BATTERY; } @@ -89,6 +90,8 @@ class ZigbeeComponent final : public Component { std::map, ZigbeeAttribute *> attributes_; ezb_af_device_desc_t dev_desc_; CallbackManager join_cb_{}; + LazyCallbackManager start_cb_{}; + bool start_reported_{false}; std::atomic started_ = false; std::atomic joined_ = false; std::atomic join_pending_ = false; diff --git a/esphome/components/zigbee/zigbee_zephyr.cpp b/esphome/components/zigbee/zigbee_zephyr.cpp index fedcb4a9c2..b8bb0a2036 100644 --- a/esphome/components/zigbee/zigbee_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_zephyr.cpp @@ -30,6 +30,9 @@ void ZigbeeComponent::zboss_signal_handler_esphome(zb_bufid_t bufid) { switch (sig) { case ZB_ZDO_SIGNAL_SKIP_STARTUP: ESP_LOGD(TAG, "ZB_ZDO_SIGNAL_SKIP_STARTUP, status: %d", status); + if (status == RET_OK) { + on_start_(); + } break; case ZB_ZDO_SIGNAL_PRODUCTION_CONFIG_READY: ESP_LOGD(TAG, "ZB_ZDO_SIGNAL_PRODUCTION_CONFIG_READY, status: %d", status); @@ -137,6 +140,13 @@ void ZigbeeComponent::on_join_(bool factory_new) { }); } +void ZigbeeComponent::on_start_() { + this->defer([this]() { + ESP_LOGD(TAG, "Started zigbee stack"); + this->start_cb_.call(); + }); +} + #ifdef USE_ZIGBEE_WIPE_ON_BOOT void ZigbeeComponent::erase_flash_(int area) { const struct flash_area *fap; diff --git a/esphome/components/zigbee/zigbee_zephyr.h b/esphome/components/zigbee/zigbee_zephyr.h index 8528aebff8..cd6deb0e95 100644 --- a/esphome/components/zigbee/zigbee_zephyr.h +++ b/esphome/components/zigbee/zigbee_zephyr.h @@ -75,6 +75,7 @@ class ZigbeeComponent final : public Component { this->callbacks_[endpoint - 1] = std::move(cb); } template void add_on_join_callback(F &&cb) { this->join_cb_.add(std::forward(cb)); } + template void add_on_start_callback(F &&cb) { this->start_cb_.add(std::forward(cb)); } void zboss_signal_handler_esphome(zb_bufid_t bufid); void after_reporting_info(zb_zcl_configure_reporting_req_t *config_rep_req, zb_zcl_attr_addr_info_t *attr_addr_info); void factory_reset(); @@ -86,12 +87,14 @@ class ZigbeeComponent final : public Component { protected: static void zcl_device_cb(zb_bufid_t bufid); void on_join_(bool factory_new); + void on_start_(); #ifdef USE_ZIGBEE_WIPE_ON_BOOT void erase_flash_(int area); #endif void dump_reporting_(); std::array, ZIGBEE_ENDPOINTS_COUNT> callbacks_{}; CallbackManager join_cb_; + LazyCallbackManager start_cb_; bool force_report_{false}; uint32_t sleep_time_{}; uint32_t sleep_remainder_{}; diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml index 6cac9c9e2a..ac25fb8faf 100644 --- a/tests/components/zigbee/common_esp32.yaml +++ b/tests/components/zigbee/common_esp32.yaml @@ -27,3 +27,6 @@ zigbee: on_join: then: - logger.log: "Joined network" + on_start: + then: + - logger.log: "Started zigbee stack" diff --git a/tests/components/zigbee/common_nrf52.yaml b/tests/components/zigbee/common_nrf52.yaml index bc39b371f5..c05c4053a5 100644 --- a/tests/components/zigbee/common_nrf52.yaml +++ b/tests/components/zigbee/common_nrf52.yaml @@ -7,6 +7,9 @@ zigbee: on_join: then: - logger.log: "Joined network" + on_start: + then: + - logger.log: "Started zigbee stack" time: - platform: zigbee From bf36a62f622cfbd4a745cfbe197249d8319095e5 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:47:12 -0400 Subject: [PATCH 1288/1815] [ci] Raise runner-concurrency caps for the OHF enterprise pool (#18125) --- .github/workflows/ci-docker.yml | 4 ++-- .github/workflows/ci.yml | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 30aa511e29..71dedd65aa 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -182,8 +182,8 @@ jobs: contents: read # actions/checkout to load the test configs strategy: fail-fast: false - # Cap concurrency so this smoke test doesn't hog all the shared runners. - max-parallel: 2 + # Modest cap so this smoke test leaves room on the shared runner pool. + max-parallel: 8 matrix: # One entry per distinct toolchain. ESP32 variants (c3/c6/s2/s3/p4) # share a toolchain bundle, so esp32 is exercised on the base variant diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 388ef3d37b..735ba73c99 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -516,7 +516,6 @@ jobs: ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf strategy: fail-fast: false - max-parallel: 2 matrix: include: - id: clang-tidy @@ -707,7 +706,6 @@ jobs: ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf strategy: fail-fast: false - max-parallel: 3 matrix: include: - id: clang-tidy @@ -787,7 +785,6 @@ jobs: ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf strategy: fail-fast: false - max-parallel: 3 matrix: include: - id: clang-tidy @@ -874,7 +871,7 @@ jobs: ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf strategy: fail-fast: false - max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 8 || 4 }} + max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 32 || 16 }} matrix: batch: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }} steps: From 9802c7f32f3004c47e3c1632ee22d8fd51cc0e12 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:49:32 -0400 Subject: [PATCH 1289/1815] Bump github/codeql-action/init from 4.37.4 to 4.37.5 (#18140) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 1586ead2e6..63cf1ee82d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + uses: github/codeql-action/init@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} From 27f6ced1a951f8af5c8e9df2cebd42bbfe7dc80e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:49:40 -0400 Subject: [PATCH 1290/1815] Bump github/codeql-action/analyze from 4.37.4 to 4.37.5 (#18139) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 63cf1ee82d..2751529222 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + uses: github/codeql-action/analyze@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5 with: category: "/language:${{matrix.language}}" From 09ecfa58dee2d1b693499bc21bac92e1a7ff43d8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:57:03 -0400 Subject: [PATCH 1291/1815] Bump prek from 0.4.11 to 0.4.12 (#18138) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 943d6b597b..b5753066ba 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -2,7 +2,7 @@ pylint==4.0.6 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating ruff==0.16.1 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating -prek==0.4.11 # also change in .github/workflows/ci.yml when updating +prek==0.4.12 # also change in .github/workflows/ci.yml when updating # Unit tests pytest==9.1.1 From 4301e5a2a2ecc99935f35a8b31938d11e8b378e7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:58:52 -0400 Subject: [PATCH 1292/1815] Bump resvg-py from 0.3.3 to 0.3.4 (#18137) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index afdb921f7e..d56a8daec1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 pillow==12.3.0 -resvg-py==0.3.3 +resvg-py==0.3.4 freetype-py==2.5.1 jinja2==3.1.6 bleak==2.1.1 From 97558ce64a69b045b8be5f407e695bc4cf456be9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 6 Aug 2026 20:40:04 -0500 Subject: [PATCH 1293/1815] [core] Add EventPool::warm() (#18127) --- esphome/components/rp2040_ble/rp2040_ble.cpp | 28 +++------ esphome/core/event_pool.h | 61 ++++++++++++------- tests/components/core/test_event_pool.cpp | 63 ++++++++++++++++++++ 3 files changed, 112 insertions(+), 40 deletions(-) diff --git a/esphome/components/rp2040_ble/rp2040_ble.cpp b/esphome/components/rp2040_ble/rp2040_ble.cpp index 405710f3e8..8e7c7d6be5 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.cpp +++ b/esphome/components/rp2040_ble/rp2040_ble.cpp @@ -15,29 +15,18 @@ static const char *const TAG = "rp2040_ble"; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) RP2040BLE *global_ble = nullptr; -// The analyzer cannot see that release() always retains the pointer here: the -// pool's free list is sized SIZE + 1, so its push cannot hit the ring-full -// drop branch for at most SIZE releases. -// NOLINTBEGIN(clang-analyzer-unix.Malloc) void RP2040BLE::setup() { global_ble = this; // Pre-create every pool entry so the packet handler's allocate() is always a - // free-list pop — the IRQ path must never reach malloc() (heap allocation - // after setup is forbidden, and the newlib malloc lock is not IRQ-safe). - // Deliberately unconditional: warming lazily on the first scan would move - // the allocations after setup, and doing it here keeps the pool's RAM cost - // visible at startup instead of appearing once scanning begins. - BLEScanReport *warm[MAX_SCAN_REPORT_QUEUE_SIZE - 1]; - size_t warmed = 0; - while (warmed < MAX_SCAN_REPORT_QUEUE_SIZE - 1 && (warm[warmed] = this->report_pool_.allocate()) != nullptr) - warmed++; - for (size_t i = 0; i < warmed; i++) - this->report_pool_.release(warm[i]); - if (warmed != MAX_SCAN_REPORT_QUEUE_SIZE - 1) { - // An incomplete warm would silently put malloc() back on the IRQ path once - // the free list runs dry; refuse to run instead (the stack is never - // enabled, so the packet handler cannot fire). + // free-list pop — the IRQ path must never reach malloc() (the newlib malloc + // lock is not IRQ-safe). Deliberately + // unconditional: warming lazily on the first scan would move the allocations + // after setup, and doing it here keeps the pool's RAM cost visible at + // startup instead of appearing once scanning begins. On an incomplete warm, + // refuse to run instead (the stack is never enabled, so the packet handler + // cannot fire). + if (!this->report_pool_.warm()) { ESP_LOGE(TAG, "Scan report pool warm-up failed"); this->mark_failed(); return; @@ -49,7 +38,6 @@ void RP2040BLE::setup() { this->state_ = BLEComponentState::DISABLED; } } -// NOLINTEND(clang-analyzer-unix.Malloc) void RP2040BLE::enable() { if (this->state_ == BLEComponentState::ACTIVE || this->state_ == BLEComponentState::ENABLING) { diff --git a/esphome/core/event_pool.h b/esphome/core/event_pool.h index fe207d04bf..b53b8064a3 100644 --- a/esphome/core/event_pool.h +++ b/esphome/core/event_pool.h @@ -10,7 +10,8 @@ namespace esphome { // Event Pool - On-demand pool of objects to avoid heap fragmentation -// Events are allocated on first use and reused thereafter, growing to peak usage +// Events are allocated on first use and reused thereafter, growing to peak +// usage; warm() pre-creates every entry up front for malloc-free producers // @tparam T The type of objects managed by the pool (must have a release() method) // @tparam SIZE The maximum number of objects in the pool (1-254, limited by uint8_t and the +1 free-list slot) // @@ -53,26 +54,8 @@ template class EventPool { T *event = this->free_list_.pop(); if (event != nullptr) return event; - // Need to create a new event - if (this->total_created_ >= SIZE) { - // Pool is at capacity - return nullptr; - } - - // Use internal RAM for better performance - RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); - event = allocator.allocate(1); - - if (event == nullptr) { - // Memory allocation failed - return nullptr; - } - - // Placement new to construct the object - new (event) T(); - this->total_created_++; - return event; + return this->create_(); } // Return an event to the pool for reuse @@ -84,7 +67,45 @@ template class EventPool { } } + // Pre-create every pool entry so allocate() is always a free-list pop + // (for producers that must never malloc, e.g. IRQ-context handlers). + // Call from setup(); on false the heap could not supply every entry and + // the caller should mark_failed() — an incomplete warm puts malloc() + // back on the producer path. Tops the pool up from any quiescent state + // (entries that already exist are counted, not re-created); must not run + // concurrently with allocate()/release(). + bool warm() { + // NOLINTNEXTLINE(clang-analyzer-unix.Malloc) -- ownership transfers to the free list + while (this->total_created_ < SIZE) { + T *event = this->create_(); + if (event == nullptr) + return false; + this->free_list_.push(event); + } + return true; + } + private: + // Create and count one new object (shared by allocate() and warm()). + // Returns nullptr at capacity or when the heap is exhausted. + T *create_() { + if (this->total_created_ >= SIZE) { + // Pool is at capacity + return nullptr; + } + // Use internal RAM for better performance + RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); + T *event = allocator.allocate(1); + if (event == nullptr) { + // Memory allocation failed + return nullptr; + } + // Placement new to construct the object + new (event) T(); + this->total_created_++; + return event; + } + // SIZE + 1 slots so all SIZE objects fit when the pool is fully drained // (the ring reserves one slot); otherwise the last release() of a // completely returned pool would drop, permanently orphaning one object. diff --git a/tests/components/core/test_event_pool.cpp b/tests/components/core/test_event_pool.cpp index af54ac3e14..da13924c65 100644 --- a/tests/components/core/test_event_pool.cpp +++ b/tests/components/core/test_event_pool.cpp @@ -70,4 +70,67 @@ TEST(EventPool, ReleaseNullptrIsSafe) { EXPECT_NE(pool.allocate(), nullptr); } +TEST(EventPool, WarmFullyPopulatesThePool) { + // warm()'s guarantee is invisible at runtime: no later allocate() may touch + // malloc(). Fully populated means SIZE allocations succeed from the free + // list and the SIZE + 1-th refuses. + esphome::EventPool pool; + ASSERT_TRUE(pool.warm()); + PoolItem *items[4]; + for (auto *&item : items) { + item = pool.allocate(); + ASSERT_NE(item, nullptr); + } + EXPECT_EQ(pool.allocate(), nullptr); +} + +TEST(EventPool, WarmIsIdempotent) { + esphome::EventPool pool; + ASSERT_TRUE(pool.warm()); + ASSERT_TRUE(pool.warm()); + // Still exactly SIZE objects: no growth past capacity. + PoolItem *items[3]; + for (auto *&item : items) { + item = pool.allocate(); + ASSERT_NE(item, nullptr); + } + EXPECT_EQ(pool.allocate(), nullptr); +} + +TEST(EventPool, AllocateAfterWarmRecyclesTheWarmedObjects) { + // The objects handed out after warm() are the ones warm() created, + // recycled rather than re-created. + esphome::EventPool pool; + ASSERT_TRUE(pool.warm()); + std::set first_round; + PoolItem *items[4]; + for (auto *&item : items) { + item = pool.allocate(); + first_round.insert(item); + } + for (auto *item : items) + pool.release(item); + for (int i = 0; i < 4; i++) { + PoolItem *item = pool.allocate(); + ASSERT_NE(item, nullptr); + EXPECT_TRUE(first_round.count(item) == 1); + } +} + +TEST(EventPool, WarmTopsUpWithEntriesOutstanding) { + // warm() counts existing entries (free or checked out) instead of failing + // when some are outstanding: it tops the pool up from any state. + esphome::EventPool pool; + PoolItem *held = pool.allocate(); + ASSERT_NE(held, nullptr); + ASSERT_TRUE(pool.warm()); + // The held object plus three more accounts for all SIZE entries. + PoolItem *items[3]; + for (auto *&item : items) { + item = pool.allocate(); + ASSERT_NE(item, nullptr); + } + EXPECT_EQ(pool.allocate(), nullptr); +} + } // namespace esphome::core::testing From 96f0ea10f4b655dea2015d2b3430f95717aa5235 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:46:28 -0400 Subject: [PATCH 1294/1815] [midea] Bump MideaUART so its ESP-IDF shims stop shadowing millis() (#18119) --- esphome/components/midea/appliance_base.h | 7 +++++++ esphome/components/midea/climate.py | 2 +- platformio.ini | 2 +- tests/components/midea/test.esp32-idf.yaml | 7 +++++++ 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/esphome/components/midea/appliance_base.h b/esphome/components/midea/appliance_base.h index a866d3f51b..bce433394b 100644 --- a/esphome/components/midea/appliance_base.h +++ b/esphome/components/midea/appliance_base.h @@ -18,6 +18,13 @@ namespace esphome::midea { +// Mirrors the ARDUINO switch in MideaUART Helpers/Platform.h: these types +// exist in the dudanov namespace exactly when the library is not on Arduino +#ifndef ARDUINO +using dudanov::Stream; +using dudanov::String; +#endif + /* Stream from UART component */ class UARTStream : public Stream { public: diff --git a/esphome/components/midea/climate.py b/esphome/components/midea/climate.py index 292e7215a9..0e03bca233 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -313,5 +313,5 @@ async def to_code(config): cg.add_library( name="MideaUART", version=None, - repository="https://github.com/dudanov/MideaUART.git#7a4d1e9a4b6f07a3464c2453ee828c0c7b7e1bcf", + repository="https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815", ) diff --git a/platformio.ini b/platformio.ini index adc1995440..bf3b0685f8 100644 --- a/platformio.ini +++ b/platformio.ini @@ -44,7 +44,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} - https://github.com/dudanov/MideaUART.git#7a4d1e9a4b6f07a3464c2453ee828c0c7b7e1bcf ; midea + https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea esphome/noise-c@0.1.11 ; api improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image diff --git a/tests/components/midea/test.esp32-idf.yaml b/tests/components/midea/test.esp32-idf.yaml index ae12002b31..5ad22b5b93 100644 --- a/tests/components/midea/test.esp32-idf.yaml +++ b/tests/components/midea/test.esp32-idf.yaml @@ -6,3 +6,10 @@ packages: wifi: ssid: MySSID password: password1 + +# Regression test for https://github.com/esphome/esphome/issues/18054: the +# MideaUART ESP-IDF shims must not make unqualified millis() ambiguous +interval: + - interval: 10s + then: + - lambda: ESP_LOGD("test", "%u", millis()); From bf95be555071b3831ddf509178a18c6f9e22d987 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:46:37 -0400 Subject: [PATCH 1295/1815] [opentherm] Initialize output state before the first write (#18126) --- esphome/components/opentherm/output/opentherm_output.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/opentherm/output/opentherm_output.h b/esphome/components/opentherm/output/opentherm_output.h index 24d5052076..da2082963d 100644 --- a/esphome/components/opentherm/output/opentherm_output.h +++ b/esphome/components/opentherm/output/opentherm_output.h @@ -14,7 +14,7 @@ class OpenthermOutput final : public output::FloatOutput, public Component, publ float min_value_, max_value_; public: - float state; + float state{0.0f}; void set_id(const char *id) { this->id_ = id; } From e58ba592887e964eddbc06a62f0939fb695419d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 6 Aug 2026 20:58:41 -0500 Subject: [PATCH 1296/1815] [core] Add StringRef::starts_with (#18142) --- esphome/core/string_ref.h | 7 +++ tests/components/core/test_string_ref.cpp | 62 +++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 tests/components/core/test_string_ref.cpp diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index 34ba2474b2..33459f48af 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -76,6 +76,13 @@ class StringRef { constexpr bool empty() const { return len_ == 0; } constexpr const_reference operator[](size_type pos) const { return *(base_ + pos); } + /// True if the view begins with the given prefix (std::string::starts_with-like) + bool starts_with(const StringRef &prefix) const { + return len_ >= prefix.len_ && std::memcmp(base_, prefix.base_, prefix.len_) == 0; + } + bool starts_with(const char *prefix) const { return this->starts_with(StringRef(prefix)); } + bool starts_with(const std::string &prefix) const { return this->starts_with(StringRef(prefix)); } + /// Copy characters to destination buffer (std::string::copy-like, but returns 0 instead of throwing on out-of-range) size_type copy(char *dest, size_type count, size_type pos = 0) const { if (pos >= len_) diff --git a/tests/components/core/test_string_ref.cpp b/tests/components/core/test_string_ref.cpp new file mode 100644 index 0000000000..bcbd0aa0d4 --- /dev/null +++ b/tests/components/core/test_string_ref.cpp @@ -0,0 +1,62 @@ +#include + +#include "esphome/core/string_ref.h" + +namespace esphome::core::testing { + +TEST(StringRefStartsWith, ProperPrefixMatches) { + StringRef ref("FR:R20:12345", 12); + EXPECT_TRUE(ref.starts_with("FR:")); +} + +TEST(StringRefStartsWith, WholeStringIsAPrefixOfItself) { + StringRef ref("TP96", 4); + EXPECT_TRUE(ref.starts_with("TP96")); +} + +TEST(StringRefStartsWith, PrefixLongerThanViewFails) { + StringRef ref("TP", 2); + EXPECT_FALSE(ref.starts_with("TP96")); +} + +TEST(StringRefStartsWith, DifferentContentFails) { + StringRef ref("TP96", 4); + EXPECT_FALSE(ref.starts_with("FR:")); +} + +TEST(StringRefStartsWith, EmptyPrefixAlwaysMatches) { + StringRef ref("abc", 3); + EXPECT_TRUE(ref.starts_with("")); + StringRef empty; + EXPECT_TRUE(empty.starts_with("")); +} + +TEST(StringRefStartsWith, EmptyViewOnlyMatchesEmptyPrefix) { + StringRef empty; + EXPECT_FALSE(empty.starts_with("a")); +} + +TEST(StringRefStartsWith, WorksOnANonTerminatedBuffer) { + // The reason the helper exists: a bounded view over a buffer with no + // terminator anywhere near the viewed bytes. + const char raw[] = {'R', 'a', 'd', 'o', 'n', 'X'}; + StringRef ref(raw, 5); + EXPECT_TRUE(ref.starts_with("Radon")); + EXPECT_FALSE(ref.starts_with("RadonEye")); + EXPECT_FALSE(ref.starts_with("adon")); +} + +TEST(StringRefStartsWith, StdStringOverload) { + StringRef ref("TP96", 4); + EXPECT_TRUE(ref.starts_with(std::string("TP"))); + EXPECT_FALSE(ref.starts_with(std::string("96"))); +} + +TEST(StringRefStartsWith, RefOverloadComparesOnlyTheViewedLength) { + // The prefix is a bounded view: bytes past its length must not be compared. + StringRef ref("FR:123", 6); + StringRef prefix("FR:xyz", 3); + EXPECT_TRUE(ref.starts_with(prefix)); +} + +} // namespace esphome::core::testing From 634515ecc56c3ac2e583f2c8558f9f73e4df72eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 6 Aug 2026 21:45:57 -0500 Subject: [PATCH 1297/1815] [ble_device_base] Add platform-neutral GATT client contract (#18128) --- .../components/ble_device_base/__init__.py | 15 ++ .../ble_device_base/ble_client_state.cpp | 26 ++++ .../ble_device_base/ble_client_state.h | 53 +++++++ .../components/ble_device_base/ble_device.cpp | 13 +- .../components/ble_device_base/ble_device.h | 21 ++- .../ble_device_base/ble_gatt_client.h | 140 ++++++++++++++++++ esphome/components/ble_device_base/ble_hub.h | 5 +- esphome/components/ble_scanner/ble_scanner.h | 2 +- .../bluetooth_proxy/bluetooth_proxy.h | 4 +- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 21 --- .../esp32_ble_tracker/esp32_ble_tracker.h | 37 +---- .../thermopro_ble/thermopro_ble.cpp | 2 +- .../components/thermopro_ble/thermopro_ble.h | 2 +- esphome/core/defines.h | 2 + .../ble_device_base/test_address.cpp | 14 ++ .../ble_device_base/test_adv_name.cpp | 91 ++++++++++++ .../test_gatt_client_contract.cpp | 66 +++++++++ 17 files changed, 450 insertions(+), 64 deletions(-) create mode 100644 esphome/components/ble_device_base/ble_client_state.cpp create mode 100644 esphome/components/ble_device_base/ble_client_state.h create mode 100644 esphome/components/ble_device_base/ble_gatt_client.h create mode 100644 tests/components/ble_device_base/test_adv_name.cpp create mode 100644 tests/components/ble_device_base/test_gatt_client_contract.cpp diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index ad162c616d..a52286f456 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -150,6 +150,21 @@ def request_irk_support() -> None: cg.add_define("USE_BLE_DEVICE_IRK") +# Number of GATT client connection slots in this build; sizes the platform +# backend's connection storage. +GATT_CLIENT_COUNT_DEFINE = "ESPHOME_BLE_GATT_CLIENT_COUNT" + +_request_gatt_connection_slot = cg.slot_counter(GATT_CLIENT_COUNT_DEFINE) + + +def request_gatt_client() -> None: + """Compile in the neutral GATT client contract (ble_gatt_client.h) and + claim one connection slot. Called by bluetooth_proxy once per connection + it instantiates on a hub platform.""" + cg.add_define("USE_BLE_GATT_CLIENT") + _request_gatt_connection_slot() + + _request_listener_slot = cg.slot_counter(LISTENER_COUNT_DEFINE) diff --git a/esphome/components/ble_device_base/ble_client_state.cpp b/esphome/components/ble_device_base/ble_client_state.cpp new file mode 100644 index 0000000000..55817024b5 --- /dev/null +++ b/esphome/components/ble_device_base/ble_client_state.cpp @@ -0,0 +1,26 @@ +#include "ble_client_state.h" + +namespace esphome::ble_device_base { + +const char *client_state_to_string(ClientState state) { + switch (state) { + case ClientState::INIT: + return "INIT"; + case ClientState::DISCONNECTING: + return "DISCONNECTING"; + case ClientState::IDLE: + return "IDLE"; + case ClientState::DISCOVERED: + return "DISCOVERED"; + case ClientState::CONNECTING: + return "CONNECTING"; + case ClientState::CONNECTED: + return "CONNECTED"; + case ClientState::ESTABLISHED: + return "ESTABLISHED"; + default: + return "UNKNOWN"; + } +} + +} // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/ble_client_state.h b/esphome/components/ble_device_base/ble_client_state.h new file mode 100644 index 0000000000..58f7d84fad --- /dev/null +++ b/esphome/components/ble_device_base/ble_client_state.h @@ -0,0 +1,53 @@ +// ble_client_state.h +// +// Platform-neutral GATT client connection state types, shared by every +// platform's GATT client implementation (esp32_ble_client, bluetooth_connection +// backends). Moved here from esp32_ble_tracker, which re-exports them under its +// own namespace for backward compatibility. + +#pragma once + +#include + +namespace esphome::ble_device_base { + +/// ESPHome-private errors for the API's plain-int error fields, outside the +/// ATT code range so they cannot be mistaken for spec errors. -1 is +/// understood by API clients as "not connected". Shared by every GATT +/// client backend. +static constexpr int GATT_ERR_NOT_CONNECTED = -1; +static constexpr int GATT_ERR_NO_MEMORY = -2; + +enum class ClientState : uint8_t { + // Connection is allocated + INIT, + // Client is disconnecting + DISCONNECTING, + // Connection is idle, no device detected. + IDLE, + // Device advertisement found. + DISCOVERED, + // Connection in progress. + CONNECTING, + // Initial connection established. + CONNECTED, + // The client and sub-clients have completed setup. + ESTABLISHED, +}; + +// Helper function to convert ClientState to string +const char *client_state_to_string(ClientState state); + +enum class ConnectionType : uint8_t { + // The default connection type, we hold all the services in ram + // for the duration of the connection. + V1, + // The client has a cache of the services and mtu so we should not + // fetch them again + V3_WITH_CACHE, + // The client does not need the services and mtu once we send them + // so we should wipe them from memory as soon as we send them + V3_WITHOUT_CACHE +}; + +} // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/ble_device.cpp b/esphome/components/ble_device_base/ble_device.cpp index c2a43ee7e9..fc5bf5c1e0 100644 --- a/esphome/components/ble_device_base/ble_device.cpp +++ b/esphome/components/ble_device_base/ble_device.cpp @@ -349,7 +349,8 @@ void ESPBTDevice::from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_ty this->address_[i] = mac[5 - i]; this->address_type_ = addr_type; this->rssi_ = rssi; - this->name_.clear(); + this->name_len_ = 0; + this->name_[0] = '\0'; this->service_uuids_.clear(); this->manufacturer_datas_.clear(); this->service_datas_.clear(); @@ -365,7 +366,7 @@ void ESPBTDevice::from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_ty " Address: %s (%s)\n" " RSSI: %d\n" " Name: '%s'", - this->address_str_to(addr_buf), this->address_type_str(), this->rssi_, this->name_.c_str()); + this->address_str_to(addr_buf), this->address_type_str(), this->rssi_, this->name_); for (auto &it : this->tx_powers_) { ESP_LOGVV(TAG, " TX Power: %d", it); } @@ -477,8 +478,12 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint16_t len) { // Keep the longest name seen — a merged adv + scan-response frame may carry both the // shortened and the complete name, and the shortened form must never replace the // complete one (same rule as esp32_ble_tracker's parse_adv_). - if (ad_data_len > this->name_.length()) - this->name_.assign(reinterpret_cast(ad_data), ad_data_len); + if (ad_data_len > this->name_len_) { + uint8_t name_len = ad_data_len > MAX_ADV_NAME_LEN ? MAX_ADV_NAME_LEN : static_cast(ad_data_len); + memcpy(this->name_, ad_data, name_len); + this->name_[name_len] = '\0'; + this->name_len_ = name_len; + } break; case 0x0A: // TX Power Level diff --git a/esphome/components/ble_device_base/ble_device.h b/esphome/components/ble_device_base/ble_device.h index 0f40c51b29..fba1fe2347 100644 --- a/esphome/components/ble_device_base/ble_device.h +++ b/esphome/components/ble_device_base/ble_device.h @@ -14,6 +14,7 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" #include #include @@ -166,6 +167,13 @@ inline uint64_t mac_lsb_first_to_uint64(const uint8_t *mac) { return addr; } +/// Unpack a uint64 BLE address into printable (MSB-first) byte order — +/// the order bd_addr_t / esp_bd_addr_t style APIs expect. +inline void uint64_to_mac_msb_first(uint64_t address, uint8_t out[6]) { + for (int i = 0; i < 6; i++) + out[i] = (address >> ((5 - i) * 8)) & 0xFF; +} + // --------------------------------------------------------------------------- // ESPBTDevice — parsed BLE advertisement // --------------------------------------------------------------------------- @@ -211,7 +219,9 @@ class ESPBTDevice { const char *address_type_str() const; int get_rssi() const { return rssi_; } - const std::string &get_name() const { return name_; } + /// Advertised name as a view into the fixed buffer (always NUL-terminated, + /// so c_str() is safe); converts implicitly to std::string where needed. + StringRef get_name() const { return StringRef(this->name_, this->name_len_); } const std::vector &get_service_uuids() const { return service_uuids_; } const std::vector &get_manufacturer_datas() const { return manufacturer_datas_; } @@ -230,10 +240,17 @@ class ESPBTDevice { protected: void parse_adv_(const uint8_t *payload, uint16_t len); + // Max name bytes in a legacy advertisement AD element (31-byte PDU minus + // the 2-byte element header); every in-tree tracker scans legacy PDUs only. + static constexpr uint8_t MAX_ADV_NAME_LEN = 29; + uint8_t address_[6]{0}; uint8_t address_type_{0}; int rssi_{0}; - std::string name_{}; + // Fixed buffer instead of std::string: no per-advertisement heap churn on + // the scan path, and no libstdc++ string/exception machinery in the image. + char name_[MAX_ADV_NAME_LEN + 1]{}; + uint8_t name_len_{0}; std::vector service_uuids_{}; std::vector manufacturer_datas_{}; std::vector service_datas_{}; diff --git a/esphome/components/ble_device_base/ble_gatt_client.h b/esphome/components/ble_device_base/ble_gatt_client.h new file mode 100644 index 0000000000..ef28f7672a --- /dev/null +++ b/esphome/components/ble_device_base/ble_gatt_client.h @@ -0,0 +1,140 @@ +// ble_gatt_client.h +// +// Platform-neutral GATT client connection contract. +// +// A platform's GATT client backend (bluetooth_connection/esp32, +// bluetooth_connection/rp2) implements BLEGattConnection; consumers +// (bluetooth_proxy) drive it through this interface and receive +// completions through GattClientEventListener. All listener callbacks are +// delivered on the ESPHome main loop; borrowed data pointers are valid only +// for the duration of the call. +// +// Error domain (plain int, forwarded to the API without translation): +// 0 success +// 1..0x11 ATT error codes (Bluetooth spec; BTstack and Bluedroid agree) +// GATT_ERR_NOT_CONNECTED (-1) no connection to the peer (on esp32 a raw +// ESP_FAIL from the stack shares this value; both read as a +// failed, unusable connection on the client side) +// GATT_ERR_NO_MEMORY (-2) backend storage exhausted +// anything else: platform stack error/status code, surfaced opaquely. +// Connection events carry HCI status/disconnect reason codes (same code +// space on every controller). + +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_BLE_GATT_CLIENT + +#include "ble_client_state.h" +#include "ble_device.h" + +#include + +namespace esphome::ble_device_base { + +// Materialized GATT database of a connected peer, discovered by the backend +// and streamed to the API by the consumer. Flat arrays with index ranges +// (not pointers): a service owns characteristics +// [first_characteristic, first_characteristic + characteristic_count) and a +// characteristic owns descriptors [first_descriptor, ...) — discovery is +// depth-first, so the ranges are naturally contiguous. +struct GattDescriptor { + ESPBTUUID uuid; + uint16_t handle; +}; + +struct GattCharacteristic { + ESPBTUUID uuid; + uint16_t value_handle; + // Needed to rebuild the stack's characteristic object for CCCD operations. + uint16_t end_handle; + uint8_t properties; // Bluetooth spec property bitfield + uint16_t first_descriptor; + uint16_t descriptor_count; +}; + +struct GattService { + ESPBTUUID uuid; + uint16_t start_handle; + uint16_t end_handle; + uint16_t first_characteristic; + uint16_t characteristic_count; +}; + +/// Borrowed view of the backend-owned service table. Valid from a successful +/// on_service_discovery_done() until release_services(). Characteristics and +/// descriptors are reached through the per-service/per-characteristic index +/// ranges; the array totals let a consumer bounds-check those ranges instead +/// of trusting the backend's discovery bookkeeping blindly. +struct GattServiceTable { + const GattService *services{nullptr}; + const GattCharacteristic *characteristics{nullptr}; + const GattDescriptor *descriptors{nullptr}; + uint16_t service_count{0}; + uint16_t characteristic_count{0}; + uint16_t descriptor_count{0}; +}; + +/// Completion/event sink for a GATT connection. Implemented by the consumer +/// (bluetooth_proxy's connection wrapper). Every callback runs on the main loop. +class GattClientEventListener { + public: + virtual ~GattClientEventListener() = default; + + /// Connected (with negotiated MTU) or disconnected/connect-failed + /// (error = HCI status or disconnect reason). + virtual void on_connection_state(bool connected, uint16_t mtu, int error) = 0; + /// Service discovery finished; on success the service table is populated. + virtual void on_service_discovery_done(int error) = 0; + /// Characteristic or descriptor read finished. data/len valid during the call. + virtual void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) = 0; + /// Characteristic write-with-response or descriptor write finished. + virtual void on_write_result(uint16_t handle, int error) = 0; + /// Notification/indication registration state changed. + virtual void on_notify_state(uint16_t handle, bool enabled, int error) = 0; + /// Notification/indication data from the peer. data/len valid during the call. + virtual void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) = 0; +}; + +/// One GATT client connection slot. Operations return 0 when accepted +/// (completion arrives via the listener) or a synchronous error code +/// (busy, not connected, stack rejection). One operation may be outstanding +/// at a time; callers see a synchronous error otherwise. +class BLEGattConnection { + public: + virtual ~BLEGattConnection() = default; + + void set_listener(GattClientEventListener *listener) { this->listener_ = listener; } + + /// Start connecting to a peer. addr_type is a BLE_ADDR_TYPE_* constant + /// (ble_device.h). Completion: on_connection_state(). + virtual int connect(uint64_t address, uint8_t addr_type) = 0; + /// Disconnect (or cancel a connect in progress). Completion: on_connection_state(). + virtual int disconnect() = 0; + /// Discover the peer's services/characteristics/descriptors into the + /// service table. Completion: on_service_discovery_done(). + virtual int discover_services() = 0; + virtual int read_characteristic(uint16_t handle) = 0; + virtual int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) = 0; + virtual int read_descriptor(uint16_t handle) = 0; + virtual int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) = 0; + /// Enable/disable delivery of on_notify_data() for a characteristic value + /// handle. Local registration only — the CCCD write is the API client's + /// responsibility (it arrives as a plain write_descriptor). + virtual int notify_characteristic(uint16_t handle, bool enable) = 0; + virtual int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout) = 0; + + /// Backend-owned service table (see GattServiceTable lifetime). + virtual GattServiceTable get_service_table() = 0; + /// Free the transient service table storage. Call after streaming. + virtual void release_services() = 0; + + protected: + GattClientEventListener *listener_{nullptr}; +}; + +} // namespace esphome::ble_device_base + +#endif // USE_BLE_GATT_CLIENT diff --git a/esphome/components/ble_device_base/ble_hub.h b/esphome/components/ble_device_base/ble_hub.h index 454478e0eb..3870e9833e 100644 --- a/esphome/components/ble_device_base/ble_hub.h +++ b/esphome/components/ble_device_base/ble_hub.h @@ -56,8 +56,9 @@ struct HubCapabilities { /// frame. When false, consumers relying on scan-response fields (e.g. names) /// may only see them where the receiver merges per address (Home Assistant does). bool merges_scan_response; - /// GATT client connections are available (today: esp32 only, but a chip SDK - /// gaining GATT support only has to flip this bit). + /// GATT client connections are available: the platform has a + /// bluetooth_connection backend implementing ble_device_base::BLEGattConnection + /// (ble_gatt_client.h). Today: esp32; rp2 follows with its BTstack backend. bool gatt; /// request_scan_mode() is honored at runtime. Distinct from active_scan: /// a passive-only controller (bk72xx) can never switch, and a hub may diff --git a/esphome/components/ble_scanner/ble_scanner.h b/esphome/components/ble_scanner/ble_scanner.h index 106171d38f..b4e4488646 100644 --- a/esphome/components/ble_scanner/ble_scanner.h +++ b/esphome/components/ble_scanner/ble_scanner.h @@ -23,7 +23,7 @@ class BLEScanner final : public text_sensor::TextSensor, // Escape special characters in the device name for valid JSON. Control characters stay in the \u00XX form this // sensor has always published. char escaped_name[128]; - json_escape_into_buffer(escaped_name, StringRef(device.get_name()), /*short_control_escapes=*/false); + json_escape_into_buffer(escaped_name, device.get_name(), /*short_control_escapes=*/false); char buf[256]; snprintf(buf, sizeof(buf), "{\"timestamp\":%" PRId64 ",\"address\":\"%s\",\"rssi\":%d,\"name\":\"%s\"}", diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 54236dbc67..fd1f1839c9 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -13,6 +13,8 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/components/ble_device_base/ble_client_state.h" + #ifdef USE_ESP32 #include "esphome/components/esp32_ble_client/ble_client_base.h" #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" @@ -41,7 +43,7 @@ using proxy_err_t = int; static constexpr proxy_err_t PROXY_OK = 0; #endif -static constexpr proxy_err_t ESP_GATT_NOT_CONNECTED = -1; +static constexpr proxy_err_t ESP_GATT_NOT_CONNECTED = ble_device_base::GATT_ERR_NOT_CONNECTED; static constexpr int DONE_SENDING_SERVICES = -2; static constexpr int INIT_SENDING_SERVICES = -3; diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index bae4e1634d..8418fc3fec 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -36,27 +36,6 @@ static const char *const TAG = "esp32_ble_tracker"; ESP32BLETracker *global_esp32_ble_tracker = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -const char *client_state_to_string(ClientState state) { - switch (state) { - case ClientState::INIT: - return "INIT"; - case ClientState::DISCONNECTING: - return "DISCONNECTING"; - case ClientState::IDLE: - return "IDLE"; - case ClientState::DISCOVERED: - return "DISCOVERED"; - case ClientState::CONNECTING: - return "CONNECTING"; - case ClientState::CONNECTED: - return "CONNECTED"; - case ClientState::ESTABLISHED: - return "ESTABLISHED"; - default: - return "UNKNOWN"; - } -} - float ESP32BLETracker::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } void ESP32BLETracker::setup() { diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index fe2a5d8599..88642fff6b 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -18,6 +18,7 @@ #include #include +#include "esphome/components/ble_device_base/ble_client_state.h" #include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/ble_device_base/ble_hub.h" #include "esphome/components/esp32_ble/ble.h" @@ -88,22 +89,11 @@ struct ClientStateCounts { bool operator!=(const ClientStateCounts &other) const { return !(*this == other); } }; -enum class ClientState : uint8_t { - // Connection is allocated - INIT, - // Client is disconnecting - DISCONNECTING, - // Connection is idle, no device detected. - IDLE, - // Device advertisement found. - DISCOVERED, - // Connection in progress. - CONNECTING, - // Initial connection established. - CONNECTED, - // The client and sub-clients have completed setup. - ESTABLISHED, -}; +// The client connection state types are owned by the platform-neutral +// ble_device_base layer; re-exported here for backward compatibility. +using ClientState = ble_device_base::ClientState; +using ConnectionType = ble_device_base::ConnectionType; +using ble_device_base::client_state_to_string; enum class ScannerState { // Scanner is idle, init state @@ -128,21 +118,6 @@ class BLEScannerStateListener { virtual void on_scanner_state(ScannerState state) = 0; }; -// Helper function to convert ClientState to string -const char *client_state_to_string(ClientState state); - -enum class ConnectionType : uint8_t { - // The default connection type, we hold all the services in ram - // for the duration of the connection. - V1, - // The client has a cache of the services and mtu so we should not - // fetch them again - V3_WITH_CACHE, - // The client does not need the services and mtu once we send them - // so we should wipe them from memory as soon as we send them - V3_WITHOUT_CACHE -}; - /// Base class for BLE GATT clients that connect to remote devices. /// /// State Change Tracking Design: diff --git a/esphome/components/thermopro_ble/thermopro_ble.cpp b/esphome/components/thermopro_ble/thermopro_ble.cpp index 2a950d3664..72e398f774 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.cpp +++ b/esphome/components/thermopro_ble/thermopro_ble.cpp @@ -92,7 +92,7 @@ bool ThermoProBLE::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { return success; } -void ThermoProBLE::update_device_type_(const std::string &device_name) { +void ThermoProBLE::update_device_type_(StringRef device_name) { // check for changed device name (should only happen on initial call) if (this->device_name_ == device_name) { return; diff --git a/esphome/components/thermopro_ble/thermopro_ble.h b/esphome/components/thermopro_ble/thermopro_ble.h index 2d7523e07a..ca04fbea39 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.h +++ b/esphome/components/thermopro_ble/thermopro_ble.h @@ -41,7 +41,7 @@ class ThermoProBLE final : public Component, public esp32_ble_tracker::ESPBTDevi sensor::Sensor *humidity_{nullptr}; sensor::Sensor *battery_level_{nullptr}; - void update_device_type_(const std::string &device_name); + void update_device_type_(StringRef device_name); }; } // namespace esphome::thermopro_ble diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 75a92f8fce..63b9c87918 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -464,6 +464,8 @@ #define USE_RP2040_BLE #define RP2040_BLE_SCAN_LISTENER_COUNT 1 #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 +#define USE_BLE_GATT_CLIENT +#define ESPHOME_BLE_GATT_CLIENT_COUNT 1 #define USE_RP2040_VARIANT_RP2040 #define USE_SPI #ifndef USE_ETHERNET diff --git a/tests/components/ble_device_base/test_address.cpp b/tests/components/ble_device_base/test_address.cpp index f1eebcbfc1..7ff318b66b 100644 --- a/tests/components/ble_device_base/test_address.cpp +++ b/tests/components/ble_device_base/test_address.cpp @@ -50,4 +50,18 @@ TEST(BleDeviceAddress, MacLsbFirstToUint64AgreesWithParsedDevice) { EXPECT_EQ(mac_lsb_first_to_uint64(MAC_LSB_FIRST), device.address_uint64()); } +// uint64_to_mac_msb_first() is the inverse: unpacking the wire value yields +// printable (MSB-first) order, and round-tripping through the LSB-first +// packer restores the original value. +TEST(BleDeviceAddress, Uint64ToMacMsbFirstRoundTrip) { + uint8_t msb_first[6]; + uint64_to_mac_msb_first(0xAABBCCDDEEFFULL, msb_first); + EXPECT_EQ(msb_first[0], 0xaa); + EXPECT_EQ(msb_first[5], 0xff); + uint8_t lsb_first[6]; + for (int i = 0; i < 6; i++) + lsb_first[i] = msb_first[5 - i]; + EXPECT_EQ(mac_lsb_first_to_uint64(lsb_first), 0xAABBCCDDEEFFULL); +} + } // namespace esphome::ble_device_base::testing diff --git a/tests/components/ble_device_base/test_adv_name.cpp b/tests/components/ble_device_base/test_adv_name.cpp new file mode 100644 index 0000000000..44fb3525ea --- /dev/null +++ b/tests/components/ble_device_base/test_adv_name.cpp @@ -0,0 +1,91 @@ +#include "esphome/components/ble_device_base/ble_device.h" + +#include + +#include +#include +#include + +namespace esphome::ble_device_base { +namespace { + +// AD types under test +constexpr uint8_t AD_SHORT_NAME = 0x08; +constexpr uint8_t AD_COMPLETE_NAME = 0x09; + +void append_name(std::vector &adv, uint8_t ad_type, const char *name) { + size_t len = strlen(name); + adv.push_back(static_cast(len + 1)); + adv.push_back(ad_type); + adv.insert(adv.end(), name, name + len); +} + +ESPBTDevice device_from(const std::vector &adv) { + const uint8_t mac[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66}; + ESPBTDevice device; + device.from_scan_result(mac, -59, 0, adv.data(), static_cast(adv.size())); + return device; +} + +} // namespace + +TEST(BleAdvName, ParsesACompleteName) { + std::vector adv; + append_name(adv, AD_COMPLETE_NAME, "TP96"); + ESPBTDevice device = device_from(adv); + EXPECT_EQ(device.get_name(), "TP96"); + // The backing buffer is NUL-terminated so c_str() is usable directly. + EXPECT_STREQ(device.get_name().c_str(), "TP96"); +} + +TEST(BleAdvName, LongestNameWinsShortenedThenComplete) { + // A merged adv + scan-response frame can carry both forms; the shortened + // one must never replace the complete one. + std::vector adv; + append_name(adv, AD_SHORT_NAME, "Radon"); + append_name(adv, AD_COMPLETE_NAME, "RadonEye"); + EXPECT_EQ(device_from(adv).get_name(), "RadonEye"); +} + +TEST(BleAdvName, LongestNameWinsCompleteThenShortened) { + std::vector adv; + append_name(adv, AD_COMPLETE_NAME, "RadonEye"); + append_name(adv, AD_SHORT_NAME, "Radon"); + EXPECT_EQ(device_from(adv).get_name(), "RadonEye"); +} + +TEST(BleAdvName, MaxLengthNameFitsAndTerminates) { + // 29 bytes is the largest name a legacy AD element can carry and exactly + // fills the fixed buffer. + std::string max_name(29, 'a'); + std::vector adv; + append_name(adv, AD_COMPLETE_NAME, max_name.c_str()); + ESPBTDevice device = device_from(adv); + EXPECT_EQ(device.get_name().size(), 29u); + EXPECT_EQ(device.get_name(), max_name); + EXPECT_STREQ(device.get_name().c_str(), max_name.c_str()); +} + +TEST(BleAdvName, ReparseResetsThePreviousName) { + const uint8_t mac[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66}; + std::vector first; + append_name(first, AD_COMPLETE_NAME, "RadonEye"); + std::vector second; + append_name(second, AD_COMPLETE_NAME, "TP96"); + + ESPBTDevice device; + device.from_scan_result(mac, -59, 0, first.data(), static_cast(first.size())); + ASSERT_EQ(device.get_name(), "RadonEye"); + // A shorter name from a fresh report must fully replace the longer one: + // the longest-name rule applies within one report, not across reports. + device.from_scan_result(mac, -59, 0, second.data(), static_cast(second.size())); + EXPECT_EQ(device.get_name(), "TP96"); + EXPECT_STREQ(device.get_name().c_str(), "TP96"); +} + +TEST(BleAdvName, NoNamePresentIsEmpty) { + std::vector adv = {0x02, 0x0A, 0x00}; // TX power only + EXPECT_TRUE(device_from(adv).get_name().empty()); +} + +} // namespace esphome::ble_device_base diff --git a/tests/components/ble_device_base/test_gatt_client_contract.cpp b/tests/components/ble_device_base/test_gatt_client_contract.cpp new file mode 100644 index 0000000000..fb743b2699 --- /dev/null +++ b/tests/components/ble_device_base/test_gatt_client_contract.cpp @@ -0,0 +1,66 @@ +// The GATT client contract compiles in no real build until a hub backend is +// configured; this TU pins it on the host so the header cannot rot unseen. +#define USE_BLE_GATT_CLIENT + +#include "esphome/components/ble_device_base/ble_gatt_client.h" + +#include + +namespace esphome::ble_device_base::testing { + +class RecordingListener : public GattClientEventListener { + public: + void on_connection_state(bool connected, uint16_t mtu, int error) override { this->connected_ = connected; } + void on_service_discovery_done(int error) override { this->discovery_error_ = error; } + void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) override {} + void on_write_result(uint16_t handle, int error) override {} + void on_notify_state(uint16_t handle, bool enabled, int error) override {} + void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) override {} + bool connected_{false}; + int discovery_error_{0}; +}; + +class MinimalConnection : public BLEGattConnection { + public: + int connect(uint64_t address, uint8_t addr_type) override { + if (this->listener_ != nullptr) + this->listener_->on_connection_state(true, 517, 0); + return 0; + } + int disconnect() override { return 0; } + int discover_services() override { + if (this->listener_ != nullptr) + this->listener_->on_service_discovery_done(0); + return 0; + } + int read_characteristic(uint16_t handle) override { return GATT_ERR_NOT_CONNECTED; } + int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) override { return 0; } + int read_descriptor(uint16_t handle) override { return 0; } + int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) override { return 0; } + int notify_characteristic(uint16_t handle, bool enable) override { return 0; } + int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout) override { + return 0; + } + GattServiceTable get_service_table() override { return {}; } + void release_services() override {} +}; + +TEST(BleGattClientContract, MinimalImplementerCompilesAndRoutesEvents) { + MinimalConnection connection; + RecordingListener listener; + connection.set_listener(&listener); + EXPECT_EQ(connection.connect(0xAABBCCDDEEFFULL, 0), 0); + EXPECT_TRUE(listener.connected_); + EXPECT_EQ(connection.discover_services(), 0); + EXPECT_EQ(listener.discovery_error_, 0); + EXPECT_EQ(connection.read_characteristic(1), GATT_ERR_NOT_CONNECTED); + + // A default table is empty and safe to walk. + GattServiceTable table = connection.get_service_table(); + EXPECT_EQ(table.service_count, 0); + EXPECT_EQ(table.characteristic_count, 0); + EXPECT_EQ(table.descriptor_count, 0); +} + +} // namespace esphome::ble_device_base::testing From 9c93d8925fa8a407e5ecbe84fe3bbe924df04841 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 7 Aug 2026 07:41:48 -0400 Subject: [PATCH 1298/1815] [ota] Use PSA crypto for signature verification on ESP-IDF 6 (#18145) --- esphome/components/ota/ota_rsa_der.h | 74 ++++++++++++++ .../components/ota/ota_signature_esp_idf.cpp | 75 ++++++++++++++- tests/components/ota/test_rsa_der.cpp | 96 +++++++++++++++++++ 3 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 esphome/components/ota/ota_rsa_der.h create mode 100644 tests/components/ota/test_rsa_der.cpp diff --git a/esphome/components/ota/ota_rsa_der.h b/esphome/components/ota/ota_rsa_der.h new file mode 100644 index 0000000000..1ec4e3cc62 --- /dev/null +++ b/esphome/components/ota/ota_rsa_der.h @@ -0,0 +1,74 @@ +#pragma once + +#include +#include +#include + +namespace esphome::ota { + +// The PSA Crypto API imports an RSA public key as a DER RSAPublicKey +// (RFC 3279 2.3.1), not as raw bignums: +// +// RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER } +// +// The Secure Boot v2 signature block stores the modulus and exponent raw, so +// they are wrapped here. Only RSA-3072 exists in that format, which fixes both +// headers: a 3072-bit modulus always has its top bit set, so its INTEGER is +// always tag + 2-byte length (0x181 = 385) + the sign pad; and the SEQUENCE +// body is always 392..396 bytes, so its header is always tag + 2-byte length. +// Only the exponent varies in width. +constexpr size_t RSA_3072_MODULUS_BYTES = 384; +constexpr uint8_t RSA_DER_MODULUS_PREFIX[] = {0x02, 0x82, 0x01, 0x81, 0x00}; +constexpr size_t RSA_DER_MODULUS_LEN = sizeof(RSA_DER_MODULUS_PREFIX) + RSA_3072_MODULUS_BYTES; // 389 +// 4-byte SEQUENCE header + modulus + the widest exponent INTEGER (tag, length, +// sign pad, 4 bytes). +constexpr size_t RSA_DER_PUBKEY_MAX = 4 + RSA_DER_MODULUS_LEN + 7; + +/// Wrap a raw RSA-3072 modulus and exponent as a DER RSAPublicKey. +/// +/// @param modulus_be Big-endian modulus, RSA_3072_MODULUS_BYTES long. +/// @param exponent_be Big-endian exponent, exponent_len bytes, leading zeros allowed. +/// Rejected if the significant bytes would not fit a short-form length. +/// @return the encoded length, or 0 if the exponent is zero or the buffer is too small. +inline size_t rsa_der_public_key(const uint8_t *modulus_be, const uint8_t *exponent_be, size_t exponent_len, + uint8_t *out, size_t out_len) { + // A DER INTEGER is signed: drop leading zero bytes, then prepend one back if + // the value would otherwise read as negative. + while (exponent_len > 0 && exponent_be[0] == 0x00) { + exponent_be++; + exponent_len--; + } + if (exponent_len == 0) { + return 0; // a zero exponent is not a usable key + } + const bool pad = (exponent_be[0] & 0x80) != 0; + const size_t exponent_content_len = exponent_len + (pad ? 1 : 0); + if (exponent_content_len > 0x7F) { + return 0; // would need a long-form length, which this encoder does not write + } + const size_t exponent_der_len = 2 + exponent_content_len; + const size_t body_len = RSA_DER_MODULUS_LEN + exponent_der_len; + const size_t total_len = 4 + body_len; + if (total_len > out_len) { + return 0; + } + + size_t i = 0; + out[i++] = 0x30; // SEQUENCE + out[i++] = 0x82; // 2-byte length follows + out[i++] = static_cast(body_len >> 8); + out[i++] = static_cast(body_len); + memcpy(out + i, RSA_DER_MODULUS_PREFIX, sizeof(RSA_DER_MODULUS_PREFIX)); + i += sizeof(RSA_DER_MODULUS_PREFIX); + memcpy(out + i, modulus_be, RSA_3072_MODULUS_BYTES); + i += RSA_3072_MODULUS_BYTES; + out[i++] = 0x02; // INTEGER + out[i++] = static_cast(exponent_content_len); + if (pad) { + out[i++] = 0x00; + } + memcpy(out + i, exponent_be, exponent_len); + return total_len; +} + +} // namespace esphome::ota diff --git a/esphome/components/ota/ota_signature_esp_idf.cpp b/esphome/components/ota/ota_signature_esp_idf.cpp index edee594bfe..b327988d2d 100644 --- a/esphome/components/ota/ota_signature_esp_idf.cpp +++ b/esphome/components/ota/ota_signature_esp_idf.cpp @@ -14,9 +14,20 @@ #include #include +#include +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +// mbedtls 4.0 (IDF 6.0) made the legacy mbedtls_rsa_*/mbedtls_sha256_* headers +// private. Use the PSA Crypto API instead, like the sha256 component does. PSA +// crypto is auto-initialized by ESP-IDF at startup (esp_psa_crypto_init.c, +// priority 104), so no psa_crypto_init() call is needed. +#define USE_OTA_SIG_PSA +#include "ota_rsa_der.h" +#include +#else #include #include #include +#endif namespace esphome::ota { @@ -70,7 +81,14 @@ bool block_is_valid(const uint8_t *block) { } bool key_digest_of(const uint8_t *block, KeyDigest &out) { +#ifdef USE_OTA_SIG_PSA + size_t out_len = 0; + return psa_hash_compute(PSA_ALG_SHA_256, block + OFFSET_KEY, KEY_REGION_LEN, out.data(), out.size(), &out_len) == + PSA_SUCCESS && + out_len == out.size(); +#else return mbedtls_sha256(block + OFFSET_KEY, KEY_REGION_LEN, out.data(), /*is224=*/0) == 0; +#endif } // The offset of the signature sector: the app length rounded up to 4 KiB. @@ -93,20 +111,40 @@ bool signature_sector_offset(const esp_partition_t *part, size_t &out_offset) { // Returns false on a read or hash error so a hash failure is not later // misreported as a signature mismatch. bool image_digest(const esp_partition_t *part, size_t image_padded_len, uint8_t *out) { +#ifdef USE_OTA_SIG_PSA + psa_hash_operation_t ctx = PSA_HASH_OPERATION_INIT; + bool ok = psa_hash_setup(&ctx, PSA_ALG_SHA_256) == PSA_SUCCESS; +#else mbedtls_sha256_context ctx; mbedtls_sha256_init(&ctx); bool ok = mbedtls_sha256_starts(&ctx, /*is224=*/0) == 0; +#endif uint8_t buf[512]; for (size_t off = 0; ok && off < image_padded_len; off += sizeof(buf)) { size_t chunk = std::min(sizeof(buf), image_padded_len - off); - if (esp_partition_read(part, off, buf, chunk) != ESP_OK || mbedtls_sha256_update(&ctx, buf, chunk) != 0) { + if (esp_partition_read(part, off, buf, chunk) != ESP_OK) { ok = false; + break; } +#ifdef USE_OTA_SIG_PSA + ok = psa_hash_update(&ctx, buf, chunk) == PSA_SUCCESS; +#else + ok = mbedtls_sha256_update(&ctx, buf, chunk) == 0; +#endif } +#ifdef USE_OTA_SIG_PSA + size_t out_len = 0; + if (ok) { + ok = psa_hash_finish(&ctx, out, SHA256_BYTES, &out_len) == PSA_SUCCESS && out_len == SHA256_BYTES; + } + // A no-op once the operation has been finished + psa_hash_abort(&ctx); +#else if (ok) { ok = mbedtls_sha256_finish(&ctx, out) == 0; } mbedtls_sha256_free(&ctx); +#endif return ok; } @@ -114,6 +152,7 @@ bool image_digest(const esp_partition_t *part, size_t image_padded_len, uint8_t // block's modulus and signature are stored little-endian; reverse them in place // -- block is the caller's scratch buffer, overwritten on the next iteration -- // rather than stacking a second 384-byte copy of each bignum. + bool rsa_pss_verify(uint8_t *block, const uint8_t *digest) { std::reverse(block + OFFSET_MODULUS, block + OFFSET_MODULUS + RSA_3072_BYTES); std::reverse(block + OFFSET_SIGNATURE, block + OFFSET_SIGNATURE + RSA_3072_BYTES); @@ -122,22 +161,48 @@ bool rsa_pss_verify(uint8_t *block, const uint8_t *digest) { uint8_t exponent_be[4] = {static_cast(exponent_le >> 24), static_cast(exponent_le >> 16), static_cast(exponent_le >> 8), static_cast(exponent_le)}; +#ifdef USE_OTA_SIG_PSA + static_assert(RSA_3072_BYTES == RSA_3072_MODULUS_BYTES, "signature block and DER encoder disagree on modulus size"); + uint8_t der[RSA_DER_PUBKEY_MAX]; + const size_t der_len = rsa_der_public_key(block + OFFSET_MODULUS, exponent_be, sizeof(exponent_be), der, sizeof(der)); + psa_key_attributes_t attr = PSA_KEY_ATTRIBUTES_INIT; + psa_set_key_type(&attr, PSA_KEY_TYPE_RSA_PUBLIC_KEY); + psa_set_key_usage_flags(&attr, PSA_KEY_USAGE_VERIFY_HASH); + // ANY_SALT preserves the salt-length acceptance of mbedtls_rsa_rsassa_pss_verify(), + // which this replaces; espsecure signs with a 32-byte salt. TF-PSA-Crypto defines + // PSA_WANT_ALG_RSA_PSS_ANY_SALT from PSA_WANT_ALG_RSA_PSS, which IDF enables. + psa_set_key_algorithm(&attr, PSA_ALG_RSA_PSS_ANY_SALT(PSA_ALG_SHA_256)); + mbedtls_svc_key_id_t key = MBEDTLS_SVC_KEY_ID_INIT; + const bool key_ok = der_len != 0 && psa_import_key(&attr, der, der_len, &key) == PSA_SUCCESS; +#else mbedtls_rsa_context rsa; mbedtls_rsa_init(&rsa); - bool key_ok = mbedtls_rsa_import_raw(&rsa, block + OFFSET_MODULUS, RSA_3072_BYTES, nullptr, 0, nullptr, 0, nullptr, 0, - exponent_be, sizeof(exponent_be)) == 0 && - mbedtls_rsa_complete(&rsa) == 0 && - mbedtls_rsa_set_padding(&rsa, MBEDTLS_RSA_PKCS_V21, MBEDTLS_MD_SHA256) == 0; + const bool key_ok = mbedtls_rsa_import_raw(&rsa, block + OFFSET_MODULUS, RSA_3072_BYTES, nullptr, 0, nullptr, 0, + nullptr, 0, exponent_be, sizeof(exponent_be)) == 0 && + mbedtls_rsa_complete(&rsa) == 0 && + mbedtls_rsa_set_padding(&rsa, MBEDTLS_RSA_PKCS_V21, MBEDTLS_MD_SHA256) == 0; +#endif bool verified = false; if (!key_ok) { // A setup/allocation failure (e.g. OOM right after the download) is not a // signature mismatch -- log it distinctly so it isn't read as "wrong key". OTA_IDF_SIG_LOG(ESP_LOGE, "RSA key setup failed"); } else { +#ifdef USE_OTA_SIG_PSA + verified = psa_verify_hash(key, PSA_ALG_RSA_PSS_ANY_SALT(PSA_ALG_SHA_256), digest, SHA256_BYTES, + block + OFFSET_SIGNATURE, RSA_3072_BYTES) == PSA_SUCCESS; +#else verified = mbedtls_rsa_rsassa_pss_verify(&rsa, MBEDTLS_MD_SHA256, SHA256_BYTES, digest, block + OFFSET_SIGNATURE) == 0; +#endif } +#ifdef USE_OTA_SIG_PSA + if (key_ok) { + psa_destroy_key(key); + } +#else mbedtls_rsa_free(&rsa); +#endif return verified; } diff --git a/tests/components/ota/test_rsa_der.cpp b/tests/components/ota/test_rsa_der.cpp new file mode 100644 index 0000000000..aefce6769a --- /dev/null +++ b/tests/components/ota/test_rsa_der.cpp @@ -0,0 +1,96 @@ +#include + +#include +#include + +#include "esphome/components/ota/ota_rsa_der.h" + +namespace esphome::ota::testing { + +namespace { + +// A modulus with the top bit set, as every real 3072-bit modulus has. +std::array make_modulus(uint8_t first = 0xC5) { + std::array modulus{}; + modulus.fill(0xAB); + modulus[0] = first; + modulus[RSA_3072_MODULUS_BYTES - 1] = 0x01; // odd, like a real modulus + return modulus; +} + +} // namespace + +// e = 65537, the exponent espsecure uses. +TEST(RsaDerPublicKey, StandardExponent) { + const auto modulus = make_modulus(); + const uint8_t exponent[4] = {0x00, 0x01, 0x00, 0x01}; + uint8_t der[RSA_DER_PUBKEY_MAX]; + const size_t len = rsa_der_public_key(modulus.data(), exponent, sizeof(exponent), der, sizeof(der)); + + // 4 (SEQUENCE header) + 389 (modulus) + 5 (exponent) = 398 + ASSERT_EQ(len, 398u); + // SEQUENCE, 2-byte length of the 394-byte body + EXPECT_EQ(der[0], 0x30); + EXPECT_EQ(der[1], 0x82); + EXPECT_EQ((der[2] << 8) | der[3], 394); + // INTEGER, 2-byte length 385, sign pad, then the modulus + EXPECT_EQ(der[4], 0x02); + EXPECT_EQ(der[5], 0x82); + EXPECT_EQ((der[6] << 8) | der[7], 385); + EXPECT_EQ(der[8], 0x00); + EXPECT_EQ(0, memcmp(der + 9, modulus.data(), modulus.size())); + // INTEGER, 3 bytes, leading zero of the input dropped + const size_t exp_at = 9 + RSA_3072_MODULUS_BYTES; + EXPECT_EQ(der[exp_at], 0x02); + EXPECT_EQ(der[exp_at + 1], 0x03); + EXPECT_EQ(der[exp_at + 2], 0x01); + EXPECT_EQ(der[exp_at + 3], 0x00); + EXPECT_EQ(der[exp_at + 4], 0x01); +} + +// An exponent whose top bit is set needs a 0x00 sign pad, widening the body. +TEST(RsaDerPublicKey, ExponentNeedingSignPad) { + const auto modulus = make_modulus(); + const uint8_t exponent[4] = {0x00, 0x00, 0x00, 0x81}; + uint8_t der[RSA_DER_PUBKEY_MAX]; + const size_t len = rsa_der_public_key(modulus.data(), exponent, sizeof(exponent), der, sizeof(der)); + + ASSERT_EQ(len, 397u); // 4 + 389 + 4 + const size_t exp_at = 9 + RSA_3072_MODULUS_BYTES; + EXPECT_EQ(der[exp_at], 0x02); + EXPECT_EQ(der[exp_at + 1], 0x02); // pad + one value byte + EXPECT_EQ(der[exp_at + 2], 0x00); + EXPECT_EQ(der[exp_at + 3], 0x81); +} + +// The widest exponent still fits the documented buffer size. +TEST(RsaDerPublicKey, WidestExponentFitsBuffer) { + const auto modulus = make_modulus(); + const uint8_t exponent[4] = {0xFF, 0xFF, 0xFF, 0xFF}; + uint8_t der[RSA_DER_PUBKEY_MAX]; + const size_t len = rsa_der_public_key(modulus.data(), exponent, sizeof(exponent), der, sizeof(der)); + + ASSERT_EQ(len, RSA_DER_PUBKEY_MAX); // 4 + 389 + 7 + EXPECT_LE(len, sizeof(der)); +} + +TEST(RsaDerPublicKey, ZeroExponentRejected) { + const auto modulus = make_modulus(); + const uint8_t exponent[4] = {0x00, 0x00, 0x00, 0x00}; + uint8_t der[RSA_DER_PUBKEY_MAX]; + EXPECT_EQ(rsa_der_public_key(modulus.data(), exponent, sizeof(exponent), der, sizeof(der)), 0u); +} + +// A buffer that cannot hold the result must be refused, not overrun. Sized +// against a heap vector so ASAN catches a write past the end. +TEST(RsaDerPublicKey, ShortBufferRejected) { + const auto modulus = make_modulus(); + const uint8_t exponent[4] = {0x00, 0x01, 0x00, 0x01}; + for (size_t out_len : {size_t(0), size_t(1), size_t(4), size_t(100), size_t(397)}) { + std::vector der(out_len); + EXPECT_EQ(rsa_der_public_key(modulus.data(), exponent, sizeof(exponent), der.data(), out_len), 0u) + << "out_len=" << out_len; + } +} + +} // namespace esphome::ota::testing From 2f2634bf6bfe49f1a88ac24ad03972c77925cfdb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 7 Aug 2026 08:54:28 -0500 Subject: [PATCH 1299/1815] [bluetooth_connection] Move BluetoothConnection out of bluetooth_proxy (#18129) --- CODEOWNERS | 1 + .../bluetooth_connection/__init__.py | 46 +++++++++++++++++++ .../bluetooth_connection.h | 37 +++++++++++++++ .../bluetooth_connection_esp32.cpp} | 22 +++++---- .../bluetooth_connection_esp32.h} | 16 +++++-- .../components/bluetooth_proxy/__init__.py | 19 ++++---- .../bluetooth_proxy/bluetooth_proxy.cpp | 24 +++++----- .../bluetooth_proxy/bluetooth_proxy.h | 40 +++++++--------- .../bluetooth_proxy/test_platform_gates.py | 18 +++++++- 9 files changed, 163 insertions(+), 60 deletions(-) create mode 100644 esphome/components/bluetooth_connection/__init__.py create mode 100644 esphome/components/bluetooth_connection/bluetooth_connection.h rename esphome/components/{bluetooth_proxy/bluetooth_connection.cpp => bluetooth_connection/bluetooth_connection_esp32.cpp} (98%) rename esphome/components/{bluetooth_proxy/bluetooth_connection.h => bluetooth_connection/bluetooth_connection_esp32.h} (86%) diff --git a/CODEOWNERS b/CODEOWNERS index d2e26edca3..9bcbe087c5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -78,6 +78,7 @@ esphome/components/bl0942/* @dbuezas @dwmw2 esphome/components/ble_client/* @buxtronix @clydebarrow esphome/components/ble_device_base/* @Bl00d-B0b esphome/components/ble_nus/* @tomaszduda23 +esphome/components/bluetooth_connection/* @bdraco @jesserockz esphome/components/bluetooth_proxy/* @bdraco @jesserockz esphome/components/bm8563/* @abmantis esphome/components/bme280_base/* @esphome/core diff --git a/esphome/components/bluetooth_connection/__init__.py b/esphome/components/bluetooth_connection/__init__.py new file mode 100644 index 0000000000..1e85c4b8f9 --- /dev/null +++ b/esphome/components/bluetooth_connection/__init__.py @@ -0,0 +1,46 @@ +"""Per-platform GATT connection backends the Bluetooth proxy drives. + +Auto-loaded by bluetooth_proxy, no user-facing configuration; the proxy's +codegen declares and registers the connection instances. +""" + +import functools + +import esphome.codegen as cg +from esphome.config_helpers import filter_source_files_from_platform +from esphome.const import PlatformFramework +from esphome.core import CORE + + +def AUTO_LOAD() -> list[str]: + """The esp32 connection header includes esp32_ble_client, so the closure + must be self-satisfying; no target platform (tooling) gets the union.""" + if CORE.is_esp32 or CORE.target_platform is None: + return ["ble_device_base", "esp32_ble_client"] + return ["ble_device_base"] + + +CODEOWNERS = ["@bdraco", "@jesserockz"] + +bluetooth_connection_ns = cg.esphome_ns.namespace("bluetooth_connection") + + +@functools.cache +def esp32_connection_class() -> cg.MockObjClass: + """Lazy: importing esp32_ble_client registers esp32-only automations as + an import side effect, which must not leak into other platforms.""" + from esphome.components import esp32_ble_client + + return bluetooth_connection_ns.class_( + "BluetoothConnection", esp32_ble_client.BLEClientBase + ) + + +FILTER_SOURCE_FILES = filter_source_files_from_platform( + { + "bluetooth_connection_esp32.cpp": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, + } +) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h new file mode 100644 index 0000000000..f63fb93492 --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -0,0 +1,37 @@ +// Shared types for the per-platform GATT connection backends and the +// Bluetooth proxy that drives them. + +#pragma once + +#include "esphome/core/defines.h" + +#include "esphome/components/ble_device_base/ble_client_state.h" + +#ifdef USE_ESP32 +#include +#endif + +namespace esphome::bluetooth_connection { + +// Connection-owned error type for the API error fields, which are plain +// integers on the wire. Aliases esp_err_t on esp32 (where the values come from +// IDF calls); a bare int elsewhere. Owning the name instead of probing for +// esp_err_t keeps the header independent of how a platform's SDK spells its +// error type. +#ifdef USE_ESP32 +using conn_err_t = esp_err_t; +static constexpr conn_err_t CONN_OK = ESP_OK; +#else +using conn_err_t = int; +static constexpr conn_err_t CONN_OK = 0; +#endif + +// The ESPHome-private "not connected" wire value, shared with the neutral +// GATT contract so backend and wrapper cannot drift. +static constexpr conn_err_t GATT_NOT_CONNECTED = ble_device_base::GATT_ERR_NOT_CONNECTED; + +// send_service_ cursor states; >= 0 is the next service index to stream. +static constexpr int DONE_SENDING_SERVICES = -2; +static constexpr int INIT_SENDING_SERVICES = -3; + +} // namespace esphome::bluetooth_connection diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp similarity index 98% rename from esphome/components/bluetooth_proxy/bluetooth_connection.cpp rename to esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp index 9820977a13..5274637b66 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp @@ -1,4 +1,4 @@ -#include "bluetooth_connection.h" +#include "bluetooth_connection_esp32.h" #include "esphome/components/api/api_pb2.h" #include "esphome/core/helpers.h" @@ -6,11 +6,13 @@ #ifdef USE_ESP32 -#include "bluetooth_proxy.h" +#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" -namespace esphome::bluetooth_proxy { +namespace esphome::bluetooth_connection { -static const char *const TAG = "bluetooth_proxy.connection"; +namespace espbt = esphome::esp32_ble_tracker; + +static const char *const TAG = "bluetooth_connection"; // This function is allocation-free and directly packs UUIDs into the output array // using precalculated constants for the Bluetooth base UUID @@ -516,7 +518,7 @@ void BluetoothConnection::gap_event_handler(esp_gap_ble_cb_event_t event, esp_bl esp_err_t BluetoothConnection::read_characteristic(uint16_t handle) { if (!this->connected()) { this->log_gatt_not_connected_("read", "characteristic"); - return ESP_GATT_NOT_CONNECTED; + return GATT_NOT_CONNECTED; } ESP_LOGV(TAG, "[%d] [%s] Reading GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); @@ -529,7 +531,7 @@ esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8 bool response) { if (!this->connected()) { this->log_gatt_not_connected_("write", "characteristic"); - return ESP_GATT_NOT_CONNECTED; + return GATT_NOT_CONNECTED; } ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); @@ -545,7 +547,7 @@ esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8 esp_err_t BluetoothConnection::read_descriptor(uint16_t handle) { if (!this->connected()) { this->log_gatt_not_connected_("read", "descriptor"); - return ESP_GATT_NOT_CONNECTED; + return GATT_NOT_CONNECTED; } ESP_LOGV(TAG, "[%d] [%s] Reading GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); @@ -556,7 +558,7 @@ esp_err_t BluetoothConnection::read_descriptor(uint16_t handle) { esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response) { if (!this->connected()) { this->log_gatt_not_connected_("write", "descriptor"); - return ESP_GATT_NOT_CONNECTED; + return GATT_NOT_CONNECTED; } ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); @@ -572,7 +574,7 @@ esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t * esp_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enable) { if (!this->connected()) { this->log_gatt_not_connected_("notify", "characteristic"); - return ESP_GATT_NOT_CONNECTED; + return GATT_NOT_CONNECTED; } if (enable) { @@ -592,6 +594,6 @@ esp32_ble_tracker::AdvertisementParserType BluetoothConnection::get_advertisemen return this->proxy_->get_advertisement_parser_type(); } -} // namespace esphome::bluetooth_proxy +} // namespace esphome::bluetooth_connection #endif // USE_ESP32 diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h similarity index 86% rename from esphome/components/bluetooth_proxy/bluetooth_connection.h rename to esphome/components/bluetooth_connection/bluetooth_connection_esp32.h index e5600f6af4..65e2d0777e 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h @@ -1,12 +1,18 @@ #pragma once +#include "esphome/core/defines.h" + #ifdef USE_ESP32 #include "esphome/components/esp32_ble_client/ble_client_base.h" -namespace esphome::bluetooth_proxy { +#include "bluetooth_connection.h" +namespace esphome::bluetooth_proxy { class BluetoothProxy; +} // namespace esphome::bluetooth_proxy + +namespace esphome::bluetooth_connection { class BluetoothConnection final : public esp32_ble_client::BLEClientBase { public: @@ -31,7 +37,7 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase { void set_address(uint64_t address) override; protected: - friend class BluetoothProxy; + friend class bluetooth_proxy::BluetoothProxy; void on_disconnect_complete(esp_err_t reason) override; @@ -47,16 +53,16 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase { // Memory optimized layout for 32-bit systems // Group 1: Pointers (4 bytes each, naturally aligned) - BluetoothProxy *proxy_; + bluetooth_proxy::BluetoothProxy *proxy_; // Group 2: 2-byte types - int16_t send_service_{-3}; // -3 = INIT_SENDING_SERVICES, -2 = DONE_SENDING_SERVICES, >=0 = service index + int16_t send_service_{INIT_SENDING_SERVICES}; // see bluetooth_connection.h cursor states // Group 3: 1-byte types bool seen_mtu_or_services_{false}; // 1 byte used, 1 byte padding }; -} // namespace esphome::bluetooth_proxy +} // namespace esphome::bluetooth_connection #endif // USE_ESP32 diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index bb05f1b21f..5916132ab2 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -2,7 +2,7 @@ import functools import logging import esphome.codegen as cg -from esphome.components import ble_device_base +from esphome.components import ble_device_base, bluetooth_connection import esphome.config_validation as cv from esphome.const import CONF_ACTIVE, CONF_ID, PLATFORM_LN882X, PLATFORM_RP2 from esphome.core import CORE @@ -27,13 +27,18 @@ def AUTO_LOAD(config: ConfigType | None = None) -> list[str]: target platform set, so it takes one of the concrete branches. """ if CORE.is_esp32: - return ["esp32_ble_client", "esp32_ble_tracker"] + return ["bluetooth_connection", "esp32_ble_client", "esp32_ble_tracker"] if CORE.target_platform in _HUB_PLATFORMS: - return ["ble_device_base"] + return ["ble_device_base", "bluetooth_connection"] # No target platform, or one this component does not support: tooling # resolving the manifest (including the host-pinned dependency resolver) — # expose every arm so the closure keeps the esp32 BLE stack. - return ["ble_device_base", "esp32_ble_client", "esp32_ble_tracker"] + return [ + "ble_device_base", + "bluetooth_connection", + "esp32_ble_client", + "esp32_ble_tracker", + ] # Platforms with an in-tree ble_device_base BLE tracker hub whose controller @@ -67,7 +72,7 @@ _IDF_MAX_CONNECTIONS = 9 @functools.cache def _esp32_config_schema() -> cv.All: """Build the esp32 schema, importing the esp32 BLE stack only when used.""" - from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker + from esphome.components import esp32_ble, esp32_ble_tracker if esp32_ble.IDF_MAX_CONNECTIONS != _IDF_MAX_CONNECTIONS: raise cv.Invalid( @@ -77,9 +82,7 @@ def _esp32_config_schema() -> cv.All: f"update _IDF_MAX_CONNECTIONS in bluetooth_proxy/__init__.py" ) - BluetoothConnection = bluetooth_proxy_ns.class_( - "BluetoothConnection", esp32_ble_client.BLEClientBase - ) + BluetoothConnection = bluetooth_connection.esp32_connection_class() CONNECTION_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA.extend( { cv.GenerateID(): cv.declare_id(BluetoothConnection), diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index e681030611..08b58fc3b4 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -124,7 +124,7 @@ void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *typ void BluetoothProxy::handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action, const char *type) { this->log_not_connected_gatt_(action, type); - this->send_gatt_error(address, handle, ESP_GATT_NOT_CONNECTED); + this->send_gatt_error(address, handle, GATT_NOT_CONNECTED); } #ifdef USE_ESP32 @@ -438,7 +438,7 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn ESP_LOGW(TAG, "[%d] [%s] Cannot set connection params, not connected", connection ? static_cast(connection->connection_index_) : -1, connection ? connection->address_str() : "unknown"); - resp.error = ESP_GATT_NOT_CONNECTED; + resp.error = GATT_NOT_CONNECTED; this->api_connection_->send_message(resp); return; } @@ -498,7 +498,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE: case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT: ESP_LOGW(TAG, "Active connections are not supported on this platform"); - this->send_device_connection(msg.address, false, 0, ESP_GATT_NOT_CONNECTED); + this->send_device_connection(msg.address, false, 0, GATT_NOT_CONNECTED); break; case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT: // Not an error: the device is already disconnected, which is the requested state. @@ -506,13 +506,13 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest this->send_connections_free(); break; case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR: - this->send_device_pairing(msg.address, false, ESP_GATT_NOT_CONNECTED); + this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED); break; case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR: - this->send_device_unpairing(msg.address, false, ESP_GATT_NOT_CONNECTED); + this->send_device_unpairing(msg.address, false, GATT_NOT_CONNECTED); break; case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE: - this->send_device_clear_cache(msg.address, false, ESP_GATT_NOT_CONNECTED); + this->send_device_clear_cache(msg.address, false, GATT_NOT_CONNECTED); break; } } @@ -546,7 +546,7 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn return; api::BluetoothSetConnectionParamsResponse resp; resp.address = msg.address; - resp.error = ESP_GATT_NOT_CONNECTED; + resp.error = GATT_NOT_CONNECTED; this->api_connection_->send_message(resp); } @@ -605,7 +605,7 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti #endif } -void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, proxy_err_t error) { +void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, conn_err_t error) { if (this->api_connection_ == nullptr) return; api::BluetoothDeviceConnectionResponse call; @@ -633,7 +633,7 @@ void BluetoothProxy::send_gatt_services_done(uint64_t address) { this->api_connection_->send_message(call); } -void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, proxy_err_t error) { +void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error) { if (this->api_connection_ == nullptr) return; api::BluetoothGATTErrorResponse call; @@ -643,7 +643,7 @@ void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, proxy_er this->api_connection_->send_message(call); } -void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, proxy_err_t error) { +void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, conn_err_t error) { if (this->api_connection_ == nullptr) return; api::BluetoothDevicePairingResponse call; @@ -654,7 +654,7 @@ void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, proxy_er this->api_connection_->send_message(call); } -void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, proxy_err_t error) { +void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, conn_err_t error) { if (this->api_connection_ == nullptr) return; api::BluetoothDeviceUnpairingResponse call; @@ -667,7 +667,7 @@ void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, proxy // Shared by both platform paths: the neutral bluetooth_device_request() uses it to // answer a clear-cache request with a clean error, so it must not be esp32-guarded. -void BluetoothProxy::send_device_clear_cache(uint64_t address, bool success, proxy_err_t error) { +void BluetoothProxy::send_device_clear_cache(uint64_t address, bool success, conn_err_t error) { if (this->api_connection_ == nullptr) return; api::BluetoothDeviceClearCacheResponse call; diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index fd1f1839c9..dbfc119d98 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -13,13 +13,13 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" -#include "esphome/components/ble_device_base/ble_client_state.h" +#include "esphome/components/bluetooth_connection/bluetooth_connection.h" #ifdef USE_ESP32 #include "esphome/components/esp32_ble_client/ble_client_base.h" #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" -#include "bluetooth_connection.h" +#include "esphome/components/bluetooth_connection/bluetooth_connection_esp32.h" #ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID #include @@ -31,23 +31,16 @@ namespace esphome::bluetooth_proxy { -// Proxy-owned error type for the API error fields, which are plain integers on -// the wire. Aliases esp_err_t on esp32 (where the values come from IDF calls); -// a bare int elsewhere. Owning the name instead of probing for esp_err_t keeps -// the header independent of how a hub platform's SDK spells its error type. -#ifdef USE_ESP32 -using proxy_err_t = esp_err_t; -static constexpr proxy_err_t PROXY_OK = ESP_OK; -#else -using proxy_err_t = int; -static constexpr proxy_err_t PROXY_OK = 0; -#endif - -static constexpr proxy_err_t ESP_GATT_NOT_CONNECTED = ble_device_base::GATT_ERR_NOT_CONNECTED; -static constexpr int DONE_SENDING_SERVICES = -2; -static constexpr int INIT_SENDING_SERVICES = -3; +// The connection-domain types live in the bluetooth_connection component; +// re-exported here so the proxy code reads unqualified. +using bluetooth_connection::CONN_OK; +using bluetooth_connection::conn_err_t; +using bluetooth_connection::DONE_SENDING_SERVICES; +using bluetooth_connection::GATT_NOT_CONNECTED; +using bluetooth_connection::INIT_SENDING_SERVICES; #ifdef USE_ESP32 +using BluetoothConnection = bluetooth_connection::BluetoothConnection; using namespace esp32_ble_client; #endif @@ -79,7 +72,8 @@ enum BluetoothProxySubscriptionFlag : uint32_t { class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, public esp32_ble_tracker::BLEScannerStateListener, public Component { - friend class BluetoothConnection; // Allow connection to update connections_free_response_ + // Allow the connection to update connections_free_response_ + friend bluetooth_connection::BluetoothConnection; #else class BluetoothProxy final : public Component { #endif @@ -129,14 +123,14 @@ class BluetoothProxy final : public Component { void unsubscribe_api_connection(api::APIConnection *api_connection); api::APIConnection *get_api_connection() { return this->api_connection_; } - void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, proxy_err_t error = PROXY_OK); + void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, conn_err_t error = CONN_OK); void send_connections_free(); void send_connections_free(api::APIConnection *api_connection); void send_gatt_services_done(uint64_t address); - void send_gatt_error(uint64_t address, uint16_t handle, proxy_err_t error); - void send_device_pairing(uint64_t address, bool paired, proxy_err_t error = PROXY_OK); - void send_device_unpairing(uint64_t address, bool success, proxy_err_t error = PROXY_OK); - void send_device_clear_cache(uint64_t address, bool success, proxy_err_t error = PROXY_OK); + void send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error); + void send_device_pairing(uint64_t address, bool paired, conn_err_t error = CONN_OK); + void send_device_unpairing(uint64_t address, bool success, conn_err_t error = CONN_OK); + void send_device_clear_cache(uint64_t address, bool success, conn_err_t error = CONN_OK); void bluetooth_scanner_set_mode(bool active); diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py index 4d7997fbce..c5240105fa 100644 --- a/tests/component_tests/bluetooth_proxy/test_platform_gates.py +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -5,12 +5,12 @@ advertisement-only arm applies its own defaults.""" import pytest from esphome import config_validation as cv -from esphome.components import bluetooth_proxy +from esphome.components import bluetooth_connection, bluetooth_proxy from esphome.const import CONF_ACTIVE, KEY_TARGET_PLATFORM from esphome.core import CORE, KEY_CORE -def _set_platform(platform: str) -> None: +def _set_platform(platform: str | None) -> None: CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = platform @@ -46,3 +46,17 @@ def test_hub_platform_accepts_the_advertisement_only_shape() -> None: _set_platform("ln882x") validated = bluetooth_proxy.CONFIG_SCHEMA({}) assert validated[CONF_ACTIVE] is False + + +def test_bluetooth_connection_auto_load_covers_its_includes() -> None: + # The esp32 connection header includes esp32_ble_client; the auto load + # must satisfy that closure itself (regression: it once relied on the + # consumer's auto loads). + _set_platform("esp32") + assert "esp32_ble_client" in bluetooth_connection.AUTO_LOAD() + _set_platform("rp2") + assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base"] + # No target platform (tooling resolving the manifest): the union, so + # dependency closures stay complete for build_codeowners and friends. + _set_platform(None) + assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "esp32_ble_client"] From 8b0e23d55b9022b0307cfe4bec3fbecff39c57fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Fri, 7 Aug 2026 17:30:00 +0300 Subject: [PATCH 1300/1815] [bluetooth_proxy] Fold scanner-state bookkeeping into the sender; extend platform-gate tests (#18150) Co-authored-by: J. Nick Koston --- .../bluetooth_proxy/bluetooth_proxy.cpp | 20 ++--- .../bluetooth_proxy/test_platform_gates.py | 81 +++++++++++++++---- 2 files changed, 75 insertions(+), 26 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 08b58fc3b4..9002727bbf 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -93,9 +93,13 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme } void BluetoothProxy::send_bluetooth_scanner_state_() { + // Records what goes on the wire so loop()'s change detector cannot report the + // same transition twice; every caller relies on this instead of updating + // last_scan_running_ itself. + this->last_scan_running_ = this->hub_->scan_running(); api::BluetoothScannerStateResponse resp; - resp.state = this->hub_->scan_running() ? api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_RUNNING - : api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_IDLE; + resp.state = this->last_scan_running_ ? api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_RUNNING + : api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_IDLE; resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; resp.configured_mode = this->configured_scan_active_ @@ -483,9 +487,7 @@ void BluetoothProxy::loop() { return; // The hub has no scanner-state listener interface; poll and report on change. - bool running = this->hub_->scan_running(); - if (running != this->last_scan_running_) { - this->last_scan_running_ = running; + if (this->hub_->scan_running() != this->last_scan_running_) { this->send_bluetooth_scanner_state_(); } @@ -561,10 +563,9 @@ void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { } } if (this->api_connection_ != nullptr) { - // Keep loop()'s change detector in step with the state sent here, so a - // failed restart (scan_running_ dropped by the tracker) is not reported - // twice — once now and again on the next tick. - this->last_scan_running_ = this->hub_->scan_running(); + // Reports the mode change; the sender also refreshes last_scan_running_, so + // a failed restart (scan_running_ dropped by the tracker) is not reported + // again by loop() on the next tick. this->send_bluetooth_scanner_state_(); } } @@ -589,7 +590,6 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection this->parent_->recalculate_advertisement_parser_types(); this->send_bluetooth_scanner_state_(this->parent_->get_scanner_state()); #else - this->last_scan_running_ = this->hub_->scan_running(); this->send_bluetooth_scanner_state_(); #endif } diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py index c5240105fa..682f98bf6b 100644 --- a/tests/component_tests/bluetooth_proxy/test_platform_gates.py +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -6,44 +6,93 @@ import pytest from esphome import config_validation as cv from esphome.components import bluetooth_connection, bluetooth_proxy -from esphome.const import CONF_ACTIVE, KEY_TARGET_PLATFORM -from esphome.core import CORE, KEY_CORE +from esphome.const import ( + CONF_ACTIVE, + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + PlatformFramework, +) +from esphome.core import CORE + +from ..types import SetCoreConfigCallable + +HUB_PLATFORM_FRAMEWORKS = [ + PlatformFramework.LN882X_ARDUINO, + PlatformFramework.RP2_ARDUINO, +] def _set_platform(platform: str | None) -> None: + # For arms set_core_config cannot express (bare platform, no framework). CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = platform -def test_ble_less_platform_gets_the_real_reason() -> None: - _set_platform("esp8266") +def test_ble_less_platform_gets_the_real_reason( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP8266_ARDUINO) with pytest.raises(cv.Invalid, match="not supported on esp8266"): bluetooth_proxy.CONFIG_SCHEMA({}) -def test_ble_less_platform_connection_keys_fall_through() -> None: +def test_ble_less_platform_connection_keys_fall_through( + set_core_config: SetCoreConfigCallable, +) -> None: # The key-level rejection must not fire here — it would imply an # advertisement-only proxy exists on this platform. - _set_platform("esp8266") + set_core_config(PlatformFramework.ESP8266_ARDUINO) with pytest.raises(cv.Invalid, match="not supported on esp8266"): bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 2}) -def test_hub_platform_rejects_active() -> None: - _set_platform("ln882x") +def test_no_target_platform_keeps_the_key_gate_out_of_the_way() -> None: + # set_core_config cannot express "no platform"; script/build_codeowners.py + # sets exactly this shape, and the key gate returns early on it so the + # platform gate is what reports. + CORE.data[KEY_CORE] = {KEY_TARGET_FRAMEWORK: None, KEY_TARGET_PLATFORM: None} + with pytest.raises(cv.Invalid, match="not supported on None"): + bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 2}) + + +@pytest.mark.parametrize("platform_framework", HUB_PLATFORM_FRAMEWORKS) +def test_hub_platform_rejects_active( + set_core_config: SetCoreConfigCallable, + platform_framework: PlatformFramework, +) -> None: + set_core_config(platform_framework) with pytest.raises(cv.Invalid, match="Active connections are not supported"): bluetooth_proxy.CONFIG_SCHEMA({"active": True}) -def test_hub_platform_rejects_connection_keys_by_name() -> None: - _set_platform("ln882x") - with pytest.raises(cv.Invalid, match="'connection_slots' requires active"): - bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 2}) - with pytest.raises(cv.Invalid, match="'cache_services' requires active"): - bluetooth_proxy.CONFIG_SCHEMA({"cache_services": True}) +@pytest.mark.parametrize("platform_framework", HUB_PLATFORM_FRAMEWORKS) +@pytest.mark.parametrize( + ("key", "value"), + [ + ("connection_slots", 2), + ("cache_services", True), + # Absent from the outer CONFIG_SCHEMA, so this gate is the only test + # that touches it. + ("connections", [{}]), + ], +) +def test_hub_platform_rejects_connection_keys_by_name( + set_core_config: SetCoreConfigCallable, + platform_framework: PlatformFramework, + key: str, + value: object, +) -> None: + set_core_config(platform_framework) + with pytest.raises(cv.Invalid, match=f"'{key}' requires active"): + bluetooth_proxy.CONFIG_SCHEMA({key: value}) -def test_hub_platform_accepts_the_advertisement_only_shape() -> None: - _set_platform("ln882x") +@pytest.mark.parametrize("platform_framework", HUB_PLATFORM_FRAMEWORKS) +def test_hub_platform_accepts_the_advertisement_only_shape( + set_core_config: SetCoreConfigCallable, + platform_framework: PlatformFramework, +) -> None: + set_core_config(platform_framework) validated = bluetooth_proxy.CONFIG_SCHEMA({}) assert validated[CONF_ACTIVE] is False From 1bbe8b415faa09118cd6da2f9fe6fceaab9d6f8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Fri, 7 Aug 2026 18:01:50 +0300 Subject: [PATCH 1301/1815] [bluetooth_proxy] Only advance the scanner-state detector when the frame was sent (#18154) --- .../bluetooth_proxy/bluetooth_proxy.cpp | 17 +++++++++-------- .../bluetooth_proxy/test_platform_gates.py | 7 +++++++ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 9002727bbf..66f22c9a90 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -59,7 +59,6 @@ void BluetoothProxy::setup() { // Capture the configured scan mode from YAML before any API changes this->configured_scan_active_ = this->hub_->scan_active(); - this->last_scan_running_ = this->hub_->scan_running(); // The hub delivers raw advertisements on the ESPHome main loop: // mac is least-significant octet first (BLE controller convention). @@ -93,19 +92,21 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme } void BluetoothProxy::send_bluetooth_scanner_state_() { - // Records what goes on the wire so loop()'s change detector cannot report the - // same transition twice; every caller relies on this instead of updating - // last_scan_running_ itself. - this->last_scan_running_ = this->hub_->scan_running(); + // One read feeds both the frame and the change detector; the detector only + // advances if the frame was accepted, so a dropped send (WOULD_BLOCK on a + // full TX buffer) is retried from loop() instead of leaving a stale state. + const bool running = this->hub_->scan_running(); api::BluetoothScannerStateResponse resp; - resp.state = this->last_scan_running_ ? api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_RUNNING - : api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_IDLE; + resp.state = running ? api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_RUNNING + : api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_IDLE; resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; resp.configured_mode = this->configured_scan_active_ ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; - this->api_connection_->send_message(resp); + if (this->api_connection_->send_message(resp)) { + this->last_scan_running_ = running; + } } #endif // USE_ESP32 diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py index 682f98bf6b..c474b5fa81 100644 --- a/tests/component_tests/bluetooth_proxy/test_platform_gates.py +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -23,6 +23,13 @@ HUB_PLATFORM_FRAMEWORKS = [ ] +def test_hub_platform_list_covers_every_hub_platform() -> None: + # A platform added to _HUB_PLATFORMS (bk72xx is planned) would otherwise + # get no gate coverage at all. + covered = {pf.value[0] for pf in HUB_PLATFORM_FRAMEWORKS} + assert covered == set(bluetooth_proxy._HUB_PLATFORMS) + + def _set_platform(platform: str | None) -> None: # For arms set_core_config cannot express (bare platform, no framework). CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = platform From b8027409974c386495a0be59f6f7c637daed2306 Mon Sep 17 00:00:00 2001 From: Mustafa KURU Date: Fri, 7 Aug 2026 18:11:29 +0300 Subject: [PATCH 1302/1815] [ld6002b] Add switch, number and text sensor platforms (3/5) (#17821) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/ld6002b/const.py | 10 + esphome/components/ld6002b/ld6002b.cpp | 591 +++++++++++++++++- esphome/components/ld6002b/ld6002b.h | 105 ++++ esphome/components/ld6002b/number/__init__.py | 82 +++ .../ld6002b/number/ld6002b_number.cpp | 10 + .../ld6002b/number/ld6002b_number.h | 18 + esphome/components/ld6002b/sensor.py | 9 + esphome/components/ld6002b/switch/__init__.py | 60 ++ .../ld6002b/switch/ld6002b_switch.cpp | 10 + .../ld6002b/switch/ld6002b_switch.h | 18 + esphome/components/ld6002b/text_sensor.py | 31 + tests/components/ld6002b/common.yaml | 32 + 12 files changed, 949 insertions(+), 27 deletions(-) create mode 100644 esphome/components/ld6002b/number/__init__.py create mode 100644 esphome/components/ld6002b/number/ld6002b_number.cpp create mode 100644 esphome/components/ld6002b/number/ld6002b_number.h create mode 100644 esphome/components/ld6002b/switch/__init__.py create mode 100644 esphome/components/ld6002b/switch/ld6002b_switch.cpp create mode 100644 esphome/components/ld6002b/switch/ld6002b_switch.h create mode 100644 esphome/components/ld6002b/text_sensor.py diff --git a/esphome/components/ld6002b/const.py b/esphome/components/ld6002b/const.py index 4419a92d23..9f9227988e 100644 --- a/esphome/components/ld6002b/const.py +++ b/esphome/components/ld6002b/const.py @@ -1,8 +1,18 @@ CONF_AUTO_WAKE = "auto_wake" CONF_CLUSTER_ID = "cluster_id" CONF_DOPPLER_INDEX = "doppler_index" +CONF_HOLD_DELAY = "hold_delay" CONF_LD6002B_ID = "ld6002b_id" +CONF_LOW_POWER = "low_power" +CONF_LOW_POWER_SLEEP_TIME = "low_power_sleep_time" +CONF_OTA_VERSION = "ota_version" +CONF_POINT_CLOUD = "point_cloud" +CONF_POINT_COUNT = "point_count" +CONF_TARGET_DISPLAY = "target_display" CONF_WAKEUP_PULSE = "wakeup_pulse" +CONF_WORK_MODE = "work_mode" CONF_Z = "z" +CONF_Z_MAX = "z_max" +CONF_Z_MIN = "z_min" MAX_TARGETS = 3 diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index 2a09e98c25..54979fe9eb 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include namespace esphome::ld6002b { @@ -14,20 +15,40 @@ static constexpr uint32_t SETUP_DELAY_MS = 100; // Command/message types static constexpr uint16_t TYPE_CONTROL = 0x0201; +static constexpr uint16_t TYPE_SET_HOLD_DELAY = 0x0203; +static constexpr uint16_t TYPE_SET_Z_RANGE = 0x0204; +static constexpr uint16_t TYPE_SET_LOW_POWER_SLEEP = 0x0205; static constexpr uint16_t TYPE_REPORT_TARGET = 0x0A04; +static constexpr uint16_t TYPE_REPORT_POINT_CLOUD = 0x0A08; +static constexpr uint16_t TYPE_REPORT_DELAY = 0x0A0D; +static constexpr uint16_t TYPE_REPORT_Z_RANGE = 0x0A10; +static constexpr uint16_t TYPE_REPORT_LOW_POWER = 0x0A12; +static constexpr uint16_t TYPE_REPORT_LOW_POWER_SLEEP = 0x0A13; +static constexpr uint16_t TYPE_REPORT_WORK_MODE = 0x0A14; +static constexpr uint16_t TYPE_QUERY_VERSION = 0xFFFF; // Control command values for TYPE_CONTROL +static constexpr uint32_t CMD_GET_DELAY = 0x05; static constexpr uint32_t CMD_POINT_CLOUD_ON = 0x06; static constexpr uint32_t CMD_POINT_CLOUD_OFF = 0x07; static constexpr uint32_t CMD_TARGET_DISPLAY_ON = 0x08; static constexpr uint32_t CMD_TARGET_DISPLAY_OFF = 0x09; +static constexpr uint32_t CMD_GET_Z_RANGE = 0x12; +static constexpr uint32_t CMD_LOW_POWER_ON = 0x16; +static constexpr uint32_t CMD_LOW_POWER_OFF = 0x17; +static constexpr uint32_t CMD_GET_LOW_POWER = 0x18; +static constexpr uint32_t CMD_GET_LOW_POWER_SLEEP = 0x19; static constexpr uint16_t TARGET_DATA_LEN = 20; // x,y,z,dop_idx,cluster_id +static constexpr uint8_t VERSION_QUERY_DATA[] = {0x01, 0x01, 0x00, 0x00}; + #ifdef ESPHOME_LOG_HAS_VERBOSE static const char *control_command_name(uint32_t command) { switch (command) { + case CMD_GET_DELAY: + return "get_delay"; case CMD_POINT_CLOUD_ON: return "point_cloud_on"; case CMD_POINT_CLOUD_OFF: @@ -36,10 +57,68 @@ static const char *control_command_name(uint32_t command) { return "target_display_on"; case CMD_TARGET_DISPLAY_OFF: return "target_display_off"; + case CMD_GET_Z_RANGE: + return "get_z_range"; + case CMD_LOW_POWER_ON: + return "low_power_on"; + case CMD_LOW_POWER_OFF: + return "low_power_off"; + case CMD_GET_LOW_POWER: + return "get_low_power"; + case CMD_GET_LOW_POWER_SLEEP: + return "get_low_power_sleep"; default: return "unknown"; } } + +static const char *frame_type_name(uint16_t type) { + switch (type) { + case TYPE_CONTROL: + return "control"; + case TYPE_SET_HOLD_DELAY: + return "set_hold_delay"; + case TYPE_SET_Z_RANGE: + return "set_z_range"; + case TYPE_SET_LOW_POWER_SLEEP: + return "set_low_power_sleep"; + case TYPE_REPORT_TARGET: + return "report_target"; + case TYPE_REPORT_POINT_CLOUD: + return "report_point_cloud"; + case TYPE_REPORT_DELAY: + return "report_delay"; + case TYPE_REPORT_Z_RANGE: + return "report_z_range"; + case TYPE_REPORT_LOW_POWER: + return "report_low_power"; + case TYPE_REPORT_LOW_POWER_SLEEP: + return "report_low_power_sleep"; + case TYPE_REPORT_WORK_MODE: + return "report_work_mode"; + case TYPE_QUERY_VERSION: + return "query_version"; + default: + return "unknown"; + } +} + +static bool is_expected_control_report(uint32_t command, uint16_t type) { + switch (command) { + case CMD_GET_DELAY: + return type == TYPE_REPORT_DELAY; + case CMD_GET_Z_RANGE: + return type == TYPE_REPORT_Z_RANGE; + case CMD_GET_LOW_POWER: + case CMD_LOW_POWER_ON: + case CMD_LOW_POWER_OFF: + return type == TYPE_REPORT_LOW_POWER; + case CMD_GET_LOW_POWER_SLEEP: + return type == TYPE_REPORT_LOW_POWER_SLEEP; + default: + return false; + } +} #endif uint16_t LD6002BComponent::read_u16_be(const uint8_t *data) { return (static_cast(data[0]) << 8) | data[1]; } @@ -70,10 +149,25 @@ void LD6002BComponent::write_u32_le(uint8_t *data, uint32_t value) { data[3] = (value >> 24) & 0xFF; } +void LD6002BComponent::write_f32_le(uint8_t *data, float value) { + uint32_t raw; + std::memcpy(&raw, &value, sizeof(raw)); + write_u32_le(data, raw); +} + void LD6002BComponent::setup() { + // Only the point cloud stream needs the larger frame; nothing resizes the buffer after setup. + bool point_cloud_configured = false; +#ifdef USE_SENSOR + point_cloud_configured = point_cloud_configured || this->point_count_sensor_ != nullptr; +#endif +#ifdef USE_SWITCH + point_cloud_configured = point_cloud_configured || this->point_cloud_switch_ != nullptr; +#endif + this->max_data_len_ = point_cloud_configured ? DEFAULT_MAX_DATA_LEN_POINT_CLOUD : DEFAULT_MAX_DATA_LEN; // One allocation for the component lifetime; the parser reuses it for the header and every payload. RAMAllocator allocator; - this->data_buf_ = allocator.allocate(DEFAULT_MAX_DATA_LEN); + this->data_buf_ = allocator.allocate(this->max_data_len_); if (this->data_buf_ == nullptr) { this->mark_failed(LOG_STR("Failed to allocate frame buffer")); return; @@ -108,25 +202,120 @@ void LD6002BComponent::setup() { } } #endif - if (want_target_stream) { - this->send_control_command_(CMD_TARGET_DISPLAY_ON); +#ifdef USE_TEXT_SENSOR + // The work mode fallback reads presence off this stream, so it counts as a + // consumer of it here. This only feeds the automatic branch below: with a + // target_display switch configured that switch still decides, and the + // fallback weighs no presence at all while the stream is off. + want_target_stream = want_target_stream || this->work_mode_text_sensor_ != nullptr; +#endif + bool target_display_controlled = false; +#ifdef USE_SWITCH + if (this->target_display_switch_ != nullptr) { + target_display_controlled = true; + // Nothing reports this switch back, so its restored state is the only state + // there is. Restoring through the switch keeps its inversion in the path: + // the restored value is logical, and turn_on()/turn_off() are what turn it + // into the raw command, the published state and the stream flag. + const bool state = this->target_display_switch_->get_initial_state_with_restore_mode().value_or(true); + if (state) { + this->target_display_switch_->turn_on(); + } else { + this->target_display_switch_->turn_off(); + } + } +#endif + if (!target_display_controlled) { + // No switch: the stream follows its consumers. With none, nothing is sent + // and the module's own default stands -- but the reports are gated out + // regardless, because there is nothing configured for them to feed. + this->target_display_enabled_ = want_target_stream; + if (want_target_stream) { + this->send_control_command_(CMD_TARGET_DISPLAY_ON); + } } - this->send_control_command_(CMD_POINT_CLOUD_OFF); + bool point_cloud_controlled = false; +#ifdef USE_SWITCH + if (this->point_cloud_switch_ != nullptr) { + point_cloud_controlled = true; + // The switch owns the stream, so it is also what applies the restored state: + // driving it rather than the module keeps the entity's inversion in the path. + const bool state = this->point_cloud_switch_->get_initial_state_with_restore_mode().value_or(false); + if (state) { + this->point_cloud_switch_->turn_on(); + } else { + this->point_cloud_switch_->turn_off(); + } + } +#endif + if (!point_cloud_controlled) { + // No switch: the stream follows the sensor that reads it, which is also what + // the frame buffer above was sized for. + bool want_point_cloud = false; +#ifdef USE_SENSOR + want_point_cloud = this->point_count_sensor_ != nullptr; +#endif + this->send_control_command_(want_point_cloud ? CMD_POINT_CLOUD_ON : CMD_POINT_CLOUD_OFF); + this->point_cloud_enabled_ = want_point_cloud; + } +#ifdef USE_NUMBER + if (this->z_min_number_ != nullptr || this->z_max_number_ != nullptr) { + this->send_control_command_(CMD_GET_Z_RANGE); + } + if (this->low_power_sleep_number_ != nullptr) { + this->send_control_command_(CMD_GET_LOW_POWER_SLEEP); + } + if (this->hold_delay_number_ != nullptr) { + this->send_control_command_(CMD_GET_DELAY); + } +#endif +#ifdef USE_SWITCH + bool want_low_power = this->low_power_switch_ != nullptr; + if (want_low_power) { + // The module reports this one back, so the query below confirms what it took. + // Driving the switch applies its inversion; it also marks the restored value + // as reported, so the work mode fallback runs on that until the query lands. + const bool state = this->low_power_switch_->get_initial_state_with_restore_mode().value_or(false); + if (state) { + this->low_power_switch_->turn_on(); + } else { + this->low_power_switch_->turn_off(); + } + } +#else + bool want_low_power = false; +#endif +#ifdef USE_TEXT_SENSOR + want_low_power = want_low_power || this->work_mode_text_sensor_ != nullptr; +#endif + if (want_low_power) { + this->send_control_command_(CMD_GET_LOW_POWER); + } + + this->init_version_pref_(); + +#ifdef USE_TEXT_SENSOR + if (this->ota_version_text_sensor_ != nullptr) { + this->queue_command_(TYPE_QUERY_VERSION, VERSION_QUERY_DATA, sizeof(VERSION_QUERY_DATA)); + } +#endif }); } void LD6002BComponent::dump_config() { ESP_LOGCONFIG(TAG, "HLK-LD6002B:\n" - " Auto wake: %s", - this->auto_wake_ ? "true" : "false"); + " Auto wake: %s\n" + " Max data length: %u", + this->auto_wake_ ? "true" : "false", static_cast(this->max_data_len_)); if (this->wakeup_pin_ != nullptr) { LOG_PIN(" Wake-up Pin: ", this->wakeup_pin_); ESP_LOGCONFIG(TAG, " Wake Pulse: %ums", this->wakeup_pulse_ms_); } #ifdef USE_SENSOR LOG_SENSOR(" ", "Target Count", this->target_count_sensor_); + LOG_SENSOR(" ", "Point Count", this->point_count_sensor_); for (auto &target : this->targets_) { LOG_SENSOR(" ", "Target X", target.x); LOG_SENSOR(" ", "Target Y", target.y); @@ -141,6 +330,21 @@ void LD6002BComponent::dump_config() { LOG_BINARY_SENSOR(" ", "Target Presence", this->target_presence_[i]); } #endif +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Work Mode", this->work_mode_text_sensor_); + LOG_TEXT_SENSOR(" ", "OTA Version", this->ota_version_text_sensor_); +#endif +#ifdef USE_NUMBER + LOG_NUMBER(" ", "Hold Delay", this->hold_delay_number_); + LOG_NUMBER(" ", "Z Min", this->z_min_number_); + LOG_NUMBER(" ", "Z Max", this->z_max_number_); + LOG_NUMBER(" ", "Low Power Sleep", this->low_power_sleep_number_); +#endif +#ifdef USE_SWITCH + LOG_SWITCH(" ", "Low Power", this->low_power_switch_); + LOG_SWITCH(" ", "Point Cloud", this->point_cloud_switch_); + LOG_SWITCH(" ", "Target Display", this->target_display_switch_); +#endif } void LD6002BComponent::loop() { @@ -192,7 +396,7 @@ void LD6002BComponent::parse_byte_(uint8_t byte) { this->frame_type_ = read_u16_be(this->data_buf_ + 4); // The length is only trustworthy once the header checksum has been verified, so just // remember that the frame is oversized and let the HCK state act on it. - this->frame_oversize_ = this->data_len_ > DEFAULT_MAX_DATA_LEN; + this->frame_oversize_ = this->data_len_ > this->max_data_len_; this->parse_state_ = ParseState::HCK; } } @@ -267,16 +471,54 @@ void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_ return; } +#ifdef ESPHOME_LOG_HAS_VERBOSE + const uint32_t active_control_command = + (this->command_active_ && this->active_command_.type == TYPE_CONTROL && this->active_command_.len >= 4) + ? read_u32_le(this->active_command_.data.data()) + : 0; + if (active_control_command != 0 && is_expected_control_report(active_control_command, type)) { + ESP_LOGV(TAG, "Received %s (0x%04X) while waiting for %s (0x%02" PRIX32 ") ACK", frame_type_name(type), type, + control_command_name(active_control_command), active_control_command); + } +#endif + switch (type) { case TYPE_REPORT_TARGET: this->handle_target_report_(data, len); break; + case TYPE_REPORT_POINT_CLOUD: + this->handle_point_cloud_(data, len); + break; + case TYPE_REPORT_DELAY: + this->handle_delay_report_(data, len); + break; + case TYPE_REPORT_Z_RANGE: + this->handle_z_range_report_(data, len); + break; + case TYPE_REPORT_LOW_POWER: + this->handle_low_power_report_(data, len); + break; + case TYPE_REPORT_LOW_POWER_SLEEP: + this->handle_low_power_sleep_report_(data, len); + break; + case TYPE_REPORT_WORK_MODE: + this->handle_work_mode_report_(data, len); + break; + case TYPE_QUERY_VERSION: + this->handle_version_report_(data, len); + break; default: break; } } void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) { + // The module stops streaming when it acts on the command, not when the command + // is queued, so trailing frames after an off must not repopulate what + // set_switch_state just cleared. + if (!this->target_display_enabled_) { + return; + } if (len < 4) return; @@ -339,6 +581,7 @@ void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) this->presence_binary_sensor_->publish_state(this->target_presence_any_); } #endif + this->update_work_mode_fallback_(); for (uint8_t i = 0; i < MAX_TARGETS; i++) { bool has_target = this->slot_occupied_[i]; @@ -373,26 +616,7 @@ void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) #endif } else { #ifdef USE_SENSOR - TargetSensors &target = this->targets_[i]; - if (this->last_target_presence_[i]) { - if (target.x != nullptr) { - target.x->publish_state(NAN); - } - if (target.y != nullptr) { - target.y->publish_state(NAN); - } - if (target.z != nullptr) { - target.z->publish_state(NAN); - } - if (target.dop_idx != nullptr) { - target.dop_idx->publish_state(NAN); - } - if (target.cluster_id != nullptr) { - target.cluster_id->publish_state(NAN); - } - // The slot is free: the next person's id is new even when it repeats this one. - this->last_cluster_id_valid_[i] = false; - } + this->clear_target_slot_(i); #endif } #ifdef USE_BINARY_SENSOR @@ -407,6 +631,150 @@ void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) } } +void LD6002BComponent::handle_point_cloud_(const uint8_t *data, uint16_t len) { + // Same window as the target stream: a frame already in flight must not put the + // count back after the switch cleared it. + if (!this->point_cloud_enabled_) { + return; + } + if (len < 4) + return; + +#ifdef USE_SENSOR + uint32_t point_num = read_u32_le(data); + if (this->point_count_sensor_ != nullptr) { + if (point_num != this->last_point_count_) { + this->point_count_sensor_->publish_state(point_num); + this->last_point_count_ = point_num; + } + } +#endif +} + +void LD6002BComponent::handle_delay_report_(const uint8_t *data, uint16_t len) { + if (len < 4) + return; +#ifdef USE_NUMBER + uint32_t delay = read_u32_le(data); + this->publish_number_clamped_(this->hold_delay_number_, delay); +#endif +} + +void LD6002BComponent::handle_z_range_report_(const uint8_t *data, uint16_t len) { + if (len < 8) + return; + float z_min = read_f32_le(data); + float z_max = read_f32_le(data + 4); + this->z_min_ = z_min; + this->z_max_ = z_max; +#ifdef USE_NUMBER + this->publish_number_clamped_(this->z_min_number_, z_min); + this->publish_number_clamped_(this->z_max_number_, z_max); +#endif +} + +void LD6002BComponent::handle_low_power_report_(const uint8_t *data, uint16_t len) { + if (len < 1) + return; + bool enabled = data[0] != 0; + this->low_power_enabled_ = enabled; + this->low_power_reported_ = true; +#ifdef USE_SWITCH + if (this->low_power_switch_ != nullptr) { + this->low_power_switch_->publish_state(enabled); + } +#endif + this->update_work_mode_fallback_(); +} + +void LD6002BComponent::handle_low_power_sleep_report_(const uint8_t *data, uint16_t len) { + if (len < 4) + return; +#ifdef USE_NUMBER + uint32_t sleep_ms = read_u32_le(data); + this->publish_number_clamped_(this->low_power_sleep_number_, sleep_ms); +#endif +} + +void LD6002BComponent::handle_work_mode_report_(const uint8_t *data, uint16_t len) { + if (len < 1) + return; +#ifdef USE_TEXT_SENSOR + const bool low_power = (data[0] == 0); + if (this->work_mode_text_sensor_ != nullptr) { + this->work_mode_reported_ = true; + this->publish_work_mode_(low_power); + } +#endif +} + +void LD6002BComponent::update_work_mode_fallback_() { +#ifdef USE_TEXT_SENSOR + if (this->work_mode_text_sensor_ == nullptr || this->work_mode_reported_) { + return; + } + if (!this->low_power_reported_) { + return; + } + // Presence is only meaningful while the stream that maintains it runs; with it + // off there is nothing to weigh and low power alone decides. + const bool presence = this->target_display_enabled_ && this->target_presence_any_; + this->publish_work_mode_(this->low_power_enabled_ && !presence); +#endif +} + +void LD6002BComponent::publish_work_mode_(bool low_power) { +#ifdef USE_TEXT_SENSOR + if (this->work_mode_text_sensor_ == nullptr) { + return; + } + if (this->last_work_mode_valid_ && this->last_work_mode_low_power_ == low_power) { + return; + } + this->work_mode_text_sensor_->publish_state(low_power ? "low_power" : "normal"); + this->last_work_mode_valid_ = true; + this->last_work_mode_low_power_ = low_power; +#endif +} + +#ifdef USE_NUMBER +void LD6002BComponent::publish_number_clamped_(number::Number *number, float value) { + if (number == nullptr) + return; + const float min_value = number->traits.get_min_value(); + const float max_value = number->traits.get_max_value(); + // Outside the declared range the user cannot write the value back, so publish + // what they can reach and say what the module actually sent. + if (value < min_value || value > max_value) { + ESP_LOGW(TAG, "'%s': module reported %.1f, clamped to %.1f..%.1f", number->get_name().c_str(), value, min_value, + max_value); + value = std::clamp(value, min_value, max_value); + } + number->publish_state(value); +} +#endif + +void LD6002BComponent::handle_version_report_(const uint8_t *data, uint16_t len) { + if (len < 4) + return; +#ifdef USE_TEXT_SENSOR + if (this->ota_version_text_sensor_ == nullptr) + return; + uint8_t project = data[0]; + uint8_t major = data[1]; + uint8_t minor = data[2]; + uint8_t patch = data[3]; + char buf[32]; + if (project == 0) { + std::snprintf(buf, sizeof(buf), "%u.%u.%u", major, minor, patch); + } else { + std::snprintf(buf, sizeof(buf), "p%u %u.%u.%u", project, major, minor, patch); + } + this->ota_version_text_sensor_->publish_state(buf); + this->save_version_pref_(buf); +#endif +} + void LD6002BComponent::queue_command_(uint16_t type, const uint8_t *data, uint8_t len) { if (len > CMD_MAX_DATA_LEN) { ESP_LOGW(TAG, "Command data too large: %u", len); @@ -587,4 +955,173 @@ void LD6002BComponent::send_control_command_(uint32_t command) { this->queue_command_(TYPE_CONTROL, data, sizeof(data)); } +void LD6002BComponent::send_z_range_() { + // One frame carries both bounds, so half a range cannot be written. + if (std::isnan(this->z_min_) || std::isnan(this->z_max_)) { + ESP_LOGW(TAG, "Z range not written, other bound unknown"); + return; + } + // Both bounds are known and crossed; the frame has no way to say that. + if (this->z_min_ > this->z_max_) { + ESP_LOGW(TAG, "Z range not written, min above max"); + return; + } + uint8_t data[8]; + write_f32_le(data, this->z_min_); + write_f32_le(data + 4, this->z_max_); + this->queue_command_(TYPE_SET_Z_RANGE, data, sizeof(data)); +} + +void LD6002BComponent::set_number_value(NumberType type, float value) { + switch (type) { + case NumberType::HOLD_DELAY: { + uint32_t delay = static_cast(value); + uint8_t data[4]; + write_u32_le(data, delay); + this->queue_command_(TYPE_SET_HOLD_DELAY, data, sizeof(data)); + break; + } + case NumberType::Z_MIN: + this->z_min_ = value; + this->send_z_range_(); + break; + case NumberType::Z_MAX: + this->z_max_ = value; + this->send_z_range_(); + break; + case NumberType::LOW_POWER_SLEEP: { + uint32_t sleep_ms = static_cast(value); + uint8_t data[4]; + write_u32_le(data, sleep_ms); + this->queue_command_(TYPE_SET_LOW_POWER_SLEEP, data, sizeof(data)); + break; + } + } +} + +void LD6002BComponent::init_version_pref_() { +#ifdef USE_TEXT_SENSOR + if (this->ota_version_text_sensor_ == nullptr) { + return; + } + this->version_pref_ = this->ota_version_text_sensor_->make_entity_preference(); + this->version_pref_initialized_ = true; + + VersionPref pref{}; + if (this->version_pref_.load(&pref) && pref.value[0] != '\0') { + pref.value[sizeof(pref.value) - 1] = '\0'; + this->ota_version_text_sensor_->publish_state(pref.value); + } +#endif +} + +void LD6002BComponent::save_version_pref_(const char *value) { +#ifdef USE_TEXT_SENSOR + if (!this->version_pref_initialized_) { + return; + } + VersionPref pref{}; + std::strncpy(pref.value, value, sizeof(pref.value) - 1); + pref.value[sizeof(pref.value) - 1] = '\0'; + this->version_pref_.save(&pref); +#endif +} + +#ifdef USE_SENSOR +void LD6002BComponent::clear_target_slot_(uint8_t index) { + if (!this->last_target_presence_[index]) { + return; + } + TargetSensors &target = this->targets_[index]; + if (target.x != nullptr) { + target.x->publish_state(NAN); + } + if (target.y != nullptr) { + target.y->publish_state(NAN); + } + if (target.z != nullptr) { + target.z->publish_state(NAN); + } + if (target.dop_idx != nullptr) { + target.dop_idx->publish_state(NAN); + } + if (target.cluster_id != nullptr) { + target.cluster_id->publish_state(NAN); + } + // The slot is free: the next person's id is new even when it repeats this one. + this->last_cluster_id_valid_[index] = false; +} +#endif + +void LD6002BComponent::clear_target_state_() { + // Nothing corrects any of this until the stream comes back. The slot table goes + // with it: slots key on cluster ids, which only track a person while reports are + // arriving, and the room can empty and refill across the gap -- so the next + // report starts from an empty table and fills slots in wire order, rather than + // handing one back to whoever last held that id. + for (uint8_t i = 0; i < MAX_TARGETS; i++) { +#ifdef USE_SENSOR + this->clear_target_slot_(i); + this->last_target_presence_[i] = false; +#endif + if (this->slot_occupied_[i]) { + this->slot_occupied_[i] = false; +#ifdef USE_BINARY_SENSOR + if (this->target_presence_[i] != nullptr) { + this->target_presence_[i]->publish_state(false); + } +#endif + } + } +#ifdef USE_SENSOR + if (this->last_target_count_ != 0xFFFFFFFF) { + if (this->target_count_sensor_ != nullptr) { + this->target_count_sensor_->publish_state(NAN); + } + this->last_target_count_ = 0xFFFFFFFF; + } +#endif + if (this->target_presence_any_) { + this->target_presence_any_ = false; +#ifdef USE_BINARY_SENSOR + if (this->presence_binary_sensor_ != nullptr) { + this->presence_binary_sensor_->publish_state(this->target_presence_any_); + } +#endif + this->update_work_mode_fallback_(); + } +} + +void LD6002BComponent::set_switch_state(SwitchType type, bool state) { + switch (type) { + case SwitchType::LOW_POWER: + this->low_power_enabled_ = state; + this->low_power_reported_ = true; + this->send_control_command_(state ? CMD_LOW_POWER_ON : CMD_LOW_POWER_OFF); + this->update_work_mode_fallback_(); + break; + case SwitchType::POINT_CLOUD: + this->point_cloud_enabled_ = state; + this->send_control_command_(state ? CMD_POINT_CLOUD_ON : CMD_POINT_CLOUD_OFF); +#ifdef USE_SENSOR + // The count only moves while the stream runs, so the last one would stand as + // a live reading. The dedup sentinel is cleared with it: the same count is + // new again when the stream comes back. + if (!state && this->point_count_sensor_ != nullptr && this->last_point_count_ != 0xFFFFFFFF) { + this->point_count_sensor_->publish_state(NAN); + this->last_point_count_ = 0xFFFFFFFF; + } +#endif + break; + case SwitchType::TARGET_DISPLAY: + this->target_display_enabled_ = state; + this->send_control_command_(state ? CMD_TARGET_DISPLAY_ON : CMD_TARGET_DISPLAY_OFF); + if (!state) { + // Every target entity is fed by the reports this just stopped. + this->clear_target_state_(); + } + break; + } +} + } // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/ld6002b.h b/esphome/components/ld6002b/ld6002b.h index 8bbfb9f6e4..5630d2d1a8 100644 --- a/esphome/components/ld6002b/ld6002b.h +++ b/esphome/components/ld6002b/ld6002b.h @@ -11,16 +11,41 @@ #ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" #endif +#ifdef USE_TEXT_SENSOR +#include "esphome/core/preferences.h" +#include "esphome/components/text_sensor/text_sensor.h" +#endif +#ifdef USE_NUMBER +#include "esphome/components/number/number.h" +#endif +#ifdef USE_SWITCH +#include "esphome/components/switch/switch.h" +#endif #include +#include namespace esphome::ld6002b { static constexpr uint8_t MAX_TARGETS = 3; static constexpr size_t DEFAULT_MAX_DATA_LEN = 1024; +static constexpr size_t DEFAULT_MAX_DATA_LEN_POINT_CLOUD = 4096; // Largest protocol payload is TYPE_SET_AREA: int32 area id + 6 floats = 28 bytes. static constexpr size_t CMD_MAX_DATA_LEN = 28; +enum class NumberType : uint8_t { + HOLD_DELAY, + Z_MIN, + Z_MAX, + LOW_POWER_SLEEP, +}; + +enum class SwitchType : uint8_t { + LOW_POWER, + POINT_CLOUD, + TARGET_DISPLAY, +}; + #ifdef USE_SENSOR struct TargetSensors { sensor::Sensor *x{nullptr}; @@ -32,6 +57,10 @@ struct TargetSensors { #endif +struct VersionPref { + char value[20]; +}; + class LD6002BComponent : public Component, public uart::UARTDevice { public: void setup() override; @@ -45,6 +74,7 @@ class LD6002BComponent : public Component, public uart::UARTDevice { #ifdef USE_SENSOR void set_target_count_sensor(sensor::Sensor *sensor) { this->target_count_sensor_ = sensor; } + void set_point_count_sensor(sensor::Sensor *sensor) { this->point_count_sensor_ = sensor; } void set_target_x_sensor(uint8_t target, sensor::Sensor *sensor) { if (target >= MAX_TARGETS) @@ -82,6 +112,27 @@ class LD6002BComponent : public Component, public uart::UARTDevice { } #endif +#ifdef USE_TEXT_SENSOR + void set_work_mode_text_sensor(text_sensor::TextSensor *sensor) { this->work_mode_text_sensor_ = sensor; } + void set_ota_version_text_sensor(text_sensor::TextSensor *sensor) { this->ota_version_text_sensor_ = sensor; } +#endif + +#ifdef USE_NUMBER + void set_hold_delay_number(number::Number *number) { this->hold_delay_number_ = number; } + void set_z_min_number(number::Number *number) { this->z_min_number_ = number; } + void set_z_max_number(number::Number *number) { this->z_max_number_ = number; } + void set_low_power_sleep_number(number::Number *number) { this->low_power_sleep_number_ = number; } +#endif + +#ifdef USE_SWITCH + void set_low_power_switch(switch_::Switch *sw) { this->low_power_switch_ = sw; } + void set_point_cloud_switch(switch_::Switch *sw) { this->point_cloud_switch_ = sw; } + void set_target_display_switch(switch_::Switch *sw) { this->target_display_switch_ = sw; } +#endif + + void set_number_value(NumberType type, float value); + void set_switch_state(SwitchType type, bool state); + protected: enum class ParseState : uint8_t { SOF, HEADER, HCK, DATA, DCK, DISCARD }; @@ -95,6 +146,25 @@ class LD6002BComponent : public Component, public uart::UARTDevice { void reset_parser_(); void handle_frame_(uint16_t type, const uint8_t *data, uint16_t len); void handle_target_report_(const uint8_t *data, uint16_t len); + void handle_point_cloud_(const uint8_t *data, uint16_t len); + void handle_delay_report_(const uint8_t *data, uint16_t len); + void handle_z_range_report_(const uint8_t *data, uint16_t len); + void handle_low_power_report_(const uint8_t *data, uint16_t len); + void handle_low_power_sleep_report_(const uint8_t *data, uint16_t len); + void handle_work_mode_report_(const uint8_t *data, uint16_t len); + void handle_version_report_(const uint8_t *data, uint16_t len); + void update_work_mode_fallback_(); + void publish_work_mode_(bool low_power); + // Drops every target-derived reading and the slot table they are indexed by. + void clear_target_state_(); +#ifdef USE_SENSOR + void clear_target_slot_(uint8_t index); +#endif +#ifdef USE_NUMBER + void publish_number_clamped_(number::Number *number, float value); +#endif + void init_version_pref_(); + void save_version_pref_(const char *value); void queue_command_(uint16_t type, const uint8_t *data, uint8_t len); void process_command_queue_(); @@ -102,21 +172,41 @@ class LD6002BComponent : public Component, public uart::UARTDevice { void send_command_internal_(uint16_t type, const uint8_t *data, uint8_t len, bool track); void write_frame_(uint16_t type, const uint8_t *data, uint8_t len, bool track); void send_control_command_(uint32_t command); + void send_z_range_(); static uint16_t read_u16_be(const uint8_t *data); static uint32_t read_u32_le(const uint8_t *data); static int32_t read_int32_le(const uint8_t *data); static float read_f32_le(const uint8_t *data); static void write_u32_le(uint8_t *data, uint32_t value); + static void write_f32_le(uint8_t *data, float value); #ifdef USE_SENSOR std::array targets_{}; sensor::Sensor *target_count_sensor_{nullptr}; + sensor::Sensor *point_count_sensor_{nullptr}; #endif #ifdef USE_BINARY_SENSOR binary_sensor::BinarySensor *presence_binary_sensor_{nullptr}; std::array target_presence_{}; #endif +#ifdef USE_TEXT_SENSOR + text_sensor::TextSensor *work_mode_text_sensor_{nullptr}; + text_sensor::TextSensor *ota_version_text_sensor_{nullptr}; + ESPPreferenceObject version_pref_{}; + bool version_pref_initialized_{false}; +#endif +#ifdef USE_NUMBER + number::Number *hold_delay_number_{nullptr}; + number::Number *z_min_number_{nullptr}; + number::Number *z_max_number_{nullptr}; + number::Number *low_power_sleep_number_{nullptr}; +#endif +#ifdef USE_SWITCH + switch_::Switch *low_power_switch_{nullptr}; + switch_::Switch *point_cloud_switch_{nullptr}; + switch_::Switch *target_display_switch_{nullptr}; +#endif GPIOPin *wakeup_pin_{nullptr}; uint32_t wakeup_pulse_ms_{50}; @@ -132,6 +222,7 @@ class LD6002BComponent : public Component, public uart::UARTDevice { uint8_t data_xor_{0}; uint32_t discard_remaining_{0}; bool frame_oversize_{false}; + size_t max_data_len_{0}; uint8_t *data_buf_{nullptr}; uint16_t next_frame_id_{0}; @@ -173,11 +264,24 @@ class LD6002BComponent : public Component, public uart::UARTDevice { // Bumped whenever the active command changes, so a deferred send can tell it was retired. uint8_t send_generation_{0}; + float z_min_{NAN}; + float z_max_{NAN}; + // Which person owns each target_N slot, so a slot survives the module re-sorting its array. std::array slot_cluster_{}; std::array slot_occupied_{}; bool target_presence_any_{false}; + // What the switches and setup asked the module for, which is not the same as + // what it is doing yet: a stream keeps sending until it acts on the command. + // The report handlers read these and drop anything a stopped stream still emits. + bool target_display_enabled_{false}; + bool point_cloud_enabled_{false}; + bool work_mode_reported_{false}; + bool low_power_enabled_{false}; + bool low_power_reported_{false}; + bool last_work_mode_valid_{false}; + bool last_work_mode_low_power_{false}; #ifdef USE_SENSOR std::array last_target_presence_{}; // one-shot NAN clear for target sensors @@ -185,6 +289,7 @@ class LD6002BComponent : public Component, public uart::UARTDevice { std::array last_cluster_id_{}; std::array last_cluster_id_valid_{}; uint32_t last_target_count_{0xFFFFFFFF}; + uint32_t last_point_count_{0xFFFFFFFF}; #endif }; diff --git a/esphome/components/ld6002b/number/__init__.py b/esphome/components/ld6002b/number/__init__.py new file mode 100644 index 0000000000..10e9e89dc8 --- /dev/null +++ b/esphome/components/ld6002b/number/__init__.py @@ -0,0 +1,82 @@ +import esphome.codegen as cg +from esphome.components import number +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_DISTANCE, + DEVICE_CLASS_DURATION, + ENTITY_CATEGORY_CONFIG, + UNIT_METER, + UNIT_MILLISECOND, + UNIT_SECOND, +) + +from .. import LD6002BComponent, ld6002b_ns +from ..const import ( + CONF_HOLD_DELAY, + CONF_LD6002B_ID, + CONF_LOW_POWER_SLEEP_TIME, + CONF_Z_MAX, + CONF_Z_MIN, +) + +DEPENDENCIES = ["ld6002b"] + +LD6002BNumber = ld6002b_ns.class_("LD6002BNumber", number.Number) +NumberType = ld6002b_ns.enum("NumberType", is_class=True) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_HOLD_DELAY): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_SECOND, + device_class=DEVICE_CLASS_DURATION, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_Z_MIN): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_Z_MAX): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_LOW_POWER_SLEEP_TIME): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_MILLISECOND, + device_class=DEVICE_CLASS_DURATION, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + } +) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_LD6002B_ID]) + + for key, number_type, setter, min_value, max_value, step in ( + (CONF_HOLD_DELAY, NumberType.HOLD_DELAY, "set_hold_delay_number", 0, 65535, 1), + (CONF_Z_MIN, NumberType.Z_MIN, "set_z_min_number", -10, 10, 0.1), + (CONF_Z_MAX, NumberType.Z_MAX, "set_z_max_number", -10, 10, 0.1), + # 0x0205 carries a uint32 of milliseconds; the vendor documents 500 ms as + # the default and no upper bound, so the range ends at a minute rather + # than at a default the module is free to be sleeping past. + ( + CONF_LOW_POWER_SLEEP_TIME, + NumberType.LOW_POWER_SLEEP, + "set_low_power_sleep_number", + 0, + 60000, + 100, + ), + ): + if conf := config.get(key): + n = await number.new_number( + conf, number_type, min_value=min_value, max_value=max_value, step=step + ) + await cg.register_parented(n, config[CONF_LD6002B_ID]) + cg.add(getattr(hub, setter)(n)) diff --git a/esphome/components/ld6002b/number/ld6002b_number.cpp b/esphome/components/ld6002b/number/ld6002b_number.cpp new file mode 100644 index 0000000000..b0b1b6f72b --- /dev/null +++ b/esphome/components/ld6002b/number/ld6002b_number.cpp @@ -0,0 +1,10 @@ +#include "ld6002b_number.h" + +namespace esphome::ld6002b { + +void LD6002BNumber::control(float value) { + this->publish_state(value); + this->parent_->set_number_value(this->type_, value); +} + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/number/ld6002b_number.h b/esphome/components/ld6002b/number/ld6002b_number.h new file mode 100644 index 0000000000..3101b4d3cd --- /dev/null +++ b/esphome/components/ld6002b/number/ld6002b_number.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/number/number.h" +#include "../ld6002b.h" + +namespace esphome::ld6002b { + +class LD6002BNumber : public number::Number, public Parented { + public: + explicit LD6002BNumber(NumberType type) : type_(type) {} + + protected: + void control(float value) override; + + NumberType type_; +}; + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/sensor.py b/esphome/components/ld6002b/sensor.py index 3aa9b0f98a..ff88d343b9 100644 --- a/esphome/components/ld6002b/sensor.py +++ b/esphome/components/ld6002b/sensor.py @@ -15,6 +15,7 @@ from .const import ( CONF_CLUSTER_ID, CONF_DOPPLER_INDEX, CONF_LD6002B_ID, + CONF_POINT_COUNT, CONF_Z, MAX_TARGETS, ) @@ -75,6 +76,10 @@ CONFIG_SCHEMA = cv.Schema( accuracy_decimals=0, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_POINT_COUNT): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), } ).extend({cv.Optional(f"target_{i + 1}"): TARGET_SCHEMA for i in range(MAX_TARGETS)}) @@ -86,6 +91,10 @@ async def to_code(config): sens = await sensor.new_sensor(target_count_config) cg.add(hub.set_target_count_sensor(sens)) + if point_count_config := config.get(CONF_POINT_COUNT): + sens = await sensor.new_sensor(point_count_config) + cg.add(hub.set_point_count_sensor(sens)) + for i in range(MAX_TARGETS): if target_config := config.get(f"target_{i + 1}"): if x_config := target_config.get(CONF_X): diff --git a/esphome/components/ld6002b/switch/__init__.py b/esphome/components/ld6002b/switch/__init__.py new file mode 100644 index 0000000000..d27baa87fe --- /dev/null +++ b/esphome/components/ld6002b/switch/__init__.py @@ -0,0 +1,60 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_SWITCH, ENTITY_CATEGORY_CONFIG + +from .. import LD6002BComponent, ld6002b_ns +from ..const import ( + CONF_LD6002B_ID, + CONF_LOW_POWER, + CONF_POINT_CLOUD, + CONF_TARGET_DISPLAY, +) + +DEPENDENCIES = ["ld6002b"] + +LD6002BSwitch = ld6002b_ns.class_("LD6002BSwitch", switch.Switch) +SwitchType = ld6002b_ns.enum("SwitchType", is_class=True) + +# None of these three carry an inversion. They name what the module is doing, not +# how something is wired to it, so an inverted one would only report the opposite +# of the truth -- and the boot restore, which applies a state nothing reports back, +# is where that would be hardest to spot. +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_LOW_POWER): switch.switch_schema( + LD6002BSwitch, + block_inverted=True, + device_class=DEVICE_CLASS_SWITCH, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_POINT_CLOUD): switch.switch_schema( + LD6002BSwitch, + block_inverted=True, + device_class=DEVICE_CLASS_SWITCH, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_TARGET_DISPLAY): switch.switch_schema( + LD6002BSwitch, + block_inverted=True, + device_class=DEVICE_CLASS_SWITCH, + entity_category=ENTITY_CATEGORY_CONFIG, + default_restore_mode="RESTORE_DEFAULT_ON", + ), + } +) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_LD6002B_ID]) + + for key, switch_type, setter in ( + (CONF_LOW_POWER, SwitchType.LOW_POWER, "set_low_power_switch"), + (CONF_POINT_CLOUD, SwitchType.POINT_CLOUD, "set_point_cloud_switch"), + (CONF_TARGET_DISPLAY, SwitchType.TARGET_DISPLAY, "set_target_display_switch"), + ): + if conf := config.get(key): + s = await switch.new_switch(conf, switch_type) + await cg.register_parented(s, config[CONF_LD6002B_ID]) + cg.add(getattr(hub, setter)(s)) diff --git a/esphome/components/ld6002b/switch/ld6002b_switch.cpp b/esphome/components/ld6002b/switch/ld6002b_switch.cpp new file mode 100644 index 0000000000..7542f7b1ff --- /dev/null +++ b/esphome/components/ld6002b/switch/ld6002b_switch.cpp @@ -0,0 +1,10 @@ +#include "ld6002b_switch.h" + +namespace esphome::ld6002b { + +void LD6002BSwitch::write_state(bool state) { + this->parent_->set_switch_state(this->type_, state); + this->publish_state(state); +} + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/switch/ld6002b_switch.h b/esphome/components/ld6002b/switch/ld6002b_switch.h new file mode 100644 index 0000000000..44773f802f --- /dev/null +++ b/esphome/components/ld6002b/switch/ld6002b_switch.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/switch/switch.h" +#include "../ld6002b.h" + +namespace esphome::ld6002b { + +class LD6002BSwitch : public switch_::Switch, public Parented { + public: + explicit LD6002BSwitch(SwitchType type) : type_(type) {} + + protected: + void write_state(bool state) override; + + SwitchType type_; +}; + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/text_sensor.py b/esphome/components/ld6002b/text_sensor.py new file mode 100644 index 0000000000..a18d387437 --- /dev/null +++ b/esphome/components/ld6002b/text_sensor.py @@ -0,0 +1,31 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC + +from . import LD6002BComponent +from .const import CONF_LD6002B_ID, CONF_OTA_VERSION, CONF_WORK_MODE + +DEPENDENCIES = ["ld6002b"] + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_WORK_MODE): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_OTA_VERSION): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + } +) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_LD6002B_ID]) + if work_mode_config := config.get(CONF_WORK_MODE): + sens = await text_sensor.new_text_sensor(work_mode_config) + cg.add(hub.set_work_mode_text_sensor(sens)) + if ota_config := config.get(CONF_OTA_VERSION): + sens = await text_sensor.new_text_sensor(ota_config) + cg.add(hub.set_ota_version_text_sensor(sens)) diff --git a/tests/components/ld6002b/common.yaml b/tests/components/ld6002b/common.yaml index 15ab06c394..f8a9e95340 100644 --- a/tests/components/ld6002b/common.yaml +++ b/tests/components/ld6002b/common.yaml @@ -7,6 +7,8 @@ sensor: ld6002b_id: ld6002b_radar target_count: name: Target Count + point_count: + name: Point Count target_1: x: name: Target-1 X @@ -48,3 +50,33 @@ binary_sensor: name: Presence target_1: name: Target-1 Presence + +text_sensor: + - platform: ld6002b + ld6002b_id: ld6002b_radar + work_mode: + name: Work Mode + ota_version: + name: OTA Version + +number: + - platform: ld6002b + ld6002b_id: ld6002b_radar + hold_delay: + name: Hold Delay + z_min: + name: Z Min + z_max: + name: Z Max + low_power_sleep_time: + name: Low Power Sleep + +switch: + - platform: ld6002b + ld6002b_id: ld6002b_radar + low_power: + name: Low Power + point_cloud: + name: Point Cloud + target_display: + name: Target Display From 5f483b11b6c9b3b222d6e0bd25239aa95e23af69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Fri, 7 Aug 2026 19:19:08 +0300 Subject: [PATCH 1303/1815] [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 1: ble_presence, ble_rssi, ble_scanner) (#17716) --- .../components/ble_presence/binary_sensor.py | 30 ++++++++----------- .../ble_presence/ble_presence_device.cpp | 4 --- .../ble_presence/ble_presence_device.h | 26 ++++++++-------- .../components/ble_rssi/ble_rssi_sensor.cpp | 4 --- esphome/components/ble_rssi/ble_rssi_sensor.h | 26 ++++++++-------- esphome/components/ble_rssi/sensor.py | 30 ++++++++----------- .../components/ble_scanner/ble_scanner.cpp | 4 --- esphome/components/ble_scanner/ble_scanner.h | 16 +++++----- esphome/components/ble_scanner/text_sensor.py | 11 +++---- tests/components/ble_presence/common-ln.yaml | 4 +++ tests/components/ble_presence/common.yaml | 3 ++ .../ble_presence/test.ln882x-ard.yaml | 3 ++ .../ble_presence/validate.bk72xx-ard.yaml | 14 +++++++++ tests/components/ble_rssi/common-ln.yaml | 5 ++++ tests/components/ble_rssi/common.yaml | 7 ++++- .../components/ble_rssi/test.ln882x-ard.yaml | 3 ++ .../validate-legacy-key.esp32-idf.yaml | 11 +++++++ .../ble_rssi/validate.bk72xx-ard.yaml | 14 +++++++++ tests/components/ble_scanner/common-ln.yaml | 3 ++ tests/components/ble_scanner/common.yaml | 3 ++ .../ble_scanner/test.ln882x-ard.yaml | 3 ++ .../ble_scanner/validate.bk72xx-ard.yaml | 13 ++++++++ 22 files changed, 150 insertions(+), 87 deletions(-) create mode 100644 tests/components/ble_presence/common-ln.yaml create mode 100644 tests/components/ble_presence/test.ln882x-ard.yaml create mode 100644 tests/components/ble_presence/validate.bk72xx-ard.yaml create mode 100644 tests/components/ble_rssi/common-ln.yaml create mode 100644 tests/components/ble_rssi/test.ln882x-ard.yaml create mode 100644 tests/components/ble_rssi/validate-legacy-key.esp32-idf.yaml create mode 100644 tests/components/ble_rssi/validate.bk72xx-ard.yaml create mode 100644 tests/components/ble_scanner/common-ln.yaml create mode 100644 tests/components/ble_scanner/test.ln882x-ard.yaml create mode 100644 tests/components/ble_scanner/validate.bk72xx-ard.yaml diff --git a/esphome/components/ble_presence/binary_sensor.py b/esphome/components/ble_presence/binary_sensor.py index 3a0f1ade98..a7713d9a4b 100644 --- a/esphome/components/ble_presence/binary_sensor.py +++ b/esphome/components/ble_presence/binary_sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import binary_sensor, esp32_ble_tracker +from esphome.components import binary_sensor, ble_device_base import esphome.config_validation as cv from esphome.const import ( CONF_IBEACON_MAJOR, @@ -13,14 +13,14 @@ from esphome.const import ( CONF_IRK = "irk" -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] ble_presence_ns = cg.esphome_ns.namespace("ble_presence") BLEPresenceDevice = ble_presence_ns.class_( "BLEPresenceDevice", binary_sensor.BinarySensor, cg.Component, - esp32_ble_tracker.ESPBTDeviceListener, + ble_device_base.ESPBTDeviceListener, ) @@ -33,23 +33,24 @@ def _validate(config): CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("ble_presence"), binary_sensor.binary_sensor_schema(BLEPresenceDevice) .extend( { cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, cv.Optional(CONF_IRK): cv.uuid, - cv.Optional(CONF_SERVICE_UUID): esp32_ble_tracker.bt_uuid, + cv.Optional(CONF_SERVICE_UUID): ble_device_base.bt_uuid, cv.Optional(CONF_IBEACON_MAJOR): cv.uint16_t, cv.Optional(CONF_IBEACON_MINOR): cv.uint16_t, - cv.Optional(CONF_IBEACON_UUID): esp32_ble_tracker.bt_uuid, + cv.Optional(CONF_IBEACON_UUID): ble_device_base.bt_uuid, cv.Optional(CONF_TIMEOUT, default="5min"): cv.positive_time_period, cv.Optional(CONF_MIN_RSSI): cv.All( cv.decibel, cv.int_range(min=-100, max=-30) ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) - .extend(cv.COMPONENT_SCHEMA), + .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), cv.has_exactly_one_key( CONF_MAC_ADDRESS, CONF_IRK, CONF_SERVICE_UUID, CONF_IBEACON_UUID ), @@ -60,7 +61,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_timeout(config[CONF_TIMEOUT].total_milliseconds)) if min_rssi := config.get(CONF_MIN_RSSI): @@ -70,20 +71,15 @@ async def to_code(config): cg.add(var.set_address(mac_address.as_hex)) if irk := config.get(CONF_IRK): - irk = esp32_ble_tracker.as_hex_array(str(irk)) + ble_device_base.request_irk_support() + irk = ble_device_base.as_hex_array(str(irk)) cg.add(var.set_irk(irk)) if service_uuid := config.get(CONF_SERVICE_UUID): - if len(service_uuid) == len(esp32_ble_tracker.bt_uuid16_format): - cg.add(var.set_service_uuid16(esp32_ble_tracker.as_hex(service_uuid))) - elif len(service_uuid) == len(esp32_ble_tracker.bt_uuid32_format): - cg.add(var.set_service_uuid32(esp32_ble_tracker.as_hex(service_uuid))) - elif len(service_uuid) == len(esp32_ble_tracker.bt_uuid128_format): - uuid128 = esp32_ble_tracker.as_reversed_hex_array(service_uuid) - cg.add(var.set_service_uuid128(uuid128)) + ble_device_base.add_service_uuid(var, service_uuid) if ibeacon_uuid := config.get(CONF_IBEACON_UUID): - ibeacon_uuid = esp32_ble_tracker.as_reversed_hex_array(ibeacon_uuid) + ibeacon_uuid = ble_device_base.as_reversed_hex_array(ibeacon_uuid) cg.add(var.set_ibeacon_uuid(ibeacon_uuid)) if (ibeacon_major := config.get(CONF_IBEACON_MAJOR)) is not None: diff --git a/esphome/components/ble_presence/ble_presence_device.cpp b/esphome/components/ble_presence/ble_presence_device.cpp index 4a70648ac5..bc169623ce 100644 --- a/esphome/components/ble_presence/ble_presence_device.cpp +++ b/esphome/components/ble_presence/ble_presence_device.cpp @@ -1,8 +1,6 @@ #include "ble_presence_device.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::ble_presence { static const char *const TAG = "ble_presence"; @@ -10,5 +8,3 @@ static const char *const TAG = "ble_presence"; void BLEPresenceDevice::dump_config() { LOG_BINARY_SENSOR("", "BLE Presence", this); } } // namespace esphome::ble_presence - -#endif diff --git a/esphome/components/ble_presence/ble_presence_device.h b/esphome/components/ble_presence/ble_presence_device.h index e17e26ff1c..4e49cc32a3 100644 --- a/esphome/components/ble_presence/ble_presence_device.h +++ b/esphome/components/ble_presence/ble_presence_device.h @@ -1,15 +1,17 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/binary_sensor/binary_sensor.h" -#ifdef USE_ESP32 - +// No platform #ifdef: ble_device_base provides the BLE types on every platform, +// and this component is only compiled when configured — which requires a BLE +// hub — so it builds on any platform with a BLEHub tracker without a per-chip +// guard. namespace esphome::ble_presence { class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener, + public ble_device_base::ESPBTDeviceListener, public Component { public: void set_address(uint64_t address) { @@ -22,19 +24,19 @@ class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff, } void set_service_uuid16(uint16_t uuid) { this->match_by_ = MATCH_BY_SERVICE_UUID; - this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_uint16(uuid); + this->uuid_ = ble_device_base::ESPBTUUID::from_uint16(uuid); } void set_service_uuid32(uint32_t uuid) { this->match_by_ = MATCH_BY_SERVICE_UUID; - this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_uint32(uuid); + this->uuid_ = ble_device_base::ESPBTUUID::from_uint32(uuid); } void set_service_uuid128(uint8_t *uuid) { this->match_by_ = MATCH_BY_SERVICE_UUID; - this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_raw(uuid); + this->uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); } void set_ibeacon_uuid(uint8_t *uuid) { this->match_by_ = MATCH_BY_IBEACON_UUID; - this->ibeacon_uuid_ = esp32_ble_tracker::ESPBTUUID::from_raw(uuid); + this->ibeacon_uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); } void set_ibeacon_major(uint16_t major) { this->check_ibeacon_major_ = true; @@ -49,7 +51,7 @@ class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff, this->minimum_rssi_ = rssi; } void set_timeout(uint32_t timeout) { this->timeout_ = timeout; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override { + bool parse_device(const ble_device_base::ESPBTDevice &device) override { if (this->check_minimum_rssi_ && this->minimum_rssi_ > device.get_rssi()) { return false; } @@ -119,9 +121,9 @@ class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff, uint64_t address_; uint8_t *irk_; - esp32_ble_tracker::ESPBTUUID uuid_; + ble_device_base::ESPBTUUID uuid_; - esp32_ble_tracker::ESPBTUUID ibeacon_uuid_; + ble_device_base::ESPBTUUID ibeacon_uuid_; uint16_t ibeacon_major_{0}; uint16_t ibeacon_minor_{0}; @@ -137,5 +139,3 @@ class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff, }; } // namespace esphome::ble_presence - -#endif diff --git a/esphome/components/ble_rssi/ble_rssi_sensor.cpp b/esphome/components/ble_rssi/ble_rssi_sensor.cpp index f678865f47..7c7c7b2148 100644 --- a/esphome/components/ble_rssi/ble_rssi_sensor.cpp +++ b/esphome/components/ble_rssi/ble_rssi_sensor.cpp @@ -1,8 +1,6 @@ #include "ble_rssi_sensor.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::ble_rssi { static const char *const TAG = "ble_rssi"; @@ -10,5 +8,3 @@ static const char *const TAG = "ble_rssi"; void BLERSSISensor::dump_config() { LOG_SENSOR("", "BLE RSSI Sensor", this); } } // namespace esphome::ble_rssi - -#endif diff --git a/esphome/components/ble_rssi/ble_rssi_sensor.h b/esphome/components/ble_rssi/ble_rssi_sensor.h index 8e804ab8e7..a30b94b8b7 100644 --- a/esphome/components/ble_rssi/ble_rssi_sensor.h +++ b/esphome/components/ble_rssi/ble_rssi_sensor.h @@ -1,14 +1,16 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/sensor/sensor.h" -#ifdef USE_ESP32 - +// No platform #ifdef: ble_device_base provides the BLE types on every platform, +// and this component is only compiled when configured — which requires a BLE +// hub — so it builds on any platform with a BLEHub tracker without a per-chip +// guard. namespace esphome::ble_rssi { -class BLERSSISensor final : public sensor::Sensor, public esp32_ble_tracker::ESPBTDeviceListener, public Component { +class BLERSSISensor final : public sensor::Sensor, public ble_device_base::ESPBTDeviceListener, public Component { public: void set_address(uint64_t address) { this->match_by_ = MATCH_BY_MAC_ADDRESS; @@ -20,19 +22,19 @@ class BLERSSISensor final : public sensor::Sensor, public esp32_ble_tracker::ESP } void set_service_uuid16(uint16_t uuid) { this->match_by_ = MATCH_BY_SERVICE_UUID; - this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_uint16(uuid); + this->uuid_ = ble_device_base::ESPBTUUID::from_uint16(uuid); } void set_service_uuid32(uint32_t uuid) { this->match_by_ = MATCH_BY_SERVICE_UUID; - this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_uint32(uuid); + this->uuid_ = ble_device_base::ESPBTUUID::from_uint32(uuid); } void set_service_uuid128(uint8_t *uuid) { this->match_by_ = MATCH_BY_SERVICE_UUID; - this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_raw(uuid); + this->uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); } void set_ibeacon_uuid(uint8_t *uuid) { this->match_by_ = MATCH_BY_IBEACON_UUID; - this->ibeacon_uuid_ = esp32_ble_tracker::ESPBTUUID::from_raw(uuid); + this->ibeacon_uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); } void set_ibeacon_major(uint16_t major) { this->check_ibeacon_major_ = true; @@ -47,7 +49,7 @@ class BLERSSISensor final : public sensor::Sensor, public esp32_ble_tracker::ESP this->publish_state(NAN); this->found_ = false; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override { + bool parse_device(const ble_device_base::ESPBTDevice &device) override { switch (this->match_by_) { case MATCH_BY_MAC_ADDRESS: if (device.address_uint64() == this->address_) { @@ -109,9 +111,9 @@ class BLERSSISensor final : public sensor::Sensor, public esp32_ble_tracker::ESP uint64_t address_; uint8_t *irk_; - esp32_ble_tracker::ESPBTUUID uuid_; + ble_device_base::ESPBTUUID uuid_; - esp32_ble_tracker::ESPBTUUID ibeacon_uuid_; + ble_device_base::ESPBTUUID ibeacon_uuid_; uint16_t ibeacon_major_; uint16_t ibeacon_minor_; @@ -120,5 +122,3 @@ class BLERSSISensor final : public sensor::Sensor, public esp32_ble_tracker::ESP }; } // namespace esphome::ble_rssi - -#endif diff --git a/esphome/components/ble_rssi/sensor.py b/esphome/components/ble_rssi/sensor.py index c4e767aa21..43e5813ea2 100644 --- a/esphome/components/ble_rssi/sensor.py +++ b/esphome/components/ble_rssi/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_IBEACON_MAJOR, @@ -14,11 +14,11 @@ from esphome.const import ( CONF_IRK = "irk" -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] ble_rssi_ns = cg.esphome_ns.namespace("ble_rssi") BLERSSISensor = ble_rssi_ns.class_( - "BLERSSISensor", sensor.Sensor, cg.Component, esp32_ble_tracker.ESPBTDeviceListener + "BLERSSISensor", sensor.Sensor, cg.Component, ble_device_base.ESPBTDeviceListener ) @@ -31,6 +31,7 @@ def _validate(config): CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("ble_rssi"), sensor.sensor_schema( BLERSSISensor, unit_of_measurement=UNIT_DECIBEL_MILLIWATT, @@ -42,14 +43,14 @@ CONFIG_SCHEMA = cv.All( { cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, cv.Optional(CONF_IRK): cv.uuid, - cv.Optional(CONF_SERVICE_UUID): esp32_ble_tracker.bt_uuid, + cv.Optional(CONF_SERVICE_UUID): ble_device_base.bt_uuid, cv.Optional(CONF_IBEACON_MAJOR): cv.uint16_t, cv.Optional(CONF_IBEACON_MINOR): cv.uint16_t, - cv.Optional(CONF_IBEACON_UUID): esp32_ble_tracker.bt_uuid, + cv.Optional(CONF_IBEACON_UUID): ble_device_base.bt_uuid, } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) - .extend(cv.COMPONENT_SCHEMA), + .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), cv.has_exactly_one_key( CONF_MAC_ADDRESS, CONF_IRK, CONF_SERVICE_UUID, CONF_IBEACON_UUID ), @@ -60,26 +61,21 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): var = await sensor.new_sensor(config) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) if mac_address := config.get(CONF_MAC_ADDRESS): cg.add(var.set_address(mac_address.as_hex)) if irk := config.get(CONF_IRK): - irk = esp32_ble_tracker.as_hex_array(str(irk)) + ble_device_base.request_irk_support() + irk = ble_device_base.as_hex_array(str(irk)) cg.add(var.set_irk(irk)) if service_uuid := config.get(CONF_SERVICE_UUID): - if len(service_uuid) == len(esp32_ble_tracker.bt_uuid16_format): - cg.add(var.set_service_uuid16(esp32_ble_tracker.as_hex(service_uuid))) - elif len(service_uuid) == len(esp32_ble_tracker.bt_uuid32_format): - cg.add(var.set_service_uuid32(esp32_ble_tracker.as_hex(service_uuid))) - elif len(service_uuid) == len(esp32_ble_tracker.bt_uuid128_format): - uuid128 = esp32_ble_tracker.as_reversed_hex_array(service_uuid) - cg.add(var.set_service_uuid128(uuid128)) + ble_device_base.add_service_uuid(var, service_uuid) if ibeacon_uuid := config.get(CONF_IBEACON_UUID): - ibeacon_uuid = esp32_ble_tracker.as_reversed_hex_array(ibeacon_uuid) + ibeacon_uuid = ble_device_base.as_reversed_hex_array(ibeacon_uuid) cg.add(var.set_ibeacon_uuid(ibeacon_uuid)) if (ibeacon_major := config.get(CONF_IBEACON_MAJOR)) is not None: diff --git a/esphome/components/ble_scanner/ble_scanner.cpp b/esphome/components/ble_scanner/ble_scanner.cpp index d85894edc8..3d7a301793 100644 --- a/esphome/components/ble_scanner/ble_scanner.cpp +++ b/esphome/components/ble_scanner/ble_scanner.cpp @@ -1,8 +1,6 @@ #include "ble_scanner.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::ble_scanner { static const char *const TAG = "ble_scanner"; @@ -10,5 +8,3 @@ static const char *const TAG = "ble_scanner"; void BLEScanner::dump_config() { LOG_TEXT_SENSOR("", "BLE Scanner", this); } } // namespace esphome::ble_scanner - -#endif diff --git a/esphome/components/ble_scanner/ble_scanner.h b/esphome/components/ble_scanner/ble_scanner.h index b4e4488646..0efc42682b 100644 --- a/esphome/components/ble_scanner/ble_scanner.h +++ b/esphome/components/ble_scanner/ble_scanner.h @@ -7,18 +7,18 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/string_ref.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/text_sensor/text_sensor.h" -#ifdef USE_ESP32 - +// No platform #ifdef: ble_device_base provides the BLE types on every platform, +// and this component is only compiled when configured — which requires a BLE +// hub — so it builds on any platform with a BLEHub tracker without a per-chip +// guard. namespace esphome::ble_scanner { -class BLEScanner final : public text_sensor::TextSensor, - public esp32_ble_tracker::ESPBTDeviceListener, - public Component { +class BLEScanner final : public text_sensor::TextSensor, public ble_device_base::ESPBTDeviceListener, public Component { public: - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override { + bool parse_device(const ble_device_base::ESPBTDevice &device) override { char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; // Escape special characters in the device name for valid JSON. Control characters stay in the \u00XX form this // sensor has always published. @@ -35,5 +35,3 @@ class BLEScanner final : public text_sensor::TextSensor, }; } // namespace esphome::ble_scanner - -#endif diff --git a/esphome/components/ble_scanner/text_sensor.py b/esphome/components/ble_scanner/text_sensor.py index 96d71a0399..0c08e1f734 100644 --- a/esphome/components/ble_scanner/text_sensor.py +++ b/esphome/components/ble_scanner/text_sensor.py @@ -1,25 +1,26 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, text_sensor +from esphome.components import ble_device_base, text_sensor import esphome.config_validation as cv -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] ble_scanner_ns = cg.esphome_ns.namespace("ble_scanner") BLEScanner = ble_scanner_ns.class_( "BLEScanner", text_sensor.TextSensor, cg.Component, - esp32_ble_tracker.ESPBTDeviceListener, + ble_device_base.ESPBTDeviceListener, ) CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("ble_scanner"), text_sensor.text_sensor_schema(BLEScanner) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = await text_sensor.new_text_sensor(config) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) diff --git a/tests/components/ble_presence/common-ln.yaml b/tests/components/ble_presence/common-ln.yaml new file mode 100644 index 0000000000..2cc5075efe --- /dev/null +++ b/tests/components/ble_presence/common-ln.yaml @@ -0,0 +1,4 @@ +binary_sensor: + - platform: ble_presence + mac_address: 11:22:33:44:55:66 + name: BLE Test Presence diff --git a/tests/components/ble_presence/common.yaml b/tests/components/ble_presence/common.yaml index 2ba6aa0754..bd2bb9fecc 100644 --- a/tests/components/ble_presence/common.yaml +++ b/tests/components/ble_presence/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub binary_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: ble_presence + ble_hub_id: ble_tracker_hub mac_address: AC:37:43:77:5F:4C name: ESP32 BLE Tracker Google Home Mini - platform: ble_presence diff --git a/tests/components/ble_presence/test.ln882x-ard.yaml b/tests/components/ble_presence/test.ln882x-ard.yaml new file mode 100644 index 0000000000..6a359c772c --- /dev/null +++ b/tests/components/ble_presence/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + ble_presence: !include common-ln.yaml diff --git a/tests/components/ble_presence/validate.bk72xx-ard.yaml b/tests/components/ble_presence/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..92b39f1255 --- /dev/null +++ b/tests/components/ble_presence/validate.bk72xx-ard.yaml @@ -0,0 +1,14 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_hub + +binary_sensor: + - platform: ble_presence + ble_hub_id: ble_hub + mac_address: AC:37:43:77:5F:4C + name: BK BLE Presence + - platform: ble_presence + irk: 1234567890abcdef1234567890abcdef + name: BK BLE Presence IRK diff --git a/tests/components/ble_rssi/common-ln.yaml b/tests/components/ble_rssi/common-ln.yaml new file mode 100644 index 0000000000..f0ccc2df06 --- /dev/null +++ b/tests/components/ble_rssi/common-ln.yaml @@ -0,0 +1,5 @@ +sensor: + - platform: ble_rssi + # irk: is the only thing that emits USE_BLE_DEVICE_IRK off ESP32 + irk: 1234567890abcdef1234567890abcdef + name: BLE Test RSSI diff --git a/tests/components/ble_rssi/common.yaml b/tests/components/ble_rssi/common.yaml index 43bed1d0e7..bbedf17c37 100644 --- a/tests/components/ble_rssi/common.yaml +++ b/tests/components/ble_rssi/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: ble_rssi + ble_hub_id: ble_tracker_hub mac_address: AC:37:43:77:5F:4C name: BLE Google Home Mini RSSI value - platform: ble_rssi @@ -14,7 +17,9 @@ sensor: service_uuid: 11223344-5566-7788-99aa-bbccddeeff00 name: BLE Test Service 128 - platform: ble_rssi - service_uuid: 11223344-5566-7788-99aa-bbccddeeff00 + ibeacon_uuid: 11223344-5566-7788-99aa-bbccddeeff00 + ibeacon_major: 100 + ibeacon_minor: 1 name: BLE Test iBeacon UUID - platform: ble_rssi irk: 1234567890abcdef1234567890abcdef diff --git a/tests/components/ble_rssi/test.ln882x-ard.yaml b/tests/components/ble_rssi/test.ln882x-ard.yaml new file mode 100644 index 0000000000..3554484ca3 --- /dev/null +++ b/tests/components/ble_rssi/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + ble_rssi: !include common-ln.yaml diff --git a/tests/components/ble_rssi/validate-legacy-key.esp32-idf.yaml b/tests/components/ble_rssi/validate-legacy-key.esp32-idf.yaml new file mode 100644 index 0000000000..926701117e --- /dev/null +++ b/tests/components/ble_rssi/validate-legacy-key.esp32-idf.yaml @@ -0,0 +1,11 @@ +# Config-only: pins the esp32_ble_id: -> ble_hub_id: deprecation alias — the +# legacy key must keep validating (with a rename warning) until its removal +# release (2027.2.0). +esp32_ble_tracker: + id: legacy_tracker + +sensor: + - platform: ble_rssi + esp32_ble_id: legacy_tracker + mac_address: AC:37:43:77:5F:4C + name: Legacy Key RSSI diff --git a/tests/components/ble_rssi/validate.bk72xx-ard.yaml b/tests/components/ble_rssi/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..8fb6bdd201 --- /dev/null +++ b/tests/components/ble_rssi/validate.bk72xx-ard.yaml @@ -0,0 +1,14 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_hub + +sensor: + - platform: ble_rssi + ble_hub_id: ble_hub + mac_address: AC:37:43:77:5F:4C + name: BK BLE RSSI + - platform: ble_rssi + irk: 1234567890abcdef1234567890abcdef + name: BK BLE RSSI IRK diff --git a/tests/components/ble_scanner/common-ln.yaml b/tests/components/ble_scanner/common-ln.yaml new file mode 100644 index 0000000000..6c732031d8 --- /dev/null +++ b/tests/components/ble_scanner/common-ln.yaml @@ -0,0 +1,3 @@ +text_sensor: + - platform: ble_scanner + name: BLE Test Scanner diff --git a/tests/components/ble_scanner/common.yaml b/tests/components/ble_scanner/common.yaml index 935a5a5a19..5c8d09892f 100644 --- a/tests/components/ble_scanner/common.yaml +++ b/tests/components/ble_scanner/common.yaml @@ -1,5 +1,8 @@ esp32_ble_tracker: + id: ble_tracker_hub text_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: ble_scanner + ble_hub_id: ble_tracker_hub name: Scanner diff --git a/tests/components/ble_scanner/test.ln882x-ard.yaml b/tests/components/ble_scanner/test.ln882x-ard.yaml new file mode 100644 index 0000000000..26dbe4476d --- /dev/null +++ b/tests/components/ble_scanner/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + ble_scanner: !include common-ln.yaml diff --git a/tests/components/ble_scanner/validate.bk72xx-ard.yaml b/tests/components/ble_scanner/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..cb025d74b7 --- /dev/null +++ b/tests/components/ble_scanner/validate.bk72xx-ard.yaml @@ -0,0 +1,13 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_hub + +text_sensor: + - platform: ble_scanner + ble_hub_id: ble_hub + name: BK Scanner + # No ble_hub_id: exercises the generated binding _require_hub guards. + - platform: ble_scanner + name: BK Scanner Implicit From c5e165d0620d8fc21f7abf99a2318ae7a08148d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 7 Aug 2026 11:29:19 -0500 Subject: [PATCH 1304/1815] [bluetooth_proxy] Make the GATT dispatch platform neutral (#18130) --- .../ble_device_base/ble_gatt_client.h | 3 +- .../bluetooth_connection/__init__.py | 11 + .../bluetooth_connection.cpp | 42 ++ .../bluetooth_connection.h | 111 ++++- .../bluetooth_connection_esp32.cpp | 169 ++----- .../bluetooth_connection_esp32.h | 11 +- .../bluetooth_connection_hub.cpp | 415 ++++++++++++++++++ .../bluetooth_connection_hub.h | 129 ++++++ .../components/bluetooth_proxy/__init__.py | 16 +- .../bluetooth_proxy/bluetooth_proxy.cpp | 176 ++++++-- .../bluetooth_proxy/bluetooth_proxy.h | 116 +++-- .../bluetooth_proxy/test_platform_gates.py | 20 +- .../bluetooth_connection/test_gatt_uuid.cpp | 49 +++ 13 files changed, 1031 insertions(+), 237 deletions(-) create mode 100644 esphome/components/bluetooth_connection/bluetooth_connection.cpp create mode 100644 esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp create mode 100644 esphome/components/bluetooth_connection/bluetooth_connection_hub.h create mode 100644 tests/components/bluetooth_connection/test_gatt_uuid.cpp diff --git a/esphome/components/ble_device_base/ble_gatt_client.h b/esphome/components/ble_device_base/ble_gatt_client.h index ef28f7672a..1bcfcf99dc 100644 --- a/esphome/components/ble_device_base/ble_gatt_client.h +++ b/esphome/components/ble_device_base/ble_gatt_client.h @@ -128,7 +128,8 @@ class BLEGattConnection { /// Backend-owned service table (see GattServiceTable lifetime). virtual GattServiceTable get_service_table() = 0; - /// Free the transient service table storage. Call after streaming. + /// Free the transient service table storage. Call after streaming; + /// idempotent (a call with no table held is a no-op). virtual void release_services() = 0; protected: diff --git a/esphome/components/bluetooth_connection/__init__.py b/esphome/components/bluetooth_connection/__init__.py index 1e85c4b8f9..d4d0a3c3af 100644 --- a/esphome/components/bluetooth_connection/__init__.py +++ b/esphome/components/bluetooth_connection/__init__.py @@ -24,6 +24,10 @@ CODEOWNERS = ["@bdraco", "@jesserockz"] bluetooth_connection_ns = cg.esphome_ns.namespace("bluetooth_connection") +# The hub-platform wrapper codegen class (drives a ble_device_base +# BLEGattConnection backend; see bluetooth_connection_hub.h). +HubBluetoothConnection = bluetooth_connection_ns.class_("BluetoothConnection") + @functools.cache def esp32_connection_class() -> cg.MockObjClass: @@ -42,5 +46,12 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP32_IDF, }, + # Every hub platform the proxy admits (the file compiles empty where + # USE_BLE_GATT_CLIENT is not defined), so a platform gaining a backend + # cannot hit a missing-symbol trap here. + "bluetooth_connection_hub.cpp": { + PlatformFramework.RP2_ARDUINO, + PlatformFramework.LN882X_ARDUINO, + }, } ) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.cpp b/esphome/components/bluetooth_connection/bluetooth_connection.cpp new file mode 100644 index 0000000000..57833edbd2 --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection.cpp @@ -0,0 +1,42 @@ +#include "bluetooth_connection.h" + +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + +#include "esphome/components/api/api_pb2.h" +#include "esphome/core/log.h" + +namespace esphome::bluetooth_connection { + +static const char *const TAG = "bluetooth_connection"; + +BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size_t ¤t_size, int16_t &send_service, + uint8_t connection_index, const char *address_str) { + // Calculate the actual size of just this service (+1 for the field tag) + size_t service_size = resp.services.back().calculate_size() + 1; + + if (current_size + service_size > MAX_PACKET_SIZE) { + if (resp.services.size() > 1) { + // We would go over -- pop the last service and retry it in the next batch + resp.services.pop_back(); + ESP_LOGD(TAG, "[%d] [%s] Service %d would exceed limit (current: %u + service: %u > %u), sending current batch", + connection_index, address_str, send_service, (unsigned) current_size, (unsigned) service_size, + (unsigned) MAX_PACKET_SIZE); + // Don't advance send_service -- the popped service goes into the next batch + } else { + // This single service is too large, but we have to send it anyway; + // advance so we don't get stuck + ESP_LOGW(TAG, "[%d] [%s] Service %d is too large (%u bytes) but sending anyway", connection_index, address_str, + send_service, (unsigned) service_size); + send_service++; + } + return BatchClose::SEND; + } + + current_size += service_size; + send_service++; + return BatchClose::CONTINUE; +} + +} // namespace esphome::bluetooth_connection + +#endif // BLUETOOTH_CONNECTION_HAS_GATT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h index f63fb93492..712251b157 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -1,16 +1,32 @@ -// Shared types for the per-platform GATT connection backends and the -// Bluetooth proxy that drives them. +// Shared types and helpers for the per-platform GATT connection backends and +// the Bluetooth proxy that drives them. #pragma once #include "esphome/core/defines.h" #include "esphome/components/ble_device_base/ble_client_state.h" +#include "esphome/components/ble_device_base/ble_device.h" + +#include +#include +#include #ifdef USE_ESP32 #include #endif +// A GATT connection backend exists in this build: esp32 (Bluedroid) or a hub +// platform with the neutral GATT client compiled in. Single-sourced here so +// the proxy and this component cannot drift. +#if defined(USE_ESP32) || defined(USE_BLE_GATT_CLIENT) +#define BLUETOOTH_CONNECTION_HAS_GATT +#endif + +namespace esphome::api { +class BluetoothGATTGetServicesResponse; +} // namespace esphome::api + namespace esphome::bluetooth_connection { // Connection-owned error type for the API error fields, which are plain @@ -30,8 +46,99 @@ static constexpr conn_err_t CONN_OK = 0; // GATT contract so backend and wrapper cannot drift. static constexpr conn_err_t GATT_NOT_CONNECTED = ble_device_base::GATT_ERR_NOT_CONNECTED; +// What the platform's connection backend supports beyond GATT operations; +// the proxy derives its feature flags and legacy version from these. +#ifdef USE_ESP32 +static constexpr bool SUPPORTS_PAIRING = true; +static constexpr bool SUPPORTS_CACHE_CLEARING = true; +#else +static constexpr bool SUPPORTS_PAIRING = false; +static constexpr bool SUPPORTS_CACHE_CLEARING = false; +#endif + +// Address-scoped (not connection-scoped) maintenance requests. +#ifdef USE_ESP32 +conn_err_t unpair_device(uint64_t address); +conn_err_t clear_gatt_cache(uint64_t address); +#else +inline conn_err_t unpair_device(uint64_t) { return GATT_NOT_CONNECTED; } +inline conn_err_t clear_gatt_cache(uint64_t) { return GATT_NOT_CONNECTED; } +#endif + // send_service_ cursor states; >= 0 is the next service index to stream. static constexpr int DONE_SENDING_SERVICES = -2; static constexpr int INIT_SENDING_SERVICES = -3; +// ---- Service-streaming size budget, shared by every platform's streamer ---- + +// Conservative MTU limit for API messages (accounts for WPA3 overhead) +static constexpr size_t MAX_PACKET_SIZE = 1360; + +// Constants for size estimation +static constexpr uint8_t SERVICE_OVERHEAD_LEGACY = 25; // UUID(20) + handle(4) + overhead(1) +static constexpr uint8_t SERVICE_OVERHEAD_EFFICIENT = 10; // UUID(6) + handle(4) +static constexpr uint8_t CHAR_SIZE_128BIT = 35; // UUID(20) + handle(4) + props(4) + overhead(7) +static constexpr uint8_t DESC_SIZE_128BIT = 25; // UUID(20) + handle(4) + overhead(1) +static constexpr uint8_t DESC_PER_CHAR = 1; // Assume 1 descriptor per characteristic + +/// Estimate the wire size of a service (service overhead + its characteristics, +/// assuming 128-bit UUIDs and one 128-bit descriptor per characteristic to be +/// safe) before fetching/packing the full data. +inline size_t estimate_service_size(uint16_t char_count, bool use_efficient_uuids) { + size_t service_overhead = use_efficient_uuids ? SERVICE_OVERHEAD_EFFICIENT : SERVICE_OVERHEAD_LEGACY; + return service_overhead + (CHAR_SIZE_128BIT + DESC_SIZE_128BIT * DESC_PER_CHAR) * char_count; +} + +// ---- UUID wire packing, shared by every platform's streamer ---- + +// This function is allocation-free and directly packs UUIDs into the output +// array using precalculated constants for the Bluetooth base UUID. ESPBTUUID +// stores its 128-bit form little-endian (same as Bluedroid). +inline void fill_128bit_uuid_array(std::array &out, const ble_device_base::ESPBTUUID &uuid) { + using ble_device_base::ESPBTUUID; + if (uuid.type() == ESPBTUUID::Type::UUID128) { + const uint8_t *u = uuid.uuid128(); + // out[0] = bytes 8-15 (big-endian), out[1] = bytes 0-7 (big-endian) + out[0] = ((uint64_t) u[15] << 56) | ((uint64_t) u[14] << 48) | ((uint64_t) u[13] << 40) | ((uint64_t) u[12] << 32) | + ((uint64_t) u[11] << 24) | ((uint64_t) u[10] << 16) | ((uint64_t) u[9] << 8) | ((uint64_t) u[8]); + out[1] = ((uint64_t) u[7] << 56) | ((uint64_t) u[6] << 48) | ((uint64_t) u[5] << 40) | ((uint64_t) u[4] << 32) | + ((uint64_t) u[3] << 24) | ((uint64_t) u[2] << 16) | ((uint64_t) u[1] << 8) | ((uint64_t) u[0]); + return; + } + // 16/32-bit UUID inserted into the Bluetooth base UUID: + // 00000000-0000-1000-8000-00805F9B34FB + uint32_t value = uuid.type() == ESPBTUUID::Type::UUID16 ? uuid.uuid16() : uuid.uuid32(); + out[0] = ((uint64_t) value << 32) | 0x00001000ULL; // Base UUID bytes 8-11 + out[1] = 0x800000805F9B34FBULL; // Base UUID bytes 0-7 +} + +/// Fill the UUID in the appropriate wire format based on client support and +/// UUID type (128-bit array for old clients or 128-bit UUIDs, short form +/// otherwise). +inline void fill_gatt_uuid(std::array &uuid_128, uint32_t &short_uuid, + const ble_device_base::ESPBTUUID &uuid, bool use_efficient_uuids) { + using ble_device_base::ESPBTUUID; + if (!use_efficient_uuids || uuid.type() == ESPBTUUID::Type::UUID128) { + fill_128bit_uuid_array(uuid_128, uuid); + } else if (uuid.type() == ESPBTUUID::Type::UUID16) { + short_uuid = uuid.uuid16(); + } else { + short_uuid = uuid.uuid32(); + } +} + +#ifdef BLUETOOTH_CONNECTION_HAS_GATT +/// Result of close_service_batch: keep filling the batch or send it now. +/// An oversized service is packed alone; a failed (backpressured) send is +/// retried from the batch start, so no service is silently skipped. +enum class BatchClose : uint8_t { CONTINUE, SEND }; + +/// Close out the service just packed into resp (account its actual wire size, +/// advance the cursor) and decide whether the batch must be sent now. Shared +/// tail of both platform streamers so the budget logic and its log lines +/// cannot drift. +BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size_t ¤t_size, int16_t &send_service, + uint8_t connection_index, const char *address_str); +#endif // BLUETOOTH_CONNECTION_HAS_GATT + } // namespace esphome::bluetooth_connection diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp index 5274637b66..7c62d3766c 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp @@ -12,81 +12,20 @@ namespace esphome::bluetooth_connection { namespace espbt = esphome::esp32_ble_tracker; +using ble_device_base::ESPBTUUID; + static const char *const TAG = "bluetooth_connection"; -// This function is allocation-free and directly packs UUIDs into the output array -// using precalculated constants for the Bluetooth base UUID -static void fill_128bit_uuid_array(std::array &out, esp_bt_uuid_t uuid_source) { - // Bluetooth base UUID: 00000000-0000-1000-8000-00805F9B34FB - // out[0] = bytes 8-15 (big-endian) - // - For 128-bit UUIDs: use bytes 8-15 as-is - // - For 16/32-bit UUIDs: insert into bytes 12-15, use 0x00001000 for bytes 8-11 - out[0] = uuid_source.len == ESP_UUID_LEN_128 - ? (((uint64_t) uuid_source.uuid.uuid128[15] << 56) | ((uint64_t) uuid_source.uuid.uuid128[14] << 48) | - ((uint64_t) uuid_source.uuid.uuid128[13] << 40) | ((uint64_t) uuid_source.uuid.uuid128[12] << 32) | - ((uint64_t) uuid_source.uuid.uuid128[11] << 24) | ((uint64_t) uuid_source.uuid.uuid128[10] << 16) | - ((uint64_t) uuid_source.uuid.uuid128[9] << 8) | ((uint64_t) uuid_source.uuid.uuid128[8])) - : (((uint64_t) (uuid_source.len == ESP_UUID_LEN_16 ? uuid_source.uuid.uuid16 : uuid_source.uuid.uuid32) - << 32) | - 0x00001000ULL); // Base UUID bytes 8-11 - // out[1] = bytes 0-7 (big-endian) - // - For 128-bit UUIDs: use bytes 0-7 as-is - // - For 16/32-bit UUIDs: use precalculated base UUID constant - out[1] = uuid_source.len == ESP_UUID_LEN_128 - ? ((uint64_t) uuid_source.uuid.uuid128[7] << 56) | ((uint64_t) uuid_source.uuid.uuid128[6] << 48) | - ((uint64_t) uuid_source.uuid.uuid128[5] << 40) | ((uint64_t) uuid_source.uuid.uuid128[4] << 32) | - ((uint64_t) uuid_source.uuid.uuid128[3] << 24) | ((uint64_t) uuid_source.uuid.uuid128[2] << 16) | - ((uint64_t) uuid_source.uuid.uuid128[1] << 8) | ((uint64_t) uuid_source.uuid.uuid128[0]) - : 0x800000805F9B34FBULL; // Base UUID bytes 0-7: 80-00-00-80-5F-9B-34-FB +conn_err_t unpair_device(uint64_t address) { + esp_bd_addr_t bd_addr; + ble_device_base::uint64_to_mac_msb_first(address, bd_addr); + return esp_ble_remove_bond_device(bd_addr); } -// Helper to fill UUID in the appropriate format based on client support and UUID type -static void fill_gatt_uuid(std::array &uuid_128, uint32_t &short_uuid, const esp_bt_uuid_t &uuid, - bool use_efficient_uuids) { - if (!use_efficient_uuids || uuid.len == ESP_UUID_LEN_128) { - // Use 128-bit format for old clients or when UUID is already 128-bit - fill_128bit_uuid_array(uuid_128, uuid); - } else if (uuid.len == ESP_UUID_LEN_16) { - short_uuid = uuid.uuid.uuid16; - } else if (uuid.len == ESP_UUID_LEN_32) { - short_uuid = uuid.uuid.uuid32; - } -} - -// Constants for size estimation -static constexpr uint8_t SERVICE_OVERHEAD_LEGACY = 25; // UUID(20) + handle(4) + overhead(1) -static constexpr uint8_t SERVICE_OVERHEAD_EFFICIENT = 10; // UUID(6) + handle(4) -static constexpr uint8_t CHAR_SIZE_128BIT = 35; // UUID(20) + handle(4) + props(4) + overhead(7) -static constexpr uint8_t DESC_SIZE_128BIT = 25; // UUID(20) + handle(4) + overhead(1) -static constexpr uint8_t DESC_SIZE_16BIT = 10; // UUID(6) + handle(4) -static constexpr uint8_t DESC_PER_CHAR = 1; // Assume 1 descriptor per characteristic - -// Helper to estimate service size before fetching all data -/** - * Estimate the size of a Bluetooth service based on the number of characteristics and UUID format. - * - * @param char_count The number of characteristics in the service. - * @param use_efficient_uuids Whether to use efficient UUIDs (16-bit or 32-bit) for newer APIVersions. - * @return The estimated size of the service in bytes. - * - * This function calculates the size of a Bluetooth service by considering: - * - A service overhead, which depends on whether efficient UUIDs are used. - * - The size of each characteristic, assuming 128-bit UUIDs for safety. - * - The size of descriptors, assuming one 128-bit descriptor per characteristic. - */ -static size_t estimate_service_size(uint16_t char_count, bool use_efficient_uuids) { - size_t service_overhead = use_efficient_uuids ? SERVICE_OVERHEAD_EFFICIENT : SERVICE_OVERHEAD_LEGACY; - // Always assume 128-bit UUIDs for characteristics to be safe - size_t char_size = CHAR_SIZE_128BIT; - // Assume one 128-bit descriptor per characteristic - size_t desc_size = DESC_SIZE_128BIT * DESC_PER_CHAR; - - return service_overhead + (char_size + desc_size) * char_count; -} - -bool BluetoothConnection::supports_efficient_uuids_() const { - auto *api_conn = this->proxy_->get_api_connection(); - return api_conn && api_conn->client_supports_api_version(1, 12); +conn_err_t clear_gatt_cache(uint64_t address) { + esp_bd_addr_t bd_addr; + ble_device_base::uint64_to_mac_msb_first(address, bd_addr); + return esp_ble_gattc_cache_clean(bd_addr); } void BluetoothConnection::dump_config() { @@ -94,28 +33,9 @@ void BluetoothConnection::dump_config() { BLEClientBase::dump_config(); } -void BluetoothConnection::update_allocated_slot_(uint64_t find_value, uint64_t set_value) { - auto &allocated = this->proxy_->connections_free_response_.allocated; - for (auto &slot : allocated) { - if (slot == find_value) { - slot = set_value; - return; - } - } -} - void BluetoothConnection::set_address(uint64_t address) { - // If we're clearing an address (disconnecting), update the pre-allocated message - if (address == 0 && this->address_ != 0) { - this->proxy_->connections_free_response_.free++; - this->update_allocated_slot_(this->address_, 0); - } - // If we're setting a new address (connecting), update the pre-allocated message - else if (address != 0 && this->address_ == 0) { - this->proxy_->connections_free_response_.free--; - this->update_allocated_slot_(0, address); - } - + // Keep the proxy's pre-allocated connections-free message in step + this->proxy_->update_address_slot_(this->address_, address); // Call parent implementation to actually set the address BLEClientBase::set_address(address); } @@ -157,20 +77,7 @@ void BluetoothConnection::on_disconnect_complete(esp_err_t reason) { this->reset_connection_(reason); } -void BluetoothConnection::reset_connection_(esp_err_t reason) { - // Send disconnection notification - this->proxy_->send_device_connection(this->address_, false, 0, reason); - - // Important: If we were in the middle of sending services, we do NOT send - // send_gatt_services_done() here. This ensures the client knows that - // the service discovery was interrupted and can retry. The client - // (aioesphomeapi) implements a 30-second timeout (DEFAULT_BLE_TIMEOUT) - // to detect incomplete service discovery rather than relying on us to - // tell them about a partial list. - this->set_address(0); - this->send_service_ = INIT_SENDING_SERVICES; - this->proxy_->send_connections_free(); -} +void BluetoothConnection::reset_connection_(esp_err_t reason) { this->proxy_->reset_connection_slot_(this, reason); } void BluetoothConnection::send_service_for_discovery_() { if (this->send_service_ >= this->service_count_) { @@ -188,18 +95,16 @@ void BluetoothConnection::send_service_for_discovery_() { } // Check if client supports efficient UUIDs - bool use_efficient_uuids = this->supports_efficient_uuids_(); + bool use_efficient_uuids = this->proxy_->client_supports_efficient_uuids(); // Prepare response api::BluetoothGATTGetServicesResponse resp; resp.address = this->address_; // Dynamic batching based on actual size - // Conservative MTU limit for API messages (accounts for WPA3 overhead) - static constexpr size_t MAX_PACKET_SIZE = 1360; - // Keep running total of actual message size size_t current_size = resp.calculate_size(); + int16_t batch_start = this->send_service_; while (this->send_service_ < this->service_count_) { esp_gattc_service_elem_t service_result; @@ -238,7 +143,8 @@ void BluetoothConnection::send_service_for_discovery_() { resp.services.emplace_back(); auto &service_resp = resp.services.back(); - fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, service_result.uuid, use_efficient_uuids); + fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, ESPBTUUID::from_uuid(service_result.uuid), + use_efficient_uuids); service_resp.handle = service_result.start_handle; @@ -268,7 +174,8 @@ void BluetoothConnection::send_service_for_discovery_() { service_resp.characteristics.emplace_back(); auto &characteristic_resp = service_resp.characteristics.back(); - fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, char_result.uuid, use_efficient_uuids); + fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, ESPBTUUID::from_uuid(char_result.uuid), + use_efficient_uuids); characteristic_resp.handle = char_result.char_handle; characteristic_resp.properties = char_result.properties; char_offset++; @@ -309,44 +216,26 @@ void BluetoothConnection::send_service_for_discovery_() { characteristic_resp.descriptors.emplace_back(); auto &descriptor_resp = characteristic_resp.descriptors.back(); - fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, desc_result.uuid, use_efficient_uuids); + fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, ESPBTUUID::from_uuid(desc_result.uuid), + use_efficient_uuids); descriptor_resp.handle = desc_result.handle; desc_offset++; } } } // end if (total_char_count > 0) - // Calculate the actual size of just this service - size_t service_size = service_resp.calculate_size() + 1; // +1 for field tag - - // Check if adding this service would exceed the limit - if (current_size + service_size > MAX_PACKET_SIZE) { - // We would go over - pop the last service if we have more than one - if (resp.services.size() > 1) { - resp.services.pop_back(); - ESP_LOGD(TAG, "[%d] [%s] Service %d would exceed limit (current: %d + service: %d > %d), sending current batch", - this->connection_index_, this->address_str(), this->send_service_, current_size, service_size, - MAX_PACKET_SIZE); - // Don't increment send_service_ - we'll retry this service in next batch - } else { - // This single service is too large, but we have to send it anyway - ESP_LOGV(TAG, "[%d] [%s] Service %d is too large (%d bytes) but sending anyway", this->connection_index_, - this->address_str(), this->send_service_, service_size); - // Increment so we don't get stuck - this->send_service_++; - } - // Send what we have + if (close_service_batch(resp, current_size, this->send_service_, this->connection_index_, this->address_str()) != + BatchClose::CONTINUE) { break; } - - // Now we know we're keeping this service, add its size - current_size += service_size; - // Successfully added this service, increment counter - this->send_service_++; } - // Send the message with dynamically batched services - api_conn->send_message(resp); + // Send the message with dynamically batched services; on a failed send, + // rewind the cursor so the batch is retried instead of silently skipped. + if (!api_conn->send_message(resp)) { + ESP_LOGW(TAG, "[%d] [%s] Failed to send service batch, retrying", this->connection_index_, this->address_str_); + this->send_service_ = batch_start; + } } void BluetoothConnection::log_connection_error_(const char *operation, esp_gatt_status_t status) { diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h index 65e2d0777e..531ff311a7 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h @@ -34,6 +34,15 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase { return this->update_conn_params_(min_interval, max_interval, latency, timeout, "custom"); } + bool has_gatt_services() const { return this->service_count_ != 0; } + + /// Start connecting: record the API address type and hand the client to the + /// tracker's promote loop (it pauses the scan and opens the connection). + void initiate_connection(uint8_t address_type) { + this->set_remote_addr_type(static_cast(address_type)); + this->set_state(esp32_ble_tracker::ClientState::DISCOVERED); + } + void set_address(uint64_t address) override; protected: @@ -41,10 +50,8 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase { void on_disconnect_complete(esp_err_t reason) override; - bool supports_efficient_uuids_() const; void send_service_for_discovery_(); void reset_connection_(esp_err_t reason); - void update_allocated_slot_(uint64_t find_value, uint64_t set_value); void log_connection_error_(const char *operation, esp_gatt_status_t status); void log_connection_warning_(const char *operation, esp_err_t err); void log_gatt_not_connected_(const char *action, const char *type); diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp new file mode 100644 index 0000000000..338bf671a8 --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -0,0 +1,415 @@ +// Hub-platform connection wrapper (USE_RP2 hub builds today). +#include "bluetooth_connection_hub.h" + +#if !defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) + +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::bluetooth_connection { + +static const char *const TAG = "bluetooth_connection"; + +void BluetoothConnection::set_address(uint64_t address) { + // Keep the proxy's pre-allocated connections-free message in step + this->proxy_->update_address_slot_(this->address_, address); + this->address_ = address; + if (address == 0) { + this->address_str_[0] = '\0'; + return; + } + uint8_t mac[6]; + ble_device_base::uint64_to_mac_msb_first(address, mac); + format_mac_addr_upper(mac, this->address_str_); +} + +void BluetoothConnection::start_connect_() { + // No connect timeout here (esp32 parity): the client's own timeout or + // the api-gone sweep drives disconnect(). + this->state_ = ClientState::CONNECTING; + int err = this->backend_->connect(this->address_, this->remote_addr_type_); + if (err != 0) { + ESP_LOGW(TAG, "[%d] [%s] connect failed, err=%d", this->connection_index_, this->address_str_, err); + this->reset_connection_(err); + } +} + +void BluetoothConnection::disconnect() { + // Idempotent like the esp32 class: the proxy's teardown loop calls this + // every 100 ms while the API subscriber is gone, and a repeat call must not + // reach the backend (whose busy error would free the slot mid-teardown). + if (this->state_ == ClientState::IDLE || this->state_ == ClientState::DISCONNECTING) { + return; + } + int err = this->backend_->disconnect(); + if (err == GATT_NOT_CONNECTED) { + // Backend already idle: free the slot so the client is not stuck. + ESP_LOGW(TAG, "[%d] [%s] disconnect while backend idle", this->connection_index_, this->address_str_); + this->reset_connection_(err); + return; + } + if (err != 0) { + // Transient refusal: stay DISCONNECTING and let the safety timeout + // arbitrate rather than freeing a slot whose teardown is unresolved. + // Latch the refusal unless a GATT cause is already recorded (first wins). + ESP_LOGW(TAG, "[%d] [%s] disconnect failed, err=%d", this->connection_index_, this->address_str_, err); + if (this->pending_error_ == 0) { + this->pending_error_ = err; + } + } + this->state_ = ClientState::DISCONNECTING; + this->disconnecting_started_ = millis(); +} + +void BluetoothConnection::check_disconnect_timeout_() { + // Safety net mirroring the esp32 base class: if the backend's disconnect + // completion is lost, force the slot free instead of leaking it. + static constexpr uint32_t DISCONNECT_TIMEOUT_MS = 10000; + if (this->state_ == ClientState::DISCONNECTING && millis() - this->disconnecting_started_ > DISCONNECT_TIMEOUT_MS) { + ESP_LOGW(TAG, "[%d] [%s] Disconnect timeout, freeing slot", this->connection_index_, this->address_str_); + this->reset_connection_(GATT_NOT_CONNECTED); + } +} + +void BluetoothConnection::reset_connection_(conn_err_t reason) { + if (this->pending_error_ != 0) { + reason = this->pending_error_; + this->pending_error_ = 0; + } + this->state_ = ClientState::IDLE; + this->services_discovered_ = false; + this->backend_->release_services(); + this->proxy_->reset_connection_slot_(this, reason); +} + +// ---- GattClientEventListener ---- + +void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int error) { + if (connected && this->address_ == 0) { + // Late completion for a slot that was already freed: nothing to report, + // and the api-gone sweep or a new reservation owns the slot now. + int err = this->backend_->disconnect(); + if (err != 0 && err != GATT_NOT_CONNECTED) { + // Log only: re-arming a freed slot could clobber a new reservation. + ESP_LOGW(TAG, "[%d] freed-slot disconnect refused, err=%d", this->connection_index_, err); + } + return; + } + if (connected && this->state_ == ClientState::DISCONNECTING) { + // The link came up after a disconnect request won the race; finish the + // teardown instead of reporting a connection the client no longer wants. + int err = this->backend_->disconnect(); + // Fresh teardown attempt: give it the full safety window. + this->disconnecting_started_ = millis(); + if (err == GATT_NOT_CONNECTED) { + // Nothing left to tear down after all. + this->reset_connection_(err); + } else if (err != 0) { + // Transient refusal while the link is up: keep DISCONNECTING and let + // the safety timeout arbitrate (same policy as disconnect()). + ESP_LOGW(TAG, "[%d] [%s] teardown disconnect failed, err=%d", this->connection_index_, this->address_str_, err); + } + return; + } + if (connected) { + this->mtu_ = mtu; + if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) { + // The API client has the services cached; never discover them. + this->state_ = ClientState::ESTABLISHED; + this->proxy_->send_device_connection(this->address_, true, mtu); + this->proxy_->send_connections_free(); + return; + } + // V3_WITHOUT_CACHE: discover services first — the connected response is + // sent when discovery completes, mirroring the esp32 flow (MTU + services + // before the response). + this->state_ = ClientState::CONNECTED; + int err = this->backend_->discover_services(); + if (err != 0) { + ESP_LOGW(TAG, "[%d] [%s] discover_services failed, err=%d", this->connection_index_, this->address_str_, err); + // Latch the real cause for the disconnect report. + this->pending_error_ = err; + this->disconnect(); + } + return; + } + // Disconnected, connect failed, or teardown complete + if (this->address_ == 0) { + return; // Slot already freed + } + ESP_LOGD(TAG, "[%d] [%s] Disconnected, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, + error); + this->reset_connection_(error); +} + +void BluetoothConnection::on_service_discovery_done(int error) { + if (error != 0) { + ESP_LOGW(TAG, "[%d] [%s] Service discovery failed, err=%d", this->connection_index_, this->address_str_, error); + // Carry the GATT error into the disconnection report so the client sees + // the real cause instead of a generic HCI reason. + this->pending_error_ = error; + this->disconnect(); + return; + } + ESP_LOGD(TAG, "[%d] [%s] Discovery finished, sending connected (mtu=%u)", this->connection_index_, this->address_str_, + this->mtu_); + this->state_ = ClientState::ESTABLISHED; + this->services_discovered_ = true; + this->proxy_->send_device_connection(this->address_, true, this->mtu_); + this->proxy_->send_connections_free(); +} + +void BluetoothConnection::log_gatt_operation_error_(const char *operation, uint16_t handle, int status) { + ESP_LOGW(TAG, "[%d] [%s] Error %s for handle 0x%2X, status=%d", this->connection_index_, this->address_str_, + operation, handle, status); +} + +void BluetoothConnection::on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) { + // Late completion for a freed slot; nothing to report. + if (this->address_ == 0) + return; + if (error != 0) { + this->log_gatt_operation_error_("reading char/descriptor", handle, error); + this->proxy_->send_gatt_error(this->address_, handle, error); + return; + } + auto *api_connection = this->proxy_->get_api_connection(); + if (api_connection == nullptr) + return; + api::BluetoothGATTReadResponse resp; + resp.address = this->address_; + resp.handle = handle; + resp.set_data(data, len); + if (!api_connection->send_message(resp)) { + ESP_LOGW(TAG, "[%d] [%s] Failed to send read response", this->connection_index_, this->address_str_); + } +} + +void BluetoothConnection::on_write_result(uint16_t handle, int error) { + if (this->address_ == 0) + return; + if (error != 0) { + this->log_gatt_operation_error_("writing char/descriptor", handle, error); + this->proxy_->send_gatt_error(this->address_, handle, error); + return; + } + auto *api_connection = this->proxy_->get_api_connection(); + if (api_connection == nullptr) + return; + api::BluetoothGATTWriteResponse resp; + resp.address = this->address_; + resp.handle = handle; + if (!api_connection->send_message(resp)) { + ESP_LOGW(TAG, "[%d] [%s] Failed to send write response", this->connection_index_, this->address_str_); + } +} + +void BluetoothConnection::on_notify_state(uint16_t handle, bool enabled, int error) { + if (this->address_ == 0) + return; + if (error != 0) { + this->log_gatt_operation_error_(enabled ? "registering notifications" : "unregistering notifications", handle, + error); + this->proxy_->send_gatt_error(this->address_, handle, error); + return; + } + auto *api_connection = this->proxy_->get_api_connection(); + if (api_connection == nullptr) + return; + api::BluetoothGATTNotifyResponse resp; + resp.address = this->address_; + resp.handle = handle; + if (!api_connection->send_message(resp)) { + ESP_LOGW(TAG, "[%d] [%s] Failed to send notify state response", this->connection_index_, this->address_str_); + } +} + +void BluetoothConnection::on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) { + if (this->address_ == 0) + return; + ESP_LOGV(TAG, "[%d] [%s] Notify: handle=0x%2X", this->connection_index_, this->address_str_, handle); + auto *api_connection = this->proxy_->get_api_connection(); + if (api_connection == nullptr) + return; + api::BluetoothGATTNotifyDataResponse resp; + resp.address = this->address_; + resp.handle = handle; + resp.set_data(data, len); + if (!api_connection->send_message(resp)) { + ESP_LOGW(TAG, "[%d] [%s] Failed to send notify data response", this->connection_index_, this->address_str_); + } +} + +// ---- GATT operations ---- + +conn_err_t BluetoothConnection::check_connected_op_(const char *action, const char *type) const { + if (this->connected()) { + return CONN_OK; + } + ESP_LOGW(TAG, "[%d] [%s] Cannot %s GATT %s, not connected.", this->connection_index_, this->address_str_, action, + type); + return GATT_NOT_CONNECTED; +} + +conn_err_t BluetoothConnection::read_characteristic(uint16_t handle) { + if (conn_err_t err = this->check_connected_op_("read", "characteristic"); err != CONN_OK) + return err; + ESP_LOGV(TAG, "[%d] [%s] Reading GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); + return this->backend_->read_characteristic(handle); +} + +conn_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8_t *data, size_t length, + bool response) { + if (conn_err_t err = this->check_connected_op_("write", "characteristic"); err != CONN_OK) + return err; + ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); + return this->backend_->write_characteristic(handle, data, static_cast(length), response); +} + +conn_err_t BluetoothConnection::read_descriptor(uint16_t handle) { + if (conn_err_t err = this->check_connected_op_("read", "descriptor"); err != CONN_OK) + return err; + ESP_LOGV(TAG, "[%d] [%s] Reading GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); + return this->backend_->read_descriptor(handle); +} + +// The neutral backend contract performs descriptor writes acknowledged, so +// the response flag is intentionally ignored (esp32 maps it to RSP/NO_RSP). +conn_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t *data, size_t length, + bool /*response*/) { + if (conn_err_t err = this->check_connected_op_("write", "descriptor"); err != CONN_OK) + return err; + ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); + return this->backend_->write_descriptor(handle, data, static_cast(length)); +} + +conn_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enable) { + if (conn_err_t err = this->check_connected_op_("notify", "characteristic"); err != CONN_OK) + return err; + ESP_LOGV(TAG, "[%d] [%s] %s GATT characteristic notifications handle %d", this->connection_index_, this->address_str_, + enable ? "Registering for" : "Unregistering for", handle); + return this->backend_->notify_characteristic(handle, enable); +} + +conn_err_t BluetoothConnection::update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout) { + if (conn_err_t err = this->check_connected_op_("update params of", "connection"); err != CONN_OK) + return err; + return this->backend_->update_connection_params(min_interval, max_interval, latency, timeout); +} + +// ---- Service streaming ---- + +void BluetoothConnection::send_service_for_discovery_() { + auto table = this->backend_->get_service_table(); + if (this->send_service_ >= table.service_count) { + this->send_service_ = DONE_SENDING_SERVICES; + this->proxy_->send_gatt_services_done(this->address_); + this->backend_->release_services(); + return; + } + + // The subscriber vanished mid-stream: park the cursor at done WITHOUT + // sending services-done (esp32 parity — a resubscribing client gets + // silence and its 30 s timeout, never an authoritative partial list) and + // free the table; the api-gone sweep tears the connection down anyway. + auto *api_conn = this->proxy_->get_api_connection(); + if (api_conn == nullptr) { + ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", this->connection_index_, + this->address_str_); + this->send_service_ = DONE_SENDING_SERVICES; + this->backend_->release_services(); + return; + } + + // Check if client supports efficient UUIDs + bool use_efficient_uuids = this->proxy_->client_supports_efficient_uuids(); + + // Prepare response + api::BluetoothGATTGetServicesResponse resp; + resp.address = this->address_; + + // Dynamic batching based on actual size, same contract as the esp32 streamer + size_t current_size = resp.calculate_size(); + int16_t batch_start = this->send_service_; + + while (this->send_service_ < table.service_count) { + const auto &service = table.services[this->send_service_]; + + // If this service likely won't fit, send current batch (unless it's the first) + size_t estimated_size = estimate_service_size(service.characteristic_count, use_efficient_uuids); + if (!resp.services.empty() && (current_size + estimated_size > MAX_PACKET_SIZE)) { + break; + } + + resp.services.emplace_back(); + auto &service_resp = resp.services.back(); + fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, service.uuid, use_efficient_uuids); + service_resp.handle = service.start_handle; + + // Bounds-check the backend's index ranges against the table totals rather + // than trusting its discovery bookkeeping blindly. A miscounted non-empty + // range must not stream a truncated database as authoritative (V3 clients + // cache it permanently): abort and tear the connection down; the client + // times out and retries. Empty ranges are tolerated regardless of index. + uint16_t char_count = service.characteristic_count; + if (char_count != 0 && service.first_characteristic + char_count > table.characteristic_count) { + ESP_LOGE(TAG, "[%d] [%s] Characteristic range out of bounds (service %d), aborting stream", + this->connection_index_, this->address_str_, this->send_service_); + this->send_service_ = DONE_SENDING_SERVICES; + this->disconnect(); + return; + } + if (char_count > 0) { + service_resp.characteristics.init(char_count); + for (uint16_t ci = 0; ci < char_count; ci++) { + const auto &chr = table.characteristics[service.first_characteristic + ci]; + service_resp.characteristics.emplace_back(); + auto &characteristic_resp = service_resp.characteristics.back(); + fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, chr.uuid, use_efficient_uuids); + characteristic_resp.handle = chr.value_handle; + characteristic_resp.properties = chr.properties; + uint16_t desc_count = chr.descriptor_count; + if (desc_count != 0 && chr.first_descriptor + desc_count > table.descriptor_count) { + ESP_LOGE(TAG, "[%d] [%s] Descriptor range out of bounds (service %d), aborting stream", + this->connection_index_, this->address_str_, this->send_service_); + this->send_service_ = DONE_SENDING_SERVICES; + this->disconnect(); + return; + } + if (desc_count == 0) { + continue; + } + characteristic_resp.descriptors.init(desc_count); + for (uint16_t di = 0; di < desc_count; di++) { + const auto &desc = table.descriptors[chr.first_descriptor + di]; + characteristic_resp.descriptors.emplace_back(); + auto &descriptor_resp = characteristic_resp.descriptors.back(); + fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, desc.uuid, use_efficient_uuids); + descriptor_resp.handle = desc.handle; + } + } + } + + if (close_service_batch(resp, current_size, this->send_service_, this->connection_index_, this->address_str_) != + BatchClose::CONTINUE) { + break; + } + } + + // Send the message with dynamically batched services; on a failed send, + // rewind the cursor so the batch is retried instead of silently skipped + // (bounded: a subscriber that stays gone ends streaming via the api-lost + // rewind above). + if (!api_conn->send_message(resp)) { + ESP_LOGW(TAG, "[%d] [%s] Failed to send service batch, retrying", this->connection_index_, this->address_str_); + this->send_service_ = batch_start; + } +} + +} // namespace esphome::bluetooth_connection + +#endif // !USE_ESP32 && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h new file mode 100644 index 0000000000..83fbd24e4c --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -0,0 +1,129 @@ +// Hub-platform BluetoothConnection: drives a platform GATT client backend +// through the neutral ble_device_base::BLEGattConnection interface and +// translates its events into the same API messages the esp32 class emits. +// Presents the identical method surface, so the proxy's GATT dispatch +// compiles against either class unchanged. + +#pragma once + +#include "esphome/core/defines.h" + +#if !defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) + +#include "bluetooth_connection.h" + +#include "esphome/components/ble_device_base/ble_client_state.h" +#include "esphome/components/ble_device_base/ble_gatt_client.h" +#include "esphome/core/helpers.h" + +namespace esphome::bluetooth_proxy { +class BluetoothProxy; +} // namespace esphome::bluetooth_proxy + +namespace esphome::bluetooth_connection { + +using ClientState = ble_device_base::ClientState; +using ConnectionType = ble_device_base::ConnectionType; + +class BluetoothConnection final : public ble_device_base::GattClientEventListener { + public: + /// Wire the platform backend. Called from codegen before setup. + void set_backend(ble_device_base::BLEGattConnection *backend) { + this->backend_ = backend; + backend->set_listener(this); + } + + // ---- proxy dispatch surface (mirrors the esp32 class) ---- + conn_err_t read_characteristic(uint16_t handle); + conn_err_t write_characteristic(uint16_t handle, const uint8_t *data, size_t length, bool response); + conn_err_t read_descriptor(uint16_t handle); + conn_err_t write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response); + conn_err_t notify_characteristic(uint16_t handle, bool enable); + conn_err_t update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout); + + /// Start connecting: record the API address type (BLE_ADDR_TYPE_* code + /// space) and open the connection through the backend. Failures report + /// through the same reset path a failed open takes on esp32. + void initiate_connection(uint8_t address_type) { + this->remote_addr_type_ = address_type; + this->start_connect_(); + } + void disconnect(); + // A backend disconnect() is a single call that also cancels an in-progress + // connect; there is no deferred-disconnect state to track. + bool disconnect_pending() const { return false; } + void cancel_pending_disconnect() {} + + void set_address(uint64_t address); + uint64_t get_address() const { return this->address_; } + const char *address_str() const { return this->address_str_; } + uint8_t get_connection_index() const { return this->connection_index_; } + + ClientState state() const { return this->state_; } + void set_state(ClientState st) { this->state_ = st; } + bool connected() const { return this->state_ == ClientState::ESTABLISHED; } + void set_connection_type(ConnectionType ct) { this->connection_type_ = ct; } + // Latched at discovery completion rather than read from the backend table: + // streaming frees the table, and this must stay true for the connection's + // lifetime (esp32 parity — a repeat GetServices is silently ignored there, + // never answered with an authoritative empty database). + bool has_gatt_services() const { return this->services_discovered_; } + + /// Stream any pending service-discovery batch and police the disconnect + /// safety timeout. Called from the proxy's loop — hub connections have no + /// Component loop of their own (the esp32 class streams from its own + /// loop() and has the same 10 s safety net in its base class). + void process_pending_services() { + if (this->send_service_ >= 0) { + this->send_service_for_discovery_(); + } + this->check_disconnect_timeout_(); + } + + // ---- ble_device_base::GattClientEventListener ---- + void on_connection_state(bool connected, uint16_t mtu, int error) override; + void on_service_discovery_done(int error) override; + void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) override; + void on_write_result(uint16_t handle, int error) override; + void on_notify_state(uint16_t handle, bool enabled, int error) override; + void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) override; + + protected: + friend class bluetooth_proxy::BluetoothProxy; + + void start_connect_(); + void send_service_for_discovery_(); + void check_disconnect_timeout_(); + void reset_connection_(conn_err_t reason); + conn_err_t check_connected_op_(const char *action, const char *type) const; + void log_gatt_operation_error_(const char *operation, uint16_t handle, int status); + + // Memory optimized layout for 32-bit systems (a vptr precedes: pointers and + // 2-byte members first fill to an 8-byte boundary before address_) + // Group 1: Pointers (4 bytes each, naturally aligned) + bluetooth_proxy::BluetoothProxy *proxy_{nullptr}; + ble_device_base::BLEGattConnection *backend_{nullptr}; + + // Group 2: 2-byte types + int16_t send_service_{INIT_SENDING_SERVICES}; + uint16_t mtu_{23}; + + // Group 3: 8-byte and 4-byte types + uint64_t address_{0}; + uint32_t disconnecting_started_{0}; + conn_err_t pending_error_{0}; + + // Group 4: Arrays + char address_str_[MAC_ADDRESS_PRETTY_BUFFER_SIZE]{}; + + // Group 5: 1-byte types + ClientState state_{ClientState::IDLE}; + ConnectionType connection_type_{ConnectionType::V1}; + uint8_t remote_addr_type_{0}; + uint8_t connection_index_{0}; + bool services_discovered_{false}; +}; + +} // namespace esphome::bluetooth_connection + +#endif // !USE_ESP32 && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index 5916132ab2..a0706ae4ae 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -47,6 +47,8 @@ def AUTO_LOAD(config: ConfigType | None = None) -> list[str]: # Assistant) assumes an ESPHome proxy can scan actively, so a passive-only # proxy would be misdriven — bk72xx follows once the API carries a feature # flag clients can trust (FEATURE_ACTIVE_SCAN + a version flag, separate PRs). +# Coupled to bluetooth_connection: platforms with a GATT backend are also +# listed in its FILTER_SOURCE_FILES hub entry. _HUB_PLATFORMS = (PLATFORM_LN882X, PLATFORM_RP2) DEPENDENCIES = ["api"] @@ -160,16 +162,14 @@ _BLE_HUB_CONFIG_SCHEMA = cv.All( cv.Schema( { **_COMMON_SCHEMA_KEYS, - # Declared directly (BLE_DEVICE_SCHEMA-style): appending a validator - # after a strict schema rejects an explicit `ble_hub_id` before it - # runs, and that key is the documented way to disambiguate once a - # platform has two trackers. - cv.GenerateID(ble_device_base.CONF_BLE_HUB_ID): cv.use_id( - ble_device_base.BLEHub - ), cv.Optional(CONF_ACTIVE, default=False): cv.boolean, } - ).extend(cv.COMPONENT_SCHEMA), + ) + .extend( + # ble_hub_id with the friendly no-tracker-configured guard. + ble_device_base.BLE_DEVICE_SCHEMA + ) + .extend(cv.COMPONENT_SCHEMA), _validate_no_active, ) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 66f22c9a90..a25c9d9608 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -54,8 +54,9 @@ void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerSta #else // !USE_ESP32 void BluetoothProxy::setup() { - this->connections_free_response_.limit = 0; - this->connections_free_response_.free = 0; + // BLUETOOTH_PROXY_MAX_CONNECTIONS is 0 on an advertisement-only proxy. + this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS; + this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS; // Capture the configured scan mode from YAML before any API changes this->configured_scan_active_ = this->hub_->scan_active(); @@ -111,16 +112,16 @@ void BluetoothProxy::send_bluetooth_scanner_state_() { #endif // USE_ESP32 -#ifdef USE_ESP32 -void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state) { +#ifdef BLUETOOTH_CONNECTION_HAS_GATT +void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, ClientState state) { ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, state: %s", connection->get_connection_index(), - connection->address_str(), espbt::client_state_to_string(state)); + connection->address_str(), ble_device_base::client_state_to_string(state)); } void BluetoothProxy::log_connection_info_(BluetoothConnection *connection, const char *message) { ESP_LOGI(TAG, "[%d] [%s] Connecting %s", connection->get_connection_index(), connection->address_str(), message); } -#endif // USE_ESP32 +#endif // BLUETOOTH_CONNECTION_HAS_GATT void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *type) { ESP_LOGW(TAG, "Cannot %s GATT %s, not connected", action, type); @@ -188,19 +189,29 @@ void BluetoothProxy::dump_config() { " Connections: %d", YESNO(this->active_), this->connection_count_); #else - // Advertisement-only: print configured facts. dump_config runs right after - // setup, before the radio is up, so live scan state would always read - // "stopped" here — the loop's BluetoothScannerStateResponse carries the - // changing value instead. + // Print configured facts. dump_config runs right after setup, before the + // radio is up, so live scan state would always read "stopped" here — the + // loop's BluetoothScannerStateResponse carries the changing value instead. char mac_str[18]; this->get_bluetooth_mac_address_pretty(mac_str); + const char *mac_out = mac_str[0] != '\0' ? mac_str : "unavailable (adapter not up yet)"; + const char *scan_mode = this->configured_scan_active_ ? "active" : "passive"; +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + ESP_LOGCONFIG(TAG, + "Bluetooth Proxy:\n" + " Active: %s\n" + " Connections: %d\n" + " Configured scan: %s\n" + " Adapter MAC: %s", + YESNO(this->active_), this->connection_count_, scan_mode, mac_out); +#else ESP_LOGCONFIG(TAG, "Bluetooth Proxy:\n" " Mode: advertisement-only (no GATT connections)\n" " Configured scan: %s\n" " Adapter MAC: %s", - this->configured_scan_active_ ? "active" : "passive", - mac_str[0] != '\0' ? mac_str : "unavailable (adapter not up yet)"); + scan_mode, mac_out); +#endif #endif } @@ -229,6 +240,51 @@ esp32_ble_tracker::AdvertisementParserType BluetoothProxy::get_advertisement_par return esp32_ble_tracker::AdvertisementParserType::RAW_ADVERTISEMENTS; } +#endif // USE_ESP32 + +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + +// maybe_unused: in a passive proxy (active: false) MAX is 0, the body is removed, and connection is unused. +void BluetoothProxy::register_connection([[maybe_unused]] BluetoothConnection *connection) { +// Guard the always-false comparison (-Wtype-limits) in a passive proxy (active: false), where MAX is 0. +#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0 + if (this->connection_count_ >= BLUETOOTH_PROXY_MAX_CONNECTIONS) { + // Cannot happen with codegen-sized registration; a silent drop would + // surface later as a null proxy_ dereference, so refuse loudly. + ESP_LOGE(TAG, "Connection registry full, dropping registration"); + return; + } +#ifndef USE_ESP32 + // esp32 assigns connection_index_ in BLEClientBase::setup(); the hub + // class has no Component lifecycle, so the index is assigned here. + connection->connection_index_ = this->connection_count_; +#endif + this->connections_[this->connection_count_++] = connection; + connection->proxy_ = this; +#endif +} + +void BluetoothProxy::log_slot_accounting_mismatch_() { ESP_LOGW(TAG, "Connection slot free-count mismatch, clamped"); } + +void BluetoothProxy::replace_allocated_slot_(uint64_t find_value, uint64_t set_value) { + for (auto &slot : this->connections_free_response_.allocated) { + if (slot == find_value) { + slot = set_value; + return; + } + } + // The accounting arrays are only mutated here and sized to the slot count, + // so a miss means the bookkeeping already drifted — say so. + ESP_LOGW(TAG, "Connection slot accounting mismatch (find 0x%llx)", (unsigned long long) find_value); +} + +void BluetoothProxy::reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason) { + this->send_device_connection(connection->get_address(), false, 0, reason); + connection->set_address(0); + connection->send_service_ = INIT_SENDING_SERVICES; + this->send_connections_free(); +} + BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool reserve) { for (uint8_t i = 0; i < this->connection_count_; i++) { auto *connection = this->connections_[i]; @@ -244,7 +300,7 @@ BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool rese // We only set the state if we allocate the connection // to avoid a race where multiple connection attempts // are made. - connection->set_state(espbt::ClientState::INIT); + connection->set_state(ClientState::INIT); return connection; } } @@ -267,13 +323,12 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest this->send_device_connection(msg.address, false); return; } - if (connection->state() == espbt::ClientState::CONNECTED || - connection->state() == espbt::ClientState::ESTABLISHED) { + if (connection->state() == ClientState::CONNECTED || connection->state() == ClientState::ESTABLISHED) { this->log_connection_request_ignored_(connection, connection->state()); this->send_device_connection(msg.address, true); this->send_connections_free(); return; - } else if (connection->state() == espbt::ClientState::CONNECTING) { + } else if (connection->state() == ClientState::CONNECTING) { if (connection->disconnect_pending()) { ESP_LOGW(TAG, "[%d] [%s] Connection request while pending disconnect, cancelling pending disconnect", connection->get_connection_index(), connection->address_str()); @@ -282,19 +337,18 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest } this->log_connection_request_ignored_(connection, connection->state()); return; - } else if (connection->state() != espbt::ClientState::INIT) { + } else if (connection->state() != ClientState::INIT) { this->log_connection_request_ignored_(connection, connection->state()); return; } if (msg.request_type == api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE) { - connection->set_connection_type(espbt::ConnectionType::V3_WITH_CACHE); + connection->set_connection_type(ble_device_base::ConnectionType::V3_WITH_CACHE); this->log_connection_info_(connection, "v3 with cache"); } else { // BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE - connection->set_connection_type(espbt::ConnectionType::V3_WITHOUT_CACHE); + connection->set_connection_type(ble_device_base::ConnectionType::V3_WITHOUT_CACHE); this->log_connection_info_(connection, "v3 without cache"); } - connection->set_remote_addr_type(static_cast(msg.address_type)); - connection->set_state(espbt::ClientState::DISCOVERED); + connection->initiate_connection(static_cast(msg.address_type)); this->send_connections_free(); break; } @@ -305,7 +359,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest this->send_connections_free(); return; } - if (connection->state() != espbt::ClientState::IDLE) { + if (connection->state() != ClientState::IDLE) { connection->disconnect(); } else { connection->set_address(0); @@ -315,6 +369,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR: { +#ifdef USE_ESP32 auto *connection = this->get_connection_(msg.address, false); if (connection != nullptr) { if (!connection->is_paired()) { @@ -326,21 +381,21 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest this->send_device_pairing(msg.address, true); } } +#else + // Explicit pairing is not offered (FEATURE_PAIRING is not advertised); + // peripheral-initiated security still works through the platform's SM. + this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED); +#endif break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR: { - esp_bd_addr_t address; - uint64_to_bd_addr(msg.address, address); - esp_err_t ret = esp_ble_remove_bond_device(address); - this->send_device_unpairing(msg.address, ret == ESP_OK, ret); + conn_err_t ret = bluetooth_connection::unpair_device(msg.address); + this->send_device_unpairing(msg.address, ret == CONN_OK, ret); break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE: { - esp_bd_addr_t address; - uint64_to_bd_addr(msg.address, address); - esp_err_t ret = esp_ble_gattc_cache_clean(address); - // Shares the sender with the neutral path, which also null-checks api_connection_. - this->send_device_clear_cache(msg.address, ret == ESP_OK, ret); + conn_err_t ret = bluetooth_connection::clear_gatt_cache(msg.address); + this->send_device_clear_cache(msg.address, ret == CONN_OK, ret); break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT: { @@ -359,7 +414,7 @@ void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &ms } auto err = connection->read_characteristic(msg.handle); - if (err != ESP_OK) { + if (err != CONN_OK) { this->send_gatt_error(msg.address, msg.handle, err); } } @@ -372,7 +427,7 @@ void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest & } auto err = connection->write_characteristic(msg.handle, msg.data, msg.data_len, msg.response); - if (err != ESP_OK) { + if (err != CONN_OK) { this->send_gatt_error(msg.address, msg.handle, err); } } @@ -385,7 +440,7 @@ void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTRead } auto err = connection->read_descriptor(msg.handle); - if (err != ESP_OK) { + if (err != CONN_OK) { this->send_gatt_error(msg.address, msg.handle, err); } } @@ -398,7 +453,7 @@ void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWri } auto err = connection->write_descriptor(msg.handle, msg.data, msg.data_len, true); - if (err != ESP_OK) { + if (err != CONN_OK) { this->send_gatt_error(msg.address, msg.handle, err); } } @@ -409,8 +464,8 @@ void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetSer this->handle_gatt_not_connected_(msg.address, 0, "get", "services"); return; } - if (!connection->service_count_) { - ESP_LOGW(TAG, "[%d] [%s] No GATT services found", connection->connection_index_, connection->address_str()); + if (!connection->has_gatt_services()) { + ESP_LOGW(TAG, "[%d] [%s] No GATT services found", connection->get_connection_index(), connection->address_str()); this->send_gatt_services_done(msg.address); return; } @@ -426,7 +481,7 @@ void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest } auto err = connection->notify_characteristic(msg.handle, msg.enable); - if (err != ESP_OK) { + if (err != CONN_OK) { this->send_gatt_error(msg.address, msg.handle, err); } } @@ -434,6 +489,7 @@ void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) { if (this->api_connection_ == nullptr) return; + // Send results unchecked (esp32 parity): a drop resolves via the client timeout. auto *connection = this->get_connection_(msg.address, false); api::BluetoothSetConnectionParamsResponse resp; @@ -441,7 +497,7 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn if (connection == nullptr || !connection->connected()) { ESP_LOGW(TAG, "[%d] [%s] Cannot set connection params, not connected", - connection ? static_cast(connection->connection_index_) : -1, + connection ? static_cast(connection->get_connection_index()) : -1, connection ? connection->address_str() : "unknown"); resp.error = GATT_NOT_CONNECTED; this->api_connection_->send_message(resp); @@ -458,6 +514,10 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn this->api_connection_->send_message(resp); } +#endif // BLUETOOTH_CONNECTION_HAS_GATT + +#ifdef USE_ESP32 + void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { if (this->parent_->get_scan_active() == active) { return; @@ -471,21 +531,35 @@ void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { #else // !USE_ESP32 -// Advertisement-only proxy. GATT client connections are excluded at compile -// time — this whole arm is selected by #ifdef USE_ESP32, and nothing consults -// HubCapabilities at runtime today — so every connection-oriented request is -// answered with a clean error instead of silence, and Home Assistant treats -// the proxy as passive. - void BluetoothProxy::loop() { +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + // Stream pending service-discovery batches every iteration (esp32 parity: + // its connections stream from their own per-iteration Component loop). + // send_service_for_discovery_() handles a vanished API connection itself. + for (uint8_t i = 0; i < this->connection_count_; i++) { + this->connections_[i]->process_pending_services(); + } +#endif + // Run advertisement flush / scanner-state poll 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) + if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) { +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + // The API subscriber is gone: tear down any connections it left behind + // (disconnect() on an already-disconnecting backend is a no-op). + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto *connection = this->connections_[i]; + if (connection->get_address() != 0) { + connection->disconnect(); + } + } +#endif return; + } // The hub has no scanner-state listener interface; poll and report on change. if (this->hub_->scan_running() != this->last_scan_running_) { @@ -495,6 +569,13 @@ void BluetoothProxy::loop() { this->flush_pending_advertisements_(); } +#ifndef BLUETOOTH_CONNECTION_HAS_GATT + +// Advertisement-only proxy. GATT client connections are excluded at compile +// time (no connection backend on this platform, or active: false), so every +// connection-oriented request is answered with a clean error instead of +// silence, and Home Assistant treats the proxy as passive. + void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest &msg) { switch (msg.request_type) { case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE: @@ -547,12 +628,15 @@ void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) { if (this->api_connection_ == nullptr) return; + // Send results unchecked (esp32 parity): a drop resolves via the client timeout. api::BluetoothSetConnectionParamsResponse resp; resp.address = msg.address; resp.error = GATT_NOT_CONNECTED; this->api_connection_->send_message(resp); } +#endif // !BLUETOOTH_CONNECTION_HAS_GATT + void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { if (this->hub_->scan_active() != active) { ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive"); diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index dbfc119d98..b8c8ab15f6 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -16,7 +16,6 @@ #include "esphome/components/bluetooth_connection/bluetooth_connection.h" #ifdef USE_ESP32 -#include "esphome/components/esp32_ble_client/ble_client_base.h" #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" #include "esphome/components/bluetooth_connection/bluetooth_connection_esp32.h" @@ -27,6 +26,9 @@ #include #else #include "esphome/components/ble_device_base/ble_hub.h" +#ifdef USE_BLE_GATT_CLIENT +#include "esphome/components/bluetooth_connection/bluetooth_connection_hub.h" +#endif #endif // USE_ESP32 namespace esphome::bluetooth_proxy { @@ -39,9 +41,9 @@ using bluetooth_connection::DONE_SENDING_SERVICES; using bluetooth_connection::GATT_NOT_CONNECTED; using bluetooth_connection::INIT_SENDING_SERVICES; -#ifdef USE_ESP32 +#ifdef BLUETOOTH_CONNECTION_HAS_GATT using BluetoothConnection = bluetooth_connection::BluetoothConnection; -using namespace esp32_ble_client; +using ClientState = ble_device_base::ClientState; #endif // Legacy versions: @@ -51,6 +53,8 @@ using namespace esp32_ble_client; // Version 4: Pairing support // Version 5: Cache clear support static constexpr uint32_t LEGACY_ACTIVE_CONNECTIONS_VERSION = 5; +static constexpr uint32_t LEGACY_ACTIVE_NO_CACHE_CLEAR_VERSION = 4; +static constexpr uint32_t LEGACY_ACTIVE_NO_PAIRING_VERSION = 3; static constexpr uint32_t LEGACY_PASSIVE_ONLY_VERSION = 1; enum BluetoothProxyFeature : uint32_t { @@ -72,10 +76,12 @@ enum BluetoothProxySubscriptionFlag : uint32_t { class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, public esp32_ble_tracker::BLEScannerStateListener, public Component { - // Allow the connection to update connections_free_response_ - friend bluetooth_connection::BluetoothConnection; #else class BluetoothProxy final : public Component { +#endif +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + // Allow the connection to update connections_free_response_ + friend bluetooth_connection::BluetoothConnection; #endif public: BluetoothProxy(); @@ -90,25 +96,17 @@ class BluetoothProxy final : public Component { void setup() override; void loop() override; -#ifdef USE_ESP32 - // maybe_unused: in a passive proxy (active: false) MAX is 0, the body below is removed, and connection is unused. - void register_connection([[maybe_unused]] BluetoothConnection *connection) { - // Guard the always-false comparison (-Wtype-limits) in a passive proxy (active: false), where MAX is 0. -#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0 - if (this->connection_count_ < BLUETOOTH_PROXY_MAX_CONNECTIONS) { - this->connections_[this->connection_count_++] = connection; - connection->proxy_ = this; - } -#endif - } -#else +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + void register_connection(BluetoothConnection *connection); +#endif // BLUETOOTH_CONNECTION_HAS_GATT +#ifndef USE_ESP32 void set_ble_hub(ble_device_base::BLEHub *hub) { this->hub_ = hub; } // Run after the hub's setup() (the trackers use AFTER_WIFI): setup() below // snapshots scan_active()/scan_running() and installs the raw callback, and // the BLEHub contract does not promise those are settled any earlier than // the hub's own setup(). float get_setup_priority() const override { return setup_priority::AFTER_WIFI - 1.0f; } -#endif // USE_ESP32 +#endif // !USE_ESP32 void bluetooth_device_request(const api::BluetoothDeviceRequest &msg); void bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg); @@ -122,6 +120,10 @@ class BluetoothProxy final : public Component { void subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags); void unsubscribe_api_connection(api::APIConnection *api_connection); api::APIConnection *get_api_connection() { return this->api_connection_; } + /// Whether the subscribed API client understands 16/32-bit UUID fields. + bool client_supports_efficient_uuids() const { + return this->api_connection_ != nullptr && this->api_connection_->client_supports_api_version(1, 12); + } void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, conn_err_t error = CONN_OK); void send_connections_free(); @@ -134,17 +136,6 @@ class BluetoothProxy final : public Component { void bluetooth_scanner_set_mode(bool active); -#ifdef USE_ESP32 - static void uint64_to_bd_addr(uint64_t address, esp_bd_addr_t bd_addr) { - bd_addr[0] = (address >> 40) & 0xff; - bd_addr[1] = (address >> 32) & 0xff; - bd_addr[2] = (address >> 24) & 0xff; - bd_addr[3] = (address >> 16) & 0xff; - bd_addr[4] = (address >> 8) & 0xff; - bd_addr[5] = (address >> 0) & 0xff; - } -#endif - void set_active(bool active) { this->active_ = active; } bool has_active() { return this->active_; } @@ -154,10 +145,17 @@ class BluetoothProxy final : public Component { #endif uint32_t get_legacy_version() const { - if (this->active_) { + if (!this->active_) { + return LEGACY_PASSIVE_ONLY_VERSION; + } + // Legacy clients (which predate the feature flags) map versions to + // capability sets: 5 adds cache clearing, 4 adds pairing, 3 is active + // connections only. + if (bluetooth_connection::SUPPORTS_CACHE_CLEARING) { return LEGACY_ACTIVE_CONNECTIONS_VERSION; } - return LEGACY_PASSIVE_ONLY_VERSION; + return bluetooth_connection::SUPPORTS_PAIRING ? LEGACY_ACTIVE_NO_CACHE_CLEAR_VERSION + : LEGACY_ACTIVE_NO_PAIRING_VERSION; } uint32_t get_feature_flags() const { @@ -176,11 +174,18 @@ class BluetoothProxy final : public Component { } #endif if (this->active_) { + // REMOTE_CACHING is mandatory for active connections: API clients + // refuse to connect without it (it selects which V3 connect request + // they send, not device-side caching). flags |= BluetoothProxyFeature::FEATURE_ACTIVE_CONNECTIONS; flags |= BluetoothProxyFeature::FEATURE_REMOTE_CACHING; - flags |= BluetoothProxyFeature::FEATURE_PAIRING; - flags |= BluetoothProxyFeature::FEATURE_CACHE_CLEARING; flags |= BluetoothProxyFeature::FEATURE_CONNECTION_PARAMS_SETTING; + if (bluetooth_connection::SUPPORTS_PAIRING) { + flags |= BluetoothProxyFeature::FEATURE_PAIRING; + } + if (bluetooth_connection::SUPPORTS_CACHE_CLEARING) { + flags |= BluetoothProxyFeature::FEATURE_CACHE_CLEARING; + } } return flags; @@ -231,22 +236,59 @@ class BluetoothProxy final : public Component { } void log_advertisement_flush_(); -#ifdef USE_ESP32 +#ifdef BLUETOOTH_CONNECTION_HAS_GATT BluetoothConnection *get_connection_(uint64_t address, bool reserve); - void log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state); + void log_connection_request_ignored_(BluetoothConnection *connection, ClientState state); void log_connection_info_(BluetoothConnection *connection, const char *message); #endif void log_not_connected_gatt_(const char *action, const char *type); void handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action, const char *type); +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + /// Keep the pre-allocated connections-free message in step when a + /// connection slot changes address (0 = free). Called from the connection + /// classes' set_address(). + // maybe_unused + guard: in a passive proxy (active: false) MAX is 0, the + // body is removed, and the free < MAX compare would trip -Wtype-limits. + void update_address_slot_([[maybe_unused]] uint64_t old_address, [[maybe_unused]] uint64_t new_address) { +#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0 + auto &resp = this->connections_free_response_; + if (new_address == 0 && old_address != 0) { + if (resp.free < BLUETOOTH_PROXY_MAX_CONNECTIONS) { + resp.free++; + } else { + this->log_slot_accounting_mismatch_(); + } + this->replace_allocated_slot_(old_address, 0); + } else if (new_address != 0 && old_address == 0) { + if (resp.free > 0) { + resp.free--; + } else { + this->log_slot_accounting_mismatch_(); + } + this->replace_allocated_slot_(0, new_address); + } +#endif // BLUETOOTH_PROXY_MAX_CONNECTIONS > 0 + } + void replace_allocated_slot_(uint64_t find_value, uint64_t set_value); + void log_slot_accounting_mismatch_(); + /// Free a connection slot after teardown: notify the API client and reset + /// the streaming cursor. Important: does NOT send send_gatt_services_done() + /// when service streaming was interrupted -- the client (aioesphomeapi) has + /// a 30-second timeout (DEFAULT_BLE_TIMEOUT) to detect incomplete service + /// discovery and retry, rather than being told a partial list is complete. + void reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason); +#endif + // Memory optimized layout for 32-bit systems // Group 1: Pointers (4 bytes each, naturally aligned) api::APIConnection *api_connection_{nullptr}; -#ifdef USE_ESP32 +#ifdef BLUETOOTH_CONNECTION_HAS_GATT // Group 2: Fixed-size array of connection pointers std::array connections_{}; -#else +#endif +#ifndef USE_ESP32 ble_device_base::BLEHub *hub_{nullptr}; #endif diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py index c474b5fa81..036530d942 100644 --- a/tests/component_tests/bluetooth_proxy/test_platform_gates.py +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -5,12 +5,14 @@ advertisement-only arm applies its own defaults.""" import pytest from esphome import config_validation as cv -from esphome.components import bluetooth_connection, bluetooth_proxy +from esphome.components import ble_device_base, bluetooth_connection, bluetooth_proxy from esphome.const import ( CONF_ACTIVE, KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, + PLATFORM_LN882X, + PLATFORM_RP2, PlatformFramework, ) from esphome.core import CORE @@ -22,12 +24,18 @@ HUB_PLATFORM_FRAMEWORKS = [ PlatformFramework.RP2_ARDUINO, ] +HUB_TRACKERS = { + PLATFORM_LN882X: "ln882h_ble_tracker", + PLATFORM_RP2: "rp2_ble_tracker", +} + def test_hub_platform_list_covers_every_hub_platform() -> None: # A platform added to _HUB_PLATFORMS (bk72xx is planned) would otherwise # get no gate coverage at all. covered = {pf.value[0] for pf in HUB_PLATFORM_FRAMEWORKS} assert covered == set(bluetooth_proxy._HUB_PLATFORMS) + assert set(HUB_TRACKERS) == set(bluetooth_proxy._HUB_PLATFORMS) def _set_platform(platform: str | None) -> None: @@ -35,6 +43,14 @@ def _set_platform(platform: str | None) -> None: CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = platform +def _register_tracker(platform: str) -> None: + # The ble_hub_id guard needs a loaded tracker, normally registered as an + # import side effect of the tracker module. + tracker = HUB_TRACKERS[platform] + ble_device_base.register_hub_provider(tracker) + CORE.loaded_integrations.add(tracker) + + def test_ble_less_platform_gets_the_real_reason( set_core_config: SetCoreConfigCallable, ) -> None: @@ -68,6 +84,7 @@ def test_hub_platform_rejects_active( platform_framework: PlatformFramework, ) -> None: set_core_config(platform_framework) + _register_tracker(platform_framework.value[0]) with pytest.raises(cv.Invalid, match="Active connections are not supported"): bluetooth_proxy.CONFIG_SCHEMA({"active": True}) @@ -100,6 +117,7 @@ def test_hub_platform_accepts_the_advertisement_only_shape( platform_framework: PlatformFramework, ) -> None: set_core_config(platform_framework) + _register_tracker(platform_framework.value[0]) validated = bluetooth_proxy.CONFIG_SCHEMA({}) assert validated[CONF_ACTIVE] is False diff --git a/tests/components/bluetooth_connection/test_gatt_uuid.cpp b/tests/components/bluetooth_connection/test_gatt_uuid.cpp new file mode 100644 index 0000000000..b3596a4364 --- /dev/null +++ b/tests/components/bluetooth_connection/test_gatt_uuid.cpp @@ -0,0 +1,49 @@ +// Pins the shared UUID wire packing and the size-estimate budget the service +// streamers rely on, in both efficient and legacy client modes. +#include "esphome/components/bluetooth_connection/bluetooth_connection.h" + +#include + +namespace esphome::bluetooth_connection::testing { + +using ble_device_base::ESPBTUUID; + +TEST(GattUuidPacking, ShortUuidUsedWhenClientSupportsIt) { + std::array uuid128{}; + uint32_t short_uuid = 0; + fill_gatt_uuid(uuid128, short_uuid, ESPBTUUID::from_uint16(0x180F), true); + EXPECT_EQ(short_uuid, 0x180Fu); + EXPECT_EQ(uuid128[0], 0u); + EXPECT_EQ(uuid128[1], 0u); +} + +TEST(GattUuidPacking, LegacyClientGetsBaseUuidExpansion) { + // 0000180F-0000-1000-8000-00805F9B34FB + std::array uuid128{}; + uint32_t short_uuid = 0; + fill_gatt_uuid(uuid128, short_uuid, ESPBTUUID::from_uint16(0x180F), false); + EXPECT_EQ(short_uuid, 0u); + EXPECT_EQ(uuid128[0], 0x0000180F00001000ULL); + EXPECT_EQ(uuid128[1], 0x800000805F9B34FBULL); +} + +TEST(GattUuidPacking, FullUuidPassesThroughBigEndian) { + // 12345678-90AB-CDEF-1122-334455667788, stored little-endian in ESPBTUUID. + const uint8_t big_endian[16] = {0x12, 0x34, 0x56, 0x78, 0x90, 0xAB, 0xCD, 0xEF, + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88}; + std::array uuid128{}; + uint32_t short_uuid = 0; + // Efficient mode must still use the 128-bit form for 128-bit UUIDs. + fill_gatt_uuid(uuid128, short_uuid, ESPBTUUID::from_raw_reversed(big_endian), true); + EXPECT_EQ(short_uuid, 0u); + EXPECT_EQ(uuid128[0], 0x1234567890ABCDEFULL); + EXPECT_EQ(uuid128[1], 0x1122334455667788ULL); +} + +TEST(GattUuidPacking, EstimateGrowsWithCharacteristicsAndMode) { + // The estimate only gates batching; pin its shape, not exact bytes. + EXPECT_LT(estimate_service_size(0, true), estimate_service_size(0, false)); + EXPECT_LT(estimate_service_size(1, false), estimate_service_size(2, false)); +} + +} // namespace esphome::bluetooth_connection::testing From c0e70d9beb09f09c283c38db10c6478a9139047e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 7 Aug 2026 11:50:18 -0500 Subject: [PATCH 1305/1815] [rp2040_ble] Add scan arbitration and GATT client hooks (#18155) --- esphome/components/rp2040_ble/rp2040_ble.cpp | 52 ++++++++++++++++++++ esphome/components/rp2040_ble/rp2040_ble.h | 28 +++++++++-- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/esphome/components/rp2040_ble/rp2040_ble.cpp b/esphome/components/rp2040_ble/rp2040_ble.cpp index 8e7c7d6be5..7dd84d9c31 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.cpp +++ b/esphome/components/rp2040_ble/rp2040_ble.cpp @@ -57,6 +57,17 @@ void RP2040BLE::enable() { l2cap_init(); sm_init(); +#ifdef USE_BLE_GATT_CLIENT + gatt_client_init(); + // The GATT engine kicks the MTU exchange explicitly right after a + // connection completes (auto negotiation would only run on the first + // query, which a with-cache connection never issues). + gatt_client_mtu_enable_auto_negotiation(0); + // Just-works security for peripheral-initiated pairing. + sm_set_io_capabilities(IO_CAPABILITY_NO_INPUT_NO_OUTPUT); + sm_set_authentication_requirements(SM_AUTHREQ_BONDING); +#endif + this->hci_event_callback_registration_.callback = &RP2040BLE::packet_handler; hci_add_event_handler(&this->hci_event_callback_registration_); @@ -215,6 +226,18 @@ bool RP2040BLE::scan_start(uint16_t interval, uint16_t window, bool active) { // the moment a tracker retries. Callers retry until the stack is up. return false; } +#ifdef USE_BLE_GATT_CLIENT + this->scan_interval_ = interval; + this->scan_window_ = window; + this->scan_active_mode_ = active; + this->scan_desired_ = true; + if (this->scan_inhibit_count_ > 0) { + // A connect attempt owns the radio; the scan starts physically when the + // inhibit is released. Report success — the controller will run it. + ESP_LOGV(TAG, "Scan start deferred (connect in progress)"); + return true; + } +#endif // Serialize with the BTstack background worker (arduino-pico's BluetoothHCI // takes the same lock around its gap_* calls). BluetoothLock lock; @@ -224,6 +247,9 @@ bool RP2040BLE::scan_start(uint16_t interval, uint16_t window, bool active) { } void RP2040BLE::scan_stop() { +#ifdef USE_BLE_GATT_CLIENT + this->scan_desired_ = false; +#endif if (!this->is_active()) { return; // nothing can be scanning on a stack that is not up } @@ -231,6 +257,32 @@ void RP2040BLE::scan_stop() { gap_stop_scan(); } +#ifdef USE_BLE_GATT_CLIENT +void RP2040BLE::inhibit_scan() { + if (this->scan_inhibit_count_++ != 0) { + return; // another connect attempt already owns the radio + } + if (this->scan_desired_ && this->is_active()) { + BluetoothLock lock; + gap_stop_scan(); + } +} + +void RP2040BLE::release_scan_inhibit() { + if (this->scan_inhibit_count_ == 0) { + return; + } + this->scan_inhibit_count_--; + if (this->scan_inhibit_count_ != 0) { + return; + } + if (this->scan_desired_) { + // One physical-start path: scan_start re-applies the remembered params. + this->scan_start(this->scan_interval_, this->scan_window_, this->scan_active_mode_); + } +} +#endif // USE_BLE_GATT_CLIENT + } // namespace esphome::rp2040_ble #endif // USE_RP2040_BLE diff --git a/esphome/components/rp2040_ble/rp2040_ble.h b/esphome/components/rp2040_ble/rp2040_ble.h index cc015c0503..99eb8cd88a 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.h +++ b/esphome/components/rp2040_ble/rp2040_ble.h @@ -91,13 +91,25 @@ class RP2040BLE final : public Component { /// (0.625 ms). Returns false until the stack is ACTIVE (callers retry — the /// tracker's rate-limited retry loop); powering the stack on stays with the /// user (enable_on_boot or an explicit enable() call). The controller keeps - /// no scan state: a disable()/enable() power cycle ends the scan, and the - /// caller must call scan_start() again once the stack is back to ACTIVE - /// (the tracker's loop() reconciliation does exactly that). + /// no scan state across power cycles: a disable()/enable() cycle ends the + /// scan, and the caller must call scan_start() again once the stack is back + /// to ACTIVE (the tracker's loop() reconciliation does exactly that). + /// While a GATT connect attempt has the scan inhibited, the desired scan is + /// remembered and started physically when the inhibit is released. bool scan_start(uint16_t interval, uint16_t window, bool active); /// Stop the controller scan (no-op when not scanning). void scan_stop(); +#ifdef USE_BLE_GATT_CLIENT + /// Pause the physical scan for the duration of a GATT connect attempt + /// (initiating and scanning contend for the radio). The desired scan state + /// set through scan_start()/scan_stop() is remembered and reconciled by + /// release_scan_inhibit(). Holders must guarantee the release on every + /// abort path (the GATT engine reclaims via its connect timeout). + void inhibit_scan(); + void release_scan_inhibit(); +#endif + protected: static void packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); @@ -128,6 +140,16 @@ class RP2040BLE final : public Component { bool enable_on_boot_{true}; bool btstack_initialized_{false}; bool active_logged_{false}; +#ifdef USE_BLE_GATT_CLIENT + // Remembered scan intent, so connect attempts can pause the physical scan + // and restore it afterwards without involving the tracker. Counted so + // overlapping connect attempts compose once multiple slots exist. + uint16_t scan_interval_{0}; + uint16_t scan_window_{0}; + uint8_t scan_inhibit_count_{0}; + bool scan_active_mode_{false}; + bool scan_desired_{false}; +#endif }; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) From b326cefea7bd19dea0034491556fb61dacc20057 Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Fri, 7 Aug 2026 19:36:59 +0200 Subject: [PATCH 1306/1815] [esp32] refactor esp32-vfs default configs to FINAL co-routine & add some defaults (#17337) Co-authored-by: Oliver Kleinecke Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: storage split Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/esp32/__init__.py | 118 +++++++++------- tests/component_tests/esp32/test_esp32.py | 159 ++++++++++++++++++++++ 2 files changed, 225 insertions(+), 52 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index e31d0352e9..d16e8ae03c 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2248,6 +2248,62 @@ async def _add_yaml_idf_components(components: list[ConfigType]): ) +@coroutine_with_priority(CoroPriority.FINAL) +async def _reconcile_vfs_fatfs_sdkconfig( + disable_vfs_termios: bool, + disable_vfs_select: bool, + disable_vfs_dir: bool, + disable_fatfs: bool, +) -> None: + """Reconcile VFS/FATFS sdkconfig flags after all require_*() calls; user sdkconfig_options win.""" + opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + + def set_opt(name: str, value: SdkconfigValueType) -> None: + # User sdkconfig_options (applied during to_code) win. + if name not in opts: + add_idf_sdkconfig_option(name, value) + + # USB Serial JTAG VFS needs termios (require_vfs_termios(), e.g. logger). ~1.8KB flash when off. + if CORE.data.get(KEY_VFS_TERMIOS_REQUIRED, False): + set_opt("CONFIG_VFS_SUPPORT_TERMIOS", True) + else: + set_opt("CONFIG_VFS_SUPPORT_TERMIOS", not disable_vfs_termios) + + # VFS select is only needed for UART/eventfd fds (require_vfs_select(), e.g. openthread); + # sockets use lwip_select() either way. ~2.7KB flash when off. + if CORE.data.get(KEY_VFS_SELECT_REQUIRED, False): + set_opt("CONFIG_VFS_SUPPORT_SELECT", True) + else: + set_opt("CONFIG_VFS_SUPPORT_SELECT", not disable_vfs_select) + + # Directory functions: opendir/readdir/mkdir etc. (require_vfs_dir()). ~0.5KB flash when off. + if CORE.data.get(KEY_VFS_DIR_REQUIRED, False): + set_opt("CONFIG_VFS_SUPPORT_DIR", True) + else: + set_opt("CONFIG_VFS_SUPPORT_DIR", not disable_vfs_dir) + + # FATFS (require_fatfs()): LFN + one volume per esp_vfs_fat mount. Defaults only; + # sdkconfig_options override. FATFS_LONG_FILENAMES is a Kconfig choice -- if the user set + # any member, leave the group alone. LFN_HEAP allocates per LFN op; LFN_STACK uses stack. + lfn_keys = ( + "CONFIG_FATFS_LFN_NONE", + "CONFIG_FATFS_LFN_HEAP", + "CONFIG_FATFS_LFN_STACK", + ) + user_picked_lfn = any(k in opts for k in lfn_keys) + if CORE.data[KEY_ESP32].get(KEY_FATFS_REQUIRED, False): + if not user_picked_lfn: + set_opt("CONFIG_FATFS_LFN_NONE", False) + set_opt("CONFIG_FATFS_LFN_HEAP", True) + set_opt("CONFIG_FATFS_MAX_LFN", 255) + set_opt("CONFIG_FATFS_VOLUME_COUNT", 4) + elif disable_fatfs: + if not user_picked_lfn: + set_opt("CONFIG_FATFS_LFN_NONE", True) + # Kconfig range is [1,10]; 0 gets clamped to the default. + set_opt("CONFIG_FATFS_VOLUME_COUNT", 1) + + @coroutine_with_priority(CoroPriority.FINAL - 1) async def _finalize_arduino_aware_flags(): """Build flags that depend on whether arduino-esp32 is linked in. @@ -2603,47 +2659,6 @@ async def to_code(config): if advanced[CONF_DISABLE_LIBC_LOCKS_IN_IRAM]: add_idf_sdkconfig_option("CONFIG_LIBC_LOCKS_PLACE_IN_IRAM", False) - # Disable VFS support for termios (terminal I/O functions) - # USB Serial JTAG VFS functions require termios support. - # Components that need it (e.g., logger when USB_SERIAL_JTAG is supported but not selected - # as the logger output) call require_vfs_termios(). - # Saves approximately 1.8KB of flash when disabled (default). - if CORE.data.get(KEY_VFS_TERMIOS_REQUIRED, False): - # Component requires VFS termios - force enable regardless of user setting - add_idf_sdkconfig_option("CONFIG_VFS_SUPPORT_TERMIOS", True) - else: - # No component needs it - allow user to control (default: disabled) - add_idf_sdkconfig_option( - "CONFIG_VFS_SUPPORT_TERMIOS", not advanced[CONF_DISABLE_VFS_SUPPORT_TERMIOS] - ) - - # Disable VFS support for select() with file descriptors - # ESPHome only uses select() with sockets via lwip_select(), which still works. - # VFS select is only needed for UART/eventfd file descriptors. - # Components that need it (e.g., openthread) call require_vfs_select(). - # Saves approximately 2.7KB of flash when disabled (default). - if CORE.data.get(KEY_VFS_SELECT_REQUIRED, False): - # Component requires VFS select - force enable regardless of user setting - add_idf_sdkconfig_option("CONFIG_VFS_SUPPORT_SELECT", True) - else: - # No component needs it - allow user to control (default: disabled) - add_idf_sdkconfig_option( - "CONFIG_VFS_SUPPORT_SELECT", not advanced[CONF_DISABLE_VFS_SUPPORT_SELECT] - ) - - # Disable VFS support for directory functions (opendir, readdir, mkdir, etc.) - # ESPHome doesn't use directory functions on ESP32. - # Components that need it (e.g., storage components) call require_vfs_dir(). - # Saves approximately 0.5KB+ of flash when disabled (default). - if CORE.data.get(KEY_VFS_DIR_REQUIRED, False): - # Component requires VFS directory support - force enable regardless of user setting - add_idf_sdkconfig_option("CONFIG_VFS_SUPPORT_DIR", True) - else: - # No component needs it - allow user to control (default: disabled) - add_idf_sdkconfig_option( - "CONFIG_VFS_SUPPORT_DIR", not advanced[CONF_DISABLE_VFS_SUPPORT_DIR] - ) - if use_platformio: cg.add_platformio_option("board_build.partitions", "partitions.csv") if CONF_PARTITIONS in config: @@ -2878,6 +2893,16 @@ async def to_code(config): # FINAL priority: runs after every network/coexistence request_*() call CORE.add_job(_reconcile_network_sdkconfig) + # FINAL: require_*() calls can come from to_code at or below this priority, so an + # inline read would be iteration-order-dependent; reconcile once after every job ran. + CORE.add_job( + _reconcile_vfs_fatfs_sdkconfig, + advanced[CONF_DISABLE_VFS_SUPPORT_TERMIOS], + advanced[CONF_DISABLE_VFS_SUPPORT_SELECT], + advanced[CONF_DISABLE_VFS_SUPPORT_DIR], + advanced[CONF_DISABLE_FATFS], + ) + # Disable regi2c control functions in IRAM # Only needed if using analog peripherals (ADC, DAC, etc.) from ISRs while cache is disabled if advanced[CONF_DISABLE_REGI2C_IN_IRAM]: @@ -2893,17 +2918,6 @@ async def to_code(config): ): add_idf_sdkconfig_option("CONFIG_ADC_ONESHOT_CTRL_FUNC_IN_IRAM", True) - # Disable FATFS support - # Components that need FATFS (SD card, etc.) can call require_fatfs() - if CORE.data[KEY_ESP32].get(KEY_FATFS_REQUIRED, False): - # Component called require_fatfs() - enable regardless of user setting - add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", False) - add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 2) - elif advanced[CONF_DISABLE_FATFS]: - add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", True) - # Kconfig range is [1,10]; 0 gets clamped to the default. - add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 1) - for name, value in conf[CONF_SDKCONFIG_OPTIONS].items(): add_idf_sdkconfig_option(name, RawSdkconfigValue(value)) diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 5620a220f8..1fd835076d 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -10,11 +10,16 @@ from typing import Any import pytest from esphome.components.esp32 import ( + KEY_FATFS_REQUIRED, + KEY_VFS_DIR_REQUIRED, + KEY_VFS_SELECT_REQUIRED, + KEY_VFS_TERMIOS_REQUIRED, VARIANT_ESP32, VARIANTS, NetworkSdkconfigData, _ota_downgrade_protection_errors, _reconcile_network_sdkconfig, + _reconcile_vfs_fatfs_sdkconfig, ) from esphome.components.esp32.const import ( KEY_ESP32, @@ -614,6 +619,160 @@ def test_reconcile_network_sdkconfig( assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == expected +@pytest.mark.parametrize( + ("requires", "fatfs_required", "disables", "preset", "expected"), + [ + # Nothing required and every disable_* flag off (NOT the shipped defaults, which + # disable everything): VFS enabled, FATFS left untouched entirely. + pytest.param( + {}, + False, + (False, False, False, False), + {}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": True, + "CONFIG_VFS_SUPPORT_SELECT": True, + "CONFIG_VFS_SUPPORT_DIR": True, + }, + id="nothing_disabled_nothing_required", + ), + # The shipped out-of-the-box path: every disable_* flag defaults to True and nothing + # is required -- VFS off, FATFS at the smallest footprint (8.3 names, one volume). + pytest.param( + {}, + False, + (True, True, True, True), + {}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": False, + "CONFIG_VFS_SUPPORT_SELECT": False, + "CONFIG_VFS_SUPPORT_DIR": False, + "CONFIG_FATFS_LFN_NONE": True, + "CONFIG_FATFS_VOLUME_COUNT": 1, + }, + id="all_disabled_fatfs_fallback", + ), + # A component's require_* beats the user's disable_* flag for every VFS feature. + pytest.param( + { + KEY_VFS_TERMIOS_REQUIRED: True, + KEY_VFS_SELECT_REQUIRED: True, + KEY_VFS_DIR_REQUIRED: True, + }, + False, + (True, True, True, False), + {}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": True, + "CONFIG_VFS_SUPPORT_SELECT": True, + "CONFIG_VFS_SUPPORT_DIR": True, + }, + id="require_beats_disable", + ), + # A user sdkconfig_options preset wins over a require (the set_opt guard). + pytest.param( + {KEY_VFS_SELECT_REQUIRED: True}, + False, + (False, False, False, False), + {"CONFIG_VFS_SUPPORT_SELECT": False}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": True, + "CONFIG_VFS_SUPPORT_SELECT": False, + "CONFIG_VFS_SUPPORT_DIR": True, + }, + id="user_preset_wins_over_require", + ), + # require_fatfs() with no user preset: long filenames on the heap, 255 chars, + # four volumes. + pytest.param( + {}, + True, + (False, False, False, False), + {}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": True, + "CONFIG_VFS_SUPPORT_SELECT": True, + "CONFIG_VFS_SUPPORT_DIR": True, + "CONFIG_FATFS_LFN_NONE": False, + "CONFIG_FATFS_LFN_HEAP": True, + "CONFIG_FATFS_MAX_LFN": 255, + "CONFIG_FATFS_VOLUME_COUNT": 4, + }, + id="fatfs_required_defaults", + ), + # CONFIG_FATFS_LONG_FILENAMES is a Kconfig choice: a user picking any member + # (here LFN_STACK) leaves the whole group untouched -- no second =y in the choice. + pytest.param( + {}, + True, + (False, False, False, False), + {"CONFIG_FATFS_LFN_STACK": "y"}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": True, + "CONFIG_VFS_SUPPORT_SELECT": True, + "CONFIG_VFS_SUPPORT_DIR": True, + "CONFIG_FATFS_LFN_STACK": "y", + "CONFIG_FATFS_VOLUME_COUNT": 4, + }, + id="fatfs_user_lfn_stack_untouched", + ), + # disable_fatfs (the shipped default) with a user LFN pick: the choice group is the + # user's -- no LFN_NONE=y written next to their member, only the volume fallback. + pytest.param( + {}, + False, + (False, False, False, True), + {"CONFIG_FATFS_LFN_HEAP": "y"}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": True, + "CONFIG_VFS_SUPPORT_SELECT": True, + "CONFIG_VFS_SUPPORT_DIR": True, + "CONFIG_FATFS_LFN_HEAP": "y", + "CONFIG_FATFS_VOLUME_COUNT": 1, + }, + id="disable_fatfs_user_lfn_untouched", + ), + # Same for an explicit LFN_NONE preset: the group is the user's, only the volume + # count default is added. + pytest.param( + {}, + True, + (False, False, False, False), + {"CONFIG_FATFS_LFN_NONE": "y"}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": True, + "CONFIG_VFS_SUPPORT_SELECT": True, + "CONFIG_VFS_SUPPORT_DIR": True, + "CONFIG_FATFS_LFN_NONE": "y", + "CONFIG_FATFS_VOLUME_COUNT": 4, + }, + id="fatfs_user_lfn_none_untouched", + ), + ], +) +def test_reconcile_vfs_fatfs_sdkconfig( + set_core_config: SetCoreConfigCallable, + requires: dict[str, bool], + fatfs_required: bool, + disables: tuple[bool, bool, bool, bool], + preset: dict[str, Any], + expected: dict[str, Any], +) -> None: + """The FINAL-priority reconciler resolves the VFS feature flags and the FATFS + defaults from the recorded require_* calls, with user sdkconfig_options winning + and the LFN Kconfig choice treated as one group.""" + set_core_config(PlatformFramework.ESP32_IDF) + CORE.data[KEY_ESP32] = {KEY_SDKCONFIG_OPTIONS: dict(preset)} + if fatfs_required: + CORE.data[KEY_ESP32][KEY_FATFS_REQUIRED] = True + for key, value in requires.items(): + CORE.data[key] = value + + asyncio.run(_reconcile_vfs_fatfs_sdkconfig(*disables)) + + assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == expected + + def test_network_wifi_only_reconciles_end_to_end( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], From 77bfda6f1fcf59fc536393c96aaded65e0514e47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Fri, 7 Aug 2026 20:48:18 +0300 Subject: [PATCH 1307/1815] [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 2: atc_mithermometer, pvvx_mithermometer, bthome_mithermometer) (#17950) --- .../atc_mithermometer/atc_mithermometer.cpp | 8 +--- .../atc_mithermometer/atc_mithermometer.h | 12 ++---- .../components/atc_mithermometer/sensor.py | 13 +++--- .../bthome_mithermometer/__init__.py | 14 +++---- .../bthome_mithermometer/bthome_ble.cpp | 42 ++++++++----------- .../bthome_mithermometer/bthome_ble.h | 17 ++++---- .../components/bthome_mithermometer/sensor.py | 4 +- .../display/pvvx_display.cpp | 13 +++--- .../pvvx_mithermometer/display/pvvx_display.h | 16 +++---- .../pvvx_mithermometer/pvvx_mithermometer.cpp | 8 +--- .../pvvx_mithermometer/pvvx_mithermometer.h | 12 ++---- .../components/pvvx_mithermometer/sensor.py | 13 +++--- .../atc_mithermometer/common-ln.yaml | 7 ++++ .../components/atc_mithermometer/common.yaml | 3 ++ .../atc_mithermometer/test.ln882x-ard.yaml | 3 ++ .../validate-legacy-key.esp32-idf.yaml | 10 +++++ .../validate.bk72xx-ard.yaml | 17 ++++++++ .../bthome_mithermometer/common-ln.yaml | 9 ++++ .../bthome_mithermometer/common.yaml | 3 ++ .../bthome_mithermometer/test.ln882x-ard.yaml | 3 ++ .../validate.bk72xx-ard.yaml | 18 ++++++++ .../pvvx_mithermometer/common-ln.yaml | 8 ++++ .../components/pvvx_mithermometer/common.yaml | 3 ++ .../pvvx_mithermometer/test.ln882x-ard.yaml | 3 ++ .../validate.bk72xx-ard.yaml | 13 ++++++ 25 files changed, 177 insertions(+), 95 deletions(-) create mode 100644 tests/components/atc_mithermometer/common-ln.yaml create mode 100644 tests/components/atc_mithermometer/test.ln882x-ard.yaml create mode 100644 tests/components/atc_mithermometer/validate-legacy-key.esp32-idf.yaml create mode 100644 tests/components/atc_mithermometer/validate.bk72xx-ard.yaml create mode 100644 tests/components/bthome_mithermometer/common-ln.yaml create mode 100644 tests/components/bthome_mithermometer/test.ln882x-ard.yaml create mode 100644 tests/components/bthome_mithermometer/validate.bk72xx-ard.yaml create mode 100644 tests/components/pvvx_mithermometer/common-ln.yaml create mode 100644 tests/components/pvvx_mithermometer/test.ln882x-ard.yaml create mode 100644 tests/components/pvvx_mithermometer/validate.bk72xx-ard.yaml diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.cpp b/esphome/components/atc_mithermometer/atc_mithermometer.cpp index 7b5cdcfa20..22cb2b3150 100644 --- a/esphome/components/atc_mithermometer/atc_mithermometer.cpp +++ b/esphome/components/atc_mithermometer/atc_mithermometer.cpp @@ -1,8 +1,6 @@ #include "atc_mithermometer.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::atc_mithermometer { static const char *const TAG = "atc_mithermometer"; @@ -15,7 +13,7 @@ void ATCMiThermometer::dump_config() { LOG_SENSOR(" ", "Battery Voltage", this->battery_voltage_); } -bool ATCMiThermometer::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool ATCMiThermometer::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -52,7 +50,7 @@ bool ATCMiThermometer::parse_device(const esp32_ble_tracker::ESPBTDevice &device return success; } -optional ATCMiThermometer::parse_header_(const esp32_ble_tracker::ServiceData &service_data) { +optional ATCMiThermometer::parse_header_(const ble_device_base::ServiceData &service_data) { ParseResult result; if (!service_data.uuid.contains(0x1A, 0x18)) { ESP_LOGVV(TAG, "parse_header(): no service data UUID magic bytes."); @@ -132,5 +130,3 @@ bool ATCMiThermometer::report_results_(const optional &result, cons } } // namespace esphome::atc_mithermometer - -#endif diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.h b/esphome/components/atc_mithermometer/atc_mithermometer.h index 0f472c11b9..3f5ca4c784 100644 --- a/esphome/components/atc_mithermometer/atc_mithermometer.h +++ b/esphome/components/atc_mithermometer/atc_mithermometer.h @@ -2,12 +2,10 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include -#ifdef USE_ESP32 - namespace esphome::atc_mithermometer { struct ParseResult { @@ -18,11 +16,11 @@ struct ParseResult { int raw_offset; }; -class ATCMiThermometer final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class ATCMiThermometer final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } @@ -40,11 +38,9 @@ class ATCMiThermometer final : public Component, public esp32_ble_tracker::ESPBT uint8_t last_frame_count_{0}; - optional parse_header_(const esp32_ble_tracker::ServiceData &service_data); + optional parse_header_(const ble_device_base::ServiceData &service_data); bool parse_message_(const std::vector &message, ParseResult &result); bool report_results_(const optional &result, const char *address); }; } // namespace esphome::atc_mithermometer - -#endif diff --git a/esphome/components/atc_mithermometer/sensor.py b/esphome/components/atc_mithermometer/sensor.py index 5286d29d1b..5c2d75753c 100644 --- a/esphome/components/atc_mithermometer/sensor.py +++ b/esphome/components/atc_mithermometer/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -24,14 +24,15 @@ from esphome.const import ( CODEOWNERS = ["@ahpohl"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] atc_mithermometer_ns = cg.esphome_ns.namespace("atc_mithermometer") ATCMiThermometer = atc_mithermometer_ns.class_( - "ATCMiThermometer", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "ATCMiThermometer", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("atc_mithermometer"), cv.Schema( { cv.GenerateID(): cv.declare_id(ATCMiThermometer), @@ -71,15 +72,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/bthome_mithermometer/__init__.py b/esphome/components/bthome_mithermometer/__init__.py index 8ce216da22..4be7ca8268 100644 --- a/esphome/components/bthome_mithermometer/__init__.py +++ b/esphome/components/bthome_mithermometer/__init__.py @@ -1,24 +1,24 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker +from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_BINDKEY, CONF_ID, CONF_MAC_ADDRESS from esphome.core import HexInt CODEOWNERS = ["@nagyrobi"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] -BLE_DEVICE_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA bthome_mithermometer_ns = cg.esphome_ns.namespace("bthome_mithermometer") BTHomeMiThermometer = bthome_mithermometer_ns.class_( - "BTHomeMiThermometer", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "BTHomeMiThermometer", ble_device_base.ESPBTDeviceListener, cg.Component ) def bthome_mithermometer_base_schema(extra_schema=None): if extra_schema is None: extra_schema = {} - return ( + return cv.All( + ble_device_base.rename_legacy_hub_id("bthome_mithermometer"), cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(BTHomeMiThermometer), @@ -26,15 +26,15 @@ def bthome_mithermometer_base_schema(extra_schema=None): cv.Optional(CONF_BINDKEY): cv.bind_key, } ) - .extend(BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) .extend(extra_schema) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def setup_bthome_mithermometer(var, config): await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) if bindkey := config.get(CONF_BINDKEY): bindkey_bytes = [ diff --git a/esphome/components/bthome_mithermometer/bthome_ble.cpp b/esphome/components/bthome_mithermometer/bthome_ble.cpp index 66f147c266..1ebabea0a3 100644 --- a/esphome/components/bthome_mithermometer/bthome_ble.cpp +++ b/esphome/components/bthome_mithermometer/bthome_ble.cpp @@ -8,13 +8,20 @@ #include #include +// AES-CCM backend for encrypted-advertisement (bindkey) decryption: +// - ESP32 + ESP-IDF >= 6.0 -> PSA crypto (psa_aead_decrypt), hardware-backed. +// - every other platform -> the portable software AES-CCM in ble_device_base, so +// decryption never depends on the SDK exposing mbedtls/PSA to application code +// (e.g. LibreTiny beken-72xx keeps its mbedtls internal). Works on any BLE platform. #ifdef USE_ESP32 - #include #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) #include -#else -#include "mbedtls/ccm.h" +#define BTHOME_CRYPTO_PSA +#endif +#endif +#ifndef BTHOME_CRYPTO_PSA +#include "esphome/components/ble_device_base/ble_aes_ccm.h" #endif namespace esphome::bthome_mithermometer { @@ -157,7 +164,7 @@ void BTHomeMiThermometer::dump_config() { LOG_SENSOR(" ", "Signal Strength", this->signal_strength_); } -bool BTHomeMiThermometer::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool BTHomeMiThermometer::parse_device(const ble_device_base::ESPBTDevice &device) { bool matched = false; for (auto &service_data : device.get_service_datas()) { if (this->handle_service_data_(service_data, device)) { @@ -204,7 +211,7 @@ bool BTHomeMiThermometer::decrypt_bthome_payload_(const std::vector &da const uint8_t *ciphertext = data.data() + 1; const uint8_t *mic = data.data() + data.size() - BTHOME_MIC_SIZE; -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +#if defined(BTHOME_CRYPTO_PSA) // PSA AEAD expects ciphertext + tag concatenated // BLE advertisement max payload is 31 bytes, so this is always sufficient static constexpr size_t MAX_CT_WITH_TAG = 32; @@ -236,29 +243,18 @@ bool BTHomeMiThermometer::decrypt_bthome_payload_(const std::vector &da return false; } #else - mbedtls_ccm_context ctx; - mbedtls_ccm_init(&ctx); - - int ret = mbedtls_ccm_setkey(&ctx, MBEDTLS_CIPHER_ID_AES, this->bindkey_, BTHOME_BINDKEY_SIZE * 8); - if (ret) { - ESP_LOGVV(TAG, "mbedtls_ccm_setkey() failed."); - mbedtls_ccm_free(&ctx); - return false; - } - - ret = mbedtls_ccm_auth_decrypt(&ctx, ciphertext_size, nonce.data(), nonce.size(), nullptr, 0, ciphertext, - payload.data(), mic, BTHOME_MIC_SIZE); - mbedtls_ccm_free(&ctx); - if (ret) { - ESP_LOGVV(TAG, "BTHome decryption failed (ret=%d).", ret); + // Portable software AES-CCM (ble_device_base) — no SDK mbedtls/PSA dependency. + if (!ble_device_base::aes_ccm_auth_decrypt(this->bindkey_, nonce.data(), nonce.size(), nullptr, 0, ciphertext, + ciphertext_size, payload.data(), mic, BTHOME_MIC_SIZE)) { + ESP_LOGVV(TAG, "BTHome decryption failed."); return false; } #endif return true; } -bool BTHomeMiThermometer::handle_service_data_(const esp32_ble_tracker::ServiceData &service_data, - const esp32_ble_tracker::ESPBTDevice &device) { +bool BTHomeMiThermometer::handle_service_data_(const ble_device_base::ServiceData &service_data, + const ble_device_base::ESPBTDevice &device) { if (!service_data.uuid.contains(0xD2, 0xFC)) { return false; } @@ -439,5 +435,3 @@ bool BTHomeMiThermometer::handle_service_data_(const esp32_ble_tracker::ServiceD } } // namespace esphome::bthome_mithermometer - -#endif diff --git a/esphome/components/bthome_mithermometer/bthome_ble.h b/esphome/components/bthome_mithermometer/bthome_ble.h index 924858e449..4a95311557 100644 --- a/esphome/components/bthome_mithermometer/bthome_ble.h +++ b/esphome/components/bthome_mithermometer/bthome_ble.h @@ -1,6 +1,6 @@ #pragma once -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" @@ -8,11 +8,12 @@ #include #include -#ifdef USE_ESP32 - +// No platform #ifdef: ble_device_base provides the BLE types on every platform; this +// component is only compiled when configured (which requires a BLE hub). bindkey (AES-CCM) +// decryption availability is selected per platform in the .cpp. namespace esphome::bthome_mithermometer { -class BTHomeMiThermometer final : public esp32_ble_tracker::ESPBTDeviceListener, public Component { +class BTHomeMiThermometer final : public ble_device_base::ESPBTDeviceListener, public Component { public: void set_address(uint64_t address) { this->address_ = address; } void set_bindkey(std::initializer_list bindkey); @@ -24,11 +25,11 @@ class BTHomeMiThermometer final : public esp32_ble_tracker::ESPBTDeviceListener, void set_signal_strength(sensor::Sensor *signal_strength) { this->signal_strength_ = signal_strength; } void dump_config() override; - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; protected: - bool handle_service_data_(const esp32_ble_tracker::ServiceData &service_data, - const esp32_ble_tracker::ESPBTDevice &device); + bool handle_service_data_(const ble_device_base::ServiceData &service_data, + const ble_device_base::ESPBTDevice &device); bool decrypt_bthome_payload_(const std::vector &data, uint64_t source_address, std::vector &payload) const; @@ -45,5 +46,3 @@ class BTHomeMiThermometer final : public esp32_ble_tracker::ESPBTDeviceListener, }; } // namespace esphome::bthome_mithermometer - -#endif diff --git a/esphome/components/bthome_mithermometer/sensor.py b/esphome/components/bthome_mithermometer/sensor.py index 9b50866db0..02551391ad 100644 --- a/esphome/components/bthome_mithermometer/sensor.py +++ b/esphome/components/bthome_mithermometer/sensor.py @@ -23,9 +23,9 @@ from esphome.const import ( from . import bthome_mithermometer_base_schema, setup_bthome_mithermometer -CODEOWNERS = ["@nagyrobi"] +AUTO_LOAD = ["ble_device_base"] -DEPENDENCIES = ["esp32_ble_tracker"] +CODEOWNERS = ["@nagyrobi"] CONFIG_SCHEMA = bthome_mithermometer_base_schema( { diff --git a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp index 7a6be40d6c..64b8974901 100644 --- a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp +++ b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp @@ -1,16 +1,17 @@ -#include "pvvx_display.h" -#include "esphome/components/esp32_ble/ble_uuid.h" -#include "esphome/core/log.h" +#include "esphome/core/defines.h" #ifdef USE_ESP32 +#include "pvvx_display.h" +#include "esphome/components/ble_device_base/ble_device.h" +#include "esphome/core/log.h" namespace esphome::pvvx_mithermometer { static const char *const TAG = "display.pvvx_mithermometer"; void PVVXDisplay::dump_config() { - char service_buf[esp32_ble::UUID_STR_LEN]; - char char_buf[esp32_ble::UUID_STR_LEN]; + char service_buf[ble_device_base::UUID_STR_LEN]; + char char_buf[ble_device_base::UUID_STR_LEN]; ESP_LOGCONFIG(TAG, "PVVX MiThermometer display:\n" " MAC address : %s\n" @@ -188,4 +189,4 @@ void PVVXDisplay::sync_time_() { } // namespace esphome::pvvx_mithermometer -#endif +#endif // USE_ESP32 diff --git a/esphome/components/pvvx_mithermometer/display/pvvx_display.h b/esphome/components/pvvx_mithermometer/display/pvvx_display.h index d231111c58..c3f6028423 100644 --- a/esphome/components/pvvx_mithermometer/display/pvvx_display.h +++ b/esphome/components/pvvx_mithermometer/display/pvvx_display.h @@ -1,13 +1,16 @@ #pragma once -#include "esphome/core/component.h" #include "esphome/core/defines.h" + +#ifdef USE_ESP32 + +#include "esphome/core/component.h" #include "esphome/components/ble_client/ble_client.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/display/display.h" #include -#ifdef USE_ESP32 #include #ifdef USE_TIME #include "esphome/components/time/real_time_clock.h" @@ -121,14 +124,13 @@ class PVVXDisplay final : public ble_client::BLEClientNode, public PollingCompon uint16_t char_handle_ = 0; bool connection_established_ = false; - esp32_ble_tracker::ESPBTUUID service_uuid_ = - esp32_ble_tracker::ESPBTUUID::from_raw("00001f10-0000-1000-8000-00805f9b34fb"); - esp32_ble_tracker::ESPBTUUID char_uuid_ = - esp32_ble_tracker::ESPBTUUID::from_raw("00001f1f-0000-1000-8000-00805f9b34fb"); + ble_device_base::ESPBTUUID service_uuid_ = + ble_device_base::ESPBTUUID::from_raw("00001f10-0000-1000-8000-00805f9b34fb"); + ble_device_base::ESPBTUUID char_uuid_ = ble_device_base::ESPBTUUID::from_raw("00001f1f-0000-1000-8000-00805f9b34fb"); pvvx_writer_t writer_{}; }; } // namespace esphome::pvvx_mithermometer -#endif +#endif // USE_ESP32 diff --git a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp index f674fc3694..9141d12b16 100644 --- a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp +++ b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp @@ -1,8 +1,6 @@ #include "pvvx_mithermometer.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::pvvx_mithermometer { static const char *const TAG = "pvvx_mithermometer"; @@ -15,7 +13,7 @@ void PVVXMiThermometer::dump_config() { LOG_SENSOR(" ", "Battery Voltage", this->battery_voltage_); } -bool PVVXMiThermometer::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool PVVXMiThermometer::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -52,7 +50,7 @@ bool PVVXMiThermometer::parse_device(const esp32_ble_tracker::ESPBTDevice &devic return success; } -optional PVVXMiThermometer::parse_header_(const esp32_ble_tracker::ServiceData &service_data) { +optional PVVXMiThermometer::parse_header_(const ble_device_base::ServiceData &service_data) { ParseResult result; if (!service_data.uuid.contains(0x1A, 0x18)) { ESP_LOGVV(TAG, "parse_header(): no service data UUID magic bytes."); @@ -140,5 +138,3 @@ bool PVVXMiThermometer::report_results_(const optional &result, con } } // namespace esphome::pvvx_mithermometer - -#endif diff --git a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h index 382e41d210..7a2244207b 100644 --- a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h +++ b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h @@ -2,12 +2,10 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include -#ifdef USE_ESP32 - namespace esphome::pvvx_mithermometer { struct ParseResult { @@ -18,11 +16,11 @@ struct ParseResult { int raw_offset; }; -class PVVXMiThermometer final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class PVVXMiThermometer final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } @@ -40,11 +38,9 @@ class PVVXMiThermometer final : public Component, public esp32_ble_tracker::ESPB uint8_t last_frame_count_{0}; - optional parse_header_(const esp32_ble_tracker::ServiceData &service_data); + optional parse_header_(const ble_device_base::ServiceData &service_data); bool parse_message_(const std::vector &message, ParseResult &result); bool report_results_(const optional &result, const char *address); }; } // namespace esphome::pvvx_mithermometer - -#endif diff --git a/esphome/components/pvvx_mithermometer/sensor.py b/esphome/components/pvvx_mithermometer/sensor.py index da57c65341..ee5b19ea77 100644 --- a/esphome/components/pvvx_mithermometer/sensor.py +++ b/esphome/components/pvvx_mithermometer/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -24,14 +24,15 @@ from esphome.const import ( CODEOWNERS = ["@pasiz"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] pvvx_mithermometer_ns = cg.esphome_ns.namespace("pvvx_mithermometer") PVVXMiThermometer = pvvx_mithermometer_ns.class_( - "PVVXMiThermometer", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "PVVXMiThermometer", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("pvvx_mithermometer"), cv.Schema( { cv.GenerateID(): cv.declare_id(PVVXMiThermometer), @@ -71,15 +72,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/tests/components/atc_mithermometer/common-ln.yaml b/tests/components/atc_mithermometer/common-ln.yaml new file mode 100644 index 0000000000..78787099dc --- /dev/null +++ b/tests/components/atc_mithermometer/common-ln.yaml @@ -0,0 +1,7 @@ +sensor: + - platform: atc_mithermometer + mac_address: A4:C1:38:4E:16:78 + temperature: + name: ATC Temperature + humidity: + name: ATC Humidity diff --git a/tests/components/atc_mithermometer/common.yaml b/tests/components/atc_mithermometer/common.yaml index 0248090c23..c6da2fa173 100644 --- a/tests/components/atc_mithermometer/common.yaml +++ b/tests/components/atc_mithermometer/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: atc_mithermometer + ble_hub_id: ble_tracker_hub mac_address: A4:C1:38:4E:16:78 temperature: name: ATC Temperature diff --git a/tests/components/atc_mithermometer/test.ln882x-ard.yaml b/tests/components/atc_mithermometer/test.ln882x-ard.yaml new file mode 100644 index 0000000000..144ba0e3f8 --- /dev/null +++ b/tests/components/atc_mithermometer/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + atc_mithermometer: !include common-ln.yaml diff --git a/tests/components/atc_mithermometer/validate-legacy-key.esp32-idf.yaml b/tests/components/atc_mithermometer/validate-legacy-key.esp32-idf.yaml new file mode 100644 index 0000000000..44bc401caa --- /dev/null +++ b/tests/components/atc_mithermometer/validate-legacy-key.esp32-idf.yaml @@ -0,0 +1,10 @@ +# The esp32_ble_id -> ble_hub_id alias (removal 2027.2.0) still validates. +esp32_ble_tracker: + id: ble_tracker_hub + +sensor: + - platform: atc_mithermometer + esp32_ble_id: ble_tracker_hub + mac_address: A4:C1:38:4E:16:78 + temperature: + name: ATC Legacy Key Temperature diff --git a/tests/components/atc_mithermometer/validate.bk72xx-ard.yaml b/tests/components/atc_mithermometer/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..7528dbf8dd --- /dev/null +++ b/tests/components/atc_mithermometer/validate.bk72xx-ard.yaml @@ -0,0 +1,17 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_hub + +sensor: + - platform: atc_mithermometer + ble_hub_id: ble_hub + mac_address: A4:C1:38:4E:16:78 + temperature: + name: BK ATC Temperature + # No ble_hub_id: exercises the generated binding real configs use. + - platform: atc_mithermometer + mac_address: A4:C1:38:4E:16:79 + temperature: + name: BK ATC Implicit Temperature diff --git a/tests/components/bthome_mithermometer/common-ln.yaml b/tests/components/bthome_mithermometer/common-ln.yaml new file mode 100644 index 0000000000..947f8890f8 --- /dev/null +++ b/tests/components/bthome_mithermometer/common-ln.yaml @@ -0,0 +1,9 @@ +sensor: + - platform: bthome_mithermometer + mac_address: A4:C1:38:4E:16:78 + # bindkey compiles ble_device_base::aes_ccm_auth_decrypt off Espressif + bindkey: eef418daf699a0c188f3bfd17e4565d9 + temperature: + name: BTHome Temperature + humidity: + name: BTHome Humidity diff --git a/tests/components/bthome_mithermometer/common.yaml b/tests/components/bthome_mithermometer/common.yaml index 7a68fae966..d61738bbe5 100644 --- a/tests/components/bthome_mithermometer/common.yaml +++ b/tests/components/bthome_mithermometer/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: bthome_mithermometer + ble_hub_id: ble_tracker_hub mac_address: A4:C1:38:4E:16:78 bindkey: eef418daf699a0c188f3bfd17e4565d9 temperature: diff --git a/tests/components/bthome_mithermometer/test.ln882x-ard.yaml b/tests/components/bthome_mithermometer/test.ln882x-ard.yaml new file mode 100644 index 0000000000..d03ff4a4d2 --- /dev/null +++ b/tests/components/bthome_mithermometer/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + bthome_mithermometer: !include common-ln.yaml diff --git a/tests/components/bthome_mithermometer/validate.bk72xx-ard.yaml b/tests/components/bthome_mithermometer/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..4a50dd6e1a --- /dev/null +++ b/tests/components/bthome_mithermometer/validate.bk72xx-ard.yaml @@ -0,0 +1,18 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_hub + +sensor: + - platform: bthome_mithermometer + ble_hub_id: ble_hub + mac_address: A4:C1:38:4E:16:78 + bindkey: eef418daf699a0c188f3bfd17e4565d9 + temperature: + name: BK BTHome Temperature + # No ble_hub_id: exercises the generated binding real configs use. + - platform: bthome_mithermometer + mac_address: A4:C1:38:4E:16:79 + temperature: + name: BK BTHome Implicit Temperature diff --git a/tests/components/pvvx_mithermometer/common-ln.yaml b/tests/components/pvvx_mithermometer/common-ln.yaml new file mode 100644 index 0000000000..b40f8cfd53 --- /dev/null +++ b/tests/components/pvvx_mithermometer/common-ln.yaml @@ -0,0 +1,8 @@ +# Sensor only: the pvvx display is a GATT client and stays ESP32-only. +sensor: + - platform: pvvx_mithermometer + mac_address: A4:C1:38:4E:16:78 + temperature: + name: PVVX Temperature + humidity: + name: PVVX Humidity diff --git a/tests/components/pvvx_mithermometer/common.yaml b/tests/components/pvvx_mithermometer/common.yaml index 972f23122c..8e3e8284a6 100644 --- a/tests/components/pvvx_mithermometer/common.yaml +++ b/tests/components/pvvx_mithermometer/common.yaml @@ -3,6 +3,7 @@ wifi: password: password1 esp32_ble_tracker: + id: ble_tracker_hub ble_client: - mac_address: 01:02:03:04:05:06 @@ -26,7 +27,9 @@ display: it.print_battery(true); sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: pvvx_mithermometer + ble_hub_id: ble_tracker_hub mac_address: A4:C1:38:4E:16:78 temperature: name: PVVX Temperature diff --git a/tests/components/pvvx_mithermometer/test.ln882x-ard.yaml b/tests/components/pvvx_mithermometer/test.ln882x-ard.yaml new file mode 100644 index 0000000000..8bf34382d4 --- /dev/null +++ b/tests/components/pvvx_mithermometer/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + pvvx_mithermometer: !include common-ln.yaml diff --git a/tests/components/pvvx_mithermometer/validate.bk72xx-ard.yaml b/tests/components/pvvx_mithermometer/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..756ebdf79d --- /dev/null +++ b/tests/components/pvvx_mithermometer/validate.bk72xx-ard.yaml @@ -0,0 +1,13 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. The display platform is excluded: it needs ble_client +# (GATT), which only the esp32 tracker stack provides. +bk72xx_ble_tracker: + id: ble_hub + +sensor: + - platform: pvvx_mithermometer + ble_hub_id: ble_hub + mac_address: A4:C1:38:4E:16:78 + temperature: + name: BK PVVX Temperature From d7b5ad77daacd1b63ce7a888f6b23b4adb8a339f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 7 Aug 2026 12:53:09 -0500 Subject: [PATCH 1308/1815] [bluetooth_connection] Add BTstack GATT client backend for rp2 (#18131) --- .../ble_device_base/ble_client_state.h | 18 + esphome/components/ble_device_base/ble_hub.h | 2 +- .../bluetooth_connection/__init__.py | 20 +- .../bluetooth_connection_hub.cpp | 12 +- .../bluetooth_connection_rp2.cpp | 970 ++++++++++++++++++ .../bluetooth_connection_rp2.h | 206 ++++ .../components/bluetooth_proxy/__init__.py | 2 +- .../esp32_ble_client/ble_client_base.cpp | 22 +- .../rp2_ble_tracker/rp2_ble_tracker.h | 10 +- 9 files changed, 1238 insertions(+), 24 deletions(-) create mode 100644 esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp create mode 100644 esphome/components/bluetooth_connection/bluetooth_connection_rp2.h diff --git a/esphome/components/ble_device_base/ble_client_state.h b/esphome/components/ble_device_base/ble_client_state.h index 58f7d84fad..b0c91397fc 100644 --- a/esphome/components/ble_device_base/ble_client_state.h +++ b/esphome/components/ble_device_base/ble_client_state.h @@ -18,6 +18,24 @@ namespace esphome::ble_device_base { static constexpr int GATT_ERR_NOT_CONNECTED = -1; static constexpr int GATT_ERR_NO_MEMORY = -2; +// Preferred connection parameters shared by every platform's GATT client so +// the backends cannot drift (units: interval 1.25 ms, timeout 10 ms; latency +// 0). FAST covers connection setup and service discovery; MEDIUM is the +// steady state once established. Stack defaults (12.5-15 ms) are too slow for +// stable connections through WiFi-based BLE proxies, causing disconnections; +// MEDIUM balances responsiveness with bandwidth usage. +static constexpr uint16_t MEDIUM_MIN_CONN_INTERVAL = 0x07; // 7 * 1.25ms = 8.75ms +static constexpr uint16_t MEDIUM_MAX_CONN_INTERVAL = 0x09; // 9 * 1.25ms = 11.25ms +// The timeout value was increased from 6s to 8s to address stability issues observed +// in certain BLE devices when operating through WiFi-based BLE proxies. The longer +// timeout reduces the likelihood of disconnections during periods of high latency. +static constexpr uint16_t MEDIUM_CONN_TIMEOUT = 800; // 800 * 10ms = 8s + +// Fastest connection parameters for devices with short discovery timeouts +static constexpr uint16_t FAST_MIN_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms (BLE minimum) +static constexpr uint16_t FAST_MAX_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms +static constexpr uint16_t FAST_CONN_TIMEOUT = 1000; // 1000 * 10ms = 10s + enum class ClientState : uint8_t { // Connection is allocated INIT, diff --git a/esphome/components/ble_device_base/ble_hub.h b/esphome/components/ble_device_base/ble_hub.h index 3870e9833e..b6fcf6f57a 100644 --- a/esphome/components/ble_device_base/ble_hub.h +++ b/esphome/components/ble_device_base/ble_hub.h @@ -58,7 +58,7 @@ struct HubCapabilities { bool merges_scan_response; /// GATT client connections are available: the platform has a /// bluetooth_connection backend implementing ble_device_base::BLEGattConnection - /// (ble_gatt_client.h). Today: esp32; rp2 follows with its BTstack backend. + /// (ble_gatt_client.h). Today: esp32 and rp2. bool gatt; /// request_scan_mode() is honored at runtime. Distinct from active_scan: /// a passive-only controller (bk72xx) can never switch, and a hub may diff --git a/esphome/components/bluetooth_connection/__init__.py b/esphome/components/bluetooth_connection/__init__.py index d4d0a3c3af..1dc1969a6a 100644 --- a/esphome/components/bluetooth_connection/__init__.py +++ b/esphome/components/bluetooth_connection/__init__.py @@ -1,14 +1,15 @@ """Per-platform GATT connection backends the Bluetooth proxy drives. -Auto-loaded by bluetooth_proxy, no user-facing configuration; the proxy's -codegen declares and registers the connection instances. +Backends: esp32 Bluedroid, rp2 BTstack. Auto-loaded by bluetooth_proxy, no +user-facing configuration; the proxy's codegen declares and registers the +connection instances. """ import functools import esphome.codegen as cg from esphome.config_helpers import filter_source_files_from_platform -from esphome.const import PlatformFramework +from esphome.const import PLATFORM_RP2, PlatformFramework from esphome.core import CORE @@ -24,9 +25,17 @@ CODEOWNERS = ["@bdraco", "@jesserockz"] bluetooth_connection_ns = cg.esphome_ns.namespace("bluetooth_connection") -# The hub-platform wrapper codegen class (drives a ble_device_base -# BLEGattConnection backend; see bluetooth_connection_hub.h). +# arduino-pico's prebuilt BTstack is compiled with MAX_NR_GATT_CLIENTS 1; +# raising this needs an upstream change (the layer itself supports N). +RP2_MAX_CONNECTIONS = 1 + +# Hub platforms with a GATT backend, mapped to their slot limit — the single +# registry of which hub platforms run the connection-capable proxy. +HUB_MAX_CONNECTIONS: dict[str, int] = {PLATFORM_RP2: RP2_MAX_CONNECTIONS} + +# The hub-platform wrapper and the rp2 BTstack backend codegen classes. HubBluetoothConnection = bluetooth_connection_ns.class_("BluetoothConnection") +RP2GattClient = bluetooth_connection_ns.class_("RP2GattClient", cg.Component) @functools.cache @@ -53,5 +62,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.RP2_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, + "bluetooth_connection_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, } ) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index 338bf671a8..37d6b21dfe 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -117,8 +117,18 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int if (connected) { this->mtu_ = mtu; if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) { - // The API client has the services cached; never discover them. + // The API client has the services cached; never discover them. No + // discovery phase needs the fast interval, so settle straight into the + // shared steady-state parameters (same lifecycle place as esp32). this->state_ = ClientState::ESTABLISHED; + int param_err = this->backend_->update_connection_params(ble_device_base::MEDIUM_MIN_CONN_INTERVAL, + ble_device_base::MEDIUM_MAX_CONN_INTERVAL, 0, + ble_device_base::MEDIUM_CONN_TIMEOUT); + if (param_err != 0) { + // Survivable: the link just stays on the fast interval. + ESP_LOGW(TAG, "[%d] [%s] conn param update failed, err=%d", this->connection_index_, this->address_str_, + param_err); + } this->proxy_->send_device_connection(this->address_, true, mtu); this->proxy_->send_connections_free(); return; diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp new file mode 100644 index 0000000000..ecd7a9713a --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -0,0 +1,970 @@ +#include "bluetooth_connection_rp2.h" + +#if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include + +#include +#include + +namespace esphome::bluetooth_connection { + +static const char *const TAG = "bluetooth_connection.rp2"; + +using ble_device_base::ESPBTUUID; +using ble_device_base::GATT_ERR_NOT_CONNECTED; +using ble_device_base::GATT_ERR_NO_MEMORY; + +// Engine-owned timeouts: BTstack has a 30 s ATT transaction timeout but no +// connect timeout — a stuck LE_CONNECTING both blocks future gap_connect calls +// and keeps the scan inhibited, so the engine cancels after 20 s. The +// disconnect timeout mirrors the esp32 CLOSE_EVT safety net. +static constexpr uint32_t CONNECT_TIMEOUT_MS = 20000; +static constexpr uint32_t DISCONNECT_TIMEOUT_MS = 10000; + +// HCI "connection timeout" reason, reported when a teardown had to be forced. +static constexpr uint8_t HCI_REASON_CONNECTION_TIMEOUT = 0x08; + +// Initiating-scan parameters and connection-event lengths for outgoing +// connections (BTstack-specific knobs; the connection intervals themselves are +// the shared FAST/MEDIUM parameters from ble_device_base/ble_client_state.h, +// used in the same lifecycle places as esp32: FAST for connect and service +// discovery, MEDIUM once established). +static constexpr uint16_t CONN_SCAN_INTERVAL = 96; // 60 ms in 0.625 ms units +static constexpr uint16_t CONN_SCAN_WINDOW = 48; // 30 ms in 0.625 ms units +static constexpr uint16_t CONN_CE_MIN = 16; // 10 ms in 0.625 ms units +static constexpr uint16_t CONN_CE_MAX = 48; // 30 ms in 0.625 ms units + +using ble_device_base::FAST_CONN_TIMEOUT; +using ble_device_base::FAST_MAX_CONN_INTERVAL; +using ble_device_base::FAST_MIN_CONN_INTERVAL; +using ble_device_base::MEDIUM_CONN_TIMEOUT; +using ble_device_base::MEDIUM_MAX_CONN_INTERVAL; +using ble_device_base::MEDIUM_MIN_CONN_INTERVAL; + +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) +RP2GattClient *RP2GattClient::instances[ESPHOME_BLE_GATT_CLIENT_COUNT] = {}; +uint8_t RP2GattClient::instance_count = 0; +btstack_packet_callback_registration_t RP2GattClient::hci_event_registration = {}; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) + +static ESPBTUUID uuid_from_btstack(uint16_t uuid16, const uint8_t uuid128[16]) { + if (uuid16 != 0) { + return ESPBTUUID::from_uint16(uuid16); + } + // BTstack structs carry the 128-bit form big-endian (printable order). + return ESPBTUUID::from_raw_reversed(uuid128); +} + +void RP2GattClient::setup() { + // Pre-create every pool entry so the packet handlers' allocate() calls are + // always a free-list pop -- the IRQ path must never reach malloc(). + if (!this->event_pool_.warm() || !this->notify_pool_.warm()) { + ESP_LOGE(TAG, "GATT event pool warm-up failed"); + this->mark_failed(); + return; + } + + // Register this engine for IRQ-context event routing. + if (instance_count >= ESPHOME_BLE_GATT_CLIENT_COUNT) { + // Cannot happen with codegen-sized storage; refuse loudly if it ever does. + ESP_LOGE(TAG, "GATT client registry full"); + this->mark_failed(); + return; + } + { + // One locked section: the slot store lands before the count bump, and a + // live HCI handler (N > 1 builds) cannot read a half-written registry. + BluetoothLock lock; + instances[instance_count] = this; + instance_count++; + // One HCI event handler for all engine instances (BTstack supports + // multiple registrations, so rp2040_ble's own handler is unaffected). + if (hci_event_registration.callback == nullptr) { + hci_event_registration.callback = &RP2GattClient::hci_packet_handler; + hci_add_event_handler(&hci_event_registration); + } + } + + this->disable_loop(); +} + +float RP2GattClient::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } + +void RP2GattClient::dump_config() { ESP_LOGCONFIG(TAG, "RP2 GATT client (BTstack)"); } + +// ---- IRQ-context handlers: copy-and-enqueue only ---- + +RP2GattClient *RP2GattClient::instance_for_con_handle(hci_con_handle_t con_handle) { + for (uint8_t i = 0; i < instance_count; i++) { + if (instances[i]->con_handle_ == con_handle) { + return instances[i]; + } + } + return nullptr; +} + +void RP2GattClient::hci_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size) { + if (type != HCI_EVENT_PACKET) { + return; + } + uint8_t event_type = hci_event_packet_get_type(packet); + switch (event_type) { + case HCI_EVENT_META_GAP: { + if (hci_event_gap_meta_get_subevent_code(packet) != GAP_SUBEVENT_LE_CONNECTION_COMPLETE) { + break; + } + bd_addr_t peer; + gap_subevent_le_connection_complete_get_peer_address(packet, peer); + uint8_t status = gap_subevent_le_connection_complete_get_status(packet); + hci_con_handle_t con_handle = gap_subevent_le_connection_complete_get_connection_handle(packet); + // Route to the engine that is waiting for this peer. + for (uint8_t i = 0; i < instance_count; i++) { + RP2GattClient *inst = instances[i]; + if (inst->state_ == EngineState::CONNECTING && memcmp(inst->peer_addr_, peer, sizeof(bd_addr_t)) == 0) { + inst->enqueue_event_irq_(RP2GattEvent::CONNECTED, status, con_handle); + break; + } + } + break; + } + case HCI_EVENT_DISCONNECTION_COMPLETE: { + hci_con_handle_t con_handle = hci_event_disconnection_complete_get_connection_handle(packet); + RP2GattClient *inst = instance_for_con_handle(con_handle); + if (inst == nullptr && instance_count == 1) { + // The main loop may not have recorded the handle yet (the CONNECTED + // event is still queued); with a single engine the connecting + // instance is unambiguous, so route there to close the + // accept-then-drop window. With multiple engines the event has no + // address to match on, so it must be dropped instead of guessed. + RP2GattClient *candidate = instances[0]; + if (candidate->con_handle_ == HCI_CON_HANDLE_INVALID && candidate->state_ != EngineState::IDLE) { + inst = candidate; + } + } + if (inst != nullptr) { + inst->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, hci_event_disconnection_complete_get_reason(packet), 0); + } + break; + } + default: + break; + } +} + +void RP2GattClient::gatt_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size) { + if (type != HCI_EVENT_PACKET) { + return; + } + uint8_t event_type = hci_event_packet_get_type(packet); + // Every GATT event carries the connection handle in the same position via + // its accessor; route on it. + hci_con_handle_t con_handle; + switch (event_type) { + case GATT_EVENT_MTU: + con_handle = gatt_event_mtu_get_handle(packet); + break; + case GATT_EVENT_SERVICE_QUERY_RESULT: + con_handle = gatt_event_service_query_result_get_handle(packet); + break; + case GATT_EVENT_CHARACTERISTIC_QUERY_RESULT: + con_handle = gatt_event_characteristic_query_result_get_handle(packet); + break; + case GATT_EVENT_ALL_CHARACTERISTIC_DESCRIPTORS_QUERY_RESULT: + con_handle = gatt_event_all_characteristic_descriptors_query_result_get_handle(packet); + break; + case GATT_EVENT_CHARACTERISTIC_VALUE_QUERY_RESULT: + con_handle = gatt_event_characteristic_value_query_result_get_handle(packet); + break; + case GATT_EVENT_CHARACTERISTIC_DESCRIPTOR_QUERY_RESULT: + con_handle = gatt_event_characteristic_descriptor_query_result_get_handle(packet); + break; + case GATT_EVENT_NOTIFICATION: + con_handle = gatt_event_notification_get_handle(packet); + break; + case GATT_EVENT_INDICATION: + con_handle = gatt_event_indication_get_handle(packet); + break; + case GATT_EVENT_QUERY_COMPLETE: + con_handle = gatt_event_query_complete_get_handle(packet); + break; + default: + return; + } + RP2GattClient *inst = instance_for_con_handle(con_handle); + if (inst != nullptr) { + inst->handle_gatt_event_irq_(event_type, packet); + } +} + +void RP2GattClient::handle_gatt_event_irq_(uint8_t event_type, const uint8_t *packet) { + switch (event_type) { + case GATT_EVENT_MTU: + this->enqueue_event_irq_(RP2GattEvent::MTU_EXCHANGED, 0, gatt_event_mtu_get_MTU(packet)); + break; + case GATT_EVENT_QUERY_COMPLETE: + this->enqueue_event_irq_(RP2GattEvent::QUERY_COMPLETE, gatt_event_query_complete_get_att_status(packet), 0); + break; + case GATT_EVENT_SERVICE_QUERY_RESULT: { + if (this->arena_ == nullptr) { + break; + } + if (this->service_count_ >= RP2_GATT_MAX_SERVICES) { + this->truncated_ = true; + break; + } + gatt_client_service_t service; + gatt_event_service_query_result_get_service(packet, &service); + auto &dst = this->arena_->services[this->service_count_]; + dst.uuid = uuid_from_btstack(service.uuid16, service.uuid128); + dst.start_handle = service.start_group_handle; + dst.end_handle = service.end_group_handle; + dst.first_characteristic = 0; + dst.characteristic_count = 0; + this->service_count_++; + break; + } + case GATT_EVENT_CHARACTERISTIC_QUERY_RESULT: { + if (this->arena_ == nullptr) { + break; + } + if (this->char_count_ >= RP2_GATT_MAX_CHARACTERISTICS) { + this->truncated_ = true; + break; + } + gatt_client_characteristic_t characteristic; + gatt_event_characteristic_query_result_get_characteristic(packet, &characteristic); + auto &dst = this->arena_->characteristics[this->char_count_]; + dst.uuid = uuid_from_btstack(characteristic.uuid16, characteristic.uuid128); + dst.value_handle = characteristic.value_handle; + dst.end_handle = characteristic.end_handle; + dst.properties = static_cast(characteristic.properties); + dst.first_descriptor = 0; + dst.descriptor_count = 0; + this->char_count_++; + break; + } + case GATT_EVENT_ALL_CHARACTERISTIC_DESCRIPTORS_QUERY_RESULT: { + if (this->arena_ == nullptr) { + break; + } + if (this->desc_count_ >= RP2_GATT_MAX_DESCRIPTORS) { + this->truncated_ = true; + break; + } + gatt_client_characteristic_descriptor_t descriptor; + gatt_event_all_characteristic_descriptors_query_result_get_characteristic_descriptor(packet, &descriptor); + auto &dst = this->arena_->descriptors[this->desc_count_]; + dst.uuid = uuid_from_btstack(descriptor.uuid16, descriptor.uuid128); + dst.handle = descriptor.handle; + this->desc_count_++; + break; + } + case GATT_EVENT_CHARACTERISTIC_VALUE_QUERY_RESULT: { + uint16_t len = gatt_event_characteristic_value_query_result_get_value_length(packet); + if (len > RP2_GATT_MAX_ATTR_LEN) { + len = RP2_GATT_MAX_ATTR_LEN; + } + memcpy(this->op_buffer_, gatt_event_characteristic_value_query_result_get_value(packet), len); + this->op_len_ = len; + break; + } + case GATT_EVENT_CHARACTERISTIC_DESCRIPTOR_QUERY_RESULT: { + uint16_t len = gatt_event_characteristic_descriptor_query_result_get_descriptor_length(packet); + if (len > RP2_GATT_MAX_ATTR_LEN) { + len = RP2_GATT_MAX_ATTR_LEN; + } + memcpy(this->op_buffer_, gatt_event_characteristic_descriptor_query_result_get_descriptor(packet), len); + this->op_len_ = len; + break; + } + case GATT_EVENT_NOTIFICATION: + this->enqueue_notify_irq_(gatt_event_notification_get_value_handle(packet), + gatt_event_notification_get_value(packet), + gatt_event_notification_get_value_length(packet)); + break; + case GATT_EVENT_INDICATION: + // BTstack auto-confirms indications; deliver like a notification. + this->enqueue_notify_irq_(gatt_event_indication_get_value_handle(packet), gatt_event_indication_get_value(packet), + gatt_event_indication_get_value_length(packet)); + break; + default: + break; + } +} + +// NOLINTBEGIN(clang-analyzer-unix.Malloc) +void RP2GattClient::enqueue_event_irq_(RP2GattEvent::Type type, uint8_t status, uint16_t value) { + RP2GattEvent *event = this->event_pool_.allocate(); + if (event == nullptr) { + this->event_queue_.increment_dropped_count(); + return; + } + event->type = type; + event->status = status; + event->value = value; + this->event_queue_.push(event); +} + +void RP2GattClient::enqueue_notify_irq_(uint16_t handle, const uint8_t *data, uint16_t len) { + RP2GattNotifyEvent *event = this->notify_pool_.allocate(); + if (event == nullptr) { + this->notify_queue_.increment_dropped_count(); + return; + } + event->handle = handle; + event->len = len > RP2_GATT_MAX_ATTR_LEN ? RP2_GATT_MAX_ATTR_LEN : len; + memcpy(event->data, data, event->len); + this->notify_queue_.push(event); +} +// NOLINTEND(clang-analyzer-unix.Malloc) + +// ---- Main-loop state machine ---- + +void RP2GattClient::loop() { + RP2GattEvent *event; + while ((event = this->event_queue_.pop()) != nullptr) { + RP2GattEvent copy = *event; + this->event_pool_.release(event); + this->handle_event_(copy); + } + + RP2GattNotifyEvent *notify; + while ((notify = this->notify_queue_.pop()) != nullptr) { + if (this->listener_ != nullptr && this->notify_subscribed_(notify->handle)) { + this->listener_->on_notify_data(notify->handle, notify->data, notify->len); + } + this->notify_pool_.release(notify); + } + + uint16_t dropped = this->event_queue_.get_and_reset_dropped_count(); + if (dropped > 0) { + // Control events must not be lost; the connection state is no longer + // trustworthy — recover with a forced teardown. + ESP_LOGE(TAG, "Dropped %u GATT control events, disconnecting", dropped); + this->disconnect(); + } + uint16_t notify_dropped = this->notify_queue_.get_and_reset_dropped_count(); + if (notify_dropped > 0) { + ESP_LOGW(TAG, "Dropped %u GATT notifications (queue full)", notify_dropped); + } + + if (this->state_ == EngineState::CONNECTING || this->state_ == EngineState::MTU_EXCHANGE) { + uint32_t now = millis(); + if (now - this->connect_started_ > CONNECT_TIMEOUT_MS) { + ESP_LOGW(TAG, "Connect timeout"); + if (this->state_ == EngineState::CONNECTING && this->con_handle_ == HCI_CON_HANDLE_INVALID) { + if (!this->connect_cancel_attempted_) { + this->connect_cancel_attempted_ = true; + BluetoothLock lock; + gap_connect_cancel(); + // The cancel produces a connection-complete event with a failure + // status, which drives the normal failure path; restart the timer + // so a lost event escalates below instead of wedging here. + this->connect_started_ = now; + } else { + // The cancel's completion never arrived: reclaim the slot and the + // scan rather than cancelling forever. + this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT); + } + } else { + // The link is up (MTU exchange stalled): tear it down properly so the + // controller frees its side; the DISCONNECTING safety net below + // reclaims state if the disconnection event is lost. Dropping engine + // state without gap_disconnect would leak the live link and the + // single GATT slot for the rest of the boot. + this->disconnect(); + } + } + } else if (this->state_ == EngineState::DISCONNECTING) { + if (millis() - this->disconnecting_started_ > DISCONNECT_TIMEOUT_MS) { + ESP_LOGW(TAG, "Disconnect timeout, forcing idle"); + this->handle_disconnected_(HCI_REASON_CONNECTION_TIMEOUT); + } + } else if (this->state_ == EngineState::IDLE) { + this->disable_loop(); + } +} + +void RP2GattClient::handle_event_(const RP2GattEvent &event) { + switch (event.type) { + case RP2GattEvent::CONNECTED: + this->handle_connected_(event.status, event.value); + break; + case RP2GattEvent::DISCONNECTED: + this->handle_disconnected_(event.status); + break; + case RP2GattEvent::MTU_EXCHANGED: + if (this->state_ == EngineState::MTU_EXCHANGE) { + this->mtu_ = event.value; + ESP_LOGD(TAG, "MTU %u", this->mtu_); + this->state_ = EngineState::READY; + // Scanning resumes and runs alongside the established connection. + this->release_scan_inhibit_(); + if (this->listener_ != nullptr) { + this->listener_->on_connection_state(true, this->mtu_, 0); + } + } + break; + case RP2GattEvent::QUERY_COMPLETE: + this->handle_query_complete_(event.status); + break; + } +} + +void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) { + if (this->state_ != EngineState::CONNECTING) { + return; + } + if (status != 0) { + ESP_LOGW(TAG, "Connect failed, status=0x%02x", status); + this->fail_connection_(status); + return; + } + if (this->cancel_requested_) { + // A disconnect request raced the connection complete and lost; finish + // the teardown instead of reporting a connection nobody wants. + this->con_handle_ = con_handle; + this->state_ = EngineState::DISCONNECTING; + this->disconnecting_started_ = millis(); + // No more initiating: give the radio back to the scanner during teardown. + this->release_scan_inhibit_(); + uint8_t disc_status; + { + BluetoothLock lock; + disc_status = gap_disconnect(this->con_handle_); + } + if (disc_status != 0) { + this->handle_disconnected_(HCI_REASON_CONNECTION_TIMEOUT); + } + return; + } + this->con_handle_ = con_handle; + this->state_ = EngineState::MTU_EXCHANGE; + ESP_LOGD(TAG, "Link up, handle=0x%04x, negotiating MTU", con_handle); + BluetoothLock lock; + // One wildcard listener covers notifications/indications for every + // characteristic on this connection; the CCCD writes come from the API + // client as plain descriptor writes. + gatt_client_listen_for_characteristic_value_updates(&this->notification_registration_, + &RP2GattClient::gatt_packet_handler, this->con_handle_, nullptr); + // Auto MTU negotiation is disabled (see rp2040_ble enable hooks), so the + // exchange is kicked explicitly; GATT_EVENT_MTU completes it. Without the + // explicit kick the MTU would only be exchanged on the first GATT query, + // which never happens on a V3_WITH_CACHE connection. + // Both registration calls above return void (BTstack 075a078, arduino-pico + // 6.0.0); failures surface as a missing GATT_EVENT_MTU and are reclaimed by + // the connect timeout in loop(). + gatt_client_send_mtu_negotiation(&RP2GattClient::gatt_packet_handler, this->con_handle_); +} + +void RP2GattClient::release_scan_inhibit_() { + if (this->holds_scan_inhibit_) { + this->holds_scan_inhibit_ = false; + this->parent_->release_scan_inhibit(); + } +} + +void RP2GattClient::fail_connection_(uint8_t reason) { + this->cleanup_link_state_(); + this->release_scan_inhibit_(); + this->state_ = EngineState::IDLE; + if (this->listener_ != nullptr) { + this->listener_->on_connection_state(false, 0, reason); + } +} + +void RP2GattClient::cleanup_link_state_() { + // Drop notifications queued behind the disconnect so they cannot emit + // against a freed slot (address 0) on the next loop. + RP2GattNotifyEvent *stale; + while ((stale = this->notify_queue_.pop()) != nullptr) { + this->notify_pool_.release(stale); + } + // The wildcard listener is registered on the normal connect path right + // after con_handle_ is recorded; the cancel branch tears down before + // registering, where stop_listening on an unregistered entry is a no-op. + if (this->con_handle_ != HCI_CON_HANDLE_INVALID) { + BluetoothLock lock; + gatt_client_stop_listening_for_characteristic_value_updates(&this->notification_registration_); + } + this->con_handle_ = HCI_CON_HANDLE_INVALID; + this->notify_subscription_count_ = 0; + this->cancel_requested_ = false; + this->op_type_ = OpType::NONE; + this->discovery_phase_ = DiscoveryPhase::NONE; + this->release_services(); +} + +void RP2GattClient::handle_disconnected_(uint8_t reason) { + if (this->state_ == EngineState::IDLE) { + return; + } + ESP_LOGD(TAG, "Disconnected, reason=0x%02x", reason); + this->fail_connection_(reason); +} + +void RP2GattClient::handle_query_complete_(uint8_t att_status) { + // Stale completions cannot cross connections: the loop drains the whole + // event queue every iteration, teardown resets op/discovery state, and a + // new discovery is only issued after the new link's MTU event — which in + // this BTstack emits no QUERY_COMPLETE (the MTU state machine is separate + // from the query state machine). Completions with nothing in flight are + // dropped below. + if (this->op_type_ != OpType::NONE) { + OpType op = this->op_type_; + this->op_type_ = OpType::NONE; + if (this->listener_ == nullptr) { + return; + } + switch (op) { + case OpType::READ_CHAR: + case OpType::READ_DESC: + this->listener_->on_read_result(this->op_handle_, this->op_buffer_, att_status == 0 ? this->op_len_ : 0, + att_status); + break; + case OpType::WRITE_CHAR: + case OpType::WRITE_DESC: + this->listener_->on_write_result(this->op_handle_, att_status); + break; + default: + break; + } + return; + } + if (this->discovery_phase_ != DiscoveryPhase::NONE) { + this->advance_discovery_(att_status); + } +} + +// ---- Service discovery ---- + +int RP2GattClient::discover_services() { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + if (this->op_in_flight_()) { + return GATT_CLIENT_IN_WRONG_STATE; + } + if (this->arena_ == nullptr) { + // Transient: freed in release_services() right after the table streams + // to the API client (mirrors Bluedroid's own per-connection GATT DB + // lifetime on esp32). Checked: a fragmented heap must surface as a + // stack error the proxy can report, not a device reset. + RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); + this->arena_ = allocator.allocate(1); + if (this->arena_ == nullptr) { + ESP_LOGE(TAG, "Service table allocation failed"); + return ble_device_base::GATT_ERR_NO_MEMORY; + } + new (this->arena_) ServiceArena(); + } + this->service_count_ = 0; + this->char_count_ = 0; + this->desc_count_ = 0; + this->truncated_ = false; + this->discovery_phase_ = DiscoveryPhase::SERVICES; + BluetoothLock lock; + uint8_t status = gatt_client_discover_primary_services(&RP2GattClient::gatt_packet_handler, this->con_handle_); + if (status != 0) { + this->discovery_phase_ = DiscoveryPhase::NONE; + this->release_services(); + return status; + } + return 0; +} + +int RP2GattClient::issue_characteristic_query_(uint16_t service_index) { + auto &service = this->arena_->services[service_index]; + gatt_client_service_t btstack_service = {}; + btstack_service.start_group_handle = service.start_handle; + btstack_service.end_group_handle = service.end_handle; + service.first_characteristic = this->char_count_; + BluetoothLock lock; + return gatt_client_discover_characteristics_for_service(&RP2GattClient::gatt_packet_handler, this->con_handle_, + &btstack_service); +} + +int RP2GattClient::issue_descriptor_query_(uint16_t char_index) { + auto &chr = this->arena_->characteristics[char_index]; + gatt_client_characteristic_t btstack_characteristic = {}; + btstack_characteristic.value_handle = chr.value_handle; + btstack_characteristic.end_handle = chr.end_handle; + chr.first_descriptor = this->desc_count_; + BluetoothLock lock; + return gatt_client_discover_characteristic_descriptors(&RP2GattClient::gatt_packet_handler, this->con_handle_, + &btstack_characteristic); +} + +void RP2GattClient::advance_discovery_(uint8_t att_status) { + if (this->arena_ == nullptr) { + // release_services() is publicly callable; a table freed mid-discovery + // must end the discovery instead of dereferencing a null arena. + this->finish_discovery_(GATT_ERR_NOT_CONNECTED); + return; + } + if (att_status != 0) { + this->finish_discovery_(att_status); + return; + } + switch (this->discovery_phase_) { + case DiscoveryPhase::SERVICES: + if (this->service_count_ == 0) { + this->finish_discovery_(0); + return; + } + this->discovery_phase_ = DiscoveryPhase::CHARACTERISTICS; + this->disc_service_cursor_ = 0; + if (int err = this->issue_characteristic_query_(0); err != 0) { + this->finish_discovery_(err); + } + break; + case DiscoveryPhase::CHARACTERISTICS: { + auto &service = this->arena_->services[this->disc_service_cursor_]; + service.characteristic_count = this->char_count_ - service.first_characteristic; + this->disc_service_cursor_++; + if (this->disc_service_cursor_ < this->service_count_) { + if (int err = this->issue_characteristic_query_(this->disc_service_cursor_); err != 0) { + this->finish_discovery_(err); + } + return; + } + if (this->char_count_ == 0) { + this->finish_discovery_(0); + return; + } + this->discovery_phase_ = DiscoveryPhase::DESCRIPTORS; + this->disc_char_cursor_ = 0; + if (int err = this->issue_descriptor_query_(0); err != 0) { + this->finish_discovery_(err); + } + break; + } + case DiscoveryPhase::DESCRIPTORS: { + auto &chr = this->arena_->characteristics[this->disc_char_cursor_]; + chr.descriptor_count = this->desc_count_ - chr.first_descriptor; + this->disc_char_cursor_++; + if (this->disc_char_cursor_ < this->char_count_) { + if (int err = this->issue_descriptor_query_(this->disc_char_cursor_); err != 0) { + this->finish_discovery_(err); + } + return; + } + this->finish_discovery_(0); + break; + } + default: + break; + } +} + +void RP2GattClient::finish_discovery_(int error) { + this->discovery_phase_ = DiscoveryPhase::NONE; + ESP_LOGD(TAG, "Discovery done (err=%d): %u services, %u characteristics, %u descriptors", error, this->service_count_, + this->char_count_, this->desc_count_); + if (error == 0 && this->truncated_) { + // A partial table must not stream: V3 clients cache the database + // permanently, so an incomplete one would be wrong forever. + error = ATT_ERROR_INSUFFICIENT_RESOURCES; + } + if (error == 0) { + // Discovery no longer needs the fast interval; settle into the shared + // steady-state parameters (same lifecycle place as esp32). Status + // discarded: BTstack fails this only for an already-gone handle. + BluetoothLock lock; + gap_update_connection_parameters(this->con_handle_, MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, + MEDIUM_CONN_TIMEOUT); + } + if (this->truncated_) { + ESP_LOGE(TAG, "Service table truncated (device exceeds %u services / %u characteristics / %u descriptors)", + RP2_GATT_MAX_SERVICES, RP2_GATT_MAX_CHARACTERISTICS, RP2_GATT_MAX_DESCRIPTORS); + } + if (error != 0) { + this->release_services(); + } + if (this->listener_ != nullptr) { + this->listener_->on_service_discovery_done(error); + } +} + +ble_device_base::GattServiceTable RP2GattClient::get_service_table() { + ble_device_base::GattServiceTable table; + if (this->arena_ != nullptr) { + table.services = this->arena_->services; + table.characteristics = this->arena_->characteristics; + table.descriptors = this->arena_->descriptors; + table.service_count = this->service_count_; + table.characteristic_count = this->char_count_; + table.descriptor_count = this->desc_count_; + } + return table; +} + +void RP2GattClient::release_services() { + if (this->arena_ != nullptr) { + // Under BluetoothLock so a discovery result landing in the BTstack + // context cannot write into the arena mid-free. + BluetoothLock lock; + RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); + this->arena_->~ServiceArena(); + allocator.deallocate(this->arena_, 1); + this->arena_ = nullptr; + } + this->service_count_ = 0; + this->char_count_ = 0; + this->desc_count_ = 0; + this->truncated_ = false; +} + +// ---- Connection control ---- + +int RP2GattClient::connect(uint64_t address, uint8_t addr_type) { + if (this->is_failed()) { + // setup() failed: nothing is registered for event routing and loop() + // never runs, so a connect could not complete or time out. + return GATT_ERR_NOT_CONNECTED; + } + if (this->state_ != EngineState::IDLE) { + return GATT_CLIENT_IN_WRONG_STATE; + } + if (!this->parent_->is_active()) { + return GATT_ERR_NOT_CONNECTED; + } + ble_device_base::uint64_to_mac_msb_first(address, this->peer_addr_); + // BLE_ADDR_TYPE_* code space: bit 0 distinguishes public from random + // (resolved RPA types 2/3 connect with the underlying kind). + this->peer_addr_type_ = (addr_type & 1) != 0 ? BD_ADDR_TYPE_LE_RANDOM : BD_ADDR_TYPE_LE_PUBLIC; + + // Stop the shared radio's scan for the duration of the connect attempt + // (esp32 parity: initiating and scanning contend for the radio). + this->holds_scan_inhibit_ = true; + this->parent_->inhibit_scan(); + this->connect_cancel_attempted_ = false; + this->cancel_requested_ = false; + uint8_t status; + { + BluetoothLock lock; + gap_set_connection_parameters(CONN_SCAN_INTERVAL, CONN_SCAN_WINDOW, FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL, + 0, FAST_CONN_TIMEOUT, CONN_CE_MIN, CONN_CE_MAX); + status = gap_connect(this->peer_addr_, this->peer_addr_type_); + } + if (status != 0) { + ESP_LOGW(TAG, "gap_connect failed, status=0x%02x", status); + this->release_scan_inhibit_(); + return status; + } + this->state_ = EngineState::CONNECTING; + this->connect_started_ = millis(); + this->enable_loop(); + return 0; +} + +int RP2GattClient::disconnect() { + switch (this->state_) { + case EngineState::IDLE: + return GATT_ERR_NOT_CONNECTED; + case EngineState::DISCONNECTING: + return 0; // already on its way down + case EngineState::CONNECTING: { + if (this->con_handle_ == HCI_CON_HANDLE_INVALID) { + // The cancel can lose the race against a successful connection + // complete; handle_connected_ checks this flag and finishes the + // teardown instead of proceeding. It also counts as the one cancel + // attempt, so a lost completion escalates on the next timeout tick. + this->cancel_requested_ = true; + this->connect_cancel_attempted_ = true; + BluetoothLock lock; + gap_connect_cancel(); + // Completion arrives as a failed connection-complete event. + return 0; + } + break; + } + default: + break; + } + uint8_t status; + { + BluetoothLock lock; + status = gap_disconnect(this->con_handle_); + } + if (status != 0) { + // Refused (handle already gone): complete via the event queue so the + // listener cannot re-enter disconnect() mid-call. BluetoothLock stops + // the IRQ producer, so this main-loop push is SPSC-safe. + ESP_LOGW(TAG, "gap_disconnect failed, status=0x%02x", status); + { + BluetoothLock lock; + this->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, HCI_REASON_CONNECTION_TIMEOUT, 0); + } + } + this->state_ = EngineState::DISCONNECTING; + this->disconnecting_started_ = millis(); + // No more initiating: give the radio back to the scanner during teardown. + this->release_scan_inhibit_(); + this->enable_loop(); + return 0; +} + +// ---- GATT operations (single outstanding op) ---- + +int RP2GattClient::read_characteristic(uint16_t handle) { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + if (this->op_in_flight_()) { + return GATT_CLIENT_IN_WRONG_STATE; + } + this->op_type_ = OpType::READ_CHAR; + this->op_handle_ = handle; + this->op_len_ = 0; + BluetoothLock lock; + uint8_t status = gatt_client_read_value_of_characteristic_using_value_handle(&RP2GattClient::gatt_packet_handler, + this->con_handle_, handle); + if (status != 0) { + this->op_type_ = OpType::NONE; + return status; + } + return 0; +} + +int RP2GattClient::write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + if (len > RP2_GATT_MAX_ATTR_LEN) { + return ATT_ERROR_INVALID_ATTRIBUTE_VALUE_LENGTH; + } + if (!response) { + // Synchronous in BTstack: the data is copied into the L2CAP buffer before + // the call returns, and no completion event exists — synthesize one so + // the wire behavior matches esp32 (which reports write-no-response too). + uint8_t status; + { + BluetoothLock lock; + status = gatt_client_write_value_of_characteristic_without_response(this->con_handle_, handle, len, + const_cast(data)); + } + if (status == 0 && this->listener_ != nullptr) { + this->listener_->on_write_result(handle, 0); + } + return status; + } + if (this->op_in_flight_()) { + return GATT_CLIENT_IN_WRONG_STATE; + } + // BTstack keeps the caller's pointer until the request is sent; the payload + // must live in engine-owned storage across the async operation. + memcpy(this->op_buffer_, data, len); + this->op_type_ = OpType::WRITE_CHAR; + this->op_handle_ = handle; + BluetoothLock lock; + uint8_t status; + if (len <= this->mtu_ - 3) { + status = gatt_client_write_value_of_characteristic(&RP2GattClient::gatt_packet_handler, this->con_handle_, handle, + len, this->op_buffer_); + } else { + status = gatt_client_write_long_value_of_characteristic(&RP2GattClient::gatt_packet_handler, this->con_handle_, + handle, len, this->op_buffer_); + } + if (status != 0) { + this->op_type_ = OpType::NONE; + return status; + } + return 0; +} + +int RP2GattClient::read_descriptor(uint16_t handle) { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + if (this->op_in_flight_()) { + return GATT_CLIENT_IN_WRONG_STATE; + } + this->op_type_ = OpType::READ_DESC; + this->op_handle_ = handle; + this->op_len_ = 0; + BluetoothLock lock; + uint8_t status = gatt_client_read_characteristic_descriptor_using_descriptor_handle( + &RP2GattClient::gatt_packet_handler, this->con_handle_, handle); + if (status != 0) { + this->op_type_ = OpType::NONE; + return status; + } + return 0; +} + +int RP2GattClient::write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + if (this->op_in_flight_()) { + return GATT_CLIENT_IN_WRONG_STATE; + } + if (len > RP2_GATT_MAX_ATTR_LEN) { + return ATT_ERROR_INVALID_ATTRIBUTE_VALUE_LENGTH; + } + memcpy(this->op_buffer_, data, len); + this->op_type_ = OpType::WRITE_DESC; + this->op_handle_ = handle; + BluetoothLock lock; + uint8_t status = gatt_client_write_characteristic_descriptor_using_descriptor_handle( + &RP2GattClient::gatt_packet_handler, this->con_handle_, handle, len, this->op_buffer_); + if (status != 0) { + this->op_type_ = OpType::NONE; + return status; + } + return 0; +} + +int RP2GattClient::notify_characteristic(uint16_t handle, bool enable) { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + // The CCCD write arrives separately as a descriptor write (V3 semantics); + // this call only gates local delivery via the subscription list. + if (enable) { + if (!this->notify_subscribed_(handle)) { + if (this->notify_subscription_count_ >= RP2_GATT_MAX_NOTIFY_SUBSCRIPTIONS) { + return GATT_ERR_NO_MEMORY; + } + this->notify_subscriptions_[this->notify_subscription_count_++] = handle; + } + } else { + for (uint8_t i = 0; i < this->notify_subscription_count_; i++) { + if (this->notify_subscriptions_[i] == handle) { + this->notify_subscriptions_[i] = this->notify_subscriptions_[--this->notify_subscription_count_]; + break; + } + } + } + if (this->listener_ != nullptr) { + this->listener_->on_notify_state(handle, enable, 0); + } + return 0; +} + +bool RP2GattClient::notify_subscribed_(uint16_t handle) const { + for (uint8_t i = 0; i < this->notify_subscription_count_; i++) { + if (this->notify_subscriptions_[i] == handle) { + return true; + } + } + return false; +} + +int RP2GattClient::update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout) { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + BluetoothLock lock; + return gap_update_connection_parameters(this->con_handle_, min_interval, max_interval, latency, timeout); +} + +} // namespace esphome::bluetooth_connection + +#endif // USE_RP2040_BLE && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h new file mode 100644 index 0000000000..145508bdf6 --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h @@ -0,0 +1,206 @@ +// RP2 (Pico W / Pico 2 W) GATT client backend over BTstack. +// +// Implements ble_device_base::BLEGattConnection for the hub BluetoothConnection +// wrapper. BTstack packet handlers run in the CYW43 async-context low-priority +// IRQ (or on the main-loop stack during BluetoothLock release), so handlers +// only copy into per-instance lock-free queues/storage; loop() drains them and +// drives the state machine. Every BTstack call issued from the main loop is +// wrapped in BluetoothLock. + +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) + +#include "esphome/components/ble_device_base/ble_gatt_client.h" +#include "esphome/components/rp2040_ble/rp2040_ble.h" +#include "esphome/core/component.h" +#include "esphome/core/event_pool.h" +#include "esphome/core/helpers.h" +#include "esphome/core/lock_free_queue.h" + +#include + +#include +#include + +namespace esphome::bluetooth_connection { + +// Caps for the transient service table. Sized generously for real devices +// (typical peripherals expose < 8 services / < 30 characteristics); a peer +// exceeding a cap fails discovery with INSUFFICIENT_RESOURCES rather than +// streaming an incomplete database a V3 client would cache permanently. +static constexpr uint16_t RP2_GATT_MAX_SERVICES = 16; +static constexpr uint16_t RP2_GATT_MAX_CHARACTERISTICS = 96; +static constexpr uint16_t RP2_GATT_MAX_DESCRIPTORS = 96; + +// Concurrent notify subscriptions per connection (enable fails with +// GATT_ERR_NO_MEMORY when exceeded; real clients subscribe to a handful). +static constexpr uint8_t RP2_GATT_MAX_NOTIFY_SUBSCRIPTIONS = 16; + +// ATT spec maximum attribute value length; bounds the op buffer and +// notification payloads. +static constexpr uint16_t RP2_GATT_MAX_ATTR_LEN = 512; + +// Control events from the BTstack handlers to loop(). +struct RP2GattEvent { + enum Type : uint8_t { + CONNECTED, // status + con_handle (value) + DISCONNECTED, // status = HCI reason + MTU_EXCHANGED, // value = negotiated MTU + QUERY_COMPLETE, // status = ATT status of the finished query + }; + Type type; + uint8_t status; + uint16_t value; + void release() {} +}; + +// One notification/indication from the peer. +struct RP2GattNotifyEvent { + uint16_t handle; + uint16_t len; + uint8_t data[RP2_GATT_MAX_ATTR_LEN]; + void release() {} +}; + +static constexpr uint8_t RP2_GATT_EVENT_QUEUE_SIZE = 8; +// Depth 4: the queue is drained every main-loop iteration and each slot is a +// full 512 B ATT payload, so depth buys burst tolerance at ~516 B per slot. +static constexpr uint8_t RP2_GATT_NOTIFY_QUEUE_SIZE = 4; + +class RP2GattClient final : public Component, + public ble_device_base::BLEGattConnection, + public Parented { + public: + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override; + + // ---- ble_device_base::BLEGattConnection ---- + int connect(uint64_t address, uint8_t addr_type) override; + int disconnect() override; + int discover_services() override; + int read_characteristic(uint16_t handle) override; + int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) override; + int read_descriptor(uint16_t handle) override; + int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) override; + int notify_characteristic(uint16_t handle, bool enable) override; + int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout) override; + ble_device_base::GattServiceTable get_service_table() override; + void release_services() override; + + protected: + // Link/engine state. Discovery and GATT ops have their own cursors below — + // the link stays READY while they run. + enum class EngineState : uint8_t { + IDLE, + CONNECTING, // gap_connect issued, waiting for connection complete + MTU_EXCHANGE, // link up, waiting for GATT_EVENT_MTU + READY, // on_connection_state(true) delivered + DISCONNECTING, + }; + + enum class DiscoveryPhase : uint8_t { NONE, SERVICES, CHARACTERISTICS, DESCRIPTORS }; + + enum class OpType : uint8_t { NONE, READ_CHAR, WRITE_CHAR, READ_DESC, WRITE_DESC }; + + // The whole table in one transient allocation (RAMAllocator, checked), + // freed after streaming. + struct ServiceArena { + ble_device_base::GattService services[RP2_GATT_MAX_SERVICES]; + ble_device_base::GattCharacteristic characteristics[RP2_GATT_MAX_CHARACTERISTICS]; + ble_device_base::GattDescriptor descriptors[RP2_GATT_MAX_DESCRIPTORS]; + }; + + // BTstack packet handlers (IRQ context: copy-and-enqueue only). + static void hci_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); + static void gatt_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); + static RP2GattClient *instance_for_con_handle(hci_con_handle_t con_handle); + + void handle_gatt_event_irq_(uint8_t event_type, const uint8_t *packet); + void enqueue_event_irq_(RP2GattEvent::Type type, uint8_t status, uint16_t value); + void enqueue_notify_irq_(uint16_t handle, const uint8_t *data, uint16_t len); + + // Main-loop state machine. + void handle_event_(const RP2GattEvent &event); + void handle_connected_(uint8_t status, uint16_t con_handle); + void handle_disconnected_(uint8_t reason); + void handle_query_complete_(uint8_t att_status); + void advance_discovery_(uint8_t att_status); + int issue_characteristic_query_(uint16_t service_index); + int issue_descriptor_query_(uint16_t char_index); + void finish_discovery_(int error); + void fail_connection_(uint8_t reason); + void cleanup_link_state_(); + bool notify_subscribed_(uint16_t handle) const; + void release_scan_inhibit_(); + bool op_in_flight_() const { + return this->op_type_ != OpType::NONE || this->discovery_phase_ != DiscoveryPhase::NONE; + } + + // Group 1: containers / large storage + ServiceArena *arena_{nullptr}; + esphome::LockFreeQueue event_queue_; + esphome::EventPool event_pool_; + esphome::LockFreeQueue notify_queue_; + esphome::EventPool notify_pool_; + + // Shared buffer for the single outstanding GATT op: write payloads (BTstack + // keeps the caller's pointer until the request is sent) and read results + // (written from the handler, read after QUERY_COMPLETE is drained). + uint8_t op_buffer_[RP2_GATT_MAX_ATTR_LEN]; + + // BTstack registrations + gatt_client_notification_t notification_registration_{}; + + // Group 3: 4-byte types + uint32_t connect_started_{0}; + uint32_t disconnecting_started_{0}; + + // Group 4: 2-byte types (table counters written from the handler during + // discovery, read from the main loop after the phase's QUERY_COMPLETE) + hci_con_handle_t con_handle_{HCI_CON_HANDLE_INVALID}; + uint16_t mtu_{23}; + uint16_t op_handle_{0}; + uint16_t op_len_{0}; + uint16_t service_count_{0}; + uint16_t char_count_{0}; + uint16_t desc_count_{0}; + uint16_t disc_service_cursor_{0}; + uint16_t disc_char_cursor_{0}; + + // Group 5: arrays / 1-byte types + // Subscribed notify handles; the loop() drain filters the wildcard + // listener's deliveries on this list (esp32 parity for enable=false). + std::array notify_subscriptions_{}; + uint8_t notify_subscription_count_{0}; + bd_addr_t peer_addr_{}; // MSB-first, as gap_connect expects + bd_addr_type_t peer_addr_type_{BD_ADDR_TYPE_LE_PUBLIC}; + EngineState state_{EngineState::IDLE}; + DiscoveryPhase discovery_phase_{DiscoveryPhase::NONE}; + OpType op_type_{OpType::NONE}; + bool truncated_{false}; + // One cancel attempt per connect: the second timeout escalates to failure. + bool connect_cancel_attempted_{false}; + // A disconnect request raced an in-flight connect; finish teardown on link-up. + bool cancel_requested_{false}; + // This engine's own hold on the shared scan inhibit, so the pairing stays + // one-to-one per connection even with multiple slots. + bool holds_scan_inhibit_{false}; + + // Instance registry for routing BTstack events (IRQ context) to engines. + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + static RP2GattClient *instances[ESPHOME_BLE_GATT_CLIENT_COUNT]; + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + static uint8_t instance_count; + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + static btstack_packet_callback_registration_t hci_event_registration; +}; + +} // namespace esphome::bluetooth_connection + +#endif // USE_RP2040_BLE && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index a0706ae4ae..ed6dbfe557 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -48,7 +48,7 @@ def AUTO_LOAD(config: ConfigType | None = None) -> list[str]: # proxy would be misdriven — bk72xx follows once the API carries a feature # flag clients can trust (FEATURE_ACTIVE_SCAN + a version flag, separate PRs). # Coupled to bluetooth_connection: platforms with a GATT backend are also -# listed in its FILTER_SOURCE_FILES hub entry. +# listed in its HUB_MAX_CONNECTIONS and its FILTER_SOURCE_FILES hub entry. _HUB_PLATFORMS = (PLATFORM_LN882X, PLATFORM_RP2) DEPENDENCIES = ["api"] diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index bd80f71a49..e6cdde9cda 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -13,20 +13,14 @@ namespace esphome::esp32_ble_client { static const char *const TAG = "esp32_ble_client"; -// Intermediate connection parameters for standard operation -// ESP-IDF defaults (12.5-15ms) are too slow for stable connections through WiFi-based BLE proxies, -// causing disconnections. These medium parameters balance responsiveness with bandwidth usage. -static constexpr uint16_t MEDIUM_MIN_CONN_INTERVAL = 0x07; // 7 * 1.25ms = 8.75ms -static constexpr uint16_t MEDIUM_MAX_CONN_INTERVAL = 0x09; // 9 * 1.25ms = 11.25ms -// The timeout value was increased from 6s to 8s to address stability issues observed -// in certain BLE devices when operating through WiFi-based BLE proxies. The longer -// timeout reduces the likelihood of disconnections during periods of high latency. -static constexpr uint16_t MEDIUM_CONN_TIMEOUT = 800; // 800 * 10ms = 8s - -// Fastest connection parameters for devices with short discovery timeouts -static constexpr uint16_t FAST_MIN_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms (BLE minimum) -static constexpr uint16_t FAST_MAX_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms -static constexpr uint16_t FAST_CONN_TIMEOUT = 1000; // 1000 * 10ms = 10s +// Connection parameters are shared with the other GATT client backends +// (ble_device_base/ble_client_state.h) so the platforms cannot drift. +using ble_device_base::FAST_CONN_TIMEOUT; +using ble_device_base::FAST_MAX_CONN_INTERVAL; +using ble_device_base::FAST_MIN_CONN_INTERVAL; +using ble_device_base::MEDIUM_CONN_TIMEOUT; +using ble_device_base::MEDIUM_MAX_CONN_INTERVAL; +using ble_device_base::MEDIUM_MIN_CONN_INTERVAL; static constexpr uint32_t DISCONNECTING_TIMEOUT = 10000; // 10s static const esp_bt_uuid_t NOTIFY_DESC_UUID = { .len = ESP_UUID_LEN_16, diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h index 70ececb528..8106763489 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h @@ -63,8 +63,14 @@ class RP2BLETracker : public Component, // BTstack delivers scan responses as separate advertisement reports rather // than merging them into the advertisement — consumers relying on // scan-response fields (device names) get them only where the receiver - // merges per address (Home Assistant does). No GATT path yet. - return {.active_scan = true, .merges_scan_response = false, .gatt = false, .scan_mode_switch = true}; + // merges per address (Home Assistant does). GATT is available when the + // BTstack connection backend is compiled in (bluetooth_proxy active). +#ifdef USE_BLE_GATT_CLIENT + constexpr bool has_gatt = true; +#else + constexpr bool has_gatt = false; +#endif + return {.active_scan = true, .merges_scan_response = false, .gatt = has_gatt, .scan_mode_switch = true}; } // The controller stores the address in printable (MSB-first) order, which is // exactly what the contract wants. From 715b14aebab1aff8bf687708e6d95bdf9d607238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Fri, 7 Aug 2026 20:59:27 +0300 Subject: [PATCH 1309/1815] [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 3: mopeka_ble, mopeka_pro_check, mopeka_std_check) (#17951) Co-authored-by: J. Nick Koston --- esphome/components/mopeka_ble/__init__.py | 23 +++++++++++-------- esphome/components/mopeka_ble/mopeka_ble.cpp | 14 ++++------- esphome/components/mopeka_ble/mopeka_ble.h | 10 +++----- .../mopeka_pro_check/mopeka_pro_check.cpp | 6 +---- .../mopeka_pro_check/mopeka_pro_check.h | 10 +++----- esphome/components/mopeka_pro_check/sensor.py | 13 ++++++----- .../mopeka_std_check/mopeka_std_check.cpp | 8 ++----- .../mopeka_std_check/mopeka_std_check.h | 10 +++----- esphome/components/mopeka_std_check/sensor.py | 13 ++++++----- tests/components/mopeka_ble/common-ln.yaml | 1 + tests/components/mopeka_ble/common.yaml | 3 +++ .../mopeka_ble/test.ln882x-ard.yaml | 3 +++ .../mopeka_ble/validate.bk72xx-ard.yaml | 8 +++++++ .../mopeka_pro_check/common-ln.yaml | 8 +++++++ tests/components/mopeka_pro_check/common.yaml | 3 +++ .../mopeka_pro_check/test.ln882x-ard.yaml | 3 +++ .../mopeka_pro_check/validate.bk72xx-ard.yaml | 13 +++++++++++ .../mopeka_std_check/common-ln.yaml | 8 +++++++ tests/components/mopeka_std_check/common.yaml | 3 +++ .../mopeka_std_check/test.ln882x-ard.yaml | 3 +++ .../mopeka_std_check/validate.bk72xx-ard.yaml | 19 +++++++++++++++ 21 files changed, 119 insertions(+), 63 deletions(-) create mode 100644 tests/components/mopeka_ble/common-ln.yaml create mode 100644 tests/components/mopeka_ble/test.ln882x-ard.yaml create mode 100644 tests/components/mopeka_ble/validate.bk72xx-ard.yaml create mode 100644 tests/components/mopeka_pro_check/common-ln.yaml create mode 100644 tests/components/mopeka_pro_check/test.ln882x-ard.yaml create mode 100644 tests/components/mopeka_pro_check/validate.bk72xx-ard.yaml create mode 100644 tests/components/mopeka_std_check/common-ln.yaml create mode 100644 tests/components/mopeka_std_check/test.ln882x-ard.yaml create mode 100644 tests/components/mopeka_std_check/validate.bk72xx-ard.yaml diff --git a/esphome/components/mopeka_ble/__init__.py b/esphome/components/mopeka_ble/__init__.py index c8648cbc63..ab261142b8 100644 --- a/esphome/components/mopeka_ble/__init__.py +++ b/esphome/components/mopeka_ble/__init__.py @@ -1,24 +1,27 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker +from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_ID CODEOWNERS = ["@spbrogan", "@Fabian-Schmidt"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] CONF_SHOW_SENSORS_WITHOUT_SYNC = "show_sensors_without_sync" mopeka_ble_ns = cg.esphome_ns.namespace("mopeka_ble") MopekaListener = mopeka_ble_ns.class_( - "MopekaListener", esp32_ble_tracker.ESPBTDeviceListener + "MopekaListener", ble_device_base.ESPBTDeviceListener ) -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(MopekaListener), - cv.Optional(CONF_SHOW_SENSORS_WITHOUT_SYNC, default=False): cv.boolean, - } -).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("mopeka_ble"), + cv.Schema( + { + cv.GenerateID(): cv.declare_id(MopekaListener), + cv.Optional(CONF_SHOW_SENSORS_WITHOUT_SYNC, default=False): cv.boolean, + } + ).extend(ble_device_base.BLE_DEVICE_SCHEMA), +) async def to_code(config): @@ -27,4 +30,4 @@ async def to_code(config): cg.add( var.set_show_sensors_without_sync(config[CONF_SHOW_SENSORS_WITHOUT_SYNC]) ) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) diff --git a/esphome/components/mopeka_ble/mopeka_ble.cpp b/esphome/components/mopeka_ble/mopeka_ble.cpp index ff5dd8d61b..0bef1eb6d4 100644 --- a/esphome/components/mopeka_ble/mopeka_ble.cpp +++ b/esphome/components/mopeka_ble/mopeka_ble.cpp @@ -2,8 +2,6 @@ #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::mopeka_ble { static const char *const TAG = "mopeka_ble"; @@ -34,7 +32,7 @@ static const uint8_t MANUFACTURER_NRF52_DATA_LENGTH = 10; * - Bluetooth data frame size */ -bool MopekaListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool MopekaListener::parse_device(const ble_device_base::ESPBTDevice &device) { char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; // Fetch information about BLE device. const auto &service_uuids = device.get_service_uuids(); @@ -50,8 +48,8 @@ bool MopekaListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) const auto &manu_data = manu_datas[0]; // Is the device maybe a Mopeka Std (CC2540) sensor. - if (service_uuid == esp32_ble_tracker::ESPBTUUID::from_uint16(SERVICE_UUID_CC2540)) { - if (manu_data.uuid != esp32_ble_tracker::ESPBTUUID::from_uint16(MANUFACTURER_CC2540_ID)) { + if (service_uuid == ble_device_base::ESPBTUUID::from_uint16(SERVICE_UUID_CC2540)) { + if (manu_data.uuid != ble_device_base::ESPBTUUID::from_uint16(MANUFACTURER_CC2540_ID)) { return false; } @@ -66,8 +64,8 @@ bool MopekaListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) } // Is the device maybe a Mopeka Pro (NRF52) sensor. - } else if (service_uuid == esp32_ble_tracker::ESPBTUUID::from_uint16(SERVICE_UUID_NRF52)) { - if (manu_data.uuid != esp32_ble_tracker::ESPBTUUID::from_uint16(MANUFACTURER_NRF52_ID)) { + } else if (service_uuid == ble_device_base::ESPBTUUID::from_uint16(SERVICE_UUID_NRF52)) { + if (manu_data.uuid != ble_device_base::ESPBTUUID::from_uint16(MANUFACTURER_NRF52_ID)) { return false; } @@ -86,5 +84,3 @@ bool MopekaListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) } } // namespace esphome::mopeka_ble - -#endif diff --git a/esphome/components/mopeka_ble/mopeka_ble.h b/esphome/components/mopeka_ble/mopeka_ble.h index e6fae23aee..460668ae65 100644 --- a/esphome/components/mopeka_ble/mopeka_ble.h +++ b/esphome/components/mopeka_ble/mopeka_ble.h @@ -2,16 +2,14 @@ #include -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/core/component.h" -#ifdef USE_ESP32 - namespace esphome::mopeka_ble { -class MopekaListener final : public esp32_ble_tracker::ESPBTDeviceListener { +class MopekaListener final : public ble_device_base::ESPBTDeviceListener { public: - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void set_show_sensors_without_sync(bool show_sensors_without_sync) { show_sensors_without_sync_ = show_sensors_without_sync; } @@ -21,5 +19,3 @@ class MopekaListener final : public esp32_ble_tracker::ESPBTDeviceListener { }; } // namespace esphome::mopeka_ble - -#endif diff --git a/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp b/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp index ab0ff9a113..fe3178d3aa 100644 --- a/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp +++ b/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp @@ -1,8 +1,6 @@ #include "mopeka_pro_check.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::mopeka_pro_check { static const char *const TAG = "mopeka_pro_check"; @@ -25,7 +23,7 @@ void MopekaProCheck::dump_config() { * Check if advertisement is for our sensor and if so decode it and * update the sensor state data. */ -bool MopekaProCheck::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool MopekaProCheck::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { return false; } @@ -154,5 +152,3 @@ SensorReadQuality MopekaProCheck::parse_read_quality_(const std::vector } } // namespace esphome::mopeka_pro_check - -#endif diff --git a/esphome/components/mopeka_pro_check/mopeka_pro_check.h b/esphome/components/mopeka_pro_check/mopeka_pro_check.h index 40fb338350..0cd53107c6 100644 --- a/esphome/components/mopeka_pro_check/mopeka_pro_check.h +++ b/esphome/components/mopeka_pro_check/mopeka_pro_check.h @@ -5,9 +5,7 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" - -#ifdef USE_ESP32 +#include "esphome/components/ble_device_base/ble_device.h" namespace esphome::mopeka_pro_check { @@ -27,11 +25,11 @@ enum SensorType { // measurement may be inaccurate. enum SensorReadQuality { QUALITY_HIGH = 0x3, QUALITY_MED = 0x2, QUALITY_LOW = 0x1, QUALITY_ZERO = 0x0 }; -class MopekaProCheck final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class MopekaProCheck final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_min_signal_quality(SensorReadQuality min) { this->min_signal_quality_ = min; }; @@ -65,5 +63,3 @@ class MopekaProCheck final : public Component, public esp32_ble_tracker::ESPBTDe }; } // namespace esphome::mopeka_pro_check - -#endif diff --git a/esphome/components/mopeka_pro_check/sensor.py b/esphome/components/mopeka_pro_check/sensor.py index 323175917d..0d10970550 100644 --- a/esphome/components/mopeka_pro_check/sensor.py +++ b/esphome/components/mopeka_pro_check/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -56,11 +56,11 @@ CONF_SUPPORTED_TANKS_MAP = { } CODEOWNERS = ["@spbrogan"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] mopeka_pro_check_ns = cg.esphome_ns.namespace("mopeka_pro_check") MopekaProCheck = mopeka_pro_check_ns.class_( - "MopekaProCheck", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "MopekaProCheck", ble_device_base.ESPBTDeviceListener, cg.Component ) SensorReadQuality = mopeka_pro_check_ns.enum("SensorReadQuality") @@ -71,7 +71,8 @@ SIGNAL_QUALITIES = { "HIGH": SensorReadQuality.QUALITY_HIGH, } -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("mopeka_pro_check"), cv.Schema( { cv.GenerateID(): cv.declare_id(MopekaProCheck), @@ -122,15 +123,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/mopeka_std_check/mopeka_std_check.cpp b/esphome/components/mopeka_std_check/mopeka_std_check.cpp index 519a45fcb5..d70306c97d 100644 --- a/esphome/components/mopeka_std_check/mopeka_std_check.cpp +++ b/esphome/components/mopeka_std_check/mopeka_std_check.cpp @@ -3,8 +3,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::mopeka_std_check { static const char *const TAG = "mopeka_std_check"; @@ -33,7 +31,7 @@ void MopekaStdCheck::dump_config() { * Check if advertisement is for our sensor and if so decode it and * update the sensor state data. */ -bool MopekaStdCheck::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool MopekaStdCheck::parse_device(const ble_device_base::ESPBTDevice &device) { // Validate address. if (device.address_uint64() != this->address_) { return false; @@ -52,7 +50,7 @@ bool MopekaStdCheck::parse_device(const esp32_ble_tracker::ESPBTDevice &device) return false; } const auto &service_uuid = service_uuids[0]; - if (service_uuid != esp32_ble_tracker::ESPBTUUID::from_uint16(SERVICE_UUID)) { + if (service_uuid != ble_device_base::ESPBTUUID::from_uint16(SERVICE_UUID)) { return false; } } @@ -232,5 +230,3 @@ int8_t MopekaStdCheck::parse_temperature_(const mopeka_std_package *message) { } } // namespace esphome::mopeka_std_check - -#endif diff --git a/esphome/components/mopeka_std_check/mopeka_std_check.h b/esphome/components/mopeka_std_check/mopeka_std_check.h index 2f1681f6ea..75d9b36a58 100644 --- a/esphome/components/mopeka_std_check/mopeka_std_check.h +++ b/esphome/components/mopeka_std_check/mopeka_std_check.h @@ -3,12 +3,10 @@ #include #include -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" -#ifdef USE_ESP32 - namespace esphome::mopeka_std_check { enum SensorType { @@ -42,11 +40,11 @@ struct mopeka_std_package { // NOLINT(readability-identifier-naming,altera-stru mopeka_std_values val[3]; } __attribute__((packed)); -class MopekaStdCheck final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class MopekaStdCheck final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_level(sensor::Sensor *level) { this->level_ = level; }; @@ -74,5 +72,3 @@ class MopekaStdCheck final : public Component, public esp32_ble_tracker::ESPBTDe }; } // namespace esphome::mopeka_std_check - -#endif diff --git a/esphome/components/mopeka_std_check/sensor.py b/esphome/components/mopeka_std_check/sensor.py index d4535d9671..5cc4ea3039 100644 --- a/esphome/components/mopeka_std_check/sensor.py +++ b/esphome/components/mopeka_std_check/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -50,14 +50,15 @@ CONF_SUPPORTED_TANKS_MAP = { } CODEOWNERS = ["@Fabian-Schmidt"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] mopeka_std_check_ns = cg.esphome_ns.namespace("mopeka_std_check") MopekaStdCheck = mopeka_std_check_ns.class_( - "MopekaStdCheck", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "MopekaStdCheck", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("mopeka_std_check"), cv.Schema( { cv.GenerateID(): cv.declare_id(MopekaStdCheck), @@ -93,15 +94,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/tests/components/mopeka_ble/common-ln.yaml b/tests/components/mopeka_ble/common-ln.yaml new file mode 100644 index 0000000000..14df729405 --- /dev/null +++ b/tests/components/mopeka_ble/common-ln.yaml @@ -0,0 +1 @@ +mopeka_ble: diff --git a/tests/components/mopeka_ble/common.yaml b/tests/components/mopeka_ble/common.yaml index a115404f1c..d511a449f9 100644 --- a/tests/components/mopeka_ble/common.yaml +++ b/tests/components/mopeka_ble/common.yaml @@ -1,3 +1,6 @@ esp32_ble_tracker: + id: ble_tracker_hub +# Explicit ble_hub_id: pins the neutral binding as a declared key. mopeka_ble: + ble_hub_id: ble_tracker_hub diff --git a/tests/components/mopeka_ble/test.ln882x-ard.yaml b/tests/components/mopeka_ble/test.ln882x-ard.yaml new file mode 100644 index 0000000000..8026866234 --- /dev/null +++ b/tests/components/mopeka_ble/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + mopeka_ble: !include common-ln.yaml diff --git a/tests/components/mopeka_ble/validate.bk72xx-ard.yaml b/tests/components/mopeka_ble/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..41fdf62d03 --- /dev/null +++ b/tests/components/mopeka_ble/validate.bk72xx-ard.yaml @@ -0,0 +1,8 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_hub + +mopeka_ble: + ble_hub_id: ble_hub diff --git a/tests/components/mopeka_pro_check/common-ln.yaml b/tests/components/mopeka_pro_check/common-ln.yaml new file mode 100644 index 0000000000..1e28e1e58d --- /dev/null +++ b/tests/components/mopeka_pro_check/common-ln.yaml @@ -0,0 +1,8 @@ +sensor: + - platform: mopeka_pro_check + mac_address: D3:75:F2:DC:16:91 + tank_type: 20LB_V + temperature: + name: Propane test temp + level: + name: Propane test level diff --git a/tests/components/mopeka_pro_check/common.yaml b/tests/components/mopeka_pro_check/common.yaml index 3533ecf631..15eabe1f25 100644 --- a/tests/components/mopeka_pro_check/common.yaml +++ b/tests/components/mopeka_pro_check/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: mopeka_pro_check + ble_hub_id: ble_tracker_hub mac_address: D3:75:F2:DC:16:91 tank_type: CUSTOM custom_distance_full: 40cm diff --git a/tests/components/mopeka_pro_check/test.ln882x-ard.yaml b/tests/components/mopeka_pro_check/test.ln882x-ard.yaml new file mode 100644 index 0000000000..3484c7b5b3 --- /dev/null +++ b/tests/components/mopeka_pro_check/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + mopeka_pro_check: !include common-ln.yaml diff --git a/tests/components/mopeka_pro_check/validate.bk72xx-ard.yaml b/tests/components/mopeka_pro_check/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..888b879e07 --- /dev/null +++ b/tests/components/mopeka_pro_check/validate.bk72xx-ard.yaml @@ -0,0 +1,13 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_hub + +sensor: + - platform: mopeka_pro_check + ble_hub_id: ble_hub + mac_address: D3:75:F2:DC:16:91 + tank_type: 20lb_v + level: + name: BK Mopeka Pro Level diff --git a/tests/components/mopeka_std_check/common-ln.yaml b/tests/components/mopeka_std_check/common-ln.yaml new file mode 100644 index 0000000000..0b645dcaf3 --- /dev/null +++ b/tests/components/mopeka_std_check/common-ln.yaml @@ -0,0 +1,8 @@ +sensor: + - platform: mopeka_std_check + mac_address: D3:75:F2:DC:16:91 + tank_type: Europe_11kg + temperature: + name: Propane test temp + level: + name: Propane test level diff --git a/tests/components/mopeka_std_check/common.yaml b/tests/components/mopeka_std_check/common.yaml index 383e2e2a19..e7224ba725 100644 --- a/tests/components/mopeka_std_check/common.yaml +++ b/tests/components/mopeka_std_check/common.yaml @@ -1,8 +1,11 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: # Example using 11kg 100% propane tank. + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: mopeka_std_check + ble_hub_id: ble_tracker_hub mac_address: D3:75:F2:DC:16:91 tank_type: Europe_11kg temperature: diff --git a/tests/components/mopeka_std_check/test.ln882x-ard.yaml b/tests/components/mopeka_std_check/test.ln882x-ard.yaml new file mode 100644 index 0000000000..11a54cb37c --- /dev/null +++ b/tests/components/mopeka_std_check/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + mopeka_std_check: !include common-ln.yaml diff --git a/tests/components/mopeka_std_check/validate.bk72xx-ard.yaml b/tests/components/mopeka_std_check/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..86b4425518 --- /dev/null +++ b/tests/components/mopeka_std_check/validate.bk72xx-ard.yaml @@ -0,0 +1,19 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_hub + +sensor: + - platform: mopeka_std_check + ble_hub_id: ble_hub + mac_address: D3:75:F2:DC:16:91 + tank_type: Europe_11kg + level: + name: BK Mopeka Std Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: mopeka_std_check + mac_address: D3:75:F2:DC:16:92 + tank_type: Europe_11kg + level: + name: BK Propane implicit level From 950cfc4da317b511f712b717a15ae49a9a2301d8 Mon Sep 17 00:00:00 2001 From: Mustafa KURU Date: Fri, 7 Aug 2026 21:33:37 +0300 Subject: [PATCH 1310/1815] [ld6002b] Add select and button platforms (4/5) (#17822) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/ld6002b/button/__init__.py | 101 ++++++++++ .../ld6002b/button/ld6002b_button.cpp | 7 + .../ld6002b/button/ld6002b_button.h | 18 ++ esphome/components/ld6002b/const.py | 11 + esphome/components/ld6002b/ld6002b.cpp | 190 ++++++++++++++++++ esphome/components/ld6002b/ld6002b.h | 43 +++- esphome/components/ld6002b/select/__init__.py | 60 ++++++ .../ld6002b/select/ld6002b_select.cpp | 10 + .../ld6002b/select/ld6002b_select.h | 18 ++ tests/component_tests/ld6002b/__init__.py | 0 .../ld6002b/test_final_validate.py | 82 ++++++++ tests/components/ld6002b/common.yaml | 32 +++ 12 files changed, 571 insertions(+), 1 deletion(-) create mode 100644 esphome/components/ld6002b/button/__init__.py create mode 100644 esphome/components/ld6002b/button/ld6002b_button.cpp create mode 100644 esphome/components/ld6002b/button/ld6002b_button.h create mode 100644 esphome/components/ld6002b/select/__init__.py create mode 100644 esphome/components/ld6002b/select/ld6002b_select.cpp create mode 100644 esphome/components/ld6002b/select/ld6002b_select.h create mode 100644 tests/component_tests/ld6002b/__init__.py create mode 100644 tests/component_tests/ld6002b/test_final_validate.py diff --git a/esphome/components/ld6002b/button/__init__.py b/esphome/components/ld6002b/button/__init__.py new file mode 100644 index 0000000000..0046131b62 --- /dev/null +++ b/esphome/components/ld6002b/button/__init__.py @@ -0,0 +1,101 @@ +import esphome.codegen as cg +from esphome.components import button +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_WAKEUP_PIN, + ENTITY_CATEGORY_CONFIG, + ENTITY_CATEGORY_DIAGNOSTIC, +) +import esphome.final_validate as fv + +from .. import LD6002BComponent, ld6002b_ns +from ..const import ( + CONF_GET_DELAY, + CONF_GET_INSTALLATION, + CONF_GET_LOW_POWER_MODE, + CONF_GET_LOW_POWER_SLEEP_TIME, + CONF_GET_SENSITIVITY, + CONF_GET_TRIGGER_SPEED, + CONF_GET_Z_RANGE, + CONF_LD6002B_ID, + CONF_RESET_UNATTENDED, + CONF_WAKE, +) + +DEPENDENCIES = ["ld6002b"] + +LD6002BButton = ld6002b_ns.class_("LD6002BButton", button.Button) +ButtonType = ld6002b_ns.enum("ButtonType", is_class=True) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_GET_DELAY): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_GET_SENSITIVITY): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_GET_TRIGGER_SPEED): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_GET_Z_RANGE): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_GET_INSTALLATION): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_GET_LOW_POWER_MODE): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_GET_LOW_POWER_SLEEP_TIME): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_RESET_UNATTENDED): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_WAKE): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + } +) + + +def final_validate(config): + full_config = fv.full_config.get() + hub_id = config[CONF_LD6002B_ID] + + if config.get(CONF_WAKE): + hub_path = full_config.get_path_for_id(hub_id) + hub_config = full_config.get_config_for_path(hub_path[:-1]) + if hub_config.get(CONF_WAKEUP_PIN) is None: + raise cv.Invalid( + f"{CONF_WAKE} requires {CONF_WAKEUP_PIN} on the parent ld6002b component", + path=[CONF_WAKE], + ) + + return config + + +FINAL_VALIDATE_SCHEMA = final_validate + +BUTTON_MAP = { + CONF_GET_DELAY: ButtonType.GET_DELAY, + CONF_GET_SENSITIVITY: ButtonType.GET_SENSITIVITY, + CONF_GET_TRIGGER_SPEED: ButtonType.GET_TRIGGER_SPEED, + CONF_GET_Z_RANGE: ButtonType.GET_Z_RANGE, + CONF_GET_INSTALLATION: ButtonType.GET_INSTALLATION, + CONF_GET_LOW_POWER_MODE: ButtonType.GET_LOW_POWER_MODE, + CONF_GET_LOW_POWER_SLEEP_TIME: ButtonType.GET_LOW_POWER_SLEEP_TIME, + CONF_RESET_UNATTENDED: ButtonType.RESET_UNATTENDED, + CONF_WAKE: ButtonType.WAKE, +} + + +async def to_code(config): + for key, button_type in BUTTON_MAP.items(): + if button_config := config.get(key): + b = cg.new_Pvariable(button_config[CONF_ID], button_type) + await button.register_button(b, button_config) + await cg.register_parented(b, config[CONF_LD6002B_ID]) diff --git a/esphome/components/ld6002b/button/ld6002b_button.cpp b/esphome/components/ld6002b/button/ld6002b_button.cpp new file mode 100644 index 0000000000..fb398a9a59 --- /dev/null +++ b/esphome/components/ld6002b/button/ld6002b_button.cpp @@ -0,0 +1,7 @@ +#include "ld6002b_button.h" + +namespace esphome::ld6002b { + +void LD6002BButton::press_action() { this->parent_->press_button(this->type_); } + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/button/ld6002b_button.h b/esphome/components/ld6002b/button/ld6002b_button.h new file mode 100644 index 0000000000..c222143453 --- /dev/null +++ b/esphome/components/ld6002b/button/ld6002b_button.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/button/button.h" +#include "../ld6002b.h" + +namespace esphome::ld6002b { + +class LD6002BButton : public button::Button, public Parented { + public: + explicit LD6002BButton(ButtonType type) : type_(type) {} + + protected: + void press_action() override; + + ButtonType type_; +}; + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/const.py b/esphome/components/ld6002b/const.py index 9f9227988e..fac9f08015 100644 --- a/esphome/components/ld6002b/const.py +++ b/esphome/components/ld6002b/const.py @@ -1,14 +1,25 @@ CONF_AUTO_WAKE = "auto_wake" CONF_CLUSTER_ID = "cluster_id" CONF_DOPPLER_INDEX = "doppler_index" +CONF_GET_DELAY = "get_delay" +CONF_GET_INSTALLATION = "get_installation" +CONF_GET_LOW_POWER_MODE = "get_low_power_mode" +CONF_GET_LOW_POWER_SLEEP_TIME = "get_low_power_sleep_time" +CONF_GET_SENSITIVITY = "get_sensitivity" +CONF_GET_TRIGGER_SPEED = "get_trigger_speed" +CONF_GET_Z_RANGE = "get_z_range" CONF_HOLD_DELAY = "hold_delay" +CONF_INSTALLATION_MODE = "installation_mode" CONF_LD6002B_ID = "ld6002b_id" CONF_LOW_POWER = "low_power" CONF_LOW_POWER_SLEEP_TIME = "low_power_sleep_time" CONF_OTA_VERSION = "ota_version" CONF_POINT_CLOUD = "point_cloud" CONF_POINT_COUNT = "point_count" +CONF_RESET_UNATTENDED = "reset_unattended" CONF_TARGET_DISPLAY = "target_display" +CONF_TRIGGER_SPEED = "trigger_speed" +CONF_WAKE = "wake" CONF_WAKEUP_PULSE = "wakeup_pulse" CONF_WORK_MODE = "work_mode" CONF_Z = "z" diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index 54979fe9eb..25b3da174c 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -22,7 +22,10 @@ static constexpr uint16_t TYPE_SET_LOW_POWER_SLEEP = 0x0205; static constexpr uint16_t TYPE_REPORT_TARGET = 0x0A04; static constexpr uint16_t TYPE_REPORT_POINT_CLOUD = 0x0A08; static constexpr uint16_t TYPE_REPORT_DELAY = 0x0A0D; +static constexpr uint16_t TYPE_REPORT_SENSITIVITY = 0x0A0E; +static constexpr uint16_t TYPE_REPORT_TRIGGER = 0x0A0F; static constexpr uint16_t TYPE_REPORT_Z_RANGE = 0x0A10; +static constexpr uint16_t TYPE_REPORT_INSTALLATION = 0x0A11; static constexpr uint16_t TYPE_REPORT_LOW_POWER = 0x0A12; static constexpr uint16_t TYPE_REPORT_LOW_POWER_SLEEP = 0x0A13; static constexpr uint16_t TYPE_REPORT_WORK_MODE = 0x0A14; @@ -34,11 +37,23 @@ static constexpr uint32_t CMD_POINT_CLOUD_ON = 0x06; static constexpr uint32_t CMD_POINT_CLOUD_OFF = 0x07; static constexpr uint32_t CMD_TARGET_DISPLAY_ON = 0x08; static constexpr uint32_t CMD_TARGET_DISPLAY_OFF = 0x09; +static constexpr uint32_t CMD_SENSITIVITY_LOW = 0x0A; +static constexpr uint32_t CMD_SENSITIVITY_MEDIUM = 0x0B; +static constexpr uint32_t CMD_SENSITIVITY_HIGH = 0x0C; +static constexpr uint32_t CMD_GET_SENSITIVITY = 0x0D; +static constexpr uint32_t CMD_TRIGGER_SLOW = 0x0E; +static constexpr uint32_t CMD_TRIGGER_MEDIUM = 0x0F; +static constexpr uint32_t CMD_TRIGGER_FAST = 0x10; +static constexpr uint32_t CMD_GET_TRIGGER = 0x11; static constexpr uint32_t CMD_GET_Z_RANGE = 0x12; +static constexpr uint32_t CMD_INSTALL_TOP = 0x13; +static constexpr uint32_t CMD_INSTALL_SIDE = 0x14; +static constexpr uint32_t CMD_GET_INSTALLATION = 0x15; static constexpr uint32_t CMD_LOW_POWER_ON = 0x16; static constexpr uint32_t CMD_LOW_POWER_OFF = 0x17; static constexpr uint32_t CMD_GET_LOW_POWER = 0x18; static constexpr uint32_t CMD_GET_LOW_POWER_SLEEP = 0x19; +static constexpr uint32_t CMD_RESET_UNATTENDED = 0x1A; static constexpr uint16_t TARGET_DATA_LEN = 20; // x,y,z,dop_idx,cluster_id @@ -57,8 +72,30 @@ static const char *control_command_name(uint32_t command) { return "target_display_on"; case CMD_TARGET_DISPLAY_OFF: return "target_display_off"; + case CMD_SENSITIVITY_LOW: + return "sensitivity_low"; + case CMD_SENSITIVITY_MEDIUM: + return "sensitivity_medium"; + case CMD_SENSITIVITY_HIGH: + return "sensitivity_high"; + case CMD_GET_SENSITIVITY: + return "get_sensitivity"; + case CMD_TRIGGER_SLOW: + return "trigger_slow"; + case CMD_TRIGGER_MEDIUM: + return "trigger_medium"; + case CMD_TRIGGER_FAST: + return "trigger_fast"; + case CMD_GET_TRIGGER: + return "get_trigger"; case CMD_GET_Z_RANGE: return "get_z_range"; + case CMD_INSTALL_TOP: + return "install_top"; + case CMD_INSTALL_SIDE: + return "install_side"; + case CMD_GET_INSTALLATION: + return "get_installation"; case CMD_LOW_POWER_ON: return "low_power_on"; case CMD_LOW_POWER_OFF: @@ -67,6 +104,8 @@ static const char *control_command_name(uint32_t command) { return "get_low_power"; case CMD_GET_LOW_POWER_SLEEP: return "get_low_power_sleep"; + case CMD_RESET_UNATTENDED: + return "reset_unattended"; default: return "unknown"; } @@ -88,8 +127,14 @@ static const char *frame_type_name(uint16_t type) { return "report_point_cloud"; case TYPE_REPORT_DELAY: return "report_delay"; + case TYPE_REPORT_SENSITIVITY: + return "report_sensitivity"; + case TYPE_REPORT_TRIGGER: + return "report_trigger"; case TYPE_REPORT_Z_RANGE: return "report_z_range"; + case TYPE_REPORT_INSTALLATION: + return "report_installation"; case TYPE_REPORT_LOW_POWER: return "report_low_power"; case TYPE_REPORT_LOW_POWER_SLEEP: @@ -107,8 +152,14 @@ static bool is_expected_control_report(uint32_t command, uint16_t type) { switch (command) { case CMD_GET_DELAY: return type == TYPE_REPORT_DELAY; + case CMD_GET_SENSITIVITY: + return type == TYPE_REPORT_SENSITIVITY; + case CMD_GET_TRIGGER: + return type == TYPE_REPORT_TRIGGER; case CMD_GET_Z_RANGE: return type == TYPE_REPORT_Z_RANGE; + case CMD_GET_INSTALLATION: + return type == TYPE_REPORT_INSTALLATION; case CMD_GET_LOW_POWER: case CMD_LOW_POWER_ON: case CMD_LOW_POWER_OFF: @@ -259,6 +310,18 @@ void LD6002BComponent::setup() { this->send_control_command_(want_point_cloud ? CMD_POINT_CLOUD_ON : CMD_POINT_CLOUD_OFF); this->point_cloud_enabled_ = want_point_cloud; } + +#ifdef USE_SELECT + if (this->sensitivity_select_ != nullptr) { + this->send_control_command_(CMD_GET_SENSITIVITY); + } + if (this->trigger_speed_select_ != nullptr) { + this->send_control_command_(CMD_GET_TRIGGER); + } + if (this->installation_select_ != nullptr) { + this->send_control_command_(CMD_GET_INSTALLATION); + } +#endif #ifdef USE_NUMBER if (this->z_min_number_ != nullptr || this->z_max_number_ != nullptr) { this->send_control_command_(CMD_GET_Z_RANGE); @@ -345,6 +408,11 @@ void LD6002BComponent::dump_config() { LOG_SWITCH(" ", "Point Cloud", this->point_cloud_switch_); LOG_SWITCH(" ", "Target Display", this->target_display_switch_); #endif +#ifdef USE_SELECT + LOG_SELECT(" ", "Sensitivity", this->sensitivity_select_); + LOG_SELECT(" ", "Trigger Speed", this->trigger_speed_select_); + LOG_SELECT(" ", "Installation Mode", this->installation_select_); +#endif } void LD6002BComponent::loop() { @@ -492,9 +560,18 @@ void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_ case TYPE_REPORT_DELAY: this->handle_delay_report_(data, len); break; + case TYPE_REPORT_SENSITIVITY: + this->handle_sensitivity_report_(data, len); + break; + case TYPE_REPORT_TRIGGER: + this->handle_trigger_speed_report_(data, len); + break; case TYPE_REPORT_Z_RANGE: this->handle_z_range_report_(data, len); break; + case TYPE_REPORT_INSTALLATION: + this->handle_installation_report_(data, len); + break; case TYPE_REPORT_LOW_POWER: this->handle_low_power_report_(data, len); break; @@ -660,6 +737,32 @@ void LD6002BComponent::handle_delay_report_(const uint8_t *data, uint16_t len) { #endif } +void LD6002BComponent::handle_sensitivity_report_(const uint8_t *data, uint16_t len) { + if (len < 1) + return; +#ifdef USE_SELECT + if (this->sensitivity_select_ == nullptr) + return; + uint8_t value = data[0]; + if (value <= 2) { + this->sensitivity_select_->publish_state(value); + } +#endif +} + +void LD6002BComponent::handle_trigger_speed_report_(const uint8_t *data, uint16_t len) { + if (len < 1) + return; +#ifdef USE_SELECT + if (this->trigger_speed_select_ == nullptr) + return; + uint8_t value = data[0]; + if (value <= 2) { + this->trigger_speed_select_->publish_state(value); + } +#endif +} + void LD6002BComponent::handle_z_range_report_(const uint8_t *data, uint16_t len) { if (len < 8) return; @@ -673,6 +776,19 @@ void LD6002BComponent::handle_z_range_report_(const uint8_t *data, uint16_t len) #endif } +void LD6002BComponent::handle_installation_report_(const uint8_t *data, uint16_t len) { + if (len < 1) + return; +#ifdef USE_SELECT + if (this->installation_select_ == nullptr) + return; + uint8_t value = data[0]; + if (value <= 1) { + this->installation_select_->publish_state(value); + } +#endif +} + void LD6002BComponent::handle_low_power_report_(const uint8_t *data, uint16_t len) { if (len < 1) return; @@ -889,6 +1005,8 @@ void LD6002BComponent::send_command_internal_(uint16_t type, const uint8_t *data if (len > 0 && data != nullptr) { std::memcpy(this->wake_scratch_.data(), data, len); } + // A button pulse must not raise the pin in the middle of this one. + this->cancel_timeout(WAKE_BUTTON_TIMEOUT); this->wake_pulse_pending_ = true; this->wakeup_pin_->digital_write(false); const uint8_t generation = this->send_generation_; @@ -972,6 +1090,16 @@ void LD6002BComponent::send_z_range_() { this->queue_command_(TYPE_SET_Z_RANGE, data, sizeof(data)); } +void LD6002BComponent::wake_() { + // A command's own pulse raises the pin and writes after it, so ride along instead of + // claiming the flag: claiming it would send that command down the immediate-write path + // with the pin still low. + if (this->wakeup_pin_ == nullptr || this->wake_pulse_pending_) + return; + this->wakeup_pin_->digital_write(false); + this->set_timeout(WAKE_BUTTON_TIMEOUT, this->wakeup_pulse_ms_, [this]() { this->wakeup_pin_->digital_write(true); }); +} + void LD6002BComponent::set_number_value(NumberType type, float value) { switch (type) { case NumberType::HOLD_DELAY: { @@ -999,6 +1127,36 @@ void LD6002BComponent::set_number_value(NumberType type, float value) { } } +void LD6002BComponent::set_select_value(SelectType type, size_t index) { + switch (type) { + case SelectType::SENSITIVITY: + if (index == 0) { + this->send_control_command_(CMD_SENSITIVITY_LOW); + } else if (index == 1) { + this->send_control_command_(CMD_SENSITIVITY_MEDIUM); + } else if (index == 2) { + this->send_control_command_(CMD_SENSITIVITY_HIGH); + } + break; + case SelectType::TRIGGER_SPEED: + if (index == 0) { + this->send_control_command_(CMD_TRIGGER_SLOW); + } else if (index == 1) { + this->send_control_command_(CMD_TRIGGER_MEDIUM); + } else if (index == 2) { + this->send_control_command_(CMD_TRIGGER_FAST); + } + break; + case SelectType::INSTALLATION_MODE: + if (index == 0) { + this->send_control_command_(CMD_INSTALL_TOP); + } else if (index == 1) { + this->send_control_command_(CMD_INSTALL_SIDE); + } + break; + } +} + void LD6002BComponent::init_version_pref_() { #ifdef USE_TEXT_SENSOR if (this->ota_version_text_sensor_ == nullptr) { @@ -1124,4 +1282,36 @@ void LD6002BComponent::set_switch_state(SwitchType type, bool state) { } } +void LD6002BComponent::press_button(ButtonType type) { + switch (type) { + case ButtonType::GET_DELAY: + this->send_control_command_(CMD_GET_DELAY); + break; + case ButtonType::GET_SENSITIVITY: + this->send_control_command_(CMD_GET_SENSITIVITY); + break; + case ButtonType::GET_TRIGGER_SPEED: + this->send_control_command_(CMD_GET_TRIGGER); + break; + case ButtonType::GET_Z_RANGE: + this->send_control_command_(CMD_GET_Z_RANGE); + break; + case ButtonType::GET_INSTALLATION: + this->send_control_command_(CMD_GET_INSTALLATION); + break; + case ButtonType::GET_LOW_POWER_MODE: + this->send_control_command_(CMD_GET_LOW_POWER); + break; + case ButtonType::GET_LOW_POWER_SLEEP_TIME: + this->send_control_command_(CMD_GET_LOW_POWER_SLEEP); + break; + case ButtonType::RESET_UNATTENDED: + this->send_control_command_(CMD_RESET_UNATTENDED); + break; + case ButtonType::WAKE: + this->wake_(); + break; + } +} + } // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/ld6002b.h b/esphome/components/ld6002b/ld6002b.h index 5630d2d1a8..141f4ff027 100644 --- a/esphome/components/ld6002b/ld6002b.h +++ b/esphome/components/ld6002b/ld6002b.h @@ -3,6 +3,7 @@ #include "esphome/core/defines.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" #include "esphome/core/gpio.h" #include "esphome/components/uart/uart.h" #ifdef USE_SENSOR @@ -12,12 +13,14 @@ #include "esphome/components/binary_sensor/binary_sensor.h" #endif #ifdef USE_TEXT_SENSOR -#include "esphome/core/preferences.h" #include "esphome/components/text_sensor/text_sensor.h" #endif #ifdef USE_NUMBER #include "esphome/components/number/number.h" #endif +#ifdef USE_SELECT +#include "esphome/components/select/select.h" +#endif #ifdef USE_SWITCH #include "esphome/components/switch/switch.h" #endif @@ -40,12 +43,30 @@ enum class NumberType : uint8_t { LOW_POWER_SLEEP, }; +enum class SelectType : uint8_t { + SENSITIVITY, + TRIGGER_SPEED, + INSTALLATION_MODE, +}; + enum class SwitchType : uint8_t { LOW_POWER, POINT_CLOUD, TARGET_DISPLAY, }; +enum class ButtonType : uint8_t { + GET_DELAY, + GET_SENSITIVITY, + GET_TRIGGER_SPEED, + GET_Z_RANGE, + GET_INSTALLATION, + GET_LOW_POWER_MODE, + GET_LOW_POWER_SLEEP_TIME, + RESET_UNATTENDED, + WAKE, +}; + #ifdef USE_SENSOR struct TargetSensors { sensor::Sensor *x{nullptr}; @@ -124,6 +145,12 @@ class LD6002BComponent : public Component, public uart::UARTDevice { void set_low_power_sleep_number(number::Number *number) { this->low_power_sleep_number_ = number; } #endif +#ifdef USE_SELECT + void set_sensitivity_select(select::Select *select) { this->sensitivity_select_ = select; } + void set_trigger_speed_select(select::Select *select) { this->trigger_speed_select_ = select; } + void set_installation_select(select::Select *select) { this->installation_select_ = select; } +#endif + #ifdef USE_SWITCH void set_low_power_switch(switch_::Switch *sw) { this->low_power_switch_ = sw; } void set_point_cloud_switch(switch_::Switch *sw) { this->point_cloud_switch_ = sw; } @@ -131,7 +158,9 @@ class LD6002BComponent : public Component, public uart::UARTDevice { #endif void set_number_value(NumberType type, float value); + void set_select_value(SelectType type, size_t index); void set_switch_state(SwitchType type, bool state); + void press_button(ButtonType type); protected: enum class ParseState : uint8_t { SOF, HEADER, HCK, DATA, DCK, DISCARD }; @@ -148,7 +177,10 @@ class LD6002BComponent : public Component, public uart::UARTDevice { void handle_target_report_(const uint8_t *data, uint16_t len); void handle_point_cloud_(const uint8_t *data, uint16_t len); void handle_delay_report_(const uint8_t *data, uint16_t len); + void handle_sensitivity_report_(const uint8_t *data, uint16_t len); + void handle_trigger_speed_report_(const uint8_t *data, uint16_t len); void handle_z_range_report_(const uint8_t *data, uint16_t len); + void handle_installation_report_(const uint8_t *data, uint16_t len); void handle_low_power_report_(const uint8_t *data, uint16_t len); void handle_low_power_sleep_report_(const uint8_t *data, uint16_t len); void handle_work_mode_report_(const uint8_t *data, uint16_t len); @@ -173,6 +205,7 @@ class LD6002BComponent : public Component, public uart::UARTDevice { void write_frame_(uint16_t type, const uint8_t *data, uint8_t len, bool track); void send_control_command_(uint32_t command); void send_z_range_(); + void wake_(); static uint16_t read_u16_be(const uint8_t *data); static uint32_t read_u32_le(const uint8_t *data); @@ -202,6 +235,11 @@ class LD6002BComponent : public Component, public uart::UARTDevice { number::Number *z_max_number_{nullptr}; number::Number *low_power_sleep_number_{nullptr}; #endif +#ifdef USE_SELECT + select::Select *sensitivity_select_{nullptr}; + select::Select *trigger_speed_select_{nullptr}; + select::Select *installation_select_{nullptr}; +#endif #ifdef USE_SWITCH switch_::Switch *low_power_switch_{nullptr}; switch_::Switch *point_cloud_switch_{nullptr}; @@ -235,6 +273,9 @@ class LD6002BComponent : public Component, public uart::UARTDevice { // How long the module stays awake after any frame, and so still answers the next one. static constexpr uint32_t MODULE_AWAKE_MS = 10000; static constexpr uint8_t CMD_MAX_RETRIES = 3; + // Named so a repeated press replaces its own pending timeout instead of stacking + // another, and so the command path can cancel it when it takes the pin over. + static constexpr const char *WAKE_BUTTON_TIMEOUT = "wake_button"; // A reply cannot trail the frame that earned it for longer than this; the field worst case is ~726ms. static constexpr uint32_t STALE_ACK_MAX_AGE_MS = 1000; diff --git a/esphome/components/ld6002b/select/__init__.py b/esphome/components/ld6002b/select/__init__.py new file mode 100644 index 0000000000..3fcc117e2f --- /dev/null +++ b/esphome/components/ld6002b/select/__init__.py @@ -0,0 +1,60 @@ +import esphome.codegen as cg +from esphome.components import select +import esphome.config_validation as cv +from esphome.const import CONF_SENSITIVITY, ENTITY_CATEGORY_CONFIG + +from .. import LD6002BComponent, ld6002b_ns +from ..const import CONF_INSTALLATION_MODE, CONF_LD6002B_ID, CONF_TRIGGER_SPEED + +DEPENDENCIES = ["ld6002b"] + +LD6002BSelect = ld6002b_ns.class_("LD6002BSelect", select.Select) +SelectType = ld6002b_ns.enum("SelectType", is_class=True) + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_SENSITIVITY): select.select_schema( + LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_TRIGGER_SPEED): select.select_schema( + LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_INSTALLATION_MODE): select.select_schema( + LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG + ), + } +) + + +SELECT_MAP = ( + ( + CONF_SENSITIVITY, + SelectType.SENSITIVITY, + "set_sensitivity_select", + ["low", "medium", "high"], + ), + ( + CONF_TRIGGER_SPEED, + SelectType.TRIGGER_SPEED, + "set_trigger_speed_select", + ["slow", "medium", "fast"], + ), + ( + CONF_INSTALLATION_MODE, + SelectType.INSTALLATION_MODE, + "set_installation_select", + ["top", "side"], + ), +) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_LD6002B_ID]) + + for key, select_type, setter, options in SELECT_MAP: + if conf := config.get(key): + s = await select.new_select(conf, select_type, options=options) + await cg.register_parented(s, config[CONF_LD6002B_ID]) + cg.add(getattr(hub, setter)(s)) diff --git a/esphome/components/ld6002b/select/ld6002b_select.cpp b/esphome/components/ld6002b/select/ld6002b_select.cpp new file mode 100644 index 0000000000..a6b524a665 --- /dev/null +++ b/esphome/components/ld6002b/select/ld6002b_select.cpp @@ -0,0 +1,10 @@ +#include "ld6002b_select.h" + +namespace esphome::ld6002b { + +void LD6002BSelect::control(size_t index) { + this->publish_state(index); + this->parent_->set_select_value(this->type_, index); +} + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/select/ld6002b_select.h b/esphome/components/ld6002b/select/ld6002b_select.h new file mode 100644 index 0000000000..f380089a1e --- /dev/null +++ b/esphome/components/ld6002b/select/ld6002b_select.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/select/select.h" +#include "../ld6002b.h" + +namespace esphome::ld6002b { + +class LD6002BSelect : public select::Select, public Parented { + public: + explicit LD6002BSelect(SelectType type) : type_(type) {} + + protected: + void control(size_t index) override; + + SelectType type_; +}; + +} // namespace esphome::ld6002b diff --git a/tests/component_tests/ld6002b/__init__.py b/tests/component_tests/ld6002b/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/ld6002b/test_final_validate.py b/tests/component_tests/ld6002b/test_final_validate.py new file mode 100644 index 0000000000..49fa35eb13 --- /dev/null +++ b/tests/component_tests/ld6002b/test_final_validate.py @@ -0,0 +1,82 @@ +"""Tests for the wake button's wakeup_pin requirement in ld6002b.""" + +from __future__ import annotations + +import pytest + +from esphome.components.ld6002b.button import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA +from esphome.config import Config +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_WAKEUP_PIN, PlatformFramework +from esphome.core import ID +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +HUB_ID = "ld6002b_hub" + + +def _full_config(hub: ConfigType) -> Config: + """A full config carrying one ld6002b hub, as the ID pass leaves it. + + final_validate resolves the hub through get_path_for_id, so the declaring + path has to be registered the way validate_config registers it: the path of + the id value itself, whose parent is the hub's own config. + """ + full = Config() + full["ld6002b"] = [hub] + full.declare_ids.append((hub[CONF_ID], ["ld6002b", 0, CONF_ID])) + return full + + +def _hub(*, wakeup_pin: bool) -> ConfigType: + hub: ConfigType = {CONF_ID: ID(HUB_ID, is_declaration=True, type="ld6002b")} + if wakeup_pin: + hub[CONF_WAKEUP_PIN] = {"number": 4} + return hub + + +def _buttons(**buttons: str) -> ConfigType: + """A button platform config naming the given buttons on the shared hub.""" + config: ConfigType = { + "ld6002b_id": ID(HUB_ID, is_declaration=False, type="ld6002b") + } + config.update({key: {"name": name} for key, name in buttons.items()}) + return config + + +def _validated(config: ConfigType) -> ConfigType: + """Run the button schema, then the final validation the hub is checked in.""" + config = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(config) + return config + + +def test_wake_without_wakeup_pin_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """The wake button drives the pin directly, so a hub without one cannot serve it.""" + set_core_config( + PlatformFramework.ESP32_IDF, full_config=_full_config(_hub(wakeup_pin=False)) + ) + + with pytest.raises(cv.Invalid, match="wake requires wakeup_pin"): + _validated(_buttons(wake="Wake")) + + +def test_wake_with_wakeup_pin_passes(set_core_config: SetCoreConfigCallable) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, full_config=_full_config(_hub(wakeup_pin=True)) + ) + + _validated(_buttons(wake="Wake")) + + +def test_other_buttons_do_not_need_the_pin( + set_core_config: SetCoreConfigCallable, +) -> None: + """Only wake drives the pin; the query buttons stay usable without one.""" + set_core_config( + PlatformFramework.ESP32_IDF, full_config=_full_config(_hub(wakeup_pin=False)) + ) + + _validated(_buttons(get_delay="Get Delay")) diff --git a/tests/components/ld6002b/common.yaml b/tests/components/ld6002b/common.yaml index f8a9e95340..e31af49aec 100644 --- a/tests/components/ld6002b/common.yaml +++ b/tests/components/ld6002b/common.yaml @@ -71,6 +71,16 @@ number: low_power_sleep_time: name: Low Power Sleep +select: + - platform: ld6002b + ld6002b_id: ld6002b_radar + sensitivity: + name: Sensitivity + trigger_speed: + name: Trigger Speed + installation_mode: + name: Installation + switch: - platform: ld6002b ld6002b_id: ld6002b_radar @@ -80,3 +90,25 @@ switch: name: Point Cloud target_display: name: Target Display + +button: + - platform: ld6002b + ld6002b_id: ld6002b_radar + get_delay: + name: Get Delay + get_sensitivity: + name: Get Sensitivity + get_trigger_speed: + name: Get Trigger Speed + get_z_range: + name: Get Z Range + get_installation: + name: Get Installation + get_low_power_mode: + name: Get Low Power Mode + get_low_power_sleep_time: + name: Get Low Power Sleep + reset_unattended: + name: Reset Unattended + wake: + name: Wake From 2e1c517821067632512ed45c00ba603f2c1a76a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 7 Aug 2026 13:51:41 -0500 Subject: [PATCH 1311/1815] [bluetooth_proxy] Enable active connections on rp2 (#18132) --- .../components/bluetooth_proxy/__init__.py | 172 +++++++++++++++--- esphome/components/rp2040_ble/__init__.py | 2 + .../components/rp2_ble_tracker/__init__.py | 3 +- esphome/core/defines.h | 7 +- .../test_idf_max_connections_mirror.py | 10 +- .../test_outer_schema_mirror.py | 6 +- .../bluetooth_proxy/test_platform_gates.py | 100 +++++++++- .../bluetooth_connection/common.yaml | 8 + .../validate.rp2040-ard.yaml | 11 ++ .../test-passive.rp2040-ard.yaml | 11 ++ .../bluetooth_proxy/test.rp2040-ard.yaml | 4 +- 11 files changed, 287 insertions(+), 47 deletions(-) create mode 100644 tests/components/bluetooth_connection/common.yaml create mode 100644 tests/components/bluetooth_connection/validate.rp2040-ard.yaml create mode 100644 tests/components/bluetooth_proxy/test-passive.rp2040-ard.yaml diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index ed6dbfe557..057b15193a 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -59,15 +59,17 @@ _LOGGER = logging.getLogger(__name__) CONF_CONNECTION_SLOTS = "connection_slots" CONF_CACHE_SERVICES = "cache_services" CONF_CONNECTIONS = "connections" +CONF_BACKEND_ID = "backend_id" DEFAULT_CONNECTION_SLOTS = 3 bluetooth_proxy_ns = cg.esphome_ns.namespace("bluetooth_proxy") BluetoothProxy = bluetooth_proxy_ns.class_("BluetoothProxy", cg.Component) -# Mirrors esp32_ble.IDF_MAX_CONNECTIONS as a literal so the statically walkable -# CONFIG_SCHEMA below can state the connection_slots range without importing the -# esp32 BLE stack. tests/component_tests/bluetooth_proxy/ pins the two together. +# Mirrors esp32_ble.IDF_MAX_CONNECTIONS (the loosest platform cap): the esp32 +# schema builder asserts the two agree, tests/component_tests/bluetooth_proxy/ +# pins them together, and the outer walkable schema uses it as the +# connection_slots bound (per-platform schemas tighten it). _IDF_MAX_CONNECTIONS = 9 @@ -147,17 +149,99 @@ def _validate_no_active(config: ConfigType) -> ConfigType: return config -# Advertisement-only proxy on a neutral BLE hub: the hub's raw-advertisement -# callback feeds the same API batching. GATT/active connections are excluded at -# compile time — only the esp32 build compiles the connection stack; nothing -# reads HubCapabilities::gatt at runtime for this today. -# Keys both platform schemas must declare identically; each arm spreads this -# dict so the shared surface cannot drift. CONF_ACTIVE deliberately stays -# per-arm: its default differs (esp32 True, hub arms False — no GATT). +@functools.cache +def _rp2_config_schema() -> cv.All: + """Full proxy on the rp2 BLE hub: active connections through the BTstack + GATT client backend in bluetooth_connection. The slot limit comes from the + prebuilt BTstack library (one connection today); the code is built for N.""" + from esphome.components import rp2040_ble + + connection_schema = cv.Schema( + { + cv.GenerateID(): cv.declare_id(bluetooth_connection.HubBluetoothConnection), + cv.GenerateID(CONF_BACKEND_ID): cv.declare_id( + bluetooth_connection.RP2GattClient + ), + } + ) + + def populate_connections(config: ConfigType) -> ConfigType: + # One wrapper + backend pair per slot, declared during validation so + # their ids exist for codegen (the esp32 arm's `connections` pattern). + if not config[CONF_ACTIVE]: + return config + return { + **config, + CONF_CONNECTIONS: [ + connection_schema({}) for _ in range(config[CONF_CONNECTION_SLOTS]) + ], + } + + max_conn = bluetooth_connection.HUB_MAX_CONNECTIONS[PLATFORM_RP2] + schema = ( + cv.Schema( + { + **_COMMON_SCHEMA_KEYS, + # The GATT backend drives the controller directly (connect, GATT + # ops), not through the tracker hub. + cv.GenerateID(rp2040_ble.CONF_RP2040_BLE_ID): cv.use_id( + rp2040_ble.RP2040BLE + ), + cv.Optional(CONF_ACTIVE, default=True): cv.boolean, + cv.Optional( + CONF_CONNECTION_SLOTS, + default=min(DEFAULT_CONNECTION_SLOTS, max_conn), + ): cv.All( + cv.positive_int, + cv.Range( + min=1, + max=max_conn, + msg=f"rp2 supports at most {max_conn} connection slot(s); " + "the framework's BTstack library is built with " + f"MAX_NR_GATT_CLIENTS {max_conn}", + ), + ), + } + ) + .extend( + # ble_hub_id with the friendly no-tracker-configured guard. + ble_device_base.BLE_DEVICE_SCHEMA + ) + .extend(cv.COMPONENT_SCHEMA) + ) + return cv.All(schema, populate_connections) + + +async def _rp2_connections_to_code(var: cg.MockObj, config: ConfigType) -> None: + from esphome.components import rp2040_ble + + # One wrapper + backend pair per slot (the esp32 arm's pattern). + for connection_conf in config[CONF_CONNECTIONS]: + ble_device_base.request_gatt_client() + backend = cg.new_Pvariable(connection_conf[CONF_BACKEND_ID]) + await cg.register_component(backend, connection_conf) + await cg.register_parented(backend, config[rp2040_ble.CONF_RP2040_BLE_ID]) + connection = cg.new_Pvariable(connection_conf[CONF_ID]) + cg.add(connection.set_backend(backend)) + cg.add(var.register_connection(connection)) + + +# Per-platform schema builders and connection codegen; every key of +# bluetooth_connection.HUB_MAX_CONNECTIONS needs an entry in both (pinned by +# tests/component_tests/bluetooth_proxy/). +_GATT_HUB_SCHEMAS = {PLATFORM_RP2: _rp2_config_schema} +_GATT_HUB_TO_CODE = {PLATFORM_RP2: _rp2_connections_to_code} + + +# Keys every platform arm declares identically; each arm spreads this dict so +# the shared surface cannot drift. CONF_ACTIVE stays per-arm: its default +# differs (esp32 True, rp2 True, advertisement-only False). _COMMON_SCHEMA_KEYS = { cv.GenerateID(): cv.declare_id(BluetoothProxy), } +# Advertisement-only proxy on a neutral BLE hub: the hub's raw-advertisement +# callback feeds the same API batching, no connection stack compiled. _BLE_HUB_CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -178,9 +262,10 @@ _BLE_HUB_CONFIG_SCHEMA = cv.All( def _validate_platform(config: ConfigType) -> ConfigType: """Apply the schema for the platform actually being compiled. - esp32 keeps the full GATT proxy; every other platform gets the - advertisement-only shape, which rejects the connection-oriented options - above because its schema does not define them. + Three-way dispatch: esp32 gets the full GATT proxy, HUB_MAX_CONNECTIONS + platforms get their _GATT_HUB_SCHEMAS arm, the remaining hub platforms get + the advertisement-only shape; unsupported keys were already rejected by + name in _reject_unsupported_connection_keys. """ if config is SCHEMA_EXTRACT: # The language-schema dumper runs without a platform. Expose the esp32 @@ -196,18 +281,21 @@ def _validate_platform(config: ConfigType) -> ConfigType: raise cv.Invalid( f"bluetooth_proxy is not supported on {CORE.target_platform}: no " "active-scan-capable BLE tracker hub is available for this " - "platform. It runs on esp32 (full proxy), and the ln882x and rp2 " - "families (advertisement-only)." + "platform. It runs on esp32 and rp2 (full proxy) and the ln882x " + "family (advertisement-only)." ) + if CORE.target_platform in bluetooth_connection.HUB_MAX_CONNECTIONS: + return _GATT_HUB_SCHEMAS[CORE.target_platform]()(config) return _BLE_HUB_CONFIG_SCHEMA(config) -def _reject_connection_keys_off_esp32(config: ConfigType) -> ConfigType: - """Reject connection-oriented options by name on hub-only platforms. +def _reject_unsupported_connection_keys(config: ConfigType) -> ConfigType: + """Reject connection options a platform does not support, by name. - Runs before the walkable schema below so the user gets "this option does - not exist here" instead of the option's esp32 value range (which would - imply a smaller number is accepted). + GATT hub platforms keep connection_slots but reject the esp32-only keys; + advertisement-only hubs reject all three. Runs before the walkable schema + below so the user gets "this option does not exist here" instead of a + value-range error implying the option works. """ if not isinstance(config, dict) or CORE.is_esp32 or CORE.target_platform is None: return config @@ -216,14 +304,28 @@ def _reject_connection_keys_off_esp32(config: ConfigType) -> ConfigType: # reports "not supported on {platform}" instead of a key-level message # implying an advertisement-only proxy is available. return config - for key in (CONF_CONNECTION_SLOTS, CONF_CACHE_SERVICES, CONF_CONNECTIONS): + if CORE.target_platform in bluetooth_connection.HUB_MAX_CONNECTIONS: + # Full proxy: connection_slots is real here; the per-connection list + # exists internally but carries no user options, and the Bluedroid + # NVS service cache is esp32-only. + rejected = { + CONF_CONNECTIONS: ( + "has no per-connection options on this platform; use " + "'connection_slots' to set the count" + ), + CONF_CACHE_SERVICES: "is esp32-only (Bluedroid NVS service cache)", + } + else: + reason = ( + "requires active connection support; this platform runs the " + "advertisement-only proxy and has no such option" + ) + rejected = dict.fromkeys( + (CONF_CONNECTION_SLOTS, CONF_CACHE_SERVICES, CONF_CONNECTIONS), reason + ) + for key, reason in rejected.items(): if key in config: - raise cv.Invalid( - f"'{key}' requires active connection support, which needs the " - "esp32 GATT stack; this platform runs the advertisement-only " - "proxy and has no such option", - path=[key], - ) + raise cv.Invalid(f"'{key}' {reason}", path=[key]) return config @@ -241,11 +343,14 @@ def _reject_connection_keys_off_esp32(config: ConfigType) -> ConfigType: # rejects it as empty. extra=ALLOW_EXTRA passes `connections` through untouched # for _ESP32_CONFIG_SCHEMA to validate exactly once. CONFIG_SCHEMA = cv.All( - _reject_connection_keys_off_esp32, + _reject_unsupported_connection_keys, cv.Schema( { cv.Optional(CONF_ACTIVE): cv.boolean, cv.Optional(CONF_CACHE_SERVICES): cv.boolean, + # Bounded by the loosest platform cap so range walkers (the + # device-builder field-range sync) see a real Range; the + # per-platform schemas tighten it (1 on rp2) with their own error. cv.Optional(CONF_CONNECTION_SLOTS): cv.All( cv.positive_int, cv.Range(min=1, max=_IDF_MAX_CONNECTIONS), @@ -295,8 +400,15 @@ async def _to_code_ble_hub(config: ConfigType) -> None: cg.add(var.set_ble_hub(hub)) # The api component sizes BluetoothConnectionsFreeResponse.allocated with - # this define whenever a proxy is present; no connections off-esp32. - cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 0) + # this define whenever a proxy is present. Zero on advertisement-only hubs. + # Sized from the instantiated connections so the define can never diverge + # from the loop below (the define sizes fixed storage in the proxy). + slots = len(config.get(CONF_CONNECTIONS, ())) + cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", slots) + if not slots: + return + + await _GATT_HUB_TO_CODE[CORE.target_platform](var, config) async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/rp2040_ble/__init__.py b/esphome/components/rp2040_ble/__init__.py index 4baee7e234..e49dceb000 100644 --- a/esphome/components/rp2040_ble/__init__.py +++ b/esphome/components/rp2040_ble/__init__.py @@ -6,6 +6,8 @@ from esphome.types import ConfigType DEPENDENCIES = ["rp2"] CODEOWNERS = ["@bdraco"] +CONF_RP2040_BLE_ID = "rp2040_ble_id" + rp2040_ble_ns = cg.esphome_ns.namespace("rp2040_ble") RP2040BLE = rp2040_ble_ns.class_("RP2040BLE", cg.Component) diff --git a/esphome/components/rp2_ble_tracker/__init__.py b/esphome/components/rp2_ble_tracker/__init__.py index cfdd78f729..5840185768 100644 --- a/esphome/components/rp2_ble_tracker/__init__.py +++ b/esphome/components/rp2_ble_tracker/__init__.py @@ -12,6 +12,7 @@ Scan modes: import esphome.codegen as cg from esphome.components import ble_device_base, ota, rp2040_ble from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW +from esphome.components.rp2040_ble import CONF_RP2040_BLE_ID import esphome.config_validation as cv from esphome.const import ( CONF_ACTIVE, @@ -22,8 +23,6 @@ from esphome.const import ( ) from esphome.types import ConfigType -CONF_RP2040_BLE_ID = "rp2040_ble_id" - DEPENDENCIES = ["rp2"] AUTO_LOAD = ["ble_device_base", "rp2040_ble"] CODEOWNERS = ["@bdraco"] diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 63b9c87918..1685467a4b 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -253,10 +253,13 @@ #if defined(USE_ESP32) || defined(USE_LIBRETINY) || defined(USE_RP2) #define USE_BLUETOOTH_PROXY // Mirror the codegen values per platform: _to_code_esp32() emits the connection -// count (default 3), _to_code_ble_hub() emits 0 — so static analysis checks the -// same std::array instantiation a real build produces. +// count (default 3), _to_code_ble_hub() emits the slot count (1 on rp2, 0 on +// advertisement-only hubs) — so static analysis checks the same +// std::array instantiation a real build produces. #ifdef USE_ESP32 #define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 +#elif defined(USE_RP2) +#define BLUETOOTH_PROXY_MAX_CONNECTIONS 1 #else #define BLUETOOTH_PROXY_MAX_CONNECTIONS 0 #endif diff --git a/tests/component_tests/bluetooth_proxy/test_idf_max_connections_mirror.py b/tests/component_tests/bluetooth_proxy/test_idf_max_connections_mirror.py index 3042c91f84..58e463b32a 100644 --- a/tests/component_tests/bluetooth_proxy/test_idf_max_connections_mirror.py +++ b/tests/component_tests/bluetooth_proxy/test_idf_max_connections_mirror.py @@ -1,10 +1,10 @@ """bluetooth_proxy mirrors esp32_ble.IDF_MAX_CONNECTIONS; pin them together. -The mirror exists so the statically walkable CONFIG_SCHEMA can express the -connection_slots range without importing the esp32 BLE stack (that import -registers esp32-only automations on every platform). The runtime check in -_esp32_config_schema() only fires while validating an esp32 config, so this -test is what actually catches drift when the upstream constant changes. +The mirror doubles as the outer CONFIG_SCHEMA's connection_slots bound, and +the esp32 schema builder lazily imports esp32_ble and asserts the two values +agree, but that assert only fires while building the esp32 schema. This test +catches drift when the upstream constant changes without any esp32 config +being validated. """ from esphome.components import esp32_ble diff --git a/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py b/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py index 17a05a67b9..32e5daf4bb 100644 --- a/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py +++ b/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py @@ -4,8 +4,10 @@ them without importing the esp32 BLE stack; pin the two declarations together. The outer schema carries no defaults (the per-platform schema applies them), so drift cannot surface in validation output — a key renamed or removed in _esp32_config_schema() but not here would silently vanish from the dashboard's -field extractor. This test is what catches that; validator bounds are pinned -separately only for connection_slots (test_idf_max_connections_mirror). +field extractor. This test is what catches that. The outer schema bounds +connection_slots with the loosest platform cap (_IDF_MAX_CONNECTIONS) so range +walkers see a real Range; per-platform schemas tighten it, and the cap itself +is pinned by test_idf_max_connections_mirror. """ import voluptuous as vol diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py index 036530d942..55e6fe2ca7 100644 --- a/tests/component_tests/bluetooth_proxy/test_platform_gates.py +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -2,6 +2,9 @@ real reason, hub platforms reject GATT-only options by name, and the advertisement-only arm applies its own defaults.""" +from pathlib import Path +import re + import pytest from esphome import config_validation as cv @@ -19,9 +22,10 @@ from esphome.core import CORE from ..types import SetCoreConfigCallable +# Advertisement-only hub platforms; rp2 runs the full proxy and has its own +# tests below. HUB_PLATFORM_FRAMEWORKS = [ PlatformFramework.LN882X_ARDUINO, - PlatformFramework.RP2_ARDUINO, ] HUB_TRACKERS = { @@ -32,9 +36,11 @@ HUB_TRACKERS = { def test_hub_platform_list_covers_every_hub_platform() -> None: # A platform added to _HUB_PLATFORMS (bk72xx is planned) would otherwise - # get no gate coverage at all. - covered = {pf.value[0] for pf in HUB_PLATFORM_FRAMEWORKS} - assert covered == set(bluetooth_proxy._HUB_PLATFORMS) + # get no gate coverage at all; GATT platforms have their own tests. + advertisement_only = set(bluetooth_proxy._HUB_PLATFORMS) - set( + bluetooth_connection.HUB_MAX_CONNECTIONS + ) + assert {pf.value[0] for pf in HUB_PLATFORM_FRAMEWORKS} == advertisement_only assert set(HUB_TRACKERS) == set(bluetooth_proxy._HUB_PLATFORMS) @@ -122,6 +128,55 @@ def test_hub_platform_accepts_the_advertisement_only_shape( assert validated[CONF_ACTIVE] is False +def test_rp2_defaults_to_the_full_proxy( + set_core_config: SetCoreConfigCallable, +) -> None: + # esp32 parity: active defaults to true, with the platform's slot limit, + # and one populated connection entry for the codegen to index. + set_core_config(PlatformFramework.RP2_ARDUINO) + _register_tracker(PLATFORM_RP2) + validated = bluetooth_proxy.CONFIG_SCHEMA({}) + assert validated[CONF_ACTIVE] is True + assert validated[bluetooth_proxy.CONF_CONNECTION_SLOTS] == 1 + assert len(validated[bluetooth_proxy.CONF_CONNECTIONS]) == 1 + + +def test_rp2_accepts_explicit_passive( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.RP2_ARDUINO) + _register_tracker(PLATFORM_RP2) + validated = bluetooth_proxy.CONFIG_SCHEMA({CONF_ACTIVE: False}) + assert validated[CONF_ACTIVE] is False + assert bluetooth_proxy.CONF_CONNECTIONS not in validated + + +def test_rp2_rejects_slots_beyond_the_btstack_limit( + set_core_config: SetCoreConfigCallable, +) -> None: + # The prebuilt BTstack library allows exactly one GATT client connection. + set_core_config(PlatformFramework.RP2_ARDUINO) + _register_tracker(PLATFORM_RP2) + with pytest.raises(cv.Invalid, match="at most 1 connection slot"): + bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 2}) + # Values past even the loosest platform cap stop at the outer walkable + # schema, which stays bounded for range walkers (device-builder sync); + # in-range values get the platform message above. + with pytest.raises(cv.Invalid, match="at most 9"): + bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 12}) + + +def test_rp2_rejects_esp32_only_keys_by_name( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.RP2_ARDUINO) + _register_tracker(PLATFORM_RP2) + with pytest.raises(cv.Invalid, match="'cache_services' is esp32-only"): + bluetooth_proxy.CONFIG_SCHEMA({"cache_services": True}) + with pytest.raises(cv.Invalid, match="'connections' has no per-connection options"): + bluetooth_proxy.CONFIG_SCHEMA({"connections": [{}]}) + + def test_bluetooth_connection_auto_load_covers_its_includes() -> None: # The esp32 connection header includes esp32_ble_client; the auto load # must satisfy that closure itself (regression: it once relied on the @@ -134,3 +189,40 @@ def test_bluetooth_connection_auto_load_covers_its_includes() -> None: # dependency closures stay complete for build_codeowners and friends. _set_platform(None) assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "esp32_ble_client"] + + +def test_every_registered_hub_platform_has_a_schema_arm() -> None: + # A platform added to HUB_MAX_CONNECTIONS without a schema builder, + # codegen arm, or _HUB_PLATFORMS entry would only fail when a config for + # it is validated (or not even then); pin all three couplings here. + registered = set(bluetooth_connection.HUB_MAX_CONNECTIONS) + assert registered <= set(bluetooth_proxy._GATT_HUB_SCHEMAS) + assert registered <= set(bluetooth_proxy._GATT_HUB_TO_CODE) + assert registered <= set(bluetooth_proxy._HUB_PLATFORMS) + # The outer walkable schema's bound must stay the loosest platform cap. + assert ( + max(bluetooth_connection.HUB_MAX_CONNECTIONS.values()) + <= bluetooth_proxy._IDF_MAX_CONNECTIONS + ) + + +def test_defines_h_mirrors_the_rp2_slot_cap() -> None: + # esphome/core/defines.h carries a literal BLUETOOTH_PROXY_MAX_CONNECTIONS + # for static analysis; pin it to the real rp2 cap. + defines = (Path(__file__).parents[3] / "esphome" / "core" / "defines.h").read_text() + cap = bluetooth_connection.RP2_MAX_CONNECTIONS + # The rp2 arm's define, tolerating blank/comment lines in between. + match = re.search( + r"#elif defined\(USE_RP2\)\s*(?:(?://[^\n]*)?\n)+#define BLUETOOTH_PROXY_MAX_CONNECTIONS (\d+)", + defines, + ) + assert match is not None, "no USE_RP2 arm defines BLUETOOTH_PROXY_MAX_CONNECTIONS" + assert int(match.group(1)) == cap, ( + f"defines.h rp2 arm carries {match.group(1)}, expected {cap}" + ) + # The static-analysis client count scales with the same cap. + match = re.search(r"#define ESPHOME_BLE_GATT_CLIENT_COUNT (\d+)", defines) + assert match is not None, "ESPHOME_BLE_GATT_CLIENT_COUNT missing from defines.h" + assert int(match.group(1)) == cap, ( + f"ESPHOME_BLE_GATT_CLIENT_COUNT is {match.group(1)}, expected {cap}" + ) diff --git a/tests/components/bluetooth_connection/common.yaml b/tests/components/bluetooth_connection/common.yaml new file mode 100644 index 0000000000..5e84f4a678 --- /dev/null +++ b/tests/components/bluetooth_connection/common.yaml @@ -0,0 +1,8 @@ +wifi: + ssid: MySSID + password: password1 + +ota: + - platform: esphome + +api: diff --git a/tests/components/bluetooth_connection/validate.rp2040-ard.yaml b/tests/components/bluetooth_connection/validate.rp2040-ard.yaml new file mode 100644 index 0000000000..620aaa177b --- /dev/null +++ b/tests/components/bluetooth_connection/validate.rp2040-ard.yaml @@ -0,0 +1,11 @@ +# Distinct shape from bluetooth_proxy's own rp2 fixtures: explicit slot count +# on the platform whose backend lives in this component (validate-only, so it +# never collides with grouped builds). +packages: + common: !include common.yaml + +rp2_ble_tracker: + +bluetooth_proxy: + active: true + connection_slots: 1 diff --git a/tests/components/bluetooth_proxy/test-passive.rp2040-ard.yaml b/tests/components/bluetooth_proxy/test-passive.rp2040-ard.yaml new file mode 100644 index 0000000000..ae0f00d765 --- /dev/null +++ b/tests/components/bluetooth_proxy/test-passive.rp2040-ard.yaml @@ -0,0 +1,11 @@ +# Advertisement-only proxy on rp2 by explicit choice. Variant tests compile +# as their own builds when this component is tested individually; under CI +# batch grouping the active default build is what runs, so this fixture's +# guarantee is the individual run plus config validation. +packages: + common: !include common.yaml + +rp2_ble_tracker: + +bluetooth_proxy: + active: false diff --git a/tests/components/bluetooth_proxy/test.rp2040-ard.yaml b/tests/components/bluetooth_proxy/test.rp2040-ard.yaml index fd327bcc78..e219c7542d 100644 --- a/tests/components/bluetooth_proxy/test.rp2040-ard.yaml +++ b/tests/components/bluetooth_proxy/test.rp2040-ard.yaml @@ -1,5 +1,5 @@ -# Advertisement-only proxy on the rp2 BLE hub — the one non-esp32 platform the -# proxy admits today (active-scan-capable), and a target CI fully compiles. +# Full proxy on the rp2 BLE hub: active defaults to true here (esp32 parity), +# so this compiles the BTstack GATT client backend and one connection slot. # No explicit ble_hub_id: the generated binding resolves the single declared # hub, and an inline id here would collide with rp2_ble_tracker's own fixture # once CI merges both components into one grouped rp2040-ard build (grouped From c24e61439b374be455dc8f6e4af69aa080249a00 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 7 Aug 2026 21:17:11 +0200 Subject: [PATCH 1312/1815] [modbus] Add server support for read/write multiple registers (0x17) (#17357) Co-authored-by: J. Nick Koston --- esphome/components/modbus/modbus.cpp | 109 +++++++++++++++--- esphome/components/modbus/modbus.h | 25 ++-- .../components/modbus/modbus_definitions.h | 12 +- .../components/modbus/modbus_helpers_test.cpp | 12 ++ .../uart_mock_modbus_server_read_write.yaml | 106 +++++++++++++++++ ...mock_modbus_server_read_write_invalid.yaml | 81 +++++++++++++ tests/integration/test_uart_mock_modbus.py | 93 +++++++++++++++ 7 files changed, 407 insertions(+), 31 deletions(-) create mode 100644 tests/integration/fixtures/uart_mock_modbus_server_read_write.yaml create mode 100644 tests/integration/fixtures/uart_mock_modbus_server_read_write_invalid.yaml diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 57371b9e79..db97d56cc6 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -376,6 +376,50 @@ bool ModbusServerHub::check_register_range_(uint8_t address, uint8_t function_co return true; } +bool ModbusServerHub::build_or_reject_read_response_(uint8_t address, uint8_t function_code, ResponseStatus status, + uint16_t number_of_registers, const RegisterValues ®isters, + std::span response_buffer, uint16_t &response_len) { + // A handler that returns an exception leaves registers partially filled, so check the exception + // first and forward it before validating the register count on the success path. + if (status.has_value()) { + this->send_exception_(address, function_code, status.value()); + return false; + } + + if (registers.size() != number_of_registers) { + ESP_LOGE(TAG, "Incorrect response %" PRIu16 " requested, %zu returned", number_of_registers, registers.size()); + this->send_exception_(address, function_code, ExceptionCode::SERVICE_DEVICE_FAILURE); + return false; + } + + // The byte count is a single byte, so the count must stay within the protocol read limit; above it the + // static_cast(number_of_registers * 2) below would silently truncate the byte count. + if (number_of_registers > MAX_NUM_OF_REGISTERS_TO_READ) { + ESP_LOGE(TAG, "Read response of %" PRIu16 " registers exceeds the limit of %" PRIu16, number_of_registers, + MAX_NUM_OF_REGISTERS_TO_READ); + this->send_exception_(address, function_code, ExceptionCode::SERVICE_DEVICE_FAILURE); + return false; + } + + // Byte count(1) + two bytes per register. Checked here rather than at the call sites so the bound travels with + // the write itself: a future caller starting at a non-zero response_len, or passing a smaller buffer, is + // rejected instead of overrunning it before send_response_'s size guard can fire. + const size_t required = static_cast(response_len) + 1 + static_cast(number_of_registers) * 2; + if (required > response_buffer.size()) { + ESP_LOGE(TAG, "Read response needs %zu bytes but only %zu are available", required, response_buffer.size()); + this->send_exception_(address, function_code, ExceptionCode::SERVICE_DEVICE_FAILURE); + return false; + } + + response_buffer[response_len++] = static_cast(number_of_registers * 2); // actual byte count + for (auto r : registers) { + auto register_bytes = decode_value(r); + response_buffer[response_len++] = register_bytes[0]; + response_buffer[response_len++] = register_bytes[1]; + } + return true; +} + void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data) { ModbusServerDevice *device = this->find_device_(address); if (device == nullptr) { @@ -410,25 +454,10 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func status = device->on_read_input_registers(start_address, number_of_registers, registers); } - // A handler that returns an exception leaves registers partially filled, so check the exception - // first and forward it before validating the register count on the success path. - if (status.has_value()) { - this->send_exception_(address, function_code, status.value()); + if (!this->build_or_reject_read_response_(address, function_code, status, number_of_registers, registers, + response_buffer, response_len)) { return; } - - if (registers.size() != number_of_registers) { - ESP_LOGE(TAG, "Incorrect response %" PRIu16 " requested, %zu returned", number_of_registers, registers.size()); - this->send_exception_(address, function_code, ExceptionCode::SERVICE_DEVICE_FAILURE); - return; - } - - response_buffer[response_len++] = static_cast(number_of_registers * 2); // actual byte count - for (auto r : registers) { - auto register_bytes = decode_value(r); - response_buffer[response_len++] = register_bytes[0]; - response_buffer[response_len++] = register_bytes[1]; - } break; } case FunctionCode::WRITE_SINGLE_REGISTER: @@ -465,6 +494,52 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func response_len = 4; break; } + case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: { + // PDU data: read start address(2) + read quantity(2) + write start address(2) + write quantity(2) + + // write byte count(1) + write register values. Per Modbus 6.17 the write is performed before the read. + uint16_t read_start_address = helpers::get_data(data, 0); + uint16_t number_of_registers = helpers::get_data(data, 2); + uint16_t write_start_address = helpers::get_data(data, 4); + uint16_t number_of_write_registers = helpers::get_data(data, 6); + uint8_t number_of_bytes = helpers::get_data(data, 8); + if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_READ || + number_of_write_registers == 0 || number_of_write_registers > MAX_NUM_OF_REGISTERS_TO_WRITE_RW || + number_of_write_registers * 2 != number_of_bytes) { + ESP_LOGW(TAG, "Invalid number of registers (read %" PRIu16 ", write %" PRIu16 ") or bytes %" PRIu8, + number_of_registers, number_of_write_registers, number_of_bytes); + this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_VALUE); + return; + } + if (!this->check_register_range_(address, function_code, read_start_address, number_of_registers) || + !this->check_register_range_(address, function_code, write_start_address, number_of_write_registers)) { + return; + } + // Perform the write first (Modbus 6.17). Scoped so the write values are off the stack before the read + // values are allocated, keeping only one RegisterValues buffer live at a time. + { + // Assemble the written register values (host byte order); they follow the 9-byte request header. + RegisterValues write_registers; + for (uint16_t i = 0; i < number_of_write_registers; i++) { + write_registers.push_back(helpers::get_data(data, 9 + i * 2)); + } + // Dispatch to the standalone write and read handlers so any device implementing those supports 0x17 + // without a dedicated handler; a device that maps registers by address reconstructs the read response + // from the values it just stored. + status = device->on_write_registers(write_start_address, write_registers); + } + if (status.has_value()) { + this->send_exception_(address, function_code, status.value()); + return; + } + RegisterValues registers; + status = device->on_read_holding_registers(read_start_address, number_of_registers, registers); + + if (!this->build_or_reject_read_response_(address, function_code, status, number_of_registers, registers, + response_buffer, response_len)) { + return; + } + break; + } default: ESP_LOGW(TAG, "Unsupported function code %" PRIu8, function_code); this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_FUNCTION); diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index c73aa6878d..9f88213985 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -312,6 +312,14 @@ class ModbusClientHub : public Modbus { std::deque tx_buffer_; }; +// Transaction status: std::nullopt on success, otherwise a Modbus exception code +using ResponseStatus = std::optional; + +// Register values exchanged with server handlers, in host byte order. Sized at the larger of the two protocol +// maxima (read = 125 / 0x7D, write = 123 / 0x7B); the per-direction count limit is enforced by the hub, not by +// the capacity of this type. +using RegisterValues = StaticVector; + class ModbusServerHub : public Modbus { public: ModbusServerHub() = default; @@ -328,6 +336,15 @@ class ModbusServerHub : public Modbus { // On failure, logs and sends an ILLEGAL_DATA_ADDRESS exception to the client. bool check_register_range_(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_registers); + + // Builds the body of a register read response (byte count followed by the big-endian register values) into + // response_buffer. Shared by every function code that answers with register values, so the read reply stays + // identical across them. Returns false once an exception has been sent: the one the handler reported via + // status, or SERVICE_DEVICE_FAILURE if it returned the wrong number of registers, the count exceeds the + // protocol read limit, or the body does not fit. + bool build_or_reject_read_response_(uint8_t address, uint8_t function_code, ResponseStatus status, + uint16_t number_of_registers, const RegisterValues ®isters, + std::span response_buffer, uint16_t &response_len); void send_raw_(const uint8_t *payload, uint16_t len); void send_exception_(uint8_t address, uint8_t function_code, ExceptionCode exception_code); void send_response_(uint8_t address, uint8_t function_code, const uint8_t *payload, uint16_t payload_len); @@ -340,9 +357,6 @@ class ModbusServerHub : public Modbus { uint16_t deferred_payload_len_{0}; }; -// Transaction status: std::nullopt on success, otherwise a Modbus exception code -using ResponseStatus = std::optional; - /// Callback contract. Each accepted request ends in exactly ONE terminal: on_response() (data), /// on_error() (exception), on_no_response() (timeout/interruption), or on_not_sent() (dropped by /// clear_tx_queue_for_address before transmission). A request refused at send_pdu() (false return) @@ -563,11 +577,6 @@ class ESPDEPRECATED("Subclass ModbusClientDevice and override on_response()/on_e } }; -// Register values exchanged with server handlers, in host byte order. Sized at the larger of the two protocol -// maxima (read = 125 / 0x7D, write = 123 / 0x7B); the per-direction count limit is enforced by the hub, not by -// the capacity of this type. -using RegisterValues = StaticVector; - class ModbusServerDevice { public: virtual ~ModbusServerDevice() = default; diff --git a/esphome/components/modbus/modbus_definitions.h b/esphome/components/modbus/modbus_definitions.h index f883bfff30..b55b3ebe01 100644 --- a/esphome/components/modbus/modbus_definitions.h +++ b/esphome/components/modbus/modbus_definitions.h @@ -33,12 +33,12 @@ enum class FunctionCode : uint8_t { GET_COMM_EVENT_LOG = 0x0C, // not implemented WRITE_MULTIPLE_COILS = 0x0F, WRITE_MULTIPLE_REGISTERS = 0x10, - REPORT_SERVER_ID = 0x11, // not implemented - READ_FILE_RECORD = 0x14, // not implemented - WRITE_FILE_RECORD = 0x15, // not implemented - MASK_WRITE_REGISTER = 0x16, // not implemented - READ_WRITE_MULTIPLE_REGISTERS = 0x17, // not implemented - READ_FIFO_QUEUE = 0x18, // not implemented + REPORT_SERVER_ID = 0x11, // not implemented + READ_FILE_RECORD = 0x14, // not implemented + WRITE_FILE_RECORD = 0x15, // not implemented + MASK_WRITE_REGISTER = 0x16, // not implemented + READ_WRITE_MULTIPLE_REGISTERS = 0x17, + READ_FIFO_QUEUE = 0x18, // not implemented }; // Remove before 2027.2.0 diff --git a/tests/components/modbus/modbus_helpers_test.cpp b/tests/components/modbus/modbus_helpers_test.cpp index 553ec163b2..768c23c33c 100644 --- a/tests/components/modbus/modbus_helpers_test.cpp +++ b/tests/components/modbus/modbus_helpers_test.cpp @@ -92,6 +92,18 @@ TEST(ModbusClientFrameLength, ReadWriteMultipleByteCountCappedAtSpecLimit) { EXPECT_EQ(client_pdu_length(pdu, sizeof(pdu)), 10 + MAX_NUM_OF_REGISTERS_TO_WRITE_RW * 2); } +TEST(ModbusClientFrameLength, ReadWriteMultipleUsesByteCount) { + // read start(2) + read qty(2) + write start(2) + write qty(2) + byte count(1) then data + const uint8_t frame[] = {0x01, 0x17, 0x9C, 0xB9, 0x00, 0x02, 0x9C, 0x41, 0x00, 0x02, 0x04, 0xAA, 0xBB, 0xCC, 0xDD}; + EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 13 + 4); +} + +TEST(ModbusClientFrameLength, ReadWriteMultipleMissingByteCount) { + // header present up to the write quantity but the byte count byte (frame[10]) is absent + const uint8_t frame[] = {0x01, 0x17, 0x9C, 0xB9, 0x00, 0x02, 0x9C, 0x41, 0x00, 0x02}; + EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 13); +} + TEST(ModbusClientFrameLength, WriteMultipleMissingByteCount) { const uint8_t frame[] = {0x01, 0x10, 0x00, 0x00, 0x00, 0x02}; EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9); diff --git a/tests/integration/fixtures/uart_mock_modbus_server_read_write.yaml b/tests/integration/fixtures/uart_mock_modbus_server_read_write.yaml new file mode 100644 index 0000000000..e998861c2d --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_server_read_write.yaml @@ -0,0 +1,106 @@ +esphome: + name: uart-mock-modbus-srv-rw + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_dev + baud_rate: 9600 + rx_full_threshold: 120 + rx_timeout: 2 + auto_start: false + debug: + injections: + # FC 0x17 Read/Write Multiple Registers on device 1: + # write reg 0x0001 = 0x1234 (qty 1), then read regs 0x0001..0x0002 (qty 2). + # Per Modbus 6.17 the write is performed before the read, so reg 0x0001 must + # read back the just-written 0x1234 in the same request. + - delay: 100ms + inject_rx: + [0x01, 0x17, 0x00, 0x01, 0x00, 0x02, 0x00, 0x01, 0x00, 0x01, 0x02, 0x12, 0x34, 0x49, 0xD8] + # FC 0x17: write reg 0x0003 = 0x5678 (qty 1), then read reg 0x0003 (qty 1) - + # a write and read targeting a different register block. + - delay: 100ms + inject_rx: + [0x01, 0x17, 0x00, 0x03, 0x00, 0x01, 0x00, 0x03, 0x00, 0x01, 0x02, 0x56, 0x78, 0x9B, 0x10] + +globals: + - id: stored_1 + type: uint16_t + initial_value: "0" + - id: stored_3 + type: uint16_t + initial_value: "0" + +modbus: + uart_id: virtual_uart_dev + role: server + +modbus_server: + - address: 1 + registers: + # Writable + readable register backed by a global. The read publishes what it + # returns so the test can confirm the write half ran before the read half. + - address: 0x01 + value_type: U_WORD + read_lambda: |- + id(rw_read_1).publish_state(id(stored_1)); + return id(stored_1); + write_lambda: |- + id(stored_1) = x; + id(rw_write_1).publish_state(x); + return true; + # Read-only register, read together with 0x01 by the first request's 2-register read. + - address: 0x02 + value_type: U_WORD + read_lambda: |- + id(rw_read_2).publish_state(0x00AA); + return 0x00AA; + # Second writable + readable register, targeted by the second request. + - address: 0x03 + value_type: U_WORD + read_lambda: |- + id(rw_read_3).publish_state(id(stored_3)); + return id(stored_3); + write_lambda: |- + id(stored_3) = x; + id(rw_write_3).publish_state(x); + return true; + +sensor: + - platform: template + name: "rw_write_1" + id: rw_write_1 + - platform: template + name: "rw_read_1" + id: rw_read_1 + - platform: template + name: "rw_read_2" + id: rw_read_2 + - platform: template + name: "rw_write_3" + id: rw_write_3 + - platform: template + name: "rw_read_3" + id: rw_read_3 + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: "id(virtual_uart_dev).start_scenario();" diff --git a/tests/integration/fixtures/uart_mock_modbus_server_read_write_invalid.yaml b/tests/integration/fixtures/uart_mock_modbus_server_read_write_invalid.yaml new file mode 100644 index 0000000000..d3c091d67d --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_server_read_write_invalid.yaml @@ -0,0 +1,81 @@ +esphome: + name: uart-mock-modbus-srv-rw-inv + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_dev + baud_rate: 9600 + rx_full_threshold: 120 + rx_timeout: 2 + auto_start: false + debug: + injections: + # Malformed FC 0x17 Read/Write Multiple Registers, otherwise well formed (valid CRC): write + # quantity 2 but byte count 2 (2 registers need 4 bytes), i.e. byte count != 2x write quantity. + # The hub must reject it (ILLEGAL_DATA_VALUE) before touching any register. + - delay: 100ms + inject_rx: + [0x01, 0x17, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x02, 0x02, 0x12, 0x34, 0x09, 0x89] + # A valid FC 0x03 read of reg 0x0A injected afterwards. Its read_lambda fires the "probe" + # sensor, which (because injections run in order) signals the malformed frame was processed. + - delay: 100ms + inject_rx: [0x01, 0x03, 0x00, 0x0A, 0x00, 0x01, 0xA4, 0x08] + +modbus: + uart_id: virtual_uart_dev + role: server + +modbus_server: + - address: 1 + registers: + # The malformed request's write half spans 0x01-0x02. Both are registered so modbus_server's + # address pre-flight cannot reject the frame on its own: if the hub wrongly accepted it, these + # write_lambdas would fire the "write_seen" sensor. + - address: 0x01 + value_type: U_WORD + read_lambda: return 0; + write_lambda: |- + id(write_seen).publish_state(1); + return true; + - address: 0x02 + value_type: U_WORD + read_lambda: return 0; + write_lambda: |- + id(write_seen).publish_state(1); + return true; + # Processing probe: a valid read of this register fires after the malformed frame. + - address: 0x0A + value_type: U_WORD + read_lambda: |- + id(probe).publish_state(1); + return 1; + +sensor: + - platform: template + name: "write_seen" + id: write_seen + - platform: template + name: "probe" + id: probe + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: "id(virtual_uart_dev).start_scenario();" diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index bf163665f2..75adcc0e3d 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -203,6 +203,99 @@ async def test_uart_mock_modbus_server( _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.asyncio +async def test_uart_mock_modbus_server_read_write( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test modbus server FC 0x17 (read/write multiple registers). + + Injects raw 0x17 request frames and checks the round-trip through the + server's read_lambda/write_lambda, independent of how the hub dispatches + 0x17 internally: + * one request writes reg 0x01 then reads regs 0x01+0x02 -- reg 0x01 reads + back the just-written value (the write happens before the read per + Modbus 6.17), and the second register is returned by the same + multi-register read; + * a second request writes and reads a different register block. + """ + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + tracker = SensorTracker( + ["rw_write_1", "rw_read_1", "rw_read_2", "rw_write_3", "rw_read_3"] + ) + futures = tracker.expect_all( + { + "rw_write_1": 4660, # 0x1234 written to reg 0x0001 + "rw_read_1": 4660, # reg 0x0001 reads back the just-written value + "rw_read_2": 170, # 0x00AA read from reg 0x0002 in the same request + "rw_write_3": 22136, # 0x5678 written to reg 0x0003 + "rw_read_3": 22136, # reg 0x0003 reads back the just-written value + } + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + await tracker.await_all(futures) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_server_read_write_invalid( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test modbus server FC 0x17 invalid-frame handling. + + Injects a well-formed (valid CRC) 0x17 request whose write byte count (2) + does not match 2x the write quantity (2 registers need 4 bytes), so the hub + must reject it with ILLEGAL_DATA_VALUE before touching any register. A valid + read is injected right after as a processing marker. + + The invalid frame is verified via bus-level signals rather than the reply + frame on the wire: the mock UART cannot observe the server's TX reliably on + the host platform (the server's transmission is gated by a millis()-based tx + delay), so instead we assert the request is rejected exactly once and never + applied to a register. + """ + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + tracker = SensorTracker(["write_seen", "probe"]) + probe_seen = tracker.expect("probe", 1) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + # The probe read is injected after the malformed frame, so once it fires + # the malformed frame has already been processed. + await tracker.await_change(probe_seen, "probe") + + # Exactly one bus-level rejection for the malformed frame (no cascade)... + invalid_warnings = [ + line for line in warning_log_lines if "Invalid number of registers" in line + ] + assert len(invalid_warnings) == 1, ( + "Expected exactly one invalid-frame rejection, got warnings:\n" + + "\n".join(warning_log_lines) + ) + assert len(error_log_lines) == 0, ( + "Expected no modbus errors, but got:\n" + "\n".join(error_log_lines) + ) + # ...and the rejected write is never applied to the target register. + assert not tracker.sensor_states["write_seen"], ( + f"malformed 0x17 must not write, but write_seen fired: {tracker.sensor_states['write_seen']}" + ) + + @pytest.mark.asyncio async def test_uart_mock_modbus_server_controller( yaml_config: str, From ee13996ee64b8cc804062f96f31821db429537c2 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:17:26 +0000 Subject: [PATCH 1313/1815] Bump bundled esphome-device-builder to 1.9.4 (#18162) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index e928fd37ca..c0f7222bca 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.9.3 +RUN uv pip install --no-cache-dir esphome-device-builder==1.9.4 RUN \ platformio settings set enable_telemetry No \ From f5ee72753dcf273821ee84812720780966f742f9 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Fri, 7 Aug 2026 14:18:20 -0500 Subject: [PATCH 1314/1815] [modbus_client] Add typed read/write actions (#18078) Co-authored-by: J. Nick Koston Co-authored-by: Claude --- esphome/components/modbus/__init__.py | 8 + esphome/components/modbus/modbus_helpers.cpp | 24 +- esphome/components/modbus/modbus_helpers.h | 16 +- esphome/components/modbus_client/__init__.py | 277 +++++++++++++++++- .../components/modbus_client/modbus_client.h | 251 +++++++++++++++- tests/components/modbus_client/common.yaml | 62 ++++ .../uart_mock_modbus_client_typed.yaml | 176 +++++++++++ tests/integration/test_uart_mock_modbus.py | 52 ++++ 8 files changed, 847 insertions(+), 19 deletions(-) create mode 100644 tests/integration/fixtures/uart_mock_modbus_client_typed.yaml diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index bc52263aef..c91032801b 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -21,6 +21,14 @@ AUTO_LOAD = ["modbus_client"] # Mirrors modbus::MAX_PDU_SIZE in modbus_definitions.h: 256-byte RTU frame minus address and CRC. MAX_PDU_SIZE = 253 +# Mirror the per-function entity count limits from modbus_definitions.h. Keep these in step with the +# C++ constants of the same name; the spec sets a different ceiling for each function code. +MAX_NUM_OF_COILS_TO_READ = 2000 +MAX_NUM_OF_DISCRETE_INPUTS_TO_READ = 2000 +MAX_NUM_OF_COILS_TO_WRITE = 1968 +MAX_NUM_OF_REGISTERS_TO_READ = 125 +MAX_NUM_OF_REGISTERS_TO_WRITE = 123 + modbus_ns = cg.esphome_ns.namespace("modbus") Modbus = modbus_ns.class_("Modbus", cg.Component, uart.UARTDevice) ModbusServer = modbus_ns.class_("ModbusServerHub", Modbus) diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index 2c87928e9f..a0c8440c79 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -534,23 +534,33 @@ PduBuffer create_write_coils_pdu(uint16_t start_address, PackedBits bits) { return pdu; } -PduBuffer create_write_coils_pdu(uint16_t start_address, std::span values) { +// Shared by the two bool-container overloads: both index the same way, so the packing is written once. +template +static PduBuffer create_write_coils_pdu_from_bools(uint16_t start_address, const BoolContainer &values) { PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object) + const size_t count = values.size(); // Bound before packing so the transient buffer below cannot overflow; the shared core validates the rest. - if (values.size() > MAX_NUM_OF_COILS_TO_WRITE) { - ESP_LOGE(TAG, "values.size() %zu exceeds maximum coils to write %u, dropping request", values.size(), + if (count > MAX_NUM_OF_COILS_TO_WRITE) { + ESP_LOGE(TAG, "values.size() %zu exceeds maximum coils to write %u, dropping request", count, MAX_NUM_OF_COILS_TO_WRITE); return pdu; } - StaticVector packed; - for (size_t i = 0; i != values.size(); i++) { + CoilPackBuffer packed; + for (size_t i = 0; i != count; i++) { if (i % 8 == 0) packed.push_back(0); if (values[i]) packed[i / 8] |= (1 << (i % 8)); } - build_write_coils_pdu(pdu, start_address, - PackedBits(std::span(packed.data(), packed.size()), values.size())); + build_write_coils_pdu(pdu, start_address, PackedBits(std::span(packed.data(), packed.size()), count)); return pdu; } + +PduBuffer create_write_coils_pdu(uint16_t start_address, std::span values) { + return create_write_coils_pdu_from_bools(start_address, values); +} + +PduBuffer create_write_coils_pdu(uint16_t start_address, const std::vector &values) { + return create_write_coils_pdu_from_bools(start_address, values); +} } // namespace esphome::modbus::helpers diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index 36e3b6c7be..2c312b8a61 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -366,6 +366,8 @@ std::optional registers_to_number(const uint16_t *registers, size_t cou using PduBuffer = StaticVector; using ReadPdu = StaticVector; using WriteSinglePdu = StaticVector; +/// Scratch space for packing coils into wire layout: one bit per coil, sized for the spec maximum. +using CoilPackBuffer = StaticVector; /** Create a modbus read request PDU. * @param function_code one of READ_COILS, READ_DISCRETE_INPUTS, READ_HOLDING_REGISTERS, READ_INPUT_REGISTERS @@ -427,12 +429,22 @@ WriteSinglePdu create_write_single_coil_pdu(uint16_t address, bool value); * Function 0x0F Write Multiple Coils * @param start_address modbus address of the first coil to write * @param values coil values to write; the coil count is values.size() (at most MAX_NUM_OF_COILS_TO_WRITE, an - * over-long set is rejected and an empty PDU is returned). Note std::vector is bit-packed and - * does not convert to a span; pass a std::array or other contiguous bool container. + * over-long set is rejected and an empty PDU is returned) * @return PDU (function code + data, no address, no CRC) */ PduBuffer create_write_coils_pdu(uint16_t start_address, std::span values); +/** Create modbus write multiple coils command (function 0x0F) from a std::vector. + * Prefer the span overload above whenever the coils are already in contiguous storage - a std::array + * or any other contiguous bool container converts to it. This overload exists only because std::vector + * is bit-packed and so cannot convert to a span; without it every caller holding one re-implements the packing. + * @param start_address modbus address of the first coil to write + * @param values coil values to write; the coil count is values.size() (at most MAX_NUM_OF_COILS_TO_WRITE, an + * over-long set is rejected and an empty PDU is returned) + * @return PDU (function code + data, no address, no CRC) + */ +PduBuffer create_write_coils_pdu(uint16_t start_address, const std::vector &values); + /** Create modbus write multiple coils command (function 0x0F) from bits packed as on the wire. * @param start_address modbus address of the first coil to write * @param bits PackedBits view of the coils to write (at most MAX_NUM_OF_COILS_TO_WRITE); invalid diff --git a/esphome/components/modbus_client/__init__.py b/esphome/components/modbus_client/__init__.py index e8a75a1b6c..538936c93b 100644 --- a/esphome/components/modbus_client/__init__.py +++ b/esphome/components/modbus_client/__init__.py @@ -1,24 +1,58 @@ +from collections.abc import Callable +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components import modbus import esphome.config_validation as cv -from esphome.const import CONF_ADDRESS, CONF_ON_ERROR, CONF_ON_RESPONSE -from esphome.core import Lambda +from esphome.const import ( + CONF_ADDRESS, + CONF_COUNT, + CONF_ON_ERROR, + CONF_ON_RESPONSE, + CONF_VALUE, +) +from esphome.core import ID, Lambda from esphome.types import ConfigType, TemplateArgsType CODEOWNERS = ["@exciton"] DEPENDENCIES = ["modbus"] +CONF_ON_CUSTOM_RESPONSE = "on_custom_response" CONF_ON_NO_RESPONSE = "on_no_response" CONF_ON_NOT_SENT = "on_not_sent" CONF_ON_SENT = "on_sent" CONF_PDU = "pdu" CONF_RETRY = "retry" +CONF_START_ADDRESS = "start_address" +CONF_VALUES = "values" modbus_client_ns = cg.esphome_ns.namespace("modbus_client") ModbusClientSendAction = modbus_client_ns.class_( "ModbusClientSendAction", automation.Action, modbus.ModbusClientDevice ) +ReadRegistersAction = modbus_client_ns.class_( + "ReadRegistersAction", automation.Action, modbus.ModbusClientDevice +) +WriteSingleRegisterAction = modbus_client_ns.class_( + "WriteSingleRegisterAction", automation.Action, modbus.ModbusClientDevice +) +WriteSingleCoilAction = modbus_client_ns.class_( + "WriteSingleCoilAction", automation.Action, modbus.ModbusClientDevice +) +ReadBitsAction = modbus_client_ns.class_( + "ReadBitsAction", automation.Action, modbus.ModbusClientDevice +) + +WriteMultipleRegistersAction = modbus_client_ns.class_( + "WriteMultipleRegistersAction", automation.Action, modbus.ModbusClientDevice +) +WriteMultipleCoilsAction = modbus_client_ns.class_( + "WriteMultipleCoilsAction", automation.Action, modbus.ModbusClientDevice +) + +# Packed bit view delivered to read_coils / read_discrete_inputs on_response handlers. +PackedBits = modbus.modbus_ns.class_("PackedBits") # The exception code passed to on_error handlers. ExceptionCode = modbus.modbus_ns.enum("ExceptionCode") @@ -34,6 +68,11 @@ _PDU_SPAN = cg.std_span.template(cg.uint8.operator("const")) _PDU_BUFFER = modbus.modbus_ns.namespace("helpers").class_("PduBuffer") +def _packed_bit_bytes(bits: int) -> int: + """Mirrors modbus::packed_bit_bytes(): bytes needed to hold this many coils on the wire.""" + return (bits + 7) // 8 + + def _synchronous_handler(value: ConfigType) -> ConfigType: """Reject deferring actions in a handler: its PDU spans point into hub buffers that are reused once the handler returns, and DelayAction and friends capture the trigger args for later replay.""" @@ -108,6 +147,11 @@ async def register_client_action( await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) ) ) + # Present for every typed action and absent from modbus_client.send, which has a pdu instead. + if (start_address := config.get(CONF_START_ADDRESS)) is not None: + cg.add( + var.set_start_address(await cg.templatable(start_address, args, cg.uint16)) + ) if sent_conf := config.get(CONF_ON_SENT): await automation.build_automation( var.get_sent_trigger(), [(_PDU_SPAN, "request")], sent_conf @@ -116,6 +160,15 @@ async def register_client_action( await automation.build_automation( var.get_response_trigger(), response_args, response_conf ) + if custom_conf := config.get(CONF_ON_CUSTOM_RESPONSE): + # Tell the action a handler exists; without this it falls back to the base's warn-once log so an + # unhandled diverted reply is still reported instead of firing an empty trigger. + cg.add(var.set_custom_response_handled()) + await automation.build_automation( + var.get_custom_response_trigger(), + [(_PDU_SPAN, "request"), (_PDU_SPAN, "response")], + custom_conf, + ) if error_conf := config.get(CONF_ON_ERROR): await automation.build_automation( var.get_error_trigger(), @@ -162,3 +215,223 @@ async def modbus_client_send_to_code(config, action_id, template_arg, args): args, [(_PDU_SPAN, "request"), (_PDU_SPAN, "response")], ) + + +# --- Typed actions: request PDUs come from the device base's typed senders, replies from its dispatch, +# --- so on_response delivers decoded arguments (host-order words) instead of raw PDU spans. + +_REGISTER_SPAN = cg.std_span.template(cg.uint16.operator("const")) + +# Every typed action addresses a register or coil range and reports through the same two reply handlers. +_TYPED_ACTION_SCHEMA = _ACTION_BASE_SCHEMA.extend( + { + cv.Required(CONF_START_ADDRESS): cv.templatable(cv.hex_uint16_t), + # Both use _handler_schema(): the decoded arguments (values span, bits view) point at buffers the + # hub reuses once the handler returns, so a deferring action would resume on freed memory. + cv.Optional(CONF_ON_RESPONSE): _handler_schema(), + # A reply the dispatch gate diverts (not a standard-conformant transaction) arrives here with the + # raw request/response PDUs; real device exceptions still arrive via on_error. + cv.Optional(CONF_ON_CUSTOM_RESPONSE): _handler_schema(), + } +) + + +def _no_address_overflow(count_key: str) -> Callable[[ConfigType], ConfigType]: + """Reject a range that runs past the 16-bit address space, which the device could never answer. + + Only literal configurations can be checked: either operand may be a lambda, and its value is not known + until play(). The PDU builders repeat this check at runtime, so the lambda case is still rejected and + logged - just later. + """ + + def validate(config: ConfigType) -> ConfigType: + start = config[CONF_START_ADDRESS] + count = config[count_key] + if isinstance(start, Lambda) or isinstance(count, Lambda): + return config + # CONF_COUNT is a number; CONF_VALUES is the list whose length is the count. + length = count if isinstance(count, int) else len(count) + if start + length > 0x10000: + raise cv.Invalid( + f"{CONF_START_ADDRESS} 0x{start:04X} plus {length} entities runs past the end of the " + f"16-bit address space (last addressable entity is 0xFFFF)", + path=[CONF_START_ADDRESS], + ) + return config + + return validate + + +def _read_schema(max_count: int) -> cv.All: + """Read action schema. The spec sets the read ceiling per function code, so each one passes its own.""" + return cv.All( + _TYPED_ACTION_SCHEMA.extend( + { + cv.Optional(CONF_COUNT, default=1): cv.templatable( + cv.int_range(min=1, max=max_count) + ), + } + ), + _no_address_overflow(CONF_COUNT), + ) + + +def _write_multiple_schema(item: Callable[[Any], Any], max_values: int) -> cv.All: + """Multi-write action schema, differing only in the element type and the spec's per-function limit.""" + return cv.All( + _TYPED_ACTION_SCHEMA.extend( + { + cv.Required(CONF_VALUES): cv.templatable( + cv.All(cv.ensure_list(item), cv.Length(min=1, max=max_values)) + ), + } + ), + _no_address_overflow(CONF_VALUES), + ) + + +_READ_REGISTERS_SCHEMA = _read_schema(modbus.MAX_NUM_OF_REGISTERS_TO_READ) + +_WRITE_SINGLE_REGISTER_SCHEMA = _TYPED_ACTION_SCHEMA.extend( + {cv.Required(CONF_VALUE): cv.templatable(cv.hex_uint16_t)} +) + +# A coil is one bit, so the value is a boolean - the wire only carries 0x0000 or 0xFF00. +_WRITE_SINGLE_COIL_SCHEMA = _TYPED_ACTION_SCHEMA.extend( + {cv.Required(CONF_VALUE): cv.templatable(cv.boolean)} +) + + +async def _read_registers_to_code(config, action_id, template_arg, args, holding): + var = cg.new_Pvariable(action_id, template_arg, holding) + cg.add(var.set_count(await cg.templatable(config[CONF_COUNT], args, cg.uint16))) + return await register_client_action(var, config, args, [(_REGISTER_SPAN, "values")]) + + +@automation.register_action( + "modbus_client.read_holding_registers", + ReadRegistersAction, + _READ_REGISTERS_SCHEMA, + synchronous=True, +) +async def read_holding_registers_to_code(config, action_id, template_arg, args): + return await _read_registers_to_code(config, action_id, template_arg, args, True) + + +@automation.register_action( + "modbus_client.read_input_registers", + ReadRegistersAction, + _READ_REGISTERS_SCHEMA, + synchronous=True, +) +async def read_input_registers_to_code(config, action_id, template_arg, args): + return await _read_registers_to_code(config, action_id, template_arg, args, False) + + +async def _write_single_to_code(config, action_id, template_arg, args, value_type): + var = cg.new_Pvariable(action_id, template_arg) + cg.add(var.set_value(await cg.templatable(config[CONF_VALUE], args, value_type))) + return await register_client_action(var, config, args, []) + + +@automation.register_action( + "modbus_client.write_single_register", + WriteSingleRegisterAction, + _WRITE_SINGLE_REGISTER_SCHEMA, + synchronous=True, +) +async def write_single_register_to_code(config, action_id, template_arg, args): + return await _write_single_to_code(config, action_id, template_arg, args, cg.uint16) + + +@automation.register_action( + "modbus_client.write_single_coil", + WriteSingleCoilAction, + _WRITE_SINGLE_COIL_SCHEMA, + synchronous=True, +) +async def write_single_coil_to_code(config, action_id, template_arg, args): + return await _write_single_to_code(config, action_id, template_arg, args, cg.bool_) + + +_READ_COILS_SCHEMA = _read_schema(modbus.MAX_NUM_OF_COILS_TO_READ) +_READ_DISCRETE_INPUTS_SCHEMA = _read_schema(modbus.MAX_NUM_OF_DISCRETE_INPUTS_TO_READ) + + +async def _read_bits_to_code(config, action_id, template_arg, args, coils): + var = cg.new_Pvariable(action_id, template_arg, coils) + cg.add(var.set_count(await cg.templatable(config[CONF_COUNT], args, cg.uint16))) + return await register_client_action(var, config, args, [(PackedBits, "bits")]) + + +@automation.register_action( + "modbus_client.read_coils", + ReadBitsAction, + _READ_COILS_SCHEMA, + synchronous=True, +) +async def read_coils_to_code(config, action_id, template_arg, args): + return await _read_bits_to_code(config, action_id, template_arg, args, True) + + +@automation.register_action( + "modbus_client.read_discrete_inputs", + ReadBitsAction, + _READ_DISCRETE_INPUTS_SCHEMA, + synchronous=True, +) +async def read_discrete_inputs_to_code(config, action_id, template_arg, args): + return await _read_bits_to_code(config, action_id, template_arg, args, False) + + +_WRITE_MULTIPLE_REGISTERS_SCHEMA = _write_multiple_schema( + cv.hex_uint16_t, modbus.MAX_NUM_OF_REGISTERS_TO_WRITE +) + +_WRITE_MULTIPLE_COILS_SCHEMA = _write_multiple_schema( + cv.boolean, modbus.MAX_NUM_OF_COILS_TO_WRITE +) + + +@automation.register_action( + "modbus_client.write_multiple_registers", + WriteMultipleRegistersAction, + _WRITE_MULTIPLE_REGISTERS_SCHEMA, + synchronous=True, +) +async def write_multiple_registers_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + values = config[CONF_VALUES] + if cg.is_template(values): + templ = await cg.templatable(values, args, cg.std_vector.template(cg.uint16)) + cg.add(var.set_values_template(templ)) + else: + # A static list goes to flash, so play() sends straight from there without allocating. + arr_id = ID(f"{action_id}_values", is_declaration=True, type=cg.uint16) + arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*values)) + cg.add(var.set_values_static(arr, len(values))) + return await register_client_action(var, config, args, []) + + +@automation.register_action( + "modbus_client.write_multiple_coils", + WriteMultipleCoilsAction, + _WRITE_MULTIPLE_COILS_SCHEMA, + synchronous=True, +) +async def write_multiple_coils_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + values = config[CONF_VALUES] + if cg.is_template(values): + templ = await cg.templatable(values, args, cg.std_vector.template(cg.bool_)) + cg.add(var.set_values_template(templ)) + else: + # Pack to wire layout (LSB first) here, so the runtime neither allocates nor packs. + packed = bytearray(_packed_bit_bytes(len(values))) + for i, coil in enumerate(values): + if coil: + packed[i // 8] |= 1 << (i % 8) + arr_id = ID(f"{action_id}_values", is_declaration=True, type=cg.uint8) + arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*packed)) + cg.add(var.set_values_static(arr, len(values))) + return await register_client_action(var, config, args, []) diff --git a/esphome/components/modbus_client/modbus_client.h b/esphome/components/modbus_client/modbus_client.h index d4b3792a5f..599be85cb8 100644 --- a/esphome/components/modbus_client/modbus_client.h +++ b/esphome/components/modbus_client/modbus_client.h @@ -5,6 +5,7 @@ #include "esphome/core/automation.h" #include +#include namespace esphome::modbus_client { @@ -57,6 +58,16 @@ template class ClientActionBase : public Action, public m } protected: + /// The hub refuses some sends at the door with no callback (a duplicate write already pending, a full + /// queue, or an empty PDU - which is how the create_*_pdu() builders reject out-of-spec input). Every + /// send still gets exactly one outcome, so resolve refusals here via on_not_sent. + /// Takes a span, not a PduBuffer: the builders return right-sized buffers (a read PDU is 5 bytes), and + /// a PduBuffer parameter would widen each one to the 253-byte maximum just to cross the call. + void send_or_resolve_(std::span pdu) { + if (!this->send_pdu(pdu)) + this->on_not_sent(pdu); + } + Trigger> sent_trigger_; Trigger, modbus::ExceptionCode> error_trigger_; Trigger> no_response_trigger_; @@ -79,14 +90,7 @@ template class ModbusClientSendAction : public ClientActionBase< return &this->response_trigger_; } - void play(const Ts &...x) override { - auto pdu = this->pdu_.value(x...); - const std::span span(pdu.data(), pdu.size()); - // The hub refuses some sends at the door with no callback (an empty PDU, a duplicate write already - // pending, a full queue). Every send still gets exactly one outcome, so resolve those via on_not_sent. - if (!this->send_pdu(span)) - this->on_not_sent(span); - } + void play(const Ts &...x) override { this->send_or_resolve_(this->pdu_.value(x...)); } void on_response(std::span request_pdu, std::span response_pdu) override { this->response_trigger_.trigger(request_pdu, response_pdu); @@ -96,4 +100,235 @@ template class ModbusClientSendAction : public ClientActionBase< Trigger, std::span> response_trigger_; }; +/// Typed actions: these do NOT override the raw on_response, so the base ModbusClientDevice default runs +/// the shared dispatch (validation gate + decode) and the typed callbacks below fire directly on the +/// action. A reply the gate diverts (not a standard-conformant transaction) fires the on_custom_response +/// trigger with the raw request/response PDUs, so non-standard replies stay handleable; the spans are only +/// valid for the duration of the trigger. (For a typed-built request the gate can only divert on the +/// response, never with an exception status - real device exceptions arrive via on_error, which +/// ClientActionBase already routes straight to its trigger, so the typed callbacks below only ever see a +/// success status.) +template class TypedClientActionBase : public ClientActionBase { + public: + Trigger, std::span> *get_custom_response_trigger() { + return &this->custom_response_trigger_; + } + /// Set by codegen when the config declares on_custom_response. Without it an unhandled diverted reply + /// would fire an empty trigger and vanish, so the base's warn-once diagnostic has to stay reachable. + void set_custom_response_handled() { this->custom_response_handled_ = true; } + + void on_custom_response(std::span request_pdu, std::span response_pdu, + modbus::ResponseStatus status) override { + if (!this->custom_response_handled_) { + modbus::ModbusClientDevice::on_custom_response(request_pdu, response_pdu, status); + return; + } + this->custom_response_trigger_.trigger(request_pdu, response_pdu); + } + + protected: + /// Defensive assertion, not a live branch: ClientActionBase::on_error intercepts every exception reply + /// before the dispatch runs, so a typed callback below is only ever reached with a success status. Kept + /// so a future change to that interception cannot silently deliver an exception as a successful reply. + bool is_success_(modbus::ResponseStatus status) { return !status.has_value(); } + + Trigger, std::span> custom_response_trigger_; + bool custom_response_handled_{false}; +}; + +/// modbus_client.read_holding_registers / read_input_registers: on_response delivers the registers in +/// host byte order as `values` (only valid for the duration of the trigger). +template class ReadRegistersAction : public TypedClientActionBase { + public: + explicit ReadRegistersAction(bool holding) : holding_(holding) {} + TEMPLATABLE_VALUE(uint16_t, start_address) + TEMPLATABLE_VALUE(uint16_t, count) + + Trigger> *get_response_trigger() { return &this->response_trigger_; } + + void play(const Ts &...x) override { + const auto function_code = + this->holding_ ? modbus::FunctionCode::READ_HOLDING_REGISTERS : modbus::FunctionCode::READ_INPUT_REGISTERS; + this->send_or_resolve_( + modbus::helpers::create_read_pdu(function_code, this->start_address_.value(x...), this->count_.value(x...))); + } + void on_read_registers(modbus::EntityType entity_type, uint16_t start_address, std::span registers, + modbus::ResponseStatus status) override { + if (this->is_success_(status)) + this->response_trigger_.trigger(registers); + } + + protected: + Trigger> response_trigger_; + bool holding_; +}; + +/// modbus_client.read_coils / read_discrete_inputs: on_response delivers the bits as a PackedBits view +/// (bit 0 = the bit at start_address; only valid for the duration of the trigger). +template class ReadBitsAction : public TypedClientActionBase { + public: + explicit ReadBitsAction(bool coils) : coils_(coils) {} + TEMPLATABLE_VALUE(uint16_t, start_address) + TEMPLATABLE_VALUE(uint16_t, count) + + Trigger *get_response_trigger() { return &this->response_trigger_; } + + void play(const Ts &...x) override { + const auto function_code = + this->coils_ ? modbus::FunctionCode::READ_COILS : modbus::FunctionCode::READ_DISCRETE_INPUTS; + this->send_or_resolve_( + modbus::helpers::create_read_pdu(function_code, this->start_address_.value(x...), this->count_.value(x...))); + } + void on_read_bits(modbus::EntityType entity_type, uint16_t start_address, modbus::PackedBits bits, + modbus::ResponseStatus status) override { + if (this->is_success_(status)) + this->response_trigger_.trigger(bits); + } + + protected: + Trigger response_trigger_; + bool coils_; +}; + +/// modbus_client.write_single_register: on_response is the acknowledgement (the ack only echoes the +/// request, so it carries no arguments). +template class WriteSingleRegisterAction : public TypedClientActionBase { + public: + TEMPLATABLE_VALUE(uint16_t, start_address) + TEMPLATABLE_VALUE(uint16_t, value) + + Trigger<> *get_response_trigger() { return &this->response_trigger_; } + + void play(const Ts &...x) override { + this->send_or_resolve_( + modbus::helpers::create_write_single_register_pdu(this->start_address_.value(x...), this->value_.value(x...))); + } + void on_write_single_register(uint16_t address, uint16_t value, modbus::ResponseStatus status) override { + if (this->is_success_(status)) + this->response_trigger_.trigger(); + } + + protected: + Trigger<> response_trigger_; +}; + +/// modbus_client.write_single_coil: on_response is the acknowledgement (no arguments). A coil holds one +/// bit, so the value is a bool - the wire only ever carries 0x0000 or 0xFF00. +template class WriteSingleCoilAction : public TypedClientActionBase { + public: + TEMPLATABLE_VALUE(uint16_t, start_address) + TEMPLATABLE_VALUE(bool, value) + + Trigger<> *get_response_trigger() { return &this->response_trigger_; } + + void play(const Ts &...x) override { + this->send_or_resolve_( + modbus::helpers::create_write_single_coil_pdu(this->start_address_.value(x...), this->value_.value(x...))); + } + void on_write_single_coil(uint16_t address, bool value, modbus::ResponseStatus status) override { + if (this->is_success_(status)) + this->response_trigger_.trigger(); + } + + protected: + Trigger<> response_trigger_; +}; + +/// modbus_client.write_multiple_registers: on_response is the acknowledgement (no arguments). +/// A `values:` list is emitted as a flash array and sent straight from there; only a lambda builds a +/// vector, and only when it runs. Same split as canbus's send action, and for the same reason: a static +/// list must not allocate on every play(). +template class WriteMultipleRegistersAction : public TypedClientActionBase { + public: + TEMPLATABLE_VALUE(uint16_t, start_address) + + /// Static config: the registers live in flash, so play() neither allocates nor copies. + void set_values_static(const uint16_t *values, size_t len) { + this->values_.data = values; + this->len_ = static_cast(len); + } + /// Lambda config: the registers are only known at play() time. Stateless lambdas (all ESPHome + /// generates) convert to a plain function pointer, so this stays pointer-sized. + void set_values_template(std::vector (*func)(Ts...)) { + this->values_.func = func; + this->len_ = -1; // sentinel: template mode + } + + Trigger<> *get_response_trigger() { return &this->response_trigger_; } + + void play(const Ts &...x) override { + const uint16_t start = this->start_address_.value(x...); + // An empty or over-long set rejects into an empty PDU inside the builder, which logs the reason; + // the empty PDU then resolves via on_not_sent like any refused send. + if (this->len_ >= 0) { + this->send_or_resolve_(modbus::helpers::create_write_registers_pdu( + start, std::span(this->values_.data, static_cast(this->len_)))); + return; + } + const std::vector values = this->values_.func(x...); + this->send_or_resolve_(modbus::helpers::create_write_registers_pdu(start, std::span(values))); + } + void on_write_multiple_registers(uint16_t start_address, std::span registers, + modbus::ResponseStatus status) override { + if (this->is_success_(status)) + this->response_trigger_.trigger(); + } + + protected: + Trigger<> response_trigger_; + ssize_t len_{-1}; // -1 = template mode, >= 0 = static mode with this many registers + union Values { + std::vector (*func)(Ts...); + const uint16_t *data; + } values_; +}; + +/// modbus_client.write_multiple_coils: on_response is the acknowledgement (no arguments). +/// A `values:` list is packed into wire layout at code-generation time and stored in flash, so play() +/// neither allocates nor packs. A lambda returns std::vector - already a bit per coil rather than +/// a byte - and is packed into a stack buffer on the way to the builder. +template class WriteMultipleCoilsAction : public TypedClientActionBase { + public: + TEMPLATABLE_VALUE(uint16_t, start_address) + + /// Static config: `packed` is the wire layout (LSB first) held in flash, `count` the number of coils. + void set_values_static(const uint8_t *packed, size_t count) { + this->values_.packed = packed; + this->count_ = static_cast(count); + } + /// Lambda config: the coils are only known at play() time. + void set_values_template(std::vector (*func)(Ts...)) { + this->values_.func = func; + this->count_ = -1; // sentinel: template mode + } + + Trigger<> *get_response_trigger() { return &this->response_trigger_; } + + void play(const Ts &...x) override { + const uint16_t start = this->start_address_.value(x...); + if (this->count_ >= 0) { + const auto count = static_cast(this->count_); + this->send_or_resolve_(modbus::helpers::create_write_coils_pdu( + start, + modbus::PackedBits(std::span(this->values_.packed, modbus::packed_bit_bytes(count)), count))); + return; + } + // The builder packs and bound-checks; an over-long set is rejected and logged there. + this->send_or_resolve_(modbus::helpers::create_write_coils_pdu(start, this->values_.func(x...))); + } + void on_write_multiple_coils(uint16_t start_address, modbus::PackedBits bits, + modbus::ResponseStatus status) override { + if (this->is_success_(status)) + this->response_trigger_.trigger(); + } + + protected: + Trigger<> response_trigger_; + ssize_t count_{-1}; // -1 = template mode, >= 0 = static mode with this many coils + union Values { + std::vector (*func)(Ts...); + const uint8_t *packed; + } values_; +}; + } // namespace esphome::modbus_client diff --git a/tests/components/modbus_client/common.yaml b/tests/components/modbus_client/common.yaml index 19e81f4e48..19acbe9b0e 100644 --- a/tests/components/modbus_client/common.yaml +++ b/tests/components/modbus_client/common.yaml @@ -51,3 +51,65 @@ button: on_not_sent: then: - lambda: 'ESP_LOGW("modbus_client.test", "not sent fc 0x%X", request.empty() ? 0 : request[0]);' + - platform: template + name: "Typed Actions" + on_press: + - modbus_client.write_single_register: + address: 0x01 + start_address: 0x0102 + value: !lambda "return 42;" + on_response: + then: + - logger.log: "write acked" + on_error: + then: + - lambda: 'ESP_LOGW("modbus_client.test", "write exception %d", (int) exception_code);' + - modbus_client.read_holding_registers: + address: !lambda "return 1;" + start_address: 0x10 + count: 2 + on_response: + then: + - lambda: 'ESP_LOGI("modbus_client.test", "first=%u n=%u", values[0], (unsigned) values.size());' + on_no_response: + then: + - logger.log: "typed read timeout" + - modbus_client.read_input_registers: + address: 0x01 + start_address: 0x20 + on_custom_response: + then: + - lambda: |- + ESP_LOGW("modbus_client.test", "non-standard reply: fc 0x%02X, %u byte request", + response.empty() ? 0 : response[0], (unsigned) request.size()); + - modbus_client.write_single_coil: + address: 0x01 + start_address: 0x01 + value: true + - modbus_client.read_coils: + address: 0x01 + start_address: 0x03 + count: 16 + on_response: + then: + - lambda: 'ESP_LOGI("modbus_client.test", "coil0=%d n=%u", bits[0], (unsigned) bits.size());' + - modbus_client.read_discrete_inputs: + address: 0x01 + start_address: 0x00 + on_error: + then: + - lambda: 'ESP_LOGW("modbus_client.test", "fc 0x%X exception %d", request.empty() ? 0 : request[0], (int) exception_code);' + - modbus_client.write_multiple_registers: + address: 0x01 + start_address: 0x0200 + values: !lambda "return {1, 2, 3};" + on_response: + then: + - lambda: 'ESP_LOGI("modbus_client.test", "multi write acked");' + - modbus_client.write_multiple_coils: + address: 0x01 + start_address: 0x0010 + values: [true, false, true] + on_error: + then: + - lambda: 'ESP_LOGW("modbus_client.test", "fc 0x%X exception %d", request.empty() ? 0 : request[0], (int) exception_code);' diff --git a/tests/integration/fixtures/uart_mock_modbus_client_typed.yaml b/tests/integration/fixtures/uart_mock_modbus_client_typed.yaml new file mode 100644 index 0000000000..167ad2c5bb --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_client_typed.yaml @@ -0,0 +1,176 @@ +esphome: + name: uart-mock-modbus-client-typed + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_client + data: !lambda return data; + - id: virtual_uart_client + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_client + id: virtual_modbus_client + role: client + turnaround_time: 10ms + +globals: + - id: reg10 + type: uint16_t + initial_value: "0" + - id: reg11 + type: uint16_t + initial_value: "0" + - id: reg12 + type: uint16_t + initial_value: "0" + +modbus_server: + - address: 1 + modbus_id: virtual_modbus_server + id: modbus_server_1 + registers: + - address: 0x10 + value_type: U_WORD + read_lambda: return id(reg10); + write_lambda: |- + id(reg10) = x; + return true; + - address: 0x11 + value_type: U_WORD + read_lambda: return id(reg11); + write_lambda: |- + id(reg11) = x; + return true; + - address: 0x12 + value_type: U_WORD + read_lambda: return id(reg12); + write_lambda: |- + id(reg12) = x; + return true; + +sensor: + - platform: template + name: "typed_value" + id: typed_value + - platform: template + name: "ack_flag" + id: ack_flag + - platform: template + name: "error_code" + id: error_code + - platform: template + name: "coil_error_code" + id: coil_error_code + - platform: template + name: "multi_value" + id: multi_value + - platform: template + name: "multi_coil_error" + id: multi_coil_error + - platform: template + name: "not_sent_flag" + id: not_sent_flag + +# Typed actions end to end: a typed write lands on the server (ack -> ack_flag), the typed read-back +# decodes the written value from the reply words (values[0] -> typed_value), and a read of an unserved +# register resolves via on_error with the device's exception code (-> error_code). +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - modbus_client.write_single_register: + address: 1 + start_address: 0x10 + value: 777 + on_response: + then: + - lambda: "id(ack_flag).publish_state(1);" + - modbus_client.read_holding_registers: + address: 1 + start_address: 0x10 + on_response: + then: + - lambda: |- + if (!values.empty()) + id(typed_value).publish_state(values[0]); + - modbus_client.read_holding_registers: + address: 1 + start_address: 0x99 + on_error: + then: + - lambda: "id(error_code).publish_state((int) exception_code);" + # The mock server is register-only, so a coil read draws ILLEGAL_FUNCTION - proving the bit-read + # action's request PDU and its typed error delivery. + - modbus_client.read_coils: + address: 1 + start_address: 0x00 + count: 8 + on_error: + then: + - lambda: "id(coil_error_code).publish_state((int) exception_code);" + # Multi-register write (fc 0x10, served) then read-back of the second written register. + - modbus_client.write_multiple_registers: + address: 1 + start_address: 0x11 + values: [111, 222] + on_response: + then: + - modbus_client.read_holding_registers: + address: 1 + start_address: 0x12 + on_response: + then: + - lambda: |- + if (!values.empty()) + id(multi_value).publish_state(values[0]); + # A count lambda can go out of spec at runtime: the builder rejects it into an empty PDU, the hub + # refuses that at the door, and the send resolves via on_not_sent (no reply will ever come). + - modbus_client.read_holding_registers: + address: 1 + start_address: 0x10 + count: !lambda "return 0;" + on_not_sent: + then: + - lambda: "id(not_sent_flag).publish_state(1);" + # Multi-coil write (fc 0x0F): the register-only server answers ILLEGAL_FUNCTION. + - modbus_client.write_multiple_coils: + address: 1 + start_address: 0x00 + values: [true, false, true] + on_error: + then: + - lambda: "id(multi_coil_error).publish_state((int) exception_code);" diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 75adcc0e3d..ca0041cc5b 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -434,6 +434,58 @@ async def test_uart_mock_modbus_server_controller_multiple( _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.asyncio +async def test_uart_mock_modbus_client_typed( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test the typed modbus_client actions end to end (each action its own hub device). + + Start Scenario fires three typed actions: write_single_register puts 777 in server register 0x10 (the + ack fires on_response -> ack_flag); read_holding_registers reads it back, + with the reply decoded by the shared device dispatch into host-order words (values[0] -> typed_value); + a read of unserved register 0x99 resolves via on_error with the device's exception code + (ILLEGAL_DATA_ADDRESS = 2 -> error_code); a coil read of the register-only server resolves via + on_error with ILLEGAL_FUNCTION (= 1 -> coil_error_code), proving the bit-read request and typed error + delivery. A multi-register write (fc 0x10) lands on registers 0x11/0x12 with the read-back of 0x12 + chained inside its ack handler (-> multi_value = 222); a multi-coil write draws ILLEGAL_FUNCTION from + the register-only server (-> multi_coil_error = 1). A read whose count lambda returns 0 at runtime + builds an empty (rejected) PDU, is refused at the hub door, and resolves via on_not_sent + (-> not_sent_flag). + """ + + tracker = SensorTracker( + [ + "typed_value", + "ack_flag", + "error_code", + "coil_error_code", + "multi_value", + "multi_coil_error", + "not_sent_flag", + ] + ) + futures = tracker.expect_all( + { + "typed_value": 777, + "ack_flag": 1, + "error_code": 2, + "coil_error_code": 1, + "multi_value": 222, + "multi_coil_error": 1, + "not_sent_flag": 1, + } + ) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + await tracker.await_all(futures) + + @pytest.mark.asyncio async def test_uart_mock_modbus_client_inline( yaml_config: str, From 23a34545512bda95450973acbc8d2f572eebfe13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Fri, 7 Aug 2026 22:21:46 +0300 Subject: [PATCH 1315/1815] [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 4: ruuvi_ble, ruuvitag, b_parasite) (#18161) --- esphome/components/b_parasite/b_parasite.cpp | 6 +-- esphome/components/b_parasite/b_parasite.h | 10 ++--- esphome/components/b_parasite/sensor.py | 13 ++++--- esphome/components/ruuvi_ble/__init__.py | 21 +++++----- esphome/components/ruuvi_ble/ruuvi_ble.cpp | 10 ++--- esphome/components/ruuvi_ble/ruuvi_ble.h | 12 ++---- esphome/components/ruuvitag/ruuvitag.cpp | 4 -- esphome/components/ruuvitag/ruuvitag.h | 10 ++--- esphome/components/ruuvitag/sensor.py | 14 +++---- tests/components/b_parasite/common-ln.yaml | 7 ++++ tests/components/b_parasite/common.yaml | 3 ++ .../b_parasite/test.ln882x-ard.yaml | 3 ++ .../b_parasite/validate.bk72xx-ard.yaml | 26 +++++++++++++ tests/components/ruuvi_ble/common-ln.yaml | 1 + tests/components/ruuvi_ble/common.yaml | 3 ++ .../components/ruuvi_ble/test.ln882x-ard.yaml | 3 ++ .../ruuvi_ble/validate.bk72xx-ard.yaml | 9 +++++ tests/components/ruuvitag/common-ln.yaml | 7 ++++ tests/components/ruuvitag/common.yaml | 3 ++ .../components/ruuvitag/test.ln882x-ard.yaml | 3 ++ .../ruuvitag/validate.bk72xx-ard.yaml | 38 +++++++++++++++++++ 21 files changed, 146 insertions(+), 60 deletions(-) create mode 100644 tests/components/b_parasite/common-ln.yaml create mode 100644 tests/components/b_parasite/test.ln882x-ard.yaml create mode 100644 tests/components/b_parasite/validate.bk72xx-ard.yaml create mode 100644 tests/components/ruuvi_ble/common-ln.yaml create mode 100644 tests/components/ruuvi_ble/test.ln882x-ard.yaml create mode 100644 tests/components/ruuvi_ble/validate.bk72xx-ard.yaml create mode 100644 tests/components/ruuvitag/common-ln.yaml create mode 100644 tests/components/ruuvitag/test.ln882x-ard.yaml create mode 100644 tests/components/ruuvitag/validate.bk72xx-ard.yaml diff --git a/esphome/components/b_parasite/b_parasite.cpp b/esphome/components/b_parasite/b_parasite.cpp index 160d22a5b6..e0ae824b0d 100644 --- a/esphome/components/b_parasite/b_parasite.cpp +++ b/esphome/components/b_parasite/b_parasite.cpp @@ -1,8 +1,6 @@ #include "b_parasite.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::b_parasite { static const char *const TAG = "b_parasite"; @@ -16,7 +14,7 @@ void BParasite::dump_config() { LOG_SENSOR(" ", "Illuminance", this->illuminance_); } -bool BParasite::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool BParasite::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -113,5 +111,3 @@ bool BParasite::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { } } // namespace esphome::b_parasite - -#endif // USE_ESP32 diff --git a/esphome/components/b_parasite/b_parasite.h b/esphome/components/b_parasite/b_parasite.h index 1d5ac6e702..65540e82fb 100644 --- a/esphome/components/b_parasite/b_parasite.h +++ b/esphome/components/b_parasite/b_parasite.h @@ -2,18 +2,16 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" - -#ifdef USE_ESP32 +#include "esphome/components/ble_device_base/ble_device.h" namespace esphome::b_parasite { -class BParasite final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class BParasite final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const std::string &bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_battery_voltage(sensor::Sensor *battery_voltage) { battery_voltage_ = battery_voltage; } @@ -35,5 +33,3 @@ class BParasite final : public Component, public esp32_ble_tracker::ESPBTDeviceL }; } // namespace esphome::b_parasite - -#endif // USE_ESP32 diff --git a/esphome/components/b_parasite/sensor.py b/esphome/components/b_parasite/sensor.py index 041303ad8b..673c5981b7 100644 --- a/esphome/components/b_parasite/sensor.py +++ b/esphome/components/b_parasite/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_VOLTAGE, @@ -23,14 +23,15 @@ from esphome.const import ( CODEOWNERS = ["@rbaron"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] b_parasite_ns = cg.esphome_ns.namespace("b_parasite") BParasite = b_parasite_ns.class_( - "BParasite", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "BParasite", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("b_parasite"), cv.Schema( { cv.GenerateID(): cv.declare_id(BParasite), @@ -68,15 +69,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/ruuvi_ble/__init__.py b/esphome/components/ruuvi_ble/__init__.py index 13d49d3cfe..8ab95dcb72 100644 --- a/esphome/components/ruuvi_ble/__init__.py +++ b/esphome/components/ruuvi_ble/__init__.py @@ -1,22 +1,25 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker +from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_ID -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] ruuvi_ble_ns = cg.esphome_ns.namespace("ruuvi_ble") RuuviListener = ruuvi_ble_ns.class_( - "RuuviListener", esp32_ble_tracker.ESPBTDeviceListener + "RuuviListener", ble_device_base.ESPBTDeviceListener ) -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(RuuviListener), - } -).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("ruuvi_ble"), + cv.Schema( + { + cv.GenerateID(): cv.declare_id(RuuviListener), + } + ).extend(ble_device_base.BLE_DEVICE_SCHEMA), +) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) diff --git a/esphome/components/ruuvi_ble/ruuvi_ble.cpp b/esphome/components/ruuvi_ble/ruuvi_ble.cpp index b73b73d56e..19753b3c9e 100644 --- a/esphome/components/ruuvi_ble/ruuvi_ble.cpp +++ b/esphome/components/ruuvi_ble/ruuvi_ble.cpp @@ -1,13 +1,11 @@ #include "ruuvi_ble.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::ruuvi_ble { static const char *const TAG = "ruuvi_ble"; -bool parse_ruuvi_data_byte(const esp32_ble_tracker::adv_data_t &adv_data, RuuviParseResult &result) { +bool parse_ruuvi_data_byte(const ble_device_base::adv_data_t &adv_data, RuuviParseResult &result) { const uint8_t data_type = adv_data[0]; const auto *data = &adv_data[1]; switch (data_type) { @@ -80,7 +78,7 @@ bool parse_ruuvi_data_byte(const esp32_ble_tracker::adv_data_t &adv_data, RuuviP return false; } } -optional parse_ruuvi(const esp32_ble_tracker::ESPBTDevice &device) { +optional parse_ruuvi(const ble_device_base::ESPBTDevice &device) { bool success = false; RuuviParseResult result{}; for (auto &it : device.get_manufacturer_datas()) { @@ -96,7 +94,7 @@ optional parse_ruuvi(const esp32_ble_tracker::ESPBTDevice &dev return result; } -bool RuuviListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool RuuviListener::parse_device(const ble_device_base::ESPBTDevice &device) { auto res = parse_ruuvi(device); if (!res.has_value()) return false; @@ -142,5 +140,3 @@ bool RuuviListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { } } // namespace esphome::ruuvi_ble - -#endif diff --git a/esphome/components/ruuvi_ble/ruuvi_ble.h b/esphome/components/ruuvi_ble/ruuvi_ble.h index e372b24944..d345790e3a 100644 --- a/esphome/components/ruuvi_ble/ruuvi_ble.h +++ b/esphome/components/ruuvi_ble/ruuvi_ble.h @@ -1,9 +1,7 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" - -#ifdef USE_ESP32 +#include "esphome/components/ble_device_base/ble_device.h" namespace esphome::ruuvi_ble { @@ -23,13 +21,11 @@ struct RuuviParseResult { bool parse_ruuvi_data_byte(uint8_t data_type, const uint8_t *data, uint8_t data_length, RuuviParseResult &result); -optional parse_ruuvi(const esp32_ble_tracker::ESPBTDevice &device); +optional parse_ruuvi(const ble_device_base::ESPBTDevice &device); -class RuuviListener final : public esp32_ble_tracker::ESPBTDeviceListener { +class RuuviListener final : public ble_device_base::ESPBTDeviceListener { public: - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; }; } // namespace esphome::ruuvi_ble - -#endif diff --git a/esphome/components/ruuvitag/ruuvitag.cpp b/esphome/components/ruuvitag/ruuvitag.cpp index 99c6b8ae26..1536befb1b 100644 --- a/esphome/components/ruuvitag/ruuvitag.cpp +++ b/esphome/components/ruuvitag/ruuvitag.cpp @@ -1,8 +1,6 @@ #include "ruuvitag.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::ruuvitag { static const char *const TAG = "ruuvitag"; @@ -23,5 +21,3 @@ void RuuviTag::dump_config() { } } // namespace esphome::ruuvitag - -#endif diff --git a/esphome/components/ruuvitag/ruuvitag.h b/esphome/components/ruuvitag/ruuvitag.h index 9602b82afc..fc2d05a642 100644 --- a/esphome/components/ruuvitag/ruuvitag.h +++ b/esphome/components/ruuvitag/ruuvitag.h @@ -2,18 +2,16 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/ruuvi_ble/ruuvi_ble.h" -#ifdef USE_ESP32 - namespace esphome::ruuvitag { -class RuuviTag final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class RuuviTag final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override { + bool parse_device(const ble_device_base::ESPBTDevice &device) override { if (device.address_uint64() != this->address_) return false; @@ -77,5 +75,3 @@ class RuuviTag final : public Component, public esp32_ble_tracker::ESPBTDeviceLi }; } // namespace esphome::ruuvitag - -#endif diff --git a/esphome/components/ruuvitag/sensor.py b/esphome/components/ruuvitag/sensor.py index af262b2950..e58d38ca84 100644 --- a/esphome/components/ruuvitag/sensor.py +++ b/esphome/components/ruuvitag/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_ACCELERATION, @@ -35,15 +35,15 @@ from esphome.const import ( UNIT_VOLT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["ruuvi_ble"] +AUTO_LOAD = ["ble_device_base", "ruuvi_ble"] ruuvitag_ns = cg.esphome_ns.namespace("ruuvitag") RuuviTag = ruuvitag_ns.class_( - "RuuviTag", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "RuuviTag", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("ruuvitag"), cv.Schema( { cv.GenerateID(): cv.declare_id(RuuviTag), @@ -116,15 +116,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/tests/components/b_parasite/common-ln.yaml b/tests/components/b_parasite/common-ln.yaml new file mode 100644 index 0000000000..797b94c76f --- /dev/null +++ b/tests/components/b_parasite/common-ln.yaml @@ -0,0 +1,7 @@ +sensor: + - platform: b_parasite + mac_address: F0:CA:F0:CA:01:01 + humidity: + name: b-parasite Air Humidity + temperature: + name: b-parasite Air Temperature diff --git a/tests/components/b_parasite/common.yaml b/tests/components/b_parasite/common.yaml index 262e891bb2..e603d058b7 100644 --- a/tests/components/b_parasite/common.yaml +++ b/tests/components/b_parasite/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: b_parasite + ble_hub_id: ble_tracker_hub mac_address: F0:CA:F0:CA:01:01 humidity: name: b-parasite Air Humidity diff --git a/tests/components/b_parasite/test.ln882x-ard.yaml b/tests/components/b_parasite/test.ln882x-ard.yaml new file mode 100644 index 0000000000..f711f63aa7 --- /dev/null +++ b/tests/components/b_parasite/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + b_parasite: !include common-ln.yaml diff --git a/tests/components/b_parasite/validate.bk72xx-ard.yaml b/tests/components/b_parasite/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..fab4895d7b --- /dev/null +++ b/tests/components/b_parasite/validate.bk72xx-ard.yaml @@ -0,0 +1,26 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: b_parasite + ble_hub_id: ble_tracker_hub + mac_address: F0:CA:F0:CA:01:01 + humidity: + name: b-parasite Air Humidity + temperature: + name: b-parasite Air Temperature + moisture: + name: b-parasite Soil Moisture + battery_voltage: + name: b-parasite Battery Voltage + illuminance: + name: b-parasite Illuminance + # No ble_hub_id: exercises the generated binding real configs use. + - platform: b_parasite + mac_address: F0:CA:F0:CA:01:02 + temperature: + name: BK b-parasite Implicit Temperature diff --git a/tests/components/ruuvi_ble/common-ln.yaml b/tests/components/ruuvi_ble/common-ln.yaml new file mode 100644 index 0000000000..39e578f349 --- /dev/null +++ b/tests/components/ruuvi_ble/common-ln.yaml @@ -0,0 +1 @@ +ruuvi_ble: diff --git a/tests/components/ruuvi_ble/common.yaml b/tests/components/ruuvi_ble/common.yaml index 1f155fd8e1..0221d865ef 100644 --- a/tests/components/ruuvi_ble/common.yaml +++ b/tests/components/ruuvi_ble/common.yaml @@ -1,3 +1,6 @@ esp32_ble_tracker: + id: ble_tracker_hub +# Explicit ble_hub_id: pins the neutral binding as a declared key. ruuvi_ble: + ble_hub_id: ble_tracker_hub diff --git a/tests/components/ruuvi_ble/test.ln882x-ard.yaml b/tests/components/ruuvi_ble/test.ln882x-ard.yaml new file mode 100644 index 0000000000..5cc6a112ce --- /dev/null +++ b/tests/components/ruuvi_ble/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + ruuvi_ble: !include common-ln.yaml diff --git a/tests/components/ruuvi_ble/validate.bk72xx-ard.yaml b/tests/components/ruuvi_ble/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..c0436c0031 --- /dev/null +++ b/tests/components/ruuvi_ble/validate.bk72xx-ard.yaml @@ -0,0 +1,9 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +# Explicit ble_hub_id: pins the neutral binding as a declared key. +ruuvi_ble: + ble_hub_id: ble_tracker_hub diff --git a/tests/components/ruuvitag/common-ln.yaml b/tests/components/ruuvitag/common-ln.yaml new file mode 100644 index 0000000000..3219624340 --- /dev/null +++ b/tests/components/ruuvitag/common-ln.yaml @@ -0,0 +1,7 @@ +sensor: + - platform: ruuvitag + mac_address: FF:56:D3:2F:7D:E8 + humidity: + name: RuuviTag Humidity + temperature: + name: RuuviTag Temperature diff --git a/tests/components/ruuvitag/common.yaml b/tests/components/ruuvitag/common.yaml index 7990617710..ce6abf5bb5 100644 --- a/tests/components/ruuvitag/common.yaml +++ b/tests/components/ruuvitag/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: ruuvitag + ble_hub_id: ble_tracker_hub mac_address: FF:56:D3:2F:7D:E8 humidity: name: RuuviTag Humidity diff --git a/tests/components/ruuvitag/test.ln882x-ard.yaml b/tests/components/ruuvitag/test.ln882x-ard.yaml new file mode 100644 index 0000000000..9b0d8c2c58 --- /dev/null +++ b/tests/components/ruuvitag/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + ruuvitag: !include common-ln.yaml diff --git a/tests/components/ruuvitag/validate.bk72xx-ard.yaml b/tests/components/ruuvitag/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..967df8e53f --- /dev/null +++ b/tests/components/ruuvitag/validate.bk72xx-ard.yaml @@ -0,0 +1,38 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: ruuvitag + ble_hub_id: ble_tracker_hub + mac_address: FF:56:D3:2F:7D:E8 + humidity: + name: RuuviTag Humidity + temperature: + name: RuuviTag Temperature + pressure: + name: RuuviTag Pressure + acceleration: + name: RuuviTag Acceleration + acceleration_x: + name: RuuviTag Acceleration X + acceleration_y: + name: RuuviTag Acceleration Y + acceleration_z: + name: RuuviTag Acceleration Z + battery_voltage: + name: RuuviTag Battery Voltage + tx_power: + name: RuuviTag TX Power + movement_counter: + name: RuuviTag Movement Counter + measurement_sequence_number: + name: RuuviTag Measurement Sequence Number + # No ble_hub_id: exercises the generated binding real configs use. + - platform: ruuvitag + mac_address: FF:56:D3:2F:7D:E9 + temperature: + name: BK RuuviTag Implicit Temperature From 5d7bd179b162cf8dd3910472d48a1c5cf6585704 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:27:09 -0500 Subject: [PATCH 1316/1815] Bump github/codeql-action/analyze from 4.37.5 to 4.37.6 (#18163) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2751529222..aa71298d4d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5 + uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 with: category: "/language:${{matrix.language}}" From ae26483be689c8dcd6c572db79e1a50cdbcb83e0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:27:57 +0000 Subject: [PATCH 1317/1815] Bump github/codeql-action/init from 4.37.5 to 4.37.6 (#18164) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index aa71298d4d..4e164cd9f6 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5 + uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} From 4a7d270494b158ff97d705f7b2518e3f65325a10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Fri, 7 Aug 2026 23:46:43 +0300 Subject: [PATCH 1318/1815] [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 5: airthings_ble, inkbird_ibsth1_mini, radon_eye_ble) (#18165) Co-authored-by: J. Nick Koston --- esphome/components/airthings_ble/__init__.py | 21 ++++++++++-------- .../airthings_ble/airthings_listener.cpp | 8 ++----- .../airthings_ble/airthings_listener.h | 10 +++------ .../inkbird_ibsth1_mini.cpp | 12 ++++------ .../inkbird_ibsth1_mini/inkbird_ibsth1_mini.h | 10 +++------ .../components/inkbird_ibsth1_mini/sensor.py | 13 ++++++----- esphome/components/radon_eye_ble/__init__.py | 21 ++++++++++-------- .../radon_eye_ble/radon_eye_listener.cpp | 6 +---- .../radon_eye_ble/radon_eye_listener.h | 10 +++------ tests/components/airthings_ble/common-ln.yaml | 1 + tests/components/airthings_ble/common.yaml | 6 +++++ .../airthings_ble/test.esp32-idf.yaml | 3 +++ .../airthings_ble/test.ln882x-ard.yaml | 3 +++ .../airthings_ble/validate.bk72xx-ard.yaml | 8 +++++++ .../inkbird_ibsth1_mini/common-ln.yaml | 7 ++++++ .../inkbird_ibsth1_mini/common.yaml | 3 +++ .../inkbird_ibsth1_mini/test.ln882x-ard.yaml | 3 +++ .../validate.bk72xx-ard.yaml | 22 +++++++++++++++++++ tests/components/radon_eye_ble/common-ln.yaml | 1 + tests/components/radon_eye_ble/common.yaml | 3 +++ .../radon_eye_ble/test.ln882x-ard.yaml | 3 +++ .../radon_eye_ble/validate.bk72xx-ard.yaml | 9 ++++++++ 22 files changed, 119 insertions(+), 64 deletions(-) create mode 100644 tests/components/airthings_ble/common-ln.yaml create mode 100644 tests/components/airthings_ble/common.yaml create mode 100644 tests/components/airthings_ble/test.esp32-idf.yaml create mode 100644 tests/components/airthings_ble/test.ln882x-ard.yaml create mode 100644 tests/components/airthings_ble/validate.bk72xx-ard.yaml create mode 100644 tests/components/inkbird_ibsth1_mini/common-ln.yaml create mode 100644 tests/components/inkbird_ibsth1_mini/test.ln882x-ard.yaml create mode 100644 tests/components/inkbird_ibsth1_mini/validate.bk72xx-ard.yaml create mode 100644 tests/components/radon_eye_ble/common-ln.yaml create mode 100644 tests/components/radon_eye_ble/test.ln882x-ard.yaml create mode 100644 tests/components/radon_eye_ble/validate.bk72xx-ard.yaml diff --git a/esphome/components/airthings_ble/__init__.py b/esphome/components/airthings_ble/__init__.py index 1545110798..d0cb7631d2 100644 --- a/esphome/components/airthings_ble/__init__.py +++ b/esphome/components/airthings_ble/__init__.py @@ -1,23 +1,26 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker +from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_ID -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] CODEOWNERS = ["@jeromelaban"] airthings_ble_ns = cg.esphome_ns.namespace("airthings_ble") AirthingsListener = airthings_ble_ns.class_( - "AirthingsListener", esp32_ble_tracker.ESPBTDeviceListener + "AirthingsListener", ble_device_base.ESPBTDeviceListener ) -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(AirthingsListener), - } -).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("airthings_ble"), + cv.Schema( + { + cv.GenerateID(): cv.declare_id(AirthingsListener), + } + ).extend(ble_device_base.BLE_DEVICE_SCHEMA), +) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) diff --git a/esphome/components/airthings_ble/airthings_listener.cpp b/esphome/components/airthings_ble/airthings_listener.cpp index 881b3e297b..f2625a7832 100644 --- a/esphome/components/airthings_ble/airthings_listener.cpp +++ b/esphome/components/airthings_ble/airthings_listener.cpp @@ -2,15 +2,13 @@ #include "esphome/core/log.h" #include -#ifdef USE_ESP32 - namespace esphome::airthings_ble { static const char *const TAG = "airthings_ble"; -bool AirthingsListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool AirthingsListener::parse_device(const ble_device_base::ESPBTDevice &device) { for (auto &it : device.get_manufacturer_datas()) { - if (it.uuid == esp32_ble_tracker::ESPBTUUID::from_uint32(0x0334)) { + if (it.uuid == ble_device_base::ESPBTUUID::from_uint32(0x0334)) { if (it.data.size() < 4) continue; @@ -29,5 +27,3 @@ bool AirthingsListener::parse_device(const esp32_ble_tracker::ESPBTDevice &devic } } // namespace esphome::airthings_ble - -#endif diff --git a/esphome/components/airthings_ble/airthings_listener.h b/esphome/components/airthings_ble/airthings_listener.h index 8105ac32eb..8fdfeb972f 100644 --- a/esphome/components/airthings_ble/airthings_listener.h +++ b/esphome/components/airthings_ble/airthings_listener.h @@ -1,17 +1,13 @@ #pragma once -#ifdef USE_ESP32 - #include "esphome/core/component.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" namespace esphome::airthings_ble { -class AirthingsListener final : public esp32_ble_tracker::ESPBTDeviceListener { +class AirthingsListener final : public ble_device_base::ESPBTDeviceListener { public: - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; }; } // namespace esphome::airthings_ble - -#endif diff --git a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp index 4df22aa9de..d360142bcb 100644 --- a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp +++ b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp @@ -1,8 +1,6 @@ #include "inkbird_ibsth1_mini.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::inkbird_ibsth1_mini { static const char *const TAG = "inkbird_ibsth1_mini"; @@ -15,7 +13,7 @@ void InkbirdIbstH1Mini::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool InkbirdIbstH1Mini::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool InkbirdIbstH1Mini::parse_device(const ble_device_base::ESPBTDevice &device) { // The below is based on my research and reverse engineering of a single device // It is entirely possible that some of that may be inaccurate or incomplete @@ -32,7 +30,7 @@ bool InkbirdIbstH1Mini::parse_device(const esp32_ble_tracker::ESPBTDevice &devic ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; } - if (device.get_address_type() != BLE_ADDR_TYPE_PUBLIC) { + if (device.get_address_type() != ble_device_base::BLE_ADDR_TYPE_PUBLIC) { ESP_LOGVV(TAG, "parse_device(): address is not public"); return false; } @@ -46,7 +44,7 @@ bool InkbirdIbstH1Mini::parse_device(const esp32_ble_tracker::ESPBTDevice &devic return false; } const auto &mnf_data = mnf_datas[0]; - if (mnf_data.uuid.get_uuid().len != ESP_UUID_LEN_16) { + if (mnf_data.uuid.type() != ble_device_base::ESPBTUUID::Type::UUID16) { ESP_LOGVV(TAG, "parse_device(): manufacturer data element is expected to have uuid of length 16"); return false; } @@ -71,7 +69,7 @@ bool InkbirdIbstH1Mini::parse_device(const esp32_ble_tracker::ESPBTDevice &devic auto external_temperature = NAN; // Read bluetooth data into variable - auto measured_temperature = ((int16_t) mnf_data.uuid.get_uuid().uuid.uuid16) / 100.0f; + auto measured_temperature = ((int16_t) mnf_data.uuid.uuid16()) / 100.0f; // Set temperature or external_temperature based on which sensor is in use if (mnf_data.data[2] == 0) { @@ -104,5 +102,3 @@ bool InkbirdIbstH1Mini::parse_device(const esp32_ble_tracker::ESPBTDevice &devic } } // namespace esphome::inkbird_ibsth1_mini - -#endif diff --git a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h index 4c90d6d35b..726ea8c5ea 100644 --- a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h +++ b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h @@ -2,17 +2,15 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" - -#ifdef USE_ESP32 +#include "esphome/components/ble_device_base/ble_device.h" namespace esphome::inkbird_ibsth1_mini { -class InkbirdIbstH1Mini final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class InkbirdIbstH1Mini final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } @@ -29,5 +27,3 @@ class InkbirdIbstH1Mini final : public Component, public esp32_ble_tracker::ESPB }; } // namespace esphome::inkbird_ibsth1_mini - -#endif diff --git a/esphome/components/inkbird_ibsth1_mini/sensor.py b/esphome/components/inkbird_ibsth1_mini/sensor.py index b446c9f1e2..2dcdb9a118 100644 --- a/esphome/components/inkbird_ibsth1_mini/sensor.py +++ b/esphome/components/inkbird_ibsth1_mini/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -18,14 +18,15 @@ from esphome.const import ( ) CODEOWNERS = ["@fkirill"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] inkbird_ibsth1_mini_ns = cg.esphome_ns.namespace("inkbird_ibsth1_mini") InkbirdIbstH1Mini = inkbird_ibsth1_mini_ns.class_( - "InkbirdIbstH1Mini", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "InkbirdIbstH1Mini", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("inkbird_ibsth1_mini"), cv.Schema( { cv.GenerateID(): cv.declare_id(InkbirdIbstH1Mini), @@ -57,15 +58,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/radon_eye_ble/__init__.py b/esphome/components/radon_eye_ble/__init__.py index 99daef30e5..2ba9d59d4c 100644 --- a/esphome/components/radon_eye_ble/__init__.py +++ b/esphome/components/radon_eye_ble/__init__.py @@ -1,23 +1,26 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker +from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_ID -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] CODEOWNERS = ["@jeffeb3"] radon_eye_ble_ns = cg.esphome_ns.namespace("radon_eye_ble") RadonEyeListener = radon_eye_ble_ns.class_( - "RadonEyeListener", esp32_ble_tracker.ESPBTDeviceListener + "RadonEyeListener", ble_device_base.ESPBTDeviceListener ) -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(RadonEyeListener), - } -).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("radon_eye_ble"), + cv.Schema( + { + cv.GenerateID(): cv.declare_id(RadonEyeListener), + } + ).extend(ble_device_base.BLE_DEVICE_SCHEMA), +) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) diff --git a/esphome/components/radon_eye_ble/radon_eye_listener.cpp b/esphome/components/radon_eye_ble/radon_eye_listener.cpp index 7e7263d73f..9ff279cab9 100644 --- a/esphome/components/radon_eye_ble/radon_eye_listener.cpp +++ b/esphome/components/radon_eye_ble/radon_eye_listener.cpp @@ -2,13 +2,11 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::radon_eye_ble { static const char *const TAG = "radon_eye_ble"; -bool RadonEyeListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool RadonEyeListener::parse_device(const ble_device_base::ESPBTDevice &device) { // Radon Eye devices have names starting with "FR:" if (device.get_name().starts_with("FR:")) { char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; @@ -19,5 +17,3 @@ bool RadonEyeListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device } } // namespace esphome::radon_eye_ble - -#endif diff --git a/esphome/components/radon_eye_ble/radon_eye_listener.h b/esphome/components/radon_eye_ble/radon_eye_listener.h index 30e3ccc1ea..f9c8aa377d 100644 --- a/esphome/components/radon_eye_ble/radon_eye_listener.h +++ b/esphome/components/radon_eye_ble/radon_eye_listener.h @@ -1,17 +1,13 @@ #pragma once -#ifdef USE_ESP32 - #include "esphome/core/component.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" namespace esphome::radon_eye_ble { -class RadonEyeListener final : public esp32_ble_tracker::ESPBTDeviceListener { +class RadonEyeListener final : public ble_device_base::ESPBTDeviceListener { public: - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; }; } // namespace esphome::radon_eye_ble - -#endif diff --git a/tests/components/airthings_ble/common-ln.yaml b/tests/components/airthings_ble/common-ln.yaml new file mode 100644 index 0000000000..292192f052 --- /dev/null +++ b/tests/components/airthings_ble/common-ln.yaml @@ -0,0 +1 @@ +airthings_ble: diff --git a/tests/components/airthings_ble/common.yaml b/tests/components/airthings_ble/common.yaml new file mode 100644 index 0000000000..347f6640ad --- /dev/null +++ b/tests/components/airthings_ble/common.yaml @@ -0,0 +1,6 @@ +esp32_ble_tracker: + id: ble_tracker_hub + +# Explicit ble_hub_id: pins the neutral binding as a declared key. +airthings_ble: + ble_hub_id: ble_tracker_hub diff --git a/tests/components/airthings_ble/test.esp32-idf.yaml b/tests/components/airthings_ble/test.esp32-idf.yaml new file mode 100644 index 0000000000..5883578909 --- /dev/null +++ b/tests/components/airthings_ble/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + ble: !include ../../test_build_components/common/ble/esp32-idf.yaml + airthings_ble: !include common.yaml diff --git a/tests/components/airthings_ble/test.ln882x-ard.yaml b/tests/components/airthings_ble/test.ln882x-ard.yaml new file mode 100644 index 0000000000..aa0ec6f7ab --- /dev/null +++ b/tests/components/airthings_ble/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + airthings_ble: !include common-ln.yaml diff --git a/tests/components/airthings_ble/validate.bk72xx-ard.yaml b/tests/components/airthings_ble/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..efcde0990e --- /dev/null +++ b/tests/components/airthings_ble/validate.bk72xx-ard.yaml @@ -0,0 +1,8 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +airthings_ble: + ble_hub_id: ble_tracker_hub diff --git a/tests/components/inkbird_ibsth1_mini/common-ln.yaml b/tests/components/inkbird_ibsth1_mini/common-ln.yaml new file mode 100644 index 0000000000..618b4ff879 --- /dev/null +++ b/tests/components/inkbird_ibsth1_mini/common-ln.yaml @@ -0,0 +1,7 @@ +sensor: + - platform: inkbird_ibsth1_mini + mac_address: 38:81:D7:0A:9C:11 + temperature: + name: Inkbird IBS-TH1 Temperature + humidity: + name: Inkbird IBS-TH1 Humidity diff --git a/tests/components/inkbird_ibsth1_mini/common.yaml b/tests/components/inkbird_ibsth1_mini/common.yaml index ba46b7dbf6..50c977cf8d 100644 --- a/tests/components/inkbird_ibsth1_mini/common.yaml +++ b/tests/components/inkbird_ibsth1_mini/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: inkbird_ibsth1_mini + ble_hub_id: ble_tracker_hub mac_address: 38:81:D7:0A:9C:11 temperature: name: Inkbird IBS-TH1 Temperature diff --git a/tests/components/inkbird_ibsth1_mini/test.ln882x-ard.yaml b/tests/components/inkbird_ibsth1_mini/test.ln882x-ard.yaml new file mode 100644 index 0000000000..2d37c8d318 --- /dev/null +++ b/tests/components/inkbird_ibsth1_mini/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + inkbird_ibsth1_mini: !include common-ln.yaml diff --git a/tests/components/inkbird_ibsth1_mini/validate.bk72xx-ard.yaml b/tests/components/inkbird_ibsth1_mini/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..93ea63c2a8 --- /dev/null +++ b/tests/components/inkbird_ibsth1_mini/validate.bk72xx-ard.yaml @@ -0,0 +1,22 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: inkbird_ibsth1_mini + ble_hub_id: ble_tracker_hub + mac_address: 38:81:D7:0A:9C:11 + temperature: + name: Inkbird IBS-TH1 Temperature + humidity: + name: Inkbird IBS-TH1 Humidity + battery_level: + name: Inkbird IBS-TH1 Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: inkbird_ibsth1_mini + mac_address: 38:81:D7:0A:9C:12 + temperature: + name: BK Inkbird Implicit Temperature diff --git a/tests/components/radon_eye_ble/common-ln.yaml b/tests/components/radon_eye_ble/common-ln.yaml new file mode 100644 index 0000000000..cfa30b967f --- /dev/null +++ b/tests/components/radon_eye_ble/common-ln.yaml @@ -0,0 +1 @@ +radon_eye_ble: diff --git a/tests/components/radon_eye_ble/common.yaml b/tests/components/radon_eye_ble/common.yaml index 85638d5c0e..4779f5db27 100644 --- a/tests/components/radon_eye_ble/common.yaml +++ b/tests/components/radon_eye_ble/common.yaml @@ -1,3 +1,6 @@ esp32_ble_tracker: + id: ble_tracker_hub +# Explicit ble_hub_id: pins the neutral binding as a declared key. radon_eye_ble: + ble_hub_id: ble_tracker_hub diff --git a/tests/components/radon_eye_ble/test.ln882x-ard.yaml b/tests/components/radon_eye_ble/test.ln882x-ard.yaml new file mode 100644 index 0000000000..f32bca2a56 --- /dev/null +++ b/tests/components/radon_eye_ble/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + radon_eye_ble: !include common-ln.yaml diff --git a/tests/components/radon_eye_ble/validate.bk72xx-ard.yaml b/tests/components/radon_eye_ble/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..88e69921b5 --- /dev/null +++ b/tests/components/radon_eye_ble/validate.bk72xx-ard.yaml @@ -0,0 +1,9 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +# Explicit ble_hub_id: pins the neutral binding as a declared key. +radon_eye_ble: + ble_hub_id: ble_tracker_hub From b0aec6dc2f2d9c3780f454d04f37a50742373326 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Fri, 7 Aug 2026 16:09:15 -0500 Subject: [PATCH 1319/1815] [modbus_client] Lambda sugar (#18146) Co-authored-by: Claude --- esphome/components/modbus_client/__init__.py | 33 ++++++++++++++ .../modbus_client/test_modbus_client.py | 44 ++++++++++++++++++- tests/components/modbus_client/common.yaml | 21 +++++++++ .../validate-autoload.esp32-idf.yaml | 16 +++++++ 4 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 tests/components/modbus_client/validate-autoload.esp32-idf.yaml diff --git a/esphome/components/modbus_client/__init__.py b/esphome/components/modbus_client/__init__.py index 538936c93b..52a61cacad 100644 --- a/esphome/components/modbus_client/__init__.py +++ b/esphome/components/modbus_client/__init__.py @@ -8,6 +8,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, CONF_COUNT, + CONF_ID, CONF_ON_ERROR, CONF_ON_RESPONSE, CONF_VALUE, @@ -17,6 +18,10 @@ from esphome.types import ConfigType, TemplateArgsType CODEOWNERS = ["@exciton"] DEPENDENCIES = ["modbus"] +MULTI_CONF = True +# The modbus hub auto-loads this component to make the actions available. Without this, that auto-load +# would try to create a device with no address. +MULTI_CONF_NO_DEFAULT = True CONF_ON_CUSTOM_RESPONSE = "on_custom_response" CONF_ON_NO_RESPONSE = "on_no_response" @@ -67,6 +72,34 @@ _PDU_SPAN = cg.std_span.template(cg.uint8.operator("const")) # modbus.MAX_PDU_SIZE without reporting it, so an over-long lambda PDU is silently truncated. _PDU_BUFFER = modbus.modbus_ns.namespace("helpers").class_("PduBuffer") +# A bare modbus::ModbusClientDevice bound to a hub and a device address, and nothing else - no polling, +# no entities, no automation wiring. It exists so a lambda can talk to a device directly: +# +# modbus_client: +# - id: my_client +# address: 0x01 +# +# - lambda: "id(my_client).write_single_register(0x10, 42);" +# +# Nothing here overrides the device callbacks, so every outcome takes the base class default, and those +# are no-ops: a successful reply, a Modbus exception, a timeout, and a frame that never reached the wire +# are all discarded without a log. The single exception is a reply the dispatch gate treats as +# non-standard, which warns once per device and logs at VERBOSE after that. So a lambda gets no feedback +# on an ordinary failure - use the modbus_client.* actions whenever the outcome matters, since they carry +# on_response/on_error/on_no_response/on_not_sent handlers. +# The id is required, not generated: the device is reachable only through id() in a lambda, so an +# entry without one builds something nothing can name. Better to say so than to accept dead config. +CONFIG_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.declare_id(modbus.ModbusClientDevice), + } +).extend(modbus.modbus_device_schema(None)) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await modbus.register_modbus_client_device(var, config) + def _packed_bit_bytes(bits: int) -> int: """Mirrors modbus::packed_bit_bytes(): bytes needed to hold this many coils on the wire.""" diff --git a/tests/component_tests/modbus_client/test_modbus_client.py b/tests/component_tests/modbus_client/test_modbus_client.py index 10f9bc588e..7966048dd7 100644 --- a/tests/component_tests/modbus_client/test_modbus_client.py +++ b/tests/component_tests/modbus_client/test_modbus_client.py @@ -7,14 +7,16 @@ guard is a safety property: these tests pin it to every handler slot. import pytest from esphome import config_validation as cv +from esphome.components import modbus_client from esphome.components.modbus_client import ( CONF_ON_NO_RESPONSE, CONF_ON_NOT_SENT, CONF_ON_SENT, CONF_PDU, + CONFIG_SCHEMA, MODBUS_CLIENT_SEND_SCHEMA, ) -from esphome.const import CONF_ADDRESS, CONF_ON_ERROR, CONF_ON_RESPONSE +from esphome.const import CONF_ADDRESS, CONF_ID, CONF_ON_ERROR, CONF_ON_RESPONSE from esphome.core import Lambda from esphome.types import ConfigType @@ -114,3 +116,43 @@ def test_on_no_response_retry_lambda_accepted() -> None: }, } ) + + +# The standalone component block. The compile fixtures cover the accepted shapes end to end; these pin +# the parts a fixture cannot express - a rejection, and a module flag whose absence breaks other +# components rather than this one. + + +def test_component_requires_an_address() -> None: + """The address identifies the device on the bus, so there is no sensible default.""" + with pytest.raises(cv.Invalid, match=CONF_ADDRESS): + CONFIG_SCHEMA({CONF_ID: "bare_client"}) + + +def test_component_requires_an_id() -> None: + """The device is reachable only through id() in a lambda, so a generated id would be dead config.""" + with pytest.raises(cv.Invalid, match=CONF_ID): + CONFIG_SCHEMA({CONF_ADDRESS: 0x01}) + + +def test_component_accepts_an_id_and_address() -> None: + """modbus_id stays optional: it resolves to the single hub when only one is declared.""" + config = CONFIG_SCHEMA({CONF_ID: "bare_client", CONF_ADDRESS: 0x01}) + assert config[CONF_ADDRESS] == 0x01 + + +def test_component_rejects_an_out_of_range_address() -> None: + """A Modbus device address is one byte.""" + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({CONF_ID: "bare_client", CONF_ADDRESS: 0x100}) + + +def test_multi_conf_no_default_is_set() -> None: + """Load-bearing: the modbus hub auto-loads this component to register its actions. + + Without MULTI_CONF_NO_DEFAULT that auto-load builds a default entry, which then fails the required + address above - breaking every configuration that uses modbus but never declares a modbus_client + block. validate-autoload.esp32-idf.yaml covers the same path end to end; this names the reason. + """ + assert modbus_client.MULTI_CONF is True + assert modbus_client.MULTI_CONF_NO_DEFAULT is True diff --git a/tests/components/modbus_client/common.yaml b/tests/components/modbus_client/common.yaml index 19acbe9b0e..cae2002342 100644 --- a/tests/components/modbus_client/common.yaml +++ b/tests/components/modbus_client/common.yaml @@ -5,6 +5,18 @@ # device is retried forever. Reset the counter before the send or on a terminal outcome (on_response) # so the cap is per transaction, not per device lifetime. Never reset in on_sent: it fires again on # every retry, so the cap would never be reached. + +# The standalone component: a bare hub device with nothing but an address, so a lambda can drive the +# device directly. Two entries cover both hub-binding paths - the auto-resolved single hub and an +# explicit modbus_id - and the button below calls them, so the generated device has to be usable +# rather than merely constructed (an unreferenced one is optimised away entirely). +modbus_client: + - id: bare_client + address: 0x01 + - id: bare_client_explicit_hub + modbus_id: modbus_bus + address: 0x02 + globals: - id: read_retries type: int @@ -14,6 +26,15 @@ globals: initial_value: "0" button: + # The bare modbus_client devices: no handlers, so nothing reports the outcome - see the component + # comment. Calls here only pin that the device is bound to its hub and the helpers are reachable. + - platform: template + name: "Bare Client" + on_press: + - lambda: |- + id(bare_client).write_single_register(0x10, 42); + id(bare_client).write_single_coil(0x01, true); + id(bare_client_explicit_hub).read_holding_registers(0x20, 4); - platform: template name: "Send Read" on_press: diff --git a/tests/components/modbus_client/validate-autoload.esp32-idf.yaml b/tests/components/modbus_client/validate-autoload.esp32-idf.yaml new file mode 100644 index 0000000000..318d492717 --- /dev/null +++ b/tests/components/modbus_client/validate-autoload.esp32-idf.yaml @@ -0,0 +1,16 @@ +# The modbus hub auto-loads modbus_client so the modbus_client.* actions are registered. That must not +# create a device on its own, which is what MULTI_CONF_NO_DEFAULT in the component buys: without it the +# auto-load builds a default entry and fails on the required address, breaking every modbus config. +# So this file deliberately declares no modbus_client: block - it is the no-block path, kept as its own +# fixture because common.yaml now declares one. +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + +button: + - platform: template + name: "Action without a component block" + on_press: + - modbus_client.read_holding_registers: + address: 0x01 + start_address: 0x10 + count: 1 From 8d494a84c5ed505af37845df600065b91d981297 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 7 Aug 2026 16:25:55 -0500 Subject: [PATCH 1320/1815] [bluetooth_connection] Engine follow-ups from the rp2 GATT series (#18159) --- .../bluetooth_connection_rp2.cpp | 149 +++++++++++++++--- .../bluetooth_connection_rp2.h | 16 +- .../bluetooth_proxy/bluetooth_proxy.cpp | 3 + 3 files changed, 138 insertions(+), 30 deletions(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index ecd7a9713a..5eb3da0263 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -24,6 +24,8 @@ using ble_device_base::GATT_ERR_NO_MEMORY; // disconnect timeout mirrors the esp32 CLOSE_EVT safety net. static constexpr uint32_t CONNECT_TIMEOUT_MS = 20000; static constexpr uint32_t DISCONNECT_TIMEOUT_MS = 10000; +// Can-send windows normally open within a connection interval (tens of ms). +static constexpr uint32_t WRITE_NO_RSP_TIMEOUT_MS = 500; // HCI "connection timeout" reason, reported when a teardown had to be forced. static constexpr uint8_t HCI_REASON_CONNECTION_TIMEOUT = 0x08; @@ -176,11 +178,11 @@ void RP2GattClient::gatt_packet_handler(uint8_t type, uint16_t channel, uint8_t case GATT_EVENT_ALL_CHARACTERISTIC_DESCRIPTORS_QUERY_RESULT: con_handle = gatt_event_all_characteristic_descriptors_query_result_get_handle(packet); break; - case GATT_EVENT_CHARACTERISTIC_VALUE_QUERY_RESULT: - con_handle = gatt_event_characteristic_value_query_result_get_handle(packet); + case GATT_EVENT_LONG_CHARACTERISTIC_VALUE_QUERY_RESULT: + con_handle = gatt_event_long_characteristic_value_query_result_get_handle(packet); break; - case GATT_EVENT_CHARACTERISTIC_DESCRIPTOR_QUERY_RESULT: - con_handle = gatt_event_characteristic_descriptor_query_result_get_handle(packet); + case GATT_EVENT_LONG_CHARACTERISTIC_DESCRIPTOR_QUERY_RESULT: + con_handle = gatt_event_long_characteristic_descriptor_query_result_get_handle(packet); break; case GATT_EVENT_NOTIFICATION: con_handle = gatt_event_notification_get_handle(packet); @@ -263,24 +265,17 @@ void RP2GattClient::handle_gatt_event_irq_(uint8_t event_type, const uint8_t *pa this->desc_count_++; break; } - case GATT_EVENT_CHARACTERISTIC_VALUE_QUERY_RESULT: { - uint16_t len = gatt_event_characteristic_value_query_result_get_value_length(packet); - if (len > RP2_GATT_MAX_ATTR_LEN) { - len = RP2_GATT_MAX_ATTR_LEN; - } - memcpy(this->op_buffer_, gatt_event_characteristic_value_query_result_get_value(packet), len); - this->op_len_ = len; + case GATT_EVENT_LONG_CHARACTERISTIC_VALUE_QUERY_RESULT: + // One blob per event at the reported offset; assemble into the op buffer. + this->assemble_blob_irq_(gatt_event_long_characteristic_value_query_result_get_value_offset(packet), + gatt_event_long_characteristic_value_query_result_get_value(packet), + gatt_event_long_characteristic_value_query_result_get_value_length(packet)); break; - } - case GATT_EVENT_CHARACTERISTIC_DESCRIPTOR_QUERY_RESULT: { - uint16_t len = gatt_event_characteristic_descriptor_query_result_get_descriptor_length(packet); - if (len > RP2_GATT_MAX_ATTR_LEN) { - len = RP2_GATT_MAX_ATTR_LEN; - } - memcpy(this->op_buffer_, gatt_event_characteristic_descriptor_query_result_get_descriptor(packet), len); - this->op_len_ = len; + case GATT_EVENT_LONG_CHARACTERISTIC_DESCRIPTOR_QUERY_RESULT: + this->assemble_blob_irq_(gatt_event_long_characteristic_descriptor_query_result_get_descriptor_offset(packet), + gatt_event_long_characteristic_descriptor_query_result_get_descriptor(packet), + gatt_event_long_characteristic_descriptor_query_result_get_descriptor_length(packet)); break; - } case GATT_EVENT_NOTIFICATION: this->enqueue_notify_irq_(gatt_event_notification_get_value_handle(packet), gatt_event_notification_get_value(packet), @@ -297,28 +292,45 @@ void RP2GattClient::handle_gatt_event_irq_(uint8_t event_type, const uint8_t *pa } // NOLINTBEGIN(clang-analyzer-unix.Malloc) +void RP2GattClient::assemble_blob_irq_(uint16_t offset, const uint8_t *data, uint16_t len) { + if (offset >= RP2_GATT_MAX_ATTR_LEN) { + return; + } + if (len > RP2_GATT_MAX_ATTR_LEN - offset) { + len = RP2_GATT_MAX_ATTR_LEN - offset; + } + memcpy(this->op_buffer_ + offset, data, len); + if (offset + len > this->op_len_) { + this->op_len_ = offset + len; + } +} + void RP2GattClient::enqueue_event_irq_(RP2GattEvent::Type type, uint8_t status, uint16_t value) { RP2GattEvent *event = this->event_pool_.allocate(); if (event == nullptr) { this->event_queue_.increment_dropped_count(); + this->enable_loop_soon_any_context(); return; } event->type = type; event->status = status; event->value = value; this->event_queue_.push(event); + this->enable_loop_soon_any_context(); } void RP2GattClient::enqueue_notify_irq_(uint16_t handle, const uint8_t *data, uint16_t len) { RP2GattNotifyEvent *event = this->notify_pool_.allocate(); if (event == nullptr) { this->notify_queue_.increment_dropped_count(); + this->enable_loop_soon_any_context(); return; } event->handle = handle; event->len = len > RP2_GATT_MAX_ATTR_LEN ? RP2_GATT_MAX_ATTR_LEN : len; memcpy(event->data, data, event->len); this->notify_queue_.push(event); + this->enable_loop_soon_any_context(); } // NOLINTEND(clang-analyzer-unix.Malloc) @@ -384,7 +396,27 @@ void RP2GattClient::loop() { ESP_LOGW(TAG, "Disconnect timeout, forcing idle"); this->handle_disconnected_(HCI_REASON_CONNECTION_TIMEOUT); } - } else if (this->state_ == EngineState::IDLE) { + } else if (this->state_ == EngineState::READY && this->op_type_ == OpType::WRITE_CHAR_NO_RSP && + millis() - this->write_no_rsp_started_ > WRITE_NO_RSP_TIMEOUT_MS) { + // The can-send window never opened; report instead of hanging the op slot. + bool timed_out = false; + { + BluetoothLock lock; + // The trampoline may have just sent it; its queued result wins. + if (this->event_queue_.empty()) { + this->op_type_ = OpType::NONE; + timed_out = true; + } + } + if (timed_out) { + ESP_LOGW(TAG, "Deferred write timeout, handle=0x%04x", this->op_handle_); + if (this->listener_ != nullptr) { + this->listener_->on_write_result(this->op_handle_, GATT_CLIENT_BUSY); + } + } + } else if (this->state_ == EngineState::IDLE || (this->state_ == EngineState::READY && !this->op_in_flight_() && + this->event_queue_.empty() && this->notify_queue_.empty())) { + // Nothing pending: the enqueue path re-arms the loop from any context. this->disable_loop(); } } @@ -412,6 +444,35 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) { case RP2GattEvent::QUERY_COMPLETE: this->handle_query_complete_(event.status); break; + case RP2GattEvent::WRITE_NO_RSP_DONE: + this->finish_write_no_rsp_(event.status); + break; + } +} + +void RP2GattClient::can_write_no_rsp_trampoline(void *context) { + // BTstack context: this callback IS the can-send window, so the deferred + // write happens here; only the result is enqueued for the main loop. + auto *self = static_cast(context); + if (self->op_type_ != OpType::WRITE_CHAR_NO_RSP) { + return; + } + uint8_t status = gatt_client_write_value_of_characteristic_without_response(self->con_handle_, self->op_handle_, + self->op_len_, self->op_buffer_); + if ((status == GATT_CLIENT_BUSY || status == BTSTACK_ACL_BUFFERS_FULL) && + gatt_client_request_to_write_without_response(&self->can_write_registration_, self->con_handle_) == 0) { + return; // next window retries; a failed re-arm falls through as an error + } + self->enqueue_event_irq_(RP2GattEvent::WRITE_NO_RSP_DONE, status, 0); +} + +void RP2GattClient::finish_write_no_rsp_(uint8_t status) { + if (this->op_type_ != OpType::WRITE_CHAR_NO_RSP) { + return; + } + this->op_type_ = OpType::NONE; + if (this->listener_ != nullptr) { + this->listener_->on_write_result(this->op_handle_, status); } } @@ -514,7 +575,7 @@ void RP2GattClient::handle_query_complete_(uint8_t att_status) { // this BTstack emits no QUERY_COMPLETE (the MTU state machine is separate // from the query state machine). Completions with nothing in flight are // dropped below. - if (this->op_type_ != OpType::NONE) { + if (this->op_type_ != OpType::NONE && this->op_type_ != OpType::WRITE_CHAR_NO_RSP) { OpType op = this->op_type_; this->op_type_ = OpType::NONE; if (this->listener_ == nullptr) { @@ -523,6 +584,13 @@ void RP2GattClient::handle_query_complete_(uint8_t att_status) { switch (op) { case OpType::READ_CHAR: case OpType::READ_DESC: + // A value that is an exact multiple of MTU - 1 ends with a trailing + // blob request some peers refuse with INVALID_OFFSET; the read is + // complete, not failed. + if ((att_status == ATT_ERROR_INVALID_OFFSET || att_status == ATT_ERROR_ATTRIBUTE_NOT_LONG) && + this->op_len_ > 0) { + att_status = 0; + } this->listener_->on_read_result(this->op_handle_, this->op_buffer_, att_status == 0 ? this->op_len_ : 0, att_status); break; @@ -822,8 +890,9 @@ int RP2GattClient::read_characteristic(uint16_t handle) { this->op_handle_ = handle; this->op_len_ = 0; BluetoothLock lock; - uint8_t status = gatt_client_read_value_of_characteristic_using_value_handle(&RP2GattClient::gatt_packet_handler, - this->con_handle_, handle); + // Long variant: plain read first, blob continuations only past MTU - 1. + uint8_t status = gatt_client_read_long_value_of_characteristic_using_value_handle(&RP2GattClient::gatt_packet_handler, + this->con_handle_, handle); if (status != 0) { this->op_type_ = OpType::NONE; return status; @@ -845,8 +914,38 @@ int RP2GattClient::write_characteristic(uint16_t handle, const uint8_t *data, ui uint8_t status; { BluetoothLock lock; + if (this->op_type_ == OpType::WRITE_CHAR_NO_RSP) { + // A deferred write is parked; sending now would overtake it. + return GATT_CLIENT_BUSY; + } status = gatt_client_write_value_of_characteristic_without_response(this->con_handle_, handle, len, const_cast(data)); + // BTSTACK_ACL_BUFFERS_FULL is the same transient flow control one layer + // down (L2CAP), so it defers identically. + if (status == GATT_CLIENT_BUSY || status == BTSTACK_ACL_BUFFERS_FULL) { + if (this->op_in_flight_()) { + // The op buffer is owned; bounce the busy to the caller as before. + return status; + } + // Stash the payload and send from the can-send callback. + memcpy(this->op_buffer_, data, len); + this->op_type_ = OpType::WRITE_CHAR_NO_RSP; + this->op_handle_ = handle; + this->op_len_ = len; + this->write_no_rsp_started_ = millis(); + this->can_write_registration_.callback = &RP2GattClient::can_write_no_rsp_trampoline; + this->can_write_registration_.context = this; + uint8_t req = gatt_client_request_to_write_without_response(&this->can_write_registration_, this->con_handle_); + if (req != 0 && req != ERROR_CODE_COMMAND_DISALLOWED) { + this->op_type_ = OpType::NONE; + return req; + } + // COMMAND_DISALLOWED = still armed from a timed-out deferral; that + // registration sends the newly parked payload. Keep the loop running + // so the deadline below can fire on a stalled link. + this->enable_loop(); + return 0; + } } if (status == 0 && this->listener_ != nullptr) { this->listener_->on_write_result(handle, 0); @@ -888,7 +987,7 @@ int RP2GattClient::read_descriptor(uint16_t handle) { this->op_handle_ = handle; this->op_len_ = 0; BluetoothLock lock; - uint8_t status = gatt_client_read_characteristic_descriptor_using_descriptor_handle( + uint8_t status = gatt_client_read_long_characteristic_descriptor_using_descriptor_handle( &RP2GattClient::gatt_packet_handler, this->con_handle_, handle); if (status != 0) { this->op_type_ = OpType::NONE; diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h index 145508bdf6..0c1bc95fe9 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h @@ -46,10 +46,11 @@ static constexpr uint16_t RP2_GATT_MAX_ATTR_LEN = 512; // Control events from the BTstack handlers to loop(). struct RP2GattEvent { enum Type : uint8_t { - CONNECTED, // status + con_handle (value) - DISCONNECTED, // status = HCI reason - MTU_EXCHANGED, // value = negotiated MTU - QUERY_COMPLETE, // status = ATT status of the finished query + CONNECTED, // status + con_handle (value) + DISCONNECTED, // status = HCI reason + MTU_EXCHANGED, // value = negotiated MTU + QUERY_COMPLETE, // status = ATT status of the finished query + WRITE_NO_RSP_DONE, // status = result of the deferred write }; Type type; uint8_t status; @@ -106,7 +107,7 @@ class RP2GattClient final : public Component, enum class DiscoveryPhase : uint8_t { NONE, SERVICES, CHARACTERISTICS, DESCRIPTORS }; - enum class OpType : uint8_t { NONE, READ_CHAR, WRITE_CHAR, READ_DESC, WRITE_DESC }; + enum class OpType : uint8_t { NONE, READ_CHAR, WRITE_CHAR, WRITE_CHAR_NO_RSP, READ_DESC, WRITE_DESC }; // The whole table in one transient allocation (RAMAllocator, checked), // freed after streaming. @@ -124,6 +125,7 @@ class RP2GattClient final : public Component, void handle_gatt_event_irq_(uint8_t event_type, const uint8_t *packet); void enqueue_event_irq_(RP2GattEvent::Type type, uint8_t status, uint16_t value); void enqueue_notify_irq_(uint16_t handle, const uint8_t *data, uint16_t len); + void assemble_blob_irq_(uint16_t offset, const uint8_t *data, uint16_t len); // Main-loop state machine. void handle_event_(const RP2GattEvent &event); @@ -137,6 +139,8 @@ class RP2GattClient final : public Component, void fail_connection_(uint8_t reason); void cleanup_link_state_(); bool notify_subscribed_(uint16_t handle) const; + static void can_write_no_rsp_trampoline(void *context); + void finish_write_no_rsp_(uint8_t status); void release_scan_inhibit_(); bool op_in_flight_() const { return this->op_type_ != OpType::NONE || this->discovery_phase_ != DiscoveryPhase::NONE; @@ -156,10 +160,12 @@ class RP2GattClient final : public Component, // BTstack registrations gatt_client_notification_t notification_registration_{}; + btstack_context_callback_registration_t can_write_registration_{}; // Group 3: 4-byte types uint32_t connect_started_{0}; uint32_t disconnecting_started_{0}; + uint32_t write_no_rsp_started_{0}; // Group 4: 2-byte types (table counters written from the handler during // discovery, read from the main loop after the phase's QUERY_COMPLETE) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index a25c9d9608..06e3b9a3b4 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -380,6 +380,9 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest } else { this->send_device_pairing(msg.address, true); } + } else { + // Answer instead of leaving the client to time out. + this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED); } #else // Explicit pairing is not offered (FEATURE_PAIRING is not advertised); From 1d184b43ebadbb34474daeae8b6854f048d292b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Sat, 8 Aug 2026 04:47:52 +0300 Subject: [PATCH 1321/1815] [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 6: thermopro_ble, exposure_notifications, xiaomi_ble) (#18168) --- .../exposure_notifications/__init__.py | 52 ++++++++++++++----- .../exposure_notifications.cpp | 6 +-- .../exposure_notifications.h | 10 ++-- esphome/components/thermopro_ble/sensor.py | 13 ++--- .../thermopro_ble/thermopro_ble.cpp | 10 ++-- .../components/thermopro_ble/thermopro_ble.h | 10 ++-- esphome/components/xiaomi_ble/__init__.py | 21 ++++---- esphome/components/xiaomi_ble/xiaomi_ble.cpp | 41 +++++++-------- esphome/components/xiaomi_ble/xiaomi_ble.h | 12 ++--- .../ble_device_base/test_aes_ccm.cpp | 27 ++++++++++ .../exposure_notifications/common-ln.yaml | 5 ++ .../exposure_notifications/common.yaml | 3 ++ .../test.ln882x-ard.yaml | 3 ++ .../validate.bk72xx-ard.yaml | 15 ++++++ tests/components/thermopro_ble/common-ln.yaml | 7 +++ tests/components/thermopro_ble/common.yaml | 3 ++ .../thermopro_ble/test.ln882x-ard.yaml | 3 ++ .../thermopro_ble/validate.bk72xx-ard.yaml | 24 +++++++++ tests/components/xiaomi_ble/common-ln.yaml | 1 + tests/components/xiaomi_ble/common.yaml | 3 ++ .../xiaomi_ble/test.ln882x-ard.yaml | 3 ++ .../xiaomi_ble/validate.bk72xx-ard.yaml | 9 ++++ 22 files changed, 196 insertions(+), 85 deletions(-) create mode 100644 tests/components/exposure_notifications/common-ln.yaml create mode 100644 tests/components/exposure_notifications/test.ln882x-ard.yaml create mode 100644 tests/components/exposure_notifications/validate.bk72xx-ard.yaml create mode 100644 tests/components/thermopro_ble/common-ln.yaml create mode 100644 tests/components/thermopro_ble/test.ln882x-ard.yaml create mode 100644 tests/components/thermopro_ble/validate.bk72xx-ard.yaml create mode 100644 tests/components/xiaomi_ble/common-ln.yaml create mode 100644 tests/components/xiaomi_ble/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_ble/validate.bk72xx-ard.yaml diff --git a/esphome/components/exposure_notifications/__init__.py b/esphome/components/exposure_notifications/__init__.py index ab7416a264..6cb5b750dd 100644 --- a/esphome/components/exposure_notifications/__init__.py +++ b/esphome/components/exposure_notifications/__init__.py @@ -1,33 +1,59 @@ +from typing import Any + from esphome import automation import esphome.codegen as cg -from esphome.components import esp32_ble_tracker +from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_TRIGGER_ID +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor +from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] exposure_notifications_ns = cg.esphome_ns.namespace("exposure_notifications") ExposureNotification = exposure_notifications_ns.struct("ExposureNotification") ExposureNotificationTrigger = exposure_notifications_ns.class_( "ExposureNotificationTrigger", - esp32_ble_tracker.ESPBTDeviceListener, + ble_device_base.ESPBTDeviceListener, automation.Trigger.template(ExposureNotification), ) CONF_ON_EXPOSURE_NOTIFICATION = "on_exposure_notification" +_RENAME_HUB_ID = ble_device_base.rename_legacy_hub_id("exposure_notifications") + +_VALIDATE_AUTOMATION = automation.validate_automation( + cv.Schema( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ExposureNotificationTrigger), + } + # The trigger is the BLE listener, so the hub id lives on it. + ).extend(ble_device_base.BLE_DEVICE_SCHEMA) +) + + +# validate_automation() needs a dict-based schema, so the rename cannot go +# inside it and has to run on the option value first. That value may also be a +# list of automations or malformed, and rename_legacy_hub_id() is dict-only, so +# map over lists and let validate_automation() report anything else. +# schema_extractor keeps the key typed as a trigger in the generated editor +# schema; build_language_schema.py recurses into cv.All but not into a plain +# function. +@schema_extractor("automation") +def _validate_on_exposure_notification(value: Any) -> list[ConfigType]: + if value is SCHEMA_EXTRACT: + return _VALIDATE_AUTOMATION(value) + if isinstance(value, dict): + value = _RENAME_HUB_ID(value) + elif isinstance(value, list): + value = [_RENAME_HUB_ID(v) if isinstance(v, dict) else v for v in value] + return _VALIDATE_AUTOMATION(value) + + CONFIG_SCHEMA = cv.Schema( { - cv.Required(CONF_ON_EXPOSURE_NOTIFICATION): automation.validate_automation( - cv.Schema( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ExposureNotificationTrigger - ), - } - ).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) - ), + cv.Required(CONF_ON_EXPOSURE_NOTIFICATION): _validate_on_exposure_notification, } ) @@ -36,4 +62,4 @@ async def to_code(config): for conf in config.get(CONF_ON_EXPOSURE_NOTIFICATION, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) await automation.build_automation(trigger, [(ExposureNotification, "x")], conf) - await esp32_ble_tracker.register_ble_device(trigger, conf) + await ble_device_base.register_ble_device(trigger, conf) diff --git a/esphome/components/exposure_notifications/exposure_notifications.cpp b/esphome/components/exposure_notifications/exposure_notifications.cpp index e7038d2ca9..4f4b93b59c 100644 --- a/esphome/components/exposure_notifications/exposure_notifications.cpp +++ b/esphome/components/exposure_notifications/exposure_notifications.cpp @@ -2,11 +2,9 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::exposure_notifications { -using namespace esp32_ble_tracker; +using namespace ble_device_base; static const char *const TAG = "exposure_notifications"; @@ -43,5 +41,3 @@ bool ExposureNotificationTrigger::parse_device(const ESPBTDevice &device) { } } // namespace esphome::exposure_notifications - -#endif diff --git a/esphome/components/exposure_notifications/exposure_notifications.h b/esphome/components/exposure_notifications/exposure_notifications.h index 6a703a9a92..dc1241db56 100644 --- a/esphome/components/exposure_notifications/exposure_notifications.h +++ b/esphome/components/exposure_notifications/exposure_notifications.h @@ -2,11 +2,9 @@ #include "esphome/core/component.h" #include "esphome/core/automation.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include -#ifdef USE_ESP32 - namespace esphome::exposure_notifications { struct ExposureNotification { @@ -17,11 +15,9 @@ struct ExposureNotification { }; class ExposureNotificationTrigger final : public Trigger, - public esp32_ble_tracker::ESPBTDeviceListener { + public ble_device_base::ESPBTDeviceListener { public: - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; }; } // namespace esphome::exposure_notifications - -#endif diff --git a/esphome/components/thermopro_ble/sensor.py b/esphome/components/thermopro_ble/sensor.py index de63229621..d0d6cdacb7 100644 --- a/esphome/components/thermopro_ble/sensor.py +++ b/esphome/components/thermopro_ble/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -22,14 +22,15 @@ from esphome.const import ( CODEOWNERS = ["@sittner"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] thermopro_ble_ns = cg.esphome_ns.namespace("thermopro_ble") ThermoProBLE = thermopro_ble_ns.class_( - "ThermoProBLE", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "ThermoProBLE", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("thermopro_ble"), cv.Schema( { cv.GenerateID(): cv.declare_id(ThermoProBLE), @@ -68,15 +69,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/thermopro_ble/thermopro_ble.cpp b/esphome/components/thermopro_ble/thermopro_ble.cpp index 72e398f774..d10a6c33cd 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.cpp +++ b/esphome/components/thermopro_ble/thermopro_ble.cpp @@ -2,8 +2,6 @@ #include #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::thermopro_ble { // this size must be large enough to hold the largest data frame @@ -34,7 +32,7 @@ void ThermoProBLE::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool ThermoProBLE::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool ThermoProBLE::parse_device(const ble_device_base::ESPBTDevice &device) { // check for matching mac address if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); @@ -66,8 +64,8 @@ bool ThermoProBLE::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { } // reconstruct whole record from 2 byte uuid and data - esp_bt_uuid_t uuid = service_data.uuid.get_uuid(); - uint8_t data[MAX_DATA_SIZE] = {static_cast(uuid.uuid.uuid16), static_cast(uuid.uuid.uuid16 >> 8)}; + uint16_t svc_uuid16 = service_data.uuid.uuid16(); + uint8_t data[MAX_DATA_SIZE] = {static_cast(svc_uuid16), static_cast(svc_uuid16 >> 8)}; std::copy(service_data.data.begin(), service_data.data.end(), std::begin(data) + 2); // dispatch data to parser @@ -202,5 +200,3 @@ static optional parse_tp3(const uint8_t *data, std::size_t data_siz } } // namespace esphome::thermopro_ble - -#endif diff --git a/esphome/components/thermopro_ble/thermopro_ble.h b/esphome/components/thermopro_ble/thermopro_ble.h index ca04fbea39..1c05516e47 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.h +++ b/esphome/components/thermopro_ble/thermopro_ble.h @@ -2,9 +2,7 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" - -#ifdef USE_ESP32 +#include "esphome/components/ble_device_base/ble_device.h" namespace esphome::thermopro_ble { @@ -17,11 +15,11 @@ struct ParseResult { using DeviceParser = optional (*)(const uint8_t *data, std::size_t data_size); -class ThermoProBLE final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class ThermoProBLE final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { this->address_ = address; }; - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_signal_strength(sensor::Sensor *signal_strength) { this->signal_strength_ = signal_strength; } void set_temperature(sensor::Sensor *temperature) { this->temperature_ = temperature; } @@ -45,5 +43,3 @@ class ThermoProBLE final : public Component, public esp32_ble_tracker::ESPBTDevi }; } // namespace esphome::thermopro_ble - -#endif diff --git a/esphome/components/xiaomi_ble/__init__.py b/esphome/components/xiaomi_ble/__init__.py index 541a0e7894..7f5045f1ce 100644 --- a/esphome/components/xiaomi_ble/__init__.py +++ b/esphome/components/xiaomi_ble/__init__.py @@ -1,22 +1,25 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker +from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_ID -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] xiaomi_ble_ns = cg.esphome_ns.namespace("xiaomi_ble") XiaomiListener = xiaomi_ble_ns.class_( - "XiaomiListener", esp32_ble_tracker.ESPBTDeviceListener + "XiaomiListener", ble_device_base.ESPBTDeviceListener ) -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(XiaomiListener), - } -).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_ble"), + cv.Schema( + { + cv.GenerateID(): cv.declare_id(XiaomiListener), + } + ).extend(ble_device_base.BLE_DEVICE_SCHEMA), +) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.cpp b/esphome/components/xiaomi_ble/xiaomi_ble.cpp index 0961df2bd6..0a05950c5a 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.cpp +++ b/esphome/components/xiaomi_ble/xiaomi_ble.cpp @@ -2,14 +2,21 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - #include + +// AES-CCM backend for encrypted-payload (bindkey) decryption: +// - ESP32 + ESP-IDF >= 6.0 -> PSA crypto (psa_aead_decrypt), hardware-backed. +// - every other platform -> the portable software AES-CCM in ble_device_base, so +// decryption never depends on the SDK exposing mbedtls/PSA to application code. +#ifdef USE_ESP32 #include #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) #include -#else -#include "mbedtls/ccm.h" +#define XIAOMI_CRYPTO_PSA +#endif +#endif +#ifndef XIAOMI_CRYPTO_PSA +#include "esphome/components/ble_device_base/ble_aes_ccm.h" #endif namespace esphome::xiaomi_ble { @@ -166,7 +173,7 @@ bool parse_xiaomi_message(const std::vector &message, XiaomiParseResult return success; } -optional parse_xiaomi_header(const esp32_ble_tracker::ServiceData &service_data) { +optional parse_xiaomi_header(const ble_device_base::ServiceData &service_data) { XiaomiParseResult result; if (!service_data.uuid.contains(0x95, 0xFE)) { ESP_LOGVV(TAG, "parse_xiaomi_header(): no service data UUID magic bytes."); @@ -318,7 +325,7 @@ bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, c memcpy(vector.iv + 6, v + 2, 3); // sensor type (2) + packet id (1) memcpy(vector.iv + 9, v + raw.size() - 7, 3); // payload counter -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +#ifdef XIAOMI_CRYPTO_PSA // PSA AEAD expects ciphertext + tag concatenated uint8_t ct_with_tag[sizeof(vector.ciphertext) + sizeof(vector.tag)]; memcpy(ct_with_tag, vector.ciphertext, vector.datasize); @@ -344,20 +351,10 @@ bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, c psa_destroy_key(key_id); bool decrypt_ok = (status == PSA_SUCCESS && plaintext_length == vector.datasize); #else - mbedtls_ccm_context ctx; - mbedtls_ccm_init(&ctx); - - int ret = mbedtls_ccm_setkey(&ctx, MBEDTLS_CIPHER_ID_AES, vector.key, vector.keysize * 8); - if (ret) { - ESP_LOGVV(TAG, "decrypt_xiaomi_payload(): mbedtls_ccm_setkey() failed."); - mbedtls_ccm_free(&ctx); - return false; - } - - ret = mbedtls_ccm_auth_decrypt(&ctx, vector.datasize, vector.iv, vector.ivsize, vector.authdata, vector.authsize, - vector.ciphertext, vector.plaintext, vector.tag, vector.tagsize); - mbedtls_ccm_free(&ctx); - bool decrypt_ok = (ret == 0); + // Portable software AES-CCM (ble_device_base) — no SDK mbedtls/PSA dependency. + bool decrypt_ok = ble_device_base::aes_ccm_auth_decrypt(vector.key, vector.iv, vector.ivsize, vector.authdata, + vector.authsize, vector.ciphertext, vector.datasize, + vector.plaintext, vector.tag, vector.tagsize); #endif if (!decrypt_ok) { @@ -448,7 +445,7 @@ bool report_xiaomi_results(const optional &result, const char return true; } -bool XiaomiListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiListener::parse_device(const ble_device_base::ESPBTDevice &device) { // Previously the message was parsed twice per packet, once by XiaomiListener::parse_device() // and then again by the respective device class's parse_device() function. Parsing the header // here and then for each device seems to be unnecessary and complicates the duplicate packet filtering. @@ -460,5 +457,3 @@ bool XiaomiListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) } } // namespace esphome::xiaomi_ble - -#endif diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.h b/esphome/components/xiaomi_ble/xiaomi_ble.h index 1ebcf0e2f5..2f3a14c150 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.h +++ b/esphome/components/xiaomi_ble/xiaomi_ble.h @@ -1,12 +1,10 @@ #pragma once -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/core/component.h" #include -#ifdef USE_ESP32 - namespace esphome::xiaomi_ble { struct XiaomiParseResult { @@ -68,15 +66,13 @@ struct XiaomiAESVector { bool parse_xiaomi_value(uint16_t value_type, const uint8_t *data, uint8_t value_length, XiaomiParseResult &result); bool parse_xiaomi_message(const std::vector &message, XiaomiParseResult &result); -optional parse_xiaomi_header(const esp32_ble_tracker::ServiceData &service_data); +optional parse_xiaomi_header(const ble_device_base::ServiceData &service_data); bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, const uint64_t &address); bool report_xiaomi_results(const optional &result, const char *address); -class XiaomiListener final : public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiListener final : public ble_device_base::ESPBTDeviceListener { public: - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; }; } // namespace esphome::xiaomi_ble - -#endif diff --git a/tests/components/ble_device_base/test_aes_ccm.cpp b/tests/components/ble_device_base/test_aes_ccm.cpp index 39b2f81dcf..c844a30b2c 100644 --- a/tests/components/ble_device_base/test_aes_ccm.cpp +++ b/tests/components/ble_device_base/test_aes_ccm.cpp @@ -19,6 +19,33 @@ const uint8_t TAG[4] = {0x48, 0x4d, 0xaa, 0x56}; const uint8_t PLAINTEXT[7] = {0x02, 0x01, 0x64, 0x03, 0x10, 0x8a, 0x01}; } // namespace +// Xiaomi's parameters differ from BTHome's: a 12-byte nonce and a 1-byte AAD +// (0x11). Both the AAD block and the l = 3 length encoding are only reachable +// through this shape, so they need their own vector. Generated the same way. +namespace { +const uint8_t NONCE_XIAOMI[12] = {0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b}; +const uint8_t AAD_XIAOMI[1] = {0x11}; +const uint8_t CIPHERTEXT_XIAOMI[5] = {0xc3, 0x7e, 0x0a, 0x1d, 0x23}; +const uint8_t TAG_XIAOMI[4] = {0x98, 0x79, 0x87, 0xc6}; +const uint8_t PLAINTEXT_XIAOMI[5] = {0x04, 0x10, 0x02, 0xd4, 0x00}; +} // namespace + +TEST(BleAesCcm, DecryptsXiaomiShapedVector) { + uint8_t out[sizeof(PLAINTEXT_XIAOMI)] = {}; + EXPECT_TRUE(aes_ccm_auth_decrypt(KEY, NONCE_XIAOMI, sizeof(NONCE_XIAOMI), AAD_XIAOMI, sizeof(AAD_XIAOMI), + CIPHERTEXT_XIAOMI, sizeof(CIPHERTEXT_XIAOMI), out, TAG_XIAOMI, sizeof(TAG_XIAOMI))); + EXPECT_EQ(0, memcmp(out, PLAINTEXT_XIAOMI, sizeof(PLAINTEXT_XIAOMI))); +} + +TEST(BleAesCcm, RejectsWrongAssociatedData) { + uint8_t bad_aad[sizeof(AAD_XIAOMI)]; + memcpy(bad_aad, AAD_XIAOMI, sizeof(AAD_XIAOMI)); + bad_aad[0] ^= 0x01; + uint8_t out[sizeof(PLAINTEXT_XIAOMI)] = {}; + EXPECT_FALSE(aes_ccm_auth_decrypt(KEY, NONCE_XIAOMI, sizeof(NONCE_XIAOMI), bad_aad, sizeof(bad_aad), + CIPHERTEXT_XIAOMI, sizeof(CIPHERTEXT_XIAOMI), out, TAG_XIAOMI, sizeof(TAG_XIAOMI))); +} + TEST(BleAesCcm, DecryptsAndAuthenticatesKnownVector) { uint8_t out[sizeof(PLAINTEXT)] = {}; EXPECT_TRUE(aes_ccm_auth_decrypt(KEY, NONCE, sizeof(NONCE), nullptr, 0, CIPHERTEXT, sizeof(CIPHERTEXT), out, TAG, diff --git a/tests/components/exposure_notifications/common-ln.yaml b/tests/components/exposure_notifications/common-ln.yaml new file mode 100644 index 0000000000..f3f9b93464 --- /dev/null +++ b/tests/components/exposure_notifications/common-ln.yaml @@ -0,0 +1,5 @@ +exposure_notifications: + on_exposure_notification: + then: + - lambda: | + ESP_LOGD("main", "RSSI: %d", x.rssi); diff --git a/tests/components/exposure_notifications/common.yaml b/tests/components/exposure_notifications/common.yaml index faba5bb2d1..8cc209ff4e 100644 --- a/tests/components/exposure_notifications/common.yaml +++ b/tests/components/exposure_notifications/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub exposure_notifications: on_exposure_notification: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + ble_hub_id: ble_tracker_hub then: - lambda: | ESP_LOGD("main", "Got notification:"); diff --git a/tests/components/exposure_notifications/test.ln882x-ard.yaml b/tests/components/exposure_notifications/test.ln882x-ard.yaml new file mode 100644 index 0000000000..964f5b68b0 --- /dev/null +++ b/tests/components/exposure_notifications/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + exposure_notifications: !include common-ln.yaml diff --git a/tests/components/exposure_notifications/validate.bk72xx-ard.yaml b/tests/components/exposure_notifications/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..b487591d95 --- /dev/null +++ b/tests/components/exposure_notifications/validate.bk72xx-ard.yaml @@ -0,0 +1,15 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +exposure_notifications: + on_exposure_notification: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + ble_hub_id: ble_tracker_hub + then: + - lambda: | + ESP_LOGD("main", "Got notification:"); + ESP_LOGD("main", " RPI: %s", format_hex(x.rolling_proximity_identifier).c_str()); + ESP_LOGD("main", " RSSI: %d", x.rssi); diff --git a/tests/components/thermopro_ble/common-ln.yaml b/tests/components/thermopro_ble/common-ln.yaml new file mode 100644 index 0000000000..10aff2d658 --- /dev/null +++ b/tests/components/thermopro_ble/common-ln.yaml @@ -0,0 +1,7 @@ +sensor: + - platform: thermopro_ble + mac_address: FE:74:B8:6A:97:B7 + temperature: + name: ThermoPro Temperature + humidity: + name: ThermoPro Humidity diff --git a/tests/components/thermopro_ble/common.yaml b/tests/components/thermopro_ble/common.yaml index 297725e1c3..63fab83c01 100644 --- a/tests/components/thermopro_ble/common.yaml +++ b/tests/components/thermopro_ble/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: thermopro_ble + ble_hub_id: ble_tracker_hub mac_address: FE:74:B8:6A:97:B7 temperature: name: "ThermoPro Temperature" diff --git a/tests/components/thermopro_ble/test.ln882x-ard.yaml b/tests/components/thermopro_ble/test.ln882x-ard.yaml new file mode 100644 index 0000000000..b3a59e83fc --- /dev/null +++ b/tests/components/thermopro_ble/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + thermopro_ble: !include common-ln.yaml diff --git a/tests/components/thermopro_ble/validate.bk72xx-ard.yaml b/tests/components/thermopro_ble/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..8f1ec417be --- /dev/null +++ b/tests/components/thermopro_ble/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: thermopro_ble + ble_hub_id: ble_tracker_hub + mac_address: FE:74:B8:6A:97:B7 + temperature: + name: "ThermoPro Temperature" + humidity: + name: "ThermoPro Humidity" + battery_level: + name: "ThermoPro Battery Level" + signal_strength: + name: "ThermoPro Signal Strength" + # No ble_hub_id: exercises the generated binding real configs use. + - platform: thermopro_ble + mac_address: FE:74:B8:6A:97:B8 + temperature: + name: BK ThermoPro Implicit Temperature diff --git a/tests/components/xiaomi_ble/common-ln.yaml b/tests/components/xiaomi_ble/common-ln.yaml new file mode 100644 index 0000000000..d46c306d65 --- /dev/null +++ b/tests/components/xiaomi_ble/common-ln.yaml @@ -0,0 +1 @@ +xiaomi_ble: diff --git a/tests/components/xiaomi_ble/common.yaml b/tests/components/xiaomi_ble/common.yaml index 9d10393177..f218426c23 100644 --- a/tests/components/xiaomi_ble/common.yaml +++ b/tests/components/xiaomi_ble/common.yaml @@ -1,3 +1,6 @@ esp32_ble_tracker: + id: ble_tracker_hub +# Explicit ble_hub_id: pins the neutral binding as a declared key. xiaomi_ble: + ble_hub_id: ble_tracker_hub diff --git a/tests/components/xiaomi_ble/test.ln882x-ard.yaml b/tests/components/xiaomi_ble/test.ln882x-ard.yaml new file mode 100644 index 0000000000..014985c5c5 --- /dev/null +++ b/tests/components/xiaomi_ble/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_ble: !include common-ln.yaml diff --git a/tests/components/xiaomi_ble/validate.bk72xx-ard.yaml b/tests/components/xiaomi_ble/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..20b42c4263 --- /dev/null +++ b/tests/components/xiaomi_ble/validate.bk72xx-ard.yaml @@ -0,0 +1,9 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +# Explicit ble_hub_id: pins the neutral binding as a declared key. +xiaomi_ble: + ble_hub_id: ble_tracker_hub From 3aaea907baa6ab6c095945410ec33d55c15162ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Sat, 8 Aug 2026 05:59:21 +0300 Subject: [PATCH 1322/1815] [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 7: xiaomi_cgd1, xiaomi_cgdk2, xiaomi_cgg1) (#18170) --- esphome/components/xiaomi_cgd1/sensor.py | 14 +++++------ .../components/xiaomi_cgd1/xiaomi_cgd1.cpp | 6 +---- esphome/components/xiaomi_cgd1/xiaomi_cgd1.h | 10 +++----- esphome/components/xiaomi_cgdk2/sensor.py | 20 ++++++++-------- .../components/xiaomi_cgdk2/xiaomi_cgdk2.cpp | 6 +---- .../components/xiaomi_cgdk2/xiaomi_cgdk2.h | 10 +++----- esphome/components/xiaomi_cgg1/sensor.py | 14 +++++------ .../components/xiaomi_cgg1/xiaomi_cgg1.cpp | 6 +---- esphome/components/xiaomi_cgg1/xiaomi_cgg1.h | 10 +++----- tests/components/xiaomi_cgd1/common-ln.yaml | 10 ++++++++ tests/components/xiaomi_cgd1/common.yaml | 3 +++ .../xiaomi_cgd1/test.ln882x-ard.yaml | 3 +++ .../xiaomi_cgd1/validate.bk72xx-ard.yaml | 24 +++++++++++++++++++ tests/components/xiaomi_cgdk2/common-ln.yaml | 10 ++++++++ tests/components/xiaomi_cgdk2/common.yaml | 9 ++++--- .../xiaomi_cgdk2/test.ln882x-ard.yaml | 3 +++ .../xiaomi_cgdk2/validate.bk72xx-ard.yaml | 24 +++++++++++++++++++ tests/components/xiaomi_cgg1/common-ln.yaml | 10 ++++++++ tests/components/xiaomi_cgg1/common.yaml | 9 ++++--- .../xiaomi_cgg1/test.ln882x-ard.yaml | 3 +++ .../xiaomi_cgg1/validate.bk72xx-ard.yaml | 24 +++++++++++++++++++ 21 files changed, 162 insertions(+), 66 deletions(-) create mode 100644 tests/components/xiaomi_cgd1/common-ln.yaml create mode 100644 tests/components/xiaomi_cgd1/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_cgd1/validate.bk72xx-ard.yaml create mode 100644 tests/components/xiaomi_cgdk2/common-ln.yaml create mode 100644 tests/components/xiaomi_cgdk2/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_cgdk2/validate.bk72xx-ard.yaml create mode 100644 tests/components/xiaomi_cgg1/common-ln.yaml create mode 100644 tests/components/xiaomi_cgg1/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_cgg1/validate.bk72xx-ard.yaml diff --git a/esphome/components/xiaomi_cgd1/sensor.py b/esphome/components/xiaomi_cgd1/sensor.py index e11ddac19d..7206f023d7 100644 --- a/esphome/components/xiaomi_cgd1/sensor.py +++ b/esphome/components/xiaomi_cgd1/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -17,15 +17,15 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_cgd1_ns = cg.esphome_ns.namespace("xiaomi_cgd1") XiaomiCGD1 = xiaomi_cgd1_ns.class_( - "XiaomiCGD1", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiCGD1", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_cgd1"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiCGD1), @@ -52,15 +52,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_cgd1/xiaomi_cgd1.cpp b/esphome/components/xiaomi_cgd1/xiaomi_cgd1.cpp index 948e02be46..0159314f4d 100644 --- a/esphome/components/xiaomi_cgd1/xiaomi_cgd1.cpp +++ b/esphome/components/xiaomi_cgd1/xiaomi_cgd1.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_cgd1 { static const char *const TAG = "xiaomi_cgd1"; @@ -21,7 +19,7 @@ void XiaomiCGD1::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiCGD1::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiCGD1::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -65,5 +63,3 @@ bool XiaomiCGD1::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { void XiaomiCGD1::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } } // namespace esphome::xiaomi_cgd1 - -#endif diff --git a/esphome/components/xiaomi_cgd1/xiaomi_cgd1.h b/esphome/components/xiaomi_cgd1/xiaomi_cgd1.h index 1c510c7eb4..afa88738f0 100644 --- a/esphome/components/xiaomi_cgd1/xiaomi_cgd1.h +++ b/esphome/components/xiaomi_cgd1/xiaomi_cgd1.h @@ -2,19 +2,17 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_cgd1 { -class XiaomiCGD1 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiCGD1 : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } @@ -29,5 +27,3 @@ class XiaomiCGD1 : public Component, public esp32_ble_tracker::ESPBTDeviceListen }; } // namespace esphome::xiaomi_cgd1 - -#endif diff --git a/esphome/components/xiaomi_cgdk2/sensor.py b/esphome/components/xiaomi_cgdk2/sensor.py index c7ec13f6e0..0e7535cd76 100644 --- a/esphome/components/xiaomi_cgdk2/sensor.py +++ b/esphome/components/xiaomi_cgdk2/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -17,18 +17,18 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] -xiaomi_cgd1_ns = cg.esphome_ns.namespace("xiaomi_cgdk2") -XiaomiCGD1 = xiaomi_cgd1_ns.class_( - "XiaomiCGDK2", esp32_ble_tracker.ESPBTDeviceListener, cg.Component +xiaomi_cgdk2_ns = cg.esphome_ns.namespace("xiaomi_cgdk2") +XiaomiCGDK2 = xiaomi_cgdk2_ns.class_( + "XiaomiCGDK2", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_cgdk2"), cv.Schema( { - cv.GenerateID(): cv.declare_id(XiaomiCGD1), + cv.GenerateID(): cv.declare_id(XiaomiCGDK2), cv.Required(CONF_BINDKEY): cv.bind_key, cv.Required(CONF_MAC_ADDRESS): cv.mac_address, cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( @@ -52,15 +52,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.cpp b/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.cpp index ff9036db14..01912c3778 100644 --- a/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.cpp +++ b/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_cgdk2 { static const char *const TAG = "xiaomi_cgdk2"; @@ -21,7 +19,7 @@ void XiaomiCGDK2::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiCGDK2::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiCGDK2::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -65,5 +63,3 @@ bool XiaomiCGDK2::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { void XiaomiCGDK2::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } } // namespace esphome::xiaomi_cgdk2 - -#endif diff --git a/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h b/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h index 36068ae227..a27d41cea5 100644 --- a/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h +++ b/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h @@ -2,19 +2,17 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_cgdk2 { -class XiaomiCGDK2 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiCGDK2 final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } @@ -29,5 +27,3 @@ class XiaomiCGDK2 final : public Component, public esp32_ble_tracker::ESPBTDevic }; } // namespace esphome::xiaomi_cgdk2 - -#endif diff --git a/esphome/components/xiaomi_cgg1/sensor.py b/esphome/components/xiaomi_cgg1/sensor.py index 1a6ed2b7da..6273d8549b 100644 --- a/esphome/components/xiaomi_cgg1/sensor.py +++ b/esphome/components/xiaomi_cgg1/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -17,15 +17,15 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_cgg1_ns = cg.esphome_ns.namespace("xiaomi_cgg1") XiaomiCGG1 = xiaomi_cgg1_ns.class_( - "XiaomiCGG1", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiCGG1", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_cgg1"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiCGG1), @@ -52,15 +52,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) if CONF_BINDKEY in config: diff --git a/esphome/components/xiaomi_cgg1/xiaomi_cgg1.cpp b/esphome/components/xiaomi_cgg1/xiaomi_cgg1.cpp index ef4ef46424..679cb76198 100644 --- a/esphome/components/xiaomi_cgg1/xiaomi_cgg1.cpp +++ b/esphome/components/xiaomi_cgg1/xiaomi_cgg1.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_cgg1 { static const char *const TAG = "xiaomi_cgg1"; @@ -21,7 +19,7 @@ void XiaomiCGG1::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiCGG1::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiCGG1::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -65,5 +63,3 @@ bool XiaomiCGG1::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { void XiaomiCGG1::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } } // namespace esphome::xiaomi_cgg1 - -#endif diff --git a/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h b/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h index 7633458cb8..ef666ab6d2 100644 --- a/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h +++ b/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h @@ -2,19 +2,17 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_cgg1 { -class XiaomiCGG1 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiCGG1 final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } void set_bindkey(const char *bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } @@ -30,5 +28,3 @@ class XiaomiCGG1 final : public Component, public esp32_ble_tracker::ESPBTDevice }; } // namespace esphome::xiaomi_cgg1 - -#endif diff --git a/tests/components/xiaomi_cgd1/common-ln.yaml b/tests/components/xiaomi_cgd1/common-ln.yaml new file mode 100644 index 0000000000..0ee92e3e14 --- /dev/null +++ b/tests/components/xiaomi_cgd1/common-ln.yaml @@ -0,0 +1,10 @@ +sensor: + - platform: xiaomi_cgd1 + mac_address: A4:C1:38:D1:61:7D + bindkey: c99d2313182473b38001086febf781bd + temperature: + name: Xiaomi CGD1 Temperature + humidity: + name: Xiaomi CGD1 Humidity + battery_level: + name: Xiaomi CGD1 Battery Level diff --git a/tests/components/xiaomi_cgd1/common.yaml b/tests/components/xiaomi_cgd1/common.yaml index 94ed09e8f2..032a6d5c19 100644 --- a/tests/components/xiaomi_cgd1/common.yaml +++ b/tests/components/xiaomi_cgd1/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_cgd1 + ble_hub_id: ble_tracker_hub mac_address: A4:C1:38:D1:61:7D bindkey: c99d2313182473b38001086febf781bd temperature: diff --git a/tests/components/xiaomi_cgd1/test.ln882x-ard.yaml b/tests/components/xiaomi_cgd1/test.ln882x-ard.yaml new file mode 100644 index 0000000000..844f9c97bc --- /dev/null +++ b/tests/components/xiaomi_cgd1/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_cgd1: !include common-ln.yaml diff --git a/tests/components/xiaomi_cgd1/validate.bk72xx-ard.yaml b/tests/components/xiaomi_cgd1/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..5980fba7ff --- /dev/null +++ b/tests/components/xiaomi_cgd1/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_cgd1 + ble_hub_id: ble_tracker_hub + mac_address: A4:C1:38:D1:61:7D + bindkey: c99d2313182473b38001086febf781bd + temperature: + name: Xiaomi CGD1 Temperature + humidity: + name: Xiaomi CGD1 Humidity + battery_level: + name: Xiaomi CGD1 Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_cgd1 + mac_address: A4:C1:38:D1:61:7E + bindkey: c99d2313182473b38001086febf781bd + temperature: + name: BK Xiaomi CGD1 Implicit Temperature diff --git a/tests/components/xiaomi_cgdk2/common-ln.yaml b/tests/components/xiaomi_cgdk2/common-ln.yaml new file mode 100644 index 0000000000..f8ff21bd5b --- /dev/null +++ b/tests/components/xiaomi_cgdk2/common-ln.yaml @@ -0,0 +1,10 @@ +sensor: + - platform: xiaomi_cgdk2 + mac_address: A4:C1:38:D1:61:7D + bindkey: c99d2313182473b38001086febf781bd + temperature: + name: Xiaomi CGDK2 Temperature + humidity: + name: Xiaomi CGDK2 Humidity + battery_level: + name: Xiaomi CGDK2 Battery Level diff --git a/tests/components/xiaomi_cgdk2/common.yaml b/tests/components/xiaomi_cgdk2/common.yaml index dddca56222..d5040aa0be 100644 --- a/tests/components/xiaomi_cgdk2/common.yaml +++ b/tests/components/xiaomi_cgdk2/common.yaml @@ -1,12 +1,15 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_cgdk2 + ble_hub_id: ble_tracker_hub mac_address: A4:C1:38:D1:61:7D bindkey: c99d2313182473b38001086febf781bd temperature: - name: Xiaomi CGD1 Temperature + name: Xiaomi CGDK2 Temperature humidity: - name: Xiaomi CGD1 Humidity + name: Xiaomi CGDK2 Humidity battery_level: - name: Xiaomi CGD1 Battery Level + name: Xiaomi CGDK2 Battery Level diff --git a/tests/components/xiaomi_cgdk2/test.ln882x-ard.yaml b/tests/components/xiaomi_cgdk2/test.ln882x-ard.yaml new file mode 100644 index 0000000000..6f0fb03fd8 --- /dev/null +++ b/tests/components/xiaomi_cgdk2/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_cgdk2: !include common-ln.yaml diff --git a/tests/components/xiaomi_cgdk2/validate.bk72xx-ard.yaml b/tests/components/xiaomi_cgdk2/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..0eb2617cd7 --- /dev/null +++ b/tests/components/xiaomi_cgdk2/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_cgdk2 + ble_hub_id: ble_tracker_hub + mac_address: A4:C1:38:D1:61:7D + bindkey: c99d2313182473b38001086febf781bd + temperature: + name: Xiaomi CGDK2 Temperature + humidity: + name: Xiaomi CGDK2 Humidity + battery_level: + name: Xiaomi CGDK2 Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_cgdk2 + mac_address: A4:C1:38:D1:61:7E + bindkey: c99d2313182473b38001086febf781bd + temperature: + name: BK Xiaomi CGDK2 Implicit Temperature diff --git a/tests/components/xiaomi_cgg1/common-ln.yaml b/tests/components/xiaomi_cgg1/common-ln.yaml new file mode 100644 index 0000000000..f26d31ed50 --- /dev/null +++ b/tests/components/xiaomi_cgg1/common-ln.yaml @@ -0,0 +1,10 @@ +sensor: + - platform: xiaomi_cgg1 + mac_address: A4:C1:38:D1:61:7D + bindkey: c99d2313182473b38001086febf781bd + temperature: + name: Xiaomi CGG1 Temperature + humidity: + name: Xiaomi CGG1 Humidity + battery_level: + name: Xiaomi CGG1 Battery Level diff --git a/tests/components/xiaomi_cgg1/common.yaml b/tests/components/xiaomi_cgg1/common.yaml index 170aebfbde..e4a3ef4ba7 100644 --- a/tests/components/xiaomi_cgg1/common.yaml +++ b/tests/components/xiaomi_cgg1/common.yaml @@ -1,12 +1,15 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_cgg1 + ble_hub_id: ble_tracker_hub mac_address: A4:C1:38:D1:61:7D bindkey: c99d2313182473b38001086febf781bd temperature: - name: Xiaomi CGD1 Temperature + name: Xiaomi CGG1 Temperature humidity: - name: Xiaomi CGD1 Humidity + name: Xiaomi CGG1 Humidity battery_level: - name: Xiaomi CGD1 Battery Level + name: Xiaomi CGG1 Battery Level diff --git a/tests/components/xiaomi_cgg1/test.ln882x-ard.yaml b/tests/components/xiaomi_cgg1/test.ln882x-ard.yaml new file mode 100644 index 0000000000..76ebbc01ed --- /dev/null +++ b/tests/components/xiaomi_cgg1/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_cgg1: !include common-ln.yaml diff --git a/tests/components/xiaomi_cgg1/validate.bk72xx-ard.yaml b/tests/components/xiaomi_cgg1/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..11420925a0 --- /dev/null +++ b/tests/components/xiaomi_cgg1/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_cgg1 + ble_hub_id: ble_tracker_hub + mac_address: A4:C1:38:D1:61:7D + bindkey: c99d2313182473b38001086febf781bd + temperature: + name: Xiaomi CGG1 Temperature + humidity: + name: Xiaomi CGG1 Humidity + battery_level: + name: Xiaomi CGG1 Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_cgg1 + mac_address: A4:C1:38:D1:61:7E + bindkey: c99d2313182473b38001086febf781bd + temperature: + name: BK Xiaomi CGG1 Implicit Temperature From b986530efc4739c10f95d45f845877714db8a909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Sat, 8 Aug 2026 06:51:33 +0300 Subject: [PATCH 1323/1815] [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 8: xiaomi_cgpr1, xiaomi_gcls002, xiaomi_hhccjcy01) (#18171) --- .../components/xiaomi_cgpr1/binary_sensor.py | 12 ++++----- .../components/xiaomi_cgpr1/xiaomi_cgpr1.cpp | 6 +---- .../components/xiaomi_cgpr1/xiaomi_cgpr1.h | 10 +++---- esphome/components/xiaomi_gcls002/sensor.py | 14 +++++----- .../xiaomi_gcls002/xiaomi_gcls002.cpp | 6 +---- .../xiaomi_gcls002/xiaomi_gcls002.h | 10 +++---- esphome/components/xiaomi_hhccjcy01/sensor.py | 14 +++++----- .../xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp | 6 +---- .../xiaomi_hhccjcy01/xiaomi_hhccjcy01.h | 10 +++---- tests/components/xiaomi_cgpr1/common-ln.yaml | 11 ++++++++ tests/components/xiaomi_cgpr1/common.yaml | 3 +++ .../xiaomi_cgpr1/test.ln882x-ard.yaml | 3 +++ .../xiaomi_cgpr1/validate.bk72xx-ard.yaml | 24 +++++++++++++++++ .../components/xiaomi_gcls002/common-ln.yaml | 11 ++++++++ tests/components/xiaomi_gcls002/common.yaml | 3 +++ .../xiaomi_gcls002/test.ln882x-ard.yaml | 3 +++ .../xiaomi_gcls002/validate.bk72xx-ard.yaml | 24 +++++++++++++++++ .../xiaomi_hhccjcy01/common-ln.yaml | 13 ++++++++++ tests/components/xiaomi_hhccjcy01/common.yaml | 3 +++ .../xiaomi_hhccjcy01/test.ln882x-ard.yaml | 3 +++ .../xiaomi_hhccjcy01/validate.bk72xx-ard.yaml | 26 +++++++++++++++++++ 21 files changed, 159 insertions(+), 56 deletions(-) create mode 100644 tests/components/xiaomi_cgpr1/common-ln.yaml create mode 100644 tests/components/xiaomi_cgpr1/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_cgpr1/validate.bk72xx-ard.yaml create mode 100644 tests/components/xiaomi_gcls002/common-ln.yaml create mode 100644 tests/components/xiaomi_gcls002/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_gcls002/validate.bk72xx-ard.yaml create mode 100644 tests/components/xiaomi_hhccjcy01/common-ln.yaml create mode 100644 tests/components/xiaomi_hhccjcy01/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_hhccjcy01/validate.bk72xx-ard.yaml diff --git a/esphome/components/xiaomi_cgpr1/binary_sensor.py b/esphome/components/xiaomi_cgpr1/binary_sensor.py index 0606c93dbe..3fdcd983b0 100644 --- a/esphome/components/xiaomi_cgpr1/binary_sensor.py +++ b/esphome/components/xiaomi_cgpr1/binary_sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import binary_sensor, esp32_ble_tracker, sensor +from esphome.components import binary_sensor, ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -18,18 +18,18 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble", "sensor"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble", "sensor"] xiaomi_cgpr1_ns = cg.esphome_ns.namespace("xiaomi_cgpr1") XiaomiCGPR1 = xiaomi_cgpr1_ns.class_( "XiaomiCGPR1", binary_sensor.BinarySensor, cg.Component, - esp32_ble_tracker.ESPBTDeviceListener, + ble_device_base.ESPBTDeviceListener, ) CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_cgpr1"), binary_sensor.binary_sensor_schema(XiaomiCGPR1, device_class=DEVICE_CLASS_MOTION) .extend( { @@ -57,15 +57,15 @@ CONFIG_SCHEMA = cv.All( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.cpp b/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.cpp index 3203f358b9..019de54ad8 100644 --- a/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.cpp +++ b/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_cgpr1 { static const char *const TAG = "xiaomi_cgpr1"; @@ -16,7 +14,7 @@ void XiaomiCGPR1::dump_config() { LOG_SENSOR(" ", "Illuminance", this->illuminance_); } -bool XiaomiCGPR1::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiCGPR1::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -62,5 +60,3 @@ bool XiaomiCGPR1::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { void XiaomiCGPR1::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } } // namespace esphome::xiaomi_cgpr1 - -#endif diff --git a/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h b/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h index 0fa6c76e54..9e1d1c4482 100644 --- a/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h +++ b/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h @@ -3,21 +3,19 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/binary_sensor/binary_sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_cgpr1 { class XiaomiCGPR1 final : public Component, public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener { + public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } void set_bindkey(const char *bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_battery_level(sensor::Sensor *battery_level) { battery_level_ = battery_level; } @@ -33,5 +31,3 @@ class XiaomiCGPR1 final : public Component, }; } // namespace esphome::xiaomi_cgpr1 - -#endif diff --git a/esphome/components/xiaomi_gcls002/sensor.py b/esphome/components/xiaomi_gcls002/sensor.py index 6c9ad2e361..f430cbdd10 100644 --- a/esphome/components/xiaomi_gcls002/sensor.py +++ b/esphome/components/xiaomi_gcls002/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_CONDUCTIVITY, @@ -19,15 +19,15 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_gcls002_ns = cg.esphome_ns.namespace("xiaomi_gcls002") XiaomiGCLS002 = xiaomi_gcls002_ns.class_( - "XiaomiGCLS002", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiGCLS002", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_gcls002"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiGCLS002), @@ -58,15 +58,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_gcls002/xiaomi_gcls002.cpp b/esphome/components/xiaomi_gcls002/xiaomi_gcls002.cpp index 11ea98045b..27effd64bb 100644 --- a/esphome/components/xiaomi_gcls002/xiaomi_gcls002.cpp +++ b/esphome/components/xiaomi_gcls002/xiaomi_gcls002.cpp @@ -1,8 +1,6 @@ #include "xiaomi_gcls002.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_gcls002 { static const char *const TAG = "xiaomi_gcls002"; @@ -15,7 +13,7 @@ void XiaomiGCLS002::dump_config() { LOG_SENSOR(" ", "Illuminance", this->illuminance_); } -bool XiaomiGCLS002::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiGCLS002::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -58,5 +56,3 @@ bool XiaomiGCLS002::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { } } // namespace esphome::xiaomi_gcls002 - -#endif diff --git a/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h b/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h index 668133f364..969218c220 100644 --- a/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h +++ b/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h @@ -2,18 +2,16 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_gcls002 { -class XiaomiGCLS002 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiGCLS002 final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } @@ -30,5 +28,3 @@ class XiaomiGCLS002 final : public Component, public esp32_ble_tracker::ESPBTDev }; } // namespace esphome::xiaomi_gcls002 - -#endif diff --git a/esphome/components/xiaomi_hhccjcy01/sensor.py b/esphome/components/xiaomi_hhccjcy01/sensor.py index 90a8753412..2c2e88b75f 100644 --- a/esphome/components/xiaomi_hhccjcy01/sensor.py +++ b/esphome/components/xiaomi_hhccjcy01/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -22,15 +22,15 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_hhccjcy01_ns = cg.esphome_ns.namespace("xiaomi_hhccjcy01") XiaomiHHCCJCY01 = xiaomi_hhccjcy01_ns.class_( - "XiaomiHHCCJCY01", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiHHCCJCY01", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_hhccjcy01"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiHHCCJCY01), @@ -68,15 +68,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp b/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp index 1d872c68c1..5e2369a6d9 100644 --- a/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp +++ b/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp @@ -1,8 +1,6 @@ #include "xiaomi_hhccjcy01.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_hhccjcy01 { static const char *const TAG = "xiaomi_hhccjcy01"; @@ -16,7 +14,7 @@ void XiaomiHHCCJCY01::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiHHCCJCY01::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiHHCCJCY01::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -61,5 +59,3 @@ bool XiaomiHHCCJCY01::parse_device(const esp32_ble_tracker::ESPBTDevice &device) } } // namespace esphome::xiaomi_hhccjcy01 - -#endif diff --git a/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h b/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h index cb53b47f6f..ce573b73c1 100644 --- a/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h +++ b/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h @@ -2,18 +2,16 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_hhccjcy01 { -class XiaomiHHCCJCY01 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiHHCCJCY01 final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } @@ -32,5 +30,3 @@ class XiaomiHHCCJCY01 final : public Component, public esp32_ble_tracker::ESPBTD }; } // namespace esphome::xiaomi_hhccjcy01 - -#endif diff --git a/tests/components/xiaomi_cgpr1/common-ln.yaml b/tests/components/xiaomi_cgpr1/common-ln.yaml new file mode 100644 index 0000000000..fa421b1eaa --- /dev/null +++ b/tests/components/xiaomi_cgpr1/common-ln.yaml @@ -0,0 +1,11 @@ +binary_sensor: + - platform: xiaomi_cgpr1 + name: CGPR1 Motion + mac_address: "12:34:56:12:34:56" + bindkey: 48403ebe2d385db8d0c187f81e62cb64 + battery_level: + name: CGPR1 battery Level + idle_time: + name: CGPR1 Idle Time + illuminance: + name: CGPR1 Illuminance diff --git a/tests/components/xiaomi_cgpr1/common.yaml b/tests/components/xiaomi_cgpr1/common.yaml index 48082a886c..ed59d31511 100644 --- a/tests/components/xiaomi_cgpr1/common.yaml +++ b/tests/components/xiaomi_cgpr1/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub binary_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_cgpr1 + ble_hub_id: ble_tracker_hub name: CGPR1 Motion mac_address: "12:34:56:12:34:56" bindkey: 48403ebe2d385db8d0c187f81e62cb64 diff --git a/tests/components/xiaomi_cgpr1/test.ln882x-ard.yaml b/tests/components/xiaomi_cgpr1/test.ln882x-ard.yaml new file mode 100644 index 0000000000..7199fd6a6c --- /dev/null +++ b/tests/components/xiaomi_cgpr1/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_cgpr1: !include common-ln.yaml diff --git a/tests/components/xiaomi_cgpr1/validate.bk72xx-ard.yaml b/tests/components/xiaomi_cgpr1/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..61c18a17cc --- /dev/null +++ b/tests/components/xiaomi_cgpr1/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +binary_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_cgpr1 + ble_hub_id: ble_tracker_hub + name: CGPR1 Motion + mac_address: "12:34:56:12:34:56" + bindkey: 48403ebe2d385db8d0c187f81e62cb64 + battery_level: + name: CGPR1 battery Level + idle_time: + name: CGPR1 Idle Time + illuminance: + name: CGPR1 Illuminance + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_cgpr1 + name: BK CGPR1 Implicit Motion + mac_address: "12:34:56:12:34:57" + bindkey: 48403ebe2d385db8d0c187f81e62cb64 diff --git a/tests/components/xiaomi_gcls002/common-ln.yaml b/tests/components/xiaomi_gcls002/common-ln.yaml new file mode 100644 index 0000000000..606f78e7cd --- /dev/null +++ b/tests/components/xiaomi_gcls002/common-ln.yaml @@ -0,0 +1,11 @@ +sensor: + - platform: xiaomi_gcls002 + mac_address: 94:2B:FF:5C:91:61 + temperature: + name: GCLS02 Temperature + moisture: + name: GCLS02 Moisture + conductivity: + name: GCLS02 Soil Conductivity + illuminance: + name: GCLS02 Illuminance diff --git a/tests/components/xiaomi_gcls002/common.yaml b/tests/components/xiaomi_gcls002/common.yaml index 32990708cc..86ec068a19 100644 --- a/tests/components/xiaomi_gcls002/common.yaml +++ b/tests/components/xiaomi_gcls002/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_gcls002 + ble_hub_id: ble_tracker_hub mac_address: 94:2B:FF:5C:91:61 temperature: name: GCLS02 Temperature diff --git a/tests/components/xiaomi_gcls002/test.ln882x-ard.yaml b/tests/components/xiaomi_gcls002/test.ln882x-ard.yaml new file mode 100644 index 0000000000..1bec7b000c --- /dev/null +++ b/tests/components/xiaomi_gcls002/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_gcls002: !include common-ln.yaml diff --git a/tests/components/xiaomi_gcls002/validate.bk72xx-ard.yaml b/tests/components/xiaomi_gcls002/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..f84d325685 --- /dev/null +++ b/tests/components/xiaomi_gcls002/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_gcls002 + ble_hub_id: ble_tracker_hub + mac_address: 94:2B:FF:5C:91:61 + temperature: + name: GCLS02 Temperature + moisture: + name: GCLS02 Moisture + conductivity: + name: GCLS02 Soil Conductivity + illuminance: + name: GCLS02 Illuminance + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_gcls002 + mac_address: 94:2B:FF:5C:91:62 + temperature: + name: BK GCLS02 Implicit Temperature diff --git a/tests/components/xiaomi_hhccjcy01/common-ln.yaml b/tests/components/xiaomi_hhccjcy01/common-ln.yaml new file mode 100644 index 0000000000..1fcbe985eb --- /dev/null +++ b/tests/components/xiaomi_hhccjcy01/common-ln.yaml @@ -0,0 +1,13 @@ +sensor: + - platform: xiaomi_hhccjcy01 + mac_address: 94:2B:FF:5C:91:61 + temperature: + name: Xiaomi HHCCJCY01 Temperature + moisture: + name: Xiaomi HHCCJCY01 Moisture + illuminance: + name: Xiaomi HHCCJCY01 Illuminance + conductivity: + name: Xiaomi HHCCJCY01 Soil Conductivity + battery_level: + name: Xiaomi HHCCJCY01 Battery Level diff --git a/tests/components/xiaomi_hhccjcy01/common.yaml b/tests/components/xiaomi_hhccjcy01/common.yaml index 0def909488..756f1280f6 100644 --- a/tests/components/xiaomi_hhccjcy01/common.yaml +++ b/tests/components/xiaomi_hhccjcy01/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_hhccjcy01 + ble_hub_id: ble_tracker_hub mac_address: 94:2B:FF:5C:91:61 temperature: name: Xiaomi HHCCJCY01 Temperature diff --git a/tests/components/xiaomi_hhccjcy01/test.ln882x-ard.yaml b/tests/components/xiaomi_hhccjcy01/test.ln882x-ard.yaml new file mode 100644 index 0000000000..3cb949bb83 --- /dev/null +++ b/tests/components/xiaomi_hhccjcy01/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_hhccjcy01: !include common-ln.yaml diff --git a/tests/components/xiaomi_hhccjcy01/validate.bk72xx-ard.yaml b/tests/components/xiaomi_hhccjcy01/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..da52b527e9 --- /dev/null +++ b/tests/components/xiaomi_hhccjcy01/validate.bk72xx-ard.yaml @@ -0,0 +1,26 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_hhccjcy01 + ble_hub_id: ble_tracker_hub + mac_address: 94:2B:FF:5C:91:61 + temperature: + name: Xiaomi HHCCJCY01 Temperature + moisture: + name: Xiaomi HHCCJCY01 Moisture + illuminance: + name: Xiaomi HHCCJCY01 Illuminance + conductivity: + name: Xiaomi HHCCJCY01 Soil Conductivity + battery_level: + name: Xiaomi HHCCJCY01 Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_hhccjcy01 + mac_address: 94:2B:FF:5C:91:62 + temperature: + name: BK Xiaomi HHCCJCY01 Implicit Temperature From 745dee0734d9233d01663335afca0d3c03f7d5f7 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 7 Aug 2026 23:24:56 -0500 Subject: [PATCH 1324/1815] [usb_cdc_acm] Don't discard queued TX data when USB flush times out (#17637) Co-authored-by: Claude Fable 5 Co-authored-by: J. Nick Koston --- esphome/components/usb_cdc_acm/usb_cdc_acm.h | 17 +++ .../usb_cdc_acm/usb_cdc_acm_esp32.cpp | 132 +++++++++++++++--- 2 files changed, 131 insertions(+), 18 deletions(-) diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.h b/esphome/components/usb_cdc_acm/usb_cdc_acm.h index 8e71fc61b2..d8eb91586a 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.h +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.h @@ -7,6 +7,7 @@ #include "esphome/core/lock_free_queue.h" #include "esphome/components/uart/uart_component.h" +#include #include #include "freertos/ringbuf.h" #include "tinyusb_cdc_acm.h" @@ -96,10 +97,26 @@ class USBCDCACMInstance final : public uart::UARTComponent, public Parented rather than std::atomic because GCC on Xtensa + // generates an indirect function call for atomic ops instead of inlining + // them; atomic inlines correctly on all platforms. + std::atomic usb_tx_busy_{0}; + // Running total of bytes dropped by write_array() (never reset), and the timestamp + // of the last "buffer full" log line (throttled so a sustained host stall doesn't + // flood the log). + uint32_t tx_dropped_bytes_{0}; + uint32_t tx_dropped_log_ms_{0}; // RX buffer for peek functionality uint8_t peek_buffer_{0}; bool has_peek_{false}; diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp index 859d6cbaea..e46369660d 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp @@ -2,6 +2,7 @@ defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_cdc_acm.h" #include "esphome/core/application.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" #include @@ -24,6 +25,13 @@ static constexpr size_t USB_CDC_MAX_LOG_BYTES = 168; static constexpr size_t USB_TX_TASK_STACK_SIZE = 4096; static constexpr size_t USB_TX_TASK_STACK_SIZE_VV = 8192; +// Upper bound on how long flush() may block in total: the TX ring buffer drain and +// the final TinyUSB flush share this budget. +static constexpr uint32_t FLUSH_TIMEOUT_MS = 100; + +// Minimum interval between repeated warnings while a host stall persists. +static constexpr uint32_t LOG_THROTTLE_MS = 1000; + static USBCDCACMInstance *get_instance_by_itf(int itf) { if (global_usb_cdc_component == nullptr) { return nullptr; @@ -186,11 +194,21 @@ void USBCDCACMInstance::usb_tx_task_fn(void *arg) { void USBCDCACMInstance::usb_tx_task() { uint8_t data[CONFIG_TINYUSB_CDC_TX_BUFSIZE] = {0}; size_t tx_data_size = 0; + // Back-dated so a stall within the first LOG_THROTTLE_MS of uptime still logs + // immediately (unsigned arithmetic keeps this wrap-safe). + uint32_t stall_log_ms = millis() - LOG_THROTTLE_MS; while (true) { + // Not holding any data while blocked waiting for more. + this->usb_tx_busy_ = 0; + // Wait for a notification from the bridge component ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + // Raise the busy flag before pulling data out of the ring buffer, so at every + // instant flush() sees pending bytes in the ring buffer count or in this flag. + this->usb_tx_busy_ = 1; + // When we do wake up, we can be sure there is data in the ring buffer esp_err_t ret = ringbuf_read_bytes(this->usb_tx_ringbuf_, data, CONFIG_TINYUSB_CDC_TX_BUFSIZE, &tx_data_size, 0); @@ -224,11 +242,50 @@ void USBCDCACMInstance::usb_tx_task() { esp_err_t flush_ret = tinyusb_cdcacm_write_flush(static_cast(this->itf_), pdMS_TO_TICKS(10)); - if (flush_ret != ESP_OK) { - ESP_LOGE(TAG, "USB TX itf=%d: flush failed", this->itf_); - tud_cdc_n_write_clear(this->itf_); - break; + if (flush_ret == ESP_OK) { + continue; } + + // Bytes not yet handed to TinyUSB plus bytes still sitting in its transmit FIFO. + // tud_cdc_n_write_occupied() is not public API in the pinned TinyUSB release, so + // derive the occupancy from the FIFO depth TinyUSB itself is configured with. + const size_t pending = tx_data_size + (CFG_TUD_CDC_TX_BUFSIZE - tud_cdc_n_write_available(this->itf_)); + + // A flush timeout only means TinyUSB's transmit FIFO did not fully drain within + // the wait window; the queued bytes are untouched and TinyUSB keeps sending them + // from its transfer-complete callback once the host polls again. Clearing the + // FIFO here would discard the tail of a frame whose head is already on the wire, + // corrupting the stream mid-frame. Hold the data and retry instead; sustained + // backpressure then propagates to the ring buffer, which drops whole writes with + // a warning instead of splitting a frame. + // + // Gate the retry on DTR (tud_cdc_n_connected()) rather than tud_ready(): an + // enumerated-but-idle host (no application holding the port open) never polls + // the IN endpoint, so retrying on tud_ready() alone would wedge this task -- and + // stall every write_array()/flush() caller behind a full ring buffer -- for as + // long as the board sits plugged into an idle PC. DTR means an application has + // the port open and is expected to eventually read. + if (flush_ret == ESP_ERR_TIMEOUT && tud_cdc_n_connected(this->itf_)) { + const uint32_t now = millis(); + if ((now - stall_log_ms) >= LOG_THROTTLE_MS) { + stall_log_ms = now; + ESP_LOGW(TAG, "USB TX itf=%d: host not reading; %zu bytes pending", this->itf_, pending); + } + continue; + } + + if (flush_ret == ESP_ERR_TIMEOUT) { + // No application has the port open (DTR deasserted) or the device is detached, + // so the data cannot be delivered. TinyUSB does not clear its transmit FIFO on + // bus reset; drop the data here so a stale partial frame is not replayed when + // the port is (re)opened. + ESP_LOGW(TAG, "USB TX itf=%d: not connected; dropping %zu bytes", this->itf_, pending); + } else { + ESP_LOGE(TAG, "USB TX itf=%d: flush failed (%s); dropping %zu bytes", this->itf_, esp_err_to_name(flush_ret), + pending); + } + tud_cdc_n_write_clear(this->itf_); + break; } } } @@ -245,7 +302,19 @@ void USBCDCACMInstance::write_array(const uint8_t *data, size_t len) { // Write data to TX ring buffer BaseType_t send_res = xRingbufferSend(this->usb_tx_ringbuf_, data, len, 0); if (send_res != pdTRUE) { - ESP_LOGW(TAG, "USB TX itf=%d: buffer full, %u bytes dropped", this->itf_, len); + // During a sustained host stall the ring buffer stays full (that is the intended + // backpressure), so this path runs for every write; throttle the warning so the + // log stays readable. The counter is a running total that is never reset: each + // line reports all bytes dropped so far, so bytes dropped in the tail of one + // stall are still accounted for by the next line, whenever that is. It also makes + // the very first drop since boot detectable, which is logged unthrottled. + const bool first_drop = this->tx_dropped_bytes_ == 0; + this->tx_dropped_bytes_ += len; + const uint32_t now = millis(); + if (first_drop || (now - this->tx_dropped_log_ms_) >= LOG_THROTTLE_MS) { + this->tx_dropped_log_ms_ = now; + ESP_LOGW(TAG, "USB TX itf=%d: buffer full, %" PRIu32 " bytes dropped total", this->itf_, this->tx_dropped_bytes_); + } return; } @@ -326,27 +395,54 @@ size_t USBCDCACMInstance::available() { return waiting + (this->has_peek_ ? 1 : 0); } +// True while TX bytes have not yet reached TinyUSB's FIFO: still counted in the ring +// buffer, or held by the TX task (usb_tx_busy_) between pulling them from the ring +// buffer and handing them to TinyUSB -- there they are in neither the ring buffer +// count nor TinyUSB's FIFO. +bool USBCDCACMInstance::tx_pending_() { + UBaseType_t waiting = 0; + vRingbufferGetInfo(this->usb_tx_ringbuf_, nullptr, nullptr, nullptr, nullptr, &waiting); + return waiting != 0 || this->usb_tx_busy_ != 0; +} + uart::UARTFlushResult USBCDCACMInstance::flush() { - // Wait for TX ring buffer to be empty if (this->usb_tx_ringbuf_ == nullptr) { return uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS; } - UBaseType_t waiting = 1; - while (waiting > 0) { - vRingbufferGetInfo(this->usb_tx_ringbuf_, nullptr, nullptr, nullptr, nullptr, &waiting); - if (waiting > 0) { - vTaskDelay(pdMS_TO_TICKS(1)); + // Bound the wait: when the host stalls or disconnects, the TX task holds on to + // pending data rather than discarding it, so the ring buffer may not drain for as + // long as the host stays away. flush() runs on the caller's (typically the main + // loop) task and must not block indefinitely. Signed tick differences keep the + // deadline arithmetic wrap-safe. + TickType_t now = xTaskGetTickCount(); + const TickType_t deadline = now + pdMS_TO_TICKS(FLUSH_TIMEOUT_MS); + while (this->tx_pending_()) { + if (static_cast(now - deadline) >= 0) { + return uart::UARTFlushResult::UART_FLUSH_RESULT_TIMEOUT; } + vTaskDelay(pdMS_TO_TICKS(1)); + now = xTaskGetTickCount(); } - // Also wait for USB to finish transmitting - esp_err_t err = tinyusb_cdcacm_write_flush(static_cast(this->itf_), pdMS_TO_TICKS(100)); - if (err == ESP_OK) - return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; - if (err == ESP_ERR_TIMEOUT) - return uart::UARTFlushResult::UART_FLUSH_RESULT_TIMEOUT; - return uart::UARTFlushResult::UART_FLUSH_RESULT_FAILED; + // Also wait for USB to finish transmitting, within whatever remains of the budget. + // Floor at one tick: a zero-tick timeout takes esp_tinyusb's non-blocking branch, + // whose return contract is that library's internal detail and may differ between + // releases. One tick keeps the call on the blocking branch (ESP_OK/ESP_ERR_TIMEOUT) + // at the cost of at most one tick over budget. + const int32_t remaining = static_cast(deadline - now); + const TickType_t flush_ticks = remaining > 0 ? static_cast(remaining) : 1; + switch (tinyusb_cdcacm_write_flush(static_cast(this->itf_), flush_ticks)) { + case ESP_OK: + return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; + case ESP_ERR_TIMEOUT: + // ESP_ERR_NOT_FINISHED is the non-blocking branch's "still draining" result; + // mapped like a timeout in case a future esp_tinyusb release returns it here. + case ESP_ERR_NOT_FINISHED: + return uart::UARTFlushResult::UART_FLUSH_RESULT_TIMEOUT; + default: + return uart::UARTFlushResult::UART_FLUSH_RESULT_FAILED; + } } void USBCDCACMInstance::check_logger_conflict() {} From 087b80eeb51857bb28ca1b1e96863281ccdab72a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Sat, 8 Aug 2026 07:42:37 +0300 Subject: [PATCH 1325/1815] [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 9: xiaomi_hhccjcy10, xiaomi_hhccpot002, xiaomi_jqjcy01ym) (#18172) --- esphome/components/xiaomi_hhccjcy10/sensor.py | 13 +++++----- .../xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp | 6 +---- .../xiaomi_hhccjcy10/xiaomi_hhccjcy10.h | 10 +++---- .../components/xiaomi_hhccpot002/sensor.py | 14 +++++----- .../xiaomi_hhccpot002/xiaomi_hhccpot002.cpp | 6 +---- .../xiaomi_hhccpot002/xiaomi_hhccpot002.h | 10 +++---- esphome/components/xiaomi_jqjcy01ym/sensor.py | 14 +++++----- .../xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp | 6 +---- .../xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h | 10 +++---- tests/components/xiaomi_cgpr1/common-ln.yaml | 2 +- tests/components/xiaomi_cgpr1/common.yaml | 2 +- .../xiaomi_cgpr1/validate.bk72xx-ard.yaml | 2 +- .../xiaomi_hhccjcy10/common-ln.yaml | 13 ++++++++++ tests/components/xiaomi_hhccjcy10/common.yaml | 18 +++++++++++++ .../xiaomi_hhccjcy10/test.esp32-idf.yaml | 3 +++ .../xiaomi_hhccjcy10/test.ln882x-ard.yaml | 3 +++ .../xiaomi_hhccjcy10/validate.bk72xx-ard.yaml | 26 +++++++++++++++++++ .../xiaomi_hhccpot002/common-ln.yaml | 7 +++++ .../components/xiaomi_hhccpot002/common.yaml | 3 +++ .../xiaomi_hhccpot002/test.ln882x-ard.yaml | 3 +++ .../validate.bk72xx-ard.yaml | 20 ++++++++++++++ .../xiaomi_jqjcy01ym/common-ln.yaml | 11 ++++++++ tests/components/xiaomi_jqjcy01ym/common.yaml | 3 +++ .../xiaomi_jqjcy01ym/test.ln882x-ard.yaml | 3 +++ .../xiaomi_jqjcy01ym/validate.bk72xx-ard.yaml | 24 +++++++++++++++++ 25 files changed, 173 insertions(+), 59 deletions(-) create mode 100644 tests/components/xiaomi_hhccjcy10/common-ln.yaml create mode 100644 tests/components/xiaomi_hhccjcy10/common.yaml create mode 100644 tests/components/xiaomi_hhccjcy10/test.esp32-idf.yaml create mode 100644 tests/components/xiaomi_hhccjcy10/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_hhccjcy10/validate.bk72xx-ard.yaml create mode 100644 tests/components/xiaomi_hhccpot002/common-ln.yaml create mode 100644 tests/components/xiaomi_hhccpot002/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_hhccpot002/validate.bk72xx-ard.yaml create mode 100644 tests/components/xiaomi_jqjcy01ym/common-ln.yaml create mode 100644 tests/components/xiaomi_jqjcy01ym/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_jqjcy01ym/validate.bk72xx-ard.yaml diff --git a/esphome/components/xiaomi_hhccjcy10/sensor.py b/esphome/components/xiaomi_hhccjcy10/sensor.py index d6a4a4adb2..56eeda484e 100644 --- a/esphome/components/xiaomi_hhccjcy10/sensor.py +++ b/esphome/components/xiaomi_hhccjcy10/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -22,14 +22,15 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] xiaomi_hhccjcy10_ns = cg.esphome_ns.namespace("xiaomi_hhccjcy10") XiaomiHHCCJCY10 = xiaomi_hhccjcy10_ns.class_( - "XiaomiHHCCJCY10", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiHHCCJCY10", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_hhccjcy10"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiHHCCJCY10), @@ -67,15 +68,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp index c6ebd5ff74..680eb04e77 100644 --- a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp +++ b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_hhccjcy10 { static const char *const TAG = "xiaomi_hhccjcy10"; @@ -17,7 +15,7 @@ void XiaomiHHCCJCY10::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiHHCCJCY10::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiHHCCJCY10::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -63,5 +61,3 @@ bool XiaomiHHCCJCY10::parse_device(const esp32_ble_tracker::ESPBTDevice &device) } } // namespace esphome::xiaomi_hhccjcy10 - -#endif diff --git a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h index fa2f461534..ce6dc2081e 100644 --- a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h +++ b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h @@ -2,17 +2,15 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" - -#ifdef USE_ESP32 +#include "esphome/components/ble_device_base/ble_device.h" namespace esphome::xiaomi_hhccjcy10 { -class XiaomiHHCCJCY10 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiHHCCJCY10 final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { this->address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { this->temperature_ = temperature; } @@ -31,5 +29,3 @@ class XiaomiHHCCJCY10 final : public Component, public esp32_ble_tracker::ESPBTD }; } // namespace esphome::xiaomi_hhccjcy10 - -#endif diff --git a/esphome/components/xiaomi_hhccpot002/sensor.py b/esphome/components/xiaomi_hhccpot002/sensor.py index adc64f6650..50b10777bb 100644 --- a/esphome/components/xiaomi_hhccpot002/sensor.py +++ b/esphome/components/xiaomi_hhccpot002/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_CONDUCTIVITY, @@ -13,15 +13,15 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_hhccpot002_ns = cg.esphome_ns.namespace("xiaomi_hhccpot002") XiaomiHHCCPOT002 = xiaomi_hhccpot002_ns.class_( - "XiaomiHHCCPOT002", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiHHCCPOT002", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_hhccpot002"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiHHCCPOT002), @@ -40,15 +40,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp index bbca9faaa6..fc8d15228d 100644 --- a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp +++ b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp @@ -1,8 +1,6 @@ #include "xiaomi_hhccpot002.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_hhccpot002 { static const char *const TAG = "xiaomi_hhccpot002"; @@ -13,7 +11,7 @@ void XiaomiHHCCPOT002 ::dump_config() { LOG_SENSOR(" ", "Conductivity", this->conductivity_); } -bool XiaomiHHCCPOT002::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiHHCCPOT002::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -52,5 +50,3 @@ bool XiaomiHHCCPOT002::parse_device(const esp32_ble_tracker::ESPBTDevice &device } } // namespace esphome::xiaomi_hhccpot002 - -#endif diff --git a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h index 3eda1b9859..e472178baa 100644 --- a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h +++ b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h @@ -2,18 +2,16 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_hhccpot002 { -class XiaomiHHCCPOT002 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiHHCCPOT002 final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_moisture(sensor::Sensor *moisture) { moisture_ = moisture; } @@ -26,5 +24,3 @@ class XiaomiHHCCPOT002 final : public Component, public esp32_ble_tracker::ESPBT }; } // namespace esphome::xiaomi_hhccpot002 - -#endif diff --git a/esphome/components/xiaomi_jqjcy01ym/sensor.py b/esphome/components/xiaomi_jqjcy01ym/sensor.py index 5890ed6b63..7467f08785 100644 --- a/esphome/components/xiaomi_jqjcy01ym/sensor.py +++ b/esphome/components/xiaomi_jqjcy01ym/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -19,15 +19,15 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_jqjcy01ym_ns = cg.esphome_ns.namespace("xiaomi_jqjcy01ym") XiaomiJQJCY01YM = xiaomi_jqjcy01ym_ns.class_( - "XiaomiJQJCY01YM", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiJQJCY01YM", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_jqjcy01ym"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiJQJCY01YM), @@ -59,15 +59,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp b/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp index c0f4de3d06..f7a1318d7c 100644 --- a/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp +++ b/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp @@ -1,8 +1,6 @@ #include "xiaomi_jqjcy01ym.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_jqjcy01ym { static const char *const TAG = "xiaomi_jqjcy01ym"; @@ -15,7 +13,7 @@ void XiaomiJQJCY01YM::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiJQJCY01YM::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiJQJCY01YM::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -58,5 +56,3 @@ bool XiaomiJQJCY01YM::parse_device(const esp32_ble_tracker::ESPBTDevice &device) } } // namespace esphome::xiaomi_jqjcy01ym - -#endif diff --git a/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h b/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h index 122c6776c9..955ee41880 100644 --- a/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h +++ b/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h @@ -2,18 +2,16 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_jqjcy01ym { -class XiaomiJQJCY01YM final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiJQJCY01YM final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } @@ -30,5 +28,3 @@ class XiaomiJQJCY01YM final : public Component, public esp32_ble_tracker::ESPBTD }; } // namespace esphome::xiaomi_jqjcy01ym - -#endif diff --git a/tests/components/xiaomi_cgpr1/common-ln.yaml b/tests/components/xiaomi_cgpr1/common-ln.yaml index fa421b1eaa..675d7ac18e 100644 --- a/tests/components/xiaomi_cgpr1/common-ln.yaml +++ b/tests/components/xiaomi_cgpr1/common-ln.yaml @@ -4,7 +4,7 @@ binary_sensor: mac_address: "12:34:56:12:34:56" bindkey: 48403ebe2d385db8d0c187f81e62cb64 battery_level: - name: CGPR1 battery Level + name: CGPR1 Battery Level idle_time: name: CGPR1 Idle Time illuminance: diff --git a/tests/components/xiaomi_cgpr1/common.yaml b/tests/components/xiaomi_cgpr1/common.yaml index ed59d31511..d713e5e996 100644 --- a/tests/components/xiaomi_cgpr1/common.yaml +++ b/tests/components/xiaomi_cgpr1/common.yaml @@ -9,7 +9,7 @@ binary_sensor: mac_address: "12:34:56:12:34:56" bindkey: 48403ebe2d385db8d0c187f81e62cb64 battery_level: - name: CGPR1 battery Level + name: CGPR1 Battery Level idle_time: name: CGPR1 Idle Time illuminance: diff --git a/tests/components/xiaomi_cgpr1/validate.bk72xx-ard.yaml b/tests/components/xiaomi_cgpr1/validate.bk72xx-ard.yaml index 61c18a17cc..749adfebe2 100644 --- a/tests/components/xiaomi_cgpr1/validate.bk72xx-ard.yaml +++ b/tests/components/xiaomi_cgpr1/validate.bk72xx-ard.yaml @@ -12,7 +12,7 @@ binary_sensor: mac_address: "12:34:56:12:34:56" bindkey: 48403ebe2d385db8d0c187f81e62cb64 battery_level: - name: CGPR1 battery Level + name: CGPR1 Battery Level idle_time: name: CGPR1 Idle Time illuminance: diff --git a/tests/components/xiaomi_hhccjcy10/common-ln.yaml b/tests/components/xiaomi_hhccjcy10/common-ln.yaml new file mode 100644 index 0000000000..c71b5cc1e7 --- /dev/null +++ b/tests/components/xiaomi_hhccjcy10/common-ln.yaml @@ -0,0 +1,13 @@ +sensor: + - platform: xiaomi_hhccjcy10 + mac_address: 94:2B:FF:5C:91:61 + temperature: + name: Xiaomi HHCCJCY10 Temperature + moisture: + name: Xiaomi HHCCJCY10 Moisture + illuminance: + name: Xiaomi HHCCJCY10 Illuminance + conductivity: + name: Xiaomi HHCCJCY10 Conductivity + battery_level: + name: Xiaomi HHCCJCY10 Battery Level diff --git a/tests/components/xiaomi_hhccjcy10/common.yaml b/tests/components/xiaomi_hhccjcy10/common.yaml new file mode 100644 index 0000000000..79efdde42d --- /dev/null +++ b/tests/components/xiaomi_hhccjcy10/common.yaml @@ -0,0 +1,18 @@ +esp32_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_hhccjcy10 + ble_hub_id: ble_tracker_hub + mac_address: 94:2B:FF:5C:91:61 + temperature: + name: Xiaomi HHCCJCY10 Temperature + moisture: + name: Xiaomi HHCCJCY10 Moisture + illuminance: + name: Xiaomi HHCCJCY10 Illuminance + conductivity: + name: Xiaomi HHCCJCY10 Conductivity + battery_level: + name: Xiaomi HHCCJCY10 Battery Level diff --git a/tests/components/xiaomi_hhccjcy10/test.esp32-idf.yaml b/tests/components/xiaomi_hhccjcy10/test.esp32-idf.yaml new file mode 100644 index 0000000000..bc67f843ff --- /dev/null +++ b/tests/components/xiaomi_hhccjcy10/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + ble: !include ../../test_build_components/common/ble/esp32-idf.yaml + xiaomi_hhccjcy10: !include common.yaml diff --git a/tests/components/xiaomi_hhccjcy10/test.ln882x-ard.yaml b/tests/components/xiaomi_hhccjcy10/test.ln882x-ard.yaml new file mode 100644 index 0000000000..8fe9e74dfd --- /dev/null +++ b/tests/components/xiaomi_hhccjcy10/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_hhccjcy10: !include common-ln.yaml diff --git a/tests/components/xiaomi_hhccjcy10/validate.bk72xx-ard.yaml b/tests/components/xiaomi_hhccjcy10/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..e39bcfb8be --- /dev/null +++ b/tests/components/xiaomi_hhccjcy10/validate.bk72xx-ard.yaml @@ -0,0 +1,26 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_hhccjcy10 + ble_hub_id: ble_tracker_hub + mac_address: 94:2B:FF:5C:91:61 + temperature: + name: Xiaomi HHCCJCY10 Temperature + moisture: + name: Xiaomi HHCCJCY10 Moisture + illuminance: + name: Xiaomi HHCCJCY10 Illuminance + conductivity: + name: Xiaomi HHCCJCY10 Conductivity + battery_level: + name: Xiaomi HHCCJCY10 Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_hhccjcy10 + mac_address: 94:2B:FF:5C:91:62 + temperature: + name: BK Xiaomi HHCCJCY10 Implicit Temperature diff --git a/tests/components/xiaomi_hhccpot002/common-ln.yaml b/tests/components/xiaomi_hhccpot002/common-ln.yaml new file mode 100644 index 0000000000..6f39b6a2b8 --- /dev/null +++ b/tests/components/xiaomi_hhccpot002/common-ln.yaml @@ -0,0 +1,7 @@ +sensor: + - platform: xiaomi_hhccpot002 + mac_address: 94:2B:FF:5C:91:61 + moisture: + name: HHCCPOT002 Moisture + conductivity: + name: HHCCPOT002 Soil Conductivity diff --git a/tests/components/xiaomi_hhccpot002/common.yaml b/tests/components/xiaomi_hhccpot002/common.yaml index 2e5fa14620..cee426f100 100644 --- a/tests/components/xiaomi_hhccpot002/common.yaml +++ b/tests/components/xiaomi_hhccpot002/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_hhccpot002 + ble_hub_id: ble_tracker_hub mac_address: 94:2B:FF:5C:91:61 moisture: name: HHCCPOT002 Moisture diff --git a/tests/components/xiaomi_hhccpot002/test.ln882x-ard.yaml b/tests/components/xiaomi_hhccpot002/test.ln882x-ard.yaml new file mode 100644 index 0000000000..1f69281400 --- /dev/null +++ b/tests/components/xiaomi_hhccpot002/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_hhccpot002: !include common-ln.yaml diff --git a/tests/components/xiaomi_hhccpot002/validate.bk72xx-ard.yaml b/tests/components/xiaomi_hhccpot002/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..c4003ecf4b --- /dev/null +++ b/tests/components/xiaomi_hhccpot002/validate.bk72xx-ard.yaml @@ -0,0 +1,20 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_hhccpot002 + ble_hub_id: ble_tracker_hub + mac_address: 94:2B:FF:5C:91:61 + moisture: + name: HHCCPOT002 Moisture + conductivity: + name: HHCCPOT002 Soil Conductivity + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_hhccpot002 + mac_address: 94:2B:FF:5C:91:62 + moisture: + name: BK HHCCPOT002 Implicit Moisture diff --git a/tests/components/xiaomi_jqjcy01ym/common-ln.yaml b/tests/components/xiaomi_jqjcy01ym/common-ln.yaml new file mode 100644 index 0000000000..c20269eab4 --- /dev/null +++ b/tests/components/xiaomi_jqjcy01ym/common-ln.yaml @@ -0,0 +1,11 @@ +sensor: + - platform: xiaomi_jqjcy01ym + mac_address: 7A:80:8E:19:36:BA + temperature: + name: JQJCY01YM Temperature + humidity: + name: JQJCY01YM Humidity + formaldehyde: + name: JQJCY01YM Formaldehyde + battery_level: + name: JQJCY01YM Battery Level diff --git a/tests/components/xiaomi_jqjcy01ym/common.yaml b/tests/components/xiaomi_jqjcy01ym/common.yaml index 54c4b33dcd..1aace227cf 100644 --- a/tests/components/xiaomi_jqjcy01ym/common.yaml +++ b/tests/components/xiaomi_jqjcy01ym/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_jqjcy01ym + ble_hub_id: ble_tracker_hub mac_address: 7A:80:8E:19:36:BA temperature: name: JQJCY01YM Temperature diff --git a/tests/components/xiaomi_jqjcy01ym/test.ln882x-ard.yaml b/tests/components/xiaomi_jqjcy01ym/test.ln882x-ard.yaml new file mode 100644 index 0000000000..f3196e5188 --- /dev/null +++ b/tests/components/xiaomi_jqjcy01ym/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_jqjcy01ym: !include common-ln.yaml diff --git a/tests/components/xiaomi_jqjcy01ym/validate.bk72xx-ard.yaml b/tests/components/xiaomi_jqjcy01ym/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..c63ddcae3e --- /dev/null +++ b/tests/components/xiaomi_jqjcy01ym/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_jqjcy01ym + ble_hub_id: ble_tracker_hub + mac_address: 7A:80:8E:19:36:BA + temperature: + name: JQJCY01YM Temperature + humidity: + name: JQJCY01YM Humidity + formaldehyde: + name: JQJCY01YM Formaldehyde + battery_level: + name: JQJCY01YM Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_jqjcy01ym + mac_address: 7A:80:8E:19:36:BB + temperature: + name: BK JQJCY01YM Implicit Temperature From 413a4c5885ae762d1e7814a7274f6109908c1c39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Sat, 8 Aug 2026 08:27:44 +0300 Subject: [PATCH 1326/1815] [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 10: xiaomi_lywsd02, xiaomi_lywsd02mmc, xiaomi_lywsd03mmc) (#18174) --- esphome/components/xiaomi_lywsd02/sensor.py | 14 +++++------ .../xiaomi_lywsd02/xiaomi_lywsd02.cpp | 6 +---- .../xiaomi_lywsd02/xiaomi_lywsd02.h | 10 +++----- .../components/xiaomi_lywsd02mmc/sensor.py | 14 +++++------ .../xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp | 6 +---- .../xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h | 10 +++----- .../components/xiaomi_lywsd03mmc/sensor.py | 14 +++++------ .../xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp | 6 +---- .../xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h | 10 +++----- .../components/xiaomi_lywsd02/common-ln.yaml | 9 +++++++ tests/components/xiaomi_lywsd02/common.yaml | 3 +++ .../xiaomi_lywsd02/test.ln882x-ard.yaml | 3 +++ .../xiaomi_lywsd02/validate.bk72xx-ard.yaml | 22 +++++++++++++++++ .../xiaomi_lywsd02mmc/common-ln.yaml | 10 ++++++++ .../components/xiaomi_lywsd02mmc/common.yaml | 3 +++ .../xiaomi_lywsd02mmc/test.ln882x-ard.yaml | 3 +++ .../validate.bk72xx-ard.yaml | 24 +++++++++++++++++++ .../xiaomi_lywsd03mmc/common-ln.yaml | 10 ++++++++ .../components/xiaomi_lywsd03mmc/common.yaml | 3 +++ .../xiaomi_lywsd03mmc/test.ln882x-ard.yaml | 3 +++ .../validate.bk72xx-ard.yaml | 24 +++++++++++++++++++ 21 files changed, 150 insertions(+), 57 deletions(-) create mode 100644 tests/components/xiaomi_lywsd02/common-ln.yaml create mode 100644 tests/components/xiaomi_lywsd02/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_lywsd02/validate.bk72xx-ard.yaml create mode 100644 tests/components/xiaomi_lywsd02mmc/common-ln.yaml create mode 100644 tests/components/xiaomi_lywsd02mmc/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_lywsd02mmc/validate.bk72xx-ard.yaml create mode 100644 tests/components/xiaomi_lywsd03mmc/common-ln.yaml create mode 100644 tests/components/xiaomi_lywsd03mmc/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_lywsd03mmc/validate.bk72xx-ard.yaml diff --git a/esphome/components/xiaomi_lywsd02/sensor.py b/esphome/components/xiaomi_lywsd02/sensor.py index ef6aebe6c0..c455961e7e 100644 --- a/esphome/components/xiaomi_lywsd02/sensor.py +++ b/esphome/components/xiaomi_lywsd02/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -16,15 +16,15 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_lywsd02_ns = cg.esphome_ns.namespace("xiaomi_lywsd02") XiaomiLYWSD02 = xiaomi_lywsd02_ns.class_( - "XiaomiLYWSD02", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiLYWSD02", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_lywsd02"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiLYWSD02), @@ -50,15 +50,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.cpp b/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.cpp index 75909738c8..d465f2fec0 100644 --- a/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.cpp +++ b/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.cpp @@ -1,8 +1,6 @@ #include "xiaomi_lywsd02.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_lywsd02 { static const char *const TAG = "xiaomi_lywsd02"; @@ -14,7 +12,7 @@ void XiaomiLYWSD02::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiLYWSD02::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiLYWSD02::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -55,5 +53,3 @@ bool XiaomiLYWSD02::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { } } // namespace esphome::xiaomi_lywsd02 - -#endif diff --git a/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h b/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h index 09256047ae..0c1035bf1d 100644 --- a/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h +++ b/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h @@ -2,18 +2,16 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_lywsd02 { -class XiaomiLYWSD02 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiLYWSD02 final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } @@ -28,5 +26,3 @@ class XiaomiLYWSD02 final : public Component, public esp32_ble_tracker::ESPBTDev }; } // namespace esphome::xiaomi_lywsd02 - -#endif diff --git a/esphome/components/xiaomi_lywsd02mmc/sensor.py b/esphome/components/xiaomi_lywsd02mmc/sensor.py index 813429a6c5..000460b333 100644 --- a/esphome/components/xiaomi_lywsd02mmc/sensor.py +++ b/esphome/components/xiaomi_lywsd02mmc/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -17,16 +17,16 @@ from esphome.const import ( UNIT_PERCENT, ) -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] CODEOWNERS = ["@juanluss31"] -DEPENDENCIES = ["esp32_ble_tracker"] xiaomi_lywsd02mmc_ns = cg.esphome_ns.namespace("xiaomi_lywsd02mmc") XiaomiLYWSD02MMC = xiaomi_lywsd02mmc_ns.class_( - "XiaomiLYWSD02MMC", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiLYWSD02MMC", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_lywsd02mmc"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiLYWSD02MMC), @@ -53,15 +53,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp b/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp index 79610ee266..dca5f73909 100644 --- a/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp +++ b/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_lywsd02mmc { static const char *const TAG = "xiaomi_lywsd02mmc"; @@ -21,7 +19,7 @@ void XiaomiLYWSD02MMC::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiLYWSD02MMC::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiLYWSD02MMC::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -65,5 +63,3 @@ bool XiaomiLYWSD02MMC::parse_device(const esp32_ble_tracker::ESPBTDevice &device void XiaomiLYWSD02MMC::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } } // namespace esphome::xiaomi_lywsd02mmc - -#endif diff --git a/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h b/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h index efd758b972..e00afffe0a 100644 --- a/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h +++ b/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h @@ -2,19 +2,17 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_lywsd02mmc { -class XiaomiLYWSD02MMC final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiLYWSD02MMC final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { this->address_ = address; } void set_bindkey(const char *bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { this->temperature_ = temperature; } @@ -30,5 +28,3 @@ class XiaomiLYWSD02MMC final : public Component, public esp32_ble_tracker::ESPBT }; } // namespace esphome::xiaomi_lywsd02mmc - -#endif diff --git a/esphome/components/xiaomi_lywsd03mmc/sensor.py b/esphome/components/xiaomi_lywsd03mmc/sensor.py index bf2de3756c..6362f26524 100644 --- a/esphome/components/xiaomi_lywsd03mmc/sensor.py +++ b/esphome/components/xiaomi_lywsd03mmc/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -19,15 +19,15 @@ from esphome.const import ( CODEOWNERS = ["@ahpohl"] -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_lywsd03mmc_ns = cg.esphome_ns.namespace("xiaomi_lywsd03mmc") XiaomiLYWSD03MMC = xiaomi_lywsd03mmc_ns.class_( - "XiaomiLYWSD03MMC", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiLYWSD03MMC", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_lywsd03mmc"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiLYWSD03MMC), @@ -54,15 +54,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp index 7aa4809e24..356a4ffd4e 100644 --- a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp +++ b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_lywsd03mmc { static const char *const TAG = "xiaomi_lywsd03mmc"; @@ -21,7 +19,7 @@ void XiaomiLYWSD03MMC::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiLYWSD03MMC::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiLYWSD03MMC::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -69,5 +67,3 @@ bool XiaomiLYWSD03MMC::parse_device(const esp32_ble_tracker::ESPBTDevice &device void XiaomiLYWSD03MMC::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } } // namespace esphome::xiaomi_lywsd03mmc - -#endif diff --git a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h index ecdbd412cb..a4f6e53215 100644 --- a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h +++ b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h @@ -2,19 +2,17 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_lywsd03mmc { -class XiaomiLYWSD03MMC final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiLYWSD03MMC final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } @@ -29,5 +27,3 @@ class XiaomiLYWSD03MMC final : public Component, public esp32_ble_tracker::ESPBT }; } // namespace esphome::xiaomi_lywsd03mmc - -#endif diff --git a/tests/components/xiaomi_lywsd02/common-ln.yaml b/tests/components/xiaomi_lywsd02/common-ln.yaml new file mode 100644 index 0000000000..ea3ec6647f --- /dev/null +++ b/tests/components/xiaomi_lywsd02/common-ln.yaml @@ -0,0 +1,9 @@ +sensor: + - platform: xiaomi_lywsd02 + mac_address: 3F:5B:7D:82:58:4E + temperature: + name: Xiaomi LYWSD02 Temperature + humidity: + name: Xiaomi LYWSD02 Humidity + battery_level: + name: Xiaomi LYWSD02 Battery Level diff --git a/tests/components/xiaomi_lywsd02/common.yaml b/tests/components/xiaomi_lywsd02/common.yaml index 3e40ab8d70..76638cec5e 100644 --- a/tests/components/xiaomi_lywsd02/common.yaml +++ b/tests/components/xiaomi_lywsd02/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_lywsd02 + ble_hub_id: ble_tracker_hub mac_address: 3F:5B:7D:82:58:4E temperature: name: Xiaomi LYWSD02 Temperature diff --git a/tests/components/xiaomi_lywsd02/test.ln882x-ard.yaml b/tests/components/xiaomi_lywsd02/test.ln882x-ard.yaml new file mode 100644 index 0000000000..cc3e0bca1e --- /dev/null +++ b/tests/components/xiaomi_lywsd02/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_lywsd02: !include common-ln.yaml diff --git a/tests/components/xiaomi_lywsd02/validate.bk72xx-ard.yaml b/tests/components/xiaomi_lywsd02/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..59d9045e37 --- /dev/null +++ b/tests/components/xiaomi_lywsd02/validate.bk72xx-ard.yaml @@ -0,0 +1,22 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_lywsd02 + ble_hub_id: ble_tracker_hub + mac_address: 3F:5B:7D:82:58:4E + temperature: + name: Xiaomi LYWSD02 Temperature + humidity: + name: Xiaomi LYWSD02 Humidity + battery_level: + name: Xiaomi LYWSD02 Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_lywsd02 + mac_address: 3F:5B:7D:82:58:4F + temperature: + name: BK Xiaomi LYWSD02 Implicit Temperature diff --git a/tests/components/xiaomi_lywsd02mmc/common-ln.yaml b/tests/components/xiaomi_lywsd02mmc/common-ln.yaml new file mode 100644 index 0000000000..9e81de78ae --- /dev/null +++ b/tests/components/xiaomi_lywsd02mmc/common-ln.yaml @@ -0,0 +1,10 @@ +sensor: + - platform: xiaomi_lywsd02mmc + mac_address: A4:C1:38:54:5E:18 + bindkey: 2529d8e0d23150a588675cc54ad48400 + temperature: + name: Xiaomi LYWSD02MMC Temperature + humidity: + name: Xiaomi LYWSD02MMC Humidity + battery_level: + name: Xiaomi LYWSD02MMC Battery Level diff --git a/tests/components/xiaomi_lywsd02mmc/common.yaml b/tests/components/xiaomi_lywsd02mmc/common.yaml index e63f585830..870a4f4916 100644 --- a/tests/components/xiaomi_lywsd02mmc/common.yaml +++ b/tests/components/xiaomi_lywsd02mmc/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_lywsd02mmc + ble_hub_id: ble_tracker_hub mac_address: A4:C1:38:54:5E:18 bindkey: 2529d8e0d23150a588675cc54ad48400 temperature: diff --git a/tests/components/xiaomi_lywsd02mmc/test.ln882x-ard.yaml b/tests/components/xiaomi_lywsd02mmc/test.ln882x-ard.yaml new file mode 100644 index 0000000000..bcbe4c20d5 --- /dev/null +++ b/tests/components/xiaomi_lywsd02mmc/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_lywsd02mmc: !include common-ln.yaml diff --git a/tests/components/xiaomi_lywsd02mmc/validate.bk72xx-ard.yaml b/tests/components/xiaomi_lywsd02mmc/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..f5266b65af --- /dev/null +++ b/tests/components/xiaomi_lywsd02mmc/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_lywsd02mmc + ble_hub_id: ble_tracker_hub + mac_address: A4:C1:38:54:5E:18 + bindkey: 2529d8e0d23150a588675cc54ad48400 + temperature: + name: Xiaomi LYWSD02MMC Temperature + humidity: + name: Xiaomi LYWSD02MMC Humidity + battery_level: + name: Xiaomi LYWSD02MMC Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_lywsd02mmc + mac_address: A4:C1:38:54:5E:19 + bindkey: 2529d8e0d23150a588675cc54ad48400 + temperature: + name: BK Xiaomi LYWSD02MMC Implicit Temperature diff --git a/tests/components/xiaomi_lywsd03mmc/common-ln.yaml b/tests/components/xiaomi_lywsd03mmc/common-ln.yaml new file mode 100644 index 0000000000..fe9b0b7b32 --- /dev/null +++ b/tests/components/xiaomi_lywsd03mmc/common-ln.yaml @@ -0,0 +1,10 @@ +sensor: + - platform: xiaomi_lywsd03mmc + mac_address: A4:C1:38:4E:16:78 + bindkey: e9efaa6873f9f9c87a5e75a5f814801c + temperature: + name: Xiaomi LYWSD03MMC Temperature + humidity: + name: Xiaomi LYWSD03MMC Humidity + battery_level: + name: Xiaomi LYWSD03MMC Battery Level diff --git a/tests/components/xiaomi_lywsd03mmc/common.yaml b/tests/components/xiaomi_lywsd03mmc/common.yaml index d10a859c56..907fdb9078 100644 --- a/tests/components/xiaomi_lywsd03mmc/common.yaml +++ b/tests/components/xiaomi_lywsd03mmc/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_lywsd03mmc + ble_hub_id: ble_tracker_hub mac_address: A4:C1:38:4E:16:78 bindkey: e9efaa6873f9f9c87a5e75a5f814801c temperature: diff --git a/tests/components/xiaomi_lywsd03mmc/test.ln882x-ard.yaml b/tests/components/xiaomi_lywsd03mmc/test.ln882x-ard.yaml new file mode 100644 index 0000000000..c85742c495 --- /dev/null +++ b/tests/components/xiaomi_lywsd03mmc/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_lywsd03mmc: !include common-ln.yaml diff --git a/tests/components/xiaomi_lywsd03mmc/validate.bk72xx-ard.yaml b/tests/components/xiaomi_lywsd03mmc/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..e13b4dac47 --- /dev/null +++ b/tests/components/xiaomi_lywsd03mmc/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_lywsd03mmc + ble_hub_id: ble_tracker_hub + mac_address: A4:C1:38:4E:16:78 + bindkey: e9efaa6873f9f9c87a5e75a5f814801c + temperature: + name: Xiaomi LYWSD03MMC Temperature + humidity: + name: Xiaomi LYWSD03MMC Humidity + battery_level: + name: Xiaomi LYWSD03MMC Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_lywsd03mmc + mac_address: A4:C1:38:4E:16:79 + bindkey: e9efaa6873f9f9c87a5e75a5f814801c + temperature: + name: BK Xiaomi LYWSD03MMC Implicit Temperature From 98f4854ecde764feb376ad05cf63924491477971 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Sat, 8 Aug 2026 09:01:24 +0300 Subject: [PATCH 1327/1815] [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 11: xiaomi_lywsdcgq, xiaomi_mhoc303, xiaomi_mhoc401) (#18178) --- esphome/components/xiaomi_lywsdcgq/sensor.py | 14 +++++------ .../xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp | 6 +---- .../xiaomi_lywsdcgq/xiaomi_lywsdcgq.h | 10 +++----- esphome/components/xiaomi_mhoc303/sensor.py | 14 +++++------ .../xiaomi_mhoc303/xiaomi_mhoc303.cpp | 6 +---- .../xiaomi_mhoc303/xiaomi_mhoc303.h | 10 +++----- esphome/components/xiaomi_mhoc401/sensor.py | 14 +++++------ .../xiaomi_mhoc401/xiaomi_mhoc401.cpp | 6 +---- .../xiaomi_mhoc401/xiaomi_mhoc401.h | 10 +++----- .../components/xiaomi_lywsdcgq/common-ln.yaml | 9 +++++++ tests/components/xiaomi_lywsdcgq/common.yaml | 3 +++ .../xiaomi_lywsdcgq/test.ln882x-ard.yaml | 3 +++ .../xiaomi_lywsdcgq/validate.bk72xx-ard.yaml | 22 +++++++++++++++++ .../components/xiaomi_mhoc303/common-ln.yaml | 9 +++++++ tests/components/xiaomi_mhoc303/common.yaml | 3 +++ .../xiaomi_mhoc303/test.ln882x-ard.yaml | 3 +++ .../xiaomi_mhoc303/validate.bk72xx-ard.yaml | 22 +++++++++++++++++ .../components/xiaomi_mhoc401/common-ln.yaml | 10 ++++++++ tests/components/xiaomi_mhoc401/common.yaml | 9 ++++--- .../xiaomi_mhoc401/test.ln882x-ard.yaml | 3 +++ .../xiaomi_mhoc401/validate.bk72xx-ard.yaml | 24 +++++++++++++++++++ 21 files changed, 150 insertions(+), 60 deletions(-) create mode 100644 tests/components/xiaomi_lywsdcgq/common-ln.yaml create mode 100644 tests/components/xiaomi_lywsdcgq/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_lywsdcgq/validate.bk72xx-ard.yaml create mode 100644 tests/components/xiaomi_mhoc303/common-ln.yaml create mode 100644 tests/components/xiaomi_mhoc303/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_mhoc303/validate.bk72xx-ard.yaml create mode 100644 tests/components/xiaomi_mhoc401/common-ln.yaml create mode 100644 tests/components/xiaomi_mhoc401/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_mhoc401/validate.bk72xx-ard.yaml diff --git a/esphome/components/xiaomi_lywsdcgq/sensor.py b/esphome/components/xiaomi_lywsdcgq/sensor.py index 5d964ea22a..0fbe4fcda9 100644 --- a/esphome/components/xiaomi_lywsdcgq/sensor.py +++ b/esphome/components/xiaomi_lywsdcgq/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -16,15 +16,15 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_lywsdcgq_ns = cg.esphome_ns.namespace("xiaomi_lywsdcgq") XiaomiLYWSDCGQ = xiaomi_lywsdcgq_ns.class_( - "XiaomiLYWSDCGQ", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiLYWSDCGQ", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_lywsdcgq"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiLYWSDCGQ), @@ -50,15 +50,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp b/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp index 56efaaef51..1ddf7ec235 100644 --- a/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp +++ b/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp @@ -1,8 +1,6 @@ #include "xiaomi_lywsdcgq.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_lywsdcgq { static const char *const TAG = "xiaomi_lywsdcgq"; @@ -14,7 +12,7 @@ void XiaomiLYWSDCGQ::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiLYWSDCGQ::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiLYWSDCGQ::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -55,5 +53,3 @@ bool XiaomiLYWSDCGQ::parse_device(const esp32_ble_tracker::ESPBTDevice &device) } } // namespace esphome::xiaomi_lywsdcgq - -#endif diff --git a/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h b/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h index 86afef4571..5cecc2f78a 100644 --- a/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h +++ b/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h @@ -2,18 +2,16 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_lywsdcgq { -class XiaomiLYWSDCGQ final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiLYWSDCGQ final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } @@ -28,5 +26,3 @@ class XiaomiLYWSDCGQ final : public Component, public esp32_ble_tracker::ESPBTDe }; } // namespace esphome::xiaomi_lywsdcgq - -#endif diff --git a/esphome/components/xiaomi_mhoc303/sensor.py b/esphome/components/xiaomi_mhoc303/sensor.py index 86c4d6699f..de1b3ea4b8 100644 --- a/esphome/components/xiaomi_mhoc303/sensor.py +++ b/esphome/components/xiaomi_mhoc303/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -16,15 +16,15 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_mhoc303_ns = cg.esphome_ns.namespace("xiaomi_mhoc303") XiaomiMHOC303 = xiaomi_mhoc303_ns.class_( - "XiaomiMHOC303", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiMHOC303", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_mhoc303"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiMHOC303), @@ -50,15 +50,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.cpp b/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.cpp index 74626ed0a5..9706e50861 100644 --- a/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.cpp +++ b/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.cpp @@ -1,8 +1,6 @@ #include "xiaomi_mhoc303.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_mhoc303 { static const char *const TAG = "xiaomi_mhoc303"; @@ -14,7 +12,7 @@ void XiaomiMHOC303::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiMHOC303::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiMHOC303::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -55,5 +53,3 @@ bool XiaomiMHOC303::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { } } // namespace esphome::xiaomi_mhoc303 - -#endif diff --git a/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h b/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h index 042a5034f1..a15b58f8ed 100644 --- a/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h +++ b/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h @@ -2,18 +2,16 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_mhoc303 { -class XiaomiMHOC303 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiMHOC303 final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } @@ -28,5 +26,3 @@ class XiaomiMHOC303 final : public Component, public esp32_ble_tracker::ESPBTDev }; } // namespace esphome::xiaomi_mhoc303 - -#endif diff --git a/esphome/components/xiaomi_mhoc401/sensor.py b/esphome/components/xiaomi_mhoc401/sensor.py index 7161e88da5..4604af218e 100644 --- a/esphome/components/xiaomi_mhoc401/sensor.py +++ b/esphome/components/xiaomi_mhoc401/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -18,15 +18,15 @@ from esphome.const import ( ) CODEOWNERS = ["@vevsvevs"] -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_mhoc401_ns = cg.esphome_ns.namespace("xiaomi_mhoc401") XiaomiMHOC401 = xiaomi_mhoc401_ns.class_( - "XiaomiMHOC401", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiMHOC401", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_mhoc401"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiMHOC401), @@ -53,15 +53,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp index 958ac59bde..d725978418 100644 --- a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp +++ b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_mhoc401 { static const char *const TAG = "xiaomi_mhoc401"; @@ -21,7 +19,7 @@ void XiaomiMHOC401::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiMHOC401::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiMHOC401::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -69,5 +67,3 @@ bool XiaomiMHOC401::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { void XiaomiMHOC401::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } } // namespace esphome::xiaomi_mhoc401 - -#endif diff --git a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h index 3570f70a16..3978e557f0 100644 --- a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h +++ b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h @@ -2,19 +2,17 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_mhoc401 { -class XiaomiMHOC401 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiMHOC401 final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } @@ -29,5 +27,3 @@ class XiaomiMHOC401 final : public Component, public esp32_ble_tracker::ESPBTDev }; } // namespace esphome::xiaomi_mhoc401 - -#endif diff --git a/tests/components/xiaomi_lywsdcgq/common-ln.yaml b/tests/components/xiaomi_lywsdcgq/common-ln.yaml new file mode 100644 index 0000000000..6a458a5b2a --- /dev/null +++ b/tests/components/xiaomi_lywsdcgq/common-ln.yaml @@ -0,0 +1,9 @@ +sensor: + - platform: xiaomi_lywsdcgq + mac_address: 7A:80:8E:19:36:BA + temperature: + name: Xiaomi LYWSDCGQ Temperature + humidity: + name: Xiaomi LYWSDCGQ Humidity + battery_level: + name: Xiaomi LYWSDCGQ Battery Level diff --git a/tests/components/xiaomi_lywsdcgq/common.yaml b/tests/components/xiaomi_lywsdcgq/common.yaml index d8422b4c0c..147b77c2d1 100644 --- a/tests/components/xiaomi_lywsdcgq/common.yaml +++ b/tests/components/xiaomi_lywsdcgq/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_lywsdcgq + ble_hub_id: ble_tracker_hub mac_address: 7A:80:8E:19:36:BA temperature: name: Xiaomi LYWSDCGQ Temperature diff --git a/tests/components/xiaomi_lywsdcgq/test.ln882x-ard.yaml b/tests/components/xiaomi_lywsdcgq/test.ln882x-ard.yaml new file mode 100644 index 0000000000..48aa38be38 --- /dev/null +++ b/tests/components/xiaomi_lywsdcgq/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_lywsdcgq: !include common-ln.yaml diff --git a/tests/components/xiaomi_lywsdcgq/validate.bk72xx-ard.yaml b/tests/components/xiaomi_lywsdcgq/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..030f74afa3 --- /dev/null +++ b/tests/components/xiaomi_lywsdcgq/validate.bk72xx-ard.yaml @@ -0,0 +1,22 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_lywsdcgq + ble_hub_id: ble_tracker_hub + mac_address: 7A:80:8E:19:36:BA + temperature: + name: Xiaomi LYWSDCGQ Temperature + humidity: + name: Xiaomi LYWSDCGQ Humidity + battery_level: + name: Xiaomi LYWSDCGQ Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_lywsdcgq + mac_address: 7A:80:8E:19:36:BB + temperature: + name: BK Xiaomi LYWSDCGQ Implicit Temperature diff --git a/tests/components/xiaomi_mhoc303/common-ln.yaml b/tests/components/xiaomi_mhoc303/common-ln.yaml new file mode 100644 index 0000000000..ca89047a68 --- /dev/null +++ b/tests/components/xiaomi_mhoc303/common-ln.yaml @@ -0,0 +1,9 @@ +sensor: + - platform: xiaomi_mhoc303 + mac_address: E7:50:59:32:A0:1C + temperature: + name: MHO-C303 Temperature + humidity: + name: MHO-C303 Humidity + battery_level: + name: MHO-C303 Battery Level diff --git a/tests/components/xiaomi_mhoc303/common.yaml b/tests/components/xiaomi_mhoc303/common.yaml index e4353d3c6a..74c96fc26d 100644 --- a/tests/components/xiaomi_mhoc303/common.yaml +++ b/tests/components/xiaomi_mhoc303/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_mhoc303 + ble_hub_id: ble_tracker_hub mac_address: E7:50:59:32:A0:1C temperature: name: MHO-C303 Temperature diff --git a/tests/components/xiaomi_mhoc303/test.ln882x-ard.yaml b/tests/components/xiaomi_mhoc303/test.ln882x-ard.yaml new file mode 100644 index 0000000000..6e927dafe8 --- /dev/null +++ b/tests/components/xiaomi_mhoc303/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_mhoc303: !include common-ln.yaml diff --git a/tests/components/xiaomi_mhoc303/validate.bk72xx-ard.yaml b/tests/components/xiaomi_mhoc303/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..5c7f29c98e --- /dev/null +++ b/tests/components/xiaomi_mhoc303/validate.bk72xx-ard.yaml @@ -0,0 +1,22 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_mhoc303 + ble_hub_id: ble_tracker_hub + mac_address: E7:50:59:32:A0:1C + temperature: + name: MHO-C303 Temperature + humidity: + name: MHO-C303 Humidity + battery_level: + name: MHO-C303 Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_mhoc303 + mac_address: E7:50:59:32:A0:1D + temperature: + name: BK MHO-C303 Implicit Temperature diff --git a/tests/components/xiaomi_mhoc401/common-ln.yaml b/tests/components/xiaomi_mhoc401/common-ln.yaml new file mode 100644 index 0000000000..43641f66d1 --- /dev/null +++ b/tests/components/xiaomi_mhoc401/common-ln.yaml @@ -0,0 +1,10 @@ +sensor: + - platform: xiaomi_mhoc401 + mac_address: E7:50:59:32:A0:1C + bindkey: eef418daf699a0c188f3bfd17e4565d9 + temperature: + name: MHO-C401 Temperature + humidity: + name: MHO-C401 Humidity + battery_level: + name: MHO-C401 Battery Level diff --git a/tests/components/xiaomi_mhoc401/common.yaml b/tests/components/xiaomi_mhoc401/common.yaml index ae378f5604..646961b3b6 100644 --- a/tests/components/xiaomi_mhoc401/common.yaml +++ b/tests/components/xiaomi_mhoc401/common.yaml @@ -1,12 +1,15 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_mhoc401 + ble_hub_id: ble_tracker_hub mac_address: E7:50:59:32:A0:1C bindkey: "eef418daf699a0c188f3bfd17e4565d9" temperature: - name: MHO-C303 Temperature + name: MHO-C401 Temperature humidity: - name: MHO-C303 Humidity + name: MHO-C401 Humidity battery_level: - name: MHO-C303 Battery Level + name: MHO-C401 Battery Level diff --git a/tests/components/xiaomi_mhoc401/test.ln882x-ard.yaml b/tests/components/xiaomi_mhoc401/test.ln882x-ard.yaml new file mode 100644 index 0000000000..a20f24671d --- /dev/null +++ b/tests/components/xiaomi_mhoc401/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_mhoc401: !include common-ln.yaml diff --git a/tests/components/xiaomi_mhoc401/validate.bk72xx-ard.yaml b/tests/components/xiaomi_mhoc401/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..f7a2bd3e4f --- /dev/null +++ b/tests/components/xiaomi_mhoc401/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_mhoc401 + ble_hub_id: ble_tracker_hub + mac_address: E7:50:59:32:A0:1C + bindkey: "eef418daf699a0c188f3bfd17e4565d9" + temperature: + name: MHO-C401 Temperature + humidity: + name: MHO-C401 Humidity + battery_level: + name: MHO-C401 Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_mhoc401 + mac_address: E7:50:59:32:A0:1D + bindkey: eef418daf699a0c188f3bfd17e4565d9 + temperature: + name: BK MHO-C401 Implicit Temperature From 252bb3333eecfd6fad95464437e86d625d91a6cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Sat, 8 Aug 2026 09:34:08 +0300 Subject: [PATCH 1328/1815] [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 12: xiaomi_miscale, xiaomi_mjyd02yla, xiaomi_mue4094rt) (#18180) --- esphome/components/xiaomi_miscale/sensor.py | 13 +++++----- .../xiaomi_miscale/xiaomi_miscale.cpp | 16 +++++-------- .../xiaomi_miscale/xiaomi_miscale.h | 12 ++++------ .../xiaomi_mjyd02yla/binary_sensor.py | 12 +++++----- .../xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp | 6 +---- .../xiaomi_mjyd02yla/xiaomi_mjyd02yla.h | 10 +++----- .../xiaomi_mue4094rt/binary_sensor.py | 12 +++++----- .../xiaomi_mue4094rt/xiaomi_mue4094rt.cpp | 6 +---- .../xiaomi_mue4094rt/xiaomi_mue4094rt.h | 10 +++----- .../components/xiaomi_miscale/common-ln.yaml | 7 ++++++ tests/components/xiaomi_miscale/common.yaml | 3 +++ .../xiaomi_miscale/test.ln882x-ard.yaml | 3 +++ .../xiaomi_miscale/validate.bk72xx-ard.yaml | 20 ++++++++++++++++ .../xiaomi_mjyd02yla/common-ln.yaml | 11 +++++++++ tests/components/xiaomi_mjyd02yla/common.yaml | 3 +++ .../xiaomi_mjyd02yla/test.ln882x-ard.yaml | 3 +++ .../xiaomi_mjyd02yla/validate.bk72xx-ard.yaml | 24 +++++++++++++++++++ .../xiaomi_mue4094rt/common-ln.yaml | 5 ++++ tests/components/xiaomi_mue4094rt/common.yaml | 3 +++ .../xiaomi_mue4094rt/test.ln882x-ard.yaml | 3 +++ .../xiaomi_mue4094rt/validate.bk72xx-ard.yaml | 18 ++++++++++++++ 21 files changed, 140 insertions(+), 60 deletions(-) create mode 100644 tests/components/xiaomi_miscale/common-ln.yaml create mode 100644 tests/components/xiaomi_miscale/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_miscale/validate.bk72xx-ard.yaml create mode 100644 tests/components/xiaomi_mjyd02yla/common-ln.yaml create mode 100644 tests/components/xiaomi_mjyd02yla/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_mjyd02yla/validate.bk72xx-ard.yaml create mode 100644 tests/components/xiaomi_mue4094rt/common-ln.yaml create mode 100644 tests/components/xiaomi_mue4094rt/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_mue4094rt/validate.bk72xx-ard.yaml diff --git a/esphome/components/xiaomi_miscale/sensor.py b/esphome/components/xiaomi_miscale/sensor.py index 14e5c1d376..8a2ac6bbb3 100644 --- a/esphome/components/xiaomi_miscale/sensor.py +++ b/esphome/components/xiaomi_miscale/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_CLEAR_IMPEDANCE, @@ -15,14 +15,15 @@ from esphome.const import ( UNIT_OHM, ) -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] xiaomi_miscale_ns = cg.esphome_ns.namespace("xiaomi_miscale") XiaomiMiscale = xiaomi_miscale_ns.class_( - "XiaomiMiscale", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiMiscale", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_miscale"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiMiscale), @@ -43,15 +44,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_clear_impedance(config[CONF_CLEAR_IMPEDANCE])) diff --git a/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp b/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp index 2b1492129c..482c0ed395 100644 --- a/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp +++ b/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp @@ -1,9 +1,7 @@ #include "xiaomi_miscale.h" -#include "esphome/components/esp32_ble/ble_uuid.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_miscale { static const char *const TAG = "xiaomi_miscale"; @@ -14,7 +12,7 @@ void XiaomiMiscale::dump_config() { LOG_SENSOR(" ", "Impedance", this->impedance_); } -bool XiaomiMiscale::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiMiscale::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -56,14 +54,14 @@ bool XiaomiMiscale::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { return success; } -optional XiaomiMiscale::parse_header_(const esp32_ble_tracker::ServiceData &service_data) { +optional XiaomiMiscale::parse_header_(const ble_device_base::ServiceData &service_data) { ParseResult result; - if (service_data.uuid == esp32_ble_tracker::ESPBTUUID::from_uint16(0x181D) && service_data.data.size() == 10) { + if (service_data.uuid == ble_device_base::ESPBTUUID::from_uint16(0x181D) && service_data.data.size() == 10) { result.version = 1; - } else if (service_data.uuid == esp32_ble_tracker::ESPBTUUID::from_uint16(0x181B) && service_data.data.size() == 13) { + } else if (service_data.uuid == ble_device_base::ESPBTUUID::from_uint16(0x181B) && service_data.data.size() == 13) { result.version = 2; } else { - char uuid_buf[esp32_ble::UUID_STR_LEN]; + char uuid_buf[ble_device_base::UUID_STR_LEN]; ESP_LOGVV(TAG, "parse_header(): Couldn't identify scale version or data size was not correct. UUID: %s, data_size: %d", service_data.uuid.to_str(uuid_buf), service_data.data.size()); @@ -167,5 +165,3 @@ bool XiaomiMiscale::report_results_(const optional &result, const c } } // namespace esphome::xiaomi_miscale - -#endif diff --git a/esphome/components/xiaomi_miscale/xiaomi_miscale.h b/esphome/components/xiaomi_miscale/xiaomi_miscale.h index 3213f5d6de..64cc2ff567 100644 --- a/esphome/components/xiaomi_miscale/xiaomi_miscale.h +++ b/esphome/components/xiaomi_miscale/xiaomi_miscale.h @@ -2,12 +2,10 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include -#ifdef USE_ESP32 - namespace esphome::xiaomi_miscale { struct ParseResult { @@ -16,11 +14,11 @@ struct ParseResult { optional impedance; }; -class XiaomiMiscale final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiMiscale final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_weight(sensor::Sensor *weight) { weight_ = weight; } void set_impedance(sensor::Sensor *impedance) { impedance_ = impedance; } @@ -32,7 +30,7 @@ class XiaomiMiscale final : public Component, public esp32_ble_tracker::ESPBTDev sensor::Sensor *impedance_{nullptr}; bool clear_impedance_{false}; - optional parse_header_(const esp32_ble_tracker::ServiceData &service_data); + optional parse_header_(const ble_device_base::ServiceData &service_data); bool parse_message_(const std::vector &message, ParseResult &result); bool parse_message_v1_(const std::vector &message, ParseResult &result); bool parse_message_v2_(const std::vector &message, ParseResult &result); @@ -40,5 +38,3 @@ class XiaomiMiscale final : public Component, public esp32_ble_tracker::ESPBTDev }; } // namespace esphome::xiaomi_miscale - -#endif diff --git a/esphome/components/xiaomi_mjyd02yla/binary_sensor.py b/esphome/components/xiaomi_mjyd02yla/binary_sensor.py index 312abc82cb..4cfc82d2c6 100644 --- a/esphome/components/xiaomi_mjyd02yla/binary_sensor.py +++ b/esphome/components/xiaomi_mjyd02yla/binary_sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import binary_sensor, esp32_ble_tracker, sensor +from esphome.components import binary_sensor, ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -20,18 +20,18 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble", "sensor"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble", "sensor"] xiaomi_mjyd02yla_ns = cg.esphome_ns.namespace("xiaomi_mjyd02yla") XiaomiMJYD02YLA = xiaomi_mjyd02yla_ns.class_( "XiaomiMJYD02YLA", binary_sensor.BinarySensor, cg.Component, - esp32_ble_tracker.ESPBTDeviceListener, + ble_device_base.ESPBTDeviceListener, ) CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_mjyd02yla"), binary_sensor.binary_sensor_schema( XiaomiMJYD02YLA, device_class=DEVICE_CLASS_MOTION ) @@ -63,15 +63,15 @@ CONFIG_SCHEMA = cv.All( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp b/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp index a7b2554aad..233f5f5783 100644 --- a/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp +++ b/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_mjyd02yla { static const char *const TAG = "xiaomi_mjyd02yla"; @@ -17,7 +15,7 @@ void XiaomiMJYD02YLA::dump_config() { LOG_SENSOR(" ", "Illuminance", this->illuminance_); } -bool XiaomiMJYD02YLA::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiMJYD02YLA::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -65,5 +63,3 @@ bool XiaomiMJYD02YLA::parse_device(const esp32_ble_tracker::ESPBTDevice &device) void XiaomiMJYD02YLA::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } } // namespace esphome::xiaomi_mjyd02yla - -#endif diff --git a/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h b/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h index da02dee003..ba2fe1b62c 100644 --- a/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h +++ b/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h @@ -3,21 +3,19 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/binary_sensor/binary_sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_mjyd02yla { class XiaomiMJYD02YLA final : public Component, public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener { + public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } void set_bindkey(const char *bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_idle_time(sensor::Sensor *idle_time) { idle_time_ = idle_time; } @@ -35,5 +33,3 @@ class XiaomiMJYD02YLA final : public Component, }; } // namespace esphome::xiaomi_mjyd02yla - -#endif diff --git a/esphome/components/xiaomi_mue4094rt/binary_sensor.py b/esphome/components/xiaomi_mue4094rt/binary_sensor.py index c5d93384c9..6df8dcb8ea 100644 --- a/esphome/components/xiaomi_mue4094rt/binary_sensor.py +++ b/esphome/components/xiaomi_mue4094rt/binary_sensor.py @@ -1,21 +1,21 @@ from esphome import core import esphome.codegen as cg -from esphome.components import binary_sensor, esp32_ble_tracker +from esphome.components import binary_sensor, ble_device_base import esphome.config_validation as cv from esphome.const import CONF_MAC_ADDRESS, CONF_TIMEOUT, DEVICE_CLASS_MOTION -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_mue4094rt_ns = cg.esphome_ns.namespace("xiaomi_mue4094rt") XiaomiMUE4094RT = xiaomi_mue4094rt_ns.class_( "XiaomiMUE4094RT", binary_sensor.BinarySensor, cg.Component, - esp32_ble_tracker.ESPBTDeviceListener, + ble_device_base.ESPBTDeviceListener, ) CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_mue4094rt"), binary_sensor.binary_sensor_schema( XiaomiMUE4094RT, device_class=DEVICE_CLASS_MOTION ) @@ -28,15 +28,15 @@ CONFIG_SCHEMA = cv.All( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_time(config[CONF_TIMEOUT])) diff --git a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp index 259e0159c5..eca83c0912 100644 --- a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp +++ b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp @@ -1,8 +1,6 @@ #include "xiaomi_mue4094rt.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_mue4094rt { static const char *const TAG = "xiaomi_mue4094rt"; @@ -12,7 +10,7 @@ void XiaomiMUE4094RT::dump_config() { LOG_BINARY_SENSOR(" ", "Motion", this); } -bool XiaomiMUE4094RT::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiMUE4094RT::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -51,5 +49,3 @@ bool XiaomiMUE4094RT::parse_device(const esp32_ble_tracker::ESPBTDevice &device) } } // namespace esphome::xiaomi_mue4094rt - -#endif diff --git a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h index 4751e35e65..1ca40bf8ca 100644 --- a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h +++ b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h @@ -2,20 +2,18 @@ #include "esphome/core/component.h" #include "esphome/components/binary_sensor/binary_sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_mue4094rt { class XiaomiMUE4094RT final : public Component, public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener { + public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_time(uint16_t timeout) { timeout_ = timeout; } @@ -26,5 +24,3 @@ class XiaomiMUE4094RT final : public Component, }; } // namespace esphome::xiaomi_mue4094rt - -#endif diff --git a/tests/components/xiaomi_miscale/common-ln.yaml b/tests/components/xiaomi_miscale/common-ln.yaml new file mode 100644 index 0000000000..38c3287402 --- /dev/null +++ b/tests/components/xiaomi_miscale/common-ln.yaml @@ -0,0 +1,7 @@ +sensor: + - platform: xiaomi_miscale + mac_address: '5C:CA:D3:70:D4:A2' + weight: + name: "Xiaomi Mi Scale Weight" + impedance: + name: "Xiaomi Mi Scale Impedance" diff --git a/tests/components/xiaomi_miscale/common.yaml b/tests/components/xiaomi_miscale/common.yaml index 89f32ad199..673db86311 100644 --- a/tests/components/xiaomi_miscale/common.yaml +++ b/tests/components/xiaomi_miscale/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_miscale + ble_hub_id: ble_tracker_hub mac_address: '5C:CA:D3:70:D4:A2' weight: name: "Xiaomi Mi Scale Weight" diff --git a/tests/components/xiaomi_miscale/test.ln882x-ard.yaml b/tests/components/xiaomi_miscale/test.ln882x-ard.yaml new file mode 100644 index 0000000000..88c5054ae3 --- /dev/null +++ b/tests/components/xiaomi_miscale/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_miscale: !include common-ln.yaml diff --git a/tests/components/xiaomi_miscale/validate.bk72xx-ard.yaml b/tests/components/xiaomi_miscale/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..46fc7d0a2a --- /dev/null +++ b/tests/components/xiaomi_miscale/validate.bk72xx-ard.yaml @@ -0,0 +1,20 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_miscale + ble_hub_id: ble_tracker_hub + mac_address: '5C:CA:D3:70:D4:A2' + weight: + name: "Xiaomi Mi Scale Weight" + impedance: + name: "Xiaomi Mi Scale Impedance" + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_miscale + mac_address: '5C:CA:D3:70:D4:A3' + weight: + name: "BK Xiaomi Mi Scale Implicit Weight" diff --git a/tests/components/xiaomi_mjyd02yla/common-ln.yaml b/tests/components/xiaomi_mjyd02yla/common-ln.yaml new file mode 100644 index 0000000000..04117e1565 --- /dev/null +++ b/tests/components/xiaomi_mjyd02yla/common-ln.yaml @@ -0,0 +1,11 @@ +binary_sensor: + - platform: xiaomi_mjyd02yla + name: MJYD02YL-A Motion + mac_address: 50:EC:50:CD:32:02 + bindkey: 48403ebe2d385db8d0c187f81e62cb64 + idle_time: + name: MJYD02YL-A Idle Time + light: + name: MJYD02YL-A Light Status + battery_level: + name: MJYD02YL-A Battery Level diff --git a/tests/components/xiaomi_mjyd02yla/common.yaml b/tests/components/xiaomi_mjyd02yla/common.yaml index dffcef84c4..1a2c67c971 100644 --- a/tests/components/xiaomi_mjyd02yla/common.yaml +++ b/tests/components/xiaomi_mjyd02yla/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub binary_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_mjyd02yla + ble_hub_id: ble_tracker_hub name: MJYD02YL-A Motion mac_address: 50:EC:50:CD:32:02 bindkey: 48403ebe2d385db8d0c187f81e62cb64 diff --git a/tests/components/xiaomi_mjyd02yla/test.ln882x-ard.yaml b/tests/components/xiaomi_mjyd02yla/test.ln882x-ard.yaml new file mode 100644 index 0000000000..3e7ec5e9ba --- /dev/null +++ b/tests/components/xiaomi_mjyd02yla/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_mjyd02yla: !include common-ln.yaml diff --git a/tests/components/xiaomi_mjyd02yla/validate.bk72xx-ard.yaml b/tests/components/xiaomi_mjyd02yla/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..b47069a939 --- /dev/null +++ b/tests/components/xiaomi_mjyd02yla/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +binary_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_mjyd02yla + ble_hub_id: ble_tracker_hub + name: MJYD02YL-A Motion + mac_address: 50:EC:50:CD:32:02 + bindkey: 48403ebe2d385db8d0c187f81e62cb64 + idle_time: + name: MJYD02YL-A Idle Time + light: + name: MJYD02YL-A Light Status + battery_level: + name: MJYD02YL-A Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_mjyd02yla + name: BK MJYD02YL-A Implicit Motion + mac_address: 50:EC:50:CD:32:03 + bindkey: 48403ebe2d385db8d0c187f81e62cb64 diff --git a/tests/components/xiaomi_mue4094rt/common-ln.yaml b/tests/components/xiaomi_mue4094rt/common-ln.yaml new file mode 100644 index 0000000000..9d28a7e7f8 --- /dev/null +++ b/tests/components/xiaomi_mue4094rt/common-ln.yaml @@ -0,0 +1,5 @@ +binary_sensor: + - platform: xiaomi_mue4094rt + name: MUE4094RT Motion + mac_address: 7A:80:8E:19:36:BA + timeout: 5s diff --git a/tests/components/xiaomi_mue4094rt/common.yaml b/tests/components/xiaomi_mue4094rt/common.yaml index 4f0e5ccbae..bd5d9348ea 100644 --- a/tests/components/xiaomi_mue4094rt/common.yaml +++ b/tests/components/xiaomi_mue4094rt/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub binary_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_mue4094rt + ble_hub_id: ble_tracker_hub name: MUE4094RT Motion mac_address: 7A:80:8E:19:36:BA timeout: 5s diff --git a/tests/components/xiaomi_mue4094rt/test.ln882x-ard.yaml b/tests/components/xiaomi_mue4094rt/test.ln882x-ard.yaml new file mode 100644 index 0000000000..bd2ccc4e59 --- /dev/null +++ b/tests/components/xiaomi_mue4094rt/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_mue4094rt: !include common-ln.yaml diff --git a/tests/components/xiaomi_mue4094rt/validate.bk72xx-ard.yaml b/tests/components/xiaomi_mue4094rt/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..10a537089b --- /dev/null +++ b/tests/components/xiaomi_mue4094rt/validate.bk72xx-ard.yaml @@ -0,0 +1,18 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +binary_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_mue4094rt + ble_hub_id: ble_tracker_hub + name: MUE4094RT Motion + mac_address: 7A:80:8E:19:36:BA + timeout: 5s + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_mue4094rt + name: BK MUE4094RT Implicit Motion + mac_address: 7A:80:8E:19:36:BB + timeout: 5s From 2730c10c2c365057fa0136df3f9e1ca1947d6a25 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sat, 8 Aug 2026 08:35:49 +0200 Subject: [PATCH 1329/1815] [modbus] Route broadcast writes (address 0) to all server devices (#17387) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/modbus/__init__.py | 19 +- esphome/components/modbus/modbus.cpp | 167 +++++++---- esphome/components/modbus/modbus.h | 29 +- .../components/modbus/modbus_definitions.h | 4 + .../modbus_server/modbus_server.cpp | 7 +- tests/component_tests/modbus/test_modbus.py | 39 +++ .../modbus/modbus_broadcast_test.cpp | 276 ++++++++++++++++++ 7 files changed, 483 insertions(+), 58 deletions(-) create mode 100644 tests/component_tests/modbus/test_modbus.py create mode 100644 tests/components/modbus/modbus_broadcast_test.cpp diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index c91032801b..377dadad76 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging -from typing import Literal +from typing import Any, Literal from esphome import pins import esphome.codegen as cg @@ -99,15 +99,28 @@ async def to_code(config): cg.add(var.set_turnaround_time(config[CONF_TURNAROUND_TIME])) +def _validate_server_address(value: Any) -> int: + address = cv.hex_uint8_t(value) + # The broadcast address (0) is delivered to every device and is never answered (Modbus 4.1), + # so it cannot identify an individual server device. + if address == 0: + raise cv.Invalid( + "Address 0 is the Modbus broadcast address and cannot be used as a " + "server device address. Assign a unique unit address instead." + ) + return address + + def modbus_device_schema(default_address, role: Literal["client", "server"] = "client"): hub_type = ModbusClient if role == "client" else ModbusServer + address_validator = _validate_server_address if role == "server" else cv.hex_uint8_t schema = { cv.GenerateID(CONF_MODBUS_ID): cv.use_id(hub_type), } if default_address is None: - schema[cv.Required(CONF_ADDRESS)] = cv.hex_uint8_t + schema[cv.Required(CONF_ADDRESS)] = address_validator else: - schema[cv.Optional(CONF_ADDRESS, default=default_address)] = cv.hex_uint8_t + schema[cv.Optional(CONF_ADDRESS, default=default_address)] = address_validator return cv.Schema(schema) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index db97d56cc6..87aace02d0 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -183,6 +183,10 @@ void ModbusServerHub::parse_modbus_frames() { size_t size = this->rx_buffer_.size(); ESP_LOGVV(TAG, "Parsing frames buffer size = %" PRIu32, size); bool retry_as_client = false; + // A broadcast is a client request, never a peer response; clear any stale expectation (RTU is half-duplex). + const bool is_broadcast = this->rx_buffer_[0] == BROADCAST_ADDRESS; + if (is_broadcast) + this->expecting_peer_response_ = 0; if (this->expecting_peer_response_ != 0) { if (!this->parse_modbus_server_frame_()) { ESP_LOGV(TAG, "Stop expecting peer response from %" PRIu8 " due to parse failure, and retry parse", @@ -277,11 +281,17 @@ bool ModbusServerHub::parse_modbus_client_frame_() { // This requires copying the frame data to a local buffer beforehand. uint8_t data_offset = helpers::client_frame_data_offset(this->rx_buffer_.data(), this->rx_buffer_.size()); uint16_t data_len = frame_length - 2 - data_offset; - uint8_t data[MAX_FRAME_SIZE] = {}; - std::memcpy(data, this->rx_buffer_.data() + data_offset, data_len); + uint8_t data_buffer[MAX_FRAME_SIZE] = {}; + std::memcpy(data_buffer, this->rx_buffer_.data() + data_offset, data_len); + std::span data(data_buffer, data_len); this->clear_rx_buffer_(LOG_STR("parse succeeded"), false, frame_length); - this->process_modbus_client_frame_(address, function_code, data); + if (address == BROADCAST_ADDRESS) { + // Keep the unicast response buffers out of the broadcast call chain. + this->process_broadcast_frame_(function_code, data); + } else { + this->process_modbus_client_frame_(address, function_code, data); + } return true; } @@ -365,15 +375,85 @@ ModbusServerDevice *ModbusServerHub::find_device_(uint8_t address) { return nullptr; } -bool ModbusServerHub::check_register_range_(uint8_t address, uint8_t function_code, uint16_t start_address, - uint16_t number_of_registers) { +ResponseStatus ModbusServerHub::check_register_range_(uint16_t start_address, uint16_t number_of_registers) { if ((uint32_t) start_address + number_of_registers > 0x10000u) { ESP_LOGW(TAG, "Register address out of range - start: %" PRIu16 " num: %" PRIu16, start_address, number_of_registers); - this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_ADDRESS); - return false; + return ExceptionCode::ILLEGAL_DATA_ADDRESS; + } + return std::nullopt; +} + +// Write PDU layout after the function code: start address(2) [+ quantity(2) + byte count(1)] + register values. +// The value subspans taken at these offsets stay in range because client_pdu_length() clamps the byte count to the +// same maximum the callers' number_of_registers * 2 == number_of_bytes guard enforces. +static constexpr size_t WRITE_SINGLE_VALUES_OFFSET = 2; +static constexpr size_t WRITE_MULTIPLE_VALUES_OFFSET = 5; +// FC 0x17 writes follow read start(2) + read quantity(2) + write start(2) + write quantity(2) + byte count(1). +static constexpr size_t READ_WRITE_VALUES_OFFSET = 9; + +ResponseStatus ModbusServerHub::parse_write_single_(std::span data, uint16_t &start_address, + RegisterValues ®isters) { + start_address = helpers::get_data(data.data(), 0); + // No range check needed: one register can never push start_address + 1 past the address space. + this->assemble_registers_(data.subspan(WRITE_SINGLE_VALUES_OFFSET, sizeof(uint16_t)), registers); + return std::nullopt; +} + +ResponseStatus ModbusServerHub::parse_write_multiple_(std::span data, uint16_t &start_address, + RegisterValues ®isters) { + start_address = helpers::get_data(data.data(), 0); + uint16_t number_of_registers = helpers::get_data(data.data(), 2); + uint8_t number_of_bytes = helpers::get_data(data.data(), 4); + if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_WRITE || + number_of_registers * 2 != number_of_bytes) { + ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 " or bytes %" PRIu8, number_of_registers, number_of_bytes); + return ExceptionCode::ILLEGAL_DATA_VALUE; + } + if (ResponseStatus status = this->check_register_range_(start_address, number_of_registers); status.has_value()) { + return status; + } + this->assemble_registers_(data.subspan(WRITE_MULTIPLE_VALUES_OFFSET, number_of_bytes), registers); + return std::nullopt; +} + +void ModbusServerHub::assemble_registers_(std::span values, RegisterValues ®isters) { + for (size_t offset = 0; offset + 1 < values.size(); offset += 2) { + registers.push_back(helpers::get_data(values.data(), offset)); + } +} + +void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span data) { + // Broadcasts are only meaningful for register writes and are never answered (Modbus 4.1 / 6.12), so an + // unsupported function code or a validation failure is silently dropped instead of replying with an exception. + // Coil writes (FC 0x05/0x0F) are also broadcastable by spec, but server coil handlers are not implemented yet. + uint16_t start_address; + RegisterValues registers; + ResponseStatus status; + switch (static_cast(function_code)) { + case FunctionCode::WRITE_SINGLE_REGISTER: + status = this->parse_write_single_(data, start_address, registers); + break; + case FunctionCode::WRITE_MULTIPLE_REGISTERS: + status = this->parse_write_multiple_(data, start_address, registers); + break; + default: + // Reads and read/write require a reply, so they are not valid as broadcasts. + ESP_LOGV(TAG, "Ignoring broadcast with unsupported function code %" PRIu8, function_code); + return; + } + if (status.has_value()) { + return; + } + for (auto *device : this->devices_) { + // A broadcast is never answered, so a rejecting device has no other feedback channel; log it so a + // misconfigured register map is diagnosable instead of looking identical to a successful write. + if (ResponseStatus device_status = device->on_broadcast_write_registers(start_address, registers); + device_status.has_value()) { + ESP_LOGV(TAG, "Device %" PRIu8 " rejected broadcast write with exception %" PRIu8, device->get_address(), + static_cast(device_status.value())); + } } - return true; } bool ModbusServerHub::build_or_reject_read_response_(uint8_t address, uint8_t function_code, ResponseStatus status, @@ -420,7 +500,8 @@ bool ModbusServerHub::build_or_reject_read_response_(uint8_t address, uint8_t fu return true; } -void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data) { +void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t function_code, + std::span data) { ModbusServerDevice *device = this->find_device_(address); if (device == nullptr) { this->expecting_peer_response_ = address; @@ -437,14 +518,16 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func case FunctionCode::READ_HOLDING_REGISTERS: case FunctionCode::READ_INPUT_REGISTERS: { // PDU data: start address(2) + quantity(2). - uint16_t start_address = helpers::get_data(data, 0); - uint16_t number_of_registers = helpers::get_data(data, 2); + uint16_t start_address = helpers::get_data(data.data(), 0); + uint16_t number_of_registers = helpers::get_data(data.data(), 2); if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_READ) { ESP_LOGW(TAG, "Invalid number of registers %" PRIu16, number_of_registers); this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_VALUE); return; } - if (!this->check_register_range_(address, function_code, start_address, number_of_registers)) { + status = this->check_register_range_(start_address, number_of_registers); + if (status.has_value()) { + this->send_exception_(address, function_code, status.value()); return; } RegisterValues registers; @@ -462,46 +545,31 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func } case FunctionCode::WRITE_SINGLE_REGISTER: case FunctionCode::WRITE_MULTIPLE_REGISTERS: { - // PDU data: start address(2) [+ quantity(2) + byte count(1)] + register values. - // A single-register write always targets one register; for a multiple-register write the - // quantity is in the frame and its byte count must equal quantity * 2. The register values are - // assembled into registers below so the handler doesn't have to know the request framing. - uint16_t start_address = helpers::get_data(data, 0); - uint16_t number_of_registers = 1; - uint16_t values_offset = 2; // single write: values follow the 2-byte start address - if (static_cast(function_code) == FunctionCode::WRITE_MULTIPLE_REGISTERS) { - number_of_registers = helpers::get_data(data, 2); - uint8_t number_of_bytes = helpers::get_data(data, 4); - values_offset = 5; // multiple write: values follow start address(2) + quantity(2) + byte count(1) - if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_WRITE || - number_of_registers * 2 != number_of_bytes) { - ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 " or bytes %" PRIu8, number_of_registers, - number_of_bytes); - this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - if (!this->check_register_range_(address, function_code, start_address, number_of_registers)) { - return; - } - } - // Assemble the register values (host byte order) so the handler never sees wire framing. + // Parse and validate the write PDU into host-order register values; reply with an exception on failure. + uint16_t start_address; RegisterValues registers; - for (uint16_t i = 0; i < number_of_registers; i++) { - registers.push_back(helpers::get_data(data, values_offset + i * 2)); + if (static_cast(function_code) == FunctionCode::WRITE_SINGLE_REGISTER) { + status = this->parse_write_single_(data, start_address, registers); + } else { + status = this->parse_write_multiple_(data, start_address, registers); + } + if (status.has_value()) { + this->send_exception_(address, function_code, status.value()); + return; } status = device->on_write_registers(start_address, registers); - response_data = data; // echo the request header per Modbus 6.6, 6.12 + response_data = data.data(); // echo the request header per Modbus 6.6, 6.12 response_len = 4; break; } case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: { // PDU data: read start address(2) + read quantity(2) + write start address(2) + write quantity(2) + // write byte count(1) + write register values. Per Modbus 6.17 the write is performed before the read. - uint16_t read_start_address = helpers::get_data(data, 0); - uint16_t number_of_registers = helpers::get_data(data, 2); - uint16_t write_start_address = helpers::get_data(data, 4); - uint16_t number_of_write_registers = helpers::get_data(data, 6); - uint8_t number_of_bytes = helpers::get_data(data, 8); + uint16_t read_start_address = helpers::get_data(data.data(), 0); + uint16_t number_of_registers = helpers::get_data(data.data(), 2); + uint16_t write_start_address = helpers::get_data(data.data(), 4); + uint16_t number_of_write_registers = helpers::get_data(data.data(), 6); + uint8_t number_of_bytes = helpers::get_data(data.data(), 8); if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_READ || number_of_write_registers == 0 || number_of_write_registers > MAX_NUM_OF_REGISTERS_TO_WRITE_RW || number_of_write_registers * 2 != number_of_bytes) { @@ -510,18 +578,19 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_VALUE); return; } - if (!this->check_register_range_(address, function_code, read_start_address, number_of_registers) || - !this->check_register_range_(address, function_code, write_start_address, number_of_write_registers)) { + status = this->check_register_range_(read_start_address, number_of_registers); + if (!status.has_value()) { + status = this->check_register_range_(write_start_address, number_of_write_registers); + } + if (status.has_value()) { + this->send_exception_(address, function_code, status.value()); return; } // Perform the write first (Modbus 6.17). Scoped so the write values are off the stack before the read // values are allocated, keeping only one RegisterValues buffer live at a time. { - // Assemble the written register values (host byte order); they follow the 9-byte request header. RegisterValues write_registers; - for (uint16_t i = 0; i < number_of_write_registers; i++) { - write_registers.push_back(helpers::get_data(data, 9 + i * 2)); - } + this->assemble_registers_(data.subspan(READ_WRITE_VALUES_OFFSET, number_of_bytes), write_registers); // Dispatch to the standalone write and read handlers so any device implementing those supports 0x17 // without a dedicated handler; a device that maps registers by address reconstructs the read response // from the values it just stored. diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 9f88213985..5a700912de 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -330,12 +330,22 @@ class ModbusServerHub : public Modbus { void parse_modbus_frames() override; bool parse_modbus_client_frame_(); void process_modbus_server_frame(uint8_t address, std::span pdu) override; - void process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data); + void process_modbus_client_frame_(uint8_t address, uint8_t function_code, std::span data); + // Dispatches a broadcast (address 0) write to every registered device; broadcasts are never answered. + void process_broadcast_frame_(uint8_t function_code, std::span data); + // Parses a WRITE_SINGLE_REGISTER / WRITE_MULTIPLE_REGISTERS PDU into start_address and the host-order register + // values, validating the register count and address range. Returns std::nullopt on success, otherwise the Modbus + // exception code describing the failure. Shared by unicast writes (which reply with the exception) and broadcast + // writes (which silently drop invalid frames). + ResponseStatus parse_write_single_(std::span data, uint16_t &start_address, RegisterValues ®isters); + ResponseStatus parse_write_multiple_(std::span data, uint16_t &start_address, + RegisterValues ®isters); + // Appends the big-endian register values in values to registers, in host byte order. + void assemble_registers_(std::span values, RegisterValues ®isters); ModbusServerDevice *find_device_(uint8_t address); - // Returns true if [start_address, start_address + number_of_registers) fits in the 16-bit address space. - // On failure, logs and sends an ILLEGAL_DATA_ADDRESS exception to the client. - bool check_register_range_(uint8_t address, uint8_t function_code, uint16_t start_address, - uint16_t number_of_registers); + // Returns std::nullopt if [start_address, start_address + number_of_registers) fits in the 16-bit address space, + // otherwise ILLEGAL_DATA_ADDRESS. The caller sends the exception reply if one is required. + ResponseStatus check_register_range_(uint16_t start_address, uint16_t number_of_registers); // Builds the body of a register read response (byte count followed by the big-endian register values) into // response_buffer. Shared by every function code that answers with register values, so the read reply stays @@ -603,9 +613,18 @@ class ModbusServerDevice { virtual ResponseStatus on_write_registers(uint16_t start_address, const RegisterValues ®isters) { return ExceptionCode::ILLEGAL_FUNCTION; }; + // Hub entry point for broadcast (address 0) writes, which are never answered. + ResponseStatus on_broadcast_write_registers(uint16_t start_address, const RegisterValues ®isters) { + this->broadcast_write_ = true; + ResponseStatus status = this->on_write_registers(start_address, registers); + this->broadcast_write_ = false; + return status; + } protected: uint8_t address_{0}; + // Set while handling a broadcast write: the caller sends no reply, so a rejection has no wire consequence. + bool broadcast_write_{false}; }; } // namespace esphome::modbus diff --git a/esphome/components/modbus/modbus_definitions.h b/esphome/components/modbus/modbus_definitions.h index b55b3ebe01..9ec776b67a 100644 --- a/esphome/components/modbus/modbus_definitions.h +++ b/esphome/components/modbus/modbus_definitions.h @@ -116,6 +116,10 @@ static constexpr uint16_t READ_PDU_SIZE = 5; // A single-write PDU is always function code(1) + address(2) + value(2) static constexpr uint16_t WRITE_SINGLE_PDU_SIZE = 5; static constexpr uint16_t MAX_FRAME_SIZE = 256; + +// 4.1 Address 0 is the broadcast address: the request is processed by every device and never answered. +static constexpr uint8_t BROADCAST_ADDRESS = 0; + // Both send paths bound their payload so the framed result lands exactly on the RTU limit: a client // PDU gains an address byte and a CRC, a raw server frame gains a CRC. send_frame_() therefore never // has to check the framed size - it cannot be exceeded. diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp index e649635848..bf39efbd54 100644 --- a/esphome/components/modbus_server/modbus_server.cpp +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -145,7 +145,12 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address, } return true; })) { - ESP_LOGW(TAG, "Write request rejected before applying any register. Sending exception response."); + // On a broadcast every device that does not map these registers rejects them, which is the normal case. + if (this->broadcast_write_) { + ESP_LOGV(TAG, "Write request rejected before applying any register."); + } else { + ESP_LOGW(TAG, "Write request rejected before applying any register."); + } return precheck; } diff --git a/tests/component_tests/modbus/test_modbus.py b/tests/component_tests/modbus/test_modbus.py new file mode 100644 index 0000000000..0e53c55b50 --- /dev/null +++ b/tests/component_tests/modbus/test_modbus.py @@ -0,0 +1,39 @@ +"""Tests for modbus configuration validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components import modbus +from esphome.components.modbus import CONF_MODBUS_ID, _validate_server_address +from esphome.const import CONF_ADDRESS + + +def test_server_address_accepts_valid_unit_address() -> None: + # A normal unit address (1-247) is accepted and returned as an int. + assert _validate_server_address(1) == 1 + assert _validate_server_address(247) == 247 + + +def test_server_address_accepts_hex_string() -> None: + # hex_uint8_t parses hex strings, and the validator returns the parsed int. + assert _validate_server_address("0x10") == 0x10 + + +def test_server_address_zero_rejected() -> None: + # Address 0 is the Modbus broadcast address and cannot identify a server device. + with pytest.raises(cv.Invalid, match="broadcast address"): + _validate_server_address(0) + + +def test_server_schema_rejects_address_zero() -> None: + # The server-role schema wires in _validate_server_address, so address 0 is rejected there too. + schema = modbus.modbus_device_schema(0x01, role="server") + with pytest.raises(cv.Invalid, match="broadcast address"): + schema({CONF_MODBUS_ID: "hub", CONF_ADDRESS: 0}) + + +def test_client_schema_still_accepts_address_zero() -> None: + # Not rejected for clients today, but not supported either: a client broadcast gets no reply and + # stalls the hub for the full send-wait. + schema = modbus.modbus_device_schema(0x01) + assert schema({CONF_MODBUS_ID: "hub", CONF_ADDRESS: 0})[CONF_ADDRESS] == 0 diff --git a/tests/components/modbus/modbus_broadcast_test.cpp b/tests/components/modbus/modbus_broadcast_test.cpp new file mode 100644 index 0000000000..5840259021 --- /dev/null +++ b/tests/components/modbus/modbus_broadcast_test.cpp @@ -0,0 +1,276 @@ +#include + +#include +#include +#include + +#include "common.h" +#include "esphome/components/modbus/modbus.h" + +namespace esphome::modbus { + +namespace { + +// A server device that records the writes the hub routes to it. +class RecordingDevice : public ModbusServerDevice { + public: + explicit RecordingDevice(uint8_t address) { this->set_address(address); } + + ResponseStatus on_write_registers(uint16_t start_address, const RegisterValues ®isters) override { + this->write_count++; + this->last_start_address = start_address; + this->last_values.assign(registers.begin(), registers.end()); + return std::nullopt; // return value is ignored for broadcasts, which are never answered + } + + int write_count{0}; + uint16_t last_start_address{0}; + std::vector last_values; +}; + +// A server device that rejects every write, to exercise the broadcast dispatch loop's rejection branch. +class RejectingDevice : public ModbusServerDevice { + public: + explicit RejectingDevice(uint8_t address) { this->set_address(address); } + + ResponseStatus on_write_registers(uint16_t start_address, const RegisterValues ®isters) override { + this->write_count++; + return ExceptionCode::ILLEGAL_DATA_ADDRESS; + } + + int write_count{0}; +}; + +// A UART that records every byte written so the test can assert the hub sends no reply. +class RecordingUART : public testing::NullUART { + public: + void write_array(const uint8_t *data, size_t len) override { + this->written.insert(this->written.end(), data, data + len); + } + std::vector written; +}; + +// Drives full frames through the server hub's receive path in tests. +class TestServerHub : public ModbusServerHub { + public: + bool tx_blocked() override { return false; } + + // Builds a complete client frame (address + FC + pdu + CRC) and runs the full receive-side parser + // (parse_modbus_frames), so the expecting-peer-response routing is exercised, not just the frame parser + // below it. Returns true once the buffer has fully drained. + bool run_receive_parser_for_test(uint8_t address, uint8_t function_code, const uint8_t *pdu_data, + size_t pdu_data_len) { + this->rx_buffer_.clear(); + this->rx_buffer_.reserve(pdu_data_len + 4); + this->rx_buffer_.push_back(address); + this->rx_buffer_.push_back(function_code); + this->rx_buffer_.insert(this->rx_buffer_.end(), pdu_data, pdu_data + pdu_data_len); + uint16_t crc = crc16(this->rx_buffer_.data(), this->rx_buffer_.size()); + this->rx_buffer_.push_back(crc & 0xFF); + this->rx_buffer_.push_back(crc >> 8); + this->parse_modbus_frames(); + return this->rx_buffer_.empty(); + } +}; + +} // namespace + +// A broadcast (address 0) single-register write reaches every registered device and is not answered. +// Driven through the full receive parser (parse_modbus_frames) so the address-0 routing -- frame length, +// CRC, and client-vs-broadcast dispatch -- is exercised, not just the handler below it. +TEST(ModbusBroadcast, SingleRegisterWriteReachesAllDevicesWithoutReply) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RecordingDevice device_a(0x02); + RecordingDevice device_b(0x03); + hub.register_device(&device_a); + hub.register_device(&device_b); + + // FC 0x06 payload: start address 0x9D31, value 0x00A5 (big-endian, no address/CRC). + const uint8_t pdu_data[] = {0x9D, 0x31, 0x00, 0xA5}; + ASSERT_TRUE(hub.run_receive_parser_for_test( + BROADCAST_ADDRESS, static_cast(FunctionCode::WRITE_SINGLE_REGISTER), pdu_data, sizeof(pdu_data))); + + for (RecordingDevice *device : {&device_a, &device_b}) { + EXPECT_EQ(device->write_count, 1); + EXPECT_EQ(device->last_start_address, 0x9D31); + ASSERT_EQ(device->last_values.size(), 1u); + EXPECT_EQ(device->last_values[0], 0x00A5); + } + EXPECT_TRUE(uart.written.empty()); // broadcasts are never answered +} + +// A single-register broadcast (FC 0x06) must still reach every device when the hub is mid-way through +// waiting for a peer's response. Its frame length matches a response frame, so without the address-0 guard +// in parse_modbus_frames() it would be swallowed by the response parser instead of being dispatched. +TEST(ModbusBroadcast, SingleRegisterBroadcastDispatchedWhileExpectingPeerResponse) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RecordingDevice device_a(0x02); + RecordingDevice device_b(0x03); + hub.register_device(&device_a); + hub.register_device(&device_b); + + // A unicast write addressed to an unregistered peer (0x09) leaves the hub expecting that peer's response. + const uint8_t peer_pdu[] = {0x00, 0x10, 0x00, 0x2A}; + ASSERT_TRUE(hub.run_receive_parser_for_test(0x09, static_cast(FunctionCode::WRITE_SINGLE_REGISTER), peer_pdu, + sizeof(peer_pdu))); + ASSERT_EQ(device_a.write_count, 0); // the peer request is not for our devices + ASSERT_EQ(device_b.write_count, 0); + + // The broadcast that follows must still be delivered to every device, and still without a reply. + const uint8_t pdu_data[] = {0x9D, 0x31, 0x00, 0xA5}; + ASSERT_TRUE(hub.run_receive_parser_for_test( + BROADCAST_ADDRESS, static_cast(FunctionCode::WRITE_SINGLE_REGISTER), pdu_data, sizeof(pdu_data))); + + for (RecordingDevice *device : {&device_a, &device_b}) { + EXPECT_EQ(device->write_count, 1); + EXPECT_EQ(device->last_start_address, 0x9D31); + ASSERT_EQ(device->last_values.size(), 1u); + EXPECT_EQ(device->last_values[0], 0x00A5); + } + EXPECT_TRUE(uart.written.empty()); // broadcasts are never answered +} + +// After dispatching a broadcast, the hub must not still expect a peer response: a following unicast FC 0x06 +// to one of our own devices must be handled, not misparsed as that peer's response and dropped. +TEST(ModbusBroadcast, BroadcastClearsStalePeerExpectation) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RecordingDevice device(0x02); + hub.register_device(&device); + + // A unicast write to an unregistered peer (0x09) leaves the hub expecting that peer's response. + const uint8_t pdu_data[] = {0x00, 0x10, 0x00, 0x2A}; + ASSERT_TRUE(hub.run_receive_parser_for_test(0x09, static_cast(FunctionCode::WRITE_SINGLE_REGISTER), pdu_data, + sizeof(pdu_data))); + + // The broadcast that follows clears that expectation as it is dispatched. + ASSERT_TRUE(hub.run_receive_parser_for_test( + BROADCAST_ADDRESS, static_cast(FunctionCode::WRITE_SINGLE_REGISTER), pdu_data, sizeof(pdu_data))); + ASSERT_EQ(device.write_count, 1); + + // The next unicast FC 0x06 to our own device is handled, not swallowed by the stale expectation. + ASSERT_TRUE(hub.run_receive_parser_for_test(0x02, static_cast(FunctionCode::WRITE_SINGLE_REGISTER), pdu_data, + sizeof(pdu_data))); + EXPECT_EQ(device.write_count, 2); +} + +// A broadcast multi-register write is decoded and delivered to every device, still without a reply. +TEST(ModbusBroadcast, MultipleRegisterWriteReachesAllDevicesWithoutReply) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RecordingDevice device_a(0x02); + RecordingDevice device_b(0x03); + hub.register_device(&device_a); + hub.register_device(&device_b); + + // FC 0x10 payload: start 0x9D31, quantity 2, byte count 4, values 0x0102 and 0x0304. + const uint8_t pdu_data[] = {0x9D, 0x31, 0x00, 0x02, 0x04, 0x01, 0x02, 0x03, 0x04}; + ASSERT_TRUE(hub.run_receive_parser_for_test( + BROADCAST_ADDRESS, static_cast(FunctionCode::WRITE_MULTIPLE_REGISTERS), pdu_data, sizeof(pdu_data))); + + for (RecordingDevice *device : {&device_a, &device_b}) { + EXPECT_EQ(device->write_count, 1); + EXPECT_EQ(device->last_start_address, 0x9D31); + ASSERT_EQ(device->last_values.size(), 2u); + EXPECT_EQ(device->last_values[0], 0x0102); + EXPECT_EQ(device->last_values[1], 0x0304); + } + EXPECT_TRUE(uart.written.empty()); +} + +// A read broadcast is meaningless (it would need a reply), so nothing is dispatched and nothing is sent. +TEST(ModbusBroadcast, ReadFunctionCodeIsIgnoredAndProducesNoReply) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RecordingDevice device(0x02); + hub.register_device(&device); + + // FC 0x03 payload: start 0x0000, quantity 2. Reads cannot be broadcast. + const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x02}; + ASSERT_TRUE(hub.run_receive_parser_for_test( + BROADCAST_ADDRESS, static_cast(FunctionCode::READ_HOLDING_REGISTERS), pdu_data, sizeof(pdu_data))); + + EXPECT_EQ(device.write_count, 0); // no device was written + EXPECT_TRUE(uart.written.empty()); // and the broadcast address is never answered +} + +// An invalid broadcast write is silently dropped: no writes dispatched and no exception reply sent. +TEST(ModbusBroadcast, InvalidMultipleWriteBroadcastProducesNoWriteAndNoReply) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RecordingDevice device_a(0x02); + RecordingDevice device_b(0x03); + hub.register_device(&device_a); + hub.register_device(&device_b); + + // FC 0x10 payload: quantity 2 but byte count 2 (should be 4), so parsing fails. + const uint8_t pdu_data[] = {0x9D, 0x31, 0x00, 0x02, 0x02, 0x01, 0x02}; + ASSERT_TRUE(hub.run_receive_parser_for_test( + BROADCAST_ADDRESS, static_cast(FunctionCode::WRITE_MULTIPLE_REGISTERS), pdu_data, sizeof(pdu_data))); + + EXPECT_EQ(device_a.write_count, 0); + EXPECT_EQ(device_b.write_count, 0); + EXPECT_TRUE(uart.written.empty()); +} + +// A device that rejects a broadcast write must not stop dispatch to devices registered after it, and the +// broadcast is still never answered. +TEST(ModbusBroadcast, RejectingDeviceDoesNotStopBroadcastDispatch) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RejectingDevice rejecter(0x02); + RecordingDevice device(0x03); + hub.register_device(&rejecter); // registered first, so a rejection happens before the normal device + hub.register_device(&device); + + // FC 0x06 payload: start address 0x9D31, value 0x00A5 (big-endian, no address/CRC). + const uint8_t pdu_data[] = {0x9D, 0x31, 0x00, 0xA5}; + ASSERT_TRUE(hub.run_receive_parser_for_test( + BROADCAST_ADDRESS, static_cast(FunctionCode::WRITE_SINGLE_REGISTER), pdu_data, sizeof(pdu_data))); + + EXPECT_EQ(rejecter.write_count, 1); // the rejecting device was still invoked + EXPECT_EQ(device.write_count, 1); // and dispatch continued to the device registered after it + EXPECT_EQ(device.last_start_address, 0x9D31); + ASSERT_EQ(device.last_values.size(), 1u); + EXPECT_EQ(device.last_values[0], 0x00A5); + EXPECT_TRUE(uart.written.empty()); // a broadcast is never answered, even when a device rejects +} + +// A unicast out-of-range write sends exactly one exception frame on the wire. +TEST(ModbusBroadcast, UnicastOutOfRangeWriteSendsSingleExceptionFrame) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RecordingDevice device(0x02); + hub.register_device(&device); + + // FC 0x10 payload: start 0xFFFF, quantity 2, byte count 4, values valid but address range overflows. + const uint8_t pdu_data[] = {0xFF, 0xFF, 0x00, 0x02, 0x04, 0x01, 0x02, 0x03, 0x04}; + ASSERT_TRUE(hub.run_receive_parser_for_test(0x02, static_cast(FunctionCode::WRITE_MULTIPLE_REGISTERS), + pdu_data, sizeof(pdu_data))); + + EXPECT_EQ(device.write_count, 0); + ASSERT_EQ(uart.written.size(), 5u); + EXPECT_EQ(uart.written[0], 0x02); // server address + EXPECT_EQ(uart.written[1], static_cast(FunctionCode::WRITE_MULTIPLE_REGISTERS) | 0x80); + EXPECT_EQ(uart.written[2], static_cast(ExceptionCode::ILLEGAL_DATA_ADDRESS)); +} + +} // namespace esphome::modbus From 68acc055bf11f2993ec40c6be4529a9e6babc863 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Sat, 8 Aug 2026 10:06:40 +0300 Subject: [PATCH 1330/1815] [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 13: xiaomi_rtcgq02lm, xiaomi_wx08zm, xiaomi_xmwsdj04mmc) (#18183) --- .../components/xiaomi_rtcgq02lm/__init__.py | 14 ++++---- .../xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp | 6 +--- .../xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h | 10 ++---- .../components/xiaomi_wx08zm/binary_sensor.py | 12 +++---- .../xiaomi_wx08zm/xiaomi_wx08zm.cpp | 6 +--- .../components/xiaomi_wx08zm/xiaomi_wx08zm.h | 10 ++---- .../components/xiaomi_xmwsdj04mmc/sensor.py | 14 ++++---- .../xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp | 6 +--- .../xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h | 10 ++---- .../xiaomi_rtcgq02lm/common-ln.yaml | 20 ++++++++++++ tests/components/xiaomi_rtcgq02lm/common.yaml | 3 ++ .../xiaomi_rtcgq02lm/test.ln882x-ard.yaml | 3 ++ .../xiaomi_rtcgq02lm/validate.bk72xx-ard.yaml | 32 +++++++++++++++++++ tests/components/xiaomi_wx08zm/common-ln.yaml | 8 +++++ tests/components/xiaomi_wx08zm/common.yaml | 3 ++ .../xiaomi_wx08zm/test.ln882x-ard.yaml | 3 ++ .../xiaomi_wx08zm/validate.bk72xx-ard.yaml | 20 ++++++++++++ .../xiaomi_xmwsdj04mmc/common-ln.yaml | 10 ++++++ .../components/xiaomi_xmwsdj04mmc/common.yaml | 3 ++ .../xiaomi_xmwsdj04mmc/test.ln882x-ard.yaml | 3 ++ .../validate.bk72xx-ard.yaml | 24 ++++++++++++++ 21 files changed, 164 insertions(+), 56 deletions(-) create mode 100644 tests/components/xiaomi_rtcgq02lm/common-ln.yaml create mode 100644 tests/components/xiaomi_rtcgq02lm/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_rtcgq02lm/validate.bk72xx-ard.yaml create mode 100644 tests/components/xiaomi_wx08zm/common-ln.yaml create mode 100644 tests/components/xiaomi_wx08zm/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_wx08zm/validate.bk72xx-ard.yaml create mode 100644 tests/components/xiaomi_xmwsdj04mmc/common-ln.yaml create mode 100644 tests/components/xiaomi_xmwsdj04mmc/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_xmwsdj04mmc/validate.bk72xx-ard.yaml diff --git a/esphome/components/xiaomi_rtcgq02lm/__init__.py b/esphome/components/xiaomi_rtcgq02lm/__init__.py index df143bac22..3e235d985f 100644 --- a/esphome/components/xiaomi_rtcgq02lm/__init__.py +++ b/esphome/components/xiaomi_rtcgq02lm/__init__.py @@ -1,19 +1,19 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker +from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_BINDKEY, CONF_ID, CONF_MAC_ADDRESS -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] CODEOWNERS = ["@jesserockz"] -DEPENDENCIES = ["esp32_ble_tracker"] MULTI_CONF = True xiaomi_rtcgq02lm_ns = cg.esphome_ns.namespace("xiaomi_rtcgq02lm") XiaomiRTCGQ02LM = xiaomi_rtcgq02lm_ns.class_( - "XiaomiRTCGQ02LM", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiRTCGQ02LM", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_rtcgq02lm"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiRTCGQ02LM), @@ -21,15 +21,15 @@ CONFIG_SCHEMA = ( cv.Required(CONF_MAC_ADDRESS): cv.mac_address, } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp index b42a5a3700..f349dfa797 100644 --- a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp +++ b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_rtcgq02lm { static const char *const TAG = "xiaomi_rtcgq02lm"; @@ -24,7 +22,7 @@ void XiaomiRTCGQ02LM::dump_config() { #endif } -bool XiaomiRTCGQ02LM::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiRTCGQ02LM::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -79,5 +77,3 @@ bool XiaomiRTCGQ02LM::parse_device(const esp32_ble_tracker::ESPBTDevice &device) void XiaomiRTCGQ02LM::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } } // namespace esphome::xiaomi_rtcgq02lm - -#endif diff --git a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h index 0d3427cc4d..d776c22d9e 100644 --- a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h +++ b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h @@ -1,6 +1,6 @@ #pragma once -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/core/defines.h" #ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" @@ -11,16 +11,14 @@ #include "esphome/components/xiaomi_ble/xiaomi_ble.h" #include "esphome/core/component.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_rtcgq02lm { -class XiaomiRTCGQ02LM final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiRTCGQ02LM final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; #ifdef USE_BINARY_SENSOR @@ -54,5 +52,3 @@ class XiaomiRTCGQ02LM final : public Component, public esp32_ble_tracker::ESPBTD }; } // namespace esphome::xiaomi_rtcgq02lm - -#endif diff --git a/esphome/components/xiaomi_wx08zm/binary_sensor.py b/esphome/components/xiaomi_wx08zm/binary_sensor.py index 69facf54ed..6aaf94f48f 100644 --- a/esphome/components/xiaomi_wx08zm/binary_sensor.py +++ b/esphome/components/xiaomi_wx08zm/binary_sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import binary_sensor, esp32_ble_tracker, sensor +from esphome.components import binary_sensor, ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -12,18 +12,18 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble", "sensor"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble", "sensor"] xiaomi_wx08zm_ns = cg.esphome_ns.namespace("xiaomi_wx08zm") XiaomiWX08ZM = xiaomi_wx08zm_ns.class_( "XiaomiWX08ZM", binary_sensor.BinarySensor, - esp32_ble_tracker.ESPBTDeviceListener, + ble_device_base.ESPBTDeviceListener, cg.Component, ) CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_wx08zm"), binary_sensor.binary_sensor_schema(XiaomiWX08ZM) .extend( { @@ -43,15 +43,15 @@ CONFIG_SCHEMA = cv.All( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.cpp b/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.cpp index 1bf861a6af..ae37d63096 100644 --- a/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.cpp +++ b/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.cpp @@ -1,8 +1,6 @@ #include "xiaomi_wx08zm.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_wx08zm { static const char *const TAG = "xiaomi_wx08zm"; @@ -14,7 +12,7 @@ void XiaomiWX08ZM::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiWX08ZM::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiWX08ZM::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -56,5 +54,3 @@ bool XiaomiWX08ZM::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { } } // namespace esphome::xiaomi_wx08zm - -#endif diff --git a/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h b/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h index 0573959473..bbb7b66352 100644 --- a/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h +++ b/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h @@ -3,20 +3,18 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/binary_sensor/binary_sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_wx08zm { class XiaomiWX08ZM final : public Component, public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener { + public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_tablet(sensor::Sensor *tablet) { tablet_ = tablet; } @@ -29,5 +27,3 @@ class XiaomiWX08ZM final : public Component, }; } // namespace esphome::xiaomi_wx08zm - -#endif diff --git a/esphome/components/xiaomi_xmwsdj04mmc/sensor.py b/esphome/components/xiaomi_xmwsdj04mmc/sensor.py index b41a775f35..758fa53d9e 100644 --- a/esphome/components/xiaomi_xmwsdj04mmc/sensor.py +++ b/esphome/components/xiaomi_xmwsdj04mmc/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -17,16 +17,16 @@ from esphome.const import ( UNIT_PERCENT, ) -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] CODEOWNERS = ["@medusalix"] -DEPENDENCIES = ["esp32_ble_tracker"] xiaomi_xmwsdj04mmc_ns = cg.esphome_ns.namespace("xiaomi_xmwsdj04mmc") XiaomiXMWSDJ04MMC = xiaomi_xmwsdj04mmc_ns.class_( - "XiaomiXMWSDJ04MMC", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiXMWSDJ04MMC", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_xmwsdj04mmc"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiXMWSDJ04MMC), @@ -53,15 +53,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp index c2b3ec1437..aba954fd91 100644 --- a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp +++ b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_xmwsdj04mmc { static const char *const TAG = "xiaomi_xmwsdj04mmc"; @@ -21,7 +19,7 @@ void XiaomiXMWSDJ04MMC::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiXMWSDJ04MMC::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiXMWSDJ04MMC::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -69,5 +67,3 @@ bool XiaomiXMWSDJ04MMC::parse_device(const esp32_ble_tracker::ESPBTDevice &devic void XiaomiXMWSDJ04MMC::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } } // namespace esphome::xiaomi_xmwsdj04mmc - -#endif diff --git a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h index c7d20aa356..90b2c4e420 100644 --- a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h +++ b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h @@ -2,19 +2,17 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_xmwsdj04mmc { -class XiaomiXMWSDJ04MMC final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiXMWSDJ04MMC final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { this->address_ = address; } void set_bindkey(const char *bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { this->temperature_ = temperature; } @@ -30,5 +28,3 @@ class XiaomiXMWSDJ04MMC final : public Component, public esp32_ble_tracker::ESPB }; } // namespace esphome::xiaomi_xmwsdj04mmc - -#endif diff --git a/tests/components/xiaomi_rtcgq02lm/common-ln.yaml b/tests/components/xiaomi_rtcgq02lm/common-ln.yaml new file mode 100644 index 0000000000..4a04476457 --- /dev/null +++ b/tests/components/xiaomi_rtcgq02lm/common-ln.yaml @@ -0,0 +1,20 @@ +xiaomi_rtcgq02lm: + - id: motion_rtcgq02lm + mac_address: 01:02:03:04:05:06 + bindkey: "48403ebe2d385db8d0c187f81e62cb64" + +binary_sensor: + - platform: xiaomi_rtcgq02lm + id: motion_rtcgq02lm + motion: + name: Mi Motion Sensor 2 + light: + name: Mi Motion Sensor 2 Light + button: + name: Mi Motion Sensor 2 Button + +sensor: + - platform: xiaomi_rtcgq02lm + id: motion_rtcgq02lm + battery_level: + name: Mi Motion Sensor 2 Battery level diff --git a/tests/components/xiaomi_rtcgq02lm/common.yaml b/tests/components/xiaomi_rtcgq02lm/common.yaml index a2e0c66ba5..4d235f6813 100644 --- a/tests/components/xiaomi_rtcgq02lm/common.yaml +++ b/tests/components/xiaomi_rtcgq02lm/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub +# Explicit ble_hub_id: pins the neutral binding as a declared key. xiaomi_rtcgq02lm: - id: motion_rtcgq02lm + ble_hub_id: ble_tracker_hub mac_address: 01:02:03:04:05:06 bindkey: "48403ebe2d385db8d0c187f81e62cb64" diff --git a/tests/components/xiaomi_rtcgq02lm/test.ln882x-ard.yaml b/tests/components/xiaomi_rtcgq02lm/test.ln882x-ard.yaml new file mode 100644 index 0000000000..6ef79a6626 --- /dev/null +++ b/tests/components/xiaomi_rtcgq02lm/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_rtcgq02lm: !include common-ln.yaml diff --git a/tests/components/xiaomi_rtcgq02lm/validate.bk72xx-ard.yaml b/tests/components/xiaomi_rtcgq02lm/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..9c67182050 --- /dev/null +++ b/tests/components/xiaomi_rtcgq02lm/validate.bk72xx-ard.yaml @@ -0,0 +1,32 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +# Explicit ble_hub_id: pins the neutral binding as a declared key. +xiaomi_rtcgq02lm: + - id: motion_rtcgq02lm + ble_hub_id: ble_tracker_hub + mac_address: 01:02:03:04:05:06 + bindkey: "48403ebe2d385db8d0c187f81e62cb64" + + # No ble_hub_id: exercises the generated binding real configs use. + - id: motion_rtcgq02lm_implicit + mac_address: 01:02:03:04:05:07 + bindkey: "48403ebe2d385db8d0c187f81e62cb64" +binary_sensor: + - platform: xiaomi_rtcgq02lm + id: motion_rtcgq02lm + motion: + name: Mi Motion Sensor 2 + light: + name: Mi Motion Sensor 2 Light + button: + name: Mi Motion Sensor 2 Button + +sensor: + - platform: xiaomi_rtcgq02lm + id: motion_rtcgq02lm + battery_level: + name: Mi Motion Sensor 2 Battery level diff --git a/tests/components/xiaomi_wx08zm/common-ln.yaml b/tests/components/xiaomi_wx08zm/common-ln.yaml new file mode 100644 index 0000000000..83766c084b --- /dev/null +++ b/tests/components/xiaomi_wx08zm/common-ln.yaml @@ -0,0 +1,8 @@ +binary_sensor: + - platform: xiaomi_wx08zm + name: WX08ZM Activation State + mac_address: 74:a3:4a:b5:07:34 + tablet: + name: WX08ZM Tablet Resource + battery_level: + name: WX08ZM Battery Level diff --git a/tests/components/xiaomi_wx08zm/common.yaml b/tests/components/xiaomi_wx08zm/common.yaml index 3e83ad3e95..6e43a92d2e 100644 --- a/tests/components/xiaomi_wx08zm/common.yaml +++ b/tests/components/xiaomi_wx08zm/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub binary_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_wx08zm + ble_hub_id: ble_tracker_hub name: WX08ZM Activation State mac_address: 74:a3:4a:b5:07:34 tablet: diff --git a/tests/components/xiaomi_wx08zm/test.ln882x-ard.yaml b/tests/components/xiaomi_wx08zm/test.ln882x-ard.yaml new file mode 100644 index 0000000000..81f05c0c7b --- /dev/null +++ b/tests/components/xiaomi_wx08zm/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_wx08zm: !include common-ln.yaml diff --git a/tests/components/xiaomi_wx08zm/validate.bk72xx-ard.yaml b/tests/components/xiaomi_wx08zm/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..fb9a4e3652 --- /dev/null +++ b/tests/components/xiaomi_wx08zm/validate.bk72xx-ard.yaml @@ -0,0 +1,20 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +binary_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_wx08zm + ble_hub_id: ble_tracker_hub + name: WX08ZM Activation State + mac_address: 74:a3:4a:b5:07:34 + tablet: + name: WX08ZM Tablet Resource + battery_level: + name: WX08ZM Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_wx08zm + name: BK WX08ZM Implicit Activation State + mac_address: 74:a3:4a:b5:07:35 diff --git a/tests/components/xiaomi_xmwsdj04mmc/common-ln.yaml b/tests/components/xiaomi_xmwsdj04mmc/common-ln.yaml new file mode 100644 index 0000000000..2a0778c2a7 --- /dev/null +++ b/tests/components/xiaomi_xmwsdj04mmc/common-ln.yaml @@ -0,0 +1,10 @@ +sensor: + - platform: xiaomi_xmwsdj04mmc + mac_address: 84:B4:DB:5D:A3:8F + bindkey: d8ca2ed09bb5541dc8f045ca360b00ea + temperature: + name: Xiaomi XMWSDJ04MMC Temperature + humidity: + name: Xiaomi XMWSDJ04MMC Humidity + battery_level: + name: Xiaomi XMWSDJ04MMC Battery Level diff --git a/tests/components/xiaomi_xmwsdj04mmc/common.yaml b/tests/components/xiaomi_xmwsdj04mmc/common.yaml index fe7a11efc5..1de13b2bc5 100644 --- a/tests/components/xiaomi_xmwsdj04mmc/common.yaml +++ b/tests/components/xiaomi_xmwsdj04mmc/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_xmwsdj04mmc + ble_hub_id: ble_tracker_hub mac_address: 84:B4:DB:5D:A3:8F bindkey: d8ca2ed09bb5541dc8f045ca360b00ea temperature: diff --git a/tests/components/xiaomi_xmwsdj04mmc/test.ln882x-ard.yaml b/tests/components/xiaomi_xmwsdj04mmc/test.ln882x-ard.yaml new file mode 100644 index 0000000000..749473a022 --- /dev/null +++ b/tests/components/xiaomi_xmwsdj04mmc/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_xmwsdj04mmc: !include common-ln.yaml diff --git a/tests/components/xiaomi_xmwsdj04mmc/validate.bk72xx-ard.yaml b/tests/components/xiaomi_xmwsdj04mmc/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..4139263b52 --- /dev/null +++ b/tests/components/xiaomi_xmwsdj04mmc/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_xmwsdj04mmc + ble_hub_id: ble_tracker_hub + mac_address: 84:B4:DB:5D:A3:8F + bindkey: d8ca2ed09bb5541dc8f045ca360b00ea + temperature: + name: Xiaomi XMWSDJ04MMC Temperature + humidity: + name: Xiaomi XMWSDJ04MMC Humidity + battery_level: + name: Xiaomi XMWSDJ04MMC Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_xmwsdj04mmc + mac_address: 84:B4:DB:5D:A3:90 + bindkey: d8ca2ed09bb5541dc8f045ca360b00ea + temperature: + name: BK Xiaomi XMWSDJ04MMC Implicit Temperature From 747c5c3e405a0e1d6ce26d1477ce17277a569d7c Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sat, 8 Aug 2026 16:34:59 +0200 Subject: [PATCH 1331/1815] [modbus] Make sure we log on no accepting device (#18187) --- esphome/components/modbus/modbus.cpp | 22 ++++++++++++++++++++-- esphome/components/modbus/modbus.h | 4 ++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 87aace02d0..c9e443cd87 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -15,6 +15,9 @@ static constexpr uint32_t MODBUS_BITS_PER_CHAR = 11; // Milliseconds per second static constexpr uint32_t MS_PER_SEC = 1000; +// Shortest gap between two "no device accepted broadcast" warnings +static constexpr uint32_t UNACCEPTED_BROADCAST_WARN_INTERVAL_MS = 60 * MS_PER_SEC; + void Modbus::setup() { if (this->flow_control_pin_ != nullptr) { this->flow_control_pin_->setup(); @@ -445,13 +448,28 @@ void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span< if (status.has_value()) { return; } + // A broadcast is never answered, so a rejecting device has no other feedback channel: report the + // per-device outcome at V, and warn if the write reached nobody at all. + bool accepted = false; for (auto *device : this->devices_) { - // A broadcast is never answered, so a rejecting device has no other feedback channel; log it so a - // misconfigured register map is diagnosable instead of looking identical to a successful write. if (ResponseStatus device_status = device->on_broadcast_write_registers(start_address, registers); device_status.has_value()) { ESP_LOGV(TAG, "Device %" PRIu8 " rejected broadcast write with exception %" PRIu8, device->get_address(), static_cast(device_status.value())); + } else { + accepted = true; + } + } + if (!accepted && !this->devices_.empty()) { + // Warn at most once per interval, then drop to VERBOSE: on a shared bus a broadcast aimed at other nodes + // repeats forever, so warning per frame would flood the log. + const uint32_t now = millis(); + if (this->last_unaccepted_broadcast_warn_ == 0 || + now - this->last_unaccepted_broadcast_warn_ > UNACCEPTED_BROADCAST_WARN_INTERVAL_MS) { + this->last_unaccepted_broadcast_warn_ = now; + ESP_LOGW(TAG, "No device accepted broadcast write of %zu registers at 0x%04X", registers.size(), start_address); + } else { + ESP_LOGV(TAG, "No device accepted broadcast write of %zu registers at 0x%04X", registers.size(), start_address); } } } diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 5a700912de..274b10f9b4 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -361,6 +361,10 @@ class ModbusServerHub : public Modbus { uint8_t expecting_peer_response_{0}; std::vector devices_; + // Stamp of the last "broadcast reached no device" warning, 0 until the first one is logged. Rate limiting + // on time rather than on address keeps the log bounded no matter how many addresses a shared bus carries. + uint32_t last_unaccepted_broadcast_warn_{0}; + // Holds the raw payload of a single reply deferred for sending when tx was blocked at send time. // Only one server reply can be waiting at once, so a single fixed buffer avoids heap allocation. std::array deferred_payload_; From 9cb46aa5846386d34fee2b33814618cf089b0dfa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2026 09:50:32 -0500 Subject: [PATCH 1332/1815] [bluetooth_proxy] Deliver esp32 advertisements through the hub callback (#18173) --- .../bk72xx_ble_tracker/bk72xx_ble_tracker.cpp | 2 +- .../components/ble_device_base/ble_device.h | 9 +- esphome/components/ble_device_base/ble_hub.h | 5 +- .../bluetooth_connection_esp32.cpp | 4 +- .../components/bluetooth_proxy/__init__.py | 6 +- .../bluetooth_proxy/bluetooth_proxy.cpp | 150 +++++------------- .../bluetooth_proxy/bluetooth_proxy.h | 21 +-- .../components/esp32_ble_tracker/__init__.py | 14 -- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 11 ++ .../ln882h_ble_tracker/ln882h_ble_tracker.cpp | 7 +- .../rp2_ble_tracker/rp2_ble_tracker.cpp | 2 +- .../ble_device_base/test_slot_counter.py | 7 +- .../ble_device_base/test_raw_callback.cpp | 8 +- 13 files changed, 95 insertions(+), 151 deletions(-) diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp index c859f22c61..a58561f2de 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp @@ -159,7 +159,7 @@ void BK72xxBLETracker::dump_config() { void BK72xxBLETracker::on_scan_report(const bk72xx_ble::BLEScanReport &report) { // Raw callback (the raw-advertisement path). if (this->raw_advertisement_callback_.is_set()) { - const ble_device_base::RawAdvertisement adv{.mac = report.mac, + const ble_device_base::RawAdvertisement adv{.address = ble_device_base::mac_lsb_first_to_uint64(report.mac), .data = report.data, .data_len = report.data_len, .rssi = report.rssi, diff --git a/esphome/components/ble_device_base/ble_device.h b/esphome/components/ble_device_base/ble_device.h index fba1fe2347..b5f198375c 100644 --- a/esphome/components/ble_device_base/ble_device.h +++ b/esphome/components/ble_device_base/ble_device.h @@ -154,12 +154,9 @@ class ESPBLEiBeacon { }; /// Pack a controller-order (LSB-first) MAC into the uint64 the API speaks. -/// -/// The result is the printable-order value esp32 has always sent -/// (esp32_ble::ble_addr_to_uint64), so both proxy paths agree on the wire. -/// This takes the raw controller order delivered by BLEHub's raw-advertisement -/// callback; ESPBTDevice::address_uint64() is the equivalent for an already -/// parsed device, whose address is stored MSB-first. +/// Trackers with LSB-native SDKs call this at the emit site before filling +/// RawAdvertisement::address; ESPBTDevice::address_uint64() is the equivalent +/// for an already parsed device, whose address is stored MSB-first. inline uint64_t mac_lsb_first_to_uint64(const uint8_t *mac) { uint64_t addr = 0; for (int i = 0; i < 6; i++) diff --git a/esphome/components/ble_device_base/ble_hub.h b/esphome/components/ble_device_base/ble_hub.h index b6fcf6f57a..d9a7731504 100644 --- a/esphome/components/ble_device_base/ble_hub.h +++ b/esphome/components/ble_device_base/ble_hub.h @@ -23,8 +23,9 @@ namespace esphome::ble_device_base { /// One raw advertisement as delivered by the controller — a borrowed view, /// valid only for the duration of the invoke() callback. struct RawAdvertisement { - /// Least-significant octet first (BLE controller convention). - const uint8_t *mac; + /// Producers convert their native byte order at the emit site, so no + /// byte-order convention crosses this contract. + uint64_t address; const uint8_t *data; uint16_t data_len; int8_t rssi; // signed dBm diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp index 7c62d3766c..be6fa4c6c5 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp @@ -480,7 +480,9 @@ esp_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enabl } esp32_ble_tracker::AdvertisementParserType BluetoothConnection::get_advertisement_parser_type() { - return this->proxy_->get_advertisement_parser_type(); + // RAW keeps the tracker from building parsed ESPBTDevice objects for the + // proxy's connections (the proxy itself consumes the hub raw callback). + return esp32_ble_tracker::AdvertisementParserType::RAW_ADVERTISEMENTS; } } // namespace esphome::bluetooth_connection diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index 057b15193a..4aa4195ff9 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -374,7 +374,11 @@ async def _to_code_esp32(config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_active(config[CONF_ACTIVE])) - await esp32_ble_tracker.register_raw_ble_device(var, config) + # Advertisements arrive through the hub raw callback (installed in + # setup()); only the scanner-state listener still registers with the + # tracker directly. + tracker = await cg.get_variable(config[esp32_ble_tracker.CONF_ESP32_BLE_ID]) + cg.add(var.set_parent(tracker)) await esp32_ble_tracker.register_scanner_state_listener(var, config) # Define max connections for protobuf fixed array diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 06e3b9a3b4..19e894600e 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -8,6 +8,7 @@ #include "esphome/core/macros.h" #include "esphome/core/application.h" #include +#include #include #include @@ -26,14 +27,6 @@ BluetoothProxy::BluetoothProxy() { global_bluetooth_proxy = this; } #ifdef USE_ESP32 -void BluetoothProxy::setup() { - this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS; - this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS; - - // Capture the configured scan mode from YAML before any API changes - this->configured_scan_active_ = this->parent_->get_scan_active(); -} - void BluetoothProxy::on_scanner_state(esp32_ble_tracker::ScannerState state) { if (this->api_connection_ != nullptr) { this->send_bluetooth_scanner_state_(state); @@ -43,8 +36,8 @@ void BluetoothProxy::on_scanner_state(esp32_ble_tracker::ScannerState state) { void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state) { api::BluetoothScannerStateResponse resp; resp.state = static_cast(state); - resp.mode = this->parent_->get_scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE - : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; + resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE + : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; resp.configured_mode = this->configured_scan_active_ ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; @@ -53,45 +46,6 @@ void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerSta #else // !USE_ESP32 -void BluetoothProxy::setup() { - // BLUETOOTH_PROXY_MAX_CONNECTIONS is 0 on an advertisement-only proxy. - this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS; - this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS; - - // Capture the configured scan mode from YAML before any API changes - this->configured_scan_active_ = this->hub_->scan_active(); - - // The hub delivers raw advertisements on the ESPHome main loop: - // mac is least-significant octet first (BLE controller convention). - this->hub_->set_raw_advertisement_callback({this, [](void *self, const ble_device_base::RawAdvertisement &adv) { - static_cast(self)->on_raw_advertisement_(adv); - }}); -} - -void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw) { - if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) - return; - - auto &adv = this->response_.advertisements[this->response_.advertisements_len]; - // raw.mac is LSB-first; this yields the same uint64 the esp32 proxy sends. - adv.address = ble_device_base::mac_lsb_first_to_uint64(raw.mac); - adv.rssi = raw.rssi; - adv.address_type = raw.addr_type; - uint8_t length = raw.data_len > sizeof(adv.data) ? sizeof(adv.data) : static_cast(raw.data_len); - adv.data_len = length; - std::memcpy(adv.data, raw.data, length); - - this->response_.advertisements_len++; - - ESP_LOGV(TAG, "Queuing raw packet from %02X:%02X:%02X:%02X:%02X:%02X, length %d. RSSI: %d dB", raw.mac[5], raw.mac[4], - raw.mac[3], raw.mac[2], raw.mac[1], raw.mac[0], length, raw.rssi); - - // Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE - if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) { - this->flush_pending_advertisements_(); - } -} - void BluetoothProxy::send_bluetooth_scanner_state_() { // One read feeds both the frame and the change detector; the detector only // advances if the frame was accepted, so a dropped send (WOULD_BLOCK on a @@ -112,6 +66,42 @@ void BluetoothProxy::send_bluetooth_scanner_state_() { #endif // USE_ESP32 +void BluetoothProxy::setup() { + // BLUETOOTH_PROXY_MAX_CONNECTIONS is 0 on an advertisement-only proxy. + this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS; + this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS; + + // Capture the configured scan mode from YAML before any API changes + this->configured_scan_active_ = this->hub_->scan_active(); + + this->hub_->set_raw_advertisement_callback({this, [](void *self, const ble_device_base::RawAdvertisement &adv) { + static_cast(self)->on_raw_advertisement_(adv); + }}); +} + +// The hub delivers raw advertisements on the ESPHome main loop. +void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw) { + if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) + return; + + auto &adv = this->response_.advertisements[this->response_.advertisements_len]; + adv.address = raw.address; + adv.rssi = raw.rssi; + adv.address_type = raw.addr_type; + uint8_t length = raw.data_len > sizeof(adv.data) ? sizeof(adv.data) : static_cast(raw.data_len); + adv.data_len = length; + std::memcpy(adv.data, raw.data, length); + + this->response_.advertisements_len++; + + ESP_LOGV(TAG, "Queuing raw packet from %012" PRIX64 ", length %d. RSSI: %d dB", raw.address, length, raw.rssi); + + // Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE + if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) { + this->flush_pending_advertisements_(); + } +} + #ifdef BLUETOOTH_CONNECTION_HAS_GATT void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, ClientState state) { ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, state: %s", connection->get_connection_index(), @@ -133,50 +123,6 @@ void BluetoothProxy::handle_gatt_not_connected_(uint64_t address, uint16_t handl this->send_gatt_error(address, handle, GATT_NOT_CONNECTED); } -#ifdef USE_ESP32 - -#ifdef USE_ESP32_BLE_DEVICE -bool BluetoothProxy::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { - // This method should never be called since bluetooth_proxy always uses raw advertisements - // but we need to provide an implementation to satisfy the virtual method requirement - return false; -} -#endif - -bool BluetoothProxy::parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) { - if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) - return false; - - auto &advertisements = this->response_.advertisements; - - for (size_t i = 0; i < count; i++) { - auto &result = scan_results[i]; - uint8_t length = result.adv_data_len + result.scan_rsp_len; - - // Fill in the data directly at current position - auto &adv = advertisements[this->response_.advertisements_len]; - adv.address = esp32_ble::ble_addr_to_uint64(result.bda); - adv.rssi = result.rssi; - adv.address_type = result.ble_addr_type; - adv.data_len = length; - std::memcpy(adv.data, result.ble_adv, length); - - this->response_.advertisements_len++; - - ESP_LOGV(TAG, "Queuing raw packet from %02X:%02X:%02X:%02X:%02X:%02X, length %d. RSSI: %d dB", result.bda[0], - result.bda[1], result.bda[2], result.bda[3], result.bda[4], result.bda[5], length, result.rssi); - - // Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE - if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) { - this->flush_pending_advertisements_(); - } - } - - return true; -} - -#endif // USE_ESP32 - void BluetoothProxy::log_advertisement_flush_() { ESP_LOGV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len); } @@ -236,10 +182,6 @@ void BluetoothProxy::loop() { } } -esp32_ble_tracker::AdvertisementParserType BluetoothProxy::get_advertisement_parser_type() { - return esp32_ble_tracker::AdvertisementParserType::RAW_ADVERTISEMENTS; -} - #endif // USE_ESP32 #ifdef BLUETOOTH_CONNECTION_HAS_GATT @@ -522,13 +464,13 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn #ifdef USE_ESP32 void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { - if (this->parent_->get_scan_active() == active) { + if (this->parent_()->get_scan_active() == active) { return; } ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive"); - this->parent_->set_scan_active(active); - this->parent_->stop_scan(); - this->parent_->set_scan_continuous( + this->parent_()->set_scan_active(active); + this->parent_()->stop_scan(); + this->parent_()->set_scan_continuous( true); // Set this to true to automatically start scanning again when it has cleaned up. } @@ -675,8 +617,7 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection } this->api_connection_ = api_connection; #ifdef USE_ESP32 - this->parent_->recalculate_advertisement_parser_types(); - this->send_bluetooth_scanner_state_(this->parent_->get_scanner_state()); + this->send_bluetooth_scanner_state_(this->parent_()->get_scanner_state()); #else this->send_bluetooth_scanner_state_(); #endif @@ -688,9 +629,6 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti return; } this->api_connection_ = nullptr; -#ifdef USE_ESP32 - this->parent_->recalculate_advertisement_parser_types(); -#endif } void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, conn_err_t error) { diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index b8c8ab15f6..ed39a697aa 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -73,9 +73,7 @@ enum BluetoothProxySubscriptionFlag : uint32_t { }; #ifdef USE_ESP32 -class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, - public esp32_ble_tracker::BLEScannerStateListener, - public Component { +class BluetoothProxy final : public esp32_ble_tracker::BLEScannerStateListener, public Component { #else class BluetoothProxy final : public Component { #endif @@ -86,11 +84,9 @@ class BluetoothProxy final : public Component { public: BluetoothProxy(); #ifdef USE_ESP32 -#ifdef USE_ESP32_BLE_DEVICE - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; -#endif - bool parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) override; - esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; + // Advertisements arrive through the hub's raw callback; parent_() below + // recovers the tracker type for the esp32-only scan-mode calls. + void set_parent(esp32_ble_tracker::ESP32BLETracker *parent) { this->hub_ = parent; } #endif // USE_ESP32 void dump_config() override; void setup() override; @@ -221,8 +217,8 @@ class BluetoothProxy final : public Component { void send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state); #else void send_bluetooth_scanner_state_(); - void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw); #endif + void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw); /// Caller must ensure api_connection_ is non-null and API server is connected. void flush_pending_advertisements_() { @@ -288,8 +284,13 @@ class BluetoothProxy final : public Component { // Group 2: Fixed-size array of connection pointers std::array connections_{}; #endif -#ifndef USE_ESP32 ble_device_base::BLEHub *hub_{nullptr}; +#ifdef USE_ESP32 + // set_parent() is the only writer of hub_ on esp32, so the downcast is + // exact; ESP32BLETracker derives from BLEHub non-virtually. + esp32_ble_tracker::ESP32BLETracker *parent_() { + return static_cast(this->hub_); + } #endif // BLE advertisement batching diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index b8f49d4fbd..646ce79233 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -374,20 +374,6 @@ async def register_client(var: cg.SafeExpType, config: ConfigType) -> cg.SafeExp return var -async def register_raw_ble_device( - var: cg.SafeExpType, config: ConfigType -) -> cg.SafeExpType: - """Register a BLE device listener that only needs raw advertisement data. - - This does NOT register the ESP_BT_DEVICE feature, meaning ESPBTDevice - will not be compiled in if this is the only registration method used. - """ - _request_listener_slot() - paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) - cg.add(paren.register_listener(var)) - return var - - async def register_raw_client( var: cg.SafeExpType, config: ConfigType ) -> cg.SafeExpType: diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 8418fc3fec..cec2f230f8 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -462,6 +462,17 @@ void ESP32BLETracker::print_bt_device_info(const ESPBTDevice &device) { #endif // USE_ESP32_BLE_DEVICE void ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) { + // Neutral raw-advertisement subscriber (the bluetooth_proxy path). + if (this->raw_advertisement_callback_.is_set()) { + ble_device_base::RawAdvertisement adv; + adv.address = esp32_ble::ble_addr_to_uint64(scan_result.bda); + adv.data = scan_result.ble_adv; + adv.data_len = static_cast(scan_result.adv_data_len) + scan_result.scan_rsp_len; + adv.rssi = scan_result.rssi; + adv.addr_type = scan_result.ble_addr_type; + this->raw_advertisement_callback_.invoke(adv); + } + // Process raw advertisements if (this->raw_advertisements_) { #ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp index 90be341820..cddcd6c17d 100644 --- a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp @@ -240,8 +240,11 @@ void LN882HBLETracker::process_adv_(const uint8_t *mac, int8_t rssi, uint8_t add // Raw callback (the raw-advertisement path). Both full advertisements and // unmatched scan responses (raw_only) are forwarded. if (this->raw_advertisement_callback_.is_set()) { - const ble_device_base::RawAdvertisement adv{ - .mac = mac, .data = data, .data_len = data_len, .rssi = rssi, .addr_type = addr_type}; + const ble_device_base::RawAdvertisement adv{.address = ble_device_base::mac_lsb_first_to_uint64(mac), + .data = data, + .data_len = data_len, + .rssi = rssi, + .addr_type = addr_type}; this->raw_advertisement_callback_.invoke(adv); } diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp index ed036328ae..c2bb93a32e 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp @@ -122,7 +122,7 @@ void RP2BLETracker::dump_config() { void RP2BLETracker::on_scan_report(const rp2040_ble::BLEScanReport &report) { // Raw callback (the raw-advertisement path). if (this->raw_advertisement_callback_.is_set()) { - const ble_device_base::RawAdvertisement adv{.mac = report.mac, + const ble_device_base::RawAdvertisement adv{.address = ble_device_base::mac_lsb_first_to_uint64(report.mac), .data = report.data, .data_len = report.data_len, .rssi = report.rssi, diff --git a/tests/component_tests/ble_device_base/test_slot_counter.py b/tests/component_tests/ble_device_base/test_slot_counter.py index 0fa5577a0b..1c1499cb2d 100644 --- a/tests/component_tests/ble_device_base/test_slot_counter.py +++ b/tests/component_tests/ble_device_base/test_slot_counter.py @@ -107,14 +107,15 @@ def test_esp32_bluetooth_proxy_requests_scanner_state_slot( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: - """The proxy requests one scanner state slot, one raw listener slot and a - client slot per connection (three by default with active: true).""" + """The proxy requests one scanner state slot and a client slot per + connection (three by default with active: true); advertisements arrive + through the hub raw callback, so no listener slot exists.""" generate_main(component_config_path("esp32_bluetooth_proxy.yaml")) assert ( get_define_value("ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT") == "1" ) - assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") == "1" + assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") is None assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") == "3" diff --git a/tests/components/ble_device_base/test_raw_callback.cpp b/tests/components/ble_device_base/test_raw_callback.cpp index cd18c3db59..4d72c8fb18 100644 --- a/tests/components/ble_device_base/test_raw_callback.cpp +++ b/tests/components/ble_device_base/test_raw_callback.cpp @@ -46,13 +46,13 @@ struct CapturingSubscriber { } }; -// Device AA:BB:CC:DD:EE:FF — controller order delivers FF first. -const uint8_t MAC_LSB_FIRST[6] = {0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa}; +// Device AA:BB:CC:DD:EE:FF, packed the way the API speaks it. +constexpr uint64_t TEST_ADDRESS = 0xAABBCCDDEEFFULL; const uint8_t ADV_DATA[4] = {0x02, 0x01, 0x06, 0x00}; RawAdvertisement make_test_adv() { return RawAdvertisement{ - .mac = MAC_LSB_FIRST, .data = ADV_DATA, .data_len = sizeof(ADV_DATA), .rssi = -63, .addr_type = 1}; + .address = TEST_ADDRESS, .data = ADV_DATA, .data_len = sizeof(ADV_DATA), .rssi = -63, .addr_type = 1}; } } // namespace @@ -70,7 +70,7 @@ TEST(RawAdvertisementCallback, SubscriberSeesFieldsUnchanged) { hub.emit(make_test_adv()); ASSERT_EQ(subscriber.calls, 1); - EXPECT_EQ(subscriber.last.mac, MAC_LSB_FIRST); + EXPECT_EQ(subscriber.last.address, TEST_ADDRESS); EXPECT_EQ(subscriber.last.data, ADV_DATA); EXPECT_EQ(subscriber.last.data_len, sizeof(ADV_DATA)); EXPECT_EQ(subscriber.last.rssi, -63); From 7218aa4803a7f38cdf8fc6bc2ea1903ab751ce3f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2026 09:51:12 -0500 Subject: [PATCH 1333/1815] [bluetooth_connection] Explicit pairing for rp2 (#18166) --- .../ble_device_base/ble_gatt_client.h | 3 + .../bluetooth_connection.h | 15 +++- .../bluetooth_connection_hub.cpp | 11 +++ .../bluetooth_connection_hub.h | 5 ++ .../bluetooth_connection_rp2.cpp | 74 +++++++++++++++++++ .../bluetooth_connection_rp2.h | 5 ++ .../bluetooth_proxy/bluetooth_proxy.cpp | 18 +++-- .../esp32_ble_client/ble_client_base.h | 2 + .../test_gatt_client_contract.cpp | 10 +++ .../bluetooth_connection/__init__.py | 18 +++++ .../test_close_service_batch.cpp | 58 +++++++++++++++ 11 files changed, 209 insertions(+), 10 deletions(-) create mode 100644 tests/components/bluetooth_connection/__init__.py create mode 100644 tests/components/bluetooth_connection/test_close_service_batch.cpp diff --git a/esphome/components/ble_device_base/ble_gatt_client.h b/esphome/components/ble_device_base/ble_gatt_client.h index 1bcfcf99dc..37edc570ec 100644 --- a/esphome/components/ble_device_base/ble_gatt_client.h +++ b/esphome/components/ble_device_base/ble_gatt_client.h @@ -95,6 +95,7 @@ class GattClientEventListener { virtual void on_notify_state(uint16_t handle, bool enabled, int error) = 0; /// Notification/indication data from the peer. data/len valid during the call. virtual void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) = 0; + virtual void on_pairing_result(int status) {} }; /// One GATT client connection slot. Operations return 0 when accepted @@ -123,6 +124,8 @@ class BLEGattConnection { /// handle. Local registration only — the CCCD write is the API client's /// responsibility (it arrives as a plain write_descriptor). virtual int notify_characteristic(uint16_t handle, bool enable) = 0; + /// Initiate pairing on the live link. Completion: on_pairing_result(). + virtual int pair() { return GATT_ERR_NOT_CONNECTED; } virtual int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout) = 0; diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h index 712251b157..2125d5b34f 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -48,20 +48,29 @@ static constexpr conn_err_t GATT_NOT_CONNECTED = ble_device_base::GATT_ERR_NOT_C // What the platform's connection backend supports beyond GATT operations; // the proxy derives its feature flags and legacy version from these. -#ifdef USE_ESP32 +#if defined(USE_ESP32) static constexpr bool SUPPORTS_PAIRING = true; static constexpr bool SUPPORTS_CACHE_CLEARING = true; +#elif defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) +// The rp2 BTstack backend pairs (just works + bonding); it has no service +// cache to clear. Keyed on the backend, not the generic client define, so a +// future backend without pairing keeps the stub arm below. +static constexpr bool SUPPORTS_PAIRING = true; +static constexpr bool SUPPORTS_CACHE_CLEARING = false; #else static constexpr bool SUPPORTS_PAIRING = false; static constexpr bool SUPPORTS_CACHE_CLEARING = false; #endif // Address-scoped (not connection-scoped) maintenance requests. -#ifdef USE_ESP32 +#if defined(USE_ESP32) || (defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT)) conn_err_t unpair_device(uint64_t address); -conn_err_t clear_gatt_cache(uint64_t address); #else inline conn_err_t unpair_device(uint64_t) { return GATT_NOT_CONNECTED; } +#endif +#ifdef USE_ESP32 +conn_err_t clear_gatt_cache(uint64_t address); +#else inline conn_err_t clear_gatt_cache(uint64_t) { return GATT_NOT_CONNECTED; } #endif diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index 37d6b21dfe..b69a07fc31 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -74,6 +74,16 @@ void BluetoothConnection::check_disconnect_timeout_() { } } +void BluetoothConnection::on_pairing_result(int status) { + if (this->address_ == 0) { + // A drop before completion already answered: reset_connection_slot_ sends + // the connection response, which the client's pair watcher raises on. + return; + } + this->paired_ = status == 0; + this->proxy_->send_device_pairing(this->address_, status == 0, status); +} + void BluetoothConnection::reset_connection_(conn_err_t reason) { if (this->pending_error_ != 0) { reason = this->pending_error_; @@ -81,6 +91,7 @@ void BluetoothConnection::reset_connection_(conn_err_t reason) { } this->state_ = ClientState::IDLE; this->services_discovered_ = false; + this->paired_ = false; this->backend_->release_services(); this->proxy_->reset_connection_slot_(this, reason); } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index 83fbd24e4c..34e400ac01 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -49,6 +49,9 @@ class BluetoothConnection final : public ble_device_base::GattClientEventListene this->start_connect_(); } void disconnect(); + bool is_paired() const { return this->paired_; } + void set_unpaired() { this->paired_ = false; } + conn_err_t pair() { return this->backend_->pair(); } // A backend disconnect() is a single call that also cancels an in-progress // connect; there is no deferred-disconnect state to track. bool disconnect_pending() const { return false; } @@ -87,6 +90,7 @@ class BluetoothConnection final : public ble_device_base::GattClientEventListene void on_write_result(uint16_t handle, int error) override; void on_notify_state(uint16_t handle, bool enabled, int error) override; void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) override; + void on_pairing_result(int status) override; protected: friend class bluetooth_proxy::BluetoothProxy; @@ -118,6 +122,7 @@ class BluetoothConnection final : public ble_device_base::GattClientEventListene // Group 5: 1-byte types ClientState state_{ClientState::IDLE}; + bool paired_{false}; ConnectionType connection_type_{ConnectionType::V1}; uint8_t remote_addr_type_{0}; uint8_t connection_index_{0}; diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index 5eb3da0263..cd7577e7f7 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -1,4 +1,5 @@ #include "bluetooth_connection_rp2.h" +#include "bluetooth_connection.h" #if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) @@ -51,6 +52,7 @@ using ble_device_base::MEDIUM_MIN_CONN_INTERVAL; RP2GattClient *RP2GattClient::instances[ESPHOME_BLE_GATT_CLIENT_COUNT] = {}; uint8_t RP2GattClient::instance_count = 0; btstack_packet_callback_registration_t RP2GattClient::hci_event_registration = {}; +btstack_packet_callback_registration_t RP2GattClient::sm_event_registration = {}; // NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) static ESPBTUUID uuid_from_btstack(uint16_t uuid16, const uint8_t uuid128[16]) { @@ -88,6 +90,8 @@ void RP2GattClient::setup() { if (hci_event_registration.callback == nullptr) { hci_event_registration.callback = &RP2GattClient::hci_packet_handler; hci_add_event_handler(&hci_event_registration); + sm_event_registration.callback = &RP2GattClient::sm_packet_handler; + sm_add_event_handler(&sm_event_registration); } } @@ -157,6 +161,38 @@ void RP2GattClient::hci_packet_handler(uint8_t type, uint16_t channel, uint8_t * } } +void RP2GattClient::sm_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size) { + if (type != HCI_EVENT_PACKET) { + return; + } + switch (hci_event_packet_get_type(packet)) { + case SM_EVENT_JUST_WORKS_REQUEST: + // Confirming from the SM callback is the intended BTstack pattern. + // Unscoped on purpose: no peripheral role exists in-tree, and scoping + // would drop a request racing the queued CONNECTED event. + sm_just_works_confirm(sm_event_just_works_request_get_handle(packet)); + break; + case SM_EVENT_PAIRING_COMPLETE: { + RP2GattClient *inst = instance_for_con_handle(sm_event_pairing_complete_get_handle(packet)); + if (inst != nullptr) { + inst->enqueue_event_irq_(RP2GattEvent::PAIRING_RESULT, sm_event_pairing_complete_get_status(packet), 0); + } + break; + } + case SM_EVENT_REENCRYPTION_COMPLETE: { + // A bonded peer re-encrypts instead of pairing; BTstack emits only this + // event on that path, so it answers the PAIR request too. + RP2GattClient *inst = instance_for_con_handle(sm_event_reencryption_complete_get_handle(packet)); + if (inst != nullptr) { + inst->enqueue_event_irq_(RP2GattEvent::PAIRING_RESULT, sm_event_reencryption_complete_get_status(packet), 0); + } + break; + } + default: + break; + } +} + void RP2GattClient::gatt_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size) { if (type != HCI_EVENT_PACKET) { return; @@ -447,6 +483,11 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) { case RP2GattEvent::WRITE_NO_RSP_DONE: this->finish_write_no_rsp_(event.status); break; + case RP2GattEvent::PAIRING_RESULT: + if (this->listener_ != nullptr) { + this->listener_->on_pairing_result(event.status); + } + break; } } @@ -1019,6 +1060,15 @@ int RP2GattClient::write_descriptor(uint16_t handle, const uint8_t *data, uint16 return 0; } +int RP2GattClient::pair() { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + BluetoothLock lock; + sm_request_pairing(this->con_handle_); // void API; completion via SM events + return 0; +} + int RP2GattClient::notify_characteristic(uint16_t handle, bool enable) { if (this->state_ != EngineState::READY) { return GATT_ERR_NOT_CONNECTED; @@ -1064,6 +1114,30 @@ int RP2GattClient::update_connection_params(uint16_t min_interval, uint16_t max_ return gap_update_connection_parameters(this->con_handle_, min_interval, max_interval, latency, timeout); } +conn_err_t unpair_device(uint64_t address) { + uint8_t mac[6]; + ble_device_base::uint64_to_mac_msb_first(address, mac); + bool found = false; + BluetoothLock lock; + // Exhaustive: the db keys on (type, address), so stale entries can share + // the same address bytes under different types. + for (int i = 0; i < le_device_db_max_count(); i++) { + int addr_type = 0; + bd_addr_t addr; + le_device_db_info(i, &addr_type, addr, nullptr); + if (addr_type != BD_ADDR_TYPE_UNKNOWN && memcmp(addr, mac, sizeof(bd_addr_t)) == 0) { + le_device_db_remove(i); + found = true; + } + } + if (found) { + return CONN_OK; + } + // No bond for this address; the shared error domain has no closer code + // (esp32 parity: its remove-bond call also errors for an unknown address). + return GATT_NOT_CONNECTED; +} + } // namespace esphome::bluetooth_connection #endif // USE_RP2040_BLE && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h index 0c1bc95fe9..1a3671354d 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h @@ -51,6 +51,7 @@ struct RP2GattEvent { MTU_EXCHANGED, // value = negotiated MTU QUERY_COMPLETE, // status = ATT status of the finished query WRITE_NO_RSP_DONE, // status = result of the deferred write + PAIRING_RESULT, // status = SM pairing status (0 = bonded) }; Type type; uint8_t status; @@ -89,6 +90,7 @@ class RP2GattClient final : public Component, int read_descriptor(uint16_t handle) override; int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) override; int notify_characteristic(uint16_t handle, bool enable) override; + int pair() override; int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout) override; ble_device_base::GattServiceTable get_service_table() override; @@ -120,6 +122,7 @@ class RP2GattClient final : public Component, // BTstack packet handlers (IRQ context: copy-and-enqueue only). static void hci_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); static void gatt_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); + static void sm_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); static RP2GattClient *instance_for_con_handle(hci_con_handle_t con_handle); void handle_gatt_event_irq_(uint8_t event_type, const uint8_t *packet); @@ -205,6 +208,8 @@ class RP2GattClient final : public Component, static uint8_t instance_count; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) static btstack_packet_callback_registration_t hci_event_registration; + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + static btstack_packet_callback_registration_t sm_event_registration; }; } // namespace esphome::bluetooth_connection diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 19e894600e..56b79fe1b4 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -311,12 +311,13 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR: { -#ifdef USE_ESP32 + // Both connection classes expose the same pairing surface; success is + // reported when the platform's pairing completion arrives. auto *connection = this->get_connection_(msg.address, false); if (connection != nullptr) { if (!connection->is_paired()) { auto err = connection->pair(); - if (err != ESP_OK) { + if (err != CONN_OK) { this->send_device_pairing(msg.address, false, err); } } else { @@ -326,15 +327,18 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest // Answer instead of leaving the client to time out. this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED); } -#else - // Explicit pairing is not offered (FEATURE_PAIRING is not advertised); - // peripheral-initiated security still works through the platform's SM. - this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED); -#endif break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR: { conn_err_t ret = bluetooth_connection::unpair_device(msg.address); + if (ret == CONN_OK) { + // The bond is gone; a live connection must not short-circuit the + // next PAIR as already paired. + auto *connection = this->get_connection_(msg.address, false); + if (connection != nullptr) { + connection->set_unpaired(); + } + } this->send_device_unpairing(msg.address, ret == CONN_OK, ret); break; } diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 0902aad924..e4b9cd5100 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -92,6 +92,8 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { uint16_t get_conn_id() const { return this->conn_id_; } uint64_t get_address() const { return this->address_; } bool is_paired() const { return this->paired_; } + // The proxy clears this when a bond is removed while the link is up. + void set_unpaired() { this->paired_ = false; } uint8_t get_connection_index() const { return this->connection_index_; } diff --git a/tests/components/ble_device_base/test_gatt_client_contract.cpp b/tests/components/ble_device_base/test_gatt_client_contract.cpp index fb743b2699..b4491295db 100644 --- a/tests/components/ble_device_base/test_gatt_client_contract.cpp +++ b/tests/components/ble_device_base/test_gatt_client_contract.cpp @@ -46,6 +46,16 @@ class MinimalConnection : public BLEGattConnection { void release_services() override {} }; +TEST(BleGattClientContract, PairingDefaultsAreSafeForNonPairingBackends) { + // pair() defaults to not-connected and on_pairing_result() to a no-op, so + // a backend without pairing still answers the client through the dispatch. + MinimalConnection conn; + RecordingListener listener; + conn.set_listener(&listener); + EXPECT_EQ(conn.pair(), GATT_ERR_NOT_CONNECTED); + listener.on_pairing_result(0); // must not crash: default body +} + TEST(BleGattClientContract, MinimalImplementerCompilesAndRoutesEvents) { MinimalConnection connection; RecordingListener listener; diff --git a/tests/components/bluetooth_connection/__init__.py b/tests/components/bluetooth_connection/__init__.py new file mode 100644 index 0000000000..eae98931ec --- /dev/null +++ b/tests/components/bluetooth_connection/__init__.py @@ -0,0 +1,18 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # close_service_batch compiles only under BLUETOOTH_CONNECTION_HAS_GATT; + # emit the backend define so the host build exercises it. + async def to_code_testing(config): + # These defines are global to the merged host test binary; safe + # because no co-compiled test observes them. + cg.add_define("USE_BLE_GATT_CLIENT") + cg.add_define("USE_BLUETOOTH_PROXY") + cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) + cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 1) + + manifest.to_code = to_code_testing + # The batcher sizes api protobuf messages. + manifest.dependencies = manifest.dependencies + ["api"] diff --git a/tests/components/bluetooth_connection/test_close_service_batch.cpp b/tests/components/bluetooth_connection/test_close_service_batch.cpp new file mode 100644 index 0000000000..601ff9202b --- /dev/null +++ b/tests/components/bluetooth_connection/test_close_service_batch.cpp @@ -0,0 +1,58 @@ +#include "esphome/components/bluetooth_connection/bluetooth_connection.h" + +#include + +#include "esphome/components/api/api_pb2.h" + +namespace esphome::bluetooth_connection { + +// The three cursor behaviors: a fitting service advances and continues, an +// overflowing batch with >1 service pops and retries it, and a single +// oversized service is force-advanced so the stream cannot wedge. + +static void add_service(api::BluetoothGATTGetServicesResponse &resp, uint16_t characteristics) { + resp.services.emplace_back(); + auto &svc = resp.services.back(); + svc.handle = resp.services.size(); + svc.uuid = {0x1234567890ABCDEFULL, 0xFEDCBA0987654321ULL}; + svc.characteristics.init(characteristics); + for (uint16_t i = 0; i < characteristics; i++) { + auto &chr = svc.characteristics.emplace_back(); + chr.handle = 100 + i; + chr.properties = 0x12; + chr.uuid = {0x1234567890ABCDEFULL, 0xFEDCBA0987654321ULL}; + } +} + +TEST(CloseServiceBatch, FittingServiceAdvancesAndContinues) { + api::BluetoothGATTGetServicesResponse resp; + add_service(resp, 1); + size_t current_size = 0; + int16_t cursor = 0; + EXPECT_EQ(close_service_batch(resp, current_size, cursor, 0, "AA:BB"), BatchClose::CONTINUE); + EXPECT_EQ(cursor, 1); + EXPECT_GT(current_size, 0u); +} + +TEST(CloseServiceBatch, OverflowPopsAndRetriesWithoutAdvancing) { + api::BluetoothGATTGetServicesResponse resp; + add_service(resp, 1); + add_service(resp, 1); + size_t current_size = MAX_PACKET_SIZE - 10; // any service is bigger than 10 bytes + int16_t cursor = 5; + EXPECT_EQ(close_service_batch(resp, current_size, cursor, 0, "AA:BB"), BatchClose::SEND); + EXPECT_EQ(resp.services.size(), 1u); // popped for the next batch + EXPECT_EQ(cursor, 5); // not advanced: retried next batch +} + +TEST(CloseServiceBatch, SingleOversizedServiceForceAdvances) { + api::BluetoothGATTGetServicesResponse resp; + add_service(resp, 60); // ~30 bytes per characteristic, far past the budget + ASSERT_GT(resp.services.back().calculate_size(), MAX_PACKET_SIZE); + size_t current_size = 0; + int16_t cursor = 7; + EXPECT_EQ(close_service_batch(resp, current_size, cursor, 0, "AA:BB"), BatchClose::SEND); + EXPECT_EQ(cursor, 8); // advanced despite not fitting, so the stream moves on +} + +} // namespace esphome::bluetooth_connection From 04384e0f5bcfbdba770f7133583eb5a425761ced Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2026 15:59:32 -0500 Subject: [PATCH 1334/1815] [ble_device_base] Bind the GATT backend at compile time (#18185) --- .../ble_device_base/ble_gatt_client.h | 118 ++++++++---------- esphome/components/ble_device_base/ble_hub.h | 5 +- .../bluetooth_connection_gatt_backend.h | 64 ++++++++++ .../bluetooth_connection_hub.cpp | 2 +- .../bluetooth_connection_hub.h | 32 ++--- .../bluetooth_connection_rp2.cpp | 2 + .../bluetooth_connection_rp2.h | 47 +++---- .../test_gatt_client_contract.cpp | 65 +++++----- .../bluetooth_connection/__init__.py | 1 + 9 files changed, 199 insertions(+), 137 deletions(-) create mode 100644 esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h diff --git a/esphome/components/ble_device_base/ble_gatt_client.h b/esphome/components/ble_device_base/ble_gatt_client.h index 37edc570ec..74548f578f 100644 --- a/esphome/components/ble_device_base/ble_gatt_client.h +++ b/esphome/components/ble_device_base/ble_gatt_client.h @@ -2,12 +2,13 @@ // // Platform-neutral GATT client connection contract. // -// A platform's GATT client backend (bluetooth_connection/esp32, -// bluetooth_connection/rp2) implements BLEGattConnection; consumers -// (bluetooth_proxy) drive it through this interface and receive -// completions through GattClientEventListener. All listener callbacks are -// delivered on the ESPHome main loop; borrowed data pointers are valid only -// for the duration of the call. +// Exactly one GATT backend exists per build, so BLEGattConnection is a +// compile-time alias (bluetooth_connection_gatt_backend.h), not an abstract +// interface. +// The hub BluetoothConnection wrapper drives it and receives completions +// through its event-sink methods, which the backend calls directly. All sink +// calls are delivered on the ESPHome main loop; borrowed data pointers are +// valid only for the duration of the call. // // Error domain (plain int, forwarded to the API without translation): // 0 success @@ -29,6 +30,7 @@ #include "ble_client_state.h" #include "ble_device.h" +#include #include namespace esphome::ble_device_base { @@ -76,67 +78,53 @@ struct GattServiceTable { uint16_t descriptor_count{0}; }; -/// Completion/event sink for a GATT connection. Implemented by the consumer -/// (bluetooth_proxy's connection wrapper). Every callback runs on the main loop. -class GattClientEventListener { - public: - virtual ~GattClientEventListener() = default; - - /// Connected (with negotiated MTU) or disconnected/connect-failed - /// (error = HCI status or disconnect reason). - virtual void on_connection_state(bool connected, uint16_t mtu, int error) = 0; - /// Service discovery finished; on success the service table is populated. - virtual void on_service_discovery_done(int error) = 0; - /// Characteristic or descriptor read finished. data/len valid during the call. - virtual void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) = 0; - /// Characteristic write-with-response or descriptor write finished. - virtual void on_write_result(uint16_t handle, int error) = 0; - /// Notification/indication registration state changed. - virtual void on_notify_state(uint16_t handle, bool enabled, int error) = 0; - /// Notification/indication data from the peer. data/len valid during the call. - virtual void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) = 0; - virtual void on_pairing_result(int status) {} +// The BLEGattConnection op surface, asserted where the alias binds +// (bluetooth_connection_gatt_backend.h). Operations return 0 when accepted (completion arrives +// through the sink) or a synchronous error (busy, not connected, stack +// rejection); one operation may be outstanding at a time. Semantics beyond +// the signatures: +// - connect: addr_type is a BLE_ADDR_TYPE_* constant (ble_device.h). +// - disconnect: also cancels a connect in progress. +// - notify_characteristic: local registration only; the CCCD write is the +// API client's responsibility (a plain write_descriptor). +// - get_service_table/release_services: backend-owned transient storage, +// released after streaming (release is idempotent). +// - completions: connect and disconnect land in on_connection_state, +// discover_services in on_service_discovery_done, pair in +// on_pairing_result, reads in on_read_result, notify_characteristic in +// on_notify_state, characteristic writes with response and descriptor +// writes in on_write_result. +template +concept BLEGattConnectionContract = requires(T conn, Sink *sink, const uint8_t *data) { + conn.set_listener(sink); + { conn.connect(uint64_t{}, uint8_t{}) } -> std::same_as; + { conn.disconnect() } -> std::same_as; + { conn.discover_services() } -> std::same_as; + { conn.read_characteristic(uint16_t{}) } -> std::same_as; + { conn.write_characteristic(uint16_t{}, data, uint16_t{}, true) } -> std::same_as; + { conn.read_descriptor(uint16_t{}) } -> std::same_as; + { conn.write_descriptor(uint16_t{}, data, uint16_t{}) } -> std::same_as; + { conn.notify_characteristic(uint16_t{}, true) } -> std::same_as; + { conn.pair() } -> std::same_as; + { conn.update_connection_params(uint16_t{}, uint16_t{}, uint16_t{}, uint16_t{}) } -> std::same_as; + { conn.get_service_table() } -> std::same_as; + { conn.release_services() } -> std::same_as; }; -/// One GATT client connection slot. Operations return 0 when accepted -/// (completion arrives via the listener) or a synchronous error code -/// (busy, not connected, stack rejection). One operation may be outstanding -/// at a time; callers see a synchronous error otherwise. -class BLEGattConnection { - public: - virtual ~BLEGattConnection() = default; - - void set_listener(GattClientEventListener *listener) { this->listener_ = listener; } - - /// Start connecting to a peer. addr_type is a BLE_ADDR_TYPE_* constant - /// (ble_device.h). Completion: on_connection_state(). - virtual int connect(uint64_t address, uint8_t addr_type) = 0; - /// Disconnect (or cancel a connect in progress). Completion: on_connection_state(). - virtual int disconnect() = 0; - /// Discover the peer's services/characteristics/descriptors into the - /// service table. Completion: on_service_discovery_done(). - virtual int discover_services() = 0; - virtual int read_characteristic(uint16_t handle) = 0; - virtual int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) = 0; - virtual int read_descriptor(uint16_t handle) = 0; - virtual int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) = 0; - /// Enable/disable delivery of on_notify_data() for a characteristic value - /// handle. Local registration only — the CCCD write is the API client's - /// responsibility (it arrives as a plain write_descriptor). - virtual int notify_characteristic(uint16_t handle, bool enable) = 0; - /// Initiate pairing on the live link. Completion: on_pairing_result(). - virtual int pair() { return GATT_ERR_NOT_CONNECTED; } - virtual int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, - uint16_t timeout) = 0; - - /// Backend-owned service table (see GattServiceTable lifetime). - virtual GattServiceTable get_service_table() = 0; - /// Free the transient service table storage. Call after streaming; - /// idempotent (a call with no table held is a no-op). - virtual void release_services() = 0; - - protected: - GattClientEventListener *listener_{nullptr}; +// The event sink the backend calls directly (the hub BluetoothConnection +// wrapper), asserted where the wrapper is defined: on_connection_state +// carries the negotiated MTU and an HCI status/disconnect reason. The +// requirements check call validity, not exact parameter types; keep sink +// parameters at the documented widths (uint16_t handles and lengths). +template +concept GattClientEventSinkContract = requires(S sink, const uint8_t *data) { + { sink.on_connection_state(true, uint16_t{}, int{}) } -> std::same_as; + { sink.on_service_discovery_done(int{}) } -> std::same_as; + { sink.on_read_result(uint16_t{}, data, uint16_t{}, int{}) } -> std::same_as; + { sink.on_write_result(uint16_t{}, int{}) } -> std::same_as; + { sink.on_notify_state(uint16_t{}, true, int{}) } -> std::same_as; + { sink.on_notify_data(uint16_t{}, data, uint16_t{}) } -> std::same_as; + { sink.on_pairing_result(int{}) } -> std::same_as; }; } // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/ble_hub.h b/esphome/components/ble_device_base/ble_hub.h index d9a7731504..f4ad051430 100644 --- a/esphome/components/ble_device_base/ble_hub.h +++ b/esphome/components/ble_device_base/ble_hub.h @@ -58,8 +58,9 @@ struct HubCapabilities { /// may only see them where the receiver merges per address (Home Assistant does). bool merges_scan_response; /// GATT client connections are available: the platform has a - /// bluetooth_connection backend implementing ble_device_base::BLEGattConnection - /// (ble_gatt_client.h). Today: esp32 and rp2. + /// bluetooth_connection backend (rp2 binds the BLEGattConnection alias in + /// bluetooth_connection_gatt_backend.h; esp32 uses its Bluedroid client). + /// Today: esp32 and rp2. bool gatt; /// request_scan_mode() is honored at runtime. Distinct from active_scan: /// a passive-only controller (bk72xx) can never switch, and a hub may diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h b/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h new file mode 100644 index 0000000000..d8792b88c1 --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h @@ -0,0 +1,64 @@ +// bluetooth_connection_gatt_backend.h +// +// Binds ble_device_base::BLEGattConnection to the build's one GATT backend. +// Backend and consumer both live in this component, so the ladder does too; +// backends implement ble_gatt_client.h (the neutral contract). + +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_BLE_GATT_CLIENT + +#include "esphome/components/ble_device_base/ble_gatt_client.h" + +#if defined(USE_RP2040_BLE) +#include "bluetooth_connection_rp2.h" +#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::RP2GattClient +#elif defined(USE_BLE_GATT_CLIENT_STUB_BACKEND) +// Emitted only by the host unit-test manifest: the tests compile the hub +// wrapper standalone, so bind a do-nothing backend. Every other backend-less +// build hits the #error below. +namespace esphome::bluetooth_connection { + +class BluetoothConnection; + +class StubGattBackend { + public: + void set_listener(BluetoothConnection *listener) {} + int connect(uint64_t address, uint8_t addr_type) { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int disconnect() { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int discover_services() { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int read_characteristic(uint16_t handle) { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + int read_descriptor(uint16_t handle) { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + int notify_characteristic(uint16_t handle, bool enable) { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int pair() { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + ble_device_base::GattServiceTable get_service_table() { return {}; } + void release_services() {} +}; + +} // namespace esphome::bluetooth_connection +#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::StubGattBackend +#else +#error "USE_BLE_GATT_CLIENT is set but this build has no GATT backend; add an alias arm here" +#endif + +namespace esphome::ble_device_base { + +using BLEGattConnection = ESPHOME_BLE_GATT_CONNECTION_TYPE; +static_assert(BLEGattConnectionContract, + "The build's GATT backend is missing part of the BLEGattConnection surface (ble_gatt_client.h)"); +#undef ESPHOME_BLE_GATT_CONNECTION_TYPE + +} // namespace esphome::ble_device_base + +#endif // USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index b69a07fc31..ec03f18e1d 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -96,7 +96,7 @@ void BluetoothConnection::reset_connection_(conn_err_t reason) { this->proxy_->reset_connection_slot_(this, reason); } -// ---- GattClientEventListener ---- +// ---- backend event sink ---- void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int error) { if (connected && this->address_ == 0) { diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index 34e400ac01..e79ee9e7a8 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -1,6 +1,6 @@ -// Hub-platform BluetoothConnection: drives a platform GATT client backend -// through the neutral ble_device_base::BLEGattConnection interface and -// translates its events into the same API messages the esp32 class emits. +// Hub-platform BluetoothConnection: drives the build's GATT backend (the +// ble_device_base::BLEGattConnection alias) and translates its events into +// the same API messages the esp32 class emits. // Presents the identical method surface, so the proxy's GATT dispatch // compiles against either class unchanged. @@ -13,7 +13,7 @@ #include "bluetooth_connection.h" #include "esphome/components/ble_device_base/ble_client_state.h" -#include "esphome/components/ble_device_base/ble_gatt_client.h" +#include "bluetooth_connection_gatt_backend.h" #include "esphome/core/helpers.h" namespace esphome::bluetooth_proxy { @@ -25,7 +25,7 @@ namespace esphome::bluetooth_connection { using ClientState = ble_device_base::ClientState; using ConnectionType = ble_device_base::ConnectionType; -class BluetoothConnection final : public ble_device_base::GattClientEventListener { +class BluetoothConnection final { public: /// Wire the platform backend. Called from codegen before setup. void set_backend(ble_device_base::BLEGattConnection *backend) { @@ -83,14 +83,14 @@ class BluetoothConnection final : public ble_device_base::GattClientEventListene this->check_disconnect_timeout_(); } - // ---- ble_device_base::GattClientEventListener ---- - void on_connection_state(bool connected, uint16_t mtu, int error) override; - void on_service_discovery_done(int error) override; - void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) override; - void on_write_result(uint16_t handle, int error) override; - void on_notify_state(uint16_t handle, bool enabled, int error) override; - void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) override; - void on_pairing_result(int status) override; + // ---- backend event sink (called directly by the backend, main loop) ---- + void on_connection_state(bool connected, uint16_t mtu, int error); + void on_service_discovery_done(int error); + void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error); + void on_write_result(uint16_t handle, int error); + void on_notify_state(uint16_t handle, bool enabled, int error); + void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len); + void on_pairing_result(int status); protected: friend class bluetooth_proxy::BluetoothProxy; @@ -102,8 +102,7 @@ class BluetoothConnection final : public ble_device_base::GattClientEventListene conn_err_t check_connected_op_(const char *action, const char *type) const; void log_gatt_operation_error_(const char *operation, uint16_t handle, int status); - // Memory optimized layout for 32-bit systems (a vptr precedes: pointers and - // 2-byte members first fill to an 8-byte boundary before address_) + // Memory optimized layout for 32-bit systems // Group 1: Pointers (4 bytes each, naturally aligned) bluetooth_proxy::BluetoothProxy *proxy_{nullptr}; ble_device_base::BLEGattConnection *backend_{nullptr}; @@ -129,6 +128,9 @@ class BluetoothConnection final : public ble_device_base::GattClientEventListene bool services_discovered_{false}; }; +static_assert(ble_device_base::GattClientEventSinkContract, + "The hub wrapper is missing part of the event-sink surface (ble_gatt_client.h)"); + } // namespace esphome::bluetooth_connection #endif // !USE_ESP32 && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index cd7577e7f7..dc730659f5 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -1,4 +1,6 @@ #include "bluetooth_connection_rp2.h" + +#include "bluetooth_connection_hub.h" #include "bluetooth_connection.h" #if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h index 1a3671354d..d5bf76e6ee 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h @@ -1,11 +1,10 @@ // RP2 (Pico W / Pico 2 W) GATT client backend over BTstack. // -// Implements ble_device_base::BLEGattConnection for the hub BluetoothConnection -// wrapper. BTstack packet handlers run in the CYW43 async-context low-priority -// IRQ (or on the main-loop stack during BluetoothLock release), so handlers -// only copy into per-instance lock-free queues/storage; loop() drains them and -// drives the state machine. Every BTstack call issued from the main loop is -// wrapped in BluetoothLock. +// The build's ble_device_base::BLEGattConnection backend (bound by alias in +// bluetooth_connection_gatt_backend.h) for the hub BluetoothConnection wrapper. BTstack packet handlers run in the +// CYW43 async-context low-priority IRQ (or on the main-loop stack during BluetoothLock release), so handlers only copy +// into per-instance lock-free queues/storage; loop() drains them and drives the state machine. Every BTstack call +// issued from the main loop is wrapped in BluetoothLock. #pragma once @@ -27,6 +26,8 @@ namespace esphome::bluetooth_connection { +class BluetoothConnection; + // Caps for the transient service table. Sized generously for real devices // (typical peripherals expose < 8 services / < 30 characteristics); a peer // exceeding a cap fails discovery with INSUFFICIENT_RESOURCES rather than @@ -72,29 +73,28 @@ static constexpr uint8_t RP2_GATT_EVENT_QUEUE_SIZE = 8; // full 512 B ATT payload, so depth buys burst tolerance at ~516 B per slot. static constexpr uint8_t RP2_GATT_NOTIFY_QUEUE_SIZE = 4; -class RP2GattClient final : public Component, - public ble_device_base::BLEGattConnection, - public Parented { +class RP2GattClient final : public Component, public Parented { public: void setup() override; void loop() override; void dump_config() override; float get_setup_priority() const override; - // ---- ble_device_base::BLEGattConnection ---- - int connect(uint64_t address, uint8_t addr_type) override; - int disconnect() override; - int discover_services() override; - int read_characteristic(uint16_t handle) override; - int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) override; - int read_descriptor(uint16_t handle) override; - int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) override; - int notify_characteristic(uint16_t handle, bool enable) override; - int pair() override; - int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, - uint16_t timeout) override; - ble_device_base::GattServiceTable get_service_table() override; - void release_services() override; + void set_listener(BluetoothConnection *listener) { this->listener_ = listener; } + + // ---- ble_device_base::BLEGattConnection contract ---- + int connect(uint64_t address, uint8_t addr_type); + int disconnect(); + int discover_services(); + int read_characteristic(uint16_t handle); + int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response); + int read_descriptor(uint16_t handle); + int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len); + int notify_characteristic(uint16_t handle, bool enable); + int pair(); + int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout); + ble_device_base::GattServiceTable get_service_table(); + void release_services(); protected: // Link/engine state. Discovery and GATT ops have their own cursors below — @@ -150,6 +150,7 @@ class RP2GattClient final : public Component, } // Group 1: containers / large storage + BluetoothConnection *listener_{nullptr}; ServiceArena *arena_{nullptr}; esphome::LockFreeQueue event_queue_; esphome::EventPool event_pool_; diff --git a/tests/components/ble_device_base/test_gatt_client_contract.cpp b/tests/components/ble_device_base/test_gatt_client_contract.cpp index b4491295db..25b6cbf002 100644 --- a/tests/components/ble_device_base/test_gatt_client_contract.cpp +++ b/tests/components/ble_device_base/test_gatt_client_contract.cpp @@ -1,5 +1,8 @@ // The GATT client contract compiles in no real build until a hub backend is // configured; this TU pins it on the host so the header cannot rot unseen. +// The contract is a concept (BLEGattConnection is a per-platform alias), so +// the minimal backend here proves the concept stays satisfiable and routes +// events through the duck-typed sink the way a real backend does. #define USE_BLE_GATT_CLIENT #include "esphome/components/ble_device_base/ble_gatt_client.h" @@ -8,57 +11,57 @@ namespace esphome::ble_device_base::testing { -class RecordingListener : public GattClientEventListener { - public: - void on_connection_state(bool connected, uint16_t mtu, int error) override { this->connected_ = connected; } - void on_service_discovery_done(int error) override { this->discovery_error_ = error; } - void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) override {} - void on_write_result(uint16_t handle, int error) override {} - void on_notify_state(uint16_t handle, bool enabled, int error) override {} - void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) override {} +struct RecordingSink { + void on_connection_state(bool connected, uint16_t mtu, int error) { this->connected_ = connected; } + void on_service_discovery_done(int error) { this->discovery_error_ = error; } + void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {} + void on_write_result(uint16_t handle, int error) {} + void on_notify_state(uint16_t handle, bool enabled, int error) {} + void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {} + void on_pairing_result(int status) {} bool connected_{false}; int discovery_error_{0}; }; -class MinimalConnection : public BLEGattConnection { +static_assert(GattClientEventSinkContract, "the recording sink must cover the full event-sink surface"); + +class MinimalConnection { public: - int connect(uint64_t address, uint8_t addr_type) override { + void set_listener(RecordingSink *listener) { this->listener_ = listener; } + + int connect(uint64_t address, uint8_t addr_type) { if (this->listener_ != nullptr) this->listener_->on_connection_state(true, 517, 0); return 0; } - int disconnect() override { return 0; } - int discover_services() override { + int disconnect() { return 0; } + int discover_services() { if (this->listener_ != nullptr) this->listener_->on_service_discovery_done(0); return 0; } - int read_characteristic(uint16_t handle) override { return GATT_ERR_NOT_CONNECTED; } - int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) override { return 0; } - int read_descriptor(uint16_t handle) override { return 0; } - int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) override { return 0; } - int notify_characteristic(uint16_t handle, bool enable) override { return 0; } - int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, - uint16_t timeout) override { + int read_characteristic(uint16_t handle) { return GATT_ERR_NOT_CONNECTED; } + int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { return 0; } + int read_descriptor(uint16_t handle) { return 0; } + int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) { return 0; } + int notify_characteristic(uint16_t handle, bool enable) { return 0; } + int pair() { return GATT_ERR_NOT_CONNECTED; } + int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout) { return 0; } - GattServiceTable get_service_table() override { return {}; } - void release_services() override {} + GattServiceTable get_service_table() { return {}; } + void release_services() {} + + protected: + RecordingSink *listener_{nullptr}; }; -TEST(BleGattClientContract, PairingDefaultsAreSafeForNonPairingBackends) { - // pair() defaults to not-connected and on_pairing_result() to a no-op, so - // a backend without pairing still answers the client through the dispatch. - MinimalConnection conn; - RecordingListener listener; - conn.set_listener(&listener); - EXPECT_EQ(conn.pair(), GATT_ERR_NOT_CONNECTED); - listener.on_pairing_result(0); // must not crash: default body -} +static_assert(BLEGattConnectionContract, + "a minimal backend must satisfy the contract the alias asserts"); TEST(BleGattClientContract, MinimalImplementerCompilesAndRoutesEvents) { MinimalConnection connection; - RecordingListener listener; + RecordingSink listener; connection.set_listener(&listener); EXPECT_EQ(connection.connect(0xAABBCCDDEEFFULL, 0), 0); EXPECT_TRUE(listener.connected_); diff --git a/tests/components/bluetooth_connection/__init__.py b/tests/components/bluetooth_connection/__init__.py index eae98931ec..eb6e174c0c 100644 --- a/tests/components/bluetooth_connection/__init__.py +++ b/tests/components/bluetooth_connection/__init__.py @@ -9,6 +9,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # These defines are global to the merged host test binary; safe # because no co-compiled test observes them. cg.add_define("USE_BLE_GATT_CLIENT") + cg.add_define("USE_BLE_GATT_CLIENT_STUB_BACKEND") cg.add_define("USE_BLUETOOTH_PROXY") cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 1) From 8c74e3d5efa3387ca45f0d2ad3b134d9e0017ecc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2026 15:59:49 -0500 Subject: [PATCH 1335/1815] [bluetooth_proxy] Deliver scanner state through the hub callback (#18175) --- esphome/components/ble_device_base/ble_hub.h | 35 ++++++++++++ .../components/bluetooth_proxy/__init__.py | 8 +-- .../bluetooth_proxy/bluetooth_proxy.cpp | 56 ++++++++++--------- .../bluetooth_proxy/bluetooth_proxy.h | 16 +----- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 5 ++ .../esp32_ble_tracker/esp32_ble_tracker.h | 14 +---- esphome/core/defines.h | 1 + .../ble_device_base/test_slot_counter.py | 10 ++-- .../test_scanner_state_callback.cpp | 52 +++++++++++++++++ 9 files changed, 136 insertions(+), 61 deletions(-) create mode 100644 tests/components/ble_device_base/test_scanner_state_callback.cpp diff --git a/esphome/components/ble_device_base/ble_hub.h b/esphome/components/ble_device_base/ble_hub.h index f4ad051430..aa813d03db 100644 --- a/esphome/components/ble_device_base/ble_hub.h +++ b/esphome/components/ble_device_base/ble_hub.h @@ -49,6 +49,28 @@ struct RawAdvertisementCallback { void invoke(const RawAdvertisement &adv) const { this->fn(this->instance, adv); } }; +/// Scanner lifecycle, wire-value aligned with the api enum so consumers cast +/// directly (pinned by static_asserts at the cast sites). +enum class ScannerState : uint8_t { + IDLE = 0, + STARTING = 1, + RUNNING = 2, + FAILED = 3, + STOPPING = 4, + STOPPED = 5, +}; + +/// Subscriber slot for scanner-state transitions; same shape as +/// RawAdvertisementCallback, delivered on the ESPHome main loop. Hubs that +/// cannot push drop the registration and the consumer falls back to polling +/// scan_running(). +struct ScannerStateCallback { + void *instance{nullptr}; + void (*fn)(void *instance, ScannerState state){nullptr}; + bool is_set() const { return this->fn != nullptr; } + void invoke(ScannerState state) const { this->fn(this->instance, state); } +}; + /// What a tracker's controller/SDK can do — consumers branch on data, not #ifdefs. struct HubCapabilities { /// Controller can send scan requests (active scanning). @@ -79,6 +101,19 @@ class BLEHub { /// Wire the raw-advertisement stream (bluetooth_proxy). One consumer at a time. virtual void set_raw_advertisement_callback(RawAdvertisementCallback callback) = 0; +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + /// Push subscriber for scanner-state transitions; hubs that can push + /// invoke scanner_state_callback_ where their state changes. Compiled only + /// when a subscriber exists (bluetooth_proxy emits the define), so + /// subscriber-less builds carry no storage. + void set_scanner_state_callback(ScannerStateCallback callback) { this->scanner_state_callback_ = callback; } + + protected: + ScannerStateCallback scanner_state_callback_{}; + + public: +#endif // USE_BLE_SCANNER_STATE_CALLBACK + virtual HubCapabilities get_capabilities() const = 0; /// Adapter MAC in printable (MSB-first) order, out[0] = MSB. diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index 4aa4195ff9..8d9aa88bd8 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -374,12 +374,10 @@ async def _to_code_esp32(config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_active(config[CONF_ACTIVE])) - # Advertisements arrive through the hub raw callback (installed in - # setup()); only the scanner-state listener still registers with the - # tracker directly. + # Advertisements and scanner state arrive through the hub callbacks + # (installed in setup()); the tracker stays typed for scan-mode calls. tracker = await cg.get_variable(config[esp32_ble_tracker.CONF_ESP32_BLE_ID]) cg.add(var.set_parent(tracker)) - await esp32_ble_tracker.register_scanner_state_listener(var, config) # Define max connections for protobuf fixed array connection_count = len(config.get(CONF_CONNECTIONS, [])) @@ -428,3 +426,5 @@ async def to_code(config: ConfigType) -> None: cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) cg.add_define("USE_BLUETOOTH_PROXY") + # Compiles the scanner-state push slot into the hub (see ble_hub.h). + cg.add_define("USE_BLE_SCANNER_STATE_CALLBACK") diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 56b79fe1b4..3f44adbef4 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -25,15 +25,22 @@ static_assert(sizeof(((api::BluetoothLERawAdvertisement *) nullptr)->data) == 62 BluetoothProxy::BluetoothProxy() { global_bluetooth_proxy = this; } -#ifdef USE_ESP32 +// The neutral enum's values are the wire values. +static_assert(static_cast(ble_device_base::ScannerState::IDLE) == api::enums::BLUETOOTH_SCANNER_STATE_IDLE); +static_assert(static_cast(ble_device_base::ScannerState::STARTING) == + api::enums::BLUETOOTH_SCANNER_STATE_STARTING); +static_assert(static_cast(ble_device_base::ScannerState::RUNNING) == + api::enums::BLUETOOTH_SCANNER_STATE_RUNNING); +static_assert(static_cast(ble_device_base::ScannerState::FAILED) == + api::enums::BLUETOOTH_SCANNER_STATE_FAILED); +static_assert(static_cast(ble_device_base::ScannerState::STOPPING) == + api::enums::BLUETOOTH_SCANNER_STATE_STOPPING); +static_assert(static_cast(ble_device_base::ScannerState::STOPPED) == + api::enums::BLUETOOTH_SCANNER_STATE_STOPPED); -void BluetoothProxy::on_scanner_state(esp32_ble_tracker::ScannerState state) { - if (this->api_connection_ != nullptr) { - this->send_bluetooth_scanner_state_(state); - } -} - -void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state) { +bool BluetoothProxy::send_bluetooth_scanner_state_(ble_device_base::ScannerState state) { + if (this->api_connection_ == nullptr) + return false; api::BluetoothScannerStateResponse resp; resp.state = static_cast(state); resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE @@ -41,30 +48,21 @@ void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerSta resp.configured_mode = this->configured_scan_active_ ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; - this->api_connection_->send_message(resp); + return this->api_connection_->send_message(resp); } -#else // !USE_ESP32 - -void BluetoothProxy::send_bluetooth_scanner_state_() { +#ifndef USE_ESP32 +void BluetoothProxy::send_polled_scanner_state_() { // One read feeds both the frame and the change detector; the detector only // advances if the frame was accepted, so a dropped send (WOULD_BLOCK on a // full TX buffer) is retried from loop() instead of leaving a stale state. const bool running = this->hub_->scan_running(); - api::BluetoothScannerStateResponse resp; - resp.state = running ? api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_RUNNING - : api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_IDLE; - resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE - : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; - resp.configured_mode = this->configured_scan_active_ - ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE - : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; - if (this->api_connection_->send_message(resp)) { + if (this->send_bluetooth_scanner_state_(running ? ble_device_base::ScannerState::RUNNING + : ble_device_base::ScannerState::IDLE)) { this->last_scan_running_ = running; } } - -#endif // USE_ESP32 +#endif // !USE_ESP32 void BluetoothProxy::setup() { // BLUETOOTH_PROXY_MAX_CONNECTIONS is 0 on an advertisement-only proxy. @@ -77,6 +75,9 @@ void BluetoothProxy::setup() { this->hub_->set_raw_advertisement_callback({this, [](void *self, const ble_device_base::RawAdvertisement &adv) { static_cast(self)->on_raw_advertisement_(adv); }}); + this->hub_->set_scanner_state_callback({this, [](void *self, ble_device_base::ScannerState state) { + static_cast(self)->send_bluetooth_scanner_state_(state); + }}); } // The hub delivers raw advertisements on the ESPHome main loop. @@ -510,9 +511,10 @@ void BluetoothProxy::loop() { return; } - // The hub has no scanner-state listener interface; poll and report on change. + // This hub doesn't push scanner-state transitions; poll and report on + // change. A hub gaining push must also refresh last_scan_running_ here. if (this->hub_->scan_running() != this->last_scan_running_) { - this->send_bluetooth_scanner_state_(); + this->send_polled_scanner_state_(); } this->flush_pending_advertisements_(); @@ -600,7 +602,7 @@ void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { // Reports the mode change; the sender also refreshes last_scan_running_, so // a failed restart (scan_running_ dropped by the tracker) is not reported // again by loop() on the next tick. - this->send_bluetooth_scanner_state_(); + this->send_polled_scanner_state_(); } } @@ -623,7 +625,7 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection #ifdef USE_ESP32 this->send_bluetooth_scanner_state_(this->parent_()->get_scanner_state()); #else - this->send_bluetooth_scanner_state_(); + this->send_polled_scanner_state_(); #endif } diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index ed39a697aa..86d45c144a 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -72,11 +72,7 @@ enum BluetoothProxySubscriptionFlag : uint32_t { SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0, }; -#ifdef USE_ESP32 -class BluetoothProxy final : public esp32_ble_tracker::BLEScannerStateListener, public Component { -#else class BluetoothProxy final : public Component { -#endif #ifdef BLUETOOTH_CONNECTION_HAS_GATT // Allow the connection to update connections_free_response_ friend bluetooth_connection::BluetoothConnection; @@ -135,11 +131,6 @@ class BluetoothProxy final : public Component { void set_active(bool active) { this->active_ = active; } bool has_active() { return this->active_; } -#ifdef USE_ESP32 - /// BLEScannerStateListener interface - void on_scanner_state(esp32_ble_tracker::ScannerState state) override; -#endif - uint32_t get_legacy_version() const { if (!this->active_) { return LEGACY_PASSIVE_ONLY_VERSION; @@ -213,10 +204,9 @@ class BluetoothProxy final : public Component { } protected: -#ifdef USE_ESP32 - void send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state); -#else - void send_bluetooth_scanner_state_(); + bool send_bluetooth_scanner_state_(ble_device_base::ScannerState state); +#ifndef USE_ESP32 + void send_polled_scanner_state_(); #endif void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw); diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index cec2f230f8..e51b293bfe 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -422,6 +422,11 @@ void ESP32BLETracker::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_i void ESP32BLETracker::set_scanner_state_(ScannerState state) { this->scanner_state_ = state; this->state_version_++; +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + if (this->scanner_state_callback_.is_set()) { + this->scanner_state_callback_.invoke(state); + } +#endif #ifdef ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT for (auto *listener : this->scanner_state_listeners_) { listener->on_scanner_state(state); diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 88642fff6b..c570c28122 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -95,18 +95,8 @@ using ClientState = ble_device_base::ClientState; using ConnectionType = ble_device_base::ConnectionType; using ble_device_base::client_state_to_string; -enum class ScannerState { - // Scanner is idle, init state - IDLE, - // Scanner is starting - STARTING, - // Scanner is running - RUNNING, - // Scanner failed to start - FAILED, - // Scanner is stopping - STOPPING, -}; +// Neutral scanner lifecycle re-exported for backward compatibility. +using ScannerState = ble_device_base::ScannerState; /** Listener interface for BLE scanner state changes. * diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 1685467a4b..7ddc607c5c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -252,6 +252,7 @@ // platforms whose API/network types the proxy header cannot assume. #if defined(USE_ESP32) || defined(USE_LIBRETINY) || defined(USE_RP2) #define USE_BLUETOOTH_PROXY +#define USE_BLE_SCANNER_STATE_CALLBACK // Mirror the codegen values per platform: _to_code_esp32() emits the connection // count (default 3), _to_code_ble_hub() emits the slot count (1 on rp2, 0 on // advertisement-only hubs) — so static analysis checks the same diff --git a/tests/component_tests/ble_device_base/test_slot_counter.py b/tests/component_tests/ble_device_base/test_slot_counter.py index 1c1499cb2d..daa2884588 100644 --- a/tests/component_tests/ble_device_base/test_slot_counter.py +++ b/tests/component_tests/ble_device_base/test_slot_counter.py @@ -103,17 +103,17 @@ def test_esp32_tracker_handler_counts( assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") is None -def test_esp32_bluetooth_proxy_requests_scanner_state_slot( +def test_esp32_bluetooth_proxy_requests_client_slots_only( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: - """The proxy requests one scanner state slot and a client slot per - connection (three by default with active: true); advertisements arrive - through the hub raw callback, so no listener slot exists.""" + """The proxy requests a client slot per connection (three by default with + active: true); advertisements and scanner state arrive through the hub + callbacks, so no listener or scanner-state slot exists.""" generate_main(component_config_path("esp32_bluetooth_proxy.yaml")) assert ( get_define_value("ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT") - == "1" + is None ) assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") is None assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") == "3" diff --git a/tests/components/ble_device_base/test_scanner_state_callback.cpp b/tests/components/ble_device_base/test_scanner_state_callback.cpp new file mode 100644 index 0000000000..7515b2f38e --- /dev/null +++ b/tests/components/ble_device_base/test_scanner_state_callback.cpp @@ -0,0 +1,52 @@ +#include + +#include + +#include "esphome/components/ble_device_base/ble_hub.h" + +namespace esphome::ble_device_base::testing { + +// Pins the ScannerStateCallback slot semantics, mirroring test_raw_callback: +// a default-constructed slot is "no subscriber", a set slot delivers the +// state, and a new registration replaces the old. +namespace { + +struct CapturingSubscriber { + ScannerState last{ScannerState::IDLE}; + int calls{0}; + + static void trampoline(void *self, ScannerState state) { + auto *sub = static_cast(self); + sub->last = state; + sub->calls++; + } +}; + +} // namespace + +TEST(ScannerStateCallback, DefaultConstructedSlotIsNotSet) { + const ScannerStateCallback callback{}; + EXPECT_FALSE(callback.is_set()); +} + +TEST(ScannerStateCallback, SubscriberSeesState) { + CapturingSubscriber subscriber; + ScannerStateCallback callback{&subscriber, CapturingSubscriber::trampoline}; + ASSERT_TRUE(callback.is_set()); + callback.invoke(ScannerState::RUNNING); + EXPECT_EQ(subscriber.calls, 1); + EXPECT_EQ(subscriber.last, ScannerState::RUNNING); +} + +TEST(ScannerStateCallback, NewSubscriberReplacesOld) { + CapturingSubscriber first; + CapturingSubscriber second; + ScannerStateCallback callback{&first, CapturingSubscriber::trampoline}; + callback = {&second, CapturingSubscriber::trampoline}; + callback.invoke(ScannerState::STOPPED); + EXPECT_EQ(first.calls, 0); + EXPECT_EQ(second.calls, 1); + EXPECT_EQ(second.last, ScannerState::STOPPED); +} + +} // namespace esphome::ble_device_base::testing From c7d6b4aaa4643d97ea21e097872d2f03178adc3f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2026 19:13:07 -0500 Subject: [PATCH 1336/1815] [esp32_ble_tracker] Retire the raw listener path and parser-type enum (#18177) --- .../bluetooth_connection_esp32.cpp | 6 -- .../bluetooth_connection_esp32.h | 3 +- .../bluetooth_proxy/bluetooth_proxy.h | 23 +------- esphome/components/esp32_ble/ble.cpp | 16 +++++- esphome/components/esp32_ble/ble.h | 2 + .../esp32_ble_tracker/esp32_ble_tracker.cpp | 57 ++----------------- .../esp32_ble_tracker/esp32_ble_tracker.h | 17 ++---- 7 files changed, 30 insertions(+), 94 deletions(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp index be6fa4c6c5..f5c59ca43a 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp @@ -479,12 +479,6 @@ esp_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enabl return this->check_and_log_error_("esp_ble_gattc_unregister_for_notify", err); } -esp32_ble_tracker::AdvertisementParserType BluetoothConnection::get_advertisement_parser_type() { - // RAW keeps the tracker from building parsed ESPBTDevice objects for the - // proxy's connections (the proxy itself consumes the hub raw callback). - return esp32_ble_tracker::AdvertisementParserType::RAW_ADVERTISEMENTS; -} - } // namespace esphome::bluetooth_connection #endif // USE_ESP32 diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h index 531ff311a7..fb60d93e9c 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h @@ -21,7 +21,8 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase { bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) override; void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; - esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; + // The proxy's connections never consume parsed ESPBTDevice objects. + bool wants_parsed_advertisements() override { return false; } esp_err_t read_characteristic(uint16_t handle); esp_err_t write_characteristic(uint16_t handle, const uint8_t *data, size_t length, bool response); diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 86d45c144a..9fc975680e 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -20,10 +20,6 @@ #include "esphome/components/bluetooth_connection/bluetooth_connection_esp32.h" -#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID -#include -#endif -#include #else #include "esphome/components/ble_device_base/ble_hub.h" #ifdef USE_BLE_GATT_CLIENT @@ -179,28 +175,15 @@ class BluetoothProxy final : public Component { } void get_bluetooth_mac_address_pretty(std::span output) { -#ifdef USE_ESP32 - const uint8_t *mac = esp_bt_dev_get_address(); - if (mac != nullptr) { - format_mac_addr_upper(mac, output.data()); - } else { - output[0] = '\0'; - } -#else uint8_t mac[6] = {}; this->hub_->get_adapter_mac(mac); - // Mirror the esp32 arm's unavailable -> empty-string fallback: some hubs - // (rp2040's BTstack) only learn the address once the link layer is up, and - // report all-zero until then. - bool nonzero = false; - for (uint8_t b : mac) - nonzero |= b != 0; - if (nonzero) { + // Unavailable -> empty string: some hubs (rp2040's BTstack) only learn + // the address once the link layer is up, and report all-zero until then. + if (mac_address_is_valid(mac)) { format_mac_addr_upper(mac, output.data()); } else { output[0] = '\0'; } -#endif } protected: diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index fb75e8837f..d11683ab35 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -674,11 +674,23 @@ void ESP32BLE::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gat } #endif +void ESP32BLE::get_mac_msb_first(uint8_t out[6]) const { + // The running stack owns the address (on hosted controllers it lives in + // the remote chip's efuse); null before init becomes all-zero. + const uint8_t *mac = esp_bt_dev_get_address(); + if (mac != nullptr) { + memcpy(out, mac, 6); + } else { + memset(out, 0, 6); + } +} + float ESP32BLE::get_setup_priority() const { return setup_priority::BLUETOOTH; } void ESP32BLE::dump_config() { - const uint8_t *mac_address = esp_bt_dev_get_address(); - if (mac_address) { + uint8_t mac_address[6]; + this->get_mac_msb_first(mac_address); + if (mac_address_is_valid(mac_address)) { const char *io_capability_s; switch (this->io_cap_) { case ESP_IO_CAP_OUT: diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index c85ddfc983..45cfd8ee71 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -108,6 +108,8 @@ class ESP32BLE final : public Component { void setup() override; void loop() override; void dump_config() override; + /// Adapter MAC in printable (MSB-first) order; all-zero until the stack is up. + void get_mac_msb_first(uint8_t out[6]) const; float get_setup_priority() const override; void set_name(const char *name) { this->name_ = name; } diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index e51b293bfe..0950bfeb70 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -271,7 +271,9 @@ void ESP32BLETracker::register_client(ESPBTClient *client) { // Safe because ESP32BLETracker (singleton) outlives all registered clients. client->set_tracker_state_version(&this->state_version_); this->clients_.push_back(client); - this->recalculate_advertisement_parser_types(); + // Registration is add-only, so the flag is a monotonic OR. + if (client->wants_parsed_advertisements()) + this->parse_advertisements_ = true; #endif } @@ -283,48 +285,11 @@ void ESP32BLETracker::register_listener(ble_device_base::ESPBTDeviceListener *li #endif } -void ESP32BLETracker::get_adapter_mac(uint8_t out[6]) { - get_mac_address_raw(out); // WiFi base MAC, MSB-first - // BT MAC = base MAC + 2 on the last octet only, wrapping without carry — - // exactly ESP-IDF's esp_read_mac(ESP_MAC_BT): mac[5] += MAC_ADDR_UNIVERSE_BT_OFFSET. - out[5] += 2; -} - void ESP32BLETracker::register_listener(ESPBTDeviceListener *listener) { #ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT listener->set_parent(this); this->listeners_.push_back(listener); - this->recalculate_advertisement_parser_types(); -#endif -} - -void ESP32BLETracker::recalculate_advertisement_parser_types() { - this->raw_advertisements_ = false; - this->parse_advertisements_ = false; -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - // Neutral (BLEHub) listeners are parsed-advertisement consumers and are not in - // listeners_; without this, any later esp32-path registration (e.g. the proxy's - // GATT clients) would recompute the flags and silently drop parsed dispatch. - if (!this->neutral_listeners_.empty()) - this->parse_advertisements_ = true; -#endif -#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT - for (auto *listener : this->listeners_) { - if (listener->get_advertisement_parser_type() == AdvertisementParserType::PARSED_ADVERTISEMENTS) { - this->parse_advertisements_ = true; - } else { - this->raw_advertisements_ = true; - } - } -#endif -#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT - for (auto *client : this->clients_) { - if (client->get_advertisement_parser_type() == AdvertisementParserType::PARSED_ADVERTISEMENTS) { - this->parse_advertisements_ = true; - } else { - this->raw_advertisements_ = true; - } - } + this->parse_advertisements_ = true; #endif } @@ -478,20 +443,6 @@ void ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) { this->raw_advertisement_callback_.invoke(adv); } - // Process raw advertisements - if (this->raw_advertisements_) { -#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT - for (auto *listener : this->listeners_) { - listener->parse_devices(&scan_result, 1); - } -#endif -#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT - for (auto *client : this->clients_) { - client->parse_devices(&scan_result, 1); - } -#endif - } - // Process parsed advertisements if (this->parse_advertisements_) { #ifdef USE_ESP32_BLE_DEVICE diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index c570c28122..ee1b1429c0 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -35,11 +35,6 @@ using namespace esp32_ble; using adv_data_t = ble_device_base::adv_data_t; -enum AdvertisementParserType { - PARSED_ADVERTISEMENTS, - RAW_ADVERTISEMENTS, -}; - #ifdef USE_ESP32_BLE_UUID using ServiceData = ble_device_base::ServiceData; #endif @@ -63,10 +58,6 @@ class ESPBTDeviceListener : public ble_device_base::ESPBTDeviceListener { // Raw-only build: no parsed-device support is compiled in. bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; } #endif - virtual bool parse_devices(const BLEScanResult *scan_results, size_t count) { return false; }; - virtual AdvertisementParserType get_advertisement_parser_type() { - return AdvertisementParserType::PARSED_ADVERTISEMENTS; - }; void set_parent(ESP32BLETracker *parent) { parent_ = parent; } protected: @@ -123,6 +114,10 @@ class BLEScannerStateListener { /// The pointer may be null if the client is not registered with a tracker. class ESPBTClient : public ESPBTDeviceListener { public: + /// False keeps the tracker from building parsed ESPBTDevice objects on + /// this client's account (raw consumers use the hub callback). + virtual bool wants_parsed_advertisements() { return true; } + virtual bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) = 0; virtual void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) = 0; @@ -199,7 +194,6 @@ class ESP32BLETracker final : public Component, // esp32-flavored path (unmigrated esp32 sensors; sets the tracker back-pointer). void register_listener(ESPBTDeviceListener *listener); void register_client(ESPBTClient *client); - void recalculate_advertisement_parser_types(); // ---- ble_device_base::BLEHub (the platform-neutral tracker contract) ---- void register_listener(ble_device_base::ESPBTDeviceListener *listener) override; @@ -212,7 +206,7 @@ class ESP32BLETracker final : public Component, return {/* active_scan = */ true, /* merges_scan_response = */ true, /* gatt = */ true, /* scan_mode_switch = */ false}; } - void get_adapter_mac(uint8_t out[6]) override; + void get_adapter_mac(uint8_t out[6]) override { this->parent_->get_mac_msb_first(out); } bool scan_running() override { return this->scanner_state_ == ScannerState::RUNNING; } bool scan_active() override { return this->scan_active_; } @@ -355,7 +349,6 @@ class ESP32BLETracker final : public Component, bool scan_continuous_before_ota_{false}; #endif bool ble_was_disabled_{true}; - bool raw_advertisements_{false}; bool parse_advertisements_{false}; #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE bool coex_prefer_ble_{false}; From 2bb01853e0917505f5dcea44ca7e031dfe946010 Mon Sep 17 00:00:00 2001 From: Edu_Coder Date: Sat, 8 Aug 2026 20:53:10 -0400 Subject: [PATCH 1337/1815] [tuya] GMT time 0x0C command handler (#17158) --- esphome/components/tuya/tuya.cpp | 35 ++++++++++++++++++++++++++++++++ esphome/components/tuya/tuya.h | 3 +++ 2 files changed, 38 insertions(+) diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index 3058d82cc4..15ab4b6dc3 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -303,6 +303,22 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff ESP_LOGW(TAG, "LOCAL_TIME_QUERY is not handled because time is not configured"); } break; + case TuyaCommandType::GMT_TIME_QUERY: +#ifdef USE_TIME + if (this->time_id_ != nullptr) { + this->send_gmt_time_(); + + if (!this->gmt_time_sync_callback_registered_) { + // tuya mcu supports time, so we let them know when our time changed + this->time_id_->add_on_time_sync_callback([this] { this->send_gmt_time_(); }); + this->gmt_time_sync_callback_registered_ = true; + } + } else +#endif + { + ESP_LOGW(TAG, "GMT_TIME_QUERY is not handled because time is not configured"); + } + break; case TuyaCommandType::VACUUM_MAP_UPLOAD: this->send_command_( TuyaCommand{.cmd = TuyaCommandType::VACUUM_MAP_UPLOAD, .payload = std::vector{0x01}}); @@ -609,6 +625,25 @@ void Tuya::send_local_time_() { } this->send_command_(TuyaCommand{.cmd = TuyaCommandType::LOCAL_TIME_QUERY, .payload = payload}); } +void Tuya::send_gmt_time_() { + std::vector payload; + ESPTime now = this->time_id_->utcnow(); + if (now.is_valid()) { + uint8_t year = now.year - 2000; + uint8_t month = now.month; + uint8_t day_of_month = now.day_of_month; + uint8_t hour = now.hour; + uint8_t minute = now.minute; + uint8_t second = now.second; + ESP_LOGD(TAG, "Sending gmt time"); + payload = std::vector{0x01, year, month, day_of_month, hour, minute, second}; + } else { + // By spec we need to notify MCU that the time was not obtained if this is a response to a query + ESP_LOGW(TAG, "Sending missing gmt time"); + payload = std::vector{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + } + this->send_command_(TuyaCommand{.cmd = TuyaCommandType::GMT_TIME_QUERY, .payload = payload}); +} #endif void Tuya::set_raw_datapoint_value(uint8_t datapoint_id, const std::vector &value) { diff --git a/esphome/components/tuya/tuya.h b/esphome/components/tuya/tuya.h index 4e7ab5c7f9..b8bf4e0ab1 100644 --- a/esphome/components/tuya/tuya.h +++ b/esphome/components/tuya/tuya.h @@ -54,6 +54,7 @@ enum class TuyaCommandType : uint8_t { DATAPOINT_DELIVER = 0x06, DATAPOINT_REPORT_ASYNC = 0x07, DATAPOINT_QUERY = 0x08, + GMT_TIME_QUERY = 0x0C, WIFI_TEST = 0x0E, LOCAL_TIME_QUERY = 0x1C, DATAPOINT_REPORT_SYNC = 0x22, @@ -138,8 +139,10 @@ class Tuya final : public Component, public uart::UARTDevice { #ifdef USE_TIME void send_local_time_(); + void send_gmt_time_(); time::RealTimeClock *time_id_{nullptr}; bool time_sync_callback_registered_{false}; + bool gmt_time_sync_callback_registered_{false}; #endif TuyaInitState init_state_ = TuyaInitState::INIT_HEARTBEAT; bool init_failed_{false}; From 862b13c8ddf4fbe0289f562472c3602edec8d502 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2026 20:58:48 -0500 Subject: [PATCH 1338/1815] [esp32_ble_tracker] Retire the scanner-state listener interface (#18179) --- .../components/esp32_ble_tracker/__init__.py | 19 +-------------- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 5 ---- .../esp32_ble_tracker/esp32_ble_tracker.h | 23 ------------------- esphome/core/defines.h | 1 - .../ble_device_base/test_slot_counter.py | 12 ++-------- 5 files changed, 3 insertions(+), 57 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 646ce79233..b1ad07dfdd 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -66,12 +66,9 @@ def _get_required_features() -> set[BLEFeatures]: # Slot counters sizing the tracker's StaticVector storage; one request per -# registered listener, client, or scanner state listener. +# registered listener or client. _request_listener_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") _request_client_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") -_request_scanner_state_listener_slot = cg.slot_counter( - "ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT" -) def register_ble_features(features: set[BLEFeatures]) -> None: @@ -386,17 +383,3 @@ async def register_raw_client( paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) cg.add(paren.register_client(var)) return var - - -async def register_scanner_state_listener( - var: cg.SafeExpType, config: ConfigType -) -> cg.SafeExpType: - """Register a listener for scanner state changes. - - The slot request here is what sizes the tracker's listener storage; a - build with no registrations compiles the storage out entirely. - """ - _request_scanner_state_listener_slot() - paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) - cg.add(paren.add_scanner_state_listener(var)) - return var diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 0950bfeb70..18b6cf022d 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -392,11 +392,6 @@ void ESP32BLETracker::set_scanner_state_(ScannerState state) { this->scanner_state_callback_.invoke(state); } #endif -#ifdef ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT - for (auto *listener : this->scanner_state_listeners_) { - listener->on_scanner_state(state); - } -#endif } void ESP32BLETracker::dump_config() { diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index ee1b1429c0..9031d86c97 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -89,16 +89,6 @@ using ble_device_base::client_state_to_string; // Neutral scanner lifecycle re-exported for backward compatibility. using ScannerState = ble_device_base::ScannerState; -/** Listener interface for BLE scanner state changes. - * - * Components can implement this interface to receive scanner state updates - * without the overhead of std::function callbacks. - */ -class BLEScannerStateListener { - public: - virtual void on_scanner_state(ScannerState state) = 0; -}; - /// Base class for BLE GATT clients that connect to remote devices. /// /// State Change Tracking Design: @@ -226,15 +216,6 @@ class ESP32BLETracker final : public Component, void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override; #endif -#ifdef ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT - /// Add a listener for scanner state changes. Only compiled when a consumer - /// requested a slot in codegen: register through - /// esp32_ble_tracker.register_scanner_state_listener() in your component's - /// to_code, which requests the slot and emits this call. - void add_scanner_state_listener(BLEScannerStateListener *listener) { - this->scanner_state_listeners_.push_back(listener); - } -#endif ScannerState get_scanner_state() const { return this->scanner_state_; } protected: @@ -300,10 +281,6 @@ class ESP32BLETracker final : public Component, #endif #ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT StaticVector clients_; -#endif -#ifdef ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT - StaticVector - scanner_state_listeners_; #endif // Parsed listeners registered through the neutral BLEHub contract (migrated // sensors); dispatched alongside listeners_. diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 7ddc607c5c..fd351356df 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -307,7 +307,6 @@ #define USE_ESP32_BLE_SERVER_ON_DISCONNECT #define ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT 1 #define ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT 1 -#define ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT 1 #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 #define ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT 2 #define ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT 1 diff --git a/tests/component_tests/ble_device_base/test_slot_counter.py b/tests/component_tests/ble_device_base/test_slot_counter.py index daa2884588..e784c9871e 100644 --- a/tests/component_tests/ble_device_base/test_slot_counter.py +++ b/tests/component_tests/ble_device_base/test_slot_counter.py @@ -94,11 +94,7 @@ def test_esp32_tracker_handler_counts( assert get_define_value("ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT") == "1" assert get_define_value("ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT") == "1" assert get_define_value("ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT") is None - # No consumer subscribed to scanner state, so the storage compiles out. - assert ( - get_define_value("ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT") - is None - ) + # No advertisement listener or client is registered, so both storages compile out. assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") is None assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") is None @@ -109,12 +105,8 @@ def test_esp32_bluetooth_proxy_requests_client_slots_only( ) -> None: """The proxy requests a client slot per connection (three by default with active: true); advertisements and scanner state arrive through the hub - callbacks, so no listener or scanner-state slot exists.""" + callbacks, so no listener slot exists.""" generate_main(component_config_path("esp32_bluetooth_proxy.yaml")) - assert ( - get_define_value("ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT") - is None - ) assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") is None assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") == "3" From e9f428983ea3b8faa31c400cfc453ee17883b747 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 9 Aug 2026 08:57:31 -0500 Subject: [PATCH 1339/1815] [ble_device_base] Bind BLEHub to the build's tracker at compile time (#18181) --- .../components/bk72xx_ble_tracker/__init__.py | 3 + .../bk72xx_ble_tracker/bk72xx_ble_tracker.h | 15 ++-- .../components/ble_device_base/__init__.py | 28 +++--- .../components/ble_device_base/automation.h | 11 +-- esphome/components/ble_device_base/ble_hub.h | 88 ++++++++----------- .../components/ble_device_base/ble_hub_impl.h | 35 ++++++++ .../components/bluetooth_proxy/__init__.py | 10 +-- .../bluetooth_proxy/bluetooth_proxy.cpp | 31 ++++--- .../bluetooth_proxy/bluetooth_proxy.h | 30 ++----- .../components/esp32_ble_tracker/__init__.py | 3 + .../esp32_ble_tracker/esp32_ble_tracker.h | 24 +++-- .../components/ln882h_ble_tracker/__init__.py | 3 + .../ln882h_ble_tracker/ln882h_ble_tracker.h | 15 ++-- .../components/rp2_ble_tracker/__init__.py | 3 + .../rp2_ble_tracker/rp2_ble_tracker.h | 15 ++-- esphome/core/defines.h | 18 +++- .../config/ln882h_tracker.yaml | 7 ++ .../ble_device_base/test_hub_binding.py | 29 +++++- .../ble_device_base/test_raw_callback.cpp | 11 +-- .../test_scan_mode_request.cpp | 76 ---------------- 20 files changed, 228 insertions(+), 227 deletions(-) create mode 100644 esphome/components/ble_device_base/ble_hub_impl.h create mode 100644 tests/component_tests/ble_device_base/config/ln882h_tracker.yaml delete mode 100644 tests/components/ble_device_base/test_scan_mode_request.cpp diff --git a/esphome/components/bk72xx_ble_tracker/__init__.py b/esphome/components/bk72xx_ble_tracker/__init__.py index e7f8ed92ba..7fefb310cd 100644 --- a/esphome/components/bk72xx_ble_tracker/__init__.py +++ b/esphome/components/bk72xx_ble_tracker/__init__.py @@ -144,6 +144,9 @@ async def stop_scan_action_to_code( async def to_code(config: ConfigType) -> None: + # Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h. + cg.add_define("USE_BK72XX_BLE_TRACKER") + var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h index 67e4467c77..cc51918da5 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h @@ -45,7 +45,6 @@ namespace esphome::bk72xx_ble_tracker { // --------------------------------------------------------------------------- class BK72xxBLETracker : public Component, - public ble_device_base::BLEHub, public bk72xx_ble::BLEScanListener, public Parented #ifdef USE_OTA_STATE_LISTENER @@ -93,15 +92,15 @@ class BK72xxBLETracker : public Component, void stop_scan(); // ---- ble_device_base::BLEHub contract ---- - void register_listener(ble_device_base::ESPBTDeviceListener *listener) override { + void register_listener(ble_device_base::ESPBTDeviceListener *listener) { #ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT this->listeners_.push_back(listener); #endif } - void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override { + void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { this->raw_advertisement_callback_ = callback; } - ble_device_base::HubCapabilities get_capabilities() const override { + static constexpr ble_device_base::HubCapabilities get_capabilities() { // The Beken BDK exposes no active-scan path (passive scanning only), so the // controller never solicits scan responses and never merges them; consumers // relying on scan-response fields (device names) get them only where the @@ -110,21 +109,21 @@ class BK72xxBLETracker : public Component, // path there is no mode to switch to. return {.active_scan = false, .merges_scan_response = false, .gatt = false, .scan_mode_switch = false}; } - bool request_scan_mode(bool active) override { + bool request_scan_mode(bool active) { // Passive-only controller: a passive request is already honored, an active // one cannot be. return !active; } // The controller stores the address LSB-first (BLE convention); the contract // wants printable (MSB-first) order. - void get_adapter_mac(uint8_t out[6]) override { + void get_adapter_mac(uint8_t out[6]) { uint8_t mac[6]; this->parent_->get_mac_lsb_first(mac); for (int i = 0; i < 6; i++) out[i] = mac[5 - i]; } - bool scan_running() override { return this->scan_running_; } - bool scan_active() override { return false; } // BK72xx scan is passive-only + bool scan_running() { return this->scan_running_; } + bool scan_active() { return false; } // BK72xx scan is passive-only // ---- bk72xx_ble::BLEScanListener ---- // Delivered by the controller's loop() on the ESPHome main task — the diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index a52286f456..fa66448867 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -3,19 +3,22 @@ ble_device_base — the platform-neutral BLE layer. Owns the shared advertisement types (ESPBTUUID / ESPBTDevice / ServiceData / ESPBLEiBeacon / ESPBTDeviceListener, in ble_device.h) and the tracker contract -(BLEHub, in ble_hub.h) on every platform. +(BLEHub, in ble_hub.h; C++-side a per-platform alias bound in ble_hub_impl.h) +on every platform. BLE consumers (sensor components, bluetooth_proxy) bind to whichever tracker the configuration declares via `cv.use_id(BLEHub)` — ESPHome resolves any declared -subclass, so there is no platform table here and no dependency in either -direction. A sensor extends BLE_DEVICE_SCHEMA in its CONFIG_SCHEMA (so an -explicit ble_hub_id: is a declared key even on strict schemas) and calls -register_ble_device() in to_code; a tracker component subclasses BLEHub (C++ -and codegen class) and MUST call register_hub_provider() at import time — -without it _require_hub rejects configs that bind through the generated id -(an explicit ble_hub_id: bypasses the registry). Adding a new BLE chip -requires only a new in-tree tracker component; out-of-tree BLE hubs are -not supported. +subclass, so there is no Python platform table here and no dependency in +either direction (C++-side, the compile-time alias header ble_hub_impl.h and the +defines.h mirror are the deliberate exceptions). A sensor extends +BLE_DEVICE_SCHEMA in its CONFIG_SCHEMA (so an explicit ble_hub_id: is a +declared key even on strict schemas) and calls register_ble_device() in +to_code; a tracker component declares BLEHub as its codegen-class parent and +MUST call register_hub_provider() at import time — without it _require_hub +rejects configs that bind through the generated id (an explicit ble_hub_id: +bypasses the registry). Adding a new BLE chip requires a new in-tree tracker +component plus its alias arm and define (see above); out-of-tree BLE hubs +are not supported. AES-CCM decryption for encrypted advertisements is provided portably in ble_aes_ccm.h. @@ -48,8 +51,9 @@ LISTENER_COUNT_DEFINE = "ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT" ble_device_base_ns = cg.esphome_ns.namespace("ble_device_base") -# The neutral tracker contract. Every tracker's codegen class declares this as a -# parent, which is what lets cv.use_id(BLEHub) resolve any of them. +# The neutral tracker contract. Every tracker's codegen class declares this as +# a parent, which is what lets cv.use_id(BLEHub) resolve any of them. Python +# only: C++-side the name is a per-platform alias (ble_hub_impl.h). BLEHub = ble_device_base_ns.class_("BLEHub") # The neutral listener base (C++: ble_device_base::ESPBTDeviceListener). diff --git a/esphome/components/ble_device_base/automation.h b/esphome/components/ble_device_base/automation.h index 507b3278d9..ba3128c0ee 100644 --- a/esphome/components/ble_device_base/automation.h +++ b/esphome/components/ble_device_base/automation.h @@ -1,11 +1,12 @@ // Platform-neutral BLE advertisement triggers: ESPBTDeviceListener subclasses // registered on a BLEHub, exposed by each tracker under its own automation // names. parse_device()'s return feeds the "Found device" suppression. +// Constructors are templated on the hub type so this header also builds with +// no tracker present (host unit tests). #pragma once #include "ble_device.h" -#include "ble_hub.h" #include "esphome/core/automation.h" #include "esphome/core/helpers.h" @@ -18,7 +19,7 @@ namespace esphome::ble_device_base { // on_ble_advertise: fires on every BLE advertisement, optionally filtered to one or more MACs. class ESPBTAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { public: - explicit ESPBTAdvertiseTrigger(BLEHub *parent) { parent->register_listener(this); } + template explicit ESPBTAdvertiseTrigger(Hub *parent) { parent->register_listener(this); } void set_addresses(std::initializer_list addresses) { this->addresses_ = addresses; } @@ -39,7 +40,7 @@ class ESPBTAdvertiseTrigger final : public Trigger, public // data for the given UUID. Optional single-MAC filter. class BLEServiceDataAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { public: - explicit BLEServiceDataAdvertiseTrigger(BLEHub *parent) { parent->register_listener(this); } + template explicit BLEServiceDataAdvertiseTrigger(Hub *parent) { parent->register_listener(this); } void set_service_uuid16(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint16(static_cast(uuid)); } void set_service_uuid32(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint32(static_cast(uuid)); } @@ -73,7 +74,7 @@ class BLEServiceDataAdvertiseTrigger final : public Trigger, // manufacturer data for the given ID. Optional single-MAC filter. class BLEManufacturerDataAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { public: - explicit BLEManufacturerDataAdvertiseTrigger(BLEHub *parent) { parent->register_listener(this); } + template explicit BLEManufacturerDataAdvertiseTrigger(Hub *parent) { parent->register_listener(this); } void set_manufacturer_uuid16(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint16(static_cast(uuid)); } void set_manufacturer_uuid32(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint32(static_cast(uuid)); } @@ -108,7 +109,7 @@ class BLEManufacturerDataAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { public: - explicit BLEEndOfScanTrigger(BLEHub *parent) { parent->register_listener(this); } + template explicit BLEEndOfScanTrigger(Hub *parent) { parent->register_listener(this); } bool parse_device(const ESPBTDevice &device) override { return false; } void on_scan_end() override { this->trigger(); } diff --git a/esphome/components/ble_device_base/ble_hub.h b/esphome/components/ble_device_base/ble_hub.h index aa813d03db..8e4c710bb1 100644 --- a/esphome/components/ble_device_base/ble_hub.h +++ b/esphome/components/ble_device_base/ble_hub.h @@ -1,13 +1,10 @@ // ble_hub.h // -// BLEHub — the platform-neutral BLE tracker contract. -// -// Every BLE tracker component (esp32_ble_tracker, bk72xx_ble_tracker, -// ln882h_ble_tracker, future chips) implements this interface; every BLE -// consumer (sensor components, bluetooth_proxy) binds to it — in YAML via -// `cv.use_id(BLEHub)`, which resolves whichever tracker the config declares. -// Adding a new BLE chip therefore requires only a new tracker component that -// implements BLEHub: no consumer, registry, or base changes. +// The platform-neutral BLE tracker contract: shared types plus the method +// surface every tracker provides (documented below). Exactly one tracker +// exists per build, so BLEHub is a compile-time alias (ble_hub_impl.h), not +// an abstract interface — no vtable, every hub call inlinable. Consumers +// include ble_hub_impl.h and bind in YAML via cv.use_id(BLEHub). // // Chip differences are expressed as data (HubCapabilities), never as // platform conditionals in consumers. @@ -15,7 +12,9 @@ #pragma once #include "ble_device.h" +#include "esphome/core/defines.h" +#include #include namespace esphome::ble_device_base { @@ -61,9 +60,8 @@ enum class ScannerState : uint8_t { }; /// Subscriber slot for scanner-state transitions; same shape as -/// RawAdvertisementCallback, delivered on the ESPHome main loop. Hubs that -/// cannot push drop the registration and the consumer falls back to polling -/// scan_running(). +/// RawAdvertisementCallback, delivered on the ESPHome main loop. Only hubs +/// that push provide the setter; consumers of the rest poll scan_running(). struct ScannerStateCallback { void *instance{nullptr}; void (*fn)(void *instance, ScannerState state){nullptr}; @@ -91,48 +89,34 @@ struct HubCapabilities { bool scan_mode_switch; }; -class BLEHub { - public: - virtual ~BLEHub() = default; - - /// Register a parsed-advertisement consumer (BLE sensors, automation triggers). - virtual void register_listener(ESPBTDeviceListener *listener) = 0; - - /// Wire the raw-advertisement stream (bluetooth_proxy). One consumer at a time. - virtual void set_raw_advertisement_callback(RawAdvertisementCallback callback) = 0; - +// The BLEHub method surface, asserted where ble_hub_impl.h binds the alias. +// Semantics beyond the signatures: +// - register_listener: parsed-advertisement consumers (sensors, triggers). +// - set_raw_advertisement_callback: raw stream, one consumer at a time. +// - get_adapter_mac: printable order, out[0] = MSB. +// - scan_active: the current/configured mode sends scan requests. +// - request_scan_mode: false = cannot honor, state untouched (the caller +// reports the real state back); true = applied immediately, restarting a +// running scan. Honoring is advertised by HubCapabilities::scan_mode_switch. +// Push hubs additionally provide set_scanner_state_callback(ScannerStateCallback) +// and get_scanner_state() under USE_BLE_SCANNER_STATE_CALLBACK; the concept +// requires both exactly when that define is set. A push hub must emit a +// transition for every accepted or refused mode request - consumers skip +// their own mode report on push builds. +template +concept BLEHubContract = requires(T hub, ESPBTDeviceListener *listener, RawAdvertisementCallback raw_callback, + uint8_t *mac) { + hub.register_listener(listener); + hub.set_raw_advertisement_callback(raw_callback); + { T::get_capabilities() } -> std::same_as; + hub.get_adapter_mac(mac); + { hub.scan_running() } -> std::same_as; + { hub.scan_active() } -> std::same_as; + { hub.request_scan_mode(true) } -> std::same_as; #ifdef USE_BLE_SCANNER_STATE_CALLBACK - /// Push subscriber for scanner-state transitions; hubs that can push - /// invoke scanner_state_callback_ where their state changes. Compiled only - /// when a subscriber exists (bluetooth_proxy emits the define), so - /// subscriber-less builds carry no storage. - void set_scanner_state_callback(ScannerStateCallback callback) { this->scanner_state_callback_ = callback; } - - protected: - ScannerStateCallback scanner_state_callback_{}; - - public: -#endif // USE_BLE_SCANNER_STATE_CALLBACK - - virtual HubCapabilities get_capabilities() const = 0; - - /// Adapter MAC in printable (MSB-first) order, out[0] = MSB. - virtual void get_adapter_mac(uint8_t out[6]) = 0; - - virtual bool scan_running() = 0; - /// True when the current/configured scan mode is active (scan requests sent). - virtual bool scan_active() = 0; - /// Request a scan-mode change (active = send scan requests). Returns false - /// when the hub cannot honor the request; the caller reports the real state - /// back to its subscriber. A hub that returns true applies the mode - /// immediately: a running scan is restarted with the new mode, an idle one - /// picks it up on its next start. The default cannot-change keeps hubs - /// without a mode switch (and out-of-tree trackers) building unchanged. - /// Independent of HubCapabilities::active_scan: that bit describes what the - /// CONTROLLER can do; whether this method honors requests is advertised by - /// HubCapabilities::scan_mode_switch, so consumers can gate features on the - /// switch without probing. - virtual bool request_scan_mode(bool active) { return false; } + hub.set_scanner_state_callback(ScannerStateCallback{}); + { hub.get_scanner_state() } -> std::same_as; +#endif }; } // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/ble_hub_impl.h b/esphome/components/ble_device_base/ble_hub_impl.h new file mode 100644 index 0000000000..87214ca7f7 --- /dev/null +++ b/esphome/components/ble_device_base/ble_hub_impl.h @@ -0,0 +1,35 @@ +// ble_hub_impl.h +// +// Binds ble_device_base::BLEHub to the build's one tracker; each tracker's +// codegen emits its USE_*_BLE_TRACKER define. Consumers include this header, +// trackers include ble_hub.h (the contract). + +#pragma once + +#include "ble_hub.h" +#include "esphome/core/defines.h" + +#if defined(USE_ESP32_BLE_TRACKER) +#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#define ESPHOME_BLE_HUB_TYPE esp32_ble_tracker::ESP32BLETracker +#elif defined(USE_RP2_BLE_TRACKER) +#include "esphome/components/rp2_ble_tracker/rp2_ble_tracker.h" +#define ESPHOME_BLE_HUB_TYPE rp2_ble_tracker::RP2BLETracker +#elif defined(USE_BK72XX_BLE_TRACKER) +#include "esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h" +#define ESPHOME_BLE_HUB_TYPE bk72xx_ble_tracker::BK72xxBLETracker +#elif defined(USE_LN882H_BLE_TRACKER) +#include "esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h" +#define ESPHOME_BLE_HUB_TYPE ln882h_ble_tracker::LN882HBLETracker +#endif +// No #else on purpose: builds without a tracker (host unit tests) get no BLEHub. + +namespace esphome::ble_device_base { + +#ifdef ESPHOME_BLE_HUB_TYPE +using BLEHub = ESPHOME_BLE_HUB_TYPE; +static_assert(BLEHubContract, "The build's BLE tracker is missing part of the BLEHub surface (ble_hub.h)"); +#undef ESPHOME_BLE_HUB_TYPE +#endif + +} // namespace esphome::ble_device_base diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index 8d9aa88bd8..a28c8abc71 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -374,10 +374,12 @@ async def _to_code_esp32(config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_active(config[CONF_ACTIVE])) - # Advertisements and scanner state arrive through the hub callbacks - # (installed in setup()); the tracker stays typed for scan-mode calls. tracker = await cg.get_variable(config[esp32_ble_tracker.CONF_ESP32_BLE_ID]) - cg.add(var.set_parent(tracker)) + cg.add(var.set_ble_hub(tracker)) + + # Compiles the scanner-state push slot into the tracker and the matching + # registration into the proxy; the other hubs are polled instead. + cg.add_define("USE_BLE_SCANNER_STATE_CALLBACK") # Define max connections for protobuf fixed array connection_count = len(config.get(CONF_CONNECTIONS, [])) @@ -426,5 +428,3 @@ async def to_code(config: ConfigType) -> None: cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) cg.add_define("USE_BLUETOOTH_PROXY") - # Compiles the scanner-state push slot into the hub (see ble_hub.h). - cg.add_define("USE_BLE_SCANNER_STATE_CALLBACK") diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 3f44adbef4..88d8cc1885 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -51,7 +51,7 @@ bool BluetoothProxy::send_bluetooth_scanner_state_(ble_device_base::ScannerState return this->api_connection_->send_message(resp); } -#ifndef USE_ESP32 +#ifndef USE_BLE_SCANNER_STATE_CALLBACK void BluetoothProxy::send_polled_scanner_state_() { // One read feeds both the frame and the change detector; the detector only // advances if the frame was accepted, so a dropped send (WOULD_BLOCK on a @@ -62,7 +62,7 @@ void BluetoothProxy::send_polled_scanner_state_() { this->last_scan_running_ = running; } } -#endif // !USE_ESP32 +#endif // !USE_BLE_SCANNER_STATE_CALLBACK void BluetoothProxy::setup() { // BLUETOOTH_PROXY_MAX_CONNECTIONS is 0 on an advertisement-only proxy. @@ -75,9 +75,12 @@ void BluetoothProxy::setup() { this->hub_->set_raw_advertisement_callback({this, [](void *self, const ble_device_base::RawAdvertisement &adv) { static_cast(self)->on_raw_advertisement_(adv); }}); +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + // Only push hubs compile the slot; elsewhere loop() polls scan_running(). this->hub_->set_scanner_state_callback({this, [](void *self, ble_device_base::ScannerState state) { static_cast(self)->send_bluetooth_scanner_state_(state); }}); +#endif } // The hub delivers raw advertisements on the ESPHome main loop. @@ -469,13 +472,15 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn #ifdef USE_ESP32 void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { - if (this->parent_()->get_scan_active() == active) { + // esp32 only: BLEHub is the concrete tracker here, so these calls reach + // tracker-native methods beyond the neutral contract. + if (this->hub_->get_scan_active() == active) { return; } ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive"); - this->parent_()->set_scan_active(active); - this->parent_()->stop_scan(); - this->parent_()->set_scan_continuous( + this->hub_->set_scan_active(active); + this->hub_->stop_scan(); + this->hub_->set_scan_continuous( true); // Set this to true to automatically start scanning again when it has cleaned up. } @@ -511,11 +516,13 @@ void BluetoothProxy::loop() { return; } +#ifndef USE_BLE_SCANNER_STATE_CALLBACK // This hub doesn't push scanner-state transitions; poll and report on - // change. A hub gaining push must also refresh last_scan_running_ here. + // change. A hub gaining push emits the define and drops this poll. if (this->hub_->scan_running() != this->last_scan_running_) { this->send_polled_scanner_state_(); } +#endif this->flush_pending_advertisements_(); } @@ -598,12 +605,15 @@ void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { ESP_LOGW(TAG, "Scanner mode %s not supported by this tracker", active ? "active" : "passive"); } } +#ifndef USE_BLE_SCANNER_STATE_CALLBACK if (this->api_connection_ != nullptr) { // Reports the mode change; the sender also refreshes last_scan_running_, so // a failed restart (scan_running_ dropped by the tracker) is not reported - // again by loop() on the next tick. + // again by loop() on the next tick. A push hub reports the restart's + // transitions (mode rides along) instead. this->send_polled_scanner_state_(); } +#endif } #endif // USE_ESP32 @@ -622,8 +632,9 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection this->api_connection_->get_peername_to(old_peername)); } this->api_connection_ = api_connection; -#ifdef USE_ESP32 - this->send_bluetooth_scanner_state_(this->parent_()->get_scanner_state()); +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + // get_scanner_state() is part of the push-hub surface (see BLEHubContract). + this->send_bluetooth_scanner_state_(this->hub_->get_scanner_state()); #else this->send_polled_scanner_state_(); #endif diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 9fc975680e..d7150617d3 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -15,17 +15,13 @@ #include "esphome/components/bluetooth_connection/bluetooth_connection.h" +#include "esphome/components/ble_device_base/ble_hub_impl.h" + #ifdef USE_ESP32 -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" - #include "esphome/components/bluetooth_connection/bluetooth_connection_esp32.h" - -#else -#include "esphome/components/ble_device_base/ble_hub.h" -#ifdef USE_BLE_GATT_CLIENT +#elif defined(USE_BLE_GATT_CLIENT) #include "esphome/components/bluetooth_connection/bluetooth_connection_hub.h" #endif -#endif // USE_ESP32 namespace esphome::bluetooth_proxy { @@ -75,11 +71,7 @@ class BluetoothProxy final : public Component { #endif public: BluetoothProxy(); -#ifdef USE_ESP32 - // Advertisements arrive through the hub's raw callback; parent_() below - // recovers the tracker type for the esp32-only scan-mode calls. - void set_parent(esp32_ble_tracker::ESP32BLETracker *parent) { this->hub_ = parent; } -#endif // USE_ESP32 + void set_ble_hub(ble_device_base::BLEHub *hub) { this->hub_ = hub; } void dump_config() override; void setup() override; void loop() override; @@ -88,7 +80,6 @@ class BluetoothProxy final : public Component { void register_connection(BluetoothConnection *connection); #endif // BLUETOOTH_CONNECTION_HAS_GATT #ifndef USE_ESP32 - void set_ble_hub(ble_device_base::BLEHub *hub) { this->hub_ = hub; } // Run after the hub's setup() (the trackers use AFTER_WIFI): setup() below // snapshots scan_active()/scan_running() and installs the raw callback, and // the BLEHub contract does not promise those are settled any earlier than @@ -152,7 +143,7 @@ class BluetoothProxy final : public Component { // scan_mode_switch is the capability bit for exactly that (#18079) — // active_scan alone is not enough, a hub may support active scanning yet // refuse the runtime switch. - if (this->hub_->get_capabilities().scan_mode_switch) { + if (ble_device_base::BLEHub::get_capabilities().scan_mode_switch) { flags |= BluetoothProxyFeature::FEATURE_STATE_AND_MODE; } #endif @@ -188,7 +179,7 @@ class BluetoothProxy final : public Component { protected: bool send_bluetooth_scanner_state_(ble_device_base::ScannerState state); -#ifndef USE_ESP32 +#ifndef USE_BLE_SCANNER_STATE_CALLBACK void send_polled_scanner_state_(); #endif void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw); @@ -258,13 +249,6 @@ class BluetoothProxy final : public Component { std::array connections_{}; #endif ble_device_base::BLEHub *hub_{nullptr}; -#ifdef USE_ESP32 - // set_parent() is the only writer of hub_ on esp32, so the downcast is - // exact; ESP32BLETracker derives from BLEHub non-virtually. - esp32_ble_tracker::ESP32BLETracker *parent_() { - return static_cast(this->hub_); - } -#endif // BLE advertisement batching api::BluetoothLERawAdvertisementsResponse response_; @@ -279,7 +263,7 @@ class BluetoothProxy final : public Component { bool active_; uint8_t connection_count_{0}; bool configured_scan_active_{false}; // Configured scan mode from YAML -#ifndef USE_ESP32 +#ifndef USE_BLE_SCANNER_STATE_CALLBACK bool last_scan_running_{false}; // Last scanner state reported to the subscriber #endif }; diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index b1ad07dfdd..84f43fb54b 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -205,6 +205,9 @@ async def to_code(config): # available on esp32 (sensors with irk: worked without opting in). ble_device_base.request_irk_support() + # Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h. + cg.add_define("USE_ESP32_BLE_TRACKER") + var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 9031d86c97..30b85b5417 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -161,7 +161,6 @@ class ESPBTClient : public ESPBTDeviceListener { }; class ESP32BLETracker final : public Component, - public ble_device_base::BLEHub, #ifdef USE_OTA_STATE_LISTENER public ota::OTAGlobalStateListener, #endif @@ -186,19 +185,27 @@ class ESP32BLETracker final : public Component, void register_client(ESPBTClient *client); // ---- ble_device_base::BLEHub (the platform-neutral tracker contract) ---- - void register_listener(ble_device_base::ESPBTDeviceListener *listener) override; - void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override { + void register_listener(ble_device_base::ESPBTDeviceListener *listener); + void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { this->raw_advertisement_callback_ = callback; } - ble_device_base::HubCapabilities get_capabilities() const override { +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + void set_scanner_state_callback(ble_device_base::ScannerStateCallback callback) { + this->scanner_state_callback_ = callback; + } +#endif + static constexpr ble_device_base::HubCapabilities get_capabilities() { // scan_mode_switch is false: the mode is driven through this tracker's own // API (set_scan_active + restart), not the neutral request_scan_mode(). return {/* active_scan = */ true, /* merges_scan_response = */ true, /* gatt = */ true, /* scan_mode_switch = */ false}; } - void get_adapter_mac(uint8_t out[6]) override { this->parent_->get_mac_msb_first(out); } - bool scan_running() override { return this->scanner_state_ == ScannerState::RUNNING; } - bool scan_active() override { return this->scan_active_; } + void get_adapter_mac(uint8_t out[6]) { this->parent_->get_mac_msb_first(out); } + bool scan_running() { return this->scanner_state_ == ScannerState::RUNNING; } + bool scan_active() { return this->scan_active_; } + // The mode is driven through this tracker's own API (see get_capabilities); + // the neutral request refuses without changing any state. + bool request_scan_mode(bool active) { return false; } #ifdef USE_ESP32_BLE_DEVICE void print_bt_device_info(const ESPBTDevice &device); @@ -288,6 +295,9 @@ class ESP32BLETracker final : public Component, StaticVector neutral_listeners_; #endif ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{}; +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + ble_device_base::ScannerStateCallback scanner_state_callback_{}; +#endif #ifdef USE_ESP32_BLE_DEVICE /// Per-period "Found device" DEBUG log with MAC dedup (shared ble_device_base impl) ble_device_base::DiscoveredDeviceLog discovered_log_; diff --git a/esphome/components/ln882h_ble_tracker/__init__.py b/esphome/components/ln882h_ble_tracker/__init__.py index ceb2aeffec..45f1b95164 100644 --- a/esphome/components/ln882h_ble_tracker/__init__.py +++ b/esphome/components/ln882h_ble_tracker/__init__.py @@ -127,6 +127,9 @@ async def stop_scan_action_to_code( async def to_code(config: ConfigType) -> None: + # Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h. + cg.add_define("USE_LN882H_BLE_TRACKER") + var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h index 1ad36c40d4..9c0e0b2f1a 100644 --- a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h @@ -26,7 +26,6 @@ namespace esphome::ln882h_ble_tracker { // --------------------------------------------------------------------------- class LN882HBLETracker : public Component, - public ble_device_base::BLEHub, public Parented, public ln882h_ble::BLEScanListener #ifdef USE_OTA_STATE_LISTENER @@ -75,15 +74,15 @@ class LN882HBLETracker : public Component, void stop_scan(); // ---- ble_device_base::BLEHub contract ---- - void register_listener(ble_device_base::ESPBTDeviceListener *listener) override { + void register_listener(ble_device_base::ESPBTDeviceListener *listener) { #ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT this->listeners_.push_back(listener); #endif } - void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override { + void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { this->raw_advertisement_callback_ = callback; } - ble_device_base::HubCapabilities get_capabilities() const override { + static constexpr ble_device_base::HubCapabilities get_capabilities() { // The LN882H controller supports active scanning; adv + scan response arrive // as separate reports and are merged by this tracker (Bluedroid semantics). // The SDK's GATT client is not exposed. @@ -92,15 +91,15 @@ class LN882HBLETracker : public Component, } // The controller stores the address LSB-first (BLE convention); the contract // wants printable (MSB-first) order. - void get_adapter_mac(uint8_t out[6]) override { + void get_adapter_mac(uint8_t out[6]) { uint8_t mac[6]; this->parent_->get_mac_lsb_first(mac); for (int i = 0; i < 6; i++) out[i] = mac[5 - i]; } - bool scan_running() override { return this->scan_running_; } - bool scan_active() override { return this->scan_active_; } - bool request_scan_mode(bool active) override; + bool scan_running() { return this->scan_running_; } + bool scan_active() { return this->scan_active_; } + bool request_scan_mode(bool active); // ---- ln882h_ble::BLEScanListener ---- // Delivered by the controller's loop() on the ESPHome main task — the diff --git a/esphome/components/rp2_ble_tracker/__init__.py b/esphome/components/rp2_ble_tracker/__init__.py index 5840185768..15c1229a85 100644 --- a/esphome/components/rp2_ble_tracker/__init__.py +++ b/esphome/components/rp2_ble_tracker/__init__.py @@ -55,6 +55,9 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: + # Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h. + cg.add_define("USE_RP2_BLE_TRACKER") + var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h index 8106763489..054f6a65d2 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h @@ -17,7 +17,6 @@ namespace esphome::rp2_ble_tracker { class RP2BLETracker : public Component, - public ble_device_base::BLEHub, public rp2040_ble::BLEScanListener, public Parented #ifdef USE_OTA_STATE_LISTENER @@ -51,15 +50,15 @@ class RP2BLETracker : public Component, void stop_scan(); // ---- ble_device_base::BLEHub contract ---- - void register_listener(ble_device_base::ESPBTDeviceListener *listener) override { + void register_listener(ble_device_base::ESPBTDeviceListener *listener) { #ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT this->listeners_.push_back(listener); #endif } - void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override { + void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { this->raw_advertisement_callback_ = callback; } - ble_device_base::HubCapabilities get_capabilities() const override { + static constexpr ble_device_base::HubCapabilities get_capabilities() { // BTstack delivers scan responses as separate advertisement reports rather // than merging them into the advertisement — consumers relying on // scan-response fields (device names) get them only where the receiver @@ -74,10 +73,10 @@ class RP2BLETracker : public Component, } // The controller stores the address in printable (MSB-first) order, which is // exactly what the contract wants. - void get_adapter_mac(uint8_t out[6]) override { this->parent_->get_mac_msb_first(out); } - bool scan_running() override { return this->scan_running_; } - bool scan_active() override { return this->scan_active_; } - bool request_scan_mode(bool active) override; + void get_adapter_mac(uint8_t out[6]) { this->parent_->get_mac_msb_first(out); } + bool scan_running() { return this->scan_running_; } + bool scan_active() { return this->scan_active_; } + bool request_scan_mode(bool active); // ---- rp2040_ble::BLEScanListener ---- // Delivered by the controller's loop() on the ESPHome main loop — the diff --git a/esphome/core/defines.h b/esphome/core/defines.h index fd351356df..ad24d27369 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -252,12 +252,12 @@ // platforms whose API/network types the proxy header cannot assume. #if defined(USE_ESP32) || defined(USE_LIBRETINY) || defined(USE_RP2) #define USE_BLUETOOTH_PROXY -#define USE_BLE_SCANNER_STATE_CALLBACK // Mirror the codegen values per platform: _to_code_esp32() emits the connection -// count (default 3), _to_code_ble_hub() emits the slot count (1 on rp2, 0 on -// advertisement-only hubs) — so static analysis checks the same -// std::array instantiation a real build produces. +// count (default 3) and the scanner-state push slot, _to_code_ble_hub() emits +// the slot count (1 on rp2, 0 on advertisement-only hubs) — so static analysis +// checks the same instantiations a real build produces. #ifdef USE_ESP32 +#define USE_BLE_SCANNER_STATE_CALLBACK #define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 #elif defined(USE_RP2) #define BLUETOOTH_PROXY_MAX_CONNECTIONS 1 @@ -305,6 +305,7 @@ #define USE_ESP32_BLE_SERVER_DESCRIPTOR_ON_WRITE #define USE_ESP32_BLE_SERVER_ON_CONNECT #define USE_ESP32_BLE_SERVER_ON_DISCONNECT +#define USE_ESP32_BLE_TRACKER #define ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT 1 #define ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT 1 #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 @@ -465,6 +466,7 @@ #define USE_LOGGER_USB_CDC #define USE_SOCKET_IMPL_LWIP_TCP #define USE_RP2040_BLE +#define USE_RP2_BLE_TRACKER #define RP2040_BLE_SCAN_LISTENER_COUNT 1 #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 #define USE_BLE_GATT_CLIENT @@ -489,6 +491,14 @@ #define BK72XX_BLE_SCAN_LISTENER_COUNT 1 #define USE_LN882H_BLE #define LN882H_BLE_SCAN_LISTENER_COUNT 1 +// One tracker arm per build: ln882x gets its real hub; bk72xx also stands in +// for hub-less LibreTiny chips (rtl87xx) so bluetooth_proxy.h has a BLEHub +// to parse against. +#ifdef USE_LN882X +#define USE_LN882H_BLE_TRACKER +#else +#define USE_BK72XX_BLE_TRACKER +#endif #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 #define USE_CAPTIVE_PORTAL #define USE_WIFI_SCAN_RESULTS_LOCK diff --git a/tests/component_tests/ble_device_base/config/ln882h_tracker.yaml b/tests/component_tests/ble_device_base/config/ln882h_tracker.yaml new file mode 100644 index 0000000000..891d65ecf6 --- /dev/null +++ b/tests/component_tests/ble_device_base/config/ln882h_tracker.yaml @@ -0,0 +1,7 @@ +esphome: + name: slotcount-ln882h-tracker + +ln882x: + board: generic-ln882h + +ln882h_ble_tracker: diff --git a/tests/component_tests/ble_device_base/test_hub_binding.py b/tests/component_tests/ble_device_base/test_hub_binding.py index 3dc71daf58..59aecc461b 100644 --- a/tests/component_tests/ble_device_base/test_hub_binding.py +++ b/tests/component_tests/ble_device_base/test_hub_binding.py @@ -1,6 +1,6 @@ """Tests for the BLE hub provider registry and the missing-hub diagnostics.""" -from collections.abc import Generator +from collections.abc import Callable, Generator from importlib import import_module from pathlib import Path @@ -202,3 +202,30 @@ def test_add_service_uuid_dispatches_by_width(monkeypatch: pytest.MonkeyPatch) - assert "0x00,0xff,0xee,0xdd" in emitted[2] with pytest.raises(ValueError, match="Unsupported UUID format"): ble_device_base.add_service_uuid(var, "123") + + +@pytest.mark.parametrize( + ("config_name", "define"), + [ + ("esp32_tracker_only.yaml", "USE_ESP32_BLE_TRACKER"), + ("rp2_tracker.yaml", "USE_RP2_BLE_TRACKER"), + ("bk72xx_tracker.yaml", "USE_BK72XX_BLE_TRACKER"), + ("ln882h_tracker.yaml", "USE_LN882H_BLE_TRACKER"), + ], +) +def test_every_tracker_emits_its_alias_define( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_name: str, + define: str, +) -> None: + """Each tracker's codegen must emit its USE_*_BLE_TRACKER define - the + ble_hub_impl.h alias ladder selects on it. Checked through real codegen + (the other two legs of the invariant, the ladder arm and the defines.h + mirror, are compile-enforced: a missing arm fails any build containing a + BLEHub consumer - today bluetooth_proxy, which CI compiles or tidy-parses + on every tracker platform - and clang-tidy compiles each arm's + static_assert).""" + generate_main(component_config_path(config_name)) + + assert define in {d.name for d in CORE.defines}, f"{define} not emitted by codegen" diff --git a/tests/components/ble_device_base/test_raw_callback.cpp b/tests/components/ble_device_base/test_raw_callback.cpp index 4d72c8fb18..62a9aebb81 100644 --- a/tests/components/ble_device_base/test_raw_callback.cpp +++ b/tests/components/ble_device_base/test_raw_callback.cpp @@ -13,17 +13,12 @@ namespace esphome::ble_device_base::testing { // // The in-tree emit site (BK72xxBLETracker::on_scan_report) compiles against // the Beken SDK and cannot run host-side, so the guard-and-fire semantics are -// pinned here through a minimal host BLEHub implementation instead. +// pinned here through a minimal host hub carrying only the slot under test. namespace { -class FakeHub : public BLEHub { +class FakeHub { public: - void register_listener(ESPBTDeviceListener *listener) override {} - void set_raw_advertisement_callback(RawAdvertisementCallback callback) override { this->callback_ = callback; } - HubCapabilities get_capabilities() const override { return {false, false, false}; } - void get_adapter_mac(uint8_t out[6]) override {} - bool scan_running() override { return false; } - bool scan_active() override { return false; } + void set_raw_advertisement_callback(RawAdvertisementCallback callback) { this->callback_ = callback; } /// The emit path every tracker implements: fire only when a subscriber is set. void emit(const RawAdvertisement &adv) { diff --git a/tests/components/ble_device_base/test_scan_mode_request.cpp b/tests/components/ble_device_base/test_scan_mode_request.cpp deleted file mode 100644 index 9125eb6f2f..0000000000 --- a/tests/components/ble_device_base/test_scan_mode_request.cpp +++ /dev/null @@ -1,76 +0,0 @@ -#include - -#include - -#include "esphome/components/ble_device_base/ble_hub.h" - -namespace esphome::ble_device_base::testing { - -// Pins the request_scan_mode() contract: the base default refuses (so hubs -// without a mode switch — and out-of-tree trackers — keep building and -// callers report the real state), while an overriding hub both honors the -// request and applies it. -namespace { - -class DefaultHub : public BLEHub { - public: - void register_listener(ESPBTDeviceListener *listener) override {} - void set_raw_advertisement_callback(RawAdvertisementCallback callback) override {} - HubCapabilities get_capabilities() const override { return {false, false, false}; } - void get_adapter_mac(uint8_t out[6]) override {} - bool scan_running() override { return false; } - // Backed by real state so "changes nothing" is observable: a base default - // that silently mutated the hub would flip this and fail the assertion. - bool scan_active() override { return this->active_; } - - protected: - bool active_{true}; -}; - -class SwitchingHub : public DefaultHub { - public: - HubCapabilities get_capabilities() const override { return {true, false, false, /* scan_mode_switch = */ true}; } - bool request_scan_mode(bool active) override { - this->active_ = active; - return true; - } -}; - -// The esp32 shape: the controller supports active scanning but the hub keeps -// the refusing default (mode is driven through its own tracker API). -class CapableRefusingHub : public DefaultHub { - public: - HubCapabilities get_capabilities() const override { return {true, false, false}; } -}; - -} // namespace - -TEST(BLEHubScanModeRequest, DefaultRefusesAndChangesNothing) { - DefaultHub hub; - EXPECT_TRUE(hub.scan_active()); - EXPECT_FALSE(hub.request_scan_mode(false)); - // Refused, not applied-and-reported-false: the state is untouched. - EXPECT_TRUE(hub.scan_active()); - EXPECT_FALSE(hub.request_scan_mode(true)); - EXPECT_TRUE(hub.scan_active()); -} - -TEST(BLEHubScanModeRequest, OverrideHonorsAndApplies) { - SwitchingHub hub; - EXPECT_TRUE(hub.get_capabilities().scan_mode_switch); - EXPECT_TRUE(hub.request_scan_mode(true)); - EXPECT_TRUE(hub.scan_active()); - EXPECT_TRUE(hub.request_scan_mode(false)); - EXPECT_FALSE(hub.scan_active()); -} - -TEST(BLEHubScanModeRequest, CapabilityAndSwitchAreIndependent) { - CapableRefusingHub hub; - EXPECT_TRUE(hub.get_capabilities().active_scan); - // The esp32 shape advertises no runtime switch, and the request refuses. - EXPECT_FALSE(hub.get_capabilities().scan_mode_switch); - EXPECT_FALSE(hub.request_scan_mode(false)); - EXPECT_TRUE(hub.scan_active()); -} - -} // namespace esphome::ble_device_base::testing From ab12e5490f680ab337236fd149ab59e96e1b373b Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 9 Aug 2026 15:00:44 -0700 Subject: [PATCH 1340/1815] [modbus] Rename send_pdu() to queue_pdu() (#18196) Co-authored-by: Claude Co-authored-by: J. Nick Koston --- esphome/components/modbus/modbus.cpp | 14 +- esphome/components/modbus/modbus.h | 99 +++--- .../components/modbus_client/modbus_client.h | 29 +- .../modbus_controller/modbus_controller.cpp | 15 +- esphome/components/pzemac/pzemac.cpp | 2 +- esphome/components/pzemdc/pzemdc.cpp | 2 +- tests/components/modbus/heap_probe_test.cpp | 8 +- .../modbus/modbus_client_hub_test.cpp | 286 ++++++++++-------- 8 files changed, 260 insertions(+), 195 deletions(-) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index c9e443cd87..9f2527d9fb 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -883,8 +883,8 @@ void ModbusClientHub::sweep_() { } // Raw send for client: pushes to tx queue. Everything except the CRC must be contained in payload. -bool ModbusClientHub::send_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device, - CommandOptions options) { +bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device, + CommandOptions options) { // Requests refused here never enter the machine and get no callback - the false return is it. if (pdu.empty()) { ESP_LOGW(TAG, "Empty PDU refused for address %" PRIu8, address); @@ -995,7 +995,7 @@ void ModbusClientHub::send_raw(const std::vector &payload, ModbusClient ESP_LOGW(TAG, "send_raw() payload too short to contain a PDU, refused"); return; } - this->send_pdu(payload[0], std::span(payload).subspan(1), device); + this->queue_pdu(payload[0], std::span(payload).subspan(1), device); } // Send raw command for server replies immediately. Except CRC everything must be contained in payload @@ -1077,7 +1077,7 @@ void ModbusClientDevice::dispatch_response_(std::span request_pdu // - On failure (status engaged) the response is empty by design (see on_error()), so only the request // is validated. bool custom = !helpers::is_client_pdu_standard(request_pdu.data(), request_pdu.size()); - if (!custom && !status.has_value()) { + if (!custom && succeeded(status)) { custom = !helpers::is_server_pdu_standard(response_pdu.data(), response_pdu.size()); if (!custom && helpers::is_function_code_read(static_cast(function_code))) { const bool bits = @@ -1104,7 +1104,7 @@ void ModbusClientDevice::dispatch_response_(std::span request_pdu // capacity of RegisterValues); a mismatch was diverted to on_custom_response(), never clamped. On // failure the registers span is empty. RegisterValues registers; - if (!status.has_value()) { + if (succeeded(status)) { for (size_t i = 0; i != count_or_value; i++) { registers.push_back(helpers::get_data(response_pdu.data(), 2 + 2 * i)); } @@ -1124,7 +1124,7 @@ void ModbusClientDevice::dispatch_response_(std::span request_pdu // PackedBits::operator[] is unchecked, so size() must never promise bits with no bytes behind them. std::span packed_bytes; uint16_t count = 0; - if (!status.has_value()) { + if (succeeded(status)) { packed_bytes = response_pdu.subspan(2); count = count_or_value; } @@ -1141,7 +1141,7 @@ void ModbusClientDevice::dispatch_response_(std::span request_pdu // copy. On an exception the response has no value and the request copy is the only one. case FunctionCode::WRITE_SINGLE_REGISTER: case FunctionCode::WRITE_SINGLE_COIL: { - const uint16_t value = (!status.has_value() && response_pdu.size() >= WRITE_SINGLE_PDU_SIZE) + const uint16_t value = (succeeded(status) && response_pdu.size() >= WRITE_SINGLE_PDU_SIZE) ? helpers::get_data(response_pdu.data(), 3) : count_or_value; if (function_code == FunctionCode::WRITE_SINGLE_REGISTER) { diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 274b10f9b4..3b6028e90a 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -262,19 +262,31 @@ class ModbusClientHub : public Modbus { void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_ms_ = time_in_ms; } bool tx_buffer_empty(); bool tx_blocked() override; - ESPDEPRECATED("Use send_pdu() with create_client_pdu() instead. Removed in 2026.10.0", "2026.4.0") + ESPDEPRECATED("Use queue_pdu() with create_client_pdu() instead. Removed in 2026.10.0", "2026.4.0") void send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0, const uint8_t *payload = nullptr, ModbusClientDevice *device = nullptr) { - this->send_pdu(address, - helpers::create_client_pdu((FunctionCode) function_code, start_address, number_of_entities, payload, - payload_len), - device); + this->queue_pdu(address, + helpers::create_client_pdu((FunctionCode) function_code, start_address, number_of_entities, payload, + payload_len), + device); }; - // Queue a request; true once it is a live entry (resolving in one terminal), false if it never - // entered the machine (empty/oversize PDU, full queue, anonymous or over-cap duplicate) - no callback. - bool send_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device = nullptr, - CommandOptions options = {}); - ESPDEPRECATED("Use send_pdu(payload[0], , device) instead. Removed in 2027.2.0", "2026.8.0") + /// Queue a request. The name says queue, not send: the frame is appended to the transmit queue and + /// goes out later from loop(), so a true return means accepted into the machine (it will resolve in + /// exactly one terminal callback), NOT that anything reached the wire - that is on_sent(). False means + /// it never entered the machine at all (empty or oversize PDU, full queue, anonymous or over-cap + /// duplicate) and no callback of any kind will follow; the false return is the whole story. + bool queue_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device = nullptr, + CommandOptions options = {}); + // Remove before 2027.2.0. Deliberately the signature 2026.7.4 shipped - void, and no CommandOptions: + // the bool return and the options argument arrived after that release, so nothing external can be + // relying on them under this name. Callers who want the queued/refused answer move to queue_pdu(). + ESPDEPRECATED("Use queue_pdu() instead - the call queues a request, it does not send one, and it " + "reports whether the request was accepted. Removed in 2027.2.0", + "2026.8.0") + void send_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device = nullptr) { + this->queue_pdu(address, pdu, device); + } + ESPDEPRECATED("Use queue_pdu(payload[0], , device) instead. Removed in 2027.2.0", "2026.8.0") void send_raw(const std::vector &payload, ModbusClientDevice *device = nullptr); // Clear an address's commands; each un-run request resolves via on_not_sent(), but a frame on the // wire still runs to its usual terminal. clear_tx_queue_for_device() instead discards silently. @@ -315,6 +327,12 @@ class ModbusClientHub : public Modbus { // Transaction status: std::nullopt on success, otherwise a Modbus exception code using ResponseStatus = std::optional; +/// True when a transaction carried no exception. The optional holds the exception, so has_value() means +/// the request FAILED - the inverse of how "status" usually reads. Prefer this at the call site; the +/// bare !status.has_value() has already been mistaken for a failure check more than once. Where the code +/// is going to unwrap the exception anyway, status.has_value() followed by status.value() stays clearer. +inline bool succeeded(ResponseStatus status) { return !status.has_value(); } + // Register values exchanged with server handlers, in host byte order. Sized at the larger of the two protocol // maxima (read = 125 / 0x7D, write = 123 / 0x7B); the per-direction count limit is enforced by the hub, not by // the capacity of this type. @@ -373,7 +391,7 @@ class ModbusServerHub : public Modbus { /// Callback contract. Each accepted request ends in exactly ONE terminal: on_response() (data), /// on_error() (exception), on_no_response() (timeout/interruption), or on_not_sent() (dropped by -/// clear_tx_queue_for_address before transmission). A request refused at send_pdu() (false return) +/// clear_tx_queue_for_address before transmission). A request refused at queue_pdu() (false return) /// gets none. on_sent() is additional, once per transmission, never for an on_not_sent() request. /// on_response()/on_error() fire at parse time and on_no_response() at the send-wait watchdog, all /// from a quiescent hub; only on_not_sent() is delivered by the sweep. Sending or clearing from @@ -383,7 +401,7 @@ class ModbusServerHub : public Modbus { /// merges into it). /// /// Invariants: -/// - Public entry points (send_pdu/clear_tx_queue_*) only append to the queue or mutate an existing +/// - Public entry points (queue_pdu/clear_tx_queue_*) only append to the queue or mutate an existing /// entry through its callback-free transition methods. /// - Public entry points can never trigger a callback synchronously. /// - Callbacks are delivered only from within loop(). @@ -485,66 +503,75 @@ class ModbusClientDevice { /// to handle custom traffic (which also silences the warning). virtual void on_custom_response(std::span request_pdu, std::span response_pdu, ResponseStatus status); - ESPDEPRECATED("Use the typed read_*/write_* helpers or send_pdu() instead. Removed in 2027.2.0", "2026.8.0") + ESPDEPRECATED("Use the typed read_*/write_* helpers or queue_pdu() instead. Removed in 2027.2.0", "2026.8.0") void send(uint8_t function, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0, const uint8_t *payload = nullptr) { - this->parent_->send_pdu( + this->parent_->queue_pdu( this->address_, helpers::create_client_pdu((FunctionCode) function, start_address, number_of_entities, payload, payload_len), this); } - /// See ModbusClientHub::send_pdu(): true = accepted (a terminal callback will follow), - /// false = refused at the door (no callback). - bool send_pdu(std::span pdu, CommandOptions options = {}) { - return this->parent_->send_pdu(this->address_, pdu, this, options); + /// See ModbusClientHub::queue_pdu(): true = accepted into the queue and a terminal callback will + /// follow, false = refused at the door and nothing further happens. Neither means the frame is on + /// the wire; on_sent() reports that. + bool queue_pdu(std::span pdu, CommandOptions options = {}) { + return this->parent_->queue_pdu(this->address_, pdu, this, options); } - ESPDEPRECATED("Use send_pdu() instead (the device address is prepended for you). Removed in 2027.2.0", "2026.8.0") - bool send_raw(const std::vector &payload) { + // Remove before 2027.2.0. As on the hub, this is the signature 2026.7.4 shipped: void, no options. + ESPDEPRECATED("Use queue_pdu() instead - the call queues a request, it does not send one, and it " + "reports whether the request was accepted. Removed in 2027.2.0", + "2026.8.0") + void send_pdu(std::span pdu) { this->queue_pdu(pdu); } + ESPDEPRECATED("Use queue_pdu() instead (the device address is prepended for you). Removed in 2027.2.0", "2026.8.0") + void send_raw(const std::vector &payload) { if (payload.empty()) - return false; // too short to contain a PDU; refused at the door like any invalid send - return this->parent_->send_pdu(payload[0], std::span(payload).subspan(1), this); + return; // too short to contain a PDU; refused at the door like any invalid send + this->parent_->queue_pdu(payload[0], std::span(payload).subspan(1), this); } + // The typed request builders below all queue through queue_pdu(), so they share its contract: true + // means the request is queued and will resolve in exactly one terminal callback, false means it was + // refused outright with no callback. Neither says the frame has been transmitted - on_sent() does. // Reads via the table-appropriate function code; an unreadable entity type maps to INVALID, which - // create_read_pdu() rejects into an empty PDU and send_pdu() refuses with a false return. + // create_read_pdu() rejects into an empty PDU and queue_pdu() refuses with a false return. bool read_entities(EntityType entity_type, uint16_t start_address, uint16_t number_of_entities, CommandOptions options = {}) { - return this->send_pdu(helpers::create_read_pdu(helpers::modbus_register_read_function(entity_type), start_address, - number_of_entities), - options); + return this->queue_pdu(helpers::create_read_pdu(helpers::modbus_register_read_function(entity_type), start_address, + number_of_entities), + options); } bool read_input_registers(uint16_t start_address, uint16_t number_of_registers, CommandOptions options = {}) { - return this->send_pdu( + return this->queue_pdu( helpers::create_read_pdu(FunctionCode::READ_INPUT_REGISTERS, start_address, number_of_registers), options); } bool read_holding_registers(uint16_t start_address, uint16_t number_of_registers, CommandOptions options = {}) { - return this->send_pdu( + return this->queue_pdu( helpers::create_read_pdu(FunctionCode::READ_HOLDING_REGISTERS, start_address, number_of_registers), options); } bool read_coils(uint16_t start_address, uint16_t number_of_coils, CommandOptions options = {}) { - return this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_COILS, start_address, number_of_coils), options); + return this->queue_pdu(helpers::create_read_pdu(FunctionCode::READ_COILS, start_address, number_of_coils), options); } bool read_discrete_inputs(uint16_t start_address, uint16_t number_of_inputs, CommandOptions options = {}) { - return this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_DISCRETE_INPUTS, start_address, number_of_inputs), - options); + return this->queue_pdu( + helpers::create_read_pdu(FunctionCode::READ_DISCRETE_INPUTS, start_address, number_of_inputs), options); } bool write_single_register(uint16_t start_address, uint16_t value) { - return this->send_pdu(helpers::create_write_single_register_pdu(start_address, value)); + return this->queue_pdu(helpers::create_write_single_register_pdu(start_address, value)); } bool write_single_coil(uint16_t address, bool value) { - return this->send_pdu(helpers::create_write_single_coil_pdu(address, value)); + return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value)); } bool write_multiple_registers(uint16_t start_address, std::span values) { - return this->send_pdu(helpers::create_write_registers_pdu(start_address, values)); + return this->queue_pdu(helpers::create_write_registers_pdu(start_address, values)); } /// Note: std::vector cannot bind to std::span; use a contiguous bool container or the packed /// overload. bool write_multiple_coils(uint16_t start_address, std::span values) { - return this->send_pdu(helpers::create_write_coils_pdu(start_address, values)); + return this->queue_pdu(helpers::create_write_coils_pdu(start_address, values)); } /// Packed variant: a PackedBits view (the same layout on_read_coils() delivers), so /// read-modify-write needs no unpack/repack. bool write_multiple_coils(uint16_t start_address, PackedBits bits) { - return this->send_pdu(helpers::create_write_coils_pdu(start_address, bits)); + return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits)); } inline void clear_tx_queue_for_address() { this->parent_->clear_tx_queue_for_address(this->address_); } inline void clear_tx_queue_for_device() { this->parent_->clear_tx_queue_for_device(this); } diff --git a/esphome/components/modbus_client/modbus_client.h b/esphome/components/modbus_client/modbus_client.h index 599be85cb8..f9a00d65f6 100644 --- a/esphome/components/modbus_client/modbus_client.h +++ b/esphome/components/modbus_client/modbus_client.h @@ -33,7 +33,11 @@ template class ClientActionBase : public Action, public m /// The frame was written to the wire: fires once per transmission, before any reply, and never for a /// send that ended in on_not_sent. request_pdu is the PDU sent (function code + data). void on_sent(std::span request_pdu) override { this->sent_trigger_.trigger(request_pdu); } - /// Never reached the wire (tx queue full, cleared, or a duplicate write dropped by the hub's dedup). + /// Never reached the wire, from either of two sources. The hub calls this for a request it accepted + /// and then dropped, which happens only when clear_tx_queue_for_address() retires it - a modbus + /// device going offline, say. Everything the hub refuses at the door instead returns false from + /// queue_pdu() with no callback at all, so send_or_resolve_() below turns those into this same + /// callback: a full queue, a duplicate write, or an empty PDU from a rejecting builder. void on_not_sent(std::span request_pdu) override { this->not_sent_trigger_.trigger(request_pdu); } /// A Modbus exception reply. Lives here beside its trigger so every action subclass gets the pairing: /// register_client_action() wires on_error for all of them, so a derived class must not have to @@ -64,7 +68,7 @@ template class ClientActionBase : public Action, public m /// Takes a span, not a PduBuffer: the builders return right-sized buffers (a read PDU is 5 bytes), and /// a PduBuffer parameter would widen each one to the 253-byte maximum just to cross the call. void send_or_resolve_(std::span pdu) { - if (!this->send_pdu(pdu)) + if (!this->queue_pdu(pdu)) this->on_not_sent(pdu); } @@ -107,7 +111,9 @@ template class ModbusClientSendAction : public ClientActionBase< /// valid for the duration of the trigger. (For a typed-built request the gate can only divert on the /// response, never with an exception status - real device exceptions arrive via on_error, which /// ClientActionBase already routes straight to its trigger, so the typed callbacks below only ever see a -/// success status.) +/// success status.) Each typed callback still checks succeeded() before firing its trigger: that branch +/// is unreachable today, and is kept so a future change to that interception cannot silently deliver an +/// exception as a successful reply. template class TypedClientActionBase : public ClientActionBase { public: Trigger, std::span> *get_custom_response_trigger() { @@ -127,11 +133,6 @@ template class TypedClientActionBase : public ClientActionBase, std::span> custom_response_trigger_; bool custom_response_handled_{false}; }; @@ -154,7 +155,7 @@ template class ReadRegistersAction : public TypedClientActionBas } void on_read_registers(modbus::EntityType entity_type, uint16_t start_address, std::span registers, modbus::ResponseStatus status) override { - if (this->is_success_(status)) + if (modbus::succeeded(status)) this->response_trigger_.trigger(registers); } @@ -181,7 +182,7 @@ template class ReadBitsAction : public TypedClientActionBaseis_success_(status)) + if (modbus::succeeded(status)) this->response_trigger_.trigger(bits); } @@ -204,7 +205,7 @@ template class WriteSingleRegisterAction : public TypedClientAct modbus::helpers::create_write_single_register_pdu(this->start_address_.value(x...), this->value_.value(x...))); } void on_write_single_register(uint16_t address, uint16_t value, modbus::ResponseStatus status) override { - if (this->is_success_(status)) + if (modbus::succeeded(status)) this->response_trigger_.trigger(); } @@ -226,7 +227,7 @@ template class WriteSingleCoilAction : public TypedClientActionB modbus::helpers::create_write_single_coil_pdu(this->start_address_.value(x...), this->value_.value(x...))); } void on_write_single_coil(uint16_t address, bool value, modbus::ResponseStatus status) override { - if (this->is_success_(status)) + if (modbus::succeeded(status)) this->response_trigger_.trigger(); } @@ -270,7 +271,7 @@ template class WriteMultipleRegistersAction : public TypedClient } void on_write_multiple_registers(uint16_t start_address, std::span registers, modbus::ResponseStatus status) override { - if (this->is_success_(status)) + if (modbus::succeeded(status)) this->response_trigger_.trigger(); } @@ -318,7 +319,7 @@ template class WriteMultipleCoilsAction : public TypedClientActi } void on_write_multiple_coils(uint16_t start_address, modbus::PackedBits bits, modbus::ResponseStatus status) override { - if (this->is_success_(status)) + if (modbus::succeeded(status)) this->response_trigger_.trigger(); } diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index c4161d454f..da9d29887e 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -160,10 +160,11 @@ void ModbusController::queue_command(ModbusCommandItem command) { } void ModbusController::unqueue_command(const ModbusCommandItem *command) { - // Called as the last action of the command's own callback, and from send() after send_pdu (which may - // synchronously call on_not_sent). Destroying `command` here would leave send() and the hub touching a - // freed object, so we only FLAG it; sweep_completed_one_shots_() erases it later at a safe point. No-op - // for polling commands (they persist and are not in the one-shot list). + // Called as the last action of the command's own callback (on_response/on_error/on_not_sent/ + // on_no_response), which the hub runs from inside its sweep while this entry is still live. + // Destroying `command` here would leave the hub touching a freed object, so we only FLAG it; + // sweep_completed_one_shots_() erases it later at a safe point. No-op for polling commands + // (they persist and are not in the one-shot list). for (auto &item : this->one_shot_command_items_) { if (item.get() == command) { item->pending_removal = true; @@ -494,13 +495,13 @@ ModbusCommandItem ModbusCommandItem::create_custom_command( bool ModbusCommandItem::send() { bool accepted; if (this->function_code_ != FunctionCode::CUSTOM) { - accepted = this->send_pdu(modbus::helpers::create_client_pdu( + accepted = this->queue_pdu(modbus::helpers::create_client_pdu( this->function_code_, this->start_address_, this->register_count_, this->payload.empty() ? nullptr : this->payload.data(), this->payload.size())); } else { // Custom command: the bytes are a complete raw frame (address + PDU). Send the PDU to the frame's own // address (which may differ from this controller's); the hub appends the CRC and routes the response - // back to this item by pointer. (send_raw() is deprecated, so send_pdu() is called with the extracted + // back to this item by pointer. (send_raw() is deprecated, so queue_pdu() is called with the extracted // address. Raw-frame semantics are kept here; the custom_pdu migration is a later step.) std::span frame = this->custom_data_ != nullptr ? std::span(*this->custom_data_) : this->payload; @@ -508,7 +509,7 @@ bool ModbusCommandItem::send() { ESP_LOGW(TAG, "Empty custom command frame, not sent"); accepted = false; } else { - accepted = this->parent_->send_pdu(frame[0], frame.subspan(1), this); + accepted = this->parent_->queue_pdu(frame[0], frame.subspan(1), this); } } // The on_command_sent trigger fires from on_sent() when the frame actually reaches the wire. diff --git a/esphome/components/pzemac/pzemac.cpp b/esphome/components/pzemac/pzemac.cpp index 5651e07af0..d817888922 100644 --- a/esphome/components/pzemac/pzemac.cpp +++ b/esphome/components/pzemac/pzemac.cpp @@ -77,7 +77,7 @@ void PZEMAC::dump_config() { void PZEMAC::reset_energy_() { const uint8_t pdu[] = {PZEM_CMD_RESET_ENERGY}; - this->send_pdu(pdu); + this->queue_pdu(pdu); } } // namespace esphome::pzemac diff --git a/esphome/components/pzemdc/pzemdc.cpp b/esphome/components/pzemdc/pzemdc.cpp index 5e505cde0c..926ad83f09 100644 --- a/esphome/components/pzemdc/pzemdc.cpp +++ b/esphome/components/pzemdc/pzemdc.cpp @@ -65,7 +65,7 @@ void PZEMDC::dump_config() { void PZEMDC::reset_energy() { const uint8_t pdu[] = {PZEM_CMD_RESET_ENERGY}; - this->send_pdu(pdu); + this->queue_pdu(pdu); } } // namespace esphome::pzemdc diff --git a/tests/components/modbus/heap_probe_test.cpp b/tests/components/modbus/heap_probe_test.cpp index ddf905a8df..869b280b0d 100644 --- a/tests/components/modbus/heap_probe_test.cpp +++ b/tests/components/modbus/heap_probe_test.cpp @@ -134,7 +134,7 @@ TEST(HeapProbe, QueueingTypicalCommandsIsAllocationFree) { size_t total = 0; for (int i = 0; i != n; i++) { req[2] = static_cast(i); // distinct start addresses: identical frames would dedup, not enqueue - total += sample([&] { device.send_pdu(req); }).count; + total += sample([&] { device.queue_pdu(req); }).count; } printf("HEAPPROBE queue_%d_typical_commands total_allocs=%zu\n", n, total); EXPECT_EQ(total, 0u); @@ -151,11 +151,11 @@ TEST(HeapProbe, WriteBehindQueuedReadsAppendsAllocationFree) { req.assign(read_pdu, read_pdu + sizeof(read_pdu)); for (int i = 0; i != 3; i++) { req[2] = static_cast(i); // distinct start addresses: identical frames would dedup, not enqueue - device.send_pdu(req); + device.queue_pdu(req); } const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - Sample append = sample([&] { device.send_pdu(write_pdu); }); + Sample append = sample([&] { device.queue_pdu(write_pdu); }); printf("HEAPPROBE write_append count=%zu bytes=%zu\n", append.count, append.bytes); EXPECT_EQ(append.count, 0u); } @@ -180,7 +180,7 @@ TEST(HeapProbe, ResponseHandlingIsAllocationFreeAfterWarmup) { const uint8_t small_resp[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; auto round_trip = [&](std::span response_pdu) { - device.send_pdu(req); + device.queue_pdu(req); hub.loop(); // transmit; the tx queue is empty during the measured receive below uart.inject_frame(0x02, response_pdu); return sample([&] { hub.loop(); }); // receive + parse + match + dispatch diff --git a/tests/components/modbus/modbus_client_hub_test.cpp b/tests/components/modbus/modbus_client_hub_test.cpp index 4d5b4e7ee8..c2a36c0da7 100644 --- a/tests/components/modbus/modbus_client_hub_test.cpp +++ b/tests/components/modbus/modbus_client_hub_test.cpp @@ -116,7 +116,7 @@ TEST(ModbusClientHubNoResponse, RetryRequeuesWaitingFrame) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/true); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); ASSERT_EQ(hub.queued_frames(), 1u); hub.force_send_next(); ASSERT_EQ(hub.queued_frames(), 0u); @@ -141,7 +141,7 @@ TEST(ModbusClientHubNoResponse, NoRetryDropsWaitingFrame) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/false); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); hub.timeout_waiting(); @@ -157,7 +157,7 @@ TEST(ModbusClientHubNoResponse, DetachedDeviceIsNotNotified) { NoResponseProbeHub hub; { RetryingDevice device(&hub, 0x02, /*retry=*/true); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); // device destructor clears its queue entries, including the waiting frame's device pointer } @@ -177,7 +177,7 @@ TEST(ModbusClientHubNoResponse, RetryBehindInterruptedShell) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/true); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); // A frame from the wrong address (0x07, expected 0x02) hits the unexpected-frame branch. @@ -204,7 +204,7 @@ TEST(ModbusClientHubNoResponse, InterruptedShellDeclinedRetryRetiresOnRelease) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/false); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); const uint8_t stray_pdu[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; @@ -229,7 +229,7 @@ TEST(ModbusClientHubNoResponse, MidCallbackClearCancelsRetry) { NoResponseProbeHub hub; ClearingRetryDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); hub.timeout_waiting(); @@ -246,9 +246,9 @@ TEST(ModbusClientHubPriority, WritesSendBeforeQueuedReads) { const uint8_t read_a[] = {0x03, 0x01, 0x00, 0x00, 0x02}; const uint8_t read_b[] = {0x03, 0x02, 0x00, 0x00, 0x02}; const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - device.send_pdu(read_a); - device.send_pdu(read_b); - device.send_pdu(write_pdu); + device.queue_pdu(read_a); + device.queue_pdu(read_b); + device.queue_pdu(write_pdu); ASSERT_EQ(hub.queued_frames(), 3u); hub.force_send_next(); @@ -266,8 +266,8 @@ TEST(ModbusClientHubPriority, DuplicateQueuedFrameAbsorbedNotDuplicated) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/false); - device.send_pdu(read_pdu()); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); + device.queue_pdu(read_pdu()); ASSERT_EQ(hub.queued_frames(), 1u); EXPECT_EQ(hub.queued(0).pending, 2u); // one entry standing for two accepted requests @@ -280,9 +280,9 @@ TEST(ModbusClientHubPriority, InFlightDuplicateRunsOnceMore) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/false); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); - device.send_pdu(read_pdu()); // duplicate of the waiting frame + device.queue_pdu(read_pdu()); // duplicate of the waiting frame EXPECT_EQ(hub.queued_frames(), 0u); // not queued twice EXPECT_EQ(hub.waiting_command().pending, 2u); @@ -303,9 +303,9 @@ TEST(ModbusClientHubPriority, AbsorbedRequestSurvivesDeviceRetry) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/true); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); - device.send_pdu(read_pdu()); // duplicate of the waiting frame -> absorbed + device.queue_pdu(read_pdu()); // duplicate of the waiting frame -> absorbed ASSERT_EQ(hub.waiting_command().pending, 2u); hub.timeout_waiting(); // no response; the device requests a retry @@ -459,8 +459,8 @@ TEST(ModbusClientHubPriority, WritesThenOneShotReadsThenContinuousPolls) { device.read_holding_registers(0x100, 2, {.continuous = true}); const uint8_t one_shot[] = {0x03, 0x02, 0x00, 0x00, 0x01}; const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - device.send_pdu(one_shot); - device.send_pdu(write_pdu); + device.queue_pdu(one_shot); + device.queue_pdu(write_pdu); ASSERT_EQ(hub.queued_frames(), 3u); EXPECT_EQ(hub.queued(0).priority(), CommandPriority::CONTINUOUS); EXPECT_EQ(hub.queued(1).priority(), CommandPriority::READ); @@ -482,7 +482,7 @@ TEST(ModbusClientHubPriority, ContinuousIgnoredForWrites) { RetryingDevice device(&hub, 0x02, /*retry=*/false); const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - device.send_pdu(write_pdu, {.continuous = true}); + device.queue_pdu(write_pdu, {.continuous = true}); ASSERT_EQ(hub.queued_frames(), 1u); EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE); EXPECT_FALSE(hub.queued(0).continuous); @@ -530,8 +530,8 @@ TEST(ModbusClientHubPriority, DuplicateQueuedWriteRefused) { SentCountingDevice device(&hub, 0x02); const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - EXPECT_TRUE(device.send_pdu(write_pdu)); - EXPECT_FALSE(device.send_pdu(write_pdu)); // duplicate write: refused + EXPECT_TRUE(device.queue_pdu(write_pdu)); + EXPECT_FALSE(device.queue_pdu(write_pdu)); // duplicate write: refused hub.sweep_for_test(); ASSERT_EQ(hub.queued_frames(), 1u); @@ -547,8 +547,8 @@ TEST(ModbusClientHubPriority, DuplicateCustomFunctionCodeRefused) { SentCountingDevice device(&hub, 0x02); const uint8_t custom_pdu[] = {0x41, 0x01, 0x02}; // user-defined function code - EXPECT_TRUE(device.send_pdu(custom_pdu)); - EXPECT_FALSE(device.send_pdu(custom_pdu)); // duplicate custom command: refused + EXPECT_TRUE(device.queue_pdu(custom_pdu)); + EXPECT_FALSE(device.queue_pdu(custom_pdu)); // duplicate custom command: refused hub.sweep_for_test(); ASSERT_EQ(hub.queued_frames(), 1u); @@ -562,8 +562,8 @@ TEST(ModbusClientHubPriority, AnonymousDuplicateDroppedNotPromoted) { NoResponseProbeHub hub; const uint8_t read[] = {0x03, 0x01, 0x00, 0x00, 0x02}; - hub.send_pdu(0x02, read); - hub.send_pdu(0x02, read); // anonymous duplicate: dropped + hub.queue_pdu(0x02, read); + hub.queue_pdu(0x02, read); // anonymous duplicate: dropped ASSERT_EQ(hub.queued_frames(), 1u); EXPECT_EQ(hub.queued(0).pending, 1u); // never absorbed for a null owner @@ -575,13 +575,13 @@ TEST(ModbusClientHubPriority, RetriedReadGoesBehindFreshReads) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/true); - device.send_pdu(read_pdu()); - hub.force_send_next(); // the frame that will time out and retry - device.send_pdu(read_pdu()); // waiting duplicate: absorbed into the waiting entry + device.queue_pdu(read_pdu()); + hub.force_send_next(); // the frame that will time out and retry + device.queue_pdu(read_pdu()); // waiting duplicate: absorbed into the waiting entry const uint8_t fresh_a[] = {0x03, 0x00, 0x10, 0x00, 0x01}; const uint8_t fresh_b[] = {0x03, 0x00, 0x20, 0x00, 0x01}; - device.send_pdu(fresh_a); - device.send_pdu(fresh_b); + device.queue_pdu(fresh_a); + device.queue_pdu(fresh_b); ASSERT_EQ(hub.queued_frames(), 2u); hub.timeout_waiting(); // device retries; the entry returns to READY behind the fresh reads @@ -608,9 +608,9 @@ TEST(ModbusClientHubPriority, AbsorbedDuplicateKeepsPlaceInLine) { const uint8_t read_a[] = {0x03, 0x00, 0x10, 0x00, 0x01}; const uint8_t read_b[] = {0x03, 0x00, 0x20, 0x00, 0x01}; - device.send_pdu(read_a); - device.send_pdu(read_b); - device.send_pdu(read_a); // duplicate of the older entry: absorbed, place unchanged + device.queue_pdu(read_a); + device.queue_pdu(read_b); + device.queue_pdu(read_a); // duplicate of the older entry: absorbed, place unchanged ASSERT_EQ(hub.queued_frames(), 2u); const ModbusDeviceCommand *next = hub.next_ready(); @@ -626,14 +626,14 @@ TEST(ModbusClientHubPriority, RetriedWriteKeepsWritePriorityAndStaysNonRequeueab RetryingDevice device(&hub, 0x02, /*retry=*/true); const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - device.send_pdu(write_pdu); + device.queue_pdu(write_pdu); hub.force_send_next(); hub.timeout_waiting(); // no response -> device requests retry -> back to READY ASSERT_EQ(hub.queued_frames(), 1u); EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE); // retry preserves the WRITE class - device.send_pdu(write_pdu); // duplicate of the retried write + device.queue_pdu(write_pdu); // duplicate of the retried write ASSERT_EQ(hub.queued_frames(), 1u); // still not queued twice... EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE); hub.sweep_for_test(); @@ -655,7 +655,7 @@ TEST(ModbusClientHubSent, BlockedHubDefersInsteadOfFailing) { AlwaysBlockedHub hub; SentCountingDevice device(&hub, 0x02); - EXPECT_TRUE(device.send_pdu(read_pdu())); + EXPECT_TRUE(device.queue_pdu(read_pdu())); hub.send_next_for_test(); EXPECT_EQ(device.sent_count_, 0); @@ -673,7 +673,7 @@ TEST(ModbusClientHubSent, FiresOnWireNotOnQueue) { hub.setup(); // frame timing derives from the baud rate SentCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); EXPECT_EQ(device.sent_count_, 0); // queued only - nothing sent yet hub.send_next_for_test(); @@ -703,7 +703,7 @@ TEST(ModbusClientHubSent, SendRejectedAfterDelayLeavesFrameReady) { hub.setup(); SentCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.send_next_for_test(); // gate passes, send_frame_ rejects on the post-delay re-check EXPECT_EQ(device.sent_count_, 0); // nothing transmitted @@ -766,7 +766,7 @@ TEST(ModbusClientHubCallbackCount, SingleReadSingleCallback) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); drain_with_responses(hub, OK_RESPONSE); EXPECT_EQ(device.data_count_, 1); @@ -781,8 +781,8 @@ TEST(ModbusClientHubCallbackCount, DuplicateReadExactlyTwoCallbacks) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); + device.queue_pdu(read_pdu()); int cycles = drain_with_responses(hub, OK_RESPONSE); EXPECT_EQ(cycles, 2); @@ -812,8 +812,8 @@ TEST(ModbusClientHubCallbackCount, ClearFromResponseResolvesDuplicateWithNotSent NoResponseProbeHub hub; ClearOnFirstResponseDevice device(&hub, 0x02); - EXPECT_TRUE(device.send_pdu(read_pdu())); - EXPECT_TRUE(device.send_pdu(read_pdu())); // absorbed: one entry, pending 2 + EXPECT_TRUE(device.queue_pdu(read_pdu())); + EXPECT_TRUE(device.queue_pdu(read_pdu())); // absorbed: one entry, pending 2 hub.force_send_next(); hub.receive_frame_for_test(0x02, OK_RESPONSE); // response -> on_response -> clear, then sweep @@ -829,9 +829,9 @@ TEST(ModbusClientHubCallbackCount, TripleReadRefusesTheThird) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - EXPECT_TRUE(device.send_pdu(read_pdu())); - EXPECT_TRUE(device.send_pdu(read_pdu())); - EXPECT_FALSE(device.send_pdu(read_pdu())); // the entry is already at its cap + EXPECT_TRUE(device.queue_pdu(read_pdu())); + EXPECT_TRUE(device.queue_pdu(read_pdu())); + EXPECT_FALSE(device.queue_pdu(read_pdu())); // the entry is already at its cap hub.sweep_for_test(); EXPECT_EQ(device.not_sent_count_, 0); // refused synchronously, nothing owed int cycles = drain_with_responses(hub, OK_RESPONSE); @@ -849,8 +849,8 @@ TEST(ModbusClientHubCallbackCount, DuplicateWriteRefusedWithoutLifecycle) { DataCountingDevice device(&hub, 0x02); const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - EXPECT_TRUE(device.send_pdu(write_pdu)); - EXPECT_FALSE(device.send_pdu(write_pdu)); + EXPECT_TRUE(device.queue_pdu(write_pdu)); + EXPECT_FALSE(device.queue_pdu(write_pdu)); hub.sweep_for_test(); EXPECT_EQ(device.terminals(), 0); // the accepted write has not resolved; the other never existed @@ -867,7 +867,7 @@ TEST(ModbusClientHubCallbackCount, ErrorResponseIsSoleTerminal) { hub.setup(); DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.send_next_for_test(); const uint8_t exception_response[] = {0x83, 0x02}; hub.receive_frame_for_test(0x02, exception_response); @@ -886,7 +886,7 @@ TEST(ModbusClientHubCallbackCount, NoResponseIsSoleTerminalAndNotSentHasNoSent) hub.setup(); DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.send_next_for_test(); hub.timeout_waiting(); EXPECT_EQ(device.no_response_count_, 1); @@ -896,8 +896,8 @@ TEST(ModbusClientHubCallbackCount, NoResponseIsSoleTerminalAndNotSentHasNoSent) // Unabsorbable duplicate: the second identical write is refused at the door - no lifecycle, no // terminal, nothing sent. const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - EXPECT_TRUE(device.send_pdu(write_pdu)); - EXPECT_FALSE(device.send_pdu(write_pdu)); + EXPECT_TRUE(device.queue_pdu(write_pdu)); + EXPECT_FALSE(device.queue_pdu(write_pdu)); hub.sweep_for_test(); EXPECT_EQ(device.not_sent_count_, 0); EXPECT_EQ(device.terminals(), 1); // still just the read's timeout @@ -920,7 +920,7 @@ TEST(ModbusClientHubCallbackCount, RetryLifecyclesEachGetSentAndTerminal) { DataCountingDevice device(&hub, 0x02); device.retries_ = 1; // ask for exactly one retry - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.send_next_for_test(); hub.timeout_waiting(); // lifecycle 1: sent + no_response (retry requested -> re-queued) ASSERT_EQ(hub.queued_frames(), 1u); @@ -946,13 +946,13 @@ TEST(ModbusClientHubCallbackCount, RetryIsNeverRefusedByFullQueue) { device.retries_ = 1; SentCountingDevice filler(&hub, 0x05); - device.send_pdu(read_pdu()); - hub.force_send_next(); // waiting - device.send_pdu(read_pdu()); // absorbed: two requests pending + device.queue_pdu(read_pdu()); + hub.force_send_next(); // waiting + device.queue_pdu(read_pdu()); // absorbed: two requests pending // Fill the remaining live capacity with distinct frames. for (uint16_t i = 0; hub.entries() < MODBUS_TX_BUFFER_SIZE; i++) { const uint8_t fill[] = {0x03, static_cast(i >> 8), static_cast(i & 0xFF), 0x00, 0x01}; - filler.send_pdu(fill); + filler.queue_pdu(fill); } hub.timeout_waiting(); // retry requested; the entry flips back to READY regardless of capacity @@ -991,10 +991,10 @@ TEST(ModbusClientHubQueue, SendRawTooShortIsRefusedAtTheDoor) { NotSentCountingRawDevice device(&hub, 0x02); #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wdeprecated-declarations" - EXPECT_FALSE(device.send_raw({})); // too short to contain a PDU + device.send_raw({}); // too short to contain a PDU; the deprecated void spelling cannot report it #pragma GCC diagnostic pop - EXPECT_EQ(device.not_sent_count_, 0); // refusals are returned, never delivered - EXPECT_TRUE(hub.tx_buffer_empty()); + EXPECT_EQ(device.not_sent_count_, 0); // refused at the door: no callback delivered + EXPECT_TRUE(hub.tx_buffer_empty()); // the only evidence of the refusal is that nothing queued } // A continuous read: every wire transmission pairs one sent with one terminal, ending on the error. @@ -1040,7 +1040,7 @@ class ChainOnSentDevice : public ModbusClientDevice { if (!this->chained_) { this->chained_ = true; const uint8_t follow[] = {0x03, 0x00, 0x09, 0x00, 0x01}; // read holding 0x0009 x1 - this->send_pdu(follow); + this->queue_pdu(follow); } } bool chained_{false}; @@ -1059,9 +1059,9 @@ TEST(ModbusClientHubQueue, ClearAddressQueueNotifiesEveryOwner) { const uint8_t read_a[] = {0x03, 0x01, 0x00, 0x00, 0x02}; const uint8_t read_b[] = {0x03, 0x02, 0x00, 0x00, 0x02}; const uint8_t read_c[] = {0x03, 0x03, 0x00, 0x00, 0x02}; - controller_like.send_pdu(read_a); - bystander_same.send_pdu(read_b); - bystander_other.send_pdu(read_c); + controller_like.queue_pdu(read_a); + bystander_same.queue_pdu(read_b); + bystander_other.queue_pdu(read_c); ASSERT_EQ(hub.queued_frames(), 3u); controller_like.clear_tx_queue_for_address(); @@ -1083,8 +1083,8 @@ TEST(ModbusClientHubQueue, ClearAddressDeliversOneTerminalPerAcceptedRequest) { SentCountingDevice device(&hub, 0x02); const uint8_t read[] = {0x03, 0x01, 0x00, 0x00, 0x02}; - device.send_pdu(read); - device.send_pdu(read); // duplicate: absorbed into the queued entry + device.queue_pdu(read); + device.queue_pdu(read); // duplicate: absorbed into the queued entry ASSERT_EQ(hub.queued_frames(), 1u); ASSERT_EQ(hub.queued(0).pending, 2u); @@ -1103,8 +1103,8 @@ TEST(ModbusClientHubQueue, ClearSentOnInFlightDuplicateStillNotifiesTheDuplicate NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); - device.send_pdu(read_pdu()); // absorbed: one entry, pending 2 + device.queue_pdu(read_pdu()); + device.queue_pdu(read_pdu()); // absorbed: one entry, pending 2 ASSERT_EQ(hub.queued(0).pending, 2u); hub.force_send_next(); // the frame is sent (WAITING); pending still 2 ASSERT_TRUE(hub.waiting()); @@ -1122,7 +1122,7 @@ TEST(ModbusClientHubQueue, ClearWhileInFlightStillDeliversTheResponse) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); // sent, now WAITING ASSERT_TRUE(hub.waiting()); @@ -1151,7 +1151,7 @@ class ResendOnNotSentDevice : public ModbusClientDevice { this->not_sent_count_++; if (this->not_sent_count_ == 1) { const uint8_t again[] = {0x06, 0x00, 0x40, 0x00, 0x01}; // a write: ranked first at selection, not by position - this->send_pdu(again); + this->queue_pdu(again); } } int not_sent_count_{0}; @@ -1165,7 +1165,7 @@ TEST(ModbusClientHubQueue, ClearAddressReentrantResendSurvives) { ResendOnNotSentDevice device(&hub, 0x02); const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; - device.send_pdu(read); + device.queue_pdu(read); ASSERT_EQ(hub.queued_frames(), 1u); hub.clear_tx_queue_for_address(0x02); @@ -1187,8 +1187,8 @@ TEST(ModbusClientHubQueue, ClearAddressReentrantResendNotSwept) { const uint8_t read_victim[] = {0x03, 0x00, 0x10, 0x00, 0x01}; const uint8_t read_other[] = {0x03, 0x00, 0x20, 0x00, 0x01}; - resender.send_pdu(read_victim); - bystander_other.send_pdu(read_other); + resender.queue_pdu(read_victim); + bystander_other.queue_pdu(read_other); ASSERT_EQ(hub.queued_frames(), 2u); hub.clear_tx_queue_for_address(0x02); @@ -1212,13 +1212,14 @@ class AlwaysResendDevice : public ModbusClientDevice { void on_not_sent(std::span request_pdu) override { this->not_sent_count_++; const uint8_t again[] = {0x03, 0x00, 0x50, 0x00, 0x01}; - this->send_pdu(again); + this->queue_pdu(again); } int not_sent_count_{0}; }; -// From inside on_not_sent, clears ANOTHER address - those victims must still be notified (the per-device -// guard suppresses deliveries only to a device already inside its own on_not_sent()). +// From inside on_not_sent, clears ANOTHER address - those victims must still be notified. Nothing +// suppresses that: a re-entrant clear only flips states, retire() is a no-op on an already-retired +// entry, and each entry still owes one notification per un-run request until pending reaches zero. class ClearOtherOnNotSentDevice : public ModbusClientDevice { public: ClearOtherOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} @@ -1238,9 +1239,9 @@ TEST(ModbusClientHubQueue, PendingNeverExceedsTheServableCap) { AlwaysResendDevice device(&hub, 0x02); const uint8_t read[] = {0x03, 0x00, 0x50, 0x00, 0x01}; - EXPECT_TRUE(device.send_pdu(read)); - EXPECT_TRUE(device.send_pdu(read)); - EXPECT_FALSE(device.send_pdu(read)); // at the cap: refused + EXPECT_TRUE(device.queue_pdu(read)); + EXPECT_TRUE(device.queue_pdu(read)); + EXPECT_FALSE(device.queue_pdu(read)); // at the cap: refused ASSERT_EQ(hub.queued_frames(), 1u); EXPECT_EQ(hub.queued(0).pending, 2u); @@ -1260,12 +1261,12 @@ TEST(ModbusClientHubQueue, FullQueueRefusesWithoutCallbacks) { // Fill the queue with distinct frames (distinct start addresses keep the dedup from absorbing them). for (uint16_t i = 0; i < MODBUS_TX_BUFFER_SIZE; i++) { const uint8_t fill[] = {0x03, static_cast(i >> 8), static_cast(i & 0xFF), 0x00, 0x01}; - filler.send_pdu(fill); + filler.queue_pdu(fill); } ASSERT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE); const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; - EXPECT_FALSE(device.send_pdu(read)); // refused synchronously + EXPECT_FALSE(device.queue_pdu(read)); // refused synchronously hub.sweep_for_test(); EXPECT_EQ(device.not_sent_count_, 0); // nothing was accepted, so nothing is owed @@ -1298,13 +1299,13 @@ TEST(ModbusClientHubQueue, SelfClearFromNotSentResolvesEveryRequest) { const uint8_t read_a[] = {0x03, 0x00, 0x10, 0x00, 0x01}; const uint8_t read_b[] = {0x03, 0x00, 0x20, 0x00, 0x01}; const uint8_t read_c[] = {0x03, 0x00, 0x30, 0x00, 0x01}; - clearer.send_pdu(read_a); - clearer.send_pdu(read_b); - bystander.send_pdu(read_c); + clearer.queue_pdu(read_a); + clearer.queue_pdu(read_b); + bystander.queue_pdu(read_c); ASSERT_EQ(hub.queued_frames(), 3u); - EXPECT_FALSE(clearer.send_pdu(std::span{})); // empty: refused, no callback - clearer.clear_tx_queue_for_address(); // the clear the handler used to make + EXPECT_FALSE(clearer.queue_pdu(std::span{})); // empty: refused, no callback + clearer.clear_tx_queue_for_address(); // the clear the handler used to make hub.sweep_for_test(); @@ -1323,8 +1324,8 @@ TEST(ModbusClientHubQueue, NestedClearFromNotSentStillNotifiesVictims) { const uint8_t read_a[] = {0x03, 0x00, 0x10, 0x00, 0x01}; const uint8_t read_b[] = {0x03, 0x00, 0x20, 0x00, 0x01}; - clearer.send_pdu(read_a); - victim.send_pdu(read_b); + clearer.queue_pdu(read_a); + victim.queue_pdu(read_b); ASSERT_EQ(hub.queued_frames(), 2u); hub.clear_tx_queue_for_address(0x02); // clearer's on_not_sent clears address 0x03 in turn @@ -1345,7 +1346,7 @@ class ResendSecondFrameDevice : public ModbusClientDevice { this->not_sent_count_++; if (this->not_sent_count_ == 1) { const uint8_t same_as_r2[] = {0x03, 0x00, 0x22, 0x00, 0x01}; - this->send_pdu(same_as_r2); + this->queue_pdu(same_as_r2); } } int not_sent_count_{0}; @@ -1360,8 +1361,8 @@ TEST(ModbusClientHubQueue, SweepDedupSkipsDeletedFrames) { const uint8_t r1[] = {0x03, 0x00, 0x21, 0x00, 0x01}; const uint8_t r2[] = {0x03, 0x00, 0x22, 0x00, 0x01}; - device.send_pdu(r1); - device.send_pdu(r2); + device.queue_pdu(r1); + device.queue_pdu(r2); ASSERT_EQ(hub.queued_frames(), 2u); hub.clear_tx_queue_for_address(0x02); @@ -1383,7 +1384,7 @@ class ResendAndClearOnNotSentDevice : public ModbusClientDevice { void on_not_sent(std::span request_pdu) override { this->not_sent_count_++; const uint8_t again[] = {0x03, 0x00, 0x70, 0x00, 0x01}; - this->send_pdu(again); + this->queue_pdu(again); this->clear_tx_queue_for_address(); } int not_sent_count_{0}; @@ -1397,7 +1398,7 @@ TEST(ModbusClientHubQueue, ResendAndClearFromNotSentCannotExtendTheSweep) { ResendAndClearOnNotSentDevice device(&hub, 0x02); const uint8_t read[] = {0x03, 0x00, 0x70, 0x00, 0x01}; - device.send_pdu(read); + device.queue_pdu(read); hub.clear_tx_queue_for_address(0x02); hub.sweep_for_test(); @@ -1421,8 +1422,8 @@ TEST(ModbusClientHubQueue, ClearDeviceQueueDropsSilently) { const uint8_t read_a[] = {0x03, 0x01, 0x00, 0x00, 0x02}; const uint8_t read_b[] = {0x03, 0x02, 0x00, 0x00, 0x02}; - device.send_pdu(read_a); - device.send_pdu(read_b); + device.queue_pdu(read_a); + device.queue_pdu(read_b); ASSERT_EQ(hub.queued_frames(), 2u); device.clear_tx_queue_for_device(); @@ -1431,7 +1432,7 @@ TEST(ModbusClientHubQueue, ClearDeviceQueueDropsSilently) { EXPECT_EQ(device.not_sent_count_, 0); // silent drop: no terminal callback } -// A send_pdu() from inside on_sent() enqueues behind the waiting frame rather than sending +// A queue_pdu() from inside on_sent() enqueues behind the waiting frame rather than sending // immediately or corrupting the waiting transaction. TEST(ModbusClientHubSent, ReentrantSendFromOnSentQueues) { NullUART uart; @@ -1440,7 +1441,7 @@ TEST(ModbusClientHubSent, ReentrantSendFromOnSentQueues) { hub.setup(); ChainOnSentDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.send_next_for_test(); // first frame is sent -> on_sent chains a follow-up EXPECT_TRUE(hub.waiting()); // first frame is waiting @@ -1507,34 +1508,69 @@ class LegacyNameDevice : public ModbusClientDevice { #pragma GCC diagnostic pop } // namespace +// send_pdu() was renamed queue_pdu() because the call queues a request rather than transmitting one. +// The old spelling stays for the deprecation window with the signature 2026.7.4 shipped - void, no +// CommandOptions - so a component built against a real release still compiles and still queues. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +TEST(ModbusClientHubCompat, DeprecatedSendPduStillQueues) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; + device.send_pdu(read); // deprecated device spelling: void, as 2026.7.4 shipped it + EXPECT_EQ(hub.queued_frames(), 1u); + + // A refusal is invisible to this spelling - no return value and no callback - so the only evidence + // is that nothing was queued. Reporting the refusal is exactly what moving to queue_pdu() buys. + device.send_pdu(std::span()); + EXPECT_EQ(hub.queued_frames(), 1u); + + // The deprecated hub spelling queues the same way, addressed explicitly. + const uint8_t other[] = {0x03, 0x00, 0x20, 0x00, 0x01}; + hub.send_pdu(0x03, other, &device); + EXPECT_EQ(hub.queued_frames(), 2u); + + // Both frames resolve to the same owner. Drain them in turn: the device-spelling frame first (FIFO), + // then the hub-spelling frame - addressed to 0x03 yet owned by &device, so reaching device's + // on_no_response proves the request routes by owner pointer, not by address. + hub.force_send_next(); + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_count_, 1); // device-spelling frame (address 0x02) + hub.force_send_next(); + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_count_, 2); // hub-spelling frame (address 0x03, &device routing) +} +#pragma GCC diagnostic pop + TEST(ModbusClientHubCompat, LegacyCallbackNamesStillForward) { NoResponseProbeHub hub; LegacyNameDevice device(&hub, 0x02); const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; - device.send_pdu(read); + device.queue_pdu(read); hub.force_send_next(); hub.timeout_waiting(); // no reply -> on_no_response -> forwards to on_modbus_no_response EXPECT_EQ(device.legacy_no_response_, 1); // A refused send returns false with no callback, so exercise the forward through an accepted // request instead: a cleared queue entry delivers on_not_sent(), which forwards to the old name. - EXPECT_FALSE(device.send_pdu(std::span())); // empty PDU: refused at the door + EXPECT_FALSE(device.queue_pdu(std::span())); // empty PDU: refused at the door EXPECT_EQ(device.legacy_not_sent_, 0); const uint8_t queued[] = {0x03, 0x00, 0x11, 0x00, 0x01}; - EXPECT_TRUE(device.send_pdu(queued)); + EXPECT_TRUE(device.queue_pdu(queued)); hub.clear_tx_queue_for_address(0x02); hub.sweep_for_test(); EXPECT_EQ(device.legacy_not_sent_, 1); } -// The send_pdu() capacity bound: a PDU larger than MAX_PDU_SIZE would build a frame past the RTU +// The queue_pdu() capacity bound: a PDU larger than MAX_PDU_SIZE would build a frame past the RTU // 256-byte limit, so it is refused up front - false at the call site, no entry, no callback. TEST(ModbusClientHub, OversizedPduIsRefusedAtTheDoor) { NoResponseProbeHub hub; LegacyNameDevice device(&hub, 0x02); std::vector big(MAX_PDU_SIZE + 1, 0x41); - EXPECT_FALSE(device.send_pdu(big)); + EXPECT_FALSE(device.queue_pdu(big)); EXPECT_EQ(device.legacy_not_sent_, 0); // refusals are returned, never delivered EXPECT_TRUE(hub.tx_buffer_empty()); EXPECT_EQ(hub.entries(), 0u); @@ -1568,7 +1604,7 @@ TEST(ModbusDeviceShim, LegacyCallbacksReceiveTheOldShapes) { // Read response: on_modbus_data() historically received the payload after the function code and // the byte-count byte, as an owning vector. const uint8_t read_req[] = {0x03, 0x00, 0x10, 0x00, 0x02}; - device.send_pdu(read_req); + device.queue_pdu(read_req); hub.force_send_next(); const uint8_t response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; hub.receive_frame_for_test(0x02, response); @@ -1577,14 +1613,14 @@ TEST(ModbusDeviceShim, LegacyCallbacksReceiveTheOldShapes) { // Write echo: no byte-count byte, so the payload is everything after the function code. const uint8_t write_req[] = {0x06, 0x00, 0x10, 0x00, 0x2A}; - device.send_pdu(write_req); + device.queue_pdu(write_req); hub.force_send_next(); hub.receive_frame_for_test(0x02, write_req); // single-write responses echo the request const std::vector expected_echo{0x00, 0x10, 0x00, 0x2A}; EXPECT_EQ(device.last_data_, expected_echo); // Exception response: on_modbus_error() received the masked function code and the exception code. - device.send_pdu(read_req); + device.queue_pdu(read_req); hub.force_send_next(); const uint8_t error[] = {0x83, 0x02}; hub.receive_frame_for_test(0x02, error); @@ -1675,9 +1711,9 @@ class ResendOnDataDevice : public ModbusClientDevice { public: ResendOnDataDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} void on_response(std::span request_pdu, std::span response_pdu) override { - this->send_pdu(std::vector(request_pdu.begin(), request_pdu.end())); + this->queue_pdu(std::vector(request_pdu.begin(), request_pdu.end())); } - void send_pdu(const std::vector &pdu) { ModbusClientDevice::send_pdu(pdu); } + void queue_pdu(const std::vector &pdu) { ModbusClientDevice::queue_pdu(pdu); } }; } // namespace @@ -1704,8 +1740,8 @@ TEST(ModbusClientHubPriority, ExceptionFlaggedDuplicateDroppedNotPromoted) { SentCountingDevice device(&hub, 0x02); const uint8_t weird[] = {0x83, 0x01, 0x00, 0x00, 0x02}; // read-shaped but exception-flagged - EXPECT_TRUE(device.send_pdu(weird)); - EXPECT_FALSE(device.send_pdu(weird)); // non-requeueable: cap of one, so the duplicate is refused + EXPECT_TRUE(device.queue_pdu(weird)); + EXPECT_FALSE(device.queue_pdu(weird)); // non-requeueable: cap of one, so the duplicate is refused hub.sweep_for_test(); ASSERT_EQ(hub.queued_frames(), 1u); @@ -1715,7 +1751,7 @@ TEST(ModbusClientHubPriority, ExceptionFlaggedDuplicateDroppedNotPromoted) { // The write-shaped twin (0x86 masks to WRITE_SINGLE_REGISTER) must not take WRITE-class // ordering either: exception-flagged codes are excluded from the mutates classification. const uint8_t weird_write[] = {0x86, 0x00, 0x10, 0xBE, 0xEF}; - device.send_pdu(weird_write); + device.queue_pdu(weird_write); ASSERT_EQ(hub.queued_frames(), 2u); EXPECT_EQ(hub.queued(1).priority(), CommandPriority::READ); // not WRITE const ModbusDeviceCommand *next = hub.next_ready(); @@ -1732,7 +1768,7 @@ class ResendInFlightOnNotSentDevice : public ModbusClientDevice { this->not_sent_count_++; if (this->not_sent_count_ == 1) { const uint8_t same_as_waiting[] = {0x03, 0x01, 0x00, 0x00, 0x02}; // == READ_PDU - this->send_pdu(same_as_waiting); + this->queue_pdu(same_as_waiting); } } int not_sent_count_{0}; @@ -1746,10 +1782,10 @@ TEST(ModbusClientHubQueue, SweepResendAfterClearQueuesFreshNotAbsorbedIntoShell) NoResponseProbeHub hub; ResendInFlightOnNotSentDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); // READ_PDU now waiting const uint8_t queued_read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; - device.send_pdu(queued_read); // a queued frame for the sweep to notify + device.queue_pdu(queued_read); // a queued frame for the sweep to notify ASSERT_EQ(hub.queued_frames(), 1u); hub.clear_tx_queue_for_address(0x02); @@ -1787,7 +1823,7 @@ TEST(ModbusClientHubNoResponse, SelfClearFromNoResponseDoesNotDoubleResolve) { NoResponseProbeHub hub; ClearAddressOnNoResponseDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); hub.timeout_waiting(); @@ -1802,8 +1838,8 @@ TEST(ModbusClientHubNoResponse, SelfClearFromNoResponseResolvesTheAbsorbedReques NoResponseProbeHub hub; ClearAddressOnNoResponseDevice device(&hub, 0x02); - EXPECT_TRUE(device.send_pdu(read_pdu())); - EXPECT_TRUE(device.send_pdu(read_pdu())); // absorbed: one entry, two requests + EXPECT_TRUE(device.queue_pdu(read_pdu())); + EXPECT_TRUE(device.queue_pdu(read_pdu())); // absorbed: one entry, two requests hub.force_send_next(); hub.timeout_waiting(); @@ -1818,7 +1854,7 @@ TEST(ModbusClientHubQueue, ClearedShellReleasesTheBusOnLateResponse) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); hub.clear_tx_queue_for_address(0x02); ASSERT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED); @@ -1836,7 +1872,7 @@ TEST(ModbusClientHubQueue, ClearedShellReleasesTheBusOnTimeout) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); hub.clear_tx_queue_for_address(0x02); ASSERT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED); @@ -1856,7 +1892,7 @@ TEST(ModbusClientHubQueue, ClearInterruptedFrameGetsNoResponseAtTimeout) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); // declines the retry (retries_ == 0) - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); const uint8_t stray_pdu[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; hub.receive_frame_for_test(0x07, stray_pdu); // wrong address: interrupts the transaction @@ -1887,7 +1923,7 @@ TEST(ModbusClientHubQueue, InterruptAfterClearStillDistrusts) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); hub.clear_tx_queue_for_address(0x02); ASSERT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED); @@ -1912,8 +1948,8 @@ TEST(ModbusClientHubQueue, ClearedInFlightDuplicateTimesOutWithoutRerunning) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); - device.send_pdu(read_pdu()); // absorbed: one entry, pending 2 + device.queue_pdu(read_pdu()); + device.queue_pdu(read_pdu()); // absorbed: one entry, pending 2 ASSERT_EQ(hub.queued(0).pending, 2u); hub.force_send_next(); // sent, pending still 2 hub.clear_tx_queue_for_address(0x02); @@ -1936,9 +1972,9 @@ TEST(ModbusClientHubCallbackCount, AbsorbedRequestRunsAfterErrorResponse) { hub.setup(); DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); - device.send_pdu(read_pdu()); // waiting duplicate: absorbed + device.queue_pdu(read_pdu()); // waiting duplicate: absorbed const uint8_t exception_response[] = {0x83, 0x02}; hub.receive_frame_for_test(0x02, exception_response); // error terminal for request 1 @@ -1954,8 +1990,8 @@ TEST(ModbusClientHubPriority, ReadModifyWritesRankAsWrites) { const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; const uint8_t mask_write[] = {0x16, 0x00, 0x10, 0x00, 0xFF, 0x00, 0x01}; - device.send_pdu(read); - device.send_pdu(mask_write); + device.queue_pdu(read); + device.queue_pdu(mask_write); ASSERT_EQ(hub.queued_frames(), 2u); const ModbusDeviceCommand *next = hub.next_ready(); From 989dbd755007a7cd231721913ff50a4976f874c3 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:10:38 +1200 Subject: [PATCH 1341/1815] [ci] Name release runs after the version or dev tag they build (#18110) --- .github/workflows/release-nightly.yml | 38 +++++++++++++++++++++++++++ .github/workflows/release.yml | 36 ++++++++++++++++++++----- 2 files changed, 68 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/release-nightly.yml diff --git a/.github/workflows/release-nightly.yml b/.github/workflows/release-nightly.yml new file mode 100644 index 0000000000..cd3b7207b7 --- /dev/null +++ b/.github/workflows/release-nightly.yml @@ -0,0 +1,38 @@ +--- +name: Nightly Dev Release + +# Works out the dated dev tag and starts the release workflow with it, so that +# the release run is named after the tag it builds. A workflow run name is +# fixed when the run starts and cannot read a file or the current date. + +on: + schedule: + - cron: "0 2 * * *" + +permissions: + contents: read # actions/checkout to read the version from esphome/const.py + +jobs: + trigger: + name: Start release build + if: github.repository == 'esphome/esphome' + runs-on: ubuntu-latest + permissions: + contents: read # actions/checkout to read the version from esphome/const.py + actions: write # gh workflow run starts release.yml + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Start the release workflow + env: + GH_TOKEN: ${{ github.token }} + run: | + VERSION=$(sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p" esphome/const.py) + if [[ -z "$VERSION" ]]; then + echo "::error::Could not read __version__ from esphome/const.py" + exit 1 + fi + TAG="${VERSION}$(date --utc '+%Y%m%d')" + echo "Starting release build for ${TAG}" + gh workflow run release.yml --ref "${GITHUB_REF_NAME}" --field tag="${TAG}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 839b805237..10b28ace38 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,12 +1,23 @@ --- name: Publish Release +# Releases (production and beta) are named after the version they publish. +# Dev builds are named after the dated dev tag, which is passed in by the +# nightly workflow because a run name cannot compute it itself. +run-name: ${{ github.event.inputs.tag || github.event.release.tag_name || format('Manual build ({0})', github.ref_name) }} + on: workflow_dispatch: + inputs: + tag: + description: >- + Tag to build. Only supported on dev, where the nightly workflow + uses it. Leave empty to build the version from esphome/const.py + with today's date appended. + required: false + default: "" release: types: [published] - schedule: - - cron: "0 2 * * *" permissions: contents: read # actions/checkout for all jobs; deploy jobs add their own scopes when they need to write @@ -23,6 +34,8 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Get tag id: tag + env: + INPUT_TAG: ${{ github.event.inputs.tag }} # yamllint disable rule:line-length run: | if [[ "${{ github.event_name }}" = "release" ]]; then @@ -34,12 +47,23 @@ jobs: ENVIRONMENT="production" fi else - TAG=$(cat esphome/const.py | sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p") - today="$(date --utc '+%Y%m%d')" - TAG="${TAG}${today}" BRANCH=${GITHUB_REF#refs/heads/} + # The nightly workflow passes the finished tag so that the run name + # matches what is built. Without it, work it out here. + TAG="${INPUT_TAG}" + if [[ -n "$TAG" && "$BRANCH" != "dev" ]]; then + echo "::error::The tag input is only supported on dev. A build from ${BRANCH} has to use the tag worked out here, which carries the branch name, so that it cannot publish over the dev, beta, latest or stable images." + exit 1 + fi + if [[ -z "$TAG" ]]; then + TAG=$(cat esphome/const.py | sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p") + today="$(date --utc '+%Y%m%d')" + TAG="${TAG}${today}" + if [[ "$BRANCH" != "dev" ]]; then + TAG="${TAG}-${BRANCH}" + fi + fi if [[ "$BRANCH" != "dev" ]]; then - TAG="${TAG}-${BRANCH}" BRANCH_BUILD="true" ENVIRONMENT="" else From 3de8c7f95c46bfb935dc62c5434e25b2056e0474 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 01:49:54 +0000 Subject: [PATCH 1342/1815] Bump bundled esphome-device-builder to 1.9.5 (#18223) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c0f7222bca..a4f5d3c3a6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.9.4 +RUN uv pip install --no-cache-dir esphome-device-builder==1.9.5 RUN \ platformio settings set enable_telemetry No \ From 0f59ef36a9ed67fcc28fe8f943241f2bed71d15f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 00:26:41 -0500 Subject: [PATCH 1343/1815] [core] Store the validated-config cache as JSON to drop YAML off the upload fast path (#18106) --- esphome/compiled_config.py | 118 ++++-- esphome/core/__init__.py | 12 +- esphome/yaml_util.py | 2 + .../python/test_compiled_config_bench.py | 2 +- .../lazy_imports/upload_command_fast_path.py | 46 ++- tests/unit_tests/test_compiled_config.py | 339 +++++++++++++++--- tests/unit_tests/test_core.py | 27 ++ tests/unit_tests/test_lazy_imports.py | 18 +- 8 files changed, 459 insertions(+), 105 deletions(-) diff --git a/esphome/compiled_config.py b/esphome/compiled_config.py index 1bcd567b84..303af99e66 100644 --- a/esphome/compiled_config.py +++ b/esphome/compiled_config.py @@ -1,48 +1,69 @@ """Validated-config cache for the upload/logs fast path. -compile dumps the validated config to /storage/.validated.yaml; +compile dumps the validated config to /storage/.validated.json; the next upload/logs for that YAML reuses it instead of running the full -read_config pipeline. YAML round-trip (yaml_util.dump/load_yaml) keeps -!lambda/!include/IDs/paths intact; mtime gates staleness. +read_config pipeline. The cache is deliberately lossy: only ``!lambda`` +bodies survive typed (``Lambda``); IDs, time periods, MAC/IP addresses, +paths, UUIDs and enums store the same string form the YAML dumper +produced for them. JSON additionally coerces non-str dict keys to +strings; validated configs only use string keys (every schema key +validator is ``cv.string``). mtime gates staleness. """ from __future__ import annotations +import json import logging from pathlib import Path +from typing import Any -from esphome.core import CORE +from esphome.const import __version__ as ESPHOME_VERSION +from esphome.core import CORE, Lambda from esphome.helpers import write_file from esphome.storage_json import StorageJSON, ext_storage_path from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) +# Bump when the on-disk shape changes; a mismatched version falls back +# to read_config. The envelope also stamps the writing esphome version: +# after an upgrade the cache holds the previous release's validation, so +# it falls back once and the re-save self-heals. +_CACHE_VERSION = 1 +_LAMBDA_KEY = "__esphome_lambda__" + def compiled_config_path(config_filename: str) -> Path: """Path to the cached validated config alongside the storage sidecar.""" - return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml" - - -def _cache_is_fresh(cache_path: Path, source_path: Path) -> bool: - """True iff the cache file exists and isn't older than the source.""" - try: - return cache_path.stat().st_mtime >= source_path.stat().st_mtime - except OSError: - return False + return CORE.data_dir / "storage" / f"{config_filename}.validated.json" def save_compiled_config(config: ConfigType) -> None: """Write the validated-config cache. Always-write so mtime stays fresh. - Mode 0600 because show_secrets=True resolves !secret inline. + Mode 0600 because config validation resolved !secret inline. Failures are non-fatal: the fast path falls back to read_config. """ - from esphome import yaml_util - try: - rendered = yaml_util.dump(config, show_secrets=True) + # The legacy YAML cache holds inline-resolved secrets and nothing + # reads it anymore; drop it even when the write below fails. A + # failed removal leaves resolved secrets on disk, so it warns. + try: + _legacy_compiled_config_path(CORE.config_filename).unlink(missing_ok=True) + except OSError as err: + _LOGGER.warning( + "Could not remove the legacy validated-config cache: %s", err + ) + rendered = json.dumps( + {"v": _CACHE_VERSION, "esphome": ESPHOME_VERSION, "config": config}, + separators=(",", ":"), + default=_json_default, + ) write_file(compiled_config_path(CORE.config_filename), rendered, private=True) + except TypeError as err: + # Structural, not transient: this config can never cache (e.g. a + # non-basic dict key), so every upload/logs pays the slow path. + _LOGGER.warning("Cannot cache the validated config: %s", err) except Exception as err: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.debug("Skipping compiled config cache write: %s", err) @@ -51,25 +72,29 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None: """Load the cached validated config and apply storage metadata to CORE. Returns None (caller falls back to read_config) when the cache is - missing, older than the source YAML, unparseable, or the sidecar - is incomplete. + missing, older than the source YAML, unparseable, a different cache + version, or the sidecar is incomplete. The loaded config carries no + source ranges; callers must not feed it into read_config/write_cpp. """ cache_path = compiled_config_path(conf_path.name) if not _cache_is_fresh(cache_path, conf_path): return None - from esphome import yaml_util - try: - # Fast path never validates or generates code - no source ranges - # needed (see load_yaml). Callers must not feed this config into - # read_config/write_cpp: the esp_range consumers in config.py and - # cpp_generator.py are isinstance-guarded and would degrade - # silently (wrong error/lambda locations) instead of raising. - config = yaml_util.load_yaml( - cache_path, clear_secrets=False, track_document_range=False + envelope = json.loads( + cache_path.read_text(encoding="utf-8"), object_hook=_decode_object ) - except Exception: # noqa: BLE001 # pylint: disable=broad-except + except (OSError, ValueError) as err: + _LOGGER.debug("Ignoring unreadable compiled config cache: %s", err) + return None + + if ( + not isinstance(envelope, dict) + or envelope.get("v") != _CACHE_VERSION + or envelope.get("esphome") != ESPHOME_VERSION + or not isinstance(config := envelope.get("config"), dict) + ): + _LOGGER.debug("Ignoring compiled config cache with a foreign envelope") return None storage = StorageJSON.load(ext_storage_path(conf_path.name)) @@ -81,3 +106,38 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None: return None storage.apply_to_core() return config + + +# Remove before 2027.8: by then every maintained install has saved the +# JSON cache at least once and dropped its legacy YAML file. +def _legacy_compiled_config_path(config_filename: str) -> Path: + """Path of the pre-JSON YAML cache; only ever removed.""" + return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml" + + +def _cache_is_fresh(cache_path: Path, source_path: Path) -> bool: + """True iff the cache file exists and isn't older than the source.""" + try: + return cache_path.stat().st_mtime >= source_path.stat().st_mtime + except OSError: + return False + + +def _json_default(value: Any) -> Any: + """Mirror ESPHomeDumper's representers: Lambda stays typed, the rest + stringify (IDs, time periods, MAC/IP addresses, paths, UUIDs, enums). + + IncludeFile/Extend/Remove have no JSON mirror and would stringify + wrong, but none survive validation (config.py's packages merge and + the substitution pass consume them) so no guard is spent on them. + """ + if isinstance(value, Lambda): + return {_LAMBDA_KEY: value.value} + return str(value) + + +def _decode_object(obj: dict[str, Any]) -> Any: + """Revive the Lambda sentinel; every other mapping passes through.""" + if len(obj) == 1 and isinstance(value := obj.get(_LAMBDA_KEY), str): + return Lambda(value) + return obj diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index e5b3ebb84d..1a5f4f2cf5 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -321,14 +321,18 @@ LAMBDA_PROG = re.compile(r"\bid\(\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\)(\.?)") class Lambda: def __init__(self, value): - from esphome.cpp_generator import Expression, statement - # pylint: disable=protected-access if isinstance(value, Lambda): self._value = value._value - elif isinstance(value, Expression): - self._value = str(statement(value)) + elif isinstance(value, str): + # The validated-config cache revives Lambdas from strings on the + # upload/logs fast path; keep codegen off that path. + self._value = value else: + from esphome.cpp_generator import Expression, statement + + if isinstance(value, Expression): + value = str(statement(value)) self._value = value self._parts = None self._requires_ids = None diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 981e508d5d..d3c6caf60b 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -1349,6 +1349,8 @@ class ESPHomeDumper(yaml.SafeDumper): return super().increase_indent(flow, False) +# Mirrored by compiled_config._json_default: a new representer that keeps a +# type round-trippable (like Lambda's) needs a sentinel there too. ESPHomeDumper.add_multi_representer( dict, lambda dumper, value: dumper.represent_mapping("tag:yaml.org,2002:map", value) ) diff --git a/tests/benchmarks/python/test_compiled_config_bench.py b/tests/benchmarks/python/test_compiled_config_bench.py index 5c8892f8d0..4d7821f704 100644 --- a/tests/benchmarks/python/test_compiled_config_bench.py +++ b/tests/benchmarks/python/test_compiled_config_bench.py @@ -52,7 +52,7 @@ def _prime_cache(yaml_path: Path) -> None: Mirrors ``esphome compile``: ``read_config`` populates ``CORE.config``, then ``update_storage_json`` writes both the StorageJSON sidecar and - the ``.validated.yaml`` compiled-config cache. + the ``.validated.json`` compiled-config cache. """ CORE.config_path = yaml_path config = read_config({}, skip_external_update=True) diff --git a/tests/unit_tests/fixtures/lazy_imports/upload_command_fast_path.py b/tests/unit_tests/fixtures/lazy_imports/upload_command_fast_path.py index f0df08aa4e..f70a3f85ac 100644 --- a/tests/unit_tests/fixtures/lazy_imports/upload_command_fast_path.py +++ b/tests/unit_tests/fixtures/lazy_imports/upload_command_fast_path.py @@ -2,12 +2,13 @@ Executed as a subprocess by test_lazy_imports.py: heavy module names come in on argv, the ones found in sys.modules afterwards go out on stdout. -Covers both fast-path claims: the bundle suffix check in run_esphome reads -BUNDLE_EXTENSION from esphome.const without importing esphome.bundle, and -the real validated-config cache parse, include resolution included, stays -voluptuous free. +Covers three fast-path claims: the bundle suffix check in run_esphome reads +BUNDLE_EXTENSION from esphome.const without importing esphome.bundle, the +validated-config cache parse stays voluptuous free, and the JSON cache +(lambda sentinel included) resolves without pyyaml or esphome.yaml_util. """ +import json import os from pathlib import Path import sys @@ -16,7 +17,6 @@ from unittest.mock import patch from _leak_report import print_leaked_modules from _storage import make_storage -import yaml # Everything imported past this point is the code under test; the pop # below must only drop what the setup itself preloaded, or it would @@ -24,8 +24,10 @@ import yaml _FIXTURE_PRELOADED = frozenset(sys.modules) from esphome import __main__ as main_mod # noqa: E402 +from esphome.const import __version__ as ESPHOME_VERSION # noqa: E402 CONFIG_TEXT = "esphome:\n name: t\n" +LAMBDA_BODY = 'ESP_LOGD("t", "x");' # An ambient data-dir override would relocate the storage tree away # from the tmp config dir this fixture builds. @@ -39,13 +41,23 @@ with tempfile.TemporaryDirectory() as _td: storage_dir = tmp / ".esphome" / "storage" storage_dir.mkdir(parents=True) - # The cache is a top-level !include so loading it resolves an - # IncludeFile for real on the fast path. The sidecar is written to the - # layout ext_storage_path resolves once run_esphome sets - # CORE.config_path; going through CORE here would be circular. - (storage_dir / "inc.yaml").write_text(CONFIG_TEXT) - cache_path = storage_dir / "test.yaml.validated.yaml" - cache_path.write_text("!include inc.yaml\n") + # The cache carries a lambda sentinel so loading revives a real Lambda + # on the fast path. The sidecar is written to the layout + # ext_storage_path resolves once run_esphome sets CORE.config_path; + # going through CORE here would be circular. + cache_path = storage_dir / "test.yaml.validated.json" + cache_path.write_text( + json.dumps( + { + "v": 1, + "esphome": ESPHOME_VERSION, + "config": { + "esphome": {"name": "t"}, + "script": [{"lambda": {"__esphome_lambda__": LAMBDA_BODY}}], + }, + } + ) + ) os.utime(cache_path) # keep the cache at least as fresh as the source make_storage().save(storage_dir / "test.yaml.json") @@ -76,7 +88,13 @@ with tempfile.TemporaryDirectory() as _td: # asserts so PYTHONOPTIMIZE in the ambient environment can't strip them. if exit_code != 0: sys.exit(f"run_esphome exited {exit_code} before dispatching upload") - if dispatched.get("config") != yaml.safe_load(CONFIG_TEXT): - sys.exit(f"cache include did not resolve through the fast path: {dispatched!r}") + config = dispatched.get("config") + if config is None or config.get("esphome") != {"name": "t"}: + sys.exit(f"cache did not resolve through the fast path: {dispatched!r}") + from esphome.core import Lambda + + revived = config["script"][0]["lambda"] + if not isinstance(revived, Lambda) or revived.value != LAMBDA_BODY: + sys.exit(f"lambda sentinel did not revive: {revived!r}") print_leaked_modules() diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index b852d2d596..b3c2170c3f 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -2,15 +2,20 @@ from __future__ import annotations +from ipaddress import IPv4Address, IPv4Network import json import os from pathlib import Path +from typing import Any from unittest.mock import patch +from uuid import UUID import pytest +from esphome import const, yaml_util from esphome.__main__ import run_esphome from esphome.compiled_config import ( + _LAMBDA_KEY, compiled_config_path, load_compiled_config, save_compiled_config, @@ -24,30 +29,26 @@ from esphome.const import ( KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, KEY_VARIANT, + Toolchain, ) -from esphome.core import CORE -from esphome.yaml_util import ESPHomeDataBase +from esphome.core import CORE, ID, HexInt, Lambda, MACAddress, TimePeriodMilliseconds +from esphome.util import OrderedDict -_VALIDATED_CONFIG_YAML = """\ -esphome: - name: lite_test - friendly_name: Lite Test Device -esp32: - board: nodemcu-32s -logger: - baud_rate: 115200 -api: - port: 6053 - encryption: - key: 6dGhpcyBpcyBhIHRlc3Q= -ota: - - platform: esphome - port: 3232 - password: secret -wifi: - ssid: ssid - use_address: 192.168.1.42 -""" +_VALIDATED_CONFIG = { + "esphome": {"name": "lite_test", "friendly_name": "Lite Test Device"}, + "esp32": {"board": "nodemcu-32s"}, + "logger": {"baud_rate": 115200}, + "api": {"port": 6053, "encryption": {"key": "6dGhpcyBpcyBhIHRlc3Q="}}, + "ota": [{"platform": "esphome", "port": 3232, "password": "secret"}], + "wifi": {"ssid": "ssid", "use_address": "192.168.1.42"}, +} + + +def _cache_body(config: dict | None = None) -> str: + """Render the JSON envelope the production save writes.""" + return json.dumps( + {"v": 1, "esphome": const.__version__, "config": config or _VALIDATED_CONFIG} + ) def _write_storage( @@ -79,10 +80,10 @@ def _write_storage( storage_path.write_text(json.dumps(data), encoding="utf-8") -def _write_cache(cache_path: Path, body: str = _VALIDATED_CONFIG_YAML) -> Path: +def _write_cache(cache_path: Path, body: str | None = None) -> Path: """Write the cache file and return it.""" cache_path.parent.mkdir(parents=True, exist_ok=True) - cache_path.write_text(body, encoding="utf-8") + cache_path.write_text(body if body is not None else _cache_body(), encoding="utf-8") return cache_path @@ -96,24 +97,28 @@ def _set_cache_mtime(cache_path: Path, yaml_path: Path, *, offset: int) -> None: @pytest.fixture -def fresh_cache_files(tmp_path: Path) -> Path: - """YAML + StorageJSON + cache, all consistent and fresh.""" +def primed_storage(tmp_path: Path) -> Path: + """YAML + StorageJSON sidecar, no cache yet.""" yaml_path = tmp_path / "lite_test.yaml" yaml_path.write_text("esphome:\n name: lite_test\n") CORE.config_path = yaml_path - - storage_dir = tmp_path / ".esphome" / "storage" - _write_storage(storage_dir / "lite_test.yaml.json") - cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") - _set_cache_mtime(cache, yaml_path, offset=5) - + _write_storage(tmp_path / ".esphome" / "storage" / "lite_test.yaml.json") return yaml_path +@pytest.fixture +def fresh_cache_files(primed_storage: Path) -> Path: + """YAML + StorageJSON + cache, all consistent and fresh.""" + storage_dir = primed_storage.parent / ".esphome" / "storage" + cache = _write_cache(storage_dir / "lite_test.yaml.validated.json") + _set_cache_mtime(cache, primed_storage, offset=5) + return primed_storage + + def test_compiled_config_path_lives_alongside_sidecar(setup_core: Path) -> None: """The cache file shape is predictable from the YAML filename.""" path = compiled_config_path("device.yaml") - assert path.name == "device.yaml.validated.yaml" + assert path.name == "device.yaml.validated.json" assert path.parent.name == "storage" @@ -126,9 +131,8 @@ def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None: assert config[CONF_API]["encryption"]["key"] == "6dGhpcyBpcyBhIHRlc3Q=" assert config["ota"][0]["password"] == "secret" - # The fast path loads without per-node source ranges (the full - # contract lives in test_yaml_util; this checks the flag is wired up). - assert not isinstance(config[CONF_ESPHOME][CONF_NAME], ESPHomeDataBase) + # The fast path loads plain scalars; no per-node source ranges exist. + assert type(config[CONF_ESPHOME][CONF_NAME]) is str # apply_to_core populated exactly what upload/logs read off CORE. assert CORE.name == "lite_test" @@ -147,7 +151,7 @@ def test_load_compiled_config_populates_esp32_variant(tmp_path: Path) -> None: storage_dir = tmp_path / ".esphome" / "storage" _write_storage(storage_dir / "lite_test.yaml.json", esp_platform="ESP32S3") - cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + cache = _write_cache(storage_dir / "lite_test.yaml.validated.json") _set_cache_mtime(cache, yaml_path, offset=5) assert load_compiled_config(yaml_path) is not None @@ -168,7 +172,7 @@ def test_load_compiled_config_skips_esp32_block_for_other_platforms( esp_platform="ESP8266", core_platform="esp8266", ) - cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + cache = _write_cache(storage_dir / "lite_test.yaml.validated.json") _set_cache_mtime(cache, yaml_path, offset=5) assert load_compiled_config(yaml_path) is not None @@ -185,7 +189,7 @@ def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None: yaml_path.write_text("esphome:\n name: lite_test\n") CORE.config_path = yaml_path storage_dir = tmp_path / ".esphome" / "storage" - cache_path = storage_dir / "lite_test.yaml.validated.yaml" + cache_path = storage_dir / "lite_test.yaml.validated.json" sidecar_path = storage_dir / "lite_test.yaml.json" if scenario == "missing_cache": @@ -196,7 +200,7 @@ def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None: elif scenario == "corrupt_cache": _write_storage(sidecar_path) _set_cache_mtime( - _write_cache(cache_path, "not: valid: yaml: ["), yaml_path, offset=5 + _write_cache(cache_path, '{"v": 1, "config": {'), yaml_path, offset=5 ) elif scenario == "missing_sidecar": # Cache fresh + parseable, but no StorageJSON → can't populate CORE. @@ -205,6 +209,108 @@ def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None: assert load_compiled_config(yaml_path) is None +@pytest.mark.parametrize( + "body", + [ + pytest.param( + json.dumps( + {"v": 999, "esphome": const.__version__, "config": {"esphome": {}}} + ), + id="wrong_version", + ), + pytest.param( + json.dumps({"esphome": const.__version__, "config": {"esphome": {}}}), + id="missing_version", + ), + pytest.param( + json.dumps({"v": 1, "esphome": "2020.1.0", "config": {"esphome": {}}}), + id="other_esphome_version", + ), + pytest.param( + json.dumps({"v": 1, "config": {"esphome": {}}}), + id="missing_esphome_version", + ), + pytest.param( + json.dumps( + { + "v": 1, + "esphome": const.__version__, + "config": ["not", "a", "dict"], + } + ), + id="non_dict_config", + ), + pytest.param( + json.dumps({"v": 1, "esphome": const.__version__}), id="missing_config" + ), + pytest.param(json.dumps(["not", "an", "envelope"]), id="non_dict_envelope"), + ], +) +def test_load_compiled_config_rejects_bad_envelope( + primed_storage: Path, body: str +) -> None: + """A foreign or future cache shape falls back instead of half-loading.""" + storage_dir = primed_storage.parent / ".esphome" / "storage" + cache = _write_cache(storage_dir / "lite_test.yaml.validated.json", body) + _set_cache_mtime(cache, primed_storage, offset=5) + + assert load_compiled_config(primed_storage) is None + + +def test_load_ignores_legacy_yaml_cache(primed_storage: Path) -> None: + """A fresh pre-JSON ``.validated.yaml`` alone can't drive the fast path.""" + storage_dir = primed_storage.parent / ".esphome" / "storage" + legacy = _write_cache( + storage_dir / "lite_test.yaml.validated.yaml", "esphome:\n name: lite_test\n" + ) + _set_cache_mtime(legacy, primed_storage, offset=5) + + assert load_compiled_config(primed_storage) is None + + +def test_save_removes_stale_legacy_yaml_cache(tmp_path: Path) -> None: + """A successful save leaves only the JSON cache behind.""" + CORE.config_path = tmp_path / "lite_test.yaml" + legacy = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml" + legacy.parent.mkdir(parents=True, exist_ok=True) + legacy.write_text("esphome:\n name: lite_test\n") + + save_compiled_config({"esphome": {"name": "lite_test"}}) + + assert compiled_config_path("lite_test.yaml").is_file() + assert not legacy.exists() + + +def test_save_removes_legacy_yaml_even_when_write_fails(tmp_path: Path) -> None: + """The secret-bearing legacy cache goes away regardless of write outcome.""" + CORE.config_path = tmp_path / "lite_test.yaml" + legacy = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml" + legacy.parent.mkdir(parents=True, exist_ok=True) + legacy.write_text("esphome:\n name: lite_test\n") + + with patch("esphome.compiled_config.write_file", side_effect=RuntimeError("boom")): + save_compiled_config({"esphome": {"name": "lite_test"}}) + + assert not legacy.exists() + assert not compiled_config_path("lite_test.yaml").exists() + + +def test_save_warns_when_legacy_cache_unremovable( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A secret-bearing legacy file that won't unlink warns; the write proceeds.""" + CORE.config_path = tmp_path / "lite_test.yaml" + legacy = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml" + legacy.parent.mkdir(parents=True, exist_ok=True) + legacy.mkdir() # unlink() on a directory raises OSError + + with caplog.at_level("WARNING", logger="esphome.compiled_config"): + save_compiled_config({"esphome": {"name": "lite_test"}}) + + assert "legacy validated-config cache" in caplog.text + assert compiled_config_path("lite_test.yaml").is_file() + + @pytest.mark.parametrize("command", ["upload", "logs"]) def test_run_esphome_upload_and_logs_use_cache_when_fresh( command: str, @@ -258,7 +364,7 @@ def test_run_esphome_upload_does_not_refresh_cache_without_sidecar( ) -> None: """Without a StorageJSON sidecar (no compile has run), the fallback skips the cache write -- load_compiled_config requires the sidecar, - so writing the rendered (secret-resolved) YAML would be inert and + so writing the rendered (secret-resolved) config would be inert and leak secrets to disk for nothing.""" yaml_path = tmp_path / "lite_test.yaml" yaml_path.write_text("esphome:\n name: lite_test\n") @@ -293,7 +399,7 @@ def test_run_esphome_upload_and_logs_refresh_cache_on_fallback( storage_dir = tmp_path / ".esphome" / "storage" _write_storage(storage_dir / "lite_test.yaml.json") - cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + cache = _write_cache(storage_dir / "lite_test.yaml.validated.json") _set_cache_mtime(cache, yaml_path, offset=-60) # stale fresh_config = {"esphome": {"name": "lite_test"}, "logger": {}} @@ -386,28 +492,161 @@ def test_run_esphome_compile_does_not_use_cache(fresh_cache_files: Path) -> None def test_save_compiled_config_writes_cache(tmp_path: Path) -> None: - """`save_compiled_config` writes the dumped YAML next to the sidecar.""" + """`save_compiled_config` writes the JSON envelope next to the sidecar.""" CORE.config_path = tmp_path / "lite_test.yaml" save_compiled_config({"esphome": {"name": "lite_test"}, "logger": {}}) cache_path = compiled_config_path("lite_test.yaml") assert cache_path.is_file() - body = cache_path.read_text() - assert "name: lite_test" in body - assert "logger:" in body + envelope = json.loads(cache_path.read_text()) + assert envelope["v"] == 1 + assert envelope["esphome"] == const.__version__ + assert envelope["config"] == {"esphome": {"name": "lite_test"}, "logger": {}} -def test_save_compiled_config_swallows_dump_errors( +def test_save_compiled_config_swallows_write_errors( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: - """Failures during the dump are non-fatal -- a bad cache just means + """Failures during the write are non-fatal -- a bad cache just means the next fast path falls back to read_config().""" CORE.config_path = tmp_path / "lite_test.yaml" - with patch("esphome.yaml_util.dump", side_effect=RuntimeError("boom")): + with patch("esphome.compiled_config.write_file", side_effect=RuntimeError("boom")): save_compiled_config({"esphome": {"name": "lite_test"}}) assert not compiled_config_path("lite_test.yaml").exists() +def test_save_stringifies_unknown_values(tmp_path: Path) -> None: + """A type with no dedicated encoding stores its string form.""" + + class Weird: + def __str__(self) -> str: + return "weird-str" + + CORE.config_path = tmp_path / "lite_test.yaml" + save_compiled_config({"esphome": {"name": "lite_test", "weird": Weird()}}) + envelope = json.loads(compiled_config_path("lite_test.yaml").read_text()) + assert envelope["config"]["esphome"]["weird"] == "weird-str" + + +def test_save_skips_cache_on_unserializable_key(tmp_path: Path) -> None: + """A non-basic dict key aborts the write; the fast path falls back.""" + CORE.config_path = tmp_path / "lite_test.yaml" + save_compiled_config({"esphome": {("a", "b"): "lite_test"}}) + assert not compiled_config_path("lite_test.yaml").exists() + + +def _normalize(value: Any) -> Any: + """Make Lambda comparable; everything else compares by value already.""" + if isinstance(value, Lambda): + return ("__lambda__", value.value) + if isinstance(value, dict): + return {k: _normalize(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_normalize(v) for v in value] + return value + + +def _round_trip_config() -> OrderedDict: + """A post-validation shaped config exercising every representer type.""" + return OrderedDict( + { + "esphome": OrderedDict( + { + "name": "lite_test", + "build_path": Path("/build/lite_test"), + "on_boot": [ + OrderedDict( + { + "trigger_id": ID("trigger_1", type="Trigger"), + "then": [{"lambda": Lambda('ESP_LOGD("t", "x");')}], + } + ) + ], + } + ), + "wifi": OrderedDict( + { + "id": ID("wifi_id", type="WiFiComponent"), + "reboot_timeout": TimePeriodMilliseconds(milliseconds=900000), + "use_address": IPv4Address("192.168.1.42"), + "subnet": IPv4Network("192.168.1.0/24"), + "mac": MACAddress(0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01), + } + ), + "misc": OrderedDict( + { + "uuid": UUID("12345678-1234-5678-1234-567812345678"), + "toolchain": Toolchain.PLATFORMIO, + "hex": HexInt(0x1234), + "levels": (1, 2.5, True, None), + "empty": {}, + } + ), + } + ) + + +def test_cache_round_trip_matches_yaml_cache(primed_storage: Path) -> None: + """The JSON cache loads the same tree the YAML cache used to.""" + config = _round_trip_config() + save_compiled_config(config) + from_json = load_compiled_config(primed_storage) + assert from_json is not None + + yaml_cache = primed_storage.parent / "dumped.yaml" + yaml_cache.write_text(yaml_util.dump(config, show_secrets=True)) + from_yaml = yaml_util.load_yaml( + yaml_cache, clear_secrets=False, track_document_range=False + ) + + assert _normalize(from_json) == _normalize(from_yaml) + + +def test_lambda_sentinel_round_trips(primed_storage: Path) -> None: + """A !lambda body comes back as a Lambda with the same source.""" + body = 'id(sensor_1).publish_state(42);\nreturn "multi\\nline";' + save_compiled_config( + { + "esphome": {"name": "lite_test"}, + "script": [{"then": [{"lambda": Lambda(body)}]}], + } + ) + + config = load_compiled_config(primed_storage) + assert config is not None + revived = config["script"][0]["then"][0]["lambda"] + assert isinstance(revived, Lambda) + assert revived.value == body + + +def test_object_hook_requires_exact_shape(primed_storage: Path) -> None: + """Only the exact one-key string-valued sentinel revives a Lambda.""" + storage_dir = primed_storage.parent / ".esphome" / "storage" + config = { + "esphome": {"name": "lite_test"}, + "extra_key": {_LAMBDA_KEY: "x", "y": 1}, + "non_str": {_LAMBDA_KEY: 5}, + } + cache = _write_cache( + storage_dir / "lite_test.yaml.validated.json", _cache_body(config) + ) + _set_cache_mtime(cache, primed_storage, offset=5) + + loaded = load_compiled_config(primed_storage) + assert loaded is not None + assert loaded["extra_key"] == {_LAMBDA_KEY: "x", "y": 1} + assert loaded["non_str"] == {_LAMBDA_KEY: 5} + + +def test_int_keys_coerce_to_strings(primed_storage: Path) -> None: + """Non-str basic keys stringify; validated configs only use string keys.""" + save_compiled_config({"esphome": {"name": "lite_test"}, "table": {1: "a", 2: "b"}}) + + config = load_compiled_config(primed_storage) + assert config is not None + assert config["table"] == {"1": "a", "2": "b"} + + def test_load_compiled_config_rejects_wizard_only_sidecar(tmp_path: Path) -> None: """A wizard-only sidecar (no compile -- no core_platform / target_platform) can't drive upload/logs, so the fast path falls back.""" @@ -426,7 +665,7 @@ def test_load_compiled_config_rejects_wizard_only_sidecar(tmp_path: Path) -> Non '"loaded_integrations": [], "loaded_platforms": [], "no_mdns": false, ' '"framework": null, "core_platform": null}' ) - cache_path = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + cache_path = _write_cache(storage_dir / "lite_test.yaml.validated.json") _set_cache_mtime(cache_path, yaml_path, offset=5) assert load_compiled_config(yaml_path) is None diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 0cb0c1f62d..7f00d00ef7 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -1,5 +1,7 @@ import os from pathlib import Path +import subprocess +import sys from unittest.mock import patch from hypothesis import given @@ -213,6 +215,31 @@ class TestLambda: assert str(target) is value.value + def test_init__expression_initializer(self): + from esphome.cpp_generator import RawExpression + + target = core.Lambda(RawExpression("foo()")) + + assert target.value == "foo();" + + def test_init__other_initializer(self): + target = core.Lambda(123) + + assert target.value == 123 + + def test_init_from_str_does_not_import_codegen(self): + """The validated-config cache revives Lambdas on the upload fast path.""" + # sys.exit rather than assert so ambient PYTHONOPTIMIZE can't strip it. + check = ( + "import sys; from esphome.core import Lambda; " + "Lambda('return 1;'); " + "sys.exit('codegen leaked' if 'esphome.cpp_generator' in sys.modules else 0)" + ) + result = subprocess.run( + [sys.executable, "-c", check], capture_output=True, text=True, check=False + ) + assert result.returncode == 0, result.stderr + def test_parts(self): target = core.Lambda(SAMPLE_LAMBDA.strip()) diff --git a/tests/unit_tests/test_lazy_imports.py b/tests/unit_tests/test_lazy_imports.py index 8358f4b781..2e09c4a945 100644 --- a/tests/unit_tests/test_lazy_imports.py +++ b/tests/unit_tests/test_lazy_imports.py @@ -46,6 +46,11 @@ API_HEAVY_MODULES = ("aioesphomeapi",) # never pays for the bundle machinery and its tarfile chain. BUNDLE_HEAVY_MODULES = ("esphome.bundle", "tarfile") +# Heavy only for a cache-hit upload/logs run: the JSON cache parse must +# not resolve pyyaml or the yaml_util chain (the read_config fallback +# still uses both). +CACHE_HIT_HEAVY_MODULES = ("esphome.yaml_util", "yaml") + # Stdlib modules deferred out of the dispatch fast path: a cache-hit # upload/logs run never writes a file (tempfile), spawns a process # (subprocess), parses a URL (urllib.parse), or prints a serial @@ -56,8 +61,6 @@ STDLIB_FAST_PATH_MODULES = ( "tempfile", "subprocess", "getpass", - # Pins the module-level contract only: PyYAML's constructor loads - # datetime during the cache parse until the JSON cache lands. "datetime", *(("urllib.parse",) if sys.version_info >= (3, 13) else ()), ) @@ -108,6 +111,7 @@ def test_watched_heavy_modules_exist() -> None: FAST_PATH_HEAVY_MODULES + API_HEAVY_MODULES + BUNDLE_HEAVY_MODULES + + CACHE_HIT_HEAVY_MODULES + STDLIB_FAST_PATH_MODULES ): assert importlib.util.find_spec(module) is not None, ( @@ -270,13 +274,13 @@ def test_upload_command_path_does_not_import_heavy_modules( leaked = _leaked_from_fixture( fixture_path, "upload_command_fast_path.py", - extra=BUNDLE_HEAVY_MODULES + STDLIB_FAST_PATH_MODULES, + extra=BUNDLE_HEAVY_MODULES + CACHE_HIT_HEAVY_MODULES + STDLIB_FAST_PATH_MODULES, ) assert not leaked, ( f"the upload dispatch path pulls in heavy modules: {leaked}. " "An ordinary run only needs the bundle suffix constant, and the " - "cache parse must not resolve voluptuous; keep the esphome.bundle " - "import inside the branch that extracts one, the Invalid import " - "inside the branch that raises it, and the deferred stdlib " - "imports inside the write/spawn/serial helpers that use them." + "JSON cache parse must not resolve voluptuous or pyyaml; keep the " + "esphome.bundle import inside the branch that extracts one, the " + "yaml_util imports inside the read_config fallback, and the " + "deferred stdlib imports inside the write/spawn/serial helpers." ) From 25cb4400059e6d8dca4b5cfb5049830d6a815c31 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 00:29:00 -0500 Subject: [PATCH 1344/1815] [core] Use Happy Eyeballs for remote file downloads (#18050) --- .../components/dashboard_import/__init__.py | 2 + esphome/components/esp32/__init__.py | 14 +- esphome/components/font/__init__.py | 2 + esphome/components/shelly_dimmer/light.py | 2 + esphome/external_files.py | 4 + esphome/framework_helpers.py | 5 + esphome/happy_eyeballs.py | 136 ++++++++ requirements.txt | 1 + tests/unit_tests/test_happy_eyeballs.py | 325 ++++++++++++++++++ 9 files changed, 489 insertions(+), 2 deletions(-) create mode 100644 esphome/happy_eyeballs.py create mode 100644 tests/unit_tests/test_happy_eyeballs.py diff --git a/esphome/components/dashboard_import/__init__.py b/esphome/components/dashboard_import/__init__.py index 000db307b9..31559a514c 100644 --- a/esphome/components/dashboard_import/__init__.py +++ b/esphome/components/dashboard_import/__init__.py @@ -12,6 +12,7 @@ from esphome.components.packages import validate_source_shorthand import esphome.config_validation as cv from esphome.const import CONF_ESPHOME, CONF_PROJECT, CONF_REF, CONF_WIFI import esphome.final_validate as fv +from esphome.happy_eyeballs import ensure_happy_eyeballs from esphome.yaml_util import dump dashboard_import_ns = cg.esphome_ns.namespace("dashboard_import") @@ -109,6 +110,7 @@ def import_config( if git_file.query and "full_config" in git_file.query: url = git_file.raw_url try: + ensure_happy_eyeballs() req = requests.get(url, timeout=30) req.raise_for_status() except requests.exceptions.RequestException as e: diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index d16e8ae03c..2e72c78974 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -3286,9 +3286,19 @@ def copy_files(): if str(path).startswith("http"): import requests + from esphome.happy_eyeballs import ensure_happy_eyeballs + + ensure_happy_eyeballs() + + try: + req = requests.get(path, timeout=30) + req.raise_for_status() + except requests.exceptions.RequestException as e: + raise EsphomeError( + f"Could not download extra build file {path}: {e}" + ) from e CORE.relative_build_path(name).parent.mkdir(parents=True, exist_ok=True) - content = requests.get(path, timeout=30).content - CORE.relative_build_path(name).write_bytes(content) + CORE.relative_build_path(name).write_bytes(req.content) else: copy_file_if_changed(path, CORE.relative_build_path(name)) diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index 7510f2f8b6..5872b607f1 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -36,6 +36,7 @@ from esphome.const import ( CONF_WEIGHT, ) from esphome.core import CORE, HexInt +from esphome.happy_eyeballs import ensure_happy_eyeballs from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -319,6 +320,7 @@ def download_gfont(value): if not external_files.is_file_recent(path, value[CONF_REFRESH]): _LOGGER.debug("download_gfont: path=%s", path) try: + ensure_happy_eyeballs() req = requests.get(url, timeout=external_files.NETWORK_TIMEOUT) req.raise_for_status() except requests.exceptions.RequestException as e: diff --git a/esphome/components/shelly_dimmer/light.py b/esphome/components/shelly_dimmer/light.py index f2ab5a4bc1..cd6d858067 100644 --- a/esphome/components/shelly_dimmer/light.py +++ b/esphome/components/shelly_dimmer/light.py @@ -29,6 +29,7 @@ from esphome.const import ( UNIT_WATT, ) from esphome.core import CORE, HexInt +from esphome.happy_eyeballs import ensure_happy_eyeballs DOMAIN = "shelly_dimmer" AUTO_LOAD = ["sensor"] @@ -81,6 +82,7 @@ def get_firmware(value): def dl(url): try: + ensure_happy_eyeballs() req = requests.get(url, timeout=30) req.raise_for_status() except requests.exceptions.RequestException as e: diff --git a/esphome/external_files.py b/esphome/external_files.py index 69423d3999..160a2b6c29 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -14,6 +14,7 @@ import requests import esphome.config_validation as cv from esphome.const import CONF_FILE, CONF_TYPE, CONF_URL, __version__ from esphome.core import CORE, EsphomeError, TimePeriodSeconds +from esphome.happy_eyeballs import ensure_happy_eyeballs from esphome.helpers import write_file from esphome.types import ConfigType @@ -92,6 +93,7 @@ def _write_etag(local_file_path: Path, etag: str | None) -> None: def has_remote_file_changed( url: str, local_file_path: Path, timeout: int = NETWORK_TIMEOUT ) -> bool: + ensure_happy_eyeballs() if local_file_path.exists(): _LOGGER.debug("has_remote_file_changed: File exists at %s", local_file_path) try: @@ -158,6 +160,7 @@ def compute_local_file_dir(domain: str) -> Path: def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> bytes: + ensure_happy_eyeballs() if CORE.skip_external_update and path.exists(): _LOGGER.debug("Skipping update for %s (refresh disabled)", url) return path.read_bytes() @@ -231,6 +234,7 @@ def download_content_many( seen: dict[Path, str] = {path: url for url, path in items} if not seen: return + ensure_happy_eyeballs() _LOGGER.info("Checking %d %s for updates", len(seen), description) if len(seen) == 1: path, url = next(iter(seen.items())) diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 202d4a2bfb..6ed608b171 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -13,6 +13,7 @@ import sys import time from typing import IO, TYPE_CHECKING +from esphome.happy_eyeballs import ensure_happy_eyeballs from esphome.helpers import ProgressBar, rmtree if TYPE_CHECKING: @@ -755,6 +756,8 @@ def download_with_resume( from esphome.core import EsphomeError + ensure_happy_eyeballs() + dest = Path(dest) part = dest.with_name(dest.name + ".part") meta = part.with_name(part.name + ".meta") @@ -922,6 +925,8 @@ def download_from_mirrors( from esphome.core import EsphomeError + ensure_happy_eyeballs() + # 1. Classify the target: filesystem path or open file object path_target: Path | None = None f: IO[bytes] | None = None diff --git a/esphome/happy_eyeballs.py b/esphome/happy_eyeballs.py new file mode 100644 index 0000000000..ebfb94f1f9 --- /dev/null +++ b/esphome/happy_eyeballs.py @@ -0,0 +1,136 @@ +"""Happy Eyeballs (RFC 8305) connection support for requests/urllib3. + +urllib3 tries each resolved address in sequence with the full connect +timeout, so a network advertising IPv6 DNS without IPv6 connectivity stalls +every download for the whole timeout before IPv4 is tried. +``ensure_happy_eyeballs()`` swaps urllib3's ``create_connection`` for one +that races address families with a short stagger via aiohappyeyeballs, run +on a daemon-thread event loop so callers stay synchronous. +""" + +from __future__ import annotations + +import logging +import socket +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Callable + +_LOGGER = logging.getLogger(__name__) + +# RFC 8305 recommended delay between staggered connection attempts. +HAPPY_EYEBALLS_DELAY = 0.25 + +# Extra seconds the connect thread gets beyond the connect timeout before +# the caller gives up waiting for it. +_THREAD_WAIT_BUFFER = 5.0 + + +def ensure_happy_eyeballs() -> None: + """Make urllib3 (and therefore requests) connect with Happy Eyeballs. + + Idempotent; call before performing requests-based downloads. + """ + stock: Callable[..., socket.socket] | None = None + try: + import urllib3.util.connection + + stock = urllib3.util.connection.create_connection + if getattr(stock, "_esphome_patched", False): + return + + urllib3.util.connection.create_connection = _make_create_connection() + except (ImportError, AttributeError) as err: # urllib3 internals moved + # WARNING: degraded mode brings back the stalls this module prevents. + _LOGGER.warning( + "Happy Eyeballs unavailable (%s); downloads use the slower stock " + "urllib3 connect", + err, + ) + _LOGGER.debug("Happy Eyeballs fallback traceback", exc_info=True) + if stock is not None: + # Latch so the warning fires once, not per download. + stock._esphome_patched = True # type: ignore[attr-defined] # pylint: disable=protected-access + + +def _make_create_connection() -> Callable[..., socket.socket]: + """Build a drop-in replacement for urllib3's ``create_connection``.""" + # Deferred so runs that never download skip the ~30 ms asyncio import. + import asyncio + + from aiohappyeyeballs import start_connection + from urllib3.exceptions import LocationParseError + from urllib3.util.connection import ( # noqa: PLC2701 + _set_socket_options, + allowed_gai_family, + ) + from urllib3.util.timeout import _DEFAULT_TIMEOUT # noqa: PLC2701 + + from esphome import async_thread + + def create_connection( + address: tuple[str, int], + timeout: Any = _DEFAULT_TIMEOUT, + source_address: tuple[str, int] | None = None, + socket_options: Any = None, + ) -> socket.socket: + host, port = address + if host.startswith("["): + host = host.strip("[]") + try: + host.encode("idna") + except UnicodeError: + raise LocationParseError(f"'{host}', label empty or too long") from None + + addr_infos = socket.getaddrinfo( + host, port, allowed_gai_family(), socket.SOCK_STREAM + ) + if not addr_infos: + # Same error as stock urllib3. + raise OSError("getaddrinfo returns an empty list") + connect_timeout = ( + socket.getdefaulttimeout() if timeout is _DEFAULT_TIMEOUT else timeout + ) + + def socket_factory(addr_info: Any) -> socket.socket: + family, type_, proto, _, _ = addr_info + sock = socket.socket(family, type_, proto) + try: + _set_socket_options(sock, socket_options) + if source_address: + sock.bind(source_address) + except BaseException: + sock.close() + raise + return sock + + async def connect() -> socket.socket: + return await asyncio.wait_for( + start_connection( + addr_infos, + happy_eyeballs_delay=HAPPY_EYEBALLS_DELAY, + interleave=1, + socket_factory=socket_factory, + ), + connect_timeout, + ) + + wait = ( + None if connect_timeout is None else connect_timeout + _THREAD_WAIT_BUFFER + ) + # on_orphan closes a socket won after the timeout so it cannot leak. + sock = async_thread.run_async( + connect, timeout=wait, on_orphan=socket.socket.close + ) + # aiohappyeyeballs leaves the winning socket non-blocking; restore the + # blocking-with-timeout behavior urllib3 callers expect. + try: + sock.settimeout(connect_timeout) + except BaseException: + sock.close() + raise + return sock + + create_connection._esphome_patched = True # type: ignore[attr-defined] # pylint: disable=protected-access + return create_connection diff --git a/requirements.txt b/requirements.txt index d56a8daec1..4501b733a6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,7 @@ platformio==6.1.19 esptool==5.3.1 click==8.3.3 aioesphomeapi==45.7.0 +aiohappyeyeballs==2.6.2 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import diff --git a/tests/unit_tests/test_happy_eyeballs.py b/tests/unit_tests/test_happy_eyeballs.py new file mode 100644 index 0000000000..3335a8a3e3 --- /dev/null +++ b/tests/unit_tests/test_happy_eyeballs.py @@ -0,0 +1,325 @@ +"""Tests for the Happy Eyeballs urllib3 shim.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Generator +import socket +from typing import Any +from unittest.mock import Mock, patch + +import pytest + +from esphome.happy_eyeballs import _make_create_connection, ensure_happy_eyeballs + + +def _addr_info(host: str, port: int) -> tuple[Any, ...]: + """Build a getaddrinfo-style result tuple for an IPv4 address.""" + return (socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", (host, port)) + + +@pytest.fixture +def create_connection() -> Any: + """A freshly built Happy Eyeballs create_connection replacement.""" + return _make_create_connection() + + +@pytest.fixture +def listener() -> Generator[tuple[str, int]]: + """A listening TCP socket on localhost; yields its address.""" + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.bind(("127.0.0.1", 0)) + server.listen(5) + yield server.getsockname() + server.close() + + +@pytest.fixture +def mock_gai(listener: tuple[str, int]) -> Generator[Any]: + """Resolve every host to two copies of the listener's address.""" + with patch("socket.getaddrinfo", return_value=[_addr_info(*listener)] * 2) as mock: + yield mock + + +def test_ensure_happy_eyeballs_patches_and_is_idempotent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The shim replaces urllib3's create_connection exactly once.""" + import urllib3.util.connection + + def stock(*args: Any, **kwargs: Any) -> None: + pass + + monkeypatch.setattr(urllib3.util.connection, "create_connection", stock) + + ensure_happy_eyeballs() + patched = urllib3.util.connection.create_connection + assert patched is not stock + assert patched._esphome_patched + + ensure_happy_eyeballs() + assert urllib3.util.connection.create_connection is patched + + +def test_connects_and_restores_socket_state( + create_connection: Any, listener: tuple[str, int], mock_gai: Any +) -> None: + """The winning socket comes back blocking, with timeout and options set.""" + sock = create_connection( + ("example.com", listener[1]), + timeout=5, + socket_options=[(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)], + ) + + try: + assert sock.getpeername() == listener + assert sock.gettimeout() == 5 + assert sock.getsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY) != 0 + finally: + sock.close() + + +def test_single_address_connects( + create_connection: Any, listener: tuple[str, int] +) -> None: + """A host resolving to one address connects through the same path.""" + with patch("socket.getaddrinfo", return_value=[_addr_info(*listener)]): + sock = create_connection(("example.com", listener[1]), timeout=5) + + try: + assert sock.getpeername() == listener + finally: + sock.close() + + +def test_falls_back_to_working_address( + create_connection: Any, listener: tuple[str, int], monkeypatch: pytest.MonkeyPatch +) -> None: + """An unreachable first address does not block the working one.""" + from esphome import happy_eyeballs + + # 192.0.2.1 (TEST-NET-1) blackholes or fails fast depending on the + # network; either way the second address must win well within the + # timeout instead of waiting out the first. A short stagger keeps the + # test's duration network independent. + monkeypatch.setattr(happy_eyeballs, "HAPPY_EYEBALLS_DELAY", 0.01) + addr_infos = [_addr_info("192.0.2.1", 9), _addr_info(*listener)] + + with patch("socket.getaddrinfo", return_value=addr_infos): + sock = create_connection(("example.com", listener[1]), timeout=10) + + try: + assert sock.getpeername() == listener + finally: + sock.close() + + +def test_bracketed_ipv6_host_is_stripped( + create_connection: Any, listener: tuple[str, int], mock_gai: Any +) -> None: + """A bracketed IPv6 literal is unbracketed before resolution.""" + sock = create_connection(("[::1]", listener[1]), timeout=5) + + try: + assert mock_gai.call_args[0][0] == "::1" + assert sock.getpeername() == listener + finally: + sock.close() + + +def test_source_address_is_bound( + create_connection: Any, listener: tuple[str, int], mock_gai: Any +) -> None: + """The socket binds to the requested source address before connecting.""" + sock = create_connection( + ("example.com", listener[1]), + timeout=5, + source_address=("127.0.0.1", 0), + ) + + try: + assert sock.getsockname()[0] == "127.0.0.1" + finally: + sock.close() + + +def test_socket_factory_failure_closes_socket( + listener: tuple[str, int], mock_gai: Any +) -> None: + """A socket-option failure fails the connect instead of leaking sockets. + + Instrumented at ``_set_socket_options`` (which the factory calls with + the just-created socket) rather than by patching ``socket.socket``, + which is platform dependent: the event loop's internal socketpair use + differs between platforms. + """ + created: list[socket.socket] = [] + + def failing_set_options(sock: socket.socket, options: Any) -> None: + created.append(sock) + raise OSError("bad socket option") + + # Patch before building the closure; it binds _set_socket_options at + # creation time. + with patch("urllib3.util.connection._set_socket_options", new=failing_set_options): + create_connection = _make_create_connection() + with pytest.raises(OSError): + create_connection( + ("example.com", listener[1]), + timeout=5, + socket_options=[(999999, 999999, 1)], + ) + + assert created, "socket factory never ran" + assert all(sock.fileno() == -1 for sock in created), "socket leaked open" + + +def test_default_timeout_yields_blocking_socket( + create_connection: Any, listener: tuple[str, int], mock_gai: Any +) -> None: + """Without an explicit timeout the socket follows the global default.""" + sock = create_connection(("example.com", listener[1])) + + try: + assert sock.gettimeout() is socket.getdefaulttimeout() + finally: + sock.close() + + +def test_settimeout_failure_closes_socket( + create_connection: Any, mock_gai: Any +) -> None: + """A failure restoring socket state closes the winner instead of leaking.""" + bad_sock = Mock() + bad_sock.settimeout.side_effect = OSError("bad timeout") + + with ( + patch("esphome.async_thread.run_async", return_value=bad_sock), + pytest.raises(OSError, match="bad timeout"), + ): + create_connection(("example.com", 80), timeout=5) + + bad_sock.close.assert_called_once() + + +def test_connect_timeout_raises() -> None: + """A connect that never completes raises within the timeout.""" + + async def never(*args: Any, **kwargs: Any) -> None: + await asyncio.sleep(60) + + addr_infos = [_addr_info("192.0.2.1", 9), _addr_info("192.0.2.2", 9)] + + # Patch before building the closure; it binds start_connection at + # creation time. + with patch("aiohappyeyeballs.start_connection", new=never): + create_connection = _make_create_connection() + with ( + patch("socket.getaddrinfo", return_value=addr_infos), + pytest.raises(TimeoutError), + ): + create_connection(("example.com", 80), timeout=0.1) + + +def test_invalid_host_raises_location_parse_error(create_connection: Any) -> None: + """Hostnames urllib3 would reject are still rejected.""" + from urllib3.exceptions import LocationParseError + + with pytest.raises(LocationParseError): + create_connection(("a" * 300, 80)) + + +def test_empty_getaddrinfo_raises_oserror(create_connection: Any) -> None: + """An empty resolution matches stock urllib3's OSError, not ValueError.""" + with ( + patch("socket.getaddrinfo", return_value=[]), + pytest.raises(OSError, match="empty"), + ): + create_connection(("example.com", 80), timeout=5) + + +def test_ensure_falls_back_to_stock_when_internals_move( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """If urllib3 private names disappear, downloads keep the stock connect + and the warning is latched to fire once, not per download.""" + import urllib3.util.connection + + from esphome import happy_eyeballs + + def stock(*args: Any, **kwargs: Any) -> None: + pass + + factory = Mock(side_effect=ImportError("gone")) + monkeypatch.setattr(urllib3.util.connection, "create_connection", stock) + monkeypatch.setattr(happy_eyeballs, "_make_create_connection", factory) + + ensure_happy_eyeballs() + ensure_happy_eyeballs() + assert urllib3.util.connection.create_connection is stock + assert factory.call_count == 1 + assert caplog.text.count("Happy Eyeballs unavailable") == 1 + + +def test_ensure_survives_missing_urllib3( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """An unimportable urllib3 degrades with a warning instead of raising.""" + import sys + + with patch.dict(sys.modules, {"urllib3.util.connection": None}): + ensure_happy_eyeballs() + assert "Happy Eyeballs unavailable" in caplog.text + + +def test_requests_routes_through_shim(monkeypatch: pytest.MonkeyPatch) -> None: + """Patching urllib3's create_connection actually reroutes requests.""" + from http.server import BaseHTTPRequestHandler, HTTPServer + import threading + + import requests + import urllib3.util.connection + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + self.send_response(200) + self.send_header("Content-Length", "2") + self.end_headers() + self.wfile.write(b"ok") + + def log_message(self, *args: Any) -> None: + pass + + server = HTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + host, port = server.server_address + + calls: list[Any] = [] + shim = _make_create_connection() + + def counting(*args: Any, **kwargs: Any) -> Any: + calls.append(args) + return shim(*args, **kwargs) + + counting._esphome_patched = True + monkeypatch.setattr(urllib3.util.connection, "create_connection", counting) + + real_getaddrinfo = socket.getaddrinfo + + def fake_getaddrinfo(h: str, p: int, *args: Any, **kwargs: Any) -> Any: + if h == "shim-test.invalid": + return [_addr_info(host, port), _addr_info(host, port)] + return real_getaddrinfo(h, p, *args, **kwargs) + + monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo) + + try: + with requests.Session() as session: + session.trust_env = False + resp = session.get(f"http://shim-test.invalid:{port}/", timeout=5) + assert resp.status_code == 200 + assert resp.content == b"ok" + assert calls, "requests did not go through the patched create_connection" + finally: + server.shutdown() + server.server_close() From b0a9bfd381262d292e5c4177813845391b93a4b2 Mon Sep 17 00:00:00 2001 From: Mustafa KURU Date: Mon, 10 Aug 2026 16:59:32 +0300 Subject: [PATCH 1345/1815] [ld6002b] Add area and zone configuration (5/5) (#17823) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/ld6002b/__init__.py | 3 +- esphome/components/ld6002b/binary_sensor.py | 46 +- esphome/components/ld6002b/button/__init__.py | 40 +- esphome/components/ld6002b/const.py | 12 + esphome/components/ld6002b/ld6002b.cpp | 588 +++++++++++++++++- esphome/components/ld6002b/ld6002b.h | 176 +++++- esphome/components/ld6002b/number/__init__.py | 99 +++ esphome/components/ld6002b/select/__init__.py | 16 +- esphome/components/ld6002b/sensor.py | 90 ++- .../ld6002b/test_final_validate.py | 146 ++++- tests/components/ld6002b/common.yaml | 53 ++ 11 files changed, 1219 insertions(+), 50 deletions(-) diff --git a/esphome/components/ld6002b/__init__.py b/esphome/components/ld6002b/__init__.py index af074fc7ea..99f2ead3bb 100644 --- a/esphome/components/ld6002b/__init__.py +++ b/esphome/components/ld6002b/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_WAKEUP_PIN +from esphome.types import ConfigType from .const import CONF_AUTO_WAKE, CONF_WAKEUP_PULSE @@ -14,7 +15,7 @@ ld6002b_ns = cg.esphome_ns.namespace("ld6002b") LD6002BComponent = ld6002b_ns.class_("LD6002BComponent", cg.Component, uart.UARTDevice) -def _validate_wakeup_options(config): +def _validate_wakeup_options(config: ConfigType) -> ConfigType: """Reject wake options that would silently do nothing. Runs before the schema so the defaults for the keys below have not been diff --git a/esphome/components/ld6002b/binary_sensor.py b/esphome/components/ld6002b/binary_sensor.py index 319ace6f5d..63f7b40c23 100644 --- a/esphome/components/ld6002b/binary_sensor.py +++ b/esphome/components/ld6002b/binary_sensor.py @@ -4,24 +4,35 @@ import esphome.config_validation as cv from esphome.const import CONF_TARGET, DEVICE_CLASS_OCCUPANCY from . import LD6002BComponent -from .const import CONF_LD6002B_ID, MAX_TARGETS +from .const import AREA_COUNT, CONF_LD6002B_ID, MAX_TARGETS DEPENDENCIES = ["ld6002b"] -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), - cv.Optional(CONF_TARGET): binary_sensor.binary_sensor_schema( - device_class=DEVICE_CLASS_OCCUPANCY, - ), - } -).extend( - { - cv.Optional(f"target_{i + 1}"): binary_sensor.binary_sensor_schema( - device_class=DEVICE_CLASS_OCCUPANCY, - ) - for i in range(MAX_TARGETS) - } +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_TARGET): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + ), + } + ) + .extend( + { + cv.Optional(f"target_{i + 1}"): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + ) + for i in range(MAX_TARGETS) + } + ) + .extend( + { + cv.Optional(f"detection_area_{i}"): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + ) + for i in range(AREA_COUNT) + } + ) ) @@ -36,3 +47,8 @@ async def to_code(config): if target_config := config.get(f"target_{i + 1}"): sens = await binary_sensor.new_binary_sensor(target_config) cg.add(hub.set_target_presence_binary_sensor(i, sens)) + + for i in range(AREA_COUNT): + if area_config := config.get(f"detection_area_{i}"): + sens = await binary_sensor.new_binary_sensor(area_config) + cg.add(hub.set_area_presence_binary_sensor(i, sens)) diff --git a/esphome/components/ld6002b/button/__init__.py b/esphome/components/ld6002b/button/__init__.py index 0046131b62..c327c331c6 100644 --- a/esphome/components/ld6002b/button/__init__.py +++ b/esphome/components/ld6002b/button/__init__.py @@ -2,15 +2,21 @@ import esphome.codegen as cg from esphome.components import button import esphome.config_validation as cv from esphome.const import ( + CONF_AREA_ID, CONF_ID, CONF_WAKEUP_PIN, ENTITY_CATEGORY_CONFIG, ENTITY_CATEGORY_DIAGNOSTIC, ) import esphome.final_validate as fv +from esphome.types import ConfigType from .. import LD6002BComponent, ld6002b_ns from ..const import ( + CONF_APPLY_AREA, + CONF_AUTO_INTERFERENCE, + CONF_CLEAR_INTERFERENCE, + CONF_GET_AREAS, CONF_GET_DELAY, CONF_GET_INSTALLATION, CONF_GET_LOW_POWER_MODE, @@ -19,6 +25,7 @@ from ..const import ( CONF_GET_TRIGGER_SPEED, CONF_GET_Z_RANGE, CONF_LD6002B_ID, + CONF_RESET_DETECTION_AREA, CONF_RESET_UNATTENDED, CONF_WAKE, ) @@ -31,6 +38,21 @@ ButtonType = ld6002b_ns.enum("ButtonType", is_class=True) CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_APPLY_AREA): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_AUTO_INTERFERENCE): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_GET_AREAS): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_CLEAR_INTERFERENCE): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_RESET_DETECTION_AREA): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG + ), cv.Optional(CONF_GET_DELAY): button.button_schema( LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC ), @@ -62,10 +84,21 @@ CONFIG_SCHEMA = cv.Schema( ) -def final_validate(config): +def final_validate(config: ConfigType) -> ConfigType: full_config = fv.full_config.get() hub_id = config[CONF_LD6002B_ID] + if config.get(CONF_APPLY_AREA): + has_area_id_select = any( + entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_AREA_ID) is not None + for entry in full_config.get("select", []) + ) + if not has_area_id_select: + raise cv.Invalid( + f"{CONF_APPLY_AREA} requires select.area_id for the same ld6002b instance", + path=[CONF_APPLY_AREA], + ) + if config.get(CONF_WAKE): hub_path = full_config.get_path_for_id(hub_id) hub_config = full_config.get_config_for_path(hub_path[:-1]) @@ -81,6 +114,11 @@ def final_validate(config): FINAL_VALIDATE_SCHEMA = final_validate BUTTON_MAP = { + CONF_APPLY_AREA: ButtonType.APPLY_AREA, + CONF_AUTO_INTERFERENCE: ButtonType.AUTO_INTERFERENCE, + CONF_GET_AREAS: ButtonType.GET_AREAS, + CONF_CLEAR_INTERFERENCE: ButtonType.CLEAR_INTERFERENCE, + CONF_RESET_DETECTION_AREA: ButtonType.RESET_DETECTION_AREA, CONF_GET_DELAY: ButtonType.GET_DELAY, CONF_GET_SENSITIVITY: ButtonType.GET_SENSITIVITY, CONF_GET_TRIGGER_SPEED: ButtonType.GET_TRIGGER_SPEED, diff --git a/esphome/components/ld6002b/const.py b/esphome/components/ld6002b/const.py index fac9f08015..b7c3f54a6f 100644 --- a/esphome/components/ld6002b/const.py +++ b/esphome/components/ld6002b/const.py @@ -1,6 +1,11 @@ +CONF_APPLY_AREA = "apply_area" +CONF_AREA_CONFIG = "area_config" +CONF_AUTO_INTERFERENCE = "auto_interference" CONF_AUTO_WAKE = "auto_wake" +CONF_CLEAR_INTERFERENCE = "clear_interference" CONF_CLUSTER_ID = "cluster_id" CONF_DOPPLER_INDEX = "doppler_index" +CONF_GET_AREAS = "get_areas" CONF_GET_DELAY = "get_delay" CONF_GET_INSTALLATION = "get_installation" CONF_GET_LOW_POWER_MODE = "get_low_power_mode" @@ -16,6 +21,7 @@ CONF_LOW_POWER_SLEEP_TIME = "low_power_sleep_time" CONF_OTA_VERSION = "ota_version" CONF_POINT_CLOUD = "point_cloud" CONF_POINT_COUNT = "point_count" +CONF_RESET_DETECTION_AREA = "reset_detection_area" CONF_RESET_UNATTENDED = "reset_unattended" CONF_TARGET_DISPLAY = "target_display" CONF_TRIGGER_SPEED = "trigger_speed" @@ -26,4 +32,10 @@ CONF_Z = "z" CONF_Z_MAX = "z_max" CONF_Z_MIN = "z_min" +KEY_X_MIN = "x_min" +KEY_X_MAX = "x_max" +KEY_Y_MIN = "y_min" +KEY_Y_MAX = "y_max" + +AREA_COUNT = 4 MAX_TARGETS = 3 diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index 25b3da174c..ca6b9b9552 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -15,12 +15,16 @@ static constexpr uint32_t SETUP_DELAY_MS = 100; // Command/message types static constexpr uint16_t TYPE_CONTROL = 0x0201; +static constexpr uint16_t TYPE_SET_AREA = 0x0202; static constexpr uint16_t TYPE_SET_HOLD_DELAY = 0x0203; static constexpr uint16_t TYPE_SET_Z_RANGE = 0x0204; static constexpr uint16_t TYPE_SET_LOW_POWER_SLEEP = 0x0205; static constexpr uint16_t TYPE_REPORT_TARGET = 0x0A04; static constexpr uint16_t TYPE_REPORT_POINT_CLOUD = 0x0A08; +static constexpr uint16_t TYPE_REPORT_AREA_PRESENCE = 0x0A0A; +static constexpr uint16_t TYPE_REPORT_INTERFERENCE_AREAS = 0x0A0B; +static constexpr uint16_t TYPE_REPORT_DETECTION_AREAS = 0x0A0C; static constexpr uint16_t TYPE_REPORT_DELAY = 0x0A0D; static constexpr uint16_t TYPE_REPORT_SENSITIVITY = 0x0A0E; static constexpr uint16_t TYPE_REPORT_TRIGGER = 0x0A0F; @@ -32,6 +36,10 @@ static constexpr uint16_t TYPE_REPORT_WORK_MODE = 0x0A14; static constexpr uint16_t TYPE_QUERY_VERSION = 0xFFFF; // Control command values for TYPE_CONTROL +static constexpr uint32_t CMD_AUTO_INTERFERENCE = 0x01; +static constexpr uint32_t CMD_GET_AREAS = 0x02; +static constexpr uint32_t CMD_CLEAR_INTERFERENCE = 0x03; +static constexpr uint32_t CMD_RESET_DETECTION_AREA = 0x04; static constexpr uint32_t CMD_GET_DELAY = 0x05; static constexpr uint32_t CMD_POINT_CLOUD_ON = 0x06; static constexpr uint32_t CMD_POINT_CLOUD_OFF = 0x07; @@ -55,13 +63,26 @@ static constexpr uint32_t CMD_GET_LOW_POWER = 0x18; static constexpr uint32_t CMD_GET_LOW_POWER_SLEEP = 0x19; static constexpr uint32_t CMD_RESET_UNATTENDED = 0x1A; -static constexpr uint16_t TARGET_DATA_LEN = 20; // x,y,z,dop_idx,cluster_id +static constexpr uint16_t TARGET_DATA_LEN = 20; // x,y,z,dop_idx,cluster_id +static constexpr uint16_t AREA_DATA_LEN = 24; // 6 floats +static constexpr uint16_t AREA_CONFIG_LEN = 28; // int32 + 6 floats +static constexpr uint16_t AREA_PRESENCE_ENTRY_LEN = 4; // uint32 per detection area + +static constexpr uint8_t AREA_ID_DEFAULT = 4; // detection_area_0 for initial display static constexpr uint8_t VERSION_QUERY_DATA[] = {0x01, 0x01, 0x00, 0x00}; #ifdef ESPHOME_LOG_HAS_VERBOSE static const char *control_command_name(uint32_t command) { switch (command) { + case CMD_AUTO_INTERFERENCE: + return "auto_interference"; + case CMD_GET_AREAS: + return "get_areas"; + case CMD_CLEAR_INTERFERENCE: + return "clear_interference"; + case CMD_RESET_DETECTION_AREA: + return "reset_detection_area"; case CMD_GET_DELAY: return "get_delay"; case CMD_POINT_CLOUD_ON: @@ -115,6 +136,8 @@ static const char *frame_type_name(uint16_t type) { switch (type) { case TYPE_CONTROL: return "control"; + case TYPE_SET_AREA: + return "set_area"; case TYPE_SET_HOLD_DELAY: return "set_hold_delay"; case TYPE_SET_Z_RANGE: @@ -125,6 +148,12 @@ static const char *frame_type_name(uint16_t type) { return "report_target"; case TYPE_REPORT_POINT_CLOUD: return "report_point_cloud"; + case TYPE_REPORT_AREA_PRESENCE: + return "report_area_presence"; + case TYPE_REPORT_INTERFERENCE_AREAS: + return "report_interference_areas"; + case TYPE_REPORT_DETECTION_AREAS: + return "report_detection_areas"; case TYPE_REPORT_DELAY: return "report_delay"; case TYPE_REPORT_SENSITIVITY: @@ -150,6 +179,8 @@ static const char *frame_type_name(uint16_t type) { static bool is_expected_control_report(uint32_t command, uint16_t type) { switch (command) { + case CMD_GET_AREAS: + return type == TYPE_REPORT_INTERFERENCE_AREAS || type == TYPE_REPORT_DETECTION_AREAS; case CMD_GET_DELAY: return type == TYPE_REPORT_DELAY; case CMD_GET_SENSITIVITY: @@ -200,6 +231,10 @@ void LD6002BComponent::write_u32_le(uint8_t *data, uint32_t value) { data[3] = (value >> 24) & 0xFF; } +void LD6002BComponent::write_int32_le(uint8_t *data, int32_t value) { + write_u32_le(data, static_cast(value)); +} + void LD6002BComponent::write_f32_le(uint8_t *data, float value) { uint32_t raw; std::memcpy(&raw, &value, sizeof(raw)); @@ -356,6 +391,37 @@ void LD6002BComponent::setup() { this->send_control_command_(CMD_GET_LOW_POWER); } + bool want_area_report = false; +#ifdef USE_SENSOR + for (const auto &area : this->interference_areas_) { + if (area.x_min != nullptr || area.x_max != nullptr || area.y_min != nullptr || area.y_max != nullptr || + area.z_min != nullptr || area.z_max != nullptr) { + want_area_report = true; + break; + } + } + if (!want_area_report) { + for (const auto &area : this->detection_areas_) { + if (area.x_min != nullptr || area.x_max != nullptr || area.y_min != nullptr || area.y_max != nullptr || + area.z_min != nullptr || area.z_max != nullptr) { + want_area_report = true; + break; + } + } + } +#endif +#ifdef USE_NUMBER + if (this->area_x_min_number_ != nullptr || this->area_x_max_number_ != nullptr || + this->area_y_min_number_ != nullptr || this->area_y_max_number_ != nullptr || + this->area_z_min_number_ != nullptr || this->area_z_max_number_ != nullptr) { + want_area_report = true; + } +#endif + if (want_area_report) { + this->send_control_command_(CMD_GET_AREAS); + } + + this->init_area_id_pref_(); this->init_version_pref_(); #ifdef USE_TEXT_SENSOR @@ -374,7 +440,7 @@ void LD6002BComponent::dump_config() { this->auto_wake_ ? "true" : "false", static_cast(this->max_data_len_)); if (this->wakeup_pin_ != nullptr) { LOG_PIN(" Wake-up Pin: ", this->wakeup_pin_); - ESP_LOGCONFIG(TAG, " Wake Pulse: %ums", this->wakeup_pulse_ms_); + ESP_LOGCONFIG(TAG, " Wake Pulse: %" PRIu32 "ms", this->wakeup_pulse_ms_); } #ifdef USE_SENSOR LOG_SENSOR(" ", "Target Count", this->target_count_sensor_); @@ -386,12 +452,31 @@ void LD6002BComponent::dump_config() { LOG_SENSOR(" ", "Target Doppler Index", target.dop_idx); LOG_SENSOR(" ", "Target Cluster ID", target.cluster_id); } + for (auto &area : this->interference_areas_) { + LOG_SENSOR(" ", "Interference Area X Min", area.x_min); + LOG_SENSOR(" ", "Interference Area X Max", area.x_max); + LOG_SENSOR(" ", "Interference Area Y Min", area.y_min); + LOG_SENSOR(" ", "Interference Area Y Max", area.y_max); + LOG_SENSOR(" ", "Interference Area Z Min", area.z_min); + LOG_SENSOR(" ", "Interference Area Z Max", area.z_max); + } + for (auto &area : this->detection_areas_) { + LOG_SENSOR(" ", "Detection Area X Min", area.x_min); + LOG_SENSOR(" ", "Detection Area X Max", area.x_max); + LOG_SENSOR(" ", "Detection Area Y Min", area.y_min); + LOG_SENSOR(" ", "Detection Area Y Max", area.y_max); + LOG_SENSOR(" ", "Detection Area Z Min", area.z_min); + LOG_SENSOR(" ", "Detection Area Z Max", area.z_max); + } #endif #ifdef USE_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Presence", this->presence_binary_sensor_); for (uint8_t i = 0; i < MAX_TARGETS; i++) { LOG_BINARY_SENSOR(" ", "Target Presence", this->target_presence_[i]); } + for (uint8_t i = 0; i < AREA_COUNT; i++) { + LOG_BINARY_SENSOR(" ", "Detection Area Presence", this->area_presence_[i]); + } #endif #ifdef USE_TEXT_SENSOR LOG_TEXT_SENSOR(" ", "Work Mode", this->work_mode_text_sensor_); @@ -402,6 +487,12 @@ void LD6002BComponent::dump_config() { LOG_NUMBER(" ", "Z Min", this->z_min_number_); LOG_NUMBER(" ", "Z Max", this->z_max_number_); LOG_NUMBER(" ", "Low Power Sleep", this->low_power_sleep_number_); + LOG_NUMBER(" ", "Area X Min", this->area_x_min_number_); + LOG_NUMBER(" ", "Area X Max", this->area_x_max_number_); + LOG_NUMBER(" ", "Area Y Min", this->area_y_min_number_); + LOG_NUMBER(" ", "Area Y Max", this->area_y_max_number_); + LOG_NUMBER(" ", "Area Z Min", this->area_z_min_number_); + LOG_NUMBER(" ", "Area Z Max", this->area_z_max_number_); #endif #ifdef USE_SWITCH LOG_SWITCH(" ", "Low Power", this->low_power_switch_); @@ -412,6 +503,7 @@ void LD6002BComponent::dump_config() { LOG_SELECT(" ", "Sensitivity", this->sensitivity_select_); LOG_SELECT(" ", "Trigger Speed", this->trigger_speed_select_); LOG_SELECT(" ", "Installation Mode", this->installation_select_); + LOG_SELECT(" ", "Area ID", this->area_id_select_); #endif } @@ -527,6 +619,7 @@ void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_ } if (len == 0 && this->command_active_ && this->command_sent_ && type == this->active_command_.type) { ESP_LOGV(TAG, "ACK for command 0x%04X (module frame 0x%04X)", type, this->frame_id_); + const bool refresh_areas = (type == TYPE_SET_AREA) && this->area_write_in_flight_; // This settles one expected reply; the rest stay owed and become the debt for the next command. this->send_generation_++; this->stale_ack_type_ = type; @@ -536,6 +629,10 @@ void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_ this->command_sent_ = false; this->last_send_ms_ = 0; this->process_command_queue_(); + if (refresh_areas) { + this->area_write_in_flight_ = false; + this->set_timeout(AREA_REFRESH_TIMEOUT, 50, [this]() { this->send_control_command_(CMD_GET_AREAS); }); + } return; } @@ -557,6 +654,15 @@ void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_ case TYPE_REPORT_POINT_CLOUD: this->handle_point_cloud_(data, len); break; + case TYPE_REPORT_AREA_PRESENCE: + this->handle_area_presence_(data, len); + break; + case TYPE_REPORT_INTERFERENCE_AREAS: + this->handle_area_report_(true, data, len); + break; + case TYPE_REPORT_DETECTION_AREAS: + this->handle_area_report_(false, data, len); + break; case TYPE_REPORT_DELAY: this->handle_delay_report_(data, len); break; @@ -654,8 +760,9 @@ void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) this->target_presence_any_ = (reported > 0); #ifdef USE_BINARY_SENSOR + bool presence = this->target_presence_any_ || this->area_presence_any_; if (this->presence_binary_sensor_ != nullptr) { - this->presence_binary_sensor_->publish_state(this->target_presence_any_); + this->presence_binary_sensor_->publish_state(presence); } #endif this->update_work_mode_fallback_(); @@ -728,6 +835,84 @@ void LD6002BComponent::handle_point_cloud_(const uint8_t *data, uint16_t len) { #endif } +// 0x0A0A carries one uint32 per detection area -- the protocol names the four +// fields detection_state_area0..3 -- so this covers area ids 4..7 only. The +// interference areas have no presence report: a target inside one is what they +// exist to suppress. +void LD6002BComponent::handle_area_presence_(const uint8_t *data, uint16_t len) { + const uint16_t needed = AREA_COUNT * AREA_PRESENCE_ENTRY_LEN; + if (len < needed) + return; + + this->area_presence_any_ = false; + for (uint8_t i = 0; i < AREA_COUNT; i++) { + uint32_t state = read_u32_le(data + (i * AREA_PRESENCE_ENTRY_LEN)); + bool present = state != 0; + this->area_presence_any_ = this->area_presence_any_ || present; +#ifdef USE_BINARY_SENSOR + if (this->area_presence_[i] != nullptr) { + this->area_presence_[i]->publish_state(present); + } +#endif + } + +#ifdef USE_BINARY_SENSOR + bool presence = this->target_presence_any_ || this->area_presence_any_; + if (this->presence_binary_sensor_ != nullptr) { + this->presence_binary_sensor_->publish_state(presence); + } +#endif + this->update_work_mode_fallback_(); +} + +void LD6002BComponent::handle_area_report_(bool interference, const uint8_t *data, uint16_t len) { + uint16_t needed = AREA_COUNT * AREA_DATA_LEN; + if (len < needed) + return; + + for (uint8_t i = 0; i < AREA_COUNT; i++) { + uint16_t offset = i * AREA_DATA_LEN; + float x_min = read_f32_le(data + offset + 0); + float x_max = read_f32_le(data + offset + 4); + float y_min = read_f32_le(data + offset + 8); + float y_max = read_f32_le(data + offset + 12); + float z_min = read_f32_le(data + offset + 16); + float z_max = read_f32_le(data + offset + 20); + +#ifdef USE_SENSOR + AreaSensors &area = interference ? this->interference_areas_[i] : this->detection_areas_[i]; + if (area.x_min != nullptr) + area.x_min->publish_state(x_min); + if (area.x_max != nullptr) + area.x_max->publish_state(x_max); + if (area.y_min != nullptr) + area.y_min->publish_state(y_min); + if (area.y_max != nullptr) + area.y_max->publish_state(y_max); + if (area.z_min != nullptr) + area.z_min->publish_state(z_min); + if (area.z_max != nullptr) + area.z_max->publish_state(z_max); +#endif + + AreaConfig &store = interference ? this->interference_area_values_[i] : this->detection_area_values_[i]; + store.x_min = x_min; + store.x_max = x_max; + store.y_min = y_min; + store.y_max = y_max; + store.z_min = z_min; + store.z_max = z_max; + + uint8_t selected_id = this->area_id_set_ ? this->area_id_ : AREA_ID_DEFAULT; + bool selected_interference = selected_id < AREA_COUNT; + uint8_t selected_index = selected_interference ? selected_id : static_cast(selected_id - AREA_COUNT); + if (selected_interference == interference && selected_index == i) { + this->update_area_numbers_(store); + } + } + this->try_apply_pending_area_(interference); +} + void LD6002BComponent::handle_delay_report_(const uint8_t *data, uint16_t len) { if (len < 4) return; @@ -815,13 +1000,24 @@ void LD6002BComponent::handle_low_power_sleep_report_(const uint8_t *data, uint1 void LD6002BComponent::handle_work_mode_report_(const uint8_t *data, uint16_t len) { if (len < 1) return; -#ifdef USE_TEXT_SENSOR + // Zero is the unattended half of this transition. Read outside the text sensor's + // ifdef because the area sensors do not need one configured to have gone stale. const bool low_power = (data[0] == 0); +#ifdef USE_TEXT_SENSOR if (this->work_mode_text_sensor_ != nullptr) { this->work_mode_reported_ = true; this->publish_work_mode_(low_power); } #endif + // Protocol V1.2 section 2.1.17: this message is sent only on the transition + // between the unattended low-power mode and normal operation, so a zero is the + // module stating that nobody is in any area. Not while a target is still being + // tracked, though: the reset_unattended command is undocumented on whether it + // forces this report, and where two statements from the module disagree the live + // one wins. + if (low_power && !this->target_presence_any_) { + this->clear_area_presence_(); + } } void LD6002BComponent::update_work_mode_fallback_() { @@ -832,9 +1028,10 @@ void LD6002BComponent::update_work_mode_fallback_() { if (!this->low_power_reported_) { return; } - // Presence is only meaningful while the stream that maintains it runs; with it - // off there is nothing to weigh and low power alone decides. - const bool presence = this->target_display_enabled_ && this->target_presence_any_; + // Target presence is only meaningful while the stream that maintains it runs. + // Area presence keeps its own report, so it still counts with the target stream + // off and low power alone decides only when neither half has anything to say. + const bool presence = (this->target_display_enabled_ && this->target_presence_any_) || this->area_presence_any_; this->publish_work_mode_(this->low_power_enabled_ && !presence); #endif } @@ -857,6 +1054,16 @@ void LD6002BComponent::publish_work_mode_(bool low_power) { void LD6002BComponent::publish_number_clamped_(number::Number *number, float value) { if (number == nullptr) return; + if (std::isnan(value)) { + // NAN is this component's "the module has not told us yet". Publishing it on an + // entity that has never had a state would report a nan where unknown is the + // truth; on one that already shows a value it is the only way to say that value + // no longer describes the selected area. + if (number->has_state()) { + number->publish_state(value); + } + return; + } const float min_value = number->traits.get_min_value(); const float max_value = number->traits.get_max_value(); // Outside the declared range the user cannot write the value back, so publish @@ -891,14 +1098,14 @@ void LD6002BComponent::handle_version_report_(const uint8_t *data, uint16_t len) #endif } -void LD6002BComponent::queue_command_(uint16_t type, const uint8_t *data, uint8_t len) { +bool LD6002BComponent::queue_command_(uint16_t type, const uint8_t *data, uint8_t len) { if (len > CMD_MAX_DATA_LEN) { ESP_LOGW(TAG, "Command data too large: %u", len); - return; + return false; } if (this->cmd_count_ >= CMD_QUEUE_SIZE) { ESP_LOGW(TAG, "Command queue full, dropping command 0x%04X", type); - return; + return false; } PendingCommand &cmd = this->cmd_queue_[this->cmd_tail_]; @@ -911,6 +1118,7 @@ void LD6002BComponent::queue_command_(uint16_t type, const uint8_t *data, uint8_ this->cmd_tail_ = (this->cmd_tail_ + 1) % CMD_QUEUE_SIZE; this->cmd_count_++; this->process_command_queue_(); + return true; } void LD6002BComponent::process_command_queue_() { @@ -945,6 +1153,18 @@ void LD6002BComponent::process_command_queue_() { } else { ESP_LOGW(TAG, "Command 0x%04X timed out", this->active_command_.type); } + if (this->active_command_.type == TYPE_SET_AREA) { + this->area_write_in_flight_ = false; + } + // The deferred apply is waiting on the report this command would have + // brought back, and nothing else re-arms it. Dropping it here is the + // difference between one apply lost to a timeout and one that rides in on + // an unrelated area report later, writing bounds the user has moved on from. + if (active_control_command == CMD_GET_AREAS && this->deferred_apply_pending_) { + this->deferred_apply_pending_ = false; + this->restore_deferred_edits_(); + ESP_LOGW(TAG, "Area read timed out, dropping deferred area apply"); + } // A reply may still be in flight for the attempt we just gave up on, so carry one over as // debt rather than clearing the ledger, or that late ACK would retire the successor. Only // one: reaching this point means nothing was answered at all, so the older attempts are @@ -1067,10 +1287,10 @@ void LD6002BComponent::write_frame_(uint16_t type, const uint8_t *data, uint8_t this->last_traffic_ms_ = now; } -void LD6002BComponent::send_control_command_(uint32_t command) { +bool LD6002BComponent::send_control_command_(uint32_t command) { uint8_t data[4]; write_u32_le(data, command); - this->queue_command_(TYPE_CONTROL, data, sizeof(data)); + return this->queue_command_(TYPE_CONTROL, data, sizeof(data)); } void LD6002BComponent::send_z_range_() { @@ -1090,6 +1310,64 @@ void LD6002BComponent::send_z_range_() { this->queue_command_(TYPE_SET_Z_RANGE, data, sizeof(data)); } +void LD6002BComponent::apply_area_config_() { + if (!this->area_id_set_) { + ESP_LOGW(TAG, "Area ID not selected; ignoring apply"); + return; + } + if (this->area_id_ >= AREA_ID_COUNT) { + ESP_LOGW(TAG, "Invalid area id: %u", this->area_id_); + return; + } + + const bool interference = this->area_id_ < AREA_COUNT; + const uint8_t index = interference ? this->area_id_ : static_cast(this->area_id_ - AREA_COUNT); + AreaConfig desired = interference ? this->interference_area_values_[index] : this->detection_area_values_[index]; + if (!std::isnan(this->area_x_min_)) + desired.x_min = this->area_x_min_; + if (!std::isnan(this->area_x_max_)) + desired.x_max = this->area_x_max_; + if (!std::isnan(this->area_y_min_)) + desired.y_min = this->area_y_min_; + if (!std::isnan(this->area_y_max_)) + desired.y_max = this->area_y_max_; + if (!std::isnan(this->area_z_min_)) + desired.z_min = this->area_z_min_; + if (!std::isnan(this->area_z_max_)) + desired.z_max = this->area_z_max_; + + if (std::isnan(desired.x_min) || std::isnan(desired.x_max) || std::isnan(desired.y_min) || + std::isnan(desired.y_max) || std::isnan(desired.z_min) || std::isnan(desired.z_max)) { + // Ask first: a read that never reached the queue would leave a deferral waiting + // on a report nobody requested, with the user's values already retired for it. + if (!this->send_control_command_(CMD_GET_AREAS)) { + ESP_LOGW(TAG, "Area read not queued; area config left unapplied"); + return; + } + this->deferred_apply_pending_ = true; + this->pending_area_id_ = this->area_id_; + // The ledger, not the mirror: the mirror also carries whatever the module last + // reported for the axes the user never touched, and staging those would hand them + // back later wearing the user's badge -- a module value the next report is then + // kept away from. Staging only what was actually typed is also what makes the + // replay's overlay right: the untouched axes come from the fresh report. An + // empty ledger is a meaning rather than a gap, then: an apply with nothing + // staged rewrites the area exactly as the report just described it, which is + // what a direct apply with nothing staged already does. + this->pending_area_updates_ = this->area_edits_; + // Staged above, so they are the deferred apply's values now rather than an + // unsent edit. Anything typed from here belongs to whatever the user does + // next, which may well be a different area. + this->area_edits_ = AreaConfig{}; + ESP_LOGI(TAG, "Area config incomplete; requesting current areas before applying"); + return; + } + // Only a write the module will actually see retires them. + if (this->queue_area_config_(this->area_id_, desired)) { + this->area_edits_ = AreaConfig{}; + } +} + void LD6002BComponent::wake_() { // A command's own pulse raises the pin and writes after it, so ride along instead of // claiming the flag: claiming it would send that command down the immediate-write path @@ -1124,6 +1402,30 @@ void LD6002BComponent::set_number_value(NumberType type, float value) { this->queue_command_(TYPE_SET_LOW_POWER_SLEEP, data, sizeof(data)); break; } + case NumberType::AREA_X_MIN: + this->area_x_min_ = value; + this->area_edits_.x_min = value; + break; + case NumberType::AREA_X_MAX: + this->area_x_max_ = value; + this->area_edits_.x_max = value; + break; + case NumberType::AREA_Y_MIN: + this->area_y_min_ = value; + this->area_edits_.y_min = value; + break; + case NumberType::AREA_Y_MAX: + this->area_y_max_ = value; + this->area_edits_.y_max = value; + break; + case NumberType::AREA_Z_MIN: + this->area_z_min_ = value; + this->area_edits_.z_min = value; + break; + case NumberType::AREA_Z_MAX: + this->area_z_max_ = value; + this->area_edits_.z_max = value; + break; } } @@ -1154,9 +1456,179 @@ void LD6002BComponent::set_select_value(SelectType type, size_t index) { this->send_control_command_(CMD_INSTALL_SIDE); } break; + case SelectType::AREA_ID: + this->area_id_ = static_cast(index); + this->area_id_set_ = true; + this->update_area_numbers_for_id_(this->area_id_); + this->save_area_id_pref_(this->area_id_); + break; } } +void LD6002BComponent::update_area_numbers_(const AreaConfig &area) { + // A report refreshes every axis the user is not in the middle of changing. An + // unapplied edit is the one value here the module cannot know about, so taking + // the report over it would discard what the user typed with nothing to show for it. + const AreaConfig &edits = this->area_edits_; + if (std::isnan(edits.x_min)) + this->area_x_min_ = area.x_min; + if (std::isnan(edits.x_max)) + this->area_x_max_ = area.x_max; + if (std::isnan(edits.y_min)) + this->area_y_min_ = area.y_min; + if (std::isnan(edits.y_max)) + this->area_y_max_ = area.y_max; + if (std::isnan(edits.z_min)) + this->area_z_min_ = area.z_min; + if (std::isnan(edits.z_max)) + this->area_z_max_ = area.z_max; + this->publish_area_numbers_(); +} + +// The mirror, not the report: an axis a report was kept away from has to keep its +// displayed value too, or the entity and the value the next apply sends disagree. +void LD6002BComponent::publish_area_numbers_() { +#ifdef USE_NUMBER + this->publish_number_clamped_(this->area_x_min_number_, this->area_x_min_); + this->publish_number_clamped_(this->area_x_max_number_, this->area_x_max_); + this->publish_number_clamped_(this->area_y_min_number_, this->area_y_min_); + this->publish_number_clamped_(this->area_y_max_number_, this->area_y_max_); + this->publish_number_clamped_(this->area_z_min_number_, this->area_z_min_); + this->publish_number_clamped_(this->area_z_max_number_, this->area_z_max_); +#endif +} + +void LD6002BComponent::update_area_numbers_for_id_(uint8_t area_id) { + if (area_id >= AREA_ID_COUNT) + return; + const bool interference = area_id < AREA_COUNT; + const uint8_t index = interference ? area_id : static_cast(area_id - AREA_COUNT); + const AreaConfig &area = interference ? this->interference_area_values_[index] : this->detection_area_values_[index]; + // The edits belonged to the area being navigated away from. + this->area_edits_ = AreaConfig{}; + this->update_area_numbers_(area); +} + +bool LD6002BComponent::queue_area_config_(uint8_t area_id, const AreaConfig &desired) { + // One frame carries all three pairs and cannot express a crossed one; the module + // would keep a box nothing can ever be inside. Both callers arrive with the six + // bounds resolved, so this is the last place that can say no -- and the return + // value is how saying no reaches the caller, which must not then retire the edits + // the user still has to fix. + if (desired.x_min > desired.x_max || desired.y_min > desired.y_max || desired.z_min > desired.z_max) { + ESP_LOGW(TAG, "Area %u not written, min above max", area_id); + return false; + } + uint8_t data[AREA_CONFIG_LEN]; + write_int32_le(data, static_cast(area_id)); + write_f32_le(data + 4, desired.x_min); + write_f32_le(data + 8, desired.x_max); + write_f32_le(data + 12, desired.y_min); + write_f32_le(data + 16, desired.y_max); + write_f32_le(data + 20, desired.z_min); + write_f32_le(data + 24, desired.z_max); + + if (!this->queue_command_(TYPE_SET_AREA, data, sizeof(data))) { + // Nothing is on its way, so the cache must not claim these bounds, the ack + // refresh must not be armed for an ack that cannot come, and the values stay + // the user's unsent edit. + return false; + } + this->area_write_in_flight_ = true; + + const bool interference = area_id < AREA_COUNT; + const uint8_t index = interference ? area_id : static_cast(area_id - AREA_COUNT); + AreaConfig &store = interference ? this->interference_area_values_[index] : this->detection_area_values_[index]; + store = desired; + // The six numbers show one area at a time, and a deferred apply can land here for + // an area the user has navigated away from. Same question handle_area_report_ + // asks before it touches them. + const uint8_t selected_id = this->area_id_set_ ? this->area_id_ : AREA_ID_DEFAULT; + if (area_id == selected_id) { + this->update_area_numbers_(store); + } + return true; +} + +void LD6002BComponent::try_apply_pending_area_(bool reported_interference) { + if (!this->deferred_apply_pending_) { + return; + } + if (this->pending_area_id_ >= AREA_ID_COUNT) { + this->deferred_apply_pending_ = false; + return; + } + const bool interference = this->pending_area_id_ < AREA_COUNT; + const uint8_t index = + interference ? this->pending_area_id_ : static_cast(this->pending_area_id_ - AREA_COUNT); + AreaConfig desired = interference ? this->interference_area_values_[index] : this->detection_area_values_[index]; + + if (!std::isnan(this->pending_area_updates_.x_min)) + desired.x_min = this->pending_area_updates_.x_min; + if (!std::isnan(this->pending_area_updates_.x_max)) + desired.x_max = this->pending_area_updates_.x_max; + if (!std::isnan(this->pending_area_updates_.y_min)) + desired.y_min = this->pending_area_updates_.y_min; + if (!std::isnan(this->pending_area_updates_.y_max)) + desired.y_max = this->pending_area_updates_.y_max; + if (!std::isnan(this->pending_area_updates_.z_min)) + desired.z_min = this->pending_area_updates_.z_min; + if (!std::isnan(this->pending_area_updates_.z_max)) + desired.z_max = this->pending_area_updates_.z_max; + + if (std::isnan(desired.x_min) || std::isnan(desired.x_max) || std::isnan(desired.y_min) || + std::isnan(desired.y_max) || std::isnan(desired.z_min) || std::isnan(desired.z_max)) { + // Only the report covering this area's half can still fill it in, and there is + // exactly one of those per read. Once it has landed with a bound still unknown, + // nothing further is coming and waiting means waiting forever. + if (reported_interference == interference) { + this->deferred_apply_pending_ = false; + this->restore_deferred_edits_(); + ESP_LOGW(TAG, "Dropping deferred area apply, area report incomplete"); + } + return; + } + + const uint8_t area_id = this->pending_area_id_; + this->deferred_apply_pending_ = false; + if (!this->queue_area_config_(area_id, desired)) { + // Nothing was queued, so this is a drop like the other two: hand the staged + // values back rather than leaving them with no ledger to protect them. + this->restore_deferred_edits_(); + } +} + +void LD6002BComponent::init_area_id_pref_() { +#ifdef USE_SELECT + if (this->area_id_select_ == nullptr) { + return; + } + this->area_id_pref_ = this->area_id_select_->make_entity_preference(); + this->area_id_pref_initialized_ = true; + + uint8_t value = 0; + if (!this->area_id_pref_.load(&value) || value >= AREA_ID_COUNT) { + // No stored selection. The numbers are about to display this area either way, + // so select it for real: a displayed area that apply_area then refuses to write + // is the one combination the user cannot make sense of. + value = AREA_ID_DEFAULT; + } + this->area_id_select_->publish_state(value); + this->area_id_ = value; + this->area_id_set_ = true; + this->update_area_numbers_for_id_(value); +#endif +} + +void LD6002BComponent::save_area_id_pref_(uint8_t value) { +#ifdef USE_SELECT + if (!this->area_id_pref_initialized_) { + return; + } + this->area_id_pref_.save(&value); +#endif +} + void LD6002BComponent::init_version_pref_() { #ifdef USE_TEXT_SENSOR if (this->ota_version_text_sensor_ == nullptr) { @@ -1211,6 +1683,74 @@ void LD6002BComponent::clear_target_slot_(uint8_t index) { } #endif +void LD6002BComponent::restore_deferred_edits_() { + // The staged values become an unsent edit again, but only for the user who is + // still looking at the area they were staged for; anyone else's ledger belongs to + // the area they are on now. + const uint8_t selected_id = this->area_id_set_ ? this->area_id_ : AREA_ID_DEFAULT; + if (this->pending_area_id_ != selected_id) { + return; + } + // Axis by axis rather than a whole-struct assignment: the user can have edited + // another bound while the deferral was in flight, and that edit is newer than + // anything the deferral staged. Assigning over the ledger would drop it back to + // NaN and let the next report take the value away. A live edit wins; only an axis + // with nothing in the ledger takes its staged value back. + // + // The mirror moves with the ledger, because on the report path handle_area_report_ + // ran update_area_numbers_ before the replay, with the ledger still empty -- so the + // mirror already holds the module's bounds and both the entities and the next apply + // would build on them. On the timeout path no report arrived, the mirror still + // holds the staged values, and this is an identity. + const AreaConfig &staged = this->pending_area_updates_; + if (std::isnan(this->area_edits_.x_min) && !std::isnan(staged.x_min)) { + this->area_edits_.x_min = staged.x_min; + this->area_x_min_ = staged.x_min; + } + if (std::isnan(this->area_edits_.x_max) && !std::isnan(staged.x_max)) { + this->area_edits_.x_max = staged.x_max; + this->area_x_max_ = staged.x_max; + } + if (std::isnan(this->area_edits_.y_min) && !std::isnan(staged.y_min)) { + this->area_edits_.y_min = staged.y_min; + this->area_y_min_ = staged.y_min; + } + if (std::isnan(this->area_edits_.y_max) && !std::isnan(staged.y_max)) { + this->area_edits_.y_max = staged.y_max; + this->area_y_max_ = staged.y_max; + } + if (std::isnan(this->area_edits_.z_min) && !std::isnan(staged.z_min)) { + this->area_edits_.z_min = staged.z_min; + this->area_z_min_ = staged.z_min; + } + if (std::isnan(this->area_edits_.z_max) && !std::isnan(staged.z_max)) { + this->area_edits_.z_max = staged.z_max; + this->area_z_max_ = staged.z_max; + } + this->publish_area_numbers_(); +} + +void LD6002BComponent::clear_area_presence_() { + if (!this->area_presence_any_) { + return; + } + // Nothing else corrects this: 0x0A0A carries no period the protocol states and no + // command stops it, so the module going unattended is the only moment the + // component can know a stored "occupied" has stopped being true. + this->area_presence_any_ = false; +#ifdef USE_BINARY_SENSOR + for (uint8_t i = 0; i < AREA_COUNT; i++) { + if (this->area_presence_[i] != nullptr) { + this->area_presence_[i]->publish_state(false); + } + } + const bool presence = this->target_presence_any_ || this->area_presence_any_; + if (this->presence_binary_sensor_ != nullptr) { + this->presence_binary_sensor_->publish_state(presence); + } +#endif +} + void LD6002BComponent::clear_target_state_() { // Nothing corrects any of this until the stream comes back. The slot table goes // with it: slots key on cluster ids, which only track a person while reports are @@ -1242,8 +1782,9 @@ void LD6002BComponent::clear_target_state_() { if (this->target_presence_any_) { this->target_presence_any_ = false; #ifdef USE_BINARY_SENSOR + bool presence = this->target_presence_any_ || this->area_presence_any_; if (this->presence_binary_sensor_ != nullptr) { - this->presence_binary_sensor_->publish_state(this->target_presence_any_); + this->presence_binary_sensor_->publish_state(presence); } #endif this->update_work_mode_fallback_(); @@ -1284,6 +1825,27 @@ void LD6002BComponent::set_switch_state(SwitchType type, bool state) { void LD6002BComponent::press_button(ButtonType type) { switch (type) { + case ButtonType::APPLY_AREA: + this->apply_area_config_(); + break; + case ButtonType::AUTO_INTERFERENCE: + this->send_control_command_(CMD_AUTO_INTERFERENCE); + // The module recomputes the interference areas without reporting them. + this->send_control_command_(CMD_GET_AREAS); + break; + case ButtonType::GET_AREAS: + this->send_control_command_(CMD_GET_AREAS); + break; + case ButtonType::CLEAR_INTERFERENCE: + this->send_control_command_(CMD_CLEAR_INTERFERENCE); + // The module rewrites the areas but does not report them, so ask for the new geometry the + // way the apply_area ack path does; the queue keeps it behind the command above. + this->send_control_command_(CMD_GET_AREAS); + break; + case ButtonType::RESET_DETECTION_AREA: + this->send_control_command_(CMD_RESET_DETECTION_AREA); + this->send_control_command_(CMD_GET_AREAS); + break; case ButtonType::GET_DELAY: this->send_control_command_(CMD_GET_DELAY); break; diff --git a/esphome/components/ld6002b/ld6002b.h b/esphome/components/ld6002b/ld6002b.h index 141f4ff027..bea3804312 100644 --- a/esphome/components/ld6002b/ld6002b.h +++ b/esphome/components/ld6002b/ld6002b.h @@ -31,6 +31,10 @@ namespace esphome::ld6002b { static constexpr uint8_t MAX_TARGETS = 3; +static constexpr uint8_t AREA_COUNT = 4; +// Interference areas own ids 0..AREA_COUNT-1 and detection areas the next four, so +// this is the whole id space TYPE_SET_AREA accepts. +static constexpr uint8_t AREA_ID_COUNT = AREA_COUNT * 2; static constexpr size_t DEFAULT_MAX_DATA_LEN = 1024; static constexpr size_t DEFAULT_MAX_DATA_LEN_POINT_CLOUD = 4096; // Largest protocol payload is TYPE_SET_AREA: int32 area id + 6 floats = 28 bytes. @@ -41,12 +45,19 @@ enum class NumberType : uint8_t { Z_MIN, Z_MAX, LOW_POWER_SLEEP, + AREA_X_MIN, + AREA_X_MAX, + AREA_Y_MIN, + AREA_Y_MAX, + AREA_Z_MIN, + AREA_Z_MAX, }; enum class SelectType : uint8_t { SENSITIVITY, TRIGGER_SPEED, INSTALLATION_MODE, + AREA_ID, }; enum class SwitchType : uint8_t { @@ -56,6 +67,11 @@ enum class SwitchType : uint8_t { }; enum class ButtonType : uint8_t { + APPLY_AREA, + AUTO_INTERFERENCE, + GET_AREAS, + CLEAR_INTERFERENCE, + RESET_DETECTION_AREA, GET_DELAY, GET_SENSITIVITY, GET_TRIGGER_SPEED, @@ -76,8 +92,25 @@ struct TargetSensors { sensor::Sensor *cluster_id{nullptr}; }; +struct AreaSensors { + sensor::Sensor *x_min{nullptr}; + sensor::Sensor *x_max{nullptr}; + sensor::Sensor *y_min{nullptr}; + sensor::Sensor *y_max{nullptr}; + sensor::Sensor *z_min{nullptr}; + sensor::Sensor *z_max{nullptr}; +}; #endif +struct AreaConfig { + float x_min{NAN}; + float x_max{NAN}; + float y_min{NAN}; + float y_max{NAN}; + float z_min{NAN}; + float z_max{NAN}; +}; + struct VersionPref { char value[20]; }; @@ -122,6 +155,67 @@ class LD6002BComponent : public Component, public uart::UARTDevice { return; this->targets_[target].cluster_id = sensor; } + void set_interference_area_x_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].x_min = sensor; + } + void set_interference_area_x_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].x_max = sensor; + } + void set_interference_area_y_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].y_min = sensor; + } + void set_interference_area_y_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].y_max = sensor; + } + void set_interference_area_z_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].z_min = sensor; + } + void set_interference_area_z_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].z_max = sensor; + } + + void set_detection_area_x_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].x_min = sensor; + } + void set_detection_area_x_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].x_max = sensor; + } + void set_detection_area_y_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].y_min = sensor; + } + void set_detection_area_y_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].y_max = sensor; + } + void set_detection_area_z_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].z_min = sensor; + } + void set_detection_area_z_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].z_max = sensor; + } #endif #ifdef USE_BINARY_SENSOR @@ -131,6 +225,11 @@ class LD6002BComponent : public Component, public uart::UARTDevice { return; this->target_presence_[target] = sensor; } + void set_area_presence_binary_sensor(uint8_t area, binary_sensor::BinarySensor *sensor) { + if (area >= AREA_COUNT) + return; + this->area_presence_[area] = sensor; + } #endif #ifdef USE_TEXT_SENSOR @@ -143,12 +242,20 @@ class LD6002BComponent : public Component, public uart::UARTDevice { void set_z_min_number(number::Number *number) { this->z_min_number_ = number; } void set_z_max_number(number::Number *number) { this->z_max_number_ = number; } void set_low_power_sleep_number(number::Number *number) { this->low_power_sleep_number_ = number; } + + void set_area_x_min_number(number::Number *number) { this->area_x_min_number_ = number; } + void set_area_x_max_number(number::Number *number) { this->area_x_max_number_ = number; } + void set_area_y_min_number(number::Number *number) { this->area_y_min_number_ = number; } + void set_area_y_max_number(number::Number *number) { this->area_y_max_number_ = number; } + void set_area_z_min_number(number::Number *number) { this->area_z_min_number_ = number; } + void set_area_z_max_number(number::Number *number) { this->area_z_max_number_ = number; } #endif #ifdef USE_SELECT void set_sensitivity_select(select::Select *select) { this->sensitivity_select_ = select; } void set_trigger_speed_select(select::Select *select) { this->trigger_speed_select_ = select; } void set_installation_select(select::Select *select) { this->installation_select_ = select; } + void set_area_id_select(select::Select *select) { this->area_id_select_ = select; } #endif #ifdef USE_SWITCH @@ -176,6 +283,8 @@ class LD6002BComponent : public Component, public uart::UARTDevice { void handle_frame_(uint16_t type, const uint8_t *data, uint16_t len); void handle_target_report_(const uint8_t *data, uint16_t len); void handle_point_cloud_(const uint8_t *data, uint16_t len); + void handle_area_presence_(const uint8_t *data, uint16_t len); + void handle_area_report_(bool interference, const uint8_t *data, uint16_t len); void handle_delay_report_(const uint8_t *data, uint16_t len); void handle_sensitivity_report_(const uint8_t *data, uint16_t len); void handle_trigger_speed_report_(const uint8_t *data, uint16_t len); @@ -189,22 +298,35 @@ class LD6002BComponent : public Component, public uart::UARTDevice { void publish_work_mode_(bool low_power); // Drops every target-derived reading and the slot table they are indexed by. void clear_target_state_(); + void clear_area_presence_(); + void restore_deferred_edits_(); + void publish_area_numbers_(); #ifdef USE_SENSOR void clear_target_slot_(uint8_t index); #endif #ifdef USE_NUMBER void publish_number_clamped_(number::Number *number, float value); #endif + void update_area_numbers_(const AreaConfig &area); + void update_area_numbers_for_id_(uint8_t area_id); + bool queue_area_config_(uint8_t area_id, const AreaConfig &desired); + void try_apply_pending_area_(bool reported_interference); + void init_area_id_pref_(); + void save_area_id_pref_(uint8_t value); void init_version_pref_(); void save_version_pref_(const char *value); - void queue_command_(uint16_t type, const uint8_t *data, uint8_t len); + // Returns whether the command was queued: it is dropped, with a log line, when + // the payload is too long or the ring is full. + bool queue_command_(uint16_t type, const uint8_t *data, uint8_t len); void process_command_queue_(); void send_command_(uint16_t type, const uint8_t *data, uint8_t len); void send_command_internal_(uint16_t type, const uint8_t *data, uint8_t len, bool track); void write_frame_(uint16_t type, const uint8_t *data, uint8_t len, bool track); - void send_control_command_(uint32_t command); + // Returns whether the command reached the queue; see queue_command_. + bool send_control_command_(uint32_t command); void send_z_range_(); + void apply_area_config_(); void wake_(); static uint16_t read_u16_be(const uint8_t *data); @@ -212,16 +334,20 @@ class LD6002BComponent : public Component, public uart::UARTDevice { static int32_t read_int32_le(const uint8_t *data); static float read_f32_le(const uint8_t *data); static void write_u32_le(uint8_t *data, uint32_t value); + static void write_int32_le(uint8_t *data, int32_t value); static void write_f32_le(uint8_t *data, float value); #ifdef USE_SENSOR std::array targets_{}; sensor::Sensor *target_count_sensor_{nullptr}; sensor::Sensor *point_count_sensor_{nullptr}; + std::array interference_areas_{}; + std::array detection_areas_{}; #endif #ifdef USE_BINARY_SENSOR binary_sensor::BinarySensor *presence_binary_sensor_{nullptr}; std::array target_presence_{}; + std::array area_presence_{}; #endif #ifdef USE_TEXT_SENSOR text_sensor::TextSensor *work_mode_text_sensor_{nullptr}; @@ -234,11 +360,21 @@ class LD6002BComponent : public Component, public uart::UARTDevice { number::Number *z_min_number_{nullptr}; number::Number *z_max_number_{nullptr}; number::Number *low_power_sleep_number_{nullptr}; + + number::Number *area_x_min_number_{nullptr}; + number::Number *area_x_max_number_{nullptr}; + number::Number *area_y_min_number_{nullptr}; + number::Number *area_y_max_number_{nullptr}; + number::Number *area_z_min_number_{nullptr}; + number::Number *area_z_max_number_{nullptr}; #endif #ifdef USE_SELECT select::Select *sensitivity_select_{nullptr}; select::Select *trigger_speed_select_{nullptr}; select::Select *installation_select_{nullptr}; + select::Select *area_id_select_{nullptr}; + ESPPreferenceObject area_id_pref_{}; + bool area_id_pref_initialized_{false}; #endif #ifdef USE_SWITCH switch_::Switch *low_power_switch_{nullptr}; @@ -264,9 +400,13 @@ class LD6002BComponent : public Component, public uart::UARTDevice { uint8_t *data_buf_{nullptr}; uint16_t next_frame_id_{0}; - // Sized for the boot burst: with every platform configured, setup() enqueues - // roughly ten GET/config commands back to back before the first ack lands. - static constexpr uint8_t CMD_QUEUE_SIZE = 16; + // Sized for the two bursts that reach it, both counted as what is still queued + // once the first command is dequeued: boot leaves 11 with every platform + // configured, and pressing all fourteen buttons before an ack lands leaves 15. + // Neither overflowed 16, but one free slot is not headroom, and overflowing is a + // dropped command with only a log line to show for it. Costs 256 bytes more per + // configured instance, and this component is MULTI_CONF. + static constexpr uint8_t CMD_QUEUE_SIZE = 24; static constexpr uint32_t CMD_ACK_TIMEOUT_MS = 300; // A sleeping module consumes the first frame to wake and answers only the one after it. static constexpr uint32_t CMD_FIRST_ACK_TIMEOUT_MS = 600; @@ -276,6 +416,9 @@ class LD6002BComponent : public Component, public uart::UARTDevice { // Named so a repeated press replaces its own pending timeout instead of stacking // another, and so the command path can cancel it when it takes the pin over. static constexpr const char *WAKE_BUTTON_TIMEOUT = "wake_button"; + // Named so a burst of writes collapses to one read once they settle, rather than + // one read per write. + static constexpr const char *AREA_REFRESH_TIMEOUT = "area_refresh"; // A reply cannot trail the frame that earned it for longer than this; the field worst case is ~726ms. static constexpr uint32_t STALE_ACK_MAX_AGE_MS = 1000; @@ -307,6 +450,24 @@ class LD6002BComponent : public Component, public uart::UARTDevice { float z_min_{NAN}; float z_max_{NAN}; + float area_x_min_{NAN}; + float area_x_max_{NAN}; + float area_y_min_{NAN}; + float area_y_max_{NAN}; + float area_z_min_{NAN}; + float area_z_max_{NAN}; + // What the user has typed and not yet applied; NaN per axis means "nothing of + // mine here, take the module's value". Same sentinel shape as + // pending_area_updates_. Exactly two things empty it: the area_id select moving + // to another area, and an apply that was accepted. A write the bounds guard + // refused leaves it alone, and a deferred apply that had to be dropped hands its + // staged values back here -- but only while the user is still on the area they + // were staged for. Either way the values stay the user's to fix. + AreaConfig area_edits_{}; + std::array interference_area_values_{}; + std::array detection_area_values_{}; + uint8_t area_id_{0xFF}; + bool area_id_set_{false}; // Which person owns each target_N slot, so a slot survives the module re-sorting its array. std::array slot_cluster_{}; @@ -318,9 +479,14 @@ class LD6002BComponent : public Component, public uart::UARTDevice { // The report handlers read these and drop anything a stopped stream still emits. bool target_display_enabled_{false}; bool point_cloud_enabled_{false}; + bool area_presence_any_{false}; + bool area_write_in_flight_{false}; bool work_mode_reported_{false}; bool low_power_enabled_{false}; bool low_power_reported_{false}; + bool deferred_apply_pending_{false}; + uint8_t pending_area_id_{0xFF}; + AreaConfig pending_area_updates_{}; bool last_work_mode_valid_{false}; bool last_work_mode_low_power_{false}; diff --git a/esphome/components/ld6002b/number/__init__.py b/esphome/components/ld6002b/number/__init__.py index 10e9e89dc8..7e0be66c64 100644 --- a/esphome/components/ld6002b/number/__init__.py +++ b/esphome/components/ld6002b/number/__init__.py @@ -2,6 +2,8 @@ import esphome.codegen as cg from esphome.components import number import esphome.config_validation as cv from esphome.const import ( + CONF_AREA_ID, + CONF_BUTTON, DEVICE_CLASS_DISTANCE, DEVICE_CLASS_DURATION, ENTITY_CATEGORY_CONFIG, @@ -9,14 +11,22 @@ from esphome.const import ( UNIT_MILLISECOND, UNIT_SECOND, ) +import esphome.final_validate as fv +from esphome.types import ConfigType from .. import LD6002BComponent, ld6002b_ns from ..const import ( + CONF_APPLY_AREA, + CONF_AREA_CONFIG, CONF_HOLD_DELAY, CONF_LD6002B_ID, CONF_LOW_POWER_SLEEP_TIME, CONF_Z_MAX, CONF_Z_MIN, + KEY_X_MAX, + KEY_X_MIN, + KEY_Y_MAX, + KEY_Y_MIN, ) DEPENDENCIES = ["ld6002b"] @@ -51,10 +61,83 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_DURATION, entity_category=ENTITY_CATEGORY_CONFIG, ), + cv.Optional(CONF_AREA_CONFIG): cv.Schema( + { + cv.Optional(KEY_X_MIN): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(KEY_X_MAX): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(KEY_Y_MIN): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(KEY_Y_MAX): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_Z_MIN): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_Z_MAX): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + } + ), } ) +def final_validate(config: ConfigType) -> ConfigType: + if config.get(CONF_AREA_CONFIG) is None: + return config + + full_config = fv.full_config.get() + hub_id = config[CONF_LD6002B_ID] + + has_apply_area = any( + entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_APPLY_AREA) is not None + for entry in full_config.get(CONF_BUTTON, []) + ) + if not has_apply_area: + raise cv.Invalid( + f"{CONF_AREA_CONFIG} requires button.apply_area for the same ld6002b instance", + path=[CONF_AREA_CONFIG], + ) + + has_area_id_select = any( + entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_AREA_ID) is not None + for entry in full_config.get("select", []) + ) + if not has_area_id_select: + raise cv.Invalid( + f"{CONF_AREA_CONFIG} requires select.area_id for the same ld6002b instance", + path=[CONF_AREA_CONFIG], + ) + + return config + + +FINAL_VALIDATE_SCHEMA = final_validate + + async def to_code(config): hub = await cg.get_variable(config[CONF_LD6002B_ID]) @@ -80,3 +163,19 @@ async def to_code(config): ) await cg.register_parented(n, config[CONF_LD6002B_ID]) cg.add(getattr(hub, setter)(n)) + + if area_config := config.get(CONF_AREA_CONFIG): + for key, number_type, setter in ( + (KEY_X_MIN, NumberType.AREA_X_MIN, "set_area_x_min_number"), + (KEY_X_MAX, NumberType.AREA_X_MAX, "set_area_x_max_number"), + (KEY_Y_MIN, NumberType.AREA_Y_MIN, "set_area_y_min_number"), + (KEY_Y_MAX, NumberType.AREA_Y_MAX, "set_area_y_max_number"), + (CONF_Z_MIN, NumberType.AREA_Z_MIN, "set_area_z_min_number"), + (CONF_Z_MAX, NumberType.AREA_Z_MAX, "set_area_z_max_number"), + ): + if conf := area_config.get(key): + n = await number.new_number( + conf, number_type, min_value=-10, max_value=10, step=0.1 + ) + await cg.register_parented(n, config[CONF_LD6002B_ID]) + cg.add(getattr(hub, setter)(n)) diff --git a/esphome/components/ld6002b/select/__init__.py b/esphome/components/ld6002b/select/__init__.py index 3fcc117e2f..3da647ee2c 100644 --- a/esphome/components/ld6002b/select/__init__.py +++ b/esphome/components/ld6002b/select/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import select import esphome.config_validation as cv -from esphome.const import CONF_SENSITIVITY, ENTITY_CATEGORY_CONFIG +from esphome.const import CONF_AREA_ID, CONF_SENSITIVITY, ENTITY_CATEGORY_CONFIG from .. import LD6002BComponent, ld6002b_ns from ..const import CONF_INSTALLATION_MODE, CONF_LD6002B_ID, CONF_TRIGGER_SPEED @@ -11,6 +11,16 @@ DEPENDENCIES = ["ld6002b"] LD6002BSelect = ld6002b_ns.class_("LD6002BSelect", select.Select) SelectType = ld6002b_ns.enum("SelectType", is_class=True) +AREA_ID_OPTIONS = [ + "interference_area_0", + "interference_area_1", + "interference_area_2", + "interference_area_3", + "detection_area_0", + "detection_area_1", + "detection_area_2", + "detection_area_3", +] CONFIG_SCHEMA = cv.Schema( { @@ -24,6 +34,9 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(CONF_INSTALLATION_MODE): select.select_schema( LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG ), + cv.Optional(CONF_AREA_ID): select.select_schema( + LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG + ), } ) @@ -47,6 +60,7 @@ SELECT_MAP = ( "set_installation_select", ["top", "side"], ), + (CONF_AREA_ID, SelectType.AREA_ID, "set_area_id_select", AREA_ID_OPTIONS), ) diff --git a/esphome/components/ld6002b/sensor.py b/esphome/components/ld6002b/sensor.py index ff88d343b9..3aedaf9fdd 100644 --- a/esphome/components/ld6002b/sensor.py +++ b/esphome/components/ld6002b/sensor.py @@ -12,11 +12,18 @@ from esphome.const import ( from . import LD6002BComponent from .const import ( + AREA_COUNT, CONF_CLUSTER_ID, CONF_DOPPLER_INDEX, CONF_LD6002B_ID, CONF_POINT_COUNT, CONF_Z, + CONF_Z_MAX, + CONF_Z_MIN, + KEY_X_MAX, + KEY_X_MIN, + KEY_Y_MAX, + KEY_Y_MIN, MAX_TARGETS, ) @@ -68,20 +75,79 @@ TARGET_SCHEMA = cv.Schema( } ) - -CONFIG_SCHEMA = cv.Schema( +AREA_SCHEMA = cv.Schema( { - cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), - cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema( - accuracy_decimals=0, + cv.Optional(KEY_X_MIN): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, state_class=STATE_CLASS_MEASUREMENT, ), - cv.Optional(CONF_POINT_COUNT): sensor.sensor_schema( - accuracy_decimals=0, + cv.Optional(KEY_X_MAX): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(KEY_Y_MIN): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(KEY_Y_MAX): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_Z_MIN): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_Z_MAX): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, state_class=STATE_CLASS_MEASUREMENT, ), } -).extend({cv.Optional(f"target_{i + 1}"): TARGET_SCHEMA for i in range(MAX_TARGETS)}) +) + +# (config key, C++ setter axis) for the six bounds every area sensor block carries. +_AREA_AXES = ( + (KEY_X_MIN, "x_min"), + (KEY_X_MAX, "x_max"), + (KEY_Y_MIN, "y_min"), + (KEY_Y_MAX, "y_max"), + (CONF_Z_MIN, "z_min"), + (CONF_Z_MAX, "z_max"), +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_POINT_COUNT): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + ) + .extend({cv.Optional(f"target_{i + 1}"): TARGET_SCHEMA for i in range(MAX_TARGETS)}) + .extend( + {cv.Optional(f"interference_area_{i}"): AREA_SCHEMA for i in range(AREA_COUNT)} + ) + .extend( + {cv.Optional(f"detection_area_{i}"): AREA_SCHEMA for i in range(AREA_COUNT)} + ) +) async def to_code(config): @@ -112,3 +178,11 @@ async def to_code(config): if cluster_id_config := target_config.get(CONF_CLUSTER_ID): sens = await sensor.new_sensor(cluster_id_config) cg.add(hub.set_target_cluster_id_sensor(i, sens)) + + for kind in ("interference", "detection"): + for i in range(AREA_COUNT): + if area_config := config.get(f"{kind}_area_{i}"): + for key, axis in _AREA_AXES: + if axis_config := area_config.get(key): + sens = await sensor.new_sensor(axis_config) + cg.add(getattr(hub, f"set_{kind}_area_{axis}_sensor")(i, sens)) diff --git a/tests/component_tests/ld6002b/test_final_validate.py b/tests/component_tests/ld6002b/test_final_validate.py index 49fa35eb13..0bb091533b 100644 --- a/tests/component_tests/ld6002b/test_final_validate.py +++ b/tests/component_tests/ld6002b/test_final_validate.py @@ -1,30 +1,62 @@ -"""Tests for the wake button's wakeup_pin requirement in ld6002b.""" +"""Tests for the ld6002b validators that reach across platforms. + +wake needs a pin on its own hub, apply_area needs a select on its own hub, and +area_config needs both a button and a select on its own hub. Every one of them +is a same-instance check, which is the half that breaks quietly. +""" from __future__ import annotations import pytest -from esphome.components.ld6002b.button import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA +from esphome.components.ld6002b.button import ( + CONFIG_SCHEMA as BUTTON_CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA as BUTTON_FINAL_VALIDATE_SCHEMA, +) +from esphome.components.ld6002b.const import CONF_AREA_CONFIG, CONF_Z_MIN +from esphome.components.ld6002b.number import ( + CONFIG_SCHEMA as NUMBER_CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA as NUMBER_FINAL_VALIDATE_SCHEMA, +) from esphome.config import Config import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_WAKEUP_PIN, PlatformFramework +from esphome.const import ( + CONF_AREA_ID, + CONF_BUTTON, + CONF_ID, + CONF_WAKEUP_PIN, + PlatformFramework, +) from esphome.core import ID from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable HUB_ID = "ld6002b_hub" +OTHER_HUB_ID = "ld6002b_other" -def _full_config(hub: ConfigType) -> Config: +def _full_config( + hub: ConfigType, + *, + selects: list[ConfigType] | None = None, + buttons: list[ConfigType] | None = None, +) -> Config: """A full config carrying one ld6002b hub, as the ID pass leaves it. final_validate resolves the hub through get_path_for_id, so the declaring path has to be registered the way validate_config registers it: the path of the id value itself, whose parent is the hub's own config. + + The platform lists are what the cross-platform validators scan, so a test can + say which of them exist and which hub each one names. """ full = Config() full["ld6002b"] = [hub] full.declare_ids.append((hub[CONF_ID], ["ld6002b", 0, CONF_ID])) + if selects is not None: + full["select"] = selects + if buttons is not None: + full[CONF_BUTTON] = buttons return full @@ -44,10 +76,33 @@ def _buttons(**buttons: str) -> ConfigType: return config +def _select(*, hub_id: str = HUB_ID) -> ConfigType: + """A select platform config naming area_id on the given hub.""" + return { + "ld6002b_id": ID(hub_id, is_declaration=False, type="ld6002b"), + CONF_AREA_ID: {"name": "Area ID"}, + } + + +def _area_numbers(*, hub_id: str = HUB_ID) -> ConfigType: + """A number platform config carrying one area_config bound.""" + return { + "ld6002b_id": ID(hub_id, is_declaration=False, type="ld6002b"), + CONF_AREA_CONFIG: {CONF_Z_MIN: {"name": "Area Z Min"}}, + } + + def _validated(config: ConfigType) -> ConfigType: """Run the button schema, then the final validation the hub is checked in.""" - config = CONFIG_SCHEMA(config) - FINAL_VALIDATE_SCHEMA(config) + config = BUTTON_CONFIG_SCHEMA(config) + BUTTON_FINAL_VALIDATE_SCHEMA(config) + return config + + +def _validated_numbers(config: ConfigType) -> ConfigType: + """The same two passes for the number platform.""" + config = NUMBER_CONFIG_SCHEMA(config) + NUMBER_FINAL_VALIDATE_SCHEMA(config) return config @@ -80,3 +135,82 @@ def test_other_buttons_do_not_need_the_pin( ) _validated(_buttons(get_delay="Get Delay")) + + +def test_apply_area_without_select_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """apply_area sends the staged bounds to whichever area the select names.""" + set_core_config( + PlatformFramework.ESP32_IDF, full_config=_full_config(_hub(wakeup_pin=False)) + ) + + with pytest.raises( + cv.Invalid, + match=( + r"^apply_area requires select\.area_id for the same ld6002b instance" + r" @ data\['apply_area'\]$" + ), + ): + _validated(_buttons(apply_area="Apply Area")) + + +def test_apply_area_select_on_another_hub_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """A select exists, but on a second ld6002b -- which cannot serve this one.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config=_full_config( + _hub(wakeup_pin=False), selects=[_select(hub_id=OTHER_HUB_ID)] + ), + ) + + with pytest.raises( + cv.Invalid, + match=( + r"^apply_area requires select\.area_id for the same ld6002b instance" + r" @ data\['apply_area'\]$" + ), + ): + _validated(_buttons(apply_area="Apply Area")) + + +def test_area_config_without_apply_area_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """The six numbers only stage a write; apply_area is what sends it.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config=_full_config(_hub(wakeup_pin=False), selects=[_select()]), + ) + + with pytest.raises( + cv.Invalid, + match=( + r"^area_config requires button\.apply_area for the same ld6002b instance" + r" @ data\['area_config'\]$" + ), + ): + _validated_numbers(_area_numbers()) + + +def test_area_config_without_select_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """The validator's other half: the staged bounds also need an area to land in.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config=_full_config( + _hub(wakeup_pin=False), buttons=[_buttons(apply_area="Apply Area")] + ), + ) + + with pytest.raises( + cv.Invalid, + match=( + r"^area_config requires select\.area_id for the same ld6002b instance" + r" @ data\['area_config'\]$" + ), + ): + _validated_numbers(_area_numbers()) diff --git a/tests/components/ld6002b/common.yaml b/tests/components/ld6002b/common.yaml index e31af49aec..ee881bd787 100644 --- a/tests/components/ld6002b/common.yaml +++ b/tests/components/ld6002b/common.yaml @@ -42,6 +42,32 @@ sensor: name: Target-3 Dop cluster_id: name: Target-3 Cluster + interference_area_0: + x_min: + name: Interference-0 X Min + x_max: + name: Interference-0 X Max + y_min: + name: Interference-0 Y Min + y_max: + name: Interference-0 Y Max + z_min: + name: Interference-0 Z Min + z_max: + name: Interference-0 Z Max + detection_area_0: + x_min: + name: Detection-0 X Min + x_max: + name: Detection-0 X Max + y_min: + name: Detection-0 Y Min + y_max: + name: Detection-0 Y Max + z_min: + name: Detection-0 Z Min + z_max: + name: Detection-0 Z Max binary_sensor: - platform: ld6002b @@ -50,6 +76,8 @@ binary_sensor: name: Presence target_1: name: Target-1 Presence + detection_area_0: + name: Detection Area-0 Presence text_sensor: - platform: ld6002b @@ -70,6 +98,19 @@ number: name: Z Max low_power_sleep_time: name: Low Power Sleep + area_config: + x_min: + name: Area X Min + x_max: + name: Area X Max + y_min: + name: Area Y Min + y_max: + name: Area Y Max + z_min: + name: Area Z Min + z_max: + name: Area Z Max select: - platform: ld6002b @@ -80,6 +121,8 @@ select: name: Trigger Speed installation_mode: name: Installation + area_id: + name: Area ID switch: - platform: ld6002b @@ -94,6 +137,16 @@ switch: button: - platform: ld6002b ld6002b_id: ld6002b_radar + apply_area: + name: Apply Area + auto_interference: + name: Auto Interference + get_areas: + name: Get Areas + clear_interference: + name: Clear Interference + reset_detection_area: + name: Reset Detection get_delay: name: Get Delay get_sensitivity: From 3656375516e08022f19e1e7b2467274da113819a Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 10 Aug 2026 10:00:29 -0400 Subject: [PATCH 1346/1815] [sendspin] Bump sendspin-cpp to v0.7.1 (#18232) --- esphome/components/sendspin/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index e20925f323..d0c2112ba9 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -198,7 +198,7 @@ async def to_code(config: ConfigType) -> None: psram.request_external_task_stack() # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.0") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.1") cg.add_define("USE_SENDSPIN", True) # for MDNS diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index b4b20cf221..9448b93cc9 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -98,7 +98,7 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.7.0 + version: 0.7.1 lvgl/lvgl: version: 9.5.0 fastled/FastLED: From 02fa18b74fb0a319f3858ed10dd145e270c02c25 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 09:44:01 -0500 Subject: [PATCH 1347/1815] [core] Skip colorama init for terminal and dashboard runs (#18224) --- esphome/__main__.py | 20 +- esphome/log.py | 23 +- tests/unit_tests/conftest.py | 14 + .../fixtures/log/setup_log_probe.py | 21 ++ tests/unit_tests/test_lazy_imports.py | 24 +- tests/unit_tests/test_log.py | 267 +++++++++++++++++- 6 files changed, 347 insertions(+), 22 deletions(-) create mode 100644 tests/unit_tests/fixtures/log/setup_log_probe.py diff --git a/esphome/__main__.py b/esphome/__main__.py index cb45dd7c5f..cc1e12cb3a 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1941,7 +1941,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: new_name = args.name for c in new_name: if c not in ALLOWED_NAME_CHARS: - print( + safe_print( color( AnsiFore.BOLD_RED, f"'{c}' is an invalid character for names. Valid characters are: " @@ -1954,7 +1954,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: yaml = yaml_util.load_yaml(CORE.config_path) if CONF_ESPHOME not in yaml or CONF_NAME not in yaml[CONF_ESPHOME]: - print( + safe_print( color( AnsiFore.BOLD_RED, "Complex YAML files cannot be automatically renamed." ) @@ -2001,7 +2001,9 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: ) > 1 ): - print(color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename")) + safe_print( + color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename") + ) return 1 new_raw = re.sub( @@ -2019,7 +2021,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: # ``kitchen``; running ``esphome rename weird-file.yaml kitchen`` # would otherwise just re-flash the same hostname). if new_name == old_name: - print( + safe_print( color( AnsiFore.BOLD_RED, f"'{new_name}' is already the device's name.", @@ -2029,7 +2031,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: new_path: Path = CORE.config_dir / (new_name + ".yaml") if new_path.resolve() == CORE.config_path.resolve(): - print( + safe_print( color( AnsiFore.BOLD_RED, f"'{new_name}' is already the device's name.", @@ -2037,7 +2039,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: ) return 1 if new_path.exists(): - print( + safe_print( color( AnsiFore.BOLD_RED, f"Cannot rename: {new_path} already exists. " @@ -2045,7 +2047,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: ) ) return 1 - print( + safe_print( f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}" ) print() @@ -2054,7 +2056,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: rc = run_external_process(*ESPHOME_COMMAND, "config", str(new_path)) if rc != 0: - print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes.")) + safe_print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes.")) new_path.unlink() return 1 @@ -2080,7 +2082,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: if CORE.config_path != new_path: CORE.config_path.unlink() - print(color(AnsiFore.BOLD_GREEN, "SUCCESS")) + safe_print(color(AnsiFore.BOLD_GREEN, "SUCCESS")) print() return 0 diff --git a/esphome/log.py b/esphome/log.py index b120c930d0..1f208bb909 100644 --- a/esphome/log.py +++ b/esphome/log.py @@ -1,5 +1,7 @@ from enum import Enum import logging +import sys +from typing import TextIO from esphome.core import CORE @@ -72,13 +74,30 @@ class ESPHomeLogFormatter(logging.Formatter): return message +def _is_tty(stream: TextIO | None) -> bool: + # A stream can be missing, closed, or not a real file object; colorama + # tolerates all three, so treat them like a redirect and let its own + # handling apply. + if stream is None or getattr(stream, "closed", True): + return False + return hasattr(stream, "isatty") and stream.isatty() + + def setup_log( log_level: int = logging.INFO, include_timestamp: bool = False, ) -> None: - import colorama + # colorama translates ANSI escapes for old Windows consoles and strips + # them from redirected output. POSIX terminals render ANSI natively, and + # dashboard runs escape their color codes before printing, so both would + # use colorama as a plain passthrough; skip the import there (it pulls + # in ctypes, ~3ms on every CLI invocation). + if sys.platform == "win32" or not ( + CORE.dashboard or (_is_tty(sys.stdout) and _is_tty(sys.stderr)) + ): + import colorama - colorama.init() + colorama.init() # Setup logging - will map log level from string to constant logging.basicConfig(level=log_level) diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 13450b10f0..9de8f715ef 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -10,6 +10,7 @@ not be part of a unit test suite. """ from collections.abc import Generator +import os from pathlib import Path import sys from unittest.mock import Mock, patch @@ -40,6 +41,19 @@ def fixture_path() -> Path: return here / "fixtures" +@pytest.fixture +def probe_env() -> dict[str, str]: + """Environment for running fixture probe scripts as subprocesses. + + Running a script file drops the cwd from sys.path, so prepend the + repo root for the child. + """ + python_path = str(package_root) + if ambient := os.environ.get("PYTHONPATH"): + python_path = os.pathsep.join((python_path, ambient)) + return os.environ | {"PYTHONPATH": python_path} + + @pytest.fixture def setup_core(tmp_path: Path) -> Path: """Set up CORE with test paths.""" diff --git a/tests/unit_tests/fixtures/log/setup_log_probe.py b/tests/unit_tests/fixtures/log/setup_log_probe.py new file mode 100644 index 0000000000..b9e2e02a8c --- /dev/null +++ b/tests/unit_tests/fixtures/log/setup_log_probe.py @@ -0,0 +1,21 @@ +"""Report whether setup_log() pulled in colorama, then print a colored line. + +Executed as a subprocess by test_log.py because module imports are +process-global: the parent prints ``colorama_loaded=True/False`` plus an +ANSI colored line so the caller can observe whether the codes survive to +the stream. Pass ``--dashboard`` to simulate a dashboard-spawned run. +""" + +import sys + +from esphome.core import CORE +from esphome.log import setup_log + +if "--dashboard" in sys.argv: + CORE.dashboard = True + +setup_log() + +print(f"colorama_loaded={'colorama' in sys.modules}") +print("\033[31mred\033[0m end") +sys.stdout.flush() diff --git a/tests/unit_tests/test_lazy_imports.py b/tests/unit_tests/test_lazy_imports.py index 2e09c4a945..b6878c33a2 100644 --- a/tests/unit_tests/test_lazy_imports.py +++ b/tests/unit_tests/test_lazy_imports.py @@ -15,7 +15,6 @@ test pins down *which* heavy modules must stay out entirely. from __future__ import annotations import importlib.util -import os from pathlib import Path import subprocess import sys @@ -120,18 +119,17 @@ def test_watched_heavy_modules_exist() -> None: def _leaked_from_fixture( - fixture_path: Path, script_name: str, extra: tuple[str, ...] = () + fixture_path: Path, + env: dict[str, str], + script_name: str, + extra: tuple[str, ...] = (), ) -> str: """Run a fixture script with the watched modules on argv. - Running a script file drops the cwd from sys.path, so prepend the - repo root for the child; a non-zero exit surfaces the child's stderr. + ``env`` comes from the ``probe_env`` fixture so the child can import + the repo checkout; a non-zero exit surfaces the child's stderr. """ script = fixture_path / "lazy_imports" / script_name - python_path = str(Path(__file__).parents[2]) - if ambient := os.environ.get("PYTHONPATH"): - python_path = os.pathsep.join((python_path, ambient)) - env = os.environ | {"PYTHONPATH": python_path} result = subprocess.run( [sys.executable, str(script), *FAST_PATH_HEAVY_MODULES, *extra], capture_output=True, @@ -145,12 +143,13 @@ def _leaked_from_fixture( def test_storage_json_fast_path_does_not_import_heavy_modules( fixture_path: Path, + probe_env: dict[str, str], ) -> None: """``apply_to_core`` runs on the upload/logs fast path for every platform; parsing the stored framework version must not drag in the validation stack or the esp32 component package. """ - leaked = _leaked_from_fixture(fixture_path, "storage_json_fast_path.py") + leaked = _leaked_from_fixture(fixture_path, probe_env, "storage_json_fast_path.py") assert not leaked, ( f"storage_json.apply_to_core pulls in heavy modules: {leaked}. " "The upload/logs fast path skips validation; importing the " @@ -160,12 +159,15 @@ def test_storage_json_fast_path_does_not_import_heavy_modules( def test_esptool_upload_fast_path_does_not_import_heavy_modules( fixture_path: Path, + probe_env: dict[str, str], ) -> None: """The esptool serial upload reads the esp32 variant from CORE.data; resolving it must not drag in the esp32 component package or the validation stack. """ - leaked = _leaked_from_fixture(fixture_path, "esptool_upload_fast_path.py") + leaked = _leaked_from_fixture( + fixture_path, probe_env, "esptool_upload_fast_path.py" + ) assert not leaked, ( f"upload_using_esptool pulls in heavy modules: {leaked}. " "The upload fast path skips validation; importing the validation " @@ -266,6 +268,7 @@ def test_yaml_util_does_not_import_heavy_modules() -> None: def test_upload_command_path_does_not_import_heavy_modules( fixture_path: Path, + probe_env: dict[str, str], ) -> None: """The single-config dispatch path checks the bundle suffix on every run; reading it from esphome.const must not drag in esphome.bundle @@ -273,6 +276,7 @@ def test_upload_command_path_does_not_import_heavy_modules( """ leaked = _leaked_from_fixture( fixture_path, + probe_env, "upload_command_fast_path.py", extra=BUNDLE_HEAVY_MODULES + CACHE_HIT_HEAVY_MODULES + STDLIB_FAST_PATH_MODULES, ) diff --git a/tests/unit_tests/test_log.py b/tests/unit_tests/test_log.py index 02798f1029..194b38209b 100644 --- a/tests/unit_tests/test_log.py +++ b/tests/unit_tests/test_log.py @@ -1,6 +1,44 @@ +from collections.abc import Generator +import errno +import io +import logging +import os +from pathlib import Path +import select +import subprocess +import sys +import time + import pytest -from esphome.log import AnsiFore, AnsiStyle, color +from esphome.core import CORE +from esphome.log import AnsiFore, AnsiStyle, color, setup_log + + +class _FakeTty(io.StringIO): + def isatty(self) -> bool: + return True + + +@pytest.fixture +def restore_logging_state() -> Generator[None, None, None]: + """Undo the global logging changes setup_log() makes.""" + root = logging.getLogger() + handlers = root.handlers[:] + formatters = [handler.formatter for handler in handlers] + level = root.level + urllib3_level = logging.getLogger("urllib3").level + yield + root.handlers[:] = handlers + for handler, formatter in zip(handlers, formatters, strict=True): + handler.setFormatter(formatter) + root.setLevel(level) + logging.getLogger("urllib3").setLevel(urllib3_level) + + +def _probe_command(fixture_path: Path, *args: str) -> list[str]: + """Build the command line for the setup_log probe fixture script.""" + return [sys.executable, str(fixture_path / "log" / "setup_log_probe.py"), *args] def test_color_keep_returns_unchanged_message() -> None: @@ -78,3 +116,230 @@ def test_ansi_fore_keep_is_enum_member() -> None: assert bool(AnsiFore.KEEP) is True # But the value itself is still an empty string assert AnsiFore.KEEP.value == "" + + +@pytest.mark.skipif( + sys.platform == "win32", reason="colorama always initializes on Windows" +) +def test_setup_log_redirected_output_strips_ansi( + fixture_path: Path, probe_env: dict[str, str] +) -> None: + """A redirected run must keep colorama so ANSI codes are stripped.""" + result = subprocess.run( + _probe_command(fixture_path), + capture_output=True, + text=True, + timeout=60, + check=False, + env=probe_env, + ) + assert result.returncode == 0, result.stderr + assert "colorama_loaded=True" in result.stdout + assert "red end" in result.stdout + assert "\033" not in result.stdout + + +@pytest.mark.skipif( + sys.platform == "win32", reason="colorama always initializes on Windows" +) +def test_setup_log_dashboard_skips_colorama( + fixture_path: Path, probe_env: dict[str, str] +) -> None: + """Dashboard runs escape their color codes, so colorama must not load.""" + result = subprocess.run( + _probe_command(fixture_path, "--dashboard"), + capture_output=True, + text=True, + timeout=60, + check=False, + env=probe_env, + ) + assert result.returncode == 0, result.stderr + assert "colorama_loaded=False" in result.stdout + # Codes pass through untouched for the dashboard to handle. + assert "\033[31mred\033[0m end" in result.stdout + + +def _run_probe_on_pty( + fixture_path: Path, probe_env: dict[str, str], *, stderr_to_pty: bool +) -> str: + """Run the probe with stdout on a pty and return the decoded pty output. + + With ``stderr_to_pty=False`` stderr goes to a pipe instead, giving the + mixed tty/redirect stream combination while keeping any traceback + available for the exit assertion. + """ + # Unix-only; a module-level import would break test collection on + # Windows, where all the callers are skipped anyway. + import pty + + controller, follower = pty.openpty() + proc = None + output = b"" + deadline = time.monotonic() + 60 + try: + try: + proc = subprocess.Popen( + _probe_command(fixture_path), + stdout=follower, + stderr=follower if stderr_to_pty else subprocess.PIPE, + stdin=follower, + env=probe_env, + ) + finally: + os.close(follower) + while True: + timeout = deadline - time.monotonic() + if timeout <= 0 or not select.select([controller], [], [], timeout)[0]: + pytest.fail(f"pty probe produced no EOF in time; got {output!r}") + try: + chunk = os.read(controller, 1024) + except OSError as err: + # macOS raises EIO once the child closes its end of the pty; + # anything else is a real failure, not end-of-stream. + if err.errno != errno.EIO: + raise + break + if not chunk: + break + output += chunk + stderr_text = "" + if proc.stderr is not None: + stderr_text = proc.stderr.read().decode(errors="replace") + proc.stderr.close() + assert proc.wait(60) == 0, stderr_text + finally: + os.close(controller) + if proc is not None and proc.poll() is None: + proc.kill() + proc.wait() + return output.decode() + + +@pytest.mark.skipif( + sys.platform == "win32", reason="pty is POSIX-only; colorama loads on Windows" +) +def test_setup_log_tty_skips_colorama( + fixture_path: Path, probe_env: dict[str, str] +) -> None: + """A terminal run must skip colorama and keep ANSI codes intact.""" + text = _run_probe_on_pty(fixture_path, probe_env, stderr_to_pty=True) + assert "colorama_loaded=False" in text + assert "\033[31mred\033[0m end" in text + + +@pytest.mark.skipif( + sys.platform == "win32", reason="pty is POSIX-only; colorama loads on Windows" +) +def test_setup_log_mixed_streams_init_colorama( + fixture_path: Path, probe_env: dict[str, str] +) -> None: + """A tty stdout with a redirected stderr must still initialize colorama. + + The guard requires both streams to be a tty; collapsing it to a + single-stream check would stop stripping ANSI from a redirected + stderr while stdout is a terminal. + """ + text = _run_probe_on_pty(fixture_path, probe_env, stderr_to_pty=False) + assert "colorama_loaded=True" in text + # stdout is a tty, so colorama leaves its codes alone. + assert "\033[31mred\033[0m end" in text + + +@pytest.fixture +def colorama_probe( + monkeypatch: pytest.MonkeyPatch, restore_logging_state: None +) -> Generator[None, None, None]: + """Shared preamble for the in-process guard-branch tests. + + Clears colorama from sys.modules so the assertions prove what + setup_log() itself did, and snapshots CORE.verbose/quiet, which is + not a no-op: CORE.reset() does not restore them, so without the + snapshot setup_log()'s log-level side effects would leak into later + tests. + """ + monkeypatch.delitem(sys.modules, "colorama", raising=False) + monkeypatch.setattr(CORE, "verbose", CORE.verbose) + monkeypatch.setattr(CORE, "quiet", CORE.quiet) + yield + # init() rebinds sys.stdout/stderr; restore them before monkeypatch + # puts the originals back. + if (colorama := sys.modules.get("colorama")) is not None: + colorama.deinit() + + +@pytest.mark.skipif( + sys.platform == "win32", reason="colorama always initializes on Windows" +) +def test_setup_log_dashboard_branch_skips_colorama_import( + monkeypatch: pytest.MonkeyPatch, colorama_probe: None +) -> None: + """The dashboard side of the guard must not import colorama.""" + monkeypatch.setattr(CORE, "dashboard", True) + setup_log() + assert "colorama" not in sys.modules + + +@pytest.mark.skipif( + sys.platform == "win32", reason="colorama always initializes on Windows" +) +def test_setup_log_tty_branch_skips_colorama_import( + monkeypatch: pytest.MonkeyPatch, colorama_probe: None +) -> None: + """The tty side of the guard must not import colorama.""" + monkeypatch.setattr(sys, "stdout", _FakeTty()) + monkeypatch.setattr(sys, "stderr", _FakeTty()) + setup_log() + assert "colorama" not in sys.modules + + +@pytest.mark.skipif( + sys.platform == "win32", reason="colorama always initializes on Windows" +) +def test_setup_log_redirected_branch_imports_colorama( + monkeypatch: pytest.MonkeyPatch, colorama_probe: None +) -> None: + """Redirected streams must keep importing and initializing colorama.""" + monkeypatch.setattr(sys, "stdout", io.StringIO()) + monkeypatch.setattr(sys, "stderr", io.StringIO()) + setup_log() + assert "colorama" in sys.modules + + +@pytest.mark.parametrize("broken", ["missing", "closed"]) +def test_setup_log_broken_streams_import_colorama( + broken: str, monkeypatch: pytest.MonkeyPatch, colorama_probe: None +) -> None: + """A missing or closed stream counts as a redirect and must not crash. + + colorama tolerates both, so setup_log() has to reach its init rather + than raise inside the tty probe. + """ + if broken == "missing": + stream = None + else: + stream = io.StringIO() + stream.close() + monkeypatch.setattr(sys, "stdout", stream) + monkeypatch.setattr(sys, "stderr", stream) + setup_log() + assert "colorama" in sys.modules + + +def test_setup_log_win32_always_imports_colorama( + monkeypatch: pytest.MonkeyPatch, colorama_probe: None +) -> None: + """The Windows clause must init colorama even when both streams are ttys. + + Old Windows consoles need colorama to translate ANSI escapes, so the + platform check has to win over the tty check. colorama itself keys + off os.name, so on a POSIX host its init/deinit pair is a + passthrough. + """ + monkeypatch.setattr(sys, "platform", "win32") + # Both streams are ttys: without the platform clause this combination + # would skip colorama. + monkeypatch.setattr(sys, "stdout", _FakeTty()) + monkeypatch.setattr(sys, "stderr", _FakeTty()) + setup_log() + assert "colorama" in sys.modules From d9567b2974f2ab6ff149589bae5289cf3ae376c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 10:05:37 -0500 Subject: [PATCH 1348/1815] Normalize marker-wrapped callable keys in the schema dump (#18218) --- script/build_language_schema.py | 20 +++++++++- tests/script/test_build_language_schema.py | 43 ++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/script/build_language_schema.py b/script/build_language_schema.py index f6dcf00851..2b64cb0256 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -1134,13 +1134,29 @@ def convert_keys(converted, schema, path): else: converted["key"] = "String" key_string_match = re.search( - r"", str(k), re.IGNORECASE + r"", str(k), re.IGNORECASE ) if key_string_match: converted["key_type"] = key_string_match.group(1) else: converted["key_type"] = str(k) + # A marker-wrapped callable key (e.g. script.execute's + # ``cv.Optional(validate_parameter_name)``) is a wildcard matcher; + # ``str(marker)`` is the function repr, whose heap address would + # churn the dump every build. Normalize like the bare-callable + # branch above: record the validator name in ``key_type`` and file + # the config var under ``string``. + key_name = str(k) + if isinstance(k, vol.Marker) and callable(k.schema): + key_string_match = re.search( + r"", key_name, re.IGNORECASE + ) + result["key_type"] = ( + key_string_match.group(1) if key_string_match else key_name + ) + key_name = "string" + # ``cv.OnlyWith`` / ``cv.OnlyWithout`` expose ``default`` as # a property that returns ``vol.UNDEFINED`` when the gating # component isn't loaded — and at schema-generation time @@ -1220,7 +1236,7 @@ def convert_keys(converted, schema, path): for base_k, base_v in get_overridden_config(k, converted).items(): if base_k in result and base_v == result[base_k]: result.pop(base_k) - converted["schema"][S_CONFIG_VARS][str(k)] = result + converted["schema"][S_CONFIG_VARS][key_name] = result if "key" in converted and converted["key"] == "String": config_vars = converted["schema"]["config_vars"] assert len(config_vars) == 1 diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index 8bbaa2773a..f3d4bbcba6 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -3,11 +3,13 @@ from __future__ import annotations import ast +from collections.abc import Callable import importlib.util import json from pathlib import Path import subprocess import sys +from typing import Any import pytest @@ -205,6 +207,47 @@ def test_convert_keys_no_marker_for_non_sensitive_field() -> None: assert "sensitive_source" not in entry +def _wildcard_validator(value: Any) -> Any: + return value + + +def test_convert_keys_marker_wrapped_callable_key_normalizes() -> None: + converted: dict = {} + _bls.convert_keys(converted, {cv.Optional(_wildcard_validator): cv.string}, "/root") + + config_vars = converted["schema"]["config_vars"] + assert set(config_vars) == {"string"} + assert config_vars["string"]["key"] == "Optional" + assert config_vars["string"]["key_type"] == "_wildcard_validator" + + +def test_convert_keys_marker_wrapped_callable_beside_fixed_keys() -> None: + converted: dict = {} + _bls.convert_keys( + converted, + {cv.Required("id"): cv.string, cv.Optional(_wildcard_validator): cv.string}, + "/root", + ) + + assert set(converted["schema"]["config_vars"]) == {"id", "string"} + + +def test_convert_keys_bare_callable_dotted_qualname() -> None: + def make_validator() -> Callable[[Any], Any]: + def validator(value: Any) -> Any: + return value + + return validator + + converted: dict = {} + _bls.convert_keys(converted, {make_validator(): cv.string}, "/root") + + assert converted["key"] == "String" + assert converted["key_type"].endswith("make_validator..validator") + assert "at 0x" not in converted["key_type"] + assert set(converted["schema"]["config_vars"]) == {"string"} + + # --------------------------------------------------------------------------- # Regression tests for the lvgl schema dump. # From c8c929d48792ec013935265edb61357f27f91a15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 10:06:06 -0500 Subject: [PATCH 1349/1815] [ble_device_base] Merge adv and scan response before delivery on rp2 (#18217) --- .../ble_device_base/scan_response_merger.cpp | 150 ++++++++++++++ .../ble_device_base/scan_response_merger.h | 152 +++++++++++++++ .../components/ln882h_ble_tracker/__init__.py | 3 + .../ln882h_ble_tracker/ln882h_ble_tracker.cpp | 162 ++-------------- .../ln882h_ble_tracker/ln882h_ble_tracker.h | 65 +------ .../components/rp2_ble_tracker/__init__.py | 3 + .../rp2_ble_tracker/rp2_ble_tracker.cpp | 62 +++--- .../rp2_ble_tracker/rp2_ble_tracker.h | 35 ++-- esphome/core/defines.h | 2 + tests/components/ble_device_base/__init__.py | 4 + .../test_scan_response_merger.cpp | 183 ++++++++++++++++++ 11 files changed, 572 insertions(+), 249 deletions(-) create mode 100644 esphome/components/ble_device_base/scan_response_merger.cpp create mode 100644 esphome/components/ble_device_base/scan_response_merger.h create mode 100644 tests/components/ble_device_base/test_scan_response_merger.cpp diff --git a/esphome/components/ble_device_base/scan_response_merger.cpp b/esphome/components/ble_device_base/scan_response_merger.cpp new file mode 100644 index 0000000000..15445cee02 --- /dev/null +++ b/esphome/components/ble_device_base/scan_response_merger.cpp @@ -0,0 +1,150 @@ +#include "scan_response_merger.h" + +#ifdef USE_BLE_SCAN_RESPONSE_MERGER + +#include + +namespace esphome::ble_device_base { + +void ScanResponseMerger::deliver_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, + uint8_t data_len, bool raw_only) { + if (this->dispatcher_ == nullptr) + return; + this->dispatcher_->dispatch(mac, rssi, addr_type, data, data_len, raw_only, + *this->scan_continuous_ ? nullptr : this->log_tag_); +} + +void ScanResponseMerger::stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, + uint8_t data_len, uint32_t now) { + // One pass: find a same-device entry (deliver + reuse) while remembering the + // first free slot as the fallback. + PendingAdv *slot = nullptr; + PendingAdv *free_slot = nullptr; + for (auto &p : this->pending_adv_) { + if (!p.used) { + if (free_slot == nullptr) + free_slot = &p; + continue; + } + if (p.addr_type == addr_type && memcmp(p.mac, mac, 6) == 0) { + // Same device advertised again before its scan response arrived — deliver + // the previous advertisement (its scan response is not coming) and reuse + // the slot, so no frame is ever lost. + p.used = false; + this->pending_count_--; + this->deliver_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); + slot = &p; + break; + } + } + if (slot == nullptr) + slot = free_slot; + if (slot == nullptr) { + // Table full — degrade gracefully: deliver the advertisement unmerged. + this->deliver_(mac, rssi, addr_type, data, data_len, /*raw_only=*/false); + return; + } + slot->used = true; + this->pending_count_++; + memcpy(slot->mac, mac, 6); + slot->addr_type = addr_type; + slot->rssi = rssi; + slot->data_len = (data_len <= sizeof(slot->data)) ? data_len : sizeof(slot->data); + memcpy(slot->data, data, slot->data_len); + slot->stored_ms = now; +} + +void ScanResponseMerger::submit_scan_rsp(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, + uint8_t data_len) { + // Fast-out on the empty table (sweep/flush use the same guard); this is the + // hottest caller. + if (this->pending_count_ != 0) { + for (auto &p : this->pending_adv_) { + if (p.used && p.addr_type == addr_type && memcmp(p.mac, mac, 6) == 0) { + // Append in place: the slot is released on delivery, so its 62-byte + // buffer (legacy adv + scan response) holds the merged frame directly. + const uint8_t room = sizeof(p.data) - p.data_len; + const uint8_t add = (data_len <= room) ? data_len : room; + memcpy(p.data + p.data_len, data, add); + p.used = false; + this->pending_count_--; + // The advertisement's RSSI, not the scan response's (header contract). + this->deliver_(mac, p.rssi, addr_type, p.data, p.data_len + add, /*raw_only=*/false); + return; + } + } + } + // Unmatched scan-response: goes out on the raw callback only (HA merges per + // address); local listeners/triggers receive each advertisement exactly once + // via the merged/plain path above. + this->deliver_(mac, rssi, addr_type, data, data_len, /*raw_only=*/true); +} + +void ScanResponseMerger::sweep(uint32_t now) { + if (this->pending_count_ == 0) + return; + for (auto &p : this->pending_adv_) { + if (p.used && now - p.stored_ms > PENDING_ADV_TIMEOUT_MS) { + p.used = false; + this->pending_count_--; + this->deliver_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); + } + } +} + +void ScanResponseMerger::flush() { + if (this->pending_count_ == 0) + return; + for (auto &p : this->pending_adv_) { + if (p.used) { + p.used = false; + this->pending_count_--; + this->deliver_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); + } + } +} + +void AdvDispatcher::dispatch(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, + bool raw_only, const char *log_unclaimed_tag) { + // Raw callback (the raw-advertisement path). Both full advertisements and + // unmatched scan responses (raw_only) are forwarded. + if (this->raw_callback_.is_set()) { + const RawAdvertisement adv{.address = mac_lsb_first_to_uint64(mac), + .data = data, + .data_len = data_len, + .rssi = rssi, + .addr_type = addr_type}; + this->raw_callback_.invoke(adv); + } + +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + // Scan-response-only frames are never parsed for local sensors/triggers. + if (raw_only) + return; + ESPBTDevice device; + device.from_scan_result(mac, rssi, addr_type, data, data_len); + // The listener list holds sensors AND the tracker's automation triggers + // (the triggers are listeners, exactly like esp32_ble_tracker), so one + // loop feeds both and ORs into `found`. + bool found = false; + for (auto *listener : this->listeners_) { + if (listener->parse_device(device)) { + found = true; + } + } + if (!found && log_unclaimed_tag != nullptr) + this->discovered_log_.log_device(log_unclaimed_tag, device); +#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT +} + +void AdvDispatcher::on_scan_end() { +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + for (auto *listener : this->listeners_) + listener->on_scan_end(); + this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity) +#endif +} + +} // namespace esphome::ble_device_base + +#endif // USE_BLE_SCAN_RESPONSE_MERGER diff --git a/esphome/components/ble_device_base/scan_response_merger.h b/esphome/components/ble_device_base/scan_response_merger.h new file mode 100644 index 0000000000..9415664fcf --- /dev/null +++ b/esphome/components/ble_device_base/scan_response_merger.h @@ -0,0 +1,152 @@ +// Shared support for trackers whose controller delivers advertisement and +// scan response as SEPARATE reports (ln882h, rp2, bk72xx; ESP-IDF concatenates +// both into one result before ESPHome sees it): +// +// ScanResponseMerger — Bluedroid-style merge: a scannable advertisement is +// held briefly, its scan response is appended on arrival and the pair is +// delivered as ONE merged frame. Merged delivery is what the receiving side +// is built around: Home Assistant keeps the latest raw frame per device and +// skips re-parsing when it is unchanged — split delivery alternates two raw +// frames per device and defeats both. +// +// AdvDispatcher — the delivery half every such tracker repeats: raw +// callback, listener parsing, discovered-device log. Trackers delegate +// their BLEHub register_listener / set_raw_advertisement_callback here. +// +// The merger delivers straight into the tracker's AdvDispatcher — bind() wires +// the pair once in setup(). Single-task use only (every tracker calls this on +// the ESPHome main task). The clock is caller-provided: pass the same clock to +// stash_adv() and sweep() (millis() or App.get_loop_component_start_time(), +// never mixed). + +#pragma once + +#include "esphome/core/defines.h" + +// Emitted (cg.add_define) by each tracker that adopts the merger, so builds +// whose tracker merges in-stack (esp32) never compile this code. +#ifdef USE_BLE_SCAN_RESPONSE_MERGER + +#include "ble_device.h" +#include "ble_hub.h" +#include "esphome/core/helpers.h" + +#include + +namespace esphome::ble_device_base { + +/// The delivery half of a split-report tracker, shared so the dispatch +/// contract (raw-callback ordering, raw_only gate, discovered-log policy) +/// lives in one place. Owns the members every tracker otherwise duplicates; +/// the tracker's BLEHub methods delegate here. +class AdvDispatcher { + public: + void register_listener(ESPBTDeviceListener *listener) { +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + this->listeners_.push_back(listener); +#endif + } + void set_raw_advertisement_callback(RawAdvertisementCallback callback) { this->raw_callback_ = callback; } + /// Dispatch one (possibly merged) advertisement: the raw callback, and — + /// unless raw_only — parsing for listeners/triggers. raw_only marks + /// unmatched scan-response frames: forwarded on the raw callback only, never + /// parsed for local sensors/triggers (Home Assistant merges per address). + /// log_unclaimed_tag: when non-null, a device no listener claimed is logged + /// under this tag (esp32_ble_tracker parity: pass the tracker TAG on + /// one-shot scans, nullptr on continuous scans, which would spam). + void dispatch(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, + bool raw_only, const char *log_unclaimed_tag); + /// Fire listeners' on_scan_end and reset the per-scan discovered-log dedup. + void on_scan_end(); + + protected: + RawAdvertisementCallback raw_callback_{}; +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + // Parsed-advertisement consumers registered through ble_device_base. + // Codegen-sized: no heap allocation, no std::vector template instantiations. + StaticVector listeners_; + // Per-period "Found device" DEBUG log with MAC dedup. Guarded like its only + // writer so a no-listener build does not carry an unused vector. + DiscoveredDeviceLog discovered_log_{}; +#endif +}; + +class ScanResponseMerger { + public: + /// Wire the merger's output; call once in the tracker's setup(). Every + /// delivered frame goes to dispatcher->dispatch(); scan_continuous is read + /// at each delivery (runtime continuous flips are honored) to decide the + /// unclaimed-device log tag, so both pointers must outlive the merger — + /// tracker members always do. + void bind(AdvDispatcher *dispatcher, const bool *scan_continuous, const char *log_tag) { + this->dispatcher_ = dispatcher; + this->scan_continuous_ = scan_continuous; + this->log_tag_ = log_tag; + } + /// Hold a scannable advertisement, waiting for its scan response. The + /// tracker calls this only when it wants the merge (scannable advertisement + /// while an active scan runs) and delivers everything else directly. A + /// same-device re-advertisement delivers the held frame (its scan response + /// is not coming) and reuses the slot; a full table degrades gracefully to + /// unmerged delivery. + void stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, + uint32_t now); + /// A scan response arrived: append it to the held advertisement from the + /// same device and deliver the pair as one frame. The merged frame reports + /// the ADVERTISEMENT's RSSI — every unmerged path reports the + /// advertisement's measurement, so a device's RSSI must not jump between two + /// measurements depending on merge timing. Unmatched responses are delivered + /// raw_only. + void submit_scan_rsp(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len); + /// Timeout flush (call from loop() with the stash_adv() clock): deliver + /// held advertisements whose scan response never arrived (device didn't + /// answer / frame lost) — unmerged, past PENDING_ADV_TIMEOUT_MS. + void sweep(uint32_t now); + /// Deliver every held advertisement now (scan period/scan is ending, before + /// on_scan_end fires): unmerged delivery, same as the timeout path. + void flush(); + /// Lets loop() skip the cross-TU sweep() call in the common case (empty: + /// passive scan, or every pair already matched). + bool empty() const { return this->pending_count_ == 0; } + + private: + /// All delivery funnels through here: an unbound merger (bind() not called) + /// drops the frame instead of jumping through a null pointer, mirroring the + /// guard-before-invoke convention of the ble_hub.h callback slots. + void deliver_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, + bool raw_only); + + // 62 bytes = legacy adv (31) + scan response (31), the same merged maximum + // as ESP-IDF delivers on ESP32. + struct PendingAdv { + bool used{false}; + uint8_t mac[6]; + uint8_t addr_type; + int8_t rssi; + uint8_t data_len; // <= sizeof(data) + uint8_t data[62]; + uint32_t stored_ms; + }; + // Sized for the unanswered case: a pair that IS answered normally matches + // within one report-queue drain, so a slot is held for the full timeout only + // by scannable devices that never reply. 8 concurrent such advertisers + // before the merge degrades (frames still delivered, just unmerged) at + // ~80 B each. + static constexpr size_t MAX_PENDING_ADV = 8; + // On air a scan response follows its advertisement by T_IFS (150 µs) — the + // timeout only covers HOST-side report queuing under WiFi/BLE coexistence, + // measured on-device (ln882h) at up to ~136 ms. 300 ms = >2x that margin, + // while staying below any device's re-advertising period. + static constexpr uint32_t PENDING_ADV_TIMEOUT_MS = 300; + AdvDispatcher *dispatcher_{nullptr}; + const bool *scan_continuous_{nullptr}; // read at delivery; see bind() + const char *log_tag_{nullptr}; + // pending_count_ mirrors the number of set `used` flags; both are updated + // together on every transition. + PendingAdv pending_adv_[MAX_PENDING_ADV]; + uint8_t pending_count_{0}; +}; + +} // namespace esphome::ble_device_base + +#endif // USE_BLE_SCAN_RESPONSE_MERGER diff --git a/esphome/components/ln882h_ble_tracker/__init__.py b/esphome/components/ln882h_ble_tracker/__init__.py index 45f1b95164..8443799144 100644 --- a/esphome/components/ln882h_ble_tracker/__init__.py +++ b/esphome/components/ln882h_ble_tracker/__init__.py @@ -129,6 +129,9 @@ async def stop_scan_action_to_code( async def to_code(config: ConfigType) -> None: # Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h. cg.add_define("USE_LN882H_BLE_TRACKER") + # Compiles the shared adv + scan-response merge (the LN controller + # delivers the pair as separate reports). + cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER") var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp index cddcd6c17d..11ea46525c 100644 --- a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp @@ -3,7 +3,6 @@ #include "ln882h_ble_tracker.h" #include -#include #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -22,6 +21,9 @@ void LN882HBLETracker::setup() { // Receive the controller's scan reports; the controller queues them from the // rw task and delivers here on the main task. this->parent_->register_scan_listener(this); + // Merged (and unmerged) frames go to the shared dispatcher; scan_continuous_ + // is read at each delivery to decide unclaimed-device logging. + this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, TAG); // scan_running_ check: an on_boot start_scan action (priority 600) runs // before this setup() (200) and enable_loop() is a no-op pre-setup — parking // the loop here would strand that already-running scan. @@ -72,19 +74,11 @@ void LN882HBLETracker::loop() { this->start_scan_(); } } - // Flush pending scannable advertisements whose scan response never arrived - // (device didn't answer / frame lost) — delivered unmerged after the timeout. - // Main-task only, like every consumer of pending_adv_. + // Deliver held scannable advertisements whose scan response never arrived — + // unmerged after the merger's timeout. Main-task only, like every merger call. const uint32_t now = millis(); - if (this->pending_count_ != 0) { - for (auto &p : this->pending_adv_) { - if (p.used && now - p.stored_ms > PENDING_ADV_TIMEOUT_MS) { - p.used = false; - this->pending_count_--; - this->process_adv_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); - } - } - } + if (!this->merger_.empty()) + this->merger_.sweep(now); if (this->scan_continuous_) { if (!this->scan_running_) { @@ -145,129 +139,25 @@ void LN882HBLETracker::dump_config() { } // --------------------------------------------------------------------------- -// Adv/scan-response demux with Bluedroid-style merge: the LN controller -// delivers the pair as separate reports; a scannable advertisement is held -// until its scan response arrives and delivered as one merged frame. +// Adv/scan-response demux into the shared merger (ble_device_base): the LN +// controller delivers the pair as separate reports; a scannable advertisement +// is held until its scan response arrives and delivered as one merged frame. // --------------------------------------------------------------------------- void LN882HBLETracker::on_scan_report(const ln882h_ble::BLEScanReport &report) { if (report.is_scan_response) { - this->deliver_scan_rsp_(report); + this->merger_.submit_scan_rsp(report.mac, report.rssi, report.addr_type, report.data, report.data_len); return; } // Stash only while the scan runs: after a one-shot stop the loop is - // disabled and nothing would sweep the table, so a late report would + // disabled and nothing would sweep the merger, so a late report would // surface minutes later as a fresh advertisement. if (this->scan_running_ && this->scan_active_ && report.scannable) { - this->stash_adv_(report); + this->merger_.stash_adv(report.mac, report.rssi, report.addr_type, report.data, report.data_len, millis()); return; } - this->process_adv_(report.mac, report.rssi, report.addr_type, report.data, report.data_len, /*raw_only=*/false); -} - -// Hold a scannable advertisement, waiting (≤ PENDING_ADV_TIMEOUT_MS) for its -// scan response. -void LN882HBLETracker::stash_adv_(const ln882h_ble::BLEScanReport &report) { - // One pass: find a same-device entry (deliver + reuse) while remembering the - // first free slot as the fallback. - PendingAdv *slot = nullptr; - PendingAdv *free_slot = nullptr; - for (auto &p : this->pending_adv_) { - if (!p.used) { - if (free_slot == nullptr) - free_slot = &p; - continue; - } - if (p.addr_type == report.addr_type && memcmp(p.mac, report.mac, 6) == 0) { - // Same device advertised again before its scan response arrived — deliver - // the previous advertisement (its scan response is not coming) and reuse - // the slot, so no frame is ever lost. - p.used = false; - this->pending_count_--; - this->process_adv_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); - slot = &p; - break; - } - } - if (slot == nullptr) - slot = free_slot; - if (slot == nullptr) { - // Table full — degrade gracefully: deliver the advertisement unmerged. - this->process_adv_(report.mac, report.rssi, report.addr_type, report.data, report.data_len, /*raw_only=*/false); - return; - } - slot->used = true; - this->pending_count_++; - memcpy(slot->mac, report.mac, 6); - slot->addr_type = report.addr_type; - slot->rssi = report.rssi; - slot->data_len = (report.data_len <= sizeof(slot->data)) ? report.data_len : sizeof(slot->data); - memcpy(slot->data, report.data, slot->data_len); - slot->stored_ms = millis(); -} - -// Scan response arrived: merge it with the pending advertisement from the same -// device into ONE frame (ESP-IDF/Bluedroid semantics). -void LN882HBLETracker::deliver_scan_rsp_(const ln882h_ble::BLEScanReport &report) { - // Fast-out on the empty table (loop()/flush use the same guard); this is - // the hottest caller. - if (this->pending_count_ != 0) { - for (auto &p : this->pending_adv_) { - if (p.used && p.addr_type == report.addr_type && memcmp(p.mac, report.mac, 6) == 0) { - // Append in place: the slot is released on delivery, so its 62-byte - // buffer (legacy adv + scan response) holds the merged frame directly. - const uint8_t room = sizeof(p.data) - p.data_len; - const uint8_t add = (report.data_len <= room) ? report.data_len : room; - memcpy(p.data + p.data_len, report.data, add); - p.used = false; - this->pending_count_--; - // The advertisement's RSSI, not the scan response's: every unmerged path - // reports the advertisement's measurement, so a device's RSSI must not - // jump between two measurements depending on merge timing. - this->process_adv_(report.mac, p.rssi, report.addr_type, p.data, p.data_len + add, /*raw_only=*/false); - return; - } - } - } - // Unmatched scan-response: goes out on the raw callback only (HA merges per - // address); local listeners/triggers receive each advertisement exactly once - // via the merged/plain path above. - this->process_adv_(report.mac, report.rssi, report.addr_type, report.data, report.data_len, /*raw_only=*/true); -} - -void LN882HBLETracker::process_adv_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, - uint8_t data_len, bool raw_only) { - // Raw callback (the raw-advertisement path). Both full advertisements and - // unmatched scan responses (raw_only) are forwarded. - if (this->raw_advertisement_callback_.is_set()) { - const ble_device_base::RawAdvertisement adv{.address = ble_device_base::mac_lsb_first_to_uint64(mac), - .data = data, - .data_len = data_len, - .rssi = rssi, - .addr_type = addr_type}; - this->raw_advertisement_callback_.invoke(adv); - } - -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - // Scan-response-only frames are never parsed for local sensors/triggers. - if (raw_only) - return; - ble_device_base::ESPBTDevice device; - device.from_scan_result(mac, rssi, addr_type, data, data_len); - // The listener list holds sensors AND this tracker's automation triggers - // (the triggers are listeners, exactly like esp32_ble_tracker), so one - // loop feeds both and ORs into `found`. - bool found = false; - for (auto *listener : this->listeners_) { - if (listener->parse_device(device)) { - found = true; - } - } - // Mirror esp32_ble_tracker: log a newly-seen device only when nothing claimed - // it and the scan is one-shot (continuous scans would spam). - if (!found && !this->scan_continuous_) - this->discovered_log_.log_device(TAG, device); -#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + this->dispatcher_.dispatch(report.mac, report.rssi, report.addr_type, report.data, report.data_len, + /*raw_only=*/false, this->scan_continuous_ ? nullptr : TAG); } // --------------------------------------------------------------------------- @@ -356,29 +246,11 @@ void LN882HBLETracker::stop_scan_() { // Close a scan period: deliver held advertisements whose scan response never // came (unmerged) BEFORE on_scan_end fires, then re-anchor the period clock. void LN882HBLETracker::end_scan_period_(uint32_t now) { - this->flush_pending_adv_(); -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - for (auto *listener : this->listeners_) - listener->on_scan_end(); - this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity) -#endif + this->merger_.flush(); + this->dispatcher_.on_scan_end(); this->scan_period_start_ = now; } -// Deliver every held advertisement now (scan period/scan is ending): unmerged -// delivery, same as the timeout path in loop(). Main-task only. -void LN882HBLETracker::flush_pending_adv_() { - if (this->pending_count_ == 0) - return; - for (auto &p : this->pending_adv_) { - if (p.used) { - p.used = false; - this->process_adv_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); - } - } - this->pending_count_ = 0; -} - } // namespace esphome::ln882h_ble_tracker #endif // USE_LIBRETINY diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h index 9c0e0b2f1a..2d88b938dd 100644 --- a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h @@ -9,6 +9,7 @@ #include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/ble_device_base/ble_hub.h" +#include "esphome/components/ble_device_base/scan_response_merger.h" #include "esphome/components/ln882h_ble/ln882h_ble.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -75,12 +76,10 @@ class LN882HBLETracker : public Component, // ---- ble_device_base::BLEHub contract ---- void register_listener(ble_device_base::ESPBTDeviceListener *listener) { -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - this->listeners_.push_back(listener); -#endif + this->dispatcher_.register_listener(listener); } void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { - this->raw_advertisement_callback_ = callback; + this->dispatcher_.set_raw_advertisement_callback(callback); } static constexpr ble_device_base::HubCapabilities get_capabilities() { // The LN882H controller supports active scanning; adv + scan response arrive @@ -108,27 +107,11 @@ class LN882HBLETracker : public Component, void on_scan_report(const ln882h_ble::BLEScanReport &report) override; protected: - // Bluedroid-style adv + scan-response merging (ESP-IDF concatenates both into - // one result before ESPHome sees it; the LN controller reports them separately): - // a scannable advertisement is held here briefly, its scan response is appended - // on arrival and the pair is delivered as ONE merged frame. Held entries whose - // scan response never arrives are flushed by loop() after PENDING_ADV_TIMEOUT_MS. - // All of this runs on the main task (the controller queue already crossed tasks), - // so no locking is involved. - void stash_adv_(const ln882h_ble::BLEScanReport &report); - void deliver_scan_rsp_(const ln882h_ble::BLEScanReport &report); - // Dispatch one (possibly merged) advertisement: the raw - // callback, and — unless raw_only — parsing for listeners/triggers. raw_only - // marks unmatched scan-response frames: forwarded on the raw callback only, - // never to local sensors/triggers (HA merges per address). - void process_adv_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, - bool raw_only); void start_scan_(); void stop_scan_(); // Close a scan period: flush held advertisements (unmerged) BEFORE // on_scan_end fires, then re-anchor the period clock to `now`. void end_scan_period_(uint32_t now); - void flush_pending_adv_(); bool scan_running_{false}; bool scan_active_{false}; @@ -147,45 +130,13 @@ class LN882HBLETracker : public Component, #endif uint32_t scan_start_time_{0}; - // Pending scannable advertisements awaiting their scan response (active scan). - // 62 bytes = legacy adv (31) + scan response (31), the same merged maximum as - // ESP-IDF delivers on ESP32. Main-task only. - struct PendingAdv { - bool used{false}; - uint8_t mac[6]; - uint8_t addr_type; - int8_t rssi; - uint8_t data_len; // <= sizeof(data) - uint8_t data[62]; - uint32_t stored_ms; - }; - // Sized for the unanswered case: a pair that IS answered normally matches - // within one queue drain, so a slot is held for the full timeout only by - // scannable devices that never reply. 8 concurrent such advertisers before - // the merge degrades (frames still delivered, just unmerged) at ~80 B each. - static constexpr size_t MAX_PENDING_ADV = 8; - // On air a scan response follows its advertisement by T_IFS (150 µs) — the - // timeout only covers HOST-side report queuing in rw_task under WiFi/BLE - // coexistence, measured on-device at up to ~136 ms. 300 ms = >2x that margin, - // while staying below any device's re-advertising period. - static constexpr uint32_t PENDING_ADV_TIMEOUT_MS = 300; - PendingAdv pending_adv_[MAX_PENDING_ADV]; - // Occupied pending_adv_ slots — lets loop()'s timeout sweep skip the table - // in the common case (empty: passive scan, or every pair already matched). - uint8_t pending_count_{0}; + // Shared adv + scan-response merge and frame dispatch (ble_device_base). + // All calls run on the main task (the controller queue already crossed + // tasks); the merger is clocked by millis() throughout this tracker. + ble_device_base::ScanResponseMerger merger_; + ble_device_base::AdvDispatcher dispatcher_; uint32_t scan_period_start_{0}; // millis() at start of current scan period; used to rate-limit on_scan_end() - - ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{}; -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - // Parsed-advertisement consumers registered through ble_device_base. - // Codegen-sized: no heap allocation, no std::vector template instantiations. - StaticVector listeners_; - // Per-period "Found device" DEBUG log with MAC dedup — shared implementation - // in ble_device_base, identical output on every tracker backend. Guarded like - // its only writer so a no-listener build does not carry an unused vector. - ble_device_base::DiscoveredDeviceLog discovered_log_{}; -#endif }; } // namespace esphome::ln882h_ble_tracker diff --git a/esphome/components/rp2_ble_tracker/__init__.py b/esphome/components/rp2_ble_tracker/__init__.py index 15c1229a85..99262babce 100644 --- a/esphome/components/rp2_ble_tracker/__init__.py +++ b/esphome/components/rp2_ble_tracker/__init__.py @@ -57,6 +57,9 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: # Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h. cg.add_define("USE_RP2_BLE_TRACKER") + # Compiles the shared adv + scan-response merge (BTstack delivers the pair + # as separate reports). + cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER") var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp index c2bb93a32e..2a87d617f8 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp @@ -24,6 +24,9 @@ void RP2BLETracker::setup() { // Receive the controller's scan reports; the controller queues them from the // BTstack packet handler (IRQ) and delivers here on the main loop. this->parent_->register_scan_listener(this); + // Merged (and unmerged) frames go to the shared dispatcher; scan_continuous_ + // is read at each delivery to decide unclaimed-device logging. + this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, TAG); #ifdef USE_OTA_STATE_LISTENER // Pause scanning while an OTA update is in flight — the BLE scan competes with // the OTA download on the shared CYW43 radio. Mirrors esp32_ble_tracker. @@ -64,6 +67,10 @@ void RP2BLETracker::on_ota_global_state(ota::OTAState state, float progress, uin void RP2BLETracker::loop() { const uint32_t now = App.get_loop_component_start_time(); + // Deliver held scannable advertisements whose scan response never arrived — + // unmerged after the merger's timeout. + if (!this->merger_.empty()) + this->merger_.sweep(now); if (this->scan_running_ && !this->parent_->is_active()) { // The controller was disabled underneath us (e.g. a lambda calling // rp2040_ble's disable()); the scan died with the stack. Reconcile so the @@ -119,30 +126,32 @@ void RP2BLETracker::dump_config() { YESNO(this->scan_continuous_)); } -void RP2BLETracker::on_scan_report(const rp2040_ble::BLEScanReport &report) { - // Raw callback (the raw-advertisement path). - if (this->raw_advertisement_callback_.is_set()) { - const ble_device_base::RawAdvertisement adv{.address = ble_device_base::mac_lsb_first_to_uint64(report.mac), - .data = report.data, - .data_len = report.data_len, - .rssi = report.rssi, - .addr_type = report.addr_type}; - this->raw_advertisement_callback_.invoke(adv); - } +// GAP advertising event types as BTstack reports them (Core spec advertising +// report event types; the tracker deliberately does not include BTstack +// headers). ADV_IND and ADV_SCAN_IND are the scannable types. +static constexpr uint8_t ADV_EVENT_TYPE_ADV_IND = 0; +static constexpr uint8_t ADV_EVENT_TYPE_ADV_SCAN_IND = 2; +static constexpr uint8_t ADV_EVENT_TYPE_SCAN_RSP = 4; -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - ble_device_base::ESPBTDevice device; - device.from_scan_result(report.mac, report.rssi, report.addr_type, report.data, report.data_len); - bool found = false; - for (auto *listener : this->listeners_) { - if (listener->parse_device(device)) - found = true; +// Demux advertisements vs scan responses into the shared merger: BTstack +// delivers the pair as separate reports; a scannable advertisement is held +// until its scan response arrives and delivered as one merged frame. +void RP2BLETracker::on_scan_report(const rp2040_ble::BLEScanReport &report) { + if (report.adv_event_type == ADV_EVENT_TYPE_SCAN_RSP) { + this->merger_.submit_scan_rsp(report.mac, report.rssi, report.addr_type, report.data, report.data_len); + return; } - // Mirror esp32_ble_tracker: log a newly-seen device only when nothing claimed - // it and the scan is one-shot (continuous scans would spam). - if (!found && !this->scan_continuous_) - this->discovered_log_.log_device(TAG, device); -#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + // Stash only while an active scan runs: a passive scan never gets a + // response, and after a stop nothing would sweep the merger, so a late + // report would surface minutes later as a fresh advertisement. + if (this->scan_running_ && this->scan_active_ && + (report.adv_event_type == ADV_EVENT_TYPE_ADV_IND || report.adv_event_type == ADV_EVENT_TYPE_ADV_SCAN_IND)) { + this->merger_.stash_adv(report.mac, report.rssi, report.addr_type, report.data, report.data_len, + App.get_loop_component_start_time()); + return; + } + this->dispatcher_.dispatch(report.mac, report.rssi, report.addr_type, report.data, report.data_len, + /*raw_only=*/false, this->scan_continuous_ ? nullptr : TAG); } void RP2BLETracker::start_scan() { @@ -229,11 +238,10 @@ void RP2BLETracker::stop_scan_() { } void RP2BLETracker::fire_scan_end_() { -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - for (auto *listener : this->listeners_) - listener->on_scan_end(); - this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity) -#endif + // Deliver held advertisements whose scan response never came (unmerged) + // BEFORE on_scan_end fires. + this->merger_.flush(); + this->dispatcher_.on_scan_end(); } } // namespace esphome::rp2_ble_tracker diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h index 054f6a65d2..02bd7dc145 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h @@ -4,6 +4,7 @@ #include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/ble_device_base/ble_hub.h" +#include "esphome/components/ble_device_base/scan_response_merger.h" #include "esphome/components/rp2040_ble/rp2040_ble.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -51,25 +52,22 @@ class RP2BLETracker : public Component, // ---- ble_device_base::BLEHub contract ---- void register_listener(ble_device_base::ESPBTDeviceListener *listener) { -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - this->listeners_.push_back(listener); -#endif + this->dispatcher_.register_listener(listener); } void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { - this->raw_advertisement_callback_ = callback; + this->dispatcher_.set_raw_advertisement_callback(callback); } static constexpr ble_device_base::HubCapabilities get_capabilities() { - // BTstack delivers scan responses as separate advertisement reports rather - // than merging them into the advertisement — consumers relying on - // scan-response fields (device names) get them only where the receiver - // merges per address (Home Assistant does). GATT is available when the - // BTstack connection backend is compiled in (bluetooth_proxy active). + // BTstack delivers scan responses as separate advertisement reports; this + // tracker merges the pair before delivery (shared ScanResponseMerger, + // Bluedroid semantics). GATT is available when the BTstack connection + // backend is compiled in (bluetooth_proxy active). #ifdef USE_BLE_GATT_CLIENT constexpr bool has_gatt = true; #else constexpr bool has_gatt = false; #endif - return {.active_scan = true, .merges_scan_response = false, .gatt = has_gatt, .scan_mode_switch = true}; + return {.active_scan = true, .merges_scan_response = true, .gatt = has_gatt, .scan_mode_switch = true}; } // The controller stores the address in printable (MSB-first) order, which is // exactly what the contract wants. @@ -104,16 +102,13 @@ class RP2BLETracker : public Component, bool scan_pending_before_ota_{false}; // one-shot scan in flight at OTA start, resumed on OTA failure #endif - ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{}; -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - // Parsed-advertisement consumers registered through ble_device_base. - // Codegen-sized: no heap allocation, no std::vector template instantiations. - StaticVector listeners_; - // Per-period "Found device" DEBUG log with MAC dedup — shared implementation - // in ble_device_base, identical output on every tracker backend. Guarded like - // its only writer so a no-listener build does not carry an unused vector. - ble_device_base::DiscoveredDeviceLog discovered_log_{}; -#endif + // Shared adv + scan-response merge and frame dispatch (ble_device_base). + // All calls run on the main loop. Merger clock: stash_adv() reads the + // PARENT's cached loop time (on_scan_report runs inside rp2040_ble's queue + // drain), sweep() this component's — same App.loop() pass, so the delta + // stays non-negative and the 300 ms timeout holds. + ble_device_base::ScanResponseMerger merger_; + ble_device_base::AdvDispatcher dispatcher_; }; } // namespace esphome::rp2_ble_tracker diff --git a/esphome/core/defines.h b/esphome/core/defines.h index ad24d27369..7be217383e 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -469,6 +469,7 @@ #define USE_RP2_BLE_TRACKER #define RP2040_BLE_SCAN_LISTENER_COUNT 1 #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 +#define USE_BLE_SCAN_RESPONSE_MERGER #define USE_BLE_GATT_CLIENT #define ESPHOME_BLE_GATT_CLIENT_COUNT 1 #define USE_RP2040_VARIANT_RP2040 @@ -500,6 +501,7 @@ #define USE_BK72XX_BLE_TRACKER #endif #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 +#define USE_BLE_SCAN_RESPONSE_MERGER #define USE_CAPTIVE_PORTAL #define USE_WIFI_SCAN_RESULTS_LOCK #define USE_SOCKET_IMPL_LWIP_SOCKETS diff --git a/tests/components/ble_device_base/__init__.py b/tests/components/ble_device_base/__init__.py index 1b041df8df..4dbd0becd8 100644 --- a/tests/components/ble_device_base/__init__.py +++ b/tests/components/ble_device_base/__init__.py @@ -6,7 +6,11 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # resolve_irk() is compiled only when a sensor configures irk: # (request_irk_support() emits USE_BLE_DEVICE_IRK). The unit-test build has # no sensors, so emit the define here to put the real IRK path under test. + # Likewise the scan-response merger (emitted by the split-report trackers) + # and the listener vector it dispatches into (codegen-sized by consumers). async def to_code_testing(config): cg.add_define("USE_BLE_DEVICE_IRK") + cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER") + cg.add_define("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT", 4) manifest.to_code = to_code_testing diff --git a/tests/components/ble_device_base/test_scan_response_merger.cpp b/tests/components/ble_device_base/test_scan_response_merger.cpp new file mode 100644 index 0000000000..013c7bf8f9 --- /dev/null +++ b/tests/components/ble_device_base/test_scan_response_merger.cpp @@ -0,0 +1,183 @@ +// The host test build gets this from the manifest override; clang-tidy does not. +#ifndef USE_BLE_SCAN_RESPONSE_MERGER +#define USE_BLE_SCAN_RESPONSE_MERGER +#endif + +#include + +#include +#include +#include + +#include "esphome/components/ble_device_base/scan_response_merger.h" + +namespace esphome::ble_device_base::testing { +namespace { + +// Pins the merge policy three trackers share (ln882h, rp2, bk72xx): slot +// bookkeeping, the same-device reuse path, the table-full fallback, the +// 62-byte truncation, the advertisement-RSSI choice and the raw_only gate. +// Delivery is observed through a real AdvDispatcher: the raw callback sees +// every frame (including raw_only), a listener only the parsed ones. + +struct DeliveredFrame { + uint64_t address; + std::vector data; + int8_t rssi; +}; + +struct RawCapture { + std::vector frames; + + static void trampoline(void *self, const RawAdvertisement &adv) { + auto *capture = static_cast(self); + capture->frames.push_back({adv.address, std::vector(adv.data, adv.data + adv.data_len), adv.rssi}); + } +}; + +class CountingListener : public ESPBTDeviceListener { + public: + bool parse_device(const ESPBTDevice &device) override { + this->parsed++; + return true; // claimed: keeps the discovered log quiet + } + int parsed{0}; +}; + +class ScanResponseMergerTest : public ::testing::Test { + protected: + void SetUp() override { + this->dispatcher_.set_raw_advertisement_callback({&this->raw_, &RawCapture::trampoline}); + this->dispatcher_.register_listener(&this->listener_); + this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, "test"); + } + + void stash_(const uint8_t (&mac)[6], int8_t rssi, uint8_t data_len, uint8_t fill, uint32_t now = 0) { + std::vector data(data_len, fill); + this->merger_.stash_adv(mac, rssi, 0, data.data(), data_len, now); + } + + void scan_rsp_(const uint8_t (&mac)[6], int8_t rssi, uint8_t data_len, uint8_t fill) { + std::vector data(data_len, fill); + this->merger_.submit_scan_rsp(mac, rssi, 0, data.data(), data_len); + } + + ScanResponseMerger merger_; + AdvDispatcher dispatcher_; + RawCapture raw_; + CountingListener listener_; + bool scan_continuous_{true}; +}; + +constexpr uint8_t MAC_A[6] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06}; +constexpr uint8_t MAC_B[6] = {0x11, 0x12, 0x13, 0x14, 0x15, 0x16}; + +TEST_F(ScanResponseMergerTest, MatchedPairDeliversOneMergedFrameWithAdvRssi) { + this->stash_(MAC_A, -40, 20, 0xAA); + EXPECT_TRUE(this->raw_.frames.empty()); // held, not delivered + + this->scan_rsp_(MAC_A, -70, 10, 0xBB); + ASSERT_EQ(this->raw_.frames.size(), 1u); + const auto &frame = this->raw_.frames[0]; + ASSERT_EQ(frame.data.size(), 30u); // adv + response as ONE frame + EXPECT_EQ(frame.data[0], 0xAA); + EXPECT_EQ(frame.data[19], 0xAA); + EXPECT_EQ(frame.data[20], 0xBB); + // The advertisement's RSSI, never the scan response's. + EXPECT_EQ(frame.rssi, -40); + EXPECT_EQ(this->listener_.parsed, 1); + EXPECT_TRUE(this->merger_.empty()); +} + +TEST_F(ScanResponseMergerTest, ReAdvertisementDeliversHeldFrameAndReusesSlot) { + this->stash_(MAC_A, -40, 20, 0xAA); + this->stash_(MAC_A, -45, 22, 0xCC); // same device again: first frame is delivered + ASSERT_EQ(this->raw_.frames.size(), 1u); + EXPECT_EQ(this->raw_.frames[0].data.size(), 20u); + EXPECT_EQ(this->raw_.frames[0].rssi, -40); + EXPECT_FALSE(this->merger_.empty()); // the second advertisement now holds the slot + + this->scan_rsp_(MAC_A, -70, 5, 0xBB); + ASSERT_EQ(this->raw_.frames.size(), 2u); + EXPECT_EQ(this->raw_.frames[1].data.size(), 27u); // 22 + 5, merged from the reused slot + EXPECT_EQ(this->raw_.frames[1].rssi, -45); +} + +TEST_F(ScanResponseMergerTest, FullTableDegradesToUnmergedDelivery) { + uint8_t mac[6] = {0x20, 0x00, 0x00, 0x00, 0x00, 0x00}; + for (uint8_t i = 0; i < 8; i++) { + mac[5] = i; + this->stash_(mac, -50, 10, i); + } + EXPECT_TRUE(this->raw_.frames.empty()); // 8 slots, all held + + mac[5] = 8; + this->stash_(mac, -50, 10, 8); // 9th device: no slot left + ASSERT_EQ(this->raw_.frames.size(), 1u); // delivered immediately, unmerged + EXPECT_EQ(this->raw_.frames[0].data.size(), 10u); + + this->merger_.flush(); // the 8 held frames are all still intact + EXPECT_EQ(this->raw_.frames.size(), 9u); + EXPECT_TRUE(this->merger_.empty()); +} + +TEST_F(ScanResponseMergerTest, MergeTruncatesAtBufferCapacity) { + this->stash_(MAC_A, -40, 31, 0xAA); + this->scan_rsp_(MAC_A, -70, 40, 0xBB); // only 31 bytes of room remain + ASSERT_EQ(this->raw_.frames.size(), 1u); + EXPECT_EQ(this->raw_.frames[0].data.size(), 62u); + EXPECT_EQ(this->raw_.frames[0].data[31], 0xBB); + EXPECT_EQ(this->raw_.frames[0].data[61], 0xBB); +} + +TEST_F(ScanResponseMergerTest, UnmatchedScanResponseIsRawOnly) { + this->scan_rsp_(MAC_B, -60, 12, 0xDD); + ASSERT_EQ(this->raw_.frames.size(), 1u); // still forwarded on the raw path + EXPECT_EQ(this->raw_.frames[0].rssi, -60); + EXPECT_EQ(this->listener_.parsed, 0); // but never parsed for listeners +} + +TEST_F(ScanResponseMergerTest, AddrTypeIsPartOfTheMatchKey) { + std::vector adv(20, 0xAA); + this->merger_.stash_adv(MAC_A, -40, /*addr_type=*/0, adv.data(), adv.size(), 0); + std::vector rsp(10, 0xBB); + this->merger_.submit_scan_rsp(MAC_A, -70, /*addr_type=*/1, rsp.data(), rsp.size()); + // Same MAC, different addr_type: no merge — the response goes out raw_only. + ASSERT_EQ(this->raw_.frames.size(), 1u); + EXPECT_EQ(this->raw_.frames[0].data.size(), 10u); + EXPECT_EQ(this->listener_.parsed, 0); + EXPECT_FALSE(this->merger_.empty()); // the advertisement is still held +} + +TEST_F(ScanResponseMergerTest, SweepDeliversOnlyPastTheTimeout) { + this->stash_(MAC_A, -40, 20, 0xAA, /*now=*/1000); + this->merger_.sweep(1300); // exactly 300 ms: not yet past the timeout + EXPECT_TRUE(this->raw_.frames.empty()); + this->merger_.sweep(1301); + ASSERT_EQ(this->raw_.frames.size(), 1u); + EXPECT_EQ(this->raw_.frames[0].rssi, -40); + EXPECT_EQ(this->listener_.parsed, 1); // timeout delivery is a full parse, not raw_only + EXPECT_TRUE(this->merger_.empty()); +} + +TEST_F(ScanResponseMergerTest, FlushDeliversEverythingImmediately) { + this->stash_(MAC_A, -40, 20, 0xAA, /*now=*/1000); + this->stash_(MAC_B, -50, 15, 0xBB, /*now=*/1000); + this->merger_.flush(); + EXPECT_EQ(this->raw_.frames.size(), 2u); + EXPECT_EQ(this->listener_.parsed, 2); + EXPECT_TRUE(this->merger_.empty()); +} + +TEST_F(ScanResponseMergerTest, UnboundMergerDropsInsteadOfCrashing) { + ScanResponseMerger unbound; + std::vector data(20, 0xAA); + unbound.stash_adv(MAC_A, -40, 0, data.data(), data.size(), 0); + unbound.submit_scan_rsp(MAC_A, -70, 0, data.data(), data.size()); + unbound.sweep(1000); + unbound.flush(); // no null jump anywhere + EXPECT_TRUE(unbound.empty()); +} + +} // namespace +} // namespace esphome::ble_device_base::testing From f3d1fc0d643ceaba252e8a0ecaeecf127a0c1bcb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 10:47:56 -0500 Subject: [PATCH 1350/1815] [bluetooth_proxy] Migrate esp32 onto the neutral GATT backend (#18198) --- .../components/ble_device_base/__init__.py | 5 +- .../ble_device_base/ble_client_state.h | 10 + .../ble_device_base/ble_gatt_client.h | 76 +- .../bluetooth_connection/__init__.py | 180 +++- .../bluetooth_connection.cpp | 27 + .../bluetooth_connection.h | 13 +- .../bluetooth_connection_bluedroid.cpp | 772 ++++++++++++++++++ .../bluetooth_connection_bluedroid.h | 142 ++++ .../bluetooth_connection_esp32.cpp | 484 ----------- .../bluetooth_connection_esp32.h | 76 -- .../bluetooth_connection_gatt_backend.h | 13 +- .../bluetooth_connection_hub.cpp | 93 +-- .../bluetooth_connection_hub.h | 131 +-- .../bluetooth_connection_rp2.cpp | 45 +- .../bluetooth_connection_rp2.h | 15 +- .../components/bluetooth_proxy/__init__.py | 99 +-- .../bluetooth_proxy/bluetooth_proxy.cpp | 140 ++-- .../bluetooth_proxy/bluetooth_proxy.h | 11 +- esphome/config_helpers.py | 15 +- esphome/core/defines.h | 2 + .../ble_device_base/test_slot_counter.py | 2 + .../test_outer_schema_mirror.py | 11 +- .../bluetooth_proxy/test_platform_gates.py | 58 +- .../test_gatt_client_contract.cpp | 45 +- .../test-passive.esp32-c6-idf.yaml | 12 + tests/unit_tests/test_config_helpers.py | 17 +- 26 files changed, 1518 insertions(+), 976 deletions(-) create mode 100644 esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp create mode 100644 esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h delete mode 100644 esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp delete mode 100644 esphome/components/bluetooth_connection/bluetooth_connection_esp32.h create mode 100644 tests/components/bluetooth_proxy/test-passive.esp32-c6-idf.yaml diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index fa66448867..ae03003713 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -163,8 +163,9 @@ _request_gatt_connection_slot = cg.slot_counter(GATT_CLIENT_COUNT_DEFINE) def request_gatt_client() -> None: """Compile in the neutral GATT client contract (ble_gatt_client.h) and - claim one connection slot. Called by bluetooth_proxy once per connection - it instantiates on a hub platform.""" + claim one compiled-in client slot (sizes ESPHOME_BLE_GATT_CLIENT_COUNT; + distinct from the proxy's validated connection budget). Called by + bluetooth_connection.new_gatt_backend() once per backend instance.""" cg.add_define("USE_BLE_GATT_CLIENT") _request_gatt_connection_slot() diff --git a/esphome/components/ble_device_base/ble_client_state.h b/esphome/components/ble_device_base/ble_client_state.h index b0c91397fc..92754b70b4 100644 --- a/esphome/components/ble_device_base/ble_client_state.h +++ b/esphome/components/ble_device_base/ble_client_state.h @@ -17,6 +17,16 @@ namespace esphome::ble_device_base { /// client backend. static constexpr int GATT_ERR_NOT_CONNECTED = -1; static constexpr int GATT_ERR_NO_MEMORY = -2; +/// ATT "Unlikely Error" (spec 0x0E): a client-side internal inconsistency, +/// e.g. a service table failing its own bounds checks. +static constexpr int GATT_ERR_UNLIKELY = 0x0E; + +/// Safety net shared by every GATT backend: force IDLE when the stack never +/// delivers its disconnect completion. +static constexpr uint32_t GATT_DISCONNECT_TIMEOUT_MS = 10000; + +/// ATT MTU before negotiation completes (Bluetooth spec default). +static constexpr uint16_t DEFAULT_ATT_MTU = 23; // Preferred connection parameters shared by every platform's GATT client so // the backends cannot drift (units: interval 1.25 ms, timeout 10 ms; latency diff --git a/esphome/components/ble_device_base/ble_gatt_client.h b/esphome/components/ble_device_base/ble_gatt_client.h index 74548f578f..b95fb6878a 100644 --- a/esphome/components/ble_device_base/ble_gatt_client.h +++ b/esphome/components/ble_device_base/ble_gatt_client.h @@ -5,10 +5,11 @@ // Exactly one GATT backend exists per build, so BLEGattConnection is a // compile-time alias (bluetooth_connection_gatt_backend.h), not an abstract // interface. -// The hub BluetoothConnection wrapper drives it and receives completions -// through its event-sink methods, which the backend calls directly. All sink -// calls are delivered on the ESPHome main loop; borrowed data pointers are -// valid only for the duration of the call. +// A consumer - the hub wrapper streaming the raw database, or a direct +// consumer owning a dedicated backend and resolving handles by UUID - +// drives it and receives completions through the GattClientListener +// interface. All listener calls are delivered on the ESPHome main loop; +// borrowed data pointers are valid only for the duration of the call. // // Error domain (plain int, forwarded to the API without translation): // 0 success @@ -78,27 +79,55 @@ struct GattServiceTable { uint16_t descriptor_count{0}; }; +/// The event surface a backend delivers completions through - the one place +/// with genuine runtime polymorphism (several consumer types, one non-virtual +/// backend). Methods default to no-ops; consumers override what they consume. +/// No destructor: components are never destroyed. +/// on_connection_state carries the negotiated MTU and an HCI status/reason. +/// Codegen wires the listener before setup(), so backends skip null checks. +class GattClientListener { + public: + virtual void on_connection_state(bool connected, uint16_t mtu, int error) {} + virtual void on_service_discovery_done(int error) {} + virtual void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {} + virtual void on_write_result(uint16_t handle, int error) {} + virtual void on_notify_state(uint16_t handle, bool enabled, int error) {} + virtual void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {} + virtual void on_pairing_result(int status) {} +}; + // The BLEGattConnection op surface, asserted where the alias binds // (bluetooth_connection_gatt_backend.h). Operations return 0 when accepted (completion arrives -// through the sink) or a synchronous error (busy, not connected, stack +// through the listener) or a synchronous error (busy, not connected, stack // rejection); one operation may be outstanding at a time. Semantics beyond // the signatures: // - connect: addr_type is a BLE_ADDR_TYPE_* constant (ble_device.h). -// - disconnect: also cancels a connect in progress. +// - gatt_disconnect: also cancels a connect in progress (named to coexist +// with a platform stack's own void disconnect() on one backend class). +// Nonzero means nothing to tear down and no completion will follow; an +// accepted teardown (0) always reaches a terminal on_connection_state. +// - cancel_gatt_disconnect: true cancels a scheduled teardown that has not +// started closing - the in-flight connect resumes and completes normally. +// False once the teardown owns the link (or nothing was scheduled). // - notify_characteristic: local registration only; the CCCD write is the // API client's responsibility (a plain write_descriptor). // - get_service_table/release_services: backend-owned transient storage, -// released after streaming (release is idempotent). -// - completions: connect and disconnect land in on_connection_state, +// released after streaming (release is idempotent). A backend may +// additionally provide its own service streamer (stream_service_batch on +// the concrete type, detected by the consumer at compile time) for +// arbitrary-size databases; the table then materializes only for consumers +// that ask for it. +// - completions: connect and gatt_disconnect land in on_connection_state, // discover_services in on_service_discovery_done, pair in // on_pairing_result, reads in on_read_result, notify_characteristic in -// on_notify_state, characteristic writes with response and descriptor -// writes in on_write_result. -template -concept BLEGattConnectionContract = requires(T conn, Sink *sink, const uint8_t *data) { - conn.set_listener(sink); +// on_notify_state, characteristic writes (with and without response) and +// descriptor writes in on_write_result. +template +concept BLEGattConnectionContract = requires(T conn, GattClientListener *listener, const uint8_t *data) { + conn.set_listener(listener); { conn.connect(uint64_t{}, uint8_t{}) } -> std::same_as; - { conn.disconnect() } -> std::same_as; + { conn.gatt_disconnect() } -> std::same_as; + { conn.cancel_gatt_disconnect() } -> std::same_as; { conn.discover_services() } -> std::same_as; { conn.read_characteristic(uint16_t{}) } -> std::same_as; { conn.write_characteristic(uint16_t{}, data, uint16_t{}, true) } -> std::same_as; @@ -109,22 +138,9 @@ concept BLEGattConnectionContract = requires(T conn, Sink *sink, const uint8_t * { conn.update_connection_params(uint16_t{}, uint16_t{}, uint16_t{}, uint16_t{}) } -> std::same_as; { conn.get_service_table() } -> std::same_as; { conn.release_services() } -> std::same_as; -}; - -// The event sink the backend calls directly (the hub BluetoothConnection -// wrapper), asserted where the wrapper is defined: on_connection_state -// carries the negotiated MTU and an HCI status/disconnect reason. The -// requirements check call validity, not exact parameter types; keep sink -// parameters at the documented widths (uint16_t handles and lengths). -template -concept GattClientEventSinkContract = requires(S sink, const uint8_t *data) { - { sink.on_connection_state(true, uint16_t{}, int{}) } -> std::same_as; - { sink.on_service_discovery_done(int{}) } -> std::same_as; - { sink.on_read_result(uint16_t{}, data, uint16_t{}, int{}) } -> std::same_as; - { sink.on_write_result(uint16_t{}, int{}) } -> std::same_as; - { sink.on_notify_state(uint16_t{}, true, int{}) } -> std::same_as; - { sink.on_notify_data(uint16_t{}, data, uint16_t{}) } -> std::same_as; - { sink.on_pairing_result(int{}) } -> std::same_as; + // Connection-type hint for backends that tune parameters by it; others + // carry an inline no-op. + { conn.set_connection_type(ConnectionType{}) } -> std::same_as; }; } // namespace esphome::ble_device_base diff --git a/esphome/components/bluetooth_connection/__init__.py b/esphome/components/bluetooth_connection/__init__.py index 1dc1969a6a..8c218c0954 100644 --- a/esphome/components/bluetooth_connection/__init__.py +++ b/esphome/components/bluetooth_connection/__init__.py @@ -1,23 +1,34 @@ -"""Per-platform GATT connection backends the Bluetooth proxy drives. +"""Per-platform GATT connection backends and the helpers to embed one. -Backends: esp32 Bluedroid, rp2 BTstack. Auto-loaded by bluetooth_proxy, no -user-facing configuration; the proxy's codegen declares and registers the -connection instances. +Backends: esp32 Bluedroid, rp2 BTstack. No user-facing configuration; the +Bluetooth proxy's codegen declares and registers the backend instances +through gatt_client_schema()/hub_connection_schema() + new_gatt_backend(). """ -import functools +from collections.abc import Awaitable, Callable +from dataclasses import dataclass import esphome.codegen as cg -from esphome.config_helpers import filter_source_files_from_platform -from esphome.const import PLATFORM_RP2, PlatformFramework +from esphome.config_helpers import ( + filter_source_files_from_platform, + frameworks_for_platforms, +) +import esphome.config_validation as cv +from esphome.const import PLATFORM_ESP32, PLATFORM_RP2, PlatformFramework from esphome.core import CORE +from esphome.types import ConfigType def AUTO_LOAD() -> list[str]: - """The esp32 connection header includes esp32_ble_client, so the closure - must be self-satisfying; no target platform (tooling) gets the union.""" - if CORE.is_esp32 or CORE.target_platform is None: - return ["ble_device_base", "esp32_ble_client"] + """ble_device_base plus the platform BLE stack the build's backend + registers with (the Bluedroid header includes the tracker's), so + consumers need not know. The platform-less arm serves manifest tooling.""" + if CORE.is_esp32: + return ["ble_device_base", "esp32_ble_tracker"] + if CORE.is_rp2: + return ["ble_device_base", "rp2040_ble"] + if CORE.target_platform is None: + return ["ble_device_base", "esp32_ble_tracker", "rp2040_ble"] return ["ble_device_base"] @@ -29,39 +40,134 @@ bluetooth_connection_ns = cg.esphome_ns.namespace("bluetooth_connection") # raising this needs an upstream change (the layer itself supports N). RP2_MAX_CONNECTIONS = 1 -# Hub platforms with a GATT backend, mapped to their slot limit — the single -# registry of which hub platforms run the connection-capable proxy. +# Slot limits for the hub platforms running the connection-capable proxy; +# the backend registry itself is _PLATFORM_BACKENDS below. HUB_MAX_CONNECTIONS: dict[str, int] = {PLATFORM_RP2: RP2_MAX_CONNECTIONS} -# The hub-platform wrapper and the rp2 BTstack backend codegen classes. +# The hub-platform wrapper and the backend codegen classes. HubBluetoothConnection = bluetooth_connection_ns.class_("BluetoothConnection") RP2GattClient = bluetooth_connection_ns.class_("RP2GattClient", cg.Component) +BluedroidGattClient = bluetooth_connection_ns.class_( + "BluedroidGattClient", cg.Component +) + +CONF_BACKEND_ID = "backend_id" -@functools.cache -def esp32_connection_class() -> cg.MockObjClass: - """Lazy: importing esp32_ble_client registers esp32-only automations as - an import side effect, which must not leak into other platforms.""" - from esphome.components import esp32_ble_client +def _esp32_schema_fragment() -> cv.Schema: + from esphome.components import esp32_ble_tracker - return bluetooth_connection_ns.class_( - "BluetoothConnection", esp32_ble_client.BLEClientBase + return esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA + + +def _rp2_schema_fragment() -> cv.Schema: + from esphome.components import rp2040_ble + + return cv.Schema( + {cv.GenerateID(rp2040_ble.CONF_RP2040_BLE_ID): cv.use_id(rp2040_ble.RP2040BLE)} ) -FILTER_SOURCE_FILES = filter_source_files_from_platform( - { - "bluetooth_connection_esp32.cpp": { - PlatformFramework.ESP32_ARDUINO, - PlatformFramework.ESP32_IDF, - }, - # Every hub platform the proxy admits (the file compiles empty where - # USE_BLE_GATT_CLIENT is not defined), so a platform gaining a backend - # cannot hit a missing-symbol trap here. - "bluetooth_connection_hub.cpp": { - PlatformFramework.RP2_ARDUINO, - PlatformFramework.LN882X_ARDUINO, - }, - "bluetooth_connection_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, - } -) +async def _esp32_register(backend: cg.MockObj, config: ConfigType) -> None: + from esphome.components import esp32_ble_tracker + + # The tracker's promote loop owns connect timing; the backend registers + # as a raw client (it is the tracker's ESPBTClient). + await esp32_ble_tracker.register_raw_client(backend, config) + + +async def _rp2_register(backend: cg.MockObj, config: ConfigType) -> None: + from esphome.components import rp2040_ble + + await cg.register_parented(backend, config[rp2040_ble.CONF_RP2040_BLE_ID]) + + +@dataclass(frozen=True) +class _PlatformBackend: + """One platform's backend: codegen class, extra schema keys (lazy so the + platform stack is only imported when targeted), and stack registration.""" + + backend_class: cg.MockObjClass + schema_fragment: Callable[[], cv.Schema] + register: Callable[[cg.MockObj, ConfigType], Awaitable[None]] + + +# The single registry of platforms with a GATT client backend; a platform +# missing here fails loudly everywhere instead of falling into another +# platform's arm. +_PLATFORM_BACKENDS: dict[str, _PlatformBackend] = { + PLATFORM_ESP32: _PlatformBackend( + BluedroidGattClient, _esp32_schema_fragment, _esp32_register + ), + PLATFORM_RP2: _PlatformBackend(RP2GattClient, _rp2_schema_fragment, _rp2_register), +} + + +def _backend_entry(platform: str | None = None) -> _PlatformBackend: + key = platform if platform is not None else CORE.target_platform + if (entry := _PLATFORM_BACKENDS.get(key)) is None: + raise cv.Invalid(f"no GATT client backend is registered for {key}") + return entry + + +def gatt_client_schema(platform: str | None = None) -> cv.Schema: + """Schema fragment for one GATT backend instance: its generated id plus + the platform-stack reference new_gatt_backend() resolves. + + Defaults to the platform being validated; pass `platform` explicitly when + building a schema outside validation (the language-schema dumper calls + per-platform builders under arbitrary CORE platforms). + """ + entry = _backend_entry(platform) + return entry.schema_fragment().extend( + {cv.GenerateID(CONF_BACKEND_ID): cv.declare_id(entry.backend_class)} + ) + + +def hub_connection_schema(platform: str | None = None) -> cv.Schema: + """Per-slot schema for the proxy's connection wrappers: the wrapper id on + top of the backend fragment, plus the component keys (setup_priority and + friends now apply to the backend, the slot's real Component). Same + platform rules as gatt_client_schema().""" + return ( + gatt_client_schema(platform) + .extend({cv.GenerateID(): cv.declare_id(HubBluetoothConnection)}) + .extend(cv.COMPONENT_SCHEMA) + ) + + +async def new_gatt_backend(config: ConfigType) -> cg.MockObj: + """Instantiate the backend declared by gatt_client_schema() and register + it with its platform stack. The connection slot is claimed at validation + (the proxy's slot validators), not here. + """ + from esphome.components import ble_device_base + + ble_device_base.request_gatt_client() + backend = cg.new_Pvariable(config[CONF_BACKEND_ID]) + # The backend is the slot's real Component: component keys from the + # connection entry (setup_priority, ...) apply to it. Consumers whose own + # schema carries keys that register_component would misapply to the + # backend (e.g. a polling interval) must not put them in this config. + await cg.register_component(backend, config) + await _backend_entry().register(backend, config) + return backend + + +# Named so tests can pin the hub entry against bluetooth_proxy's platform +# list (this module cannot import bluetooth_proxy to derive it). +SOURCE_FILE_FRAMEWORKS: dict[str, set[PlatformFramework]] = { + "bluetooth_connection_bluedroid.cpp": frameworks_for_platforms([PLATFORM_ESP32]), + # Every hub platform the proxy admits (the file compiles empty where + # USE_BLE_GATT_CLIENT is not defined), so a platform gaining a backend + # cannot hit a missing-symbol trap here. + "bluetooth_connection_hub.cpp": { + PlatformFramework.RP2_ARDUINO, + PlatformFramework.LN882X_ARDUINO, + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, + "bluetooth_connection_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, +} + +FILTER_SOURCE_FILES = filter_source_files_from_platform(SOURCE_FILE_FRAMEWORKS) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.cpp b/esphome/components/bluetooth_connection/bluetooth_connection.cpp index 57833edbd2..94bb119c84 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection.cpp @@ -1,5 +1,10 @@ #include "bluetooth_connection.h" +#ifdef USE_ESP32 +#include +#include +#endif + #ifdef BLUETOOTH_CONNECTION_HAS_GATT #include "esphome/components/api/api_pb2.h" @@ -40,3 +45,25 @@ BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size } // namespace esphome::bluetooth_connection #endif // BLUETOOTH_CONNECTION_HAS_GATT + +#ifdef USE_ESP32 +namespace esphome::bluetooth_connection { + +// Address-scoped Bluedroid maintenance shared by every esp32 proxy build, +// including advertisement-only ones where no GATT backend (and none of the +// gated surface above) is compiled - so this block sits outside that gate. + +conn_err_t unpair_device(uint64_t address) { + esp_bd_addr_t bda; + ble_device_base::uint64_to_mac_msb_first(address, bda); + return esp_ble_remove_bond_device(bda); +} + +conn_err_t clear_gatt_cache(uint64_t address) { + esp_bd_addr_t bda; + ble_device_base::uint64_to_mac_msb_first(address, bda); + return esp_ble_gattc_cache_clean(bda); +} + +} // namespace esphome::bluetooth_connection +#endif // USE_ESP32 diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h index 2125d5b34f..5052e7eca1 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -16,10 +16,15 @@ #include #endif -// A GATT connection backend exists in this build: esp32 (Bluedroid) or a hub -// platform with the neutral GATT client compiled in. Single-sourced here so -// the proxy and this component cannot drift. -#if defined(USE_ESP32) || defined(USE_BLE_GATT_CLIENT) +// The connection-aware API request handlers are compiled: a GATT backend is +// wired by codegen (one slot per connection). This is the single spelling of +// that predicate - the hub wrapper and the API request handlers gate on it. +// The wrapper serves the proxy's API surface, so it compiles only when a +// backend AND the proxy are present; advertisement-only and backend-only +// builds get the clean-error handlers instead. Address-scoped maintenance +// (unpair, cache clear) still works there through the per-platform free +// functions below. +#if defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUETOOTH_PROXY) #define BLUETOOTH_CONNECTION_HAS_GATT #endif diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp new file mode 100644 index 0000000000..f24d261c57 --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -0,0 +1,772 @@ +#include "bluetooth_connection_bluedroid.h" + +#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT) + +// The in-place streamer serves the proxy's service-discovery API; backend-only +// builds compile without the proxy headers or the streamer. +#ifdef USE_BLUETOOTH_PROXY +#include "bluetooth_connection.h" +#include "bluetooth_connection_hub.h" + +#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" +#endif + +#include "esphome/components/ble_device_base/ble_client_state.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::bluetooth_connection { + +static const char *const TAG = "bluetooth_connection.bluedroid"; + +using ble_device_base::FAST_CONN_TIMEOUT; +using ble_device_base::FAST_MAX_CONN_INTERVAL; +using ble_device_base::FAST_MIN_CONN_INTERVAL; +using ble_device_base::MEDIUM_CONN_TIMEOUT; +using ble_device_base::MEDIUM_MAX_CONN_INTERVAL; +using ble_device_base::MEDIUM_MIN_CONN_INTERVAL; +using esp32_ble_tracker::ClientState; +using esp32_ble_tracker::ConnectionType; + +// ---- tracker surface ---- + +void BluedroidGattClient::connect() { this->tracker_connect_(); } +void BluedroidGattClient::disconnect() { this->gatt_disconnect(); } + +// ---- component ---- + +void BluedroidGattClient::setup() { + static uint8_t connection_index = 0; + this->connection_index_ = connection_index++; +} + +void BluedroidGattClient::loop() { + if (!esp32_ble::global_ble->is_active()) { + // Stack down: no CLOSE_EVT will come. Settle a live link so the consumer + // frees its slot, then re-register the app on the next enable. + auto down_st = this->state(); + if (down_st != ClientState::IDLE && down_st != ClientState::INIT) { + this->release_services(); + this->set_idle_(); + this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED); + } + this->set_state(ClientState::INIT); + return; + } + auto st = this->state(); + if (st == ClientState::INIT) { + // Parity with BLEClientBase: a failed registration marks the slot + // failed and idles it without retry. + auto ret = esp_ble_gattc_app_register(this->app_id); + if (ret) { + ESP_LOGE(TAG, "gattc app register failed: app_id=%d code=%d", this->app_id, ret); + this->mark_failed(); + } + // Do not wait for REG_EVT; a dropped event must not wedge the slot. + this->set_idle_(); + } else if (st == ClientState::DISCONNECTING || this->disconnect_pending()) { + // The one teardown safety net: a lost CLOSE_EVT, or a scheduled + // teardown whose OPEN_EVT never arrives. + if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) { + ESP_LOGE(TAG, "[%d] Timeout waiting for teardown, forcing IDLE", this->connection_index_); + // Release before idling: a lost completion must not leak the cache. + this->release_services(); + this->set_idle_(); // also clears want_disconnect_ + this->listener_->on_connection_state(false, 0, ESP_GATT_CONN_TIMEOUT); + } + } else { + // The loop stays on while a link exists (stack-down watch, pre-started + // search flush); it settles only back at IDLE. + this->deliver_pending_search_(); + if (this->state() == ClientState::IDLE) { + this->disable_loop(); + } + } +} + +void BluedroidGattClient::dump_config() { + ESP_LOGCONFIG(TAG, "Bluedroid GATT client %d", this->connection_index_); + if (this->is_failed()) { + ESP_LOGE(TAG, " Registration failed; if the error was ESP_GATT_NO_RESOURCES, reduce the connection slots"); + } +} + +// ---- contract ops ---- + +int BluedroidGattClient::connect(uint64_t address, uint8_t addr_type) { + // Only from idle: clobbering DISCONNECTING would open a new link the + // stale CLOSE_EVT then tears down. + if (this->state() != ClientState::IDLE) { + ESP_LOGW(TAG, "[%d] Connect rejected, slot busy", this->connection_index_); + return ESP_GATT_BUSY; + } + ble_device_base::uint64_to_mac_msb_first(address, this->remote_bda_); + this->remote_addr_type_ = addr_type; + // Hand the request to the tracker's promote loop: it stops the scan, raises + // coex, and calls tracker_connect_() - the tracker owns connect timing here. + this->set_state(ClientState::DISCOVERED); + return 0; +} + +void BluedroidGattClient::tracker_connect_() { + auto st = this->state(); + if (st == ClientState::CONNECTING || st == ClientState::CONNECTED || st == ClientState::ESTABLISHED) { + ESP_LOGW(TAG, "[%d] Connection already in progress", this->connection_index_); + return; + } + if (st == ClientState::DISCONNECTING) { + ESP_LOGW(TAG, "[%d] Cannot connect, still waiting for CLOSE_EVT", this->connection_index_); + return; + } + ESP_LOGI(TAG, "[%d] 0x%02x Connecting", this->connection_index_, this->remote_addr_type_); + // Per-attempt latches; the search machine is reset by set_idle_(), the + // one door back to IDLE. + this->services_released_ = false; + this->seen_mtu_ = false; + this->mtu_failed_ = false; + this->enable_loop(); + this->set_state(ClientState::CONNECTING); + if (this->connection_type_ == ConnectionType::V3_WITHOUT_CACHE) { + // Fast params for the discovery phase; stepped down at SEARCH_CMPL. + this->check_and_log_error_("esp_ble_gap_set_prefer_conn_params", + esp_ble_gap_set_prefer_conn_params(this->remote_bda_, FAST_MIN_CONN_INTERVAL, + FAST_MAX_CONN_INTERVAL, 0, FAST_CONN_TIMEOUT)); + } else { + this->check_and_log_error_("esp_ble_gap_set_prefer_conn_params", + esp_ble_gap_set_prefer_conn_params(this->remote_bda_, MEDIUM_MIN_CONN_INTERVAL, + MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT)); + } + auto ret = esp_ble_gattc_open(this->gattc_if_, this->remote_bda_, + static_cast(this->remote_addr_type_), true); + if (ret) { + this->log_gattc_warning_("esp_ble_gattc_open", ret); + // CONNECT_EVT never fired; nothing to close. + this->set_idle_(); + this->listener_->on_connection_state(false, 0, ret); + } +} + +int BluedroidGattClient::gatt_disconnect() { + auto st = this->state(); + if (st == ClientState::DISCONNECTING) { + return 0; + } + // Nothing was opened, so no completion event will follow: report + // not-connected and the hub frees the slot at once (rp2 convention). + if (st == ClientState::IDLE) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + if (st == ClientState::DISCOVERED) { + // Parked for the tracker promote loop, never opened. + this->set_idle_(); + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + if (st == ClientState::CONNECTING || this->conn_id_ == UNSET_CONN_ID) { + ESP_LOGD(TAG, "[%d] Disconnect scheduled", this->connection_index_); + this->want_disconnect_ = true; + // Arm the safety window: a lost OPEN_EVT must not leak the teardown. + this->disconnecting_started_ = millis(); + this->enable_loop(); + return 0; + } + this->unconditional_disconnect_(); + return 0; +} + +void BluedroidGattClient::unconditional_disconnect_() { + ESP_LOGI(TAG, "[%d] Disconnecting (conn_id: %d)", this->connection_index_, this->conn_id_); + if (this->conn_id_ == UNSET_CONN_ID) { + // Terminal state now rather than leaning on the scheduled-teardown timer. + ESP_LOGE(TAG, "[%d] conn id unset, cannot disconnect", this->connection_index_); + this->release_services(); + this->set_idle_(); + this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED); + return; + } + auto err = esp_ble_gattc_close(this->gattc_if_, this->conn_id_); + if (err != ESP_OK) { + // The stack is now in an indeterminate state for this link. + ESP_LOGE(TAG, "[%d] esp_ble_gattc_close error: %d", this->connection_index_, err); + } + this->set_disconnecting_(); +} + +bool BluedroidGattClient::cancel_gatt_disconnect() { + // Only a scheduled teardown (want_disconnect_ latched while the open is + // still in flight) is cancellable; once closing started the terminal + // report settles the race. + if (this->state() != ClientState::CONNECTING || !this->disconnect_pending()) { + return false; + } + this->want_disconnect_ = false; + return true; +} + +int BluedroidGattClient::discover_services() { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + switch (this->search_state_) { + case SearchState::PRESTARTED: + // The pending SEARCH_CMPL reports once it lands. + this->search_state_ = SearchState::CLAIMED; + return 0; + case SearchState::PRESTART_DONE: + // Already landed: the flush after the connected report delivers + // (loop() covers a claim made outside that event drain). + this->search_state_ = SearchState::REPORT_PENDING; + this->enable_loop(); + return 0; + case SearchState::CLAIMED: + case SearchState::REPORT_PENDING: + return 0; // One completion is already owed to this claimant. + case SearchState::NONE: + break; + } + int err = this->check_and_log_error_("esp_ble_gattc_search_service", + esp_ble_gattc_search_service(this->gattc_if_, this->conn_id_, nullptr)); + if (err == 0) { + this->search_state_ = SearchState::CLAIMED; + } + return err; +} + +int BluedroidGattClient::read_characteristic(uint16_t handle) { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + return this->check_and_log_error_("esp_ble_gattc_read_char", esp_ble_gattc_read_char(this->gattc_if_, this->conn_id_, + handle, ESP_GATT_AUTH_REQ_NONE)); +} + +int BluedroidGattClient::write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + // The BTC layer copies the payload immediately, so the const_cast is safe. + return this->check_and_log_error_( + "esp_ble_gattc_write_char", + esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, len, const_cast(data), + response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, + ESP_GATT_AUTH_REQ_NONE)); +} + +int BluedroidGattClient::read_descriptor(uint16_t handle) { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + return this->check_and_log_error_( + "esp_ble_gattc_read_char_descr", + esp_ble_gattc_read_char_descr(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE)); +} + +int BluedroidGattClient::write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + return this->check_and_log_error_( + "esp_ble_gattc_write_char_descr", + esp_ble_gattc_write_char_descr(this->gattc_if_, this->conn_id_, handle, len, const_cast(data), + ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE)); +} + +int BluedroidGattClient::notify_characteristic(uint16_t handle, bool enable) { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + // Local registration only; the CCCD write is the API client's responsibility. + if (enable) { + return this->check_and_log_error_("esp_ble_gattc_register_for_notify", + esp_ble_gattc_register_for_notify(this->gattc_if_, this->remote_bda_, handle)); + } + return this->check_and_log_error_("esp_ble_gattc_unregister_for_notify", + esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle)); +} + +int BluedroidGattClient::pair() { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + return esp_ble_set_encryption(this->remote_bda_, ESP_BLE_SEC_ENCRYPT); +} + +int BluedroidGattClient::update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout) { + return this->update_conn_params_(min_interval, max_interval, latency, timeout, "custom"); +} + +void BluedroidGattClient::release_services() { + this->service_total_ = 0; + // Always set: terminates any in-flight stream on every cache config. + this->services_released_ = true; +#ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH + // A failed clean leaves a stale database the next connection could serve + // as authoritative. A disabled stack invalidates its own cache; skip the + // meaningless call instead of warning on every OTA/ble.disable teardown. + if (esp32_ble::global_ble->is_active()) { + this->check_and_log_error_("esp_ble_gattc_cache_clean", esp_ble_gattc_cache_clean(this->remote_bda_)); + } +#endif +} + +// ---- internals ---- + +bool BluedroidGattClient::check_addr_(const esp_bd_addr_t &addr) const { + return memcmp(addr, this->remote_bda_, sizeof(esp_bd_addr_t)) == 0; +} + +void BluedroidGattClient::set_idle_() { + this->set_state(ClientState::IDLE); + this->conn_id_ = UNSET_CONN_ID; + this->search_state_ = SearchState::NONE; + this->search_status_ = 0; +} + +void BluedroidGattClient::set_disconnecting_() { + this->disconnecting_started_ = millis(); + this->set_state(ClientState::DISCONNECTING); + // The loop may be disabled while idle; the safety timeout needs it. + this->enable_loop(); +} + +esp_err_t BluedroidGattClient::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout, const char *param_type) { + esp_ble_conn_update_params_t conn_params = {{0}}; + memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); + conn_params.min_int = min_interval; + conn_params.max_int = max_interval; + conn_params.latency = latency; + conn_params.timeout = timeout; + ESP_LOGD(TAG, "[%d] %s conn params", this->connection_index_, param_type); + return this->check_and_log_error_("esp_ble_gap_update_conn_params", esp_ble_gap_update_conn_params(&conn_params)); +} + +int BluedroidGattClient::check_and_log_error_(const char *operation, esp_err_t err) { + if (err != ESP_OK) { + this->log_gattc_warning_(operation, err); + } + return err; +} + +void BluedroidGattClient::log_gattc_warning_(const char *operation, int code) { + ESP_LOGW(TAG, "[%d] %s failed, status=%d", this->connection_index_, operation, code); +} + +// ---- service streaming ---- + +int BluedroidGattClient::handle_search_cmpl_(esp_gatt_status_t status) { + // Step down from the fast discovery params. + this->update_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium"); + if (status != ESP_GATT_OK) { + // A failed discovery reads as a clean zero from the count calls below; + // honoring the event status stops it becoming an authoritative empty + // list. + return status; + } + uint16_t primary = 0; + uint16_t secondary = 0; + auto primary_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_PRIMARY_SERVICE, + 0x0001, 0xFFFF, 0, &primary); + auto secondary_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_SECONDARY_SERVICE, + 0x0001, 0xFFFF, 0, &secondary); + if (primary_status != ESP_GATT_OK || secondary_status != ESP_GATT_OK) { + // A failed count must not become an authoritative empty database. + auto count_status = primary_status != ESP_GATT_OK ? primary_status : secondary_status; + this->log_gattc_warning_("esp_ble_gattc_get_attr_count", count_status); + return count_status; + } + this->service_total_ = primary + secondary; + return 0; +} + +// Reports a completed search once claimed; delivery consumes the state so +// a re-discovery issues a real search. +void BluedroidGattClient::deliver_pending_search_() { + if (this->search_state_ != SearchState::REPORT_PENDING) + return; + this->search_state_ = SearchState::NONE; + this->listener_->on_service_discovery_done(this->search_status_); +} + +#ifdef USE_BLUETOOTH_PROXY +// The wrapper's compile-time streamer detection must keep finding this +// method; a signature drift would silently fall back to the table streamer, +// which proxy builds compile without a materializer. +static_assert(requires(BluedroidGattClient c, BluetoothConnection &conn) { c.stream_service_batch(conn); }); + +void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) { + if (this->services_released_) { + // Released under the stream: park without services-done so a partial + // list is never cached as authoritative (the client retries after its + // GetServices timeout). + ESP_LOGW(TAG, "[%d] [%s] Services released mid-stream, parking", conn.connection_index_, conn.address_str_); + conn.send_service_ = DONE_SENDING_SERVICES; + return; + } + if (conn.send_service_ >= this->service_total_) { + conn.send_service_ = DONE_SENDING_SERVICES; + conn.proxy_->send_gatt_services_done(conn.address_); + this->release_services(); + return; + } + + // The subscriber vanished mid-stream: park the cursor at done WITHOUT + // sending services-done (a resubscribing client gets silence and its 30 s + // timeout, never an authoritative partial list). + auto *api_conn = conn.proxy_->get_api_connection(); + if (api_conn == nullptr) { + ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", conn.connection_index_, conn.address_str_); + conn.send_service_ = DONE_SENDING_SERVICES; + this->release_services(); + return; + } + + bool use_efficient_uuids = conn.proxy_->client_supports_efficient_uuids(); + api::BluetoothGATTGetServicesResponse resp; + resp.address = conn.address_; + size_t current_size = resp.calculate_size(); + int16_t batch_start = conn.send_service_; + + while (conn.send_service_ < this->service_total_) { + esp_gattc_service_elem_t service_result; + uint16_t svc_count = 1; + esp_gatt_status_t svc_status = esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, &service_result, + &svc_count, conn.send_service_); + if (svc_status != ESP_GATT_OK || svc_count == 0) { + ESP_LOGE(TAG, "[%d] [%s] Service walk failed (service %d), aborting stream", conn.connection_index_, + conn.address_str_, conn.send_service_); + conn.abort_service_stream(svc_status != ESP_GATT_OK ? svc_status : ESP_GATT_NOT_FOUND); + return; + } + uint16_t total_char_count = 0; + auto char_count_status = + esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC, + service_result.start_handle, service_result.end_handle, 0, &total_char_count); + if (char_count_status != ESP_GATT_OK) { + this->log_gattc_warning_("esp_ble_gattc_get_attr_count", char_count_status); + conn.abort_service_stream(char_count_status); + return; + } + + // If this service likely won't fit, send the current batch first. + size_t estimated_size = estimate_service_size(total_char_count, use_efficient_uuids); + if (!resp.services.empty() && current_size + estimated_size > MAX_PACKET_SIZE) { + break; + } + + resp.services.emplace_back(); + auto &service_resp = resp.services.back(); + fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, + ble_device_base::ESPBTUUID::from_uuid(service_result.uuid), use_efficient_uuids); + service_resp.handle = service_result.start_handle; + + if (total_char_count > 0) { + service_resp.characteristics.init(total_char_count); + uint16_t char_offset = 0; + esp_gattc_char_elem_t char_result; + // Bounded by the count query: a misbehaving peripheral can make the + // enumeration return more entries than it reported. + while (char_offset < total_char_count) { + uint16_t cc = 1; + auto char_status = esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, + service_result.end_handle, &char_result, &cc, char_offset); + if (char_status != ESP_GATT_OK || cc == 0) { + // An early terminator contradicts the count from the same cache; + // never stream a silently truncated list. + this->log_gattc_warning_("esp_ble_gattc_get_all_char", char_status); + conn.abort_service_stream(char_status != ESP_GATT_OK ? char_status : ESP_GATT_NOT_FOUND); + return; + } + service_resp.characteristics.emplace_back(); + auto &characteristic_resp = service_resp.characteristics.back(); + fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, + ble_device_base::ESPBTUUID::from_uuid(char_result.uuid), use_efficient_uuids); + characteristic_resp.handle = char_result.char_handle; + characteristic_resp.properties = char_result.properties; + + uint16_t total_desc_count = 0; + auto desc_count_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, + 0, 0, char_result.char_handle, &total_desc_count); + if (desc_count_status != ESP_GATT_OK) { + // Abort rather than stream the characteristic descriptor-less: a + // missing CCCD in a cached database breaks notifications for good. + this->log_gattc_warning_("esp_ble_gattc_get_attr_count", desc_count_status); + conn.abort_service_stream(desc_count_status); + return; + } + if (total_desc_count > 0) { + characteristic_resp.descriptors.init(total_desc_count); + uint16_t desc_offset = 0; + esp_gattc_descr_elem_t desc_result; + while (desc_offset < total_desc_count) { + uint16_t dc = 1; + auto desc_status = esp_ble_gattc_get_all_descr(this->gattc_if_, this->conn_id_, char_result.char_handle, + &desc_result, &dc, desc_offset); + if (desc_status != ESP_GATT_OK || dc == 0) { + this->log_gattc_warning_("esp_ble_gattc_get_all_descr", desc_status); + conn.abort_service_stream(desc_status != ESP_GATT_OK ? desc_status : ESP_GATT_NOT_FOUND); + return; + } + characteristic_resp.descriptors.emplace_back(); + auto &descriptor_resp = characteristic_resp.descriptors.back(); + fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, + ble_device_base::ESPBTUUID::from_uuid(desc_result.uuid), use_efficient_uuids); + descriptor_resp.handle = desc_result.handle; + desc_offset++; + } + } + char_offset++; + } + } + + if (close_service_batch(resp, current_size, conn.send_service_, conn.connection_index_, conn.address_str_) != + BatchClose::CONTINUE) { + break; + } + } + + // On a failed send, rewind the cursor so the batch is retried instead of + // silently skipped. + if (!api_conn->send_message(resp)) { + ESP_LOGW(TAG, "[%d] [%s] Failed to send service batch, retrying", conn.connection_index_, conn.address_str_); + conn.send_service_ = batch_start; + } +} +#endif // USE_BLUETOOTH_PROXY + +// ---- events ---- + +void BluedroidGattClient::handle_open_evt_(esp_ble_gattc_cb_param_t *param) { + auto st = this->state(); + if (st == ClientState::IDLE) { + // Late OPEN_EVT after the slot went IDLE (open-error race, or the + // teardown net gave up): close a won link, never resurrect the slot. + ESP_LOGD(TAG, "[%d] OPEN_EVT in IDLE state (status=%d)", this->connection_index_, param->open.status); + if (param->open.status == ESP_GATT_OK || param->open.status == ESP_GATT_ALREADY_OPEN) { + // A failed close here leaks a live link nothing tracks; make it heard. + this->check_and_log_error_("esp_ble_gattc_close", esp_ble_gattc_close(this->gattc_if_, param->open.conn_id)); + } + return; + } + if (st != ClientState::CONNECTING) { + ESP_LOGE(TAG, "[%d] OPEN_EVT in unexpected state", this->connection_index_); + } + if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { + this->log_gattc_warning_("Connection open", param->open.status); + // Never established, CLOSE_EVT may not follow. + this->set_idle_(); + this->listener_->on_connection_state(false, 0, param->open.status); + return; + } + if (this->disconnect_pending()) { + // Open resolved with a teardown scheduled: close now (conn_id_ stays set + // so CLOSE_EVT still matches). + this->unconditional_disconnect_(); + return; + } + this->set_state(ClientState::CONNECTED); + ESP_LOGI(TAG, "[%d] Connection open", this->connection_index_); + if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) { + this->set_state(ClientState::ESTABLISHED); + // No discovery phase: report immediately with the default MTU. The + // cached path never waits for (or reports) the exchange - seen_mtu_ + // suppresses the CFG_MTU report, matching the previous esp32 behavior. + this->seen_mtu_ = true; + this->listener_->on_connection_state(true, ble_device_base::DEFAULT_ATT_MTU, 0); + } else { + // Discovery-bound connection: start the search now so it overlaps the + // MTU exchange. On a refusal fall back to the serialized path - the + // consumer's own discover_services() call retries the real search. + if (this->check_and_log_error_("esp_ble_gattc_search_service", + esp_ble_gattc_search_service(this->gattc_if_, param->open.conn_id, nullptr)) == 0) { + this->search_state_ = SearchState::PRESTARTED; + } + if (this->mtu_failed_ && !this->seen_mtu_) { + // Refused MTU request: report with the default so the consumer + // proceeds. + this->seen_mtu_ = true; + this->listener_->on_connection_state(true, ble_device_base::DEFAULT_ATT_MTU, 0); + this->deliver_pending_search_(); + } + } +} + +void BluedroidGattClient::handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param) { + if (param->disconnect.reason == ESP_GATT_CONN_TERMINATE_PEER_USER && this->state() == ClientState::CONNECTED) { + ESP_LOGW(TAG, "[%d] Remote closed during discovery", this->connection_index_); + } else { + ESP_LOGD(TAG, "[%d] DISCONNECT_EVT reason=0x%02x", this->connection_index_, param->disconnect.reason); + } + if (this->state() == ClientState::IDLE) { + // Active close delivers CLOSE_EVT first; never walk back to DISCONNECTING. + return; + } + // Passive disconnect: wait for CLOSE_EVT before going IDLE (reconnecting + // earlier makes the controller reject with 133 or assert) and before + // reporting - the wrapper frees the slot on the report, and a freed slot + // invites a reconnect into the still-closing link. + this->release_services(); + this->set_disconnecting_(); +} + +bool BluedroidGattClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if, + esp_ble_gattc_cb_param_t *param) { + if (event == ESP_GATTC_REG_EVT && this->app_id != param->reg.app_id) + return false; + if (event != ESP_GATTC_REG_EVT && esp_gattc_if != ESP_GATT_IF_NONE && esp_gattc_if != this->gattc_if_) + return false; + + switch (event) { + case ESP_GATTC_REG_EVT: { + if (param->reg.status == ESP_GATT_OK) { + this->gattc_if_ = esp_gattc_if; + } else { + ESP_LOGE(TAG, "[%d] gattc app registration failed, status=%d", this->connection_index_, param->reg.status); + this->mark_failed(); + } + break; + } + case ESP_GATTC_CONNECT_EVT: { + if (!this->check_addr_(param->connect.remote_bda)) + return false; + this->conn_id_ = param->connect.conn_id; + // MTU request here rather than OPEN_EVT, matching the IDF examples. + auto ret = esp_ble_gattc_send_mtu_req(this->gattc_if_, param->connect.conn_id); + if (ret) { + this->log_gattc_warning_("esp_ble_gattc_send_mtu_req", ret); + // No CFG_MTU_EVT will follow; OPEN_EVT reports with the default. + this->mtu_failed_ = true; + } + break; + } + case ESP_GATTC_OPEN_EVT: { + if (!this->check_addr_(param->open.remote_bda)) + return false; + this->handle_open_evt_(param); + break; + } + case ESP_GATTC_CFG_MTU_EVT: { + if (this->conn_id_ != param->cfg_mtu.conn_id) + return false; + if (param->cfg_mtu.status != ESP_GATT_OK) { + // Warn only; a disconnect will follow if the link is dead. + this->log_gattc_warning_("MTU exchange", param->cfg_mtu.status); + } + if (!this->seen_mtu_ && !this->disconnect_pending() && this->state() != ClientState::DISCONNECTING) { + // Teardown owns the link: suppress the connected report here like + // OPEN_EVT and SEARCH_CMPL do; the terminal report settles it. + this->seen_mtu_ = true; + // The connected report waited for the MTU; forwarded, not stored. + this->listener_->on_connection_state( + true, param->cfg_mtu.status == ESP_GATT_OK ? param->cfg_mtu.mtu : ble_device_base::DEFAULT_ATT_MTU, 0); + // The consumer requests discovery from inside that report; when the + // pre-started search already finished, complete it in the same drain. + this->deliver_pending_search_(); + } + break; + } + case ESP_GATTC_DISCONNECT_EVT: { + if (!this->check_addr_(param->disconnect.remote_bda)) + return false; + this->handle_disconnect_evt_(param); + break; + } + case ESP_GATTC_CLOSE_EVT: { + if (this->conn_id_ != param->close.conn_id) + return false; + this->release_services(); + this->set_idle_(); + // The one connected=false report: the wrapper frees the slot on it, + // so it must not fire before the controller finished closing. + this->listener_->on_connection_state(false, 0, param->close.reason); + break; + } + case ESP_GATTC_SEARCH_CMPL_EVT: { + if (this->conn_id_ != param->search_cmpl.conn_id) + return false; + ESP_LOGI(TAG, "[%d] Service discovery complete", this->connection_index_); + if (this->state() == ClientState::DISCONNECTING) { + // Teardown owns the link; the result is never delivered, skip the + // work. + break; + } + this->search_status_ = this->handle_search_cmpl_(static_cast(param->search_cmpl.status)); + this->search_state_ = + this->search_state_ == SearchState::CLAIMED ? SearchState::REPORT_PENDING : SearchState::PRESTART_DONE; + this->set_state(ClientState::ESTABLISHED); + this->deliver_pending_search_(); + break; + } + case ESP_GATTC_READ_CHAR_EVT: + case ESP_GATTC_READ_DESCR_EVT: { + if (this->conn_id_ != param->read.conn_id) + return false; + bool ok = param->read.status == ESP_GATT_OK; + this->listener_->on_read_result(param->read.handle, ok ? param->read.value : nullptr, + ok ? param->read.value_len : 0, ok ? 0 : param->read.status); + break; + } + case ESP_GATTC_WRITE_CHAR_EVT: + case ESP_GATTC_WRITE_DESCR_EVT: { + if (this->conn_id_ != param->write.conn_id) + return false; + this->listener_->on_write_result(param->write.handle, + param->write.status == ESP_GATT_OK ? 0 : param->write.status); + break; + } + case ESP_GATTC_REG_FOR_NOTIFY_EVT: { + this->listener_->on_notify_state(param->reg_for_notify.handle, true, + param->reg_for_notify.status == ESP_GATT_OK ? 0 : param->reg_for_notify.status); + break; + } + case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: { + this->listener_->on_notify_state( + param->unreg_for_notify.handle, false, + param->unreg_for_notify.status == ESP_GATT_OK ? 0 : param->unreg_for_notify.status); + break; + } + case ESP_GATTC_NOTIFY_EVT: { + if (this->conn_id_ != param->notify.conn_id) + return false; + ESP_LOGV(TAG, "[%d] NOTIFY_EVT handle=0x%2X", this->connection_index_, param->notify.handle); + this->listener_->on_notify_data(param->notify.handle, param->notify.value, param->notify.value_len); + break; + } + default: + break; + } + return true; +} + +void BluedroidGattClient::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { + switch (event) { + case ESP_GAP_BLE_SEC_REQ_EVT: { + if (!this->check_addr_(param->ble_security.auth_cmpl.bd_addr)) + break; + // Always accept; a refused response means no AUTH_CMPL, so answer the + // pairing request with the failure. + int sec_err = this->check_and_log_error_("esp_ble_gap_security_rsp", + esp_ble_gap_security_rsp(param->ble_security.ble_req.bd_addr, true)); + if (sec_err != 0) { + this->listener_->on_pairing_result(sec_err); + } + break; + } + case ESP_GAP_BLE_AUTH_CMPL_EVT: { + if (!this->check_addr_(param->ble_security.auth_cmpl.bd_addr)) + break; + this->listener_->on_pairing_result( + param->ble_security.auth_cmpl.success ? 0 : param->ble_security.auth_cmpl.fail_reason); + break; + } + default: + break; + } +} + +} // namespace esphome::bluetooth_connection + +#endif // USE_ESP32_BLE && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h new file mode 100644 index 0000000000..19b89ea5cd --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h @@ -0,0 +1,142 @@ +// Bluedroid (esp32) GATT client backend: the esp32 arm of the +// ble_device_base::BLEGattConnection alias for the hub BluetoothConnection +// wrapper. Not a BLEClientBase: the tracker's promote loop owns +// scan-stop/coex/one-connect-at-a-time, so the contract's connect() only +// parks the address in DISCOVERED; the real esp_ble_gattc_open happens in +// the tracker-invoked connect() override. + +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT) + +#include "esphome/components/ble_device_base/ble_gatt_client.h" +#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/core/component.h" + +#include +#include + +namespace esphome::bluetooth_connection { + +#ifdef USE_BLUETOOTH_PROXY +class BluetoothConnection; +#endif + +// One class carries both halves: the tracker's ESPBTClient surface (its +// promote loop owns scan-stop/coex/one-connect-at-a-time and calls the +// virtual connect()/disconnect()) and the neutral contract ops. The +// contract's teardown op is named gatt_disconnect() because the tracker's +// void disconnect() cannot overload with an int-returning twin. +class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public Component { + public: + static constexpr uint16_t UNSET_CONN_ID = 0xFFFF; + + // Lifecycle of one connection attempt's service search. + enum class SearchState : uint8_t { + NONE, // no search this attempt + PRESTARTED, // issued at OPEN_EVT, no claimant yet + PRESTART_DONE, // completed with search_status_ latched, no claimant yet + CLAIMED, // in flight with a claimant (pre-started or direct) + REPORT_PENDING // completed and claimed: deliver on the next flush + }; + + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; } + + // Wired by codegen before setup and invariant for the device lifetime. + void set_listener(ble_device_base::GattClientListener *listener) { this->listener_ = listener; } + + // ---- esp32_ble_tracker::ESPBTClient ---- + bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t *param) override; + void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; + void connect() override; + void disconnect() override; + bool wants_parsed_advertisements() override { return false; } + void on_scan_end() override {} + bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; } + + // ---- ble_device_base::BLEGattConnection contract ---- + int connect(uint64_t address, uint8_t addr_type); + int gatt_disconnect(); + bool cancel_gatt_disconnect(); + int discover_services(); + int read_characteristic(uint16_t handle); + int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response); + int read_descriptor(uint16_t handle); + int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len); + int notify_characteristic(uint16_t handle, bool enable); + int pair(); + int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout); + // Contract stub: the proxy streams in place; the on-demand materializer + // for direct consumers lands with #18205. NOTE: a direct consumer reaching + // this stub gets an empty table indistinguishable from a service-less + // peer - do not ship one against this backend before the materializer. + ble_device_base::GattServiceTable get_service_table() { return {}; } + void release_services(); + +#ifdef USE_BLUETOOTH_PROXY + /// In-place service streamer (the proxy wrapper detects and prefers it): + /// builds one api response batch directly from Bluedroid's cached database, + /// so the streaming peak is the response itself - the old esp32 model. + void stream_service_batch(BluetoothConnection &conn); +#endif + + void set_connection_type(ble_device_base::ConnectionType ct) { this->connection_type_ = ct; } + + protected: + bool check_addr_(const esp_bd_addr_t &addr) const; + void tracker_connect_(); + void handle_open_evt_(esp_ble_gattc_cb_param_t *param); + void handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param); + int handle_search_cmpl_(esp_gatt_status_t status); + void deliver_pending_search_(); + void unconditional_disconnect_(); + void set_idle_(); + void set_disconnecting_(); + esp_err_t update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, + const char *param_type); + int check_and_log_error_(const char *operation, esp_err_t err); + void log_gattc_warning_(const char *operation, int code); + + // Group 1: pointers / composed objects + ble_device_base::GattClientListener *listener_{nullptr}; + // Group 2: 4-byte types + uint32_t disconnecting_started_{0}; + + // Group 3: arrays + esp_bd_addr_t remote_bda_{}; + + // Group 4: 2-byte types + uint16_t conn_id_{UNSET_CONN_ID}; + uint16_t service_total_{0}; + + // Group 5: 1-byte types + esp_gatt_if_t gattc_if_{ESP_GATT_IF_NONE}; // uint8_t width keeps the object at 48 bytes + // Stored narrow (the enum is 4 bytes); widened at the esp_ble_gattc_open call. + uint8_t remote_addr_type_{0}; + esp32_ble_tracker::ConnectionType connection_type_{esp32_ble_tracker::ConnectionType::V3_WITHOUT_CACHE}; + uint8_t connection_index_{0}; + // Terminates an in-flight stream (never send a partial list as authoritative) + // and marks a cleaned cache unsafe to walk (Bluedroid asserts). + bool services_released_ : 1 {false}; + // The connected report waits for the MTU exchange; OPEN_EVT alone would + // hand HA the default 23. + bool seen_mtu_ : 1 {false}; + // The MTU request was refused at CONNECT_EVT; OPEN_EVT reports instead. + bool mtu_failed_ : 1 {false}; + // Search issued at OPEN_EVT overlaps the MTU exchange; discover_services() + // completes from it. Reset by set_idle_(). + static_assert(static_cast(SearchState::REPORT_PENDING) < (1 << 4), "search_state_ bitfield too narrow"); + SearchState search_state_ : 4 {SearchState::NONE}; + // esp_gatt_status_t of the completed search, held until claimed. + uint8_t search_status_{0}; +}; + +} // namespace esphome::bluetooth_connection + +#endif // USE_ESP32_BLE && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp deleted file mode 100644 index f5c59ca43a..0000000000 --- a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp +++ /dev/null @@ -1,484 +0,0 @@ -#include "bluetooth_connection_esp32.h" - -#include "esphome/components/api/api_pb2.h" -#include "esphome/core/helpers.h" -#include "esphome/core/log.h" - -#ifdef USE_ESP32 - -#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" - -namespace esphome::bluetooth_connection { - -namespace espbt = esphome::esp32_ble_tracker; - -using ble_device_base::ESPBTUUID; - -static const char *const TAG = "bluetooth_connection"; - -conn_err_t unpair_device(uint64_t address) { - esp_bd_addr_t bd_addr; - ble_device_base::uint64_to_mac_msb_first(address, bd_addr); - return esp_ble_remove_bond_device(bd_addr); -} - -conn_err_t clear_gatt_cache(uint64_t address) { - esp_bd_addr_t bd_addr; - ble_device_base::uint64_to_mac_msb_first(address, bd_addr); - return esp_ble_gattc_cache_clean(bd_addr); -} - -void BluetoothConnection::dump_config() { - ESP_LOGCONFIG(TAG, "BLE Connection:"); - BLEClientBase::dump_config(); -} - -void BluetoothConnection::set_address(uint64_t address) { - // Keep the proxy's pre-allocated connections-free message in step - this->proxy_->update_address_slot_(this->address_, address); - // Call parent implementation to actually set the address - BLEClientBase::set_address(address); -} - -void BluetoothConnection::loop() { - BLEClientBase::loop(); - - // Early return if no active connection - if (this->address_ == 0) { - return; - } - - // Handle service discovery if in valid range - if (this->send_service_ >= 0 && this->send_service_ <= this->service_count_) { - this->send_service_for_discovery_(); - } - - // Check if we should disable the loop - // - For V3_WITH_CACHE: Services are never sent, disable after INIT state - // - For V3_WITHOUT_CACHE: Disable only after service discovery is complete - // (send_service_ == DONE_SENDING_SERVICES, which is only set after services are sent) - // Never disable while DISCONNECTING — BLEClientBase::loop() needs to keep running so the - // 10s safety timeout can force IDLE if CLOSE_EVT is never delivered. - if (this->state() != espbt::ClientState::INIT && this->state() != espbt::ClientState::DISCONNECTING && - (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || - this->send_service_ == DONE_SENDING_SERVICES)) { - this->disable_loop(); - } -} - -void BluetoothConnection::on_disconnect_complete(esp_err_t reason) { - // Called from both the CLOSE_EVT handler and the DISCONNECTING safety timeout in the - // base class. Free the proxy slot, notify the API client, and reset send_service_. - // address_ may already be 0 if reset_connection_ ran earlier on this teardown. - if (this->address_ == 0) { - return; - } - ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, reason); - this->reset_connection_(reason); -} - -void BluetoothConnection::reset_connection_(esp_err_t reason) { this->proxy_->reset_connection_slot_(this, reason); } - -void BluetoothConnection::send_service_for_discovery_() { - if (this->send_service_ >= this->service_count_) { - this->send_service_ = DONE_SENDING_SERVICES; - this->proxy_->send_gatt_services_done(this->address_); - this->release_services(); - return; - } - - // Early return if no API connection - auto *api_conn = this->proxy_->get_api_connection(); - if (api_conn == nullptr) { - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - - // Check if client supports efficient UUIDs - bool use_efficient_uuids = this->proxy_->client_supports_efficient_uuids(); - - // Prepare response - api::BluetoothGATTGetServicesResponse resp; - resp.address = this->address_; - - // Dynamic batching based on actual size - // Keep running total of actual message size - size_t current_size = resp.calculate_size(); - int16_t batch_start = this->send_service_; - - while (this->send_service_ < this->service_count_) { - esp_gattc_service_elem_t service_result; - uint16_t service_count = 1; - esp_gatt_status_t service_status = esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, - &service_result, &service_count, this->send_service_); - - if (service_status != ESP_GATT_OK || service_count == 0) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_service %s, status=%d, service_count=%d, offset=%d", - this->connection_index_, this->address_str(), service_status != ESP_GATT_OK ? "error" : "missing", - service_status, service_count, this->send_service_); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - - // Get the number of characteristics BEFORE adding to response - uint16_t total_char_count = 0; - esp_gatt_status_t char_count_status = - esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC, - service_result.start_handle, service_result.end_handle, 0, &total_char_count); - - if (char_count_status != ESP_GATT_OK) { - this->log_connection_error_("esp_ble_gattc_get_attr_count", char_count_status); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - - // If this service likely won't fit, send current batch (unless it's the first) - size_t estimated_size = estimate_service_size(total_char_count, use_efficient_uuids); - if (!resp.services.empty() && (current_size + estimated_size > MAX_PACKET_SIZE)) { - // This service likely won't fit, send current batch - break; - } - - // Now add the service since we know it will likely fit - resp.services.emplace_back(); - auto &service_resp = resp.services.back(); - - fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, ESPBTUUID::from_uuid(service_result.uuid), - use_efficient_uuids); - - service_resp.handle = service_result.start_handle; - - if (total_char_count > 0) { - // Initialize FixedVector with exact count and process characteristics - service_resp.characteristics.init(total_char_count); - uint16_t char_offset = 0; - esp_gattc_char_elem_t char_result; - // Bound by total_char_count: the vector is sized for it, and a malicious peripheral - // can make enumeration return more entries than the count query reported - while (char_offset < total_char_count) { // characteristics - uint16_t char_count = 1; - esp_gatt_status_t char_status = - esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, - service_result.end_handle, &char_result, &char_count, char_offset); - if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) { - break; - } - if (char_status != ESP_GATT_OK) { - this->log_connection_error_("esp_ble_gattc_get_all_char", char_status); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - if (char_count == 0) { - break; - } - - service_resp.characteristics.emplace_back(); - auto &characteristic_resp = service_resp.characteristics.back(); - fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, ESPBTUUID::from_uuid(char_result.uuid), - use_efficient_uuids); - characteristic_resp.handle = char_result.char_handle; - characteristic_resp.properties = char_result.properties; - char_offset++; - - // Get the number of descriptors directly with one call - uint16_t total_desc_count = 0; - esp_gatt_status_t desc_count_status = esp_ble_gattc_get_attr_count( - this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, 0, 0, char_result.char_handle, &total_desc_count); - - if (desc_count_status != ESP_GATT_OK) { - this->log_connection_error_("esp_ble_gattc_get_attr_count", desc_count_status); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - if (total_desc_count == 0) { - continue; - } - - // Initialize FixedVector with exact count and process descriptors - characteristic_resp.descriptors.init(total_desc_count); - uint16_t desc_offset = 0; - esp_gattc_descr_elem_t desc_result; - while (desc_offset < total_desc_count) { // descriptors - uint16_t desc_count = 1; - esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr( - this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset); - if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) { - break; - } - if (desc_status != ESP_GATT_OK) { - this->log_connection_error_("esp_ble_gattc_get_all_descr", desc_status); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - if (desc_count == 0) { - break; // No more descriptors - } - - characteristic_resp.descriptors.emplace_back(); - auto &descriptor_resp = characteristic_resp.descriptors.back(); - fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, ESPBTUUID::from_uuid(desc_result.uuid), - use_efficient_uuids); - descriptor_resp.handle = desc_result.handle; - desc_offset++; - } - } - } // end if (total_char_count > 0) - - if (close_service_batch(resp, current_size, this->send_service_, this->connection_index_, this->address_str()) != - BatchClose::CONTINUE) { - break; - } - } - - // Send the message with dynamically batched services; on a failed send, - // rewind the cursor so the batch is retried instead of silently skipped. - if (!api_conn->send_message(resp)) { - ESP_LOGW(TAG, "[%d] [%s] Failed to send service batch, retrying", this->connection_index_, this->address_str_); - this->send_service_ = batch_start; - } -} - -void BluetoothConnection::log_connection_error_(const char *operation, esp_gatt_status_t status) { - ESP_LOGE(TAG, "[%d] [%s] %s error, status=%d", this->connection_index_, this->address_str(), operation, status); -} - -void BluetoothConnection::log_connection_warning_(const char *operation, esp_err_t err) { - ESP_LOGW(TAG, "[%d] [%s] %s failed, err=%d", this->connection_index_, this->address_str(), operation, err); -} - -void BluetoothConnection::log_gatt_not_connected_(const char *action, const char *type) { - ESP_LOGW(TAG, "[%d] [%s] Cannot %s GATT %s, not connected.", this->connection_index_, this->address_str(), action, - type); -} - -void BluetoothConnection::log_gatt_operation_error_(const char *operation, uint16_t handle, esp_gatt_status_t status) { - ESP_LOGW(TAG, "[%d] [%s] Error %s for handle 0x%2X, status=%d", this->connection_index_, this->address_str(), - operation, handle, status); -} - -esp_err_t BluetoothConnection::check_and_log_error_(const char *operation, esp_err_t err) { - if (err != ESP_OK) { - this->log_connection_warning_(operation, err); - return err; - } - return ESP_OK; -} - -bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, - esp_ble_gattc_cb_param_t *param) { - if (!BLEClientBase::gattc_event_handler(event, gattc_if, param)) - return false; - - switch (event) { - case ESP_GATTC_DISCONNECT_EVT: { - // Don't reset connection yet - wait for CLOSE_EVT to ensure controller has freed resources - // This prevents race condition where we mark slot as free before controller cleanup is complete - ESP_LOGD(TAG, "[%d] [%s] Disconnect, reason=0x%02x", this->connection_index_, this->address_str_, - param->disconnect.reason); - // Send disconnection notification but don't free the slot yet - this->proxy_->send_device_connection(this->address_, false, 0, param->disconnect.reason); - break; - } - case ESP_GATTC_OPEN_EVT: { - if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { - this->reset_connection_(param->open.status); - } else if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - this->proxy_->send_device_connection(this->address_, true, this->mtu_); - this->proxy_->send_connections_free(); - } - this->seen_mtu_or_services_ = false; - break; - } - case ESP_GATTC_CFG_MTU_EVT: - case ESP_GATTC_SEARCH_CMPL_EVT: { - if (!this->seen_mtu_or_services_) { - // We don't know if we will get the MTU or the services first, so - // only send the device connection true if we have already received - // the services. - this->seen_mtu_or_services_ = true; - break; - } - this->proxy_->send_device_connection(this->address_, true, this->mtu_); - this->proxy_->send_connections_free(); - break; - } - case ESP_GATTC_READ_DESCR_EVT: - case ESP_GATTC_READ_CHAR_EVT: { - if (param->read.status != ESP_GATT_OK) { - this->log_gatt_operation_error_("reading char/descriptor", param->read.handle, param->read.status); - this->proxy_->send_gatt_error(this->address_, param->read.handle, param->read.status); - break; - } - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - break; - api::BluetoothGATTReadResponse resp; - resp.address = this->address_; - resp.handle = param->read.handle; - resp.set_data(param->read.value, param->read.value_len); - api_connection->send_message(resp); - break; - } - case ESP_GATTC_WRITE_CHAR_EVT: - case ESP_GATTC_WRITE_DESCR_EVT: { - if (param->write.status != ESP_GATT_OK) { - this->log_gatt_operation_error_("writing char/descriptor", param->write.handle, param->write.status); - this->proxy_->send_gatt_error(this->address_, param->write.handle, param->write.status); - break; - } - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - break; - api::BluetoothGATTWriteResponse resp; - resp.address = this->address_; - resp.handle = param->write.handle; - api_connection->send_message(resp); - break; - } - case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: { - if (param->unreg_for_notify.status != ESP_GATT_OK) { - this->log_gatt_operation_error_("unregistering notifications", param->unreg_for_notify.handle, - param->unreg_for_notify.status); - this->proxy_->send_gatt_error(this->address_, param->unreg_for_notify.handle, param->unreg_for_notify.status); - break; - } - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - break; - api::BluetoothGATTNotifyResponse resp; - resp.address = this->address_; - resp.handle = param->unreg_for_notify.handle; - api_connection->send_message(resp); - break; - } - case ESP_GATTC_REG_FOR_NOTIFY_EVT: { - if (param->reg_for_notify.status != ESP_GATT_OK) { - this->log_gatt_operation_error_("registering notifications", param->reg_for_notify.handle, - param->reg_for_notify.status); - this->proxy_->send_gatt_error(this->address_, param->reg_for_notify.handle, param->reg_for_notify.status); - break; - } - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - break; - api::BluetoothGATTNotifyResponse resp; - resp.address = this->address_; - resp.handle = param->reg_for_notify.handle; - api_connection->send_message(resp); - break; - } - case ESP_GATTC_NOTIFY_EVT: { - ESP_LOGV(TAG, "[%d] [%s] ESP_GATTC_NOTIFY_EVT: handle=0x%2X", this->connection_index_, this->address_str_, - param->notify.handle); - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - break; - api::BluetoothGATTNotifyDataResponse resp; - resp.address = this->address_; - resp.handle = param->notify.handle; - resp.set_data(param->notify.value, param->notify.value_len); - api_connection->send_message(resp); - break; - } - default: - break; - } - return true; -} - -void BluetoothConnection::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { - BLEClientBase::gap_event_handler(event, param); - - switch (event) { - case ESP_GAP_BLE_AUTH_CMPL_EVT: - if (memcmp(param->ble_security.auth_cmpl.bd_addr, this->remote_bda_, 6) != 0) - break; - if (param->ble_security.auth_cmpl.success) { - this->proxy_->send_device_pairing(this->address_, true); - } else { - this->proxy_->send_device_pairing(this->address_, false, param->ble_security.auth_cmpl.fail_reason); - } - break; - default: - break; - } -} - -esp_err_t BluetoothConnection::read_characteristic(uint16_t handle) { - if (!this->connected()) { - this->log_gatt_not_connected_("read", "characteristic"); - return GATT_NOT_CONNECTED; - } - - ESP_LOGV(TAG, "[%d] [%s] Reading GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); - - esp_err_t err = esp_ble_gattc_read_char(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE); - return this->check_and_log_error_("esp_ble_gattc_read_char", err); -} - -esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8_t *data, size_t length, - bool response) { - if (!this->connected()) { - this->log_gatt_not_connected_("write", "characteristic"); - return GATT_NOT_CONNECTED; - } - ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); - - // ESP-IDF's API requires a non-const uint8_t* but it doesn't modify the data - // The BTC layer immediately copies the data to its own buffer (see btc_gattc.c) - // const_cast is safe here and was previously hidden by a C-style cast - esp_err_t err = - esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, length, const_cast(data), - response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - return this->check_and_log_error_("esp_ble_gattc_write_char", err); -} - -esp_err_t BluetoothConnection::read_descriptor(uint16_t handle) { - if (!this->connected()) { - this->log_gatt_not_connected_("read", "descriptor"); - return GATT_NOT_CONNECTED; - } - ESP_LOGV(TAG, "[%d] [%s] Reading GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); - - esp_err_t err = esp_ble_gattc_read_char_descr(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE); - return this->check_and_log_error_("esp_ble_gattc_read_char_descr", err); -} - -esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response) { - if (!this->connected()) { - this->log_gatt_not_connected_("write", "descriptor"); - return GATT_NOT_CONNECTED; - } - ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); - - // ESP-IDF's API requires a non-const uint8_t* but it doesn't modify the data - // The BTC layer immediately copies the data to its own buffer (see btc_gattc.c) - // const_cast is safe here and was previously hidden by a C-style cast - esp_err_t err = esp_ble_gattc_write_char_descr( - this->gattc_if_, this->conn_id_, handle, length, const_cast(data), - response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - return this->check_and_log_error_("esp_ble_gattc_write_char_descr", err); -} - -esp_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enable) { - if (!this->connected()) { - this->log_gatt_not_connected_("notify", "characteristic"); - return GATT_NOT_CONNECTED; - } - - if (enable) { - ESP_LOGV(TAG, "[%d] [%s] Registering for GATT characteristic notifications handle %d", this->connection_index_, - this->address_str_, handle); - esp_err_t err = esp_ble_gattc_register_for_notify(this->gattc_if_, this->remote_bda_, handle); - return this->check_and_log_error_("esp_ble_gattc_register_for_notify", err); - } - - ESP_LOGV(TAG, "[%d] [%s] Unregistering for GATT characteristic notifications handle %d", this->connection_index_, - this->address_str_, handle); - esp_err_t err = esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle); - return this->check_and_log_error_("esp_ble_gattc_unregister_for_notify", err); -} - -} // namespace esphome::bluetooth_connection - -#endif // USE_ESP32 diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h deleted file mode 100644 index fb60d93e9c..0000000000 --- a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h +++ /dev/null @@ -1,76 +0,0 @@ -#pragma once - -#include "esphome/core/defines.h" - -#ifdef USE_ESP32 - -#include "esphome/components/esp32_ble_client/ble_client_base.h" - -#include "bluetooth_connection.h" - -namespace esphome::bluetooth_proxy { -class BluetoothProxy; -} // namespace esphome::bluetooth_proxy - -namespace esphome::bluetooth_connection { - -class BluetoothConnection final : public esp32_ble_client::BLEClientBase { - public: - void dump_config() override; - void loop() override; - bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, - esp_ble_gattc_cb_param_t *param) override; - void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; - // The proxy's connections never consume parsed ESPBTDevice objects. - bool wants_parsed_advertisements() override { return false; } - - esp_err_t read_characteristic(uint16_t handle); - esp_err_t write_characteristic(uint16_t handle, const uint8_t *data, size_t length, bool response); - esp_err_t read_descriptor(uint16_t handle); - esp_err_t write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response); - - esp_err_t notify_characteristic(uint16_t handle, bool enable); - - esp_err_t update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout) { - return this->update_conn_params_(min_interval, max_interval, latency, timeout, "custom"); - } - - bool has_gatt_services() const { return this->service_count_ != 0; } - - /// Start connecting: record the API address type and hand the client to the - /// tracker's promote loop (it pauses the scan and opens the connection). - void initiate_connection(uint8_t address_type) { - this->set_remote_addr_type(static_cast(address_type)); - this->set_state(esp32_ble_tracker::ClientState::DISCOVERED); - } - - void set_address(uint64_t address) override; - - protected: - friend class bluetooth_proxy::BluetoothProxy; - - void on_disconnect_complete(esp_err_t reason) override; - - void send_service_for_discovery_(); - void reset_connection_(esp_err_t reason); - void log_connection_error_(const char *operation, esp_gatt_status_t status); - void log_connection_warning_(const char *operation, esp_err_t err); - void log_gatt_not_connected_(const char *action, const char *type); - void log_gatt_operation_error_(const char *operation, uint16_t handle, esp_gatt_status_t status); - esp_err_t check_and_log_error_(const char *operation, esp_err_t err); - - // Memory optimized layout for 32-bit systems - // Group 1: Pointers (4 bytes each, naturally aligned) - bluetooth_proxy::BluetoothProxy *proxy_; - - // Group 2: 2-byte types - int16_t send_service_{INIT_SENDING_SERVICES}; // see bluetooth_connection.h cursor states - - // Group 3: 1-byte types - bool seen_mtu_or_services_{false}; - // 1 byte used, 1 byte padding -}; - -} // namespace esphome::bluetooth_connection - -#endif // USE_ESP32 diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h b/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h index d8792b88c1..3c982d81ae 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h @@ -15,19 +15,21 @@ #if defined(USE_RP2040_BLE) #include "bluetooth_connection_rp2.h" #define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::RP2GattClient +#elif defined(USE_ESP32_BLE) +#include "bluetooth_connection_bluedroid.h" +#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::BluedroidGattClient #elif defined(USE_BLE_GATT_CLIENT_STUB_BACKEND) // Emitted only by the host unit-test manifest: the tests compile the hub // wrapper standalone, so bind a do-nothing backend. Every other backend-less // build hits the #error below. namespace esphome::bluetooth_connection { -class BluetoothConnection; - class StubGattBackend { public: - void set_listener(BluetoothConnection *listener) {} + void set_listener(ble_device_base::GattClientListener *listener) {} int connect(uint64_t address, uint8_t addr_type) { return ble_device_base::GATT_ERR_NOT_CONNECTED; } - int disconnect() { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int gatt_disconnect() { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + bool cancel_gatt_disconnect() { return false; } int discover_services() { return ble_device_base::GATT_ERR_NOT_CONNECTED; } int read_characteristic(uint16_t handle) { return ble_device_base::GATT_ERR_NOT_CONNECTED; } int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { @@ -43,6 +45,7 @@ class StubGattBackend { return ble_device_base::GATT_ERR_NOT_CONNECTED; } ble_device_base::GattServiceTable get_service_table() { return {}; } + void set_connection_type(ble_device_base::ConnectionType ct) {} void release_services() {} }; @@ -55,7 +58,7 @@ class StubGattBackend { namespace esphome::ble_device_base { using BLEGattConnection = ESPHOME_BLE_GATT_CONNECTION_TYPE; -static_assert(BLEGattConnectionContract, +static_assert(BLEGattConnectionContract, "The build's GATT backend is missing part of the BLEGattConnection surface (ble_gatt_client.h)"); #undef ESPHOME_BLE_GATT_CONNECTION_TYPE diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index ec03f18e1d..b913bb9a55 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -1,7 +1,7 @@ -// Hub-platform connection wrapper (USE_RP2 hub builds today). +// The proxy's per-slot connection wrapper, shared by every platform. #include "bluetooth_connection_hub.h" -#if !defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) +#ifdef BLUETOOTH_CONNECTION_HAS_GATT #include "esphome/components/api/api_pb2.h" #include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" @@ -26,11 +26,11 @@ void BluetoothConnection::set_address(uint64_t address) { format_mac_addr_upper(mac, this->address_str_); } -void BluetoothConnection::start_connect_() { - // No connect timeout here (esp32 parity): the client's own timeout or - // the api-gone sweep drives disconnect(). +void BluetoothConnection::initiate_connection(uint8_t address_type) { + // No connect timeout here: the API client's own timeout or the api-gone + // sweep drives disconnect(). this->state_ = ClientState::CONNECTING; - int err = this->backend_->connect(this->address_, this->remote_addr_type_); + int err = this->backend_->connect(this->address_, address_type); if (err != 0) { ESP_LOGW(TAG, "[%d] [%s] connect failed, err=%d", this->connection_index_, this->address_str_, err); this->reset_connection_(err); @@ -38,40 +38,21 @@ void BluetoothConnection::start_connect_() { } void BluetoothConnection::disconnect() { - // Idempotent like the esp32 class: the proxy's teardown loop calls this - // every 100 ms while the API subscriber is gone, and a repeat call must not - // reach the backend (whose busy error would free the slot mid-teardown). + // Idempotent: the proxy's teardown loop calls this every 100 ms while the + // API subscriber is gone, and a repeat call reaching the backend would + // re-arm its teardown timer so the safety timeout never fires. if (this->state_ == ClientState::IDLE || this->state_ == ClientState::DISCONNECTING) { return; } - int err = this->backend_->disconnect(); - if (err == GATT_NOT_CONNECTED) { - // Backend already idle: free the slot so the client is not stuck. - ESP_LOGW(TAG, "[%d] [%s] disconnect while backend idle", this->connection_index_, this->address_str_); + int err = this->backend_->gatt_disconnect(); + if (err != 0) { + // Nonzero means nothing to tear down (both backends): free the slot. + // Accepted teardowns always reach a terminal report. + ESP_LOGW(TAG, "[%d] [%s] disconnect while backend idle, err=%d", this->connection_index_, this->address_str_, err); this->reset_connection_(err); return; } - if (err != 0) { - // Transient refusal: stay DISCONNECTING and let the safety timeout - // arbitrate rather than freeing a slot whose teardown is unresolved. - // Latch the refusal unless a GATT cause is already recorded (first wins). - ESP_LOGW(TAG, "[%d] [%s] disconnect failed, err=%d", this->connection_index_, this->address_str_, err); - if (this->pending_error_ == 0) { - this->pending_error_ = err; - } - } this->state_ = ClientState::DISCONNECTING; - this->disconnecting_started_ = millis(); -} - -void BluetoothConnection::check_disconnect_timeout_() { - // Safety net mirroring the esp32 base class: if the backend's disconnect - // completion is lost, force the slot free instead of leaking it. - static constexpr uint32_t DISCONNECT_TIMEOUT_MS = 10000; - if (this->state_ == ClientState::DISCONNECTING && millis() - this->disconnecting_started_ > DISCONNECT_TIMEOUT_MS) { - ESP_LOGW(TAG, "[%d] [%s] Disconnect timeout, freeing slot", this->connection_index_, this->address_str_); - this->reset_connection_(GATT_NOT_CONNECTED); - } } void BluetoothConnection::on_pairing_result(int status) { @@ -96,32 +77,24 @@ void BluetoothConnection::reset_connection_(conn_err_t reason) { this->proxy_->reset_connection_slot_(this, reason); } -// ---- backend event sink ---- +// ---- backend event listener ---- void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int error) { if (connected && this->address_ == 0) { // Late completion for a slot that was already freed: nothing to report, // and the api-gone sweep or a new reservation owns the slot now. - int err = this->backend_->disconnect(); - if (err != 0 && err != GATT_NOT_CONNECTED) { - // Log only: re-arming a freed slot could clobber a new reservation. - ESP_LOGW(TAG, "[%d] freed-slot disconnect refused, err=%d", this->connection_index_, err); - } + // Return ignored: nonzero just means the backend was already idle, and + // re-arming a freed slot could clobber a new reservation. + this->backend_->gatt_disconnect(); return; } if (connected && this->state_ == ClientState::DISCONNECTING) { // The link came up after a disconnect request won the race; finish the // teardown instead of reporting a connection the client no longer wants. - int err = this->backend_->disconnect(); - // Fresh teardown attempt: give it the full safety window. - this->disconnecting_started_ = millis(); - if (err == GATT_NOT_CONNECTED) { + int err = this->backend_->gatt_disconnect(); + if (err != 0) { // Nothing left to tear down after all. this->reset_connection_(err); - } else if (err != 0) { - // Transient refusal while the link is up: keep DISCONNECTING and let - // the safety timeout arbitrate (same policy as disconnect()). - ESP_LOGW(TAG, "[%d] [%s] teardown disconnect failed, err=%d", this->connection_index_, this->address_str_, err); } return; } @@ -130,7 +103,10 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) { // The API client has the services cached; never discover them. No // discovery phase needs the fast interval, so settle straight into the - // shared steady-state parameters (same lifecycle place as esp32). + // shared steady-state parameters. On esp32 the backend already set the + // same values as prefer-params before opening, so this request is + // usually redundant there - kept because rp2 has no prefer-params and + // the explicit update is its only path to the steady-state interval. this->state_ = ClientState::ESTABLISHED; int param_err = this->backend_->update_connection_params(ble_device_base::MEDIUM_MIN_CONN_INTERVAL, ble_device_base::MEDIUM_MAX_CONN_INTERVAL, 0, @@ -145,14 +121,13 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int return; } // V3_WITHOUT_CACHE: discover services first — the connected response is - // sent when discovery completes, mirroring the esp32 flow (MTU + services - // before the response). + // sent when discovery completes (MTU + services before the response). this->state_ = ClientState::CONNECTED; int err = this->backend_->discover_services(); if (err != 0) { ESP_LOGW(TAG, "[%d] [%s] discover_services failed, err=%d", this->connection_index_, this->address_str_, err); // Latch the real cause for the disconnect report. - this->pending_error_ = err; + this->latch_pending_error_(err); this->disconnect(); } return; @@ -171,7 +146,7 @@ void BluetoothConnection::on_service_discovery_done(int error) { ESP_LOGW(TAG, "[%d] [%s] Service discovery failed, err=%d", this->connection_index_, this->address_str_, error); // Carry the GATT error into the disconnection report so the client sees // the real cause instead of a generic HCI reason. - this->pending_error_ = error; + this->latch_pending_error_(error); this->disconnect(); return; } @@ -334,9 +309,9 @@ void BluetoothConnection::send_service_for_discovery_() { } // The subscriber vanished mid-stream: park the cursor at done WITHOUT - // sending services-done (esp32 parity — a resubscribing client gets - // silence and its 30 s timeout, never an authoritative partial list) and - // free the table; the api-gone sweep tears the connection down anyway. + // sending services-done (a resubscribing client gets silence and its 30 s + // timeout, never an authoritative partial list) and free the table; the + // api-gone sweep tears the connection down anyway. auto *api_conn = this->proxy_->get_api_connection(); if (api_conn == nullptr) { ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", this->connection_index_, @@ -380,8 +355,7 @@ void BluetoothConnection::send_service_for_discovery_() { if (char_count != 0 && service.first_characteristic + char_count > table.characteristic_count) { ESP_LOGE(TAG, "[%d] [%s] Characteristic range out of bounds (service %d), aborting stream", this->connection_index_, this->address_str_, this->send_service_); - this->send_service_ = DONE_SENDING_SERVICES; - this->disconnect(); + this->abort_service_stream(ble_device_base::GATT_ERR_UNLIKELY); return; } if (char_count > 0) { @@ -397,8 +371,7 @@ void BluetoothConnection::send_service_for_discovery_() { if (desc_count != 0 && chr.first_descriptor + desc_count > table.descriptor_count) { ESP_LOGE(TAG, "[%d] [%s] Descriptor range out of bounds (service %d), aborting stream", this->connection_index_, this->address_str_, this->send_service_); - this->send_service_ = DONE_SENDING_SERVICES; - this->disconnect(); + this->abort_service_stream(ble_device_base::GATT_ERR_UNLIKELY); return; } if (desc_count == 0) { @@ -433,4 +406,4 @@ void BluetoothConnection::send_service_for_discovery_() { } // namespace esphome::bluetooth_connection -#endif // !USE_ESP32 && USE_BLE_GATT_CLIENT +#endif // BLUETOOTH_CONNECTION_HAS_GATT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index e79ee9e7a8..82d9ae7db4 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -1,17 +1,17 @@ -// Hub-platform BluetoothConnection: drives the build's GATT backend (the +// BluetoothConnection: drives the build's GATT backend (the // ble_device_base::BLEGattConnection alias) and translates its events into -// the same API messages the esp32 class emits. -// Presents the identical method surface, so the proxy's GATT dispatch -// compiles against either class unchanged. +// the proxy's API messages. One wrapper for every platform; per-backend +// differences live behind the alias and the streamer cut-through. #pragma once -#include "esphome/core/defines.h" - -#if !defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) - #include "bluetooth_connection.h" +// The wrapper exists to serve the proxy's API surface; direct consumers +// drive the backend themselves, so backend-only builds compile this header +// empty. +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + #include "esphome/components/ble_device_base/ble_client_state.h" #include "bluetooth_connection_gatt_backend.h" #include "esphome/core/helpers.h" @@ -25,7 +25,7 @@ namespace esphome::bluetooth_connection { using ClientState = ble_device_base::ClientState; using ConnectionType = ble_device_base::ConnectionType; -class BluetoothConnection final { +class BluetoothConnection final : public ble_device_base::GattClientListener { public: /// Wire the platform backend. Called from codegen before setup. void set_backend(ble_device_base::BLEGattConnection *backend) { @@ -33,7 +33,7 @@ class BluetoothConnection final { backend->set_listener(this); } - // ---- proxy dispatch surface (mirrors the esp32 class) ---- + // ---- proxy dispatch surface ---- conn_err_t read_characteristic(uint16_t handle); conn_err_t write_characteristic(uint16_t handle, const uint8_t *data, size_t length, bool response); conn_err_t read_descriptor(uint16_t handle); @@ -41,21 +41,31 @@ class BluetoothConnection final { conn_err_t notify_characteristic(uint16_t handle, bool enable); conn_err_t update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout); - /// Start connecting: record the API address type (BLE_ADDR_TYPE_* code - /// space) and open the connection through the backend. Failures report - /// through the same reset path a failed open takes on esp32. - void initiate_connection(uint8_t address_type) { - this->remote_addr_type_ = address_type; - this->start_connect_(); + /// Streamer abort: latch the GATT cause, park the cursor, tear down. + void abort_service_stream(conn_err_t err) { + this->latch_pending_error_(err); + this->send_service_ = DONE_SENDING_SERVICES; + this->disconnect(); } + + /// Start connecting with the API address type (BLE_ADDR_TYPE_* code + /// space). Failures report through the same reset path a failed open + /// takes. + void initiate_connection(uint8_t address_type); void disconnect(); + /// A connect request racing a scheduled teardown: true when the backend + /// had not started closing - the in-flight open resumes and reports + /// connected. False once the teardown owns the link. + bool cancel_teardown() { + if (this->state_ == ClientState::DISCONNECTING && this->backend_->cancel_gatt_disconnect()) { + this->state_ = ClientState::CONNECTING; + return true; + } + return false; + } bool is_paired() const { return this->paired_; } void set_unpaired() { this->paired_ = false; } conn_err_t pair() { return this->backend_->pair(); } - // A backend disconnect() is a single call that also cancels an in-progress - // connect; there is no deferred-disconnect state to track. - bool disconnect_pending() const { return false; } - void cancel_pending_disconnect() {} void set_address(uint64_t address); uint64_t get_address() const { return this->address_; } @@ -65,39 +75,58 @@ class BluetoothConnection final { ClientState state() const { return this->state_; } void set_state(ClientState st) { this->state_ = st; } bool connected() const { return this->state_ == ClientState::ESTABLISHED; } - void set_connection_type(ConnectionType ct) { this->connection_type_ = ct; } + void set_connection_type(ConnectionType ct) { + this->connection_type_ = ct; + // The bluedroid backend branches on the type itself (prefer-params and + // the with-cache report at OPEN_EVT); the others ignore it. + this->backend_->set_connection_type(ct); + } // Latched at discovery completion rather than read from the backend table: // streaming frees the table, and this must stay true for the connection's - // lifetime (esp32 parity — a repeat GetServices is silently ignored there, - // never answered with an authoritative empty database). + // lifetime (a repeat GetServices is silently ignored, never answered with + // an authoritative empty database). bool has_gatt_services() const { return this->services_discovered_; } - /// Stream any pending service-discovery batch and police the disconnect - /// safety timeout. Called from the proxy's loop — hub connections have no - /// Component loop of their own (the esp32 class streams from its own - /// loop() and has the same 10 s safety net in its base class). + /// Stream any pending service-discovery batch (proxy loop; the backend + /// owns the disconnect safety timer). void process_pending_services() { if (this->send_service_ >= 0) { - this->send_service_for_discovery_(); + this->stream_pending_(this->backend_); } - this->check_disconnect_timeout_(); } - // ---- backend event sink (called directly by the backend, main loop) ---- - void on_connection_state(bool connected, uint16_t mtu, int error); - void on_service_discovery_done(int error); - void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error); - void on_write_result(uint16_t handle, int error); - void on_notify_state(uint16_t handle, bool enabled, int error); - void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len); - void on_pairing_result(int status); + // ---- backend event listener (called directly by the backend, main loop) ---- + void on_connection_state(bool connected, uint16_t mtu, int error) override; + void on_service_discovery_done(int error) override; + void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) override; + void on_write_result(uint16_t handle, int error) override; + void on_notify_state(uint16_t handle, bool enabled, int error) override; + void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) override; + void on_pairing_result(int status) override; protected: friend class bluetooth_proxy::BluetoothProxy; + // The Bluedroid backend streams services in place from its stack cache. + friend class BluedroidGattClient; - void start_connect_(); + /// First cause wins: a later, less specific error must not overwrite it. + void latch_pending_error_(conn_err_t err) { + if (this->pending_error_ == 0) { + this->pending_error_ = err; + } + } + // A backend providing its own streamer (see the contract doc) builds the + // response in place from its stack cache; the rest use the table streamer. + // Template so the discarded branch is not odr-checked against backends + // that lack the method. + template void stream_pending_(Backend *backend) { + if constexpr (requires { backend->stream_service_batch(*this); }) { + backend->stream_service_batch(*this); + } else { + this->send_service_for_discovery_(); + } + } void send_service_for_discovery_(); - void check_disconnect_timeout_(); void reset_connection_(conn_err_t reason); conn_err_t check_connected_op_(const char *action, const char *type) const; void log_gatt_operation_error_(const char *operation, uint16_t handle, int status); @@ -109,28 +138,26 @@ class BluetoothConnection final { // Group 2: 2-byte types int16_t send_service_{INIT_SENDING_SERVICES}; - uint16_t mtu_{23}; + uint16_t mtu_{ble_device_base::DEFAULT_ATT_MTU}; // Group 3: 8-byte and 4-byte types uint64_t address_{0}; - uint32_t disconnecting_started_{0}; conn_err_t pending_error_{0}; // Group 4: Arrays char address_str_[MAC_ADDRESS_PRETTY_BUFFER_SIZE]{}; - // Group 5: 1-byte types - ClientState state_{ClientState::IDLE}; - bool paired_{false}; - ConnectionType connection_type_{ConnectionType::V1}; - uint8_t remote_addr_type_{0}; - uint8_t connection_index_{0}; - bool services_discovered_{false}; + // Group 5: bit-packed tail; within 2 bytes the 8-aligned object stays 48. + static_assert(static_cast(ClientState::ESTABLISHED) < (1 << 3), "state_ bitfield too narrow"); + static_assert(static_cast(ConnectionType::V3_WITHOUT_CACHE) < (1 << 2), + "connection_type_ bitfield too narrow"); + ClientState state_ : 3 {ClientState::IDLE}; + bool paired_ : 1 {false}; + ConnectionType connection_type_ : 2 {ConnectionType::V1}; + uint8_t connection_index_ : 4 {0}; + bool services_discovered_ : 1 {false}; }; -static_assert(ble_device_base::GattClientEventSinkContract, - "The hub wrapper is missing part of the event-sink surface (ble_gatt_client.h)"); - } // namespace esphome::bluetooth_connection -#endif // !USE_ESP32 && USE_BLE_GATT_CLIENT +#endif // BLUETOOTH_CONNECTION_HAS_GATT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index dc730659f5..dc77d448a5 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -1,6 +1,5 @@ #include "bluetooth_connection_rp2.h" -#include "bluetooth_connection_hub.h" #include "bluetooth_connection.h" #if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) @@ -26,7 +25,6 @@ using ble_device_base::GATT_ERR_NO_MEMORY; // and keeps the scan inhibited, so the engine cancels after 20 s. The // disconnect timeout mirrors the esp32 CLOSE_EVT safety net. static constexpr uint32_t CONNECT_TIMEOUT_MS = 20000; -static constexpr uint32_t DISCONNECT_TIMEOUT_MS = 10000; // Can-send windows normally open within a connection interval (tens of ms). static constexpr uint32_t WRITE_NO_RSP_TIMEOUT_MS = 500; @@ -384,7 +382,7 @@ void RP2GattClient::loop() { RP2GattNotifyEvent *notify; while ((notify = this->notify_queue_.pop()) != nullptr) { - if (this->listener_ != nullptr && this->notify_subscribed_(notify->handle)) { + if (this->notify_subscribed_(notify->handle)) { this->listener_->on_notify_data(notify->handle, notify->data, notify->len); } this->notify_pool_.release(notify); @@ -395,7 +393,7 @@ void RP2GattClient::loop() { // Control events must not be lost; the connection state is no longer // trustworthy — recover with a forced teardown. ESP_LOGE(TAG, "Dropped %u GATT control events, disconnecting", dropped); - this->disconnect(); + this->gatt_disconnect(); } uint16_t notify_dropped = this->notify_queue_.get_and_reset_dropped_count(); if (notify_dropped > 0) { @@ -426,11 +424,11 @@ void RP2GattClient::loop() { // reclaims state if the disconnection event is lost. Dropping engine // state without gap_disconnect would leak the live link and the // single GATT slot for the rest of the boot. - this->disconnect(); + this->gatt_disconnect(); } } } else if (this->state_ == EngineState::DISCONNECTING) { - if (millis() - this->disconnecting_started_ > DISCONNECT_TIMEOUT_MS) { + if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) { ESP_LOGW(TAG, "Disconnect timeout, forcing idle"); this->handle_disconnected_(HCI_REASON_CONNECTION_TIMEOUT); } @@ -448,9 +446,7 @@ void RP2GattClient::loop() { } if (timed_out) { ESP_LOGW(TAG, "Deferred write timeout, handle=0x%04x", this->op_handle_); - if (this->listener_ != nullptr) { - this->listener_->on_write_result(this->op_handle_, GATT_CLIENT_BUSY); - } + this->listener_->on_write_result(this->op_handle_, GATT_CLIENT_BUSY); } } else if (this->state_ == EngineState::IDLE || (this->state_ == EngineState::READY && !this->op_in_flight_() && this->event_queue_.empty() && this->notify_queue_.empty())) { @@ -474,9 +470,7 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) { this->state_ = EngineState::READY; // Scanning resumes and runs alongside the established connection. this->release_scan_inhibit_(); - if (this->listener_ != nullptr) { - this->listener_->on_connection_state(true, this->mtu_, 0); - } + this->listener_->on_connection_state(true, this->mtu_, 0); } break; case RP2GattEvent::QUERY_COMPLETE: @@ -486,9 +480,7 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) { this->finish_write_no_rsp_(event.status); break; case RP2GattEvent::PAIRING_RESULT: - if (this->listener_ != nullptr) { - this->listener_->on_pairing_result(event.status); - } + this->listener_->on_pairing_result(event.status); break; } } @@ -514,9 +506,7 @@ void RP2GattClient::finish_write_no_rsp_(uint8_t status) { return; } this->op_type_ = OpType::NONE; - if (this->listener_ != nullptr) { - this->listener_->on_write_result(this->op_handle_, status); - } + this->listener_->on_write_result(this->op_handle_, status); } void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) { @@ -576,9 +566,7 @@ void RP2GattClient::fail_connection_(uint8_t reason) { this->cleanup_link_state_(); this->release_scan_inhibit_(); this->state_ = EngineState::IDLE; - if (this->listener_ != nullptr) { - this->listener_->on_connection_state(false, 0, reason); - } + this->listener_->on_connection_state(false, 0, reason); } void RP2GattClient::cleanup_link_state_() { @@ -621,9 +609,6 @@ void RP2GattClient::handle_query_complete_(uint8_t att_status) { if (this->op_type_ != OpType::NONE && this->op_type_ != OpType::WRITE_CHAR_NO_RSP) { OpType op = this->op_type_; this->op_type_ = OpType::NONE; - if (this->listener_ == nullptr) { - return; - } switch (op) { case OpType::READ_CHAR: case OpType::READ_DESC: @@ -796,9 +781,7 @@ void RP2GattClient::finish_discovery_(int error) { if (error != 0) { this->release_services(); } - if (this->listener_ != nullptr) { - this->listener_->on_service_discovery_done(error); - } + this->listener_->on_service_discovery_done(error); } ble_device_base::GattServiceTable RP2GattClient::get_service_table() { @@ -873,7 +856,7 @@ int RP2GattClient::connect(uint64_t address, uint8_t addr_type) { return 0; } -int RP2GattClient::disconnect() { +int RP2GattClient::gatt_disconnect() { switch (this->state_) { case EngineState::IDLE: return GATT_ERR_NOT_CONNECTED; @@ -990,7 +973,7 @@ int RP2GattClient::write_characteristic(uint16_t handle, const uint8_t *data, ui return 0; } } - if (status == 0 && this->listener_ != nullptr) { + if (status == 0) { this->listener_->on_write_result(handle, 0); } return status; @@ -1092,9 +1075,7 @@ int RP2GattClient::notify_characteristic(uint16_t handle, bool enable) { } } } - if (this->listener_ != nullptr) { - this->listener_->on_notify_state(handle, enable, 0); - } + this->listener_->on_notify_state(handle, enable, 0); return 0; } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h index d5bf76e6ee..df43ebd66d 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h @@ -26,8 +26,6 @@ namespace esphome::bluetooth_connection { -class BluetoothConnection; - // Caps for the transient service table. Sized generously for real devices // (typical peripherals expose < 8 services / < 30 characteristics); a peer // exceeding a cap fails discovery with INSUFFICIENT_RESOURCES rather than @@ -80,11 +78,14 @@ class RP2GattClient final : public Component, public Parentedlistener_ = listener; } + void set_listener(ble_device_base::GattClientListener *listener) { this->listener_ = listener; } // ---- ble_device_base::BLEGattConnection contract ---- int connect(uint64_t address, uint8_t addr_type); - int disconnect(); + int gatt_disconnect(); + // Teardown starts inside gatt_disconnect() on this backend; nothing is + // ever scheduled, so there is nothing to cancel. + bool cancel_gatt_disconnect() { return false; } int discover_services(); int read_characteristic(uint16_t handle); int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response); @@ -94,6 +95,8 @@ class RP2GattClient final : public Component, public Parented event_queue_; esphome::EventPool event_pool_; @@ -174,7 +177,7 @@ class RP2GattClient final : public Component, public Parented list[str]: @@ -27,7 +33,7 @@ def AUTO_LOAD(config: ConfigType | None = None) -> list[str]: target platform set, so it takes one of the concrete branches. """ if CORE.is_esp32: - return ["bluetooth_connection", "esp32_ble_client", "esp32_ble_tracker"] + return ["bluetooth_connection", "esp32_ble_tracker"] if CORE.target_platform in _HUB_PLATFORMS: return ["ble_device_base", "bluetooth_connection"] # No target platform, or one this component does not support: tooling @@ -36,7 +42,6 @@ def AUTO_LOAD(config: ConfigType | None = None) -> list[str]: return [ "ble_device_base", "bluetooth_connection", - "esp32_ble_client", "esp32_ble_tracker", ] @@ -47,8 +52,9 @@ def AUTO_LOAD(config: ConfigType | None = None) -> list[str]: # Assistant) assumes an ESPHome proxy can scan actively, so a passive-only # proxy would be misdriven — bk72xx follows once the API carries a feature # flag clients can trust (FEATURE_ACTIVE_SCAN + a version flag, separate PRs). -# Coupled to bluetooth_connection: platforms with a GATT backend are also -# listed in its HUB_MAX_CONNECTIONS and its FILTER_SOURCE_FILES hub entry. +# Coupled to bluetooth_connection: platforms here are also listed in its +# _PLATFORM_BACKENDS registry, HUB_MAX_CONNECTIONS, and FILTER_SOURCE_FILES +# hub entry. _HUB_PLATFORMS = (PLATFORM_LN882X, PLATFORM_RP2) DEPENDENCIES = ["api"] @@ -59,7 +65,6 @@ _LOGGER = logging.getLogger(__name__) CONF_CONNECTION_SLOTS = "connection_slots" CONF_CACHE_SERVICES = "cache_services" CONF_CONNECTIONS = "connections" -CONF_BACKEND_ID = "backend_id" DEFAULT_CONNECTION_SLOTS = 3 bluetooth_proxy_ns = cg.esphome_ns.namespace("bluetooth_proxy") @@ -86,12 +91,7 @@ def _esp32_config_schema() -> cv.All: f"update _IDF_MAX_CONNECTIONS in bluetooth_proxy/__init__.py" ) - BluetoothConnection = bluetooth_connection.esp32_connection_class() - CONNECTION_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA.extend( - { - cv.GenerateID(): cv.declare_id(BluetoothConnection), - } - ).extend(cv.COMPONENT_SCHEMA) + CONNECTION_SCHEMA = bluetooth_connection.hub_connection_schema(PLATFORM_ESP32) def validate_connections(config): if CONF_CONNECTIONS in config: @@ -154,16 +154,7 @@ def _rp2_config_schema() -> cv.All: """Full proxy on the rp2 BLE hub: active connections through the BTstack GATT client backend in bluetooth_connection. The slot limit comes from the prebuilt BTstack library (one connection today); the code is built for N.""" - from esphome.components import rp2040_ble - - connection_schema = cv.Schema( - { - cv.GenerateID(): cv.declare_id(bluetooth_connection.HubBluetoothConnection), - cv.GenerateID(CONF_BACKEND_ID): cv.declare_id( - bluetooth_connection.RP2GattClient - ), - } - ) + connection_schema = bluetooth_connection.hub_connection_schema(PLATFORM_RP2) def populate_connections(config: ConfigType) -> ConfigType: # One wrapper + backend pair per slot, declared during validation so @@ -182,11 +173,6 @@ def _rp2_config_schema() -> cv.All: cv.Schema( { **_COMMON_SCHEMA_KEYS, - # The GATT backend drives the controller directly (connect, GATT - # ops), not through the tracker hub. - cv.GenerateID(rp2040_ble.CONF_RP2040_BLE_ID): cv.use_id( - rp2040_ble.RP2040BLE - ), cv.Optional(CONF_ACTIVE, default=True): cv.boolean, cv.Optional( CONF_CONNECTION_SLOTS, @@ -212,25 +198,25 @@ def _rp2_config_schema() -> cv.All: return cv.All(schema, populate_connections) -async def _rp2_connections_to_code(var: cg.MockObj, config: ConfigType) -> None: - from esphome.components import rp2040_ble - - # One wrapper + backend pair per slot (the esp32 arm's pattern). - for connection_conf in config[CONF_CONNECTIONS]: - ble_device_base.request_gatt_client() - backend = cg.new_Pvariable(connection_conf[CONF_BACKEND_ID]) - await cg.register_component(backend, connection_conf) - await cg.register_parented(backend, config[rp2040_ble.CONF_RP2040_BLE_ID]) +async def _connections_to_code(var: cg.MockObj, config: ConfigType) -> None: + """One wrapper + backend pair per slot; the platform-specific backend + registration lives in bluetooth_connection.new_gatt_backend().""" + connections = config.get(CONF_CONNECTIONS, []) + # The api component sizes BluetoothConnectionsFreeResponse.allocated with + # this define whenever a proxy is present (zero on advertisement-only + # hubs); sized here so it can never diverge from the loop below. + cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", len(connections)) + for connection_conf in connections: + backend = await bluetooth_connection.new_gatt_backend(connection_conf) connection = cg.new_Pvariable(connection_conf[CONF_ID]) cg.add(connection.set_backend(backend)) cg.add(var.register_connection(connection)) -# Per-platform schema builders and connection codegen; every key of -# bluetooth_connection.HUB_MAX_CONNECTIONS needs an entry in both (pinned by -# tests/component_tests/bluetooth_proxy/). +# Per-platform schema builders; every key of +# bluetooth_connection.HUB_MAX_CONNECTIONS needs an entry here (pinned by +# tests/component_tests/bluetooth_proxy/). Connection codegen is shared. _GATT_HUB_SCHEMAS = {PLATFORM_RP2: _rp2_config_schema} -_GATT_HUB_TO_CODE = {PLATFORM_RP2: _rp2_connections_to_code} # Keys every platform arm declares identically; each arm spreads this dict so @@ -381,15 +367,7 @@ async def _to_code_esp32(config: ConfigType) -> None: # registration into the proxy; the other hubs are polled instead. cg.add_define("USE_BLE_SCANNER_STATE_CALLBACK") - # Define max connections for protobuf fixed array - connection_count = len(config.get(CONF_CONNECTIONS, [])) - cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", connection_count) - - for connection_conf in config.get(CONF_CONNECTIONS, []): - connection_var = cg.new_Pvariable(connection_conf[CONF_ID]) - await cg.register_component(connection_var, connection_conf) - cg.add(var.register_connection(connection_var)) - await esp32_ble_tracker.register_raw_client(connection_var, connection_conf) + await _connections_to_code(var, config) if config.get(CONF_CACHE_SERVICES): add_idf_sdkconfig_option("CONFIG_BT_GATTC_CACHE_NVS_FLASH", True) @@ -403,16 +381,7 @@ async def _to_code_ble_hub(config: ConfigType) -> None: hub = await cg.get_variable(config[ble_device_base.CONF_BLE_HUB_ID]) cg.add(var.set_ble_hub(hub)) - # The api component sizes BluetoothConnectionsFreeResponse.allocated with - # this define whenever a proxy is present. Zero on advertisement-only hubs. - # Sized from the instantiated connections so the define can never diverge - # from the loop below (the define sizes fixed storage in the proxy). - slots = len(config.get(CONF_CONNECTIONS, ())) - cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", slots) - if not slots: - return - - await _GATT_HUB_TO_CODE[CORE.target_platform](var, config) + await _connections_to_code(var, config) async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 88d8cc1885..0a16567549 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -132,13 +132,6 @@ void BluetoothProxy::log_advertisement_flush_() { } void BluetoothProxy::dump_config() { -#ifdef USE_ESP32 - ESP_LOGCONFIG(TAG, - "Bluetooth Proxy:\n" - " Active: %s\n" - " Connections: %d", - YESNO(this->active_), this->connection_count_); -#else // Print configured facts. dump_config runs right after setup, before the // radio is up, so live scan state would always read "stopped" here — the // loop's BluetoothScannerStateResponse carries the changing value instead. @@ -162,32 +155,8 @@ void BluetoothProxy::dump_config() { " Adapter MAC: %s", scan_mode, mac_out); #endif -#endif } -#ifdef USE_ESP32 - -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(); - } - } -} - -#endif // USE_ESP32 - #ifdef BLUETOOTH_CONNECTION_HAS_GATT // maybe_unused: in a passive proxy (active: false) MAX is 0, the body is removed, and connection is unused. @@ -200,11 +169,8 @@ void BluetoothProxy::register_connection([[maybe_unused]] BluetoothConnection *c ESP_LOGE(TAG, "Connection registry full, dropping registration"); return; } -#ifndef USE_ESP32 - // esp32 assigns connection_index_ in BLEClientBase::setup(); the hub - // class has no Component lifecycle, so the index is assigned here. + // The hub wrapper has no Component lifecycle, so the index is assigned here. connection->connection_index_ = this->connection_count_; -#endif this->connections_[this->connection_count_++] = connection; connection->proxy_ = this; #endif @@ -274,16 +240,13 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest this->send_device_connection(msg.address, true); this->send_connections_free(); return; - } else if (connection->state() == ClientState::CONNECTING) { - if (connection->disconnect_pending()) { - ESP_LOGW(TAG, "[%d] [%s] Connection request while pending disconnect, cancelling pending disconnect", - connection->get_connection_index(), connection->address_str()); - connection->cancel_pending_disconnect(); - return; - } - this->log_connection_request_ignored_(connection, connection->state()); + } else if (connection->state() == ClientState::DISCONNECTING && connection->cancel_teardown()) { + ESP_LOGW(TAG, "[%d] [%s] Connection request while pending disconnect, cancelling pending disconnect", + connection->get_connection_index(), connection->address_str()); return; } else if (connection->state() != ClientState::INIT) { + // Covers CONNECTING too: a repeat request during a connect attempt is + // ignored the same way. this->log_connection_request_ignored_(connection, connection->state()); return; } @@ -315,7 +278,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR: { - // Both connection classes expose the same pairing surface; success is + // The connection wrapper exposes the pairing surface; success is // reported when the platform's pairing completion arrives. auto *connection = this->get_connection_(msg.address, false); if (connection != nullptr) { @@ -486,11 +449,33 @@ void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { #else // !USE_ESP32 +void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { + if (this->hub_->scan_active() != active) { + ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive"); + if (!this->hub_->request_scan_mode(active)) { + // Passive-only controller asked for active scanning; the state report + // below carries the real, unchanged mode so the subscriber does not + // assume the change happened. + ESP_LOGW(TAG, "Scanner mode %s not supported by this tracker", active ? "active" : "passive"); + } + } +#ifndef USE_BLE_SCANNER_STATE_CALLBACK + if (this->api_connection_ != nullptr) { + // Reports the mode change; the sender also refreshes last_scan_running_, so + // a failed restart (scan_running_ dropped by the tracker) is not reported + // again by loop() on the next tick. A push hub reports the restart's + // transitions (mode rides along) instead. + this->send_polled_scanner_state_(); + } +#endif +} + +#endif // USE_ESP32 + void BluetoothProxy::loop() { #ifdef BLUETOOTH_CONNECTION_HAS_GATT - // Stream pending service-discovery batches every iteration (esp32 parity: - // its connections stream from their own per-iteration Component loop). - // send_service_for_discovery_() handles a vanished API connection itself. + // Stream pending service-discovery batches every iteration; the streamer + // handles a vanished API connection itself. for (uint8_t i = 0; i < this->connection_count_; i++) { this->connections_[i]->process_pending_services(); } @@ -502,10 +487,19 @@ void BluetoothProxy::loop() { return; this->last_advertisement_flush_time_ = now; + if (this->connections_free_pending_ && this->api_connection_ != nullptr) { + // Resend a dropped slot-state update, paced by the 100 ms gate so the + // retry does not hammer the congestion it exists to survive; the + // advertisement-only arm answers DISCONNECT requests with this message + // too, so the drain compiles on every proxy build. + this->connections_free_pending_ = false; + this->send_connections_free(this->api_connection_); + } + if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) { #ifdef BLUETOOTH_CONNECTION_HAS_GATT // The API subscriber is gone: tear down any connections it left behind - // (disconnect() on an already-disconnecting backend is a no-op). + // (disconnect() on an already-disconnecting slot is a no-op). for (uint8_t i = 0; i < this->connection_count_; i++) { auto *connection = this->connections_[i]; if (connection->get_address() != 0) { @@ -550,12 +544,18 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR: this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED); break; - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR: - this->send_device_unpairing(msg.address, false, GATT_NOT_CONNECTED); + case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR: { + // Address-scoped maintenance needs no connection slot: real on esp32 + // (Bluedroid bond table), the stub elsewhere keeps the old error reply. + conn_err_t ret = bluetooth_connection::unpair_device(msg.address); + this->send_device_unpairing(msg.address, ret == CONN_OK, ret); break; - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE: - this->send_device_clear_cache(msg.address, false, GATT_NOT_CONNECTED); + } + case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE: { + conn_err_t ret = bluetooth_connection::clear_gatt_cache(msg.address); + this->send_device_clear_cache(msg.address, ret == CONN_OK, ret); break; + } } } @@ -595,29 +595,6 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn #endif // !BLUETOOTH_CONNECTION_HAS_GATT -void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { - if (this->hub_->scan_active() != active) { - ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive"); - if (!this->hub_->request_scan_mode(active)) { - // Passive-only controller asked for active scanning; the state report - // below carries the real, unchanged mode so the subscriber does not - // assume the change happened. - ESP_LOGW(TAG, "Scanner mode %s not supported by this tracker", active ? "active" : "passive"); - } - } -#ifndef USE_BLE_SCANNER_STATE_CALLBACK - if (this->api_connection_ != nullptr) { - // Reports the mode change; the sender also refreshes last_scan_running_, so - // a failed restart (scan_running_ dropped by the tracker) is not reported - // again by loop() on the next tick. A push hub reports the restart's - // transitions (mode rides along) instead. - this->send_polled_scanner_state_(); - } -#endif -} - -#endif // USE_ESP32 - void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) { if (this->api_connection_ != nullptr && this->api_connection_ != api_connection) { // A previous subscriber still holds the slot. This is almost always a stale @@ -631,6 +608,8 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection api_connection->get_peername_to(new_peername), this->api_connection_->get_name(), this->api_connection_->get_peername_to(old_peername)); } + // A stale retry latch belongs to the previous subscriber's session. + this->connections_free_pending_ = false; this->api_connection_ = api_connection; #ifdef USE_BLE_SCANNER_STATE_CALLBACK // get_scanner_state() is part of the push-hub surface (see BLEHubContract). @@ -646,6 +625,7 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti return; } this->api_connection_ = nullptr; + this->connections_free_pending_ = false; } void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, conn_err_t error) { @@ -656,6 +636,8 @@ void BluetoothProxy::send_device_connection(uint64_t address, bool connected, ui call.connected = connected; call.mtu = mtu; call.error = error; + // Fire and forget: a drop is covered by the client's own timeouts and the + // retried connections-free state. this->api_connection_->send_message(call); } void BluetoothProxy::send_connections_free() { @@ -665,7 +647,13 @@ void BluetoothProxy::send_connections_free() { } void BluetoothProxy::send_connections_free(api::APIConnection *api_connection) { - api_connection->send_message(this->connections_free_response_); + // Latch only for the current subscriber: loop() resends to api_connection_. + if (!api_connection->send_message(this->connections_free_response_) && api_connection == this->api_connection_) { + // V like the api layer's own buffer-full log: a D would ride the same + // full connection. + ESP_LOGV(TAG, "Connections-free update deferred, TCP buffer full"); + this->connections_free_pending_ = true; + } } void BluetoothProxy::send_gatt_services_done(uint64_t address) { diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index d7150617d3..26f99fcca2 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -5,8 +5,6 @@ #ifdef USE_BLUETOOTH_PROXY #include -#include -#include #include "esphome/components/api/api_connection.h" #include "esphome/components/api/api_pb2.h" @@ -17,11 +15,7 @@ #include "esphome/components/ble_device_base/ble_hub_impl.h" -#ifdef USE_ESP32 -#include "esphome/components/bluetooth_connection/bluetooth_connection_esp32.h" -#elif defined(USE_BLE_GATT_CLIENT) #include "esphome/components/bluetooth_connection/bluetooth_connection_hub.h" -#endif namespace esphome::bluetooth_proxy { @@ -29,7 +23,6 @@ namespace esphome::bluetooth_proxy { // re-exported here so the proxy code reads unqualified. using bluetooth_connection::CONN_OK; using bluetooth_connection::conn_err_t; -using bluetooth_connection::DONE_SENDING_SERVICES; using bluetooth_connection::GATT_NOT_CONNECTED; using bluetooth_connection::INIT_SENDING_SERVICES; @@ -261,6 +254,10 @@ class BluetoothProxy final : public Component { // Group 4: 1-byte types grouped together bool active_; + // A dropped send (full TCP buffer) would leave the API client with a stale + // slot state forever; the cached response is current by construction, so + // retrying it from loop() is an idempotent resync. + bool connections_free_pending_{false}; uint8_t connection_count_{0}; bool configured_scan_active_{false}; // Configured scan mode from YAML #ifndef USE_BLE_SCANNER_STATE_CALLBACK diff --git a/esphome/config_helpers.py b/esphome/config_helpers.py index c0a3b99968..c82c2b3dbe 100644 --- a/esphome/config_helpers.py +++ b/esphome/config_helpers.py @@ -1,4 +1,4 @@ -from collections.abc import Callable +from collections.abc import Callable, Collection from esphome.const import ( CONF_LEVEL, @@ -98,6 +98,19 @@ def merge_config(old, new): return new +def frameworks_for_platforms(platforms: Collection[str]) -> set[PlatformFramework]: + """All PlatformFramework members whose platform is in `platforms`. + + For FILTER_SOURCE_FILES maps that must stay in sync with a platform + registry: deriving the framework set here means a platform added to the + registry cannot validate and then fail at link on a filtered-out file. + """ + known = {pf.value[0].value for pf in PlatformFramework} + if unknown := set(platforms) - known: + raise ValueError(f"unknown platform(s): {sorted(unknown)}") + return {pf for pf in PlatformFramework if pf.value[0].value in platforms} + + def filter_source_files_from_platform( files_map: dict[str, set[PlatformFramework]], ) -> Callable[[], list[str]]: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 7be217383e..bfb019d7ae 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -306,6 +306,8 @@ #define USE_ESP32_BLE_SERVER_ON_CONNECT #define USE_ESP32_BLE_SERVER_ON_DISCONNECT #define USE_ESP32_BLE_TRACKER +#define USE_BLE_GATT_CLIENT +#define ESPHOME_BLE_GATT_CLIENT_COUNT 1 #define ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT 1 #define ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT 1 #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 diff --git a/tests/component_tests/ble_device_base/test_slot_counter.py b/tests/component_tests/ble_device_base/test_slot_counter.py index e784c9871e..86ee53fe8a 100644 --- a/tests/component_tests/ble_device_base/test_slot_counter.py +++ b/tests/component_tests/ble_device_base/test_slot_counter.py @@ -109,6 +109,8 @@ def test_esp32_bluetooth_proxy_requests_client_slots_only( generate_main(component_config_path("esp32_bluetooth_proxy.yaml")) assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") is None assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") == "3" + # One neutral GATT backend slot per connection (the hub-model flip). + assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "3" def test_counts_reset_between_compiles( diff --git a/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py b/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py index 32e5daf4bb..765b2e48d4 100644 --- a/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py +++ b/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py @@ -15,6 +15,13 @@ import voluptuous as vol from esphome import config_validation as cv from esphome.components.bluetooth_proxy import CONFIG_SCHEMA, _esp32_config_schema + +def _esp32_schema_keys() -> dict[str, object]: + # The builder names its platform explicitly, so no CORE state is needed + # (this also mirrors how the language-schema dumper calls it). + return _keys(_schema_of(_esp32_config_schema())) + + # esp32-schema keys with no place in the outer schema: COMPONENT_SCHEMA # plumbing (derived, so a future core key does not fail this component's test), # generated IDs (not user-walkable options), and connections (must validate @@ -38,7 +45,7 @@ def _keys(schema: vol.Schema) -> dict[str, object]: def test_outer_scalar_keys_exist_in_esp32_schema() -> None: outer = _keys(_schema_of(CONFIG_SCHEMA)) - esp32 = _keys(_schema_of(_esp32_config_schema())) + esp32 = _esp32_schema_keys() missing = set(outer) - set(esp32) assert not missing, ( f"outer CONFIG_SCHEMA declares {sorted(missing)} which the esp32 schema " @@ -51,7 +58,7 @@ def test_esp32_scalars_all_walkable() -> None: """Every non-generated esp32 scalar option must appear in the outer schema (connections is deliberately excluded — it must validate exactly once).""" outer = _keys(_schema_of(CONFIG_SCHEMA)) - esp32 = _keys(_schema_of(_esp32_config_schema())) + esp32 = _esp32_schema_keys() scalar = { name for name, key in esp32.items() diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py index 55e6fe2ca7..a47dfd53fa 100644 --- a/tests/component_tests/bluetooth_proxy/test_platform_gates.py +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -9,11 +9,13 @@ import pytest from esphome import config_validation as cv from esphome.components import ble_device_base, bluetooth_connection, bluetooth_proxy +from esphome.config_helpers import frameworks_for_platforms from esphome.const import ( CONF_ACTIVE, KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, + PLATFORM_ESP32, PLATFORM_LN882X, PLATFORM_RP2, PlatformFramework, @@ -177,28 +179,49 @@ def test_rp2_rejects_esp32_only_keys_by_name( bluetooth_proxy.CONFIG_SCHEMA({"connections": [{}]}) +def test_hub_source_filter_covers_every_hub_platform() -> None: + # bluetooth_connection cannot import this module to derive the hub.cpp + # framework set, so pin it here: a platform admitted to the proxy but + # missing from the filter would validate, then fail at link. + expected = frameworks_for_platforms( + [*bluetooth_proxy._HUB_PLATFORMS, PLATFORM_ESP32] + ) + hub_frameworks = bluetooth_connection.SOURCE_FILE_FRAMEWORKS[ + "bluetooth_connection_hub.cpp" + ] + assert expected == hub_frameworks + + def test_bluetooth_connection_auto_load_covers_its_includes() -> None: - # The esp32 connection header includes esp32_ble_client; the auto load - # must satisfy that closure itself (regression: it once relied on the - # consumer's auto loads). + # The backend registers with its platform BLE stack (and the Bluedroid + # header includes the tracker's), so that closure lives here and + # consumers stay platform-blind; the platform-less arm is the union for + # manifest-resolving tooling. _set_platform("esp32") - assert "esp32_ble_client" in bluetooth_connection.AUTO_LOAD() + assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "esp32_ble_tracker"] _set_platform("rp2") + assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "rp2040_ble"] + _set_platform("ln882x") assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base"] - # No target platform (tooling resolving the manifest): the union, so - # dependency closures stay complete for build_codeowners and friends. _set_platform(None) - assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "esp32_ble_client"] + assert bluetooth_connection.AUTO_LOAD() == [ + "ble_device_base", + "esp32_ble_tracker", + "rp2040_ble", + ] def test_every_registered_hub_platform_has_a_schema_arm() -> None: - # A platform added to HUB_MAX_CONNECTIONS without a schema builder, - # codegen arm, or _HUB_PLATFORMS entry would only fail when a config for - # it is validated (or not even then); pin all three couplings here. + # A platform added to HUB_MAX_CONNECTIONS without a schema builder or + # _HUB_PLATFORMS entry would only fail when a config for it is validated + # (or not even then); pin both couplings here. Connection codegen is + # shared (bluetooth_connection.new_gatt_backend), so it needs no arm. registered = set(bluetooth_connection.HUB_MAX_CONNECTIONS) assert registered <= set(bluetooth_proxy._GATT_HUB_SCHEMAS) - assert registered <= set(bluetooth_proxy._GATT_HUB_TO_CODE) assert registered <= set(bluetooth_proxy._HUB_PLATFORMS) + # Hub platforms must also be in the backend registry the shared codegen + # helpers dispatch on. + assert registered <= set(bluetooth_connection._PLATFORM_BACKENDS) # The outer walkable schema's bound must stay the loosest platform cap. assert ( max(bluetooth_connection.HUB_MAX_CONNECTIONS.values()) @@ -220,9 +243,14 @@ def test_defines_h_mirrors_the_rp2_slot_cap() -> None: assert int(match.group(1)) == cap, ( f"defines.h rp2 arm carries {match.group(1)}, expected {cap}" ) - # The static-analysis client count scales with the same cap. - match = re.search(r"#define ESPHOME_BLE_GATT_CLIENT_COUNT (\d+)", defines) - assert match is not None, "ESPHOME_BLE_GATT_CLIENT_COUNT missing from defines.h" + # The static-analysis client count scales with the same cap. Scoped to + # the USE_RP2 block: the esp32 arm carries its own count. + rp2_block = re.search(r"#ifdef USE_RP2\n((?:#define [^\n]*\n)+)", defines) + assert rp2_block is not None, "no USE_RP2 platform block in defines.h" + match = re.search( + r"#define ESPHOME_BLE_GATT_CLIENT_COUNT (\d+)", rp2_block.group(1) + ) + assert match is not None, "ESPHOME_BLE_GATT_CLIENT_COUNT missing from rp2 block" assert int(match.group(1)) == cap, ( - f"ESPHOME_BLE_GATT_CLIENT_COUNT is {match.group(1)}, expected {cap}" + f"rp2 ESPHOME_BLE_GATT_CLIENT_COUNT is {match.group(1)}, expected {cap}" ) diff --git a/tests/components/ble_device_base/test_gatt_client_contract.cpp b/tests/components/ble_device_base/test_gatt_client_contract.cpp index 25b6cbf002..89eb56642e 100644 --- a/tests/components/ble_device_base/test_gatt_client_contract.cpp +++ b/tests/components/ble_device_base/test_gatt_client_contract.cpp @@ -2,7 +2,7 @@ // configured; this TU pins it on the host so the header cannot rot unseen. // The contract is a concept (BLEGattConnection is a per-platform alias), so // the minimal backend here proves the concept stays satisfiable and routes -// events through the duck-typed sink the way a real backend does. +// events through the GattClientListener interface the way a real backend does. #define USE_BLE_GATT_CLIENT #include "esphome/components/ble_device_base/ble_gatt_client.h" @@ -11,37 +11,37 @@ namespace esphome::ble_device_base::testing { -struct RecordingSink { - void on_connection_state(bool connected, uint16_t mtu, int error) { this->connected_ = connected; } - void on_service_discovery_done(int error) { this->discovery_error_ = error; } - void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {} - void on_write_result(uint16_t handle, int error) {} - void on_notify_state(uint16_t handle, bool enabled, int error) {} - void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {} - void on_pairing_result(int status) {} +// Overrides only what it records; the interface's defaults cover the rest. +class RecordingListener : public GattClientListener { + public: + void on_connection_state(bool connected, uint16_t mtu, int error) override { this->connected_ = connected; } + void on_service_discovery_done(int error) override { this->discovery_error_ = error; } + void on_write_result(uint16_t handle, int error) override { this->write_handle_ = handle; } + bool connected_{false}; int discovery_error_{0}; + uint16_t write_handle_{0}; }; -static_assert(GattClientEventSinkContract, "the recording sink must cover the full event-sink surface"); - class MinimalConnection { public: - void set_listener(RecordingSink *listener) { this->listener_ = listener; } + void set_listener(GattClientListener *listener) { this->listener_ = listener; } int connect(uint64_t address, uint8_t addr_type) { - if (this->listener_ != nullptr) - this->listener_->on_connection_state(true, 517, 0); + this->listener_->on_connection_state(true, 517, 0); return 0; } - int disconnect() { return 0; } + bool cancel_gatt_disconnect() { return false; } + int gatt_disconnect() { return 0; } int discover_services() { - if (this->listener_ != nullptr) - this->listener_->on_service_discovery_done(0); + this->listener_->on_service_discovery_done(0); return 0; } int read_characteristic(uint16_t handle) { return GATT_ERR_NOT_CONNECTED; } - int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { return 0; } + int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { + this->listener_->on_write_result(handle, 0); + return 0; + } int read_descriptor(uint16_t handle) { return 0; } int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) { return 0; } int notify_characteristic(uint16_t handle, bool enable) { return 0; } @@ -51,23 +51,26 @@ class MinimalConnection { } GattServiceTable get_service_table() { return {}; } void release_services() {} + void set_connection_type(ConnectionType ct) {} protected: - RecordingSink *listener_{nullptr}; + GattClientListener *listener_{nullptr}; }; -static_assert(BLEGattConnectionContract, +static_assert(BLEGattConnectionContract, "a minimal backend must satisfy the contract the alias asserts"); TEST(BleGattClientContract, MinimalImplementerCompilesAndRoutesEvents) { MinimalConnection connection; - RecordingSink listener; + RecordingListener listener; connection.set_listener(&listener); EXPECT_EQ(connection.connect(0xAABBCCDDEEFFULL, 0), 0); EXPECT_TRUE(listener.connected_); EXPECT_EQ(connection.discover_services(), 0); EXPECT_EQ(listener.discovery_error_, 0); EXPECT_EQ(connection.read_characteristic(1), GATT_ERR_NOT_CONNECTED); + EXPECT_EQ(connection.write_characteristic(7, nullptr, 0, true), 0); + EXPECT_EQ(listener.write_handle_, 7); // A default table is empty and safe to walk. GattServiceTable table = connection.get_service_table(); diff --git a/tests/components/bluetooth_proxy/test-passive.esp32-c6-idf.yaml b/tests/components/bluetooth_proxy/test-passive.esp32-c6-idf.yaml new file mode 100644 index 0000000000..b3445f16c8 --- /dev/null +++ b/tests/components/bluetooth_proxy/test-passive.esp32-c6-idf.yaml @@ -0,0 +1,12 @@ +# Advertisement-only proxy on esp32 by explicit choice: no GATT backend is +# compiled (USE_BLE_GATT_CLIENT unset), which pins the HAS_GATT gating and the +# address-scoped maintenance path that a connections build never exercises. +# Under batch grouping the active default build is what runs; the standalone +# compile of this fixture is what exercises the passive gating. +packages: + common: !include common.yaml + +esp32_ble_tracker: + +bluetooth_proxy: + active: false diff --git a/tests/unit_tests/test_config_helpers.py b/tests/unit_tests/test_config_helpers.py index 1c850e3759..88913c0f23 100644 --- a/tests/unit_tests/test_config_helpers.py +++ b/tests/unit_tests/test_config_helpers.py @@ -3,7 +3,13 @@ from collections.abc import Callable from unittest.mock import patch -from esphome.config_helpers import filter_source_files_from_platform, get_logger_level +import pytest + +from esphome.config_helpers import ( + filter_source_files_from_platform, + frameworks_for_platforms, + get_logger_level, +) from esphome.const import ( CONF_LEVEL, CONF_LOGGER, @@ -133,3 +139,12 @@ def test_get_logger_level() -> None: mock_config = {CONF_LOGGER: {}} with patch("esphome.config_helpers.CORE.config", mock_config): assert get_logger_level() == "DEBUG" + + +def test_frameworks_for_platforms_derives_and_rejects_unknown() -> None: + assert frameworks_for_platforms(["esp32"]) == { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + } + with pytest.raises(ValueError, match="unknown platform"): + frameworks_for_platforms(["esp32", "not_a_platform"]) From e90b4abe9c07549f745cfe4318868524b292b34b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 11:20:24 -0500 Subject: [PATCH 1351/1815] [core] Enforce the preferences contracts with concepts (#18191) --- esphome/core/defines.h | 12 ++- esphome/core/preference_backend.h | 45 +++++++++ esphome/core/preferences.h | 10 ++ tests/component_tests/preferences/__init__.py | 0 .../preferences/config/bk72xx.yaml | 5 + .../preferences/config/esp32.yaml | 5 + .../preferences/config/esp8266.yaml | 5 + .../preferences/config/host.yaml | 4 + .../preferences/config/nrf52.yaml | 6 ++ .../preferences/config/rp2.yaml | 5 + .../preferences/test_key_lookup_gate.py | 39 ++++++++ .../core/test_preference_contract.cpp | 91 +++++++++++++++++++ 12 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/preferences/__init__.py create mode 100644 tests/component_tests/preferences/config/bk72xx.yaml create mode 100644 tests/component_tests/preferences/config/esp32.yaml create mode 100644 tests/component_tests/preferences/config/esp8266.yaml create mode 100644 tests/component_tests/preferences/config/host.yaml create mode 100644 tests/component_tests/preferences/config/nrf52.yaml create mode 100644 tests/component_tests/preferences/config/rp2.yaml create mode 100644 tests/component_tests/preferences/test_key_lookup_gate.py create mode 100644 tests/components/core/test_preference_contract.cpp diff --git a/esphome/core/defines.h b/esphome/core/defines.h index bfb019d7ae..49b9583be3 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -157,9 +157,17 @@ #define USE_OUTPUT_FLOAT_POWER_SCALING #define USE_POWER_SUPPLY #define USE_PREFERENCES_SYNC_EVERY_LOOP -// Only defined by key-lookup preference backends (esp32, libretiny, host, zephyr); -// slot-based platforms (esp8266, rp2040) never set it in generated builds +// Only defined by key-lookup preference backends; the slot-based platforms +// (esp8266, rp2040) never set it in generated builds, and their preferences +// managers do not provide load_from_key(), so the PreferencesKeyLookupContract +// assert would fail their clang-tidy environments. Written as a deny-list so +// the no-platform analysis configuration (whose Preferences stub provides +// load_from_key()) keeps covering the key-lookup code paths, and so a future +// slot-based platform fails the assert loudly instead of silently losing +// analysis coverage. +#if !defined(USE_ESP8266) && !defined(USE_RP2) #define USE_PREFERENCE_KEY_LOOKUP +#endif #define USE_PROVISIONING #define USE_QR_CODE #define USE_SAFE_MODE_BOOT_IS_GOOD_ON_SHUTDOWN diff --git a/esphome/core/preference_backend.h b/esphome/core/preference_backend.h index b9bb9a0252..0622376fca 100644 --- a/esphome/core/preference_backend.h +++ b/esphome/core/preference_backend.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include "esphome/core/defines.h" @@ -30,6 +31,15 @@ namespace esphome { +// The PreferenceBackend method surface, asserted on the alias each platform +// header binds. save() persists len bytes; load() fills dest only when the +// stored data exists and matches len. Both report success as their return. +template +concept PreferenceBackendContract = requires(T backend, const uint8_t *src, uint8_t *dest, size_t len) { + { backend.save(src, len) } -> std::same_as; + { backend.load(dest, len) } -> std::same_as; +}; + #if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2) && !defined(USE_LIBRETINY) && \ !defined(USE_HOST) && !(defined(USE_ZEPHYR) && defined(CONFIG_SETTINGS)) // Stub for static analysis when no platform is defined. @@ -40,6 +50,8 @@ struct PreferenceBackend { #endif using ESPPreferenceBackend = PreferenceBackend; +static_assert(PreferenceBackendContract, + "The platform's preference backend is missing part of the PreferenceBackend surface"); class ESPPreferenceObject { public: @@ -68,6 +80,39 @@ class ESPPreferenceObject { PreferenceBackend *backend_{nullptr}; }; +// The preferences manager method surface, asserted in esphome/core/preferences.h +// on the ESPPreferences alias each platform's preferences.h binds through +// DECLARE_PREFERENCE_ALIASES. Semantics beyond the signatures: +// - make_preference: the two-argument form applies the platform's historic +// default storage; in_flash=false may fall back to flash where the platform +// has no faster storage. +// - sync: commit pending writes to flash, true on success. +// - reset: forget unsaved changes and re-initialize the permanent storage +// (usually followed by a restart), true on success. +// The template forms are what component call sites use; PreferencesMixin +// supplies them, but the derived class's non-template overloads hide them +// unless it also declares `using PreferencesMixin::make_preference;`, so +// the concept pins those too. +template +concept PreferencesContract = requires(T prefs, size_t len, uint32_t type, bool in_flash) { + { prefs.make_preference(len, type, in_flash) } -> std::same_as; + { prefs.make_preference(len, type) } -> std::same_as; + { prefs.template make_preference(type, in_flash) } -> std::same_as; + { prefs.template make_preference(type) } -> std::same_as; + { prefs.sync() } -> std::same_as; + { prefs.reset() } -> std::same_as; +}; + +// Key-lookup platforms additionally provide load_from_key(), a one-shot read +// of a stored preference by key that migrate_preference() relies on; see the +// key-lookup note at the top of this file. Not part of PreferencesContract, +// so it is asserted in preferences.h only where USE_PREFERENCE_KEY_LOOKUP +// is set. +template +concept PreferencesKeyLookupContract = requires(T prefs, uint32_t type, uint8_t *data, size_t len) { + { prefs.load_from_key(type, data, len) } -> std::same_as; +}; + /// CRTP mixin providing type-safe template make_preference() helpers. /// Platform preferences classes inherit this to avoid duplicating these templates. template class PreferencesMixin { diff --git a/esphome/core/preferences.h b/esphome/core/preferences.h index d24d51164a..cfeddebda7 100644 --- a/esphome/core/preferences.h +++ b/esphome/core/preferences.h @@ -45,8 +45,18 @@ extern ESPPreferences *global_preferences; // NOLINT(cppcoreguidelines-avoid-no } // namespace esphome #endif +namespace esphome { +static_assert(PreferencesContract, + "The platform's preferences manager is missing part of the ESPPreferences surface " + "(esphome/core/preference_backend.h)"); +} // namespace esphome + #ifdef USE_PREFERENCE_KEY_LOOKUP namespace esphome { +static_assert(PreferencesKeyLookupContract, + "This platform emits USE_PREFERENCE_KEY_LOOKUP but its preferences manager does not provide " + "load_from_key() (esphome/core/preference_backend.h)"); + /// Copy preference data stored under old_key into new_pref (created for new_key) if the keys /// differ and new_pref has no data yet. scratch must hold at least size bytes. /// Returns true when scratch holds the entity's current data (loaded or just migrated). diff --git a/tests/component_tests/preferences/__init__.py b/tests/component_tests/preferences/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/preferences/config/bk72xx.yaml b/tests/component_tests/preferences/config/bk72xx.yaml new file mode 100644 index 0000000000..9ea4154bca --- /dev/null +++ b/tests/component_tests/preferences/config/bk72xx.yaml @@ -0,0 +1,5 @@ +esphome: + name: preftest + +bk72xx: + board: generic-bk7252 diff --git a/tests/component_tests/preferences/config/esp32.yaml b/tests/component_tests/preferences/config/esp32.yaml new file mode 100644 index 0000000000..586979d7b6 --- /dev/null +++ b/tests/component_tests/preferences/config/esp32.yaml @@ -0,0 +1,5 @@ +esphome: + name: preftest + +esp32: + board: esp32dev diff --git a/tests/component_tests/preferences/config/esp8266.yaml b/tests/component_tests/preferences/config/esp8266.yaml new file mode 100644 index 0000000000..b8a1035159 --- /dev/null +++ b/tests/component_tests/preferences/config/esp8266.yaml @@ -0,0 +1,5 @@ +esphome: + name: preftest + +esp8266: + board: esp01_1m diff --git a/tests/component_tests/preferences/config/host.yaml b/tests/component_tests/preferences/config/host.yaml new file mode 100644 index 0000000000..047f8693a8 --- /dev/null +++ b/tests/component_tests/preferences/config/host.yaml @@ -0,0 +1,4 @@ +esphome: + name: preftest + +host: diff --git a/tests/component_tests/preferences/config/nrf52.yaml b/tests/component_tests/preferences/config/nrf52.yaml new file mode 100644 index 0000000000..00892addb5 --- /dev/null +++ b/tests/component_tests/preferences/config/nrf52.yaml @@ -0,0 +1,6 @@ +esphome: + name: preftest + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 diff --git a/tests/component_tests/preferences/config/rp2.yaml b/tests/component_tests/preferences/config/rp2.yaml new file mode 100644 index 0000000000..d57b96a54e --- /dev/null +++ b/tests/component_tests/preferences/config/rp2.yaml @@ -0,0 +1,5 @@ +esphome: + name: preftest + +rp2: + board: rpipicow diff --git a/tests/component_tests/preferences/test_key_lookup_gate.py b/tests/component_tests/preferences/test_key_lookup_gate.py new file mode 100644 index 0000000000..7726113aab --- /dev/null +++ b/tests/component_tests/preferences/test_key_lookup_gate.py @@ -0,0 +1,39 @@ +"""Every preferences platform either emits USE_PREFERENCE_KEY_LOOKUP from +codegen (key-lookup backends) or must not (slot-based backends, whose managers +have no load_from_key()). Run each platform's real codegen and assert the +emission, mirroring the split the deny-list in esphome/core/defines.h assumes +for static analysis. + +The fixtures cover every distinct preferences backend today: ln882x and +rtl87xx route through libretiny (bk72xx stands in for the family), rp2040 is +an alias of rp2, and nrf52 exercises zephyr. A seventh backend needs a new +fixture here.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.core import CORE + + +@pytest.mark.parametrize( + ("fixture", "emits"), + [ + ("esp32.yaml", True), + ("bk72xx.yaml", True), # libretiny + ("host.yaml", True), + ("nrf52.yaml", True), # zephyr + ("esp8266.yaml", False), + ("rp2.yaml", False), + ], +) +def test_key_lookup_define_matches_the_platform_backend( + fixture: str, + emits: bool, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path(fixture)) + defines = {define.name for define in CORE.defines} + assert ("USE_PREFERENCE_KEY_LOOKUP" in defines) is emits diff --git a/tests/components/core/test_preference_contract.cpp b/tests/components/core/test_preference_contract.cpp new file mode 100644 index 0000000000..f0833a5929 --- /dev/null +++ b/tests/components/core/test_preference_contract.cpp @@ -0,0 +1,91 @@ +// Pins the preferences contract concepts so the surface they enforce cannot +// drift unnoticed: a minimal conforming type must satisfy each concept, and a +// type missing a method or returning the wrong type must not. + +#include + +#include "esphome/core/preference_backend.h" + +namespace esphome::core::testing { + +struct MinimalBackend { + bool save(const uint8_t *, size_t) { return true; } + bool load(uint8_t *, size_t) { return true; } +}; +static_assert(PreferenceBackendContract); + +struct BackendMissingLoad { + bool save(const uint8_t *, size_t) { return true; } +}; +static_assert(!PreferenceBackendContract); + +struct BackendWrongReturn { + void save(const uint8_t *, size_t) {} + bool load(uint8_t *, size_t) { return true; } +}; +static_assert(!PreferenceBackendContract); + +struct MinimalPreferences : public PreferencesMixin { + using PreferencesMixin::make_preference; + ESPPreferenceObject make_preference(size_t, uint32_t, bool) { return {}; } + ESPPreferenceObject make_preference(size_t, uint32_t) { return {}; } + bool sync() { return true; } + bool reset() { return true; } +}; +static_assert(PreferencesContract); + +struct PreferencesMissingTwoArgForm : public PreferencesMixin { + using PreferencesMixin::make_preference; + ESPPreferenceObject make_preference(size_t, uint32_t, bool) { return {}; } + bool sync() { return true; } + bool reset() { return true; } +}; +static_assert(!PreferencesContract); + +struct PreferencesMissingReset : public PreferencesMixin { + using PreferencesMixin::make_preference; + ESPPreferenceObject make_preference(size_t, uint32_t, bool) { return {}; } + ESPPreferenceObject make_preference(size_t, uint32_t) { return {}; } + bool sync() { return true; } +}; +static_assert(!PreferencesContract); + +struct PreferencesWrongSyncReturn : public PreferencesMixin { + using PreferencesMixin::make_preference; + ESPPreferenceObject make_preference(size_t, uint32_t, bool) { return {}; } + ESPPreferenceObject make_preference(size_t, uint32_t) { return {}; } + void sync() {} + bool reset() { return true; } +}; +static_assert(!PreferencesContract); + +// Forgot `using PreferencesMixin::make_preference;`, so the derived +// overloads hide the template forms (see the PreferencesContract note in +// preference_backend.h); the concept must reject the class. +struct PreferencesForgotUsingDeclaration : public PreferencesMixin { + ESPPreferenceObject make_preference(size_t, uint32_t, bool) { return {}; } + ESPPreferenceObject make_preference(size_t, uint32_t) { return {}; } + bool sync() { return true; } + bool reset() { return true; } +}; +static_assert(!PreferencesContract); + +struct MinimalKeyLookup { + bool load_from_key(uint32_t, uint8_t *, size_t) { return true; } +}; +static_assert(PreferencesKeyLookupContract); + +struct KeyLookupMissingMethod {}; +static_assert(!PreferencesKeyLookupContract); + +TEST(PreferenceContract, NullBackendRefusesBothOperations) { + // ESPPreferenceObject forwards to whichever backend the platform binds; a + // default-constructed object has no backend and must refuse both operations + // instead of crashing. + ESPPreferenceObject without_backend; + uint32_t value = 42; + EXPECT_FALSE(without_backend.save(&value)); + EXPECT_FALSE(without_backend.load(&value)); +} + +} // namespace esphome::core::testing From 2798ef4de29e0b7d8d05994f23f06acde1631926 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 11:20:38 -0500 Subject: [PATCH 1352/1815] [ota] Enforce the backend contract with a concept (#18192) --- esphome/components/ota/ota_backend.h | 21 ++++++++ esphome/components/ota/ota_backend_factory.h | 13 ++++- .../components/ota/test_backend_contract.cpp | 49 +++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 tests/components/ota/test_backend_contract.cpp diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index 01be46a518..aa93df60a5 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -4,6 +4,8 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#include +#include #include #ifdef USE_OTA_STATE_LISTENER @@ -78,6 +80,25 @@ enum OTAType : uint8_t { OTA_TYPE_UPDATE_BOOTLOADER = 0x02, }; +// The OTA backend method surface. Exactly one backend exists per build, +// selected in ota_backend_factory.h where this concept is asserted on +// make_ota_backend()'s return type. Semantics beyond the signatures: +// - begin: prepare for an image of the given size; ota_type defaults to an +// app update, so both call forms must be accepted. +// - set_update_md5: expected digest of the incoming image, hex string. +// - write: consume the next chunk; end: finalize and mark bootable. +// - abort: safe to call in any state, including after end(). +template +concept OTABackendContract = requires(T backend, size_t image_size, uint8_t *data, size_t len, const char *md5) { + { backend.begin(image_size, OTA_TYPE_UPDATE_APP) } -> std::same_as; + { backend.begin(image_size) } -> std::same_as; + backend.set_update_md5(md5); + { backend.write(data, len) } -> std::same_as; + { backend.end() } -> std::same_as; + backend.abort(); + { backend.supports_compression() } -> std::same_as; +}; + /** Listener interface for OTA state changes. * * Components can implement this interface to receive OTA state updates diff --git a/esphome/components/ota/ota_backend_factory.h b/esphome/components/ota/ota_backend_factory.h index c543983d8d..82d001ed9e 100644 --- a/esphome/components/ota/ota_backend_factory.h +++ b/esphome/components/ota/ota_backend_factory.h @@ -17,11 +17,22 @@ #else // Stub for static analysis when no platform is defined namespace esphome::ota { -struct StubOTABackend {}; +struct StubOTABackend { + OTAResponseTypes begin(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP) { + return OTA_RESPONSE_ERROR_UNKNOWN; + } + void set_update_md5(const char *md5) {} + OTAResponseTypes write(uint8_t *data, size_t len) { return OTA_RESPONSE_ERROR_UNKNOWN; } + OTAResponseTypes end() { return OTA_RESPONSE_ERROR_UNKNOWN; } + void abort() {} + bool supports_compression() { return false; } +}; std::unique_ptr make_ota_backend(); } // namespace esphome::ota #endif namespace esphome::ota { using OTABackendPtr = decltype(make_ota_backend()); +static_assert(OTABackendContract, + "The platform's OTA backend is missing part of the backend surface (ota_backend.h)"); } // namespace esphome::ota diff --git a/tests/components/ota/test_backend_contract.cpp b/tests/components/ota/test_backend_contract.cpp new file mode 100644 index 0000000000..1b4fbbc32d --- /dev/null +++ b/tests/components/ota/test_backend_contract.cpp @@ -0,0 +1,49 @@ +// Pins the OTA backend contract concept so the surface it enforces cannot +// drift unnoticed: the build's real backend and a minimal conforming type +// must satisfy it, and a type missing a method or returning the wrong type +// must not. + +#include "esphome/components/ota/ota_backend.h" +#include "esphome/components/ota/ota_backend_host.h" + +namespace esphome::ota::testing { + +struct MinimalBackend { + OTAResponseTypes begin(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP) { return OTA_RESPONSE_OK; } + void set_update_md5(const char *md5) {} + OTAResponseTypes write(uint8_t *data, size_t len) { return OTA_RESPONSE_OK; } + OTAResponseTypes end() { return OTA_RESPONSE_OK; } + void abort() {} + bool supports_compression() { return false; } +}; +static_assert(OTABackendContract); + +// Each negative case derives from MinimalBackend and breaks exactly one +// requirement; the declaration in the derived struct hides the conforming +// one from the base. + +// begin() without the default ota_type argument breaks consumers that only +// pass the image size. +struct BackendWithoutDefaultOTAType : MinimalBackend { + OTAResponseTypes begin(size_t image_size, OTAType ota_type) { return OTA_RESPONSE_OK; } +}; +static_assert(!OTABackendContract); + +struct BackendMissingAbort : MinimalBackend { + void abort() = delete; +}; +static_assert(!OTABackendContract); + +struct BackendWrongWriteReturn : MinimalBackend { + bool write(uint8_t *data, size_t len) { return true; } +}; +static_assert(!OTABackendContract); + +// Pin the build's real backend, not just local mocks: the unit test harness +// builds for the host platform, so this is the same check the factory's +// static_assert performs in a firmware compile. +#ifdef USE_HOST +static_assert(OTABackendContract); +#endif + +} // namespace esphome::ota::testing From 56ec21d950494463f0d079c5b2eb4c96e058869e Mon Sep 17 00:00:00 2001 From: Petter Ljungqvist Date: Mon, 10 Aug 2026 18:31:31 +0200 Subject: [PATCH 1353/1815] [ufm01] Improve startup with reset retry and passive polling fallback (#17567) Co-authored-by: Cursor Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/ufm01/ufm01.cpp | 306 ++++++++++++++++-- esphome/components/ufm01/ufm01.h | 56 +++- tests/components/ufm01/common.h | 156 +++++++++ tests/components/ufm01/ufm01_frame_test.cpp | 83 +++++ tests/components/ufm01/ufm01_startup_test.cpp | 43 +++ 5 files changed, 612 insertions(+), 32 deletions(-) create mode 100644 tests/components/ufm01/common.h create mode 100644 tests/components/ufm01/ufm01_frame_test.cpp create mode 100644 tests/components/ufm01/ufm01_startup_test.cpp diff --git a/esphome/components/ufm01/ufm01.cpp b/esphome/components/ufm01/ufm01.cpp index 1380c34284..2859ee4aaa 100644 --- a/esphome/components/ufm01/ufm01.cpp +++ b/esphome/components/ufm01/ufm01.cpp @@ -4,13 +4,23 @@ #include "esphome/core/log.h" #include +#include +#include namespace esphome::ufm01 { static const char *const TAG = "ufm01"; static constexpr uint8_t COMMAND_ACK = 0xE5; -static constexpr uint32_t COMMAND_ACK_TIMEOUT_MS = 200; +static constexpr uint32_t COMMAND_ACK_TIMEOUT_MS = 500; +static constexpr uint32_t STARTUP_DELAY_MS = 2000; +static constexpr uint32_t POST_RESET_DELAY_MS = 2000; +static constexpr uint32_t RESET_RETRY_DELAY_MS = 800; +static constexpr uint32_t STARTUP_RETRY_MS = 3000; +static constexpr uint32_t PASSIVE_POLL_INTERVAL_MS = 1000; +static constexpr uint32_t ACTIVE_STALE_MS = 5000; +static constexpr uint32_t PASSIVE_READ_TIMEOUT_MS = 1000; +static constexpr uint32_t ACTIVE_FRAME_TIMEOUT_MS = 3000; static constexpr float L_PER_M3 = 1000.0f; static constexpr float M3_PER_L = 1.0f / L_PER_M3; @@ -18,12 +28,14 @@ static constexpr float M3_PER_L = 1.0f / L_PER_M3; static constexpr std::array ACTIVE_MODE = {0xFE, 0xFE, 0x11, 0x5C, 0x00, 0x5C, 0x16}; static constexpr std::array CLEAR_ACCUMULATED_FLOW = {0xFE, 0xFE, 0x11, 0x5A, 0xFD, 0x57, 0x16}; static constexpr std::array RESET_DEVICE = {0xFE, 0xFE, 0x11, 0x5D, 0xCB, 0x28, 0x16}; +static constexpr std::array READ_SENSOR_DATA_NO_ID = {0xFE, 0xFE, 0x11, 0x5B, 0x0F, 0x6A, 0x16}; // Active-mode frame layout (datasheet Table 7) static constexpr size_t FRAME_CHECKSUM_INDEX = 30; static constexpr size_t FRAME_STOP_INDEX = 31; static constexpr uint8_t FRAME_START_BYTE_1 = 0x3C; static constexpr uint8_t FRAME_START_BYTE_2 = 0x32; +static constexpr uint8_t PASSIVE_START_BYTE_2 = 0x64; static constexpr uint8_t FRAME_STOP_BYTE = 0x16; static constexpr uint8_t FRAME_INDEX_INSTANT_FLOW_FLAG = 15; static constexpr uint8_t FRAME_INDEX_RESERVED_SECTION = 21; @@ -55,7 +67,7 @@ static bool check_byte(const uint8_t data[FRAME_SIZE], size_t index, uint8_t exp return false; } -static bool validate_data(uint8_t data[FRAME_SIZE]) { +static bool validate_active_frame(const uint8_t data[FRAME_SIZE]) { uint8_t sum = 0; for (size_t i = 0; i < FRAME_CHECKSUM_INDEX; ++i) sum += data[i]; @@ -68,13 +80,43 @@ static bool validate_data(uint8_t data[FRAME_SIZE]) { check_byte(data, FRAME_STOP_INDEX, FRAME_STOP_BYTE, "stop byte"); } -static float read_accumulated_flow(uint8_t data[FRAME_SIZE]) { +static bool validate_passive_frame(const uint8_t data[PASSIVE_FRAME_SIZE]) { + if (data[0] != FRAME_START_BYTE_1 || data[1] != PASSIVE_START_BYTE_2 || data[22] != FRAME_STOP_BYTE) + return false; + uint8_t sum = 0; + for (size_t i = 0; i < 21; ++i) + sum += data[i]; + return data[21] == (sum & 0xFF); +} + +static void passive_no_id_to_active_frame(const uint8_t passive[PASSIVE_FRAME_SIZE], uint8_t active[FRAME_SIZE]) { + std::memset(active, 0, FRAME_SIZE); + active[0] = FRAME_START_BYTE_1; + active[1] = FRAME_START_BYTE_2; + active[7] = 0x01; + active[8] = passive[2]; + for (size_t i = 0; i < 6; ++i) + active[9 + i] = passive[3 + i]; + active[15] = passive[9]; + for (size_t i = 0; i < 5; ++i) + active[16 + i] = passive[10 + i]; + active[21] = FRAME_FLAG_RESERVED_SECTION; + active[24] = passive[15]; + for (size_t i = 0; i < 3; ++i) + active[25 + i] = passive[16 + i]; + active[28] = passive[19]; + active[29] = passive[20]; + active[30] = passive[21]; + active[31] = FRAME_STOP_BYTE; +} + +static float read_accumulated_flow(const uint8_t data[FRAME_SIZE]) { return (data[FRAME_ACC_FLOW_FLAG_INDEX] == ACC_FLOW_M3_FLAG ? L_PER_M3 : 1.0f) * (to_float(data[14]) * 10000000.0f + to_float(data[13]) * 100000.0f + to_float(data[12]) * 1000.0f + to_float(data[11]) * 10.0f + to_float(data[10]) * 0.1f + to_float(data[9]) * 0.001f); } -static float read_flow(uint8_t data[FRAME_SIZE]) { +static float read_flow(const uint8_t data[FRAME_SIZE]) { return (data[FRAME_FLOW_SIGN_INDEX] == FLOW_NEGATIVE_SIGN ? -1.0f : 1.0f) * (to_float(data[19]) * 10000.0f + to_float(data[18]) * 100.0f + to_float(data[17]) + to_float(data[16]) * 0.01f) * @@ -86,7 +128,7 @@ static void log_hex(const uint8_t *data, size_t len) { ESP_LOGD(TAG, "%s", format_hex_pretty_to(hex_buf, data, len, ' ')); } -static float read_temperature(uint8_t data[FRAME_SIZE]) { +static float read_temperature(const uint8_t data[FRAME_SIZE]) { // happens sometimes before getting a real reading if (data[27] == 0x00 && (data[26] == 0x00 || data[26] == 0x70) && data[25] == 0x00) { return NAN; @@ -106,19 +148,39 @@ static bool read_flow_rate_out_of_range(const uint8_t data[FRAME_SIZE]) { return data[FRAME_ST2_INDEX] & ST2_FLOW_RATE_OUT_OF_RANGE_MASK; } -bool UFM01Component::send_command_(const std::array &command) { +void UFM01Component::flush_rx_() { + while (this->available()) { + uint8_t byte; + this->read_byte(&byte); + } + this->read_index_ = 0; +} + +void UFM01Component::send_command_no_wait_(const std::array &command) { + this->flush_rx_(); this->write_array(command); this->flush(); +} + +// Drains whatever is currently in the RX buffer, looking for a command ACK. +bool UFM01Component::consume_ack_() { + while (this->available()) { + uint8_t byte; + if (!this->read_byte(&byte)) + return false; + if (byte == COMMAND_ACK) + return true; + ESP_LOGV(TAG, "Unexpected byte while waiting for command ACK: 0x%02X", byte); + } + return false; +} + +bool UFM01Component::send_command_(const std::array &command) { + this->send_command_no_wait_(command); const uint32_t start = millis(); while (millis() - start < COMMAND_ACK_TIMEOUT_MS) { - if (this->available()) { - uint8_t byte; - if (this->read_byte(&byte)) { - if (byte == COMMAND_ACK) - return true; - ESP_LOGV(TAG, "Unexpected byte while waiting for command ACK: 0x%02X", byte); - } - } + if (this->consume_ack_()) + return true; delay(1); } return false; @@ -130,14 +192,12 @@ bool UFM01Component::clear_accumulated_flow_() { return this->send_command_(CLEA bool UFM01Component::set_active_mode_() { return this->send_command_(ACTIVE_MODE); } -float UFM01Component::get_setup_priority() const { return setup_priority::IO; } +float UFM01Component::get_setup_priority() const { return setup_priority::LATE; } void UFM01Component::setup() { ESP_LOGI(TAG, "Setting up UFM-01..."); - if (!this->set_active_mode_()) { - ESP_LOGW(TAG, "Failed to set active mode (no ACK from device)"); - this->mark_failed(); - } + this->startup_wait_ms_ = STARTUP_DELAY_MS; + this->set_startup_phase_(StartupPhase::WAIT); } void UFM01Component::dump_config() { @@ -154,12 +214,9 @@ void UFM01Component::dump_config() { LOG_BINARY_SENSOR(" ", "Flow Rate Out Of Range", this->flow_rate_out_of_range_binary_sensor_); #endif this->check_uart_settings(2400, 1, uart::UART_CONFIG_PARITY_EVEN, 8); - if (this->is_failed()) { - ESP_LOGW(TAG, "Setup failed: active mode not acknowledged by device"); - } } -void UFM01Component::on_data_(uint8_t data[FRAME_SIZE]) { +void UFM01Component::on_active_frame_(uint8_t data[FRAME_SIZE]) { bool empty_tube = read_empty_tube(data); #ifdef USE_BINARY_SENSOR if (this->ufc_chip_error_binary_sensor_ != nullptr) @@ -189,10 +246,14 @@ void UFM01Component::on_data_(uint8_t data[FRAME_SIZE]) { this->temperature_sensor_->publish_state(read_temperature(data)); } #endif + this->last_valid_frame_ms_ = millis(); + this->status_clear_warning(); + this->status_clear_error(); } -void UFM01Component::loop() { - // Drain the UART buffer each loop, reading one byte at a time into the frame +bool UFM01Component::process_active_stream_() { + bool got_valid_frame = false; + while (this->available()) { if (!this->read_byte(&this->data_[this->read_index_])) { ESP_LOGW(TAG, "unable to read byte"); @@ -201,23 +262,22 @@ void UFM01Component::loop() { } if ((this->read_index_ == 0 && this->data_[0] != FRAME_START_BYTE_1) || (this->read_index_ == 1 && this->data_[1] != FRAME_START_BYTE_2)) { - ESP_LOGW(TAG, "not start of data at %d (is 0x%02X)", this->read_index_, this->data_[this->read_index_]); + ESP_LOGD(TAG, "not start of data at %d (is 0x%02X)", this->read_index_, this->data_[this->read_index_]); this->read_index_ = 0; continue; } if (++this->read_index_ < static_cast(FRAME_SIZE)) continue; - // Full frame received - if (validate_data(this->data_)) { - this->on_data_(this->data_); + if (validate_active_frame(this->data_)) { + this->on_active_frame_(this->data_); this->read_index_ = 0; + got_valid_frame = true; continue; } - // Invalid frame: try to resync on the next start marker within the buffer log_hex(this->data_, sizeof(this->data_)); - ESP_LOGE(TAG, "unable to read data"); + ESP_LOGW(TAG, "unable to read data"); for (int32_t i = 2; i < static_cast(FRAME_STOP_INDEX) && this->read_index_ == static_cast(FRAME_SIZE); ++i) { if ((this->data_[i] == FRAME_START_BYTE_1) && (this->data_[i + 1] == FRAME_START_BYTE_2)) { @@ -229,6 +289,190 @@ void UFM01Component::loop() { if (this->read_index_ == static_cast(FRAME_SIZE)) this->read_index_ = 0; } + + return got_valid_frame; +} + +void UFM01Component::set_startup_phase_(StartupPhase phase) { + this->startup_phase_ = phase; + this->phase_start_ms_ = millis(); +} + +void UFM01Component::enter_active_stream_(const char *reason) { + ESP_LOGI(TAG, "UFM-01 active stream %s", reason); + this->operating_mode_ = OperatingMode::ACTIVE_STREAM; + this->passive_read_pending_ = false; +} + +void UFM01Component::start_passive_read_() { + this->send_command_no_wait_(READ_SENSOR_DATA_NO_ID); + this->passive_index_ = 0; + this->passive_start_ms_ = millis(); +} + +// Accumulates the reply to a passive read request across loop iterations. +PassiveReadResult UFM01Component::continue_passive_read_() { + while (this->available() && this->passive_index_ < PASSIVE_FRAME_SIZE) { + uint8_t byte; + if (!this->read_byte(&byte)) + break; + + if (this->passive_index_ == 0 && byte != FRAME_START_BYTE_1) + continue; + if (this->passive_index_ == 1 && byte != PASSIVE_START_BYTE_2) { + // The mismatched byte may itself be the start of the real frame + this->passive_index_ = (byte == FRAME_START_BYTE_1) ? 1 : 0; + continue; + } + this->passive_frame_[this->passive_index_++] = byte; + } + + if (this->passive_index_ < PASSIVE_FRAME_SIZE) { + if (millis() - this->passive_start_ms_ < PASSIVE_READ_TIMEOUT_MS) + return PassiveReadResult::PENDING; + ESP_LOGD(TAG, "passive read timeout (%zu/%zu bytes)", this->passive_index_, PASSIVE_FRAME_SIZE); + return PassiveReadResult::FAILURE; + } + + if (!validate_passive_frame(this->passive_frame_)) { + log_hex(this->passive_frame_, PASSIVE_FRAME_SIZE); + ESP_LOGW(TAG, "invalid passive frame"); + return PassiveReadResult::FAILURE; + } + + uint8_t active_frame[FRAME_SIZE]; + passive_no_id_to_active_frame(this->passive_frame_, active_frame); + this->on_active_frame_(active_frame); + return PassiveReadResult::SUCCESS; +} + +void UFM01Component::loop_startup_() { + const uint32_t elapsed = millis() - this->phase_start_ms_; + + switch (this->startup_phase_) { + case StartupPhase::WAIT: + // Pick up an already-streaming device without resetting it + if (this->process_active_stream_()) { + this->enter_active_stream_("started"); + return; + } + if (elapsed < this->startup_wait_ms_) + return; + ESP_LOGD(TAG, "Running startup sequence"); + this->status_set_warning("initializing UFM-01"); + this->reset_retried_ = false; + this->send_command_no_wait_(RESET_DEVICE); + this->set_startup_phase_(StartupPhase::RESET_WAIT_ACK); + return; + + case StartupPhase::RESET_WAIT_ACK: + if (this->consume_ack_()) { + this->set_startup_phase_(StartupPhase::POST_RESET_WAIT); + return; + } + if (elapsed < COMMAND_ACK_TIMEOUT_MS) + return; + if (!this->reset_retried_) { + ESP_LOGW(TAG, "Reset not acknowledged, retrying in %" PRIu32 " ms", RESET_RETRY_DELAY_MS); + this->set_startup_phase_(StartupPhase::RESET_RETRY_WAIT); + } else { + ESP_LOGW(TAG, "Reset failed during startup"); + this->set_startup_phase_(StartupPhase::POST_RESET_WAIT); + } + return; + + case StartupPhase::RESET_RETRY_WAIT: + if (elapsed < RESET_RETRY_DELAY_MS) + return; + this->reset_retried_ = true; + this->send_command_no_wait_(RESET_DEVICE); + this->set_startup_phase_(StartupPhase::RESET_WAIT_ACK); + return; + + case StartupPhase::POST_RESET_WAIT: + if (elapsed < POST_RESET_DELAY_MS) + return; + this->send_command_no_wait_(ACTIVE_MODE); + this->set_startup_phase_(StartupPhase::ACTIVE_WAIT_FRAME); + return; + + case StartupPhase::ACTIVE_WAIT_FRAME: + // The command ACK (0xE5) is consumed by the frame parser as noise + if (this->process_active_stream_()) { + this->enter_active_stream_("started"); + return; + } + if (elapsed < ACTIVE_FRAME_TIMEOUT_MS) + return; + this->start_passive_read_(); + this->set_startup_phase_(StartupPhase::PASSIVE_WAIT_REPLY); + return; + + case StartupPhase::PASSIVE_WAIT_REPLY: + switch (this->continue_passive_read_()) { + case PassiveReadResult::PENDING: + return; + case PassiveReadResult::SUCCESS: + ESP_LOGI(TAG, "UFM-01 using passive polling"); + this->operating_mode_ = OperatingMode::PASSIVE_POLL; + this->passive_read_pending_ = false; + this->last_poll_ms_ = millis(); + return; + case PassiveReadResult::FAILURE: + ESP_LOGW(TAG, "Startup failed, retrying in %" PRIu32 " ms", STARTUP_RETRY_MS); + this->startup_wait_ms_ = STARTUP_RETRY_MS; + this->set_startup_phase_(StartupPhase::WAIT); + return; + } + } +} + +void UFM01Component::loop_active_stream_() { + this->process_active_stream_(); + if (this->last_valid_frame_ms_ != 0 && millis() - this->last_valid_frame_ms_ > ACTIVE_STALE_MS) { + ESP_LOGW(TAG, "Active stream stale, switching to passive polling"); + this->operating_mode_ = OperatingMode::PASSIVE_POLL; + this->passive_read_pending_ = false; + this->last_poll_ms_ = 0; + this->status_set_warning("UFM-01 passive poll"); + } +} + +void UFM01Component::loop_passive_poll_() { + if (this->passive_read_pending_) { + const PassiveReadResult result = this->continue_passive_read_(); + if (result == PassiveReadResult::PENDING) + return; + this->passive_read_pending_ = false; + if (result == PassiveReadResult::FAILURE) + this->status_set_warning("UFM-01 passive poll failed"); + return; + } + + if (this->process_active_stream_()) { + this->enter_active_stream_("resumed"); + return; + } + + if (millis() - this->last_poll_ms_ >= PASSIVE_POLL_INTERVAL_MS) { + this->last_poll_ms_ = millis(); + this->start_passive_read_(); + this->passive_read_pending_ = true; + } +} + +void UFM01Component::loop() { + switch (this->operating_mode_) { + case OperatingMode::STARTUP: + this->loop_startup_(); + return; + case OperatingMode::ACTIVE_STREAM: + this->loop_active_stream_(); + return; + case OperatingMode::PASSIVE_POLL: + this->loop_passive_poll_(); + return; + } } } // namespace esphome::ufm01 diff --git a/esphome/components/ufm01/ufm01.h b/esphome/components/ufm01/ufm01.h index e759de9169..6c1da65167 100644 --- a/esphome/components/ufm01/ufm01.h +++ b/esphome/components/ufm01/ufm01.h @@ -11,12 +11,39 @@ #include "esphome/components/uart/uart.h" #include +#include // component API definition at https://www.sciosense.com/wp-content/uploads/2025/06/UFM-01-Datasheet-1.pdf namespace esphome::ufm01 { +namespace testing { +class TestableUFM01; +} // namespace testing + static constexpr size_t FRAME_SIZE = 32; +static constexpr size_t PASSIVE_FRAME_SIZE = 23; + +enum class OperatingMode : uint8_t { + STARTUP = 0, + ACTIVE_STREAM = 1, + PASSIVE_POLL = 2, +}; + +enum class StartupPhase : uint8_t { + WAIT = 0, + RESET_WAIT_ACK = 1, + RESET_RETRY_WAIT = 2, + POST_RESET_WAIT = 3, + ACTIVE_WAIT_FRAME = 4, + PASSIVE_WAIT_REPLY = 5, +}; + +enum class PassiveReadResult : uint8_t { + PENDING = 0, + SUCCESS = 1, + FAILURE = 2, +}; class UFM01Component : public uart::UARTDevice, public Component { #ifdef USE_SENSOR @@ -48,10 +75,37 @@ class UFM01Component : public uart::UARTDevice, public Component { private: bool send_command_(const std::array &command); + void send_command_no_wait_(const std::array &command); + bool consume_ack_(); + void flush_rx_(); + bool process_active_stream_(); + void on_active_frame_(uint8_t data[FRAME_SIZE]); + + void loop_startup_(); + void loop_active_stream_(); + void loop_passive_poll_(); + void set_startup_phase_(StartupPhase phase); + void enter_active_stream_(const char *reason); + void start_passive_read_(); + PassiveReadResult continue_passive_read_(); + + OperatingMode operating_mode_{OperatingMode::STARTUP}; + StartupPhase startup_phase_{StartupPhase::WAIT}; + uint32_t phase_start_ms_{0}; + uint32_t startup_wait_ms_{0}; + bool reset_retried_{false}; + uint32_t last_valid_frame_ms_{0}; + uint32_t last_poll_ms_{0}; + + bool passive_read_pending_{false}; + uint32_t passive_start_ms_{0}; + size_t passive_index_{0}; + uint8_t passive_frame_[PASSIVE_FRAME_SIZE]; int32_t read_index_ = 0; uint8_t data_[FRAME_SIZE]; - void on_data_(uint8_t data[FRAME_SIZE]); + + friend class testing::TestableUFM01; }; } // namespace esphome::ufm01 diff --git a/tests/components/ufm01/common.h b/tests/components/ufm01/common.h new file mode 100644 index 0000000000..1582358700 --- /dev/null +++ b/tests/components/ufm01/common.h @@ -0,0 +1,156 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include "esphome/components/uart/uart_component.h" +#include "esphome/components/ufm01/ufm01.h" + +namespace esphome::ufm01::testing { + +static constexpr uint8_t FRAME_START_BYTE_1 = 0x3C; +static constexpr uint8_t FRAME_START_BYTE_2 = 0x32; +static constexpr uint8_t PASSIVE_START_BYTE_2 = 0x64; +static constexpr uint8_t FRAME_STOP_BYTE = 0x16; +static constexpr uint8_t FRAME_FLAG_INSTANT_FLOW = 0x0B; +static constexpr uint8_t FRAME_FLAG_RESERVED_SECTION = 0x0C; +static constexpr uint8_t FRAME_FLAG_TEMP = 0x0D; +static constexpr uint8_t COMMAND_ACK = 0xE5; + +// UART mock with a byte queue for read-side simulation. +class QueuedMockUART : public uart::UARTComponent { + public: + std::deque rx_queue; + std::vector written_data; + + void enqueue(const std::vector &data) { + this->rx_queue.insert(this->rx_queue.end(), data.begin(), data.end()); + } + + void enqueue(std::initializer_list data) { + for (uint8_t byte : data) + this->rx_queue.push_back(byte); + } + + void clear_rx() { this->rx_queue.clear(); } + + bool read_array(uint8_t *data, size_t len) override { + if (this->rx_queue.size() < len) + return false; + for (size_t i = 0; i < len; ++i) { + data[i] = this->rx_queue.front(); + this->rx_queue.pop_front(); + } + return true; + } + + bool peek_byte(uint8_t *data) override { + if (this->rx_queue.empty()) + return false; + *data = this->rx_queue.front(); + return true; + } + + size_t available() override { return this->rx_queue.size(); } + + uart::UARTFlushResult flush() override { return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; } + + void write_array(const uint8_t *data, size_t len) override { this->written_data.assign(data, data + len); } + + void check_logger_conflict() override {} +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif +}; + +class TestableUFM01 : public UFM01Component { + public: + void set_mock_uart(QueuedMockUART *uart) { this->set_uart_parent(uart); } + + bool process_active_stream() { return this->process_active_stream_(); } + + PassiveReadResult continue_passive_read() { return this->continue_passive_read_(); } + + bool consume_ack() { return this->consume_ack_(); } + + void start_passive_read() { this->start_passive_read_(); } + + void loop_startup() { this->loop_startup_(); } + + OperatingMode operating_mode() const { return this->operating_mode_; } + + StartupPhase startup_phase() const { return this->startup_phase_; } + + int32_t read_index() const { return this->read_index_; } + + size_t passive_index() const { return this->passive_index_; } + + uint32_t last_valid_frame_ms() const { return this->last_valid_frame_ms_; } + + void prepare_passive_read() { + this->passive_index_ = 0; + this->passive_start_ms_ = millis(); + } + + void init_wait_phase() { + this->operating_mode_ = OperatingMode::STARTUP; + this->startup_phase_ = StartupPhase::WAIT; + this->startup_wait_ms_ = 60000; + this->phase_start_ms_ = millis(); + } + + void reset_state() { + this->read_index_ = 0; + this->last_valid_frame_ms_ = 0; + this->passive_index_ = 0; + this->passive_read_pending_ = false; + } +}; + +inline std::array make_active_frame() { + std::array frame{}; + frame[0] = FRAME_START_BYTE_1; + frame[1] = FRAME_START_BYTE_2; + frame[15] = FRAME_FLAG_INSTANT_FLOW; + frame[21] = FRAME_FLAG_RESERVED_SECTION; + frame[24] = FRAME_FLAG_TEMP; + frame[31] = FRAME_STOP_BYTE; + uint8_t sum = 0; + for (size_t i = 0; i < 30; ++i) + sum += frame[i]; + frame[30] = sum; + return frame; +} + +inline std::array make_passive_frame() { + std::array frame{}; + frame[0] = FRAME_START_BYTE_1; + frame[1] = PASSIVE_START_BYTE_2; + frame[9] = FRAME_FLAG_INSTANT_FLOW; + frame[15] = FRAME_FLAG_TEMP; + frame[22] = FRAME_STOP_BYTE; + uint8_t sum = 0; + for (size_t i = 0; i < 21; ++i) + sum += frame[i]; + frame[21] = sum; + return frame; +} + +class UFM01Test : public ::testing::Test { + protected: + void SetUp() override { + this->mock_uart_.clear_rx(); + this->mock_uart_.written_data.clear(); + this->ufm01_.set_mock_uart(&this->mock_uart_); + this->ufm01_.reset_state(); + } + + QueuedMockUART mock_uart_; + TestableUFM01 ufm01_; +}; + +} // namespace esphome::ufm01::testing diff --git a/tests/components/ufm01/ufm01_frame_test.cpp b/tests/components/ufm01/ufm01_frame_test.cpp new file mode 100644 index 0000000000..82d74b58a5 --- /dev/null +++ b/tests/components/ufm01/ufm01_frame_test.cpp @@ -0,0 +1,83 @@ +#include "common.h" + +namespace esphome::ufm01::testing { + +TEST_F(UFM01Test, ValidActiveFrameAccepted) { + auto frame = make_active_frame(); + this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + + EXPECT_TRUE(this->ufm01_.process_active_stream()); + EXPECT_EQ(this->ufm01_.read_index(), 0); + EXPECT_NE(this->ufm01_.last_valid_frame_ms(), 0u); +} + +TEST_F(UFM01Test, GarbagePrefixThenValidActiveFrame) { + this->mock_uart_.enqueue({0x00, 0xFF, 0xAA}); + auto frame = make_active_frame(); + this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + + EXPECT_TRUE(this->ufm01_.process_active_stream()); + EXPECT_EQ(this->ufm01_.read_index(), 0); +} + +TEST_F(UFM01Test, InvalidActiveFrameChecksumRejected) { + auto frame = make_active_frame(); + frame[30] ^= 0xFF; + this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + + EXPECT_FALSE(this->ufm01_.process_active_stream()); + EXPECT_EQ(this->ufm01_.read_index(), 0); + EXPECT_EQ(this->ufm01_.last_valid_frame_ms(), 0u); +} + +TEST_F(UFM01Test, ValidPassiveFrameReadSuccess) { + auto frame = make_passive_frame(); + this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->ufm01_.prepare_passive_read(); + + EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::SUCCESS); + EXPECT_EQ(this->ufm01_.passive_index(), PASSIVE_FRAME_SIZE); + EXPECT_NE(this->ufm01_.last_valid_frame_ms(), 0u); +} + +TEST_F(UFM01Test, InvalidPassiveChecksumFails) { + auto frame = make_passive_frame(); + frame[21] ^= 0xFF; + this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->ufm01_.prepare_passive_read(); + + EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::FAILURE); + EXPECT_EQ(this->ufm01_.last_valid_frame_ms(), 0u); +} + +TEST_F(UFM01Test, PassiveReadResyncsAfterGarbagePrefix) { + auto frame = make_passive_frame(); + this->mock_uart_.enqueue({0x00, 0x01, 0x02}); + this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->ufm01_.prepare_passive_read(); + + EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::SUCCESS); +} + +TEST_F(UFM01Test, PassiveReadResyncsOnSecondStartByte) { + auto frame = make_passive_frame(); + this->mock_uart_.enqueue({FRAME_START_BYTE_1, 0x99}); + this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->ufm01_.prepare_passive_read(); + + EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::SUCCESS); +} + +TEST_F(UFM01Test, PassiveReadPendingWhenPartial) { + auto frame = make_passive_frame(); + this->mock_uart_.enqueue(std::vector(frame.begin(), frame.begin() + 10)); + this->ufm01_.prepare_passive_read(); + + EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PENDING); + EXPECT_LT(this->ufm01_.passive_index(), PASSIVE_FRAME_SIZE); + + this->mock_uart_.enqueue(std::vector(frame.begin() + 10, frame.end())); + EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::SUCCESS); +} + +} // namespace esphome::ufm01::testing diff --git a/tests/components/ufm01/ufm01_startup_test.cpp b/tests/components/ufm01/ufm01_startup_test.cpp new file mode 100644 index 0000000000..6b7feb0f1b --- /dev/null +++ b/tests/components/ufm01/ufm01_startup_test.cpp @@ -0,0 +1,43 @@ +#include "common.h" + +#include "esphome/core/component.h" + +namespace esphome::ufm01::testing { + +TEST(UFM01SetupPriority, IsLate) { + TestableUFM01 ufm01; + EXPECT_EQ(ufm01.get_setup_priority(), setup_priority::LATE); +} + +TEST_F(UFM01Test, ConsumeAckFindsByteAmongGarbage) { + this->mock_uart_.enqueue({0x00, 0x01, COMMAND_ACK, 0x02}); + + EXPECT_TRUE(this->ufm01_.consume_ack()); + EXPECT_EQ(this->mock_uart_.available(), 1u); +} + +TEST_F(UFM01Test, ConsumeAckReturnsFalseWhenEmpty) { EXPECT_FALSE(this->ufm01_.consume_ack()); } + +TEST_F(UFM01Test, StartupWaitDetectsActiveStream) { + auto frame = make_active_frame(); + this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->ufm01_.init_wait_phase(); + + this->ufm01_.loop_startup(); + + EXPECT_EQ(this->ufm01_.operating_mode(), OperatingMode::ACTIVE_STREAM); + EXPECT_EQ(this->ufm01_.startup_phase(), StartupPhase::WAIT); +} + +TEST_F(UFM01Test, StartPassiveReadSendsCommand) { + this->ufm01_.start_passive_read(); + + ASSERT_EQ(this->mock_uart_.written_data.size(), 7u); + EXPECT_EQ(this->mock_uart_.written_data[0], 0xFE); + EXPECT_EQ(this->mock_uart_.written_data[1], 0xFE); + EXPECT_EQ(this->mock_uart_.written_data[2], 0x11); + EXPECT_EQ(this->mock_uart_.written_data[3], 0x5B); + EXPECT_EQ(this->mock_uart_.written_data[6], FRAME_STOP_BYTE); +} + +} // namespace esphome::ufm01::testing From bae7f1932329e9672297758551375e671b60a770 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 11:32:02 -0500 Subject: [PATCH 1354/1815] [core] Extend '/' in names deprecation window to 2027.7.0 (#18236) --- esphome/config_validation.py | 4 ++-- tests/unit_tests/test_config_validation.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index c04d43bbee..0eebf12e66 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -2331,13 +2331,13 @@ def _validate_no_slash(value): the visually similar Unicode FRACTION SLASH (U+2044) character. """ if "/" in value: - # Remove before 2026.7.0 + # Remove before 2027.7.0 new_value = value.replace("/", FRACTION_SLASH) _LOGGER.warning( "'%s' contains '/' which is reserved as a URL path separator. " "Automatically replacing with '%s' (Unicode FRACTION SLASH). " "Please update your configuration. " - "This will become an error in ESPHome 2026.7.0.", + "This will become an error in ESPHome 2027.7.0.", value, new_value, ) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 4a4e37e5c4..7627ef9273 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -932,7 +932,7 @@ def test_string_no_slash__slash_replaced_with_warning( actual = cv.string_no_slash(value) assert actual == expected assert "reserved as a URL path separator" in caplog.text - assert "will become an error in ESPHome 2026.7.0" in caplog.text + assert "will become an error in ESPHome 2027.7.0" in caplog.text def test_string_no_slash__long_string_allowed() -> None: From 2184ec292828be5a03e068fc809d785bc1ac43ad Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 10 Aug 2026 10:24:16 -0700 Subject: [PATCH 1355/1815] [ethernet] Add CH390 SPI ethernet support (#18226) Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: J. Nick Koston --- esphome/components/ethernet/__init__.py | 21 +++++-- .../components/ethernet/ethernet_component.h | 1 + .../ethernet/ethernet_component_esp32.cpp | 23 +++++++ esphome/core/defines.h | 1 + .../component_tests/ethernet/test_ethernet.py | 61 ++++++++++++++++++- tests/components/ethernet/common-ch390.yaml | 19 ++++++ .../ethernet/test-ch390.esp32-idf.yaml | 1 + 7 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 tests/components/ethernet/common-ch390.yaml create mode 100644 tests/components/ethernet/test-ch390.esp32-idf.yaml diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index d1d5e45c6b..8bdd536ffb 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -132,6 +132,7 @@ ETHERNET_TYPES = { "W6300": EthernetType.ETHERNET_TYPE_W6300, "GENERIC": EthernetType.ETHERNET_TYPE_GENERIC, "YT8531": EthernetType.ETHERNET_TYPE_YT8531, + "CH390": EthernetType.ETHERNET_TYPE_CH390, } # PHY types that need compile-time defines for conditional compilation @@ -153,6 +154,7 @@ _PHY_TYPE_TO_DEFINE = { "W6300": "USE_ETHERNET_W6300", "GENERIC": "USE_ETHERNET_GENERIC", "YT8531": "USE_ETHERNET_YT8531", + "CH390": "USE_ETHERNET_CH390", } @@ -176,13 +178,14 @@ _IDF6_ETHERNET_COMPONENTS: dict[str, IDFRegistryComponent] = { "DM9051": IDFRegistryComponent("espressif/dm9051", "1.1.0"), "ENC28J60": IDFRegistryComponent("espressif/enc28j60", "1.0.1"), "LAN8670": IDFRegistryComponent("espressif/lan867x", "2.0.0"), + "CH390": IDFRegistryComponent("espressif/ch390", "0.3.0"), } # These types are always external IDF components (never built-in to ESP-IDF) -_ALWAYS_EXTERNAL_IDF_COMPONENTS = {"LAN8670", "ENC28J60"} +_ALWAYS_EXTERNAL_IDF_COMPONENTS = {"LAN8670", "ENC28J60", "CH390"} # ESP32-only SPI ethernet types (W5100 is RP2040-only, no ESP-IDF driver) -SPI_ETHERNET_TYPES = {"W5500", "DM9051", "ENC28J60"} +SPI_ETHERNET_TYPES = {"W5500", "DM9051", "ENC28J60", "CH390"} # RP2-supported ethernet types (SPI and PIO QSPI). Applies to the whole # RP2 family (RP2040 and RP2350); the chip-specific W5100 caveat in the # comment above is about ESP-IDF driver coverage, not the RP2 platform. @@ -480,6 +483,12 @@ SPI_SCHEMA = _spi_schema() # of spec for it and makes the driver's CS hold time helper compute no hold SPI_SCHEMA_ENC28J60 = _spi_schema(default_clock="20MHz", max_clock=int(20e6)) +# The CH390H/D rates SCK at 50 MHz typical and 72 MHz maximum with VDDIO at 3.3V, +# so the shared 80 MHz ceiling is out of spec while the 26.67 MHz default is not. +# CH390 datasheet v1.8, tables 9-4 and 9-5: +# https://www.wch-ic.com/downloads/CH390DS1_PDF.html +SPI_SCHEMA_CH390 = _spi_schema(max_clock=int(72e6)) + CONFIG_SCHEMA = cv.All( cv.typed_schema( { @@ -494,6 +503,7 @@ CONFIG_SCHEMA = cv.All( "W5500": SPI_SCHEMA, "OPENETH": cv.All(BASE_SCHEMA, cv.only_on([Platform.ESP32])), "DM9051": SPI_SCHEMA, + "CH390": SPI_SCHEMA_CH390, "ENC28J60": SPI_SCHEMA_ENC28J60, "W6100": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])), "W6300": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])), @@ -629,8 +639,11 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: cg.add(var.set_interface(SPI_INTERFACE_MAP[config[CONF_INTERFACE]])) add_idf_sdkconfig_option("CONFIG_ETH_USE_SPI_ETHERNET", True) # CONFIG_ETH_SPI_ETHERNET_{TYPE} Kconfig options were removed in IDF 6.0 - # ENC28J60 was never built-in to IDF, so it has no Kconfig option - if idf_version() < cv.Version(6, 0, 0) and config[CONF_TYPE] != "ENC28J60": + # Types that are never built into IDF ship no Kconfig option at all + if ( + idf_version() < cv.Version(6, 0, 0) + and config[CONF_TYPE] not in _ALWAYS_EXTERNAL_IDF_COMPONENTS + ): add_idf_sdkconfig_option( f"CONFIG_ETH_SPI_ETHERNET_{config[CONF_TYPE]}", True ) diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 9f4398c621..dc084796e7 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -88,6 +88,7 @@ enum EthernetType : uint8_t { ETHERNET_TYPE_W6300, ETHERNET_TYPE_GENERIC, ETHERNET_TYPE_YT8531, + ETHERNET_TYPE_CH390, }; struct ManualIP { diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 94f4c23479..7cf8cdf736 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -50,6 +50,12 @@ #include "esp_eth_enc28j60.h" #endif +// CH390 headers exist on all IDF versions (always an external component) +#ifdef USE_ETHERNET_CH390 +#include "esp_eth_mac_ch390.h" +#include "esp_eth_phy_ch390.h" +#endif + #ifdef USE_ETHERNET_SPI #include #include @@ -215,6 +221,8 @@ void EthernetComponent::ethernet_lazy_init_() { eth_dm9051_config_t dm9051_config = ETH_DM9051_DEFAULT_CONFIG(host, &devcfg); #elif defined(USE_ETHERNET_ENC28J60) eth_enc28j60_config_t enc28j60_config = ETH_ENC28J60_DEFAULT_CONFIG(host, &devcfg); +#elif defined(USE_ETHERNET_CH390) + eth_ch390_config_t ch390_config = ETH_CH390_DEFAULT_CONFIG(host, &devcfg); #endif #if defined(USE_ETHERNET_W5500) @@ -236,6 +244,11 @@ void EthernetComponent::ethernet_lazy_init_() { // time (t10, 210 ns) after the last clock or MAC/MII register reads fail ("wrong chip ID") enc28j60_config.spi_devcfg->cs_ena_posttrans = enc28j60_cal_spi_cs_hold_time((this->clock_speed_ + 999999) / 1000000); enc28j60_config.int_gpio_num = this->interrupt_pin_; +#elif defined(USE_ETHERNET_CH390) + ch390_config.int_gpio_num = this->interrupt_pin_; +#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT + ch390_config.poll_period_ms = this->polling_interval_; +#endif #endif phy_config.phy_addr = this->phy_addr_spi_; @@ -360,6 +373,12 @@ void EthernetComponent::ethernet_lazy_init_() { this->phy_ = esp_eth_phy_new_enc28j60(&phy_config); break; } +#elif defined(USE_ETHERNET_CH390) + case ETHERNET_TYPE_CH390: { + mac = esp_eth_mac_new_ch390(&ch390_config, &mac_config); + this->phy_ = esp_eth_phy_new_ch390(&phy_config); + break; + } #endif #endif default: { @@ -519,6 +538,10 @@ void EthernetComponent::dump_config() { case ETHERNET_TYPE_ENC28J60: eth_type = "ENC28J60"; break; +#elif defined(USE_ETHERNET_CH390) + case ETHERNET_TYPE_CH390: + eth_type = "CH390"; + break; #endif #ifdef USE_ETHERNET_OPENETH case ETHERNET_TYPE_OPENETH: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 49b9583be3..319018a36f 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -397,6 +397,7 @@ #define USE_ETHERNET_W6100 #define USE_ETHERNET_W6300 #define USE_ETHERNET_DM9051 +#define USE_ETHERNET_CH390 #define CONFIG_ETH_SPI_ETHERNET_W5500 1 #define CONFIG_ETH_SPI_ETHERNET_DM9051 1 #define CONFIG_ETH_USE_ESP32_EMAC 1 diff --git a/tests/component_tests/ethernet/test_ethernet.py b/tests/component_tests/ethernet/test_ethernet.py index b3d37561c7..9308d0b099 100644 --- a/tests/component_tests/ethernet/test_ethernet.py +++ b/tests/component_tests/ethernet/test_ethernet.py @@ -1,13 +1,31 @@ -"""Tests for the ethernet final-validation coexistence gate.""" +"""Tests for the ethernet final-validation coexistence gate and schema bounds.""" import pytest from voluptuous import Invalid -from esphome.components.ethernet import _final_validate +from esphome import config_validation as cv +from esphome.components.esp32 import ( + KEY_BOARD, + KEY_IDF_VERSION, + KEY_VARIANT, + VARIANT_ESP32S3, +) +from esphome.components.ethernet import CONF_CLOCK_SPEED, CONFIG_SCHEMA, _final_validate from esphome.components.network import _validate_priority_list -from esphome.const import CONF_PRIORITY +from esphome.const import CONF_PRIORITY, PlatformFramework +from esphome.core import CORE import esphome.final_validate as fv +from ..types import SetCoreConfigCallable + +_CH390_CONFIG = { + "type": "CH390", + "clk_pin": 47, + "mosi_pin": 48, + "miso_pin": 14, + "cs_pin": 21, +} + @pytest.fixture(autouse=True) def _reset_full_config(): @@ -35,3 +53,40 @@ def test_rejects_wifi_and_ethernet_with_incomplete_priority() -> None: ) with pytest.raises(Invalid, match=r"must.*list both interfaces; missing: wifi"): _final_validate({}) + + +@pytest.mark.parametrize("clock_speed", ["26.67MHz", "72MHz"]) +def test_ch390_accepts_clock_speed_up_to_the_datasheet_maximum( + set_core_config: SetCoreConfigCallable, clock_speed: str +) -> None: + """CH390 SCK is rated to 72MHz, so the schema must accept the whole range.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + KEY_IDF_VERSION: cv.Version(5, 3, 2), + }, + ) + # _validate derives use_address from the node name, which has no default here. + CORE.name = "ch390-test" + config = CONFIG_SCHEMA({**_CH390_CONFIG, CONF_CLOCK_SPEED: clock_speed}) + assert config[CONF_CLOCK_SPEED] == cv.frequency(clock_speed) + + +def test_ch390_rejects_clock_speed_above_the_datasheet_maximum( + set_core_config: SetCoreConfigCallable, +) -> None: + """The shared 80MHz ceiling is out of spec for this part.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + KEY_IDF_VERSION: cv.Version(5, 3, 2), + }, + ) + # _validate derives use_address from the node name, which has no default here. + CORE.name = "ch390-test" + with pytest.raises(Invalid, match="value must be at most 72000000"): + CONFIG_SCHEMA({**_CH390_CONFIG, CONF_CLOCK_SPEED: "80MHz"}) diff --git a/tests/components/ethernet/common-ch390.yaml b/tests/components/ethernet/common-ch390.yaml new file mode 100644 index 0000000000..b27bc6ab4f --- /dev/null +++ b/tests/components/ethernet/common-ch390.yaml @@ -0,0 +1,19 @@ +ethernet: + type: CH390 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + clock_speed: 10Mhz + 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" + on_connect: + - logger.log: "Ethernet connected!" + on_disconnect: + - logger.log: "Ethernet disconnected!" diff --git a/tests/components/ethernet/test-ch390.esp32-idf.yaml b/tests/components/ethernet/test-ch390.esp32-idf.yaml new file mode 100644 index 0000000000..50165d458f --- /dev/null +++ b/tests/components/ethernet/test-ch390.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common-ch390.yaml From b7c6245388e1fed932f1050061c023c55c83507c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 12:39:32 -0500 Subject: [PATCH 1356/1815] [wifi] Save fast connect settings and reset roaming bookkeeping after driver initiated roams (#18167) --- esphome/components/wifi/wifi_component.cpp | 25 ++++++++++++++++--- esphome/components/wifi/wifi_component.h | 8 +++++- .../wifi/wifi_component_esp_idf.cpp | 13 ++++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 182e86daed..9e78e7c48e 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1676,7 +1676,7 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { this->clear_all_bssid_priorities_(); #ifdef USE_WIFI_FAST_CONNECT - this->save_fast_connect_settings_(); + this->save_fast_connect_settings_(this->wifi_bssid(), get_wifi_channel()); #endif this->release_scan_results_(); @@ -2301,9 +2301,7 @@ bool WiFiComponent::load_fast_connect_settings_(WiFiAP ¶ms) { return false; } -void WiFiComponent::save_fast_connect_settings_() { - bssid_t bssid = wifi_bssid(); - uint8_t channel = get_wifi_channel(); +void WiFiComponent::save_fast_connect_settings_(const bssid_t &bssid, uint8_t channel) { // selected_sta_index_ is always valid here (called only after successful connection) // Fallback to 0 is defensive programming for robustness int8_t ap_index = this->selected_sta_index_ >= 0 ? this->selected_sta_index_ : 0; @@ -2416,6 +2414,25 @@ void WiFiComponent::clear_roaming_state_() { this->roaming_state_ = RoamingState::IDLE; } +#ifdef USE_ESP32 +void WiFiComponent::handle_driver_roam_(const bssid_t &bssid, uint8_t channel) { + // A driver-initiated roam (e.g. 802.11v BTM) re-associates without the state + // machine ever leaving STA_CONNECTED, so check_connecting_finished() never runs. + // Redo its post-connect bookkeeping here. roaming_state_ is deliberately left + // untouched so an in-flight roaming scan is not orphaned. The BSSID and + // channel both come from the connected event so the saved pair is consistent: + // the radio may be off-channel during a roaming scan, and a later queued + // event may have moved the driver on again by the time this one is processed. + this->roaming_last_check_ = App.get_loop_component_start_time(); + this->roaming_attempts_ = 0; + this->roaming_scan_end_ = 0; + this->clear_all_bssid_priorities_(); +#ifdef USE_WIFI_FAST_CONNECT + this->save_fast_connect_settings_(bssid, channel); +#endif +} +#endif + void WiFiComponent::release_scan_results_() { if (!this->keep_scan_results_) { ScanResultsLock lock(this); diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 43e44a135f..a851ea4015 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -781,13 +781,19 @@ class WiFiComponent final : public Component { #ifdef USE_WIFI_FAST_CONNECT bool load_fast_connect_settings_(WiFiAP ¶ms); - void save_fast_connect_settings_(); + void save_fast_connect_settings_(const bssid_t &bssid, uint8_t channel); #endif // Post-connect roaming methods void check_roaming_(uint32_t now); void process_roaming_scan_(); void clear_roaming_state_(); +#ifdef USE_ESP32 + /// Redo post-connect bookkeeping after a driver-initiated roam (e.g. 802.11v BTM) + /// @param bssid The new AP's BSSID, taken from the connected event + /// @param channel The new AP's channel, taken from the connected event + void handle_driver_roam_(const bssid_t &bssid, uint8_t channel); +#endif /// Returns true if a component has requested that roaming scans be suppressed (e.g. during audio playback). bool roaming_suppressed_() const { diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index d78cd21380..783c000f7b 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -825,6 +825,19 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { (const char *) it.ssid, bssid_buf, it.channel, get_auth_mode_str(it.authmode)); #endif s_sta_connected = true; + if (this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTED) { + // Driver-initiated roam: the WIFI_REASON_ROAMING disconnect was ignored, + // so the state machine never left STA_CONNECTED. +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_INFO + char roam_bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + format_mac_addr_upper(it.bssid, roam_bssid_s); + ESP_LOGI(TAG, "Roamed ssid='%.*s' bssid=" LOG_SECRET("%s") " channel=%u", it.ssid_len, (const char *) it.ssid, + roam_bssid_s, it.channel); +#endif + bssid_t roam_bssid; + std::copy(it.bssid, it.bssid + 6, roam_bssid.begin()); + this->handle_driver_roam_(roam_bssid, it.channel); + } #ifdef USE_WIFI_CONNECT_STATE_LISTENERS // Defer listener notification until state machine reaches STA_CONNECTED // This ensures wifi.connected condition returns true in listener automations From ad733272e56df06a53532f8eb4736fa91819ca13 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 12:40:22 -0500 Subject: [PATCH 1357/1815] [bluetooth_proxy] Pair the advertisement flush time with hub_ to close alignment holes (#18234) --- esphome/components/bluetooth_proxy/bluetooth_proxy.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 26f99fcca2..d3e3144831 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -242,13 +242,13 @@ class BluetoothProxy final : public Component { std::array connections_{}; #endif ble_device_base::BLEHub *hub_{nullptr}; + // Group 3: 4-byte types; paired with hub_ so the 8-aligned messages below + // start on an even word, closing two alignment holes. + uint32_t last_advertisement_flush_time_{0}; // 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_; From 8f1e4397920da4849771666ab71b474d9f1648b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 12:56:16 -0500 Subject: [PATCH 1358/1815] [bluetooth_proxy] Finish the connection scan before reserving a slot (#18239) --- .../bluetooth_proxy/bluetooth_proxy.cpp | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 0a16567549..af52a25ec0 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -198,6 +198,10 @@ void BluetoothProxy::reset_connection_slot_(BluetoothConnection *connection, con } BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool reserve) { + // Finish the scan before reserving: a free slot earlier in the array must + // not win over a later slot that already holds the address, or one device + // ends up on two slots with a second connection attempt racing the first. + BluetoothConnection *free_slot = nullptr; for (uint8_t i = 0; i < this->connection_count_; i++) { auto *connection = this->connections_[i]; uint64_t conn_addr = connection->get_address(); @@ -205,18 +209,19 @@ BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool rese if (conn_addr == address) return connection; - if (reserve && conn_addr == 0) { - connection->send_service_ = INIT_SENDING_SERVICES; - connection->set_address(address); - // All connections must start at INIT - // We only set the state if we allocate the connection - // to avoid a race where multiple connection attempts - // are made. - connection->set_state(ClientState::INIT); - return connection; - } + if (free_slot == nullptr && conn_addr == 0) + free_slot = connection; } - return nullptr; + if (!reserve || free_slot == nullptr) + return nullptr; + free_slot->send_service_ = INIT_SENDING_SERVICES; + free_slot->set_address(address); + // All connections must start at INIT + // We only set the state if we allocate the connection + // to avoid a race where multiple connection attempts + // are made. + free_slot->set_state(ClientState::INIT); + return free_slot; } void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest &msg) { From 293d0b90d912b1a7121bb1347e88a03246ccb974 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 13:02:02 -0500 Subject: [PATCH 1359/1815] [core] Document enum class value naming to avoid platform SDK macro collisions (#18241) --- AGENTS.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 40381030cb..fa0f61c263 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,12 @@ This document provides essential context for AI models interacting with this pro - Function-local constants: `lower_snake_case` - Protected/private fields: `lower_snake_case_with_trailing_underscore_` - Favor descriptive names over abbreviations + - Enumerator names: prefix every value of an `enum class` with the enum name converted to + `UPPER_SNAKE_CASE` (e.g. `UARTFlushResult::UART_FLUSH_RESULT_SUCCESS`). Never use bare + names like `SUCCESS`, `FAILURE`, `OK`, or `FAIL`: platform SDK headers define macros with + these common names (for example the Realtek SDKs used by LibreTiny define + `#define SUCCESS 0` in `basic_types.h`), and the preprocessor replaces the enumerator + before the compiler sees it, breaking the build and clang-tidy on those platforms. * **Python Idioms:** * **Assignment expressions (PEP 572):** Prefer the walrus operator (`:=`) wherever it removes a redundant lookup or a throwaway temporary. The most common case in component code is presence-checking a config key and then indexing it separately — fetch once with `.get()` and bind in the condition instead: From 4cf7ad9c6320364fe2e95d0e2d19f37d6464faf4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 13:02:27 -0500 Subject: [PATCH 1360/1815] [ble_device_base] Treat a partially bound merger as unbound (#18235) --- .../ble_device_base/scan_response_merger.cpp | 3 ++- .../ble_device_base/test_scan_response_merger.cpp | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/esphome/components/ble_device_base/scan_response_merger.cpp b/esphome/components/ble_device_base/scan_response_merger.cpp index 15445cee02..2dd1fd6927 100644 --- a/esphome/components/ble_device_base/scan_response_merger.cpp +++ b/esphome/components/ble_device_base/scan_response_merger.cpp @@ -8,7 +8,8 @@ namespace esphome::ble_device_base { void ScanResponseMerger::deliver_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, bool raw_only) { - if (this->dispatcher_ == nullptr) + // A partial bind is treated as unbound; never dereference half a binding. + if (this->dispatcher_ == nullptr || this->scan_continuous_ == nullptr) return; this->dispatcher_->dispatch(mac, rssi, addr_type, data, data_len, raw_only, *this->scan_continuous_ ? nullptr : this->log_tag_); diff --git a/tests/components/ble_device_base/test_scan_response_merger.cpp b/tests/components/ble_device_base/test_scan_response_merger.cpp index 013c7bf8f9..ec157715fd 100644 --- a/tests/components/ble_device_base/test_scan_response_merger.cpp +++ b/tests/components/ble_device_base/test_scan_response_merger.cpp @@ -179,5 +179,15 @@ TEST_F(ScanResponseMergerTest, UnboundMergerDropsInsteadOfCrashing) { EXPECT_TRUE(unbound.empty()); } +TEST_F(ScanResponseMergerTest, PartialBindIsTreatedAsUnbound) { + ScanResponseMerger partial; + partial.bind(&this->dispatcher_, nullptr, "test"); + std::vector data(20, 0xAA); + partial.submit_scan_rsp(MAC_A, -70, 0, data.data(), data.size()); + partial.stash_adv(MAC_A, -40, 0, data.data(), data.size(), 0); + partial.flush(); // dropped, not dispatched through half a binding + EXPECT_TRUE(this->raw_.frames.empty()); +} + } // namespace } // namespace esphome::ble_device_base::testing From e4d08a73b0ce00848f71913dbd48a8a072c9a0dd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 13:03:01 -0500 Subject: [PATCH 1361/1815] [ufm01] Prefix PassiveReadResult enumerators to avoid Realtek SDK macro collision (#18240) --- esphome/components/ufm01/ufm01.cpp | 18 +++++++++--------- esphome/components/ufm01/ufm01.h | 6 +++--- tests/components/ufm01/ufm01_frame_test.cpp | 12 ++++++------ 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/esphome/components/ufm01/ufm01.cpp b/esphome/components/ufm01/ufm01.cpp index 2859ee4aaa..bafdb5d853 100644 --- a/esphome/components/ufm01/ufm01.cpp +++ b/esphome/components/ufm01/ufm01.cpp @@ -329,21 +329,21 @@ PassiveReadResult UFM01Component::continue_passive_read_() { if (this->passive_index_ < PASSIVE_FRAME_SIZE) { if (millis() - this->passive_start_ms_ < PASSIVE_READ_TIMEOUT_MS) - return PassiveReadResult::PENDING; + return PassiveReadResult::PASSIVE_READ_RESULT_PENDING; ESP_LOGD(TAG, "passive read timeout (%zu/%zu bytes)", this->passive_index_, PASSIVE_FRAME_SIZE); - return PassiveReadResult::FAILURE; + return PassiveReadResult::PASSIVE_READ_RESULT_FAILURE; } if (!validate_passive_frame(this->passive_frame_)) { log_hex(this->passive_frame_, PASSIVE_FRAME_SIZE); ESP_LOGW(TAG, "invalid passive frame"); - return PassiveReadResult::FAILURE; + return PassiveReadResult::PASSIVE_READ_RESULT_FAILURE; } uint8_t active_frame[FRAME_SIZE]; passive_no_id_to_active_frame(this->passive_frame_, active_frame); this->on_active_frame_(active_frame); - return PassiveReadResult::SUCCESS; + return PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS; } void UFM01Component::loop_startup_() { @@ -410,15 +410,15 @@ void UFM01Component::loop_startup_() { case StartupPhase::PASSIVE_WAIT_REPLY: switch (this->continue_passive_read_()) { - case PassiveReadResult::PENDING: + case PassiveReadResult::PASSIVE_READ_RESULT_PENDING: return; - case PassiveReadResult::SUCCESS: + case PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS: ESP_LOGI(TAG, "UFM-01 using passive polling"); this->operating_mode_ = OperatingMode::PASSIVE_POLL; this->passive_read_pending_ = false; this->last_poll_ms_ = millis(); return; - case PassiveReadResult::FAILURE: + case PassiveReadResult::PASSIVE_READ_RESULT_FAILURE: ESP_LOGW(TAG, "Startup failed, retrying in %" PRIu32 " ms", STARTUP_RETRY_MS); this->startup_wait_ms_ = STARTUP_RETRY_MS; this->set_startup_phase_(StartupPhase::WAIT); @@ -441,10 +441,10 @@ void UFM01Component::loop_active_stream_() { void UFM01Component::loop_passive_poll_() { if (this->passive_read_pending_) { const PassiveReadResult result = this->continue_passive_read_(); - if (result == PassiveReadResult::PENDING) + if (result == PassiveReadResult::PASSIVE_READ_RESULT_PENDING) return; this->passive_read_pending_ = false; - if (result == PassiveReadResult::FAILURE) + if (result == PassiveReadResult::PASSIVE_READ_RESULT_FAILURE) this->status_set_warning("UFM-01 passive poll failed"); return; } diff --git a/esphome/components/ufm01/ufm01.h b/esphome/components/ufm01/ufm01.h index 6c1da65167..0a39dcc9af 100644 --- a/esphome/components/ufm01/ufm01.h +++ b/esphome/components/ufm01/ufm01.h @@ -40,9 +40,9 @@ enum class StartupPhase : uint8_t { }; enum class PassiveReadResult : uint8_t { - PENDING = 0, - SUCCESS = 1, - FAILURE = 2, + PASSIVE_READ_RESULT_PENDING = 0, + PASSIVE_READ_RESULT_SUCCESS = 1, + PASSIVE_READ_RESULT_FAILURE = 2, }; class UFM01Component : public uart::UARTDevice, public Component { diff --git a/tests/components/ufm01/ufm01_frame_test.cpp b/tests/components/ufm01/ufm01_frame_test.cpp index 82d74b58a5..3b1b148d50 100644 --- a/tests/components/ufm01/ufm01_frame_test.cpp +++ b/tests/components/ufm01/ufm01_frame_test.cpp @@ -35,7 +35,7 @@ TEST_F(UFM01Test, ValidPassiveFrameReadSuccess) { this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); this->ufm01_.prepare_passive_read(); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::SUCCESS); + EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS); EXPECT_EQ(this->ufm01_.passive_index(), PASSIVE_FRAME_SIZE); EXPECT_NE(this->ufm01_.last_valid_frame_ms(), 0u); } @@ -46,7 +46,7 @@ TEST_F(UFM01Test, InvalidPassiveChecksumFails) { this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); this->ufm01_.prepare_passive_read(); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::FAILURE); + EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_FAILURE); EXPECT_EQ(this->ufm01_.last_valid_frame_ms(), 0u); } @@ -56,7 +56,7 @@ TEST_F(UFM01Test, PassiveReadResyncsAfterGarbagePrefix) { this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); this->ufm01_.prepare_passive_read(); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::SUCCESS); + EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS); } TEST_F(UFM01Test, PassiveReadResyncsOnSecondStartByte) { @@ -65,7 +65,7 @@ TEST_F(UFM01Test, PassiveReadResyncsOnSecondStartByte) { this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); this->ufm01_.prepare_passive_read(); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::SUCCESS); + EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS); } TEST_F(UFM01Test, PassiveReadPendingWhenPartial) { @@ -73,11 +73,11 @@ TEST_F(UFM01Test, PassiveReadPendingWhenPartial) { this->mock_uart_.enqueue(std::vector(frame.begin(), frame.begin() + 10)); this->ufm01_.prepare_passive_read(); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PENDING); + EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_PENDING); EXPECT_LT(this->ufm01_.passive_index(), PASSIVE_FRAME_SIZE); this->mock_uart_.enqueue(std::vector(frame.begin() + 10, frame.end())); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::SUCCESS); + EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS); } } // namespace esphome::ufm01::testing From 9e78a768a21b5b37acb15f07261cb5cc23eb9f9e Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Mon, 10 Aug 2026 21:02:16 +0200 Subject: [PATCH 1362/1815] [mitsubishi_cn105] Extract top-level hub (#16987) --- .../components/mitsubishi_cn105/__init__.py | 137 ++++++++++ .../components/mitsubishi_cn105/automation.h | 23 ++ .../components/mitsubishi_cn105/climate.py | 240 +++++++++++++----- .../mitsubishi_cn105/mitsubishi_cn105.cpp | 29 ++- .../mitsubishi_cn105/mitsubishi_cn105.h | 29 ++- .../mitsubishi_cn105_climate.cpp | 46 ++-- .../mitsubishi_cn105_climate.h | 26 +- .../mitsubishi_cn105_component.cpp | 34 +++ .../mitsubishi_cn105_component.h | 46 ++++ ...op_level_hub_with_legacy_climate_keys.yaml | 10 + .../mitsubishi_cn105/test_climate.py | 30 +++ .../climate/mitsubishi_cn105_tests.cpp | 8 +- tests/components/mitsubishi_cn105/common.h | 8 +- tests/components/mitsubishi_cn105/common.yaml | 15 +- ...test-legacy-climate-actions.esp32-idf.yaml | 16 ++ ...nt-temperature-min-interval.esp32-idf.yaml | 7 + ...date-legacy-climate-minimal.esp32-idf.yaml | 6 + ...date-legacy-climate-uart-id.esp32-idf.yaml | 7 + ...acy-climate-update-interval.esp32-idf.yaml | 7 + .../validate-top-level-minimal.esp32-idf.yaml | 8 + 20 files changed, 592 insertions(+), 140 deletions(-) create mode 100644 esphome/components/mitsubishi_cn105/automation.h create mode 100644 esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp create mode 100644 esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h create mode 100644 tests/component_tests/mitsubishi_cn105/fixtures/top_level_hub_with_legacy_climate_keys.yaml create mode 100644 tests/component_tests/mitsubishi_cn105/test_climate.py create mode 100644 tests/components/mitsubishi_cn105/test-legacy-climate-actions.esp32-idf.yaml create mode 100644 tests/components/mitsubishi_cn105/validate-legacy-climate-current-temperature-min-interval.esp32-idf.yaml create mode 100644 tests/components/mitsubishi_cn105/validate-legacy-climate-minimal.esp32-idf.yaml create mode 100644 tests/components/mitsubishi_cn105/validate-legacy-climate-uart-id.esp32-idf.yaml create mode 100644 tests/components/mitsubishi_cn105/validate-legacy-climate-update-interval.esp32-idf.yaml create mode 100644 tests/components/mitsubishi_cn105/validate-top-level-minimal.esp32-idf.yaml diff --git a/esphome/components/mitsubishi_cn105/__init__.py b/esphome/components/mitsubishi_cn105/__init__.py index e69de29bb2..7d5594495a 100644 --- a/esphome/components/mitsubishi_cn105/__init__.py +++ b/esphome/components/mitsubishi_cn105/__init__.py @@ -0,0 +1,137 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_TEMPERATURE, CONF_UPDATE_INTERVAL +from esphome.core import ID +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType, TemplateArgsType + +CODEOWNERS = ["@crnjan"] +DEPENDENCIES = ["uart"] +DOMAIN = "mitsubishi_cn105" + +CONF_MITSUBISHI_CN105_ID = f"{DOMAIN}_id" +CONF_TELEMETRY_REQUEST_MIN_INTERVAL = "telemetry_request_min_interval" + +mitsubishi_ns = cg.esphome_ns.namespace(DOMAIN) + +MitsubishiCN105Component = mitsubishi_ns.class_( + "MitsubishiCN105Component", + cg.Component, + uart.UARTDevice, +) + +SetRemoteTemperatureAction = mitsubishi_ns.class_( + "SetRemoteTemperatureAction", + automation.Action, + cg.Parented.template(MitsubishiCN105Component), +) + +ClearRemoteTemperatureAction = mitsubishi_ns.class_( + "ClearRemoteTemperatureAction", + automation.Action, + cg.Parented.template(MitsubishiCN105Component), +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(MitsubishiCN105Component), + cv.Optional(CONF_UPDATE_INTERVAL, default="1s"): cv.update_interval, + cv.Optional( + CONF_TELEMETRY_REQUEST_MIN_INTERVAL, default="60s" + ): cv.update_interval, + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA) +) + +MITSUBISHI_CN105_DEVICE_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_MITSUBISHI_CN105_ID): cv.use_id(MitsubishiCN105Component), + } +) + +FINAL_VALIDATE_SCHEMA = cv.All( + uart.final_validate_device_schema( + DOMAIN, + require_rx=True, + require_tx=True, + data_bits=8, + parity="EVEN", + stop_bits=1, + ) +) + + +async def register_mitsubishi_cn105_device(var: MockObj, config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_MITSUBISHI_CN105_ID]) + cg.add(var.set_parent(parent)) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + cg.add( + var.set_telemetry_request_min_interval( + config[CONF_TELEMETRY_REQUEST_MIN_INTERVAL] + ) + ) + + +REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Component), + cv.Required(CONF_TEMPERATURE): cv.templatable( + cv.All( + cv.temperature, + cv.Range(min=8.0, max=39.5), + ) + ), + } +) + +CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Component), + } +) + + +@automation.register_action( + f"{DOMAIN}.set_remote_temperature", + SetRemoteTemperatureAction, + REMOTE_TEMPERATURE_ACTION_SCHEMA, + synchronous=True, +) +async def remote_temperature_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + temperature = await cg.templatable(config[CONF_TEMPERATURE], args, float) + cg.add(var.set_temperature(temperature)) + return var + + +@automation.register_action( + f"{DOMAIN}.clear_remote_temperature", + ClearRemoteTemperatureAction, + CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA, + synchronous=True, +) +async def clear_temperature_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var diff --git a/esphome/components/mitsubishi_cn105/automation.h b/esphome/components/mitsubishi_cn105/automation.h new file mode 100644 index 0000000000..879e556f9c --- /dev/null +++ b/esphome/components/mitsubishi_cn105/automation.h @@ -0,0 +1,23 @@ +#pragma once + +#include "mitsubishi_cn105_component.h" + +#include "esphome/core/automation.h" + +namespace esphome::mitsubishi_cn105 { + +template +class SetRemoteTemperatureAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(float, temperature) + + void play(const Ts &...x) override { this->parent_->set_remote_temperature(this->temperature_.value(x...)); } +}; + +template +class ClearRemoteTemperatureAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); } +}; + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/climate.py b/esphome/components/mitsubishi_cn105/climate.py index 522b9218fc..64475d0e32 100644 --- a/esphome/components/mitsubishi_cn105/climate.py +++ b/esphome/components/mitsubishi_cn105/climate.py @@ -1,3 +1,5 @@ +import logging + from esphome import automation import esphome.codegen as cg from esphome.components import climate, uart @@ -7,126 +9,248 @@ from esphome.const import ( CONF_ID, CONF_SUPPORTED_SWING_MODES, CONF_TEMPERATURE, + CONF_UART_ID, CONF_UPDATE_INTERVAL, ) -from esphome.core import ID +from esphome.core import CORE, ID from esphome.cpp_generator import MockObj +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType, TemplateArgsType +from . import ( + CONF_MITSUBISHI_CN105_ID, + DOMAIN, + MITSUBISHI_CN105_DEVICE_SCHEMA, + MitsubishiCN105Component, + mitsubishi_ns, + register_mitsubishi_cn105_device, +) + +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. DEPENDENCIES = ["uart"] AUTO_LOAD = ["climate"] -CODEOWNERS = ["@crnjan"] +_LOGGER = logging.getLogger(__name__) + +# Deprecated legacy climate-owned hub option. Remove in 2027.2.0. CONF_CURRENT_TEMPERATURE_MIN_INTERVAL = "current_temperature_min_interval" - -mitsubishi_ns = cg.esphome_ns.namespace("mitsubishi_cn105") +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. +CONF_LEGACY_MITSUBISHI_CN105_ID = "legacy_mitsubishi_cn105_id" MitsubishiCN105Climate = mitsubishi_ns.class_( "MitsubishiCN105Climate", climate.Climate, cg.Component, - uart.UARTDevice, + cg.Parented.template(MitsubishiCN105Component), ) -SetRemoteTemperatureAction = mitsubishi_ns.class_( - "SetRemoteTemperatureAction", +# Legacy climate action compatibility. Remove in 2027.2.0. +LegacySetRemoteTemperatureAction = mitsubishi_ns.class_( + "LegacySetRemoteTemperatureAction", automation.Action, cg.Parented.template(MitsubishiCN105Climate), ) -ClearRemoteTemperatureAction = mitsubishi_ns.class_( - "ClearRemoteTemperatureAction", +# Legacy climate action compatibility. Remove in 2027.2.0. +LegacyClearRemoteTemperatureAction = mitsubishi_ns.class_( + "LegacyClearRemoteTemperatureAction", automation.Action, cg.Parented.template(MitsubishiCN105Climate), ) -CONFIG_SCHEMA = ( - climate.climate_schema(MitsubishiCN105Climate) - .extend(uart.UART_DEVICE_SCHEMA) + +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. +def _has_top_level_hub_config() -> bool: + return DOMAIN in (CORE.raw_config or {}) + + +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. +def _prepare_legacy_hub_config(config: ConfigType) -> ConfigType: + _LOGGER.warning( + "Defining 'climate.mitsubishi_cn105' without a top-level '%s:' hub is " + "deprecated. Declare '%s:' and reference it with '%s:' instead. Will " + "be removed in ESPHome 2027.2.0.", + DOMAIN, + DOMAIN, + CONF_MITSUBISHI_CN105_ID, + ) + + # Add the hidden hub declaration only for legacy climate-owned configs, + # so normal auto-ID resolution does not see it as a top-level hub. + config[CONF_LEGACY_MITSUBISHI_CN105_ID] = cv.declare_id(MitsubishiCN105Component)( + None + ) + return config + + +_BASE_SCHEMA = climate.climate_schema(MitsubishiCN105Climate).extend( + { + cv.Optional( + CONF_SUPPORTED_SWING_MODES, default="OFF" + ): validate_climate_swing_mode, + } +) + +_HUB_SCHEMA = _BASE_SCHEMA.extend(MITSUBISHI_CN105_DEVICE_SCHEMA) + +# Hub options accepted in the legacy climate-owned configuration. When a +# top-level hub exists, leaving these on the climate is always a migration +# mistake and the generic schema error does not explain where they belong. +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. +_LEGACY_HUB_KEYS = ( + CONF_CURRENT_TEMPERATURE_MIN_INTERVAL, + CONF_UART_ID, + CONF_UPDATE_INTERVAL, +) + + +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. +def _validate_no_legacy_hub_keys(config: ConfigType) -> ConfigType: + legacy_keys = [key for key in _LEGACY_HUB_KEYS if key in config] + if not legacy_keys: + return config + + keys = ", ".join(f"'{key}'" for key in legacy_keys) + message = f"{keys} must be moved under the top-level '{DOMAIN}:' block" + if CONF_CURRENT_TEMPERATURE_MIN_INTERVAL in legacy_keys: + message += ( + f"; rename '{CONF_CURRENT_TEMPERATURE_MIN_INTERVAL}' to " + "'telemetry_request_min_interval' there" + ) + raise cv.Invalid(message) + + +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. +_LEGACY_SCHEMA = ( + _BASE_SCHEMA.extend(uart.UART_DEVICE_SCHEMA) .extend( { - cv.Optional(CONF_UPDATE_INTERVAL, default="1s"): cv.update_interval, - cv.Optional( - CONF_CURRENT_TEMPERATURE_MIN_INTERVAL, default="60s" - ): cv.update_interval, - cv.Optional( - CONF_SUPPORTED_SWING_MODES, default="OFF" - ): validate_climate_swing_mode, + cv.Optional(CONF_CURRENT_TEMPERATURE_MIN_INTERVAL): cv.update_interval, + cv.Optional(CONF_UPDATE_INTERVAL): cv.update_interval, } ) + .add_extra(_prepare_legacy_hub_config) ) -FINAL_VALIDATE_SCHEMA = cv.All( - uart.final_validate_device_schema( - "mitsubishi_cn105", + +@schema_extractor("schema") +def CONFIG_SCHEMA(config: ConfigType) -> ConfigType: + if config is SCHEMA_EXTRACT: + return _HUB_SCHEMA + if CONF_MITSUBISHI_CN105_ID in config or _has_top_level_hub_config(): + return _HUB_SCHEMA(_validate_no_legacy_hub_keys(config)) + return _LEGACY_SCHEMA(config) + + +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. +def _legacy_final_validate(config: ConfigType) -> ConfigType: + if CONF_MITSUBISHI_CN105_ID in config: + return config + + return uart.final_validate_device_schema( + DOMAIN, require_rx=True, require_tx=True, data_bits=8, parity="EVEN", stop_bits=1, - ) -) + )(config) + + +FINAL_VALIDATE_SCHEMA = _legacy_final_validate async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) - cg.add(var.set_supported_swing_mode(config[CONF_SUPPORTED_SWING_MODES])) - cg.add( - var.set_current_temperature_min_interval( - config[CONF_CURRENT_TEMPERATURE_MIN_INTERVAL] - ) - ) - - -@automation.register_action( - "climate.mitsubishi_cn105.set_remote_temperature", - SetRemoteTemperatureAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate), - cv.Required(CONF_TEMPERATURE): cv.templatable( - cv.All( - cv.temperature, - cv.Range(min=8.0, max=39.5), + climate_config = config.copy() + # update_interval configures the protocol hub, not the climate entity. + climate_config.pop(CONF_UPDATE_INTERVAL, None) + await cg.register_component(var, climate_config) + if CONF_MITSUBISHI_CN105_ID in config: + await register_mitsubishi_cn105_device(var, config) + else: + # Legacy climate-owned hub compatibility. Remove in 2027.2.0. + parent = cg.new_Pvariable(config[CONF_LEGACY_MITSUBISHI_CN105_ID]) + await cg.register_component(parent, config) + await uart.register_uart_device(parent, config) + if CONF_CURRENT_TEMPERATURE_MIN_INTERVAL in config: + cg.add( + parent.set_telemetry_request_min_interval( + config[CONF_CURRENT_TEMPERATURE_MIN_INTERVAL] ) - ), - } - ), + ) + cg.add(var.set_parent(parent)) + cg.add(var.set_supported_swing_mode(config[CONF_SUPPORTED_SWING_MODES])) + + +# Legacy climate action compatibility. Remove in 2027.2.0. +LEGACY_REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate), + cv.Required(CONF_TEMPERATURE): cv.templatable( + cv.All( + cv.temperature, + cv.Range(min=8.0, max=39.5), + ) + ), + } +) + +# Legacy climate action compatibility. Remove in 2027.2.0. +LEGACY_CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate), + } +) + + +# Legacy climate action compatibility. Remove in 2027.2.0. +@automation.register_action( + f"climate.{DOMAIN}.set_remote_temperature", + LegacySetRemoteTemperatureAction, + LEGACY_REMOTE_TEMPERATURE_ACTION_SCHEMA, synchronous=True, ) -async def set_remote_temperature_action_to_code( +async def legacy_remote_temperature_action_to_code( config: ConfigType, action_id: ID, template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: + _LOGGER.warning( + "The 'climate.%s.set_remote_temperature' action is deprecated. Use " + "'%s.set_remote_temperature' instead. It will be removed in ESPHome " + "2027.2.0.", + DOMAIN, + DOMAIN, + ) var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - temperature = await cg.templatable(config[CONF_TEMPERATURE], args, float) cg.add(var.set_temperature(temperature)) - return var +# Legacy climate action compatibility. Remove in 2027.2.0. @automation.register_action( - "climate.mitsubishi_cn105.clear_remote_temperature", - ClearRemoteTemperatureAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate), - } - ), + f"climate.{DOMAIN}.clear_remote_temperature", + LegacyClearRemoteTemperatureAction, + LEGACY_CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA, synchronous=True, ) -async def clear_remote_temperature_action_to_code( +async def legacy_clear_temperature_action_to_code( config: ConfigType, action_id: ID, template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: + _LOGGER.warning( + "The 'climate.%s.clear_remote_temperature' action is deprecated. Use " + "'%s.clear_remote_temperature' instead. It will be removed in ESPHome " + "2027.2.0.", + DOMAIN, + DOMAIN, + ) var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index 4782a2ef93..415de34166 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -1,8 +1,9 @@ +#include "mitsubishi_cn105.h" + #include #include #include #include -#include "mitsubishi_cn105.h" namespace esphome::mitsubishi_cn105 { @@ -25,7 +26,7 @@ static constexpr std::array CONNECT_REQUEST_PAYLOAD = {0xCA, 0x01}; static constexpr uint8_t PACKET_TYPE_STATUS_REQUEST = 0x42; static constexpr uint8_t PACKET_TYPE_STATUS_RESPONSE = 0x62; static constexpr uint8_t STATUS_MSG_SETTINGS = 0x02; -static constexpr uint8_t STATUS_MSG_ROOM_TEMP = 0x03; +static constexpr uint8_t STATUS_MSG_TELEMETRY = 0x03; static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_REQUEST = 0x41; static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_RESPONSE = 0x61; @@ -229,8 +230,8 @@ void MitsubishiCN105::did_transition_(State to) { case State::STATUS_UPDATED: { if (this->pending_updates_.any() && this->is_status_initialized()) { this->set_state_(State::APPLYING_SETTINGS); - } else if (this->current_status_msg_type_ == STATUS_MSG_SETTINGS && this->should_request_room_temperature_()) { - this->current_status_msg_type_ = STATUS_MSG_ROOM_TEMP; + } else if (this->current_status_msg_type_ == STATUS_MSG_SETTINGS && this->should_request_telemetry_()) { + this->current_status_msg_type_ = STATUS_MSG_TELEMETRY; this->set_state_(State::UPDATING_STATUS); } else { this->set_state_(State::SCHEDULE_NEXT_STATUS_UPDATE); @@ -264,16 +265,16 @@ void MitsubishiCN105::did_transition_(State to) { } } -bool MitsubishiCN105::should_request_room_temperature_() const { - if (!this->is_room_temperature_enabled()) { +bool MitsubishiCN105::should_request_telemetry_() const { + if (!this->is_telemetry_polling_enabled()) { return false; } - if (!this->last_room_temperature_update_ms_.has_value()) { + if (!this->last_telemetry_update_ms_.has_value()) { return true; } - return (get_loop_time_ms() - *this->last_room_temperature_update_ms_) >= this->room_temperature_min_interval_ms_; + return (get_loop_time_ms() - *this->last_telemetry_update_ms_) >= this->telemetry_request_min_interval_ms_; } void MitsubishiCN105::send_packet_(const uint8_t *packet, size_t len) { @@ -327,7 +328,7 @@ bool MitsubishiCN105::process_status_packet_(const uint8_t *payload, size_t len) previous.fan_mode != this->status_.fan_mode || previous.target_temperature != this->status_.target_temperature || previous.vane_mode != this->status_.vane_mode || previous.wide_vane_mode != this->status_.wide_vane_mode; - if (this->is_room_temperature_enabled()) { + if (this->is_telemetry_polling_enabled()) { changed |= previous.room_temperature != this->status_.room_temperature; } @@ -339,8 +340,8 @@ bool MitsubishiCN105::parse_status_payload_(uint8_t msg_type, const uint8_t *pay case STATUS_MSG_SETTINGS: return this->parse_status_settings_(payload, len); - case STATUS_MSG_ROOM_TEMP: - return this->parse_status_room_temperature_(payload, len); + case STATUS_MSG_TELEMETRY: + return this->parse_status_telemetry_(payload, len); default: ESP_LOGVV(TAG, "RX unsupported status msg type 0x%02X", msg_type); @@ -384,14 +385,14 @@ bool MitsubishiCN105::parse_status_settings_(const uint8_t *payload, size_t len) return true; } -bool MitsubishiCN105::parse_status_room_temperature_(const uint8_t *payload, size_t len) { +bool MitsubishiCN105::parse_status_telemetry_(const uint8_t *payload, size_t len) { if (len <= 5) { - ESP_LOGVV(TAG, "RX room temperature payload too short"); + ESP_LOGVV(TAG, "RX telemetry payload too short"); return false; } this->status_.room_temperature = decode_temperature(payload[2], payload[5], 10); - this->last_room_temperature_update_ms_ = get_loop_time_ms(); + this->last_telemetry_update_ms_ = get_loop_time_ms(); return true; } diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h index 742d8e18a9..3169359290 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h @@ -1,9 +1,10 @@ #pragma once +#include "esphome/components/uart/uart.h" +#include "esphome/core/finite_set_mask.h" + #include #include -#include "esphome/components/uart/uart.h" -#include "esphome/core/finite_set_mask.h" namespace esphome::mitsubishi_cn105 { @@ -70,16 +71,16 @@ class MitsubishiCN105 { uint32_t get_update_interval() const { return this->update_interval_ms_; } void set_update_interval(uint32_t interval_ms) { this->update_interval_ms_ = interval_ms; } - uint32_t get_room_temperature_min_interval() const { return this->room_temperature_min_interval_ms_; } - bool is_room_temperature_enabled() const { return this->room_temperature_min_interval_ms_ != SCHEDULER_DONT_RUN; } - void set_room_temperature_min_interval(uint32_t interval_ms) { - this->room_temperature_min_interval_ms_ = interval_ms; + uint32_t get_telemetry_request_min_interval() const { return this->telemetry_request_min_interval_ms_; } + bool is_telemetry_polling_enabled() const { return this->telemetry_request_min_interval_ms_ != SCHEDULER_DONT_RUN; } + void set_telemetry_request_min_interval(uint32_t interval_ms) { + this->telemetry_request_min_interval_ms_ = interval_ms; } const Status &status() const { return this->status_; } bool is_status_initialized() const { - return this->is_room_temperature_enabled() ? !std::isnan(this->status_.room_temperature) - : !std::isnan(this->status_.target_temperature); + return this->is_telemetry_polling_enabled() ? !std::isnan(this->status_.room_temperature) + : !std::isnan(this->status_.target_temperature); } void set_power(bool power_on); @@ -150,10 +151,10 @@ class MitsubishiCN105 { bool process_status_packet_(const uint8_t *payload, size_t len); bool parse_status_payload_(uint8_t msg_type, const uint8_t *payload, size_t len); bool parse_status_settings_(const uint8_t *payload, size_t len); - bool parse_status_room_temperature_(const uint8_t *payload, size_t len); + bool parse_status_telemetry_(const uint8_t *payload, size_t len); void send_packet_(const uint8_t *packet, size_t len); void update_status_(); - bool should_request_room_temperature_() const; + bool should_request_telemetry_() const; void apply_settings_(); bool has_timed_out_(uint32_t timeout) const { return ((get_loop_time_ms() - this->operation_start_ms_) >= timeout); } void set_remote_temperature_half_deg_(uint8_t temperature_half_deg); @@ -162,11 +163,15 @@ class MitsubishiCN105 { static const LogString *state_to_string(State state); uart::UARTDevice &device_; + // Default 1s; legacy climate-owned hub compatibility relies on this when update_interval is omitted. + // Remove legacy note in 2027.2.0. uint32_t update_interval_ms_{1000}; uint32_t status_update_wait_credit_ms_{0}; uint32_t operation_start_ms_{0}; - uint32_t room_temperature_min_interval_ms_{60000}; - std::optional last_room_temperature_update_ms_; + // Default 60s; legacy climate-owned hub compatibility relies on this when current_temperature_min_interval is + // omitted. Remove legacy note in 2027.2.0. + uint32_t telemetry_request_min_interval_ms_{60000}; + std::optional last_telemetry_update_ms_; Status status_{}; State state_{State::NOT_CONNECTED}; UpdateFlags pending_updates_; diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp index afffe7ea5e..13e02668d1 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp @@ -1,5 +1,5 @@ -#include #include "mitsubishi_cn105_climate.h" + #include "esphome/core/log.h" namespace esphome::mitsubishi_cn105 { @@ -50,25 +50,11 @@ static constexpr std::optional reverse_map_lookup(const std::arrayhp_.is_room_temperature_enabled()) { - ESP_LOGCONFIG(TAG, " Current temperature min interval: %" PRIu32 " ms", - this->hp_.get_room_temperature_min_interval()); - } else { - ESP_LOGCONFIG(TAG, " Current temperature: DISABLED"); - } - ESP_LOGCONFIG(TAG, - " Update interval: %" PRIu32 " ms\n" - " UART: baud_rate=%" PRIu32 " data_bits=%u parity=%s stop_bits=%u", - this->hp_.get_update_interval(), this->parent_->get_baud_rate(), this->parent_->get_data_bits(), - LOG_STR_ARG(parity_to_str(this->parent_->get_parity())), this->parent_->get_stop_bits()); -} +void MitsubishiCN105Climate::dump_config() { LOG_CLIMATE("", "Mitsubishi CN105 Climate", this); } -void MitsubishiCN105Climate::setup() { this->hp_.initialize(); } - -void MitsubishiCN105Climate::loop() { - if (this->hp_.update()) { +void MitsubishiCN105Climate::setup() { + this->parent_->add_on_status_callback([this]() { this->apply_values_(); }); + if (this->parent_->is_status_initialized()) { this->apply_values_(); } } @@ -90,7 +76,7 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() { traits.set_visual_max_temperature(31.0f); traits.set_visual_temperature_step(1.0f); - if (this->hp_.is_room_temperature_enabled()) { + if (this->parent_->is_telemetry_polling_enabled()) { traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); traits.set_visual_current_temperature_step(0.5f); } @@ -100,20 +86,20 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() { void MitsubishiCN105Climate::control(const climate::ClimateCall &call) { if (const auto target_temperature = call.get_target_temperature()) { - this->hp_.set_target_temperature(*target_temperature); + this->parent_->set_target_temperature(*target_temperature); } if (const auto mode = call.get_mode()) { if (*mode == climate::CLIMATE_MODE_OFF) { - this->hp_.set_power(false); + this->parent_->set_power(false); } else if (const auto mapped = reverse_map_lookup(MODE_MAP, *mode)) { - this->hp_.set_power(true); - this->hp_.set_mode(*mapped); + this->parent_->set_power(true); + this->parent_->set_mode(*mapped); } } if (const auto fan_mode = reverse_map_lookup(FAN_MODE_MAP, call.get_fan_mode())) { - this->hp_.set_fan_mode(*fan_mode); + this->parent_->set_fan_mode(*fan_mode); } if (const auto swing_mode = call.get_swing_mode()) { @@ -140,24 +126,24 @@ void MitsubishiCN105Climate::control(const climate::ClimateCall &call) { } if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) { - this->hp_.set_vane_mode(vane); + this->parent_->set_vane_mode(vane); } if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) { - this->hp_.set_wide_vane_mode(wide); + this->parent_->set_wide_vane_mode(wide); } } - if (this->hp_.is_status_initialized()) { + if (this->parent_->is_status_initialized()) { this->apply_values_(); } } void MitsubishiCN105Climate::apply_values_() { - const auto &status = this->hp_.status(); + const auto &status = this->parent_->status(); this->target_temperature = status.target_temperature; - if (this->hp_.is_room_temperature_enabled()) { + if (this->parent_->is_telemetry_polling_enabled()) { this->current_temperature = status.room_temperature; } diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h index c83a5519c1..5341c2d2d9 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h @@ -1,51 +1,47 @@ #pragma once +#include "mitsubishi_cn105_component.h" +#include "mitsubishi_cn105.h" + #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/components/climate/climate.h" -#include "esphome/components/uart/uart.h" -#include "mitsubishi_cn105.h" namespace esphome::mitsubishi_cn105 { -class MitsubishiCN105Climate : public climate::Climate, public Component, public uart::UARTDevice { +class MitsubishiCN105Climate : public climate::Climate, public Component, public Parented { public: - explicit MitsubishiCN105Climate() : hp_(*this) {} - void setup() override; - void loop() override; void dump_config() override; climate::ClimateTraits traits() override; void control(const climate::ClimateCall &call) override; - void set_update_interval(uint32_t ms) { this->hp_.set_update_interval(ms); } - void set_current_temperature_min_interval(uint32_t ms) { this->hp_.set_room_temperature_min_interval(ms); } - - void set_remote_temperature(float temperature) { this->hp_.set_remote_temperature(temperature); } - void clear_remote_temperature() { this->hp_.clear_remote_temperature(); } - void set_supported_swing_mode(climate::ClimateSwingMode mode); + // Legacy climate action compatibility. Remove in 2027.2.0. + void set_remote_temperature(float temperature) { this->parent_->set_remote_temperature(temperature); } + void clear_remote_temperature() { this->parent_->clear_remote_temperature(); } protected: void apply_values_(); - MitsubishiCN105 hp_; climate::ClimateSwingModeMask supported_swing_modes_{}; MitsubishiCN105::VaneMode last_non_swing_vane_mode_{MitsubishiCN105::VaneMode::AUTO}; MitsubishiCN105::WideVaneMode last_non_swing_wide_vane_mode_{MitsubishiCN105::WideVaneMode::CENTER}; }; +// Legacy climate action compatibility. Remove in 2027.2.0. template -class SetRemoteTemperatureAction : public Action, public Parented { +class LegacySetRemoteTemperatureAction : public Action, public Parented { public: TEMPLATABLE_VALUE(float, temperature) void play(const Ts &...x) override { this->parent_->set_remote_temperature(this->temperature_.value(x...)); } }; +// Legacy climate action compatibility. Remove in 2027.2.0. template -class ClearRemoteTemperatureAction : public Action, public Parented { +class LegacyClearRemoteTemperatureAction : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); } }; diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp new file mode 100644 index 0000000000..166e7fbf88 --- /dev/null +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp @@ -0,0 +1,34 @@ +#include "mitsubishi_cn105_component.h" + +#include "esphome/core/log.h" + +#include + +namespace esphome::mitsubishi_cn105 { + +static const char *const TAG = "mitsubishi_cn105"; + +void MitsubishiCN105Component::dump_config() { + ESP_LOGCONFIG(TAG, "Mitsubishi CN105:"); + if (this->hp_.is_telemetry_polling_enabled()) { + ESP_LOGCONFIG(TAG, " Telemetry polling min interval: %" PRIu32 " ms", + this->hp_.get_telemetry_request_min_interval()); + } else { + ESP_LOGCONFIG(TAG, " Telemetry polling: DISABLED"); + } + ESP_LOGCONFIG(TAG, + " Update interval: %" PRIu32 " ms\n" + " UART: baud_rate=%" PRIu32 " data_bits=%u parity=%s stop_bits=%u", + this->hp_.get_update_interval(), this->parent_->get_baud_rate(), this->parent_->get_data_bits(), + LOG_STR_ARG(parity_to_str(this->parent_->get_parity())), this->parent_->get_stop_bits()); +} + +void MitsubishiCN105Component::setup() { this->hp_.initialize(); } + +void MitsubishiCN105Component::loop() { + if (this->hp_.update()) { + this->status_callback_.call(); + } +} + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h new file mode 100644 index 0000000000..2319ea7c54 --- /dev/null +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h @@ -0,0 +1,46 @@ +#pragma once + +#include "mitsubishi_cn105.h" + +#include "esphome/core/component.h" +#include "esphome/components/uart/uart.h" + +#include + +namespace esphome::mitsubishi_cn105 { + +class MitsubishiCN105Component : public Component, public uart::UARTDevice { + public: + explicit MitsubishiCN105Component() : hp_(*this) {} + + void setup() override; + void loop() override; + void dump_config() override; + + void set_update_interval(uint32_t ms) { this->hp_.set_update_interval(ms); } + void set_telemetry_request_min_interval(uint32_t ms) { this->hp_.set_telemetry_request_min_interval(ms); } + + void set_remote_temperature(float temperature) { this->hp_.set_remote_temperature(temperature); } + void clear_remote_temperature() { this->hp_.clear_remote_temperature(); } + + void set_power(bool power_on) { this->hp_.set_power(power_on); } + void set_target_temperature(float target_temperature) { this->hp_.set_target_temperature(target_temperature); } + void set_mode(MitsubishiCN105::Mode mode) { this->hp_.set_mode(mode); } + void set_fan_mode(MitsubishiCN105::FanMode fan_mode) { this->hp_.set_fan_mode(fan_mode); } + void set_vane_mode(MitsubishiCN105::VaneMode vane_mode) { this->hp_.set_vane_mode(vane_mode); } + void set_wide_vane_mode(MitsubishiCN105::WideVaneMode mode) { this->hp_.set_wide_vane_mode(mode); } + + const MitsubishiCN105::Status &status() const { return this->hp_.status(); } + bool is_status_initialized() const { return this->hp_.is_status_initialized(); } + bool is_telemetry_polling_enabled() const { return this->hp_.is_telemetry_polling_enabled(); } + + template void add_on_status_callback(F &&callback) { + this->status_callback_.add(std::forward(callback)); + } + + protected: + MitsubishiCN105 hp_; + CallbackManager status_callback_; +}; + +} // namespace esphome::mitsubishi_cn105 diff --git a/tests/component_tests/mitsubishi_cn105/fixtures/top_level_hub_with_legacy_climate_keys.yaml b/tests/component_tests/mitsubishi_cn105/fixtures/top_level_hub_with_legacy_climate_keys.yaml new file mode 100644 index 0000000000..0226d680a4 --- /dev/null +++ b/tests/component_tests/mitsubishi_cn105/fixtures/top_level_hub_with_legacy_climate_keys.yaml @@ -0,0 +1,10 @@ +mitsubishi_cn105: + id: ac_hub + +climate: + - platform: mitsubishi_cn105 + mitsubishi_cn105_id: ac_hub + name: AC + current_temperature_min_interval: 30s + uart_id: uart_bus + update_interval: 10s diff --git a/tests/component_tests/mitsubishi_cn105/test_climate.py b/tests/component_tests/mitsubishi_cn105/test_climate.py new file mode 100644 index 0000000000..e4e3da9c7f --- /dev/null +++ b/tests/component_tests/mitsubishi_cn105/test_climate.py @@ -0,0 +1,30 @@ +"""Tests for Mitsubishi CN105 climate configuration migration diagnostics.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.mitsubishi_cn105 import climate +import esphome.config_validation as cv +from esphome.core import CORE +from esphome.yaml_util import load_yaml + + +def test_top_level_hub_rejects_leftover_legacy_climate_keys( + component_fixture_path: Callable[[str], Path], +) -> None: + config = load_yaml( + component_fixture_path("top_level_hub_with_legacy_climate_keys.yaml") + ) + CORE.raw_config = config + + with pytest.raises(cv.Invalid) as exc_info: + climate.CONFIG_SCHEMA(config["climate"][0]) + + message = str(exc_info.value) + assert "'current_temperature_min_interval'" in message + assert "'uart_id'" in message + assert "'update_interval'" in message + assert "top-level 'mitsubishi_cn105:' block" in message + assert "'telemetry_request_min_interval'" in message diff --git a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp index ef3cdd0fff..7703b02fcd 100644 --- a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp +++ b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp @@ -75,7 +75,7 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { EXPECT_EQ(ctx.sut.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_4); EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::SWING); - // Now fetch room temperature (0x03) + // Now fetch telemetry (0x03) EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS); EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x42, 0x01, 0x30, 0x10, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7A)); @@ -84,11 +84,11 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { // Clear TX bytes. ctx.uart.tx.clear(); - // Room temperature response + // Telemetry response ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x03, 0x00, 0x00, 0x0B, 0x00, 0x00, 0xAA, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xA5}); - // Room temperature should still have initial value + // Room temperature from telemetry should still have initial value EXPECT_THAT(ctx.sut.status().room_temperature, ::testing::IsNan()); ctx.sut.set_current_time(400); @@ -97,7 +97,7 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { EXPECT_TRUE(ctx.uart.rx.empty()); EXPECT_TRUE(ctx.sut.is_status_initialized()); - // Check room temperature we just read from received package + // Check room temperature we just read from telemetry package EXPECT_EQ(ctx.sut.status().room_temperature, 21.0f); EXPECT_TRUE(ctx.uart.tx.empty()); diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h index 45f7b65289..a14043c737 100644 --- a/tests/components/mitsubishi_cn105/common.h +++ b/tests/components/mitsubishi_cn105/common.h @@ -8,6 +8,7 @@ #include #include "esphome/components/uart/uart_component.h" #include "esphome/components/mitsubishi_cn105/mitsubishi_cn105.h" +#include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h" #include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h" namespace esphome::mitsubishi_cn105::testing { @@ -65,11 +66,16 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 { class TestableMitsubishiCN105Climate : public MitsubishiCN105Climate { public: + TestableMitsubishiCN105Climate() { this->set_parent(&this->component_); } + using MitsubishiCN105Climate::apply_values_; using MitsubishiCN105Climate::last_non_swing_vane_mode_; using MitsubishiCN105Climate::last_non_swing_wide_vane_mode_; - MitsubishiCN105::Status &status() { return static_cast(this->hp_).status_; } + MitsubishiCN105::Status &status() { return const_cast(this->component_.status()); } + + protected: + MitsubishiCN105Component component_; }; } // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/mitsubishi_cn105/common.yaml b/tests/components/mitsubishi_cn105/common.yaml index 5b9c3aaaf6..5966523b34 100644 --- a/tests/components/mitsubishi_cn105/common.yaml +++ b/tests/components/mitsubishi_cn105/common.yaml @@ -1,17 +1,20 @@ +mitsubishi_cn105: + id: ac + uart_id: uart_bus + update_interval: 30s + telemetry_request_min_interval: 120s + climate: - platform: mitsubishi_cn105 - id: ac + mitsubishi_cn105_id: ac name: "AC Test" - uart_id: uart_bus - update_interval: 30s - current_temperature_min_interval: 120s supported_swing_modes: BOTH esphome: on_boot: then: - - climate.mitsubishi_cn105.set_remote_temperature: + - mitsubishi_cn105.set_remote_temperature: id: ac temperature: 22.0 - - climate.mitsubishi_cn105.clear_remote_temperature: + - mitsubishi_cn105.clear_remote_temperature: id: ac diff --git a/tests/components/mitsubishi_cn105/test-legacy-climate-actions.esp32-idf.yaml b/tests/components/mitsubishi_cn105/test-legacy-climate-actions.esp32-idf.yaml new file mode 100644 index 0000000000..247568cfc3 --- /dev/null +++ b/tests/components/mitsubishi_cn105/test-legacy-climate-actions.esp32-idf.yaml @@ -0,0 +1,16 @@ +packages: + uart_9600_even: !include ../../test_build_components/common/uart_9600_even/esp32-idf.yaml + +climate: + - platform: mitsubishi_cn105 + id: ac + name: "AC Test" + +esphome: + on_boot: + then: + - climate.mitsubishi_cn105.set_remote_temperature: + id: ac + temperature: 22.0 + - climate.mitsubishi_cn105.clear_remote_temperature: + id: ac diff --git a/tests/components/mitsubishi_cn105/validate-legacy-climate-current-temperature-min-interval.esp32-idf.yaml b/tests/components/mitsubishi_cn105/validate-legacy-climate-current-temperature-min-interval.esp32-idf.yaml new file mode 100644 index 0000000000..a2abaf8b9b --- /dev/null +++ b/tests/components/mitsubishi_cn105/validate-legacy-climate-current-temperature-min-interval.esp32-idf.yaml @@ -0,0 +1,7 @@ +packages: + uart_9600_even: !include ../../test_build_components/common/uart_9600_even/esp32-idf.yaml + +climate: + - platform: mitsubishi_cn105 + name: "AC Test" + current_temperature_min_interval: 30s diff --git a/tests/components/mitsubishi_cn105/validate-legacy-climate-minimal.esp32-idf.yaml b/tests/components/mitsubishi_cn105/validate-legacy-climate-minimal.esp32-idf.yaml new file mode 100644 index 0000000000..0ef70b6535 --- /dev/null +++ b/tests/components/mitsubishi_cn105/validate-legacy-climate-minimal.esp32-idf.yaml @@ -0,0 +1,6 @@ +packages: + uart_9600_even: !include ../../test_build_components/common/uart_9600_even/esp32-idf.yaml + +climate: + - platform: mitsubishi_cn105 + name: "AC Test" diff --git a/tests/components/mitsubishi_cn105/validate-legacy-climate-uart-id.esp32-idf.yaml b/tests/components/mitsubishi_cn105/validate-legacy-climate-uart-id.esp32-idf.yaml new file mode 100644 index 0000000000..065d2b5495 --- /dev/null +++ b/tests/components/mitsubishi_cn105/validate-legacy-climate-uart-id.esp32-idf.yaml @@ -0,0 +1,7 @@ +packages: + uart_9600_even: !include ../../test_build_components/common/uart_9600_even/esp32-idf.yaml + +climate: + - platform: mitsubishi_cn105 + name: "AC Test" + uart_id: uart_bus diff --git a/tests/components/mitsubishi_cn105/validate-legacy-climate-update-interval.esp32-idf.yaml b/tests/components/mitsubishi_cn105/validate-legacy-climate-update-interval.esp32-idf.yaml new file mode 100644 index 0000000000..2e8f714f52 --- /dev/null +++ b/tests/components/mitsubishi_cn105/validate-legacy-climate-update-interval.esp32-idf.yaml @@ -0,0 +1,7 @@ +packages: + uart_9600_even: !include ../../test_build_components/common/uart_9600_even/esp32-idf.yaml + +climate: + - platform: mitsubishi_cn105 + name: "AC Test" + update_interval: 30s diff --git a/tests/components/mitsubishi_cn105/validate-top-level-minimal.esp32-idf.yaml b/tests/components/mitsubishi_cn105/validate-top-level-minimal.esp32-idf.yaml new file mode 100644 index 0000000000..03f05da5f4 --- /dev/null +++ b/tests/components/mitsubishi_cn105/validate-top-level-minimal.esp32-idf.yaml @@ -0,0 +1,8 @@ +packages: + uart_9600_even: !include ../../test_build_components/common/uart_9600_even/esp32-idf.yaml + +mitsubishi_cn105: + +climate: + - platform: mitsubishi_cn105 + name: "AC Test" From 207ae2e4cb53b29eb566c3e94861a583ac8a016d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 14:02:37 -0500 Subject: [PATCH 1363/1815] [bk72xx_ble] Support active scanning by packing the GAPM start command (#18169) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: bdraco <663432+bdraco@users.noreply.github.com> --- esphome/components/bk72xx_ble/bdk_scan.cpp | 119 ++++++++ esphome/components/bk72xx_ble/bdk_scan.h | 51 ++++ esphome/components/bk72xx_ble/bk72xx_ble.cpp | 281 ++++++++++++++++-- esphome/components/bk72xx_ble/bk72xx_ble.h | 68 ++++- .../components/bk72xx_ble_tracker/__init__.py | 5 + .../bk72xx_ble_tracker/bk72xx_ble_tracker.cpp | 232 +++++++++++---- .../bk72xx_ble_tracker/bk72xx_ble_tracker.h | 80 ++--- .../components/ble_device_base/__init__.py | 11 +- esphome/components/ble_device_base/ble_hub.h | 6 +- .../bluetooth_connection/__init__.py | 1 + .../components/bluetooth_proxy/__init__.py | 25 +- .../components/esp32_ble_tracker/__init__.py | 4 +- .../components/ln882h_ble_tracker/__init__.py | 2 +- .../components/rp2_ble_tracker/__init__.py | 4 +- .../config/test_automations.yaml | 1 + .../test_automations_codegen.py | 2 + .../test_scan_parameter_validation.py | 16 +- .../bluetooth_proxy/test_platform_gates.py | 7 +- .../validate-passive.bk72xx-ard.yaml | 8 + .../bluetooth_proxy/validate.bk72xx-ard.yaml | 11 + 20 files changed, 754 insertions(+), 180 deletions(-) create mode 100644 esphome/components/bk72xx_ble/bdk_scan.cpp create mode 100644 esphome/components/bk72xx_ble/bdk_scan.h create mode 100644 tests/components/bk72xx_ble_tracker/validate-passive.bk72xx-ard.yaml create mode 100644 tests/components/bluetooth_proxy/validate.bk72xx-ard.yaml diff --git a/esphome/components/bk72xx_ble/bdk_scan.cpp b/esphome/components/bk72xx_ble/bdk_scan.cpp new file mode 100644 index 0000000000..bd4e51d9b7 --- /dev/null +++ b/esphome/components/bk72xx_ble/bdk_scan.cpp @@ -0,0 +1,119 @@ +// Every SDK call the scan reconciler makes. The BDK's own start hardcodes +// passive (the active bit is commented out in both stacks), so +// bdk_scan_start() packs the GAPM_ACTIVITY_START_CMD itself, field-for-field +// the SDK's app_ble_start_scaning() except that prop takes the mode, armed +// through the SDK's own operation bookkeeping. The component pins +// beken-bdk 3.0.78; the static asserts catch a layout change on a bump. + +#include "bdk_scan.h" + +#ifdef USE_BK72XX_BLE + +// Same SDK gate as bk72xx_ble.cpp (which carries the explanatory #error). +#if !defined(CLANG_TIDY) && __has_include("ble_api.h") + +extern "C" { +#include "app_ble.h" // app_ble_env, app_ble_run, app_ble_reset, actv_state_t, + // app_ble_actv_state_get, app_ble_env_state_get, + // app_ble_get_idle_actv_idx_handle, UNKNOW_ACT_IDX, + // bk_ble_* (via ble_api_5_x.h) +#include "kernel_msg.h" // KERNEL_MSG_ALLOC, kernel_msg_send +#if __has_include("gapm_msg.h") +#include "gapm_msg.h" // BLE 5.2 (BK7238/BK7252N): gapm_activity_start_cmd, GAPM_SCAN_* +#else +#include "gapm_task.h" // BLE 5.1 (BK7231N/BK7236): same declarations, older header name +#endif +} + +#include "esphome/core/log.h" + +namespace esphome::bk72xx_ble { + +static const char *const TAG = "bk72xx_ble"; + +// Pin the SDK surface this file depends on: a beken-bdk bump that moves these +// must fail the build, not corrupt the kernel message. +static_assert(GAPM_SCAN_PROP_PHY_1M_BIT == (1 << 0) && GAPM_SCAN_PROP_ACTIVE_1M_BIT == (1 << 2) && + sizeof(struct gapm_scan_param) == 16 && sizeof(struct gapm_scan_wd_op_param) == 4, + "beken-bdk GAPM scan layout changed; revalidate bdk_scan_start() " + "against the SDK's app_ble_start_scaning()"); +static_assert(INVALID_ACTIVITY_IDX == UNKNOW_ACT_IDX, + "beken-bdk activity sentinel changed; revalidate the scan reconciler"); +static_assert(GAPM_REPORT_TYPE_SCAN_RSP_EXT == 2 && GAPM_REPORT_TYPE_SCAN_RSP_LEG == 3 && + GAPM_REPORT_INFO_SCAN_ADV_BIT == (1 << 5), + "beken-bdk GAPM report info changed; revalidate the tracker's demux constants"); + +bool bdk_scan_ready() { return app_ble_env_state_get() == APP_BLE_READY; } + +BdkActivityState bdk_scan_state(uint8_t activity_idx) { + if (activity_idx == INVALID_ACTIVITY_IDX) + return BdkActivityState::IDLE; + switch (app_ble_actv_state_get(activity_idx)) { + case ACTV_IDLE: + return BdkActivityState::IDLE; + case ACTV_SCAN_CREATED: + return BdkActivityState::CREATED; + case ACTV_SCAN_STARTED: + return BdkActivityState::STARTED; + default: + return BdkActivityState::OTHER; + } +} + +uint8_t bdk_scan_acquire_activity() { + uint8_t idx = app_ble_get_idle_actv_idx_handle(SCAN_ACTV); + if (idx == INVALID_ACTIVITY_IDX) + ESP_LOGE(TAG, "Scan start failed: no idle activity handle"); + return idx; +} + +BdkOpResult bdk_scan_create(uint8_t activity_idx) { + ble_err_t ret = bk_ble_create_scaning(activity_idx, nullptr); + if (ret == ERR_SUCCESS) + return BdkOpResult::OK; + if (ret == ERR_BLE_STATUS) + return BdkOpResult::BUSY; + ESP_LOGE(TAG, "Scan activity create failed (err %d)", static_cast(ret)); + return BdkOpResult::FAILED; +} + +BdkOpResult bdk_scan_start(uint8_t activity_idx, uint16_t interval, uint16_t window, bool active) { + app_ble_run(activity_idx, BLE_START_SCAN, 1 << BLE_OP_START_SCAN_POS, nullptr); + struct gapm_activity_start_cmd *cmd = + KERNEL_MSG_ALLOC(GAPM_ACTIVITY_START_CMD, TASK_BLE_GAPM, TASK_BLE_APP, gapm_activity_start_cmd); + if (cmd == nullptr) { + app_ble_reset(); // the SDK's own failure path for an unsent operation + ESP_LOGE(TAG, "Scan start failed: kernel message allocation"); + return BdkOpResult::FAILED; + } + cmd->operation = GAPM_START_ACTIVITY; + cmd->actv_idx = app_ble_env.actvs[activity_idx].gap_advt_idx; + cmd->u_param.scan_param.type = GAPM_SCAN_TYPE_OBSERVER; + cmd->u_param.scan_param.prop = GAPM_SCAN_PROP_PHY_1M_BIT | (active ? GAPM_SCAN_PROP_ACTIVE_1M_BIT : 0); + cmd->u_param.scan_param.scan_param_1m.scan_intv = interval; + cmd->u_param.scan_param.scan_param_1m.scan_wd = window; + cmd->u_param.scan_param.scan_param_coded.scan_intv = 0; + cmd->u_param.scan_param.scan_param_coded.scan_wd = 0; + cmd->u_param.scan_param.dup_filt_pol = 0; + cmd->u_param.scan_param.rsvd = 0; + cmd->u_param.scan_param.duration = 0; // scan until stopped + cmd->u_param.scan_param.period = 10; // matches the SDK's passive start + kernel_msg_send(cmd); + return BdkOpResult::OK; +} + +BdkOpResult bdk_scan_release(uint8_t activity_idx, bool created, int *err_out) { + ble_err_t ret = created ? bk_ble_delete_scaning(activity_idx, nullptr) : bk_ble_scan_stop(activity_idx, nullptr); + *err_out = static_cast(ret); + if (ret == ERR_SUCCESS) + return BdkOpResult::OK; + // DEBUG on purpose: the reconciler WARNs once per streak and the stuck + // ERROR carries this code — a per-retry ERROR would be unbounded. + ESP_LOGD(TAG, "Scan release %s (err %d)", ret == ERR_BLE_STATUS ? "rejected" : "failed", static_cast(ret)); + return ret == ERR_BLE_STATUS ? BdkOpResult::BUSY : BdkOpResult::FAILED; +} + +} // namespace esphome::bk72xx_ble + +#endif // !CLANG_TIDY && ble_api.h +#endif // USE_BK72XX_BLE diff --git a/esphome/components/bk72xx_ble/bdk_scan.h b/esphome/components/bk72xx_ble/bdk_scan.h new file mode 100644 index 0000000000..47bce2449d --- /dev/null +++ b/esphome/components/bk72xx_ble/bdk_scan.h @@ -0,0 +1,51 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_BK72XX_BLE + +#include + +namespace esphome::bk72xx_ble { + +/// Activity index value marking "no scan activity", the BDK's own convention +/// (asserted against its symbol in bdk_scan.cpp). +inline constexpr uint8_t INVALID_ACTIVITY_IDX = 0xFF; + +/// Scan-relevant controller activity states, read live from the SDK. +enum class BdkActivityState : uint8_t { + IDLE, ///< No activity (or one whose create failed). + CREATED, ///< Created but not started. + STARTED, ///< Scanning. + OTHER, ///< A non-scan or transitional state; settles on a later read. +}; + +/// Outcome of a BDK scan operation request. +enum class BdkOpResult : uint8_t { + OK, ///< Accepted; completion is asynchronous. + BUSY, ///< Another controller operation is in flight; retry later. + FAILED, ///< Rejected. +}; + +/// True when no controller operation is in flight (APP_BLE_READY). +bool bdk_scan_ready(); +/// Live state of the given activity; INVALID_ACTIVITY_IDX reads as IDLE. +BdkActivityState bdk_scan_state(uint8_t activity_idx); +/// Claim an idle activity slot; INVALID_ACTIVITY_IDX when none is free. +uint8_t bdk_scan_acquire_activity(); +/// Create the scan activity (asynchronous); started once CREATED is observed. +BdkOpResult bdk_scan_create(uint8_t activity_idx); +/// Start a created activity: the packed GAPM start, taking the scan mode the +/// BDK's own start path hardcodes away. Fire-and-forget; FAILED when the +/// kernel message could not be allocated (the armed SDK operation is rolled +/// back). +BdkOpResult bdk_scan_start(uint8_t activity_idx, uint16_t interval, uint16_t window, bool active); +/// Release the activity: delete when never started (a stop would be +/// rejected), stop otherwise. BUSY on a transient rejection (retry), FAILED +/// on any other error; err_out receives the SDK code (0 on success). +/// Teardown is asynchronous — observe IDLE to confirm. +BdkOpResult bdk_scan_release(uint8_t activity_idx, bool created, int *err_out); + +} // namespace esphome::bk72xx_ble + +#endif // USE_BK72XX_BLE diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.cpp b/esphome/components/bk72xx_ble/bk72xx_ble.cpp index 69c7df0b96..954cb9fe87 100644 --- a/esphome/components/bk72xx_ble/bk72xx_ble.cpp +++ b/esphome/components/bk72xx_ble/bk72xx_ble.cpp @@ -5,7 +5,8 @@ // talks to the Beken BDK BLE stack: // - one-time stack bring-up (ble_set_notice_cb() + ble_entry()), // - the controller BLE address, -// - the raw controller scan primitives (bk_ble_scan_start/stop), +// - the scan reconciler (request, pacing, bring-up budget) over the +// bdk_scan surface, // - the scan-report ring: the BDK notice callback (BLE task) takes a report // from a fixed pool and pushes it on a lock-free SPSC queue; loop() drains, // dispatches on the main task and returns reports to the pool — the same @@ -20,10 +21,13 @@ #include "bk72xx_ble.h" // pulls esphome/core/defines.h for USE_BK72XX_BLE +#include "bdk_scan.h" // the raw BDK scan surface (state reads, starts, release) + #ifdef USE_BK72XX_BLE #include +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" // get_mac_address_raw() #include "esphome/core/log.h" @@ -57,9 +61,8 @@ // are C headers consumed from C++ (a standard C-header-from-C++ pattern). // --------------------------------------------------------------------------- extern "C" { -#include "ble_api.h" // bk_ble_scan_start/stop, ble_entry, ble_set_notice_cb, - // app_ble_get_idle_actv_idx_handle, struct scan_param, - // recv_adv_t, ble_notice_t, BLE_5_REPORT_ADV, SCAN_ACTV +#include "ble_api.h" // ble_set_notice_cb, recv_adv_t, ble_notice_t, + // BLE_5_REPORT_ADV (scan primitives live in bdk_scan.cpp) #ifdef BK72XX_BLE_HAS_COMMON_BDADDR #include "common_bt_defines.h" // struct bd_addr // The controller's public BLE address, populated by the BDK during ble_entry(). @@ -76,6 +79,12 @@ namespace esphome::bk72xx_ble { static const char *const TAG = "bk72xx_ble"; +static constexpr uint32_t RECONCILE_RETRY_MS = 10; // pump floor for fast loops +static constexpr uint32_t RECONCILE_REJECTED_RETRY_MS = 500; // retry gate after a rejected release +static constexpr uint32_t RECONCILE_PENDING_TIMEOUT_MS = 2000; // bring-up budget before FAILED +static constexpr uint32_t SCAN_LIVENESS_CHECK_MS = 1000; // settled-scan re-check cadence +static constexpr uint32_t TEARDOWN_STUCK_ERROR_MS = 30000; // stuck-teardown ERROR (stop also goes FAILED) + // The BDK notice callback is a plain C function pointer with no user argument, // so it reaches the (single) component instance through a file-static pointer. static BK72xxBLE *s_ble = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -95,12 +104,12 @@ static void ble_notice_callback(ble_notice_t notice, void *param) { const recv_adv_t *info = reinterpret_cast(param); // rssi is a signed dBm carried in a uint8_t; cast through int8_t (standard for // a signed dBm value packed in a uint8_t). - s_ble->enqueue_scan_report(info->adv_addr, static_cast(info->rssi), info->adv_addr_type, info->data, - info->data_len); + s_ble->enqueue_scan_report(info->adv_addr, static_cast(info->rssi), info->adv_addr_type, + static_cast(info->evt_type), info->data, info->data_len); } -void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, - uint16_t data_len) { +void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, uint8_t evt_type, + const uint8_t *data, uint16_t data_len) { BLEScanReport *report = this->report_pool_.allocate(); if (report == nullptr) { // Pool exhausted — the queue is full; count and drop. @@ -110,6 +119,7 @@ void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t add memcpy(report->mac, mac, 6); report->rssi = rssi; report->addr_type = addr_type; + report->evt_type = evt_type; report->data_len = (data_len <= sizeof(report->data)) ? static_cast(data_len) : static_cast(sizeof(report->data)); memcpy(report->data, data, report->data_len); @@ -123,6 +133,9 @@ void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t add void BK72xxBLE::setup() { s_ble = this; + // The report pool grows lazily on purpose: the BDK notice callback runs in + // task context (malloc-safe, unlike rp2040's IRQ path), and typical traffic + // stays far below the pool cap, so not warming contains RAM. // Resolve the MAC early so get_mac_lsb_first() is valid for consumers before // the stack is up (it is re-read once ble_entry() has run). this->resolve_mac_(); @@ -173,6 +186,30 @@ void BK72xxBLE::enable() { } void BK72xxBLE::loop() { + // Keep reconciling toward the requested scan state (e.g. complete a stop + // that arrived while a controller operation was in flight), and re-check a + // settled scan at low frequency: a controller-side drop re-enters the + // bring-up, and the budget's FAILED feeds the tracker's recovery. + // Keep driving until settled: any PENDING, plus a terminal stop whose slot + // must still be freed. A FAILED scan request is the one combination not + // re-driven here — that belongs to the tracker's backoff. + const uint32_t pump_now = App.get_loop_component_start_time(); + if (this->last_result_ == ScanOpResult::PENDING || + (!this->scan_wanted_ && this->last_result_ == ScanOpResult::FAILED)) { + const uint32_t gate = (this->release_warned_ || this->last_result_ == ScanOpResult::FAILED) + ? RECONCILE_REJECTED_RETRY_MS + : RECONCILE_RETRY_MS; + if (pump_now - this->last_advance_ms_ >= gate) + this->advance_(); + } else if (this->scan_wanted_ && this->last_result_ == ScanOpResult::SETTLED && + pump_now - this->last_advance_ms_ >= SCAN_LIVENESS_CHECK_MS) { + // Re-check a settled scan; scan_start() refills the bring-up budget. + // WARN: the only report of a drop that recovers inside its budget. + if (this->scan_start(this->requested_.interval, this->requested_.window, this->requested_.active) != + ScanOpResult::SETTLED) + ESP_LOGW(TAG, "Controller dropped the scan; restarting"); + } + // Drain the lock-free ring filled by the BLE task; all per-report work runs // here on the main task, then the report returns to the pool. BLEScanReport *report = this->report_queue_.pop(); @@ -248,44 +285,228 @@ void BK72xxBLE::resolve_mac_() { } // --------------------------------------------------------------------------- -// Controller scan primitives +// Scan reconciler // --------------------------------------------------------------------------- -bool BK72xxBLE::scan_start(uint16_t interval, uint16_t window) { +// Episode boundary: fresh teardown deadline and error bookkeeping. +void BK72xxBLE::reset_teardown_episode_() { + this->teardown_since_ms_ = 0; + this->restarting_ = false; + this->last_release_err_ = 0; +} + +ScanOpResult BK72xxBLE::scan_start(uint16_t interval, uint16_t window, bool active) { if (!this->is_active()) this->enable(); - if (this->scan_actv_idx_ != 0xFF) { - // Already scanning — stop first so this call cleanly restarts with the new - // parameters (the BDK cannot start a second scan on a busy activity). - this->scan_stop(); + const ScanParams params{active, interval, window}; + // A new episode refills the budget and gets a fresh teardown deadline; a + // re-call observing an in-flight bring-up (last result PENDING) must not. + if (this->last_result_ != ScanOpResult::PENDING || !this->scan_wanted_ || params != this->requested_) { + this->pending_since_ms_ = App.get_loop_component_start_time(); + this->reset_teardown_episode_(); } + this->scan_wanted_ = true; + this->requested_ = params; + return this->advance_(); +} - struct scan_param sp; - memset(&sp, 0, sizeof(sp)); - sp.channel_map = 7; // advertising channels 37/38/39 - sp.interval = interval; - sp.window = window; +void BK72xxBLE::scan_stop() { + if (this->scan_wanted_) { + // A stamp inherited from a stuck restart would fail the stop on its + // first advance. + this->reset_teardown_episode_(); + } + this->scan_wanted_ = false; + this->advance_(); +} - this->scan_actv_idx_ = app_ble_get_idle_actv_idx_handle(SCAN_ACTV); - if (this->scan_actv_idx_ == 0xFF) { - ESP_LOGE(TAG, "Scan start failed: no idle activity handle"); +bool BK72xxBLE::flush_pending_stop(uint32_t timeout_ms) { + // millis() on both sides: the loop clock is frozen while this blocks. + const uint32_t start = millis(); + while (!this->scan_wanted_ && this->last_result_ == ScanOpResult::PENDING) { + if (millis() - start >= timeout_ms) + return false; + delay(RECONCILE_RETRY_MS); + this->advance_(); + } + return this->last_result_ == ScanOpResult::SETTLED; +} + +// Teardown is asynchronous: the handle is kept until an IDLE observation +// confirms the radio is idle. A rejection WARNs once per failure streak and +// widens the pump gate; the epilogue owns the stuck-teardown deadline. +void BK72xxBLE::release_activity_(BdkActivityState state) { + const BdkOpResult result = + bdk_scan_release(this->scan_activity_idx_, state == BdkActivityState::CREATED, &this->last_release_err_); + if (result == BdkOpResult::OK) { + this->release_warned_ = false; + return; + } + if (!this->release_warned_) { + // A hard error carries its code immediately; the 30 s stuck ERROR follows + // if it persists. + if (result == BdkOpResult::FAILED) { + ESP_LOGW(TAG, "Scan activity release failed (err %d); retrying", this->last_release_err_); + } else { + ESP_LOGW(TAG, "Scan activity release rejected; retrying"); + } + this->release_warned_ = true; + } +} + +// Stamp/track the teardown episode; once past the deadline, ERROR (re-logged +// each interval) and report stuck. +bool BK72xxBLE::teardown_stuck_(uint32_t now) { + if (this->teardown_since_ms_ == 0) { + this->teardown_since_ms_ = now; + this->teardown_stuck_log_ms_ = now; // first ERROR fires at the deadline return false; } - ble_err_t ret = bk_ble_scan_start(this->scan_actv_idx_, &sp, nullptr); - if (ret != ERR_SUCCESS) { - ESP_LOGE(TAG, "Scan start failed (err %d)", static_cast(ret)); - this->scan_actv_idx_ = 0xFF; + if (now - this->teardown_since_ms_ < TEARDOWN_STUCK_ERROR_MS) return false; + if (now - this->teardown_stuck_log_ms_ >= TEARDOWN_STUCK_ERROR_MS) { + if (this->last_release_err_ != 0) { + ESP_LOGE(TAG, "Scan teardown cannot proceed; scanner is stuck (release err %d)", this->last_release_err_); + } else { + // No rejected release this episode: stuck waiting on the controller. + ESP_LOGE(TAG, "Scan teardown cannot proceed; scanner is stuck (controller busy)"); + } + this->teardown_stuck_log_ms_ = now; } return true; } -void BK72xxBLE::scan_stop() { - if (this->scan_actv_idx_ != 0xFF) { - bk_ble_scan_stop(this->scan_actv_idx_, nullptr); - this->scan_actv_idx_ = 0xFF; +// One SDK operation per call toward the latched request; controller state is +// read live each time (it changes on the BLE task, so nothing is mirrored). +// The epilogue owns all deadlines and episode bookkeeping. +ScanOpResult BK72xxBLE::advance_() { + if (!this->scan_wanted_ && this->scan_activity_idx_ == INVALID_ACTIVITY_IDX) { + // Nothing to do; also keeps SDK reads off the pre-enable() path. + this->last_result_ = ScanOpResult::SETTLED; + return ScanOpResult::SETTLED; } + const BdkActivityState state = bdk_scan_state(this->scan_activity_idx_); + const bool ready = bdk_scan_ready(); + ScanOpResult result = this->scan_wanted_ ? this->advance_start_(state, ready) : this->advance_stop_(state, ready); + + const uint32_t now = App.get_loop_component_start_time(); + this->last_advance_ms_ = now; + if (result == ScanOpResult::SETTLED || (state == BdkActivityState::IDLE && ready)) { + // Any teardown episode is over (IDLE observed with the controller + // settled, or e.g. a mode flip that settled back without ever reaching + // IDLE). An IDLE read while an operation is in flight proves nothing — + // a stop deferred there must keep its episode running. + this->reset_teardown_episode_(); + this->release_warned_ = false; + } + if (this->restarting_ && (state == BdkActivityState::IDLE || state == BdkActivityState::CREATED)) { + // The mode-change release is observed complete; the rest is a normal + // bring-up on a fresh budget. + this->restarting_ = false; + this->pending_since_ms_ = now; + } + // Not chained to the clear above: a bring-up waiting at IDLE (create still + // in flight) must keep spending its budget. + if (result == ScanOpResult::PENDING) { + if (this->scan_wanted_ && state != BdkActivityState::STARTED && !this->restarting_) { + // A downed radio spends the bring-up budget; exhausting it hands + // recovery to the tracker's backoff. + if (now - this->pending_since_ms_ >= RECONCILE_PENDING_TIMEOUT_MS) { + ESP_LOGE(TAG, "Scan bring-up did not settle; giving up until the next start"); + result = ScanOpResult::FAILED; + } + } else { + // A teardown is pending: a stop, or a mode-change release still in + // flight (restarting_); either way the bring-up budget waits. + if (this->scan_wanted_) + this->pending_since_ms_ = now; + if (this->teardown_stuck_(now)) { + // Terminal for stop AND restart: the tracker's backoff owns recovery + // (a stop's release keeps re-driving from loop(); a restart is + // re-requested through scan_start() with a fresh deadline). + result = ScanOpResult::FAILED; + } + } + } + this->last_result_ = result; + return result; +} + +ScanOpResult BK72xxBLE::advance_stop_(BdkActivityState state, bool ready) { + if (state == BdkActivityState::IDLE && ready) { + // Fully torn down (or never created): the radio is idle. IDLE is trusted + // only when the controller is settled — mid-create the slot still reads + // IDLE, and dropping the handle then would leak the activity once the + // create lands. + this->scan_activity_idx_ = INVALID_ACTIVITY_IDX; + return ScanOpResult::SETTLED; + } + if (!ready) { + // Acting mid-operation could delete an activity whose start lands + // afterwards, leaking the slot with the radio on; wait. + if (this->last_result_ == ScanOpResult::SETTLED) + ESP_LOGD(TAG, "Scan stop deferred (controller busy)"); + return ScanOpResult::PENDING; + } + // Settled, so CREATED unambiguously means "never started". + this->release_activity_(state); + return ScanOpResult::PENDING; // confirmed once IDLE is observed +} + +ScanOpResult BK72xxBLE::advance_start_(BdkActivityState state, bool ready) { + if (state == BdkActivityState::STARTED) { + if (this->applied_ == this->requested_) + return ScanOpResult::SETTLED; + // Running with different mode or parameters: tear down (the SDK stop + // chain also deletes the activity) and recreate on a later advance. + if (ready) { + this->release_activity_(state); + // Invalidate so a flip back to the old params cannot SETTLE against the + // activity being deleted (interval 0 never matches a real request). + this->applied_.interval = 0; + this->restarting_ = true; + } + return ScanOpResult::PENDING; + } + if (!ready) { + if (this->last_result_ == ScanOpResult::SETTLED) + ESP_LOGD(TAG, "Scan start deferred (controller busy)"); + return ScanOpResult::PENDING; + } + if (state == BdkActivityState::CREATED) { + // Fire-and-forget: SETTLED only once a later advance observes the scan + // running, so a rejected start is retried rather than silently dead. On + // failure the created activity is intact; keep the handle. + if (bdk_scan_start(this->scan_activity_idx_, this->requested_.interval, this->requested_.window, + this->requested_.active) != BdkOpResult::OK) + return ScanOpResult::FAILED; + this->applied_ = this->requested_; + return ScanOpResult::PENDING; + } + if (state == BdkActivityState::OTHER) + return ScanOpResult::PENDING; // transitional; settles on a later read + + // IDLE and ready: acquire a slot and create. A kept index is deliberately + // reused: SDK delete returns the slot to idle and create requires an idle + // slot, so it equals a fresh acquire — while clearing here would orphan a + // create still in flight (the BUSY race below). + if (this->scan_activity_idx_ == INVALID_ACTIVITY_IDX) { + this->scan_activity_idx_ = bdk_scan_acquire_activity(); + if (this->scan_activity_idx_ == INVALID_ACTIVITY_IDX) + return ScanOpResult::FAILED; + } + switch (bdk_scan_create(this->scan_activity_idx_)) { + case BdkOpResult::BUSY: // raced the BLE task; keep the index, the retry resumes this slot + case BdkOpResult::OK: + return ScanOpResult::PENDING; + case BdkOpResult::FAILED: + break; + } + // Safe to clear (unlike BUSY): acquire is a pure search, so a rejected + // create leaves the slot IDLE for re-acquire. + this->scan_activity_idx_ = INVALID_ACTIVITY_IDX; + return ScanOpResult::FAILED; } } // namespace esphome::bk72xx_ble diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.h b/esphome/components/bk72xx_ble/bk72xx_ble.h index 4e615af159..7646f17161 100644 --- a/esphome/components/bk72xx_ble/bk72xx_ble.h +++ b/esphome/components/bk72xx_ble/bk72xx_ble.h @@ -11,6 +11,8 @@ #include +#include "bdk_scan.h" + namespace esphome::bk72xx_ble { enum class BLEComponentState : uint8_t { @@ -19,11 +21,32 @@ enum class BLEComponentState : uint8_t { ACTIVE, }; +/// Outcome of one reconciliation step. +enum class ScanOpResult : uint8_t { + SETTLED, ///< The request is reached: scan observed running, or stopped + ///< with the activity fully released. + PENDING, ///< A step is in flight; loop() keeps advancing — call + ///< scan_start() again to learn the outcome. + FAILED, ///< The controller rejected a step; retry later. +}; + +/// One scan request: mode plus timing, in BLE units (0.625 ms). +struct ScanParams { + bool active; + uint16_t interval; + uint16_t window; + bool operator==(const ScanParams &) const = default; +}; + /// One advertisement report from the controller. struct BLEScanReport { uint8_t mac[6]; // LSB-first, as the controller delivers it int8_t rssi; // signed dBm uint8_t addr_type; + // GAPM report info byte (recv_adv_t.evt_type): bits 0-2 report type + // (1 = legacy adv, 3 = legacy scan response), bit 5 scannable — lets the + // tracker's merger tell the two frames apart. + uint8_t evt_type; uint8_t data_len; // bytes valid in data[] uint8_t data[62]; // legacy advertisement (31) + scan response (31) @@ -69,18 +92,33 @@ class BK72xxBLE final : public Component { void register_scan_listener(BLEScanListener *listener) { this->scan_listeners_.push_back(listener); } #endif - /// Start the controller scan. Interval/window are in BLE units (0.625 ms). - /// Enables the stack first if needed. Returns false on controller failure. - bool scan_start(uint16_t interval, uint16_t window); - /// Stop the controller scan (no-op when not scanning). + /// Request a scan (interval/window in 0.625 ms BLE units); enables the + /// stack first if needed. PENDING until the scan is observed running — + /// loop() keeps advancing, call again to learn the outcome. + ScanOpResult scan_start(uint16_t interval, uint16_t window, bool active); + /// Request the scanner stopped and the activity released; steps that + /// cannot run yet are completed from loop(). void scan_stop(); + /// Drive a requested stop until the radio is observed idle, bounded by + /// timeout_ms (for OTA). Returns false if it still has not settled. + bool flush_pending_stop(uint32_t timeout_ms); + /// Last reconciliation outcome; on FAILED the consumer's retry policy owns + /// recovery. + ScanOpResult last_scan_result() const { return this->last_result_; } /// Internal: buffer one controller report (BDK notice callback, BLE task /// context — bounded copy under the scheduler lock, nothing else). - void enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint16_t data_len); + void enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, uint8_t evt_type, const uint8_t *data, + uint16_t data_len); protected: void resolve_mac_(); + ScanOpResult advance_(); + ScanOpResult advance_stop_(BdkActivityState state, bool ready); + ScanOpResult advance_start_(BdkActivityState state, bool ready); + bool teardown_stuck_(uint32_t now); + void reset_teardown_episode_(); + void release_activity_(BdkActivityState state); #ifdef BK72XX_BLE_SCAN_LISTENER_COUNT // Codegen-sized: no heap allocation, no std::vector template instantiation — @@ -95,10 +133,24 @@ class BK72xxBLE final : public Component { // allocate() returns nullptr before push() can fail. This prevents leaking a // pool slot on a failed push and keeps release() off the producer path. esphome::EventPool report_pool_; - uint8_t ble_mac_[6]{0}; // LSB-first (BLE convention) - uint8_t scan_actv_idx_{0xFF}; - BLEComponentState state_{BLEComponentState::STATE_OFF}; + // Largest-to-smallest: padding only at the tail, absorbed by future byte fields. + uint32_t last_advance_ms_{0}; + uint32_t pending_since_ms_{0}; // bring-up budget anchor; refilled on request change + uint32_t teardown_since_ms_{0}; // unfinished teardown episode start; 0 = none + uint32_t teardown_stuck_log_ms_{0}; // last stuck-teardown ERROR; re-logged each TEARDOWN_STUCK_ERROR_MS + int last_release_err_{0}; // SDK code of the episode's last failed release; 0 = none + ScanParams requested_{}; // latched by scan_start() + ScanParams applied_{}; // last params we commanded; mismatch with requested_ restarts + uint8_t ble_mac_[6]{0}; // LSB-first (BLE convention) + uint8_t scan_activity_idx_{INVALID_ACTIVITY_IDX}; + bool scan_wanted_{false}; // the latched request is to scan (vs stopped) + bool release_warned_{false}; // gates the release WARN; widens the pump gate + bool restarting_{false}; // mode-change release in flight; teardown deadline governs until released bool enable_on_boot_{false}; + // PENDING means advance_() has more to do; loop() drives it, paced and + // (for a bring-up) bounded. + ScanOpResult last_result_{ScanOpResult::SETTLED}; + BLEComponentState state_{BLEComponentState::STATE_OFF}; }; } // namespace esphome::bk72xx_ble diff --git a/esphome/components/bk72xx_ble_tracker/__init__.py b/esphome/components/bk72xx_ble_tracker/__init__.py index 7fefb310cd..96b3536601 100644 --- a/esphome/components/bk72xx_ble_tracker/__init__.py +++ b/esphome/components/bk72xx_ble_tracker/__init__.py @@ -25,6 +25,7 @@ from esphome.components.ble_device_base import automation as ble_automation from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW import esphome.config_validation as cv from esphome.const import ( + CONF_ACTIVE, CONF_CONTINUOUS, CONF_DURATION, CONF_ID, @@ -146,6 +147,9 @@ async def stop_scan_action_to_code( async def to_code(config: ConfigType) -> None: # Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h. cg.add_define("USE_BK72XX_BLE_TRACKER") + # Compiles the shared adv + scan-response merge (the BDK delivers the pair + # as separate reports). + cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER") var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -164,6 +168,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_scan_window(ble_device_base.to_ble_units(scan[CONF_WINDOW]))) cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds)) cg.add(var.set_configured_continuous(scan[CONF_CONTINUOUS])) + cg.add(var.set_scan_active(scan[CONF_ACTIVE])) for conf in config.get(CONF_ON_BLE_ADVERTISE, []): await ble_automation.advertise_trigger_to_code(conf, var) diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp index a58561f2de..a312d2496f 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp @@ -9,10 +9,9 @@ #include "bk72xx_ble_tracker.h" -#include #include -#include "esphome/core/hal.h" +#include "esphome/core/application.h" #include "esphome/core/log.h" namespace esphome::bk72xx_ble_tracker { @@ -27,6 +26,15 @@ static const char *const TAG = "bk72xx_ble_tracker"; // a single WARN is emitted when the retry interval first saturates. static constexpr uint32_t SCAN_START_RETRY_MS = 1000; static constexpr uint8_t SCAN_START_RETRY_MAX_DOUBLINGS = 6; // 1 s << 6 = 64 s +// Stable-run time before the failure streak clears; reset-on-start would keep +// a flapping controller at the 1 s gate. +static constexpr uint32_t SCAN_STABLE_RESET_MS = 30000; + +// Radio-idle deadline for the bounded stop drain at OTA start. +static constexpr uint32_t OTA_STOP_FLUSH_MS = 100; + +// 0.625 ms BLE units; integer math avoids soft-float on this FPU-less part. +constexpr uint32_t ble_units_to_ms(uint32_t units) { return units * 5 / 8; } // --------------------------------------------------------------------------- // Component lifecycle @@ -36,11 +44,20 @@ void BK72xxBLETracker::setup() { // Receive the controller's scan reports; the controller queues them from the // BLE task and delivers here on the main task. this->parent_->register_scan_listener(this); + // Merged (and unmerged) frames go to the shared dispatcher; unclaimed + // devices are logged only on one-shot scans (continuous would spam). + this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, TAG); #ifdef USE_OTA_STATE_LISTENER // Pause scanning while an OTA update is in flight — on the single-core BK72xx the // BLE scan competes with the OTA flash writes. Mirrors esp32_ble_tracker. ota::get_global_ota_callback()->add_global_state_listener(this); #endif + // scan_requested_ check: an on_boot start_scan latched before this setup() + // must keep the retry loop running (rp2/ln882h parity). + if (!this->scan_continuous_ && !this->scan_requested_) { + // Nothing to time until an explicit start_scan(); it re-enables the loop. + this->disable_loop(); + } } #ifdef USE_OTA_STATE_LISTENER @@ -50,30 +67,54 @@ void BK72xxBLETracker::on_ota_global_state(ota::OTAState state, float progress, this->scan_continuous_before_ota_ = this->scan_continuous_; this->scan_requested_before_ota_ = this->scan_requested_; this->stop_scan(); + // The transfer starves the loop; a deferred stop would leave the radio + // scanning for the whole update, so drain it here, bounded. + if (!this->parent_->flush_pending_stop(OTA_STOP_FLUSH_MS)) + ESP_LOGE(TAG, "Scan still stopping at OTA start; the radio may contend with the update"); } else if (state == ota::OTA_ERROR || state == ota::OTA_ABORT) { // On success the device reboots, so restore only on a failed/aborted update; // loop() restarts the scan on its next iteration (continuous idle branch). if (this->scan_continuous_before_ota_) { this->scan_continuous_before_ota_ = false; this->scan_continuous_ = true; + this->enable_loop(); // stop_scan() parked it } // A one-shot request that was still pending (latched, retrying) when the // OTA paused scanning is re-latched, not dropped — loop() resumes the retry. if (this->scan_requested_before_ota_) { this->scan_requested_before_ota_ = false; this->scan_requested_ = true; + this->enable_loop(); } } } #endif // USE_OTA_STATE_LISTENER void BK72xxBLETracker::loop() { - const uint32_t now = millis(); + const uint32_t now = App.get_loop_component_start_time(); + + // Deliver held scannable advertisements whose scan response never arrived — + // unmerged after the merger's timeout. + if (!this->merger_.empty()) + this->merger_.sweep(now); + + // Before the drop branch: a drop after a stable run starts a fresh streak. + if (this->scan_running_ && this->failed_start_count_ != 0 && now - this->scan_start_time_ >= SCAN_STABLE_RESET_MS) + this->failed_start_count_ = 0; + + // A terminal failure while we report running recovers via the normal retry + // path; the drop charges the backoff so a flapping controller escalates. + if (this->scan_running_ && this->parent_->last_scan_result() == bk72xx_ble::ScanOpResult::FAILED) { + ESP_LOGW(TAG, "Controller scan lost; retrying"); + this->scan_requested_ = true; + this->count_failed_start_(); + this->mark_scan_ended_(now); + } + if (this->scan_continuous_) { if (!this->scan_running_) { - // A start that succeeded re-anchored the period timer from a later millis(), - // so the stale `now` below would underflow the comparison and fire - // on_scan_end() for a scan that just began. Resume next iteration. + // One-iteration deferral; all stamps share this iteration's cached + // timestamp, so the period check below cannot underflow. if (this->try_start_with_backoff_(now)) return; } @@ -81,11 +122,7 @@ void BK72xxBLETracker::loop() { // esp32_ble_tracker::cleanup_scan_state_(). Gated on scan_started_once_ so a scan // that never came up (start kept failing) does not fire spurious on_scan_end events. if (this->scan_started_once_ && now - this->scan_period_start_ >= this->scan_duration_) { -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - for (auto *listener : this->listeners_) - listener->on_scan_end(); - this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity) -#endif + this->fire_scan_end_(); this->scan_period_start_ = now; } return; @@ -99,13 +136,14 @@ void BK72xxBLETracker::loop() { // would be silent: the scan never runs, stop_scan_() is never reached and // on_scan_end() never fires, leaving period-keyed consumers waiting forever. if (this->scan_requested_ && !this->scan_running_) { - // Same stale-`now` hazard as the continuous branch: start_scan_() stamps - // scan_start_time_ from a later millis(), so the duration check below would - // underflow and stop the scan in the iteration that started it. + // Same one-iteration deferral as the continuous branch. if (this->try_start_with_backoff_(now)) return; } if (this->scan_running_ && now - this->scan_start_time_ >= this->scan_duration_) { + // A full-duration run proves the controller healthy even when duration is + // shorter than SCAN_STABLE_RESET_MS. + this->failed_start_count_ = 0; this->stop_scan_(); } } @@ -122,32 +160,54 @@ bool BK72xxBLETracker::try_start_with_backoff_(uint32_t now, bool force) { // even user-initiated attempts respect the backoff, so a start_scan() action // on a short cadence cannot hammer a failing controller; the attempt stays // inside the failure accounting below either way. - const uint8_t doublings = std::min(this->failed_start_count_, SCAN_START_RETRY_MAX_DOUBLINGS); - if ((!force || this->failed_start_count_ != 0) && - now - this->last_scan_start_attempt_ < (SCAN_START_RETRY_MS << doublings)) + // Mid bring-up, observe instead of re-issuing (the hub self-advances). A + // SETTLED outcome completes immediately; only fresh attempts after FAILED + // are rate-limited. + const auto hub = this->parent_->last_scan_result(); + if (hub == bk72xx_ble::ScanOpResult::PENDING) return false; - this->last_scan_start_attempt_ = now; + if (hub == bk72xx_ble::ScanOpResult::FAILED) { + if (this->start_attempt_open_) { + // Our bring-up gave up asynchronously; charge it to the backoff. + this->start_attempt_open_ = false; + this->count_failed_start_(); + } + if ((!force || this->failed_start_count_ != 0) && + now - this->last_scan_start_attempt_ < (SCAN_START_RETRY_MS << this->failed_start_count_)) + return false; + } this->start_scan_(); - if (!this->scan_running_ && this->failed_start_count_ < SCAN_START_RETRY_MAX_DOUBLINGS) { + if (!this->scan_running_) { + if (this->parent_->last_scan_result() == bk72xx_ble::ScanOpResult::PENDING) { + this->start_attempt_open_ = true; + return false; // the controller is still bringing the scan up; not a failure + } + this->count_failed_start_(); + } + return this->scan_running_; +} + +void BK72xxBLETracker::count_failed_start_() { + if (this->failed_start_count_ < SCAN_START_RETRY_MAX_DOUBLINGS) { ++this->failed_start_count_; if (this->failed_start_count_ == SCAN_START_RETRY_MAX_DOUBLINGS) { ESP_LOGW(TAG, "Scan start keeps failing; retrying every %" PRIu32 " s", (SCAN_START_RETRY_MS << SCAN_START_RETRY_MAX_DOUBLINGS) / 1000); } } - return this->scan_running_; } void BK72xxBLETracker::dump_config() { ESP_LOGCONFIG(TAG, "BK72xx BLE Tracker:\n" " Scan Duration: %" PRIu32 " s\n" - " Scan Interval: %.0f ms (%" PRIu32 " BLE units)\n" - " Scan Window: %.0f ms (%" PRIu32 " BLE units)\n" - " Scan Type: PASSIVE\n" + " Scan Interval: %" PRIu32 " ms (%" PRIu32 " BLE units)\n" + " Scan Window: %" PRIu32 " ms (%" PRIu32 " BLE units)\n" + " Scan Type: %s (configured %s)\n" " Continuous Scanning: %s", - this->scan_duration_ / 1000, this->scan_interval_ * 0.625f, this->scan_interval_, - this->scan_window_ * 0.625f, this->scan_window_, YESNO(this->scan_continuous_)); + this->scan_duration_ / 1000, ble_units_to_ms(this->scan_interval_), this->scan_interval_, + ble_units_to_ms(this->scan_window_), this->scan_window_, this->scan_active_ ? "ACTIVE" : "PASSIVE", + this->scan_active_configured_ ? "ACTIVE" : "PASSIVE", YESNO(this->scan_continuous_)); } // --------------------------------------------------------------------------- @@ -156,31 +216,33 @@ void BK72xxBLETracker::dump_config() { // listener dispatch run in main-loop context with no cross-task handling here. // --------------------------------------------------------------------------- -void BK72xxBLETracker::on_scan_report(const bk72xx_ble::BLEScanReport &report) { - // Raw callback (the raw-advertisement path). - if (this->raw_advertisement_callback_.is_set()) { - const ble_device_base::RawAdvertisement adv{.address = ble_device_base::mac_lsb_first_to_uint64(report.mac), - .data = report.data, - .data_len = report.data_len, - .rssi = report.rssi, - .addr_type = report.addr_type}; - this->raw_advertisement_callback_.invoke(adv); - } +// GAPM report info byte (BLEScanReport::evt_type): bits 0-2 report type, +// bit 5 scannable advertisement. Verified against both BDK stacks (5.1 and +// 5.2 fill it from gapm_ext_adv_report_ind.info). +static constexpr uint8_t GAPM_REPORT_TYPE_MASK = 0x07; +static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_EXT = 2; +static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_LEG = 3; +static constexpr uint8_t GAPM_REPORT_INFO_SCAN_ADV_BIT = 1 << 5; -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - ble_device_base::ESPBTDevice device; - device.from_scan_result(report.mac, report.rssi, report.addr_type, report.data, report.data_len); - bool found = false; - for (auto *listener : this->listeners_) { - if (listener->parse_device(device)) { - found = true; - } +// Demux advertisements vs scan responses into the shared merger: the BDK +// delivers the pair as separate reports; a scannable advertisement is held +// until its scan response arrives and delivered as one merged frame. +void BK72xxBLETracker::on_scan_report(const bk72xx_ble::BLEScanReport &report) { + const uint8_t rtype = report.evt_type & GAPM_REPORT_TYPE_MASK; + if (rtype == GAPM_REPORT_TYPE_SCAN_RSP_LEG || rtype == GAPM_REPORT_TYPE_SCAN_RSP_EXT) { + this->merger_.submit_scan_rsp(report.mac, report.rssi, report.addr_type, report.data, report.data_len); + return; } - // Mirror esp32_ble_tracker: log a newly-seen device only when nothing claimed - // it and the scan is one-shot (continuous scans would spam). - if (!found && !this->scan_continuous_) - this->discovered_log_.log_device(TAG, device); -#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + // Stash only while an active scan runs: a passive scan never gets a + // response, and after a stop nothing would sweep the merger, so a late + // report would surface minutes later as a fresh advertisement. + if (this->scan_running_ && this->scan_active_ && (report.evt_type & GAPM_REPORT_INFO_SCAN_ADV_BIT)) { + this->merger_.stash_adv(report.mac, report.rssi, report.addr_type, report.data, report.data_len, + App.get_loop_component_start_time()); + return; + } + this->dispatcher_.dispatch(report.mac, report.rssi, report.addr_type, report.data, report.data_len, + /*raw_only=*/false, this->scan_continuous_ ? nullptr : TAG); } // --------------------------------------------------------------------------- @@ -207,7 +269,8 @@ void BK72xxBLETracker::start_scan() { // against a failing controller, repeated start_scan() calls are rate-limited // like any other attempt. this->scan_requested_ = true; - this->try_start_with_backoff_(millis(), /* force= */ true); + this->enable_loop(); // an idle one-shot tracker parked it in stop_scan_() + this->try_start_with_backoff_(App.get_loop_component_start_time(), /* force= */ true); } void BK72xxBLETracker::restart_scan_duration() { @@ -218,7 +281,7 @@ void BK72xxBLETracker::restart_scan_duration() { // start_scan action fired more often than scan_duration_ would otherwise // suppress on_scan_end indefinitely — and absence detection (ble_rssi's NAN // publish) rides on that period. - this->scan_start_time_ = millis(); + this->scan_start_time_ = App.get_loop_component_start_time(); } void BK72xxBLETracker::stop_scan() { @@ -231,24 +294,31 @@ void BK72xxBLETracker::stop_scan() { // Internal scan start / stop // --------------------------------------------------------------------------- +bk72xx_ble::ScanOpResult BK72xxBLETracker::controller_scan_start_() { + this->last_scan_start_attempt_ = App.get_loop_component_start_time(); + return this->parent_->scan_start(static_cast(this->scan_interval_), + static_cast(this->scan_window_), this->scan_active_); +} + void BK72xxBLETracker::start_scan_() { if (this->scan_running_) return; - if (!this->parent_->scan_start(static_cast(this->scan_interval_), - static_cast(this->scan_window_))) + if (this->controller_scan_start_() != bk72xx_ble::ScanOpResult::SETTLED) return; - const uint32_t now = millis(); + const uint32_t now = App.get_loop_component_start_time(); this->scan_running_ = true; this->scan_requested_ = false; // the latched one-shot request is satisfied - this->failed_start_count_ = 0; // reset here so direct starts clear the backoff too + this->start_attempt_open_ = false; + // failed_start_count_ deliberately not reset here; only a stable run clears it (loop()). this->scan_start_time_ = now; // Log every explicit start at DEBUG — stop_scan_() logs every stop at DEBUG, and // in non-continuous mode each period is an explicit start, so asymmetric logging // would read as the scanner failing to come back up. - ESP_LOGD(TAG, "Scan started (passive, window=%.0fms, interval=%.0fms)", this->scan_window_ * 0.625f, - this->scan_interval_ * 0.625f); + ESP_LOGD(TAG, "Scan started (%s, window=%" PRIu32 "ms, interval=%" PRIu32 "ms)", + this->scan_active_ ? "active" : "passive", ble_units_to_ms(this->scan_window_), + ble_units_to_ms(this->scan_interval_)); // Re-anchor the on_scan_end period to every successful start — first start (so the // period counts from the scan, not from boot) and every restart after a stop (so // resuming after longer than scan_duration, e.g. a failed OTA restoring continuous @@ -258,18 +328,48 @@ void BK72xxBLETracker::start_scan_() { this->scan_started_once_ = true; } +// Deliberate logical/physical split: on_scan_end() reports the tracker's +// intent while the hub winds the radio down asynchronously; OTA is the one +// path that must wait, and it flushes explicitly. void BK72xxBLETracker::stop_scan_() { - if (!this->scan_running_) - return; - this->parent_->scan_stop(); + this->start_attempt_open_ = false; // an abandoned bring-up is not charged + this->parent_->scan_stop(); // idempotent: releases whatever the hub holds + if (this->scan_running_) { + ESP_LOGD(TAG, "Scan stopped"); + this->mark_scan_ended_(App.get_loop_component_start_time()); + } + // Park when idle (the hub drives its own teardown); re-check because an + // on_scan_end automation may have restarted the scan. + if (!this->scan_continuous_ && !this->scan_running_ && !this->scan_requested_) + this->disable_loop(); +} + +// The period re-anchor keeps on_scan_end from double-firing in one iteration. +void BK72xxBLETracker::mark_scan_ended_(uint32_t now) { this->scan_running_ = false; - ESP_LOGD(TAG, "Scan stopped"); -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - for (auto *listener : this->listeners_) - listener->on_scan_end(); - this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity) -#endif - this->scan_period_start_ = millis(); // reset period clock so on_scan_end does not double-fire + this->fire_scan_end_(); + this->scan_period_start_ = now; +} + +void BK72xxBLETracker::fire_scan_end_() { + // Deliver held advertisements whose scan response never came (unmerged) + // BEFORE on_scan_end fires. + this->merger_.flush(); + this->dispatcher_.on_scan_end(); +} + +// true = request latched, not applied: the reconciler applies it +// asynchronously and loop() recovers a failed re-arm (ln882h parity). +bool BK72xxBLETracker::request_scan_mode(bool active) { + if (this->scan_active_ == active) + return true; + this->scan_active_ = active; + ESP_LOGD(TAG, "Scan mode %s", active ? "active" : "passive"); + // The controller reconciler restarts a running scan itself; the scan stays + // logically running. An idle scanner picks the mode up on its next start. + if (this->scan_running_) + this->controller_scan_start_(); + return true; } } // namespace esphome::bk72xx_ble_tracker diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h index cc51918da5..59d17f9b84 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h @@ -21,6 +21,7 @@ // window: 30ms // duration: 5min // continuous: true +// active: true #pragma once @@ -29,6 +30,7 @@ #include "esphome/components/bk72xx_ble/bk72xx_ble.h" #include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/ble_device_base/ble_hub.h" +#include "esphome/components/ble_device_base/scan_response_merger.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -69,6 +71,12 @@ class BK72xxBLETracker : public Component, void set_scan_interval(uint32_t scan_interval) { this->scan_interval_ = scan_interval; } void set_scan_window(uint32_t scan_window) { this->scan_window_ = scan_window; } void set_scan_duration(uint32_t scan_duration) { this->scan_duration_ = scan_duration; } + /// Set from YAML (scan_parameters.active); runtime mode requests change + /// only the resolved mode. + void set_scan_active(bool scan_active) { + this->scan_active_ = scan_active; + this->scan_active_configured_ = scan_active; + } /// Set from YAML (scan_parameters.continuous); also the value /// configured_continuous() reports and a bare start_scan action restores. void set_configured_continuous(bool scan_continuous) { @@ -93,27 +101,19 @@ class BK72xxBLETracker : public Component, // ---- ble_device_base::BLEHub contract ---- void register_listener(ble_device_base::ESPBTDeviceListener *listener) { -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - this->listeners_.push_back(listener); -#endif + this->dispatcher_.register_listener(listener); } void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { - this->raw_advertisement_callback_ = callback; + this->dispatcher_.set_raw_advertisement_callback(callback); } static constexpr ble_device_base::HubCapabilities get_capabilities() { - // The Beken BDK exposes no active-scan path (passive scanning only), so the - // controller never solicits scan responses and never merges them; consumers - // relying on scan-response fields (device names) get them only where the - // receiver merges per address (Home Assistant does). No GATT client either. - // scan_mode_switch stays false for the same reason: with no active-scan - // path there is no mode to switch to. - return {.active_scan = false, .merges_scan_response = false, .gatt = false, .scan_mode_switch = false}; - } - bool request_scan_mode(bool active) { - // Passive-only controller: a passive request is already honored, an active - // one cannot be. - return !active; + // Active scanning is driven through bk72xx_ble's reconciler because the BDK + // API itself is passive-only. The controller delivers scan responses as + // separate reports; this tracker merges the pair before delivery (shared + // ScanResponseMerger, Bluedroid semantics). No GATT client. + return {.active_scan = true, .merges_scan_response = true, .gatt = false, .scan_mode_switch = true}; } + bool request_scan_mode(bool active); // The controller stores the address LSB-first (BLE convention); the contract // wants printable (MSB-first) order. void get_adapter_mac(uint8_t out[6]) { @@ -123,7 +123,7 @@ class BK72xxBLETracker : public Component, out[i] = mac[5 - i]; } bool scan_running() { return this->scan_running_; } - bool scan_active() { return false; } // BK72xx scan is passive-only + bool scan_active() { return this->scan_active_; } // ---- bk72xx_ble::BLEScanListener ---- // Delivered by the controller's loop() on the ESPHome main task — the @@ -133,15 +133,20 @@ class BK72xxBLETracker : public Component, protected: void start_scan_(); void stop_scan_(); - /// Attempt a rate-limited (re)start; returns true when the scan is running, - /// which means the caller must not compare its cached millis() against the - /// timestamps start_scan_() just refreshed. force bypasses the rate gate for - /// an explicit user start only while the failure streak is clean; a failing - /// controller rate-limits forced attempts too. Failure accounting always runs. + void fire_scan_end_(); + void mark_scan_ended_(uint32_t now); + /// Stamp-and-start for every controller scan attempt, so the retry rate + /// limit covers all callers. + bk72xx_ble::ScanOpResult controller_scan_start_(); + /// Rate-limited (re)start; true when the scan is running (the caller must + /// not reuse a `now` older than the stamps this refreshed). Force and + /// backoff rules are documented at the definition. bool try_start_with_backoff_(uint32_t now, bool force = false); + void count_failed_start_(); bool scan_running_{false}; - bool scan_requested_{false}; // latched start_scan() request not yet running; loop() retries with backoff + bool scan_requested_{false}; // latched start_scan() request not yet running; loop() retries with backoff + bool start_attempt_open_{false}; // charge a later FAILED observation to the backoff exactly once // Defaults: the BK reference — 30 % duty cycle // (interval 100 ms / window 30 ms), in 0.625 ms BLE units. uint32_t scan_interval_{160}; // 160 × 0.625 ms = 100 ms @@ -149,30 +154,27 @@ class BK72xxBLETracker : public Component, uint32_t scan_duration_{300000}; bool scan_continuous_{true}; bool scan_continuous_configured_{true}; // YAML value; stop_scan() must not lose it + bool scan_active_{true}; // resolved mode; see scan_parameters.active + bool scan_active_configured_{true}; // YAML value; runtime requests must not lose it #ifdef USE_OTA_STATE_LISTENER bool scan_continuous_before_ota_{false}; // continuous mode saved at OTA start, restored on OTA failure bool scan_requested_before_ota_{false}; // pending one-shot latch saved at OTA start, re-latched on OTA failure #endif uint32_t scan_start_time_{0}; - uint32_t last_scan_start_attempt_{0}; // millis() of last start_scan_() attempt; rate-limits retries - uint8_t failed_start_count_{0}; // consecutive failed starts; drives the retry backoff (reset on success) - uint32_t scan_period_start_{0}; // millis() at start of current scan period; used to rate-limit on_scan_end() + uint32_t last_scan_start_attempt_{0}; // last controller start attempt, any caller; rate-limits retries + uint8_t failed_start_count_{0}; // failed starts AND drops; backoff shift, cleared after a stable run (loop()) + uint32_t scan_period_start_{0}; // loop-clock start of the scan period; rate-limits on_scan_end() bool scan_started_once_{false}; // true after first successful scan start; gates the period timer - ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{}; -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - // Parsed-advertisement consumers registered through ble_device_base. - // Codegen-sized: no heap allocation, no std::vector template instantiations. - StaticVector listeners_; -#endif - -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - // Per-period "Found device" DEBUG log with MAC dedup — shared implementation - // in ble_device_base, identical output on every tracker backend. Guarded like - // its only writer so a no-listener build does not carry an unused vector. - ble_device_base::DiscoveredDeviceLog discovered_log_{}; -#endif + // Shared adv + scan-response merge and frame dispatch (ble_device_base). + // All calls run on the main task (the controller queue already crossed + // tasks). Merger clock: stash_adv() reads the PARENT's cached loop time + // (on_scan_report runs inside bk72xx_ble's queue drain), sweep() this + // component's — same App.loop() pass, so the delta stays non-negative and + // the 300 ms timeout holds. + ble_device_base::ScanResponseMerger merger_; + ble_device_base::AdvDispatcher dispatcher_; }; } // namespace esphome::bk72xx_ble_tracker diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index ae03003713..4da7d48882 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -247,24 +247,23 @@ def scan_parameters_schema( interval_default: str, *, window_default: str = "30ms", - supports_active: bool = False, ) -> cv.All: """Build the scan_parameters value schema shared by all BLE trackers. interval_default and window_default are per chip (e.g. esp32 320/30 ms, bk72xx/rp2 100/30 ms — the reference scan rates of the respective stacks; - LN882H's SDK recommends 100/50 ms). Pass supports_active=True only when - the tracker supports active scanning; it exposes the `active` option - (whose own default is on, esp32_ble_tracker behavior). + LN882H's SDK recommends 100/50 ms). The `active` option (default on) is + unconditional: active scanning is part of the tracker contract — every + current proxy client assumes it, so a passive-only tracker must not share + this schema. """ schema = { cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds, cv.Optional(CONF_INTERVAL, default=interval_default): cv.positive_time_period, cv.Optional(CONF_WINDOW, default=window_default): cv.positive_time_period, cv.Optional(CONF_CONTINUOUS, default=True): cv.boolean, + cv.Optional(CONF_ACTIVE, default=True): cv.boolean, } - if supports_active: - schema[cv.Optional(CONF_ACTIVE, default=True)] = cv.boolean return cv.All(cv.Schema(schema), validate_scan_parameters) diff --git a/esphome/components/ble_device_base/ble_hub.h b/esphome/components/ble_device_base/ble_hub.h index 8e4c710bb1..9da6371012 100644 --- a/esphome/components/ble_device_base/ble_hub.h +++ b/esphome/components/ble_device_base/ble_hub.h @@ -83,9 +83,9 @@ struct HubCapabilities { /// Today: esp32 and rp2. bool gatt; /// request_scan_mode() is honored at runtime. Distinct from active_scan: - /// a passive-only controller (bk72xx) can never switch, and a hub may - /// support active scanning yet still refuse the runtime switch - /// (esp32_ble_tracker drives its mode through its own tracker API). + /// a passive-only controller can never switch, and a hub may support + /// active scanning yet still refuse the runtime switch (esp32_ble_tracker + /// drives its mode through its own tracker API). bool scan_mode_switch; }; diff --git a/esphome/components/bluetooth_connection/__init__.py b/esphome/components/bluetooth_connection/__init__.py index 8c218c0954..ee46f85a38 100644 --- a/esphome/components/bluetooth_connection/__init__.py +++ b/esphome/components/bluetooth_connection/__init__.py @@ -164,6 +164,7 @@ SOURCE_FILE_FRAMEWORKS: dict[str, set[PlatformFramework]] = { "bluetooth_connection_hub.cpp": { PlatformFramework.RP2_ARDUINO, PlatformFramework.LN882X_ARDUINO, + PlatformFramework.BK72XX_ARDUINO, PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP32_IDF, }, diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index b1c684fcc3..ffa942f27b 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -7,6 +7,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ACTIVE, CONF_ID, + PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_LN882X, PLATFORM_RP2, @@ -47,15 +48,13 @@ def AUTO_LOAD(config: ConfigType | None = None) -> list[str]: # Platforms with an in-tree ble_device_base BLE tracker hub whose controller -# supports active scanning. Passive-only hubs (bk72xx) are deliberately NOT -# admitted yet: every current client (aioesphomeapi, bleak-esphome, Home -# Assistant) assumes an ESPHome proxy can scan actively, so a passive-only -# proxy would be misdriven — bk72xx follows once the API carries a feature -# flag clients can trust (FEATURE_ACTIVE_SCAN + a version flag, separate PRs). -# Coupled to bluetooth_connection: platforms here are also listed in its -# _PLATFORM_BACKENDS registry, HUB_MAX_CONNECTIONS, and FILTER_SOURCE_FILES -# hub entry. -_HUB_PLATFORMS = (PLATFORM_LN882X, PLATFORM_RP2) +# supports active scanning — every current client (aioesphomeapi, bleak-esphome, +# Home Assistant) assumes an ESPHome proxy can scan actively, so a passive-only +# hub must not be admitted (it would be misdriven). +# Coupled to bluetooth_connection: platforms with a GATT backend are also +# listed in its _PLATFORM_BACKENDS registry, HUB_MAX_CONNECTIONS, and +# FILTER_SOURCE_FILES hub entry. +_HUB_PLATFORMS = (PLATFORM_BK72XX, PLATFORM_LN882X, PLATFORM_RP2) DEPENDENCIES = ["api"] CODEOWNERS = ["@jesserockz", "@bdraco"] @@ -264,11 +263,15 @@ def _validate_platform(config: ConfigType) -> ConfigType: # Fail here with the actual reason. Without this gate the error surfaces # later as an unresolvable hub ID ("Are you missing a hub declaration?") # on platforms where no hub component can be declared. + full = ", ".join(["esp32", *sorted(bluetooth_connection.HUB_MAX_CONNECTIONS)]) + adv_only = ", ".join( + sorted(set(_HUB_PLATFORMS) - set(bluetooth_connection.HUB_MAX_CONNECTIONS)) + ) raise cv.Invalid( f"bluetooth_proxy is not supported on {CORE.target_platform}: no " "active-scan-capable BLE tracker hub is available for this " - "platform. It runs on esp32 and rp2 (full proxy) and the ln882x " - "family (advertisement-only)." + f"platform. It runs on {full} (full proxy) and {adv_only} " + "(advertisement-only)." ) if CORE.target_platform in bluetooth_connection.HUB_MAX_CONNECTIONS: return _GATT_HUB_SCHEMAS[CORE.target_platform]()(config) diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 84f43fb54b..634b8c3bef 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -128,9 +128,7 @@ def validate_max_connections_deprecated(config: ConfigType) -> ConfigType: # 320 ms is the ESP-IDF reference scan interval; the shared schema also # tightens validation to the controller's 2.5 ms .. 10240 ms range and rejects # window/interval pairs that collapse to the same 0.625 ms unit count. -SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema( - "320ms", supports_active=True -) +SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("320ms") # Codegen helpers are owned by ble_device_base; kept under the historical names # here for the components that import them from this module. diff --git a/esphome/components/ln882h_ble_tracker/__init__.py b/esphome/components/ln882h_ble_tracker/__init__.py index 8443799144..4bfaa93ab7 100644 --- a/esphome/components/ln882h_ble_tracker/__init__.py +++ b/esphome/components/ln882h_ble_tracker/__init__.py @@ -47,7 +47,7 @@ BLEEndOfScanTrigger = ble_automation.BLEEndOfScanTrigger # LN882H SDK reference scan rate: 100 ms interval / 50 ms window (50 % duty). SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema( - "100ms", window_default="50ms", supports_active=True + "100ms", window_default="50ms" ) diff --git a/esphome/components/rp2_ble_tracker/__init__.py b/esphome/components/rp2_ble_tracker/__init__.py index 99262babce..7709df9899 100644 --- a/esphome/components/rp2_ble_tracker/__init__.py +++ b/esphome/components/rp2_ble_tracker/__init__.py @@ -41,9 +41,7 @@ RP2BLETracker = rp2_ble_tracker_ns.class_( # to_code(). `active` defaults on for esp32_ble_tracker parity; it adds scan # request TX and roughly doubles the reports through the queue, so # `active: false` is the lighter choice when scan response data is not needed. -SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema( - "100ms", supports_active=True -) +SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("100ms") CONFIG_SCHEMA = cv.Schema( { diff --git a/tests/component_tests/bk72xx_ble_tracker/config/test_automations.yaml b/tests/component_tests/bk72xx_ble_tracker/config/test_automations.yaml index 994855b782..123d4296db 100644 --- a/tests/component_tests/bk72xx_ble_tracker/config/test_automations.yaml +++ b/tests/component_tests/bk72xx_ble_tracker/config/test_automations.yaml @@ -15,6 +15,7 @@ bk72xx: bk72xx_ble_tracker: scan_parameters: continuous: false + active: false on_ble_advertise: - mac_address: - AC:37:43:77:5F:4C diff --git a/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py b/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py index 3a03f98adf..777ae76b4f 100644 --- a/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py +++ b/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py @@ -48,6 +48,8 @@ def test_trigger_codegen( # scan_parameters continuous: false reaches the YAML-mode setter, not the # runtime override. assert "->set_configured_continuous(false)" in main_cpp + # active: false (non-default) flows through to the setter. + assert "->set_scan_active(false)" in main_cpp # Constructor call, not just the declaration: the parent argument is what # registers the trigger as a listener. assert re.search( diff --git a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py index bd41a9476a..2549125a43 100644 --- a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py +++ b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py @@ -16,8 +16,8 @@ from esphome.components.ln882h_ble_tracker import ( from esphome.components.rp2_ble_tracker import SCAN_PARAMETERS_SCHEMA as RP2_SCHEMA -def _validate(**kwargs: str) -> dict: - """Run a scan_parameters config through a passive tracker's real schema.""" +def _validate(**kwargs: str | bool) -> dict: + """Run a scan_parameters config through the bk72xx tracker's real schema.""" return BK72XX_SCHEMA(kwargs) @@ -48,11 +48,12 @@ def test_to_ble_units_truncates() -> None: def test_bk72xx_defaults_are_valid() -> None: - """bk72xx pins the BK reference rate: 100 ms interval, shared 30 ms window.""" + """bk72xx pins the BK reference rate — 100 ms interval, shared 30 ms window — + and exposes active (default on, like every active-capable tracker).""" config = _validate() assert to_ble_units(config["interval"]) == 160 assert to_ble_units(config["window"]) == 48 - assert "active" not in config + assert config["active"] is True def test_esp32_defaults_are_valid() -> None: @@ -86,10 +87,9 @@ def test_esp32_active_can_disable() -> None: assert config["active"] is False -def test_passive_schema_rejects_active_key() -> None: - """Trackers without active scan support must not silently accept the option.""" - with pytest.raises(cv.Invalid): - _validate(active="true") +def test_bk72xx_active_can_disable() -> None: + config = _validate(active=False) + assert config["active"] is False # --- accepted configurations --- diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py index a47dfd53fa..16a3850d46 100644 --- a/tests/component_tests/bluetooth_proxy/test_platform_gates.py +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -15,6 +15,7 @@ from esphome.const import ( KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, + PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_LN882X, PLATFORM_RP2, @@ -27,18 +28,20 @@ from ..types import SetCoreConfigCallable # Advertisement-only hub platforms; rp2 runs the full proxy and has its own # tests below. HUB_PLATFORM_FRAMEWORKS = [ + PlatformFramework.BK72XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, ] HUB_TRACKERS = { + PLATFORM_BK72XX: "bk72xx_ble_tracker", PLATFORM_LN882X: "ln882h_ble_tracker", PLATFORM_RP2: "rp2_ble_tracker", } def test_hub_platform_list_covers_every_hub_platform() -> None: - # A platform added to _HUB_PLATFORMS (bk72xx is planned) would otherwise - # get no gate coverage at all; GATT platforms have their own tests. + # A platform added to _HUB_PLATFORMS would otherwise get no gate coverage + # at all; GATT platforms have their own tests. advertisement_only = set(bluetooth_proxy._HUB_PLATFORMS) - set( bluetooth_connection.HUB_MAX_CONNECTIONS ) diff --git a/tests/components/bk72xx_ble_tracker/validate-passive.bk72xx-ard.yaml b/tests/components/bk72xx_ble_tracker/validate-passive.bk72xx-ard.yaml new file mode 100644 index 0000000000..ad25fd6b64 --- /dev/null +++ b/tests/components/bk72xx_ble_tracker/validate-passive.bk72xx-ard.yaml @@ -0,0 +1,8 @@ +# Passive scanning variant: the package merge keeps the shared parameters from +# common.yaml and overrides only the mode. +packages: + bk72xx_ble_tracker: !include common.yaml + +bk72xx_ble_tracker: + scan_parameters: + active: false diff --git a/tests/components/bluetooth_proxy/validate.bk72xx-ard.yaml b/tests/components/bluetooth_proxy/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..331d679510 --- /dev/null +++ b/tests/components/bluetooth_proxy/validate.bk72xx-ard.yaml @@ -0,0 +1,11 @@ +# Advertisement-only proxy on the bk72xx BLE hub (active-scan-capable since the +# tracker's packed-command start). Config-only: the CI base board generic-bk7252 +# is BLE 4.2 and cannot compile the BLE 5.x tracker. Same bare-hub arrangement +# as test.ln882x-ard.yaml: no explicit ble_hub_id so a grouped build cannot +# collide with bk72xx_ble_tracker's own fixture id. +packages: + common: !include common.yaml + +bk72xx_ble_tracker: + +bluetooth_proxy: From 2999e7b9257a668c9132d38f949369b181fd2132 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Mon, 10 Aug 2026 12:23:49 -0700 Subject: [PATCH 1364/1815] [modbus] Add API for reading/writing coils and discrete inputs in server mode (#17264) Co-authored-by: Claude Fable 5 Co-authored-by: J. Nick Koston --- esphome/components/modbus/modbus.cpp | 210 +++++++-- esphome/components/modbus/modbus.h | 53 ++- .../components/modbus/modbus_definitions.h | 13 + esphome/components/modbus/modbus_helpers.cpp | 11 +- esphome/components/modbus/modbus_helpers.h | 23 + .../modbus_controller/modbus_controller.cpp | 19 +- .../modbus_server/modbus_server.cpp | 9 +- tests/components/modbus/common.h | 11 + .../modbus/modbus_broadcast_test.cpp | 111 ++++- .../components/modbus/modbus_helpers_test.cpp | 14 + .../modbus/modbus_server_coils_test.cpp | 398 ++++++++++++++++++ .../command_payload_test.cpp | 31 ++ 12 files changed, 825 insertions(+), 78 deletions(-) create mode 100644 tests/components/modbus/modbus_server_coils_test.cpp create mode 100644 tests/components/modbus_controller/command_payload_test.cpp diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 9f2527d9fb..901bfcc52e 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -1,4 +1,7 @@ #include "modbus.h" + +#include + #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -378,10 +381,9 @@ ModbusServerDevice *ModbusServerHub::find_device_(uint8_t address) { return nullptr; } -ResponseStatus ModbusServerHub::check_register_range_(uint16_t start_address, uint16_t number_of_registers) { - if ((uint32_t) start_address + number_of_registers > 0x10000u) { - ESP_LOGW(TAG, "Register address out of range - start: %" PRIu16 " num: %" PRIu16, start_address, - number_of_registers); +ResponseStatus ModbusServerHub::check_address_range_(uint16_t start_address, uint16_t count) { + if ((uint32_t) start_address + count > 0x10000u) { + ESP_LOGW(TAG, "Address out of range - start: %" PRIu16 " num: %" PRIu16, start_address, count); return ExceptionCode::ILLEGAL_DATA_ADDRESS; } return std::nullopt; @@ -394,6 +396,11 @@ static constexpr size_t WRITE_SINGLE_VALUES_OFFSET = 2; static constexpr size_t WRITE_MULTIPLE_VALUES_OFFSET = 5; // FC 0x17 writes follow read start(2) + read quantity(2) + write start(2) + write quantity(2) + byte count(1). static constexpr size_t READ_WRITE_VALUES_OFFSET = 9; +// A coil write (FC 0x0F) is function(1) + start(2) + quantity(2) + byte count(1) + packed bits. The largest +// one (MAX_NUM_OF_COILS_TO_WRITE coils) must fit the received request PDU, so the value subspan taken at +// WRITE_MULTIPLE_VALUES_OFFSET can never run past it. +static_assert(1 + WRITE_MULTIPLE_VALUES_OFFSET + packed_bit_bytes(MAX_NUM_OF_COILS_TO_WRITE) <= MAX_PDU_SIZE, + "the largest FC 0x0F coil write must fit within MAX_PDU_SIZE"); ResponseStatus ModbusServerHub::parse_write_single_(std::span data, uint16_t &start_address, RegisterValues ®isters) { @@ -413,13 +420,59 @@ ResponseStatus ModbusServerHub::parse_write_multiple_(std::span d ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 " or bytes %" PRIu8, number_of_registers, number_of_bytes); return ExceptionCode::ILLEGAL_DATA_VALUE; } - if (ResponseStatus status = this->check_register_range_(start_address, number_of_registers); status.has_value()) { + if (ResponseStatus status = this->check_address_range_(start_address, number_of_registers); status.has_value()) { return status; } this->assemble_registers_(data.subspan(WRITE_MULTIPLE_VALUES_OFFSET, number_of_bytes), registers); return std::nullopt; } +ResponseStatus ModbusServerHub::parse_read_request_(std::span data, uint16_t max_entities, + const LogString *entity_name, uint16_t &start_address, + uint16_t &count) { + // Every read request is start address(2) + quantity(2); only the protocol ceiling differs per function + // code, so registers and coils/discrete inputs validate through here and cannot drift apart. + start_address = helpers::get_data(data.data(), 0); + count = helpers::get_data(data.data(), 2); + if (count == 0 || count > max_entities) { + ESP_LOGW(TAG, "Invalid number of %s %" PRIu16, LOG_STR_ARG(entity_name), count); + return ExceptionCode::ILLEGAL_DATA_VALUE; + } + return this->check_address_range_(start_address, count); +} + +ResponseStatus ModbusServerHub::parse_write_single_coil_(std::span data, uint16_t &start_address, + bool &value) { + start_address = helpers::get_data(data.data(), 0); + const uint16_t raw_value = helpers::get_data(data.data(), WRITE_SINGLE_VALUES_OFFSET); + if (raw_value != 0xFF00 && raw_value != 0x0000) { + ESP_LOGW(TAG, "Invalid coil value 0x%04X", raw_value); + return ExceptionCode::ILLEGAL_DATA_VALUE; + } + // No range check needed: one coil can never push start_address + 1 past the address space. + value = raw_value == 0xFF00; + return std::nullopt; +} + +ResponseStatus ModbusServerHub::parse_write_multiple_coils_(std::span data, uint16_t &start_address, + uint16_t &count, std::span &packed_bytes) { + start_address = helpers::get_data(data.data(), 0); + const uint16_t number_of_bits = helpers::get_data(data.data(), 2); + const uint8_t number_of_bytes = helpers::get_data(data.data(), 4); + if (number_of_bits == 0 || number_of_bits > MAX_NUM_OF_COILS_TO_WRITE || + packed_bit_bytes(number_of_bits) != number_of_bytes) { + ESP_LOGW(TAG, "Invalid number of coils %" PRIu16 " or bytes %" PRIu8, number_of_bits, number_of_bytes); + return ExceptionCode::ILLEGAL_DATA_VALUE; + } + if (ResponseStatus status = this->check_address_range_(start_address, number_of_bits); status.has_value()) { + return status; + } + count = number_of_bits; + // coil values follow start(2) + quantity(2) + byte count(1) + packed_bytes = data.subspan(WRITE_MULTIPLE_VALUES_OFFSET, number_of_bytes); + return std::nullopt; +} + void ModbusServerHub::assemble_registers_(std::span values, RegisterValues ®isters) { for (size_t offset = 0; offset + 1 < values.size(); offset += 2) { registers.push_back(helpers::get_data(values.data(), offset)); @@ -427,11 +480,16 @@ void ModbusServerHub::assemble_registers_(std::span values, Regis } void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span data) { - // Broadcasts are only meaningful for register writes and are never answered (Modbus 4.1 / 6.12), so an - // unsupported function code or a validation failure is silently dropped instead of replying with an exception. - // Coil writes (FC 0x05/0x0F) are also broadcastable by spec, but server coil handlers are not implemented yet. + // Broadcasts are only meaningful for writes and are never answered (Modbus 4.1 / 6.12), so an unsupported + // function code or a validation failure is silently dropped instead of replying with an exception. Both + // register writes (FC 0x06/0x10) and coil writes (FC 0x05/0x0F) are broadcastable by spec, and each shares + // its parser with the addressed path so a broadcast is validated exactly as the unicast form would be. uint16_t start_address; RegisterValues registers; + uint16_t coil_count = 0; + std::span packed_bytes; + uint8_t single_bit = 0; // backs packed_bytes for a single-coil write, so it must outlive the loop below + bool coils = false; ResponseStatus status; switch (static_cast(function_code)) { case FunctionCode::WRITE_SINGLE_REGISTER: @@ -440,6 +498,19 @@ void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span< case FunctionCode::WRITE_MULTIPLE_REGISTERS: status = this->parse_write_multiple_(data, start_address, registers); break; + case FunctionCode::WRITE_SINGLE_COIL: { + coils = true; + bool value = false; + status = this->parse_write_single_coil_(data, start_address, value); + single_bit = value ? 0x01 : 0x00; + coil_count = 1; + packed_bytes = std::span(&single_bit, 1); + break; + } + case FunctionCode::WRITE_MULTIPLE_COILS: + coils = true; + status = this->parse_write_multiple_coils_(data, start_address, coil_count, packed_bytes); + break; default: // Reads and read/write require a reply, so they are not valid as broadcasts. ESP_LOGV(TAG, "Ignoring broadcast with unsupported function code %" PRIu8, function_code); @@ -452,8 +523,12 @@ void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span< // per-device outcome at V, and warn if the write reached nobody at all. bool accepted = false; for (auto *device : this->devices_) { - if (ResponseStatus device_status = device->on_broadcast_write_registers(start_address, registers); - device_status.has_value()) { + // Same handlers as an addressed write - a device cannot tell a broadcast apart, and does not need + // to: the hub owns the difference, which is only that no reply is ever sent. + const ResponseStatus device_status = + coils ? device->on_write_coils(start_address, PackedBits(packed_bytes, coil_count)) + : device->on_write_registers(start_address, registers); + if (device_status.has_value()) { ESP_LOGV(TAG, "Device %" PRIu8 " rejected broadcast write with exception %" PRIu8, device->get_address(), static_cast(device_status.value())); } else { @@ -461,15 +536,19 @@ void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span< } } if (!accepted && !this->devices_.empty()) { + const uint16_t entity_count = coils ? coil_count : static_cast(registers.size()); + const LogString *const entity_name = coils ? LOG_STR("coils") : LOG_STR("registers"); // Warn at most once per interval, then drop to VERBOSE: on a shared bus a broadcast aimed at other nodes // repeats forever, so warning per frame would flood the log. const uint32_t now = millis(); if (this->last_unaccepted_broadcast_warn_ == 0 || now - this->last_unaccepted_broadcast_warn_ > UNACCEPTED_BROADCAST_WARN_INTERVAL_MS) { this->last_unaccepted_broadcast_warn_ = now; - ESP_LOGW(TAG, "No device accepted broadcast write of %zu registers at 0x%04X", registers.size(), start_address); + ESP_LOGW(TAG, "No device accepted broadcast write of %" PRIu16 " %s at 0x%04X", entity_count, + LOG_STR_ARG(entity_name), start_address); } else { - ESP_LOGV(TAG, "No device accepted broadcast write of %zu registers at 0x%04X", registers.size(), start_address); + ESP_LOGV(TAG, "No device accepted broadcast write of %" PRIu16 " %s at 0x%04X", entity_count, + LOG_STR_ARG(entity_name), start_address); } } } @@ -479,8 +558,7 @@ bool ModbusServerHub::build_or_reject_read_response_(uint8_t address, uint8_t fu std::span response_buffer, uint16_t &response_len) { // A handler that returns an exception leaves registers partially filled, so check the exception // first and forward it before validating the register count on the success path. - if (status.has_value()) { - this->send_exception_(address, function_code, status.value()); + if (this->rejected_(address, function_code, status)) { return false; } @@ -535,17 +613,11 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func switch (static_cast(function_code)) { case FunctionCode::READ_HOLDING_REGISTERS: case FunctionCode::READ_INPUT_REGISTERS: { - // PDU data: start address(2) + quantity(2). - uint16_t start_address = helpers::get_data(data.data(), 0); - uint16_t number_of_registers = helpers::get_data(data.data(), 2); - if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_READ) { - ESP_LOGW(TAG, "Invalid number of registers %" PRIu16, number_of_registers); - this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - status = this->check_register_range_(start_address, number_of_registers); - if (status.has_value()) { - this->send_exception_(address, function_code, status.value()); + uint16_t start_address; + uint16_t number_of_registers; + status = this->parse_read_request_(data, MAX_NUM_OF_REGISTERS_TO_READ, LOG_STR("registers"), start_address, + number_of_registers); + if (this->rejected_(address, function_code, status)) { return; } RegisterValues registers; @@ -571,8 +643,7 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func } else { status = this->parse_write_multiple_(data, start_address, registers); } - if (status.has_value()) { - this->send_exception_(address, function_code, status.value()); + if (this->rejected_(address, function_code, status)) { return; } status = device->on_write_registers(start_address, registers); @@ -580,6 +651,64 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func response_len = 4; break; } + case FunctionCode::READ_COILS: + case FunctionCode::READ_DISCRETE_INPUTS: { + uint16_t start_address; + uint16_t number_of_bits; + status = + this->parse_read_request_(data, MAX_NUM_OF_COILS_TO_READ, LOG_STR("bits"), start_address, number_of_bits); + if (this->rejected_(address, function_code, status)) { + return; + } + // Response: byte count(1) + packed bytes, written straight into the pre-zeroed response buffer. It + // always fits: the parse above caps the count, and a static_assert bounds that against MAX_RAW_SIZE. + const uint8_t byte_count = static_cast(packed_bit_bytes(number_of_bits)); + response_buffer[response_len++] = byte_count; + // Take the packed-bytes span off a span that knows response_buffer's real size, so a future non-zero + // response_len (e.g. a prefix written before the packed data) is a bounds error, not a silent overrun. + std::span packed_out = std::span(response_buffer).subspan(response_len, byte_count); + std::fill(packed_out.begin(), packed_out.end(), 0); + MutablePackedBits bits(packed_out, number_of_bits); + if (static_cast(function_code) == FunctionCode::READ_COILS) { + status = device->on_read_coils(start_address, bits); + } else { + status = device->on_read_discrete_inputs(start_address, bits); + } + if (this->rejected_(address, function_code, status)) { + return; + } + response_len += byte_count; + break; + } + case FunctionCode::WRITE_SINGLE_COIL: { + // A single coil is handed to the device as a one-bit packed view, the same form a multiple-coil + // write takes, so a device only ever implements one coil write handler. + uint16_t start_address; + bool value = false; + status = this->parse_write_single_coil_(data, start_address, value); + if (this->rejected_(address, function_code, status)) { + return; + } + const uint8_t single_bit = value ? 0x01 : 0x00; + status = device->on_write_coils(start_address, PackedBits(std::span(&single_bit, 1), 1)); + response_data = data.data(); // echo the request header per Modbus 6.5, 6.11 + response_len = 4; + break; + } + case FunctionCode::WRITE_MULTIPLE_COILS: { + // Parse and validate the coil write PDU into a packed-bit view; reply with an exception on failure. + uint16_t start_address; + uint16_t count; + std::span packed_bytes; + status = this->parse_write_multiple_coils_(data, start_address, count, packed_bytes); + if (this->rejected_(address, function_code, status)) { + return; + } + status = device->on_write_coils(start_address, PackedBits(packed_bytes, count)); + response_data = data.data(); // echo the request header per Modbus 6.5, 6.11 + response_len = 4; + break; + } case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: { // PDU data: read start address(2) + read quantity(2) + write start address(2) + write quantity(2) + // write byte count(1) + write register values. Per Modbus 6.17 the write is performed before the read. @@ -596,12 +725,11 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_VALUE); return; } - status = this->check_register_range_(read_start_address, number_of_registers); + status = this->check_address_range_(read_start_address, number_of_registers); if (!status.has_value()) { - status = this->check_register_range_(write_start_address, number_of_write_registers); + status = this->check_address_range_(write_start_address, number_of_write_registers); } - if (status.has_value()) { - this->send_exception_(address, function_code, status.value()); + if (this->rejected_(address, function_code, status)) { return; } // Perform the write first (Modbus 6.17). Scoped so the write values are off the stack before the read @@ -614,8 +742,7 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func // from the values it just stored. status = device->on_write_registers(write_start_address, write_registers); } - if (status.has_value()) { - this->send_exception_(address, function_code, status.value()); + if (this->rejected_(address, function_code, status)) { return; } RegisterValues registers; @@ -632,9 +759,7 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_FUNCTION); return; } - if (status.has_value()) { - this->send_exception_(address, function_code, status.value()); - } else { + if (!this->rejected_(address, function_code, status)) { this->send_response_(address, function_code, response_data, response_len); } } @@ -733,6 +858,19 @@ void ModbusServerHub::send_response_(uint8_t address, uint8_t function_code, con this->send_raw_(raw_frame, payload_len + 2); } +bool ModbusServerHub::rejected_(uint8_t address, uint8_t function_code, ResponseStatus status) { + if (!status.has_value()) + return false; + // The one place a rejection becomes an exception reply, so the log carries the transaction context a + // device handler never has: which client-facing address and function code drew which exception. DEBUG + // rather than WARN because an exception reply is a normal protocol outcome and arrives per frame - a + // probing or broken client would otherwise flood the log. The parse helpers still WARN with specifics. + ESP_LOGD(TAG, "Exception %" PRIu8 " replied to function 0x%02X for address %" PRIu8, + static_cast(status.value()), function_code, address); + this->send_exception_(address, function_code, status.value()); + return true; +} + void ModbusServerHub::send_exception_(uint8_t address, uint8_t function_code, ExceptionCode exception_code) { uint8_t raw_frame[3]; raw_frame[0] = address; diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 3b6028e90a..6331f23f99 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -361,9 +361,27 @@ class ModbusServerHub : public Modbus { // Appends the big-endian register values in values to registers, in host byte order. void assemble_registers_(std::span values, RegisterValues ®isters); ModbusServerDevice *find_device_(uint8_t address); - // Returns std::nullopt if [start_address, start_address + number_of_registers) fits in the 16-bit address space, - // otherwise ILLEGAL_DATA_ADDRESS. The caller sends the exception reply if one is required. - ResponseStatus check_register_range_(uint16_t start_address, uint16_t number_of_registers); + // Returns std::nullopt if [start_address, start_address + count) fits in the 16-bit address space, + // otherwise ILLEGAL_DATA_ADDRESS. The caller sends the exception reply if one is required - a broadcast + // write is never answered, so the check cannot send it itself. Shared by the register and + // coil/discrete-input handlers, which all address the same 16-bit space. + ResponseStatus check_address_range_(uint16_t start_address, uint16_t count); + + // Parses a read request PDU (start address(2) + quantity(2)), shared by the register and + // coil/discrete-input reads so the two cannot drift apart. max_entities is the protocol ceiling for the + // function code; entity_name only labels the rejection log. + ResponseStatus parse_read_request_(std::span data, uint16_t max_entities, const LogString *entity_name, + uint16_t &start_address, uint16_t &count); + + // Parses a single-coil write PDU (FC 0x05), which carries a 2-byte on/off value rather than packed + // bytes. The caller packs value into a byte it owns to build the PackedBits view the handlers take. + ResponseStatus parse_write_single_coil_(std::span data, uint16_t &start_address, bool &value); + + // Parses a multiple-coil write PDU (FC 0x0F) into a packed-bit view pointing straight into the receive + // buffer, so the coil values are never copied. Both coil parsers are shared by the addressed and + // broadcast paths so the two validate identically. + ResponseStatus parse_write_multiple_coils_(std::span data, uint16_t &start_address, uint16_t &count, + std::span &packed_bytes); // Builds the body of a register read response (byte count followed by the big-endian register values) into // response_buffer. Shared by every function code that answers with register values, so the read reply stays @@ -374,6 +392,9 @@ class ModbusServerHub : public Modbus { uint16_t number_of_registers, const RegisterValues ®isters, std::span response_buffer, uint16_t &response_len); void send_raw_(const uint8_t *payload, uint16_t len); + // Sends and logs the exception reply when status holds one; returns true if the request was rejected. + // Every parse and handler rejection funnels through here, so the reply and its log cannot drift apart. + bool rejected_(uint8_t address, uint8_t function_code, ResponseStatus status); void send_exception_(uint8_t address, uint8_t function_code, ExceptionCode exception_code); void send_response_(uint8_t address, uint8_t function_code, const uint8_t *payload, uint16_t payload_len); uint8_t expecting_peer_response_{0}; @@ -644,18 +665,26 @@ class ModbusServerDevice { virtual ResponseStatus on_write_registers(uint16_t start_address, const RegisterValues ®isters) { return ExceptionCode::ILLEGAL_FUNCTION; }; - // Hub entry point for broadcast (address 0) writes, which are never answered. - ResponseStatus on_broadcast_write_registers(uint16_t start_address, const RegisterValues ®isters) { - this->broadcast_write_ = true; - ResponseStatus status = this->on_write_registers(start_address, registers); - this->broadcast_write_ = false; - return status; - } + /// Coil/discrete-input reads: set the requested bits (bit 0 = the coil at start_address) with + /// bits.set(). The view covers bits.size() pre-zeroed bits and writes land directly in the hub's + /// response buffer (no copy); it is only valid during the call. + virtual ResponseStatus on_read_bits(uint16_t start_address, MutablePackedBits bits) { + return ExceptionCode::ILLEGAL_FUNCTION; + }; + virtual ResponseStatus on_read_coils(uint16_t start_address, MutablePackedBits bits) { + return this->on_read_bits(start_address, bits); + }; + virtual ResponseStatus on_read_discrete_inputs(uint16_t start_address, MutablePackedBits bits) { + return this->on_read_bits(start_address, bits); + }; + /// Coil writes deliver the values as a PackedBits view over the hub's receive buffer (only valid + /// during the call). A single-coil write (FC 0x05) arrives as bits.size() == 1. + virtual ResponseStatus on_write_coils(uint16_t start_address, PackedBits bits) { + return ExceptionCode::ILLEGAL_FUNCTION; + }; protected: uint8_t address_{0}; - // Set while handling a broadcast write: the caller sends no reply, so a rejection has no wire consequence. - bool broadcast_write_{false}; }; } // namespace esphome::modbus diff --git a/esphome/components/modbus/modbus_definitions.h b/esphome/components/modbus/modbus_definitions.h index 9ec776b67a..64f7210585 100644 --- a/esphome/components/modbus/modbus_definitions.h +++ b/esphome/components/modbus/modbus_definitions.h @@ -128,6 +128,19 @@ static_assert(MAX_RAW_SIZE + 2 == MAX_FRAME_SIZE, "a framed raw server payload m /// Bits pack 8 per data byte, rounded up to whole bytes. constexpr size_t packed_bit_bytes(size_t bits) { return (bits + 7) / 8; } +// A coil/discrete-input read answers with byte count(1) + packed_bit_bytes(count) bytes, which has to fit +// the raw frame body. The runtime check on that path catches a caller entering with bytes already written; +// this catches the other way in, raising the ceiling past what a frame can carry. +static_assert(1 + packed_bit_bytes(MAX_NUM_OF_COILS_TO_READ) <= MAX_RAW_SIZE, + "MAX_NUM_OF_COILS_TO_READ yields a read response larger than MAX_RAW_SIZE"); +static_assert(1 + packed_bit_bytes(MAX_NUM_OF_DISCRETE_INPUTS_TO_READ) <= MAX_RAW_SIZE, + "MAX_NUM_OF_DISCRETE_INPUTS_TO_READ yields a read response larger than MAX_RAW_SIZE"); + +// The coil and discrete-input ceilings are separate limits in the spec but hold the same value, so the +// read paths validate both against MAX_NUM_OF_COILS_TO_READ. Should the spec ever split them, this fires. +static_assert(MAX_NUM_OF_COILS_TO_READ == MAX_NUM_OF_DISCRETE_INPUTS_TO_READ, + "the coil and discrete-input read ceilings must match"); + /** Read-only view of Modbus-packed bits: bit 0 of byte 0 is the first bit (LSB first), the layout * coil/discrete-input values use on the wire. Bundles the bit count with the packed bytes so the * two cannot desynchronize. The view does not own the bytes - it is only valid while they are. diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index a0c8440c79..4287256101 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -69,8 +69,10 @@ uint16_t client_pdu_length(const uint8_t *frame, size_t size) { case FunctionCode::WRITE_SINGLE_REGISTER: return 5; // function(1) + output/register address(2) + value(2) case FunctionCode::WRITE_MULTIPLE_COILS: + // function(1) + start address(2) + quantity(2) + byte count(1) + packed coil data (8 coils per byte). + return 6 + (size > 5 ? std::min(frame[5], uint8_t(packed_bit_bytes(MAX_NUM_OF_COILS_TO_WRITE))) : 0); case FunctionCode::WRITE_MULTIPLE_REGISTERS: - // function(1) + start address(2) + quantity(2) + byte count(1) + data + // function(1) + start address(2) + quantity(2) + byte count(1) + register data (2 bytes per register). return 6 + (size > 5 ? std::min(frame[5], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE * 2)) : 0); // Unsupported function codes. Included here to prevent parser failures. Excluding Serial Line specific functions. case FunctionCode::READ_FILE_RECORD: @@ -546,12 +548,7 @@ static PduBuffer create_write_coils_pdu_from_bools(uint16_t start_address, const return pdu; } CoilPackBuffer packed; - for (size_t i = 0; i != count; i++) { - if (i % 8 == 0) - packed.push_back(0); - if (values[i]) - packed[i / 8] |= (1 << (i % 8)); - } + pack_bits(packed, values); build_write_coils_pdu(pdu, start_address, PackedBits(std::span(packed.data(), packed.size()), count)); return pdu; } diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index 2c312b8a61..e47a6835cd 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -257,6 +257,29 @@ inline bool bit_from_packed(int bit, std::span data) { ESPDEPRECATED("Use bit_from_packed() instead. Removed in 2027.2.0", "2026.8.0") inline bool coil_from_vector(int coil, std::span data) { return bit_from_packed(coil, data); } +/** Append packed bytes (LSB first) for the given bits onto a growable byte container. + * push_back-based so callers can build a payload incrementally (e.g. a std::vector + * with no fixed upper bound). A non-byte-aligned count appends n+1 bytes, the last holding + * the remaining bits in its low positions. + * @param out destination byte container exposing push_back(uint8_t) + * @param bits container of bool exposing range-based iteration + */ +template void pack_bits(Out &out, const Bits &bits) { + uint8_t byte = 0; + uint8_t bit = 0; + for (bool b : bits) { + if (b) + byte |= (1 << bit); + if (++bit == 8) { + out.push_back(byte); + byte = 0; + bit = 0; + } + } + if (bit != 0) // flush the final partial byte + out.push_back(byte); +} + /** Extract bits from value and shift right according to the bitmask * if the bitmask is 0x00F0 we want the values frrom bit 5 - 8. * the result is then shifted right by the position if the first right set bit in the mask diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index da9d29887e..35f21fd0af 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -2,6 +2,8 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" +#include + namespace esphome::modbus_controller { static const char *const TAG = "modbus_controller"; @@ -427,14 +429,15 @@ ModbusCommandItem ModbusCommandItem::create_write_multiple_coils(ModbusControlle modbusdevice->on_write_register_response(register_type, start_address, data); }; - uint8_t *p = cmd.payload.init((values.size() + 7) / 8); - memset(p, 0, (values.size() + 7) / 8); - size_t bit = 0; - for (auto coil : values) { - if (coil) { - p[bit / 8] |= (1 << (bit % 8)); - } - bit++; + // Pack through the shared bit view (MutablePackedBits) so the coil wire layout lives in one place + // instead of an open-coded loop. + const size_t byte_count = modbus::packed_bit_bytes(values.size()); + uint8_t *p = cmd.payload.init(byte_count); + memset(p, 0, byte_count); + modbus::MutablePackedBits bits(std::span(p, byte_count), static_cast(values.size())); + for (size_t i = 0; i != values.size(); i++) { + if (values[i]) + bits.set(i, true); } return cmd; } diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp index bf39efbd54..e63495cb25 100644 --- a/esphome/components/modbus_server/modbus_server.cpp +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -145,12 +145,9 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address, } return true; })) { - // On a broadcast every device that does not map these registers rejects them, which is the normal case. - if (this->broadcast_write_) { - ESP_LOGV(TAG, "Write request rejected before applying any register."); - } else { - ESP_LOGW(TAG, "Write request rejected before applying any register."); - } + // Only VERBOSE: one handler serves both addressed and broadcast writes, and rejecting a broadcast for + // registers this device does not map is routine. The hub logs the outcome with the context it has. + ESP_LOGV(TAG, "Write request rejected before applying any register."); return precheck; } diff --git a/tests/components/modbus/common.h b/tests/components/modbus/common.h index 659b72014c..d03ccf8ec3 100644 --- a/tests/components/modbus/common.h +++ b/tests/components/modbus/common.h @@ -1,5 +1,6 @@ #pragma once #include +#include #include "esphome/components/uart/uart_component.h" namespace esphome::modbus::testing { @@ -19,4 +20,14 @@ class NullUART : public uart::UARTComponent { void check_logger_conflict() override {} }; +// A UART that records every byte written so tests can assert on the exact wire response. +class RecordingUART : public NullUART { + public: + void write_array(const uint8_t *data, size_t len) override { + this->written.insert(this->written.end(), data, data + len); + } + + std::vector written; +}; + } // namespace esphome::modbus::testing diff --git a/tests/components/modbus/modbus_broadcast_test.cpp b/tests/components/modbus/modbus_broadcast_test.cpp index 5840259021..6f088f4888 100644 --- a/tests/components/modbus/modbus_broadcast_test.cpp +++ b/tests/components/modbus/modbus_broadcast_test.cpp @@ -28,6 +28,27 @@ class RecordingDevice : public ModbusServerDevice { std::vector last_values; }; +// A server device that records the coil writes the hub routes to it. Coils arrive as a PackedBits view +// over the hub's buffers, so the bits are copied out here rather than the view retained. +class RecordingCoilDevice : public ModbusServerDevice { + public: + explicit RecordingCoilDevice(uint8_t address) { this->set_address(address); } + + ResponseStatus on_write_coils(uint16_t start_address, PackedBits bits) override { + this->write_count++; + this->last_start_address = start_address; + this->last_bits.clear(); + for (uint16_t i = 0; i != bits.size(); i++) { + this->last_bits.push_back(bits[i]); + } + return std::nullopt; // return value is ignored for broadcasts, which are never answered + } + + int write_count{0}; + uint16_t last_start_address{0}; + std::vector last_bits; +}; + // A server device that rejects every write, to exercise the broadcast dispatch loop's rejection branch. class RejectingDevice : public ModbusServerDevice { public: @@ -41,15 +62,6 @@ class RejectingDevice : public ModbusServerDevice { int write_count{0}; }; -// A UART that records every byte written so the test can assert the hub sends no reply. -class RecordingUART : public testing::NullUART { - public: - void write_array(const uint8_t *data, size_t len) override { - this->written.insert(this->written.end(), data, data + len); - } - std::vector written; -}; - // Drives full frames through the server hub's receive path in tests. class TestServerHub : public ModbusServerHub { public: @@ -75,6 +87,8 @@ class TestServerHub : public ModbusServerHub { } // namespace +using testing::RecordingUART; + // A broadcast (address 0) single-register write reaches every registered device and is not answered. // Driven through the full receive parser (parse_modbus_frames) so the address-0 routing -- frame length, // CRC, and client-vs-broadcast dispatch -- is exercised, not just the handler below it. @@ -273,4 +287,83 @@ TEST(ModbusBroadcast, UnicastOutOfRangeWriteSendsSingleExceptionFrame) { EXPECT_EQ(uart.written[2], static_cast(ExceptionCode::ILLEGAL_DATA_ADDRESS)); } +// A broadcast single-coil write (FC 0x05) reaches every device and is not answered. The 2-byte ON value +// is normalized to a one-bit view, so the handler sees the same shape as a multiple-coil write of one. +TEST(ModbusBroadcast, SingleCoilWriteReachesAllDevicesWithoutReply) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RecordingCoilDevice device_a(0x02); + RecordingCoilDevice device_b(0x03); + hub.register_device(&device_a); + hub.register_device(&device_b); + + // FC 0x05 payload: coil 0x00AC, value 0xFF00 (ON). + const uint8_t pdu_data[] = {0x00, 0xAC, 0xFF, 0x00}; + ASSERT_TRUE(hub.run_receive_parser_for_test(BROADCAST_ADDRESS, static_cast(FunctionCode::WRITE_SINGLE_COIL), + pdu_data, sizeof(pdu_data))); + + for (RecordingCoilDevice *device : {&device_a, &device_b}) { + EXPECT_EQ(device->write_count, 1); + EXPECT_EQ(device->last_start_address, 0x00AC); + ASSERT_EQ(device->last_bits.size(), 1u); + EXPECT_TRUE(device->last_bits[0]); + } + EXPECT_TRUE(uart.written.empty()); // broadcasts are never answered +} + +// A broadcast multiple-coil write (FC 0x0F) delivers the packed bits to every device, LSB first. +TEST(ModbusBroadcast, MultipleCoilWriteReachesAllDevicesWithoutReply) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RecordingCoilDevice device_a(0x02); + RecordingCoilDevice device_b(0x03); + hub.register_device(&device_a); + hub.register_device(&device_b); + + // FC 0x0F payload: start 0x0013, 10 coils, 2 bytes, 0xCD 0x01 -> bit 0 set, bit 8 set. + const uint8_t pdu_data[] = {0x00, 0x13, 0x00, 0x0A, 0x02, 0xCD, 0x01}; + ASSERT_TRUE(hub.run_receive_parser_for_test( + BROADCAST_ADDRESS, static_cast(FunctionCode::WRITE_MULTIPLE_COILS), pdu_data, sizeof(pdu_data))); + + for (RecordingCoilDevice *device : {&device_a, &device_b}) { + EXPECT_EQ(device->write_count, 1); + EXPECT_EQ(device->last_start_address, 0x0013); + ASSERT_EQ(device->last_bits.size(), 10u); + EXPECT_TRUE(device->last_bits[0]); // 0xCD bit 0 + EXPECT_FALSE(device->last_bits[1]); // 0xCD bit 1 + EXPECT_TRUE(device->last_bits[8]); // 0x01 bit 0 + EXPECT_FALSE(device->last_bits[9]); // padding bit + } + EXPECT_TRUE(uart.written.empty()); +} + +// A coil broadcast that fails validation is dropped exactly like a bad register broadcast: no handler +// call and, because broadcasts are never answered, no exception frame either. +TEST(ModbusBroadcast, InvalidCoilBroadcastProducesNoWriteAndNoReply) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RecordingCoilDevice device(0x02); + hub.register_device(&device); + + // Byte count disagrees with the coil quantity: 10 coils need 2 bytes, not 1. + const uint8_t bad_count[] = {0x00, 0x13, 0x00, 0x0A, 0x01, 0xCD}; + ASSERT_TRUE(hub.run_receive_parser_for_test( + BROADCAST_ADDRESS, static_cast(FunctionCode::WRITE_MULTIPLE_COILS), bad_count, sizeof(bad_count))); + EXPECT_EQ(device.write_count, 0); + + // A single-coil value must be 0x0000 or 0xFF00; anything else is out of spec. + const uint8_t bad_value[] = {0x00, 0xAC, 0x12, 0x34}; + ASSERT_TRUE(hub.run_receive_parser_for_test(BROADCAST_ADDRESS, static_cast(FunctionCode::WRITE_SINGLE_COIL), + bad_value, sizeof(bad_value))); + EXPECT_EQ(device.write_count, 0); + + EXPECT_TRUE(uart.written.empty()); +} + } // namespace esphome::modbus diff --git a/tests/components/modbus/modbus_helpers_test.cpp b/tests/components/modbus/modbus_helpers_test.cpp index 768c23c33c..6a65c3bf68 100644 --- a/tests/components/modbus/modbus_helpers_test.cpp +++ b/tests/components/modbus/modbus_helpers_test.cpp @@ -426,6 +426,20 @@ TEST(ModbusHelpersTest, RegistersToNumberRejectsTruncatedMultiRegisterValue) { EXPECT_FALSE(registers_to_number(registers, 1, SensorValueType::U_DWORD).has_value()); } +// --- packed bit helpers ------------------------------------------------------ + +TEST(ModbusHelpersTest, PackBitsAppendsToContainer) { + // Bits are packed LSB first: the first value is bit 0 of the first byte, and the push_back + // overload appends packed bytes onto a growable container preserving existing content. + std::vector bits{true, false, true, true, false, false, false, false, true, true}; + std::vector out{0x55}; // pre-existing content must be preserved + pack_bits(out, bits); + ASSERT_EQ(out.size(), 3u); // leading byte + 2 packed bytes (10 bits) + EXPECT_EQ(out[0], 0x55); + EXPECT_EQ(out[1], 0x0D); // 0b00001101 + EXPECT_EQ(out[2], 0x03); // bits 8 and 9 -> bits 0,1 of second byte +} + // --- typed builders ---------------------------------------------------------- TEST(ModbusTypedBuilders, ReadPduWireBytes) { diff --git a/tests/components/modbus/modbus_server_coils_test.cpp b/tests/components/modbus/modbus_server_coils_test.cpp new file mode 100644 index 0000000000..e4bf3f6b14 --- /dev/null +++ b/tests/components/modbus/modbus_server_coils_test.cpp @@ -0,0 +1,398 @@ +#include + +#include +#include +#include + +#include "common.h" +#include "esphome/components/modbus/modbus.h" +#include "esphome/core/hal.h" + +namespace esphome::modbus { + +namespace { + +// A server device backed by a small coil array: reads deliver the stored bits, writes apply them. +class CoilDevice : public ModbusServerDevice { + public: + explicit CoilDevice(uint8_t address) { this->set_address(address); } + + ResponseStatus on_read_coils(uint16_t start_address, MutablePackedBits bits) override { + this->read_count++; + for (uint16_t i = 0; i < bits.size(); i++) + bits.set(i, this->coils[start_address + i]); + return std::nullopt; + } + + ResponseStatus on_write_coils(uint16_t start_address, PackedBits bits) override { + this->write_count++; + this->last_write_count = bits.size(); + for (uint16_t i = 0; i < bits.size(); i++) + this->coils[start_address + i] = bits[i]; + return std::nullopt; + } + + bool coils[32] = {}; + int read_count{0}; + int write_count{0}; + uint16_t last_write_count{0}; +}; + +// A device with no bit handlers, to exercise the ILLEGAL_FUNCTION defaults. +class NoBitsDevice : public ModbusServerDevice { + public: + explicit NoBitsDevice(uint8_t address) { this->set_address(address); } +}; + +// Distinguishes the two bit-read entry points: each fills a different pattern and counts its calls, so a +// test can prove FC 0x01 vs 0x02 dispatch routes to the right handler (and not merely that bits came back). +class DualReadDevice : public ModbusServerDevice { + public: + explicit DualReadDevice(uint8_t address) { this->set_address(address); } + + ResponseStatus on_read_coils(uint16_t start_address, MutablePackedBits bits) override { + this->coil_reads++; + bits.set(0, true); // pattern 0x01 + return std::nullopt; + } + ResponseStatus on_read_discrete_inputs(uint16_t start_address, MutablePackedBits bits) override { + this->discrete_reads++; + bits.set(1, true); // pattern 0x02 + return std::nullopt; + } + + int coil_reads{0}; + int discrete_reads{0}; +}; + +// Overrides only on_read_bits() - the shared fallback the header documents that on_read_coils() and +// on_read_discrete_inputs() default to. Both FC 0x01 and FC 0x02 must reach it. +class BitsOnlyDevice : public ModbusServerDevice { + public: + explicit BitsOnlyDevice(uint8_t address) { this->set_address(address); } + ResponseStatus on_read_bits(uint16_t start_address, MutablePackedBits bits) override { + this->calls++; + bits.set(0, true); // set bit 0 so the response proves the fallback ran + return std::nullopt; + } + int calls{0}; +}; + +using testing::RecordingUART; + +// Exposes the client-frame parser so a fully CRC-framed request can be pushed through the hub. +class TestServerHub : public ModbusServerHub { + public: + bool tx_blocked() override { return false; } + + void prime_send_timestamps_for_test() { + uint32_t now = millis(); + this->last_modbus_byte_ = now; + this->last_send_ = now; + } + + bool process_full_client_frame_for_test(uint8_t address, uint8_t function_code, const uint8_t *pdu_data, + size_t pdu_data_len) { + this->rx_buffer_.clear(); + this->rx_buffer_.reserve(pdu_data_len + 4); + this->rx_buffer_.push_back(address); + this->rx_buffer_.push_back(function_code); + this->rx_buffer_.insert(this->rx_buffer_.end(), pdu_data, pdu_data + pdu_data_len); + uint16_t crc = crc16(this->rx_buffer_.data(), this->rx_buffer_.size()); + this->rx_buffer_.push_back(crc & 0xFF); + this->rx_buffer_.push_back(crc >> 8); + return this->parse_modbus_client_frame_(); + } +}; + +struct CoilFixture { + CoilFixture() { + hub.set_uart_parent(&uart); + hub.prime_send_timestamps_for_test(); + hub.register_device(&device); + } + TestServerHub hub; + RecordingUART uart; + CoilDevice device{0x02}; +}; + +} // namespace + +// A coil read returns byte count + packed bits, set by the handler directly in the response buffer. +TEST(ModbusServerCoils, ReadCoilsReturnsPackedBits) { + CoilFixture f; + f.device.coils[0] = true; + f.device.coils[2] = true; + f.device.coils[3] = true; + f.device.coils[9] = true; + + // FC 0x01: start 0x0000, quantity 10 -> 2 packed bytes + const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x0A}; + ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::READ_COILS), pdu_data, + sizeof(pdu_data))); + + EXPECT_EQ(f.device.read_count, 1); + // Response: address(1) + fc(1) + byte count(1) + packed(2) + CRC(2) + ASSERT_EQ(f.uart.written.size(), 7u); + EXPECT_EQ(f.uart.written[0], 0x02); + EXPECT_EQ(f.uart.written[1], static_cast(FunctionCode::READ_COILS)); + EXPECT_EQ(f.uart.written[2], 2u); // byte count + EXPECT_EQ(f.uart.written[3], 0x0D); // coils 0,2,3 + EXPECT_EQ(f.uart.written[4], 0x02); // coil 9 -> bit 1 of byte 1 +} + +// A device overriding only on_read_bits() - the documented fallback - still serves both FC 0x01 (coils) +// and FC 0x02 (discrete inputs), since on_read_coils()/on_read_discrete_inputs() default to it. +TEST(ModbusServerCoils, ReadBitsFallbackServesBothCoilsAndDiscreteInputs) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + hub.prime_send_timestamps_for_test(); + BitsOnlyDevice device{0x05}; + hub.register_device(&device); + + const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x01}; // start 0x0000, quantity 1 + + ASSERT_TRUE(hub.process_full_client_frame_for_test(0x05, static_cast(FunctionCode::READ_COILS), pdu_data, + sizeof(pdu_data))); + EXPECT_EQ(device.calls, 1); + // address(1) + fc(1) + byte count(1) + packed(1) + CRC(2); bit 0 set -> 0x01 + ASSERT_EQ(uart.written.size(), 6u); + EXPECT_EQ(uart.written[1], static_cast(FunctionCode::READ_COILS)); + EXPECT_EQ(uart.written[3], 0x01); + + uart.written.clear(); + ASSERT_TRUE(hub.process_full_client_frame_for_test(0x05, static_cast(FunctionCode::READ_DISCRETE_INPUTS), + pdu_data, sizeof(pdu_data))); + EXPECT_EQ(device.calls, 2); + ASSERT_EQ(uart.written.size(), 6u); + EXPECT_EQ(uart.written[1], static_cast(FunctionCode::READ_DISCRETE_INPUTS)); + EXPECT_EQ(uart.written[3], 0x01); +} + +// A multiple-coil write hands the handler the packed wire bytes and echoes the request header. +TEST(ModbusServerCoils, WriteMultipleCoilsAppliesPackedBits) { + CoilFixture f; + + // FC 0x0F: start 0x0000, quantity 10, byte count 2, packed values 0x0D 0x02 + const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x0A, 0x02, 0x0D, 0x02}; + ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::WRITE_MULTIPLE_COILS), + pdu_data, sizeof(pdu_data))); + + EXPECT_EQ(f.device.write_count, 1); + EXPECT_EQ(f.device.last_write_count, 10u); + EXPECT_TRUE(f.device.coils[0]); + EXPECT_FALSE(f.device.coils[1]); + EXPECT_TRUE(f.device.coils[2]); + EXPECT_TRUE(f.device.coils[3]); + EXPECT_TRUE(f.device.coils[9]); + EXPECT_FALSE(f.device.coils[10]); + // Response echoes start address + quantity: address(1) + fc(1) + start(2) + quantity(2) + CRC(2) + ASSERT_EQ(f.uart.written.size(), 8u); + EXPECT_EQ(f.uart.written[1], static_cast(FunctionCode::WRITE_MULTIPLE_COILS)); +} + +// A single-coil write (FC 0x05) is normalized to a one-bit packed buffer. +TEST(ModbusServerCoils, WriteSingleCoilNormalizedToOneBit) { + CoilFixture f; + + const uint8_t pdu_on[] = {0x00, 0x03, 0xFF, 0x00}; // coil 3 ON + ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::WRITE_SINGLE_COIL), + pdu_on, sizeof(pdu_on))); + EXPECT_EQ(f.device.last_write_count, 1u); + EXPECT_TRUE(f.device.coils[3]); + + f.uart.written.clear(); + f.hub.prime_send_timestamps_for_test(); + const uint8_t pdu_off[] = {0x00, 0x03, 0x00, 0x00}; // coil 3 OFF + ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::WRITE_SINGLE_COIL), + pdu_off, sizeof(pdu_off))); + EXPECT_FALSE(f.device.coils[3]); + EXPECT_EQ(f.device.write_count, 2); +} + +// An invalid single-coil value (not 0xFF00/0x0000) is rejected with ILLEGAL_DATA_VALUE, no write. +TEST(ModbusServerCoils, InvalidSingleCoilValueRejected) { + CoilFixture f; + + const uint8_t pdu_data[] = {0x00, 0x03, 0x12, 0x34}; + ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::WRITE_SINGLE_COIL), + pdu_data, sizeof(pdu_data))); + + EXPECT_EQ(f.device.write_count, 0); + ASSERT_EQ(f.uart.written.size(), 5u); // one exception frame + EXPECT_EQ(f.uart.written[1], static_cast(FunctionCode::WRITE_SINGLE_COIL) | 0x80); + EXPECT_EQ(f.uart.written[2], static_cast(ExceptionCode::ILLEGAL_DATA_VALUE)); +} + +// Read quantity validation lives in the shared read-request parser, so the register and bit reads cannot +// drift apart. These pin both ends of the range for coils; the register case below pins that the same +// parser is on that path too. +TEST(ModbusServerCoils, ZeroCoilReadQuantityRejected) { + CoilFixture f; + + // FC 0x01: start 0x0000, quantity 0 - a read of nothing is out of spec. + const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x00}; + ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::READ_COILS), pdu_data, + sizeof(pdu_data))); + + EXPECT_EQ(f.device.read_count, 0); + ASSERT_EQ(f.uart.written.size(), 5u); // one exception frame + EXPECT_EQ(f.uart.written[1], static_cast(FunctionCode::READ_COILS) | 0x80); + EXPECT_EQ(f.uart.written[2], static_cast(ExceptionCode::ILLEGAL_DATA_VALUE)); +} + +TEST(ModbusServerCoils, OverLimitCoilReadQuantityRejected) { + CoilFixture f; + + // One past MAX_NUM_OF_COILS_TO_READ (2000 = 0x07D0), which no frame could carry anyway. + const uint8_t pdu_data[] = {0x00, 0x00, 0x07, 0xD1}; + ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::READ_COILS), pdu_data, + sizeof(pdu_data))); + + EXPECT_EQ(f.device.read_count, 0); + ASSERT_EQ(f.uart.written.size(), 5u); + EXPECT_EQ(f.uart.written[2], static_cast(ExceptionCode::ILLEGAL_DATA_VALUE)); +} + +// The register read path shares that parser, so a zero quantity is rejected there identically. Lives +// beside the coil cases deliberately: together they are what stops the shared parser being bypassed on +// one side without the other noticing. +TEST(ModbusServerCoils, ZeroRegisterReadQuantityRejectedByTheSameParser) { + CoilFixture f; + + const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x00}; + ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::READ_HOLDING_REGISTERS), + pdu_data, sizeof(pdu_data))); + + ASSERT_EQ(f.uart.written.size(), 5u); + EXPECT_EQ(f.uart.written[1], static_cast(FunctionCode::READ_HOLDING_REGISTERS) | 0x80); + EXPECT_EQ(f.uart.written[2], static_cast(ExceptionCode::ILLEGAL_DATA_VALUE)); +} + +// A device without bit handlers rejects coil requests with ILLEGAL_FUNCTION via the defaults. +TEST(ModbusServerCoils, UnhandledCoilReadIsIllegalFunction) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + hub.prime_send_timestamps_for_test(); + NoBitsDevice device(0x02); + hub.register_device(&device); + + const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x08}; + ASSERT_TRUE(hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::READ_COILS), pdu_data, + sizeof(pdu_data))); + + ASSERT_EQ(uart.written.size(), 5u); + EXPECT_EQ(uart.written[1], static_cast(FunctionCode::READ_COILS) | 0x80); + EXPECT_EQ(uart.written[2], static_cast(ExceptionCode::ILLEGAL_FUNCTION)); +} + +// The view contracts are enforced, not merely documented: bytes() returns exactly ceil(size()/8) bytes +// even over a larger buffer (forwarding it can never leak trailing buffer content), and set() drops +// out-of-range bits instead of writing past the span (on the server read path that span wraps a stack +// response buffer). +TEST(ModbusServerCoils, PackedBitsViewContractsEnforced) { + uint8_t buf[8] = {}; + PackedBits view(buf, 10); // 10 bits -> 2 bytes, over an 8-byte buffer + EXPECT_EQ(view.bytes().size(), 2u); + + PackedBits short_view(std::span(buf, 1), 10); // contract-violating: 10 bits over 1 byte + EXPECT_EQ(short_view.bytes().size(), 1u); // clamped to the real span, not a fabricated 2-byte span + + MutablePackedBits bits(std::span(buf, 2), 10); + bits.set(9, true); // in range: lands in byte 1 + bits.set(10, true); // out of range: dropped + bits.set(300, true); // far out of range: dropped, no write past the span + EXPECT_EQ(buf[1], 0x02); + for (size_t i = 2; i < sizeof(buf); i++) + EXPECT_EQ(buf[i], 0) << "byte " << i; +} + +// FC 0x02 must dispatch to on_read_discrete_inputs, not on_read_coils: the two handlers fill different +// patterns, so a swapped dispatch would fail on both the counters and the wire bytes. +TEST(ModbusServerCoils, ReadDiscreteInputsDispatchesToItsOwnHandler) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + hub.prime_send_timestamps_for_test(); + DualReadDevice device(0x02); + hub.register_device(&device); + + const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x08}; + ASSERT_TRUE(hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::READ_DISCRETE_INPUTS), + pdu_data, sizeof(pdu_data))); + + EXPECT_EQ(device.discrete_reads, 1); + EXPECT_EQ(device.coil_reads, 0); + ASSERT_GE(uart.written.size(), 4u); + EXPECT_EQ(uart.written[1], static_cast(FunctionCode::READ_DISCRETE_INPUTS)); + EXPECT_EQ(uart.written[3], 0x02); // the discrete handler's pattern, not the coil handler's + + uart.written.clear(); + ASSERT_TRUE(hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::READ_COILS), pdu_data, + sizeof(pdu_data))); + EXPECT_EQ(device.coil_reads, 1); + EXPECT_EQ(device.discrete_reads, 1); + ASSERT_GE(uart.written.size(), 4u); + EXPECT_EQ(uart.written[3], 0x01); +} + +// The write-side ILLEGAL_FUNCTION defaults: a device without bit handlers rejects coil writes too +// (single and multiple), mirroring the read-side default already covered above. +TEST(ModbusServerCoils, UnhandledCoilWriteIsIllegalFunction) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + hub.prime_send_timestamps_for_test(); + NoBitsDevice device(0x02); + hub.register_device(&device); + + const uint8_t single[] = {0x00, 0x03, 0xFF, 0x00}; + ASSERT_TRUE(hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::WRITE_SINGLE_COIL), + single, sizeof(single))); + ASSERT_EQ(uart.written.size(), 5u); + EXPECT_EQ(uart.written[1], static_cast(FunctionCode::WRITE_SINGLE_COIL) | 0x80); + EXPECT_EQ(uart.written[2], static_cast(ExceptionCode::ILLEGAL_FUNCTION)); + + uart.written.clear(); + const uint8_t multiple[] = {0x00, 0x00, 0x00, 0x08, 0x01, 0xAA}; + ASSERT_TRUE(hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::WRITE_MULTIPLE_COILS), + multiple, sizeof(multiple))); + ASSERT_EQ(uart.written.size(), 5u); + EXPECT_EQ(uart.written[1], static_cast(FunctionCode::WRITE_MULTIPLE_COILS) | 0x80); + EXPECT_EQ(uart.written[2], static_cast(ExceptionCode::ILLEGAL_FUNCTION)); +} + +// FC 0x0F with a byte count that does not match ceil(quantity / 8) is ILLEGAL_DATA_VALUE and never +// reaches the handler. +TEST(ModbusServerCoils, WriteCoilsByteCountMismatchRejected) { + CoilFixture f; + + // quantity 10 needs 2 bytes; claim 1 + const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x0A, 0x01, 0xFF}; + ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::WRITE_MULTIPLE_COILS), + pdu_data, sizeof(pdu_data))); + + ASSERT_EQ(f.uart.written.size(), 5u); + EXPECT_EQ(f.uart.written[1], static_cast(FunctionCode::WRITE_MULTIPLE_COILS) | 0x80); + EXPECT_EQ(f.uart.written[2], static_cast(ExceptionCode::ILLEGAL_DATA_VALUE)); + EXPECT_EQ(f.device.write_count, 0); +} + +// A coil range that runs past address 0xFFFF is ILLEGAL_DATA_ADDRESS and never reaches the handler. +TEST(ModbusServerCoils, CoilAddressRangeOverflowRejected) { + CoilFixture f; + + // start 0xFFF8, quantity 16 -> 0x10008 > 0x10000 + const uint8_t pdu_data[] = {0xFF, 0xF8, 0x00, 0x10}; + ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::READ_COILS), pdu_data, + sizeof(pdu_data))); + + ASSERT_EQ(f.uart.written.size(), 5u); + EXPECT_EQ(f.uart.written[1], static_cast(FunctionCode::READ_COILS) | 0x80); + EXPECT_EQ(f.uart.written[2], static_cast(ExceptionCode::ILLEGAL_DATA_ADDRESS)); + EXPECT_EQ(f.device.read_count, 0); +} + +} // namespace esphome::modbus diff --git a/tests/components/modbus_controller/command_payload_test.cpp b/tests/components/modbus_controller/command_payload_test.cpp new file mode 100644 index 0000000000..c125a44da5 --- /dev/null +++ b/tests/components/modbus_controller/command_payload_test.cpp @@ -0,0 +1,31 @@ +#include + +#include +#include + +#include "esphome/components/modbus_controller/modbus_controller.h" + +namespace esphome::modbus_controller::testing { + +// The coil write factory packs into an exact-size payload. Pinned at one past the protocol maximum +// because a fixed pack buffer sized for the maximum would silently truncate there while the quantity +// field still claimed every coil - and the truncated frame would fit the RTU limit and go on the wire +// malformed. Built at its true byte count, the oversize frame is refused by the hub's size check with +// a log instead. +TEST(ModbusCommandPayload, CoilWritePayloadIsExactSizedNotTruncated) { + ModbusController controller; + std::vector coils(modbus::MAX_NUM_OF_COILS_TO_WRITE + 1, true); + auto cmd = ModbusCommandItem::create_write_multiple_coils(&controller, 0x10, coils); + EXPECT_EQ(cmd.payload.size(), modbus::packed_bit_bytes(coils.size())); +} + +// LSB-first packing with zeroed pad bits, matching the wire layout the PDU builders produce. +TEST(ModbusCommandPayload, CoilWritePacksLsbFirstWithZeroPad) { + ModbusController controller; + const std::vector coils{true, false, true, true}; + auto cmd = ModbusCommandItem::create_write_multiple_coils(&controller, 0x10, coils); + ASSERT_EQ(cmd.payload.size(), 1u); + EXPECT_EQ(cmd.payload.data()[0], 0b00001101); +} + +} // namespace esphome::modbus_controller::testing From 8650d175f4272bfb85a098f7e5173ac0debe2b18 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:26:41 -0500 Subject: [PATCH 1365/1815] Bump aiohappyeyeballs from 2.6.2 to 2.7.1 (#18244) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4501b733a6..cd6ebc7db7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ platformio==6.1.19 esptool==5.3.1 click==8.3.3 aioesphomeapi==45.7.0 -aiohappyeyeballs==2.6.2 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi +aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import From b5a78c6c468d33becd9a6656968f920df50c9d51 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:36:22 +0000 Subject: [PATCH 1366/1815] Bump CodSpeedHQ/action from 5.0.2 to 5.0.3 (#18246) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 735ba73c99..7e695bb46b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -466,7 +466,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@0ca9cbbf4623b599a6c3ed4fc8a922942705d9f1 # v5.0.2 + uses: CodSpeedHQ/action@4296e51e7041e24dadb86d1d6e8b9320d223dbe8 # v5.0.3 with: run: | . venv/bin/activate From e7a2980b9cf1a0121258693b0e5b13f9e125cfd3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:44:30 -0400 Subject: [PATCH 1367/1815] Bump ruff from 0.16.1 to 0.16.2 (#18245) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index b5753066ba..0905fe6be1 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.6 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.16.1 # also change in .pre-commit-config.yaml when updating +ruff==0.16.2 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating prek==0.4.12 # also change in .github/workflows/ci.yml when updating From 334afdefb589563493937f7332ff216e1ab48994 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:44:32 -0400 Subject: [PATCH 1368/1815] Update argcomplete requirement from >=3.7.0 to >=3.7.2 (#18243) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index cd6ebc7db7..98c2f47d7e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,4 +34,4 @@ filelock==3.32.2 # inter-process locks (PlatformIO cache heal, git clone cache) pyparsing >= 3.3.2 # For autocompletion -argcomplete>=3.7.0 +argcomplete>=3.7.2 From 8a16ead8ce7286aa3690950cf8d63b6818d24995 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 14:49:14 -0500 Subject: [PATCH 1369/1815] [web_server] Fix basic auth with long credentials (#18237) --- esphome/components/web_server/__init__.py | 18 +++++++-- .../web_server_base/web_server_base.cpp | 2 +- .../web_server_base/web_server_base.h | 34 ++++++++++++---- .../web_server/test_web_server_auth.py | 39 ++++++++++++++++++- .../web_server_auth_basic_esp8266.yaml | 16 ++++++++ .../web_server_auth_digest_esp8266.yaml | 16 ++++++++ .../test-basicauth.esp8266-ard.yaml | 8 ++++ .../web_server/test.rp2040-ard.yaml | 2 +- 8 files changed, 120 insertions(+), 15 deletions(-) create mode 100644 tests/component_tests/web_server/web_server_auth_basic_esp8266.yaml create mode 100644 tests/component_tests/web_server/web_server_auth_digest_esp8266.yaml create mode 100644 tests/components/web_server/test-basicauth.esp8266-ard.yaml diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 2587d13b9e..c1887cc3fc 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 import gzip import logging import re @@ -406,10 +407,21 @@ async def to_code(config): # The scheme is fixed at build time so the unused Basic/Digest code path is compiled # out. Basic is the current default (the absence of this define); an explicit # 'type: digest' opts in early. Default changes to digest in 2027.1.0. - if auth.get(CONF_TYPE) == AUTH_TYPE_DIGEST: + is_digest = auth.get(CONF_TYPE) == AUTH_TYPE_DIGEST + if is_digest: cg.add_define("USE_WEBSERVER_AUTH_DIGEST") - cg.add(paren.set_auth_username(auth[CONF_USERNAME])) - cg.add(paren.set_auth_password(auth[CONF_PASSWORD])) + if is_digest or CORE.is_esp32: + cg.add(paren.set_auth_username(auth[CONF_USERNAME])) + cg.add(paren.set_auth_password(auth[CONF_PASSWORD])) + else: + # Every non-ESP32 basic auth build takes this path. The ESP8266 and RP2040 + # core base64 encoders wrap output every 72 chars, which breaks + # ESPAsyncWebServer's basic auth compare for long credentials. + # Precompute the hash here and let C++ compare the raw header payload. + basic_hash = base64.b64encode( + f"{auth[CONF_USERNAME]}:{auth[CONF_PASSWORD]}".encode() + ).decode() + cg.add(paren.set_auth_basic_hash(basic_hash)) if CONF_CSS_INCLUDE in config: cg.add_define("USE_WEBSERVER_CSS_INCLUDE") path = CORE.relative_config_path(config[CONF_CSS_INCLUDE]) diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index ccfc04f674..873c5b5a49 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -7,7 +7,7 @@ WebServerBase *global_web_server_base = nullptr; // NOLINT(cppcoreguidelines-av void WebServerBase::add_handler(AsyncWebHandler *handler) { #ifdef USE_WEBSERVER_AUTH - if (!credentials_.username.empty()) { + if (credentials_.is_set()) { handler = new internal::AuthMiddlewareHandler(handler, &credentials_); } #endif diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 9657853a73..c647a13b50 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/defines.h" #if defined(USE_NETWORK) && !defined(USE_ZEPHYR) -#include #include #include "esphome/core/progmem.h" @@ -46,9 +45,20 @@ class MiddlewareHandler : public AsyncWebHandler { }; #ifdef USE_WEBSERVER_AUTH +// All fields point to string literals in generated code; nothing is copied. struct Credentials { - std::string username; - std::string password; +#if USE_ESP32 || defined(USE_WEBSERVER_AUTH_DIGEST) + const char *username{nullptr}; + const char *password{nullptr}; + bool is_set() const { return username != nullptr; } +#else + // base64("username:password"), precomputed at codegen time. Used by every non-ESP32 basic + // auth build. The ESP8266 and RP2040 core libb64 wraps base64 output every 72 chars, so + // letting the library encode and compare fails for long credentials; instead the header + // payload is compared against this hash. + const char *basic_auth_hash{nullptr}; + bool is_set() const { return basic_auth_hash != nullptr; } +#endif }; class AuthMiddlewareHandler : public MiddlewareHandler { @@ -57,10 +67,14 @@ class AuthMiddlewareHandler : public MiddlewareHandler { : MiddlewareHandler(next), credentials_(credentials) {} bool check_auth(AsyncWebServerRequest *request) { - bool success = request->authenticate(credentials_->username.c_str(), credentials_->password.c_str()); + // The scheme is chosen at build time (USE_WEBSERVER_AUTH_DIGEST); the unused path is + // compiled out. On ESP32 our own server picks the scheme internally. +#if USE_ESP32 || defined(USE_WEBSERVER_AUTH_DIGEST) + bool success = request->authenticate(credentials_->username, credentials_->password); +#else + bool success = request->authenticate(credentials_->basic_auth_hash); +#endif if (!success) { - // The scheme is chosen at build time (USE_WEBSERVER_AUTH_DIGEST); the unused path is - // compiled out. On ESP32 our own server picks the scheme internally. #if USE_ESP32 request->requestAuthentication(); #elif defined(USE_WEBSERVER_AUTH_DIGEST) @@ -125,8 +139,12 @@ class WebServerBase final { AsyncWebServer *get_server() const { return this->server_; } #ifdef USE_WEBSERVER_AUTH - void set_auth_username(std::string auth_username) { credentials_.username = std::move(auth_username); } - void set_auth_password(std::string auth_password) { credentials_.password = std::move(auth_password); } +#if USE_ESP32 || defined(USE_WEBSERVER_AUTH_DIGEST) + void set_auth_username(const char *auth_username) { credentials_.username = auth_username; } + void set_auth_password(const char *auth_password) { credentials_.password = auth_password; } +#else + void set_auth_basic_hash(const char *hash) { credentials_.basic_auth_hash = hash; } +#endif #endif void add_handler(AsyncWebHandler *handler); diff --git a/tests/component_tests/web_server/test_web_server_auth.py b/tests/component_tests/web_server/test_web_server_auth.py index 82635b26da..183c586a36 100644 --- a/tests/component_tests/web_server/test_web_server_auth.py +++ b/tests/component_tests/web_server/test_web_server_auth.py @@ -33,14 +33,49 @@ def test_web_server_auth_explicit_basic_no_warning( generate_main: Callable[[str], str], caplog: pytest.LogCaptureFixture, ) -> None: - """Auth type basic builds Basic and does not warn.""" - generate_main("tests/component_tests/web_server/web_server_auth_basic.yaml") + """Auth type basic on ESP32 uses plaintext credentials and does not warn.""" + main_cpp = generate_main( + "tests/component_tests/web_server/web_server_auth_basic.yaml" + ) + assert '->set_auth_username("admin");' in main_cpp + assert '->set_auth_password("password");' in main_cpp + assert "set_auth_basic_hash" not in main_cpp assert _has_define("USE_WEBSERVER_AUTH") assert not _has_define("USE_WEBSERVER_AUTH_DIGEST") assert _DEFAULT_CHANGE_WARNING not in caplog.text +def test_web_server_auth_basic_esp8266_uses_precomputed_hash( + generate_main: Callable[[str], str], +) -> None: + """Auth type basic on ESP8266 emits the precomputed base64 hash, not the credentials.""" + main_cpp = generate_main( + "tests/component_tests/web_server/web_server_auth_basic_esp8266.yaml" + ) + + assert '->set_auth_basic_hash("YWRtaW46cGFzc3dvcmQ=");' in main_cpp + assert "set_auth_username" not in main_cpp + assert "set_auth_password" not in main_cpp + assert _has_define("USE_WEBSERVER_AUTH") + assert not _has_define("USE_WEBSERVER_AUTH_DIGEST") + + +def test_web_server_auth_digest_esp8266_uses_plaintext_credentials( + generate_main: Callable[[str], str], +) -> None: + """Auth type digest on ESP8266 uses plaintext credentials, not the basic hash.""" + main_cpp = generate_main( + "tests/component_tests/web_server/web_server_auth_digest_esp8266.yaml" + ) + + assert '->set_auth_username("admin");' in main_cpp + assert '->set_auth_password("password");' in main_cpp + assert "set_auth_basic_hash" not in main_cpp + assert _has_define("USE_WEBSERVER_AUTH") + assert _has_define("USE_WEBSERVER_AUTH_DIGEST") + + def test_web_server_auth_explicit_digest( generate_main: Callable[[str], str], caplog: pytest.LogCaptureFixture, diff --git a/tests/component_tests/web_server/web_server_auth_basic_esp8266.yaml b/tests/component_tests/web_server/web_server_auth_basic_esp8266.yaml new file mode 100644 index 0000000000..79e0c0ccf5 --- /dev/null +++ b/tests/component_tests/web_server/web_server_auth_basic_esp8266.yaml @@ -0,0 +1,16 @@ +--- +esphome: + name: test + +esp8266: + board: esp01_1m + +wifi: + ssid: MySSID + password: password1 + +web_server: + auth: + username: admin + password: password + type: basic diff --git a/tests/component_tests/web_server/web_server_auth_digest_esp8266.yaml b/tests/component_tests/web_server/web_server_auth_digest_esp8266.yaml new file mode 100644 index 0000000000..59565f8733 --- /dev/null +++ b/tests/component_tests/web_server/web_server_auth_digest_esp8266.yaml @@ -0,0 +1,16 @@ +--- +esphome: + name: test + +esp8266: + board: esp01_1m + +wifi: + ssid: MySSID + password: password1 + +web_server: + auth: + username: admin + password: password + type: digest diff --git a/tests/components/web_server/test-basicauth.esp8266-ard.yaml b/tests/components/web_server/test-basicauth.esp8266-ard.yaml new file mode 100644 index 0000000000..6a01180892 --- /dev/null +++ b/tests/components/web_server/test-basicauth.esp8266-ard.yaml @@ -0,0 +1,8 @@ +packages: + web_server: !include common_v2.yaml + +web_server: + auth: + username: admin + password: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + type: basic diff --git a/tests/components/web_server/test.rp2040-ard.yaml b/tests/components/web_server/test.rp2040-ard.yaml index e4d50d7776..6a01180892 100644 --- a/tests/components/web_server/test.rp2040-ard.yaml +++ b/tests/components/web_server/test.rp2040-ard.yaml @@ -4,5 +4,5 @@ packages: web_server: auth: username: admin - password: password + password: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA type: basic From 596827c51cec6a0ab6833c19544074f36caa6a3c Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Mon, 10 Aug 2026 13:18:48 -0700 Subject: [PATCH 1370/1815] [modbus_client] Add read/write multiple registers (FC 0x17) (#18215) --- esphome/components/modbus/__init__.py | 1 + esphome/components/modbus/modbus.cpp | 23 +++- esphome/components/modbus/modbus.h | 16 ++- esphome/components/modbus/modbus_helpers.cpp | 89 +++++++++----- esphome/components/modbus/modbus_helpers.h | 39 +++++- esphome/components/modbus_client/__init__.py | 100 ++++++++++++++-- .../components/modbus_client/modbus_client.h | 52 ++++++++ .../modbus/modbus_client_device_test.cpp | 29 +++++ .../components/modbus/modbus_helpers_test.cpp | 65 ++++++++++ tests/components/modbus_client/common.yaml | 12 ++ .../uart_mock_modbus_client_read_write.yaml | 111 ++++++++++++++++++ tests/integration/test_uart_mock_modbus.py | 35 ++++++ 12 files changed, 520 insertions(+), 52 deletions(-) create mode 100644 tests/integration/fixtures/uart_mock_modbus_client_read_write.yaml diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 377dadad76..58bd0f65dc 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -28,6 +28,7 @@ MAX_NUM_OF_DISCRETE_INPUTS_TO_READ = 2000 MAX_NUM_OF_COILS_TO_WRITE = 1968 MAX_NUM_OF_REGISTERS_TO_READ = 125 MAX_NUM_OF_REGISTERS_TO_WRITE = 123 +MAX_NUM_OF_REGISTERS_TO_WRITE_RW = 121 modbus_ns = cg.esphome_ns.namespace("modbus") Modbus = modbus_ns.class_("Modbus", cg.Component, uart.UARTDevice) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 901bfcc52e..cff086aeea 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -382,7 +382,7 @@ ModbusServerDevice *ModbusServerHub::find_device_(uint8_t address) { } ResponseStatus ModbusServerHub::check_address_range_(uint16_t start_address, uint16_t count) { - if ((uint32_t) start_address + count > 0x10000u) { + if (!helpers::address_range_fits(start_address, count)) { ESP_LOGW(TAG, "Address out of range - start: %" PRIu16 " num: %" PRIu16, start_address, count); return ExceptionCode::ILLEGAL_DATA_ADDRESS; } @@ -1056,7 +1056,8 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M continue; if (device == nullptr) { // A dropped read is routine (DEBUG); a dropped write/custom warns (unobservable without a device). - const bool requeueable = !helpers::is_function_code_exception(pdu[0]) && helpers::is_function_code_read(pdu[0]); + const bool requeueable = + !helpers::is_function_code_exception(pdu[0]) && helpers::is_function_code_read_only(pdu[0]); if (requeueable) { ESP_LOGD(TAG, "Anonymous duplicate of active frame for %" PRIu8 " (function 0x%X), dropped", address, pdu[0]); } else { @@ -1236,7 +1237,14 @@ void ModbusClientDevice::dispatch_response_(std::span request_pdu switch (function_code) { case FunctionCode::READ_HOLDING_REGISTERS: - case FunctionCode::READ_INPUT_REGISTERS: { + case FunctionCode::READ_INPUT_REGISTERS: + // FC 0x17 lands here too: its read start address and read quantity sit at the same request offsets as a + // plain read's (bytes 1..2 and 3..4), so start_address and count_or_value already hold the read block; its + // response carries only that read data, and the write half is confirmed by the response arriving at all. + // An exception routes here as well (the gate only validates the request when status is set), delivering + // empty registers with the error in status - so a 0x17 subclass handles success and failure in the one + // on_read_holding_registers() callback and never needs to also override on_error(). + case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: { // Decode the big-endian register words into host byte order. The gate guarantees a success response // carries exactly count_or_value registers (and count_or_value <= MAX_NUM_OF_REGISTERS_TO_READ, the // capacity of RegisterValues); a mismatch was diverted to on_custom_response(), never clamped. On @@ -1248,10 +1256,15 @@ void ModbusClientDevice::dispatch_response_(std::span request_pdu } } std::span register_span(registers.data(), registers.size()); - if (function_code == FunctionCode::READ_HOLDING_REGISTERS) { + if (function_code == FunctionCode::READ_INPUT_REGISTERS) { + this->on_read_input_registers(start_address, register_span, status); + } else if (function_code == FunctionCode::READ_HOLDING_REGISTERS || + function_code == FunctionCode::READ_WRITE_MULTIPLE_REGISTERS) { this->on_read_holding_registers(start_address, register_span, status); } else { - this->on_read_input_registers(start_address, register_span, status); + // Unreachable for the current case labels; match explicitly so a function code added to this group + // later is diverted to on_custom_response() rather than silently delivered as a holding read. + this->on_custom_response(request_pdu, response_pdu, status); } break; } diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 6331f23f99..6bd407a687 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -151,9 +151,7 @@ struct ModbusDeviceCommand { static CommandPriority classify(uint8_t function_code) { if (helpers::is_function_code_exception(function_code)) return CommandPriority::READ; - const auto code = static_cast(function_code); - if (helpers::is_function_code_write(function_code) || code == FunctionCode::MASK_WRITE_REGISTER || - code == FunctionCode::READ_WRITE_MULTIPLE_REGISTERS) { + if (helpers::is_function_code_write(function_code)) { return CommandPriority::WRITE; } return CommandPriority::READ; @@ -162,7 +160,7 @@ struct ModbusDeviceCommand { // Requests this entry can serve: a standard read twice (run plus one re-run), everything else once. uint8_t max_pending() const { const uint8_t fc = this->frame.pdu()[0]; - const bool requeueable = !helpers::is_function_code_exception(fc) && helpers::is_function_code_read(fc); + const bool requeueable = !helpers::is_function_code_exception(fc) && helpers::is_function_code_read_only(fc); return (requeueable && !this->continuous) ? 2 : 1; } // Device-scoped clear: detach with no callback (device-less, pending 0). An entry still waiting for @@ -594,6 +592,16 @@ class ModbusClientDevice { bool write_multiple_coils(uint16_t start_address, PackedBits bits) { return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits)); } + /// FC 0x17: the read-back is delivered through on_read_holding_registers() (the response carries only the + /// read registers, the same wire shape as a holding-register read). A device exception - typically a + /// rejected write half - arrives at that same on_read_holding_registers() with the error in its status, + /// exactly as success does, so a subclass overriding that one callback handles both outcomes and never + /// needs to also override on_error(). + bool read_write_multiple_registers(uint16_t read_start_address, uint16_t read_count, uint16_t write_start_address, + std::span write_values) { + return this->queue_pdu(helpers::create_read_write_multiple_registers_pdu(read_start_address, read_count, + write_start_address, write_values)); + } inline void clear_tx_queue_for_address() { this->parent_->clear_tx_queue_for_address(this->address_); } inline void clear_tx_queue_for_device() { this->parent_->clear_tx_queue_for_device(this); } diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index 4287256101..db21b6e6fd 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -8,10 +8,11 @@ namespace esphome::modbus::helpers { static const char *const TAG = "modbus_helpers"; // A quantity/address pair is standard when the quantity is non-zero, within the per-table maximum, -// and the range [start_address, start_address + quantity) stays inside the 16-bit address space -// (the 32-bit promotion is the overflow guard - a 16-bit sum could wrap and pass). +// and the range [start_address, start_address + quantity) stays inside the 16-bit address space. +// Non-logging twin of register_block_in_range(): the same three predicates for the parser side, taking a +// uint16_t quantity. register_block_in_range() is the builder-side variant that also logs which half failed. static bool quantity_in_range(uint16_t start_address, uint16_t quantity, uint16_t max_quantity) { - return quantity != 0 && quantity <= max_quantity && uint32_t(start_address) + quantity <= 0x10000u; + return quantity != 0 && quantity <= max_quantity && address_range_fits(start_address, quantity); } // The spec allows exactly ON (0xFF00) and OFF (0x0000) for a single-coil value, on the request and @@ -307,16 +308,20 @@ std::optional registers_to_number(const uint16_t *registers, size_t cou return payload_to_number(bytes, required_size, sensor_value_type, 0, 0xFFFFFFFF); } +// Append a 16-bit value to a PDU in big-endian (wire) byte order. +template static void append_pdu_word(StaticVector &pdu, uint16_t value) { + pdu.push_back(value >> 8); + pdu.push_back(value >> 0); +} + // Every request PDU opens with the same 5-byte layout: function code, then two big-endian 16-bit // fields (start address + quantity for reads and multi-writes, address + value for single writes). template static void append_pdu_header(StaticVector &pdu, FunctionCode function_code, uint16_t first, uint16_t second) { pdu.push_back(static_cast(function_code)); - pdu.push_back(first >> 8); - pdu.push_back(first >> 0); - pdu.push_back(second >> 8); - pdu.push_back(second >> 0); + append_pdu_word(pdu, first); + append_pdu_word(pdu, second); } // Zero the unused bits of a multi-coil write's final data byte, as the spec requires. Kept in one @@ -335,7 +340,7 @@ ReadPdu create_read_pdu(FunctionCode function_code, uint16_t start_address, uint ESP_LOGE(TAG, "Number of entities is zero for function code %02X", static_cast(function_code)); return pdu; } - if (uint32_t(start_address) + number_of_entities > 0x10000u) { + if (!address_range_fits(start_address, number_of_entities)) { ESP_LOGE(TAG, "Read of %u entities at %u runs past the 16-bit address space, dropping request", number_of_entities, start_address); return pdu; @@ -378,7 +383,7 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address, PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object) // Generic entry point; prefer the direction- and type-specific builders (create_read_pdu(), // create_write_registers_pdu(), etc.) which bound their inputs per spec. - if (is_function_code_read(static_cast(function_code))) { + if (is_function_code_read_only(static_cast(function_code))) { if (values != nullptr || values_len > 0) { ESP_LOGW(TAG, "Values provided for read function code %02X, but will be ignored", static_cast(function_code)); @@ -417,7 +422,7 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address, static_cast(function_code)); return pdu; } - if (!is_single && uint32_t(start_address) + number_of_entities > 0x10000u) { + if (!is_single && !address_range_fits(start_address, number_of_entities)) { ESP_LOGE(TAG, "Write of %u entities at %u runs past the 16-bit address space, dropping request", number_of_entities, start_address); return pdu; @@ -460,29 +465,59 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address, return pdu; } +// Validate one register block for a client builder: a non-zero quantity within max_quantity that does not +// run past the 16-bit address space (register count × 2 stays within MAX_PDU_SIZE as a result). On failure +// it logs the reason and returns false, on which the caller returns an empty PDU. `role` names the block in +// the log ("Read"/"Write"). Logging twin of quantity_in_range(): the same three predicates, split so each +// failure names its reason, and taking size_t so an oversize span is caught before any narrowing. +static bool register_block_in_range(const LogString *role, uint16_t start_address, size_t quantity, + uint16_t max_quantity) { + if (quantity == 0 || quantity > max_quantity) { + ESP_LOGE(TAG, "%s count %zu out of range [1, %u], dropping request", LOG_STR_ARG(role), quantity, max_quantity); + return false; + } + if (!address_range_fits(start_address, quantity)) { + ESP_LOGE(TAG, "%s of %zu registers at %u runs past the 16-bit address space, dropping request", LOG_STR_ARG(role), + quantity, start_address); + return false; + } + return true; +} + PduBuffer create_write_registers_pdu(uint16_t start_address, std::span values) { PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object) - if (values.empty()) { - ESP_LOGE(TAG, "No values provided for write multiple registers, dropping request"); - return pdu; - } - // Byte count is registers × 2 (per spec); bounding the register count keeps the PDU within MAX_PDU_SIZE. - if (values.size() > MAX_NUM_OF_REGISTERS_TO_WRITE) { - ESP_LOGE(TAG, "values.size() %zu exceeds maximum registers to write %u, dropping request", values.size(), - MAX_NUM_OF_REGISTERS_TO_WRITE); - return pdu; - } - if (uint32_t(start_address) + values.size() > 0x10000u) { - ESP_LOGE(TAG, "Write of %zu registers at %u runs past the 16-bit address space, dropping request", values.size(), - start_address); + if (!register_block_in_range(LOG_STR("Write"), start_address, values.size(), MAX_NUM_OF_REGISTERS_TO_WRITE)) { return pdu; } append_pdu_header(pdu, FunctionCode::WRITE_MULTIPLE_REGISTERS, start_address, values.size()); pdu.push_back(static_cast(values.size() * 2)); // byte count for (auto v : values) { - auto decoded_value = decode_value(v); - pdu.push_back(decoded_value[0]); - pdu.push_back(decoded_value[1]); + append_pdu_word(pdu, v); + } + return pdu; +} + +PduBuffer create_read_write_multiple_registers_pdu(uint16_t read_start_address, uint16_t read_count, + uint16_t write_start_address, + std::span write_values) { + PduBuffer pdu; + if (!register_block_in_range(LOG_STR("Read"), read_start_address, read_count, MAX_NUM_OF_REGISTERS_TO_READ)) { + return pdu; + } + if (!register_block_in_range(LOG_STR("Write"), write_start_address, write_values.size(), + MAX_NUM_OF_REGISTERS_TO_WRITE_RW)) { + return pdu; + } + // fc + read start(2) + read qty(2) + write start(2) + write qty(2) + write byte count(1) + write values. + const auto write_count = static_cast(write_values.size()); + pdu.push_back(static_cast(FunctionCode::READ_WRITE_MULTIPLE_REGISTERS)); + append_pdu_word(pdu, read_start_address); + append_pdu_word(pdu, read_count); + append_pdu_word(pdu, write_start_address); + append_pdu_word(pdu, write_count); + pdu.push_back(static_cast(write_count * 2)); // byte count + for (auto v : write_values) { + append_pdu_word(pdu, v); } return pdu; } @@ -512,7 +547,7 @@ static void build_write_coils_pdu(PduBuffer &pdu, uint16_t start_address, Packed ESP_LOGE(TAG, "count %u exceeds maximum coils to write %u, dropping request", count, MAX_NUM_OF_COILS_TO_WRITE); return; } - if (uint32_t(start_address) + count > 0x10000u) { + if (!address_range_fits(start_address, count)) { ESP_LOGE(TAG, "Write of %u coils at %u runs past the 16-bit address space, dropping request", count, start_address); return; } diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index e47a6835cd..c737e206c0 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -11,7 +11,8 @@ namespace esphome::modbus::helpers { -inline bool is_function_code_read(uint8_t function_code) { +// Pure read codes (0x01-0x04): they only read, so they are idempotent and safe to retry. +inline bool is_function_code_read_only(uint8_t function_code) { FunctionCode masked_function_code = static_cast(function_code & FUNCTION_CODE_MASK); return masked_function_code == FunctionCode::READ_COILS || masked_function_code == FunctionCode::READ_DISCRETE_INPUTS || @@ -19,12 +20,27 @@ inline bool is_function_code_read(uint8_t function_code) { masked_function_code == FunctionCode::READ_INPUT_REGISTERS; } +// Codes whose response carries read-back data: the pure reads plus 0x17, which reads and writes at once. +inline bool is_function_code_read(uint8_t function_code) { + return is_function_code_read_only(function_code) || + static_cast(function_code & FUNCTION_CODE_MASK) == FunctionCode::READ_WRITE_MULTIPLE_REGISTERS; +} + +// Codes that mutate registers or coils: the pure writes, 0x16 mask-write, and 0x17 read/write multiple. inline bool is_function_code_write(uint8_t function_code) { FunctionCode masked_function_code = static_cast(function_code & FUNCTION_CODE_MASK); return masked_function_code == FunctionCode::WRITE_SINGLE_COIL || masked_function_code == FunctionCode::WRITE_SINGLE_REGISTER || masked_function_code == FunctionCode::WRITE_MULTIPLE_COILS || - masked_function_code == FunctionCode::WRITE_MULTIPLE_REGISTERS; + masked_function_code == FunctionCode::WRITE_MULTIPLE_REGISTERS || + masked_function_code == FunctionCode::MASK_WRITE_REGISTER || + masked_function_code == FunctionCode::READ_WRITE_MULTIPLE_REGISTERS; +} + +// True if [start_address, start_address + count) fits within the 16-bit Modbus address space. The 32-bit +// promotion is the overflow guard - a 16-bit sum could wrap and pass. +inline bool address_range_fits(uint16_t start_address, size_t count) { + return uint32_t(start_address) + count <= 0x10000u; } inline bool is_function_code_exception(uint8_t function_code) { @@ -90,8 +106,8 @@ inline uint8_t server_frame_data_offset(const uint8_t *frame, size_t size) { } /** Returns the payload portion of a server response PDU: the bytes after the function code, and for the - * standard read responses (0x01-0x04) also after the byte-count byte. Responses to 0x14/0x17 also carry a - * byte-count byte, but those codes are not implemented and their count byte is left in the payload. For + * read responses (0x01-0x04 and 0x17) also after the byte-count byte. Response 0x14 also carries a + * byte-count byte, but that code is not implemented and its count byte is left in the payload. For * an exception PDU the payload is the exception code byte (the read check must not see the masked * function code, or an exception-of-read would classify as a read and return an empty span). Returns an * empty span if the PDU is too short. @@ -432,6 +448,21 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address, */ PduBuffer create_write_registers_pdu(uint16_t start_address, std::span values); +/** Create modbus read/write multiple registers command + * Function 0x17 Read/Write Multiple Registers + * Writes write_values then reads read_count registers in one transaction (write first, per Modbus 6.17); + * the response carries only the read registers. + * @param read_start_address modbus address of the first register to read back + * @param read_count number of registers to read (at most MAX_NUM_OF_REGISTERS_TO_READ) + * @param write_start_address modbus address of the first register to write + * @param write_values register values to write; the register count is write_values.size() (at most + * MAX_NUM_OF_REGISTERS_TO_WRITE_RW). Any contiguous uint16_t container converts. + * @return PDU (function code + data, no address, no CRC); an empty PDU on any out-of-range input + */ +PduBuffer create_read_write_multiple_registers_pdu(uint16_t read_start_address, uint16_t read_count, + uint16_t write_start_address, + std::span write_values); + /** Create modbus write single register command * Function 0x06 Write Single Register * @param start_address modbus address of the register to write diff --git a/esphome/components/modbus_client/__init__.py b/esphome/components/modbus_client/__init__.py index 52a61cacad..bb113d649c 100644 --- a/esphome/components/modbus_client/__init__.py +++ b/esphome/components/modbus_client/__init__.py @@ -28,9 +28,12 @@ CONF_ON_NO_RESPONSE = "on_no_response" CONF_ON_NOT_SENT = "on_not_sent" CONF_ON_SENT = "on_sent" CONF_PDU = "pdu" +CONF_READ_ADDRESS = "read_address" +CONF_READ_COUNT = "read_count" CONF_RETRY = "retry" CONF_START_ADDRESS = "start_address" CONF_VALUES = "values" +CONF_WRITE_ADDRESS = "write_address" modbus_client_ns = cg.esphome_ns.namespace("modbus_client") ModbusClientSendAction = modbus_client_ns.class_( @@ -55,6 +58,9 @@ WriteMultipleRegistersAction = modbus_client_ns.class_( WriteMultipleCoilsAction = modbus_client_ns.class_( "WriteMultipleCoilsAction", automation.Action, modbus.ModbusClientDevice ) +ReadWriteMultipleRegistersAction = modbus_client_ns.class_( + "ReadWriteMultipleRegistersAction", automation.Action, modbus.ModbusClientDevice +) # Packed bit view delivered to read_coils / read_discrete_inputs on_response handlers. PackedBits = modbus.modbus_ns.class_("PackedBits") @@ -255,21 +261,30 @@ async def modbus_client_send_to_code(config, action_id, template_arg, args): _REGISTER_SPAN = cg.std_span.template(cg.uint16.operator("const")) -# Every typed action addresses a register or coil range and reports through the same two reply handlers. -_TYPED_ACTION_SCHEMA = _ACTION_BASE_SCHEMA.extend( +# The reply-handler pair every typed-dispatch action reports through. Kept in one place so the +# read/write-multiple schema (which cannot require start_address) shares it instead of drifting. +# Both use _handler_schema(): the decoded arguments (values span, bits view) point at buffers the hub +# reuses once the handler returns, so a deferring action would resume on freed memory. A reply the +# dispatch gate diverts (not a standard-conformant transaction) arrives at on_custom_response with the +# raw request/response PDUs; real device exceptions still arrive via on_error. +_REPLY_HANDLERS_SCHEMA = cv.Schema( { - cv.Required(CONF_START_ADDRESS): cv.templatable(cv.hex_uint16_t), - # Both use _handler_schema(): the decoded arguments (values span, bits view) point at buffers the - # hub reuses once the handler returns, so a deferring action would resume on freed memory. cv.Optional(CONF_ON_RESPONSE): _handler_schema(), - # A reply the dispatch gate diverts (not a standard-conformant transaction) arrives here with the - # raw request/response PDUs; real device exceptions still arrive via on_error. cv.Optional(CONF_ON_CUSTOM_RESPONSE): _handler_schema(), } ) +# Every typed action addresses a register or coil range and reports through the shared reply handlers. +_TYPED_ACTION_SCHEMA = _ACTION_BASE_SCHEMA.extend(_REPLY_HANDLERS_SCHEMA).extend( + { + cv.Required(CONF_START_ADDRESS): cv.templatable(cv.hex_uint16_t), + } +) -def _no_address_overflow(count_key: str) -> Callable[[ConfigType], ConfigType]: + +def _no_address_overflow( + count_key: str, address_key: str = CONF_START_ADDRESS +) -> Callable[[ConfigType], ConfigType]: """Reject a range that runs past the 16-bit address space, which the device could never answer. Only literal configurations can be checked: either operand may be a lambda, and its value is not known @@ -278,17 +293,17 @@ def _no_address_overflow(count_key: str) -> Callable[[ConfigType], ConfigType]: """ def validate(config: ConfigType) -> ConfigType: - start = config[CONF_START_ADDRESS] + start = config[address_key] count = config[count_key] if isinstance(start, Lambda) or isinstance(count, Lambda): return config - # CONF_COUNT is a number; CONF_VALUES is the list whose length is the count. + # A count key holds a number; a values key holds the list whose length is the count. length = count if isinstance(count, int) else len(count) if start + length > 0x10000: raise cv.Invalid( - f"{CONF_START_ADDRESS} 0x{start:04X} plus {length} entities runs past the end of the " + f"{address_key} 0x{start:04X} plus {length} entities runs past the end of the " f"16-bit address space (last addressable entity is 0xFFFF)", - path=[CONF_START_ADDRESS], + path=[address_key], ) return config @@ -468,3 +483,64 @@ async def write_multiple_coils_to_code(config, action_id, template_arg, args): arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*packed)) cg.add(var.set_values_static(arr, len(values))) return await register_client_action(var, config, args, []) + + +# Read/write multiple registers (FC 0x17) writes one register block and reads another in a single +# transaction, so it has two address ranges and uses read_address/write_address instead of start_address. +# Note the two meanings of `values`: here it is the block being WRITTEN, while in on_response the lambda +# argument `values` is the block that was READ BACK (host-order words, the same shape as +# read_holding_registers, so a caller can feed it through the same handler). +_READ_WRITE_MULTIPLE_REGISTERS_SCHEMA = cv.All( + _ACTION_BASE_SCHEMA.extend(_REPLY_HANDLERS_SCHEMA).extend( + { + cv.Required(CONF_READ_ADDRESS): cv.templatable(cv.hex_uint16_t), + cv.Optional(CONF_READ_COUNT, default=1): cv.templatable( + cv.int_range(min=1, max=modbus.MAX_NUM_OF_REGISTERS_TO_READ) + ), + cv.Required(CONF_WRITE_ADDRESS): cv.templatable(cv.hex_uint16_t), + cv.Required(CONF_VALUES): cv.templatable( + cv.All( + cv.ensure_list(cv.hex_uint16_t), + cv.Length(min=1, max=modbus.MAX_NUM_OF_REGISTERS_TO_WRITE_RW), + ) + ), + } + ), + _no_address_overflow(CONF_READ_COUNT, CONF_READ_ADDRESS), + _no_address_overflow(CONF_VALUES, CONF_WRITE_ADDRESS), +) + + +@automation.register_action( + "modbus_client.read_write_multiple_registers", + ReadWriteMultipleRegistersAction, + _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA, + synchronous=True, +) +async def read_write_multiple_registers_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + cg.add( + var.set_read_address( + await cg.templatable(config[CONF_READ_ADDRESS], args, cg.uint16) + ) + ) + cg.add( + var.set_read_count( + await cg.templatable(config[CONF_READ_COUNT], args, cg.uint16) + ) + ) + cg.add( + var.set_write_address( + await cg.templatable(config[CONF_WRITE_ADDRESS], args, cg.uint16) + ) + ) + values = config[CONF_VALUES] + if cg.is_template(values): + templ = await cg.templatable(values, args, cg.std_vector.template(cg.uint16)) + cg.add(var.set_values_template(templ)) + else: + # A static list goes to flash, so play() sends straight from there without allocating. + arr_id = ID(f"{action_id}_values", is_declaration=True, type=cg.uint16) + arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*values)) + cg.add(var.set_values_static(arr, len(values))) + return await register_client_action(var, config, args, [(_REGISTER_SPAN, "values")]) diff --git a/esphome/components/modbus_client/modbus_client.h b/esphome/components/modbus_client/modbus_client.h index f9a00d65f6..7e6d9d069f 100644 --- a/esphome/components/modbus_client/modbus_client.h +++ b/esphome/components/modbus_client/modbus_client.h @@ -332,4 +332,56 @@ template class WriteMultipleCoilsAction : public TypedClientActi } values_; }; +/// modbus_client.read_write_multiple_registers (FC 0x17): writes one register block and reads another back in +/// one transaction (write first, per Modbus 6.17). on_response delivers the read-back words as `values`. +template class ReadWriteMultipleRegistersAction : public TypedClientActionBase { + public: + TEMPLATABLE_VALUE(uint16_t, read_address) + TEMPLATABLE_VALUE(uint16_t, read_count) + TEMPLATABLE_VALUE(uint16_t, write_address) + + /// Static config: the write registers live in flash, so play() neither allocates nor copies. + void set_values_static(const uint16_t *values, size_t len) { + this->values_.data = values; + this->len_ = static_cast(len); + } + /// Lambda config: the write registers are only known at play() time. + void set_values_template(std::vector (*func)(Ts...)) { + this->values_.func = func; + this->len_ = -1; // sentinel: template mode + } + + Trigger> *get_response_trigger() { return &this->response_trigger_; } + + void play(const Ts &...x) override { + const uint16_t read_start = this->read_address_.value(x...); + const uint16_t read_count = this->read_count_.value(x...); + const uint16_t write_start = this->write_address_.value(x...); + // An out-of-range read/write count builds an empty PDU (the builder logs why), resolving via on_not_sent. + if (this->len_ >= 0) { + this->send_or_resolve_(modbus::helpers::create_read_write_multiple_registers_pdu( + read_start, read_count, write_start, + std::span(this->values_.data, static_cast(this->len_)))); + return; + } + const std::vector values = this->values_.func(x...); + this->send_or_resolve_(modbus::helpers::create_read_write_multiple_registers_pdu( + read_start, read_count, write_start, std::span(values))); + } + // The 0x17 response carries only the read block, so the hub dispatch delivers it as a holding-register read. + void on_read_registers(modbus::EntityType entity_type, uint16_t start_address, std::span registers, + modbus::ResponseStatus status) override { + if (modbus::succeeded(status)) + this->response_trigger_.trigger(registers); + } + + protected: + Trigger> response_trigger_; + ssize_t len_{-1}; // -1 = template mode, >= 0 = static mode with this many write registers + union Values { + std::vector (*func)(Ts...); + const uint16_t *data; + } values_; +}; + } // namespace esphome::modbus_client diff --git a/tests/components/modbus/modbus_client_device_test.cpp b/tests/components/modbus/modbus_client_device_test.cpp index 38c28ce2df..333da5b228 100644 --- a/tests/components/modbus/modbus_client_device_test.cpp +++ b/tests/components/modbus/modbus_client_device_test.cpp @@ -118,6 +118,35 @@ TEST(ModbusClientDeviceFanOut, ReadHoldingRegistersSuccess) { EXPECT_FALSE(call.status.has_value()); } +// FC 0x17: the response carries only the read block, so it decodes as a holding-register read of the read +// start/count. The write half has no client-side ack callback - it is confirmed by a successful response. +TEST(ModbusClientDeviceFanOut, ReadWriteMultipleRegistersDeliversReadBlockAsHolding) { + RecordingDevice device; + // read 2 regs at 0x0010, write 1 reg (0x00FF) at 0x0020 + const uint8_t request[] = {0x17, 0x00, 0x10, 0x00, 0x02, 0x00, 0x20, 0x00, 0x01, 0x02, 0x00, 0xFF}; + const uint8_t response[] = {0x17, 0x04, 0x00, 0x2A, 0x01, 0x00}; // read-back: 0x002A, 0x0100 + device.on_response(request, response); + + ASSERT_EQ(device.holding_calls.size(), 1u); + const auto &call = device.holding_calls.front(); + EXPECT_EQ(call.start_address, 0x0010); // the READ start address, not the write + EXPECT_EQ(call.registers, (std::vector{0x002A, 0x0100})); + EXPECT_FALSE(call.status.has_value()); + EXPECT_TRUE(device.write_multiple_registers_calls.empty()); // no separate write-ack on the client side +} + +// A 0x17 response shorter than the requested read count is self-consistent but wrong; it must be diverted +// to on_custom_response(), never clamped and delivered as if complete. +TEST(ModbusClientDeviceFanOut, ReadWriteMultipleRegistersShortResponseGoesToCustom) { + RecordingDevice device; + const uint8_t request[] = {0x17, 0x00, 0x10, 0x00, 0x02, 0x00, 0x20, 0x00, 0x01, 0x02, 0x00, 0xFF}; + const uint8_t response[] = {0x17, 0x02, 0x00, 0x2A}; // only 1 register, but 2 were requested + device.on_response(request, response); + + EXPECT_TRUE(device.holding_calls.empty()); + EXPECT_EQ(device.custom_requests.size(), 1u); +} + TEST(ModbusClientDeviceFanOut, ReadInputRegistersDelegateToGeneric) { GenericDevice device; const uint8_t request[] = {0x04, 0x00, 0x10, 0x00, 0x01}; diff --git a/tests/components/modbus/modbus_helpers_test.cpp b/tests/components/modbus/modbus_helpers_test.cpp index 6a65c3bf68..53f51b016b 100644 --- a/tests/components/modbus/modbus_helpers_test.cpp +++ b/tests/components/modbus/modbus_helpers_test.cpp @@ -483,6 +483,71 @@ TEST(ModbusTypedBuilders, WriteRegistersPduRejectsOverLimit) { EXPECT_FALSE(create_write_registers_pdu(0x0000, values).empty()); } +TEST(ModbusTypedBuilders, ReadWriteMultipleRegistersPduWireBytes) { + const uint16_t write_values[] = {0x000B, 0x0016}; + // Read 2 registers at 0x0010, write 2 registers at 0x0020. + auto pdu = create_read_write_multiple_registers_pdu(0x0010, 2, 0x0020, write_values); + const std::vector expected{0x17, 0x00, 0x10, 0x00, 0x02, 0x00, 0x20, + 0x00, 0x02, 0x04, 0x00, 0x0B, 0x00, 0x16}; + EXPECT_EQ(std::vector(pdu.begin(), pdu.end()), expected); + EXPECT_TRUE(is_client_pdu_standard(pdu.data(), pdu.size())); +} + +TEST(ModbusTypedBuilders, ReadWriteMultipleRegistersPduRejectsOutOfRange) { + const uint16_t one_value[] = {0x0001}; + const uint16_t two_values[] = {0x0001, 0x0002}; + // Read count out of range (zero and above the read ceiling). + EXPECT_TRUE(create_read_write_multiple_registers_pdu(0x0000, 0, 0x0020, one_value).empty()); + EXPECT_TRUE( + create_read_write_multiple_registers_pdu(0x0000, MAX_NUM_OF_REGISTERS_TO_READ + 1, 0x0020, one_value).empty()); + // Write count out of range (empty, and above the read/write ceiling which is lower than a plain write). + EXPECT_TRUE(create_read_write_multiple_registers_pdu(0x0000, 1, 0x0020, std::span()).empty()); + std::vector too_many(MAX_NUM_OF_REGISTERS_TO_WRITE_RW + 1, 0xAAAA); + EXPECT_TRUE(create_read_write_multiple_registers_pdu(0x0000, 1, 0x0020, too_many).empty()); + // Both blocks at their respective ceilings are accepted. + std::vector at_write_limit(MAX_NUM_OF_REGISTERS_TO_WRITE_RW, 0xAAAA); + EXPECT_FALSE( + create_read_write_multiple_registers_pdu(0x0000, MAX_NUM_OF_REGISTERS_TO_READ, 0x0020, at_write_limit).empty()); + // A block that runs past the 16-bit address space is refused (read block, then write block). + EXPECT_TRUE(create_read_write_multiple_registers_pdu(0xFFFF, 2, 0x0020, one_value).empty()); + EXPECT_TRUE(create_read_write_multiple_registers_pdu(0x0000, 2, 0xFFFF, two_values).empty()); + // Accept boundary: a block ending exactly at 0x10000 (last register 0xFFFF) still fits. + EXPECT_FALSE(create_read_write_multiple_registers_pdu(0xFFFE, 2, 0x0000, one_value).empty()); // read ends at 0x10000 + EXPECT_FALSE( + create_read_write_multiple_registers_pdu(0x0000, 1, 0xFFFF, one_value).empty()); // write ends at 0x10000 +} + +TEST(ModbusFunctionCodeClass, ReadWriteMultipleCountsAsBothReadAndWrite) { + const auto rw = static_cast(FC::READ_WRITE_MULTIPLE_REGISTERS); + // 0x17 both reads and writes, but it is not a pure (retry-safe) read. + EXPECT_TRUE(is_function_code_read(rw)); + EXPECT_TRUE(is_function_code_write(rw)); + EXPECT_FALSE(is_function_code_read_only(rw)); + // Pure reads are read and read-only, never write. + const auto rd = static_cast(FC::READ_HOLDING_REGISTERS); + EXPECT_TRUE(is_function_code_read(rd)); + EXPECT_TRUE(is_function_code_read_only(rd)); + EXPECT_FALSE(is_function_code_write(rd)); + // Plain writes are write only. + const auto wr = static_cast(FC::WRITE_MULTIPLE_REGISTERS); + EXPECT_TRUE(is_function_code_write(wr)); + EXPECT_FALSE(is_function_code_read(wr)); + EXPECT_FALSE(is_function_code_read_only(wr)); + // Mask-write register mutates via read-modify-write, so it classes as a write, never a read. + const auto mask = static_cast(FC::MASK_WRITE_REGISTER); + EXPECT_TRUE(is_function_code_write(mask)); + EXPECT_FALSE(is_function_code_read(mask)); + EXPECT_FALSE(is_function_code_read_only(mask)); +} + +TEST(ModbusCreateClientPdu, ReadWriteMultipleReturnsEmpty) { + // The generic builder cannot express 0x17's two blocks; callers use the dedicated builder instead. + const uint16_t values[] = {0x0001}; + EXPECT_TRUE(create_client_pdu(FC::READ_WRITE_MULTIPLE_REGISTERS, 0x0000, 1, reinterpret_cast(values), + sizeof(values)) + .empty()); +} + TEST(ModbusTypedBuilders, FloatToPayloadAppendsToExistingContent) { // The container overload appends - the semantic every migrated caller relies on when a lambda // has already put words into the buffer. diff --git a/tests/components/modbus_client/common.yaml b/tests/components/modbus_client/common.yaml index cae2002342..bce2149fbf 100644 --- a/tests/components/modbus_client/common.yaml +++ b/tests/components/modbus_client/common.yaml @@ -35,6 +35,8 @@ button: id(bare_client).write_single_register(0x10, 42); id(bare_client).write_single_coil(0x01, true); id(bare_client_explicit_hub).read_holding_registers(0x20, 4); + const uint16_t rw_vals[] = {1, 2}; + id(bare_client).read_write_multiple_registers(0x0400, 2, 0x0300, rw_vals); - platform: template name: "Send Read" on_press: @@ -134,3 +136,13 @@ button: on_error: then: - lambda: 'ESP_LOGW("modbus_client.test", "fc 0x%X exception %d", request.empty() ? 0 : request[0], (int) exception_code);' + - modbus_client.read_write_multiple_registers: + address: 0x01 + write_address: 0x0300 + values: !lambda "return {1, 2};" + read_address: 0x0400 + read_count: 2 + on_response: + then: + # `values` here is the READ-BACK block, not the written block above + - lambda: 'ESP_LOGI("modbus_client.test", "rw read0=%u n=%u", values[0], (unsigned) values.size());' diff --git a/tests/integration/fixtures/uart_mock_modbus_client_read_write.yaml b/tests/integration/fixtures/uart_mock_modbus_client_read_write.yaml new file mode 100644 index 0000000000..1f89889c95 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_client_read_write.yaml @@ -0,0 +1,111 @@ +esphome: + name: uart-mock-modbus-cli-rw + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +# Two virtual buses looped back to each other: the client's transmissions reach the server and the +# server's replies reach the client. auto_start so forwarding is active before the button fires. +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_client + data: !lambda return data; + - id: virtual_uart_client + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + +globals: + - id: stored_1 + type: uint16_t + initial_value: "0" + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_client + id: virtual_modbus_client + role: client + turnaround_time: 10ms + +modbus_server: + - address: 1 + modbus_id: virtual_modbus_server + registers: + # Writable + readable register: the read publishes what it returns, so the test can confirm the + # write half of the 0x17 ran before the read half (Modbus 6.17). + - address: 0x01 + value_type: U_WORD + read_lambda: |- + id(srv_read_1).publish_state(id(stored_1)); + return id(stored_1); + write_lambda: |- + id(stored_1) = x; + id(srv_write_1).publish_state(x); + return true; + # Read-only register, returned together with 0x01 by the 2-register read half. + - address: 0x02 + value_type: U_WORD + read_lambda: return 0x00AA; + +sensor: + # Server-side observations. + - platform: template + name: "srv_write_1" + id: srv_write_1 + - platform: template + name: "srv_read_1" + id: srv_read_1 + # Client-side read-back: the values the client's on_response received. + - platform: template + name: "client_read_0" + id: client_read_0 + - platform: template + name: "client_read_1" + id: client_read_1 + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + # FC 0x17: write reg 0x0001 = 0x1234, then read regs 0x0001..0x0002 back in the same transaction. + - modbus_client.read_write_multiple_registers: + address: 0x01 + read_address: 0x0001 + read_count: 2 + write_address: 0x0001 + values: [0x1234] + on_response: + then: + - lambda: |- + // values is the read-back block: reg 0x0001 (must be the just-written 0x1234) and reg 0x0002. + if (values.size() >= 2) { + id(client_read_0).publish_state(values[0]); + id(client_read_1).publish_state(values[1]); + } diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index ca0041cc5b..1994d02c34 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -756,3 +756,38 @@ async def test_uart_mock_modbus_fairness( f"controllers did not get a fair share of the bus: " f"controller 1 issued {count_1}, controller 2 issued {count_2}" ) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_client_read_write( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A modbus_client.read_write_multiple_registers action (FC 0x17) drives a server end to end. + + The client writes reg 0x0001 = 0x1234 and reads regs 0x0001..0x0002 in one transaction; the server + applies the write first (Modbus 6.17). The test confirms both ends: the server's write_lambda ran + (srv_write_1) and the read half came back to the client's on_response (client_read_0 = the + just-written 0x1234, client_read_1 = the read-only 0x00AA). + """ + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + tracker = SensorTracker( + ["srv_write_1", "srv_read_1", "client_read_0", "client_read_1"] + ) + futures = tracker.expect_all( + { + "srv_write_1": 4660, # server wrote 0x1234 to reg 0x0001 + "client_read_0": 4660, # client read reg 0x0001 back as the just-written 0x1234 + "client_read_1": 170, # client read reg 0x0002 (0x00AA) in the same request + } + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + await tracker.await_all(futures) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) From 82a63658f9da55eed7ecd80b6a9b8a1b470ac6a0 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 11 Aug 2026 00:20:20 +0200 Subject: [PATCH 1371/1815] =?UTF-8?q?[hoermann=5Fhcp]=20Add=20H=C3=B6rmann?= =?UTF-8?q?=20HCP=20garage=20door=20component=20(#17355)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/hoermann_hcp/__init__.py | 33 ++ .../components/hoermann_hcp/cover/__init__.py | 22 + .../hoermann_hcp/cover/hoermann_hcp_cover.cpp | 87 ++++ .../hoermann_hcp/cover/hoermann_hcp_cover.h | 27 ++ .../components/hoermann_hcp/hoermann_hcp.cpp | 336 ++++++++++++++ .../components/hoermann_hcp/hoermann_hcp.h | 110 +++++ script/analyze_component_buses.py | 1 + tests/components/hoermann_hcp/common.yaml | 8 + .../cover/hoermann_hcp_cover_test.cpp | 174 +++++++ .../hoermann_hcp/hoermann_hcp_test.cpp | 430 ++++++++++++++++++ .../hoermann_hcp/test.esp32-idf.yaml | 3 + .../hoermann_hcp/test.esp8266-ard.yaml | 3 + tests/test_build_components/common/README.md | 5 +- .../common/modbus_server/esp32-idf.yaml | 10 + .../common/modbus_server/esp8266-ard.yaml | 10 + 16 files changed, 1259 insertions(+), 1 deletion(-) create mode 100644 esphome/components/hoermann_hcp/__init__.py create mode 100644 esphome/components/hoermann_hcp/cover/__init__.py create mode 100644 esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp create mode 100644 esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h create mode 100644 esphome/components/hoermann_hcp/hoermann_hcp.cpp create mode 100644 esphome/components/hoermann_hcp/hoermann_hcp.h create mode 100644 tests/components/hoermann_hcp/common.yaml create mode 100644 tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp create mode 100644 tests/components/hoermann_hcp/hoermann_hcp_test.cpp create mode 100644 tests/components/hoermann_hcp/test.esp32-idf.yaml create mode 100644 tests/components/hoermann_hcp/test.esp8266-ard.yaml create mode 100644 tests/test_build_components/common/modbus_server/esp32-idf.yaml create mode 100644 tests/test_build_components/common/modbus_server/esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 9bcbe087c5..253b0c05b1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -238,6 +238,7 @@ esphome/components/hlw8032/* @rici4kubicek esphome/components/hm3301/* @freekode esphome/components/hmac_md5/* @dwmw2 esphome/components/hmac_sha256/* @dwmw2 +esphome/components/hoermann_hcp/* @zweckj esphome/components/homeassistant/* @esphome/core @OttoWinter esphome/components/homeassistant/number/* @landonr esphome/components/homeassistant/switch/* @Links2004 diff --git a/esphome/components/hoermann_hcp/__init__.py b/esphome/components/hoermann_hcp/__init__.py new file mode 100644 index 0000000000..958b495c2e --- /dev/null +++ b/esphome/components/hoermann_hcp/__init__.py @@ -0,0 +1,33 @@ +import esphome.codegen as cg +from esphome.components import modbus +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@zweckj"] +DEPENDENCIES = ["modbus"] +MULTI_CONF = True + +CONF_HOERMANN_HCP_ID = "hoermann_hcp_id" + +hoermann_hcp_ns = cg.esphome_ns.namespace("hoermann_hcp") +HoermannHcp = hoermann_hcp_ns.class_( + "HoermannHcp", cg.PollingComponent, modbus.ModbusServerDevice +) + +# The Hoermann UAP module answers on Modbus server address 2. +CONFIG_SCHEMA = ( + cv.Schema({cv.GenerateID(): cv.declare_id(HoermannHcp)}) + .extend(cv.polling_component_schema("500ms")) + .extend(modbus.modbus_device_schema(0x02, role="server")) +) + +FINAL_VALIDATE_SCHEMA = modbus.final_validate_modbus_device( + "hoermann_hcp", role="server" +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await modbus.register_modbus_server_device(var, config) diff --git a/esphome/components/hoermann_hcp/cover/__init__.py b/esphome/components/hoermann_hcp/cover/__init__.py new file mode 100644 index 0000000000..50deacff63 --- /dev/null +++ b/esphome/components/hoermann_hcp/cover/__init__.py @@ -0,0 +1,22 @@ +import esphome.codegen as cg +from esphome.components import cover +import esphome.config_validation as cv +from esphome.types import ConfigType + +from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns + +DEPENDENCIES = ["hoermann_hcp"] + +HoermannHcpCover = hoermann_hcp_ns.class_("HoermannHcpCover", cover.Cover, cg.Component) + +CONFIG_SCHEMA = ( + cover.cover_schema(HoermannHcpCover) + .extend({cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp)}) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) + var = await cover.new_cover(config, parent) + await cg.register_component(var, config) diff --git a/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp new file mode 100644 index 0000000000..66a141758e --- /dev/null +++ b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp @@ -0,0 +1,87 @@ +#include "hoermann_hcp_cover.h" + +#include "esphome/core/log.h" + +namespace esphome::hoermann_hcp { + +static const char *const TAG = "hoermann_hcp.cover"; + +cover::CoverTraits HoermannHcpCover::get_traits() { + cover::CoverTraits traits; + traits.set_supports_position(true); + traits.set_supports_stop(true); + traits.set_supports_toggle(true); + return traits; +} + +void HoermannHcpCover::setup() { + // Nothing is published before the bus controller is heard from, and the untouched position reads as fully + // open, so flag the entity until the first contact clears it again. + this->status_set_warning("waiting for the bus controller"); + this->parent_->add_on_state_callback([this]() { this->update_from_state_(); }); +} + +void HoermannHcpCover::dump_config() { LOG_COVER("", "Hoermann HCP Cover", this); } + +void HoermannHcpCover::control(const cover::CoverCall &call) { + bool accepted = true; + if (call.get_stop()) + accepted &= this->parent_->stop_door(); + if (call.get_toggle().has_value()) + accepted &= this->parent_->impulse_door(); + if (const auto position = call.get_position()) + accepted &= this->parent_->set_position(*position); + if (!accepted) { + // The command never reached the door, so publish the unchanged state over the one the caller assumed. + ESP_LOGW(TAG, "Command was not accepted by the door"); + this->publish_state(false); + } +} + +void HoermannHcpCover::update_from_state_() { + if (!this->parent_->is_valid()) { + this->status_set_warning(); + // The door can now move unheard, so drop the baseline a direction would be inferred from and stop + // reporting motion instead of leaving the cover travelling until the controller returns. + this->previous_position_ = NAN; + if (this->current_operation != cover::COVER_OPERATION_IDLE) { + this->current_operation = cover::COVER_OPERATION_IDLE; + this->publish_state(); + } + return; + } + this->status_clear_warning(); + + const auto previous_operation = this->current_operation; + const float current_position = this->parent_->get_current_position(); + switch (this->parent_->get_door_state()) { + case DoorState::OPENING: + this->current_operation = cover::COVER_OPERATION_OPENING; + break; + case DoorState::CLOSING: + this->current_operation = cover::COVER_OPERATION_CLOSING; + break; + case DoorState::MOVE_VENTING: + case DoorState::MOVE_HALF: + // These states carry no direction, so keep the current one until the position actually moves. + if (!std::isnan(this->previous_position_) && current_position != this->previous_position_) { + this->current_operation = current_position > this->previous_position_ ? cover::COVER_OPERATION_OPENING + : cover::COVER_OPERATION_CLOSING; + } + break; + default: + this->current_operation = cover::COVER_OPERATION_IDLE; + break; + } + this->previous_position_ = current_position; + + // Compare against the position last published, which starts at COVER_OPEN rather than at zero. + const bool changed = this->position != current_position || previous_operation != this->current_operation; + this->position = current_position; + if (changed) { + // The bus reports the position on every broadcast, so nothing here is worth restoring from flash. + this->publish_state(false); + } +} + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h new file mode 100644 index 0000000000..1ba8328fd2 --- /dev/null +++ b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +#include "esphome/components/cover/cover.h" +#include "esphome/core/component.h" +#include "../hoermann_hcp.h" + +namespace esphome::hoermann_hcp { + +class HoermannHcpCover : public cover::Cover, public Component { + public: + explicit HoermannHcpCover(HoermannHcp *parent) : parent_(parent) {} + + void setup() override; + void dump_config() override; + cover::CoverTraits get_traits() override; + void control(const cover::CoverCall &call) override; + + protected: + void update_from_state_(); + HoermannHcp *const parent_; + // NAN until the first position is observed, so no direction is inferred from a baseline that never existed. + float previous_position_{NAN}; +}; + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.cpp b/esphome/components/hoermann_hcp/hoermann_hcp.cpp new file mode 100644 index 0000000000..0dc146a061 --- /dev/null +++ b/esphome/components/hoermann_hcp/hoermann_hcp.cpp @@ -0,0 +1,336 @@ +#include "hoermann_hcp.h" + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::hoermann_hcp { + +static const char *const TAG = "hoermann_hcp"; + +// Hoermann HCP holding-register blocks. +static constexpr uint16_t COMMAND_REG = 0x9C41; // Commands written by the bus controller +static constexpr uint16_t STATE_REG = 0x9CB9; // Internal state read back by the bus controller +static constexpr uint16_t BROADCAST_REG = 0x9D31; // Door status broadcast by the bus controller +static constexpr float CLOSE_POSITION_THRESHOLD = 0.05f; +static constexpr float OPEN_POSITION_THRESHOLD = 0.95f; + +static constexpr HoermannHcpCommand COMMAND_OPEN{"open", 0x0210, 0x0110}; +static constexpr HoermannHcpCommand COMMAND_CLOSE{"close", 0x0220, 0x0120}; +static constexpr HoermannHcpCommand COMMAND_IMPULSE{"impulse", 0x0240, 0x0140}; + +// High byte of the state register and the door state it stands for. State 0x00 is decoded separately because +// its low byte tells a plain stop from the vent position. +struct DoorStateMapping { + uint8_t code; + DoorState state; +}; +static constexpr DoorStateMapping DOOR_STATE_MAPPINGS[] = { + {0x01, DoorState::OPENING}, {0x02, DoorState::CLOSING}, {0x05, DoorState::MOVE_HALF}, + {0x09, DoorState::MOVE_VENTING}, {0x0A, DoorState::VENT}, {0x20, DoorState::OPEN}, + {0x40, DoorState::CLOSED}, {0x80, DoorState::HALF_OPEN}, +}; + +// The hub rejects a reply whose register count does not match the request, so an unrecognized block length +// is padded with zeros rather than answered with an exception that would fail the controller's whole poll. +static void push_zeros(modbus::RegisterValues ®isters, uint16_t count) { + for (uint16_t i = 0; i < count; i++) + registers.push_back(0x0000); +} + +// True while the door is travelling. An impulse toggles the door, so it only stops one that is moving. +static bool is_moving(DoorState state) { + switch (state) { + case DoorState::OPENING: + case DoorState::CLOSING: + case DoorState::MOVE_HALF: + case DoorState::MOVE_VENTING: + return true; + default: + return false; + } +} + +void HoermannHcp::update() { + const uint32_t now = millis(); + // Time out the connection flag if the bus controller stopped polling. + if (this->valid_ && now - this->last_response_ > this->connection_timeout_ms_) + this->set_valid_(false); + // Status broadcasts alone keep the connection alive, so a command the controller never fetches would + // otherwise block every later one for as long as it keeps broadcasting. + if (this->next_command_ != nullptr && now - this->command_queued_at_ > this->connection_timeout_ms_) { + ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name); + this->next_command_ = nullptr; + this->command_written_at_ = 0; + this->clear_target_(); + } + // A target waits for a door still travelling the other way to turn around. If it never does, the target has + // to go as well, otherwise it would cut a later move short. The connection timeout doubles as that window. + if (this->has_target_() && !this->target_started_ && now - this->command_queued_at_ > this->connection_timeout_ms_) { + ESP_LOGW(TAG, "Door did not start moving towards the requested position, dropping it"); + this->clear_target_(); + } + if (this->changed_) { + this->changed_ = false; + this->state_callback_.call(); + } +} + +void HoermannHcp::dump_config() { + ESP_LOGCONFIG(TAG, + "Hoermann HCP bridge:\n" + " Modbus server address: 0x%02X", + this->get_address()); +} + +modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers, + modbus::RegisterValues ®isters) { + if (start_address != STATE_REG) { + ESP_LOGW(TAG, "Unknown read address 0x%04X", start_address); + return modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS; + } + + this->record_response_(); + + // 0x17 read half: STATE_REG is read back right after COMMAND_REG was written, so echo the stored message + // counter (high byte) and command (low byte). The read length identifies which internal block is requested. + const uint16_t counter = this->command_reg_value_ & 0xFF00; + const uint16_t command = static_cast((this->command_reg_value_ & 0x00FF) << 8); + + switch (number_of_registers) { + case 8: + // Command request: return the internal state, injecting any pending command. + registers.push_back(counter); + registers.push_back(static_cast(0x0001 | command)); + this->push_command_registers_(registers); + push_zeros(registers, 4); + break; + case 2: + // Empty command request. + registers.push_back(static_cast(0x0004 | counter)); + registers.push_back(command); + break; + case 5: + // Bus scan (the bus controller discovering us, typically at startup). + ESP_LOGD(TAG, "Bus scan received from bus controller"); + registers.push_back(counter); + registers.push_back(static_cast(0x0005 | command)); + registers.push_back(0x0430); + registers.push_back(0x10FF); + registers.push_back(0xA845); + break; + default: + ESP_LOGW(TAG, "Unknown read request (read %u registers)", number_of_registers); + push_zeros(registers, number_of_registers); + break; + } + + return {}; +} + +modbus::ResponseStatus HoermannHcp::on_write_registers(uint16_t start_address, + const modbus::RegisterValues ®isters) { + if (start_address == COMMAND_REG) { + // 0x17 write half: stash the command register so the following read half can echo its message counter and + // command byte back from STATE_REG. The hub always runs the write before the read within one request. + this->record_response_(); + this->command_reg_value_ = registers[0]; + return {}; + } + + if (start_address != BROADCAST_REG) { + // Every device sees every broadcast, so a frame meant for another node is ordinary traffic + ESP_LOGV(TAG, "Ignoring write to address 0x%04X", start_address); + return modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS; + } + + this->record_response_(); + + // Door status broadcast. The state is decoded first so that a frame reporting both a new state and a new + // position checks the target against the new state. + if (registers.size() > 2) + this->on_state_reg_(registers[2]); + if (registers.size() > 1) + this->on_position_reg_(registers[1]); + return {}; +} + +void HoermannHcp::push_command_registers_(modbus::RegisterValues ®isters) { + const HoermannHcpCommand *command = this->next_command_; + if (command == nullptr) { + push_zeros(registers, 2); + return; + } + if (this->command_written_at_ == 0) { + // First read after the command was queued: present the "key pressed" values. + this->command_written_at_ = millis(); + ESP_LOGI(TAG, "Sending '%s' command to door", command->name); + registers.push_back(command->pressed_value); + registers.push_back(0x0000); + return; + } + if (millis() - this->command_written_at_ <= this->key_press_delay_ms_) { + // Still inside the key-press window, so keep presenting 0x0000. + push_zeros(registers, 2); + return; + } + // Enough time passed: present the "key released" values and clear the command. + ESP_LOGD(TAG, "Released '%s' command", command->name); + this->command_written_at_ = 0; + this->next_command_ = nullptr; + registers.push_back(command->released_value); + registers.push_back(0x0000); +} + +void HoermannHcp::on_position_reg_(uint16_t value) { + // Low byte: current position. + const uint8_t position = static_cast(value); + if (this->position_raw_ == position) + return; + + this->position_raw_ = position; + this->update_current_position_(); + // Until the door actually travels the way it was told to, its position says nothing about the target. + if (!this->has_target_() || !this->target_started_) + return; + + // The door only knows "open" and "close", so a half-open target is reached by stopping it on the way. + const bool reached = this->target_direction_ == DoorState::OPENING + ? this->current_position_ >= this->target_position_ + : this->current_position_ <= this->target_position_; + if (reached) + this->stop_door(); +} + +void HoermannHcp::on_state_reg_(uint16_t value) { + // The low byte is part of the state for 0x00, so the whole register has to be compared, not just the high byte. + const uint16_t previous = this->prev_state_reg_; + this->prev_state_reg_ = value; + if (previous == value) + return; + + const uint8_t state = value >> 8; + if (state == 0x00) { + // Low byte 0x61 marks the door resting in the vent position, anything else a plain stop. + this->set_door_state_((value & 0x00FF) == 0x61 ? DoorState::VENT : DoorState::STOPPED); + return; + } + for (const auto &mapping : DOOR_STATE_MAPPINGS) { + if (mapping.code == state) { + this->set_door_state_(mapping.state); + return; + } + } + // The low byte can change on its own, so only report a state we cannot decode once. + if (state != (previous >> 8)) + ESP_LOGW(TAG, "Unknown door state 0x%02X", state); +} + +bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) { + if (!this->valid_) { + // Queueing now would fire the command whenever the controller comes back, which may be much later. + ESP_LOGW(TAG, "Not connected to the bus controller, dropping '%s' command", command.name); + return false; + } + if (this->next_command_ != nullptr) { + ESP_LOGW(TAG, "Previous command not yet fetched by the bus controller"); + return false; + } + // A new command supersedes any half-open target the door was still travelling to. + this->clear_target_(); + this->next_command_ = &command; + this->command_queued_at_ = millis(); + return true; +} + +bool HoermannHcp::open_door() { return this->queue_command_(COMMAND_OPEN); } +bool HoermannHcp::close_door() { return this->queue_command_(COMMAND_CLOSE); } +bool HoermannHcp::impulse_door() { return this->queue_command_(COMMAND_IMPULSE); } + +bool HoermannHcp::stop_door() { + if (!is_moving(this->door_state_)) { + this->clear_target_(); + return true; + } + // On success queue_command_() clears the target; on refusal it stays armed so the next position retries. + return this->queue_command_(COMMAND_IMPULSE); +} + +bool HoermannHcp::set_position(float position) { + // The first and last movement segments are inconsistent on some doors, so snap to fully open/closed. + if (position <= CLOSE_POSITION_THRESHOLD) + return this->close_door(); + if (position >= OPEN_POSITION_THRESHOLD) + return this->open_door(); + // Asking the door to travel to where it already is means stopping it. + if (position == this->current_position_) + return this->stop_door(); + + // The door itself has no notion of a target, so it is started in the right direction and stopped on the way. + const bool opening = position > this->current_position_; + if (!this->queue_command_(opening ? COMMAND_OPEN : COMMAND_CLOSE)) + return false; + this->target_position_ = position; + this->target_direction_ = opening ? DoorState::OPENING : DoorState::CLOSING; + // A door already travelling that way is on its way; one moving the other way has to turn around first. + this->target_started_ = this->door_state_ == this->target_direction_; + return true; +} + +void HoermannHcp::record_response_() { + this->last_response_ = millis(); + this->set_valid_(true); +} + +void HoermannHcp::set_valid_(bool valid) { + if (this->valid_ == valid) + return; + this->valid_ = valid; + this->changed_ = true; + if (valid) { + ESP_LOGI(TAG, "Bus controller connected"); + return; + } + ESP_LOGW(TAG, "Bus controller connection lost (no request for %" PRIu32 "ms)", millis() - this->last_response_); + // Drop what the controller never fetched, so it neither blocks later commands nor fires on reconnect. + this->next_command_ = nullptr; + this->command_written_at_ = 0; + this->clear_target_(); +} + +void HoermannHcp::set_door_state_(DoorState state) { + if (this->door_state_ == state) + return; + this->door_state_ = state; + this->changed_ = true; + this->update_current_position_(); + if (!this->has_target_()) + return; + if (state == this->target_direction_) { + this->target_started_ = true; + } else if (this->target_started_ && !is_moving(state)) { + // The door came to rest without reaching the target, so the request it belonged to is over. + this->clear_target_(); + } +} + +void HoermannHcp::update_current_position_() { + // Doors do not always park at exactly 0 or 200, and Cover::is_fully_closed() is an exact comparison, so + // trust the reported end stop over the raw count. + float position = static_cast(this->position_raw_) / 200.0f; + if (this->door_state_ == DoorState::CLOSED) { + position = 0.0f; + } else if (this->door_state_ == DoorState::OPEN) { + position = 1.0f; + } + if (this->current_position_ != position) { + this->current_position_ = position; + this->changed_ = true; + } +} + +void HoermannHcp::clear_target_() { + this->target_position_ = 0.0f; + this->target_started_ = false; +} + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.h b/esphome/components/hoermann_hcp/hoermann_hcp.h new file mode 100644 index 0000000000..142365f16e --- /dev/null +++ b/esphome/components/hoermann_hcp/hoermann_hcp.h @@ -0,0 +1,110 @@ +#pragma once + +#include + +#include "esphome/components/modbus/modbus.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::hoermann_hcp { + +// Door state as reported by the Hoermann bus controller. +enum class DoorState : uint8_t { + OPEN, + OPENING, + CLOSED, + CLOSING, + HALF_OPEN, + MOVE_VENTING, + VENT, + MOVE_HALF, + STOPPED, +}; + +// A HCP command is a simulated key press: the pressed value is presented to the bus controller, then after a +// short delay the released value. The second command register remains zero. +struct HoermannHcpCommand { + const char *name; + uint16_t pressed_value; + uint16_t released_value; +}; + +class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { + public: + void update() override; + void dump_config() override; + + // Registered by child entities to be notified when the door state changes. + template void add_on_state_callback(F &&callback) { + this->state_callback_.add(std::forward(callback)); + } + + // Modbus server callbacks. The bus controller pushes commands and polls state with 0x17 (the hub runs the write + // half first, storing the command register that the read half echoes back) and broadcasts status with 0x10. + modbus::ResponseStatus on_write_registers(uint16_t start_address, const modbus::RegisterValues ®isters) override; + modbus::ResponseStatus on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers, + modbus::RegisterValues ®isters) override; + + // Positions follow the cover convention: 0.0 is fully closed, 1.0 fully open. These return false when the bus + // controller cannot be asked right now, so the caller can react. + bool open_door(); + bool close_door(); + bool impulse_door(); + bool stop_door(); + bool set_position(float position); + + DoorState get_door_state() const { return this->door_state_; } + float get_current_position() const { return this->current_position_; } + bool is_valid() const { return this->valid_; } + + protected: + void record_response_(); + // Returns false when the bus controller has not fetched the previous command yet. + bool queue_command_(const HoermannHcpCommand &command); + // Appends the two key-press registers and advances the pending command's press/release state. + void push_command_registers_(modbus::RegisterValues ®isters); + void on_position_reg_(uint16_t value); + void on_state_reg_(uint16_t value); + + void set_valid_(bool valid); + void set_door_state_(DoorState state); + // Recomputes the reported position from position_raw_ and the current door state. + void update_current_position_(); + bool has_target_() const { return this->target_position_ != 0.0f; } + void clear_target_(); + + CallbackManager state_callback_; + + float current_position_{0.0f}; + // Position the door was told to travel to; 0.0 means no target is armed. + float target_position_{0.0f}; + + // Pending command / key-press state machine. + const HoermannHcpCommand *next_command_{nullptr}; + uint32_t command_queued_at_{0}; + uint32_t command_written_at_{0}; + uint32_t last_response_{0}; + + // A command is "pressed" for this long before its end value is sent. + uint16_t key_press_delay_ms_{100}; + // Drop the "connected" flag if the bus controller has not polled us for this long. + uint16_t connection_timeout_ms_{2000}; + // The state starts on a value the bus controller never reports, so the first broadcast is decoded even when + // it reads 0x0000. + uint16_t prev_state_reg_{0xFFFF}; + // 0x17 write half: command register last written to COMMAND_REG. The read half echoes its high-byte message + // counter and low-byte command back from STATE_REG. + uint16_t command_reg_value_{0}; + + DoorState door_state_{DoorState::CLOSED}; + // Direction the door was started in for the current target. A target armed while the door is still travelling + // the other way must not be judged by the reported direction until the door has turned around. + DoorState target_direction_{DoorState::STOPPED}; + // Position as reported by the bus controller, 0..200 across the full travel. + uint8_t position_raw_{0}; + bool target_started_{false}; + bool valid_{false}; + bool changed_{false}; +}; + +} // namespace esphome::hoermann_hcp diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index a6ccb79544..b8ee3066bd 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -52,6 +52,7 @@ COMMON_BUS_PATH = ( # the packages on the right as well PACKAGE_DEPENDENCIES = { "modbus": ["uart"], # modbus packages include uart packages + "modbus_server": ["uart"], # modbus_server packages include uart packages # Add more package dependencies here as needed } diff --git a/tests/components/hoermann_hcp/common.yaml b/tests/components/hoermann_hcp/common.yaml new file mode 100644 index 0000000000..3b77eed7ea --- /dev/null +++ b/tests/components/hoermann_hcp/common.yaml @@ -0,0 +1,8 @@ +hoermann_hcp: + id: hoermann_hcp_hub + modbus_id: modbus_server_bus + +cover: + - platform: hoermann_hcp + name: Garage Door + device_class: garage diff --git a/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp b/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp new file mode 100644 index 0000000000..0ec2ed1ddd --- /dev/null +++ b/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp @@ -0,0 +1,174 @@ +#include + +#include "esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h" + +namespace esphome::hoermann_hcp { + +using modbus::RegisterValues; + +namespace { + +constexpr uint16_t COMMAND_REG = 0x9C41; +constexpr uint16_t STATE_REG = 0x9CB9; +constexpr uint16_t BROADCAST_REG = 0x9D31; + +RegisterValues make_registers(std::initializer_list values) { + RegisterValues registers; + for (uint16_t value : values) + registers.push_back(value); + return registers; +} + +// The door only accepts commands once the bus controller has actually talked to it. +void connect(HoermannHcp &door) { door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); } + +// Runs one command poll (write 2 / read 8) and returns the register carrying the key-press value. +uint16_t poll_command(HoermannHcp &door) { + door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + RegisterValues response; + door.on_read_holding_registers(STATE_REG, 8, response); + EXPECT_EQ(response.size(), 8u); + return response.size() == 8u ? response[2] : 0xFFFF; +} + +} // namespace + +// Cover::position starts at COVER_OPEN, so a door that is already closed still has a state to publish. +TEST(HoermannHcpCoverTest, ClosedDoorPublishesItsInitialPosition) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + int publishes = 0; + cover.add_on_state_callback([&publishes]() { publishes++; }); + ASSERT_FLOAT_EQ(cover.position, cover::COVER_OPEN); + + // Any request marks the device connected, which is itself a state change. + door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + door.update(); + + EXPECT_EQ(publishes, 1); + EXPECT_FLOAT_EQ(cover.position, cover::COVER_CLOSED); +} + +// Venting and half-open moves report no direction, so one is only derived once the position has moved. +TEST(HoermannHcpCoverTest, DirectionlessMoveHoldsTheOperationUntilThePositionMoves) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + + // Position 100/200 = 0.5, state 0x80 -> resting half open. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x8000})); + door.update(); + ASSERT_EQ(cover.current_operation, cover::COVER_OPERATION_IDLE); + + // State 0x05 -> moving to half-open, but the position has not moved yet. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0500})); + door.update(); + EXPECT_EQ(cover.current_operation, cover::COVER_OPERATION_IDLE); + + // Position 120/200 = 0.6 is higher than before, so the door is opening. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0500})); + door.update(); + EXPECT_EQ(cover.current_operation, cover::COVER_OPERATION_OPENING); + EXPECT_FLOAT_EQ(cover.position, 0.6f); +} + +// Booting while the door is already mid-move gives no baseline to compare against, so no direction +// may be inferred from the first update. +TEST(HoermannHcpCoverTest, FirstDirectionlessMoveDoesNotGuessADirection) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + + // The very first thing seen is a half-open move already at 100/200 = 0.5. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0500})); + door.update(); + EXPECT_EQ(cover.current_operation, cover::COVER_OPERATION_IDLE); +} + +// A cover.open arrives as a position of 1.0, so it has to reach the door as a plain open command rather +// than as a target the door would be stopped at. +TEST(HoermannHcpCoverTest, OpenCommandOpensTheDoor) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + connect(door); + + cover.make_call().set_command_open().perform(); + EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed +} + +// The same for cover.close, which arrives as a position of 0.0. +TEST(HoermannHcpCoverTest, CloseCommandClosesTheDoor) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + connect(door); + + cover.make_call().set_command_close().perform(); + EXPECT_EQ(poll_command(door), 0x0220); // COMMAND_CLOSE pressed +} + +TEST(HoermannHcpCoverTest, ToggleCommandSendsAnImpulse) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + connect(door); + + cover.make_call().set_command_toggle().perform(); + EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed +} + +TEST(HoermannHcpCoverTest, StopCommandStopsAMovingDoor) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + connect(door); + // The door is opening, so it takes an impulse to stop it. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100})); + + cover.make_call().set_command_stop().perform(); + EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed +} + +// A position between the end stops starts the door in the right direction; it is stopped there later. +TEST(HoermannHcpCoverTest, PositionCommandStartsTheDoorTowardsTheTarget) { + HoermannHcp door; // starts out fully closed + HoermannHcpCover cover(&door); + cover.setup(); + connect(door); + + cover.make_call().set_position(0.5f).perform(); + EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed +} + +// A command the door cannot take is assumed to have worked by whoever sent it, so the unchanged state has +// to be published back over that assumption. +TEST(HoermannHcpCoverTest, RefusedCommandPublishesTheUnchangedState) { + HoermannHcp door; // never contacted by a bus controller + HoermannHcpCover cover(&door); + cover.setup(); + int publishes = 0; + cover.add_on_state_callback([&publishes]() { publishes++; }); + + cover.make_call().set_command_close().perform(); + + EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(publishes, 1); + EXPECT_FLOAT_EQ(cover.position, cover::COVER_OPEN); +} + +// Nothing is published before the bus controller is heard from, so a door that never reaches the bus would +// otherwise sit at its fully open default and look healthy. +TEST(HoermannHcpCoverTest, MissingBusControllerIsFlaggedUntilFirstContact) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + EXPECT_TRUE(cover.status_has_warning()); + + connect(door); + door.update(); + EXPECT_FALSE(cover.status_has_warning()); +} + +} // namespace esphome::hoermann_hcp diff --git a/tests/components/hoermann_hcp/hoermann_hcp_test.cpp b/tests/components/hoermann_hcp/hoermann_hcp_test.cpp new file mode 100644 index 0000000000..8463c3f605 --- /dev/null +++ b/tests/components/hoermann_hcp/hoermann_hcp_test.cpp @@ -0,0 +1,430 @@ +#include + +#include +#include + +#include "esphome/components/hoermann_hcp/hoermann_hcp.h" + +namespace esphome::hoermann_hcp { + +using modbus::RegisterValues; + +namespace { + +// Register block addresses the Hoermann bus controller polls (see hoermann_hcp.cpp). +constexpr uint16_t COMMAND_REG = 0x9C41; +constexpr uint16_t STATE_REG = 0x9CB9; +constexpr uint16_t BROADCAST_REG = 0x9D31; + +// The tests shorten the key-press delay to zero, so the release only needs the millis() clock to tick on. +constexpr auto KEY_PRESS_ELAPSED = std::chrono::milliseconds(2); + +RegisterValues make_registers(std::initializer_list values) { + RegisterValues registers; + for (uint16_t value : values) + registers.push_back(value); + return registers; +} + +// The device only accepts commands once the bus controller has actually talked to it. +void connect(HoermannHcp &door) { door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); } + +// Runs one command poll (write 2 / read 8) and returns the register carrying the key-press value. +uint16_t poll_command(HoermannHcp &door) { + door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + RegisterValues response; + door.on_read_holding_registers(STATE_REG, 8, response); + EXPECT_EQ(response.size(), 8u); + return response.size() == 8u ? response[2] : 0xFFFF; +} + +// Exposes the internal timings and the connection bookkeeping, so no test has to wait out a real delay. +class TestableHoermannHcp : public HoermannHcp { + public: + TestableHoermannHcp() { this->key_press_delay_ms_ = 0; } + + using HoermannHcp::connection_timeout_ms_; + using HoermannHcp::set_valid_; +}; + +} // namespace + +// An empty poll (write 2 / read 2) answers with the fixed status word 0x0004. +TEST(HoermannHcpReadWrite, EmptyPollReturnsStatusWord) { + HoermannHcp door; + EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value()); + RegisterValues response; + auto status = door.on_read_holding_registers(STATE_REG, 2, response); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(response.size(), 2u); + EXPECT_EQ(response[0], 0x0004); + EXPECT_EQ(response[1], 0x0000); +} + +// A bus scan (write 3 / read 5) answers with the fixed device identification block. +TEST(HoermannHcpReadWrite, BusScanReturnsIdentification) { + HoermannHcp door; + EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000, 0x0000})).has_value()); + RegisterValues response; + auto status = door.on_read_holding_registers(STATE_REG, 5, response); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(response.size(), 5u); + EXPECT_EQ(response[1], 0x0005); + EXPECT_EQ(response[2], 0x0430); + EXPECT_EQ(response[3], 0x10ff); + EXPECT_EQ(response[4], 0xa845); +} + +// Without a queued command, the command poll (write 2 / read 8) reports idle and no key press. +TEST(HoermannHcpReadWrite, IdleCommandPollHasNoCommand) { + HoermannHcp door; + EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value()); + RegisterValues response; + auto status = door.on_read_holding_registers(STATE_REG, 8, response); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(response.size(), 8u); + EXPECT_EQ(response[1], 0x0001); + EXPECT_EQ(response[2], 0x0000); + EXPECT_EQ(response[3], 0x0000); +} + +// A queued control command is injected into the next command poll as a simulated key press. +TEST(HoermannHcpReadWrite, QueuedCommandIsInjectedIntoPoll) { + HoermannHcp door; + connect(door); + door.open_door(); + EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value()); + RegisterValues response; + auto status = door.on_read_holding_registers(STATE_REG, 8, response); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(response.size(), 8u); + EXPECT_EQ(response[2], 0x0210); // COMMAND_OPEN "key pressed" value + EXPECT_EQ(response[3], 0x0000); +} + +// A read of any other block is an addressing error rather than a successful all-zero reply. +TEST(HoermannHcpReadWrite, UnknownAddressIsRejected) { + HoermannHcp door; + RegisterValues response; + EXPECT_EQ(door.on_read_holding_registers(0x1234, 2, response), modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_EQ(door.on_write_registers(0x1234, make_registers({0x0000})), modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +// A command is held for the key-press duration, then released, and only then can the next one be queued. +TEST(HoermannHcpReadWrite, CommandIsReleasedAfterTheKeyPressDelay) { + TestableHoermannHcp door; + connect(door); + door.open_door(); + EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed + // Refused while one is pending: were it accepted, the release below would carry COMMAND_CLOSE's 0x0120. + door.close_door(); + + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released + // With the command gone, the next one is accepted again. + door.close_door(); + EXPECT_EQ(poll_command(door), 0x0220); // COMMAND_CLOSE pressed +} + +// Commands issued while the bus controller is absent are dropped instead of firing when it returns. +TEST(HoermannHcpReadWrite, CommandIsDroppedWhileDisconnected) { + HoermannHcp door; + door.open_door(); + EXPECT_EQ(poll_command(door), 0x0000); +} + +// Losing the controller must drop a command it never fetched, otherwise it blocks every later command +// and fires unasked once the bus comes back. +TEST(HoermannHcpReadWrite, ConnectionLossDropsThePendingCommand) { + TestableHoermannHcp door; + connect(door); + door.open_door(); + ASSERT_TRUE(door.is_valid()); + + door.set_valid_(false); + EXPECT_FALSE(door.is_valid()); + + // The reconnecting poll must not replay the dropped command. + EXPECT_EQ(poll_command(door), 0x0000); + // And the slot is free, so a new command is accepted. + door.close_door(); + EXPECT_EQ(poll_command(door), 0x0220); +} + +// The connection is dropped by update() once the controller stops polling, which is what releases a +// command it never fetched in the field. +TEST(HoermannHcpReadWrite, PollingTimeoutDropsTheConnection) { + TestableHoermannHcp door; + // Wide enough that a stall cannot expire the connection before the check below runs. + door.connection_timeout_ms_ = 10000; + connect(door); + door.open_door(); + + // Still inside the window: the controller counts as present. + door.update(); + ASSERT_TRUE(door.is_valid()); + + // Shrink the window so the expiry needs only a short sleep; overshooting it only makes it surer. + door.connection_timeout_ms_ = 20; + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + door.update(); + EXPECT_FALSE(door.is_valid()); + // The pending command went with the connection instead of firing on the reconnecting poll. + EXPECT_EQ(poll_command(door), 0x0000); +} + +// Status broadcasts alone keep the connection alive, so a command the controller never fetches has to +// expire on its own; otherwise it blocks every later command until the bus goes quiet entirely. +TEST(HoermannHcpReadWrite, UnfetchedCommandExpiresWhileConnected) { + TestableHoermannHcp door; + door.connection_timeout_ms_ = 200; + connect(door); + door.open_door(); + + std::this_thread::sleep_for(std::chrono::milliseconds(220)); + // A status broadcast refreshes the connection without ever fetching the command. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100})); + door.update(); + ASSERT_TRUE(door.is_valid()); + + // With the stale command gone, the door accepts commands again. + door.close_door(); + EXPECT_EQ(poll_command(door), 0x0220); +} + +// The 0x17 read half echoes the message counter and command byte written to COMMAND_REG, packed +// differently per block length. +TEST(HoermannHcpReadWrite, CommandRegisterIsEchoedBack) { + HoermannHcp door; + // Counter 0x34 in the high byte, command 0x07 in the low byte. + door.on_write_registers(COMMAND_REG, make_registers({0x3407, 0x0000})); + + RegisterValues command_poll; + door.on_read_holding_registers(STATE_REG, 8, command_poll); + ASSERT_EQ(command_poll.size(), 8u); + EXPECT_EQ(command_poll[0], 0x3400); // counter alone + EXPECT_EQ(command_poll[1], 0x0701); // command in the high byte, status 0x01 in the low + + RegisterValues empty_poll; + door.on_read_holding_registers(STATE_REG, 2, empty_poll); + ASSERT_EQ(empty_poll.size(), 2u); + EXPECT_EQ(empty_poll[0], 0x3404); // status 0x04 shares the register with the counter here + EXPECT_EQ(empty_poll[1], 0x0700); // command alone + + RegisterValues scan; + door.on_read_holding_registers(STATE_REG, 5, scan); + ASSERT_EQ(scan.size(), 5u); + EXPECT_EQ(scan[0], 0x3400); + EXPECT_EQ(scan[1], 0x0705); +} + +// A status broadcast (function code 0x10 to 0x9D31) updates the decoded door state and position. +TEST(HoermannHcpWrite, BroadcastUpdatesStateAndPosition) { + HoermannHcp door; + // registers[1] low byte = position (value / 200), registers[2] high byte = state (0x01 -> opening). + auto status = door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100})); + EXPECT_FALSE(status.has_value()); + EXPECT_EQ(door.get_door_state(), DoorState::OPENING); + EXPECT_FLOAT_EQ(door.get_current_position(), 0.5f); +} + +// The first broadcast has to be decoded even when it carries the register's initial value, otherwise a +// door parked mid-travel at boot keeps the CLOSED default and reports itself fully closed. +TEST(HoermannHcpWrite, FirstBroadcastReportingAStopIsDecoded) { + HoermannHcp door; + auto status = door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0000})); + EXPECT_FALSE(status.has_value()); + EXPECT_EQ(door.get_door_state(), DoorState::STOPPED); + EXPECT_FLOAT_EQ(door.get_current_position(), 0.5f); +} + +// The vent position is reported as state 0x00 with low byte 0x61, so a change confined to the low byte of +// the state register still has to be decoded. +TEST(HoermannHcpWrite, VentIsDecodedFromTheStateLowByte) { + HoermannHcp door; + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0100})); + ASSERT_EQ(door.get_door_state(), DoorState::OPENING); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0000})); + ASSERT_EQ(door.get_door_state(), DoorState::STOPPED); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0061})); + EXPECT_EQ(door.get_door_state(), DoorState::VENT); +} + +// A door parking a count short of its end stop must still report exactly closed or open, because +// Cover::is_fully_closed() compares against 0.0 exactly. +TEST(HoermannHcpWrite, EndStopsReportExactPositions) { + HoermannHcp door; + // Position register 1 of 200 while the door reports itself closed. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0001, 0x4000})); + ASSERT_EQ(door.get_door_state(), DoorState::CLOSED); + EXPECT_FLOAT_EQ(door.get_current_position(), 0.0f); + + // Position register 199 of 200 while the door reports itself open. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x00C7, 0x2000})); + ASSERT_EQ(door.get_door_state(), DoorState::OPEN); + EXPECT_FLOAT_EQ(door.get_current_position(), 1.0f); + + // Away from the end stops the raw count is reported as-is. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100})); + EXPECT_FLOAT_EQ(door.get_current_position(), 0.5f); +} + +// A position request below the lower snap threshold becomes a plain close command. +TEST(HoermannHcpPosition, NearlyClosedTargetClosesTheDoor) { + HoermannHcp door; + connect(door); + door.set_position(0.02f); + RegisterValues response; + door.on_read_holding_registers(STATE_REG, 8, response); + ASSERT_EQ(response.size(), 8u); + EXPECT_EQ(response[2], 0x0220); // COMMAND_CLOSE "key pressed" value +} + +// A half-open target starts the door moving towards the requested position. +TEST(HoermannHcpPosition, HalfOpenTargetOpensTheDoor) { + HoermannHcp door; // starts out fully closed + connect(door); + door.set_position(0.5f); + RegisterValues response; + door.on_read_holding_registers(STATE_REG, 8, response); + ASSERT_EQ(response.size(), 8u); + EXPECT_EQ(response[2], 0x0210); // COMMAND_OPEN "key pressed" value +} + +// The door has no notion of a target, so it is stopped with an impulse once it travels past the request. +TEST(HoermannHcpPosition, TargetPositionStopsTheDoor) { + TestableHoermannHcp door; + connect(door); + door.set_position(0.5f); + EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released + + // Position 20/200 = 0.1 while opening: short of the target, so the door keeps going. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100})); + ASSERT_EQ(door.get_door_state(), DoorState::OPENING); + EXPECT_EQ(poll_command(door), 0x0000); + + // Position 120/200 = 0.6 is past the target, so the door is stopped. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed +} + +// An impulse restarts a stopped door, so a frame reporting the stop and the target crossing at once +// must be read as "already stopped" rather than "still opening". +TEST(HoermannHcpPosition, StopReportedWithTheCrossingSendsNoImpulse) { + TestableHoermannHcp door; + connect(door); + door.set_position(0.5f); + EXPECT_EQ(poll_command(door), 0x0210); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door), 0x0110); + + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100})); + ASSERT_EQ(door.get_door_state(), DoorState::OPENING); + + // Same frame: position 0.6 (past the target) and state 0x20 -> the door has reached its open end stop. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x2000})); + ASSERT_EQ(door.get_door_state(), DoorState::OPEN); + EXPECT_EQ(poll_command(door), 0x0000); +} + +// A target the door never reaches is dropped once it comes to rest, so a later move is not cut short. +TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorStopsShort) { + TestableHoermannHcp door; + connect(door); + door.set_position(0.5f); + EXPECT_EQ(poll_command(door), 0x0210); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door), 0x0110); + + // The door is stopped at 0.3 by a wall button, short of the requested 0.5. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100})); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0000})); + ASSERT_EQ(door.get_door_state(), DoorState::STOPPED); + + // A later manual open must run freely instead of being stopped at the abandoned target. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100})); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + EXPECT_EQ(poll_command(door), 0x0000); +} + +// A target armed while the door is still travelling the other way must not be judged by that old direction, +// otherwise the very next position it reports counts as reached and stops the door where it stands. +TEST(HoermannHcpPosition, TargetArmedWhileMovingTheOtherWayWaitsForTheTurnaround) { + TestableHoermannHcp door; + connect(door); + // The door is closing, passing 60/200 = 0.3. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); + ASSERT_EQ(door.get_door_state(), DoorState::CLOSING); + + door.set_position(0.5f); + EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released + + // Still closing at 58/200 = 0.29: below the target, but not on the way to it. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003A, 0x0200})); + EXPECT_EQ(poll_command(door), 0x0000); + + // Now opening at 62/200 = 0.31, still short of the target. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100})); + EXPECT_EQ(poll_command(door), 0x0000); + + // Past the target at 110/200 = 0.55, so the door is stopped. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100})); + EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed +} + +// A motor turning around can report a momentary stop; dropping the target there would let the door run on +// to the end stop that the reversing command asked for. +TEST(HoermannHcpPosition, MomentaryStopWhileTurningAroundKeepsTheTarget) { + TestableHoermannHcp door; + connect(door); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); + ASSERT_EQ(door.get_door_state(), DoorState::CLOSING); + + door.set_position(0.5f); + EXPECT_EQ(poll_command(door), 0x0210); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door), 0x0110); + + // The stop reported on the way from closing to opening. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0000})); + ASSERT_EQ(door.get_door_state(), DoorState::STOPPED); + + // The door then opens and still has to be stopped at the requested position. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100})); + EXPECT_EQ(poll_command(door), 0x0000); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100})); + EXPECT_EQ(poll_command(door), 0x0240); +} + +// A door that never turns around has to lose the target as well, otherwise it would cut a later move short. +TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorNeverTurnsAround) { + TestableHoermannHcp door; + door.connection_timeout_ms_ = 200; + connect(door); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); + ASSERT_EQ(door.get_door_state(), DoorState::CLOSING); + + door.set_position(0.5f); + EXPECT_EQ(poll_command(door), 0x0210); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door), 0x0110); + + std::this_thread::sleep_for(std::chrono::milliseconds(220)); + // The door ignored the command and closed all the way. Its broadcast keeps the connection alive, so the + // target is the only thing that may expire here. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000})); + door.update(); + ASSERT_TRUE(door.is_valid()); + ASSERT_EQ(door.get_door_state(), DoorState::CLOSED); + + // A later manual open must run freely instead of being stopped at the abandoned target. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100})); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100})); + EXPECT_EQ(poll_command(door), 0x0000); +} + +} // namespace esphome::hoermann_hcp diff --git a/tests/components/hoermann_hcp/test.esp32-idf.yaml b/tests/components/hoermann_hcp/test.esp32-idf.yaml new file mode 100644 index 0000000000..ce3aa2437a --- /dev/null +++ b/tests/components/hoermann_hcp/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + modbus_server: !include ../../test_build_components/common/modbus_server/esp32-idf.yaml + hoermann_hcp: !include common.yaml diff --git a/tests/components/hoermann_hcp/test.esp8266-ard.yaml b/tests/components/hoermann_hcp/test.esp8266-ard.yaml new file mode 100644 index 0000000000..8f7ba81b5b --- /dev/null +++ b/tests/components/hoermann_hcp/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + modbus_server: !include ../../test_build_components/common/modbus_server/esp8266-ard.yaml + hoermann_hcp: !include common.yaml diff --git a/tests/test_build_components/common/README.md b/tests/test_build_components/common/README.md index a3c6f476e0..010313db7f 100644 --- a/tests/test_build_components/common/README.md +++ b/tests/test_build_components/common/README.md @@ -31,11 +31,14 @@ common/ │ ├── esp32-c3-idf.yaml │ ├── esp8266-ard.yaml │ └── rp2040-ard.yaml -├── modbus/ # Modbus (includes uart via packages) +├── modbus/ # Modbus client (includes uart via packages) │ ├── esp32-idf.yaml │ ├── esp32-c3-idf.yaml │ ├── esp8266-ard.yaml │ └── rp2040-ard.yaml +├── modbus_server/ # Modbus server (includes uart via packages) +│ ├── esp32-idf.yaml +│ └── esp8266-ard.yaml └── ble/ ├── esp32-idf.yaml ├── esp32-ard.yaml diff --git a/tests/test_build_components/common/modbus_server/esp32-idf.yaml b/tests/test_build_components/common/modbus_server/esp32-idf.yaml new file mode 100644 index 0000000000..093467ebfd --- /dev/null +++ b/tests/test_build_components/common/modbus_server/esp32-idf.yaml @@ -0,0 +1,10 @@ +# Common server-role Modbus configuration for ESP32 IDF tests +# Provides a shared Modbus bus that all Modbus server components can use + +packages: + uart: !include ../uart/esp32-idf.yaml + +modbus: + - id: modbus_server_bus + uart_id: uart_bus + role: server diff --git a/tests/test_build_components/common/modbus_server/esp8266-ard.yaml b/tests/test_build_components/common/modbus_server/esp8266-ard.yaml new file mode 100644 index 0000000000..ab9cad8b56 --- /dev/null +++ b/tests/test_build_components/common/modbus_server/esp8266-ard.yaml @@ -0,0 +1,10 @@ +# Common server-role Modbus configuration for ESP8266 Arduino tests +# Provides a shared Modbus bus that all Modbus server components can use + +packages: + uart: !include ../uart/esp8266-ard.yaml + +modbus: + - id: modbus_server_bus + uart_id: uart_bus + role: server From c8d2c3691a1a9fd45f958e64c89b27fcbba0ba80 Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Tue, 11 Aug 2026 00:58:37 +0200 Subject: [PATCH 1372/1815] [mitsubishi_cn105] Add vertical vane direction select (#16723) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../mitsubishi_cn105_climate.cpp | 4 +- .../mitsubishi_cn105_component.h | 6 + .../mitsubishi_cn105/select/__init__.py | 47 ++++++++ .../mitsubishi_cn105_vane_select_vertical.cpp | 39 ++++++ .../mitsubishi_cn105_vane_select_vertical.h | 21 ++++ .../climate/mitsubishi_cn105_tests.cpp | 60 +++++----- tests/components/mitsubishi_cn105/common.yaml | 6 + ...bishi_cn105_vane_select_vertical_tests.cpp | 111 ++++++++++++++++++ 8 files changed, 261 insertions(+), 33 deletions(-) create mode 100644 esphome/components/mitsubishi_cn105/select/__init__.py create mode 100644 esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.cpp create mode 100644 esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.h create mode 100644 tests/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical_tests.cpp diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp index 13e02668d1..197e1e1bb5 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp @@ -133,9 +133,7 @@ void MitsubishiCN105Climate::control(const climate::ClimateCall &call) { } } - if (this->parent_->is_status_initialized()) { - this->apply_values_(); - } + this->parent_->publish_status(); } void MitsubishiCN105Climate::apply_values_() { diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h index 2319ea7c54..1caf779f40 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h @@ -38,6 +38,12 @@ class MitsubishiCN105Component : public Component, public uart::UARTDevice { this->status_callback_.add(std::forward(callback)); } + void publish_status() { + if (this->is_status_initialized()) { + this->status_callback_.call(); + } + } + protected: MitsubishiCN105 hp_; CallbackManager status_callback_; diff --git a/esphome/components/mitsubishi_cn105/select/__init__.py b/esphome/components/mitsubishi_cn105/select/__init__.py new file mode 100644 index 0000000000..a2e0353f85 --- /dev/null +++ b/esphome/components/mitsubishi_cn105/select/__init__.py @@ -0,0 +1,47 @@ +import esphome.codegen as cg +from esphome.components import select +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +from .. import ( + MITSUBISHI_CN105_DEVICE_SCHEMA, + MitsubishiCN105Component, + mitsubishi_ns, + register_mitsubishi_cn105_device, +) + +DEPENDENCIES = ["mitsubishi_cn105"] + +CONF_VERTICAL_VANE_DIRECTION = "vertical_vane_direction" + +# The insertion order must match VALUES in mitsubishi_cn105_vane_select_vertical.cpp. +VERTICAL_VANE_DIRECTIONS = ["Auto", "1", "2", "3", "4", "5", "Swing"] + +MitsubishiCN105VerticalVaneDirectionSelect = mitsubishi_ns.class_( + "MitsubishiCN105VerticalVaneDirectionSelect", + select.Select, + cg.Component, + cg.Parented.template(MitsubishiCN105Component), +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.Optional(CONF_VERTICAL_VANE_DIRECTION): select.select_schema( + MitsubishiCN105VerticalVaneDirectionSelect, + icon="mdi:arrow-up-down", + ), + } +).extend(MITSUBISHI_CN105_DEVICE_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + if vertical_vane_direction := config.get(CONF_VERTICAL_VANE_DIRECTION): + var = cg.new_Pvariable(vertical_vane_direction[CONF_ID]) + await cg.register_component(var, vertical_vane_direction) + await select.register_select( + var, + vertical_vane_direction, + options=VERTICAL_VANE_DIRECTIONS, + ) + await register_mitsubishi_cn105_device(var, config) diff --git a/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.cpp b/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.cpp new file mode 100644 index 0000000000..0f9142fe5e --- /dev/null +++ b/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.cpp @@ -0,0 +1,39 @@ +#include "mitsubishi_cn105_vane_select_vertical.h" + +#include + +namespace esphome::mitsubishi_cn105 { + +// NOTE: This order must match VERTICAL_VANE_DIRECTIONS in select.py. +// MitsubishiCN105VerticalVaneDirectionSelect uses the preferred index-based +// Select API, so Python option order and this array must stay aligned. +static constexpr std::array VALUES{ + MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::VaneMode::POSITION_1, MitsubishiCN105::VaneMode::POSITION_2, + MitsubishiCN105::VaneMode::POSITION_3, MitsubishiCN105::VaneMode::POSITION_4, MitsubishiCN105::VaneMode::POSITION_5, + MitsubishiCN105::VaneMode::SWING, +}; + +void MitsubishiCN105VerticalVaneDirectionSelect::setup() { + this->parent_->add_on_status_callback([this]() { this->publish_vane_state(this->parent_->status().vane_mode); }); + if (this->parent_->is_status_initialized()) { + this->publish_vane_state(this->parent_->status().vane_mode); + } +} + +void MitsubishiCN105VerticalVaneDirectionSelect::control(size_t index) { + if (index < VALUES.size()) { + this->parent_->set_vane_mode(VALUES[index]); + this->parent_->publish_status(); + } +} + +void MitsubishiCN105VerticalVaneDirectionSelect::publish_vane_state(MitsubishiCN105::VaneMode mode) { + for (size_t i = 0; i < VALUES.size(); ++i) { + if (VALUES[i] == mode) { + this->publish_state(i); + return; + } + } +} + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.h b/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.h new file mode 100644 index 0000000000..76977d59d7 --- /dev/null +++ b/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.h @@ -0,0 +1,21 @@ +#pragma once + +#include "../mitsubishi_cn105_component.h" + +#include "esphome/components/select/select.h" +#include "esphome/core/component.h" + +namespace esphome::mitsubishi_cn105 { + +class MitsubishiCN105VerticalVaneDirectionSelect : public select::Select, + public Component, + public Parented { + public: + void setup() override; + void publish_vane_state(MitsubishiCN105::VaneMode mode); + + protected: + void control(size_t index) override; +}; + +} // namespace esphome::mitsubishi_cn105 diff --git a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp index 7703b02fcd..28fdfbb313 100644 --- a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp +++ b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp @@ -2,16 +2,16 @@ namespace esphome::mitsubishi_cn105::testing { -struct TestContext { +struct MitsubishiCN105TestsContext { MockUARTComponent uart; uart::UARTDevice device{&uart}; TestableMitsubishiCN105 sut{device}; - TestContext() { this->sut.set_current_time(0); } + MitsubishiCN105TestsContext() { this->sut.set_current_time(0); } }; TEST(MitsubishiCN105Tests, InitSendsConnectPacket) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_current_time(123); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::NOT_CONNECTED); @@ -26,7 +26,7 @@ TEST(MitsubishiCN105Tests, InitSendsConnectPacket) { } TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.initialize(); ctx.uart.tx.clear(); // Remove first connect packet bytes @@ -106,7 +106,7 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { } TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.initialize(); ctx.uart.tx.clear(); // Remove first connect packet bytes @@ -133,7 +133,7 @@ TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) { } TEST(MitsubishiCN105Tests, RxWatchdogLimitsProcessingPerUpdate) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.initialize(); ctx.uart.tx.clear(); // Remove first connect packet bytes @@ -164,7 +164,7 @@ TEST(MitsubishiCN105Tests, RxWatchdogLimitsProcessingPerUpdate) { } TEST(MitsubishiCN105Tests, ParserHandlesMixedRxStream) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.initialize(); ctx.uart.tx.clear(); // Remove first connect packet bytes @@ -228,7 +228,7 @@ TEST(MitsubishiCN105Tests, ParserHandlesMixedRxStream) { } TEST(MitsubishiCN105Tests, NextStatusUpdateAfterUpdateIntervalMilliseconds) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_update_interval(2000); ctx.sut.set_current_time(80000); @@ -258,7 +258,7 @@ TEST(MitsubishiCN105Tests, NextStatusUpdateAfterUpdateIntervalMilliseconds) { } TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedA) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.uart.push_rx( {0xFC, 0x62, 0x01, 0x30, 0x0C, 0x02, 0x00, 0x00, 0x01, 0x03, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x55}); @@ -273,7 +273,7 @@ TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedA) { } TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedB) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.uart.push_rx( {0xFC, 0x62, 0x01, 0x30, 0x0C, 0x02, 0x00, 0x00, 0x00, 0x07, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0xA5, 0xAD}); @@ -288,7 +288,7 @@ TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedB) { } TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedA) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x07, 0x03, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x5D}); @@ -298,7 +298,7 @@ TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedA) { } TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedB) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x07, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0xBC, 0xA7}); @@ -308,7 +308,7 @@ TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedB) { } TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitNotSet) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x58}); @@ -320,7 +320,7 @@ TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitNotSet) { } TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitSet) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x83, 0x00, 0x00, 0x00, 0x00, 0x00, 0xD8}); @@ -332,7 +332,7 @@ TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitSet) { } TEST(MitsubishiCN105Tests, ApplySettingsPowerOn) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_power(true); ctx.sut.apply_settings(); @@ -342,7 +342,7 @@ TEST(MitsubishiCN105Tests, ApplySettingsPowerOn) { } TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedA) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_target_temperature(23.0f); ctx.sut.apply_settings(); @@ -352,7 +352,7 @@ TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedA) { } TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedB) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.use_temperature_encoding_b_ = true; ctx.sut.set_target_temperature(26.0f); @@ -363,7 +363,7 @@ TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedB) { } TEST(MitsubishiCN105Tests, ApplySettingsHalfDegreeTemperatureEncodedB) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.use_temperature_encoding_b_ = true; ctx.sut.set_target_temperature(26.5f); @@ -374,7 +374,7 @@ TEST(MitsubishiCN105Tests, ApplySettingsHalfDegreeTemperatureEncodedB) { } TEST(MitsubishiCN105Tests, ApplyModeCool) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_mode(MitsubishiCN105::Mode::COOL); ctx.sut.apply_settings(); @@ -384,7 +384,7 @@ TEST(MitsubishiCN105Tests, ApplyModeCool) { } TEST(MitsubishiCN105Tests, ApplyFanModeSpeed1) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_fan_mode(MitsubishiCN105::FanMode::SPEED_1); ctx.sut.apply_settings(); @@ -394,7 +394,7 @@ TEST(MitsubishiCN105Tests, ApplyFanModeSpeed1) { } TEST(MitsubishiCN105Tests, ApplyVaneModeSwing) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_vane_mode(MitsubishiCN105::VaneMode::SWING); ctx.sut.apply_settings(); @@ -404,7 +404,7 @@ TEST(MitsubishiCN105Tests, ApplyVaneModeSwing) { } TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitNotSet) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_wide_vane_mode(MitsubishiCN105::WideVaneMode::LEFT); ctx.sut.apply_settings(); @@ -414,7 +414,7 @@ TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitNotSet) { } TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitSet) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_wide_vane_high_bit_ = true; ctx.sut.set_wide_vane_mode(MitsubishiCN105::WideVaneMode::LEFT); @@ -425,7 +425,7 @@ TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitSet) { } TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_update_interval(2000); ctx.sut.set_current_time(5000); @@ -470,7 +470,7 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) { } TEST(MitsubishiCN105Tests, SetAndClearRemoteRoomTemp) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; // Set remote temperature ctx.sut.set_remote_temperature(28.5f); @@ -505,7 +505,7 @@ TEST(MitsubishiCN105Tests, SetAndClearRemoteRoomTemp) { } TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; // Queue normal settings plus remote temperature together. ctx.sut.use_temperature_encoding_b_ = true; @@ -545,7 +545,7 @@ TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) { } TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_update_interval(2000); ctx.sut.set_current_time(5000); @@ -578,7 +578,7 @@ TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect) } TEST(MitsubishiCN105Tests, SetOutOfRangeRemoteRoomTempIsIgnored) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_remote_temperature(7.0f); EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); @@ -591,13 +591,13 @@ TEST(MitsubishiCN105Tests, SetOutOfRangeRemoteRoomTempIsIgnored) { } TEST(MitsubishiCN105Tests, SetMinRemoteRoomTemp) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_remote_temperature(8.0f); EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); } TEST(MitsubishiCN105Tests, SetMaxRemoteRoomTemp) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_remote_temperature(39.5f); EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); } diff --git a/tests/components/mitsubishi_cn105/common.yaml b/tests/components/mitsubishi_cn105/common.yaml index 5966523b34..12a3b8ce9d 100644 --- a/tests/components/mitsubishi_cn105/common.yaml +++ b/tests/components/mitsubishi_cn105/common.yaml @@ -10,6 +10,12 @@ climate: name: "AC Test" supported_swing_modes: BOTH +select: + - platform: mitsubishi_cn105 + mitsubishi_cn105_id: ac + vertical_vane_direction: + name: "Vertical Vane" + esphome: on_boot: then: diff --git a/tests/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical_tests.cpp b/tests/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical_tests.cpp new file mode 100644 index 0000000000..4c980d69d8 --- /dev/null +++ b/tests/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical_tests.cpp @@ -0,0 +1,111 @@ +#include "../common.h" +#include "esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.h" + +namespace esphome::mitsubishi_cn105::testing { + +class TestableMitsubishiCN105Component : public MitsubishiCN105Component { + public: + MitsubishiCN105::Status &mutable_status() { return const_cast(this->status()); } + + void notify_status() { this->status_callback_.call(); } +}; + +class TestableMitsubishiCN105VerticalVaneDirectionSelect : public MitsubishiCN105VerticalVaneDirectionSelect { + public: + using MitsubishiCN105VerticalVaneDirectionSelect::control; +}; + +struct VerticalVaneDirectionSelectTestContext { + TestableMitsubishiCN105Component hub; + TestableMitsubishiCN105VerticalVaneDirectionSelect select; + + VerticalVaneDirectionSelectTestContext() { + this->select.traits.set_options({"Auto", "1", "2", "3", "4", "5", "Swing"}); + this->select.set_parent(&this->hub); + this->select.setup(); + } +}; + +TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, MapsIndexesToVaneModes) { + VerticalVaneDirectionSelectTestContext ctx; + + constexpr std::array expected_modes{ + MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::VaneMode::POSITION_1, + MitsubishiCN105::VaneMode::POSITION_2, MitsubishiCN105::VaneMode::POSITION_3, + MitsubishiCN105::VaneMode::POSITION_4, MitsubishiCN105::VaneMode::POSITION_5, + MitsubishiCN105::VaneMode::SWING, + }; + + for (size_t i = 0; i < expected_modes.size(); ++i) { + SCOPED_TRACE(i); + ctx.select.control(i); + EXPECT_EQ(ctx.hub.status().vane_mode, expected_modes[i]); + } +} + +TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, PublishesIncomingVaneModes) { + VerticalVaneDirectionSelectTestContext ctx; + + constexpr std::array modes{ + MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::VaneMode::POSITION_1, + MitsubishiCN105::VaneMode::POSITION_2, MitsubishiCN105::VaneMode::POSITION_3, + MitsubishiCN105::VaneMode::POSITION_4, MitsubishiCN105::VaneMode::POSITION_5, + MitsubishiCN105::VaneMode::SWING, + }; + + for (size_t i = 0; i < modes.size(); ++i) { + SCOPED_TRACE(i); + ctx.hub.mutable_status().vane_mode = modes[i]; + ctx.hub.notify_status(); + EXPECT_EQ(ctx.select.active_index(), std::optional{i}); + } + + ctx.hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN; + ctx.hub.notify_status(); + EXPECT_EQ(ctx.select.active_index(), std::optional{modes.size() - 1}); +} + +TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ControlPublishesSelectAndClimateThroughHub) { + VerticalVaneDirectionSelectTestContext ctx; + MitsubishiCN105Climate climate_entity; + climate_entity.set_parent(&ctx.hub); + climate_entity.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL); + + ctx.hub.mutable_status().room_temperature = 20.0f; + climate_entity.setup(); + + ctx.select.control(6); + EXPECT_EQ(ctx.select.active_index(), std::optional{6}); + EXPECT_EQ(climate_entity.swing_mode, climate::CLIMATE_SWING_VERTICAL); + + ctx.select.control(3); + EXPECT_EQ(ctx.select.active_index(), std::optional{3}); + EXPECT_EQ(climate_entity.swing_mode, climate::CLIMATE_SWING_OFF); +} + +TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ClimateControlPublishesSelectThroughHub) { + VerticalVaneDirectionSelectTestContext ctx; + MitsubishiCN105Climate climate_entity; + climate_entity.set_parent(&ctx.hub); + climate_entity.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL); + + ctx.hub.mutable_status().room_temperature = 20.0f; + climate_entity.setup(); + + climate_entity.make_call().set_swing_mode(climate::CLIMATE_SWING_VERTICAL).perform(); + EXPECT_EQ(ctx.select.active_index(), std::optional{6}); + + climate_entity.make_call().set_swing_mode(climate::CLIMATE_SWING_OFF).perform(); + EXPECT_EQ(ctx.select.active_index(), std::optional{0}); +} + +TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, BeforeInitializationDoesNotPublishSelectState) { + VerticalVaneDirectionSelectTestContext ctx; + + ctx.select.control(3); + + EXPECT_EQ(ctx.hub.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_3); + EXPECT_FALSE(ctx.select.has_state()); +} + +} // namespace esphome::mitsubishi_cn105::testing From 2a0f2d59f0160700abd6912a883cfd4368aeaccd Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Mon, 10 Aug 2026 16:13:12 -0700 Subject: [PATCH 1373/1815] [modbus_server] Add coil/discrete-input support (#17464) Co-authored-by: Claude Fable 5 --- esphome/components/modbus_server/__init__.py | 54 +++++- esphome/components/modbus_server/const.py | 1 + .../modbus_server/modbus_server.cpp | 101 +++++++++- .../components/modbus_server/modbus_server.h | 47 ++++- .../modbus_server/test_modbus_server.py | 23 ++- tests/components/modbus_server/common.yaml | 10 + .../modbus_server/modbus_server_test.cpp | 182 +++++++++++++++++- .../uart_mock_modbus_client_typed.yaml | 6 +- ...rt_mock_modbus_server_controller_bits.yaml | 147 ++++++++++++++ tests/integration/state_utils.py | 10 +- tests/integration/test_uart_mock_modbus.py | 73 ++++++- 11 files changed, 632 insertions(+), 22 deletions(-) create mode 100644 tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml diff --git a/esphome/components/modbus_server/__init__.py b/esphome/components/modbus_server/__init__.py index 14f4ca8a4d..16b956d7b5 100644 --- a/esphome/components/modbus_server/__init__.py +++ b/esphome/components/modbus_server/__init__.py @@ -12,6 +12,7 @@ from esphome.types import ConfigType from .const import ( CONF_ALLOW_PARTIAL_READ, + CONF_BITS, CONF_COURTESY_RESPONSE, CONF_READ_LAMBDA, CONF_REGISTER_LAST_ADDRESS, @@ -34,6 +35,7 @@ ModbusServer = modbus_server_ns.class_( ServerCourtesyResponse = modbus_server_ns.struct("ServerCourtesyResponse") ServerRegister = modbus_server_ns.struct("ServerRegister") +ServerBit = modbus_server_ns.class_("ServerBit") SERVER_COURTESY_RESPONSE_SCHEMA = cv.Schema( { @@ -64,6 +66,32 @@ ModbusServerRegisterSchema = cv.Schema( ) +ModbusServerBitSchema = cv.Schema( + { + cv.GenerateID(): cv.declare_id(ServerBit), + cv.Required(CONF_ADDRESS): cv.hex_uint16_t, + cv.Required(CONF_READ_LAMBDA): cv.returning_lambda, + cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, + } +) + + +def _validate_unique_bit_addresses(config: ConfigType) -> ConfigType: + # Coils and discrete inputs share one bit address space (like holding/input registers share the + # register table), so each bit address may appear only once. + seen: set[int] = set() + for bit in config.get(CONF_BITS, []): + address = bit[CONF_ADDRESS] + if address in seen: + raise cv.Invalid( + f"Bit address 0x{address:04X} is configured more than once; coils and discrete " + "inputs share one bit address space, so each address must be unique", + path=[CONF_BITS], + ) + seen.add(address) + return config + + def _validate_register_ranges(config: ConfigType) -> ConfigType: # Each register occupies [address, address + register_count); the whole span must fit inside the 16-bit # Modbus address space (0x0000-0xFFFF). @@ -107,10 +135,12 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_REGISTERS, ): cv.ensure_list(ModbusServerRegisterSchema), + cv.Optional(CONF_BITS): cv.ensure_list(ModbusServerBitSchema), } ).extend(modbus.modbus_device_schema(0x01, role="server")), _validate_register_ranges, _validate_no_overlapping_registers, + _validate_unique_bit_addresses, ) @@ -152,7 +182,7 @@ async def to_code(config): await cg.process_lambda( server_register[CONF_READ_LAMBDA], [(cg.uint16, "address")], - return_type=cpp_type, + return_type=cg.optional.template(cpp_type), ), ) ) @@ -170,5 +200,27 @@ async def to_code(config): if server_register[CONF_ALLOW_PARTIAL_READ]: cg.add(server_register_var.set_allow_partial_read(True)) cg.add(var.add_server_register(server_register_var)) + for server_bit in config.get(CONF_BITS, []): + server_bit_var = cg.new_Pvariable(server_bit[CONF_ID], server_bit[CONF_ADDRESS]) + cg.add( + server_bit_var.set_read_lambda( + await cg.process_lambda( + server_bit[CONF_READ_LAMBDA], + [(cg.uint16, "address")], + return_type=cg.optional.template(cg.bool_), + ) + ) + ) + if (write_lambda := server_bit.get(CONF_WRITE_LAMBDA)) is not None: + cg.add( + server_bit_var.set_write_lambda( + await cg.process_lambda( + write_lambda, + parameters=[(cg.uint16, "address"), (cg.bool_, "x")], + return_type=cg.bool_, + ) + ) + ) + cg.add(var.add_server_bit(server_bit_var)) await cg.register_component(var, config) return await modbus.register_modbus_server_device(var, config) diff --git a/esphome/components/modbus_server/const.py b/esphome/components/modbus_server/const.py index f2a8c53f45..86366c7ce0 100644 --- a/esphome/components/modbus_server/const.py +++ b/esphome/components/modbus_server/const.py @@ -5,4 +5,5 @@ CONF_COURTESY_RESPONSE = "courtesy_response" CONF_READ_LAMBDA = "read_lambda" CONF_WRITE_LAMBDA = "write_lambda" CONF_REGISTERS = "registers" +CONF_BITS = "bits" CONF_ALLOW_PARTIAL_READ = "allow_partial_read" diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp index e63495cb25..feb0e67725 100644 --- a/esphome/components/modbus_server/modbus_server.cpp +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -33,6 +33,12 @@ modbus::ResponseStatus ModbusServer::on_read_registers(uint16_t start_address, u "Received read holding/input registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%X.", this->address_, start_address, number_of_registers); + // No registers configured (e.g. a bits-only server) and no courtesy default: this device does not implement + // the register-read function, so answer ILLEGAL_FUNCTION. A populated map with a wrong address answers + // ILLEGAL_DATA_ADDRESS below. + if (this->server_registers_.empty() && !this->server_courtesy_response_.enabled) + return ExceptionCode::ILLEGAL_FUNCTION; + const uint32_t end_address = static_cast(start_address) + number_of_registers; uint32_t current_address = start_address; while (current_address < end_address) { @@ -75,7 +81,13 @@ modbus::ResponseStatus ModbusServer::on_read_registers(uint16_t start_address, u return ExceptionCode::ILLEGAL_DATA_ADDRESS; } - int64_t value = server_register->read_lambda(); + const optional read_value = server_register->read_lambda(); + if (!read_value.has_value()) { + ESP_LOGW(TAG, "Register read at 0x%04X declined to produce a value. Sending exception response.", + server_register->address); + return ExceptionCode::SERVICE_DEVICE_FAILURE; + } + const int64_t value = *read_value; char value_buf[ServerRegister::FORMAT_VALUE_BUF_SIZE]; ESP_LOGV(TAG, "Matched register. Address: 0x%02X. Value type: %zu. Register count: %u. Value: %s.", server_register->address, static_cast(server_register->value_type), @@ -106,6 +118,11 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address, ESP_LOGV(TAG, "Received write registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%zX.", this->address_, start_address, registers.size()); + // No registers configured (e.g. a bits-only server): this device does not implement the register-write + // function, so answer ILLEGAL_FUNCTION rather than ILLEGAL_DATA_ADDRESS. + if (this->server_registers_.empty()) + return ExceptionCode::ILLEGAL_FUNCTION; + auto for_each_register = [this, start_address, ®isters](const std::function &callback) -> bool { @@ -167,6 +184,83 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address, return {}; } +ServerBit *ModbusServer::find_bit_(uint16_t address) const { + for (auto *server_bit : this->server_bits_) { + if (server_bit->address == address) { + return server_bit; + } + } + return nullptr; +} + +modbus::ResponseStatus ModbusServer::on_read_bits(uint16_t start_address, modbus::MutablePackedBits bits) { + ESP_LOGV(TAG, "Received read coils/discrete inputs for device 0x%X. Start address: 0x%X. Count: 0x%X.", + this->address_, start_address, bits.size()); + + // No bits configured: this device does not implement the coil/discrete-input function, so answer + // ILLEGAL_FUNCTION. A populated table with a wrong address answers ILLEGAL_DATA_ADDRESS below. + if (this->server_bits_.empty()) + return ExceptionCode::ILLEGAL_FUNCTION; + + for (uint16_t i = 0; i < bits.size(); i++) { + const uint16_t address = static_cast(start_address + i); // range pre-checked by the hub + ServerBit *server_bit = this->find_bit_(address); + if (server_bit == nullptr || !server_bit->read_lambda) { + ESP_LOGW(TAG, "No readable bit at 0x%04X. Sending exception response.", address); + return ExceptionCode::ILLEGAL_DATA_ADDRESS; + } + const optional value = server_bit->read_lambda(address); + if (!value.has_value()) { + ESP_LOGW(TAG, "Bit read at 0x%04X declined to produce a value. Sending exception response.", address); + return ExceptionCode::SERVICE_DEVICE_FAILURE; + } + bits.set(i, *value); + } + return {}; +} + +modbus::ResponseStatus ModbusServer::on_write_coils(uint16_t start_address, modbus::PackedBits bits) { + ESP_LOGV(TAG, "Received write coils for device 0x%X. Start address: 0x%X. Count: 0x%X.", this->address_, + start_address, bits.size()); + + // No bits configured: this device does not implement the coil function, so answer ILLEGAL_FUNCTION rather + // than ILLEGAL_DATA_ADDRESS. + if (this->server_bits_.empty()) + return ExceptionCode::ILLEGAL_FUNCTION; + + // Pre-flight: every targeted bit must exist and be writable, so we never apply a partial write + // before discovering a problem (mirrors the register write's two passes). + for (uint16_t i = 0; i < bits.size(); i++) { + const uint16_t address = static_cast(start_address + i); + ServerBit *server_bit = this->find_bit_(address); + if (server_bit == nullptr || !server_bit->write_lambda) { + // Only VERBOSE: one handler serves both addressed and broadcast writes, and rejecting a broadcast for + // bits this device does not map is routine. The hub logs the outcome with the context it has. + ESP_LOGV(TAG, "No writable bit at 0x%04X; write request rejected before applying any bit.", address); + return ExceptionCode::ILLEGAL_DATA_ADDRESS; + } + } + + // Commit: the pre-flight above proved every address resolves to a writable bit. Re-resolve here rather + // than caching up to MAX_NUM_OF_COILS_TO_WRITE pointers (a per-request heap allocation), matching the + // register write's two-pass shape -- but guard the pointer anyway, so a future change to the pre-flight + // can never turn this into a silent null dereference. The only expected failure is a write callback + // rejecting the value at runtime, which cannot be rolled back. + for (uint16_t i = 0; i < bits.size(); i++) { + const uint16_t address = static_cast(start_address + i); + ServerBit *server_bit = this->find_bit_(address); + if (server_bit == nullptr || !server_bit->write_lambda) { + ESP_LOGE(TAG, "Bit at 0x%04X unresolved between pre-flight and commit; aborting write.", address); + return ExceptionCode::SERVICE_DEVICE_FAILURE; + } + if (!server_bit->write_lambda(address, bits[i])) { + ESP_LOGW(TAG, "Bit write callback failed at 0x%04X mid-sequence; earlier writes were already applied.", address); + return ExceptionCode::SERVICE_DEVICE_FAILURE; + } + } + return {}; +} + void ModbusServer::dump_config() { ESP_LOGCONFIG(TAG, "ModbusServer:\n" @@ -184,6 +278,11 @@ void ModbusServer::dump_config() { ESP_LOGCONFIG(TAG, " Address=0x%02X value_type=%u register_count=%u", r->address, static_cast(r->value_type), r->register_count); } + ESP_LOGCONFIG(TAG, "server bits"); + for (auto &b : this->server_bits_) { + ESP_LOGCONFIG(TAG, " Address=0x%04X readable=%s writable=%s", b->address, b->read_lambda ? "true" : "false", + b->write_lambda ? "true" : "false"); + } #endif } diff --git a/esphome/components/modbus_server/modbus_server.h b/esphome/components/modbus_server/modbus_server.h index f6484d8e6b..22903abfad 100644 --- a/esphome/components/modbus_server/modbus_server.h +++ b/esphome/components/modbus_server/modbus_server.h @@ -20,7 +20,7 @@ struct ServerCourtesyResponse { }; class ServerRegister { - using ReadLambda = std::function; + using ReadLambda = std::function()>; using WriteLambda = std::function; public: @@ -30,13 +30,18 @@ class ServerRegister { 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); + /// The user lambda returns optional: an empty optional declines the read, answering the whole + /// request with a SERVICE_DEVICE_FAILURE exception. Plain values convert implicitly. + template void set_read_lambda(const std::function(uint16_t address)> &&user_read_lambda) { + this->read_lambda = [this, user_read_lambda]() -> optional { + const optional user_value = user_read_lambda(this->address); + if (!user_value.has_value()) { + return {}; + } if constexpr (std::is_same_v) { - return bit_cast(user_value); + return bit_cast(*user_value); } else { - return static_cast(user_value); + return static_cast(*user_value); } }; } @@ -97,17 +102,43 @@ class ServerRegister { WriteLambda write_lambda; }; +/// A single bit in the server's coil/discrete-input table. Coils (0x01/0x05/0x0F) and discrete +/// inputs (0x02) share one bit address space, mirroring how holding and input registers share the +/// register table: both read function codes are served from the same bits. +class ServerBit { + /// Returning an empty optional declines the read: the whole request is answered with a + /// SERVICE_DEVICE_FAILURE exception. `return true;`/`return false;` convert implicitly. + using ReadLambda = std::function(uint16_t address)>; + using WriteLambda = std::function; + + public: + explicit ServerBit(uint16_t address) : address(address) {} + void set_read_lambda(ReadLambda &&read_lambda) { this->read_lambda = std::move(read_lambda); } + void set_write_lambda(WriteLambda &&write_lambda) { this->write_lambda = std::move(write_lambda); } + + uint16_t address{0}; + ReadLambda read_lambda; + WriteLambda write_lambda; +}; + class ModbusServer final : public Component, public modbus::ModbusServerDevice { public: void dump_config() 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); } + /// Registers a server bit with the controller. Called by esphomes code generator + void add_server_bit(ServerBit *server_bit) { server_bits_.push_back(server_bit); } /// called when a modbus request (function code 0x03 or 0x04) was parsed without errors modbus::ResponseStatus on_read_registers(uint16_t start_address, uint16_t number_of_registers, modbus::RegisterValues ®isters) final; /// called when a modbus request (function code 0x06 or 0x10) was parsed without errors modbus::ResponseStatus on_write_registers(uint16_t start_address, const modbus::RegisterValues ®isters) final; + /// called when a modbus request (function code 0x01 or 0x02) was parsed without errors; both are + /// served from the same bit table (see ServerBit) + modbus::ResponseStatus on_read_bits(uint16_t start_address, modbus::MutablePackedBits bits) final; + /// called when a modbus request (function code 0x05 or 0x0F) was parsed without errors + modbus::ResponseStatus on_write_coils(uint16_t start_address, modbus::PackedBits bits) 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; @@ -118,8 +149,12 @@ class ModbusServer final : public Component, public modbus::ModbusServerDevice { protected: /// Find the registered value whose register span contains address, or nullptr if none does. ServerRegister *find_containing_register_(uint32_t address) const; + /// Find the registered bit at address, or nullptr if none is. + ServerBit *find_bit_(uint16_t address) const; /// Collection of all server registers for this component std::vector server_registers_{}; + /// Collection of all server bits (coils/discrete inputs) for this component + std::vector server_bits_{}; /// Server courtesy response ServerCourtesyResponse server_courtesy_response_{ .enabled = false, .register_last_address = 0xFFFF, .register_value = 0}; diff --git a/tests/component_tests/modbus_server/test_modbus_server.py b/tests/component_tests/modbus_server/test_modbus_server.py index 3e041c6d4a..ce1098fbca 100644 --- a/tests/component_tests/modbus_server/test_modbus_server.py +++ b/tests/component_tests/modbus_server/test_modbus_server.py @@ -7,8 +7,13 @@ from esphome.components.modbus_server import ( SERVER_SENSOR_VALUE_TYPE, _validate_no_overlapping_registers, _validate_register_ranges, + _validate_unique_bit_addresses, +) +from esphome.components.modbus_server.const import ( + CONF_BITS, + CONF_REGISTERS, + CONF_VALUE_TYPE, ) -from esphome.components.modbus_server.const import CONF_REGISTERS, CONF_VALUE_TYPE from esphome.const import CONF_ADDRESS @@ -21,6 +26,10 @@ def _config(registers: list[tuple[int, str]]) -> dict: } +def _bits_config(addresses: list[int]) -> dict: + return {CONF_BITS: [{CONF_ADDRESS: address} for address in addresses]} + + def test_non_overlapping_registers_pass() -> None: # Values that tile the address space without gaps or overlaps are accepted. config = _config([(0x00, "U_WORD"), (0x01, "U_DWORD"), (0x03, "U_WORD")]) @@ -42,6 +51,18 @@ def test_duplicate_address_rejected() -> None: _validate_no_overlapping_registers(config) +def test_unique_bit_addresses_pass() -> None: + config = _bits_config([0x00, 0x01, 0x02]) + assert _validate_unique_bit_addresses(config) is config + + +def test_duplicate_bit_address_rejected() -> None: + # Coils and discrete inputs share one bit address space, so a repeated address is rejected. + config = _bits_config([0x05, 0x05]) + with pytest.raises(cv.Invalid, match="more than once"): + _validate_unique_bit_addresses(config) + + def test_multi_register_value_overlapping_neighbour_rejected() -> None: # U_DWORD at 0x10 occupies 0x10 and 0x11; a U_WORD at 0x11 collides with its low word. config = _config([(0x10, "U_DWORD"), (0x11, "U_WORD")]) diff --git a/tests/components/modbus_server/common.yaml b/tests/components/modbus_server/common.yaml index 1f3a8f551b..3f84a3f6da 100644 --- a/tests/components/modbus_server/common.yaml +++ b/tests/components/modbus_server/common.yaml @@ -15,6 +15,16 @@ modbus_server: - id: modbus_server3 address: 0x3 modbus_id: mod_bus2 + bits: + - address: 0x0 + read_lambda: |- + return true; + - address: 0x1 + read_lambda: |- + return address == 0x1; + write_lambda: |- + printf("bit address=%d, value=%d\n", (int) address, (int) x); + return true; registers: - address: 0x9 value_type: S_DWORD diff --git a/tests/components/modbus_server/modbus_server_test.cpp b/tests/components/modbus_server/modbus_server_test.cpp index 2137a77f3d..ce39e83736 100644 --- a/tests/components/modbus_server/modbus_server_test.cpp +++ b/tests/components/modbus_server/modbus_server_test.cpp @@ -105,15 +105,29 @@ TEST(ModbusServerWrite, UnwritableRegisterRejected) { EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS); } -// An address with no registered register yields ILLEGAL_DATA_ADDRESS. +// A write to an address not covered by any configured register (on a populated server) yields +// ILLEGAL_DATA_ADDRESS. TEST(ModbusServerWrite, UnmatchedAddressRejected) { ModbusServer server; + ServerRegister reg(0x0000, SensorValueType::U_WORD, 1); + reg.write_lambda = [](int64_t) { return true; }; + server.add_server_register(®); + auto status = server.on_write_registers(0x0005, make_registers({0x1234})); ASSERT_TRUE(status.has_value()); if (status.has_value()) EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS); } +// A server with no registers configured does not implement the register-write function: ILLEGAL_FUNCTION. +TEST(ModbusServerWrite, EmptyServerRejectsWithIllegalFunction) { + ModbusServer server; + auto status = server.on_write_registers(0x0000, make_registers({0x1234})); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_FUNCTION); +} + // A write_lambda failing at runtime is the one non-atomic case: the earlier register is already // applied, and the handler reports SERVICE_DEVICE_FAILURE. TEST(ModbusServerWrite, CallbackFailureIsServiceDeviceFailure) { @@ -248,9 +262,13 @@ TEST(ModbusServerRead, CourtesyDefaultForUnregistered) { EXPECT_EQ(out[1], 0xABCD); } -// An unregistered address with courtesy disabled is rejected. +// An unregistered address on a populated server (courtesy disabled) is rejected with ILLEGAL_DATA_ADDRESS. TEST(ModbusServerRead, UnregisteredRejectedWithoutCourtesy) { ModbusServer server; + ServerRegister reg(0x0000, SensorValueType::U_WORD, 1); + reg.read_lambda = []() -> int64_t { return 0x1234; }; + server.add_server_register(®); + RegisterValues out; auto status = server.on_read_registers(0x0005, 1, out); ASSERT_TRUE(status.has_value()); @@ -258,6 +276,31 @@ TEST(ModbusServerRead, UnregisteredRejectedWithoutCourtesy) { EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS); } +// A server with no registers configured (courtesy disabled) does not implement the register-read +// function: ILLEGAL_FUNCTION. +TEST(ModbusServerRead, EmptyServerRejectsWithIllegalFunction) { + ModbusServer server; + RegisterValues out; + auto status = server.on_read_registers(0x0005, 1, out); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_FUNCTION); +} + +// A register read lambda returning an empty optional declines the read: the whole request is +// answered with SERVICE_DEVICE_FAILURE. Uses set_read_lambda so the optional-forwarding wrapper +// (not a hand-assigned read_lambda) is what carries the decline through. +TEST(ModbusServerRead, ReadLambdaDecliningIsServiceDeviceFailure) { + ModbusServer server; + ServerRegister reg(0x0000, SensorValueType::U_WORD, 1); + reg.set_read_lambda([](uint16_t address) -> optional { return {}; }); + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_read_registers(0x0000, 1, out); + EXPECT_EQ(status, ExceptionCode::SERVICE_DEVICE_FAILURE); +} + // --- partial reads (opt-in) ---------------------------------------------------- // With allow_partial_read, reading only the first register of a DWORD returns its high word. @@ -310,4 +353,139 @@ TEST(ModbusServerRead, PartialReadReversedType) { EXPECT_EQ(second[0], 0x1234); } +// --- bits (coils / discrete inputs, one shared address space) ------------------- + +// Bits are read through the shared table regardless of which read function code arrived: +// the hub routes both 0x01 and 0x02 to on_read_bits(). +TEST(ModbusServerBits, ReadSetsRequestedBits) { + ModbusServer server; + ServerBit bit0(0x0000); + bit0.set_read_lambda([](uint16_t) { return true; }); + ServerBit bit1(0x0001); + bit1.set_read_lambda([](uint16_t) { return false; }); + ServerBit bit2(0x0002); + bit2.set_read_lambda([](uint16_t) { return true; }); + server.add_server_bit(&bit0); + server.add_server_bit(&bit1); + server.add_server_bit(&bit2); + + uint8_t packed[1] = {0}; + auto status = server.on_read_bits(0x0000, modbus::MutablePackedBits(packed, 3)); + EXPECT_FALSE(status.has_value()); + EXPECT_EQ(packed[0], 0b101); +} + +// The read lambda receives the bit's address, so one lambda can serve several bits. +TEST(ModbusServerBits, ReadLambdaReceivesAddress) { + ModbusServer server; + ServerBit server_bit(0x0007); + server_bit.set_read_lambda([](uint16_t address) { return address == 0x0007; }); + server.add_server_bit(&server_bit); + + uint8_t packed[1] = {0}; + auto status = server.on_read_bits(0x0007, modbus::MutablePackedBits(packed, 1)); + EXPECT_FALSE(status.has_value()); + EXPECT_EQ(packed[0], 0x01); +} + +// An unregistered or write-only bit rejects the whole read with ILLEGAL_DATA_ADDRESS. +TEST(ModbusServerBits, UnreadableBitRejectsRead) { + ModbusServer server; + ServerBit readable(0x0000); + readable.set_read_lambda([](uint16_t) { return true; }); + ServerBit write_only(0x0001); + write_only.set_write_lambda([](uint16_t, bool) { return true; }); + server.add_server_bit(&readable); + server.add_server_bit(&write_only); + + uint8_t packed[1] = {0}; + auto status = server.on_read_bits(0x0000, modbus::MutablePackedBits(packed, 2)); + EXPECT_EQ(status, ExceptionCode::ILLEGAL_DATA_ADDRESS); + + auto unregistered = server.on_read_bits(0x0005, modbus::MutablePackedBits(packed, 1)); + EXPECT_EQ(unregistered, ExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +// A read lambda returning an empty optional declines the read: the whole request is answered +// with SERVICE_DEVICE_FAILURE. +TEST(ModbusServerBits, ReadLambdaDecliningIsServiceDeviceFailure) { + ModbusServer server; + ServerBit ok(0x0000); + ok.set_read_lambda([](uint16_t) { return true; }); + ServerBit declining(0x0001); + declining.set_read_lambda([](uint16_t) -> optional { return {}; }); + server.add_server_bit(&ok); + server.add_server_bit(&declining); + + uint8_t packed[1] = {0}; + auto status = server.on_read_bits(0x0000, modbus::MutablePackedBits(packed, 2)); + EXPECT_EQ(status, ExceptionCode::SERVICE_DEVICE_FAILURE); +} + +// A multi-coil write applies every bit and reports success. +TEST(ModbusServerBits, WriteAppliesAllBits) { + ModbusServer server; + bool state[2] = {false, true}; + ServerBit bit0(0x0000); + bit0.set_write_lambda([&state](uint16_t, bool value) { + state[0] = value; + return true; + }); + ServerBit bit1(0x0001); + bit1.set_write_lambda([&state](uint16_t, bool value) { + state[1] = value; + return true; + }); + server.add_server_bit(&bit0); + server.add_server_bit(&bit1); + + const uint8_t packed[1] = {0b01}; // bit0 on, bit1 off + auto status = server.on_write_coils(0x0000, modbus::PackedBits(packed, 2)); + EXPECT_FALSE(status.has_value()); + EXPECT_TRUE(state[0]); + EXPECT_FALSE(state[1]); +} + +// Pre-flight atomicity: an unwritable bit anywhere in the span rejects the write before any +// bit is applied. +TEST(ModbusServerBits, UnwritableBitAppliesNothing) { + ModbusServer server; + bool written = false; + ServerBit writable(0x0000); + writable.set_write_lambda([&written](uint16_t, bool) { + written = true; + return true; + }); + ServerBit read_only(0x0001); + read_only.set_read_lambda([](uint16_t) { return false; }); + server.add_server_bit(&writable); + server.add_server_bit(&read_only); + + const uint8_t packed[1] = {0b11}; + auto status = server.on_write_coils(0x0000, modbus::PackedBits(packed, 2)); + EXPECT_EQ(status, ExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_FALSE(written); // the writable bit must NOT have been applied +} + +// A write lambda failing at runtime is the one non-atomic case: earlier bits stay applied and +// the handler reports SERVICE_DEVICE_FAILURE (mirrors the register behavior). +TEST(ModbusServerBits, CallbackFailureIsServiceDeviceFailure) { + ModbusServer server; + bool first_written = false; + ServerBit first(0x0000); + first.set_write_lambda([&first_written](uint16_t, bool) { + first_written = true; + return true; + }); + ServerBit second(0x0001); + second.set_write_lambda([](uint16_t, bool) { return false; }); // rejects at runtime + server.add_server_bit(&first); + server.add_server_bit(&second); + + const uint8_t packed[1] = {0b11}; + auto status = server.on_write_coils(0x0000, modbus::PackedBits(packed, 2)); + EXPECT_EQ(status, ExceptionCode::SERVICE_DEVICE_FAILURE); + EXPECT_TRUE(first_written); +} + } // namespace esphome::modbus_server diff --git a/tests/integration/fixtures/uart_mock_modbus_client_typed.yaml b/tests/integration/fixtures/uart_mock_modbus_client_typed.yaml index 167ad2c5bb..e445093625 100644 --- a/tests/integration/fixtures/uart_mock_modbus_client_typed.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_client_typed.yaml @@ -133,8 +133,8 @@ button: on_error: then: - lambda: "id(error_code).publish_state((int) exception_code);" - # The mock server is register-only, so a coil read draws ILLEGAL_FUNCTION - proving the bit-read - # action's request PDU and its typed error delivery. + # The mock server maps no bits, so it does not implement the coil function: a coil read draws + # ILLEGAL_FUNCTION - proving the bit-read action's request PDU and its typed error delivery. - modbus_client.read_coils: address: 1 start_address: 0x00 @@ -166,7 +166,7 @@ button: on_not_sent: then: - lambda: "id(not_sent_flag).publish_state(1);" - # Multi-coil write (fc 0x0F): the register-only server answers ILLEGAL_FUNCTION. + # Multi-coil write (fc 0x0F): the server maps no bits, so it answers ILLEGAL_FUNCTION. - modbus_client.write_multiple_coils: address: 1 start_address: 0x00 diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml new file mode 100644 index 0000000000..cb6fc6f074 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml @@ -0,0 +1,147 @@ +esphome: + name: uart-mock-modbus-srv-bits + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + # auto_start must be true for loopback fixtures: the modbus controller + # polls on its update_interval immediately at boot, so the uart_mock + # forwarding must already be active or early requests are lost and + # generate modbus warnings. + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - id: virtual_uart_controller + baud_rate: 9600 + auto_start: true # See comment on virtual_uart_server above + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + +globals: + - id: stored_bit_2 + type: bool + initial_value: "false" + - id: stored_bit_3 + type: bool + initial_value: "true" + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_controller + id: virtual_modbus_controller + role: client + turnaround_time: 10ms + +modbus_controller: + - address: 1 + modbus_id: virtual_modbus_controller + update_interval: 1s + id: modbus_controller_1 + +modbus_server: + - address: 1 + modbus_id: virtual_modbus_server + id: modbus_server_1 + bits: + - address: 0x00 + read_lambda: return true; + - address: 0x01 + read_lambda: return false; + - address: 0x02 + read_lambda: return id(stored_bit_2); + write_lambda: id(stored_bit_2) = x; return true; + - address: 0x03 + read_lambda: return id(stored_bit_3); + write_lambda: id(stored_bit_3) = x; return true; + +# The same four bits are read both as coils (FC 0x01) and as discrete inputs +# (FC 0x02): the server serves both from one shared bit table, so the two +# views must always agree. +binary_sensor: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_coil_0" + address: 0x00 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_coil_1" + address: 0x01 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_coil_2" + address: 0x02 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_coil_3" + address: 0x03 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_di_0" + address: 0x00 + register_type: discrete_input + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_di_1" + address: 0x01 + register_type: discrete_input + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_di_2" + address: 0x02 + register_type: discrete_input + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_di_3" + address: 0x03 + register_type: discrete_input + +# write_bit_2 uses the single-coil write (FC 0x05); write_bit_3 opts into the +# multiple-coils write (FC 0x0F) so both server write paths are exercised. +switch: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_bit_2" + address: 0x02 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_bit_3" + address: 0x03 + register_type: coil + use_write_multiple: true + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/state_utils.py b/tests/integration/state_utils.py index 65af57b944..4d31644559 100644 --- a/tests/integration/state_utils.py +++ b/tests/integration/state_utils.py @@ -387,8 +387,9 @@ class SensorStateCollector: class SensorTracker: """Data-driven sensor state tracker with expected-value futures. - Tracks sensor state updates and resolves futures when sensors report - specific expected values. Eliminates per-sensor future boilerplate. + Tracks sensor and binary sensor state updates and resolves futures when + they report specific expected values. Eliminates per-sensor future + boilerplate. Usage:: @@ -421,7 +422,10 @@ class SensorTracker: def on_state(self, state: EntityState) -> None: """State callback suitable for ``subscribe_states``.""" - if not isinstance(state, SensorState) or state.missing_state: + if ( + not isinstance(state, (SensorState, BinarySensorState)) + or state.missing_state + ): return sensor_name = self.key_to_sensor.get(state.key) if not sensor_name or sensor_name not in self.sensor_states: diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 1994d02c34..17ab21f873 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -21,7 +21,7 @@ import asyncio from collections.abc import Callable from dataclasses import dataclass -from aioesphomeapi import ButtonInfo, NumberInfo +from aioesphomeapi import ButtonInfo, NumberInfo, SwitchInfo import pytest from .state_utils import SensorTracker, find_entity @@ -411,6 +411,68 @@ async def test_uart_mock_modbus_server_controller_write( _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.asyncio +async def test_uart_mock_modbus_server_controller_bits( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test coil/discrete-input round trips between controller and server bits. + + The server serves four bits from one shared table. The controller reads + each of them both as a coil (FC 0x01) and as a discrete input (FC 0x02), + so the two views must always agree. Two bits are then written back, one + via the single-coil write (FC 0x05) and one via the multiple-coils write + (FC 0x0F), and the new values must show up in both read views. + """ + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + initial_values = { + "bit_coil_0": True, + "bit_coil_1": False, + "bit_coil_2": False, + "bit_coil_3": True, + "bit_di_0": True, + "bit_di_1": False, + "bit_di_2": False, + "bit_di_3": True, + } + tracker = SensorTracker(list(initial_values.keys())) + + # Phase 1: expect initial baseline values in both read views + initial_futures = tracker.expect_all(initial_values) + # Phase 2: expect post-write values (registered now so on_state can match them) + written_futures = tracker.expect_all( + { + "bit_coil_2": True, + "bit_di_2": True, + "bit_coil_3": False, + "bit_di_3": False, + } + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + entities = await tracker.setup_and_start_scenario(client) + + # Wait for initial baseline values to confirm the controller <-> server + # connection is working before issuing writes + await tracker.await_all(initial_futures, timeout=4.0) + + # Flip both writable bits: 0x02 false -> true, 0x03 true -> false + for switch_name, value in (("write_bit_2", True), ("write_bit_3", False)): + entity = find_entity(entities, switch_name, SwitchInfo) + assert entity is not None, f"{switch_name} switch entity not found" + client.switch_command(entity.key, value) + + # Wait for both read views to reflect the written values + await tracker.await_all(written_futures, timeout=4.0) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + @pytest.mark.asyncio async def test_uart_mock_modbus_server_controller_multiple( yaml_config: str, @@ -447,10 +509,11 @@ async def test_uart_mock_modbus_client_typed( with the reply decoded by the shared device dispatch into host-order words (values[0] -> typed_value); a read of unserved register 0x99 resolves via on_error with the device's exception code (ILLEGAL_DATA_ADDRESS = 2 -> error_code); a coil read of the register-only server resolves via - on_error with ILLEGAL_FUNCTION (= 1 -> coil_error_code), proving the bit-read request and typed error - delivery. A multi-register write (fc 0x10) lands on registers 0x11/0x12 with the read-back of 0x12 - chained inside its ack handler (-> multi_value = 222); a multi-coil write draws ILLEGAL_FUNCTION from - the register-only server (-> multi_coil_error = 1). A read whose count lambda returns 0 at runtime + on_error with ILLEGAL_FUNCTION (= 1 -> coil_error_code) - the server maps no bits, so it does not + implement the coil function - proving the bit-read request and typed error delivery. A multi-register + write (fc 0x10) lands on registers 0x11/0x12 with the read-back of 0x12 chained inside its ack handler + (-> multi_value = 222); a multi-coil write likewise draws ILLEGAL_FUNCTION from the register-only server + (-> multi_coil_error = 1). A read whose count lambda returns 0 at runtime builds an empty (rejected) PDU, is refused at the hub door, and resolves via on_not_sent (-> not_sent_flag). """ From 007c677da13723a81cc4084ee783631756c72e15 Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Tue, 11 Aug 2026 01:58:34 +0200 Subject: [PATCH 1374/1815] [mitsubishi_cn105] Refactor property encoding/decoding (#16709) Co-authored-by: J. Nick Koston --- .../mitsubishi_cn105/mitsubishi_cn105.cpp | 246 ++------------ .../mitsubishi_cn105/mitsubishi_cn105.h | 31 +- .../mitsubishi_cn105_properties.h | 302 ++++++++++++++++++ .../climate/mitsubishi_cn105_tests.cpp | 42 +-- tests/components/mitsubishi_cn105/common.h | 5 +- 5 files changed, 374 insertions(+), 252 deletions(-) create mode 100644 esphome/components/mitsubishi_cn105/mitsubishi_cn105_properties.h diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index 415de34166..6683a9a25b 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -4,6 +4,7 @@ #include #include #include +#include "mitsubishi_cn105_properties.h" namespace esphome::mitsubishi_cn105 { @@ -11,8 +12,6 @@ static const char *const TAG = "mitsubishi_cn105.driver"; static constexpr uint32_t RESPONSE_TIMEOUT_MS = 2000; -static constexpr uint8_t TARGET_TEMPERATURE_ENC_A_OFFSET = 31; - static constexpr size_t REQUEST_PAYLOAD_LEN = 0x10; static constexpr size_t HEADER_LEN = 5; static constexpr uint8_t PREAMBLE = 0xFC; @@ -31,86 +30,6 @@ static constexpr uint8_t STATUS_MSG_TELEMETRY = 0x03; static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_REQUEST = 0x41; static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_RESPONSE = 0x61; -template struct LookupMap { - using value_type = decltype(Unknown); - static constexpr auto UNKNOWN_VALUE = Unknown; - const std::array table; - - constexpr value_type lookup(uint8_t raw) const { return (raw < N) ? this->table[raw] : UNKNOWN_VALUE; } - - constexpr bool reverse_lookup(value_type value, uint8_t &out) const { - static_assert(N <= std::numeric_limits::max()); - if (value == UNKNOWN_VALUE) { - return false; - } - for (uint8_t i = 0; i < static_cast(N); ++i) { - if (this->table[i] == value) { - out = i; - return true; - } - } - return false; - } - - constexpr bool is_valid(value_type value) const { - uint8_t raw; - return reverse_lookup(value, raw); - } -}; - -template static constexpr auto make_map(const T (&values)[N]) { - return LookupMap{std::to_array(values)}; -} - -static constexpr auto PROTOCOL_MODE_MAP = make_map({ - MitsubishiCN105::Mode::UNKNOWN, // 0x00 - MitsubishiCN105::Mode::HEAT, // 0x01 - MitsubishiCN105::Mode::DRY, // 0x02 - MitsubishiCN105::Mode::COOL, // 0x03 - MitsubishiCN105::Mode::UNKNOWN, // 0x04 - MitsubishiCN105::Mode::UNKNOWN, // 0x05 - MitsubishiCN105::Mode::UNKNOWN, // 0x06 - MitsubishiCN105::Mode::FAN_ONLY, // 0x07 - MitsubishiCN105::Mode::AUTO // 0x08 -}); - -static constexpr auto PROTOCOL_FAN_MODE_MAP = make_map({ - MitsubishiCN105::FanMode::AUTO, // 0x00 - MitsubishiCN105::FanMode::QUIET, // 0x01 - MitsubishiCN105::FanMode::SPEED_1, // 0x02 - MitsubishiCN105::FanMode::SPEED_2, // 0x03 - MitsubishiCN105::FanMode::UNKNOWN, // 0x04 - MitsubishiCN105::FanMode::SPEED_3, // 0x05 - MitsubishiCN105::FanMode::SPEED_4 // 0x06 -}); - -static constexpr auto PROTOCOL_VANE_MODE_MAP = make_map({ - MitsubishiCN105::VaneMode::AUTO, // 0x00 - MitsubishiCN105::VaneMode::POSITION_1, // 0x01 - MitsubishiCN105::VaneMode::POSITION_2, // 0x02 - MitsubishiCN105::VaneMode::POSITION_3, // 0x03 - MitsubishiCN105::VaneMode::POSITION_4, // 0x04 - MitsubishiCN105::VaneMode::POSITION_5, // 0x05 - MitsubishiCN105::VaneMode::UNKNOWN, // 0x06 - MitsubishiCN105::VaneMode::SWING // 0x07 -}); - -static constexpr auto PROTOCOL_WIDE_VANE_MODE_MAP = make_map({ - MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x00 - MitsubishiCN105::WideVaneMode::FAR_LEFT, // 0x01 - MitsubishiCN105::WideVaneMode::LEFT, // 0x02 - MitsubishiCN105::WideVaneMode::CENTER, // 0x03 - MitsubishiCN105::WideVaneMode::RIGHT, // 0x04 - MitsubishiCN105::WideVaneMode::FAR_RIGHT, // 0x05 - MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x06 - MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x07 - MitsubishiCN105::WideVaneMode::LEFT_RIGHT, // 0x08 - MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x09 - MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0A - MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0B - MitsubishiCN105::WideVaneMode::SWING // 0x0C -}); - static constexpr uint8_t checksum(const uint8_t *bytes, size_t length) { return static_cast(0xFC - std::accumulate(bytes, bytes + length, uint8_t{0})); } @@ -124,10 +43,6 @@ static constexpr auto make_packet(uint8_t type, const std::arrayset_state_(State::CONNECTING); } @@ -277,14 +192,14 @@ bool MitsubishiCN105::should_request_telemetry_() const { return (get_loop_time_ms() - *this->last_telemetry_update_ms_) >= this->telemetry_request_min_interval_ms_; } -void MitsubishiCN105::send_packet_(const uint8_t *packet, size_t len) { - FrameParser::dump_buffer_vv("TX", packet, len); - this->device_.write_array(packet, len); +void MitsubishiCN105::send_packet_(std::span packet) { + FrameParser::dump_buffer_vv("TX", packet.data(), packet.size()); + this->device_.write_array(packet.data(), packet.size()); this->operation_start_ms_ = get_loop_time_ms(); } void MitsubishiCN105::update_status_() { - std::array payload = {this->current_status_msg_type_}; + std::array payload{this->current_status_msg_type_}; this->send_packet_(make_packet(PACKET_TYPE_STATUS_REQUEST, payload)); } @@ -336,12 +251,22 @@ bool MitsubishiCN105::process_status_packet_(const uint8_t *payload, size_t len) } bool MitsubishiCN105::parse_status_payload_(uint8_t msg_type, const uint8_t *payload, size_t len) { + Property::Decoder decoder{std::span{payload, len}, this->property_context_, this->pending_updates_}; switch (msg_type) { case STATUS_MSG_SETTINGS: - return this->parse_status_settings_(payload, len); + if (!decoder.decode_settings(this->status_)) { + ESP_LOGVV(TAG, "RX settings payload too short"); + return false; + } + return true; case STATUS_MSG_TELEMETRY: - return this->parse_status_telemetry_(payload, len); + if (!decoder.decode_room_temperature(this->status_)) { + ESP_LOGVV(TAG, "RX telemetry payload too short"); + return false; + } + this->last_telemetry_update_ms_ = get_loop_time_ms(); + return true; default: ESP_LOGVV(TAG, "RX unsupported status msg type 0x%02X", msg_type); @@ -349,54 +274,6 @@ bool MitsubishiCN105::parse_status_payload_(uint8_t msg_type, const uint8_t *pay } } -bool MitsubishiCN105::parse_status_settings_(const uint8_t *payload, size_t len) { - if (len <= 10) { - ESP_LOGVV(TAG, "RX settings payload too short"); - return false; - } - - if (!this->pending_updates_.contains(UpdateFlag::POWER)) { - this->status_.power_on = payload[2] != 0; - } - - this->use_temperature_encoding_b_ = payload[10] != 0; - if (!this->pending_updates_.contains(UpdateFlag::TEMPERATURE)) { - this->status_.target_temperature = decode_temperature(-payload[4], payload[10], TARGET_TEMPERATURE_ENC_A_OFFSET); - } - - if (!this->pending_updates_.contains(UpdateFlag::MODE)) { - const bool i_see = payload[3] > 0x08; - this->status_.mode = PROTOCOL_MODE_MAP.lookup(payload[3] - (i_see ? 0x08 : 0)); - } - - if (!this->pending_updates_.contains(UpdateFlag::FAN)) { - this->status_.fan_mode = PROTOCOL_FAN_MODE_MAP.lookup(payload[5]); - } - - if (!this->pending_updates_.contains(UpdateFlag::VANE)) { - this->status_.vane_mode = PROTOCOL_VANE_MODE_MAP.lookup(payload[6]); - } - - this->set_wide_vane_high_bit_ = (payload[9] & 0xF0) == 0x80; - if (!this->pending_updates_.contains(UpdateFlag::WIDE_VANE)) { - this->status_.wide_vane_mode = PROTOCOL_WIDE_VANE_MODE_MAP.lookup(payload[9] & 0x0F); - } - - return true; -} - -bool MitsubishiCN105::parse_status_telemetry_(const uint8_t *payload, size_t len) { - if (len <= 5) { - ESP_LOGVV(TAG, "RX telemetry payload too short"); - return false; - } - - this->status_.room_temperature = decode_temperature(payload[2], payload[5], 10); - this->last_telemetry_update_ms_ = get_loop_time_ms(); - - return true; -} - void MitsubishiCN105::set_remote_temperature(float temperature) { if (std::isnan(temperature)) { ESP_LOGD(TAG, "Ignoring NaN remote temperature"); @@ -415,12 +292,12 @@ void MitsubishiCN105::clear_remote_temperature() { void MitsubishiCN105::set_remote_temperature_half_deg_(uint8_t temperature_half_deg) { this->remote_temperature_half_deg_ = temperature_half_deg; - this->pending_updates_.set(UpdateFlag::REMOTE_TEMPERATURE); + this->pending_updates_.set(Property::Temperature::Remote::ID); } void MitsubishiCN105::set_power(bool power_on) { this->status_.power_on = power_on; - this->pending_updates_.set(UpdateFlag::POWER); + this->pending_updates_.set(Property::Power::ID); } void MitsubishiCN105::set_target_temperature(float target_temperature) { @@ -429,101 +306,42 @@ void MitsubishiCN105::set_target_temperature(float target_temperature) { return; } this->status_.target_temperature = target_temperature; - this->pending_updates_.set(UpdateFlag::TEMPERATURE); + this->pending_updates_.set(Property::Temperature::Target::ID); } void MitsubishiCN105::set_mode(Mode mode) { - if (!PROTOCOL_MODE_MAP.is_valid(mode)) { - ESP_LOGD(TAG, "Setting invalid mode: %u", static_cast(mode)); - return; + if (!Property::Mode::validate_and_set(mode, this->status_, this->pending_updates_)) { + ESP_LOGD(TAG, "Ignoring invalid mode: %u", static_cast(mode)); } - this->status_.mode = mode; - this->pending_updates_.set(UpdateFlag::MODE); } void MitsubishiCN105::set_fan_mode(FanMode fan_mode) { - if (!PROTOCOL_FAN_MODE_MAP.is_valid(fan_mode)) { - ESP_LOGD(TAG, "Setting invalid fan mode: %u", static_cast(fan_mode)); - return; + if (!Property::FanMode::validate_and_set(fan_mode, this->status_, this->pending_updates_)) { + ESP_LOGD(TAG, "Ignoring invalid fan mode: %u", static_cast(fan_mode)); } - this->status_.fan_mode = fan_mode; - this->pending_updates_.set(UpdateFlag::FAN); } void MitsubishiCN105::set_vane_mode(VaneMode vane_mode) { - if (!PROTOCOL_VANE_MODE_MAP.is_valid(vane_mode)) { - ESP_LOGD(TAG, "Setting invalid vane mode: %u", static_cast(vane_mode)); - return; + if (!Property::VaneMode::validate_and_set(vane_mode, this->status_, this->pending_updates_)) { + ESP_LOGD(TAG, "Ignoring invalid vane mode: %u", static_cast(vane_mode)); } - this->status_.vane_mode = vane_mode; - this->pending_updates_.set(UpdateFlag::VANE); } void MitsubishiCN105::set_wide_vane_mode(WideVaneMode wide_vane_mode) { - if (!PROTOCOL_WIDE_VANE_MODE_MAP.is_valid(wide_vane_mode)) { - ESP_LOGD(TAG, "Setting invalid wide vane mode: %u", static_cast(wide_vane_mode)); - return; + if (!Property::WideVaneMode::validate_and_set(wide_vane_mode, this->status_, this->pending_updates_)) { + ESP_LOGD(TAG, "Ignoring invalid wide vane mode: %u", static_cast(wide_vane_mode)); } - this->status_.wide_vane_mode = wide_vane_mode; - this->pending_updates_.set(UpdateFlag::WIDE_VANE); } void MitsubishiCN105::apply_settings_() { std::array payload{}; + Property::Encoder encoder{payload.data(), this->property_context_, this->pending_updates_}; // Apply all other pending settings first; handle REMOTE_TEMPERATURE last - if (this->pending_updates_.contains_only(UpdateFlag::REMOTE_TEMPERATURE)) { - payload[0] = 0x07; - if (this->remote_temperature_half_deg_ == REMOTE_TEMPERATURE_DISABLED) { - payload[3] = 0x80; - } else { - payload[1] = 0x01; - payload[2] = static_cast(this->remote_temperature_half_deg_ - 16); - payload[3] = static_cast(this->remote_temperature_half_deg_ + 128); - } - this->pending_updates_.clear(UpdateFlag::REMOTE_TEMPERATURE); + if (this->pending_updates_.contains_only(Property::Temperature::Remote::ID)) { + encoder.encode_remote_temperature(this->remote_temperature_half_deg_); } else { - payload[0] = 0x01; - if (this->pending_updates_.contains(UpdateFlag::POWER)) { - payload[1] |= 0x01; - payload[3] = this->status_.power_on ? 0x01 : 0x00; - } - - if (this->pending_updates_.contains(UpdateFlag::TEMPERATURE)) { - payload[1] |= 0x04; - if (this->use_temperature_encoding_b_) { - payload[14] = static_cast(std::round(this->status_.target_temperature * 2.0f) + 128); - } else { - payload[5] = - static_cast(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(this->status_.target_temperature)); - } - } - - if (this->pending_updates_.contains(UpdateFlag::MODE) && - PROTOCOL_MODE_MAP.reverse_lookup(this->status_.mode, payload[4])) { - payload[1] |= 0x02; - } - - if (this->pending_updates_.contains(UpdateFlag::FAN) && - PROTOCOL_FAN_MODE_MAP.reverse_lookup(this->status_.fan_mode, payload[6])) { - payload[1] |= 0x08; - } - - if (this->pending_updates_.contains(UpdateFlag::VANE) && - PROTOCOL_VANE_MODE_MAP.reverse_lookup(this->status_.vane_mode, payload[7])) { - payload[1] |= 0x10; - } - - if (this->pending_updates_.contains(UpdateFlag::WIDE_VANE) && - PROTOCOL_WIDE_VANE_MODE_MAP.reverse_lookup(this->status_.wide_vane_mode, payload[13])) { - payload[2] |= 0x01; - if (this->set_wide_vane_high_bit_) { - payload[13] |= 0x80; - } - } - - this->pending_updates_.clear(UpdateFlag::POWER, UpdateFlag::TEMPERATURE, UpdateFlag::MODE, UpdateFlag::FAN, - UpdateFlag::VANE, UpdateFlag::WIDE_VANE); + encoder.encode_settings(this->status_); } this->send_packet_(make_packet(PACKET_TYPE_WRITE_SETTINGS_REQUEST, payload)); diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h index 3169359290..b6b11b4820 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h @@ -5,6 +5,7 @@ #include #include +#include namespace esphome::mitsubishi_cn105 { @@ -121,44 +122,47 @@ class MitsubishiCN105 { uint8_t read_pos_{0}; }; - enum class UpdateFlag : uint8_t { + enum class PropertyId : uint8_t { TEMPERATURE = 0, POWER = 1, MODE = 2, FAN = 3, VANE = 4, WIDE_VANE = 5, - REMOTE_TEMPERATURE = 6, + REMOTE_TEMPERATURE = 6 }; struct UpdateFlags { - template void set(Flags... flags) { (this->mask_.insert(flags), ...); } - template void clear(Flags... flags) { (this->mask_.erase(flags), ...); } + void set(PropertyId id) { this->mask_.insert(id); } + void clear(PropertyId id) { this->mask_.erase(id); } bool any() const { return !this->mask_.empty(); } - bool contains(UpdateFlag flag) const { return this->mask_.count(flag); } - bool contains_only(UpdateFlag flag) const { return this->mask_.get_mask() == Mask{flag}.get_mask(); } + bool contains(PropertyId id) const { return this->mask_.count(id); } + bool contains_only(PropertyId id) const { return this->mask_.get_mask() == Mask{id}.get_mask(); } protected: using Mask = - FiniteSetMask(UpdateFlag::REMOTE_TEMPERATURE) + 1>>; - + FiniteSetMask(PropertyId::REMOTE_TEMPERATURE) + 1>>; Mask mask_; }; + struct PropertyContext { + bool use_temperature_encoding_b{false}; + bool set_wide_vane_high_bit{false}; + }; + + friend struct Property; + void set_state_(State new_state); void did_transition_(State to); bool process_rx_packet_(uint8_t type, const uint8_t *payload, size_t len); bool process_status_packet_(const uint8_t *payload, size_t len); bool parse_status_payload_(uint8_t msg_type, const uint8_t *payload, size_t len); - bool parse_status_settings_(const uint8_t *payload, size_t len); - bool parse_status_telemetry_(const uint8_t *payload, size_t len); - void send_packet_(const uint8_t *packet, size_t len); + void send_packet_(std::span packet); void update_status_(); bool should_request_telemetry_() const; void apply_settings_(); bool has_timed_out_(uint32_t timeout) const { return ((get_loop_time_ms() - this->operation_start_ms_) >= timeout); } void set_remote_temperature_half_deg_(uint8_t temperature_half_deg); - template void send_packet_(const T &packet) { this->send_packet_(packet.data(), packet.size()); } static bool should_transition(State from, State to); static const LogString *state_to_string(State state); @@ -175,8 +179,7 @@ class MitsubishiCN105 { Status status_{}; State state_{State::NOT_CONNECTED}; UpdateFlags pending_updates_; - bool use_temperature_encoding_b_{false}; - bool set_wide_vane_high_bit_{false}; + PropertyContext property_context_; FrameParser frame_parser_; uint8_t current_status_msg_type_{0}; diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_properties.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_properties.h new file mode 100644 index 0000000000..1f5faf61af --- /dev/null +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_properties.h @@ -0,0 +1,302 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include "mitsubishi_cn105.h" + +namespace esphome::mitsubishi_cn105 { + +template struct LookupMap { + using value_type = decltype(Unknown); + const std::array table; + + constexpr value_type lookup(uint8_t raw) const { return (raw < N) ? this->table[raw] : Unknown; } + + constexpr bool reverse_lookup(value_type value, uint8_t &out) const { + static_assert(N <= std::numeric_limits::max()); + if (value == Unknown) { + return false; + } + for (uint8_t i = 0; i < static_cast(N); ++i) { + if (this->table[i] == value) { + out = i; + return true; + } + } + return false; + } +}; + +template static constexpr auto make_map(const T (&values)[N]) { + return LookupMap{std::to_array(values)}; +} + +struct Property { + using PropertyId = MitsubishiCN105::PropertyId; + using Status = MitsubishiCN105::Status; + using PropertyContext = MitsubishiCN105::PropertyContext; + + struct Power { + static constexpr auto ID = PropertyId::POWER; + + static void decode_context(PropertyContext &ctx, const uint8_t *payload) {} + + static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) { + status.power_on = payload[2] != 0; + } + + static void encode(uint8_t *payload, const Status &status, const PropertyContext &ctx) { + payload[1] |= 0x01; + payload[3] = status.power_on ? 0x01 : 0x00; + } + }; + + struct Temperature { + struct Target { + static constexpr auto ID = PropertyId::TEMPERATURE; + static constexpr uint8_t TARGET_TEMPERATURE_ENC_A_OFFSET = 31; + + static void decode_context(PropertyContext &ctx, const uint8_t *payload) { + ctx.use_temperature_encoding_b = payload[10] != 0; + } + + static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) { + status.target_temperature = Temperature::decode(-payload[4], payload[10], TARGET_TEMPERATURE_ENC_A_OFFSET); + } + + static void encode(uint8_t *payload, const Status &status, const PropertyContext &ctx) { + payload[1] |= 0x04; + if (ctx.use_temperature_encoding_b) { + payload[14] = static_cast(std::round(status.target_temperature * 2.0f) + 128); + } else { + payload[5] = static_cast(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(status.target_temperature)); + } + } + }; + + struct Room { + static void decode_context(PropertyContext &ctx, const uint8_t *payload) {} + + static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) { + status.room_temperature = Temperature::decode(payload[2], payload[5], 10); + } + }; + + struct Remote { + static constexpr auto ID = PropertyId::REMOTE_TEMPERATURE; + + static void encode(uint8_t *payload, uint8_t remote_temperature_half_deg, const PropertyContext &) { + if (remote_temperature_half_deg == MitsubishiCN105::REMOTE_TEMPERATURE_DISABLED) { + payload[3] = 0x80; + } else { + payload[1] = 0x01; + payload[2] = static_cast(remote_temperature_half_deg - 16); + payload[3] = static_cast(remote_temperature_half_deg + 128); + } + } + }; + + protected: + static constexpr float decode(int temp_a, int temp_b, int delta) { + return temp_b != 0 ? (temp_b - 128) / 2.0f : delta + temp_a; + } + }; + + template struct Lookup { + using Value = std::remove_cvref_t().*Field)>; + + static void decode_context(PropertyContext &ctx, const uint8_t *payload) {} + + static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) { + status.*Field = Derived::MAP.lookup(Derived::decode_raw(payload, ctx)); + } + + static void encode(uint8_t *payload, const Status &status, const PropertyContext &ctx) { + uint8_t raw; + if (Derived::MAP.reverse_lookup(status.*Field, raw)) { + Derived::encode_raw(payload, raw, ctx); + } + } + + template static bool validate_and_set(Value value, Status &status, Mask &mask) { + uint8_t raw; + if (!Derived::MAP.reverse_lookup(value, raw)) { + return false; + } + status.*Field = value; + mask.set(Derived::ID); + return true; + } + + private: + friend Derived; + constexpr Lookup() = default; + }; + + struct Mode : Lookup { + static constexpr auto ID = PropertyId::MODE; + static constexpr auto MAP = make_map({ + MitsubishiCN105::Mode::UNKNOWN, // 0x00 + MitsubishiCN105::Mode::HEAT, // 0x01 + MitsubishiCN105::Mode::DRY, // 0x02 + MitsubishiCN105::Mode::COOL, // 0x03 + MitsubishiCN105::Mode::UNKNOWN, // 0x04 + MitsubishiCN105::Mode::UNKNOWN, // 0x05 + MitsubishiCN105::Mode::UNKNOWN, // 0x06 + MitsubishiCN105::Mode::FAN_ONLY, // 0x07 + MitsubishiCN105::Mode::AUTO // 0x08 + }); + + static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { + const bool i_see = payload[3] > 0x08; + return payload[3] - (i_see ? 0x08 : 0); + } + + static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &) { + payload[1] |= 0x02; + payload[4] = raw; + } + }; + + struct FanMode : Lookup { + static constexpr auto ID = PropertyId::FAN; + static constexpr auto MAP = make_map({ + MitsubishiCN105::FanMode::AUTO, // 0x00 + MitsubishiCN105::FanMode::QUIET, // 0x01 + MitsubishiCN105::FanMode::SPEED_1, // 0x02 + MitsubishiCN105::FanMode::SPEED_2, // 0x03 + MitsubishiCN105::FanMode::UNKNOWN, // 0x04 + MitsubishiCN105::FanMode::SPEED_3, // 0x05 + MitsubishiCN105::FanMode::SPEED_4 // 0x06 + }); + + static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { return payload[5]; } + + static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &) { + payload[1] |= 0x08; + payload[6] = raw; + } + }; + + struct VaneMode : Lookup { + static constexpr auto ID = PropertyId::VANE; + static constexpr auto MAP = make_map({ + MitsubishiCN105::VaneMode::AUTO, // 0x00 + MitsubishiCN105::VaneMode::POSITION_1, // 0x01 + MitsubishiCN105::VaneMode::POSITION_2, // 0x02 + MitsubishiCN105::VaneMode::POSITION_3, // 0x03 + MitsubishiCN105::VaneMode::POSITION_4, // 0x04 + MitsubishiCN105::VaneMode::POSITION_5, // 0x05 + MitsubishiCN105::VaneMode::UNKNOWN, // 0x06 + MitsubishiCN105::VaneMode::SWING // 0x07 + }); + + static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { return payload[6]; } + + static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &) { + payload[1] |= 0x10; + payload[7] = raw; + } + }; + + struct WideVaneMode : Lookup { + static constexpr auto ID = PropertyId::WIDE_VANE; + static constexpr auto MAP = make_map({ + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x00 + MitsubishiCN105::WideVaneMode::FAR_LEFT, // 0x01 + MitsubishiCN105::WideVaneMode::LEFT, // 0x02 + MitsubishiCN105::WideVaneMode::CENTER, // 0x03 + MitsubishiCN105::WideVaneMode::RIGHT, // 0x04 + MitsubishiCN105::WideVaneMode::FAR_RIGHT, // 0x05 + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x06 + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x07 + MitsubishiCN105::WideVaneMode::LEFT_RIGHT, // 0x08 + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x09 + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0A + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0B + MitsubishiCN105::WideVaneMode::SWING // 0x0C + }); + + static void decode_context(PropertyContext &ctx, const uint8_t *payload) { + ctx.set_wide_vane_high_bit = (payload[9] & 0xF0) == 0x80; + } + + static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { return payload[9] & 0x0F; } + + static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &ctx) { + payload[2] |= 0x01; + payload[13] = ctx.set_wide_vane_high_bit ? raw | 0x80 : raw; + } + }; + + template struct Decoder { + const std::span payload; + PropertyContext &context; + const Mask &pending_writes; + + bool ESPHOME_ALWAYS_INLINE decode_settings(Status &status) { + if (this->payload.size() <= 10) { + return false; + } + this->decode_(status); + return true; + } + + bool ESPHOME_ALWAYS_INLINE decode_room_temperature(Status &status) { + if (this->payload.size() <= 5) { + return false; + } + this->decode_(status); + return true; + } + + protected: + template ESPHOME_ALWAYS_INLINE void decode_one_(Out &out) { + T::decode_context(this->context, this->payload.data()); + if constexpr (requires { T::ID; }) { + if (this->pending_writes.contains(T::ID)) { + return; + } + } + T::decode(out, this->payload.data(), this->context); + } + + template void ESPHOME_ALWAYS_INLINE decode_(Out &out) { + (this->decode_one_(out), ...); + } + }; + + template struct Encoder { + uint8_t *payload; + const PropertyContext &context; + Mask &pending_writes; + + void ESPHOME_ALWAYS_INLINE encode_settings(const Status &status) { + this->payload[0] = 0x01; + this->encode_and_clear_(status); + } + + void ESPHOME_ALWAYS_INLINE encode_remote_temperature(uint8_t remote_temperature_half_deg) { + this->payload[0] = 0x07; + this->encode_and_clear_(remote_temperature_half_deg); + } + + protected: + template void ESPHOME_ALWAYS_INLINE encode_and_clear_(const In &in) { + (this->encode_one_(in), ...); + (this->pending_writes.clear(T::ID), ...); + } + + template void encode_one_(const In &in) { + if (this->pending_writes.contains(T::ID)) { + T::encode(this->payload, in, this->context); + } + } + }; +}; + +} // namespace esphome::mitsubishi_cn105 diff --git a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp index 28fdfbb313..3bc6d5b2b8 100644 --- a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp +++ b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp @@ -266,7 +266,7 @@ TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedA) { ctx.sut.update(); EXPECT_TRUE(ctx.sut.status().power_on); - EXPECT_FALSE(ctx.sut.use_temperature_encoding_b_); + EXPECT_FALSE(ctx.sut.property_context_.use_temperature_encoding_b); EXPECT_EQ(ctx.sut.status().target_temperature, 26.0f); EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::COOL); EXPECT_EQ(ctx.sut.status().fan_mode, MitsubishiCN105::FanMode::QUIET); @@ -281,7 +281,7 @@ TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedB) { ctx.sut.update(); EXPECT_FALSE(ctx.sut.status().power_on); - EXPECT_TRUE(ctx.sut.use_temperature_encoding_b_); + EXPECT_TRUE(ctx.sut.property_context_.use_temperature_encoding_b); EXPECT_EQ(ctx.sut.status().target_temperature, 18.5f); EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::FAN_ONLY); EXPECT_EQ(ctx.sut.status().fan_mode, MitsubishiCN105::FanMode::SPEED_4); @@ -316,7 +316,7 @@ TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitNotSet) { ctx.sut.update(); EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::CENTER); - EXPECT_FALSE(ctx.sut.set_wide_vane_high_bit_); + EXPECT_FALSE(ctx.sut.property_context_.set_wide_vane_high_bit); } TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitSet) { @@ -328,7 +328,7 @@ TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitSet) { ctx.sut.update(); EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::CENTER); - EXPECT_TRUE(ctx.sut.set_wide_vane_high_bit_); + EXPECT_TRUE(ctx.sut.property_context_.set_wide_vane_high_bit); } TEST(MitsubishiCN105Tests, ApplySettingsPowerOn) { @@ -354,7 +354,7 @@ TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedA) { TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedB) { MitsubishiCN105TestsContext ctx; - ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.property_context_.use_temperature_encoding_b = true; ctx.sut.set_target_temperature(26.0f); ctx.sut.apply_settings(); @@ -365,7 +365,7 @@ TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedB) { TEST(MitsubishiCN105Tests, ApplySettingsHalfDegreeTemperatureEncodedB) { MitsubishiCN105TestsContext ctx; - ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.property_context_.use_temperature_encoding_b = true; ctx.sut.set_target_temperature(26.5f); ctx.sut.apply_settings(); @@ -416,7 +416,7 @@ TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitNotSet) { TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitSet) { MitsubishiCN105TestsContext ctx; - ctx.sut.set_wide_vane_high_bit_ = true; + ctx.sut.property_context_.set_wide_vane_high_bit = true; ctx.sut.set_wide_vane_mode(MitsubishiCN105::WideVaneMode::LEFT); ctx.sut.apply_settings(); @@ -445,7 +445,7 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) { EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0); // Write new values - ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.property_context_.use_temperature_encoding_b = true; ctx.sut.set_power(false); ctx.sut.set_target_temperature(25.0f); ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT); @@ -508,7 +508,7 @@ TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) { MitsubishiCN105TestsContext ctx; // Queue normal settings plus remote temperature together. - ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.property_context_.use_temperature_encoding_b = true; ctx.sut.set_power(false); ctx.sut.set_target_temperature(25.0f); ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT); @@ -521,11 +521,11 @@ TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) { EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x0F, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB2, 0x00, 0xBB)); - EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::POWER)); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::TEMPERATURE)); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::MODE)); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::FAN)); + EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::POWER)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::TEMPERATURE)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::MODE)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::FAN)); // ACK the first write. Remote temperature should still be pending afterward. ctx.uart.tx.clear(); @@ -533,7 +533,7 @@ TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5E}); ASSERT_FALSE(ctx.sut.update()); - EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); // The next apply sends the remote-temperature packet and clears the last pending flag. ctx.uart.tx.clear(); @@ -557,7 +557,7 @@ TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect) ASSERT_EQ(ctx.sut.status_update_wait_credit_ms_, 0); // Interrupt that wait with a write so credit is accumulated. - ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.property_context_.use_temperature_encoding_b = true; ctx.sut.set_power(false); ctx.sut.set_target_temperature(25.0f); ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT); @@ -581,25 +581,25 @@ TEST(MitsubishiCN105Tests, SetOutOfRangeRemoteRoomTempIsIgnored) { MitsubishiCN105TestsContext ctx; ctx.sut.set_remote_temperature(7.0f); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); ctx.sut.set_remote_temperature(40.0f); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); ctx.sut.set_remote_temperature(NAN); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); } TEST(MitsubishiCN105Tests, SetMinRemoteRoomTemp) { MitsubishiCN105TestsContext ctx; ctx.sut.set_remote_temperature(8.0f); - EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); } TEST(MitsubishiCN105Tests, SetMaxRemoteRoomTemp) { MitsubishiCN105TestsContext ctx; ctx.sut.set_remote_temperature(39.5f); - EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); } } // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h index a14043c737..b90ddf3995 100644 --- a/tests/components/mitsubishi_cn105/common.h +++ b/tests/components/mitsubishi_cn105/common.h @@ -47,12 +47,11 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 { public: using MitsubishiCN105::MitsubishiCN105; using MitsubishiCN105::State; - using MitsubishiCN105::UpdateFlag; + using MitsubishiCN105::PropertyId; using MitsubishiCN105::state_; using MitsubishiCN105::status_; using MitsubishiCN105::operation_start_ms_; - using MitsubishiCN105::use_temperature_encoding_b_; - using MitsubishiCN105::set_wide_vane_high_bit_; + using MitsubishiCN105::property_context_; using MitsubishiCN105::status_update_wait_credit_ms_; using MitsubishiCN105::pending_updates_; From 791d6659c03ce13b19561a6917d2d53a257ac8f6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:43:35 +0000 Subject: [PATCH 1375/1815] Bump platformdirs from 4.11.0 to 4.11.1 (#18250) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 98c2f47d7e..92db51db7d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ bleak==2.1.1 smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 -platformdirs==4.11.0 # native esp-idf toolchain global cache dir +platformdirs==4.11.1 # native esp-idf toolchain global cache dir filelock==3.32.2 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this From 3de01d2a2eb414ea7775594a4a9a6c71e20859b4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 19:56:35 -0500 Subject: [PATCH 1376/1815] Bump aioesphomeapi to 45.8.0 (#18251) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 92db51db7d..a90d9ec9eb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.7.0 +aioesphomeapi==45.8.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From c2ba2fbfc4d4862f5dba26bc6e402ee5ba0b5a31 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:18:09 +1200 Subject: [PATCH 1377/1815] [ld24xx] Use MAC address size constants instead of literals (#18253) --- esphome/components/ld2410/ld2410.cpp | 5 +++-- esphome/components/ld2410/ld2410.h | 2 +- esphome/components/ld2412/ld2412.cpp | 4 ++-- esphome/components/ld2412/ld2412.h | 2 +- esphome/components/ld2450/ld2450.cpp | 4 ++-- esphome/components/ld2450/ld2450.h | 2 +- esphome/components/ld24xx/ld24xx.h | 3 +-- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/esphome/components/ld2410/ld2410.cpp b/esphome/components/ld2410/ld2410.cpp index 32e49c643f..914de8e145 100644 --- a/esphome/components/ld2410/ld2410.cpp +++ b/esphome/components/ld2410/ld2410.cpp @@ -8,6 +8,7 @@ #endif #include "esphome/core/application.h" +#include "esphome/core/helpers.h" namespace esphome::ld2410 { @@ -178,7 +179,7 @@ static inline bool validate_header_footer(const uint8_t *header_footer, const ui } void LD2410Component::dump_config() { - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; char version_s[20]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ld24xx::format_version_str(this->version_, version_s); @@ -511,7 +512,7 @@ bool LD2410Component::handle_ack_data_() { std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_)); } - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ESP_LOGV(TAG, "MAC address: %s", mac_str); #ifdef USE_TEXT_SENSOR diff --git a/esphome/components/ld2410/ld2410.h b/esphome/components/ld2410/ld2410.h index a0cce36d16..061846f1f1 100644 --- a/esphome/components/ld2410/ld2410.h +++ b/esphome/components/ld2410/ld2410.h @@ -121,7 +121,7 @@ class LD2410Component final : public Component, public uart::UARTDevice { uint8_t out_pin_level_ = 0; uint8_t buffer_pos_ = 0; // where to resume processing/populating buffer uint8_t buffer_data_[MAX_LINE_LENGTH]; - uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0}; + uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0}; uint8_t version_[6] = {0, 0, 0, 0, 0, 0}; bool bluetooth_on_{false}; #ifdef USE_NUMBER diff --git a/esphome/components/ld2412/ld2412.cpp b/esphome/components/ld2412/ld2412.cpp index 093e8c72dc..7041b7539f 100644 --- a/esphome/components/ld2412/ld2412.cpp +++ b/esphome/components/ld2412/ld2412.cpp @@ -197,7 +197,7 @@ static inline bool validate_header_footer(const uint8_t *header_footer, const ui } void LD2412Component::dump_config() { - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; char version_s[20]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ld24xx::format_version_str(this->version_, version_s); @@ -555,7 +555,7 @@ bool LD2412Component::handle_ack_data_() { std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_)); } - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ESP_LOGV(TAG, "MAC address: %s", mac_str); #ifdef USE_TEXT_SENSOR diff --git a/esphome/components/ld2412/ld2412.h b/esphome/components/ld2412/ld2412.h index f722f938ae..a52402c2ea 100644 --- a/esphome/components/ld2412/ld2412.h +++ b/esphome/components/ld2412/ld2412.h @@ -124,7 +124,7 @@ class LD2412Component final : public Component, public uart::UARTDevice { uint8_t out_pin_level_ = 0; uint8_t buffer_pos_ = 0; // where to resume processing/populating buffer uint8_t buffer_data_[MAX_LINE_LENGTH]; - uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0}; + uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0}; uint8_t version_[6] = {0, 0, 0, 0, 0, 0}; bool bluetooth_on_{false}; bool dynamic_background_correction_active_{false}; diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index 0dc2638aad..4b41d63a88 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -184,7 +184,7 @@ void LD2450Component::setup() { } void LD2450Component::dump_config() { - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; char version_s[20]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ld24xx::format_version_str(this->version_, version_s); @@ -680,7 +680,7 @@ bool LD2450Component::handle_ack_data_() { std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_)); } - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ESP_LOGV(TAG, "MAC address: %s", mac_str); #ifdef USE_TEXT_SENSOR diff --git a/esphome/components/ld2450/ld2450.h b/esphome/components/ld2450/ld2450.h index 10f9bb874a..c4f06ad224 100644 --- a/esphome/components/ld2450/ld2450.h +++ b/esphome/components/ld2450/ld2450.h @@ -169,7 +169,7 @@ class LD2450Component : public Component, public uart::UARTDevice { uint32_t moving_presence_millis_ = 0; uint32_t timeout_ = 5; uint8_t buffer_data_[MAX_LINE_LENGTH]; - uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0}; + uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0}; uint8_t version_[6] = {0, 0, 0, 0, 0, 0}; uint8_t buffer_pos_ = 0; // where to resume processing/populating buffer uint8_t zone_type_ = 0; diff --git a/esphome/components/ld24xx/ld24xx.h b/esphome/components/ld24xx/ld24xx.h index cba1b68a15..deac04e86f 100644 --- a/esphome/components/ld24xx/ld24xx.h +++ b/esphome/components/ld24xx/ld24xx.h @@ -45,8 +45,7 @@ static const char *const VERSION_FMT = "%u.%02X.%02X%02X%02X%02X"; // Helper function to format MAC address with stack allocation // Returns pointer to UNKNOWN_MAC constant or formatted buffer -// Buffer must be exactly 18 bytes (17 for "XX:XX:XX:XX:XX:XX" + null terminator) -inline const char *format_mac_str(const uint8_t *mac_address, std::span buffer) { +inline const char *format_mac_str(const uint8_t *mac_address, std::span buffer) { if (mac_address_is_valid(mac_address)) { format_mac_addr_upper(mac_address, buffer.data()); return buffer.data(); From eab9a47aa2010d18332c15daec709fb1152409c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 21:20:39 -0500 Subject: [PATCH 1378/1815] [bluetooth_proxy] Retry dropped services-done, disconnect and scanner-state notifications (#18225) --- .../bluetooth_connection.h | 10 + .../bluetooth_connection_bluedroid.cpp | 10 +- .../bluetooth_connection_hub.cpp | 30 ++- .../bluetooth_connection_hub.h | 24 ++- .../bluetooth_proxy/bluetooth_proxy.cpp | 181 ++++++++++++++---- .../bluetooth_proxy/bluetooth_proxy.h | 65 ++++++- 6 files changed, 262 insertions(+), 58 deletions(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h index 5052e7eca1..53e319e369 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -82,6 +82,16 @@ inline conn_err_t clear_gatt_cache(uint64_t) { return GATT_NOT_CONNECTED; } // send_service_ cursor states; >= 0 is the next service index to stream. static constexpr int DONE_SENDING_SERVICES = -2; static constexpr int INIT_SENDING_SERVICES = -3; +static constexpr int SERVICES_DONE_PENDING = -4; // all batches delivered, done-message still owed +// Every sentinel must stay below the >= 0 streaming gate and clear of +// GATT_NOT_CONNECTED (-1) so cursor and error values can never be confused. +static_assert(DONE_SENDING_SERVICES < 0 && INIT_SENDING_SERVICES < 0 && SERVICES_DONE_PENDING < 0); +static_assert(DONE_SENDING_SERVICES != GATT_NOT_CONNECTED && INIT_SENDING_SERVICES != GATT_NOT_CONNECTED && + SERVICES_DONE_PENDING != GATT_NOT_CONNECTED); +// Owed-done retries stop here (~3 s at the 100 ms drain cadence): a done +// delivered near the client's 30 s timeout could land on a fresh request's +// empty accumulator and cache as an empty database. +static constexpr uint8_t SERVICES_DONE_RETRY_LIMIT = 30; // ---- Service-streaming size budget, shared by every platform's streamer ---- diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index f24d261c57..d6b815fc2e 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -407,20 +407,16 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) { return; } if (conn.send_service_ >= this->service_total_) { - conn.send_service_ = DONE_SENDING_SERVICES; - conn.proxy_->send_gatt_services_done(conn.address_); this->release_services(); + conn.send_services_done_(); return; } - // The subscriber vanished mid-stream: park the cursor at done WITHOUT - // sending services-done (a resubscribing client gets silence and its 30 s - // timeout, never an authoritative partial list). + // The subscriber vanished mid-stream. auto *api_conn = conn.proxy_->get_api_connection(); if (api_conn == nullptr) { ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", conn.connection_index_, conn.address_str_); - conn.send_service_ = DONE_SENDING_SERVICES; - this->release_services(); + conn.park_service_stream_(); return; } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index b913bb9a55..f79669dc32 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -299,25 +299,39 @@ conn_err_t BluetoothConnection::update_connection_params(uint16_t min_interval, // ---- Service streaming ---- +void BluetoothConnection::send_services_done_() { + if (this->proxy_->send_gatt_services_done(this->address_)) { + // Sent, or subscriber gone (park silently; its timeout arbitrates). + this->send_service_ = DONE_SENDING_SERVICES; + return; + } + if (this->send_service_ != SERVICES_DONE_PENDING) { + // Warn on the transition only; retries stay silent. + ESP_LOGW(TAG, "[%d] [%s] Failed to send services done, retrying", this->connection_index_, this->address_str_); + this->services_done_retries_ = 0; + this->send_service_ = SERVICES_DONE_PENDING; + } else if (++this->services_done_retries_ >= SERVICES_DONE_RETRY_LIMIT) { + // Undeliverable (see SERVICES_DONE_RETRY_LIMIT); silence arbitrates. + ESP_LOGW(TAG, "[%d] [%s] Services done undeliverable, abandoning", this->connection_index_, this->address_str_); + this->send_service_ = DONE_SENDING_SERVICES; + } +} + void BluetoothConnection::send_service_for_discovery_() { auto table = this->backend_->get_service_table(); if (this->send_service_ >= table.service_count) { - this->send_service_ = DONE_SENDING_SERVICES; - this->proxy_->send_gatt_services_done(this->address_); this->backend_->release_services(); + this->send_services_done_(); return; } - // The subscriber vanished mid-stream: park the cursor at done WITHOUT - // sending services-done (a resubscribing client gets silence and its 30 s - // timeout, never an authoritative partial list) and free the table; the - // api-gone sweep tears the connection down anyway. + // The subscriber vanished mid-stream; the api-gone sweep tears the + // connection down anyway. auto *api_conn = this->proxy_->get_api_connection(); if (api_conn == nullptr) { ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", this->connection_index_, this->address_str_); - this->send_service_ = DONE_SENDING_SERVICES; - this->backend_->release_services(); + this->park_service_stream_(); return; } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index 82d9ae7db4..783a8c466b 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -126,7 +126,23 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { this->send_service_for_discovery_(); } } + /// Park the stream without services-done and free any held table: an + /// interrupted stream must never be declared complete (the client's + /// timeout arbitrates), and an owed done is dropped with it. + void park_service_stream_() { + if (this->send_service_ >= 0) { + this->backend_->release_services(); + this->send_service_ = DONE_SENDING_SERVICES; + } else if (this->send_service_ == SERVICES_DONE_PENDING) { + this->send_service_ = DONE_SENDING_SERVICES; + } + } void send_service_for_discovery_(); + /// Send services-done and settle the cursor: DONE when it lands (or no + /// subscriber), SERVICES_DONE_PENDING on a refused frame (proxy drain + /// retries). Callers release the table first; the message needs only the + /// address. + void send_services_done_(); void reset_connection_(conn_err_t reason); conn_err_t check_connected_op_(const char *action, const char *type) const; void log_gatt_operation_error_(const char *operation, uint16_t handle, int status); @@ -151,10 +167,14 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { static_assert(static_cast(ClientState::ESTABLISHED) < (1 << 3), "state_ bitfield too narrow"); static_assert(static_cast(ConnectionType::V3_WITHOUT_CACHE) < (1 << 2), "connection_type_ bitfield too narrow"); + // Ordered so neither byte's fields straddle a storage unit: 3+5 and + // 4+2+1+1 fill the two tail bytes exactly. ClientState state_ : 3 {ClientState::IDLE}; - bool paired_ : 1 {false}; - ConnectionType connection_type_ : 2 {ConnectionType::V1}; + static_assert(SERVICES_DONE_RETRY_LIMIT < (1 << 5), "counter bitfield too narrow"); + uint8_t services_done_retries_ : 5 {0}; uint8_t connection_index_ : 4 {0}; + ConnectionType connection_type_ : 2 {ConnectionType::V1}; + bool paired_ : 1 {false}; bool services_discovered_ : 1 {false}; }; diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index af52a25ec0..6b49b28cd6 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -40,7 +40,7 @@ static_assert(static_cast(ble_device_base::ScannerState::STOPPED) == bool BluetoothProxy::send_bluetooth_scanner_state_(ble_device_base::ScannerState state) { if (this->api_connection_ == nullptr) - return false; + return true; // Nobody subscribed: nothing owed api::BluetoothScannerStateResponse resp; resp.state = static_cast(state); resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE @@ -51,7 +51,12 @@ bool BluetoothProxy::send_bluetooth_scanner_state_(ble_device_base::ScannerState return this->api_connection_->send_message(resp); } -#ifndef USE_BLE_SCANNER_STATE_CALLBACK +#ifdef USE_BLE_SCANNER_STATE_CALLBACK +void BluetoothProxy::send_scanner_state_(ble_device_base::ScannerState state) { + // False only on a refused frame, so the latch arms only when a retry is owed. + this->scanner_state_pending_ = !this->send_bluetooth_scanner_state_(state); +} +#else void BluetoothProxy::send_polled_scanner_state_() { // One read feeds both the frame and the change detector; the detector only // advances if the frame was accepted, so a dropped send (WOULD_BLOCK on a @@ -62,7 +67,7 @@ void BluetoothProxy::send_polled_scanner_state_() { this->last_scan_running_ = running; } } -#endif // !USE_BLE_SCANNER_STATE_CALLBACK +#endif // USE_BLE_SCANNER_STATE_CALLBACK void BluetoothProxy::setup() { // BLUETOOTH_PROXY_MAX_CONNECTIONS is 0 on an advertisement-only proxy. @@ -78,7 +83,7 @@ void BluetoothProxy::setup() { #ifdef USE_BLE_SCANNER_STATE_CALLBACK // Only push hubs compile the slot; elsewhere loop() polls scan_running(). this->hub_->set_scanner_state_callback({this, [](void *self, ble_device_base::ScannerState state) { - static_cast(self)->send_bluetooth_scanner_state_(state); + static_cast(self)->send_scanner_state_(state); }}); #endif } @@ -190,8 +195,48 @@ void BluetoothProxy::replace_allocated_slot_(uint64_t find_value, uint64_t set_v ESP_LOGW(TAG, "Connection slot accounting mismatch (find 0x%llx)", (unsigned long long) find_value); } +void BluetoothProxy::latch_pending_disconnection_(uint64_t address, conn_err_t error) { + // Match before free entry so one address never occupies two pool slots. + PendingDisconnect *free_entry = nullptr; + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto &owed = this->pending_disconnections_[i]; + if (owed.matches(address)) { + owed.set(address, error); + return; + } + if (free_entry == nullptr && owed.empty()) { + free_entry = &owed; + } + } + if (free_entry != nullptr) { + free_entry->set(address, error); + return; + } + // Every entry is owed: evict the first so the newest loss is not silent too. + ESP_LOGW(TAG, "Owed disconnect dropped (0x%llx), retry pool full", + (unsigned long long) this->pending_disconnections_[0].address()); + this->pending_disconnections_[0].set(address, error); +} + +void BluetoothProxy::clear_pending_disconnection_(uint64_t address) { + // A reconnect supersedes the owed disconnect; a late resend would shadow + // the new connection. + for (uint8_t i = 0; i < this->connection_count_; i++) { + if (this->pending_disconnections_[i].matches(address)) { + this->pending_disconnections_[i].clear(); + } + } +} + void BluetoothProxy::reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason) { - this->send_device_connection(connection->get_address(), false, 0, reason); + if (!this->send_device_connection(connection->get_address(), false, 0, reason)) { + // The client has no other way to learn of an unsolicited disconnect; + // latch and let loop()'s paced drain deliver it. V by design: a louder + // level would ride the same congested link this reports on. + ESP_LOGV(TAG, "[%d] [%s] Disconnect notification deferred, TCP buffer full", connection->get_connection_index(), + connection->address_str()); + this->latch_pending_disconnection_(connection->get_address(), reason); + } connection->set_address(0); connection->send_service_ = INIT_SENDING_SERVICES; this->send_connections_free(); @@ -206,14 +251,20 @@ BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool rese auto *connection = this->connections_[i]; uint64_t conn_addr = connection->get_address(); - if (conn_addr == address) + if (conn_addr == address) { + // A connect request supersedes an owed disconnect. + if (reserve) { + this->clear_pending_disconnection_(address); + } return connection; + } if (free_slot == nullptr && conn_addr == 0) free_slot = connection; } if (!reserve || free_slot == nullptr) return nullptr; + this->clear_pending_disconnection_(address); free_slot->send_service_ = INIT_SENDING_SERVICES; free_slot->set_address(address); // All connections must start at INIT @@ -387,7 +438,30 @@ void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetSer } if (!connection->has_gatt_services()) { ESP_LOGW(TAG, "[%d] [%s] No GATT services found", connection->get_connection_index(), connection->address_str()); - this->send_gatt_services_done(msg.address); + // Through the retrying sender: a drop must not leave discovery hanging. + // Re-entry does not depend on the cursor - this branch is gated on + // has_gatt_services() alone, so no restore is needed. + connection->send_services_done_(); + return; + } + if (connection->send_service_ > 0) { + // A request mid-stream restarts from the top so the requester always + // gets the full list. No duplicate risk: the client accumulates batches + // per request, and a same-session re-request only happens after the + // previous request timed out and discarded its partial list. + ESP_LOGD(TAG, "[%d] [%s] GetServices mid-stream, restarting", connection->get_connection_index(), + connection->address_str()); + connection->send_service_ = 0; + return; + } + if (connection->send_service_ == SERVICES_DONE_PENDING) { + // A new request supersedes an owed done: the client accumulates batches + // per request, so its fresh, empty accumulator plus a bare done would + // cache as an empty database. The table is freed; the client's timeout + // arbitrates. + ESP_LOGW(TAG, "[%d] [%s] GetServices superseded an undelivered done; client timeout will retry", + connection->get_connection_index(), connection->address_str()); + connection->send_service_ = DONE_SENDING_SERVICES; return; } if (connection->send_service_ == INIT_SENDING_SERVICES) // Start sending services if not started yet @@ -515,7 +589,27 @@ void BluetoothProxy::loop() { return; } -#ifndef USE_BLE_SCANNER_STATE_CALLBACK +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + // Paced retries of owed per-slot notifications; subscriber swaps clear + // stale latches before this runs. + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto *connection = this->connections_[i]; + if (connection->send_service_ == SERVICES_DONE_PENDING) { + connection->send_services_done_(); + } + auto &owed = this->pending_disconnections_[i]; + if (!owed.empty() && this->send_device_connection(owed.address(), false, 0, owed.error())) { + owed.clear(); + } + } +#endif + +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + // Resend a dropped scanner-state push (see scanner_state_pending_). + if (this->scanner_state_pending_) { + this->send_scanner_state_(this->hub_->get_scanner_state()); + } +#else // This hub doesn't push scanner-state transitions; poll and report on // change. A hub gaining push emits the define and drops this poll. if (this->hub_->scan_running() != this->last_scan_running_) { @@ -601,24 +695,35 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn #endif // !BLUETOOTH_CONNECTION_HAS_GATT void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) { - if (this->api_connection_ != nullptr && this->api_connection_ != api_connection) { - // A previous subscriber still holds the slot. This is almost always a stale - // connection from a client that dropped without a clean disconnect and has - // not yet hit the keepalive timeout; rejecting the new subscriber would - // silently starve it of advertisements until it reconnects, so the newest - // subscriber wins instead. - char old_peername[socket::SOCKADDR_STR_LEN]; - char new_peername[socket::SOCKADDR_STR_LEN]; - ESP_LOGW(TAG, "Subscription from %s (%s) replaces %s (%s)", api_connection->get_name(), - api_connection->get_peername_to(new_peername), this->api_connection_->get_name(), - this->api_connection_->get_peername_to(old_peername)); + if (api_connection != this->api_connection_) { + if (this->api_connection_ != nullptr) { + // A previous subscriber still holds the slot. This is almost always a + // stale connection from a client that dropped without a clean disconnect + // and has not yet hit the keepalive timeout; rejecting the new + // subscriber would silently starve it of advertisements until it + // reconnects, so the newest subscriber wins instead. + char old_peername[socket::SOCKADDR_STR_LEN]; + char new_peername[socket::SOCKADDR_STR_LEN]; + ESP_LOGW(TAG, "Subscription from %s (%s) replaces %s (%s)", api_connection->get_name(), + api_connection->get_peername_to(new_peername), this->api_connection_->get_name(), + this->api_connection_->get_peername_to(old_peername)); + } + // Stale retry latches belong to the previous subscriber's session; a + // re-subscribe by the current one keeps what it is still owed. + this->connections_free_pending_ = false; +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + for (uint8_t i = 0; i < this->connection_count_; i++) { + // Neither a partial stream's tail nor an owed done belongs to the new + // session; silence (the client's timeout) arbitrates. + this->connections_[i]->park_service_stream_(); + } + this->pending_disconnections_.fill({}); +#endif } - // A stale retry latch belongs to the previous subscriber's session. - this->connections_free_pending_ = false; this->api_connection_ = api_connection; #ifdef USE_BLE_SCANNER_STATE_CALLBACK // get_scanner_state() is part of the push-hub surface (see BLEHubContract). - this->send_bluetooth_scanner_state_(this->hub_->get_scanner_state()); + this->send_scanner_state_(this->hub_->get_scanner_state()); #else this->send_polled_scanner_state_(); #endif @@ -631,20 +736,11 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti } this->api_connection_ = nullptr; this->connections_free_pending_ = false; +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + this->scanner_state_pending_ = false; +#endif } -void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, conn_err_t error) { - if (this->api_connection_ == nullptr) - return; - api::BluetoothDeviceConnectionResponse call; - call.address = address; - call.connected = connected; - call.mtu = mtu; - call.error = error; - // Fire and forget: a drop is covered by the client's own timeouts and the - // retried connections-free state. - this->api_connection_->send_message(call); -} void BluetoothProxy::send_connections_free() { if (this->api_connection_ != nullptr) { this->send_connections_free(this->api_connection_); @@ -661,12 +757,23 @@ void BluetoothProxy::send_connections_free(api::APIConnection *api_connection) { } } -void BluetoothProxy::send_gatt_services_done(uint64_t address) { +bool BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, conn_err_t error) { if (this->api_connection_ == nullptr) - return; + return true; // Nobody subscribed: nothing owed + api::BluetoothDeviceConnectionResponse call; + call.address = address; + call.connected = connected; + call.mtu = mtu; + call.error = error; + return this->api_connection_->send_message(call); +} + +bool BluetoothProxy::send_gatt_services_done(uint64_t address) { + if (this->api_connection_ == nullptr) + return true; // Nobody subscribed: nothing is owed, only a refused frame reports false api::BluetoothGATTGetServicesDoneResponse call; call.address = address; - this->api_connection_->send_message(call); + return this->api_connection_->send_message(call); } void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error) { diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index d3e3144831..725429df24 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -24,7 +24,9 @@ namespace esphome::bluetooth_proxy { using bluetooth_connection::CONN_OK; using bluetooth_connection::conn_err_t; using bluetooth_connection::GATT_NOT_CONNECTED; +using bluetooth_connection::DONE_SENDING_SERVICES; using bluetooth_connection::INIT_SENDING_SERVICES; +using bluetooth_connection::SERVICES_DONE_PENDING; #ifdef BLUETOOTH_CONNECTION_HAS_GATT using BluetoothConnection = bluetooth_connection::BluetoothConnection; @@ -57,6 +59,43 @@ enum BluetoothProxySubscriptionFlag : uint32_t { SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0, }; +#ifdef BLUETOOTH_CONNECTION_HAS_GATT +/// One owed freed-slot connected=false notification in a single word: the +/// 48-bit address in the low bits, the sign-extending 16-bit reason on top. +/// Every reason that reaches the pool (esp_gatt_status_t, +/// esp_gatt_conn_reason_t, generic ESP_ERR_*, -1) fits int16_t. +class PendingDisconnect { + public: + constexpr void set(uint64_t address, conn_err_t error) { + // Mask: the address originates from the client, and a stray high bit + // must not corrupt the reason. + this->word_ = (address & ADDRESS_MASK) | (static_cast(static_cast(error)) << 48); + } + constexpr void clear() { this->word_ = 0; } + // Whole-word test: set() is only ever given a live (nonzero) address. + constexpr bool empty() const { return this->word_ == 0; } + // Masked like set(), so a stray high bit cannot defeat the pool lookups. + constexpr bool matches(uint64_t address) const { return this->address() == (address & ADDRESS_MASK); } + constexpr uint64_t address() const { return this->word_ & ADDRESS_MASK; } + constexpr conn_err_t error() const { return static_cast(this->word_ >> 48); } + + private: + static constexpr uint64_t ADDRESS_MASK = 0x0000FFFFFFFFFFFFULL; + uint64_t word_{0}; +}; +// Pin the packing at compile time: mask and sign round-trip for every +// reachable shape (negative, GATT status, ESP_ERR_* range, stray high bit). +constexpr bool pending_disconnect_round_trips(uint64_t address, uint64_t expected_address, conn_err_t error) { + PendingDisconnect p; + p.set(address, error); + return p.address() == expected_address && p.error() == error && !p.empty() && p.matches(address); +} +static_assert(pending_disconnect_round_trips(0x0000112233445566ULL, 0x0000112233445566ULL, -1)); +static_assert(pending_disconnect_round_trips(0x0000FFFFFFFFFFFFULL, 0x0000FFFFFFFFFFFFULL, 0x8F)); +static_assert(pending_disconnect_round_trips(0xABCD112233445566ULL, 0x0000112233445566ULL, 0x110)); +static_assert(PendingDisconnect{}.empty()); +#endif + class BluetoothProxy final : public Component { #ifdef BLUETOOTH_CONNECTION_HAS_GATT // Allow the connection to update connections_free_response_ @@ -97,10 +136,14 @@ class BluetoothProxy final : public Component { return this->api_connection_ != nullptr && this->api_connection_->client_supports_api_version(1, 12); } - void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, conn_err_t error = CONN_OK); + /// False only when a subscriber refused the frame; true = delivered or + /// nobody subscribed. Request-answer callers ignore the result (client + /// timeouts cover those); only reset_connection_slot_ latches for retry. + bool send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, conn_err_t error = CONN_OK); void send_connections_free(); void send_connections_free(api::APIConnection *api_connection); - void send_gatt_services_done(uint64_t address); + /// Same convention as send_device_connection: false only on a refused frame. + bool send_gatt_services_done(uint64_t address); void send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error); void send_device_pairing(uint64_t address, bool paired, conn_err_t error = CONN_OK); void send_device_unpairing(uint64_t address, bool success, conn_err_t error = CONN_OK); @@ -172,7 +215,9 @@ class BluetoothProxy final : public Component { protected: bool send_bluetooth_scanner_state_(ble_device_base::ScannerState state); -#ifndef USE_BLE_SCANNER_STATE_CALLBACK +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + void send_scanner_state_(ble_device_base::ScannerState state); +#else void send_polled_scanner_state_(); #endif void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw); @@ -231,6 +276,10 @@ class BluetoothProxy final : public Component { /// a 30-second timeout (DEFAULT_BLE_TIMEOUT) to detect incomplete service /// discovery and retry, rather than being told a partial list is complete. void reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason); + /// Drop any owed freed-slot notification for this address (client reconnected). + void clear_pending_disconnection_(uint64_t address); + /// Pool a refused freed-slot notification for the paced drain. + void latch_pending_disconnection_(uint64_t address, conn_err_t error); #endif // Memory optimized layout for 32-bit systems @@ -240,6 +289,10 @@ class BluetoothProxy final : public Component { #ifdef BLUETOOTH_CONNECTION_HAS_GATT // Group 2: Fixed-size array of connection pointers std::array connections_{}; + // Address-keyed pool of owed freed-slot notifications; loop() resends. + // Proxy-only state, kept off BluetoothConnection; entries are not tied to + // slot indices. + std::array pending_disconnections_{}; #endif ble_device_base::BLEHub *hub_{nullptr}; // Group 3: 4-byte types; paired with hub_ so the 8-aligned messages below @@ -260,7 +313,11 @@ class BluetoothProxy final : public Component { bool connections_free_pending_{false}; uint8_t connection_count_{0}; bool configured_scan_active_{false}; // Configured scan mode from YAML -#ifndef USE_BLE_SCANNER_STATE_CALLBACK +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + // A dropped push (full TX buffer) is re-queried from the hub and resent + // from loop(); the hub's current state is idempotent by construction. + bool scanner_state_pending_{false}; +#else bool last_scan_running_{false}; // Last scanner state reported to the subscriber #endif }; From 8728aaa6163d163219ba0447e0d3c73634e36d2a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:21:03 +1200 Subject: [PATCH 1379/1815] [core] Use MAC address size constants in BLE components (#18252) --- esphome/components/api/api_connection.cpp | 4 ++-- esphome/components/bk72xx_ble/bk72xx_ble.cpp | 12 +++++------ esphome/components/bk72xx_ble/bk72xx_ble.h | 20 +++++++++---------- .../bk72xx_ble_tracker/bk72xx_ble_tracker.h | 4 ++-- .../components/ble_device_base/ble_device.cpp | 2 +- .../components/ble_device_base/ble_device.h | 2 +- .../ble_device_base/scan_response_merger.cpp | 8 +++++--- .../ble_device_base/scan_response_merger.h | 2 +- .../bluetooth_connection_hub.cpp | 2 +- .../bluetooth_connection_rp2.cpp | 3 ++- .../bluetooth_proxy/bluetooth_proxy.cpp | 2 +- .../bluetooth_proxy/bluetooth_proxy.h | 5 +++-- esphome/components/esp32_ble/ble.cpp | 10 +++++----- esphome/components/esp32_ble/ble.h | 2 +- .../esp32_ble_tracker/esp32_ble_tracker.h | 2 +- esphome/components/ln882h_ble/ln882h_ble.cpp | 6 +++--- esphome/components/ln882h_ble/ln882h_ble.h | 6 +++--- .../ln882h_ble_tracker/ln882h_ble_tracker.h | 4 ++-- esphome/components/rp2040_ble/rp2040_ble.cpp | 9 ++++++--- esphome/components/rp2040_ble/rp2040_ble.h | 8 ++++---- .../rp2_ble_tracker/rp2_ble_tracker.h | 2 +- esphome/components/xiaomi_ble/xiaomi_ble.cpp | 4 ++-- 22 files changed, 63 insertions(+), 56 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index cb57db9ce8..2d03052d63 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -23,6 +23,7 @@ #include "esphome/core/application.h" #include "esphome/core/entity_base.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/version.h" #ifdef USE_PROVISIONING @@ -1849,8 +1850,7 @@ bool APIConnection::send_device_info_response_() { #endif #ifdef USE_BLUETOOTH_PROXY resp.bluetooth_proxy_feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags(); - // Stack buffer for Bluetooth MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes) - char bluetooth_mac[18]; + char bluetooth_mac[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac); resp.bluetooth_mac_address = StringRef(bluetooth_mac); #endif diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.cpp b/esphome/components/bk72xx_ble/bk72xx_ble.cpp index 954cb9fe87..d40f08d111 100644 --- a/esphome/components/bk72xx_ble/bk72xx_ble.cpp +++ b/esphome/components/bk72xx_ble/bk72xx_ble.cpp @@ -116,7 +116,7 @@ void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t add this->report_queue_.increment_dropped_count(); return; } - memcpy(report->mac, mac, 6); + memcpy(report->mac, mac, MAC_ADDRESS_SIZE); report->rssi = rssi; report->addr_type = addr_type; report->evt_type = evt_type; @@ -230,7 +230,7 @@ void BK72xxBLE::loop() { ESP_LOGW(TAG, "Dropped %u scan reports due to queue overflow", dropped); } -void BK72xxBLE::get_mac_lsb_first(uint8_t out[6]) const { +void BK72xxBLE::get_mac_lsb_first(uint8_t out[MAC_ADDRESS_SIZE]) const { for (int i = 0; i < 6; i++) out[i] = this->ble_mac_[i]; } @@ -263,7 +263,7 @@ void BK72xxBLE::resolve_mac_() { } } if (nonzero) { - memcpy(this->ble_mac_, common_default_bdaddr.addr, 6); + memcpy(this->ble_mac_, common_default_bdaddr.addr, MAC_ADDRESS_SIZE); return; } #endif @@ -275,10 +275,10 @@ void BK72xxBLE::resolve_mac_() { // (verified against the BK7231N BLE-5.1 and BK7252N/BK7238 BLE-5.2 SDK sources), so it // matches on every device, including the last-byte == 0xFF edge that a 24-bit increment // would carry differently. - uint8_t wifi_mac[6]; + uint8_t wifi_mac[MAC_ADDRESS_SIZE]; get_mac_address_raw(wifi_mac); // MSB-first - const uint8_t ble[6] = {wifi_mac[0], wifi_mac[1], wifi_mac[2], - wifi_mac[3], wifi_mac[4], static_cast(wifi_mac[5] + 1)}; + const uint8_t ble[MAC_ADDRESS_SIZE] = {wifi_mac[0], wifi_mac[1], wifi_mac[2], + wifi_mac[3], wifi_mac[4], static_cast(wifi_mac[5] + 1)}; // Store LSB-first to match recv_adv_t adv_addr ordering. for (int i = 0; i < 6; i++) this->ble_mac_[i] = ble[5 - i]; diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.h b/esphome/components/bk72xx_ble/bk72xx_ble.h index 7646f17161..687fd396e4 100644 --- a/esphome/components/bk72xx_ble/bk72xx_ble.h +++ b/esphome/components/bk72xx_ble/bk72xx_ble.h @@ -40,8 +40,8 @@ struct ScanParams { /// One advertisement report from the controller. struct BLEScanReport { - uint8_t mac[6]; // LSB-first, as the controller delivers it - int8_t rssi; // signed dBm + uint8_t mac[MAC_ADDRESS_SIZE]; // LSB-first, as the controller delivers it + int8_t rssi; // signed dBm uint8_t addr_type; // GAPM report info byte (recv_adv_t.evt_type): bits 0-2 report type // (1 = legacy adv, 3 = legacy scan response), bit 5 scannable — lets the @@ -83,7 +83,7 @@ class BK72xxBLE final : public Component { void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } /// Controller BLE address, least-significant octet first (BLE convention). - void get_mac_lsb_first(uint8_t out[6]) const; + void get_mac_lsb_first(uint8_t out[MAC_ADDRESS_SIZE]) const; #ifdef BK72XX_BLE_SCAN_LISTENER_COUNT /// Register a consumer for scan reports (delivered on the main task via loop()). @@ -135,13 +135,13 @@ class BK72xxBLE final : public Component { esphome::EventPool report_pool_; // Largest-to-smallest: padding only at the tail, absorbed by future byte fields. uint32_t last_advance_ms_{0}; - uint32_t pending_since_ms_{0}; // bring-up budget anchor; refilled on request change - uint32_t teardown_since_ms_{0}; // unfinished teardown episode start; 0 = none - uint32_t teardown_stuck_log_ms_{0}; // last stuck-teardown ERROR; re-logged each TEARDOWN_STUCK_ERROR_MS - int last_release_err_{0}; // SDK code of the episode's last failed release; 0 = none - ScanParams requested_{}; // latched by scan_start() - ScanParams applied_{}; // last params we commanded; mismatch with requested_ restarts - uint8_t ble_mac_[6]{0}; // LSB-first (BLE convention) + uint32_t pending_since_ms_{0}; // bring-up budget anchor; refilled on request change + uint32_t teardown_since_ms_{0}; // unfinished teardown episode start; 0 = none + uint32_t teardown_stuck_log_ms_{0}; // last stuck-teardown ERROR; re-logged each TEARDOWN_STUCK_ERROR_MS + int last_release_err_{0}; // SDK code of the episode's last failed release; 0 = none + ScanParams requested_{}; // latched by scan_start() + ScanParams applied_{}; // last params we commanded; mismatch with requested_ restarts + uint8_t ble_mac_[MAC_ADDRESS_SIZE]{0}; // LSB-first (BLE convention) uint8_t scan_activity_idx_{INVALID_ACTIVITY_IDX}; bool scan_wanted_{false}; // the latched request is to scan (vs stopped) bool release_warned_{false}; // gates the release WARN; widens the pump gate diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h index 59d17f9b84..2334cfe414 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h @@ -116,8 +116,8 @@ class BK72xxBLETracker : public Component, bool request_scan_mode(bool active); // The controller stores the address LSB-first (BLE convention); the contract // wants printable (MSB-first) order. - void get_adapter_mac(uint8_t out[6]) { - uint8_t mac[6]; + void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) { + uint8_t mac[MAC_ADDRESS_SIZE]; this->parent_->get_mac_lsb_first(mac); for (int i = 0; i < 6; i++) out[i] = mac[5 - i]; diff --git a/esphome/components/ble_device_base/ble_device.cpp b/esphome/components/ble_device_base/ble_device.cpp index fc5bf5c1e0..23ca6b1dbd 100644 --- a/esphome/components/ble_device_base/ble_device.cpp +++ b/esphome/components/ble_device_base/ble_device.cpp @@ -137,7 +137,7 @@ void ESPBTDevice::parse_scan_rst(const esp32_ble::BLEScanResult &scan_result) { // BLEScanResult's bda is most-significant octet first; the neutral ingest // takes the BLE controller (LSB-first) order, so reverse — address_uint64()/ // address_str_to() then produce exactly the historical esp32 values. - uint8_t mac_lsb_first[6]; + uint8_t mac_lsb_first[MAC_ADDRESS_SIZE]; for (uint8_t i = 0; i < 6; i++) mac_lsb_first[i] = scan_result.bda[5 - i]; this->from_scan_result(mac_lsb_first, scan_result.rssi, scan_result.ble_addr_type, scan_result.ble_adv, diff --git a/esphome/components/ble_device_base/ble_device.h b/esphome/components/ble_device_base/ble_device.h index b5f198375c..668f7e09f8 100644 --- a/esphome/components/ble_device_base/ble_device.h +++ b/esphome/components/ble_device_base/ble_device.h @@ -241,7 +241,7 @@ class ESPBTDevice { // the 2-byte element header); every in-tree tracker scans legacy PDUs only. static constexpr uint8_t MAX_ADV_NAME_LEN = 29; - uint8_t address_[6]{0}; + uint8_t address_[MAC_ADDRESS_SIZE]{0}; uint8_t address_type_{0}; int rssi_{0}; // Fixed buffer instead of std::string: no per-advertisement heap churn on diff --git a/esphome/components/ble_device_base/scan_response_merger.cpp b/esphome/components/ble_device_base/scan_response_merger.cpp index 2dd1fd6927..2c0d766683 100644 --- a/esphome/components/ble_device_base/scan_response_merger.cpp +++ b/esphome/components/ble_device_base/scan_response_merger.cpp @@ -2,6 +2,8 @@ #ifdef USE_BLE_SCAN_RESPONSE_MERGER +#include "esphome/core/helpers.h" + #include namespace esphome::ble_device_base { @@ -27,7 +29,7 @@ void ScanResponseMerger::stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr free_slot = &p; continue; } - if (p.addr_type == addr_type && memcmp(p.mac, mac, 6) == 0) { + if (p.addr_type == addr_type && memcmp(p.mac, mac, MAC_ADDRESS_SIZE) == 0) { // Same device advertised again before its scan response arrived — deliver // the previous advertisement (its scan response is not coming) and reuse // the slot, so no frame is ever lost. @@ -47,7 +49,7 @@ void ScanResponseMerger::stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr } slot->used = true; this->pending_count_++; - memcpy(slot->mac, mac, 6); + memcpy(slot->mac, mac, MAC_ADDRESS_SIZE); slot->addr_type = addr_type; slot->rssi = rssi; slot->data_len = (data_len <= sizeof(slot->data)) ? data_len : sizeof(slot->data); @@ -61,7 +63,7 @@ void ScanResponseMerger::submit_scan_rsp(const uint8_t *mac, int8_t rssi, uint8_ // hottest caller. if (this->pending_count_ != 0) { for (auto &p : this->pending_adv_) { - if (p.used && p.addr_type == addr_type && memcmp(p.mac, mac, 6) == 0) { + if (p.used && p.addr_type == addr_type && memcmp(p.mac, mac, MAC_ADDRESS_SIZE) == 0) { // Append in place: the slot is released on delivery, so its 62-byte // buffer (legacy adv + scan response) holds the merged frame directly. const uint8_t room = sizeof(p.data) - p.data_len; diff --git a/esphome/components/ble_device_base/scan_response_merger.h b/esphome/components/ble_device_base/scan_response_merger.h index 9415664fcf..f28790f207 100644 --- a/esphome/components/ble_device_base/scan_response_merger.h +++ b/esphome/components/ble_device_base/scan_response_merger.h @@ -120,7 +120,7 @@ class ScanResponseMerger { // as ESP-IDF delivers on ESP32. struct PendingAdv { bool used{false}; - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; uint8_t addr_type; int8_t rssi; uint8_t data_len; // <= sizeof(data) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index f79669dc32..0b5d996349 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -21,7 +21,7 @@ void BluetoothConnection::set_address(uint64_t address) { this->address_str_[0] = '\0'; return; } - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; ble_device_base::uint64_to_mac_msb_first(address, mac); format_mac_addr_upper(mac, this->address_str_); } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index dc77d448a5..dea3b5d9c8 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -5,6 +5,7 @@ #if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -1098,7 +1099,7 @@ int RP2GattClient::update_connection_params(uint16_t min_interval, uint16_t max_ } conn_err_t unpair_device(uint64_t address) { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; ble_device_base::uint64_to_mac_msb_first(address, mac); bool found = false; BluetoothLock lock; diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 6b49b28cd6..13c84b86d1 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -140,7 +140,7 @@ void BluetoothProxy::dump_config() { // Print configured facts. dump_config runs right after setup, before the // radio is up, so live scan state would always read "stopped" here — the // loop's BluetoothScannerStateResponse carries the changing value instead. - char mac_str[18]; + char mac_str[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; this->get_bluetooth_mac_address_pretty(mac_str); const char *mac_out = mac_str[0] != '\0' ? mac_str : "unavailable (adapter not up yet)"; const char *scan_mode = this->configured_scan_active_ ? "active" : "passive"; diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 725429df24..cf7a09a7e5 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -10,6 +10,7 @@ #include "esphome/components/api/api_pb2.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/helpers.h" #include "esphome/components/bluetooth_connection/bluetooth_connection.h" @@ -201,8 +202,8 @@ class BluetoothProxy final : public Component { return flags; } - void get_bluetooth_mac_address_pretty(std::span output) { - uint8_t mac[6] = {}; + void get_bluetooth_mac_address_pretty(std::span output) { + uint8_t mac[MAC_ADDRESS_SIZE] = {}; this->hub_->get_adapter_mac(mac); // Unavailable -> empty string: some hubs (rp2040's BTstack) only learn // the address once the link layer is up, and report all-zero until then. diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index d11683ab35..16501ef3b2 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -674,21 +674,21 @@ void ESP32BLE::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gat } #endif -void ESP32BLE::get_mac_msb_first(uint8_t out[6]) const { +void ESP32BLE::get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const { // The running stack owns the address (on hosted controllers it lives in // the remote chip's efuse); null before init becomes all-zero. const uint8_t *mac = esp_bt_dev_get_address(); if (mac != nullptr) { - memcpy(out, mac, 6); + memcpy(out, mac, MAC_ADDRESS_SIZE); } else { - memset(out, 0, 6); + memset(out, 0, MAC_ADDRESS_SIZE); } } float ESP32BLE::get_setup_priority() const { return setup_priority::BLUETOOTH; } void ESP32BLE::dump_config() { - uint8_t mac_address[6]; + uint8_t mac_address[MAC_ADDRESS_SIZE]; this->get_mac_msb_first(mac_address); if (mac_address_is_valid(mac_address)) { const char *io_capability_s; @@ -713,7 +713,7 @@ void ESP32BLE::dump_config() { break; } - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(mac_address, mac_s); ESP_LOGCONFIG(TAG, "BLE:\n" diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 45cfd8ee71..2a355a6c8b 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -109,7 +109,7 @@ class ESP32BLE final : public Component { void loop() override; void dump_config() override; /// Adapter MAC in printable (MSB-first) order; all-zero until the stack is up. - void get_mac_msb_first(uint8_t out[6]) const; + void get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const; float get_setup_priority() const override; void set_name(const char *name) { this->name_ = name; } diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 30b85b5417..7c3e5538fd 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -200,7 +200,7 @@ class ESP32BLETracker final : public Component, return {/* active_scan = */ true, /* merges_scan_response = */ true, /* gatt = */ true, /* scan_mode_switch = */ false}; } - void get_adapter_mac(uint8_t out[6]) { this->parent_->get_mac_msb_first(out); } + void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) { this->parent_->get_mac_msb_first(out); } bool scan_running() { return this->scanner_state_ == ScannerState::RUNNING; } bool scan_active() { return this->scan_active_; } // The mode is driven through this tracker's own API (see get_capabilities); diff --git a/esphome/components/ln882h_ble/ln882h_ble.cpp b/esphome/components/ln882h_ble/ln882h_ble.cpp index 152ca571e9..021e138f08 100644 --- a/esphome/components/ln882h_ble/ln882h_ble.cpp +++ b/esphome/components/ln882h_ble/ln882h_ble.cpp @@ -236,7 +236,7 @@ static void ble_scan_callback(void *param) { // downstream the value is used exactly like on ESP32. const int8_t raw = info->rssi; - memcpy(slot->mac, info->trans_addr, 6); + memcpy(slot->mac, info->trans_addr, MAC_ADDRESS_SIZE); slot->rssi = (raw > 20) ? static_cast(-raw) : raw; slot->addr_type = info->trans_addr_type; slot->is_scan_response = report_type == GAPM_REPORT_TYPE_SCAN_RSP_LEG; @@ -407,7 +407,7 @@ void LN882HBLE::resolve_mac_() { ESP_LOGW(TAG, "BLE address KV unavailable; deriving address from WiFi MAC"); } if (!have_unique_addr) { - uint8_t wifi_mac[6] = {0}; + uint8_t wifi_mac[MAC_ADDRESS_SIZE] = {0}; get_mac_address_raw(wifi_mac); // MSB-first // Reverse into controller (LSB-first) order, then BLE = WiFi + 1: increment // the NIC low byte (addr[0] once reversed), no carry, OUI unchanged — the @@ -421,7 +421,7 @@ void LN882HBLE::resolve_mac_() { ESP_LOGD(TAG, "MAC derived (WiFi+1) and stored"); } } - memcpy(this->ble_mac_, bt_addr.addr, 6); + memcpy(this->ble_mac_, bt_addr.addr, MAC_ADDRESS_SIZE); } // --------------------------------------------------------------------------- diff --git a/esphome/components/ln882h_ble/ln882h_ble.h b/esphome/components/ln882h_ble/ln882h_ble.h index 2186822208..5b4a67b566 100644 --- a/esphome/components/ln882h_ble/ln882h_ble.h +++ b/esphome/components/ln882h_ble/ln882h_ble.h @@ -23,8 +23,8 @@ enum class BLEComponentState : uint8_t { /// One scan report from the controller, decoded from the SDK's rw-task event /// (RSSI already sign-corrected). struct BLEScanReport { - uint8_t mac[6]; // as the controller delivers it (LSB-first) - int8_t rssi; // signed dBm (-127..+20) + uint8_t mac[MAC_ADDRESS_SIZE]; // as the controller delivers it (LSB-first) + int8_t rssi; // signed dBm (-127..+20) uint8_t addr_type; bool is_scan_response; // report is a scan response (active scan) bool scannable; // advertisement may be followed by a scan response @@ -138,7 +138,7 @@ class LN882HBLE final : public Component { // Reports rejected by the legacy-only filter (rw-task producer, main-task // consumer via exchange in loop()). std::atomic rejected_reports_{0}; - uint8_t ble_mac_[6]{0}; // controller (LSB-first) order, as ln_bd_addr_t stores it + uint8_t ble_mac_[MAC_ADDRESS_SIZE]{0}; // controller (LSB-first) order, as ln_bd_addr_t stores it BLEComponentState state_{BLEComponentState::STATE_OFF}; bool enable_on_boot_{false}; bool scanning_{false}; // controller scan running (re-entry guard for scan_start) diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h index 2d88b938dd..dc42aebce9 100644 --- a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h @@ -90,8 +90,8 @@ class LN882HBLETracker : public Component, } // The controller stores the address LSB-first (BLE convention); the contract // wants printable (MSB-first) order. - void get_adapter_mac(uint8_t out[6]) { - uint8_t mac[6]; + void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) { + uint8_t mac[MAC_ADDRESS_SIZE]; this->parent_->get_mac_lsb_first(mac); for (int i = 0; i < 6; i++) out[i] = mac[5 - i]; diff --git a/esphome/components/rp2040_ble/rp2040_ble.cpp b/esphome/components/rp2040_ble/rp2040_ble.cpp index 7dd84d9c31..80e8bf9415 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.cpp +++ b/esphome/components/rp2040_ble/rp2040_ble.cpp @@ -2,6 +2,7 @@ #ifdef USE_RP2040_BLE +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -180,7 +181,7 @@ void RP2040BLE::packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, // ESPHome main loop: bounded copy into the lock-free queue only. bd_addr_t addr; // accessor returns printable (MSB-first) order gap_event_advertising_report_get_address(packet, addr); - uint8_t mac_lsb[6]; + uint8_t mac_lsb[MAC_ADDRESS_SIZE]; reverse_bd_addr(addr, mac_lsb); // LSB-first, the BLE convention consumers expect global_ble->enqueue_scan_report_(mac_lsb, static_cast(gap_event_advertising_report_get_rssi(packet)), gap_event_advertising_report_get_address_type(packet), @@ -206,7 +207,7 @@ void RP2040BLE::enqueue_scan_report_(const uint8_t *mac_lsb_first, int8_t rssi, this->report_queue_.increment_dropped_count(); return; } - memcpy(report->mac, mac_lsb_first, 6); + memcpy(report->mac, mac_lsb_first, MAC_ADDRESS_SIZE); report->rssi = rssi; report->addr_type = addr_type; report->adv_event_type = adv_event_type; @@ -217,7 +218,9 @@ void RP2040BLE::enqueue_scan_report_(const uint8_t *mac_lsb_first, int8_t rssi, } // NOLINTEND(clang-analyzer-unix.Malloc) -void RP2040BLE::get_mac_msb_first(uint8_t out[6]) const { memcpy(out, this->ble_mac_, 6); } +void RP2040BLE::get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const { + memcpy(out, this->ble_mac_, MAC_ADDRESS_SIZE); +} bool RP2040BLE::scan_start(uint16_t interval, uint16_t window, bool active) { if (!this->is_active()) { diff --git a/esphome/components/rp2040_ble/rp2040_ble.h b/esphome/components/rp2040_ble/rp2040_ble.h index 99eb8cd88a..263a32106b 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.h +++ b/esphome/components/rp2040_ble/rp2040_ble.h @@ -25,8 +25,8 @@ enum class BLEComponentState : uint8_t { /// One advertisement report from the controller. struct BLEScanReport { - uint8_t mac[6]; // LSB-first, as the controller delivers it - int8_t rssi; // signed dBm + uint8_t mac[MAC_ADDRESS_SIZE]; // LSB-first, as the controller delivers it + int8_t rssi; // signed dBm uint8_t addr_type; uint8_t adv_event_type; // GAP advertising event type (ADV_IND .. SCAN_RSP); lets a merger tell the two apart uint8_t data_len; // bytes valid in data[] @@ -77,7 +77,7 @@ class RP2040BLE final : public Component { /// (LSB-first) order, hence the explicit names. All zeros until the stack /// reports ACTIVE (BTstack reads the address from the controller during /// power-up). - void get_mac_msb_first(uint8_t out[6]) const; + void get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const; #ifdef RP2040_BLE_SCAN_LISTENER_COUNT /// Register a consumer for scan reports (delivered on the main loop via loop()). @@ -135,7 +135,7 @@ class RP2040BLE final : public Component { btstack_packet_callback_registration_t hci_event_callback_registration_{}; btstack_packet_callback_registration_t sm_event_callback_registration_{}; - uint8_t ble_mac_[6]{0}; // printable (MSB-first) order; zeros until ACTIVE + uint8_t ble_mac_[MAC_ADDRESS_SIZE]{0}; // printable (MSB-first) order; zeros until ACTIVE BLEComponentState state_{BLEComponentState::STATE_OFF}; bool enable_on_boot_{true}; bool btstack_initialized_{false}; diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h index 02bd7dc145..431f2daec7 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h @@ -71,7 +71,7 @@ class RP2BLETracker : public Component, } // The controller stores the address in printable (MSB-first) order, which is // exactly what the contract wants. - void get_adapter_mac(uint8_t out[6]) { this->parent_->get_mac_msb_first(out); } + void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) { this->parent_->get_mac_msb_first(out); } bool scan_running() { return this->scan_running_; } bool scan_active() { return this->scan_active_; } bool request_scan_mode(bool active); diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.cpp b/esphome/components/xiaomi_ble/xiaomi_ble.cpp index 0a05950c5a..06c3a7ab7a 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.cpp +++ b/esphome/components/xiaomi_ble/xiaomi_ble.cpp @@ -293,7 +293,7 @@ bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, c return false; } - uint8_t mac_reverse[6] = {0}; + uint8_t mac_reverse[MAC_ADDRESS_SIZE] = {0}; mac_reverse[5] = (uint8_t) (address >> 40); mac_reverse[4] = (uint8_t) (address >> 32); mac_reverse[3] = (uint8_t) (address >> 24); @@ -358,7 +358,7 @@ bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, c #endif if (!decrypt_ok) { - uint8_t mac_address[6] = {0}; + uint8_t mac_address[MAC_ADDRESS_SIZE] = {0}; memcpy(mac_address, mac_reverse + 5, 1); memcpy(mac_address + 1, mac_reverse + 4, 1); memcpy(mac_address + 2, mac_reverse + 3, 1); From f697cf20113a2e8375ecd44bb682b188f0e304a8 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:52:19 +1200 Subject: [PATCH 1380/1815] [api] Add DeviceCapabilities message for optional-feature flags (#17984) --- esphome/components/api/api.proto | 72 ++++ esphome/components/api/api_connection.cpp | 36 +- esphome/components/api/api_connection.h | 2 + esphome/components/api/api_pb2.cpp | 76 ++++ esphome/components/api/api_pb2.h | 68 ++++ esphome/components/api/api_pb2_dump.cpp | 49 +++ esphome/components/api/api_pb2_service.cpp | 7 + esphome/components/api/api_pb2_service.h | 2 + .../components/api/test_api_proto.py | 371 ++++++++++++++++++ 9 files changed, 682 insertions(+), 1 deletion(-) create mode 100644 tests/unit_tests/components/api/test_api_proto.py diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 4b3df62ec4..88af5957e7 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -19,6 +19,7 @@ service APIConnection { rpc device_info (DeviceInfoRequest) returns (DeviceInfoResponse) { option (needs_authentication) = false; } + rpc device_capabilities (DeviceCapabilitiesRequest) returns (DeviceCapabilitiesResponse) {} rpc list_entities (ListEntitiesRequest) returns (void) {} rpc subscribe_states (SubscribeStatesRequest) returns (void) {} rpc subscribe_logs (SubscribeLogsRequest) returns (void) {} @@ -243,6 +244,12 @@ message SerialProxyInfo { // model = 127 (core/config.BOARD_MAX_LENGTH, validated in platform schemas) // project_name/project_version = 127 (core/config.PROJECT_MAX_LENGTH) // suggested_area = 120 (core/config.FRIENDLY_NAME_MAX_LEN via AREA_SCHEMA) +// +// Some fields below are marked "Superseded by DeviceCapabilitiesResponse". They +// have moved to that message as of API 1.15, but are still sent here so that +// older clients keep working. Do NOT mark them (deprecated) until the removal +// release: in this repo (deprecated) makes the generator drop the field +// entirely, so the device would stop sending it. message DeviceInfoResponse { option (id) = 10; option (source) = SOURCE_SERVER; @@ -280,6 +287,8 @@ message DeviceInfoResponse { // Deprecated in API version 1.9 uint32 legacy_bluetooth_proxy_version = 11 [deprecated=true, (field_ifdef) = "USE_BLUETOOTH_PROXY"]; + + // Superseded by DeviceCapabilitiesResponse.bluetooth_proxy as of API 1.15. uint32 bluetooth_proxy_feature_flags = 15 [(field_ifdef) = "USE_BLUETOOTH_PROXY"]; string manufacturer = 12 [(max_data_length) = 20, (force) = true]; @@ -288,11 +297,14 @@ message DeviceInfoResponse { // Deprecated in API version 1.10 uint32 legacy_voice_assistant_version = 14 [deprecated=true, (field_ifdef) = "USE_VOICE_ASSISTANT"]; + + // Superseded by DeviceCapabilitiesResponse.voice_assistant as of API 1.15. uint32 voice_assistant_feature_flags = 17 [(field_ifdef) = "USE_VOICE_ASSISTANT"]; string suggested_area = 16 [(max_data_length) = 120, (force) = true, (field_ifdef) = "USE_AREAS"]; // The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA" + // Superseded by DeviceCapabilitiesResponse.bluetooth_proxy.mac_address as of API 1.15. string bluetooth_mac_address = 18 [(max_data_length) = 17, (force) = true, (field_ifdef) = "USE_BLUETOOTH_PROXY"]; // Supports receiving and saving api encryption key @@ -305,10 +317,13 @@ message DeviceInfoResponse { AreaInfo area = 22 [(field_ifdef) = "USE_AREAS"]; // Indicates if Z-Wave proxy support is available and features supported + // Superseded by DeviceCapabilitiesResponse.zwave_proxy as of API 1.15. uint32 zwave_proxy_feature_flags = 23 [(field_ifdef) = "USE_ZWAVE_PROXY"]; + // Superseded by DeviceCapabilitiesResponse.zwave_proxy as of API 1.15. uint32 zwave_home_id = 24 [(field_ifdef) = "USE_ZWAVE_PROXY"]; // Serial proxy instance metadata + // Superseded by DeviceCapabilitiesResponse.serial_proxies as of API 1.15. repeated SerialProxyInfo serial_proxies = 25 [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"]; // Device is unprovisioned and accepts Noise handshakes with the well-known @@ -317,6 +332,63 @@ message DeviceInfoResponse { bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"]; } +// ==================== DEVICE CAPABILITIES ==================== + +// Asks the device which optional features it supports. +// +// This message exists so that DeviceInfoResponse does not have to keep growing +// a flat list of feature flags. DeviceInfoResponse is served before +// authentication, so it is limited to identity information. Capabilities are +// only served on an authenticated connection (encrypted as well, when +// encryption is configured). +// +// Clients that see api_version >= 1.15 should read these values from +// DeviceCapabilitiesResponse and ignore the matching DeviceInfoResponse fields. +// Older clients keep reading DeviceInfoResponse, which still carries the same +// values, so this is not a breaking change. +message DeviceCapabilitiesRequest { + option (id) = 149; + option (source) = SOURCE_CLIENT; + // Empty +} + +// Each feature gets its own sub-message so that it can gain fields over time +// without crowding the top-level field numbering. +// +// Note: a sub-message whose fields are all at their default value is not sent +// at all, so the presence of a sub-message is not a reliable test for "this +// feature is compiled in". Clients should test a value inside it, for example +// a non-zero feature_flags, exactly as they do today with DeviceInfoResponse. + +message BluetoothProxyCapabilities { + // Bitmask of the features this proxy supports + uint32 feature_flags = 1; + // The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA" + string mac_address = 2 [(max_data_length) = 17, (force) = true]; +} + +message VoiceAssistantCapabilities { + // Bitmask of the features this voice assistant supports + uint32 feature_flags = 1; +} + +message ZWaveProxyCapabilities { + // Bitmask of the features this proxy supports + uint32 feature_flags = 1; + uint32 home_id = 2; +} + +message DeviceCapabilitiesResponse { + option (id) = 150; + option (source) = SOURCE_SERVER; + + BluetoothProxyCapabilities bluetooth_proxy = 1 [(field_ifdef) = "USE_BLUETOOTH_PROXY"]; + VoiceAssistantCapabilities voice_assistant = 2 [(field_ifdef) = "USE_VOICE_ASSISTANT"]; + ZWaveProxyCapabilities zwave_proxy = 3 [(field_ifdef) = "USE_ZWAVE_PROXY"]; + repeated SerialProxyInfo serial_proxies = 4 + [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"]; +} + message ListEntitiesRequest { option (id) = 11; option (source) = SOURCE_CLIENT; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 2d03052d63..18eb2592ff 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1736,7 +1736,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { HelloResponse resp; resp.api_version_major = 1; - resp.api_version_minor = 14; + resp.api_version_minor = 15; // Send only the version string - the client only logs this for debugging and doesn't use it otherwise resp.server_info = ESPHOME_VERSION_REF; resp.name = StringRef(App.get_name()); @@ -1904,6 +1904,35 @@ bool APIConnection::send_device_info_response_() { return this->send_message(resp); } +bool APIConnection::send_device_capabilities_response_() { + // These are the same values DeviceInfoResponse still reports for older clients. Keep the blocks + // below in sync with send_device_info_response_() until those copies are removed. + DeviceCapabilitiesResponse resp; +#ifdef USE_BLUETOOTH_PROXY + resp.bluetooth_proxy.feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags(); + char bluetooth_mac[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac); + resp.bluetooth_proxy.mac_address = StringRef(bluetooth_mac); +#endif +#ifdef USE_VOICE_ASSISTANT + resp.voice_assistant.feature_flags = voice_assistant::global_voice_assistant->get_feature_flags(); +#endif +#ifdef USE_ZWAVE_PROXY + resp.zwave_proxy.feature_flags = zwave_proxy::global_zwave_proxy->get_feature_flags(); + resp.zwave_proxy.home_id = zwave_proxy::global_zwave_proxy->get_home_id(); +#endif +#ifdef USE_SERIAL_PROXY + size_t serial_proxy_index = 0; + for (auto const &proxy : App.get_serial_proxies()) { + if (serial_proxy_index >= SERIAL_PROXY_COUNT) + break; + auto &info = resp.serial_proxies[serial_proxy_index++]; + info.name = StringRef(proxy->get_name()); + info.port_type = proxy->get_port_type(); + } +#endif + return this->send_message(resp); +} void APIConnection::on_hello_request(const HelloRequest &msg) { if (!this->send_hello_response_(msg)) { this->on_fatal_error(); @@ -1925,6 +1954,11 @@ void APIConnection::on_device_info_request() { this->on_fatal_error(); } } +void APIConnection::on_device_capabilities_request() { + if (!this->send_device_capabilities_response_()) { + this->on_fatal_error(); + } +} #ifdef USE_API_HOMEASSISTANT_STATES void APIConnection::on_home_assistant_state_response(const HomeAssistantStateResponse &msg) { diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 7df7ea1429..9ca1b8b6a4 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -266,6 +266,7 @@ class APIConnection final : public APIServerConnectionBase { void on_disconnect_request(const DisconnectRequest &msg); void on_ping_request(); void on_device_info_request(); + void on_device_capabilities_request(); void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); } void on_subscribe_states_request() { this->flags_.state_subscription = true; @@ -385,6 +386,7 @@ class APIConnection final : public APIServerConnectionBase { bool send_disconnect_response_(); bool send_ping_response_(); bool send_device_info_response_(); + bool send_device_capabilities_response_(); #ifdef USE_API_NOISE bool send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg); #endif diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 190bd32425..5776ec5c62 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -241,6 +241,82 @@ uint32_t DeviceInfoResponse::calculate_size() const { #endif return size; } +#ifdef USE_BLUETOOTH_PROXY +uint8_t *BluetoothProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->mac_address); + return pos; +} +uint32_t BluetoothProxyCapabilities::calculate_size() const { + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, this->feature_flags); + size += 2 + this->mac_address.size(); + return size; +} +#endif +#ifdef USE_VOICE_ASSISTANT +uint8_t *VoiceAssistantCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); + return pos; +} +uint32_t VoiceAssistantCapabilities::calculate_size() const { + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, this->feature_flags); + return size; +} +#endif +#ifdef USE_ZWAVE_PROXY +uint8_t *ZWaveProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->home_id); + return pos; +} +uint32_t ZWaveProxyCapabilities::calculate_size() const { + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, this->feature_flags); + size += ProtoSize::calc_uint32(1, this->home_id); + return size; +} +#endif +uint8_t *DeviceCapabilitiesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); +#ifdef USE_BLUETOOTH_PROXY + ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, this->bluetooth_proxy); +#endif +#ifdef USE_VOICE_ASSISTANT + ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, this->voice_assistant); +#endif +#ifdef USE_ZWAVE_PROXY + ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, this->zwave_proxy); +#endif +#ifdef USE_SERIAL_PROXY + for (const auto &it : this->serial_proxies) { + ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it); + } +#endif + return pos; +} +uint32_t DeviceCapabilitiesResponse::calculate_size() const { + uint32_t size = 0; +#ifdef USE_BLUETOOTH_PROXY + size += ProtoSize::calc_message(1, this->bluetooth_proxy.calculate_size()); +#endif +#ifdef USE_VOICE_ASSISTANT + size += ProtoSize::calc_message(1, this->voice_assistant.calculate_size()); +#endif +#ifdef USE_ZWAVE_PROXY + size += ProtoSize::calc_message(1, this->zwave_proxy.calculate_size()); +#endif +#ifdef USE_SERIAL_PROXY + for (const auto &it : this->serial_proxies) { + size += ProtoSize::calc_message_force(1, it.calculate_size()); + } +#endif + return size; +} #ifdef USE_BINARY_SENSOR uint8_t *ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 4d5866da0b..f35f551060 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -600,6 +600,74 @@ class DeviceInfoResponse final : public ProtoMessage { protected: }; +#ifdef USE_BLUETOOTH_PROXY +class BluetoothProxyCapabilities final : public ProtoMessage { + public: + uint32_t feature_flags{0}; + StringRef mac_address{}; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; + uint32_t calculate_size() const; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#endif +#ifdef USE_VOICE_ASSISTANT +class VoiceAssistantCapabilities final : public ProtoMessage { + public: + uint32_t feature_flags{0}; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; + uint32_t calculate_size() const; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#endif +#ifdef USE_ZWAVE_PROXY +class ZWaveProxyCapabilities final : public ProtoMessage { + public: + uint32_t feature_flags{0}; + uint32_t home_id{0}; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; + uint32_t calculate_size() const; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#endif +class DeviceCapabilitiesResponse final : public ProtoMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 150; + static constexpr uint8_t ESTIMATED_SIZE = 102; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("device_capabilities_response"); } +#endif +#ifdef USE_BLUETOOTH_PROXY + BluetoothProxyCapabilities bluetooth_proxy{}; +#endif +#ifdef USE_VOICE_ASSISTANT + VoiceAssistantCapabilities voice_assistant{}; +#endif +#ifdef USE_ZWAVE_PROXY + ZWaveProxyCapabilities zwave_proxy{}; +#endif +#ifdef USE_SERIAL_PROXY + std::array serial_proxies{}; +#endif + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; + uint32_t calculate_size() const; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; class ListEntitiesDoneResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 19; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 09570b09e4..17ce7fba45 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -988,6 +988,55 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const { #endif return out.c_str(); } +#ifdef USE_BLUETOOTH_PROXY +const char *BluetoothProxyCapabilities::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothProxyCapabilities")); + dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags); + dump_field(out, ESPHOME_PSTR("mac_address"), this->mac_address); + return out.c_str(); +} +#endif +#ifdef USE_VOICE_ASSISTANT +const char *VoiceAssistantCapabilities::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("VoiceAssistantCapabilities")); + dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags); + return out.c_str(); +} +#endif +#ifdef USE_ZWAVE_PROXY +const char *ZWaveProxyCapabilities::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("ZWaveProxyCapabilities")); + dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags); + dump_field(out, ESPHOME_PSTR("home_id"), this->home_id); + return out.c_str(); +} +#endif +const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceCapabilitiesResponse")); +#ifdef USE_BLUETOOTH_PROXY + out.append(2, ' ').append_p(ESPHOME_PSTR("bluetooth_proxy")).append(": "); + this->bluetooth_proxy.dump_to(out); + out.append("\n"); +#endif +#ifdef USE_VOICE_ASSISTANT + out.append(2, ' ').append_p(ESPHOME_PSTR("voice_assistant")).append(": "); + this->voice_assistant.dump_to(out); + out.append("\n"); +#endif +#ifdef USE_ZWAVE_PROXY + out.append(2, ' ').append_p(ESPHOME_PSTR("zwave_proxy")).append(": "); + this->zwave_proxy.dump_to(out); + out.append("\n"); +#endif +#ifdef USE_SERIAL_PROXY + for (const auto &it : this->serial_proxies) { + out.append(4, ' ').append_p(ESPHOME_PSTR("serial_proxies")).append(": "); + it.dump_to(out); + out.append("\n"); + } +#endif + return out.c_str(); +} const char *ListEntitiesDoneResponse::dump_to(DumpBuffer &out) const { out.append_p(ESPHOME_PSTR("ListEntitiesDoneResponse {}")); return out.c_str(); diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 5c9df433dd..19dcbfb77c 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -705,6 +705,13 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif + case 149 /* DeviceCapabilitiesRequest is empty */: { +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_device_capabilities_request")); +#endif + this->on_device_capabilities_request(); + break; + } default: break; } diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index d1b51f4846..5ed78b3385 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -27,6 +27,8 @@ class APIServerConnectionBase { void on_ping_response(){}; void on_device_info_request(){}; + void on_device_capabilities_request(){}; + void on_list_entities_request(){}; void on_subscribe_states_request(){}; diff --git a/tests/unit_tests/components/api/test_api_proto.py b/tests/unit_tests/components/api/test_api_proto.py new file mode 100644 index 0000000000..35aa5ff529 --- /dev/null +++ b/tests/unit_tests/components/api/test_api_proto.py @@ -0,0 +1,371 @@ +"""Invariant tests for esphome/components/api/api.proto and its generated code. + +These guard the DeviceCapabilitiesRequest/DeviceCapabilitiesResponse addition +(API 1.15) against regressions that protoc-based codegen would not catch on +its own, without requiring protoc to be installed at test time: + +* script/api_protobuf/api_protobuf.py skips any field marked + `[deprecated = true]` completely -- it generates no C++ for it at all, so + the device silently stops sending that value. Six DeviceInfoResponse fields + were superseded by DeviceCapabilitiesResponse but must keep being sent for + backward compatibility with clients older than API 1.15. If a future edit + "tidies up" by marking one of them deprecated, this file breaks that field + for every existing client with nothing else in CI noticing. +* Field numbers are the wire protocol, not the field names. Renaming a field + is harmless; renumbering it is a silent breaking change, because an old + client still decodes by number. This file pins the field number of each of + the six superseded DeviceInfoResponse fields and of every field on the new + DeviceCapabilitiesResponse/BluetoothProxyCapabilities/ + VoiceAssistantCapabilities/ZWaveProxyCapabilities sub-messages, so a + well-intentioned reshuffle of api.proto gets caught here instead of on a + device in the field. +* Message wire ids must be unique, and the new capabilities RPC must stay + authenticated-only. + +Group A below asserts on the checked-in generated files (api_pb2.h / +api_pb2.cpp), since "the field is present in the generated C++" is exactly +equivalent to "the device still sends it". Group B parses api.proto as plain +text (no protoc). Group C checks the advertised API minor version. +""" + +from __future__ import annotations + +from pathlib import Path +import re + +import esphome + +API_DIR = Path(esphome.__file__).parent / "components" / "api" + +PROTO_TEXT = (API_DIR / "api.proto").read_text(encoding="utf-8") +HEADER_TEXT = (API_DIR / "api_pb2.h").read_text(encoding="utf-8") +CPP_TEXT = (API_DIR / "api_pb2.cpp").read_text(encoding="utf-8") +API_CONNECTION_TEXT = (API_DIR / "api_connection.cpp").read_text(encoding="utf-8") + +# Fields on DeviceInfoResponse that were superseded by DeviceCapabilitiesResponse +# as of API 1.15 but must still be generated (and therefore still sent) for +# backward compatibility with older clients. +SUPERSEDED_FIELDS: dict[str, int] = { + "bluetooth_proxy_feature_flags": 15, + "voice_assistant_feature_flags": 17, + "bluetooth_mac_address": 18, + "zwave_proxy_feature_flags": 23, + "zwave_home_id": 24, + "serial_proxies": 25, +} + +# Field numbers on the new capability messages. These are a frozen wire +# contract from the moment they ship: an old client decodes a sub-message +# field purely by number, so renumbering any of these -- even without +# touching a name -- silently corrupts what every already-deployed client +# reads. Keyed by message name so the next capability sub-message is a +# data-only addition here. +NEW_CAPABILITY_FIELDS: dict[str, dict[str, int]] = { + "DeviceCapabilitiesResponse": { + "bluetooth_proxy": 1, + "voice_assistant": 2, + "zwave_proxy": 3, + "serial_proxies": 4, + }, + "BluetoothProxyCapabilities": { + "feature_flags": 1, + "mac_address": 2, + }, + "VoiceAssistantCapabilities": { + "feature_flags": 1, + }, + "ZWaveProxyCapabilities": { + "feature_flags": 1, + "home_id": 2, + }, +} + +# Fields that are genuinely dead and are expected to carry `deprecated=true`. +# Used to prove the deprecated-detection logic below actually detects +# deprecation rather than trivially passing. +GENUINELY_DEPRECATED_FIELDS: tuple[str, ...] = ( + "legacy_bluetooth_proxy_version", + "legacy_voice_assistant_version", +) + +DEPRECATED_FIELD_TRAP = ( + "script/api_protobuf/api_protobuf.py skips fields marked `[deprecated = " + "true]` completely, generating no C++ for them at all. Marking this field " + "deprecated would silently stop the device from ever sending it, breaking " + "every existing client that still reads it from DeviceInfoResponse." +) + + +def _extract_braced_region(text: str, anchor_pattern: str) -> str: + """Return the region of `text` starting at the first match of + `anchor_pattern` up to the matching closing brace (inclusive), using + brace-depth counting so nested braces (e.g. a `for (...) { ... }` loop + inside a function body) don't cause a premature stop. + """ + anchor_match = re.search(anchor_pattern, text) + if anchor_match is None: + raise AssertionError(f"could not find a match for {anchor_pattern!r}") + start = anchor_match.start() + open_brace = text.index("{", start) + depth = 0 + for i in range(open_brace, len(text)): + if text[i] == "{": + depth += 1 + elif text[i] == "}": + depth -= 1 + if depth == 0: + return text[start : i + 1] + raise AssertionError(f"unbalanced braces while scanning after {anchor_pattern!r}") + + +def _extract_class_body(header_text: str, class_name: str) -> str: + """Return the body of a generated C++ class, scoped so a field name that + also happens to exist on some other class cannot satisfy the assertion. + """ + return _extract_braced_region(header_text, rf"class {re.escape(class_name)}\b") + + +def _extract_function_body(cpp_text: str, qualified_name: str) -> str: + """Return the body of a generated `Class::method(...)` definition.""" + return _extract_braced_region(cpp_text, rf"{re.escape(qualified_name)}\(") + + +def _extract_proto_message(proto_text: str, message_name: str) -> str: + """Return the body of a top-level `message Name { ... }` block from the + .proto source. Proto message bodies here contain no nested `{`/`}` of + their own (options use parens, not braces), so a non-greedy match up to + the first line that is just `}` is sufficient and keeps the parsing + simple. + """ + match = re.search( + rf"^message {re.escape(message_name)}\s*\{{(.*?)^\}}", + proto_text, + re.MULTILINE | re.DOTALL, + ) + if match is None: + raise AssertionError(f"could not find `message {message_name}` in api.proto") + return match.group(1) + + +def _extract_rpc_body(proto_text: str, rpc_name: str) -> str: + """Return the option body of an `rpc name (...) returns (...) { ... }` + declaration from the APIConnection service, robust to it being written + on one line (`{}`) or spread across several with options inside. + """ + match = re.search( + rf"rpc\s+{re.escape(rpc_name)}\s*\([^)]*\)\s*returns\s*\([^)]*\)\s*\{{(.*?)\}}", + proto_text, + re.DOTALL, + ) + if match is None: + raise AssertionError(f"could not find `rpc {rpc_name}` in api.proto") + return match.group(1) + + +def _field_declaration_line(message_body: str, field_name: str) -> str: + """Return the single source line declaring `field_name` inside a proto + message body (all fields here are declared on one line). + """ + for line in message_body.splitlines(): + if re.search(rf"\b{re.escape(field_name)}\s*=\s*\d+", line): + return line + raise AssertionError( + f"could not find a field declaration for {field_name!r} in the given message body" + ) + + +# ==================== Group A: generated files ==================== + + +def test_superseded_device_info_fields_still_declared_in_header() -> None: + """Each superseded field must still be a real member of DeviceInfoResponse + in api_pb2.h -- not merely present somewhere in the file. Several of these + names (e.g. serial_proxies) also exist on DeviceCapabilitiesResponse, so an + unscoped substring search over the whole header would pass even if the + field were removed from DeviceInfoResponse. + """ + class_body = _extract_class_body(HEADER_TEXT, "DeviceInfoResponse") + for field_name in SUPERSEDED_FIELDS: + assert re.search(rf"\b{field_name}\b", class_body), ( + f"{field_name} is missing from the DeviceInfoResponse class body in " + f"api_pb2.h. {DEPRECATED_FIELD_TRAP}" + ) + + +def test_superseded_device_info_fields_still_encoded_and_sized() -> None: + """Each superseded field must still be touched by DeviceInfoResponse's + generated encode() and calculate_size(), i.e. it is still put on the wire. + """ + encode_body = _extract_function_body(CPP_TEXT, "DeviceInfoResponse::encode") + size_body = _extract_function_body(CPP_TEXT, "DeviceInfoResponse::calculate_size") + for field_name in SUPERSEDED_FIELDS: + assert f"this->{field_name}" in encode_body, ( + f"DeviceInfoResponse::encode() no longer references {field_name}. " + f"{DEPRECATED_FIELD_TRAP}" + ) + assert f"this->{field_name}" in size_body, ( + f"DeviceInfoResponse::calculate_size() no longer references " + f"{field_name}. {DEPRECATED_FIELD_TRAP}" + ) + + +def test_new_capability_classes_present_in_header() -> None: + """The new response message and its capability sub-messages must exist as + generated classes. + """ + for class_name in ( + "DeviceCapabilitiesResponse", + "BluetoothProxyCapabilities", + "VoiceAssistantCapabilities", + "ZWaveProxyCapabilities", + ): + assert re.search(rf"class {re.escape(class_name)}\b", HEADER_TEXT), ( + f"expected a generated class named {class_name} in api_pb2.h" + ) + + +# ==================== Group B: api.proto source text ==================== + + +def test_all_message_ids_are_unique() -> None: + """Every `option (id) = N;` in api.proto must be unique. Two messages + sharing a wire id would make the client and server misinterpret each + other's messages -- nothing else currently checks this. + """ + ids = [int(value) for value in re.findall(r"option \(id\) = (\d+);", PROTO_TEXT)] + assert ids, "did not find any `option (id) = N;` declarations in api.proto" + duplicates = sorted({value for value in ids if ids.count(value) > 1}) + assert not duplicates, ( + f"Duplicate `option (id)` values found in api.proto: {duplicates}. Each " + "message must have a unique wire id." + ) + + +def test_device_capabilities_request_has_id_149() -> None: + body = _extract_proto_message(PROTO_TEXT, "DeviceCapabilitiesRequest") + match = re.search(r"option \(id\) = (\d+);", body) + assert match is not None, "DeviceCapabilitiesRequest is missing `option (id)`" + assert int(match.group(1)) == 149, ( + f"DeviceCapabilitiesRequest has id {match.group(1)}, expected 149. " + "Message ids are part of the wire protocol and must not change once " + "assigned." + ) + + +def test_device_capabilities_response_has_id_150() -> None: + body = _extract_proto_message(PROTO_TEXT, "DeviceCapabilitiesResponse") + match = re.search(r"option \(id\) = (\d+);", body) + assert match is not None, "DeviceCapabilitiesResponse is missing `option (id)`" + assert int(match.group(1)) == 150, ( + f"DeviceCapabilitiesResponse has id {match.group(1)}, expected 150. " + "Message ids are part of the wire protocol and must not change once " + "assigned." + ) + + +def test_superseded_fields_are_not_marked_deprecated_in_proto() -> None: + """The six superseded fields must not carry `[deprecated = true]` in + api.proto, or the generator drops them and old clients stop receiving + them (see module docstring). The second half of this test proves the + deprecated-detection itself works: two genuinely dead fields + (legacy_bluetooth_proxy_version, legacy_voice_assistant_version) must + still be detected as deprecated, so the first half isn't vacuously true. + """ + body = _extract_proto_message(PROTO_TEXT, "DeviceInfoResponse") + + for field_name in SUPERSEDED_FIELDS: + line = _field_declaration_line(body, field_name) + assert "deprecated" not in line, ( + f"{field_name} in DeviceInfoResponse is marked deprecated in " + f"api.proto ({line.strip()!r}). {DEPRECATED_FIELD_TRAP}" + ) + + for field_name in GENUINELY_DEPRECATED_FIELDS: + line = _field_declaration_line(body, field_name) + assert "deprecated" in line, ( + f"expected {field_name} to still carry `deprecated=true` in " + f"api.proto ({line.strip()!r}). If this fails, the deprecated " + "detection used above is broken, and the sibling assertion that " + "the superseded fields are NOT deprecated is not testing anything." + ) + + +def test_superseded_fields_keep_their_wire_numbers() -> None: + """Each superseded field must stay on the field number recorded in + SUPERSEDED_FIELDS. Old clients decode DeviceInfoResponse purely by field + number, so renumbering one of these -- even without touching its name -- + would make an old client read a completely different value out of the + wire, with nothing else in CI noticing. + """ + body = _extract_proto_message(PROTO_TEXT, "DeviceInfoResponse") + + for field_name, field_number in SUPERSEDED_FIELDS.items(): + line = _field_declaration_line(body, field_name) + assert re.search(rf"\b{field_name}\s*=\s*{field_number}\b", line), ( + f"{field_name} in DeviceInfoResponse is no longer declared at " + f"field number {field_number} ({line.strip()!r}). Field numbers " + "are the wire protocol -- renumbering this field silently breaks " + "every existing client that still decodes DeviceInfoResponse by " + "the old numbering." + ) + + +def test_capability_message_fields_keep_their_wire_numbers() -> None: + """Every field on DeviceCapabilitiesResponse and its three capability + sub-messages must stay on the field number recorded in + NEW_CAPABILITY_FIELDS. These messages are brand new as of API 1.15, but + the moment a device ships with them, their field numbers are a frozen + wire contract -- a client decodes a sub-message field purely by number, + so a later "cleanup" that renumbers one of these would silently corrupt + what every already-deployed client reads, with nothing else in CI + noticing. + """ + for message_name, fields in NEW_CAPABILITY_FIELDS.items(): + body = _extract_proto_message(PROTO_TEXT, message_name) + for field_name, field_number in fields.items(): + line = _field_declaration_line(body, field_name) + assert re.search(rf"\b{field_name}\s*=\s*{field_number}\b", line), ( + f"{field_name} on {message_name} is no longer declared at " + f"field number {field_number} ({line.strip()!r}). Field " + "numbers are the wire protocol -- renumbering this field " + "silently breaks every existing client that decodes this " + "message by the old numbering." + ) + + +def test_device_capabilities_rpc_requires_authentication() -> None: + """The `device_capabilities` RPC must not set + `option (needs_authentication) = false;` (or set it to anything at all). + Leaving it unset makes it inherit needs_authentication = true, keeping + capability data behind authentication (and encryption, when configured). + """ + body = _extract_rpc_body(PROTO_TEXT, "device_capabilities") + assert "needs_authentication" not in body, ( + "rpc device_capabilities sets a `needs_authentication` option in " + "api.proto. It must stay unset so it inherits needs_authentication = " + "true; otherwise device capability data could be requested over an " + "unauthenticated connection." + ) + + +# ==================== Group C: advertised API version ==================== + + +def test_api_version_minor_is_at_least_15() -> None: + """Clients gate sending DeviceCapabilitiesRequest on seeing + api_version >= 1.15 in HelloResponse. Regressing api_version_minor below + 15 would make every client believe capabilities are unsupported even + though the RPC exists, so this must never go backwards. Use >= rather + than == so the next unrelated minor-version bump doesn't need to touch + this test. + """ + match = re.search(r"resp\.api_version_minor\s*=\s*(\d+);", API_CONNECTION_TEXT) + assert match is not None, ( + "could not find `resp.api_version_minor = N;` in api_connection.cpp" + ) + minor = int(match.group(1)) + assert minor >= 15, ( + f"api_version_minor is {minor}, but device_capabilities requires " + "clients to see api_version >= 1.15 in HelloResponse before they will " + "ever request it." + ) From 51b0240fb8a3c31a6d33070e6cb5099ef67da78f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 22:05:19 -0500 Subject: [PATCH 1381/1815] [rp2] Fix %f formatting in logs (#18256) --- esphome/components/rp2/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index 1bf01e6828..3bc2df7a61 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -351,6 +351,11 @@ async def to_code(config): ], ) + # newlib-nano is the default libc for the arduino-pico toolchain and its + # printf silently drops %f unless _printf_float is force-linked. Components + # use %f widely in logging, so pull it in. + cg.add_build_flag("-Wl,-u,_printf_float") + # Wrap FILE*-based printf functions to eliminate newlib's _vfprintf_r # (~9.2 KB). See printf_stubs.cpp for implementation. if config.get(CONF_ENABLE_FULL_PRINTF): From 9823ad6abcdc56edb03d9a76819f9e0083ddd5ba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 23:56:32 -0500 Subject: [PATCH 1382/1815] [tests] Fix flaky modbus server/controller integration tests (#18258) --- tests/integration/state_utils.py | 40 ++++++++++++++++++---- tests/integration/test_uart_mock_modbus.py | 23 ++++++++++--- 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/tests/integration/state_utils.py b/tests/integration/state_utils.py index 4d31644559..9c0debbc5c 100644 --- a/tests/integration/state_utils.py +++ b/tests/integration/state_utils.py @@ -420,8 +420,15 @@ class SensorTracker: """Call ``expect`` for every entry and return a dict of futures.""" return {name: self.expect(name, value) for name, value in expected.items()} - def on_state(self, state: EntityState) -> None: - """State callback suitable for ``subscribe_states``.""" + def on_state(self, state: EntityState, first_pending_only: bool = False) -> None: + """State callback suitable for ``subscribe_states``. + + Args: + state: The state update to record + first_pending_only: Only allow the first pending expectation for this + sensor to match, instead of the first matching one. Used for + connect-time states so they cannot satisfy a later phase. + """ if ( not isinstance(state, (SensorState, BinarySensorState)) or state.missing_state @@ -432,11 +439,13 @@ class SensorTracker: return self.sensor_states[sensor_name].append(state.state) for expected_value, future in self._expectations.get(sensor_name, []): - if not future.done() and ( - expected_value is self._ANY or state.state == expected_value - ): + if future.done(): + continue + if expected_value is self._ANY or state.state == expected_value: future.set_result(True) break + if first_pending_only: + break async def await_change( self, future: asyncio.Future, name: str, timeout: float = 2.0 @@ -474,8 +483,22 @@ class SensorTracker: for name, future in futures.items(): await self.await_change(future, name, timeout=timeout) - async def setup_and_start_scenario(self, client) -> list: - """Wire up subscriptions, wait for initial states, press Start Scenario.""" + async def setup_and_start_scenario( + self, client: APIClient, match_initial_states: bool = False + ) -> list[EntityInfo]: + """Wire up subscriptions, wait for initial states, press Start Scenario. + + Args: + client: The connected API client + match_initial_states: Also match expectations against the states the + device sends when the client connects, so a value published before + the client subscribed still counts. Binary sensors need this: they + drop repeats, so a value that lands in the connect-time dump is + never sent again. Plain sensors publish on every poll, so there it + only saves waiting for the next one. Only the first pending + expectation per sensor can match, so a connect-time value cannot + satisfy a later phase. + """ entities, _ = await client.list_entities_services() self.key_to_sensor.update( build_key_to_entity_mapping(entities, list(self.sensor_states.keys())) @@ -488,6 +511,9 @@ class SensorTracker: import pytest pytest.fail("Timeout waiting for initial states") + if match_initial_states: + for state in initial_state_helper.initial_states.values(): + self.on_state(state, first_pending_only=True) start_btn = find_entity(entities, "start_scenario", ButtonInfo) assert start_btn is not None, "Start Scenario button not found" client.button_command(start_btn.key) diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 17ab21f873..057d419fd8 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -330,7 +330,10 @@ async def test_uart_mock_modbus_server_controller( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - await tracker.setup_and_start_scenario(client) + # The controller polls from boot, so the first values can already be in + # the states the device sends on connect; matching them there saves + # waiting for the next poll + await tracker.setup_and_start_scenario(client, match_initial_states=True) await tracker.await_all(futures) _assert_no_modbus_errors(error_log_lines, warning_log_lines) @@ -392,7 +395,12 @@ async def test_uart_mock_modbus_server_controller_write( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - entities = await tracker.setup_and_start_scenario(client) + # The controller polls from boot, so the baseline can already be in the + # states the device sends on connect; matching it there saves waiting for + # the next poll + entities = await tracker.setup_and_start_scenario( + client, match_initial_states=True + ) # Wait for initial baseline values to confirm the controller <-> server # connection is working before issuing writes @@ -456,7 +464,11 @@ async def test_uart_mock_modbus_server_controller_bits( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - entities = await tracker.setup_and_start_scenario(client) + # The controller polls from boot and binary sensors drop repeats, so the + # baseline can arrive only in the states the device sends on connect + entities = await tracker.setup_and_start_scenario( + client, match_initial_states=True + ) # Wait for initial baseline values to confirm the controller <-> server # connection is working before issuing writes @@ -491,7 +503,10 @@ async def test_uart_mock_modbus_server_controller_multiple( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - await tracker.setup_and_start_scenario(client) + # The controller polls from boot, so the first values can already be in + # the states the device sends on connect; matching them there saves + # waiting for the next poll + await tracker.setup_and_start_scenario(client, match_initial_states=True) await tracker.await_all(futures) _assert_no_modbus_errors(error_log_lines, warning_log_lines) From ecec3a19c7264e63a8756637caa9f9ed3c4e8f45 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:22:20 +1200 Subject: [PATCH 1383/1815] [debug] Remove Arduino core dependency from RP2 platform code (#18267) --- esphome/components/debug/debug_rp2.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/components/debug/debug_rp2.cpp b/esphome/components/debug/debug_rp2.cpp index 336e9c7e06..4ace4be0a3 100644 --- a/esphome/components/debug/debug_rp2.cpp +++ b/esphome/components/debug/debug_rp2.cpp @@ -1,8 +1,9 @@ #include "debug_component.h" #ifdef USE_RP2 #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include +#include #include #if defined(PICO_RP2350) #include @@ -68,13 +69,14 @@ const char *DebugComponent::get_reset_reason_(std::span buffer) { return ""; } -uint32_t DebugComponent::get_free_heap_() { return ::rp2040.getFreeHeap(); } +// RAMAllocator already implements the free-heap calculation for this platform, so it is not duplicated here. +uint32_t DebugComponent::get_free_heap_() { return RAMAllocator().get_free_heap_size(); } size_t DebugComponent::get_device_info_(std::span buffer, size_t pos) { constexpr size_t size = DEVICE_INFO_BUFFER_SIZE; char *buf = buffer.data(); - uint32_t cpu_freq = RP2040::f_cpu(); + uint32_t cpu_freq = clock_get_hz(clk_sys); ESP_LOGD(TAG, "CPU Frequency: %" PRIu32, cpu_freq); pos = buf_append_printf(buf, size, pos, "|CPU Frequency: %" PRIu32, cpu_freq); From e5cdda9ee5c993efd5b39f5c82b81458e98534cd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:22:52 +1200 Subject: [PATCH 1384/1815] [adc] Fix RP2350B internal temperature reading wrong ADC channel (#18270) --- esphome/components/adc/adc_sensor_rp2.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/adc/adc_sensor_rp2.cpp b/esphome/components/adc/adc_sensor_rp2.cpp index 6cb9ef113f..2732f5328b 100644 --- a/esphome/components/adc/adc_sensor_rp2.cpp +++ b/esphome/components/adc/adc_sensor_rp2.cpp @@ -52,7 +52,11 @@ float ADCSensor::sample() { if (this->is_temperature_) { adc_set_temp_sensor_enabled(true); delay(1); - adc_select_input(4); + // The on-die temperature sensor sits on the last ADC channel, and which one + // that is depends on the chip: input 4 on RP2040 and RP2350A, but input 8 on + // RP2350B, which has eight external channels instead of four. The SDK + // resolves it for the target being built, so do not hardcode it. + adc_select_input(ADC_TEMPERATURE_CHANNEL_NUM); for (uint8_t sample = 0; sample < this->sample_count_; sample++) { raw = adc_read(); From 2ff55e305825557c66dbd916eb61eea92ebb9644 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:23:08 +1200 Subject: [PATCH 1385/1815] [rp2] Use SDK clock query directly in arch_get_cpu_freq_hz (#18269) --- esphome/components/rp2/hal.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/rp2/hal.cpp b/esphome/components/rp2/hal.cpp index 8eb1b469bc..ac1467e5e6 100644 --- a/esphome/components/rp2/hal.cpp +++ b/esphome/components/rp2/hal.cpp @@ -7,6 +7,7 @@ #include "crash_handler.h" #endif +#include "hardware/clocks.h" #include "hardware/watchdog.h" // Empty rp2 namespace block to satisfy ci-custom's lint_namespace check. @@ -33,7 +34,8 @@ void arch_init() { #endif } -uint32_t arch_get_cpu_freq_hz() { return RP2040::f_cpu(); } +// clock_get_hz(clk_sys) is the SDK query for the current system clock frequency in Hz. +uint32_t arch_get_cpu_freq_hz() { return clock_get_hz(clk_sys); } } // namespace esphome From 04dd6b3a553a79ccf4173c4e9b778134714c6917 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:27:32 +1200 Subject: [PATCH 1386/1815] [core] Use MAC address size constants instead of literals (#18254) --- esphome/components/api/api_connection.cpp | 5 ++--- esphome/components/captive_portal/captive_portal.cpp | 2 +- esphome/components/debug/debug_esp32.cpp | 3 ++- esphome/components/esp32/helpers.cpp | 2 +- esphome/components/ethernet/ethernet_component.h | 4 ++-- esphome/components/ethernet/ethernet_component_esp32.cpp | 8 ++++---- esphome/components/ethernet/ethernet_component_rp2.cpp | 4 ++-- esphome/components/host/helpers.cpp | 2 +- esphome/components/tinyusb/tinyusb_component.cpp | 2 +- esphome/components/wake_on_lan/wake_on_lan.h | 3 ++- esphome/components/web_server/web_server.cpp | 2 +- esphome/components/wifi/wifi_component.cpp | 4 ++-- esphome/components/wifi/wifi_component_esp8266.cpp | 2 +- esphome/components/wifi/wifi_component_esp_idf.cpp | 4 ++-- esphome/components/wifi/wifi_component_libretiny.cpp | 4 ++-- esphome/components/wifi_info/wifi_info_text_sensor.cpp | 3 ++- esphome/components/wifi_info/wifi_info_text_sensor.h | 2 +- esphome/core/alloc_helpers.cpp | 6 +++--- esphome/core/helpers.cpp | 4 ++-- 19 files changed, 34 insertions(+), 32 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 18eb2592ff..19d2b14a32 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1772,9 +1772,8 @@ bool APIConnection::send_device_info_response_() { #ifdef USE_AREAS resp.suggested_area = StringRef(App.get_area()); #endif - // Stack buffer for MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes) - char mac_address[18]; - uint8_t mac[6]; + char mac_address[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_mac_address_raw(mac); format_mac_addr_upper(mac, mac_address); resp.mac_address = StringRef(mac_address); diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 8094903008..704a61d4de 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -14,7 +14,7 @@ static const char *const TAG = "captive_portal"; void CaptivePortal::handle_config(AsyncWebServerRequest *request) { AsyncResponseStream *stream = request->beginResponseStream(ESPHOME_F("application/json")); stream->addHeader(ESPHOME_F("cache-control"), ESPHOME_F("public, max-age=0, must-revalidate")); - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; const char *mac_str = get_mac_address_pretty_into_buffer(mac_s); #ifdef USE_ESP8266 stream->print(ESPHOME_F("{\"mac\":\"")); diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index 7c01f9b54f..969cd840cf 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -4,6 +4,7 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include #include @@ -249,7 +250,7 @@ size_t DebugComponent::get_device_info_(std::span const char *reset_reason = get_reset_reason_(std::span(reset_buffer)); const char *wakeup_cause = get_wakeup_cause_(std::span(wakeup_buffer)); - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_mac_address_raw(mac); ESP_LOGD(TAG, diff --git a/esphome/components/esp32/helpers.cpp b/esphome/components/esp32/helpers.cpp index afcec8bfc7..c2ff6cf34d 100644 --- a/esphome/components/esp32/helpers.cpp +++ b/esphome/components/esp32/helpers.cpp @@ -109,7 +109,7 @@ void set_mac_address(uint8_t *mac) { esp_base_mac_addr_set(mac); } bool has_custom_mac_address() { #if !defined(USE_ESP32_IGNORE_EFUSE_CUSTOM_MAC) - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; // do not use 'esp_efuse_mac_get_custom(mac)' because it drops an error in the logs whenever it fails #ifndef USE_ESP32_VARIANT_ESP32 return (esp_efuse_read_field_blob(ESP_EFUSE_USER_DATA_MAC_CUSTOM, mac, MAC_ADDRESS_SIZE_BITS) == ESP_OK) && diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index dc084796e7..ad329f9b81 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -144,7 +144,7 @@ class EthernetComponent final : public Component { #ifdef USE_ETHERNET_MANUAL_IP void set_manual_ip(const ManualIP &manual_ip); #endif - void set_fixed_mac(const std::array &mac) { this->fixed_mac_ = mac; } + void set_fixed_mac(const std::array &mac) { this->fixed_mac_ = mac; } network::IPAddresses get_ip_addresses(); network::IPAddress get_dns_address(uint8_t num); @@ -336,7 +336,7 @@ class EthernetComponent final : public Component { bool ipv6_setup_done_{false}; #endif /* LWIP_IPV6 */ - optional> fixed_mac_; + optional> fixed_mac_; #ifdef USE_ETHERNET_IP_STATE_LISTENERS StaticVector ip_state_listeners_; diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 7cf8cdf736..dc623b6e5b 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -429,9 +429,9 @@ void EthernetComponent::ethernet_lazy_init_() { #endif // !USE_ETHERNET_SPI // use ESP internal eth mac - uint8_t mac_addr[6]; + uint8_t mac_addr[MAC_ADDRESS_SIZE]; if (this->fixed_mac_.has_value()) { - memcpy(mac_addr, this->fixed_mac_->data(), 6); + memcpy(mac_addr, this->fixed_mac_->data(), MAC_ADDRESS_SIZE); } else { esp_read_mac(mac_addr, ESP_MAC_ETH); } @@ -926,7 +926,7 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { // External callers (mdns, ethernet_info, etc.) may ask for the MAC before/regardless // of whether ethernet is enabled. Use the configured MAC if set, else the system ETH MAC. if (this->fixed_mac_.has_value()) { - memcpy(mac, this->fixed_mac_->data(), 6); + memcpy(mac, this->fixed_mac_->data(), MAC_ADDRESS_SIZE); } else { esp_read_mac(mac, ESP_MAC_ETH); } @@ -944,7 +944,7 @@ std::string EthernetComponent::get_eth_mac_address_pretty() { const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer( std::span buf) { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_eth_mac_address_raw(mac); format_mac_addr_upper(mac, buf.data()); return buf.data(); diff --git a/esphome/components/ethernet/ethernet_component_rp2.cpp b/esphome/components/ethernet/ethernet_component_rp2.cpp index 4d6d6c4f5b..119e447689 100644 --- a/esphome/components/ethernet/ethernet_component_rp2.cpp +++ b/esphome/components/ethernet/ethernet_component_rp2.cpp @@ -245,7 +245,7 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { if (this->eth_ != nullptr) { this->eth_->macAddress(mac); } else { - memset(mac, 0, 6); + memset(mac, 0, MAC_ADDRESS_SIZE); } } @@ -256,7 +256,7 @@ std::string EthernetComponent::get_eth_mac_address_pretty() { const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer( std::span buf) { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_eth_mac_address_raw(mac); format_mac_addr_upper(mac, buf.data()); return buf.data(); diff --git a/esphome/components/host/helpers.cpp b/esphome/components/host/helpers.cpp index 7e8849b3e1..7274d9de57 100644 --- a/esphome/components/host/helpers.cpp +++ b/esphome/components/host/helpers.cpp @@ -39,7 +39,7 @@ bool Mutex::try_lock() { return static_cast(handle_)->try_lock(); void Mutex::unlock() { static_cast(handle_)->unlock(); } void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter) - static const uint8_t esphome_host_mac_address[6] = USE_ESPHOME_HOST_MAC_ADDRESS; + static const uint8_t esphome_host_mac_address[MAC_ADDRESS_SIZE] = USE_ESPHOME_HOST_MAC_ADDRESS; memcpy(mac, esphome_host_mac_address, sizeof(esphome_host_mac_address)); } diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index b748959571..c8c36f0ffb 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -12,7 +12,7 @@ static const char *const TAG = "tinyusb"; void TinyUSB::setup() { // Use the device's MAC address as its serial number if no serial number is defined if (this->string_descriptor_[SERIAL_NUMBER] == nullptr) { - static char mac_addr_buf[13]; + static char mac_addr_buf[MAC_ADDRESS_BUFFER_SIZE]; get_mac_address_into_buffer(mac_addr_buf); this->string_descriptor_[SERIAL_NUMBER] = mac_addr_buf; } diff --git a/esphome/components/wake_on_lan/wake_on_lan.h b/esphome/components/wake_on_lan/wake_on_lan.h index ddf3433e7d..cef60c54f8 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.h +++ b/esphome/components/wake_on_lan/wake_on_lan.h @@ -3,6 +3,7 @@ #if defined(USE_NETWORK) && !defined(USE_ZEPHYR) #include "esphome/components/button/button.h" #include "esphome/core/component.h" +#include "esphome/core/helpers.h" #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) #include "esphome/components/socket/socket.h" #else @@ -27,7 +28,7 @@ class WakeOnLanButton final : public button::Button, public Component { #endif void press_action() override; uint16_t port_{9}; - uint8_t macaddr_[6]; + uint8_t macaddr_[MAC_ADDRESS_SIZE]; }; } // namespace esphome::wake_on_lan diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 3fe3979a8b..9e50b7a394 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -510,7 +510,7 @@ void WebServer::handle_pna_cors_request(AsyncWebServerRequest *request) { response->addHeader(ESPHOME_F("Access-Control-Allow-Origin"), origin.empty() ? "*" : origin.c_str()); response->addHeader(ESPHOME_F("Access-Control-Allow-Private-Network"), ESPHOME_F("true")); response->addHeader(ESPHOME_F("Private-Network-Access-Name"), App.get_name().c_str()); - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; response->addHeader(ESPHOME_F("Private-Network-Access-ID"), get_mac_address_pretty_into_buffer(mac_s)); request->send(response); } diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 9e78e7c48e..127eb50df1 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1117,7 +1117,7 @@ void WiFiComponent::connect_soon_() { void WiFiComponent::start_connecting(const WiFiAP &ap) { // Log connection attempt at INFO level with priority - char bssid_s[18]; + char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; int8_t priority = 0; if (ap.has_bssid()) { @@ -2068,7 +2068,7 @@ void WiFiComponent::log_and_adjust_priority_for_failed_connect_() { (old_priority > std::numeric_limits::min()) ? (old_priority - 1) : std::numeric_limits::min(); this->set_sta_priority(failed_bssid.value(), new_priority); } - char bssid_s[18]; + char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(failed_bssid.value().data(), bssid_s); ESP_LOGD(TAG, "Failed " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") ", priority %d → %d", ssid != nullptr ? ssid : "", bssid_s, old_priority, new_priority); diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index e082b2c8c1..719a276bf9 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -516,7 +516,7 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { (const char *) it.ssid); global_wifi_component->sta_state_ = static_cast(ESP8266WiFiSTAState::ERROR_NOT_FOUND); } else { - char bssid_s[18]; + char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(it.bssid, bssid_s); ESP_LOGW(TAG, "Disconnected ssid='%.*s' bssid=" LOG_SECRET("%s") " reason='%s'", it.ssid_len, (const char *) it.ssid, bssid_s, LOG_STR_ARG(get_disconnect_reason_str(it.reason))); diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 783c000f7b..0198f899d5 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -140,7 +140,7 @@ void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, voi } void WiFiComponent::wifi_pre_setup_() { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; if (has_custom_mac_address()) { get_mac_address_raw(mac); set_mac_address(mac); @@ -860,7 +860,7 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { ESP_LOGI(TAG, "Disconnected ssid='%.*s' reason='Station Roaming'", it.ssid_len, (const char *) it.ssid); return; } else { - char bssid_s[18]; + char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(it.bssid, bssid_s); ESP_LOGW(TAG, "Disconnected ssid='%.*s' bssid=" LOG_SECRET("%s") " reason='%s'", it.ssid_len, (const char *) it.ssid, bssid_s, get_disconnect_reason_str(it.reason)); diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index ce9c4eb6ce..66c397a8ad 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -81,7 +81,7 @@ struct LTWiFiEvent { uint8_t scan_id; } scan_done; struct { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; int rssi; } ap_probe_req; } data; @@ -391,7 +391,7 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ } case ESPHOME_EVENT_ID_WIFI_AP_PROBEREQRECVED: { auto &it = info.wifi_ap_probereqrecved; - memcpy(to_send->data.ap_probe_req.mac, it.mac, 6); + memcpy(to_send->data.ap_probe_req.mac, it.mac, MAC_ADDRESS_SIZE); to_send->data.ap_probe_req.rssi = it.rssi; break; } diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.cpp b/esphome/components/wifi_info/wifi_info_text_sensor.cpp index b5ebfd7390..5d4e77eaad 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.cpp +++ b/esphome/components/wifi_info/wifi_info_text_sensor.cpp @@ -1,5 +1,6 @@ #include "wifi_info_text_sensor.h" #ifdef USE_WIFI +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #ifdef USE_ESP8266 @@ -125,7 +126,7 @@ void BSSIDWiFiInfo::setup() { wifi::global_wifi_component->add_connect_state_lis void BSSIDWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "BSSID", this); } void BSSIDWiFiInfo::on_wifi_connect_state(StringRef ssid, std::span bssid) { - char buf[18] = "unknown"; + char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE] = "unknown"; if (mac_address_is_valid(bssid.data())) { format_mac_addr_upper(bssid.data(), buf); } diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.h b/esphome/components/wifi_info/wifi_info_text_sensor.h index 7ade170c02..eecedee133 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.h +++ b/esphome/components/wifi_info/wifi_info_text_sensor.h @@ -87,7 +87,7 @@ class PowerSaveModeWiFiInfo final : public Component, class MacAddressWifiInfo final : public Component, public text_sensor::TextSensor { public: void setup() override { - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; this->publish_state(get_mac_address_pretty_into_buffer(mac_s)); } void dump_config() override; diff --git a/esphome/core/alloc_helpers.cpp b/esphome/core/alloc_helpers.cpp index 27c50ebb2a..d9cfad70b9 100644 --- a/esphome/core/alloc_helpers.cpp +++ b/esphome/core/alloc_helpers.cpp @@ -144,7 +144,7 @@ std::vector base64_decode(const std::string &encoded_string) { // --- Hex/binary formatting helpers --- std::string format_mac_address_pretty(const uint8_t *mac) { - char buf[18]; + char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(mac, buf); return std::string(buf); } @@ -206,9 +206,9 @@ std::string format_bin(const uint8_t *data, size_t length) { // --- MAC address helpers --- std::string get_mac_address() { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_mac_address_raw(mac); - char buf[13]; + char buf[MAC_ADDRESS_BUFFER_SIZE]; format_mac_addr_lower_no_sep(mac, buf); return std::string(buf); } diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index c8cf85d7d6..8c4442f1b2 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -808,13 +808,13 @@ void HighFrequencyLoopRequester::stop() { // get_mac_address, get_mac_address_pretty moved to alloc_helpers.cpp void get_mac_address_into_buffer(std::span buf) { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_mac_address_raw(mac); format_mac_addr_lower_no_sep(mac, buf.data()); } const char *get_mac_address_pretty_into_buffer(std::span buf) { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_mac_address_raw(mac); format_mac_addr_upper(mac, buf.data()); return buf.data(); From 069f40f6533b8b8a6cf5001e77103544572ff0bd Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 11 Aug 2026 15:09:33 +0200 Subject: [PATCH 1387/1815] [hoermann_hcp] Add connectivity binary sensor (#18189) Co-authored-by: J. Nick Koston --- .../hoermann_hcp/binary_sensor/__init__.py | 36 ++++++++++ .../hoermann_hcp_binary_sensor.cpp | 18 +++++ .../hoermann_hcp_binary_sensor.h | 20 ++++++ .../hoermann_hcp_binary_sensor_test.cpp | 72 +++++++++++++++++++ tests/components/hoermann_hcp/common.yaml | 5 ++ 5 files changed, 151 insertions(+) create mode 100644 esphome/components/hoermann_hcp/binary_sensor/__init__.py create mode 100644 esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.cpp create mode 100644 esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h create mode 100644 tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp diff --git a/esphome/components/hoermann_hcp/binary_sensor/__init__.py b/esphome/components/hoermann_hcp/binary_sensor/__init__.py new file mode 100644 index 0000000000..3de6a161e6 --- /dev/null +++ b/esphome/components/hoermann_hcp/binary_sensor/__init__.py @@ -0,0 +1,36 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns + +DEPENDENCIES = ["hoermann_hcp"] + +CONF_IS_CONNECTED = "is_connected" + +HoermannHcpConnectedBinarySensor = hoermann_hcp_ns.class_( + "HoermannHcpConnectedBinarySensor", binary_sensor.BinarySensor, cg.Component +) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp), + cv.Optional(CONF_IS_CONNECTED): binary_sensor.binary_sensor_schema( + HoermannHcpConnectedBinarySensor, + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.COMPONENT_SCHEMA), + } + ), + cv.has_at_least_one_key(CONF_IS_CONNECTED), +) + + +async def to_code(config: ConfigType) -> None: + if (conf := config.get(CONF_IS_CONNECTED)) is not None: + parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) + var = await binary_sensor.new_binary_sensor(conf, parent) + await cg.register_component(var, conf) diff --git a/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.cpp b/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.cpp new file mode 100644 index 0000000000..edce6ce4c2 --- /dev/null +++ b/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.cpp @@ -0,0 +1,18 @@ +#include "hoermann_hcp_binary_sensor.h" + +#include "esphome/core/log.h" + +namespace esphome::hoermann_hcp { + +static const char *const TAG = "hoermann_hcp.binary_sensor"; + +void HoermannHcpConnectedBinarySensor::setup() { + // Publishing unconditionally is deliberate: the base class dedupes, and filters need every input to drive + // their timers. + this->parent_->add_on_state_callback([this]() { this->publish_state(this->parent_->is_valid()); }); + this->publish_initial_state(this->parent_->is_valid()); +} + +void HoermannHcpConnectedBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "Hoermann HCP Connected", this); } + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h b/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h new file mode 100644 index 0000000000..c111c17834 --- /dev/null +++ b/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/core/component.h" +#include "../hoermann_hcp.h" + +namespace esphome::hoermann_hcp { + +class HoermannHcpConnectedBinarySensor : public binary_sensor::BinarySensor, public Component { + public: + explicit HoermannHcpConnectedBinarySensor(HoermannHcp *parent) : parent_(parent) {} + + void setup() override; + void dump_config() override; + + protected: + HoermannHcp *const parent_; +}; + +} // namespace esphome::hoermann_hcp diff --git a/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp b/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp new file mode 100644 index 0000000000..3cf708c19e --- /dev/null +++ b/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp @@ -0,0 +1,72 @@ +#include + +#include "esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h" + +namespace esphome::hoermann_hcp { + +using modbus::RegisterValues; + +namespace { + +constexpr uint16_t COMMAND_REG = 0x9C41; +constexpr uint16_t BROADCAST_REG = 0x9D31; + +RegisterValues make_registers(std::initializer_list values) { + RegisterValues registers; + for (uint16_t value : values) + registers.push_back(value); + return registers; +} + +// Exposes the connection bookkeeping so a drop can be driven without waiting one out. +class TestableHoermannHcp : public HoermannHcp { + public: + using HoermannHcp::set_valid_; +}; + +} // namespace + +// Nothing has been heard from the bus controller yet, so the sensor starts out seeded as disconnected. +TEST(HoermannHcpBinarySensorTest, StartsDisconnected) { + HoermannHcp door; + HoermannHcpConnectedBinarySensor sensor(&door); + sensor.setup(); + EXPECT_TRUE(sensor.has_state()); + EXPECT_FALSE(sensor.state); +} + +// The connection flag follows the bus controller in both directions. +TEST(HoermannHcpBinarySensorTest, FollowsTheConnectionState) { + TestableHoermannHcp door; + HoermannHcpConnectedBinarySensor sensor(&door); + sensor.setup(); + ASSERT_FALSE(sensor.state); + + door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + door.update(); + EXPECT_TRUE(sensor.state); + + door.set_valid_(false); + door.update(); + EXPECT_FALSE(sensor.state); +} + +// Any hub change re-runs the publish path, so an unchanged connection must not be reported twice. +TEST(HoermannHcpBinarySensorTest, UnchangedConnectionIsPublishedOnce) { + HoermannHcp door; + HoermannHcpConnectedBinarySensor sensor(&door); + sensor.setup(); + int publishes = 0; + sensor.add_on_state_callback([&publishes](bool /*state*/) { publishes++; }); + + door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + door.update(); + ASSERT_EQ(publishes, 1); + + // A status broadcast changes the door state without touching the connection. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100})); + door.update(); + EXPECT_EQ(publishes, 1); +} + +} // namespace esphome::hoermann_hcp diff --git a/tests/components/hoermann_hcp/common.yaml b/tests/components/hoermann_hcp/common.yaml index 3b77eed7ea..84162e8812 100644 --- a/tests/components/hoermann_hcp/common.yaml +++ b/tests/components/hoermann_hcp/common.yaml @@ -6,3 +6,8 @@ cover: - platform: hoermann_hcp name: Garage Door device_class: garage + +binary_sensor: + - platform: hoermann_hcp + is_connected: + name: Garage Connected From e8852c59501c525603375543afdc3f8f18306e6e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 08:11:23 -0500 Subject: [PATCH 1388/1815] [core] Flush stdout in safe_print so logs stream live (#18261) --- esphome/util.py | 14 ++++++++-- tests/unit_tests/test_util.py | 49 +++++++++++++++++++++++++++++++++-- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/esphome/util.py b/esphome/util.py index 136d6362f2..5bb341b700 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -87,8 +87,11 @@ def safe_print(message="", end="\n"): except UnicodeEncodeError: pass + # Always flush: stdout is block buffered when it is a pipe (the dashboard + # runs us that way), so live log lines would otherwise sit in the buffer + # for a long time instead of streaming out. try: - print(message, end=end) + print(message, end=end, flush=True) return except UnicodeEncodeError: pass @@ -104,6 +107,7 @@ def safe_print(message="", end="\n"): print( message.encode(encoding, "backslashreplace").decode(encoding), end=end, + flush=True, ) return except UnicodeEncodeError: @@ -113,9 +117,10 @@ def safe_print(message="", end="\n"): print( message.encode("ascii", "backslashreplace").decode("ascii"), end=end, + flush=True, ) except UnicodeEncodeError: - print("Cannot print line because of invalid locale!") + print("Cannot print line because of invalid locale!", flush=True) def safe_input(prompt=""): @@ -211,6 +216,11 @@ class RedirectText: else: self._write_color_replace(s) + # Same reason as safe_print: the dashboard gives us a pipe, which is + # block buffered, so in-process esptool progress would not show up + # until the buffer filled. + self._out.flush() + # write() returns the number of characters written # Let's print the number of characters of the original string in order to not confuse # any caller. diff --git a/tests/unit_tests/test_util.py b/tests/unit_tests/test_util.py index 02309fbff8..bd3d3d4836 100644 --- a/tests/unit_tests/test_util.py +++ b/tests/unit_tests/test_util.py @@ -422,6 +422,26 @@ def _make_redirect( return redirect, buf +def test_redirect_text_flushes_so_piped_output_streams() -> None: + """Regression: in-process esptool progress must reach the pipe right away. + + ``run_external_command`` runs esptool inside our own process, so its + progress output goes through ``RedirectText.write``. That used to be + flushed only because ``colorama.init()`` wrapped stdout in a stream that + flushed after every write. + """ + buf = io.BytesIO() + piped_stream = io.TextIOWrapper( + buf, encoding="utf-8", newline="\n", line_buffering=False + ) + redirect = util.RedirectText(piped_stream) + + redirect.write("Writing at 0x00010000 (50%)\r") + + # No explicit flush here on purpose: RedirectText has to do it. + assert buf.getvalue() == b"Writing at 0x00010000 (50%)\r" + + def test_redirect_text_callback_called_on_matching_line() -> None: """Test that a line callback is called and its output is written.""" results: list[str] = [] @@ -745,6 +765,31 @@ class TestSafePrint: util.safe_print("\033[0;32mhi\033[0m") assert capsys.readouterr().out == "\\033[0;32mhi\\033[0m\n" + def test_flushes_so_piped_output_streams( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Regression: each line must reach the OS pipe right away. + + The dashboard runs ``esphome logs`` with stdout as a pipe, which + Python block buffers at 8 KiB. Log lines used to be flushed only + because ``colorama.init()`` wrapped stdout in a stream that flushed + after every write; once that wrapping was skipped for dashboard runs + the lines sat in the buffer and the log view stayed empty until + enough output piled up to fill it. + """ + buf = io.BytesIO() + # newline="\n" keeps Windows from rewriting the terminator to "\r\n"; + # this test is about flushing, not about line endings. + piped_stream = io.TextIOWrapper( + buf, encoding="utf-8", newline="\n", line_buffering=False + ) + monkeypatch.setattr(sys, "stdout", piped_stream) + + util.safe_print("live log line") + + # No explicit flush here on purpose: safe_print has to do it. + assert buf.getvalue() == b"live log line\n" + def test_fallback_writes_string_not_bytes_repr( self, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -764,7 +809,7 @@ class TestSafePrint: monkeypatch.setattr(sys, "stdout", cp1252_stream) util.safe_print("bars: \u2582\u2584\u2586\u2588 done") - cp1252_stream.flush() + # No explicit flush: the fallback path has to flush too. output = buf.getvalue().decode("cp1252") # Output is a clean line, not the bytes repr. @@ -789,7 +834,7 @@ class TestSafePrint: monkeypatch.setattr(sys, "stdout", cp1252_stream) util.safe_print("\033[0;32m\u2582\u2584\u2586\u2588\033[0m") - cp1252_stream.flush() + # No explicit flush: the fallback path has to flush too. output = buf.getvalue().decode("cp1252") # Dashboard escaping turned ESC into literal "\033" (5 chars), which From eefd2a00c753ea0ca740000fc85cfd9e1f91f5f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 08:11:42 -0500 Subject: [PATCH 1389/1815] [espidf] Flush the runner's output so dashboard builds stream (#18264) --- esphome/espidf/runner.py | 29 ++-- .../fixtures/espidf/filtering_probe.py | 15 +++ .../fixtures/espidf/streaming_probe.py | 14 ++ tests/unit_tests/test_espidf_runner.py | 127 ++++++++++++++++++ 4 files changed, 173 insertions(+), 12 deletions(-) create mode 100644 tests/unit_tests/fixtures/espidf/filtering_probe.py create mode 100644 tests/unit_tests/fixtures/espidf/streaming_probe.py create mode 100644 tests/unit_tests/test_espidf_runner.py diff --git a/esphome/espidf/runner.py b/esphome/espidf/runner.py index 7c568db7be..9e1f24d5ed 100644 --- a/esphome/espidf/runner.py +++ b/esphome/espidf/runner.py @@ -187,20 +187,25 @@ def main() -> int: if self._filter_pattern is None: self._stream.write(data) - return len(data) + else: + self._line_buffer += data + for line in self._line_buffer.splitlines(keepends=True): + if "\n" not in line and "\r" not in line: + # Incomplete — hold until we see a terminator. + self._line_buffer = line + break + self._line_buffer = "" - self._line_buffer += data - for line in self._line_buffer.splitlines(keepends=True): - if "\n" not in line and "\r" not in line: - # Incomplete — hold until we see a terminator. - self._line_buffer = line - break - self._line_buffer = "" + stripped = ansi_escape.sub("", line).rstrip() + if self._filter_pattern.match(stripped) is not None: + continue + self._stream.write(line) - stripped = ansi_escape.sub("", line).rstrip() - if self._filter_pattern.match(stripped) is not None: - continue - self._stream.write(line) + # We tell idf.py it is talking to a terminal, so it sends progress + # bars and cursor moves. Our own stdout is usually a pipe, which is + # block buffered, so without this the build looks frozen until + # 8 KiB of output piles up. + self._stream.flush() return len(data) if len(sys.argv) < 2: diff --git a/tests/unit_tests/fixtures/espidf/filtering_probe.py b/tests/unit_tests/fixtures/espidf/filtering_probe.py new file mode 100644 index 0000000000..04c2b2ed8c --- /dev/null +++ b/tests/unit_tests/fixtures/espidf/filtering_probe.py @@ -0,0 +1,15 @@ +"""Write a mix of noisy and useful build lines, without flushing. + +Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. The +runner's shim owns both the filtering and the flushing, so this script +only writes. +""" + +import sys + +sys.stdout.write("Project build complete.\n") +sys.stdout.write("Compiling main.cpp\n") +sys.stdout.write("-- Component paths: /a /b /c\n") +sys.stdout.write("[2/9] Building C object\n") +# No terminator, so the shim has to hold this one back. +sys.stdout.write("still going") diff --git a/tests/unit_tests/fixtures/espidf/streaming_probe.py b/tests/unit_tests/fixtures/espidf/streaming_probe.py new file mode 100644 index 0000000000..c05741e311 --- /dev/null +++ b/tests/unit_tests/fixtures/espidf/streaming_probe.py @@ -0,0 +1,14 @@ +"""Print one line, then stay alive so the caller can prove it streamed. + +Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. The +runner wraps stdout in its filtering shim, so this script deliberately +does not flush: the shim has to do it. The long sleep keeps the process +running, so anything the caller reads must have arrived while the build +was still going rather than at exit. +""" + +import sys +import time + +sys.stdout.write("Compiling main.cpp\n") +time.sleep(60) diff --git a/tests/unit_tests/test_espidf_runner.py b/tests/unit_tests/test_espidf_runner.py new file mode 100644 index 0000000000..831c8d1cc8 --- /dev/null +++ b/tests/unit_tests/test_espidf_runner.py @@ -0,0 +1,127 @@ +"""Tests for esphome.espidf.runner.""" + +from __future__ import annotations + +import io +import os +from pathlib import Path +import subprocess +import sys +import threading + +import pytest + +from esphome.espidf import runner + +# A flushing runner delivers the first line in well under a second; this is +# only ever waited out when the shim has gone back to buffering, so keep it +# just long enough to cover interpreter startup on a loaded CI machine. +FIRST_LINE_TIMEOUT = 10.0 + + +def _run_main( + monkeypatch: pytest.MonkeyPatch, probe: Path, *args: str +) -> tuple[io.BytesIO, io.TextIOWrapper]: + """Run ``runner.main()`` in-process against a buffered fake stdout. + + ``main`` rewrites ``sys.path``, ``sys.argv``, both std streams and + ``os.get_terminal_size``; every one of those is monkeypatched so it is + put back afterwards. The fake stdout is block buffered like a pipe, so + the caller can tell whether the shim flushed. The wrapper comes back with + the buffer because dropping it would close the buffer underneath us. + """ + buf = io.BytesIO() + stream = io.TextIOWrapper(buf, encoding="utf-8", newline="\n", line_buffering=False) + + monkeypatch.setattr(sys, "path", list(sys.path)) + monkeypatch.setattr(sys, "argv", ["runner.py", str(probe), *args]) + monkeypatch.setattr(sys, "stdout", stream) + monkeypatch.setattr(sys, "stderr", stream) + monkeypatch.setattr(os, "get_terminal_size", os.get_terminal_size) + + assert runner.main() == 0 + return buf, stream + + +def test_main_filters_noise_and_flushes_each_write( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """Useful lines reach the stream right away; noisy ones are dropped.""" + buf, _stream = _run_main( + monkeypatch, fixture_path / "espidf" / "filtering_probe.py" + ) + + # Read before any flush of our own: the shim has to have flushed. + output = buf.getvalue().decode("utf-8") + + assert "Compiling main.cpp\n" in output + assert "[2/9] Building C object\n" in output + # Matched by FILTER_IDF_LINES, so they never leave the runner. + assert "Project build complete." not in output + assert "-- Component paths:" not in output + # Held back because no terminator arrived. + assert "still going" not in output + + +def test_main_keeps_everything_in_verbose_mode( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """``-v`` turns the filter off so the noisy lines survive.""" + buf, _stream = _run_main( + monkeypatch, fixture_path / "espidf" / "filtering_probe.py", "-v" + ) + + output = buf.getvalue().decode("utf-8") + + assert "Project build complete.\n" in output + assert "-- Component paths: /a /b /c\n" in output + # With no filter there is no line buffering, so the partial line goes + # straight through as well. + assert output.endswith("still going") + + +def test_runner_streams_output_before_the_build_finishes( + fixture_path: Path, probe_env: dict[str, str] +) -> None: + """The runner must flush, or a dashboard build looks frozen. + + ``toolchain.py`` spawns the runner as a plain script with no ``-u``, and + hands it a pipe when esphome itself is running under the dashboard. A + pipe is block buffered, so without a flush in the shim's ``write()`` the + output sits in the child until 8 KiB piles up or the build ends. + """ + runner_py = Path(runner.__file__) + probe = fixture_path / "espidf" / "streaming_probe.py" + + with subprocess.Popen( + [sys.executable, str(runner_py), str(probe)], + stdout=subprocess.PIPE, + # Keep stderr: if the runner dies on startup, its traceback is the + # only clue about why no line showed up. + stderr=subprocess.PIPE, + env=probe_env, + text=True, + ) as proc: + assert proc.stdout is not None + assert proc.stderr is not None + first_line: list[str] = [] + reader = threading.Thread( + target=lambda: first_line.append(proc.stdout.readline()), daemon=True + ) + try: + reader.start() + reader.join(FIRST_LINE_TIMEOUT) + still_running = proc.poll() is None + + # The probe sleeps for a minute after writing, so reaching us at + # all means the line was flushed rather than released at exit. + assert first_line == ["Compiling main.cpp\n"], ( + f"runner stderr: {'' if still_running else proc.stderr.read()}" + ) + assert still_running + finally: + proc.kill() + proc.wait() + # Join before leaving the block, so the reader is done rather than + # racing ``Popen`` closing the pipe under it. + reader.join(1.0) From 8ee3c8d41d50368f9839535e8ca8125caa54777d Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 11 Aug 2026 06:31:15 -0700 Subject: [PATCH 1390/1815] [modbus] Properly support client-mode broadcast sends (#17467) Co-authored-by: J. Nick Koston --- esphome/components/modbus/modbus.cpp | 27 +++- esphome/components/modbus/modbus.h | 35 ++-- .../components/modbus_client/modbus_client.h | 3 +- .../modbus_controller/modbus_controller.cpp | 18 ++- .../modbus/modbus_client_hub_test.cpp | 149 +++++++++++++++++ .../uart_mock_modbus_broadcast_write.yaml | 150 ++++++++++++++++++ tests/integration/test_uart_mock_modbus.py | 38 +++++ 7 files changed, 405 insertions(+), 15 deletions(-) create mode 100644 tests/integration/fixtures/uart_mock_modbus_broadcast_write.yaml diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index cff086aeea..5305f6313f 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -815,6 +815,16 @@ void ModbusClientHub::send_next_frame_() { } cmd->sent(); + if (cmd->frame.address() == BROADCAST_ADDRESS) { + // A broadcast (address 0) is never answered (Modbus 4.1), so it is fire-and-forget: on_sent above + // reports the transmission, and the entry then retires with no terminal callback instead of + // occupying the waiting slot until the send-wait timeout expires. The turnaround delay already + // spaces the next frame; the following sweep erases the entry. + ESP_LOGV(TAG, "Broadcast to address 0 sent; no reply expected (fire-and-forget)"); + cmd->complete_broadcast(); + this->sweep_needed_ = true; + return; + } this->waiting_for_response_ = true; } @@ -1033,9 +1043,24 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M ESP_LOGE(TAG, "Frame too large, refused: %" PRIu8 ":%zu bytes", address, pdu.size()); return false; } + // classify() drives both the broadcast guard and the continuous check below; compute it once. + const CommandPriority priority = ModbusDeviceCommand::classify(pdu[0]); + + // A broadcast (address 0) is never answered (Modbus 4.1), so it is only meaningful for a command that + // changes state. Refuse a broadcast that expects a reply - anything but a write or a custom/vendor code - + // as it could never deliver a result, so the caller learns via the false return (and on_not_sent). + // 0x17 (read/write multiple) is a knowing inclusion: classify() treats it as a write, so its write half + // lands on every server and its unanswerable read half is simply discarded. An exception-flagged custom + // code (0x80 bit set) is refused: is_function_code_custom() masks that bit away, so exclude it explicitly + // here to match classify()'s exception-first handling of the write side. + if (address == BROADCAST_ADDRESS && priority != CommandPriority::WRITE && + (!helpers::is_function_code_custom(pdu[0]) || helpers::is_function_code_exception(pdu[0]))) { + ESP_LOGW(TAG, "Broadcast refused for function 0x%X: a broadcast (address 0) is never answered", pdu[0]); + return false; + } // continuous is ignored for every mutating code (re-writing a value forever is never intended). - const bool mutates = ModbusDeviceCommand::classify(pdu[0]) == CommandPriority::WRITE; + const bool mutates = priority == CommandPriority::WRITE; bool continuous = false; if (options.continuous) { if (mutates) { diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 6bd407a687..dfe4a4872d 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -171,6 +171,15 @@ struct ModbusDeviceCommand { this->pending = 0; this->device = nullptr; } + // Fire-and-forget completion for a broadcast (address 0): the frame was transmitted (on_sent already + // fired), but a broadcast is never answered (Modbus 4.1), so the entry retires with NO terminal + // callback and the sweep erases it. Unlike response()/error()/timed_out(), it delivers nothing. + // A broadcast only carries a write or a custom code (reads are refused at queue_pdu()), and every such + // code caps pending at 1, so pending is always 1 here - clear it. + void complete_broadcast() { + this->state = FrameState::RETIRED; + this->pending = 0; + } // Re-ready for another transmission, restamped to the tail of its class (hub passes next_seq_++). void requeue(uint16_t seq) { this->state = FrameState::READY; @@ -270,7 +279,8 @@ class ModbusClientHub : public Modbus { }; /// Queue a request. The name says queue, not send: the frame is appended to the transmit queue and /// goes out later from loop(), so a true return means accepted into the machine (it will resolve in - /// exactly one terminal callback), NOT that anything reached the wire - that is on_sent(). False means + /// exactly one terminal callback - except a broadcast (address 0), which is never answered and so gets + /// only on_sent()), NOT that anything reached the wire - that is on_sent(). False means /// it never entered the machine at all (empty or oversize PDU, full queue, anonymous or over-cap /// duplicate) and no callback of any kind will follow; the false return is the whole story. bool queue_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device = nullptr, @@ -411,13 +421,14 @@ class ModbusServerHub : public Modbus { /// Callback contract. Each accepted request ends in exactly ONE terminal: on_response() (data), /// on_error() (exception), on_no_response() (timeout/interruption), or on_not_sent() (dropped by /// clear_tx_queue_for_address before transmission). A request refused at queue_pdu() (false return) -/// gets none. on_sent() is additional, once per transmission, never for an on_not_sent() request. -/// on_response()/on_error() fire at parse time and on_no_response() at the send-wait watchdog, all -/// from a quiescent hub; only on_not_sent() is delivered by the sweep. Sending or clearing from +/// gets none, and a broadcast (address 0) gets on_sent() with NO terminal, since a broadcast is never +/// answered (Modbus 4.1). on_sent() is additional, once per transmission, never for an on_not_sent() +/// request. on_response()/on_error() fire at parse time and on_no_response() at the send-wait watchdog, +/// all from a quiescent hub; only on_not_sent() is delivered by the sweep. Sending or clearing from /// inside a callback is safe (picked up by the next sweep). Exceptions to "exactly one terminal": -/// clear_tx_queue_for_device() drops the caller's own frames silently; a continuous poll's cycles are -/// its own accounting (a one-shot duplicate downgrades the poll to a one-shot; a continuous duplicate -/// merges into it). +/// a broadcast is fire-and-forget (on_sent, no terminal); clear_tx_queue_for_device() drops the caller's +/// own frames silently; a continuous poll's cycles are its own accounting (a one-shot duplicate +/// downgrades the poll to a one-shot; a continuous duplicate merges into it). /// /// Invariants: /// - Public entry points (queue_pdu/clear_tx_queue_*) only append to the queue or mutate an existing @@ -531,8 +542,9 @@ class ModbusClientDevice { this); } /// See ModbusClientHub::queue_pdu(): true = accepted into the queue and a terminal callback will - /// follow, false = refused at the door and nothing further happens. Neither means the frame is on - /// the wire; on_sent() reports that. + /// follow (except a broadcast (address 0), which is never answered and so gets only on_sent()), + /// false = refused at the door and nothing further happens. Neither means the frame is on the wire; + /// on_sent() reports that. bool queue_pdu(std::span pdu, CommandOptions options = {}) { return this->parent_->queue_pdu(this->address_, pdu, this, options); } @@ -548,8 +560,9 @@ class ModbusClientDevice { this->parent_->queue_pdu(payload[0], std::span(payload).subspan(1), this); } // The typed request builders below all queue through queue_pdu(), so they share its contract: true - // means the request is queued and will resolve in exactly one terminal callback, false means it was - // refused outright with no callback. Neither says the frame has been transmitted - on_sent() does. + // means the request is queued and will resolve in exactly one terminal callback (except a broadcast + // (address 0), which is never answered and so gets only on_sent()), false means it was refused outright + // with no callback. Neither says the frame has been transmitted - on_sent() does. // Reads via the table-appropriate function code; an unreadable entity type maps to INVALID, which // create_read_pdu() rejects into an empty PDU and queue_pdu() refuses with a false return. bool read_entities(EntityType entity_type, uint16_t start_address, uint16_t number_of_entities, diff --git a/esphome/components/modbus_client/modbus_client.h b/esphome/components/modbus_client/modbus_client.h index 7e6d9d069f..20dc1a4745 100644 --- a/esphome/components/modbus_client/modbus_client.h +++ b/esphome/components/modbus_client/modbus_client.h @@ -64,7 +64,8 @@ template class ClientActionBase : public Action, public m protected: /// The hub refuses some sends at the door with no callback (a duplicate write already pending, a full /// queue, or an empty PDU - which is how the create_*_pdu() builders reject out-of-spec input). Every - /// send still gets exactly one outcome, so resolve refusals here via on_not_sent. + /// send still gets exactly one outcome (a broadcast (address 0) is the exception - never answered, it + /// resolves through on_sent() alone), so resolve refusals here via on_not_sent. /// Takes a span, not a PduBuffer: the builders return right-sized buffers (a read PDU is 5 bytes), and /// a PduBuffer parameter would widen each one to the 253-byte maximum just to cross the call. void send_or_resolve_(std::span pdu) { diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 35f21fd0af..2c568938e4 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -109,8 +109,22 @@ void ModbusCommandItem::on_not_sent(std::span request_pdu) { // Fired once per wire transmission (including hub re-queues from a retry), so the on_command_sent // trigger reflects when the frame actually went out, not when it was queued. void ModbusCommandItem::on_sent(std::span request_pdu) { - if (this->controller_ != nullptr) - this->controller_->command_sent(static_cast(this->function_code_), this->start_address_); + if (this->controller_ == nullptr) + return; + this->controller_->command_sent(static_cast(this->function_code_), this->start_address_); + // A broadcast (address 0) is never answered (Modbus 4.1), so the hub delivers no terminal callback. + // on_sent is this command's only callback, so drop the one-shot from the queue here, or it would leak. + // Test the address the frame went to, not address_: a custom command's frame carries its own address + // (frame[0]), which may differ from this controller's. (unqueue_command() is a no-op for a poll.) + uint8_t wire_address = this->address_; + if (this->function_code_ == FunctionCode::CUSTOM) { + std::span frame = + this->custom_data_ != nullptr ? std::span(*this->custom_data_) : this->payload; + if (!frame.empty()) + wire_address = frame[0]; + } + if (wire_address == modbus::BROADCAST_ADDRESS) + this->controller_->unqueue_command(this); } bool ModbusCommandItem::on_no_response(std::span request_pdu) { diff --git a/tests/components/modbus/modbus_client_hub_test.cpp b/tests/components/modbus/modbus_client_hub_test.cpp index c2a36c0da7..43c81bbf34 100644 --- a/tests/components/modbus/modbus_client_hub_test.cpp +++ b/tests/components/modbus/modbus_client_hub_test.cpp @@ -684,6 +684,155 @@ TEST(ModbusClientHubSent, FiresOnWireNotOnQueue) { EXPECT_TRUE(hub.waiting()); } +namespace { +// Records on_sent / on_response / on_no_response so a broadcast's fire-and-forget completion +// (on_sent, and no terminal) can be asserted. +class BroadcastProbeDevice : public ModbusClientDevice { + public: + BroadcastProbeDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_sent(std::span request_pdu) override { this->sent_count_++; } + void on_response(std::span request_pdu, std::span response_pdu) override { + this->response_count_++; + this->last_response_size_ = response_pdu.size(); + } + bool on_no_response(std::span request_pdu) override { + this->no_response_count_++; + return false; + } + int sent_count_{0}; + int response_count_{0}; + int no_response_count_{0}; + size_t last_response_size_{0}; +}; +} // namespace + +// A broadcast (address 0) is never answered (Modbus 4.1), so the client treats it as fire-and-forget: +// on_sent fires as the frame goes out, NO terminal (on_response/on_error/on_no_response) is delivered, +// the hub is left NOT waiting - no timeout is burned - and the sweep erases the entry. +TEST(ModbusClientHubBroadcast, CompletesAtTransmissionWithoutWaiting) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01}; // write single register 0x0010 = 0x0001 + ASSERT_TRUE(device.queue_pdu(write)); + EXPECT_EQ(hub.queued_frames(), 1u); + + hub.send_next_for_test(); // transmit + sweep + + EXPECT_EQ(device.sent_count_, 1); // the frame went on the wire + EXPECT_EQ(device.response_count_, 0); // fire-and-forget: no terminal callback + EXPECT_EQ(device.no_response_count_, 0); // and it never waited for a reply + EXPECT_FALSE(hub.waiting()); // no waiting slot occupied + EXPECT_EQ(hub.queued_frames(), 0u); // and the entry is gone + EXPECT_EQ(hub.entries(), 0u); +} + +namespace { +// Keeps the DEFAULT on_response() (so the base typed dispatcher runs) and records the typed write +// callback and the catch-all, to prove a broadcast reaches neither - only on_sent. +class BroadcastTypedProbeDevice : public ModbusClientDevice { + public: + BroadcastTypedProbeDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_sent(std::span request_pdu) override { this->sent_count_++; } + void on_write_single_register(uint16_t address, uint16_t value, ResponseStatus status) override { + this->write_single_count_++; + } + void on_custom_response(std::span request_pdu, std::span response_pdu, + ResponseStatus status) override { + this->custom_count_++; + } + int sent_count_{0}; + int write_single_count_{0}; + int custom_count_{0}; +}; +} // namespace + +// Completing a broadcast with an empty response({}) used to fall, for a device on the default +// on_response(), through the typed dispatcher to on_custom_response() - firing the wrong callback and +// logging a spurious "non-standard" warning. Fire-and-forget delivers no terminal at all, so a broadcast +// write reaches neither the typed write callback nor the catch-all: only on_sent. +TEST(ModbusClientHubBroadcast, DeliversNoTerminalToTypedDevice) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastTypedProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01}; // write single register 0x0010 = 0x0001 + ASSERT_TRUE(device.queue_pdu(write)); + + hub.send_next_for_test(); // transmit + sweep + + EXPECT_EQ(device.sent_count_, 1); // on_sent still reports the transmission + EXPECT_EQ(device.write_single_count_, 0); // no terminal: the typed write callback never fires + EXPECT_EQ(device.custom_count_, 0); // and it is NOT diverted to the catch-all (no false warning) + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// A broadcast is only meaningful for a command that changes state; a broadcast READ could never be +// answered, so the hub refuses it at the door (false return, no entry queued) rather than silently +// retiring it. Writes, 0x17, and custom codes still go through (covered above). +TEST(ModbusClientHubBroadcast, RefusesReadBroadcast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; // read holding registers 0x0010, count 2 + EXPECT_FALSE(device.queue_pdu(read)); // refused: a broadcast read is never answered + EXPECT_EQ(hub.entries(), 0u); // nothing entered the machine + EXPECT_FALSE(hub.waiting()); + + hub.send_next_for_test(); // nothing to send + EXPECT_EQ(device.sent_count_, 0); // never transmitted +} + +// The counterpart to RefusesReadBroadcast: a custom (user-defined) function code carries no reply the +// hub knows how to expect, so a broadcast of one is accepted and completes fire-and-forget like a write. +TEST(ModbusClientHubBroadcast, AcceptsCustomBroadcast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t custom[] = {0x41, 0x01, 0x02}; // FC 0x41: first user-defined function code space + ASSERT_TRUE(device.queue_pdu(custom)); // accepted: a custom code is not a read + EXPECT_EQ(hub.queued_frames(), 1u); + + hub.send_next_for_test(); // transmit + sweep + + EXPECT_EQ(device.sent_count_, 1); // the frame went on the wire + EXPECT_EQ(device.response_count_, 0); // fire-and-forget: no terminal callback + EXPECT_EQ(device.no_response_count_, 0); // and it never waited for a reply + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); // the entry is gone +} + +// An exception-flagged custom code (0x80 bit set) is not a real request: is_function_code_custom() masks +// the bit away and would accept it, but the broadcast guard excludes it, matching classify()'s handling +// of an exception-flagged write. +TEST(ModbusClientHubBroadcast, RefusesExceptionFlaggedCustomBroadcast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t exception_custom[] = {0xC1, 0x01, 0x02}; // 0x41 | 0x80: custom code with the exception bit + EXPECT_FALSE(device.queue_pdu(exception_custom)); // refused: exception-flagged, never a real broadcast + EXPECT_EQ(hub.entries(), 0u); // nothing entered the machine + EXPECT_FALSE(hub.waiting()); + + hub.send_next_for_test(); // nothing to send + EXPECT_EQ(device.sent_count_, 0); // never transmitted +} + namespace { // tx_blocked() clear for send_next_frame_'s gate, then blocked for send_frame_'s post-delay re-check. class RejectPostDelayHub : public NoResponseProbeHub { diff --git a/tests/integration/fixtures/uart_mock_modbus_broadcast_write.yaml b/tests/integration/fixtures/uart_mock_modbus_broadcast_write.yaml new file mode 100644 index 0000000000..8857bf8c96 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_broadcast_write.yaml @@ -0,0 +1,150 @@ +esphome: + name: uart-mock-modbus-broadcast + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + auto_start: true # controller polls at boot; forwarding must already be active + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - uart_mock.inject_rx: + id: virtual_uart_server_2 + data: !lambda return data; + - id: virtual_uart_server_2 + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - id: virtual_uart_controller + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + - uart_mock.inject_rx: + id: virtual_uart_server_2 + data: !lambda return data; + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_server_2 + id: virtual_modbus_server_2 + role: server + - uart_id: virtual_uart_controller + id: virtual_modbus_client + role: client + turnaround_time: 10ms + +globals: + - id: srv1_reg + type: int + initial_value: "0" + - id: srv2_reg + type: int + initial_value: "0" + +modbus_controller: + - address: 1 + modbus_id: virtual_modbus_client + # Polling is off until the test has subscribed; the Start Scenario button starts it, so the + # first poll is never lost to a boot-time race ahead of the API subscription. + update_interval: never + id: modbus_controller_1 + +modbus_server: + - address: 1 + modbus_id: virtual_modbus_server + registers: + - address: 0x01 + value_type: U_WORD + read_lambda: return 919; + - address: 0x10 + value_type: U_WORD + read_lambda: return id(srv1_reg); + write_lambda: |- + id(srv1_reg) = x; + return true; + - address: 2 + modbus_id: virtual_modbus_server_2 + registers: + - address: 0x10 + value_type: U_WORD + read_lambda: return id(srv2_reg); + write_lambda: |- + id(srv2_reg) = x; + return true; + +sensor: + # Normal polling continues before and after the broadcast: the old behavior burned a + # timeout per broadcast, which surfaces as modbus warnings and failed expectations here. + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_word" + address: 0x01 + register_type: holding + value_type: U_WORD + # Republish every poll (the value is constant 919): the test observes successive publishes to + # prove polling continues before and after the broadcast, which dedup would otherwise hide. + force_update: true + # The servers' written values, published locally. + - platform: template + name: "srv1_written" + lambda: return id(srv1_reg); + update_interval: 0.2s + - platform: template + name: "srv2_written" + lambda: return id(srv2_reg); + update_interval: 0.2s + # Whether the hub accepted the broadcast into the transmit queue (the bool queue_pdu() returns). + - platform: template + name: "broadcast_accepted" + id: broadcast_accepted + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: |- + // Start polling now that the test has subscribed. + id(modbus_controller_1).set_update_interval(1000); + id(modbus_controller_1).start_poller(); + // Broadcast (address 0) write single register: reg 0x10 = 777 on every server. + // PDU is function code + data (no address/CRC); the hub prepends address 0 and appends CRC. + const uint8_t pdu[] = {0x06, 0x00, 0x10, 0x03, 0x09}; + // queue_pdu() returns whether the broadcast was accepted into the machine (the answer this PR + // makes meaningful); publish it so the test asserts the accept, not just the servers' writes. + bool accepted = id(virtual_modbus_client)->queue_pdu(0x00, pdu); + id(broadcast_accepted).publish_state(accepted ? 1.0f : 0.0f); diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 057d419fd8..d0b375dd25 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -836,6 +836,44 @@ async def test_uart_mock_modbus_fairness( ) +@pytest.mark.asyncio +async def test_uart_mock_modbus_broadcast_write( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A client broadcast write (address 0) reaches every server and costs no timeout. + + The scenario button sends a broadcast single-register write of 777 to register + 0x10; both servers must apply it. The client's normal polling sensor must keep + updating, and no modbus warnings may appear - the pre-broadcast-support behavior + parked the frame in the waiting slot until the send-wait timeout, which surfaced + here as 'Stop waiting for response' warnings and a stalled poll. + """ + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + tracker = SensorTracker( + ["reg_u_word", "srv1_written", "srv2_written", "broadcast_accepted"] + ) + poll_before = tracker.expect("reg_u_word", 919) + written = tracker.expect_all({"srv1_written": 777, "srv2_written": 777}) + # queue_pdu() must accept the broadcast into the machine (return true), the answer this PR adds. + accepted = tracker.expect("broadcast_accepted", 1) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + await tracker.await_change(accepted, "broadcast_accepted") + await tracker.await_change(poll_before, "reg_u_word") + await tracker.await_all(written) + # Polling must continue after the broadcast (a burned timeout stalls it). + poll_after = tracker.expect("reg_u_word", 919) + await tracker.await_change(poll_after, "reg_u_word", timeout=3.0) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + @pytest.mark.asyncio async def test_uart_mock_modbus_client_read_write( yaml_config: str, From f3a5a9fbd5ff1f1bb57f631654723065b9fe0a20 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 08:41:55 -0500 Subject: [PATCH 1391/1815] [core] Retry transient git network failures with backoff (#18242) --- esphome/espidf/framework.py | 7 +- esphome/git.py | 181 ++++++- tests/unit_tests/test_espidf_framework.py | 12 + tests/unit_tests/test_git.py | 579 +++++++++++++++++++++- 4 files changed, 766 insertions(+), 13 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 39bf0465d5..0f6ef873b8 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -458,11 +458,16 @@ def _clone_idf_with_submodules( key = f"{git_url}@{ref}" if ref else git_url _LOGGER.info("Cloning ESP-IDF from %s", key) - run_git_command(["git", "clone", "--depth=1", "--", git_url, str(framework_path)]) + run_git_command( + ["git", "clone", "--depth=1", "--", git_url, str(framework_path)], + network=True, + retry_cleanup=framework_path, + ) if ref: run_git_command( ["git", "fetch", "--depth=1", "--", "origin", ref], git_dir=framework_path, + network=True, ) run_git_command( ["git", "reset", "--hard", "FETCH_HEAD"], diff --git a/esphome/git.py b/esphome/git.py index d1dca3b3ae..9815377f51 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -5,6 +5,7 @@ from enum import Enum, auto import errno import hashlib import logging +import math import os from pathlib import Path import re @@ -77,6 +78,45 @@ _GIT_REPO_SCOPING_ENV = frozenset( } ) +# Substrings (matched case-insensitively against git's full stderr) that +# identify transient network failures worth retrying. Auth failures, +# missing repositories, and bad refs must fail immediately. Patterns are +# phrase-anchored so a repository URL quoted back in stderr never matches. +_TRANSIENT_GIT_ERROR_PATTERNS: tuple[str, ...] = ( + "unable to access", + "could not resolve host", + "could not connect", + "failed to connect", + "timed out", + "connection reset", + "connection refused", + "early eof", + "rpc failed", + "certificate verification failed", + # Anchored to curl's diagnostic prefix so repository URLs containing + # "ssl_" tokens never classify as transient + "openssl ssl_", + "ssl routines", + "ssl connect error", + "gnutls recv error", + "gnutls_handshake", + "unexpected disconnect", + "remote end hung up unexpectedly", +) + +# git quotes HTTP failures in two forms: curl's "The requested URL returned +# error: " and smart-HTTP's "RPC failed; HTTP curl ". 4xx is +# permanent (rejected credentials, missing repository) except 429 rate +# limiting; 408/425 are also treated as permanent, a deliberate trade for a +# simple rule since git hosts rarely emit them. +_PERMANENT_HTTP_ERROR_RE = re.compile(r"(?:http |returned error: )4(?!29)\d\d") + +# Network commands get 3 attempts with 2s/4s backoff. Worst case is ~3x +# the command's own duration plus 6s of sleep, held under the cache entry +# lock; peers with a complete entry fall back to it after +# _COMPLETE_ENTRY_LOCK_TIMEOUT_SECONDS. +_NETWORK_MAX_ATTEMPTS = 3 + class GitException(cv.Invalid): """Base exception for git-related errors.""" @@ -87,7 +127,18 @@ class GitNotInstalledError(GitException): class GitCommandError(GitException): - """Exception raised when a git command fails.""" + """Exception raised when a git command fails. + + ``stderr`` holds git's full stderr output; the exception message is + usually only the last ``fatal:`` line, but transient network markers + (``RPC failed``, ``GnuTLS``, ...) often appear on earlier lines. + Empty when git produced no stderr, so classification never reads the + command line (which embeds the user-supplied repository URL). + """ + + def __init__(self, message: str, stderr: str = "") -> None: + super().__init__(message) + self.stderr = stderr class GitRepositoryError(GitException): @@ -103,8 +154,23 @@ def _redact_url_credentials(text: str) -> str: return re.sub(r"://[^/@\s]+@", "://***@", text) +def _is_transient_git_error(stderr: str) -> bool: + """Return True when git's stderr looks like a transient network failure.""" + lowered = stderr.lower() + if _PERMANENT_HTTP_ERROR_RE.search(lowered): + return False + if "authentication failed" in lowered: + return False + return any(pattern in lowered for pattern in _TRANSIENT_GIT_ERROR_PATTERNS) + + def run_git_command( - cmd: list[str], git_dir: Path | None = None, *, cwd: Path | None = None + cmd: list[str], + git_dir: Path | None = None, + *, + cwd: Path | None = None, + network: bool = False, + retry_cleanup: Path | None = None, ) -> str: """Run a git command and return its stdout. @@ -113,7 +179,50 @@ def run_git_command( to that repository and runs the command there; ``cwd`` alone runs the command in that directory with GIT_CEILING_DIRECTORIES capping repository discovery at its parent. + + ``network=True`` marks a command that talks to a remote (clone, fetch, + submodule update): transient network failures (DNS, TLS, dropped + connections) are retried with a short backoff so a momentary blip does + not fail the whole build. Local-only commands must not set it. + ``retry_cleanup`` names a directory to remove before each retry, for + commands like clone that can leave a partial destination behind. """ + attempts = _NETWORK_MAX_ATTEMPTS if network else 1 + attempt = 0 + while True: + try: + return _run_git_command_once(cmd, git_dir, cwd=cwd) + except GitCommandError as err: + attempt += 1 + if attempt >= attempts or not _is_transient_git_error(err.stderr): + raise + if retry_cleanup is not None and retry_cleanup.is_dir(): + try: + rmtree(retry_cleanup) + except OSError as cleanup_err: + # A retry would fail on the leftover directory anyway; + # give up and keep the git error as the reported cause. + _LOGGER.warning( + "Could not remove %s before retry (%s); not retrying", + retry_cleanup, + cleanup_err, + ) + raise err from None + delay = 2**attempt + _LOGGER.warning( + "Git command failed: %s. Retrying in %d seconds... (attempt %d/%d)", + _redact_url_credentials(str(err)), + delay, + attempt, + attempts, + ) + time.sleep(delay) + + +def _run_git_command_once( + cmd: list[str], git_dir: Path | None = None, *, cwd: Path | None = None +) -> str: + """Single attempt of ``run_git_command``; see its docstring.""" # Every invocation starts from an environment with the repository-scoping # variables stripped (see _GIT_REPO_SCOPING_ENV) so a git hook or CI # wrapper invoking ESPHome can never redirect these commands to its own @@ -168,11 +277,15 @@ def run_git_command( if ret.returncode != 0: if ret.stderr: - err_str = ret.stderr.decode("utf-8") + # errors="replace": git can emit locale-encoded (non-UTF-8) bytes + # in stderr; the error path must never raise UnicodeDecodeError. + err_str = ret.stderr.decode("utf-8", errors="replace") lines = [x.strip() for x in err_str.splitlines()] if lines[-1].startswith("fatal:"): - raise GitCommandError(lines[-1][len("fatal: ") :]) - raise GitCommandError(err_str) + raise GitCommandError(lines[-1][len("fatal: ") :], stderr=err_str) + raise GitCommandError(err_str, stderr=err_str) + # No stderr (e.g. git killed by a signal): nothing to classify, + # never retried. raise GitCommandError( f"git exited with code {ret.returncode}: " f"{_redact_url_credentials(' '.join(cmd))}" @@ -409,6 +522,7 @@ def update_submodules(repo_dir: Path, key: str) -> None: run_git_command( ["git", "submodule", "update", "--init", "--recursive", "--depth=1"], cwd=repo_dir, + network=True, ) @@ -605,7 +719,7 @@ def _clone_or_update_locked( try: cmd = ["git", "clone", "--depth=1"] cmd += ["--", url, str(repo_dir)] - run_git_command(cmd) + run_git_command(cmd, network=True, retry_cleanup=repo_dir) if ref is not None: # We need to fetch the PR branch first, otherwise git will complain @@ -614,6 +728,7 @@ def _clone_or_update_locked( run_git_command( ["git", "fetch", "--depth=1", "--", "origin", ref], git_dir=repo_dir, + network=True, ) run_git_command( ["git", "reset", "--hard", "FETCH_HEAD"], git_dir=repo_dir @@ -684,7 +799,57 @@ def _clone_or_update_locked( cmd = ["git", "fetch", "--depth=1", "--", "origin"] if ref is not None: cmd.append(ref) - run_git_command(cmd, git_dir=repo_dir) + fetch_head = Path(repo_dir) / ".git" / "FETCH_HEAD" + try: + fetch_head_stat = fetch_head.stat() + except OSError: + # Missing (or unreadable): no pre-fetch FETCH_HEAD + fetch_head_stat = None + try: + run_git_command(cmd, git_dir=repo_dir, network=True) + except GitCommandError as err: + if not _is_transient_git_error(err.stderr): + raise + # Verified clone, untouched worktree, network-only + # failure: keep the clone instead of destroying it via + # recovery, which would re-clone on the same dead + # network. The marker must be restored or the next run + # removes the entry as an incomplete clone. + # + # A failed fetch still freshens FETCH_HEAD's mtime, + # which would suppress refresh attempts for the whole + # refresh window; restore it so the next run retries. + try: + if fetch_head_stat is not None: + os.utime( + fetch_head, + (fetch_head_stat.st_atime, fetch_head_stat.st_mtime), + ) + else: + fetch_head.unlink(missing_ok=True) + except OSError as stamp_err: + # Cannot keep the fallback honest; let the git error + # route through the recovery below instead. + _LOGGER.warning( + "Could not restore the refresh timestamp for %s (%s)", + safe_key, + stamp_err, + ) + raise err from None + _LOGGER.warning( + "Could not refresh %s (%s); using the existing clone " + "at %s (last updated %s ago)", + safe_key, + _redact_url_credentials(str(err)), + old_sha, + # age_seconds is inf when neither FETCH_HEAD nor HEAD + # could be stat'ed; format_duration would overflow + format_duration(age_seconds) + if math.isfinite(age_seconds) + else "unknown time", + ) + _write_clone_complete_marker(repo_dir, key, hash_dir_name, safe_key) + return repo_dir, None # Hard reset to FETCH_HEAD (short-lived git ref corresponding to most recent fetch) run_git_command( @@ -719,7 +884,7 @@ def _clone_or_update_locked( _LOGGER.warning( "Repository %s has issues (%s), attempting recovery", safe_key, - err, + _redact_url_credentials(str(err)), ) _LOGGER.info("Removing broken repository at %s", repo_dir) _remove_repo_dir(repo_dir) diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index 5912facbb3..d8e7738569 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -178,6 +178,11 @@ def test_clone_idf_with_submodules_without_ref(tmp_path: Path) -> None: assert calls[-1][:5] == ["git", "submodule", "update", "--init", "--recursive"] assert not any(c[1] == "fetch" for c in calls) assert not any(c[1] == "reset" for c in calls) + # The clone must retry transient network failures and clean up a + # partial destination between attempts + clone_kwargs = run_git_command_mock.call_args_list[0].kwargs + assert clone_kwargs["network"] is True + assert clone_kwargs["retry_cleanup"] == framework_path def test_clone_idf_with_submodules_with_ref(tmp_path: Path) -> None: @@ -205,6 +210,13 @@ def test_clone_idf_with_submodules_with_ref(tmp_path: Path) -> None: ] assert calls[2] == ["git", "reset", "--hard", "FETCH_HEAD"] assert calls[3][:5] == ["git", "submodule", "update", "--init", "--recursive"] + # Clone and fetch talk to the network and must carry the retry flag; + # the local reset must not + kwargs = [c.kwargs for c in run_git_command_mock.call_args_list] + assert kwargs[0]["network"] is True + assert kwargs[0]["retry_cleanup"] == framework_path + assert kwargs[1]["network"] is True + assert "network" not in kwargs[2] def test_clone_idf_with_submodules_raises_when_tree_missing( diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index ec1becf3e8..e296d48a46 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -247,6 +247,347 @@ def test_run_git_command_strips_fatal_prefix( assert "repository not found" in str(exc_info.value) +def _git_failure(stderr: bytes, returncode: int = 128) -> Mock: + """Build a failed subprocess.run result with the given stderr.""" + return Mock(returncode=returncode, stdout=b"", stderr=stderr) + + +_GIT_OK = Mock(returncode=0, stdout=b"ok", stderr=b"") + + +def test_run_git_command_network_retries_transient_then_succeeds( + mock_subprocess_run: Mock, +) -> None: + """A transient network failure is retried and the retry's result returned.""" + mock_subprocess_run.side_effect = [ + _git_failure( + b"fatal: unable to access 'https://github.com/test/repo/': " + b"Could not resolve host: github.com\n" + ), + _GIT_OK, + ] + + with patch("esphome.git.time.sleep") as mock_sleep: + result = git.run_git_command( + ["git", "clone", "--depth=1", "--", "https://github.com/test/repo", "x"], + network=True, + ) + + assert result == "ok" + assert mock_subprocess_run.call_count == 2 + mock_sleep.assert_called_once_with(2) + + +def test_run_git_command_network_gives_up_after_max_attempts( + mock_subprocess_run: Mock, +) -> None: + """A persistent transient-looking failure raises after the final attempt.""" + mock_subprocess_run.side_effect = lambda *args, **kwargs: _git_failure( + b"fatal: unable to access 'https://github.com/test/repo/': " + b"server certificate verification failed. CAfile: none CRLfile: none\n" + ) + + with ( + patch("esphome.git.time.sleep") as mock_sleep, + pytest.raises(GitCommandError, match="certificate verification failed"), + ): + git.run_git_command(["git", "fetch", "--", "origin"], network=True) + + assert mock_subprocess_run.call_count == 3 + assert [c.args[0] for c in mock_sleep.call_args_list] == [2, 4] + + +@pytest.mark.parametrize( + ("stderr", "transient"), + [ + # Transient: DNS, TLS, dropped connections, server-side errors + ("unable to access 'https://x/': The requested URL returned error: 502", True), + ("unable to access 'https://x/': Could not resolve host: github.com", True), + ("unable to access 'https://x/': Failed to connect: Timed out", True), + ("unable to access 'https://x/': Recv failure: Connection reset", True), + ("unable to access 'https://x/': Connection refused", True), + ("fatal: early EOF\nfatal: fetch-pack: invalid index-pack output", True), + ( + ( + "error: RPC failed; HTTP 500 curl 22 The requested URL returned " + "error: 500\nfatal: expected flush after ref listing" + ), + True, + ), + ( + ( + "unable to access 'https://x/': server certificate verification " + "failed. CAfile: none CRLfile: none" + ), + True, + ), + ( + ( + "error: RPC failed; curl 56 GnuTLS recv error (-110)\n" + "fatal: the remote end hung up unexpectedly" + ), + True, + ), + ( + ( + "fetch-pack: unexpected disconnect while reading sideband packet\n" + "fatal: early EOF" + ), + True, + ), + # 429 rate limiting is the one retryable 4xx, in both curl forms + ("unable to access 'https://x/': The requested URL returned error: 429", True), + ("error: RPC failed; HTTP 429 curl 22\nfatal: expected flush", True), + ( + ( + "unable to access 'https://x/': OpenSSL SSL_read: error:0A000126:" + "SSL routines::unexpected eof while reading, errno 0" + ), + True, + ), + # Permanent: missing repo, auth, bad ref, other 4xx + ("fatal: repository 'https://github.com/test/repo/' not found", False), + ( + ( + "fatal: could not read Username for 'https://github.com': " + "terminal prompts disabled" + ), + False, + ), + ("fatal: couldn't find remote ref refs/heads/nope", False), + ( + ( + "unable to access 'https://github.com/org/private.git/': " + "The requested URL returned error: 403" + ), + False, + ), + ("fatal: Authentication failed for 'https://github.com/test/repo/'", False), + # Smart-HTTP (HTTP/2) 4xx form has no "returned error:" text and + # mixes in transient-looking wording; still permanent + ( + ( + "error: RPC failed; HTTP 403 curl 92 HTTP/2 stream 5 was not " + "closed cleanly: CANCEL (err 8)\nfatal: expected flush after " + "ref listing" + ), + False, + ), + ( + ( + "error: RPC failed; HTTP 404 curl 22\n" + "fatal: the remote end hung up unexpectedly" + ), + False, + ), + ( + ( + "fatal: unable to access 'https://x/': gnutls_handshake() " + "failed: The TLS connection was non-properly terminated." + ), + True, + ), + # Transient-looking tokens in the URL must not classify as transient + ("fatal: repository 'https://github.com/x/esp32_ssl_reader/' not found", False), + ("fatal: repository 'https://gitlab.com/gnutls/gnutls.git/' not found", False), + ("", False), + ], +) +def test_is_transient_git_error(stderr: str, transient: bool) -> None: + """Real-world stderr outputs classify correctly as transient or permanent.""" + assert git._is_transient_git_error(stderr) is transient + + +def test_run_git_command_network_no_retry_on_permanent_error( + mock_subprocess_run: Mock, +) -> None: + """Permanent failures (missing repo, auth, bad ref) fail on the first try.""" + mock_subprocess_run.return_value = _git_failure( + b"fatal: repository 'https://github.com/test/repo/' not found\n" + ) + + with ( + patch("esphome.git.time.sleep") as mock_sleep, + pytest.raises(GitCommandError), + ): + git.run_git_command(["git", "fetch", "--", "origin"], network=True) + + assert mock_subprocess_run.call_count == 1 + mock_sleep.assert_not_called() + + +def test_run_git_command_network_no_retry_when_git_missing( + mock_subprocess_run: Mock, +) -> None: + """A missing git binary is not transient and must not be retried.""" + from esphome.git import GitNotInstalledError + + mock_subprocess_run.side_effect = FileNotFoundError("git not found") + + with ( + patch("esphome.git.time.sleep") as mock_sleep, + pytest.raises(GitNotInstalledError), + ): + git.run_git_command(["git", "fetch", "--", "origin"], network=True) + + assert mock_subprocess_run.call_count == 1 + mock_sleep.assert_not_called() + + +def test_run_git_command_no_retry_by_default(mock_subprocess_run: Mock) -> None: + """Without network=True even a transient-looking failure is not retried.""" + mock_subprocess_run.return_value = _git_failure( + b"fatal: unable to access 'https://github.com/test/repo/': " + b"Could not resolve host: github.com\n" + ) + + with ( + patch("esphome.git.time.sleep") as mock_sleep, + pytest.raises(GitCommandError), + ): + git.run_git_command(["git", "status"]) + + assert mock_subprocess_run.call_count == 1 + mock_sleep.assert_not_called() + + +def test_run_git_command_network_retry_matches_full_stderr_not_last_line( + mock_subprocess_run: Mock, +) -> None: + """The transient marker often sits above the final fatal line; the retry + decision must look at the full stderr, not just the extracted message.""" + mock_subprocess_run.side_effect = [ + _git_failure( + b"error: RPC failed; curl 56 GnuTLS recv error (-54)\n" + b"fatal: fetch-pack: invalid index-pack output\n" + ), + _GIT_OK, + ] + + with patch("esphome.git.time.sleep"): + result = git.run_git_command(["git", "fetch", "--", "origin"], network=True) + + assert result == "ok" + assert mock_subprocess_run.call_count == 2 + + +def test_run_git_command_retry_warning_redacts_credentials( + mock_subprocess_run: Mock, caplog: pytest.LogCaptureFixture +) -> None: + """The retry warning embeds the git error, which embeds the URL; embedded + credentials must be redacted since warnings end up in pasted logs.""" + mock_subprocess_run.side_effect = [ + _git_failure( + b"fatal: unable to access 'https://user:hunter2@github.com/test/repo/': " + b"Could not resolve host: github.com\n" + ), + _GIT_OK, + ] + + with ( + patch("esphome.git.time.sleep"), + caplog.at_level(logging.WARNING, logger="esphome.git"), + ): + git.run_git_command(["git", "fetch", "--", "origin"], network=True) + + assert "hunter2" not in caplog.text + assert "://***@github.com/test/repo" in caplog.text + + +def test_run_git_command_clone_retry_removes_leftover_destination( + tmp_path: Path, mock_subprocess_run: Mock +) -> None: + """A partial clone destination left by a failed attempt is removed before + the retry, so the retry cannot fail on 'destination path already exists'.""" + dest = tmp_path / "leftover_clone" + dest.mkdir() + (dest / "partial").write_text("x") + + mock_subprocess_run.side_effect = [ + _git_failure( + b"fatal: unable to access 'https://github.com/test/repo/': " + b"Could not resolve host: github.com\n" + ), + _GIT_OK, + ] + + with patch("esphome.git.time.sleep"): + result = git.run_git_command( + [ + "git", + "clone", + "--depth=1", + "--", + "https://github.com/test/repo", + str(dest), + ], + network=True, + retry_cleanup=dest, + ) + + assert result == "ok" + assert mock_subprocess_run.call_count == 2 + assert not dest.exists() + + +def test_run_git_command_cleanup_failure_reraises_original_error( + tmp_path: Path, mock_subprocess_run: Mock +) -> None: + """When the pre-retry cleanup fails, the git error stays the reported + cause instead of being replaced by the cleanup OSError.""" + dest = tmp_path / "leftover_clone" + dest.mkdir() + + mock_subprocess_run.return_value = _git_failure( + b"fatal: unable to access 'https://github.com/test/repo/': " + b"Could not resolve host: github.com\n" + ) + + with ( + patch("esphome.git.rmtree", side_effect=OSError("locked")), + patch("esphome.git.time.sleep") as mock_sleep, + pytest.raises(GitCommandError, match="Could not resolve host"), + ): + git.run_git_command( + ["git", "clone", "--depth=1", "--", "https://github.com/test/repo", "x"], + network=True, + retry_cleanup=dest, + ) + + assert mock_subprocess_run.call_count == 1 + mock_sleep.assert_not_called() + + +def test_run_git_command_no_retry_on_empty_stderr_failure( + mock_subprocess_run: Mock, +) -> None: + """A failure with no stderr (e.g. git killed by a signal) is not retried.""" + mock_subprocess_run.return_value = _git_failure(b"", returncode=1) + + with ( + patch("esphome.git.time.sleep") as mock_sleep, + pytest.raises(GitCommandError, match="git exited with code 1"), + ): + git.run_git_command(["git", "fetch", "--", "origin"], network=True) + + assert mock_subprocess_run.call_count == 1 + mock_sleep.assert_not_called() + + +def test_run_git_command_non_utf8_stderr_does_not_crash( + mock_subprocess_run: Mock, +) -> None: + """Locale-encoded (non-UTF-8) stderr must not raise UnicodeDecodeError.""" + mock_subprocess_run.return_value = _git_failure( + b"fatal: repositorio no encontrado \xe9\xff\n" + ) + + with pytest.raises(GitCommandError, match="repositorio no encontrado"): + git.run_git_command(["git", "fetch", "--", "origin"], network=True) + + assert mock_subprocess_run.call_count == 1 + + def test_run_git_command_without_git_dir(mock_subprocess_run: Mock) -> None: """Test that run_git_command works without git_dir (clone case).""" # Configure mock to return success @@ -677,10 +1018,10 @@ def test_clone_or_update_with_none_refresh_always_updates( "ambiguous argument 'HEAD': unknown revision or path not in the working tree.", ), ("stash", "fatal: unable to write new index file"), - ( - "fetch", - "fatal: unable to access 'https://github.com/test/repo/': Could not resolve host", - ), + # The fetch failure must be non-transient: a transient one (e.g. + # "Could not resolve host") now keeps the existing clone instead of + # triggering recovery. + ("fetch", "fatal: couldn't find remote ref main"), ("reset", "fatal: Could not reset index file to revision 'FETCH_HEAD'"), ], ) @@ -747,6 +1088,236 @@ def test_clone_or_update_recovers_from_git_failures( assert result_dir == repo_dir +@pytest.mark.parametrize("fetch_head_preexists", [True, False]) +def test_clone_or_update_transient_fetch_keeps_existing_clone( + tmp_path: Path, + mock_run_git_command: Mock, + caplog: pytest.LogCaptureFixture, + fetch_head_preexists: bool, +) -> None: + """A transient network failure while refreshing a verified clone falls back + to the existing clone instead of destroying it with a recovery re-clone.""" + 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) + if not fetch_head_preexists: + # First-ever refresh: age comes from HEAD, FETCH_HEAD absent + (repo_dir / ".git" / "FETCH_HEAD").unlink() + head = repo_dir / ".git" / "HEAD" + head.write_text("test") + old_time = time.time() - 2 * 86400 + os.utime(head, (old_time, old_time)) + + 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": + return "abc123" + if cmd_type == "fetch": + # A failed fetch still freshens FETCH_HEAD, like real git + (repo_dir / ".git" / "FETCH_HEAD").touch() + stderr = ( + "fatal: unable to access " + "'https://user:hunter2@github.com/test/repo/': " + "Could not resolve host: github.com" + ) + raise GitCommandError(stderr, stderr=stderr) + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + refresh = TimePeriodSeconds(days=1) + with caplog.at_level(logging.WARNING, logger="esphome.git"): + result_dir, revert = git.clone_or_update( + url=url, + ref=ref, + refresh=refresh, + domain=domain, + ) + + # The existing clone is returned, not removed or re-cloned + assert result_dir == repo_dir + assert repo_dir.is_dir() + assert revert is None + assert not any( + _get_git_command_type(c[0][0]) == "clone" + for c in mock_run_git_command.call_args_list + ) + # The completion marker must be restored, or the next run treats the + # entry as an incomplete clone and removes it + assert _marker_path(repo_dir).is_file() + # The warning must say what the build will actually use and how stale it is + assert "using the existing clone at abc123" in caplog.text + assert "ago" in caplog.text + # Credentials embedded in the URL must not reach the warning log + assert "hunter2" not in caplog.text + assert "://***@github.com/test/repo" in caplog.text + # The FETCH_HEAD the failed fetch freshened must not survive, or the + # refresh window would suppress retrying the update on subsequent runs + fetch_head = repo_dir / ".git" / "FETCH_HEAD" + if fetch_head_preexists: + assert time.time() - fetch_head.stat().st_mtime > refresh.total_seconds + else: + assert not fetch_head.exists() + + +def test_clone_or_update_timestamp_restore_failure_routes_to_recovery( + tmp_path: Path, mock_run_git_command: Mock, caplog: pytest.LogCaptureFixture +) -> None: + """If the FETCH_HEAD restore fails, the fallback cannot stay honest, so + the git error must route through recovery instead of a raw OSError.""" + 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) + + call_counts: dict[str, int] = {} + + 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: + call_counts[cmd_type] = call_counts.get(cmd_type, 0) + 1 + if cmd_type == "rev-parse": + return "abc123" + if cmd_type == "fetch" and call_counts[cmd_type] == 1: + stderr = ( + "fatal: unable to access 'https://github.com/test/repo/': " + "Could not resolve host: github.com" + ) + raise GitCommandError(stderr, stderr=stderr) + if cmd_type == "clone": + _simulate_cloned_repo(repo_dir) + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + refresh = TimePeriodSeconds(days=1) + with ( + patch("esphome.git.os.utime", side_effect=OSError("read-only")), + caplog.at_level(logging.WARNING, logger="esphome.git"), + ): + result_dir, _ = git.clone_or_update( + url=url, + ref=ref, + refresh=refresh, + domain=domain, + ) + + assert result_dir == repo_dir + assert "Could not restore the refresh timestamp" in caplog.text + # Recovery re-cloned rather than surfacing the OSError + assert call_counts.get("clone", 0) == 1 + + +@pytest.mark.parametrize( + "refresh", [None, TimePeriodSeconds(days=1)], ids=["clone", "refresh"] +) +def test_clone_or_update_network_commands_carry_retry_flag( + tmp_path: Path, mock_run_git_command: Mock, refresh: TimePeriodSeconds | None +) -> None: + """clone/fetch/submodule opt into transient-failure retry; local commands + (rev-parse, stash, reset) must not, so a refactor cannot silently drop or + widen the retry wiring.""" + 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) + + if refresh is None: + mock_run_git_command.side_effect = _make_clone_side_effect( + repo_dir, gitmodules=True + ) + else: + _setup_old_repo(repo_dir) + (repo_dir / ".gitmodules").write_text("test") + mock_run_git_command.return_value = "abc123" + + git.clone_or_update( + url=url, + ref=ref, + refresh=refresh, + domain=domain, + init_submodules=True, + ) + + seen: set[str] = set() + for call in mock_run_git_command.call_args_list: + cmd_type = _get_git_command_type(call.args[0]) + seen.add(cmd_type) + if cmd_type in ("clone", "fetch", "submodule"): + assert call.kwargs.get("network") is True, cmd_type + else: + assert "network" not in call.kwargs, cmd_type + if cmd_type == "clone": + assert call.kwargs.get("retry_cleanup") == repo_dir + + expected = {"fetch", "reset", "submodule"} + expected |= {"clone"} if refresh is None else {"rev-parse", "stash"} + assert expected <= seen + + +def test_clone_or_update_transient_submodule_failure_still_recovers( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A transient failure after the reset (submodules) leaves a half-updated + tree, so it must route through recovery instead of keeping the clone.""" + 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) + (repo_dir / ".gitmodules").write_text("test") + + call_counts: dict[str, int] = {} + + 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: + call_counts[cmd_type] = call_counts.get(cmd_type, 0) + 1 + if cmd_type == "rev-parse": + return "abc123" + if cmd_type == "submodule" and call_counts[cmd_type] == 1: + stderr = ( + "fatal: unable to access 'https://github.com/test/sub/': " + "Could not resolve host: github.com" + ) + raise GitCommandError(stderr, stderr=stderr) + if cmd_type == "clone": + _simulate_cloned_repo(repo_dir) + (repo_dir / ".gitmodules").write_text("test") + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + refresh = TimePeriodSeconds(days=1) + result_dir, _ = git.clone_or_update( + url=url, + ref=None, + refresh=refresh, + domain=domain, + init_submodules=True, + ) + + assert result_dir == repo_dir + # The half-updated tree must be recovered via re-clone, not kept + assert call_counts.get("clone", 0) == 1 + + def test_clone_or_update_fails_when_recovery_also_fails( tmp_path: Path, mock_run_git_command: Mock ) -> None: From aee41d64c2657ddec2c54ad5b66a6f4ce602edf9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 08:43:08 -0500 Subject: [PATCH 1392/1815] [rp2040_ble][bluetooth_connection] 3 connection slots on rp2 with esp32 parity (#18247) --- .../bluetooth_connection/__init__.py | 45 ++- .../bluetooth_connection_hub.cpp | 8 +- .../bluetooth_connection_hub.h | 5 +- .../bluetooth_connection_rp2.cpp | 324 +++++++++++++----- .../bluetooth_connection_rp2.h | 46 ++- .../components/bluetooth_proxy/__init__.py | 17 +- esphome/components/rp2040_ble/__init__.py | 68 +++- .../components/rp2040_ble/btstack_memory.cpp | 118 +++++++ esphome/core/defines.h | 6 +- .../bluetooth_proxy/test_platform_gates.py | 14 +- tests/component_tests/rp2040_ble/__init__.py | 0 .../rp2040_ble/config/rp2_proxy_default.yaml | 15 + .../config/rp2_proxy_single_slot.yaml | 16 + .../config/rp2_proxy_two_slots.yaml | 16 + .../rp2040_ble/test_connection_slots.py | 41 +++ .../rp2040_ble/test_pool_wrap.py | 52 +++ .../validate.rp2040-ard.yaml | 3 +- .../bluetooth_proxy/test.rp2040-ard.yaml | 4 +- .../bluetooth_proxy/test.rp2350-ard.yaml | 9 + .../build_components_base.rp2350-ard.yaml | 4 +- 20 files changed, 688 insertions(+), 123 deletions(-) create mode 100644 esphome/components/rp2040_ble/btstack_memory.cpp create mode 100644 tests/component_tests/rp2040_ble/__init__.py create mode 100644 tests/component_tests/rp2040_ble/config/rp2_proxy_default.yaml create mode 100644 tests/component_tests/rp2040_ble/config/rp2_proxy_single_slot.yaml create mode 100644 tests/component_tests/rp2040_ble/config/rp2_proxy_two_slots.yaml create mode 100644 tests/component_tests/rp2040_ble/test_connection_slots.py create mode 100644 tests/component_tests/rp2040_ble/test_pool_wrap.py create mode 100644 tests/components/bluetooth_proxy/test.rp2350-ard.yaml diff --git a/esphome/components/bluetooth_connection/__init__.py b/esphome/components/bluetooth_connection/__init__.py index ee46f85a38..fa1a86be3a 100644 --- a/esphome/components/bluetooth_connection/__init__.py +++ b/esphome/components/bluetooth_connection/__init__.py @@ -9,6 +9,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass import esphome.codegen as cg +from esphome.components import rp2040_ble from esphome.config_helpers import ( filter_source_files_from_platform, frameworks_for_platforms, @@ -36,9 +37,12 @@ CODEOWNERS = ["@bdraco", "@jesserockz"] bluetooth_connection_ns = cg.esphome_ns.namespace("bluetooth_connection") -# arduino-pico's prebuilt BTstack is compiled with MAX_NR_GATT_CLIENTS 1; -# raising this needs an upstream change (the layer itself supports N). -RP2_MAX_CONNECTIONS = 1 +# arduino-pico's prebuilt BTstack is compiled with MAX_NR_GATT_CLIENTS 1 and +# MAX_NR_HCI_CONNECTIONS 2; for more than one backend, rp2040_ble's +# btstack_memory.cpp replaces those pools via linker --wrap (requested by +# _rp2_register), sized from ESPHOME_BLE_GATT_CLIENT_COUNT. The cap itself +# belongs to the platform stack that owns the pools. +RP2_MAX_CONNECTIONS = rp2040_ble.MAX_CONNECTIONS # Slot limits for the hub platforms running the connection-capable proxy; # the backend registry itself is _PLATFORM_BACKENDS below. @@ -53,6 +57,19 @@ BluedroidGattClient = bluetooth_connection_ns.class_( CONF_BACKEND_ID = "backend_id" +DOMAIN = "bluetooth_connection" + + +@dataclass +class _ConnectionData: + rp2_backend_count: int = 0 + + +def _get_data() -> _ConnectionData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = _ConnectionData() + return CORE.data[DOMAIN] + def _esp32_schema_fragment() -> cv.Schema: from esphome.components import esp32_ble_tracker @@ -61,8 +78,6 @@ def _esp32_schema_fragment() -> cv.Schema: def _rp2_schema_fragment() -> cv.Schema: - from esphome.components import rp2040_ble - return cv.Schema( {cv.GenerateID(rp2040_ble.CONF_RP2040_BLE_ID): cv.use_id(rp2040_ble.RP2040BLE)} ) @@ -77,15 +92,29 @@ async def _esp32_register(backend: cg.MockObj, config: ConfigType) -> None: async def _rp2_register(backend: cg.MockObj, config: ConfigType) -> None: - from esphome.components import rp2040_ble + from esphome.components import ota + # The backend drops its link when an OTA starts (esp32 tracker parity). + ota.request_ota_state_listeners() + # More than one backend outgrows the prebuilt BTstack pools: swap them for + # the ESPHOME_BLE_GATT_CLIENT_COUNT-sized ones in rp2040_ble's + # btstack_memory.cpp. Keyed to backend registrations (the same event that + # grows the count that sizes the pools), so single-backend builds emit no + # flags and stay byte-identical to previous releases. + data = _get_data() + data.rp2_backend_count += 1 + if data.rp2_backend_count == 2: + rp2040_ble.add_btstack_pool_overrides() await cg.register_parented(backend, config[rp2040_ble.CONF_RP2040_BLE_ID]) @dataclass(frozen=True) class _PlatformBackend: - """One platform's backend: codegen class, extra schema keys (lazy so the - platform stack is only imported when targeted), and stack registration.""" + """One platform's backend: codegen class, extra schema keys, and stack + registration. The esp32 fragments import their stack lazily because those + imports register esp32-only automations as a side effect; rp2040_ble is + side-effect-free, so it is imported at module scope (the cap constant + needs it there anyway).""" backend_class: cg.MockObjClass schema_fragment: Callable[[], cv.Schema] diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index 0b5d996349..c43b2a6f7c 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -103,10 +103,10 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) { // The API client has the services cached; never discover them. No // discovery phase needs the fast interval, so settle straight into the - // shared steady-state parameters. On esp32 the backend already set the - // same values as prefer-params before opening, so this request is - // usually redundant there - kept because rp2 has no prefer-params and - // the explicit update is its only path to the steady-state interval. + // shared steady-state parameters. Both backends already open cached + // connections with these values (esp32 prefer-params, rp2 initiating + // params), so this request is normally redundant - kept as a backstop + // in case the initial parameters were negotiated away. this->state_ = ClientState::ESTABLISHED; int param_err = this->backend_->update_connection_params(ble_device_base::MEDIUM_MIN_CONN_INTERVAL, ble_device_base::MEDIUM_MAX_CONN_INTERVAL, 0, diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index 783a8c466b..d964af5530 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -77,8 +77,9 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { bool connected() const { return this->state_ == ClientState::ESTABLISHED; } void set_connection_type(ConnectionType ct) { this->connection_type_ = ct; - // The bluedroid backend branches on the type itself (prefer-params and - // the with-cache report at OPEN_EVT); the others ignore it. + // Both backends branch on the type before connecting (bluedroid picks + // prefer-params and the with-cache report at OPEN_EVT; rp2 picks the + // initiating parameters), so this must be set before the connect starts. this->backend_->set_connection_type(ct); } // Latched at discovery completion rather than read from the backend table: diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index dea3b5d9c8..dc8fb6714b 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -26,6 +26,13 @@ using ble_device_base::GATT_ERR_NO_MEMORY; // and keeps the scan inhibited, so the engine cancels after 20 s. The // disconnect timeout mirrors the esp32 CLOSE_EVT safety net. static constexpr uint32_t CONNECT_TIMEOUT_MS = 20000; +// Budget after a cancel is in flight: its completion normally lands within +// tens of ms, and while the engine waits it pins the stack-wide connect slot, +// so a lost completion must cost seconds, not another full connect budget. +static constexpr uint32_t CONNECT_CANCEL_TIMEOUT_MS = 2000; +// Pending engines re-attempt gap_connect on this cadence instead of every +// loop pass: the DISALLOWED path (teardown overlap) takes BluetoothLock. +static constexpr uint32_t CONNECT_RETRY_INTERVAL_MS = 50; // Can-send windows normally open within a connection interval (tens of ms). static constexpr uint32_t WRITE_NO_RSP_TIMEOUT_MS = 500; @@ -54,6 +61,7 @@ RP2GattClient *RP2GattClient::instances[ESPHOME_BLE_GATT_CLIENT_COUNT] = {}; uint8_t RP2GattClient::instance_count = 0; btstack_packet_callback_registration_t RP2GattClient::hci_event_registration = {}; btstack_packet_callback_registration_t RP2GattClient::sm_event_registration = {}; +RP2GattClient *RP2GattClient::connect_owner = nullptr; // NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) static ESPBTUUID uuid_from_btstack(uint16_t uuid16, const uint8_t uuid128[16]) { @@ -84,6 +92,7 @@ void RP2GattClient::setup() { // One locked section: the slot store lands before the count bump, and a // live HCI handler (N > 1 builds) cannot read a half-written registry. BluetoothLock lock; + this->engine_index_ = instance_count; instances[instance_count] = this; instance_count++; // One HCI event handler for all engine instances (BTstack supports @@ -96,9 +105,24 @@ void RP2GattClient::setup() { } } +#ifdef USE_OTA_STATE_LISTENER + ota::get_global_ota_callback()->add_global_state_listener(this); +#endif + this->disable_loop(); } +#ifdef USE_OTA_STATE_LISTENER +void RP2GattClient::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) { + // esp32 parity (its tracker disconnects every client at OTA start): free + // the shared radio for the transfer. No restore needed; the client + // reconnects, and on success the device reboots anyway. + if (state == ota::OTA_STARTED && this->state_ != EngineState::IDLE) { + this->gatt_disconnect(); + } +} +#endif + float RP2GattClient::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } void RP2GattClient::dump_config() { ESP_LOGCONFIG(TAG, "RP2 GATT client (BTstack)"); } @@ -124,34 +148,56 @@ void RP2GattClient::hci_packet_handler(uint8_t type, uint16_t channel, uint8_t * if (hci_event_gap_meta_get_subevent_code(packet) != GAP_SUBEVENT_LE_CONNECTION_COMPLETE) { break; } - bd_addr_t peer; - gap_subevent_le_connection_complete_get_peer_address(packet, peer); uint8_t status = gap_subevent_le_connection_complete_get_status(packet); hci_con_handle_t con_handle = gap_subevent_le_connection_complete_get_connection_handle(packet); - // Route to the engine that is waiting for this peer. - for (uint8_t i = 0; i < instance_count; i++) { - RP2GattClient *inst = instances[i]; - if (inst->state_ == EngineState::CONNECTING && memcmp(inst->peer_addr_, peer, sizeof(bd_addr_t)) == 0) { - inst->enqueue_event_irq_(RP2GattEvent::CONNECTED, status, con_handle); - break; + bd_addr_t peer; + gap_subevent_le_connection_complete_get_peer_address(packet, peer); + // Route by ownership, not address: gap_connect refuses a new + // create-connection until the previous completion is processed, so the + // event belongs to the owner by construction. Cancel completions carry + // a zeroed peer address on this controller, so an address match would + // drop them and pin the owner until its backstop. + RP2GattClient *inst = connect_owner; + static constexpr bd_addr_t ZERO_ADDR = {}; + if (inst != nullptr && memcmp(peer, ZERO_ADDR, sizeof(bd_addr_t)) != 0 && + memcmp(inst->peer_addr_, peer, sizeof(bd_addr_t)) != 0) { + // Addressed completion for a peer the owner is not connecting to: a + // success delayed past a cancel and an ownership handoff (the cancel + // idles the stack's request immediately) must not stamp the old + // procedure's link onto the new owner. Zero-address (cancel) + // completions need no such guard: BTstack only emits them while its + // request state is idle, and a new owner re-arms that state when it + // claims the token, so a stale cancel completion is swallowed by the + // stack, never re-attributed. A successful stale link still needs + // disposal (same hazard as the unowned branch below). + if (status == 0) { + gap_disconnect(con_handle); } + break; } + connect_owner = nullptr; + if (inst == nullptr) { + if (status == 0) { + // Nobody owns this late link (the owner escalated first): tear it + // down here or the hci_connection_t leaks and the peer answers + // DISALLOWED until reboot. + gap_disconnect(con_handle); + } + break; + } + if (status == 0) { + // Stamp the handle here in the BTstack context: a disconnection + // racing the queued CONNECTED event arrives in this same context + // and must route by handle (it carries no address). + inst->con_handle_ = con_handle; + } + inst->enqueue_event_irq_(RP2GattEvent::CONNECTED, status, con_handle); break; } case HCI_EVENT_DISCONNECTION_COMPLETE: { - hci_con_handle_t con_handle = hci_event_disconnection_complete_get_connection_handle(packet); - RP2GattClient *inst = instance_for_con_handle(con_handle); - if (inst == nullptr && instance_count == 1) { - // The main loop may not have recorded the handle yet (the CONNECTED - // event is still queued); with a single engine the connecting - // instance is unambiguous, so route there to close the - // accept-then-drop window. With multiple engines the event has no - // address to match on, so it must be dropped instead of guessed. - RP2GattClient *candidate = instances[0]; - if (candidate->con_handle_ == HCI_CON_HANDLE_INVALID && candidate->state_ != EngineState::IDLE) { - inst = candidate; - } - } + // Routable even against a still-queued CONNECTED event: the handle is + // stamped in this context at connection-complete time. + RP2GattClient *inst = instance_for_con_handle(hci_event_disconnection_complete_get_connection_handle(packet)); if (inst != nullptr) { inst->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, hci_event_disconnection_complete_get_reason(packet), 0); } @@ -393,44 +439,73 @@ void RP2GattClient::loop() { if (dropped > 0) { // Control events must not be lost; the connection state is no longer // trustworthy — recover with a forced teardown. - ESP_LOGE(TAG, "Dropped %u GATT control events, disconnecting", dropped); + ESP_LOGE(TAG, "[%u] Dropped %u GATT control events, disconnecting", this->engine_index_, dropped); this->gatt_disconnect(); } uint16_t notify_dropped = this->notify_queue_.get_and_reset_dropped_count(); if (notify_dropped > 0) { - ESP_LOGW(TAG, "Dropped %u GATT notifications (queue full)", notify_dropped); + ESP_LOGW(TAG, "[%u] Dropped %u GATT notifications (queue full)", this->engine_index_, notify_dropped); } - if (this->state_ == EngineState::CONNECTING || this->state_ == EngineState::MTU_EXCHANGE) { + if (this->state_ == EngineState::CONNECT_PENDING) { uint32_t now = millis(); if (now - this->connect_started_ > CONNECT_TIMEOUT_MS) { - ESP_LOGW(TAG, "Connect timeout"); - if (this->state_ == EngineState::CONNECTING && this->con_handle_ == HCI_CON_HANDLE_INVALID) { - if (!this->connect_cancel_attempted_) { - this->connect_cancel_attempted_ = true; - BluetoothLock lock; + // Never reached the radio; nothing stack-side to cancel. + ESP_LOGW(TAG, "[%u] Connect timeout (queued)", this->engine_index_); + this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT); + } else if (now - this->connect_retry_ms_ >= CONNECT_RETRY_INTERVAL_MS) { + this->connect_retry_ms_ = now; + if (int err = this->try_gap_connect_(); err != 0) { + this->fail_connection_(static_cast(err)); + } + } + } else if (this->state_ == EngineState::CONNECTING || this->state_ == EngineState::MTU_EXCHANGE) { + uint32_t now = millis(); + bool cancel_in_flight = this->state_ == EngineState::CONNECTING && this->con_handle_ == HCI_CON_HANDLE_INVALID && + this->connect_cancel_attempted_; + uint32_t budget = cancel_in_flight ? CONNECT_CANCEL_TIMEOUT_MS : CONNECT_TIMEOUT_MS; + if (now - this->connect_started_ > budget) { + ESP_LOGW(TAG, "[%u] Connect timeout", this->engine_index_); + bool link_up = this->state_ != EngineState::CONNECTING; + bool cancel_sent = false; + if (!link_up) { + BluetoothLock lock; + // Handle check under the lock: a success completion can stamp it in + // the BTstack context right up to this point, and escalating past a + // live link would orphan it (the queued CONNECTED event is dropped + // by the state guard once fail_connection_ runs). + link_up = this->con_handle_ != HCI_CON_HANDLE_INVALID; + if (!link_up && connect_owner == this) { + // gap_connect_cancel is stack-global; only the engine whose + // create-connection is in flight may issue it. First timeout: + // cancel and give the completion a grace period. Second: the + // completion was lost, re-issue the cancel in case the procedure + // still runs (a no-op on an idle stack), then escalate. gap_connect_cancel(); - // The cancel produces a connection-complete event with a failure - // status, which drives the normal failure path; restart the timer - // so a lost event escalates below instead of wedging here. - this->connect_started_ = now; - } else { - // The cancel's completion never arrived: reclaim the slot and the - // scan rather than cancelling forever. - this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT); + cancel_sent = !this->connect_cancel_attempted_; } - } else { - // The link is up (MTU exchange stalled): tear it down properly so the - // controller frees its side; the DISCONNECTING safety net below - // reclaims state if the disconnection event is lost. Dropping engine - // state without gap_disconnect would leak the live link and the - // single GATT slot for the rest of the boot. + this->connect_cancel_attempted_ = true; + } + if (link_up) { + // The link is up (stamped mid-timeout or MTU exchange stalled): tear + // it down properly so the controller frees its side; the + // DISCONNECTING safety net below reclaims state if the disconnection + // event is lost. Dropping engine state without gap_disconnect would + // leak the live link and this engine's GATT slot for the rest of the + // boot. this->gatt_disconnect(); + } else if (cancel_sent) { + // The cancel produces a connection-complete event with a failure + // status, which drives the normal failure path; restart the timer so + // a lost event escalates on the short cancel budget. + this->connect_started_ = now; + } else { + this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT); } } } else if (this->state_ == EngineState::DISCONNECTING) { if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) { - ESP_LOGW(TAG, "Disconnect timeout, forcing idle"); + ESP_LOGW(TAG, "[%u] Disconnect timeout, forcing idle", this->engine_index_); this->handle_disconnected_(HCI_REASON_CONNECTION_TIMEOUT); } } else if (this->state_ == EngineState::READY && this->op_type_ == OpType::WRITE_CHAR_NO_RSP && @@ -446,7 +521,7 @@ void RP2GattClient::loop() { } } if (timed_out) { - ESP_LOGW(TAG, "Deferred write timeout, handle=0x%04x", this->op_handle_); + ESP_LOGW(TAG, "[%u] Deferred write timeout, handle=0x%04x", this->engine_index_, this->op_handle_); this->listener_->on_write_result(this->op_handle_, GATT_CLIENT_BUSY); } } else if (this->state_ == EngineState::IDLE || (this->state_ == EngineState::READY && !this->op_in_flight_() && @@ -467,7 +542,7 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) { case RP2GattEvent::MTU_EXCHANGED: if (this->state_ == EngineState::MTU_EXCHANGE) { this->mtu_ = event.value; - ESP_LOGD(TAG, "MTU %u", this->mtu_); + ESP_LOGD(TAG, "[%u] MTU %u", this->engine_index_, this->mtu_); this->state_ = EngineState::READY; // Scanning resumes and runs alongside the established connection. this->release_scan_inhibit_(); @@ -515,7 +590,7 @@ void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) { return; } if (status != 0) { - ESP_LOGW(TAG, "Connect failed, status=0x%02x", status); + ESP_LOGW(TAG, "[%u] Connect failed, status=0x%02x", this->engine_index_, status); this->fail_connection_(status); return; } @@ -539,7 +614,7 @@ void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) { } this->con_handle_ = con_handle; this->state_ = EngineState::MTU_EXCHANGE; - ESP_LOGD(TAG, "Link up, handle=0x%04x, negotiating MTU", con_handle); + ESP_LOGD(TAG, "[%u] Link up, handle=0x%04x, negotiating MTU", this->engine_index_, con_handle); BluetoothLock lock; // One wildcard listener covers notifications/indications for every // characteristic on this connection; the CCCD writes come from the API @@ -564,6 +639,24 @@ void RP2GattClient::release_scan_inhibit_() { } void RP2GattClient::fail_connection_(uint8_t reason) { + { + // Timeout escalation can fire with the completion event lost; release the + // stack-wide connect slot so pending engines can proceed. Until the old + // completion is processed, gap_connect answers any peer with DISALLOWED + // (the request-level guard in hci.c); a cancel idles that request + // immediately, and a late addressed completion from the old procedure is + // then dropped by the owner-peer cross-check in the handler. + BluetoothLock lock; + if (connect_owner == this) { + connect_owner = nullptr; + } + if (this->state_ == EngineState::CONNECTING && this->con_handle_ != HCI_CON_HANDLE_INVALID) { + // A success completion stamped the handle between the escalation + // decision and this lock: tear the link down before cleanup wipes the + // handle, or it leaks its pool block for the rest of the boot. + gap_disconnect(this->con_handle_); + } + } this->cleanup_link_state_(); this->release_scan_inhibit_(); this->state_ = EngineState::IDLE; @@ -577,14 +670,19 @@ void RP2GattClient::cleanup_link_state_() { while ((stale = this->notify_queue_.pop()) != nullptr) { this->notify_pool_.release(stale); } - // The wildcard listener is registered on the normal connect path right - // after con_handle_ is recorded; the cancel branch tears down before - // registering, where stop_listening on an unregistered entry is a no-op. - if (this->con_handle_ != HCI_CON_HANDLE_INVALID) { + // con_handle_ may be stamped in the BTstack context before the main loop + // registers the listener, so a valid handle does not imply a registration; + // stop_listening on an unregistered entry is a benign no-op. One lock + // scope around check and reset so an IRQ stamp cannot land in between + // (unreachable today — ownership is released before cleanup — but the + // invariant lives three functions away). + { BluetoothLock lock; - gatt_client_stop_listening_for_characteristic_value_updates(&this->notification_registration_); + if (this->con_handle_ != HCI_CON_HANDLE_INVALID) { + gatt_client_stop_listening_for_characteristic_value_updates(&this->notification_registration_); + } + this->con_handle_ = HCI_CON_HANDLE_INVALID; } - this->con_handle_ = HCI_CON_HANDLE_INVALID; this->notify_subscription_count_ = 0; this->cancel_requested_ = false; this->op_type_ = OpType::NONE; @@ -596,7 +694,7 @@ void RP2GattClient::handle_disconnected_(uint8_t reason) { if (this->state_ == EngineState::IDLE) { return; } - ESP_LOGD(TAG, "Disconnected, reason=0x%02x", reason); + ESP_LOGD(TAG, "[%u] Disconnected, reason=0x%02x", this->engine_index_, reason); this->fail_connection_(reason); } @@ -654,7 +752,7 @@ int RP2GattClient::discover_services() { RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); this->arena_ = allocator.allocate(1); if (this->arena_ == nullptr) { - ESP_LOGE(TAG, "Service table allocation failed"); + ESP_LOGE(TAG, "[%u] Service table allocation failed", this->engine_index_); return ble_device_base::GATT_ERR_NO_MEMORY; } new (this->arena_) ServiceArena(); @@ -760,8 +858,8 @@ void RP2GattClient::advance_discovery_(uint8_t att_status) { void RP2GattClient::finish_discovery_(int error) { this->discovery_phase_ = DiscoveryPhase::NONE; - ESP_LOGD(TAG, "Discovery done (err=%d): %u services, %u characteristics, %u descriptors", error, this->service_count_, - this->char_count_, this->desc_count_); + ESP_LOGD(TAG, "[%u] Discovery done (err=%d): %u services, %u characteristics, %u descriptors", this->engine_index_, + error, this->service_count_, this->char_count_, this->desc_count_); if (error == 0 && this->truncated_) { // A partial table must not stream: V3 clients cache the database // permanently, so an incomplete one would be wrong forever. @@ -839,22 +937,68 @@ int RP2GattClient::connect(uint64_t address, uint8_t addr_type) { this->parent_->inhibit_scan(); this->connect_cancel_attempted_ = false; this->cancel_requested_ = false; + // Bounds the queued wait; restarted when gap_connect is accepted so the + // radio attempt gets its full budget (HA's own ~20 s timeout arbitrates the + // sum via a disconnect request). + this->connect_started_ = millis(); + if (int err = this->try_gap_connect_(); err != 0) { + this->release_scan_inhibit_(); + return err; + } + this->enable_loop(); + return 0; +} + +// One outgoing LE create-connection exists stack-wide: issue it if no other +// engine owns it, otherwise park in CONNECT_PENDING for loop() to retry. +// Returns nonzero only for hard failures (state untouched; caller cleans up). +int RP2GattClient::try_gap_connect_() { + // Unlocked peek: single core, aligned pointer; a stale value costs one loop + // pass and the locked re-check below is authoritative. Keeps the per-loop + // pending retry from taking BluetoothLock just to find the radio busy. + if (connect_owner != nullptr) { + this->state_ = EngineState::CONNECT_PENDING; + return 0; + } uint8_t status; { BluetoothLock lock; - gap_set_connection_parameters(CONN_SCAN_INTERVAL, CONN_SCAN_WINDOW, FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL, - 0, FAST_CONN_TIMEOUT, CONN_CE_MIN, CONN_CE_MAX); - status = gap_connect(this->peer_addr_, this->peer_addr_type_); + if (connect_owner != nullptr) { + status = ERROR_CODE_COMMAND_DISALLOWED; + } else { + // esp32 parity: cached connections come up at MEDIUM already (nothing + // consumes the fast interval without a discovery phase), so there is no + // post-connect update procedure to race or silently lose; sustained + // FAST intervals also starve WiFi on the shared CYW43 radio. + // Without-cache runs FAST for discovery and steps down in + // finish_discovery_. + bool cached = this->connection_type_ == ble_device_base::ConnectionType::V3_WITH_CACHE; + gap_set_connection_parameters(CONN_SCAN_INTERVAL, CONN_SCAN_WINDOW, + cached ? MEDIUM_MIN_CONN_INTERVAL : FAST_MIN_CONN_INTERVAL, + cached ? MEDIUM_MAX_CONN_INTERVAL : FAST_MAX_CONN_INTERVAL, 0, + cached ? MEDIUM_CONN_TIMEOUT : FAST_CONN_TIMEOUT, CONN_CE_MIN, CONN_CE_MAX); + status = gap_connect(this->peer_addr_, this->peer_addr_type_); + if (status == 0) { + connect_owner = this; + // Still under the lock: a synthesized failure completion can fire in + // the BTstack context the instant it releases, and completion routing + // requires CONNECTING — set after the fact, the event is discarded + // and the engine burns its whole budget waiting for it. + this->state_ = EngineState::CONNECTING; + this->connect_started_ = millis(); + } + } } - if (status != 0) { - ESP_LOGW(TAG, "gap_connect failed, status=0x%02x", status); - this->release_scan_inhibit_(); - return status; + if (status == 0) { + return 0; } - this->state_ = EngineState::CONNECTING; - this->connect_started_ = millis(); - this->enable_loop(); - return 0; + if (status == ERROR_CODE_COMMAND_DISALLOWED) { + // Radio busy with another engine's connect; resolved from loop(). + this->state_ = EngineState::CONNECT_PENDING; + return 0; + } + ESP_LOGW(TAG, "[%u] gap_connect failed, status=0x%02x", this->engine_index_, status); + return status; } int RP2GattClient::gatt_disconnect() { @@ -863,6 +1007,10 @@ int RP2GattClient::gatt_disconnect() { return GATT_ERR_NOT_CONNECTED; case EngineState::DISCONNECTING: return 0; // already on its way down + case EngineState::CONNECT_PENDING: + // Nothing issued stack-side; the invalid handle takes the refused + // path below without touching the stack. + break; case EngineState::CONNECTING: { if (this->con_handle_ == HCI_CON_HANDLE_INVALID) { // The cancel can lose the race against a successful connection @@ -871,9 +1019,18 @@ int RP2GattClient::gatt_disconnect() { // attempt, so a lost completion escalates on the next timeout tick. this->cancel_requested_ = true; this->connect_cancel_attempted_ = true; + // Grace period for the cancel completion: the client's disconnect + // often lands right at the engine's own deadline, and without the + // restart the loop timeout fires first and reports before the + // completion can finish the teardown cleanly. + this->connect_started_ = millis(); BluetoothLock lock; - gap_connect_cancel(); - // Completion arrives as a failed connection-complete event. + // Owner: the cancel completes as a failed connection-complete. Not + // the owner (completion already resolved in the BTstack context): the + // queued event drives the same teardown, nothing to cancel. + if (connect_owner == this) { + gap_connect_cancel(); + } return 0; } break; @@ -881,20 +1038,23 @@ int RP2GattClient::gatt_disconnect() { default: break; } - uint8_t status; - { - BluetoothLock lock; - status = gap_disconnect(this->con_handle_); - } - if (status != 0) { - // Refused (handle already gone): complete via the event queue so the - // listener cannot re-enter disconnect() mid-call. BluetoothLock stops - // the IRQ producer, so this main-loop push is SPSC-safe. - ESP_LOGW(TAG, "gap_disconnect failed, status=0x%02x", status); + uint8_t status = ERROR_CODE_UNKNOWN_CONNECTION_IDENTIFIER; + if (this->con_handle_ != HCI_CON_HANDLE_INVALID) { { BluetoothLock lock; - this->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, HCI_REASON_CONNECTION_TIMEOUT, 0); + status = gap_disconnect(this->con_handle_); } + if (status != 0) { + ESP_LOGW(TAG, "[%u] gap_disconnect failed, status=0x%02x", this->engine_index_, status); + } + } + if (status != 0) { + // Refused (handle already gone) or never issued (CONNECT_PENDING): + // complete via the event queue so the listener cannot re-enter + // disconnect mid-call. BluetoothLock stops the IRQ producer, so this + // main-loop push is SPSC-safe. + BluetoothLock lock; + this->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, HCI_REASON_CONNECTION_TIMEOUT, 0); } this->state_ = EngineState::DISCONNECTING; this->disconnecting_started_ = millis(); diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h index df43ebd66d..4d407269b6 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h @@ -19,6 +19,10 @@ #include "esphome/core/helpers.h" #include "esphome/core/lock_free_queue.h" +#ifdef USE_OTA_STATE_LISTENER +#include "esphome/components/ota/ota_backend.h" +#endif + #include #include @@ -71,7 +75,13 @@ static constexpr uint8_t RP2_GATT_EVENT_QUEUE_SIZE = 8; // full 512 B ATT payload, so depth buys burst tolerance at ~516 B per slot. static constexpr uint8_t RP2_GATT_NOTIFY_QUEUE_SIZE = 4; -class RP2GattClient final : public Component, public Parented { +class RP2GattClient final : public Component, + public Parented +#ifdef USE_OTA_STATE_LISTENER + , + public ota::OTAGlobalStateListener +#endif +{ public: void setup() override; void loop() override; @@ -95,18 +105,26 @@ class RP2GattClient final : public Component, public Parentedconnection_type_ = ct; } void release_services(); +#ifdef USE_OTA_STATE_LISTENER + // Drop the connection while an OTA runs (esp32 parity): an active link + // competes with the transfer for the shared radio. + void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override; +#endif + protected: // Link/engine state. Discovery and GATT ops have their own cursors below — // the link stays READY while they run. enum class EngineState : uint8_t { IDLE, - CONNECTING, // gap_connect issued, waiting for connection complete - MTU_EXCHANGE, // link up, waiting for GATT_EVENT_MTU - READY, // on_connection_state(true) delivered + CONNECT_PENDING, // queued: another engine owns the stack-wide create-connection + CONNECTING, // gap_connect issued, waiting for connection complete + MTU_EXCHANGE, // link up, waiting for GATT_EVENT_MTU + READY, // on_connection_state(true) delivered DISCONNECTING, }; @@ -143,6 +161,7 @@ class RP2GattClient final : public Component, public Parented notify_subscriptions_{}; uint8_t notify_subscription_count_{0}; - bd_addr_t peer_addr_{}; // MSB-first, as gap_connect expects - bd_addr_type_t peer_addr_type_{BD_ADDR_TYPE_LE_PUBLIC}; + uint8_t engine_index_{0}; // position in instances[]; tags log lines per slot + bd_addr_t peer_addr_{}; // MSB-first, as gap_connect expects + ble_device_base::ConnectionType connection_type_{ble_device_base::ConnectionType::V3_WITHOUT_CACHE}; EngineState state_{EngineState::IDLE}; DiscoveryPhase discovery_phase_{DiscoveryPhase::NONE}; OpType op_type_{OpType::NONE}; @@ -214,6 +238,12 @@ class RP2GattClient final : public Component, public Parented ConfigType: @functools.cache def _rp2_config_schema() -> cv.All: """Full proxy on the rp2 BLE hub: active connections through the BTstack - GATT client backend in bluetooth_connection. The slot limit comes from the - prebuilt BTstack library (one connection today); the code is built for N.""" + GATT client backend in bluetooth_connection. Multi-slot builds replace the + prebuilt library's one-client BTstack pools via linker --wrap, owned by + rp2040_ble and requested when a second backend registers.""" connection_schema = bluetooth_connection.hub_connection_schema(PLATFORM_RP2) def populate_connections(config: ConfigType) -> ConfigType: + from esphome.components import rp2040_ble + # One wrapper + backend pair per slot, declared during validation so # their ids exist for codegen (the esp32 arm's `connections` pattern). if not config[CONF_ACTIVE]: return config + connection_slots: int = config[CONF_CONNECTION_SLOTS] + rp2040_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(config) return { **config, - CONF_CONNECTIONS: [ - connection_schema({}) for _ in range(config[CONF_CONNECTION_SLOTS]) - ], + CONF_CONNECTIONS: [connection_schema({}) for _ in range(connection_slots)], } max_conn = bluetooth_connection.HUB_MAX_CONNECTIONS[PLATFORM_RP2] @@ -182,8 +185,8 @@ def _rp2_config_schema() -> cv.All: min=1, max=max_conn, msg=f"rp2 supports at most {max_conn} connection slot(s); " - "the framework's BTstack library is built with " - f"MAX_NR_GATT_CLIENTS {max_conn}", + "the BTstack pool overrides in rp2040_ble are sized " + f"for {max_conn}", ), ), } diff --git a/esphome/components/rp2040_ble/__init__.py b/esphome/components/rp2040_ble/__init__.py index e49dceb000..332ea73a61 100644 --- a/esphome/components/rp2040_ble/__init__.py +++ b/esphome/components/rp2040_ble/__init__.py @@ -1,6 +1,9 @@ +from collections.abc import Callable, MutableMapping + import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID +from esphome.core import CORE from esphome.types import ConfigType DEPENDENCIES = ["rp2"] @@ -8,6 +11,15 @@ CODEOWNERS = ["@bdraco"] CONF_RP2040_BLE_ID = "rp2040_ble_id" +KEY_RP2040_BLE = "rp2040_ble" +KEY_USED_CONNECTION_SLOTS = "used_connection_slots" + +# Hard platform cap on concurrent GATT connections: the BTstack pool overrides +# in btstack_memory.cpp are sized from ESPHOME_BLE_GATT_CLIENT_COUNT with this +# as the ceiling. 3 matches the esp32 default and stays within the +# controller's resources (MAX_NR_CONTROLLER_ACL_BUFFERS 3). +MAX_CONNECTIONS = 3 + rp2040_ble_ns = cg.esphome_ns.namespace("rp2040_ble") RP2040BLE = rp2040_ble_ns.class_("RP2040BLE", cg.Component) @@ -30,13 +42,67 @@ def _validate_board(config: ConfigType) -> ConfigType: return config -FINAL_VALIDATE_SCHEMA = _validate_board +def consume_connection_slots( + value: int, consumer: str +) -> Callable[[MutableMapping], MutableMapping]: + """Reserve BLE connection slots for a component (the esp32_ble pattern); + the total is checked against MAX_CONNECTIONS in final validation.""" + + def _consume_connection_slots(config: MutableMapping) -> MutableMapping: + data: dict = CORE.data.setdefault(KEY_RP2040_BLE, {}) + slots: list[str] = data.setdefault(KEY_USED_CONNECTION_SLOTS, []) + slots.extend([consumer] * value) + return config + + return _consume_connection_slots + + +def validate_connection_slots() -> None: + """Fail when consumers claimed more slots than the platform cap.""" + # Skip in testing mode to allow component grouping (esp32_ble parity). + if CORE.testing_mode: + return + used = CORE.data.get(KEY_RP2040_BLE, {}).get(KEY_USED_CONNECTION_SLOTS, []) + if len(used) > MAX_CONNECTIONS: + raise cv.Invalid( + f"BLE components require {len(used)} connection slots but the " + f"rp2 maximum is {MAX_CONNECTIONS}. " + f"Components: {', '.join(used)}" + ) + + +def _final_validate(config: ConfigType) -> ConfigType: + _validate_board(config) + validate_connection_slots() + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate # Once per registered scan listener; sizes the controller's StaticVector # listener storage. request_scan_listener_slot = cg.slot_counter("RP2040_BLE_SCAN_LISTENER_COUNT") +# The four btstack_memory accessors whose static pools are baked into the +# prebuilt liblwip-bt.a; every internal use crosses an object boundary in the +# archive, so --wrap intercepts them all (see btstack_memory.cpp). +_BTSTACK_POOL_SYMBOLS = ( + "btstack_memory_gatt_client_get", + "btstack_memory_gatt_client_free", + "btstack_memory_hci_connection_get", + "btstack_memory_hci_connection_free", +) + + +def add_btstack_pool_overrides() -> None: + """Emit the --wrap flags that swap the prebuilt BTstack pools for the + ESPHOME_BLE_GATT_CLIENT_COUNT-sized ones in btstack_memory.cpp. Called by + bluetooth_connection when a second GATT backend registers; idempotent + (build flags are a set).""" + for symbol in _BTSTACK_POOL_SYMBOLS: + cg.add_build_flag(f"-Wl,--wrap={symbol}") + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/rp2040_ble/btstack_memory.cpp b/esphome/components/rp2040_ble/btstack_memory.cpp new file mode 100644 index 0000000000..8af57924a2 --- /dev/null +++ b/esphome/components/rp2040_ble/btstack_memory.cpp @@ -0,0 +1,118 @@ +// Replaces the gatt_client / hci_connection static pools baked into +// arduino-pico's prebuilt liblwip-bt.a (built with MAX_NR_GATT_CLIENTS 1, +// MAX_NR_HCI_CONNECTIONS 2) with pools sized from ESPHOME_BLE_GATT_CLIENT_COUNT. +// add_btstack_pool_overrides() in this component's codegen emits the matching +// -Wl,--wrap flags, requested by bluetooth_connection when more than one GATT +// backend registers; single-backend builds emit no flags and this file +// compiles to nothing, leaving the prebuilt pools in charge. Layout safety: +// the framework defines ENABLE_CLASSIC / ENABLE_BLE for every user TU +// whenever PIO_FRAMEWORK_ARDUINO_ENABLE_BLUETOOTH is set (this component +// always sets it), so sizeof() here matches the archive. + +#include "esphome/core/defines.h" + +#if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) && (ESPHOME_BLE_GATT_CLIENT_COUNT > 1) + +#include + +#include + +namespace esphome::rp2040_ble { +namespace { + +// Pinned against arduino-pico 6.0.0's prebuilt archives: a framework bump (or +// a changed ENABLE_* macro) shifting the struct layout must fail the build +// here, not overrun the pool blocks at runtime. Sizes differ per core +// architecture (measured from each archive's own storage symbols). GCC only: +// the clang-tidy frontend lays these structs out differently, and the guard +// targets the real link. +#ifndef __clang__ +#ifdef __riscv +static_assert(sizeof(gatt_client_t) == 140 && sizeof(hci_connection_t) == 3740, "BTstack layout changed"); +#else +static_assert(sizeof(gatt_client_t) == 128 && sizeof(hci_connection_t) == 3688, "BTstack layout changed"); +#endif +#endif // __clang__ + +// One gatt_client_t per configured connection slot. An hci_connection_t is +// held from gap_connect() to DISCONNECTION_COMPLETE (scanning holds none); +// +1 mirrors the prebuilt library's own headroom (2 connections for 1 GATT +// client) so a teardown/re-connect overlap can never starve a slot. +constexpr int HCI_CONNECTION_POOL_SIZE = ESPHOME_BLE_GATT_CLIENT_COUNT + 1; + +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables,cert-err58-cpp) +gatt_client_t gatt_client_storage[ESPHOME_BLE_GATT_CLIENT_COUNT]; +btstack_memory_pool_t gatt_client_pool; +hci_connection_t hci_connection_storage[HCI_CONNECTION_POOL_SIZE]; +btstack_memory_pool_t hci_connection_pool; + +// Static init: pool_create only links a free list through its own storage, +// and BTstack first allocates long after static construction. +struct PoolInit { + PoolInit() { + btstack_memory_pool_create(&gatt_client_pool, gatt_client_storage, ESPHOME_BLE_GATT_CLIENT_COUNT, + sizeof(gatt_client_t)); + btstack_memory_pool_create(&hci_connection_pool, hci_connection_storage, HCI_CONNECTION_POOL_SIZE, + sizeof(hci_connection_t)); + } +} pool_init; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables,cert-err58-cpp) + +} // namespace + +// Exact semantics of btstack_memory.c's static-pool arm: zeroed block on +// success, NULL when exhausted; free returns the block to the pool. The +// prebuilt pools stay resident in .bss (~7.4 KB, kept live by +// btstack_memory_init in the archive) — dead weight here, not a leak. +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" gatt_client_t *__real_btstack_memory_gatt_client_get(void); +extern "C" void __real_btstack_memory_gatt_client_free(gatt_client_t *gatt_client); +extern "C" hci_connection_t *__real_btstack_memory_hci_connection_get(void); +extern "C" void __real_btstack_memory_hci_connection_free(hci_connection_t *hci_connection); + +namespace { +// Fails the link if the corresponding --wrap flag is missing: __real_* only +// exists while --wrap is in effect, and each wrap function anchors its own +// symbol so dropping any single flag fails loudly. A code reference is used +// because the framework links with --gc-sections, which discards an +// unreferenced data anchor regardless of [[gnu::used]] (and this toolchain +// does not emit SHF_GNU_RETAIN for [[gnu::retain]]). +template void anchor_wrap(T *symbol) { asm volatile("" ::"r"(symbol)); } +} // namespace + +extern "C" { + +gatt_client_t *__wrap_btstack_memory_gatt_client_get(void) { + anchor_wrap(&__real_btstack_memory_gatt_client_get); + void *buffer = btstack_memory_pool_get(&gatt_client_pool); + if (buffer != nullptr) { + memset(buffer, 0, sizeof(gatt_client_t)); + } + return static_cast(buffer); +} + +void __wrap_btstack_memory_gatt_client_free(gatt_client_t *gatt_client) { + anchor_wrap(&__real_btstack_memory_gatt_client_free); + btstack_memory_pool_free(&gatt_client_pool, gatt_client); +} + +hci_connection_t *__wrap_btstack_memory_hci_connection_get(void) { + anchor_wrap(&__real_btstack_memory_hci_connection_get); + void *buffer = btstack_memory_pool_get(&hci_connection_pool); + if (buffer != nullptr) { + memset(buffer, 0, sizeof(hci_connection_t)); + } + return static_cast(buffer); +} + +void __wrap_btstack_memory_hci_connection_free(hci_connection_t *hci_connection) { + anchor_wrap(&__real_btstack_memory_hci_connection_free); + btstack_memory_pool_free(&hci_connection_pool, hci_connection); +} + +} // extern "C" +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +} // namespace esphome::rp2040_ble + +#endif // USE_RP2040_BLE && USE_BLE_GATT_CLIENT && ESPHOME_BLE_GATT_CLIENT_COUNT > 1 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 319018a36f..21cea31749 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -262,13 +262,13 @@ #define USE_BLUETOOTH_PROXY // Mirror the codegen values per platform: _to_code_esp32() emits the connection // count (default 3) and the scanner-state push slot, _to_code_ble_hub() emits -// the slot count (1 on rp2, 0 on advertisement-only hubs) — so static analysis +// the slot count (3 on rp2, 0 on advertisement-only hubs) — so static analysis // checks the same instantiations a real build produces. #ifdef USE_ESP32 #define USE_BLE_SCANNER_STATE_CALLBACK #define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 #elif defined(USE_RP2) -#define BLUETOOTH_PROXY_MAX_CONNECTIONS 1 +#define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 #else #define BLUETOOTH_PROXY_MAX_CONNECTIONS 0 #endif @@ -482,7 +482,7 @@ #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 #define USE_BLE_SCAN_RESPONSE_MERGER #define USE_BLE_GATT_CLIENT -#define ESPHOME_BLE_GATT_CLIENT_COUNT 1 +#define ESPHOME_BLE_GATT_CLIENT_COUNT 3 #define USE_RP2040_VARIANT_RP2040 #define USE_SPI #ifndef USE_ETHERNET diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py index 16a3850d46..8fc7ffd23b 100644 --- a/tests/component_tests/bluetooth_proxy/test_platform_gates.py +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -142,8 +142,8 @@ def test_rp2_defaults_to_the_full_proxy( _register_tracker(PLATFORM_RP2) validated = bluetooth_proxy.CONFIG_SCHEMA({}) assert validated[CONF_ACTIVE] is True - assert validated[bluetooth_proxy.CONF_CONNECTION_SLOTS] == 1 - assert len(validated[bluetooth_proxy.CONF_CONNECTIONS]) == 1 + assert validated[bluetooth_proxy.CONF_CONNECTION_SLOTS] == 3 + assert len(validated[bluetooth_proxy.CONF_CONNECTIONS]) == 3 def test_rp2_accepts_explicit_passive( @@ -159,11 +159,15 @@ def test_rp2_accepts_explicit_passive( def test_rp2_rejects_slots_beyond_the_btstack_limit( set_core_config: SetCoreConfigCallable, ) -> None: - # The prebuilt BTstack library allows exactly one GATT client connection. + # The BTstack pool overrides are sized for RP2_MAX_CONNECTIONS slots. set_core_config(PlatformFramework.RP2_ARDUINO) _register_tracker(PLATFORM_RP2) - with pytest.raises(cv.Invalid, match="at most 1 connection slot"): - bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 2}) + with pytest.raises(cv.Invalid, match="at most 3 connection slot"): + bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 4}) + # Fewer slots than the cap stay accepted (the prebuilt single-client pool + # path for 1, the wrap path for 2). + validated = bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 1}) + assert len(validated[bluetooth_proxy.CONF_CONNECTIONS]) == 1 # Values past even the loosest platform cap stop at the outer walkable # schema, which stays bounded for range walkers (device-builder sync); # in-range values get the platform message above. diff --git a/tests/component_tests/rp2040_ble/__init__.py b/tests/component_tests/rp2040_ble/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/rp2040_ble/config/rp2_proxy_default.yaml b/tests/component_tests/rp2040_ble/config/rp2_proxy_default.yaml new file mode 100644 index 0000000000..93c769283f --- /dev/null +++ b/tests/component_tests/rp2040_ble/config/rp2_proxy_default.yaml @@ -0,0 +1,15 @@ +esphome: + name: poolwrap-rp2-default + +rp2: + board: rpipicow + +wifi: + ssid: MySSID + password: password1 + +api: + +rp2_ble_tracker: + +bluetooth_proxy: diff --git a/tests/component_tests/rp2040_ble/config/rp2_proxy_single_slot.yaml b/tests/component_tests/rp2040_ble/config/rp2_proxy_single_slot.yaml new file mode 100644 index 0000000000..4e9c94df59 --- /dev/null +++ b/tests/component_tests/rp2040_ble/config/rp2_proxy_single_slot.yaml @@ -0,0 +1,16 @@ +esphome: + name: poolwrap-rp2-single + +rp2: + board: rpipicow + +wifi: + ssid: MySSID + password: password1 + +api: + +rp2_ble_tracker: + +bluetooth_proxy: + connection_slots: 1 diff --git a/tests/component_tests/rp2040_ble/config/rp2_proxy_two_slots.yaml b/tests/component_tests/rp2040_ble/config/rp2_proxy_two_slots.yaml new file mode 100644 index 0000000000..c631562743 --- /dev/null +++ b/tests/component_tests/rp2040_ble/config/rp2_proxy_two_slots.yaml @@ -0,0 +1,16 @@ +esphome: + name: poolwrap-rp2-two + +rp2: + board: rpipicow + +wifi: + ssid: MySSID + password: password1 + +api: + +rp2_ble_tracker: + +bluetooth_proxy: + connection_slots: 2 diff --git a/tests/component_tests/rp2040_ble/test_connection_slots.py b/tests/component_tests/rp2040_ble/test_connection_slots.py new file mode 100644 index 0000000000..f33180e2d0 --- /dev/null +++ b/tests/component_tests/rp2040_ble/test_connection_slots.py @@ -0,0 +1,41 @@ +"""Connection-slot accounting: consumers claim against MAX_CONNECTIONS and +final validation rejects over-subscription with the consumer list.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome import config_validation as cv +from esphome.components import rp2040_ble +from esphome.core import CORE + + +def test_proxy_claims_its_slots_through_the_shared_accounting( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + # A default (3-slot) proxy build records one claim per slot, attributed + # to the consumer, and passes final validation. + generate_main(component_config_path("rp2_proxy_default.yaml")) + used = CORE.data[rp2040_ble.KEY_RP2040_BLE][rp2040_ble.KEY_USED_CONNECTION_SLOTS] + assert used == ["bluetooth_proxy"] * 3 + + +def test_oversubscription_is_rejected_with_the_consumer_list() -> None: + # No YAML shape reaches this today (the proxy schema caps at the same + # limit); the guard exists for a second consumer such as ble_client. + rp2040_ble.consume_connection_slots(3, "bluetooth_proxy")({}) + rp2040_ble.consume_connection_slots(1, "ble_client")({}) + with pytest.raises( + cv.Invalid, + match=r"4 connection slots.*maximum is 3.*bluetooth_proxy.*ble_client", + ): + rp2040_ble.validate_connection_slots() + + +def test_at_cap_passes() -> None: + rp2040_ble.consume_connection_slots(3, "bluetooth_proxy")({}) + rp2040_ble.validate_connection_slots() diff --git a/tests/component_tests/rp2040_ble/test_pool_wrap.py b/tests/component_tests/rp2040_ble/test_pool_wrap.py new file mode 100644 index 0000000000..291ca5eb58 --- /dev/null +++ b/tests/component_tests/rp2040_ble/test_pool_wrap.py @@ -0,0 +1,52 @@ +"""The rp2 BTstack pool overrides: multi-slot builds emit the --wrap flags +that swap the prebuilt single-client pools for the codegen-sized ones; +single-slot builds emit none and stay byte-identical to previous releases.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +from esphome.core import CORE + +from ..helpers import get_define_value + +# Spelled out rather than derived from rp2040_ble's symbol tuple, so a typo +# in the component's list fails here instead of mirroring into the test. +WRAP_FLAGS = ( + "-Wl,--wrap=btstack_memory_gatt_client_get", + "-Wl,--wrap=btstack_memory_gatt_client_free", + "-Wl,--wrap=btstack_memory_hci_connection_get", + "-Wl,--wrap=btstack_memory_hci_connection_free", +) + + +def test_default_slots_emit_the_pool_wrap( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("rp2_proxy_default.yaml")) + assert all(flag in CORE.build_flags for flag in WRAP_FLAGS) + assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "3" + assert get_define_value("BLUETOOTH_PROXY_MAX_CONNECTIONS") == "3" + + +def test_two_slots_emit_the_pool_wrap( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + # Two slots: the wrap pools are smaller than the cap, sized from the count. + generate_main(component_config_path("rp2_proxy_two_slots.yaml")) + assert all(flag in CORE.build_flags for flag in WRAP_FLAGS) + assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "2" + assert get_define_value("BLUETOOTH_PROXY_MAX_CONNECTIONS") == "2" + + +def test_single_slot_keeps_the_prebuilt_pools( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("rp2_proxy_single_slot.yaml")) + assert not any(flag in CORE.build_flags for flag in WRAP_FLAGS) + assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "1" + assert get_define_value("BLUETOOTH_PROXY_MAX_CONNECTIONS") == "1" diff --git a/tests/components/bluetooth_connection/validate.rp2040-ard.yaml b/tests/components/bluetooth_connection/validate.rp2040-ard.yaml index 620aaa177b..d3674b8406 100644 --- a/tests/components/bluetooth_connection/validate.rp2040-ard.yaml +++ b/tests/components/bluetooth_connection/validate.rp2040-ard.yaml @@ -6,6 +6,7 @@ packages: rp2_ble_tracker: +# Two slots: the one shape where the wrap pools are smaller than the cap. bluetooth_proxy: active: true - connection_slots: 1 + connection_slots: 2 diff --git a/tests/components/bluetooth_proxy/test.rp2040-ard.yaml b/tests/components/bluetooth_proxy/test.rp2040-ard.yaml index e219c7542d..77ed2ea32d 100644 --- a/tests/components/bluetooth_proxy/test.rp2040-ard.yaml +++ b/tests/components/bluetooth_proxy/test.rp2040-ard.yaml @@ -1,5 +1,7 @@ # Full proxy on the rp2 BLE hub: active defaults to true here (esp32 parity), -# so this compiles the BTstack GATT client backend and one connection slot. +# so this compiles the BTstack GATT client backend with the default three +# connection slots, exercising the rp2040_ble/btstack_memory.cpp pool --wrap +# link. # No explicit ble_hub_id: the generated binding resolves the single declared # hub, and an inline id here would collide with rp2_ble_tracker's own fixture # once CI merges both components into one grouped rp2040-ard build (grouped diff --git a/tests/components/bluetooth_proxy/test.rp2350-ard.yaml b/tests/components/bluetooth_proxy/test.rp2350-ard.yaml new file mode 100644 index 0000000000..1abc62cedb --- /dev/null +++ b/tests/components/bluetooth_proxy/test.rp2350-ard.yaml @@ -0,0 +1,9 @@ +# Pico 2 W build of the full proxy: links the rp2350 framework archive, so +# the pool --wrap overrides and their per-architecture layout asserts are +# exercised for this chip too (see test.rp2040-ard.yaml for the slot shape). +packages: + common: !include common.yaml + +rp2_ble_tracker: + +bluetooth_proxy: diff --git a/tests/test_build_components/build_components_base.rp2350-ard.yaml b/tests/test_build_components/build_components_base.rp2350-ard.yaml index 5df1670862..f76c5fc3f9 100644 --- a/tests/test_build_components/build_components_base.rp2350-ard.yaml +++ b/tests/test_build_components/build_components_base.rp2350-ard.yaml @@ -2,8 +2,10 @@ esphome: name: componenttestrp2040pico2ard friendly_name: $component_name +# rpipico2w: superset of rpipico2 with the CYW43 radio, so wireless +# components (wifi, BLE) can share this target too. rp2: - board: rpipico2 + board: rpipico2w logger: level: VERY_VERBOSE From 9938a2487dfab22a2e2668302c9a5e44b19e5f6c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 10:55:42 -0500 Subject: [PATCH 1393/1815] [core] Show the last line when output stops without a newline (#18265) --- esphome/espidf/runner.py | 67 +++++- esphome/platformio/runner.py | 18 +- esphome/util.py | 83 ++++++-- .../fixtures/espidf/closing_probe.py | 11 + .../fixtures/espidf/crashing_probe.py | 11 + .../fixtures/espidf/partial_noise_probe.py | 10 + tests/unit_tests/test_espidf_runner.py | 81 ++++++- tests/unit_tests/test_platformio_runner.py | 93 ++++++++ tests/unit_tests/test_util.py | 198 ++++++++++++++++++ 9 files changed, 532 insertions(+), 40 deletions(-) create mode 100644 tests/unit_tests/fixtures/espidf/closing_probe.py create mode 100644 tests/unit_tests/fixtures/espidf/crashing_probe.py create mode 100644 tests/unit_tests/fixtures/espidf/partial_noise_probe.py create mode 100644 tests/unit_tests/test_platformio_runner.py diff --git a/esphome/espidf/runner.py b/esphome/espidf/runner.py index 9e1f24d5ed..298a21fb82 100644 --- a/esphome/espidf/runner.py +++ b/esphome/espidf/runner.py @@ -90,6 +90,7 @@ def main() -> int: sys.path.pop(0) # ---- end sys.path fix-up ----------------------------------------------- + import contextlib import os from pathlib import Path import re @@ -179,6 +180,44 @@ def main() -> int: def flush(self) -> None: self._stream.flush() + def _emit(self, line: str) -> None: + if self._filter_pattern is not None: + stripped = ansi_escape.sub("", line).rstrip() + if self._filter_pattern.match(stripped) is not None: + return + self._stream.write(line) + + def drain(self) -> None: + """Write out a held-back line that never got its terminator. + + idf.py and CMake do not always end their last line with a + newline, and a build that dies part way through can stop mid + line. Without this the user is left staring at a build that + ended with no explanation. + """ + if not self._line_buffer: + return + line, self._line_buffer = self._line_buffer, "" + try: + # Add the terminator the line never got, so whatever ESPHome + # prints next does not run onto the same line. + self._emit(line + "\n") + self._stream.flush() + except (OSError, ValueError) as err: + # We are called from cleanup, so raising would replace the + # build's real exit code. Saying so must not raise either: + # under the dashboard our stdout and stderr are the same + # pipe, so whatever broke the write has most likely broken + # the report, and ``sys.__stderr__`` is None on some + # interpreters. Carry the line along; it is usually the + # message saying why the build failed. + if (real_stderr := sys.__stderr__) is not None: + with contextlib.suppress(OSError, ValueError): + print( + f"Could not write out remaining output ({err}): {line}", + file=real_stderr, + ) + def write(self, data) -> int: # Text streams normally hand us ``str``; decode in case # somebody writes bytes directly. @@ -186,7 +225,8 @@ def main() -> int: data = data.decode(errors="replace") if self._filter_pattern is None: - self._stream.write(data) + # Nothing to match against, so no need to wait for a full line. + self._emit(data) else: self._line_buffer += data for line in self._line_buffer.splitlines(keepends=True): @@ -195,11 +235,7 @@ def main() -> int: self._line_buffer = line break self._line_buffer = "" - - stripped = ansi_escape.sub("", line).rstrip() - if self._filter_pattern.match(stripped) is not None: - continue - self._stream.write(line) + self._emit(line) # We tell idf.py it is talking to a terminal, so it sends progress # bars and cursor moves. Our own stdout is usually a pipe, which is @@ -222,8 +258,8 @@ def main() -> int: is_verbose = any(arg in ("-v", "--verbose") for arg in sys.argv[2:]) filter_lines = None if is_verbose else FILTER_IDF_LINES or None - sys.stdout = _FilteringTTYStream(sys.stdout, filter_lines) # type: ignore[assignment] - sys.stderr = _FilteringTTYStream(sys.stderr, filter_lines) # type: ignore[assignment] + stdout_shim = sys.stdout = _FilteringTTYStream(sys.stdout, filter_lines) # type: ignore[assignment] + stderr_shim = sys.stderr = _FilteringTTYStream(sys.stderr, filter_lines) # type: ignore[assignment] # Shift argv so the target script sees its own path as argv[0] and # its own arguments starting at argv[1]. runpy.run_path does not @@ -241,8 +277,19 @@ def main() -> int: # If idf.py calls sys.exit(), SystemExit propagates out of run_path # and carries the exit code back to our caller. For normal returns, - # fall through and exit with 0. - runpy.run_path(script_path, run_name="__main__") + # fall through and exit with 0. Either way the streams get a chance to + # release a last line that never got its terminator. Drain the shims we + # made rather than sys.stdout, which the script is free to replace, and + # report instead of raising so cleanup cannot bury the real exit code. + try: + runpy.run_path(script_path, run_name="__main__") + finally: + # Drain stderr from a finally so a surprise from the first one cannot + # strand the second. + try: + stdout_shim.drain() + finally: + stderr_shim.drain() return 0 diff --git a/esphome/platformio/runner.py b/esphome/platformio/runner.py index c49220a044..9bb2205a90 100644 --- a/esphome/platformio/runner.py +++ b/esphome/platformio/runner.py @@ -179,12 +179,24 @@ def main() -> int: is_verbose = any(arg in ("-v", "--verbose") for arg in sys.argv[1:]) filter_lines = None if is_verbose else FILTER_PLATFORMIO_LINES - sys.stdout = RedirectText(sys.stdout, filter_lines=filter_lines) - sys.stderr = RedirectText(sys.stderr, filter_lines=filter_lines) + stdout_redirect = sys.stdout = RedirectText(sys.stdout, filter_lines=filter_lines) + stderr_redirect = sys.stderr = RedirectText(sys.stderr, filter_lines=filter_lines) import platformio.__main__ - return platformio.__main__.main() or 0 + # PlatformIO exits through ``sys.exit``, so drain from a finally to give + # a last line without a terminator a chance to reach the user. Drain the + # wrappers we made rather than sys.stdout, which PlatformIO is free to + # replace while it runs. + try: + return platformio.__main__.main() or 0 + finally: + # Drain stderr from a finally so a surprise from the first one cannot + # strand the second. + try: + stdout_redirect.drain() + finally: + stderr_redirect.drain() if __name__ == "__main__": diff --git a/esphome/util.py b/esphome/util.py index 5bb341b700..71c8334a02 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -174,6 +174,51 @@ class RedirectText: s = s.replace("\033", "\\033") self._out.write(s) + def _emit_line(self, line: str) -> None: + line_without_ansi = ANSI_ESCAPE.sub("", line) + line_without_end = line_without_ansi.rstrip() + if ( + self._filter_pattern is not None + and self._filter_pattern.match(line_without_end) is not None + ): + # Filter pattern matched, ignore the line + return + + self._write_color_replace(line) + # Check for flash size error and provide helpful guidance + if ( + "Error: The program size" in line + and "is greater than maximum allowed" in line + and (help_msg := get_esp32_arduino_flash_error_help()) + ): + self._write_color_replace(help_msg) + for callback in self._line_callbacks: + if msg := callback(line_without_end): + self._write_color_replace(msg) + + def drain(self) -> None: + """Write out a held-back line that never got its terminator. + + A tool that dies part way through a line, or ends its output without + a final newline, would otherwise have that text sit in the buffer + and never reach the user. + """ + if not self._line_buffer: + return + line, self._line_buffer = self._line_buffer, "" + try: + # Add the terminator the line never got, so whatever ESPHome + # prints next does not run onto the same line. + self._emit_line(line + "\n") + self._out.flush() + except (OSError, ValueError) as err: + # Every caller drains from a cleanup path, where the command's + # real result is already on its way out; raising here would + # replace it with an unrelated traceback. Carry the line into + # the warning, since the stream we were told to write it to is + # the one that just failed. + _LOGGER.warning("Could not write out remaining output (%s): %s", err, line) + def write(self, s: str | bytes) -> int: # s is usually a str already (self._out is of type TextIOWrapper) # However, s is sometimes also a bytes object in python3. Let's make sure it's a @@ -192,27 +237,7 @@ class RedirectText: self._line_buffer = line break self._line_buffer = "" - - line_without_ansi = ANSI_ESCAPE.sub("", line) - line_without_end = line_without_ansi.rstrip() - if ( - self._filter_pattern is not None - and self._filter_pattern.match(line_without_end) is not None - ): - # Filter pattern matched, ignore the line - continue - - self._write_color_replace(line) - # Check for flash size error and provide helpful guidance - if ( - "Error: The program size" in line - and "is greater than maximum allowed" in line - and (help_msg := get_esp32_arduino_flash_error_help()) - ): - self._write_color_replace(help_msg) - for callback in self._line_callbacks: - if msg := callback(line_without_end): - self._write_color_replace(msg) + self._emit_line(line) else: self._write_color_replace(s) @@ -261,11 +286,11 @@ def run_external_command( _LOGGER.debug("Running: %s", full_cmd) orig_stdout = sys.stdout - sys.stdout = RedirectText( + stdout_redirect = sys.stdout = RedirectText( sys.stdout, filter_lines=filter_lines, line_callbacks=line_callbacks ) orig_stderr = sys.stderr - sys.stderr = RedirectText( + stderr_redirect = sys.stderr = RedirectText( sys.stderr, filter_lines=filter_lines, line_callbacks=line_callbacks ) @@ -291,6 +316,18 @@ def run_external_command( sys.stdout = orig_stdout sys.stderr = orig_stderr + # Release a last line that never got its terminator. This runs after + # the real streams are back, and uses the wrappers we made rather + # than whatever the command left in sys.stdout, so it cannot strand + # them. With capture_stdout the stdout wrapper was never written to, + # so draining it does nothing. Drain stderr from a finally so a + # surprise from the first one cannot strand the second; a real bug + # still propagates, it just does not take the other line with it. + try: + stdout_redirect.drain() + finally: + stderr_redirect.drain() + if capture_stdout: return cap_stdout.getvalue() diff --git a/tests/unit_tests/fixtures/espidf/closing_probe.py b/tests/unit_tests/fixtures/espidf/closing_probe.py new file mode 100644 index 0000000000..a77d5c8f28 --- /dev/null +++ b/tests/unit_tests/fixtures/espidf/closing_probe.py @@ -0,0 +1,11 @@ +"""Leave a partial line behind and then close the stream under the runner. + +Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. Draining +cannot work here; the point is that the failure is reported rather than +raised out of the runner's cleanup, where it would bury the exit code. +""" + +import sys + +sys.stdout.write("partial before close") +sys.stdout.close() diff --git a/tests/unit_tests/fixtures/espidf/crashing_probe.py b/tests/unit_tests/fixtures/espidf/crashing_probe.py new file mode 100644 index 0000000000..bf434cc24e --- /dev/null +++ b/tests/unit_tests/fixtures/espidf/crashing_probe.py @@ -0,0 +1,11 @@ +"""Die part way through a line, the way a build that blows up does. + +Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. The +message has no trailing newline, so the runner's shim is holding it when +the process exits; nothing else will ever come to release it. +""" + +import sys + +sys.stdout.write("FATAL: ld returned 1 exit status") +sys.exit(2) diff --git a/tests/unit_tests/fixtures/espidf/partial_noise_probe.py b/tests/unit_tests/fixtures/espidf/partial_noise_probe.py new file mode 100644 index 0000000000..9c81f8eb7b --- /dev/null +++ b/tests/unit_tests/fixtures/espidf/partial_noise_probe.py @@ -0,0 +1,10 @@ +"""End on an unterminated line that the filter is supposed to drop. + +Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py, to +check that releasing a held-back line still applies the filter. +""" + +import sys + +sys.stdout.write("Compiling main.cpp\n") +sys.stdout.write("Project build complete.") diff --git a/tests/unit_tests/test_espidf_runner.py b/tests/unit_tests/test_espidf_runner.py index 831c8d1cc8..2c8fc7304b 100644 --- a/tests/unit_tests/test_espidf_runner.py +++ b/tests/unit_tests/test_espidf_runner.py @@ -19,10 +19,10 @@ from esphome.espidf import runner FIRST_LINE_TIMEOUT = 10.0 -def _run_main( +def _prepare_main( monkeypatch: pytest.MonkeyPatch, probe: Path, *args: str ) -> tuple[io.BytesIO, io.TextIOWrapper]: - """Run ``runner.main()`` in-process against a buffered fake stdout. + """Point ``runner.main()`` at *probe* with a buffered fake stdout. ``main`` rewrites ``sys.path``, ``sys.argv``, both std streams and ``os.get_terminal_size``; every one of those is monkeypatched so it is @@ -39,6 +39,14 @@ def _run_main( monkeypatch.setattr(sys, "stderr", stream) monkeypatch.setattr(os, "get_terminal_size", os.get_terminal_size) + return buf, stream + + +def _run_main( + monkeypatch: pytest.MonkeyPatch, probe: Path, *args: str +) -> tuple[io.BytesIO, io.TextIOWrapper]: + """Run ``runner.main()`` against *probe* and expect a clean exit.""" + buf, stream = _prepare_main(monkeypatch, probe, *args) assert runner.main() == 0 return buf, stream @@ -59,8 +67,73 @@ def test_main_filters_noise_and_flushes_each_write( # Matched by FILTER_IDF_LINES, so they never leave the runner. assert "Project build complete." not in output assert "-- Component paths:" not in output - # Held back because no terminator arrived. - assert "still going" not in output + # Held back until the end because no terminator arrived. + assert output.endswith("still going\n") + + +def test_main_drains_a_partial_line_when_the_build_dies( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """A build that stops mid line must still show that line. + + This is the whole point of draining: the message explaining why the + build failed is exactly the one most likely to arrive without a + trailing newline. + """ + buf, _stream = _prepare_main( + monkeypatch, fixture_path / "espidf" / "crashing_probe.py" + ) + + with pytest.raises(SystemExit) as excinfo: + runner.main() + + assert excinfo.value.code == 2 + assert buf.getvalue().decode("utf-8") == "FATAL: ld returned 1 exit status\n" + + +def test_main_reports_rather_than_raises_when_draining_fails( + monkeypatch: pytest.MonkeyPatch, + fixture_path: Path, + capfd: pytest.CaptureFixture[str], +) -> None: + """A stream that closed under us must not crash the runner's cleanup. + + The drain runs from a ``finally``, so an exception there would replace + whatever exit code the build was carrying back. + """ + _prepare_main(monkeypatch, fixture_path / "espidf" / "closing_probe.py") + + assert runner.main() == 0 + reported = capfd.readouterr().err + assert "Could not write out remaining output" in reported + # The held line has to come along; the stream it was meant for is gone. + assert "partial before close" in reported + + +def test_main_survives_a_drain_failure_with_nowhere_to_report_it( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """With no real stderr to report to, cleanup still must not raise. + + ``sys.__stderr__`` is None on some interpreters, and ``print(file=None)`` + falls back to ``sys.stdout``, which here is the shim wrapping the stream + that just failed. + """ + monkeypatch.setattr(sys, "__stderr__", None) + _prepare_main(monkeypatch, fixture_path / "espidf" / "closing_probe.py") + + assert runner.main() == 0 + + +def test_main_still_filters_a_drained_partial_line( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """Releasing a held line does not smuggle noise past the filter.""" + buf, _stream = _run_main( + monkeypatch, fixture_path / "espidf" / "partial_noise_probe.py" + ) + + assert buf.getvalue().decode("utf-8") == "Compiling main.cpp\n" def test_main_keeps_everything_in_verbose_mode( diff --git a/tests/unit_tests/test_platformio_runner.py b/tests/unit_tests/test_platformio_runner.py new file mode 100644 index 0000000000..f375aa457a --- /dev/null +++ b/tests/unit_tests/test_platformio_runner.py @@ -0,0 +1,93 @@ +"""Tests for esphome.platformio.runner.""" + +from __future__ import annotations + +from collections.abc import Callable +import io +import sys +from types import ModuleType + +import pytest + +from esphome.platformio import runner + + +def _prepare_main( + monkeypatch: pytest.MonkeyPatch, pio_main: Callable[[], int] +) -> io.BytesIO: + """Point ``runner.main()`` at a fake PlatformIO with a fake stdout. + + The real ``main`` patches PlatformIO internals and then hands control to + it; both are stubbed out so only the stream wrapping is exercised. The + fake stdout is block buffered like a pipe, so the caller can see what + actually left the wrapper. + """ + buf = io.BytesIO() + stream = io.TextIOWrapper(buf, encoding="utf-8", newline="\n", line_buffering=False) + + monkeypatch.setattr(sys, "argv", ["pio", "run"]) + monkeypatch.setattr(sys, "stdout", stream) + monkeypatch.setattr(sys, "stderr", stream) + monkeypatch.setattr(runner, "patch_structhash", lambda: None) + monkeypatch.setattr(runner, "patch_file_downloader", lambda: None) + + platformio = ModuleType("platformio") + platformio_main = ModuleType("platformio.__main__") + platformio_main.main = pio_main # type: ignore[attr-defined] + platformio.__main__ = platformio_main # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "platformio", platformio) + monkeypatch.setitem(sys.modules, "platformio.__main__", platformio_main) + + return buf + + +def test_main_drains_a_partial_line_on_a_clean_run( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A build ending mid line still shows that line.""" + + def pio_main() -> int: + print("Linking .pioenvs/firmware.elf\n", end="") + print("Building took 12.4 seconds", end="") + return 0 + + buf = _prepare_main(monkeypatch, pio_main) + + assert runner.main() == 0 + assert buf.getvalue().decode("utf-8") == ( + "Linking .pioenvs/firmware.elf\nBuilding took 12.4 seconds\n" + ) + + +def test_main_drains_when_platformio_exits_early( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Leaving through ``sys.exit`` still drains, because it runs in a finally.""" + + def pio_main() -> int: + print("*** [.pioenvs/firmware.elf] Error 1", end="") + sys.exit(1) + + buf = _prepare_main(monkeypatch, pio_main) + + with pytest.raises(SystemExit) as excinfo: + runner.main() + + assert excinfo.value.code == 1 + assert buf.getvalue().decode("utf-8") == "*** [.pioenvs/firmware.elf] Error 1\n" + + +def test_main_still_filters_a_drained_partial_line( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Releasing a held line does not smuggle noise past the filter.""" + + def pio_main() -> int: + # Matches FILTER_PLATFORMIO_LINES, and arrives without a terminator. + print("Verbose mode can be enabled via `-v, --verbose` option", end="") + return 0 + + buf = _prepare_main(monkeypatch, pio_main) + + assert runner.main() == 0 + assert buf.getvalue() == b"" diff --git a/tests/unit_tests/test_util.py b/tests/unit_tests/test_util.py index bd3d3d4836..006464842c 100644 --- a/tests/unit_tests/test_util.py +++ b/tests/unit_tests/test_util.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable import io +import logging from pathlib import Path import subprocess import sys @@ -442,6 +443,69 @@ def test_redirect_text_flushes_so_piped_output_streams() -> None: assert buf.getvalue() == b"Writing at 0x00010000 (50%)\r" +def test_redirect_text_drain_releases_held_partial_line() -> None: + """A last line with no terminator must still reach the user. + + A tool that dies part way through a line leaves that text in the buffer, + and it is usually the message saying what went wrong. + """ + redirect, buf = _make_redirect(filter_lines=["ignore me"]) + redirect.write("FATAL: ld returned 1 exit status") + + # Still held: no terminator has arrived. + assert buf.getvalue() == "" + + redirect.drain() + + assert buf.getvalue() == "FATAL: ld returned 1 exit status\n" + + +def test_redirect_text_drain_still_applies_the_filter() -> None: + """Releasing a held line does not smuggle noise past the filter.""" + redirect, buf = _make_redirect(filter_lines=["Verbose mode can be enabled"]) + redirect.write("Verbose mode can be enabled") + + redirect.drain() + + assert buf.getvalue() == "" + + +def test_redirect_text_drain_is_a_no_op_when_nothing_is_held() -> None: + """Draining twice, or with an empty buffer, writes nothing extra.""" + redirect, buf = _make_redirect(filter_lines=["ignore me"]) + redirect.write("complete line\n") + + redirect.drain() + redirect.drain() + + assert buf.getvalue() == "complete line\n" + + +def test_redirect_text_adds_flash_size_help(monkeypatch: pytest.MonkeyPatch) -> None: + """An out-of-flash error gets the how-to-fix note appended.""" + monkeypatch.setattr( + util, "get_esp32_arduino_flash_error_help", lambda: "TIP: switch to esp-idf\n" + ) + redirect, buf = _make_redirect(filter_lines=["ignore me"]) + + redirect.write("Error: The program size is greater than maximum allowed\n") + + assert "Error: The program size" in buf.getvalue() + assert "TIP: switch to esp-idf" in buf.getvalue() + + +def test_redirect_text_skips_flash_size_help_on_other_platforms( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The note is ESP32-with-Arduino only, so elsewhere the line stands alone.""" + monkeypatch.setattr(util, "get_esp32_arduino_flash_error_help", lambda: None) + redirect, buf = _make_redirect(filter_lines=["ignore me"]) + + redirect.write("Error: The program size is greater than maximum allowed\n") + + assert buf.getvalue() == "Error: The program size is greater than maximum allowed\n" + + def test_redirect_text_callback_called_on_matching_line() -> None: """Test that a line callback is called and its output is written.""" results: list[str] = [] @@ -571,6 +635,140 @@ def test_run_external_command_line_callbacks(capsys: pytest.CaptureFixture) -> N assert "CALLBACK FIRED" in captured.out +def test_run_external_command_drains_partial_line( + capsys: pytest.CaptureFixture, +) -> None: + """A command that stops mid line still shows that line. + + esptool runs in-process here, so a message it writes without a trailing + newline would otherwise be dropped when the streams are put back. + """ + + def fake_main() -> int: + print("A fatal error occurred: no serial data", end="") + return 1 + + rc = util.run_external_command(fake_main, "fake", filter_lines=["ignore me"]) + + assert rc == 1 + assert "A fatal error occurred: no serial data" in capsys.readouterr().out + + +def test_run_external_command_drains_on_early_exit( + capsys: pytest.CaptureFixture, +) -> None: + """The drain also happens when the command exits through ``sys.exit``.""" + + def fake_main() -> int: + print("Fatal: bailing out", end="") + sys.exit(3) + + rc = util.run_external_command(fake_main, "fake", filter_lines=["ignore me"]) + + assert rc == 3 + assert "Fatal: bailing out" in capsys.readouterr().out + + +def test_run_external_command_capture_stdout_has_nothing_to_drain() -> None: + """With ``capture_stdout`` there is nothing held to write out. + + The stdout wrapper still gets built, but ``sys.stdout`` is replaced by + the capture buffer right after, so the wrapper never sees a write and + draining it does nothing. + """ + + def fake_main() -> int: + print("captured output", end="") + return 0 + + out = util.run_external_command( + fake_main, "fake", capture_stdout=True, filter_lines=["ignore me"] + ) + + assert out == "captured output" + + +def test_run_external_command_survives_a_command_that_swaps_stdout( + capsys: pytest.CaptureFixture, +) -> None: + """Draining must not depend on what the command left in ``sys.stdout``. + + A command is free to replace the stream; reaching for ``drain`` on + whatever it left there would raise from the cleanup path and bury the + real exit code. + """ + + def fake_main() -> int: + print("before the swap", end="") + sys.stdout = io.StringIO() + sys.exit(7) + + rc = util.run_external_command(fake_main, "fake", filter_lines=["ignore me"]) + + assert rc == 7 + assert "before the swap" in capsys.readouterr().out + + +def test_drain_reports_the_lost_line_instead_of_raising( + caplog: pytest.LogCaptureFixture, +) -> None: + """A broken stream during cleanup is reported, not raised. + + The warning carries the held text, because the stream we were asked to + write it to is the one that just failed. + """ + caplog.set_level(logging.WARNING, logger=util.__name__) + out = MagicMock() + out.write.side_effect = BrokenPipeError("pipe is gone") + redirect = util.RedirectText(out, filter_lines=["ignore me"]) + redirect.write("FATAL: ld returned 1 exit status") + + redirect.drain() + + assert "pipe is gone" in caplog.text + assert "FATAL: ld returned 1 exit status" in caplog.text + + +def test_drain_lets_other_errors_through() -> None: + """Only an unusable stream is tolerated; a bug still has to be visible.""" + + def broken_callback(line: str) -> str | None: + raise TypeError("a line callback is broken") + + redirect, _buf = _make_redirect(line_callbacks=[broken_callback]) + redirect.write("a line with no terminator") + + with pytest.raises(TypeError): + redirect.drain() + + +def test_run_external_command_drains_stderr_even_if_stdout_drain_raises( + capsys: pytest.CaptureFixture, +) -> None: + """One stream failing must not strand the other's held line. + + ``drain`` deliberately lets anything that is not a stream error through, + so a broken line callback would otherwise skip the stderr drain and take + that line down with it. + """ + + def broken_on_stdout(line: str) -> str | None: + if "stdout" in line: + raise TypeError("a line callback is broken") + return None + + def fake_main() -> int: + print("stdout partial", end="") + print("stderr FATAL: the real reason", end="", file=sys.stderr) + return 0 + + with pytest.raises(TypeError): + util.run_external_command(fake_main, "fake", line_callbacks=[broken_on_stdout]) + + # The bug still surfaces, but stderr's held line was written first. + assert "stderr FATAL: the real reason" in capsys.readouterr().err + + def test_run_external_process_line_callbacks() -> None: """Test that run_external_process passes line_callbacks to RedirectText.""" results: list[str] = [] From 55e8bc3b1478aa19ad710774603230bf51c04196 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:23:36 +0000 Subject: [PATCH 1394/1815] Bump aioesphomeapi from 45.8.0 to 45.9.0 (#18283) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a90d9ec9eb..4e7de9eff1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.8.0 +aioesphomeapi==45.9.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From 3ae651af7be5939b084f5bf1de985a560910d973 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 12:38:55 -0500 Subject: [PATCH 1395/1815] [core] Stop dropping complete lines behind an unfinished one (#18279) --- esphome/espidf/runner.py | 31 ++++++++++------ esphome/util.py | 21 +++++++---- .../fixtures/espidf/formfeed_probe.py | 12 +++++++ tests/unit_tests/test_espidf_runner.py | 11 ++++++ tests/unit_tests/test_util.py | 36 +++++++++++++++++++ 5 files changed, 94 insertions(+), 17 deletions(-) create mode 100644 tests/unit_tests/fixtures/espidf/formfeed_probe.py diff --git a/esphome/espidf/runner.py b/esphome/espidf/runner.py index 298a21fb82..7ed11d7554 100644 --- a/esphome/espidf/runner.py +++ b/esphome/espidf/runner.py @@ -144,12 +144,14 @@ def main() -> int: * ``isatty()`` unconditionally returns True, tricking downstream code into emitting TTY-format output. - * Input is split on ``\\n`` / ``\\r`` via - ``str.splitlines(keepends=True)`` and any complete line whose + * Input is split with ``str.splitlines(keepends=True)``, which + breaks on more than ``\\n`` and ``\\r``; form feed and a few + other control characters count too. Any piece whose ANSI-stripped, right-stripped form matches one of ``filter_lines`` is dropped. - * Incomplete trailing chunks are held in a buffer until a - terminator arrives. + * Only the final piece can still be waiting for more text, so + that one is held until a ``\\n`` or ``\\r`` arrives. A piece + that ended on one of the other breaks goes out as it is. Mirrors the matching semantics of ``esphome.util.RedirectText`` so filter patterns behave identically in both the PlatformIO @@ -228,13 +230,22 @@ def main() -> int: # Nothing to match against, so no need to wait for a full line. self._emit(data) else: - self._line_buffer += data - for line in self._line_buffer.splitlines(keepends=True): - if "\n" not in line and "\r" not in line: - # Incomplete — hold until we see a terminator. - self._line_buffer = line - break + lines = (self._line_buffer + data).splitlines(keepends=True) + # Every piece but the last ends with something + # ``str.splitlines`` treats as a break, so only the last one + # can still be waiting for more text. Hold that one, write + # out the rest. + # + # Some of those breaks are not line endings to us, a form + # feed for one, so a piece can go out without ending in a + # newline. That beats what we did before, which was to stop + # at the first such piece and drop every complete line + # behind it. + if lines and not lines[-1].endswith(("\n", "\r")): + self._line_buffer = lines.pop() + else: self._line_buffer = "" + for line in lines: self._emit(line) # We tell idf.py it is talking to a terminal, so it sends progress diff --git a/esphome/util.py b/esphome/util.py index 71c8334a02..0b9025c73b 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -229,14 +229,21 @@ class RedirectText: s = s.decode() if self._filter_pattern is not None or self._line_callbacks: - self._line_buffer += s - lines = self._line_buffer.splitlines(True) - for line in lines: - if "\n" not in line and "\r" not in line: - # Not a complete line, set line buffer - self._line_buffer = line - break + lines = (self._line_buffer + s).splitlines(True) + # Every piece but the last ends with something + # ``str.splitlines`` treats as a break, so only the last one can + # still be waiting for more text. Hold that one, write out the + # rest. + # + # Some of those breaks are not line endings to us, a form feed + # for one, so a piece can go out without ending in a newline. + # That beats what we did before, which was to stop at the first + # such piece and drop every complete line behind it. + if lines and not lines[-1].endswith(("\n", "\r")): + self._line_buffer = lines.pop() + else: self._line_buffer = "" + for line in lines: self._emit_line(line) else: self._write_color_replace(s) diff --git a/tests/unit_tests/fixtures/espidf/formfeed_probe.py b/tests/unit_tests/fixtures/espidf/formfeed_probe.py new file mode 100644 index 0000000000..727cda25ce --- /dev/null +++ b/tests/unit_tests/fixtures/espidf/formfeed_probe.py @@ -0,0 +1,12 @@ +"""Write a form feed part way through the output. + +Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. A form +feed is not a line terminator here, so everything written must still come +out, including the complete lines that follow it. +""" + +import sys + +sys.stdout.write("Compiling main.cpp\n") +sys.stdout.write("page one\x0cpage two\n") +sys.stdout.write("[2/9] Building C object\n") diff --git a/tests/unit_tests/test_espidf_runner.py b/tests/unit_tests/test_espidf_runner.py index 2c8fc7304b..e4cc6e137e 100644 --- a/tests/unit_tests/test_espidf_runner.py +++ b/tests/unit_tests/test_espidf_runner.py @@ -71,6 +71,17 @@ def test_main_filters_noise_and_flushes_each_write( assert output.endswith("still going\n") +def test_main_keeps_output_after_a_form_feed( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """A form feed is text, not a line break, so nothing after it is lost.""" + buf, _stream = _run_main(monkeypatch, fixture_path / "espidf" / "formfeed_probe.py") + + assert buf.getvalue().decode("utf-8") == ( + "Compiling main.cpp\npage one\x0cpage two\n[2/9] Building C object\n" + ) + + def test_main_drains_a_partial_line_when_the_build_dies( monkeypatch: pytest.MonkeyPatch, fixture_path: Path ) -> None: diff --git a/tests/unit_tests/test_util.py b/tests/unit_tests/test_util.py index 006464842c..fcd8bf2e9c 100644 --- a/tests/unit_tests/test_util.py +++ b/tests/unit_tests/test_util.py @@ -443,6 +443,42 @@ def test_redirect_text_flushes_so_piped_output_streams() -> None: assert buf.getvalue() == b"Writing at 0x00010000 (50%)\r" +@pytest.mark.parametrize( + "break_char", + ["\x0c", "\x0b", "\x1c", "\x1d", "\x1e", "\x85", "\u2028", "\u2029"], + ids=["formfeed", "vtab", "fs", "gs", "rs", "nel", "lsep", "psep"], +) +def test_redirect_text_keeps_output_after_an_exotic_break_character( + break_char: str, +) -> None: + r"""Only ``\n`` and ``\r`` end a line; the rest is ordinary text. + + ``str.splitlines`` treats all of these as line breaks. Splitting on them + used to strand the fragment in the buffer and drop every complete line + that came after it, which for a form feed in toolchain output meant + losing the rest of the build log. + """ + redirect, buf = _make_redirect(filter_lines=["ignore me"]) + + redirect.write(f"first{break_char}second\nthird\n") + + assert buf.getvalue() == f"first{break_char}second\nthird\n" + + +def test_redirect_text_treats_crlf_as_one_terminator() -> None: + r"""``\r\n``, a lone ``\r`` and a lone ``\n`` each end exactly one line.""" + redirect, buf = _make_redirect(filter_lines=["ignore me"]) + + redirect.write("one\r\ntwo\rthree\nfour") + + # "four" has no terminator yet, so it is held back. + assert buf.getvalue() == "one\r\ntwo\rthree\n" + + redirect.drain() + + assert buf.getvalue() == "one\r\ntwo\rthree\nfour\n" + + def test_redirect_text_drain_releases_held_partial_line() -> None: """A last line with no terminator must still reach the user. From 55bd63732d984ab8b8227015594201d565ed82ff Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Tue, 11 Aug 2026 20:56:12 +0200 Subject: [PATCH 1396/1815] [mitsubishi_cn105] Add vertical vane state trigger (#16727) Co-authored-by: J. Nick Koston --- .../components/mitsubishi_cn105/__init__.py | 19 +++++++- .../mitsubishi_cn105_component.cpp | 2 +- .../mitsubishi_cn105_component.h | 34 ++++++++++++- tests/components/mitsubishi_cn105/common.h | 7 +++ tests/components/mitsubishi_cn105/common.yaml | 5 ++ .../mitsubishi_cn105_component_tests.cpp | 48 +++++++++++++++++++ ...bishi_cn105_vane_select_vertical_tests.cpp | 7 --- 7 files changed, 112 insertions(+), 10 deletions(-) create mode 100644 tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp diff --git a/esphome/components/mitsubishi_cn105/__init__.py b/esphome/components/mitsubishi_cn105/__init__.py index 7d5594495a..70ed0a7a85 100644 --- a/esphome/components/mitsubishi_cn105/__init__.py +++ b/esphome/components/mitsubishi_cn105/__init__.py @@ -2,7 +2,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_TEMPERATURE, CONF_UPDATE_INTERVAL +from esphome.const import CONF_ID, CONF_ON_STATE, CONF_TEMPERATURE, CONF_UPDATE_INTERVAL from esphome.core import ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType, TemplateArgsType @@ -13,6 +13,7 @@ DOMAIN = "mitsubishi_cn105" CONF_MITSUBISHI_CN105_ID = f"{DOMAIN}_id" CONF_TELEMETRY_REQUEST_MIN_INTERVAL = "telemetry_request_min_interval" +CONF_VANE = "vane" mitsubishi_ns = cg.esphome_ns.namespace(DOMAIN) @@ -22,6 +23,8 @@ MitsubishiCN105Component = mitsubishi_ns.class_( uart.UARTDevice, ) +VaneState = mitsubishi_ns.struct("VaneState") + SetRemoteTemperatureAction = mitsubishi_ns.class_( "SetRemoteTemperatureAction", automation.Action, @@ -42,6 +45,11 @@ CONFIG_SCHEMA = ( cv.Optional( CONF_TELEMETRY_REQUEST_MIN_INTERVAL, default="60s" ): cv.update_interval, + cv.Optional(CONF_VANE): cv.Schema( + { + cv.Optional(CONF_ON_STATE): automation.validate_automation({}), + } + ), } ) .extend(cv.COMPONENT_SCHEMA) @@ -80,6 +88,15 @@ async def to_code(config: ConfigType) -> None: config[CONF_TELEMETRY_REQUEST_MIN_INTERVAL] ) ) + if on_state := config.get(CONF_VANE, {}).get(CONF_ON_STATE): + cg.add_global(mitsubishi_ns.using) + for conf in on_state: + await automation.build_callback_automation( + var, + "add_on_vane_state_callback", + [(VaneState.operator("const").operator("ref"), "x")], + conf, + ) REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema( diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp index 166e7fbf88..5314965af6 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp @@ -27,7 +27,7 @@ void MitsubishiCN105Component::setup() { this->hp_.initialize(); } void MitsubishiCN105Component::loop() { if (this->hp_.update()) { - this->status_callback_.call(); + this->notify_status_listeners_(); } } diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h index 1caf779f40..64077432fd 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h @@ -9,6 +9,24 @@ namespace esphome::mitsubishi_cn105 { +enum VerticalVaneMode : uint8_t { + VERTICAL_VANE_MODE_AUTO = static_cast(MitsubishiCN105::VaneMode::AUTO), + VERTICAL_VANE_MODE_POSITION_1 = static_cast(MitsubishiCN105::VaneMode::POSITION_1), + VERTICAL_VANE_MODE_POSITION_2 = static_cast(MitsubishiCN105::VaneMode::POSITION_2), + VERTICAL_VANE_MODE_POSITION_3 = static_cast(MitsubishiCN105::VaneMode::POSITION_3), + VERTICAL_VANE_MODE_POSITION_4 = static_cast(MitsubishiCN105::VaneMode::POSITION_4), + VERTICAL_VANE_MODE_POSITION_5 = static_cast(MitsubishiCN105::VaneMode::POSITION_5), + VERTICAL_VANE_MODE_SWING = static_cast(MitsubishiCN105::VaneMode::SWING), +}; + +struct VaneState { + struct Vertical { + VerticalVaneMode direction; + }; + + Vertical vertical; +}; + class MitsubishiCN105Component : public Component, public uart::UARTDevice { public: explicit MitsubishiCN105Component() : hp_(*this) {} @@ -38,15 +56,29 @@ class MitsubishiCN105Component : public Component, public uart::UARTDevice { this->status_callback_.add(std::forward(callback)); } + template void add_on_vane_state_callback(F &&callback) { + this->vane_state_callback_.add(std::forward(callback)); + } + void publish_status() { if (this->is_status_initialized()) { - this->status_callback_.call(); + this->notify_status_listeners_(); } } protected: + void notify_status_listeners_() { + this->status_callback_.call(); + if (this->status().vane_mode != MitsubishiCN105::VaneMode::UNKNOWN) { + this->vane_state_callback_.call(VaneState{ + .vertical = {.direction = static_cast(this->status().vane_mode)}, + }); + } + } + MitsubishiCN105 hp_; CallbackManager status_callback_; + LazyCallbackManager vane_state_callback_; }; } // namespace esphome::mitsubishi_cn105 diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h index b90ddf3995..a119a38d24 100644 --- a/tests/components/mitsubishi_cn105/common.h +++ b/tests/components/mitsubishi_cn105/common.h @@ -77,4 +77,11 @@ class TestableMitsubishiCN105Climate : public MitsubishiCN105Climate { MitsubishiCN105Component component_; }; +class TestableMitsubishiCN105Component : public MitsubishiCN105Component { + public: + MitsubishiCN105::Status &mutable_status() { return const_cast(this->status()); } + + void notify_status() { this->status_callback_.call(); } +}; + } // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/mitsubishi_cn105/common.yaml b/tests/components/mitsubishi_cn105/common.yaml index 12a3b8ce9d..fcd1b048dd 100644 --- a/tests/components/mitsubishi_cn105/common.yaml +++ b/tests/components/mitsubishi_cn105/common.yaml @@ -3,6 +3,11 @@ mitsubishi_cn105: uart_id: uart_bus update_interval: 30s telemetry_request_min_interval: 120s + vane: + on_state: + - logger.log: + format: "TRIGGER: vane on_state is auto: %s" + args: ['x.vertical.direction == VERTICAL_VANE_MODE_AUTO ? "yes" : "no"'] climate: - platform: mitsubishi_cn105 diff --git a/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp b/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp new file mode 100644 index 0000000000..48ea6b0c29 --- /dev/null +++ b/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp @@ -0,0 +1,48 @@ +#include "common.h" + +namespace esphome::mitsubishi_cn105::testing { + +TEST(MitsubishiCN105ComponentTests, PublishesVaneStateForEveryValidSnapshot) { + TestableMitsubishiCN105Component hub; + size_t callback_count = 0; + std::optional callback_direction; + hub.add_on_vane_state_callback([&](const VaneState &state) { + callback_count++; + callback_direction = state.vertical.direction; + }); + + hub.mutable_status().room_temperature = 20.0f; + hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4; + hub.publish_status(); + + EXPECT_EQ(callback_count, 1); + EXPECT_EQ(callback_direction, std::optional{VERTICAL_VANE_MODE_POSITION_4}); + + hub.publish_status(); + + EXPECT_EQ(callback_count, 2); + EXPECT_EQ(callback_direction, std::optional{VERTICAL_VANE_MODE_POSITION_4}); +} + +TEST(MitsubishiCN105ComponentTests, DoesNotPublishUnknownVaneState) { + TestableMitsubishiCN105Component hub; + size_t status_callback_count = 0; + size_t vane_callback_count = 0; + hub.add_on_status_callback([&]() { status_callback_count++; }); + hub.add_on_vane_state_callback([&](const VaneState &) { vane_callback_count++; }); + + hub.mutable_status().room_temperature = 20.0f; + hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN; + hub.publish_status(); + + EXPECT_EQ(status_callback_count, 1); + EXPECT_EQ(vane_callback_count, 0); + + hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4; + hub.publish_status(); + + EXPECT_EQ(status_callback_count, 2); + EXPECT_EQ(vane_callback_count, 1); +} + +} // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical_tests.cpp b/tests/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical_tests.cpp index 4c980d69d8..1f928e3bf4 100644 --- a/tests/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical_tests.cpp +++ b/tests/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical_tests.cpp @@ -3,13 +3,6 @@ namespace esphome::mitsubishi_cn105::testing { -class TestableMitsubishiCN105Component : public MitsubishiCN105Component { - public: - MitsubishiCN105::Status &mutable_status() { return const_cast(this->status()); } - - void notify_status() { this->status_callback_.call(); } -}; - class TestableMitsubishiCN105VerticalVaneDirectionSelect : public MitsubishiCN105VerticalVaneDirectionSelect { public: using MitsubishiCN105VerticalVaneDirectionSelect::control; From 94fbfa05de1103c7ce4f4d1a437f0687b6a2c7d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 14:35:06 -0500 Subject: [PATCH 1397/1815] [core] Show the out-of-flash tip instead of crashing the build (#18280) --- esphome/core/__init__.py | 5 ++ esphome/platformio/toolchain.py | 9 ++- esphome/util.py | 24 +++++- tests/unit_tests/test_platformio_toolchain.py | 63 ++++++++++++++- tests/unit_tests/test_util.py | 79 ++++++++++++++++++- 5 files changed, 172 insertions(+), 8 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 1a5f4f2cf5..534b740a5d 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -885,6 +885,11 @@ class EsphomeCore: return self.relative_build_path("build", "bootloader", "bootloader.bin") return self.relative_pioenvs_path(self.name, "bootloader.bin") + @property + def is_configured(self) -> bool: + """Whether anything has set this CORE up for a target.""" + return KEY_CORE in self.data + @property def target_platform(self): return self.data[KEY_CORE][KEY_TARGET_PLATFORM] diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index 32e30290ac..0e7ffce939 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -19,7 +19,7 @@ from esphome.helpers import ( rmtree, write_file, ) -from esphome.util import FlashImage, run_external_process +from esphome.util import ESP32_ARDUINO_ENV, FlashImage, run_external_process if TYPE_CHECKING: from platformio.project.config import ProjectConfig @@ -342,6 +342,13 @@ def run_platformio_cli(*args, **kwargs) -> str | int: base_env = kwargs.pop("env", None) env = dict(os.environ if base_env is None else base_env) env.update(_ccache_env()) + # The runner offers the out-of-flash tip but has no configured CORE, so + # tell it. Ask CORE, not is_esp32_arduino_build(), which reads this same + # variable; clear an inherited one so it cannot reach the wrong build. + if CORE.is_configured and CORE.is_esp32 and CORE.using_arduino: + env[ESP32_ARDUINO_ENV] = "1" + else: + env.pop(ESP32_ARDUINO_ENV, None) return run_external_process(*cmd, env=env, **kwargs) diff --git a/esphome/util.py b/esphome/util.py index 0b9025c73b..2fc34f3a69 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -3,6 +3,7 @@ from collections.abc import Callable, Iterable from dataclasses import dataclass import io import logging +import os from pathlib import Path import re import sys @@ -141,6 +142,10 @@ def shlex_quote(s: str | Path) -> str: return "'" + s.replace("'", "'\"'\"'") + "'" +# Tells the PlatformIO runner subprocess, which has no configured CORE, that +# this is an ESP32 Arduino build. +ESP32_ARDUINO_ENV = "ESPHOME_ESP32_ARDUINO_BUILD" + ANSI_ESCAPE = re.compile(r"\033[@-_][0-?]*[ -/]*[@-~]") @@ -520,11 +525,24 @@ def detect_rp2040_bootsel(picotool_path: str | Path) -> BootselResult: return BootselResult(0) -def get_esp32_arduino_flash_error_help() -> str | None: - """Returns helpful message when ESP32 with Arduino runs out of flash space.""" +def is_esp32_arduino_build() -> bool: + """Whether the build targets ESP32 with the Arduino framework. + + The PlatformIO runner subprocess has no configured CORE, so the parent + passes the answer in the environment. + """ from esphome.core import CORE - if not (CORE.is_esp32 and CORE.using_arduino): + if not CORE.is_configured: + # The runner subprocess. A half filled in CORE still counts as + # configured, so reading from it raises instead of landing here. + return os.environ.get(ESP32_ARDUINO_ENV) == "1" + return CORE.is_esp32 and CORE.using_arduino + + +def get_esp32_arduino_flash_error_help() -> str | None: + """Returns helpful message when ESP32 with Arduino runs out of flash space.""" + if not is_esp32_arduino_build(): return None from esphome.log import AnsiFore, color diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index 9450e8e0e1..02c11b4e45 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -16,9 +16,10 @@ from unittest.mock import MagicMock, Mock, call, patch import pytest +from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM from esphome.core import CORE, EsphomeError from esphome.platformio import runner, toolchain -from esphome.util import FlashImage +from esphome.util import ESP32_ARDUINO_ENV, FlashImage def test_idedata_firmware_elf_path(setup_core: Path) -> None: @@ -328,6 +329,66 @@ def test_idedata_null_section_raises_esphome_error(setup_core: Path) -> None: _ = toolchain.IDEData({"extra": None}).extra_flash_images +@pytest.mark.parametrize( + ("platform", "framework", "expected"), + [ + ("esp32", "arduino", "1"), + ("esp32", "esp-idf", None), + ("esp8266", "arduino", None), + ], +) +def test_run_platformio_cli_flags_an_esp32_arduino_build( + setup_core: Path, + mock_run_external_process: Mock, + platform: str, + framework: str, + expected: str | None, +) -> None: + """Only an ESP32 Arduino build is flagged, and an inherited one is cleared.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: platform, + KEY_TARGET_FRAMEWORK: framework, + } + + with patch.dict(os.environ, {ESP32_ARDUINO_ENV: "1"}, clear=False): + mock_run_external_process.return_value = 0 + toolchain.run_platformio_cli("test", "arg") + + env = mock_run_external_process.call_args[1]["env"] + assert env.get(ESP32_ARDUINO_ENV) == expected + # Only the subprocess env is touched; ours is left as it was. + assert os.environ[ESP32_ARDUINO_ENV] == "1" + + +def test_run_platformio_cli_ignores_an_inherited_flag_without_core( + setup_core: Path, mock_run_external_process: Mock +) -> None: + """An inherited flag must not end up answering for CORE.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.data.pop(KEY_CORE, None) + + with patch.dict(os.environ, {ESP32_ARDUINO_ENV: "1"}, clear=False): + mock_run_external_process.return_value = 0 + toolchain.run_platformio_cli("test", "arg") + + env = mock_run_external_process.call_args[1]["env"] + assert ESP32_ARDUINO_ENV not in env + + +def test_run_platformio_cli_raises_on_a_half_filled_core( + setup_core: Path, mock_run_external_process: Mock +) -> None: + """A CORE set up but left incomplete must surface, not fall back.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.data[KEY_CORE] = {} + + with patch.dict(os.environ, {}, clear=False): + mock_run_external_process.return_value = 0 + with pytest.raises(KeyError): + toolchain.run_platformio_cli("test", "arg") + + def test_run_platformio_cli_sets_environment_variables( setup_core: Path, mock_run_external_process: Mock ) -> None: diff --git a/tests/unit_tests/test_util.py b/tests/unit_tests/test_util.py index fcd8bf2e9c..a4b091b7c2 100644 --- a/tests/unit_tests/test_util.py +++ b/tests/unit_tests/test_util.py @@ -14,6 +14,8 @@ from unittest.mock import MagicMock, patch import pytest from esphome import util +from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM +from esphome.core import CORE def test_list_yaml_files_with_files_and_directories(tmp_path: Path) -> None: @@ -517,6 +519,80 @@ def test_redirect_text_drain_is_a_no_op_when_nothing_is_held() -> None: assert buf.getvalue() == "complete line\n" +def test_flash_error_help_is_quiet_when_core_is_unconfigured( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regression: reading the platform used to raise in the runner.""" + + monkeypatch.setattr(CORE, "data", {}) + monkeypatch.delenv(util.ESP32_ARDUINO_ENV, raising=False) + + assert util.get_esp32_arduino_flash_error_help() is None + + +def test_flash_error_help_reads_the_env_var_when_core_is_unconfigured( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The parent tells the subprocess what it cannot work out for itself.""" + + monkeypatch.setattr(CORE, "data", {}) + monkeypatch.setenv(util.ESP32_ARDUINO_ENV, "1") + + help_msg = util.get_esp32_arduino_flash_error_help() + + assert help_msg is not None + assert "esp-idf" in help_msg + + +def test_is_esp32_arduino_build_raises_on_a_half_filled_core( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A half filled in CORE is a bug, so it must raise, not fall back.""" + + monkeypatch.setattr(CORE, "data", {KEY_CORE: {}}) + monkeypatch.setenv(util.ESP32_ARDUINO_ENV, "1") + + with pytest.raises(KeyError): + util.is_esp32_arduino_build() + + +@pytest.mark.parametrize( + ("platform", "framework", "expected"), + [ + ("esp32", "arduino", True), + ("esp32", "esp-idf", False), + ("esp8266", "arduino", False), + ], +) +def test_is_esp32_arduino_build_from_a_configured_core( + monkeypatch: pytest.MonkeyPatch, platform: str, framework: str, expected: bool +) -> None: + """With CORE set up, it is the source of truth and the env var is ignored.""" + + monkeypatch.setattr( + CORE, + "data", + {KEY_CORE: {KEY_TARGET_PLATFORM: platform, KEY_TARGET_FRAMEWORK: framework}}, + ) + monkeypatch.delenv(util.ESP32_ARDUINO_ENV, raising=False) + + assert util.is_esp32_arduino_build() is expected + + +def test_redirect_text_survives_a_flash_error_without_core( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The overflow line goes through even from a process with no CORE.""" + + monkeypatch.setattr(CORE, "data", {}) + monkeypatch.delenv(util.ESP32_ARDUINO_ENV, raising=False) + redirect, buf = _make_redirect(filter_lines=["ignore me"]) + + redirect.write("Error: The program size is greater than maximum allowed\n") + + assert buf.getvalue() == "Error: The program size is greater than maximum allowed\n" + + def test_redirect_text_adds_flash_size_help(monkeypatch: pytest.MonkeyPatch) -> None: """An out-of-flash error gets the how-to-fix note appended.""" monkeypatch.setattr( @@ -971,7 +1047,6 @@ class TestSafePrint: @pytest.fixture(autouse=True) def _no_dashboard(self, monkeypatch: pytest.MonkeyPatch) -> None: """Default ``CORE.dashboard`` to False so each test starts hermetic.""" - from esphome.core import CORE monkeypatch.setattr(CORE, "dashboard", False) @@ -993,7 +1068,6 @@ class TestSafePrint: monkeypatch: pytest.MonkeyPatch, ) -> None: r"""Dashboard mode escapes raw ``\033`` ESC bytes to literal ``\\033``.""" - from esphome.core import CORE monkeypatch.setattr(CORE, "dashboard", True) util.safe_print("\033[0;32mhi\033[0m") @@ -1060,7 +1134,6 @@ class TestSafePrint: self, monkeypatch: pytest.MonkeyPatch ) -> None: """Dashboard ESC escaping + cp1252 fallback compose correctly.""" - from esphome.core import CORE monkeypatch.setattr(CORE, "dashboard", True) buf = io.BytesIO() From 74c30c62ef97c3545b3bf313742a86c2522db489 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:36:59 -0500 Subject: [PATCH 1398/1815] Bump setuptools from 83.0.0 to 84.0.0 (#18290) Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index eda3c4cf7c..166b3cf6bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools==83.0.0", "wheel>=0.43,<0.48"] +requires = ["setuptools==84.0.0", "wheel>=0.43,<0.48"] build-backend = "setuptools.build_meta" [project] From b9d1d2f06b34a55ce9bec11d660771e86d93e117 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:02:53 +0000 Subject: [PATCH 1399/1815] Bump aioesphomeapi from 45.9.0 to 45.10.0 (#18294) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4e7de9eff1..9c231bd0fe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.9.0 +aioesphomeapi==45.10.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From 4a7de87bffde0729fccec91735480015c54a31e2 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 11 Aug 2026 16:33:37 -0400 Subject: [PATCH 1400/1815] [audio] Bump microDecoder to v0.4.0 (#18291) --- esphome/components/audio/__init__.py | 4 +++- esphome/idf_component.yml | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index d87f32fc36..1c522cbb5d 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -371,7 +371,7 @@ async def to_code(config): data.wav_support = True if data.micro_decoder_support: - add_idf_component(name="esphome/micro-decoder", ref="0.2.0") + add_idf_component(name="esphome/micro-decoder", ref="0.4.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: @@ -380,6 +380,8 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_MP3", False) if not data.opus_support: add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_OPUS", False) + # Vorbis is unsupported in ESPHome, so always disable it + add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_VORBIS", False) if not data.wav_support: add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_WAV", False) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 9448b93cc9..6a9d7171ec 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -8,7 +8,7 @@ dependencies: esphome/esp-micro-speech-features: version: 1.2.3 esphome/micro-decoder: - version: 0.2.0 + version: 0.4.0 esphome/micro-flac: version: 0.2.0 esphome/micro-mp3: From 7f0d6a86968ab05d548e878671697d8133d676b9 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 11 Aug 2026 16:49:08 -0400 Subject: [PATCH 1401/1815] [sendspin] Add image platform for artwork (#17937) --- CODEOWNERS | 1 + esphome/components/sendspin/__init__.py | 61 +++- esphome/components/sendspin/image/__init__.py | 228 +++++++++++++++ .../components/sendspin/image/automation.h | 20 ++ .../sendspin/image/sendspin_image.cpp | 261 ++++++++++++++++++ .../sendspin/image/sendspin_image.h | 185 +++++++++++++ esphome/components/sendspin/sendspin_hub.cpp | 47 ++++ esphome/components/sendspin/sendspin_hub.h | 44 +++ tests/component_tests/sendspin/__init__.py | 0 tests/component_tests/sendspin/test_image.py | 114 ++++++++ tests/components/sendspin/common-image.yaml | 47 ++++ .../sendspin/test-image-lvgl.esp32-idf.yaml | 66 +++++ .../sendspin/test-image.esp32-idf.yaml | 3 + 13 files changed, 1076 insertions(+), 1 deletion(-) create mode 100644 esphome/components/sendspin/image/__init__.py create mode 100644 esphome/components/sendspin/image/automation.h create mode 100644 esphome/components/sendspin/image/sendspin_image.cpp create mode 100644 esphome/components/sendspin/image/sendspin_image.h create mode 100644 tests/component_tests/sendspin/__init__.py create mode 100644 tests/component_tests/sendspin/test_image.py create mode 100644 tests/components/sendspin/common-image.yaml create mode 100644 tests/components/sendspin/test-image-lvgl.esp32-idf.yaml create mode 100644 tests/components/sendspin/test-image.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 253b0c05b1..9ddbca5c71 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -466,6 +466,7 @@ esphome/components/sen21231/* @shreyaskarnik esphome/components/sen5x/* @martgras esphome/components/sen6x/* @martgras @mebner86 @tuct esphome/components/sendspin/* @kahrendt +esphome/components/sendspin/image/* @kahrendt esphome/components/sendspin/media_player/* @kahrendt esphome/components/sendspin/media_source/* @kahrendt esphome/components/sendspin/sensor/* @kahrendt diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index d0c2112ba9..bd889c2c92 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from esphome import automation import esphome.codegen as cg @@ -6,9 +6,13 @@ from esphome.components import esp32, network, psram, socket, wifi import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, + CONF_FORMAT, + CONF_HEIGHT, CONF_ID, CONF_SAMPLE_RATE, + CONF_SOURCE, CONF_TASK_STACK_IN_PSRAM, + CONF_WIDTH, ) from esphome.core import CORE, ID from esphome.cpp_generator import TemplateArgsType @@ -20,12 +24,16 @@ CODEOWNERS = ["@kahrendt"] DEPENDENCIES = ["network"] DOMAIN = "sendspin" +CONF_DISPLAY_OFFSET = "display_offset" CONF_SENDSPIN_ID = "sendspin_id" CONF_INITIAL_STATIC_DELAY = "initial_static_delay" CONF_FIXED_DELAY = "fixed_delay" CONF_DECODE_MEMORY = "decode_memory" +# Matches ARTWORK_MAX_SLOTS in sendspin-cpp. +MAX_ARTWORK_SLOTS = 4 + # sendspin-cpp library lives in the global `sendspin` namespace. sendspin_library_ns = cg.global_ns.namespace("sendspin") @@ -36,9 +44,20 @@ CODEC_FORMAT_OPUS = SendspinCodecFormat.enum("OPUS") CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM") CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED") +SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True) +IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG") +IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG") +IMAGE_FORMAT_BMP = SendspinImageFormat.enum("BMP") + +SendspinImageSource = sendspin_library_ns.enum("SendspinImageSource", is_class=True) +IMAGE_SOURCE_ALBUM = SendspinImageSource.enum("ALBUM") +IMAGE_SOURCE_ARTIST = SendspinImageSource.enum("ARTIST") + # Library Structs AudioSupportedFormatObject = sendspin_library_ns.struct("AudioSupportedFormatObject") PlayerRoleConfig = sendspin_library_ns.struct("PlayerRoleConfig") +ArtworkRoleConfig = sendspin_library_ns.struct("ArtworkRoleConfig") +ImageSlotPreference = sendspin_library_ns.struct("ImageSlotPreference") # MemoryLocation enum (from sendspin/types.h) controls SPIRAM-vs-internal-RAM placement # preference for the player role's transfer buffers. @@ -76,6 +95,7 @@ class SendspinConfiguration: player_support: bool = False visualizer_support: bool = False + artwork_preferences: list[ConfigType] = field(default_factory=list) player_config: ConfigType | None = None @@ -110,6 +130,22 @@ def request_visualizer_support() -> None: _get_data().visualizer_support = True +def register_artwork_preference(config: ConfigType) -> int: + """Register an artwork slot preference and return the slot it was given. + + A slot is a preference's position in the list, which is also the order the roles are + advertised to the server in. + """ + request_artwork_support() + preferences = _get_data().artwork_preferences + if len(preferences) >= MAX_ARTWORK_SLOTS: + raise cv.Invalid( + f"Too many Sendspin image slots. Maximum is {MAX_ARTWORK_SLOTS}." + ) + preferences.append(config) + return len(preferences) - 1 + + def register_player_config(config: ConfigType) -> None: """Register the player role config from the media source subcomponent.""" data = _get_data() @@ -211,6 +247,29 @@ async def to_code(config: ConfigType) -> None: # and disable building unused code paths in the sendspin-cpp library (IDF SDKConfig via CONFIG_SENDSPIN_ENABLE_*). if data.artwork_support: cg.add_define("USE_SENDSPIN_ARTWORK", True) + + # require_frame_done is always on: SendspinImageSlot always acks a delivery, either + # immediately or from the transition_finished action. + preference_structs = [ + cg.StructInitializer( + ImageSlotPreference, + ("source", pref[CONF_SOURCE]), + ("format", pref[CONF_FORMAT]), + ("width", pref[CONF_WIDTH]), + ("height", pref[CONF_HEIGHT]), + ("require_frame_done", True), + ("display_offset_ms", pref[CONF_DISPLAY_OFFSET]), + ) + for pref in data.artwork_preferences + ] + + artwork_psram_stack = bool(config.get(CONF_TASK_STACK_IN_PSRAM)) + artwork_config = cg.StructInitializer( + ArtworkRoleConfig, + ("preferred_formats", preference_structs), + ("psram_stack", artwork_psram_stack), + ) + cg.add(var.set_artwork_config(artwork_config)) else: esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_ARTWORK", False) diff --git a/esphome/components/sendspin/image/__init__.py b/esphome/components/sendspin/image/__init__.py new file mode 100644 index 0000000000..94d6e7cfca --- /dev/null +++ b/esphome/components/sendspin/image/__init__.py @@ -0,0 +1,228 @@ +"""Sendspin image platform.""" + +from esphome import automation +import esphome.codegen as cg +from esphome.components import runtime_image +from esphome.components.image import CONF_TRANSPARENCY, Image_, add_metadata +import esphome.config_validation as cv +from esphome.const import ( + CONF_FORMAT, + CONF_HEIGHT, + CONF_ID, + CONF_RESIZE, + CONF_SOURCE, + CONF_TYPE, + CONF_WIDTH, +) +from esphome.core import ID +from esphome.cpp_generator import TemplateArgsType +from esphome.types import ConfigType + +from .. import ( + CONF_DISPLAY_OFFSET, + CONF_SENDSPIN_ID, + IMAGE_FORMAT_BMP, + IMAGE_FORMAT_JPEG, + IMAGE_FORMAT_PNG, + IMAGE_SOURCE_ALBUM, + IMAGE_SOURCE_ARTIST, + SendspinHub, + register_artwork_preference, + sendspin_ns, +) + +AUTO_LOAD = ["runtime_image"] +CODEOWNERS = ["@kahrendt"] +DEPENDENCIES = ["sendspin"] + +# runtime_image refuses to size a buffer beyond this, so anything larger fails at setup rather +# than at validation. The library's ImageSlotPreference width/height fields are uint16_t, which +# is the looser of the two bounds. +MAX_IMAGE_DIMENSION = 32767 + +# Sanity bound for display_offset; the library field is int32_t milliseconds and offsets beyond +# a few seconds around the track boundary are meaningless. +MAX_DISPLAY_OFFSET = cv.TimePeriod(seconds=60) +MIN_DISPLAY_OFFSET = cv.TimePeriod(seconds=-60) + +CONF_SLOT = "slot" +CONF_CURRENT_IMAGE = "current_image" +CONF_TRANSITION_IMAGE = "transition_image" +CONF_ON_IMAGE_DISPLAY = "on_image_display" +CONF_ON_IMAGE_CLEAR = "on_image_clear" +CONF_ON_IMAGE_ERROR = "on_image_error" + +# Map runtime_image's validated format string to the sendspin library's SendspinImageFormat enum. +# runtime_image accepts "JPG" as an alias for JPEG, so both keys map to the JPEG enum. +_FORMAT_TO_SENDSPIN_ENUM = { + "JPEG": IMAGE_FORMAT_JPEG, + "JPG": IMAGE_FORMAT_JPEG, + "PNG": IMAGE_FORMAT_PNG, + "BMP": IMAGE_FORMAT_BMP, +} + +# The library's SendspinImageSource::NONE is its internal "unset" sentinel; a slot advertising it +# would never receive artwork while still paying for two frame buffers, so it is not offered here. +IMAGE_SOURCES = { + "ALBUM": IMAGE_SOURCE_ALBUM, + "ARTIST": IMAGE_SOURCE_ARTIST, +} + +# The platform entry configures an artwork slot; the images it shows are declared inside it. The +# slot itself is the automation target (triggers and the transition_finished action). +SendspinImageSlot = sendspin_ns.class_( + "SendspinImageSlot", + cg.Component, + cg.Parented.template(SendspinHub), +) +ArtworkImageView = sendspin_ns.class_("ArtworkImageView", Image_) + +# A dict rather than a bare ID so per-image options can be added later without a new top-level key. +_IMAGE_SCHEMA = cv.Schema({cv.Required(CONF_ID): cv.declare_id(ArtworkImageView)}) + +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_IMAGE_DISPLAY, + "add_on_image_display_callback", + [(cg.uint32, "lateness_ms")], + ), + automation.CallbackAutomation(CONF_ON_IMAGE_CLEAR, "add_on_image_clear_callback"), + automation.CallbackAutomation(CONF_ON_IMAGE_ERROR, "add_on_image_error_callback"), +) + + +def _assign_slot_and_register(config: ConfigType) -> ConfigType: + """Register the artwork preference with the hub and record the slot it was given.""" + width, height = config[CONF_RESIZE] + if width > MAX_IMAGE_DIMENSION or height > MAX_IMAGE_DIMENSION: + raise cv.Invalid( + f"'{CONF_RESIZE}' width and height must be {MAX_IMAGE_DIMENSION} or less", + path=[CONF_RESIZE], + ) + + config[CONF_SLOT] = register_artwork_preference( + { + CONF_SOURCE: config[CONF_SOURCE], + CONF_FORMAT: _FORMAT_TO_SENDSPIN_ENUM[config[CONF_FORMAT]], + CONF_WIDTH: width, + CONF_HEIGHT: height, + CONF_DISPLAY_OFFSET: config[CONF_DISPLAY_OFFSET].total_milliseconds, + } + ) + return config + + +# The format, type, resize, transparency, byte order and placeholder keys all describe the slot: +# they set what is requested from the server and how it is decoded, not either individual image. +# Only the IDs are per-image, so runtime_image_schema declares the slot itself. +CONFIG_SCHEMA = cv.All( + runtime_image.runtime_image_schema(SendspinImageSlot).extend( + { + cv.GenerateID(): cv.declare_id(SendspinImageSlot), + cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), + # Narrow runtime_image's format list to what the library can request, so the + # accepted set and the enum map below cannot drift apart. + cv.Required(CONF_FORMAT): cv.one_of(*_FORMAT_TO_SENDSPIN_ENUM, upper=True), + cv.Required(CONF_RESIZE): cv.dimensions, + cv.Required(CONF_CURRENT_IMAGE): _IMAGE_SCHEMA, + cv.Optional(CONF_TRANSITION_IMAGE): _IMAGE_SCHEMA, + cv.Optional(CONF_SOURCE, default="ALBUM"): cv.enum( + IMAGE_SOURCES, upper=True + ), + # Positive fires on_image_display before the server's display timestamp (negative + # delays it), so a cross-fade can straddle the track boundary. + cv.Optional(CONF_DISPLAY_OFFSET, default="0ms"): cv.All( + cv.time_period, + # The library field is whole milliseconds; reject finer values rather than + # silently rounding them down to zero. + cv.time_period_in_milliseconds_, + cv.Range(min=MIN_DISPLAY_OFFSET, max=MAX_DISPLAY_OFFSET), + ), + cv.Optional(CONF_ON_IMAGE_DISPLAY): automation.validate_automation({}), + cv.Optional(CONF_ON_IMAGE_CLEAR): automation.validate_automation({}), + cv.Optional(CONF_ON_IMAGE_ERROR): automation.validate_automation({}), + } + ), + runtime_image.validate_runtime_image_settings, + cv.only_on_esp32, + _assign_slot_and_register, +) + + +async def to_code(config: ConfigType) -> None: + settings = await runtime_image.process_runtime_image_config(config) + + def make_view(view_id: ID) -> cg.MockObj: + # Views start with no frame; the slot points them at its buffers in setup(). The size is + # given up front so the view is well formed before then. LVGL picks it up from the first + # lvgl.image.update in on_image_display, not from the widget's initial src: at that point + # the view still has no frame, so its descriptor is empty. + view = cg.new_Pvariable( + view_id, + cg.nullptr, + settings.width, + settings.height, + settings.image_type_enum, + settings.transparent, + ) + add_metadata( + view_id, + settings.width, + settings.height, + config[CONF_TYPE], + config[CONF_TRANSPARENCY], + ) + return view + + current_image = make_view(config[CONF_CURRENT_IMAGE][CONF_ID]) + if settings.placeholder is not None: + cg.add(current_image.set_placeholder(settings.placeholder)) + + var = cg.new_Pvariable( + config[CONF_ID], + config[CONF_SLOT], + current_image, + settings.width, + settings.height, + settings.format_enum, + settings.image_type_enum, + settings.transparent, + settings.byte_order_big_endian, + ) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_SENDSPIN_ID]) + + if (transition_image := config.get(CONF_TRANSITION_IMAGE)) is not None: + cg.add(var.set_transition_image(make_view(transition_image[CONF_ID]))) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + + +SendspinImageTransitionFinishedAction = sendspin_ns.class_( + "SendspinImageTransitionFinishedAction", + automation.Action, + cg.Parented.template(SendspinImageSlot), +) + + +@automation.register_action( + "sendspin.image.transition_finished", + SendspinImageTransitionFinishedAction, + automation.maybe_simple_id( + cv.Schema( + { + cv.GenerateID(): cv.use_id(SendspinImageSlot), + } + ) + ), + synchronous=True, +) +async def sendspin_image_transition_finished_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> cg.MockObj: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var diff --git a/esphome/components/sendspin/image/automation.h b/esphome/components/sendspin/image/automation.h new file mode 100644 index 0000000000..154e62a4b2 --- /dev/null +++ b/esphome/components/sendspin/image/automation.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK) + +#include "esphome/core/automation.h" +#include "sendspin_image.h" + +namespace esphome::sendspin_ { + +template +class SendspinImageTransitionFinishedAction final : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->transition_finished(); } +}; + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/image/sendspin_image.cpp b/esphome/components/sendspin/image/sendspin_image.cpp new file mode 100644 index 0000000000..626d7966b7 --- /dev/null +++ b/esphome/components/sendspin/image/sendspin_image.cpp @@ -0,0 +1,261 @@ +#include "sendspin_image.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK) + +#include "esphome/core/log.h" + +#include + +namespace esphome::sendspin_ { + +static const char *const TAG = "sendspin.image"; + +// How long a displayed frame may wait for sendspin.image.transition_finished before a warning +// names the missing ack. Generous next to a typical fade of a second or two. +static constexpr uint32_t TRANSITION_ACK_WARNING_MS = 10000; + +// THREAD CONTEXT: Main loop. Children set up after the hub, so the artwork role already exists. +void SendspinImageSlot::setup() { + const size_t frame_size = this->decode_sink_.get_buffer_size(this->width_, this->height_); + if (frame_size == 0) { + // The sink would refuse a buffer of these dimensions, so every decode would fall back to + // allocating one of its own. Fail here instead, where the dimensions are already known. + ESP_LOGE(TAG, "Cannot decode artwork at %dx%d", this->width_, this->height_); + this->mark_failed(); + return; + } + + RAMAllocator allocator; + for (uint8_t *&buffer : this->buffers_) { + buffer = allocator.allocate(frame_size); + if (buffer == nullptr) { + ESP_LOGE(TAG, "Could not allocate %zu bytes for an artwork frame. Largest free block: %zu", frame_size, + allocator.get_max_free_block_size()); + for (uint8_t *&allocated : this->buffers_) { + allocator.deallocate(allocated, frame_size); + allocated = nullptr; + } + this->mark_failed(); + return; + } + // Both buffers start black, so a transition has something to fade from before any artwork + // has arrived. + memset(buffer, 0, frame_size); + } + + // Point both views at buffers_[current_index_] rather than the buffer the first decode writes + // into, so they name a frame that stays black until artwork arrives. + this->current_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_); + if (this->transition_image_ != nullptr) { + this->transition_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_); + } + + this->parent_->add_image_decode_callback( + [this](uint8_t slot, const uint8_t *data, size_t length, sendspin::SendspinImageFormat) { + if (slot == this->slot_) + this->on_decode_(data, length); + }); + this->parent_->add_image_display_callback([this](uint8_t slot, uint32_t lateness_ms) { + if (slot == this->slot_) + this->on_display_(lateness_ms); + }); + this->parent_->add_image_clear_callback([this](uint8_t slot) { + if (slot == this->slot_) + this->on_clear_(); + }); +} + +// THREAD CONTEXT: Dedicated artwork decode thread. The data pointer is valid only for this call. +void SendspinImageSlot::on_decode_(const uint8_t *data, size_t length) { + uint8_t *target; + { + // The lock makes the main loop's last swap of current_index_ visible here. The frame_done gate + // is what guarantees the buffer it picks out is not still needed by the main loop. + LockGuard lock(this->pending_mutex_); + target = this->buffers_[this->current_index_ ^ 1]; + } + + // The server letterboxes artwork onto a canvas of exactly the requested dimensions, so the sink + // is pinned to them: a decode that asks for anything else is a malformed payload and drops the + // frame. + if (!this->decode_sink_.set_external_buffer(target, this->width_, this->height_)) { + // setup() rules this out, but decoding without the handover would allocate a frame-sized + // buffer on this thread, which is exactly what the permanent buffers exist to avoid. + this->report_error_(); + return; + } + + const bool decoded = this->decode_frame_(data, length, target); + // Drops any half-finished decoder. An external buffer is let go of rather than freed, so this is + // safe on every path. + this->decode_sink_.release(); + + if (!decoded) { + // The buffer keeps whatever the failed decode painted into it, but no view names it while a + // decode can run, so nothing shows it. + this->report_error_(); + return; + } + + LockGuard lock(this->pending_mutex_); + this->frame_pending_ = true; +} + +// THREAD CONTEXT: Artwork decode thread, with target already handed to the sink. +bool SendspinImageSlot::decode_frame_(const uint8_t *data, size_t length, const uint8_t *target) { + if (!this->decode_sink_.begin_decode(length)) { + ESP_LOGE(TAG, "Could not start decode"); + return false; + } + + size_t total_consumed = 0; + while (total_consumed < length) { + int consumed = this->decode_sink_.feed_data(const_cast(data) + total_consumed, length - total_consumed); + if (consumed <= 0) { + // <0 is a decode error; 0 means the decoder cannot make progress (truncated/corrupt data). + ESP_LOGE(TAG, "Decode failed at offset %zu (result %d)", total_consumed, consumed); + return false; + } + total_consumed += consumed; + } + + if (!this->decode_sink_.end_decode()) { + ESP_LOGE(TAG, "Could not finalize decode"); + return false; + } + + // A decode that asked for other dimensions had the buffer taken away from it, so it painted + // nothing (or stopped partway). JPEG and BMP report that as an error above; PNG carries on + // regardless, so the frame is dropped here. + return this->decode_sink_.decoded_into(target); +} + +// THREAD CONTEXT: Main loop (fired once the slot's offset-shifted display deadline is reached). +void SendspinImageSlot::on_display_(uint32_t lateness_ms) { + bool frame_ready; + { + LockGuard lock(this->pending_mutex_); + frame_ready = this->frame_pending_; + this->frame_pending_ = false; + if (frame_ready) { + // The decoded frame becomes the current one; the frame it replaces becomes the outgoing + // frame, and the next decode target once the transition is acked. + this->current_index_ ^= 1; + } + } + if (!frame_ready) { + // The decode for this display failed, so there is nothing new to show. The delivery still owes + // its ack or the library would withhold every later frame for this slot. + this->parent_->artwork_frame_done(this->slot_); + return; + } + + // The frame this display replaces is only real artwork if something was already on screen. + const bool outgoing_is_artwork = this->showing_artwork_; + this->showing_artwork_ = true; + this->apply_frames_(outgoing_is_artwork); + + // Armed before the trigger fires so an automation that acks synchronously still counts, and armed + // for the first frame too so the contract stays uniform: one transition_finished per display. + this->transition_pending_ = this->transition_image_ != nullptr; + if (this->transition_pending_) { + // The library holds back further deliveries until the ack, with no timeout, so an automation + // that never reaches the action stalls the slot with nothing in the log. Name the cause after + // a generous wait. Arming again replaces the previous timeout, so it cannot fire for a frame + // that was already acked and superseded. + this->set_timeout("transition_ack", TRANSITION_ACK_WARNING_MS, [this]() { + if (this->transition_pending_) { + ESP_LOGW(TAG, + "Slot %u: displayed artwork was never acknowledged; no new artwork will arrive until " + "sendspin.image.transition_finished runs or the stream is cleared", + this->slot_); + } + }); + } + this->image_display_callback_.call(lateness_ms); + if (this->transition_image_ == nullptr) { + this->finish_transition_(); + } +} + +// THREAD CONTEXT: Main loop. +void SendspinImageSlot::finish_transition_() { + this->transition_pending_ = false; + if (this->transition_image_ != nullptr) { + // Move it off the buffer the next decode writes into. What it shows does not change: the + // buffer it moves to holds the artwork the transition just settled on. + this->transition_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_); + this->transition_image_->set_showing_artwork(this->showing_artwork_); + } + // The ack wakes the decode thread, which may start writing buffers_[current_index_ ^ 1] straight + // away, so nothing may still name that buffer by the time this runs. + this->parent_->artwork_frame_done(this->slot_); +} + +// THREAD CONTEXT: Main loop (invoked from the sendspin.image.transition_finished action). +void SendspinImageSlot::transition_finished() { + if (!this->transition_pending_) { + return; + } + this->finish_transition_(); +} + +// THREAD CONTEXT: Main loop (fired on stream end or clear for this slot). +void SendspinImageSlot::on_clear_() { + { + LockGuard lock(this->pending_mutex_); + // Drop a frame that was decoded but never displayed; its buffer stays the decode target. + this->frame_pending_ = false; + } + // No pixels are touched and the views keep naming the frames they had: a widget goes on drawing + // the last artwork until the automation points it elsewhere or hides it. Only the display lambda + // path stops drawing the artwork, falling back to the placeholder. + this->current_image_->set_showing_artwork(false); + if (this->transition_image_ != nullptr) { + // Point it away from the decode target, as at setup, so it cannot show a frame being decoded. + this->transition_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_); + this->transition_image_->set_showing_artwork(false); + } + this->showing_artwork_ = false; + // Drops a running transition. Its automation cannot be cancelled here, so a late + // transition_finished() can ack the next stream's first frame early, showing it without its + // transition. The ack count stays right. + this->transition_pending_ = false; + this->image_clear_callback_.call(); + // A clear is itself a delivery owing exactly one ack, and it supersedes any un-acked frame -- + // including one whose transition never signalled transition_finished(), so a stalled slot + // recovers here. + this->parent_->artwork_frame_done(this->slot_); +} + +// THREAD CONTEXT: Main loop. +void SendspinImageSlot::dump_config() { + ESP_LOGCONFIG(TAG, + "Artwork slot %u:\n" + " Dimensions: %dx%d\n" + " Frame buffers: 2 x %zu bytes\n" + " Transition image: %s", + this->slot_, this->width_, this->height_, + this->decode_sink_.get_buffer_size(this->width_, this->height_), + YESNO(this->transition_image_ != nullptr)); +} + +// THREAD CONTEXT: Main loop. +void SendspinImageSlot::apply_frames_(bool transition_is_artwork) { + this->current_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_); + this->current_image_->set_showing_artwork(true); + if (this->transition_image_ != nullptr) { + this->transition_image_->set_frame(this->buffers_[this->current_index_ ^ 1], this->width_, this->height_); + this->transition_image_->set_showing_artwork(transition_is_artwork); + } +} + +// THREAD CONTEXT: Artwork decode thread. Triggers must run on the main loop; defer() is thread-safe +// here because the hub enables wake_loop_threadsafe support. +void SendspinImageSlot::report_error_() { + this->defer([this]() { this->image_error_callback_.call(); }); +} + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/image/sendspin_image.h b/esphome/components/sendspin/image/sendspin_image.h new file mode 100644 index 0000000000..2f6f4e8a4d --- /dev/null +++ b/esphome/components/sendspin/image/sendspin_image.h @@ -0,0 +1,185 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK) + +#include "esphome/components/image/image.h" +#include "esphome/components/runtime_image/runtime_image.h" +#include "esphome/components/sendspin/sendspin_hub.h" + +#include "esphome/core/helpers.h" + +#include + +#include +#include + +namespace esphome::sendspin_ { + +/// @brief Decode-only RuntimeImage that decodes into a buffer owned by SendspinImageSlot. +/// +/// Runs exclusively on the sendspin library's artwork decode thread. RuntimeImage's decode path +/// overwrites the fields the display reads (data_start_/width_/height_), so it must never be the +/// object shown on screen. +class ArtworkDecodeSink : public runtime_image::RuntimeImage { + public: + using runtime_image::RuntimeImage::RuntimeImage; + + /// @brief True when the decode ended with the given buffer still in place. + /// + /// An external buffer is dropped rather than resized, so a decode that wanted other dimensions + /// leaves the sink holding nothing. The JPEG and BMP decoders report that as a decode error, but + /// the PNG decoder ignores it and reports success, so the outcome is checked here as well. + bool decoded_into(const uint8_t *buffer) const { return this->buffer_ == buffer; } +}; + +/// @brief A non-owning image::Image view over a buffer owned by SendspinImageSlot. +/// +/// Each slot publishes its frames through these: one for the artwork on screen, and optionally a +/// second for the outgoing frame during a cross-fade. A view always names a frame, black to begin +/// with, so LVGL can be given it as a widget source before any artwork exists. Main loop only. +class ArtworkImageView : public image::Image { + public: + using image::Image::Image; + + void set_frame(const uint8_t *data, int width, int height) { + this->data_start_ = data; + this->width_ = width; + this->height_ = height; +#ifdef USE_LVGL + // Keep the descriptor LVGL is handed in step with the frame. This does not redraw anything: + // only setting a widget's source invalidates it. + this->get_lv_image_dsc(); +#endif + } + + /// @brief Records whether the frame on show is real artwork rather than the black it starts as. + /// + /// Only changes what the display lambda path draws. The frame itself is left alone, so anything + /// reading the pixels directly (an LVGL widget) keeps drawing the last artwork until it is + /// pointed elsewhere. + void set_showing_artwork(bool showing_artwork) { this->showing_artwork_ = showing_artwork; } + + void set_placeholder(image::Image *placeholder) { this->placeholder_ = placeholder; } + + void draw(int x, int y, display::Display *display, Color color_on, Color color_off) override { + if (!this->showing_artwork_) { + // Nothing worth showing yet: the placeholder if there is one, otherwise leave the area be + // rather than paint a blank frame over it. + if (this->placeholder_ != nullptr) { + this->placeholder_->draw(x, y, display, color_on, color_off); + } + return; + } + image::Image::draw(x, y, display, color_on, color_off); + } + + protected: + image::Image *placeholder_{nullptr}; + bool showing_artwork_{false}; +}; + +/// @brief A single artwork slot: owns the frame buffers and publishes them to its image views. +/// +/// BUFFERS: two buffers, allocated zeroed at setup and never freed. One holds the frame the current +/// image shows; the other holds the outgoing frame a transition shows, and is where the next +/// artwork is decoded. Each display swaps their roles. +/// +/// THREADING: the sendspin library decodes on a dedicated thread and fires display/clear on the +/// main loop. Decoding runs into decode_sink_, which writes into the buffer the current image is +/// not showing; the swap that puts it on screen happens on the main loop. Every slot enables the +/// library's require_frame_done gate, which withholds further deliveries for the slot (buffering +/// the newest payload, latest wins) until the hub's artwork_frame_done() runs. That gate is what +/// makes two buffers enough: no decode starts while the main loop still needs the outgoing frame. +/// +/// LVGL: publishing a frame to a view updates the descriptor LVGL was handed but does not +/// invalidate the widget, so every widget's source must be set again on each display. +class SendspinImageSlot : public SendspinChild { + public: + SendspinImageSlot(uint8_t slot, ArtworkImageView *current_image, int width, int height, + runtime_image::ImageFormat format, image::ImageType type, image::Transparency transparency, + bool is_big_endian) + : decode_sink_(format, type, transparency, nullptr, is_big_endian, width, height), + current_image_(current_image), + width_(width), + height_(height), + slot_(slot) {} + + void setup() override; + void dump_config() override; + + template void add_on_image_display_callback(F &&callback) { + this->image_display_callback_.add(std::forward(callback)); + } + template void add_on_image_clear_callback(F &&callback) { + this->image_clear_callback_.add(std::forward(callback)); + } + template void add_on_image_error_callback(F &&callback) { + this->image_error_callback_.add(std::forward(callback)); + } + + /// @brief Sets the optional view a transition draws the outgoing artwork from. + /// + /// It holds the outgoing frame while a transition is running and the current frame at any other + /// time, so it always names a picture and never the frame being decoded. + /// + /// Setting it is also what defers the library ack to transition_finished(): the ack releases the + /// outgoing frame to be decoded over, and this view is the only thing that still names it. + void set_transition_image(ArtworkImageView *transition_image) { this->transition_image_ = transition_image; } + + /// @brief Signals that the display transition for the last frame has finished. + /// + /// Acks the library so the next artwork can be delivered, which also hands the outgoing frame's + /// buffer over to be decoded into. Safe no-op when no transition is pending (e.g. no transition + /// image is configured, a clear already ended the transition, or the call is a duplicate). Must + /// run on the main loop thread; exposed as the sendspin.image.transition_finished action. + void transition_finished(); + + protected: + void on_decode_(const uint8_t *data, size_t length); + bool decode_frame_(const uint8_t *data, size_t length, const uint8_t *target); + void on_display_(uint32_t lateness_ms); + void on_clear_(); + void finish_transition_(); + void apply_frames_(bool transition_is_artwork); + void report_error_(); + + ArtworkDecodeSink decode_sink_; + + // The two frame buffers, allocated in setup() and never freed. Their contents are written on the + // decode thread and read by whatever draws the views, so only their roles are swapped, never the + // pointers themselves. + std::array buffers_{}; + + // pending_mutex_ guards the two fields below, the only state shared across threads. Everything + // after them is touched on the main loop only. + Mutex pending_mutex_; + // Index into buffers_ of the frame the current image shows. buffers_[current_index_ ^ 1] holds + // the outgoing frame and is the next decode target. Written on the main loop, read on the + // decode thread. + uint8_t current_index_{0}; + // Set on the decode thread once a frame is waiting in buffers_[current_index_ ^ 1]. + bool frame_pending_{false}; + + // True once artwork has been displayed, until the next clear; decides whether the outgoing frame + // is real artwork or the black the buffers start as. Main loop only. + bool showing_artwork_{false}; + // True while a displayed frame awaits transition_finished(); gates duplicate or stray calls + // so exactly one ack reaches the library per delivery. Main loop only. + bool transition_pending_{false}; + + ArtworkImageView *current_image_; + ArtworkImageView *transition_image_{nullptr}; + int width_; + int height_; + uint8_t slot_; + + LazyCallbackManager image_display_callback_{}; + LazyCallbackManager image_clear_callback_{}; + LazyCallbackManager image_error_callback_{}; +}; + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 04dbab0080..2d2f646382 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -21,6 +21,12 @@ namespace esphome::sendspin_ { static const char *const TAG = "sendspin.hub"; +#ifdef USE_SENDSPIN_ARTWORK +// Indexed by the library enums, which start at zero and are contiguous. +static const char *const IMAGE_SOURCE_NAMES[] = {"ALBUM", "ARTIST", "NONE"}; +static const char *const IMAGE_FORMAT_NAMES[] = {"JPEG", "PNG", "BMP"}; +#endif + void SendspinHub::setup() { auto config = this->build_client_config_(); this->client_ = std::make_unique(std::move(config)); @@ -37,6 +43,11 @@ void SendspinHub::setup() { this->client_->set_network_provider(this); this->client_->set_persistence_provider(this); +#ifdef USE_SENDSPIN_ARTWORK + this->artwork_role_ = &this->client_->add_artwork(this->artwork_config_); + this->artwork_role_->set_listener(this); +#endif + #ifdef USE_SENDSPIN_CONTROLLER this->controller_role_ = &this->client_->add_controller(); this->controller_role_->set_listener(this); @@ -67,6 +78,18 @@ void SendspinHub::dump_config() { " Client ID: %s\n" " Task stack in PSRAM: %s", get_client_id_into_buffer(mac_buf), YESNO(this->task_stack_in_psram_)); + +#ifdef USE_SENDSPIN_ARTWORK + // Slot indices come from the order the image platform entries were declared, so the log is the + // only place the mapping from a slot to the artwork it asked for can be read back. + uint8_t slot = 0; + for (const auto &preference : this->artwork_config_.preferred_formats) { + ESP_LOGCONFIG(TAG, " Artwork slot %u: %s as %s, %ux%u, display offset %" PRId32 " ms", slot++, + IMAGE_SOURCE_NAMES[static_cast(preference.source)], + IMAGE_FORMAT_NAMES[static_cast(preference.format)], preference.width, preference.height, + preference.display_offset_ms); + } +#endif } // --- Delegating methods --- @@ -174,6 +197,30 @@ std::optional SendspinHub::load_last_server_hash() { // --- Sendspin role specific methods/overrides --- +#ifdef USE_SENDSPIN_ARTWORK +// THREAD CONTEXT: Dedicated artwork decode thread; downstream callbacks run here too +void SendspinHub::on_image_decode(uint8_t slot, const uint8_t *data, size_t length, + sendspin::SendspinImageFormat format) { + this->artwork_image_decode_callbacks_.call(slot, data, length, format); +} + +// THREAD CONTEXT: Main loop (fired from client_->loop() once the slot's offset-shifted display +// deadline is reached; lateness_ms reports how far past the deadline the display slipped) +void SendspinHub::on_image_display(uint8_t slot, uint32_t lateness_ms) { + this->artwork_image_display_callbacks_.call(slot, lateness_ms); +} + +// THREAD CONTEXT: Main loop (fired from client_->loop()) +void SendspinHub::on_image_clear(uint8_t slot) { this->artwork_image_clear_callbacks_.call(slot); } + +// THREAD CONTEXT: Main loop (invoked from SendspinImageSlot once a delivery is fully presented) +void SendspinHub::artwork_frame_done(uint8_t slot) { + if (this->artwork_role_ != nullptr) { + this->artwork_role_->frame_done(slot); + } +} +#endif + #ifdef USE_SENDSPIN_CONTROLLER // THREAD CONTEXT: Main loop (invoked from ESPHome actions / other components) void SendspinHub::send_client_command(sendspin::SendspinControllerCommand command, std::optional volume, diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index c6b1ed97f7..a495fdcf37 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -13,6 +13,9 @@ #include #include +#ifdef USE_SENDSPIN_ARTWORK +#include +#endif #ifdef USE_SENDSPIN_CONTROLLER #include #endif @@ -69,6 +72,9 @@ struct StaticDelayPref { /// (for services the library pulls; e.g., persistence, network readiness). /// - User -> library communication uses exposed functions on the client and role objects that the user calls. class SendspinHub final : public Component, +#ifdef USE_SENDSPIN_ARTWORK + public sendspin::ArtworkRoleListener, +#endif #ifdef USE_SENDSPIN_CONTROLLER public sendspin::ControllerRoleListener, #endif @@ -121,6 +127,27 @@ class SendspinHub final : public Component, // --- Sendspin role specific methods --- +#ifdef USE_SENDSPIN_ARTWORK + void set_artwork_config(const sendspin::ArtworkRoleConfig &config) { this->artwork_config_ = config; } + + /// @brief Acknowledges the most recent artwork delivery (display or clear) for a slot. + /// + /// Every slot is configured with the library's require_frame_done gate, which withholds the + /// next delivery for the slot until this is called. Exactly one ack is owed per delivery; a + /// redundant call is a safe no-op in the library. Must be called from the main loop thread. + void artwork_frame_done(uint8_t slot); + + template void add_image_decode_callback(F &&callback) { + this->artwork_image_decode_callbacks_.add(std::forward(callback)); + } + template void add_image_display_callback(F &&callback) { + this->artwork_image_display_callbacks_.add(std::forward(callback)); + } + template void add_image_clear_callback(F &&callback) { + this->artwork_image_clear_callbacks_.add(std::forward(callback)); + } +#endif + #ifdef USE_SENDSPIN_CONTROLLER void send_client_command(sendspin::SendspinControllerCommand command, std::optional volume = std::nullopt, std::optional mute = std::nullopt); @@ -171,6 +198,23 @@ class SendspinHub final : public Component, // --- Sendspin role specific methods/overrides/member variables --- +#ifdef USE_SENDSPIN_ARTWORK + void on_image_decode(uint8_t slot, const uint8_t *data, size_t length, sendspin::SendspinImageFormat format) override; + + void on_image_display(uint8_t slot, uint32_t lateness_ms) override; + + void on_image_clear(uint8_t slot) override; + + sendspin::ArtworkRoleConfig artwork_config_{}; + sendspin::ArtworkRole *artwork_role_{nullptr}; + + // Callback fan-out to child components; they filter by slot as needed. + CallbackManager + artwork_image_decode_callbacks_{}; + CallbackManager artwork_image_display_callbacks_{}; + CallbackManager artwork_image_clear_callbacks_{}; +#endif + #ifdef USE_SENDSPIN_CONTROLLER sendspin::ControllerRole *controller_role_{nullptr}; diff --git a/tests/component_tests/sendspin/__init__.py b/tests/component_tests/sendspin/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/sendspin/test_image.py b/tests/component_tests/sendspin/test_image.py new file mode 100644 index 0000000000..be3b7d6684 --- /dev/null +++ b/tests/component_tests/sendspin/test_image.py @@ -0,0 +1,114 @@ +"""Validation tests for the sendspin image platform. + +These cover the rejection branches, which a compile test cannot reach: a +`test*.yaml` can only assert that a configuration is accepted. +""" + +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.sendspin import IMAGE_FORMAT_JPEG, MAX_ARTWORK_SLOTS, _get_data +from esphome.components.sendspin.image import CONFIG_SCHEMA, MAX_IMAGE_DIMENSION +from esphome.const import PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _slot_config(**overrides: Any) -> ConfigType: + """Build a minimal valid artwork slot config, allowing field overrides.""" + config: ConfigType = { + "id": "album_slot", + "format": "JPEG", + "type": "RGB565", + "resize": "240x240", + "current_image": {"id": "album_art"}, + } + config.update(overrides) + return config + + +def test_minimal_config_is_accepted(set_core_config: SetCoreConfigCallable) -> None: + """The baseline the rejection tests vary is itself valid.""" + set_core_config(PlatformFramework.ESP32_IDF) + + config = CONFIG_SCHEMA(_slot_config()) + + assert config["slot"] == 0 + assert config["source"] == "ALBUM" + assert config["display_offset"].total_milliseconds == 0 + + +@pytest.mark.parametrize("image_format", ["JPEG", "JPG"]) +def test_jpeg_alias_maps_to_one_enum( + set_core_config: SetCoreConfigCallable, image_format: str +) -> None: + """runtime_image takes JPG as an alias for JPEG, so both spellings must reach the + library's single JPEG enum.""" + set_core_config(PlatformFramework.ESP32_IDF) + + CONFIG_SCHEMA(_slot_config(format=image_format)) + + assert _get_data().artwork_preferences[0]["format"] == IMAGE_FORMAT_JPEG + + +def test_too_many_slots_rejected(set_core_config: SetCoreConfigCallable) -> None: + """Slot numbers run out after MAX_ARTWORK_SLOTS entries.""" + set_core_config(PlatformFramework.ESP32_IDF) + + for slot in range(MAX_ARTWORK_SLOTS): + assert CONFIG_SCHEMA(_slot_config(id=f"slot_{slot}"))["slot"] == slot + + with pytest.raises(cv.Invalid, match="Too many Sendspin image slots"): + CONFIG_SCHEMA(_slot_config(id="one_too_many")) + + +@pytest.mark.parametrize( + "resize", + [f"{MAX_IMAGE_DIMENSION + 1}x240", f"240x{MAX_IMAGE_DIMENSION + 1}"], +) +def test_oversized_resize_rejected( + set_core_config: SetCoreConfigCallable, resize: str +) -> None: + """Either dimension past the decoder's limit is refused.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match=f"must be {MAX_IMAGE_DIMENSION} or less"): + CONFIG_SCHEMA(_slot_config(resize=resize)) + + +def test_sub_millisecond_display_offset_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """The library field is whole milliseconds, so finer values are refused + rather than silently rounded down to zero.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="Maximum precision is milliseconds"): + CONFIG_SCHEMA(_slot_config(display_offset="500us")) + + +@pytest.mark.parametrize("display_offset", ["61s", "-61s"]) +def test_out_of_range_display_offset_rejected( + set_core_config: SetCoreConfigCallable, display_offset: str +) -> None: + """Offsets more than a minute either side of the boundary are refused.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="value must be at (most|least)"): + CONFIG_SCHEMA(_slot_config(display_offset=display_offset)) + + +@pytest.mark.parametrize( + ("display_offset", "expected_ms"), [("250ms", 250), ("-2s", -2000)] +) +def test_display_offset_accepted( + set_core_config: SetCoreConfigCallable, display_offset: str, expected_ms: int +) -> None: + """Whole-millisecond offsets pass through in both directions.""" + set_core_config(PlatformFramework.ESP32_IDF) + + config = CONFIG_SCHEMA(_slot_config(display_offset=display_offset)) + + assert config["display_offset"].total_milliseconds == expected_ms diff --git a/tests/components/sendspin/common-image.yaml b/tests/components/sendspin/common-image.yaml new file mode 100644 index 0000000000..7c32a5e257 --- /dev/null +++ b/tests/components/sendspin/common-image.yaml @@ -0,0 +1,47 @@ +packages: + sendspin: !include common.yaml + +display: + - platform: ili9xxx + spi_id: spi_bus + id: main_lcd + model: ili9342 + cs_pin: 20 + dc_pin: 13 + reset_pin: 21 + invert_colors: true + lambda: |- + it.fill(Color(0, 0, 0)); + it.image(0, 0, id(album_art)); + +image: + - platform: sendspin + id: album_slot + format: JPEG + type: RGB565 + resize: 240x240 + source: ALBUM + current_image: + id: album_art + transition_image: + id: album_art_transition + on_image_display: + - logger.log: + format: "Album art displayed (late by %u ms)" + args: ["(unsigned) lateness_ms"] + # Stand-in for a display transition; with a transition image every display must end + # with transition_finished so the library releases the next artwork frame. + - delay: 300ms + - sendspin.image.transition_finished: album_slot + on_image_clear: + - logger.log: "Album art cleared" + on_image_error: + - logger.log: "Album art error" + - platform: sendspin + id: artist_slot + format: PNG + type: RGB565 + resize: 96x96 + source: ARTIST + current_image: + id: artist_art diff --git a/tests/components/sendspin/test-image-lvgl.esp32-idf.yaml b/tests/components/sendspin/test-image-lvgl.esp32-idf.yaml new file mode 100644 index 0000000000..9084d77262 --- /dev/null +++ b/tests/components/sendspin/test-image-lvgl.esp32-idf.yaml @@ -0,0 +1,66 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + sendspin: !include common.yaml + +display: + - platform: ili9xxx + spi_id: spi_bus + id: main_lcd + model: ili9342 + cs_pin: 20 + dc_pin: 13 + reset_pin: 21 + invert_colors: true + auto_clear_enabled: false + +lvgl: + displays: + - main_lcd + animations: + # Fades the top widget out to reveal the new artwork underneath. Starting it also snaps the + # widget back to full opacity, and on_stop acks the transition so the library can deliver the + # next artwork. + - id: album_art_crossfade + duration: 2s + widgets: + - id: outgoing_art + opa: + from: 100% + to: 0% + on_stop: + - sendspin.image.transition_finished: album_slot + widgets: + # Cross-fade pair: the bottom widget always shows the current artwork; the top widget is + # pointed at the outgoing frame on each display event and faded out over it. + - image: + id: incoming_art + src: album_art + - image: + id: outgoing_art + src: album_art + +image: + - platform: sendspin + id: album_slot + format: JPEG + type: RGB565 + resize: 240x240 + source: ALBUM + # Start the fade 1s before the track boundary so the 2s cross-fade straddles it. + display_offset: 1s + current_image: + id: album_art + transition_image: + id: album_art_transition + on_image_display: + # A widget keeps drawing the buffer it was last pointed at until its source is set again, so + # both widgets are re-pointed on every display: the top widget at the outgoing frame + # (covering the bottom), the bottom widget at the new frame. The transition image is black + # before the first artwork, so the first fade needs no special case. + - lvgl.image.update: + id: outgoing_art + src: album_art_transition + - lvgl.image.update: + id: incoming_art + src: album_art + - lvgl.animation.start: album_art_crossfade diff --git a/tests/components/sendspin/test-image.esp32-idf.yaml b/tests/components/sendspin/test-image.esp32-idf.yaml new file mode 100644 index 0000000000..a4f9e492c6 --- /dev/null +++ b/tests/components/sendspin/test-image.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + sendspin: !include common-image.yaml From e0b112c584ed91b799308840f935f139c11e4a86 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 11 Aug 2026 17:03:27 -0400 Subject: [PATCH 1402/1815] [sendspin] Clear metadata and controller state on disconnect (#18289) Co-authored-by: J. Nick Koston --- .../media_player/sendspin_media_player.cpp | 22 ++++++++++--- .../media_player/sendspin_media_player.h | 3 ++ esphome/components/sendspin/sendspin_hub.cpp | 12 +++++++ esphome/components/sendspin/sendspin_hub.h | 19 +++++++++-- .../sendspin/sensor/sendspin_sensor.cpp | 26 ++++++++++++--- .../text_sensor/sendspin_text_sensor.cpp | 32 +++++++++---------- 6 files changed, 87 insertions(+), 27 deletions(-) diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.cpp b/esphome/components/sendspin/media_player/sendspin_media_player.cpp index beb2028689..fe0bda6f42 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.cpp +++ b/esphome/components/sendspin/media_player/sendspin_media_player.cpp @@ -34,11 +34,7 @@ void SendspinMediaPlayer::setup() { new_state = media_player::MEDIA_PLAYER_STATE_IDLE; break; } - if (this->state != new_state) { - this->state = new_state; - this->publish_state(); - ESP_LOGD(TAG, "State changed to %s", media_player::media_player_state_to_string(this->state)); - } + this->set_playback_state_(new_state); } }); @@ -52,11 +48,27 @@ void SendspinMediaPlayer::setup() { } }); + // The connection dropped, so nothing is playing. The server never gets to send a final "stopped" group update, so + // without this the entity keeps reporting playing indefinitely. Volume and mute keep their last values, since + // media_player has no way to express an unknown volume. + this->parent_->add_controller_state_clear_callback( + [this]() { this->set_playback_state_(media_player::MEDIA_PLAYER_STATE_IDLE); }); + // Publish an initial state this->state = media_player::MEDIA_PLAYER_STATE_IDLE; this->publish_state(); } +// THREAD CONTEXT: Main loop (called from the callbacks registered in setup()) +void SendspinMediaPlayer::set_playback_state_(media_player::MediaPlayerState new_state) { + if (this->state == new_state) { + return; + } + this->state = new_state; + this->publish_state(); + ESP_LOGD(TAG, "State changed to %s", media_player::media_player_state_to_string(this->state)); +} + // THREAD CONTEXT: Main loop (invoked by the media_player framework) media_player::MediaPlayerTraits SendspinMediaPlayer::get_traits() { auto traits = media_player::MediaPlayerTraits(); diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.h b/esphome/components/sendspin/media_player/sendspin_media_player.h index 651e1562be..ff76473189 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.h +++ b/esphome/components/sendspin/media_player/sendspin_media_player.h @@ -25,6 +25,9 @@ class SendspinMediaPlayer final : public SendspinChild, public media_player::Med // Receives commands from HA void control(const media_player::MediaPlayerCall &call) override; + /// @brief Publishes @p new_state if it differs from the current state. + void set_playback_state_(media_player::MediaPlayerState new_state); + float volume_increment_{0.05f}; bool muted_{false}; }; diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 2d2f646382..028491284a 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -239,6 +239,12 @@ void SendspinHub::send_client_command(sendspin::SendspinControllerCommand comman void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObject &state) { this->controller_state_callbacks_.call(state); } + +// THREAD CONTEXT: Main loop (ControllerRoleListener override, fired from client_->loop()) +// Unlike metadata, this cannot be fanned out as a default-constructed state object: volume and muted are plain values +// rather than optionals, so children would read a real-looking 0% volume where we mean no value at all. A separate +// callback lets each child clear only what it can represent. +void SendspinHub::on_controller_state_clear() { this->controller_state_clear_callbacks_.call(); } #endif #ifdef USE_SENDSPIN_METADATA @@ -247,6 +253,12 @@ void SendspinHub::on_metadata(const sendspin::ServerMetadataStateObject &metadat this->metadata_update_callbacks_.call(metadata); } +// THREAD CONTEXT: Main loop (MetadataRoleListener override, fired from client_->loop()) +// The cached metadata was dropped because the connection to the server was lost, so what the children now mirror is +// the empty state. Fanning that out as a default-constructed state object rather than through a separate callback +// keeps one code path in the children: every field is nullopt, which they already publish as empty/unknown. +void SendspinHub::on_metadata_clear() { this->metadata_update_callbacks_.call(sendspin::ServerMetadataStateObject{}); } + // THREAD CONTEXT: Main loop (invoked from Sendspin components) uint32_t SendspinHub::get_track_progress_ms() const { if (this->is_ready()) { diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index a495fdcf37..7c50c3eb80 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -155,9 +155,18 @@ class SendspinHub final : public Component, template void add_controller_state_callback(F &&callback) { this->controller_state_callbacks_.add(std::forward(callback)); } + + /// @brief Registers a callback that fires when the connection is lost and the cached controller state is dropped. + template void add_controller_state_clear_callback(F &&callback) { + this->controller_state_clear_callbacks_.add(std::forward(callback)); + } #endif #ifdef USE_SENDSPIN_METADATA + /// @brief Registers a callback that fires when the server sends metadata. + /// + /// Also fires when the connection is lost, with an all-empty state object (every field nullopt, timestamp 0) meaning + /// the cached metadata was dropped. Subscribers must treat an absent field as cleared, not as no update. template void add_metadata_update_callback(F &&callback) { this->metadata_update_callbacks_.add(std::forward(callback)); } @@ -220,8 +229,12 @@ class SendspinHub final : public Component, void on_controller_state(const sendspin::ServerStateControllerObject &state) override; - // Callback fan-out to child components; they filter as needed - CallbackManager controller_state_callbacks_{}; + void on_controller_state_clear() override; + + // Callback fan-out to child components; they filter as needed. Only a media_player subscribes, while the switch + // action and the media source enable the controller role without one, so keep the idle cost to a single pointer. + LazyCallbackManager controller_state_callbacks_{}; + LazyCallbackManager controller_state_clear_callbacks_{}; #endif #ifdef USE_SENDSPIN_METADATA @@ -229,6 +242,8 @@ class SendspinHub final : public Component, void on_metadata(const sendspin::ServerMetadataStateObject &metadata) override; + void on_metadata_clear() override; + // Callback fan-out to child components; they filter as needed CallbackManager metadata_update_callbacks_{}; #endif diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.cpp b/esphome/components/sendspin/sensor/sendspin_sensor.cpp index 68848a6f3e..dcbab75b65 100644 --- a/esphome/components/sendspin/sensor/sendspin_sensor.cpp +++ b/esphome/components/sendspin/sensor/sendspin_sensor.cpp @@ -4,6 +4,8 @@ #include +#include + namespace esphome::sendspin_ { static const char *const TAG = "sendspin.sensor"; @@ -20,6 +22,13 @@ void SendspinTrackProgressSensor::dump_config() { void SendspinTrackProgressSensor::setup() { this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { if (!metadata.progress.has_value()) { + // Progress is unknown: the server has not reported it, or it was cleared (e.g. on disconnect). Stop polling and + // report unknown rather than leaving the last position frozen on the frontend. Only the transition is published; + // NAN never compares equal to itself, so an unguarded publish would repeat on every metadata update. + this->stop_poller(); + if (!std::isnan(this->get_raw_state())) { + this->publish_state(NAN); + } return; } const auto &progress = metadata.progress.value(); @@ -34,6 +43,11 @@ void SendspinTrackProgressSensor::setup() { this->start_poller(); } }); + + // PollingComponent starts the poller before setup(), but there is nothing to interpolate yet: + // get_track_progress_ms() returns 0 until the server reports a position, so polling now would publish 0 every tick + // from boot until the first metadata arrives. The callback above starts it once playback is running. + this->stop_poller(); } // THREAD CONTEXT: Main loop. @@ -80,15 +94,19 @@ std::optional SendspinMetadataSensor::extract_value_(const sendspin::Serv // (SendspinHub dispatches metadata from client_->loop()). void SendspinMetadataSensor::setup() { this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { - if (auto value = this->extract_value_(metadata)) { - this->publish_if_changed_(*value); - } + // A field the server has not provided, or has explicitly cleared, is published as NAN (the sensor convention for + // unknown) rather than skipped, so a value that goes away does not linger from the previous track. + this->publish_if_changed_(this->extract_value_(metadata).value_or(NAN)); }); } // Dedup to avoid frontend churn; Sensor::publish_state always notifies without checking for changes. void SendspinMetadataSensor::publish_if_changed_(float value) { - if (this->get_raw_state() != value) { + const float current = this->get_raw_state(); + // The raw state starts as NAN, so a field that is already cleared when the first update arrives is suppressed here + // as well: the frontend still shows the sensor as unknown, which is what a clear means. NAN never compares equal to + // itself, so a field that stays cleared would republish on every metadata update without the second check. + if (current != value && !(std::isnan(current) && std::isnan(value))) { this->publish_state(value); } } diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp index 9843fb966e..554e01cf88 100644 --- a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp @@ -12,40 +12,40 @@ static const char *const TAG = "sendspin.text_sensor"; void SendspinTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Sendspin", this); } +// A field is nullopt when the server has not provided it or has explicitly cleared it. Both mean there is nothing to +// show, so return the empty string and let the caller publish it; returning early would leave the previous track's +// value on display. +// +// The empty string is not the same as unknown. A text sensor reports unknown through the API's missing_state flag, +// which follows has_state(), and has_state() is only ever set, never cleared. Once a real value has been published, +// an empty state is the closest we can get. The numeric sensors publish NAN, which does read as unknown. const char *SendspinTextSensor::extract_value_(const sendspin::ServerMetadataStateObject &metadata) const { switch (this->metadata_type_) { case SendspinTextMetadataTypes::TITLE: - if (metadata.title.has_value()) - return metadata.title.value().c_str(); - return nullptr; + return metadata.title.has_value() ? metadata.title.value().c_str() : ""; case SendspinTextMetadataTypes::ARTIST: - if (metadata.artist.has_value()) - return metadata.artist.value().c_str(); - return nullptr; + return metadata.artist.has_value() ? metadata.artist.value().c_str() : ""; case SendspinTextMetadataTypes::ALBUM: - if (metadata.album.has_value()) - return metadata.album.value().c_str(); - return nullptr; + return metadata.album.has_value() ? metadata.album.value().c_str() : ""; case SendspinTextMetadataTypes::ALBUM_ARTIST: - if (metadata.album_artist.has_value()) - return metadata.album_artist.value().c_str(); - return nullptr; + return metadata.album_artist.has_value() ? metadata.album_artist.value().c_str() : ""; } - return nullptr; + return ""; } // THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop // (SendspinHub dispatches metadata from client_->loop()). void SendspinTextSensor::setup() { this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { - if (const char *value = this->extract_value_(metadata)) { - this->publish_if_changed_(value); - } + this->publish_if_changed_(this->extract_value_(metadata)); }); } // Dedup to avoid frontend churn; TextSensor::publish_state already dedups the string assign but still notifies. void SendspinTextSensor::publish_if_changed_(const char *value) { + // The state starts empty, so a field that is already cleared when the first update arrives is suppressed here: the + // entity stays unknown rather than being dropped out of it for good by an empty publish. Later clears do publish the + // empty string and fire on_value with it. if (this->get_raw_state() != value) { this->publish_state(value); } From 3540012529473103e8c684688d4061107b2bbca4 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 11 Aug 2026 16:09:51 -0700 Subject: [PATCH 1403/1815] [tests] Build tests from the tree they run in, not the venv's editable install (#18248) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- pyproject.toml | 3 +++ tests/integration/conftest.py | 9 ++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 166b3cf6bb..afa6208cae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,9 @@ include = ["esphome*"] testpaths = [ "tests", ] +# Prepend the repo root so in-process esphome imports resolve to THIS tree, +# not wherever the venv's editable install points (e.g. another git worktree). +pythonpath = ["."] addopts = [ "--cov=esphome", "--cov-branch", diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index a9c9e0686f..1bf799b658 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -63,6 +63,11 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]: env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps") # Prevent cache cleaning during integration tests env["ESPHOME_SKIP_CLEAN_BUILD"] = "1" + # Compile with THIS tree's esphome sources, not wherever the venv's editable + # install points (which may be a different git worktree or checkout). + repo_root = str(Path(__file__).resolve().parent.parent.parent) + existing = env.get("PYTHONPATH") + env["PYTHONPATH"] = f"{repo_root}{os.pathsep}{existing}" if existing else repo_root return env @@ -101,7 +106,7 @@ def shared_platformio_cache() -> Generator[Path]: env = _get_platformio_env(cache_dir) subprocess.run( - ["esphome", "compile", str(config_path)], + [sys.executable, "-m", "esphome", "compile", str(config_path)], check=True, cwd=init_dir, env=env, @@ -245,6 +250,8 @@ async def compile_esphome( for attempt in range(max_retries): # Compile using subprocess, inheriting stdout/stderr to show progress proc = await asyncio.create_subprocess_exec( + sys.executable, + "-m", "esphome", "compile", str(config_path), From 9556c2bc4c4c77871a2e48d631a2f78a452c7232 Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Wed, 12 Aug 2026 01:39:05 +0200 Subject: [PATCH 1404/1815] [mitsubishi_cn105] Add vertical vane control action (#16737) Co-authored-by: J. Nick Koston --- .../components/mitsubishi_cn105/__init__.py | 99 ++++++++++++++++++- .../components/mitsubishi_cn105/automation.h | 19 ++++ .../mitsubishi_cn105_component.cpp | 7 ++ .../mitsubishi_cn105_component.h | 32 +++++- .../mitsubishi_cn105/select/__init__.py | 6 +- .../mitsubishi_cn105_vane_select_vertical.cpp | 2 +- tests/components/mitsubishi_cn105/common.h | 1 + tests/components/mitsubishi_cn105/common.yaml | 8 ++ .../mitsubishi_cn105_component_tests.cpp | 33 ++++++- 9 files changed, 190 insertions(+), 17 deletions(-) diff --git a/esphome/components/mitsubishi_cn105/__init__.py b/esphome/components/mitsubishi_cn105/__init__.py index 70ed0a7a85..450d1cd222 100644 --- a/esphome/components/mitsubishi_cn105/__init__.py +++ b/esphome/components/mitsubishi_cn105/__init__.py @@ -2,9 +2,15 @@ from esphome import automation import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_ON_STATE, CONF_TEMPERATURE, CONF_UPDATE_INTERVAL -from esphome.core import ID -from esphome.cpp_generator import MockObj +from esphome.const import ( + CONF_DIRECTION, + CONF_ID, + CONF_ON_STATE, + CONF_TEMPERATURE, + CONF_UPDATE_INTERVAL, +) +from esphome.core import ID, Lambda +from esphome.cpp_generator import LambdaExpression, MockObj from esphome.types import ConfigType, TemplateArgsType CODEOWNERS = ["@crnjan"] @@ -14,6 +20,7 @@ DOMAIN = "mitsubishi_cn105" CONF_MITSUBISHI_CN105_ID = f"{DOMAIN}_id" CONF_TELEMETRY_REQUEST_MIN_INTERVAL = "telemetry_request_min_interval" CONF_VANE = "vane" +CONF_VERTICAL = "vertical" mitsubishi_ns = cg.esphome_ns.namespace(DOMAIN) @@ -24,6 +31,20 @@ MitsubishiCN105Component = mitsubishi_ns.class_( ) VaneState = mitsubishi_ns.struct("VaneState") +VaneCall = mitsubishi_ns.class_("VaneCall") +VerticalVaneMode = mitsubishi_ns.enum("VerticalVaneMode") + +# The insertion order must match VALUES in +# select/mitsubishi_cn105_vane_select_vertical.cpp. +VERTICAL_VANE_DIRECTIONS = { + "AUTO": VerticalVaneMode.VERTICAL_VANE_MODE_AUTO, + "1": VerticalVaneMode.VERTICAL_VANE_MODE_POSITION_1, + "2": VerticalVaneMode.VERTICAL_VANE_MODE_POSITION_2, + "3": VerticalVaneMode.VERTICAL_VANE_MODE_POSITION_3, + "4": VerticalVaneMode.VERTICAL_VANE_MODE_POSITION_4, + "5": VerticalVaneMode.VERTICAL_VANE_MODE_POSITION_5, + "SWING": VerticalVaneMode.VERTICAL_VANE_MODE_SWING, +} SetRemoteTemperatureAction = mitsubishi_ns.class_( "SetRemoteTemperatureAction", @@ -37,6 +58,11 @@ ClearRemoteTemperatureAction = mitsubishi_ns.class_( cg.Parented.template(MitsubishiCN105Component), ) +VaneControlAction = mitsubishi_ns.class_( + "VaneControlAction", + automation.Action, +) + CONFIG_SCHEMA = ( cv.Schema( { @@ -152,3 +178,70 @@ async def clear_temperature_action_to_code( var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var + + +VANE_CONTROL_FIELDS = ( + ( + (CONF_VERTICAL, CONF_DIRECTION), + "vertical.set_direction", + VerticalVaneMode, + ), +) + +VANE_CONTROL_ACTION_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Component), + cv.Optional(CONF_VERTICAL): cv.Schema( + { + cv.Optional(CONF_DIRECTION): cv.templatable( + cv.enum(VERTICAL_VANE_DIRECTIONS, upper=True) + ), + } + ), + } +) + + +@automation.register_action( + f"{DOMAIN}.vane.control", + VaneControlAction, + VANE_CONTROL_ACTION_SCHEMA, + synchronous=True, +) +async def vane_control_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + cg.add_global(mitsubishi_ns.using) + parent = await cg.get_variable(config[CONF_ID]) + normalized_args = [ + (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), name) + for t, name in args + ] + forwarded_args = ", ".join(name for _, name in args) + body_lines: list[str] = [] + + for path, setter, type_ in VANE_CONTROL_FIELDS: + if (section := config.get(path[0])) is None: + continue + if (value := section.get(path[1])) is None: + continue + if isinstance(value, Lambda): + inner = await cg.process_lambda( + value, + normalized_args, + return_type=type_, + ) + body_lines.append(f"call.{setter}(({inner})({forwarded_args}));") + else: + body_lines.append(f"call.{setter}({cg.safe_exp(value)});") + + apply_lambda = LambdaExpression( + ["\n".join(body_lines)], + [(VaneCall.operator("ref"), "call"), *normalized_args], + capture="", + return_type=cg.void, + ) + return cg.new_Pvariable(action_id, template_arg, parent, apply_lambda) diff --git a/esphome/components/mitsubishi_cn105/automation.h b/esphome/components/mitsubishi_cn105/automation.h index 879e556f9c..2fc6ba3c32 100644 --- a/esphome/components/mitsubishi_cn105/automation.h +++ b/esphome/components/mitsubishi_cn105/automation.h @@ -4,6 +4,8 @@ #include "esphome/core/automation.h" +#include + namespace esphome::mitsubishi_cn105 { template @@ -20,4 +22,21 @@ class ClearRemoteTemperatureAction : public Action, public Parentedparent_->clear_remote_temperature(); } }; +template class VaneControlAction : public Action { + public: + using ApplyFn = void (*)(VaneCall &, const std::remove_cvref_t &...); + + VaneControlAction(MitsubishiCN105Component *parent, ApplyFn apply) : parent_(parent), apply_(apply) {} + + void play(const Ts &...x) override { + auto call = this->parent_->make_vane_call(); + this->apply_(call, x...); + call.perform(); + } + + protected: + MitsubishiCN105Component *parent_; + ApplyFn apply_; +}; + } // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp index 5314965af6..8e9e954645 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp @@ -31,4 +31,11 @@ void MitsubishiCN105Component::loop() { } } +void VaneCall::perform() { + if (const auto &direction = this->vertical.get_direction(); direction.has_value()) { + this->parent_->set_vane_mode(static_cast(*direction)); + } + this->parent_->publish_status(); +} + } // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h index 64077432fd..6461fb464b 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h @@ -6,6 +6,7 @@ #include "esphome/components/uart/uart.h" #include +#include namespace esphome::mitsubishi_cn105 { @@ -17,6 +18,7 @@ enum VerticalVaneMode : uint8_t { VERTICAL_VANE_MODE_POSITION_4 = static_cast(MitsubishiCN105::VaneMode::POSITION_4), VERTICAL_VANE_MODE_POSITION_5 = static_cast(MitsubishiCN105::VaneMode::POSITION_5), VERTICAL_VANE_MODE_SWING = static_cast(MitsubishiCN105::VaneMode::SWING), + VERTICAL_VANE_MODE_UNKNOWN = static_cast(MitsubishiCN105::VaneMode::UNKNOWN), }; struct VaneState { @@ -27,6 +29,27 @@ struct VaneState { Vertical vertical; }; +class MitsubishiCN105Component; + +struct VaneCall { + struct Vertical { + void set_direction(VerticalVaneMode direction) { this->direction_ = direction; } + const std::optional &get_direction() const { return this->direction_; } + + protected: + std::optional direction_; + }; + + explicit VaneCall(MitsubishiCN105Component *parent) : parent_(parent) {} + + Vertical vertical; + + void perform(); + + protected: + MitsubishiCN105Component *parent_; +}; + class MitsubishiCN105Component : public Component, public uart::UARTDevice { public: explicit MitsubishiCN105Component() : hp_(*this) {} @@ -47,6 +70,7 @@ class MitsubishiCN105Component : public Component, public uart::UARTDevice { void set_fan_mode(MitsubishiCN105::FanMode fan_mode) { this->hp_.set_fan_mode(fan_mode); } void set_vane_mode(MitsubishiCN105::VaneMode vane_mode) { this->hp_.set_vane_mode(vane_mode); } void set_wide_vane_mode(MitsubishiCN105::WideVaneMode mode) { this->hp_.set_wide_vane_mode(mode); } + VaneCall make_vane_call() { return VaneCall(this); } const MitsubishiCN105::Status &status() const { return this->hp_.status(); } bool is_status_initialized() const { return this->hp_.is_status_initialized(); } @@ -69,11 +93,9 @@ class MitsubishiCN105Component : public Component, public uart::UARTDevice { protected: void notify_status_listeners_() { this->status_callback_.call(); - if (this->status().vane_mode != MitsubishiCN105::VaneMode::UNKNOWN) { - this->vane_state_callback_.call(VaneState{ - .vertical = {.direction = static_cast(this->status().vane_mode)}, - }); - } + this->vane_state_callback_.call(VaneState{ + .vertical = {.direction = static_cast(this->status().vane_mode)}, + }); } MitsubishiCN105 hp_; diff --git a/esphome/components/mitsubishi_cn105/select/__init__.py b/esphome/components/mitsubishi_cn105/select/__init__.py index a2e0353f85..4ca12edbb4 100644 --- a/esphome/components/mitsubishi_cn105/select/__init__.py +++ b/esphome/components/mitsubishi_cn105/select/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType from .. import ( MITSUBISHI_CN105_DEVICE_SCHEMA, + VERTICAL_VANE_DIRECTIONS, MitsubishiCN105Component, mitsubishi_ns, register_mitsubishi_cn105_device, @@ -15,9 +16,6 @@ DEPENDENCIES = ["mitsubishi_cn105"] CONF_VERTICAL_VANE_DIRECTION = "vertical_vane_direction" -# The insertion order must match VALUES in mitsubishi_cn105_vane_select_vertical.cpp. -VERTICAL_VANE_DIRECTIONS = ["Auto", "1", "2", "3", "4", "5", "Swing"] - MitsubishiCN105VerticalVaneDirectionSelect = mitsubishi_ns.class_( "MitsubishiCN105VerticalVaneDirectionSelect", select.Select, @@ -42,6 +40,6 @@ async def to_code(config: ConfigType) -> None: await select.register_select( var, vertical_vane_direction, - options=VERTICAL_VANE_DIRECTIONS, + options=[direction.capitalize() for direction in VERTICAL_VANE_DIRECTIONS], ) await register_mitsubishi_cn105_device(var, config) diff --git a/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.cpp b/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.cpp index 0f9142fe5e..d703ddbb02 100644 --- a/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.cpp +++ b/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.cpp @@ -4,7 +4,7 @@ namespace esphome::mitsubishi_cn105 { -// NOTE: This order must match VERTICAL_VANE_DIRECTIONS in select.py. +// NOTE: This order must match VERTICAL_VANE_DIRECTIONS in the hub's __init__.py. // MitsubishiCN105VerticalVaneDirectionSelect uses the preferred index-based // Select API, so Python option order and this array must stay aligned. static constexpr std::array VALUES{ diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h index a119a38d24..f542880eef 100644 --- a/tests/components/mitsubishi_cn105/common.h +++ b/tests/components/mitsubishi_cn105/common.h @@ -8,6 +8,7 @@ #include #include "esphome/components/uart/uart_component.h" #include "esphome/components/mitsubishi_cn105/mitsubishi_cn105.h" +#include "esphome/components/mitsubishi_cn105/automation.h" #include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h" #include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h" diff --git a/tests/components/mitsubishi_cn105/common.yaml b/tests/components/mitsubishi_cn105/common.yaml index fcd1b048dd..fc14724786 100644 --- a/tests/components/mitsubishi_cn105/common.yaml +++ b/tests/components/mitsubishi_cn105/common.yaml @@ -29,3 +29,11 @@ esphome: temperature: 22.0 - mitsubishi_cn105.clear_remote_temperature: id: ac + - mitsubishi_cn105.vane.control: + id: ac + vertical: + direction: SWING + - mitsubishi_cn105.vane.control: + id: ac + vertical: + direction: !lambda return esphome::mitsubishi_cn105::VERTICAL_VANE_MODE_SWING; diff --git a/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp b/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp index 48ea6b0c29..c957759223 100644 --- a/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp +++ b/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp @@ -24,25 +24,50 @@ TEST(MitsubishiCN105ComponentTests, PublishesVaneStateForEveryValidSnapshot) { EXPECT_EQ(callback_direction, std::optional{VERTICAL_VANE_MODE_POSITION_4}); } -TEST(MitsubishiCN105ComponentTests, DoesNotPublishUnknownVaneState) { +TEST(MitsubishiCN105ComponentTests, PublishesUnknownVaneState) { TestableMitsubishiCN105Component hub; size_t status_callback_count = 0; size_t vane_callback_count = 0; + std::optional callback_direction; hub.add_on_status_callback([&]() { status_callback_count++; }); - hub.add_on_vane_state_callback([&](const VaneState &) { vane_callback_count++; }); + hub.add_on_vane_state_callback([&](const VaneState &state) { + vane_callback_count++; + callback_direction = state.vertical.direction; + }); hub.mutable_status().room_temperature = 20.0f; hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN; hub.publish_status(); EXPECT_EQ(status_callback_count, 1); - EXPECT_EQ(vane_callback_count, 0); + EXPECT_EQ(vane_callback_count, 1); + EXPECT_EQ(callback_direction, std::optional{VERTICAL_VANE_MODE_UNKNOWN}); hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4; hub.publish_status(); EXPECT_EQ(status_callback_count, 2); - EXPECT_EQ(vane_callback_count, 1); + EXPECT_EQ(vane_callback_count, 2); + EXPECT_EQ(callback_direction, std::optional{VERTICAL_VANE_MODE_POSITION_4}); +} + +TEST(MitsubishiCN105ComponentTests, VaneCallAppliesVerticalDirection) { + TestableMitsubishiCN105Component hub; + + auto call = hub.make_vane_call(); + call.vertical.set_direction(VERTICAL_VANE_MODE_POSITION_5); + call.perform(); + + EXPECT_EQ(hub.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_5); +} + +TEST(MitsubishiCN105ComponentTests, VaneControlActionAppliesConfiguredFields) { + TestableMitsubishiCN105Component hub; + VaneControlAction<> action(&hub, [](VaneCall &call) { call.vertical.set_direction(VERTICAL_VANE_MODE_SWING); }); + + action.play(); + + EXPECT_EQ(hub.status().vane_mode, MitsubishiCN105::VaneMode::SWING); } } // namespace esphome::mitsubishi_cn105::testing From 37a59a07bce1947604324e875bcd3da3bb15f54a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 18:40:47 -0500 Subject: [PATCH 1405/1815] [bluetooth_proxy] Retry dropped GATT acks and stop the retry log spam (#18259) --- .../bluetooth_connection.h | 4 + .../bluetooth_connection_bluedroid.cpp | 6 +- .../bluetooth_connection_hub.cpp | 137 +++++++++++++++--- .../bluetooth_connection_hub.h | 76 +++++++++- .../bluetooth_proxy/bluetooth_proxy.cpp | 21 ++- .../bluetooth_proxy/bluetooth_proxy.h | 3 +- 6 files changed, 212 insertions(+), 35 deletions(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h index 53e319e369..d200c6b48f 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -92,6 +92,10 @@ static_assert(DONE_SENDING_SERVICES != GATT_NOT_CONNECTED && INIT_SENDING_SERVIC // delivered near the client's 30 s timeout could land on a fresh request's // empty accumulator and cache as an empty database. static constexpr uint8_t SERVICES_DONE_RETRY_LIMIT = 30; +// Owed-ack retries stop after ~25 s of subscribed drain time from the first +// refusal, keeping most of the client's 30 s GATT window for congestion to +// clear while still bounding how stale a delivered reply can be. +static constexpr uint16_t PENDING_ACK_RETRY_LIMIT = 250; // ---- Service-streaming size budget, shared by every platform's streamer ---- diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index d6b815fc2e..99a6a312ec 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -397,6 +397,8 @@ void BluedroidGattClient::deliver_pending_search_() { // which proxy builds compile without a materializer. static_assert(requires(BluedroidGattClient c, BluetoothConnection &conn) { c.stream_service_batch(conn); }); +// Bound by the SERVICE STREAMING HAZARD note at the top of +// bluetooth_connection_hub.cpp: never skip a batch, never send done early. void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) { if (this->services_released_) { // Released under the stream: park without services-done so a partial @@ -527,9 +529,11 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) { // On a failed send, rewind the cursor so the batch is retried instead of // silently skipped. if (!api_conn->send_message(resp)) { - ESP_LOGW(TAG, "[%d] [%s] Failed to send service batch, retrying", conn.connection_index_, conn.address_str_); + conn.note_batch_stalled_(); conn.send_service_ = batch_start; + return; } + conn.batch_stalled_ = false; } #endif // USE_BLUETOOTH_PROXY diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index c43b2a6f7c..3909e16305 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -1,4 +1,24 @@ // The proxy's per-slot connection wrapper, shared by every platform. +// +// SERVICE STREAMING HAZARD - read before touching the streaming code here or +// in the platform streamers (bluetooth_connection_bluedroid.cpp). +// +// A V3 client caches the service list it receives as the device's complete, +// permanent database. Nothing on the wire marks a list as partial, so a +// stream that is truncated, has a skipped batch, or is terminated early +// would be cached whole and poison every later session with the device. +// +// The rule: it is always better to send nothing and let the client time out +// than to let services-done follow an incomplete stream. Concretely: +// - a refused batch rewinds the cursor and is retried, never skipped; +// - services-done is sent only after every batch was accepted; +// - every interruption (subscriber lost or swapped, backend abort, +// bounds-check failure) parks or aborts WITHOUT services-done and drops +// any owed done; +// - a new GetServices supersedes an owed done, so a stale done can never +// land on a fresh request's empty accumulator and cache it as empty. +// The client only caches a list terminated by services-done within the same +// request; timeouts, disconnects and errors raise instead of caching. #include "bluetooth_connection_hub.h" #ifdef BLUETOOTH_CONNECTION_HAS_GATT @@ -16,6 +36,9 @@ static const char *const TAG = "bluetooth_connection"; void BluetoothConnection::set_address(uint64_t address) { // Keep the proxy's pre-allocated connections-free message in step this->proxy_->update_address_slot_(this->address_, address); + // Slot changing hands: anything owed belonged to the old address. The + // choke point for every reassignment, not just reset_connection_()'s path. + this->clear_pending_ack_(); this->address_ = address; if (address == 0) { this->address_str_[0] = '\0'; @@ -73,6 +96,9 @@ void BluetoothConnection::reset_connection_(conn_err_t reason) { this->state_ = ClientState::IDLE; this->services_discovered_ = false; this->paired_ = false; + // Link gone: the slot may hold a different device before the drain runs. + this->clear_pending_ack_(); + this->batch_stalled_ = false; this->backend_->release_services(); this->proxy_->reset_connection_slot_(this, reason); } @@ -163,13 +189,85 @@ void BluetoothConnection::log_gatt_operation_error_(const char *operation, uint1 operation, handle, status); } +void BluetoothConnection::note_batch_stalled_() { + if (this->batch_stalled_) + return; + this->batch_stalled_ = true; + ESP_LOGW(TAG, "[%d] [%s] Service batch deferred, TCP buffer full; retrying", this->connection_index_, + this->address_str_); +} + +/// Both payload-free acks are just (address, handle); only the type differs. +template +static bool send_handle_reply(api::APIConnection *api_connection, uint64_t address, uint16_t handle) { + Response resp; + resp.address = address; + resp.handle = handle; + return api_connection->send_message(resp); +} + +/// Sole construction site, so a re-offer cannot drift from the original. +bool BluetoothConnection::try_send_ack_(PendingAck kind, uint16_t handle, conn_err_t error) { + if (kind == PendingAck::PENDING_ACK_ERROR) { + // Proxy owns the error reply and reports a refusal the same way. + return this->proxy_->send_gatt_error(this->address_, handle, error); + } + auto *api_connection = this->proxy_->get_api_connection(); + if (api_connection == nullptr) + return true; // Nobody subscribed: nothing is owed + switch (kind) { + case PendingAck::PENDING_ACK_WRITE: + return send_handle_reply(api_connection, this->address_, handle); + case PendingAck::PENDING_ACK_NOTIFY: + return send_handle_reply(api_connection, this->address_, handle); + case PendingAck::PENDING_ACK_NONE: + case PendingAck::PENDING_ACK_ERROR: // returned above + return true; + } + // No default label above, so a new enumerator is a -Wswitch warning rather + // than a silent notify reply. This return only satisfies -Wreturn-type. + return true; +} + +void BluetoothConnection::send_ack_(PendingAck kind, uint16_t handle, conn_err_t error) { + if (this->try_send_ack_(kind, handle, error)) + return; + // Report a newly owed reply and a displaced one; displacing is the case + // that loses a reply. Re-refusing the same one stays quiet. + if (!this->has_pending_ack_()) { + ESP_LOGW(TAG, "[%d] [%s] GATT reply for handle 0x%04X deferred, TCP buffer full", this->connection_index_, + this->address_str_, handle); + } else if (this->pending_ack_handle_ != handle || this->pending_ack_ != kind) { + ESP_LOGW(TAG, "[%d] [%s] GATT reply for handle 0x%04X dropped for handle 0x%04X", this->connection_index_, + this->address_str_, this->pending_ack_handle_, handle); + } + this->latch_pending_ack_(kind, handle, error); +} + +void BluetoothConnection::flush_pending_ack_() { + // No-op on its own rather than relying on the proxy drain's pre-check. + if (!this->has_pending_ack_()) + return; + if (this->try_send_ack_(this->pending_ack_, this->pending_ack_handle_, this->pending_ack_error_)) { + this->clear_pending_ack_(); + return; + } + if (++this->pending_ack_retries_ >= PENDING_ACK_RETRY_LIMIT) { + // Undeliverable: past here the client has given up and may have re-asked, + // and a late reply would answer the new request instead of this one. + ESP_LOGW(TAG, "[%d] [%s] GATT reply for handle 0x%04X undeliverable, abandoning", this->connection_index_, + this->address_str_, this->pending_ack_handle_); + this->clear_pending_ack_(); + } +} + void BluetoothConnection::on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) { // Late completion for a freed slot; nothing to report. if (this->address_ == 0) return; if (error != 0) { this->log_gatt_operation_error_("reading char/descriptor", handle, error); - this->proxy_->send_gatt_error(this->address_, handle, error); + this->send_gatt_error_(handle, error); return; } auto *api_connection = this->proxy_->get_api_connection(); @@ -180,6 +278,8 @@ void BluetoothConnection::on_read_result(uint16_t handle, const uint8_t *data, u resp.handle = handle; resp.set_data(data, len); if (!api_connection->send_message(resp)) { + // Not latched: would mean holding the payload through the congestion + // that refused it. The client's read timeout arbitrates. ESP_LOGW(TAG, "[%d] [%s] Failed to send read response", this->connection_index_, this->address_str_); } } @@ -189,18 +289,10 @@ void BluetoothConnection::on_write_result(uint16_t handle, int error) { return; if (error != 0) { this->log_gatt_operation_error_("writing char/descriptor", handle, error); - this->proxy_->send_gatt_error(this->address_, handle, error); + this->send_gatt_error_(handle, error); return; } - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - return; - api::BluetoothGATTWriteResponse resp; - resp.address = this->address_; - resp.handle = handle; - if (!api_connection->send_message(resp)) { - ESP_LOGW(TAG, "[%d] [%s] Failed to send write response", this->connection_index_, this->address_str_); - } + this->send_ack_(PendingAck::PENDING_ACK_WRITE, handle); } void BluetoothConnection::on_notify_state(uint16_t handle, bool enabled, int error) { @@ -209,18 +301,10 @@ void BluetoothConnection::on_notify_state(uint16_t handle, bool enabled, int err if (error != 0) { this->log_gatt_operation_error_(enabled ? "registering notifications" : "unregistering notifications", handle, error); - this->proxy_->send_gatt_error(this->address_, handle, error); + this->send_gatt_error_(handle, error); return; } - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - return; - api::BluetoothGATTNotifyResponse resp; - resp.address = this->address_; - resp.handle = handle; - if (!api_connection->send_message(resp)) { - ESP_LOGW(TAG, "[%d] [%s] Failed to send notify state response", this->connection_index_, this->address_str_); - } + this->send_ack_(PendingAck::PENDING_ACK_NOTIFY, handle); } void BluetoothConnection::on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) { @@ -235,6 +319,8 @@ void BluetoothConnection::on_notify_data(uint16_t handle, const uint8_t *data, u resp.handle = handle; resp.set_data(data, len); if (!api_connection->send_message(resp)) { + // Not latched, same reason as the read reply. Notify data is lossy: the + // peripheral will not resend it. ESP_LOGW(TAG, "[%d] [%s] Failed to send notify data response", this->connection_index_, this->address_str_); } } @@ -251,6 +337,7 @@ conn_err_t BluetoothConnection::check_connected_op_(const char *action, const ch } conn_err_t BluetoothConnection::read_characteristic(uint16_t handle) { + this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_NONE); if (conn_err_t err = this->check_connected_op_("read", "characteristic"); err != CONN_OK) return err; ESP_LOGV(TAG, "[%d] [%s] Reading GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); @@ -259,6 +346,7 @@ conn_err_t BluetoothConnection::read_characteristic(uint16_t handle) { conn_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8_t *data, size_t length, bool response) { + this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_WRITE); if (conn_err_t err = this->check_connected_op_("write", "characteristic"); err != CONN_OK) return err; ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); @@ -266,6 +354,7 @@ conn_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint } conn_err_t BluetoothConnection::read_descriptor(uint16_t handle) { + this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_NONE); if (conn_err_t err = this->check_connected_op_("read", "descriptor"); err != CONN_OK) return err; ESP_LOGV(TAG, "[%d] [%s] Reading GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); @@ -276,6 +365,7 @@ conn_err_t BluetoothConnection::read_descriptor(uint16_t handle) { // the response flag is intentionally ignored (esp32 maps it to RSP/NO_RSP). conn_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool /*response*/) { + this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_WRITE); if (conn_err_t err = this->check_connected_op_("write", "descriptor"); err != CONN_OK) return err; ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); @@ -283,6 +373,7 @@ conn_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t } conn_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enable) { + this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_NOTIFY); if (conn_err_t err = this->check_connected_op_("notify", "characteristic"); err != CONN_OK) return err; ESP_LOGV(TAG, "[%d] [%s] %s GATT characteristic notifications handle %d", this->connection_index_, this->address_str_, @@ -413,9 +504,11 @@ void BluetoothConnection::send_service_for_discovery_() { // (bounded: a subscriber that stays gone ends streaming via the api-lost // rewind above). if (!api_conn->send_message(resp)) { - ESP_LOGW(TAG, "[%d] [%s] Failed to send service batch, retrying", this->connection_index_, this->address_str_); + this->note_batch_stalled_(); this->send_service_ = batch_start; + return; } + this->batch_stalled_ = false; } } // namespace esphome::bluetooth_connection diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index d964af5530..b0d0f5fd46 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -25,6 +25,16 @@ namespace esphome::bluetooth_connection { using ClientState = ble_device_base::ClientState; using ConnectionType = ble_device_base::ConnectionType; +/// A refused GATT reply owed to the current subscriber. Payload-free only: +/// these rebuild from address + handle + error, so a retry costs no buffered +/// data. Read and notify-data carry payloads and are deliberately absent. +enum class PendingAck : uint8_t { + PENDING_ACK_NONE = 0, + PENDING_ACK_WRITE, + PENDING_ACK_NOTIFY, + PENDING_ACK_ERROR, +}; + class BluetoothConnection final : public ble_device_base::GattClientListener { public: /// Wire the platform backend. Called from codegen before setup. @@ -116,6 +126,42 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { this->pending_error_ = err; } } + + /// Latch a refused reply for the proxy drain. One slot per connection, + /// newest wins: a GATT client works one request at a time, and a discarded + /// reply falls back to the timeout it would have hit anyway. + void latch_pending_ack_(PendingAck kind, uint16_t handle, conn_err_t error = 0) { + this->pending_ack_retries_ = 0; + this->pending_ack_ = kind; + this->pending_ack_handle_ = handle; + this->pending_ack_error_ = error; + } + void clear_pending_ack_() { this->pending_ack_ = PendingAck::PENDING_ACK_NONE; } + /// Drop an owed reply this re-ask makes stale. Clients match futures on + /// response type as well as handle, so an owed error (which resolves any op + /// on the handle) is cleared by any re-ask, other kinds only by their own. + void supersede_pending_ack_(uint16_t handle, PendingAck kind) { + if (this->has_pending_ack_() && this->pending_ack_handle_ == handle && + (this->pending_ack_ == PendingAck::PENDING_ACK_ERROR || this->pending_ack_ == kind)) { + this->clear_pending_ack_(); + } + } + bool has_pending_ack_() const { return this->pending_ack_ != PendingAck::PENDING_ACK_NONE; } + /// Warn on the stall's leading edge only. The batch is never lost (the + /// caller rewinds the cursor), and a warning per attempt would add traffic + /// to the connection already refusing frames. Both streamers route here. + void note_batch_stalled_(); + /// Sole construction site for these replies, shared by send and retry. + bool try_send_ack_(PendingAck kind, uint16_t handle, conn_err_t error); + /// First attempt: send, and latch it for the drain if the API refuses. + void send_ack_(PendingAck kind, uint16_t handle, conn_err_t error = 0); + /// Report a rejected request. Latched like a completion reply, so a + /// refused frame does not strand the client for its whole timeout. + void send_gatt_error_(uint16_t handle, conn_err_t error) { + this->send_ack_(PendingAck::PENDING_ACK_ERROR, handle, error); + } + /// Re-offer the owed reply; clears on success, stays owed on a refusal. + void flush_pending_ack_(); // A backend providing its own streamer (see the contract doc) builds the // response in place from its stack cache; the rest use the table streamer. // Template so the discarded branch is not odr-checked against backends @@ -131,6 +177,9 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { /// interrupted stream must never be declared complete (the client's /// timeout arbitrates), and an owed done is dropped with it. void park_service_stream_() { + // Agree with reset_connection_(): a stall flag left set would swallow the + // next session's leading-edge warning. + this->batch_stalled_ = false; if (this->send_service_ >= 0) { this->backend_->release_services(); this->send_service_ = DONE_SENDING_SERVICES; @@ -153,23 +202,31 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { bluetooth_proxy::BluetoothProxy *proxy_{nullptr}; ble_device_base::BLEGattConnection *backend_{nullptr}; - // Group 2: 2-byte types + // Group 2: 2-byte types. Exactly 4 bytes, so address_ below stays + // 8-aligned with no padding (the vptr makes Group 1 12 bytes, not 8). int16_t send_service_{INIT_SENDING_SERVICES}; uint16_t mtu_{ble_device_base::DEFAULT_ATT_MTU}; // Group 3: 8-byte and 4-byte types uint64_t address_{0}; conn_err_t pending_error_{0}; + // Full width: the GATT error domain is open-ended (ble_gatt_client.h) and + // forwarded untranslated, so narrowing would corrupt platform codes. + conn_err_t pending_ack_error_{0}; // Group 4: Arrays char address_str_[MAC_ADDRESS_PRETTY_BUFFER_SIZE]{}; + // Parked here rather than in Group 2: address_str_ ends 2-aligned, so this + // uses tail slack instead of pushing address_ out by 6 bytes of padding. + uint16_t pending_ack_handle_{0}; - // Group 5: bit-packed tail; within 2 bytes the 8-aligned object stays 48. + // Group 5: bit-packed tail. pending_ack_error_ takes the 8-aligned object + // from 48 to 56, so the third tail byte is free; first two stay packed. static_assert(static_cast(ClientState::ESTABLISHED) < (1 << 3), "state_ bitfield too narrow"); static_assert(static_cast(ConnectionType::V3_WITHOUT_CACHE) < (1 << 2), "connection_type_ bitfield too narrow"); // Ordered so neither byte's fields straddle a storage unit: 3+5 and - // 4+2+1+1 fill the two tail bytes exactly. + // 4+2+1+1 fill the first two tail bytes exactly. ClientState state_ : 3 {ClientState::IDLE}; static_assert(SERVICES_DONE_RETRY_LIMIT < (1 << 5), "counter bitfield too narrow"); uint8_t services_done_retries_ : 5 {0}; @@ -177,8 +234,21 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { ConnectionType connection_type_ : 2 {ConnectionType::V1}; bool paired_ : 1 {false}; bool services_discovered_ : 1 {false}; + static_assert(static_cast(PendingAck::PENDING_ACK_ERROR) < (1 << 2), "pending_ack_ bitfield too narrow"); + PendingAck pending_ack_ : 2 {PendingAck::PENDING_ACK_NONE}; + /// Set while a refused batch is retrying, so only the first one warns. + bool batch_stalled_ : 1 {false}; + // Plain byte after the bitfields: takes the padding byte instead of + // straddling pending_ack_'s storage unit and growing the object. + static_assert(PENDING_ACK_RETRY_LIMIT <= 0xFF, "retry counter too narrow"); + uint8_t pending_ack_retries_{0}; }; +// Pins the grouping above: pending_ack_handle_ in Group 2 instead would pad +// address_ out and reach 64. 32-bit only; the host unit tests build 64-bit. +static_assert(sizeof(void *) != 4 || sizeof(BluetoothConnection) <= 56, + "BluetoothConnection layout regressed on a 32-bit target"); + } // namespace esphome::bluetooth_connection #endif // BLUETOOTH_CONNECTION_HAS_GATT diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 13c84b86d1..ff5b7bc5cb 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -387,7 +387,7 @@ void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &ms auto err = connection->read_characteristic(msg.handle); if (err != CONN_OK) { - this->send_gatt_error(msg.address, msg.handle, err); + connection->send_gatt_error_(msg.handle, err); } } @@ -400,7 +400,7 @@ void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest & auto err = connection->write_characteristic(msg.handle, msg.data, msg.data_len, msg.response); if (err != CONN_OK) { - this->send_gatt_error(msg.address, msg.handle, err); + connection->send_gatt_error_(msg.handle, err); } } @@ -413,7 +413,7 @@ void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTRead auto err = connection->read_descriptor(msg.handle); if (err != CONN_OK) { - this->send_gatt_error(msg.address, msg.handle, err); + connection->send_gatt_error_(msg.handle, err); } } @@ -426,7 +426,7 @@ void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWri auto err = connection->write_descriptor(msg.handle, msg.data, msg.data_len, true); if (err != CONN_OK) { - this->send_gatt_error(msg.address, msg.handle, err); + connection->send_gatt_error_(msg.handle, err); } } @@ -477,7 +477,7 @@ void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest auto err = connection->notify_characteristic(msg.handle, msg.enable); if (err != CONN_OK) { - this->send_gatt_error(msg.address, msg.handle, err); + connection->send_gatt_error_(msg.handle, err); } } @@ -597,6 +597,9 @@ void BluetoothProxy::loop() { if (connection->send_service_ == SERVICES_DONE_PENDING) { connection->send_services_done_(); } + if (connection->has_pending_ack_()) { + connection->flush_pending_ack_(); + } auto &owed = this->pending_disconnections_[i]; if (!owed.empty() && this->send_device_connection(owed.address(), false, 0, owed.error())) { owed.clear(); @@ -716,6 +719,8 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection // Neither a partial stream's tail nor an owed done belongs to the new // session; silence (the client's timeout) arbitrates. this->connections_[i]->park_service_stream_(); + // An ack owed to the previous subscriber means nothing to the new one. + this->connections_[i]->clear_pending_ack_(); } this->pending_disconnections_.fill({}); #endif @@ -776,14 +781,14 @@ bool BluetoothProxy::send_gatt_services_done(uint64_t address) { return this->api_connection_->send_message(call); } -void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error) { +bool BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error) { if (this->api_connection_ == nullptr) - return; + return true; // Nobody subscribed: nothing is owed, only a refused frame reports false api::BluetoothGATTErrorResponse call; call.address = address; call.handle = handle; call.error = error; - this->api_connection_->send_message(call); + return this->api_connection_->send_message(call); } void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, conn_err_t error) { diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index cf7a09a7e5..0b51d61c60 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -145,7 +145,8 @@ class BluetoothProxy final : public Component { void send_connections_free(api::APIConnection *api_connection); /// Same convention as send_device_connection: false only on a refused frame. bool send_gatt_services_done(uint64_t address); - void send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error); + /// False only when the API refused the frame, so the reply is still owed. + bool send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error); void send_device_pairing(uint64_t address, bool paired, conn_err_t error = CONN_OK); void send_device_unpairing(uint64_t address, bool success, conn_err_t error = CONN_OK); void send_device_clear_cache(uint64_t address, bool success, conn_err_t error = CONN_OK); From 201f843e95a98f35f0185661dc504a72d88f421b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 18:57:51 -0500 Subject: [PATCH 1406/1815] [bluetooth_proxy] Latch the unpair reply (#18274) --- .../bluetooth_proxy/bluetooth_proxy.cpp | 45 ++++++++++++++++++- .../bluetooth_proxy/bluetooth_proxy.h | 34 ++++++++------ 2 files changed, 63 insertions(+), 16 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index ff5b7bc5cb..4c4cbf27a2 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -197,7 +197,7 @@ void BluetoothProxy::replace_allocated_slot_(uint64_t find_value, uint64_t set_v void BluetoothProxy::latch_pending_disconnection_(uint64_t address, conn_err_t error) { // Match before free entry so one address never occupies two pool slots. - PendingDisconnect *free_entry = nullptr; + PendingReply *free_entry = nullptr; for (uint8_t i = 0; i < this->connection_count_; i++) { auto &owed = this->pending_disconnections_[i]; if (owed.matches(address)) { @@ -605,6 +605,13 @@ void BluetoothProxy::loop() { owed.clear(); } } + + // An owed unpair reply. Not pre-cleared: the sender clears on success and + // re-latches on refusal, keeping its leading-edge warn guard honest. + if (!this->pending_unpairing_.empty()) { + conn_err_t error = this->pending_unpairing_.error(); + this->send_device_unpairing(this->pending_unpairing_.address(), error == CONN_OK, error); + } #endif #ifdef USE_BLE_SCANNER_STATE_CALLBACK @@ -715,6 +722,7 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection // re-subscribe by the current one keeps what it is still owed. this->connections_free_pending_ = false; #ifdef BLUETOOTH_CONNECTION_HAS_GATT + this->pending_unpairing_.clear(); for (uint8_t i = 0; i < this->connection_count_; i++) { // Neither a partial stream's tail nor an owed done belongs to the new // session; silence (the client's timeout) arbitrates. @@ -741,6 +749,9 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti } this->api_connection_ = nullptr; this->connections_free_pending_ = false; +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + this->pending_unpairing_.clear(); +#endif #ifdef USE_BLE_SCANNER_STATE_CALLBACK this->scanner_state_pending_ = false; #endif @@ -805,12 +816,42 @@ void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, conn_err void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, conn_err_t error) { if (this->api_connection_ == nullptr) return; +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + // An owed success is the authoritative answer: a later attempt for the + // same address fails only because the first already removed the bond. + if (!this->pending_unpairing_.empty() && this->pending_unpairing_.matches(address) && + this->pending_unpairing_.error() == CONN_OK) { + success = true; + error = CONN_OK; + } +#endif api::BluetoothDeviceUnpairingResponse call; call.address = address; call.success = success; call.error = error; - this->api_connection_->send_message(call); + // Advertisement-only builds answer this with a canned reply and keep no + // retry state, so only the latch is conditional, not the send. + [[maybe_unused]] bool sent = this->api_connection_->send_message(call); +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + if (sent) { + // A later unpair landing for an address that still has one owed would + // otherwise have the drain repeat it. + if (this->pending_unpairing_.matches(address)) { + this->pending_unpairing_.clear(); + } + } else { + // Warn on the leading edge and on displacement (that one loses a reply); + // the drain's re-refusals of the same reply stay quiet. + if (this->pending_unpairing_.empty()) { + ESP_LOGW(TAG, "Unpair reply for %012" PRIX64 " deferred, TCP buffer full", address); + } else if (!this->pending_unpairing_.matches(address)) { + ESP_LOGW(TAG, "Owed unpair reply for %012" PRIX64 " dropped, displaced by %012" PRIX64, + this->pending_unpairing_.address(), address); + } + this->pending_unpairing_.set(address, error); + } +#endif } // Shared by both platform paths: the neutral bluetooth_device_request() uses it to diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 0b51d61c60..e7c7c3cb20 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -61,11 +61,9 @@ enum BluetoothProxySubscriptionFlag : uint32_t { }; #ifdef BLUETOOTH_CONNECTION_HAS_GATT -/// One owed freed-slot connected=false notification in a single word: the -/// 48-bit address in the low bits, the sign-extending 16-bit reason on top. -/// Every reason that reaches the pool (esp_gatt_status_t, -/// esp_gatt_conn_reason_t, generic ESP_ERR_*, -1) fits int16_t. -class PendingDisconnect { +/// One owed address-keyed reply in a single word: 48-bit address low, 16-bit +/// error on top. Every error that reaches it fits int16_t. +class PendingReply { public: constexpr void set(uint64_t address, conn_err_t error) { // Mask: the address originates from the client, and a stray high bit @@ -73,7 +71,9 @@ class PendingDisconnect { this->word_ = (address & ADDRESS_MASK) | (static_cast(static_cast(error)) << 48); } constexpr void clear() { this->word_ = 0; } - // Whole-word test: set() is only ever given a live (nonzero) address. + // Whole-word test: only (address 0, error 0) reads back as nothing owed. + // A zero-address failure still latches, which is correct - that reply is + // owed too. Neither backend can unpair address 0 successfully. constexpr bool empty() const { return this->word_ == 0; } // Masked like set(), so a stray high bit cannot defeat the pool lookups. constexpr bool matches(uint64_t address) const { return this->address() == (address & ADDRESS_MASK); } @@ -86,15 +86,15 @@ class PendingDisconnect { }; // Pin the packing at compile time: mask and sign round-trip for every // reachable shape (negative, GATT status, ESP_ERR_* range, stray high bit). -constexpr bool pending_disconnect_round_trips(uint64_t address, uint64_t expected_address, conn_err_t error) { - PendingDisconnect p; +constexpr bool pending_reply_round_trips(uint64_t address, uint64_t expected_address, conn_err_t error) { + PendingReply p; p.set(address, error); return p.address() == expected_address && p.error() == error && !p.empty() && p.matches(address); } -static_assert(pending_disconnect_round_trips(0x0000112233445566ULL, 0x0000112233445566ULL, -1)); -static_assert(pending_disconnect_round_trips(0x0000FFFFFFFFFFFFULL, 0x0000FFFFFFFFFFFFULL, 0x8F)); -static_assert(pending_disconnect_round_trips(0xABCD112233445566ULL, 0x0000112233445566ULL, 0x110)); -static_assert(PendingDisconnect{}.empty()); +static_assert(pending_reply_round_trips(0x0000112233445566ULL, 0x0000112233445566ULL, -1)); +static_assert(pending_reply_round_trips(0x0000FFFFFFFFFFFFULL, 0x0000FFFFFFFFFFFFULL, 0x8F)); +static_assert(pending_reply_round_trips(0xABCD112233445566ULL, 0x0000112233445566ULL, 0x110)); +static_assert(PendingReply{}.empty()); #endif class BluetoothProxy final : public Component { @@ -148,7 +148,9 @@ class BluetoothProxy final : public Component { /// False only when the API refused the frame, so the reply is still owed. bool send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error); void send_device_pairing(uint64_t address, bool paired, conn_err_t error = CONN_OK); - void send_device_unpairing(uint64_t address, bool success, conn_err_t error = CONN_OK); + /// No default error: the drain rebuilds success as (error == CONN_OK), so a + /// caller that omitted it would have a reported failure resent as a success. + void send_device_unpairing(uint64_t address, bool success, conn_err_t error); void send_device_clear_cache(uint64_t address, bool success, conn_err_t error = CONN_OK); void bluetooth_scanner_set_mode(bool active); @@ -294,7 +296,11 @@ class BluetoothProxy final : public Component { // Address-keyed pool of owed freed-slot notifications; loop() resends. // Proxy-only state, kept off BluetoothConnection; entries are not tied to // slot indices. - std::array pending_disconnections_{}; + std::array pending_disconnections_{}; + // Owed unpair reply. The bond is already gone when the send is refused, so + // a retry is told the unpair failed when it succeeded. One slot: a second + // refused unpair displaces the first, as happened to both before this. + PendingReply pending_unpairing_{}; #endif ble_device_base::BLEHub *hub_{nullptr}; // Group 3: 4-byte types; paired with hub_ so the 8-aligned messages below From 9f28638ee77056850456e505f38904613f03dd0f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 19:15:21 -0500 Subject: [PATCH 1407/1815] [bluetooth_proxy] Reset every owed reply in one place (#18276) --- .../bluetooth_proxy/bluetooth_proxy.cpp | 46 +++++++++++-------- .../bluetooth_proxy/bluetooth_proxy.h | 6 +++ 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 4c4cbf27a2..0d91b541e8 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -704,6 +704,31 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn #endif // !BLUETOOTH_CONNECTION_HAS_GATT +void BluetoothProxy::reset_owed_replies_() { + this->connections_free_pending_ = false; +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + // Owed on unsubscribe; on subscribe the trailing send_scanner_state_() + // re-drives it from the hub, so clearing it there is free. + this->scanner_state_pending_ = false; +#else + // Force a poll-arm mismatch: a frame refused at subscribe time could + // otherwise match the stale detector and never be retried. Inert on + // unsubscribe: loop() returns at the no-subscriber gate before the + // detector runs, and a re-subscribe re-arms this anyway. + this->last_scan_running_ = !this->hub_->scan_running(); +#endif +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + this->pending_unpairing_.clear(); + this->pending_disconnections_.fill({}); + for (uint8_t i = 0; i < this->connection_count_; i++) { + // Neither a partial stream's tail nor an owed done belongs to the next + // session; silence (the client's timeout) arbitrates. + this->connections_[i]->park_service_stream_(); + this->connections_[i]->clear_pending_ack_(); + } +#endif +} + void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) { if (api_connection != this->api_connection_) { if (this->api_connection_ != nullptr) { @@ -720,18 +745,7 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection } // Stale retry latches belong to the previous subscriber's session; a // re-subscribe by the current one keeps what it is still owed. - this->connections_free_pending_ = false; -#ifdef BLUETOOTH_CONNECTION_HAS_GATT - this->pending_unpairing_.clear(); - for (uint8_t i = 0; i < this->connection_count_; i++) { - // Neither a partial stream's tail nor an owed done belongs to the new - // session; silence (the client's timeout) arbitrates. - this->connections_[i]->park_service_stream_(); - // An ack owed to the previous subscriber means nothing to the new one. - this->connections_[i]->clear_pending_ack_(); - } - this->pending_disconnections_.fill({}); -#endif + this->reset_owed_replies_(); } this->api_connection_ = api_connection; #ifdef USE_BLE_SCANNER_STATE_CALLBACK @@ -748,13 +762,7 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti return; } this->api_connection_ = nullptr; - this->connections_free_pending_ = false; -#ifdef BLUETOOTH_CONNECTION_HAS_GATT - this->pending_unpairing_.clear(); -#endif -#ifdef USE_BLE_SCANNER_STATE_CALLBACK - this->scanner_state_pending_ = false; -#endif + this->reset_owed_replies_(); } void BluetoothProxy::send_connections_free() { diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index e7c7c3cb20..16b153625f 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -286,6 +286,12 @@ class BluetoothProxy final : public Component { void latch_pending_disconnection_(uint64_t address, conn_err_t error); #endif + /// Drop everything the ending session was owed. One list, so a new latch is + /// one edit rather than two call sites where an omission looks deliberate. + /// Drops state only, never sends: api_connection_ is the departing + /// subscriber on subscribe and nullptr on unsubscribe. + void reset_owed_replies_(); + // Memory optimized layout for 32-bit systems // Group 1: Pointers (4 bytes each, naturally aligned) api::APIConnection *api_connection_{nullptr}; From be5e28ea9e09bf223c72527264829d1df1e1d804 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:06:57 -0400 Subject: [PATCH 1408/1815] [core] Warn when running a different source tree than the one you are in (#18288) Co-authored-by: J. Nick Koston --- esphome/__main__.py | 44 +++++++++++ tests/unit_tests/test_main.py | 142 ++++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+) diff --git a/esphome/__main__.py b/esphome/__main__.py index cc1e12cb3a..c4ba6b54d7 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2509,6 +2509,49 @@ def parse_args(argv): return parser.parse_args(arguments) +def _warn_if_source_tree_mismatch() -> None: + """Warn when the checkout the user is standing in is not the one being run. + + An editable install records one absolute path, so a venv shared between git + worktrees (or reused after a checkout is copied or renamed) keeps importing + the tree it was installed from. Every command then silently runs, and + compiles, sources the user is not looking at. Only fires inside a checkout, + so ordinary installs never see it. + """ + try: + cwd = Path.cwd() + except OSError: + return # working directory is gone; a diagnostic must not break startup + for candidate in (cwd, *cwd.parents): + if (candidate / "esphome" / "__main__.py").is_file(): + standing_in = candidate.resolve() + break + else: + return # not inside a checkout; nothing to compare against + + running = Path(__file__).resolve().parent.parent + # Both sides are resolved, so on a case-sensitive filesystem this matches + # plain equality. samefile() compares device and inode, which additionally + # covers a case-insensitive filesystem (macOS) reaching one directory by + # differently cased paths. Falls back to equality if either path is gone. + try: + same = standing_in.samefile(running) + except OSError: + same = standing_in == running + if same: + return + + _LOGGER.warning( + "Running ESPHome from a different checkout than the one you are in:\n" + " running from: %s\n" + " you are in: %s\n" + "The installed esphome resolves to the first, so its sources are used.\n" + "Run 'python -m esphome' from the second to use that one instead.", + running, + standing_in, + ) + + def run_esphome(argv): from esphome.address_cache import AddressCache @@ -2527,6 +2570,7 @@ def run_esphome(argv): args.log_level = "CRITICAL" setup_log(log_level=args.log_level) + _warn_if_source_tree_mismatch() if args.command in PRE_CONFIG_ACTIONS: try: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 6c13cd5f12..14b49a1a05 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -18,6 +18,7 @@ import pytest from pytest import CaptureFixture from zeroconf import ServiceStateChange +from esphome import __main__ as main from esphome.__main__ import ( Purpose, _get_configured_xtal_freq, @@ -6760,3 +6761,144 @@ def test_check_permissions_unreadable_port() -> None: pytest.raises(EsphomeError, match="read or write permission"), ): check_permissions("/dev/ttyUSB99") + + +def _make_checkout(root: Path) -> Path: + """Create a directory that looks like an esphome checkout.""" + (root / "esphome").mkdir(parents=True) + (root / "esphome" / "__main__.py").write_text("", encoding="utf-8") + return root + + +def test_warn_source_tree_mismatch_warns_for_other_tree( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Standing in a checkout other than the one being run warns.""" + standing_in = _make_checkout(tmp_path / "worktree") + running = _make_checkout(tmp_path / "main") + monkeypatch.chdir(standing_in) + monkeypatch.setattr(main, "__file__", str(running / "esphome" / "__main__.py")) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + assert "worktree" in caplog.text + assert "main" in caplog.text + + +def test_warn_source_tree_mismatch_silent_in_same_tree( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Standing in the tree that is running is the normal case and is silent.""" + tree = _make_checkout(tmp_path / "main") + monkeypatch.chdir(tree) + monkeypatch.setattr(main, "__file__", str(tree / "esphome" / "__main__.py")) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + assert not caplog.text + + +def test_warn_source_tree_mismatch_silent_outside_checkout( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """An ordinary install run from a config directory never warns.""" + running = _make_checkout(tmp_path / "main") + config_dir = tmp_path / "configs" + config_dir.mkdir() + monkeypatch.chdir(config_dir) + monkeypatch.setattr(main, "__file__", str(running / "esphome" / "__main__.py")) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + assert not caplog.text + + +def test_warn_source_tree_mismatch_silent_in_subdirectory( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """A subdirectory of the running tree resolves to that tree, so no warning.""" + tree = _make_checkout(tmp_path / "main") + subdir = tree / "esphome" / "components" + subdir.mkdir(parents=True) + monkeypatch.chdir(subdir) + monkeypatch.setattr(main, "__file__", str(tree / "esphome" / "__main__.py")) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + assert not caplog.text + + +def test_warn_source_tree_mismatch_warns_when_stat_fails_on_other_tree( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """The samefile() fallback must still warn when the trees really differ.""" + standing_in = _make_checkout(tmp_path / "worktree") + running = _make_checkout(tmp_path / "main") + monkeypatch.chdir(standing_in) + monkeypatch.setattr(main, "__file__", str(running / "esphome" / "__main__.py")) + + def raise_oserror(self: Path, other: Path) -> bool: + raise OSError("stat failed") + + monkeypatch.setattr(Path, "samefile", raise_oserror) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + assert "worktree" in caplog.text + + +def test_warn_source_tree_mismatch_silent_when_cwd_is_gone( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """A deleted working directory must not turn the diagnostic into a traceback.""" + running = _make_checkout(tmp_path / "main") + monkeypatch.setattr(main, "__file__", str(running / "esphome" / "__main__.py")) + + def raise_filenotfound() -> Path: + raise FileNotFoundError("cwd is gone") + + monkeypatch.setattr(Path, "cwd", staticmethod(raise_filenotfound)) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + assert not caplog.text + + +def test_warn_source_tree_mismatch_falls_back_when_stat_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """If samefile() cannot stat, fall back to comparing the paths.""" + tree = _make_checkout(tmp_path / "main") + monkeypatch.chdir(tree) + monkeypatch.setattr(main, "__file__", str(tree / "esphome" / "__main__.py")) + + def raise_oserror(self: Path, other: Path) -> bool: + raise OSError("stat failed") + + monkeypatch.setattr(Path, "samefile", raise_oserror) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + # Same tree, so the path comparison still finds them equal and stays silent + assert not caplog.text From 3f5b8139f32ffd28722f8544a688584d1b820d05 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" <3060199+jesserockz@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:52:25 -0500 Subject: [PATCH 1409/1815] [bluetooth_proxy] Latch the connection replies and tighten the send paths (#18278) --- .../bluetooth_connection.cpp | 9 +- .../bluetooth_connection.h | 11 +- .../bluetooth_connection_hub.cpp | 61 ++++- .../bluetooth_connection_hub.h | 28 ++- .../bluetooth_proxy/bluetooth_proxy.cpp | 236 ++++++++---------- .../bluetooth_proxy/bluetooth_proxy.h | 24 +- 6 files changed, 220 insertions(+), 149 deletions(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.cpp b/esphome/components/bluetooth_connection/bluetooth_connection.cpp index 94bb119c84..a7e9825e56 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection.cpp @@ -46,12 +46,11 @@ BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size #endif // BLUETOOTH_CONNECTION_HAS_GATT -#ifdef USE_ESP32 +#if defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) namespace esphome::bluetooth_connection { -// Address-scoped Bluedroid maintenance shared by every esp32 proxy build, -// including advertisement-only ones where no GATT backend (and none of the -// gated surface above) is compiled - so this block sits outside that gate. +// Address-scoped Bluedroid maintenance. Gated with the connection surface: +// the advertisement-only arm no longer dispatches these requests at all. conn_err_t unpair_device(uint64_t address) { esp_bd_addr_t bda; @@ -66,4 +65,4 @@ conn_err_t clear_gatt_cache(uint64_t address) { } } // namespace esphome::bluetooth_connection -#endif // USE_ESP32 +#endif // USE_ESP32 && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h index d200c6b48f..bcfbdaa6cf 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -20,10 +20,9 @@ // wired by codegen (one slot per connection). This is the single spelling of // that predicate - the hub wrapper and the API request handlers gate on it. // The wrapper serves the proxy's API surface, so it compiles only when a -// backend AND the proxy are present; advertisement-only and backend-only -// builds get the clean-error handlers instead. Address-scoped maintenance -// (unpair, cache clear) still works there through the per-platform free -// functions below. +// backend AND the proxy are present. The address-scoped maintenance functions +// below are only reached from that gated surface; their #else stubs just +// keep this header parsing on arms without a backend. #if defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUETOOTH_PROXY) #define BLUETOOTH_CONNECTION_HAS_GATT #endif @@ -68,12 +67,12 @@ static constexpr bool SUPPORTS_CACHE_CLEARING = false; #endif // Address-scoped (not connection-scoped) maintenance requests. -#if defined(USE_ESP32) || (defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT)) +#if (defined(USE_ESP32) || defined(USE_RP2040_BLE)) && defined(USE_BLE_GATT_CLIENT) conn_err_t unpair_device(uint64_t address); #else inline conn_err_t unpair_device(uint64_t) { return GATT_NOT_CONNECTED; } #endif -#ifdef USE_ESP32 +#if defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) conn_err_t clear_gatt_cache(uint64_t address); #else inline conn_err_t clear_gatt_cache(uint64_t) { return GATT_NOT_CONNECTED; } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index 3909e16305..8707637e9d 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -38,7 +38,7 @@ void BluetoothConnection::set_address(uint64_t address) { this->proxy_->update_address_slot_(this->address_, address); // Slot changing hands: anything owed belonged to the old address. The // choke point for every reassignment, not just reset_connection_()'s path. - this->clear_pending_ack_(); + this->clear_owed_flags_(); this->address_ = address; if (address == 0) { this->address_str_[0] = '\0'; @@ -97,8 +97,7 @@ void BluetoothConnection::reset_connection_(conn_err_t reason) { this->services_discovered_ = false; this->paired_ = false; // Link gone: the slot may hold a different device before the drain runs. - this->clear_pending_ack_(); - this->batch_stalled_ = false; + this->clear_owed_flags_(); this->backend_->release_services(); this->proxy_->reset_connection_slot_(this, reason); } @@ -142,7 +141,7 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int ESP_LOGW(TAG, "[%d] [%s] conn param update failed, err=%d", this->connection_index_, this->address_str_, param_err); } - this->proxy_->send_device_connection(this->address_, true, mtu); + this->send_connected_reply_(); this->proxy_->send_connections_free(); return; } @@ -180,10 +179,49 @@ void BluetoothConnection::on_service_discovery_done(int error) { this->mtu_); this->state_ = ClientState::ESTABLISHED; this->services_discovered_ = true; - this->proxy_->send_device_connection(this->address_, true, this->mtu_); + this->send_connected_reply_(); this->proxy_->send_connections_free(); } +void BluetoothConnection::flush_owed_replies_() { + // Connected first: the client should never see services-done or an ack for + // a link it has not been told is up. Structural, not size-dependent: a + // still-owed connected reply defers the smaller sends to the next tick. + if (this->connected_reply_owed_) { + this->send_connected_reply_(); + if (this->connected_reply_owed_) { + // The retry limits are wall-clock windows: age the deferred budgets so + // a reply cannot outlive the window it was sized for. + if (this->send_service_ == SERVICES_DONE_PENDING) { + this->age_services_done_(); + } + if (this->has_pending_ack_()) { + this->age_pending_ack_(); + } + return; + } + } + if (this->send_service_ == SERVICES_DONE_PENDING) { + this->send_services_done_(); + } + if (this->has_pending_ack_()) { + this->flush_pending_ack_(); + } +} + +void BluetoothConnection::send_connected_reply_() { + if (this->proxy_->send_device_connection(this->address_, true, this->mtu_)) { + this->connected_reply_owed_ = false; + return; + } + // Warn on the leading edge only, as elsewhere: the drop must be visible but + // must not add traffic to the connection that just refused a frame. + if (!this->connected_reply_owed_) { + ESP_LOGW(TAG, "[%d] [%s] Connected reply deferred, TCP buffer full", this->connection_index_, this->address_str_); + this->connected_reply_owed_ = true; + } +} + void BluetoothConnection::log_gatt_operation_error_(const char *operation, uint16_t handle, int status) { ESP_LOGW(TAG, "[%d] [%s] Error %s for handle 0x%2X, status=%d", this->connection_index_, this->address_str_, operation, handle, status); @@ -245,13 +283,16 @@ void BluetoothConnection::send_ack_(PendingAck kind, uint16_t handle, conn_err_t } void BluetoothConnection::flush_pending_ack_() { - // No-op on its own rather than relying on the proxy drain's pre-check. if (!this->has_pending_ack_()) return; if (this->try_send_ack_(this->pending_ack_, this->pending_ack_handle_, this->pending_ack_error_)) { this->clear_pending_ack_(); return; } + this->age_pending_ack_(); +} + +void BluetoothConnection::age_pending_ack_() { if (++this->pending_ack_retries_ >= PENDING_ACK_RETRY_LIMIT) { // Undeliverable: past here the client has given up and may have re-asked, // and a late reply would answer the new request instead of this one. @@ -401,7 +442,13 @@ void BluetoothConnection::send_services_done_() { ESP_LOGW(TAG, "[%d] [%s] Failed to send services done, retrying", this->connection_index_, this->address_str_); this->services_done_retries_ = 0; this->send_service_ = SERVICES_DONE_PENDING; - } else if (++this->services_done_retries_ >= SERVICES_DONE_RETRY_LIMIT) { + } else { + this->age_services_done_(); + } +} + +void BluetoothConnection::age_services_done_() { + if (++this->services_done_retries_ >= SERVICES_DONE_RETRY_LIMIT) { // Undeliverable (see SERVICES_DONE_RETRY_LIMIT); silence arbitrates. ESP_LOGW(TAG, "[%d] [%s] Services done undeliverable, abandoning", this->connection_index_, this->address_str_); this->send_service_ = DONE_SENDING_SERVICES; diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index b0d0f5fd46..3553f8bf00 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -151,6 +151,20 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { /// caller rewinds the cursor), and a warning per attempt would add traffic /// to the connection already refusing frames. Both streamers route here. void note_batch_stalled_(); + /// Send the connected=true reply, latching it if the API refuses. Rebuilt + /// from address_ and mtu_, so the latch is one bit; a dropped confirmation + /// leaves the client timing out while this slot holds a live link. No retry + /// bound: the slot's lifetime is the bound (teardown clears the flag). + void send_connected_reply_(); + /// Re-offer everything this slot owes. One entry point so the proxy drain + /// does not have to know which latches exist. + void flush_owed_replies_(); + /// Drop everything this slot owes, in one write to the shared tail byte. + void clear_owed_flags_() { + this->pending_ack_ = PendingAck::PENDING_ACK_NONE; + this->batch_stalled_ = false; + this->connected_reply_owed_ = false; + } /// Sole construction site for these replies, shared by send and retry. bool try_send_ack_(PendingAck kind, uint16_t handle, conn_err_t error); /// First attempt: send, and latch it for the drain if the API refuses. @@ -162,6 +176,8 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { } /// Re-offer the owed reply; clears on success, stays owed on a refusal. void flush_pending_ack_(); + /// Advance the retry budget and abandon at the limit, without sending. + void age_pending_ack_(); // A backend providing its own streamer (see the contract doc) builds the // response in place from its stack cache; the rest use the table streamer. // Template so the discarded branch is not odr-checked against backends @@ -177,8 +193,6 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { /// interrupted stream must never be declared complete (the client's /// timeout arbitrates), and an owed done is dropped with it. void park_service_stream_() { - // Agree with reset_connection_(): a stall flag left set would swallow the - // next session's leading-edge warning. this->batch_stalled_ = false; if (this->send_service_ >= 0) { this->backend_->release_services(); @@ -193,6 +207,8 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { /// retries). Callers release the table first; the message needs only the /// address. void send_services_done_(); + /// Advance the retry budget and abandon at the limit, without sending. + void age_services_done_(); void reset_connection_(conn_err_t reason); conn_err_t check_connected_op_(const char *action, const char *type) const; void log_gatt_operation_error_(const char *operation, uint16_t handle, int status); @@ -220,8 +236,10 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { // uses tail slack instead of pushing address_ out by 6 bytes of padding. uint16_t pending_ack_handle_{0}; - // Group 5: bit-packed tail. pending_ack_error_ takes the 8-aligned object - // from 48 to 56, so the third tail byte is free; first two stay packed. + // Group 5: bit-packed tail. The first two bytes were already full, so the + // first added bit forced a third and took the 8-aligned object 48 -> 56; + // the handle, error and retry counter ride in that padding. Four bitfield + // bits left; another byte-sized member costs 8 per slot. static_assert(static_cast(ClientState::ESTABLISHED) < (1 << 3), "state_ bitfield too narrow"); static_assert(static_cast(ConnectionType::V3_WITHOUT_CACHE) < (1 << 2), "connection_type_ bitfield too narrow"); @@ -238,6 +256,8 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { PendingAck pending_ack_ : 2 {PendingAck::PENDING_ACK_NONE}; /// Set while a refused batch is retrying, so only the first one warns. bool batch_stalled_ : 1 {false}; + /// An owed connected=true reply; the proxy's paced drain re-offers it. + bool connected_reply_owed_ : 1 {false}; // Plain byte after the bitfields: takes the padding byte instead of // straddling pending_ack_'s storage unit and growing the object. static_assert(PENDING_ACK_RETRY_LIMIT <= 0xFF, "retry counter too narrow"); diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 0d91b541e8..0cf8483cea 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -122,6 +122,18 @@ void BluetoothProxy::log_connection_info_(BluetoothConnection *connection, const } #endif // BLUETOOTH_CONNECTION_HAS_GATT +void BluetoothProxy::log_reply_dropped_(const char *what, uint64_t address) { + ESP_LOGW(TAG, "%s reply for %012" PRIX64 " dropped, TCP buffer full", what, address); +} + +void BluetoothProxy::log_reply_deferred_(const char *what, uint64_t address) { + ESP_LOGW(TAG, "%s reply for %012" PRIX64 " deferred, TCP buffer full", what, address); +} + +void BluetoothProxy::log_reply_displaced_(const char *what, uint64_t owed, uint64_t address) { + ESP_LOGW(TAG, "%s reply for %012" PRIX64 " dropped, displaced by %012" PRIX64, what, owed, address); +} + void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *type) { ESP_LOGW(TAG, "Cannot %s GATT %s, not connected", action, type); } @@ -129,7 +141,10 @@ void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *typ void BluetoothProxy::handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action, const char *type) { this->log_not_connected_gatt_(action, type); - this->send_gatt_error(address, handle, GATT_NOT_CONNECTED); + if (!this->send_gatt_error(address, handle, GATT_NOT_CONNECTED)) { + // No connection, so nothing to latch against; the client's timeout arbitrates. + this->log_reply_dropped_("Not-connected", address); + } } void BluetoothProxy::log_advertisement_flush_() { @@ -209,12 +224,12 @@ void BluetoothProxy::latch_pending_disconnection_(uint64_t address, conn_err_t e } } if (free_entry != nullptr) { + this->log_reply_deferred_("Disconnect", address); free_entry->set(address, error); return; } // Every entry is owed: evict the first so the newest loss is not silent too. - ESP_LOGW(TAG, "Owed disconnect dropped (0x%llx), retry pool full", - (unsigned long long) this->pending_disconnections_[0].address()); + this->log_reply_displaced_("Disconnect", this->pending_disconnections_[0].address(), address); this->pending_disconnections_[0].set(address, error); } @@ -224,19 +239,39 @@ void BluetoothProxy::clear_pending_disconnection_(uint64_t address) { for (uint8_t i = 0; i < this->connection_count_; i++) { if (this->pending_disconnections_[i].matches(address)) { this->pending_disconnections_[i].clear(); + return; // latch_pending_disconnection_ keeps at most one entry per address } } } -void BluetoothProxy::reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason) { - if (!this->send_device_connection(connection->get_address(), false, 0, reason)) { - // The client has no other way to learn of an unsolicited disconnect; - // latch and let loop()'s paced drain deliver it. V by design: a louder - // level would ride the same congested link this reports on. - ESP_LOGV(TAG, "[%d] [%s] Disconnect notification deferred, TCP buffer full", connection->get_connection_index(), - connection->address_str()); - this->latch_pending_disconnection_(connection->get_address(), reason); +void BluetoothProxy::answer_device_disconnected_(uint64_t address) { + if (this->send_device_connection(address, false)) { + // A landed answer satisfies any owed notification for the address; a + // drained duplicate would follow it otherwise. + this->clear_pending_disconnection_(address); + return; } + // Not latched: the client's own request timeout arbitrates, and pooling + // these would let a request retry loop displace an unsolicited disconnect. + this->log_reply_dropped_("Disconnect", address); +} + +void BluetoothProxy::send_device_disconnected_(uint64_t address, conn_err_t error) { + if (this->send_device_connection(address, false, 0, error)) { + // A later disconnect landing for an address that still has one owed would + // otherwise have the drain repeat it. + this->clear_pending_disconnection_(address); + return; + } + // A dropped disconnect leaves the client believing the link is live, so + // every GATT operation on it times out until something else corrects it. + // latch_pending_disconnection_() reports the leading edge. + this->latch_pending_disconnection_(address, error); +} + +void BluetoothProxy::reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason) { + // The client has no other way to learn of an unsolicited disconnect. + this->send_device_disconnected_(connection->get_address(), reason); connection->set_address(0); connection->send_service_ = INIT_SENDING_SERVICES; this->send_connections_free(); @@ -282,18 +317,18 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest auto *connection = this->get_connection_(msg.address, true); if (connection == nullptr) { ESP_LOGW(TAG, "No free connections available"); - this->send_device_connection(msg.address, false); + this->answer_device_disconnected_(msg.address); return; } if (!msg.has_address_type) { ESP_LOGE(TAG, "[%d] [%s] Missing address type in connect request", connection->get_connection_index(), connection->address_str()); - this->send_device_connection(msg.address, false); + this->answer_device_disconnected_(msg.address); return; } if (connection->state() == ClientState::CONNECTED || connection->state() == ClientState::ESTABLISHED) { this->log_connection_request_ignored_(connection, connection->state()); - this->send_device_connection(msg.address, true); + connection->send_connected_reply_(); this->send_connections_free(); return; } else if (connection->state() == ClientState::DISCONNECTING && connection->cancel_teardown()) { @@ -320,7 +355,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT: { auto *connection = this->get_connection_(msg.address, false); if (connection == nullptr) { - this->send_device_connection(msg.address, false); + this->answer_device_disconnected_(msg.address); this->send_connections_free(); return; } @@ -328,7 +363,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest connection->disconnect(); } else { connection->set_address(0); - this->send_device_connection(msg.address, false); + this->answer_device_disconnected_(msg.address); this->send_connections_free(); } break; @@ -372,7 +407,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT: { ESP_LOGE(TAG, "V1 connections removed"); - this->send_device_connection(msg.address, false); + this->answer_device_disconnected_(msg.address); break; } } @@ -484,7 +519,8 @@ void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) { if (this->api_connection_ == nullptr) return; - // Send results unchecked (esp32 parity): a drop resolves via the client timeout. + // Not latched (esp32 parity): the request is idempotent, so a drop resolves + // via the client timeout and a retry gives the same answer. Still reported. auto *connection = this->get_connection_(msg.address, false); api::BluetoothSetConnectionParamsResponse resp; @@ -495,7 +531,9 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn connection ? static_cast(connection->get_connection_index()) : -1, connection ? connection->address_str() : "unknown"); resp.error = GATT_NOT_CONNECTED; - this->api_connection_->send_message(resp); + if (!this->api_connection_->send_message(resp)) { + this->log_reply_dropped_("Connection-params", msg.address); + } return; } @@ -506,7 +544,9 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn static_cast(std::min(msg.max_interval, max_val)), static_cast(std::min(msg.latency, max_val)), static_cast(std::min(msg.timeout, max_val))); - this->api_connection_->send_message(resp); + if (!this->api_connection_->send_message(resp)) { + this->log_reply_dropped_("Connection-params", msg.address); + } } #endif // BLUETOOTH_CONNECTION_HAS_GATT @@ -568,9 +608,9 @@ void BluetoothProxy::loop() { if (this->connections_free_pending_ && this->api_connection_ != nullptr) { // Resend a dropped slot-state update, paced by the 100 ms gate so the - // retry does not hammer the congestion it exists to survive; the - // advertisement-only arm answers DISCONNECT requests with this message - // too, so the drain compiles on every proxy build. + // retry does not hammer the congestion it exists to survive. Every build + // sends this at subscribe time (api_connection.cpp), so the drain + // compiles on every proxy build. this->connections_free_pending_ = false; this->send_connections_free(this->api_connection_); } @@ -593,17 +633,17 @@ void BluetoothProxy::loop() { // Paced retries of owed per-slot notifications; subscriber swaps clear // stale latches before this runs. for (uint8_t i = 0; i < this->connection_count_; i++) { - auto *connection = this->connections_[i]; - if (connection->send_service_ == SERVICES_DONE_PENDING) { - connection->send_services_done_(); - } - if (connection->has_pending_ack_()) { - connection->flush_pending_ack_(); - } + this->connections_[i]->flush_owed_replies_(); + } + // Address-keyed, not slot-keyed, so it gets its own loop; bounded by + // connection_count_ like the latch and clear helpers. Not pre-cleared: + // the sender clears on success and re-latches on refusal, keeping the + // latch's leading-edge warn honest (same shape as the unpair drain). + for (uint8_t i = 0; i < this->connection_count_; i++) { auto &owed = this->pending_disconnections_[i]; - if (!owed.empty() && this->send_device_connection(owed.address(), false, 0, owed.error())) { - owed.clear(); - } + if (owed.empty()) + continue; + this->send_device_disconnected_(owed.address(), owed.error()); } // An owed unpair reply. Not pre-cleared: the sender clears on success and @@ -632,75 +672,21 @@ void BluetoothProxy::loop() { #ifndef BLUETOOTH_CONNECTION_HAS_GATT -// Advertisement-only proxy. GATT client connections are excluded at compile -// time (no connection backend on this platform, or active: false), so every -// connection-oriented request is answered with a clean error instead of -// silence, and Home Assistant treats the proxy as passive. +// Advertisement-only proxy: no connection backend on this platform, or +// active: false. get_feature_flags() then omits FEATURE_ACTIVE_CONNECTIONS, +// so a client treats the proxy as passive and never sends a connection or +// GATT request. These exist only because the api layer dispatches them +// unconditionally; answering would link response encoders this build has no +// use for. -void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest &msg) { - switch (msg.request_type) { - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE: - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE: - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT: - ESP_LOGW(TAG, "Active connections are not supported on this platform"); - this->send_device_connection(msg.address, false, 0, GATT_NOT_CONNECTED); - break; - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT: - // Not an error: the device is already disconnected, which is the requested state. - this->send_device_connection(msg.address, false); - this->send_connections_free(); - break; - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR: - this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED); - break; - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR: { - // Address-scoped maintenance needs no connection slot: real on esp32 - // (Bluedroid bond table), the stub elsewhere keeps the old error reply. - conn_err_t ret = bluetooth_connection::unpair_device(msg.address); - this->send_device_unpairing(msg.address, ret == CONN_OK, ret); - break; - } - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE: { - conn_err_t ret = bluetooth_connection::clear_gatt_cache(msg.address); - this->send_device_clear_cache(msg.address, ret == CONN_OK, ret); - break; - } - } -} - -void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg) { - this->handle_gatt_not_connected_(msg.address, msg.handle, "read", "characteristic"); -} - -void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &msg) { - this->handle_gatt_not_connected_(msg.address, msg.handle, "write", "characteristic"); -} - -void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTReadDescriptorRequest &msg) { - this->handle_gatt_not_connected_(msg.address, msg.handle, "read", "descriptor"); -} - -void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWriteDescriptorRequest &msg) { - this->handle_gatt_not_connected_(msg.address, msg.handle, "write", "descriptor"); -} - -void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg) { - this->handle_gatt_not_connected_(msg.address, 0, "get", "services"); -} - -void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg) { - this->handle_gatt_not_connected_(msg.address, msg.handle, "notify", "characteristic"); -} - -void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) { - if (this->api_connection_ == nullptr) - return; - // Send results unchecked (esp32 parity): a drop resolves via the client timeout. - api::BluetoothSetConnectionParamsResponse resp; - resp.address = msg.address; - resp.error = GATT_NOT_CONNECTED; - this->api_connection_->send_message(resp); -} +void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest &msg) {} +void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg) {} +void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &msg) {} +void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTReadDescriptorRequest &msg) {} +void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWriteDescriptorRequest &msg) {} +void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg) {} +void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg) {} +void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) {} #endif // !BLUETOOTH_CONNECTION_HAS_GATT @@ -723,8 +709,9 @@ void BluetoothProxy::reset_owed_replies_() { for (uint8_t i = 0; i < this->connection_count_; i++) { // Neither a partial stream's tail nor an owed done belongs to the next // session; silence (the client's timeout) arbitrates. - this->connections_[i]->park_service_stream_(); - this->connections_[i]->clear_pending_ack_(); + auto *connection = this->connections_[i]; + connection->park_service_stream_(); + connection->clear_owed_flags_(); } #endif } @@ -810,6 +797,7 @@ bool BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, conn_err return this->api_connection_->send_message(call); } +#ifdef BLUETOOTH_CONNECTION_HAS_GATT void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, conn_err_t error) { if (this->api_connection_ == nullptr) return; @@ -818,13 +806,16 @@ void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, conn_err call.paired = paired; call.error = error; - this->api_connection_->send_message(call); + if (!this->api_connection_->send_message(call)) { + // Not latched: a retried PAIR is answered from is_paired(), so the client + // recovers on its own. Still worth saying it happened. + this->log_reply_dropped_("Pairing", address); + } } void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, conn_err_t error) { if (this->api_connection_ == nullptr) return; -#ifdef BLUETOOTH_CONNECTION_HAS_GATT // An owed success is the authoritative answer: a later attempt for the // same address fails only because the first already removed the bond. if (!this->pending_unpairing_.empty() && this->pending_unpairing_.matches(address) && @@ -832,38 +823,29 @@ void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, conn_ success = true; error = CONN_OK; } -#endif api::BluetoothDeviceUnpairingResponse call; call.address = address; call.success = success; call.error = error; - // Advertisement-only builds answer this with a canned reply and keep no - // retry state, so only the latch is conditional, not the send. - [[maybe_unused]] bool sent = this->api_connection_->send_message(call); -#ifdef BLUETOOTH_CONNECTION_HAS_GATT - if (sent) { + if (this->api_connection_->send_message(call)) { // A later unpair landing for an address that still has one owed would // otherwise have the drain repeat it. if (this->pending_unpairing_.matches(address)) { this->pending_unpairing_.clear(); } - } else { - // Warn on the leading edge and on displacement (that one loses a reply); - // the drain's re-refusals of the same reply stay quiet. - if (this->pending_unpairing_.empty()) { - ESP_LOGW(TAG, "Unpair reply for %012" PRIX64 " deferred, TCP buffer full", address); - } else if (!this->pending_unpairing_.matches(address)) { - ESP_LOGW(TAG, "Owed unpair reply for %012" PRIX64 " dropped, displaced by %012" PRIX64, - this->pending_unpairing_.address(), address); - } - this->pending_unpairing_.set(address, error); + return; } -#endif + if (this->pending_unpairing_.empty()) { + this->log_reply_deferred_("Unpair", address); + } else if (!this->pending_unpairing_.matches(address)) { + this->log_reply_displaced_("Unpair", this->pending_unpairing_.address(), address); + } + this->pending_unpairing_.set(address, error); } -// Shared by both platform paths: the neutral bluetooth_device_request() uses it to -// answer a clear-cache request with a clean error, so it must not be esp32-guarded. +// GATT arm only: the advertisement-only arm no longer dispatches CLEAR_CACHE, +// so its response encoder would be dead weight there. void BluetoothProxy::send_device_clear_cache(uint64_t address, bool success, conn_err_t error) { if (this->api_connection_ == nullptr) return; @@ -872,8 +854,12 @@ void BluetoothProxy::send_device_clear_cache(uint64_t address, bool success, con call.success = success; call.error = error; - this->api_connection_->send_message(call); + if (!this->api_connection_->send_message(call)) { + // Not latched: clear-cache is idempotent, so a retry gives the same answer. + this->log_reply_dropped_("Clear-cache", address); + } } +#endif BluetoothProxy *global_bluetooth_proxy = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 16b153625f..de70b35aaf 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -138,8 +138,8 @@ class BluetoothProxy final : public Component { } /// False only when a subscriber refused the frame; true = delivered or - /// nobody subscribed. Request-answer callers ignore the result (client - /// timeouts cover those); only reset_connection_slot_ latches for retry. + /// nobody subscribed. Refusals latch in send_device_disconnected_() and + /// send_connected_reply_(); other callers report via log_reply_dropped_(). bool send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, conn_err_t error = CONN_OK); void send_connections_free(); void send_connections_free(api::APIConnection *api_connection); @@ -147,11 +147,13 @@ class BluetoothProxy final : public Component { bool send_gatt_services_done(uint64_t address); /// False only when the API refused the frame, so the reply is still owed. bool send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error); +#ifdef BLUETOOTH_CONNECTION_HAS_GATT void send_device_pairing(uint64_t address, bool paired, conn_err_t error = CONN_OK); /// No default error: the drain rebuilds success as (error == CONN_OK), so a /// caller that omitted it would have a reported failure resent as a success. void send_device_unpairing(uint64_t address, bool success, conn_err_t error); void send_device_clear_cache(uint64_t address, bool success, conn_err_t error = CONN_OK); +#endif void bluetooth_scanner_set_mode(bool active); @@ -230,6 +232,9 @@ class BluetoothProxy final : public Component { void flush_pending_advertisements_() { if (this->response_.advertisements_len == 0) return; + // The one deliberately ignored result: advertisements are perishable and + // this is the highest-frequency send here, so reporting each drop would be + // the flood the batch pacing exists to avoid. this->api_connection_->send_message(this->response_); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE this->log_advertisement_flush_(); @@ -282,6 +287,15 @@ class BluetoothProxy final : public Component { void reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason); /// Drop any owed freed-slot notification for this address (client reconnected). void clear_pending_disconnection_(uint64_t address); + /// Send connected=false and pool it for the paced drain if refused. A + /// dropped disconnect desynchronises the proxy: the client keeps a link it + /// believes is live and every operation on it times out. Unsolicited and + /// drained notifications only; request answers use the variant below. + void send_device_disconnected_(uint64_t address, conn_err_t error = CONN_OK); + /// Answer a request with connected=false. Never pools: a refusal falls back + /// to the client's request timeout, keeping the pool for the unsolicited + /// notifications the client cannot recover on its own. + void answer_device_disconnected_(uint64_t address); /// Pool a refused freed-slot notification for the paced drain. void latch_pending_disconnection_(uint64_t address, conn_err_t error); #endif @@ -291,6 +305,12 @@ class BluetoothProxy final : public Component { /// Drops state only, never sends: api_connection_ is the departing /// subscriber on subscribe and nullptr on unsubscribe. void reset_owed_replies_(); + /// Report a reply we deliberately do not latch, so no drop is silent. + void log_reply_dropped_(const char *what, uint64_t address); + /// A latched reply's leading edge; the drain's re-refusals stay quiet. + void log_reply_deferred_(const char *what, uint64_t address); + /// A latched reply lost to a newer one for a different address. + void log_reply_displaced_(const char *what, uint64_t owed, uint64_t address); // Memory optimized layout for 32-bit systems // Group 1: Pointers (4 bytes each, naturally aligned) From 3349046c5d0d4963e20f37e6ac0181f869557315 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:56:05 +1200 Subject: [PATCH 1410/1815] [rp2] Record the RP2350 die on generated board entries (#18305) --- esphome/components/rp2/boards.jinja2 | 4 + esphome/components/rp2/boards.py | 48 +++++++++++ esphome/components/rp2/generate_boards.py | 60 ++++++++++---- .../components/test_rp2_generate_boards.py | 83 ++++++++++++++++++- 4 files changed, 175 insertions(+), 20 deletions(-) diff --git a/esphome/components/rp2/boards.jinja2 b/esphome/components/rp2/boards.jinja2 index 9223009c26..6e5e55d771 100644 --- a/esphome/components/rp2/boards.jinja2 +++ b/esphome/components/rp2/boards.jinja2 @@ -14,6 +14,10 @@ RP2_BOARD_PINS = { {%- endfor %} } +# RP2350 boards carry a {{ rp2350_die_key | repr }} key holding the die letter: +# 'A' for the RP2350A (GPIO 0-29, 5 ADC channels), 'B' for the RP2350B +# (GPIO 0-47, 9 ADC channels), and None when the die is a build-time menu +# choice and so is not known here. The key is absent on non-RP2350 boards. BOARDS = { {%- for name, info in boards %} {{ name | repr }}: { diff --git a/esphome/components/rp2/boards.py b/esphome/components/rp2/boards.py index d2502b8fb8..4b2f9769b0 100644 --- a/esphome/components/rp2/boards.py +++ b/esphome/components/rp2/boards.py @@ -1533,6 +1533,10 @@ RP2_BOARD_PINS = { }, } +# RP2350 boards carry a 'die' key holding the die letter: +# 'A' for the RP2350A (GPIO 0-29, 5 ADC channels), 'B' for the RP2350B +# (GPIO 0-47, 9 ADC channels), and None when the die is a build-time menu +# choice and so is not known here. The key is absent on non-RP2350 boards. BOARDS = { "0xcb_helios": { "name": "0xCB Helios", @@ -1548,6 +1552,7 @@ BOARDS = { "name": "MyMakers RP2350B", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "MyRP_bot": { "name": "MyMakers RP2040", @@ -1588,11 +1593,13 @@ BOARDS = { "name": "Adafruit Feather RP2350 Adalogger", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "adafruit_feather_rp2350_hstx": { "name": "Adafruit Feather RP2350 HSTX", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "adafruit_feather_scorpio": { "name": "Adafruit Feather RP2040 SCORPIO", @@ -1618,6 +1625,7 @@ BOARDS = { "name": "Adafruit Fruit Jam RP2350", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "adafruit_itsybitsy": { "name": "Adafruit ItsyBitsy RP2040", @@ -1643,6 +1651,7 @@ BOARDS = { "name": "Adafruit Metro RP2350", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "adafruit_qtpy": { "name": "Adafruit QT Py RP2040", @@ -1763,16 +1772,19 @@ BOARDS = { "name": "iLabs Challenger 2350 BConnect", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "challenger_2350_nbiot": { "name": "iLabs Challenger 2350 NB-IoT", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "challenger_2350_wifi6_ble5": { "name": "iLabs Challenger 2350 WiFi/BLE", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "challenger_nb_2040_wifi": { "name": "iLabs Challenger NB 2040 WiFi", @@ -1788,6 +1800,7 @@ BOARDS = { "name": "Cytron IRIV IO Controller", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "cytron_maker_nano_rp2040": { "name": "Cytron Maker Nano RP2040", @@ -1808,6 +1821,7 @@ BOARDS = { "name": "Cytron Motion 2350 Pro", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "datanoisetv_picoadk": { "name": "DatanoiseTV PicoADK", @@ -1818,6 +1832,7 @@ BOARDS = { "name": "DatanoiseTV PicoADK v2", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "degz_suibo": { "name": "Degz Robotics Suibo RP2040", @@ -1863,6 +1878,7 @@ BOARDS = { "name": "Generic RP2350", "mcu": "rp2350", "max_pin": 47, + "die": None, }, "groundstudio_marble_pico": { "name": "GroundStudio Marble Pico", @@ -1873,6 +1889,7 @@ BOARDS = { "name": "iLabs CPico 2350", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "ilabs_rpico32": { "name": "iLabs RPICO32", @@ -1888,6 +1905,7 @@ BOARDS = { "name": "Architeuthis Flux Jumperless V5", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "melopero_cookie_rp2040": { "name": "Melopero Cookie RP2040", @@ -1928,16 +1946,19 @@ BOARDS = { "name": "Olimex Pico2BB48", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "olimex_pico2xl": { "name": "Olimex Pico2XL", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "olimex_pico2xxl": { "name": "Olimex Pico2XXL", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "olimex_rp2040pico30": { "name": "Olimex RP2040-Pico30", @@ -1963,6 +1984,7 @@ BOARDS = { "name": "Pimoroni Explorer", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "pimoroni_pga2040": { "name": "Pimoroni PGA2040", @@ -1973,16 +1995,19 @@ BOARDS = { "name": "Pimoroni PGA2350", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "pimoroni_pico_plus_2": { "name": "Pimoroni PicoPlus2", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "pimoroni_pico_plus_2w": { "name": "Pimoroni PicoPlus2W", "mcu": "rp2350", "max_pin": 47, + "die": "B", "wifi": True, "max_virtual_pin": 64, }, @@ -1995,11 +2020,13 @@ BOARDS = { "name": "Pimoroni Plasma2350", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "pimoroni_plasma2350w": { "name": "Pimoroni Plasma2350W", "mcu": "rp2350", "max_pin": 29, + "die": "A", "wifi": True, }, "pimoroni_servo2040": { @@ -2016,6 +2043,7 @@ BOARDS = { "name": "Pimoroni Tiny2350", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "pintronix_pinmax": { "name": "Pintronix PinMax", @@ -2046,11 +2074,13 @@ BOARDS = { "name": "Raspberry Pi Pico 2", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "rpipico2w": { "name": "Raspberry Pi Pico 2W", "mcu": "rp2350", "max_pin": 29, + "die": "A", "wifi": True, "max_virtual_pin": 64, }, @@ -2085,6 +2115,7 @@ BOARDS = { "name": "Seeed XIAO RP2350", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "silicognition_rp2040_shim": { "name": "Silicognition RP2040-Shim", @@ -2100,6 +2131,7 @@ BOARDS = { "name": "Soldered Electronics NULA RP2350", "mcu": "rp2350", "max_pin": 47, + "die": "B", "wifi": True, }, "solderparty_rp2040_stamp": { @@ -2111,21 +2143,25 @@ BOARDS = { "name": "Solder Party RP2350 Stamp", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "solderparty_rp2350_stamp_xl": { "name": "Solder Party RP2350 Stamp XL", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "sparkfun_iotnode_lorawanrp2350": { "name": "SparkFun IoT Node LoRaWAN", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "sparkfun_iotredboard_rp2350": { "name": "SparkFun IoT RedBoard RP2350", "mcu": "rp2350", "max_pin": 47, + "die": "B", "wifi": True, }, "sparkfun_micromodrp2040": { @@ -2142,6 +2178,7 @@ BOARDS = { "name": "SparkFun ProMicro RP2350", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "sparkfun_thingplusrp2040": { "name": "SparkFun Thing Plus RP2040", @@ -2152,6 +2189,7 @@ BOARDS = { "name": "SparkFun Thing Plus RP2350", "mcu": "rp2350", "max_pin": 29, + "die": "A", "wifi": True, "max_virtual_pin": 64, }, @@ -2159,6 +2197,7 @@ BOARDS = { "name": "SparkFun XRP Controller", "mcu": "rp2350", "max_pin": 47, + "die": "B", "wifi": True, "max_virtual_pin": 64, }, @@ -2233,32 +2272,38 @@ BOARDS = { "name": "Waveshare RP2350 LCD 0.96", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "waveshare_rp2350_pizero": { "name": "Waveshare RP2350 PiZero", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "waveshare_rp2350_plus": { "name": "Waveshare RP2350 Plus", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "waveshare_rp2350_zero": { "name": "Waveshare RP2350 Zero", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "waveshare_rp2350b_plus_w": { "name": "Waveshare RP2350B Plus W", "mcu": "rp2350", "max_pin": 47, + "die": "B", "wifi": True, }, "weact_rp2350b": { "name": "WeAct Studio RP2350B Core Board", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "wiznet_5100s_evb_pico": { "name": "WIZnet W5100S-EVB-Pico", @@ -2269,6 +2314,7 @@ BOARDS = { "name": "WIZnet W5100S-EVB-Pico2", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "wiznet_5500_evb_pico": { "name": "WIZnet W5500-EVB-Pico", @@ -2279,6 +2325,7 @@ BOARDS = { "name": "WIZnet W5500-EVB-Pico2", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "wiznet_55rp20_evb_pico": { "name": "WIZnet W55RP20-EVB-Pico", @@ -2294,6 +2341,7 @@ BOARDS = { "name": "WIZnet W6300-EVB-Pico2", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "wiznet_wizfi360_evb_pico": { "name": "WIZnet WizFi360-EVB-Pico", diff --git a/esphome/components/rp2/generate_boards.py b/esphome/components/rp2/generate_boards.py index 5618287cce..cd3f50182c 100644 --- a/esphome/components/rp2/generate_boards.py +++ b/esphome/components/rp2/generate_boards.py @@ -37,13 +37,23 @@ MCU_MAX_PIN = { "rp2350": 47, # GPIO 0-47 (RP2350B; A-die boards are narrowed to 29 below) } DEFAULT_MAX_PIN = 29 -# The RP2350 comes in two die variants: RP2350A exposes GPIO 0-29, RP2350B -# GPIO 0-47. Variant headers declare the die via PICO_RP2350A (1 = A, 0 = B). +# The RP2350 currently comes in two die variants: RP2350A exposes GPIO 0-29, +# RP2350B GPIO 0-47. Variant headers declare the die via PICO_RP2350A +# (1 = A, 0 = B). +RP2350_DIE_A = "A" +RP2350_DIE_B = "B" RP2350A_MAX_PIN = 29 +# Key recording the die letter on RP2350 board entries. Holds a letter rather +# than a bool so a future die can be named instead of forced into "not A". +RP2350_DIE_KEY = "die" PIN_DEFINE_RE = re.compile(r"#define\s+PIN_(\w+)\s+\((\d+)u\)") # Accepts the literal forms seen in these headers: 1, (1), 1u, (1u) RP2350A_DEFINE_RE = re.compile(r"#define\s+PICO_RP2350A\s+(\S+)") +# Only PICO_RP2350A exists today. A define for any other die letter means the +# A/B assumption below no longer holds. The trailing \b keeps this from +# matching unrelated names such as PICO_RP2350_A2_SUPPORTED. +OTHER_DIE_DEFINE_RE = re.compile(r"#define\s+PICO_RP2350(?!A\b)([B-Z])\b") RP2350A_MENU_PLACEHOLDER = "__PICO_RP2350A" @@ -62,23 +72,30 @@ def parse_variant_pins(variant_dir: Path) -> dict[str, int]: return pins -def parse_variant_is_rp2350a(variant_dir: Path) -> bool: - """Return True if the variant declares an RP2350A die (GPIO 0-29 only). +def parse_variant_rp2350_die(variant_dir: Path) -> str | None: + """Return the RP2350 die letter the variant declares, or None if unknown. Generic boards leave the die a build-time menu choice (PICO_RP2350A is set - to a __PICO_RP2350A placeholder rather than a literal); those return False - so they keep the permissive B-die pin range. + to a __PICO_RP2350A placeholder rather than a literal); those return None, + meaning the die is genuinely unknown at code generation time. They keep the + permissive B-die pin range, but that is a fallback and must not be recorded + as a known die. A missing or unrecognized define raises: silently treating it as B-die would widen pin validation back to GPIO 47 on A-die boards, so a framework - bump that changes the header format must fail loudly here instead. + bump that changes the header format must fail loudly here instead. The same + goes for a die beyond A and B: PICO_RP2350A is a yes/no answer about the A + die, so "not A" can only be read as B while A and B are the whole family. """ header = variant_dir / "pins_arduino.h" - match = ( - RP2350A_DEFINE_RE.search(header.read_text(encoding="utf-8")) - if header.exists() - else None - ) + text = header.read_text(encoding="utf-8") if header.exists() else "" + if other_die := OTHER_DIE_DEFINE_RE.search(text): + raise ValueError( + f"{header}: found a PICO_RP2350{other_die.group(1)} define; the " + "RP2350 gained a die beyond A and B, so PICO_RP2350A being 0 no " + "longer means the B die" + ) + match = RP2350A_DEFINE_RE.search(text) if match is None: raise ValueError( f"{header}: no PICO_RP2350A define found; cannot classify the " @@ -86,14 +103,14 @@ def parse_variant_is_rp2350a(variant_dir: Path) -> bool: ) value = match.group(1) if value == RP2350A_MENU_PLACEHOLDER: - return False + return None literal = value.strip("()u") if not literal.isdigit(): raise ValueError( f"{header}: unrecognized PICO_RP2350A value {value!r}; cannot " "classify the RP2350 die (A exposes GPIO 0-29, B exposes GPIO 0-47)" ) - return int(literal) == 1 + return RP2350_DIE_A if int(literal) == 1 else RP2350_DIE_B def load_boards(arduino_pico_path: Path) -> tuple[dict, dict]: @@ -104,7 +121,7 @@ def load_boards(arduino_pico_path: Path) -> tuple[dict, dict]: board_pins = {} boards = {} variant_pins_cache: dict[str, dict[str, int]] = {} - variant_rp2350a_cache: dict[str, bool] = {} + variant_die_cache: dict[str, str | None] = {} for json_file in sorted(json_dir.glob("*.json")): board_name = json_file.stem @@ -123,12 +140,14 @@ def load_boards(arduino_pico_path: Path) -> tuple[dict, dict]: has_wifi = "PICO_CYW43_SUPPORTED=1" in extra_flags max_pin = MCU_MAX_PIN.get(mcu, DEFAULT_MAX_PIN) + die: str | None = None if mcu == "rp2350": - if variant not in variant_rp2350a_cache: - variant_rp2350a_cache[variant] = parse_variant_is_rp2350a( + if variant not in variant_die_cache: + variant_die_cache[variant] = parse_variant_rp2350_die( variants_dir / variant ) - if variant_rp2350a_cache[variant]: + die = variant_die_cache[variant] + if die == RP2350_DIE_A: max_pin = RP2350A_MAX_PIN board_entry: dict = { @@ -136,6 +155,10 @@ def load_boards(arduino_pico_path: Path) -> tuple[dict, dict]: "mcu": mcu, "max_pin": max_pin, } + if mcu == "rp2350": + # Recorded explicitly because max_pin cannot express the die: + # 29 also means RP2040, and 47 also means "die not known yet". + board_entry[RP2350_DIE_KEY] = die if has_wifi: board_entry["wifi"] = True boards[board_name] = board_entry @@ -218,6 +241,7 @@ def generate(arduino_pico_path: Path) -> str: cyw43_gpio_offset=CYW43_GPIO_OFFSET, cyw43_max_gpio=CYW43_GPIO_OFFSET + CYW43_GPIO_COUNT - 1, default_max_pin=DEFAULT_MAX_PIN, + rp2350_die_key=RP2350_DIE_KEY, board_pins=sorted(board_pins.items()), boards=sorted(boards.items()), ) diff --git a/tests/unit_tests/components/test_rp2_generate_boards.py b/tests/unit_tests/components/test_rp2_generate_boards.py index c5d2214695..329248488c 100644 --- a/tests/unit_tests/components/test_rp2_generate_boards.py +++ b/tests/unit_tests/components/test_rp2_generate_boards.py @@ -8,7 +8,11 @@ import textwrap import pytest -from esphome.components.rp2.generate_boards import load_boards, parse_variant_pins +from esphome.components.rp2.generate_boards import ( + generate, + load_boards, + parse_variant_pins, +) PICO_PINS_HEADER = textwrap.dedent("""\ #pragma once @@ -151,6 +155,8 @@ def test_load_basic_board(arduino_pico: Path) -> None: assert boards["rpipico"]["name"] == "Raspberry Pi Pico" assert boards["rpipico"]["mcu"] == "rp2040" assert boards["rpipico"]["max_pin"] == 29 + # The die key only applies to the RP2350, which ships as more than one die + assert "die" not in boards["rpipico"] assert "rpipico" in board_pins assert board_pins["rpipico"]["LED"] == 25 @@ -172,6 +178,7 @@ def test_load_rp2350_board(arduino_pico: Path) -> None: assert boards["rpipico2"]["mcu"] == "rp2350" assert boards["rpipico2"]["max_pin"] == 29 + assert boards["rpipico2"]["die"] == "A" def test_rp2350_missing_die_define_raises(arduino_pico: Path) -> None: @@ -200,6 +207,35 @@ def test_rp2350_unrecognized_die_define_raises(arduino_pico: Path) -> None: load_boards(arduino_pico) +def test_rp2350_unknown_die_define_raises(arduino_pico: Path) -> None: + """A third die breaks the "not A means B" reading, so stop rather than guess.""" + _add_board( + arduino_pico, + "future_die", + mcu="rp2350", + pins_header="#define PICO_RP2350A 0\n#define PICO_RP2350C 1\n" + + PICO_PINS_HEADER, + ) + + with pytest.raises(ValueError, match="found a PICO_RP2350C define"): + load_boards(arduino_pico) + + +def test_rp2350_silicon_revision_define_ignored(arduino_pico: Path) -> None: + """PICO_RP2350_A2_SUPPORTED is a silicon revision, not a die letter.""" + _add_board( + arduino_pico, + "revision_define", + mcu="rp2350", + pins_header="#define PICO_RP2350A 1\n#define PICO_RP2350_A2_SUPPORTED 1\n" + + PICO_PINS_HEADER, + ) + + _, boards = load_boards(arduino_pico) + + assert boards["revision_define"]["die"] == "A" + + def test_rp2350a_parenthesized_die_define(arduino_pico: Path) -> None: """Literal forms like (1u) classify the same as bare 1.""" _add_board( @@ -212,6 +248,7 @@ def test_rp2350a_parenthesized_die_define(arduino_pico: Path) -> None: _, boards = load_boards(arduino_pico) assert boards["paren_die"]["max_pin"] == 29 + assert boards["paren_die"]["die"] == "A" def test_rp2350b_board_keeps_max_pin_47(arduino_pico: Path) -> None: @@ -229,10 +266,15 @@ def test_rp2350b_board_keeps_max_pin_47(arduino_pico: Path) -> None: _, boards = load_boards(arduino_pico) assert boards["weact_rp2350b"]["max_pin"] == 47 + assert boards["weact_rp2350b"]["die"] == "B" def test_rp2350_menu_selectable_die_keeps_max_pin_47(arduino_pico: Path) -> None: - """Generic boards leave the die a build-time choice; stay permissive.""" + """Generic boards leave the die a build-time choice; stay permissive. + + The permissive range is a fallback, so the die must be recorded as unknown + rather than as the B die. + """ _add_board( arduino_pico, "generic_rp2350", @@ -243,6 +285,43 @@ def test_rp2350_menu_selectable_die_keeps_max_pin_47(arduino_pico: Path) -> None _, boards = load_boards(arduino_pico) assert boards["generic_rp2350"]["max_pin"] == 47 + assert boards["generic_rp2350"]["die"] is None + + +def test_generated_output_records_die(arduino_pico: Path) -> None: + """The rendered boards.py carries the die on every RP2350 entry.""" + _add_board( + arduino_pico, + "rpipico", + pins_header=PICO_PINS_HEADER, + ) + _add_board( + arduino_pico, + "a_die", + mcu="rp2350", + pins_header="#define PICO_RP2350A 1\n" + PICO_PINS_HEADER, + ) + _add_board( + arduino_pico, + "b_die", + mcu="rp2350", + pins_header="#define PICO_RP2350A 0\n" + PICO_PINS_HEADER, + ) + _add_board( + arduino_pico, + "menu_die", + mcu="rp2350", + pins_header="#define PICO_RP2350A __PICO_RP2350A\n" + PICO_PINS_HEADER, + ) + + namespace: dict = {} + exec(compile(generate(arduino_pico), "boards.py", "exec"), namespace) + + boards = namespace["BOARDS"] + assert boards["a_die"]["die"] == "A" + assert boards["b_die"]["die"] == "B" + assert boards["menu_die"]["die"] is None + assert "die" not in boards["rpipico"] def test_rp2350a_pins_above_29_filtered(arduino_pico: Path) -> None: From bab62b345bb16cb273a28fbe7334f37ae340e5a3 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:19:59 +1200 Subject: [PATCH 1411/1815] [adc] Fix internal temperature channel on RP2350A under arduino-pico (#18307) --- esphome/components/adc/adc_sensor_rp2.cpp | 25 ++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/esphome/components/adc/adc_sensor_rp2.cpp b/esphome/components/adc/adc_sensor_rp2.cpp index 2732f5328b..8652a46029 100644 --- a/esphome/components/adc/adc_sensor_rp2.cpp +++ b/esphome/components/adc/adc_sensor_rp2.cpp @@ -19,6 +19,25 @@ namespace esphome::adc { static const char *const TAG = "adc.rp2"; +// The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040 +// and RP2350A, but input 8 on RP2350B, which has eight external channels rather +// than four. +// +// This deliberately does not use the SDK's ADC_TEMPERATURE_CHANNEL_NUM. That +// derives from NUM_ADC_CHANNELS, which settles from a board header, and +// arduino-pico supplies a fixed B-die one for every RP2350 build. The real die +// is only declared later, by the variant's pins_arduino.h, so the SDK constant +// reads 8 on A-die boards. PICO_RP2350A itself is correct by the time this file +// is compiled, on both arduino-pico and pico-sdk builds. +#if defined(PICO_RP2350) && !defined(PICO_RP2350A) +#error "PICO_RP2350A is not defined, so the RP2350 die is unknown and the temperature ADC channel cannot be chosen" +#endif +#if defined(PICO_RP2350) && !PICO_RP2350A +static constexpr uint8_t TEMPERATURE_ADC_INPUT = 8; +#else +static constexpr uint8_t TEMPERATURE_ADC_INPUT = 4; +#endif + void ADCSensor::setup() { static bool initialized = false; if (!initialized) { @@ -52,11 +71,7 @@ float ADCSensor::sample() { if (this->is_temperature_) { adc_set_temp_sensor_enabled(true); delay(1); - // The on-die temperature sensor sits on the last ADC channel, and which one - // that is depends on the chip: input 4 on RP2040 and RP2350A, but input 8 on - // RP2350B, which has eight external channels instead of four. The SDK - // resolves it for the target being built, so do not hardcode it. - adc_select_input(ADC_TEMPERATURE_CHANNEL_NUM); + adc_select_input(TEMPERATURE_ADC_INPUT); for (uint8_t sample = 0; sample < this->sample_count_; sample++) { raw = adc_read(); From 9605b34c697191f53f6dd47842052f0e642e47b8 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:20:41 +1200 Subject: [PATCH 1412/1815] [adc] Deprecate pin: TEMPERATURE in favour of internal_temperature (#18304) --- esphome/components/adc/__init__.py | 1 + esphome/components/adc/sensor.py | 8 +++++ tests/component_tests/adc/test_adc_sensor.py | 32 +++++++++++++++++++ .../component_tests/adc/test_adc_sensor.yaml | 16 ++++++++++ tests/components/adc/validate.rp2040-ard.yaml | 7 ++++ 5 files changed, 64 insertions(+) create mode 100644 tests/component_tests/adc/test_adc_sensor.py create mode 100644 tests/component_tests/adc/test_adc_sensor.yaml create mode 100644 tests/components/adc/validate.rp2040-ard.yaml diff --git a/esphome/components/adc/__init__.py b/esphome/components/adc/__init__.py index 555d511f6e..1c50b6b81b 100644 --- a/esphome/components/adc/__init__.py +++ b/esphome/components/adc/__init__.py @@ -231,6 +231,7 @@ def validate_adc_pin(value): return pins.internal_gpio_input_pin_schema(29) return cv.only_on([PLATFORM_ESP8266])("VCC") + # Deprecated in favour of the `internal_temperature` platform, remove before 2027.2.0 if str(value).upper() == "TEMPERATURE": return cv.only_on_rp2("TEMPERATURE") diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index c5a4288c07..b2a4382a21 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -67,6 +67,13 @@ def validate_config(config): # Alter value here so `config` command prints the recommended change config[CONF_ATTENUATION] = _attenuation("12db") + # Remove before 2027.2.0 + if config[CONF_PIN] == "TEMPERATURE": + _LOGGER.warning( + "[adc] `pin: TEMPERATURE` is deprecated, use the `internal_temperature` " + "sensor platform instead. Will be removed in 2027.2.0" + ) + return config @@ -133,6 +140,7 @@ async def to_code(config): if config[CONF_PIN] == "VCC": cg.add_define("USE_ADC_SENSOR_VCC") elif config[CONF_PIN] == "TEMPERATURE": + # Remove before 2027.2.0 cg.add(var.set_is_temperature()) elif not CORE.is_nrf52 or config[CONF_PIN][CONF_NUMBER] not in EXTRA_ADC: pin = await cg.gpio_pin_expression(config[CONF_PIN]) diff --git a/tests/component_tests/adc/test_adc_sensor.py b/tests/component_tests/adc/test_adc_sensor.py new file mode 100644 index 0000000000..a6d86f0305 --- /dev/null +++ b/tests/component_tests/adc/test_adc_sensor.py @@ -0,0 +1,32 @@ +"""Tests for the ADC sensor component.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +def test_adc_temperature_pin_is_deprecated( + generate_main: Callable[[str | Path], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """`pin: TEMPERATURE` still works, but warns and points at internal_temperature.""" + main_cpp = generate_main("tests/component_tests/adc/test_adc_sensor.yaml") + + assert "adc_temperature->set_is_temperature();" in main_cpp + assert "`pin: TEMPERATURE` is deprecated" in caplog.text + assert "internal_temperature" in caplog.text + assert "2027.2.0" in caplog.text + + +def test_adc_regular_pin_is_not_deprecated( + generate_main: Callable[[str | Path], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """A normal ADC pin does not emit the temperature deprecation warning.""" + main_cpp = generate_main("tests/component_tests/adc/test_adc_sensor.yaml") + + assert "adc_voltage->set_is_temperature();" not in main_cpp + assert caplog.text.count("`pin: TEMPERATURE` is deprecated") == 1 diff --git a/tests/component_tests/adc/test_adc_sensor.yaml b/tests/component_tests/adc/test_adc_sensor.yaml new file mode 100644 index 0000000000..9455fef21b --- /dev/null +++ b/tests/component_tests/adc/test_adc_sensor.yaml @@ -0,0 +1,16 @@ +esphome: + name: test + +rp2: + board: rpipicow + +sensor: + - platform: adc + pin: TEMPERATURE + name: Deprecated ADC Temperature + id: adc_temperature + + - platform: adc + pin: 26 + name: ADC Voltage + id: adc_voltage diff --git a/tests/components/adc/validate.rp2040-ard.yaml b/tests/components/adc/validate.rp2040-ard.yaml new file mode 100644 index 0000000000..cbe15f2746 --- /dev/null +++ b/tests/components/adc/validate.rp2040-ard.yaml @@ -0,0 +1,7 @@ +# Deprecated `pin: TEMPERATURE`, superseded by the `internal_temperature` platform. +# Remove before 2027.2.0 +sensor: + - id: adc_temperature_sensor + platform: adc + pin: TEMPERATURE + name: ADC Test temperature From 37eae9b466350f4be08056e8a99d89fa5286c417 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 21:31:13 -0500 Subject: [PATCH 1413/1815] [api] Gate the bluetooth connection messages on their own define (#18281) --- esphome/components/api/api.proto | 44 ++++----- esphome/components/api/api_connection.cpp | 9 +- esphome/components/api/api_connection.h | 6 +- esphome/components/api/api_pb2.cpp | 6 +- esphome/components/api/api_pb2.h | 10 +- esphome/components/api/api_pb2_defines.h | 2 +- esphome/components/api/api_pb2_dump.cpp | 10 +- esphome/components/api/api_pb2_service.cpp | 18 ++-- esphome/components/api/api_pb2_service.h | 18 ++-- .../bluetooth_connection.cpp | 4 +- .../bluetooth_connection.h | 22 ++--- .../bluetooth_connection_bluedroid.cpp | 6 +- .../bluetooth_connection_bluedroid.h | 4 +- .../bluetooth_connection_hub.cpp | 4 +- .../bluetooth_connection_hub.h | 4 +- .../components/bluetooth_proxy/__init__.py | 5 + .../bluetooth_proxy/bluetooth_proxy.cpp | 57 ++++-------- .../bluetooth_proxy/bluetooth_proxy.h | 32 ++++--- esphome/core/defines.h | 2 + script/api_protobuf/api_protobuf.py | 5 +- .../bluetooth_connection/__init__.py | 5 +- .../api/test_api_protobuf_generator.py | 93 +++++++++++++++++++ 22 files changed, 238 insertions(+), 128 deletions(-) create mode 100644 tests/unit_tests/components/api/test_api_protobuf_generator.py diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 88af5957e7..f1bc9b003a 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1760,7 +1760,7 @@ enum BluetoothDeviceRequestType { message BluetoothDeviceRequest { option (id) = 68; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; BluetoothDeviceRequestType request_type = 2; @@ -1771,7 +1771,7 @@ message BluetoothDeviceRequest { message BluetoothDeviceConnectionResponse { option (id) = 69; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; bool connected = 2; @@ -1782,7 +1782,7 @@ message BluetoothDeviceConnectionResponse { message BluetoothGATTGetServicesRequest { option (id) = 70; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; } @@ -1826,7 +1826,7 @@ message BluetoothGATTService { message BluetoothGATTGetServicesResponse { option (id) = 71; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; repeated BluetoothGATTService services = 2; @@ -1835,7 +1835,7 @@ message BluetoothGATTGetServicesResponse { message BluetoothGATTGetServicesDoneResponse { option (id) = 72; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; } @@ -1843,7 +1843,7 @@ message BluetoothGATTGetServicesDoneResponse { message BluetoothGATTReadRequest { option (id) = 73; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1852,7 +1852,7 @@ message BluetoothGATTReadRequest { message BluetoothGATTReadResponse { option (id) = 74; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1864,7 +1864,7 @@ message BluetoothGATTReadResponse { message BluetoothGATTWriteRequest { option (id) = 75; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1876,7 +1876,7 @@ message BluetoothGATTWriteRequest { message BluetoothGATTReadDescriptorRequest { option (id) = 76; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1885,7 +1885,7 @@ message BluetoothGATTReadDescriptorRequest { message BluetoothGATTWriteDescriptorRequest { option (id) = 77; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1896,7 +1896,7 @@ message BluetoothGATTWriteDescriptorRequest { message BluetoothGATTNotifyRequest { option (id) = 78; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1906,7 +1906,7 @@ message BluetoothGATTNotifyRequest { message BluetoothGATTNotifyDataResponse { option (id) = 79; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1917,13 +1917,13 @@ message BluetoothGATTNotifyDataResponse { message SubscribeBluetoothConnectionsFreeRequest { option (id) = 80; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; } message BluetoothConnectionsFreeResponse { option (id) = 81; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint32 free = 1; uint32 limit = 2; @@ -1936,7 +1936,7 @@ message BluetoothConnectionsFreeResponse { message BluetoothGATTErrorResponse { option (id) = 82; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1946,7 +1946,7 @@ message BluetoothGATTErrorResponse { message BluetoothGATTWriteResponse { option (id) = 83; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1955,7 +1955,7 @@ message BluetoothGATTWriteResponse { message BluetoothGATTNotifyResponse { option (id) = 84; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1964,7 +1964,7 @@ message BluetoothGATTNotifyResponse { message BluetoothDevicePairingResponse { option (id) = 85; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; bool paired = 2; @@ -1974,7 +1974,7 @@ message BluetoothDevicePairingResponse { message BluetoothDeviceUnpairingResponse { option (id) = 86; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; bool success = 2; @@ -1990,7 +1990,7 @@ message UnsubscribeBluetoothLEAdvertisementsRequest { message BluetoothDeviceClearCacheResponse { option (id) = 88; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; bool success = 2; @@ -2807,7 +2807,7 @@ message SerialProxyRequestResponse { message BluetoothSetConnectionParamsRequest { option (id) = 145; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 min_interval = 2; // units of 1.25ms @@ -2819,7 +2819,7 @@ message BluetoothSetConnectionParamsRequest { message BluetoothSetConnectionParamsResponse { option (id) = 146; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; int32 error = 2; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 19d2b14a32..afd7e696af 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1236,6 +1236,7 @@ void APIConnection::on_subscribe_bluetooth_le_advertisements_request( void APIConnection::on_unsubscribe_bluetooth_le_advertisements_request() { bluetooth_proxy::global_bluetooth_proxy->unsubscribe_api_connection(this); } +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void APIConnection::on_bluetooth_device_request(const BluetoothDeviceRequest &msg) { bluetooth_proxy::global_bluetooth_proxy->bluetooth_device_request(msg); } @@ -1269,13 +1270,15 @@ void APIConnection::on_subscribe_bluetooth_connections_free_request() { } } +void APIConnection::on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg) { + bluetooth_proxy::global_bluetooth_proxy->bluetooth_set_connection_params(msg); +} +#endif + void APIConnection::on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg) { bluetooth_proxy::global_bluetooth_proxy->bluetooth_scanner_set_mode( msg.mode == enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE); } -void APIConnection::on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg) { - bluetooth_proxy::global_bluetooth_proxy->bluetooth_set_connection_params(msg); -} #endif #ifdef USE_VOICE_ASSISTANT diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 9ca1b8b6a4..d548b921b3 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -183,6 +183,7 @@ class APIConnection final : public APIServerConnectionBase { void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &msg); void on_unsubscribe_bluetooth_le_advertisements_request(); +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_device_request(const BluetoothDeviceRequest &msg); void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &msg); void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &msg); @@ -191,8 +192,9 @@ class APIConnection final : public APIServerConnectionBase { void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &msg); void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &msg); void on_subscribe_bluetooth_connections_free_request(); - void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg); void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg); +#endif + void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg); #endif #ifdef USE_HOMEASSISTANT_TIME @@ -390,7 +392,7 @@ class APIConnection final : public APIServerConnectionBase { #ifdef USE_API_NOISE bool send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg); #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS bool send_subscribe_bluetooth_connections_free_response_(); #endif #ifdef USE_VOICE_ASSISTANT diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 5776ec5c62..1b8c6b05bd 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -2482,6 +2482,8 @@ BluetoothLERawAdvertisementsResponse::calculate_size() const { } return size; } +#endif +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: @@ -2858,6 +2860,8 @@ uint32_t BluetoothDeviceClearCacheResponse::calculate_size() const { size += ProtoSize::calc_int32(1, this->error); return size; } +#endif +#ifdef USE_BLUETOOTH_PROXY uint8_t *BluetoothScannerStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->state)); @@ -4221,7 +4225,7 @@ uint32_t SerialProxyRequestResponse::calculate_size() const { return size; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index f35f551060..8335dae1f2 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -225,7 +225,7 @@ enum MediaPlayerFormatPurpose : uint32_t { MEDIA_PLAYER_FORMAT_PURPOSE_ANNOUNCEMENT = 1, }; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS enum BluetoothDeviceRequestType : uint32_t { BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT = 0, BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT = 1, @@ -235,6 +235,8 @@ enum BluetoothDeviceRequestType : uint32_t { BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE = 5, BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE = 6, }; +#endif +#ifdef USE_BLUETOOTH_PROXY enum BluetoothScannerState : uint32_t { BLUETOOTH_SCANNER_STATE_IDLE = 0, BLUETOOTH_SCANNER_STATE_STARTING = 1, @@ -1999,6 +2001,8 @@ class BluetoothLERawAdvertisementsResponse final : public ProtoMessage { protected: }; +#endif +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS class BluetoothDeviceRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 68; @@ -2384,6 +2388,8 @@ class BluetoothDeviceClearCacheResponse final : public ProtoMessage { protected: }; +#endif +#ifdef USE_BLUETOOTH_PROXY class BluetoothScannerStateResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 126; @@ -3358,7 +3364,7 @@ class SerialProxyRequestResponse final : public ProtoMessage { protected: }; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 145; diff --git a/esphome/components/api/api_pb2_defines.h b/esphome/components/api/api_pb2_defines.h index 8ebd60fb5d..3603fac6d7 100644 --- a/esphome/components/api/api_pb2_defines.h +++ b/esphome/components/api/api_pb2_defines.h @@ -3,7 +3,7 @@ #pragma once #include "esphome/core/defines.h" -#ifdef USE_BLUETOOTH_PROXY +#if defined(USE_BLUETOOTH_PROXY) || defined(USE_BLUETOOTH_PROXY_CONNECTIONS) #ifndef USE_API_VARINT64 #define USE_API_VARINT64 #endif diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 17ce7fba45..4d5829e45d 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -584,7 +584,7 @@ template<> const char *proto_enum_to_string(enu } } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS template<> const char *proto_enum_to_string(enums::BluetoothDeviceRequestType value) { switch (value) { @@ -606,6 +606,8 @@ const char *proto_enum_to_string(enums::Bluet return ESPHOME_PSTR("UNKNOWN"); } } +#endif +#ifdef USE_BLUETOOTH_PROXY template<> const char *proto_enum_to_string(enums::BluetoothScannerState value) { switch (value) { case enums::BLUETOOTH_SCANNER_STATE_IDLE: @@ -2002,6 +2004,8 @@ const char *BluetoothLERawAdvertisementsResponse::dump_to(DumpBuffer &out) const } return out.c_str(); } +#endif +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS const char *BluetoothDeviceRequest::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothDeviceRequest")); dump_field(out, ESPHOME_PSTR("address"), this->address); @@ -2173,6 +2177,8 @@ const char *BluetoothDeviceClearCacheResponse::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("error"), this->error); return out.c_str(); } +#endif +#ifdef USE_BLUETOOTH_PROXY const char *BluetoothScannerStateResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothScannerStateResponse")); dump_field(out, ESPHOME_PSTR("state"), static_cast(this->state)); @@ -2764,7 +2770,7 @@ const char *SerialProxyRequestResponse::dump_to(DumpBuffer &out) const { return out.c_str(); } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothSetConnectionParamsRequest")); dump_field(out, ESPHOME_PSTR("address"), this->address); diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 19dcbfb77c..65c7b8858c 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -302,7 +302,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothDeviceRequest::MESSAGE_TYPE: { BluetoothDeviceRequest msg; msg.decode(msg_data, msg_size); @@ -313,7 +313,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothGATTGetServicesRequest::MESSAGE_TYPE: { BluetoothGATTGetServicesRequest msg; msg.decode(msg_data, msg_size); @@ -324,7 +324,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothGATTReadRequest::MESSAGE_TYPE: { BluetoothGATTReadRequest msg; msg.decode(msg_data, msg_size); @@ -335,7 +335,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothGATTWriteRequest::MESSAGE_TYPE: { BluetoothGATTWriteRequest msg; msg.decode(msg_data, msg_size); @@ -346,7 +346,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothGATTReadDescriptorRequest::MESSAGE_TYPE: { BluetoothGATTReadDescriptorRequest msg; msg.decode(msg_data, msg_size); @@ -357,7 +357,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothGATTWriteDescriptorRequest::MESSAGE_TYPE: { BluetoothGATTWriteDescriptorRequest msg; msg.decode(msg_data, msg_size); @@ -368,7 +368,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothGATTNotifyRequest::MESSAGE_TYPE: { BluetoothGATTNotifyRequest msg; msg.decode(msg_data, msg_size); @@ -379,7 +379,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case 80 /* SubscribeBluetoothConnectionsFreeRequest is empty */: { #ifdef HAS_PROTO_MESSAGE_DUMP this->log_receive_message_(LOG_STR("on_subscribe_bluetooth_connections_free_request")); @@ -694,7 +694,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothSetConnectionParamsRequest::MESSAGE_TYPE: { BluetoothSetConnectionParamsRequest msg; msg.decode(msg_data, msg_size); diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 5ed78b3385..6abdf7093e 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -115,32 +115,32 @@ class APIServerConnectionBase { void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_device_request(const BluetoothDeviceRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_gatt_read_descriptor_request(const BluetoothGATTReadDescriptorRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_gatt_write_descriptor_request(const BluetoothGATTWriteDescriptorRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_subscribe_bluetooth_connections_free_request(){}; #endif @@ -235,7 +235,7 @@ class APIServerConnectionBase { void on_serial_proxy_request(const SerialProxyRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){}; #endif }; diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.cpp b/esphome/components/bluetooth_connection/bluetooth_connection.cpp index a7e9825e56..a001729083 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection.cpp @@ -5,7 +5,7 @@ #include #endif -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS #include "esphome/components/api/api_pb2.h" #include "esphome/core/log.h" @@ -44,7 +44,7 @@ BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size } // namespace esphome::bluetooth_connection -#endif // BLUETOOTH_CONNECTION_HAS_GATT +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS #if defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) namespace esphome::bluetooth_connection { diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h index bcfbdaa6cf..b21d997b4f 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -16,16 +16,14 @@ #include #endif -// The connection-aware API request handlers are compiled: a GATT backend is -// wired by codegen (one slot per connection). This is the single spelling of -// that predicate - the hub wrapper and the API request handlers gate on it. -// The wrapper serves the proxy's API surface, so it compiles only when a -// backend AND the proxy are present. The address-scoped maintenance functions -// below are only reached from that gated surface; their #else stubs just -// keep this header parsing on arms without a backend. -#if defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUETOOTH_PROXY) -#define BLUETOOTH_CONNECTION_HAS_GATT -#endif +// USE_BLUETOOTH_PROXY_CONNECTIONS is the single spelling of "this build has +// proxy connection slots": codegen emits it per configured slot, and each +// slot brings a GATT backend, so it also implies USE_BLE_GATT_CLIENT (not +// the converse: a backend can exist without proxy slots). The hub +// wrapper, the proxy's connection surface and the API's connection messages +// all gate on it. The address-scoped maintenance functions below are only +// reached from that gated surface; the #else stubs just keep this header +// parsing on arms without a backend. namespace esphome::api { class BluetoothGATTGetServicesResponse; @@ -154,7 +152,7 @@ inline void fill_gatt_uuid(std::array &uuid_128, uint32_t &short_uu } } -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS /// Result of close_service_batch: keep filling the batch or send it now. /// An oversized service is packed alone; a failed (backpressured) send is /// retried from the batch start, so no service is silently skipped. @@ -166,6 +164,6 @@ enum class BatchClose : uint8_t { CONTINUE, SEND }; /// cannot drift. BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size_t ¤t_size, int16_t &send_service, uint8_t connection_index, const char *address_str); -#endif // BLUETOOTH_CONNECTION_HAS_GATT +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS } // namespace esphome::bluetooth_connection diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index 99a6a312ec..076c77b18e 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -4,7 +4,7 @@ // The in-place streamer serves the proxy's service-discovery API; backend-only // builds compile without the proxy headers or the streamer. -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS #include "bluetooth_connection.h" #include "bluetooth_connection_hub.h" @@ -391,7 +391,7 @@ void BluedroidGattClient::deliver_pending_search_() { this->listener_->on_service_discovery_done(this->search_status_); } -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // The wrapper's compile-time streamer detection must keep finding this // method; a signature drift would silently fall back to the table streamer, // which proxy builds compile without a materializer. @@ -535,7 +535,7 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) { } conn.batch_stalled_ = false; } -#endif // USE_BLUETOOTH_PROXY +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS // ---- events ---- diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h index 19b89ea5cd..0d0b4fed5b 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h @@ -20,7 +20,7 @@ namespace esphome::bluetooth_connection { -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS class BluetoothConnection; #endif @@ -79,7 +79,7 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public ble_device_base::GattServiceTable get_service_table() { return {}; } void release_services(); -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS /// In-place service streamer (the proxy wrapper detects and prefers it): /// builds one api response batch directly from Bluedroid's cached database, /// so the streaming peak is the response itself - the old esp32 model. diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index 8707637e9d..50267e7c73 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -21,7 +21,7 @@ // request; timeouts, disconnects and errors raise instead of caching. #include "bluetooth_connection_hub.h" -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS #include "esphome/components/api/api_pb2.h" #include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" @@ -560,4 +560,4 @@ void BluetoothConnection::send_service_for_discovery_() { } // namespace esphome::bluetooth_connection -#endif // BLUETOOTH_CONNECTION_HAS_GATT +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index 3553f8bf00..f87d545f7d 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -10,7 +10,7 @@ // The wrapper exists to serve the proxy's API surface; direct consumers // drive the backend themselves, so backend-only builds compile this header // empty. -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS #include "esphome/components/ble_device_base/ble_client_state.h" #include "bluetooth_connection_gatt_backend.h" @@ -271,4 +271,4 @@ static_assert(sizeof(void *) != 4 || sizeof(BluetoothConnection) <= 56, } // namespace esphome::bluetooth_connection -#endif // BLUETOOTH_CONNECTION_HAS_GATT +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index cc7aed6be2..95f71fc8ea 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -208,6 +208,11 @@ async def _connections_to_code(var: cg.MockObj, config: ConfigType) -> None: # this define whenever a proxy is present (zero on advertisement-only # hubs); sized here so it can never diverge from the loop below. cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", len(connections)) + if connections: + # Gates the connection and GATT half of the API surface. A proxy + # without slots omits FEATURE_ACTIVE_CONNECTIONS, so a client never + # sends those requests and their handlers and encoders are dead. + cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS") for connection_conf in connections: backend = await bluetooth_connection.new_gatt_backend(connection_conf) connection = cg.new_Pvariable(connection_conf[CONF_ID]) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 0cf8483cea..75830e83ef 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -70,9 +70,10 @@ void BluetoothProxy::send_polled_scanner_state_() { #endif // USE_BLE_SCANNER_STATE_CALLBACK void BluetoothProxy::setup() { - // BLUETOOTH_PROXY_MAX_CONNECTIONS is 0 on an advertisement-only proxy. +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS; this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS; +#endif // Capture the configured scan mode from YAML before any API changes this->configured_scan_active_ = this->hub_->scan_active(); @@ -111,7 +112,7 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme } } -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, ClientState state) { ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, state: %s", connection->get_connection_index(), connection->address_str(), ble_device_base::client_state_to_string(state)); @@ -120,8 +121,9 @@ void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connec void BluetoothProxy::log_connection_info_(BluetoothConnection *connection, const char *message) { ESP_LOGI(TAG, "[%d] [%s] Connecting %s", connection->get_connection_index(), connection->address_str(), message); } -#endif // BLUETOOTH_CONNECTION_HAS_GATT +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void BluetoothProxy::log_reply_dropped_(const char *what, uint64_t address) { ESP_LOGW(TAG, "%s reply for %012" PRIX64 " dropped, TCP buffer full", what, address); } @@ -146,6 +148,7 @@ void BluetoothProxy::handle_gatt_not_connected_(uint64_t address, uint16_t handl this->log_reply_dropped_("Not-connected", address); } } +#endif void BluetoothProxy::log_advertisement_flush_() { ESP_LOGV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len); @@ -159,7 +162,7 @@ void BluetoothProxy::dump_config() { this->get_bluetooth_mac_address_pretty(mac_str); const char *mac_out = mac_str[0] != '\0' ? mac_str : "unavailable (adapter not up yet)"; const char *scan_mode = this->configured_scan_active_ ? "active" : "passive"; -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS ESP_LOGCONFIG(TAG, "Bluetooth Proxy:\n" " Active: %s\n" @@ -177,12 +180,9 @@ void BluetoothProxy::dump_config() { #endif } -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS -// maybe_unused: in a passive proxy (active: false) MAX is 0, the body is removed, and connection is unused. -void BluetoothProxy::register_connection([[maybe_unused]] BluetoothConnection *connection) { -// Guard the always-false comparison (-Wtype-limits) in a passive proxy (active: false), where MAX is 0. -#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0 +void BluetoothProxy::register_connection(BluetoothConnection *connection) { if (this->connection_count_ >= BLUETOOTH_PROXY_MAX_CONNECTIONS) { // Cannot happen with codegen-sized registration; a silent drop would // surface later as a null proxy_ dereference, so refuse loudly. @@ -193,7 +193,6 @@ void BluetoothProxy::register_connection([[maybe_unused]] BluetoothConnection *c connection->connection_index_ = this->connection_count_; this->connections_[this->connection_count_++] = connection; connection->proxy_ = this; -#endif } void BluetoothProxy::log_slot_accounting_mismatch_() { ESP_LOGW(TAG, "Connection slot free-count mismatch, clamped"); } @@ -549,7 +548,7 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn } } -#endif // BLUETOOTH_CONNECTION_HAS_GATT +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS #ifdef USE_ESP32 @@ -592,7 +591,7 @@ void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { #endif // USE_ESP32 void BluetoothProxy::loop() { -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // Stream pending service-discovery batches every iteration; the streamer // handles a vanished API connection itself. for (uint8_t i = 0; i < this->connection_count_; i++) { @@ -606,6 +605,7 @@ void BluetoothProxy::loop() { return; this->last_advertisement_flush_time_ = now; +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS if (this->connections_free_pending_ && this->api_connection_ != nullptr) { // Resend a dropped slot-state update, paced by the 100 ms gate so the // retry does not hammer the congestion it exists to survive. Every build @@ -614,9 +614,10 @@ void BluetoothProxy::loop() { this->connections_free_pending_ = false; this->send_connections_free(this->api_connection_); } +#endif if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) { -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // The API subscriber is gone: tear down any connections it left behind // (disconnect() on an already-disconnecting slot is a no-op). for (uint8_t i = 0; i < this->connection_count_; i++) { @@ -629,7 +630,7 @@ void BluetoothProxy::loop() { return; } -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // Paced retries of owed per-slot notifications; subscriber swaps clear // stale latches before this runs. for (uint8_t i = 0; i < this->connection_count_; i++) { @@ -670,28 +671,10 @@ void BluetoothProxy::loop() { this->flush_pending_advertisements_(); } -#ifndef BLUETOOTH_CONNECTION_HAS_GATT - -// Advertisement-only proxy: no connection backend on this platform, or -// active: false. get_feature_flags() then omits FEATURE_ACTIVE_CONNECTIONS, -// so a client treats the proxy as passive and never sends a connection or -// GATT request. These exist only because the api layer dispatches them -// unconditionally; answering would link response encoders this build has no -// use for. - -void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest &msg) {} -void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg) {} -void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &msg) {} -void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTReadDescriptorRequest &msg) {} -void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWriteDescriptorRequest &msg) {} -void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg) {} -void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg) {} -void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) {} - -#endif // !BLUETOOTH_CONNECTION_HAS_GATT - void BluetoothProxy::reset_owed_replies_() { +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS this->connections_free_pending_ = false; +#endif #ifdef USE_BLE_SCANNER_STATE_CALLBACK // Owed on unsubscribe; on subscribe the trailing send_scanner_state_() // re-drives it from the hub, so clearing it there is free. @@ -703,7 +686,7 @@ void BluetoothProxy::reset_owed_replies_() { // detector runs, and a re-subscribe re-arms this anyway. this->last_scan_running_ = !this->hub_->scan_running(); #endif -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS this->pending_unpairing_.clear(); this->pending_disconnections_.fill({}); for (uint8_t i = 0; i < this->connection_count_; i++) { @@ -752,6 +735,7 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti this->reset_owed_replies_(); } +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void BluetoothProxy::send_connections_free() { if (this->api_connection_ != nullptr) { this->send_connections_free(this->api_connection_); @@ -797,7 +781,6 @@ bool BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, conn_err return this->api_connection_->send_message(call); } -#ifdef BLUETOOTH_CONNECTION_HAS_GATT void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, conn_err_t error) { if (this->api_connection_ == nullptr) return; @@ -859,7 +842,7 @@ void BluetoothProxy::send_device_clear_cache(uint64_t address, bool success, con this->log_reply_dropped_("Clear-cache", address); } } -#endif +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS BluetoothProxy *global_bluetooth_proxy = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index de70b35aaf..e5f3a259a1 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -29,7 +29,7 @@ using bluetooth_connection::DONE_SENDING_SERVICES; using bluetooth_connection::INIT_SENDING_SERVICES; using bluetooth_connection::SERVICES_DONE_PENDING; -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS using BluetoothConnection = bluetooth_connection::BluetoothConnection; using ClientState = ble_device_base::ClientState; #endif @@ -60,7 +60,7 @@ enum BluetoothProxySubscriptionFlag : uint32_t { SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0, }; -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS /// One owed address-keyed reply in a single word: 48-bit address low, 16-bit /// error on top. Every error that reaches it fits int16_t. class PendingReply { @@ -98,7 +98,7 @@ static_assert(PendingReply{}.empty()); #endif class BluetoothProxy final : public Component { -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // Allow the connection to update connections_free_response_ friend bluetooth_connection::BluetoothConnection; #endif @@ -109,9 +109,9 @@ class BluetoothProxy final : public Component { void setup() override; void loop() override; -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void register_connection(BluetoothConnection *connection); -#endif // BLUETOOTH_CONNECTION_HAS_GATT +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS #ifndef USE_ESP32 // Run after the hub's setup() (the trackers use AFTER_WIFI): setup() below // snapshots scan_active()/scan_running() and installs the raw callback, and @@ -120,6 +120,7 @@ class BluetoothProxy final : public Component { float get_setup_priority() const override { return setup_priority::AFTER_WIFI - 1.0f; } #endif // !USE_ESP32 +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void bluetooth_device_request(const api::BluetoothDeviceRequest &msg); void bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg); void bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &msg); @@ -128,6 +129,7 @@ class BluetoothProxy final : public Component { void bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg); void bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg); void bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg); +#endif void subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags); void unsubscribe_api_connection(api::APIConnection *api_connection); @@ -137,6 +139,7 @@ class BluetoothProxy final : public Component { return this->api_connection_ != nullptr && this->api_connection_->client_supports_api_version(1, 12); } +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS /// False only when a subscriber refused the frame; true = delivered or /// nobody subscribed. Refusals latch in send_device_disconnected_() and /// send_connected_reply_(); other callers report via log_reply_dropped_(). @@ -147,7 +150,6 @@ class BluetoothProxy final : public Component { bool send_gatt_services_done(uint64_t address); /// False only when the API refused the frame, so the reply is still owed. bool send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error); -#ifdef BLUETOOTH_CONNECTION_HAS_GATT void send_device_pairing(uint64_t address, bool paired, conn_err_t error = CONN_OK); /// No default error: the drain rebuilds success as (error == CONN_OK), so a /// caller that omitted it would have a reported failure resent as a success. @@ -243,22 +245,17 @@ class BluetoothProxy final : public Component { } void log_advertisement_flush_(); -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS BluetoothConnection *get_connection_(uint64_t address, bool reserve); void log_connection_request_ignored_(BluetoothConnection *connection, ClientState state); void log_connection_info_(BluetoothConnection *connection, const char *message); -#endif void log_not_connected_gatt_(const char *action, const char *type); void handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action, const char *type); -#ifdef BLUETOOTH_CONNECTION_HAS_GATT /// Keep the pre-allocated connections-free message in step when a /// connection slot changes address (0 = free). Called from the connection /// classes' set_address(). - // maybe_unused + guard: in a passive proxy (active: false) MAX is 0, the - // body is removed, and the free < MAX compare would trip -Wtype-limits. - void update_address_slot_([[maybe_unused]] uint64_t old_address, [[maybe_unused]] uint64_t new_address) { -#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0 + void update_address_slot_(uint64_t old_address, uint64_t new_address) { auto &resp = this->connections_free_response_; if (new_address == 0 && old_address != 0) { if (resp.free < BLUETOOTH_PROXY_MAX_CONNECTIONS) { @@ -275,7 +272,6 @@ class BluetoothProxy final : public Component { } this->replace_allocated_slot_(0, new_address); } -#endif // BLUETOOTH_PROXY_MAX_CONNECTIONS > 0 } void replace_allocated_slot_(uint64_t find_value, uint64_t set_value); void log_slot_accounting_mismatch_(); @@ -305,18 +301,20 @@ class BluetoothProxy final : public Component { /// Drops state only, never sends: api_connection_ is the departing /// subscriber on subscribe and nullptr on unsubscribe. void reset_owed_replies_(); +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS /// Report a reply we deliberately do not latch, so no drop is silent. void log_reply_dropped_(const char *what, uint64_t address); /// A latched reply's leading edge; the drain's re-refusals stay quiet. void log_reply_deferred_(const char *what, uint64_t address); /// A latched reply lost to a newer one for a different address. void log_reply_displaced_(const char *what, uint64_t owed, uint64_t address); +#endif // Memory optimized layout for 32-bit systems // Group 1: Pointers (4 bytes each, naturally aligned) api::APIConnection *api_connection_{nullptr}; -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // Group 2: Fixed-size array of connection pointers std::array connections_{}; // Address-keyed pool of owed freed-slot notifications; loop() resends. @@ -336,16 +334,20 @@ class BluetoothProxy final : public Component { // BLE advertisement batching api::BluetoothLERawAdvertisementsResponse response_; +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // Pre-allocated response message - always ready to send api::BluetoothConnectionsFreeResponse connections_free_response_; +#endif // Group 4: 1-byte types grouped together bool active_; +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // A dropped send (full TCP buffer) would leave the API client with a stale // slot state forever; the cached response is current by construction, so // retrying it from loop() is an idempotent resync. bool connections_free_pending_{false}; uint8_t connection_count_{0}; +#endif bool configured_scan_active_{false}; // Configured scan mode from YAML #ifdef USE_BLE_SCANNER_STATE_CALLBACK // A dropped push (full TX buffer) is re-queried from the hub and resent diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 21cea31749..26b9025d96 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -267,8 +267,10 @@ #ifdef USE_ESP32 #define USE_BLE_SCANNER_STATE_CALLBACK #define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 +#define USE_BLUETOOTH_PROXY_CONNECTIONS #elif defined(USE_RP2) #define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 +#define USE_BLUETOOTH_PROXY_CONNECTIONS #else #define BLUETOOTH_PROXY_MAX_CONNECTIONS 0 #endif diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 451cd9ac1f..f4eff4a254 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2401,7 +2401,10 @@ def get_varint64_ifdef( # At least one 64-bit varint field is unconditional, so the guard must be unconditional. return True, None ifdefs.discard(None) - return True, ifdefs.pop() if len(ifdefs) == 1 else None + # Several guards: the define is needed under any of them, so emit the union. + # Falling back to unconditional would pull 64-bit varint support into builds + # that have none of them. + return True, " || ".join(sorted(ifdefs)) def build_enum_type(desc, enum_ifdef_map) -> tuple[str, str, str]: diff --git a/tests/components/bluetooth_connection/__init__.py b/tests/components/bluetooth_connection/__init__.py index eb6e174c0c..9c1ad4e74d 100644 --- a/tests/components/bluetooth_connection/__init__.py +++ b/tests/components/bluetooth_connection/__init__.py @@ -3,7 +3,7 @@ from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: - # close_service_batch compiles only under BLUETOOTH_CONNECTION_HAS_GATT; + # close_service_batch compiles only under USE_BLUETOOTH_PROXY_CONNECTIONS; # emit the backend define so the host build exercises it. async def to_code_testing(config): # These defines are global to the merged host test binary; safe @@ -11,6 +11,9 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: cg.add_define("USE_BLE_GATT_CLIENT") cg.add_define("USE_BLE_GATT_CLIENT_STUB_BACKEND") cg.add_define("USE_BLUETOOTH_PROXY") + # Gates the connection half of the API surface, which is what + # close_service_batch and the GATT response types live behind. + cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS") cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 1) diff --git a/tests/unit_tests/components/api/test_api_protobuf_generator.py b/tests/unit_tests/components/api/test_api_protobuf_generator.py new file mode 100644 index 0000000000..2a07cbd49c --- /dev/null +++ b/tests/unit_tests/components/api/test_api_protobuf_generator.py @@ -0,0 +1,93 @@ +"""Unit tests for script/api_protobuf/api_protobuf.py generator logic. + +ci-api-proto.yml only checks that the committed output matches what the +generator currently produces, so a semantic regression in the generator would +be committed and matched without anything failing. These tests pin the +semantics directly. +""" + +from __future__ import annotations + +from pathlib import Path +import sys + +import pytest + +sys.path.insert(0, str(Path(__file__).parents[4] / "script" / "api_protobuf")) + +from api_protobuf import _make_ifdef_line, get_varint64_ifdef # noqa: E402 +from google.protobuf import descriptor_pb2 # noqa: E402 + + +def _file_with_messages( + *messages: tuple[str, int, bool], +) -> descriptor_pb2.FileDescriptorProto: + """Build a FileDescriptorProto with one single-field message per entry. + + Each entry is (message_name, field_type, deprecated). + """ + file_desc = descriptor_pb2.FileDescriptorProto(name="test.proto") + for name, field_type, deprecated in messages: + msg = file_desc.message_type.add(name=name) + field = msg.field.add(name="value", number=1, type=field_type) + field.options.deprecated = deprecated + return file_desc + + +UINT64 = descriptor_pb2.FieldDescriptorProto.TYPE_UINT64 +INT64 = descriptor_pb2.FieldDescriptorProto.TYPE_INT64 +SINT64 = descriptor_pb2.FieldDescriptorProto.TYPE_SINT64 +UINT32 = descriptor_pb2.FieldDescriptorProto.TYPE_UINT32 +FIXED64 = descriptor_pb2.FieldDescriptorProto.TYPE_FIXED64 + + +def test_no_varint64_fields() -> None: + file_desc = _file_with_messages(("A", UINT32, False), ("B", FIXED64, False)) + assert get_varint64_ifdef(file_desc, {}) == (False, None) + + +@pytest.mark.parametrize("field_type", [UINT64, INT64, SINT64]) +def test_single_guard_is_kept(field_type: int) -> None: + file_desc = _file_with_messages(("A", field_type, False)) + assert get_varint64_ifdef(file_desc, {"A": "USE_X"}) == (True, "USE_X") + + +def test_two_guards_emit_the_union() -> None: + # The regression this pins: multiple guards used to collapse to + # unconditional, pulling 64-bit varint support into unrelated builds. + file_desc = _file_with_messages(("A", UINT64, False), ("B", INT64, False)) + guards = {"A": "USE_X", "B": "USE_Y"} + assert get_varint64_ifdef(file_desc, guards) == (True, "USE_X || USE_Y") + + +def test_union_is_sorted_for_deterministic_output() -> None: + file_desc = _file_with_messages(("B", UINT64, False), ("A", INT64, False)) + guards = {"B": "USE_Y", "A": "USE_X"} + assert get_varint64_ifdef(file_desc, guards) == (True, "USE_X || USE_Y") + + +def test_any_unconditional_message_wins() -> None: + file_desc = _file_with_messages(("A", UINT64, False), ("B", INT64, False)) + assert get_varint64_ifdef(file_desc, {"A": "USE_X"}) == (True, None) + + +def test_deprecated_fields_and_messages_are_ignored() -> None: + file_desc = _file_with_messages(("A", UINT64, True), ("B", INT64, False)) + file_desc.message_type[1].options.deprecated = True + assert get_varint64_ifdef(file_desc, {"A": "USE_X", "B": "USE_Y"}) == (False, None) + + +def test_make_ifdef_line_simple_identifier() -> None: + assert _make_ifdef_line("USE_X") == "#ifdef USE_X" + + +def test_make_ifdef_line_union_wraps_each_identifier() -> None: + # The second half of the varint64 union guard: compound conditions must + # become #if defined(A) || defined(B), never #ifdef of the raw string. + assert _make_ifdef_line("USE_X || USE_Y") == "#if defined(USE_X) || defined(USE_Y)" + + +def test_make_ifdef_line_conjunction_and_negation() -> None: + assert ( + _make_ifdef_line("USE_X && !USE_Y") == "#if defined(USE_X) && !defined(USE_Y)" + ) From a2feff8f68c530f3d82ed9e799f0046d4b96e3bd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 22:23:08 -0500 Subject: [PATCH 1414/1815] [api] Mark send_message nodiscard so refused frames are never silent (#18293) --- esphome/components/api/api_connection.cpp | 61 ++++++++++++++++--- esphome/components/api/api_connection.h | 27 ++++---- esphome/components/api/api_server.cpp | 15 +++-- .../bluetooth_proxy/bluetooth_proxy.cpp | 8 ++- .../bluetooth_proxy/bluetooth_proxy.h | 11 ++-- .../voice_assistant/voice_assistant.cpp | 16 +++-- .../components/zwave_proxy/zwave_proxy.cpp | 12 +++- 7 files changed, 113 insertions(+), 37 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index afd7e696af..53fe40f682 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -89,6 +89,13 @@ static_assert(ESPHOME_DEVICE_NAME_MAX_LEN <= 31, "Update max_data_length for nam static_assert(ESPHOME_FRIENDLY_NAME_MAX_LEN <= 120, "Update max_data_length for friendly_name in api.proto"); static const char *const TAG = "api.connection"; + +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN +void log_dropped_message(const char *tag, int line, const LogString *what) { + esp_log_printf_(ESPHOME_LOG_LEVEL_WARN, tag, line, ESPHOME_LOG_FORMAT("%s dropped, TCP buffer full"), + LOG_STR_ARG(what)); +} +#endif #ifdef USE_CAMERA static const int CAMERA_STOP_STREAM = 5000; #endif @@ -1536,7 +1543,13 @@ void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRF #endif #if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) -void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) { this->send_message(msg); } +void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) { + if (!this->send_message(msg)) { + // V: fires per decoded frame with no subscription gate, so a warning + // would flood the congested link it reports on. + ESP_LOGV(TAG, "IR/RF event dropped, TCP buffer full"); + } +} #endif #ifdef USE_SERIAL_PROXY @@ -1578,7 +1591,9 @@ void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetM SerialProxyGetModemPinsResponse resp{}; resp.instance = msg.instance; resp.line_states = proxies[msg.instance]->get_modem_pins(); - this->send_message(resp); + if (!this->send_message(resp)) { + API_LOG_MSG_DROPPED(TAG, "Serial proxy response"); + } } void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { @@ -1610,7 +1625,9 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { resp.status = enums::SERIAL_PROXY_STATUS_ERROR; break; } - this->send_message(resp); + if (!this->send_message(resp)) { + API_LOG_MSG_DROPPED(TAG, "Serial proxy response"); + } break; } default: @@ -1619,7 +1636,11 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { } } -void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) { this->send_message(msg); } +void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) { + if (!this->send_message(msg)) { + ESP_LOGV(TAG, "Serial proxy data dropped, TCP buffer full"); + } +} #endif #ifdef USE_INFRARED @@ -1750,7 +1771,9 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { // Acknowledge the hello so the client can read the server name, then request // disconnect with the reason. Authentication is intentionally not completed. this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Provisioning closed; rejecting connection")); - this->send_message(resp); + if (!this->send_message(resp)) { + API_LOG_MSG_DROPPED(TAG, "Hello response"); + } DisconnectRequest req; req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED; return this->send_message(req); @@ -2039,7 +2062,9 @@ void APIConnection::send_execute_service_response(uint32_t call_id, bool success resp.call_id = call_id; resp.success = success; resp.error_message = error_message; - this->send_message(resp); + if (!this->send_message(resp)) { + API_LOG_MSG_DROPPED(TAG, "Action response"); + } } #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON void APIConnection::send_execute_service_response(uint32_t call_id, bool success, StringRef error_message, @@ -2050,12 +2075,34 @@ void APIConnection::send_execute_service_response(uint32_t call_id, bool success resp.error_message = error_message; resp.response_data = response_data; resp.response_data_len = response_data_len; - this->send_message(resp); + if (!this->send_message(resp)) { + API_LOG_MSG_DROPPED(TAG, "Action response"); + } } #endif // USE_API_USER_DEFINED_ACTION_RESPONSES_JSON #endif // USE_API_USER_DEFINED_ACTION_RESPONSES #endif +#ifdef USE_API_HOMEASSISTANT_SERVICES +bool APIConnection::send_homeassistant_action(const HomeassistantActionRequest &call) { + if (!this->flags_.service_call_subscription) + return false; + if (!this->send_message(call)) { + API_LOG_MSG_DROPPED(TAG, "Action request"); + } + return true; +} +#endif // USE_API_HOMEASSISTANT_SERVICES + +#ifdef USE_HOMEASSISTANT_TIME +void APIConnection::send_time_request() { + GetTimeRequest req; + if (!this->send_message(req)) { + API_LOG_MSG_DROPPED(TAG, "Time request"); + } +} +#endif // USE_HOMEASSISTANT_TIME + #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES void APIConnection::on_homeassistant_action_response(const HomeassistantActionResponse &msg) { #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index d548b921b3..bb51a13000 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -25,6 +25,7 @@ #include "esphome/components/esp8266/crash_handler.h" #endif #include "esphome/core/entity_base.h" +#include "esphome/core/log.h" #include "esphome/core/string_ref.h" #include @@ -40,6 +41,16 @@ namespace esphome::api { // Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h. class APIServer; +// One shared flash string for every refused-frame warning: send_message() +// fails as soon as the TCP buffer is full, and each caller only pays for its +// short name. The guard drops the helper and its arguments below WARN. +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN +void log_dropped_message(const char *tag, int line, const LogString *what); +#define API_LOG_MSG_DROPPED(tag, what) esphome::api::log_dropped_message(tag, __LINE__, LOG_STR(what)) +#else +#define API_LOG_MSG_DROPPED(tag, what) +#endif + // Keepalive timeout in milliseconds static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000; // Maximum number of entities to process in a single batch during initial state/info sending @@ -169,12 +180,7 @@ class APIConnection final : public APIServerConnectionBase { // Returns whether this client has subscribed to Home Assistant actions; the message // is only handed to the send path when subscribed. A true return does not guarantee // delivery - it lets the caller warn when no connected client has the subscription. - bool send_homeassistant_action(const HomeassistantActionRequest &call) { - if (!this->flags_.service_call_subscription) - return false; - this->send_message(call); - return true; - } + bool send_homeassistant_action(const HomeassistantActionRequest &call); #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES void on_homeassistant_action_response(const HomeassistantActionResponse &msg); #endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES @@ -198,10 +204,7 @@ class APIConnection final : public APIServerConnectionBase { #endif #ifdef USE_HOMEASSISTANT_TIME - void send_time_request() { - GetTimeRequest req; - this->send_message(req); - } + void send_time_request(); #endif #ifdef USE_VOICE_ASSISTANT @@ -337,7 +340,9 @@ class APIConnection final : public APIServerConnectionBase { // Function pointer type for type-erased size calculation using CalculateSizeFn = uint32_t (*)(const void *); - template bool send_message(const T &msg) { + /// Returns false as soon as the TCP buffer is full. Marked nodiscard so we + /// have no silent failures: every caller must handle (or log) a refusal. + template [[nodiscard]] bool send_message(const T &msg) { if constexpr (T::ESTIMATED_SIZE == 0) { return this->send_message_(0, T::MESSAGE_TYPE, &encode_msg_noop, &msg); } else { diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 6e3448121c..ef5b43d7b1 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -123,7 +123,9 @@ void APIServer::setup() { // Best-effort: if the send buffer is full the reason is dropped, but the // client still learns the window is closed when it reconnects (rejected at // hello) or via the socket close. - c->send_message(req); + if (!c->send_message(req)) { + API_LOG_MSG_DROPPED(TAG, "Disconnect request"); + } } }); } @@ -394,8 +396,11 @@ void APIServer::on_update(update::UpdateEntity *obj) { void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) { // We could add code to manage a second subscription type, but, since this message type is // very infrequent and small, we simply send it to all clients - for (auto &c : this->active_clients()) - c->send_message(msg); + for (auto &c : this->active_clients()) { + if (!c->send_message(msg)) { + API_LOG_MSG_DROPPED(TAG, "Home ID notification"); + } + } } #endif @@ -576,7 +581,9 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString ESP_LOGW(TAG, "Disconnecting all clients to reset PSK"); for (auto &c : this->active_clients()) { DisconnectRequest req; - c->send_message(req); + if (!c->send_message(req)) { + API_LOG_MSG_DROPPED(TAG, "Disconnect request"); + } } }); } diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 75830e83ef..1489f9f4ba 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -150,8 +150,12 @@ void BluetoothProxy::handle_gatt_not_connected_(uint64_t address, uint16_t handl } #endif -void BluetoothProxy::log_advertisement_flush_() { - ESP_LOGV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len); +void BluetoothProxy::log_advertisement_flush_(bool sent) { + if (sent) { + ESP_LOGV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len); + } else { + ESP_LOGV(TAG, "Batch of %u BLE advertisements dropped, TCP buffer full", this->response_.advertisements_len); + } } void BluetoothProxy::dump_config() { diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index e5f3a259a1..5d2605e761 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -234,16 +234,15 @@ class BluetoothProxy final : public Component { void flush_pending_advertisements_() { if (this->response_.advertisements_len == 0) return; - // The one deliberately ignored result: advertisements are perishable and - // this is the highest-frequency send here, so reporting each drop would be - // the flood the batch pacing exists to avoid. - this->api_connection_->send_message(this->response_); + // Perishable and the highest-frequency send here: a drop only reports at + // V, anything louder would be the flood the batch pacing exists to avoid. + [[maybe_unused]] bool sent = this->api_connection_->send_message(this->response_); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - this->log_advertisement_flush_(); + this->log_advertisement_flush_(sent); #endif this->response_.advertisements_len = 0; } - void log_advertisement_flush_(); + void log_advertisement_flush_(bool sent); #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS BluetoothConnection *get_connection_(uint64_t address, bool reserve); diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index 76ae145b16..50add6b1d0 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -248,7 +248,9 @@ void VoiceAssistant::stream_api_audio_() { msg.data2_len = available2; } - this->api_client_->send_message(msg); + if (!this->api_client_->send_message(msg)) { + ESP_LOGV(TAG, "Audio frame dropped, TCP buffer full"); + } this->audio_source_->consume(available); if (this->audio_source2_ != nullptr) { @@ -477,7 +479,9 @@ void VoiceAssistant::loop() { api::VoiceAssistantAnnounceFinished msg; msg.success = true; - this->api_client_->send_message(msg); + if (!this->api_client_->send_message(msg)) { + API_LOG_MSG_DROPPED(TAG, "Announce-finished"); + } break; } } @@ -741,7 +745,9 @@ void VoiceAssistant::signal_stop_() { ESP_LOGD(TAG, "Signaling stop"); api::VoiceAssistantRequest msg; msg.start = false; - this->api_client_->send_message(msg); + if (!this->api_client_->send_message(msg)) { + API_LOG_MSG_DROPPED(TAG, "Stop request"); + } } void VoiceAssistant::start_playback_timeout_() { @@ -753,7 +759,9 @@ void VoiceAssistant::start_playback_timeout_() { return; api::VoiceAssistantAnnounceFinished msg; msg.success = true; - this->api_client_->send_message(msg); + if (!this->api_client_->send_message(msg)) { + API_LOG_MSG_DROPPED(TAG, "Announce-finished"); + } }); } diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index 5f56861e6d..6e3f109ca1 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -166,7 +166,9 @@ void ZWaveProxy::process_uart_slow_() { // If this is a data frame, use frame length indicator + 2 (for SoF + checksum), else assume 1 for ACK/NAK/CAN this->outgoing_proto_msg_.data_len = this->buffer_[0] == ZWAVE_FRAME_TYPE_START ? this->buffer_[1] + 2 : 1; } - this->api_connection_->send_message(this->outgoing_proto_msg_); + if (!this->api_connection_->send_message(this->outgoing_proto_msg_)) { + ESP_LOGV(TAG, "Frame dropped, TCP buffer full"); + } } } } while (this->available()); @@ -328,7 +330,9 @@ void ZWaveProxy::send_homeid_changed_msg_(api::APIConnection *conn) { msg.data_len = this->home_id_.size(); if (conn != nullptr) { // Send to specific connection - conn->send_message(msg); + if (!conn->send_message(msg)) { + API_LOG_MSG_DROPPED(TAG, "Home ID notification"); + } } else if (api::global_api_server != nullptr) { // We could add code to manage a second subscription type, but, since this message is // very infrequent and small, we simply send it to all clients @@ -483,7 +487,9 @@ void ZWaveProxy::parse_start_(uint8_t byte) { this->buffer_[0] = byte; this->outgoing_proto_msg_.data = this->buffer_.data(); this->outgoing_proto_msg_.data_len = 1; - this->api_connection_->send_message(this->outgoing_proto_msg_); + if (!this->api_connection_->send_message(this->outgoing_proto_msg_)) { + ESP_LOGV(TAG, "Frame dropped, TCP buffer full"); + } } } From 1758653330b59d1050c4324a3e36394a6c94d2f4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 22:59:26 -0500 Subject: [PATCH 1415/1815] [voice_assistant] Do not consume the audio chunk when the send is refused (#18295) --- esphome/components/voice_assistant/voice_assistant.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index 50add6b1d0..dba9b925d0 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -249,7 +249,11 @@ void VoiceAssistant::stream_api_audio_() { } if (!this->api_client_->send_message(msg)) { - ESP_LOGV(TAG, "Audio frame dropped, TCP buffer full"); + // Keep the chunk exposed and retry next pass, the same shape as + // APIConnection::try_send_camera_image_(): the slice is only lost if + // the ring buffer overflows before the TCP buffer clears, instead of + // on every refusal. The api layer already reports the refusal at V. + return; } this->audio_source_->consume(available); From a4e05cd1c86fb31c146f80e99aa1f4456c18a2cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 22:59:41 -0500 Subject: [PATCH 1416/1815] [bluetooth_proxy] Move per-advertisement logging to very verbose (#18299) --- esphome/components/bluetooth_proxy/bluetooth_proxy.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 1489f9f4ba..5b6f4211f2 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -104,7 +104,7 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme this->response_.advertisements_len++; - ESP_LOGV(TAG, "Queuing raw packet from %012" PRIX64 ", length %d. RSSI: %d dB", raw.address, length, raw.rssi); + ESP_LOGVV(TAG, "Queuing raw packet from %012" PRIX64 ", length %d. RSSI: %d dB", raw.address, length, raw.rssi); // Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) { @@ -152,8 +152,10 @@ void BluetoothProxy::handle_gatt_not_connected_(uint64_t address, uint16_t handl void BluetoothProxy::log_advertisement_flush_(bool sent) { if (sent) { - ESP_LOGV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len); + // VV: one line per flush drowns a verbose log in any busy environment. + ESP_LOGVV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len); } else { + // The rare congestion signal stays at V. ESP_LOGV(TAG, "Batch of %u BLE advertisements dropped, TCP buffer full", this->response_.advertisements_len); } } From a3d599ac699d7f51e0dd09536c8ee1d1f25602a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 23:00:37 -0500 Subject: [PATCH 1417/1815] [bluetooth_connection] Demote rp2 backend connection logs to verbose (#18301) --- .../bluetooth_connection/bluetooth_connection_hub.cpp | 4 ++++ .../bluetooth_connection/bluetooth_connection_rp2.cpp | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index 50267e7c73..c8f97f207e 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -133,6 +133,10 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int // params), so this request is normally redundant - kept as a backstop // in case the initial parameters were negotiated away. this->state_ = ClientState::ESTABLISHED; + // The one D-level line for a cached connect; the uncached path narrates + // through "Discovery finished" instead. + ESP_LOGD(TAG, "[%d] [%s] Connected with cached services, sending connected (mtu=%u)", this->connection_index_, + this->address_str_, mtu); int param_err = this->backend_->update_connection_params(ble_device_base::MEDIUM_MIN_CONN_INTERVAL, ble_device_base::MEDIUM_MAX_CONN_INTERVAL, 0, ble_device_base::MEDIUM_CONN_TIMEOUT); diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index dc8fb6714b..855c895196 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -542,7 +542,7 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) { case RP2GattEvent::MTU_EXCHANGED: if (this->state_ == EngineState::MTU_EXCHANGE) { this->mtu_ = event.value; - ESP_LOGD(TAG, "[%u] MTU %u", this->engine_index_, this->mtu_); + ESP_LOGV(TAG, "[%u] MTU %u", this->engine_index_, this->mtu_); this->state_ = EngineState::READY; // Scanning resumes and runs alongside the established connection. this->release_scan_inhibit_(); @@ -614,7 +614,7 @@ void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) { } this->con_handle_ = con_handle; this->state_ = EngineState::MTU_EXCHANGE; - ESP_LOGD(TAG, "[%u] Link up, handle=0x%04x, negotiating MTU", this->engine_index_, con_handle); + ESP_LOGV(TAG, "[%u] Link up, handle=0x%04x, negotiating MTU", this->engine_index_, con_handle); BluetoothLock lock; // One wildcard listener covers notifications/indications for every // characteristic on this connection; the CCCD writes come from the API @@ -694,7 +694,7 @@ void RP2GattClient::handle_disconnected_(uint8_t reason) { if (this->state_ == EngineState::IDLE) { return; } - ESP_LOGD(TAG, "[%u] Disconnected, reason=0x%02x", this->engine_index_, reason); + ESP_LOGV(TAG, "[%u] Disconnected, reason=0x%02x", this->engine_index_, reason); this->fail_connection_(reason); } @@ -858,7 +858,7 @@ void RP2GattClient::advance_discovery_(uint8_t att_status) { void RP2GattClient::finish_discovery_(int error) { this->discovery_phase_ = DiscoveryPhase::NONE; - ESP_LOGD(TAG, "[%u] Discovery done (err=%d): %u services, %u characteristics, %u descriptors", this->engine_index_, + ESP_LOGV(TAG, "[%u] Discovery done (err=%d): %u services, %u characteristics, %u descriptors", this->engine_index_, error, this->service_count_, this->char_count_, this->desc_count_); if (error == 0 && this->truncated_) { // A partial table must not stream: V3 clients cache the database From 5d04c1dc18a248cf2495a49fa891d6d977d6cb6b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 23:01:45 -0500 Subject: [PATCH 1418/1815] [esp32_ble_tracker] Demote scan-state echoes to verbose (#18302) --- esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp | 3 ++- esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp | 5 ++++- esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp | 3 ++- esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp | 3 ++- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp index a312d2496f..1b4e6245ae 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp @@ -364,7 +364,8 @@ bool BK72xxBLETracker::request_scan_mode(bool active) { if (this->scan_active_ == active) return true; this->scan_active_ = active; - ESP_LOGD(TAG, "Scan mode %s", active ? "active" : "passive"); + // V: the proxy's "Setting scanner mode" line already narrates this at D. + ESP_LOGV(TAG, "Scan mode %s", active ? "active" : "passive"); // The controller reconciler restarts a running scan itself; the scan stays // logically running. An idle scanner picks the mode up on its next start. if (this->scan_running_) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 18b6cf022d..86cb7293a1 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -55,6 +55,7 @@ void ESP32BLETracker::setup() { #ifdef USE_OTA_STATE_LISTENER void ESP32BLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) { if (state == ota::OTA_STARTED) { + ESP_LOGD(TAG, "Stopping scan for OTA"); this->scan_continuous_before_ota_ = this->scan_continuous_; this->stop_scan(); #ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT @@ -190,7 +191,9 @@ void ESP32BLETracker::loop() { void ESP32BLETracker::start_scan() { this->start_scan_(true); } void ESP32BLETracker::stop_scan() { - ESP_LOGD(TAG, "Stopping scan."); + // V to match the start log: the mode-switch and OTA callers narrate their + // reason at D themselves, and the user-facing stop action is deliberate. + ESP_LOGV(TAG, "Stopping scan."); this->scan_continuous_ = false; this->stop_scan_(); } diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp index 11ea46525c..e1083e5fbe 100644 --- a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp @@ -116,7 +116,8 @@ bool LN882HBLETracker::request_scan_mode(bool active) { if (this->scan_active_ == active) return true; this->scan_active_ = active; - ESP_LOGD(TAG, "Scan mode %s", active ? "active" : "passive"); + // V: the proxy's "Setting scanner mode" line already narrates this at D. + ESP_LOGV(TAG, "Scan mode %s", active ? "active" : "passive"); // scan_start() re-enters cleanly (stops + GAPM settle). No on_scan_end and // no period reset: the scan logically continues, only the mode changes. if (this->scan_running_) { diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp index 2a87d617f8..06beb186ae 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp @@ -165,7 +165,8 @@ bool RP2BLETracker::request_scan_mode(bool active) { if (this->scan_active_ == active) return true; this->scan_active_ = active; - ESP_LOGD(TAG, "Scan mode %s", active ? "active" : "passive"); + // V: the proxy's "Setting scanner mode" line already narrates this at D. + ESP_LOGV(TAG, "Scan mode %s", active ? "active" : "passive"); // Apply to a running scan by restarting the CONTROLLER scan with the new // mode, bypassing the tracker's stop/start bookkeeping: no on_scan_end (the // scan logically continues, only the request mode changes), no period reset. From 3ef74d17af6d03685939a005a81376060f7682c6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 23:02:51 -0500 Subject: [PATCH 1419/1815] [bluetooth_proxy] Give partial advertisement batches 200ms to fill on Wi-Fi (#18303) --- .../bluetooth_proxy/bluetooth_proxy.cpp | 20 +++++++++++++++++++ .../bluetooth_proxy/bluetooth_proxy.h | 6 ++++++ 2 files changed, 26 insertions(+) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 5b6f4211f2..878d3cd44e 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -674,7 +674,27 @@ void BluetoothProxy::loop() { } #endif +#ifdef USE_WIFI + // Wi-Fi (or a coexistence build that can fall back to it): every other + // non-empty 100 ms tick (~200 ms) gives partial batches time to fill + // toward BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE, so the air gets fewer, + // fuller frames. Full batches still ship immediately from the queueing + // path, and the owed-reply drains above keep the 100 ms cadence. + if (this->response_.advertisements_len != 0) { + if (this->adv_flush_toggle_) { + this->flush_pending_advertisements_(); + } + this->adv_flush_toggle_ = !this->adv_flush_toggle_; + } else { + // Nothing pending (idle, or a full batch just shipped inline): arm so + // the next batch ships on the next tick. + this->adv_flush_toggle_ = true; + } +#else + // No Wi-Fi in the build (ethernet): no airtime worth trading latency for, + // so partial batches flush every tick. this->flush_pending_advertisements_(); +#endif } void BluetoothProxy::reset_owed_replies_() { diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 5d2605e761..e233c38b56 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -348,6 +348,12 @@ class BluetoothProxy final : public Component { uint8_t connection_count_{0}; #endif bool configured_scan_active_{false}; // Configured scan mode from YAML +#ifdef USE_WIFI + /// Wi-Fi only: flush on every other non-empty tick (~200 ms) so partial + /// batches fill; an idle tick re-arms, so the first batch after a gap + /// still ships on the next tick. See loop(). + bool adv_flush_toggle_{false}; +#endif #ifdef USE_BLE_SCANNER_STATE_CALLBACK // A dropped push (full TX buffer) is re-queried from the hub and resent // from loop(); the hub's current state is idempotent by construction. From 08c6585915ffa2203773b4d122ad23f3a054e14f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 23:03:28 -0500 Subject: [PATCH 1420/1815] [esp32_ble_tracker] Don't log an error when a scan stop is already in flight (#18306) --- esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 86cb7293a1..798fd6e0ca 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -202,8 +202,9 @@ void ESP32BLETracker::ble_before_disabled_event_handler() { this->stop_scan_(); void ESP32BLETracker::stop_scan_() { if (this->scanner_state_ != ScannerState::RUNNING && this->scanner_state_ != ScannerState::FAILED) { - // If scanner is already idle, there's nothing to stop - this is not an error - if (this->scanner_state_ != ScannerState::IDLE) { + // IDLE means there is nothing to stop; STOPPING means a stop is already in + // flight and will finish on its own. Neither is an error. + if (this->scanner_state_ != ScannerState::IDLE && this->scanner_state_ != ScannerState::STOPPING) { ESP_LOGE(TAG, "Cannot stop scan: %s", this->scanner_state_to_string_(this->scanner_state_)); } return; From 8d87ba34d986a763d0328b1d242b9c50028e4c06 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 23:04:07 -0500 Subject: [PATCH 1421/1815] [api] Move the generic buffer-full log to very verbose (#18300) --- esphome/components/api/api_connection.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 53fe40f682..d05f98d03b 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -2177,7 +2177,10 @@ bool APIConnection::try_to_clear_buffer_slow_(bool log_out_of_space) { if (this->helper_->can_write_without_blocking()) return true; if (log_out_of_space) { - ESP_LOGV(TAG, "Cannot send message because of TCP buffer space"); + // VV: refusals are either reported by the sending call site (naming what + // was lost) or retried without loss (the deferred batch), so this generic + // line only duplicates them. + ESP_LOGVV(TAG, "Cannot send message because of TCP buffer space"); } return false; } From cffd775450f9cdafb31b9fe741bdd04182fb53c1 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 11 Aug 2026 23:21:37 -0500 Subject: [PATCH 1422/1815] [ethernet][network][wifi] Arbitrate the default route from the network priority list (#17797) --- .../components/ethernet/ethernet_component.h | 6 ++ .../ethernet/ethernet_component_esp32.cpp | 23 +++-- esphome/components/network/__init__.py | 41 ++++++++- .../components/network/network_component.cpp | 90 +++++++++++++++++++ .../components/network/network_component.h | 18 ++++ esphome/components/network/util.cpp | 27 ++++-- esphome/components/network/util.h | 3 +- esphome/components/wifi/wifi_component.h | 12 +++ .../wifi/wifi_component_esp_idf.cpp | 2 + esphome/core/defines.h | 1 + .../network/config/priority_arduino.yaml | 26 ++++++ .../network/config/priority_rp2040.yaml | 24 +++++ .../network/config/priority_single.yaml | 15 ++++ .../component_tests/network/test_priority.py | 71 ++++++++++++++- .../network/test-priority.esp32-ard.yaml | 23 +++++ 15 files changed, 366 insertions(+), 16 deletions(-) create mode 100644 tests/component_tests/network/config/priority_arduino.yaml create mode 100644 tests/component_tests/network/config/priority_rp2040.yaml create mode 100644 tests/component_tests/network/config/priority_single.yaml create mode 100644 tests/components/network/test-priority.esp32-ard.yaml diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index ad329f9b81..646e0af8e6 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -140,6 +140,12 @@ class EthernetComponent final : public Component { bool is_disabled() { return this->disabled_; } bool is_enabled() { return !this->disabled_; } +#ifdef USE_ESP32 + /// esp_netif handle, used by network for default-route arbitration. + /// nullptr until the driver/netif installation has run. + esp_netif_t *get_esp_netif() { return this->eth_netif_; } +#endif + void set_type(EthernetType type); #ifdef USE_ETHERNET_MANUAL_IP void set_manual_ip(const ManualIP &manual_ip); diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index dc623b6e5b..0220d6a19b 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -789,16 +789,25 @@ void EthernetComponent::start_connect_() { #ifdef USE_ETHERNET_MANUAL_IP if (this->manual_ip_.has_value()) { - LwIPLock lock; + // Set DNS through esp_netif so the servers are stored in the netif's own + // dns[] array; raw dns_setserver() would be lost when the default-route + // arbitration re-applies the default netif's DNS. + // Log-only on failure: the link still has a working IP/gateway, so degraded + // name resolution does not justify marking the whole component failed. + esp_netif_dns_info_t dns{}; if (this->manual_ip_->dns1.is_set()) { - ip_addr_t d; - d = this->manual_ip_->dns1; - dns_setserver(0, &d); + dns.ip = this->manual_ip_->dns1; + err = esp_netif_set_dns_info(this->eth_netif_, ESP_NETIF_DNS_MAIN, &dns); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Set main DNS failed: %s", esp_err_to_name(err)); + } } if (this->manual_ip_->dns2.is_set()) { - ip_addr_t d; - d = this->manual_ip_->dns2; - dns_setserver(1, &d); + dns.ip = this->manual_ip_->dns2; + err = esp_netif_set_dns_info(this->eth_netif_, ESP_NETIF_DNS_BACKUP, &dns); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Set backup DNS failed: %s", esp_err_to_name(err)); + } } } else #endif diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 24e9aa45e1..3544fb2647 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -39,6 +39,12 @@ KEY_NETWORK_PRIORITY = "network_priority" # NETWORK_PLAN.md for the full multi-interface roadmap. VALID_NETWORK_TYPES = ["ethernet", "wifi"] +# Interfaces NetworkComponent::loop() knows how to arbitrate the default route +# for. Deliberately NOT derived from VALID_NETWORK_TYPES: extending that list +# without extending the C++ arbitration (and then this set) is caught in +# _final_validate() as a config error instead of a silently mis-routed interface. +ARBITRATED_NETWORK_TYPES = frozenset({"ethernet", "wifi"}) + # Setup priority base values — first in list gets the highest priority. # # The base equals the historical setup_priority::WIFI / ::ETHERNET default @@ -310,7 +316,8 @@ CONFIG_SCHEMA = cv.All( def _final_validate(config: ConfigType) -> None: """Check that every interface named in 'priority' has a corresponding component block.""" full = fv.full_config.get() - for entry in config.get(CONF_PRIORITY, []): + priority_list = config.get(CONF_PRIORITY, []) + for entry in priority_list: iface = entry["interface"] if iface not in full: raise cv.Invalid( @@ -319,6 +326,24 @@ def _final_validate(config: ConfigType) -> None: [CONF_PRIORITY], ) + # Tripwire for future interface types (openthread, modem): the C++ default-route + # arbitration pivots on USE_NETWORK_PRIMARY_INTERFACE_WIFI and only knows + # ethernet and wifi. Extend NetworkComponent::loop() before allowing another + # type here. Unreachable until VALID_NETWORK_TYPES grows. + if ( + len(priority_list) > 1 + and ( + unsupported := {e["interface"] for e in priority_list} + - ARBITRATED_NETWORK_TYPES + ) + and CORE.is_esp32 + ): + raise cv.Invalid( + "Default-route arbitration does not support: " + f"{', '.join(sorted(unsupported))}", + [CONF_PRIORITY], + ) + FINAL_VALIDATE_SCHEMA = _final_validate @@ -337,10 +362,22 @@ async def to_code(config): # network/util.cpp resolves the reported address (get_use_address_to, # get_ip_addresses) in a fixed ethernet-first order; a wifi-first priority # list is the only case that deviates from it, so it is the only case that - # needs a define. Runtime (active-interface) selection is a planned follow-up. + # needs a define. if priority_list[0]["interface"] == "wifi": cg.add_define("USE_NETWORK_PRIMARY_INTERFACE_WIFI") + # With more than one interface, NetworkComponent::loop() arbitrates the + # default route (ESP-IDF's fixed route_prio values would always favor + # WiFi). ESP32 only: the arbitration needs esp_netif, which both + # frameworks build from source. + # The ethernet/wifi-only assumption behind the arbitration is enforced in + # _final_validate() so a future unsupported type fails as a config error. + if len(priority_list) > 1 and CORE.is_esp32: + cg.add_define("USE_NETWORK_DEFAULT_ROUTE") + # Have lwIP switch to the DNS servers of the netif that owns the + # default route whenever the arbitration changes it. + add_idf_sdkconfig_option("CONFIG_ESP_NETIF_SET_DNS_PER_DEFAULT_NETIF", True) + _LOGGER.info( "Network interface priority: %s", " > ".join(entry["interface"] for entry in priority_list), diff --git a/esphome/components/network/network_component.cpp b/esphome/components/network/network_component.cpp index 40cf64906c..cf457bb661 100644 --- a/esphome/components/network/network_component.cpp +++ b/esphome/components/network/network_component.cpp @@ -6,6 +6,20 @@ #include "esp_err.h" #include "esp_netif.h" #include "esp_event.h" + +#ifdef USE_NETWORK_DEFAULT_ROUTE +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" +#include "esp_netif_net_stack.h" +#include "lwip/netif.h" +#ifdef USE_ETHERNET +#include "esphome/components/ethernet/ethernet_component.h" +#endif +#ifdef USE_WIFI +#include "esphome/components/wifi/wifi_component.h" +#endif +#endif + namespace esphome::network { static const char *const TAG = "network"; @@ -29,5 +43,81 @@ void NetworkComponent::setup() { } } +#ifdef USE_NETWORK_DEFAULT_ROUTE +static esp_netif_t *connected_wifi_netif() { +#ifdef USE_WIFI + auto *wifi = wifi::global_wifi_component; + if (wifi != nullptr && wifi->is_connected()) + return wifi->get_esp_netif_sta(); +#endif + return nullptr; +} + +static esp_netif_t *connected_ethernet_netif() { +#ifdef USE_ETHERNET + auto *eth = ethernet::global_eth_component; + if (eth != nullptr && eth->is_connected()) + return eth->get_esp_netif(); +#endif + return nullptr; +} + +void NetworkComponent::loop() { + // Pin the default route to the first connected interface in the user's priority + // order; ESP-IDF's own route_prio selection would always favor WiFi. + // USE_NETWORK_PRIMARY_INTERFACE_WIFI is emitted for a wifi-first priority list; + // it selects the reported address in util.cpp and doubles as the route-order + // pivot here — the two uses must stay in sync. + esp_netif_t *best; +#ifdef USE_NETWORK_PRIMARY_INTERFACE_WIFI + best = connected_wifi_netif(); + if (best == nullptr) + best = connected_ethernet_netif(); +#else + best = connected_ethernet_netif(); + if (best == nullptr) + best = connected_wifi_netif(); +#endif + if (best == nullptr) { + // Forget the last winner: stopping its netif cleared lwIP's default route and + // IDF's manual override suppresses re-election, so reconnect must re-assert it. + this->default_netif_ = nullptr; + return; + } + if (best == this->default_netif_) { + // Same winner as the last assert. Still re-assert if lwIP's default route is + // not the winner's netif: a winner whose netif bounced down and up between two + // polls would otherwise stay routeless (stopping a netif nulls lwIP's + // netif_default). Checking lwIP directly keeps this independent of IDF's + // re-election bookkeeping (esp_netif_get_default_netif() cannot detect it). + // Throttled: LwIPLock is the global lwIP core mutex, and this branch runs on + // every pass once the route has settled. + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_route_check_ < ROUTE_CHECK_INTERVAL_MS) + return; + this->last_route_check_ = now; + bool route_is_ours; + { + LwIPLock lock; + route_is_ours = static_cast(netif_default) == esp_netif_get_netif_impl(best); + } + if (route_is_ours) + return; + } + esp_err_t err = esp_netif_set_default_netif(best); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to set default interface: (%d) %s", err, esp_err_to_name(err)); + // Cache the intent anyway: subsequent passes take the same-winner branch + // above, so retries are throttled to ROUTE_CHECK_INTERVAL_MS and the lwIP + // verification keeps re-attempting until the route is actually ours. + this->default_netif_ = best; + this->last_route_check_ = App.get_loop_component_start_time(); + return; + } + this->default_netif_ = best; + ESP_LOGI(TAG, "Default interface: %s", esp_netif_get_desc(best)); +} +#endif // USE_NETWORK_DEFAULT_ROUTE + } // namespace esphome::network #endif diff --git a/esphome/components/network/network_component.h b/esphome/components/network/network_component.h index 2e76a95673..8d4866d4f0 100644 --- a/esphome/components/network/network_component.h +++ b/esphome/components/network/network_component.h @@ -3,12 +3,30 @@ #if defined(USE_NETWORK) && defined(USE_ESP32) #include "esphome/core/component.h" +#ifdef USE_NETWORK_DEFAULT_ROUTE +// Forward declaration matching esp_netif's own typedef; avoids pulling esp_netif.h +// into this header. +using esp_netif_t = struct esp_netif_obj; +#endif + namespace esphome::network { class NetworkComponent final : public Component { public: void setup() override; // AFTER_BLUETOOTH: BLE controller must initialize before esp_netif_init per IDF guidance. float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; } + +#ifdef USE_NETWORK_DEFAULT_ROUTE + void loop() override; + + protected: + // Verify-lwIP-route interval for the settled state; keeps the global lwIP core + // mutex off the hot loop path. + static constexpr uint32_t ROUTE_CHECK_INTERVAL_MS = 1000; + // Last netif this component made the default; avoids redundant esp_netif calls. + esp_netif_t *default_netif_{nullptr}; + uint32_t last_route_check_{0}; +#endif }; } // namespace esphome::network #endif diff --git a/esphome/components/network/util.cpp b/esphome/components/network/util.cpp index d90c28801e..11485fdcf0 100644 --- a/esphome/components/network/util.cpp +++ b/esphome/components/network/util.cpp @@ -10,16 +10,33 @@ namespace esphome::network { // an AP that uses a previous interface for NAT). bool is_disabled() { + // The network is disabled only when every configured interface with a + // disable() lifecycle is disabled; one enabled interface means traffic can flow. + bool disabled = false; #ifdef USE_MODEM - if (modem::global_modem_component != nullptr) - return modem::global_modem_component->is_disabled(); + if (modem::global_modem_component != nullptr) { + if (!modem::global_modem_component->is_disabled()) + return false; + disabled = true; + } #endif #ifdef USE_WIFI - if (wifi::global_wifi_component != nullptr) - return wifi::global_wifi_component->is_disabled(); + if (wifi::global_wifi_component != nullptr) { + if (!wifi::global_wifi_component->is_disabled()) + return false; + disabled = true; + } #endif - return false; + +#ifdef USE_ETHERNET + if (ethernet::global_eth_component != nullptr) { + if (!ethernet::global_eth_component->is_disabled()) + return false; + disabled = true; + } +#endif + return disabled; } const char *get_use_address_to(std::span buf) { diff --git a/esphome/components/network/util.h b/esphome/components/network/util.h index df7e164bda..65a578c22f 100644 --- a/esphome/components/network/util.h +++ b/esphome/components/network/util.h @@ -52,7 +52,8 @@ ESPHOME_ALWAYS_INLINE inline bool is_connected() { return false; } -/// Return whether the network is disabled (only wifi for now) +/// Return whether the network is disabled: every configured interface with a +/// disable() lifecycle (modem, wifi, ethernet) is disabled. bool is_disabled(); /// Buffer size for get_use_address_to(): 63-char DNS label + ".local" + null terminator static constexpr size_t USE_ADDRESS_BUFFER_SIZE = 70; diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index a851ea4015..ea043fd5c6 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -65,6 +65,12 @@ extern "C" { #include #endif +#ifdef USE_ESP32 +// Forward declaration matching esp_netif's own typedef; avoids pulling esp_netif.h +// into this widely-included header. +using esp_netif_t = struct esp_netif_obj; +#endif + namespace esphome::wifi { /// Sentinel value for RSSI when WiFi is not connected @@ -469,6 +475,12 @@ class WiFiComponent final : public Component { bool is_connected() const { return this->connected_; } +#ifdef USE_ESP32 + /// esp_netif handle of the station interface, used by network for default-route + /// arbitration. nullptr until wifi_lazy_init_() has run. + esp_netif_t *get_esp_netif_sta(); +#endif + void set_power_save_mode(WiFiPowerSaveMode power_save); void set_min_auth_mode(WifiMinAuthMode min_auth_mode) { min_auth_mode_ = min_auth_mode; } void set_output_power(float output_power) { output_power_ = output_power; } diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 0198f899d5..245390b097 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -620,6 +620,8 @@ bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { return true; } +esp_netif_t *WiFiComponent::get_esp_netif_sta() { return s_sta_netif; } + network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() { if (!this->has_sta()) return {}; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 26b9025d96..bb4960aec7 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -138,6 +138,7 @@ #define USE_MEDIA_PLAYER #define USE_MEDIA_SOURCE #define USE_NETWORK +#define USE_NETWORK_DEFAULT_ROUTE #define USE_NETWORK_PRIMARY_INTERFACE_WIFI #define USE_NEXTION_COMMAND_SPACING #define USE_NEXTION_CONF_START_UP_PAGE diff --git a/tests/component_tests/network/config/priority_arduino.yaml b/tests/component_tests/network/config/priority_arduino.yaml new file mode 100644 index 0000000000..b66f676601 --- /dev/null +++ b/tests/component_tests/network/config/priority_arduino.yaml @@ -0,0 +1,26 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: arduino + +wifi: + ssid: "test_ssid" + password: "test_password" + +ethernet: + type: W5500 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + clock_speed: 10Mhz + +network: + priority: + - ethernet + - wifi diff --git a/tests/component_tests/network/config/priority_rp2040.yaml b/tests/component_tests/network/config/priority_rp2040.yaml new file mode 100644 index 0000000000..984f2dcbb4 --- /dev/null +++ b/tests/component_tests/network/config/priority_rp2040.yaml @@ -0,0 +1,24 @@ +esphome: + name: test + +rp2: + board: rpipicow + +wifi: + ssid: "test_ssid" + password: "test_password" + +ethernet: + type: W5500 + clk_pin: 18 + mosi_pin: 19 + miso_pin: 16 + cs_pin: 17 + interrupt_pin: 21 + reset_pin: 20 + mac_address: "02:AA:BB:CC:DD:01" + +network: + priority: + - ethernet + - wifi diff --git a/tests/component_tests/network/config/priority_single.yaml b/tests/component_tests/network/config/priority_single.yaml new file mode 100644 index 0000000000..bd23697808 --- /dev/null +++ b/tests/component_tests/network/config/priority_single.yaml @@ -0,0 +1,15 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +network: + priority: + - wifi diff --git a/tests/component_tests/network/test_priority.py b/tests/component_tests/network/test_priority.py index da1c0a061d..017f0711a3 100644 --- a/tests/component_tests/network/test_priority.py +++ b/tests/component_tests/network/test_priority.py @@ -16,9 +16,10 @@ from esphome.components.network import ( _validate_priority_list, get_network_priority, ) -from esphome.const import CONF_PRIORITY +from esphome.const import CONF_PRIORITY, PlatformFramework from esphome.core import CORE import esphome.final_validate as fv +from tests.component_tests.types import SetCoreConfigCallable @pytest.fixture(autouse=True) @@ -138,6 +139,22 @@ def test_final_validate_noop_without_priority_list() -> None: _final_validate({}) # must not raise +def test_final_validate_rejects_unsupported_arbitration_interface( + set_core_config: SetCoreConfigCallable, +) -> None: + """The ethernet/wifi-only arbitration tripwire fails as a clean config error. + + Unreachable through the public schema today (VALID_NETWORK_TYPES gates the + list), so the config is hand-built to simulate a future interface type that + was added to the schema without extending NetworkComponent::loop(). + """ + set_core_config(PlatformFramework.ESP32_IDF) + fv.full_config.set({"openthread": {}, "wifi": {}}) + config = {CONF_PRIORITY: [{"interface": "openthread"}, {"interface": "wifi"}]} + with pytest.raises(Invalid, match="arbitration does not support: openthread"): + _final_validate(config) + + def _cpp_setup_priority(name: str) -> float: """Read a setup_priority constant straight from esphome/core/component.h.""" header = Path(__file__).parents[3] / "esphome" / "core" / "component.h" @@ -199,3 +216,55 @@ def test_no_primary_interface_define_without_priority( assert not any( d.name.startswith("USE_NETWORK_PRIMARY_INTERFACE_") for d in CORE.defines ) + + +def _dns_per_default_netif_option() -> bool | None: + from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS + + if KEY_ESP32 not in CORE.data: # non-ESP32 configs have no sdkconfig at all + return None + return CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS].get( + "CONFIG_ESP_NETIF_SET_DNS_PER_DEFAULT_NETIF" + ) + + +@pytest.mark.parametrize( + "config_file", + [ + "priority_wifi_first.yaml", + "priority_ethernet_first.yaml", + "priority_arduino.yaml", + ], +) +def test_multi_interface_priority_enables_default_route_arbitration( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, +) -> None: + """More than one interface in 'priority' enables default-route arbitration.""" + generate_main(component_config_path(config_file)) + assert "USE_NETWORK_DEFAULT_ROUTE" in {d.name for d in CORE.defines} + assert _dns_per_default_netif_option() is True + + +@pytest.mark.parametrize( + "config_file", + [ + # Single-entry priority list / no list at all. + "priority_single.yaml", + "wifi_only.yaml", + # Dual-interface on rp2040: validates, but the arbitration is ESP32-only + # (NetworkComponent::loop() is compiled under USE_ESP32) — emitting the + # define here would be a hard build break. + "priority_rp2040.yaml", + ], +) +def test_single_interface_has_no_default_route_arbitration( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, +) -> None: + """Single-interface and non-ESP32 configs must not compile in the arbitration.""" + generate_main(component_config_path(config_file)) + assert "USE_NETWORK_DEFAULT_ROUTE" not in {d.name for d in CORE.defines} + assert _dns_per_default_netif_option() is None diff --git a/tests/components/network/test-priority.esp32-ard.yaml b/tests/components/network/test-priority.esp32-ard.yaml new file mode 100644 index 0000000000..a04246a128 --- /dev/null +++ b/tests/components/network/test-priority.esp32-ard.yaml @@ -0,0 +1,23 @@ +# Arduino dual-stack test: default-route arbitration must also compile under +# the Arduino framework, which builds the same esp_netif/ESP-IDF from source. +# Ethernet is listed first so this build exercises the ethernet-first side of +# the arbitration pivot in NetworkComponent::loop() (the IDF variant of this +# test covers the wifi-first side). +wifi: + ssid: MySSID + password: password1 + +ethernet: + type: W5500 + clk_pin: GPIO19 + mosi_pin: GPIO21 + miso_pin: GPIO23 + cs_pin: GPIO18 + interrupt_pin: GPIO36 + reset_pin: GPIO22 + clock_speed: 10Mhz + +network: + priority: + - ethernet + - wifi From 622942482cf79818396e2db38f6e7ea717b4a7eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 00:30:17 -0500 Subject: [PATCH 1423/1815] [rp2] Size the lwIP segment pool and heap for concurrent senders (#18257) --- esphome/components/api/api_frame_helper.h | 4 +- esphome/components/rp2/__init__.py | 150 ++++++++++++++-------- esphome/components/rp2/lwipopts.h.jinja | 13 +- tests/unit_tests/components/test_rp2.py | 107 +++++++++++++-- 4 files changed, 207 insertions(+), 67 deletions(-) diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 9cae6ba92e..9c49956bbd 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -149,7 +149,7 @@ class APIFrameHelper { // holding data too long waiting for Nagle's timer causes buffer exhaustion // and dropped messages. // - // ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (8×MSS) / LibreTiny (4×MSS): 4 logs per cycle + // ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (4×MSS) / LibreTiny (4×MSS): 4 logs per cycle // ESP8266 (2×MSS): 3 logs per cycle (tightest buffers) // // Flow (ESP32/RP2040/LT): Log 1 (Nagle on) -> Log 2 -> Log 3 -> Log 4 (NODELAY, flush) @@ -312,7 +312,7 @@ class APIFrameHelper { // Values 1..LOG_NAGLE_COUNT count log messages in the current Nagle batch. // After LOG_NAGLE_COUNT logs, we flush by re-enabling NODELAY and resetting to 0. // ESP8266 has the tightest TCP send buffer (2×MSS) and needs conservative batching. - // ESP32 (4×MSS+), RP2040 (8×MSS), and LibreTiny (4×MSS) can coalesce more. + // ESP32 (4×MSS+), RP2040 (4×MSS), and LibreTiny (4×MSS) can coalesce more. #ifdef USE_ESP8266 static constexpr uint8_t LOG_NAGLE_COUNT = 2; #else diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index 3bc2df7a61..87e78003ed 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -388,6 +388,74 @@ async def to_code(config): _configure_lwip() +# --- lwIP sizing. See _configure_lwip() for the platform comparison table. --- + +# TCP_SND_BUF: 4×MSS=5,840 matches ESP32. Down from arduino-pico's 8×MSS. +# ESPAsyncWebServer allocates malloc(tcp_sndbuf()) per response chunk. +LWIP_TCP_SND_BUF = "(4*TCP_MSS)" + +# TCP_WND: receive window. 4×MSS matches ESP32. Down from arduino-pico's 8×MSS. +LWIP_TCP_WND = "(4*TCP_MSS)" + +# TCP_SND_QUEUELEN: max pbufs queued per PCB for the send buffer +# ESP-IDF formula: (4 * TCP_SND_BUF + (TCP_MSS - 1)) / TCP_MSS +# With 4×MSS: (4*5840 + 1459) / 1460 = 17 — match ESP32 +LWIP_TCP_SND_QUEUELEN = 17 + +# MEMP_NUM_TCP_SEG: pool shared by every PCB, so it must not be the per-PCB +# queue length — lwIP's sanity check only demands >=, the floor for a single +# connection. 2× lets two PCBs fill up before the rest see ERR_MEM. Measured +# at 20 bytes per entry, so under 700 bytes total. +LWIP_MEMP_NUM_TCP_SEG = 2 * LWIP_TCP_SND_QUEUELEN + +# PBUF_POOL_SIZE: RP2040 has 264KB RAM, more generous than LibreTiny. +# 16 matches ESP32 (vs arduino-pico's 24). Receive side only; the send path +# copies into PBUF_RAM out of MEM_SIZE. +LWIP_PBUF_POOL_SIZE = 16 + +# MEM_SIZE: lwIP heap backing PBUF_RAM, where tcp_write() copies outgoing +# data. TCP_OVERSIZE defaults to TCP_MSS, so each queued segment takes a full +# MSS block whatever was written (pbuf 16 + PBUF_TRANSPORT 54 + MSS 1460 + +# block header ≈ 1.5KB); a PCB at a full TCP_SND_BUF holds four, ~6KB. +# +# Two of those is ~12KB of arduino-pico's 16KB heap and already fails: mem.c +# is first-fit, so a *contiguous* 1.5KB block must be free, and at 75% +# occupancy interleaved with ARP/DHCP/DNS/mDNS the largest run collapses well +# before the total does — hence the intermittent failures. With rp2's +# max_connections of 4, a third sender has nothing left. +# +# 32KB is arduino-pico's own next tier (__LWIP_MEMMULT=2 boards). +# Must stay under 64000 or lwIP widens mem_size_t to u32_t. +LWIP_MEM_SIZE = 32768 + + +def build_lwip_defines( + tcp_sockets: int, udp_sockets: int, listening_tcp: int +) -> dict[str, str]: + """Render the lwIP override values for the Jinja2 template. + + The template uses #include_next to chain to the framework's original + lwipopts.h, then #undef/#define only these. Split out from + _configure_lwip() so the values that actually reach the generated header + can be checked without standing up CORE. + + Both malloc flags stay 0 (framework defaults); see _configure_lwip(). The + static pools are the only IRQ-safe allocator on this platform, so the fix + is to size them correctly rather than to make them dynamic. + """ + return { + "TCP_SND_BUF": LWIP_TCP_SND_BUF, + "TCP_WND": LWIP_TCP_WND, + "TCP_SND_QUEUELEN": str(LWIP_TCP_SND_QUEUELEN), + "MEM_SIZE": str(LWIP_MEM_SIZE), + "MEMP_NUM_TCP_SEG": str(LWIP_MEMP_NUM_TCP_SEG), + "PBUF_POOL_SIZE": str(LWIP_PBUF_POOL_SIZE), + "MEMP_NUM_TCP_PCB": str(tcp_sockets), + "MEMP_NUM_TCP_PCB_LISTEN": str(listening_tcp), + "MEMP_NUM_UDP_PCB": str(udp_sockets), + } + + def _configure_lwip() -> None: """Configure lwIP options for RP2040 by generating a custom lwipopts.h. @@ -407,25 +475,36 @@ def _configure_lwip() -> None: ──────────────────────────────────────────────────────────────── TCP_SND_BUF 2×MSS 4×MSS 8×MSS 4×MSS TCP_WND 4×MSS 4×MSS 8×MSS 4×MSS + TCP_SND_QUEUELEN ~8 17 32 17 MEM_LIBC_MALLOC 1 1 0 0* MEMP_MEM_MALLOC 1 1 0 0** - MEM_SIZE N/A*** N/A*** 16KB 16KB + MEM_SIZE N/A*** N/A*** 16KB 32KB PBUF_POOL_SIZE 10 16 24 16 - MEMP_NUM_TCP_SEG 10 16 32 17 + MEMP_NUM_TCP_SEG 10 16 32 34**** MEMP_NUM_TCP_PCB 5 16 5 dynamic - MEMP_NUM_TCP_PCB_LISTEN 4 16 8**** dynamic + MEMP_NUM_TCP_PCB_LISTEN 4 16 8***** dynamic MEMP_NUM_UDP_PCB 4 16 7 dynamic - TCP_SND_QUEUELEN ~8 17 32 17 * MEM_LIBC_MALLOC must stay 0: arduino-pico uses PICO_CYW43_ARCH_THREADSAFE_BACKGROUND which runs lwIP callbacks from a low-priority pendsv IRQ. The pico-sdk explicitly blocks MEM_LIBC_MALLOC=1 because libc malloc uses mutexes (unsafe in IRQ). - ** MEMP_MEM_MALLOC must stay 0: the dedicated lwIP heap (MEM_SIZE=16KB) - is too small to hold all pools dynamically. The PBUF_POOL alone needs - ~24KB (16 × 1524 bytes). Increasing MEM_SIZE would negate BSS savings. - *** ESP8266/ESP32 use MEM_LIBC_MALLOC=1 (system heap, no dedicated pool). - **** opt.h default; arduino-pico doesn't override MEMP_NUM_TCP_PCB_LISTEN. + ** MEMP_MEM_MALLOC must stay 0 for IRQ safety, not size. memp_malloc() + pops the pool free list inside SYS_ARCH_PROTECT, but lwIP's heap takes + its protection from LWIP_ALLOW_MEM_FREE_FROM_OTHER_CONTEXT (default 0), + so under NO_SYS=1 mem_malloc()/mem_free() are unprotected — and memp.c + calls mem_malloc() outside the guard anyway. RX pbufs would then be + allocated from the pendsv IRQ on the same unguarded free list the main + loop uses for tcp_write(). Tried on hardware: faults within seconds on + CYW43. Ethernet survives only because it polls from the main loop. + *** ESP8266/ESP32 ship MEMP_MEM_MALLOC=1, so their pool entries come from + the heap on demand and MEMP_NUM_*/PBUF_POOL_SIZE are labels, not caps + (MEM_LIBC_MALLOC=1 points that heap at the system heap). Both flags are + 0 here, so ours are hard limits; don't copy their numbers. + **** MEMP_NUM_TCP_SEG is *global* while TCP_SND_QUEUELEN is *per-PCB*, so + sizing it to the per-PCB value lets one busy connection drain it for + every other. 2× covers two PCBs; MEM_SIZE is the real limit past that. + ***** opt.h default; arduino-pico doesn't override MEMP_NUM_TCP_PCB_LISTEN. "dynamic" = auto-calculated from component socket registrations via socket.get_socket_counts() with minimums of 8 TCP / 6 UDP / 2 TCP_LISTEN. """ @@ -444,48 +523,7 @@ def _configure_lwip() -> None: # UDP PCBs (2) are absorbed by the generous minimum of 6. listening_tcp = max(MIN_TCP_LISTEN_SOCKETS, sc.tcp_listen) - # TCP_SND_BUF: 4×MSS=5,840 matches ESP32. Down from arduino-pico's 8×MSS. - # ESPAsyncWebServer allocates malloc(tcp_sndbuf()) per response chunk. - tcp_snd_buf = "(4*TCP_MSS)" - - # TCP_WND: receive window. 4×MSS matches ESP32. Down from arduino-pico's 8×MSS. - tcp_wnd = "(4*TCP_MSS)" - - # TCP_SND_QUEUELEN: max pbufs queued for send buffer - # ESP-IDF formula: (4 * TCP_SND_BUF + (TCP_MSS - 1)) / TCP_MSS - # With 4×MSS: (4*5840 + 1459) / 1460 = 17 — match ESP32 - tcp_snd_queuelen = 17 - # MEMP_NUM_TCP_SEG: segment pool, must be >= TCP_SND_QUEUELEN (lwIP sanity check) - memp_num_tcp_seg = tcp_snd_queuelen - - # PBUF_POOL_SIZE: RP2040 has 264KB RAM, more generous than LibreTiny. - # 16 matches ESP32 (vs arduino-pico's 24). With MEMP_MEM_MALLOC=1, - # this is a max count (allocated on demand from heap). - pbuf_pool_size = 16 - - # Build the lwIP override defines for the Jinja2 template. - # The template uses #include_next to chain to the framework's original - # lwipopts.h, then #undef/#define only the values we need to change. - # - # Note: MEMP_MEM_MALLOC stays 0 (framework default). While the memp - # allocations use the dedicated lwIP heap (IRQ-safe), the 16KB MEM_SIZE - # is too small to hold all pools dynamically under stress. The PBUF_POOL - # alone needs ~24KB (16 × 1524 bytes). Increasing MEM_SIZE would negate - # the BSS savings. - # - # MEM_LIBC_MALLOC stays 0 (framework default): arduino-pico uses - # PICO_CYW43_ARCH_THREADSAFE_BACKGROUND which runs lwIP callbacks from - # a low-priority pendsv IRQ where libc malloc (mutex-based) is unsafe. - lwip_defines: dict[str, str] = { - "TCP_SND_BUF": tcp_snd_buf, - "TCP_WND": tcp_wnd, - "TCP_SND_QUEUELEN": str(tcp_snd_queuelen), - "MEMP_NUM_TCP_SEG": str(memp_num_tcp_seg), - "PBUF_POOL_SIZE": str(pbuf_pool_size), - "MEMP_NUM_TCP_PCB": str(tcp_sockets), - "MEMP_NUM_TCP_PCB_LISTEN": str(listening_tcp), - "MEMP_NUM_UDP_PCB": str(udp_sockets), - } + lwip_defines = build_lwip_defines(tcp_sockets, udp_sockets, listening_tcp) # Store for copy_files() to generate the header CORE.data[KEY_RP2][KEY_LWIP_OPTS] = lwip_defines @@ -500,7 +538,8 @@ def _configure_lwip() -> None: udp_min = " (min)" if udp_sockets > sc.udp else "" listen_min = " (min)" if listening_tcp > sc.tcp_listen else "" _LOGGER.info( - "Configuring lwIP: TCP=%d%s [%s], UDP=%d%s [%s], TCP_LISTEN=%d%s [%s]", + "Configuring lwIP: %d byte heap; TCP=%d%s [%s], UDP=%d%s [%s], TCP_LISTEN=%d%s [%s]", + LWIP_MEM_SIZE, tcp_sockets, tcp_min, sc.tcp_details, @@ -521,7 +560,7 @@ def _generate_lwipopts_h() -> None: in the build directory, and a pre-build script injects this directory into the compiler include path before the framework's own include dir. """ - from jinja2 import Environment + from jinja2 import Environment, StrictUndefined lwip_defines = CORE.data[KEY_RP2].get(KEY_LWIP_OPTS) if not lwip_defines: @@ -534,7 +573,10 @@ def _generate_lwipopts_h() -> None: template_text = (Path(__file__).parent / "lwipopts.h.jinja").read_text( encoding="utf-8" ) - jinja_env = Environment(keep_trailing_newline=True) + # StrictUndefined: a placeholder with no value would otherwise render + # empty, emitting a bare #define that compiles and silently means + # something else in lwIP's config. + jinja_env = Environment(keep_trailing_newline=True, undefined=StrictUndefined) template = jinja_env.from_string(template_text) content = template.render(**lwip_defines) diff --git a/esphome/components/rp2/lwipopts.h.jinja b/esphome/components/rp2/lwipopts.h.jinja index 36d7d4da14..2da4f467a9 100644 --- a/esphome/components/rp2/lwipopts.h.jinja +++ b/esphome/components/rp2/lwipopts.h.jinja @@ -20,13 +20,24 @@ #undef TCP_WND #define TCP_WND {{ TCP_WND }} -// Queued segment limits: derived from 4xMSS buffer size, matching ESP32 +// Per-PCB send queue: derived from 4xMSS buffer size, matching ESP32 #undef TCP_SND_QUEUELEN #define TCP_SND_QUEUELEN {{ TCP_SND_QUEUELEN }} +// Segment pool: global across every PCB, so it is sized above the per-PCB +// queue length rather than equal to it. lwIP's sanity check only requires +// >= TCP_SND_QUEUELEN, which is the floor for a single connection. #undef MEMP_NUM_TCP_SEG #define MEMP_NUM_TCP_SEG {{ MEMP_NUM_TCP_SEG }} +// lwIP heap backing PBUF_RAM, which is what tcp_write() copies into. +// Raised from arduino-pico's 16KB: TCP_OVERSIZE is TCP_MSS, so a single PCB +// at a full TCP_SND_BUF pins about 6KB. Two of those left the 16KB heap at +// 75%, and mem.c is first-fit, so the largest contiguous run ran out well +// before the total did. +#undef MEM_SIZE +#define MEM_SIZE {{ MEM_SIZE }} + // Packet buffer pool: 16 matches ESP32 (down from 24) #undef PBUF_POOL_SIZE #define PBUF_POOL_SIZE {{ PBUF_POOL_SIZE }} diff --git a/tests/unit_tests/components/test_rp2.py b/tests/unit_tests/components/test_rp2.py index 023d926dc4..cd92bc24fa 100644 --- a/tests/unit_tests/components/test_rp2.py +++ b/tests/unit_tests/components/test_rp2.py @@ -13,25 +13,24 @@ itself (Python imports, YAML key rename, deprecation warning) is covered by the framework tests under ``tests/unit_tests/``. """ +from pathlib import Path +import re + +from esphome.components import rp2 + def test_board_id_has_wifi_for_known_wifi_board() -> None: """``rpipicow`` is the canonical Pico W → True.""" - from esphome.components import rp2 - assert rp2.board_id_has_wifi("rpipicow") is True def test_board_id_has_wifi_for_known_non_wifi_board() -> None: """Plain ``rpipico`` has no CYW43 → False.""" - from esphome.components import rp2 - assert rp2.board_id_has_wifi("rpipico") is False def test_board_id_has_wifi_for_rp2350_w_variant() -> None: """``rpipico2w`` is the RP2350 Pico 2 W → True.""" - from esphome.components import rp2 - assert rp2.board_id_has_wifi("rpipico2w") is True @@ -43,8 +42,6 @@ def test_board_id_has_wifi_for_unknown_board_returns_true() -> None: block and any genuinely-unsupported config trips the existing "no CYW43" guard at compile time. """ - from esphome.components import rp2 - assert rp2.board_id_has_wifi("not-a-real-board-id") is True @@ -55,8 +52,6 @@ def test_rp2_declares_rp2040_as_alias() -> None: opts in via ``ALIASES``; without this declaration the rename framework wouldn't route legacy configs. """ - from esphome.components import rp2 - assert "rp2040" in rp2.ALIASES assert rp2.ALIAS_REMOVAL_VERSION == "2027.7.0" @@ -93,3 +88,95 @@ def test_rp2040_submodule_imports_resolve_to_rp2_submodules() -> None: assert rp2040_boards is rp2_boards assert rp2040_generate is rp2_generate + + +def test_lwip_segment_pool_exceeds_per_pcb_queue() -> None: + """The segment pool is global while the send queue is per-PCB. + + lwIP's sanity check only requires ``MEMP_NUM_TCP_SEG >= TCP_SND_QUEUELEN``, + which is the floor for a *single* connection: at equality one busy PCB can + drain the pool for every other PCB. Dropping back to that floor would + rebuild the starvation this sizing exists to prevent, and nothing in the + build would complain. + """ + assert rp2.LWIP_MEMP_NUM_TCP_SEG >= 2 * rp2.LWIP_TCP_SND_QUEUELEN + + +def test_lwip_mem_size_keeps_mem_size_t_narrow() -> None: + """``lwip/mem.h`` widens ``mem_size_t`` to ``u32_t`` on + ``MEM_SIZE > 64000L``, growing the header on every heap block. Raising the + heap past that bound is a real option, but it should be a deliberate one + rather than a side effect of tuning. + """ + assert rp2.LWIP_MEM_SIZE <= 64000 + + +def test_lwip_mem_size_holds_the_concurrent_senders_it_claims() -> None: + """Pin the floor as well as the ceiling. + + The ceiling above is satisfied by arduino-pico's own 16 KB, which is the + value this change exists to move off, so on its own it would let a revert + through. Derive the floor from the sizing comment on the constant: with + TCP_OVERSIZE at TCP_MSS every queued segment takes a full MSS-sized block + (pbuf header + PBUF_TRANSPORT offset + 1460 + heap block header, ~1.5 KB), + a PCB at a full 4xMSS TCP_SND_BUF holds four of them, and api's + max_connections on rp2 is 4. Room for three concurrent senders is the + minimum that makes the change worth making; 16 KB does not reach it. + """ + segments_per_full_send_buf = 4 + bytes_per_mss_block = 1536 + concurrent_senders = 3 + + assert ( + concurrent_senders * segments_per_full_send_buf * bytes_per_mss_block + <= rp2.LWIP_MEM_SIZE + ) + + +def test_lwip_defines_carry_the_sizing_into_the_header() -> None: + """The constants above only matter if they reach the generated header. + + ``build_lwip_defines()`` is what feeds lwipopts.h.jinja, so assert on it + rather than on the constants alone: dropping a key here would silently + fall back to arduino-pico's own value while every other assertion in this + file stayed green. + """ + defines = rp2.build_lwip_defines(tcp_sockets=8, udp_sockets=6, listening_tcp=2) + + assert defines["MEM_SIZE"] == str(rp2.LWIP_MEM_SIZE) + assert defines["MEMP_NUM_TCP_SEG"] == str(rp2.LWIP_MEMP_NUM_TCP_SEG) + assert defines["TCP_SND_QUEUELEN"] == str(rp2.LWIP_TCP_SND_QUEUELEN) + # Socket-derived counts pass through untouched. + assert defines["MEMP_NUM_TCP_PCB"] == "8" + assert defines["MEMP_NUM_UDP_PCB"] == "6" + assert defines["MEMP_NUM_TCP_PCB_LISTEN"] == "2" + + +def test_lwipopts_template_renders_every_sizing_value() -> None: + """Render the template the way _generate_lwipopts_h() does and check the + header that actually ships. + + Covers both directions. A ``#define`` block deleted from the template + leaves the value at arduino-pico's own, which for MEM_SIZE is the 16 KB + heap this change exists to move off, and the loop below catches that. A + placeholder with no dict key would otherwise render empty and emit a bare + ``#define FOO``; StrictUndefined turns that into an error instead. + Matching on text also survives a filter or conditional appearing in the + template later, which a placeholder regex would not. + """ + from jinja2 import Environment, StrictUndefined + + defines = rp2.build_lwip_defines(tcp_sockets=8, udp_sockets=6, listening_tcp=2) + template_text = (Path(rp2.__file__).parent / "lwipopts.h.jinja").read_text( + encoding="utf-8" + ) + rendered = ( + Environment(keep_trailing_newline=True, undefined=StrictUndefined) + .from_string(template_text) + .render(**defines) + ) + + for name, value in defines.items(): + assert re.search( + rf"^#define {re.escape(name)} +{re.escape(value)}$", rendered, re.MULTILINE + ), f"{name} did not reach the generated header as {value!r}" From 25c0c2c97b1a9ff45b7213c048d3b12dc6720d39 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Wed, 12 Aug 2026 08:10:23 +0200 Subject: [PATCH 1424/1815] [hoermann_hcp] Add garage light control (#18190) Co-authored-by: J. Nick Koston Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../components/hoermann_hcp/hoermann_hcp.cpp | 148 +++- .../components/hoermann_hcp/hoermann_hcp.h | 39 +- .../components/hoermann_hcp/light/__init__.py | 24 + .../hoermann_hcp/light/hoermann_hcp_light.cpp | 82 ++ .../hoermann_hcp/light/hoermann_hcp_light.h | 30 + .../hoermann_hcp_binary_sensor_test.cpp | 30 +- tests/components/hoermann_hcp/common.h | 68 ++ tests/components/hoermann_hcp/common.yaml | 4 + .../cover/hoermann_hcp_cover_test.cpp | 57 +- .../hoermann_hcp/hoermann_hcp_test.cpp | 134 ++- .../light/hoermann_hcp_light_test.cpp | 761 ++++++++++++++++++ 11 files changed, 1211 insertions(+), 166 deletions(-) create mode 100644 esphome/components/hoermann_hcp/light/__init__.py create mode 100644 esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp create mode 100644 esphome/components/hoermann_hcp/light/hoermann_hcp_light.h create mode 100644 tests/components/hoermann_hcp/common.h create mode 100644 tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.cpp b/esphome/components/hoermann_hcp/hoermann_hcp.cpp index 0dc146a061..a780854831 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.cpp +++ b/esphome/components/hoermann_hcp/hoermann_hcp.cpp @@ -13,10 +13,17 @@ static constexpr uint16_t STATE_REG = 0x9CB9; // Internal state read back b static constexpr uint16_t BROADCAST_REG = 0x9D31; // Door status broadcast by the bus controller static constexpr float CLOSE_POSITION_THRESHOLD = 0.05f; static constexpr float OPEN_POSITION_THRESHOLD = 0.95f; +// Only the parity of the outstanding toggles says where the lamp is heading, so the count must not run away. +static constexpr uint8_t MAX_LIGHT_TOGGLES_IN_FLIGHT = 4; +// Command encoding: the high byte of the first register is the phase (0x02 pressed, 0x01 released) and the +// rest names the button - the low byte for the door commands, the second register for those that do not fit +// there. Both halves repeat that name, so neither register is a level to hold; they carry one event each. static constexpr HoermannHcpCommand COMMAND_OPEN{"open", 0x0210, 0x0110}; static constexpr HoermannHcpCommand COMMAND_CLOSE{"close", 0x0220, 0x0120}; static constexpr HoermannHcpCommand COMMAND_IMPULSE{"impulse", 0x0240, 0x0140}; +// The lamp is named in the second register, but its phase bytes follow no scheme the door commands share. +static constexpr HoermannHcpCommand COMMAND_TOGGLE_LAMP{"toggle light", 0x0100, 0x0800, 0x0200, 0x0200, false}; // High byte of the state register and the door state it stands for. State 0x00 is decoded separately because // its low byte tells a plain stop from the vent position. @@ -58,17 +65,29 @@ void HoermannHcp::update() { // Status broadcasts alone keep the connection alive, so a command the controller never fetches would // otherwise block every later one for as long as it keeps broadcasting. if (this->next_command_ != nullptr && now - this->command_queued_at_ > this->connection_timeout_ms_) { - ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name); - this->next_command_ = nullptr; - this->command_written_at_ = 0; - this->clear_target_(); + // Dropping after the press was presented leaves the door without its release value, which is worth saying + // apart from a command the controller never looked at. + if (this->command_written_at_ != 0) { + ESP_LOGW(TAG, "Bus controller stopped polling during '%s' command, dropping it mid key press", + this->next_command_->name); + } else { + ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name); + } + this->drop_command_(); + // Children may have assumed the command would land, so let them re-derive from the door. + this->changed_ = true; } // A target waits for a door still travelling the other way to turn around. If it never does, the target has // to go as well, otherwise it would cut a later move short. The connection timeout doubles as that window. - if (this->has_target_() && !this->target_started_ && now - this->command_queued_at_ > this->connection_timeout_ms_) { + if (this->has_target_() && !this->target_started_ && now - this->target_queued_at_ > this->connection_timeout_ms_) { ESP_LOGW(TAG, "Door did not start moving towards the requested position, dropping it"); this->clear_target_(); } + // The door took the lamp key press but never reported the lamp changing, so stop expecting it to. + if (this->light_toggle_released_at_ != 0 && now - this->light_toggle_released_at_ > this->connection_timeout_ms_) { + ESP_LOGW(TAG, "Door did not report the lamp changing, giving up on the toggle"); + this->forget_light_toggles_(); + } if (this->changed_) { this->changed_ = false; this->state_callback_.call(); @@ -151,6 +170,16 @@ modbus::ResponseStatus HoermannHcp::on_write_registers(uint16_t start_address, this->on_state_reg_(registers[2]); if (registers.size() > 1) this->on_position_reg_(registers[1]); + if (registers.size() > 6) { + this->on_light_reg_(registers[6]); + return {}; + } + // Nothing refreshes the lamp any more, so what was read before must not be commanded against. + this->set_light_seen_(false); + if (!this->short_broadcast_logged_) { + this->short_broadcast_logged_ = true; + ESP_LOGD(TAG, "Broadcast of %u registers carries no lamp state", static_cast(registers.size())); + } return {}; } @@ -165,11 +194,11 @@ void HoermannHcp::push_command_registers_(modbus::RegisterValues ®isters) { this->command_written_at_ = millis(); ESP_LOGI(TAG, "Sending '%s' command to door", command->name); registers.push_back(command->pressed_value); - registers.push_back(0x0000); + registers.push_back(command->pressed_value_2); return; } if (millis() - this->command_written_at_ <= this->key_press_delay_ms_) { - // Still inside the key-press window, so keep presenting 0x0000. + // Between the two events there is nothing to report, including in the second register. push_zeros(registers, 2); return; } @@ -177,8 +206,12 @@ void HoermannHcp::push_command_registers_(modbus::RegisterValues ®isters) { ESP_LOGD(TAG, "Released '%s' command", command->name); this->command_written_at_ = 0; this->next_command_ = nullptr; + // A toggle whose count was already settled, by a lamp change reported from the door's side, has nothing left + // to wait for, so it must not re-arm the watchdog. + if (command == &COMMAND_TOGGLE_LAMP && this->light_toggles_in_flight_ != 0) + this->light_toggle_released_at_ = millis(); registers.push_back(command->released_value); - registers.push_back(0x0000); + registers.push_back(command->released_value_2); } void HoermannHcp::on_position_reg_(uint16_t value) { @@ -225,6 +258,13 @@ void HoermannHcp::on_state_reg_(uint16_t value) { ESP_LOGW(TAG, "Unknown door state 0x%02X", state); } +// Low byte of register 6: bit 0x10 is the lamp, bit 0x04 the relay. The reference implementation records +// 0x00, 0x04, 0x10 and 0x14, so only the lamp bit decides here. +void HoermannHcp::on_light_reg_(uint16_t value) { + this->set_light_seen_(true); + this->set_light_on_((value & 0x0010) != 0); +} + bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) { if (!this->valid_) { // Queueing now would fire the command whenever the controller comes back, which may be much later. @@ -236,7 +276,8 @@ bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) { return false; } // A new command supersedes any half-open target the door was still travelling to. - this->clear_target_(); + if (command.clears_target) + this->clear_target_(); this->next_command_ = &command; this->command_queued_at_ = millis(); return true; @@ -245,6 +286,31 @@ bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) { bool HoermannHcp::open_door() { return this->queue_command_(COMMAND_OPEN); } bool HoermannHcp::close_door() { return this->queue_command_(COMMAND_CLOSE); } bool HoermannHcp::impulse_door() { return this->queue_command_(COMMAND_IMPULSE); } +bool HoermannHcp::toggle_light() { + if (this->light_toggles_in_flight_ >= MAX_LIGHT_TOGGLES_IN_FLIGHT) { + ESP_LOGW(TAG, "Too many lamp toggles are still waiting to be confirmed, dropping this one"); + return false; + } + if (!this->queue_command_(COMMAND_TOGGLE_LAMP)) + return false; + this->light_toggles_in_flight_++; + return true; +} +bool HoermannHcp::is_light_toggle_pending_() const { return this->next_command_ == &COMMAND_TOGGLE_LAMP; } + +uint8_t HoermannHcp::unsent_light_toggles_() const { + return this->is_light_toggle_pending_() && this->command_written_at_ == 0 ? 1 : 0; +} + +bool HoermannHcp::cancel_light_toggle() { + // Once the pressed value has been presented the key press is already on the wire, so only an untouched + // command can be withdrawn. + if (!this->is_light_toggle_pending_() || this->command_written_at_ != 0) + return false; + ESP_LOGD(TAG, "Cancelling '%s' command the controller had not fetched", this->next_command_->name); + this->drop_command_(); + return true; +} bool HoermannHcp::stop_door() { if (!is_moving(this->door_state_)) { @@ -270,6 +336,7 @@ bool HoermannHcp::set_position(float position) { if (!this->queue_command_(opening ? COMMAND_OPEN : COMMAND_CLOSE)) return false; this->target_position_ = position; + this->target_queued_at_ = millis(); this->target_direction_ = opening ? DoorState::OPENING : DoorState::CLOSING; // A door already travelling that way is on its way; one moving the other way has to turn around first. this->target_started_ = this->door_state_ == this->target_direction_; @@ -292,9 +359,48 @@ void HoermannHcp::set_valid_(bool valid) { } ESP_LOGW(TAG, "Bus controller connection lost (no request for %" PRIu32 "ms)", millis() - this->last_response_); // Drop what the controller never fetched, so it neither blocks later commands nor fires on reconnect. + this->drop_command_(); + // The door cannot be watched while the bus is quiet, so a target left armed would stop it long afterwards. + this->clear_target_(); + this->forget_light_toggles_(); + // The lamp can be switched at the door while the bus is quiet, so what was last read is no longer trusted. + this->set_light_seen_(false); + this->short_broadcast_logged_ = false; +} + +void HoermannHcp::drop_command_() { + const bool was_light_toggle = this->is_light_toggle_pending_(); + // Cleared first so the settling below no longer counts this command among the toggles still to be sent. this->next_command_ = nullptr; this->command_written_at_ = 0; - this->clear_target_(); + if (was_light_toggle) { + // A lamp toggle says nothing about where the door was going, so it leaves the target alone. + this->light_toggle_settled_(); + } else { + this->clear_target_(); + } +} + +void HoermannHcp::light_toggle_settled_() { + if (this->light_toggles_in_flight_ == 0) + return; + this->light_toggles_in_flight_--; + // Only a toggle the door has been shown can still be confirmed, so unsent ones leave nothing to wait for. + if (this->light_toggles_in_flight_ == this->unsent_light_toggles_()) + this->light_toggle_released_at_ = 0; + // The light was showing where the lamp was heading, so it has to be told to look again. + this->changed_ = true; +} + +void HoermannHcp::forget_light_toggles_() { + // Nothing outstanding must always mean nothing to wait for, or the watchdog below would fire for ever. + this->light_toggle_released_at_ = 0; + // A toggle the door has not been shown yet is still going to fire, so it keeps counting. + const uint8_t unsent = this->unsent_light_toggles_(); + if (this->light_toggles_in_flight_ == unsent) + return; + this->light_toggles_in_flight_ = unsent; + this->changed_ = true; } void HoermannHcp::set_door_state_(DoorState state) { @@ -333,4 +439,26 @@ void HoermannHcp::clear_target_() { this->target_started_ = false; } +void HoermannHcp::set_light_on_(bool on) { + if (this->light_on_ == on) + return; + this->light_on_ = on; + this->changed_ = true; + if (this->light_toggles_in_flight_ <= this->unsent_light_toggles_()) { + // The door has not been shown a toggle that could explain this, so the lamp was switched at the door. + ESP_LOGD(TAG, "Lamp %s at the door", ONOFF(on)); + return; + } + // The door acted, so one of the toggles it has seen has arrived. Any others still count. + this->light_toggle_settled_(); +} + +void HoermannHcp::set_light_seen_(bool seen) { + if (this->light_seen_ == seen) + return; + this->light_seen_ = seen; + // A resting door changes nothing else, so without this the light would never hear about it. + this->changed_ = true; +} + } // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.h b/esphome/components/hoermann_hcp/hoermann_hcp.h index 142365f16e..41fd7617e4 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.h +++ b/esphome/components/hoermann_hcp/hoermann_hcp.h @@ -22,11 +22,15 @@ enum class DoorState : uint8_t { }; // A HCP command is a simulated key press: the pressed value is presented to the bus controller, then after a -// short delay the released value. The second command register remains zero. +// short delay the released value. Each half also carries a second register, which only the lamp command uses. struct HoermannHcpCommand { const char *name; uint16_t pressed_value; uint16_t released_value; + uint16_t pressed_value_2{0x0000}; + uint16_t released_value_2{0x0000}; + // A door command supersedes a half-open target; the lamp has no bearing on where the door is going. + bool clears_target{true}; }; class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { @@ -52,19 +56,41 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { bool impulse_door(); bool stop_door(); bool set_position(float position); + bool toggle_light(); DoorState get_door_state() const { return this->door_state_; } float get_current_position() const { return this->current_position_; } bool is_valid() const { return this->valid_; } + bool is_light_on() const { return this->light_on_; } + // False until a broadcast has actually carried the lamp register. Bus traffic alone makes the connection + // valid without saying anything about the lamp, so is_light_on() would still be its default. + bool is_light_known() const { return this->light_seen_; } + // Where the lamp ends up once every toggle on its way has landed, each of which inverts it. Until then the + // lamp still reads as its old self, so this is what a request has to be judged against. + bool is_light_heading_on() const { return this->light_on_ != (this->light_toggles_in_flight_ % 2 != 0); } + // Drops a lamp toggle the controller has not started reading, so a reversing request cancels it outright + // instead of fighting it. Returns false if there is nothing to cancel. + bool cancel_light_toggle(); protected: + // True while a lamp toggle is queued but not yet fetched, so the lamp is about to invert. + bool is_light_toggle_pending_() const; + // Toggles the door has not been shown yet, which is at most the one still waiting in the command slot. + uint8_t unsent_light_toggles_() const; void record_response_(); // Returns false when the bus controller has not fetched the previous command yet. bool queue_command_(const HoermannHcpCommand &command); + // Throws away the pending command, taking any armed target with it unless the command was the lamp toggle. + void drop_command_(); + // One outstanding toggle reached the lamp, was withdrawn, or was thrown away. + void light_toggle_settled_(); + // Stops expecting the toggles the door has already been shown to reach the lamp. + void forget_light_toggles_(); // Appends the two key-press registers and advances the pending command's press/release state. void push_command_registers_(modbus::RegisterValues ®isters); void on_position_reg_(uint16_t value); void on_state_reg_(uint16_t value); + void on_light_reg_(uint16_t value); void set_valid_(bool valid); void set_door_state_(DoorState state); @@ -72,6 +98,8 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { void update_current_position_(); bool has_target_() const { return this->target_position_ != 0.0f; } void clear_target_(); + void set_light_on_(bool on); + void set_light_seen_(bool seen); CallbackManager state_callback_; @@ -82,8 +110,13 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { // Pending command / key-press state machine. const HoermannHcpCommand *next_command_{nullptr}; uint32_t command_queued_at_{0}; + // Separate from command_queued_at_ so an unrelated command cannot extend the target's start deadline. + uint32_t target_queued_at_{0}; uint32_t command_written_at_{0}; uint32_t last_response_{0}; + // When the door was last handed a lamp key press. It reports the lamp a moment later, so this bounds the + // wait. Queueing another toggle deliberately leaves it alone, so the one already sent keeps its deadline. + uint32_t light_toggle_released_at_{0}; // A command is "pressed" for this long before its end value is sent. uint16_t key_press_delay_ms_{100}; @@ -102,9 +135,13 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { DoorState target_direction_{DoorState::STOPPED}; // Position as reported by the bus controller, 0..200 across the full travel. uint8_t position_raw_{0}; + uint8_t light_toggles_in_flight_{0}; bool target_started_{false}; bool valid_{false}; bool changed_{false}; + bool light_on_{false}; + bool light_seen_{false}; + bool short_broadcast_logged_{false}; }; } // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/light/__init__.py b/esphome/components/hoermann_hcp/light/__init__.py new file mode 100644 index 0000000000..e895115db4 --- /dev/null +++ b/esphome/components/hoermann_hcp/light/__init__.py @@ -0,0 +1,24 @@ +import esphome.codegen as cg +from esphome.components import light +import esphome.config_validation as cv +from esphome.types import ConfigType + +from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns + +DEPENDENCIES = ["hoermann_hcp"] + +HoermannHcpLight = hoermann_hcp_ns.class_( + "HoermannHcpLight", light.LightOutput, cg.Component +) + +CONFIG_SCHEMA = ( + light.light_schema(HoermannHcpLight, light.LightType.BINARY) + .extend({cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp)}) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) + var = await light.new_light(config, parent) + await cg.register_component(var, config) diff --git a/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp new file mode 100644 index 0000000000..d3d784928d --- /dev/null +++ b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp @@ -0,0 +1,82 @@ +#include "hoermann_hcp_light.h" + +#include "esphome/core/log.h" + +namespace esphome::hoermann_hcp { + +static const char *const TAG = "hoermann_hcp.light"; + +light::LightTraits HoermannHcpLight::get_traits() { + auto traits = light::LightTraits(); + traits.set_supported_color_modes({light::ColorMode::ON_OFF}); + return traits; +} + +void HoermannHcpLight::setup() { + // Nothing is known about the lamp until the bus controller is heard from, so flag the entity until then. + this->status_set_warning(LOG_STR("waiting for the bus controller")); + this->parent_->add_on_state_callback([this]() { this->update_from_state_(); }); +} + +void HoermannHcpLight::setup_state(light::LightState *state) { this->light_state_ = state; } + +void HoermannHcpLight::write_state(light::LightState *state) { + bool binary; + state->current_values_as_binary(&binary); + // A publish of ours only reaches write_state() a loop pass later, by which time the lamp may have moved on, + // so it is recognised by the value it carried rather than by the current one. + const optional published = this->published_state_; + this->published_state_.reset(); + // LightState::setup() always performs a call, so the very first write here is the restored state coming back + // rather than a request. + const bool restored = !this->boot_replay_done_; + this->boot_replay_done_ = true; + const bool heading_on = this->parent_->is_light_heading_on(); + if (binary == heading_on) + return; + if (restored) { + ESP_LOGD(TAG, "Ignoring the restored state, the door decides what the lamp is doing"); + } else if (published != binary) { + if (!this->parent_->is_light_known()) { + // Commanding a lamp that has not been read could switch off one that is already on. + ESP_LOGW(TAG, "Door has not reported the lamp yet, ignoring the requested state"); + } else if (this->parent_->cancel_light_toggle() || this->parent_->toggle_light()) { + // A toggle the controller has not fetched is withdrawn outright rather than fought with a second one. + return; + } else { + ESP_LOGW(TAG, "Light command was not accepted by the door"); + } + } + // Nothing was sent, so the entity has to go back to showing the lamp rather than the request. + this->publish_lamp_state_(heading_on); +} + +void HoermannHcpLight::update_from_state_() { + if (this->light_state_ == nullptr) + return; + if (!this->parent_->is_valid()) { + this->status_set_warning(LOG_STR("bus controller not responding")); + return; + } + if (!this->parent_->is_light_known()) { + // Commands are refused until the door says, so say so rather than looking healthy and doing nothing. + this->status_set_warning(LOG_STR("door has not reported the lamp")); + return; + } + this->status_clear_warning(); + const bool heading_on = this->parent_->is_light_heading_on(); + if (this->light_state_->remote_values.is_on() != heading_on) + this->publish_lamp_state_(heading_on); +} + +// Re-enters write_state() a loop pass later, where published_state_ marks the write as ours. +void HoermannHcpLight::publish_lamp_state_(bool on) { + this->published_state_ = on; + auto call = this->light_state_->make_call(); + call.set_state(on); + // The bus reports the lamp on every broadcast, so nothing here is worth restoring from flash. + call.set_save(false); + call.perform(); +} + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/light/hoermann_hcp_light.h b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.h new file mode 100644 index 0000000000..82b12cb791 --- /dev/null +++ b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.h @@ -0,0 +1,30 @@ +#pragma once + +#include "esphome/components/light/light_output.h" +#include "esphome/core/component.h" +#include "../hoermann_hcp.h" + +namespace esphome::hoermann_hcp { + +class HoermannHcpLight : public light::LightOutput, public Component { + public: + explicit HoermannHcpLight(HoermannHcp *parent) : parent_(parent) {} + + void setup() override; + void setup_state(light::LightState *state) override; + light::LightTraits get_traits() override; + void write_state(light::LightState *state) override; + + protected: + void update_from_state_(); + void publish_lamp_state_(bool on); + + HoermannHcp *const parent_; + light::LightState *light_state_{nullptr}; + // Value last published and not yet seen come back, so the write carrying it is that publish, not a request. + optional published_state_; + // Set by the first write_state(), which is always the restored state replayed on boot. + bool boot_replay_done_{false}; +}; + +} // namespace esphome::hoermann_hcp diff --git a/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp b/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp index 3cf708c19e..6e9b567080 100644 --- a/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp +++ b/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp @@ -2,29 +2,9 @@ #include "esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h" -namespace esphome::hoermann_hcp { +#include "../common.h" -using modbus::RegisterValues; - -namespace { - -constexpr uint16_t COMMAND_REG = 0x9C41; -constexpr uint16_t BROADCAST_REG = 0x9D31; - -RegisterValues make_registers(std::initializer_list values) { - RegisterValues registers; - for (uint16_t value : values) - registers.push_back(value); - return registers; -} - -// Exposes the connection bookkeeping so a drop can be driven without waiting one out. -class TestableHoermannHcp : public HoermannHcp { - public: - using HoermannHcp::set_valid_; -}; - -} // namespace +namespace esphome::hoermann_hcp::testing { // Nothing has been heard from the bus controller yet, so the sensor starts out seeded as disconnected. TEST(HoermannHcpBinarySensorTest, StartsDisconnected) { @@ -42,7 +22,7 @@ TEST(HoermannHcpBinarySensorTest, FollowsTheConnectionState) { sensor.setup(); ASSERT_FALSE(sensor.state); - door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + connect_controller(door); door.update(); EXPECT_TRUE(sensor.state); @@ -59,7 +39,7 @@ TEST(HoermannHcpBinarySensorTest, UnchangedConnectionIsPublishedOnce) { int publishes = 0; sensor.add_on_state_callback([&publishes](bool /*state*/) { publishes++; }); - door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + connect_controller(door); door.update(); ASSERT_EQ(publishes, 1); @@ -69,4 +49,4 @@ TEST(HoermannHcpBinarySensorTest, UnchangedConnectionIsPublishedOnce) { EXPECT_EQ(publishes, 1); } -} // namespace esphome::hoermann_hcp +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/common.h b/tests/components/hoermann_hcp/common.h new file mode 100644 index 0000000000..a6151697f0 --- /dev/null +++ b/tests/components/hoermann_hcp/common.h @@ -0,0 +1,68 @@ +#pragma once +#include +#include +#include +#include +#include +#include "esphome/components/hoermann_hcp/hoermann_hcp.h" + +namespace esphome::hoermann_hcp::testing { + +using modbus::RegisterValues; + +// Register block addresses the Hoermann bus controller polls (see hoermann_hcp.cpp). +constexpr uint16_t COMMAND_REG = 0x9C41; +constexpr uint16_t STATE_REG = 0x9CB9; +constexpr uint16_t BROADCAST_REG = 0x9D31; + +// The tests shorten the key-press delay to zero, so the release only needs the millis() clock to tick on. +constexpr auto KEY_PRESS_ELAPSED = std::chrono::milliseconds(2); + +inline RegisterValues make_registers(std::initializer_list values) { + RegisterValues registers; + for (uint16_t value : values) + registers.push_back(value); + return registers; +} + +// A status broadcast carrying the lamp register, which the door reports at index 6. +inline RegisterValues lamp_broadcast(uint16_t lamp_reg) { + return make_registers({0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, lamp_reg}); +} + +// The door only accepts commands once the bus controller has actually talked to it. +inline void connect_controller(HoermannHcp &door) { + door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); +} + +// Runs one command poll (write 2 / read 8) and returns both key-press registers. +inline std::pair poll_command(HoermannHcp &door) { + door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + RegisterValues response; + door.on_read_holding_registers(STATE_REG, 8, response); + EXPECT_EQ(response.size(), 8u); + if (response.size() != 8u) + return {0xFFFF, 0xFFFF}; + return {response[2], response[3]}; +} + +// Presents and then releases the queued command, leaving the slot free. +inline void consume_command(HoermannHcp &door) { + poll_command(door); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(door); +} + +// Exposes the internal timings and the connection bookkeeping, so no test has to wait out a real delay. +class TestableHoermannHcp : public HoermannHcp { + public: + TestableHoermannHcp() { this->key_press_delay_ms_ = 0; } + + using HoermannHcp::connection_timeout_ms_; + using HoermannHcp::is_light_toggle_pending_; + using HoermannHcp::light_toggle_released_at_; + using HoermannHcp::light_toggles_in_flight_; + using HoermannHcp::set_valid_; +}; + +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/common.yaml b/tests/components/hoermann_hcp/common.yaml index 84162e8812..552b1cb0fd 100644 --- a/tests/components/hoermann_hcp/common.yaml +++ b/tests/components/hoermann_hcp/common.yaml @@ -11,3 +11,7 @@ binary_sensor: - platform: hoermann_hcp is_connected: name: Garage Connected + +light: + - platform: hoermann_hcp + name: Garage Light diff --git a/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp b/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp index 0ec2ed1ddd..43ca47edb2 100644 --- a/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp +++ b/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp @@ -2,36 +2,9 @@ #include "esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h" -namespace esphome::hoermann_hcp { +#include "../common.h" -using modbus::RegisterValues; - -namespace { - -constexpr uint16_t COMMAND_REG = 0x9C41; -constexpr uint16_t STATE_REG = 0x9CB9; -constexpr uint16_t BROADCAST_REG = 0x9D31; - -RegisterValues make_registers(std::initializer_list values) { - RegisterValues registers; - for (uint16_t value : values) - registers.push_back(value); - return registers; -} - -// The door only accepts commands once the bus controller has actually talked to it. -void connect(HoermannHcp &door) { door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); } - -// Runs one command poll (write 2 / read 8) and returns the register carrying the key-press value. -uint16_t poll_command(HoermannHcp &door) { - door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); - RegisterValues response; - door.on_read_holding_registers(STATE_REG, 8, response); - EXPECT_EQ(response.size(), 8u); - return response.size() == 8u ? response[2] : 0xFFFF; -} - -} // namespace +namespace esphome::hoermann_hcp::testing { // Cover::position starts at COVER_OPEN, so a door that is already closed still has a state to publish. TEST(HoermannHcpCoverTest, ClosedDoorPublishesItsInitialPosition) { @@ -92,10 +65,10 @@ TEST(HoermannHcpCoverTest, OpenCommandOpensTheDoor) { HoermannHcp door; HoermannHcpCover cover(&door); cover.setup(); - connect(door); + connect_controller(door); cover.make_call().set_command_open().perform(); - EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed + EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed } // The same for cover.close, which arrives as a position of 0.0. @@ -103,32 +76,32 @@ TEST(HoermannHcpCoverTest, CloseCommandClosesTheDoor) { HoermannHcp door; HoermannHcpCover cover(&door); cover.setup(); - connect(door); + connect_controller(door); cover.make_call().set_command_close().perform(); - EXPECT_EQ(poll_command(door), 0x0220); // COMMAND_CLOSE pressed + EXPECT_EQ(poll_command(door).first, 0x0220); // COMMAND_CLOSE pressed } TEST(HoermannHcpCoverTest, ToggleCommandSendsAnImpulse) { HoermannHcp door; HoermannHcpCover cover(&door); cover.setup(); - connect(door); + connect_controller(door); cover.make_call().set_command_toggle().perform(); - EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed + EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed } TEST(HoermannHcpCoverTest, StopCommandStopsAMovingDoor) { HoermannHcp door; HoermannHcpCover cover(&door); cover.setup(); - connect(door); + connect_controller(door); // The door is opening, so it takes an impulse to stop it. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100})); cover.make_call().set_command_stop().perform(); - EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed + EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed } // A position between the end stops starts the door in the right direction; it is stopped there later. @@ -136,10 +109,10 @@ TEST(HoermannHcpCoverTest, PositionCommandStartsTheDoorTowardsTheTarget) { HoermannHcp door; // starts out fully closed HoermannHcpCover cover(&door); cover.setup(); - connect(door); + connect_controller(door); cover.make_call().set_position(0.5f).perform(); - EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed + EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed } // A command the door cannot take is assumed to have worked by whoever sent it, so the unchanged state has @@ -153,7 +126,7 @@ TEST(HoermannHcpCoverTest, RefusedCommandPublishesTheUnchangedState) { cover.make_call().set_command_close().perform(); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); EXPECT_EQ(publishes, 1); EXPECT_FLOAT_EQ(cover.position, cover::COVER_OPEN); } @@ -166,9 +139,9 @@ TEST(HoermannHcpCoverTest, MissingBusControllerIsFlaggedUntilFirstContact) { cover.setup(); EXPECT_TRUE(cover.status_has_warning()); - connect(door); + connect_controller(door); door.update(); EXPECT_FALSE(cover.status_has_warning()); } -} // namespace esphome::hoermann_hcp +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/hoermann_hcp_test.cpp b/tests/components/hoermann_hcp/hoermann_hcp_test.cpp index 8463c3f605..1cc5301b4a 100644 --- a/tests/components/hoermann_hcp/hoermann_hcp_test.cpp +++ b/tests/components/hoermann_hcp/hoermann_hcp_test.cpp @@ -3,51 +3,9 @@ #include #include -#include "esphome/components/hoermann_hcp/hoermann_hcp.h" +#include "common.h" -namespace esphome::hoermann_hcp { - -using modbus::RegisterValues; - -namespace { - -// Register block addresses the Hoermann bus controller polls (see hoermann_hcp.cpp). -constexpr uint16_t COMMAND_REG = 0x9C41; -constexpr uint16_t STATE_REG = 0x9CB9; -constexpr uint16_t BROADCAST_REG = 0x9D31; - -// The tests shorten the key-press delay to zero, so the release only needs the millis() clock to tick on. -constexpr auto KEY_PRESS_ELAPSED = std::chrono::milliseconds(2); - -RegisterValues make_registers(std::initializer_list values) { - RegisterValues registers; - for (uint16_t value : values) - registers.push_back(value); - return registers; -} - -// The device only accepts commands once the bus controller has actually talked to it. -void connect(HoermannHcp &door) { door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); } - -// Runs one command poll (write 2 / read 8) and returns the register carrying the key-press value. -uint16_t poll_command(HoermannHcp &door) { - door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); - RegisterValues response; - door.on_read_holding_registers(STATE_REG, 8, response); - EXPECT_EQ(response.size(), 8u); - return response.size() == 8u ? response[2] : 0xFFFF; -} - -// Exposes the internal timings and the connection bookkeeping, so no test has to wait out a real delay. -class TestableHoermannHcp : public HoermannHcp { - public: - TestableHoermannHcp() { this->key_press_delay_ms_ = 0; } - - using HoermannHcp::connection_timeout_ms_; - using HoermannHcp::set_valid_; -}; - -} // namespace +namespace esphome::hoermann_hcp::testing { // An empty poll (write 2 / read 2) answers with the fixed status word 0x0004. TEST(HoermannHcpReadWrite, EmptyPollReturnsStatusWord) { @@ -91,7 +49,7 @@ TEST(HoermannHcpReadWrite, IdleCommandPollHasNoCommand) { // A queued control command is injected into the next command poll as a simulated key press. TEST(HoermannHcpReadWrite, QueuedCommandIsInjectedIntoPoll) { HoermannHcp door; - connect(door); + connect_controller(door); door.open_door(); EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value()); RegisterValues response; @@ -113,31 +71,31 @@ TEST(HoermannHcpReadWrite, UnknownAddressIsRejected) { // A command is held for the key-press duration, then released, and only then can the next one be queued. TEST(HoermannHcpReadWrite, CommandIsReleasedAfterTheKeyPressDelay) { TestableHoermannHcp door; - connect(door); + connect_controller(door); door.open_door(); - EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed + EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed // Refused while one is pending: were it accepted, the release below would carry COMMAND_CLOSE's 0x0120. door.close_door(); std::this_thread::sleep_for(KEY_PRESS_ELAPSED); - EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released + EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN released // With the command gone, the next one is accepted again. door.close_door(); - EXPECT_EQ(poll_command(door), 0x0220); // COMMAND_CLOSE pressed + EXPECT_EQ(poll_command(door).first, 0x0220); // COMMAND_CLOSE pressed } // Commands issued while the bus controller is absent are dropped instead of firing when it returns. TEST(HoermannHcpReadWrite, CommandIsDroppedWhileDisconnected) { HoermannHcp door; door.open_door(); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); } // Losing the controller must drop a command it never fetched, otherwise it blocks every later command // and fires unasked once the bus comes back. TEST(HoermannHcpReadWrite, ConnectionLossDropsThePendingCommand) { TestableHoermannHcp door; - connect(door); + connect_controller(door); door.open_door(); ASSERT_TRUE(door.is_valid()); @@ -145,10 +103,10 @@ TEST(HoermannHcpReadWrite, ConnectionLossDropsThePendingCommand) { EXPECT_FALSE(door.is_valid()); // The reconnecting poll must not replay the dropped command. - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); // And the slot is free, so a new command is accepted. door.close_door(); - EXPECT_EQ(poll_command(door), 0x0220); + EXPECT_EQ(poll_command(door).first, 0x0220); } // The connection is dropped by update() once the controller stops polling, which is what releases a @@ -157,7 +115,7 @@ TEST(HoermannHcpReadWrite, PollingTimeoutDropsTheConnection) { TestableHoermannHcp door; // Wide enough that a stall cannot expire the connection before the check below runs. door.connection_timeout_ms_ = 10000; - connect(door); + connect_controller(door); door.open_door(); // Still inside the window: the controller counts as present. @@ -170,7 +128,7 @@ TEST(HoermannHcpReadWrite, PollingTimeoutDropsTheConnection) { door.update(); EXPECT_FALSE(door.is_valid()); // The pending command went with the connection instead of firing on the reconnecting poll. - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); } // Status broadcasts alone keep the connection alive, so a command the controller never fetches has to @@ -178,7 +136,7 @@ TEST(HoermannHcpReadWrite, PollingTimeoutDropsTheConnection) { TEST(HoermannHcpReadWrite, UnfetchedCommandExpiresWhileConnected) { TestableHoermannHcp door; door.connection_timeout_ms_ = 200; - connect(door); + connect_controller(door); door.open_door(); std::this_thread::sleep_for(std::chrono::milliseconds(220)); @@ -189,7 +147,7 @@ TEST(HoermannHcpReadWrite, UnfetchedCommandExpiresWhileConnected) { // With the stale command gone, the door accepts commands again. door.close_door(); - EXPECT_EQ(poll_command(door), 0x0220); + EXPECT_EQ(poll_command(door).first, 0x0220); } // The 0x17 read half echoes the message counter and command byte written to COMMAND_REG, packed @@ -272,7 +230,7 @@ TEST(HoermannHcpWrite, EndStopsReportExactPositions) { // A position request below the lower snap threshold becomes a plain close command. TEST(HoermannHcpPosition, NearlyClosedTargetClosesTheDoor) { HoermannHcp door; - connect(door); + connect_controller(door); door.set_position(0.02f); RegisterValues response; door.on_read_holding_registers(STATE_REG, 8, response); @@ -283,7 +241,7 @@ TEST(HoermannHcpPosition, NearlyClosedTargetClosesTheDoor) { // A half-open target starts the door moving towards the requested position. TEST(HoermannHcpPosition, HalfOpenTargetOpensTheDoor) { HoermannHcp door; // starts out fully closed - connect(door); + connect_controller(door); door.set_position(0.5f); RegisterValues response; door.on_read_holding_registers(STATE_REG, 8, response); @@ -294,31 +252,31 @@ TEST(HoermannHcpPosition, HalfOpenTargetOpensTheDoor) { // The door has no notion of a target, so it is stopped with an impulse once it travels past the request. TEST(HoermannHcpPosition, TargetPositionStopsTheDoor) { TestableHoermannHcp door; - connect(door); + connect_controller(door); door.set_position(0.5f); - EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed + EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed std::this_thread::sleep_for(KEY_PRESS_ELAPSED); - EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released + EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN released // Position 20/200 = 0.1 while opening: short of the target, so the door keeps going. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100})); ASSERT_EQ(door.get_door_state(), DoorState::OPENING); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); // Position 120/200 = 0.6 is past the target, so the door is stopped. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); - EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed + EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed } // An impulse restarts a stopped door, so a frame reporting the stop and the target crossing at once // must be read as "already stopped" rather than "still opening". TEST(HoermannHcpPosition, StopReportedWithTheCrossingSendsNoImpulse) { TestableHoermannHcp door; - connect(door); + connect_controller(door); door.set_position(0.5f); - EXPECT_EQ(poll_command(door), 0x0210); + EXPECT_EQ(poll_command(door).first, 0x0210); std::this_thread::sleep_for(KEY_PRESS_ELAPSED); - EXPECT_EQ(poll_command(door), 0x0110); + EXPECT_EQ(poll_command(door).first, 0x0110); door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100})); ASSERT_EQ(door.get_door_state(), DoorState::OPENING); @@ -326,17 +284,17 @@ TEST(HoermannHcpPosition, StopReportedWithTheCrossingSendsNoImpulse) { // Same frame: position 0.6 (past the target) and state 0x20 -> the door has reached its open end stop. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x2000})); ASSERT_EQ(door.get_door_state(), DoorState::OPEN); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); } // A target the door never reaches is dropped once it comes to rest, so a later move is not cut short. TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorStopsShort) { TestableHoermannHcp door; - connect(door); + connect_controller(door); door.set_position(0.5f); - EXPECT_EQ(poll_command(door), 0x0210); + EXPECT_EQ(poll_command(door).first, 0x0210); std::this_thread::sleep_for(KEY_PRESS_ELAPSED); - EXPECT_EQ(poll_command(door), 0x0110); + EXPECT_EQ(poll_command(door).first, 0x0110); // The door is stopped at 0.3 by a wall button, short of the requested 0.5. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100})); @@ -346,48 +304,48 @@ TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorStopsShort) { // A later manual open must run freely instead of being stopped at the abandoned target. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100})); door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); } // A target armed while the door is still travelling the other way must not be judged by that old direction, // otherwise the very next position it reports counts as reached and stops the door where it stands. TEST(HoermannHcpPosition, TargetArmedWhileMovingTheOtherWayWaitsForTheTurnaround) { TestableHoermannHcp door; - connect(door); + connect_controller(door); // The door is closing, passing 60/200 = 0.3. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); ASSERT_EQ(door.get_door_state(), DoorState::CLOSING); door.set_position(0.5f); - EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed + EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed std::this_thread::sleep_for(KEY_PRESS_ELAPSED); - EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released + EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN released // Still closing at 58/200 = 0.29: below the target, but not on the way to it. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003A, 0x0200})); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); // Now opening at 62/200 = 0.31, still short of the target. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100})); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); // Past the target at 110/200 = 0.55, so the door is stopped. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100})); - EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed + EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed } // A motor turning around can report a momentary stop; dropping the target there would let the door run on // to the end stop that the reversing command asked for. TEST(HoermannHcpPosition, MomentaryStopWhileTurningAroundKeepsTheTarget) { TestableHoermannHcp door; - connect(door); + connect_controller(door); door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); ASSERT_EQ(door.get_door_state(), DoorState::CLOSING); door.set_position(0.5f); - EXPECT_EQ(poll_command(door), 0x0210); + EXPECT_EQ(poll_command(door).first, 0x0210); std::this_thread::sleep_for(KEY_PRESS_ELAPSED); - EXPECT_EQ(poll_command(door), 0x0110); + EXPECT_EQ(poll_command(door).first, 0x0110); // The stop reported on the way from closing to opening. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0000})); @@ -395,23 +353,23 @@ TEST(HoermannHcpPosition, MomentaryStopWhileTurningAroundKeepsTheTarget) { // The door then opens and still has to be stopped at the requested position. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100})); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100})); - EXPECT_EQ(poll_command(door), 0x0240); + EXPECT_EQ(poll_command(door).first, 0x0240); } // A door that never turns around has to lose the target as well, otherwise it would cut a later move short. TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorNeverTurnsAround) { TestableHoermannHcp door; door.connection_timeout_ms_ = 200; - connect(door); + connect_controller(door); door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); ASSERT_EQ(door.get_door_state(), DoorState::CLOSING); door.set_position(0.5f); - EXPECT_EQ(poll_command(door), 0x0210); + EXPECT_EQ(poll_command(door).first, 0x0210); std::this_thread::sleep_for(KEY_PRESS_ELAPSED); - EXPECT_EQ(poll_command(door), 0x0110); + EXPECT_EQ(poll_command(door).first, 0x0110); std::this_thread::sleep_for(std::chrono::milliseconds(220)); // The door ignored the command and closed all the way. Its broadcast keeps the connection alive, so the @@ -424,7 +382,7 @@ TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorNeverTurnsAround) { // A later manual open must run freely instead of being stopped at the abandoned target. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100})); door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100})); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); } -} // namespace esphome::hoermann_hcp +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp b/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp new file mode 100644 index 0000000000..ed7e81b279 --- /dev/null +++ b/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp @@ -0,0 +1,761 @@ +#include + +#include +#include + +#include "esphome/components/hoermann_hcp/light/hoermann_hcp_light.h" + +#include "../common.h" + +namespace esphome::hoermann_hcp::testing { + +namespace { + +// Counts how often the platform is asked to write, so a publish that re-triggers itself becomes visible. +class CountingHoermannHcpLight : public HoermannHcpLight { + public: + using HoermannHcpLight::HoermannHcpLight; + + void write_state(light::LightState *state) override { + this->writes++; + HoermannHcpLight::write_state(state); + } + + int writes{0}; +}; + +// Drives the platform against a real LightState. ALWAYS_OFF keeps setup() clear of preferences. +struct LightFixture { + TestableHoermannHcp door; + CountingHoermannHcpLight output{&door}; + light::LightState state{&output}; + + explicit LightFixture(light::LightRestoreMode restore_mode = light::LIGHT_ALWAYS_OFF) { + this->state.set_restore_mode(restore_mode); + this->output.setup(); + // setup() queues the restored state for write_state(); the first settle() below delivers it, which is the + // boot ordering tests need to be able to place around the bus controller coming up. + this->state.setup(); + } + + // Brings the bus controller up and lets the platform read the lamp once, which is what a device does before + // any user command can arrive. + void bring_up() { + connect_controller(this->door); + this->report_lamp(false); + } + + // Issues a command the way Home Assistant would, then lets the state machine settle. + void command(bool on) { + auto call = this->state.make_call(); + call.set_state(on); + call.perform(); + this->settle(); + } + + // Delivers a status broadcast and runs the hub's notification pass. + void report_broadcast(const RegisterValues ®isters) { + this->door.on_write_registers(BROADCAST_REG, registers); + this->pump(); + } + + void report_lamp(bool on) { this->report_broadcast(lamp_broadcast(on ? 0x0010 : 0x0000)); } + + // Runs the hub's notification pass and lets the resulting publishes settle. + void pump() { + this->door.update(); + this->settle(); + } + + void settle() { + for (int i = 0; i < 4; i++) + this->state.loop(); + } + + bool entity_on() { return this->state.remote_values.is_on(); } +}; + +} // namespace + +// The lamp state lives in the low byte of register 6; only 0x14 and 0x10 mean lit. +TEST(HoermannHcpLightTest, LampStateIsDecodedFromTheBroadcast) { + HoermannHcp door; + EXPECT_FALSE(door.is_light_on()); + + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0014)); + EXPECT_TRUE(door.is_light_on()); + + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + EXPECT_FALSE(door.is_light_on()); +} + +// The lamp command is the only one that drives the second command register, on both halves of the press. +TEST(HoermannHcpLightTest, LampCommandUsesTheSecondRegister) { + TestableHoermannHcp door; + connect_controller(door); + ASSERT_FALSE(door.is_light_on()); + ASSERT_TRUE(door.toggle_light()); + + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0100); + EXPECT_EQ(pressed_2, 0x0200); + + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + auto [released, released_2] = poll_command(door); + EXPECT_EQ(released, 0x0800); + EXPECT_EQ(released_2, 0x0200); + + // The command is spent, so the next poll carries nothing. + auto [idle, idle_2] = poll_command(door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); +} + +// Toggling the lamp must not disturb a cover position the door is still travelling to. +TEST(HoermannHcpLightTest, LampToggleKeepsTheCoverTarget) { + TestableHoermannHcp door; + connect_controller(door); + // Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100})); + ASSERT_TRUE(door.set_position(0.5f)); + consume_command(door); + + ASSERT_TRUE(door.toggle_light()); + consume_command(door); + + // Past the target: the door still has to be stopped despite the lamp command in between. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0240); // COMMAND_IMPULSE + EXPECT_EQ(pressed_2, 0x0000); +} + +// A lamp toggle occupies the single command slot, so a target stop falling due while it waits to be fetched +// has to wait too. The target stays armed and the stop goes out on the next position report, which costs the +// door a little overshoot but never loses the stop. +TEST(HoermannHcpLightTest, LampToggleDelaysButDoesNotLoseTheTargetStop) { + TestableHoermannHcp door; + connect_controller(door); + // Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100})); + ASSERT_TRUE(door.set_position(0.5f)); + consume_command(door); + + ASSERT_TRUE(door.toggle_light()); + // The door passes the target while the lamp toggle still holds the slot, so the lamp goes out first. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0100); + EXPECT_EQ(pressed_2, 0x0200); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(door); + + // The target survived the refusal, so the next position report still stops the door. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0079, 0x0100})); + auto [stop, stop_2] = poll_command(door); + EXPECT_EQ(stop, 0x0240); // COMMAND_IMPULSE + EXPECT_EQ(stop_2, 0x0000); +} + +// The target's start deadline is its own, so toggling the lamp cannot keep a stale target alive. +TEST(HoermannHcpLightTest, LampToggleDoesNotExtendTheTargetWatchdog) { + TestableHoermannHcp door; + door.connection_timeout_ms_ = 20; + connect_controller(door); + // The door is closing, so an opening target is armed but not yet under way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); + ASSERT_TRUE(door.set_position(0.5f)); + consume_command(door); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + ASSERT_TRUE(door.toggle_light()); + consume_command(door); + door.update(); + + // The target expired on its own schedule, so a later opening move runs freely. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100})); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0000); + EXPECT_EQ(pressed_2, 0x0000); +} + +// Without a bus controller the command cannot be delivered, and the caller is told. +TEST(HoermannHcpLightTest, LampCommandIsRefusedWhileDisconnected) { + HoermannHcp door; + EXPECT_FALSE(door.toggle_light()); +} + +// Switching the entity on sends one toggle, and the door's own report does not send a second. +TEST(HoermannHcpLightPlatformTest, CommandTogglesOnceAndSettles) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + auto [pressed, pressed_2] = poll_command(fixture.door); + EXPECT_EQ(pressed, 0x0100); + EXPECT_EQ(pressed_2, 0x0200); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(fixture.door); // release, clearing the slot + + // The lamp is now on, and the resulting broadcast must not queue another toggle. + fixture.report_lamp(true); + EXPECT_TRUE(fixture.entity_on()); + auto [idle, idle_2] = poll_command(fixture.door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); +} + +// A broadcast arriving while a toggle is queued must not reconcile against the not-yet-inverted lamp, which +// would cancel the user's own command. +TEST(HoermannHcpLightPlatformTest, BroadcastDuringPendingToggleKeepsTheCommand) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + + // A door movement sets changed_, firing the state callback while the toggle is still queued. + fixture.report_broadcast(make_registers({0x0000, 0x0064, 0x0100})); + + EXPECT_TRUE(fixture.door.is_light_toggle_pending_()); + EXPECT_TRUE(fixture.entity_on()); +} + +// A lamp switched on at the door itself has to reach the entity. +TEST(HoermannHcpLightPlatformTest, DoorDrivenChangeReachesTheEntity) { + LightFixture fixture; + fixture.bring_up(); + ASSERT_FALSE(fixture.entity_on()); + + fixture.report_lamp(true); + EXPECT_TRUE(fixture.entity_on()); + + fixture.report_lamp(false); + EXPECT_FALSE(fixture.entity_on()); +} + +// A refused command must leave the entity showing the lamp, not the request. +TEST(HoermannHcpLightPlatformTest, RefusedCommandRepublishesTheLamp) { + LightFixture fixture; // never connected, so the hub refuses every command + + fixture.command(true); + EXPECT_FALSE(fixture.entity_on()); +} + +// A reversing press once the toggle is already on the wire cannot stop it, so the entity has to end up +// showing the lamp rather than the request that was refused. +TEST(HoermannHcpLightPlatformTest, RefusedPressAfterFetchShowsWhereTheLampIsHeading) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + poll_command(fixture.door); // the controller fetches the press, so it can no longer be cancelled + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + + fixture.command(false); + EXPECT_TRUE(fixture.entity_on()); + + // A door movement while the refused toggle is still on the wire must not pull the entity back either. + fixture.report_broadcast(make_registers({0x0000, 0x0064, 0x0100})); + EXPECT_TRUE(fixture.entity_on()); + + // The toggle lands and the door confirms it; the entity must already agree. + fixture.report_lamp(true); + EXPECT_TRUE(fixture.entity_on()); +} + +// The lamp is only reported some time after the key press is released, so an unrelated door broadcast in +// that gap must not publish the state the lamp is about to leave. +TEST(HoermannHcpLightPlatformTest, DoorMovementDoesNotFlipTheEntityBeforeTheLampReports) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + poll_command(fixture.door); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(fixture.door); // release, so nothing is pending any more + ASSERT_FALSE(fixture.door.is_light_toggle_pending_()); + ASSERT_FALSE(fixture.door.is_light_on()); // the lamp has still not been reported + + fixture.report_broadcast(make_registers({0x0000, 0x0064, 0x0100})); + + EXPECT_TRUE(fixture.entity_on()); +} + +// A toggle the controller never fetches is eventually dropped, and nothing else will ever report the lamp +// moving, so the entity has to be brought back to what the lamp actually is. +TEST(HoermannHcpLightPlatformTest, DroppedToggleReturnsTheEntityToTheLamp) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + + fixture.command(true); + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + EXPECT_TRUE(fixture.entity_on()); + + // The controller keeps broadcasting but never fetches the command, so the connection stays up. + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + fixture.pump(); + + EXPECT_FALSE(fixture.door.is_light_toggle_pending_()); + EXPECT_FALSE(fixture.entity_on()); +} + +// Losing the bus controller discards the queued toggle too, so the entity must not keep showing it once the +// controller is back and still reporting the lamp unchanged. +TEST(HoermannHcpLightPlatformTest, ToggleLostWithTheConnectionReturnsTheEntityToTheLamp) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + + fixture.command(true); + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.pump(); // the connection times out and the command goes with it + ASSERT_FALSE(fixture.door.is_valid()); + + connect_controller(fixture.door); + fixture.report_lamp(false); + EXPECT_FALSE(fixture.entity_on()); +} + +// The lamp can be switched at the door while the bus is quiet, so what was read before an outage must not +// decide whether a toggle is needed after it. +TEST(HoermannHcpLightPlatformTest, LampIsNotTrustedAcrossAConnectionLoss) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + fixture.report_lamp(true); + ASSERT_TRUE(fixture.entity_on()); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.pump(); + ASSERT_FALSE(fixture.door.is_valid()); + + // Back on the bus, but nothing has said what the lamp is doing yet. + connect_controller(fixture.door); + fixture.pump(); + ASSERT_TRUE(fixture.door.is_valid()); + ASSERT_FALSE(fixture.door.is_light_known()); + + fixture.command(false); + auto [idle, idle_2] = poll_command(fixture.door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); +} + +// A door that never reports the lamp leaves the entity unable to do anything, so it must not look healthy. +TEST(HoermannHcpLightPlatformTest, UnreportedLampIsFlaggedOnTheEntity) { + LightFixture fixture; + connect_controller(fixture.door); + fixture.pump(); + ASSERT_TRUE(fixture.door.is_valid()); + EXPECT_TRUE(fixture.output.status_has_warning()); + + fixture.report_lamp(false); + EXPECT_FALSE(fixture.output.status_has_warning()); +} + +// Two outstanding toggles leave the lamp where it started, so a third tap has to be judged against that and +// withdraw the one still waiting rather than deciding nothing is needed. +TEST(HoermannHcpLightPlatformTest, ThirdTapWithTwoTogglesOutstandingIsHonoured) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + poll_command(fixture.door); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(fixture.door); // the first toggle is released but not reported back + fixture.command(false); + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + ASSERT_EQ(fixture.door.light_toggles_in_flight_, 2); + + // Two toggles cancel out, so asking for on again means withdrawing the second one. + fixture.command(true); + EXPECT_FALSE(fixture.door.is_light_toggle_pending_()); + EXPECT_EQ(fixture.door.light_toggles_in_flight_, 1); + EXPECT_TRUE(fixture.entity_on()); +} + +// The boot replay is the first write and nothing else, so a real command arriving before the hub's next poll +// must not be mistaken for it and swallowed. +TEST(HoermannHcpLightPlatformTest, CommandBeforeTheFirstPollIsNotMistakenForTheBootReplay) { + LightFixture fixture; + connect_controller(fixture.door); + fixture.settle(); // the boot replay lands here, while the lamp is still unknown + + // The first status broadcast arrives, but the hub has not polled yet, so no callback has fired. + fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(fixture.door.is_light_known()); + + fixture.command(true); + auto [pressed, pressed_2] = poll_command(fixture.door); + EXPECT_EQ(pressed, 0x0100); // COMMAND_TOGGLE_LAMP + EXPECT_EQ(pressed_2, 0x0200); +} + +// On boot the restored state is replayed through write_state() before the lamp has ever been read. A lamp +// that is already on must not be switched off by that replay. +TEST(HoermannHcpLightPlatformTest, RestoredStateOnBootDoesNotCommandTheLamp) { + LightFixture fixture; + // The controller is already up and reporting the lamp lit before the entity's first loop. + connect_controller(fixture.door); + fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); + ASSERT_TRUE(fixture.door.is_light_on()); + + fixture.settle(); + auto [idle, idle_2] = poll_command(fixture.door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); + // Once the platform has read the lamp the entity follows it, still without commanding anything. + fixture.pump(); + EXPECT_TRUE(fixture.entity_on()); +} + +// Bus traffic makes the connection valid without saying anything about the lamp, so a request arriving before +// the first status broadcast must not be judged against a lamp state that was never read. +TEST(HoermannHcpLightPlatformTest, RequestBeforeTheLampIsReportedDoesNotCommandTheLamp) { + LightFixture fixture; + // The controller polls for commands, which is enough to connect but carries no lamp register. + connect_controller(fixture.door); + fixture.pump(); + ASSERT_TRUE(fixture.door.is_valid()); + ASSERT_FALSE(fixture.door.is_light_known()); + + fixture.command(true); + auto [idle, idle_2] = poll_command(fixture.door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); + EXPECT_FALSE(fixture.entity_on()); +} + +// A toggle that has been released onto the wire is no longer pending, but the lamp has not reported it yet. +// A reversing request in that window is a real request and has to be sent, not swallowed. +TEST(HoermannHcpLightPlatformTest, ReversingRequestAfterReleaseQueuesASecondToggle) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + poll_command(fixture.door); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(fixture.door); // released, so nothing is pending and the lamp is still unreported + ASSERT_FALSE(fixture.door.is_light_toggle_pending_()); + ASSERT_FALSE(fixture.door.is_light_on()); + + fixture.command(false); + auto [pressed, pressed_2] = poll_command(fixture.door); + EXPECT_EQ(pressed, 0x0100); // COMMAND_TOGGLE_LAMP + EXPECT_EQ(pressed_2, 0x0200); + EXPECT_FALSE(fixture.entity_on()); + + // The first toggle lands and is reported, but the entity is already heading for off. + fixture.report_lamp(true); + EXPECT_FALSE(fixture.entity_on()); + + // The second toggle lands too, and the lamp finally agrees with the request. + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(fixture.door); + fixture.report_lamp(false); + EXPECT_FALSE(fixture.entity_on()); +} + +// A refusal that has no toggle on the wire leaves nothing outstanding, so it must not latch the entity +// against the next lamp change the door reports. +TEST(HoermannHcpLightPlatformTest, RefusalWithoutAToggleStillFollowsTheLamp) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.pump(); + ASSERT_FALSE(fixture.door.is_valid()); + + // Refused because the bus is down, so no toggle is heading for the lamp. + fixture.command(true); + EXPECT_FALSE(fixture.entity_on()); + + // The controller returns and reports the lamp switched on at the door itself. + connect_controller(fixture.door); + fixture.report_lamp(true); + EXPECT_TRUE(fixture.entity_on()); +} + +// A lamp toggle carries no target, so dropping it unfetched must leave the cover's target alone. +TEST(HoermannHcpLightTest, DroppedLampToggleKeepsTheCoverTarget) { + TestableHoermannHcp door; + door.connection_timeout_ms_ = 20; + connect_controller(door); + // Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100})); + ASSERT_TRUE(door.set_position(0.5f)); + consume_command(door); + + // The controller keeps broadcasting but stops fetching, so the lamp toggle expires on its own. + ASSERT_TRUE(door.toggle_light()); + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100})); + door.update(); + + // The target survived the lamp toggle being dropped, so the door is still stopped on the way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0240); // COMMAND_IMPULSE + EXPECT_EQ(pressed_2, 0x0000); +} + +// A door that takes the key press but never actually switches the lamp must not leave the entity showing the +// request for ever; the wait has to end so the entity can settle back on what the door reports. +TEST(HoermannHcpLightPlatformTest, ToggleTheDoorIgnoresStopsBeingWaitedFor) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + + fixture.command(true); + consume_command(fixture.door); // the door takes press and release, then does nothing + ASSERT_FALSE(fixture.door.is_light_toggle_pending_()); + EXPECT_TRUE(fixture.entity_on()); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.report_lamp(false); // the lamp is still off, and keeps saying so + EXPECT_FALSE(fixture.entity_on()); +} + +// A resting door's first broadcast changes nothing except the lamp finally being reported, so unless that +// counts as a change the light never hears about it and swallows the first command. +TEST(HoermannHcpLightPlatformTest, FirstLampReportReachesTheEntity) { + LightFixture fixture; + // A command poll connects the controller without saying anything about the lamp. + connect_controller(fixture.door); + fixture.pump(); + ASSERT_FALSE(fixture.door.is_light_known()); + + // Closed, at rest, lamp off: every field matches the defaults the hub started with. + fixture.report_broadcast(make_registers({0x0000, 0x0000, 0x4000, 0x0000, 0x0000, 0x0000, 0x0000})); + ASSERT_TRUE(fixture.door.is_light_known()); + + fixture.command(true); + auto [pressed, pressed_2] = poll_command(fixture.door); + EXPECT_EQ(pressed, 0x0100); // COMMAND_TOGGLE_LAMP + EXPECT_EQ(pressed_2, 0x0200); +} + +// A lost connection means the door can travel unwatched, so a target left armed would stop it long afterwards. +// Which command happened to be in the slot must not change that. +TEST(HoermannHcpLightTest, ConnectionLossWithALampTogglePendingClearsTheTarget) { + TestableHoermannHcp door; + door.connection_timeout_ms_ = 20; + connect_controller(door); + // Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100})); + ASSERT_TRUE(door.set_position(0.5f)); + consume_command(door); + ASSERT_TRUE(door.toggle_light()); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + door.update(); + ASSERT_FALSE(door.is_valid()); + + // Back on the bus and travelling past where the target was: nothing should stop the door now. + connect_controller(door); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0000); + EXPECT_EQ(pressed_2, 0x0000); +} + +// Withdrawing a later toggle must not take the deadline of the one already on the wire with it, or a door +// that never reports the lamp would leave the entity waiting for ever. +TEST(HoermannHcpLightPlatformTest, WithdrawingALaterToggleKeepsTheWatchdogArmed) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + + fixture.command(true); + consume_command(fixture.door); // the first toggle is released but never reported back + fixture.command(false); + ASSERT_EQ(fixture.door.light_toggles_in_flight_, 2); + fixture.command(true); // withdraws the second, leaving the first outstanding + ASSERT_EQ(fixture.door.light_toggles_in_flight_, 1); + + // The door still says nothing about the lamp, so the wait has to time out on its own. + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.report_lamp(false); + EXPECT_EQ(fixture.door.light_toggles_in_flight_, 0); + EXPECT_FALSE(fixture.entity_on()); +} + +// A request refused while the lamp is unknown must leave the entity idle. Republishing unconditionally would +// re-enter write_state() on every loop, so the platform would never stop asking to be written. +TEST(HoermannHcpLightPlatformTest, RefusedRequestLeavesTheEntityIdle) { + LightFixture fixture; + connect_controller(fixture.door); + fixture.settle(); + ASSERT_FALSE(fixture.door.is_light_known()); + + // The lamp is unknown and the entity already shows off, so asking for off cannot be serviced or displayed. + fixture.command(false); + const int settled_writes = fixture.output.writes; + fixture.settle(); + EXPECT_EQ(fixture.output.writes, settled_writes); +} + +// A door that acts on the key press and reports the lamp before the release is even fetched leaves nothing +// outstanding. Arming the watchdog on that release anyway would leave it firing on every poll and abandoning +// the next toggle the moment it is queued. +TEST(HoermannHcpLightTest, ReleaseWithNothingOutstandingLeavesTheWatchdogDisarmed) { + TestableHoermannHcp door; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(door.toggle_light()); + poll_command(door); // the door is shown the key press + + // The door acts on it and reports the lamp straight away, which settles the count. + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); + ASSERT_EQ(door.light_toggles_in_flight_, 0); + + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(door); // the release, with nothing left to wait for + EXPECT_EQ(door.light_toggle_released_at_, 0u); +} + +// A restore mode that boots the entity on replays a lit state the door has never confirmed, so it has to be +// adopted back to what is known rather than turned into a command. +TEST(HoermannHcpLightPlatformTest, RestoredOnStateIsAdoptedNotCommanded) { + LightFixture fixture{light::LIGHT_ALWAYS_ON}; + connect_controller(fixture.door); + fixture.settle(); + + auto [idle, idle_2] = poll_command(fixture.door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); + EXPECT_FALSE(fixture.entity_on()); +} + +// A reversing press before the toggle is fetched cancels it, so the lamp never moves. +TEST(HoermannHcpLightPlatformTest, ReversingPressCancelsTheQueuedToggle) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + + fixture.command(false); + EXPECT_FALSE(fixture.door.is_light_toggle_pending_()); + EXPECT_FALSE(fixture.entity_on()); + + // Nothing is left for the controller to fetch, so the lamp stays off as asked. + auto [pressed, pressed_2] = poll_command(fixture.door); + EXPECT_EQ(pressed, 0x0000); + EXPECT_EQ(pressed_2, 0x0000); +} + +// A lamp switched at the door itself is not one of our toggles landing, so a toggle the door has not even +// been shown has to keep counting. +TEST(HoermannHcpLightTest, DoorSideLampChangeLeavesAnUnsentToggleCounted) { + TestableHoermannHcp door; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(door.toggle_light()); + + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); + + EXPECT_EQ(door.light_toggles_in_flight_, 1); + // The toggle still in the slot will invert what the door just reported. + EXPECT_FALSE(door.is_light_heading_on()); +} + +// Once the toggles left over are all still waiting in the slot, nothing the door has seen is outstanding, +// so the wait has to end rather than time out against toggles the door was never shown. +TEST(HoermannHcpLightTest, SettlingTheLastSentToggleEndsTheWait) { + TestableHoermannHcp door; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(door.toggle_light()); + consume_command(door); // shown to the door, so the wait for a lamp report starts + ASSERT_TRUE(door.toggle_light()); // queued behind it, never shown + ASSERT_NE(door.light_toggle_released_at_, 0u); + + // The door reports the lamp change the first toggle caused, leaving only the unsent one. + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); + + ASSERT_EQ(door.light_toggles_in_flight_, 1); + EXPECT_EQ(door.light_toggle_released_at_, 0u); +} + +// The watchdog gives up on the toggles the door was shown, but one still waiting in the command slot is +// going to fire, so it keeps counting. +TEST(HoermannHcpLightTest, WatchdogKeepsAToggleTheDoorHasNotSeen) { + TestableHoermannHcp door; + // Wide enough that the toggle queued after the sleep cannot expire before update() runs. + door.connection_timeout_ms_ = 200; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(door.toggle_light()); + consume_command(door); // shown to the door, which then says nothing about the lamp + + std::this_thread::sleep_for(std::chrono::milliseconds(220)); + // Queued just now, so only the wait for the first toggle is overdue. + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(door.toggle_light()); + door.update(); + + EXPECT_EQ(door.light_toggles_in_flight_, 1); + EXPECT_TRUE(door.is_light_toggle_pending_()); + EXPECT_TRUE(door.is_light_heading_on()); +} + +// Only the parity of the outstanding count says where the lamp is heading, so the count must not run away. +TEST(HoermannHcpLightTest, TogglesAreRefusedOnceTooManyAreOutstanding) { + TestableHoermannHcp door; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + + // The door takes every key press but never reports the lamp, so nothing is ever confirmed. + for (int i = 0; i < 4; i++) { + ASSERT_TRUE(door.toggle_light()); + consume_command(door); + } + + EXPECT_FALSE(door.toggle_light()); + EXPECT_EQ(door.light_toggles_in_flight_, 4); +} + +// A controller that stops carrying the lamp register leaves nothing refreshing it, so the entity has to flag +// itself rather than command against what was read before. +TEST(HoermannHcpLightPlatformTest, BroadcastWithoutTheLampRegisterMarksItUnknown) { + LightFixture fixture; + fixture.bring_up(); + ASSERT_TRUE(fixture.door.is_light_known()); + + fixture.report_broadcast(make_registers({0x0000, 0x0000, 0x4000})); + + EXPECT_FALSE(fixture.door.is_light_known()); + EXPECT_TRUE(fixture.output.status_has_warning()); +} + +// A publish of ours only reaches write_state() a loop pass later. If the lamp changed at the door in that +// gap, the write still carries the old value and must not be taken for a request to invert the lamp. +TEST(HoermannHcpLightPlatformTest, PublishOvertakenByTheLampIsNotARequest) { + LightFixture fixture; + fixture.bring_up(); + // A door command holds the only command slot, so the request below is refused and the lamp published back. + ASSERT_TRUE(fixture.door.open_door()); + + auto call = fixture.state.make_call(); + call.set_state(true); + call.perform(); + fixture.state.loop(); // the refusal happens here and schedules the publish for a later pass + + // The slot frees up and the lamp is switched on at the door before that publish arrives. + consume_command(fixture.door); + fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); + fixture.settle(); + + EXPECT_EQ(fixture.door.light_toggles_in_flight_, 0); + EXPECT_TRUE(fixture.entity_on()); +} + +} // namespace esphome::hoermann_hcp::testing From 58a42fe5c27bdb2158d8c444d544f473fd0e3b0a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 01:44:35 -0500 Subject: [PATCH 1425/1815] [core] Batch remote file downloads during config validation (#18069) --- esphome/components/animation/__init__.py | 5 + esphome/components/animation/image.py | 5 + esphome/components/bme68x_bsec2/__init__.py | 64 +++- .../components/bme68x_bsec2_i2c/__init__.py | 7 +- esphome/components/esp32/__init__.py | 50 ++- esphome/components/file/image.py | 81 ++-- esphome/components/font/__init__.py | 246 +++++++++--- esphome/components/gsl3670/touchscreen.py | 22 +- .../components/micro_wake_word/__init__.py | 16 +- esphome/components/shelly_dimmer/light.py | 108 ++++-- esphome/config.py | 128 ++++++- esphome/external_files.py | 224 +++++++++-- esphome/loader.py | 18 +- tests/component_tests/gsl3670/test_init.py | 22 +- .../components/bme68x_bsec2/__init__.py | 0 .../components/bme68x_bsec2/test_init.py | 64 ++++ tests/unit_tests/components/file/__init__.py | 0 .../unit_tests/components/file/test_image.py | 75 ++++ tests/unit_tests/components/font/__init__.py | 0 tests/unit_tests/components/font/test_init.py | 229 +++++++++++ .../unit_tests/components/gsl3670/__init__.py | 0 .../components/gsl3670/test_touchscreen.py | 35 ++ .../components/micro_wake_word/test_init.py | 9 +- .../components/shelly_dimmer/__init__.py | 0 .../components/shelly_dimmer/test_light.py | 154 ++++++++ tests/unit_tests/test_config_prefetch.py | 355 ++++++++++++++++++ tests/unit_tests/test_external_files.py | 341 +++++++++++++++-- 27 files changed, 2005 insertions(+), 253 deletions(-) create mode 100644 tests/unit_tests/components/bme68x_bsec2/__init__.py create mode 100644 tests/unit_tests/components/bme68x_bsec2/test_init.py create mode 100644 tests/unit_tests/components/file/__init__.py create mode 100644 tests/unit_tests/components/file/test_image.py create mode 100644 tests/unit_tests/components/font/__init__.py create mode 100644 tests/unit_tests/components/font/test_init.py create mode 100644 tests/unit_tests/components/gsl3670/__init__.py create mode 100644 tests/unit_tests/components/gsl3670/test_touchscreen.py create mode 100644 tests/unit_tests/components/shelly_dimmer/__init__.py create mode 100644 tests/unit_tests/components/shelly_dimmer/test_light.py create mode 100644 tests/unit_tests/test_config_prefetch.py diff --git a/esphome/components/animation/__init__.py b/esphome/components/animation/__init__.py index 0df7c56313..6da5268432 100644 --- a/esphome/components/animation/__init__.py +++ b/esphome/components/animation/__init__.py @@ -13,8 +13,13 @@ import esphome.components.image as espImage import esphome.config_validation as cv +from . import image as animation_image from .image import ANIMATION_CONFIG_SCHEMA, setup_animation +# The deprecated top-level `animation:` shim gets the same batched +# downloads as the `image:` platform form. +PREFETCH_FILES = animation_image.PREFETCH_FILES + AUTO_LOAD = ["image", "file"] CODEOWNERS = ["@syndlex"] DEPENDENCIES = ["display"] diff --git a/esphome/components/animation/image.py b/esphome/components/animation/image.py index 95875fe2b0..73d428bd20 100644 --- a/esphome/components/animation/image.py +++ b/esphome/components/animation/image.py @@ -1,6 +1,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components.const import CONF_LOOP +from esphome.components.file import image as file_image from esphome.components.file.image import image_schema, write_image from esphome.components.image import Image_, validate_settings import esphome.config_validation as cv @@ -8,6 +9,10 @@ from esphome.const import CONF_ID, CONF_REPEAT from esphome.types import ConfigType CODEOWNERS = ["@syndlex"] + +# The animation platform shares the file platform's remote file handling, +# including its batch-download hook. +PREFETCH_FILES = file_image.PREFETCH_FILES AUTO_LOAD = ["file"] DEPENDENCIES = ["display"] diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index 63f63c5da2..c12eb39d2d 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -1,4 +1,3 @@ -import hashlib from pathlib import Path from esphome import core, external_files @@ -12,6 +11,8 @@ from esphome.const import ( CONF_SAMPLE_RATE, CONF_TEMPERATURE_OFFSET, ) +from esphome.external_files import RemoteFile +from esphome.types import ConfigType CODEOWNERS = ["@neffs", "@kbx81"] CONFLICTS_WITH = ["bme680_bsec"] @@ -74,11 +75,7 @@ VOLTAGE_FILE_NAME = { def _compute_local_file_path(url: str) -> Path: - h = hashlib.new("sha256") - h.update(url.encode()) - key = h.hexdigest()[:8] - base_dir = external_files.compute_local_file_dir(DOMAIN) - return base_dir / key + return external_files.compute_local_file_path(DOMAIN, url) def _compute_url(config: dict) -> str: @@ -105,6 +102,42 @@ def download_bme68x_blob(config): return config +# Shared by the schema and the prefetch hook so they cannot drift. +_MODEL_VALIDATOR = cv.one_of(*MODEL_OPTIONS, lower=True) +_ALGORITHM_OUTPUT_VALIDATOR = cv.enum(ALGORITHM_OUTPUT_OPTIONS, lower=True) +# Key -> (validator, default) for the defaulted options that select the blob. +_BLOB_OPTIONS = { + CONF_OPERATING_AGE: (cv.enum(OPERATING_AGE_OPTIONS, lower=True), "28d"), + CONF_SAMPLE_RATE: (cv.enum(SAMPLE_RATE_OPTIONS, upper=True), "LP"), + CONF_SUPPLY_VOLTAGE: (cv.enum(VOLTAGE_OPTIONS, upper=True), "3.3V"), +} + + +def _extract_blob_ref(entry: ConfigType) -> RemoteFile | None: + """Raw entry to its BSEC2 blob; None when a value is unrecognized. + + Applies the schema defaults and validators read-only; skipped entries + are left to the schema validator. + """ + try: + spec = { + key: validator(str(entry.get(key, default))) # pylint: disable=not-callable + for key, (validator, default) in _BLOB_OPTIONS.items() + } + spec[CONF_MODEL] = _MODEL_VALIDATOR(str(entry.get(CONF_MODEL, ""))) + if (algorithm_output := entry.get(CONF_ALGORITHM_OUTPUT)) is not None: + spec[CONF_ALGORITHM_OUTPUT] = _ALGORITHM_OUTPUT_VALIDATOR( + str(algorithm_output) + ) + except cv.Invalid: + return None + url = _compute_url(spec) + return RemoteFile(url, _compute_local_file_path(url)) + + +PREFETCH_FILES = external_files.single_stage_prefetch(_extract_blob_ref) + + def validate_bme68x(config): if CONF_ALGORITHM_OUTPUT not in config: return config @@ -128,19 +161,12 @@ CONFIG_SCHEMA_BASE = ( { cv.GenerateID(): cv.declare_id(BME68xBSEC2Component), cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), - cv.Required(CONF_MODEL): cv.one_of(*MODEL_OPTIONS, lower=True), - cv.Optional(CONF_ALGORITHM_OUTPUT): cv.enum( - ALGORITHM_OUTPUT_OPTIONS, lower=True - ), - cv.Optional(CONF_OPERATING_AGE, default="28d"): cv.enum( - OPERATING_AGE_OPTIONS, lower=True - ), - cv.Optional(CONF_SAMPLE_RATE, default="LP"): cv.enum( - SAMPLE_RATE_OPTIONS, upper=True - ), - cv.Optional(CONF_SUPPLY_VOLTAGE, default="3.3V"): cv.enum( - VOLTAGE_OPTIONS, upper=True - ), + cv.Required(CONF_MODEL): _MODEL_VALIDATOR, + cv.Optional(CONF_ALGORITHM_OUTPUT): _ALGORITHM_OUTPUT_VALIDATOR, + **{ + cv.Optional(key, default=default): validator + for key, (validator, default) in _BLOB_OPTIONS.items() + }, cv.Optional(CONF_TEMPERATURE_OFFSET, default=0): cv.temperature_delta, cv.Optional( CONF_STATE_SAVE_INTERVAL, default="6hours" diff --git a/esphome/components/bme68x_bsec2_i2c/__init__.py b/esphome/components/bme68x_bsec2_i2c/__init__.py index c8ca0ba022..dacd4e32ad 100644 --- a/esphome/components/bme68x_bsec2_i2c/__init__.py +++ b/esphome/components/bme68x_bsec2_i2c/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import bme68x_bsec2, i2c from esphome.components.bme68x_bsec2 import ( CONFIG_SCHEMA_BASE, BME68xBSEC2Component, @@ -13,6 +13,11 @@ AUTO_LOAD = ["bme68x_bsec2"] DEPENDENCIES = ["i2c"] MULTI_CONF = True +# The user-facing domain is this module (the base component only appears +# via AUTO_LOAD), so the batch-download hook must be re-exported here to +# take effect. +PREFETCH_FILES = bme68x_bsec2.PREFETCH_FILES + bme68x_bsec2_i2c_ns = cg.esphome_ns.namespace("bme68x_bsec2_i2c") BME68xBSEC2I2CComponent = bme68x_bsec2_i2c_ns.class_( "BME68xBSEC2I2CComponent", BME68xBSEC2Component, i2c.I2CDevice diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 2e72c78974..ada6d25db5 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -3280,27 +3280,45 @@ def copy_files(): __version__, ) + # Remote extra build files are fetched into the shared download cache in + # one parallel batch (conditional requests skip unchanged files), then + # copied into the build tree like their local counterparts. + sources: dict[str, Path] = {} + remote: list[tuple[str, str]] = [] for file in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES].values(): name: str = file[KEY_NAME] path: Path = file[KEY_PATH] if str(path).startswith("http"): - import requests - - from esphome.happy_eyeballs import ensure_happy_eyeballs - - ensure_happy_eyeballs() - - try: - req = requests.get(path, timeout=30) - req.raise_for_status() - except requests.exceptions.RequestException as e: - raise EsphomeError( - f"Could not download extra build file {path}: {e}" - ) from e - CORE.relative_build_path(name).parent.mkdir(parents=True, exist_ok=True) - CORE.relative_build_path(name).write_bytes(req.content) + remote.append((name, str(path))) else: - copy_file_if_changed(path, CORE.relative_build_path(name)) + sources[name] = path + if remote: + # Imported lazily: requests (via external_files) is a heavy import + # and remote extra build files are rare. + from esphome import external_files + + downloads: list[external_files.RemoteFile] = [] + for name, url in remote: + cache_path = external_files.compute_local_file_path(KEY_ESP32, url) + # Unverifiable bytes: an unrevalidated copy is an error, matching + # the old always-download behavior on network failure. + downloads.append( + external_files.RemoteFile(url, cache_path, allow_stale=False) + ) + sources[name] = cache_path + try: + external_files.download_content_many( + downloads, description="extra build file(s)" + ) + except cv.MultipleInvalid as e: + details = "; ".join(str(err) for err in e.errors) + raise EsphomeError( + f"Could not download extra build file(s): {details}" + ) from e + except cv.Invalid as e: + raise EsphomeError(f"Could not download extra build file(s): {e}") from e + for name, source in sources.items(): + copy_file_if_changed(source, CORE.relative_build_path(name)) def _decode_pc(config, addr): diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index 9a7c762a79..b54c3f2adf 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -1,7 +1,6 @@ from __future__ import annotations import contextlib -import hashlib import io import logging from pathlib import Path @@ -43,15 +42,13 @@ from esphome.const import ( ) from esphome.core import CORE, HexInt from esphome.cpp_generator import MockObj, MockObjClass +from esphome.external_files import RemoteFile from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] _LOGGER = logging.getLogger(__name__) -# If the MDI file cannot be downloaded within this time, abort. -IMAGE_DOWNLOAD_TIMEOUT = 30 # seconds - SOURCE_LOCAL = "local" SOURCE_WEB = "web" @@ -65,16 +62,16 @@ MDI_SOURCES = { SOURCE_MEMORY: "https://raw.githubusercontent.com/Pictogrammers/Memory/refs/heads/main/src/svg/", } +# Shared by the schema validator and the prefetch extractor so they cannot +# drift. +_MDI_ICON_RE = re.compile(r"^[a-zA-Z0-9\-]+$") -def compute_local_image_path(value) -> Path: + +def compute_local_image_path(value: str | ConfigType) -> Path: url = value[CONF_URL] if isinstance(value, dict) else value - h = hashlib.new("sha256") - h.update(url.encode()) - key = h.hexdigest()[:8] # Downloaded files are cached under the shared `image` domain directory so # the cache location is unaffected by which platform requested the file. - base_dir = external_files.compute_local_file_dir(DOMAIN) - return base_dir / key + return external_files.compute_local_file_path(DOMAIN, url) def local_path(value): @@ -83,16 +80,20 @@ def local_path(value): def download_file(url, path): - external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT) + # The shared NETWORK_TIMEOUT applies; a per-caller timeout would be + # silently ignored on a per-run memo hit anyway (memos key by path). + external_files.download_content(url, path) return str(path) -def download_gh_svg(value, source): - mdi_id = value[CONF_ICON] if isinstance(value, dict) else value +def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]: base_dir = external_files.compute_local_file_dir(DOMAIN) / source - path = base_dir / f"{mdi_id}.svg" + return MDI_SOURCES[source] + mdi_id + ".svg", base_dir / f"{mdi_id}.svg" - url = MDI_SOURCES[source] + mdi_id + ".svg" + +def download_gh_svg(value: str | ConfigType, source: str) -> str: + mdi_id = value[CONF_ICON] if isinstance(value, dict) else value + url, path = _gh_svg_url_path(mdi_id, source) return download_file(url, path) @@ -101,17 +102,53 @@ def download_image(value): return download_file(value, compute_local_image_path(value)) -def validate_file_shorthand(value): - value = cv.string_strict(value) +def _parse_remote_shorthand(value: str) -> RemoteFile | None: + """Parse a string `file:` shorthand to its remote file; None if local. + + Raises cv.Invalid for a malformed icon name. Shared by the schema + validator and the prefetch extractor so they cannot drift. + """ parts = value.strip().split(":") if len(parts) == 2 and parts[0] in MDI_SOURCES: - match = re.match(r"^[a-zA-Z0-9\-]+$", parts[1]) - if match is None: + if _MDI_ICON_RE.match(parts[1]) is None: raise cv.Invalid(f"Could not parse mdi icon name from '{value}'.") - return download_gh_svg(parts[1], parts[0]) - + return RemoteFile(*_gh_svg_url_path(parts[1], parts[0])) if value.startswith(("http://", "https://")): - return download_image(value) + return RemoteFile(value, compute_local_image_path(value)) + return None + + +def _extract_file_ref(value: object) -> RemoteFile | None: + """Map a raw, pre-schema `file:` value to its remote file. + + Returns None for local files and anything it does not recognize; the + schema validators stay authoritative. + """ + if isinstance(value, str): + try: + return _parse_remote_shorthand(value) + except cv.Invalid: + return None + if isinstance(value, dict): + source = value.get(CONF_SOURCE) + if source == SOURCE_WEB and isinstance(url := value.get(CONF_URL), str): + return RemoteFile(url, compute_local_image_path(url)) + if source in MDI_SOURCES and isinstance(icon := value.get(CONF_ICON), str): + return RemoteFile(*_gh_svg_url_path(icon, source)) + return None + + +def _extract_entry_ref(entry: ConfigType) -> RemoteFile | None: + return _extract_file_ref(entry.get(CONF_FILE)) + + +PREFETCH_FILES = external_files.single_stage_prefetch(_extract_entry_ref) + + +def validate_file_shorthand(value): + value = cv.string_strict(value) + if (remote := _parse_remote_shorthand(value)) is not None: + return download_file(remote.url, remote.path) value = cv.file_(value) return local_path(value) diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index 5872b607f1..918fde5dbd 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -1,6 +1,5 @@ -from collections.abc import MutableMapping +from collections.abc import Iterable, MutableMapping import functools -import hashlib from itertools import accumulate import logging from pathlib import Path @@ -17,7 +16,6 @@ from freetype import ( FT_Exception, ft_pixel_mode_mono, ) -import requests from esphome import external_files import esphome.codegen as cg @@ -36,7 +34,7 @@ from esphome.const import ( CONF_WEIGHT, ) from esphome.core import CORE, HexInt -from esphome.happy_eyeballs import ensure_happy_eyeballs +from esphome.external_files import RemoteFile from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -296,46 +294,80 @@ def validate_weight_name(value): return FONT_WEIGHTS[cv.one_of(*FONT_WEIGHTS, lower=True, space="-")(value)] -def _compute_local_font_path(value: dict) -> Path: - url = value[CONF_URL] - h = hashlib.new("sha256") - h.update(url.encode()) - key = h.hexdigest()[:8] - base_dir = external_files.compute_local_file_dir(DOMAIN) - _LOGGER.debug("_compute_local_font_path: %s", base_dir / key) - return base_dir / key +def _web_font_path(value: dict) -> Path: + return external_files.compute_local_file_path(DOMAIN, value[CONF_URL]) / "font.ttf" -def download_gfont(value): +def _gfonts_css_url(value: dict) -> str: + return ( + f"https://fonts.googleapis.com/css2?family={value[CONF_FAMILY]}" + f":ital,wght@{int(value[CONF_ITALIC])},{value[CONF_WEIGHT]}" + ) + + +def _gfonts_cache_path(value: dict, suffix: str) -> Path: + name = f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1" + return external_files.compute_local_file_dir(DOMAIN) / f"{name}.{suffix}" + + +def _gfonts_ttf_path(value: dict) -> Path: + return _gfonts_cache_path(value, "ttf") + + +def _gfonts_css_path(value: dict) -> Path: + return _gfonts_cache_path(value, "css") + + +def _parse_gfonts_css(css: str) -> str | None: + """Extract the truetype URL from a Google Fonts CSS response.""" + match = re.search(r"src:\s+url\((.+)\)\s+format\('truetype'\);", css) + return match.group(1) if match else None + + +def download_gfont(value: ConfigType) -> ConfigType: if value in FONT_CACHE: return value - name = ( - f"{value[CONF_FAMILY]}:ital,wght@{int(value[CONF_ITALIC])},{value[CONF_WEIGHT]}" - ) - url = f"https://fonts.googleapis.com/css2?family={name}" - path = ( - external_files.compute_local_file_dir(DOMAIN) - / f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1.ttf" - ) + path = _gfonts_ttf_path(value) if not external_files.is_file_recent(path, value[CONF_REFRESH]): _LOGGER.debug("download_gfont: path=%s", path) + url = _gfonts_css_url(value) + css_path = _gfonts_css_path(value) try: - ensure_happy_eyeballs() - req = requests.get(url, timeout=external_files.NETWORK_TIMEOUT) - req.raise_for_status() - except requests.exceptions.RequestException as e: + css_bytes = external_files.download_content(url, css_path) + except cv.Invalid as e: raise cv.Invalid( f"Could not download font at {url}, please check the fonts exists " f"at google fonts ({e})" ) from e - match = re.search(r"src:\s+url\((.+)\)\s+format\('truetype'\);", req.text) - if match is None: + if not ( + external_files.is_fresh_this_run(css_path) or CORE.skip_external_update + ): + # Same rule as PREFETCH_FILES stage two: a CSS body that could + # not be revalidated may name a rotated ttf URL. Use the cached + # font instead (the failed check already warned). + if path.exists(): + FONT_CACHE[value] = path + return value raise cv.Invalid( - f"Could not extract ttf file from gfonts response for {name}, " - f"please report this." + f"Could not refresh the Google Fonts CSS for " + f"{value[CONF_FAMILY]} and no cached font is available" + ) + try: + css = css_bytes.decode("utf-8") + except UnicodeDecodeError as e: + # Do not leave an unusable body in the cache to be served again. + css_path.unlink(missing_ok=True) + raise cv.Invalid( + f"Bad response from Google Fonts for {value[CONF_FAMILY]}: " + f"not a text document" + ) from e + ttf_url = _parse_gfonts_css(css) + if ttf_url is None: + css_path.unlink(missing_ok=True) + raise cv.Invalid( + f"Could not extract ttf file from gfonts response for " + f"{value[CONF_FAMILY]}, please report this." ) - - ttf_url = match.group(1) _LOGGER.debug("download_gfont: ttf_url=%s", ttf_url) external_files.download_content(ttf_url, path) @@ -346,11 +378,11 @@ def download_gfont(value): return value -def download_web_font(value): +def download_web_font(value: ConfigType) -> ConfigType: if value in FONT_CACHE: return value url = value[CONF_URL] - path = _compute_local_font_path(value) / "font.ttf" + path = _web_font_path(value) external_files.download_content(url, path) _LOGGER.debug("download_web_font: path=%s", path) @@ -358,13 +390,18 @@ def download_web_font(value): return value +# Shared by the schema and the prefetch extractor so they cannot drift. +_DEFAULT_WEIGHT = "regular" +_DEFAULT_ITALIC = False +_DEFAULT_REFRESH = "1d" +_WEIGHT_VALIDATOR = cv.Any(cv.int_, validate_weight_name) +_REFRESH_VALIDATOR = cv.All(cv.string, cv.source_refresh) + EXTERNAL_FONT_SCHEMA = cv.Schema( { - cv.Optional(CONF_WEIGHT, default="regular"): cv.Any( - cv.int_, validate_weight_name - ), - cv.Optional(CONF_ITALIC, default=False): cv.boolean, - cv.Optional(CONF_REFRESH, default="1d"): cv.All(cv.string, cv.source_refresh), + cv.Optional(CONF_WEIGHT, default=_DEFAULT_WEIGHT): _WEIGHT_VALIDATOR, + cv.Optional(CONF_ITALIC, default=_DEFAULT_ITALIC): cv.boolean, + cv.Optional(CONF_REFRESH, default=_DEFAULT_REFRESH): _REFRESH_VALIDATOR, } ) @@ -387,36 +424,123 @@ WEB_FONT_SCHEMA = cv.All( ) -def validate_file_shorthand(value): - value = cv.string_strict(value) +_GFONTS_SHORTHAND_RE = re.compile(r"^gfonts://([^@]+)(@.+)?$") + + +def _shorthand_to_file_dict(value: str) -> ConfigType | None: + """Typed-dict form of a remote font shorthand. + + Shared by the schema validator and the prefetch extractor so the two + cannot drift. Returns None for values that are not remote shorthand + (i.e. local paths); raises cv.Invalid for a malformed gfonts shorthand. + """ if value.startswith("gfonts://"): - match = re.match(r"^gfonts://([^@]+)(@.+)?$", value) - if match is None: + if (match := _GFONTS_SHORTHAND_RE.match(value)) is None: raise cv.Invalid("Could not parse gfonts shorthand syntax, please check it") - family = match.group(1) - weight = match.group(2) - data = { + data = {CONF_TYPE: TYPE_GFONTS, CONF_FAMILY: match.group(1)} + if match.group(2): + data[CONF_WEIGHT] = match.group(2)[1:] + return data + if value.startswith(("http://", "https://")): + return {CONF_TYPE: TYPE_WEB, CONF_URL: value} + return None + + +def _extract_remote_font(value: object) -> ConfigType | None: + """Map a raw, pre-schema font `file:` value to a normalized remote spec. + + Read-only mirror of `validate_file_shorthand` / `TYPED_FILE_SCHEMA` for + the prefetch hooks; returns None for local fonts and anything it does + not recognize. A wrong answer only wastes or misses a prefetch, the + schema validators stay authoritative. + """ + if isinstance(value, str): + try: + value = _shorthand_to_file_dict(value) + except cv.Invalid: + return None + if not isinstance(value, dict): + return None + font_type = value.get(CONF_TYPE) + if font_type == TYPE_WEB and isinstance(url := value.get(CONF_URL), str): + return {CONF_TYPE: TYPE_WEB, CONF_URL: url} + if font_type == TYPE_GFONTS and isinstance(family := value.get(CONF_FAMILY), str): + try: + italic = cv.boolean(value.get(CONF_ITALIC, _DEFAULT_ITALIC)) + weight = _WEIGHT_VALIDATOR(value.get(CONF_WEIGHT, _DEFAULT_WEIGHT)) + refresh = _REFRESH_VALIDATOR(value.get(CONF_REFRESH, _DEFAULT_REFRESH)) + except cv.Invalid: + return None + return { CONF_TYPE: TYPE_GFONTS, CONF_FAMILY: family, + CONF_WEIGHT: weight, + CONF_ITALIC: italic, + CONF_REFRESH: refresh, } - if weight is not None: - data[CONF_WEIGHT] = weight[1:] - return font_file_schema(data) + return None - if value.startswith(("http://", "https://")): - return font_file_schema( - { - CONF_TYPE: TYPE_WEB, - CONF_URL: value, - } - ) - return font_file_schema( - { - CONF_TYPE: TYPE_LOCAL, - CONF_PATH: value, - } - ) +def _iter_remote_specs(entries: list[ConfigType]) -> Iterable[ConfigType]: + """Yield the remote spec of every `file:` value, including extras.""" + for entry in entries: + values = [entry.get(CONF_FILE)] + extras = entry.get(CONF_EXTRAS) + if isinstance(extras, dict): + # The schema runs cv.ensure_list on extras, so a bare mapping + # is valid raw config; mirror that normalization here. + extras = [extras] + if isinstance(extras, list): + values.extend( + extra.get(CONF_FILE) for extra in extras if isinstance(extra, dict) + ) + for value in values: + if (spec := _extract_remote_font(value)) is not None: + yield spec + + +def PREFETCH_FILES(entries: list[ConfigType]) -> Iterable[list[RemoteFile]]: + """Batch-download hook: web fonts, then Google Fonts CSS, then ttf. + + Stage one fetches web fonts and the CSS of stale gfonts; stage two + parses the now-cached CSS for the ttf URLs it names. + """ + stage1: list[RemoteFile] = [] + # Keyed by cache path: the same font at several sizes is one download, + # one freshness stat, and one stage-two CSS parse. + stale_gfonts: dict[Path, ConfigType] = {} + seen_web: set[Path] = set() + for spec in _iter_remote_specs(entries): + if spec[CONF_TYPE] == TYPE_WEB: + if (path := _web_font_path(spec)) not in seen_web: + seen_web.add(path) + stage1.append(RemoteFile(spec[CONF_URL], path)) + elif (css_path := _gfonts_css_path(spec)) not in stale_gfonts and ( + not external_files.is_file_recent( + _gfonts_ttf_path(spec), spec[CONF_REFRESH] + ) + ): + stale_gfonts[css_path] = spec + stage1.append(RemoteFile(_gfonts_css_url(spec), css_path)) + yield stage1 + + yield [ + RemoteFile(ttf_url, _gfonts_ttf_path(spec)) + for css_path, spec in stale_gfonts.items() + # Only trust CSS that stage one actually refreshed this run; a + # leftover from an earlier run may name a rotated ttf URL. + if external_files.is_fresh_this_run(css_path) + and css_path.exists() + and (ttf_url := _parse_gfonts_css(css_path.read_text("utf-8", "replace"))) + is not None + ] + + +def validate_file_shorthand(value: object) -> ConfigType: + value = cv.string_strict(value) + if (data := _shorthand_to_file_dict(value)) is None: + data = {CONF_TYPE: TYPE_LOCAL, CONF_PATH: value} + return font_file_schema(data) TYPED_FILE_SCHEMA = cv.typed_schema( diff --git a/esphome/components/gsl3670/touchscreen.py b/esphome/components/gsl3670/touchscreen.py index fc0318f076..ccccf06d69 100644 --- a/esphome/components/gsl3670/touchscreen.py +++ b/esphome/components/gsl3670/touchscreen.py @@ -29,6 +29,8 @@ from esphome.const import ( CONF_URL, ) from esphome.core import ID +from esphome.external_files import RemoteFile +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] AUTO_LOAD = ["touchscreen"] @@ -103,8 +105,7 @@ def _validate_firmware_data(data: bytes, source: str) -> None: def _cache_path(url: str) -> Path: """Cache path for a downloaded firmware blob, keyed by URL.""" - key = hashlib.sha256(url.encode()).hexdigest()[:8] - return external_files.compute_local_file_dir(DOMAIN) / key + return external_files.compute_local_file_path(DOMAIN, url) def firmware_path(firmware: dict) -> Path: @@ -156,6 +157,23 @@ FIRMWARE_SCHEMA = cv.All( ) +def _extract_firmware_ref(entry: ConfigType) -> RemoteFile | None: + firmware = entry.get(CONF_FIRMWARE) + if firmware is None: + model = str(entry.get(CONF_MODEL, "CUSTOM")).upper() + firmware = MODELS.get(model, {}).get(CONF_FIRMWARE) + if ( + isinstance(firmware, dict) + and CONF_FILE not in firmware + and isinstance(url := firmware.get(CONF_URL), str) + ): + return RemoteFile(url, _cache_path(url)) + return None + + +PREFETCH_FILES = external_files.single_stage_prefetch(_extract_firmware_ref) + + def _config_schema(config): model_option = { cv.Optional(CONF_MODEL, default="CUSTOM"): cv.one_of(*MODELS, upper=True) diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index 255923f878..092c4977ce 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -166,12 +166,7 @@ MANIFEST_SCHEMA_V2 = cv.Schema( def _compute_local_file_path(config: dict) -> Path: - url = config[CONF_URL] - h = hashlib.new("sha256") - h.update(url.encode()) - key = h.hexdigest()[:8] - base_dir = external_files.compute_local_file_dir(DOMAIN) - return base_dir / key + return external_files.compute_local_file_path(DOMAIN, config[CONF_URL]) def _convert_manifest_v1_to_v2(v1_manifest): @@ -389,11 +384,14 @@ def _download_http_models(config: ConfigType) -> ConfigType: return config external_files.download_content_many( - ((url, path / "manifest.json") for path, url in http_models.items()), + ( + external_files.RemoteFile(url, path / "manifest.json") + for path, url in http_models.items() + ), description="wake word manifest(s)", ) - model_files: list[tuple[str, Path]] = [] + model_files: list[external_files.RemoteFile] = [] errors: list[cv.Invalid] = [] for path, url in http_models.items(): try: @@ -412,7 +410,7 @@ def _download_http_models(config: ConfigType) -> ConfigType: cv.Invalid(f"Manifest file at {url} is missing the 'model' key") ) continue - model_files.append((urljoin(url, model), path / model)) + model_files.append(external_files.RemoteFile(urljoin(url, model), path / model)) if errors: raise cv.MultipleInvalid(errors) diff --git a/esphome/components/shelly_dimmer/light.py b/esphome/components/shelly_dimmer/light.py index cd6d858067..dd99fcbc90 100644 --- a/esphome/components/shelly_dimmer/light.py +++ b/esphome/components/shelly_dimmer/light.py @@ -2,9 +2,7 @@ import hashlib from pathlib import Path import re -import requests - -from esphome import pins +from esphome import external_files, pins import esphome.codegen as cg from esphome.components import light, sensor, uart from esphome.components.const import CONF_SHA256 @@ -28,8 +26,9 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) -from esphome.core import CORE, HexInt -from esphome.happy_eyeballs import ensure_happy_eyeballs +from esphome.core import HexInt +from esphome.external_files import RemoteFile +from esphome.types import ConfigType DOMAIN = "shelly_dimmer" AUTO_LOAD = ["sensor"] @@ -76,46 +75,85 @@ def parse_firmware_version(value): return major, minor -def get_firmware(value): +def _firmware_cache_path(name: str) -> Path: + return external_files.compute_local_file_dir(DOMAIN) / f"{name}_fw_stm.bin" + + +def _firmware_path(url: str, sha: str | None) -> Path: + """Cache path for a firmware blob: sha-keyed when verifiable, else + URL-keyed. Shared by the validator and the prefetch hook.""" + return _firmware_cache_path( + sha.lower() if sha else external_files.url_cache_key(url) + ) + + +def get_firmware(value: ConfigType) -> list[HexInt] | None: if not value[CONF_UPDATE]: return None - def dl(url): - try: - ensure_happy_eyeballs() - req = requests.get(url, timeout=30) - req.raise_for_status() - except requests.exceptions.RequestException as e: - raise cv.Invalid(f"Could not download firmware file ({url}): {e}") from e - - h = hashlib.new("sha256") - h.update(req.content) - return req.content, h.hexdigest() - url = value[CONF_URL] - if CONF_SHA256 in value: # we have a hash, enable caching - path = Path(CORE.data_dir) / DOMAIN / (value[CONF_SHA256] + "_fw_stm.bin") - - if not path.is_file(): - firmware_data, dl_hash = dl(url) - - if dl_hash != value[CONF_SHA256]: - raise cv.Invalid( - f"Hash mismatch for {url}: {dl_hash} != {value[CONF_SHA256]}" - ) - - path.parent.mkdir(exist_ok=True, parents=True) - path.write_bytes(firmware_data) - - else: + if expected := value.get(CONF_SHA256): + expected = expected.lower() + path = _firmware_path(url, expected) + if path.is_file(): firmware_data = path.read_bytes() - else: # no caching, download every time - firmware_data, dl_hash = dl(url) + if hashlib.sha256(firmware_data).hexdigest() == expected: + return [HexInt(x) for x in firmware_data] + # A corrupted or foreign cache entry must never be trusted just + # because the file exists; discard it and download again. + path.unlink() + firmware_data = external_files.download_content(url, path) + if (actual := hashlib.sha256(firmware_data).hexdigest()) != expected: + path.unlink(missing_ok=True) + raise cv.Invalid(f"Hash mismatch for {url}: {actual} != {expected}") + else: + # No hash to verify the bytes, so an unrevalidated copy is an + # error rather than a silent fallback. + firmware_data = external_files.download_content( + url, + _firmware_path(url, None), + allow_stale=False, + ) return [HexInt(x) for x in firmware_data] +def _extract_firmware_ref(entry: ConfigType) -> RemoteFile | None: + firmware = entry.get(CONF_FIRMWARE) + if not isinstance(firmware, dict): + return None + try: + # cv.boolean, not truthiness: `update: "false"` is a valid False. + if not cv.boolean(firmware.get(CONF_UPDATE, False)): + return None + except cv.Invalid: + return None + url = firmware.get(CONF_URL) + sha = firmware.get(CONF_SHA256) + if url is None and (known := KNOWN_FIRMWARE.get(str(firmware.get(CONF_VERSION)))): + url, sha = known + if not isinstance(url, str): + return None + if sha is not None: + # Reject anything but a well-formed hash; a raw string would + # otherwise become a path component before validation runs. + try: + sha = validate_sha256(sha) + except (cv.Invalid, ValueError, TypeError): + return None + path = _firmware_path(url, sha) + if sha is not None and path.is_file(): + # Content-addressed and already on disk; get_firmware verifies it + # by hash, so there is nothing to revalidate. + return None + # No hash means no stale copies, matching the validator's policy. + return RemoteFile(url, path, allow_stale=sha is not None) + + +PREFETCH_FILES = external_files.single_stage_prefetch(_extract_firmware_ref) + + def validate_firmware(value): config = value.copy() if CONF_URL not in config: diff --git a/esphome/config.py b/esphome/config.py index b747c69b3a..987bb9c96a 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -1,14 +1,15 @@ from __future__ import annotations import abc -from contextlib import contextmanager +from collections.abc import Iterator +from contextlib import contextmanager, suppress import contextvars import copy import functools import heapq import logging import re -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -40,6 +41,9 @@ from esphome.util import OrderedDict, safe_print from esphome.voluptuous_schema import ExtraKeysInvalid from esphome.yaml_util import ESPHomeDataBase, ESPLiteralValue, is_secret +if TYPE_CHECKING: + from esphome.external_files import RemoteFile + _LOGGER = logging.getLogger(__name__) @@ -717,6 +721,125 @@ class AutoLoadValidationStep(ConfigValidationStep): ) +# Backstop against a runaway PREFETCH_FILES generator; no real component +# needs anywhere near this many stages (font, the deepest, uses two). +_MAX_PREFETCH_STAGES = 10 + + +class PrefetchRemoteFilesValidationStep(ConfigValidationStep): + """Batch-download remote files referenced by the raw config. + + Each round, the batches yielded by every ``PREFETCH_FILES`` hook (see + ``ComponentManifest.prefetch_files``) download in one parallel pass, so + per-entry schema validators find a warm cache. Must run between + AutoLoadValidationStep (-1.0) and MetadataValidationStep (-2.0): + metadata steps push priority-0 schema steps that pop immediately, so + this is the last point where every raw entry list is intact. Best + effort: failures are logged and memoized per run; the per-entry + validators stay authoritative. + """ + + priority = -1.5 + + def run(self, result: Config) -> None: + active: list[tuple[str, Iterator[list[RemoteFile]]]] = [] + + def warn_hook_failed(name: str, err: Exception) -> None: + # A broken hook must not fail validation; it only loses the + # batching speedup. + _LOGGER.warning("Remote file prefetch for %s failed: %s", name, err) + _LOGGER.debug("Prefetch hook traceback", exc_info=err) + + def start_hook( + name: str, manifest: ComponentManifest, entries: list[ConfigType] + ) -> None: + if (hook := manifest.prefetch_files) is None: + return + try: + active.append((name, iter(hook(entries)))) + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + warn_hook_failed(name, err) + + for domain, conf in result.items(): + if not isinstance(domain, str) or domain.startswith("."): + continue + if (component := get_component(domain)) is None: + continue + if component.prefetch_files is None and not component.is_platform_component: + continue + if conf is None or isinstance(conf, core.AutoLoad): + continue + entries = [ + entry + for entry in (conf if isinstance(conf, list) else [conf]) + if isinstance(entry, dict) + ] + if not entries: + continue + # A domain-level hook on a platform component receives every + # entry; overlap with per-platform hooks dedupes by path. + start_hook(domain, component, entries) + if not component.is_platform_component: + continue + by_platform: dict[str, list[ConfigType]] = {} + for entry in entries: + if isinstance(p_name := entry.get(CONF_PLATFORM), str): + by_platform.setdefault(p_name, []).append(entry) + for p_name, p_entries in by_platform.items(): + if (platform := get_platform(domain, p_name)) is not None: + start_hook(f"{domain}.{p_name}", platform, p_entries) + + # One stage per round; later stages can read what earlier ones + # fetched. + for _ in range(_MAX_PREFETCH_STAGES): + if not active: + break + items: list[RemoteFile] = [] + still_active: list[tuple[str, Iterator[list[RemoteFile]]]] = [] + for name, generator in active: + try: + batch = list(next(generator)) + except StopIteration: + continue + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + warn_hook_failed(name, err) + continue + items.extend(batch) + still_active.append((name, generator)) + active = still_active + self._download(items) + for name, generator in active: + # A tripped backstop means a broken hook. + _LOGGER.warning( + "Remote file prefetch for %s stopped after %d stages", + name, + _MAX_PREFETCH_STAGES, + ) + if (close := getattr(generator, "close", None)) is not None: + # close() runs hook code too; it must not fail validation. + with suppress(Exception): + close() + + @staticmethod + def _download(items: list[RemoteFile]) -> None: + if not items: + return + # Imported lazily: requests is a heavy import (~85ms) and is only + # needed when a config actually references remote files. + from esphome import external_files + + try: + external_files.download_content_many(items, description="remote file(s)") + except cv.Invalid as err: + # INFO: the trace if an extractor's cache path ever drifts from + # its validator's, hiding the memoized failure replay. + _LOGGER.info("Remote file prefetch download failed: %s", err) + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + # The batch downloader itself broke; make it visible. + _LOGGER.warning("Remote file prefetch failed: %s", err) + _LOGGER.debug("Prefetch download traceback", exc_info=err) + + class MetadataValidationStep(ConfigValidationStep): """Validate component metadata @@ -1259,6 +1382,7 @@ def validate_config( for domain, conf in config.items(): result.add_validation_step(LoadValidationStep(domain, conf)) + result.add_validation_step(PrefetchRemoteFilesValidationStep()) result.add_validation_step(IDPassValidationStep()) result.add_validation_step(CoreFinalValidateStep()) result.add_validation_step(PinUseValidationCheck()) diff --git a/esphome/external_files.py b/esphome/external_files.py index 160a2b6c29..f30d429425 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -1,16 +1,16 @@ from __future__ import annotations -from collections.abc import Callable, Iterable +from collections.abc import Callable, Iterable, Iterator from concurrent.futures import ThreadPoolExecutor import contextlib +from dataclasses import dataclass, field from datetime import UTC, datetime +import hashlib import logging import os from pathlib import Path import time -import requests - import esphome.config_validation as cv from esphome.const import CONF_FILE, CONF_TYPE, CONF_URL, __version__ from esphome.core import CORE, EsphomeError, TimePeriodSeconds @@ -21,8 +21,54 @@ from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@landonr"] +DOMAIN = "external_files" + NETWORK_TIMEOUT = 30 + +@dataclass(frozen=True, slots=True) +class RemoteFile: + """A remote file to prefetch, yielded in stages by ``PREFETCH_FILES`` + hooks. A dataclass rather than a tuple so fields can be added later.""" + + url: str + path: Path + # False when nothing downstream can verify the bytes; a copy that + # cannot be revalidated is then an error, not a silent fallback. + allow_stale: bool = True + + +@dataclass(frozen=True, slots=True) +class FailedDownload: + """What went wrong for a cache path this run, kept for fast replay.""" + + url: str + message: str + cause: BaseException + + +@dataclass +class ExternalFilesRunData: + """Per-run download state, cleared by ``CORE.reset()`` between runs.""" + + # Verified fresh this run; later touches skip even the conditional HEAD. + fresh_paths: set[Path] = field(default_factory=set) + # Served from disk without revalidation; strict callers reject these. + stale_paths: set[Path] = field(default_factory=set) + # Served under skip_external_update, deliberately unchecked; skips the + # network like fresh_paths but never counts as verified. + unchecked_paths: set[Path] = field(default_factory=set) + # Failed with no usable copy; later touches replay the error fast. + failed_paths: dict[Path, FailedDownload] = field(default_factory=dict) + + +def _run_data() -> ExternalFilesRunData: + if (data := CORE.data.get(DOMAIN)) is not None: + return data + # setdefault: first touch may race on download_content_many's workers. + return CORE.data.setdefault(DOMAIN, ExternalFilesRunData()) + + IF_MODIFIED_SINCE = "If-Modified-Since" IF_NONE_MATCH = "If-None-Match" ETAG = "ETag" @@ -93,6 +139,9 @@ def _write_etag(local_file_path: Path, etag: str | None) -> None: def has_remote_file_changed( url: str, local_file_path: Path, timeout: int = NETWORK_TIMEOUT ) -> bool: + # Deferred so configs with no remote files skip the heavy import. + import requests + ensure_happy_eyeballs() if local_file_path.exists(): _LOGGER.debug("has_remote_file_changed: File exists at %s", local_file_path) @@ -127,6 +176,9 @@ def has_remote_file_changed( ) if (new_etag := response.headers.get(ETAG)) and new_etag != etag: _write_etag(local_file_path, new_etag) + # A confirmed 304 supersedes any earlier failed + # revalidation of this file. + _run_data().stale_paths.discard(local_file_path) return False _LOGGER.debug("has_remote_file_changed: File modified") return True @@ -136,6 +188,9 @@ def has_remote_file_changed( url, e, ) + # The copy is a fallback, not a verified 304; record that so + # callers that must not use unverified bytes can reject it. + _run_data().stale_paths.add(local_file_path) return False _LOGGER.debug("has_remote_file_changed: File doesn't exists at %s", local_file_path) @@ -159,14 +214,81 @@ def compute_local_file_dir(domain: str) -> Path: return base_directory -def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> bytes: +def url_cache_key(url: str) -> str: + """Short stable cache key for a URL.""" + return hashlib.sha256(url.encode()).hexdigest()[:8] + + +def compute_local_file_path(domain: str, url: str) -> Path: + """Cache path for a URL-keyed download under the domain's cache dir. + + Pure (no mkdir); parent directories are created at write time. + """ + return Path(CORE.data_dir) / domain / url_cache_key(url) + + +def is_fresh_this_run(path: Path) -> bool: + """Whether `path` was verified or downloaded during this run.""" + return path in _run_data().fresh_paths + + +def download_content( + url: str, + path: Path, + timeout: int = NETWORK_TIMEOUT, + allow_stale: bool = True, + return_content: bool = True, +) -> bytes: + """Download `url` into `path` and return the bytes, using the cache. + + On network failure an on-disk copy is served with a warning, unless + ``allow_stale=False``. ``CORE.skip_external_update`` always serves the + copy. ``return_content=False`` skips the disk read on cache hits. + """ + + # Deferred so configs with no remote files skip the heavy import. + import requests + + def _cached() -> bytes: + return path.read_bytes() if return_content else b"" + + # Memoized paths skip the network entirely; concurrent access is safe + # because download_content_many dedupes by path before fanning out. + run_data = _run_data() + fresh_paths = run_data.fresh_paths + if (path in fresh_paths or path in run_data.unchecked_paths) and path.exists(): + return _cached() + if allow_stale and path in run_data.stale_paths and path.exists(): + # Strict callers fall through to try the network themselves. + _LOGGER.info("Using cached copy of %s that could not be revalidated", url) + return _cached() + if (failure := run_data.failed_paths.get(path)) is not None: + if not path.exists(): + if failure.url == url: + raise cv.Invalid(failure.message) from failure.cause + raise cv.Invalid( + f"Could not download from {url}: an earlier download of " + f"{failure.url} to the same cache file failed: {failure.cause}" + ) from failure.cause + # The file appeared since the failure; revalidate normally. + del run_data.failed_paths[path] ensure_happy_eyeballs() if CORE.skip_external_update and path.exists(): _LOGGER.debug("Skipping update for %s (refresh disabled)", url) - return path.read_bytes() + run_data.unchecked_paths.add(path) + return _cached() if not has_remote_file_changed(url, path, timeout): + if path in run_data.stale_paths: + # The HEAD fell back to the copy without confirming it. + if not allow_stale: + raise cv.Invalid( + f"Could not check {url} for updates due to a network error " + f"and the cached copy cannot be verified" + ) + return _cached() _LOGGER.debug("Remote file has not changed %s", url) - return path.read_bytes() + fresh_paths.add(path) + return _cached() _LOGGER.info("Downloading %s", url) _LOGGER.debug("Saving to %s", path) @@ -185,16 +307,24 @@ def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> by data = req.content except requests.exceptions.RequestException as e: if path.exists(): + # Memoized so a flaky host warns once per run, not per consumer. + run_data.stale_paths.add(path) + if not allow_stale: + raise cv.Invalid(f"Could not download from {url}: {e}") from e _LOGGER.warning( "Could not download from %s due to network error (%s), using cached file", url, e, ) - return path.read_bytes() - raise cv.Invalid(f"Could not download from {url}: {e}") from e + return _cached() + message = f"Could not download from {url}: {e}" + run_data.failed_paths[path] = FailedDownload(url, message, e) + raise cv.Invalid(message) from e write_file(path, data) _write_etag(path, req.headers.get(ETAG)) + fresh_paths.add(path) + run_data.stale_paths.discard(path) return data @@ -207,50 +337,47 @@ DEFAULT_DOWNLOAD_WORKERS = 8 def download_content_many( - items: Iterable[tuple[str, Path]], + items: Iterable[RemoteFile], timeout: int = NETWORK_TIMEOUT, max_workers: int = DEFAULT_DOWNLOAD_WORKERS, description: str = "remote file(s)", ) -> None: - """Run `download_content` for each (url, path) pair concurrently. + """Run `download_content` for each `RemoteFile` concurrently. - `description` names the kind of files in the progress log line, e.g. - "wake word manifest(s)". - - Wall time drops from `sum(latency)` to roughly `max(latency)` for cached - files where the HEAD round-trip dominates. All workers run to - completion before this returns; every `cv.Invalid` raised by a worker - is collected and surfaced together as `cv.MultipleInvalid` so the user - sees every broken file in a single validation pass instead of fixing - them one round-trip at a time. - - Items are de-duplicated by `path` -- two callers asking for the same - cache file (e.g. the same URL referenced twice in a config) would - otherwise race on `download_content`'s non-atomic write. When the - same `path` appears more than once, the last URL wins (standard dict - comprehension semantics); in practice duplicate paths only arise when - the URL is duplicated, so the choice doesn't matter. + `description` names the files in the progress log line. All workers run + to completion; every `cv.Invalid` raised is surfaced together as + `cv.MultipleInvalid`. Items dedupe by `path` (avoiding write races on + the same cache file); the last URL wins and a strict + `allow_stale=False` from any duplicate is kept. """ - seen: dict[Path, str] = {path: url for url, path in items} - if not seen: + seen: dict[Path, RemoteFile] = {} + for file in items: + if (prior := seen.get(file.path)) is not None and not prior.allow_stale: + file = RemoteFile(file.url, file.path, allow_stale=False) + seen[file.path] = file + unique = list(seen.values()) + if not unique: return ensure_happy_eyeballs() - _LOGGER.info("Checking %d %s for updates", len(seen), description) - if len(seen) == 1: - path, url = next(iter(seen.items())) - download_content(url, path, timeout) + _LOGGER.info("Checking %d %s for updates", len(unique), description) + + def _download_one(file: RemoteFile) -> None: + download_content( + file.url, + file.path, + timeout, + allow_stale=file.allow_stale, + return_content=False, + ) + + if len(unique) == 1: + _download_one(unique[0]) return - def _download_one(path_url: tuple[Path, str]) -> None: - # `seen` stores entries as (path, url) so the dict can dedupe by - # path; flip them back to download_content's (url, path) order. - path, url = path_url - download_content(url, path, timeout) - - workers = max(1, min(max_workers, len(seen))) + workers = max(1, min(max_workers, len(unique))) errors: list[cv.Invalid] = [] with ThreadPoolExecutor(max_workers=workers) as ex: - futures = [ex.submit(_download_one, item) for item in seen.items()] + futures = [ex.submit(_download_one, file) for file in unique] for future in futures: try: future.result() @@ -263,6 +390,21 @@ def download_content_many( raise cv.MultipleInvalid(errors) +def single_stage_prefetch( + extract: Callable[[ConfigType], RemoteFile | None], +) -> Callable[[list[ConfigType]], Iterator[list[RemoteFile]]]: + """Build a one-batch ``PREFETCH_FILES`` hook from a per-entry extractor. + + Covers the common case of one remote file per raw config entry; + components with staged downloads write their own generator. + """ + + def prefetch_files(entries: list[ConfigType]) -> Iterator[list[RemoteFile]]: + yield [ref for entry in entries if (ref := extract(entry)) is not None] + + return prefetch_files + + # Each component that uses external_files defines its own local # `TYPE_WEB = "web"`; the string is repeated here rather than imported # because there is no canonical `TYPE_WEB` in `esphome.const` to share. @@ -282,7 +424,7 @@ def download_web_files_in_config( slotted directly into a `cv.All(...)` chain. """ download_content_many( - (conf_file[CONF_URL], path_for(conf_file)) + RemoteFile(conf_file[CONF_URL], path_for(conf_file)) for entry in config if (conf_file := entry.get(CONF_FILE, {})).get(CONF_TYPE) == WEB_TYPE ) diff --git a/esphome/loader.py b/esphome/loader.py index 22db8b156a..7a659aa0a8 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -1,4 +1,4 @@ -from collections.abc import Callable +from collections.abc import Callable, Iterable from contextlib import AbstractContextManager from dataclasses import dataclass import importlib @@ -16,6 +16,7 @@ from esphome.types import ConfigType if TYPE_CHECKING: from esphome.cpp_generator import MockObjClass + from esphome.external_files import RemoteFile # `esphome.core.config` is imported lazily in `_lookup_module` when the # "esphome" pseudo-component is first resolved. It pulls in @@ -135,6 +136,21 @@ class ComponentManifest: """ return getattr(self.module, "FINAL_VALIDATE_SCHEMA", None) + @property + def prefetch_files( + self, + ) -> Callable[[list[ConfigType]], Iterable[list["RemoteFile"]]] | None: + """Optional `PREFETCH_FILES` hook for batched remote file downloads. + + A generator called once per run with the component's raw, pre-schema + config entries; each yield is a stage of ``RemoteFile`` downloaded in + one parallel pass before schema validation, so a later stage may + derive URLs from earlier files' content. Best effort: skip anything + unrecognized. On platform components, place it on the platform + sub-module; a domain-module hook receives every entry. + """ + return getattr(self.module, "PREFETCH_FILES", None) + @property def legacy_config_migrate(self) -> Callable[[ConfigType], ConfigType | None] | None: """Optional `LEGACY_CONFIG_MIGRATE` callable on a platform component module. diff --git a/tests/component_tests/gsl3670/test_init.py b/tests/component_tests/gsl3670/test_init.py index 8528cf23ca..950fa389be 100644 --- a/tests/component_tests/gsl3670/test_init.py +++ b/tests/component_tests/gsl3670/test_init.py @@ -87,13 +87,11 @@ def test_cache_path_is_deterministic_per_url( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """The cache path is derived from (and stable for) the URL.""" - monkeypatch.setattr( - gsl.external_files, "compute_local_file_dir", lambda _: tmp_path - ) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path)) first = gsl._cache_path(VALID_URL) assert first == gsl._cache_path(VALID_URL) assert first != gsl._cache_path("https://example.com/other.bin") - assert first.parent == tmp_path + assert first.parent == tmp_path / "gsl3670" def test_firmware_path_prefers_local_file(tmp_path: Path) -> None: @@ -106,9 +104,7 @@ def test_firmware_path_uses_cache_for_url( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """A ``url`` source resolves to the cache path for that URL.""" - monkeypatch.setattr( - gsl.external_files, "compute_local_file_dir", lambda _: tmp_path - ) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path)) assert gsl.firmware_path({"url": VALID_URL}) == gsl._cache_path(VALID_URL) @@ -145,9 +141,7 @@ def test_firmware_url_downloads_and_validates( ) -> None: """A url source downloads the content and validates its structure.""" data = _make_firmware() - monkeypatch.setattr( - gsl.external_files, "compute_local_file_dir", lambda _: tmp_path - ) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path)) monkeypatch.setattr(gsl.external_files, "download_content", lambda url, path: data) assert gsl._validate_firmware({"url": VALID_URL}) == {"url": VALID_URL} @@ -157,9 +151,7 @@ def test_firmware_url_sha256_mismatch_rejected( ) -> None: """A configured SHA-256 that does not match the download is rejected.""" data = _make_firmware() - monkeypatch.setattr( - gsl.external_files, "compute_local_file_dir", lambda _: tmp_path - ) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path)) monkeypatch.setattr(gsl.external_files, "download_content", lambda url, path: data) with pytest.raises(cv.Invalid, match="SHA-256 mismatch"): gsl._validate_firmware({"url": VALID_URL, "sha256": "00" * 32}) @@ -169,9 +161,7 @@ def test_firmware_url_invalid_structure_rejected( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """Downloaded content that is not a valid blob is rejected.""" - monkeypatch.setattr( - gsl.external_files, "compute_local_file_dir", lambda _: tmp_path - ) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path)) monkeypatch.setattr( gsl.external_files, "download_content", lambda url, path: b"\x00\x01\x02" ) diff --git a/tests/unit_tests/components/bme68x_bsec2/__init__.py b/tests/unit_tests/components/bme68x_bsec2/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/bme68x_bsec2/test_init.py b/tests/unit_tests/components/bme68x_bsec2/test_init.py new file mode 100644 index 0000000000..b34231a1aa --- /dev/null +++ b/tests/unit_tests/components/bme68x_bsec2/test_init.py @@ -0,0 +1,64 @@ +"""Tests for the bme68x_bsec2 prefetch extraction.""" + +from __future__ import annotations + +from pathlib import Path + +from esphome.components import bme68x_bsec2 as bsec +from esphome.loader import get_component + + +def test_prefetch_applies_defaults(setup_core: Path) -> None: + [files] = list(bsec.PREFETCH_FILES([{"model": "bme680"}])) + assert len(files) == 1 + assert "bme680_iaq_33v_3s_28d" in files[0].url + assert files[0].path == bsec._compute_local_file_path(files[0].url) + + +def test_prefetch_normalizes_enum_case(setup_core: Path) -> None: + [files] = list( + bsec.PREFETCH_FILES( + [ + { + "model": "BME688", + "sample_rate": "ulp", + "supply_voltage": "1.8v", + "algorithm_output": "REGRESSION", + "operating_age": "4D", + } + ] + ) + ) + assert len(files) == 1 + assert "bme688_reg_18v_300s_4d" in files[0].url + + +def test_prefetch_skips_unknown_values(setup_core: Path) -> None: + entries = [ + {"model": "bme999"}, + {"model": "bme680", "sample_rate": "TURBO"}, + {"model": "bme680", "algorithm_output": "psychic"}, + {}, + ] + assert list(bsec.PREFETCH_FILES(entries)) == [[]] + + +def test_prefetch_matches_validator_url(setup_core: Path) -> None: + """The hook's URL equals _compute_url over the validated config shape.""" + validated = { + "model": "bme688", + "operating_age": "28d", + "sample_rate": "LP", + "supply_voltage": "3.3V", + "algorithm_output": "classification", + } + [files] = list(bsec.PREFETCH_FILES([dict(validated)])) + assert files[0].url == bsec._compute_url(validated) + + +def test_hook_is_wired_to_the_user_facing_domain() -> None: + """The i2c domain (the only user-facing one) exposes the hook.""" + + component = get_component("bme68x_bsec2_i2c") + assert component is not None + assert component.prefetch_files is bsec.PREFETCH_FILES diff --git a/tests/unit_tests/components/file/__init__.py b/tests/unit_tests/components/file/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/file/test_image.py b/tests/unit_tests/components/file/test_image.py new file mode 100644 index 0000000000..a9c1684db3 --- /dev/null +++ b/tests/unit_tests/components/file/test_image.py @@ -0,0 +1,75 @@ +"""Tests for the file image platform's prefetch extraction.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from esphome.components.file import image as file_image +from esphome.external_files import RemoteFile +from esphome.loader import get_component, get_platform + + +def test_extract_mdi_shorthand(setup_core: Path) -> None: + ref = file_image._extract_file_ref("mdi:home") + assert ref is not None + assert ref.url == file_image.MDI_SOURCES["mdi"] + "home.svg" + assert ref.path.name == "home.svg" + assert ref.path.parent.name == "mdi" + + +def test_extract_web_url(setup_core: Path) -> None: + url = "https://example.com/img.png" + ref = file_image._extract_file_ref(url) + assert ref == RemoteFile(url, file_image.compute_local_image_path(url)) + + +def test_extract_typed_dicts(setup_core: Path) -> None: + url = "https://example.com/img.png" + assert file_image._extract_file_ref({"source": "web", "url": url}) == RemoteFile( + url, file_image.compute_local_image_path(url) + ) + ref = file_image._extract_file_ref({"source": "mdil", "icon": "home"}) + assert ref is not None + assert ref.url == file_image.MDI_SOURCES["mdil"] + "home.svg" + + +def test_extract_skips_local_and_garbage(setup_core: Path) -> None: + assert file_image._extract_file_ref("images/local.png") is None + assert file_image._extract_file_ref("mdi:not a valid icon!") is None + assert file_image._extract_file_ref({"source": "local", "path": "x.png"}) is None + assert file_image._extract_file_ref(42) is None + assert file_image._extract_file_ref(None) is None + + +def test_prefetch_files_yields_remote_refs(setup_core: Path) -> None: + entries = [ + {"file": "mdi:home"}, + {"file": "images/local.png"}, + {"file": "https://example.com/img.png"}, + {"no_file_key": True}, + ] + [files] = list(file_image.PREFETCH_FILES(entries)) + assert len(files) == 2 + assert files[0].url.endswith("home.svg") + assert files[1].url == "https://example.com/img.png" + + +def test_extractor_matches_validator_path(setup_core: Path) -> None: + """The path the validator downloads to equals the extractor's path.""" + with patch( + "esphome.components.file.image.external_files.download_content" + ) as mock_download: + file_image.validate_file_shorthand("mdi:home") + + validated_path = mock_download.call_args[0][1] + assert validated_path == file_image._extract_file_ref("mdi:home").path + + +def test_hook_is_wired_to_both_animation_domains() -> None: + """Both animation entry points expose the shared image hook.""" + + assert get_component("animation").prefetch_files is file_image.PREFETCH_FILES + assert ( + get_platform("image", "animation").prefetch_files is file_image.PREFETCH_FILES + ) diff --git a/tests/unit_tests/components/font/__init__.py b/tests/unit_tests/components/font/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/font/test_init.py b/tests/unit_tests/components/font/test_init.py new file mode 100644 index 0000000000..0ea3a0e3a1 --- /dev/null +++ b/tests/unit_tests/components/font/test_init.py @@ -0,0 +1,229 @@ +"""Tests for the font component's prefetch extraction.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from esphome import external_files +from esphome.components import font +import esphome.config_validation as cv +from esphome.external_files import RemoteFile + + +def _gspec(family: str, weight: int = 400, italic: bool = False) -> dict: + return {"family": family, "weight": weight, "italic": italic} + + +def test_extract_gfonts_shorthand_defaults(setup_core: Path) -> None: + spec = font._extract_remote_font("gfonts://Roboto") + assert spec is not None + assert spec[font.CONF_FAMILY] == "Roboto" + assert spec[font.CONF_WEIGHT] == 400 + assert spec[font.CONF_ITALIC] is False + + +def test_extract_gfonts_shorthand_weight_variants(setup_core: Path) -> None: + assert font._extract_remote_font("gfonts://Roboto@bold")[font.CONF_WEIGHT] == 700 + assert font._extract_remote_font("gfonts://Roboto@500")[font.CONF_WEIGHT] == 500 + + +def test_extract_gfonts_normalizes_quoted_italic(setup_core: Path) -> None: + """Boolean spellings the schema accepts are accepted by the extractor.""" + spec = font._extract_remote_font( + {"type": "gfonts", "family": "Roboto", "italic": "true"} + ) + assert spec is not None + assert spec[font.CONF_ITALIC] is True + assert ( + font._extract_remote_font( + {"type": "gfonts", "family": "Roboto", "italic": "maybe"} + ) + is None + ) + + +def test_extract_typed_gfonts_dict(setup_core: Path) -> None: + spec = font._extract_remote_font( + {"type": "gfonts", "family": "Roboto", "weight": "medium", "italic": True} + ) + assert spec is not None + assert spec[font.CONF_WEIGHT] == 500 + assert spec[font.CONF_ITALIC] is True + + +def test_extract_web_font(setup_core: Path) -> None: + url = "https://example.com/font.ttf" + for value in (url, {"type": "web", "url": url}): + spec = font._extract_remote_font(value) + assert spec is not None + assert spec[font.CONF_URL] == url + + +def test_extract_skips_local_and_garbage(setup_core: Path) -> None: + assert font._extract_remote_font("fonts/local.ttf") is None + assert font._extract_remote_font({"type": "local", "path": "x.ttf"}) is None + assert ( + font._extract_remote_font({"type": "gfonts", "family": "R", "weight": "no"}) + is None + ) + assert font._extract_remote_font(42) is None + + +def test_prefetch_yields_css_for_stale_gfont(setup_core: Path) -> None: + entries = [ + {"file": "gfonts://Roboto"}, + {"file": "fonts/local.ttf"}, + { + "file": "https://example.com/font.ttf", + "extras": [{"file": "gfonts://Monocraft"}], + }, + ] + batches = list(font.PREFETCH_FILES(entries)) + urls = [file.url for file in batches[0]] + assert font._gfonts_css_url(_gspec("Roboto")) in urls + assert font._gfonts_css_url(_gspec("Monocraft")) in urls + assert "https://example.com/font.ttf" in urls + assert len(batches[0]) == 3 + + +def test_prefetch_skips_recent_ttf(setup_core: Path) -> None: + path = font._gfonts_ttf_path(_gspec("Roboto")) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"cached ttf") + + batches = list(font.PREFETCH_FILES([{"file": "gfonts://Roboto"}])) + assert batches == [[], []] + + +def test_stage2_parses_cached_css(setup_core: Path) -> None: + + css_path = font._gfonts_css_path(_gspec("Roboto")) + css_path.parent.mkdir(parents=True, exist_ok=True) + css_path.write_text( + "src: url(https://fonts.gstatic.com/roboto.ttf) format('truetype');" + ) + # Stage two only trusts CSS confirmed fetched this run. + external_files._run_data().fresh_paths.add(css_path) + + batches = list(font.PREFETCH_FILES([{"file": "gfonts://Roboto"}])) + assert batches[1] == [ + RemoteFile( + "https://fonts.gstatic.com/roboto.ttf", + font._gfonts_ttf_path(_gspec("Roboto")), + ) + ] + + +def test_stage2_skips_missing_css(setup_core: Path) -> None: + batches = list(font.PREFETCH_FILES([{"file": "gfonts://NoCss"}])) + assert batches[1] == [] + + +def test_prefetch_handles_bare_mapping_extras(setup_core: Path) -> None: + """A bare-mapping extras value (valid raw config) is scanned.""" + entries = [ + { + "file": "fonts/local.ttf", + "extras": {"file": "gfonts://Roboto", "glyphs": "ABC"}, + } + ] + batches = list(font.PREFETCH_FILES(entries)) + assert [file.url for file in batches[0]] == [font._gfonts_css_url(_gspec("Roboto"))] + + +def test_unparseable_gfonts_css_is_evicted(setup_core: Path) -> None: + """A CSS body that fails to parse is removed from the cache.""" + + spec = { + "family": "Roboto", + "weight": 400, + "italic": False, + "refresh": font._REFRESH_VALIDATOR("0s"), + } + css_path = font._gfonts_css_path(spec) + with ( + patch( + "esphome.components.font.external_files.download_content", + return_value=b"no truetype url here", + ), + patch( + "esphome.components.font.external_files.is_fresh_this_run", + return_value=True, + ), + pytest.raises(cv.Invalid, match="please report this"), + ): + font.download_gfont(spec) + assert not css_path.exists() + + with ( + patch( + "esphome.components.font.external_files.download_content", + return_value=b"\xff\xfe\x00\x01binary", + ), + patch( + "esphome.components.font.external_files.is_fresh_this_run", + return_value=True, + ), + pytest.raises(cv.Invalid, match="not a text document"), + ): + font.download_gfont(spec) + assert not css_path.exists() + + +def test_unrevalidated_gfonts_css_uses_cached_font(setup_core: Path) -> None: + """A CSS body that could not be revalidated is not parsed for a ttf + URL; the cached font is used instead.""" + spec = { + "family": "Roboto", + "weight": 400, + "italic": False, + "refresh": font._REFRESH_VALIDATOR("0s"), + } + ttf_path = font._gfonts_ttf_path(spec) + ttf_path.parent.mkdir(parents=True, exist_ok=True) + ttf_path.write_bytes(b"cached ttf") + cache = MagicMock() + with ( + patch.object(font, "FONT_CACHE", cache), + patch( + "esphome.components.font.external_files.download_content", + return_value=b"stale css", + ), + ): + assert font.download_gfont(spec) is spec + cache.__setitem__.assert_called_once_with(spec, ttf_path) + + +def test_unrevalidated_gfonts_css_without_cached_font_errors( + setup_core: Path, +) -> None: + """No verified CSS and no cached font is a clear error.""" + spec = { + "family": "Roboto", + "weight": 500, + "italic": False, + "refresh": font._REFRESH_VALIDATOR("0s"), + } + with ( + patch( + "esphome.components.font.external_files.download_content", + return_value=b"stale css", + ), + pytest.raises(cv.Invalid, match="no cached font"), + ): + font.download_gfont(spec) + + +def test_stage2_skips_css_not_fetched_this_run(setup_core: Path) -> None: + """A leftover CSS from an earlier run is not trusted for stage two.""" + css_path = font._gfonts_css_path(_gspec("Roboto")) + css_path.parent.mkdir(parents=True, exist_ok=True) + css_path.write_text( + "src: url(https://fonts.gstatic.com/rotated.ttf) format('truetype');" + ) + + batches = list(font.PREFETCH_FILES([{"file": "gfonts://Roboto"}])) + assert batches[1] == [] diff --git a/tests/unit_tests/components/gsl3670/__init__.py b/tests/unit_tests/components/gsl3670/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/gsl3670/test_touchscreen.py b/tests/unit_tests/components/gsl3670/test_touchscreen.py new file mode 100644 index 0000000000..a4b96d72da --- /dev/null +++ b/tests/unit_tests/components/gsl3670/test_touchscreen.py @@ -0,0 +1,35 @@ +"""Tests for the gsl3670 touchscreen prefetch extraction.""" + +from __future__ import annotations + +from pathlib import Path + +from esphome.components.gsl3670 import touchscreen as gsl +from esphome.external_files import RemoteFile + + +def test_prefetch_explicit_url(setup_core: Path) -> None: + url = "https://example.com/fw.bin" + entries = [{"platform": "gsl3670", "firmware": {"url": url}}] + assert list(gsl.PREFETCH_FILES(entries)) == [ + [RemoteFile(url, gsl._cache_path(url))] + ] + + +def test_prefetch_model_default_firmware(setup_core: Path) -> None: + entries = [{"platform": "gsl3670", "model": "seeed-reterminal-d1001"}] + [files] = list(gsl.PREFETCH_FILES(entries)) + assert len(files) == 1 + assert ( + files[0].url == gsl.MODELS["SEEED-RETERMINAL-D1001"][gsl.CONF_FIRMWARE]["url"] + ) + assert files[0].path == gsl._cache_path(files[0].url) + + +def test_prefetch_skips_local_file_and_custom(setup_core: Path) -> None: + entries = [ + {"platform": "gsl3670", "firmware": {"file": "fw.bin"}}, + {"platform": "gsl3670", "model": "CUSTOM"}, + {"platform": "gsl3670"}, + ] + assert list(gsl.PREFETCH_FILES(entries)) == [[]] diff --git a/tests/unit_tests/components/micro_wake_word/test_init.py b/tests/unit_tests/components/micro_wake_word/test_init.py index 84371ab906..96fb73b18b 100644 --- a/tests/unit_tests/components/micro_wake_word/test_init.py +++ b/tests/unit_tests/components/micro_wake_word/test_init.py @@ -16,6 +16,7 @@ from esphome.const import ( CONF_TYPE, CONF_URL, ) +from esphome.external_files import RemoteFile @pytest.fixture @@ -114,12 +115,16 @@ def test_download_http_models_batches_manifests_then_models( assert mock_download_content_many.call_count == 2 manifest_items = list(mock_download_content_many.call_args_list[0].args[0]) assert manifest_items == [ - (f"https://example.com/models/{name}.json", paths[name] / "manifest.json") + RemoteFile( + f"https://example.com/models/{name}.json", paths[name] / "manifest.json" + ) for name in names ] model_items = list(mock_download_content_many.call_args_list[1].args[0]) assert model_items == [ - (f"https://example.com/models/{name}.tflite", paths[name] / f"{name}.tflite") + RemoteFile( + f"https://example.com/models/{name}.tflite", paths[name] / f"{name}.tflite" + ) for name in names ] diff --git a/tests/unit_tests/components/shelly_dimmer/__init__.py b/tests/unit_tests/components/shelly_dimmer/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/shelly_dimmer/test_light.py b/tests/unit_tests/components/shelly_dimmer/test_light.py new file mode 100644 index 0000000000..e5440db4c9 --- /dev/null +++ b/tests/unit_tests/components/shelly_dimmer/test_light.py @@ -0,0 +1,154 @@ +"""Tests for the shelly_dimmer firmware download and prefetch extraction.""" + +from __future__ import annotations + +import hashlib +from pathlib import Path +from unittest.mock import patch + +import pytest + +from esphome import external_files +from esphome.components.shelly_dimmer import light as shd +from esphome.config_validation import Invalid +from esphome.external_files import RemoteFile + + +def _sha(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def test_prefetch_known_version(setup_core: Path) -> None: + entries = [{"firmware": {"version": "51.6", "update": True}}] + stages = list(shd.PREFETCH_FILES(entries)) + url, sha = shd.KNOWN_FIRMWARE["51.6"] + assert stages == [[RemoteFile(url, shd._firmware_cache_path(sha))]] + + +def test_prefetch_normalizes_update_like_the_schema(setup_core: Path) -> None: + """Quoted booleans behave as the schema will normalize them.""" + url, sha = shd.KNOWN_FIRMWARE["51.6"] + off = [{"firmware": {"version": "51.6", "update": "false"}}] + assert list(shd.PREFETCH_FILES(off)) == [[]] + on = [{"firmware": {"version": "51.6", "update": "true"}}] + assert list(shd.PREFETCH_FILES(on)) == [ + [RemoteFile(url, shd._firmware_cache_path(sha))] + ] + + +def test_prefetch_rejects_malformed_sha256(setup_core: Path) -> None: + """A raw sha256 that is not a hash never becomes a path component.""" + entries = [ + { + "firmware": { + "url": "https://example.com/fw.bin", + "sha256": "/tmp/payload", + "update": True, + } + } + ] + assert list(shd.PREFETCH_FILES(entries)) == [[]] + + +def test_prefetch_skips_content_addressed_blob_on_disk(setup_core: Path) -> None: + """A sha-keyed cache file needs no revalidation; get_firmware hashes it.""" + url, sha = shd.KNOWN_FIRMWARE["51.6"] + shd._firmware_cache_path(sha).write_bytes(b"pinned firmware") + entries = [{"firmware": {"version": "51.6", "update": True}}] + assert list(shd.PREFETCH_FILES(entries)) == [[]] + + +def test_prefetch_explicit_url_without_sha(setup_core: Path) -> None: + url = "https://example.com/fw.bin" + entries = [{"firmware": {"url": url, "update": True}}] + stages = list(shd.PREFETCH_FILES(entries)) + key = external_files.url_cache_key(url) + # No sha means the bytes cannot be verified, so the prefetch itself + # must carry the validator's strict no-stale policy. + assert stages == [ + [RemoteFile(url, shd._firmware_cache_path(key), allow_stale=False)] + ] + + +def test_prefetch_skips_no_update(setup_core: Path) -> None: + entries = [ + {"firmware": {"version": "51.6"}}, + {"firmware": "51.6"}, + {"firmware": {"version": "0.0", "update": True}}, + {}, + ] + assert list(shd.PREFETCH_FILES(entries)) == [[]] + + +def test_get_firmware_rejects_corrupted_cache(setup_core: Path) -> None: + """A cached blob failing its hash check is discarded and re-downloaded.""" + good = b"good firmware" + expected = _sha(good) + path = shd._firmware_cache_path(expected) + path.write_bytes(b"corrupted blob") + + with patch( + "esphome.components.shelly_dimmer.light.external_files.download_content", + return_value=good, + ) as mock_download: + result = shd.get_firmware( + { + "update": True, + "url": "https://example.com/fw.bin", + "sha256": expected, + } + ) + + mock_download.assert_called_once() + assert result == [int(b) for b in good] + + +def test_get_firmware_trusts_valid_cache(setup_core: Path) -> None: + """A cached blob passing its hash check is used with zero network.""" + good = b"good firmware" + expected = _sha(good) + shd._firmware_cache_path(expected).write_bytes(good) + + with patch( + "esphome.components.shelly_dimmer.light.external_files.download_content" + ) as mock_download: + result = shd.get_firmware( + { + "update": True, + "url": "https://example.com/fw.bin", + "sha256": expected, + } + ) + + mock_download.assert_not_called() + assert result == [int(b) for b in good] + + +def test_get_firmware_hash_mismatch_raises_and_uncaches(setup_core: Path) -> None: + """A fresh download failing its hash check raises and is not cached.""" + expected = _sha(b"expected firmware") + path = shd._firmware_cache_path(expected) + + with ( + patch( + "esphome.components.shelly_dimmer.light.external_files.download_content", + return_value=b"wrong firmware", + ), + pytest.raises(Invalid, match="Hash mismatch"), + ): + shd.get_firmware( + {"update": True, "url": "https://example.com/fw.bin", "sha256": expected} + ) + + assert not path.exists() + + +def test_get_firmware_without_sha_rejects_stale(setup_core: Path) -> None: + """The unverifiable no-hash branch must not accept a stale copy.""" + with patch( + "esphome.components.shelly_dimmer.light.external_files.download_content", + return_value=b"fw", + ) as mock_download: + shd.get_firmware({"update": True, "url": "https://example.com/fw.bin"}) + + assert mock_download.call_args.kwargs["allow_stale"] is False diff --git a/tests/unit_tests/test_config_prefetch.py b/tests/unit_tests/test_config_prefetch.py new file mode 100644 index 0000000000..afb93a09a0 --- /dev/null +++ b/tests/unit_tests/test_config_prefetch.py @@ -0,0 +1,355 @@ +"""Tests for the remote file prefetch validation step.""" + +from __future__ import annotations + +from collections.abc import Iterable +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from esphome import core +from esphome.config import Config, PrefetchRemoteFilesValidationStep +import esphome.config_validation as cv +from esphome.external_files import RemoteFile + + +def _component(prefetch: Any = None, is_platform: bool = False) -> SimpleNamespace: + return SimpleNamespace( + is_platform_component=is_platform, + prefetch_files=prefetch, + ) + + +def _run_step( + domains: dict[str, Any], + components: dict[str, Any], + platforms: dict[tuple[str, str], Any] | None = None, + download_side_effect: Any = None, +) -> tuple[Config, MagicMock]: + result = Config() + for domain, conf in domains.items(): + result[domain] = conf + with ( + patch("esphome.config.get_component", side_effect=components.get), + patch( + "esphome.config.get_platform", + side_effect=lambda d, p: (platforms or {}).get((d, p)), + ), + patch( + "esphome.external_files.download_content_many", + side_effect=download_side_effect, + ) as mock_download, + ): + PrefetchRemoteFilesValidationStep().run(result) + return result, mock_download + + +def _downloaded(mock_download: MagicMock, call: int = 0) -> list[RemoteFile]: + return list(mock_download.call_args_list[call][0][0]) + + +def test_component_hook_receives_normalized_entries() -> None: + """A bare dict conf is passed to the hook as a one-entry list.""" + seen: list[Any] = [] + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + seen.append(entries) + yield [RemoteFile("https://example.com/a", Path("/cache/a"))] + + _, mock_download = _run_step( + {"my_comp": {"key": "value"}}, + {"my_comp": _component(prefetch=hook)}, + ) + + assert seen == [[{"key": "value"}]] + mock_download.assert_called_once() + assert _downloaded(mock_download) == [ + RemoteFile("https://example.com/a", Path("/cache/a")) + ] + + +def test_platform_entries_are_grouped_per_platform() -> None: + """Platform domains route entries to each platform module's hook.""" + seen_a: list[Any] = [] + seen_b: list[Any] = [] + + def hook_a(entries: list[dict]) -> Iterable[list[RemoteFile]]: + seen_a.extend(entries) + yield [RemoteFile("url-a", Path("/a"))] + + def hook_b(entries: list[dict]) -> Iterable[list[RemoteFile]]: + seen_b.extend(entries) + yield [RemoteFile("url-b", Path("/b"))] + + entries = [ + {"platform": "a", "n": 1}, + {"platform": "b", "n": 2}, + {"platform": "a", "n": 3}, + ] + _, mock_download = _run_step( + {"image": entries}, + {"image": _component(is_platform=True)}, + platforms={ + ("image", "a"): _component(prefetch=hook_a), + ("image", "b"): _component(prefetch=hook_b), + }, + ) + + assert seen_a == [entries[0], entries[2]] + assert seen_b == [entries[1]] + assert sorted(_downloaded(mock_download), key=lambda f: f.url) == [ + RemoteFile("url-a", Path("/a")), + RemoteFile("url-b", Path("/b")), + ] + + +def test_hook_failure_does_not_fail_validation( + caplog: pytest.LogCaptureFixture, +) -> None: + """A raising hook is logged and other hooks still prefetch.""" + + def bad_hook(entries: list[dict]) -> list[RemoteFile]: + raise RuntimeError("garbage config") + + def good_hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + yield [RemoteFile("url", Path("/g"))] + + _, mock_download = _run_step( + {"bad": {"x": 1}, "good": {"y": 2}}, + { + "bad": _component(prefetch=bad_hook), + "good": _component(prefetch=good_hook), + }, + ) + + assert "Remote file prefetch for bad failed" in caplog.text + assert _downloaded(mock_download) == [RemoteFile("url", Path("/g"))] + + +def test_stages_download_between_resumptions() -> None: + """Each yielded stage is downloaded before the generator resumes.""" + order: list[str] = [] + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + order.append("stage1") + yield [RemoteFile("css-url", Path("/css"))] + order.append("stage2") + yield [RemoteFile("ttf-url", Path("/ttf"))] + + def record_download(items: Any, description: str) -> None: + order.append(f"download:{[file.url for file in items]}") + + _, mock_download = _run_step( + {"font": {"f": 1}}, + {"font": _component(prefetch=hook)}, + download_side_effect=record_download, + ) + + assert order == [ + "stage1", + "download:['css-url']", + "stage2", + "download:['ttf-url']", + ] + assert mock_download.call_count == 2 + + +def test_runaway_generator_is_capped(caplog: pytest.LogCaptureFixture) -> None: + """An endless generator stops after the stage backstop.""" + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + n = 0 + while True: + yield [RemoteFile(f"url-{n}", Path(f"/f{n}"))] + n += 1 + + _, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + ) + + assert mock_download.call_count == 10 + assert "stopped after" in caplog.text + + +def test_mid_stage_failure_stops_only_that_hook( + caplog: pytest.LogCaptureFixture, +) -> None: + """A generator raising on a later stage does not affect other hooks.""" + + def flaky_hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + yield [RemoteFile("first", Path("/first"))] + raise RuntimeError("stage two exploded") + + def steady_hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + yield [RemoteFile("one", Path("/one"))] + yield [RemoteFile("two", Path("/two"))] + + _, mock_download = _run_step( + {"flaky": {"x": 1}, "steady": {"y": 2}}, + { + "flaky": _component(prefetch=flaky_hook), + "steady": _component(prefetch=steady_hook), + }, + ) + + assert "Remote file prefetch for flaky failed" in caplog.text + assert mock_download.call_count == 2 + assert _downloaded(mock_download, 1) == [RemoteFile("two", Path("/two"))] + + +def test_download_failure_is_swallowed() -> None: + """cv.Invalid from the batch download never escapes the step.""" + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + yield [RemoteFile("url", Path("/p"))] + + result, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + download_side_effect=cv.Invalid("download failed"), + ) + + mock_download.assert_called_once() + assert not result.errors + + +def test_domains_without_hooks_do_not_download() -> None: + """Components without PREFETCH_FILES cause no download call.""" + _, mock_download = _run_step( + {"plain": {"x": 1}, ".ignored": {"y": 2}, "unknown": {"z": 3}}, + {"plain": _component()}, + ) + mock_download.assert_not_called() + + +def test_none_and_autoload_confs_are_skipped() -> None: + """None and AutoLoad confs never reach a hook.""" + hook = MagicMock() + _, mock_download = _run_step( + {"a": None, "b": core.AutoLoad()}, + {"a": _component(prefetch=hook), "b": _component(prefetch=hook)}, + ) + hook.assert_not_called() + mock_download.assert_not_called() + + +def test_non_dict_entries_are_ignored() -> None: + """Garbage entries never reach a component hook.""" + hook = MagicMock() + _, mock_download = _run_step( + {"my_comp": ["just-a-string", 42]}, + {"my_comp": _component(prefetch=hook)}, + ) + hook.assert_not_called() + mock_download.assert_not_called() + + +def test_platform_entries_without_platform_key_are_ignored() -> None: + """Entries with a missing or unknown platform never reach a hook.""" + _, mock_download = _run_step( + {"image": [{"n": 1}, "garbage", {"platform": "unknown"}]}, + {"image": _component(is_platform=True)}, + ) + mock_download.assert_not_called() + + +def test_generator_still_alive_at_the_cap_is_warned_and_closed( + caplog: pytest.LogCaptureFixture, +) -> None: + """A generator with a stage left at the cap is warned about and closed.""" + closed: list[bool] = [] + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + try: + for n in range(10): + yield [RemoteFile(f"url-{n}", Path(f"/f{n}"))] + finally: + closed.append(True) + + _, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + ) + + assert mock_download.call_count == 10 + assert "stopped after" in caplog.text + assert closed == [True] + + +def test_plain_iterable_hook_survives_the_cap( + caplog: pytest.LogCaptureFixture, +) -> None: + """A hook returning a plain list of batches cannot crash the backstop.""" + + def hook(entries: list[dict]) -> list[list[RemoteFile]]: + return [[RemoteFile(f"url-{n}", Path(f"/f{n}"))] for n in range(12)] + + _, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + ) + + assert mock_download.call_count == 10 + assert "stopped after" in caplog.text + + +def test_domain_level_hook_on_platform_component() -> None: + """A hook on the platform component's domain module sees all entries.""" + seen: list[Any] = [] + + def domain_hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + seen.append(entries) + yield [RemoteFile("domain-url", Path("/domain"))] + + entries = [{"platform": "a", "n": 1}, {"platform": "b", "n": 2}] + _, mock_download = _run_step( + {"image": entries}, + {"image": _component(prefetch=domain_hook, is_platform=True)}, + ) + + assert seen == [entries] + assert _downloaded(mock_download) == [RemoteFile("domain-url", Path("/domain"))] + + +def test_generator_raising_on_close_is_contained( + caplog: pytest.LogCaptureFixture, +) -> None: + """A generator whose close() raises at the cap is logged, not crashed on.""" + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + try: + for n in range(10): + yield [RemoteFile(f"url-{n}", Path(f"/f{n}"))] + except GeneratorExit: + raise RuntimeError("close exploded") from None + + _, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + ) + + assert mock_download.call_count == 10 + assert "stopped after" in caplog.text + + +def test_unexpected_download_error_is_logged_visibly( + caplog: pytest.LogCaptureFixture, +) -> None: + """A broken batch downloader warns instead of silently disabling prefetch.""" + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + yield [RemoteFile("url", Path("/p"))] + + result, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + download_side_effect=TypeError("not a RemoteFile"), + ) + + mock_download.assert_called_once() + assert not result.errors + assert "Remote file prefetch failed" in caplog.text diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index 16cee9564f..4e993ff4f3 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -3,6 +3,7 @@ import os from pathlib import Path import time +from typing import Any from unittest.mock import MagicMock, patch import pytest @@ -26,19 +27,21 @@ def _seed_etag(cache_file: Path, etag: str) -> Path: @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. + """Patch `requests.head` so the conditional HEAD-request validator can + be tested without doing real HTTP. Patched on the requests module + because external_files imports it lazily inside the function. """ - with patch("esphome.external_files.requests.head") as m: + with patch("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. + """Patch `requests.get` so the download path can be tested without + doing real HTTP. Patched on the requests module because + external_files imports it lazily inside the function. """ - with patch("esphome.external_files.requests.get") as m: + with patch("requests.get") as m: yield m @@ -549,6 +552,10 @@ def test_download_content_skip_external_update_uses_cache( assert result == cached_content mock_has_remote_file_changed.assert_not_called() mock_requests_get.assert_not_called() + # Deliberately unchecked is memoized for the run but never "fresh". + assert not external_files.is_fresh_this_run(test_file) + assert external_files.download_content(url, test_file) == cached_content + mock_has_remote_file_changed.assert_not_called() def test_download_content_skip_external_update_downloads_when_missing( @@ -587,10 +594,16 @@ def test_download_content_many_single_item_avoids_pool( mock_download_content: MagicMock, setup_core: Path ) -> None: """A single item should be downloaded inline (no thread pool overhead).""" - item = ("https://example.com/file.txt", setup_core / "f.txt") + item = external_files.RemoteFile( + "https://example.com/file.txt", setup_core / "f.txt" + ) external_files.download_content_many([item]) mock_download_content.assert_called_once_with( - item[0], item[1], external_files.NETWORK_TIMEOUT + item.url, + item.path, + external_files.NETWORK_TIMEOUT, + allow_stale=True, + return_content=False, ) @@ -602,7 +615,12 @@ def test_download_content_many_runs_in_parallel( barrier = threading.Barrier(3) - def slow_download(url: str, path: Path, timeout: int) -> bytes: + def slow_download( + url: str, + path: Path, + *args: Any, + **kwargs: Any, + ) -> bytes: # If calls were serial this would deadlock (third caller never arrives # while the first is blocked at the barrier). barrier.wait(timeout=2.0) @@ -610,9 +628,9 @@ def test_download_content_many_runs_in_parallel( mock_download_content.side_effect = slow_download items = [ - ("https://example.com/a", setup_core / "a"), - ("https://example.com/b", setup_core / "b"), - ("https://example.com/c", setup_core / "c"), + external_files.RemoteFile("https://example.com/a", setup_core / "a"), + external_files.RemoteFile("https://example.com/b", setup_core / "b"), + external_files.RemoteFile("https://example.com/c", setup_core / "c"), ] external_files.download_content_many(items, max_workers=4) assert mock_download_content.call_count == 3 @@ -625,15 +643,20 @@ def test_download_content_many_propagates_single_error( it in a `MultipleInvalid` that the caller would have to unpack. """ - def fake_download(url: str, path: Path, timeout: int) -> bytes: + def fake_download( + url: str, + path: Path, + *args: Any, + **kwargs: Any, + ) -> bytes: if url.endswith("bad"): raise Invalid(f"could not download {url}") return b"" mock_download_content.side_effect = fake_download items = [ - ("https://example.com/ok", setup_core / "ok"), - ("https://example.com/bad", setup_core / "bad"), + external_files.RemoteFile("https://example.com/ok", setup_core / "ok"), + external_files.RemoteFile("https://example.com/bad", setup_core / "bad"), ] with pytest.raises(Invalid, match="could not download") as exc_info: external_files.download_content_many(items) @@ -648,16 +671,21 @@ def test_download_content_many_aggregates_multiple_errors( them one network round-trip at a time. """ - def fake_download(url: str, path: Path, timeout: int) -> bytes: + def fake_download( + url: str, + path: Path, + *args: Any, + **kwargs: Any, + ) -> bytes: if url.endswith("ok"): return b"" raise Invalid(f"could not download {url}") mock_download_content.side_effect = fake_download items = [ - ("https://example.com/ok", setup_core / "ok"), - ("https://example.com/bad1", setup_core / "bad1"), - ("https://example.com/bad2", setup_core / "bad2"), + external_files.RemoteFile("https://example.com/ok", setup_core / "ok"), + external_files.RemoteFile("https://example.com/bad1", setup_core / "bad1"), + external_files.RemoteFile("https://example.com/bad2", setup_core / "bad2"), ] with pytest.raises(MultipleInvalid) as exc_info: external_files.download_content_many(items) @@ -678,9 +706,9 @@ def test_download_content_many_dedupes_by_path( """ path = setup_core / "shared" items = [ - ("https://example.com/a", path), - ("https://example.com/b", path), - ("https://example.com/a", path), + external_files.RemoteFile("https://example.com/a", path), + external_files.RemoteFile("https://example.com/b", path), + external_files.RemoteFile("https://example.com/a", path), ] external_files.download_content_many(items) assert mock_download_content.call_count == 1 @@ -695,8 +723,8 @@ def test_download_content_many_clamps_invalid_max_workers( be clamped up to at least 1 worker. """ items = [ - ("https://example.com/a", setup_core / "a"), - ("https://example.com/b", setup_core / "b"), + external_files.RemoteFile("https://example.com/a", setup_core / "a"), + external_files.RemoteFile("https://example.com/b", setup_core / "b"), ] external_files.download_content_many(items, max_workers=0) assert mock_download_content.call_count == 2 @@ -724,8 +752,8 @@ def test_download_web_files_in_config_filters_and_dispatches( assert result is config mock_download_content_many.assert_called_once() assert list(mock_download_content_many.call_args[0][0]) == [ - ("https://example.com/a", setup_core / "a"), - ("https://example.com/c", setup_core / "c"), + external_files.RemoteFile("https://example.com/a", setup_core / "a"), + external_files.RemoteFile("https://example.com/c", setup_core / "c"), ] @@ -799,3 +827,264 @@ def test_download_content_atomic_write_no_partial_on_failure( # into the cache directory either way. leftover_tmps = list(setup_core.glob("tmp*")) assert leftover_tmps == [] + + +def test_download_content_memoizes_fresh_path( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A path downloaded once this run skips all network on later calls.""" + test_file = setup_core / "memo.txt" + + mock_has_remote_file_changed.return_value = True + mock_response = MagicMock() + mock_response.content = b"fresh content" + mock_response.headers = {} + mock_requests_get.return_value = mock_response + + url = "https://example.com/file.txt" + assert external_files.download_content(url, test_file) == b"fresh content" + assert external_files.download_content(url, test_file) == b"fresh content" + + mock_has_remote_file_changed.assert_called_once() + mock_requests_get.assert_called_once() + + +def test_download_content_memo_revalidates_deleted_file( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A memoized path whose file vanished is downloaded again.""" + test_file = setup_core / "memo.txt" + + mock_has_remote_file_changed.return_value = True + mock_response = MagicMock() + mock_response.content = b"fresh content" + mock_response.headers = {} + mock_requests_get.return_value = mock_response + + url = "https://example.com/file.txt" + external_files.download_content(url, test_file) + test_file.unlink() + external_files.download_content(url, test_file) + + assert mock_requests_get.call_count == 2 + + +def test_download_content_failure_fails_fast_on_retry( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A failed download is remembered; a retry raises without network.""" + test_file = setup_core / "memo.txt" + + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + with pytest.raises(Invalid, match="boom"): + external_files.download_content(url, test_file) + with pytest.raises(Invalid, match="boom"): + external_files.download_content(url, test_file) + + mock_requests_get.assert_called_once() + + +def test_download_content_failed_path_revalidates_when_file_appears( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A recorded failure is dropped once the file exists on disk.""" + test_file = setup_core / "memo.txt" + + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + with pytest.raises(Invalid): + external_files.download_content(url, test_file) + + # Another writer produced the file; the cached failure no longer applies + # and the network error now falls back to the on-disk copy. + test_file.write_bytes(b"appeared") + assert external_files.download_content(url, test_file) == b"appeared" + + +def test_download_content_network_error_fallback_memoizes( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """Falling back to a cached file memoizes, so a flaky host is hit once.""" + test_file = setup_core / "memo.txt" + test_file.write_bytes(b"cached content") + + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + assert external_files.download_content(url, test_file) == b"cached content" + assert external_files.download_content(url, test_file) == b"cached content" + + mock_requests_get.assert_called_once() + + +def test_download_content_not_changed_uses_cache( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A 304 not-changed check serves the cached file without a GET.""" + test_file = setup_core / "cached.txt" + test_file.write_bytes(b"cached content") + mock_has_remote_file_changed.return_value = False + + url = "https://example.com/file.txt" + assert external_files.download_content(url, test_file) == b"cached content" + + mock_requests_get.assert_not_called() + + +def test_head_failure_fallback_is_stale_not_fresh( + mock_requests_head: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A HEAD network failure serves the copy once and memoizes it as stale.""" + test_file = setup_core / "cached.txt" + test_file.write_bytes(b"cached content") + + mock_requests_head.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + assert external_files.download_content(url, test_file) == b"cached content" + assert external_files.download_content(url, test_file) == b"cached content" + + mock_requests_head.assert_called_once() + mock_requests_get.assert_not_called() + + +def test_allow_stale_false_rejects_unverified_copy( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """allow_stale=False raises instead of building from an unverified copy.""" + test_file = setup_core / "cached.txt" + test_file.write_bytes(b"cached content") + + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + with pytest.raises(Invalid, match="Could not download"): + external_files.download_content(url, test_file, allow_stale=False) + + # A strict caller gets its own attempt at the network rather than + # inheriting the stale memo's verdict. + with pytest.raises(Invalid, match="Could not download"): + external_files.download_content(url, test_file, allow_stale=False) + assert mock_requests_get.call_count == 2 + + # A caller that tolerates stale copies still gets the cached bytes. + assert external_files.download_content(url, test_file) == b"cached content" + + +def test_allow_stale_false_rejects_head_failure_fallback( + mock_requests_head: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """allow_stale=False also rejects a copy the HEAD could not confirm.""" + test_file = setup_core / "cached.txt" + test_file.write_bytes(b"cached content") + + mock_requests_head.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + with pytest.raises(Invalid, match="cannot be verified"): + external_files.download_content(url, test_file, allow_stale=False) + mock_requests_get.assert_not_called() + + +def test_download_content_many_forwards_per_file_allow_stale( + mock_download_content: MagicMock, setup_core: Path +) -> None: + """Each RemoteFile's own allow_stale reaches download_content.""" + files = [ + external_files.RemoteFile("https://example.com/a", setup_core / "a"), + external_files.RemoteFile( + "https://example.com/b", setup_core / "b", allow_stale=False + ), + ] + external_files.download_content_many(files) + forwarded = { + call.args[1]: call.kwargs["allow_stale"] + for call in mock_download_content.call_args_list + } + assert forwarded == {setup_core / "a": True, setup_core / "b": False} + + +def test_download_content_many_dedupe_keeps_strictest( + mock_download_content: MagicMock, setup_core: Path +) -> None: + """A strict duplicate wins over a permissive one for the same path.""" + path = setup_core / "fw.bin" + files = [ + external_files.RemoteFile("https://example.com/fw", path, allow_stale=False), + external_files.RemoteFile("https://example.com/fw", path), + ] + external_files.download_content_many(files) + mock_download_content.assert_called_once() + assert mock_download_content.call_args.kwargs["allow_stale"] is False + + +def test_successful_head_revalidation_clears_stale( + mock_requests_head: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A confirmed 304 supersedes an earlier failed revalidation.""" + test_file = setup_core / "cached.txt" + test_file.write_bytes(b"cached content") + + ok_304 = MagicMock(status_code=304, headers={}) + mock_requests_head.side_effect = [ + requests.exceptions.RequestException("blip"), + ok_304, + ] + + url = "https://example.com/file.txt" + assert external_files.download_content(url, test_file) == b"cached content" + # The stale memo short-circuits tolerant callers; a strict caller + # triggers a fresh HEAD, which now succeeds and clears the marker. + assert ( + external_files.download_content(url, test_file, allow_stale=False) + == b"cached content" + ) + # Verified now: served from the fresh memo with no more network. + assert external_files.download_content(url, test_file) == b"cached content" + assert mock_requests_head.call_count == 2 + mock_requests_get.assert_not_called() + + +def test_failed_path_replay_names_the_other_url( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A shared cache path replays the failure naming the original URL.""" + test_file = setup_core / "shared.bin" + + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException("boom") + + with pytest.raises(Invalid, match="first-url"): + external_files.download_content("https://example.com/first-url", test_file) + with pytest.raises(Invalid, match="earlier download of.*first-url"): + external_files.download_content("https://example.com/second-url", test_file) + mock_requests_get.assert_called_once() From 3f490fe1ed023e8ac31b2a757f5d6415040d8d59 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:42:05 +1200 Subject: [PATCH 1426/1815] [internal_temperature] Read the RP2 on-die sensor directly instead of via the Arduino API (#18262) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../internal_temperature_rp2.cpp | 63 ++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/esphome/components/internal_temperature/internal_temperature_rp2.cpp b/esphome/components/internal_temperature/internal_temperature_rp2.cpp index 11f8e27fc3..2e408b3b01 100644 --- a/esphome/components/internal_temperature/internal_temperature_rp2.cpp +++ b/esphome/components/internal_temperature/internal_temperature_rp2.cpp @@ -3,17 +3,76 @@ #include "esphome/core/log.h" #include "internal_temperature.h" -#include "Arduino.h" +#include +#include +#include + +// The RP2 variant headers (pulled in transitively by Arduino.h) define +// ADC_RESOLUTION as the pin-level ADC bit count, which would be substituted +// into the constant below. Nothing here uses the Arduino definition, so drop +// it for this file. Not restored with pop_macro: the uses below would then be +// substituted again. +#undef ADC_RESOLUTION namespace esphome::internal_temperature { static const char *const TAG = "internal_temperature.rp2"; +// The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040 +// and RP2350A, but input 8 on RP2350B, which has eight external channels rather +// than four. +// +// This deliberately does not use the SDK's ADC_TEMPERATURE_CHANNEL_NUM. That +// derives from NUM_ADC_CHANNELS, which settles from a board header, and +// arduino-pico supplies a fixed B-die one for every RP2350 build. The real die +// is only declared later, by the variant's pins_arduino.h, so the SDK constant +// reads 8 on A-die boards. PICO_RP2350A itself is correct by the time this file +// is compiled, on both arduino-pico and pico-sdk builds. +#if defined(PICO_RP2350) && !defined(PICO_RP2350A) +#error "PICO_RP2350A is not defined, so the RP2350 die is unknown and the temperature ADC channel cannot be chosen" +#endif +#if defined(PICO_RP2350) && !PICO_RP2350A +static constexpr uint8_t TEMPERATURE_ADC_INPUT = 8; +#else +static constexpr uint8_t TEMPERATURE_ADC_INPUT = 4; +#endif +static constexpr float ADC_VREF = 3.3f; +static constexpr float ADC_RESOLUTION = 4096.0f; // 12-bit +// RP2040 datasheet 4.9.5 / RP2350 datasheet 12.4.6: T = 27 - (V - 0.706) / 0.001721 +static constexpr float TEMPERATURE_AT_REFERENCE = 27.0f; +static constexpr float REFERENCE_VOLTAGE = 0.706f; +static constexpr float VOLTS_PER_DEGREE = 0.001721f; +// The sensor is powered down again after each read, so every conversion is the +// first one after enabling. Let the bias circuitry settle first, matching what +// the adc component does for its own temperature readings. +static constexpr uint32_t SETTLE_TIME_US = 1000; + +static float read_internal_temperature() { + // adc_init() resets the ADC block, so this runs at most once for this + // component. The adc component guards its own adc_init() the same way, so a + // redundant reset is still possible when both are used. That is harmless + // because both re-select their input on every read. + static bool adc_ready = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + if (!adc_ready) { + adc_init(); + adc_ready = true; + } + + adc_set_temp_sensor_enabled(true); + busy_wait_us(SETTLE_TIME_US); + adc_select_input(TEMPERATURE_ADC_INPUT); + const uint16_t raw = adc_read(); + adc_set_temp_sensor_enabled(false); + + const float voltage = raw * (ADC_VREF / ADC_RESOLUTION); + return TEMPERATURE_AT_REFERENCE - (voltage - REFERENCE_VOLTAGE) / VOLTS_PER_DEGREE; +} + void InternalTemperatureSensor::update() { float temperature = NAN; bool success = false; - temperature = analogReadTemp(); + temperature = read_internal_temperature(); success = (temperature != 0.0f); if (success && std::isfinite(temperature)) { From 22153be4cda5f4817df44c99afdff30d03b255ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 05:00:07 -0500 Subject: [PATCH 1427/1815] [core] Add esphome logs over web_server HTTP SSE (#17110) --- esphome/__main__.py | 65 +++- esphome/helpers.py | 18 + esphome/web_server_helpers.py | 43 +++ esphome/web_server_logs.py | 189 ++++++++++ esphome/web_server_ota.py | 19 +- tests/unit_tests/test_helpers.py | 18 +- tests/unit_tests/test_main.py | 133 +++++++ tests/unit_tests/test_web_server_helpers.py | 64 ++++ tests/unit_tests/test_web_server_logs.py | 397 ++++++++++++++++++++ tests/unit_tests/test_web_server_ota.py | 14 +- 10 files changed, 919 insertions(+), 41 deletions(-) create mode 100644 esphome/web_server_helpers.py create mode 100644 esphome/web_server_logs.py create mode 100644 tests/unit_tests/test_web_server_helpers.py create mode 100644 tests/unit_tests/test_web_server_logs.py diff --git a/esphome/__main__.py b/esphome/__main__.py index c4ba6b54d7..0ac5898268 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -21,7 +21,6 @@ from esphome.const import ( ARGUMENT_HELP_DEVICE, BUNDLE_EXTENSION, CONF_API, - CONF_AUTH, CONF_BAUD_RATE, CONF_BROKER, CONF_DEASSERT_RTS_DTR, @@ -29,6 +28,7 @@ from esphome.const import ( CONF_DISCOVER_IP, CONF_ESPHOME, CONF_LEVEL, + CONF_LOG, CONF_LOG_TOPIC, CONF_LOGGER, CONF_MDNS, @@ -42,7 +42,7 @@ from esphome.const import ( CONF_PORT, CONF_SUBSTITUTIONS, CONF_TOPIC, - CONF_USERNAME, + CONF_VERSION, CONF_WEB_SERVER, CONF_WIFI, ENV_NOGITIGNORE, @@ -273,8 +273,8 @@ def _unresolved_default_error(purpose: Purpose, defaults: list[str]) -> str: if purpose == Purpose.LOGGING and not has_api(): return ( "Cannot view logs over the network: no 'api:' component is " - "configured. Network log streaming requires the native API; add " - "an 'api:' component, enable MQTT logging, or view logs over USB." + "configured. Add an 'api:' component, enable MQTT logging, add a " + "'web_server:' component, or view logs over USB." ) if purpose == Purpose.UPLOADING and not has_ota(): return ( @@ -314,9 +314,12 @@ def choose_upload_log_host( ] resolved.append(choose_prompt(options, purpose=purpose)) elif device == "OTA": + # Logs can stream over a network transport via the native API + # or the web_server HTTP SSE feed. + network_logging = has_api() or has_web_server_logging() # ensure IP adresses are used first if is_ip_address(CORE.address) and ( - (purpose == Purpose.LOGGING and has_api()) + (purpose == Purpose.LOGGING and network_logging) or (purpose == Purpose.UPLOADING and has_ota()) ): resolved.extend(_resolve_with_cache(CORE.address, purpose)) @@ -328,7 +331,11 @@ def choose_upload_log_host( if has_mqtt_logging(): resolved.append("MQTT") - if has_api() and has_non_ip_address() and has_resolvable_address(): + if ( + network_logging + and has_non_ip_address() + and has_resolvable_address() + ): resolved.extend(_ota_hostnames_for_default(purpose)) elif purpose == Purpose.UPLOADING: @@ -390,7 +397,7 @@ def choose_upload_log_host( mqtt_config = CORE.config[CONF_MQTT] options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT")) - if has_api(): + if has_api() or has_web_server_logging(): add_ota_options() elif purpose == Purpose.UPLOADING and has_ota(): @@ -483,6 +490,21 @@ def has_web_server_ota() -> bool: ) +def has_web_server_logging() -> bool: + """Check if logs can be streamed over the web_server HTTP SSE endpoint. + + The ``web_server`` component exposes a ``/events`` Server-Sent Events + stream that carries ``event: log`` frames. This requires version 2+ (the + v1 UI has no ``/events`` endpoint) and the ``log`` option enabled (default). + """ + web_conf = CORE.config.get(CONF_WEB_SERVER) + if web_conf is None: + return False + if web_conf.get(CONF_VERSION, 2) == 1: + return False + return web_conf.get(CONF_LOG, True) + + def has_mqtt_ip_lookup() -> bool: """Check if MQTT is available and IP lookup is supported.""" if CONF_MQTT not in CORE.config: @@ -1291,25 +1313,23 @@ def _upload_via_native_api( def _upload_via_web_server( config: ConfigType, network_devices: list[str], binary: Path ) -> tuple[int, str | None]: - web_conf = config.get(CONF_WEB_SERVER) - if not web_conf: - raise EsphomeError( - f"Cannot upload via web_server OTA: the {CONF_WEB_SERVER} component " - f"is not configured." - ) - - remote_port = int(web_conf[CONF_PORT]) - auth = web_conf.get(CONF_AUTH) or {} - username = auth.get(CONF_USERNAME) - password = auth.get(CONF_PASSWORD) - from esphome import web_server_ota + from esphome.web_server_helpers import get_web_server_connection + remote_port, username, password = get_web_server_connection(config) return web_server_ota.run_ota( network_devices, remote_port, username, password, binary ) +def _show_logs_via_web_server(config: ConfigType, network_devices: list[str]) -> int: + from esphome import web_server_logs + from esphome.web_server_helpers import get_web_server_connection + + port, username, password = get_web_server_connection(config) + return web_server_logs.run_logs(network_devices, port, username, password) + + # Layout of esp_partition_info_t on flash. Each entry is 32 bytes, leading with a # 16-bit little-endian magic. ESP-IDF defines ESP_PARTITION_MAGIC = 0x50AA (stored as # bytes 0xAA, 0x50) for partition entries and ESP_PARTITION_MAGIC_MD5 = 0xEBEB for the @@ -1437,6 +1457,13 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int config, args.topic, args.username, args.password, args.client_id ) + # Fall back to the web_server HTTP SSE log stream for devices that have + # web_server: but no api: (the logging counterpart to web_server OTA). + if has_web_server_logging() and ( + network_devices := _resolve_network_devices(devices, config, args) + ): + return _show_logs_via_web_server(config, network_devices) + raise EsphomeError("No remote or local logging method configured (api/mqtt/logger)") diff --git a/esphome/helpers.py b/esphome/helpers.py index 15d9797ce1..2731109164 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -357,6 +357,24 @@ def resolve_ip_address( return res +def format_ip_url(family: int, sockaddr: tuple, port: int, path: str) -> str: + """Build an ``http://host:port/path`` URL for a resolved address. + + ``family``/``sockaddr`` come from a :func:`resolve_ip_address` entry. IPv6 + literals must be wrapped in brackets in URLs; link-local addresses need a + percent-encoded zone index per RFC 6874. + """ + import socket + + ip = sockaddr[0] + if family == socket.AF_INET6: + scope = sockaddr[3] if len(sockaddr) >= 4 else 0 + host_part = f"[{ip}%25{scope}]" if scope else f"[{ip}]" + else: + host_part = ip + return f"http://{host_part}:{port}{path}" + + def sort_ip_addresses(address_list: list[str]) -> list[str]: """Takes a list of IP addresses in string form, e.g. from mDNS or MQTT, and sorts them into the best order to actually try connecting to them. diff --git a/esphome/web_server_helpers.py b/esphome/web_server_helpers.py new file mode 100644 index 0000000000..f48934b185 --- /dev/null +++ b/esphome/web_server_helpers.py @@ -0,0 +1,43 @@ +"""Shared helpers for the web_server HTTP transports (OTA upload and logs).""" + +from __future__ import annotations + +from esphome.const import ( + CONF_AUTH, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_WEB_SERVER, +) +from esphome.core import CORE, EsphomeError +from esphome.helpers import format_ip_url, resolve_ip_address +from esphome.types import ConfigType + + +def resolve_web_server_urls(host: str, port: int, path: str) -> list[tuple[str, str]]: + """Resolve ``host`` to ``(ip, url)`` pairs for the web_server ``path``. + + Wraps :func:`resolve_ip_address` (honoring ``CORE.address_cache``) and + formats each resolved address into an ``http://host:port/path`` URL via + :func:`format_ip_url`, handling both IPv4 and IPv6. Shared by the + web_server OTA upload and log streaming paths. + """ + addr_infos = resolve_ip_address(host, port, address_cache=CORE.address_cache) + return [ + (sockaddr[0], format_ip_url(family, sockaddr, port, path)) + for family, _socktype, _, _, sockaddr in addr_infos + ] + + +def get_web_server_connection(config: ConfigType) -> tuple[int, str | None, str | None]: + """Return ``(port, username, password)`` for the web_server HTTP endpoint. + + Reads the port and optional HTTP Basic-auth credentials from the validated + ``web_server:`` config, shared by the web_server OTA upload and log + streaming paths. Raises :class:`EsphomeError` if ``web_server`` is absent. + """ + web_conf = config.get(CONF_WEB_SERVER) + if not web_conf: + raise EsphomeError(f"The {CONF_WEB_SERVER} component is not configured.") + auth = web_conf.get(CONF_AUTH) or {} + return int(web_conf[CONF_PORT]), auth.get(CONF_USERNAME), auth.get(CONF_PASSWORD) diff --git a/esphome/web_server_logs.py b/esphome/web_server_logs.py new file mode 100644 index 0000000000..e091e24bb7 --- /dev/null +++ b/esphome/web_server_logs.py @@ -0,0 +1,189 @@ +"""Stream device logs over the ``web_server`` component's HTTP SSE endpoint. + +The ``web_server`` component exposes a Server-Sent Events stream at ``/events`` +that multiplexes entity state, keepalive pings, and log lines (``event: log``). +This is the logging counterpart to the web_server OTA upload path +(:mod:`esphome.web_server_ota`); it lets ``esphome logs`` reach a device that +has ``web_server:`` configured but no ``api:``. + +Only the ``event: log`` frames are rendered; the payload is the device's +already-formatted, ANSI-colored log line, so it is passed through the same +``LogParser`` + ``safe_print`` path the serial and native-API log viewers use. +The stream is long-lived and the server drops idle connections, so the reader +reconnects automatically until interrupted. +""" + +from __future__ import annotations + +from datetime import datetime +import logging +import time +from typing import TYPE_CHECKING + +import requests +from requests.auth import HTTPBasicAuth + +from esphome.core import EsphomeError +from esphome.util import safe_print +from esphome.web_server_helpers import resolve_web_server_urls + +if TYPE_CHECKING: + from aioesphomeapi import LogParser + +_LOGGER = logging.getLogger(__name__) + +EVENTS_PATH = "/events" +# (connect_timeout, read_timeout). The device sends a keepalive ``ping`` every +# 10s, so a 30s read timeout tolerates a few missed pings before we treat the +# connection as dead and reconnect. +TIMEOUT = (10.0, 30.0) +# Pause between reconnect attempts so a downed device doesn't spin the CPU. +RECONNECT_DELAY = 1.0 +# Upper bound for the exponential backoff applied to consecutive failures, so an +# unreachable host backs off instead of retrying (and logging) once a second. +MAX_RECONNECT_DELAY = 10.0 + + +class WebServerLogsError(EsphomeError): + """Raised when the web_server log stream cannot be used (e.g. bad auth).""" + + +def _build_urls(hosts: list[str], port: int) -> list[tuple[str, str]]: + """Resolve ``hosts`` to ``(ip, url)`` pairs for the ``/events`` endpoint.""" + urls: list[tuple[str, str]] = [] + seen: set[str] = set() + for host in hosts: + try: + resolved = resolve_web_server_urls(host, port, EVENTS_PATH) + except EsphomeError as err: + _LOGGER.warning("Error resolving IP address of %s: %s", host, err) + continue + for ip, url in resolved: + if url not in seen: + seen.add(url) + urls.append((ip, url)) + return urls + + +def _emit(data_lines: list[str], parser: LogParser) -> None: + """Render the accumulated ``data:`` lines of one ``event: log`` frame.""" + time_ = datetime.now().astimezone() + milliseconds = time_.microsecond // 1000 + time_str = ( + f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{milliseconds:03}]" + ) + for line in data_lines: + safe_print(parser.parse_line(line, time_str)) + + +def _consume(response: requests.Response, parser: LogParser) -> None: + """Parse the SSE stream, rendering only ``event: log`` frames. + + Implements the minimal slice of the SSE grammar the ``web_server`` stream + uses: ``field: value`` lines (with one optional leading space after the + colon) accumulated until a blank line dispatches the frame. ``id:``, + ``retry:``, and comment (``:``) lines are ignored, as are non-``log`` + events (``ping``, ``state``, ...). + """ + event_type = "message" + data_lines: list[str] = [] + # Iterate bytes and decode as UTF-8 ourselves (matching run_miniterm); the + # text/event-stream response has no charset, so requests' decode_unicode + # would fall back to Latin-1 and mojibake UTF-8 log characters. + for raw in response.iter_lines(): + line = raw.decode("utf8", "backslashreplace") + if not line: + if event_type == "log" and data_lines: + _emit(data_lines, parser) + event_type = "message" + data_lines = [] + continue + if line.startswith(":"): + continue + field, _, value = line.partition(":") + value = value.removeprefix(" ") + if field == "event": + event_type = value + elif field == "data": + data_lines.append(value) + + +def _stream(url: str, ip: str, auth: HTTPBasicAuth | None, parser: LogParser) -> bool: + """Connect and stream one session. + + Returns ``True`` if a connection was established (even if it later + dropped), ``False`` if the connection attempt itself failed so the caller + can try the next resolved address. + """ + connected = False + _LOGGER.info("Connecting to %s ...", url) + try: + with requests.get( + url, + stream=True, + auth=auth, + timeout=TIMEOUT, + headers={"Accept": "text/event-stream"}, + ) as response: + if response.status_code == 401: + raise WebServerLogsError( + "Authentication failed (HTTP 401). Check the 'web_server' " + "'auth' username and password." + ) + if response.status_code in (403, 404): + # Permanent: the endpoint won't appear on retry (wrong version, + # 'log' disabled, or forbidden). Surface it instead of looping. + raise WebServerLogsError( + f"Device returned HTTP {response.status_code} for " + f"{EVENTS_PATH}; the web_server log stream is unavailable. " + "Ensure 'web_server' is version 2 or higher with 'log' enabled." + ) + if response.status_code != 200: + _LOGGER.error( + "Unexpected HTTP %s response from %s", response.status_code, ip + ) + return False + connected = True + _LOGGER.info("Connected to %s", ip) + _consume(response, parser) + except requests.RequestException as err: + if connected: + _LOGGER.info("Log stream from %s ended (%s); reconnecting...", ip, err) + else: + _LOGGER.warning("Could not connect to %s: %s", ip, err) + return connected + + +def run_logs( + hosts: list[str], + port: int, + username: str | None, + password: str | None, +) -> int: + """Stream logs from the first reachable host over the web_server SSE feed. + + Reconnects automatically when the stream drops and returns ``0`` on + ``KeyboardInterrupt`` (Ctrl+C), mirroring how the serial log viewer exits. + """ + from aioesphomeapi import LogParser + + auth = HTTPBasicAuth(username, password) if username and password else None + parser = LogParser() + delay = RECONNECT_DELAY + try: + while True: + if not (urls := _build_urls(hosts, port)): + _LOGGER.error("Could not resolve any of: %s", ", ".join(hosts)) + connected = False + else: + # ``any`` stops at the first address that connects; when that + # stream drops we reconnect to the same set on the next pass. + connected = any(_stream(url, ip, auth, parser) for ip, url in urls) + # Reset the backoff once we reach the device; otherwise grow it + # (capped) so an unreachable host doesn't retry/log once a second. + delay = ( + RECONNECT_DELAY if connected else min(delay * 2, MAX_RECONNECT_DELAY) + ) + time.sleep(delay) + except KeyboardInterrupt: + return 0 diff --git a/esphome/web_server_ota.py b/esphome/web_server_ota.py index 8d0fdeecff..7b508e8527 100644 --- a/esphome/web_server_ota.py +++ b/esphome/web_server_ota.py @@ -12,14 +12,14 @@ import io import logging from pathlib import Path import secrets -import socket from typing import BinaryIO import requests from requests.auth import HTTPBasicAuth from esphome.core import EsphomeError -from esphome.helpers import ProgressBar, resolve_ip_address +from esphome.helpers import ProgressBar +from esphome.web_server_helpers import resolve_web_server_urls _LOGGER = logging.getLogger(__name__) @@ -95,7 +95,7 @@ def _try_upload( from esphome.core import CORE try: - addr_infos = resolve_ip_address(host, port, address_cache=CORE.address_cache) + addr_urls = resolve_web_server_urls(host, port, OTA_PATH) except EsphomeError as err: _LOGGER.error( "Error resolving IP address of %s. Is it connected to WiFi?", host @@ -104,7 +104,7 @@ def _try_upload( _LOGGER.error("(If you know the IP, try --device )") raise WebServerOTAError(err) from err - if not addr_infos: + if not addr_urls: _LOGGER.error("Could not resolve %s", host) return 1, None @@ -113,16 +113,7 @@ def _try_upload( auth = HTTPBasicAuth(username, password) if username and password else None # Iterate resolved IPs (IPv4 + IPv6 candidates) just like espota2 does. - for af, _socktype, _, _, sa in addr_infos: - ip = sa[0] - # IPv6 literals must be wrapped in brackets in URLs; link-local - # addresses need a percent-encoded zone index per RFC 6874. - if af == socket.AF_INET6: - scope = sa[3] if len(sa) >= 4 else 0 - host_part = f"[{ip}%25{scope}]" if scope else f"[{ip}]" - else: - host_part = ip - url = f"http://{host_part}:{port}{OTA_PATH}" + for ip, url in addr_urls: _LOGGER.info("Connecting to %s port %s...", ip, port) try: diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 211fbf5112..6e00e5b80f 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -14,7 +14,7 @@ import pytest from esphome import helpers from esphome.address_cache import AddressCache from esphome.core import CORE, EsphomeError -from esphome.helpers import ProgressBar +from esphome.helpers import ProgressBar, format_ip_url @pytest.mark.parametrize( @@ -135,6 +135,22 @@ def test_is_ip_address__invalid(host): assert actual is False +@pytest.mark.parametrize( + ("family", "sockaddr", "expected"), + ( + (socket.AF_INET, ("192.168.1.5", 80), "http://192.168.1.5:80/events"), + (socket.AF_INET6, ("2001:db8::1", 80, 0, 0), "http://[2001:db8::1]:80/events"), + ( + socket.AF_INET6, + ("fe80::1", 8080, 0, 7), + "http://[fe80::1%257]:8080/events", + ), + ), +) +def test_format_ip_url(family, sockaddr, expected): + assert format_ip_url(family, sockaddr, sockaddr[1], "/events") == expected + + @settings(deadline=None) @given(value=ip_addresses(v=4).map(str)) def test_is_ip_address__valid(value): diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 14b49a1a05..23bfdbcd69 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -52,6 +52,7 @@ from esphome.__main__ import ( has_non_ip_address, has_ota, has_resolvable_address, + has_web_server_logging, has_web_server_ota, mqtt_get_ip, parse_args, @@ -80,6 +81,7 @@ from esphome.const import ( CONF_DISABLED, CONF_ESPHOME, CONF_LEVEL, + CONF_LOG, CONF_LOG_TOPIC, CONF_LOGGER, CONF_MDNS, @@ -94,6 +96,7 @@ from esphome.const import ( CONF_TOPIC, CONF_USE_ADDRESS, CONF_USERNAME, + CONF_VERSION, CONF_WEB_SERVER, CONF_WIFI, KEY_CORE, @@ -816,6 +819,30 @@ def test_choose_upload_log_host_with_ota_device_with_api_config_logging() -> Non assert result == ["192.168.1.100"] +def test_choose_upload_log_host_logging_web_server_only_ip() -> None: + """A web_server-only device with a static IP resolves to that IP for logs.""" + setup_core(config={CONF_WEB_SERVER: {}}, address="192.168.1.100") + + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, + ) + assert result == ["192.168.1.100"] + + +def test_choose_upload_log_host_logging_web_server_only_mdns() -> None: + """A web_server-only device with a .local name resolves to that hostname.""" + setup_core(config={CONF_WEB_SERVER: {}}, address="test.local") + + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, + ) + assert result == ["test.local"] + + def test_choose_upload_log_host_logging_without_api_reports_missing_api() -> None: """A resolvable device with only ota: fails logs with a missing-api message.""" setup_core( @@ -855,6 +882,17 @@ def test_unresolved_default_error_unresolvable_keeps_dashboard_hint() -> None: assert "set 'use_address'" in msg +def test_unresolved_default_error_logging_suggests_web_server() -> None: + """The missing-api log message lists web_server among the remediations.""" + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100" + ) + + msg = _unresolved_default_error(Purpose.LOGGING, ["OTA"]) + assert "no 'api:' component is configured" in msg + assert "'web_server:'" in msg + + def test_unresolved_default_error_upload_with_ota_is_generic() -> None: """With ota: present the upload error stays generic, not transport-specific.""" setup_core( @@ -2534,6 +2572,30 @@ def test_has_web_server_ota_returns_false_without_config() -> None: assert has_ota() is True +def test_has_web_server_logging_default() -> None: + """has_web_server_logging is True for a default web_server (v2, log on).""" + setup_core(config={CONF_WEB_SERVER: {}}) + assert has_web_server_logging() is True + + +def test_has_web_server_logging_without_config() -> None: + """has_web_server_logging is False when web_server is not configured.""" + setup_core(config={CONF_API: {}}) + assert has_web_server_logging() is False + + +def test_has_web_server_logging_v1_has_no_events_stream() -> None: + """has_web_server_logging is False for v1, which has no /events endpoint.""" + setup_core(config={CONF_WEB_SERVER: {CONF_VERSION: 1}}) + assert has_web_server_logging() is False + + +def test_has_web_server_logging_respects_log_disabled() -> None: + """has_web_server_logging is False when the web_server log option is off.""" + setup_core(config={CONF_WEB_SERVER: {CONF_LOG: False}}) + assert has_web_server_logging() is False + + def test_upload_program_web_server_only_auto_dispatches( mock_run_web_server_ota: Mock, mock_run_ota: Mock, @@ -3102,6 +3164,77 @@ def test_show_logs_network_with_mqtt_only( ) +@patch("esphome.web_server_logs.run_logs") +def test_show_logs_web_server( + mock_run_logs: Mock, +) -> None: + """A web_server-only device streams logs over the HTTP SSE endpoint.""" + setup_core( + config={ + "logger": {}, + CONF_WEB_SERVER: {CONF_PORT: 80}, + # No API or MQTT configured + }, + platform=PLATFORM_ESP32, + ) + mock_run_logs.return_value = 0 + + result = show_logs(CORE.config, MockArgs(), ["192.168.1.100"]) + + assert result == 0 + mock_run_logs.assert_called_once_with(["192.168.1.100"], 80, None, None) + + +@patch("esphome.web_server_logs.run_logs") +def test_show_logs_web_server_with_auth_and_port( + mock_run_logs: Mock, +) -> None: + """web_server port and basic-auth credentials are forwarded to the streamer.""" + setup_core( + config={ + "logger": {}, + CONF_WEB_SERVER: { + CONF_PORT: 8080, + CONF_AUTH: {CONF_USERNAME: "admin", CONF_PASSWORD: "secret"}, + }, + }, + platform=PLATFORM_ESP32, + ) + mock_run_logs.return_value = 0 + + result = show_logs(CORE.config, MockArgs(), ["192.168.1.100"]) + + assert result == 0 + mock_run_logs.assert_called_once_with(["192.168.1.100"], 8080, "admin", "secret") + + +@patch("esphome.web_server_logs.run_logs") +@patch("esphome.mqtt.show_logs") +def test_show_logs_mqtt_preferred_over_web_server( + mock_mqtt_show_logs: Mock, + mock_run_logs: Mock, +) -> None: + """With both MQTT logging and web_server, MQTT wins (API > MQTT > web_server).""" + setup_core( + config={ + "logger": {}, + "mqtt": {CONF_BROKER: "mqtt.local"}, + CONF_WEB_SERVER: {CONF_PORT: 80}, + }, + platform=PLATFORM_ESP32, + ) + mock_mqtt_show_logs.return_value = 0 + + args = MockArgs( + topic="esphome/logs", username="user", password="pass", client_id="client" + ) + result = show_logs(CORE.config, args, ["192.168.1.100"]) + + assert result == 0 + mock_mqtt_show_logs.assert_called_once() + mock_run_logs.assert_not_called() + + def test_show_logs_no_method_configured() -> None: """Test show_logs when no remote logging method is configured.""" setup_core( diff --git a/tests/unit_tests/test_web_server_helpers.py b/tests/unit_tests/test_web_server_helpers.py new file mode 100644 index 0000000000..0280630d69 --- /dev/null +++ b/tests/unit_tests/test_web_server_helpers.py @@ -0,0 +1,64 @@ +"""Unit tests for esphome.web_server_helpers module.""" + +from __future__ import annotations + +import socket + +import pytest + +from esphome.const import ( + CONF_AUTH, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_WEB_SERVER, +) +from esphome.core import EsphomeError +from esphome.web_server_helpers import ( + get_web_server_connection, + resolve_web_server_urls, +) + + +def test_resolve_web_server_urls_maps_ipv4_and_ipv6( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Each resolved address becomes an (ip, url) pair with IPv6 bracketing.""" + addr_infos = [ + (socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.5", 80)), + (socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("fe80::1", 80, 0, 7)), + ] + monkeypatch.setattr( + "esphome.web_server_helpers.resolve_ip_address", + lambda *args, **kwargs: addr_infos, + ) + + assert resolve_web_server_urls("dev.local", 80, "/events") == [ + ("192.168.1.5", "http://192.168.1.5:80/events"), + ("fe80::1", "http://[fe80::1%257]:80/events"), + ] + + +def test_get_web_server_connection_without_auth() -> None: + """Port is returned and credentials are None when no auth is configured.""" + config = {CONF_WEB_SERVER: {CONF_PORT: 80}} + + assert get_web_server_connection(config) == (80, None, None) + + +def test_get_web_server_connection_with_auth() -> None: + """Port and HTTP Basic credentials are returned when auth is configured.""" + config = { + CONF_WEB_SERVER: { + CONF_PORT: 8080, + CONF_AUTH: {CONF_USERNAME: "admin", CONF_PASSWORD: "secret"}, + } + } + + assert get_web_server_connection(config) == (8080, "admin", "secret") + + +def test_get_web_server_connection_missing_component() -> None: + """A config without web_server raises a clear error.""" + with pytest.raises(EsphomeError, match="web_server.*not configured"): + get_web_server_connection({}) diff --git a/tests/unit_tests/test_web_server_logs.py b/tests/unit_tests/test_web_server_logs.py new file mode 100644 index 0000000000..bbdf37bed7 --- /dev/null +++ b/tests/unit_tests/test_web_server_logs.py @@ -0,0 +1,397 @@ +"""Unit tests for esphome.web_server_logs module.""" + +from __future__ import annotations + +from collections.abc import Iterator +import logging +import socket +from typing import Self +from unittest.mock import MagicMock + +import pytest +import requests +from requests.auth import HTTPBasicAuth + +from esphome import web_server_logs +from esphome.core import EsphomeError +from esphome.web_server_logs import ( + EVENTS_PATH, + WebServerLogsError, + _build_urls, + _consume, + _stream, + run_logs, +) + +# A realistic slice of the web_server /events SSE stream: an initial ping +# carrying the config, a state frame, two log frames (one multi-line), plus +# comment/id/retry lines that must be ignored. +SSE_LINES = [ + "retry: 30000", + "id: 12345", + "event: ping", + 'data: {"title":"dev","log":true}', + "", + "event: state", + 'data: {"id":"sensor-x","state":"ON"}', + "", + "event: log", + "data: \x1b[0;32m[I][main:001]: hello\x1b[0m", + "", + ": keepalive-comment", + "event: log", + "data: line one", + "data: line two", + "", +] + + +class _FakeResponse: + """Minimal stand-in for a streamed ``requests`` response.""" + + def __init__(self, status_code: int, lines: list[str]) -> None: + self.status_code = status_code + self._lines = lines + + def __enter__(self) -> Self: + return self + + def __exit__(self, *exc: object) -> bool: + return False + + def iter_lines(self) -> Iterator[bytes]: + for line in self._lines: + yield line.encode("utf8") + + +@pytest.fixture +def fake_parser() -> MagicMock: + """A LogParser whose parse_line returns the raw line unchanged.""" + parser = MagicMock() + parser.parse_line.side_effect = lambda line, time_str: line + return parser + + +def _patch_resolve( + monkeypatch: pytest.MonkeyPatch, + addr_infos: list[tuple[int, int, int, str, tuple]], +) -> None: + monkeypatch.setattr( + "esphome.web_server_helpers.resolve_ip_address", + lambda *args, **kwargs: addr_infos, + ) + + +# --------------------------------------------------------------------------- +# _build_urls +# --------------------------------------------------------------------------- + + +def test_build_urls_ipv4(monkeypatch: pytest.MonkeyPatch) -> None: + """An IPv4 host resolves to a plain http://ip:port/events URL.""" + _patch_resolve( + monkeypatch, + [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.5", 80))], + ) + + assert _build_urls(["dev.local"], 80) == [ + ("192.168.1.5", f"http://192.168.1.5:80{EVENTS_PATH}") + ] + + +def test_build_urls_ipv6_brackets_and_zone(monkeypatch: pytest.MonkeyPatch) -> None: + """IPv6 literals are bracketed; link-local addresses get a %25 zone index.""" + _patch_resolve( + monkeypatch, + [(socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("fe80::1", 8080, 0, 7))], + ) + + assert _build_urls(["dev.local"], 8080) == [ + ("fe80::1", f"http://[fe80::1%257]:8080{EVENTS_PATH}") + ] + + +def test_build_urls_dedups_and_skips_unresolvable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Duplicate resolved IPs collapse to one URL; resolve errors are skipped.""" + calls: list[str] = [] + + def fake_resolve(host: str, port: int, **kwargs: object) -> list[tuple]: + calls.append(host) + if host == "bad": + raise EsphomeError("nope") + return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("10.0.0.1", port))] + + monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", fake_resolve) + + # "good" and "dup" both resolve to 10.0.0.1, "bad" raises. + assert _build_urls(["good", "bad", "dup"], 80) == [ + ("10.0.0.1", f"http://10.0.0.1:80{EVENTS_PATH}") + ] + assert calls == ["good", "bad", "dup"] + + +# --------------------------------------------------------------------------- +# _consume (SSE parsing) +# --------------------------------------------------------------------------- + + +def test_consume_emits_only_log_frames( + monkeypatch: pytest.MonkeyPatch, fake_parser: MagicMock +) -> None: + """Only event: log data lines are printed; ping/state/comments are ignored.""" + printed: list[str] = [] + monkeypatch.setattr(web_server_logs, "safe_print", printed.append) + + _consume(_FakeResponse(200, SSE_LINES), fake_parser) + + assert printed == [ + "\x1b[0;32m[I][main:001]: hello\x1b[0m", + "line one", + "line two", + ] + + +def test_consume_ignores_unterminated_trailing_frame( + monkeypatch: pytest.MonkeyPatch, fake_parser: MagicMock +) -> None: + """A log frame without its terminating blank line is not emitted.""" + printed: list[str] = [] + monkeypatch.setattr(web_server_logs, "safe_print", printed.append) + + _consume(_FakeResponse(200, ["event: log", "data: dangling"]), fake_parser) + + assert printed == [] + + +# --------------------------------------------------------------------------- +# _stream +# --------------------------------------------------------------------------- + + +def test_stream_returns_false_when_connect_fails( + monkeypatch: pytest.MonkeyPatch, + fake_parser: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A failed connection logs a warning and reports not-connected.""" + + def boom(*args: object, **kwargs: object) -> _FakeResponse: + raise requests.ConnectionError("refused") + + monkeypatch.setattr(requests, "get", boom) + + with caplog.at_level(logging.WARNING): + assert ( + _stream("http://10.0.0.1:80/events", "10.0.0.1", None, fake_parser) is False + ) + assert "Could not connect to 10.0.0.1" in caplog.text + + +def test_stream_returns_true_when_established_then_dropped( + monkeypatch: pytest.MonkeyPatch, + fake_parser: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A mid-stream drop after connecting reports connected so we reconnect.""" + printed: list[str] = [] + monkeypatch.setattr(web_server_logs, "safe_print", printed.append) + + class _DroppingResponse(_FakeResponse): + def iter_lines(self) -> Iterator[bytes]: + yield b"event: log" + yield b"data: before-drop" + yield b"" + raise requests.exceptions.ChunkedEncodingError("connection lost") + + monkeypatch.setattr(requests, "get", lambda *a, **kw: _DroppingResponse(200, [])) + + with caplog.at_level(logging.INFO): + assert ( + _stream("http://10.0.0.1:80/events", "10.0.0.1", None, fake_parser) is True + ) + assert printed == ["before-drop"] + assert "reconnecting" in caplog.text + + +# --------------------------------------------------------------------------- +# run_logs +# --------------------------------------------------------------------------- + + +def test_run_logs_streams_then_reconnects_until_interrupt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A dropped stream reconnects; KeyboardInterrupt during the pause exits 0.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + printed: list[str] = [] + monkeypatch.setattr(web_server_logs, "safe_print", printed.append) + monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(200, SSE_LINES)) + + def stop(_delay: float) -> None: + raise KeyboardInterrupt + + monkeypatch.setattr(web_server_logs.time, "sleep", stop) + + assert run_logs(["dev.local"], 80, None, None) == 0 + # The single stream was consumed before the reconnect pause interrupted us. + # run_logs renders through the real LogParser, which prefixes a timestamp, + # so assert on the payloads rather than exact equality. + assert len(printed) == 3 + assert "[I][main:001]: hello" in printed[0] + assert "line one" in printed[1] + assert "line two" in printed[2] + + +def test_run_logs_passes_basic_auth(monkeypatch: pytest.MonkeyPatch) -> None: + """Username + password are forwarded as HTTP Basic auth on the request.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + monkeypatch.setattr(web_server_logs, "safe_print", lambda line: None) + captured: dict[str, object] = {} + + def fake_get(url: str, **kwargs: object) -> _FakeResponse: + captured.update(kwargs) + captured["url"] = url + return _FakeResponse(200, SSE_LINES) + + monkeypatch.setattr(requests, "get", fake_get) + monkeypatch.setattr( + web_server_logs.time, + "sleep", + lambda _d: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + + assert run_logs(["dev.local"], 80, "admin", "secret") == 0 + auth = captured["auth"] + assert isinstance(auth, HTTPBasicAuth) + assert (auth.username, auth.password) == ("admin", "secret") + assert captured["stream"] is True + assert captured["headers"] == {"Accept": "text/event-stream"} + + +def test_run_logs_no_auth_when_credentials_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """No auth object is sent when username/password are not configured.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + monkeypatch.setattr(web_server_logs, "safe_print", lambda line: None) + captured: dict[str, object] = {} + + def fake_get(url: str, **kwargs: object) -> _FakeResponse: + captured.update(kwargs) + return _FakeResponse(200, SSE_LINES) + + monkeypatch.setattr(requests, "get", fake_get) + monkeypatch.setattr( + web_server_logs.time, + "sleep", + lambda _d: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + + assert run_logs(["dev.local"], 80, None, None) == 0 + assert captured["auth"] is None + + +def test_run_logs_raises_on_auth_failure(monkeypatch: pytest.MonkeyPatch) -> None: + """HTTP 401 aborts with a clear error rather than reconnecting forever.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(401, [])) + + with pytest.raises(WebServerLogsError, match="Authentication failed"): + run_logs(["dev.local"], 80, "admin", "bad") + + +def test_run_logs_retries_on_transient_status( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """A transient non-200 (e.g. 503) is logged and the loop retries.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(503, [])) + monkeypatch.setattr( + web_server_logs.time, + "sleep", + lambda _d: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + + with caplog.at_level(logging.ERROR): + assert run_logs(["dev.local"], 80, None, None) == 0 + assert "Unexpected HTTP 503" in caplog.text + + +@pytest.mark.parametrize("status", (403, 404)) +def test_run_logs_raises_on_permanent_status( + monkeypatch: pytest.MonkeyPatch, status: int +) -> None: + """A permanent 403/404 aborts instead of retrying the endpoint forever.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(status, [])) + + with pytest.raises(WebServerLogsError, match=str(status)): + run_logs(["dev.local"], 80, None, None) + + +def test_run_logs_backs_off_on_repeated_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Consecutive unreachable attempts grow the reconnect delay up to the cap.""" + monkeypatch.setattr(web_server_logs, "_build_urls", lambda hosts, port: []) + delays: list[float] = [] + + def record(delay: float) -> None: + delays.append(delay) + if len(delays) >= 4: + raise KeyboardInterrupt + + monkeypatch.setattr(web_server_logs.time, "sleep", record) + + assert run_logs(["dev.local"], 80, None, None) == 0 + # 1 -> 2 -> 4 -> 8 ... doubling, capped at MAX_RECONNECT_DELAY (10.0). + assert delays == [2.0, 4.0, 8.0, 10.0] + + +def test_run_logs_reports_unresolvable( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """When no host resolves, an error is logged and the loop pauses/retries.""" + monkeypatch.setattr(web_server_logs, "_build_urls", lambda hosts, port: []) + + # Let the first reconnect pause pass so the loop continues, then interrupt + # on the second so the retry path (the ``continue``) is exercised. + sleeps = {"n": 0} + + def sleep(_delay: float) -> None: + sleeps["n"] += 1 + if sleeps["n"] >= 2: + raise KeyboardInterrupt + + monkeypatch.setattr(web_server_logs.time, "sleep", sleep) + + with caplog.at_level(logging.ERROR): + assert run_logs(["dev.local"], 80, None, None) == 0 + assert sleeps["n"] == 2 + assert "Could not resolve" in caplog.text diff --git a/tests/unit_tests/test_web_server_ota.py b/tests/unit_tests/test_web_server_ota.py index 606905e36e..bde04f4db7 100644 --- a/tests/unit_tests/test_web_server_ota.py +++ b/tests/unit_tests/test_web_server_ota.py @@ -46,7 +46,7 @@ def _patch_resolve( for host, port in hosts ] monkeypatch.setattr( - "esphome.web_server_ota.resolve_ip_address", lambda *a, **kw: addr_infos + "esphome.web_server_helpers.resolve_ip_address", lambda *a, **kw: addr_infos ) @@ -475,7 +475,7 @@ def test_run_ota_resolution_failure( def _raise(*_args, **_kwargs): raise EsphomeError("dns failed") - monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _raise) + monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _raise) exit_code, host = run_ota(["does.not.exist"], 80, None, None, firmware) @@ -491,7 +491,7 @@ def test_run_ota_resolution_failure_dashboard_mode( def _raise(*_args, **_kwargs): raise EsphomeError("dns failed") - monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _raise) + monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _raise) monkeypatch.setattr(CORE, "dashboard", True) try: exit_code, host = run_ota(["does.not.exist"], 80, None, None, firmware) @@ -541,7 +541,7 @@ def test_run_ota_multiple_hosts_first_fails( def _resolve(host, port, address_cache=None): # noqa: ARG001 return addr_lookup[host] - monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _resolve) + monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _resolve) with patch( "esphome.web_server_ota.requests.post", @@ -570,7 +570,7 @@ def test_run_ota_all_hosts_return_failure_no_exception( def _resolve(host, port, address_cache=None): # noqa: ARG001 return addr_lookup[host] - monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _resolve) + monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _resolve) exit_code, host = run_ota(["a.local", "b.local"], 80, None, None, firmware) @@ -633,7 +633,7 @@ def test_run_ota_ipv6_url_brackets_host( (socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("2001:db8::1", 80, 0, 0)), ] monkeypatch.setattr( - "esphome.web_server_ota.resolve_ip_address", lambda *a, **kw: addr_infos + "esphome.web_server_helpers.resolve_ip_address", lambda *a, **kw: addr_infos ) with patch( @@ -656,7 +656,7 @@ def test_run_ota_ipv6_link_local_includes_scope_id( (socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("fe80::1", 80, 0, 3)), ] monkeypatch.setattr( - "esphome.web_server_ota.resolve_ip_address", lambda *a, **kw: addr_infos + "esphome.web_server_helpers.resolve_ip_address", lambda *a, **kw: addr_infos ) with patch( From 3e4661fe1e96a3546fa53d9fa4b00db8c26c4c83 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:53:42 +1200 Subject: [PATCH 1428/1815] Bump version to 2026.8.0b1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 3bb08e5b06..006f97acb7 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.8.0-dev +PROJECT_NUMBER = 2026.8.0b1 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index a3e9f47909..623d9673bc 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.8.0-dev" +__version__ = "2026.8.0b1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 8a1aa5753d45c9819940b9cbaba2f0897c6f16cd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:53:42 +1200 Subject: [PATCH 1429/1815] Bump version to 2026.9.0-dev --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 3bb08e5b06..8f6048b4d8 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.8.0-dev +PROJECT_NUMBER = 2026.9.0-dev # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index a3e9f47909..0dd948544f 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.8.0-dev" +__version__ = "2026.9.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 3c46cc9c3572d4bf70026559d95eb96f49096759 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 09:59:24 -0500 Subject: [PATCH 1430/1815] [usb_uart] Fix uint32_t format specifier warning in pl2303 (#18310) --- esphome/components/usb_uart/pl2303.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index 3c7ecd9a83..c56f43f75a 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -292,8 +292,8 @@ bool USBUartTypePL2303::config_step(USBUartChannel *channel, uint8_t step, bool // Data bits line_coding[6] = channel->get_data_bits(); - ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%u stop=%u parity=%u data=%u", baud, line_coding[4], line_coding[5], - line_coding[6]); + ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%" PRIu32 " stop=%u parity=%u data=%u", baud, line_coding[4], + line_coding[5], line_coding[6]); std::vector lc_vec(line_coding, line_coding + 7); this->config_transfer_(SET_LINE_REQUEST_TYPE, SET_LINE_REQUEST, 0, iface, lc_vec); From 1a01c34ec4fed020264e69e78f08ad3f29af8bad Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:33:45 +0000 Subject: [PATCH 1431/1815] Bump aioesphomeapi from 45.10.0 to 45.10.1 (#18318) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9c231bd0fe..85a0f55263 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.10.0 +aioesphomeapi==45.10.1 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From f2121130f971b63fd6776dcd00d8befe0fea2aee Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 12 Aug 2026 12:49:54 -0400 Subject: [PATCH 1432/1815] [sendspin] Bump sendspin-cpp to v0.7.2 (#18316) --- esphome/components/sendspin/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index bd889c2c92..082639374f 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -234,7 +234,7 @@ async def to_code(config: ConfigType) -> None: psram.request_external_task_stack() # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.1") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.2") cg.add_define("USE_SENDSPIN", True) # for MDNS diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 6a9d7171ec..aff1a6819f 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -98,7 +98,7 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.7.1 + version: 0.7.2 lvgl/lvgl: version: 9.5.0 fastled/FastLED: From 99677390e04468438ec2a908bf431a6f16a63bae Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:27:14 -0500 Subject: [PATCH 1433/1815] Bump bundled esphome-device-builder to 1.9.6 (#18328) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index a4f5d3c3a6..d7ae2cd4ec 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.9.5 +RUN uv pip install --no-cache-dir esphome-device-builder==1.9.6 RUN \ platformio settings set enable_telemetry No \ From 905485b6738213f33e1663ae3ab56cf8a3281289 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 18:49:34 -0500 Subject: [PATCH 1434/1815] [ld2420] Fix out-of-bounds read when device reports unknown command error (#18322) --- esphome/components/ld2420/ld2420.cpp | 9 ++++++++- esphome/components/ld2420/ld2420.h | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/esphome/components/ld2420/ld2420.cpp b/esphome/components/ld2420/ld2420.cpp index ae622cda28..f71bec7e5f 100644 --- a/esphome/components/ld2420/ld2420.cpp +++ b/esphome/components/ld2420/ld2420.cpp @@ -746,7 +746,14 @@ void LD2420Component::set_reg_value(uint16_t reg, uint16_t value) { this->send_cmd_from_array(cmd_frame); } -void LD2420Component::handle_cmd_error(uint8_t error) { ESP_LOGE(TAG, "Command failed: %s", ERR_MESSAGE[error]); } +void LD2420Component::handle_cmd_error(uint16_t error) { + if (error < std::size(ERR_MESSAGE)) { + ESP_LOGE(TAG, "Command failed: %s", ERR_MESSAGE[error]); + } else { + // The error word comes from the device reply frame; unknown codes must not index ERR_MESSAGE + ESP_LOGE(TAG, "Command failed: error 0x%04X", error); + } +} int LD2420Component::get_gate_threshold_(uint8_t gate) { uint8_t error; diff --git a/esphome/components/ld2420/ld2420.h b/esphome/components/ld2420/ld2420.h index ae44b16065..977ee2eccc 100644 --- a/esphome/components/ld2420/ld2420.h +++ b/esphome/components/ld2420/ld2420.h @@ -108,7 +108,7 @@ class LD2420Component final : public Component, public uart::UARTDevice { float get_setup_priority() const override; int send_cmd_from_array(CmdFrameT cmd_frame); void report_gate_data(); - void handle_cmd_error(uint8_t error); + void handle_cmd_error(uint16_t error); void set_operating_mode(const char *state); void auto_calibrate_sensitivity(); void update_radar_data(uint16_t const *gate_energy, uint8_t sample_number); From 787a909aa49df808ed1d55f77d9dea0e60e6e4ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 19:11:32 -0500 Subject: [PATCH 1435/1815] [core] Don't block logs startup on MQTT IP discovery when addresses are known (#18313) --- esphome/__main__.py | 144 +++++++++---- esphome/api_client.py | 87 +++++++- esphome/mqtt.py | 91 ++++++-- tests/unit_tests/test_api_client.py | 323 +++++++++++++++++++++++++++- tests/unit_tests/test_main.py | 171 +++++++++++---- tests/unit_tests/test_mqtt.py | 262 ++++++++++++++++++++++ 6 files changed, 982 insertions(+), 96 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 0ac5898268..1262a4525e 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -10,7 +10,7 @@ from pathlib import Path import re import sys import time -from typing import Protocol +from typing import TYPE_CHECKING, Protocol # Note: Do not import modules from esphome.components here, as this would # cause them to be loaded before external components are processed, resulting @@ -71,6 +71,9 @@ from esphome.util import ( safe_print, ) +if TYPE_CHECKING: + import threading + # Keep expensive imports (zeroconf, writer, yaml_util, etc.) out of this # module's top level. Every `esphome` invocation — including fast paths # like `esphome version` — pays the cost of what's imported here before @@ -567,11 +570,48 @@ def has_name_add_mac_suffix() -> bool: def mqtt_get_ip( - config: ConfigType, username: str, password: str, client_id: str + config: ConfigType, + username: str, + password: str, + client_id: str, + stop_event: "threading.Event | None" = None, ) -> list[str]: from esphome import mqtt - return mqtt.get_esphome_device_ip(config, username, password, client_id) + return mqtt.get_esphome_device_ip( + config, username, password, client_id, stop_event=stop_event + ) + + +def _add_network_device(device: str, network_devices: list[str]) -> None: + """Append a device to the list, expanding it through ``CORE.address_cache``. + + If the hostname is already in the address cache (e.g. populated by mDNS + discovery), substitute the cached IPs so aioesphomeapi doesn't open its + own Zeroconf to re-resolve it. Duplicates are dropped. + """ + if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)): + network_devices.extend(addr for addr in cached if addr not in network_devices) + elif device not in network_devices: + network_devices.append(device) + + +def _split_network_devices(devices: list[str]) -> tuple[list[str], bool]: + """Split the device list into direct addresses and an MQTT-lookup flag. + + Direct addresses are expanded through ``CORE.address_cache`` and deduped + the same way ``_resolve_network_devices`` does; MQTT/MQTTIP magic strings + are not resolved, only reported via the returned bool so the caller can + defer the broker lookup. + """ + network_devices: list[str] = [] + has_mqtt_lookup = False + for device in devices: + if get_port_type(device) in _MQTT_PORT_TYPES: + has_mqtt_lookup = True + else: + _add_network_device(device, network_devices) + return network_devices, has_mqtt_lookup def _resolve_network_devices( @@ -604,40 +644,44 @@ def _resolve_network_devices( if port_type in _MQTT_PORT_TYPES: # Only resolve MQTT once, even if multiple MQTT entries if not mqtt_resolved: - try: - mqtt_ips = mqtt_get_ip( - config, args.username, args.password, args.client_id - ) - # pylint can't infer mqtt_get_ip's return through its - # lazy ``from esphome import mqtt`` import, so it flags - # the genexpr below. - network_devices.extend( - addr - for addr in mqtt_ips # pylint: disable=not-an-iterable - if addr not in network_devices - ) - except EsphomeError as err: - _LOGGER.warning( - "MQTT IP discovery failed (%s), will try other devices if available", - err, - ) + mqtt_ips = _mqtt_get_ip_or_warn( + config, args.username, args.password, args.client_id + ) + network_devices.extend( + addr for addr in mqtt_ips if addr not in network_devices + ) mqtt_resolved = True continue - # If the hostname is already in the address cache (e.g. populated by - # mDNS discovery), substitute the cached IPs so aioesphomeapi doesn't - # open its own Zeroconf to re-resolve it. - if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)): - network_devices.extend( - addr for addr in cached if addr not in network_devices - ) - elif device not in network_devices: - # Regular network address or IP - add if not already present - network_devices.append(device) + _add_network_device(device, network_devices) return network_devices +def _mqtt_get_ip_or_warn( + config: ConfigType, + username: str, + password: str, + client_id: str, + stop_event: "threading.Event | None" = None, +) -> list[str]: + """Look up the device IP via MQTT, returning [] with a warning on failure. + + This owns the failure policy for MQTT IP discovery on paths that have + other addresses to fall back on: a broker problem must not abort the + operation. Also used as the deferred resolver handed to ``run_logs``, + where it runs in a worker thread. + """ + try: + return mqtt_get_ip(config, username, password, client_id, stop_event=stop_event) + except EsphomeError as err: + _LOGGER.warning( + "MQTT IP discovery failed (%s), will try other devices if available", + err, + ) + return [] + + def run_miniterm(config: ConfigType, port: str, args) -> int: from datetime import datetime @@ -1438,17 +1482,37 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int return run_miniterm(config, port, args) # Check if we should use API for logging - # Resolve MQTT magic strings to actual IP addresses - if has_api() and ( - network_devices := _resolve_network_devices(devices, config, args) - ): - from esphome.api_client import run_logs + if has_api(): + network_devices, has_mqtt_lookup = _split_network_devices(devices) + mqtt_resolver = None + if has_mqtt_lookup: + if network_devices: + # Addresses are already known, so don't block startup on the + # MQTT broker lookup; hand it to run_logs as a deferred + # resolver that runs in the background and feeds discovered + # addresses into the running log client, keeping MQTT as a + # fallback for when the known addresses are stale (e.g. DHCP + # reassigned the IP). + mqtt_resolver = functools.partial( + _mqtt_get_ip_or_warn, + config, + args.username, + args.password, + args.client_id, + ) + else: + # The MQTT lookup is the only way to find the device; resolve + # it up front since the client needs an address to start with. + network_devices = _resolve_network_devices(devices, config, args) + if network_devices: + from esphome.api_client import run_logs - return run_logs( - config, - network_devices, - subscribe_states=_should_subscribe_states(args), - ) + return run_logs( + config, + network_devices, + subscribe_states=_should_subscribe_states(args), + mqtt_resolver=mqtt_resolver, + ) if port_type in (PortType.NETWORK, PortType.MQTT) and has_mqtt_logging(): from esphome import mqtt diff --git a/esphome/api_client.py b/esphome/api_client.py index a75f219b17..fb41075de8 100644 --- a/esphome/api_client.py +++ b/esphome/api_client.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from contextlib import suppress import logging +import threading from typing import TYPE_CHECKING, Any import warnings @@ -20,6 +21,8 @@ from esphome.stacktrace import LogLineProcessor from esphome.util import safe_print if TYPE_CHECKING: + from collections.abc import Callable + from aioesphomeapi.api_pb2 import ( SubscribeLogsResponse, # pylint: disable=no-name-in-module ) @@ -32,8 +35,18 @@ async def async_run_logs( config: dict[str, Any], addresses: list[str], subscribe_states: bool = True, + mqtt_resolver: Callable[[threading.Event], list[str]] | None = None, ) -> None: - """Run the logs command in the event loop.""" + """Run the logs command in the event loop. + + If ``mqtt_resolver`` is given, it is called in a worker thread (paho-mqtt + has no asyncio support on Windows) concurrently with the connection + attempts to ``addresses``, and any addresses it discovers are fed into + the running client. It owns its own failure handling (returning [] when + discovery fails) and must honor the ``threading.Event`` it is passed so + teardown is not delayed by the lookup's wait window; the initial broker + connect itself is only bounded by the socket timeout. + """ from datetime import datetime conf = config["api"] @@ -60,6 +73,41 @@ async def async_run_logs( # Decoder resolution policy lives in LogLineProcessor. processor = LogLineProcessor(config, CORE.target_platform) + mqtt_task: asyncio.Task[None] | None = None + mqtt_stop_event = threading.Event() + + def _cancel_mqtt_discovery() -> None: + """Stop the broker lookup once a connection has been established. + + Its answer is only useful while still disconnected: after that it + either duplicates the connected address or arrives too late to + matter, so don't keep an idle broker session open for it. + """ + mqtt_stop_event.set() + if mqtt_task is not None and not mqtt_task.done(): + mqtt_task.cancel() + + async def _resolve_mqtt_addresses() -> None: + """Discover the device address via the MQTT broker in the background.""" + try: + mqtt_ips = await asyncio.to_thread(mqtt_resolver, mqtt_stop_event) + if not mqtt_ips: + _LOGGER.debug( + "MQTT discovery %s", + "aborted" if mqtt_stop_event.is_set() else "found no addresses", + ) + return + if cli.add_addresses(mqtt_ips): + _LOGGER.info("Discovered address(es) via MQTT: %s", ", ".join(mqtt_ips)) + else: + _LOGGER.debug( + "MQTT-discovered address(es) already known: %s", ", ".join(mqtt_ips) + ) + except Exception: # pylint: disable=broad-except + # A background task failure would otherwise stay invisible for + # the whole session and only re-raise at teardown + _LOGGER.exception("MQTT address discovery failed") + def on_log(msg: SubscribeLogsResponse) -> None: """Handle a new log message.""" time_ = datetime.now().astimezone() @@ -98,20 +146,53 @@ async def async_run_logs( # A top-level ``deep_sleep:`` block means the device is only awake # briefly; cap the reconnect backoff so a wake window is not missed. deep_sleep="deep_sleep" in config, + on_connect=_cancel_mqtt_discovery if mqtt_resolver is not None else None, ) try: + # Don't start (or keep) the broker lookup if a connection already + # succeeded; the stop event doubles as the not-needed-anymore latch + # and get_esphome_device_ip returns immediately when it is set. + if mqtt_resolver is not None and not mqtt_stop_event.is_set(): + mqtt_task = asyncio.create_task(_resolve_mqtt_addresses()) await asyncio.Event().wait() finally: - await stop() + try: + if mqtt_task is not None: + # Unblock the worker thread first so it can't hold up + # loop.shutdown_default_executor() for the full lookup timeout. + mqtt_stop_event.set() + # Give the worker a moment to exit through its own error + # handling; cancelling first would race out a late failure. + done, _ = await asyncio.wait([mqtt_task], timeout=1.0) + if not done: + mqtt_task.cancel() + # return_exceptions keeps a CancelledError from the cancel() + # above from re-raising here and jumping over the stop() below. + # The task handles Exception itself, so only a BaseException + # escape (e.g. SystemExit from the worker) can land here. + (result,) = await asyncio.gather(mqtt_task, return_exceptions=True) + if isinstance(result, BaseException) and not isinstance( + result, asyncio.CancelledError + ): + _LOGGER.error("MQTT address discovery failed", exc_info=result) + finally: + # Must run even if a second cancellation lands mid-cleanup above + await stop() def run_logs( config: dict[str, Any], addresses: list[str], subscribe_states: bool = True, + mqtt_resolver: Callable[[threading.Event], list[str]] | None = None, ) -> None: """Run the logs command.""" with suppress(KeyboardInterrupt): asyncio.run( - async_run_logs(config, addresses, subscribe_states=subscribe_states) + async_run_logs( + config, + addresses, + subscribe_states=subscribe_states, + mqtt_resolver=mqtt_resolver, + ) ) diff --git a/esphome/mqtt.py b/esphome/mqtt.py index 3198de9d21..62deafb09a 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -6,6 +6,7 @@ from pathlib import Path import ssl import tempfile import time +from typing import TYPE_CHECKING import paho.mqtt.client as mqtt @@ -31,6 +32,9 @@ from esphome.helpers import get_int_env, get_str_env from esphome.types import ConfigType from esphome.util import safe_print +if TYPE_CHECKING: + import threading + _LOGGER = logging.getLogger(__name__) @@ -164,6 +168,7 @@ def get_esphome_device_ip( password: str | None = None, client_id: str | None = None, timeout: float = 25, + stop_event: "threading.Event | None" = None, ) -> list[str]: if CONF_MQTT not in config: raise EsphomeError( @@ -182,55 +187,113 @@ def get_esphome_device_ip( dev_name = config[CONF_ESPHOME][CONF_NAME] dev_ip = None + failed = False topic = "esphome/discover/" + dev_name _LOGGER.info("Starting looking for IP in topic %s", topic) def on_message(client, userdata, msg): - nonlocal dev_ip + nonlocal dev_ip, failed time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]") payload = msg.payload.decode(errors="backslashreplace") if len(payload) > 0: message = time_ + " " + payload _LOGGER.debug(message) - data = json.loads(payload) + try: + data = json.loads(payload) + except ValueError: + data = None + if not isinstance(data, dict): + # A raise in this handler would kill paho's network thread + _LOGGER.warning("Ignoring unparsable discovery payload") + return if "name" not in data or data["name"] != dev_name: _LOGGER.warning("Wrong device answer") return - dev_ip = [] + addresses = [] key = "ip" n = 0 while key in data: - dev_ip.append(data[key]) + value = data[key] + if ( + isinstance(value, str) + and (value := value.strip()) + and value.isprintable() + ): + addresses.append(value) + else: + # repr-escaped and truncated: must not forge log lines + _LOGGER.warning( + "Ignoring invalid address in discovery answer: %s", + repr(value)[:100], + ) n = n + 1 key = "ip" + str(n) - if dev_ip: - client.disconnect() + if not addresses: + _LOGGER.warning("Device answer did not include an IP address") + failed = True + return + + dev_ip = addresses + failed = False # a complete answer wins over an earlier empty one + client.disconnect() def on_connect(client, userdata, flags, return_code): topic = "esphome/ping/" + dev_name _LOGGER.info("Send discover via MQTT broker topic: %s", topic) client.publish(topic, None, retain=False) + if stop_event is not None and stop_event.is_set(): + # Teardown already started; don't open a broker connection at all + return [] + + def on_disconnect(client, userdata, result_code): + nonlocal failed + if result_code != 0: + _LOGGER.warning("Disconnected from MQTT broker (%s)", result_code) + failed = True + mqtt_client = prepare( config, [topic], on_message, on_connect, username, password, client_id ) + # Discovery is one-shot; prepare()'s reconnect-forever on_disconnect runs + # on the network thread and would make loop_stop() below join forever. + mqtt_client.on_disconnect = on_disconnect - mqtt_client.loop_start() - while timeout > 0: - if dev_ip is not None: - break - timeout -= 0.250 - time.sleep(0.250) - mqtt_client.loop_stop() + if stop_event is None: + import threading + + stop_event = threading.Event() # never set; wait() below is a plain sleep + stopped = stop_event.is_set() # teardown may have started during connect + try: + if not stopped: + mqtt_client.loop_start() + while timeout > 0: + if dev_ip is not None or failed: + break + if stop_event.wait(0.250): + stopped = True + break + timeout -= 0.250 + finally: + # A cleanup failure must not replace the discovery result or its + # EsphomeError; a second disconnect after on_message's is harmless. + try: + mqtt_client.disconnect() + except Exception: # pylint: disable=broad-except + _LOGGER.debug("Error disconnecting from MQTT broker", exc_info=True) + mqtt_client.loop_stop() # only signals and joins; does not raise if dev_ip is None: + if stopped: + # Aborted by the caller, not a failure; stay quiet + return [] raise EsphomeError("Failed to find IP via MQTT") - _LOGGER.info("Found IP: %s", dev_ip) + _LOGGER.info("Found IP via MQTT broker: %s", ", ".join(dev_ip)) return dev_ip diff --git a/tests/unit_tests/test_api_client.py b/tests/unit_tests/test_api_client.py index 19ed83abe1..405567d84f 100644 --- a/tests/unit_tests/test_api_client.py +++ b/tests/unit_tests/test_api_client.py @@ -56,7 +56,7 @@ async def test_async_run_logs_full_flow(caplog) -> None: with ( patch.object(api_client, "async_run", mock_run), - patch.object(api_client, "APIClient") as mock_client, + patch.object(api_client, "APIClient", autospec=True) as mock_client, patch.object(api_client, "safe_print", printed.append), ): task = asyncio.get_running_loop().create_task( @@ -163,3 +163,324 @@ async def test_async_run_logs_passes_deep_sleep( await api_client.async_run_logs(config, ["1.2.3.4"]) assert mock_run.call_args.kwargs["deep_sleep"] is expected_deep_sleep + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_feeds_addresses(caplog) -> None: + """Addresses discovered via MQTT are fed into the running client.""" + caplog.set_level("INFO", logger="esphome.api_client") + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + fed = asyncio.Event() + + def resolver(stop_event): + return ["10.0.0.9", "10.0.0.10"] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True) as mock_client, + ): + mock_client.return_value.add_addresses.side_effect = lambda addrs: ( + fed.set() or True + ) + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + async with asyncio.timeout(1): + await fed.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + mock_client.return_value.add_addresses.assert_called_once_with( + ["10.0.0.9", "10.0.0.10"] + ) + assert "Discovered address(es) via MQTT" in caplog.text + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_no_addresses_keeps_running() -> None: + """A resolver returning nothing (failed lookup) leaves the session running.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver_ran = threading.Event() + + def resolver(stop_event): + # The resolver owns failure handling; a failed lookup returns [] + resolver_ran.set() + return [] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True) as mock_client, + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_ran.wait, 1) + await asyncio.sleep(0) + assert not task.done() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + mock_client.return_value.add_addresses.assert_not_called() + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_stopped_on_teardown() -> None: + """Teardown sets the resolver's stop event so the thread exits promptly.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + captured_event: threading.Event | None = None + resolver_started = threading.Event() + + def resolver(stop_event): + nonlocal captured_event + captured_event = stop_event + resolver_started.set() + # Simulate a slow broker lookup that only ends via the stop event. + stop_event.wait(timeout=5) + return [] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_started.wait, 1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert captured_event is not None + assert captured_event.is_set() + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_crash_still_stops_cleanly(caplog) -> None: + """A resolver raising unexpectedly must not skip stop() at teardown.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver_ran = threading.Event() + + def resolver(stop_event): + resolver_ran.set() + raise RuntimeError("resolver blew up") + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_ran.wait, 1) + await asyncio.sleep(0.05) + assert not task.done() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert "MQTT address discovery failed" in caplog.text + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_connect_cancels_mqtt_discovery() -> None: + """A successful connection stops the in-flight broker lookup.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + captured_event: threading.Event | None = None + resolver_started = threading.Event() + + def resolver(stop_event): + nonlocal captured_event + captured_event = stop_event + resolver_started.set() + stop_event.wait(timeout=5) + return [] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)) as mock_run, + patch.object(api_client, "APIClient", autospec=True) as mock_client, + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_started.wait, 1) + + # The runner reports a successful connection + on_connect = mock_run.call_args.kwargs["on_connect"] + on_connect() + await asyncio.sleep(0.05) + + assert captured_event is not None + assert captured_event.is_set() + assert not task.done() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + mock_client.return_value.add_addresses.assert_not_called() + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_connect_before_discovery_skips_lookup() -> None: + """A connection during async_run startup prevents the lookup from starting.""" + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver = Mock(name="resolver") + + async def fake_async_run(*args, **kwargs): + # Connection succeeds before async_run even returns + kwargs["on_connect"]() + return stop + + with ( + patch.object(api_client, "async_run", AsyncMock(side_effect=fake_async_run)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.sleep(0.05) + assert not task.done() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + resolver.assert_not_called() + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_duplicate_addresses_logged(caplog) -> None: + """A discovery the client rejects as already known leaves a debug trace.""" + import threading + + caplog.set_level("DEBUG", logger="esphome.api_client") + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + fed = threading.Event() + + def resolver(stop_event): + return ["1.2.3.4"] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True) as mock_client, + ): + mock_client.return_value.add_addresses.side_effect = lambda addrs: ( + fed.set() or False + ) + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(fed.wait, 1) + await asyncio.sleep(0.05) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + mock_client.return_value.add_addresses.assert_called_once_with(["1.2.3.4"]) + assert "MQTT-discovered address(es) already known: 1.2.3.4" in caplog.text + assert "Discovered address(es) via MQTT" not in caplog.text + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_base_exception_escape_logged_at_teardown(caplog) -> None: + """A BaseException escaping the worker is reported, and stop() still runs.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver_ran = threading.Event() + + class WorkerEscape(BaseException): + """Not an Exception, so the task-level guard must not catch it.""" + + def resolver(stop_event): + resolver_ran.set() + raise WorkerEscape("worker bailed") + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_ran.wait, 1) + await asyncio.sleep(0.05) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert "MQTT address discovery failed" in caplog.text + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_stubborn_worker_cancelled_at_teardown() -> None: + """A worker that ignores the stop event is cancelled after the grace period.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver_ran = threading.Event() + release = threading.Event() + + def resolver(stop_event): + resolver_ran.set() + # Ignore stop_event entirely; only the test releases us + release.wait(timeout=10) + return [] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_ran.wait, 1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + release.set() + + stop.assert_awaited_once() diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 23bfdbcd69..a40341e194 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -25,6 +25,7 @@ from esphome.__main__ import ( _make_crystal_freq_callback, _redact_with_legacy_fallback, _resolve_network_devices, + _split_network_devices, _unresolved_default_error, _validate_bootloader_binary, _validate_partition_table_binary, @@ -2879,7 +2880,9 @@ def test_upload_program_ota_with_mqtt_resolution( assert exit_code == 0 assert host == "192.168.1.100" - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) expected_firmware = ( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) @@ -2926,7 +2929,9 @@ def test_upload_program_ota_with_mqtt_empty_broker( assert exit_code == 0 assert host == "192.168.1.50" # Verify MQTT was attempted but failed gracefully - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify we fell back to the IP address expected_firmware = ( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" @@ -3015,7 +3020,10 @@ def test_show_logs_api( assert result == 0 mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100", "192.168.1.101"], subscribe_states=True + CORE.config, + ["192.168.1.100", "192.168.1.101"], + subscribe_states=True, + mqtt_resolver=None, ) @@ -3042,7 +3050,7 @@ def test_show_logs_api_no_states( assert result == 0 mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100"], subscribe_states=False + CORE.config, ["192.168.1.100"], subscribe_states=False, mqtt_resolver=None ) @@ -3069,7 +3077,7 @@ def test_show_logs_api_with_fqdn_mdns_disabled( assert result == 0 # Should use the FQDN directly, not try MQTT lookup mock_run_logs.assert_called_once_with( - CORE.config, ["device.example.com"], subscribe_states=True + CORE.config, ["device.example.com"], subscribe_states=True, mqtt_resolver=None ) @@ -3097,9 +3105,44 @@ def test_show_logs_api_with_mqtt_fallback( result = show_logs(CORE.config, args, devices) assert result == 0 - mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None + ) mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.200"], subscribe_states=True + CORE.config, ["192.168.1.200"], subscribe_states=True, mqtt_resolver=None + ) + + +@patch("esphome.mqtt.show_logs") +def test_show_logs_api_mqtt_only_resolve_failure_falls_back_to_mqtt_logs( + mock_mqtt_show_logs: Mock, + mock_mqtt_get_ip: Mock, +) -> None: + """With no addresses at all after a failed MQTT lookup, MQTT logging is used.""" + setup_core( + config={ + "logger": {}, + CONF_API: {}, + CONF_MQTT: {CONF_BROKER: "mqtt.local"}, + }, + platform=PLATFORM_ESP32, + ) + mock_mqtt_show_logs.return_value = 0 + mock_mqtt_get_ip.side_effect = EsphomeError("Failed to find IP via MQTT") + + args = MockArgs( + topic="esphome/logs", username="user", password="pass", client_id="client" + ) + devices = ["MQTT", "MQTTIP"] + + result = show_logs(CORE.config, args, devices) + + assert result == 0 + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None + ) + mock_mqtt_show_logs.assert_called_once_with( + CORE.config, "esphome/logs", "user", "pass", "client" ) @@ -3466,7 +3509,9 @@ def test_mqtt_get_ip() -> None: result = mqtt_get_ip(config, "user", "pass", "client-id") assert result == ["192.168.1.100", "192.168.1.101"] - mock_get_ip.assert_called_once_with(config, "user", "pass", "client-id") + mock_get_ip.assert_called_once_with( + config, "user", "pass", "client-id", stop_event=None + ) def test_has_resolvable_address() -> None: @@ -3847,6 +3892,37 @@ def test_resolve_network_devices_keeps_uncached_hosts(tmp_path: Path) -> None: assert result == ["unknown.local", "192.168.1.50"] +def test_split_network_devices_direct_only(tmp_path: Path) -> None: + """Direct addresses pass through deduped, with no MQTT flag.""" + setup_core(tmp_path=tmp_path) + + assert _split_network_devices(["192.168.1.50", "device.local", "192.168.1.50"]) == ( + ["192.168.1.50", "device.local"], + False, + ) + + +def test_split_network_devices_mqtt_only(tmp_path: Path) -> None: + """MQTT magic strings produce no direct addresses, only the flag.""" + setup_core(tmp_path=tmp_path) + + assert _split_network_devices(["MQTTIP", "MQTT"]) == ([], True) + + +def test_split_network_devices_expands_cached_mdns_hosts(tmp_path: Path) -> None: + """Hostnames in ``CORE.address_cache`` are expanded like _resolve_network_devices.""" + setup_core(tmp_path=tmp_path) + CORE.address_cache = AddressCache( + mdns_cache={ + "device-abc123.local": ["10.0.0.1", "10.0.0.2"], + } + ) + + assert _split_network_devices( + ["device-abc123.local", "MQTTIP", "192.168.1.50", "device-abc123.local"] + ) == (["10.0.0.1", "10.0.0.2", "192.168.1.50"], True) + + def test_await_discovery_timeout_returns_empty( caplog: pytest.LogCaptureFixture, ) -> None: @@ -5022,7 +5098,9 @@ def test_upload_program_ota_static_ip_with_mqttip( assert host == "192.168.1.100" # Verify MQTT was resolved - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify espota2.run_ota was called with both IPs expected_firmware = ( @@ -5069,7 +5147,9 @@ def test_upload_program_ota_multiple_mqttip_resolves_once( assert host == "192.168.2.50" # Verify MQTT was only resolved once despite multiple MQTT magic strings - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify espota2.run_ota was called with all unique IPs expected_firmware = ( @@ -5116,7 +5196,9 @@ def test_upload_program_ota_mqttip_deduplication( assert host == "192.168.1.100" # Verify MQTT was resolved - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify espota2.run_ota was called with deduplicated IPs (only one instance of 192.168.1.100) # Note: Current implementation doesn't dedupe, so we'll get the IP twice @@ -5136,7 +5218,9 @@ def test_show_logs_api_static_ip_with_mqttip( This tests the scenario where a device has manual_ip (static IP) configured and MQTT is also configured. The devices list contains both the static IP - and "MQTTIP" magic string. + and "MQTTIP" magic string. The MQTT lookup must not block startup; it is + handed to run_logs as a deferred resolver instead (issue #18311), while + still being reachable as a fallback for a stale static IP. """ setup_core( config={ @@ -5157,12 +5241,19 @@ def test_show_logs_api_static_ip_with_mqttip( assert result == 0 - # Verify MQTT was resolved - mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") + # The broker must not be contacted before run_logs starts + mock_mqtt_get_ip.assert_not_called() - # Verify run_logs was called with both IPs - mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100", "192.168.2.50"], subscribe_states=True + # run_logs gets the static IP immediately plus a deferred MQTT resolver + mock_run_logs.assert_called_once() + assert mock_run_logs.call_args.args == (CORE.config, ["192.168.1.100"]) + assert mock_run_logs.call_args.kwargs["subscribe_states"] is True + resolver = mock_run_logs.call_args.kwargs["mqtt_resolver"] + + # Invoking the resolver performs the MQTT lookup (the #11260 fallback) + assert resolver(None) == ["192.168.2.50"] + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None ) @@ -5171,7 +5262,7 @@ def test_show_logs_api_multiple_mqttip_resolves_once( mock_run_logs: Mock, mock_mqtt_get_ip: Mock, ) -> None: - """Test that MQTT resolution only happens once for show_logs with multiple MQTT magic strings.""" + """Test that multiple MQTT magic strings collapse into one deferred resolver.""" setup_core( config={ "logger": {}, @@ -5191,16 +5282,16 @@ def test_show_logs_api_multiple_mqttip_resolves_once( assert result == 0 - # Verify MQTT was only resolved once despite multiple MQTT magic strings - mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") + # Note: "MQTT" is a different magic string from "MQTTIP", but both defer + # to the same single resolver; the broker is not contacted eagerly + mock_mqtt_get_ip.assert_not_called() + mock_run_logs.assert_called_once() + assert mock_run_logs.call_args.args == (CORE.config, ["192.168.1.100"]) - # Verify run_logs was called with all unique IPs (MQTT strings replaced with IPs) - # Note: "MQTT" is a different magic string from "MQTTIP", but both trigger MQTT resolution - # The _resolve_network_devices helper filters out both after first resolution - mock_run_logs.assert_called_once_with( - CORE.config, - ["192.168.2.50", "192.168.2.51", "192.168.1.100"], - subscribe_states=True, + resolver = mock_run_logs.call_args.kwargs["mqtt_resolver"] + assert resolver(None) == ["192.168.2.50", "192.168.2.51"] + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None ) @@ -5238,7 +5329,9 @@ def test_upload_program_ota_mqtt_timeout_fallback( assert host == "192.168.1.100" # Verify MQTT was attempted - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify espota2.run_ota was called with only the static IP (MQTT failed) expected_firmware = ( @@ -5254,7 +5347,7 @@ def test_show_logs_api_mqtt_timeout_fallback( mock_run_logs: Mock, mock_mqtt_get_ip: Mock, ) -> None: - """Test show_logs falls back to other devices when MQTT times out.""" + """Test show_logs proceeds with the static IP when MQTT times out.""" setup_core( config={ "logger": {}, @@ -5273,15 +5366,17 @@ def test_show_logs_api_mqtt_timeout_fallback( result = show_logs(CORE.config, args, devices) - # Should succeed using the static IP even though MQTT failed + # Logs start on the static IP without waiting for the broker assert result == 0 + mock_run_logs.assert_called_once() + assert mock_run_logs.call_args.args == (CORE.config, ["192.168.1.100"]) - # Verify MQTT was attempted - mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") - - # Verify run_logs was called with only the static IP (MQTT failed) - mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100"], subscribe_states=True + # The deferred resolver owns the failure policy: it logs a warning and + # returns no addresses so the session keeps running on the known ones + resolver = mock_run_logs.call_args.kwargs["mqtt_resolver"] + assert resolver(None) == [] + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None ) @@ -6764,7 +6859,7 @@ def test_command_run_passes_no_states_to_show_logs( assert result == 0 mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100"], subscribe_states=False + CORE.config, ["192.168.1.100"], subscribe_states=False, mqtt_resolver=None ) @@ -6805,7 +6900,7 @@ def test_command_run_defaults_subscribe_states_true( assert result == 0 mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100"], subscribe_states=True + CORE.config, ["192.168.1.100"], subscribe_states=True, mqtt_resolver=None ) diff --git a/tests/unit_tests/test_mqtt.py b/tests/unit_tests/test_mqtt.py index 4c2c34dff1..1ae10d0eb5 100644 --- a/tests/unit_tests/test_mqtt.py +++ b/tests/unit_tests/test_mqtt.py @@ -2,6 +2,11 @@ from __future__ import annotations +import json +import threading +import time +from unittest.mock import MagicMock, patch + import pytest from esphome.const import CONF_BROKER, CONF_ESPHOME, CONF_MQTT, CONF_NAME @@ -89,3 +94,260 @@ def test_get_esphome_device_ip_missing_name() -> None: match="Cannot discover IP via MQTT as the config does not include the device name:", ): get_esphome_device_ip(config) + + +def _discovery_config() -> dict: + return { + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + }, + CONF_ESPHOME: { + CONF_NAME: "test-device", + }, + } + + +def _deliver_on_loop_start(mock_prepare, client, payload: bytes) -> None: + """Deliver a discovery answer as soon as the network loop starts.""" + + def deliver(*args, **kwargs): + msg = MagicMock() + msg.payload = payload + mock_prepare.call_args.args[2](client, None, msg) + + client.loop_start.side_effect = deliver + + +def test_get_esphome_device_ip_success() -> None: + """A device answer on the discovery topic returns its IPs.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start( + mock_prepare, + client, + json.dumps( + {"name": "test-device", "ip": "10.0.0.5", "ip1": "10.0.0.6"} + ).encode(), + ) + + result = get_esphome_device_ip(_discovery_config()) + + assert result == ["10.0.0.5", "10.0.0.6"] + client.loop_stop.assert_called_once_with() + # Once from on_message on receiving the answer, once from the finally + assert client.disconnect.call_count == 2 + + +def test_get_esphome_device_ip_preset_stop_event_skips_lookup() -> None: + """A stop event set before the call returns [] without touching the broker.""" + stop_event = threading.Event() + stop_event.set() + + with patch("esphome.mqtt.prepare") as mock_prepare: + result = get_esphome_device_ip(_discovery_config(), stop_event=stop_event) + + assert result == [] + mock_prepare.assert_not_called() + + +def test_get_esphome_device_ip_stop_event_aborts_wait() -> None: + """A stop event set mid-wait exits quietly with no addresses.""" + stop_event = threading.Event() + client = MagicMock() + # Simulate teardown starting right after the network loop spins up + client.loop_start.side_effect = stop_event.set + + start = time.monotonic() + with patch("esphome.mqtt.prepare", return_value=client): + result = get_esphome_device_ip(_discovery_config(), stop_event=stop_event) + + # An abort is not a failure and must be nowhere near the 25s timeout + assert result == [] + assert time.monotonic() - start < 5 + client.disconnect.assert_called_once_with() + client.loop_stop.assert_called_once_with() + + +def test_get_esphome_device_ip_timeout_raises() -> None: + """No answer within the timeout raises EsphomeError (default stop event path).""" + client = MagicMock() + with ( + patch("esphome.mqtt.prepare", return_value=client), + pytest.raises(EsphomeError, match="Failed to find IP via MQTT"), + ): + get_esphome_device_ip(_discovery_config(), timeout=0.25) + + client.disconnect.assert_called_once_with() + client.loop_stop.assert_called_once_with() + + +def test_get_esphome_device_ip_stop_during_connect_skips_wait() -> None: + """A stop event set while the broker connect is in flight still cleans up.""" + stop_event = threading.Event() + client = MagicMock() + + def prepare_and_stop(*args): + stop_event.set() + return client + + with patch("esphome.mqtt.prepare", side_effect=prepare_and_stop): + result = get_esphome_device_ip(_discovery_config(), stop_event=stop_event) + + assert result == [] + client.loop_start.assert_not_called() + client.disconnect.assert_called_once_with() + client.loop_stop.assert_called_once_with() + + +def test_get_esphome_device_ip_replaces_reconnect_handler( + caplog: pytest.LogCaptureFixture, +) -> None: + """The one-shot discovery client must not inherit the reconnect-forever + handler, which would make loop_stop() join the network thread forever; + its replacement still reports a broker-initiated disconnect.""" + client = MagicMock() + prepare_handler = MagicMock() + client.on_disconnect = prepare_handler + + with ( + patch("esphome.mqtt.prepare", return_value=client), + pytest.raises(EsphomeError, match="Failed to find IP via MQTT"), + ): + get_esphome_device_ip(_discovery_config(), timeout=0.25) + + assert client.on_disconnect is not prepare_handler + client.on_disconnect(client, None, 0) + assert "Disconnected from MQTT broker" not in caplog.text + client.on_disconnect(client, None, 5) + assert "Disconnected from MQTT broker (5)" in caplog.text + + +def test_get_esphome_device_ip_answer_without_ip_fails_fast( + caplog: pytest.LogCaptureFixture, +) -> None: + """A device answer with no IP fields fails promptly, not at the timeout.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start( + mock_prepare, client, json.dumps({"name": "test-device"}).encode() + ) + + start = time.monotonic() + with pytest.raises(EsphomeError, match="Failed to find IP via MQTT"): + get_esphome_device_ip(_discovery_config(), timeout=5) + + assert time.monotonic() - start < 1 + assert "Device answer did not include an IP address" in caplog.text + + +@pytest.mark.parametrize("payload", [b"not json {", b"123", b"null"]) +def test_get_esphome_device_ip_unparsable_payload_ignored( + caplog: pytest.LogCaptureFixture, + payload: bytes, +) -> None: + """Garbage on the discovery topic must not kill paho's network thread.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start(mock_prepare, client, payload) + + with pytest.raises(EsphomeError, match="Failed to find IP via MQTT"): + get_esphome_device_ip(_discovery_config(), timeout=0) + + assert "Ignoring unparsable discovery payload" in caplog.text + + +def test_get_esphome_device_ip_broker_disconnect_fails_fast( + caplog: pytest.LogCaptureFixture, +) -> None: + """A broker-initiated disconnect aborts the wait instead of timing out.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client): + + def drop_connection(*args, **kwargs): + client.on_disconnect(client, None, 5) + + client.loop_start.side_effect = drop_connection + + start = time.monotonic() + with pytest.raises(EsphomeError, match="Failed to find IP via MQTT"): + get_esphome_device_ip(_discovery_config(), timeout=5) + + assert time.monotonic() - start < 1 + assert "Disconnected from MQTT broker (5)" in caplog.text + + +def test_get_esphome_device_ip_sends_discovery_ping() -> None: + """Connecting publishes the discovery ping for the device.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + + def connect_then_answer(*args, **kwargs): + on_connect = mock_prepare.call_args.args[3] + on_connect(client, None, None, 0) + msg = MagicMock() + msg.payload = json.dumps({"name": "test-device", "ip": "10.0.0.5"}).encode() + mock_prepare.call_args.args[2](client, None, msg) + + client.loop_start.side_effect = connect_then_answer + + result = get_esphome_device_ip(_discovery_config()) + + assert result == ["10.0.0.5"] + client.publish.assert_called_once_with( + "esphome/ping/test-device", None, retain=False + ) + + +def test_get_esphome_device_ip_disconnect_error_does_not_mask_result( + caplog: pytest.LogCaptureFixture, +) -> None: + """A cleanup failure must not replace the discovery result.""" + client = MagicMock() + # First disconnect (from on_message) succeeds; the finally's fails + client.disconnect.side_effect = [None, OSError("socket already closed")] + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start( + mock_prepare, + client, + json.dumps({"name": "test-device", "ip": "10.0.0.5"}).encode(), + ) + + result = get_esphome_device_ip(_discovery_config()) + + assert result == ["10.0.0.5"] + client.loop_stop.assert_called_once_with() + + +def test_get_esphome_device_ip_invalid_address_values_skipped( + caplog: pytest.LogCaptureFixture, +) -> None: + """Non-string or non-printable ip values are skipped, valid ones kept.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start( + mock_prepare, + client, + json.dumps( + { + "name": "test-device", + "ip": 1234, + "ip1": "x\n[00:00:00][I][forged] fake line", + "ip2": " 10.0.0.5 ", + } + ).encode(), + ) + + result = get_esphome_device_ip(_discovery_config()) + + assert result == ["10.0.0.5"] + assert caplog.text.count("Ignoring invalid address in discovery answer") == 2 + assert "forged" not in "".join( + r.getMessage() for r in caplog.records if "Found IP" in r.getMessage() + ) From 8e624b4117ab7c7cec32b6303efaa9cfe50462cd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 19:14:50 -0500 Subject: [PATCH 1436/1815] [core] Retry framework downloads on transient network errors (#18330) --- esphome/framework_helpers.py | 259 ++++++++++++++------- tests/unit_tests/test_framework_helpers.py | 201 +++++++++++++++- 2 files changed, 373 insertions(+), 87 deletions(-) diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 6ed608b171..86d5e4eaea 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -25,9 +25,13 @@ _LOGGER = logging.getLogger(__name__) # Attempts per mirror URL before falling through to the next mirror; only # mid-stream drops retry (resuming when the server gave a validator), -# connect errors move on immediately. +# connect errors move on to the next mirror immediately. _MIRROR_ATTEMPTS = 3 +# Passes over the whole mirror list when a transient network error is in +# the mix; matches git.py's _NETWORK_MAX_ATTEMPTS (3 tries, 2s/4s backoff). +_MIRROR_SWEEP_ATTEMPTS = 3 + def get_project_link_flags() -> list[str]: """Return the sorted -Wl, linker flags from the current build.""" @@ -887,37 +891,51 @@ def _failure_reason(e: Exception) -> str: return str(e).split(" for url: ", maxsplit=1)[0] or repr(e) -def download_from_mirrors( - mirrors: list[str], - substitutions: dict[str, str], - target: io.RawIOBase | IO[bytes] | PathType, - timeout: int = 30, -) -> str: +def _spent_attempts_error(e: Exception, attempts: int) -> Exception: + """Wrap a failure whose mirror already consumed download attempts, so + the sweep classifies it as permanent.""" + from esphome.core import EsphomeError + + err = EsphomeError(f"failed after {attempts} attempts: {_failure_reason(e)}") + err.__cause__ = e + return err + + +def _is_transient_download_error(e: Exception) -> bool: + """Return True when a download failure is worth retrying. + + Connection-level failures and HTTP 429/5xx are transient. Other HTTP + errors, local errors, and exhausted-attempts EsphomeError wrappers + (their per-mirror retries are already spent) are permanent. """ - Download file from multiple mirrors with substitution support. + # Imported lazily: requests is a heavy import (~85ms) and is only + # needed when actually downloading, never during config validation. + import requests - Args: - mirrors: list of mirror URLs - substitutions: Dictionary of substitutions to apply to URLs - target: Target file path or file-like object - timeout: Download timeout in seconds + if isinstance(e, requests.exceptions.HTTPError): + resp = e.response + return resp is not None and (resp.status_code == 429 or resp.status_code >= 500) + return isinstance( + e, + ( + requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + requests.exceptions.ChunkedEncodingError, + ), + ) - Returns: - The source URL. - Mirror URL templates that reference a substitution not present in - ``substitutions`` are skipped, so callers can offer templates that only - apply to some downloads. +def _try_mirrors_once( + urls: list[str], + path_target: Path | None, + f: IO[bytes] | None, + timeout: int, + failures: list[tuple[str, Exception]], +) -> str | None: + """Single pass over the resolved mirror ``urls``, one try per URL. - A path target downloads through ``download_with_resume``, so an - interrupted download resumes on the next esphome run; a file-like target - only resumes mid-stream drops within this call. - - Raises: - ValueError: If mirrors list is empty. - EsphomeError: If all download attempts fail; the message lists every - attempted URL with its individual failure reason. Also raised if - no template matched the provided substitutions. + Returns the source URL on success, or None with each URL's exception + appended to ``failures``. """ # Imported lazily: requests is a heavy import (~85ms) and is only # needed when actually downloading, never during config validation. @@ -925,43 +943,7 @@ def download_from_mirrors( from esphome.core import EsphomeError - ensure_happy_eyeballs() - - # 1. Classify the target: filesystem path or open file object - path_target: Path | None = None - f: IO[bytes] | None = None - if isinstance(target, (str, os.PathLike)): - path_target = Path(target) - elif isinstance(target, (io.RawIOBase, io.IOBase)): - f = target - else: - raise TypeError( - f"target must be str, Path, or file-like object: {type(target)}" - ) - - # 2. Try each mirror in order - failures: list[tuple[str, Exception]] = [] - skipped: list[tuple[str, str]] = [] - - for mirror in mirrors: - # 3. Apply substitutions to URL - try: - url = mirror.format(**substitutions) - except KeyError as e: - # The template references a substitution not provided for - # this download (e.g. SHORT_VERSION only exists for x.y.0 - # versions) - expected, the template just doesn't apply. - _LOGGER.debug("Skipping mirror %s: %s not available", mirror, e) - skipped.append((mirror, f"not applicable ({e.args[0]} not available)")) - continue - except (IndexError, ValueError) as e: - # A malformed template (unbalanced braces, bad format spec) - # is an authoring error, not an expected fallthrough - warn - # even if a later mirror succeeds. - _LOGGER.warning("Skipping malformed mirror URL template %s: %r", mirror, e) - skipped.append((mirror, f"skipped ({e!r})")) - continue - + for url in urls: _LOGGER.debug("Trying to download from %s", url) # Path targets delegate to download_with_resume so a partial @@ -986,14 +968,14 @@ def download_from_mirrors( failures.append((url, e)) continue - # 4. Download; mid-stream failures retry the same mirror with - # resume (see download_with_resume) instead of starting over. - # There is no checksum to verify a resumed file against, so a - # stitch is only trusted when the server proves consistency: the - # If-Range validator guarantees 206 only for unchanged content, - # and the expected total length (when the first response carried - # one) guards against short or shifted bodies. Without a - # validator the retry restarts from zero. + # File-like targets download here; mid-stream failures retry the + # same mirror with resume (see download_with_resume) instead of + # starting over. There is no checksum to verify a resumed file + # against, so a stitch is only trusted when the server proves + # consistency: the If-Range validator guarantees 206 only for + # unchanged content, and the expected total length (when the first + # response carried one) guards against short or shifted bodies. + # Without a validator the retry restarts from zero. offset = 0 expected_total = 0 validator = None @@ -1001,9 +983,12 @@ def download_from_mirrors( try: resp, offset = _open_ranged(url, offset, timeout, validator) except (requests.RequestException, OSError) as e: - # Connect/HTTP error, no bytes flowed — next mirror. + # Connect/HTTP error, no bytes flowed — next mirror. Wrap + # when earlier attempts were already spent on this mirror. _LOGGER.debug("Failed to download %s: %s", url, str(e)) - failures.append((url, e)) + failures.append( + (url, _spent_attempts_error(e, attempt + 1) if attempt else e) + ) break try: @@ -1031,7 +1016,7 @@ def download_from_mirrors( _LOGGER.debug("Downloaded successfully from: %s", url) - # 5. Reset file pointer and return + # Reset file pointer and return f.seek(0) return url @@ -1054,16 +1039,124 @@ def download_from_mirrors( ) offset = 0 if attempt == _MIRROR_ATTEMPTS - 1: - failures.append((url, e)) + failures.append((url, _spent_attempts_error(e, _MIRROR_ATTEMPTS))) - # 6. Report every attempted URL if all mirrors failed. Falling back - # past an early mirror is normal (e.g. only one of the framework URL - # templates matches a given version's tag), so raising only the last - # error would hide the failure that actually matters. - if failures: - attempts = "".join( - f"\n {url}\n {_failure_reason(e)}" for url, e in failures + return None + + +def download_from_mirrors( + mirrors: list[str], + substitutions: dict[str, str], + target: io.RawIOBase | IO[bytes] | PathType, + timeout: int = 30, +) -> str: + """ + Download file from multiple mirrors with substitution support. + + Args: + mirrors: list of mirror URLs + substitutions: Dictionary of substitutions to apply to URLs + target: Target file path or file-like object + timeout: Download timeout in seconds + + Returns: + The source URL. + + Mirror URL templates that reference a substitution not present in + ``substitutions`` are skipped, so callers can offer templates that only + apply to some downloads. + + A path target downloads through ``download_with_resume``, so an + interrupted download resumes on the next esphome run; a file-like target + only resumes mid-stream drops within this call. + + When every mirror fails and at least one failure is transient (dropped + connection, timeout, HTTP 429/5xx), the whole list is retried with a + short backoff; permanent failures (e.g. 404) raise immediately. + + Raises: + ValueError: If mirrors list is empty. + EsphomeError: If all download attempts fail; the message lists every + attempted URL with its individual failure reason. Also raised if + no template matched the provided substitutions. + """ + from esphome.core import EsphomeError + + ensure_happy_eyeballs() + + # 1. Classify the target: filesystem path or open file object + path_target: Path | None = None + f: IO[bytes] | None = None + if isinstance(target, (str, os.PathLike)): + path_target = Path(target) + elif isinstance(target, (io.RawIOBase, io.IOBase)): + f = target + else: + raise TypeError( + f"target must be str, Path, or file-like object: {type(target)}" ) + + # 2. Resolve the mirror templates (invariant across retry sweeps) + urls: list[str] = [] + skipped: list[tuple[str, str]] = [] + for mirror in mirrors: + try: + urls.append(mirror.format(**substitutions)) + except KeyError as e: + # The template references a substitution not provided for + # this download (e.g. SHORT_VERSION only exists for x.y.0 + # versions) - expected, the template just doesn't apply. + _LOGGER.debug("Skipping mirror %s: %s not available", mirror, e) + skipped.append((mirror, f"not applicable ({e.args[0]} not available)")) + except (IndexError, ValueError) as e: + # A malformed template (unbalanced braces, bad format spec) + # is an authoring error, not an expected fallthrough - warn + # even if a later mirror succeeds. + _LOGGER.warning("Skipping malformed mirror URL template %s: %r", mirror, e) + skipped.append((mirror, f"skipped ({e!r})")) + + # 3. Sweep the mirror list, retrying transient failures with backoff: + # a single pass keeps mirror failover fast, re-sweeping keeps one + # network blip from failing the build when only one mirror applies. + failures: list[tuple[str, Exception]] = [] + for sweep in range(1, _MIRROR_SWEEP_ATTEMPTS + 1): + sweep_failures: list[tuple[str, Exception]] = [] + if ( + url := _try_mirrors_once(urls, path_target, f, timeout, sweep_failures) + ) is not None: + return url + failures.extend(sweep_failures) + # Permanent failures (404, verification mismatch) won't heal; + # only retry when a transient error is in the mix (as git.py does). + transient = next( + ((u, e) for u, e in sweep_failures if _is_transient_download_error(e)), + None, + ) + if transient is None: + break + if sweep < _MIRROR_SWEEP_ATTEMPTS: + delay = 2**sweep + _LOGGER.warning( + "Download of %s failed (%s); retrying in %d seconds (attempt %d/%d)", + transient[0], + _failure_reason(transient[1]), + delay, + sweep + 1, + _MIRROR_SWEEP_ATTEMPTS, + ) + time.sleep(delay) + + # 4. Report every attempted URL if all mirrors failed. failures spans + # all sweeps (deduplicated by URL and reason), so neither an early + # mirror's failure nor an earlier sweep's failure mode is hidden. + if failures: + seen: set[tuple[str, str]] = set() + attempts = "" + for url, e in failures: + reason = _failure_reason(e) + if (url, reason) not in seen: + seen.add((url, reason)) + attempts += f"\n {url}\n {reason}" attempts += "".join(f"\n {mirror}\n {reason}" for mirror, reason in skipped) raise EsphomeError( f"Failed to download from all mirrors:{attempts}" diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index 08751879c2..7451ee9b39 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -12,7 +12,7 @@ from pathlib import Path import subprocess import sys import tarfile -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, Mock, call, patch import zipfile import pytest @@ -23,6 +23,7 @@ from esphome.core import EsphomeError from esphome.framework_helpers import ( _7z_extract_all, _detect_archive_root, + _is_transient_download_error, _rename_with_retry, _tar_extract_all, _zip_extract_all, @@ -515,16 +516,23 @@ class TestArchiveExtractAll: # --------------------------------------------------------------------------- -def _mock_response(content: bytes, ok: bool = True) -> MagicMock: +def _mock_response( + content: bytes, ok: bool = True, status: int | None = None +) -> MagicMock: + """A fake requests response. The HTTPError carries the response (as + ``raise_for_status`` on a real response) so the transient classifier + can see its ``status``; failures default to a permanent 404.""" + if status is None: + status = 200 if ok else 404 r = MagicMock() r.__enter__.return_value = r r.__exit__.return_value = False - r.status_code = 200 + r.status_code = status r.ok = ok if ok: r.raise_for_status.return_value = None else: - r.raise_for_status.side_effect = req.HTTPError("503") + r.raise_for_status.side_effect = req.HTTPError(str(status), response=r) r.headers = {"content-length": "0"} # suppress ProgressBar r.iter_content.return_value = [content] if content else [] return r @@ -1419,6 +1427,191 @@ class TestDownloadFromMirrors: assert target.exists() assert target.read_bytes() == b"" + @pytest.mark.parametrize("target_kind", ["path", "file-like"]) + def test_transient_failure_retries_mirror_sweep( + self, tmp_path: Path, target_kind: str + ) -> None: + """A transient connect error on the only applicable mirror retries the + whole mirror list with backoff instead of failing the build.""" + target = tmp_path / "idf.tar.xz" if target_kind == "path" else io.BytesIO() + with ( + patch( + "requests.get", + side_effect=[ + req.ConnectionError("Remote end closed connection"), + _mock_response(b"data"), + ], + ) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + ): + url = download_from_mirrors(["https://mirror1.com/f"], {}, target) + assert url == "https://mirror1.com/f" + data = target.read_bytes() if target_kind == "path" else target.getvalue() + assert data == b"data" + assert mock_get.call_count == 2 + mock_sleep.assert_called_once_with(2) + + def test_permanent_failure_does_not_retry_sweep(self, tmp_path: Path) -> None: + """An HTTP 404 will not heal on its own; fail after a single pass.""" + with ( + patch( + "requests.get", return_value=_mock_response(b"", ok=False, status=404) + ) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="all mirrors"), + ): + download_from_mirrors(["https://mirror1.com/f"], {}, tmp_path / "out.bin") + assert mock_get.call_count == 1 + mock_sleep.assert_not_called() + + def test_transient_failure_exhausts_sweeps(self, tmp_path: Path) -> None: + """A persistent transient error gives up after the configured number + of passes, with 2s/4s backoff, and still lists the attempted URL.""" + with ( + patch("requests.get", side_effect=req.ConnectionError("down")) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="all mirrors") as ei, + ): + download_from_mirrors(["https://mirror1.com/f"], {}, tmp_path / "out.bin") + assert mock_get.call_count == 3 + assert mock_sleep.call_args_list == [call(2), call(4)] + assert "https://mirror1.com/f" in str(ei.value) + + def test_mixed_permanent_and_transient_retries_sweep(self, tmp_path: Path) -> None: + """One mirror 404s permanently while another hits a transient error; + the transient failure makes the whole list worth another pass.""" + dest = tmp_path / "out.bin" + with ( + patch( + "requests.get", + side_effect=[ + _mock_response(b"", ok=False, status=404), + req.ConnectionError("down"), + _mock_response(b"", ok=False, status=404), + _mock_response(b"data"), + ], + ), + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + ): + url = download_from_mirrors( + ["https://mirror1.com/f", "https://mirror2.com/f"], {}, dest + ) + assert url == "https://mirror2.com/f" + assert dest.read_bytes() == b"data" + mock_sleep.assert_called_once_with(2) + + def test_http_5xx_retries_sweep(self, tmp_path: Path) -> None: + """A real 5xx (response attached to the HTTPError) is transient.""" + dest = tmp_path / "out.bin" + with ( + patch( + "requests.get", + side_effect=[ + _mock_response(b"", ok=False, status=503), + _mock_response(b"data"), + ], + ), + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + ): + url = download_from_mirrors(["https://mirror1.com/f"], {}, dest) + assert url == "https://mirror1.com/f" + assert dest.read_bytes() == b"data" + mock_sleep.assert_called_once_with(2) + + def test_error_reports_failure_modes_from_all_sweeps(self, tmp_path: Path) -> None: + """A failure mode that changes between sweeps stays in the final + error; the first failure (the one that started the retries) is + chained as the cause.""" + with ( + patch( + "requests.get", + side_effect=[ + req.ConnectionError("dropped by middlebox"), + _mock_response(b"", ok=False, status=404), + ], + ), + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="all mirrors") as ei, + ): + download_from_mirrors(["https://mirror1.com/f"], {}, tmp_path / "out.bin") + assert "dropped by middlebox" in str(ei.value) + assert "404" in str(ei.value) + assert isinstance(ei.value.__cause__, req.ConnectionError) + mock_sleep.assert_called_once_with(2) + + def test_exhausted_mid_stream_attempts_not_swept(self) -> None: + """A file-like mirror that spent all its mid-stream attempts is not + retried again at the sweep level (unlike a path target, it has no + part file to resume from on a later sweep).""" + buf = io.BytesIO() + with ( + patch( + "requests.get", + side_effect=[_interrupted_response(b"1234") for _ in range(3)], + ) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="failed after 3 attempts"), + ): + download_from_mirrors(["https://mirror1.com/f"], {}, buf) + assert mock_get.call_count == 3 + mock_sleep.assert_not_called() + + def test_mid_stream_drop_then_connect_error_not_swept(self) -> None: + """A connect error on a later attempt (after a mid-stream drop spent + one) also counts as spent budget and does not re-arm the sweep.""" + buf = io.BytesIO() + with ( + patch( + "requests.get", + side_effect=[ + _interrupted_response(b"1234"), + req.ConnectionError("down"), + ], + ) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="failed after 2 attempts"), + ): + download_from_mirrors(["https://mirror1.com/f"], {}, buf) + assert mock_get.call_count == 2 + mock_sleep.assert_not_called() + + +def _http_error(status: int) -> req.HTTPError: + """An HTTPError carrying a response with the given status, as raised by + ``raise_for_status`` on a real response.""" + resp = MagicMock() + resp.status_code = status + return req.HTTPError(str(status), response=resp) + + +class TestIsTransientDownloadError: + def test_connection_errors_are_transient(self) -> None: + assert _is_transient_download_error(req.ConnectionError("reset")) + assert _is_transient_download_error(req.Timeout("timed out")) + assert _is_transient_download_error( + req.exceptions.ChunkedEncodingError("dropped") + ) + + def test_http_statuses(self) -> None: + assert not _is_transient_download_error(_http_error(404)) + assert not _is_transient_download_error(_http_error(403)) + assert _is_transient_download_error(_http_error(429)) + assert _is_transient_download_error(_http_error(503)) + + def test_http_error_without_response_is_permanent(self) -> None: + assert not _is_transient_download_error(req.HTTPError("boom")) + + def test_exhausted_resume_attempts_are_permanent(self) -> None: + """download_with_resume already spent its own resume attempts; its + EsphomeError wrapper is not retried again at the sweep level.""" + wrapped = EsphomeError("Failed to download after 3 attempts") + wrapped.__cause__ = req.ConnectionError("down") + assert not _is_transient_download_error(wrapped) + + def test_unrelated_errors_are_permanent(self) -> None: + assert not _is_transient_download_error(OSError("disk full")) + assert not _is_transient_download_error(EsphomeError("size mismatch")) + def test_importing_framework_helpers_does_not_import_requests() -> None: """Importing framework_helpers must not drag in requests. From 7569a7b5ced61c4a2826fac165e64fceee1baccd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 09:59:24 -0500 Subject: [PATCH 1437/1815] [usb_uart] Fix uint32_t format specifier warning in pl2303 (#18310) --- esphome/components/usb_uart/pl2303.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index 3c7ecd9a83..c56f43f75a 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -292,8 +292,8 @@ bool USBUartTypePL2303::config_step(USBUartChannel *channel, uint8_t step, bool // Data bits line_coding[6] = channel->get_data_bits(); - ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%u stop=%u parity=%u data=%u", baud, line_coding[4], line_coding[5], - line_coding[6]); + ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%" PRIu32 " stop=%u parity=%u data=%u", baud, line_coding[4], + line_coding[5], line_coding[6]); std::vector lc_vec(line_coding, line_coding + 7); this->config_transfer_(SET_LINE_REQUEST_TYPE, SET_LINE_REQUEST, 0, iface, lc_vec); From 89489b1f0dabee05c18ce8327386dd4a660d79a5 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:33:45 +0000 Subject: [PATCH 1438/1815] Bump aioesphomeapi from 45.10.0 to 45.10.1 (#18318) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9c231bd0fe..85a0f55263 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.10.0 +aioesphomeapi==45.10.1 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From 83cff59fddca496cdb60d66d1ea8525c62c620f6 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 12 Aug 2026 12:49:54 -0400 Subject: [PATCH 1439/1815] [sendspin] Bump sendspin-cpp to v0.7.2 (#18316) --- esphome/components/sendspin/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index bd889c2c92..082639374f 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -234,7 +234,7 @@ async def to_code(config: ConfigType) -> None: psram.request_external_task_stack() # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.1") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.2") cg.add_define("USE_SENDSPIN", True) # for MDNS diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 6a9d7171ec..aff1a6819f 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -98,7 +98,7 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.7.1 + version: 0.7.2 lvgl/lvgl: version: 9.5.0 fastled/FastLED: From 48d6368ff9e4b3ba18b663eb84f686e5ee84065e Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:27:14 -0500 Subject: [PATCH 1440/1815] Bump bundled esphome-device-builder to 1.9.6 (#18328) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index a4f5d3c3a6..d7ae2cd4ec 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.9.5 +RUN uv pip install --no-cache-dir esphome-device-builder==1.9.6 RUN \ platformio settings set enable_telemetry No \ From a14ea0e8fabce6ad71a3386dd5fa1ecb9edb1166 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 18:49:34 -0500 Subject: [PATCH 1441/1815] [ld2420] Fix out-of-bounds read when device reports unknown command error (#18322) --- esphome/components/ld2420/ld2420.cpp | 9 ++++++++- esphome/components/ld2420/ld2420.h | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/esphome/components/ld2420/ld2420.cpp b/esphome/components/ld2420/ld2420.cpp index ae622cda28..f71bec7e5f 100644 --- a/esphome/components/ld2420/ld2420.cpp +++ b/esphome/components/ld2420/ld2420.cpp @@ -746,7 +746,14 @@ void LD2420Component::set_reg_value(uint16_t reg, uint16_t value) { this->send_cmd_from_array(cmd_frame); } -void LD2420Component::handle_cmd_error(uint8_t error) { ESP_LOGE(TAG, "Command failed: %s", ERR_MESSAGE[error]); } +void LD2420Component::handle_cmd_error(uint16_t error) { + if (error < std::size(ERR_MESSAGE)) { + ESP_LOGE(TAG, "Command failed: %s", ERR_MESSAGE[error]); + } else { + // The error word comes from the device reply frame; unknown codes must not index ERR_MESSAGE + ESP_LOGE(TAG, "Command failed: error 0x%04X", error); + } +} int LD2420Component::get_gate_threshold_(uint8_t gate) { uint8_t error; diff --git a/esphome/components/ld2420/ld2420.h b/esphome/components/ld2420/ld2420.h index ae44b16065..977ee2eccc 100644 --- a/esphome/components/ld2420/ld2420.h +++ b/esphome/components/ld2420/ld2420.h @@ -108,7 +108,7 @@ class LD2420Component final : public Component, public uart::UARTDevice { float get_setup_priority() const override; int send_cmd_from_array(CmdFrameT cmd_frame); void report_gate_data(); - void handle_cmd_error(uint8_t error); + void handle_cmd_error(uint16_t error); void set_operating_mode(const char *state); void auto_calibrate_sensitivity(); void update_radar_data(uint16_t const *gate_energy, uint8_t sample_number); From c1a326f32e710d26e0671c56b1a74e85a37a5822 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 19:11:32 -0500 Subject: [PATCH 1442/1815] [core] Don't block logs startup on MQTT IP discovery when addresses are known (#18313) --- esphome/__main__.py | 144 +++++++++---- esphome/api_client.py | 87 +++++++- esphome/mqtt.py | 91 ++++++-- tests/unit_tests/test_api_client.py | 323 +++++++++++++++++++++++++++- tests/unit_tests/test_main.py | 171 +++++++++++---- tests/unit_tests/test_mqtt.py | 262 ++++++++++++++++++++++ 6 files changed, 982 insertions(+), 96 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 0ac5898268..1262a4525e 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -10,7 +10,7 @@ from pathlib import Path import re import sys import time -from typing import Protocol +from typing import TYPE_CHECKING, Protocol # Note: Do not import modules from esphome.components here, as this would # cause them to be loaded before external components are processed, resulting @@ -71,6 +71,9 @@ from esphome.util import ( safe_print, ) +if TYPE_CHECKING: + import threading + # Keep expensive imports (zeroconf, writer, yaml_util, etc.) out of this # module's top level. Every `esphome` invocation — including fast paths # like `esphome version` — pays the cost of what's imported here before @@ -567,11 +570,48 @@ def has_name_add_mac_suffix() -> bool: def mqtt_get_ip( - config: ConfigType, username: str, password: str, client_id: str + config: ConfigType, + username: str, + password: str, + client_id: str, + stop_event: "threading.Event | None" = None, ) -> list[str]: from esphome import mqtt - return mqtt.get_esphome_device_ip(config, username, password, client_id) + return mqtt.get_esphome_device_ip( + config, username, password, client_id, stop_event=stop_event + ) + + +def _add_network_device(device: str, network_devices: list[str]) -> None: + """Append a device to the list, expanding it through ``CORE.address_cache``. + + If the hostname is already in the address cache (e.g. populated by mDNS + discovery), substitute the cached IPs so aioesphomeapi doesn't open its + own Zeroconf to re-resolve it. Duplicates are dropped. + """ + if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)): + network_devices.extend(addr for addr in cached if addr not in network_devices) + elif device not in network_devices: + network_devices.append(device) + + +def _split_network_devices(devices: list[str]) -> tuple[list[str], bool]: + """Split the device list into direct addresses and an MQTT-lookup flag. + + Direct addresses are expanded through ``CORE.address_cache`` and deduped + the same way ``_resolve_network_devices`` does; MQTT/MQTTIP magic strings + are not resolved, only reported via the returned bool so the caller can + defer the broker lookup. + """ + network_devices: list[str] = [] + has_mqtt_lookup = False + for device in devices: + if get_port_type(device) in _MQTT_PORT_TYPES: + has_mqtt_lookup = True + else: + _add_network_device(device, network_devices) + return network_devices, has_mqtt_lookup def _resolve_network_devices( @@ -604,40 +644,44 @@ def _resolve_network_devices( if port_type in _MQTT_PORT_TYPES: # Only resolve MQTT once, even if multiple MQTT entries if not mqtt_resolved: - try: - mqtt_ips = mqtt_get_ip( - config, args.username, args.password, args.client_id - ) - # pylint can't infer mqtt_get_ip's return through its - # lazy ``from esphome import mqtt`` import, so it flags - # the genexpr below. - network_devices.extend( - addr - for addr in mqtt_ips # pylint: disable=not-an-iterable - if addr not in network_devices - ) - except EsphomeError as err: - _LOGGER.warning( - "MQTT IP discovery failed (%s), will try other devices if available", - err, - ) + mqtt_ips = _mqtt_get_ip_or_warn( + config, args.username, args.password, args.client_id + ) + network_devices.extend( + addr for addr in mqtt_ips if addr not in network_devices + ) mqtt_resolved = True continue - # If the hostname is already in the address cache (e.g. populated by - # mDNS discovery), substitute the cached IPs so aioesphomeapi doesn't - # open its own Zeroconf to re-resolve it. - if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)): - network_devices.extend( - addr for addr in cached if addr not in network_devices - ) - elif device not in network_devices: - # Regular network address or IP - add if not already present - network_devices.append(device) + _add_network_device(device, network_devices) return network_devices +def _mqtt_get_ip_or_warn( + config: ConfigType, + username: str, + password: str, + client_id: str, + stop_event: "threading.Event | None" = None, +) -> list[str]: + """Look up the device IP via MQTT, returning [] with a warning on failure. + + This owns the failure policy for MQTT IP discovery on paths that have + other addresses to fall back on: a broker problem must not abort the + operation. Also used as the deferred resolver handed to ``run_logs``, + where it runs in a worker thread. + """ + try: + return mqtt_get_ip(config, username, password, client_id, stop_event=stop_event) + except EsphomeError as err: + _LOGGER.warning( + "MQTT IP discovery failed (%s), will try other devices if available", + err, + ) + return [] + + def run_miniterm(config: ConfigType, port: str, args) -> int: from datetime import datetime @@ -1438,17 +1482,37 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int return run_miniterm(config, port, args) # Check if we should use API for logging - # Resolve MQTT magic strings to actual IP addresses - if has_api() and ( - network_devices := _resolve_network_devices(devices, config, args) - ): - from esphome.api_client import run_logs + if has_api(): + network_devices, has_mqtt_lookup = _split_network_devices(devices) + mqtt_resolver = None + if has_mqtt_lookup: + if network_devices: + # Addresses are already known, so don't block startup on the + # MQTT broker lookup; hand it to run_logs as a deferred + # resolver that runs in the background and feeds discovered + # addresses into the running log client, keeping MQTT as a + # fallback for when the known addresses are stale (e.g. DHCP + # reassigned the IP). + mqtt_resolver = functools.partial( + _mqtt_get_ip_or_warn, + config, + args.username, + args.password, + args.client_id, + ) + else: + # The MQTT lookup is the only way to find the device; resolve + # it up front since the client needs an address to start with. + network_devices = _resolve_network_devices(devices, config, args) + if network_devices: + from esphome.api_client import run_logs - return run_logs( - config, - network_devices, - subscribe_states=_should_subscribe_states(args), - ) + return run_logs( + config, + network_devices, + subscribe_states=_should_subscribe_states(args), + mqtt_resolver=mqtt_resolver, + ) if port_type in (PortType.NETWORK, PortType.MQTT) and has_mqtt_logging(): from esphome import mqtt diff --git a/esphome/api_client.py b/esphome/api_client.py index a75f219b17..fb41075de8 100644 --- a/esphome/api_client.py +++ b/esphome/api_client.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from contextlib import suppress import logging +import threading from typing import TYPE_CHECKING, Any import warnings @@ -20,6 +21,8 @@ from esphome.stacktrace import LogLineProcessor from esphome.util import safe_print if TYPE_CHECKING: + from collections.abc import Callable + from aioesphomeapi.api_pb2 import ( SubscribeLogsResponse, # pylint: disable=no-name-in-module ) @@ -32,8 +35,18 @@ async def async_run_logs( config: dict[str, Any], addresses: list[str], subscribe_states: bool = True, + mqtt_resolver: Callable[[threading.Event], list[str]] | None = None, ) -> None: - """Run the logs command in the event loop.""" + """Run the logs command in the event loop. + + If ``mqtt_resolver`` is given, it is called in a worker thread (paho-mqtt + has no asyncio support on Windows) concurrently with the connection + attempts to ``addresses``, and any addresses it discovers are fed into + the running client. It owns its own failure handling (returning [] when + discovery fails) and must honor the ``threading.Event`` it is passed so + teardown is not delayed by the lookup's wait window; the initial broker + connect itself is only bounded by the socket timeout. + """ from datetime import datetime conf = config["api"] @@ -60,6 +73,41 @@ async def async_run_logs( # Decoder resolution policy lives in LogLineProcessor. processor = LogLineProcessor(config, CORE.target_platform) + mqtt_task: asyncio.Task[None] | None = None + mqtt_stop_event = threading.Event() + + def _cancel_mqtt_discovery() -> None: + """Stop the broker lookup once a connection has been established. + + Its answer is only useful while still disconnected: after that it + either duplicates the connected address or arrives too late to + matter, so don't keep an idle broker session open for it. + """ + mqtt_stop_event.set() + if mqtt_task is not None and not mqtt_task.done(): + mqtt_task.cancel() + + async def _resolve_mqtt_addresses() -> None: + """Discover the device address via the MQTT broker in the background.""" + try: + mqtt_ips = await asyncio.to_thread(mqtt_resolver, mqtt_stop_event) + if not mqtt_ips: + _LOGGER.debug( + "MQTT discovery %s", + "aborted" if mqtt_stop_event.is_set() else "found no addresses", + ) + return + if cli.add_addresses(mqtt_ips): + _LOGGER.info("Discovered address(es) via MQTT: %s", ", ".join(mqtt_ips)) + else: + _LOGGER.debug( + "MQTT-discovered address(es) already known: %s", ", ".join(mqtt_ips) + ) + except Exception: # pylint: disable=broad-except + # A background task failure would otherwise stay invisible for + # the whole session and only re-raise at teardown + _LOGGER.exception("MQTT address discovery failed") + def on_log(msg: SubscribeLogsResponse) -> None: """Handle a new log message.""" time_ = datetime.now().astimezone() @@ -98,20 +146,53 @@ async def async_run_logs( # A top-level ``deep_sleep:`` block means the device is only awake # briefly; cap the reconnect backoff so a wake window is not missed. deep_sleep="deep_sleep" in config, + on_connect=_cancel_mqtt_discovery if mqtt_resolver is not None else None, ) try: + # Don't start (or keep) the broker lookup if a connection already + # succeeded; the stop event doubles as the not-needed-anymore latch + # and get_esphome_device_ip returns immediately when it is set. + if mqtt_resolver is not None and not mqtt_stop_event.is_set(): + mqtt_task = asyncio.create_task(_resolve_mqtt_addresses()) await asyncio.Event().wait() finally: - await stop() + try: + if mqtt_task is not None: + # Unblock the worker thread first so it can't hold up + # loop.shutdown_default_executor() for the full lookup timeout. + mqtt_stop_event.set() + # Give the worker a moment to exit through its own error + # handling; cancelling first would race out a late failure. + done, _ = await asyncio.wait([mqtt_task], timeout=1.0) + if not done: + mqtt_task.cancel() + # return_exceptions keeps a CancelledError from the cancel() + # above from re-raising here and jumping over the stop() below. + # The task handles Exception itself, so only a BaseException + # escape (e.g. SystemExit from the worker) can land here. + (result,) = await asyncio.gather(mqtt_task, return_exceptions=True) + if isinstance(result, BaseException) and not isinstance( + result, asyncio.CancelledError + ): + _LOGGER.error("MQTT address discovery failed", exc_info=result) + finally: + # Must run even if a second cancellation lands mid-cleanup above + await stop() def run_logs( config: dict[str, Any], addresses: list[str], subscribe_states: bool = True, + mqtt_resolver: Callable[[threading.Event], list[str]] | None = None, ) -> None: """Run the logs command.""" with suppress(KeyboardInterrupt): asyncio.run( - async_run_logs(config, addresses, subscribe_states=subscribe_states) + async_run_logs( + config, + addresses, + subscribe_states=subscribe_states, + mqtt_resolver=mqtt_resolver, + ) ) diff --git a/esphome/mqtt.py b/esphome/mqtt.py index 3198de9d21..62deafb09a 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -6,6 +6,7 @@ from pathlib import Path import ssl import tempfile import time +from typing import TYPE_CHECKING import paho.mqtt.client as mqtt @@ -31,6 +32,9 @@ from esphome.helpers import get_int_env, get_str_env from esphome.types import ConfigType from esphome.util import safe_print +if TYPE_CHECKING: + import threading + _LOGGER = logging.getLogger(__name__) @@ -164,6 +168,7 @@ def get_esphome_device_ip( password: str | None = None, client_id: str | None = None, timeout: float = 25, + stop_event: "threading.Event | None" = None, ) -> list[str]: if CONF_MQTT not in config: raise EsphomeError( @@ -182,55 +187,113 @@ def get_esphome_device_ip( dev_name = config[CONF_ESPHOME][CONF_NAME] dev_ip = None + failed = False topic = "esphome/discover/" + dev_name _LOGGER.info("Starting looking for IP in topic %s", topic) def on_message(client, userdata, msg): - nonlocal dev_ip + nonlocal dev_ip, failed time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]") payload = msg.payload.decode(errors="backslashreplace") if len(payload) > 0: message = time_ + " " + payload _LOGGER.debug(message) - data = json.loads(payload) + try: + data = json.loads(payload) + except ValueError: + data = None + if not isinstance(data, dict): + # A raise in this handler would kill paho's network thread + _LOGGER.warning("Ignoring unparsable discovery payload") + return if "name" not in data or data["name"] != dev_name: _LOGGER.warning("Wrong device answer") return - dev_ip = [] + addresses = [] key = "ip" n = 0 while key in data: - dev_ip.append(data[key]) + value = data[key] + if ( + isinstance(value, str) + and (value := value.strip()) + and value.isprintable() + ): + addresses.append(value) + else: + # repr-escaped and truncated: must not forge log lines + _LOGGER.warning( + "Ignoring invalid address in discovery answer: %s", + repr(value)[:100], + ) n = n + 1 key = "ip" + str(n) - if dev_ip: - client.disconnect() + if not addresses: + _LOGGER.warning("Device answer did not include an IP address") + failed = True + return + + dev_ip = addresses + failed = False # a complete answer wins over an earlier empty one + client.disconnect() def on_connect(client, userdata, flags, return_code): topic = "esphome/ping/" + dev_name _LOGGER.info("Send discover via MQTT broker topic: %s", topic) client.publish(topic, None, retain=False) + if stop_event is not None and stop_event.is_set(): + # Teardown already started; don't open a broker connection at all + return [] + + def on_disconnect(client, userdata, result_code): + nonlocal failed + if result_code != 0: + _LOGGER.warning("Disconnected from MQTT broker (%s)", result_code) + failed = True + mqtt_client = prepare( config, [topic], on_message, on_connect, username, password, client_id ) + # Discovery is one-shot; prepare()'s reconnect-forever on_disconnect runs + # on the network thread and would make loop_stop() below join forever. + mqtt_client.on_disconnect = on_disconnect - mqtt_client.loop_start() - while timeout > 0: - if dev_ip is not None: - break - timeout -= 0.250 - time.sleep(0.250) - mqtt_client.loop_stop() + if stop_event is None: + import threading + + stop_event = threading.Event() # never set; wait() below is a plain sleep + stopped = stop_event.is_set() # teardown may have started during connect + try: + if not stopped: + mqtt_client.loop_start() + while timeout > 0: + if dev_ip is not None or failed: + break + if stop_event.wait(0.250): + stopped = True + break + timeout -= 0.250 + finally: + # A cleanup failure must not replace the discovery result or its + # EsphomeError; a second disconnect after on_message's is harmless. + try: + mqtt_client.disconnect() + except Exception: # pylint: disable=broad-except + _LOGGER.debug("Error disconnecting from MQTT broker", exc_info=True) + mqtt_client.loop_stop() # only signals and joins; does not raise if dev_ip is None: + if stopped: + # Aborted by the caller, not a failure; stay quiet + return [] raise EsphomeError("Failed to find IP via MQTT") - _LOGGER.info("Found IP: %s", dev_ip) + _LOGGER.info("Found IP via MQTT broker: %s", ", ".join(dev_ip)) return dev_ip diff --git a/tests/unit_tests/test_api_client.py b/tests/unit_tests/test_api_client.py index 19ed83abe1..405567d84f 100644 --- a/tests/unit_tests/test_api_client.py +++ b/tests/unit_tests/test_api_client.py @@ -56,7 +56,7 @@ async def test_async_run_logs_full_flow(caplog) -> None: with ( patch.object(api_client, "async_run", mock_run), - patch.object(api_client, "APIClient") as mock_client, + patch.object(api_client, "APIClient", autospec=True) as mock_client, patch.object(api_client, "safe_print", printed.append), ): task = asyncio.get_running_loop().create_task( @@ -163,3 +163,324 @@ async def test_async_run_logs_passes_deep_sleep( await api_client.async_run_logs(config, ["1.2.3.4"]) assert mock_run.call_args.kwargs["deep_sleep"] is expected_deep_sleep + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_feeds_addresses(caplog) -> None: + """Addresses discovered via MQTT are fed into the running client.""" + caplog.set_level("INFO", logger="esphome.api_client") + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + fed = asyncio.Event() + + def resolver(stop_event): + return ["10.0.0.9", "10.0.0.10"] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True) as mock_client, + ): + mock_client.return_value.add_addresses.side_effect = lambda addrs: ( + fed.set() or True + ) + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + async with asyncio.timeout(1): + await fed.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + mock_client.return_value.add_addresses.assert_called_once_with( + ["10.0.0.9", "10.0.0.10"] + ) + assert "Discovered address(es) via MQTT" in caplog.text + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_no_addresses_keeps_running() -> None: + """A resolver returning nothing (failed lookup) leaves the session running.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver_ran = threading.Event() + + def resolver(stop_event): + # The resolver owns failure handling; a failed lookup returns [] + resolver_ran.set() + return [] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True) as mock_client, + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_ran.wait, 1) + await asyncio.sleep(0) + assert not task.done() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + mock_client.return_value.add_addresses.assert_not_called() + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_stopped_on_teardown() -> None: + """Teardown sets the resolver's stop event so the thread exits promptly.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + captured_event: threading.Event | None = None + resolver_started = threading.Event() + + def resolver(stop_event): + nonlocal captured_event + captured_event = stop_event + resolver_started.set() + # Simulate a slow broker lookup that only ends via the stop event. + stop_event.wait(timeout=5) + return [] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_started.wait, 1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert captured_event is not None + assert captured_event.is_set() + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_crash_still_stops_cleanly(caplog) -> None: + """A resolver raising unexpectedly must not skip stop() at teardown.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver_ran = threading.Event() + + def resolver(stop_event): + resolver_ran.set() + raise RuntimeError("resolver blew up") + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_ran.wait, 1) + await asyncio.sleep(0.05) + assert not task.done() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert "MQTT address discovery failed" in caplog.text + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_connect_cancels_mqtt_discovery() -> None: + """A successful connection stops the in-flight broker lookup.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + captured_event: threading.Event | None = None + resolver_started = threading.Event() + + def resolver(stop_event): + nonlocal captured_event + captured_event = stop_event + resolver_started.set() + stop_event.wait(timeout=5) + return [] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)) as mock_run, + patch.object(api_client, "APIClient", autospec=True) as mock_client, + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_started.wait, 1) + + # The runner reports a successful connection + on_connect = mock_run.call_args.kwargs["on_connect"] + on_connect() + await asyncio.sleep(0.05) + + assert captured_event is not None + assert captured_event.is_set() + assert not task.done() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + mock_client.return_value.add_addresses.assert_not_called() + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_connect_before_discovery_skips_lookup() -> None: + """A connection during async_run startup prevents the lookup from starting.""" + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver = Mock(name="resolver") + + async def fake_async_run(*args, **kwargs): + # Connection succeeds before async_run even returns + kwargs["on_connect"]() + return stop + + with ( + patch.object(api_client, "async_run", AsyncMock(side_effect=fake_async_run)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.sleep(0.05) + assert not task.done() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + resolver.assert_not_called() + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_duplicate_addresses_logged(caplog) -> None: + """A discovery the client rejects as already known leaves a debug trace.""" + import threading + + caplog.set_level("DEBUG", logger="esphome.api_client") + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + fed = threading.Event() + + def resolver(stop_event): + return ["1.2.3.4"] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True) as mock_client, + ): + mock_client.return_value.add_addresses.side_effect = lambda addrs: ( + fed.set() or False + ) + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(fed.wait, 1) + await asyncio.sleep(0.05) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + mock_client.return_value.add_addresses.assert_called_once_with(["1.2.3.4"]) + assert "MQTT-discovered address(es) already known: 1.2.3.4" in caplog.text + assert "Discovered address(es) via MQTT" not in caplog.text + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_base_exception_escape_logged_at_teardown(caplog) -> None: + """A BaseException escaping the worker is reported, and stop() still runs.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver_ran = threading.Event() + + class WorkerEscape(BaseException): + """Not an Exception, so the task-level guard must not catch it.""" + + def resolver(stop_event): + resolver_ran.set() + raise WorkerEscape("worker bailed") + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_ran.wait, 1) + await asyncio.sleep(0.05) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert "MQTT address discovery failed" in caplog.text + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_stubborn_worker_cancelled_at_teardown() -> None: + """A worker that ignores the stop event is cancelled after the grace period.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver_ran = threading.Event() + release = threading.Event() + + def resolver(stop_event): + resolver_ran.set() + # Ignore stop_event entirely; only the test releases us + release.wait(timeout=10) + return [] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_ran.wait, 1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + release.set() + + stop.assert_awaited_once() diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 23bfdbcd69..a40341e194 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -25,6 +25,7 @@ from esphome.__main__ import ( _make_crystal_freq_callback, _redact_with_legacy_fallback, _resolve_network_devices, + _split_network_devices, _unresolved_default_error, _validate_bootloader_binary, _validate_partition_table_binary, @@ -2879,7 +2880,9 @@ def test_upload_program_ota_with_mqtt_resolution( assert exit_code == 0 assert host == "192.168.1.100" - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) expected_firmware = ( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) @@ -2926,7 +2929,9 @@ def test_upload_program_ota_with_mqtt_empty_broker( assert exit_code == 0 assert host == "192.168.1.50" # Verify MQTT was attempted but failed gracefully - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify we fell back to the IP address expected_firmware = ( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" @@ -3015,7 +3020,10 @@ def test_show_logs_api( assert result == 0 mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100", "192.168.1.101"], subscribe_states=True + CORE.config, + ["192.168.1.100", "192.168.1.101"], + subscribe_states=True, + mqtt_resolver=None, ) @@ -3042,7 +3050,7 @@ def test_show_logs_api_no_states( assert result == 0 mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100"], subscribe_states=False + CORE.config, ["192.168.1.100"], subscribe_states=False, mqtt_resolver=None ) @@ -3069,7 +3077,7 @@ def test_show_logs_api_with_fqdn_mdns_disabled( assert result == 0 # Should use the FQDN directly, not try MQTT lookup mock_run_logs.assert_called_once_with( - CORE.config, ["device.example.com"], subscribe_states=True + CORE.config, ["device.example.com"], subscribe_states=True, mqtt_resolver=None ) @@ -3097,9 +3105,44 @@ def test_show_logs_api_with_mqtt_fallback( result = show_logs(CORE.config, args, devices) assert result == 0 - mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None + ) mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.200"], subscribe_states=True + CORE.config, ["192.168.1.200"], subscribe_states=True, mqtt_resolver=None + ) + + +@patch("esphome.mqtt.show_logs") +def test_show_logs_api_mqtt_only_resolve_failure_falls_back_to_mqtt_logs( + mock_mqtt_show_logs: Mock, + mock_mqtt_get_ip: Mock, +) -> None: + """With no addresses at all after a failed MQTT lookup, MQTT logging is used.""" + setup_core( + config={ + "logger": {}, + CONF_API: {}, + CONF_MQTT: {CONF_BROKER: "mqtt.local"}, + }, + platform=PLATFORM_ESP32, + ) + mock_mqtt_show_logs.return_value = 0 + mock_mqtt_get_ip.side_effect = EsphomeError("Failed to find IP via MQTT") + + args = MockArgs( + topic="esphome/logs", username="user", password="pass", client_id="client" + ) + devices = ["MQTT", "MQTTIP"] + + result = show_logs(CORE.config, args, devices) + + assert result == 0 + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None + ) + mock_mqtt_show_logs.assert_called_once_with( + CORE.config, "esphome/logs", "user", "pass", "client" ) @@ -3466,7 +3509,9 @@ def test_mqtt_get_ip() -> None: result = mqtt_get_ip(config, "user", "pass", "client-id") assert result == ["192.168.1.100", "192.168.1.101"] - mock_get_ip.assert_called_once_with(config, "user", "pass", "client-id") + mock_get_ip.assert_called_once_with( + config, "user", "pass", "client-id", stop_event=None + ) def test_has_resolvable_address() -> None: @@ -3847,6 +3892,37 @@ def test_resolve_network_devices_keeps_uncached_hosts(tmp_path: Path) -> None: assert result == ["unknown.local", "192.168.1.50"] +def test_split_network_devices_direct_only(tmp_path: Path) -> None: + """Direct addresses pass through deduped, with no MQTT flag.""" + setup_core(tmp_path=tmp_path) + + assert _split_network_devices(["192.168.1.50", "device.local", "192.168.1.50"]) == ( + ["192.168.1.50", "device.local"], + False, + ) + + +def test_split_network_devices_mqtt_only(tmp_path: Path) -> None: + """MQTT magic strings produce no direct addresses, only the flag.""" + setup_core(tmp_path=tmp_path) + + assert _split_network_devices(["MQTTIP", "MQTT"]) == ([], True) + + +def test_split_network_devices_expands_cached_mdns_hosts(tmp_path: Path) -> None: + """Hostnames in ``CORE.address_cache`` are expanded like _resolve_network_devices.""" + setup_core(tmp_path=tmp_path) + CORE.address_cache = AddressCache( + mdns_cache={ + "device-abc123.local": ["10.0.0.1", "10.0.0.2"], + } + ) + + assert _split_network_devices( + ["device-abc123.local", "MQTTIP", "192.168.1.50", "device-abc123.local"] + ) == (["10.0.0.1", "10.0.0.2", "192.168.1.50"], True) + + def test_await_discovery_timeout_returns_empty( caplog: pytest.LogCaptureFixture, ) -> None: @@ -5022,7 +5098,9 @@ def test_upload_program_ota_static_ip_with_mqttip( assert host == "192.168.1.100" # Verify MQTT was resolved - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify espota2.run_ota was called with both IPs expected_firmware = ( @@ -5069,7 +5147,9 @@ def test_upload_program_ota_multiple_mqttip_resolves_once( assert host == "192.168.2.50" # Verify MQTT was only resolved once despite multiple MQTT magic strings - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify espota2.run_ota was called with all unique IPs expected_firmware = ( @@ -5116,7 +5196,9 @@ def test_upload_program_ota_mqttip_deduplication( assert host == "192.168.1.100" # Verify MQTT was resolved - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify espota2.run_ota was called with deduplicated IPs (only one instance of 192.168.1.100) # Note: Current implementation doesn't dedupe, so we'll get the IP twice @@ -5136,7 +5218,9 @@ def test_show_logs_api_static_ip_with_mqttip( This tests the scenario where a device has manual_ip (static IP) configured and MQTT is also configured. The devices list contains both the static IP - and "MQTTIP" magic string. + and "MQTTIP" magic string. The MQTT lookup must not block startup; it is + handed to run_logs as a deferred resolver instead (issue #18311), while + still being reachable as a fallback for a stale static IP. """ setup_core( config={ @@ -5157,12 +5241,19 @@ def test_show_logs_api_static_ip_with_mqttip( assert result == 0 - # Verify MQTT was resolved - mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") + # The broker must not be contacted before run_logs starts + mock_mqtt_get_ip.assert_not_called() - # Verify run_logs was called with both IPs - mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100", "192.168.2.50"], subscribe_states=True + # run_logs gets the static IP immediately plus a deferred MQTT resolver + mock_run_logs.assert_called_once() + assert mock_run_logs.call_args.args == (CORE.config, ["192.168.1.100"]) + assert mock_run_logs.call_args.kwargs["subscribe_states"] is True + resolver = mock_run_logs.call_args.kwargs["mqtt_resolver"] + + # Invoking the resolver performs the MQTT lookup (the #11260 fallback) + assert resolver(None) == ["192.168.2.50"] + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None ) @@ -5171,7 +5262,7 @@ def test_show_logs_api_multiple_mqttip_resolves_once( mock_run_logs: Mock, mock_mqtt_get_ip: Mock, ) -> None: - """Test that MQTT resolution only happens once for show_logs with multiple MQTT magic strings.""" + """Test that multiple MQTT magic strings collapse into one deferred resolver.""" setup_core( config={ "logger": {}, @@ -5191,16 +5282,16 @@ def test_show_logs_api_multiple_mqttip_resolves_once( assert result == 0 - # Verify MQTT was only resolved once despite multiple MQTT magic strings - mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") + # Note: "MQTT" is a different magic string from "MQTTIP", but both defer + # to the same single resolver; the broker is not contacted eagerly + mock_mqtt_get_ip.assert_not_called() + mock_run_logs.assert_called_once() + assert mock_run_logs.call_args.args == (CORE.config, ["192.168.1.100"]) - # Verify run_logs was called with all unique IPs (MQTT strings replaced with IPs) - # Note: "MQTT" is a different magic string from "MQTTIP", but both trigger MQTT resolution - # The _resolve_network_devices helper filters out both after first resolution - mock_run_logs.assert_called_once_with( - CORE.config, - ["192.168.2.50", "192.168.2.51", "192.168.1.100"], - subscribe_states=True, + resolver = mock_run_logs.call_args.kwargs["mqtt_resolver"] + assert resolver(None) == ["192.168.2.50", "192.168.2.51"] + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None ) @@ -5238,7 +5329,9 @@ def test_upload_program_ota_mqtt_timeout_fallback( assert host == "192.168.1.100" # Verify MQTT was attempted - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify espota2.run_ota was called with only the static IP (MQTT failed) expected_firmware = ( @@ -5254,7 +5347,7 @@ def test_show_logs_api_mqtt_timeout_fallback( mock_run_logs: Mock, mock_mqtt_get_ip: Mock, ) -> None: - """Test show_logs falls back to other devices when MQTT times out.""" + """Test show_logs proceeds with the static IP when MQTT times out.""" setup_core( config={ "logger": {}, @@ -5273,15 +5366,17 @@ def test_show_logs_api_mqtt_timeout_fallback( result = show_logs(CORE.config, args, devices) - # Should succeed using the static IP even though MQTT failed + # Logs start on the static IP without waiting for the broker assert result == 0 + mock_run_logs.assert_called_once() + assert mock_run_logs.call_args.args == (CORE.config, ["192.168.1.100"]) - # Verify MQTT was attempted - mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") - - # Verify run_logs was called with only the static IP (MQTT failed) - mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100"], subscribe_states=True + # The deferred resolver owns the failure policy: it logs a warning and + # returns no addresses so the session keeps running on the known ones + resolver = mock_run_logs.call_args.kwargs["mqtt_resolver"] + assert resolver(None) == [] + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None ) @@ -6764,7 +6859,7 @@ def test_command_run_passes_no_states_to_show_logs( assert result == 0 mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100"], subscribe_states=False + CORE.config, ["192.168.1.100"], subscribe_states=False, mqtt_resolver=None ) @@ -6805,7 +6900,7 @@ def test_command_run_defaults_subscribe_states_true( assert result == 0 mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100"], subscribe_states=True + CORE.config, ["192.168.1.100"], subscribe_states=True, mqtt_resolver=None ) diff --git a/tests/unit_tests/test_mqtt.py b/tests/unit_tests/test_mqtt.py index 4c2c34dff1..1ae10d0eb5 100644 --- a/tests/unit_tests/test_mqtt.py +++ b/tests/unit_tests/test_mqtt.py @@ -2,6 +2,11 @@ from __future__ import annotations +import json +import threading +import time +from unittest.mock import MagicMock, patch + import pytest from esphome.const import CONF_BROKER, CONF_ESPHOME, CONF_MQTT, CONF_NAME @@ -89,3 +94,260 @@ def test_get_esphome_device_ip_missing_name() -> None: match="Cannot discover IP via MQTT as the config does not include the device name:", ): get_esphome_device_ip(config) + + +def _discovery_config() -> dict: + return { + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + }, + CONF_ESPHOME: { + CONF_NAME: "test-device", + }, + } + + +def _deliver_on_loop_start(mock_prepare, client, payload: bytes) -> None: + """Deliver a discovery answer as soon as the network loop starts.""" + + def deliver(*args, **kwargs): + msg = MagicMock() + msg.payload = payload + mock_prepare.call_args.args[2](client, None, msg) + + client.loop_start.side_effect = deliver + + +def test_get_esphome_device_ip_success() -> None: + """A device answer on the discovery topic returns its IPs.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start( + mock_prepare, + client, + json.dumps( + {"name": "test-device", "ip": "10.0.0.5", "ip1": "10.0.0.6"} + ).encode(), + ) + + result = get_esphome_device_ip(_discovery_config()) + + assert result == ["10.0.0.5", "10.0.0.6"] + client.loop_stop.assert_called_once_with() + # Once from on_message on receiving the answer, once from the finally + assert client.disconnect.call_count == 2 + + +def test_get_esphome_device_ip_preset_stop_event_skips_lookup() -> None: + """A stop event set before the call returns [] without touching the broker.""" + stop_event = threading.Event() + stop_event.set() + + with patch("esphome.mqtt.prepare") as mock_prepare: + result = get_esphome_device_ip(_discovery_config(), stop_event=stop_event) + + assert result == [] + mock_prepare.assert_not_called() + + +def test_get_esphome_device_ip_stop_event_aborts_wait() -> None: + """A stop event set mid-wait exits quietly with no addresses.""" + stop_event = threading.Event() + client = MagicMock() + # Simulate teardown starting right after the network loop spins up + client.loop_start.side_effect = stop_event.set + + start = time.monotonic() + with patch("esphome.mqtt.prepare", return_value=client): + result = get_esphome_device_ip(_discovery_config(), stop_event=stop_event) + + # An abort is not a failure and must be nowhere near the 25s timeout + assert result == [] + assert time.monotonic() - start < 5 + client.disconnect.assert_called_once_with() + client.loop_stop.assert_called_once_with() + + +def test_get_esphome_device_ip_timeout_raises() -> None: + """No answer within the timeout raises EsphomeError (default stop event path).""" + client = MagicMock() + with ( + patch("esphome.mqtt.prepare", return_value=client), + pytest.raises(EsphomeError, match="Failed to find IP via MQTT"), + ): + get_esphome_device_ip(_discovery_config(), timeout=0.25) + + client.disconnect.assert_called_once_with() + client.loop_stop.assert_called_once_with() + + +def test_get_esphome_device_ip_stop_during_connect_skips_wait() -> None: + """A stop event set while the broker connect is in flight still cleans up.""" + stop_event = threading.Event() + client = MagicMock() + + def prepare_and_stop(*args): + stop_event.set() + return client + + with patch("esphome.mqtt.prepare", side_effect=prepare_and_stop): + result = get_esphome_device_ip(_discovery_config(), stop_event=stop_event) + + assert result == [] + client.loop_start.assert_not_called() + client.disconnect.assert_called_once_with() + client.loop_stop.assert_called_once_with() + + +def test_get_esphome_device_ip_replaces_reconnect_handler( + caplog: pytest.LogCaptureFixture, +) -> None: + """The one-shot discovery client must not inherit the reconnect-forever + handler, which would make loop_stop() join the network thread forever; + its replacement still reports a broker-initiated disconnect.""" + client = MagicMock() + prepare_handler = MagicMock() + client.on_disconnect = prepare_handler + + with ( + patch("esphome.mqtt.prepare", return_value=client), + pytest.raises(EsphomeError, match="Failed to find IP via MQTT"), + ): + get_esphome_device_ip(_discovery_config(), timeout=0.25) + + assert client.on_disconnect is not prepare_handler + client.on_disconnect(client, None, 0) + assert "Disconnected from MQTT broker" not in caplog.text + client.on_disconnect(client, None, 5) + assert "Disconnected from MQTT broker (5)" in caplog.text + + +def test_get_esphome_device_ip_answer_without_ip_fails_fast( + caplog: pytest.LogCaptureFixture, +) -> None: + """A device answer with no IP fields fails promptly, not at the timeout.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start( + mock_prepare, client, json.dumps({"name": "test-device"}).encode() + ) + + start = time.monotonic() + with pytest.raises(EsphomeError, match="Failed to find IP via MQTT"): + get_esphome_device_ip(_discovery_config(), timeout=5) + + assert time.monotonic() - start < 1 + assert "Device answer did not include an IP address" in caplog.text + + +@pytest.mark.parametrize("payload", [b"not json {", b"123", b"null"]) +def test_get_esphome_device_ip_unparsable_payload_ignored( + caplog: pytest.LogCaptureFixture, + payload: bytes, +) -> None: + """Garbage on the discovery topic must not kill paho's network thread.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start(mock_prepare, client, payload) + + with pytest.raises(EsphomeError, match="Failed to find IP via MQTT"): + get_esphome_device_ip(_discovery_config(), timeout=0) + + assert "Ignoring unparsable discovery payload" in caplog.text + + +def test_get_esphome_device_ip_broker_disconnect_fails_fast( + caplog: pytest.LogCaptureFixture, +) -> None: + """A broker-initiated disconnect aborts the wait instead of timing out.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client): + + def drop_connection(*args, **kwargs): + client.on_disconnect(client, None, 5) + + client.loop_start.side_effect = drop_connection + + start = time.monotonic() + with pytest.raises(EsphomeError, match="Failed to find IP via MQTT"): + get_esphome_device_ip(_discovery_config(), timeout=5) + + assert time.monotonic() - start < 1 + assert "Disconnected from MQTT broker (5)" in caplog.text + + +def test_get_esphome_device_ip_sends_discovery_ping() -> None: + """Connecting publishes the discovery ping for the device.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + + def connect_then_answer(*args, **kwargs): + on_connect = mock_prepare.call_args.args[3] + on_connect(client, None, None, 0) + msg = MagicMock() + msg.payload = json.dumps({"name": "test-device", "ip": "10.0.0.5"}).encode() + mock_prepare.call_args.args[2](client, None, msg) + + client.loop_start.side_effect = connect_then_answer + + result = get_esphome_device_ip(_discovery_config()) + + assert result == ["10.0.0.5"] + client.publish.assert_called_once_with( + "esphome/ping/test-device", None, retain=False + ) + + +def test_get_esphome_device_ip_disconnect_error_does_not_mask_result( + caplog: pytest.LogCaptureFixture, +) -> None: + """A cleanup failure must not replace the discovery result.""" + client = MagicMock() + # First disconnect (from on_message) succeeds; the finally's fails + client.disconnect.side_effect = [None, OSError("socket already closed")] + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start( + mock_prepare, + client, + json.dumps({"name": "test-device", "ip": "10.0.0.5"}).encode(), + ) + + result = get_esphome_device_ip(_discovery_config()) + + assert result == ["10.0.0.5"] + client.loop_stop.assert_called_once_with() + + +def test_get_esphome_device_ip_invalid_address_values_skipped( + caplog: pytest.LogCaptureFixture, +) -> None: + """Non-string or non-printable ip values are skipped, valid ones kept.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start( + mock_prepare, + client, + json.dumps( + { + "name": "test-device", + "ip": 1234, + "ip1": "x\n[00:00:00][I][forged] fake line", + "ip2": " 10.0.0.5 ", + } + ).encode(), + ) + + result = get_esphome_device_ip(_discovery_config()) + + assert result == ["10.0.0.5"] + assert caplog.text.count("Ignoring invalid address in discovery answer") == 2 + assert "forged" not in "".join( + r.getMessage() for r in caplog.records if "Found IP" in r.getMessage() + ) From bd58b5c8b31fb97618335e163170c12beb2e1c4c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 19:14:50 -0500 Subject: [PATCH 1443/1815] [core] Retry framework downloads on transient network errors (#18330) --- esphome/framework_helpers.py | 259 ++++++++++++++------- tests/unit_tests/test_framework_helpers.py | 201 +++++++++++++++- 2 files changed, 373 insertions(+), 87 deletions(-) diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 6ed608b171..86d5e4eaea 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -25,9 +25,13 @@ _LOGGER = logging.getLogger(__name__) # Attempts per mirror URL before falling through to the next mirror; only # mid-stream drops retry (resuming when the server gave a validator), -# connect errors move on immediately. +# connect errors move on to the next mirror immediately. _MIRROR_ATTEMPTS = 3 +# Passes over the whole mirror list when a transient network error is in +# the mix; matches git.py's _NETWORK_MAX_ATTEMPTS (3 tries, 2s/4s backoff). +_MIRROR_SWEEP_ATTEMPTS = 3 + def get_project_link_flags() -> list[str]: """Return the sorted -Wl, linker flags from the current build.""" @@ -887,37 +891,51 @@ def _failure_reason(e: Exception) -> str: return str(e).split(" for url: ", maxsplit=1)[0] or repr(e) -def download_from_mirrors( - mirrors: list[str], - substitutions: dict[str, str], - target: io.RawIOBase | IO[bytes] | PathType, - timeout: int = 30, -) -> str: +def _spent_attempts_error(e: Exception, attempts: int) -> Exception: + """Wrap a failure whose mirror already consumed download attempts, so + the sweep classifies it as permanent.""" + from esphome.core import EsphomeError + + err = EsphomeError(f"failed after {attempts} attempts: {_failure_reason(e)}") + err.__cause__ = e + return err + + +def _is_transient_download_error(e: Exception) -> bool: + """Return True when a download failure is worth retrying. + + Connection-level failures and HTTP 429/5xx are transient. Other HTTP + errors, local errors, and exhausted-attempts EsphomeError wrappers + (their per-mirror retries are already spent) are permanent. """ - Download file from multiple mirrors with substitution support. + # Imported lazily: requests is a heavy import (~85ms) and is only + # needed when actually downloading, never during config validation. + import requests - Args: - mirrors: list of mirror URLs - substitutions: Dictionary of substitutions to apply to URLs - target: Target file path or file-like object - timeout: Download timeout in seconds + if isinstance(e, requests.exceptions.HTTPError): + resp = e.response + return resp is not None and (resp.status_code == 429 or resp.status_code >= 500) + return isinstance( + e, + ( + requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + requests.exceptions.ChunkedEncodingError, + ), + ) - Returns: - The source URL. - Mirror URL templates that reference a substitution not present in - ``substitutions`` are skipped, so callers can offer templates that only - apply to some downloads. +def _try_mirrors_once( + urls: list[str], + path_target: Path | None, + f: IO[bytes] | None, + timeout: int, + failures: list[tuple[str, Exception]], +) -> str | None: + """Single pass over the resolved mirror ``urls``, one try per URL. - A path target downloads through ``download_with_resume``, so an - interrupted download resumes on the next esphome run; a file-like target - only resumes mid-stream drops within this call. - - Raises: - ValueError: If mirrors list is empty. - EsphomeError: If all download attempts fail; the message lists every - attempted URL with its individual failure reason. Also raised if - no template matched the provided substitutions. + Returns the source URL on success, or None with each URL's exception + appended to ``failures``. """ # Imported lazily: requests is a heavy import (~85ms) and is only # needed when actually downloading, never during config validation. @@ -925,43 +943,7 @@ def download_from_mirrors( from esphome.core import EsphomeError - ensure_happy_eyeballs() - - # 1. Classify the target: filesystem path or open file object - path_target: Path | None = None - f: IO[bytes] | None = None - if isinstance(target, (str, os.PathLike)): - path_target = Path(target) - elif isinstance(target, (io.RawIOBase, io.IOBase)): - f = target - else: - raise TypeError( - f"target must be str, Path, or file-like object: {type(target)}" - ) - - # 2. Try each mirror in order - failures: list[tuple[str, Exception]] = [] - skipped: list[tuple[str, str]] = [] - - for mirror in mirrors: - # 3. Apply substitutions to URL - try: - url = mirror.format(**substitutions) - except KeyError as e: - # The template references a substitution not provided for - # this download (e.g. SHORT_VERSION only exists for x.y.0 - # versions) - expected, the template just doesn't apply. - _LOGGER.debug("Skipping mirror %s: %s not available", mirror, e) - skipped.append((mirror, f"not applicable ({e.args[0]} not available)")) - continue - except (IndexError, ValueError) as e: - # A malformed template (unbalanced braces, bad format spec) - # is an authoring error, not an expected fallthrough - warn - # even if a later mirror succeeds. - _LOGGER.warning("Skipping malformed mirror URL template %s: %r", mirror, e) - skipped.append((mirror, f"skipped ({e!r})")) - continue - + for url in urls: _LOGGER.debug("Trying to download from %s", url) # Path targets delegate to download_with_resume so a partial @@ -986,14 +968,14 @@ def download_from_mirrors( failures.append((url, e)) continue - # 4. Download; mid-stream failures retry the same mirror with - # resume (see download_with_resume) instead of starting over. - # There is no checksum to verify a resumed file against, so a - # stitch is only trusted when the server proves consistency: the - # If-Range validator guarantees 206 only for unchanged content, - # and the expected total length (when the first response carried - # one) guards against short or shifted bodies. Without a - # validator the retry restarts from zero. + # File-like targets download here; mid-stream failures retry the + # same mirror with resume (see download_with_resume) instead of + # starting over. There is no checksum to verify a resumed file + # against, so a stitch is only trusted when the server proves + # consistency: the If-Range validator guarantees 206 only for + # unchanged content, and the expected total length (when the first + # response carried one) guards against short or shifted bodies. + # Without a validator the retry restarts from zero. offset = 0 expected_total = 0 validator = None @@ -1001,9 +983,12 @@ def download_from_mirrors( try: resp, offset = _open_ranged(url, offset, timeout, validator) except (requests.RequestException, OSError) as e: - # Connect/HTTP error, no bytes flowed — next mirror. + # Connect/HTTP error, no bytes flowed — next mirror. Wrap + # when earlier attempts were already spent on this mirror. _LOGGER.debug("Failed to download %s: %s", url, str(e)) - failures.append((url, e)) + failures.append( + (url, _spent_attempts_error(e, attempt + 1) if attempt else e) + ) break try: @@ -1031,7 +1016,7 @@ def download_from_mirrors( _LOGGER.debug("Downloaded successfully from: %s", url) - # 5. Reset file pointer and return + # Reset file pointer and return f.seek(0) return url @@ -1054,16 +1039,124 @@ def download_from_mirrors( ) offset = 0 if attempt == _MIRROR_ATTEMPTS - 1: - failures.append((url, e)) + failures.append((url, _spent_attempts_error(e, _MIRROR_ATTEMPTS))) - # 6. Report every attempted URL if all mirrors failed. Falling back - # past an early mirror is normal (e.g. only one of the framework URL - # templates matches a given version's tag), so raising only the last - # error would hide the failure that actually matters. - if failures: - attempts = "".join( - f"\n {url}\n {_failure_reason(e)}" for url, e in failures + return None + + +def download_from_mirrors( + mirrors: list[str], + substitutions: dict[str, str], + target: io.RawIOBase | IO[bytes] | PathType, + timeout: int = 30, +) -> str: + """ + Download file from multiple mirrors with substitution support. + + Args: + mirrors: list of mirror URLs + substitutions: Dictionary of substitutions to apply to URLs + target: Target file path or file-like object + timeout: Download timeout in seconds + + Returns: + The source URL. + + Mirror URL templates that reference a substitution not present in + ``substitutions`` are skipped, so callers can offer templates that only + apply to some downloads. + + A path target downloads through ``download_with_resume``, so an + interrupted download resumes on the next esphome run; a file-like target + only resumes mid-stream drops within this call. + + When every mirror fails and at least one failure is transient (dropped + connection, timeout, HTTP 429/5xx), the whole list is retried with a + short backoff; permanent failures (e.g. 404) raise immediately. + + Raises: + ValueError: If mirrors list is empty. + EsphomeError: If all download attempts fail; the message lists every + attempted URL with its individual failure reason. Also raised if + no template matched the provided substitutions. + """ + from esphome.core import EsphomeError + + ensure_happy_eyeballs() + + # 1. Classify the target: filesystem path or open file object + path_target: Path | None = None + f: IO[bytes] | None = None + if isinstance(target, (str, os.PathLike)): + path_target = Path(target) + elif isinstance(target, (io.RawIOBase, io.IOBase)): + f = target + else: + raise TypeError( + f"target must be str, Path, or file-like object: {type(target)}" ) + + # 2. Resolve the mirror templates (invariant across retry sweeps) + urls: list[str] = [] + skipped: list[tuple[str, str]] = [] + for mirror in mirrors: + try: + urls.append(mirror.format(**substitutions)) + except KeyError as e: + # The template references a substitution not provided for + # this download (e.g. SHORT_VERSION only exists for x.y.0 + # versions) - expected, the template just doesn't apply. + _LOGGER.debug("Skipping mirror %s: %s not available", mirror, e) + skipped.append((mirror, f"not applicable ({e.args[0]} not available)")) + except (IndexError, ValueError) as e: + # A malformed template (unbalanced braces, bad format spec) + # is an authoring error, not an expected fallthrough - warn + # even if a later mirror succeeds. + _LOGGER.warning("Skipping malformed mirror URL template %s: %r", mirror, e) + skipped.append((mirror, f"skipped ({e!r})")) + + # 3. Sweep the mirror list, retrying transient failures with backoff: + # a single pass keeps mirror failover fast, re-sweeping keeps one + # network blip from failing the build when only one mirror applies. + failures: list[tuple[str, Exception]] = [] + for sweep in range(1, _MIRROR_SWEEP_ATTEMPTS + 1): + sweep_failures: list[tuple[str, Exception]] = [] + if ( + url := _try_mirrors_once(urls, path_target, f, timeout, sweep_failures) + ) is not None: + return url + failures.extend(sweep_failures) + # Permanent failures (404, verification mismatch) won't heal; + # only retry when a transient error is in the mix (as git.py does). + transient = next( + ((u, e) for u, e in sweep_failures if _is_transient_download_error(e)), + None, + ) + if transient is None: + break + if sweep < _MIRROR_SWEEP_ATTEMPTS: + delay = 2**sweep + _LOGGER.warning( + "Download of %s failed (%s); retrying in %d seconds (attempt %d/%d)", + transient[0], + _failure_reason(transient[1]), + delay, + sweep + 1, + _MIRROR_SWEEP_ATTEMPTS, + ) + time.sleep(delay) + + # 4. Report every attempted URL if all mirrors failed. failures spans + # all sweeps (deduplicated by URL and reason), so neither an early + # mirror's failure nor an earlier sweep's failure mode is hidden. + if failures: + seen: set[tuple[str, str]] = set() + attempts = "" + for url, e in failures: + reason = _failure_reason(e) + if (url, reason) not in seen: + seen.add((url, reason)) + attempts += f"\n {url}\n {reason}" attempts += "".join(f"\n {mirror}\n {reason}" for mirror, reason in skipped) raise EsphomeError( f"Failed to download from all mirrors:{attempts}" diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index 08751879c2..7451ee9b39 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -12,7 +12,7 @@ from pathlib import Path import subprocess import sys import tarfile -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, Mock, call, patch import zipfile import pytest @@ -23,6 +23,7 @@ from esphome.core import EsphomeError from esphome.framework_helpers import ( _7z_extract_all, _detect_archive_root, + _is_transient_download_error, _rename_with_retry, _tar_extract_all, _zip_extract_all, @@ -515,16 +516,23 @@ class TestArchiveExtractAll: # --------------------------------------------------------------------------- -def _mock_response(content: bytes, ok: bool = True) -> MagicMock: +def _mock_response( + content: bytes, ok: bool = True, status: int | None = None +) -> MagicMock: + """A fake requests response. The HTTPError carries the response (as + ``raise_for_status`` on a real response) so the transient classifier + can see its ``status``; failures default to a permanent 404.""" + if status is None: + status = 200 if ok else 404 r = MagicMock() r.__enter__.return_value = r r.__exit__.return_value = False - r.status_code = 200 + r.status_code = status r.ok = ok if ok: r.raise_for_status.return_value = None else: - r.raise_for_status.side_effect = req.HTTPError("503") + r.raise_for_status.side_effect = req.HTTPError(str(status), response=r) r.headers = {"content-length": "0"} # suppress ProgressBar r.iter_content.return_value = [content] if content else [] return r @@ -1419,6 +1427,191 @@ class TestDownloadFromMirrors: assert target.exists() assert target.read_bytes() == b"" + @pytest.mark.parametrize("target_kind", ["path", "file-like"]) + def test_transient_failure_retries_mirror_sweep( + self, tmp_path: Path, target_kind: str + ) -> None: + """A transient connect error on the only applicable mirror retries the + whole mirror list with backoff instead of failing the build.""" + target = tmp_path / "idf.tar.xz" if target_kind == "path" else io.BytesIO() + with ( + patch( + "requests.get", + side_effect=[ + req.ConnectionError("Remote end closed connection"), + _mock_response(b"data"), + ], + ) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + ): + url = download_from_mirrors(["https://mirror1.com/f"], {}, target) + assert url == "https://mirror1.com/f" + data = target.read_bytes() if target_kind == "path" else target.getvalue() + assert data == b"data" + assert mock_get.call_count == 2 + mock_sleep.assert_called_once_with(2) + + def test_permanent_failure_does_not_retry_sweep(self, tmp_path: Path) -> None: + """An HTTP 404 will not heal on its own; fail after a single pass.""" + with ( + patch( + "requests.get", return_value=_mock_response(b"", ok=False, status=404) + ) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="all mirrors"), + ): + download_from_mirrors(["https://mirror1.com/f"], {}, tmp_path / "out.bin") + assert mock_get.call_count == 1 + mock_sleep.assert_not_called() + + def test_transient_failure_exhausts_sweeps(self, tmp_path: Path) -> None: + """A persistent transient error gives up after the configured number + of passes, with 2s/4s backoff, and still lists the attempted URL.""" + with ( + patch("requests.get", side_effect=req.ConnectionError("down")) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="all mirrors") as ei, + ): + download_from_mirrors(["https://mirror1.com/f"], {}, tmp_path / "out.bin") + assert mock_get.call_count == 3 + assert mock_sleep.call_args_list == [call(2), call(4)] + assert "https://mirror1.com/f" in str(ei.value) + + def test_mixed_permanent_and_transient_retries_sweep(self, tmp_path: Path) -> None: + """One mirror 404s permanently while another hits a transient error; + the transient failure makes the whole list worth another pass.""" + dest = tmp_path / "out.bin" + with ( + patch( + "requests.get", + side_effect=[ + _mock_response(b"", ok=False, status=404), + req.ConnectionError("down"), + _mock_response(b"", ok=False, status=404), + _mock_response(b"data"), + ], + ), + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + ): + url = download_from_mirrors( + ["https://mirror1.com/f", "https://mirror2.com/f"], {}, dest + ) + assert url == "https://mirror2.com/f" + assert dest.read_bytes() == b"data" + mock_sleep.assert_called_once_with(2) + + def test_http_5xx_retries_sweep(self, tmp_path: Path) -> None: + """A real 5xx (response attached to the HTTPError) is transient.""" + dest = tmp_path / "out.bin" + with ( + patch( + "requests.get", + side_effect=[ + _mock_response(b"", ok=False, status=503), + _mock_response(b"data"), + ], + ), + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + ): + url = download_from_mirrors(["https://mirror1.com/f"], {}, dest) + assert url == "https://mirror1.com/f" + assert dest.read_bytes() == b"data" + mock_sleep.assert_called_once_with(2) + + def test_error_reports_failure_modes_from_all_sweeps(self, tmp_path: Path) -> None: + """A failure mode that changes between sweeps stays in the final + error; the first failure (the one that started the retries) is + chained as the cause.""" + with ( + patch( + "requests.get", + side_effect=[ + req.ConnectionError("dropped by middlebox"), + _mock_response(b"", ok=False, status=404), + ], + ), + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="all mirrors") as ei, + ): + download_from_mirrors(["https://mirror1.com/f"], {}, tmp_path / "out.bin") + assert "dropped by middlebox" in str(ei.value) + assert "404" in str(ei.value) + assert isinstance(ei.value.__cause__, req.ConnectionError) + mock_sleep.assert_called_once_with(2) + + def test_exhausted_mid_stream_attempts_not_swept(self) -> None: + """A file-like mirror that spent all its mid-stream attempts is not + retried again at the sweep level (unlike a path target, it has no + part file to resume from on a later sweep).""" + buf = io.BytesIO() + with ( + patch( + "requests.get", + side_effect=[_interrupted_response(b"1234") for _ in range(3)], + ) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="failed after 3 attempts"), + ): + download_from_mirrors(["https://mirror1.com/f"], {}, buf) + assert mock_get.call_count == 3 + mock_sleep.assert_not_called() + + def test_mid_stream_drop_then_connect_error_not_swept(self) -> None: + """A connect error on a later attempt (after a mid-stream drop spent + one) also counts as spent budget and does not re-arm the sweep.""" + buf = io.BytesIO() + with ( + patch( + "requests.get", + side_effect=[ + _interrupted_response(b"1234"), + req.ConnectionError("down"), + ], + ) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="failed after 2 attempts"), + ): + download_from_mirrors(["https://mirror1.com/f"], {}, buf) + assert mock_get.call_count == 2 + mock_sleep.assert_not_called() + + +def _http_error(status: int) -> req.HTTPError: + """An HTTPError carrying a response with the given status, as raised by + ``raise_for_status`` on a real response.""" + resp = MagicMock() + resp.status_code = status + return req.HTTPError(str(status), response=resp) + + +class TestIsTransientDownloadError: + def test_connection_errors_are_transient(self) -> None: + assert _is_transient_download_error(req.ConnectionError("reset")) + assert _is_transient_download_error(req.Timeout("timed out")) + assert _is_transient_download_error( + req.exceptions.ChunkedEncodingError("dropped") + ) + + def test_http_statuses(self) -> None: + assert not _is_transient_download_error(_http_error(404)) + assert not _is_transient_download_error(_http_error(403)) + assert _is_transient_download_error(_http_error(429)) + assert _is_transient_download_error(_http_error(503)) + + def test_http_error_without_response_is_permanent(self) -> None: + assert not _is_transient_download_error(req.HTTPError("boom")) + + def test_exhausted_resume_attempts_are_permanent(self) -> None: + """download_with_resume already spent its own resume attempts; its + EsphomeError wrapper is not retried again at the sweep level.""" + wrapped = EsphomeError("Failed to download after 3 attempts") + wrapped.__cause__ = req.ConnectionError("down") + assert not _is_transient_download_error(wrapped) + + def test_unrelated_errors_are_permanent(self) -> None: + assert not _is_transient_download_error(OSError("disk full")) + assert not _is_transient_download_error(EsphomeError("size mismatch")) + def test_importing_framework_helpers_does_not_import_requests() -> None: """Importing framework_helpers must not drag in requests. From d3e27054f6f621e712d85e1bba4172ea6d7444c5 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:30:47 +1200 Subject: [PATCH 1444/1815] Bump version to 2026.8.0b2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 006f97acb7..a8c77f4bb8 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.8.0b1 +PROJECT_NUMBER = 2026.8.0b2 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 623d9673bc..b6770d0001 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.8.0b1" +__version__ = "2026.8.0b2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 9cc05b30d401daba19a061fe610a952590987961 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 21:09:01 -0500 Subject: [PATCH 1445/1815] [web_server_base] Stop deleting the web server on captive portal teardown (#18324) --- .../web_server/ota/ota_web_server.cpp | 2 +- .../web_server_base/web_server_base.h | 20 +++++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 9812714ec0..95763e2daf 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -249,7 +249,7 @@ void WebServerOTAComponent::setup() { return; } - // AsyncWebServer takes ownership of the handler and will delete it when the server is destroyed + // The handler lives for the life of the process; WebServerBase never destroys its server base->add_handler(new OTARequestHandler(this)); // NOLINT } diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index c647a13b50..94579de70f 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -112,9 +112,18 @@ class AuthMiddlewareHandler : public MiddlewareHandler { class WebServerBase final { public: + // The AsyncWebServer is created once and intentionally never deleted: on Arduino + // platforms ESPAsyncWebServer owns its registered handlers, so destroying it would + // also destroy live components (e.g. the captive portal) out from under us. + // init()/deinit() refcount users and start/stop the listener; handlers are + // registered once at creation and survive listener restarts. void init() { - if (this->initialized_) { - this->initialized_++; + this->initialized_++; + if (this->server_ != nullptr) { + if (this->initialized_ == 1) { + // Restart the listener after a previous deinit() + this->server_->begin(); + } return; } this->server_ = new AsyncWebServer(this->port_); @@ -126,14 +135,13 @@ class WebServerBase final { for (auto *handler : this->handlers_) this->server_->addHandler(handler); - - this->initialized_++; } void deinit() { + if (this->initialized_ == 0) + return; // unbalanced deinit() this->initialized_--; if (this->initialized_ == 0) { - delete this->server_; - this->server_ = nullptr; + this->server_->end(); } } AsyncWebServer *get_server() const { return this->server_; } From f337d0acff4dfae8e09021508f2bfdff070d2903 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:13:29 +0000 Subject: [PATCH 1446/1815] Bump pylint from 4.0.6 to 4.0.7 (#18320) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 0905fe6be1..1832ffd433 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,4 @@ -pylint==4.0.6 +pylint==4.0.7 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating ruff==0.16.2 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating From f1c40867783064dc711be70c3412d0066a80c378 Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Thu, 13 Aug 2026 13:36:11 +0200 Subject: [PATCH 1447/1815] [const] Move CONF_SLOT to components/const (#18350) Co-authored-by: Oliver Kleinecke --- esphome/components/const/__init__.py | 1 + esphome/components/esp32_hosted/__init__.py | 3 +-- esphome/components/sendspin/image/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 44878274d6..2d02c7d179 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -35,6 +35,7 @@ CONF_REQUEST_HEADERS = "request_headers" CONF_ROWS = "rows" CONF_SCAN_PARAMETERS = "scan_parameters" CONF_SHA256 = "sha256" +CONF_SLOT = "slot" CONF_STATE_SAVE_INTERVAL = "state_save_interval" CONF_STOP_BITS = "stop_bits" CONF_TARGET_COUNT = "target_count" diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index b15ae53711..c6a714aace 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -3,7 +3,7 @@ from pathlib import Path from esphome import pins from esphome.components import esp32 -from esphome.components.const import CONF_USE_PSRAM +from esphome.components.const import CONF_SLOT, CONF_USE_PSRAM import esphome.config_validation as cv from esphome.const import ( CONF_CLK_PIN, @@ -33,7 +33,6 @@ CONF_DATA_READY_PIN = "data_ready_pin" CONF_HANDSHAKE_ACTIVE_HIGH = "handshake_active_high" CONF_HANDSHAKE_PIN = "handshake_pin" CONF_SDIO_FREQUENCY = "sdio_frequency" -CONF_SLOT = "slot" CONF_SPI_MODE = "spi_mode" # Shared fields for both transport modes diff --git a/esphome/components/sendspin/image/__init__.py b/esphome/components/sendspin/image/__init__.py index 94d6e7cfca..3c6c82b009 100644 --- a/esphome/components/sendspin/image/__init__.py +++ b/esphome/components/sendspin/image/__init__.py @@ -3,6 +3,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import runtime_image +from esphome.components.const import CONF_SLOT from esphome.components.image import CONF_TRANSPARENCY, Image_, add_metadata import esphome.config_validation as cv from esphome.const import ( @@ -45,7 +46,6 @@ MAX_IMAGE_DIMENSION = 32767 MAX_DISPLAY_OFFSET = cv.TimePeriod(seconds=60) MIN_DISPLAY_OFFSET = cv.TimePeriod(seconds=-60) -CONF_SLOT = "slot" CONF_CURRENT_IMAGE = "current_image" CONF_TRANSITION_IMAGE = "transition_image" CONF_ON_IMAGE_DISPLAY = "on_image_display" From 87045ab9c020e95da9264c26391ffbf2bc5a4438 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:14:14 -0500 Subject: [PATCH 1448/1815] Bump aioesphomeapi from 45.10.1 to 45.10.2 (#18357) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 85a0f55263..683008400a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.10.1 +aioesphomeapi==45.10.2 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From c7940382a9a210226b2e2a7eee668dc6dc4c21a5 Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Thu, 13 Aug 2026 20:18:22 +0200 Subject: [PATCH 1449/1815] [const] move CONF_LABEL to components/const (#18354) Co-authored-by: Oliver Kleinecke Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/const/__init__.py | 1 + esphome/components/display_menu_base/__init__.py | 2 +- esphome/components/lvgl/widgets/label.py | 3 +-- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 2d02c7d179..3ba89d2838 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -22,6 +22,7 @@ CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" CONF_IAQ = "iaq" CONF_IGNORE_NOT_FOUND = "ignore_not_found" +CONF_LABEL = "label" CONF_LIBRETINY = "libretiny" CONF_LOOP = "loop" CONF_NOX_INDEX = "nox_index" diff --git a/esphome/components/display_menu_base/__init__.py b/esphome/components/display_menu_base/__init__.py index 9125c43f0c..2120abe5f7 100644 --- a/esphome/components/display_menu_base/__init__.py +++ b/esphome/components/display_menu_base/__init__.py @@ -3,6 +3,7 @@ import re from esphome import automation, core from esphome.automation import maybe_simple_id import esphome.codegen as cg +from esphome.components.const import CONF_LABEL from esphome.components.number import Number from esphome.components.select import Select from esphome.components.switch import Switch @@ -30,7 +31,6 @@ display_menu_base_ns = cg.esphome_ns.namespace("display_menu_base") CONF_ROTARY = "rotary" CONF_JOYSTICK = "joystick" -CONF_LABEL = "label" CONF_MENU = "menu" CONF_BACK = "back" CONF_SELECT = "select" diff --git a/esphome/components/lvgl/widgets/label.py b/esphome/components/lvgl/widgets/label.py index 5ac92f2717..54c9819d2b 100644 --- a/esphome/components/lvgl/widgets/label.py +++ b/esphome/components/lvgl/widgets/label.py @@ -1,3 +1,4 @@ +from esphome.components.const import CONF_LABEL import esphome.config_validation as cv from esphome.const import CONF_TEXT @@ -14,8 +15,6 @@ from ..schemas import TEXT_SCHEMA from ..types import LvText from . import Widget, WidgetType -CONF_LABEL = "label" - class LabelType(WidgetType): def __init__(self): From db5173697a40c92e6f7a4dfc1d97c59580bc9271 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 13:22:48 -0500 Subject: [PATCH 1450/1815] [esp32_ble_tracker] Fix missed BLE advertisements with WiFi on ESP-IDF 5.5.5 (#18356) --- .../components/ble_device_base/__init__.py | 20 ++- .../components/esp32_ble_tracker/__init__.py | 71 +++++++++- .../test_scan_parameter_validation.py | 7 +- .../esp32_ble_tracker/__init__.py | 0 .../test_scan_window_default.py | 122 ++++++++++++++++++ 5 files changed, 211 insertions(+), 9 deletions(-) create mode 100644 tests/component_tests/esp32_ble_tracker/__init__.py create mode 100644 tests/component_tests/esp32_ble_tracker/test_scan_window_default.py diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index 4da7d48882..15a8b08139 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -37,7 +37,7 @@ from esphome.const import ( CONF_INTERVAL, KEY_TARGET_PLATFORM, ) -from esphome.core import CORE, ID, KEY_CORE +from esphome.core import CORE, ID, KEY_CORE, TimePeriod from esphome.types import ConfigType CODEOWNERS = ["@Bl00d-B0b"] @@ -243,19 +243,27 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType: return config +# The historical scan window default shared by the trackers that do not pin +# their own; also the fallback for esp32's conditional default. +DEFAULT_SCAN_WINDOW = "30ms" + + def scan_parameters_schema( interval_default: str, *, - window_default: str = "30ms", + window_default: str | Callable[[], TimePeriod] = DEFAULT_SCAN_WINDOW, ) -> cv.All: """Build the scan_parameters value schema shared by all BLE trackers. interval_default and window_default are per chip (e.g. esp32 320/30 ms, bk72xx/rp2 100/30 ms — the reference scan rates of the respective stacks; - LN882H's SDK recommends 100/50 ms). The `active` option (default on) is - unconditional: active scanning is part of the tracker contract — every - current proxy client assumes it, so a passive-only tracker must not share - this schema. + LN882H's SDK recommends 100/50 ms). window_default may also be a zero-arg + callable evaluated per validation when the user omits the key (esp32 uses + this to record that the window was defaulted, so a later validation step + can adjust it once sibling keys are resolved). The `active` option + (default on) is unconditional: active scanning is part of the tracker + contract — every current proxy client assumes it, so a passive-only + tracker must not share this schema. """ schema = { cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds, diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 634b8c3bef..28c8c7fcf1 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -1,5 +1,7 @@ from __future__ import annotations +import copy +from dataclasses import dataclass import logging from esphome import automation @@ -8,6 +10,7 @@ from esphome.components import ble_device_base, esp32_ble, ota from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW from esphome.components.esp32 import ( add_idf_sdkconfig_option, + idf_version, request_bluetooth, request_software_coexistence, ) @@ -35,10 +38,12 @@ from esphome.const import ( CONF_SERVICE_UUID, CONF_TRIGGER_ID, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, TimePeriod, coroutine_with_priority from esphome.enum import StrEnum from esphome.types import ConfigType +DOMAIN = "esp32_ble_tracker" + AUTO_LOAD = ["ble_device_base", "esp32_ble"] DEPENDENCIES = ["esp32"] CODEOWNERS = ["@bdraco"] @@ -125,10 +130,71 @@ def validate_max_connections_deprecated(config: ConfigType) -> ConfigType: return config +# ESP-IDF 5.5.5 fixed a coexistence bug on the ESP32 where BLE scans ran far +# longer than the configured window (espressif/esp-idf#18931). Before the fix, +# the default 30 ms window in a 320 ms interval effectively scanned at a much +# higher duty cycle than requested; with the fix, that same default only +# listens 9.4 % of the time and misses most advertisements when wifi shares +# the radio. Espressif recommends setting the window equal to the interval in +# that case: the coexistence arbiter still shares the radio with wifi, and +# BLE uses the airtime wifi does not claim. +IDF_SCAN_WINDOW_FIX_VERSION = cv.Version(5, 5, 5) + + +@dataclass +class TrackerData: + """Per-run validation state, namespaced under DOMAIN in CORE.data.""" + + scan_window_defaulted: bool = False + + +def _get_data() -> TrackerData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = TrackerData() + return CORE.data[DOMAIN] + + +def _scan_window_default() -> TimePeriod: + """Schema default for the scan window. + + Records that the user did not set a window, so _raise_defaulted_scan_window + can tell a defaulted 30 ms from an explicit one; the raise itself must wait + for the outer schema because it depends on software_coexistence, a sibling + key not yet resolved here. + """ + _get_data().scan_window_defaulted = True + return cv.positive_time_period(ble_device_base.DEFAULT_SCAN_WINDOW) + + +def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType: + """Raise a defaulted scan window to the interval where that is safe. + + Only when the coexistence arbiter is compiled in (software_coexistence, + present iff wifi is configured and not disabled by the user) and the IDF + honors the window strictly (>= 5.5.5); without the arbiter a full-duty + scan would starve wifi outright, and a user-set window is never touched. + Raising to the interval cannot invalidate the already-validated + parameters, so no re-validation is needed. + """ + if ( + _get_data().scan_window_defaulted + and config.get(CONF_SOFTWARE_COEXISTENCE) + and idf_version() >= IDF_SCAN_WINDOW_FIX_VERSION + ): + params = config[CONF_SCAN_PARAMETERS] + # Copy so the config dump shows a plain value instead of a YAML + # anchor/alias pair pointing at the interval. + params[CONF_WINDOW] = copy.copy(params[CONF_INTERVAL]) + return config + + # 320 ms is the ESP-IDF reference scan interval; the shared schema also # tightens validation to the controller's 2.5 ms .. 10240 ms range and rejects # window/interval pairs that collapse to the same 0.625 ms unit count. -SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("320ms") +# The window default is conditional (see _scan_window_default above). +SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema( + "320ms", window_default=_scan_window_default +) # Codegen helpers are owned by ble_device_base; kept under the historical names # here for the components that import them from this module. @@ -183,6 +249,7 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), validate_max_connections_deprecated, + _raise_defaulted_scan_window, ) diff --git a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py index 2549125a43..3774d990d3 100644 --- a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py +++ b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py @@ -57,7 +57,12 @@ def test_bk72xx_defaults_are_valid() -> None: def test_esp32_defaults_are_valid() -> None: - """esp32 pins the ESP-IDF reference rate and exposes active (default on).""" + """esp32 pins the ESP-IDF reference rate and exposes active (default on). + + Without wifi loaded, the conditional window default falls back to the + historical 30 ms; the wifi-aware resolution is covered by the + esp32_ble_tracker component tests. + """ config = ESP32_SCHEMA({}) assert to_ble_units(config["interval"]) == 512 assert to_ble_units(config["window"]) == 48 diff --git a/tests/component_tests/esp32_ble_tracker/__init__.py b/tests/component_tests/esp32_ble_tracker/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py b/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py new file mode 100644 index 0000000000..8a25f488fa --- /dev/null +++ b/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py @@ -0,0 +1,122 @@ +"""Tests for the esp32_ble_tracker conditional scan window default. + +The scan window default depends on wifi coexistence and the IDF version: +IDF 5.5.5 fixed a coexistence bug where BLE scans ran far longer than the +configured window (espressif/esp-idf#18931), so on fixed versions the +historical 30 ms default would only listen 9.4 % of the time and miss most +advertisements. With the coexistence arbiter compiled in on a fixed IDF, the +window instead defaults to the interval, as Espressif recommends; without the +arbiter a full-duty scan would starve wifi, so the 30 ms default is kept. +""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest + +from esphome import config_validation as cv +from esphome.components.ble_device_base import to_ble_units +from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW +from esphome.components.esp32 import KEY_IDF_VERSION +from esphome.components.esp32_ble_tracker import ( + CONF_SOFTWARE_COEXISTENCE, + CONFIG_SCHEMA, +) +from esphome.const import CONF_INTERVAL, PlatformFramework +from esphome.core import CORE +from esphome.types import ConfigType + +from ..types import SetCoreConfigCallable + + +@pytest.fixture +def stage_esp32( + set_core_config: SetCoreConfigCallable, +) -> Callable[..., None]: + """Stage an esp32 build with a given IDF version and wifi presence.""" + + def stage(idf: str, *, wifi: bool) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)}, + ) + if wifi: + # Makes cv.OnlyWith default software_coexistence to True, exactly + # as a real config with wifi: does. + CORE.loaded_integrations.add("wifi") + + return stage + + +def _scan_params(config: ConfigType) -> ConfigType: + return CONFIG_SCHEMA(config)[CONF_SCAN_PARAMETERS] + + +@pytest.mark.parametrize( + ("idf", "config", "expected_units"), + [ + ("5.5.5", {}, 512), # first fixed version, default 320 ms interval + ("6.0.1", {}, 512), # any newer version behaves the same + # Follows a user-set interval. + ("5.5.5", {"scan_parameters": {"interval": "1s"}}, 1600), + ], +) +def test_wifi_on_fixed_idf_defaults_window_to_interval( + stage_esp32: Callable[..., None], + idf: str, + config: ConfigType, + expected_units: int, +) -> None: + """With wifi coexistence on a fixed IDF, the window defaults to the interval.""" + stage_esp32(idf, wifi=True) + params = _scan_params(config) + assert params[CONF_WINDOW] == params[CONF_INTERVAL] + assert to_ble_units(params[CONF_WINDOW]) == expected_units + + +@pytest.mark.parametrize( + ("idf", "wifi", "config"), + [ + # Buggy IDF over-scans anyway; keep the 30 ms default. + ("5.5.4", True, {}), + # No wifi (e.g. ethernet) means no radio contention. + ("5.5.5", False, {}), + # Coexistence disabled: no arbiter, so a full-duty scan would starve + # wifi outright. + ("5.5.5", True, {CONF_SOFTWARE_COEXISTENCE: False}), + ], +) +def test_30ms_default_kept( + stage_esp32: Callable[..., None], + idf: str, + wifi: bool, + config: ConfigType, +) -> None: + stage_esp32(idf, wifi=wifi) + assert to_ble_units(_scan_params(config)[CONF_WINDOW]) == 48 + + +@pytest.mark.parametrize("window", ["60ms", "30ms"]) +def test_explicit_window_is_never_touched( + stage_esp32: Callable[..., None], window: str +) -> None: + """A user-set window wins over the conditional default. + + The explicit 30 ms case matters: it is indistinguishable from the + defaulted value by inspection, so the defaulted flag must separate them. + """ + stage_esp32("5.5.5", wifi=True) + params = _scan_params({"scan_parameters": {"window": window}}) + assert to_ble_units(params[CONF_WINDOW]) == to_ble_units( + cv.positive_time_period(window) + ) + + +def test_short_interval_without_window_still_rejected( + stage_esp32: Callable[..., None], +) -> None: + """The provisional 30 ms default validates against the interval as before.""" + stage_esp32("5.5.5", wifi=True) + with pytest.raises(cv.Invalid, match="needs to be smaller than scan interval"): + _scan_params({"scan_parameters": {"interval": "20ms"}}) From 137351fa8d85f27130b8ccfcbdfa9a42555a6635 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 13:27:01 -0500 Subject: [PATCH 1451/1815] [esp32_ble] Silence spurious warnings for local key GAP events (#18359) --- esphome/components/esp32_ble/ble.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 16501ef3b2..e2d79173ff 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -648,6 +648,8 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa case ESP_GAP_BLE_SET_PKT_LENGTH_COMPLETE_EVT: case ESP_GAP_BLE_PHY_UPDATE_COMPLETE_EVT: // BLE 5.0 PHY update complete case ESP_GAP_BLE_CHANNEL_SELECT_ALGORITHM_EVT: // BLE 5.0 channel selection algorithm + case ESP_GAP_BLE_LOCAL_IR_EVT: // Local identity root key generated at security init + case ESP_GAP_BLE_LOCAL_ER_EVT: // Local encryption root key generated at security init return; default: From 191686c5b3e106581ec58ab0ee15e6b8af4e9527 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 13:30:53 -0500 Subject: [PATCH 1452/1815] [wifi] Fix ESP8266 crash in cnx_node_search when lwIP transmits after disconnect (#18333) --- .../wifi/wifi_component_esp8266.cpp | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 719a276bf9..acaa94b13c 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -136,10 +136,21 @@ bool WiFiComponent::wifi_apply_power_save_() { https://github.com/d-a-v/Arduino/blob/0e7d21e17144cfc5f53c016191daca8723e89ee8/libraries/ESP8266WiFi/src/ESP8266WiFiSTA.cpp#L251 */ #undef netif_set_addr // need to call lwIP-v1.4 netif_set_addr() +#undef netif_set_down // need to call lwIP-v1.4 netif_set_down() extern "C" { struct netif *eagle_lwip_getif(int netif_index); void netif_set_addr(struct netif *netif, const ip4_addr_t *ip, const ip4_addr_t *netmask, const ip4_addr_t *gw); +void netif_set_down(struct netif *netif); }; + +// The SDK can free its WiFi connection node before taking the STA netif down, letting lwIP +// timers (e.g. IGMP reports armed by mDNS) transmit into the dead driver and crash in +// cnx_node_search; taking the netif down first makes the glue drop such frames (#18308). +static void sta_netif_down() { + struct netif *iface = eagle_lwip_getif(STATION_IF); + if (iface != nullptr) + netif_set_down(iface); +} #endif bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { @@ -523,6 +534,9 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { global_wifi_component->sta_state_ = static_cast(ESP8266WiFiSTAState::ERROR_FAILED); } global_wifi_component->error_from_callback_ = true; +#if LWIP_VERSION_MAJOR != 1 + sta_netif_down(); +#endif #ifdef USE_WIFI_CONNECT_STATE_LISTENERS global_wifi_component->pending_.disconnect = true; #endif @@ -536,6 +550,9 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { // https://lbsfilm.at/blog/wpa2-authenticationmode-downgrade-in-espressif-microprocessors if (it.old_mode != AUTH_OPEN && it.new_mode == AUTH_OPEN) { ESP_LOGW(TAG, "Potential Authmode downgrade detected, disconnecting"); +#if LWIP_VERSION_MAJOR != 1 + sta_netif_down(); +#endif wifi_station_disconnect(); global_wifi_component->error_from_callback_ = true; } @@ -719,8 +736,12 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { bool WiFiComponent::wifi_disconnect_() { bool ret = true; // Only call disconnect if interface is up - if (wifi_get_opmode() & WIFI_STA) + if (wifi_get_opmode() & WIFI_STA) { +#if LWIP_VERSION_MAJOR != 1 + sta_netif_down(); +#endif ret = wifi_station_disconnect(); + } station_config conf{}; memset(&conf, 0, sizeof(conf)); ETS_UART_INTR_DISABLE(); From 7420d238673fb3a593b9314bef93f8e8e1cd4546 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 13:31:06 -0500 Subject: [PATCH 1453/1815] [ota] Retry uploads that fail from network errors (#18332) --- esphome/espota2.py | 158 ++++++++++--- tests/unit_tests/test_espota2.py | 382 +++++++++++++++++++++++++++++-- 2 files changed, 493 insertions(+), 47 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index fa15c1dda2..61e897f601 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Callable +import contextlib import gzip import hashlib import io @@ -8,7 +9,6 @@ import logging from pathlib import Path import secrets import socket -import sys import time from typing import Any @@ -76,6 +76,14 @@ _SUPPORTED_OTA_TYPES: frozenset[int] = frozenset( UPLOAD_BLOCK_SIZE = 8192 UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 +# Flaky Wi-Fi links often drop the first OTA attempt, and the device may need time +# to clean up a half-open connection (its handshake watchdog runs at 20s) before it +# accepts a new one, so wait between attempts instead of failing the upload outright. +# Every resolved address is tried once, and this many extra attempts are shared +# across the addresses on top of that. +EXTRA_UPLOAD_ATTEMPTS = 2 +UPLOAD_RETRY_DELAY = 5.0 + _LOGGER = logging.getLogger(__name__) # Authentication method lookup table: response -> (hash_func, nonce_size, name) @@ -171,6 +179,23 @@ class OTAError(EsphomeError): pass +class OTANetworkError(OTAError): + """Network-level OTA failure (timeout, reset, closed connection); retrying may succeed.""" + + +def _committed_error(err: OTANetworkError) -> OTAError: + """Wrap a network failure that happened once the device had the full image. + + Past that point the device commits and reboots on its own, so the failure + must not be retried; a re-upload could flash a device that already updated. + """ + return OTAError( + f"{err} (the device may have already committed the update and " + f"be rebooting; check whether it comes back with the new " + f"firmware before uploading again)" + ) + + def recv_decode( sock: socket.socket, amount: int, decode: bool = True ) -> bytes | list[int]: @@ -209,19 +234,22 @@ def receive_exactly( try: data += recv_decode(sock, 1, decode=decode) # type: ignore[operator] except OSError as err: - raise OTAError(f"receiving {msg} response: {err}") from err + raise OTANetworkError(f"receiving {msg} response: {err}") from err try: check_error(data, expect) except OTAError as err: sock.close() - raise OTAError(f"receiving {msg}: {err}") from err + # type(err) preserves OTANetworkError vs OTAError so callers can tell + # retryable network failures from device-reported errors; subclasses + # must accept a single message argument + raise type(err)(f"receiving {msg}: {err}") from err while len(data) < amount: try: data += recv_decode(sock, amount - len(data), decode=decode) # type: ignore[operator] except OSError as err: - raise OTAError(f"receiving {msg}: {err}") from err + raise OTANetworkError(f"receiving {msg}: {err}") from err return data @@ -237,7 +265,7 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None # accept-any-response reads (e.g. feature negotiation, auth nonces) would be # silently passed through and surface later as cryptic decode/timeout failures. if not data: - raise OTAError( + raise OTANetworkError( "Device closed connection without responding. " "This may indicate the device ran out of memory, " "a network issue, or the connection was interrupted." @@ -274,7 +302,7 @@ def send_check( sock.sendall(data) except OSError as err: - raise OTAError(f"sending {msg}: {err}") from err + raise OTANetworkError(f"sending {msg}: {err}") from err def perform_ota( @@ -306,7 +334,7 @@ def perform_ota( send_check(sock, MAGIC_BYTES, "magic bytes") _, version = receive_exactly(sock, 2, "version", RESPONSE_OK) - _LOGGER.debug("Device support OTA version: %s", version) + _LOGGER.info("Connection established; device supports OTA version %s", version) supported_versions = (OTA_VERSION_1_0, OTA_VERSION_2_0) if version not in supported_versions: raise OTAError( @@ -417,6 +445,8 @@ def perform_ota( hash_func, nonce_size, hash_name = _AUTH_METHODS[auth] perform_auth(sock, password, hash_func, nonce_size, hash_name) + _LOGGER.info("Handshake complete") + # Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures sock.settimeout(90.0) @@ -449,21 +479,43 @@ def perform_ota( offset = 0 progress = ProgressBar("Uploading") - while True: - chunk = upload_contents[offset : offset + UPLOAD_BLOCK_SIZE] - if not chunk: - break - offset += len(chunk) + try: + while True: + chunk = upload_contents[offset : offset + UPLOAD_BLOCK_SIZE] + if not chunk: + break + offset += len(chunk) + + try: + sock.sendall(chunk) + except OSError as err: + # A send failure can hide an error byte the device reported + # just before dropping the connection; surface that as the + # real, non-retryable cause when it is available + try: + sock.settimeout(1.0) + check_error(recv_decode(sock, 1), None) + except (OSError, OTANetworkError) as probe_err: + _LOGGER.debug( + "No device error behind the send failure: %s", probe_err + ) + raise OTANetworkError(f"sending data: {err}") from err - try: - sock.sendall(chunk) if version >= OTA_VERSION_2_0: - receive_exactly(sock, 1, "chunk result", RESPONSE_CHUNK_OK) - except OSError as err: - sys.stderr.write("\n") - raise OTAError(f"sending data: {err}") from err + try: + receive_exactly(sock, 1, "chunk result", RESPONSE_CHUNK_OK) + except OTANetworkError as err: + if offset < upload_size: + raise + # The device already had the complete image when this ack + # was lost, so it may be committing; do not retry + raise _committed_error(err) from err - progress.update(offset / upload_size) + progress.update(offset / upload_size) + except OTAError: + # Terminate the progress bar line before the error is logged + progress.done() + raise progress.done() # Enable nodelay for last checks @@ -472,11 +524,25 @@ def perform_ota( _LOGGER.info("Upload took %.2f seconds, waiting for result...", duration) - receive_exactly(sock, 1, "update receive result", RESPONSE_RECEIVE_OK) - receive_exactly(sock, 1, "update end result", RESPONSE_UPDATE_END_OK) - send_check(sock, RESPONSE_OK, "end acknowledgement") + # Once the device has the complete image it commits the update and + # reboots on its own; the exact commit point is not observable from + # here, so treat everything past the data phase as non-retryable. A + # re-upload could flash a device that already updated successfully. + try: + receive_exactly(sock, 1, "update receive result", RESPONSE_RECEIVE_OK) + receive_exactly(sock, 1, "update end result", RESPONSE_UPDATE_END_OK) + except OTANetworkError as err: + raise _committed_error(err) from err - _LOGGER.info("OTA successful") + try: + send_check(sock, RESPONSE_OK, "end acknowledgement") + except OTANetworkError as err: + # The device treats a missing end acknowledgement as non-fatal and is + # already rebooting into the new firmware, so the update succeeded + _LOGGER.warning("Failed sending end acknowledgement: %s", err) + _LOGGER.info("OTA successful (end acknowledgement not delivered)") + else: + _LOGGER.info("OTA successful") # Do not connect logs until it is fully on time.sleep(1) @@ -510,8 +576,33 @@ def run_ota_impl_( ) raise OTAError(err) from err - for r in res: - af, socktype, _, _, sa = r + if not res: + _LOGGER.error("No addresses to connect to for %s", remote_host) + return 1, None + + # Every address is tried at least once and EXTRA_UPLOAD_ATTEMPTS retries + # are shared across the addresses, cycling through them. Wait before an + # attempt when the previous one actually reached the device, or when + # revisiting an address, so a flaky link can recover and the device can + # clean up a half-open connection (its handshake watchdog runs at 20s); + # moving on to the next address family stays immediate. Known limitation: + # a silent mid-transfer drop with no reset can wedge the device until its + # 90s data timeout, which outlasts this budget; the retries target the + # common failures where the device resets or closes the link promptly. + total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS + last_error = "" + reached_device = False + for attempt in range(total_attempts): + af, socktype, _, _, sa = res[attempt % len(res)] + if reached_device or attempt >= len(res): + _LOGGER.info( + "Retrying in %.0f seconds (attempt %d of %d)...", + UPLOAD_RETRY_DELAY, + attempt + 1, + total_attempts, + ) + time.sleep(UPLOAD_RETRY_DELAY) + reached_device = False _LOGGER.info("Connecting to %s port %s...", sa[0], sa[1]) sock = socket.socket(af, socktype) sock.settimeout(20.0) @@ -519,23 +610,30 @@ def run_ota_impl_( sock.connect(sa) except OSError as err: sock.close() - _LOGGER.error("Connecting to %s port %s failed: %s", sa[0], sa[1], err) + _LOGGER.warning("Connecting to %s port %s failed: %s", sa[0], sa[1], err) + last_error = f"connecting to {sa[0]} failed: {err}" continue _LOGGER.info("Connected to %s", sa[0]) - with Path(filename).open("rb") as file_handle: + reached_device = True + with contextlib.closing(sock), Path(filename).open("rb") as file_handle: try: perform_ota(sock, password, file_handle, filename, ota_type) + except OTANetworkError as err: + # Transient network failure; retry + last_error = str(err) + _LOGGER.warning("%s", last_error) + continue except OTAError as err: + # Device-reported error (wrong password, wrong flash size, ...); + # retrying cannot succeed, so fail immediately _LOGGER.error(str(err)) return 1, None - finally: - sock.close() # Successfully uploaded to sa[0] return 0, sa[0] - _LOGGER.error("Connection failed.") + _LOGGER.error("Upload failed after %d attempts: %s", total_attempts, last_error) return 1, None diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 9413fbcf29..db4a4b1117 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -44,13 +44,17 @@ def mock_file() -> io.BytesIO: @pytest.fixture -def mock_time() -> Generator[None]: +def mock_sleep() -> Generator[Mock]: + """Mock time.sleep so delays don't slow down tests.""" + with patch("time.sleep") as mock: + yield mock + + +@pytest.fixture +def mock_time(mock_sleep: Mock) -> Generator[None]: """Mock time-related functions for consistent testing.""" # Provide enough values for multiple calls (tests may call perform_ota multiple times) - with ( - patch("time.sleep"), - patch("time.perf_counter", side_effect=[0, 1, 0, 1, 0, 1]), - ): + with patch("time.perf_counter", side_effect=[0, 1, 0, 1, 0, 1]): yield @@ -79,6 +83,28 @@ def mock_resolve_ip() -> Generator[Mock]: yield mock +DUAL_STACK_SA6 = ("2001:db8::1", 3232, 0, 0) +DUAL_STACK_SA4 = ("192.168.1.100", 3232) + + +@pytest.fixture +def mock_resolve_ip_dual(mock_resolve_ip: Mock) -> Mock: + """Make resolve_ip_address return an IPv6 and an IPv4 address.""" + mock_resolve_ip.return_value = [ + (socket.AF_INET6, socket.SOCK_STREAM, 0, "", DUAL_STACK_SA6), + (socket.AF_INET, socket.SOCK_STREAM, 0, "", DUAL_STACK_SA4), + ] + return mock_resolve_ip + + +@pytest.fixture +def firmware_file(tmp_path: Path) -> Path: + """Create a firmware file on disk for run_ota_impl_ tests.""" + firmware = tmp_path / "firmware.bin" + firmware.write_bytes(b"firmware content") + return firmware + + @pytest.fixture def mock_perform_ota() -> Generator[Mock]: """Mock perform_ota function for testing.""" @@ -137,9 +163,11 @@ def test_receive_exactly_with_error_response(mock_socket: Mock) -> None: with pytest.raises( espota2.OTAError, match="receiving auth:.*Authentication invalid" - ): + ) as exc_info: espota2.receive_exactly(mock_socket, 1, "auth", [espota2.RESPONSE_OK]) + # Device-reported errors must stay plain OTAError, not the retryable kind + assert not isinstance(exc_info.value, espota2.OTANetworkError) mock_socket.close.assert_called_once() @@ -147,10 +175,30 @@ def test_receive_exactly_socket_error(mock_socket: Mock) -> None: """Test receive_exactly handles socket errors.""" mock_socket.recv.side_effect = OSError("Connection reset") - with pytest.raises(espota2.OTAError, match="receiving test response"): + with pytest.raises(espota2.OTANetworkError, match="receiving test response"): espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK) +def test_receive_exactly_mid_read_socket_error(mock_socket: Mock) -> None: + """Test receive_exactly handles socket errors after the first byte.""" + mock_socket.recv.side_effect = [b"\x00", OSError("Connection reset")] + + with pytest.raises(espota2.OTANetworkError, match="receiving test:"): + espota2.receive_exactly(mock_socket, 3, "test", espota2.RESPONSE_OK) + + +def test_receive_exactly_closed_connection_is_network_error(mock_socket: Mock) -> None: + """Test receive_exactly raises OTANetworkError when the device closes the connection.""" + mock_socket.recv.return_value = b"" + + with pytest.raises( + espota2.OTANetworkError, match="Device closed connection without responding" + ): + espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK) + + mock_socket.close.assert_called_once() + + @pytest.mark.parametrize( ("error_code", "expected_msg"), [ @@ -227,15 +275,15 @@ def test_check_error_unexpected_response() -> None: def test_check_error_empty_data() -> None: - """Test check_error raises error when device closes connection without responding.""" + """Test check_error raises the retryable OTANetworkError when the device closes the connection.""" with pytest.raises( - espota2.OTAError, match="Device closed connection without responding" + espota2.OTANetworkError, match="Device closed connection without responding" ): espota2.check_error([], [espota2.RESPONSE_OK]) # Also test with empty bytes with pytest.raises( - espota2.OTAError, match="Device closed connection without responding" + espota2.OTANetworkError, match="Device closed connection without responding" ): espota2.check_error(b"", [espota2.RESPONSE_OK]) @@ -530,6 +578,144 @@ def test_perform_ota_upload_error(mock_socket: Mock, mock_file: io.BytesIO) -> N espota2.perform_ota(mock_socket, None, mock_file, "test.bin") +def _no_auth_handshake(version: int) -> list[bytes]: + """Recv responses for a handshake without auth, up to the MD5 check.""" + return [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([version]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + ] + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_chunk_send_error(mock_socket: Mock, mock_file: io.BytesIO) -> None: + """Test OTA raises the retryable OTANetworkError when sending a chunk fails.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_2_0), + OSError("Connection reset"), # Probe for a pending error byte fails too + ] + # Sends before the data phase: magic bytes, features, binary size, MD5; + # fail on the fifth sendall, the first firmware chunk + mock_socket.sendall.side_effect = [None] * 4 + [OSError("Broken pipe")] + + with pytest.raises(espota2.OTANetworkError, match="sending data:"): + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_chunk_send_error_surfaces_device_error( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a device error byte pending behind a send failure becomes the cause.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_ERROR_WRITING_FLASH]), # Reason the device closed + ] + mock_socket.sendall.side_effect = [None] * 4 + [OSError("Broken pipe")] + + with pytest.raises( + espota2.OTAError, match="Writing OTA data to flash memory failed" + ) as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # The device-reported error is not retryable + assert not isinstance(exc.value, espota2.OTANetworkError) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_final_chunk_ack_failure_not_retryable( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a lost ack for the final chunk is not retried.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_2_0), + OSError("Connection reset"), # Ack for the only (final) chunk is lost + ] + + with pytest.raises(espota2.OTAError, match="receiving chunk result") as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # The device already had the whole image, so it may be committing + assert not isinstance(exc.value, espota2.OTANetworkError) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_intermediate_chunk_ack_failure_retryable( + mock_socket: Mock, +) -> None: + """Test a lost ack for a non-final chunk stays retryable.""" + # Two chunks: the firmware is larger than one upload block + big_file = io.BytesIO(b"x" * (espota2.UPLOAD_BLOCK_SIZE + 1)) + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_2_0), + OSError("Connection reset"), # Ack for the first of two chunks is lost + ] + + with pytest.raises(espota2.OTANetworkError, match="receiving chunk result"): + espota2.perform_ota(mock_socket, None, big_file, "test.bin") + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_post_commit_failure_not_retryable( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a network failure after the device committed is a plain OTAError.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything + OSError("Connection reset"), # Connection lost waiting for end result + ] + + with pytest.raises(espota2.OTAError, match="receiving update end result") as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # Must not be the retryable kind; the device is already rebooting + assert not isinstance(exc.value, espota2.OTANetworkError) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_md5_mismatch_not_marked_committed( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test an MD5 mismatch keeps its own message and stays non-retryable.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything + bytes([espota2.RESPONSE_ERROR_MD5_MISMATCH]), # Device aborted the update + ] + + with pytest.raises(espota2.OTAError, match="MD5 code mismatch") as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # The device aborted without committing, so the message must not claim + # the update may have been installed, and the error must not be retried + assert not isinstance(exc.value, espota2.OTANetworkError) + assert "committed" not in str(exc.value) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_end_ack_send_failure_is_success( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a send failure on the final acknowledgement does not fail the OTA.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update committed + ] + # Sends: magic bytes, features, binary size, MD5, one firmware chunk; + # fail on the sixth sendall, the end acknowledgement + mock_socket.sendall.side_effect = [None] * 5 + [OSError("Broken pipe")] + + # Must not raise; the device treats a missing acknowledgement as non-fatal + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + assert mock_socket.sendall.call_count == 6 + + @pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") def test_run_ota_impl_successful( mock_socket: Mock, tmp_path: Path, mock_perform_ota: Mock @@ -564,21 +750,183 @@ def test_run_ota_impl_successful( @pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") -def test_run_ota_impl_connection_failed(mock_socket: Mock, tmp_path: Path) -> None: - """Test run_ota_impl_ when connection fails.""" +def test_run_ota_impl_connection_failed( + mock_socket: Mock, firmware_file: Path, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ retries when connection fails and eventually gives up.""" mock_socket.connect.side_effect = OSError("Connection refused") - # Create a real firmware file - firmware_file = tmp_path / "firmware.bin" - firmware_file.write_bytes(b"firmware content") - result_code, result_host = espota2.run_ota_impl_( "test.local", 3232, "password", str(firmware_file) ) assert result_code == 1 assert result_host is None - mock_socket.close.assert_called_once() + # A single address gets the whole attempt budget, with a delay before + # each revisit + assert mock_socket.connect.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1 + assert mock_socket.close.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1 + assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + mock_sleep.assert_called_with(espota2.UPLOAD_RETRY_DELAY) + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_connect_retry_succeeds( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ succeeds when a retry connects after a failed attempt.""" + mock_socket.connect.side_effect = [OSError("Connection timed out"), None] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + assert mock_socket.connect.call_count == 2 + mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY) + mock_perform_ota.assert_called_once() + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_network_error_retry_succeeds( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ retries after a network error during the upload.""" + mock_perform_ota.side_effect = [ + espota2.OTANetworkError("receiving features: Device closed connection"), + None, + ] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + assert mock_perform_ota.call_count == 2 + mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY) + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_network_error_exhausts_attempts( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ gives up after all attempts hit network errors.""" + mock_perform_ota.side_effect = espota2.OTANetworkError("sending data: broken pipe") + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + assert mock_perform_ota.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1 + assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual") +def test_run_ota_impl_multiple_addresses_cycle( + mock_socket: Mock, firmware_file: Path, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ visits every address and cycles for the retries.""" + mock_socket.connect.side_effect = OSError("No route to host") + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + # Each address is visited once, then the EXTRA_UPLOAD_ATTEMPTS spare + # attempts cycle back through them; the budget is shared, not per address + assert mock_socket.connect.call_args_list == [ + call(DUAL_STACK_SA6), + call(DUAL_STACK_SA4), + call(DUAL_STACK_SA6), + call(DUAL_STACK_SA4), + ] + # No connect ever reached the device, so the delay only applies before + # the revisits + assert mock_sleep.call_count == 2 + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual") +def test_run_ota_impl_second_address_succeeds_without_delay( + mock_socket: Mock, + firmware_file: Path, + mock_perform_ota: Mock, + mock_sleep: Mock, +) -> None: + """Test run_ota_impl_ falls through to the next address with no pause.""" + mock_socket.connect.side_effect = [OSError("No route to host"), None] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + mock_sleep.assert_not_called() + mock_perform_ota.assert_called_once() + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual") +def test_run_ota_impl_pauses_after_reaching_device( + mock_socket: Mock, + firmware_file: Path, + mock_perform_ota: Mock, + mock_sleep: Mock, +) -> None: + """Test run_ota_impl_ pauses before the next address once the device was reached.""" + mock_perform_ota.side_effect = [ + espota2.OTANetworkError("sending data: connection reset"), + None, + ] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + # The first attempt reached the device, so the next one waits first even + # though it targets a fresh address + mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY) + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_device_error_not_retried( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ fails immediately on a device-reported error.""" + mock_perform_ota.side_effect = espota2.OTAError( + "Authentication invalid. Is the password correct?" + ) + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + mock_perform_ota.assert_called_once() + mock_sleep.assert_not_called() + + +def test_run_ota_impl_no_addresses( + firmware_file: Path, mock_resolve_ip: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ fails cleanly when resolution yields no addresses.""" + mock_resolve_ip.return_value = [] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + mock_sleep.assert_not_called() def test_run_ota_impl_resolve_failed(tmp_path: Path, mock_resolve_ip: Mock) -> None: From 945c2458b3642964232503bf162bb9d1ab657d8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 13:31:19 -0500 Subject: [PATCH 1454/1815] [core] Load component aliases from a generated registry (#18335) --- .github/workflows/ci.yml | 1 + esphome/component_aliases.py | 10 ++++++ esphome/loader.py | 61 +++++++++++++-------------------- script/build_alias_registry.py | 59 +++++++++++++++++++++++++++++++ tests/unit_tests/test_loader.py | 28 +++++++++++++++ 5 files changed, 122 insertions(+), 37 deletions(-) create mode 100644 esphome/component_aliases.py create mode 100755 script/build_alias_registry.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e695bb46b..b603e68ad7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -179,6 +179,7 @@ jobs: . venv/bin/activate script/ci-custom.py script/build_codeowners.py --check + script/build_alias_registry.py --check script/build_language_schema.py --check script/generate-esp32-boards.py --check script/generate-rp2-boards.py --check diff --git a/esphome/component_aliases.py b/esphome/component_aliases.py new file mode 100644 index 0000000000..e701bd98d4 --- /dev/null +++ b/esphome/component_aliases.py @@ -0,0 +1,10 @@ +"""Component alias registry. + +Generated by script/build_alias_registry.py - do not edit manually. +See the component-alias section of esphome/loader.py. +""" + +# alias -> (canonical component, removal version or None) +COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = { + "rp2040": ("rp2", "2027.7.0"), +} diff --git a/esphome/loader.py b/esphome/loader.py index 7a659aa0a8..f994f0c5eb 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -269,10 +269,9 @@ def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None: # If `domain` is the legacy name of a renamed component, redirect to the # canonical module so the rest of the loader (and every caller of # `get_component(legacy)`) transparently sees the new component. - alias_map = _get_alias_map() - if domain in alias_map: - canonical = alias_map[domain] - manif = _lookup_module(canonical, exception) + alias_meta = get_alias_metadata().get(domain) + if alias_meta is not None: + manif = _lookup_module(alias_meta.canonical, exception) if manif is not None: _COMPONENT_CACHE[domain] = manif return manif @@ -329,8 +328,10 @@ def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> Non # --------------------------------------------------------------------------- # # A component can declare ``ALIASES = ["legacy_name"]`` (and optionally -# ``ALIAS_REMOVAL_VERSION = "YYYY.M.0"``) in its ``__init__.py``. Two -# integrations are then wired up automatically: +# ``ALIAS_REMOVAL_VERSION = "YYYY.M.0"``) in its ``__init__.py``, then run +# ``script/build_alias_registry.py`` to regenerate +# ``esphome/component_aliases.py`` (CI and a unit test fail if the registry +# is stale). Two integrations are then wired up automatically: # # 1. **Python imports** — a ``sys.meta_path`` finder (``_AliasFinder``) # intercepts ``esphome.components.``/``....`` @@ -344,13 +345,13 @@ def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> Non # dependency checks, schema validation and codegen all see only the # canonical name. # -# Both lookups are populated by ``_build_alias_map``, which **AST-parses** -# every component's ``__init__.py`` rather than importing it. That keeps the -# cost low: scanning ~400 components on disk takes ~5 ms instead of the -# multi-second cost of executing every component's import side-effects. +# Both lookups read the checked-in registry in ``esphome.component_aliases`` +# (generated by ``script/build_alias_registry.py``, verified in CI), so no +# component-directory scan happens at runtime. ``_build_alias_map`` below is +# the generator's scan implementation; it **AST-parses** each component's +# ``__init__.py`` rather than importing it. -_ALIAS_MAP_CACHE: dict[str, str] | None = None _ALIAS_META_CACHE: dict[str, "AliasMeta"] | None = None @@ -367,31 +368,17 @@ class AliasMeta: removal_version: str | None -def _ensure_alias_caches() -> None: - """Populate both alias caches from a single directory scan. - - ``_build_alias_map`` returns both maps together, so building them in one - shot avoids scanning every component's ``__init__.py`` twice when a run - needs both the canonical map (loader) and the metadata map (config - pre-pass). - """ - global _ALIAS_MAP_CACHE, _ALIAS_META_CACHE - if _ALIAS_MAP_CACHE is None or _ALIAS_META_CACHE is None: - _ALIAS_MAP_CACHE, _ALIAS_META_CACHE = _build_alias_map() - - -def _get_alias_map() -> dict[str, str]: - """Return the legacy-name → canonical-name map, building it lazily.""" - _ensure_alias_caches() - return _ALIAS_MAP_CACHE - - def get_alias_metadata() -> dict[str, AliasMeta]: - """Return the legacy-name → :class:`AliasMeta` map (cached). + """Return the legacy-name → :class:`AliasMeta` map, built lazily from + the generated registry.""" + global _ALIAS_META_CACHE # noqa: PLW0603 + if _ALIAS_META_CACHE is None: + from esphome.component_aliases import COMPONENT_ALIASES - Used by the YAML pre-pass to format a per-alias deprecation warning. - """ - _ensure_alias_caches() + _ALIAS_META_CACHE = { + alias: AliasMeta(canonical=canonical, removal_version=removal_version) + for alias, (canonical, removal_version) in COMPONENT_ALIASES.items() + } return _ALIAS_META_CACHE @@ -537,11 +524,11 @@ class _AliasFinder(importlib.abc.MetaPathFinder): # least three parts, so ``parts[2]`` (the domain) always exists. parts = fullname.split(".") domain = parts[2] - alias_map = _get_alias_map() - if domain not in alias_map: + alias_meta = get_alias_metadata().get(domain) + if alias_meta is None: return None - parts[2] = alias_map[domain] + parts[2] = alias_meta.canonical canonical_fullname = ".".join(parts) try: canonical_module = importlib.import_module(canonical_fullname) diff --git a/script/build_alias_registry.py b/script/build_alias_registry.py new file mode 100755 index 0000000000..e007c075eb --- /dev/null +++ b/script/build_alias_registry.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Generate esphome/component_aliases.py from component ALIASES declarations. + +Run without arguments to regenerate the registry; ``--check`` (run in CI) +verifies it is up to date. +""" + +import argparse +from pathlib import Path +import sys + +# The root directory of the repo +root = Path(__file__).parent.parent +# Make the repo's esphome package win over any installed copy +sys.path.insert(0, str(root)) + +from esphome.helpers import write_file_if_changed # noqa: E402 +from esphome.loader import _build_alias_map # noqa: E402 + +parser = argparse.ArgumentParser() +parser.add_argument( + "--check", + help="Check if the alias registry is up to date.", + action="store_true", +) +args = parser.parse_args() + +registry_file = root / "esphome" / "component_aliases.py" + +HEADER = '''"""Component alias registry. + +Generated by script/build_alias_registry.py - do not edit manually. +See the component-alias section of esphome/loader.py. +""" + +# alias -> (canonical component, removal version or None) +COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = { +''' + +# _build_alias_map scans the real component tree and already rejects +# duplicate and shadowing aliases with an EsphomeError. +_, alias_meta = _build_alias_map() + +lines = [HEADER] +for alias, meta in sorted(alias_meta.items()): + removal = f'"{meta.removal_version}"' if meta.removal_version else "None" + lines.append(f' "{alias}": ("{meta.canonical}", {removal}),\n') +lines.append("}\n") +content = "".join(lines) + +if args.check: + if registry_file.read_text(encoding="utf-8") != content: + print("Component alias registry is not up to date.") + print("Please run `script/build_alias_registry.py`") + sys.exit(1) + print("Component alias registry is up to date") +else: + write_file_if_changed(registry_file, content) + print(f"Wrote {registry_file}") diff --git a/tests/unit_tests/test_loader.py b/tests/unit_tests/test_loader.py index 41dd462678..74515e9d4c 100644 --- a/tests/unit_tests/test_loader.py +++ b/tests/unit_tests/test_loader.py @@ -8,6 +8,7 @@ from unittest.mock import MagicMock, patch import pytest +from esphome.component_aliases import COMPONENT_ALIASES from esphome.loader import ( AliasMeta, ComponentManifest, @@ -481,6 +482,33 @@ def test_real_alias_map_includes_rp2040() -> None: assert meta["rp2040"].removal_version == "2027.7.0" +def test_alias_registry_matches_component_tree() -> None: + """The checked-in registry must match a live scan of the component tree.""" + _, meta_map = _build_alias_map() + expected = { + alias: (meta.canonical, meta.removal_version) + for alias, meta in meta_map.items() + } + assert expected == COMPONENT_ALIASES, ( + "esphome/component_aliases.py is out of date; " + "run script/build_alias_registry.py" + ) + + +def test_alias_map_built_from_registry() -> None: + """The runtime alias map comes from the generated registry, not a scan.""" + with ( + patch( + "esphome.component_aliases.COMPONENT_ALIASES", + {"legacy": ("modern", "2099.1.0")}, + ), + patch("esphome.loader._ALIAS_META_CACHE", None), + ): + assert get_alias_metadata() == { + "legacy": AliasMeta(canonical="modern", removal_version="2099.1.0") + } + + def test_get_component_resolves_alias() -> None: """``get_component('rp2040')`` should return the rp2 manifest — every caller of the loader (dep checker, schema validator, codegen) hits From 37782f72069e10f0d6a32c79a46a4d58180cb240 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:54:37 -0500 Subject: [PATCH 1455/1815] Bump prek from 0.4.12 to 0.4.13 (#18362) Signed-off-by: dependabot[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 1832ffd433..95ee97437d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -2,7 +2,7 @@ pylint==4.0.7 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating ruff==0.16.2 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating -prek==0.4.12 # also change in .github/workflows/ci.yml when updating +prek==0.4.13 # also change in .github/workflows/ci.yml when updating # Unit tests pytest==9.1.1 From 45a056e33774333778e0264222d4434aac434a36 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:54:51 -0500 Subject: [PATCH 1456/1815] Bump platformdirs from 4.11.1 to 4.11.2 (#18363) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 683008400a..6bc8bdf74a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ bleak==2.1.1 smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 -platformdirs==4.11.1 # native esp-idf toolchain global cache dir +platformdirs==4.11.2 # native esp-idf toolchain global cache dir filelock==3.32.2 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this From dd51624fbb909ccaa902e8d38480b91b782fb6ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 20:06:48 -0500 Subject: [PATCH 1457/1815] [core] Partially revert "Hash entity keys from the raw name to fix collisions" (#18361) --- esphome/components/api/api_connection.cpp | 6 +- esphome/components/infrared/infrared.cpp | 8 +- esphome/components/mqtt/__init__.py | 63 --- esphome/components/prometheus/__init__.py | 6 - .../radio_frequency/radio_frequency.cpp | 8 +- .../template/text/template_text.cpp | 20 +- .../components/template/text/template_text.h | 15 +- esphome/core/application.h | 8 +- esphome/core/entity_base.cpp | 52 +-- esphome/core/entity_base.h | 80 ++-- esphome/core/entity_helpers.py | 190 ++++---- esphome/core/helpers.h | 29 +- esphome/core/preference_backend.h | 14 +- esphome/core/preferences.cpp | 25 - esphome/core/preferences.h | 12 - esphome/helpers.py | 15 +- tests/integration/entity_utils.py | 27 +- .../fixtures/fnv1_hash_object_id.yaml | 32 -- .../fixtures/multi_device_preferences.yaml | 21 +- .../fixtures/preference_key_migration.yaml | 35 -- tests/integration/host_prefs.py | 24 +- tests/integration/test_fnv1_hash_object_id.py | 4 - .../test_object_id_api_verification.py | 10 +- ...t_object_id_friendly_name_no_mac_suffix.py | 4 +- .../test_object_id_no_friendly_name.py | 6 +- .../test_preference_key_migration.py | 165 ------- tests/unit_tests/components/mqtt/__init__.py | 0 .../mqtt/test_object_id_conflicts.py | 239 ---------- tests/unit_tests/core/test_entity_helpers.py | 432 ++++++++++-------- .../object_id_conflict_mqtt.yaml | 22 - .../object_id_conflict_no_mqtt.yaml | 15 - .../test_preference_hash_stability.py | 34 +- 32 files changed, 489 insertions(+), 1132 deletions(-) delete mode 100644 esphome/core/preferences.cpp delete mode 100644 tests/integration/fixtures/preference_key_migration.yaml delete mode 100644 tests/integration/test_preference_key_migration.py delete mode 100644 tests/unit_tests/components/mqtt/__init__.py delete mode 100644 tests/unit_tests/components/mqtt/test_object_id_conflicts.py delete mode 100644 tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml delete mode 100644 tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index d05f98d03b..73b4f3e5bd 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -448,7 +448,7 @@ void APIConnection::on_disconnect_response() { uint16_t APIConnection::fill_and_encode_entity_state(EntityBase *entity, StateResponseProtoMessage &msg, CalculateSizeFn size_fn, MessageEncodeFn encode_fn, APIConnection *conn, uint32_t remaining_size) { - msg.key = entity->get_entity_key(); + msg.key = entity->get_object_id_hash(); #ifdef USE_DEVICES msg.device_id = entity->get_device_id(); #endif @@ -459,7 +459,7 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp CalculateSizeFn size_fn, MessageEncodeFn encode_fn, APIConnection *conn, uint32_t remaining_size) { // Set common fields that are shared by all entity types - msg.key = entity->get_entity_key(); + msg.key = entity->get_object_id_hash(); if (entity->has_own_name()) { msg.name = entity->get_name(); @@ -1149,7 +1149,7 @@ void APIConnection::try_send_camera_image_() { bool done = this->image_reader_->available() == to_send; CameraImageResponse msg; - msg.key = camera::Camera::instance()->get_entity_key(); + msg.key = camera::Camera::instance()->get_object_id_hash(); msg.set_data(this->image_reader_->peek_data_buffer(), to_send); msg.done = done; #ifdef USE_DEVICES diff --git a/esphome/components/infrared/infrared.cpp b/esphome/components/infrared/infrared.cpp index 288b1e5c40..9b97995a96 100644 --- a/esphome/components/infrared/infrared.cpp +++ b/esphome/components/infrared/infrared.cpp @@ -154,8 +154,12 @@ bool Infrared::on_receive(remote_base::RemoteReceiveData data) { // Forward received IR data to API server #if defined(USE_API) && defined(USE_IR_RF) if (api::global_api_server != nullptr) { - api::global_api_server->send_infrared_rf_receive_event(this->get_device_id_or_zero(), this->get_entity_key(), - &data.get_raw_data()); +#ifdef USE_DEVICES + uint32_t device_id = this->get_device_id(); +#else + uint32_t device_id = 0; +#endif + api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data()); } #endif return false; // Don't consume the event, allow other listeners to process it diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 713969ab88..98ca23b60b 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -63,7 +63,6 @@ from esphome.const import ( PlatformFramework, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import ObjectIdEntity, validate_no_object_id_conflicts from esphome.types import ConfigType DEPENDENCIES = ["network"] @@ -333,68 +332,6 @@ CONFIG_SCHEMA = cv.All( ) -# Platforms whose MQTT components subscribe to an object_id-derived command topic. -# Keep in sync with the platforms extending cv.MQTT_COMMAND_COMPONENT_SCHEMA, plus -# text, whose MQTT component subscribes a command topic that cannot be overridden. -_COMMAND_TOPIC_PLATFORMS = frozenset( - { - "alarm_control_panel", - "button", - "climate", - "cover", - "datetime", - "fan", - "light", - "lock", - "number", - "select", - "switch", - "text", - "update", - "valve", - } -) - - -# Platforms whose MQTT components derive extra sub-topics (position/command, -# mode/command, speed/command, ...) from the object_id, each with its own config -# key; custom state and command topics cannot exempt them from conflicting. -_SUB_TOPIC_PLATFORMS = frozenset({"climate", "cover", "fan", "valve"}) - - -def _topics_conflict(entities: list[ObjectIdEntity], config: ConfigType) -> bool: - """Check whether more than one entity actually uses an object_id-derived topic. - - An empty topic_prefix disables default topics entirely, custom state and - command topics avoid the default topics, and disabling discovery (globally - or per entity) avoids the discovery config topic. - """ - if config[CONF_TOPIC_PREFIX]: - platform = entities[0].platform - if platform in _SUB_TOPIC_PLATFORMS: - return True - if sum(CONF_STATE_TOPIC not in entity.config for entity in entities) > 1: - return True - if ( - platform in _COMMAND_TOPIC_PLATFORMS - and sum(CONF_COMMAND_TOPIC not in entity.config for entity in entities) > 1 - ): - return True - if not config[CONF_DISCOVERY]: - return False - discovery_entities = sum( - entity.config.get(CONF_DISCOVERY, True) for entity in entities - ) - return discovery_entities > 1 - - -FINAL_VALIDATE_SCHEMA = validate_no_object_id_conflicts( - "mqtt builds default topics and discovery topics from the entity object_id, " - "which is the name converted to ASCII", - conflict_filter=_topics_conflict, -) - - def exp_mqtt_message(config): if config is None: return cg.optional(cg.TemplateArguments(MQTTMessage)) diff --git a/esphome/components/prometheus/__init__.py b/esphome/components/prometheus/__init__.py index 0a69160fc1..cc1541ce80 100644 --- a/esphome/components/prometheus/__init__.py +++ b/esphome/components/prometheus/__init__.py @@ -3,7 +3,6 @@ from esphome.components import web_server_base from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_INCLUDE_INTERNAL, CONF_NAME, CONF_RELABEL -from esphome.core.entity_helpers import validate_no_object_id_conflicts from esphome.cpp_types import EntityBase AUTO_LOAD = ["web_server_base"] @@ -36,11 +35,6 @@ CONFIG_SCHEMA = cv.Schema( }, ).extend(cv.COMPONENT_SCHEMA) -FINAL_VALIDATE_SCHEMA = validate_no_object_id_conflicts( - "prometheus builds metric labels from the entity object_id, " - "which is the name converted to ASCII" -) - async def to_code(config): paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID]) diff --git a/esphome/components/radio_frequency/radio_frequency.cpp b/esphome/components/radio_frequency/radio_frequency.cpp index fe6c6a9cb5..3e0a905737 100644 --- a/esphome/components/radio_frequency/radio_frequency.cpp +++ b/esphome/components/radio_frequency/radio_frequency.cpp @@ -99,8 +99,12 @@ bool RadioFrequency::on_receive(remote_base::RemoteReceiveData data) { // Forward received RF data to API server #if defined(USE_API) && defined(USE_RADIO_FREQUENCY) if (api::global_api_server != nullptr) { - api::global_api_server->send_infrared_rf_receive_event(this->get_device_id_or_zero(), this->get_entity_key(), - &data.get_raw_data()); +#ifdef USE_DEVICES + uint32_t device_id = this->get_device_id(); +#else + uint32_t device_id = 0; +#endif + api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data()); } #endif return false; // Don't consume the event, allow other listeners to process it diff --git a/esphome/components/template/text/template_text.cpp b/esphome/components/template/text/template_text.cpp index ffe11cf229..af134e6ed4 100644 --- a/esphome/components/template/text/template_text.cpp +++ b/esphome/components/template/text/template_text.cpp @@ -20,14 +20,18 @@ void TemplateText::setup() { // Need std::string for pref_->setup() to fill from flash std::string value{this->initial_value_ != nullptr ? this->initial_value_ : ""}; - uint32_t extra = 0; - extra += this->traits.get_min_length() << 2; - extra += this->traits.get_max_length() << 4; - extra += fnv1_hash(this->traits.get_pattern_c_str()) << 6; - // TextSaver::setup() picks the key for the platform and migrates old data once - uint32_t key = this->preference_key_base_() + extra; - uint32_t old_key = this->old_preference_key_base_() + extra; - this->pref_->setup(key, old_key, value); + // For future hash migration: use migrate_entity_preference_() with: + // old_key = get_preference_hash() + extra + // new_key = get_preference_hash_v2() + extra + // See: https://github.com/esphome/backlog/issues/85 +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + uint32_t key = this->get_preference_hash(); +#pragma GCC diagnostic pop + key += this->traits.get_min_length() << 2; + key += this->traits.get_max_length() << 4; + key += fnv1_hash(this->traits.get_pattern_c_str()) << 6; + this->pref_->setup(key, value); if (!value.empty()) this->publish_state(value); } diff --git a/esphome/components/template/text/template_text.h b/esphome/components/template/text/template_text.h index beeea4396a..229a61d9b8 100644 --- a/esphome/components/template/text/template_text.h +++ b/esphome/components/template/text/template_text.h @@ -14,9 +14,7 @@ class TemplateTextSaverBase { public: virtual bool save(const std::string &value) { return true; } - /// old_id is the pre-2026.8.0 preference key; data stored under it is moved to id once. - /// See: https://github.com/esphome/backlog/issues/85 - virtual void setup(uint32_t id, uint32_t old_id, std::string &value) {} + virtual void setup(uint32_t id, std::string &value) {} protected: ESPPreferenceObject pref_; @@ -47,16 +45,11 @@ template class TextSaver : public TemplateTextSaverBase { // Make the preference object. Fill the provided location with the saved data // If it is available, else leave it alone - void setup(uint32_t id, uint32_t old_id, std::string &value) override { - char temp[SZ + 1]; -#ifdef USE_PREFERENCE_KEY_LOOKUP + void setup(uint32_t id, std::string &value) override { this->pref_ = global_preferences->make_preference(id); - bool hasdata = migrate_preference(this->pref_, reinterpret_cast(temp), SZ + 1, old_id, id); -#else - // Slot-based backends keep the old key; it is only a validity tag on a positional slot - this->pref_ = global_preferences->make_preference(old_id); + + char temp[SZ + 1]; bool hasdata = this->pref_.load(&temp); -#endif if (hasdata) { size_t len = static_cast(temp[0]); diff --git a/esphome/core/application.h b/esphome/core/application.h index a18a6b31c8..a12cdc4ac8 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -120,8 +120,8 @@ class Application { // 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, const char *name, uint32_t entity_key, uint32_t entity_fields) { \ - obj->configure_entity_(name, entity_key, entity_fields); \ + 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) \ @@ -329,7 +329,7 @@ class Application { #define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \ entity_type *get_##entity_name##_by_key(uint32_t key, uint32_t device_id, bool include_internal = false) { \ for (auto *obj : this->entities_member##_) { \ - if (obj->get_entity_key() == key && obj->get_device_id() == device_id && \ + if (obj->get_object_id_hash() == key && obj->get_device_id() == device_id && \ (include_internal || !obj->is_internal())) \ return obj; \ } \ @@ -340,7 +340,7 @@ class Application { #define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \ entity_type *get_##entity_name##_by_key(uint32_t key, bool include_internal = false) { \ for (auto *obj : this->entities_member##_) { \ - if (obj->get_entity_key() == key && (include_internal || !obj->is_internal())) \ + if (obj->get_object_id_hash() == key && (include_internal || !obj->is_internal())) \ return obj; \ } \ return nullptr; \ diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 328de05302..fc6ac503b5 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -8,7 +8,7 @@ namespace esphome { static const char *const TAG = "entity_base"; -void EntityBase::configure_entity_(const char *name, uint32_t entity_key, uint32_t entity_fields) { +void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields) { this->name_ = StringRef(name); if (this->name_.empty()) { #ifdef USE_DEVICES @@ -30,15 +30,15 @@ void EntityBase::configure_entity_(const char *name, uint32_t entity_key, uint32 } } this->flags_.has_own_name = false; - // Dynamic name - must calculate key at runtime - this->calc_entity_key_(); + // Dynamic name - must calculate hash at runtime + this->calc_object_id_(); } else { this->flags_.has_own_name = true; - // Static name - use pre-computed key if provided - if (entity_key != 0) { - this->entity_key_ = entity_key; + // Static name - use pre-computed hash if provided + if (object_id_hash != 0) { + this->object_id_hash_ = object_id_hash; } else { - this->calc_entity_key_(); + this->calc_object_id_(); } } // Unpack entity string table indices and flags from entity_fields. @@ -147,15 +147,9 @@ std::string EntityBase::get_icon() const { } #endif // !USE_ESP8266 -// Calculate the entity key directly from the raw name (no transformations) -void EntityBase::calc_entity_key_() { this->entity_key_ = fnv1_hash_bytes(this->name_.c_str(), this->name_.size()); } - -// Reconstruct the OLD (pre-2026.8.0) object_id-based hash for preference key compatibility. -// Named entities historically used the hash pre-computed by Python code generation, which -// sanitized per UTF-8 code point; entities without their own name computed the hash at -// runtime per byte. See https://github.com/esphome/backlog/issues/85 -uint32_t EntityBase::calc_old_object_id_hash_() const { - return fnv1_hash_object_id(this->name_.c_str(), this->name_.size(), this->flags_.has_own_name); +// Calculate Object ID Hash directly from name using snake_case + sanitize +void EntityBase::calc_object_id_() { + this->object_id_hash_ = fnv1_hash_object_id(this->name_.c_str(), this->name_.size()); } size_t EntityBase::write_object_id_to(char *buf, size_t buf_size) const { @@ -173,22 +167,16 @@ StringRef EntityBase::get_object_id_to(std::span buf) c } ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t version) { - // The old key hashed the sanitized object_id, so multiple entity names could collide on - // one key and overwrite each other's stored preferences; the new key hashes the raw name. - // See: https://github.com/esphome/backlog/issues/85 - uint32_t old_key = this->old_preference_key_base_() ^ version; -#ifdef USE_PREFERENCE_KEY_LOOKUP - uint32_t new_key = this->preference_key_base_() ^ version; - auto pref = global_preferences->make_preference(size, new_key); - // All in-tree entity preferences fit the stack buffer, so migration never hits the heap - SmallBufferWithHeapFallback<64> buffer(size); - migrate_preference(pref, buffer.get(), size, old_key, new_key); - return pref; -#else - // Slot-based backends keep the old key: it is only a validity tag on a positional slot, - // so collisions cannot corrupt data there and keeping it preserves stored state. - return global_preferences->make_preference(size, old_key); -#endif + // The key hashes the sanitized object_id, so multiple entity names can collide on one + // key and overwrite each other's stored preferences ("Living Room" and "living_room", + // or two UTF-8 names that both sanitize to underscores). Keys hashed from the raw name + // fix this, but they change the entity key API clients track, which the Home Assistant + // esphome integration cannot handle yet. See: https://github.com/esphome/backlog/issues/85 +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + uint32_t key = this->get_preference_hash() ^ version; +#pragma GCC diagnostic pop + return global_preferences->make_preference(size, key); } #ifdef USE_ENTITY_ICON diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 7f8e5f2630..5f2e173d8d 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -73,17 +73,8 @@ class EntityBase { // Get whether this Entity has its own name or it should use the device friendly_name. bool has_own_name() const { return this->flags_.has_own_name; } - // Get the unique key of this Entity: FNV-1 hash of the raw entity name. - // This is the key sent to API clients and used to route entity state. - uint32_t get_entity_key() const { return this->entity_key_; } - - /// Returns the LEGACY object_id hash, unchanged from previous releases, so existing - /// callers keep getting stable values (for example preference keys). This is no longer - /// the key sent to API clients; that is get_entity_key(). - ESPDEPRECATED("Use get_entity_key() for the entity key sent to API clients, or " - "make_entity_preference() for preference storage. Will be removed in 2027.1.0.", - "2026.8.0") - uint32_t get_object_id_hash() const { return this->calc_old_object_id_hash_(); } + // Get the unique Object ID of this Entity + uint32_t get_object_id_hash() const { return this->object_id_hash_; } /// Get object_id with zero heap allocation /// For static case: returns StringRef to internal storage (buffer unused) @@ -190,23 +181,39 @@ class EntityBase { // Set has_state - for components that need to manually set this void set_has_state(bool state) { this->flags_.has_state = state; } - /// Get this entity's device id, or 0 when devices are not compiled in (main device). - uint32_t get_device_id_or_zero() const { -#ifdef USE_DEVICES - return this->get_device_id(); -#else - return 0; -#endif - } - - /// Get the LEGACY preference key: FNV-1 hash of the sanitized object_id, XOR device_id. - /// Intentionally keeps the old algorithm so external callers that store preferences under - /// this key keep stable keys; make_entity_preference() migrates to the new raw-name key, - /// this method never will. + /** + * @brief Get a unique hash for storing preferences/settings for this entity. + * + * This method returns a hash that uniquely identifies the entity for the purpose of + * storing preferences (such as calibration, state, etc.). Unlike get_object_id_hash(), + * this hash also incorporates the device_id (if devices are enabled), ensuring uniqueness + * across multiple devices that may have entities with the same object_id. + * + * Use this method when storing or retrieving preferences/settings that should be unique + * per device-entity pair. Use get_object_id_hash() when you need a hash that identifies + * the entity regardless of the device it belongs to. + * + * For backward compatibility, if device_id is 0 (the main device), the hash is unchanged + * from previous versions, so existing single-device configurations will continue to work. + * + * @return uint32_t The unique hash for preferences, including device_id if available. + * @deprecated Use make_entity_preference() instead, or preferences won't be migrated. + * See https://github.com/esphome/backlog/issues/85 + */ ESPDEPRECATED("Use make_entity_preference() instead, or preferences won't be migrated. " "See https://github.com/esphome/backlog/issues/85. Will be removed in 2027.1.0.", - "2026.8.0") - uint32_t get_preference_hash() { return this->old_preference_key_base_(); } + "2026.7.0") + uint32_t get_preference_hash() { +#ifdef USE_DEVICES + // Combine object_id_hash with device_id to ensure uniqueness across devices + // Note: device_id is 0 for the main device, so XORing with 0 preserves the original hash + // This ensures backward compatibility for existing single-device configurations + return this->get_object_id_hash() ^ this->get_device_id(); +#else + // Without devices, just use object_id_hash as before + return this->get_object_id_hash(); +#endif + } /// Create a preference object for storing this entity's state/settings. /// @tparam T The type of data to store (must be trivially copyable) @@ -223,9 +230,9 @@ class EntityBase { // before push_back, so codegen can emit a single combined call per entity. friend class Application; - /// Combined entity setup from codegen: set name, entity key, entity string indices, and flags. + /// 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. - void configure_entity_(const char *name, uint32_t entity_key, uint32_t entity_fields); + void configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields); #ifdef USE_DEVICES // Codegen-only setter — only accessible from setup() via friend declaration. @@ -233,24 +240,13 @@ class EntityBase { #endif /// Non-template helper for make_entity_preference() to avoid code bloat. - /// Migrates preferences from the old sanitized-object_id key to the raw-name key - /// on key-lookup platforms. See: https://github.com/esphome/backlog/issues/85 + /// When the preference hash algorithm changes, migration logic goes here. ESPPreferenceObject make_entity_preference_(size_t size, uint32_t version); - void calc_entity_key_(); - - /// Reconstruct the OLD (pre-2026.8.0) sanitized-object_id hash for preference keys. - uint32_t calc_old_object_id_hash_() const; - - /// Preference key base for this entity: raw-name entity key XOR device_id. - uint32_t preference_key_base_() const { return this->entity_key_ ^ this->get_device_id_or_zero(); } - - /// Legacy preference key base: sanitized-object_id hash XOR device_id. - /// Note: device_id is 0 for the main device, so XORing with 0 preserves the original hash. - uint32_t old_preference_key_base_() const { return this->calc_old_object_id_hash_() ^ this->get_device_id_or_zero(); } + void calc_object_id_(); StringRef name_; - uint32_t entity_key_{}; + uint32_t object_id_hash_{}; #ifdef USE_DEVICES Device *device_{}; #endif diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 5060e32a2d..54e2551cb4 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -25,86 +25,25 @@ from esphome.core.config import ( 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_name, sanitize, snake_case +from esphome.helpers import ( + cpp_string_escape, + fnv1_hash, + fnv1_hash_object_id, + sanitize, + snake_case, +) from esphome.types import ConfigType, EntityMetadata _LOGGER = logging.getLogger(__name__) DOMAIN = "entity_string_pool" -_OBJECT_ID_DOMAIN = "entity_object_ids" - - -@dataclass -class ObjectIdEntity: - """An entity tracked by the sanitized object_id its name resolves to.""" - - name: str - platform: str - config: ConfigType - - -def _get_object_id_registry() -> dict[tuple[str, str, str], list[ObjectIdEntity]]: - """(device_id, platform, sanitized object_id) -> entities resolving to it.""" - return CORE.data.setdefault(_OBJECT_ID_DOMAIN, {}) - - -def validate_no_object_id_conflicts( - reason: str, - conflict_filter: Callable[[list[ObjectIdEntity], ConfigType], bool] | None = None, -) -> Callable[[ConfigType], ConfigType]: - """Create a final-validate step that rejects entities with colliding object_ids. - - Entity keys are hashed from the raw name, so names that only differ in characters - lost during sanitizing (for example two UTF-8 names) validate fine in general. - Components that still address entities by the sanitized object_id string must - reject those configs until they are migrated to raw names. - - Args: - reason: One sentence stating what the component builds from the object_id, - e.g. "mqtt builds default topics from the entity object_id" - conflict_filter: Optional predicate receiving the colliding entities and the - component config; return False when the component is not affected - - Returns: - A validator function for use as (or within) FINAL_VALIDATE_SCHEMA - """ - - def validator(config: ConfigType) -> ConfigType: - # Skip in testing_mode, which is used for grouped component testing - if CORE.testing_mode: - return config - conflicts = { - key: entities - for key, entities in _get_object_id_registry().items() - if len(entities) > 1 - and (conflict_filter is None or conflict_filter(entities, config)) - } - if not conflicts: - return config - lines = [f"{reason}, so these entities would conflict:"] - lines.extend( - f" - {platform} entities " - + ", ".join(f"'{e.name}'" for e in entities) - + (f" on device '{device_id}'" if device_id else "") - + f" share the object_id '{object_id}'" - for (device_id, platform, object_id), entities in conflicts.items() - ) - lines.append( - "To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B') " - "to distinguish the names" - ) - raise cv.Invalid("\n".join(lines)) - - return validator - - # Private config keys for storing registered string indices _KEY_DC_IDX = "_entity_dc_idx" _KEY_UOM_IDX = "_entity_uom_idx" _KEY_ICON_IDX = "_entity_icon_idx" _KEY_ENTITY_NAME = "_entity_name" -_KEY_ENTITY_KEY = "_entity_key" +_KEY_OBJECT_ID_HASH = "_entity_object_id_hash" # Bit layout for entity_fields in configure_entity_(). # Keep in sync with ENTITY_FIELD_*_SHIFT constants in esphome/core/entity_base.h @@ -367,7 +306,7 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: standalone ``var->configure_entity_(name, hash, packed)``. """ entity_name = config[_KEY_ENTITY_NAME] - entity_key = config[_KEY_ENTITY_KEY] + object_id_hash = config[_KEY_OBJECT_ID_HASH] dc_idx = config.get(_KEY_DC_IDX, 0) uom_idx = config.get(_KEY_UOM_IDX, 0) icon_idx = config.get(_KEY_ICON_IDX, 0) @@ -387,30 +326,57 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: register_method = config.get(_KEY_REGISTER_METHOD) if register_method is not None: expr = getattr(App, f"register_{register_method}")( - var, entity_name, entity_key, packed + var, entity_name, object_id_hash, packed ) else: - expr = var.configure_entity_(entity_name, entity_key, packed) + expr = var.configure_entity_(entity_name, object_id_hash, packed) if comment: add(RawStatement(f"{expr}; // {comment}")) else: add(expr) -def get_base_entity_name( +def get_base_entity_object_id( name: str, friendly_name: str | None, device_name: str | None = None ) -> str: - """Return the base name whose hash becomes this entity's key on the device. + """Calculate the base object ID for an entity that will be set via set_object_id(). - Follows the name selection in C++ EntityBase::configure_entity_() (entity_base.cpp): - entity name, then sub-device name, then friendly name, then the device name. + This function calculates what object_id_c_str_ should be set to in C++. - This is a config-time approximation for duplicate checking: when - name_add_mac_suffix is enabled the device appends the MAC suffix at runtime, - which is unknown here and identical for every entity on the device, so - ignoring it cannot change whether two entities collide with each other. + The C++ EntityBase::write_object_id_to() (entity_base.cpp) works as: + - If !has_own_name && is_name_add_mac_suffix_enabled(): + return str_sanitize(str_snake_case(App.get_friendly_name())) // Dynamic + - Else: + return object_id_c_str_ ?? "" // What we set via set_object_id() + + Since we're calculating what to pass to set_object_id(), we always need to + generate the object_id the same way, regardless of name_add_mac_suffix setting. + + Args: + name: The entity name (empty string if no name) + friendly_name: The friendly name from CORE.friendly_name + device_name: The device name if entity is on a sub-device + + Returns: + The base object ID to use for duplicate checking and to pass to set_object_id() """ - return name or device_name or friendly_name or CORE.name + + if name: + # Entity has its own name (has_own_name will be true) + base_str = name + elif device_name: + # Entity has empty name and is on a sub-device + # C++ EntityBase::set_name() uses device->get_name() when device is set + base_str = device_name + elif friendly_name: + # Entity has empty name (has_own_name will be false) + # C++ uses App.get_friendly_name() which returns friendly_name or device name + base_str = friendly_name + else: + # Fallback to device name + base_str = CORE.name + + return sanitize(snake_case(base_str)) def setup_entity(var_or_platform, config=None, platform=None): @@ -469,15 +435,15 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) -> device: MockObj = await get_variable(device_id_obj) add(var.set_device_(device)) - # Pre-compute entity name and entity key for configure_entity_() + # Pre-compute entity name and object_id hash for configure_entity_() # which is emitted later by finalize_entity_strings(). - # For named entities: pre-compute the key from the raw entity name - # For empty-name entities: pass 0, C++ calculates the key at runtime from - # device name, friendly_name, or app name + # For named entities: pre-compute hash from entity name + # For empty-name entities: pass 0, C++ calculates hash at runtime from + # device name, friendly_name, or app name (bug-for-bug compatibility) entity_name = config[CONF_NAME] - entity_key = fnv1_hash_name(entity_name) if entity_name else 0 + object_id_hash = fnv1_hash_object_id(entity_name) if entity_name else 0 config[_KEY_ENTITY_NAME] = entity_name - config[_KEY_ENTITY_KEY] = entity_key + config[_KEY_OBJECT_ID_HASH] = object_id_hash # Store flags for packing into configure_entity_() config[_KEY_DISABLED_BY_DEFAULT] = int(config[CONF_DISABLED_BY_DEFAULT]) if CONF_INTERNAL in config: @@ -590,13 +556,16 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy # Use the device ID string directly for uniqueness device_id = device_id_obj.id - # Hash the same raw name the device hashes into the entity key at runtime. - # This handles empty names correctly by using device/friendly names. - base_name = get_base_entity_name(entity_name, CORE.friendly_name, device_name) - name_hash = fnv1_hash_name(base_name) + # Calculate what object_id will actually be used + # This handles empty names correctly by using device/friendly names + name_key = get_base_entity_object_id( + entity_name, CORE.friendly_name, device_name + ) - # Check for duplicates: two entities on the same device and platform must not - # share an entity key, since the key is what routes state to API clients + # Check for duplicates by the FNV-1 hash of the object_id, which is the entity + # key that routes state to API clients. This rejects names that sanitize to the + # same object_id, and also two different object_ids whose 32-bit hashes collide. + name_hash = fnv1_hash(name_key) unique_key = (device_id, platform, name_hash) if unique_key in CORE.unique_ids: # Get the existing entity metadata @@ -621,14 +590,26 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy if existing_component != "unknown": conflict_msg += f" from component '{existing_component}'" - # Different names can only clash here through a genuine hash collision + # Distinguish names that sanitize to the same object_id from a genuine + # 32-bit hash collision between two different object_ids collision_msg = "" if entity_name != existing_name: - collision_msg = ( - f"\n The names '{entity_name}' and '{existing_name}' produce the" - f"\n same entity key hash ({name_hash:#010x})." - "\n To fix: Rename one of the entities" + existing_object_id = get_base_entity_object_id( + existing_name, CORE.friendly_name, existing_device or None ) + if existing_object_id == name_key: + collision_msg = ( + f"\n Original names: '{entity_name}' and '{existing_name}'" + f"\n Both convert to ASCII ID: '{name_key}'" + "\n To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B')" + "\n to distinguish them" + ) + else: + collision_msg = ( + f"\n The object_ids '{name_key}' and '{existing_object_id}'" + f"\n produce the same entity key hash ({name_hash:#010x})." + "\n To fix: Rename one of the entities" + ) # Skip duplicate entity name validation when testing_mode is enabled # This flag is used for grouped component testing @@ -640,19 +621,6 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy f"{collision_msg}" ) - # Components that still address entities by the sanitized object_id reject - # colliding names in final validation via validate_no_object_id_conflicts(), - # so track every entity by the object_id its name resolves to. Scoped per - # device and platform to match the strictness configs had before entity keys - # moved to raw names: same-named entities on different sub-devices were - # already accepted then, internal entities were already skipped (above), and - # overlaps between platforms that share an MQTT component type (sensor and - # text_sensor both publish under "sensor") were already possible. - object_id = sanitize(snake_case(base_name)) - _get_object_id_registry().setdefault( - (device_id, platform, object_id), [] - ).append(ObjectIdEntity(base_name, platform, config)) - # Store metadata about this entity entity_metadata: EntityMetadata = { "name": entity_name, diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index d883ce146e..994fa2c26a 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -809,19 +809,6 @@ constexpr uint32_t FNV1_OFFSET_BASIS = 2166136261UL; /// FNV-1 32-bit prime constexpr uint32_t FNV1_PRIME = 16777619UL; -/// Calculate a FNV-1 hash over raw bytes with an explicit length. Unlike fnv1_hash(const char *), -/// each byte is hashed as an unsigned value, so results are platform-independent for bytes >= 0x80. -/// IMPORTANT: Must match Python fnv1_hash_name() in esphome/helpers.py, which hashes the UTF-8 -/// encoded bytes of the name. Used to compute entity keys from raw names. -inline uint32_t fnv1_hash_bytes(const char *str, size_t len) { - uint32_t hash = FNV1_OFFSET_BASIS; - for (size_t i = 0; i < len; i++) { - hash *= FNV1_PRIME; - hash ^= static_cast(str[i]); - } - return hash; -} - /// Extend a FNV-1 hash with an integer (hashes each byte). template constexpr uint32_t fnv1_hash_extend(uint32_t hash, T value) { using UnsignedT = std::make_unsigned_t; @@ -1026,20 +1013,12 @@ template inline char *str_sanitize_to(char (&buffer)[N], const char *s // str_sanitize moved to alloc_helpers.h - remove this comment before 2026.11.0 /// Calculate FNV-1 hash of a string while applying snake_case + sanitize transformations. -/// This is the LEGACY entity hash, kept only to reconstruct preference keys that existing -/// devices already have stored; see https://github.com/esphome/backlog/issues/85. -/// With per_code_point set, UTF-8 continuation bytes are skipped so each multi-byte character -/// contributes one underscore — this matches Python fnv1_hash_object_id() in esphome/helpers.py, -/// which produced the hash for named entities. The per-byte form (default) matches the old -/// runtime hash for entities without their own name. Do not change either behavior. -/// Known limitation: Python's lower() is Unicode aware, so the rare code points it maps to a -/// different number of characters or to ASCII (e.g. 'İ', the Kelvin sign) reconstruct wrong; -/// such names skip migration once and fall back to their defaults. -inline uint32_t fnv1_hash_object_id(const char *str, size_t len, bool per_code_point = false) { +/// This computes object_id hashes directly from names without creating an intermediate buffer. +/// IMPORTANT: Must match Python fnv1_hash_object_id() in esphome/helpers.py. +/// If you modify this function, update the Python version and tests in both places. +inline uint32_t fnv1_hash_object_id(const char *str, size_t len) { uint32_t hash = FNV1_OFFSET_BASIS; for (size_t i = 0; i < len; i++) { - if (per_code_point && (static_cast(str[i]) & 0xC0) == 0x80) - continue; // UTF-8 continuation byte, already counted via its lead byte hash *= FNV1_PRIME; // Apply snake_case (space->underscore, uppercase->lowercase) then sanitize hash ^= static_cast(to_sanitized_char(to_snake_case_char(str[i]))); diff --git a/esphome/core/preference_backend.h b/esphome/core/preference_backend.h index 0622376fca..5df0804bdd 100644 --- a/esphome/core/preference_backend.h +++ b/esphome/core/preference_backend.h @@ -24,9 +24,10 @@ #endif // Key-lookup preference backends find stored data by key; their platforms add the -// USE_PREFERENCE_KEY_LOOKUP define from Python codegen, which enables preference key -// migration. Slot-based backends (ESP8266, RP2040) instead allocate a storage slot for -// every make_preference() call and use the key only as a validity tag on that slot; +// USE_PREFERENCE_KEY_LOOKUP define from Python codegen, which enables one-shot reads +// of stored data by key (the primitive preference key migrations need). Slot-based +// backends (ESP8266, RP2040) instead allocate a storage slot for every +// make_preference() call and use the key only as a validity tag on that slot; // migration is not possible there, and key collisions cannot corrupt data. namespace esphome { @@ -104,10 +105,9 @@ concept PreferencesContract = requires(T prefs, size_t len, uint32_t type, bool }; // Key-lookup platforms additionally provide load_from_key(), a one-shot read -// of a stored preference by key that migrate_preference() relies on; see the -// key-lookup note at the top of this file. Not part of PreferencesContract, -// so it is asserted in preferences.h only where USE_PREFERENCE_KEY_LOOKUP -// is set. +// of a stored preference by key; see the key-lookup note at the top of this +// file. Not part of PreferencesContract, so it is asserted in preferences.h +// only where USE_PREFERENCE_KEY_LOOKUP is set. template concept PreferencesKeyLookupContract = requires(T prefs, uint32_t type, uint8_t *data, size_t len) { { prefs.load_from_key(type, data, len) } -> std::same_as; diff --git a/esphome/core/preferences.cpp b/esphome/core/preferences.cpp deleted file mode 100644 index 8508647255..0000000000 --- a/esphome/core/preferences.cpp +++ /dev/null @@ -1,25 +0,0 @@ -#include "esphome/core/preferences.h" -#include "esphome/core/log.h" -#include - -namespace esphome { - -#ifdef USE_PREFERENCE_KEY_LOOKUP -static const char *const TAG = "preferences"; - -bool migrate_preference(ESPPreferenceObject &new_pref, uint8_t *scratch, size_t size, uint32_t old_key, - uint32_t new_key) { - if (new_pref.load(scratch, size)) - return true; // Current data present - never overwrite newer data with the old copy - // One-shot read by key: no backend is allocated for the old key, so boots with - // nothing to migrate (for example fresh installs) cost no heap - if (old_key == new_key || !global_preferences->load_from_key(old_key, scratch, size)) - return false; // No data stored under the old key, nothing to migrate - if (!new_pref.save(scratch, size)) { - ESP_LOGW(TAG, "Pref migration %" PRIx32 " -> %" PRIx32 " failed", old_key, new_key); - } - return true; -} -#endif // USE_PREFERENCE_KEY_LOOKUP - -} // namespace esphome diff --git a/esphome/core/preferences.h b/esphome/core/preferences.h index cfeddebda7..ed23dfae56 100644 --- a/esphome/core/preferences.h +++ b/esphome/core/preferences.h @@ -56,17 +56,5 @@ namespace esphome { static_assert(PreferencesKeyLookupContract, "This platform emits USE_PREFERENCE_KEY_LOOKUP but its preferences manager does not provide " "load_from_key() (esphome/core/preference_backend.h)"); - -/// Copy preference data stored under old_key into new_pref (created for new_key) if the keys -/// differ and new_pref has no data yet. scratch must hold at least size bytes. -/// Returns true when scratch holds the entity's current data (loaded or just migrated). -/// The old entry is intentionally left in place so a firmware downgrade still finds its data. -/// If saving under the new key fails, callers that consume scratch (like TextSaver) still get -/// valid data for this boot, callers that reload from the preference fall back to their -/// defaults, and the migration simply runs again on the next boot. -/// Only available on key-lookup preference backends; slot-based backends keep their old -/// keys instead. See: https://github.com/esphome/backlog/issues/85 -bool migrate_preference(ESPPreferenceObject &new_pref, uint8_t *scratch, size_t size, uint32_t old_key, - uint32_t new_key); } // namespace esphome #endif // USE_PREFERENCE_KEY_LOOKUP diff --git a/esphome/helpers.py b/esphome/helpers.py index 2731109164..9b2a461ccd 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -91,13 +91,8 @@ def fnv1a_32bit_hash(string: str) -> int: def fnv1_hash_object_id(name: str) -> int: """Compute FNV-1 hash of name with snake_case + sanitize transformations. - IMPORTANT: Must produce same result as C++ fnv1_hash_object_id() in helpers.h - with per_code_point set. This is the OLD entity hash; it computes preference - keys that existing devices already have stored (see - https://github.com/esphome/backlog/issues/85) and is also still used for live - keys derived from config IDs (see the motion component's calibration key). - Note: lower() here is Unicode aware while the C++ reconstruction is not; see - the known limitation note on the C++ function. + IMPORTANT: Must produce same result as C++ fnv1_hash_object_id() in helpers.h. + If you modify this function, update the C++ version and tests in both places. """ return fnv1_hash(sanitize(snake_case(name))) @@ -105,9 +100,9 @@ def fnv1_hash_object_id(name: str) -> int: def fnv1_hash_name(name: str) -> int: """Compute FNV-1 hash of the raw entity name (UTF-8 bytes, no transformations). - IMPORTANT: Must produce same result as C++ fnv1_hash_bytes() in helpers.h, - which hashes the name bytes as stored on the device. - Used for pre-computing entity keys at code generation time. + 2026.8 beta firmware stored preferences under keys derived from this hash; + a future key migration must reconstruct those keys to recover that data + (see https://github.com/esphome/backlog/issues/85). """ return _fnv1_hash(name.encode("utf-8")) diff --git a/tests/integration/entity_utils.py b/tests/integration/entity_utils.py index 95f6a0321e..7596983ee2 100644 --- a/tests/integration/entity_utils.py +++ b/tests/integration/entity_utils.py @@ -8,7 +8,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from esphome.helpers import fnv1_hash_name, sanitize, snake_case +from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case if TYPE_CHECKING: from aioesphomeapi import DeviceInfo, EntityInfo @@ -25,16 +25,15 @@ def infer_name_add_mac_suffix(device_info: DeviceInfo) -> bool: return device_info.name.endswith(f"-{mac_suffix}") -def _resolve_entity_name( +def _get_name_for_object_id( entity: EntityInfo, device_info: DeviceInfo, device_id_to_name: dict[int, str], ) -> str: - """Resolve the effective name for an entity. + """Get the name used for object_id computation. This is the algorithm that aioesphomeapi will use to determine which - name to use for computing object_id client-side from API data; the same - name is what the device hashes into the entity key. + name to use for computing object_id client-side from API data. Args: entity: The entity to get name for @@ -73,27 +72,27 @@ def compute_entity_object_id( Returns: The computed object_id string """ - name = _resolve_entity_name(entity, device_info, device_id_to_name) - return compute_object_id(name) + name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name) + return compute_object_id(name_for_id) -def compute_entity_key( +def compute_entity_hash( entity: EntityInfo, device_info: DeviceInfo, device_id_to_name: dict[int, str], ) -> int: - """Compute expected entity key for an entity. + """Compute expected object_id hash for an entity. Args: - entity: The entity to compute the key for + entity: The entity to compute hash for device_info: Device info from the API device_id_to_name: Mapping of device_id to device name for sub-devices Returns: - The computed FNV-1 hash of the raw name + The computed FNV-1 hash """ - name = _resolve_entity_name(entity, device_info, device_id_to_name) - return fnv1_hash_name(name) + name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name) + return fnv1_hash_object_id(name_for_id) def verify_entity_object_id( @@ -119,7 +118,7 @@ def verify_entity_object_id( f"expected '{expected_object_id}', got '{entity.object_id}'" ) - expected_hash = compute_entity_key(entity, device_info, device_id_to_name) + expected_hash = compute_entity_hash(entity, device_info, device_id_to_name) assert entity.key == expected_hash, ( f"hash mismatch for entity '{entity.name}': " f"expected {expected_hash:#x}, got {entity.key:#x}" diff --git a/tests/integration/fixtures/fnv1_hash_object_id.yaml b/tests/integration/fixtures/fnv1_hash_object_id.yaml index d4511bb8c6..2097b2fbf9 100644 --- a/tests/integration/fixtures/fnv1_hash_object_id.yaml +++ b/tests/integration/fixtures/fnv1_hash_object_id.yaml @@ -71,38 +71,6 @@ esphome: ESP_LOGE("FNV1_OID", "empty FAILED: 0x%08x != 0x811c9dc5", hash_empty); } - // Raw name hash: matches Python fnv1_hash_name("My Sensor Name") - uint32_t hash_raw = esphome::fnv1_hash_bytes("My Sensor Name", 14); - if (hash_raw == 0x8cec6fb0) { - ESP_LOGI("FNV1_OID", "raw PASSED"); - } else { - ESP_LOGE("FNV1_OID", "raw FAILED: 0x%08x != 0x8cec6fb0", hash_raw); - } - - // Raw name hash over UTF-8 bytes: matches Python fnv1_hash_name("Température") - uint32_t hash_raw_utf8 = esphome::fnv1_hash_bytes("Temp\xc3\xa9rature", 12); - if (hash_raw_utf8 == 0x531a74aa) { - ESP_LOGI("FNV1_OID", "raw_utf8 PASSED"); - } else { - ESP_LOGE("FNV1_OID", "raw_utf8 FAILED: 0x%08x != 0x531a74aa", hash_raw_utf8); - } - - // Old-key UTF-8 variant: matches Python fnv1_hash_object_id("Température") - uint32_t hash_old_utf8 = esphome::fnv1_hash_object_id("Temp\xc3\xa9rature", 12, true); - if (hash_old_utf8 == 0x965698f3) { - ESP_LOGI("FNV1_OID", "old_utf8 PASSED"); - } else { - ESP_LOGE("FNV1_OID", "old_utf8 FAILED: 0x%08x != 0x965698f3", hash_old_utf8); - } - - // Old-key UTF-8 variant with multi-byte only name: Python fnv1_hash_object_id("温度") - uint32_t hash_old_cjk = esphome::fnv1_hash_object_id("\xe6\xb8\xa9\xe5\xba\xa6", 6, true); - if (hash_old_cjk == 0x3276cb9f) { - ESP_LOGI("FNV1_OID", "old_cjk PASSED"); - } else { - ESP_LOGE("FNV1_OID", "old_cjk FAILED: 0x%08x != 0x3276cb9f", hash_old_cjk); - } - host: api: logger: diff --git a/tests/integration/fixtures/multi_device_preferences.yaml b/tests/integration/fixtures/multi_device_preferences.yaml index 582add90a8..01e4394559 100644 --- a/tests/integration/fixtures/multi_device_preferences.yaml +++ b/tests/integration/fixtures/multi_device_preferences.yaml @@ -156,17 +156,10 @@ button: ESP_LOGI("test", "Device A Mode: %s", id(mode_device_a).current_option().c_str()); ESP_LOGI("test", "Device B Mode: %s", id(mode_device_b).current_option().c_str()); ESP_LOGI("test", "Main Mode: %s", id(mode_main).current_option().c_str()); - // Log preference key bases for entities that actually store preferences. - // This is the key base make_entity_preference() uses: entity key XOR device id. - ESP_LOGI("test", "Device A Switch Pref Hash: %u", - id(light_device_a).get_entity_key() ^ id(light_device_a).get_device_id_or_zero()); - ESP_LOGI("test", "Device B Switch Pref Hash: %u", - id(light_device_b).get_entity_key() ^ id(light_device_b).get_device_id_or_zero()); - ESP_LOGI("test", "Main Switch Pref Hash: %u", - id(light_main).get_entity_key() ^ id(light_main).get_device_id_or_zero()); - ESP_LOGI("test", "Device A Number Pref Hash: %u", - id(setpoint_device_a).get_entity_key() ^ id(setpoint_device_a).get_device_id_or_zero()); - ESP_LOGI("test", "Device B Number Pref Hash: %u", - id(setpoint_device_b).get_entity_key() ^ id(setpoint_device_b).get_device_id_or_zero()); - ESP_LOGI("test", "Main Number Pref Hash: %u", - id(setpoint_main).get_entity_key() ^ id(setpoint_main).get_device_id_or_zero()); + // Log preference hashes for entities that actually store preferences + ESP_LOGI("test", "Device A Switch Pref Hash: %u", id(light_device_a).get_preference_hash()); + ESP_LOGI("test", "Device B Switch Pref Hash: %u", id(light_device_b).get_preference_hash()); + ESP_LOGI("test", "Main Switch Pref Hash: %u", id(light_main).get_preference_hash()); + ESP_LOGI("test", "Device A Number Pref Hash: %u", id(setpoint_device_a).get_preference_hash()); + ESP_LOGI("test", "Device B Number Pref Hash: %u", id(setpoint_device_b).get_preference_hash()); + ESP_LOGI("test", "Main Number Pref Hash: %u", id(setpoint_main).get_preference_hash()); diff --git a/tests/integration/fixtures/preference_key_migration.yaml b/tests/integration/fixtures/preference_key_migration.yaml deleted file mode 100644 index a9b01fc2d2..0000000000 --- a/tests/integration/fixtures/preference_key_migration.yaml +++ /dev/null @@ -1,35 +0,0 @@ -esphome: - name: host-pref-key-migration - -host: -api: -logger: - -switch: - - platform: template - id: test_switch_restore - name: Test Switch - optimistic: true - restore_mode: RESTORE_DEFAULT_OFF - -number: - - platform: template - id: test_number_restore - name: Test Number - optimistic: true - restore_value: true - initial_value: 1.0 - min_value: 0 - max_value: 100 - step: 0.5 - -text: - - platform: template - id: test_text_restore - name: Test Text - mode: text - optimistic: true - restore_value: true - initial_value: fallback - min_length: 0 - max_length: 20 diff --git a/tests/integration/host_prefs.py b/tests/integration/host_prefs.py index c7f21d8a01..f835bee3bc 100644 --- a/tests/integration/host_prefs.py +++ b/tests/integration/host_prefs.py @@ -25,25 +25,15 @@ def clear_host_prefs(device_name: str) -> None: host_prefs_path(device_name).unlink(missing_ok=True) -def write_host_prefs(device_name: str, entries: dict[int, bytes]) -> Path: - """Write preference entries, replacing the file's contents. - - Returns the path that was written. - """ - payload = b"" - for key, data in entries.items(): - if len(data) > 255: - raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)") - payload += struct.pack(" Path: """Write a single preference entry, replacing the file's contents. Returns the path that was written. """ - return write_host_prefs(device_name, {key: data}) + if len(data) > 255: + raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)") + path = host_prefs_path(device_name) + path.parent.mkdir(parents=True, exist_ok=True) + payload = struct.pack(" None: diff --git a/tests/integration/test_object_id_api_verification.py b/tests/integration/test_object_id_api_verification.py index 8dafb37c64..c8603e0682 100644 --- a/tests/integration/test_object_id_api_verification.py +++ b/tests/integration/test_object_id_api_verification.py @@ -2,8 +2,8 @@ This test verifies a three-way match between: 1. C++ object_id generation (get_object_id_to using to_sanitized_char/to_snake_case_char) -2. C++ entity key generation (fnv1_hash of the raw name in helpers.h) -3. Python computation (sanitize/snake_case and fnv1_hash_name in helpers.py) +2. C++ hash generation (fnv1_hash_object_id in helpers.h) +3. Python computation (sanitize/snake_case in helpers.py, fnv1_hash_object_id) The API response contains C++ computed values, so verifying API == Python implicitly verifies C++ == Python == API for both object_id and hash. @@ -25,7 +25,7 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_name +from esphome.helpers import fnv1_hash_object_id from .entity_utils import compute_object_id, verify_all_entities from .types import APIClientConnectedFactory, RunCompiledFunction @@ -123,7 +123,7 @@ async def test_object_id_api_verification( ) # Verify hash can be computed from the name - hash_from_name = fnv1_hash_name(entity_name) + hash_from_name = fnv1_hash_object_id(entity_name) assert hash_from_name == entity.key, ( f"Entity '{entity_name}': hash mismatch. " f"Python hash {hash_from_name:#x}, API key {entity.key:#x}" @@ -164,7 +164,7 @@ async def test_object_id_api_verification( ) # Verify hash matches - expected_hash = fnv1_hash_name(expected_name) + expected_hash = fnv1_hash_object_id(expected_name) assert entity.key == expected_hash, ( f"Empty-name entity (device_id={entity.device_id}): hash mismatch. " f"API key: {entity.key:#x}, expected: {expected_hash:#x}" diff --git a/tests/integration/test_object_id_friendly_name_no_mac_suffix.py b/tests/integration/test_object_id_friendly_name_no_mac_suffix.py index b58593f2ef..7199a2b371 100644 --- a/tests/integration/test_object_id_friendly_name_no_mac_suffix.py +++ b/tests/integration/test_object_id_friendly_name_no_mac_suffix.py @@ -11,7 +11,7 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_name +from esphome.helpers import fnv1_hash_object_id from .entity_utils import ( compute_object_id, @@ -62,7 +62,7 @@ async def test_object_id_friendly_name_no_mac_suffix( ) # Hash should match friendly_name - expected_hash = fnv1_hash_name("My Friendly Device") + expected_hash = fnv1_hash_object_id("My Friendly Device") assert entity.key == expected_hash, ( f"Expected hash {expected_hash:#x}, got {entity.key:#x}" ) diff --git a/tests/integration/test_object_id_no_friendly_name.py b/tests/integration/test_object_id_no_friendly_name.py index 45b5f730a6..b548f02fde 100644 --- a/tests/integration/test_object_id_no_friendly_name.py +++ b/tests/integration/test_object_id_no_friendly_name.py @@ -17,7 +17,7 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_name +from esphome.helpers import fnv1_hash_object_id from .entity_utils import compute_object_id, verify_all_entities from .types import APIClientConnectedFactory, RunCompiledFunction @@ -96,7 +96,7 @@ async def test_object_id_no_friendly_name_no_mac_suffix( OLD behavior: - is_object_id_dynamic_() returned false (mac suffix not enabled) - Used object_id_c_str_ which was pre-computed in Python - - Python used get_base_entity_name() with fallback to CORE.name + - Python used get_base_entity_object_id() with fallback to CORE.name Result: object_id = sanitize(snake_case(device_name)) """ @@ -126,7 +126,7 @@ async def test_object_id_no_friendly_name_no_mac_suffix( ) # Hash should match device name - expected_hash = fnv1_hash_name("test-device") + expected_hash = fnv1_hash_object_id("test-device") assert entity.key == expected_hash, ( f"Expected hash {expected_hash:#x}, got {entity.key:#x}" ) diff --git a/tests/integration/test_preference_key_migration.py b/tests/integration/test_preference_key_migration.py deleted file mode 100644 index e7f699bb12..0000000000 --- a/tests/integration/test_preference_key_migration.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Integration test for entity preference key migration. - -Entity keys are now the FNV-1 hash of the raw name instead of the sanitized -object_id (https://github.com/esphome/backlog/issues/85). On key-lookup -preference backends, make_entity_preference() must move data stored under the -old key to the new key, so devices keep their restored state after upgrading. - -This test seeds the host preferences file the way a pre-migration firmware -would have written it and verifies: -1. Data stored under the OLD key is restored (migration happened, no data loss) -2. Data already stored under the NEW key is never overwritten by old data -""" - -from __future__ import annotations - -import socket -import struct - -from aioesphomeapi import ( - NumberInfo, - NumberState, - SwitchInfo, - SwitchState, - TextInfo, - TextState, -) -import pytest - -from esphome.helpers import fnv1_hash, fnv1_hash_name, fnv1_hash_object_id - -from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client -from .host_prefs import clear_host_prefs, write_host_prefs -from .state_utils import InitialStateHelper, require_entity -from .types import CompileFunction, ConfigWriter - -DEVICE_NAME = "host-pref-key-migration" - -# The pre-migration preference key was the sanitized object_id hash; the new -# key is the raw-name hash. All entities are on the main device (device_id 0) -# and their preferences use no version salt, so the key is just the hash. -SWITCH_OLD_KEY = fnv1_hash_object_id("Test Switch") -SWITCH_NEW_KEY = fnv1_hash_name("Test Switch") -NUMBER_OLD_KEY = fnv1_hash_object_id("Test Number") -NUMBER_NEW_KEY = fnv1_hash_name("Test Number") - -# template_text salts its key with the length limits and pattern hash; this must -# match TemplateText::setup() in template_text.cpp (min_length 0, max_length 20, -# no pattern configured) -TEXT_KEY_EXTRA = (0 << 2) + (20 << 4) + (fnv1_hash("") << 6) -TEXT_OLD_KEY = (fnv1_hash_object_id("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF -TEXT_NEW_KEY = (fnv1_hash_name("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF - -# TextSaver<20> stores a length-prefixed buffer of max_length + 1 bytes -TEXT_MAX_LENGTH = 20 - - -def text_pref_payload(value: str) -> bytes: - """Build the length-prefixed buffer TextSaver stores for a value.""" - data = value.encode("utf-8") - assert len(data) <= TEXT_MAX_LENGTH - return bytes([len(data)]) + data + b"\x00" * (TEXT_MAX_LENGTH - len(data)) - - -@pytest.mark.asyncio -async def test_preference_key_migration( - yaml_config: str, - write_yaml_config: ConfigWriter, - compile_esphome: CompileFunction, - reserved_tcp_port: tuple[int, socket.socket], -) -> None: - """Test that preferences stored under the old key survive the upgrade.""" - port, port_socket = reserved_tcp_port - - assert SWITCH_OLD_KEY != SWITCH_NEW_KEY - assert NUMBER_OLD_KEY != NUMBER_NEW_KEY - assert TEXT_OLD_KEY != TEXT_NEW_KEY - - # Write and compile once - config_path = await write_yaml_config(yaml_config) - binary_path = await compile_esphome(config_path) - - # Release the reserved port so the binary can bind to it - port_socket.close() - - async def boot_and_get_initial_states() -> tuple[ - SwitchState, NumberState, TextState - ]: - """Boot the binary and return the restored entity states.""" - async with ( - run_binary_and_wait_for_port(binary_path, "127.0.0.1", port), - wait_and_connect_api_client(port=port) as client, - ): - device_info = await client.device_info() - assert device_info.name == DEVICE_NAME - - entities, _ = await client.list_entities_services() - switch_entity = require_entity( - entities, "test_switch", SwitchInfo, "Test Switch" - ) - number_entity = require_entity( - entities, "test_number", NumberInfo, "Test Number" - ) - text_entity = require_entity(entities, "test_text", TextInfo, "Test Text") - - initial_state_helper = InitialStateHelper(entities) - client.subscribe_states( - initial_state_helper.on_state_wrapper(lambda s: None) - ) - await initial_state_helper.wait_for_initial_states() - - switch_state = initial_state_helper.initial_states[switch_entity.key] - number_state = initial_state_helper.initial_states[number_entity.key] - text_state = initial_state_helper.initial_states[text_entity.key] - assert isinstance(switch_state, SwitchState) - assert isinstance(number_state, NumberState) - assert isinstance(text_state, TextState) - return switch_state, number_state, text_state - - try: - # --- Run 1: only OLD keys present, as written by pre-migration firmware. - # The restored states prove the data was migrated to the new keys. - write_host_prefs( - DEVICE_NAME, - { - SWITCH_OLD_KEY: b"\x01", # bool: switch was ON - NUMBER_OLD_KEY: struct.pack(" None: - """Verify _COMMAND_TOPIC_PLATFORMS matches the MQTT components that subscribe. - - Drift silently reintroduces shared subscribe topics, so this derives the set - from the C++ components that actually call subscribe(); that also catches - platforms like text that subscribe a command topic without exposing a - command_topic key in their schema. - """ - expected: set[str] = set() - for path in (COMPONENTS_DIR / "mqtt").glob("mqtt_*.cpp"): - if path.stem in _NON_ENTITY_MQTT_SOURCES: - continue - if "this->subscribe" not in path.read_text(encoding="utf-8"): - continue - stem = path.stem.removeprefix("mqtt_") - expected.add("datetime" if stem in _DATETIME_STEMS else stem) - assert expected == _COMMAND_TOPIC_PLATFORMS - - -def test_sub_topic_platforms_in_sync() -> None: - """Verify _SUB_TOPIC_PLATFORMS matches the MQTT components with sub-topics. - - Platforms whose MQTT headers use MQTT_COMPONENT_CUSTOM_TOPIC derive extra - topics such as position/command from the object_id. - """ - expected = { - path.stem.removeprefix("mqtt_") - for path in (COMPONENTS_DIR / "mqtt").glob("mqtt_*.h") - if path.stem != "mqtt_component" - and "MQTT_COMPONENT_CUSTOM_TOPIC" in path.read_text(encoding="utf-8") - } - assert expected == _SUB_TOPIC_PLATFORMS - - -def test_conflict_filter_exempts_custom_topics() -> None: - """Test that custom state topics with discovery off avoid the conflict.""" - validator = entity_duplicate_validator("sensor") - # Both entities have custom state topics and discovery disabled per entity, - # so no object_id-derived MQTT topic is used - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"} - assert component_validator(config) is config - - # Without the filter the same conflicts are fatal - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - validate_no_object_id_conflicts(REASON)({}) - - -def test_conflict_on_default_command_topic() -> None: - """Test that commandable platforms conflict through their default command topic. - - Custom state topics with discovery off are not enough for platforms that also - subscribe to an object_id-derived command topic. - """ - validator = entity_duplicate_validator("switch") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - mqtt_config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"} - # Both switches share the default command topic: rejected - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - component_validator(mqtt_config) - - # With custom command topics as well, nothing derives from the object_id - CORE.reset() - validator = entity_duplicate_validator("switch") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_COMMAND_TOPIC: "custom/cmd/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_COMMAND_TOPIC: "custom/cmd/b", - CONF_DISCOVERY: False, - } - ) - assert component_validator(mqtt_config) is mqtt_config - - -def test_conflict_on_sub_topic_platforms() -> None: - """Test that platforms with extra object_id sub-topics always conflict. - - Covers derive topics like position/command from the object_id through their - own config keys, so custom state and command topics cannot exempt them. - """ - validator = entity_duplicate_validator("cover") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_COMMAND_TOPIC: "custom/cmd/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_COMMAND_TOPIC: "custom/cmd/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - component_validator({CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"}) - - -def test_no_conflict_on_disjoint_default_topics() -> None: - """Test that entities whose default topics are disjoint do not conflict. - - One entity uses only the default command topic and the other only the default - state topic, so they never share a topic. - """ - validator = entity_duplicate_validator("switch") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_COMMAND_TOPIC: "custom/cmd/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"} - assert component_validator(config) is config - - -def test_no_conflict_on_empty_topic_prefix() -> None: - """Test that an empty topic_prefix disables the default topic conflict. - - With topic_prefix set to null no default topics exist at runtime, so entities - without custom state topics cannot conflict; only discovery still matters. - """ - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Датчик открытия"}) - validator({CONF_NAME: "Датчик закрытия"}) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - # No default topics and no discovery: valid - config: dict = {CONF_DISCOVERY: False, CONF_TOPIC_PREFIX: ""} - assert component_validator(config) is config - - # Discovery still uses object_id-derived config topics: rejected - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - component_validator({CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: ""}) diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 64400c4fd4..53035ad713 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -1,4 +1,4 @@ -"""Tests for entity helpers: name selection, entity key hashing, duplicate checks.""" +"""Test get_base_entity_object_id function matches C++ behavior.""" from collections.abc import Callable, Generator from pathlib import Path @@ -25,17 +25,16 @@ from esphome.core.entity_helpers import ( _setup_entity_impl, entity_duplicate_validator, finalize_entity_strings, - get_base_entity_name, + get_base_entity_object_id, register_device_class, register_icon, register_unit_of_measurement, setup_device_class, setup_entity, setup_unit_of_measurement, - validate_no_object_id_conflicts, ) from esphome.cpp_generator import MockObj -from esphome.helpers import fnv1_hash_name, sanitize, snake_case +from esphome.helpers import fnv1_hash, sanitize, snake_case from .common import load_config_from_fixture @@ -58,26 +57,206 @@ def restore_core_state() -> Generator[None, None, None]: CORE.friendly_name = original_friendly_name -def test_get_base_entity_name_priority_order() -> None: +def test_with_entity_name() -> None: + """Test when entity has its own name - should use entity name.""" + # Simple name + assert get_base_entity_object_id("Temperature Sensor", None) == "temperature_sensor" + assert ( + get_base_entity_object_id("Temperature Sensor", "Device Name") + == "temperature_sensor" + ) + # Even with device name, entity name takes precedence + assert ( + get_base_entity_object_id("Temperature Sensor", "Device Name", "Sub Device") + == "temperature_sensor" + ) + + # Name with special characters + assert ( + get_base_entity_object_id("Temp!@#$%^&*()Sensor", None) + == "temp__________sensor" + ) + assert get_base_entity_object_id("Temp-Sensor_123", None) == "temp-sensor_123" + + # Already snake_case + assert get_base_entity_object_id("temperature_sensor", None) == "temperature_sensor" + + # Mixed case + assert get_base_entity_object_id("TemperatureSensor", None) == "temperaturesensor" + assert get_base_entity_object_id("TEMPERATURE SENSOR", None) == "temperature_sensor" + + +def test_empty_name_with_device_name() -> None: + """Test when entity has empty name and is on a sub-device - should use device name.""" + # C++ behavior: when has_own_name is false and device is set, uses device->get_name() + assert ( + get_base_entity_object_id("", "Friendly Device", "Sub Device 1") + == "sub_device_1" + ) + assert ( + get_base_entity_object_id("", "Kitchen Controller", "controller_1") + == "controller_1" + ) + assert get_base_entity_object_id("", None, "Test-Device_123") == "test-device_123" + + +def test_empty_name_with_friendly_name() -> None: + """Test when entity has empty name and no device - should use friendly name.""" + # C++ behavior: when has_own_name is false, uses App.get_friendly_name() + assert get_base_entity_object_id("", "Friendly Device") == "friendly_device" + assert get_base_entity_object_id("", "Kitchen Controller") == "kitchen_controller" + assert get_base_entity_object_id("", "Test-Device_123") == "test-device_123" + + # Special characters in friendly name + assert get_base_entity_object_id("", "Device!@#$%") == "device_____" + + +def test_empty_name_no_friendly_name() -> None: + """Test when entity has empty name and no friendly name - should use device name.""" + # Test with CORE.name set + CORE.name = "device-name" + assert get_base_entity_object_id("", None) == "device-name" + + CORE.name = "Test Device" + assert get_base_entity_object_id("", None) == "test_device" + + +def test_edge_cases() -> None: + """Test edge cases.""" + # Only spaces + assert get_base_entity_object_id(" ", None) == "___" + + # Unicode characters (should be replaced) + assert get_base_entity_object_id("Température", None) == "temp_rature" + assert get_base_entity_object_id("测试", None) == "__" + + # Empty string with empty friendly name (empty friendly name is treated as None) + # Falls back to CORE.name + CORE.name = "device" + assert get_base_entity_object_id("", "") == "device" + + # Very long name (should work fine) + long_name = "a" * 100 + " " + "b" * 100 + expected = "a" * 100 + "_" + "b" * 100 + assert get_base_entity_object_id(long_name, None) == expected + + +@pytest.mark.parametrize( + ("name", "expected"), + [ + ("Temperature Sensor", "temperature_sensor"), + ("Living Room Light", "living_room_light"), + ("Test-Device_123", "test-device_123"), + ("Special!@#Chars", "special___chars"), + ("UPPERCASE NAME", "uppercase_name"), + ("lowercase name", "lowercase_name"), + ("Mixed Case Name", "mixed_case_name"), + (" Spaces ", "___spaces___"), + ], +) +def test_matches_cpp_helpers(name: str, expected: str) -> None: + """Test that the logic matches using snake_case and sanitize directly.""" + # For non-empty names, verify our function produces same result as direct snake_case + sanitize + assert get_base_entity_object_id(name, None) == sanitize(snake_case(name)) + assert get_base_entity_object_id(name, None) == expected + + +def test_empty_name_fallback() -> None: + """Test empty name handling which falls back to friendly_name or CORE.name.""" + # Empty name is handled specially - it doesn't just use sanitize(snake_case("")) + # Instead it falls back to friendly_name or CORE.name + assert sanitize(snake_case("")) == "" # Direct conversion gives empty string + # But our function returns a fallback + CORE.name = "device" + assert get_base_entity_object_id("", None) == "device" # Uses device name + + +def test_name_add_mac_suffix_behavior() -> None: + """Test behavior related to name_add_mac_suffix. + + In C++, an entity's object_id is computed from its name_ via + write_object_id_to() (sanitized snake_case). When an entity has no name, + configure_entity_() sets name_ from the friendly name, with the MAC suffix + appended when name_add_mac_suffix is enabled. Our function always returns + the same result since we're calculating the base for duplicate tracking. + """ + # The function should always return the same result regardless of + # name_add_mac_suffix setting, as we're calculating the base object_id + assert get_base_entity_object_id("", "Test Device") == "test_device" + assert get_base_entity_object_id("Entity Name", "Test Device") == "entity_name" + + +def test_priority_order() -> None: """Test the priority order: entity name > device name > friendly name > CORE.name.""" CORE.name = "core-device" - # 1. Entity name has highest priority and is used as-is, no transformations + # 1. Entity name has highest priority assert ( - get_base_entity_name("Entity Name", "Friendly Name", "Device Name") - == "Entity Name" + get_base_entity_object_id("Entity Name", "Friendly Name", "Device Name") + == "entity_name" ) - assert get_base_entity_name("Température", None) == "Température" # 2. Device name is next priority (when entity name is empty) - assert get_base_entity_name("", "Friendly Name", "Device Name") == "Device Name" + assert ( + get_base_entity_object_id("", "Friendly Name", "Device Name") == "device_name" + ) # 3. Friendly name is next (when entity and device names are empty) - assert get_base_entity_name("", "Friendly Name", None) == "Friendly Name" + assert get_base_entity_object_id("", "Friendly Name", None) == "friendly_name" - # 4. CORE.name is last resort; an empty friendly name falls through to it - assert get_base_entity_name("", None, None) == "core-device" - assert get_base_entity_name("", "") == "core-device" + # 4. CORE.name is last resort + assert get_base_entity_object_id("", None, None) == "core-device" + + +@pytest.mark.parametrize( + ("name", "friendly_name", "device_name", "expected"), + [ + # name, friendly_name, device_name, expected + ("Living Room Light", None, None, "living_room_light"), + ("", "Kitchen Controller", None, "kitchen_controller"), + ( + "", + "ESP32 Device", + "controller_1", + "controller_1", + ), # Device name takes precedence + ("GPIO2 Button", None, None, "gpio2_button"), + ("WiFi Signal", "My Device", None, "wifi_signal"), + ("", None, "esp32_node", "esp32_node"), + ("Front Door Sensor", "Home Assistant", "door_controller", "front_door_sensor"), + ], +) +def test_real_world_examples( + name: str, friendly_name: str | None, device_name: str | None, expected: str +) -> None: + """Test real-world entity naming scenarios.""" + result = get_base_entity_object_id(name, friendly_name, device_name) + assert result == expected + + +def test_issue_6953_scenarios() -> None: + """Test specific scenarios from issue #6953.""" + # Scenario 1: Multiple empty names on main device with name_add_mac_suffix + # The Python code calculates the base, C++ might append MAC suffix dynamically + CORE.name = "device-name" + CORE.friendly_name = "Friendly Device" + + # All empty names should resolve to same base + assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" + assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" + assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" + + # Scenario 2: Empty names on sub-devices + assert ( + get_base_entity_object_id("", "Main Device", "controller_1") == "controller_1" + ) + assert ( + get_base_entity_object_id("", "Main Device", "controller_2") == "controller_2" + ) + + # Scenario 3: xyz duplicates + assert get_base_entity_object_id("xyz", None) == "xyz" + assert get_base_entity_object_id("xyz", "Device") == "xyz" # Tests for setup_entity function @@ -336,10 +515,9 @@ def test_entity_duplicate_validator() -> None: config1 = {CONF_NAME: "Temperature"} validated1 = validator(config1) assert validated1 == config1 - temperature_key = ("", "sensor", fnv1_hash_name("Temperature")) - assert temperature_key in CORE.unique_ids + assert ("", "sensor", fnv1_hash("temperature")) in CORE.unique_ids # Check metadata was stored - metadata = CORE.unique_ids[temperature_key] + metadata = CORE.unique_ids[("", "sensor", fnv1_hash("temperature"))] assert metadata["name"] == "Temperature" assert metadata["platform"] == "sensor" @@ -347,9 +525,8 @@ def test_entity_duplicate_validator() -> None: config2 = {CONF_NAME: "Humidity"} validated2 = validator(config2) assert validated2 == config2 - humidity_key = ("", "sensor", fnv1_hash_name("Humidity")) - assert humidity_key in CORE.unique_ids - metadata2 = CORE.unique_ids[humidity_key] + assert ("", "sensor", fnv1_hash("humidity")) in CORE.unique_ids + metadata2 = CORE.unique_ids[("", "sensor", fnv1_hash("humidity"))] assert metadata2["name"] == "Humidity" # Duplicate entity should fail @@ -360,6 +537,34 @@ def test_entity_duplicate_validator() -> None: validator(config3) +def test_entity_duplicate_validator_hash_collision() -> None: + """Test that two different object_ids with the same FNV-1 hash are rejected.""" + # Brute-forced FNV-1 32-bit collision pair; both object_ids hash to 0xe95747e4 + name_a = "Sensor aooxzi" + name_b = "Sensor baraia" + object_id_a = sanitize(snake_case(name_a)) + object_id_b = sanitize(snake_case(name_b)) + assert object_id_a != object_id_b + assert fnv1_hash(object_id_a) == fnv1_hash(object_id_b) + + validator = entity_duplicate_validator("sensor") + + config1 = {CONF_NAME: name_a} + validated1 = validator(config1) + assert validated1 == config1 + + config2 = {CONF_NAME: name_b} + with pytest.raises( + Invalid, + match=re.compile( + r"Duplicate sensor entity with name 'Sensor baraia' found.*" + r"produce the same entity key hash \(0xe95747e4\)", + re.DOTALL, + ), + ): + validator(config2) + + def test_entity_duplicate_validator_with_devices() -> None: """Test entity_duplicate_validator with devices.""" # Create validator for sensor platform @@ -370,19 +575,18 @@ def test_entity_duplicate_validator_with_devices() -> None: device2 = ID("device2", type="Device") # Same name on different devices should pass - name_hash = fnv1_hash_name("Temperature") config1 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1} validated1 = validator(config1) assert validated1 == config1 - assert ("device1", "sensor", name_hash) in CORE.unique_ids - metadata1 = CORE.unique_ids[("device1", "sensor", name_hash)] + assert ("device1", "sensor", fnv1_hash("temperature")) in CORE.unique_ids + metadata1 = CORE.unique_ids[("device1", "sensor", fnv1_hash("temperature"))] assert metadata1["device_id"] == "device1" config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device2} validated2 = validator(config2) assert validated2 == config2 - assert ("device2", "sensor", name_hash) in CORE.unique_ids - metadata2 = CORE.unique_ids[("device2", "sensor", name_hash)] + assert ("device2", "sensor", fnv1_hash("temperature")) in CORE.unique_ids + metadata2 = CORE.unique_ids[("device2", "sensor", fnv1_hash("temperature"))] assert metadata2["device_id"] == "device2" # Duplicate on same device should fail @@ -434,33 +638,6 @@ def test_entity_different_platforms_yaml_validation( assert result is not None -def test_object_id_conflict_mqtt_yaml_validation( - yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] -) -> None: - """Test that names sanitizing to the same object_id fail when mqtt is configured.""" - result = load_config_from_fixture( - yaml_file, "object_id_conflict_mqtt.yaml", FIXTURES_DIR - ) - assert result is None - - captured = capsys.readouterr() - assert ( - "mqtt builds default topics and discovery topics from the entity object_id" - in captured.out - ) - - -def test_object_id_conflict_without_mqtt_yaml_validation( - yaml_file: Callable[[str], str], -) -> None: - """Test that names sanitizing to the same object_id pass without mqtt/prometheus.""" - result = load_config_from_fixture( - yaml_file, "object_id_conflict_no_mqtt.yaml", FIXTURES_DIR - ) - # This should succeed - assert result is not None - - def test_entity_duplicate_validator_error_message() -> None: """Test that duplicate entity error messages include helpful metadata.""" # Create validator for sensor platform @@ -519,8 +696,7 @@ def test_entity_duplicate_validator_internal_entities() -> None: validated1 = validator(config1) assert validated1 == config1 # New format includes device_id (empty string for main device) - temperature_key = ("", "sensor", fnv1_hash_name("Temperature")) - assert temperature_key in CORE.unique_ids + assert ("", "sensor", fnv1_hash("temperature")) in CORE.unique_ids # Internal entity with same name should pass (not added to unique_ids) config2 = {CONF_NAME: "Temperature", CONF_INTERNAL: True} @@ -528,7 +704,9 @@ def test_entity_duplicate_validator_internal_entities() -> None: assert validated2 == config2 # Internal entity should not be added to unique_ids # Count how many times the key appears (should still be 1) - count = sum(1 for k in CORE.unique_ids if k == temperature_key) + count = sum( + 1 for k in CORE.unique_ids if k == ("", "sensor", fnv1_hash("temperature")) + ) assert count == 1 # Another internal entity with same name should also pass @@ -536,7 +714,9 @@ def test_entity_duplicate_validator_internal_entities() -> None: validated3 = validator(config3) assert validated3 == config3 # Still only one entry in unique_ids (from the non-internal entity) - count = sum(1 for k in CORE.unique_ids if k == temperature_key) + count = sum( + 1 for k in CORE.unique_ids if k == ("", "sensor", fnv1_hash("temperature")) + ) assert count == 1 # Non-internal entity with same name should fail @@ -564,148 +744,30 @@ def test_empty_or_null_device_id_on_entity() -> None: def test_entity_duplicate_validator_non_ascii_names() -> None: - """Test that distinct non-ASCII names no longer collide. - - These names used to be rejected because both sanitize to only underscores; - the entity key now hashes the raw name so they stay distinct. - """ + """Test that non-ASCII names show helpful error messages.""" # Create validator for binary_sensor platform validator = entity_duplicate_validator("binary_sensor") - # Both Russian sensors should pass even though they sanitize identically + # First Russian sensor should pass config1 = {CONF_NAME: "Датчик открытия основного крана"} validated1 = validator(config1) assert validated1 == config1 + # Second Russian sensor with different text but same ASCII conversion should fail config2 = {CONF_NAME: "Датчик закрытия основного крана"} - validated2 = validator(config2) - assert validated2 == config2 - - # An exact duplicate still fails - config3 = {CONF_NAME: "Датчик открытия основного крана"} - with pytest.raises( - Invalid, - match=r"Duplicate binary_sensor entity with name 'Датчик открытия основного крана' found", - ): - validator(config3) - - -def test_entity_duplicate_validator_hash_collision() -> None: - """Test that two different names with the same FNV-1 hash are rejected.""" - # Brute-forced FNV-1 32-bit collision pair; both hash to 0x0ee5ff7b - name_a = "Sensor m2CZ" - name_b = "Sensor qCaa" - assert name_a != name_b - assert fnv1_hash_name(name_a) == fnv1_hash_name(name_b) - - validator = entity_duplicate_validator("sensor") - - config1 = {CONF_NAME: name_a} - validated1 = validator(config1) - assert validated1 == config1 - - config2 = {CONF_NAME: name_b} with pytest.raises( Invalid, match=re.compile( - rf"Duplicate sensor entity with name '{name_b}' found.*" - rf"The names '{name_b}' and '{name_a}' produce the.*" - r"same entity key hash \(0x0ee5ff7b\).*" - r"To fix: Rename one of the entities", + r"Duplicate binary_sensor entity with name 'Датчик закрытия основного крана' found.*" + r"Original names: 'Датчик закрытия основного крана' and 'Датчик открытия основного крана'.*" + r"Both convert to ASCII ID: '_______________________________'.*" + r"To fix: Add unique ASCII characters \(e\.g\., '1', '2', or 'A', 'B'\)", re.DOTALL, ), ): validator(config2) -def test_object_id_conflicts_rejected_by_component_validator() -> None: - """Test that object_id conflicts pass entity validation but fail for mqtt/prometheus.""" - validator = entity_duplicate_validator("sensor") - - # Both names validate fine in general (distinct raw names, distinct keys) - validator({CONF_NAME: "Датчик открытия"}) - validator({CONF_NAME: "Датчик закрытия"}) - - # A component that addresses entities by object_id must reject the config - component_validator = validate_no_object_id_conflicts( - "mqtt builds default topics from the entity object_id" - ) - with pytest.raises( - Invalid, - match=re.compile( - r"mqtt builds default topics from the entity object_id.*" - r"sensor entities 'Датчик открытия', 'Датчик закрытия' " - r"share the object_id '_______________'.*" - r"To fix: Add unique ASCII characters", - re.DOTALL, - ), - ): - component_validator({}) - - -def test_object_id_conflicts_skipped_in_testing_mode() -> None: - """Test that testing_mode skips the conflict check, as used for grouped testing.""" - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Датчик открытия"}) - validator({CONF_NAME: "Датчик закрытия"}) - - component_validator = validate_no_object_id_conflicts( - "mqtt builds default topics from the entity object_id" - ) - CORE.testing_mode = True - try: - config: dict = {} - assert component_validator(config) is config - finally: - CORE.testing_mode = False - - -def test_object_id_conflicts_none_recorded() -> None: - """Test that distinct object_ids produce no conflicts.""" - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Temperature"}) - validator({CONF_NAME: "Humidity"}) - - component_validator = validate_no_object_id_conflicts( - "mqtt builds default topics from the entity object_id" - ) - config: dict = {} - assert component_validator(config) is config - - -def test_object_id_conflicts_device_scoped() -> None: - """Test that the object_id conflict check is scoped per device. - - Same-named entities on different sub-devices were accepted before entity keys - moved to raw names, so the check keeps that scope; conflicts within one device - are still reported with the device named in the message. - """ - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Temperature", CONF_DEVICE_ID: ID("device1", type="Device")}) - validator({CONF_NAME: "Temperature", CONF_DEVICE_ID: ID("device2", type="Device")}) - - component_validator = validate_no_object_id_conflicts( - "prometheus builds metric labels from the entity object_id" - ) - config: dict = {} - assert component_validator(config) is config - - # Two names sanitizing identically on the same sub-device still conflict - validator( - {CONF_NAME: "Датчик открытия", CONF_DEVICE_ID: ID("device1", type="Device")} - ) - validator( - {CONF_NAME: "Датчик закрытия", CONF_DEVICE_ID: ID("device1", type="Device")} - ) - with pytest.raises( - Invalid, - match=re.compile( - r"prometheus builds metric labels.*on device 'device1'", re.DOTALL - ), - ): - component_validator({}) - - def test_entity_duplicate_validator_same_name_no_enhanced_message() -> None: """Test that identical names don't show the enhanced message.""" # Create validator for sensor platform @@ -763,7 +825,7 @@ async def test_setup_entity_empty_name_with_device( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 @pytest.mark.asyncio @@ -792,7 +854,7 @@ async def test_setup_entity_empty_name_with_mac_suffix( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 @pytest.mark.asyncio @@ -822,7 +884,7 @@ async def test_setup_entity_empty_name_with_mac_suffix_no_friendly_name( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 @pytest.mark.asyncio @@ -853,7 +915,7 @@ async def test_setup_entity_empty_name_no_mac_suffix_no_friendly_name( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 def test_register_string_overflow() -> None: diff --git a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml b/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml deleted file mode 100644 index 4a6f56f473..0000000000 --- a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml +++ /dev/null @@ -1,22 +0,0 @@ -esphome: - name: test-object-id-conflict - -esp32: - board: esp32dev - -wifi: - ssid: MySSID - password: password1 - -mqtt: - broker: test.mosquitto.org - -sensor: - # Distinct raw names are fine in general, but both sanitize to the same - # object_id, which MQTT still uses to build default topics - should fail - - platform: template - name: "Датчик открытия" - lambda: return 21.0; - - platform: template - name: "Датчик закрытия" - lambda: return 22.0; diff --git a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml b/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml deleted file mode 100644 index c0fbd5cbba..0000000000 --- a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml +++ /dev/null @@ -1,15 +0,0 @@ -esphome: - name: test-object-id-ok - -esp32: - board: esp32dev - -sensor: - # Distinct raw names that sanitize to the same object_id are allowed when no - # component addresses entities by object_id (no mqtt or prometheus configured) - - platform: template - name: "Датчик открытия" - lambda: return 21.0; - - platform: template - name: "Датчик закрытия" - lambda: return 22.0; diff --git a/tests/unit_tests/test_preference_hash_stability.py b/tests/unit_tests/test_preference_hash_stability.py index d3e5fac36a..d8506afae7 100644 --- a/tests/unit_tests/test_preference_hash_stability.py +++ b/tests/unit_tests/test_preference_hash_stability.py @@ -5,11 +5,11 @@ users to lose stored preferences (calibration values, restore states, etc.) on firmware upgrades, or break entity state routing to API clients. Two algorithms are locked here (see https://github.com/esphome/backlog/issues/85): -1. `fnv1_hash_object_id(name)` - the LEGACY hash (snake_case + sanitize, then FNV-1). - Existing devices have preferences stored under keys derived from it; slot-based - backends (ESP8266, RP2040) keep using it, and key-lookup backends migrate FROM it. -2. `fnv1_hash_name(name)` - the entity key (FNV-1 over the raw UTF-8 name bytes). - Sent to API clients and used as the preference key base on key-lookup backends. +1. `fnv1_hash_object_id(name)` - the object_id hash (snake_case + sanitize, then FNV-1). + The entity key sent to API clients and the base of every stored preference key. +2. `fnv1_hash_name(name)` - FNV-1 over the raw UTF-8 name bytes. 2026.8 beta + firmware stored preferences under keys derived from it; a future key migration + must reconstruct those keys to recover that data. DO NOT CHANGE THE EXPECTED VALUES - if tests fail after modifying a hash algorithm, the change breaks backward compatibility and will cause data loss. @@ -124,8 +124,9 @@ def test_entity_object_id_hash_stability( """Verify fnv1_hash_object_id produces stable hashes for entity names. CRITICAL: These expected values MUST NOT CHANGE. Existing devices have - preferences stored under keys derived from this legacy hash; changing it - breaks the old-to-new key migration and loses stored preferences. + preferences stored under keys derived from this hash, and it is the entity + key sent to API clients; changing it loses stored preferences and breaks + entity state routing. """ actual = fnv1_hash_object_id(entity_name) assert actual == expected_object_id_hash, ( @@ -144,9 +145,8 @@ def compute_legacy_preference_key( ) -> int: """Compute the legacy preference key: (object_id_hash ^ device_id) ^ version. - This is the key existing devices have data stored under. Slot-based backends - (ESP8266, RP2040) still use it directly; key-lookup backends compute it as the - migration source in EntityBase::make_entity_preference_() (entity_base.cpp). + This is the key EntityBase::make_entity_preference_() (entity_base.cpp) + stores every entity preference under. """ object_id_hash = fnv1_hash_object_id(entity_name) preference_hash = object_id_hash ^ device_id @@ -179,8 +179,8 @@ def test_legacy_preference_key_computation( ) -> None: """Verify legacy preference key computation matches expected values. - This test ensures the formula doesn't change, which would break both slot-based - preference storage and the migration source keys on key-lookup backends. + This test ensures the formula doesn't change, which would lose stored + preferences on every platform. """ actual_key = compute_legacy_preference_key(entity_name, version, device_id) @@ -215,12 +215,12 @@ def test_legacy_preference_key_computation( ], ) def test_entity_key_hash_stability(entity_name: str, expected_key: int) -> None: - """Verify fnv1_hash_name produces stable entity keys. + """Verify fnv1_hash_name produces stable raw-name hashes. - CRITICAL: These expected values MUST NOT CHANGE. The entity key is sent to - API clients and is the new preference key base; changing the algorithm - would break state routing and lose stored preferences. - Must match C++ fnv1_hash_bytes() in esphome/core/helpers.h. + CRITICAL: These expected values MUST NOT CHANGE. 2026.8 beta firmware stored + preferences under keys derived from this hash; a future key migration must + reconstruct those keys, and changing the algorithm would strand that data. + Matched C++ fnv1_hash_bytes() (2026.8 beta), which the unrevert restores. """ actual = fnv1_hash_name(entity_name) assert actual == expected_key, ( From 990fc402fdf12fd71e0d327edc3045591617aa53 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:24:02 -0500 Subject: [PATCH 1458/1815] Bump bleak from 2.1.1 to 3.0.2 (#16246) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6bc8bdf74a..876b13793c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,7 +23,7 @@ pillow==12.3.0 resvg-py==0.3.4 freetype-py==2.5.1 jinja2==3.1.6 -bleak==2.1.1 +bleak==3.0.2 smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 From b05465145fb261eb3d142982fda2d4741fa49c92 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 21:14:30 -0500 Subject: [PATCH 1459/1815] [core] Add preference key stability integration test (#18364) --- .../fixtures/preference_key_stability.yaml | 35 ++++ tests/integration/host_prefs.py | 24 ++- .../test_preference_key_stability.py | 168 ++++++++++++++++++ 3 files changed, 220 insertions(+), 7 deletions(-) create mode 100644 tests/integration/fixtures/preference_key_stability.yaml create mode 100644 tests/integration/test_preference_key_stability.py diff --git a/tests/integration/fixtures/preference_key_stability.yaml b/tests/integration/fixtures/preference_key_stability.yaml new file mode 100644 index 0000000000..a74bb2c7f7 --- /dev/null +++ b/tests/integration/fixtures/preference_key_stability.yaml @@ -0,0 +1,35 @@ +esphome: + name: host-pref-key-stability + +host: +api: +logger: + +switch: + - platform: template + id: test_switch_restore + name: Test Switch + optimistic: true + restore_mode: RESTORE_DEFAULT_OFF + +number: + - platform: template + id: test_number_restore + name: Test Number + optimistic: true + restore_value: true + initial_value: 1.0 + min_value: 0 + max_value: 100 + step: 0.5 + +text: + - platform: template + id: test_text_restore + name: Test Text + mode: text + optimistic: true + restore_value: true + initial_value: fallback + min_length: 0 + max_length: 20 diff --git a/tests/integration/host_prefs.py b/tests/integration/host_prefs.py index f835bee3bc..c7f21d8a01 100644 --- a/tests/integration/host_prefs.py +++ b/tests/integration/host_prefs.py @@ -25,15 +25,25 @@ def clear_host_prefs(device_name: str) -> None: host_prefs_path(device_name).unlink(missing_ok=True) +def write_host_prefs(device_name: str, entries: dict[int, bytes]) -> Path: + """Write preference entries, replacing the file's contents. + + Returns the path that was written. + """ + payload = b"" + for key, data in entries.items(): + if len(data) > 255: + raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)") + payload += struct.pack(" Path: """Write a single preference entry, replacing the file's contents. Returns the path that was written. """ - if len(data) > 255: - raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)") - path = host_prefs_path(device_name) - path.parent.mkdir(parents=True, exist_ok=True) - payload = struct.pack(" stores a length-prefixed buffer of max_length + 1 bytes +TEXT_MAX_LENGTH = 20 + + +def text_pref_payload(value: str) -> bytes: + """Build the length-prefixed buffer TextSaver stores for a value.""" + data = value.encode("utf-8") + assert len(data) <= TEXT_MAX_LENGTH + return bytes([len(data)]) + data + b"\x00" * (TEXT_MAX_LENGTH - len(data)) + + +@pytest.mark.asyncio +async def test_preference_key_stability( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], +) -> None: + """Test that preferences stored by earlier firmware are restored.""" + port, port_socket = reserved_tcp_port + + assert SWITCH_KEY != SWITCH_BETA_KEY + assert NUMBER_KEY != NUMBER_BETA_KEY + assert TEXT_KEY != TEXT_BETA_KEY + + # Write and compile once + config_path = await write_yaml_config(yaml_config) + binary_path = await compile_esphome(config_path) + + # Release the reserved port so the binary can bind to it + port_socket.close() + + async def boot_and_get_initial_states() -> tuple[ + SwitchState, NumberState, TextState + ]: + """Boot the binary and return the restored entity states.""" + async with ( + run_binary_and_wait_for_port(binary_path, "127.0.0.1", port), + wait_and_connect_api_client(port=port) as client, + ): + device_info = await client.device_info() + assert device_info.name == DEVICE_NAME + + entities, _ = await client.list_entities_services() + switch_entity = require_entity( + entities, "test_switch", SwitchInfo, "Test Switch" + ) + number_entity = require_entity( + entities, "test_number", NumberInfo, "Test Number" + ) + text_entity = require_entity(entities, "test_text", TextInfo, "Test Text") + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states( + initial_state_helper.on_state_wrapper(lambda s: None) + ) + await initial_state_helper.wait_for_initial_states() + + switch_state = initial_state_helper.initial_states[switch_entity.key] + number_state = initial_state_helper.initial_states[number_entity.key] + text_state = initial_state_helper.initial_states[text_entity.key] + assert isinstance(switch_state, SwitchState) + assert isinstance(number_state, NumberState) + assert isinstance(text_state, TextState) + return switch_state, number_state, text_state + + try: + # --- Run 1: entries under the object_id-hash keys, exactly as any + # earlier firmware wrote them. The restored states prove the key + # scheme has not drifted. + write_host_prefs( + DEVICE_NAME, + { + SWITCH_KEY: b"\x01", # bool: switch was ON + NUMBER_KEY: struct.pack(" Date: Fri, 14 Aug 2026 00:22:22 -0500 Subject: [PATCH 1460/1815] [core] Restore cv.parse_esphome_version as a deprecated helper (#18366) --- esphome/config_validation.py | 4 ++++ esphome/util.py | 14 ++++++++++++++ tests/unit_tests/test_config_validation.py | 17 +++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 0eebf12e66..f455c7b8bf 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -99,6 +99,10 @@ from esphome.schema_extractors import ( schema_extractor_registry, schema_extractor_typed, ) + +# Deprecated re-export for external components; remove before 2027.2.0 +# pylint: disable-next=unused-import +from esphome.util import parse_esphome_version # noqa: F401 from esphome.voluptuous_schema import _Schema from esphome.yaml_util import SensitiveStr, make_data_base diff --git a/esphome/util.py b/esphome/util.py index 2fc34f3a69..b8ffa048ca 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -390,6 +390,20 @@ def is_dev_esphome_version(): return "dev" in const.__version__ +# Remove before 2027.2.0 +def parse_esphome_version() -> tuple[int, int, int]: + """Deprecated: use esphome.config_validation.require_esphome_version instead.""" + from esphome.core import Version + + _LOGGER.warning( + "parse_esphome_version() is deprecated. Use " + "cv.require_esphome_version to gate on a minimum version. " + "Removed in 2027.2.0" + ) + version = Version.parse(const.__version__) + return version.major, version.minor, version.patch + + # Custom OrderedDict with nicer repr method for debugging class OrderedDict(collections.OrderedDict): def __repr__(self): diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 7627ef9273..971c4e462d 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -2967,6 +2967,23 @@ def test_require_esphome_version_older_prerelease_fails() -> None: cv.require_esphome_version(2026, 8, 0)("test") +def test_parse_esphome_version_deprecated_shim( + caplog: pytest.LogCaptureFixture, +) -> None: + """The removed helper still works for external components and warns.""" + from esphome import const, util + + with ( + patch.object(const, "__version__", "2026.9.0-dev"), + caplog.at_level(logging.WARNING), + ): + assert cv.parse_esphome_version() == (2026, 9, 0) + assert cv.parse_esphome_version() < (9999, 0, 0) + assert "parse_esphome_version() is deprecated" in caplog.text + # Both historical import paths resolve to the same function + assert cv.parse_esphome_version is util.parse_esphome_version + + # --------------------------------------------------------------------------- # suppress_invalid / validate_source_shorthand / rename_key # --------------------------------------------------------------------------- From 617e2ec1e051f181ca7892966c2be34c46e2e806 Mon Sep 17 00:00:00 2001 From: Karl Beecken Date: Fri, 14 Aug 2026 07:23:34 +0200 Subject: [PATCH 1461/1815] [core] fix PYTHONPATH leak (#18360) --- esphome/espidf/toolchain.py | 2 ++ esphome/framework_helpers.py | 2 ++ tests/unit_tests/test_espidf_toolchain.py | 15 +++++++++++++++ tests/unit_tests/test_framework_helpers.py | 18 ++++++++++++++++++ 4 files changed, 37 insertions(+) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index e1688f4170..bb6452acf2 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -109,6 +109,8 @@ def _get_idf_env(version: str | None = None) -> dict[str, str]: env_cache = _cache().env if version not in env_cache: env_cache[version] = os.environ.copy() + # Do not leak PYTHONPATH into child env + env_cache[version].pop("PYTHONPATH", None) # Use provided IDF framework if available if "IDF_PATH" not in os.environ: diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 86d5e4eaea..b8a43220ff 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -155,6 +155,8 @@ def run_command( _LOGGER.debug("%s - running ...", cmd_str) run_env = os.environ.copy() + # Do not leak PYTHONPATH + run_env.pop("PYTHONPATH", None) if env: run_env.update(env) diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 56f358a24c..26d812af8b 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -265,6 +265,21 @@ def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None: assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep) +def test_get_idf_env_pops_inherited_pythonpath(setup_core: Path) -> None: + """A PYTHONPATH from the parent environment must not reach idf.py. + + It would override the IDF venv's isolation, shadowing its pinned + packages and failing idf.py's dependency check. + """ + toolchain._cache().env.clear() + with patch.dict( + os.environ, + {"IDF_PATH": str(setup_core), "PYTHONPATH": "/outside/site-packages"}, + ): + env = toolchain._get_idf_env(version="5.5.4") + assert "PYTHONPATH" not in env + + def test_get_cmake_output_without_build_dir(setup_core: Path) -> None: """A build dir that was never created raises EsphomeError. diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index 7451ee9b39..2022c15bfe 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -188,6 +188,24 @@ def test_run_command_passes_env(mock_subprocess_run: Mock) -> None: assert mock_subprocess_run.call_args[1]["env"]["MY_VAR"] == "42" +def test_run_command_pops_inherited_pythonpath(mock_subprocess_run: Mock) -> None: + """A PYTHONPATH from the parent environment must not leak into subprocesses.""" + mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="") + with patch.dict(os.environ, {"PYTHONPATH": "/outside/site-packages"}): + run_command(["cmd"]) + assert "PYTHONPATH" not in mock_subprocess_run.call_args[1]["env"] + + +def test_run_command_env_pythonpath_preferred_over_pop( + mock_subprocess_run: Mock, +) -> None: + """A PYTHONPATH set explicitly via ``env`` is passed through.""" + mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="") + with patch.dict(os.environ, {"PYTHONPATH": "/outside/site-packages"}): + run_command(["cmd"], env={"PYTHONPATH": "/idf/tools"}) + assert mock_subprocess_run.call_args[1]["env"]["PYTHONPATH"] == "/idf/tools" + + def test_run_command_passes_cwd(mock_subprocess_run: Mock, tmp_path: Path) -> None: mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="") run_command(["cmd"], cwd=str(tmp_path)) From d72bab79d7363c81d849078556f1de71953cf60c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 21:09:01 -0500 Subject: [PATCH 1462/1815] [web_server_base] Stop deleting the web server on captive portal teardown (#18324) --- .../web_server/ota/ota_web_server.cpp | 2 +- .../web_server_base/web_server_base.h | 20 +++++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 9812714ec0..95763e2daf 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -249,7 +249,7 @@ void WebServerOTAComponent::setup() { return; } - // AsyncWebServer takes ownership of the handler and will delete it when the server is destroyed + // The handler lives for the life of the process; WebServerBase never destroys its server base->add_handler(new OTARequestHandler(this)); // NOLINT } diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index c647a13b50..94579de70f 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -112,9 +112,18 @@ class AuthMiddlewareHandler : public MiddlewareHandler { class WebServerBase final { public: + // The AsyncWebServer is created once and intentionally never deleted: on Arduino + // platforms ESPAsyncWebServer owns its registered handlers, so destroying it would + // also destroy live components (e.g. the captive portal) out from under us. + // init()/deinit() refcount users and start/stop the listener; handlers are + // registered once at creation and survive listener restarts. void init() { - if (this->initialized_) { - this->initialized_++; + this->initialized_++; + if (this->server_ != nullptr) { + if (this->initialized_ == 1) { + // Restart the listener after a previous deinit() + this->server_->begin(); + } return; } this->server_ = new AsyncWebServer(this->port_); @@ -126,14 +135,13 @@ class WebServerBase final { for (auto *handler : this->handlers_) this->server_->addHandler(handler); - - this->initialized_++; } void deinit() { + if (this->initialized_ == 0) + return; // unbalanced deinit() this->initialized_--; if (this->initialized_ == 0) { - delete this->server_; - this->server_ = nullptr; + this->server_->end(); } } AsyncWebServer *get_server() const { return this->server_; } From 7c07fb48c5cbf1c5ca7c8ba04e033d2d0eb14568 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 13:22:48 -0500 Subject: [PATCH 1463/1815] [esp32_ble_tracker] Fix missed BLE advertisements with WiFi on ESP-IDF 5.5.5 (#18356) --- .../components/ble_device_base/__init__.py | 20 ++- .../components/esp32_ble_tracker/__init__.py | 71 +++++++++- .../test_scan_parameter_validation.py | 7 +- .../esp32_ble_tracker/__init__.py | 0 .../test_scan_window_default.py | 122 ++++++++++++++++++ 5 files changed, 211 insertions(+), 9 deletions(-) create mode 100644 tests/component_tests/esp32_ble_tracker/__init__.py create mode 100644 tests/component_tests/esp32_ble_tracker/test_scan_window_default.py diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index 4da7d48882..15a8b08139 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -37,7 +37,7 @@ from esphome.const import ( CONF_INTERVAL, KEY_TARGET_PLATFORM, ) -from esphome.core import CORE, ID, KEY_CORE +from esphome.core import CORE, ID, KEY_CORE, TimePeriod from esphome.types import ConfigType CODEOWNERS = ["@Bl00d-B0b"] @@ -243,19 +243,27 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType: return config +# The historical scan window default shared by the trackers that do not pin +# their own; also the fallback for esp32's conditional default. +DEFAULT_SCAN_WINDOW = "30ms" + + def scan_parameters_schema( interval_default: str, *, - window_default: str = "30ms", + window_default: str | Callable[[], TimePeriod] = DEFAULT_SCAN_WINDOW, ) -> cv.All: """Build the scan_parameters value schema shared by all BLE trackers. interval_default and window_default are per chip (e.g. esp32 320/30 ms, bk72xx/rp2 100/30 ms — the reference scan rates of the respective stacks; - LN882H's SDK recommends 100/50 ms). The `active` option (default on) is - unconditional: active scanning is part of the tracker contract — every - current proxy client assumes it, so a passive-only tracker must not share - this schema. + LN882H's SDK recommends 100/50 ms). window_default may also be a zero-arg + callable evaluated per validation when the user omits the key (esp32 uses + this to record that the window was defaulted, so a later validation step + can adjust it once sibling keys are resolved). The `active` option + (default on) is unconditional: active scanning is part of the tracker + contract — every current proxy client assumes it, so a passive-only + tracker must not share this schema. """ schema = { cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds, diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 634b8c3bef..28c8c7fcf1 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -1,5 +1,7 @@ from __future__ import annotations +import copy +from dataclasses import dataclass import logging from esphome import automation @@ -8,6 +10,7 @@ from esphome.components import ble_device_base, esp32_ble, ota from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW from esphome.components.esp32 import ( add_idf_sdkconfig_option, + idf_version, request_bluetooth, request_software_coexistence, ) @@ -35,10 +38,12 @@ from esphome.const import ( CONF_SERVICE_UUID, CONF_TRIGGER_ID, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, TimePeriod, coroutine_with_priority from esphome.enum import StrEnum from esphome.types import ConfigType +DOMAIN = "esp32_ble_tracker" + AUTO_LOAD = ["ble_device_base", "esp32_ble"] DEPENDENCIES = ["esp32"] CODEOWNERS = ["@bdraco"] @@ -125,10 +130,71 @@ def validate_max_connections_deprecated(config: ConfigType) -> ConfigType: return config +# ESP-IDF 5.5.5 fixed a coexistence bug on the ESP32 where BLE scans ran far +# longer than the configured window (espressif/esp-idf#18931). Before the fix, +# the default 30 ms window in a 320 ms interval effectively scanned at a much +# higher duty cycle than requested; with the fix, that same default only +# listens 9.4 % of the time and misses most advertisements when wifi shares +# the radio. Espressif recommends setting the window equal to the interval in +# that case: the coexistence arbiter still shares the radio with wifi, and +# BLE uses the airtime wifi does not claim. +IDF_SCAN_WINDOW_FIX_VERSION = cv.Version(5, 5, 5) + + +@dataclass +class TrackerData: + """Per-run validation state, namespaced under DOMAIN in CORE.data.""" + + scan_window_defaulted: bool = False + + +def _get_data() -> TrackerData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = TrackerData() + return CORE.data[DOMAIN] + + +def _scan_window_default() -> TimePeriod: + """Schema default for the scan window. + + Records that the user did not set a window, so _raise_defaulted_scan_window + can tell a defaulted 30 ms from an explicit one; the raise itself must wait + for the outer schema because it depends on software_coexistence, a sibling + key not yet resolved here. + """ + _get_data().scan_window_defaulted = True + return cv.positive_time_period(ble_device_base.DEFAULT_SCAN_WINDOW) + + +def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType: + """Raise a defaulted scan window to the interval where that is safe. + + Only when the coexistence arbiter is compiled in (software_coexistence, + present iff wifi is configured and not disabled by the user) and the IDF + honors the window strictly (>= 5.5.5); without the arbiter a full-duty + scan would starve wifi outright, and a user-set window is never touched. + Raising to the interval cannot invalidate the already-validated + parameters, so no re-validation is needed. + """ + if ( + _get_data().scan_window_defaulted + and config.get(CONF_SOFTWARE_COEXISTENCE) + and idf_version() >= IDF_SCAN_WINDOW_FIX_VERSION + ): + params = config[CONF_SCAN_PARAMETERS] + # Copy so the config dump shows a plain value instead of a YAML + # anchor/alias pair pointing at the interval. + params[CONF_WINDOW] = copy.copy(params[CONF_INTERVAL]) + return config + + # 320 ms is the ESP-IDF reference scan interval; the shared schema also # tightens validation to the controller's 2.5 ms .. 10240 ms range and rejects # window/interval pairs that collapse to the same 0.625 ms unit count. -SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("320ms") +# The window default is conditional (see _scan_window_default above). +SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema( + "320ms", window_default=_scan_window_default +) # Codegen helpers are owned by ble_device_base; kept under the historical names # here for the components that import them from this module. @@ -183,6 +249,7 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), validate_max_connections_deprecated, + _raise_defaulted_scan_window, ) diff --git a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py index 2549125a43..3774d990d3 100644 --- a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py +++ b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py @@ -57,7 +57,12 @@ def test_bk72xx_defaults_are_valid() -> None: def test_esp32_defaults_are_valid() -> None: - """esp32 pins the ESP-IDF reference rate and exposes active (default on).""" + """esp32 pins the ESP-IDF reference rate and exposes active (default on). + + Without wifi loaded, the conditional window default falls back to the + historical 30 ms; the wifi-aware resolution is covered by the + esp32_ble_tracker component tests. + """ config = ESP32_SCHEMA({}) assert to_ble_units(config["interval"]) == 512 assert to_ble_units(config["window"]) == 48 diff --git a/tests/component_tests/esp32_ble_tracker/__init__.py b/tests/component_tests/esp32_ble_tracker/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py b/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py new file mode 100644 index 0000000000..8a25f488fa --- /dev/null +++ b/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py @@ -0,0 +1,122 @@ +"""Tests for the esp32_ble_tracker conditional scan window default. + +The scan window default depends on wifi coexistence and the IDF version: +IDF 5.5.5 fixed a coexistence bug where BLE scans ran far longer than the +configured window (espressif/esp-idf#18931), so on fixed versions the +historical 30 ms default would only listen 9.4 % of the time and miss most +advertisements. With the coexistence arbiter compiled in on a fixed IDF, the +window instead defaults to the interval, as Espressif recommends; without the +arbiter a full-duty scan would starve wifi, so the 30 ms default is kept. +""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest + +from esphome import config_validation as cv +from esphome.components.ble_device_base import to_ble_units +from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW +from esphome.components.esp32 import KEY_IDF_VERSION +from esphome.components.esp32_ble_tracker import ( + CONF_SOFTWARE_COEXISTENCE, + CONFIG_SCHEMA, +) +from esphome.const import CONF_INTERVAL, PlatformFramework +from esphome.core import CORE +from esphome.types import ConfigType + +from ..types import SetCoreConfigCallable + + +@pytest.fixture +def stage_esp32( + set_core_config: SetCoreConfigCallable, +) -> Callable[..., None]: + """Stage an esp32 build with a given IDF version and wifi presence.""" + + def stage(idf: str, *, wifi: bool) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)}, + ) + if wifi: + # Makes cv.OnlyWith default software_coexistence to True, exactly + # as a real config with wifi: does. + CORE.loaded_integrations.add("wifi") + + return stage + + +def _scan_params(config: ConfigType) -> ConfigType: + return CONFIG_SCHEMA(config)[CONF_SCAN_PARAMETERS] + + +@pytest.mark.parametrize( + ("idf", "config", "expected_units"), + [ + ("5.5.5", {}, 512), # first fixed version, default 320 ms interval + ("6.0.1", {}, 512), # any newer version behaves the same + # Follows a user-set interval. + ("5.5.5", {"scan_parameters": {"interval": "1s"}}, 1600), + ], +) +def test_wifi_on_fixed_idf_defaults_window_to_interval( + stage_esp32: Callable[..., None], + idf: str, + config: ConfigType, + expected_units: int, +) -> None: + """With wifi coexistence on a fixed IDF, the window defaults to the interval.""" + stage_esp32(idf, wifi=True) + params = _scan_params(config) + assert params[CONF_WINDOW] == params[CONF_INTERVAL] + assert to_ble_units(params[CONF_WINDOW]) == expected_units + + +@pytest.mark.parametrize( + ("idf", "wifi", "config"), + [ + # Buggy IDF over-scans anyway; keep the 30 ms default. + ("5.5.4", True, {}), + # No wifi (e.g. ethernet) means no radio contention. + ("5.5.5", False, {}), + # Coexistence disabled: no arbiter, so a full-duty scan would starve + # wifi outright. + ("5.5.5", True, {CONF_SOFTWARE_COEXISTENCE: False}), + ], +) +def test_30ms_default_kept( + stage_esp32: Callable[..., None], + idf: str, + wifi: bool, + config: ConfigType, +) -> None: + stage_esp32(idf, wifi=wifi) + assert to_ble_units(_scan_params(config)[CONF_WINDOW]) == 48 + + +@pytest.mark.parametrize("window", ["60ms", "30ms"]) +def test_explicit_window_is_never_touched( + stage_esp32: Callable[..., None], window: str +) -> None: + """A user-set window wins over the conditional default. + + The explicit 30 ms case matters: it is indistinguishable from the + defaulted value by inspection, so the defaulted flag must separate them. + """ + stage_esp32("5.5.5", wifi=True) + params = _scan_params({"scan_parameters": {"window": window}}) + assert to_ble_units(params[CONF_WINDOW]) == to_ble_units( + cv.positive_time_period(window) + ) + + +def test_short_interval_without_window_still_rejected( + stage_esp32: Callable[..., None], +) -> None: + """The provisional 30 ms default validates against the interval as before.""" + stage_esp32("5.5.5", wifi=True) + with pytest.raises(cv.Invalid, match="needs to be smaller than scan interval"): + _scan_params({"scan_parameters": {"interval": "20ms"}}) From 236ff33a09e4865c4ee88a0aae711d71955640f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 13:27:01 -0500 Subject: [PATCH 1464/1815] [esp32_ble] Silence spurious warnings for local key GAP events (#18359) --- esphome/components/esp32_ble/ble.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 16501ef3b2..e2d79173ff 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -648,6 +648,8 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa case ESP_GAP_BLE_SET_PKT_LENGTH_COMPLETE_EVT: case ESP_GAP_BLE_PHY_UPDATE_COMPLETE_EVT: // BLE 5.0 PHY update complete case ESP_GAP_BLE_CHANNEL_SELECT_ALGORITHM_EVT: // BLE 5.0 channel selection algorithm + case ESP_GAP_BLE_LOCAL_IR_EVT: // Local identity root key generated at security init + case ESP_GAP_BLE_LOCAL_ER_EVT: // Local encryption root key generated at security init return; default: From 1c3a67b5e815617abf419721525c182d302931aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 13:30:53 -0500 Subject: [PATCH 1465/1815] [wifi] Fix ESP8266 crash in cnx_node_search when lwIP transmits after disconnect (#18333) --- .../wifi/wifi_component_esp8266.cpp | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 719a276bf9..acaa94b13c 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -136,10 +136,21 @@ bool WiFiComponent::wifi_apply_power_save_() { https://github.com/d-a-v/Arduino/blob/0e7d21e17144cfc5f53c016191daca8723e89ee8/libraries/ESP8266WiFi/src/ESP8266WiFiSTA.cpp#L251 */ #undef netif_set_addr // need to call lwIP-v1.4 netif_set_addr() +#undef netif_set_down // need to call lwIP-v1.4 netif_set_down() extern "C" { struct netif *eagle_lwip_getif(int netif_index); void netif_set_addr(struct netif *netif, const ip4_addr_t *ip, const ip4_addr_t *netmask, const ip4_addr_t *gw); +void netif_set_down(struct netif *netif); }; + +// The SDK can free its WiFi connection node before taking the STA netif down, letting lwIP +// timers (e.g. IGMP reports armed by mDNS) transmit into the dead driver and crash in +// cnx_node_search; taking the netif down first makes the glue drop such frames (#18308). +static void sta_netif_down() { + struct netif *iface = eagle_lwip_getif(STATION_IF); + if (iface != nullptr) + netif_set_down(iface); +} #endif bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { @@ -523,6 +534,9 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { global_wifi_component->sta_state_ = static_cast(ESP8266WiFiSTAState::ERROR_FAILED); } global_wifi_component->error_from_callback_ = true; +#if LWIP_VERSION_MAJOR != 1 + sta_netif_down(); +#endif #ifdef USE_WIFI_CONNECT_STATE_LISTENERS global_wifi_component->pending_.disconnect = true; #endif @@ -536,6 +550,9 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { // https://lbsfilm.at/blog/wpa2-authenticationmode-downgrade-in-espressif-microprocessors if (it.old_mode != AUTH_OPEN && it.new_mode == AUTH_OPEN) { ESP_LOGW(TAG, "Potential Authmode downgrade detected, disconnecting"); +#if LWIP_VERSION_MAJOR != 1 + sta_netif_down(); +#endif wifi_station_disconnect(); global_wifi_component->error_from_callback_ = true; } @@ -719,8 +736,12 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { bool WiFiComponent::wifi_disconnect_() { bool ret = true; // Only call disconnect if interface is up - if (wifi_get_opmode() & WIFI_STA) + if (wifi_get_opmode() & WIFI_STA) { +#if LWIP_VERSION_MAJOR != 1 + sta_netif_down(); +#endif ret = wifi_station_disconnect(); + } station_config conf{}; memset(&conf, 0, sizeof(conf)); ETS_UART_INTR_DISABLE(); From 4f3153375a7acb307de0ce6deef708975fff58cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 13:31:06 -0500 Subject: [PATCH 1466/1815] [ota] Retry uploads that fail from network errors (#18332) --- esphome/espota2.py | 158 ++++++++++--- tests/unit_tests/test_espota2.py | 382 +++++++++++++++++++++++++++++-- 2 files changed, 493 insertions(+), 47 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index fa15c1dda2..61e897f601 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Callable +import contextlib import gzip import hashlib import io @@ -8,7 +9,6 @@ import logging from pathlib import Path import secrets import socket -import sys import time from typing import Any @@ -76,6 +76,14 @@ _SUPPORTED_OTA_TYPES: frozenset[int] = frozenset( UPLOAD_BLOCK_SIZE = 8192 UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 +# Flaky Wi-Fi links often drop the first OTA attempt, and the device may need time +# to clean up a half-open connection (its handshake watchdog runs at 20s) before it +# accepts a new one, so wait between attempts instead of failing the upload outright. +# Every resolved address is tried once, and this many extra attempts are shared +# across the addresses on top of that. +EXTRA_UPLOAD_ATTEMPTS = 2 +UPLOAD_RETRY_DELAY = 5.0 + _LOGGER = logging.getLogger(__name__) # Authentication method lookup table: response -> (hash_func, nonce_size, name) @@ -171,6 +179,23 @@ class OTAError(EsphomeError): pass +class OTANetworkError(OTAError): + """Network-level OTA failure (timeout, reset, closed connection); retrying may succeed.""" + + +def _committed_error(err: OTANetworkError) -> OTAError: + """Wrap a network failure that happened once the device had the full image. + + Past that point the device commits and reboots on its own, so the failure + must not be retried; a re-upload could flash a device that already updated. + """ + return OTAError( + f"{err} (the device may have already committed the update and " + f"be rebooting; check whether it comes back with the new " + f"firmware before uploading again)" + ) + + def recv_decode( sock: socket.socket, amount: int, decode: bool = True ) -> bytes | list[int]: @@ -209,19 +234,22 @@ def receive_exactly( try: data += recv_decode(sock, 1, decode=decode) # type: ignore[operator] except OSError as err: - raise OTAError(f"receiving {msg} response: {err}") from err + raise OTANetworkError(f"receiving {msg} response: {err}") from err try: check_error(data, expect) except OTAError as err: sock.close() - raise OTAError(f"receiving {msg}: {err}") from err + # type(err) preserves OTANetworkError vs OTAError so callers can tell + # retryable network failures from device-reported errors; subclasses + # must accept a single message argument + raise type(err)(f"receiving {msg}: {err}") from err while len(data) < amount: try: data += recv_decode(sock, amount - len(data), decode=decode) # type: ignore[operator] except OSError as err: - raise OTAError(f"receiving {msg}: {err}") from err + raise OTANetworkError(f"receiving {msg}: {err}") from err return data @@ -237,7 +265,7 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None # accept-any-response reads (e.g. feature negotiation, auth nonces) would be # silently passed through and surface later as cryptic decode/timeout failures. if not data: - raise OTAError( + raise OTANetworkError( "Device closed connection without responding. " "This may indicate the device ran out of memory, " "a network issue, or the connection was interrupted." @@ -274,7 +302,7 @@ def send_check( sock.sendall(data) except OSError as err: - raise OTAError(f"sending {msg}: {err}") from err + raise OTANetworkError(f"sending {msg}: {err}") from err def perform_ota( @@ -306,7 +334,7 @@ def perform_ota( send_check(sock, MAGIC_BYTES, "magic bytes") _, version = receive_exactly(sock, 2, "version", RESPONSE_OK) - _LOGGER.debug("Device support OTA version: %s", version) + _LOGGER.info("Connection established; device supports OTA version %s", version) supported_versions = (OTA_VERSION_1_0, OTA_VERSION_2_0) if version not in supported_versions: raise OTAError( @@ -417,6 +445,8 @@ def perform_ota( hash_func, nonce_size, hash_name = _AUTH_METHODS[auth] perform_auth(sock, password, hash_func, nonce_size, hash_name) + _LOGGER.info("Handshake complete") + # Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures sock.settimeout(90.0) @@ -449,21 +479,43 @@ def perform_ota( offset = 0 progress = ProgressBar("Uploading") - while True: - chunk = upload_contents[offset : offset + UPLOAD_BLOCK_SIZE] - if not chunk: - break - offset += len(chunk) + try: + while True: + chunk = upload_contents[offset : offset + UPLOAD_BLOCK_SIZE] + if not chunk: + break + offset += len(chunk) + + try: + sock.sendall(chunk) + except OSError as err: + # A send failure can hide an error byte the device reported + # just before dropping the connection; surface that as the + # real, non-retryable cause when it is available + try: + sock.settimeout(1.0) + check_error(recv_decode(sock, 1), None) + except (OSError, OTANetworkError) as probe_err: + _LOGGER.debug( + "No device error behind the send failure: %s", probe_err + ) + raise OTANetworkError(f"sending data: {err}") from err - try: - sock.sendall(chunk) if version >= OTA_VERSION_2_0: - receive_exactly(sock, 1, "chunk result", RESPONSE_CHUNK_OK) - except OSError as err: - sys.stderr.write("\n") - raise OTAError(f"sending data: {err}") from err + try: + receive_exactly(sock, 1, "chunk result", RESPONSE_CHUNK_OK) + except OTANetworkError as err: + if offset < upload_size: + raise + # The device already had the complete image when this ack + # was lost, so it may be committing; do not retry + raise _committed_error(err) from err - progress.update(offset / upload_size) + progress.update(offset / upload_size) + except OTAError: + # Terminate the progress bar line before the error is logged + progress.done() + raise progress.done() # Enable nodelay for last checks @@ -472,11 +524,25 @@ def perform_ota( _LOGGER.info("Upload took %.2f seconds, waiting for result...", duration) - receive_exactly(sock, 1, "update receive result", RESPONSE_RECEIVE_OK) - receive_exactly(sock, 1, "update end result", RESPONSE_UPDATE_END_OK) - send_check(sock, RESPONSE_OK, "end acknowledgement") + # Once the device has the complete image it commits the update and + # reboots on its own; the exact commit point is not observable from + # here, so treat everything past the data phase as non-retryable. A + # re-upload could flash a device that already updated successfully. + try: + receive_exactly(sock, 1, "update receive result", RESPONSE_RECEIVE_OK) + receive_exactly(sock, 1, "update end result", RESPONSE_UPDATE_END_OK) + except OTANetworkError as err: + raise _committed_error(err) from err - _LOGGER.info("OTA successful") + try: + send_check(sock, RESPONSE_OK, "end acknowledgement") + except OTANetworkError as err: + # The device treats a missing end acknowledgement as non-fatal and is + # already rebooting into the new firmware, so the update succeeded + _LOGGER.warning("Failed sending end acknowledgement: %s", err) + _LOGGER.info("OTA successful (end acknowledgement not delivered)") + else: + _LOGGER.info("OTA successful") # Do not connect logs until it is fully on time.sleep(1) @@ -510,8 +576,33 @@ def run_ota_impl_( ) raise OTAError(err) from err - for r in res: - af, socktype, _, _, sa = r + if not res: + _LOGGER.error("No addresses to connect to for %s", remote_host) + return 1, None + + # Every address is tried at least once and EXTRA_UPLOAD_ATTEMPTS retries + # are shared across the addresses, cycling through them. Wait before an + # attempt when the previous one actually reached the device, or when + # revisiting an address, so a flaky link can recover and the device can + # clean up a half-open connection (its handshake watchdog runs at 20s); + # moving on to the next address family stays immediate. Known limitation: + # a silent mid-transfer drop with no reset can wedge the device until its + # 90s data timeout, which outlasts this budget; the retries target the + # common failures where the device resets or closes the link promptly. + total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS + last_error = "" + reached_device = False + for attempt in range(total_attempts): + af, socktype, _, _, sa = res[attempt % len(res)] + if reached_device or attempt >= len(res): + _LOGGER.info( + "Retrying in %.0f seconds (attempt %d of %d)...", + UPLOAD_RETRY_DELAY, + attempt + 1, + total_attempts, + ) + time.sleep(UPLOAD_RETRY_DELAY) + reached_device = False _LOGGER.info("Connecting to %s port %s...", sa[0], sa[1]) sock = socket.socket(af, socktype) sock.settimeout(20.0) @@ -519,23 +610,30 @@ def run_ota_impl_( sock.connect(sa) except OSError as err: sock.close() - _LOGGER.error("Connecting to %s port %s failed: %s", sa[0], sa[1], err) + _LOGGER.warning("Connecting to %s port %s failed: %s", sa[0], sa[1], err) + last_error = f"connecting to {sa[0]} failed: {err}" continue _LOGGER.info("Connected to %s", sa[0]) - with Path(filename).open("rb") as file_handle: + reached_device = True + with contextlib.closing(sock), Path(filename).open("rb") as file_handle: try: perform_ota(sock, password, file_handle, filename, ota_type) + except OTANetworkError as err: + # Transient network failure; retry + last_error = str(err) + _LOGGER.warning("%s", last_error) + continue except OTAError as err: + # Device-reported error (wrong password, wrong flash size, ...); + # retrying cannot succeed, so fail immediately _LOGGER.error(str(err)) return 1, None - finally: - sock.close() # Successfully uploaded to sa[0] return 0, sa[0] - _LOGGER.error("Connection failed.") + _LOGGER.error("Upload failed after %d attempts: %s", total_attempts, last_error) return 1, None diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 9413fbcf29..db4a4b1117 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -44,13 +44,17 @@ def mock_file() -> io.BytesIO: @pytest.fixture -def mock_time() -> Generator[None]: +def mock_sleep() -> Generator[Mock]: + """Mock time.sleep so delays don't slow down tests.""" + with patch("time.sleep") as mock: + yield mock + + +@pytest.fixture +def mock_time(mock_sleep: Mock) -> Generator[None]: """Mock time-related functions for consistent testing.""" # Provide enough values for multiple calls (tests may call perform_ota multiple times) - with ( - patch("time.sleep"), - patch("time.perf_counter", side_effect=[0, 1, 0, 1, 0, 1]), - ): + with patch("time.perf_counter", side_effect=[0, 1, 0, 1, 0, 1]): yield @@ -79,6 +83,28 @@ def mock_resolve_ip() -> Generator[Mock]: yield mock +DUAL_STACK_SA6 = ("2001:db8::1", 3232, 0, 0) +DUAL_STACK_SA4 = ("192.168.1.100", 3232) + + +@pytest.fixture +def mock_resolve_ip_dual(mock_resolve_ip: Mock) -> Mock: + """Make resolve_ip_address return an IPv6 and an IPv4 address.""" + mock_resolve_ip.return_value = [ + (socket.AF_INET6, socket.SOCK_STREAM, 0, "", DUAL_STACK_SA6), + (socket.AF_INET, socket.SOCK_STREAM, 0, "", DUAL_STACK_SA4), + ] + return mock_resolve_ip + + +@pytest.fixture +def firmware_file(tmp_path: Path) -> Path: + """Create a firmware file on disk for run_ota_impl_ tests.""" + firmware = tmp_path / "firmware.bin" + firmware.write_bytes(b"firmware content") + return firmware + + @pytest.fixture def mock_perform_ota() -> Generator[Mock]: """Mock perform_ota function for testing.""" @@ -137,9 +163,11 @@ def test_receive_exactly_with_error_response(mock_socket: Mock) -> None: with pytest.raises( espota2.OTAError, match="receiving auth:.*Authentication invalid" - ): + ) as exc_info: espota2.receive_exactly(mock_socket, 1, "auth", [espota2.RESPONSE_OK]) + # Device-reported errors must stay plain OTAError, not the retryable kind + assert not isinstance(exc_info.value, espota2.OTANetworkError) mock_socket.close.assert_called_once() @@ -147,10 +175,30 @@ def test_receive_exactly_socket_error(mock_socket: Mock) -> None: """Test receive_exactly handles socket errors.""" mock_socket.recv.side_effect = OSError("Connection reset") - with pytest.raises(espota2.OTAError, match="receiving test response"): + with pytest.raises(espota2.OTANetworkError, match="receiving test response"): espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK) +def test_receive_exactly_mid_read_socket_error(mock_socket: Mock) -> None: + """Test receive_exactly handles socket errors after the first byte.""" + mock_socket.recv.side_effect = [b"\x00", OSError("Connection reset")] + + with pytest.raises(espota2.OTANetworkError, match="receiving test:"): + espota2.receive_exactly(mock_socket, 3, "test", espota2.RESPONSE_OK) + + +def test_receive_exactly_closed_connection_is_network_error(mock_socket: Mock) -> None: + """Test receive_exactly raises OTANetworkError when the device closes the connection.""" + mock_socket.recv.return_value = b"" + + with pytest.raises( + espota2.OTANetworkError, match="Device closed connection without responding" + ): + espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK) + + mock_socket.close.assert_called_once() + + @pytest.mark.parametrize( ("error_code", "expected_msg"), [ @@ -227,15 +275,15 @@ def test_check_error_unexpected_response() -> None: def test_check_error_empty_data() -> None: - """Test check_error raises error when device closes connection without responding.""" + """Test check_error raises the retryable OTANetworkError when the device closes the connection.""" with pytest.raises( - espota2.OTAError, match="Device closed connection without responding" + espota2.OTANetworkError, match="Device closed connection without responding" ): espota2.check_error([], [espota2.RESPONSE_OK]) # Also test with empty bytes with pytest.raises( - espota2.OTAError, match="Device closed connection without responding" + espota2.OTANetworkError, match="Device closed connection without responding" ): espota2.check_error(b"", [espota2.RESPONSE_OK]) @@ -530,6 +578,144 @@ def test_perform_ota_upload_error(mock_socket: Mock, mock_file: io.BytesIO) -> N espota2.perform_ota(mock_socket, None, mock_file, "test.bin") +def _no_auth_handshake(version: int) -> list[bytes]: + """Recv responses for a handshake without auth, up to the MD5 check.""" + return [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([version]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + ] + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_chunk_send_error(mock_socket: Mock, mock_file: io.BytesIO) -> None: + """Test OTA raises the retryable OTANetworkError when sending a chunk fails.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_2_0), + OSError("Connection reset"), # Probe for a pending error byte fails too + ] + # Sends before the data phase: magic bytes, features, binary size, MD5; + # fail on the fifth sendall, the first firmware chunk + mock_socket.sendall.side_effect = [None] * 4 + [OSError("Broken pipe")] + + with pytest.raises(espota2.OTANetworkError, match="sending data:"): + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_chunk_send_error_surfaces_device_error( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a device error byte pending behind a send failure becomes the cause.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_ERROR_WRITING_FLASH]), # Reason the device closed + ] + mock_socket.sendall.side_effect = [None] * 4 + [OSError("Broken pipe")] + + with pytest.raises( + espota2.OTAError, match="Writing OTA data to flash memory failed" + ) as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # The device-reported error is not retryable + assert not isinstance(exc.value, espota2.OTANetworkError) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_final_chunk_ack_failure_not_retryable( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a lost ack for the final chunk is not retried.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_2_0), + OSError("Connection reset"), # Ack for the only (final) chunk is lost + ] + + with pytest.raises(espota2.OTAError, match="receiving chunk result") as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # The device already had the whole image, so it may be committing + assert not isinstance(exc.value, espota2.OTANetworkError) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_intermediate_chunk_ack_failure_retryable( + mock_socket: Mock, +) -> None: + """Test a lost ack for a non-final chunk stays retryable.""" + # Two chunks: the firmware is larger than one upload block + big_file = io.BytesIO(b"x" * (espota2.UPLOAD_BLOCK_SIZE + 1)) + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_2_0), + OSError("Connection reset"), # Ack for the first of two chunks is lost + ] + + with pytest.raises(espota2.OTANetworkError, match="receiving chunk result"): + espota2.perform_ota(mock_socket, None, big_file, "test.bin") + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_post_commit_failure_not_retryable( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a network failure after the device committed is a plain OTAError.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything + OSError("Connection reset"), # Connection lost waiting for end result + ] + + with pytest.raises(espota2.OTAError, match="receiving update end result") as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # Must not be the retryable kind; the device is already rebooting + assert not isinstance(exc.value, espota2.OTANetworkError) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_md5_mismatch_not_marked_committed( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test an MD5 mismatch keeps its own message and stays non-retryable.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything + bytes([espota2.RESPONSE_ERROR_MD5_MISMATCH]), # Device aborted the update + ] + + with pytest.raises(espota2.OTAError, match="MD5 code mismatch") as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # The device aborted without committing, so the message must not claim + # the update may have been installed, and the error must not be retried + assert not isinstance(exc.value, espota2.OTANetworkError) + assert "committed" not in str(exc.value) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_end_ack_send_failure_is_success( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a send failure on the final acknowledgement does not fail the OTA.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update committed + ] + # Sends: magic bytes, features, binary size, MD5, one firmware chunk; + # fail on the sixth sendall, the end acknowledgement + mock_socket.sendall.side_effect = [None] * 5 + [OSError("Broken pipe")] + + # Must not raise; the device treats a missing acknowledgement as non-fatal + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + assert mock_socket.sendall.call_count == 6 + + @pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") def test_run_ota_impl_successful( mock_socket: Mock, tmp_path: Path, mock_perform_ota: Mock @@ -564,21 +750,183 @@ def test_run_ota_impl_successful( @pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") -def test_run_ota_impl_connection_failed(mock_socket: Mock, tmp_path: Path) -> None: - """Test run_ota_impl_ when connection fails.""" +def test_run_ota_impl_connection_failed( + mock_socket: Mock, firmware_file: Path, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ retries when connection fails and eventually gives up.""" mock_socket.connect.side_effect = OSError("Connection refused") - # Create a real firmware file - firmware_file = tmp_path / "firmware.bin" - firmware_file.write_bytes(b"firmware content") - result_code, result_host = espota2.run_ota_impl_( "test.local", 3232, "password", str(firmware_file) ) assert result_code == 1 assert result_host is None - mock_socket.close.assert_called_once() + # A single address gets the whole attempt budget, with a delay before + # each revisit + assert mock_socket.connect.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1 + assert mock_socket.close.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1 + assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + mock_sleep.assert_called_with(espota2.UPLOAD_RETRY_DELAY) + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_connect_retry_succeeds( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ succeeds when a retry connects after a failed attempt.""" + mock_socket.connect.side_effect = [OSError("Connection timed out"), None] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + assert mock_socket.connect.call_count == 2 + mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY) + mock_perform_ota.assert_called_once() + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_network_error_retry_succeeds( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ retries after a network error during the upload.""" + mock_perform_ota.side_effect = [ + espota2.OTANetworkError("receiving features: Device closed connection"), + None, + ] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + assert mock_perform_ota.call_count == 2 + mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY) + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_network_error_exhausts_attempts( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ gives up after all attempts hit network errors.""" + mock_perform_ota.side_effect = espota2.OTANetworkError("sending data: broken pipe") + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + assert mock_perform_ota.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1 + assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual") +def test_run_ota_impl_multiple_addresses_cycle( + mock_socket: Mock, firmware_file: Path, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ visits every address and cycles for the retries.""" + mock_socket.connect.side_effect = OSError("No route to host") + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + # Each address is visited once, then the EXTRA_UPLOAD_ATTEMPTS spare + # attempts cycle back through them; the budget is shared, not per address + assert mock_socket.connect.call_args_list == [ + call(DUAL_STACK_SA6), + call(DUAL_STACK_SA4), + call(DUAL_STACK_SA6), + call(DUAL_STACK_SA4), + ] + # No connect ever reached the device, so the delay only applies before + # the revisits + assert mock_sleep.call_count == 2 + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual") +def test_run_ota_impl_second_address_succeeds_without_delay( + mock_socket: Mock, + firmware_file: Path, + mock_perform_ota: Mock, + mock_sleep: Mock, +) -> None: + """Test run_ota_impl_ falls through to the next address with no pause.""" + mock_socket.connect.side_effect = [OSError("No route to host"), None] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + mock_sleep.assert_not_called() + mock_perform_ota.assert_called_once() + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual") +def test_run_ota_impl_pauses_after_reaching_device( + mock_socket: Mock, + firmware_file: Path, + mock_perform_ota: Mock, + mock_sleep: Mock, +) -> None: + """Test run_ota_impl_ pauses before the next address once the device was reached.""" + mock_perform_ota.side_effect = [ + espota2.OTANetworkError("sending data: connection reset"), + None, + ] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + # The first attempt reached the device, so the next one waits first even + # though it targets a fresh address + mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY) + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_device_error_not_retried( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ fails immediately on a device-reported error.""" + mock_perform_ota.side_effect = espota2.OTAError( + "Authentication invalid. Is the password correct?" + ) + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + mock_perform_ota.assert_called_once() + mock_sleep.assert_not_called() + + +def test_run_ota_impl_no_addresses( + firmware_file: Path, mock_resolve_ip: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ fails cleanly when resolution yields no addresses.""" + mock_resolve_ip.return_value = [] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + mock_sleep.assert_not_called() def test_run_ota_impl_resolve_failed(tmp_path: Path, mock_resolve_ip: Mock) -> None: From 02c1810c3ad388b37025fd65307fd86dbf3e66a8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 13:31:19 -0500 Subject: [PATCH 1467/1815] [core] Load component aliases from a generated registry (#18335) --- .github/workflows/ci.yml | 1 + esphome/component_aliases.py | 10 ++++++ esphome/loader.py | 61 +++++++++++++-------------------- script/build_alias_registry.py | 59 +++++++++++++++++++++++++++++++ tests/unit_tests/test_loader.py | 28 +++++++++++++++ 5 files changed, 122 insertions(+), 37 deletions(-) create mode 100644 esphome/component_aliases.py create mode 100755 script/build_alias_registry.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e695bb46b..b603e68ad7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -179,6 +179,7 @@ jobs: . venv/bin/activate script/ci-custom.py script/build_codeowners.py --check + script/build_alias_registry.py --check script/build_language_schema.py --check script/generate-esp32-boards.py --check script/generate-rp2-boards.py --check diff --git a/esphome/component_aliases.py b/esphome/component_aliases.py new file mode 100644 index 0000000000..e701bd98d4 --- /dev/null +++ b/esphome/component_aliases.py @@ -0,0 +1,10 @@ +"""Component alias registry. + +Generated by script/build_alias_registry.py - do not edit manually. +See the component-alias section of esphome/loader.py. +""" + +# alias -> (canonical component, removal version or None) +COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = { + "rp2040": ("rp2", "2027.7.0"), +} diff --git a/esphome/loader.py b/esphome/loader.py index 7a659aa0a8..f994f0c5eb 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -269,10 +269,9 @@ def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None: # If `domain` is the legacy name of a renamed component, redirect to the # canonical module so the rest of the loader (and every caller of # `get_component(legacy)`) transparently sees the new component. - alias_map = _get_alias_map() - if domain in alias_map: - canonical = alias_map[domain] - manif = _lookup_module(canonical, exception) + alias_meta = get_alias_metadata().get(domain) + if alias_meta is not None: + manif = _lookup_module(alias_meta.canonical, exception) if manif is not None: _COMPONENT_CACHE[domain] = manif return manif @@ -329,8 +328,10 @@ def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> Non # --------------------------------------------------------------------------- # # A component can declare ``ALIASES = ["legacy_name"]`` (and optionally -# ``ALIAS_REMOVAL_VERSION = "YYYY.M.0"``) in its ``__init__.py``. Two -# integrations are then wired up automatically: +# ``ALIAS_REMOVAL_VERSION = "YYYY.M.0"``) in its ``__init__.py``, then run +# ``script/build_alias_registry.py`` to regenerate +# ``esphome/component_aliases.py`` (CI and a unit test fail if the registry +# is stale). Two integrations are then wired up automatically: # # 1. **Python imports** — a ``sys.meta_path`` finder (``_AliasFinder``) # intercepts ``esphome.components.``/``....`` @@ -344,13 +345,13 @@ def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> Non # dependency checks, schema validation and codegen all see only the # canonical name. # -# Both lookups are populated by ``_build_alias_map``, which **AST-parses** -# every component's ``__init__.py`` rather than importing it. That keeps the -# cost low: scanning ~400 components on disk takes ~5 ms instead of the -# multi-second cost of executing every component's import side-effects. +# Both lookups read the checked-in registry in ``esphome.component_aliases`` +# (generated by ``script/build_alias_registry.py``, verified in CI), so no +# component-directory scan happens at runtime. ``_build_alias_map`` below is +# the generator's scan implementation; it **AST-parses** each component's +# ``__init__.py`` rather than importing it. -_ALIAS_MAP_CACHE: dict[str, str] | None = None _ALIAS_META_CACHE: dict[str, "AliasMeta"] | None = None @@ -367,31 +368,17 @@ class AliasMeta: removal_version: str | None -def _ensure_alias_caches() -> None: - """Populate both alias caches from a single directory scan. - - ``_build_alias_map`` returns both maps together, so building them in one - shot avoids scanning every component's ``__init__.py`` twice when a run - needs both the canonical map (loader) and the metadata map (config - pre-pass). - """ - global _ALIAS_MAP_CACHE, _ALIAS_META_CACHE - if _ALIAS_MAP_CACHE is None or _ALIAS_META_CACHE is None: - _ALIAS_MAP_CACHE, _ALIAS_META_CACHE = _build_alias_map() - - -def _get_alias_map() -> dict[str, str]: - """Return the legacy-name → canonical-name map, building it lazily.""" - _ensure_alias_caches() - return _ALIAS_MAP_CACHE - - def get_alias_metadata() -> dict[str, AliasMeta]: - """Return the legacy-name → :class:`AliasMeta` map (cached). + """Return the legacy-name → :class:`AliasMeta` map, built lazily from + the generated registry.""" + global _ALIAS_META_CACHE # noqa: PLW0603 + if _ALIAS_META_CACHE is None: + from esphome.component_aliases import COMPONENT_ALIASES - Used by the YAML pre-pass to format a per-alias deprecation warning. - """ - _ensure_alias_caches() + _ALIAS_META_CACHE = { + alias: AliasMeta(canonical=canonical, removal_version=removal_version) + for alias, (canonical, removal_version) in COMPONENT_ALIASES.items() + } return _ALIAS_META_CACHE @@ -537,11 +524,11 @@ class _AliasFinder(importlib.abc.MetaPathFinder): # least three parts, so ``parts[2]`` (the domain) always exists. parts = fullname.split(".") domain = parts[2] - alias_map = _get_alias_map() - if domain not in alias_map: + alias_meta = get_alias_metadata().get(domain) + if alias_meta is None: return None - parts[2] = alias_map[domain] + parts[2] = alias_meta.canonical canonical_fullname = ".".join(parts) try: canonical_module = importlib.import_module(canonical_fullname) diff --git a/script/build_alias_registry.py b/script/build_alias_registry.py new file mode 100755 index 0000000000..e007c075eb --- /dev/null +++ b/script/build_alias_registry.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Generate esphome/component_aliases.py from component ALIASES declarations. + +Run without arguments to regenerate the registry; ``--check`` (run in CI) +verifies it is up to date. +""" + +import argparse +from pathlib import Path +import sys + +# The root directory of the repo +root = Path(__file__).parent.parent +# Make the repo's esphome package win over any installed copy +sys.path.insert(0, str(root)) + +from esphome.helpers import write_file_if_changed # noqa: E402 +from esphome.loader import _build_alias_map # noqa: E402 + +parser = argparse.ArgumentParser() +parser.add_argument( + "--check", + help="Check if the alias registry is up to date.", + action="store_true", +) +args = parser.parse_args() + +registry_file = root / "esphome" / "component_aliases.py" + +HEADER = '''"""Component alias registry. + +Generated by script/build_alias_registry.py - do not edit manually. +See the component-alias section of esphome/loader.py. +""" + +# alias -> (canonical component, removal version or None) +COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = { +''' + +# _build_alias_map scans the real component tree and already rejects +# duplicate and shadowing aliases with an EsphomeError. +_, alias_meta = _build_alias_map() + +lines = [HEADER] +for alias, meta in sorted(alias_meta.items()): + removal = f'"{meta.removal_version}"' if meta.removal_version else "None" + lines.append(f' "{alias}": ("{meta.canonical}", {removal}),\n') +lines.append("}\n") +content = "".join(lines) + +if args.check: + if registry_file.read_text(encoding="utf-8") != content: + print("Component alias registry is not up to date.") + print("Please run `script/build_alias_registry.py`") + sys.exit(1) + print("Component alias registry is up to date") +else: + write_file_if_changed(registry_file, content) + print(f"Wrote {registry_file}") diff --git a/tests/unit_tests/test_loader.py b/tests/unit_tests/test_loader.py index 41dd462678..74515e9d4c 100644 --- a/tests/unit_tests/test_loader.py +++ b/tests/unit_tests/test_loader.py @@ -8,6 +8,7 @@ from unittest.mock import MagicMock, patch import pytest +from esphome.component_aliases import COMPONENT_ALIASES from esphome.loader import ( AliasMeta, ComponentManifest, @@ -481,6 +482,33 @@ def test_real_alias_map_includes_rp2040() -> None: assert meta["rp2040"].removal_version == "2027.7.0" +def test_alias_registry_matches_component_tree() -> None: + """The checked-in registry must match a live scan of the component tree.""" + _, meta_map = _build_alias_map() + expected = { + alias: (meta.canonical, meta.removal_version) + for alias, meta in meta_map.items() + } + assert expected == COMPONENT_ALIASES, ( + "esphome/component_aliases.py is out of date; " + "run script/build_alias_registry.py" + ) + + +def test_alias_map_built_from_registry() -> None: + """The runtime alias map comes from the generated registry, not a scan.""" + with ( + patch( + "esphome.component_aliases.COMPONENT_ALIASES", + {"legacy": ("modern", "2099.1.0")}, + ), + patch("esphome.loader._ALIAS_META_CACHE", None), + ): + assert get_alias_metadata() == { + "legacy": AliasMeta(canonical="modern", removal_version="2099.1.0") + } + + def test_get_component_resolves_alias() -> None: """``get_component('rp2040')`` should return the rp2 manifest — every caller of the loader (dep checker, schema validator, codegen) hits From add18d4e351f582757781aa92f68eaa534ea8748 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 20:06:48 -0500 Subject: [PATCH 1468/1815] [core] Partially revert "Hash entity keys from the raw name to fix collisions" (#18361) --- esphome/components/api/api_connection.cpp | 6 +- esphome/components/infrared/infrared.cpp | 8 +- esphome/components/mqtt/__init__.py | 63 --- esphome/components/prometheus/__init__.py | 6 - .../radio_frequency/radio_frequency.cpp | 8 +- .../template/text/template_text.cpp | 20 +- .../components/template/text/template_text.h | 15 +- esphome/core/application.h | 8 +- esphome/core/entity_base.cpp | 52 +-- esphome/core/entity_base.h | 80 ++-- esphome/core/entity_helpers.py | 190 ++++---- esphome/core/helpers.h | 29 +- esphome/core/preference_backend.h | 14 +- esphome/core/preferences.cpp | 25 - esphome/core/preferences.h | 12 - esphome/helpers.py | 15 +- tests/integration/entity_utils.py | 27 +- .../fixtures/fnv1_hash_object_id.yaml | 32 -- .../fixtures/multi_device_preferences.yaml | 21 +- .../fixtures/preference_key_migration.yaml | 35 -- tests/integration/host_prefs.py | 24 +- tests/integration/test_fnv1_hash_object_id.py | 4 - .../test_object_id_api_verification.py | 10 +- ...t_object_id_friendly_name_no_mac_suffix.py | 4 +- .../test_object_id_no_friendly_name.py | 6 +- .../test_preference_key_migration.py | 165 ------- tests/unit_tests/components/mqtt/__init__.py | 0 .../mqtt/test_object_id_conflicts.py | 239 ---------- tests/unit_tests/core/test_entity_helpers.py | 432 ++++++++++-------- .../object_id_conflict_mqtt.yaml | 22 - .../object_id_conflict_no_mqtt.yaml | 15 - .../test_preference_hash_stability.py | 34 +- 32 files changed, 489 insertions(+), 1132 deletions(-) delete mode 100644 esphome/core/preferences.cpp delete mode 100644 tests/integration/fixtures/preference_key_migration.yaml delete mode 100644 tests/integration/test_preference_key_migration.py delete mode 100644 tests/unit_tests/components/mqtt/__init__.py delete mode 100644 tests/unit_tests/components/mqtt/test_object_id_conflicts.py delete mode 100644 tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml delete mode 100644 tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index d05f98d03b..73b4f3e5bd 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -448,7 +448,7 @@ void APIConnection::on_disconnect_response() { uint16_t APIConnection::fill_and_encode_entity_state(EntityBase *entity, StateResponseProtoMessage &msg, CalculateSizeFn size_fn, MessageEncodeFn encode_fn, APIConnection *conn, uint32_t remaining_size) { - msg.key = entity->get_entity_key(); + msg.key = entity->get_object_id_hash(); #ifdef USE_DEVICES msg.device_id = entity->get_device_id(); #endif @@ -459,7 +459,7 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp CalculateSizeFn size_fn, MessageEncodeFn encode_fn, APIConnection *conn, uint32_t remaining_size) { // Set common fields that are shared by all entity types - msg.key = entity->get_entity_key(); + msg.key = entity->get_object_id_hash(); if (entity->has_own_name()) { msg.name = entity->get_name(); @@ -1149,7 +1149,7 @@ void APIConnection::try_send_camera_image_() { bool done = this->image_reader_->available() == to_send; CameraImageResponse msg; - msg.key = camera::Camera::instance()->get_entity_key(); + msg.key = camera::Camera::instance()->get_object_id_hash(); msg.set_data(this->image_reader_->peek_data_buffer(), to_send); msg.done = done; #ifdef USE_DEVICES diff --git a/esphome/components/infrared/infrared.cpp b/esphome/components/infrared/infrared.cpp index 288b1e5c40..9b97995a96 100644 --- a/esphome/components/infrared/infrared.cpp +++ b/esphome/components/infrared/infrared.cpp @@ -154,8 +154,12 @@ bool Infrared::on_receive(remote_base::RemoteReceiveData data) { // Forward received IR data to API server #if defined(USE_API) && defined(USE_IR_RF) if (api::global_api_server != nullptr) { - api::global_api_server->send_infrared_rf_receive_event(this->get_device_id_or_zero(), this->get_entity_key(), - &data.get_raw_data()); +#ifdef USE_DEVICES + uint32_t device_id = this->get_device_id(); +#else + uint32_t device_id = 0; +#endif + api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data()); } #endif return false; // Don't consume the event, allow other listeners to process it diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 713969ab88..98ca23b60b 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -63,7 +63,6 @@ from esphome.const import ( PlatformFramework, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import ObjectIdEntity, validate_no_object_id_conflicts from esphome.types import ConfigType DEPENDENCIES = ["network"] @@ -333,68 +332,6 @@ CONFIG_SCHEMA = cv.All( ) -# Platforms whose MQTT components subscribe to an object_id-derived command topic. -# Keep in sync with the platforms extending cv.MQTT_COMMAND_COMPONENT_SCHEMA, plus -# text, whose MQTT component subscribes a command topic that cannot be overridden. -_COMMAND_TOPIC_PLATFORMS = frozenset( - { - "alarm_control_panel", - "button", - "climate", - "cover", - "datetime", - "fan", - "light", - "lock", - "number", - "select", - "switch", - "text", - "update", - "valve", - } -) - - -# Platforms whose MQTT components derive extra sub-topics (position/command, -# mode/command, speed/command, ...) from the object_id, each with its own config -# key; custom state and command topics cannot exempt them from conflicting. -_SUB_TOPIC_PLATFORMS = frozenset({"climate", "cover", "fan", "valve"}) - - -def _topics_conflict(entities: list[ObjectIdEntity], config: ConfigType) -> bool: - """Check whether more than one entity actually uses an object_id-derived topic. - - An empty topic_prefix disables default topics entirely, custom state and - command topics avoid the default topics, and disabling discovery (globally - or per entity) avoids the discovery config topic. - """ - if config[CONF_TOPIC_PREFIX]: - platform = entities[0].platform - if platform in _SUB_TOPIC_PLATFORMS: - return True - if sum(CONF_STATE_TOPIC not in entity.config for entity in entities) > 1: - return True - if ( - platform in _COMMAND_TOPIC_PLATFORMS - and sum(CONF_COMMAND_TOPIC not in entity.config for entity in entities) > 1 - ): - return True - if not config[CONF_DISCOVERY]: - return False - discovery_entities = sum( - entity.config.get(CONF_DISCOVERY, True) for entity in entities - ) - return discovery_entities > 1 - - -FINAL_VALIDATE_SCHEMA = validate_no_object_id_conflicts( - "mqtt builds default topics and discovery topics from the entity object_id, " - "which is the name converted to ASCII", - conflict_filter=_topics_conflict, -) - - def exp_mqtt_message(config): if config is None: return cg.optional(cg.TemplateArguments(MQTTMessage)) diff --git a/esphome/components/prometheus/__init__.py b/esphome/components/prometheus/__init__.py index 0a69160fc1..cc1541ce80 100644 --- a/esphome/components/prometheus/__init__.py +++ b/esphome/components/prometheus/__init__.py @@ -3,7 +3,6 @@ from esphome.components import web_server_base from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_INCLUDE_INTERNAL, CONF_NAME, CONF_RELABEL -from esphome.core.entity_helpers import validate_no_object_id_conflicts from esphome.cpp_types import EntityBase AUTO_LOAD = ["web_server_base"] @@ -36,11 +35,6 @@ CONFIG_SCHEMA = cv.Schema( }, ).extend(cv.COMPONENT_SCHEMA) -FINAL_VALIDATE_SCHEMA = validate_no_object_id_conflicts( - "prometheus builds metric labels from the entity object_id, " - "which is the name converted to ASCII" -) - async def to_code(config): paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID]) diff --git a/esphome/components/radio_frequency/radio_frequency.cpp b/esphome/components/radio_frequency/radio_frequency.cpp index fe6c6a9cb5..3e0a905737 100644 --- a/esphome/components/radio_frequency/radio_frequency.cpp +++ b/esphome/components/radio_frequency/radio_frequency.cpp @@ -99,8 +99,12 @@ bool RadioFrequency::on_receive(remote_base::RemoteReceiveData data) { // Forward received RF data to API server #if defined(USE_API) && defined(USE_RADIO_FREQUENCY) if (api::global_api_server != nullptr) { - api::global_api_server->send_infrared_rf_receive_event(this->get_device_id_or_zero(), this->get_entity_key(), - &data.get_raw_data()); +#ifdef USE_DEVICES + uint32_t device_id = this->get_device_id(); +#else + uint32_t device_id = 0; +#endif + api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data()); } #endif return false; // Don't consume the event, allow other listeners to process it diff --git a/esphome/components/template/text/template_text.cpp b/esphome/components/template/text/template_text.cpp index ffe11cf229..af134e6ed4 100644 --- a/esphome/components/template/text/template_text.cpp +++ b/esphome/components/template/text/template_text.cpp @@ -20,14 +20,18 @@ void TemplateText::setup() { // Need std::string for pref_->setup() to fill from flash std::string value{this->initial_value_ != nullptr ? this->initial_value_ : ""}; - uint32_t extra = 0; - extra += this->traits.get_min_length() << 2; - extra += this->traits.get_max_length() << 4; - extra += fnv1_hash(this->traits.get_pattern_c_str()) << 6; - // TextSaver::setup() picks the key for the platform and migrates old data once - uint32_t key = this->preference_key_base_() + extra; - uint32_t old_key = this->old_preference_key_base_() + extra; - this->pref_->setup(key, old_key, value); + // For future hash migration: use migrate_entity_preference_() with: + // old_key = get_preference_hash() + extra + // new_key = get_preference_hash_v2() + extra + // See: https://github.com/esphome/backlog/issues/85 +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + uint32_t key = this->get_preference_hash(); +#pragma GCC diagnostic pop + key += this->traits.get_min_length() << 2; + key += this->traits.get_max_length() << 4; + key += fnv1_hash(this->traits.get_pattern_c_str()) << 6; + this->pref_->setup(key, value); if (!value.empty()) this->publish_state(value); } diff --git a/esphome/components/template/text/template_text.h b/esphome/components/template/text/template_text.h index beeea4396a..229a61d9b8 100644 --- a/esphome/components/template/text/template_text.h +++ b/esphome/components/template/text/template_text.h @@ -14,9 +14,7 @@ class TemplateTextSaverBase { public: virtual bool save(const std::string &value) { return true; } - /// old_id is the pre-2026.8.0 preference key; data stored under it is moved to id once. - /// See: https://github.com/esphome/backlog/issues/85 - virtual void setup(uint32_t id, uint32_t old_id, std::string &value) {} + virtual void setup(uint32_t id, std::string &value) {} protected: ESPPreferenceObject pref_; @@ -47,16 +45,11 @@ template class TextSaver : public TemplateTextSaverBase { // Make the preference object. Fill the provided location with the saved data // If it is available, else leave it alone - void setup(uint32_t id, uint32_t old_id, std::string &value) override { - char temp[SZ + 1]; -#ifdef USE_PREFERENCE_KEY_LOOKUP + void setup(uint32_t id, std::string &value) override { this->pref_ = global_preferences->make_preference(id); - bool hasdata = migrate_preference(this->pref_, reinterpret_cast(temp), SZ + 1, old_id, id); -#else - // Slot-based backends keep the old key; it is only a validity tag on a positional slot - this->pref_ = global_preferences->make_preference(old_id); + + char temp[SZ + 1]; bool hasdata = this->pref_.load(&temp); -#endif if (hasdata) { size_t len = static_cast(temp[0]); diff --git a/esphome/core/application.h b/esphome/core/application.h index a18a6b31c8..a12cdc4ac8 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -120,8 +120,8 @@ class Application { // 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, const char *name, uint32_t entity_key, uint32_t entity_fields) { \ - obj->configure_entity_(name, entity_key, entity_fields); \ + 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) \ @@ -329,7 +329,7 @@ class Application { #define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \ entity_type *get_##entity_name##_by_key(uint32_t key, uint32_t device_id, bool include_internal = false) { \ for (auto *obj : this->entities_member##_) { \ - if (obj->get_entity_key() == key && obj->get_device_id() == device_id && \ + if (obj->get_object_id_hash() == key && obj->get_device_id() == device_id && \ (include_internal || !obj->is_internal())) \ return obj; \ } \ @@ -340,7 +340,7 @@ class Application { #define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \ entity_type *get_##entity_name##_by_key(uint32_t key, bool include_internal = false) { \ for (auto *obj : this->entities_member##_) { \ - if (obj->get_entity_key() == key && (include_internal || !obj->is_internal())) \ + if (obj->get_object_id_hash() == key && (include_internal || !obj->is_internal())) \ return obj; \ } \ return nullptr; \ diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 328de05302..fc6ac503b5 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -8,7 +8,7 @@ namespace esphome { static const char *const TAG = "entity_base"; -void EntityBase::configure_entity_(const char *name, uint32_t entity_key, uint32_t entity_fields) { +void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields) { this->name_ = StringRef(name); if (this->name_.empty()) { #ifdef USE_DEVICES @@ -30,15 +30,15 @@ void EntityBase::configure_entity_(const char *name, uint32_t entity_key, uint32 } } this->flags_.has_own_name = false; - // Dynamic name - must calculate key at runtime - this->calc_entity_key_(); + // Dynamic name - must calculate hash at runtime + this->calc_object_id_(); } else { this->flags_.has_own_name = true; - // Static name - use pre-computed key if provided - if (entity_key != 0) { - this->entity_key_ = entity_key; + // Static name - use pre-computed hash if provided + if (object_id_hash != 0) { + this->object_id_hash_ = object_id_hash; } else { - this->calc_entity_key_(); + this->calc_object_id_(); } } // Unpack entity string table indices and flags from entity_fields. @@ -147,15 +147,9 @@ std::string EntityBase::get_icon() const { } #endif // !USE_ESP8266 -// Calculate the entity key directly from the raw name (no transformations) -void EntityBase::calc_entity_key_() { this->entity_key_ = fnv1_hash_bytes(this->name_.c_str(), this->name_.size()); } - -// Reconstruct the OLD (pre-2026.8.0) object_id-based hash for preference key compatibility. -// Named entities historically used the hash pre-computed by Python code generation, which -// sanitized per UTF-8 code point; entities without their own name computed the hash at -// runtime per byte. See https://github.com/esphome/backlog/issues/85 -uint32_t EntityBase::calc_old_object_id_hash_() const { - return fnv1_hash_object_id(this->name_.c_str(), this->name_.size(), this->flags_.has_own_name); +// Calculate Object ID Hash directly from name using snake_case + sanitize +void EntityBase::calc_object_id_() { + this->object_id_hash_ = fnv1_hash_object_id(this->name_.c_str(), this->name_.size()); } size_t EntityBase::write_object_id_to(char *buf, size_t buf_size) const { @@ -173,22 +167,16 @@ StringRef EntityBase::get_object_id_to(std::span buf) c } ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t version) { - // The old key hashed the sanitized object_id, so multiple entity names could collide on - // one key and overwrite each other's stored preferences; the new key hashes the raw name. - // See: https://github.com/esphome/backlog/issues/85 - uint32_t old_key = this->old_preference_key_base_() ^ version; -#ifdef USE_PREFERENCE_KEY_LOOKUP - uint32_t new_key = this->preference_key_base_() ^ version; - auto pref = global_preferences->make_preference(size, new_key); - // All in-tree entity preferences fit the stack buffer, so migration never hits the heap - SmallBufferWithHeapFallback<64> buffer(size); - migrate_preference(pref, buffer.get(), size, old_key, new_key); - return pref; -#else - // Slot-based backends keep the old key: it is only a validity tag on a positional slot, - // so collisions cannot corrupt data there and keeping it preserves stored state. - return global_preferences->make_preference(size, old_key); -#endif + // The key hashes the sanitized object_id, so multiple entity names can collide on one + // key and overwrite each other's stored preferences ("Living Room" and "living_room", + // or two UTF-8 names that both sanitize to underscores). Keys hashed from the raw name + // fix this, but they change the entity key API clients track, which the Home Assistant + // esphome integration cannot handle yet. See: https://github.com/esphome/backlog/issues/85 +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + uint32_t key = this->get_preference_hash() ^ version; +#pragma GCC diagnostic pop + return global_preferences->make_preference(size, key); } #ifdef USE_ENTITY_ICON diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 7f8e5f2630..5f2e173d8d 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -73,17 +73,8 @@ class EntityBase { // Get whether this Entity has its own name or it should use the device friendly_name. bool has_own_name() const { return this->flags_.has_own_name; } - // Get the unique key of this Entity: FNV-1 hash of the raw entity name. - // This is the key sent to API clients and used to route entity state. - uint32_t get_entity_key() const { return this->entity_key_; } - - /// Returns the LEGACY object_id hash, unchanged from previous releases, so existing - /// callers keep getting stable values (for example preference keys). This is no longer - /// the key sent to API clients; that is get_entity_key(). - ESPDEPRECATED("Use get_entity_key() for the entity key sent to API clients, or " - "make_entity_preference() for preference storage. Will be removed in 2027.1.0.", - "2026.8.0") - uint32_t get_object_id_hash() const { return this->calc_old_object_id_hash_(); } + // Get the unique Object ID of this Entity + uint32_t get_object_id_hash() const { return this->object_id_hash_; } /// Get object_id with zero heap allocation /// For static case: returns StringRef to internal storage (buffer unused) @@ -190,23 +181,39 @@ class EntityBase { // Set has_state - for components that need to manually set this void set_has_state(bool state) { this->flags_.has_state = state; } - /// Get this entity's device id, or 0 when devices are not compiled in (main device). - uint32_t get_device_id_or_zero() const { -#ifdef USE_DEVICES - return this->get_device_id(); -#else - return 0; -#endif - } - - /// Get the LEGACY preference key: FNV-1 hash of the sanitized object_id, XOR device_id. - /// Intentionally keeps the old algorithm so external callers that store preferences under - /// this key keep stable keys; make_entity_preference() migrates to the new raw-name key, - /// this method never will. + /** + * @brief Get a unique hash for storing preferences/settings for this entity. + * + * This method returns a hash that uniquely identifies the entity for the purpose of + * storing preferences (such as calibration, state, etc.). Unlike get_object_id_hash(), + * this hash also incorporates the device_id (if devices are enabled), ensuring uniqueness + * across multiple devices that may have entities with the same object_id. + * + * Use this method when storing or retrieving preferences/settings that should be unique + * per device-entity pair. Use get_object_id_hash() when you need a hash that identifies + * the entity regardless of the device it belongs to. + * + * For backward compatibility, if device_id is 0 (the main device), the hash is unchanged + * from previous versions, so existing single-device configurations will continue to work. + * + * @return uint32_t The unique hash for preferences, including device_id if available. + * @deprecated Use make_entity_preference() instead, or preferences won't be migrated. + * See https://github.com/esphome/backlog/issues/85 + */ ESPDEPRECATED("Use make_entity_preference() instead, or preferences won't be migrated. " "See https://github.com/esphome/backlog/issues/85. Will be removed in 2027.1.0.", - "2026.8.0") - uint32_t get_preference_hash() { return this->old_preference_key_base_(); } + "2026.7.0") + uint32_t get_preference_hash() { +#ifdef USE_DEVICES + // Combine object_id_hash with device_id to ensure uniqueness across devices + // Note: device_id is 0 for the main device, so XORing with 0 preserves the original hash + // This ensures backward compatibility for existing single-device configurations + return this->get_object_id_hash() ^ this->get_device_id(); +#else + // Without devices, just use object_id_hash as before + return this->get_object_id_hash(); +#endif + } /// Create a preference object for storing this entity's state/settings. /// @tparam T The type of data to store (must be trivially copyable) @@ -223,9 +230,9 @@ class EntityBase { // before push_back, so codegen can emit a single combined call per entity. friend class Application; - /// Combined entity setup from codegen: set name, entity key, entity string indices, and flags. + /// 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. - void configure_entity_(const char *name, uint32_t entity_key, uint32_t entity_fields); + void configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields); #ifdef USE_DEVICES // Codegen-only setter — only accessible from setup() via friend declaration. @@ -233,24 +240,13 @@ class EntityBase { #endif /// Non-template helper for make_entity_preference() to avoid code bloat. - /// Migrates preferences from the old sanitized-object_id key to the raw-name key - /// on key-lookup platforms. See: https://github.com/esphome/backlog/issues/85 + /// When the preference hash algorithm changes, migration logic goes here. ESPPreferenceObject make_entity_preference_(size_t size, uint32_t version); - void calc_entity_key_(); - - /// Reconstruct the OLD (pre-2026.8.0) sanitized-object_id hash for preference keys. - uint32_t calc_old_object_id_hash_() const; - - /// Preference key base for this entity: raw-name entity key XOR device_id. - uint32_t preference_key_base_() const { return this->entity_key_ ^ this->get_device_id_or_zero(); } - - /// Legacy preference key base: sanitized-object_id hash XOR device_id. - /// Note: device_id is 0 for the main device, so XORing with 0 preserves the original hash. - uint32_t old_preference_key_base_() const { return this->calc_old_object_id_hash_() ^ this->get_device_id_or_zero(); } + void calc_object_id_(); StringRef name_; - uint32_t entity_key_{}; + uint32_t object_id_hash_{}; #ifdef USE_DEVICES Device *device_{}; #endif diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 5060e32a2d..54e2551cb4 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -25,86 +25,25 @@ from esphome.core.config import ( 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_name, sanitize, snake_case +from esphome.helpers import ( + cpp_string_escape, + fnv1_hash, + fnv1_hash_object_id, + sanitize, + snake_case, +) from esphome.types import ConfigType, EntityMetadata _LOGGER = logging.getLogger(__name__) DOMAIN = "entity_string_pool" -_OBJECT_ID_DOMAIN = "entity_object_ids" - - -@dataclass -class ObjectIdEntity: - """An entity tracked by the sanitized object_id its name resolves to.""" - - name: str - platform: str - config: ConfigType - - -def _get_object_id_registry() -> dict[tuple[str, str, str], list[ObjectIdEntity]]: - """(device_id, platform, sanitized object_id) -> entities resolving to it.""" - return CORE.data.setdefault(_OBJECT_ID_DOMAIN, {}) - - -def validate_no_object_id_conflicts( - reason: str, - conflict_filter: Callable[[list[ObjectIdEntity], ConfigType], bool] | None = None, -) -> Callable[[ConfigType], ConfigType]: - """Create a final-validate step that rejects entities with colliding object_ids. - - Entity keys are hashed from the raw name, so names that only differ in characters - lost during sanitizing (for example two UTF-8 names) validate fine in general. - Components that still address entities by the sanitized object_id string must - reject those configs until they are migrated to raw names. - - Args: - reason: One sentence stating what the component builds from the object_id, - e.g. "mqtt builds default topics from the entity object_id" - conflict_filter: Optional predicate receiving the colliding entities and the - component config; return False when the component is not affected - - Returns: - A validator function for use as (or within) FINAL_VALIDATE_SCHEMA - """ - - def validator(config: ConfigType) -> ConfigType: - # Skip in testing_mode, which is used for grouped component testing - if CORE.testing_mode: - return config - conflicts = { - key: entities - for key, entities in _get_object_id_registry().items() - if len(entities) > 1 - and (conflict_filter is None or conflict_filter(entities, config)) - } - if not conflicts: - return config - lines = [f"{reason}, so these entities would conflict:"] - lines.extend( - f" - {platform} entities " - + ", ".join(f"'{e.name}'" for e in entities) - + (f" on device '{device_id}'" if device_id else "") - + f" share the object_id '{object_id}'" - for (device_id, platform, object_id), entities in conflicts.items() - ) - lines.append( - "To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B') " - "to distinguish the names" - ) - raise cv.Invalid("\n".join(lines)) - - return validator - - # Private config keys for storing registered string indices _KEY_DC_IDX = "_entity_dc_idx" _KEY_UOM_IDX = "_entity_uom_idx" _KEY_ICON_IDX = "_entity_icon_idx" _KEY_ENTITY_NAME = "_entity_name" -_KEY_ENTITY_KEY = "_entity_key" +_KEY_OBJECT_ID_HASH = "_entity_object_id_hash" # Bit layout for entity_fields in configure_entity_(). # Keep in sync with ENTITY_FIELD_*_SHIFT constants in esphome/core/entity_base.h @@ -367,7 +306,7 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: standalone ``var->configure_entity_(name, hash, packed)``. """ entity_name = config[_KEY_ENTITY_NAME] - entity_key = config[_KEY_ENTITY_KEY] + object_id_hash = config[_KEY_OBJECT_ID_HASH] dc_idx = config.get(_KEY_DC_IDX, 0) uom_idx = config.get(_KEY_UOM_IDX, 0) icon_idx = config.get(_KEY_ICON_IDX, 0) @@ -387,30 +326,57 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: register_method = config.get(_KEY_REGISTER_METHOD) if register_method is not None: expr = getattr(App, f"register_{register_method}")( - var, entity_name, entity_key, packed + var, entity_name, object_id_hash, packed ) else: - expr = var.configure_entity_(entity_name, entity_key, packed) + expr = var.configure_entity_(entity_name, object_id_hash, packed) if comment: add(RawStatement(f"{expr}; // {comment}")) else: add(expr) -def get_base_entity_name( +def get_base_entity_object_id( name: str, friendly_name: str | None, device_name: str | None = None ) -> str: - """Return the base name whose hash becomes this entity's key on the device. + """Calculate the base object ID for an entity that will be set via set_object_id(). - Follows the name selection in C++ EntityBase::configure_entity_() (entity_base.cpp): - entity name, then sub-device name, then friendly name, then the device name. + This function calculates what object_id_c_str_ should be set to in C++. - This is a config-time approximation for duplicate checking: when - name_add_mac_suffix is enabled the device appends the MAC suffix at runtime, - which is unknown here and identical for every entity on the device, so - ignoring it cannot change whether two entities collide with each other. + The C++ EntityBase::write_object_id_to() (entity_base.cpp) works as: + - If !has_own_name && is_name_add_mac_suffix_enabled(): + return str_sanitize(str_snake_case(App.get_friendly_name())) // Dynamic + - Else: + return object_id_c_str_ ?? "" // What we set via set_object_id() + + Since we're calculating what to pass to set_object_id(), we always need to + generate the object_id the same way, regardless of name_add_mac_suffix setting. + + Args: + name: The entity name (empty string if no name) + friendly_name: The friendly name from CORE.friendly_name + device_name: The device name if entity is on a sub-device + + Returns: + The base object ID to use for duplicate checking and to pass to set_object_id() """ - return name or device_name or friendly_name or CORE.name + + if name: + # Entity has its own name (has_own_name will be true) + base_str = name + elif device_name: + # Entity has empty name and is on a sub-device + # C++ EntityBase::set_name() uses device->get_name() when device is set + base_str = device_name + elif friendly_name: + # Entity has empty name (has_own_name will be false) + # C++ uses App.get_friendly_name() which returns friendly_name or device name + base_str = friendly_name + else: + # Fallback to device name + base_str = CORE.name + + return sanitize(snake_case(base_str)) def setup_entity(var_or_platform, config=None, platform=None): @@ -469,15 +435,15 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) -> device: MockObj = await get_variable(device_id_obj) add(var.set_device_(device)) - # Pre-compute entity name and entity key for configure_entity_() + # Pre-compute entity name and object_id hash for configure_entity_() # which is emitted later by finalize_entity_strings(). - # For named entities: pre-compute the key from the raw entity name - # For empty-name entities: pass 0, C++ calculates the key at runtime from - # device name, friendly_name, or app name + # For named entities: pre-compute hash from entity name + # For empty-name entities: pass 0, C++ calculates hash at runtime from + # device name, friendly_name, or app name (bug-for-bug compatibility) entity_name = config[CONF_NAME] - entity_key = fnv1_hash_name(entity_name) if entity_name else 0 + object_id_hash = fnv1_hash_object_id(entity_name) if entity_name else 0 config[_KEY_ENTITY_NAME] = entity_name - config[_KEY_ENTITY_KEY] = entity_key + config[_KEY_OBJECT_ID_HASH] = object_id_hash # Store flags for packing into configure_entity_() config[_KEY_DISABLED_BY_DEFAULT] = int(config[CONF_DISABLED_BY_DEFAULT]) if CONF_INTERNAL in config: @@ -590,13 +556,16 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy # Use the device ID string directly for uniqueness device_id = device_id_obj.id - # Hash the same raw name the device hashes into the entity key at runtime. - # This handles empty names correctly by using device/friendly names. - base_name = get_base_entity_name(entity_name, CORE.friendly_name, device_name) - name_hash = fnv1_hash_name(base_name) + # Calculate what object_id will actually be used + # This handles empty names correctly by using device/friendly names + name_key = get_base_entity_object_id( + entity_name, CORE.friendly_name, device_name + ) - # Check for duplicates: two entities on the same device and platform must not - # share an entity key, since the key is what routes state to API clients + # Check for duplicates by the FNV-1 hash of the object_id, which is the entity + # key that routes state to API clients. This rejects names that sanitize to the + # same object_id, and also two different object_ids whose 32-bit hashes collide. + name_hash = fnv1_hash(name_key) unique_key = (device_id, platform, name_hash) if unique_key in CORE.unique_ids: # Get the existing entity metadata @@ -621,14 +590,26 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy if existing_component != "unknown": conflict_msg += f" from component '{existing_component}'" - # Different names can only clash here through a genuine hash collision + # Distinguish names that sanitize to the same object_id from a genuine + # 32-bit hash collision between two different object_ids collision_msg = "" if entity_name != existing_name: - collision_msg = ( - f"\n The names '{entity_name}' and '{existing_name}' produce the" - f"\n same entity key hash ({name_hash:#010x})." - "\n To fix: Rename one of the entities" + existing_object_id = get_base_entity_object_id( + existing_name, CORE.friendly_name, existing_device or None ) + if existing_object_id == name_key: + collision_msg = ( + f"\n Original names: '{entity_name}' and '{existing_name}'" + f"\n Both convert to ASCII ID: '{name_key}'" + "\n To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B')" + "\n to distinguish them" + ) + else: + collision_msg = ( + f"\n The object_ids '{name_key}' and '{existing_object_id}'" + f"\n produce the same entity key hash ({name_hash:#010x})." + "\n To fix: Rename one of the entities" + ) # Skip duplicate entity name validation when testing_mode is enabled # This flag is used for grouped component testing @@ -640,19 +621,6 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy f"{collision_msg}" ) - # Components that still address entities by the sanitized object_id reject - # colliding names in final validation via validate_no_object_id_conflicts(), - # so track every entity by the object_id its name resolves to. Scoped per - # device and platform to match the strictness configs had before entity keys - # moved to raw names: same-named entities on different sub-devices were - # already accepted then, internal entities were already skipped (above), and - # overlaps between platforms that share an MQTT component type (sensor and - # text_sensor both publish under "sensor") were already possible. - object_id = sanitize(snake_case(base_name)) - _get_object_id_registry().setdefault( - (device_id, platform, object_id), [] - ).append(ObjectIdEntity(base_name, platform, config)) - # Store metadata about this entity entity_metadata: EntityMetadata = { "name": entity_name, diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index d883ce146e..994fa2c26a 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -809,19 +809,6 @@ constexpr uint32_t FNV1_OFFSET_BASIS = 2166136261UL; /// FNV-1 32-bit prime constexpr uint32_t FNV1_PRIME = 16777619UL; -/// Calculate a FNV-1 hash over raw bytes with an explicit length. Unlike fnv1_hash(const char *), -/// each byte is hashed as an unsigned value, so results are platform-independent for bytes >= 0x80. -/// IMPORTANT: Must match Python fnv1_hash_name() in esphome/helpers.py, which hashes the UTF-8 -/// encoded bytes of the name. Used to compute entity keys from raw names. -inline uint32_t fnv1_hash_bytes(const char *str, size_t len) { - uint32_t hash = FNV1_OFFSET_BASIS; - for (size_t i = 0; i < len; i++) { - hash *= FNV1_PRIME; - hash ^= static_cast(str[i]); - } - return hash; -} - /// Extend a FNV-1 hash with an integer (hashes each byte). template constexpr uint32_t fnv1_hash_extend(uint32_t hash, T value) { using UnsignedT = std::make_unsigned_t; @@ -1026,20 +1013,12 @@ template inline char *str_sanitize_to(char (&buffer)[N], const char *s // str_sanitize moved to alloc_helpers.h - remove this comment before 2026.11.0 /// Calculate FNV-1 hash of a string while applying snake_case + sanitize transformations. -/// This is the LEGACY entity hash, kept only to reconstruct preference keys that existing -/// devices already have stored; see https://github.com/esphome/backlog/issues/85. -/// With per_code_point set, UTF-8 continuation bytes are skipped so each multi-byte character -/// contributes one underscore — this matches Python fnv1_hash_object_id() in esphome/helpers.py, -/// which produced the hash for named entities. The per-byte form (default) matches the old -/// runtime hash for entities without their own name. Do not change either behavior. -/// Known limitation: Python's lower() is Unicode aware, so the rare code points it maps to a -/// different number of characters or to ASCII (e.g. 'İ', the Kelvin sign) reconstruct wrong; -/// such names skip migration once and fall back to their defaults. -inline uint32_t fnv1_hash_object_id(const char *str, size_t len, bool per_code_point = false) { +/// This computes object_id hashes directly from names without creating an intermediate buffer. +/// IMPORTANT: Must match Python fnv1_hash_object_id() in esphome/helpers.py. +/// If you modify this function, update the Python version and tests in both places. +inline uint32_t fnv1_hash_object_id(const char *str, size_t len) { uint32_t hash = FNV1_OFFSET_BASIS; for (size_t i = 0; i < len; i++) { - if (per_code_point && (static_cast(str[i]) & 0xC0) == 0x80) - continue; // UTF-8 continuation byte, already counted via its lead byte hash *= FNV1_PRIME; // Apply snake_case (space->underscore, uppercase->lowercase) then sanitize hash ^= static_cast(to_sanitized_char(to_snake_case_char(str[i]))); diff --git a/esphome/core/preference_backend.h b/esphome/core/preference_backend.h index 0622376fca..5df0804bdd 100644 --- a/esphome/core/preference_backend.h +++ b/esphome/core/preference_backend.h @@ -24,9 +24,10 @@ #endif // Key-lookup preference backends find stored data by key; their platforms add the -// USE_PREFERENCE_KEY_LOOKUP define from Python codegen, which enables preference key -// migration. Slot-based backends (ESP8266, RP2040) instead allocate a storage slot for -// every make_preference() call and use the key only as a validity tag on that slot; +// USE_PREFERENCE_KEY_LOOKUP define from Python codegen, which enables one-shot reads +// of stored data by key (the primitive preference key migrations need). Slot-based +// backends (ESP8266, RP2040) instead allocate a storage slot for every +// make_preference() call and use the key only as a validity tag on that slot; // migration is not possible there, and key collisions cannot corrupt data. namespace esphome { @@ -104,10 +105,9 @@ concept PreferencesContract = requires(T prefs, size_t len, uint32_t type, bool }; // Key-lookup platforms additionally provide load_from_key(), a one-shot read -// of a stored preference by key that migrate_preference() relies on; see the -// key-lookup note at the top of this file. Not part of PreferencesContract, -// so it is asserted in preferences.h only where USE_PREFERENCE_KEY_LOOKUP -// is set. +// of a stored preference by key; see the key-lookup note at the top of this +// file. Not part of PreferencesContract, so it is asserted in preferences.h +// only where USE_PREFERENCE_KEY_LOOKUP is set. template concept PreferencesKeyLookupContract = requires(T prefs, uint32_t type, uint8_t *data, size_t len) { { prefs.load_from_key(type, data, len) } -> std::same_as; diff --git a/esphome/core/preferences.cpp b/esphome/core/preferences.cpp deleted file mode 100644 index 8508647255..0000000000 --- a/esphome/core/preferences.cpp +++ /dev/null @@ -1,25 +0,0 @@ -#include "esphome/core/preferences.h" -#include "esphome/core/log.h" -#include - -namespace esphome { - -#ifdef USE_PREFERENCE_KEY_LOOKUP -static const char *const TAG = "preferences"; - -bool migrate_preference(ESPPreferenceObject &new_pref, uint8_t *scratch, size_t size, uint32_t old_key, - uint32_t new_key) { - if (new_pref.load(scratch, size)) - return true; // Current data present - never overwrite newer data with the old copy - // One-shot read by key: no backend is allocated for the old key, so boots with - // nothing to migrate (for example fresh installs) cost no heap - if (old_key == new_key || !global_preferences->load_from_key(old_key, scratch, size)) - return false; // No data stored under the old key, nothing to migrate - if (!new_pref.save(scratch, size)) { - ESP_LOGW(TAG, "Pref migration %" PRIx32 " -> %" PRIx32 " failed", old_key, new_key); - } - return true; -} -#endif // USE_PREFERENCE_KEY_LOOKUP - -} // namespace esphome diff --git a/esphome/core/preferences.h b/esphome/core/preferences.h index cfeddebda7..ed23dfae56 100644 --- a/esphome/core/preferences.h +++ b/esphome/core/preferences.h @@ -56,17 +56,5 @@ namespace esphome { static_assert(PreferencesKeyLookupContract, "This platform emits USE_PREFERENCE_KEY_LOOKUP but its preferences manager does not provide " "load_from_key() (esphome/core/preference_backend.h)"); - -/// Copy preference data stored under old_key into new_pref (created for new_key) if the keys -/// differ and new_pref has no data yet. scratch must hold at least size bytes. -/// Returns true when scratch holds the entity's current data (loaded or just migrated). -/// The old entry is intentionally left in place so a firmware downgrade still finds its data. -/// If saving under the new key fails, callers that consume scratch (like TextSaver) still get -/// valid data for this boot, callers that reload from the preference fall back to their -/// defaults, and the migration simply runs again on the next boot. -/// Only available on key-lookup preference backends; slot-based backends keep their old -/// keys instead. See: https://github.com/esphome/backlog/issues/85 -bool migrate_preference(ESPPreferenceObject &new_pref, uint8_t *scratch, size_t size, uint32_t old_key, - uint32_t new_key); } // namespace esphome #endif // USE_PREFERENCE_KEY_LOOKUP diff --git a/esphome/helpers.py b/esphome/helpers.py index 2731109164..9b2a461ccd 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -91,13 +91,8 @@ def fnv1a_32bit_hash(string: str) -> int: def fnv1_hash_object_id(name: str) -> int: """Compute FNV-1 hash of name with snake_case + sanitize transformations. - IMPORTANT: Must produce same result as C++ fnv1_hash_object_id() in helpers.h - with per_code_point set. This is the OLD entity hash; it computes preference - keys that existing devices already have stored (see - https://github.com/esphome/backlog/issues/85) and is also still used for live - keys derived from config IDs (see the motion component's calibration key). - Note: lower() here is Unicode aware while the C++ reconstruction is not; see - the known limitation note on the C++ function. + IMPORTANT: Must produce same result as C++ fnv1_hash_object_id() in helpers.h. + If you modify this function, update the C++ version and tests in both places. """ return fnv1_hash(sanitize(snake_case(name))) @@ -105,9 +100,9 @@ def fnv1_hash_object_id(name: str) -> int: def fnv1_hash_name(name: str) -> int: """Compute FNV-1 hash of the raw entity name (UTF-8 bytes, no transformations). - IMPORTANT: Must produce same result as C++ fnv1_hash_bytes() in helpers.h, - which hashes the name bytes as stored on the device. - Used for pre-computing entity keys at code generation time. + 2026.8 beta firmware stored preferences under keys derived from this hash; + a future key migration must reconstruct those keys to recover that data + (see https://github.com/esphome/backlog/issues/85). """ return _fnv1_hash(name.encode("utf-8")) diff --git a/tests/integration/entity_utils.py b/tests/integration/entity_utils.py index 95f6a0321e..7596983ee2 100644 --- a/tests/integration/entity_utils.py +++ b/tests/integration/entity_utils.py @@ -8,7 +8,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from esphome.helpers import fnv1_hash_name, sanitize, snake_case +from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case if TYPE_CHECKING: from aioesphomeapi import DeviceInfo, EntityInfo @@ -25,16 +25,15 @@ def infer_name_add_mac_suffix(device_info: DeviceInfo) -> bool: return device_info.name.endswith(f"-{mac_suffix}") -def _resolve_entity_name( +def _get_name_for_object_id( entity: EntityInfo, device_info: DeviceInfo, device_id_to_name: dict[int, str], ) -> str: - """Resolve the effective name for an entity. + """Get the name used for object_id computation. This is the algorithm that aioesphomeapi will use to determine which - name to use for computing object_id client-side from API data; the same - name is what the device hashes into the entity key. + name to use for computing object_id client-side from API data. Args: entity: The entity to get name for @@ -73,27 +72,27 @@ def compute_entity_object_id( Returns: The computed object_id string """ - name = _resolve_entity_name(entity, device_info, device_id_to_name) - return compute_object_id(name) + name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name) + return compute_object_id(name_for_id) -def compute_entity_key( +def compute_entity_hash( entity: EntityInfo, device_info: DeviceInfo, device_id_to_name: dict[int, str], ) -> int: - """Compute expected entity key for an entity. + """Compute expected object_id hash for an entity. Args: - entity: The entity to compute the key for + entity: The entity to compute hash for device_info: Device info from the API device_id_to_name: Mapping of device_id to device name for sub-devices Returns: - The computed FNV-1 hash of the raw name + The computed FNV-1 hash """ - name = _resolve_entity_name(entity, device_info, device_id_to_name) - return fnv1_hash_name(name) + name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name) + return fnv1_hash_object_id(name_for_id) def verify_entity_object_id( @@ -119,7 +118,7 @@ def verify_entity_object_id( f"expected '{expected_object_id}', got '{entity.object_id}'" ) - expected_hash = compute_entity_key(entity, device_info, device_id_to_name) + expected_hash = compute_entity_hash(entity, device_info, device_id_to_name) assert entity.key == expected_hash, ( f"hash mismatch for entity '{entity.name}': " f"expected {expected_hash:#x}, got {entity.key:#x}" diff --git a/tests/integration/fixtures/fnv1_hash_object_id.yaml b/tests/integration/fixtures/fnv1_hash_object_id.yaml index d4511bb8c6..2097b2fbf9 100644 --- a/tests/integration/fixtures/fnv1_hash_object_id.yaml +++ b/tests/integration/fixtures/fnv1_hash_object_id.yaml @@ -71,38 +71,6 @@ esphome: ESP_LOGE("FNV1_OID", "empty FAILED: 0x%08x != 0x811c9dc5", hash_empty); } - // Raw name hash: matches Python fnv1_hash_name("My Sensor Name") - uint32_t hash_raw = esphome::fnv1_hash_bytes("My Sensor Name", 14); - if (hash_raw == 0x8cec6fb0) { - ESP_LOGI("FNV1_OID", "raw PASSED"); - } else { - ESP_LOGE("FNV1_OID", "raw FAILED: 0x%08x != 0x8cec6fb0", hash_raw); - } - - // Raw name hash over UTF-8 bytes: matches Python fnv1_hash_name("Température") - uint32_t hash_raw_utf8 = esphome::fnv1_hash_bytes("Temp\xc3\xa9rature", 12); - if (hash_raw_utf8 == 0x531a74aa) { - ESP_LOGI("FNV1_OID", "raw_utf8 PASSED"); - } else { - ESP_LOGE("FNV1_OID", "raw_utf8 FAILED: 0x%08x != 0x531a74aa", hash_raw_utf8); - } - - // Old-key UTF-8 variant: matches Python fnv1_hash_object_id("Température") - uint32_t hash_old_utf8 = esphome::fnv1_hash_object_id("Temp\xc3\xa9rature", 12, true); - if (hash_old_utf8 == 0x965698f3) { - ESP_LOGI("FNV1_OID", "old_utf8 PASSED"); - } else { - ESP_LOGE("FNV1_OID", "old_utf8 FAILED: 0x%08x != 0x965698f3", hash_old_utf8); - } - - // Old-key UTF-8 variant with multi-byte only name: Python fnv1_hash_object_id("温度") - uint32_t hash_old_cjk = esphome::fnv1_hash_object_id("\xe6\xb8\xa9\xe5\xba\xa6", 6, true); - if (hash_old_cjk == 0x3276cb9f) { - ESP_LOGI("FNV1_OID", "old_cjk PASSED"); - } else { - ESP_LOGE("FNV1_OID", "old_cjk FAILED: 0x%08x != 0x3276cb9f", hash_old_cjk); - } - host: api: logger: diff --git a/tests/integration/fixtures/multi_device_preferences.yaml b/tests/integration/fixtures/multi_device_preferences.yaml index 582add90a8..01e4394559 100644 --- a/tests/integration/fixtures/multi_device_preferences.yaml +++ b/tests/integration/fixtures/multi_device_preferences.yaml @@ -156,17 +156,10 @@ button: ESP_LOGI("test", "Device A Mode: %s", id(mode_device_a).current_option().c_str()); ESP_LOGI("test", "Device B Mode: %s", id(mode_device_b).current_option().c_str()); ESP_LOGI("test", "Main Mode: %s", id(mode_main).current_option().c_str()); - // Log preference key bases for entities that actually store preferences. - // This is the key base make_entity_preference() uses: entity key XOR device id. - ESP_LOGI("test", "Device A Switch Pref Hash: %u", - id(light_device_a).get_entity_key() ^ id(light_device_a).get_device_id_or_zero()); - ESP_LOGI("test", "Device B Switch Pref Hash: %u", - id(light_device_b).get_entity_key() ^ id(light_device_b).get_device_id_or_zero()); - ESP_LOGI("test", "Main Switch Pref Hash: %u", - id(light_main).get_entity_key() ^ id(light_main).get_device_id_or_zero()); - ESP_LOGI("test", "Device A Number Pref Hash: %u", - id(setpoint_device_a).get_entity_key() ^ id(setpoint_device_a).get_device_id_or_zero()); - ESP_LOGI("test", "Device B Number Pref Hash: %u", - id(setpoint_device_b).get_entity_key() ^ id(setpoint_device_b).get_device_id_or_zero()); - ESP_LOGI("test", "Main Number Pref Hash: %u", - id(setpoint_main).get_entity_key() ^ id(setpoint_main).get_device_id_or_zero()); + // Log preference hashes for entities that actually store preferences + ESP_LOGI("test", "Device A Switch Pref Hash: %u", id(light_device_a).get_preference_hash()); + ESP_LOGI("test", "Device B Switch Pref Hash: %u", id(light_device_b).get_preference_hash()); + ESP_LOGI("test", "Main Switch Pref Hash: %u", id(light_main).get_preference_hash()); + ESP_LOGI("test", "Device A Number Pref Hash: %u", id(setpoint_device_a).get_preference_hash()); + ESP_LOGI("test", "Device B Number Pref Hash: %u", id(setpoint_device_b).get_preference_hash()); + ESP_LOGI("test", "Main Number Pref Hash: %u", id(setpoint_main).get_preference_hash()); diff --git a/tests/integration/fixtures/preference_key_migration.yaml b/tests/integration/fixtures/preference_key_migration.yaml deleted file mode 100644 index a9b01fc2d2..0000000000 --- a/tests/integration/fixtures/preference_key_migration.yaml +++ /dev/null @@ -1,35 +0,0 @@ -esphome: - name: host-pref-key-migration - -host: -api: -logger: - -switch: - - platform: template - id: test_switch_restore - name: Test Switch - optimistic: true - restore_mode: RESTORE_DEFAULT_OFF - -number: - - platform: template - id: test_number_restore - name: Test Number - optimistic: true - restore_value: true - initial_value: 1.0 - min_value: 0 - max_value: 100 - step: 0.5 - -text: - - platform: template - id: test_text_restore - name: Test Text - mode: text - optimistic: true - restore_value: true - initial_value: fallback - min_length: 0 - max_length: 20 diff --git a/tests/integration/host_prefs.py b/tests/integration/host_prefs.py index c7f21d8a01..f835bee3bc 100644 --- a/tests/integration/host_prefs.py +++ b/tests/integration/host_prefs.py @@ -25,25 +25,15 @@ def clear_host_prefs(device_name: str) -> None: host_prefs_path(device_name).unlink(missing_ok=True) -def write_host_prefs(device_name: str, entries: dict[int, bytes]) -> Path: - """Write preference entries, replacing the file's contents. - - Returns the path that was written. - """ - payload = b"" - for key, data in entries.items(): - if len(data) > 255: - raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)") - payload += struct.pack(" Path: """Write a single preference entry, replacing the file's contents. Returns the path that was written. """ - return write_host_prefs(device_name, {key: data}) + if len(data) > 255: + raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)") + path = host_prefs_path(device_name) + path.parent.mkdir(parents=True, exist_ok=True) + payload = struct.pack(" None: diff --git a/tests/integration/test_object_id_api_verification.py b/tests/integration/test_object_id_api_verification.py index 8dafb37c64..c8603e0682 100644 --- a/tests/integration/test_object_id_api_verification.py +++ b/tests/integration/test_object_id_api_verification.py @@ -2,8 +2,8 @@ This test verifies a three-way match between: 1. C++ object_id generation (get_object_id_to using to_sanitized_char/to_snake_case_char) -2. C++ entity key generation (fnv1_hash of the raw name in helpers.h) -3. Python computation (sanitize/snake_case and fnv1_hash_name in helpers.py) +2. C++ hash generation (fnv1_hash_object_id in helpers.h) +3. Python computation (sanitize/snake_case in helpers.py, fnv1_hash_object_id) The API response contains C++ computed values, so verifying API == Python implicitly verifies C++ == Python == API for both object_id and hash. @@ -25,7 +25,7 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_name +from esphome.helpers import fnv1_hash_object_id from .entity_utils import compute_object_id, verify_all_entities from .types import APIClientConnectedFactory, RunCompiledFunction @@ -123,7 +123,7 @@ async def test_object_id_api_verification( ) # Verify hash can be computed from the name - hash_from_name = fnv1_hash_name(entity_name) + hash_from_name = fnv1_hash_object_id(entity_name) assert hash_from_name == entity.key, ( f"Entity '{entity_name}': hash mismatch. " f"Python hash {hash_from_name:#x}, API key {entity.key:#x}" @@ -164,7 +164,7 @@ async def test_object_id_api_verification( ) # Verify hash matches - expected_hash = fnv1_hash_name(expected_name) + expected_hash = fnv1_hash_object_id(expected_name) assert entity.key == expected_hash, ( f"Empty-name entity (device_id={entity.device_id}): hash mismatch. " f"API key: {entity.key:#x}, expected: {expected_hash:#x}" diff --git a/tests/integration/test_object_id_friendly_name_no_mac_suffix.py b/tests/integration/test_object_id_friendly_name_no_mac_suffix.py index b58593f2ef..7199a2b371 100644 --- a/tests/integration/test_object_id_friendly_name_no_mac_suffix.py +++ b/tests/integration/test_object_id_friendly_name_no_mac_suffix.py @@ -11,7 +11,7 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_name +from esphome.helpers import fnv1_hash_object_id from .entity_utils import ( compute_object_id, @@ -62,7 +62,7 @@ async def test_object_id_friendly_name_no_mac_suffix( ) # Hash should match friendly_name - expected_hash = fnv1_hash_name("My Friendly Device") + expected_hash = fnv1_hash_object_id("My Friendly Device") assert entity.key == expected_hash, ( f"Expected hash {expected_hash:#x}, got {entity.key:#x}" ) diff --git a/tests/integration/test_object_id_no_friendly_name.py b/tests/integration/test_object_id_no_friendly_name.py index 45b5f730a6..b548f02fde 100644 --- a/tests/integration/test_object_id_no_friendly_name.py +++ b/tests/integration/test_object_id_no_friendly_name.py @@ -17,7 +17,7 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_name +from esphome.helpers import fnv1_hash_object_id from .entity_utils import compute_object_id, verify_all_entities from .types import APIClientConnectedFactory, RunCompiledFunction @@ -96,7 +96,7 @@ async def test_object_id_no_friendly_name_no_mac_suffix( OLD behavior: - is_object_id_dynamic_() returned false (mac suffix not enabled) - Used object_id_c_str_ which was pre-computed in Python - - Python used get_base_entity_name() with fallback to CORE.name + - Python used get_base_entity_object_id() with fallback to CORE.name Result: object_id = sanitize(snake_case(device_name)) """ @@ -126,7 +126,7 @@ async def test_object_id_no_friendly_name_no_mac_suffix( ) # Hash should match device name - expected_hash = fnv1_hash_name("test-device") + expected_hash = fnv1_hash_object_id("test-device") assert entity.key == expected_hash, ( f"Expected hash {expected_hash:#x}, got {entity.key:#x}" ) diff --git a/tests/integration/test_preference_key_migration.py b/tests/integration/test_preference_key_migration.py deleted file mode 100644 index e7f699bb12..0000000000 --- a/tests/integration/test_preference_key_migration.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Integration test for entity preference key migration. - -Entity keys are now the FNV-1 hash of the raw name instead of the sanitized -object_id (https://github.com/esphome/backlog/issues/85). On key-lookup -preference backends, make_entity_preference() must move data stored under the -old key to the new key, so devices keep their restored state after upgrading. - -This test seeds the host preferences file the way a pre-migration firmware -would have written it and verifies: -1. Data stored under the OLD key is restored (migration happened, no data loss) -2. Data already stored under the NEW key is never overwritten by old data -""" - -from __future__ import annotations - -import socket -import struct - -from aioesphomeapi import ( - NumberInfo, - NumberState, - SwitchInfo, - SwitchState, - TextInfo, - TextState, -) -import pytest - -from esphome.helpers import fnv1_hash, fnv1_hash_name, fnv1_hash_object_id - -from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client -from .host_prefs import clear_host_prefs, write_host_prefs -from .state_utils import InitialStateHelper, require_entity -from .types import CompileFunction, ConfigWriter - -DEVICE_NAME = "host-pref-key-migration" - -# The pre-migration preference key was the sanitized object_id hash; the new -# key is the raw-name hash. All entities are on the main device (device_id 0) -# and their preferences use no version salt, so the key is just the hash. -SWITCH_OLD_KEY = fnv1_hash_object_id("Test Switch") -SWITCH_NEW_KEY = fnv1_hash_name("Test Switch") -NUMBER_OLD_KEY = fnv1_hash_object_id("Test Number") -NUMBER_NEW_KEY = fnv1_hash_name("Test Number") - -# template_text salts its key with the length limits and pattern hash; this must -# match TemplateText::setup() in template_text.cpp (min_length 0, max_length 20, -# no pattern configured) -TEXT_KEY_EXTRA = (0 << 2) + (20 << 4) + (fnv1_hash("") << 6) -TEXT_OLD_KEY = (fnv1_hash_object_id("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF -TEXT_NEW_KEY = (fnv1_hash_name("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF - -# TextSaver<20> stores a length-prefixed buffer of max_length + 1 bytes -TEXT_MAX_LENGTH = 20 - - -def text_pref_payload(value: str) -> bytes: - """Build the length-prefixed buffer TextSaver stores for a value.""" - data = value.encode("utf-8") - assert len(data) <= TEXT_MAX_LENGTH - return bytes([len(data)]) + data + b"\x00" * (TEXT_MAX_LENGTH - len(data)) - - -@pytest.mark.asyncio -async def test_preference_key_migration( - yaml_config: str, - write_yaml_config: ConfigWriter, - compile_esphome: CompileFunction, - reserved_tcp_port: tuple[int, socket.socket], -) -> None: - """Test that preferences stored under the old key survive the upgrade.""" - port, port_socket = reserved_tcp_port - - assert SWITCH_OLD_KEY != SWITCH_NEW_KEY - assert NUMBER_OLD_KEY != NUMBER_NEW_KEY - assert TEXT_OLD_KEY != TEXT_NEW_KEY - - # Write and compile once - config_path = await write_yaml_config(yaml_config) - binary_path = await compile_esphome(config_path) - - # Release the reserved port so the binary can bind to it - port_socket.close() - - async def boot_and_get_initial_states() -> tuple[ - SwitchState, NumberState, TextState - ]: - """Boot the binary and return the restored entity states.""" - async with ( - run_binary_and_wait_for_port(binary_path, "127.0.0.1", port), - wait_and_connect_api_client(port=port) as client, - ): - device_info = await client.device_info() - assert device_info.name == DEVICE_NAME - - entities, _ = await client.list_entities_services() - switch_entity = require_entity( - entities, "test_switch", SwitchInfo, "Test Switch" - ) - number_entity = require_entity( - entities, "test_number", NumberInfo, "Test Number" - ) - text_entity = require_entity(entities, "test_text", TextInfo, "Test Text") - - initial_state_helper = InitialStateHelper(entities) - client.subscribe_states( - initial_state_helper.on_state_wrapper(lambda s: None) - ) - await initial_state_helper.wait_for_initial_states() - - switch_state = initial_state_helper.initial_states[switch_entity.key] - number_state = initial_state_helper.initial_states[number_entity.key] - text_state = initial_state_helper.initial_states[text_entity.key] - assert isinstance(switch_state, SwitchState) - assert isinstance(number_state, NumberState) - assert isinstance(text_state, TextState) - return switch_state, number_state, text_state - - try: - # --- Run 1: only OLD keys present, as written by pre-migration firmware. - # The restored states prove the data was migrated to the new keys. - write_host_prefs( - DEVICE_NAME, - { - SWITCH_OLD_KEY: b"\x01", # bool: switch was ON - NUMBER_OLD_KEY: struct.pack(" None: - """Verify _COMMAND_TOPIC_PLATFORMS matches the MQTT components that subscribe. - - Drift silently reintroduces shared subscribe topics, so this derives the set - from the C++ components that actually call subscribe(); that also catches - platforms like text that subscribe a command topic without exposing a - command_topic key in their schema. - """ - expected: set[str] = set() - for path in (COMPONENTS_DIR / "mqtt").glob("mqtt_*.cpp"): - if path.stem in _NON_ENTITY_MQTT_SOURCES: - continue - if "this->subscribe" not in path.read_text(encoding="utf-8"): - continue - stem = path.stem.removeprefix("mqtt_") - expected.add("datetime" if stem in _DATETIME_STEMS else stem) - assert expected == _COMMAND_TOPIC_PLATFORMS - - -def test_sub_topic_platforms_in_sync() -> None: - """Verify _SUB_TOPIC_PLATFORMS matches the MQTT components with sub-topics. - - Platforms whose MQTT headers use MQTT_COMPONENT_CUSTOM_TOPIC derive extra - topics such as position/command from the object_id. - """ - expected = { - path.stem.removeprefix("mqtt_") - for path in (COMPONENTS_DIR / "mqtt").glob("mqtt_*.h") - if path.stem != "mqtt_component" - and "MQTT_COMPONENT_CUSTOM_TOPIC" in path.read_text(encoding="utf-8") - } - assert expected == _SUB_TOPIC_PLATFORMS - - -def test_conflict_filter_exempts_custom_topics() -> None: - """Test that custom state topics with discovery off avoid the conflict.""" - validator = entity_duplicate_validator("sensor") - # Both entities have custom state topics and discovery disabled per entity, - # so no object_id-derived MQTT topic is used - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"} - assert component_validator(config) is config - - # Without the filter the same conflicts are fatal - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - validate_no_object_id_conflicts(REASON)({}) - - -def test_conflict_on_default_command_topic() -> None: - """Test that commandable platforms conflict through their default command topic. - - Custom state topics with discovery off are not enough for platforms that also - subscribe to an object_id-derived command topic. - """ - validator = entity_duplicate_validator("switch") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - mqtt_config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"} - # Both switches share the default command topic: rejected - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - component_validator(mqtt_config) - - # With custom command topics as well, nothing derives from the object_id - CORE.reset() - validator = entity_duplicate_validator("switch") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_COMMAND_TOPIC: "custom/cmd/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_COMMAND_TOPIC: "custom/cmd/b", - CONF_DISCOVERY: False, - } - ) - assert component_validator(mqtt_config) is mqtt_config - - -def test_conflict_on_sub_topic_platforms() -> None: - """Test that platforms with extra object_id sub-topics always conflict. - - Covers derive topics like position/command from the object_id through their - own config keys, so custom state and command topics cannot exempt them. - """ - validator = entity_duplicate_validator("cover") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_COMMAND_TOPIC: "custom/cmd/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_COMMAND_TOPIC: "custom/cmd/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - component_validator({CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"}) - - -def test_no_conflict_on_disjoint_default_topics() -> None: - """Test that entities whose default topics are disjoint do not conflict. - - One entity uses only the default command topic and the other only the default - state topic, so they never share a topic. - """ - validator = entity_duplicate_validator("switch") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_COMMAND_TOPIC: "custom/cmd/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"} - assert component_validator(config) is config - - -def test_no_conflict_on_empty_topic_prefix() -> None: - """Test that an empty topic_prefix disables the default topic conflict. - - With topic_prefix set to null no default topics exist at runtime, so entities - without custom state topics cannot conflict; only discovery still matters. - """ - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Датчик открытия"}) - validator({CONF_NAME: "Датчик закрытия"}) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - # No default topics and no discovery: valid - config: dict = {CONF_DISCOVERY: False, CONF_TOPIC_PREFIX: ""} - assert component_validator(config) is config - - # Discovery still uses object_id-derived config topics: rejected - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - component_validator({CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: ""}) diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 64400c4fd4..53035ad713 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -1,4 +1,4 @@ -"""Tests for entity helpers: name selection, entity key hashing, duplicate checks.""" +"""Test get_base_entity_object_id function matches C++ behavior.""" from collections.abc import Callable, Generator from pathlib import Path @@ -25,17 +25,16 @@ from esphome.core.entity_helpers import ( _setup_entity_impl, entity_duplicate_validator, finalize_entity_strings, - get_base_entity_name, + get_base_entity_object_id, register_device_class, register_icon, register_unit_of_measurement, setup_device_class, setup_entity, setup_unit_of_measurement, - validate_no_object_id_conflicts, ) from esphome.cpp_generator import MockObj -from esphome.helpers import fnv1_hash_name, sanitize, snake_case +from esphome.helpers import fnv1_hash, sanitize, snake_case from .common import load_config_from_fixture @@ -58,26 +57,206 @@ def restore_core_state() -> Generator[None, None, None]: CORE.friendly_name = original_friendly_name -def test_get_base_entity_name_priority_order() -> None: +def test_with_entity_name() -> None: + """Test when entity has its own name - should use entity name.""" + # Simple name + assert get_base_entity_object_id("Temperature Sensor", None) == "temperature_sensor" + assert ( + get_base_entity_object_id("Temperature Sensor", "Device Name") + == "temperature_sensor" + ) + # Even with device name, entity name takes precedence + assert ( + get_base_entity_object_id("Temperature Sensor", "Device Name", "Sub Device") + == "temperature_sensor" + ) + + # Name with special characters + assert ( + get_base_entity_object_id("Temp!@#$%^&*()Sensor", None) + == "temp__________sensor" + ) + assert get_base_entity_object_id("Temp-Sensor_123", None) == "temp-sensor_123" + + # Already snake_case + assert get_base_entity_object_id("temperature_sensor", None) == "temperature_sensor" + + # Mixed case + assert get_base_entity_object_id("TemperatureSensor", None) == "temperaturesensor" + assert get_base_entity_object_id("TEMPERATURE SENSOR", None) == "temperature_sensor" + + +def test_empty_name_with_device_name() -> None: + """Test when entity has empty name and is on a sub-device - should use device name.""" + # C++ behavior: when has_own_name is false and device is set, uses device->get_name() + assert ( + get_base_entity_object_id("", "Friendly Device", "Sub Device 1") + == "sub_device_1" + ) + assert ( + get_base_entity_object_id("", "Kitchen Controller", "controller_1") + == "controller_1" + ) + assert get_base_entity_object_id("", None, "Test-Device_123") == "test-device_123" + + +def test_empty_name_with_friendly_name() -> None: + """Test when entity has empty name and no device - should use friendly name.""" + # C++ behavior: when has_own_name is false, uses App.get_friendly_name() + assert get_base_entity_object_id("", "Friendly Device") == "friendly_device" + assert get_base_entity_object_id("", "Kitchen Controller") == "kitchen_controller" + assert get_base_entity_object_id("", "Test-Device_123") == "test-device_123" + + # Special characters in friendly name + assert get_base_entity_object_id("", "Device!@#$%") == "device_____" + + +def test_empty_name_no_friendly_name() -> None: + """Test when entity has empty name and no friendly name - should use device name.""" + # Test with CORE.name set + CORE.name = "device-name" + assert get_base_entity_object_id("", None) == "device-name" + + CORE.name = "Test Device" + assert get_base_entity_object_id("", None) == "test_device" + + +def test_edge_cases() -> None: + """Test edge cases.""" + # Only spaces + assert get_base_entity_object_id(" ", None) == "___" + + # Unicode characters (should be replaced) + assert get_base_entity_object_id("Température", None) == "temp_rature" + assert get_base_entity_object_id("测试", None) == "__" + + # Empty string with empty friendly name (empty friendly name is treated as None) + # Falls back to CORE.name + CORE.name = "device" + assert get_base_entity_object_id("", "") == "device" + + # Very long name (should work fine) + long_name = "a" * 100 + " " + "b" * 100 + expected = "a" * 100 + "_" + "b" * 100 + assert get_base_entity_object_id(long_name, None) == expected + + +@pytest.mark.parametrize( + ("name", "expected"), + [ + ("Temperature Sensor", "temperature_sensor"), + ("Living Room Light", "living_room_light"), + ("Test-Device_123", "test-device_123"), + ("Special!@#Chars", "special___chars"), + ("UPPERCASE NAME", "uppercase_name"), + ("lowercase name", "lowercase_name"), + ("Mixed Case Name", "mixed_case_name"), + (" Spaces ", "___spaces___"), + ], +) +def test_matches_cpp_helpers(name: str, expected: str) -> None: + """Test that the logic matches using snake_case and sanitize directly.""" + # For non-empty names, verify our function produces same result as direct snake_case + sanitize + assert get_base_entity_object_id(name, None) == sanitize(snake_case(name)) + assert get_base_entity_object_id(name, None) == expected + + +def test_empty_name_fallback() -> None: + """Test empty name handling which falls back to friendly_name or CORE.name.""" + # Empty name is handled specially - it doesn't just use sanitize(snake_case("")) + # Instead it falls back to friendly_name or CORE.name + assert sanitize(snake_case("")) == "" # Direct conversion gives empty string + # But our function returns a fallback + CORE.name = "device" + assert get_base_entity_object_id("", None) == "device" # Uses device name + + +def test_name_add_mac_suffix_behavior() -> None: + """Test behavior related to name_add_mac_suffix. + + In C++, an entity's object_id is computed from its name_ via + write_object_id_to() (sanitized snake_case). When an entity has no name, + configure_entity_() sets name_ from the friendly name, with the MAC suffix + appended when name_add_mac_suffix is enabled. Our function always returns + the same result since we're calculating the base for duplicate tracking. + """ + # The function should always return the same result regardless of + # name_add_mac_suffix setting, as we're calculating the base object_id + assert get_base_entity_object_id("", "Test Device") == "test_device" + assert get_base_entity_object_id("Entity Name", "Test Device") == "entity_name" + + +def test_priority_order() -> None: """Test the priority order: entity name > device name > friendly name > CORE.name.""" CORE.name = "core-device" - # 1. Entity name has highest priority and is used as-is, no transformations + # 1. Entity name has highest priority assert ( - get_base_entity_name("Entity Name", "Friendly Name", "Device Name") - == "Entity Name" + get_base_entity_object_id("Entity Name", "Friendly Name", "Device Name") + == "entity_name" ) - assert get_base_entity_name("Température", None) == "Température" # 2. Device name is next priority (when entity name is empty) - assert get_base_entity_name("", "Friendly Name", "Device Name") == "Device Name" + assert ( + get_base_entity_object_id("", "Friendly Name", "Device Name") == "device_name" + ) # 3. Friendly name is next (when entity and device names are empty) - assert get_base_entity_name("", "Friendly Name", None) == "Friendly Name" + assert get_base_entity_object_id("", "Friendly Name", None) == "friendly_name" - # 4. CORE.name is last resort; an empty friendly name falls through to it - assert get_base_entity_name("", None, None) == "core-device" - assert get_base_entity_name("", "") == "core-device" + # 4. CORE.name is last resort + assert get_base_entity_object_id("", None, None) == "core-device" + + +@pytest.mark.parametrize( + ("name", "friendly_name", "device_name", "expected"), + [ + # name, friendly_name, device_name, expected + ("Living Room Light", None, None, "living_room_light"), + ("", "Kitchen Controller", None, "kitchen_controller"), + ( + "", + "ESP32 Device", + "controller_1", + "controller_1", + ), # Device name takes precedence + ("GPIO2 Button", None, None, "gpio2_button"), + ("WiFi Signal", "My Device", None, "wifi_signal"), + ("", None, "esp32_node", "esp32_node"), + ("Front Door Sensor", "Home Assistant", "door_controller", "front_door_sensor"), + ], +) +def test_real_world_examples( + name: str, friendly_name: str | None, device_name: str | None, expected: str +) -> None: + """Test real-world entity naming scenarios.""" + result = get_base_entity_object_id(name, friendly_name, device_name) + assert result == expected + + +def test_issue_6953_scenarios() -> None: + """Test specific scenarios from issue #6953.""" + # Scenario 1: Multiple empty names on main device with name_add_mac_suffix + # The Python code calculates the base, C++ might append MAC suffix dynamically + CORE.name = "device-name" + CORE.friendly_name = "Friendly Device" + + # All empty names should resolve to same base + assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" + assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" + assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" + + # Scenario 2: Empty names on sub-devices + assert ( + get_base_entity_object_id("", "Main Device", "controller_1") == "controller_1" + ) + assert ( + get_base_entity_object_id("", "Main Device", "controller_2") == "controller_2" + ) + + # Scenario 3: xyz duplicates + assert get_base_entity_object_id("xyz", None) == "xyz" + assert get_base_entity_object_id("xyz", "Device") == "xyz" # Tests for setup_entity function @@ -336,10 +515,9 @@ def test_entity_duplicate_validator() -> None: config1 = {CONF_NAME: "Temperature"} validated1 = validator(config1) assert validated1 == config1 - temperature_key = ("", "sensor", fnv1_hash_name("Temperature")) - assert temperature_key in CORE.unique_ids + assert ("", "sensor", fnv1_hash("temperature")) in CORE.unique_ids # Check metadata was stored - metadata = CORE.unique_ids[temperature_key] + metadata = CORE.unique_ids[("", "sensor", fnv1_hash("temperature"))] assert metadata["name"] == "Temperature" assert metadata["platform"] == "sensor" @@ -347,9 +525,8 @@ def test_entity_duplicate_validator() -> None: config2 = {CONF_NAME: "Humidity"} validated2 = validator(config2) assert validated2 == config2 - humidity_key = ("", "sensor", fnv1_hash_name("Humidity")) - assert humidity_key in CORE.unique_ids - metadata2 = CORE.unique_ids[humidity_key] + assert ("", "sensor", fnv1_hash("humidity")) in CORE.unique_ids + metadata2 = CORE.unique_ids[("", "sensor", fnv1_hash("humidity"))] assert metadata2["name"] == "Humidity" # Duplicate entity should fail @@ -360,6 +537,34 @@ def test_entity_duplicate_validator() -> None: validator(config3) +def test_entity_duplicate_validator_hash_collision() -> None: + """Test that two different object_ids with the same FNV-1 hash are rejected.""" + # Brute-forced FNV-1 32-bit collision pair; both object_ids hash to 0xe95747e4 + name_a = "Sensor aooxzi" + name_b = "Sensor baraia" + object_id_a = sanitize(snake_case(name_a)) + object_id_b = sanitize(snake_case(name_b)) + assert object_id_a != object_id_b + assert fnv1_hash(object_id_a) == fnv1_hash(object_id_b) + + validator = entity_duplicate_validator("sensor") + + config1 = {CONF_NAME: name_a} + validated1 = validator(config1) + assert validated1 == config1 + + config2 = {CONF_NAME: name_b} + with pytest.raises( + Invalid, + match=re.compile( + r"Duplicate sensor entity with name 'Sensor baraia' found.*" + r"produce the same entity key hash \(0xe95747e4\)", + re.DOTALL, + ), + ): + validator(config2) + + def test_entity_duplicate_validator_with_devices() -> None: """Test entity_duplicate_validator with devices.""" # Create validator for sensor platform @@ -370,19 +575,18 @@ def test_entity_duplicate_validator_with_devices() -> None: device2 = ID("device2", type="Device") # Same name on different devices should pass - name_hash = fnv1_hash_name("Temperature") config1 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1} validated1 = validator(config1) assert validated1 == config1 - assert ("device1", "sensor", name_hash) in CORE.unique_ids - metadata1 = CORE.unique_ids[("device1", "sensor", name_hash)] + assert ("device1", "sensor", fnv1_hash("temperature")) in CORE.unique_ids + metadata1 = CORE.unique_ids[("device1", "sensor", fnv1_hash("temperature"))] assert metadata1["device_id"] == "device1" config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device2} validated2 = validator(config2) assert validated2 == config2 - assert ("device2", "sensor", name_hash) in CORE.unique_ids - metadata2 = CORE.unique_ids[("device2", "sensor", name_hash)] + assert ("device2", "sensor", fnv1_hash("temperature")) in CORE.unique_ids + metadata2 = CORE.unique_ids[("device2", "sensor", fnv1_hash("temperature"))] assert metadata2["device_id"] == "device2" # Duplicate on same device should fail @@ -434,33 +638,6 @@ def test_entity_different_platforms_yaml_validation( assert result is not None -def test_object_id_conflict_mqtt_yaml_validation( - yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] -) -> None: - """Test that names sanitizing to the same object_id fail when mqtt is configured.""" - result = load_config_from_fixture( - yaml_file, "object_id_conflict_mqtt.yaml", FIXTURES_DIR - ) - assert result is None - - captured = capsys.readouterr() - assert ( - "mqtt builds default topics and discovery topics from the entity object_id" - in captured.out - ) - - -def test_object_id_conflict_without_mqtt_yaml_validation( - yaml_file: Callable[[str], str], -) -> None: - """Test that names sanitizing to the same object_id pass without mqtt/prometheus.""" - result = load_config_from_fixture( - yaml_file, "object_id_conflict_no_mqtt.yaml", FIXTURES_DIR - ) - # This should succeed - assert result is not None - - def test_entity_duplicate_validator_error_message() -> None: """Test that duplicate entity error messages include helpful metadata.""" # Create validator for sensor platform @@ -519,8 +696,7 @@ def test_entity_duplicate_validator_internal_entities() -> None: validated1 = validator(config1) assert validated1 == config1 # New format includes device_id (empty string for main device) - temperature_key = ("", "sensor", fnv1_hash_name("Temperature")) - assert temperature_key in CORE.unique_ids + assert ("", "sensor", fnv1_hash("temperature")) in CORE.unique_ids # Internal entity with same name should pass (not added to unique_ids) config2 = {CONF_NAME: "Temperature", CONF_INTERNAL: True} @@ -528,7 +704,9 @@ def test_entity_duplicate_validator_internal_entities() -> None: assert validated2 == config2 # Internal entity should not be added to unique_ids # Count how many times the key appears (should still be 1) - count = sum(1 for k in CORE.unique_ids if k == temperature_key) + count = sum( + 1 for k in CORE.unique_ids if k == ("", "sensor", fnv1_hash("temperature")) + ) assert count == 1 # Another internal entity with same name should also pass @@ -536,7 +714,9 @@ def test_entity_duplicate_validator_internal_entities() -> None: validated3 = validator(config3) assert validated3 == config3 # Still only one entry in unique_ids (from the non-internal entity) - count = sum(1 for k in CORE.unique_ids if k == temperature_key) + count = sum( + 1 for k in CORE.unique_ids if k == ("", "sensor", fnv1_hash("temperature")) + ) assert count == 1 # Non-internal entity with same name should fail @@ -564,148 +744,30 @@ def test_empty_or_null_device_id_on_entity() -> None: def test_entity_duplicate_validator_non_ascii_names() -> None: - """Test that distinct non-ASCII names no longer collide. - - These names used to be rejected because both sanitize to only underscores; - the entity key now hashes the raw name so they stay distinct. - """ + """Test that non-ASCII names show helpful error messages.""" # Create validator for binary_sensor platform validator = entity_duplicate_validator("binary_sensor") - # Both Russian sensors should pass even though they sanitize identically + # First Russian sensor should pass config1 = {CONF_NAME: "Датчик открытия основного крана"} validated1 = validator(config1) assert validated1 == config1 + # Second Russian sensor with different text but same ASCII conversion should fail config2 = {CONF_NAME: "Датчик закрытия основного крана"} - validated2 = validator(config2) - assert validated2 == config2 - - # An exact duplicate still fails - config3 = {CONF_NAME: "Датчик открытия основного крана"} - with pytest.raises( - Invalid, - match=r"Duplicate binary_sensor entity with name 'Датчик открытия основного крана' found", - ): - validator(config3) - - -def test_entity_duplicate_validator_hash_collision() -> None: - """Test that two different names with the same FNV-1 hash are rejected.""" - # Brute-forced FNV-1 32-bit collision pair; both hash to 0x0ee5ff7b - name_a = "Sensor m2CZ" - name_b = "Sensor qCaa" - assert name_a != name_b - assert fnv1_hash_name(name_a) == fnv1_hash_name(name_b) - - validator = entity_duplicate_validator("sensor") - - config1 = {CONF_NAME: name_a} - validated1 = validator(config1) - assert validated1 == config1 - - config2 = {CONF_NAME: name_b} with pytest.raises( Invalid, match=re.compile( - rf"Duplicate sensor entity with name '{name_b}' found.*" - rf"The names '{name_b}' and '{name_a}' produce the.*" - r"same entity key hash \(0x0ee5ff7b\).*" - r"To fix: Rename one of the entities", + r"Duplicate binary_sensor entity with name 'Датчик закрытия основного крана' found.*" + r"Original names: 'Датчик закрытия основного крана' and 'Датчик открытия основного крана'.*" + r"Both convert to ASCII ID: '_______________________________'.*" + r"To fix: Add unique ASCII characters \(e\.g\., '1', '2', or 'A', 'B'\)", re.DOTALL, ), ): validator(config2) -def test_object_id_conflicts_rejected_by_component_validator() -> None: - """Test that object_id conflicts pass entity validation but fail for mqtt/prometheus.""" - validator = entity_duplicate_validator("sensor") - - # Both names validate fine in general (distinct raw names, distinct keys) - validator({CONF_NAME: "Датчик открытия"}) - validator({CONF_NAME: "Датчик закрытия"}) - - # A component that addresses entities by object_id must reject the config - component_validator = validate_no_object_id_conflicts( - "mqtt builds default topics from the entity object_id" - ) - with pytest.raises( - Invalid, - match=re.compile( - r"mqtt builds default topics from the entity object_id.*" - r"sensor entities 'Датчик открытия', 'Датчик закрытия' " - r"share the object_id '_______________'.*" - r"To fix: Add unique ASCII characters", - re.DOTALL, - ), - ): - component_validator({}) - - -def test_object_id_conflicts_skipped_in_testing_mode() -> None: - """Test that testing_mode skips the conflict check, as used for grouped testing.""" - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Датчик открытия"}) - validator({CONF_NAME: "Датчик закрытия"}) - - component_validator = validate_no_object_id_conflicts( - "mqtt builds default topics from the entity object_id" - ) - CORE.testing_mode = True - try: - config: dict = {} - assert component_validator(config) is config - finally: - CORE.testing_mode = False - - -def test_object_id_conflicts_none_recorded() -> None: - """Test that distinct object_ids produce no conflicts.""" - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Temperature"}) - validator({CONF_NAME: "Humidity"}) - - component_validator = validate_no_object_id_conflicts( - "mqtt builds default topics from the entity object_id" - ) - config: dict = {} - assert component_validator(config) is config - - -def test_object_id_conflicts_device_scoped() -> None: - """Test that the object_id conflict check is scoped per device. - - Same-named entities on different sub-devices were accepted before entity keys - moved to raw names, so the check keeps that scope; conflicts within one device - are still reported with the device named in the message. - """ - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Temperature", CONF_DEVICE_ID: ID("device1", type="Device")}) - validator({CONF_NAME: "Temperature", CONF_DEVICE_ID: ID("device2", type="Device")}) - - component_validator = validate_no_object_id_conflicts( - "prometheus builds metric labels from the entity object_id" - ) - config: dict = {} - assert component_validator(config) is config - - # Two names sanitizing identically on the same sub-device still conflict - validator( - {CONF_NAME: "Датчик открытия", CONF_DEVICE_ID: ID("device1", type="Device")} - ) - validator( - {CONF_NAME: "Датчик закрытия", CONF_DEVICE_ID: ID("device1", type="Device")} - ) - with pytest.raises( - Invalid, - match=re.compile( - r"prometheus builds metric labels.*on device 'device1'", re.DOTALL - ), - ): - component_validator({}) - - def test_entity_duplicate_validator_same_name_no_enhanced_message() -> None: """Test that identical names don't show the enhanced message.""" # Create validator for sensor platform @@ -763,7 +825,7 @@ async def test_setup_entity_empty_name_with_device( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 @pytest.mark.asyncio @@ -792,7 +854,7 @@ async def test_setup_entity_empty_name_with_mac_suffix( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 @pytest.mark.asyncio @@ -822,7 +884,7 @@ async def test_setup_entity_empty_name_with_mac_suffix_no_friendly_name( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 @pytest.mark.asyncio @@ -853,7 +915,7 @@ async def test_setup_entity_empty_name_no_mac_suffix_no_friendly_name( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 def test_register_string_overflow() -> None: diff --git a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml b/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml deleted file mode 100644 index 4a6f56f473..0000000000 --- a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml +++ /dev/null @@ -1,22 +0,0 @@ -esphome: - name: test-object-id-conflict - -esp32: - board: esp32dev - -wifi: - ssid: MySSID - password: password1 - -mqtt: - broker: test.mosquitto.org - -sensor: - # Distinct raw names are fine in general, but both sanitize to the same - # object_id, which MQTT still uses to build default topics - should fail - - platform: template - name: "Датчик открытия" - lambda: return 21.0; - - platform: template - name: "Датчик закрытия" - lambda: return 22.0; diff --git a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml b/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml deleted file mode 100644 index c0fbd5cbba..0000000000 --- a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml +++ /dev/null @@ -1,15 +0,0 @@ -esphome: - name: test-object-id-ok - -esp32: - board: esp32dev - -sensor: - # Distinct raw names that sanitize to the same object_id are allowed when no - # component addresses entities by object_id (no mqtt or prometheus configured) - - platform: template - name: "Датчик открытия" - lambda: return 21.0; - - platform: template - name: "Датчик закрытия" - lambda: return 22.0; diff --git a/tests/unit_tests/test_preference_hash_stability.py b/tests/unit_tests/test_preference_hash_stability.py index d3e5fac36a..d8506afae7 100644 --- a/tests/unit_tests/test_preference_hash_stability.py +++ b/tests/unit_tests/test_preference_hash_stability.py @@ -5,11 +5,11 @@ users to lose stored preferences (calibration values, restore states, etc.) on firmware upgrades, or break entity state routing to API clients. Two algorithms are locked here (see https://github.com/esphome/backlog/issues/85): -1. `fnv1_hash_object_id(name)` - the LEGACY hash (snake_case + sanitize, then FNV-1). - Existing devices have preferences stored under keys derived from it; slot-based - backends (ESP8266, RP2040) keep using it, and key-lookup backends migrate FROM it. -2. `fnv1_hash_name(name)` - the entity key (FNV-1 over the raw UTF-8 name bytes). - Sent to API clients and used as the preference key base on key-lookup backends. +1. `fnv1_hash_object_id(name)` - the object_id hash (snake_case + sanitize, then FNV-1). + The entity key sent to API clients and the base of every stored preference key. +2. `fnv1_hash_name(name)` - FNV-1 over the raw UTF-8 name bytes. 2026.8 beta + firmware stored preferences under keys derived from it; a future key migration + must reconstruct those keys to recover that data. DO NOT CHANGE THE EXPECTED VALUES - if tests fail after modifying a hash algorithm, the change breaks backward compatibility and will cause data loss. @@ -124,8 +124,9 @@ def test_entity_object_id_hash_stability( """Verify fnv1_hash_object_id produces stable hashes for entity names. CRITICAL: These expected values MUST NOT CHANGE. Existing devices have - preferences stored under keys derived from this legacy hash; changing it - breaks the old-to-new key migration and loses stored preferences. + preferences stored under keys derived from this hash, and it is the entity + key sent to API clients; changing it loses stored preferences and breaks + entity state routing. """ actual = fnv1_hash_object_id(entity_name) assert actual == expected_object_id_hash, ( @@ -144,9 +145,8 @@ def compute_legacy_preference_key( ) -> int: """Compute the legacy preference key: (object_id_hash ^ device_id) ^ version. - This is the key existing devices have data stored under. Slot-based backends - (ESP8266, RP2040) still use it directly; key-lookup backends compute it as the - migration source in EntityBase::make_entity_preference_() (entity_base.cpp). + This is the key EntityBase::make_entity_preference_() (entity_base.cpp) + stores every entity preference under. """ object_id_hash = fnv1_hash_object_id(entity_name) preference_hash = object_id_hash ^ device_id @@ -179,8 +179,8 @@ def test_legacy_preference_key_computation( ) -> None: """Verify legacy preference key computation matches expected values. - This test ensures the formula doesn't change, which would break both slot-based - preference storage and the migration source keys on key-lookup backends. + This test ensures the formula doesn't change, which would lose stored + preferences on every platform. """ actual_key = compute_legacy_preference_key(entity_name, version, device_id) @@ -215,12 +215,12 @@ def test_legacy_preference_key_computation( ], ) def test_entity_key_hash_stability(entity_name: str, expected_key: int) -> None: - """Verify fnv1_hash_name produces stable entity keys. + """Verify fnv1_hash_name produces stable raw-name hashes. - CRITICAL: These expected values MUST NOT CHANGE. The entity key is sent to - API clients and is the new preference key base; changing the algorithm - would break state routing and lose stored preferences. - Must match C++ fnv1_hash_bytes() in esphome/core/helpers.h. + CRITICAL: These expected values MUST NOT CHANGE. 2026.8 beta firmware stored + preferences under keys derived from this hash; a future key migration must + reconstruct those keys, and changing the algorithm would strand that data. + Matched C++ fnv1_hash_bytes() (2026.8 beta), which the unrevert restores. """ actual = fnv1_hash_name(entity_name) assert actual == expected_key, ( From b794b7b1d19d7491df12d417bca5ad19187ac767 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Aug 2026 00:22:22 -0500 Subject: [PATCH 1469/1815] [core] Restore cv.parse_esphome_version as a deprecated helper (#18366) --- esphome/config_validation.py | 4 ++++ esphome/util.py | 14 ++++++++++++++ tests/unit_tests/test_config_validation.py | 17 +++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 0eebf12e66..f455c7b8bf 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -99,6 +99,10 @@ from esphome.schema_extractors import ( schema_extractor_registry, schema_extractor_typed, ) + +# Deprecated re-export for external components; remove before 2027.2.0 +# pylint: disable-next=unused-import +from esphome.util import parse_esphome_version # noqa: F401 from esphome.voluptuous_schema import _Schema from esphome.yaml_util import SensitiveStr, make_data_base diff --git a/esphome/util.py b/esphome/util.py index 2fc34f3a69..b8ffa048ca 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -390,6 +390,20 @@ def is_dev_esphome_version(): return "dev" in const.__version__ +# Remove before 2027.2.0 +def parse_esphome_version() -> tuple[int, int, int]: + """Deprecated: use esphome.config_validation.require_esphome_version instead.""" + from esphome.core import Version + + _LOGGER.warning( + "parse_esphome_version() is deprecated. Use " + "cv.require_esphome_version to gate on a minimum version. " + "Removed in 2027.2.0" + ) + version = Version.parse(const.__version__) + return version.major, version.minor, version.patch + + # Custom OrderedDict with nicer repr method for debugging class OrderedDict(collections.OrderedDict): def __repr__(self): diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 7627ef9273..971c4e462d 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -2967,6 +2967,23 @@ def test_require_esphome_version_older_prerelease_fails() -> None: cv.require_esphome_version(2026, 8, 0)("test") +def test_parse_esphome_version_deprecated_shim( + caplog: pytest.LogCaptureFixture, +) -> None: + """The removed helper still works for external components and warns.""" + from esphome import const, util + + with ( + patch.object(const, "__version__", "2026.9.0-dev"), + caplog.at_level(logging.WARNING), + ): + assert cv.parse_esphome_version() == (2026, 9, 0) + assert cv.parse_esphome_version() < (9999, 0, 0) + assert "parse_esphome_version() is deprecated" in caplog.text + # Both historical import paths resolve to the same function + assert cv.parse_esphome_version is util.parse_esphome_version + + # --------------------------------------------------------------------------- # suppress_invalid / validate_source_shorthand / rename_key # --------------------------------------------------------------------------- From c8de63276479cc80db40c03079b90b7c97a11f4f Mon Sep 17 00:00:00 2001 From: Karl Beecken Date: Fri, 14 Aug 2026 07:23:34 +0200 Subject: [PATCH 1470/1815] [core] fix PYTHONPATH leak (#18360) --- esphome/espidf/toolchain.py | 2 ++ esphome/framework_helpers.py | 2 ++ tests/unit_tests/test_espidf_toolchain.py | 15 +++++++++++++++ tests/unit_tests/test_framework_helpers.py | 18 ++++++++++++++++++ 4 files changed, 37 insertions(+) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index e1688f4170..bb6452acf2 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -109,6 +109,8 @@ def _get_idf_env(version: str | None = None) -> dict[str, str]: env_cache = _cache().env if version not in env_cache: env_cache[version] = os.environ.copy() + # Do not leak PYTHONPATH into child env + env_cache[version].pop("PYTHONPATH", None) # Use provided IDF framework if available if "IDF_PATH" not in os.environ: diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 86d5e4eaea..b8a43220ff 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -155,6 +155,8 @@ def run_command( _LOGGER.debug("%s - running ...", cmd_str) run_env = os.environ.copy() + # Do not leak PYTHONPATH + run_env.pop("PYTHONPATH", None) if env: run_env.update(env) diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 56f358a24c..26d812af8b 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -265,6 +265,21 @@ def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None: assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep) +def test_get_idf_env_pops_inherited_pythonpath(setup_core: Path) -> None: + """A PYTHONPATH from the parent environment must not reach idf.py. + + It would override the IDF venv's isolation, shadowing its pinned + packages and failing idf.py's dependency check. + """ + toolchain._cache().env.clear() + with patch.dict( + os.environ, + {"IDF_PATH": str(setup_core), "PYTHONPATH": "/outside/site-packages"}, + ): + env = toolchain._get_idf_env(version="5.5.4") + assert "PYTHONPATH" not in env + + def test_get_cmake_output_without_build_dir(setup_core: Path) -> None: """A build dir that was never created raises EsphomeError. diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index 7451ee9b39..2022c15bfe 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -188,6 +188,24 @@ def test_run_command_passes_env(mock_subprocess_run: Mock) -> None: assert mock_subprocess_run.call_args[1]["env"]["MY_VAR"] == "42" +def test_run_command_pops_inherited_pythonpath(mock_subprocess_run: Mock) -> None: + """A PYTHONPATH from the parent environment must not leak into subprocesses.""" + mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="") + with patch.dict(os.environ, {"PYTHONPATH": "/outside/site-packages"}): + run_command(["cmd"]) + assert "PYTHONPATH" not in mock_subprocess_run.call_args[1]["env"] + + +def test_run_command_env_pythonpath_preferred_over_pop( + mock_subprocess_run: Mock, +) -> None: + """A PYTHONPATH set explicitly via ``env`` is passed through.""" + mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="") + with patch.dict(os.environ, {"PYTHONPATH": "/outside/site-packages"}): + run_command(["cmd"], env={"PYTHONPATH": "/idf/tools"}) + assert mock_subprocess_run.call_args[1]["env"]["PYTHONPATH"] == "/idf/tools" + + def test_run_command_passes_cwd(mock_subprocess_run: Mock, tmp_path: Path) -> None: mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="") run_command(["cmd"], cwd=str(tmp_path)) From 4db47de556375f0554001d03c6c80fdea005db97 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:36:56 +1200 Subject: [PATCH 1471/1815] Bump version to 2026.8.0b3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index a8c77f4bb8..d9421273af 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.8.0b2 +PROJECT_NUMBER = 2026.8.0b3 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index b6770d0001..1a8be98c03 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.8.0b2" +__version__ = "2026.8.0b3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From be66e8b99c3aaee6b2ed8b3ab75434ecdfdde612 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:33:47 -0700 Subject: [PATCH 1472/1815] [ci] Disable CodSpeed benchmarks job outside esphome/esphome (#18372) --- .github/workflows/ci.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b603e68ad7..026c2ba27a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -445,8 +445,12 @@ jobs: - common - determine-jobs if: >- - (github.event_name == 'push' && github.ref_name == 'dev') || - (github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true') + github.repository == 'esphome/esphome' && ( + (github.event_name == 'push' && github.ref_name == 'dev') || + (github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true') + ) + # CodSpeed benchmarks require a CodSpeed account linked to the repository to run + # (https://codspeed.io) -- disabled on forks that aren't esphome/esphome itself. steps: - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 From e5224e22ae1fd07a284794690db68544f76f1b0c Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:18:22 -0700 Subject: [PATCH 1473/1815] [ci] Compare merge-branch base ref against the default branch (#18385) --- .github/scripts/auto-label-pr/detectors.js | 3 ++- .../auto-label-pr/tests/detectors.test.js | 19 +++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index bb85ccd681..1d76c18be8 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -70,6 +70,7 @@ async function isStackedPr(github, context) { async function detectMergeBranch(github, context) { const labels = new Set(); const baseRef = context.payload.pull_request.base.ref; + const defaultBranch = context.payload.repository.default_branch; if (baseRef === 'release') { labels.add('merging-to-release'); @@ -78,7 +79,7 @@ async function detectMergeBranch(github, context) { } else if (await isStackedPr(github, context)) { // GitHub manages the merge order for a stack, so these are not blocked. labels.add('stacked-pr'); - } else if (baseRef !== 'dev') { + } else if (baseRef !== defaultBranch) { // A chain built by hand: it must not merge until its base branch does. labels.add('chained-pr'); } diff --git a/.github/scripts/auto-label-pr/tests/detectors.test.js b/.github/scripts/auto-label-pr/tests/detectors.test.js index f30ceff8c1..be239e2f1b 100644 --- a/.github/scripts/auto-label-pr/tests/detectors.test.js +++ b/.github/scripts/auto-label-pr/tests/detectors.test.js @@ -43,14 +43,14 @@ const WITHOUT_SCHEMA = 'CODEOWNERS = ["@esphome/core"]'; // Builds a fresh context for detectMergeBranch tests instead of mutating the // shared CONTEXT fixture above (which other describe blocks rely on). -function makeMergeContext(baseRef, { stack } = {}) { +function makeMergeContext(baseRef, { stack, defaultBranch = 'dev' } = {}) { const pull_request = { number: 1, base: { ref: baseRef } }; if (stack !== undefined) { pull_request.stack = stack; } return { repo: { owner: 'esphome', repo: 'esphome' }, - payload: { pull_request } + payload: { pull_request, repository: { default_branch: defaultBranch } } }; } @@ -136,6 +136,21 @@ describe('detectMergeBranch', () => { assert.deepEqual(Array.from(labels).sort(), ['chained-pr']); assert.equal(state.calls, 1); }); + + it('base ref matches default branch adds no labels', async () => { + const { github } = makeStackGithub({ stack: null }); + const context = makeMergeContext('other', { defaultBranch: 'other' }); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), []); + }); + + it('base ref dev when the default branch is main adds chained-pr', async () => { + const { github } = makeStackGithub({ stack: null }); + const context = makeMergeContext('dev', { defaultBranch: 'main' }); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), ['chained-pr']); + }); + }); // --------------------------------------------------------------------------- From b178f74e5d6b229293b28bfd2cc78ffb79f5be77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Aug 2026 18:45:23 -0700 Subject: [PATCH 1474/1815] [core] Save the validated config cache on the first upload or logs run (#18367) --- esphome/__main__.py | 28 +- esphome/compiled_config.py | 77 ++++- esphome/components/esp32/__init__.py | 3 + esphome/components/esp8266/__init__.py | 3 + esphome/components/libretiny/__init__.py | 3 + esphome/components/nrf52/__init__.py | 3 + esphome/components/rp2/__init__.py | 3 + esphome/storage_json.py | 56 +++- .../fixtures/lazy_imports/_storage.py | 11 +- tests/unit_tests/test_compiled_config.py | 299 ++++++++++++++++-- tests/unit_tests/test_download_types.py | 52 +++ tests/unit_tests/test_storage_json.py | 99 ++++++ 12 files changed, 573 insertions(+), 64 deletions(-) create mode 100644 tests/unit_tests/test_download_types.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 1262a4525e..c1e05d2ea7 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2732,7 +2732,8 @@ def run_esphome(argv): conf_path.name, ) - if config is None: + cache_missed = config is None + if cache_missed: from esphome.config import read_config config = read_config( @@ -2741,26 +2742,25 @@ def run_esphome(argv): # Snapshot only needed by `esphome config --no-defaults`. snapshot_user_config=getattr(args, "no_defaults", False), ) - # Refresh the cache so the next upload/logs hits the fast path - # instead of re-running read_config. Skip when the storage - # sidecar is absent (no compile has run): the cache would - # never be loaded back, so writing secrets to disk is wasted. - if cache_eligible and config is not None: - from esphome.compiled_config import save_compiled_config - from esphome.storage_json import ext_storage_path - - if ext_storage_path(conf_path.name).exists(): - save_compiled_config(config) - if config is None: - return 2 + if config is None: + return 2 CORE.config = config # Fallback for platforms whose validators didn't set the toolchain # (only the esp32 component reads esp32.framework.toolchain). All - # other platforms only support PlatformIO today. + # other platforms only support PlatformIO today. Must run before the + # cache refresh below so its sidecar records the same toolchain a + # compile would. if CORE.toolchain is None: CORE.toolchain = Toolchain.PLATFORMIO + # Refresh the cache so the next upload/logs hits the fast path + # instead of re-running read_config. + if cache_eligible and cache_missed: + from esphome.compiled_config import save_compiled_config_and_sidecar + + save_compiled_config_and_sidecar(config) + if args.command not in POST_CONFIG_ACTIONS: safe_print(f"Unknown command {args.command}") return 1 diff --git a/esphome/compiled_config.py b/esphome/compiled_config.py index 303af99e66..be03eea965 100644 --- a/esphome/compiled_config.py +++ b/esphome/compiled_config.py @@ -18,9 +18,9 @@ from pathlib import Path from typing import Any from esphome.const import __version__ as ESPHOME_VERSION -from esphome.core import CORE, Lambda +from esphome.core import CORE, EsphomeError, Lambda from esphome.helpers import write_file -from esphome.storage_json import StorageJSON, ext_storage_path +from esphome.storage_json import StorageJSON, ext_storage_path, storage_path from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -65,7 +65,71 @@ def save_compiled_config(config: ConfigType) -> None: # non-basic dict key), so every upload/logs pays the slow path. _LOGGER.warning("Cannot cache the validated config: %s", err) except Exception as err: # noqa: BLE001 # pylint: disable=broad-except - _LOGGER.debug("Skipping compiled config cache write: %s", err) + # Likely persistent (permissions, full disk): every upload/logs + # pays the slow path until it clears, so surface it. + _LOGGER.warning("Skipping compiled config cache write: %s", err) + + +def save_compiled_config_and_sidecar(config: ConfigType) -> None: + """Refresh the cache from the upload/logs fallback (CORE.config must be set). + + The cache is only written when a complete sidecar is on disk: + load_compiled_config can't use it otherwise, and it holds resolved + secrets. + """ + if _refresh_sidecar(): + save_compiled_config(config) + + +def _refresh_sidecar() -> bool: + """Ensure a complete sidecar is on disk; True when one is. + + Writes one (without claiming a build) when missing or wizard-only. + Failures are non-fatal; the next upload/logs pays the slow path again. + """ + try: + path = storage_path() + try: + old = StorageJSON.load_strict(path) + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + # Present but unreadable: it may hold a real build's metadata, + # and a fresh rewrite would also stop the next compile from + # cleaning a possibly incoherent build tree. + _LOGGER.warning( + "Not caching: storage sidecar %s is unreadable (%s)", path, err + ) + return False + if old is not None and old.can_apply_to_core(): + # Compile-written; nothing to refresh. + return True + if CORE.build_path is not None and CORE.build_path.exists(): + # An unvalidated build tree: its absent or mismatched sidecar + # is what makes the next compile wipe it, so don't vouch for + # a build this run never saw. + _LOGGER.warning( + "Not caching: build tree %s has no matching sidecar; " + "'esphome compile' will settle it", + CORE.build_path, + ) + return False + new = StorageJSON.from_esphome_core(CORE, old, claim_build=False) + if not new.can_apply_to_core(): + _LOGGER.warning("Not caching: rebuilt storage sidecar is still incomplete") + return False + new.save(path) + return True + except (OSError, EsphomeError) as err: + # write_file wraps OSError into EsphomeError. Persistent + # (unwritable storage dir), so surface that every upload/logs + # pays the slow path. + _LOGGER.warning("Could not refresh the storage sidecar: %s", err) + except Exception: # noqa: BLE001 # pylint: disable=broad-except + # A structural bug; keep the traceback so it isn't mistaken + # for the I/O failure above. + _LOGGER.warning( + "Unexpected error refreshing the storage sidecar", exc_info=True + ) + return False def load_compiled_config(conf_path: Path) -> ConfigType | None: @@ -98,11 +162,8 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None: return None storage = StorageJSON.load(ext_storage_path(conf_path.name)) - if storage is None: - return None - # apply_to_core assumes a real compile wrote the sidecar; wizard-only - # sidecars leave both of these unset and can't drive upload/logs. - if not storage.core_platform and not storage.target_platform: + if storage is None or not storage.can_apply_to_core(): + _LOGGER.debug("Ignoring compiled config cache: sidecar missing or incomplete") return None storage.apply_to_core() return config diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index ada6d25db5..7263571d69 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -570,6 +570,9 @@ def get_download_types(storage_json): the shape stable so the download panel doesn't have to special-case per-platform schemas. """ + # No recorded firmware path means nothing was built; no downloads. + if storage_json.firmware_bin_path is None: + return [] return [ { "title": "Factory format (Previously Modern)", diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 1f7159919d..2161a902cb 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -113,6 +113,9 @@ def get_download_types(storage_json): the shape stable so the download panel doesn't have to special-case per-platform schemas. """ + # No recorded firmware path means nothing was built; no downloads. + if storage_json.firmware_bin_path is None: + return [] return [ { "title": "Standard format", diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index c51af373b3..c56cc48055 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -182,6 +182,9 @@ def get_download_types(storage_json: StorageJSON = None): the shape stable so the download panel doesn't have to special-case per-platform schemas. """ + # No recorded firmware path means nothing was built; no downloads. + if storage_json.firmware_bin_path is None: + return [] types = [ { "title": "UF2 package (recommended)", diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 386fed5412..2d25558254 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -473,6 +473,9 @@ def copy_files() -> None: def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: """Get the download types for the firmware.""" + # No recorded firmware path means nothing was built; no downloads. + if storage_json.firmware_bin_path is None: + return [] types = [] UF2_PATH = "zephyr/zephyr.uf2" DFU_PATH = "firmware.zip" diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index 87e78003ed..60fcd4f8b0 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -156,6 +156,9 @@ def get_download_types(storage_json): the shape stable so the download panel doesn't have to special-case per-platform schemas. """ + # No recorded firmware path means nothing was built; no downloads. + if storage_json.firmware_bin_path is None: + return [] return [ { "title": "UF2 factory format", diff --git a/esphome/storage_json.py b/esphome/storage_json.py index a90a36b848..9219914529 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -71,8 +71,11 @@ def archive_storage_path() -> Path: def _to_path_if_not_none(value: str | None) -> Path | None: - """Convert a string to Path if it's not None.""" - return Path(value) if value is not None else None + """Convert a string to Path; None and the legacy "None" both map to None. + + Sidecars written before as_dict skipped unset paths hold str(None). + """ + return Path(value) if value is not None and value != "None" else None def _parse_framework_version(framework_version: str) -> Version: @@ -170,8 +173,10 @@ class StorageJSON: "address": self.address, "web_port": self.web_port, "esp_platform": self.target_platform, - "build_path": str(self.build_path), - "firmware_bin_path": str(self.firmware_bin_path), + "build_path": str(self.build_path) if self.build_path else None, + "firmware_bin_path": ( + str(self.firmware_bin_path) if self.firmware_bin_path else None + ), "loaded_integrations": sorted(self.loaded_integrations), "loaded_platforms": sorted(self.loaded_platforms), "no_mdns": self.no_mdns, @@ -189,7 +194,18 @@ class StorageJSON: write_file_if_changed(path, self.to_json()) @staticmethod - def from_esphome_core(esph: CoreType, old: StorageJSON | None) -> StorageJSON: + def from_esphome_core( + esph: CoreType, old: StorageJSON | None, *, claim_build: bool = True + ) -> StorageJSON: + """Build a sidecar from post-validation CORE state. + + claim_build=False (the upload/logs fallback, which runs no build) + carries the build-artifact fields (esphome_version, + firmware_bin_path) from *old* instead of asserting this run built + firmware. Validation-derived fields (platform, framework_version, + toolchain, build_path) always stamp; storage_should_clean compares + them against the next compile. + """ hardware = esph.target_platform.upper() framework_version: str | None = None if esph.is_esp32: @@ -204,13 +220,21 @@ class StorageJSON: name=esph.name, friendly_name=esph.friendly_name, comment=esph.comment, - esphome_version=const.__version__, + esphome_version=( + const.__version__ + if claim_build + else (old.esphome_version if old else None) + ), src_version=1, address=esph.address, web_port=esph.web_port, target_platform=hardware, build_path=esph.build_path, - firmware_bin_path=esph.firmware_bin, + firmware_bin_path=( + esph.firmware_bin + if claim_build + else (old.firmware_bin_path if old else None) + ), loaded_integrations=esph.loaded_integrations, loaded_platforms=esph.loaded_platforms, no_mdns=( @@ -302,11 +326,27 @@ class StorageJSON: except Exception: # noqa: BLE001 # pylint: disable=broad-except return None + @staticmethod + def load_strict(path: Path) -> StorageJSON | None: + """Like load, but None only means missing; an unreadable file raises.""" + if not path.is_file(): + return None + return StorageJSON._load_impl(path) + + def can_apply_to_core(self) -> bool: + """True when the sidecar carries everything apply_to_core hands CORE. + + Wizard-written sidecars leave build_path unset (older wizards also + the platform fields) and can't drive upload/logs. + """ + return bool((self.core_platform or self.target_platform) and self.build_path) + def apply_to_core(self) -> None: """Populate CORE with the metadata upload/logs read. Inverse of :meth:`from_esphome_core`. Keep paired -- a new - attribute upload/logs needs has to be captured there too. + attribute upload/logs needs has to be captured there too and + reflected in :meth:`can_apply_to_core`. Validator-only fields (loaded_integrations/platforms, friendly_name) are skipped; the fast path doesn't run validation and CORE.__init__ defaults them. diff --git a/tests/unit_tests/fixtures/lazy_imports/_storage.py b/tests/unit_tests/fixtures/lazy_imports/_storage.py index 969528304b..94acd2e93a 100644 --- a/tests/unit_tests/fixtures/lazy_imports/_storage.py +++ b/tests/unit_tests/fixtures/lazy_imports/_storage.py @@ -1,10 +1,15 @@ """Shared storage-sidecar factory for the lazy-import fixture scripts.""" +from pathlib import Path + from esphome.storage_json import StorageJSON def make_storage() -> StorageJSON: - """A minimal post-compile esp32 sidecar the upload/logs fast path accepts.""" + """A minimal post-compile esp32 sidecar the upload/logs fast path accepts. + + build_path must be set: the fast path rejects sidecars without one. + """ return StorageJSON( storage_version=1, name="test", @@ -15,8 +20,8 @@ def make_storage() -> StorageJSON: address="1.2.3.4", web_port=None, target_platform="ESP32S3", - build_path=None, - firmware_bin_path=None, + build_path=Path("/build/test"), + firmware_bin_path=Path("/build/test/firmware.bin"), loaded_integrations=set(), loaded_platforms=set(), no_mdns=False, diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index b3c2170c3f..77690a6897 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -2,6 +2,7 @@ from __future__ import annotations +from contextlib import contextmanager from ipaddress import IPv4Address, IPv4Network import json import os @@ -19,6 +20,7 @@ from esphome.compiled_config import ( compiled_config_path, load_compiled_config, save_compiled_config, + save_compiled_config_and_sidecar, ) from esphome.const import ( CONF_API, @@ -31,7 +33,16 @@ from esphome.const import ( KEY_VARIANT, Toolchain, ) -from esphome.core import CORE, ID, HexInt, Lambda, MACAddress, TimePeriodMilliseconds +from esphome.core import ( + CORE, + ID, + EsphomeError, + HexInt, + Lambda, + MACAddress, + TimePeriodMilliseconds, +) +from esphome.storage_json import StorageJSON from esphome.util import OrderedDict _VALIDATED_CONFIG = { @@ -54,8 +65,9 @@ def _cache_body(config: dict | None = None) -> str: def _write_storage( storage_path: Path, *, - esp_platform: str = "ESP32", + esp_platform: str | None = "ESP32", core_platform: str | None = "esp32", + build_path: str | None = "/build/lite_test", ) -> None: """Write a vanilla StorageJSON sidecar for the cache tests.""" storage_path.parent.mkdir(parents=True, exist_ok=True) @@ -69,7 +81,7 @@ def _write_storage( "address": "192.168.1.42", "web_port": None, "esp_platform": esp_platform, - "build_path": "/build/lite_test", + "build_path": build_path, "firmware_bin_path": "/build/lite_test/firmware.bin", "loaded_integrations": ["api", "logger", "ota", "wifi"], "loaded_platforms": [], @@ -359,31 +371,262 @@ def test_run_esphome_upload_and_logs_fall_back_when_no_cache( mock_read.assert_called_once() -def test_run_esphome_upload_does_not_refresh_cache_without_sidecar( - tmp_path: Path, -) -> None: - """Without a StorageJSON sidecar (no compile has run), the fallback - skips the cache write -- load_compiled_config requires the sidecar, - so writing the rendered (secret-resolved) config would be inert and - leak secrets to disk for nothing.""" +def _storage_fixture(tmp_path: Path) -> StorageJSON: + """A loaded StorageJSON instance matching _write_storage's contents.""" + fixture = tmp_path / "fixture_storage.json" + _write_storage(fixture) + return StorageJSON.load(fixture) + + +def _bare_yaml(tmp_path: Path) -> Path: + """A minimal YAML with CORE.config_path pointed at it.""" yaml_path = tmp_path / "lite_test.yaml" yaml_path.write_text("esphome:\n name: lite_test\n") CORE.config_path = yaml_path + return yaml_path + +@contextmanager +def _fallback_run(command: str = "upload", **from_core_kwargs) -> Any: + """Patch the fallback path's collaborators for a run_esphome call. + + Without kwargs, from_esphome_core stays real (yielded mock is None). + """ with ( patch( "esphome.config.read_config", return_value={"esphome": {"name": "lite_test"}}, - ), - patch("esphome.compiled_config.save_compiled_config") as mock_save, + ) as mock_read, patch.dict( "esphome.__main__.POST_CONFIG_ACTIONS", - {"upload": lambda args, config: 0}, + {command: lambda args, config: 0}, ), ): - run_esphome(["esphome", "upload", str(yaml_path)]) + if not from_core_kwargs: + yield mock_read, None + return + with patch.object( + StorageJSON, "from_esphome_core", **from_core_kwargs + ) as mock_from_core: + yield mock_read, mock_from_core + + +@pytest.mark.parametrize("command", ["upload", "logs"]) +def test_run_esphome_fallback_writes_sidecar_and_cache_without_sidecar( + tmp_path: Path, command: str +) -> None: + """A never-compiled config caches on its first upload/logs run: the + fallback writes the StorageJSON sidecar itself (load_compiled_config + needs it), so the second run hits the fast path.""" + yaml_path = _bare_yaml(tmp_path) + storage_dir = tmp_path / ".esphome" / "storage" + + with _fallback_run(command, return_value=_storage_fixture(tmp_path)) as ( + mock_read, + mock_from_core, + ): + assert run_esphome(["esphome", command, str(yaml_path)]) == 0 + mock_from_core.assert_called_once() + assert (storage_dir / "lite_test.yaml.validated.json").exists() + storage = StorageJSON.load(storage_dir / "lite_test.yaml.json") + assert storage is not None + # No compile happened, so the sidecar must not claim one. + assert mock_from_core.call_args.kwargs == {"claim_build": False} + + # The second run loads the cache instead of re-validating. + assert run_esphome(["esphome", command, str(yaml_path)]) == 0 + mock_read.assert_called_once() + + +# as_dict serialized unset paths as str(None) until 2026.9; files +# written by those wizards are still on disk. +_WIZARD_SIDECAR_CASES = pytest.mark.parametrize( + "wizard_kwargs", + [ + {"esp_platform": None, "core_platform": None, "build_path": None}, + {"build_path": None}, + {"build_path": "None"}, + ], + ids=["legacy_wizard", "modern_wizard", "none_string_wizard"], +) + + +def _prime_core(tmp_path: Path) -> None: + """Set the post-validation CORE state from_esphome_core reads.""" + CORE.name = "lite_test" + CORE.build_path = tmp_path / "build" / "lite_test" + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "esp8266", + KEY_TARGET_FRAMEWORK: "arduino", + } + + +@_WIZARD_SIDECAR_CASES +def test_run_esphome_fallback_completes_wizard_sidecar( + tmp_path: Path, wizard_kwargs: dict[str, Any] +) -> None: + """A wizard-written sidecar can't drive the fast path (no build_path; + older wizards also no platform fields); the fallback rewrites it from + CORE so the cache loads on the next run.""" + yaml_path = _bare_yaml(tmp_path) + storage_dir = tmp_path / ".esphome" / "storage" + _write_storage(storage_dir / "lite_test.yaml.json", **wizard_kwargs) + + with _fallback_run(return_value=_storage_fixture(tmp_path)) as (_, mock_from_core): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 + + mock_from_core.assert_called_once() + storage = StorageJSON.load(storage_dir / "lite_test.yaml.json") + assert storage is not None and storage.core_platform == "esp32" + # What the wizard recorded about a build (nothing, or a real one) + # carries through instead of being stamped with this run's values. + assert storage.esphome_version == "2026.1.0" + assert load_compiled_config(yaml_path) is not None + + +def test_run_esphome_fallback_skips_cache_when_sidecar_write_fails( + tmp_path: Path, +) -> None: + """A failed sidecar write is non-fatal and skips the cache save too: + without the sidecar the cache could never be loaded back, so writing + it would only leave resolved secrets on disk.""" + yaml_path = _bare_yaml(tmp_path) + + with ( + _fallback_run(side_effect=RuntimeError("boom")), + patch("esphome.compiled_config.save_compiled_config") as mock_save, + ): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 mock_save.assert_not_called() + assert not (tmp_path / ".esphome" / "storage" / "lite_test.yaml.json").exists() + + +def test_run_esphome_fallback_write_failure_takes_io_branch( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """StorageJSON.save raises EsphomeError (write_file wraps OSError into + it), which must land in the plain I/O warning, not the traceback + branch for structural bugs.""" + yaml_path = _bare_yaml(tmp_path) + + with ( + _fallback_run(return_value=_storage_fixture(tmp_path)), + patch.object(StorageJSON, "save", side_effect=EsphomeError("boom")), + patch("esphome.compiled_config.save_compiled_config") as mock_save, + caplog.at_level("WARNING", logger="esphome.compiled_config"), + ): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 + + mock_save.assert_not_called() + assert "Could not refresh the storage sidecar" in caplog.text + assert "Unexpected error" not in caplog.text + + +def test_run_esphome_fallback_leaves_unreadable_sidecar_alone(tmp_path: Path) -> None: + """A present-but-corrupt sidecar is not overwritten: it may hold a real + build's metadata, and replacing it would suppress the next compile's + clean of a possibly incoherent build tree. The cache save is skipped.""" + yaml_path = _bare_yaml(tmp_path) + storage_dir = tmp_path / ".esphome" / "storage" + sidecar = storage_dir / "lite_test.yaml.json" + sidecar.parent.mkdir(parents=True, exist_ok=True) + sidecar.write_text("{truncated", encoding="utf-8") + + with _fallback_run(return_value=None) as (_, mock_from_core): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 + + mock_from_core.assert_not_called() + assert sidecar.read_text(encoding="utf-8") == "{truncated" + assert not (storage_dir / "lite_test.yaml.validated.json").exists() + + +def test_run_esphome_fallback_skips_cache_when_rebuilt_sidecar_incomplete( + tmp_path: Path, +) -> None: + """If the rebuilt sidecar would still be incomplete, nothing is written: + the cache could never be loaded back, so saving it would only rewrite + resolved secrets on every run.""" + yaml_path = _bare_yaml(tmp_path) + storage_dir = tmp_path / ".esphome" / "storage" + + incomplete = tmp_path / "incomplete_storage.json" + _write_storage(incomplete, build_path=None) + + with _fallback_run(return_value=StorageJSON.load(incomplete)): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 + + assert not (storage_dir / "lite_test.yaml.json").exists() + assert not (storage_dir / "lite_test.yaml.validated.json").exists() + + +def test_run_esphome_fallback_sidecar_records_platformio_toolchain( + tmp_path: Path, +) -> None: + """The toolchain fallback runs before the sidecar write, so platforms + whose validators leave CORE.toolchain unset record the same + "platformio" a compile writes, not null.""" + yaml_path = _bare_yaml(tmp_path) + _prime_core(tmp_path) + assert CORE.toolchain is None + + with _fallback_run(): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 + + storage = StorageJSON.load( + tmp_path / ".esphome" / "storage" / "lite_test.yaml.json" + ) + assert storage is not None + assert storage.toolchain == "platformio" + + +@pytest.mark.parametrize("existing_sidecar", [None, "wizard"]) +def test_run_esphome_fallback_skips_sidecar_when_build_tree_exists( + tmp_path: Path, existing_sidecar: str | None +) -> None: + """An existing build tree with a missing or wizard-only sidecar keeps + it that way: the mismatch is what makes the next compile wipe the + unknown tree, so the fallback writes nothing and skips the cache.""" + yaml_path = _bare_yaml(tmp_path) + _prime_core(tmp_path) + CORE.build_path.mkdir(parents=True) + storage_dir = tmp_path / ".esphome" / "storage" + if existing_sidecar == "wizard": + _write_storage(storage_dir / "lite_test.yaml.json", build_path=None) + wizard_body = (storage_dir / "lite_test.yaml.json").read_text(encoding="utf-8") + + with _fallback_run(return_value=_storage_fixture(tmp_path)) as (_, mock_from_core): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 + + mock_from_core.assert_not_called() + assert not (storage_dir / "lite_test.yaml.validated.json").exists() + if existing_sidecar == "wizard": + sidecar_body = (storage_dir / "lite_test.yaml.json").read_text(encoding="utf-8") + assert sidecar_body == wizard_body + else: + assert not (storage_dir / "lite_test.yaml.json").exists() + + +def test_save_compiled_config_and_sidecar_builds_real_sidecar(tmp_path: Path) -> None: + """Drive the real from_esphome_core on the fallback path: the + post-validation CORE state yields a complete, loadable sidecar.""" + yaml_path = _bare_yaml(tmp_path) + _prime_core(tmp_path) + CORE.config = {CONF_ESPHOME: {CONF_NAME: "lite_test"}} + CORE.toolchain = Toolchain.PLATFORMIO + + save_compiled_config_and_sidecar(CORE.config) + + storage = StorageJSON.load( + tmp_path / ".esphome" / "storage" / "lite_test.yaml.json" + ) + assert storage is not None + assert storage.core_platform == "esp8266" + assert storage.build_path is not None + # No compile happened, so the sidecar must not claim one. + assert storage.esphome_version is None + assert storage.firmware_bin_path is None + assert load_compiled_config(yaml_path) is not None @pytest.mark.parametrize("command", ["upload", "logs"]) @@ -409,6 +652,7 @@ def test_run_esphome_upload_and_logs_refresh_cache_on_fallback( patch( "esphome.compiled_config.save_compiled_config", wraps=save_compiled_config ) as mock_save, + patch.object(StorageJSON, "from_esphome_core") as mock_from_core, patch.dict( "esphome.__main__.POST_CONFIG_ACTIONS", {command: lambda args, config: 0}, @@ -417,6 +661,8 @@ def test_run_esphome_upload_and_logs_refresh_cache_on_fallback( assert run_esphome(["esphome", command, str(yaml_path)]) == 0 mock_save.assert_called_once_with(fresh_config) + # The compile-written sidecar is complete; the fallback leaves it alone. + mock_from_core.assert_not_called() # mtime is now newer than the source YAML, so a follow-up call hits # the fast path instead of repeating read_config. assert cache.stat().st_mtime >= yaml_path.stat().st_mtime @@ -647,24 +893,15 @@ def test_int_keys_coerce_to_strings(primed_storage: Path) -> None: assert config["table"] == {"1": "a", "2": "b"} -def test_load_compiled_config_rejects_wizard_only_sidecar(tmp_path: Path) -> None: - """A wizard-only sidecar (no compile -- no core_platform / target_platform) - can't drive upload/logs, so the fast path falls back.""" - yaml_path = tmp_path / "lite_test.yaml" - yaml_path.write_text("esphome:\n name: lite_test\n") - CORE.config_path = yaml_path - +@_WIZARD_SIDECAR_CASES +def test_load_compiled_config_rejects_wizard_only_sidecar( + tmp_path: Path, wizard_kwargs: dict[str, Any] +) -> None: + """A wizard-written sidecar (no build_path; older wizards also no + platform fields) can't drive upload/logs, so the fast path falls back.""" + yaml_path = _bare_yaml(tmp_path) storage_dir = tmp_path / ".esphome" / "storage" - storage_dir.mkdir(parents=True, exist_ok=True) - # StorageJSON with both core_platform and target_platform unset. - (storage_dir / "lite_test.yaml.json").write_text( - '{"storage_version": 1, "name": "lite_test", "friendly_name": null, ' - '"comment": null, "esphome_version": null, "src_version": 1, ' - '"address": null, "web_port": null, "esp_platform": null, ' - '"build_path": null, "firmware_bin_path": null, ' - '"loaded_integrations": [], "loaded_platforms": [], "no_mdns": false, ' - '"framework": null, "core_platform": null}' - ) + _write_storage(storage_dir / "lite_test.yaml.json", **wizard_kwargs) cache_path = _write_cache(storage_dir / "lite_test.yaml.validated.json") _set_cache_mtime(cache_path, yaml_path, offset=5) diff --git a/tests/unit_tests/test_download_types.py b/tests/unit_tests/test_download_types.py new file mode 100644 index 0000000000..2ccf53f7e3 --- /dev/null +++ b/tests/unit_tests/test_download_types.py @@ -0,0 +1,52 @@ +"""Platform get_download_types contract for never-built configs. + +Wizard-written and upload/logs-fallback sidecars record no +firmware_bin_path; the download panel must get an empty list for them, +not entries pointing at files that were never built. +""" + +from __future__ import annotations + +from importlib import import_module +from pathlib import Path +from typing import Any + +import pytest + +from esphome.storage_json import StorageJSON + +PLATFORMS = ["esp32", "esp8266", "rp2", "libretiny", "nrf52"] + + +def _download_types(platform: str, storage: StorageJSON) -> list[dict[str, Any]]: + return import_module(f"esphome.components.{platform}").get_download_types(storage) + + +def _wizard_storage() -> StorageJSON: + return StorageJSON.from_wizard( + name="test_device", + friendly_name="Test Device", + address="test_device.local", + platform="ESP32", + ) + + +@pytest.mark.parametrize("platform", PLATFORMS) +def test_no_firmware_path_yields_no_downloads(platform: str) -> None: + """No recorded firmware path means nothing was built; no downloads.""" + assert _download_types(platform, _wizard_storage()) == [] + + +@pytest.mark.parametrize("platform", PLATFORMS) +def test_recorded_firmware_path_yields_downloads(platform: str, tmp_path: Path) -> None: + """With a firmware path recorded, every platform offers entries in + the documented title/description/file/download shape.""" + storage = _wizard_storage() + storage.firmware_bin_path = tmp_path / "firmware.bin" + + types = _download_types(platform, storage) + + assert types + assert all( + {"title", "description", "file", "download"} <= entry.keys() for entry in types + ) diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index 01683507c1..857795d02f 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -915,3 +915,102 @@ def test_storage_json_load_area(tmp_path: Path) -> None: legacy = storage_json.StorageJSON.load(legacy_path) assert legacy is not None assert legacy.area is None + + +def test_from_esphome_core_without_claiming_a_build(setup_core: Path) -> None: + """claim_build=False carries the build artifact fields from the old + sidecar while validation-derived fields still stamp from CORE.""" + mock_core = MagicMock() + mock_core.name = "my_device" + mock_core.friendly_name = "My Device" + mock_core.comment = None + mock_core.address = "my_device.local" + mock_core.web_port = None + mock_core.target_platform = "esp8266" + mock_core.is_esp32 = False + mock_core.is_nrf52 = False + mock_core.build_path = "/build/my_device" + mock_core.loaded_integrations = set() + mock_core.loaded_platforms = set() + mock_core.config = {} + mock_core.target_framework = "arduino" + mock_core.toolchain = Toolchain.PLATFORMIO + mock_core.area = None + + old = storage_json.StorageJSON.from_wizard( + name="my_device", + friendly_name="My Device", + address="my_device.local", + platform="ESP8266", + ) + old.esphome_version = "2025.1.0" + old.firmware_bin_path = Path("/old/firmware.bin") + + result = storage_json.StorageJSON.from_esphome_core( + mock_core, old, claim_build=False + ) + + # Build artifact fields carry from the old sidecar, not this run. + assert result.esphome_version == "2025.1.0" + assert result.firmware_bin_path == Path("/old/firmware.bin") + # Validation-derived fields stamp from CORE. + assert result.build_path == "/build/my_device" + assert result.toolchain == "platformio" + assert result.core_platform == "esp8266" + + # With no old sidecar, no build is claimed at all. + bare = storage_json.StorageJSON.from_esphome_core( + mock_core, None, claim_build=False + ) + assert bare.esphome_version is None + assert bare.firmware_bin_path is None + + +def test_load_strict_distinguishes_missing_from_unreadable(tmp_path: Path) -> None: + """load_strict returns None only for a missing file; corrupt raises.""" + assert storage_json.StorageJSON.load_strict(tmp_path / "missing.json") is None + + corrupt = tmp_path / "corrupt.json" + corrupt.write_text("{truncated") + with pytest.raises(ValueError): + storage_json.StorageJSON.load_strict(corrupt) + + +def test_as_dict_serializes_unset_paths_as_null(setup_core: Path) -> None: + """Unset build/firmware paths serialize as JSON null, not str(None).""" + storage = storage_json.StorageJSON.from_wizard( + name="wiz", + friendly_name="Wiz", + address="wiz.local", + platform="ESP32", + ) + + result = storage.as_dict() + + assert result["build_path"] is None + assert result["firmware_bin_path"] is None + + +def test_load_treats_legacy_none_string_paths_as_unset(tmp_path: Path) -> None: + """Sidecars written before as_dict emitted null hold str(None); those + must load as unset, not as Path("None").""" + file_path = tmp_path / "legacy_none.json" + file_path.write_text( + json.dumps( + { + "storage_version": 1, + "name": "wiz", + "friendly_name": "Wiz", + "esp_platform": "ESP32", + "core_platform": "esp32", + "build_path": "None", + "firmware_bin_path": "None", + } + ) + ) + + result = storage_json.StorageJSON.load(file_path) + + assert result is not None + assert result.build_path is None + assert result.firmware_bin_path is None From 039b897e7b83267ffe2cee749138b29cf1a5b2cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Aug 2026 18:45:36 -0700 Subject: [PATCH 1475/1815] [ethernet] Defer clk_mode removal to 2026.11.0 (#18380) --- esphome/components/ethernet/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 8bdd536ffb..f3c77baaae 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -355,7 +355,7 @@ def _validate(config): " clk:\n" " mode: %s\n" " pin: %s\n" - "Removal scheduled for 2026.9.0.", + "Removal scheduled for 2026.11.0.", config[CONF_CLK_MODE], mode, pin, From 7cceddb8a34b891681b150a8e45af49d80898228 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:48:27 +0000 Subject: [PATCH 1476/1815] Bump bundled esphome-device-builder to 1.10.0 (#18389) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index d7ae2cd4ec..a62eb59a58 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.9.6 +RUN uv pip install --no-cache-dir esphome-device-builder==1.10.0 RUN \ platformio settings set enable_telemetry No \ From 6ed676fe32a35a82f9857fdb2319c18102d1f8cd Mon Sep 17 00:00:00 2001 From: Joppy Furr Date: Sat, 15 Aug 2026 18:14:53 +1200 Subject: [PATCH 1477/1815] [lvgl] Restore long_press_repeat_time functionality (#18393) --- esphome/components/lvgl/lvgl_esphome.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index b66a904437..acd5a9bdef 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -444,7 +444,7 @@ LVTouchListener::LVTouchListener(uint16_t long_press_time, uint16_t long_press_r lv_indev_set_type(this->drv_, LV_INDEV_TYPE_POINTER); lv_indev_set_disp(this->drv_, parent->get_disp()); lv_indev_set_long_press_time(this->drv_, long_press_time); - // long press repeat time TBD + lv_indev_set_long_press_repeat_time(this->drv_, long_press_repeat_time); lv_indev_set_user_data(this->drv_, this); lv_indev_set_read_cb(this->drv_, [](lv_indev_t *d, lv_indev_data_t *data) { auto *l = static_cast(lv_indev_get_user_data(d)); From 5a000cf5e43acbbdd3f9a82e84302094cd9b2e0f Mon Sep 17 00:00:00 2001 From: Samuel Sieb Date: Sat, 15 Aug 2026 11:16:04 -0700 Subject: [PATCH 1478/1815] [rotary_encoder] account for min and max value when resetting (#18197) --- esphome/components/rotary_encoder/rotary_encoder.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/rotary_encoder/rotary_encoder.cpp b/esphome/components/rotary_encoder/rotary_encoder.cpp index 0831822d86..0734ca87d3 100644 --- a/esphome/components/rotary_encoder/rotary_encoder.cpp +++ b/esphome/components/rotary_encoder/rotary_encoder.cpp @@ -220,7 +220,7 @@ void RotaryEncoderSensor::loop() { } if (this->pin_i_ != nullptr && this->pin_i_->digital_read()) { - this->store_.counter = 0; + this->store_.counter = std::clamp(0, this->store_.min_value, this->store_.max_value); } int counter = this->store_.counter; if (this->store_.last_read != counter || this->publish_initial_value_) { From 1add72689222010acbd521d2437260183fb3c731 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:36:52 -0700 Subject: [PATCH 1479/1815] Bump bundled esphome-device-builder to 1.11.0 (#18403) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index a62eb59a58..2f23b2f690 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.10.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.0 RUN \ platformio settings set enable_telemetry No \ From de3e657d8bcae1ec1c9298ff869390d77d2e25d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 08:42:47 -0700 Subject: [PATCH 1480/1815] [platformio] Skip ccache when the binary on PATH fails to run (#18407) --- esphome/platformio/toolchain.py | 32 ++++++++++++- tests/unit_tests/test_platformio_toolchain.py | 48 ++++++++++++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index 0e7ffce939..08a4fcff78 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -5,6 +5,7 @@ import os from pathlib import Path import re import shutil +import subprocess import sys from typing import TYPE_CHECKING, Any @@ -234,6 +235,35 @@ def _check_platformio_python_stamp(config: "ProjectConfig") -> None: _write_pio_stamp_python(stamp_file, current) +def _ccache_usable() -> bool: + """Return True when the ``ccache`` on PATH actually runs. + + ``shutil.which`` proves existence, not runnability: on Windows it also + matches ``.bat``/``.cmd`` wrappers and stale package-manager shims whose + target is gone. Wrapping compiles around such a find fails every compile + step with an opaque OS error, so probe once and fall back to compiling + without ccache when the probe fails. + """ + ccache = shutil.which("ccache") + if ccache is None: + return False + try: + subprocess.run( + [ccache, "--version"], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=15, + ) + except (OSError, subprocess.SubprocessError): + _LOGGER.warning( + "Ignoring ccache at %s because it failed to run; compiling without ccache", + ccache, + ) + return False + return True + + def _ccache_env() -> dict[str, str]: """Return ccache settings for PlatformIO builds. @@ -266,7 +296,7 @@ def _ccache_env() -> dict[str, str]: if "ESPHOME_CCACHE_ENABLE" in os.environ: enabled = get_bool_env("ESPHOME_CCACHE_ENABLE") else: - enabled = shutil.which("ccache") is not None + enabled = _ccache_usable() env = {"ESPHOME_CCACHE_ENABLE": "1" if enabled else "0"} if not enabled: return env diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index 02c11b4e45..eebb0b8cd7 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -9,6 +9,7 @@ import json import os from pathlib import Path import shutil +import subprocess import sys import threading from types import SimpleNamespace @@ -431,6 +432,7 @@ def test_ccache_env_enabled_by_default(setup_core: Path) -> None: with ( patch.dict(os.environ, {}, clear=True), patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), ): env = toolchain._ccache_env() @@ -457,6 +459,44 @@ def test_ccache_env_disabled_without_binary(setup_core: Path) -> None: assert env == {"ESPHOME_CCACHE_ENABLE": "0"} +@pytest.mark.parametrize( + "probe_error", + [ + pytest.param(OSError("not runnable"), id="oserror"), + pytest.param(subprocess.CalledProcessError(1, "ccache"), id="nonzero-exit"), + pytest.param(subprocess.TimeoutExpired("ccache", 15), id="timeout"), + ], +) +def test_ccache_env_disabled_when_probe_fails( + setup_core: Path, probe_error: Exception +) -> None: + """A ccache that resolves on PATH but fails to run stays disabled.""" + CORE.build_path = setup_core / "build" / "test" + + with ( + patch.dict(os.environ, {}, clear=True), + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run", side_effect=probe_error), + ): + env = toolchain._ccache_env() + + assert env == {"ESPHOME_CCACHE_ENABLE": "0"} + + +def test_ccache_env_forced_on_skips_probe(setup_core: Path) -> None: + """An explicit ESPHOME_CCACHE_ENABLE=1 does not probe the binary.""" + CORE.build_path = setup_core / "build" / "test" + + with ( + patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True), + patch.object(toolchain.subprocess, "run") as mock_probe, + ): + env = toolchain._ccache_env() + + assert env["ESPHOME_CCACHE_ENABLE"] == "1" + mock_probe.assert_not_called() + + def test_ccache_env_opt_out(setup_core: Path) -> None: """ESPHOME_CCACHE_ENABLE=0 disables ccache even with the binary present.""" CORE.build_path = setup_core / "build" / "test" @@ -496,6 +536,7 @@ def test_ccache_env_respects_user_values_and_refreshes_basedir( with ( patch.dict(os.environ, user_env, clear=True), patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), ): env = toolchain._ccache_env() @@ -514,6 +555,7 @@ def test_run_platformio_cli_passes_ccache_env_to_subprocess_only( with ( patch.dict(os.environ, {}, clear=False), patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), ): os.environ.pop("ESPHOME_CCACHE_ENABLE", None) mock_run_external_process.return_value = 0 @@ -533,6 +575,7 @@ def test_ccache_env_requires_build_path(setup_core: Path) -> None: with ( patch.dict(os.environ, {}, clear=True), patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), pytest.raises(ValueError, match="CORE.build_path must be set"), ): toolchain._ccache_env() @@ -544,7 +587,10 @@ def test_run_platformio_cli_merges_caller_env( """A caller-supplied env is the base and gains the ccache settings.""" CORE.build_path = str(setup_core / "build" / "test") - with patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"): + with ( + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), + ): mock_run_external_process.return_value = 0 toolchain.run_platformio_cli( "test", env={"CUSTOM_VAR": "1", "ESPHOME_CCACHE_ENABLE": "0"} From 646501b0eff760267fd12de74c5fb5d283779eaa Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:36:51 -0400 Subject: [PATCH 1481/1815] [sensor] Pass NaN through the delta filter again (#18400) --- esphome/components/sensor/filter.cpp | 10 +++-- .../fixtures/sensor_filters_delta.yaml | 36 ++++++++++++++++++ .../integration/test_sensor_filters_delta.py | 38 +++++++++++++++++-- 3 files changed, 77 insertions(+), 7 deletions(-) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 5f7f19769a..0105580d26 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -283,8 +283,11 @@ DeltaFilter::DeltaFilter(float min_a0, float min_a1, float max_a0, float max_a1) void DeltaFilter::set_baseline(float (*fn)(float)) { this->baseline_ = fn; } optional DeltaFilter::new_value(float value) { - // Always yield the first value. - if (std::isnan(this->last_value_)) { + const bool no_value = std::isnan(value); + const bool no_reference = std::isnan(this->last_value_); + if (no_value && no_reference) + return {}; + if (no_value || no_reference) { this->last_value_ = value; return value; } @@ -293,8 +296,7 @@ optional DeltaFilter::new_value(float value) { float min = fabsf(this->min_a0_ + ref * this->min_a1_); float max = fabsf(this->max_a0_ + ref * this->max_a1_); float delta = fabsf(value - ref); - // if there is no reference, e.g. for the first value, just accept this one, - // otherwise accept only if within range. + // accept only if within range if (delta > min && delta <= max) { this->last_value_ = value; return value; diff --git a/tests/integration/fixtures/sensor_filters_delta.yaml b/tests/integration/fixtures/sensor_filters_delta.yaml index 2494a430da..b01c8e452b 100644 --- a/tests/integration/fixtures/sensor_filters_delta.yaml +++ b/tests/integration/fixtures/sensor_filters_delta.yaml @@ -33,6 +33,11 @@ sensor: id: source_sensor_5 accuracy_decimals: 1 + - platform: template + name: "Source Sensor 6" + id: source_sensor_6 + accuracy_decimals: 1 + - platform: copy source_id: source_sensor_1 name: "Filter Min" @@ -81,6 +86,13 @@ sensor: filters: - delta: 50% + - platform: copy + source_id: source_sensor_6 + name: "Filter NaN" + id: filter_nan + filters: + - delta: 0 + script: - id: test_filter_min then: @@ -188,6 +200,24 @@ script: id: source_sensor_5 state: 250.0 # Passes (delta=90 > 80) + - id: test_filter_nan + then: + - sensor.template.publish: + id: source_sensor_6 + state: 1.0 + - delay: 20ms + - sensor.template.publish: + id: source_sensor_6 + state: !lambda "return NAN;" + - delay: 20ms + - sensor.template.publish: + id: source_sensor_6 + state: !lambda "return NAN;" # Filtered out + - delay: 20ms + - sensor.template.publish: + id: source_sensor_6 + state: 2.0 + button: - platform: template name: "Test Filter Min" @@ -218,3 +248,9 @@ button: id: btn_filter_percentage on_press: - script.execute: test_filter_percentage + + - platform: template + name: "Test Filter NaN" + id: btn_filter_nan + on_press: + - script.execute: test_filter_nan diff --git a/tests/integration/test_sensor_filters_delta.py b/tests/integration/test_sensor_filters_delta.py index 9d0114e0c4..af8f314f49 100644 --- a/tests/integration/test_sensor_filters_delta.py +++ b/tests/integration/test_sensor_filters_delta.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import math from aioesphomeapi import ButtonInfo, EntityState, SensorState import pytest @@ -25,6 +26,7 @@ async def test_sensor_filters_delta( "filter_baseline_max": [], "filter_zero_delta": [], "filter_percentage": [], + "filter_nan": [], } filter_min_done = loop.create_future() @@ -32,16 +34,23 @@ async def test_sensor_filters_delta( filter_baseline_max_done = loop.create_future() filter_zero_delta_done = loop.create_future() filter_percentage_done = loop.create_future() + filter_nan_done = loop.create_future() def on_state(state: EntityState) -> None: - if not isinstance(state, SensorState) or state.missing_state: + if not isinstance(state, SensorState): return sensor_name = key_to_sensor.get(state.key) if sensor_name not in sensor_values: return - sensor_values[sensor_name].append(state.state) + if state.missing_state: + # Only the NaN test is interested in unavailable states + if sensor_name != "filter_nan": + return + sensor_values[sensor_name].append(math.nan) + else: + sensor_values[sensor_name].append(state.state) # Check completion conditions if ( @@ -74,6 +83,12 @@ async def test_sensor_filters_delta( and not filter_percentage_done.done() ): filter_percentage_done.set_result(True) + elif ( + sensor_name == "filter_nan" + and len(sensor_values[sensor_name]) == 3 + and not filter_nan_done.done() + ): + filter_nan_done.set_result(True) async with ( run_compiled(yaml_config), @@ -89,6 +104,7 @@ async def test_sensor_filters_delta( "filter_baseline_max": "Filter Baseline Max", "filter_zero_delta": "Filter Zero Delta", "filter_percentage": "Filter Percentage", + "filter_nan": "Filter NaN", }, ) @@ -108,13 +124,14 @@ async def test_sensor_filters_delta( "Test Filter Baseline Max": "filter_baseline_max", "Test Filter Zero Delta": "filter_zero_delta", "Test Filter Percentage": "filter_percentage", + "Test Filter NaN": "filter_nan", } buttons = {} for entity in entities: if isinstance(entity, ButtonInfo) and entity.name in button_name_map: buttons[button_name_map[entity.name]] = entity.key - assert len(buttons) == 5, f"Expected 5 buttons, found {len(buttons)}" + assert len(buttons) == 6, f"Expected 6 buttons, found {len(buttons)}" # Test 1: Min sensor_values["filter_min"].clear() @@ -186,3 +203,18 @@ async def test_sensor_filters_delta( assert sensor_values["filter_percentage"] == pytest.approx(expected), ( f"Test 5 failed: expected {expected}, got {sensor_values['filter_percentage']}" ) + + # Test 6: NaN passes through once, then is suppressed + sensor_values["filter_nan"].clear() + client.button_command(buttons["filter_nan"]) + try: + await asyncio.wait_for(filter_nan_done, timeout=2.0) + except TimeoutError: + pytest.fail(f"Test 6 timed out. Values: {sensor_values['filter_nan']}") + + values = sensor_values["filter_nan"] + assert values[0] == pytest.approx(1.0), f"Test 6 failed: got {values}" + assert math.isnan(values[1]), ( + f"Test 6 failed: NaN not passed through, got {values}" + ) + assert values[2] == pytest.approx(2.0), f"Test 6 failed: got {values}" From 2bc4681fd6d54d5959b93e6e5873b35ece42196d Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:33:25 +0200 Subject: [PATCH 1482/1815] [zigbee] bump esp-zigbee-sdk to 2.0.4 (#18415) --- esphome/components/zigbee/zigbee_esp32.cpp | 5 +++++ esphome/components/zigbee/zigbee_esp32.py | 2 +- esphome/idf_component.yml | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 482995e2c5..cd094306f4 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -307,6 +307,11 @@ void ZigbeeComponent::setup() { return; } #endif + +#ifdef CONFIG_ZB_ZCZR + ezb_bdb_set_router_rejoin_required(true); +#endif + ezb_aps_secur_enable_distributed_security(false); ezb_nwk_set_min_join_lqi(32); if (ezb_app_signal_add_handler(ZigbeeComponent::app_signal_handler) != ESP_OK) { diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 8e63c09e67..ade45e8cc3 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -285,7 +285,7 @@ async def attributes_to_code( async def esp32_to_code(config: ConfigType) -> "MockObj": add_idf_component( name="espressif/esp-zigbee-lib", - ref="2.0.3", + ref="2.0.4", ) # add sdkconfigs later so they can overwrite esp32 defaults diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index aff1a6819f..62fd597845 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -48,7 +48,7 @@ dependencies: rules: - if: "target in [esp32, esp32p4]" espressif/esp-zigbee-lib: - version: 2.0.3 + version: 2.0.4 rules: - if: "target in [esp32h2, esp32c5, esp32c6]" espressif/lan87xx: From c664f5fc951a8ae55eef64f14a382cdfc9e0b3dc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 11:06:22 -0700 Subject: [PATCH 1483/1815] [bk72xx_ble] Fail early with a clear error on non BLE 5.x SoCs (#18406) --- esphome/components/bk72xx_ble/__init__.py | 44 ++++++++++++++++--- esphome/components/bk72xx_ble/bdk_scan.cpp | 4 +- esphome/components/bk72xx_ble/bk72xx_ble.cpp | 22 ++++++---- tests/component_tests/bk72xx_ble/__init__.py | 0 .../bk72xx_ble/config/test_bk7231n.yaml | 7 +++ .../bk72xx_ble/config/test_bk7231q.yaml | 7 +++ .../bk72xx_ble/config/test_bk7231t.yaml | 7 +++ .../bk72xx_ble/config/test_bk7252.yaml | 7 +++ .../bk72xx_ble/test_family_gate.py | 40 +++++++++++++++++ .../config/bk72xx_controller_only.yaml | 2 +- .../config/bk72xx_tracker.yaml | 2 +- 11 files changed, 123 insertions(+), 19 deletions(-) create mode 100644 tests/component_tests/bk72xx_ble/__init__.py create mode 100644 tests/component_tests/bk72xx_ble/config/test_bk7231n.yaml create mode 100644 tests/component_tests/bk72xx_ble/config/test_bk7231q.yaml create mode 100644 tests/component_tests/bk72xx_ble/config/test_bk7231t.yaml create mode 100644 tests/component_tests/bk72xx_ble/config/test_bk7252.yaml create mode 100644 tests/component_tests/bk72xx_ble/test_family_gate.py diff --git a/esphome/components/bk72xx_ble/__init__.py b/esphome/components/bk72xx_ble/__init__.py index 23f3d06184..b58464a1f6 100644 --- a/esphome/components/bk72xx_ble/__init__.py +++ b/esphome/components/bk72xx_ble/__init__.py @@ -5,11 +5,11 @@ bring-up and the controller BLE address. Consumers (bk72xx_ble_tracker) build on this component and contain no SDK calls of their own. Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7238/BK7252N/BK7253 -(BLE 5.2), and any future BLE-5.x SoC. Capability is detected at compile time, -not by a chip list: the C++ guards on `__has_include("ble_api.h")` — the Beken -BLE 5.x public API header, which the LibreTiny beken-72xx builder ships only -for BLE-5.x SoCs. BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) fail -with a clear #error. +(BLE 5.2), and any future BLE-5.x SoC. Known non-5.x families are rejected in +to_code; unknown families are capability-checked at compile time via +`__has_include("app_ble.h")`, a header only on the BLE 5.x include path +(ble_api.h ships for every SoC, so it cannot be the probe). A non-5.x build +fails with a clear #error. No framework patch is needed: the LibreTiny beken-72xx builder already compiles and links the BLE 5.x stack (CFG_SUPPORT_BLE=1 + CFG_BLE_VERSION=BLE_VERSION_5_x; @@ -21,9 +21,16 @@ import logging import esphome.codegen as cg from esphome.components import libretiny -from esphome.components.libretiny.const import FAMILY_BK7231N, FAMILY_BK7238 +from esphome.components.libretiny.const import ( + FAMILY_BK7231N, + FAMILY_BK7231Q, + FAMILY_BK7231T, + FAMILY_BK7238, + FAMILY_BK7251, +) import esphome.config_validation as cv from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID +from esphome.core import EsphomeError from esphome.types import ConfigType DEPENDENCIES = ["bk72xx"] @@ -50,7 +57,32 @@ CONFIG_SCHEMA = cv.Schema( request_scan_listener_slot = cg.slot_counter("BK72XX_BLE_SCAN_LISTENER_COUNT") +def _unsupported_family_message(family: str) -> str | None: + if family in (FAMILY_BK7231T, FAMILY_BK7251): + return ( + f"bk72xx_ble does not support {family}: this SoC has the Beken BLE 4.2 " + "stack; a BLE 5.x SoC such as BK7231N or BK7238 is required" + ) + if family == FAMILY_BK7231Q: + return "bk72xx_ble does not support BK7231Q: this SoC has no BLE" + return None + + +def _final_validate(config: ConfigType) -> ConfigType: + # Warn only: a hard error here would break the validate-only CI fixtures, + # which run on a BLE 4.2 board. The hard error is raised at codegen. + if msg := _unsupported_family_message(libretiny.get_libretiny_family()): + _LOGGER.warning("%s (this configuration cannot compile)", msg) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config: ConfigType) -> None: + if msg := _unsupported_family_message(libretiny.get_libretiny_family()): + raise EsphomeError(msg) + var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/bk72xx_ble/bdk_scan.cpp b/esphome/components/bk72xx_ble/bdk_scan.cpp index bd4e51d9b7..f17f21c06b 100644 --- a/esphome/components/bk72xx_ble/bdk_scan.cpp +++ b/esphome/components/bk72xx_ble/bdk_scan.cpp @@ -10,7 +10,7 @@ #ifdef USE_BK72XX_BLE // Same SDK gate as bk72xx_ble.cpp (which carries the explanatory #error). -#if !defined(CLANG_TIDY) && __has_include("ble_api.h") +#if !defined(CLANG_TIDY) && __has_include("ble_api.h") && __has_include("app_ble.h") extern "C" { #include "app_ble.h" // app_ble_env, app_ble_run, app_ble_reset, actv_state_t, @@ -115,5 +115,5 @@ BdkOpResult bdk_scan_release(uint8_t activity_idx, bool created, int *err_out) { } // namespace esphome::bk72xx_ble -#endif // !CLANG_TIDY && ble_api.h +#endif // !CLANG_TIDY && ble_api.h && app_ble.h #endif // USE_BK72XX_BLE diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.cpp b/esphome/components/bk72xx_ble/bk72xx_ble.cpp index d40f08d111..52401114e6 100644 --- a/esphome/components/bk72xx_ble/bk72xx_ble.cpp +++ b/esphome/components/bk72xx_ble/bk72xx_ble.cpp @@ -34,22 +34,26 @@ // --------------------------------------------------------------------------- // SDK-capability gate (not a chip allowlist). -// This component drives the Beken BLE *5.x* controller via its public API, -// `ble_api.h`, which the LibreTiny beken-72xx builder ships only for the -// BLE-5.x SoCs (it selects the `ble_pub` 5.x stack from CFG_BLE_VERSION; the -// 4.2 SoCs build a different, older API with no ble_api.h). Gate on the header -// itself so any BLE-5.x Beken chip — present or future — is supported without a -// hard-coded list, and a non-5.x build fails here with a clear message instead -// of a cryptic "ble_api.h: No such file or directory". +// This component drives the Beken BLE *5.x* controller. `ble_api.h` cannot be +// the probe: it ships for every SoC (driver/include) and merely switches on +// CFG_BLE_VERSION internally. `app_ble.h` is on the include path only when the +// LibreTiny beken-72xx builder selects a 5.x stack, so gating on it supports +// any BLE-5.x chip — present or future — without a hard-coded list, and a +// non-5.x build fails here with a clear message instead of a cryptic +// "app_ble.h: No such file or directory". // --------------------------------------------------------------------------- #if defined(CLANG_TIDY) // The clang-tidy environment does not carry the full Beken BDK BLE 5.x API // (its ble_api.h variant lacks parts of the 5.x surface), so there is nothing // accurate to analyze the SDK calls against — skip the file under analysis. #define BK72XX_BLE_NO_SDK -#elif !__has_include("ble_api.h") +#elif !__has_include("ble_api.h") || !__has_include("app_ble.h") +// Also skip the SDK body: #error does not stop the preprocessor, and on a 4.2 +// SoC ble_api.h exists, so without the guard the 5.x symbols would fail one by +// one and bury this message. +#define BK72XX_BLE_NO_SDK #error \ - "bk72xx_ble requires a BLE 5.x Beken SDK (ble_api.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported." + "bk72xx_ble requires a BLE 5.x Beken SDK (app_ble.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported." #endif #ifndef BK72XX_BLE_NO_SDK diff --git a/tests/component_tests/bk72xx_ble/__init__.py b/tests/component_tests/bk72xx_ble/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7231n.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7231n.yaml new file mode 100644 index 0000000000..772ab93c79 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7231n.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-n + +bk72xx: + board: cb2s + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7231q.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7231q.yaml new file mode 100644 index 0000000000..17fd15b1b4 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7231q.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-q + +bk72xx: + board: wa2 + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7231t.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7231t.yaml new file mode 100644 index 0000000000..fec21a6aae --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7231t.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-t + +bk72xx: + board: generic-bk7231t-qfn32-tuya + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7252.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7252.yaml new file mode 100644 index 0000000000..a3290ab50a --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7252.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-7252 + +bk72xx: + board: generic-bk7252 + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/test_family_gate.py b/tests/component_tests/bk72xx_ble/test_family_gate.py new file mode 100644 index 0000000000..da67749bb3 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/test_family_gate.py @@ -0,0 +1,40 @@ +"""The non-5.x family rejection lives in to_code (config validation must stay +family-agnostic for the validate-only CI fixtures), so codegen is the only +place it can be pinned.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.core import EsphomeError + + +@pytest.mark.parametrize( + ("config_file", "match"), + [ + ("test_bk7231t.yaml", "BK7231T.*BLE 4.2"), + ("test_bk7252.yaml", "BK7251.*BLE 4.2"), + ("test_bk7231q.yaml", "BK7231Q.*no BLE"), + ], +) +def test_unsupported_family_rejected( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + match: str, + caplog: pytest.LogCaptureFixture, +) -> None: + with pytest.raises(EsphomeError, match=match): + generate_main(component_config_path(config_file)) + # Validation itself must not fail (CI validate fixtures run on a BLE 4.2 + # board), but it warns before codegen raises. + assert "cannot compile" in caplog.text + + +def test_ble5_family_generates( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("test_bk7231n.yaml")) + assert "bk72xx_ble::BK72xxBLE" in main_cpp diff --git a/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml b/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml index 4d4dab0198..7912fceed6 100644 --- a/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml +++ b/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml @@ -2,6 +2,6 @@ esphome: name: slotcount-controller bk72xx: - board: generic-bk7252 + board: cb2s bk72xx_ble: diff --git a/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml b/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml index 79e9644006..b813e2702e 100644 --- a/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml +++ b/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml @@ -2,6 +2,6 @@ esphome: name: slotcount-tracker bk72xx: - board: generic-bk7252 + board: cb2s bk72xx_ble_tracker: From 32c76ae8289326cb2f17d9712db190fc2d599028 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 12:43:27 -0700 Subject: [PATCH 1484/1815] [core] Skip redundant ESP8266 main loop wake posts from ISR context (#18416) --- esphome/core/wake/wake_esp8266.h | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/esphome/core/wake/wake_esp8266.h b/esphome/core/wake/wake_esp8266.h index 7eaaae5293..73b7a38a35 100644 --- a/esphome/core/wake/wake_esp8266.h +++ b/esphome/core/wake/wake_esp8266.h @@ -15,6 +15,13 @@ 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(); + // Skip the post when a wake was already signalled and not yet consumed by + // wakeable_delay(): esp_schedule() -> ets_post() can enter SDK WiFi pm code, + // which must not be poked per-byte from the software serial RX ISR (see + // esphome#18409). The flag can stay latched while the loop is awake, which + // is intentional; posts are only needed to cut a suspend short. + if (g_main_loop_woke) + return; g_main_loop_woke = true; esp_schedule(); } From 801a1817b58909e5bc243b493c53e3e2265ada07 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 12:44:00 -0700 Subject: [PATCH 1485/1815] [esp32] Split crash handler addr2line hint per core (#18418) --- esphome/components/esp32/crash_handler.cpp | 29 +++++++++++----------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 1b054dcc49..b61dad7386 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -360,17 +360,6 @@ static bool has_fault_addr() { return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause; } -// Append both cores' backtrace addresses to buf; returns the new position. -static int append_all_backtraces(char *buf, int size, int pos) { - pos = append_addrs_to_hint(buf, size, pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, - s_raw_crash_data.reg_frame_count); -#if SOC_CPU_CORES_NUM > 1 - pos = append_addrs_to_hint(buf, size, pos, s_raw_crash_data.other_backtrace, s_raw_crash_data.other_backtrace_count, - s_raw_crash_data.other_reg_frame_count); -#endif - return pos; -} - // The record was captured by a different firmware build (it survives soft // resets, including the OTA reboot), so symbolizing its addresses against the // current ELF would produce misleading symbols. Print them with lowercase @@ -443,11 +432,23 @@ void crash_handler_log() { } #endif - // Build addr2line hint with all captured addresses for easy copy-paste + // Build addr2line hints for easy copy-paste. One line per core: the two + // backtraces are separate stacks, and a combined list decodes as one + // impossible call chain (and can overflow the buffer, dropping addresses). + static const char *const ADDR2LINE_CMD = "addr2line -pfiaC -e firmware.elf"; char hint[256]; - int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e firmware.elf 0x%08" PRIX32, s_raw_crash_data.pc); - append_all_backtraces(hint, sizeof(hint), pos); + int pos = snprintf(hint, sizeof(hint), "Use: %s 0x%08" PRIX32, ADDR2LINE_CMD, s_raw_crash_data.pc); + append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, + s_raw_crash_data.reg_frame_count); ESP_LOGE(TAG, "%s", hint); +#if SOC_CPU_CORES_NUM > 1 + if (s_raw_crash_data.other_backtrace_count > 0) { + pos = snprintf(hint, sizeof(hint), "Other core: %s", ADDR2LINE_CMD); + append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.other_backtrace, + s_raw_crash_data.other_backtrace_count, s_raw_crash_data.other_reg_frame_count); + ESP_LOGE(TAG, "%s", hint); + } +#endif } } // namespace esphome::esp32 From 3f01f9895f0c98179d8301dbed46aa02801d7f77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 12:44:49 -0700 Subject: [PATCH 1486/1815] [esp32_hosted] Require ESP-IDF 5.3 or newer (#18417) --- esphome/components/esp32_hosted/__init__.py | 34 ++++++++++++------ .../component_tests/esp32_hosted/__init__.py | 0 .../component_tests/esp32_hosted/test_init.py | 35 +++++++++++++++++++ 3 files changed, 59 insertions(+), 10 deletions(-) create mode 100644 tests/component_tests/esp32_hosted/__init__.py create mode 100644 tests/component_tests/esp32_hosted/test_init.py diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index c6a714aace..d3432fb461 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -16,8 +16,10 @@ from esphome.const import ( CONF_VARIANT, ) from esphome.cpp_generator import add_define +from esphome.types import ConfigType CODEOWNERS = ["@swoboda1337"] +DEPENDENCIES = ["esp32"] # esp32_ble raises the task watchdog around the remote BT controller bring-up AUTO_LOAD = ["watchdog"] @@ -124,6 +126,22 @@ CONFIG_SCHEMA = cv.typed_schema( ) +def _final_validate(config: ConfigType) -> ConfigType: + # The esp_hosted releases compatible with older ESP-IDF versions crash at + # boot with a heap double free in the SDIO RX path (fixed in esp_hosted + # 2.11.0, which requires ESP-IDF 5.3), so reject them at validation time. + if (idf_ver := esp32.idf_version()) < cv.Version(5, 3, 0): + raise cv.Invalid( + f"esp32_hosted requires ESP-IDF 5.3 or newer, got {idf_ver}. " + "Remove the framework version from your configuration to use the " + "recommended version, or pin a version at or above 5.3." + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + def _configure_sdio(config): slot = config[CONF_SLOT] esp32.add_idf_sdkconfig_option( @@ -251,18 +269,14 @@ async def to_code(config): if config[CONF_USE_PSRAM]: esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM", True) - # Library versions + # Library versions; this component set requires ESP-IDF 5.3 or newer, + # which is enforced at validation time. idf_ver = esp32.idf_version() os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}" - if idf_ver >= cv.Version(5, 5, 0): - esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.3") - esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.3") - esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.12") - else: - esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") - esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.0.11") + esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.3") + esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.3") + esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.12") esp32.add_extra_script( "post", "esp32_hosted.py", diff --git a/tests/component_tests/esp32_hosted/__init__.py b/tests/component_tests/esp32_hosted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/esp32_hosted/test_init.py b/tests/component_tests/esp32_hosted/test_init.py new file mode 100644 index 0000000000..cec81e4e83 --- /dev/null +++ b/tests/component_tests/esp32_hosted/test_init.py @@ -0,0 +1,35 @@ +"""Tests for the esp32_hosted ESP-IDF version gate.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_IDF_VERSION +from esphome.components.esp32_hosted import _final_validate +from esphome.const import PlatformFramework + +from ..types import SetCoreConfigCallable + + +@pytest.mark.parametrize("idf", ["5.3.0", "5.4.2", "5.5.5"]) +def test_final_validate_accepts_supported_idf( + set_core_config: SetCoreConfigCallable, idf: str +) -> None: + """ESP-IDF 5.3 and newer passes validation unchanged.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)}, + ) + assert _final_validate({}) == {} + + +@pytest.mark.parametrize("idf", ["5.0.0", "5.2.2"]) +def test_final_validate_rejects_old_idf( + set_core_config: SetCoreConfigCallable, idf: str +) -> None: + """ESP-IDF older than 5.3 is rejected with a clear error.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)}, + ) + with pytest.raises(cv.Invalid, match="requires ESP-IDF 5.3 or newer"): + _final_validate({}) From 9161f74bb1e58b29f76f92bd5c298adbcbdf728b Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:48:27 +0000 Subject: [PATCH 1487/1815] Bump bundled esphome-device-builder to 1.10.0 (#18389) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index d7ae2cd4ec..a62eb59a58 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.9.6 +RUN uv pip install --no-cache-dir esphome-device-builder==1.10.0 RUN \ platformio settings set enable_telemetry No \ From 46a5665a66873f990398a477dab767c8620e66a1 Mon Sep 17 00:00:00 2001 From: Samuel Sieb Date: Sat, 15 Aug 2026 11:16:04 -0700 Subject: [PATCH 1488/1815] [rotary_encoder] account for min and max value when resetting (#18197) --- esphome/components/rotary_encoder/rotary_encoder.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/rotary_encoder/rotary_encoder.cpp b/esphome/components/rotary_encoder/rotary_encoder.cpp index 0831822d86..0734ca87d3 100644 --- a/esphome/components/rotary_encoder/rotary_encoder.cpp +++ b/esphome/components/rotary_encoder/rotary_encoder.cpp @@ -220,7 +220,7 @@ void RotaryEncoderSensor::loop() { } if (this->pin_i_ != nullptr && this->pin_i_->digital_read()) { - this->store_.counter = 0; + this->store_.counter = std::clamp(0, this->store_.min_value, this->store_.max_value); } int counter = this->store_.counter; if (this->store_.last_read != counter || this->publish_initial_value_) { From dda4566b9e32fd2fab3faa5b7a7335c0bda2fda3 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:36:52 -0700 Subject: [PATCH 1489/1815] Bump bundled esphome-device-builder to 1.11.0 (#18403) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index a62eb59a58..2f23b2f690 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.10.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.0 RUN \ platformio settings set enable_telemetry No \ From ce09504c923a171935d4cb80e598aeaf1cdea1fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 08:42:47 -0700 Subject: [PATCH 1490/1815] [platformio] Skip ccache when the binary on PATH fails to run (#18407) --- esphome/platformio/toolchain.py | 32 ++++++++++++- tests/unit_tests/test_platformio_toolchain.py | 48 ++++++++++++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index 0e7ffce939..08a4fcff78 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -5,6 +5,7 @@ import os from pathlib import Path import re import shutil +import subprocess import sys from typing import TYPE_CHECKING, Any @@ -234,6 +235,35 @@ def _check_platformio_python_stamp(config: "ProjectConfig") -> None: _write_pio_stamp_python(stamp_file, current) +def _ccache_usable() -> bool: + """Return True when the ``ccache`` on PATH actually runs. + + ``shutil.which`` proves existence, not runnability: on Windows it also + matches ``.bat``/``.cmd`` wrappers and stale package-manager shims whose + target is gone. Wrapping compiles around such a find fails every compile + step with an opaque OS error, so probe once and fall back to compiling + without ccache when the probe fails. + """ + ccache = shutil.which("ccache") + if ccache is None: + return False + try: + subprocess.run( + [ccache, "--version"], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=15, + ) + except (OSError, subprocess.SubprocessError): + _LOGGER.warning( + "Ignoring ccache at %s because it failed to run; compiling without ccache", + ccache, + ) + return False + return True + + def _ccache_env() -> dict[str, str]: """Return ccache settings for PlatformIO builds. @@ -266,7 +296,7 @@ def _ccache_env() -> dict[str, str]: if "ESPHOME_CCACHE_ENABLE" in os.environ: enabled = get_bool_env("ESPHOME_CCACHE_ENABLE") else: - enabled = shutil.which("ccache") is not None + enabled = _ccache_usable() env = {"ESPHOME_CCACHE_ENABLE": "1" if enabled else "0"} if not enabled: return env diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index 02c11b4e45..eebb0b8cd7 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -9,6 +9,7 @@ import json import os from pathlib import Path import shutil +import subprocess import sys import threading from types import SimpleNamespace @@ -431,6 +432,7 @@ def test_ccache_env_enabled_by_default(setup_core: Path) -> None: with ( patch.dict(os.environ, {}, clear=True), patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), ): env = toolchain._ccache_env() @@ -457,6 +459,44 @@ def test_ccache_env_disabled_without_binary(setup_core: Path) -> None: assert env == {"ESPHOME_CCACHE_ENABLE": "0"} +@pytest.mark.parametrize( + "probe_error", + [ + pytest.param(OSError("not runnable"), id="oserror"), + pytest.param(subprocess.CalledProcessError(1, "ccache"), id="nonzero-exit"), + pytest.param(subprocess.TimeoutExpired("ccache", 15), id="timeout"), + ], +) +def test_ccache_env_disabled_when_probe_fails( + setup_core: Path, probe_error: Exception +) -> None: + """A ccache that resolves on PATH but fails to run stays disabled.""" + CORE.build_path = setup_core / "build" / "test" + + with ( + patch.dict(os.environ, {}, clear=True), + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run", side_effect=probe_error), + ): + env = toolchain._ccache_env() + + assert env == {"ESPHOME_CCACHE_ENABLE": "0"} + + +def test_ccache_env_forced_on_skips_probe(setup_core: Path) -> None: + """An explicit ESPHOME_CCACHE_ENABLE=1 does not probe the binary.""" + CORE.build_path = setup_core / "build" / "test" + + with ( + patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True), + patch.object(toolchain.subprocess, "run") as mock_probe, + ): + env = toolchain._ccache_env() + + assert env["ESPHOME_CCACHE_ENABLE"] == "1" + mock_probe.assert_not_called() + + def test_ccache_env_opt_out(setup_core: Path) -> None: """ESPHOME_CCACHE_ENABLE=0 disables ccache even with the binary present.""" CORE.build_path = setup_core / "build" / "test" @@ -496,6 +536,7 @@ def test_ccache_env_respects_user_values_and_refreshes_basedir( with ( patch.dict(os.environ, user_env, clear=True), patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), ): env = toolchain._ccache_env() @@ -514,6 +555,7 @@ def test_run_platformio_cli_passes_ccache_env_to_subprocess_only( with ( patch.dict(os.environ, {}, clear=False), patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), ): os.environ.pop("ESPHOME_CCACHE_ENABLE", None) mock_run_external_process.return_value = 0 @@ -533,6 +575,7 @@ def test_ccache_env_requires_build_path(setup_core: Path) -> None: with ( patch.dict(os.environ, {}, clear=True), patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), pytest.raises(ValueError, match="CORE.build_path must be set"), ): toolchain._ccache_env() @@ -544,7 +587,10 @@ def test_run_platformio_cli_merges_caller_env( """A caller-supplied env is the base and gains the ccache settings.""" CORE.build_path = str(setup_core / "build" / "test") - with patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"): + with ( + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), + ): mock_run_external_process.return_value = 0 toolchain.run_platformio_cli( "test", env={"CUSTOM_VAR": "1", "ESPHOME_CCACHE_ENABLE": "0"} From bca72e9b6d7d6a4bebff6da0a946e952aef081e5 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:36:51 -0400 Subject: [PATCH 1491/1815] [sensor] Pass NaN through the delta filter again (#18400) --- esphome/components/sensor/filter.cpp | 10 +++-- .../fixtures/sensor_filters_delta.yaml | 36 ++++++++++++++++++ .../integration/test_sensor_filters_delta.py | 38 +++++++++++++++++-- 3 files changed, 77 insertions(+), 7 deletions(-) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 5f7f19769a..0105580d26 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -283,8 +283,11 @@ DeltaFilter::DeltaFilter(float min_a0, float min_a1, float max_a0, float max_a1) void DeltaFilter::set_baseline(float (*fn)(float)) { this->baseline_ = fn; } optional DeltaFilter::new_value(float value) { - // Always yield the first value. - if (std::isnan(this->last_value_)) { + const bool no_value = std::isnan(value); + const bool no_reference = std::isnan(this->last_value_); + if (no_value && no_reference) + return {}; + if (no_value || no_reference) { this->last_value_ = value; return value; } @@ -293,8 +296,7 @@ optional DeltaFilter::new_value(float value) { float min = fabsf(this->min_a0_ + ref * this->min_a1_); float max = fabsf(this->max_a0_ + ref * this->max_a1_); float delta = fabsf(value - ref); - // if there is no reference, e.g. for the first value, just accept this one, - // otherwise accept only if within range. + // accept only if within range if (delta > min && delta <= max) { this->last_value_ = value; return value; diff --git a/tests/integration/fixtures/sensor_filters_delta.yaml b/tests/integration/fixtures/sensor_filters_delta.yaml index 2494a430da..b01c8e452b 100644 --- a/tests/integration/fixtures/sensor_filters_delta.yaml +++ b/tests/integration/fixtures/sensor_filters_delta.yaml @@ -33,6 +33,11 @@ sensor: id: source_sensor_5 accuracy_decimals: 1 + - platform: template + name: "Source Sensor 6" + id: source_sensor_6 + accuracy_decimals: 1 + - platform: copy source_id: source_sensor_1 name: "Filter Min" @@ -81,6 +86,13 @@ sensor: filters: - delta: 50% + - platform: copy + source_id: source_sensor_6 + name: "Filter NaN" + id: filter_nan + filters: + - delta: 0 + script: - id: test_filter_min then: @@ -188,6 +200,24 @@ script: id: source_sensor_5 state: 250.0 # Passes (delta=90 > 80) + - id: test_filter_nan + then: + - sensor.template.publish: + id: source_sensor_6 + state: 1.0 + - delay: 20ms + - sensor.template.publish: + id: source_sensor_6 + state: !lambda "return NAN;" + - delay: 20ms + - sensor.template.publish: + id: source_sensor_6 + state: !lambda "return NAN;" # Filtered out + - delay: 20ms + - sensor.template.publish: + id: source_sensor_6 + state: 2.0 + button: - platform: template name: "Test Filter Min" @@ -218,3 +248,9 @@ button: id: btn_filter_percentage on_press: - script.execute: test_filter_percentage + + - platform: template + name: "Test Filter NaN" + id: btn_filter_nan + on_press: + - script.execute: test_filter_nan diff --git a/tests/integration/test_sensor_filters_delta.py b/tests/integration/test_sensor_filters_delta.py index 9d0114e0c4..af8f314f49 100644 --- a/tests/integration/test_sensor_filters_delta.py +++ b/tests/integration/test_sensor_filters_delta.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import math from aioesphomeapi import ButtonInfo, EntityState, SensorState import pytest @@ -25,6 +26,7 @@ async def test_sensor_filters_delta( "filter_baseline_max": [], "filter_zero_delta": [], "filter_percentage": [], + "filter_nan": [], } filter_min_done = loop.create_future() @@ -32,16 +34,23 @@ async def test_sensor_filters_delta( filter_baseline_max_done = loop.create_future() filter_zero_delta_done = loop.create_future() filter_percentage_done = loop.create_future() + filter_nan_done = loop.create_future() def on_state(state: EntityState) -> None: - if not isinstance(state, SensorState) or state.missing_state: + if not isinstance(state, SensorState): return sensor_name = key_to_sensor.get(state.key) if sensor_name not in sensor_values: return - sensor_values[sensor_name].append(state.state) + if state.missing_state: + # Only the NaN test is interested in unavailable states + if sensor_name != "filter_nan": + return + sensor_values[sensor_name].append(math.nan) + else: + sensor_values[sensor_name].append(state.state) # Check completion conditions if ( @@ -74,6 +83,12 @@ async def test_sensor_filters_delta( and not filter_percentage_done.done() ): filter_percentage_done.set_result(True) + elif ( + sensor_name == "filter_nan" + and len(sensor_values[sensor_name]) == 3 + and not filter_nan_done.done() + ): + filter_nan_done.set_result(True) async with ( run_compiled(yaml_config), @@ -89,6 +104,7 @@ async def test_sensor_filters_delta( "filter_baseline_max": "Filter Baseline Max", "filter_zero_delta": "Filter Zero Delta", "filter_percentage": "Filter Percentage", + "filter_nan": "Filter NaN", }, ) @@ -108,13 +124,14 @@ async def test_sensor_filters_delta( "Test Filter Baseline Max": "filter_baseline_max", "Test Filter Zero Delta": "filter_zero_delta", "Test Filter Percentage": "filter_percentage", + "Test Filter NaN": "filter_nan", } buttons = {} for entity in entities: if isinstance(entity, ButtonInfo) and entity.name in button_name_map: buttons[button_name_map[entity.name]] = entity.key - assert len(buttons) == 5, f"Expected 5 buttons, found {len(buttons)}" + assert len(buttons) == 6, f"Expected 6 buttons, found {len(buttons)}" # Test 1: Min sensor_values["filter_min"].clear() @@ -186,3 +203,18 @@ async def test_sensor_filters_delta( assert sensor_values["filter_percentage"] == pytest.approx(expected), ( f"Test 5 failed: expected {expected}, got {sensor_values['filter_percentage']}" ) + + # Test 6: NaN passes through once, then is suppressed + sensor_values["filter_nan"].clear() + client.button_command(buttons["filter_nan"]) + try: + await asyncio.wait_for(filter_nan_done, timeout=2.0) + except TimeoutError: + pytest.fail(f"Test 6 timed out. Values: {sensor_values['filter_nan']}") + + values = sensor_values["filter_nan"] + assert values[0] == pytest.approx(1.0), f"Test 6 failed: got {values}" + assert math.isnan(values[1]), ( + f"Test 6 failed: NaN not passed through, got {values}" + ) + assert values[2] == pytest.approx(2.0), f"Test 6 failed: got {values}" From 594c12b3d961a20576b2425e75d4d05f18fc1993 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:33:25 +0200 Subject: [PATCH 1492/1815] [zigbee] bump esp-zigbee-sdk to 2.0.4 (#18415) --- esphome/components/zigbee/zigbee_esp32.cpp | 5 +++++ esphome/components/zigbee/zigbee_esp32.py | 2 +- esphome/idf_component.yml | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 482995e2c5..cd094306f4 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -307,6 +307,11 @@ void ZigbeeComponent::setup() { return; } #endif + +#ifdef CONFIG_ZB_ZCZR + ezb_bdb_set_router_rejoin_required(true); +#endif + ezb_aps_secur_enable_distributed_security(false); ezb_nwk_set_min_join_lqi(32); if (ezb_app_signal_add_handler(ZigbeeComponent::app_signal_handler) != ESP_OK) { diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 8e63c09e67..ade45e8cc3 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -285,7 +285,7 @@ async def attributes_to_code( async def esp32_to_code(config: ConfigType) -> "MockObj": add_idf_component( name="espressif/esp-zigbee-lib", - ref="2.0.3", + ref="2.0.4", ) # add sdkconfigs later so they can overwrite esp32 defaults diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index aff1a6819f..62fd597845 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -48,7 +48,7 @@ dependencies: rules: - if: "target in [esp32, esp32p4]" espressif/esp-zigbee-lib: - version: 2.0.3 + version: 2.0.4 rules: - if: "target in [esp32h2, esp32c5, esp32c6]" espressif/lan87xx: From 0bc2d7137078ccb28aa3a8fc8ddbb4ae100a3c52 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 11:06:22 -0700 Subject: [PATCH 1493/1815] [bk72xx_ble] Fail early with a clear error on non BLE 5.x SoCs (#18406) --- esphome/components/bk72xx_ble/__init__.py | 44 ++++++++++++++++--- esphome/components/bk72xx_ble/bdk_scan.cpp | 4 +- esphome/components/bk72xx_ble/bk72xx_ble.cpp | 22 ++++++---- tests/component_tests/bk72xx_ble/__init__.py | 0 .../bk72xx_ble/config/test_bk7231n.yaml | 7 +++ .../bk72xx_ble/config/test_bk7231q.yaml | 7 +++ .../bk72xx_ble/config/test_bk7231t.yaml | 7 +++ .../bk72xx_ble/config/test_bk7252.yaml | 7 +++ .../bk72xx_ble/test_family_gate.py | 40 +++++++++++++++++ .../config/bk72xx_controller_only.yaml | 2 +- .../config/bk72xx_tracker.yaml | 2 +- 11 files changed, 123 insertions(+), 19 deletions(-) create mode 100644 tests/component_tests/bk72xx_ble/__init__.py create mode 100644 tests/component_tests/bk72xx_ble/config/test_bk7231n.yaml create mode 100644 tests/component_tests/bk72xx_ble/config/test_bk7231q.yaml create mode 100644 tests/component_tests/bk72xx_ble/config/test_bk7231t.yaml create mode 100644 tests/component_tests/bk72xx_ble/config/test_bk7252.yaml create mode 100644 tests/component_tests/bk72xx_ble/test_family_gate.py diff --git a/esphome/components/bk72xx_ble/__init__.py b/esphome/components/bk72xx_ble/__init__.py index 23f3d06184..b58464a1f6 100644 --- a/esphome/components/bk72xx_ble/__init__.py +++ b/esphome/components/bk72xx_ble/__init__.py @@ -5,11 +5,11 @@ bring-up and the controller BLE address. Consumers (bk72xx_ble_tracker) build on this component and contain no SDK calls of their own. Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7238/BK7252N/BK7253 -(BLE 5.2), and any future BLE-5.x SoC. Capability is detected at compile time, -not by a chip list: the C++ guards on `__has_include("ble_api.h")` — the Beken -BLE 5.x public API header, which the LibreTiny beken-72xx builder ships only -for BLE-5.x SoCs. BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) fail -with a clear #error. +(BLE 5.2), and any future BLE-5.x SoC. Known non-5.x families are rejected in +to_code; unknown families are capability-checked at compile time via +`__has_include("app_ble.h")`, a header only on the BLE 5.x include path +(ble_api.h ships for every SoC, so it cannot be the probe). A non-5.x build +fails with a clear #error. No framework patch is needed: the LibreTiny beken-72xx builder already compiles and links the BLE 5.x stack (CFG_SUPPORT_BLE=1 + CFG_BLE_VERSION=BLE_VERSION_5_x; @@ -21,9 +21,16 @@ import logging import esphome.codegen as cg from esphome.components import libretiny -from esphome.components.libretiny.const import FAMILY_BK7231N, FAMILY_BK7238 +from esphome.components.libretiny.const import ( + FAMILY_BK7231N, + FAMILY_BK7231Q, + FAMILY_BK7231T, + FAMILY_BK7238, + FAMILY_BK7251, +) import esphome.config_validation as cv from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID +from esphome.core import EsphomeError from esphome.types import ConfigType DEPENDENCIES = ["bk72xx"] @@ -50,7 +57,32 @@ CONFIG_SCHEMA = cv.Schema( request_scan_listener_slot = cg.slot_counter("BK72XX_BLE_SCAN_LISTENER_COUNT") +def _unsupported_family_message(family: str) -> str | None: + if family in (FAMILY_BK7231T, FAMILY_BK7251): + return ( + f"bk72xx_ble does not support {family}: this SoC has the Beken BLE 4.2 " + "stack; a BLE 5.x SoC such as BK7231N or BK7238 is required" + ) + if family == FAMILY_BK7231Q: + return "bk72xx_ble does not support BK7231Q: this SoC has no BLE" + return None + + +def _final_validate(config: ConfigType) -> ConfigType: + # Warn only: a hard error here would break the validate-only CI fixtures, + # which run on a BLE 4.2 board. The hard error is raised at codegen. + if msg := _unsupported_family_message(libretiny.get_libretiny_family()): + _LOGGER.warning("%s (this configuration cannot compile)", msg) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config: ConfigType) -> None: + if msg := _unsupported_family_message(libretiny.get_libretiny_family()): + raise EsphomeError(msg) + var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/bk72xx_ble/bdk_scan.cpp b/esphome/components/bk72xx_ble/bdk_scan.cpp index bd4e51d9b7..f17f21c06b 100644 --- a/esphome/components/bk72xx_ble/bdk_scan.cpp +++ b/esphome/components/bk72xx_ble/bdk_scan.cpp @@ -10,7 +10,7 @@ #ifdef USE_BK72XX_BLE // Same SDK gate as bk72xx_ble.cpp (which carries the explanatory #error). -#if !defined(CLANG_TIDY) && __has_include("ble_api.h") +#if !defined(CLANG_TIDY) && __has_include("ble_api.h") && __has_include("app_ble.h") extern "C" { #include "app_ble.h" // app_ble_env, app_ble_run, app_ble_reset, actv_state_t, @@ -115,5 +115,5 @@ BdkOpResult bdk_scan_release(uint8_t activity_idx, bool created, int *err_out) { } // namespace esphome::bk72xx_ble -#endif // !CLANG_TIDY && ble_api.h +#endif // !CLANG_TIDY && ble_api.h && app_ble.h #endif // USE_BK72XX_BLE diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.cpp b/esphome/components/bk72xx_ble/bk72xx_ble.cpp index d40f08d111..52401114e6 100644 --- a/esphome/components/bk72xx_ble/bk72xx_ble.cpp +++ b/esphome/components/bk72xx_ble/bk72xx_ble.cpp @@ -34,22 +34,26 @@ // --------------------------------------------------------------------------- // SDK-capability gate (not a chip allowlist). -// This component drives the Beken BLE *5.x* controller via its public API, -// `ble_api.h`, which the LibreTiny beken-72xx builder ships only for the -// BLE-5.x SoCs (it selects the `ble_pub` 5.x stack from CFG_BLE_VERSION; the -// 4.2 SoCs build a different, older API with no ble_api.h). Gate on the header -// itself so any BLE-5.x Beken chip — present or future — is supported without a -// hard-coded list, and a non-5.x build fails here with a clear message instead -// of a cryptic "ble_api.h: No such file or directory". +// This component drives the Beken BLE *5.x* controller. `ble_api.h` cannot be +// the probe: it ships for every SoC (driver/include) and merely switches on +// CFG_BLE_VERSION internally. `app_ble.h` is on the include path only when the +// LibreTiny beken-72xx builder selects a 5.x stack, so gating on it supports +// any BLE-5.x chip — present or future — without a hard-coded list, and a +// non-5.x build fails here with a clear message instead of a cryptic +// "app_ble.h: No such file or directory". // --------------------------------------------------------------------------- #if defined(CLANG_TIDY) // The clang-tidy environment does not carry the full Beken BDK BLE 5.x API // (its ble_api.h variant lacks parts of the 5.x surface), so there is nothing // accurate to analyze the SDK calls against — skip the file under analysis. #define BK72XX_BLE_NO_SDK -#elif !__has_include("ble_api.h") +#elif !__has_include("ble_api.h") || !__has_include("app_ble.h") +// Also skip the SDK body: #error does not stop the preprocessor, and on a 4.2 +// SoC ble_api.h exists, so without the guard the 5.x symbols would fail one by +// one and bury this message. +#define BK72XX_BLE_NO_SDK #error \ - "bk72xx_ble requires a BLE 5.x Beken SDK (ble_api.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported." + "bk72xx_ble requires a BLE 5.x Beken SDK (app_ble.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported." #endif #ifndef BK72XX_BLE_NO_SDK diff --git a/tests/component_tests/bk72xx_ble/__init__.py b/tests/component_tests/bk72xx_ble/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7231n.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7231n.yaml new file mode 100644 index 0000000000..772ab93c79 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7231n.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-n + +bk72xx: + board: cb2s + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7231q.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7231q.yaml new file mode 100644 index 0000000000..17fd15b1b4 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7231q.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-q + +bk72xx: + board: wa2 + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7231t.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7231t.yaml new file mode 100644 index 0000000000..fec21a6aae --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7231t.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-t + +bk72xx: + board: generic-bk7231t-qfn32-tuya + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7252.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7252.yaml new file mode 100644 index 0000000000..a3290ab50a --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7252.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-7252 + +bk72xx: + board: generic-bk7252 + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/test_family_gate.py b/tests/component_tests/bk72xx_ble/test_family_gate.py new file mode 100644 index 0000000000..da67749bb3 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/test_family_gate.py @@ -0,0 +1,40 @@ +"""The non-5.x family rejection lives in to_code (config validation must stay +family-agnostic for the validate-only CI fixtures), so codegen is the only +place it can be pinned.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.core import EsphomeError + + +@pytest.mark.parametrize( + ("config_file", "match"), + [ + ("test_bk7231t.yaml", "BK7231T.*BLE 4.2"), + ("test_bk7252.yaml", "BK7251.*BLE 4.2"), + ("test_bk7231q.yaml", "BK7231Q.*no BLE"), + ], +) +def test_unsupported_family_rejected( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + match: str, + caplog: pytest.LogCaptureFixture, +) -> None: + with pytest.raises(EsphomeError, match=match): + generate_main(component_config_path(config_file)) + # Validation itself must not fail (CI validate fixtures run on a BLE 4.2 + # board), but it warns before codegen raises. + assert "cannot compile" in caplog.text + + +def test_ble5_family_generates( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("test_bk7231n.yaml")) + assert "bk72xx_ble::BK72xxBLE" in main_cpp diff --git a/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml b/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml index 4d4dab0198..7912fceed6 100644 --- a/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml +++ b/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml @@ -2,6 +2,6 @@ esphome: name: slotcount-controller bk72xx: - board: generic-bk7252 + board: cb2s bk72xx_ble: diff --git a/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml b/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml index 79e9644006..b813e2702e 100644 --- a/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml +++ b/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml @@ -2,6 +2,6 @@ esphome: name: slotcount-tracker bk72xx: - board: generic-bk7252 + board: cb2s bk72xx_ble_tracker: From f42fe9af297c8a19c63fdaa2ae06aac43748186b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 12:43:27 -0700 Subject: [PATCH 1494/1815] [core] Skip redundant ESP8266 main loop wake posts from ISR context (#18416) --- esphome/core/wake/wake_esp8266.h | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/esphome/core/wake/wake_esp8266.h b/esphome/core/wake/wake_esp8266.h index 7eaaae5293..73b7a38a35 100644 --- a/esphome/core/wake/wake_esp8266.h +++ b/esphome/core/wake/wake_esp8266.h @@ -15,6 +15,13 @@ 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(); + // Skip the post when a wake was already signalled and not yet consumed by + // wakeable_delay(): esp_schedule() -> ets_post() can enter SDK WiFi pm code, + // which must not be poked per-byte from the software serial RX ISR (see + // esphome#18409). The flag can stay latched while the loop is awake, which + // is intentional; posts are only needed to cut a suspend short. + if (g_main_loop_woke) + return; g_main_loop_woke = true; esp_schedule(); } From bb7d4c3630bf085c45c6991c8d5964baeb2da832 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 12:44:00 -0700 Subject: [PATCH 1495/1815] [esp32] Split crash handler addr2line hint per core (#18418) --- esphome/components/esp32/crash_handler.cpp | 29 +++++++++++----------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 1b054dcc49..b61dad7386 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -360,17 +360,6 @@ static bool has_fault_addr() { return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause; } -// Append both cores' backtrace addresses to buf; returns the new position. -static int append_all_backtraces(char *buf, int size, int pos) { - pos = append_addrs_to_hint(buf, size, pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, - s_raw_crash_data.reg_frame_count); -#if SOC_CPU_CORES_NUM > 1 - pos = append_addrs_to_hint(buf, size, pos, s_raw_crash_data.other_backtrace, s_raw_crash_data.other_backtrace_count, - s_raw_crash_data.other_reg_frame_count); -#endif - return pos; -} - // The record was captured by a different firmware build (it survives soft // resets, including the OTA reboot), so symbolizing its addresses against the // current ELF would produce misleading symbols. Print them with lowercase @@ -443,11 +432,23 @@ void crash_handler_log() { } #endif - // Build addr2line hint with all captured addresses for easy copy-paste + // Build addr2line hints for easy copy-paste. One line per core: the two + // backtraces are separate stacks, and a combined list decodes as one + // impossible call chain (and can overflow the buffer, dropping addresses). + static const char *const ADDR2LINE_CMD = "addr2line -pfiaC -e firmware.elf"; char hint[256]; - int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e firmware.elf 0x%08" PRIX32, s_raw_crash_data.pc); - append_all_backtraces(hint, sizeof(hint), pos); + int pos = snprintf(hint, sizeof(hint), "Use: %s 0x%08" PRIX32, ADDR2LINE_CMD, s_raw_crash_data.pc); + append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, + s_raw_crash_data.reg_frame_count); ESP_LOGE(TAG, "%s", hint); +#if SOC_CPU_CORES_NUM > 1 + if (s_raw_crash_data.other_backtrace_count > 0) { + pos = snprintf(hint, sizeof(hint), "Other core: %s", ADDR2LINE_CMD); + append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.other_backtrace, + s_raw_crash_data.other_backtrace_count, s_raw_crash_data.other_reg_frame_count); + ESP_LOGE(TAG, "%s", hint); + } +#endif } } // namespace esphome::esp32 From 1ec21a22450393cfe777fc6f3923adaa085ff890 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:22:41 +1200 Subject: [PATCH 1496/1815] Bump version to 2026.8.0b4 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index d9421273af..2df6d3ded0 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.8.0b3 +PROJECT_NUMBER = 2026.8.0b4 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 1a8be98c03..73155e06ee 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.8.0b3" +__version__ = "2026.8.0b4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 58d549ed4c53ddc72408a8e18f81c12a3648d30a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 19:56:37 -0700 Subject: [PATCH 1497/1815] [api] Move NoiseProtocolId off the connection object (#18420) --- .../components/api/api_frame_helper_noise.cpp | 25 +++++++++++-------- .../components/api/api_frame_helper_noise.h | 3 --- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 225bac51a6..09e3ca2b9e 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -591,18 +591,21 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { */ APIError APINoiseFrameHelper::init_handshake_() { int err; - memset(&nid_, 0, sizeof(nid_)); - // const char *proto = "Noise_NNpsk0_25519_ChaChaPoly_SHA256"; - // err = noise_protocol_name_to_id(&nid_, proto, strlen(proto)); - nid_.pattern_id = NOISE_PATTERN_NN; - nid_.cipher_id = NOISE_CIPHER_CHACHAPOLY; - nid_.dh_id = NOISE_DH_CURVE25519; - nid_.prefix_id = NOISE_PREFIX_STANDARD; - nid_.hybrid_id = NOISE_DH_NONE; - nid_.hash_id = NOISE_HASH_SHA256; - nid_.modifier_ids[0] = NOISE_MODIFIER_PSK0; + // Noise_NNpsk0_25519_ChaChaPoly_SHA256, built on the stack: + // noise_handshakestate_new_by_id copies it, so a member would waste + // 104 bytes per connection, and a static const would sit in RAM on + // ESP8266 (.rodata is DRAM there). + const NoiseProtocolId nid = { + .prefix_id = NOISE_PREFIX_STANDARD, + .pattern_id = NOISE_PATTERN_NN, + .modifier_ids = {NOISE_MODIFIER_PSK0}, + .dh_id = NOISE_DH_CURVE25519, + .cipher_id = NOISE_CIPHER_CHACHAPOLY, + .hash_id = NOISE_HASH_SHA256, + .hybrid_id = NOISE_DH_NONE, + }; - err = noise_handshakestate_new_by_id(&handshake_, &nid_, NOISE_ROLE_RESPONDER); + err = noise_handshakestate_new_by_id(&handshake_, &nid, NOISE_ROLE_RESPONDER); APIError aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_new_by_id"), APIError::HANDSHAKESTATE_SETUP_FAILED); if (aerr != APIError::OK) diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index b0ba9fd01c..46bd366672 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -63,9 +63,6 @@ class APINoiseFrameHelper final : public APIFrameHelper { // Buffer for noise handshake prologue (released after handshake) APIBuffer prologue_; - // NoiseProtocolId (size depends on implementation) - NoiseProtocolId nid_; - // Group small types together // Fixed-size header buffer for noise protocol: // 1 byte for indicator + 2 bytes for message size (16-bit value, not varint) From cf764740cf8c186907edb09354df0e4d95750f1c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 19:56:58 -0700 Subject: [PATCH 1498/1815] [api] Create the camera image reader lazily (#18421) --- esphome/components/api/api_connection.cpp | 26 +++--- esphome/components/camera/camera.h | 3 +- tests/integration/fixtures/camera_mock.yaml | 19 +++++ .../mock_camera/__init__.py | 28 +++++++ .../mock_camera/mock_camera.cpp | 30 +++++++ .../mock_camera/mock_camera.h | 80 +++++++++++++++++++ tests/integration/test_camera_mock.py | 73 +++++++++++++++++ 7 files changed, 245 insertions(+), 14 deletions(-) create mode 100644 tests/integration/fixtures/camera_mock.yaml create mode 100644 tests/integration/fixtures/external_components/mock_camera/__init__.py create mode 100644 tests/integration/fixtures/external_components/mock_camera/mock_camera.cpp create mode 100644 tests/integration/fixtures/external_components/mock_camera/mock_camera.h create mode 100644 tests/integration/test_camera_mock.py diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 73b4f3e5bd..2eb8c21c73 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -160,11 +160,6 @@ APIConnection::APIConnection(std::unique_ptr sock, APIServer *pa #else #error "No frame helper defined" #endif -#ifdef USE_CAMERA - if (camera::Camera::instance() != nullptr) { - this->image_reader_ = std::unique_ptr{camera::Camera::instance()->create_image_reader()}; - } -#endif } void APIConnection::start() { @@ -1140,6 +1135,7 @@ void APIConnection::try_send_camera_image_() { if (!this->image_reader_) return; + const auto *cam = camera::Camera::instance(); // Send as many chunks as possible without blocking while (this->image_reader_->available()) { if (!this->helper_->can_write_without_blocking()) @@ -1149,11 +1145,11 @@ void APIConnection::try_send_camera_image_() { bool done = this->image_reader_->available() == to_send; CameraImageResponse msg; - msg.key = camera::Camera::instance()->get_object_id_hash(); + msg.key = cam->get_object_id_hash(); msg.set_data(this->image_reader_->peek_data_buffer(), to_send); msg.done = done; #ifdef USE_DEVICES - msg.device_id = camera::Camera::instance()->get_device_id(); + msg.device_id = cam->get_device_id(); #endif if (!this->send_message(msg)) { @@ -1169,15 +1165,19 @@ void APIConnection::try_send_camera_image_() { void APIConnection::set_camera_state(std::shared_ptr image) { if (!this->flags_.state_subscription) return; - if (!this->image_reader_) + if (this->image_reader_ && this->image_reader_->available()) return; - if (this->image_reader_->available()) + if (!image->was_requested_by(esphome::camera::API_REQUESTER) && !image->was_requested_by(esphome::camera::IDLE)) return; - if (image->was_requested_by(esphome::camera::API_REQUESTER) || image->was_requested_by(esphome::camera::IDLE)) { - this->image_reader_->set_image(std::move(image)); - // Try to send immediately to reduce latency - this->try_send_camera_image_(); + if (!this->image_reader_) { + // Created on the first image this connection will send, so connections + // that never receive one never pay for a reader. Only a registered + // camera's listener can reach this, so instance() is non-null here. + this->image_reader_ = std::unique_ptr{camera::Camera::instance()->create_image_reader()}; } + this->image_reader_->set_image(std::move(image)); + // Try to send immediately to reduce latency + this->try_send_camera_image_(); } uint16_t APIConnection::try_send_camera_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) { auto *camera = static_cast(entity); diff --git a/esphome/components/camera/camera.h b/esphome/components/camera/camera.h index bf80b42e54..433361d298 100644 --- a/esphome/components/camera/camera.h +++ b/esphome/components/camera/camera.h @@ -103,7 +103,8 @@ struct CameraImageSpec { /** Abstract camera base class. Collaborates with API. * 1) API server starts and registers as a listener (add_listener) * to receive new images from the camera. - * 2) New API client connects and creates a new image reader (create_image_reader). + * 2) API connection creates an image reader (create_image_reader) when it receives + * the first image it will send. * 3) API connection receives protobuf CameraImageRequest and calls request_image. * 3.a) API connection receives protobuf CameraImageRequest and calls start_stream. * 4) Camera implementation provides JPEG data in the CameraImage and notifies listeners. diff --git a/tests/integration/fixtures/camera_mock.yaml b/tests/integration/fixtures/camera_mock.yaml new file mode 100644 index 0000000000..fa354d341f --- /dev/null +++ b/tests/integration/fixtures/camera_mock.yaml @@ -0,0 +1,19 @@ +esphome: + name: camera-mock-test + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +mock_camera: + name: Mock Camera + # Larger than MAX_BATCH_PACKET_SIZE (1390) so the image is split across + # multiple CameraImageResponse chunks and the client must reassemble. + # Must match IMAGE_SIZE in test_camera_mock.py. + image_size: 4096 diff --git a/tests/integration/fixtures/external_components/mock_camera/__init__.py b/tests/integration/fixtures/external_components/mock_camera/__init__.py new file mode 100644 index 0000000000..57aaf07ab9 --- /dev/null +++ b/tests/integration/fixtures/external_components/mock_camera/__init__.py @@ -0,0 +1,28 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.core.entity_helpers import setup_entity +from esphome.types import ConfigType + +CODEOWNERS = ["@esphome/tests"] +AUTO_LOAD = ["camera"] + +CONF_IMAGE_SIZE = "image_size" + +mock_camera_ns = cg.esphome_ns.namespace("mock_camera") +MockCamera = mock_camera_ns.class_("MockCamera", cg.Component, cg.EntityBase) + +CONFIG_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(MockCamera), + cv.Optional(CONF_IMAGE_SIZE, default=1024): cv.positive_not_null_int, + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_CAMERA") + var = cg.new_Pvariable(config[CONF_ID]) + await setup_entity(var, config, "camera") + await cg.register_component(var, config) + cg.add(var.set_image_size(config[CONF_IMAGE_SIZE])) diff --git a/tests/integration/fixtures/external_components/mock_camera/mock_camera.cpp b/tests/integration/fixtures/external_components/mock_camera/mock_camera.cpp new file mode 100644 index 0000000000..64ed6bfe5c --- /dev/null +++ b/tests/integration/fixtures/external_components/mock_camera/mock_camera.cpp @@ -0,0 +1,30 @@ +#include "mock_camera.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::mock_camera { + +static const char *const TAG = "mock_camera"; + +void MockCamera::loop() { + uint8_t requesters = this->single_requesters_ | this->stream_requesters_; + if (requesters == 0) + return; + uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_frame_ms_ < FRAME_INTERVAL_MS) + return; + this->last_frame_ms_ = now; + this->single_requesters_ = 0; + + auto image = std::make_shared(this->image_size_, this->frame_counter_, requesters); + ESP_LOGV(TAG, "Producing frame %u (%u bytes, requesters 0x%02X)", this->frame_counter_, this->image_size_, + requesters); + this->frame_counter_++; + for (auto *listener : this->listeners_) { + listener->on_camera_image(image); + } +} + +void MockCamera::dump_config() { ESP_LOGCONFIG(TAG, "Mock Camera (%u byte frames)", this->image_size_); } + +} // namespace esphome::mock_camera diff --git a/tests/integration/fixtures/external_components/mock_camera/mock_camera.h b/tests/integration/fixtures/external_components/mock_camera/mock_camera.h new file mode 100644 index 0000000000..bcf40bba67 --- /dev/null +++ b/tests/integration/fixtures/external_components/mock_camera/mock_camera.h @@ -0,0 +1,80 @@ +#pragma once + +#include "esphome/components/camera/camera.h" +#include "esphome/core/component.h" + +#include +#include + +namespace esphome::mock_camera { + +/** Deterministic in-memory camera image. + * Byte i of frame N is (N + i) & 0xFF so tests can validate + * reassembled data from just the first byte. + */ +class MockCameraImage : public camera::CameraImage { + public: + MockCameraImage(size_t size, uint8_t frame_counter, uint8_t requesters) + : data_(new uint8_t[size]), size_(size), requesters_(requesters) { + for (size_t i = 0; i < size; i++) { + this->data_[i] = static_cast(frame_counter + i); + } + } + uint8_t *get_data_buffer() override { return this->data_.get(); } + size_t get_data_length() override { return this->size_; } + bool was_requested_by(camera::CameraRequester requester) const override { + return (this->requesters_ & (1 << requester)) != 0; + } + + protected: + std::unique_ptr data_; + size_t size_; + uint8_t requesters_; +}; + +class MockCameraImageReader : public camera::CameraImageReader { + public: + void set_image(std::shared_ptr image) override { + this->image_ = std::move(image); + this->offset_ = 0; + } + size_t available() const override { return this->image_ ? this->image_->get_data_length() - this->offset_ : 0; } + uint8_t *peek_data_buffer() override { return this->image_->get_data_buffer() + this->offset_; } + void consume_data(size_t consumed) override { this->offset_ += consumed; } + void return_image() override { + this->image_.reset(); + this->offset_ = 0; + } + + protected: + std::shared_ptr image_; + size_t offset_{0}; +}; + +/** Virtual camera producing deterministic frames on request or stream. */ +class MockCamera : public camera::Camera { + public: + void loop() override; + void dump_config() override; + + void add_listener(camera::CameraListener *listener) override { this->listeners_.push_back(listener); } + camera::CameraImageReader *create_image_reader() override { return new MockCameraImageReader(); } + void request_image(camera::CameraRequester requester) override { this->single_requesters_ |= (1 << requester); } + void start_stream(camera::CameraRequester requester) override { this->stream_requesters_ |= (1 << requester); } + void stop_stream(camera::CameraRequester requester) override { this->stream_requesters_ &= ~(1 << requester); } + + void set_image_size(uint32_t size) { this->image_size_ = size; } + + protected: + static constexpr uint32_t FRAME_INTERVAL_MS = 50; + + // Members ordered largest to smallest to minimize padding + std::vector listeners_; + uint32_t image_size_{1024}; + uint32_t last_frame_ms_{0}; + uint8_t frame_counter_{0}; + uint8_t single_requesters_{0}; + uint8_t stream_requesters_{0}; +}; + +} // namespace esphome::mock_camera diff --git a/tests/integration/test_camera_mock.py b/tests/integration/test_camera_mock.py new file mode 100644 index 0000000000..6819d7a6d4 --- /dev/null +++ b/tests/integration/test_camera_mock.py @@ -0,0 +1,73 @@ +"""Integration test for the camera API flow using a mock camera platform.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import CameraInfo, CameraState, EntityState +import pytest + +from .state_utils import require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Must match image_size in fixtures/camera_mock.yaml +IMAGE_SIZE = 4096 +STREAM_FRAMES = 3 + + +def _verify_frame(data: bytes) -> int: + """Verify the deterministic frame pattern and return the frame counter.""" + assert len(data) == IMAGE_SIZE, f"expected {IMAGE_SIZE} bytes, got {len(data)}" + counter = data[0] + assert data == bytes((counter + i) & 0xFF for i in range(IMAGE_SIZE)), ( + "frame pattern mismatch" + ) + return counter + + +@pytest.mark.asyncio +async def test_camera_mock( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Single-image and stream requests deliver reassembled deterministic frames.""" + async with run_compiled(yaml_config), api_client_connected() as client: + entities, _ = await client.list_entities_services() + camera = require_entity(entities, "mock_camera", CameraInfo) + + loop = asyncio.get_running_loop() + images: list[bytes] = [] + single_image: asyncio.Future[None] = loop.create_future() + stream_done: asyncio.Future[None] = loop.create_future() + + def on_state(state: EntityState) -> None: + if not (isinstance(state, CameraState) and state.key == camera.key): + return + images.append(bytes(state.data)) + if not single_image.done(): + single_image.set_result(None) + elif len(images) >= STREAM_FRAMES and not stream_done.done(): + stream_done.set_result(None) + + client.subscribe_states(on_state) + + # Single image request: one complete frame arrives, reassembled + # from multiple chunks (4096 > 1390 byte packets) + client.request_single_image() + await asyncio.wait_for(single_image, timeout=10) + first_counter = _verify_frame(images[0]) + + # Stream request: multiple consecutive frames arrive + images.clear() + client.request_image_stream() + await asyncio.wait_for(stream_done, timeout=10) + + # Frames are distinct, ordered, and fresh per the mock's counter. + # Not exactly consecutive: the API drops frames by design while the + # previous image is still being sent, so allow small gaps. + counters = [_verify_frame(img) for img in images[:STREAM_FRAMES]] + for prev, cur in zip(counters, counters[1:], strict=False): + assert cur != prev, f"duplicate frames: {counters}" + assert ((cur - prev) & 0xFF) < 16, f"frames out of order: {counters}" + assert counters[0] != first_counter, "stream should produce new frames" From e1c279718fafe3884101efdbff3a9d1a1d5ed529 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 19:57:12 -0700 Subject: [PATCH 1499/1815] [ld2420] Drop the setup priority override so setup runs after the UART bus (#18428) --- esphome/components/ld2420/ld2420.cpp | 2 -- esphome/components/ld2420/ld2420.h | 1 - 2 files changed, 3 deletions(-) diff --git a/esphome/components/ld2420/ld2420.cpp b/esphome/components/ld2420/ld2420.cpp index f71bec7e5f..4aa00f8fd4 100644 --- a/esphome/components/ld2420/ld2420.cpp +++ b/esphome/components/ld2420/ld2420.cpp @@ -184,8 +184,6 @@ static int32_t get_firmware_int(const char *version_string) { return result; } -float LD2420Component::get_setup_priority() const { return setup_priority::BUS; } - void LD2420Component::dump_config() { ESP_LOGCONFIG(TAG, "LD2420:\n" diff --git a/esphome/components/ld2420/ld2420.h b/esphome/components/ld2420/ld2420.h index 977ee2eccc..e13d0271e1 100644 --- a/esphome/components/ld2420/ld2420.h +++ b/esphome/components/ld2420/ld2420.h @@ -105,7 +105,6 @@ class LD2420Component final : public Component, public uart::UARTDevice { void apply_config_action(); void factory_reset_action(); void revert_config_action(); - float get_setup_priority() const override; int send_cmd_from_array(CmdFrameT cmd_frame); void report_gate_data(); void handle_cmd_error(uint16_t error); From ebb0923362601879742a870d39e114b2279258cc Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:34:00 -0500 Subject: [PATCH 1500/1815] Bump aioesphomeapi from 45.10.2 to 45.10.3 (#18433) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 876b13793c..61011f2fbd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.10.2 +aioesphomeapi==45.10.3 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From 07e8b303b9a4f588285795b841c8ae7061d31712 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:52:56 -0700 Subject: [PATCH 1501/1815] [ota] Shorten platform backend TAG strings (#18438) --- esphome/components/ota/ota_backend_arduino_libretiny.cpp | 2 +- esphome/components/ota/ota_backend_arduino_rp2.cpp | 2 +- esphome/components/ota/ota_backend_esp8266.cpp | 2 +- esphome/components/ota/ota_backend_esp_idf.cpp | 2 +- esphome/components/ota/ota_backend_host.cpp | 2 +- esphome/components/ota/ota_bootloader_esp_idf.cpp | 2 +- esphome/components/ota/ota_partitions_esp_idf.cpp | 2 +- esphome/components/ota/ota_signature_esp_idf.cpp | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.cpp b/esphome/components/ota/ota_backend_arduino_libretiny.cpp index 4cc99202a7..231c4d2dd2 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.cpp +++ b/esphome/components/ota/ota_backend_arduino_libretiny.cpp @@ -9,7 +9,7 @@ namespace esphome::ota { -static const char *const TAG = "ota.arduino_libretiny"; +static const char *const TAG = "ota"; std::unique_ptr make_ota_backend() { return make_unique(); } diff --git a/esphome/components/ota/ota_backend_arduino_rp2.cpp b/esphome/components/ota/ota_backend_arduino_rp2.cpp index b35eb38c12..48725b1265 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2.cpp +++ b/esphome/components/ota/ota_backend_arduino_rp2.cpp @@ -11,7 +11,7 @@ namespace esphome::ota { -static const char *const TAG = "ota.arduino_rp2"; +static const char *const TAG = "ota"; std::unique_ptr make_ota_backend() { return make_unique(); } diff --git a/esphome/components/ota/ota_backend_esp8266.cpp b/esphome/components/ota/ota_backend_esp8266.cpp index 6a678fb419..2a6a9e08b1 100644 --- a/esphome/components/ota/ota_backend_esp8266.cpp +++ b/esphome/components/ota/ota_backend_esp8266.cpp @@ -46,7 +46,7 @@ static constexpr size_t MIN_BUFFER_SIZE = 256; namespace esphome::ota { -static const char *const TAG = "ota.esp8266"; +static const char *const TAG = "ota"; std::unique_ptr make_ota_backend() { return make_unique(); } diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 108605e4c9..eb23ad82dd 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -15,7 +15,7 @@ namespace esphome::ota { -static const char *const TAG = "ota.idf"; +static const char *const TAG = "ota"; std::unique_ptr make_ota_backend() { return make_unique(); } diff --git a/esphome/components/ota/ota_backend_host.cpp b/esphome/components/ota/ota_backend_host.cpp index ee503a49e1..89e3f99e1e 100644 --- a/esphome/components/ota/ota_backend_host.cpp +++ b/esphome/components/ota/ota_backend_host.cpp @@ -27,7 +27,7 @@ namespace esphome::ota { namespace { -const char *const TAG = "ota.host"; +const char *const TAG = "ota"; constexpr size_t MAX_OTA_SIZE = 256u * 1024u * 1024u; // 256 MiB constexpr size_t HEADER_PEEK_SIZE = 64; diff --git a/esphome/components/ota/ota_bootloader_esp_idf.cpp b/esphome/components/ota/ota_bootloader_esp_idf.cpp index 264218a3df..57b5529350 100644 --- a/esphome/components/ota/ota_bootloader_esp_idf.cpp +++ b/esphome/components/ota/ota_bootloader_esp_idf.cpp @@ -11,7 +11,7 @@ namespace esphome::ota { -static const char *const TAG = "ota.idf"; +static const char *const TAG = "ota"; OTAResponseTypes IDFOTABackend::register_and_validate_bootloader_part_() { // Register the bootloader partition diff --git a/esphome/components/ota/ota_partitions_esp_idf.cpp b/esphome/components/ota/ota_partitions_esp_idf.cpp index a7fc709313..d2b1196de6 100644 --- a/esphome/components/ota/ota_partitions_esp_idf.cpp +++ b/esphome/components/ota/ota_partitions_esp_idf.cpp @@ -16,7 +16,7 @@ namespace esphome::ota { -static const char *const TAG = "ota.idf"; +static const char *const TAG = "ota"; static inline bool check_overlap(uint32_t a_offset, size_t a_size, uint32_t b_offset, size_t b_size) { return (a_offset + a_size > b_offset && b_offset + b_size > a_offset); diff --git a/esphome/components/ota/ota_signature_esp_idf.cpp b/esphome/components/ota/ota_signature_esp_idf.cpp index b327988d2d..71dcc0eb83 100644 --- a/esphome/components/ota/ota_signature_esp_idf.cpp +++ b/esphome/components/ota/ota_signature_esp_idf.cpp @@ -31,7 +31,7 @@ namespace esphome::ota { -static const char *const TAG = "ota.idf"; +static const char *const TAG = "ota"; // Route the "Signature check: " prefix (and its per-block form) through one // shared format string each, so the prefix is pooled once by the linker instead From c01f24553c129327ef591cb9e7198369918663b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:53:29 -0700 Subject: [PATCH 1502/1815] [uart] Shorten platform backend TAG strings (#18439) --- esphome/components/uart/uart_component_esp8266.cpp | 2 +- esphome/components/uart/uart_component_esp_idf.cpp | 2 +- esphome/components/uart/uart_component_host.cpp | 2 +- esphome/components/uart/uart_component_libretiny.cpp | 2 +- esphome/components/uart/uart_component_rp2.cpp | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index fc1509f737..2f8b4dbd11 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -14,7 +14,7 @@ namespace esphome::uart { -static const char *const TAG = "uart.arduino_esp8266"; +static const char *const TAG = "uart"; bool ESP8266UartComponent::serial0_in_use = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) uint32_t ESP8266UartComponent::get_config() { diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index 93e43e0372..a61339feb4 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -21,7 +21,7 @@ namespace esphome::uart { -static const char *const TAG = "uart.idf"; +static const char *const TAG = "uart"; /// Check if a pin number matches one of the default UART0 GPIO pins. /// These pins may have residual IOMUX state from the ROM bootloader that diff --git a/esphome/components/uart/uart_component_host.cpp b/esphome/components/uart/uart_component_host.cpp index 5bb7a49726..63b5631564 100644 --- a/esphome/components/uart/uart_component_host.cpp +++ b/esphome/components/uart/uart_component_host.cpp @@ -98,7 +98,7 @@ speed_t get_baud(int baud) { namespace esphome::uart { -static const char *const TAG = "uart.host"; +static const char *const TAG = "uart"; HostUartComponent::~HostUartComponent() { if (this->file_descriptor_ != -1) { diff --git a/esphome/components/uart/uart_component_libretiny.cpp b/esphome/components/uart/uart_component_libretiny.cpp index fbf0c20ded..4eacd980db 100644 --- a/esphome/components/uart/uart_component_libretiny.cpp +++ b/esphome/components/uart/uart_component_libretiny.cpp @@ -16,7 +16,7 @@ namespace esphome::uart { -static const char *const TAG = "uart.lt"; +static const char *const TAG = "uart"; static const char *const UART_TYPE[] = { "hardware", diff --git a/esphome/components/uart/uart_component_rp2.cpp b/esphome/components/uart/uart_component_rp2.cpp index 9cc3009a22..ffb9bc0f2d 100644 --- a/esphome/components/uart/uart_component_rp2.cpp +++ b/esphome/components/uart/uart_component_rp2.cpp @@ -13,7 +13,7 @@ namespace esphome::uart { -static const char *const TAG = "uart.arduino_rp2"; +static const char *const TAG = "uart"; uint16_t RP2UartComponent::get_config() { uint16_t config = 0; From d1a7b8df8b616cd49affa5a70298fbdfce07d6be Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:53:42 -0700 Subject: [PATCH 1503/1815] [adc] Shorten platform TAG strings (#18441) --- esphome/components/adc/adc_sensor_common.cpp | 2 +- esphome/components/adc/adc_sensor_esp32.cpp | 2 +- esphome/components/adc/adc_sensor_esp8266.cpp | 2 +- esphome/components/adc/adc_sensor_libretiny.cpp | 2 +- esphome/components/adc/adc_sensor_rp2.cpp | 2 +- esphome/components/adc/adc_sensor_zephyr.cpp | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/components/adc/adc_sensor_common.cpp b/esphome/components/adc/adc_sensor_common.cpp index 16c86aee18..5ca58df10e 100644 --- a/esphome/components/adc/adc_sensor_common.cpp +++ b/esphome/components/adc/adc_sensor_common.cpp @@ -3,7 +3,7 @@ namespace esphome::adc { -static const char *const TAG = "adc.common"; +static const char *const TAG = "adc"; const LogString *sampling_mode_to_str(SamplingMode mode) { switch (mode) { diff --git a/esphome/components/adc/adc_sensor_esp32.cpp b/esphome/components/adc/adc_sensor_esp32.cpp index a761b37749..a0f7a1ed08 100644 --- a/esphome/components/adc/adc_sensor_esp32.cpp +++ b/esphome/components/adc/adc_sensor_esp32.cpp @@ -6,7 +6,7 @@ namespace esphome::adc { -static const char *const TAG = "adc.esp32"; +static const char *const TAG = "adc"; adc_oneshot_unit_handle_t ADCSensor::shared_adc_handles[2] = {nullptr, nullptr}; diff --git a/esphome/components/adc/adc_sensor_esp8266.cpp b/esphome/components/adc/adc_sensor_esp8266.cpp index e4f2f82f08..77a192e025 100644 --- a/esphome/components/adc/adc_sensor_esp8266.cpp +++ b/esphome/components/adc/adc_sensor_esp8266.cpp @@ -13,7 +13,7 @@ ADC_MODE(ADC_VCC) namespace esphome::adc { -static const char *const TAG = "adc.esp8266"; +static const char *const TAG = "adc"; void ADCSensor::setup() { #ifndef USE_ADC_SENSOR_VCC diff --git a/esphome/components/adc/adc_sensor_libretiny.cpp b/esphome/components/adc/adc_sensor_libretiny.cpp index d9b9f50be1..dfa545b395 100644 --- a/esphome/components/adc/adc_sensor_libretiny.cpp +++ b/esphome/components/adc/adc_sensor_libretiny.cpp @@ -5,7 +5,7 @@ namespace esphome::adc { -static const char *const TAG = "adc.libretiny"; +static const char *const TAG = "adc"; void ADCSensor::setup() { #ifndef USE_ADC_SENSOR_VCC diff --git a/esphome/components/adc/adc_sensor_rp2.cpp b/esphome/components/adc/adc_sensor_rp2.cpp index 8652a46029..ce665e8501 100644 --- a/esphome/components/adc/adc_sensor_rp2.cpp +++ b/esphome/components/adc/adc_sensor_rp2.cpp @@ -17,7 +17,7 @@ namespace esphome::adc { -static const char *const TAG = "adc.rp2"; +static const char *const TAG = "adc"; // The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040 // and RP2350A, but input 8 on RP2350B, which has eight external channels rather diff --git a/esphome/components/adc/adc_sensor_zephyr.cpp b/esphome/components/adc/adc_sensor_zephyr.cpp index c3632b00e2..bf45059740 100644 --- a/esphome/components/adc/adc_sensor_zephyr.cpp +++ b/esphome/components/adc/adc_sensor_zephyr.cpp @@ -7,7 +7,7 @@ namespace esphome::adc { -static const char *const TAG = "adc.zephyr"; +static const char *const TAG = "adc"; void ADCSensor::setup() { if (!adc_is_ready_dt(this->channel_)) { From 37bea1c1538c830691e95bcce398e816069f7556 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:53:48 -0700 Subject: [PATCH 1504/1815] [spi] Shorten platform backend TAG strings (#18442) --- esphome/components/spi/spi_arduino.cpp | 2 +- esphome/components/spi/spi_esp_idf.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/spi/spi_arduino.cpp b/esphome/components/spi/spi_arduino.cpp index a3e09d2800..14428bed62 100644 --- a/esphome/components/spi/spi_arduino.cpp +++ b/esphome/components/spi/spi_arduino.cpp @@ -4,7 +4,7 @@ namespace esphome::spi { #if defined(USE_ARDUINO) && !defined(USE_ESP32) -static const char *const TAG = "spi-esp-arduino"; +static const char *const TAG = "spi"; class SPIDelegateHw : public SPIDelegate { public: SPIDelegateHw(SPIInterface channel, uint32_t data_rate, SPIBitOrder bit_order, SPIMode mode, GPIOPin *cs_pin) diff --git a/esphome/components/spi/spi_esp_idf.cpp b/esphome/components/spi/spi_esp_idf.cpp index 0731078eec..d5d5053117 100644 --- a/esphome/components/spi/spi_esp_idf.cpp +++ b/esphome/components/spi/spi_esp_idf.cpp @@ -4,7 +4,7 @@ namespace esphome::spi { #ifdef USE_ESP32 -static const char *const TAG = "spi-esp-idf"; +static const char *const TAG = "spi"; static const size_t MAX_TRANSFER_SIZE = 4092; // dictated by ESP-IDF API. class SPIDelegateHw : public SPIDelegate { From 47a58dd7991affd47b61df0cd491076d77ceff18 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:53:56 -0700 Subject: [PATCH 1505/1815] [internal_temperature] Shorten platform TAG strings (#18443) --- .../internal_temperature/internal_temperature_bk72xx.cpp | 2 +- .../internal_temperature/internal_temperature_esp32.cpp | 2 +- .../internal_temperature/internal_temperature_rp2.cpp | 2 +- .../internal_temperature/internal_temperature_zephyr.cpp | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp b/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp index b7332ee81f..91f47d831f 100644 --- a/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp +++ b/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp @@ -9,7 +9,7 @@ uint32_t temp_single_get_current_temperature(uint32_t *temp_value); namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature.bk72xx"; +static const char *const TAG = "internal_temperature"; void InternalTemperatureSensor::update() { float temperature = NAN; diff --git a/esphome/components/internal_temperature/internal_temperature_esp32.cpp b/esphome/components/internal_temperature/internal_temperature_esp32.cpp index 64fe3707b1..2c6fda2af4 100644 --- a/esphome/components/internal_temperature/internal_temperature_esp32.cpp +++ b/esphome/components/internal_temperature/internal_temperature_esp32.cpp @@ -16,7 +16,7 @@ uint8_t temprature_sens_read(); namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature.esp32"; +static const char *const TAG = "internal_temperature"; void InternalTemperatureSensor::update() { float temperature = NAN; diff --git a/esphome/components/internal_temperature/internal_temperature_rp2.cpp b/esphome/components/internal_temperature/internal_temperature_rp2.cpp index 2e408b3b01..c4ab33b0a5 100644 --- a/esphome/components/internal_temperature/internal_temperature_rp2.cpp +++ b/esphome/components/internal_temperature/internal_temperature_rp2.cpp @@ -16,7 +16,7 @@ namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature.rp2"; +static const char *const TAG = "internal_temperature"; // The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040 // and RP2350A, but input 8 on RP2350B, which has eight external channels rather diff --git a/esphome/components/internal_temperature/internal_temperature_zephyr.cpp b/esphome/components/internal_temperature/internal_temperature_zephyr.cpp index be72ab6f51..50c597f6f1 100644 --- a/esphome/components/internal_temperature/internal_temperature_zephyr.cpp +++ b/esphome/components/internal_temperature/internal_temperature_zephyr.cpp @@ -8,7 +8,7 @@ namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature.zephyr"; +static const char *const TAG = "internal_temperature"; static const struct device *const DIE_TEMPERATURE_SENSOR = DEVICE_DT_GET_ONE(nordic_nrf_temp); From e0d28d7f5c9128ca98436ec7d4a25cc5ebe91914 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:54:03 -0700 Subject: [PATCH 1506/1815] [http_request] Shorten platform backend TAG strings (#18444) --- esphome/components/http_request/http_request_arduino.cpp | 2 +- esphome/components/http_request/http_request_host.cpp | 2 +- esphome/components/http_request/http_request_idf.cpp | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index 84333e7169..43ab2e5b53 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -16,7 +16,7 @@ namespace esphome::http_request { -static const char *const TAG = "http_request.arduino"; +static const char *const TAG = "http_request"; #ifdef USE_ESP8266 // ESP8266 Arduino core (WiFiClientSecureBearSSL.cpp) returns -1000 on OOM static constexpr int ESP8266_SSL_ERR_OOM = -1000; diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index 85c6e8b3c7..cf231e20bd 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -14,7 +14,7 @@ namespace esphome::http_request { -static const char *const TAG = "http_request.host"; +static const char *const TAG = "http_request"; std::shared_ptr HttpRequestHost::perform(const std::string &url, const std::string &method, const std::string &body, diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index a437540241..ddff954950 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -16,7 +16,7 @@ namespace esphome::http_request { -static const char *const TAG = "http_request.idf"; +static const char *const TAG = "http_request"; static constexpr uint32_t ERROR_DURATION_MS = 1000; void HttpRequestIDF::dump_config() { From 1f000ba66899be59be0aaa655692757111c8f435 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:54:17 -0700 Subject: [PATCH 1507/1815] [mqtt] Shorten esp32 backend TAG string (#18446) --- esphome/components/mqtt/mqtt_backend_esp32.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/mqtt/mqtt_backend_esp32.cpp b/esphome/components/mqtt/mqtt_backend_esp32.cpp index 499a330730..09eb5f97dc 100644 --- a/esphome/components/mqtt/mqtt_backend_esp32.cpp +++ b/esphome/components/mqtt/mqtt_backend_esp32.cpp @@ -10,7 +10,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.idf"; +static const char *const TAG = "mqtt"; bool MQTTBackendESP32::initialize_() { mqtt_cfg_.broker.address.hostname = this->host_.c_str(); From 1f4fcead38d9897e37c6c3ec059c86988408e5b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:54:29 -0700 Subject: [PATCH 1508/1815] [nextion] Shorten upload TAG strings (#18448) --- esphome/components/nextion/nextion_upload_arduino.cpp | 2 +- esphome/components/nextion/nextion_upload_esp32.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index 2f3377d950..f02f32d5ca 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -13,7 +13,7 @@ namespace esphome::nextion { -static const char *const TAG = "nextion.upload.arduino"; +static const char *const TAG = "nextion.upload"; static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; // Timeout for display acknowledgment during TFT upload (ms). diff --git a/esphome/components/nextion/nextion_upload_esp32.cpp b/esphome/components/nextion/nextion_upload_esp32.cpp index e2d5ae8ad7..c4dc74b5d3 100644 --- a/esphome/components/nextion/nextion_upload_esp32.cpp +++ b/esphome/components/nextion/nextion_upload_esp32.cpp @@ -16,7 +16,7 @@ namespace esphome::nextion { -static const char *const TAG = "nextion.upload.esp32"; +static const char *const TAG = "nextion.upload"; static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; // Timeout for display acknowledgment during TFT upload (ms). From ebe93e2c684c46ea960262c05a0914b5f6bda61e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:54:35 -0700 Subject: [PATCH 1509/1815] [bluetooth_connection] Shorten platform TAG strings (#18449) --- .../bluetooth_connection/bluetooth_connection_bluedroid.cpp | 2 +- .../bluetooth_connection/bluetooth_connection_rp2.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index 076c77b18e..15f854239d 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -20,7 +20,7 @@ namespace esphome::bluetooth_connection { -static const char *const TAG = "bluetooth_connection.bluedroid"; +static const char *const TAG = "bluetooth_connection"; using ble_device_base::FAST_CONN_TIMEOUT; using ble_device_base::FAST_MAX_CONN_INTERVAL; diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index 855c895196..16a89dcfdd 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -15,7 +15,7 @@ namespace esphome::bluetooth_connection { -static const char *const TAG = "bluetooth_connection.rp2"; +static const char *const TAG = "bluetooth_connection"; using ble_device_base::ESPBTUUID; using ble_device_base::GATT_ERR_NOT_CONNECTED; From 3d9fecb56229ddefc7eaf246e23d4ed656f28f03 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:54:52 -0700 Subject: [PATCH 1510/1815] [remote_receiver] Shorten esp32 TAG string (#18447) --- esphome/components/remote_receiver/remote_receiver_rmt.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/remote_receiver/remote_receiver_rmt.cpp b/esphome/components/remote_receiver/remote_receiver_rmt.cpp index 596608a4d0..632ca9763a 100644 --- a/esphome/components/remote_receiver/remote_receiver_rmt.cpp +++ b/esphome/components/remote_receiver/remote_receiver_rmt.cpp @@ -9,7 +9,7 @@ namespace esphome::remote_receiver { -static const char *const TAG = "remote_receiver.esp32"; +static const char *const TAG = "remote_receiver"; static bool IRAM_ATTR HOT rmt_callback(rmt_channel_handle_t channel, const rmt_rx_done_event_data_t *event, void *arg) { RemoteReceiverComponentStore *store = (RemoteReceiverComponentStore *) arg; From f6c7434b2abb0cba04ce08e669c14b380b9622bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:54:55 -0700 Subject: [PATCH 1511/1815] [i2c] Shorten platform backend TAG strings (#18440) --- esphome/components/i2c/i2c_bus_arduino.cpp | 2 +- esphome/components/i2c/i2c_bus_esp_idf.cpp | 2 +- esphome/components/i2c/i2c_bus_host.cpp | 2 +- esphome/components/i2c/i2c_bus_zephyr.cpp | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index cc036b12c3..39a6aec774 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -9,7 +9,7 @@ namespace esphome::i2c { -static const char *const TAG = "i2c.arduino"; +static const char *const TAG = "i2c"; // Maximum bytes to log in hex format (truncates larger transfers) static constexpr size_t I2C_MAX_LOG_BYTES = 32; diff --git a/esphome/components/i2c/i2c_bus_esp_idf.cpp b/esphome/components/i2c/i2c_bus_esp_idf.cpp index 4aca4f0fae..7ca9537e2d 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.cpp +++ b/esphome/components/i2c/i2c_bus_esp_idf.cpp @@ -12,7 +12,7 @@ namespace esphome::i2c { -static const char *const TAG = "i2c.idf"; +static const char *const TAG = "i2c"; // Maximum bytes to log in hex format (truncates larger transfers) static constexpr size_t I2C_MAX_LOG_BYTES = 32; diff --git a/esphome/components/i2c/i2c_bus_host.cpp b/esphome/components/i2c/i2c_bus_host.cpp index 17279fda50..303944636b 100644 --- a/esphome/components/i2c/i2c_bus_host.cpp +++ b/esphome/components/i2c/i2c_bus_host.cpp @@ -16,7 +16,7 @@ namespace esphome::i2c { -static const char *const TAG = "i2c.host"; +static const char *const TAG = "i2c"; HostI2CBus::~HostI2CBus() { if (this->file_descriptor_ != -1) { diff --git a/esphome/components/i2c/i2c_bus_zephyr.cpp b/esphome/components/i2c/i2c_bus_zephyr.cpp index 1eb9944dcb..ffdd2ba8bb 100644 --- a/esphome/components/i2c/i2c_bus_zephyr.cpp +++ b/esphome/components/i2c/i2c_bus_zephyr.cpp @@ -6,7 +6,7 @@ namespace esphome::i2c { -static const char *const TAG = "i2c.zephyr"; +static const char *const TAG = "i2c"; static const char *get_speed(uint32_t dev_config) { switch (I2C_SPEED_GET(dev_config)) { From 031a038b49318018ca1aeee08cc111c2aa5e9b9c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:56:37 -0700 Subject: [PATCH 1512/1815] [deep_sleep] Shorten bk72xx TAG string (#18445) --- esphome/components/deep_sleep/deep_sleep_bk72xx.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp index 73e0331c76..2c97dc3211 100644 --- a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp +++ b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp @@ -5,7 +5,7 @@ namespace esphome::deep_sleep { -static const char *const TAG = "deep_sleep.bk72xx"; +static const char *const TAG = "deep_sleep"; #ifdef USE_DEEP_SLEEP_ON_WAKE WakeupCause get_wakeup_cause() { From 6d20ebc66b309df4d413d316f369ffc48742f4cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 10:18:43 -0700 Subject: [PATCH 1513/1815] [socket] Shorten lwip TAG string (#18450) --- esphome/components/socket/lwip_raw_tcp_impl.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 4fcec553fa..b80a394eec 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -43,7 +43,7 @@ namespace esphome::socket { // (Ethernet). On ESP8266, it's a no-op. #define LWIP_LOCK() esphome::LwIPLock lwip_lock_guard // NOLINT -static const char *const TAG = "socket.lwip"; +static const char *const TAG = "socket"; // set to 1 to enable verbose lwip logging #if 0 // NOLINT(readability-avoid-unconditional-preprocessor-if) From 27483a4101e2098cefff3a5c7c56d0b1594b2506 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 11:15:31 -0700 Subject: [PATCH 1514/1815] [core] Retry gh CLI calls on transient network errors in CI scripts (#18292) --- script/ci_memory_impact_comment.py | 24 +++--- script/helpers.py | 91 +++++++++++++++++++++- tests/script/test_helpers.py | 121 +++++++++++++++++++++++++++++ 3 files changed, 221 insertions(+), 15 deletions(-) diff --git a/script/ci_memory_impact_comment.py b/script/ci_memory_impact_comment.py index 0908b99595..33ca84d76c 100755 --- a/script/ci_memory_impact_comment.py +++ b/script/ci_memory_impact_comment.py @@ -20,17 +20,21 @@ from jinja2 import Environment, FileSystemLoader sys.path.insert(0, str(Path(__file__).parent.parent)) # pylint: disable=wrong-import-position +from helpers import run_gh_command # noqa: E402 # Comment marker to identify our memory impact comments COMMENT_MARKER = "" -def run_gh_command(args: list[str], operation: str) -> subprocess.CompletedProcess: - """Run a gh CLI command with error handling. +def run_gh_command_logged( + args: list[str], operation: str, *, retry: bool = True +) -> subprocess.CompletedProcess: + """Run a gh CLI command with retries and error reporting. Args: args: Command arguments (including 'gh') operation: Description of the operation for error messages + retry: Pass False for non-idempotent commands (see run_gh_command) Returns: CompletedProcess result @@ -39,12 +43,7 @@ def run_gh_command(args: list[str], operation: str) -> subprocess.CompletedProce subprocess.CalledProcessError: If command fails (with detailed error output) """ try: - return subprocess.run( - args, - check=True, - capture_output=True, - text=True, - ) + return run_gh_command(args, retry=retry) except subprocess.CalledProcessError as e: print( f"ERROR: {operation} failed with exit code {e.returncode}", file=sys.stderr @@ -472,7 +471,7 @@ def find_existing_comment(pr_number: str) -> str | None: print(f"DEBUG: Looking for existing comment on PR #{pr_number}", file=sys.stderr) # Use gh api to get comments directly - this returns the numeric id field - result = run_gh_command( + result = run_gh_command_logged( [ "gh", "api", @@ -535,7 +534,7 @@ def update_existing_comment(comment_id: str, comment_body: str) -> None: """ print(f"DEBUG: Updating existing comment {comment_id}", file=sys.stderr) print(f"DEBUG: Comment body length: {len(comment_body)} bytes", file=sys.stderr) - result = run_gh_command( + result = run_gh_command_logged( [ "gh", "api", @@ -562,9 +561,12 @@ def create_new_comment(pr_number: str, comment_body: str) -> None: """ print(f"DEBUG: Posting new comment on PR #{pr_number}", file=sys.stderr) print(f"DEBUG: Comment body length: {len(comment_body)} bytes", file=sys.stderr) - result = run_gh_command( + # Creating a comment is not idempotent: a retry after a dropped response + # could post the same comment twice, so fail on the first error instead. + result = run_gh_command_logged( ["gh", "pr", "comment", pr_number, "--body", comment_body], operation="Create PR comment", + retry=False, ) print(f"DEBUG: Post response: {result.stdout}", file=sys.stderr) diff --git a/script/helpers.py b/script/helpers.py index 7cc001d92f..11549808ff 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -469,6 +469,77 @@ def get_target_branch() -> str | None: return None +# Substrings (matched case-insensitively against gh's stderr) that identify +# transient failures worth retrying: server errors (HTTP 5xx) and dropped or +# failed connections. Permanent failures (bad auth, missing PR, the 300-file +# diff limit) never match so callers see them immediately. Phrases are +# anchored so gh's GraphQL "Could not resolve to a PullRequest" (a missing +# PR) never classifies as a DNS failure. +_TRANSIENT_GH_ERROR_RE = re.compile( + r"http 5\d\d" + r"|timed out|timeout" + r"|connection (?:reset|refused|closed)" + r"|no such host|could not resolve host" + # gh intercepts DNS errors and prints its own "error connecting to + # " text; the Go phrases above are kept as a hedge in case a + # future gh stops swallowing the underlying error + r"|error connecting to" + r"|failed to verify certificate" + # Go reports a server-closed connection as 'Post "": EOF'; the + # quote-and-colon anchor keeps a URL or message body containing the + # letters from matching + r"|unexpected eof" + r'|": eof' + r"|network is unreachable" + r"|temporary failure" +) + +# Same retry policy as git network commands in esphome/git.py: 3 attempts +# with 2s/4s backoff. +_GH_MAX_ATTEMPTS = 3 + + +def run_gh_command( + args: list[str], *, retry: bool = True +) -> subprocess.CompletedProcess[str]: + """Run a gh CLI command, retrying transient network and server failures. + + Args: + args: Full command line, including the leading "gh". + retry: Pass False for commands that are not idempotent (e.g. posting + a comment), where a retry after a dropped response could repeat + a write that already succeeded server-side. + + Returns: + CompletedProcess with captured text output. + + Raises: + subprocess.CalledProcessError: If the command fails with a permanent + error, or is still failing after the retries are exhausted. + """ + attempts = _GH_MAX_ATTEMPTS if retry else 1 + attempt = 0 + while True: + try: + return subprocess.run( + args, check=True, capture_output=True, text=True, close_fds=False + ) + except subprocess.CalledProcessError as err: + attempt += 1 + stderr = err.stderr or "" + if attempt >= attempts or not _TRANSIENT_GH_ERROR_RE.search(stderr.lower()): + raise + delay = 2**attempt + # Only the leading arguments: comment-update calls carry the + # whole multi-KB comment body in the argument list + print( + f"WARNING: {' '.join(args[:3])} failed: {stderr.strip()}; " + f"retrying in {delay}s (attempt {attempt}/{attempts})", + file=sys.stderr, + ) + time.sleep(delay) + + @cache def _get_changed_files_github_actions() -> list[str] | None: """Get changed files in GitHub Actions environment. @@ -542,10 +613,22 @@ def changed_files(branch: str | None = None) -> list[str]: def _get_changed_files_from_command(command: list[str]) -> list[str]: - """Run a git command to get changed files and return them as a list.""" - proc = subprocess.run(command, capture_output=True, text=True, check=False) - if proc.returncode != 0: - raise Exception(f"Command failed: {' '.join(command)}\nstderr: {proc.stderr}") + """Run a git or gh command to get changed files and return them as a list.""" + if command[0] == "gh": + try: + proc = run_gh_command(command) + except subprocess.CalledProcessError as e: + raise Exception( + f"Command failed: {' '.join(command)}\nstderr: {e.stderr}" + ) from e + else: + proc = subprocess.run( + command, capture_output=True, text=True, check=False, close_fds=False + ) + if proc.returncode != 0: + raise Exception( + f"Command failed: {' '.join(command)}\nstderr: {proc.stderr}" + ) changed_files = splitlines_no_ends(proc.stdout) cwd = Path.cwd() diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 077b6ef23e..a07e56cea5 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -20,6 +20,7 @@ changed_files = helpers.changed_files filter_changed = helpers.filter_changed get_changed_components = helpers.get_changed_components _get_changed_files_from_command = helpers._get_changed_files_from_command +run_gh_command = helpers.run_gh_command _get_pr_number_from_github_env = helpers._get_pr_number_from_github_env _get_changed_files_github_actions = helpers._get_changed_files_github_actions _filter_changed_ci = helpers._filter_changed_ci @@ -1872,3 +1873,123 @@ def test_is_validate_only_file(filename: str, expected: bool, tmp_path: Path) -> def test_base_python_changed(files: list[str], expected: bool) -> None: """Only Python modules directly in esphome/ count as base Python changes.""" assert helpers.base_python_changed(files) is expected + + +def _gh_error(stderr: str) -> subprocess.CalledProcessError: + return subprocess.CalledProcessError(1, ["gh"], output="", stderr=stderr) + + +def _gh_success(stdout: str = "ok\n") -> subprocess.CompletedProcess: + return subprocess.CompletedProcess(["gh"], 0, stdout=stdout, stderr="") + + +def test_run_gh_command_success() -> None: + """A successful command returns without retrying.""" + with patch("helpers.subprocess.run", return_value=_gh_success()) as mock_run: + result = run_gh_command(["gh", "pr", "diff", "123", "--name-only"]) + + assert result.stdout == "ok\n" + mock_run.assert_called_once() + + +@pytest.mark.parametrize( + "second_error", + [ + ( + 'Post "https://api.github.com/graphql": tls: failed to verify' + " certificate: x509: certificate is not valid for any names," + " but wanted to match api.github.com" + ), + 'Post "https://api.github.com/graphql": EOF', + ( + "error connecting to api.github.com\n" + "check your internet connection or https://githubstatus.com" + ), + ], +) +def test_run_gh_command_retries_transient_error(second_error: str) -> None: + """Transient server errors are retried with 2s/4s backoff.""" + with ( + patch( + "helpers.subprocess.run", + side_effect=[ + _gh_error("HTTP 502: 502 Bad Gateway (https://api.github.com/graphql)"), + _gh_error(second_error), + _gh_success(), + ], + ) as mock_run, + patch("helpers.time.sleep") as mock_sleep, + ): + result = run_gh_command(["gh", "pr", "diff", "123", "--name-only"]) + + assert result.stdout == "ok\n" + assert mock_run.call_count == 3 + assert [call.args[0] for call in mock_sleep.call_args_list] == [2, 4] + + +def test_run_gh_command_gives_up_after_max_attempts() -> None: + """A persistent transient error raises after the third attempt.""" + with ( + patch( + "helpers.subprocess.run", + side_effect=_gh_error("HTTP 503: Service Unavailable"), + ) as mock_run, + patch("helpers.time.sleep") as mock_sleep, + pytest.raises(subprocess.CalledProcessError), + ): + run_gh_command(["gh", "pr", "diff", "123", "--name-only"]) + + assert mock_run.call_count == 3 + assert mock_sleep.call_count == 2 + + +@pytest.mark.parametrize( + "stderr", + [ + "HTTP 404: Not Found (https://api.github.com/repos/x)", + "HTTP 401: Bad credentials", + "HTTP 403: API rate limit exceeded for installation ID 123.", + "diff exceeded the maximum number of changed files (300)", + ( + "GraphQL: Could not resolve to a PullRequest with the number of 999999." + " (repository.pullRequest)" + ), + ], +) +def test_run_gh_command_permanent_error_not_retried(stderr: str) -> None: + """Permanent failures raise immediately without any retry.""" + with ( + patch("helpers.subprocess.run", side_effect=_gh_error(stderr)) as mock_run, + patch("helpers.time.sleep") as mock_sleep, + pytest.raises(subprocess.CalledProcessError), + ): + run_gh_command(["gh", "pr", "diff", "123", "--name-only"]) + + mock_run.assert_called_once() + mock_sleep.assert_not_called() + + +def test_run_gh_command_no_retry_for_non_idempotent_commands() -> None: + """retry=False fails on the first error even when it looks transient.""" + with ( + patch( + "helpers.subprocess.run", + side_effect=_gh_error("HTTP 502: 502 Bad Gateway"), + ) as mock_run, + patch("helpers.time.sleep") as mock_sleep, + pytest.raises(subprocess.CalledProcessError), + ): + run_gh_command(["gh", "pr", "comment", "123", "--body", "x"], retry=False) + + mock_run.assert_called_once() + mock_sleep.assert_not_called() + + +def test_get_changed_files_from_command_gh_failure_keeps_stderr() -> None: + """Failures from gh surface stderr so callers can detect the 300-file limit.""" + stderr = "diff exceeded the maximum number of changed files (300)" + with ( + patch("helpers.subprocess.run", side_effect=_gh_error(stderr)), + pytest.raises(Exception, match="maximum number of changed files"), + ): + _get_changed_files_from_command(["gh", "pr", "diff", "123", "--name-only"]) From a3af82867b3cebfbf6af9a3be9c54731f0e8d544 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 12:00:39 -0700 Subject: [PATCH 1515/1815] [api] Bump noise-c to 0.1.18 (#18451) --- esphome/components/api/__init__.py | 2 +- platformio.ini | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 8ec94df1db..5ca9484336 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -497,7 +497,7 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") - cg.add_library("esphome/noise-c", "0.1.11") + cg.add_library("esphome/noise-c", "0.1.18") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index bf3b0685f8..2c22523be5 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.11 ; api + esphome/noise-c@0.1.18 ; api improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.11 ; api + esphome/noise-c@0.1.18 ; api ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.11 ; used by api + esphome/noise-c@0.1.18 ; used by api lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} From 6d20943a9c17a994338ba176c2709de80897d1df Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:38:06 -0400 Subject: [PATCH 1516/1815] Bump esphome/workflows/.github/workflows/stale.yml from 61fd37a044cad4e9aa4303027b2a61b6a34da855 to a1c1485ab46ef41a84a6a9d8abd7fa4b7628fd70 (#18464) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 3c471b6efb..aa31094f81 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -16,7 +16,7 @@ jobs: # No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome # GitHub App token so the labels, comments and closures come from # esphome[bot] instead of github-actions[bot]. - uses: esphome/workflows/.github/workflows/stale.yml@61fd37a044cad4e9aa4303027b2a61b6a34da855 # main + uses: esphome/workflows/.github/workflows/stale.yml@a1c1485ab46ef41a84a6a9d8abd7fa4b7628fd70 # main secrets: ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} with: From 9bc72529a6a4dab3bcfb6d182b1a5aa2f0b66b9a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:42:08 -0500 Subject: [PATCH 1517/1815] Bump astral-sh/setup-uv from 9.0.0 to 10.0.1 in /.github/actions/restore-python (#18462) Signed-off-by: dependabot[bot] --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index daf041819c..6279a26dc4 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -32,7 +32,7 @@ runs: # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # downstream jobs rely on is preserved. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can From 4712c15c75d0457aea5d75ff717a0f2bc7171d00 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:42:24 -0500 Subject: [PATCH 1518/1815] Bump github/codeql-action/init from 4.37.6 to 4.37.7 (#18466) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4e164cd9f6..f01441cdfd 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} From 3d5f6f692f4916fd2923c490d28a5df3287c087c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:45:41 -0500 Subject: [PATCH 1519/1815] Bump filelock from 3.32.2 to 3.32.3 (#18461) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 61011f2fbd..4b1708637d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,7 +28,7 @@ smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 platformdirs==4.11.2 # native esp-idf toolchain global cache dir -filelock==3.32.2 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg +filelock==3.32.3 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 From 95180067245bf49d5154889082323c91450145c3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:46:04 -0500 Subject: [PATCH 1520/1815] Bump ruff from 0.16.2 to 0.16.3 (#18458) Signed-off-by: dependabot[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 95ee97437d..cedc107b17 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.7 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.16.2 # also change in .pre-commit-config.yaml when updating +ruff==0.16.3 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating prek==0.4.13 # also change in .github/workflows/ci.yml when updating From 346ba7e831d21d5b005276ca1085fcc570ce3ce8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:47:18 -0500 Subject: [PATCH 1521/1815] Bump github/codeql-action/analyze from 4.37.6 to 4.37.7 (#18467) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f01441cdfd..103cecc1f9 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: category: "/language:${{matrix.language}}" From 55726120db6bfaf8ed8fc699a92580f59b7b0e46 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:47:28 -0500 Subject: [PATCH 1522/1815] Bump esphome/workflows/.github/workflows/lock.yml from 2026.7.0 to 2026.8.1 (#18465) Signed-off-by: dependabot[bot] --- .github/workflows/lock.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index ec736a2002..e09e9bf2d1 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -14,4 +14,4 @@ jobs: permissions: issues: write # issues.lock on closed issues pull-requests: write # issues.lock on closed pull requests - uses: esphome/workflows/.github/workflows/lock.yml@9f6577fd37b5cf773ab1b9be929714a0dcd15661 # 2026.7.0 + uses: esphome/workflows/.github/workflows/lock.yml@0fdd5e311b7e744069166696072a1a9cbc5fbeb6 # 2026.8.1 From 199e368fe2dfca47d8c59796bb54d1d292934396 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:57:50 -0500 Subject: [PATCH 1523/1815] Bump astral-sh/setup-uv from 9.0.0 to 10.0.1 (#18463) Signed-off-by: dependabot[bot] --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci.yml | 6 +++--- .github/workflows/sync-device-classes.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 820081cc46..1ccff96f24 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -29,7 +29,7 @@ jobs: - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true # Pull-request-only workflow: a save could never be shared and diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 026c2ba27a..cd1a382c21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -367,7 +367,7 @@ jobs: - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -1095,7 +1095,7 @@ jobs: # install step (order-of-magnitude faster on cold boots, # with its own wheel cache). actions/setup-python still # provides the interpreter. - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index a299e76584..9100064176 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -47,7 +47,7 @@ jobs: # setup-python interpreter so subsequent ``prek`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true # Pin uv version so the action does not have to fetch the From e32329cc11a38d9b0a70bbd401b1e0ea6a062423 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 15:00:37 -0500 Subject: [PATCH 1524/1815] [core] Sync pre-commit ruff hook with requirements (0.16.3) (#18469) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 99a4f40201..0ea799aa4d 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.16.0 + rev: v0.16.3 hooks: # Run the linter. - id: ruff From e45b4e493886e089880cef1b77a4ae367ca0aea9 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:26:24 +1200 Subject: [PATCH 1525/1815] [core] Make FINAL_VALIDATE_SCHEMA functions return None (#18457) --- esphome/components/bk72xx_ble/__init__.py | 3 +-- esphome/components/captive_portal/__init__.py | 4 +--- esphome/components/dsmr/__init__.py | 4 +--- esphome/components/emontx/__init__.py | 4 ++-- esphome/components/epaper_spi/display.py | 3 +-- esphome/components/esp32/__init__.py | 4 +--- esphome/components/esp32_ble/__init__.py | 4 +--- esphome/components/esp32_ble_server/__init__.py | 3 +-- esphome/components/esp32_hosted/__init__.py | 3 +-- esphome/components/ethernet/__init__.py | 3 +-- esphome/components/factory_reset/__init__.py | 3 +-- esphome/components/file/image.py | 3 +-- .../components/gpio/binary_sensor/__init__.py | 10 ++++------ esphome/components/growatt_solar/sensor.py | 4 ++-- esphome/components/haier/climate.py | 3 +-- esphome/components/haier/switch/__init__.py | 3 +-- esphome/components/havells_solar/sensor.py | 4 ++-- esphome/components/hub75/display.py | 4 +--- esphome/components/improv_serial/__init__.py | 3 +-- esphome/components/inkplate/display.py | 3 +-- esphome/components/it8951/display.py | 3 +-- esphome/components/kuntze/sensor.py | 4 ++-- esphome/components/ld6002b/button/__init__.py | 4 +--- esphome/components/ld6002b/number/__init__.py | 6 ++---- esphome/components/light/__init__.py | 6 ++---- esphome/components/mcp4461/output/__init__.py | 5 ++--- esphome/components/mdns/__init__.py | 5 ++--- esphome/components/mipi_dsi/display.py | 3 +-- esphome/components/mipi_rgb/display.py | 3 +-- esphome/components/mitsubishi_cn105/climate.py | 6 +++--- .../components/modbus_controller/__init__.py | 6 ++---- esphome/components/modbus_server/__init__.py | 4 ++-- .../packet_transport/binary_sensor.py | 6 +++--- esphome/components/provisioning/__init__.py | 3 +-- esphome/components/pzemac/sensor.py | 4 ++-- esphome/components/pzemdc/sensor.py | 4 ++-- esphome/components/router/speaker/__init__.py | 3 +-- esphome/components/rp2040_ble/__init__.py | 3 +-- esphome/components/sdm_meter/sensor.py | 4 ++-- esphome/components/sds011/sensor.py | 3 +-- esphome/components/selec_meter/sensor.py | 4 ++-- esphome/components/tinyusb/__init__.py | 3 +-- esphome/components/web_server/__init__.py | 3 +-- esphome/components/zephyr_pwm/output.py | 3 +-- esphome/components/zwave_proxy/__init__.py | 4 +--- tests/component_tests/esp32_hosted/test_init.py | 2 +- tests/component_tests/image/test_init.py | 17 +++++++++-------- .../provisioning/test_provisioning.py | 6 +++--- 48 files changed, 79 insertions(+), 123 deletions(-) diff --git a/esphome/components/bk72xx_ble/__init__.py b/esphome/components/bk72xx_ble/__init__.py index b58464a1f6..81073c9b02 100644 --- a/esphome/components/bk72xx_ble/__init__.py +++ b/esphome/components/bk72xx_ble/__init__.py @@ -68,12 +68,11 @@ def _unsupported_family_message(family: str) -> str | None: return None -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: # Warn only: a hard error here would break the validate-only CI fixtures, # which run on a BLE 4.2 board. The hard error is raised at codegen. if msg := _unsupported_family_message(libretiny.get_libretiny_family()): _LOGGER.warning("%s (this configuration cannot compile)", msg) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index d62c718097..8e5274f58f 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -61,7 +61,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: full_config = fv.full_config.get() wifi_conf = full_config.get("wifi") @@ -88,8 +88,6 @@ def _final_validate(config: ConfigType) -> ConfigType: socket.consume_sockets(3, "captive_portal")(config) socket.consume_sockets(1, "captive_portal", socket.SocketType.UDP)(config) - return config - FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/dsmr/__init__.py b/esphome/components/dsmr/__init__.py index 34f37ace35..eaf36d34fa 100644 --- a/esphome/components/dsmr/__init__.py +++ b/esphome/components/dsmr/__init__.py @@ -88,7 +88,7 @@ async def to_code(config): cg.add_library("esphome/dsmr_parser", "1.9.0") -def final_validate(config: ConfigType) -> ConfigType: +def final_validate(config: ConfigType) -> None: full_config = fv.full_config.get() for uart_conf in full_config["uart"]: @@ -102,7 +102,5 @@ def final_validate(config: ConfigType) -> ConfigType: ) break - return config - FINAL_VALIDATE_SCHEMA = final_validate diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index a2d4349698..3f83578926 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -59,7 +59,7 @@ CONFIG_SCHEMA = ( ) -def final_validate(config: ConfigType) -> ConfigType: +def final_validate(config: ConfigType) -> None: full_config = fv.full_config.get() # Count sensors registered to this hub (IDs are resolved at final_validate stage) @@ -95,7 +95,7 @@ def final_validate(config: ConfigType) -> ConfigType: parity="NONE", stop_bits=1, ) - return schema(config) + schema(config) FINAL_VALIDATE_SCHEMA = final_validate diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 0b82850f1e..e9da924de5 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -153,7 +153,7 @@ def customise_schema(config): CONFIG_SCHEMA = customise_schema -def _final_validate(config): +def _final_validate(config) -> None: spi.final_validate_device_schema( "epaper_spi", require_miso=False, require_mosi=True )(config) @@ -170,7 +170,6 @@ def _final_validate(config): config[CONF_SHOW_TEST_CARD] = True elif CONF_UPDATE_INTERVAL not in config: config[CONF_UPDATE_INTERVAL] = update_interval("1min") - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 7263571d69..7d43c3ac07 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1368,7 +1368,7 @@ def _validate_signed_ota_keys(config: ConfigType) -> ConfigType: return config -def final_validate(config): +def final_validate(config) -> None: # Imported locally to avoid circular import issues from esphome.components.psram import DOMAIN as PSRAM_DOMAIN @@ -1629,8 +1629,6 @@ def final_validate(config): if errs: raise cv.MultipleInvalid(errs) - return config - CONF_SDKCONFIG_OPTIONS = "sdkconfig_options" CONF_ENABLE_LWIP_DHCP_SERVER = "enable_lwip_dhcp_server" diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 935d8b1b7e..f099c68e57 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -443,7 +443,7 @@ def validate_connection_slots(max_connections: int) -> None: ) -def final_validation(config): +def final_validation(config) -> None: validate_variant(config) if (name := config.get(CONF_NAME)) is not None: full_config = fv.full_config.get() @@ -514,8 +514,6 @@ def final_validation(config): # For newer chips (C3/S3/etc), different configs are used automatically add_idf_sdkconfig_option("CONFIG_BTDM_CTRL_BLE_MAX_CONN", max_connections) - return config - FINAL_VALIDATE_SCHEMA = final_validation diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index ea2a9667d7..855a3be29b 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -307,7 +307,7 @@ def create_device_information_service(config): return config -def final_validate_config(config): +def final_validate_config(config) -> None: # Validate max_clients does not exceed esp32_ble max_connections max_clients = config[CONF_MAX_CLIENTS] if max_clients > 1: @@ -355,7 +355,6 @@ def final_validate_config(config): raise cv.Invalid( f"Characteristic {char_config[CONF_UUID]} has both a set_value action and a templated value" ) - return config def validate_value_type(value_config): diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index d3432fb461..7dc61ce382 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -126,7 +126,7 @@ CONFIG_SCHEMA = cv.typed_schema( ) -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: # The esp_hosted releases compatible with older ESP-IDF versions crash at # boot with a heap double free in the SDIO RX path (fixed in esp_hosted # 2.11.0, which requires ESP-IDF 5.3), so reject them at validation time. @@ -136,7 +136,6 @@ def _final_validate(config: ConfigType) -> ConfigType: "Remove the framework version from your configuration to use the " "recommended version, or pin a version at or above 5.3." ) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index f3c77baaae..5eda0fc12c 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -767,7 +767,7 @@ def _final_validate_rmii_pins(config: ConfigType) -> None: raise cv.Invalid(error_msg, path=pin_path) -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: """Final validation for Ethernet component.""" # Allow ethernet + wifi coexistence only when both are declared in network: priority:. if "wifi" in fv.full_config.get(): @@ -787,7 +787,6 @@ def _final_validate(config: ConfigType) -> ConfigType: _final_validate_spi(config) _final_validate_rmii_pins(config) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/factory_reset/__init__.py b/esphome/components/factory_reset/__init__.py index 818a53c0ed..d5d5d2ecb5 100644 --- a/esphome/components/factory_reset/__init__.py +++ b/esphome/components/factory_reset/__init__.py @@ -60,14 +60,13 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config): +def _final_validate(config) -> None: if CORE.is_esp8266 and CONF_RESETS_REQUIRED in config: fconfig = full_config.get() if not fconfig.get_config_for_path([KEY_ESP8266, CONF_RESTORE_FROM_FLASH]): raise cv.Invalid( "'resets_required' needs 'restore_from_flash' to be enabled in the 'esp8266' configuration" ) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index b54c3f2adf..d340d21490 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -225,7 +225,7 @@ def image_schema(class_: MockObjClass = Image_) -> cv.Schema: ) -def validate_image_final(config: ConfigType) -> ConfigType: +def validate_image_final(config: ConfigType) -> None: """Per-entry final validation, shared by file-backed image platforms. For LVGL 9 the default byte order for RGB565 images is little-endian, so @@ -240,7 +240,6 @@ def validate_image_final(config: ConfigType) -> ConfigType: ) else: config[CONF_BYTE_ORDER] = "LITTLE_ENDIAN" - return config async def new_image(config: ConfigType) -> MockObj: diff --git a/esphome/components/gpio/binary_sensor/__init__.py b/esphome/components/gpio/binary_sensor/__init__.py index 43358baedb..703806670c 100644 --- a/esphome/components/gpio/binary_sensor/__init__.py +++ b/esphome/components/gpio/binary_sensor/__init__.py @@ -68,10 +68,10 @@ def _pin_shared_only_with_deep_sleep(pin_num: int) -> bool: return any(path and path[0] == "deep_sleep" for path, _, _ in pin_users) -def _final_validate(config): +def _final_validate(config) -> None: use_interrupt = config[CONF_USE_INTERRUPT] if not use_interrupt: - return config + return # Expander pins (e.g. PCF8574, MCP23017) don't support direct interrupt # attachment — only internal/native GPIO pins do. @@ -82,7 +82,7 @@ def _final_validate(config): config.get(CONF_NAME, config[CONF_ID]), ) config[CONF_USE_INTERRUPT] = False - return config + return pin_num = config[CONF_PIN][CONF_NUMBER] @@ -96,7 +96,7 @@ def _final_validate(config): config.get(CONF_NAME, config[CONF_ID]), ) config[CONF_USE_INTERRUPT] = False - return config + return # When a pin is shared, interrupts can interfere with other components # (e.g., duty_cycle sensor) that need to monitor the pin's state changes. @@ -120,8 +120,6 @@ def _final_validate(config): pin_num, ) - return config - FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/growatt_solar/sensor.py b/esphome/components/growatt_solar/sensor.py index d1f0069341..d62486f5ec 100644 --- a/esphome/components/growatt_solar/sensor.py +++ b/esphome/components/growatt_solar/sensor.py @@ -163,8 +163,8 @@ CONFIG_SCHEMA = ( ) -def _final_validate(config: ConfigType) -> ConfigType: - return modbus.final_validate_modbus_device("growatt_solar", role="client")(config) +def _final_validate(config: ConfigType) -> None: + modbus.final_validate_modbus_device("growatt_solar", role="client")(config) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/haier/climate.py b/esphome/components/haier/climate.py index 424ef46392..70ae36f528 100644 --- a/esphome/components/haier/climate.py +++ b/esphome/components/haier/climate.py @@ -424,7 +424,7 @@ async def power_action_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, paren) -def _final_validate(config): +def _final_validate(config) -> None: full_config = fv.full_config.get() if CONF_LOGGER in full_config: _level = "NONE" @@ -448,7 +448,6 @@ def _final_validate(config): raise cv.Invalid( f"No WiFi configured, if you want to use haier climate without WiFi add {CONF_WIFI_SIGNAL}: false to climate configuration" ) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/haier/switch/__init__.py b/esphome/components/haier/switch/__init__.py index acff0cf265..99ffcb37af 100644 --- a/esphome/components/haier/switch/__init__.py +++ b/esphome/components/haier/switch/__init__.py @@ -60,7 +60,7 @@ CONFIG_SCHEMA = cv.Schema( ) -def _final_validate(config): +def _final_validate(config) -> None: full_config = fv.full_config.get() for switch_type in [CONF_BEEPER, CONF_QUIET_MODE]: # Check switches that are only supported for HonClimate @@ -72,7 +72,6 @@ def _final_validate(config): raise cv.Invalid( f"{switch_type} switch is only supported for hon climate" ) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/havells_solar/sensor.py b/esphome/components/havells_solar/sensor.py index d18ae0d9af..8eafe1d9d6 100644 --- a/esphome/components/havells_solar/sensor.py +++ b/esphome/components/havells_solar/sensor.py @@ -217,8 +217,8 @@ CONFIG_SCHEMA = ( ) -def _final_validate(config: ConfigType) -> ConfigType: - return modbus.final_validate_modbus_device("havells_solar", role="client")(config) +def _final_validate(config: ConfigType) -> None: + modbus.final_validate_modbus_device("havells_solar", role="client")(config) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index a404fbbade..24b8197073 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -315,7 +315,7 @@ def _validate_config(config: ConfigType) -> ConfigType: return config -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: """Validate requirements when using HUB75 display.""" # Local imports to avoid circular dependencies from esphome.components.esp32 import get_esp32_variant @@ -381,8 +381,6 @@ def _final_validate(config: ConfigType) -> ConfigType: if errs: raise cv.MultipleInvalid(errs) - return config - FINAL_VALIDATE_SCHEMA = cv.Schema(_final_validate) diff --git a/esphome/components/improv_serial/__init__.py b/esphome/components/improv_serial/__init__.py index 4266f5b78b..3e2a6db1bc 100644 --- a/esphome/components/improv_serial/__init__.py +++ b/esphome/components/improv_serial/__init__.py @@ -22,7 +22,7 @@ CONFIG_SCHEMA = ( ) -def validate_logger(config): +def validate_logger(config) -> None: logger_conf = fv.full_config.get()[CONF_LOGGER] if logger_conf[CONF_BAUD_RATE] == 0: raise cv.Invalid("improv_serial requires the logger baud_rate to be not 0") @@ -33,7 +33,6 @@ def validate_logger(config): raise cv.Invalid( "improv_serial does not support the selected logger hardware_uart" ) - return config FINAL_VALIDATE_SCHEMA = validate_logger diff --git a/esphome/components/inkplate/display.py b/esphome/components/inkplate/display.py index 47c8c898e5..a0c0d5dc18 100644 --- a/esphome/components/inkplate/display.py +++ b/esphome/components/inkplate/display.py @@ -146,13 +146,12 @@ CONFIG_SCHEMA = cv.All( ) -def _validate_cpu_frequency(config): +def _validate_cpu_frequency(config) -> None: esp32_config = fv.full_config.get()[PLATFORM_ESP32] if esp32_config[CONF_CPU_FREQUENCY] != "240MHZ": raise cv.Invalid( "Inkplate requires 240MHz CPU frequency (set in esp32 component)" ) - return config FINAL_VALIDATE_SCHEMA = _validate_cpu_frequency diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py index 51c5fc6118..bdc68b5257 100644 --- a/esphome/components/it8951/display.py +++ b/esphome/components/it8951/display.py @@ -336,7 +336,7 @@ def _customise_schema(config): CONFIG_SCHEMA = _customise_schema -def _final_validate(config): +def _final_validate(config) -> None: # IT8951 reads from SPI (DevInfo, VCOM, register reads) so MISO is required. spi.final_validate_device_schema("it8951", require_miso=True, require_mosi=True)( config @@ -351,7 +351,6 @@ def _final_validate(config): config[CONF_UPDATE_INTERVAL] = update_interval("never") else: config[CONF_SHOW_TEST_CARD] = True - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/kuntze/sensor.py b/esphome/components/kuntze/sensor.py index c11ede9db6..2b53e70756 100644 --- a/esphome/components/kuntze/sensor.py +++ b/esphome/components/kuntze/sensor.py @@ -89,8 +89,8 @@ CONFIG_SCHEMA = ( ) -def _final_validate(config: ConfigType) -> ConfigType: - return modbus.final_validate_modbus_device("kuntze", role="client")(config) +def _final_validate(config: ConfigType) -> None: + modbus.final_validate_modbus_device("kuntze", role="client")(config) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/ld6002b/button/__init__.py b/esphome/components/ld6002b/button/__init__.py index c327c331c6..508d5c2bc6 100644 --- a/esphome/components/ld6002b/button/__init__.py +++ b/esphome/components/ld6002b/button/__init__.py @@ -84,7 +84,7 @@ CONFIG_SCHEMA = cv.Schema( ) -def final_validate(config: ConfigType) -> ConfigType: +def final_validate(config: ConfigType) -> None: full_config = fv.full_config.get() hub_id = config[CONF_LD6002B_ID] @@ -108,8 +108,6 @@ def final_validate(config: ConfigType) -> ConfigType: path=[CONF_WAKE], ) - return config - FINAL_VALIDATE_SCHEMA = final_validate diff --git a/esphome/components/ld6002b/number/__init__.py b/esphome/components/ld6002b/number/__init__.py index 7e0be66c64..452e38d6e3 100644 --- a/esphome/components/ld6002b/number/__init__.py +++ b/esphome/components/ld6002b/number/__init__.py @@ -105,9 +105,9 @@ CONFIG_SCHEMA = cv.Schema( ) -def final_validate(config: ConfigType) -> ConfigType: +def final_validate(config: ConfigType) -> None: if config.get(CONF_AREA_CONFIG) is None: - return config + return full_config = fv.full_config.get() hub_id = config[CONF_LD6002B_ID] @@ -132,8 +132,6 @@ def final_validate(config: ConfigType) -> ConfigType: path=[CONF_AREA_CONFIG], ) - return config - FINAL_VALIDATE_SCHEMA = final_validate diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 7c4d7ed431..b5b3d7c905 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -165,7 +165,7 @@ def available_effects_str(effects: list) -> str: return ", ".join(f"'{name}'" for name in available) if available else "none" -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: """Validate all recorded effect name references against their target lights. This runs once per light platform instance. If no light platform is configured, @@ -173,7 +173,7 @@ def _final_validate(config: ConfigType) -> ConfigType: """ data = _get_data() if not data.effect_refs and not data.effect_cycle_refs: - return config + return # Drain the lists so we only validate once even though # FINAL_VALIDATE_SCHEMA runs for each light platform instance. @@ -217,8 +217,6 @@ def _final_validate(config: ConfigType) -> ConfigType: path=[cv.ROOT_CONFIG_PATH] + ref.component_path, ) - return config - FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/mcp4461/output/__init__.py b/esphome/components/mcp4461/output/__init__.py index 1642f6149a..99d4988c90 100644 --- a/esphome/components/mcp4461/output/__init__.py +++ b/esphome/components/mcp4461/output/__init__.py @@ -34,7 +34,7 @@ CONF_NONVOLATILE_WRITE_DELAY = "nonvolatile_write_delay" VOLATILE_CHANNELS = ("A", "B", "C", "D") -def _validate_nonvolatile(config): +def _validate_nonvolatile(config) -> None: channel = str(config[CONF_CHANNEL]) # Channels E-H address the nonvolatile registers directly — the mirroring options only @@ -49,7 +49,7 @@ def _validate_nonvolatile(config): f"enabling '{CONF_NONVOLATILE}' or setting '{CONF_NONVOLATILE_WRITE_DELAY}' is only valid for the " f"volatile channels A-D; channels E-H are the nonvolatile registers themselves" ) - return config + return config.setdefault(CONF_NONVOLATILE, True) if config[CONF_NONVOLATILE]: @@ -62,7 +62,6 @@ def _validate_nonvolatile(config): raise cv.Invalid( f"'{CONF_NONVOLATILE_WRITE_DELAY}' requires '{CONF_NONVOLATILE}: true'" ) - return config CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 2d4f6085e5..24bce0cc3c 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -62,7 +62,7 @@ def _consume_mdns_sockets(config: ConfigType) -> ConfigType: return config -def _require_network_interface(config: ConfigType) -> ConfigType: +def _require_network_interface(config: ConfigType) -> None: """Require a network interface for mDNS on Arduino/LEAmDNS platforms. On ESP8266 and RP2040 the C++ implementation needs at least one IP state @@ -71,7 +71,7 @@ def _require_network_interface(config: ConfigType) -> ConfigType: that never initializes. """ if config.get(CONF_DISABLED) or not (CORE.is_esp8266 or CORE.is_rp2): - return config + return full_config = fv.full_config.get() has_wifi = "wifi" in full_config has_ethernet = CORE.is_rp2 and "ethernet" in full_config @@ -81,7 +81,6 @@ def _require_network_interface(config: ConfigType) -> ConfigType: "mdns on this platform requires a network interface — " f"add a {options} component to your configuration." ) - return config CONFIG_SCHEMA = cv.All( diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index e5bb3d413d..8c125a9606 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -175,7 +175,7 @@ def _config_schema(config): return config -def _final_validate(config): +def _final_validate(config) -> None: global_config = full_config.get() from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN @@ -183,7 +183,6 @@ def _final_validate(config): if not requires_buffer(config) and LVGL_DOMAIN not in global_config: # If no drawing methods are configured, and LVGL is not enabled, show a test card config[CONF_SHOW_TEST_CARD] = True - return config CONFIG_SCHEMA = _config_schema diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index ebe930d37a..897088a257 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -248,7 +248,7 @@ def _config_schema(config): CONFIG_SCHEMA = _config_schema -def _final_validate(config): +def _final_validate(config) -> None: global_config = full_config.get() from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN @@ -260,7 +260,6 @@ def _final_validate(config): config = spi.final_validate_device_schema( "mipi_rgb", require_miso=False, require_mosi=True )(config) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/mitsubishi_cn105/climate.py b/esphome/components/mitsubishi_cn105/climate.py index 64475d0e32..05a29b3665 100644 --- a/esphome/components/mitsubishi_cn105/climate.py +++ b/esphome/components/mitsubishi_cn105/climate.py @@ -143,11 +143,11 @@ def CONFIG_SCHEMA(config: ConfigType) -> ConfigType: # Legacy climate-owned hub compatibility. Remove in 2027.2.0. -def _legacy_final_validate(config: ConfigType) -> ConfigType: +def _legacy_final_validate(config: ConfigType) -> None: if CONF_MITSUBISHI_CN105_ID in config: - return config + return - return uart.final_validate_device_schema( + uart.final_validate_device_schema( DOMAIN, require_rx=True, require_tx=True, diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index 1ce1e38d16..f3cd28d138 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -135,10 +135,8 @@ def validate_modbus_register(config): return config -def _final_validate(config: ConfigType) -> ConfigType: - return modbus.final_validate_modbus_device("modbus_controller", role="client")( - config - ) +def _final_validate(config: ConfigType) -> None: + modbus.final_validate_modbus_device("modbus_controller", role="client")(config) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/modbus_server/__init__.py b/esphome/components/modbus_server/__init__.py index 16b956d7b5..249454b6b0 100644 --- a/esphome/components/modbus_server/__init__.py +++ b/esphome/components/modbus_server/__init__.py @@ -144,8 +144,8 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config: ConfigType) -> ConfigType: - return modbus.final_validate_modbus_device("modbus_server", role="server")(config) +def _final_validate(config: ConfigType) -> None: + modbus.final_validate_modbus_device("modbus_server", role="server")(config) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/packet_transport/binary_sensor.py b/esphome/components/packet_transport/binary_sensor.py index 09bbf91c99..3291ff2c59 100644 --- a/esphome/components/packet_transport/binary_sensor.py +++ b/esphome/components/packet_transport/binary_sensor.py @@ -44,10 +44,10 @@ CONFIG_SCHEMA = cv.typed_schema( ) -def _final_validate(config): +def _final_validate(config) -> None: if config[CONF_TYPE] != CONF_STATUS: # Only run this validation if a status sensor is being configured - return config + return full_config = fv.full_config.get() transport_path = full_config.get_path_for_id(config[CONF_TRANSPORT_ID])[:-1] transport_config = full_config.get_config_for_path(transport_path) @@ -56,7 +56,7 @@ def _final_validate(config): for p in transport_config[CONF_PROVIDERS] if p[CONF_NAME] == config[CONF_PROVIDER] ): - return config + return raise cv.Invalid( "Status sensor requires ping-pong to be enabled and the nominated provider to use encryption." ) diff --git a/esphome/components/provisioning/__init__.py b/esphome/components/provisioning/__init__.py index 36fa69357a..9462bbb3b7 100644 --- a/esphome/components/provisioning/__init__.py +++ b/esphome/components/provisioning/__init__.py @@ -67,7 +67,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: """Validate the provisioning setup once every component has been processed. Sources register during their own config validation, so by final validation @@ -89,7 +89,6 @@ def _final_validate(config: ConfigType) -> ConfigType: "hardcoding them makes the window pointless.", ", ".join(sorted(data.hardcoded_credentials)), ) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/pzemac/sensor.py b/esphome/components/pzemac/sensor.py index 4e228f6aa3..5bb734cb2d 100644 --- a/esphome/components/pzemac/sensor.py +++ b/esphome/components/pzemac/sensor.py @@ -98,8 +98,8 @@ async def reset_energy_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, paren) -def _final_validate(config: ConfigType) -> ConfigType: - return modbus.final_validate_modbus_device("pzemac", role="client")(config) +def _final_validate(config: ConfigType) -> None: + modbus.final_validate_modbus_device("pzemac", role="client")(config) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/pzemdc/sensor.py b/esphome/components/pzemdc/sensor.py index 40cfe7b08a..b2c7c3a29d 100644 --- a/esphome/components/pzemdc/sensor.py +++ b/esphome/components/pzemdc/sensor.py @@ -80,8 +80,8 @@ async def reset_energy_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, paren) -def _final_validate(config: ConfigType) -> ConfigType: - return modbus.final_validate_modbus_device("pzemdc", role="client")(config) +def _final_validate(config: ConfigType) -> None: + modbus.final_validate_modbus_device("pzemdc", role="client")(config) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/router/speaker/__init__.py b/esphome/components/router/speaker/__init__.py index 2b2dc56433..18311416c3 100644 --- a/esphome/components/router/speaker/__init__.py +++ b/esphome/components/router/speaker/__init__.py @@ -63,7 +63,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: # Validate every configured output speaker can accept the router's format. # Switching to an output that can't reproduce the format the producer is # already sending would otherwise fail silently at runtime. @@ -76,7 +76,6 @@ def _final_validate(config: ConfigType) -> ConfigType: channels=config[CONF_NUM_CHANNELS], sample_rate=config[CONF_SAMPLE_RATE], )(proxy) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/rp2040_ble/__init__.py b/esphome/components/rp2040_ble/__init__.py index 332ea73a61..d2a08e9fc0 100644 --- a/esphome/components/rp2040_ble/__init__.py +++ b/esphome/components/rp2040_ble/__init__.py @@ -71,10 +71,9 @@ def validate_connection_slots() -> None: ) -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: _validate_board(config) validate_connection_slots() - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/sdm_meter/sensor.py b/esphome/components/sdm_meter/sensor.py index 46f5025080..125240e891 100644 --- a/esphome/components/sdm_meter/sensor.py +++ b/esphome/components/sdm_meter/sensor.py @@ -148,8 +148,8 @@ CONFIG_SCHEMA = ( ) -def _final_validate(config: ConfigType) -> ConfigType: - return modbus.final_validate_modbus_device("sdm_meter", role="client")(config) +def _final_validate(config: ConfigType) -> None: + modbus.final_validate_modbus_device("sdm_meter", role="client")(config) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/sds011/sensor.py b/esphome/components/sds011/sensor.py index 2d7b6b07e5..59ee6667a1 100644 --- a/esphome/components/sds011/sensor.py +++ b/esphome/components/sds011/sensor.py @@ -63,7 +63,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config): +def _final_validate(config) -> None: # In the default mode setup() writes config commands, so tx is required; # rx_only mode never writes, so tx is optional. uart.final_validate_device_schema( @@ -75,7 +75,6 @@ def _final_validate(config): parity="NONE", stop_bits=1, )(config) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/selec_meter/sensor.py b/esphome/components/selec_meter/sensor.py index ef4929c375..120b997605 100644 --- a/esphome/components/selec_meter/sensor.py +++ b/esphome/components/selec_meter/sensor.py @@ -164,8 +164,8 @@ CONFIG_SCHEMA = ( ) -def _final_validate(config: ConfigType) -> ConfigType: - return modbus.final_validate_modbus_device("selec_meter", role="client")(config) +def _final_validate(config: ConfigType) -> None: + modbus.final_validate_modbus_device("selec_meter", role="client")(config) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 9e1ad3afc4..4c6f4db85b 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -57,7 +57,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config): +def _final_validate(config) -> None: full_config = fv.full_config.get() if not any(name in full_config for name in _USB_CLASS_COMPONENTS): raise cv.Invalid( @@ -75,7 +75,6 @@ def _final_validate(config): "USB_SERIAL_JTAG on variants that support it " "(ESP32-S3, ESP32-S31, ESP32-P4, ESP32-H4)" ) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index c1887cc3fc..b2c0ea14ad 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -193,7 +193,7 @@ def _validate_no_sorting_component( ) -def _final_validate_sorting(config: ConfigType) -> ConfigType: +def _final_validate_sorting(config: ConfigType) -> None: if (webserver_version := config.get(CONF_VERSION)) != 3: _validate_no_sorting_component( CONF_SORTING_WEIGHT, webserver_version, fv.full_config.get() @@ -201,7 +201,6 @@ def _final_validate_sorting(config: ConfigType) -> ConfigType: _validate_no_sorting_component( CONF_SORTING_GROUP_ID, webserver_version, fv.full_config.get() ) - return config FINAL_VALIDATE_SCHEMA = _final_validate_sorting diff --git a/esphome/components/zephyr_pwm/output.py b/esphome/components/zephyr_pwm/output.py index 54c04473e3..b7ee27f63c 100644 --- a/esphome/components/zephyr_pwm/output.py +++ b/esphome/components/zephyr_pwm/output.py @@ -102,9 +102,8 @@ def _allocate_blocks() -> None: _get_data().pwm_blocks = pwm_blocks -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: _allocate_blocks() - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/zwave_proxy/__init__.py b/esphome/components/zwave_proxy/__init__.py index d88f9f7041..14b8474045 100644 --- a/esphome/components/zwave_proxy/__init__.py +++ b/esphome/components/zwave_proxy/__init__.py @@ -11,7 +11,7 @@ zwave_proxy_ns = cg.esphome_ns.namespace("zwave_proxy") ZWaveProxy = zwave_proxy_ns.class_("ZWaveProxy", cg.Component, uart.UARTDevice) -def final_validate(config): +def final_validate(config) -> None: full_config = fv.full_config.get() if (wifi_conf := full_config.get(CONF_WIFI)) and ( wifi_conf.get(CONF_POWER_SAVE_MODE).lower() != "none" @@ -20,8 +20,6 @@ def final_validate(config): f"{CONF_WIFI} {CONF_POWER_SAVE_MODE} must be set to 'none' when using Z-Wave proxy" ) - return config - CONFIG_SCHEMA = ( cv.Schema( diff --git a/tests/component_tests/esp32_hosted/test_init.py b/tests/component_tests/esp32_hosted/test_init.py index cec81e4e83..5cc3f928cc 100644 --- a/tests/component_tests/esp32_hosted/test_init.py +++ b/tests/component_tests/esp32_hosted/test_init.py @@ -19,7 +19,7 @@ def test_final_validate_accepts_supported_idf( PlatformFramework.ESP32_IDF, platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)}, ) - assert _final_validate({}) == {} + _final_validate({}) @pytest.mark.parametrize("idf", ["5.0.0", "5.2.2"]) diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index 78462463b1..f52c477c85 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -371,27 +371,28 @@ def test_migrate_returns_none_for_invalid_legacy_shapes( def test_validate_image_final_defaults_to_little_endian() -> None: - out = validate_image_final({CONF_FILE: "x.png"}) - assert out[CONF_BYTE_ORDER] == "LITTLE_ENDIAN" + config = {CONF_FILE: "x.png"} + validate_image_final(config) + assert config[CONF_BYTE_ORDER] == "LITTLE_ENDIAN" def test_validate_image_final_keeps_little_endian( caplog: pytest.LogCaptureFixture, ) -> None: + config = {CONF_FILE: "x.png", CONF_BYTE_ORDER: "LITTLE_ENDIAN"} with caplog.at_level(logging.WARNING): - out = validate_image_final( - {CONF_FILE: "x.png", CONF_BYTE_ORDER: "LITTLE_ENDIAN"} - ) - assert out[CONF_BYTE_ORDER] == "LITTLE_ENDIAN" + validate_image_final(config) + assert config[CONF_BYTE_ORDER] == "LITTLE_ENDIAN" assert "big-endian" not in caplog.text def test_validate_image_final_warns_on_big_endian( caplog: pytest.LogCaptureFixture, ) -> None: + config = {CONF_FILE: "x.png", CONF_BYTE_ORDER: "BIG_ENDIAN"} with caplog.at_level(logging.WARNING): - out = validate_image_final({CONF_FILE: "x.png", CONF_BYTE_ORDER: "BIG_ENDIAN"}) - assert out[CONF_BYTE_ORDER] == "BIG_ENDIAN" + validate_image_final(config) + assert config[CONF_BYTE_ORDER] == "BIG_ENDIAN" assert "big-endian" in caplog.text diff --git a/tests/component_tests/provisioning/test_provisioning.py b/tests/component_tests/provisioning/test_provisioning.py index 07f5065241..d3a3771bbc 100644 --- a/tests/component_tests/provisioning/test_provisioning.py +++ b/tests/component_tests/provisioning/test_provisioning.py @@ -37,7 +37,7 @@ def test_provisioning_accepts_a_registered_source( set_core_config(PlatformFramework.ESP32_IDF) register_source("network") # Should not raise. - assert FINAL_VALIDATE_SCHEMA({}) == {} + FINAL_VALIDATE_SCHEMA({}) def test_provisioning_warns_on_hardcoded_credentials( @@ -49,7 +49,7 @@ def test_provisioning_warns_on_hardcoded_credentials( register_source("network") report_hardcoded_credentials("wifi") with caplog.at_level(logging.WARNING): - assert FINAL_VALIDATE_SCHEMA({}) == {} + FINAL_VALIDATE_SCHEMA({}) assert "wifi" in caplog.text assert "credentials" in caplog.text @@ -62,7 +62,7 @@ def test_provisioning_no_warning_without_hardcoded_credentials( set_core_config(PlatformFramework.ESP32_IDF) register_source("network") with caplog.at_level(logging.WARNING): - assert FINAL_VALIDATE_SCHEMA({}) == {} + FINAL_VALIDATE_SCHEMA({}) assert "credentials" not in caplog.text From 7362c01c6744e0be6eae307a31486d59f8b50f49 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 16:14:06 -0500 Subject: [PATCH 1526/1815] [core] Replace base64 lookup tables with arithmetic mapping (#18454) --- esphome/core/alloc_helpers.cpp | 16 ++++-- esphome/core/helpers.cpp | 24 ++++----- tests/components/core/test_helpers.cpp | 67 ++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 16 deletions(-) diff --git a/esphome/core/alloc_helpers.cpp b/esphome/core/alloc_helpers.cpp index d9cfad70b9..f6130b7b78 100644 --- a/esphome/core/alloc_helpers.cpp +++ b/esphome/core/alloc_helpers.cpp @@ -88,9 +88,17 @@ std::string str_sprintf(const char *fmt, ...) { // --- Base64 helpers --- -static constexpr const char *BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" - "abcdefghijklmnopqrstuvwxyz" - "0123456789+/"; +// Map a 6-bit value (0-63) to its base64 character arithmetically. +// No lookup table: a table would occupy RAM on ESP8266 (.rodata lives in DRAM there). +static inline char base64_char(uint8_t index) { + if (index < 26) + return 'A' + index; + if (index < 52) + return 'a' + (index - 26); + if (index < 62) + return '0' + (index - 52); + return index == 62 ? '+' : '/'; +} // Encode 3 input bytes to 4 base64 characters, append 'count' to ret. static inline void base64_encode_triple(const char *char_array_3, int count, std::string &ret) { @@ -101,7 +109,7 @@ static inline void base64_encode_triple(const char *char_array_3, int count, std char_array_4[3] = char_array_3[2] & 0x3f; for (int j = 0; j < count; j++) - ret += BASE64_CHARS[static_cast(char_array_4[j])]; + ret += base64_char(static_cast(char_array_4[j])); } std::string base64_encode(const std::vector &buf) { return base64_encode(buf.data(), buf.size()); } diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 8c4442f1b2..bd08d3b63e 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -579,13 +579,8 @@ int8_t step_to_accuracy_decimals(float step) { return str.length() - dot_pos - 1; } -// Use C-style string constant to store in ROM instead of RAM (saves 24 bytes) -static constexpr const char *BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" - "abcdefghijklmnopqrstuvwxyz" - "0123456789+/"; - -// Helper function to find the index of a base64/base64url character in the lookup table. -// Returns the character's position (0-63) if found, or 0 if not found. +// Map a base64/base64url character to its 6-bit value (0-63) arithmetically. +// No lookup table: a table would occupy RAM on ESP8266 (.rodata lives in DRAM there). // Supports both standard base64 (+/) and base64url (-_) alphabets. // NOTE: This returns 0 for both 'A' (valid base64 char at index 0) and invalid characters. // This is safe because is_base64() is ALWAYS checked before calling this function, @@ -593,13 +588,18 @@ static constexpr const char *BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" // stops processing at the first invalid character due to the is_base64() check in its // while loop condition, making this edge case harmless in practice. static inline uint8_t base64_find_char(char c) { - // Handle base64url variants: '-' maps to '+' (index 62), '_' maps to '/' (index 63) - if (c == '-') + if (c >= 'A' && c <= 'Z') + return c - 'A'; + if (c >= 'a' && c <= 'z') + return c - 'a' + 26; + if (c >= '0' && c <= '9') + return c - '0' + 52; + // base64url variants: '-' maps to '+' (index 62), '_' maps to '/' (index 63) + if (c == '+' || c == '-') return 62; - if (c == '_') + if (c == '/' || c == '_') return 63; - const char *pos = strchr(BASE64_CHARS, c); - return pos ? (pos - BASE64_CHARS) : 0; + return 0; } // Check if character is valid base64 or base64url diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp index 5fb77ef753..3767b24d86 100644 --- a/tests/components/core/test_helpers.cpp +++ b/tests/components/core/test_helpers.cpp @@ -1,6 +1,7 @@ #include #include +#include "esphome/core/alloc_helpers.h" #include "esphome/core/helpers.h" namespace esphome::core::testing { @@ -213,4 +214,70 @@ TEST(BufAppendSepStr, Truncation) { EXPECT_EQ(end - buf, 7); } +// --- base64 encode/decode --- + +static const char BASE64_ALPHABET[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +// Pack 6-bit indices 0..63 into 48 bytes so encoding yields the full alphabet in order +TEST(Base64, EncodeProducesCanonicalAlphabet) { + uint8_t bytes[48]; + size_t n = 0; + for (uint8_t i = 0; i < 64; i += 4) { + bytes[n++] = (i << 2) | ((i + 1) >> 4); + bytes[n++] = ((i + 1) & 0x0F) << 4 | ((i + 2) >> 2); + bytes[n++] = ((i + 2) & 0x03) << 6 | (i + 3); + } + std::string encoded = base64_encode(bytes, sizeof(bytes)); // NOLINT(esphome-heap-allocation) - host test + EXPECT_EQ(encoded, BASE64_ALPHABET); +} + +// Decode the alphabet then re-encode: locks the encode and decode mappings together +TEST(Base64, DecodeCanonicalAlphabetRoundTrip) { + uint8_t buf[48]; + size_t len = base64_decode(std::string(BASE64_ALPHABET), buf, sizeof(buf)); + EXPECT_EQ(len, 48u); + std::string reencoded = base64_encode(buf, len); // NOLINT(esphome-heap-allocation) - host test + EXPECT_EQ(reencoded, BASE64_ALPHABET); +} + +TEST(Base64, DecodeBase64UrlMatchesStandard) { + std::string url = BASE64_ALPHABET; + for (char &c : url) { + if (c == '+') + c = '-'; + if (c == '/') + c = '_'; + } + uint8_t standard[48], urlsafe[48]; + size_t len_standard = base64_decode(std::string(BASE64_ALPHABET), standard, sizeof(standard)); + size_t len_url = base64_decode(url, urlsafe, sizeof(urlsafe)); + EXPECT_EQ(len_standard, len_url); + EXPECT_EQ(memcmp(standard, urlsafe, len_standard), 0); +} + +// RFC 4648 vectors cover both padding cases (len % 3 == 1 and len % 3 == 2) +TEST(Base64, Rfc4648Vectors) { + const struct { + const char *plain; + const char *encoded; + } vectors[] = { + {"", ""}, + {"f", "Zg=="}, + {"fo", "Zm8="}, + {"foo", "Zm9v"}, + {"foob", "Zm9vYg=="}, + {"fooba", "Zm9vYmE="}, + {"foobar", "Zm9vYmFy"}, + }; + for (const auto &v : vectors) { + const auto *plain = reinterpret_cast(v.plain); + std::string encoded = base64_encode(plain, strlen(v.plain)); // NOLINT(esphome-heap-allocation) - host test + EXPECT_EQ(encoded, v.encoded); + uint8_t buf[8]; + size_t len = base64_decode(reinterpret_cast(v.encoded), strlen(v.encoded), buf, sizeof(buf)); + EXPECT_EQ(len, strlen(v.plain)); + EXPECT_EQ(memcmp(buf, v.plain, len), 0); + } +} + } // namespace esphome::core::testing From 96e26c6a5f4d7eed5cae1a46eaa1d23b348b5c36 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:54:23 -0500 Subject: [PATCH 1527/1815] Bump bundled esphome-device-builder to 1.11.1 (#18475) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 2f23b2f690..b78d183e02 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.1 RUN \ platformio settings set enable_telemetry No \ From 7be4566b411d96f7a103879c80f38c9248ec97a4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:33:13 -0500 Subject: [PATCH 1528/1815] Bump platformdirs from 4.11.2 to 4.11.3 (#18468) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4b1708637d..a986646230 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ bleak==3.0.2 smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 -platformdirs==4.11.2 # native esp-idf toolchain global cache dir +platformdirs==4.11.3 # native esp-idf toolchain global cache dir filelock==3.32.3 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this From a347a2e8793243dbaa5ffcc4b07fe3481abbe252 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 18:58:11 -0500 Subject: [PATCH 1529/1815] [api] Bump noise-c to 0.1.19 (#18473) --- esphome/components/api/__init__.py | 2 +- platformio.ini | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 5ca9484336..cdc0d97c49 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -497,7 +497,7 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") - cg.add_library("esphome/noise-c", "0.1.18") + cg.add_library("esphome/noise-c", "0.1.19") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index 2c22523be5..39600d622a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.18 ; api + esphome/noise-c@0.1.19 ; api improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.18 ; api + esphome/noise-c@0.1.19 ; api ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.18 ; used by api + esphome/noise-c@0.1.19 ; used by api lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} From 15a626bcf34cc6905bb5c972dcf74475a86af691 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:16:38 -0500 Subject: [PATCH 1530/1815] Bump bundled esphome-device-builder to 1.11.2 (#18477) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index b78d183e02..4a8daeaaf6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.2 RUN \ platformio settings set enable_telemetry No \ From 4416aacebb4b991a3c8916efffa21a62fa42cf70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 20:09:29 -0500 Subject: [PATCH 1531/1815] [socket] Fix multi-second TCP stalls on ESP8266 by yielding to the SYS context (#18455) --- .../components/socket/lwip_raw_tcp_impl.cpp | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index b80a394eec..8d00dbede2 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -45,6 +45,11 @@ namespace esphome::socket { static const char *const TAG = "socket"; +#ifdef USE_ESP8266 +// optimistic_yield() rate limit in microseconds of CONT time; cheap when hot. +static constexpr uint32_t ESP8266_YIELD_INTERVAL_US = 1000; +#endif + // set to 1 to enable verbose lwip logging #if 0 // NOLINT(readability-avoid-unconditional-preprocessor-if) #define LWIP_LOG(msg, ...) ESP_LOGVV(TAG, "socket %p: " msg, this, ##__VA_ARGS__) @@ -535,6 +540,14 @@ ssize_t LWIPRawImpl::read_locked_(void *buf, size_t len) { } ssize_t LWIPRawImpl::read(void *buf, size_t len) { +#ifdef USE_ESP8266 + // Would block: yield to SYS so queued WiFi RX reaches lwip and this read + // may succeed. Without this, inbound segments can sit unprocessed for + // seconds while the main loop polls (CONT/SYS are cooperative on ESP8266). + if (this->waiting_for_data_()) { + optimistic_yield(ESP8266_YIELD_INTERVAL_US); + } +#endif // See waiting_for_data_() for safety of unlocked reads. if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) { this->wait_for_data_(); @@ -545,6 +558,8 @@ ssize_t LWIPRawImpl::read(void *buf, size_t len) { } ssize_t LWIPRawImpl::readv(const struct iovec *iov, int iovcnt) { + // No ESP8266 SYS yield here: only read() needs it today. If a consumer + // switches to scatter-gather reads, mirror the yield from read(). // See waiting_for_data_() for safety of unlocked reads. if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) { this->wait_for_data_(); @@ -609,19 +624,24 @@ int LWIPRawImpl::internal_output_() { } LWIP_LOG("tcp_output(%p)", this->pcb_); err_t err = tcp_output(this->pcb_); - if (err == ERR_ABRT) { - // sometimes lwip returns ERR_ABRT for no apparent reason - // the connection works fine afterwards, and back with ESPAsyncTCP we - // indirectly also ignored this error - // FIXME: figure out where this is returned and what it means in this context - LWIP_LOG(" -> err ERR_ABRT"); - return 0; - } if (err != ERR_OK) { LWIP_LOG(" -> err %d", err); - errno = ECONNRESET; - return -1; + // ERR_ABRT: sometimes lwip returns it for no apparent reason; the + // connection works fine afterwards, and back with ESPAsyncTCP we + // indirectly also ignored this error, so treat it as success for + // flush purposes too. + // FIXME: figure out where this is returned and what it means in this context + if (err != ERR_ABRT) { + errno = ECONNRESET; + return -1; + } } +#ifdef USE_ESP8266 + // Flushed: yield to SYS so the queued segments reach the WiFi driver + // instead of waiting seconds for an unrelated SYS slot. Callers only get + // here after a successful tcp_write, so idle paths never yield. + optimistic_yield(ESP8266_YIELD_INTERVAL_US); +#endif return 0; } From 463e3833dae23329ad484c1a549dab13c2de7541 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:19:48 +1200 Subject: [PATCH 1532/1815] [light] Replace rgb_order/is_rgbw/is_wrgb with channel_colors (#18474) --- .../beken_spi_led_strip/led_strip.cpp | 75 ++------- .../beken_spi_led_strip/led_strip.h | 23 +-- .../components/beken_spi_led_strip/light.py | 41 +++-- esphome/components/const/__init__.py | 2 + .../esp32_rmt_led_strip/led_strip.cpp | 86 ++--------- .../esp32_rmt_led_strip/led_strip.h | 29 +--- .../components/esp32_rmt_led_strip/light.py | 65 ++------ esphome/components/light/__init__.py | 106 +++++++++++++ esphome/components/light/channel_colors.h | 41 +++++ esphome/components/light/types.py | 3 + .../rp2040_pio_led_strip/led_strip.cpp | 59 ++----- .../rp2040_pio_led_strip/led_strip.h | 42 +---- .../components/rp2040_pio_led_strip/light.py | 33 ++-- .../common-ard-esp32_rmt_led_strip.yaml | 2 +- .../common-idf-esp32_rmt_led_strip.yaml | 2 +- .../beken_spi_led_strip/test.bk72xx-ard.yaml | 2 +- .../validate-legacy.bk72xx-ard.yaml | 10 ++ tests/components/e131/common-ard.yaml | 2 +- tests/components/e131/common-idf.yaml | 2 +- tests/components/e131/test.rp2040-ard.yaml | 2 +- .../esp32_rmt_led_strip/common.yaml | 4 +- .../test.esp32-s3-idf.yaml | 4 +- .../validate-legacy.esp32-idf.yaml | 23 +++ tests/components/partition/common-ard.yaml | 2 +- tests/components/partition/common-idf.yaml | 2 +- .../rp2040_pio_led_strip/common.yaml | 4 +- .../validate-legacy.rp2040-ard.yaml | 18 +++ tests/components/wled/test.esp32-ard.yaml | 2 +- .../components/light/test_channel_colors.py | 144 ++++++++++++++++++ .../components/test_esp32_rmt_led_strip.py | 57 ------- 30 files changed, 454 insertions(+), 433 deletions(-) create mode 100644 esphome/components/light/channel_colors.h create mode 100644 tests/components/beken_spi_led_strip/validate-legacy.bk72xx-ard.yaml create mode 100644 tests/components/esp32_rmt_led_strip/validate-legacy.esp32-idf.yaml create mode 100644 tests/components/rp2040_pio_led_strip/validate-legacy.rp2040-ard.yaml create mode 100644 tests/unit_tests/components/light/test_channel_colors.py delete mode 100644 tests/unit_tests/components/test_esp32_rmt_led_strip.py diff --git a/esphome/components/beken_spi_led_strip/led_strip.cpp b/esphome/components/beken_spi_led_strip/led_strip.cpp index 9e14615d7a..0cf970b3cc 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.cpp +++ b/esphome/components/beken_spi_led_strip/led_strip.cpp @@ -300,46 +300,12 @@ void BekenSPILEDStripLightOutput::write_state(light::LightState *state) { } light::ESPColorView BekenSPILEDStripLightOutput::get_view_internal(int32_t index) const { - int32_t r = 0, g = 0, b = 0; - switch (this->rgb_order_) { - case ORDER_RGB: - r = 0; - g = 1; - b = 2; - break; - case ORDER_RBG: - r = 0; - g = 2; - b = 1; - break; - case ORDER_GRB: - r = 1; - g = 0; - b = 2; - break; - case ORDER_GBR: - r = 2; - g = 0; - b = 1; - break; - case ORDER_BGR: - r = 2; - g = 1; - b = 0; - break; - case ORDER_BRG: - r = 1; - g = 2; - b = 0; - break; - } - uint8_t multiplier = this->is_rgbw_ || this->is_wrgb_ ? 4 : 3; - uint8_t white = this->is_wrgb_ ? 0 : 3; - - return {this->buf_ + (index * multiplier) + r + this->is_wrgb_, - this->buf_ + (index * multiplier) + g + this->is_wrgb_, - this->buf_ + (index * multiplier) + b + this->is_wrgb_, - this->is_rgbw_ || this->is_wrgb_ ? this->buf_ + (index * multiplier) + white : nullptr, + const light::ChannelColors &colors = this->channel_colors_; + uint8_t *led = this->buf_ + (index * colors.bytes_per_led()); + return {led + colors.r, + led + colors.g, + led + colors.b, + colors.has_white() ? led + colors.w : nullptr, &this->effect_data_[index], &this->correction_}; } @@ -349,35 +315,12 @@ void BekenSPILEDStripLightOutput::dump_config() { "Beken SPI LED Strip:\n" " Pin: %u", this->pin_); - const char *rgb_order; - switch (this->rgb_order_) { - case ORDER_RGB: - rgb_order = "RGB"; - break; - case ORDER_RBG: - rgb_order = "RBG"; - break; - case ORDER_GRB: - rgb_order = "GRB"; - break; - case ORDER_GBR: - rgb_order = "GBR"; - break; - case ORDER_BGR: - rgb_order = "BGR"; - break; - case ORDER_BRG: - rgb_order = "BRG"; - break; - default: - rgb_order = "UNKNOWN"; - break; - } + char channel_colors[5]; ESP_LOGCONFIG(TAG, - " RGB Order: %s\n" + " Channel colors: %s\n" " Max refresh rate: %" PRIu32 "\n" " Number of LEDs: %u", - rgb_order, this->max_refresh_rate_.value_or(0), this->num_leds_); + this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_.value_or(0), this->num_leds_); } float BekenSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/beken_spi_led_strip/led_strip.h b/esphome/components/beken_spi_led_strip/led_strip.h index 909634e266..1496e65d4d 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.h +++ b/esphome/components/beken_spi_led_strip/led_strip.h @@ -3,6 +3,7 @@ #ifdef USE_BK72XX #include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/channel_colors.h" #include "esphome/components/light/light_output.h" #include "esphome/core/color.h" #include "esphome/core/component.h" @@ -10,15 +11,6 @@ namespace esphome::beken_spi_led_strip { -enum RGBOrder : uint8_t { - ORDER_RGB, - ORDER_RBG, - ORDER_GRB, - ORDER_GBR, - ORDER_BGR, - ORDER_BRG, -}; - class BekenSPILEDStripLightOutput final : public light::AddressableLight { public: void setup() override; @@ -28,7 +20,7 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight { int32_t size() const override { return this->num_leds_; } light::LightTraits get_traits() override { auto traits = light::LightTraits(); - if (this->is_rgbw_ || this->is_wrgb_) { + if (this->channel_colors_.has_white()) { traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}); } else { traits.set_supported_color_modes({light::ColorMode::RGB}); @@ -38,16 +30,13 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight { void set_pin(uint8_t pin) { this->pin_ = pin; } void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; } - void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; } - void set_is_wrgb(bool is_wrgb) { this->is_wrgb_ = is_wrgb; } + void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; } /// Set a maximum refresh rate in µs as some lights do not like being updated too often. void set_max_refresh_rate(uint32_t interval_us) { this->max_refresh_rate_ = interval_us; } void set_led_params(uint8_t bit0, uint8_t bit1, uint32_t spi_frequency); - void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; } - void clear_effect_data() override { for (int i = 0; i < this->size(); i++) this->effect_data_[i] = 0; @@ -58,7 +47,7 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight { protected: light::ESPColorView get_view_internal(int32_t index) const override; - size_t get_buffer_size_() const { return this->num_leds_ * (this->is_rgbw_ || this->is_wrgb_ ? 4 : 3); } + size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); } uint8_t *buf_{nullptr}; uint8_t *effect_data_{nullptr}; @@ -66,13 +55,11 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight { uint8_t pin_; uint16_t num_leds_; - bool is_rgbw_; - bool is_wrgb_; uint32_t spi_frequency_{6666666}; uint8_t bit0_{0xE0}; uint8_t bit1_{0xFC}; - RGBOrder rgb_order_; + light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE}; uint32_t last_refresh_{0}; optional max_refresh_rate_{}; diff --git a/esphome/components/beken_spi_led_strip/light.py b/esphome/components/beken_spi_led_strip/light.py index 9093b08b62..2be5842818 100644 --- a/esphome/components/beken_spi_led_strip/light.py +++ b/esphome/components/beken_spi_led_strip/light.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from esphome import pins import esphome.codegen as cg from esphome.components import libretiny, light +from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB import esphome.config_validation as cv from esphome.const import ( CONF_CHIPSET, @@ -13,6 +14,7 @@ from esphome.const import ( CONF_PIN, CONF_RGB_ORDER, ) +from esphome.types import ConfigType CODEOWNERS = ["@Mat931"] DEPENDENCIES = ["libretiny"] @@ -22,17 +24,6 @@ BekenSPILEDStripLightOutput = beken_spi_led_strip_ns.class_( "BekenSPILEDStripLightOutput", light.AddressableLight ) -RGBOrder = beken_spi_led_strip_ns.enum("RGBOrder") - -RGB_ORDERS = { - "RGB": RGBOrder.ORDER_RGB, - "RBG": RGBOrder.ORDER_RBG, - "GRB": RGBOrder.ORDER_GRB, - "GBR": RGBOrder.ORDER_GBR, - "BGR": RGBOrder.ORDER_BGR, - "BRG": RGBOrder.ORDER_BRG, -} - @dataclass class LEDStripTimings: @@ -57,8 +48,6 @@ CHIPSETS = { } -CONF_IS_WRGB = "is_wrgb" - SUPPORTED_PINS = { libretiny.const.FAMILY_BK7231N: [16], libretiny.const.FAMILY_BK7231T: [16], @@ -79,10 +68,9 @@ def _validate_pin(value): return value -def _validate_num_leds(value): - max_num_leds = 165 # 170 - if value[CONF_IS_RGBW] or value[CONF_IS_WRGB]: - max_num_leds = 123 # 127 +def _validate_num_leds(value: ConfigType) -> ConfigType: + # A white channel makes each LED one byte wider, so fewer of them fit in the DMA buffer. + max_num_leds = 123 if "W" in value[CONF_CHANNEL_COLORS] else 165 # 127 / 170 if value[CONF_NUM_LEDS] > max_num_leds: raise cv.Invalid( f"The maximum number of LEDs for this configuration is {max_num_leds}.", @@ -99,18 +87,23 @@ CONFIG_SCHEMA = cv.All( pins.internal_gpio_output_pin_number, _validate_pin ), cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int, - cv.Required(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True), + cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors, + # Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0 + cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True), + cv.Optional(CONF_IS_RGBW): cv.boolean, + cv.Optional(CONF_IS_WRGB): cv.boolean, cv.Optional(CONF_MAX_REFRESH_RATE): cv.positive_time_period_microseconds, cv.Required(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True), - cv.Optional(CONF_IS_RGBW, default=False): cv.boolean, - cv.Optional(CONF_IS_WRGB, default=False): cv.boolean, } ), + light.migrate_channel_colors( + removed_in="2027.3.0", component="beken_spi_led_strip" + ), _validate_num_leds, ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) await cg.register_component(var, config) @@ -130,6 +123,6 @@ async def to_code(config): ) ) - cg.add(var.set_rgb_order(config[CONF_RGB_ORDER])) - cg.add(var.set_is_rgbw(config[CONF_IS_RGBW])) - cg.add(var.set_is_wrgb(config[CONF_IS_WRGB])) + cg.add( + var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS])) + ) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 3ba89d2838..10710c8d29 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -10,6 +10,7 @@ CONF_ACCELEROMETER_RANGE = "accelerometer_range" CONF_B_CONSTANT = "b_constant" CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" CONF_BYTE_ORDER = "byte_order" +CONF_CHANNEL_COLORS = "channel_colors" CONF_CLIMATE_ID = "climate_id" CONF_CO2_EQUIVALENT = "co2_equivalent" CONF_COLOR_DEPTH = "color_depth" @@ -22,6 +23,7 @@ CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" CONF_IAQ = "iaq" CONF_IGNORE_NOT_FOUND = "ignore_not_found" +CONF_IS_WRGB = "is_wrgb" CONF_LABEL = "label" CONF_LIBRETINY = "libretiny" CONF_LOOP = "loop" diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.cpp b/esphome/components/esp32_rmt_led_strip/led_strip.cpp index 95391ef100..7cac1dfb41 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.cpp +++ b/esphome/components/esp32_rmt_led_strip/led_strip.cpp @@ -221,46 +221,12 @@ void ESP32RMTLEDStripLightOutput::write_state(light::LightState *state) { } light::ESPColorView ESP32RMTLEDStripLightOutput::get_view_internal(int32_t index) const { - int32_t r = 0, g = 0, b = 0; - switch (this->rgb_order_) { - case ORDER_RGB: - r = 0; - g = 1; - b = 2; - break; - case ORDER_RBG: - r = 0; - g = 2; - b = 1; - break; - case ORDER_GRB: - r = 1; - g = 0; - b = 2; - break; - case ORDER_GBR: - r = 2; - g = 0; - b = 1; - break; - case ORDER_BGR: - r = 2; - g = 1; - b = 0; - break; - case ORDER_BRG: - r = 1; - g = 2; - b = 0; - break; - } - uint8_t multiplier = this->is_rgbw_ || this->is_wrgb_ ? 4 : 3; - uint8_t white = this->is_wrgb_ ? 0 : this->white_index_; - - return {this->buf_ + (index * multiplier) + r + (white <= r), - this->buf_ + (index * multiplier) + g + (white <= g), - this->buf_ + (index * multiplier) + b + (white <= b), - this->is_rgbw_ || this->is_wrgb_ ? this->buf_ + (index * multiplier) + white : nullptr, + const light::ChannelColors &colors = this->channel_colors_; + uint8_t *led = this->buf_ + (index * colors.bytes_per_led()); + return {led + colors.r, + led + colors.g, + led + colors.b, + colors.has_white() ? led + colors.w : nullptr, &this->effect_data_[index], &this->correction_}; } @@ -271,46 +237,12 @@ void ESP32RMTLEDStripLightOutput::dump_config() { " Pin: %u", this->pin_); ESP_LOGCONFIG(TAG, " RMT Symbols: %" PRIu32, this->rmt_symbols_); - const char *rgb_order; - switch (this->rgb_order_) { - case ORDER_RGB: - rgb_order = "RGB"; - break; - case ORDER_RBG: - rgb_order = "RBG"; - break; - case ORDER_GRB: - rgb_order = "GRB"; - break; - case ORDER_GBR: - rgb_order = "GBR"; - break; - case ORDER_BGR: - rgb_order = "BGR"; - break; - case ORDER_BRG: - rgb_order = "BRG"; - break; - default: - rgb_order = "UNKNOWN"; - break; - } - if (this->is_rgbw_ || this->is_wrgb_) { - char rgbw_order[5]; - uint8_t white = this->is_wrgb_ ? 0 : this->white_index_; - uint8_t rgb_index = 0; - for (uint8_t i = 0; i < 4; i++) { - rgbw_order[i] = i == white ? 'W' : rgb_order[rgb_index++]; - } - rgbw_order[4] = '\0'; - ESP_LOGCONFIG(TAG, " RGBW Order: %s", rgbw_order); - } else { - ESP_LOGCONFIG(TAG, " RGB Order: %s", rgb_order); - } + char channel_colors[5]; ESP_LOGCONFIG(TAG, + " Channel colors: %s\n" " Max refresh rate: %" PRIu32 "\n" " Number of LEDs: %u", - this->max_refresh_rate_.value_or(0), this->num_leds_); + this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_.value_or(0), this->num_leds_); } float ESP32RMTLEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.h b/esphome/components/esp32_rmt_led_strip/led_strip.h index 3e31309bff..61aac06d76 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.h +++ b/esphome/components/esp32_rmt_led_strip/led_strip.h @@ -3,6 +3,7 @@ #ifdef USE_ESP32 #include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/channel_colors.h" #include "esphome/components/light/light_output.h" #include "esphome/core/color.h" #include "esphome/core/component.h" @@ -15,15 +16,6 @@ namespace esphome::esp32_rmt_led_strip { -enum RGBOrder : uint8_t { - ORDER_RGB, - ORDER_RBG, - ORDER_GRB, - ORDER_GBR, - ORDER_BGR, - ORDER_BRG, -}; - struct LedParams { rmt_symbol_word_t bit0; rmt_symbol_word_t bit1; @@ -39,7 +31,7 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { int32_t size() const override { return this->num_leds_; } light::LightTraits get_traits() override { auto traits = light::LightTraits(); - if (this->is_rgbw_ || this->is_wrgb_) { + if (this->channel_colors_.has_white()) { traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}); } else { traits.set_supported_color_modes({light::ColorMode::RGB}); @@ -50,13 +42,7 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { void set_pin(uint8_t pin) { this->pin_ = pin; } void set_inverted(bool inverted) { this->invert_out_ = inverted; } void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; } - void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; } - void set_is_wrgb(bool is_wrgb) { this->is_wrgb_ = is_wrgb; } - void set_rgbw_order(uint8_t white_index) { - this->is_rgbw_ = true; - this->is_wrgb_ = false; - this->white_index_ = white_index; - } + void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; } void set_use_dma(bool use_dma) { this->use_dma_ = use_dma; } void set_use_psram(bool use_psram) { this->use_psram_ = use_psram; } @@ -66,7 +52,6 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { void set_led_params(uint32_t bit0_high, uint32_t bit0_low, uint32_t bit1_high, uint32_t bit1_low, uint32_t reset_time_high, uint32_t reset_time_low); - void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; } void set_rmt_symbols(uint32_t rmt_symbols) { this->rmt_symbols_ = rmt_symbols; } void clear_effect_data() override { @@ -79,7 +64,7 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { protected: light::ESPColorView get_view_internal(int32_t index) const override; - size_t get_buffer_size_() const { return this->num_leds_ * (this->is_rgbw_ || this->is_wrgb_ ? 4 : 3); } + size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); } uint8_t *buf_{nullptr}; uint8_t *effect_data_{nullptr}; @@ -94,15 +79,11 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { uint32_t rmt_symbols_{48}; uint8_t pin_; uint16_t num_leds_; - bool is_rgbw_{false}; - bool is_wrgb_{false}; - // An index after the RGB channels makes offset adjustment a no-op for three-channel strips. - uint8_t white_index_{3}; bool use_dma_{false}; bool use_psram_{false}; bool invert_out_{false}; - RGBOrder rgb_order_{ORDER_RGB}; + light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE}; uint32_t last_refresh_{0}; optional max_refresh_rate_{}; diff --git a/esphome/components/esp32_rmt_led_strip/light.py b/esphome/components/esp32_rmt_led_strip/light.py index 2722a9b656..571b7d93b8 100644 --- a/esphome/components/esp32_rmt_led_strip/light.py +++ b/esphome/components/esp32_rmt_led_strip/light.py @@ -1,10 +1,9 @@ from dataclasses import dataclass -import logging from esphome import pins import esphome.codegen as cg from esphome.components import esp32, esp32_rmt, light -from esphome.components.const import CONF_USE_PSRAM +from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB, CONF_USE_PSRAM from esphome.components.esp32 import include_builtin_idf_component import esphome.config_validation as cv from esphome.const import ( @@ -22,8 +21,6 @@ from esphome.const import ( ) from esphome.types import ConfigType -_LOGGER = logging.getLogger(__name__) - CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["esp32"] @@ -32,17 +29,6 @@ ESP32RMTLEDStripLightOutput = esp32_rmt_led_strip_ns.class_( "ESP32RMTLEDStripLightOutput", light.AddressableLight ) -RGBOrder = esp32_rmt_led_strip_ns.enum("RGBOrder") - -RGB_ORDERS = { - "RGB": RGBOrder.ORDER_RGB, - "RBG": RGBOrder.ORDER_RBG, - "GRB": RGBOrder.ORDER_GRB, - "GBR": RGBOrder.ORDER_GBR, - "BGR": RGBOrder.ORDER_BGR, - "BRG": RGBOrder.ORDER_BRG, -} - @dataclass class LEDStripTimings: @@ -62,8 +48,6 @@ CHIPSETS = { "SM16703": LEDStripTimings(300, 900, 900, 300, 0, 0), } -CONF_IS_WRGB = "is_wrgb" -CONF_RGBW_ORDER = "rgbw_order" CONF_BIT0_HIGH = "bit0_high" CONF_BIT0_LOW = "bit0_low" CONF_BIT1_HIGH = "bit1_high" @@ -72,26 +56,6 @@ CONF_RESET_HIGH = "reset_high" CONF_RESET_LOW = "reset_low" -def _validate_rgbw_order(value: str) -> str: - value = cv.string(value).upper() - if len(value) != 4 or set(value) != set("RGBW"): - raise cv.Invalid("RGBW order must be a permutation of RGBW") - return value - - -def _split_rgbw_order(rgbw_order: str) -> tuple[str, int]: - return rgbw_order.replace("W", ""), rgbw_order.index("W") - - -def _validate_rgbw_order_exclusivity(config: ConfigType) -> ConfigType: - if CONF_RGBW_ORDER in config and (config[CONF_IS_RGBW] or config[CONF_IS_WRGB]): - raise cv.Invalid( - f"'{CONF_RGBW_ORDER}' cannot be used with '{CONF_IS_RGBW}' or " - f"'{CONF_IS_WRGB}'" - ) - return config - - CONFIG_SCHEMA = cv.All( esp32.only_on_variant( unsupported=list(esp32_rmt.VARIANTS_NO_RMT), @@ -102,8 +66,11 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(ESP32RMTLEDStripLightOutput), cv.Required(CONF_PIN): pins.internal_gpio_output_pin_schema, cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int, - cv.Optional(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True), - cv.Optional(CONF_RGBW_ORDER): _validate_rgbw_order, + cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors, + # Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0 + cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True), + cv.Optional(CONF_IS_RGBW): cv.boolean, + cv.Optional(CONF_IS_WRGB): cv.boolean, cv.SplitDefault( CONF_RMT_SYMBOLS, esp32=192, @@ -117,8 +84,6 @@ CONFIG_SCHEMA = cv.All( ): cv.int_range(min=2), cv.Optional(CONF_MAX_REFRESH_RATE): cv.positive_time_period_microseconds, cv.Optional(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True), - cv.Optional(CONF_IS_RGBW, default=False): cv.boolean, - cv.Optional(CONF_IS_WRGB, default=False): cv.boolean, cv.Optional(CONF_USE_DMA): cv.All( esp32.only_on_variant( supported=[esp32.VARIANT_ESP32P4, esp32.VARIANT_ESP32S3] @@ -153,12 +118,13 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), cv.has_exactly_one_key(CONF_CHIPSET, CONF_BIT0_HIGH), - cv.has_exactly_one_key(CONF_RGB_ORDER, CONF_RGBW_ORDER), - _validate_rgbw_order_exclusivity, + light.migrate_channel_colors( + removed_in="2027.3.0", component="esp32_rmt_led_strip" + ), ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: # Re-enable ESP-IDF's RMT driver (excluded by default to save compile time) include_builtin_idf_component("esp_driver_rmt") @@ -198,14 +164,9 @@ async def to_code(config): ) ) - if (rgbw_order := config.get(CONF_RGBW_ORDER)) is not None: - rgb_order, white_index = _split_rgbw_order(rgbw_order) - cg.add(var.set_rgb_order(RGB_ORDERS[rgb_order])) - cg.add(var.set_rgbw_order(white_index)) - else: - cg.add(var.set_rgb_order(config[CONF_RGB_ORDER])) - cg.add(var.set_is_rgbw(config[CONF_IS_RGBW])) - cg.add(var.set_is_wrgb(config[CONF_IS_WRGB])) + cg.add( + var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS])) + ) cg.add(var.set_use_psram(config[CONF_USE_PSRAM])) cg.add(var.set_rmt_symbols(config[CONF_RMT_SYMBOLS])) if CONF_USE_DMA in config: diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index b5b3d7c905..175f5b43cf 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -1,9 +1,12 @@ +from collections.abc import Callable from dataclasses import dataclass, field import enum +import logging import esphome.automation as auto import esphome.codegen as cg from esphome.components import mqtt, power_supply, web_server +from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB import esphome.config_validation as cv from esphome.const import ( CONF_BLUE, @@ -23,6 +26,7 @@ from esphome.const import ( CONF_ICON, CONF_ID, CONF_INITIAL_STATE, + CONF_IS_RGBW, CONF_MQTT_ID, CONF_NAME, CONF_ON_STATE, @@ -32,6 +36,7 @@ from esphome.const import ( CONF_POWER_SUPPLY, CONF_RED, CONF_RESTORE_MODE, + CONF_RGB_ORDER, CONF_STATE, CONF_TRIGGER_ID, CONF_WARM_WHITE, @@ -61,6 +66,7 @@ from .effects import ( from .types import ( # noqa: F401 AddressableLight, AddressableLightState, + ChannelColors, ColorMode, LightOutput, LightState, @@ -71,6 +77,8 @@ from .types import ( # noqa: F401 light_ns, ) +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -165,6 +173,104 @@ def available_effects_str(effects: list) -> str: return ", ".join(f"'{name}'" for name in available) if available else "none" +# Accepted values of the deprecated `rgb_order` key. +RGB_ORDERS = ("RGB", "RBG", "GRB", "GBR", "BGR", "BRG") + +_RGB_CHANNELS = frozenset("RGB") +_RGBW_CHANNELS = frozenset("RGBW") + + +def validate_channel_colors(value: str) -> str: + """Validate the channel order of an addressable strip, e.g. "GRB" or "WRGB".""" + value = cv.string_strict(value).upper() + channels = frozenset(value) + if len(channels) != len(value) or channels not in (_RGB_CHANNELS, _RGBW_CHANNELS): + raise cv.Invalid( + f"'{value}' is not a valid channel order. List each of R, G and B exactly " + "once, optionally with a single W, in the order the strip expects them " + "(for example GRB, GRBW or WRGB)" + ) + return value + + +def channel_colors_struct(value: str) -> cg.StructInitializer: + """Build the C++ `light::ChannelColors` for a validated channel order string.""" + return cg.StructInitializer( + ChannelColors, + ("r", value.index("R")), + ("g", value.index("G")), + ("b", value.index("B")), + ( + "w", + value.index("W") + if "W" in value + else cg.RawExpression(f"{ChannelColors}::NO_WHITE"), + ), + ) + + +def _quote_and_join(keys: list[str]) -> str: + """Quote each key and join them into a readable list, e.g. "'a', 'b' and 'c'".""" + quoted = [f"'{key}'" for key in keys] + if len(quoted) == 1: + return quoted[0] + return f"{', '.join(quoted[:-1])} and {quoted[-1]}" + + +def migrate_channel_colors( + *, removed_in: str, component: str +) -> Callable[[ConfigType], ConfigType]: + """Fold the deprecated `rgb_order`, `is_rgbw` and `is_wrgb` keys into `channel_colors`. + + This also enforces that `channel_colors` is set, which the schema cannot do on its + own while the deprecated keys are still accepted. After this runs, `to_code` only + ever sees `channel_colors`. + """ + + def validator(config: ConfigType) -> ConfigType: + config = config.copy() + deprecated = [ + key for key in (CONF_RGB_ORDER, CONF_IS_RGBW, CONF_IS_WRGB) if key in config + ] + if CONF_CHANNEL_COLORS in config: + if deprecated: + raise cv.Invalid( + f"'{CONF_CHANNEL_COLORS}' cannot be combined with " + f"{_quote_and_join(deprecated)}" + ) + return config + if CONF_RGB_ORDER not in config: + raise cv.Invalid( + f"'{CONF_CHANNEL_COLORS}' is required", path=[CONF_CHANNEL_COLORS] + ) + rgb_order = config.pop(CONF_RGB_ORDER) + is_rgbw = config.pop(CONF_IS_RGBW, False) + is_wrgb = config.pop(CONF_IS_WRGB, False) + if is_rgbw and is_wrgb: + raise cv.Invalid( + f"'{CONF_IS_RGBW}' and '{CONF_IS_WRGB}' cannot both be enabled" + ) + if is_wrgb: + channel_colors = f"W{rgb_order}" + elif is_rgbw: + channel_colors = f"{rgb_order}W" + else: + channel_colors = rgb_order + _LOGGER.warning( + "[%s] %s %s deprecated, use '%s: %s'. Will be removed in %s", + component, + _quote_and_join(deprecated), + "are" if len(deprecated) > 1 else "is", + CONF_CHANNEL_COLORS, + channel_colors, + removed_in, + ) + config[CONF_CHANNEL_COLORS] = channel_colors + return config + + return validator + + def _final_validate(config: ConfigType) -> None: """Validate all recorded effect name references against their target lights. diff --git a/esphome/components/light/channel_colors.h b/esphome/components/light/channel_colors.h new file mode 100644 index 0000000000..9d8f46d575 --- /dev/null +++ b/esphome/components/light/channel_colors.h @@ -0,0 +1,41 @@ +#pragma once + +#include + +namespace esphome::light { + +/// Which byte of an addressable LED's data carries each colour. +/// +/// Built from a configuration string such as "GRB" or "WRGB": every field holds the +/// position that colour occupies in the bytes the strip expects. `w` is NO_WHITE when +/// the strip has no separate white channel. +struct ChannelColors { + /// Value of `w` for a strip that only has red, green and blue channels. + static constexpr uint8_t NO_WHITE = 0xFF; + + uint8_t r; + uint8_t g; + uint8_t b; + uint8_t w; + + bool has_white() const { return this->w != NO_WHITE; } + + uint8_t bytes_per_led() const { return this->has_white() ? 4 : 3; } + + /// Write the order back out as text, e.g. "GRBW". + /// + /// `buf` must have room for at least 5 characters. Returns `buf` so the result can be + /// passed straight to a log call. + const char *to_string(char *buf) const { + buf[this->r] = 'R'; + buf[this->g] = 'G'; + buf[this->b] = 'B'; + if (this->has_white()) { + buf[this->w] = 'W'; + } + buf[this->bytes_per_led()] = '\0'; + return buf; + } +}; + +} // namespace esphome::light diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index 9c1c7331d1..1778aa8410 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -16,6 +16,9 @@ LightColorValues = light_ns.class_("LightColorValues") LightStateRTCState = light_ns.struct("LightStateRTCState") LightCall = light_ns.class_("LightCall") +# Addressable strips +ChannelColors = light_ns.struct("ChannelColors") + # Color modes ColorMode = light_ns.enum("ColorMode", is_class=True) COLOR_MODES = { diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.cpp b/esphome/components/rp2040_pio_led_strip/led_strip.cpp index cf7041931e..1f4bea9ecd 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.cpp +++ b/esphome/components/rp2040_pio_led_strip/led_strip.cpp @@ -107,10 +107,10 @@ void RP2040PIOLEDStripLightOutput::setup() { pio_get_dreq(this->pio_, this->sm_, true)); // set the DREQ to the state machine's TX FIFO dma_channel_configure(this->dma_chan_, &this->dma_config_, - &this->pio_->txf[this->sm_], // write to the state machine's TX FIFO - this->buf_, // read from memory - this->is_rgbw_ ? num_leds_ * 4 : num_leds_ * 3, // number of bytes to transfer - false // don't start yet + &this->pio_->txf[this->sm_], // write to the state machine's TX FIFO + this->buf_, // read from memory + this->get_buffer_size_(), // number of bytes to transfer + false // don't start yet ); // Initialize the semaphore for this DMA channel @@ -142,58 +142,25 @@ void RP2040PIOLEDStripLightOutput::write_state(light::LightState *state) { } light::ESPColorView RP2040PIOLEDStripLightOutput::get_view_internal(int32_t index) const { - int32_t r = 0, g = 0, b = 0; - switch (this->rgb_order_) { - case ORDER_RGB: - r = 0; - g = 1; - b = 2; - break; - case ORDER_RBG: - r = 0; - g = 2; - b = 1; - break; - case ORDER_GRB: - r = 1; - g = 0; - b = 2; - break; - case ORDER_GBR: - r = 2; - g = 0; - b = 1; - break; - case ORDER_BGR: - r = 2; - g = 1; - b = 0; - break; - case ORDER_BRG: - r = 1; - g = 2; - b = 0; - break; - } - uint8_t multiplier = this->is_rgbw_ ? 4 : 3; - return {this->buf_ + (index * multiplier) + r, - this->buf_ + (index * multiplier) + g, - this->buf_ + (index * multiplier) + b, - this->is_rgbw_ ? this->buf_ + (index * multiplier) + 3 : nullptr, + const light::ChannelColors &colors = this->channel_colors_; + uint8_t *led = this->buf_ + (index * colors.bytes_per_led()); + return {led + colors.r, + led + colors.g, + led + colors.b, + colors.has_white() ? led + colors.w : nullptr, &this->effect_data_[index], &this->correction_}; } void RP2040PIOLEDStripLightOutput::dump_config() { + char channel_colors[5]; ESP_LOGCONFIG(TAG, "RP2040 PIO LED Strip Light Output:\n" " Pin: GPIO%d\n" " Number of LEDs: %d\n" - " RGBW: %s\n" - " RGB Order: %s\n" + " Channel colors: %s\n" " Max Refresh Rate: %f Hz", - this->pin_, this->num_leds_, YESNO(this->is_rgbw_), rgb_order_to_string(this->rgb_order_), - this->max_refresh_rate_); + this->pin_, this->num_leds_, this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_); } float RP2040PIOLEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.h b/esphome/components/rp2040_pio_led_strip/led_strip.h index c499f0a7ca..b2162f641d 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.h +++ b/esphome/components/rp2040_pio_led_strip/led_strip.h @@ -7,6 +7,7 @@ #include "esphome/core/helpers.h" #include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/channel_colors.h" #include "esphome/components/light/light_output.h" #include @@ -18,15 +19,6 @@ namespace esphome::rp2040_pio_led_strip { -enum RGBOrder : uint8_t { - ORDER_RGB, - ORDER_RBG, - ORDER_GRB, - ORDER_GBR, - ORDER_BGR, - ORDER_BRG, -}; - enum Chipset : uint8_t { CHIPSET_WS2812, CHIPSET_WS2812B, @@ -36,25 +28,6 @@ enum Chipset : uint8_t { CHIPSET_CUSTOM = 0xFF, }; -inline const char *rgb_order_to_string(RGBOrder order) { - switch (order) { - case ORDER_RGB: - return "RGB"; - case ORDER_RBG: - return "RBG"; - case ORDER_GRB: - return "GRB"; - case ORDER_GBR: - return "GBR"; - case ORDER_BGR: - return "BGR"; - case ORDER_BRG: - return "BRG"; - default: - return "UNKNOWN"; - } -} - using init_fn = void (*)(PIO pio, uint sm, uint offset, uint pin, float freq); class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { @@ -66,13 +39,14 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { int32_t size() const override { return this->num_leds_; } light::LightTraits get_traits() override { auto traits = light::LightTraits(); - this->is_rgbw_ ? traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}) - : traits.set_supported_color_modes({light::ColorMode::RGB}); + this->channel_colors_.has_white() + ? traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}) + : traits.set_supported_color_modes({light::ColorMode::RGB}); return traits; } void set_pin(uint8_t pin) { this->pin_ = pin; } void set_num_leds(uint32_t num_leds) { this->num_leds_ = num_leds; } - void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; } + void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; } void set_max_refresh_rate(float interval_us) { this->max_refresh_rate_ = interval_us; } @@ -81,7 +55,6 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { void set_init_function(init_fn init) { this->init_ = init; } void set_chipset(Chipset chipset) { this->chipset_ = chipset; }; - void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; } void clear_effect_data() override { for (int i = 0; i < this->size(); i++) { this->effect_data_[i] = 0; @@ -93,7 +66,7 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { protected: light::ESPColorView get_view_internal(int32_t index) const override; - size_t get_buffer_size_() const { return this->num_leds_ * (3 + this->is_rgbw_); } + size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); } static void dma_write_complete_handler(); @@ -102,14 +75,13 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { uint8_t pin_; uint32_t num_leds_; - bool is_rgbw_; pio_hw_t *pio_; uint sm_; uint dma_chan_; dma_channel_config dma_config_; - RGBOrder rgb_order_{ORDER_RGB}; + light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE}; Chipset chipset_{CHIPSET_CUSTOM}; uint32_t last_refresh_{0}; diff --git a/esphome/components/rp2040_pio_led_strip/light.py b/esphome/components/rp2040_pio_led_strip/light.py index b3f816102a..9f7479edd0 100644 --- a/esphome/components/rp2040_pio_led_strip/light.py +++ b/esphome/components/rp2040_pio_led_strip/light.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from esphome import pins import esphome.codegen as cg from esphome.components import light, rp2 +from esphome.components.const import CONF_CHANNEL_COLORS import esphome.config_validation as cv from esphome.const import ( CONF_CHIPSET, @@ -13,6 +14,7 @@ from esphome.const import ( CONF_PIN, CONF_RGB_ORDER, ) +from esphome.types import ConfigType from esphome.util import _LOGGER @@ -37,7 +39,7 @@ def get_nops(timing): return nops -def generate_assembly_code(id, rgbw, t0h, t0l, t1h, t1l): +def generate_assembly_code(id, t0h, t0l, t1h, t1l): """ Generate assembly code with the given timing values. """ @@ -139,8 +141,6 @@ RP2040PIOLEDStripLightOutput = rp2040_pio_led_strip_ns.class_( "RP2040PIOLEDStripLightOutput", light.AddressableLight ) -RGBOrder = rp2040_pio_led_strip_ns.enum("RGBOrder") - Chipset = rp2040_pio_led_strip_ns.enum("Chipset") CHIPSETS = { @@ -159,15 +159,6 @@ class LEDStripTimings: T1L: int -RGB_ORDERS = { - "RGB": RGBOrder.ORDER_RGB, - "RBG": RGBOrder.ORDER_RBG, - "GRB": RGBOrder.ORDER_GRB, - "GBR": RGBOrder.ORDER_GBR, - "BGR": RGBOrder.ORDER_BGR, - "BRG": RGBOrder.ORDER_BRG, -} - CHIPSET_TIMINGS = { "WS2812": LEDStripTimings(20, 40, 46, 34), "WS2812B": LEDStripTimings(23, 49, 46, 26), @@ -199,10 +190,12 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(RP2040PIOLEDStripLightOutput), cv.Required(CONF_PIN): pins.internal_gpio_output_pin_number, cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int, - cv.Required(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True), + cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors, + # Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0 + cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True), + cv.Optional(CONF_IS_RGBW): cv.boolean, cv.Required(CONF_PIO): cv.one_of(0, 1, int=True), cv.Optional(CONF_CHIPSET): cv.enum(CHIPSETS, upper=True), - cv.Optional(CONF_IS_RGBW, default=False): cv.boolean, cv.Inclusive( CONF_BIT0_HIGH, "custom", @@ -222,10 +215,13 @@ CONFIG_SCHEMA = cv.All( } ), cv.has_exactly_one_key(CONF_CHIPSET, CONF_BIT0_HIGH), + light.migrate_channel_colors( + removed_in="2027.3.0", component="rp2040_pio_led_strip" + ), ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) id = config[CONF_ID].id await light.register_light(var, config) @@ -234,8 +230,9 @@ async def to_code(config): cg.add(var.set_num_leds(config[CONF_NUM_LEDS])) cg.add(var.set_pin(config[CONF_PIN])) - cg.add(var.set_rgb_order(config[CONF_RGB_ORDER])) - cg.add(var.set_is_rgbw(config[CONF_IS_RGBW])) + cg.add( + var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS])) + ) cg.add(var.set_pio(config[CONF_PIO])) cg.add(var.set_program(cg.RawExpression(f"&rp2040_pio_led_strip_{id}_program"))) @@ -255,7 +252,6 @@ async def to_code(config): key, generate_assembly_code( id, - config[CONF_IS_RGBW], CHIPSET_TIMINGS[chipset].T0H, CHIPSET_TIMINGS[chipset].T0L, CHIPSET_TIMINGS[chipset].T1H, @@ -270,7 +266,6 @@ async def to_code(config): key, generate_assembly_code( id, - config[CONF_IS_RGBW], time_to_cycles(config[CONF_BIT0_HIGH]), time_to_cycles(config[CONF_BIT0_LOW]), time_to_cycles(config[CONF_BIT1_HIGH]), diff --git a/tests/components/addressable_light/common-ard-esp32_rmt_led_strip.yaml b/tests/components/addressable_light/common-ard-esp32_rmt_led_strip.yaml index a071f9df91..d21c4b61b9 100644 --- a/tests/components/addressable_light/common-ard-esp32_rmt_led_strip.yaml +++ b/tests/components/addressable_light/common-ard-esp32_rmt_led_strip.yaml @@ -3,7 +3,7 @@ light: id: led_matrix_32x8 default_transition_length: 500ms chipset: ws2812 - rgb_order: GRB + channel_colors: GRB num_leds: 256 pin: ${pin} diff --git a/tests/components/addressable_light/common-idf-esp32_rmt_led_strip.yaml b/tests/components/addressable_light/common-idf-esp32_rmt_led_strip.yaml index a071f9df91..d21c4b61b9 100644 --- a/tests/components/addressable_light/common-idf-esp32_rmt_led_strip.yaml +++ b/tests/components/addressable_light/common-idf-esp32_rmt_led_strip.yaml @@ -3,7 +3,7 @@ light: id: led_matrix_32x8 default_transition_length: 500ms chipset: ws2812 - rgb_order: GRB + channel_colors: GRB num_leds: 256 pin: ${pin} diff --git a/tests/components/beken_spi_led_strip/test.bk72xx-ard.yaml b/tests/components/beken_spi_led_strip/test.bk72xx-ard.yaml index 15409caeaf..2bb831848c 100644 --- a/tests/components/beken_spi_led_strip/test.bk72xx-ard.yaml +++ b/tests/components/beken_spi_led_strip/test.bk72xx-ard.yaml @@ -1,6 +1,6 @@ light: - platform: beken_spi_led_strip - rgb_order: GRB + channel_colors: GRB pin: P16 num_leds: 30 chipset: ws2812 diff --git a/tests/components/beken_spi_led_strip/validate-legacy.bk72xx-ard.yaml b/tests/components/beken_spi_led_strip/validate-legacy.bk72xx-ard.yaml new file mode 100644 index 0000000000..3ca78398c3 --- /dev/null +++ b/tests/components/beken_spi_led_strip/validate-legacy.bk72xx-ard.yaml @@ -0,0 +1,10 @@ +# The deprecated rgb_order / is_rgbw / is_wrgb keys, kept working until 2027.3.0. +# Config-only, and only one strip because P16 is the sole supported pin. +light: + - platform: beken_spi_led_strip + name: Legacy RGBW + pin: P16 + num_leds: 30 + chipset: sk6812 + rgb_order: GRB + is_rgbw: true # -> GRBW diff --git a/tests/components/e131/common-ard.yaml b/tests/components/e131/common-ard.yaml index 8300dbb01b..48ccafc2d2 100644 --- a/tests/components/e131/common-ard.yaml +++ b/tests/components/e131/common-ard.yaml @@ -5,7 +5,7 @@ light: id: led_matrix_32x8 default_transition_length: 500ms chipset: ws2812 - rgb_order: GRB + channel_colors: GRB num_leds: 256 pin: ${pin} effects: diff --git a/tests/components/e131/common-idf.yaml b/tests/components/e131/common-idf.yaml index 8300dbb01b..48ccafc2d2 100644 --- a/tests/components/e131/common-idf.yaml +++ b/tests/components/e131/common-idf.yaml @@ -5,7 +5,7 @@ light: id: led_matrix_32x8 default_transition_length: 500ms chipset: ws2812 - rgb_order: GRB + channel_colors: GRB num_leds: 256 pin: ${pin} effects: diff --git a/tests/components/e131/test.rp2040-ard.yaml b/tests/components/e131/test.rp2040-ard.yaml index 4593784ef9..89255e2d87 100644 --- a/tests/components/e131/test.rp2040-ard.yaml +++ b/tests/components/e131/test.rp2040-ard.yaml @@ -6,7 +6,7 @@ light: pin: 2 pio: 0 num_leds: 256 - rgb_order: GRB + channel_colors: GRB chipset: WS2812 effects: - e131: diff --git a/tests/components/esp32_rmt_led_strip/common.yaml b/tests/components/esp32_rmt_led_strip/common.yaml index 701e513ebd..7f52d32229 100644 --- a/tests/components/esp32_rmt_led_strip/common.yaml +++ b/tests/components/esp32_rmt_led_strip/common.yaml @@ -3,13 +3,13 @@ light: id: led_strip1 pin: ${pin1} num_leds: 60 - rgb_order: GRB + channel_colors: GRB chipset: ws2812 - platform: esp32_rmt_led_strip id: led_strip2 pin: ${pin2} num_leds: 60 - rgbw_order: RWGB + channel_colors: RWGB bit0_high: 100us bit0_low: 100us bit1_high: 100us diff --git a/tests/components/esp32_rmt_led_strip/test.esp32-s3-idf.yaml b/tests/components/esp32_rmt_led_strip/test.esp32-s3-idf.yaml index 6bf0639a52..132966eddf 100644 --- a/tests/components/esp32_rmt_led_strip/test.esp32-s3-idf.yaml +++ b/tests/components/esp32_rmt_led_strip/test.esp32-s3-idf.yaml @@ -8,14 +8,14 @@ light: id: led_strip1 pin: ${pin1} num_leds: 60 - rgb_order: GRB + channel_colors: GRB chipset: ws2812 use_dma: "true" - platform: esp32_rmt_led_strip id: led_strip2 pin: ${pin2} num_leds: 60 - rgb_order: RGB + channel_colors: RGB bit0_high: 100us bit0_low: 100us bit1_high: 100us diff --git a/tests/components/esp32_rmt_led_strip/validate-legacy.esp32-idf.yaml b/tests/components/esp32_rmt_led_strip/validate-legacy.esp32-idf.yaml new file mode 100644 index 0000000000..6dd1bcdad3 --- /dev/null +++ b/tests/components/esp32_rmt_led_strip/validate-legacy.esp32-idf.yaml @@ -0,0 +1,23 @@ +# The deprecated rgb_order / is_rgbw / is_wrgb keys, kept working until 2027.3.0. +# Config-only: each strip below must migrate to the channel_colors shown in the comment. +light: + - platform: esp32_rmt_led_strip + id: legacy_rgb + pin: GPIO13 + num_leds: 60 + chipset: ws2812 + rgb_order: GRB # -> GRB + - platform: esp32_rmt_led_strip + id: legacy_rgbw + pin: GPIO14 + num_leds: 60 + chipset: sk6812 + rgb_order: GRB + is_rgbw: true # -> GRBW + - platform: esp32_rmt_led_strip + id: legacy_wrgb + pin: GPIO15 + num_leds: 60 + chipset: sk6812 + rgb_order: GRB + is_wrgb: true # -> WGRB diff --git a/tests/components/partition/common-ard.yaml b/tests/components/partition/common-ard.yaml index b2ceadd6f7..8d39670e32 100644 --- a/tests/components/partition/common-ard.yaml +++ b/tests/components/partition/common-ard.yaml @@ -4,7 +4,7 @@ light: default_transition_length: 500ms chipset: ws2812 num_leds: 256 - rgb_order: GRB + channel_colors: GRB pin: ${pin} - platform: partition name: Partition Light diff --git a/tests/components/partition/common-idf.yaml b/tests/components/partition/common-idf.yaml index b2ceadd6f7..8d39670e32 100644 --- a/tests/components/partition/common-idf.yaml +++ b/tests/components/partition/common-idf.yaml @@ -4,7 +4,7 @@ light: default_transition_length: 500ms chipset: ws2812 num_leds: 256 - rgb_order: GRB + channel_colors: GRB pin: ${pin} - platform: partition name: Partition Light diff --git a/tests/components/rp2040_pio_led_strip/common.yaml b/tests/components/rp2040_pio_led_strip/common.yaml index 254ac0e13d..1cb5fe0737 100644 --- a/tests/components/rp2040_pio_led_strip/common.yaml +++ b/tests/components/rp2040_pio_led_strip/common.yaml @@ -4,14 +4,14 @@ light: pin: 4 num_leds: 60 pio: 0 - rgb_order: GRB + channel_colors: GRB chipset: WS2812 - platform: rp2040_pio_led_strip id: led_strip_custom_timings pin: 5 num_leds: 60 pio: 1 - rgb_order: GRB + channel_colors: GRB bit0_high: .1us bit0_low: 1.2us bit1_high: .69us diff --git a/tests/components/rp2040_pio_led_strip/validate-legacy.rp2040-ard.yaml b/tests/components/rp2040_pio_led_strip/validate-legacy.rp2040-ard.yaml new file mode 100644 index 0000000000..2ab124393b --- /dev/null +++ b/tests/components/rp2040_pio_led_strip/validate-legacy.rp2040-ard.yaml @@ -0,0 +1,18 @@ +# The deprecated rgb_order / is_rgbw keys, kept working until 2027.3.0. +# Config-only: each strip below must migrate to the channel_colors shown in the comment. +light: + - platform: rp2040_pio_led_strip + id: legacy_rgb + pin: 4 + num_leds: 60 + pio: 0 + chipset: WS2812 + rgb_order: GRB # -> GRB + - platform: rp2040_pio_led_strip + id: legacy_rgbw + pin: 5 + num_leds: 60 + pio: 1 + chipset: SK6812 + rgb_order: GRB + is_rgbw: true # -> GRBW diff --git a/tests/components/wled/test.esp32-ard.yaml b/tests/components/wled/test.esp32-ard.yaml index 156b31181e..ecab767812 100644 --- a/tests/components/wled/test.esp32-ard.yaml +++ b/tests/components/wled/test.esp32-ard.yaml @@ -9,7 +9,7 @@ light: id: led_matrix_32x8 default_transition_length: 500ms chipset: ws2812 - rgb_order: GRB + channel_colors: GRB num_leds: 256 pin: 2 effects: diff --git a/tests/unit_tests/components/light/test_channel_colors.py b/tests/unit_tests/components/light/test_channel_colors.py new file mode 100644 index 0000000000..0c129a8bb2 --- /dev/null +++ b/tests/unit_tests/components/light/test_channel_colors.py @@ -0,0 +1,144 @@ +"""Tests for the shared addressable-strip channel order helpers.""" + +import logging + +import pytest + +from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB +from esphome.components.light import ( + channel_colors_struct, + migrate_channel_colors, + validate_channel_colors, +) +import esphome.config_validation as cv +from esphome.const import CONF_IS_RGBW, CONF_RGB_ORDER +from esphome.types import ConfigType + +NO_WHITE = "light::ChannelColors::NO_WHITE" + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("RGB", "RGB"), + ("grb", "GRB"), + ("BRG", "BRG"), + ("rgbw", "RGBW"), + ("WRGB", "WRGB"), + ("GWRB", "GWRB"), + ], +) +def test_validate_channel_colors(value: str, expected: str) -> None: + assert validate_channel_colors(value) == expected + + +@pytest.mark.parametrize( + "value", + [ + "RG", # missing a channel + "RGBB", # duplicate channel + "RRGB", # duplicate channel, correct length + "RGBWW", # two white channels + "RGBX", # unknown channel + "RGBWX", # unknown channel, correct length + "", + ], +) +def test_validate_channel_colors_rejects_invalid(value: str) -> None: + with pytest.raises(cv.Invalid, match="is not a valid channel order"): + validate_channel_colors(value) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("RGB", (0, 1, 2, NO_WHITE)), + ("GRB", (1, 0, 2, NO_WHITE)), + ("BRG", (1, 2, 0, NO_WHITE)), + ("RGBW", (0, 1, 2, 3)), + ("GRBW", (1, 0, 2, 3)), + ("WRGB", (1, 2, 3, 0)), + ("GWRB", (2, 0, 3, 1)), + ], +) +def test_channel_colors_struct(value: str, expected: tuple[int, int, int, int]) -> None: + struct = channel_colors_struct(value) + assert str(struct.base) == "light::ChannelColors" + assert tuple(str(arg) for arg in struct.args.values()) == tuple( + str(field) for field in expected + ) + + +def _migrate(config: ConfigType) -> ConfigType: + return migrate_channel_colors(removed_in="2027.3.0", component="test_strip")(config) + + +def test_migrate_passes_through_channel_colors() -> None: + config = {CONF_CHANNEL_COLORS: "GRBW"} + assert _migrate(config) == {CONF_CHANNEL_COLORS: "GRBW"} + + +@pytest.mark.parametrize( + ("deprecated", "expected", "named"), + [ + ({}, "GRB", "'rgb_order' is"), + ( + {CONF_IS_RGBW: False, CONF_IS_WRGB: False}, + "GRB", + "'rgb_order', 'is_rgbw' and 'is_wrgb' are", + ), + ({CONF_IS_RGBW: True}, "GRBW", "'rgb_order' and 'is_rgbw' are"), + ({CONF_IS_WRGB: True}, "WGRB", "'rgb_order' and 'is_wrgb' are"), + ], +) +def test_migrate_folds_deprecated_keys( + deprecated: ConfigType, + expected: str, + named: str, + caplog: pytest.LogCaptureFixture, +) -> None: + config = {CONF_RGB_ORDER: "GRB", "num_leds": 1, **deprecated} + with caplog.at_level(logging.WARNING): + result = _migrate(config) + + assert result == {CONF_CHANNEL_COLORS: expected, "num_leds": 1} + assert f"[test_strip] {named} deprecated" in caplog.text + assert f"'{CONF_CHANNEL_COLORS}: {expected}'" in caplog.text + assert "2027.3.0" in caplog.text + + +def test_migrate_does_not_mutate_input() -> None: + config = {CONF_RGB_ORDER: "GRB", CONF_IS_RGBW: True} + _migrate(config) + assert config == {CONF_RGB_ORDER: "GRB", CONF_IS_RGBW: True} + + +@pytest.mark.parametrize("deprecated", [CONF_RGB_ORDER, CONF_IS_RGBW, CONF_IS_WRGB]) +def test_migrate_rejects_mixing_old_and_new(deprecated: str) -> None: + config = {CONF_CHANNEL_COLORS: "GRBW", deprecated: "GRB"} + with pytest.raises(cv.Invalid, match=f"cannot be combined with '{deprecated}'"): + _migrate(config) + + +def test_migrate_reports_every_conflicting_key() -> None: + config = { + CONF_CHANNEL_COLORS: "GRBW", + CONF_RGB_ORDER: "GRB", + CONF_IS_RGBW: True, + CONF_IS_WRGB: False, + } + with pytest.raises( + cv.Invalid, match="cannot be combined with 'rgb_order', 'is_rgbw' and 'is_wrgb'" + ): + _migrate(config) + + +def test_migrate_requires_channel_colors() -> None: + with pytest.raises(cv.Invalid, match=f"'{CONF_CHANNEL_COLORS}' is required"): + _migrate({"num_leds": 1}) + + +def test_migrate_rejects_is_rgbw_with_is_wrgb() -> None: + config = {CONF_RGB_ORDER: "GRB", CONF_IS_RGBW: True, CONF_IS_WRGB: True} + with pytest.raises(cv.Invalid, match="cannot both be enabled"): + _migrate(config) diff --git a/tests/unit_tests/components/test_esp32_rmt_led_strip.py b/tests/unit_tests/components/test_esp32_rmt_led_strip.py deleted file mode 100644 index e2cb513e3b..0000000000 --- a/tests/unit_tests/components/test_esp32_rmt_led_strip.py +++ /dev/null @@ -1,57 +0,0 @@ -import pytest - -from esphome.components.esp32_rmt_led_strip.light import ( - CONF_IS_WRGB, - CONF_RGBW_ORDER, - _split_rgbw_order, - _validate_rgbw_order, - _validate_rgbw_order_exclusivity, -) -import esphome.config_validation as cv -from esphome.const import CONF_IS_RGBW - - -def test_validate_rgbw_order() -> None: - assert _validate_rgbw_order("rwgb") == "RWGB" - - -@pytest.mark.parametrize("rgbw_order", ["RGB", "RRGB", "RGBWW"]) -def test_validate_rgbw_order_rejects_invalid_order(rgbw_order: str) -> None: - with pytest.raises(cv.Invalid, match="permutation of RGBW"): - _validate_rgbw_order(rgbw_order) - - -@pytest.mark.parametrize( - ("rgbw_order", "expected"), - [ - ("WRGB", ("RGB", 0)), - ("RWGB", ("RGB", 1)), - ("GWRB", ("GRB", 1)), - ("RGBW", ("RGB", 3)), - ], -) -def test_split_rgbw_order(rgbw_order: str, expected: tuple[str, int]) -> None: - assert _split_rgbw_order(rgbw_order) == expected - - -@pytest.mark.parametrize("conflict", [CONF_IS_RGBW, CONF_IS_WRGB]) -def test_rgbw_order_is_mutually_exclusive(conflict: str) -> None: - with pytest.raises(cv.Invalid, match="cannot be used with"): - _validate_rgbw_order_exclusivity( - { - CONF_RGBW_ORDER: "RGBW", - CONF_IS_RGBW: conflict == CONF_IS_RGBW, - CONF_IS_WRGB: conflict == CONF_IS_WRGB, - } - ) - - -@pytest.mark.parametrize("legacy_option", [CONF_IS_RGBW, CONF_IS_WRGB]) -def test_rgbw_order_allows_disabled_legacy_options(legacy_option: str) -> None: - config = { - CONF_RGBW_ORDER: "RGBW", - CONF_IS_RGBW: False, - CONF_IS_WRGB: False, - } - config[legacy_option] = False - assert _validate_rgbw_order_exclusivity(config) is config From fffa902a1a78ac0daa6c759de0f20611e4846fbb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 20:32:03 -0500 Subject: [PATCH 1533/1815] [gpio_expander][pcf8574][pca9554][tca9555][pca6416a][pi4ioe5v6408][mcp23016][mcp23xxx_base] Reject unsupported interrupt_pin options (inverted, allow_other_uses) (#18472) --- esphome/components/gpio_expander/__init__.py | 22 +++++++ esphome/components/mcp23016/__init__.py | 4 +- esphome/components/mcp23xxx_base/__init__.py | 22 +------ esphome/components/pca6416a/__init__.py | 4 +- esphome/components/pca9554/__init__.py | 4 +- esphome/components/pcf8574/__init__.py | 4 +- esphome/components/pi4ioe5v6408/__init__.py | 4 +- esphome/components/tca9555/__init__.py | 4 +- script/build_language_schema.py | 10 +++ .../component_tests/gpio_expander/__init__.py | 0 .../gpio_expander/test_init.py | 61 +++++++++++++++++++ 11 files changed, 107 insertions(+), 32 deletions(-) create mode 100644 tests/component_tests/gpio_expander/__init__.py create mode 100644 tests/component_tests/gpio_expander/test_init.py diff --git a/esphome/components/gpio_expander/__init__.py b/esphome/components/gpio_expander/__init__.py index e69de29bb2..0c7199b6df 100644 --- a/esphome/components/gpio_expander/__init__.py +++ b/esphome/components/gpio_expander/__init__.py @@ -0,0 +1,22 @@ +from esphome import pins +import esphome.config_validation as cv +from esphome.const import CONF_ALLOW_OTHER_USES, CONF_INTERRUPT_PIN, CONF_INVERTED +from esphome.types import ConfigType + + +def validate_interrupt_pin(value: ConfigType) -> ConfigType: + # The expander components own INT polarity (active-low, hardcoded falling-edge ISR) + # and install a single ISR per GPIO, so neither inversion nor sharing is supported. + value = pins.internal_gpio_input_pin_schema(value) + if value.get(CONF_INVERTED): + raise cv.Invalid( + f"'{CONF_INVERTED}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " + "the expander INT line is fixed active-low" + ) + if value.get(CONF_ALLOW_OTHER_USES): + raise cv.Invalid( + f"'{CONF_ALLOW_OTHER_USES}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " + "sharing the interrupt pin between multiple components is not implemented. " + f"Remove the '{CONF_INTERRUPT_PIN}' to fall back to polling." + ) + return value diff --git a/esphome/components/mcp23016/__init__.py b/esphome/components/mcp23016/__init__.py index b71d57498a..37c5205fe8 100644 --- a/esphome/components/mcp23016/__init__.py +++ b/esphome/components/mcp23016/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -25,7 +25,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(MCP23016), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index 76a3aabe3f..d53499a78f 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -1,8 +1,8 @@ from esphome import pins import esphome.codegen as cg +from esphome.components import gpio_expander import esphome.config_validation as cv from esphome.const import ( - CONF_ALLOW_OTHER_USES, CONF_ID, CONF_INPUT, CONF_INTERRUPT, @@ -32,28 +32,10 @@ MCP23XXX_INTERRUPT_MODES = { } -def _validate_interrupt_pin(value): - # The MCP component owns INT polarity (active-low, hardcoded falling-edge ISR) - # and installs a single ISR per GPIO, so neither inversion nor sharing is supported. - value = pins.internal_gpio_input_pin_schema(value) - if value.get(CONF_INVERTED): - raise cv.Invalid( - f"'{CONF_INVERTED}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " - "the MCP23xxx INT line is fixed active-low" - ) - if value.get(CONF_ALLOW_OTHER_USES): - raise cv.Invalid( - f"'{CONF_ALLOW_OTHER_USES}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " - "sharing the interrupt pin between multiple MCP23xxx (or other components) " - "is not implemented. Remove the interrupt_pin to fall back to polling." - ) - return value - - MCP23XXX_CONFIG_SCHEMA = cv.Schema( { cv.Optional(CONF_OPEN_DRAIN_INTERRUPT, default=False): cv.boolean, - cv.Optional(CONF_INTERRUPT_PIN): _validate_interrupt_pin, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ).extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/pca6416a/__init__.py b/esphome/components/pca6416a/__init__.py index 813bb35c48..1df22a8ff5 100644 --- a/esphome/components/pca6416a/__init__.py +++ b/esphome/components/pca6416a/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -29,7 +29,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(PCA6416AComponent), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/pca9554/__init__.py b/esphome/components/pca9554/__init__.py index 99b812b33b..f49a68bc3f 100644 --- a/esphome/components/pca9554/__init__.py +++ b/esphome/components/pca9554/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -30,7 +30,7 @@ CONFIG_SCHEMA = ( { cv.Required(CONF_ID): cv.declare_id(PCA9554Component), cv.Optional(CONF_PIN_COUNT, default=8): cv.one_of(4, 8, 16), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/pcf8574/__init__.py b/esphome/components/pcf8574/__init__.py index d8a1e20db6..559fe1d76d 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -28,7 +28,7 @@ CONFIG_SCHEMA = ( { cv.Required(CONF_ID): cv.declare_id(PCF8574Component), cv.Optional(CONF_PCF8575, default=False): cv.boolean, - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/pi4ioe5v6408/__init__.py b/esphome/components/pi4ioe5v6408/__init__.py index d5b19dab1c..ee270138e1 100644 --- a/esphome/components/pi4ioe5v6408/__init__.py +++ b/esphome/components/pi4ioe5v6408/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -34,7 +34,7 @@ CONFIG_SCHEMA = ( { cv.Required(CONF_ID): cv.declare_id(PI4IOE5V6408Component), cv.Optional(CONF_RESET, default=True): cv.boolean, - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/tca9555/__init__.py b/esphome/components/tca9555/__init__.py index 5f571fcea6..1c643fe1c9 100644 --- a/esphome/components/tca9555/__init__.py +++ b/esphome/components/tca9555/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -28,7 +28,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(TCA9555Component), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 2b64cb0256..91c1de00cd 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -250,6 +250,16 @@ def add_pin_validators(): "modes": ["input"], } + from esphome.components import gpio_expander + + # Wraps pins.internal_gpio_input_pin_schema, so the editor schema must keep + # treating the config var as a pin + pin_validators[repr(gpio_expander.validate_interrupt_pin)] = { + "schema": True, + "internal": True, + "modes": ["input"], + } + def add_module_registries(domain, module): for attr_name in dir(module): diff --git a/tests/component_tests/gpio_expander/__init__.py b/tests/component_tests/gpio_expander/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/gpio_expander/test_init.py b/tests/component_tests/gpio_expander/test_init.py new file mode 100644 index 0000000000..806b1775d2 --- /dev/null +++ b/tests/component_tests/gpio_expander/test_init.py @@ -0,0 +1,61 @@ +"""Tests for the shared io expander interrupt_pin validator.""" + +from __future__ import annotations + +import importlib + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.gpio_expander import validate_interrupt_pin +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +@pytest.fixture +def stage_esp32(set_core_config: SetCoreConfigCallable) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + +def test_plain_pin_accepted(stage_esp32: None) -> None: + value = validate_interrupt_pin( + {"number": 16, "mode": {"input": True, "pullup": True}} + ) + assert value["number"] == 16 + + +def test_inverted_rejected(stage_esp32: None) -> None: + with pytest.raises(cv.Invalid, match="'inverted: true' is not supported"): + validate_interrupt_pin({"number": 16, "inverted": True}) + + +def test_allow_other_uses_rejected(stage_esp32: None) -> None: + with pytest.raises(cv.Invalid, match="'allow_other_uses: true' is not supported"): + validate_interrupt_pin({"number": 16, "allow_other_uses": True}) + + +# mcp23017 covers the shared mcp23xxx_base schema +@pytest.mark.parametrize( + "component", + [ + "pcf8574", + "pca9554", + "tca9555", + "pca6416a", + "pi4ioe5v6408", + "mcp23016", + "mcp23017", + ], +) +def test_component_schemas_route_through_validator( + stage_esp32: None, component: str +) -> None: + module = importlib.import_module(f"esphome.components.{component}") + with pytest.raises(cv.Invalid, match="'inverted: true' is not supported"): + module.CONFIG_SCHEMA( + {"id": "expander_hub", "interrupt_pin": {"number": 16, "inverted": True}} + ) From 096e71bd678ff5707ddbd013fe59c012e3abc8f9 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:14:14 -0500 Subject: [PATCH 1534/1815] Bump aioesphomeapi from 45.10.1 to 45.10.2 (#18357) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 85a0f55263..683008400a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.10.1 +aioesphomeapi==45.10.2 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From 3a403c40d5f7d02dcf6bfb8eca6d614471fa3b91 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 19:57:12 -0700 Subject: [PATCH 1535/1815] [ld2420] Drop the setup priority override so setup runs after the UART bus (#18428) --- esphome/components/ld2420/ld2420.cpp | 2 -- esphome/components/ld2420/ld2420.h | 1 - 2 files changed, 3 deletions(-) diff --git a/esphome/components/ld2420/ld2420.cpp b/esphome/components/ld2420/ld2420.cpp index f71bec7e5f..4aa00f8fd4 100644 --- a/esphome/components/ld2420/ld2420.cpp +++ b/esphome/components/ld2420/ld2420.cpp @@ -184,8 +184,6 @@ static int32_t get_firmware_int(const char *version_string) { return result; } -float LD2420Component::get_setup_priority() const { return setup_priority::BUS; } - void LD2420Component::dump_config() { ESP_LOGCONFIG(TAG, "LD2420:\n" diff --git a/esphome/components/ld2420/ld2420.h b/esphome/components/ld2420/ld2420.h index 977ee2eccc..e13d0271e1 100644 --- a/esphome/components/ld2420/ld2420.h +++ b/esphome/components/ld2420/ld2420.h @@ -105,7 +105,6 @@ class LD2420Component final : public Component, public uart::UARTDevice { void apply_config_action(); void factory_reset_action(); void revert_config_action(); - float get_setup_priority() const override; int send_cmd_from_array(CmdFrameT cmd_frame); void report_gate_data(); void handle_cmd_error(uint16_t error); From b9041566eaee70079271526dc3104360a78d067c Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:34:00 -0500 Subject: [PATCH 1536/1815] Bump aioesphomeapi from 45.10.2 to 45.10.3 (#18433) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 683008400a..080a437147 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.10.2 +aioesphomeapi==45.10.3 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From 014cc199021325153f0572c17f85386760e5ae09 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 12:00:39 -0700 Subject: [PATCH 1537/1815] [api] Bump noise-c to 0.1.18 (#18451) --- esphome/components/api/__init__.py | 2 +- platformio.ini | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 8ec94df1db..5ca9484336 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -497,7 +497,7 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") - cg.add_library("esphome/noise-c", "0.1.11") + cg.add_library("esphome/noise-c", "0.1.18") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index bf3b0685f8..2c22523be5 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.11 ; api + esphome/noise-c@0.1.18 ; api improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.11 ; api + esphome/noise-c@0.1.18 ; api ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.11 ; used by api + esphome/noise-c@0.1.18 ; used by api lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} From 4ce6d59484be6fb55b1bf7cd4d0bdea4785ee1d1 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:54:23 -0500 Subject: [PATCH 1538/1815] Bump bundled esphome-device-builder to 1.11.1 (#18475) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 2f23b2f690..b78d183e02 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.1 RUN \ platformio settings set enable_telemetry No \ From 1fd63372545525bfa8cc48e781fb96101b37e3f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 18:58:11 -0500 Subject: [PATCH 1539/1815] [api] Bump noise-c to 0.1.19 (#18473) --- esphome/components/api/__init__.py | 2 +- platformio.ini | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 5ca9484336..cdc0d97c49 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -497,7 +497,7 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") - cg.add_library("esphome/noise-c", "0.1.18") + cg.add_library("esphome/noise-c", "0.1.19") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index 2c22523be5..39600d622a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.18 ; api + esphome/noise-c@0.1.19 ; api improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.18 ; api + esphome/noise-c@0.1.19 ; api ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.18 ; used by api + esphome/noise-c@0.1.19 ; used by api lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} From 4dea147386d4d309e84ec3eee96e6169a224cf40 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:16:38 -0500 Subject: [PATCH 1540/1815] Bump bundled esphome-device-builder to 1.11.2 (#18477) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index b78d183e02..4a8daeaaf6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.2 RUN \ platformio settings set enable_telemetry No \ From 482869fbbe04a8ff28746f066e90ee7d57bbe81e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 20:09:29 -0500 Subject: [PATCH 1541/1815] [socket] Fix multi-second TCP stalls on ESP8266 by yielding to the SYS context (#18455) --- .../components/socket/lwip_raw_tcp_impl.cpp | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 4fcec553fa..098056d499 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -45,6 +45,11 @@ namespace esphome::socket { static const char *const TAG = "socket.lwip"; +#ifdef USE_ESP8266 +// optimistic_yield() rate limit in microseconds of CONT time; cheap when hot. +static constexpr uint32_t ESP8266_YIELD_INTERVAL_US = 1000; +#endif + // set to 1 to enable verbose lwip logging #if 0 // NOLINT(readability-avoid-unconditional-preprocessor-if) #define LWIP_LOG(msg, ...) ESP_LOGVV(TAG, "socket %p: " msg, this, ##__VA_ARGS__) @@ -535,6 +540,14 @@ ssize_t LWIPRawImpl::read_locked_(void *buf, size_t len) { } ssize_t LWIPRawImpl::read(void *buf, size_t len) { +#ifdef USE_ESP8266 + // Would block: yield to SYS so queued WiFi RX reaches lwip and this read + // may succeed. Without this, inbound segments can sit unprocessed for + // seconds while the main loop polls (CONT/SYS are cooperative on ESP8266). + if (this->waiting_for_data_()) { + optimistic_yield(ESP8266_YIELD_INTERVAL_US); + } +#endif // See waiting_for_data_() for safety of unlocked reads. if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) { this->wait_for_data_(); @@ -545,6 +558,8 @@ ssize_t LWIPRawImpl::read(void *buf, size_t len) { } ssize_t LWIPRawImpl::readv(const struct iovec *iov, int iovcnt) { + // No ESP8266 SYS yield here: only read() needs it today. If a consumer + // switches to scatter-gather reads, mirror the yield from read(). // See waiting_for_data_() for safety of unlocked reads. if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) { this->wait_for_data_(); @@ -609,19 +624,24 @@ int LWIPRawImpl::internal_output_() { } LWIP_LOG("tcp_output(%p)", this->pcb_); err_t err = tcp_output(this->pcb_); - if (err == ERR_ABRT) { - // sometimes lwip returns ERR_ABRT for no apparent reason - // the connection works fine afterwards, and back with ESPAsyncTCP we - // indirectly also ignored this error - // FIXME: figure out where this is returned and what it means in this context - LWIP_LOG(" -> err ERR_ABRT"); - return 0; - } if (err != ERR_OK) { LWIP_LOG(" -> err %d", err); - errno = ECONNRESET; - return -1; + // ERR_ABRT: sometimes lwip returns it for no apparent reason; the + // connection works fine afterwards, and back with ESPAsyncTCP we + // indirectly also ignored this error, so treat it as success for + // flush purposes too. + // FIXME: figure out where this is returned and what it means in this context + if (err != ERR_ABRT) { + errno = ECONNRESET; + return -1; + } } +#ifdef USE_ESP8266 + // Flushed: yield to SYS so the queued segments reach the WiFi driver + // instead of waiting seconds for an unrelated SYS slot. Callers only get + // here after a successful tcp_write, so idle paths never yield. + optimistic_yield(ESP8266_YIELD_INTERVAL_US); +#endif return 0; } From 6a247dfe912477e516f8da6f13e4ab002544a44e Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:19:48 +1200 Subject: [PATCH 1542/1815] [light] Replace rgb_order/is_rgbw/is_wrgb with channel_colors (#18474) --- .../beken_spi_led_strip/led_strip.cpp | 75 ++------- .../beken_spi_led_strip/led_strip.h | 23 +-- .../components/beken_spi_led_strip/light.py | 41 +++-- esphome/components/const/__init__.py | 2 + .../esp32_rmt_led_strip/led_strip.cpp | 86 ++--------- .../esp32_rmt_led_strip/led_strip.h | 29 +--- .../components/esp32_rmt_led_strip/light.py | 65 ++------ esphome/components/light/__init__.py | 108 ++++++++++++- esphome/components/light/channel_colors.h | 41 +++++ esphome/components/light/types.py | 3 + .../rp2040_pio_led_strip/led_strip.cpp | 59 ++----- .../rp2040_pio_led_strip/led_strip.h | 42 +---- .../components/rp2040_pio_led_strip/light.py | 33 ++-- .../common-ard-esp32_rmt_led_strip.yaml | 2 +- .../common-idf-esp32_rmt_led_strip.yaml | 2 +- .../beken_spi_led_strip/test.bk72xx-ard.yaml | 2 +- .../validate-legacy.bk72xx-ard.yaml | 10 ++ tests/components/e131/common-ard.yaml | 2 +- tests/components/e131/common-idf.yaml | 2 +- tests/components/e131/test.rp2040-ard.yaml | 2 +- .../esp32_rmt_led_strip/common.yaml | 4 +- .../test.esp32-s3-idf.yaml | 4 +- .../validate-legacy.esp32-idf.yaml | 23 +++ tests/components/partition/common-ard.yaml | 2 +- tests/components/partition/common-idf.yaml | 2 +- .../rp2040_pio_led_strip/common.yaml | 4 +- .../validate-legacy.rp2040-ard.yaml | 18 +++ tests/components/wled/test.esp32-ard.yaml | 2 +- .../components/light/test_channel_colors.py | 144 ++++++++++++++++++ .../components/test_esp32_rmt_led_strip.py | 57 ------- 30 files changed, 455 insertions(+), 434 deletions(-) create mode 100644 esphome/components/light/channel_colors.h create mode 100644 tests/components/beken_spi_led_strip/validate-legacy.bk72xx-ard.yaml create mode 100644 tests/components/esp32_rmt_led_strip/validate-legacy.esp32-idf.yaml create mode 100644 tests/components/rp2040_pio_led_strip/validate-legacy.rp2040-ard.yaml create mode 100644 tests/unit_tests/components/light/test_channel_colors.py delete mode 100644 tests/unit_tests/components/test_esp32_rmt_led_strip.py diff --git a/esphome/components/beken_spi_led_strip/led_strip.cpp b/esphome/components/beken_spi_led_strip/led_strip.cpp index 9e14615d7a..0cf970b3cc 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.cpp +++ b/esphome/components/beken_spi_led_strip/led_strip.cpp @@ -300,46 +300,12 @@ void BekenSPILEDStripLightOutput::write_state(light::LightState *state) { } light::ESPColorView BekenSPILEDStripLightOutput::get_view_internal(int32_t index) const { - int32_t r = 0, g = 0, b = 0; - switch (this->rgb_order_) { - case ORDER_RGB: - r = 0; - g = 1; - b = 2; - break; - case ORDER_RBG: - r = 0; - g = 2; - b = 1; - break; - case ORDER_GRB: - r = 1; - g = 0; - b = 2; - break; - case ORDER_GBR: - r = 2; - g = 0; - b = 1; - break; - case ORDER_BGR: - r = 2; - g = 1; - b = 0; - break; - case ORDER_BRG: - r = 1; - g = 2; - b = 0; - break; - } - uint8_t multiplier = this->is_rgbw_ || this->is_wrgb_ ? 4 : 3; - uint8_t white = this->is_wrgb_ ? 0 : 3; - - return {this->buf_ + (index * multiplier) + r + this->is_wrgb_, - this->buf_ + (index * multiplier) + g + this->is_wrgb_, - this->buf_ + (index * multiplier) + b + this->is_wrgb_, - this->is_rgbw_ || this->is_wrgb_ ? this->buf_ + (index * multiplier) + white : nullptr, + const light::ChannelColors &colors = this->channel_colors_; + uint8_t *led = this->buf_ + (index * colors.bytes_per_led()); + return {led + colors.r, + led + colors.g, + led + colors.b, + colors.has_white() ? led + colors.w : nullptr, &this->effect_data_[index], &this->correction_}; } @@ -349,35 +315,12 @@ void BekenSPILEDStripLightOutput::dump_config() { "Beken SPI LED Strip:\n" " Pin: %u", this->pin_); - const char *rgb_order; - switch (this->rgb_order_) { - case ORDER_RGB: - rgb_order = "RGB"; - break; - case ORDER_RBG: - rgb_order = "RBG"; - break; - case ORDER_GRB: - rgb_order = "GRB"; - break; - case ORDER_GBR: - rgb_order = "GBR"; - break; - case ORDER_BGR: - rgb_order = "BGR"; - break; - case ORDER_BRG: - rgb_order = "BRG"; - break; - default: - rgb_order = "UNKNOWN"; - break; - } + char channel_colors[5]; ESP_LOGCONFIG(TAG, - " RGB Order: %s\n" + " Channel colors: %s\n" " Max refresh rate: %" PRIu32 "\n" " Number of LEDs: %u", - rgb_order, this->max_refresh_rate_.value_or(0), this->num_leds_); + this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_.value_or(0), this->num_leds_); } float BekenSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/beken_spi_led_strip/led_strip.h b/esphome/components/beken_spi_led_strip/led_strip.h index 909634e266..1496e65d4d 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.h +++ b/esphome/components/beken_spi_led_strip/led_strip.h @@ -3,6 +3,7 @@ #ifdef USE_BK72XX #include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/channel_colors.h" #include "esphome/components/light/light_output.h" #include "esphome/core/color.h" #include "esphome/core/component.h" @@ -10,15 +11,6 @@ namespace esphome::beken_spi_led_strip { -enum RGBOrder : uint8_t { - ORDER_RGB, - ORDER_RBG, - ORDER_GRB, - ORDER_GBR, - ORDER_BGR, - ORDER_BRG, -}; - class BekenSPILEDStripLightOutput final : public light::AddressableLight { public: void setup() override; @@ -28,7 +20,7 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight { int32_t size() const override { return this->num_leds_; } light::LightTraits get_traits() override { auto traits = light::LightTraits(); - if (this->is_rgbw_ || this->is_wrgb_) { + if (this->channel_colors_.has_white()) { traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}); } else { traits.set_supported_color_modes({light::ColorMode::RGB}); @@ -38,16 +30,13 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight { void set_pin(uint8_t pin) { this->pin_ = pin; } void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; } - void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; } - void set_is_wrgb(bool is_wrgb) { this->is_wrgb_ = is_wrgb; } + void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; } /// Set a maximum refresh rate in µs as some lights do not like being updated too often. void set_max_refresh_rate(uint32_t interval_us) { this->max_refresh_rate_ = interval_us; } void set_led_params(uint8_t bit0, uint8_t bit1, uint32_t spi_frequency); - void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; } - void clear_effect_data() override { for (int i = 0; i < this->size(); i++) this->effect_data_[i] = 0; @@ -58,7 +47,7 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight { protected: light::ESPColorView get_view_internal(int32_t index) const override; - size_t get_buffer_size_() const { return this->num_leds_ * (this->is_rgbw_ || this->is_wrgb_ ? 4 : 3); } + size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); } uint8_t *buf_{nullptr}; uint8_t *effect_data_{nullptr}; @@ -66,13 +55,11 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight { uint8_t pin_; uint16_t num_leds_; - bool is_rgbw_; - bool is_wrgb_; uint32_t spi_frequency_{6666666}; uint8_t bit0_{0xE0}; uint8_t bit1_{0xFC}; - RGBOrder rgb_order_; + light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE}; uint32_t last_refresh_{0}; optional max_refresh_rate_{}; diff --git a/esphome/components/beken_spi_led_strip/light.py b/esphome/components/beken_spi_led_strip/light.py index 9093b08b62..2be5842818 100644 --- a/esphome/components/beken_spi_led_strip/light.py +++ b/esphome/components/beken_spi_led_strip/light.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from esphome import pins import esphome.codegen as cg from esphome.components import libretiny, light +from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB import esphome.config_validation as cv from esphome.const import ( CONF_CHIPSET, @@ -13,6 +14,7 @@ from esphome.const import ( CONF_PIN, CONF_RGB_ORDER, ) +from esphome.types import ConfigType CODEOWNERS = ["@Mat931"] DEPENDENCIES = ["libretiny"] @@ -22,17 +24,6 @@ BekenSPILEDStripLightOutput = beken_spi_led_strip_ns.class_( "BekenSPILEDStripLightOutput", light.AddressableLight ) -RGBOrder = beken_spi_led_strip_ns.enum("RGBOrder") - -RGB_ORDERS = { - "RGB": RGBOrder.ORDER_RGB, - "RBG": RGBOrder.ORDER_RBG, - "GRB": RGBOrder.ORDER_GRB, - "GBR": RGBOrder.ORDER_GBR, - "BGR": RGBOrder.ORDER_BGR, - "BRG": RGBOrder.ORDER_BRG, -} - @dataclass class LEDStripTimings: @@ -57,8 +48,6 @@ CHIPSETS = { } -CONF_IS_WRGB = "is_wrgb" - SUPPORTED_PINS = { libretiny.const.FAMILY_BK7231N: [16], libretiny.const.FAMILY_BK7231T: [16], @@ -79,10 +68,9 @@ def _validate_pin(value): return value -def _validate_num_leds(value): - max_num_leds = 165 # 170 - if value[CONF_IS_RGBW] or value[CONF_IS_WRGB]: - max_num_leds = 123 # 127 +def _validate_num_leds(value: ConfigType) -> ConfigType: + # A white channel makes each LED one byte wider, so fewer of them fit in the DMA buffer. + max_num_leds = 123 if "W" in value[CONF_CHANNEL_COLORS] else 165 # 127 / 170 if value[CONF_NUM_LEDS] > max_num_leds: raise cv.Invalid( f"The maximum number of LEDs for this configuration is {max_num_leds}.", @@ -99,18 +87,23 @@ CONFIG_SCHEMA = cv.All( pins.internal_gpio_output_pin_number, _validate_pin ), cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int, - cv.Required(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True), + cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors, + # Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0 + cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True), + cv.Optional(CONF_IS_RGBW): cv.boolean, + cv.Optional(CONF_IS_WRGB): cv.boolean, cv.Optional(CONF_MAX_REFRESH_RATE): cv.positive_time_period_microseconds, cv.Required(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True), - cv.Optional(CONF_IS_RGBW, default=False): cv.boolean, - cv.Optional(CONF_IS_WRGB, default=False): cv.boolean, } ), + light.migrate_channel_colors( + removed_in="2027.3.0", component="beken_spi_led_strip" + ), _validate_num_leds, ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) await cg.register_component(var, config) @@ -130,6 +123,6 @@ async def to_code(config): ) ) - cg.add(var.set_rgb_order(config[CONF_RGB_ORDER])) - cg.add(var.set_is_rgbw(config[CONF_IS_RGBW])) - cg.add(var.set_is_wrgb(config[CONF_IS_WRGB])) + cg.add( + var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS])) + ) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 44878274d6..956a5490e3 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -10,6 +10,7 @@ CONF_ACCELEROMETER_RANGE = "accelerometer_range" CONF_B_CONSTANT = "b_constant" CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" CONF_BYTE_ORDER = "byte_order" +CONF_CHANNEL_COLORS = "channel_colors" CONF_CLIMATE_ID = "climate_id" CONF_CO2_EQUIVALENT = "co2_equivalent" CONF_COLOR_DEPTH = "color_depth" @@ -22,6 +23,7 @@ CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" CONF_IAQ = "iaq" CONF_IGNORE_NOT_FOUND = "ignore_not_found" +CONF_IS_WRGB = "is_wrgb" CONF_LIBRETINY = "libretiny" CONF_LOOP = "loop" CONF_NOX_INDEX = "nox_index" diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.cpp b/esphome/components/esp32_rmt_led_strip/led_strip.cpp index 95391ef100..7cac1dfb41 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.cpp +++ b/esphome/components/esp32_rmt_led_strip/led_strip.cpp @@ -221,46 +221,12 @@ void ESP32RMTLEDStripLightOutput::write_state(light::LightState *state) { } light::ESPColorView ESP32RMTLEDStripLightOutput::get_view_internal(int32_t index) const { - int32_t r = 0, g = 0, b = 0; - switch (this->rgb_order_) { - case ORDER_RGB: - r = 0; - g = 1; - b = 2; - break; - case ORDER_RBG: - r = 0; - g = 2; - b = 1; - break; - case ORDER_GRB: - r = 1; - g = 0; - b = 2; - break; - case ORDER_GBR: - r = 2; - g = 0; - b = 1; - break; - case ORDER_BGR: - r = 2; - g = 1; - b = 0; - break; - case ORDER_BRG: - r = 1; - g = 2; - b = 0; - break; - } - uint8_t multiplier = this->is_rgbw_ || this->is_wrgb_ ? 4 : 3; - uint8_t white = this->is_wrgb_ ? 0 : this->white_index_; - - return {this->buf_ + (index * multiplier) + r + (white <= r), - this->buf_ + (index * multiplier) + g + (white <= g), - this->buf_ + (index * multiplier) + b + (white <= b), - this->is_rgbw_ || this->is_wrgb_ ? this->buf_ + (index * multiplier) + white : nullptr, + const light::ChannelColors &colors = this->channel_colors_; + uint8_t *led = this->buf_ + (index * colors.bytes_per_led()); + return {led + colors.r, + led + colors.g, + led + colors.b, + colors.has_white() ? led + colors.w : nullptr, &this->effect_data_[index], &this->correction_}; } @@ -271,46 +237,12 @@ void ESP32RMTLEDStripLightOutput::dump_config() { " Pin: %u", this->pin_); ESP_LOGCONFIG(TAG, " RMT Symbols: %" PRIu32, this->rmt_symbols_); - const char *rgb_order; - switch (this->rgb_order_) { - case ORDER_RGB: - rgb_order = "RGB"; - break; - case ORDER_RBG: - rgb_order = "RBG"; - break; - case ORDER_GRB: - rgb_order = "GRB"; - break; - case ORDER_GBR: - rgb_order = "GBR"; - break; - case ORDER_BGR: - rgb_order = "BGR"; - break; - case ORDER_BRG: - rgb_order = "BRG"; - break; - default: - rgb_order = "UNKNOWN"; - break; - } - if (this->is_rgbw_ || this->is_wrgb_) { - char rgbw_order[5]; - uint8_t white = this->is_wrgb_ ? 0 : this->white_index_; - uint8_t rgb_index = 0; - for (uint8_t i = 0; i < 4; i++) { - rgbw_order[i] = i == white ? 'W' : rgb_order[rgb_index++]; - } - rgbw_order[4] = '\0'; - ESP_LOGCONFIG(TAG, " RGBW Order: %s", rgbw_order); - } else { - ESP_LOGCONFIG(TAG, " RGB Order: %s", rgb_order); - } + char channel_colors[5]; ESP_LOGCONFIG(TAG, + " Channel colors: %s\n" " Max refresh rate: %" PRIu32 "\n" " Number of LEDs: %u", - this->max_refresh_rate_.value_or(0), this->num_leds_); + this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_.value_or(0), this->num_leds_); } float ESP32RMTLEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.h b/esphome/components/esp32_rmt_led_strip/led_strip.h index 3e31309bff..61aac06d76 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.h +++ b/esphome/components/esp32_rmt_led_strip/led_strip.h @@ -3,6 +3,7 @@ #ifdef USE_ESP32 #include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/channel_colors.h" #include "esphome/components/light/light_output.h" #include "esphome/core/color.h" #include "esphome/core/component.h" @@ -15,15 +16,6 @@ namespace esphome::esp32_rmt_led_strip { -enum RGBOrder : uint8_t { - ORDER_RGB, - ORDER_RBG, - ORDER_GRB, - ORDER_GBR, - ORDER_BGR, - ORDER_BRG, -}; - struct LedParams { rmt_symbol_word_t bit0; rmt_symbol_word_t bit1; @@ -39,7 +31,7 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { int32_t size() const override { return this->num_leds_; } light::LightTraits get_traits() override { auto traits = light::LightTraits(); - if (this->is_rgbw_ || this->is_wrgb_) { + if (this->channel_colors_.has_white()) { traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}); } else { traits.set_supported_color_modes({light::ColorMode::RGB}); @@ -50,13 +42,7 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { void set_pin(uint8_t pin) { this->pin_ = pin; } void set_inverted(bool inverted) { this->invert_out_ = inverted; } void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; } - void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; } - void set_is_wrgb(bool is_wrgb) { this->is_wrgb_ = is_wrgb; } - void set_rgbw_order(uint8_t white_index) { - this->is_rgbw_ = true; - this->is_wrgb_ = false; - this->white_index_ = white_index; - } + void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; } void set_use_dma(bool use_dma) { this->use_dma_ = use_dma; } void set_use_psram(bool use_psram) { this->use_psram_ = use_psram; } @@ -66,7 +52,6 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { void set_led_params(uint32_t bit0_high, uint32_t bit0_low, uint32_t bit1_high, uint32_t bit1_low, uint32_t reset_time_high, uint32_t reset_time_low); - void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; } void set_rmt_symbols(uint32_t rmt_symbols) { this->rmt_symbols_ = rmt_symbols; } void clear_effect_data() override { @@ -79,7 +64,7 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { protected: light::ESPColorView get_view_internal(int32_t index) const override; - size_t get_buffer_size_() const { return this->num_leds_ * (this->is_rgbw_ || this->is_wrgb_ ? 4 : 3); } + size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); } uint8_t *buf_{nullptr}; uint8_t *effect_data_{nullptr}; @@ -94,15 +79,11 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { uint32_t rmt_symbols_{48}; uint8_t pin_; uint16_t num_leds_; - bool is_rgbw_{false}; - bool is_wrgb_{false}; - // An index after the RGB channels makes offset adjustment a no-op for three-channel strips. - uint8_t white_index_{3}; bool use_dma_{false}; bool use_psram_{false}; bool invert_out_{false}; - RGBOrder rgb_order_{ORDER_RGB}; + light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE}; uint32_t last_refresh_{0}; optional max_refresh_rate_{}; diff --git a/esphome/components/esp32_rmt_led_strip/light.py b/esphome/components/esp32_rmt_led_strip/light.py index 2722a9b656..571b7d93b8 100644 --- a/esphome/components/esp32_rmt_led_strip/light.py +++ b/esphome/components/esp32_rmt_led_strip/light.py @@ -1,10 +1,9 @@ from dataclasses import dataclass -import logging from esphome import pins import esphome.codegen as cg from esphome.components import esp32, esp32_rmt, light -from esphome.components.const import CONF_USE_PSRAM +from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB, CONF_USE_PSRAM from esphome.components.esp32 import include_builtin_idf_component import esphome.config_validation as cv from esphome.const import ( @@ -22,8 +21,6 @@ from esphome.const import ( ) from esphome.types import ConfigType -_LOGGER = logging.getLogger(__name__) - CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["esp32"] @@ -32,17 +29,6 @@ ESP32RMTLEDStripLightOutput = esp32_rmt_led_strip_ns.class_( "ESP32RMTLEDStripLightOutput", light.AddressableLight ) -RGBOrder = esp32_rmt_led_strip_ns.enum("RGBOrder") - -RGB_ORDERS = { - "RGB": RGBOrder.ORDER_RGB, - "RBG": RGBOrder.ORDER_RBG, - "GRB": RGBOrder.ORDER_GRB, - "GBR": RGBOrder.ORDER_GBR, - "BGR": RGBOrder.ORDER_BGR, - "BRG": RGBOrder.ORDER_BRG, -} - @dataclass class LEDStripTimings: @@ -62,8 +48,6 @@ CHIPSETS = { "SM16703": LEDStripTimings(300, 900, 900, 300, 0, 0), } -CONF_IS_WRGB = "is_wrgb" -CONF_RGBW_ORDER = "rgbw_order" CONF_BIT0_HIGH = "bit0_high" CONF_BIT0_LOW = "bit0_low" CONF_BIT1_HIGH = "bit1_high" @@ -72,26 +56,6 @@ CONF_RESET_HIGH = "reset_high" CONF_RESET_LOW = "reset_low" -def _validate_rgbw_order(value: str) -> str: - value = cv.string(value).upper() - if len(value) != 4 or set(value) != set("RGBW"): - raise cv.Invalid("RGBW order must be a permutation of RGBW") - return value - - -def _split_rgbw_order(rgbw_order: str) -> tuple[str, int]: - return rgbw_order.replace("W", ""), rgbw_order.index("W") - - -def _validate_rgbw_order_exclusivity(config: ConfigType) -> ConfigType: - if CONF_RGBW_ORDER in config and (config[CONF_IS_RGBW] or config[CONF_IS_WRGB]): - raise cv.Invalid( - f"'{CONF_RGBW_ORDER}' cannot be used with '{CONF_IS_RGBW}' or " - f"'{CONF_IS_WRGB}'" - ) - return config - - CONFIG_SCHEMA = cv.All( esp32.only_on_variant( unsupported=list(esp32_rmt.VARIANTS_NO_RMT), @@ -102,8 +66,11 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(ESP32RMTLEDStripLightOutput), cv.Required(CONF_PIN): pins.internal_gpio_output_pin_schema, cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int, - cv.Optional(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True), - cv.Optional(CONF_RGBW_ORDER): _validate_rgbw_order, + cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors, + # Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0 + cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True), + cv.Optional(CONF_IS_RGBW): cv.boolean, + cv.Optional(CONF_IS_WRGB): cv.boolean, cv.SplitDefault( CONF_RMT_SYMBOLS, esp32=192, @@ -117,8 +84,6 @@ CONFIG_SCHEMA = cv.All( ): cv.int_range(min=2), cv.Optional(CONF_MAX_REFRESH_RATE): cv.positive_time_period_microseconds, cv.Optional(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True), - cv.Optional(CONF_IS_RGBW, default=False): cv.boolean, - cv.Optional(CONF_IS_WRGB, default=False): cv.boolean, cv.Optional(CONF_USE_DMA): cv.All( esp32.only_on_variant( supported=[esp32.VARIANT_ESP32P4, esp32.VARIANT_ESP32S3] @@ -153,12 +118,13 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), cv.has_exactly_one_key(CONF_CHIPSET, CONF_BIT0_HIGH), - cv.has_exactly_one_key(CONF_RGB_ORDER, CONF_RGBW_ORDER), - _validate_rgbw_order_exclusivity, + light.migrate_channel_colors( + removed_in="2027.3.0", component="esp32_rmt_led_strip" + ), ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: # Re-enable ESP-IDF's RMT driver (excluded by default to save compile time) include_builtin_idf_component("esp_driver_rmt") @@ -198,14 +164,9 @@ async def to_code(config): ) ) - if (rgbw_order := config.get(CONF_RGBW_ORDER)) is not None: - rgb_order, white_index = _split_rgbw_order(rgbw_order) - cg.add(var.set_rgb_order(RGB_ORDERS[rgb_order])) - cg.add(var.set_rgbw_order(white_index)) - else: - cg.add(var.set_rgb_order(config[CONF_RGB_ORDER])) - cg.add(var.set_is_rgbw(config[CONF_IS_RGBW])) - cg.add(var.set_is_wrgb(config[CONF_IS_WRGB])) + cg.add( + var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS])) + ) cg.add(var.set_use_psram(config[CONF_USE_PSRAM])) cg.add(var.set_rmt_symbols(config[CONF_RMT_SYMBOLS])) if CONF_USE_DMA in config: diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 7c4d7ed431..f3a859e38c 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -1,9 +1,12 @@ +from collections.abc import Callable from dataclasses import dataclass, field import enum +import logging import esphome.automation as auto import esphome.codegen as cg from esphome.components import mqtt, power_supply, web_server +from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB import esphome.config_validation as cv from esphome.const import ( CONF_BLUE, @@ -23,6 +26,7 @@ from esphome.const import ( CONF_ICON, CONF_ID, CONF_INITIAL_STATE, + CONF_IS_RGBW, CONF_MQTT_ID, CONF_NAME, CONF_ON_STATE, @@ -32,6 +36,7 @@ from esphome.const import ( CONF_POWER_SUPPLY, CONF_RED, CONF_RESTORE_MODE, + CONF_RGB_ORDER, CONF_STATE, CONF_TRIGGER_ID, CONF_WARM_WHITE, @@ -61,6 +66,7 @@ from .effects import ( from .types import ( # noqa: F401 AddressableLight, AddressableLightState, + ChannelColors, ColorMode, LightOutput, LightState, @@ -71,6 +77,8 @@ from .types import ( # noqa: F401 light_ns, ) +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -165,7 +173,105 @@ def available_effects_str(effects: list) -> str: return ", ".join(f"'{name}'" for name in available) if available else "none" -def _final_validate(config: ConfigType) -> ConfigType: +# Accepted values of the deprecated `rgb_order` key. +RGB_ORDERS = ("RGB", "RBG", "GRB", "GBR", "BGR", "BRG") + +_RGB_CHANNELS = frozenset("RGB") +_RGBW_CHANNELS = frozenset("RGBW") + + +def validate_channel_colors(value: str) -> str: + """Validate the channel order of an addressable strip, e.g. "GRB" or "WRGB".""" + value = cv.string_strict(value).upper() + channels = frozenset(value) + if len(channels) != len(value) or channels not in (_RGB_CHANNELS, _RGBW_CHANNELS): + raise cv.Invalid( + f"'{value}' is not a valid channel order. List each of R, G and B exactly " + "once, optionally with a single W, in the order the strip expects them " + "(for example GRB, GRBW or WRGB)" + ) + return value + + +def channel_colors_struct(value: str) -> cg.StructInitializer: + """Build the C++ `light::ChannelColors` for a validated channel order string.""" + return cg.StructInitializer( + ChannelColors, + ("r", value.index("R")), + ("g", value.index("G")), + ("b", value.index("B")), + ( + "w", + value.index("W") + if "W" in value + else cg.RawExpression(f"{ChannelColors}::NO_WHITE"), + ), + ) + + +def _quote_and_join(keys: list[str]) -> str: + """Quote each key and join them into a readable list, e.g. "'a', 'b' and 'c'".""" + quoted = [f"'{key}'" for key in keys] + if len(quoted) == 1: + return quoted[0] + return f"{', '.join(quoted[:-1])} and {quoted[-1]}" + + +def migrate_channel_colors( + *, removed_in: str, component: str +) -> Callable[[ConfigType], ConfigType]: + """Fold the deprecated `rgb_order`, `is_rgbw` and `is_wrgb` keys into `channel_colors`. + + This also enforces that `channel_colors` is set, which the schema cannot do on its + own while the deprecated keys are still accepted. After this runs, `to_code` only + ever sees `channel_colors`. + """ + + def validator(config: ConfigType) -> ConfigType: + config = config.copy() + deprecated = [ + key for key in (CONF_RGB_ORDER, CONF_IS_RGBW, CONF_IS_WRGB) if key in config + ] + if CONF_CHANNEL_COLORS in config: + if deprecated: + raise cv.Invalid( + f"'{CONF_CHANNEL_COLORS}' cannot be combined with " + f"{_quote_and_join(deprecated)}" + ) + return config + if CONF_RGB_ORDER not in config: + raise cv.Invalid( + f"'{CONF_CHANNEL_COLORS}' is required", path=[CONF_CHANNEL_COLORS] + ) + rgb_order = config.pop(CONF_RGB_ORDER) + is_rgbw = config.pop(CONF_IS_RGBW, False) + is_wrgb = config.pop(CONF_IS_WRGB, False) + if is_rgbw and is_wrgb: + raise cv.Invalid( + f"'{CONF_IS_RGBW}' and '{CONF_IS_WRGB}' cannot both be enabled" + ) + if is_wrgb: + channel_colors = f"W{rgb_order}" + elif is_rgbw: + channel_colors = f"{rgb_order}W" + else: + channel_colors = rgb_order + _LOGGER.warning( + "[%s] %s %s deprecated, use '%s: %s'. Will be removed in %s", + component, + _quote_and_join(deprecated), + "are" if len(deprecated) > 1 else "is", + CONF_CHANNEL_COLORS, + channel_colors, + removed_in, + ) + config[CONF_CHANNEL_COLORS] = channel_colors + return config + + return validator + + +def _final_validate(config: ConfigType) -> None: """Validate all recorded effect name references against their target lights. This runs once per light platform instance. If no light platform is configured, diff --git a/esphome/components/light/channel_colors.h b/esphome/components/light/channel_colors.h new file mode 100644 index 0000000000..9d8f46d575 --- /dev/null +++ b/esphome/components/light/channel_colors.h @@ -0,0 +1,41 @@ +#pragma once + +#include + +namespace esphome::light { + +/// Which byte of an addressable LED's data carries each colour. +/// +/// Built from a configuration string such as "GRB" or "WRGB": every field holds the +/// position that colour occupies in the bytes the strip expects. `w` is NO_WHITE when +/// the strip has no separate white channel. +struct ChannelColors { + /// Value of `w` for a strip that only has red, green and blue channels. + static constexpr uint8_t NO_WHITE = 0xFF; + + uint8_t r; + uint8_t g; + uint8_t b; + uint8_t w; + + bool has_white() const { return this->w != NO_WHITE; } + + uint8_t bytes_per_led() const { return this->has_white() ? 4 : 3; } + + /// Write the order back out as text, e.g. "GRBW". + /// + /// `buf` must have room for at least 5 characters. Returns `buf` so the result can be + /// passed straight to a log call. + const char *to_string(char *buf) const { + buf[this->r] = 'R'; + buf[this->g] = 'G'; + buf[this->b] = 'B'; + if (this->has_white()) { + buf[this->w] = 'W'; + } + buf[this->bytes_per_led()] = '\0'; + return buf; + } +}; + +} // namespace esphome::light diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index 9c1c7331d1..1778aa8410 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -16,6 +16,9 @@ LightColorValues = light_ns.class_("LightColorValues") LightStateRTCState = light_ns.struct("LightStateRTCState") LightCall = light_ns.class_("LightCall") +# Addressable strips +ChannelColors = light_ns.struct("ChannelColors") + # Color modes ColorMode = light_ns.enum("ColorMode", is_class=True) COLOR_MODES = { diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.cpp b/esphome/components/rp2040_pio_led_strip/led_strip.cpp index cf7041931e..1f4bea9ecd 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.cpp +++ b/esphome/components/rp2040_pio_led_strip/led_strip.cpp @@ -107,10 +107,10 @@ void RP2040PIOLEDStripLightOutput::setup() { pio_get_dreq(this->pio_, this->sm_, true)); // set the DREQ to the state machine's TX FIFO dma_channel_configure(this->dma_chan_, &this->dma_config_, - &this->pio_->txf[this->sm_], // write to the state machine's TX FIFO - this->buf_, // read from memory - this->is_rgbw_ ? num_leds_ * 4 : num_leds_ * 3, // number of bytes to transfer - false // don't start yet + &this->pio_->txf[this->sm_], // write to the state machine's TX FIFO + this->buf_, // read from memory + this->get_buffer_size_(), // number of bytes to transfer + false // don't start yet ); // Initialize the semaphore for this DMA channel @@ -142,58 +142,25 @@ void RP2040PIOLEDStripLightOutput::write_state(light::LightState *state) { } light::ESPColorView RP2040PIOLEDStripLightOutput::get_view_internal(int32_t index) const { - int32_t r = 0, g = 0, b = 0; - switch (this->rgb_order_) { - case ORDER_RGB: - r = 0; - g = 1; - b = 2; - break; - case ORDER_RBG: - r = 0; - g = 2; - b = 1; - break; - case ORDER_GRB: - r = 1; - g = 0; - b = 2; - break; - case ORDER_GBR: - r = 2; - g = 0; - b = 1; - break; - case ORDER_BGR: - r = 2; - g = 1; - b = 0; - break; - case ORDER_BRG: - r = 1; - g = 2; - b = 0; - break; - } - uint8_t multiplier = this->is_rgbw_ ? 4 : 3; - return {this->buf_ + (index * multiplier) + r, - this->buf_ + (index * multiplier) + g, - this->buf_ + (index * multiplier) + b, - this->is_rgbw_ ? this->buf_ + (index * multiplier) + 3 : nullptr, + const light::ChannelColors &colors = this->channel_colors_; + uint8_t *led = this->buf_ + (index * colors.bytes_per_led()); + return {led + colors.r, + led + colors.g, + led + colors.b, + colors.has_white() ? led + colors.w : nullptr, &this->effect_data_[index], &this->correction_}; } void RP2040PIOLEDStripLightOutput::dump_config() { + char channel_colors[5]; ESP_LOGCONFIG(TAG, "RP2040 PIO LED Strip Light Output:\n" " Pin: GPIO%d\n" " Number of LEDs: %d\n" - " RGBW: %s\n" - " RGB Order: %s\n" + " Channel colors: %s\n" " Max Refresh Rate: %f Hz", - this->pin_, this->num_leds_, YESNO(this->is_rgbw_), rgb_order_to_string(this->rgb_order_), - this->max_refresh_rate_); + this->pin_, this->num_leds_, this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_); } float RP2040PIOLEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.h b/esphome/components/rp2040_pio_led_strip/led_strip.h index c499f0a7ca..b2162f641d 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.h +++ b/esphome/components/rp2040_pio_led_strip/led_strip.h @@ -7,6 +7,7 @@ #include "esphome/core/helpers.h" #include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/channel_colors.h" #include "esphome/components/light/light_output.h" #include @@ -18,15 +19,6 @@ namespace esphome::rp2040_pio_led_strip { -enum RGBOrder : uint8_t { - ORDER_RGB, - ORDER_RBG, - ORDER_GRB, - ORDER_GBR, - ORDER_BGR, - ORDER_BRG, -}; - enum Chipset : uint8_t { CHIPSET_WS2812, CHIPSET_WS2812B, @@ -36,25 +28,6 @@ enum Chipset : uint8_t { CHIPSET_CUSTOM = 0xFF, }; -inline const char *rgb_order_to_string(RGBOrder order) { - switch (order) { - case ORDER_RGB: - return "RGB"; - case ORDER_RBG: - return "RBG"; - case ORDER_GRB: - return "GRB"; - case ORDER_GBR: - return "GBR"; - case ORDER_BGR: - return "BGR"; - case ORDER_BRG: - return "BRG"; - default: - return "UNKNOWN"; - } -} - using init_fn = void (*)(PIO pio, uint sm, uint offset, uint pin, float freq); class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { @@ -66,13 +39,14 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { int32_t size() const override { return this->num_leds_; } light::LightTraits get_traits() override { auto traits = light::LightTraits(); - this->is_rgbw_ ? traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}) - : traits.set_supported_color_modes({light::ColorMode::RGB}); + this->channel_colors_.has_white() + ? traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}) + : traits.set_supported_color_modes({light::ColorMode::RGB}); return traits; } void set_pin(uint8_t pin) { this->pin_ = pin; } void set_num_leds(uint32_t num_leds) { this->num_leds_ = num_leds; } - void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; } + void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; } void set_max_refresh_rate(float interval_us) { this->max_refresh_rate_ = interval_us; } @@ -81,7 +55,6 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { void set_init_function(init_fn init) { this->init_ = init; } void set_chipset(Chipset chipset) { this->chipset_ = chipset; }; - void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; } void clear_effect_data() override { for (int i = 0; i < this->size(); i++) { this->effect_data_[i] = 0; @@ -93,7 +66,7 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { protected: light::ESPColorView get_view_internal(int32_t index) const override; - size_t get_buffer_size_() const { return this->num_leds_ * (3 + this->is_rgbw_); } + size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); } static void dma_write_complete_handler(); @@ -102,14 +75,13 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { uint8_t pin_; uint32_t num_leds_; - bool is_rgbw_; pio_hw_t *pio_; uint sm_; uint dma_chan_; dma_channel_config dma_config_; - RGBOrder rgb_order_{ORDER_RGB}; + light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE}; Chipset chipset_{CHIPSET_CUSTOM}; uint32_t last_refresh_{0}; diff --git a/esphome/components/rp2040_pio_led_strip/light.py b/esphome/components/rp2040_pio_led_strip/light.py index b3f816102a..9f7479edd0 100644 --- a/esphome/components/rp2040_pio_led_strip/light.py +++ b/esphome/components/rp2040_pio_led_strip/light.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from esphome import pins import esphome.codegen as cg from esphome.components import light, rp2 +from esphome.components.const import CONF_CHANNEL_COLORS import esphome.config_validation as cv from esphome.const import ( CONF_CHIPSET, @@ -13,6 +14,7 @@ from esphome.const import ( CONF_PIN, CONF_RGB_ORDER, ) +from esphome.types import ConfigType from esphome.util import _LOGGER @@ -37,7 +39,7 @@ def get_nops(timing): return nops -def generate_assembly_code(id, rgbw, t0h, t0l, t1h, t1l): +def generate_assembly_code(id, t0h, t0l, t1h, t1l): """ Generate assembly code with the given timing values. """ @@ -139,8 +141,6 @@ RP2040PIOLEDStripLightOutput = rp2040_pio_led_strip_ns.class_( "RP2040PIOLEDStripLightOutput", light.AddressableLight ) -RGBOrder = rp2040_pio_led_strip_ns.enum("RGBOrder") - Chipset = rp2040_pio_led_strip_ns.enum("Chipset") CHIPSETS = { @@ -159,15 +159,6 @@ class LEDStripTimings: T1L: int -RGB_ORDERS = { - "RGB": RGBOrder.ORDER_RGB, - "RBG": RGBOrder.ORDER_RBG, - "GRB": RGBOrder.ORDER_GRB, - "GBR": RGBOrder.ORDER_GBR, - "BGR": RGBOrder.ORDER_BGR, - "BRG": RGBOrder.ORDER_BRG, -} - CHIPSET_TIMINGS = { "WS2812": LEDStripTimings(20, 40, 46, 34), "WS2812B": LEDStripTimings(23, 49, 46, 26), @@ -199,10 +190,12 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(RP2040PIOLEDStripLightOutput), cv.Required(CONF_PIN): pins.internal_gpio_output_pin_number, cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int, - cv.Required(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True), + cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors, + # Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0 + cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True), + cv.Optional(CONF_IS_RGBW): cv.boolean, cv.Required(CONF_PIO): cv.one_of(0, 1, int=True), cv.Optional(CONF_CHIPSET): cv.enum(CHIPSETS, upper=True), - cv.Optional(CONF_IS_RGBW, default=False): cv.boolean, cv.Inclusive( CONF_BIT0_HIGH, "custom", @@ -222,10 +215,13 @@ CONFIG_SCHEMA = cv.All( } ), cv.has_exactly_one_key(CONF_CHIPSET, CONF_BIT0_HIGH), + light.migrate_channel_colors( + removed_in="2027.3.0", component="rp2040_pio_led_strip" + ), ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) id = config[CONF_ID].id await light.register_light(var, config) @@ -234,8 +230,9 @@ async def to_code(config): cg.add(var.set_num_leds(config[CONF_NUM_LEDS])) cg.add(var.set_pin(config[CONF_PIN])) - cg.add(var.set_rgb_order(config[CONF_RGB_ORDER])) - cg.add(var.set_is_rgbw(config[CONF_IS_RGBW])) + cg.add( + var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS])) + ) cg.add(var.set_pio(config[CONF_PIO])) cg.add(var.set_program(cg.RawExpression(f"&rp2040_pio_led_strip_{id}_program"))) @@ -255,7 +252,6 @@ async def to_code(config): key, generate_assembly_code( id, - config[CONF_IS_RGBW], CHIPSET_TIMINGS[chipset].T0H, CHIPSET_TIMINGS[chipset].T0L, CHIPSET_TIMINGS[chipset].T1H, @@ -270,7 +266,6 @@ async def to_code(config): key, generate_assembly_code( id, - config[CONF_IS_RGBW], time_to_cycles(config[CONF_BIT0_HIGH]), time_to_cycles(config[CONF_BIT0_LOW]), time_to_cycles(config[CONF_BIT1_HIGH]), diff --git a/tests/components/addressable_light/common-ard-esp32_rmt_led_strip.yaml b/tests/components/addressable_light/common-ard-esp32_rmt_led_strip.yaml index a071f9df91..d21c4b61b9 100644 --- a/tests/components/addressable_light/common-ard-esp32_rmt_led_strip.yaml +++ b/tests/components/addressable_light/common-ard-esp32_rmt_led_strip.yaml @@ -3,7 +3,7 @@ light: id: led_matrix_32x8 default_transition_length: 500ms chipset: ws2812 - rgb_order: GRB + channel_colors: GRB num_leds: 256 pin: ${pin} diff --git a/tests/components/addressable_light/common-idf-esp32_rmt_led_strip.yaml b/tests/components/addressable_light/common-idf-esp32_rmt_led_strip.yaml index a071f9df91..d21c4b61b9 100644 --- a/tests/components/addressable_light/common-idf-esp32_rmt_led_strip.yaml +++ b/tests/components/addressable_light/common-idf-esp32_rmt_led_strip.yaml @@ -3,7 +3,7 @@ light: id: led_matrix_32x8 default_transition_length: 500ms chipset: ws2812 - rgb_order: GRB + channel_colors: GRB num_leds: 256 pin: ${pin} diff --git a/tests/components/beken_spi_led_strip/test.bk72xx-ard.yaml b/tests/components/beken_spi_led_strip/test.bk72xx-ard.yaml index 15409caeaf..2bb831848c 100644 --- a/tests/components/beken_spi_led_strip/test.bk72xx-ard.yaml +++ b/tests/components/beken_spi_led_strip/test.bk72xx-ard.yaml @@ -1,6 +1,6 @@ light: - platform: beken_spi_led_strip - rgb_order: GRB + channel_colors: GRB pin: P16 num_leds: 30 chipset: ws2812 diff --git a/tests/components/beken_spi_led_strip/validate-legacy.bk72xx-ard.yaml b/tests/components/beken_spi_led_strip/validate-legacy.bk72xx-ard.yaml new file mode 100644 index 0000000000..3ca78398c3 --- /dev/null +++ b/tests/components/beken_spi_led_strip/validate-legacy.bk72xx-ard.yaml @@ -0,0 +1,10 @@ +# The deprecated rgb_order / is_rgbw / is_wrgb keys, kept working until 2027.3.0. +# Config-only, and only one strip because P16 is the sole supported pin. +light: + - platform: beken_spi_led_strip + name: Legacy RGBW + pin: P16 + num_leds: 30 + chipset: sk6812 + rgb_order: GRB + is_rgbw: true # -> GRBW diff --git a/tests/components/e131/common-ard.yaml b/tests/components/e131/common-ard.yaml index 8300dbb01b..48ccafc2d2 100644 --- a/tests/components/e131/common-ard.yaml +++ b/tests/components/e131/common-ard.yaml @@ -5,7 +5,7 @@ light: id: led_matrix_32x8 default_transition_length: 500ms chipset: ws2812 - rgb_order: GRB + channel_colors: GRB num_leds: 256 pin: ${pin} effects: diff --git a/tests/components/e131/common-idf.yaml b/tests/components/e131/common-idf.yaml index 8300dbb01b..48ccafc2d2 100644 --- a/tests/components/e131/common-idf.yaml +++ b/tests/components/e131/common-idf.yaml @@ -5,7 +5,7 @@ light: id: led_matrix_32x8 default_transition_length: 500ms chipset: ws2812 - rgb_order: GRB + channel_colors: GRB num_leds: 256 pin: ${pin} effects: diff --git a/tests/components/e131/test.rp2040-ard.yaml b/tests/components/e131/test.rp2040-ard.yaml index 4593784ef9..89255e2d87 100644 --- a/tests/components/e131/test.rp2040-ard.yaml +++ b/tests/components/e131/test.rp2040-ard.yaml @@ -6,7 +6,7 @@ light: pin: 2 pio: 0 num_leds: 256 - rgb_order: GRB + channel_colors: GRB chipset: WS2812 effects: - e131: diff --git a/tests/components/esp32_rmt_led_strip/common.yaml b/tests/components/esp32_rmt_led_strip/common.yaml index 701e513ebd..7f52d32229 100644 --- a/tests/components/esp32_rmt_led_strip/common.yaml +++ b/tests/components/esp32_rmt_led_strip/common.yaml @@ -3,13 +3,13 @@ light: id: led_strip1 pin: ${pin1} num_leds: 60 - rgb_order: GRB + channel_colors: GRB chipset: ws2812 - platform: esp32_rmt_led_strip id: led_strip2 pin: ${pin2} num_leds: 60 - rgbw_order: RWGB + channel_colors: RWGB bit0_high: 100us bit0_low: 100us bit1_high: 100us diff --git a/tests/components/esp32_rmt_led_strip/test.esp32-s3-idf.yaml b/tests/components/esp32_rmt_led_strip/test.esp32-s3-idf.yaml index 6bf0639a52..132966eddf 100644 --- a/tests/components/esp32_rmt_led_strip/test.esp32-s3-idf.yaml +++ b/tests/components/esp32_rmt_led_strip/test.esp32-s3-idf.yaml @@ -8,14 +8,14 @@ light: id: led_strip1 pin: ${pin1} num_leds: 60 - rgb_order: GRB + channel_colors: GRB chipset: ws2812 use_dma: "true" - platform: esp32_rmt_led_strip id: led_strip2 pin: ${pin2} num_leds: 60 - rgb_order: RGB + channel_colors: RGB bit0_high: 100us bit0_low: 100us bit1_high: 100us diff --git a/tests/components/esp32_rmt_led_strip/validate-legacy.esp32-idf.yaml b/tests/components/esp32_rmt_led_strip/validate-legacy.esp32-idf.yaml new file mode 100644 index 0000000000..6dd1bcdad3 --- /dev/null +++ b/tests/components/esp32_rmt_led_strip/validate-legacy.esp32-idf.yaml @@ -0,0 +1,23 @@ +# The deprecated rgb_order / is_rgbw / is_wrgb keys, kept working until 2027.3.0. +# Config-only: each strip below must migrate to the channel_colors shown in the comment. +light: + - platform: esp32_rmt_led_strip + id: legacy_rgb + pin: GPIO13 + num_leds: 60 + chipset: ws2812 + rgb_order: GRB # -> GRB + - platform: esp32_rmt_led_strip + id: legacy_rgbw + pin: GPIO14 + num_leds: 60 + chipset: sk6812 + rgb_order: GRB + is_rgbw: true # -> GRBW + - platform: esp32_rmt_led_strip + id: legacy_wrgb + pin: GPIO15 + num_leds: 60 + chipset: sk6812 + rgb_order: GRB + is_wrgb: true # -> WGRB diff --git a/tests/components/partition/common-ard.yaml b/tests/components/partition/common-ard.yaml index b2ceadd6f7..8d39670e32 100644 --- a/tests/components/partition/common-ard.yaml +++ b/tests/components/partition/common-ard.yaml @@ -4,7 +4,7 @@ light: default_transition_length: 500ms chipset: ws2812 num_leds: 256 - rgb_order: GRB + channel_colors: GRB pin: ${pin} - platform: partition name: Partition Light diff --git a/tests/components/partition/common-idf.yaml b/tests/components/partition/common-idf.yaml index b2ceadd6f7..8d39670e32 100644 --- a/tests/components/partition/common-idf.yaml +++ b/tests/components/partition/common-idf.yaml @@ -4,7 +4,7 @@ light: default_transition_length: 500ms chipset: ws2812 num_leds: 256 - rgb_order: GRB + channel_colors: GRB pin: ${pin} - platform: partition name: Partition Light diff --git a/tests/components/rp2040_pio_led_strip/common.yaml b/tests/components/rp2040_pio_led_strip/common.yaml index 254ac0e13d..1cb5fe0737 100644 --- a/tests/components/rp2040_pio_led_strip/common.yaml +++ b/tests/components/rp2040_pio_led_strip/common.yaml @@ -4,14 +4,14 @@ light: pin: 4 num_leds: 60 pio: 0 - rgb_order: GRB + channel_colors: GRB chipset: WS2812 - platform: rp2040_pio_led_strip id: led_strip_custom_timings pin: 5 num_leds: 60 pio: 1 - rgb_order: GRB + channel_colors: GRB bit0_high: .1us bit0_low: 1.2us bit1_high: .69us diff --git a/tests/components/rp2040_pio_led_strip/validate-legacy.rp2040-ard.yaml b/tests/components/rp2040_pio_led_strip/validate-legacy.rp2040-ard.yaml new file mode 100644 index 0000000000..2ab124393b --- /dev/null +++ b/tests/components/rp2040_pio_led_strip/validate-legacy.rp2040-ard.yaml @@ -0,0 +1,18 @@ +# The deprecated rgb_order / is_rgbw keys, kept working until 2027.3.0. +# Config-only: each strip below must migrate to the channel_colors shown in the comment. +light: + - platform: rp2040_pio_led_strip + id: legacy_rgb + pin: 4 + num_leds: 60 + pio: 0 + chipset: WS2812 + rgb_order: GRB # -> GRB + - platform: rp2040_pio_led_strip + id: legacy_rgbw + pin: 5 + num_leds: 60 + pio: 1 + chipset: SK6812 + rgb_order: GRB + is_rgbw: true # -> GRBW diff --git a/tests/components/wled/test.esp32-ard.yaml b/tests/components/wled/test.esp32-ard.yaml index 156b31181e..ecab767812 100644 --- a/tests/components/wled/test.esp32-ard.yaml +++ b/tests/components/wled/test.esp32-ard.yaml @@ -9,7 +9,7 @@ light: id: led_matrix_32x8 default_transition_length: 500ms chipset: ws2812 - rgb_order: GRB + channel_colors: GRB num_leds: 256 pin: 2 effects: diff --git a/tests/unit_tests/components/light/test_channel_colors.py b/tests/unit_tests/components/light/test_channel_colors.py new file mode 100644 index 0000000000..0c129a8bb2 --- /dev/null +++ b/tests/unit_tests/components/light/test_channel_colors.py @@ -0,0 +1,144 @@ +"""Tests for the shared addressable-strip channel order helpers.""" + +import logging + +import pytest + +from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB +from esphome.components.light import ( + channel_colors_struct, + migrate_channel_colors, + validate_channel_colors, +) +import esphome.config_validation as cv +from esphome.const import CONF_IS_RGBW, CONF_RGB_ORDER +from esphome.types import ConfigType + +NO_WHITE = "light::ChannelColors::NO_WHITE" + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("RGB", "RGB"), + ("grb", "GRB"), + ("BRG", "BRG"), + ("rgbw", "RGBW"), + ("WRGB", "WRGB"), + ("GWRB", "GWRB"), + ], +) +def test_validate_channel_colors(value: str, expected: str) -> None: + assert validate_channel_colors(value) == expected + + +@pytest.mark.parametrize( + "value", + [ + "RG", # missing a channel + "RGBB", # duplicate channel + "RRGB", # duplicate channel, correct length + "RGBWW", # two white channels + "RGBX", # unknown channel + "RGBWX", # unknown channel, correct length + "", + ], +) +def test_validate_channel_colors_rejects_invalid(value: str) -> None: + with pytest.raises(cv.Invalid, match="is not a valid channel order"): + validate_channel_colors(value) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("RGB", (0, 1, 2, NO_WHITE)), + ("GRB", (1, 0, 2, NO_WHITE)), + ("BRG", (1, 2, 0, NO_WHITE)), + ("RGBW", (0, 1, 2, 3)), + ("GRBW", (1, 0, 2, 3)), + ("WRGB", (1, 2, 3, 0)), + ("GWRB", (2, 0, 3, 1)), + ], +) +def test_channel_colors_struct(value: str, expected: tuple[int, int, int, int]) -> None: + struct = channel_colors_struct(value) + assert str(struct.base) == "light::ChannelColors" + assert tuple(str(arg) for arg in struct.args.values()) == tuple( + str(field) for field in expected + ) + + +def _migrate(config: ConfigType) -> ConfigType: + return migrate_channel_colors(removed_in="2027.3.0", component="test_strip")(config) + + +def test_migrate_passes_through_channel_colors() -> None: + config = {CONF_CHANNEL_COLORS: "GRBW"} + assert _migrate(config) == {CONF_CHANNEL_COLORS: "GRBW"} + + +@pytest.mark.parametrize( + ("deprecated", "expected", "named"), + [ + ({}, "GRB", "'rgb_order' is"), + ( + {CONF_IS_RGBW: False, CONF_IS_WRGB: False}, + "GRB", + "'rgb_order', 'is_rgbw' and 'is_wrgb' are", + ), + ({CONF_IS_RGBW: True}, "GRBW", "'rgb_order' and 'is_rgbw' are"), + ({CONF_IS_WRGB: True}, "WGRB", "'rgb_order' and 'is_wrgb' are"), + ], +) +def test_migrate_folds_deprecated_keys( + deprecated: ConfigType, + expected: str, + named: str, + caplog: pytest.LogCaptureFixture, +) -> None: + config = {CONF_RGB_ORDER: "GRB", "num_leds": 1, **deprecated} + with caplog.at_level(logging.WARNING): + result = _migrate(config) + + assert result == {CONF_CHANNEL_COLORS: expected, "num_leds": 1} + assert f"[test_strip] {named} deprecated" in caplog.text + assert f"'{CONF_CHANNEL_COLORS}: {expected}'" in caplog.text + assert "2027.3.0" in caplog.text + + +def test_migrate_does_not_mutate_input() -> None: + config = {CONF_RGB_ORDER: "GRB", CONF_IS_RGBW: True} + _migrate(config) + assert config == {CONF_RGB_ORDER: "GRB", CONF_IS_RGBW: True} + + +@pytest.mark.parametrize("deprecated", [CONF_RGB_ORDER, CONF_IS_RGBW, CONF_IS_WRGB]) +def test_migrate_rejects_mixing_old_and_new(deprecated: str) -> None: + config = {CONF_CHANNEL_COLORS: "GRBW", deprecated: "GRB"} + with pytest.raises(cv.Invalid, match=f"cannot be combined with '{deprecated}'"): + _migrate(config) + + +def test_migrate_reports_every_conflicting_key() -> None: + config = { + CONF_CHANNEL_COLORS: "GRBW", + CONF_RGB_ORDER: "GRB", + CONF_IS_RGBW: True, + CONF_IS_WRGB: False, + } + with pytest.raises( + cv.Invalid, match="cannot be combined with 'rgb_order', 'is_rgbw' and 'is_wrgb'" + ): + _migrate(config) + + +def test_migrate_requires_channel_colors() -> None: + with pytest.raises(cv.Invalid, match=f"'{CONF_CHANNEL_COLORS}' is required"): + _migrate({"num_leds": 1}) + + +def test_migrate_rejects_is_rgbw_with_is_wrgb() -> None: + config = {CONF_RGB_ORDER: "GRB", CONF_IS_RGBW: True, CONF_IS_WRGB: True} + with pytest.raises(cv.Invalid, match="cannot both be enabled"): + _migrate(config) diff --git a/tests/unit_tests/components/test_esp32_rmt_led_strip.py b/tests/unit_tests/components/test_esp32_rmt_led_strip.py deleted file mode 100644 index e2cb513e3b..0000000000 --- a/tests/unit_tests/components/test_esp32_rmt_led_strip.py +++ /dev/null @@ -1,57 +0,0 @@ -import pytest - -from esphome.components.esp32_rmt_led_strip.light import ( - CONF_IS_WRGB, - CONF_RGBW_ORDER, - _split_rgbw_order, - _validate_rgbw_order, - _validate_rgbw_order_exclusivity, -) -import esphome.config_validation as cv -from esphome.const import CONF_IS_RGBW - - -def test_validate_rgbw_order() -> None: - assert _validate_rgbw_order("rwgb") == "RWGB" - - -@pytest.mark.parametrize("rgbw_order", ["RGB", "RRGB", "RGBWW"]) -def test_validate_rgbw_order_rejects_invalid_order(rgbw_order: str) -> None: - with pytest.raises(cv.Invalid, match="permutation of RGBW"): - _validate_rgbw_order(rgbw_order) - - -@pytest.mark.parametrize( - ("rgbw_order", "expected"), - [ - ("WRGB", ("RGB", 0)), - ("RWGB", ("RGB", 1)), - ("GWRB", ("GRB", 1)), - ("RGBW", ("RGB", 3)), - ], -) -def test_split_rgbw_order(rgbw_order: str, expected: tuple[str, int]) -> None: - assert _split_rgbw_order(rgbw_order) == expected - - -@pytest.mark.parametrize("conflict", [CONF_IS_RGBW, CONF_IS_WRGB]) -def test_rgbw_order_is_mutually_exclusive(conflict: str) -> None: - with pytest.raises(cv.Invalid, match="cannot be used with"): - _validate_rgbw_order_exclusivity( - { - CONF_RGBW_ORDER: "RGBW", - CONF_IS_RGBW: conflict == CONF_IS_RGBW, - CONF_IS_WRGB: conflict == CONF_IS_WRGB, - } - ) - - -@pytest.mark.parametrize("legacy_option", [CONF_IS_RGBW, CONF_IS_WRGB]) -def test_rgbw_order_allows_disabled_legacy_options(legacy_option: str) -> None: - config = { - CONF_RGBW_ORDER: "RGBW", - CONF_IS_RGBW: False, - CONF_IS_WRGB: False, - } - config[legacy_option] = False - assert _validate_rgbw_order_exclusivity(config) is config From 8b888f31e0bf4d2dedd34fa25d7f1aa593a0c581 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 20:32:03 -0500 Subject: [PATCH 1543/1815] [gpio_expander][pcf8574][pca9554][tca9555][pca6416a][pi4ioe5v6408][mcp23016][mcp23xxx_base] Reject unsupported interrupt_pin options (inverted, allow_other_uses) (#18472) --- esphome/components/gpio_expander/__init__.py | 22 +++++++ esphome/components/mcp23016/__init__.py | 4 +- esphome/components/mcp23xxx_base/__init__.py | 22 +------ esphome/components/pca6416a/__init__.py | 4 +- esphome/components/pca9554/__init__.py | 4 +- esphome/components/pcf8574/__init__.py | 4 +- esphome/components/pi4ioe5v6408/__init__.py | 4 +- esphome/components/tca9555/__init__.py | 4 +- script/build_language_schema.py | 10 +++ .../component_tests/gpio_expander/__init__.py | 0 .../gpio_expander/test_init.py | 61 +++++++++++++++++++ 11 files changed, 107 insertions(+), 32 deletions(-) create mode 100644 tests/component_tests/gpio_expander/__init__.py create mode 100644 tests/component_tests/gpio_expander/test_init.py diff --git a/esphome/components/gpio_expander/__init__.py b/esphome/components/gpio_expander/__init__.py index e69de29bb2..0c7199b6df 100644 --- a/esphome/components/gpio_expander/__init__.py +++ b/esphome/components/gpio_expander/__init__.py @@ -0,0 +1,22 @@ +from esphome import pins +import esphome.config_validation as cv +from esphome.const import CONF_ALLOW_OTHER_USES, CONF_INTERRUPT_PIN, CONF_INVERTED +from esphome.types import ConfigType + + +def validate_interrupt_pin(value: ConfigType) -> ConfigType: + # The expander components own INT polarity (active-low, hardcoded falling-edge ISR) + # and install a single ISR per GPIO, so neither inversion nor sharing is supported. + value = pins.internal_gpio_input_pin_schema(value) + if value.get(CONF_INVERTED): + raise cv.Invalid( + f"'{CONF_INVERTED}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " + "the expander INT line is fixed active-low" + ) + if value.get(CONF_ALLOW_OTHER_USES): + raise cv.Invalid( + f"'{CONF_ALLOW_OTHER_USES}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " + "sharing the interrupt pin between multiple components is not implemented. " + f"Remove the '{CONF_INTERRUPT_PIN}' to fall back to polling." + ) + return value diff --git a/esphome/components/mcp23016/__init__.py b/esphome/components/mcp23016/__init__.py index b71d57498a..37c5205fe8 100644 --- a/esphome/components/mcp23016/__init__.py +++ b/esphome/components/mcp23016/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -25,7 +25,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(MCP23016), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index 76a3aabe3f..d53499a78f 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -1,8 +1,8 @@ from esphome import pins import esphome.codegen as cg +from esphome.components import gpio_expander import esphome.config_validation as cv from esphome.const import ( - CONF_ALLOW_OTHER_USES, CONF_ID, CONF_INPUT, CONF_INTERRUPT, @@ -32,28 +32,10 @@ MCP23XXX_INTERRUPT_MODES = { } -def _validate_interrupt_pin(value): - # The MCP component owns INT polarity (active-low, hardcoded falling-edge ISR) - # and installs a single ISR per GPIO, so neither inversion nor sharing is supported. - value = pins.internal_gpio_input_pin_schema(value) - if value.get(CONF_INVERTED): - raise cv.Invalid( - f"'{CONF_INVERTED}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " - "the MCP23xxx INT line is fixed active-low" - ) - if value.get(CONF_ALLOW_OTHER_USES): - raise cv.Invalid( - f"'{CONF_ALLOW_OTHER_USES}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " - "sharing the interrupt pin between multiple MCP23xxx (or other components) " - "is not implemented. Remove the interrupt_pin to fall back to polling." - ) - return value - - MCP23XXX_CONFIG_SCHEMA = cv.Schema( { cv.Optional(CONF_OPEN_DRAIN_INTERRUPT, default=False): cv.boolean, - cv.Optional(CONF_INTERRUPT_PIN): _validate_interrupt_pin, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ).extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/pca6416a/__init__.py b/esphome/components/pca6416a/__init__.py index 813bb35c48..1df22a8ff5 100644 --- a/esphome/components/pca6416a/__init__.py +++ b/esphome/components/pca6416a/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -29,7 +29,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(PCA6416AComponent), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/pca9554/__init__.py b/esphome/components/pca9554/__init__.py index 99b812b33b..f49a68bc3f 100644 --- a/esphome/components/pca9554/__init__.py +++ b/esphome/components/pca9554/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -30,7 +30,7 @@ CONFIG_SCHEMA = ( { cv.Required(CONF_ID): cv.declare_id(PCA9554Component), cv.Optional(CONF_PIN_COUNT, default=8): cv.one_of(4, 8, 16), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/pcf8574/__init__.py b/esphome/components/pcf8574/__init__.py index d8a1e20db6..559fe1d76d 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -28,7 +28,7 @@ CONFIG_SCHEMA = ( { cv.Required(CONF_ID): cv.declare_id(PCF8574Component), cv.Optional(CONF_PCF8575, default=False): cv.boolean, - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/pi4ioe5v6408/__init__.py b/esphome/components/pi4ioe5v6408/__init__.py index d5b19dab1c..ee270138e1 100644 --- a/esphome/components/pi4ioe5v6408/__init__.py +++ b/esphome/components/pi4ioe5v6408/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -34,7 +34,7 @@ CONFIG_SCHEMA = ( { cv.Required(CONF_ID): cv.declare_id(PI4IOE5V6408Component), cv.Optional(CONF_RESET, default=True): cv.boolean, - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/tca9555/__init__.py b/esphome/components/tca9555/__init__.py index 5f571fcea6..1c643fe1c9 100644 --- a/esphome/components/tca9555/__init__.py +++ b/esphome/components/tca9555/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -28,7 +28,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(TCA9555Component), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 2b64cb0256..91c1de00cd 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -250,6 +250,16 @@ def add_pin_validators(): "modes": ["input"], } + from esphome.components import gpio_expander + + # Wraps pins.internal_gpio_input_pin_schema, so the editor schema must keep + # treating the config var as a pin + pin_validators[repr(gpio_expander.validate_interrupt_pin)] = { + "schema": True, + "internal": True, + "modes": ["input"], + } + def add_module_registries(domain, module): for attr_name in dir(module): diff --git a/tests/component_tests/gpio_expander/__init__.py b/tests/component_tests/gpio_expander/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/gpio_expander/test_init.py b/tests/component_tests/gpio_expander/test_init.py new file mode 100644 index 0000000000..806b1775d2 --- /dev/null +++ b/tests/component_tests/gpio_expander/test_init.py @@ -0,0 +1,61 @@ +"""Tests for the shared io expander interrupt_pin validator.""" + +from __future__ import annotations + +import importlib + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.gpio_expander import validate_interrupt_pin +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +@pytest.fixture +def stage_esp32(set_core_config: SetCoreConfigCallable) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + +def test_plain_pin_accepted(stage_esp32: None) -> None: + value = validate_interrupt_pin( + {"number": 16, "mode": {"input": True, "pullup": True}} + ) + assert value["number"] == 16 + + +def test_inverted_rejected(stage_esp32: None) -> None: + with pytest.raises(cv.Invalid, match="'inverted: true' is not supported"): + validate_interrupt_pin({"number": 16, "inverted": True}) + + +def test_allow_other_uses_rejected(stage_esp32: None) -> None: + with pytest.raises(cv.Invalid, match="'allow_other_uses: true' is not supported"): + validate_interrupt_pin({"number": 16, "allow_other_uses": True}) + + +# mcp23017 covers the shared mcp23xxx_base schema +@pytest.mark.parametrize( + "component", + [ + "pcf8574", + "pca9554", + "tca9555", + "pca6416a", + "pi4ioe5v6408", + "mcp23016", + "mcp23017", + ], +) +def test_component_schemas_route_through_validator( + stage_esp32: None, component: str +) -> None: + module = importlib.import_module(f"esphome.components.{component}") + with pytest.raises(cv.Invalid, match="'inverted: true' is not supported"): + module.CONFIG_SCHEMA( + {"id": "expander_hub", "interrupt_pin": {"number": 16, "inverted": True}} + ) From d1391c2b10a2f473b11d2a69c0ea8e3eecd260c2 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:04:49 +1200 Subject: [PATCH 1544/1815] Bump version to 2026.8.0b5 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 2df6d3ded0..3dad4629be 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.8.0b4 +PROJECT_NUMBER = 2026.8.0b5 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 73155e06ee..e86465f9a0 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.8.0b4" +__version__ = "2026.8.0b5" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 9823205ef3080e5e7fd9c4004f3cefc1d68a0e37 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 09:03:14 -0500 Subject: [PATCH 1545/1815] [api] Bump noise-c to 0.1.20 (#18482) --- esphome/components/api/__init__.py | 2 +- platformio.ini | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index cdc0d97c49..3e69d5842c 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -497,7 +497,7 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") - cg.add_library("esphome/noise-c", "0.1.19") + cg.add_library("esphome/noise-c", "0.1.20") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index 39600d622a..13bb5a556f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.19 ; api + esphome/noise-c@0.1.20 ; api improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.19 ; api + esphome/noise-c@0.1.20 ; api ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.19 ; used by api + esphome/noise-c@0.1.20 ; used by api lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} From ae730d6357e6a82eed82f89a3e396793bf499baf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 09:03:33 -0500 Subject: [PATCH 1546/1815] [ci] Fail the benchmark job when the C++ benchmark build fails (#18480) --- .github/workflows/ci.yml | 17 ++++++++++++++--- tests/benchmarks/components/api/__init__.py | 1 + 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd1a382c21..0c81c783b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -464,10 +464,21 @@ jobs: - name: Build benchmarks id: build run: | + # pipefail: without it a failed build is masked by the grep/cut + # pipeline below, leaving BINARY empty and silently dropping every + # C++ benchmark from the run while the job still reports success. + set -o pipefail . venv/bin/activate - export BENCHMARK_LIB_CONFIG=$(python script/setup_codspeed_lib.py) - # --build-only prints BUILD_BINARY= to stdout - BINARY=$(script/cpp_benchmark.py --all --build-only | grep '^BUILD_BINARY=' | tail -1 | cut -d= -f2-) + BENCHMARK_LIB_CONFIG=$(python script/setup_codspeed_lib.py) + export BENCHMARK_LIB_CONFIG + # --build-only prints BUILD_BINARY= to stdout; the grep is + # non-fatal so a missing marker reaches the check below instead of + # tripping errexit at this assignment + BINARY=$(script/cpp_benchmark.py --all --build-only | { grep '^BUILD_BINARY=' || true; } | tail -1 | cut -d= -f2-) + if [ -z "$BINARY" ]; then + echo "::error::Benchmark build did not report a binary path" + exit 1 + fi echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks diff --git a/tests/benchmarks/components/api/__init__.py b/tests/benchmarks/components/api/__init__.py index 0d02e0b054..0565bc5330 100644 --- a/tests/benchmarks/components/api/__init__.py +++ b/tests/benchmarks/components/api/__init__.py @@ -15,6 +15,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # components have hardware dependencies (BLE/UART/RMT); lightweight # stub headers in tests/benchmarks/stubs/ satisfy the includes. cg.add_define("USE_BLUETOOTH_PROXY") + cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS") cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 3) cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) cg.add_define("USE_ZWAVE_PROXY") From 476d540065ecd352a5aa4ff52179966d1f732163 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:22:28 -0400 Subject: [PATCH 1547/1815] [ci] Fall back to files API when PR diff exceeds GitHub line limit (#18486) --- script/helpers.py | 5 +++-- tests/script/test_helpers.py | 38 ++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/script/helpers.py b/script/helpers.py index 11549808ff..8132ee49e5 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -558,8 +558,9 @@ def _get_changed_files_github_actions() -> list[str] | None: try: return _get_changed_files_from_command(cmd) except Exception as e: - # If it fails due to the 300 file limit, use the API method - if "maximum" in str(e) and "files" in str(e): + # If it fails due to a diff limit (300 files or 20000 lines), + # use the API method which only returns filenames + if "diff exceeded the maximum" in str(e): cmd = [ "gh", "api", diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index a07e56cea5..2c3ae95655 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -244,6 +244,44 @@ def test_get_changed_files_github_actions_pull_request_large_pr( assert result == expected_files +def test_get_changed_files_github_actions_pull_request_large_diff( + monkeypatch: MonkeyPatch, +) -> None: + """Test _get_changed_files_github_actions fallback for PRs with >20000 diff lines.""" + monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request") + + expected_files = ["file1.py", "file2.cpp"] + + with ( + patch("helpers._get_pr_number_from_github_env", return_value="17909"), + patch("helpers._get_changed_files_from_command") as mock_get, + ): + # First call fails with too many diff lines error, second succeeds with API method + mock_get.side_effect = [ + Exception( + "could not find pull request diff: HTTP 406: Sorry, " + "the diff exceeded the maximum number of lines (20000)" + ), + expected_files, + ] + + result = _get_changed_files_github_actions() + + assert mock_get.call_count == 2 + mock_get.assert_any_call(["gh", "pr", "diff", "17909", "--name-only"]) + mock_get.assert_any_call( + [ + "gh", + "api", + "repos/esphome/esphome/pulls/17909/files", + "--paginate", + "--jq", + ".[].filename", + ] + ) + assert result == expected_files + + def test_get_changed_files_github_actions_pull_request_other_error( monkeypatch: MonkeyPatch, ) -> None: From 92f55f721f35d36b4a883811c6cebb6b1027cb7b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 09:32:37 -0500 Subject: [PATCH 1548/1815] [api] Bump noise-c to 0.1.21 (#18484) --- esphome/components/api/__init__.py | 2 +- platformio.ini | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 3e69d5842c..912d580a0f 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -497,7 +497,7 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") - cg.add_library("esphome/noise-c", "0.1.20") + cg.add_library("esphome/noise-c", "0.1.21") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index 13bb5a556f..4c372cc0bb 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.20 ; api + esphome/noise-c@0.1.21 ; api improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.20 ; api + esphome/noise-c@0.1.21 ; api ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.20 ; used by api + esphome/noise-c@0.1.21 ; used by api lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} From 285a508e09effe69510fbde25f92b6eb7dd21c03 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 18 Aug 2026 09:09:12 -0700 Subject: [PATCH 1549/1815] [modbus] CRC scan all unknown function codes (#18483) Co-authored-by: Claude Opus 4.8 (1M context) --- esphome/components/modbus/modbus.cpp | 33 ++-- esphome/components/modbus/modbus.h | 2 +- esphome/components/modbus/modbus_helpers.h | 32 ++++ tests/components/modbus/common.h | 36 +++++ .../modbus/modbus_unknown_function_test.cpp | 141 ++++++++++++++++++ 5 files changed, 232 insertions(+), 12 deletions(-) create mode 100644 tests/components/modbus/modbus_unknown_function_test.cpp diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 5305f6313f..e4bd51ad5a 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -219,14 +219,25 @@ void ModbusServerHub::parse_modbus_frames() { this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true); } -uint16_t Modbus::find_custom_frame_end_(uint16_t min_length) const { - // Custom functions could be any length - we have to rely on the CRC to determine completeness. +uint16_t Modbus::find_frame_end_by_crc_(uint16_t min_length) const { + // Unknown-length functions (user-defined codes, unimplemented management codes, unassigned values) + // could be any length - we have to rely on the CRC to determine completeness. // If a CRC match is never found, the buffer will eventually overflow and be cleared. const uint8_t *raw = &this->rx_buffer_[0]; const size_t size = this->rx_buffer_.size(); - for (uint16_t len = min_length; len <= std::min(size, size_t(MAX_FRAME_SIZE)); len++) { - if (crc16(raw, len) == 0) - return len; + const auto max_len = static_cast(std::min(size, size_t(MAX_FRAME_SIZE))); + if (min_length > max_len) + return 0; + // The Modbus CRC (poly 0xa001, refin/refout false) keeps its running state in the returned value, + // so we seed once over the first min_length bytes and extend one byte at a time instead of + // recomputing the whole prefix for every candidate length. + uint16_t crc = crc16(raw, min_length); + if (crc == 0) + return min_length; + for (uint16_t len = min_length; len < max_len; len++) { + crc = crc16(&raw[len], 1, crc); + if (crc == 0) + return len + 1; } return 0; } @@ -241,11 +252,11 @@ bool Modbus::parse_modbus_server_frame_() { uint8_t address = this->rx_buffer_[0]; uint8_t function_code = this->rx_buffer_[1]; - if (helpers::is_function_code_custom(function_code)) { - frame_length = this->find_custom_frame_end_(frame_length); + if (helpers::is_function_code_unknown_length(function_code)) { + frame_length = this->find_frame_end_by_crc_(frame_length); if (frame_length == 0) return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size - ESP_LOGD(TAG, "User-defined function %02X found", function_code); + ESP_LOGD(TAG, "Unknown-length function %02X found", function_code); } else { if (crc16(&this->rx_buffer_[0], frame_length) != 0) return false; @@ -272,11 +283,11 @@ bool ModbusServerHub::parse_modbus_client_frame_() { uint8_t address = this->rx_buffer_[0]; uint8_t function_code = this->rx_buffer_[1]; - if (helpers::is_function_code_custom(function_code)) { - frame_length = this->find_custom_frame_end_(frame_length); + if (helpers::is_function_code_unknown_length(function_code)) { + frame_length = this->find_frame_end_by_crc_(frame_length); if (frame_length == 0) return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size - ESP_LOGD(TAG, "User-defined function %02X found", function_code); + ESP_LOGD(TAG, "Unknown-length function %02X found", function_code); } else { if (crc16(&this->rx_buffer_[0], frame_length) != 0) return false; diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index dfe4a4872d..bb303c43a8 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -82,7 +82,7 @@ class Modbus : public uart::UARTDevice, public Component { bool send_frame_(const ModbusFrame &frame); // Scans forward from min_length to find a frame boundary by CRC match for custom function codes. // Returns the matched frame length, or 0 if no valid CRC was found within MAX_FRAME_SIZE. - uint16_t find_custom_frame_end_(uint16_t min_length) const; + uint16_t find_frame_end_by_crc_(uint16_t min_length) const; uint32_t last_modbus_byte_{0}; uint32_t last_receive_check_{0}; diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index c737e206c0..b2454e6f14 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -55,6 +55,38 @@ inline bool is_function_code_custom(uint8_t function_code) { masked_function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_2_END); } +/// True for any function code whose frame length the parsers cannot predict - everything the +/// server_pdu_length()/client_pdu_length() switches fall through to `default` on (keep the case list +/// in step with those switches). Deliberately wider than is_function_code_custom(): the user-defined +/// ranges are unknown to the parser too, but so are the assigned-but-unimplemented codes +/// (READ_EXCEPTION_STATUS, DIAGNOSTICS, GET_COMM_EVENT_*, REPORT_SERVER_ID) and every unassigned value. +/// The 0x80 exception flag is masked off first, so a frame with it set classifies by its base code - +/// even though a spec exception reply has a known 2-byte PDU. That is deliberate, matching what +/// is_function_code_custom() has always done: some vendors use codes with the 0x80 bit set as ordinary +/// codes with longer payloads, so the response parser CRC-scans these rather than assuming the spec +/// length. For an intact spec exception the scan matches at its first candidate, so only a corrupt one +/// pays (recovery by timeout instead of an immediate CRC failure). +inline bool is_function_code_unknown_length(uint8_t function_code) { + switch (static_cast(function_code & FUNCTION_CODE_MASK)) { + case FunctionCode::READ_COILS: + case FunctionCode::READ_DISCRETE_INPUTS: + case FunctionCode::READ_HOLDING_REGISTERS: + case FunctionCode::READ_INPUT_REGISTERS: + case FunctionCode::WRITE_SINGLE_COIL: + case FunctionCode::WRITE_SINGLE_REGISTER: + case FunctionCode::WRITE_MULTIPLE_COILS: + case FunctionCode::WRITE_MULTIPLE_REGISTERS: + case FunctionCode::READ_FILE_RECORD: + case FunctionCode::WRITE_FILE_RECORD: + case FunctionCode::MASK_WRITE_REGISTER: + case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: + case FunctionCode::READ_FIFO_QUEUE: + return false; + default: + return true; + } +} + // Returns the expected length of a server response PDU based on the function code. // If too few bytes have arrived to determine the length, returns the minimum length. `size` is the // number of bytes available so far, which may exceed the eventual PDU (e.g. include the frame's CRC diff --git a/tests/components/modbus/common.h b/tests/components/modbus/common.h index d03ccf8ec3..e6c37b0e6d 100644 --- a/tests/components/modbus/common.h +++ b/tests/components/modbus/common.h @@ -1,7 +1,10 @@ #pragma once #include +#include +#include #include #include "esphome/components/uart/uart_component.h" +#include "esphome/core/helpers.h" namespace esphome::modbus::testing { @@ -30,4 +33,37 @@ class RecordingUART : public NullUART { std::vector written; }; +// A UART the test can inject received bytes into, so frames travel the full receive path +// (receive_modbus_frames -> parse -> dispatch) through hub.loop(). Writes are recorded. +class InjectableUART : public RecordingUART { + public: + bool peek_byte(uint8_t *data) override { + if (this->rx_.empty()) + return false; + *data = this->rx_.front(); + return true; + } + bool read_array(uint8_t *data, size_t len) override { + if (len > this->rx_.size()) + return false; + memcpy(data, this->rx_.data(), len); + this->rx_.erase(this->rx_.begin(), this->rx_.begin() + len); + return true; + } + size_t available() override { return this->rx_.size(); } + + // Queues a complete wire frame: address + PDU + CRC16 (low byte first). + void inject_frame(uint8_t address, std::span pdu) { + size_t start = this->rx_.size(); + this->rx_.push_back(address); + this->rx_.insert(this->rx_.end(), pdu.begin(), pdu.end()); + uint16_t crc = crc16(this->rx_.data() + start, this->rx_.size() - start); + this->rx_.push_back(crc & 0xFF); + this->rx_.push_back(crc >> 8); + } + + private: + std::vector rx_; +}; + } // namespace esphome::modbus::testing diff --git a/tests/components/modbus/modbus_unknown_function_test.cpp b/tests/components/modbus/modbus_unknown_function_test.cpp new file mode 100644 index 0000000000..8b91d088b8 --- /dev/null +++ b/tests/components/modbus/modbus_unknown_function_test.cpp @@ -0,0 +1,141 @@ +#include + +#include +#include +#include + +#include "common.h" +#include "esphome/components/modbus/modbus.h" + +namespace esphome::modbus::testing { + +namespace { + +// Records custom-response dispatches so tests can assert an unknown-length frame reached the device. +class CustomRecordingDevice : public ModbusClientDevice { + public: + using ModbusClientDevice::ModbusClientDevice; + void on_custom_response(std::span request_pdu, std::span response_pdu, + ResponseStatus status) override { + this->requests.emplace_back(request_pdu.begin(), request_pdu.end()); + this->responses.emplace_back(response_pdu.begin(), response_pdu.end()); + this->statuses.push_back(status); + } + std::vector> requests; + std::vector> responses; + std::vector statuses; +}; + +// Every handler keeps its ILLEGAL_FUNCTION default; the hub's dispatch is what is under test. +class SilentServerDevice : public ModbusServerDevice {}; + +// Drives full client frames through the server hub's receive path (same shape as the broadcast tests). +class TestServerHub : public ModbusServerHub { + public: + bool tx_blocked() override { return false; } + + // Builds a complete client frame (address + FC + data + CRC) and runs the full receive-side parser. + // Returns true once the buffer has fully drained. + bool run_receive_parser_for_test(uint8_t address, uint8_t function_code, std::span data) { + this->rx_buffer_.clear(); + this->rx_buffer_.reserve(data.size() + 4); + this->rx_buffer_.push_back(address); + this->rx_buffer_.push_back(function_code); + this->rx_buffer_.insert(this->rx_buffer_.end(), data.begin(), data.end()); + uint16_t crc = crc16(this->rx_buffer_.data(), this->rx_buffer_.size()); + this->rx_buffer_.push_back(crc & 0xFF); + this->rx_buffer_.push_back(crc >> 8); + this->parse_modbus_frames(); + return this->rx_buffer_.empty(); + } +}; + +} // namespace + +// The frame-length parsers have explicit cases for exactly these 13 codes; every other value - the +// assigned-but-unimplemented management codes, both user-defined ranges, and all unassigned codes - +// must classify as unknown length. The exception flag masks off first. +TEST(ModbusUnknownFunction, HelperMatchesParserCoverage) { + for (uint8_t fc : {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x0F, 0x10, 0x14, 0x15, 0x16, 0x17, 0x18}) { + EXPECT_FALSE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << int(fc); + } + for (uint8_t fc : {0x07, 0x08, 0x0B, 0x0C, 0x11, 0x2A, 0x41, 0x48, 0x49, 0x64, 0x6E, 0x00, 0x7F}) { + EXPECT_TRUE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << int(fc); + } + // Exception replies classify by their base code. + EXPECT_FALSE(helpers::is_function_code_unknown_length(0x83)); + EXPECT_TRUE(helpers::is_function_code_unknown_length(0x87)); + // Strictly wider than the user-defined ranges: every custom code is unknown-length, but not vice versa. + for (int fc = 0; fc <= 0xFF; fc++) { + if (helpers::is_function_code_custom(fc)) + EXPECT_TRUE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << fc; + } + EXPECT_FALSE(helpers::is_function_code_custom(0x49)); + + // Derived contract check: the helper must say "unknown" exactly when both length parsers fall + // through to default. With a zero-filled max-size PDU every explicit case returns at least 2 + // (file records bottom out at 2, FIFO at 3) and only default returns MIN_PDU_SIZE, so comparing + // against MIN_PDU_SIZE detects a case added to either switch without updating the helper. The + // loop stops at 0x7F: above it the helper masks the exception flag off while client_pdu_length() + // switches on the unmasked byte and server_pdu_length() early-returns the exception length. + for (int fc = 0; fc <= 0x7F; fc++) { + const uint8_t pdu[MAX_PDU_SIZE] = {static_cast(fc)}; // zero header fields + EXPECT_EQ(helpers::is_function_code_unknown_length(fc), + helpers::client_pdu_length(pdu, sizeof(pdu)) == MIN_PDU_SIZE) + << "client_pdu_length disagrees for fc 0x" << std::hex << fc; + EXPECT_EQ(helpers::is_function_code_unknown_length(fc), + helpers::server_pdu_length(pdu, sizeof(pdu)) == MIN_PDU_SIZE) + << "server_pdu_length disagrees for fc 0x" << std::hex << fc; + } +} + +// A response with a function code outside the user-defined ranges (0x49) has no length case in +// server_pdu_length(), so the parser must find the frame end by CRC scan - the same way it already +// handles user-defined codes. Frame: address + FC 0x49 + 3 data bytes + CRC = 7 bytes. Without the +// scan the parser assumes a 4-byte frame, fails the CRC, and the response never reaches the device. +TEST(ModbusUnknownFunction, ClientParsesUnknownLengthResponse) { + InjectableUART uart; + ModbusClientHub hub; + hub.set_uart_parent(&uart); + hub.setup(); // computes frame timing from the baud rate + CustomRecordingDevice device(&hub, 0x02); + + const uint8_t request[] = {0x49, 0x01}; + ASSERT_TRUE(device.queue_pdu(request)); + hub.loop(); // transmit + ASSERT_FALSE(uart.written.empty()); + + const uint8_t response_pdu[] = {0x49, 0x02, 0xAA, 0xBB}; + uart.inject_frame(0x02, response_pdu); + hub.loop(); // receive + parse + match + dispatch + + ASSERT_EQ(device.responses.size(), 1u); + EXPECT_EQ(device.requests[0], std::vector(request, request + sizeof(request))); + EXPECT_EQ(device.responses[0], std::vector(response_pdu, response_pdu + sizeof(response_pdu))); + EXPECT_FALSE(device.statuses[0].has_value()); +} + +// The server side of the same gap: a request with FC 0x49 for a registered device must parse (CRC +// scan again) so the hub can answer ILLEGAL_FUNCTION per the spec. Without the scan the frame fails +// to parse and the client gets silence instead of the exception. +TEST(ModbusUnknownFunction, ServerRepliesIllegalFunctionToUnknownLengthRequest) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + SilentServerDevice device; + device.set_address(0x02); + hub.register_device(&device); + + const uint8_t data[] = {0x02, 0xAA, 0xBB}; + ASSERT_TRUE(hub.run_receive_parser_for_test(0x02, 0x49, data)); + + // Expected reply: address + FC with exception flag + ILLEGAL_FUNCTION + CRC. + std::vector expected = {0x02, 0xC9, 0x01}; + uint16_t crc = crc16(expected.data(), expected.size()); + expected.push_back(crc & 0xFF); + expected.push_back(crc >> 8); + EXPECT_EQ(uart.written, expected); +} + +} // namespace esphome::modbus::testing From 5c9d050ebe2ef415484e2c3dc1de61cb26f6b09a Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:02:54 +0000 Subject: [PATCH 1550/1815] Bump aioesphomeapi from 45.10.3 to 45.11.0 (#18493) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a986646230..3d25440671 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.10.3 +aioesphomeapi==45.11.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From 804e8fb856ce5a56a1dd1216f3f3e38273d8b4df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 15:44:38 -0500 Subject: [PATCH 1551/1815] [socket] Remove constant duplicated by the beta merge (#18496) --- esphome/components/socket/lwip_raw_tcp_impl.cpp | 5 ----- 1 file changed, 5 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index b20f79fba1..8d00dbede2 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -50,11 +50,6 @@ static const char *const TAG = "socket"; static constexpr uint32_t ESP8266_YIELD_INTERVAL_US = 1000; #endif -#ifdef USE_ESP8266 -// optimistic_yield() rate limit in microseconds of CONT time; cheap when hot. -static constexpr uint32_t ESP8266_YIELD_INTERVAL_US = 1000; -#endif - // set to 1 to enable verbose lwip logging #if 0 // NOLINT(readability-avoid-unconditional-preprocessor-if) #define LWIP_LOG(msg, ...) ESP_LOGVV(TAG, "socket %p: " msg, this, ##__VA_ARGS__) From 0a88c81d95897db3504aca4e623c8fe27daa9a76 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:38:23 -0500 Subject: [PATCH 1552/1815] Bump aioesphomeapi from 45.11.0 to 45.12.0 (#18501) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3d25440671..e4521859e7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.11.0 +aioesphomeapi==45.12.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From 7a999f9a48f89d9d6561ed5cd46853d5c64f9828 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 19:06:55 -0500 Subject: [PATCH 1553/1815] [ci] Install requirements_dev.txt when the venv cache misses (#18502) --- .github/actions/restore-python/action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 6279a26dc4..ce14b0152a 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -49,7 +49,7 @@ runs: python -m venv venv source venv/bin/activate python --version - uv pip install -r requirements.txt -r requirements_test.txt + uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt uv pip install -e . - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os == 'Windows' @@ -58,5 +58,5 @@ runs: python -m venv venv source ./venv/Scripts/activate python --version - uv pip install -r requirements.txt -r requirements_test.txt + uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt uv pip install -e . From 4f866c563b5721a1a9ed1225b6b4fe50ef4f2637 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 19:36:57 -0500 Subject: [PATCH 1554/1815] [platformio] Give the ccache wrapper a cmd.exe safe path (#18495) --- esphome/platformio/ccache.py.script | 9 +- esphome/platformio/toolchain.py | 63 +++-- tests/unit_tests/test_platformio_toolchain.py | 240 +++++++++++++++++- 3 files changed, 286 insertions(+), 26 deletions(-) diff --git a/esphome/platformio/ccache.py.script b/esphome/platformio/ccache.py.script index cc08a8c044..22592a2398 100644 --- a/esphome/platformio/ccache.py.script +++ b/esphome/platformio/ccache.py.script @@ -1,5 +1,4 @@ import os -import shutil # pylint: disable=E0602 Import("env") # noqa @@ -9,15 +8,17 @@ Import("env") # noqa # esphome/platformio/toolchain.py); this script only supplies the SCons-level # mechanism. # +# The binary comes pre-resolved in ESPHOME_CCACHE_PATH; _ccache_env() has +# already stripped the Windows \\?\ prefix that cmd.exe cannot run. +# # This is a "pre" script, so the platform's builder (which sets CC/CXX and # clones the construction environment for framework and library builds) runs # after it. Replacing CC/CXX here would be overwritten, and replacing them in # a "post" script would miss the already-cloned library environments. Wrapping # SPAWN instead is ordering-proof: clones copy the wrapper, and every compiler # invocation from every environment funnels through it at execution time. -if ( - os.environ.get("ESPHOME_CCACHE_ENABLE") == "1" - and (ccache_path := shutil.which("ccache")) is not None +if os.environ.get("ESPHOME_CCACHE_ENABLE") == "1" and ( + ccache_path := os.environ.get("ESPHOME_CCACHE_PATH") ): original_spawn = env["SPAWN"] diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index 08a4fcff78..d76581d032 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -60,6 +60,9 @@ def _strip_win_long_path_prefix(path: str) -> str: "The system cannot find the path specified." Stripping the prefix early keeps the path shell-quotable. + Also applied to the ccache path exported by ``_ccache_env()``, which + ``shutil.which`` can return with the same prefix. + No-op on non-Windows platforms. """ if sys.platform != "win32": @@ -235,8 +238,8 @@ def _check_platformio_python_stamp(config: "ProjectConfig") -> None: _write_pio_stamp_python(stamp_file, current) -def _ccache_usable() -> bool: - """Return True when the ``ccache`` on PATH actually runs. +def _ccache_runs(ccache: str) -> bool: + """Return True when the ``ccache`` found on PATH actually runs. ``shutil.which`` proves existence, not runnability: on Windows it also matches ``.bat``/``.cmd`` wrappers and stale package-manager shims whose @@ -244,9 +247,6 @@ def _ccache_usable() -> bool: step with an opaque OS error, so probe once and fall back to compiling without ccache when the probe fails. """ - ccache = shutil.which("ccache") - if ccache is None: - return False try: subprocess.run( [ccache, "--version"], @@ -265,14 +265,29 @@ def _ccache_usable() -> bool: def _ccache_env() -> dict[str, str]: - """Return ccache settings for PlatformIO builds. + r"""Return ccache settings for PlatformIO builds. Enabled by default whenever the ``ccache`` binary is on PATH; set ``ESPHOME_CCACHE_ENABLE=0`` in the environment to opt out (or ``1`` to - force it on). The decision is normalized into ``ESPHOME_CCACHE_ENABLE`` - so platform build scripts (e.g. the esp8266 ``ccache.py`` extra script, - which wraps compiler invocations inside SCons) only have to check for - ``"1"`` instead of re-implementing the policy. + force it on without the runnability probe; a binary is still needed). + The decision is normalized into ``ESPHOME_CCACHE_ENABLE`` and the + binary's location into ``ESPHOME_CCACHE_PATH`` so platform build scripts + (the shared ``ccache.py`` extra script, which wraps compiler invocations + inside SCons) only have to check for ``"1"`` and use the path as given + instead of re-implementing the policy. + + The path is exported rather than looked up again inside SCons because + ``shutil.which`` can return a Windows extended-length ``\\?\`` path + (ESPHome Desktop puts its bundled ccache on PATH that way). Such a path + runs fine through ``CreateProcess``, which is how ESP-IDF invokes it, + but SCons runs every compile through ``cmd.exe``, which fails on it with + "The system cannot find the path specified." (#18399), so the prefix is + stripped here with ``_strip_win_long_path_prefix()`` before the + runnability probe, which therefore validates the exact string the build + will execute. + ``ESPHOME_CCACHE_PATH`` is an internal channel, not a user setting: the + script only honours it together with ``ESPHOME_CCACHE_ENABLE=1``, and this + function always sets both or neither. The returned values are merged into the environment of the PlatformIO subprocess only, never into ``os.environ``: a long-running process @@ -293,13 +308,27 @@ def _ccache_env() -> dict[str, str]: build dir. The other ``CCACHE_*`` values the user already set in the environment are respected. """ - if "ESPHOME_CCACHE_ENABLE" in os.environ: - enabled = get_bool_env("ESPHOME_CCACHE_ENABLE") - else: - enabled = _ccache_usable() - env = {"ESPHOME_CCACHE_ENABLE": "1" if enabled else "0"} - if not enabled: - return env + explicit = "ESPHOME_CCACHE_ENABLE" in os.environ + if explicit and not get_bool_env("ESPHOME_CCACHE_ENABLE"): + return {"ESPHOME_CCACHE_ENABLE": "0"} + ccache_path = shutil.which("ccache") + if ccache_path is None: + if explicit: + _LOGGER.warning( + "ESPHOME_CCACHE_ENABLE is set but no ccache binary is on PATH; " + "compiling without ccache" + ) + return {"ESPHOME_CCACHE_ENABLE": "0"} + # Strip before probing so the probe validates (and the failure warning + # names) the exact string the build will execute through cmd.exe. + ccache_path = _strip_win_long_path_prefix(ccache_path) + # An explicit opt-in skips the runnability probe. + if not explicit and not _ccache_runs(ccache_path): + return {"ESPHOME_CCACHE_ENABLE": "0"} + env = { + "ESPHOME_CCACHE_ENABLE": "1", + "ESPHOME_CCACHE_PATH": ccache_path, + } # build_path is set during preload for every config-loading command, so it # being unset means a caller built the environment too early; fail loudly # rather than with an opaque TypeError from Path(None). diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index eebb0b8cd7..172b288c25 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -2,7 +2,7 @@ # pylint: disable=protected-access -from collections.abc import Generator +from collections.abc import Callable, Generator from contextlib import contextmanager from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json @@ -437,6 +437,7 @@ def test_ccache_env_enabled_by_default(setup_core: Path) -> None: env = toolchain._ccache_env() assert env["ESPHOME_CCACHE_ENABLE"] == "1" + assert env["ESPHOME_CCACHE_PATH"] == "/usr/bin/ccache" assert env["CCACHE_BASEDIR"] == str((setup_core / "build" / "test").resolve()) assert env["CCACHE_DIR"].endswith("platformio-ccache") assert env["CCACHE_NOHASHDIR"] == "true" @@ -446,17 +447,35 @@ def test_ccache_env_enabled_by_default(setup_core: Path) -> None: assert "ESPHOME_CCACHE_ENABLE" not in os.environ -def test_ccache_env_disabled_without_binary(setup_core: Path) -> None: - """Ccache stays off when the binary is not on PATH.""" +@pytest.mark.parametrize( + ("env_vars", "expect_warning"), + [ + pytest.param({}, False, id="default"), + pytest.param({"ESPHOME_CCACHE_ENABLE": "1"}, True, id="forced-on"), + ], +) +def test_ccache_env_disabled_without_binary( + setup_core: Path, + caplog: pytest.LogCaptureFixture, + env_vars: dict[str, str], + expect_warning: bool, +) -> None: + """Ccache stays off when the binary is not on PATH, even when forced on. + + A deliberate opt-in that finds no binary is downgraded with a warning so + the user can tell why it had no effect; the default path stays quiet. + """ CORE.build_path = setup_core / "build" / "test" with ( - patch.dict(os.environ, {}, clear=True), + patch.dict(os.environ, env_vars, clear=True), patch.object(toolchain.shutil, "which", return_value=None), + caplog.at_level("WARNING"), ): env = toolchain._ccache_env() assert env == {"ESPHOME_CCACHE_ENABLE": "0"} + assert ("no ccache binary is on PATH" in caplog.text) is expect_warning @pytest.mark.parametrize( @@ -489,14 +508,47 @@ def test_ccache_env_forced_on_skips_probe(setup_core: Path) -> None: with ( patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True), + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), patch.object(toolchain.subprocess, "run") as mock_probe, ): env = toolchain._ccache_env() assert env["ESPHOME_CCACHE_ENABLE"] == "1" + # The binary's location is still handed to the build script. + assert env["ESPHOME_CCACHE_PATH"] == "/usr/bin/ccache" mock_probe.assert_not_called() +def test_ccache_env_strips_win_long_path_prefix(setup_core: Path) -> None: + r"""A ``\\?\`` ccache path from PATH is exported without the prefix. + + That is the shape ESPHome Desktop puts on PATH (#18399); see ``_ccache_env``. + """ + CORE.build_path = setup_core / "build" / "test" + prefixed = ( + "\\\\?\\C:\\Users\\jesse\\AppData\\Local\\ESPHome Device Builder" + "\\ccache\\ccache.exe" + ) + stripped = ( + "C:\\Users\\jesse\\AppData\\Local\\ESPHome Device Builder\\ccache\\ccache.exe" + ) + + with ( + patch.dict(os.environ, {}, clear=True), + # shutil.which is patched, so the win32 code path of the real + # implementation (which crashes on a POSIX host) is never reached. + patch("esphome.platformio.toolchain.sys.platform", "win32"), + patch.object(toolchain.shutil, "which", return_value=prefixed), + patch.object(toolchain.subprocess, "run") as mock_probe, + ): + env = toolchain._ccache_env() + + assert env["ESPHOME_CCACHE_ENABLE"] == "1" + assert env["ESPHOME_CCACHE_PATH"] == stripped + # The probe validates the exact string the build will execute. + assert mock_probe.call_args[0][0] == [stripped, "--version"] + + def test_ccache_env_opt_out(setup_core: Path) -> None: """ESPHOME_CCACHE_ENABLE=0 disables ccache even with the binary present.""" CORE.build_path = setup_core / "build" / "test" @@ -516,7 +568,7 @@ def test_ccache_env_normalizes_enable_value(setup_core: Path) -> None: with ( patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "yes"}, clear=True), - patch.object(toolchain.shutil, "which", return_value=None), + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), ): env = toolchain._ccache_env() @@ -563,8 +615,10 @@ def test_run_platformio_cli_passes_ccache_env_to_subprocess_only( env = mock_run_external_process.call_args[1]["env"] assert env["ESPHOME_CCACHE_ENABLE"] == "1" + assert env["ESPHOME_CCACHE_PATH"] == "/usr/bin/ccache" assert env["CCACHE_BASEDIR"] == str((setup_core / "build" / "test").resolve()) assert "ESPHOME_CCACHE_ENABLE" not in os.environ + assert "ESPHOME_CCACHE_PATH" not in os.environ assert "CCACHE_BASEDIR" not in os.environ @@ -613,6 +667,182 @@ def test_copy_ccache_script(setup_core: Path) -> None: assert dest.read_text() == source.read_text() +class _FakeSConsEnv(dict): + """Just enough of a SCons construction environment for ccache.py.""" + + def Replace(self, **kwargs: object) -> None: # noqa: N802 + self.update(kwargs) + + +def _load_ccache_script( + env_vars: dict[str, str], original_spawn: Callable[..., int] | None = None +) -> tuple[_FakeSConsEnv, Callable[..., int]]: + """Run ccache.py.script against a fake SCons env and return (env, original SPAWN).""" + if original_spawn is None: + original_spawn = Mock(name="original_spawn", return_value=0) + scons_env = _FakeSConsEnv(SPAWN=original_spawn) + source = (Path(toolchain.__file__).parent / "ccache.py.script").read_text() + with patch.dict(os.environ, env_vars, clear=True): + exec( # noqa: S102 + compile(source, "ccache.py", "exec"), + {"Import": lambda *_names: None, "env": scons_env}, + ) + return scons_env, original_spawn + + +def _scons_win32_escape(x: str) -> str: + """Copy of ``SCons.Platform.win32.escape``: quote, guarding a trailing backslash.""" + if x[-1] == "\\": + x = x + "\\" + return '"' + x + '"' + + +def test_ccache_script_wraps_compiles_with_exported_path() -> None: + """The SCons script uses ESPHOME_CCACHE_PATH as given, without a PATH lookup.""" + ccache_path = "C:\\Users\\jesse\\ESPHome Device Builder\\ccache\\ccache.exe" + scons_env, original_spawn = _load_ccache_script( + {"ESPHOME_CCACHE_ENABLE": "1", "ESPHOME_CCACHE_PATH": ccache_path} + ) + spawn = scons_env["SPAWN"] + assert spawn is not original_spawn + + # A compile step is routed through ccache, with the same path used for + # the program and (escaped) as the first argument. + compile_args = ["xtensa-lx106-elf-g++", "-o", "main.o", "-c", "main.cpp"] + spawn("cmd.exe", _scons_win32_escape, "xtensa-lx106-elf-g++", compile_args, {}) + original_spawn.assert_called_once_with( + "cmd.exe", + _scons_win32_escape, + ccache_path, + [_scons_win32_escape(ccache_path), *compile_args], + {}, + ) + + # Link steps pass through untouched. + original_spawn.reset_mock() + link_args = ["xtensa-lx106-elf-g++", "-o", "firmware.elf", "main.o"] + spawn("cmd.exe", _scons_win32_escape, "xtensa-lx106-elf-g++", link_args, {}) + original_spawn.assert_called_once_with( + "cmd.exe", _scons_win32_escape, "xtensa-lx106-elf-g++", link_args, {} + ) + + +@pytest.mark.parametrize( + "env_vars", + [ + pytest.param({"ESPHOME_CCACHE_ENABLE": "0"}, id="disabled"), + pytest.param({"ESPHOME_CCACHE_ENABLE": "1"}, id="enabled-without-path"), + pytest.param({}, id="unset"), + ], +) +def test_ccache_script_leaves_spawn_alone_without_path( + env_vars: dict[str, str], +) -> None: + """Without both the enable flag and a path, SPAWN is not replaced.""" + scons_env, original_spawn = _load_ccache_script(env_vars) + assert scons_env["SPAWN"] is original_spawn + + +def _scons_win32_spawn( + sh: str, escape: Callable[[str], str], cmd: str, args: list[str], env: dict +) -> int: + r"""Mirror of ``SCons.Platform.win32.spawn``: every command runs via ``cmd.exe /C``. + + SCons is not importable in the test environment (PlatformIO fetches it at + build time), so the lines that matter are mirrored here. The command line + SCons hands ``os.spawnve`` goes to ``CreateProcess`` via ``subprocess`` + instead (identical on Windows, where a string passes through untouched); + ``spawnve`` itself crashes inside pytest. + """ + return subprocess.run( + " ".join([sh, "/C", escape(" ".join(args))]), env=env, check=False + ).returncode + + +_MARKER_ENV = "ESPHOME_TEST_CCACHE_MARKER" +# Stands in for a compile: the "ccache" is really the Python interpreter, and +# the compile "flags" make it write a marker file so the test can tell whether +# the wrapped command actually ran to completion. +_FAKE_COMPILE_ARGS = [ + "-c", + f"import os, pathlib; pathlib.Path(os.environ['{_MARKER_ENV}']).write_text('compiled')", +] + + +def _spawn_fake_compile_via_cmd_exe(scons_env: _FakeSConsEnv, marker: Path) -> int: + """Run one wrapped compile step the way SCons does on Windows.""" + child_env = {**os.environ, _MARKER_ENV: str(marker)} + return scons_env["SPAWN"]( + os.environ.get("COMSPEC", "cmd.exe"), + _scons_win32_escape, + "xtensa-lx106-elf-gcc", + [_scons_win32_escape(arg) if " " in arg else arg for arg in _FAKE_COMPILE_ARGS], + child_env, + ) + + +_WINDOWS_ONLY = pytest.mark.skipif( + sys.platform != "win32", reason="drives cmd.exe, which SCons uses only on Windows" +) + + +@_WINDOWS_ONLY +def test_ccache_env_real_probe_runs_stripped_path(setup_core: Path) -> None: + r"""With a ``\\?\`` which result, the real probe runs the stripped binary. + + The probe therefore validates the exact string the build will execute + through ``cmd.exe``; probing the verbatim path instead would pass even + when the stripped path is unusable (``CreateProcess`` accepts + extended-length paths, ``cmd.exe`` does not). + """ + CORE.build_path = setup_core / "build" / "test" + assert not sys.executable.startswith("\\\\?\\") + + with ( + patch.dict(os.environ, {}, clear=False), + patch.object( + toolchain.shutil, "which", return_value="\\\\?\\" + sys.executable + ), + ): + os.environ.pop("ESPHOME_CCACHE_ENABLE", None) + env = toolchain._ccache_env() + + assert env["ESPHOME_CCACHE_ENABLE"] == "1" + assert env["ESPHOME_CCACHE_PATH"] == sys.executable + + +@_WINDOWS_ONLY +@pytest.mark.parametrize( + ("prefix", "expect_ok"), + [ + pytest.param("", True, id="stripped-path-compiles"), + pytest.param("\\\\?\\", False, id="verbatim-path-fails"), + ], +) +def test_ccache_wrapper_through_cmd_exe( + tmp_path: Path, prefix: str, expect_ok: bool +) -> None: + r"""End to end through ``cmd.exe``: the exported path works, a ``\\?\`` one does not. + + The interpreter stands in for ccache; the spawn mirrors SCons on Windows. + The failing case is the mechanism behind #18399 ("The system cannot find + the path specified." on every compile step); should it ever start passing, + ``cmd.exe`` learned extended-length paths and the strip is no longer needed. + """ + marker = tmp_path / "compiled.txt" + scons_env, _ = _load_ccache_script( + {"ESPHOME_CCACHE_ENABLE": "1", "ESPHOME_CCACHE_PATH": prefix + sys.executable}, + original_spawn=_scons_win32_spawn, + ) + assert scons_env["SPAWN"] is not _scons_win32_spawn + + rc = _spawn_fake_compile_via_cmd_exe(scons_env, marker) + assert (rc == 0) is expect_ok + assert marker.exists() is expect_ok + if expect_ok: + assert marker.read_text() == "compiled" + + @pytest.mark.parametrize( ("platform", "input_path", "expected"), [ From 8b637b339b109ceaab298dad6f748a4671afd420 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 19:37:10 -0500 Subject: [PATCH 1555/1815] [vscode] Report the origin of an unexpected exception during validation (#18494) --- esphome/vscode.py | 20 +++++++++- tests/unit_tests/test_vscode.py | 66 +++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/esphome/vscode.py b/esphome/vscode.py index f404f02f00..ba7b4e727b 100644 --- a/esphome/vscode.py +++ b/esphome/vscode.py @@ -3,12 +3,14 @@ from __future__ import annotations from io import StringIO import json from pathlib import Path +import sys +import traceback from typing import Any from esphome.config import Config, _format_vol_invalid, validate_config import esphome.config_validation as cv from esphome.const import __version__ as ESPHOME_VERSION -from esphome.core import CORE, DocumentRange +from esphome.core import CORE, DocumentRange, EsphomeError from esphome.yaml_util import parse_yaml @@ -97,6 +99,16 @@ def _ace_loader(fname: Path) -> dict[str, Any]: return parse_yaml(fname, raw_yaml_stream) +def _format_unexpected_error(err: Exception) -> str: + """Describe a crash inside validation with the frame it came from.""" + message = f"Unexpected error while validating: {type(err).__name__}: {err}" + frames = traceback.extract_tb(err.__traceback__) + if not frames: + return message + frame = frames[-1] + return f"{message} ({frame.filename}:{frame.lineno} in {frame.name})" + + def _print_version(): """Print ESPHome version.""" print( @@ -134,8 +146,12 @@ def read_config(args): try: config = loader(file_name) res = validate_config(config, command_line_substitutions) - except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + except (EsphomeError, cv.Invalid) as err: vs.add_yaml_error(str(err)) + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + # stdout carries the JSON protocol; the full chain goes to stderr. + traceback.print_exc(file=sys.stderr) + vs.add_yaml_error(_format_unexpected_error(err)) else: for err in res.errors: try: diff --git a/tests/unit_tests/test_vscode.py b/tests/unit_tests/test_vscode.py index 63bdf3e255..9b7d1e9504 100644 --- a/tests/unit_tests/test_vscode.py +++ b/tests/unit_tests/test_vscode.py @@ -3,6 +3,8 @@ from pathlib import Path from unittest.mock import Mock, patch from esphome import vscode +import esphome.config_validation as cv +from esphome.core import EsphomeError def _run_repl_test(input_data): @@ -126,3 +128,67 @@ packages: assert range["start_col"] == 2 assert range["end_line"] == 1 assert range["end_col"] == 7 + + +def _explode(*_args: object, **_kwargs: object) -> None: + raise AttributeError("'NoneType' object has no attribute 'get'") + + +def test_unexpected_error_reports_origin() -> None: + source_path = str(Path("dir_path", "x.yaml")) + with patch("esphome.vscode.validate_config", _explode): + output_lines = _run_repl_test( + [ + _validate(source_path), + _file_response("""esphome: + name: test1 +"""), + ] + ) + + result = json.loads(output_lines[-1]) + assert result["validation_errors"] == [] + (error,) = result["yaml_errors"] + assert error["message"].startswith( + "Unexpected error while validating: AttributeError: " + "'NoneType' object has no attribute 'get' (" + ) + assert "test_vscode.py" in error["message"] + assert error["message"].endswith(" in _explode)") + + +def test_esphome_error_stays_plain() -> None: + source_path = str(Path("dir_path", "x.yaml")) + with patch("esphome.vscode.validate_config", side_effect=EsphomeError("boom")): + output_lines = _run_repl_test( + [ + _validate(source_path), + _file_response("""esphome: + name: test1 +"""), + ] + ) + + result = json.loads(output_lines[-1]) + assert result["yaml_errors"] == [{"message": "boom"}] + + +def test_invalid_stays_plain() -> None: + source_path = str(Path("dir_path", "x.yaml")) + with patch("esphome.vscode.validate_config", side_effect=cv.Invalid("bad value")): + output_lines = _run_repl_test( + [ + _validate(source_path), + _file_response("""esphome: + name: test1 +"""), + ] + ) + + result = json.loads(output_lines[-1]) + assert result["yaml_errors"] == [{"message": "bad value"}] + + +def test_format_unexpected_error_without_traceback() -> None: + message = vscode._format_unexpected_error(ValueError("boom")) + assert message == "Unexpected error while validating: ValueError: boom" From 8aa7db15e52e7842edb4e0ce634c58028b20f4fa Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:25:09 -0400 Subject: [PATCH 1556/1815] [esp32] Fix ESP32-P4 bootloop on rev3 (v3.x) chips when only variant is set (#18500) --- esphome/components/esp32/__init__.py | 78 +++++++++++++++------------- 1 file changed, 41 insertions(+), 37 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 7d43c3ac07..3065cdadad 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1073,6 +1073,26 @@ def _parse_pio_platform_version(value): return value +def _normalize_p4_engineering_sample(value: ConfigType) -> bool: + """Fill in CONF_ENGINEERING_SAMPLE when unset, warning that production + silicon (rev3) is assumed. Returns the normalized flag.""" + if (engineering_sample := value.get(CONF_ENGINEERING_SAMPLE)) is None: + _LOGGER.warning( + "Defaulting to ESP32-P4 production silicon (rev3).\n" + "If you have an early engineering sample (pre-rev3), add this to your config:\n" + "\n" + " esp32:\n" + " engineering_sample: true\n" + "\n" + "To check your chip revision, look for 'chip revision: vX.Y' in the boot log.\n" + "Engineering samples will show a revision below v3.0.\n" + "The 'debug:' component also reports the revision (e.g. Revision: 100 = v1.0, 300 = v3.0)." + ) + engineering_sample = False + value[CONF_ENGINEERING_SAMPLE] = engineering_sample + return engineering_sample + + def _detect_variant(value): board = value.get(CONF_BOARD) variant = value.get(CONF_VARIANT) @@ -1085,6 +1105,8 @@ def _detect_variant(value): # name rather than carrying a PIO board name through the IDF build. if CORE.using_toolchain_esp_idf: value = value.copy() + if variant == VARIANT_ESP32P4: + _normalize_p4_engineering_sample(value) value[CONF_BOARD] = VARIANT_FRIENDLY[variant].lower() return value if variant not in STANDARD_BOARDS: @@ -1095,22 +1117,8 @@ def _detect_variant(value): ) value = value.copy() value[CONF_BOARD] = STANDARD_BOARDS[variant] - if variant == VARIANT_ESP32P4: - engineering_sample = value.get(CONF_ENGINEERING_SAMPLE) - if engineering_sample is None: - _LOGGER.warning( - "No board specified for ESP32-P4. Defaulting to production silicon (rev3).\n" - "If you have an early engineering sample (pre-rev3), add this to your config:\n" - "\n" - " esp32:\n" - " engineering_sample: true\n" - "\n" - "To check your chip revision, look for 'chip revision: vX.Y' in the boot log.\n" - "Engineering samples will show a revision below v3.0.\n" - "The 'debug:' component also reports the revision (e.g. Revision: 100 = v1.0, 300 = v3.0)." - ) - elif engineering_sample: - value[CONF_BOARD] = "esp32-p4-evboard" + if variant == VARIANT_ESP32P4 and _normalize_p4_engineering_sample(value): + value[CONF_BOARD] = "esp32-p4-evboard" elif board in BOARDS: variant = variant or BOARDS[board][KEY_VARIANT] if variant != BOARDS[board][KEY_VARIANT]: @@ -1120,6 +1128,14 @@ def _detect_variant(value): ) value = value.copy() value[CONF_VARIANT] = variant + if variant == VARIANT_ESP32P4: + board_is_es = BOARDS[board].get("engineering_sample", False) + engineering_sample = value.setdefault(CONF_ENGINEERING_SAMPLE, board_is_es) + if engineering_sample != board_is_es: + raise cv.Invalid( + f"'{CONF_ENGINEERING_SAMPLE}' does not match board '{board}'", + path=[CONF_ENGINEERING_SAMPLE], + ) elif not variant: raise cv.Invalid( "This board is unknown, if you are sure you want to compile with this board selection, " @@ -1131,6 +1147,9 @@ def _detect_variant(value): "This board is unknown; the specified variant '%s' will be used but this may not work as expected.", variant, ) + if variant == VARIANT_ESP32P4: + value = value.copy() + _normalize_p4_engineering_sample(value) return value @@ -1434,20 +1453,6 @@ def final_validate(config) -> None: path=[CONF_ENGINEERING_SAMPLE], ) ) - if ( - config[CONF_VARIANT] == VARIANT_ESP32P4 - and config.get(CONF_ENGINEERING_SAMPLE) is not None - ): - board_is_es = BOARDS.get(config[CONF_BOARD], {}).get( - "engineering_sample", False - ) - if config[CONF_ENGINEERING_SAMPLE] != board_is_es: - errs.append( - cv.Invalid( - f"'{CONF_ENGINEERING_SAMPLE}' does not match board '{config[CONF_BOARD]}'", - path=[CONF_ENGINEERING_SAMPLE], - ) - ) if advanced[CONF_EXECUTE_FROM_PSRAM]: if config[CONF_VARIANT] not in {VARIANT_ESP32S3, VARIANT_ESP32P4}: errs.append( @@ -2518,15 +2523,14 @@ async def to_code(config): f"CONFIG_ESPTOOLPY_FLASHFREQ_{flash_frequency[:-3]}M", True ) - # ESP32-P4: ESP-IDF 5.5.3 changed the default of ESP32P4_SELECTS_REV_LESS_V3 - # from y to n. PlatformIO uses sections.ld.in (for rev <3) or - # sections.rev3.ld.in (for rev >=3) based on board definition. - # Set the sdkconfig option to match the board's chip revision. + # ESP32-P4: pre-v3 and rev3 (v3.0+) silicon are not binary compatible. + # CONFIG_ESP32P4_SELECTS_REV_LESS_V3 selects which layout ESP-IDF links; + # validation normalizes CONF_ENGINEERING_SAMPLE from the board when unset. if variant == VARIANT_ESP32P4: - is_eng_sample = BOARDS.get(config[CONF_BOARD], {}).get( - "engineering_sample", False + add_idf_sdkconfig_option( + "CONFIG_ESP32P4_SELECTS_REV_LESS_V3", + config.get(CONF_ENGINEERING_SAMPLE, False), ) - add_idf_sdkconfig_option("CONFIG_ESP32P4_SELECTS_REV_LESS_V3", is_eng_sample) # Set minimum chip revision for ESP32 variant # Setting this to 3.0 or higher reduces flash size by excluding workaround code, From 07fa16e2e74f9964da91147028b630a7436b5d0e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:38:18 -0400 Subject: [PATCH 1557/1815] [ci] Stop persisting the integration test ccache (#18504) --- .github/workflows/ci.yml | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c81c783b5..c3f830a5aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -341,18 +341,6 @@ jobs: run: | sudo apt-get update -qq sudo apt-get install -y --no-install-recommends ccache - - name: Restore ccache (restore-only) - # esphome stores the PlatformIO ccache under the machine-global cache - # dir (see _ccache_env() in esphome/platformio/toolchain.py). The - # bucket-name prefix prefers a same-bucket seed; the bare prefix falls - # back to any seed when the bucket layout differs from dev. - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.cache/esphome/platformio-ccache - key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }} - restore-keys: | - integration-ccache-${{ matrix.bucket.name }}- - integration-ccache- - name: Set up Python 3.13 id: python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -401,14 +389,6 @@ jobs: # esphome stores the PlatformIO ccache under the machine-global cache # dir (see _ccache_env() in esphome/platformio/toolchain.py). run: CCACHE_DIR="$HOME/.cache/esphome/platformio-ccache" ccache -s - - name: Save ccache - # Pull request saves land in per-PR scopes nothing else can reuse; - # dev pushes seed the shared copy instead. - if: github.event_name != 'pull_request' - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.cache/esphome/platformio-ccache - key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }} import-time: name: Check import esphome.__main__ time From b7121940c85ca166fd344d5c6b3c37f49e228a24 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:08:23 -0400 Subject: [PATCH 1558/1815] Update wheel requirement from <0.48,>=0.43 to >=0.43,<0.49 (#18459) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index afa6208cae..3185fe0a9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools==84.0.0", "wheel>=0.43,<0.48"] +requires = ["setuptools==84.0.0", "wheel>=0.43,<0.49"] build-backend = "setuptools.build_meta" [project] From 17eed7055bf516797478e3c12724758e05e8f94c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:22:51 -0400 Subject: [PATCH 1559/1815] Bump resvg-py from 0.3.4 to 0.4.0 (#18460) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e4521859e7..740a8c1a79 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,7 +20,7 @@ ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 pillow==12.3.0 -resvg-py==0.3.4 +resvg-py==0.4.0 freetype-py==2.5.1 jinja2==3.1.6 bleak==3.0.2 From b7cc271219467909b28f81f244710bc06b81f79f Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:57:51 -0500 Subject: [PATCH 1560/1815] Bump bundled esphome-device-builder to 1.11.3 (#18505) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 4a8daeaaf6..50b698224c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.2 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.3 RUN \ platformio settings set enable_telemetry No \ From d2c3f749abb87fdc4f7740ef5f05b4d33772de77 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:24:23 -0500 Subject: [PATCH 1561/1815] Bump bundled esphome-device-builder to 1.11.4 (#18506) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 50b698224c..5c21e07618 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.3 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.4 RUN \ platformio settings set enable_telemetry No \ From e26237e57d66ea9d8e064323bd9d12a83790499f Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:42:05 -0500 Subject: [PATCH 1562/1815] Bump bundled esphome-device-builder to 1.11.5 (#18507) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 5c21e07618..1be10db3af 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.4 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.5 RUN \ platformio settings set enable_telemetry No \ From f90b7760714a96a396bcda158bd7465b65888b69 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 19 Aug 2026 11:39:01 -0500 Subject: [PATCH 1563/1815] [ci] Key PlatformIO cache on the Python version so a runner image bump does not serve a broken LibreTiny venv (#18512) --- .github/workflows/ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3f830a5aa..6afb8a9d22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -545,24 +545,29 @@ jobs: fetch-depth: 2 - name: Restore Python + id: restore-python uses: ./.github/actions/restore-python with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} + # Key on the exact Python version as well: LibreTiny creates a venv under + # ~/.platformio/penv whose interpreter is a symlink into the runner's + # hosted toolcache, so a cache saved on an older runner image breaks once + # a new image ships a newer patch release and drops the old interpreter. - name: Cache platformio if: github.ref == 'refs/heads/dev' && matrix.pio_cache_key uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio - key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} + key: platformio-${{ matrix.pio_cache_key }}-${{ steps.restore-python.outputs.python-version }}-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' && matrix.pio_cache_key uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio - key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} + key: platformio-${{ matrix.pio_cache_key }}-${{ steps.restore-python.outputs.python-version }}-${{ hashFiles('platformio.ini') }} - name: Cache ESP-IDF install if: matrix.cache_idf From 7b7107556f4637c157a6bbdbae5bbd80cbd5f3ee Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:21:58 -0500 Subject: [PATCH 1564/1815] Bump bundled esphome-device-builder to 1.12.0 (#18514) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 1be10db3af..18f705b501 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.5 +RUN uv pip install --no-cache-dir esphome-device-builder==1.12.0 RUN \ platformio settings set enable_telemetry No \ From 470226ca03dfee7b8b6d08589a15a237bba12499 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:30:12 -0700 Subject: [PATCH 1565/1815] [image] Restore defaults:/files: support for platform entries (#18032) Co-authored-by: Claude Sonnet 5 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/file/image.py | 3 +- esphome/components/image/__init__.py | 152 +++++++- esphome/components/runtime_image/__init__.py | 5 +- esphome/config.py | 17 + esphome/loader.py | 8 + tests/component_tests/image/test_init.py | 328 +++++++++++++++++- .../validate-platform-defaults.host.yaml | 21 ++ .../validate-platform-defaults.host.yaml | 24 ++ tests/unit_tests/test_config_normalization.py | 124 ++++++- 9 files changed, 657 insertions(+), 25 deletions(-) create mode 100644 tests/components/animation/validate-platform-defaults.host.yaml create mode 100644 tests/components/image/validate-platform-defaults.host.yaml diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index d340d21490..feced063d0 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -23,6 +23,7 @@ from esphome.components.image import ( get_image_type_enum, get_transparency_enum, is_svg_file, + validate_byte_order, validate_settings, validate_transparency, validate_type, @@ -200,7 +201,7 @@ OPTIONS_SCHEMA = { "NONE", "FLOYDSTEINBERG", upper=True ), cv.Optional(CONF_INVERT_ALPHA, default=False): cv.boolean, - cv.Optional(CONF_BYTE_ORDER): cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True), + cv.Optional(CONF_BYTE_ORDER): validate_byte_order, cv.Optional(CONF_TRANSPARENCY, default=CONF_OPAQUE): validate_transparency(), } diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 37a9afb84d..eaee31a1c7 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -10,7 +10,14 @@ from PIL import Image, UnidentifiedImageError import esphome.codegen as cg from esphome.components.const import CONF_BYTE_ORDER, KEY_METADATA import esphome.config_validation as cv -from esphome.const import CONF_DEFAULTS, CONF_FILE, CONF_ID, CONF_PLATFORM, CONF_TYPE +from esphome.const import ( + CONF_DEFAULTS, + CONF_FILE, + CONF_FILES, + CONF_ID, + CONF_PLATFORM, + CONF_TYPE, +) from esphome.core import CORE from esphome.types import ConfigType @@ -48,6 +55,9 @@ TRANSPARENCY_TYPES = ( CONF_ALPHA_CHANNEL, ) +# Shared validator for the image platform schemas and `_drop_incompatible_byte_order`. +validate_byte_order = cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True) + def get_image_type_enum(type): return getattr(ImageType, f"IMAGE_TYPE_{type.upper()}") @@ -404,6 +414,120 @@ def get_image_metadata(image_id: str) -> ImageMetaData | None: return get_all_image_metadata().get(image_id) +# --------------------------------------------------------------------------- +# `defaults:`/`files:` expansion: a `platform:` entry merges shared `defaults:` +# into every `files:` entry; the platform's CONFIG_SCHEMA validates each. +# Permanent, unlike the legacy migration below. +# --------------------------------------------------------------------------- + + +def _drop_incompatible_byte_order( + merged: dict, explicit: dict, *, index: int | None = None +) -> dict: + """Drop `byte_order` when the resolved type doesn't support it, unless written directly on `explicit`. + + With `index`, inherited values are validated before being dropped (the legacy flattener always drops). + """ + if CONF_BYTE_ORDER in explicit: + return merged + type_class = IMAGE_TYPE.get(str(merged.get(CONF_TYPE, "")).upper()) + if ( + CONF_BYTE_ORDER in merged + and isinstance(type_class, type) + and issubclass(type_class, ImageEncoder) + and not type_class.is_endian() + ): + if index is not None: + try: + validate_byte_order(merged[CONF_BYTE_ORDER]) + except cv.Invalid as exc: + exc.prepend([index]) + raise + del merged[CONF_BYTE_ORDER] + return merged + + +def _expand_platform_entry(index: int, entry: dict) -> list[dict]: + if CONF_FILES not in entry: + if CONF_DEFAULTS in entry: + raise cv.Invalid( + f"'{CONF_DEFAULTS}' may only be used together with '{CONF_FILES}'", + path=[index], + ) + return [entry] + + extra_keys = set(entry) - {CONF_PLATFORM, CONF_DEFAULTS, CONF_FILES} + if extra_keys: + raise cv.Invalid( + f"'{CONF_FILES}' cannot be combined with " + f"{', '.join(sorted(extra_keys))} on the same entry", + path=[index], + ) + + files = entry[CONF_FILES] + if files is None: + raise cv.Invalid(f"'{CONF_FILES}' must not be empty", path=[index]) + if not isinstance(files, list): + raise cv.Invalid(f"'{CONF_FILES}' must be a list", path=[index]) + if not files: + raise cv.Invalid(f"'{CONF_FILES}' must not be empty", path=[index]) + + defaults = entry.get(CONF_DEFAULTS, {}) + if defaults is None: + defaults = {} + if not isinstance(defaults, dict): + raise cv.Invalid(f"'{CONF_DEFAULTS}' must be a mapping", path=[index]) + # Neither `id:` nor `platform:` makes sense inside `defaults:`. + for disallowed in (CONF_ID, CONF_PLATFORM): + if disallowed in defaults: + raise cv.Invalid( + f"'{disallowed}' is not allowed inside '{CONF_DEFAULTS}'", + path=[index], + ) + + from esphome import yaml_util + + platform = entry[CONF_PLATFORM] + result: list[dict] = [] + for file_entry in files: + if not isinstance(file_entry, dict): + raise cv.Invalid( + f"each entry in '{CONF_FILES}' must be a mapping", path=[index] + ) + # The platform is chosen by the entry's own `platform:` key, not per file. + if CONF_PLATFORM in file_entry: + raise cv.Invalid( + f"'{CONF_PLATFORM}' is not allowed inside '{CONF_FILES}'", + path=[index], + ) + # Keep the `files:` item's source range so whole-entry errors anchor there; + # `make_data_base` needs a real ESPHomeDataBase, so skip it for plain dicts. + source = ( + file_entry if isinstance(file_entry, yaml_util.ESPHomeDataBase) else None + ) + merged = yaml_util.make_data_base( + {CONF_PLATFORM: platform, **defaults, **file_entry}, source + ) + result.append(_drop_incompatible_byte_order(merged, file_entry, index=index)) + return result + + +def expand_platform_config(config: list) -> list: + """Expand `defaults:`/`files:` entries; the platform's own CONFIG_SCHEMA validates each result.""" + result = [] + for i, entry in enumerate(config): + if isinstance(entry, dict) and CONF_PLATFORM in entry: + result.extend(_expand_platform_entry(i, entry)) + else: + result.append(entry) + return result + + +EXPAND_PLATFORM_CONFIG = expand_platform_config + +# --------------------- end defaults/files expansion ------------------------- + + # --------------------------------------------------------------------------- # Legacy top-level component -> `image:` platform deprecation helpers # -- REMOVE after 2027.1.0 together with the `animation:`/`online_image:` shims. @@ -496,11 +620,17 @@ def _is_legacy_image_format(config: object) -> bool: proper error instead of the migration silently dropping the input. """ if isinstance(config, list): - # A bare list of (not-yet-platform-tagged) image dicts. + # Exclude `files:` entries -- the list branch would otherwise silently + # migrate them to `platform: file` instead of raising the missing-platform error. return bool(config) and all( - isinstance(entry, dict) and CONF_PLATFORM not in entry for entry in config + isinstance(entry, dict) + and CONF_PLATFORM not in entry + and CONF_FILES not in entry + for entry in config ) - if not isinstance(config, dict): + if not isinstance(config, dict) or CONF_PLATFORM in config or CONF_FILES in config: + # `platform:`/`files:` dicts are new-format (left for list-wrapping + + # expansion); the legacy flattener has no `files:` branch and would drop them. return False # A single image dict, or the grouped `defaults:`/`images:`/type-key form. return ( @@ -532,18 +662,8 @@ def _flatten_legacy_image_config(config: object) -> list[dict]: def _add(entry: dict, extra: dict) -> None: merged = {**defaults, **extra, **entry} - # The legacy `defaults:`/type-grouped forms only applied `byte_order` to - # types that support it. Replicate that so an endian default merged into - # e.g. a binary image stays valid. - type_class = IMAGE_TYPE.get(str(merged.get(CONF_TYPE, "")).upper()) - if ( - CONF_BYTE_ORDER in merged - and isinstance(type_class, type) - and issubclass(type_class, ImageEncoder) - and not type_class.is_endian() - ): - del merged[CONF_BYTE_ORDER] - result.append(merged) + # Always drop, matching the pre-platform behavior -- see `_drop_incompatible_byte_order`. + result.append(_drop_incompatible_byte_order(merged, {})) def _add_entries(entries: object, extra: dict) -> None: # `entries` may be a single image dict or a list of them; non-dict diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index d8517d4493..9fa32a5a65 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -5,6 +5,7 @@ from esphome.components.const import CONF_BYTE_ORDER from esphome.components.image import ( IMAGE_TYPE, Image_, + validate_byte_order, validate_settings, validate_transparency, validate_type, @@ -128,9 +129,7 @@ def runtime_image_schema(image_class: cg.MockObjClass = RuntimeImage) -> cv.Sche cv.Required(CONF_FORMAT): cv.one_of(*IMAGE_FORMATS, upper=True), cv.Optional(CONF_RESIZE): cv.dimensions, cv.Required(CONF_TYPE): validate_type(IMAGE_TYPE), - cv.Optional(CONF_BYTE_ORDER): cv.one_of( - "BIG_ENDIAN", "LITTLE_ENDIAN", upper=True - ), + cv.Optional(CONF_BYTE_ORDER): validate_byte_order, cv.Optional(CONF_TRANSPARENCY, default="OPAQUE"): validate_transparency(), cv.Optional(CONF_PLACEHOLDER): cv.use_id(Image_), } diff --git a/esphome/config.py b/esphome/config.py index 987bb9c96a..13ec744ce4 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -620,6 +620,23 @@ class LoadValidationStep(ConfigValidationStep): elif not isinstance(self.conf, list): result[self.domain] = self.conf = [self.conf] + # Permanent expansion hook: a platform-tagged entry may expand into + # several (e.g. `image`'s `defaults:`/`files:`), for `platform:`-tagged dicts only. + if (expand := component.expand_platform_config) is not None and all( + isinstance(entry, dict) and CONF_PLATFORM in entry + for entry in self.conf + ): + with result.catch_error(path): + expanded = expand(self.conf) + if not isinstance(expanded, list): + # A non-list return is a component bug (not a user error): + # raise explicitly (survives -O/-OO) so it escapes catch_error. + raise TypeError( + f"{self.domain}: EXPAND_PLATFORM_CONFIG must " + f"return a list, got {type(expanded).__name__}" + ) + result[self.domain] = self.conf = expanded + # Process AUTO_LOAD _process_auto_load(result, component, path) diff --git a/esphome/loader.py b/esphome/loader.py index f994f0c5eb..23c6d1bfa5 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -164,6 +164,14 @@ class ComponentManifest: """ return getattr(self.module, "LEGACY_CONFIG_MIGRATE", None) + @property + def expand_platform_config( + self, + ) -> Callable[[list[ConfigType]], list[ConfigType]] | None: + """Optional `EXPAND_PLATFORM_CONFIG` callable; runs on the normalized `platform:`-tagged + entry list before per-entry CONFIG_SCHEMA. Must return a list (raise `cv.Invalid` for user errors).""" + return getattr(self.module, "EXPAND_PLATFORM_CONFIG", None) + @property def resources(self) -> list[FileResource]: """Return a list of all file resources defined in the package of this component. diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index f52c477c85..fad8b7df09 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -21,16 +21,20 @@ from esphome.components.image import ( CONF_OPAQUE, CONF_TRANSPARENCY, PLATFORM_FILE, + _expand_platform_entry, _flatten_legacy_image_config, _is_legacy_image_format, _is_new_image_format, _migrate_legacy_image_config, + expand_platform_config, get_all_image_metadata, get_image_metadata, ) from esphome.const import ( + CONF_DEFAULTS, CONF_DITHER, CONF_FILE, + CONF_FILES, CONF_ID, CONF_PLATFORM, CONF_RAW_DATA_ID, @@ -259,6 +263,15 @@ def test_flatten_keeps_byte_order_for_endian_type() -> None: assert out[0][CONF_BYTE_ORDER] == "little_endian" +def test_flatten_drops_byte_order_written_directly_on_legacy_entry() -> None: + """The legacy flattener drops an incompatible byte_order even when written directly on the entry.""" + out = _flatten_legacy_image_config( + {"binary": [{"id": "a", "file": "x.png", "byte_order": "little_endian"}]} + ) + assert out == [{"id": "a", "file": "x.png", "type": "binary"}] + assert CONF_BYTE_ORDER not in out[0] + + def test_flatten_skips_meta_and_unknown_keys() -> None: out = _flatten_legacy_image_config( { @@ -342,6 +355,42 @@ def test_migrate_legacy_warns_and_prepends_platform( ), pytest.param({"foo": 1}, False, id="dict_unknown_keys"), pytest.param("a string", False, id="scalar"), + # A `platform:`-tagged dict is the new format written without list brackets. + pytest.param( + {CONF_PLATFORM: "file", "id": "a", "file": "x.png"}, + False, + id="platform_tagged_flat_dict", + ), + pytest.param( + { + CONF_PLATFORM: "file", + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "x.png"}], + }, + False, + id="platform_tagged_defaults_files_dict", + ), + # `files:` without `platform:` is not legacy either -- the flattener has no branch for it. + pytest.param( + { + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "x.png"}], + }, + False, + id="defaults_files_dict_without_platform", + ), + # Same as above in a list -- without this exclusion it would be silently + # migrated to a hard-coded `platform: file` instead of raising the error. + pytest.param( + [ + { + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "x.png"}], + } + ], + False, + id="defaults_files_list_entry_without_platform", + ), ], ) def test_is_legacy_image_format(config: object, expected: bool) -> None: @@ -359,17 +408,290 @@ def test_is_legacy_image_format(config: object, expected: bool) -> None: def test_migrate_returns_none_for_invalid_legacy_shapes( config: object, caplog: pytest.LogCaptureFixture ) -> None: - """Unrecognised shapes are not migrated (and emit no warning) so normal - platform validation surfaces a proper error instead of silently dropping - the offending input.""" + """Unrecognised shapes are not migrated (and emit no warning), so normal platform validation reports them.""" with caplog.at_level(logging.WARNING): assert _migrate_legacy_image_config(config) is None assert "deprecated" not in caplog.text +def test_migrate_returns_none_for_mapping_form_defaults_files() -> None: + """A `platform:`-tagged `defaults:`/`files:` mapping must not be swallowed by the legacy migrator.""" + config = { + CONF_PLATFORM: "file", + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "a.png"}], + } + assert _migrate_legacy_image_config(config) is None + + +def test_migrate_returns_none_for_defaults_files_dict_without_platform() -> None: + """`defaults:`/`files:` without `platform:` must not be swallowed either -- the flattener has + no `files:` branch and would silently return `[]`.""" + config = { + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "a.png"}], + } + assert _migrate_legacy_image_config(config) is None + + +def test_migrate_returns_none_for_defaults_files_list_entry_without_platform() -> None: + """Same, in a list -- previously the list branch migrated it to a hard-coded + `platform: file` instead of raising a missing-platform error.""" + config = [ + { + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "a.png"}], + } + ] + assert _migrate_legacy_image_config(config) is None + + # --------------------------- end legacy migration -------------------------- +def test_expand_platform_entry_passes_through_plain_entry() -> None: + entry = {CONF_PLATFORM: "file", "id": "a", "file": "x.png"} + assert _expand_platform_entry(0, entry) == [entry] + + +def test_expand_platform_entry_expands_files_with_defaults() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "RGB565", "transparency": "opaque"}, + CONF_FILES: [ + {"id": "img1", "file": "foo.png"}, + {"id": "img2", "file": "bar.png", "type": "GRAYSCALE"}, + ], + } + assert _expand_platform_entry(0, entry) == [ + { + CONF_PLATFORM: "file", + "id": "img1", + "file": "foo.png", + "type": "RGB565", + "transparency": "opaque", + }, + { + CONF_PLATFORM: "file", + "id": "img2", + "file": "bar.png", + "type": "GRAYSCALE", + "transparency": "opaque", + }, + ] + + +def test_expand_platform_entry_files_without_defaults() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_FILES: [{"id": "img1", "file": "foo.png"}], + } + assert _expand_platform_entry(0, entry) == [ + {CONF_PLATFORM: "file", "id": "img1", "file": "foo.png"} + ] + + +def test_expand_platform_entry_preserves_source_range() -> None: + """A merged entry keeps the source range of its `files:` item so whole-entry errors anchor there.""" + from esphome import yaml_util + + file_entry = yaml_util.make_data_base({"id": "img1", "file": "foo.png"}) + file_entry._esp_range = "sentinel-range" + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "RGB565"}, + CONF_FILES: [file_entry], + } + [out] = _expand_platform_entry(0, entry) + assert isinstance(out, yaml_util.ESPHomeDataBase) + assert out.esp_range == "sentinel-range" + + +def test_expand_platform_entry_plain_dict_file_entry_has_no_source_range() -> None: + """Plain-dict `files:` items must not crash -- `from_database` reads `.esp_range` unconditionally.""" + entry = { + CONF_PLATFORM: "file", + CONF_FILES: [{"id": "img1", "file": "foo.png"}], + } + [out] = _expand_platform_entry(0, entry) + assert out == {CONF_PLATFORM: "file", "id": "img1", "file": "foo.png"} + + +def test_expand_platform_entry_per_file_overrides_win() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "RGB565"}, + CONF_FILES: [{"id": "img1", "file": "foo.png", "type": "BINARY"}], + } + [out] = _expand_platform_entry(0, entry) + assert out["type"] == "BINARY" + + +def test_expand_platform_entry_drops_byte_order_for_non_endian_override() -> None: + """A `byte_order` default merged into a non-endian override is dropped, as the legacy flattener did.""" + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "rgb565", "byte_order": "little_endian"}, + CONF_FILES: [ + {"id": "a", "file": "x.png"}, + {"id": "b", "file": "y.png", "type": "binary"}, + ], + } + out = _expand_platform_entry(0, entry) + assert out[0]["byte_order"] == "little_endian" + assert "byte_order" not in out[1] + + +def test_expand_platform_entry_invalid_byte_order_in_defaults_raises() -> None: + """A dropped `byte_order` inherited from `defaults:` is still validated, so a typo raises.""" + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "rgb565", "byte_order": "little_andian"}, + CONF_FILES: [{"id": "a", "file": "x.png", "type": "binary"}], + } + with pytest.raises(cv.Invalid, match="did you mean") as excinfo: + _expand_platform_entry(0, entry) + assert excinfo.value.path == [0] + + +def test_expand_platform_entry_keeps_byte_order_for_endian_override() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "rgb565", "byte_order": "big_endian"}, + CONF_FILES: [{"id": "a", "file": "x.png", "type": "rgb565"}], + } + [out] = _expand_platform_entry(0, entry) + assert out["byte_order"] == "big_endian" + + +def test_expand_platform_entry_keeps_explicit_byte_order_conflict() -> None: + """A `byte_order` written directly on the entry is kept so validate_settings raises the normal error.""" + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "rgb565"}, + CONF_FILES: [ + { + "id": "a", + "file": "x.png", + "type": "binary", + "byte_order": "little_endian", + } + ], + } + [out] = _expand_platform_entry(0, entry) + assert out["byte_order"] == "little_endian" + + +def test_expand_platform_entry_defaults_without_files_raises() -> None: + entry = {CONF_PLATFORM: "file", CONF_DEFAULTS: {"type": "RGB565"}} + with pytest.raises(cv.Invalid, match="may only be used together with") as excinfo: + _expand_platform_entry(0, entry) + assert excinfo.value.path == [0] + + +def test_expand_platform_entry_null_files_raises_not_empty() -> None: + """A `files:` key with no value parses to `None` and must be reported clearly.""" + entry = {CONF_PLATFORM: "file", CONF_DEFAULTS: {"type": "RGB565"}, CONF_FILES: None} + with pytest.raises(cv.Invalid, match="must not be empty"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_empty_files_list_raises_not_empty() -> None: + """An explicit `files: []` must not silently drop the whole platform entry.""" + entry = {CONF_PLATFORM: "file", CONF_FILES: []} + with pytest.raises(cv.Invalid, match="must not be empty"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_files_with_stray_key_raises() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_FILES: [{"id": "a", "file": "x.png"}], + "extra": 1, + } + with pytest.raises(cv.Invalid, match="cannot be combined with"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_id_in_defaults_raises() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {CONF_ID: "a"}, + CONF_FILES: [{"file": "x.png"}], + } + with pytest.raises(cv.Invalid, match="not allowed inside"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_platform_in_defaults_raises() -> None: + """`platform:` inside `defaults:` would silently reassign every file's platform.""" + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {CONF_PLATFORM: "animation"}, + CONF_FILES: [{"id": "a", "file": "x.png"}], + } + with pytest.raises(cv.Invalid, match="not allowed inside"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_platform_in_file_entry_raises() -> None: + """`platform:` on a `files:` item must not silently override the entry's platform.""" + entry = { + CONF_PLATFORM: "file", + CONF_FILES: [{"id": "a", "file": "x.png", CONF_PLATFORM: "animation"}], + } + with pytest.raises(cv.Invalid, match="not allowed inside"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_files_not_list_raises() -> None: + entry = {CONF_PLATFORM: "file", CONF_FILES: "not-a-list"} + with pytest.raises(cv.Invalid, match="must be a list"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_defaults_not_mapping_raises() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: "not-a-mapping", + CONF_FILES: [{"id": "a", "file": "x.png"}], + } + with pytest.raises(cv.Invalid, match="must be a mapping"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_file_item_not_mapping_raises() -> None: + entry = {CONF_PLATFORM: "file", CONF_FILES: [1, 2]} + with pytest.raises(cv.Invalid, match="must be a mapping"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_config_mixes_plain_and_expanded_entries() -> None: + config = [ + { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "RGB565"}, + CONF_FILES: [ + {"id": "img1", "file": "foo.png"}, + {"id": "img2", "file": "bar.png"}, + ], + }, + {CONF_PLATFORM: "file", "id": "plain", "file": "baz.png", "type": "BINARY"}, + ] + out = expand_platform_config(config) + assert [entry["id"] for entry in out] == ["img1", "img2", "plain"] + + +def test_expand_platform_config_ignores_non_platform_entries() -> None: + # Not expanded here -- legacy_config_migrate runs before this hook and is + # responsible for tagging/flattening pre-platform shapes. + config = ["not-a-platform-entry"] + assert expand_platform_config(config) == config + + +# --------------------- end defaults/files expansion ------------------------- + + def test_validate_image_final_defaults_to_little_endian() -> None: config = {CONF_FILE: "x.png"} validate_image_final(config) diff --git a/tests/components/animation/validate-platform-defaults.host.yaml b/tests/components/animation/validate-platform-defaults.host.yaml new file mode 100644 index 0000000000..034497c548 --- /dev/null +++ b/tests/components/animation/validate-platform-defaults.host.yaml @@ -0,0 +1,21 @@ +# `platform: animation` entry exercising the shared `defaults:`/`files:` expansion. +display: + - platform: sdl + id: animation_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + - platform: animation + defaults: + type: rgb565 + transparency: opaque + resize: 50x50 + files: + - id: platform_defaults_animation + file: $component_dir/anim.gif + - id: platform_defaults_animation_rgb + file: $component_dir/anim.apng + type: rgb diff --git a/tests/components/image/validate-platform-defaults.host.yaml b/tests/components/image/validate-platform-defaults.host.yaml new file mode 100644 index 0000000000..e1b3037cc3 --- /dev/null +++ b/tests/components/image/validate-platform-defaults.host.yaml @@ -0,0 +1,24 @@ +# `platform: file` entry using the `defaults:`/`files:` shape, including the +# per-type byte_order drop when an entry overrides to a non-endian type. +display: + - platform: sdl + id: image_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + - platform: file + defaults: + type: rgb565 + transparency: opaque + byte_order: little_endian + resize: 50x50 + dither: FloydSteinberg + files: + - id: platform_defaults_image + file: ../../pnglogo.png + - id: platform_defaults_binary + file: ../../pnglogo.png + type: binary diff --git a/tests/unit_tests/test_config_normalization.py b/tests/unit_tests/test_config_normalization.py index c8b7b63094..04363ad45b 100644 --- a/tests/unit_tests/test_config_normalization.py +++ b/tests/unit_tests/test_config_normalization.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock, Mock, patch import pytest -from esphome import config, yaml_util +from esphome import config, config_validation as cv, yaml_util from esphome.core import CORE, AutoLoad from esphome.types import ConfigType @@ -127,12 +127,14 @@ def _run_load_step( domain: str, conf: object, migrate: Callable[[ConfigType], list | None] | None, + expand: Callable[[list], list] | None = None, ) -> config.Config: - """Run a LoadValidationStep for a platform component with a given migrate hook.""" + """Run a LoadValidationStep for a platform component with given hooks.""" component = Mock() component.is_platform_component = True component.multi_conf_no_default = False component.legacy_config_migrate = migrate + component.expand_platform_config = expand result = config.Config() with ( @@ -197,6 +199,124 @@ def test_legacy_migrate_skipped_for_autoload() -> None: assert result["image"] == [auto] +# --------------------------------------------------------------------------- +# EXPAND_PLATFORM_CONFIG hook on LoadValidationStep -- permanent counterpart +# to legacy_config_migrate; runs after legacy migration/list normalization. +# --------------------------------------------------------------------------- + + +def test_expand_hook_rewrites_conf() -> None: + """A config the expand hook rewrites is replaced with the expanded list.""" + expanded = [{"platform": "file", "id": "a"}, {"platform": "file", "id": "b"}] + expand = Mock(return_value=expanded) + + result = _run_load_step("image", [{"platform": "file", "id": "a"}], None, expand) + + expand.assert_called_once_with([{"platform": "file", "id": "a"}]) + assert result["image"] == expanded + + +def test_expand_hook_absent_is_noop() -> None: + """A platform component without the hook is left as normalized by the + existing list-wrapping logic.""" + result = _run_load_step("image", [{"platform": "file", "id": "a"}], None, None) + + assert result["image"] == [{"platform": "file", "id": "a"}] + + +def test_expand_hook_runs_after_legacy_migrate() -> None: + """The expand hook sees the already-migrated list, not the raw legacy conf.""" + migrated = [{"platform": "file", "id": "a"}] + migrate = Mock(return_value=migrated) + expand = Mock(side_effect=lambda conf: conf) + + _run_load_step("image", [{"id": "a", "file": "x.png"}], migrate, expand) + + expand.assert_called_once_with(migrated) + + +def test_expand_hook_skipped_for_non_dict_entry() -> None: + """Malformed entries are left alone; the hook only sees `platform:`-tagged dicts.""" + expand = Mock(side_effect=lambda conf: conf) + + result = _run_load_step("image", ["not-a-dict"], None, expand) + + expand.assert_not_called() + assert result["image"] == ["not-a-dict"] + + +def test_expand_hook_skipped_for_entry_missing_platform_key() -> None: + """A dict entry missing the `platform:` key is left alone -- the normal + per-entry error reporting further down catches this case instead.""" + expand = Mock(side_effect=lambda conf: conf) + + result = _run_load_step("image", [{"id": "a"}], None, expand) + + expand.assert_not_called() + assert result["image"] == [{"id": "a"}] + + +def test_expand_hook_skipped_for_autoload() -> None: + """A non-empty AutoLoad reaching the hook stage is left alone.""" + expand = Mock(side_effect=lambda conf: conf) + auto = AutoLoad() + auto["id"] = "a" + + result = _run_load_step("image", auto, None, expand) + + expand.assert_not_called() + assert result["image"] == [auto] + + +def test_expand_hook_runs_when_all_entries_are_platform_tagged_dicts() -> None: + """The guard does not block the normal, well-formed case.""" + expand = Mock(side_effect=lambda conf: conf) + conf = [{"platform": "file", "id": "a"}, {"platform": "animation", "id": "b"}] + + result = _run_load_step("image", conf, None, expand) + + expand.assert_called_once_with(conf) + assert result["image"] == conf + + +def test_expand_hook_invalid_reports_single_error_at_domain_path() -> None: + """A `cv.Invalid` from the hook is reported once with the domain path prepended; no further validation runs.""" + expand = Mock(side_effect=cv.Invalid("bad shape")) + pre_expand_conf = [{"platform": "file", "id": "a"}] + + result = _run_load_step("image", pre_expand_conf, None, expand) + + assert len(result.errors) == 1 + assert result.errors[0].path == ["image"] + assert "bad shape" in str(result.errors[0]) + assert result["image"] == pre_expand_conf + + +def test_expand_hook_final_external_invalid_reports_without_path_prepend() -> None: + """`cv.FinalExternalInvalid` keeps its already-resolved path (no domain path prepended).""" + already_resolved_error = cv.FinalExternalInvalid( + "bad shape", path=["image", 3, "files"] + ) + expand = Mock(side_effect=already_resolved_error) + pre_expand_conf = [{"platform": "file", "id": "a"}] + + result = _run_load_step("image", pre_expand_conf, None, expand) + + assert len(result.errors) == 1 + assert result.errors[0] is already_resolved_error + assert result.errors[0].path == ["image", 3, "files"] + assert result["image"] == pre_expand_conf + + +def test_expand_hook_non_list_return_raises_type_error() -> None: + """A non-list return is a component bug: it escapes as an uncaught TypeError + (explicit raise survives -O/-OO).""" + expand = Mock(return_value={"not": "a list"}) + + with pytest.raises(TypeError, match="must return a list"): + _run_load_step("image", [{"platform": "file", "id": "a"}], None, expand) + + def _write_merge_conflict_config(tmp_path: Path, *, suppress: bool) -> Path: """Create a config where two `<<` includes both define `logger:`. From 47da743d11ec42374a9026d8b473174d20489077 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:09:12 -0700 Subject: [PATCH 1566/1815] [ai] Add instructions for concise comments (#18522) --- AGENTS.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index fa0f61c263..f006ee6087 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -763,3 +763,13 @@ The project uses English for non-code content. When drafting documentation, code PR descriptions, and similar text, avoid technical jargon. Instead, express concepts in plain English, using standard technical terms only when required. Ensure the text is readily comprehensible to a wide audience, including non-native English speakers. + +## 10. Code Comments + +Code comments on individual lines should be used only where necessary to flag issues that may not be obvious +on a simple reading of the code. Keep them short (e.g. 1 or 2 lines). + +Function and method comment blocks may include more detail as required to make +calling contracts clear and document parameter usage, but should still be kept concise. + +Avoid redundancy and repetition; comments should never simply restate what the code already says. From 0b1065feee095c82220fa4e9d1cd6b3b164aa82a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 19 Aug 2026 16:30:34 -0500 Subject: [PATCH 1567/1815] [ci] Stop jobs hanging on apt by restoring the cached apt action and bounding raw apt calls (#18518) --- .github/workflows/ci-api-proto.yml | 28 +++++++++- .github/workflows/ci.yml | 89 +++++++++++++++++++++++++----- 2 files changed, 101 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 1ccff96f24..63219a1dbc 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -41,10 +41,32 @@ jobs: version: "0.11.15" - name: Install apt dependencies + # PR-only workflow, so nothing on dev could seed a shared apt cache + # entry; the cached apt action would save one copy per PR. Plain apt + # with every call bounded: the apt.conf.d timeouts make a dead + # mirror fail over in seconds, and timeout runs under sudo so it can + # kill apt-get itself. Install without update first: image lists are + # fresh, and the index refresh is what a congested mirror makes slow. + timeout-minutes: 15 run: | - sudo apt update - sudo apt-cache show protobuf-compiler - sudo apt install -y protobuf-compiler + sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF' + Acquire::Retries "1"; + Acquire::http::Timeout "15"; + Acquire::https::Timeout "15"; + EOF + # Common path: the image's package lists are fresh enough. + if sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 \ + apt-get install -y protobuf-compiler; then + protoc --version + exit 0 + fi + # Rescue path: refresh the lists once with a generous bound; the + # apt config already fails a stalled mirror over quickly. + sudo DEBIAN_FRONTEND=noninteractive timeout -k 10 30 \ + dpkg --configure -a || true + sudo timeout -k 15 300 apt-get update + sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 \ + apt-get install -y protobuf-compiler protoc --version - name: Install python dependencies run: uv pip install --system aioesphomeapi -c requirements.txt -r requirements_dev.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6afb8a9d22..35148de0c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,22 @@ jobs: uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt uv pip install -e . + seed-apt-cache: + name: Seed apt package cache + runs-on: ubuntu-24.04 + # PR-branch cache saves are invisible to other PRs, so dev/beta/release + # pushes seed the one shared entry PR jobs restore. The key is derived + # only from the package list and version; keep both identical in every + # step that restores it. In ci-status needs so a broken seed fails dev. + if: github.event_name == 'push' + timeout-minutes: 10 + steps: + - name: Install apt packages (cached) + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 + with: + packages: libsdl2-dev ccache + version: 1.1 + determine-jobs: name: Determine which jobs to run runs-on: ubuntu-24.04 @@ -323,7 +339,8 @@ jobs: integration-tests: name: Run integration tests (${{ matrix.bucket.name }}) - runs-on: ubuntu-latest + # Must match seed-apt-cache's image: the apt cache key has no OS in it. + runs-on: ubuntu-24.04 needs: - common - determine-jobs @@ -335,12 +352,16 @@ jobs: steps: - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install ccache - # Speeds up the host compiles: tests in a bucket compile overlapping - # component sets, so later tests reuse earlier tests' objects. - run: | - sudo apt-get update -qq - sudo apt-get install -y --no-install-recommends ccache + - name: Install apt packages (cached) + # ccache speeds up the host compiles. A cache hit never touches apt + # (mirror outages cannot hang the job); the timeout bounds the cold + # path. Packages and version must match seed-apt-cache exactly; + # libsdl2-dev is unused here and carried only for cache-key parity. + timeout-minutes: 10 + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 + with: + packages: libsdl2-dev ccache + version: 1.1 - name: Set up Python 3.13 id: python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -421,6 +442,7 @@ jobs: benchmarks: name: Run CodSpeed benchmarks runs-on: ubuntu-24.04 + timeout-minutes: 30 needs: - common - determine-jobs @@ -461,6 +483,41 @@ jobs: fi echo "binary=$BINARY" >> $GITHUB_OUTPUT + - name: Bound apt fetches and pre-install libc6-dbg + # The CodSpeed runner installs valgrind + libc6-dbg via its own + # unbounded apt-get update; per-invocation apt options cannot reach + # it. The apt.conf.d timeouts below bound every later apt call in + # this job, the runner's included. Pre-installing libc6-dbg lets the + # runner skip apt once its valgrind cache is restored (it checks + # ``dpkg -s libc6-dbg``, so the cache action's unregistered restores + # would not count). Install without update first: image lists are + # fresh, and the index refresh is what a congested mirror makes + # slow. Best effort; the job timeout is the last backstop. + timeout-minutes: 15 + continue-on-error: true + run: | + sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF' + Acquire::Retries "1"; + Acquire::http::Timeout "15"; + Acquire::https::Timeout "15"; + EOF + if dpkg -s libc6-dbg >/dev/null 2>&1; then + echo "libc6-dbg already installed" + exit 0 + fi + # Common path: the image's package lists are fresh enough. + if sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 \ + apt-get install -y libc6-dbg; then + exit 0 + fi + # Rescue path: refresh the lists once with a generous bound; the + # apt config already fails a stalled mirror over quickly. + sudo DEBIAN_FRONTEND=noninteractive timeout -k 10 30 \ + dpkg --configure -a || true + sudo timeout -k 15 300 apt-get update + sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 \ + apt-get install -y libc6-dbg + - name: Run CodSpeed benchmarks uses: CodSpeedHQ/action@4296e51e7041e24dadb86d1d6e8b9320d223dbe8 # v5.0.3 with: @@ -884,12 +941,17 @@ jobs: - name: List components run: echo ${{ matrix.batch.components }} - - name: Install apt packages - # Not cached: this job is pull-request-only, so a cache save could - # never be shared and would only consume quota. - run: | - sudo apt-get update -qq - sudo apt-get install -y --no-install-recommends libsdl2-dev ccache + - name: Install apt packages (cached) + # A cache hit (seeded on dev by seed-apt-cache) never touches apt, + # so mirror outages cannot hang this PR-only job; the timeout bounds + # the cold path. Packages and version must match seed-apt-cache + # exactly. The action has no --no-install-recommends; same package + # set this job used before #17463. + timeout-minutes: 10 + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 + with: + packages: libsdl2-dev ccache + version: 1.1 - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -1424,6 +1486,7 @@ jobs: # this check. needs: - common + - seed-apt-cache - determine-jobs - ci-custom - pylint From 9daae377fca5eea6d1f39d13fa33e790d50b2f9c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 09:03:14 -0500 Subject: [PATCH 1568/1815] [api] Bump noise-c to 0.1.20 (#18482) --- esphome/components/api/__init__.py | 2 +- platformio.ini | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index cdc0d97c49..3e69d5842c 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -497,7 +497,7 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") - cg.add_library("esphome/noise-c", "0.1.19") + cg.add_library("esphome/noise-c", "0.1.20") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index 39600d622a..13bb5a556f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.19 ; api + esphome/noise-c@0.1.20 ; api improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.19 ; api + esphome/noise-c@0.1.20 ; api ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.19 ; used by api + esphome/noise-c@0.1.20 ; used by api lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} From 200a1644a5d12c30e4f0d562f1e0132b96482dc6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 09:03:33 -0500 Subject: [PATCH 1569/1815] [ci] Fail the benchmark job when the C++ benchmark build fails (#18480) --- .github/workflows/ci.yml | 17 ++++++++++++++--- tests/benchmarks/components/api/__init__.py | 1 + 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b603e68ad7..2075fde9ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -460,10 +460,21 @@ jobs: - name: Build benchmarks id: build run: | + # pipefail: without it a failed build is masked by the grep/cut + # pipeline below, leaving BINARY empty and silently dropping every + # C++ benchmark from the run while the job still reports success. + set -o pipefail . venv/bin/activate - export BENCHMARK_LIB_CONFIG=$(python script/setup_codspeed_lib.py) - # --build-only prints BUILD_BINARY= to stdout - BINARY=$(script/cpp_benchmark.py --all --build-only | grep '^BUILD_BINARY=' | tail -1 | cut -d= -f2-) + BENCHMARK_LIB_CONFIG=$(python script/setup_codspeed_lib.py) + export BENCHMARK_LIB_CONFIG + # --build-only prints BUILD_BINARY= to stdout; the grep is + # non-fatal so a missing marker reaches the check below instead of + # tripping errexit at this assignment + BINARY=$(script/cpp_benchmark.py --all --build-only | { grep '^BUILD_BINARY=' || true; } | tail -1 | cut -d= -f2-) + if [ -z "$BINARY" ]; then + echo "::error::Benchmark build did not report a binary path" + exit 1 + fi echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks diff --git a/tests/benchmarks/components/api/__init__.py b/tests/benchmarks/components/api/__init__.py index 0d02e0b054..0565bc5330 100644 --- a/tests/benchmarks/components/api/__init__.py +++ b/tests/benchmarks/components/api/__init__.py @@ -15,6 +15,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # components have hardware dependencies (BLE/UART/RMT); lightweight # stub headers in tests/benchmarks/stubs/ satisfy the includes. cg.add_define("USE_BLUETOOTH_PROXY") + cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS") cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 3) cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) cg.add_define("USE_ZWAVE_PROXY") From a99a8f364e8bad032d89d65e18e2470fd2df2267 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 09:32:37 -0500 Subject: [PATCH 1570/1815] [api] Bump noise-c to 0.1.21 (#18484) --- esphome/components/api/__init__.py | 2 +- platformio.ini | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 3e69d5842c..912d580a0f 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -497,7 +497,7 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") - cg.add_library("esphome/noise-c", "0.1.20") + cg.add_library("esphome/noise-c", "0.1.21") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index 13bb5a556f..4c372cc0bb 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.20 ; api + esphome/noise-c@0.1.21 ; api improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.20 ; api + esphome/noise-c@0.1.21 ; api ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.20 ; used by api + esphome/noise-c@0.1.21 ; used by api lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} From 10e592fa3a51b301644b5a742c75088cc3ab286a Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 18 Aug 2026 09:09:12 -0700 Subject: [PATCH 1571/1815] [modbus] CRC scan all unknown function codes (#18483) Co-authored-by: Claude Opus 4.8 (1M context) --- esphome/components/modbus/modbus.cpp | 33 ++-- esphome/components/modbus/modbus.h | 2 +- esphome/components/modbus/modbus_helpers.h | 32 ++++ tests/components/modbus/common.h | 36 +++++ .../modbus/modbus_unknown_function_test.cpp | 141 ++++++++++++++++++ 5 files changed, 232 insertions(+), 12 deletions(-) create mode 100644 tests/components/modbus/modbus_unknown_function_test.cpp diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 5305f6313f..e4bd51ad5a 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -219,14 +219,25 @@ void ModbusServerHub::parse_modbus_frames() { this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true); } -uint16_t Modbus::find_custom_frame_end_(uint16_t min_length) const { - // Custom functions could be any length - we have to rely on the CRC to determine completeness. +uint16_t Modbus::find_frame_end_by_crc_(uint16_t min_length) const { + // Unknown-length functions (user-defined codes, unimplemented management codes, unassigned values) + // could be any length - we have to rely on the CRC to determine completeness. // If a CRC match is never found, the buffer will eventually overflow and be cleared. const uint8_t *raw = &this->rx_buffer_[0]; const size_t size = this->rx_buffer_.size(); - for (uint16_t len = min_length; len <= std::min(size, size_t(MAX_FRAME_SIZE)); len++) { - if (crc16(raw, len) == 0) - return len; + const auto max_len = static_cast(std::min(size, size_t(MAX_FRAME_SIZE))); + if (min_length > max_len) + return 0; + // The Modbus CRC (poly 0xa001, refin/refout false) keeps its running state in the returned value, + // so we seed once over the first min_length bytes and extend one byte at a time instead of + // recomputing the whole prefix for every candidate length. + uint16_t crc = crc16(raw, min_length); + if (crc == 0) + return min_length; + for (uint16_t len = min_length; len < max_len; len++) { + crc = crc16(&raw[len], 1, crc); + if (crc == 0) + return len + 1; } return 0; } @@ -241,11 +252,11 @@ bool Modbus::parse_modbus_server_frame_() { uint8_t address = this->rx_buffer_[0]; uint8_t function_code = this->rx_buffer_[1]; - if (helpers::is_function_code_custom(function_code)) { - frame_length = this->find_custom_frame_end_(frame_length); + if (helpers::is_function_code_unknown_length(function_code)) { + frame_length = this->find_frame_end_by_crc_(frame_length); if (frame_length == 0) return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size - ESP_LOGD(TAG, "User-defined function %02X found", function_code); + ESP_LOGD(TAG, "Unknown-length function %02X found", function_code); } else { if (crc16(&this->rx_buffer_[0], frame_length) != 0) return false; @@ -272,11 +283,11 @@ bool ModbusServerHub::parse_modbus_client_frame_() { uint8_t address = this->rx_buffer_[0]; uint8_t function_code = this->rx_buffer_[1]; - if (helpers::is_function_code_custom(function_code)) { - frame_length = this->find_custom_frame_end_(frame_length); + if (helpers::is_function_code_unknown_length(function_code)) { + frame_length = this->find_frame_end_by_crc_(frame_length); if (frame_length == 0) return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size - ESP_LOGD(TAG, "User-defined function %02X found", function_code); + ESP_LOGD(TAG, "Unknown-length function %02X found", function_code); } else { if (crc16(&this->rx_buffer_[0], frame_length) != 0) return false; diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index dfe4a4872d..bb303c43a8 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -82,7 +82,7 @@ class Modbus : public uart::UARTDevice, public Component { bool send_frame_(const ModbusFrame &frame); // Scans forward from min_length to find a frame boundary by CRC match for custom function codes. // Returns the matched frame length, or 0 if no valid CRC was found within MAX_FRAME_SIZE. - uint16_t find_custom_frame_end_(uint16_t min_length) const; + uint16_t find_frame_end_by_crc_(uint16_t min_length) const; uint32_t last_modbus_byte_{0}; uint32_t last_receive_check_{0}; diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index c737e206c0..b2454e6f14 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -55,6 +55,38 @@ inline bool is_function_code_custom(uint8_t function_code) { masked_function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_2_END); } +/// True for any function code whose frame length the parsers cannot predict - everything the +/// server_pdu_length()/client_pdu_length() switches fall through to `default` on (keep the case list +/// in step with those switches). Deliberately wider than is_function_code_custom(): the user-defined +/// ranges are unknown to the parser too, but so are the assigned-but-unimplemented codes +/// (READ_EXCEPTION_STATUS, DIAGNOSTICS, GET_COMM_EVENT_*, REPORT_SERVER_ID) and every unassigned value. +/// The 0x80 exception flag is masked off first, so a frame with it set classifies by its base code - +/// even though a spec exception reply has a known 2-byte PDU. That is deliberate, matching what +/// is_function_code_custom() has always done: some vendors use codes with the 0x80 bit set as ordinary +/// codes with longer payloads, so the response parser CRC-scans these rather than assuming the spec +/// length. For an intact spec exception the scan matches at its first candidate, so only a corrupt one +/// pays (recovery by timeout instead of an immediate CRC failure). +inline bool is_function_code_unknown_length(uint8_t function_code) { + switch (static_cast(function_code & FUNCTION_CODE_MASK)) { + case FunctionCode::READ_COILS: + case FunctionCode::READ_DISCRETE_INPUTS: + case FunctionCode::READ_HOLDING_REGISTERS: + case FunctionCode::READ_INPUT_REGISTERS: + case FunctionCode::WRITE_SINGLE_COIL: + case FunctionCode::WRITE_SINGLE_REGISTER: + case FunctionCode::WRITE_MULTIPLE_COILS: + case FunctionCode::WRITE_MULTIPLE_REGISTERS: + case FunctionCode::READ_FILE_RECORD: + case FunctionCode::WRITE_FILE_RECORD: + case FunctionCode::MASK_WRITE_REGISTER: + case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: + case FunctionCode::READ_FIFO_QUEUE: + return false; + default: + return true; + } +} + // Returns the expected length of a server response PDU based on the function code. // If too few bytes have arrived to determine the length, returns the minimum length. `size` is the // number of bytes available so far, which may exceed the eventual PDU (e.g. include the frame's CRC diff --git a/tests/components/modbus/common.h b/tests/components/modbus/common.h index d03ccf8ec3..e6c37b0e6d 100644 --- a/tests/components/modbus/common.h +++ b/tests/components/modbus/common.h @@ -1,7 +1,10 @@ #pragma once #include +#include +#include #include #include "esphome/components/uart/uart_component.h" +#include "esphome/core/helpers.h" namespace esphome::modbus::testing { @@ -30,4 +33,37 @@ class RecordingUART : public NullUART { std::vector written; }; +// A UART the test can inject received bytes into, so frames travel the full receive path +// (receive_modbus_frames -> parse -> dispatch) through hub.loop(). Writes are recorded. +class InjectableUART : public RecordingUART { + public: + bool peek_byte(uint8_t *data) override { + if (this->rx_.empty()) + return false; + *data = this->rx_.front(); + return true; + } + bool read_array(uint8_t *data, size_t len) override { + if (len > this->rx_.size()) + return false; + memcpy(data, this->rx_.data(), len); + this->rx_.erase(this->rx_.begin(), this->rx_.begin() + len); + return true; + } + size_t available() override { return this->rx_.size(); } + + // Queues a complete wire frame: address + PDU + CRC16 (low byte first). + void inject_frame(uint8_t address, std::span pdu) { + size_t start = this->rx_.size(); + this->rx_.push_back(address); + this->rx_.insert(this->rx_.end(), pdu.begin(), pdu.end()); + uint16_t crc = crc16(this->rx_.data() + start, this->rx_.size() - start); + this->rx_.push_back(crc & 0xFF); + this->rx_.push_back(crc >> 8); + } + + private: + std::vector rx_; +}; + } // namespace esphome::modbus::testing diff --git a/tests/components/modbus/modbus_unknown_function_test.cpp b/tests/components/modbus/modbus_unknown_function_test.cpp new file mode 100644 index 0000000000..8b91d088b8 --- /dev/null +++ b/tests/components/modbus/modbus_unknown_function_test.cpp @@ -0,0 +1,141 @@ +#include + +#include +#include +#include + +#include "common.h" +#include "esphome/components/modbus/modbus.h" + +namespace esphome::modbus::testing { + +namespace { + +// Records custom-response dispatches so tests can assert an unknown-length frame reached the device. +class CustomRecordingDevice : public ModbusClientDevice { + public: + using ModbusClientDevice::ModbusClientDevice; + void on_custom_response(std::span request_pdu, std::span response_pdu, + ResponseStatus status) override { + this->requests.emplace_back(request_pdu.begin(), request_pdu.end()); + this->responses.emplace_back(response_pdu.begin(), response_pdu.end()); + this->statuses.push_back(status); + } + std::vector> requests; + std::vector> responses; + std::vector statuses; +}; + +// Every handler keeps its ILLEGAL_FUNCTION default; the hub's dispatch is what is under test. +class SilentServerDevice : public ModbusServerDevice {}; + +// Drives full client frames through the server hub's receive path (same shape as the broadcast tests). +class TestServerHub : public ModbusServerHub { + public: + bool tx_blocked() override { return false; } + + // Builds a complete client frame (address + FC + data + CRC) and runs the full receive-side parser. + // Returns true once the buffer has fully drained. + bool run_receive_parser_for_test(uint8_t address, uint8_t function_code, std::span data) { + this->rx_buffer_.clear(); + this->rx_buffer_.reserve(data.size() + 4); + this->rx_buffer_.push_back(address); + this->rx_buffer_.push_back(function_code); + this->rx_buffer_.insert(this->rx_buffer_.end(), data.begin(), data.end()); + uint16_t crc = crc16(this->rx_buffer_.data(), this->rx_buffer_.size()); + this->rx_buffer_.push_back(crc & 0xFF); + this->rx_buffer_.push_back(crc >> 8); + this->parse_modbus_frames(); + return this->rx_buffer_.empty(); + } +}; + +} // namespace + +// The frame-length parsers have explicit cases for exactly these 13 codes; every other value - the +// assigned-but-unimplemented management codes, both user-defined ranges, and all unassigned codes - +// must classify as unknown length. The exception flag masks off first. +TEST(ModbusUnknownFunction, HelperMatchesParserCoverage) { + for (uint8_t fc : {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x0F, 0x10, 0x14, 0x15, 0x16, 0x17, 0x18}) { + EXPECT_FALSE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << int(fc); + } + for (uint8_t fc : {0x07, 0x08, 0x0B, 0x0C, 0x11, 0x2A, 0x41, 0x48, 0x49, 0x64, 0x6E, 0x00, 0x7F}) { + EXPECT_TRUE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << int(fc); + } + // Exception replies classify by their base code. + EXPECT_FALSE(helpers::is_function_code_unknown_length(0x83)); + EXPECT_TRUE(helpers::is_function_code_unknown_length(0x87)); + // Strictly wider than the user-defined ranges: every custom code is unknown-length, but not vice versa. + for (int fc = 0; fc <= 0xFF; fc++) { + if (helpers::is_function_code_custom(fc)) + EXPECT_TRUE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << fc; + } + EXPECT_FALSE(helpers::is_function_code_custom(0x49)); + + // Derived contract check: the helper must say "unknown" exactly when both length parsers fall + // through to default. With a zero-filled max-size PDU every explicit case returns at least 2 + // (file records bottom out at 2, FIFO at 3) and only default returns MIN_PDU_SIZE, so comparing + // against MIN_PDU_SIZE detects a case added to either switch without updating the helper. The + // loop stops at 0x7F: above it the helper masks the exception flag off while client_pdu_length() + // switches on the unmasked byte and server_pdu_length() early-returns the exception length. + for (int fc = 0; fc <= 0x7F; fc++) { + const uint8_t pdu[MAX_PDU_SIZE] = {static_cast(fc)}; // zero header fields + EXPECT_EQ(helpers::is_function_code_unknown_length(fc), + helpers::client_pdu_length(pdu, sizeof(pdu)) == MIN_PDU_SIZE) + << "client_pdu_length disagrees for fc 0x" << std::hex << fc; + EXPECT_EQ(helpers::is_function_code_unknown_length(fc), + helpers::server_pdu_length(pdu, sizeof(pdu)) == MIN_PDU_SIZE) + << "server_pdu_length disagrees for fc 0x" << std::hex << fc; + } +} + +// A response with a function code outside the user-defined ranges (0x49) has no length case in +// server_pdu_length(), so the parser must find the frame end by CRC scan - the same way it already +// handles user-defined codes. Frame: address + FC 0x49 + 3 data bytes + CRC = 7 bytes. Without the +// scan the parser assumes a 4-byte frame, fails the CRC, and the response never reaches the device. +TEST(ModbusUnknownFunction, ClientParsesUnknownLengthResponse) { + InjectableUART uart; + ModbusClientHub hub; + hub.set_uart_parent(&uart); + hub.setup(); // computes frame timing from the baud rate + CustomRecordingDevice device(&hub, 0x02); + + const uint8_t request[] = {0x49, 0x01}; + ASSERT_TRUE(device.queue_pdu(request)); + hub.loop(); // transmit + ASSERT_FALSE(uart.written.empty()); + + const uint8_t response_pdu[] = {0x49, 0x02, 0xAA, 0xBB}; + uart.inject_frame(0x02, response_pdu); + hub.loop(); // receive + parse + match + dispatch + + ASSERT_EQ(device.responses.size(), 1u); + EXPECT_EQ(device.requests[0], std::vector(request, request + sizeof(request))); + EXPECT_EQ(device.responses[0], std::vector(response_pdu, response_pdu + sizeof(response_pdu))); + EXPECT_FALSE(device.statuses[0].has_value()); +} + +// The server side of the same gap: a request with FC 0x49 for a registered device must parse (CRC +// scan again) so the hub can answer ILLEGAL_FUNCTION per the spec. Without the scan the frame fails +// to parse and the client gets silence instead of the exception. +TEST(ModbusUnknownFunction, ServerRepliesIllegalFunctionToUnknownLengthRequest) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + SilentServerDevice device; + device.set_address(0x02); + hub.register_device(&device); + + const uint8_t data[] = {0x02, 0xAA, 0xBB}; + ASSERT_TRUE(hub.run_receive_parser_for_test(0x02, 0x49, data)); + + // Expected reply: address + FC with exception flag + ILLEGAL_FUNCTION + CRC. + std::vector expected = {0x02, 0xC9, 0x01}; + uint16_t crc = crc16(expected.data(), expected.size()); + expected.push_back(crc & 0xFF); + expected.push_back(crc >> 8); + EXPECT_EQ(uart.written, expected); +} + +} // namespace esphome::modbus::testing From 2df953f3d7c0b022cd3a75c05ab2ca0d63cc39f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 19:36:57 -0500 Subject: [PATCH 1572/1815] [platformio] Give the ccache wrapper a cmd.exe safe path (#18495) --- esphome/platformio/ccache.py.script | 9 +- esphome/platformio/toolchain.py | 63 +++-- tests/unit_tests/test_platformio_toolchain.py | 240 +++++++++++++++++- 3 files changed, 286 insertions(+), 26 deletions(-) diff --git a/esphome/platformio/ccache.py.script b/esphome/platformio/ccache.py.script index cc08a8c044..22592a2398 100644 --- a/esphome/platformio/ccache.py.script +++ b/esphome/platformio/ccache.py.script @@ -1,5 +1,4 @@ import os -import shutil # pylint: disable=E0602 Import("env") # noqa @@ -9,15 +8,17 @@ Import("env") # noqa # esphome/platformio/toolchain.py); this script only supplies the SCons-level # mechanism. # +# The binary comes pre-resolved in ESPHOME_CCACHE_PATH; _ccache_env() has +# already stripped the Windows \\?\ prefix that cmd.exe cannot run. +# # This is a "pre" script, so the platform's builder (which sets CC/CXX and # clones the construction environment for framework and library builds) runs # after it. Replacing CC/CXX here would be overwritten, and replacing them in # a "post" script would miss the already-cloned library environments. Wrapping # SPAWN instead is ordering-proof: clones copy the wrapper, and every compiler # invocation from every environment funnels through it at execution time. -if ( - os.environ.get("ESPHOME_CCACHE_ENABLE") == "1" - and (ccache_path := shutil.which("ccache")) is not None +if os.environ.get("ESPHOME_CCACHE_ENABLE") == "1" and ( + ccache_path := os.environ.get("ESPHOME_CCACHE_PATH") ): original_spawn = env["SPAWN"] diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index 08a4fcff78..d76581d032 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -60,6 +60,9 @@ def _strip_win_long_path_prefix(path: str) -> str: "The system cannot find the path specified." Stripping the prefix early keeps the path shell-quotable. + Also applied to the ccache path exported by ``_ccache_env()``, which + ``shutil.which`` can return with the same prefix. + No-op on non-Windows platforms. """ if sys.platform != "win32": @@ -235,8 +238,8 @@ def _check_platformio_python_stamp(config: "ProjectConfig") -> None: _write_pio_stamp_python(stamp_file, current) -def _ccache_usable() -> bool: - """Return True when the ``ccache`` on PATH actually runs. +def _ccache_runs(ccache: str) -> bool: + """Return True when the ``ccache`` found on PATH actually runs. ``shutil.which`` proves existence, not runnability: on Windows it also matches ``.bat``/``.cmd`` wrappers and stale package-manager shims whose @@ -244,9 +247,6 @@ def _ccache_usable() -> bool: step with an opaque OS error, so probe once and fall back to compiling without ccache when the probe fails. """ - ccache = shutil.which("ccache") - if ccache is None: - return False try: subprocess.run( [ccache, "--version"], @@ -265,14 +265,29 @@ def _ccache_usable() -> bool: def _ccache_env() -> dict[str, str]: - """Return ccache settings for PlatformIO builds. + r"""Return ccache settings for PlatformIO builds. Enabled by default whenever the ``ccache`` binary is on PATH; set ``ESPHOME_CCACHE_ENABLE=0`` in the environment to opt out (or ``1`` to - force it on). The decision is normalized into ``ESPHOME_CCACHE_ENABLE`` - so platform build scripts (e.g. the esp8266 ``ccache.py`` extra script, - which wraps compiler invocations inside SCons) only have to check for - ``"1"`` instead of re-implementing the policy. + force it on without the runnability probe; a binary is still needed). + The decision is normalized into ``ESPHOME_CCACHE_ENABLE`` and the + binary's location into ``ESPHOME_CCACHE_PATH`` so platform build scripts + (the shared ``ccache.py`` extra script, which wraps compiler invocations + inside SCons) only have to check for ``"1"`` and use the path as given + instead of re-implementing the policy. + + The path is exported rather than looked up again inside SCons because + ``shutil.which`` can return a Windows extended-length ``\\?\`` path + (ESPHome Desktop puts its bundled ccache on PATH that way). Such a path + runs fine through ``CreateProcess``, which is how ESP-IDF invokes it, + but SCons runs every compile through ``cmd.exe``, which fails on it with + "The system cannot find the path specified." (#18399), so the prefix is + stripped here with ``_strip_win_long_path_prefix()`` before the + runnability probe, which therefore validates the exact string the build + will execute. + ``ESPHOME_CCACHE_PATH`` is an internal channel, not a user setting: the + script only honours it together with ``ESPHOME_CCACHE_ENABLE=1``, and this + function always sets both or neither. The returned values are merged into the environment of the PlatformIO subprocess only, never into ``os.environ``: a long-running process @@ -293,13 +308,27 @@ def _ccache_env() -> dict[str, str]: build dir. The other ``CCACHE_*`` values the user already set in the environment are respected. """ - if "ESPHOME_CCACHE_ENABLE" in os.environ: - enabled = get_bool_env("ESPHOME_CCACHE_ENABLE") - else: - enabled = _ccache_usable() - env = {"ESPHOME_CCACHE_ENABLE": "1" if enabled else "0"} - if not enabled: - return env + explicit = "ESPHOME_CCACHE_ENABLE" in os.environ + if explicit and not get_bool_env("ESPHOME_CCACHE_ENABLE"): + return {"ESPHOME_CCACHE_ENABLE": "0"} + ccache_path = shutil.which("ccache") + if ccache_path is None: + if explicit: + _LOGGER.warning( + "ESPHOME_CCACHE_ENABLE is set but no ccache binary is on PATH; " + "compiling without ccache" + ) + return {"ESPHOME_CCACHE_ENABLE": "0"} + # Strip before probing so the probe validates (and the failure warning + # names) the exact string the build will execute through cmd.exe. + ccache_path = _strip_win_long_path_prefix(ccache_path) + # An explicit opt-in skips the runnability probe. + if not explicit and not _ccache_runs(ccache_path): + return {"ESPHOME_CCACHE_ENABLE": "0"} + env = { + "ESPHOME_CCACHE_ENABLE": "1", + "ESPHOME_CCACHE_PATH": ccache_path, + } # build_path is set during preload for every config-loading command, so it # being unset means a caller built the environment too early; fail loudly # rather than with an opaque TypeError from Path(None). diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index eebb0b8cd7..172b288c25 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -2,7 +2,7 @@ # pylint: disable=protected-access -from collections.abc import Generator +from collections.abc import Callable, Generator from contextlib import contextmanager from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json @@ -437,6 +437,7 @@ def test_ccache_env_enabled_by_default(setup_core: Path) -> None: env = toolchain._ccache_env() assert env["ESPHOME_CCACHE_ENABLE"] == "1" + assert env["ESPHOME_CCACHE_PATH"] == "/usr/bin/ccache" assert env["CCACHE_BASEDIR"] == str((setup_core / "build" / "test").resolve()) assert env["CCACHE_DIR"].endswith("platformio-ccache") assert env["CCACHE_NOHASHDIR"] == "true" @@ -446,17 +447,35 @@ def test_ccache_env_enabled_by_default(setup_core: Path) -> None: assert "ESPHOME_CCACHE_ENABLE" not in os.environ -def test_ccache_env_disabled_without_binary(setup_core: Path) -> None: - """Ccache stays off when the binary is not on PATH.""" +@pytest.mark.parametrize( + ("env_vars", "expect_warning"), + [ + pytest.param({}, False, id="default"), + pytest.param({"ESPHOME_CCACHE_ENABLE": "1"}, True, id="forced-on"), + ], +) +def test_ccache_env_disabled_without_binary( + setup_core: Path, + caplog: pytest.LogCaptureFixture, + env_vars: dict[str, str], + expect_warning: bool, +) -> None: + """Ccache stays off when the binary is not on PATH, even when forced on. + + A deliberate opt-in that finds no binary is downgraded with a warning so + the user can tell why it had no effect; the default path stays quiet. + """ CORE.build_path = setup_core / "build" / "test" with ( - patch.dict(os.environ, {}, clear=True), + patch.dict(os.environ, env_vars, clear=True), patch.object(toolchain.shutil, "which", return_value=None), + caplog.at_level("WARNING"), ): env = toolchain._ccache_env() assert env == {"ESPHOME_CCACHE_ENABLE": "0"} + assert ("no ccache binary is on PATH" in caplog.text) is expect_warning @pytest.mark.parametrize( @@ -489,14 +508,47 @@ def test_ccache_env_forced_on_skips_probe(setup_core: Path) -> None: with ( patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True), + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), patch.object(toolchain.subprocess, "run") as mock_probe, ): env = toolchain._ccache_env() assert env["ESPHOME_CCACHE_ENABLE"] == "1" + # The binary's location is still handed to the build script. + assert env["ESPHOME_CCACHE_PATH"] == "/usr/bin/ccache" mock_probe.assert_not_called() +def test_ccache_env_strips_win_long_path_prefix(setup_core: Path) -> None: + r"""A ``\\?\`` ccache path from PATH is exported without the prefix. + + That is the shape ESPHome Desktop puts on PATH (#18399); see ``_ccache_env``. + """ + CORE.build_path = setup_core / "build" / "test" + prefixed = ( + "\\\\?\\C:\\Users\\jesse\\AppData\\Local\\ESPHome Device Builder" + "\\ccache\\ccache.exe" + ) + stripped = ( + "C:\\Users\\jesse\\AppData\\Local\\ESPHome Device Builder\\ccache\\ccache.exe" + ) + + with ( + patch.dict(os.environ, {}, clear=True), + # shutil.which is patched, so the win32 code path of the real + # implementation (which crashes on a POSIX host) is never reached. + patch("esphome.platformio.toolchain.sys.platform", "win32"), + patch.object(toolchain.shutil, "which", return_value=prefixed), + patch.object(toolchain.subprocess, "run") as mock_probe, + ): + env = toolchain._ccache_env() + + assert env["ESPHOME_CCACHE_ENABLE"] == "1" + assert env["ESPHOME_CCACHE_PATH"] == stripped + # The probe validates the exact string the build will execute. + assert mock_probe.call_args[0][0] == [stripped, "--version"] + + def test_ccache_env_opt_out(setup_core: Path) -> None: """ESPHOME_CCACHE_ENABLE=0 disables ccache even with the binary present.""" CORE.build_path = setup_core / "build" / "test" @@ -516,7 +568,7 @@ def test_ccache_env_normalizes_enable_value(setup_core: Path) -> None: with ( patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "yes"}, clear=True), - patch.object(toolchain.shutil, "which", return_value=None), + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), ): env = toolchain._ccache_env() @@ -563,8 +615,10 @@ def test_run_platformio_cli_passes_ccache_env_to_subprocess_only( env = mock_run_external_process.call_args[1]["env"] assert env["ESPHOME_CCACHE_ENABLE"] == "1" + assert env["ESPHOME_CCACHE_PATH"] == "/usr/bin/ccache" assert env["CCACHE_BASEDIR"] == str((setup_core / "build" / "test").resolve()) assert "ESPHOME_CCACHE_ENABLE" not in os.environ + assert "ESPHOME_CCACHE_PATH" not in os.environ assert "CCACHE_BASEDIR" not in os.environ @@ -613,6 +667,182 @@ def test_copy_ccache_script(setup_core: Path) -> None: assert dest.read_text() == source.read_text() +class _FakeSConsEnv(dict): + """Just enough of a SCons construction environment for ccache.py.""" + + def Replace(self, **kwargs: object) -> None: # noqa: N802 + self.update(kwargs) + + +def _load_ccache_script( + env_vars: dict[str, str], original_spawn: Callable[..., int] | None = None +) -> tuple[_FakeSConsEnv, Callable[..., int]]: + """Run ccache.py.script against a fake SCons env and return (env, original SPAWN).""" + if original_spawn is None: + original_spawn = Mock(name="original_spawn", return_value=0) + scons_env = _FakeSConsEnv(SPAWN=original_spawn) + source = (Path(toolchain.__file__).parent / "ccache.py.script").read_text() + with patch.dict(os.environ, env_vars, clear=True): + exec( # noqa: S102 + compile(source, "ccache.py", "exec"), + {"Import": lambda *_names: None, "env": scons_env}, + ) + return scons_env, original_spawn + + +def _scons_win32_escape(x: str) -> str: + """Copy of ``SCons.Platform.win32.escape``: quote, guarding a trailing backslash.""" + if x[-1] == "\\": + x = x + "\\" + return '"' + x + '"' + + +def test_ccache_script_wraps_compiles_with_exported_path() -> None: + """The SCons script uses ESPHOME_CCACHE_PATH as given, without a PATH lookup.""" + ccache_path = "C:\\Users\\jesse\\ESPHome Device Builder\\ccache\\ccache.exe" + scons_env, original_spawn = _load_ccache_script( + {"ESPHOME_CCACHE_ENABLE": "1", "ESPHOME_CCACHE_PATH": ccache_path} + ) + spawn = scons_env["SPAWN"] + assert spawn is not original_spawn + + # A compile step is routed through ccache, with the same path used for + # the program and (escaped) as the first argument. + compile_args = ["xtensa-lx106-elf-g++", "-o", "main.o", "-c", "main.cpp"] + spawn("cmd.exe", _scons_win32_escape, "xtensa-lx106-elf-g++", compile_args, {}) + original_spawn.assert_called_once_with( + "cmd.exe", + _scons_win32_escape, + ccache_path, + [_scons_win32_escape(ccache_path), *compile_args], + {}, + ) + + # Link steps pass through untouched. + original_spawn.reset_mock() + link_args = ["xtensa-lx106-elf-g++", "-o", "firmware.elf", "main.o"] + spawn("cmd.exe", _scons_win32_escape, "xtensa-lx106-elf-g++", link_args, {}) + original_spawn.assert_called_once_with( + "cmd.exe", _scons_win32_escape, "xtensa-lx106-elf-g++", link_args, {} + ) + + +@pytest.mark.parametrize( + "env_vars", + [ + pytest.param({"ESPHOME_CCACHE_ENABLE": "0"}, id="disabled"), + pytest.param({"ESPHOME_CCACHE_ENABLE": "1"}, id="enabled-without-path"), + pytest.param({}, id="unset"), + ], +) +def test_ccache_script_leaves_spawn_alone_without_path( + env_vars: dict[str, str], +) -> None: + """Without both the enable flag and a path, SPAWN is not replaced.""" + scons_env, original_spawn = _load_ccache_script(env_vars) + assert scons_env["SPAWN"] is original_spawn + + +def _scons_win32_spawn( + sh: str, escape: Callable[[str], str], cmd: str, args: list[str], env: dict +) -> int: + r"""Mirror of ``SCons.Platform.win32.spawn``: every command runs via ``cmd.exe /C``. + + SCons is not importable in the test environment (PlatformIO fetches it at + build time), so the lines that matter are mirrored here. The command line + SCons hands ``os.spawnve`` goes to ``CreateProcess`` via ``subprocess`` + instead (identical on Windows, where a string passes through untouched); + ``spawnve`` itself crashes inside pytest. + """ + return subprocess.run( + " ".join([sh, "/C", escape(" ".join(args))]), env=env, check=False + ).returncode + + +_MARKER_ENV = "ESPHOME_TEST_CCACHE_MARKER" +# Stands in for a compile: the "ccache" is really the Python interpreter, and +# the compile "flags" make it write a marker file so the test can tell whether +# the wrapped command actually ran to completion. +_FAKE_COMPILE_ARGS = [ + "-c", + f"import os, pathlib; pathlib.Path(os.environ['{_MARKER_ENV}']).write_text('compiled')", +] + + +def _spawn_fake_compile_via_cmd_exe(scons_env: _FakeSConsEnv, marker: Path) -> int: + """Run one wrapped compile step the way SCons does on Windows.""" + child_env = {**os.environ, _MARKER_ENV: str(marker)} + return scons_env["SPAWN"]( + os.environ.get("COMSPEC", "cmd.exe"), + _scons_win32_escape, + "xtensa-lx106-elf-gcc", + [_scons_win32_escape(arg) if " " in arg else arg for arg in _FAKE_COMPILE_ARGS], + child_env, + ) + + +_WINDOWS_ONLY = pytest.mark.skipif( + sys.platform != "win32", reason="drives cmd.exe, which SCons uses only on Windows" +) + + +@_WINDOWS_ONLY +def test_ccache_env_real_probe_runs_stripped_path(setup_core: Path) -> None: + r"""With a ``\\?\`` which result, the real probe runs the stripped binary. + + The probe therefore validates the exact string the build will execute + through ``cmd.exe``; probing the verbatim path instead would pass even + when the stripped path is unusable (``CreateProcess`` accepts + extended-length paths, ``cmd.exe`` does not). + """ + CORE.build_path = setup_core / "build" / "test" + assert not sys.executable.startswith("\\\\?\\") + + with ( + patch.dict(os.environ, {}, clear=False), + patch.object( + toolchain.shutil, "which", return_value="\\\\?\\" + sys.executable + ), + ): + os.environ.pop("ESPHOME_CCACHE_ENABLE", None) + env = toolchain._ccache_env() + + assert env["ESPHOME_CCACHE_ENABLE"] == "1" + assert env["ESPHOME_CCACHE_PATH"] == sys.executable + + +@_WINDOWS_ONLY +@pytest.mark.parametrize( + ("prefix", "expect_ok"), + [ + pytest.param("", True, id="stripped-path-compiles"), + pytest.param("\\\\?\\", False, id="verbatim-path-fails"), + ], +) +def test_ccache_wrapper_through_cmd_exe( + tmp_path: Path, prefix: str, expect_ok: bool +) -> None: + r"""End to end through ``cmd.exe``: the exported path works, a ``\\?\`` one does not. + + The interpreter stands in for ccache; the spawn mirrors SCons on Windows. + The failing case is the mechanism behind #18399 ("The system cannot find + the path specified." on every compile step); should it ever start passing, + ``cmd.exe`` learned extended-length paths and the strip is no longer needed. + """ + marker = tmp_path / "compiled.txt" + scons_env, _ = _load_ccache_script( + {"ESPHOME_CCACHE_ENABLE": "1", "ESPHOME_CCACHE_PATH": prefix + sys.executable}, + original_spawn=_scons_win32_spawn, + ) + assert scons_env["SPAWN"] is not _scons_win32_spawn + + rc = _spawn_fake_compile_via_cmd_exe(scons_env, marker) + assert (rc == 0) is expect_ok + assert marker.exists() is expect_ok + if expect_ok: + assert marker.read_text() == "compiled" + + @pytest.mark.parametrize( ("platform", "input_path", "expected"), [ From 6084314cc9c029b4b6b131a92665d98d4046e464 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 19:37:10 -0500 Subject: [PATCH 1573/1815] [vscode] Report the origin of an unexpected exception during validation (#18494) --- esphome/vscode.py | 20 +++++++++- tests/unit_tests/test_vscode.py | 66 +++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/esphome/vscode.py b/esphome/vscode.py index f404f02f00..ba7b4e727b 100644 --- a/esphome/vscode.py +++ b/esphome/vscode.py @@ -3,12 +3,14 @@ from __future__ import annotations from io import StringIO import json from pathlib import Path +import sys +import traceback from typing import Any from esphome.config import Config, _format_vol_invalid, validate_config import esphome.config_validation as cv from esphome.const import __version__ as ESPHOME_VERSION -from esphome.core import CORE, DocumentRange +from esphome.core import CORE, DocumentRange, EsphomeError from esphome.yaml_util import parse_yaml @@ -97,6 +99,16 @@ def _ace_loader(fname: Path) -> dict[str, Any]: return parse_yaml(fname, raw_yaml_stream) +def _format_unexpected_error(err: Exception) -> str: + """Describe a crash inside validation with the frame it came from.""" + message = f"Unexpected error while validating: {type(err).__name__}: {err}" + frames = traceback.extract_tb(err.__traceback__) + if not frames: + return message + frame = frames[-1] + return f"{message} ({frame.filename}:{frame.lineno} in {frame.name})" + + def _print_version(): """Print ESPHome version.""" print( @@ -134,8 +146,12 @@ def read_config(args): try: config = loader(file_name) res = validate_config(config, command_line_substitutions) - except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + except (EsphomeError, cv.Invalid) as err: vs.add_yaml_error(str(err)) + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + # stdout carries the JSON protocol; the full chain goes to stderr. + traceback.print_exc(file=sys.stderr) + vs.add_yaml_error(_format_unexpected_error(err)) else: for err in res.errors: try: diff --git a/tests/unit_tests/test_vscode.py b/tests/unit_tests/test_vscode.py index 63bdf3e255..9b7d1e9504 100644 --- a/tests/unit_tests/test_vscode.py +++ b/tests/unit_tests/test_vscode.py @@ -3,6 +3,8 @@ from pathlib import Path from unittest.mock import Mock, patch from esphome import vscode +import esphome.config_validation as cv +from esphome.core import EsphomeError def _run_repl_test(input_data): @@ -126,3 +128,67 @@ packages: assert range["start_col"] == 2 assert range["end_line"] == 1 assert range["end_col"] == 7 + + +def _explode(*_args: object, **_kwargs: object) -> None: + raise AttributeError("'NoneType' object has no attribute 'get'") + + +def test_unexpected_error_reports_origin() -> None: + source_path = str(Path("dir_path", "x.yaml")) + with patch("esphome.vscode.validate_config", _explode): + output_lines = _run_repl_test( + [ + _validate(source_path), + _file_response("""esphome: + name: test1 +"""), + ] + ) + + result = json.loads(output_lines[-1]) + assert result["validation_errors"] == [] + (error,) = result["yaml_errors"] + assert error["message"].startswith( + "Unexpected error while validating: AttributeError: " + "'NoneType' object has no attribute 'get' (" + ) + assert "test_vscode.py" in error["message"] + assert error["message"].endswith(" in _explode)") + + +def test_esphome_error_stays_plain() -> None: + source_path = str(Path("dir_path", "x.yaml")) + with patch("esphome.vscode.validate_config", side_effect=EsphomeError("boom")): + output_lines = _run_repl_test( + [ + _validate(source_path), + _file_response("""esphome: + name: test1 +"""), + ] + ) + + result = json.loads(output_lines[-1]) + assert result["yaml_errors"] == [{"message": "boom"}] + + +def test_invalid_stays_plain() -> None: + source_path = str(Path("dir_path", "x.yaml")) + with patch("esphome.vscode.validate_config", side_effect=cv.Invalid("bad value")): + output_lines = _run_repl_test( + [ + _validate(source_path), + _file_response("""esphome: + name: test1 +"""), + ] + ) + + result = json.loads(output_lines[-1]) + assert result["yaml_errors"] == [{"message": "bad value"}] + + +def test_format_unexpected_error_without_traceback() -> None: + message = vscode._format_unexpected_error(ValueError("boom")) + assert message == "Unexpected error while validating: ValueError: boom" From b768e2a1ce796f7055f9fcba8e8b3494798ce8fa Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:25:09 -0400 Subject: [PATCH 1574/1815] [esp32] Fix ESP32-P4 bootloop on rev3 (v3.x) chips when only variant is set (#18500) --- esphome/components/esp32/__init__.py | 78 +++++++++++++++------------- 1 file changed, 41 insertions(+), 37 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index ada6d25db5..2c06ebac9a 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1070,6 +1070,26 @@ def _parse_pio_platform_version(value): return value +def _normalize_p4_engineering_sample(value: ConfigType) -> bool: + """Fill in CONF_ENGINEERING_SAMPLE when unset, warning that production + silicon (rev3) is assumed. Returns the normalized flag.""" + if (engineering_sample := value.get(CONF_ENGINEERING_SAMPLE)) is None: + _LOGGER.warning( + "Defaulting to ESP32-P4 production silicon (rev3).\n" + "If you have an early engineering sample (pre-rev3), add this to your config:\n" + "\n" + " esp32:\n" + " engineering_sample: true\n" + "\n" + "To check your chip revision, look for 'chip revision: vX.Y' in the boot log.\n" + "Engineering samples will show a revision below v3.0.\n" + "The 'debug:' component also reports the revision (e.g. Revision: 100 = v1.0, 300 = v3.0)." + ) + engineering_sample = False + value[CONF_ENGINEERING_SAMPLE] = engineering_sample + return engineering_sample + + def _detect_variant(value): board = value.get(CONF_BOARD) variant = value.get(CONF_VARIANT) @@ -1082,6 +1102,8 @@ def _detect_variant(value): # name rather than carrying a PIO board name through the IDF build. if CORE.using_toolchain_esp_idf: value = value.copy() + if variant == VARIANT_ESP32P4: + _normalize_p4_engineering_sample(value) value[CONF_BOARD] = VARIANT_FRIENDLY[variant].lower() return value if variant not in STANDARD_BOARDS: @@ -1092,22 +1114,8 @@ def _detect_variant(value): ) value = value.copy() value[CONF_BOARD] = STANDARD_BOARDS[variant] - if variant == VARIANT_ESP32P4: - engineering_sample = value.get(CONF_ENGINEERING_SAMPLE) - if engineering_sample is None: - _LOGGER.warning( - "No board specified for ESP32-P4. Defaulting to production silicon (rev3).\n" - "If you have an early engineering sample (pre-rev3), add this to your config:\n" - "\n" - " esp32:\n" - " engineering_sample: true\n" - "\n" - "To check your chip revision, look for 'chip revision: vX.Y' in the boot log.\n" - "Engineering samples will show a revision below v3.0.\n" - "The 'debug:' component also reports the revision (e.g. Revision: 100 = v1.0, 300 = v3.0)." - ) - elif engineering_sample: - value[CONF_BOARD] = "esp32-p4-evboard" + if variant == VARIANT_ESP32P4 and _normalize_p4_engineering_sample(value): + value[CONF_BOARD] = "esp32-p4-evboard" elif board in BOARDS: variant = variant or BOARDS[board][KEY_VARIANT] if variant != BOARDS[board][KEY_VARIANT]: @@ -1117,6 +1125,14 @@ def _detect_variant(value): ) value = value.copy() value[CONF_VARIANT] = variant + if variant == VARIANT_ESP32P4: + board_is_es = BOARDS[board].get("engineering_sample", False) + engineering_sample = value.setdefault(CONF_ENGINEERING_SAMPLE, board_is_es) + if engineering_sample != board_is_es: + raise cv.Invalid( + f"'{CONF_ENGINEERING_SAMPLE}' does not match board '{board}'", + path=[CONF_ENGINEERING_SAMPLE], + ) elif not variant: raise cv.Invalid( "This board is unknown, if you are sure you want to compile with this board selection, " @@ -1128,6 +1144,9 @@ def _detect_variant(value): "This board is unknown; the specified variant '%s' will be used but this may not work as expected.", variant, ) + if variant == VARIANT_ESP32P4: + value = value.copy() + _normalize_p4_engineering_sample(value) return value @@ -1431,20 +1450,6 @@ def final_validate(config): path=[CONF_ENGINEERING_SAMPLE], ) ) - if ( - config[CONF_VARIANT] == VARIANT_ESP32P4 - and config.get(CONF_ENGINEERING_SAMPLE) is not None - ): - board_is_es = BOARDS.get(config[CONF_BOARD], {}).get( - "engineering_sample", False - ) - if config[CONF_ENGINEERING_SAMPLE] != board_is_es: - errs.append( - cv.Invalid( - f"'{CONF_ENGINEERING_SAMPLE}' does not match board '{config[CONF_BOARD]}'", - path=[CONF_ENGINEERING_SAMPLE], - ) - ) if advanced[CONF_EXECUTE_FROM_PSRAM]: if config[CONF_VARIANT] not in {VARIANT_ESP32S3, VARIANT_ESP32P4}: errs.append( @@ -2517,15 +2522,14 @@ async def to_code(config): f"CONFIG_ESPTOOLPY_FLASHFREQ_{flash_frequency[:-3]}M", True ) - # ESP32-P4: ESP-IDF 5.5.3 changed the default of ESP32P4_SELECTS_REV_LESS_V3 - # from y to n. PlatformIO uses sections.ld.in (for rev <3) or - # sections.rev3.ld.in (for rev >=3) based on board definition. - # Set the sdkconfig option to match the board's chip revision. + # ESP32-P4: pre-v3 and rev3 (v3.0+) silicon are not binary compatible. + # CONFIG_ESP32P4_SELECTS_REV_LESS_V3 selects which layout ESP-IDF links; + # validation normalizes CONF_ENGINEERING_SAMPLE from the board when unset. if variant == VARIANT_ESP32P4: - is_eng_sample = BOARDS.get(config[CONF_BOARD], {}).get( - "engineering_sample", False + add_idf_sdkconfig_option( + "CONFIG_ESP32P4_SELECTS_REV_LESS_V3", + config.get(CONF_ENGINEERING_SAMPLE, False), ) - add_idf_sdkconfig_option("CONFIG_ESP32P4_SELECTS_REV_LESS_V3", is_eng_sample) # Set minimum chip revision for ESP32 variant # Setting this to 3.0 or higher reduces flash size by excluding workaround code, From 7418fcce8d8f154bceb088f1ad10782c6dadb4ca Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:38:18 -0400 Subject: [PATCH 1575/1815] [ci] Stop persisting the integration test ccache (#18504) --- .github/workflows/ci.yml | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2075fde9ef..0d8f35ed83 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -341,18 +341,6 @@ jobs: run: | sudo apt-get update -qq sudo apt-get install -y --no-install-recommends ccache - - name: Restore ccache (restore-only) - # esphome stores the PlatformIO ccache under the machine-global cache - # dir (see _ccache_env() in esphome/platformio/toolchain.py). The - # bucket-name prefix prefers a same-bucket seed; the bare prefix falls - # back to any seed when the bucket layout differs from dev. - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.cache/esphome/platformio-ccache - key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }} - restore-keys: | - integration-ccache-${{ matrix.bucket.name }}- - integration-ccache- - name: Set up Python 3.13 id: python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -401,14 +389,6 @@ jobs: # esphome stores the PlatformIO ccache under the machine-global cache # dir (see _ccache_env() in esphome/platformio/toolchain.py). run: CCACHE_DIR="$HOME/.cache/esphome/platformio-ccache" ccache -s - - name: Save ccache - # Pull request saves land in per-PR scopes nothing else can reuse; - # dev pushes seed the shared copy instead. - if: github.event_name != 'pull_request' - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.cache/esphome/platformio-ccache - key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }} import-time: name: Check import esphome.__main__ time From e9e77d02a00d6d9b8f0661b0e4c4a025b4f697b9 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:57:51 -0500 Subject: [PATCH 1576/1815] Bump bundled esphome-device-builder to 1.11.3 (#18505) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 4a8daeaaf6..50b698224c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.2 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.3 RUN \ platformio settings set enable_telemetry No \ From 74e22b5ad74308fbed86738bf63fbcaed9f0fd03 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:24:23 -0500 Subject: [PATCH 1577/1815] Bump bundled esphome-device-builder to 1.11.4 (#18506) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 50b698224c..5c21e07618 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.3 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.4 RUN \ platformio settings set enable_telemetry No \ From 2c92a2498e5fb5632554f485eedd3446155d7e83 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:42:05 -0500 Subject: [PATCH 1578/1815] Bump bundled esphome-device-builder to 1.11.5 (#18507) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 5c21e07618..1be10db3af 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.4 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.5 RUN \ platformio settings set enable_telemetry No \ From 4a85c98285c1a2c38b2e5e9115fb4793b5b1d69f Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:21:58 -0500 Subject: [PATCH 1579/1815] Bump bundled esphome-device-builder to 1.12.0 (#18514) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 1be10db3af..18f705b501 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.5 +RUN uv pip install --no-cache-dir esphome-device-builder==1.12.0 RUN \ platformio settings set enable_telemetry No \ From b3fda9973ebd67fcafb26b4f3b7a831427ecafff Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:30:12 -0700 Subject: [PATCH 1580/1815] [image] Restore defaults:/files: support for platform entries (#18032) Co-authored-by: Claude Sonnet 5 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/file/image.py | 3 +- esphome/components/image/__init__.py | 152 +++++++- esphome/components/runtime_image/__init__.py | 5 +- esphome/config.py | 17 + esphome/loader.py | 8 + tests/component_tests/image/test_init.py | 328 +++++++++++++++++- .../validate-platform-defaults.host.yaml | 21 ++ .../validate-platform-defaults.host.yaml | 24 ++ tests/unit_tests/test_config_normalization.py | 124 ++++++- 9 files changed, 657 insertions(+), 25 deletions(-) create mode 100644 tests/components/animation/validate-platform-defaults.host.yaml create mode 100644 tests/components/image/validate-platform-defaults.host.yaml diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index b54c3f2adf..212c778763 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -23,6 +23,7 @@ from esphome.components.image import ( get_image_type_enum, get_transparency_enum, is_svg_file, + validate_byte_order, validate_settings, validate_transparency, validate_type, @@ -200,7 +201,7 @@ OPTIONS_SCHEMA = { "NONE", "FLOYDSTEINBERG", upper=True ), cv.Optional(CONF_INVERT_ALPHA, default=False): cv.boolean, - cv.Optional(CONF_BYTE_ORDER): cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True), + cv.Optional(CONF_BYTE_ORDER): validate_byte_order, cv.Optional(CONF_TRANSPARENCY, default=CONF_OPAQUE): validate_transparency(), } diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 37a9afb84d..eaee31a1c7 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -10,7 +10,14 @@ from PIL import Image, UnidentifiedImageError import esphome.codegen as cg from esphome.components.const import CONF_BYTE_ORDER, KEY_METADATA import esphome.config_validation as cv -from esphome.const import CONF_DEFAULTS, CONF_FILE, CONF_ID, CONF_PLATFORM, CONF_TYPE +from esphome.const import ( + CONF_DEFAULTS, + CONF_FILE, + CONF_FILES, + CONF_ID, + CONF_PLATFORM, + CONF_TYPE, +) from esphome.core import CORE from esphome.types import ConfigType @@ -48,6 +55,9 @@ TRANSPARENCY_TYPES = ( CONF_ALPHA_CHANNEL, ) +# Shared validator for the image platform schemas and `_drop_incompatible_byte_order`. +validate_byte_order = cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True) + def get_image_type_enum(type): return getattr(ImageType, f"IMAGE_TYPE_{type.upper()}") @@ -404,6 +414,120 @@ def get_image_metadata(image_id: str) -> ImageMetaData | None: return get_all_image_metadata().get(image_id) +# --------------------------------------------------------------------------- +# `defaults:`/`files:` expansion: a `platform:` entry merges shared `defaults:` +# into every `files:` entry; the platform's CONFIG_SCHEMA validates each. +# Permanent, unlike the legacy migration below. +# --------------------------------------------------------------------------- + + +def _drop_incompatible_byte_order( + merged: dict, explicit: dict, *, index: int | None = None +) -> dict: + """Drop `byte_order` when the resolved type doesn't support it, unless written directly on `explicit`. + + With `index`, inherited values are validated before being dropped (the legacy flattener always drops). + """ + if CONF_BYTE_ORDER in explicit: + return merged + type_class = IMAGE_TYPE.get(str(merged.get(CONF_TYPE, "")).upper()) + if ( + CONF_BYTE_ORDER in merged + and isinstance(type_class, type) + and issubclass(type_class, ImageEncoder) + and not type_class.is_endian() + ): + if index is not None: + try: + validate_byte_order(merged[CONF_BYTE_ORDER]) + except cv.Invalid as exc: + exc.prepend([index]) + raise + del merged[CONF_BYTE_ORDER] + return merged + + +def _expand_platform_entry(index: int, entry: dict) -> list[dict]: + if CONF_FILES not in entry: + if CONF_DEFAULTS in entry: + raise cv.Invalid( + f"'{CONF_DEFAULTS}' may only be used together with '{CONF_FILES}'", + path=[index], + ) + return [entry] + + extra_keys = set(entry) - {CONF_PLATFORM, CONF_DEFAULTS, CONF_FILES} + if extra_keys: + raise cv.Invalid( + f"'{CONF_FILES}' cannot be combined with " + f"{', '.join(sorted(extra_keys))} on the same entry", + path=[index], + ) + + files = entry[CONF_FILES] + if files is None: + raise cv.Invalid(f"'{CONF_FILES}' must not be empty", path=[index]) + if not isinstance(files, list): + raise cv.Invalid(f"'{CONF_FILES}' must be a list", path=[index]) + if not files: + raise cv.Invalid(f"'{CONF_FILES}' must not be empty", path=[index]) + + defaults = entry.get(CONF_DEFAULTS, {}) + if defaults is None: + defaults = {} + if not isinstance(defaults, dict): + raise cv.Invalid(f"'{CONF_DEFAULTS}' must be a mapping", path=[index]) + # Neither `id:` nor `platform:` makes sense inside `defaults:`. + for disallowed in (CONF_ID, CONF_PLATFORM): + if disallowed in defaults: + raise cv.Invalid( + f"'{disallowed}' is not allowed inside '{CONF_DEFAULTS}'", + path=[index], + ) + + from esphome import yaml_util + + platform = entry[CONF_PLATFORM] + result: list[dict] = [] + for file_entry in files: + if not isinstance(file_entry, dict): + raise cv.Invalid( + f"each entry in '{CONF_FILES}' must be a mapping", path=[index] + ) + # The platform is chosen by the entry's own `platform:` key, not per file. + if CONF_PLATFORM in file_entry: + raise cv.Invalid( + f"'{CONF_PLATFORM}' is not allowed inside '{CONF_FILES}'", + path=[index], + ) + # Keep the `files:` item's source range so whole-entry errors anchor there; + # `make_data_base` needs a real ESPHomeDataBase, so skip it for plain dicts. + source = ( + file_entry if isinstance(file_entry, yaml_util.ESPHomeDataBase) else None + ) + merged = yaml_util.make_data_base( + {CONF_PLATFORM: platform, **defaults, **file_entry}, source + ) + result.append(_drop_incompatible_byte_order(merged, file_entry, index=index)) + return result + + +def expand_platform_config(config: list) -> list: + """Expand `defaults:`/`files:` entries; the platform's own CONFIG_SCHEMA validates each result.""" + result = [] + for i, entry in enumerate(config): + if isinstance(entry, dict) and CONF_PLATFORM in entry: + result.extend(_expand_platform_entry(i, entry)) + else: + result.append(entry) + return result + + +EXPAND_PLATFORM_CONFIG = expand_platform_config + +# --------------------- end defaults/files expansion ------------------------- + + # --------------------------------------------------------------------------- # Legacy top-level component -> `image:` platform deprecation helpers # -- REMOVE after 2027.1.0 together with the `animation:`/`online_image:` shims. @@ -496,11 +620,17 @@ def _is_legacy_image_format(config: object) -> bool: proper error instead of the migration silently dropping the input. """ if isinstance(config, list): - # A bare list of (not-yet-platform-tagged) image dicts. + # Exclude `files:` entries -- the list branch would otherwise silently + # migrate them to `platform: file` instead of raising the missing-platform error. return bool(config) and all( - isinstance(entry, dict) and CONF_PLATFORM not in entry for entry in config + isinstance(entry, dict) + and CONF_PLATFORM not in entry + and CONF_FILES not in entry + for entry in config ) - if not isinstance(config, dict): + if not isinstance(config, dict) or CONF_PLATFORM in config or CONF_FILES in config: + # `platform:`/`files:` dicts are new-format (left for list-wrapping + + # expansion); the legacy flattener has no `files:` branch and would drop them. return False # A single image dict, or the grouped `defaults:`/`images:`/type-key form. return ( @@ -532,18 +662,8 @@ def _flatten_legacy_image_config(config: object) -> list[dict]: def _add(entry: dict, extra: dict) -> None: merged = {**defaults, **extra, **entry} - # The legacy `defaults:`/type-grouped forms only applied `byte_order` to - # types that support it. Replicate that so an endian default merged into - # e.g. a binary image stays valid. - type_class = IMAGE_TYPE.get(str(merged.get(CONF_TYPE, "")).upper()) - if ( - CONF_BYTE_ORDER in merged - and isinstance(type_class, type) - and issubclass(type_class, ImageEncoder) - and not type_class.is_endian() - ): - del merged[CONF_BYTE_ORDER] - result.append(merged) + # Always drop, matching the pre-platform behavior -- see `_drop_incompatible_byte_order`. + result.append(_drop_incompatible_byte_order(merged, {})) def _add_entries(entries: object, extra: dict) -> None: # `entries` may be a single image dict or a list of them; non-dict diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index d8517d4493..9fa32a5a65 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -5,6 +5,7 @@ from esphome.components.const import CONF_BYTE_ORDER from esphome.components.image import ( IMAGE_TYPE, Image_, + validate_byte_order, validate_settings, validate_transparency, validate_type, @@ -128,9 +129,7 @@ def runtime_image_schema(image_class: cg.MockObjClass = RuntimeImage) -> cv.Sche cv.Required(CONF_FORMAT): cv.one_of(*IMAGE_FORMATS, upper=True), cv.Optional(CONF_RESIZE): cv.dimensions, cv.Required(CONF_TYPE): validate_type(IMAGE_TYPE), - cv.Optional(CONF_BYTE_ORDER): cv.one_of( - "BIG_ENDIAN", "LITTLE_ENDIAN", upper=True - ), + cv.Optional(CONF_BYTE_ORDER): validate_byte_order, cv.Optional(CONF_TRANSPARENCY, default="OPAQUE"): validate_transparency(), cv.Optional(CONF_PLACEHOLDER): cv.use_id(Image_), } diff --git a/esphome/config.py b/esphome/config.py index 987bb9c96a..13ec744ce4 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -620,6 +620,23 @@ class LoadValidationStep(ConfigValidationStep): elif not isinstance(self.conf, list): result[self.domain] = self.conf = [self.conf] + # Permanent expansion hook: a platform-tagged entry may expand into + # several (e.g. `image`'s `defaults:`/`files:`), for `platform:`-tagged dicts only. + if (expand := component.expand_platform_config) is not None and all( + isinstance(entry, dict) and CONF_PLATFORM in entry + for entry in self.conf + ): + with result.catch_error(path): + expanded = expand(self.conf) + if not isinstance(expanded, list): + # A non-list return is a component bug (not a user error): + # raise explicitly (survives -O/-OO) so it escapes catch_error. + raise TypeError( + f"{self.domain}: EXPAND_PLATFORM_CONFIG must " + f"return a list, got {type(expanded).__name__}" + ) + result[self.domain] = self.conf = expanded + # Process AUTO_LOAD _process_auto_load(result, component, path) diff --git a/esphome/loader.py b/esphome/loader.py index f994f0c5eb..23c6d1bfa5 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -164,6 +164,14 @@ class ComponentManifest: """ return getattr(self.module, "LEGACY_CONFIG_MIGRATE", None) + @property + def expand_platform_config( + self, + ) -> Callable[[list[ConfigType]], list[ConfigType]] | None: + """Optional `EXPAND_PLATFORM_CONFIG` callable; runs on the normalized `platform:`-tagged + entry list before per-entry CONFIG_SCHEMA. Must return a list (raise `cv.Invalid` for user errors).""" + return getattr(self.module, "EXPAND_PLATFORM_CONFIG", None) + @property def resources(self) -> list[FileResource]: """Return a list of all file resources defined in the package of this component. diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index 78462463b1..846c152cab 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -21,16 +21,20 @@ from esphome.components.image import ( CONF_OPAQUE, CONF_TRANSPARENCY, PLATFORM_FILE, + _expand_platform_entry, _flatten_legacy_image_config, _is_legacy_image_format, _is_new_image_format, _migrate_legacy_image_config, + expand_platform_config, get_all_image_metadata, get_image_metadata, ) from esphome.const import ( + CONF_DEFAULTS, CONF_DITHER, CONF_FILE, + CONF_FILES, CONF_ID, CONF_PLATFORM, CONF_RAW_DATA_ID, @@ -259,6 +263,15 @@ def test_flatten_keeps_byte_order_for_endian_type() -> None: assert out[0][CONF_BYTE_ORDER] == "little_endian" +def test_flatten_drops_byte_order_written_directly_on_legacy_entry() -> None: + """The legacy flattener drops an incompatible byte_order even when written directly on the entry.""" + out = _flatten_legacy_image_config( + {"binary": [{"id": "a", "file": "x.png", "byte_order": "little_endian"}]} + ) + assert out == [{"id": "a", "file": "x.png", "type": "binary"}] + assert CONF_BYTE_ORDER not in out[0] + + def test_flatten_skips_meta_and_unknown_keys() -> None: out = _flatten_legacy_image_config( { @@ -342,6 +355,42 @@ def test_migrate_legacy_warns_and_prepends_platform( ), pytest.param({"foo": 1}, False, id="dict_unknown_keys"), pytest.param("a string", False, id="scalar"), + # A `platform:`-tagged dict is the new format written without list brackets. + pytest.param( + {CONF_PLATFORM: "file", "id": "a", "file": "x.png"}, + False, + id="platform_tagged_flat_dict", + ), + pytest.param( + { + CONF_PLATFORM: "file", + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "x.png"}], + }, + False, + id="platform_tagged_defaults_files_dict", + ), + # `files:` without `platform:` is not legacy either -- the flattener has no branch for it. + pytest.param( + { + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "x.png"}], + }, + False, + id="defaults_files_dict_without_platform", + ), + # Same as above in a list -- without this exclusion it would be silently + # migrated to a hard-coded `platform: file` instead of raising the error. + pytest.param( + [ + { + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "x.png"}], + } + ], + False, + id="defaults_files_list_entry_without_platform", + ), ], ) def test_is_legacy_image_format(config: object, expected: bool) -> None: @@ -359,17 +408,290 @@ def test_is_legacy_image_format(config: object, expected: bool) -> None: def test_migrate_returns_none_for_invalid_legacy_shapes( config: object, caplog: pytest.LogCaptureFixture ) -> None: - """Unrecognised shapes are not migrated (and emit no warning) so normal - platform validation surfaces a proper error instead of silently dropping - the offending input.""" + """Unrecognised shapes are not migrated (and emit no warning), so normal platform validation reports them.""" with caplog.at_level(logging.WARNING): assert _migrate_legacy_image_config(config) is None assert "deprecated" not in caplog.text +def test_migrate_returns_none_for_mapping_form_defaults_files() -> None: + """A `platform:`-tagged `defaults:`/`files:` mapping must not be swallowed by the legacy migrator.""" + config = { + CONF_PLATFORM: "file", + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "a.png"}], + } + assert _migrate_legacy_image_config(config) is None + + +def test_migrate_returns_none_for_defaults_files_dict_without_platform() -> None: + """`defaults:`/`files:` without `platform:` must not be swallowed either -- the flattener has + no `files:` branch and would silently return `[]`.""" + config = { + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "a.png"}], + } + assert _migrate_legacy_image_config(config) is None + + +def test_migrate_returns_none_for_defaults_files_list_entry_without_platform() -> None: + """Same, in a list -- previously the list branch migrated it to a hard-coded + `platform: file` instead of raising a missing-platform error.""" + config = [ + { + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "a.png"}], + } + ] + assert _migrate_legacy_image_config(config) is None + + # --------------------------- end legacy migration -------------------------- +def test_expand_platform_entry_passes_through_plain_entry() -> None: + entry = {CONF_PLATFORM: "file", "id": "a", "file": "x.png"} + assert _expand_platform_entry(0, entry) == [entry] + + +def test_expand_platform_entry_expands_files_with_defaults() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "RGB565", "transparency": "opaque"}, + CONF_FILES: [ + {"id": "img1", "file": "foo.png"}, + {"id": "img2", "file": "bar.png", "type": "GRAYSCALE"}, + ], + } + assert _expand_platform_entry(0, entry) == [ + { + CONF_PLATFORM: "file", + "id": "img1", + "file": "foo.png", + "type": "RGB565", + "transparency": "opaque", + }, + { + CONF_PLATFORM: "file", + "id": "img2", + "file": "bar.png", + "type": "GRAYSCALE", + "transparency": "opaque", + }, + ] + + +def test_expand_platform_entry_files_without_defaults() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_FILES: [{"id": "img1", "file": "foo.png"}], + } + assert _expand_platform_entry(0, entry) == [ + {CONF_PLATFORM: "file", "id": "img1", "file": "foo.png"} + ] + + +def test_expand_platform_entry_preserves_source_range() -> None: + """A merged entry keeps the source range of its `files:` item so whole-entry errors anchor there.""" + from esphome import yaml_util + + file_entry = yaml_util.make_data_base({"id": "img1", "file": "foo.png"}) + file_entry._esp_range = "sentinel-range" + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "RGB565"}, + CONF_FILES: [file_entry], + } + [out] = _expand_platform_entry(0, entry) + assert isinstance(out, yaml_util.ESPHomeDataBase) + assert out.esp_range == "sentinel-range" + + +def test_expand_platform_entry_plain_dict_file_entry_has_no_source_range() -> None: + """Plain-dict `files:` items must not crash -- `from_database` reads `.esp_range` unconditionally.""" + entry = { + CONF_PLATFORM: "file", + CONF_FILES: [{"id": "img1", "file": "foo.png"}], + } + [out] = _expand_platform_entry(0, entry) + assert out == {CONF_PLATFORM: "file", "id": "img1", "file": "foo.png"} + + +def test_expand_platform_entry_per_file_overrides_win() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "RGB565"}, + CONF_FILES: [{"id": "img1", "file": "foo.png", "type": "BINARY"}], + } + [out] = _expand_platform_entry(0, entry) + assert out["type"] == "BINARY" + + +def test_expand_platform_entry_drops_byte_order_for_non_endian_override() -> None: + """A `byte_order` default merged into a non-endian override is dropped, as the legacy flattener did.""" + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "rgb565", "byte_order": "little_endian"}, + CONF_FILES: [ + {"id": "a", "file": "x.png"}, + {"id": "b", "file": "y.png", "type": "binary"}, + ], + } + out = _expand_platform_entry(0, entry) + assert out[0]["byte_order"] == "little_endian" + assert "byte_order" not in out[1] + + +def test_expand_platform_entry_invalid_byte_order_in_defaults_raises() -> None: + """A dropped `byte_order` inherited from `defaults:` is still validated, so a typo raises.""" + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "rgb565", "byte_order": "little_andian"}, + CONF_FILES: [{"id": "a", "file": "x.png", "type": "binary"}], + } + with pytest.raises(cv.Invalid, match="did you mean") as excinfo: + _expand_platform_entry(0, entry) + assert excinfo.value.path == [0] + + +def test_expand_platform_entry_keeps_byte_order_for_endian_override() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "rgb565", "byte_order": "big_endian"}, + CONF_FILES: [{"id": "a", "file": "x.png", "type": "rgb565"}], + } + [out] = _expand_platform_entry(0, entry) + assert out["byte_order"] == "big_endian" + + +def test_expand_platform_entry_keeps_explicit_byte_order_conflict() -> None: + """A `byte_order` written directly on the entry is kept so validate_settings raises the normal error.""" + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "rgb565"}, + CONF_FILES: [ + { + "id": "a", + "file": "x.png", + "type": "binary", + "byte_order": "little_endian", + } + ], + } + [out] = _expand_platform_entry(0, entry) + assert out["byte_order"] == "little_endian" + + +def test_expand_platform_entry_defaults_without_files_raises() -> None: + entry = {CONF_PLATFORM: "file", CONF_DEFAULTS: {"type": "RGB565"}} + with pytest.raises(cv.Invalid, match="may only be used together with") as excinfo: + _expand_platform_entry(0, entry) + assert excinfo.value.path == [0] + + +def test_expand_platform_entry_null_files_raises_not_empty() -> None: + """A `files:` key with no value parses to `None` and must be reported clearly.""" + entry = {CONF_PLATFORM: "file", CONF_DEFAULTS: {"type": "RGB565"}, CONF_FILES: None} + with pytest.raises(cv.Invalid, match="must not be empty"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_empty_files_list_raises_not_empty() -> None: + """An explicit `files: []` must not silently drop the whole platform entry.""" + entry = {CONF_PLATFORM: "file", CONF_FILES: []} + with pytest.raises(cv.Invalid, match="must not be empty"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_files_with_stray_key_raises() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_FILES: [{"id": "a", "file": "x.png"}], + "extra": 1, + } + with pytest.raises(cv.Invalid, match="cannot be combined with"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_id_in_defaults_raises() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {CONF_ID: "a"}, + CONF_FILES: [{"file": "x.png"}], + } + with pytest.raises(cv.Invalid, match="not allowed inside"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_platform_in_defaults_raises() -> None: + """`platform:` inside `defaults:` would silently reassign every file's platform.""" + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {CONF_PLATFORM: "animation"}, + CONF_FILES: [{"id": "a", "file": "x.png"}], + } + with pytest.raises(cv.Invalid, match="not allowed inside"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_platform_in_file_entry_raises() -> None: + """`platform:` on a `files:` item must not silently override the entry's platform.""" + entry = { + CONF_PLATFORM: "file", + CONF_FILES: [{"id": "a", "file": "x.png", CONF_PLATFORM: "animation"}], + } + with pytest.raises(cv.Invalid, match="not allowed inside"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_files_not_list_raises() -> None: + entry = {CONF_PLATFORM: "file", CONF_FILES: "not-a-list"} + with pytest.raises(cv.Invalid, match="must be a list"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_defaults_not_mapping_raises() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: "not-a-mapping", + CONF_FILES: [{"id": "a", "file": "x.png"}], + } + with pytest.raises(cv.Invalid, match="must be a mapping"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_file_item_not_mapping_raises() -> None: + entry = {CONF_PLATFORM: "file", CONF_FILES: [1, 2]} + with pytest.raises(cv.Invalid, match="must be a mapping"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_config_mixes_plain_and_expanded_entries() -> None: + config = [ + { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "RGB565"}, + CONF_FILES: [ + {"id": "img1", "file": "foo.png"}, + {"id": "img2", "file": "bar.png"}, + ], + }, + {CONF_PLATFORM: "file", "id": "plain", "file": "baz.png", "type": "BINARY"}, + ] + out = expand_platform_config(config) + assert [entry["id"] for entry in out] == ["img1", "img2", "plain"] + + +def test_expand_platform_config_ignores_non_platform_entries() -> None: + # Not expanded here -- legacy_config_migrate runs before this hook and is + # responsible for tagging/flattening pre-platform shapes. + config = ["not-a-platform-entry"] + assert expand_platform_config(config) == config + + +# --------------------- end defaults/files expansion ------------------------- + + def test_validate_image_final_defaults_to_little_endian() -> None: out = validate_image_final({CONF_FILE: "x.png"}) assert out[CONF_BYTE_ORDER] == "LITTLE_ENDIAN" diff --git a/tests/components/animation/validate-platform-defaults.host.yaml b/tests/components/animation/validate-platform-defaults.host.yaml new file mode 100644 index 0000000000..034497c548 --- /dev/null +++ b/tests/components/animation/validate-platform-defaults.host.yaml @@ -0,0 +1,21 @@ +# `platform: animation` entry exercising the shared `defaults:`/`files:` expansion. +display: + - platform: sdl + id: animation_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + - platform: animation + defaults: + type: rgb565 + transparency: opaque + resize: 50x50 + files: + - id: platform_defaults_animation + file: $component_dir/anim.gif + - id: platform_defaults_animation_rgb + file: $component_dir/anim.apng + type: rgb diff --git a/tests/components/image/validate-platform-defaults.host.yaml b/tests/components/image/validate-platform-defaults.host.yaml new file mode 100644 index 0000000000..e1b3037cc3 --- /dev/null +++ b/tests/components/image/validate-platform-defaults.host.yaml @@ -0,0 +1,24 @@ +# `platform: file` entry using the `defaults:`/`files:` shape, including the +# per-type byte_order drop when an entry overrides to a non-endian type. +display: + - platform: sdl + id: image_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + - platform: file + defaults: + type: rgb565 + transparency: opaque + byte_order: little_endian + resize: 50x50 + dither: FloydSteinberg + files: + - id: platform_defaults_image + file: ../../pnglogo.png + - id: platform_defaults_binary + file: ../../pnglogo.png + type: binary diff --git a/tests/unit_tests/test_config_normalization.py b/tests/unit_tests/test_config_normalization.py index c8b7b63094..04363ad45b 100644 --- a/tests/unit_tests/test_config_normalization.py +++ b/tests/unit_tests/test_config_normalization.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock, Mock, patch import pytest -from esphome import config, yaml_util +from esphome import config, config_validation as cv, yaml_util from esphome.core import CORE, AutoLoad from esphome.types import ConfigType @@ -127,12 +127,14 @@ def _run_load_step( domain: str, conf: object, migrate: Callable[[ConfigType], list | None] | None, + expand: Callable[[list], list] | None = None, ) -> config.Config: - """Run a LoadValidationStep for a platform component with a given migrate hook.""" + """Run a LoadValidationStep for a platform component with given hooks.""" component = Mock() component.is_platform_component = True component.multi_conf_no_default = False component.legacy_config_migrate = migrate + component.expand_platform_config = expand result = config.Config() with ( @@ -197,6 +199,124 @@ def test_legacy_migrate_skipped_for_autoload() -> None: assert result["image"] == [auto] +# --------------------------------------------------------------------------- +# EXPAND_PLATFORM_CONFIG hook on LoadValidationStep -- permanent counterpart +# to legacy_config_migrate; runs after legacy migration/list normalization. +# --------------------------------------------------------------------------- + + +def test_expand_hook_rewrites_conf() -> None: + """A config the expand hook rewrites is replaced with the expanded list.""" + expanded = [{"platform": "file", "id": "a"}, {"platform": "file", "id": "b"}] + expand = Mock(return_value=expanded) + + result = _run_load_step("image", [{"platform": "file", "id": "a"}], None, expand) + + expand.assert_called_once_with([{"platform": "file", "id": "a"}]) + assert result["image"] == expanded + + +def test_expand_hook_absent_is_noop() -> None: + """A platform component without the hook is left as normalized by the + existing list-wrapping logic.""" + result = _run_load_step("image", [{"platform": "file", "id": "a"}], None, None) + + assert result["image"] == [{"platform": "file", "id": "a"}] + + +def test_expand_hook_runs_after_legacy_migrate() -> None: + """The expand hook sees the already-migrated list, not the raw legacy conf.""" + migrated = [{"platform": "file", "id": "a"}] + migrate = Mock(return_value=migrated) + expand = Mock(side_effect=lambda conf: conf) + + _run_load_step("image", [{"id": "a", "file": "x.png"}], migrate, expand) + + expand.assert_called_once_with(migrated) + + +def test_expand_hook_skipped_for_non_dict_entry() -> None: + """Malformed entries are left alone; the hook only sees `platform:`-tagged dicts.""" + expand = Mock(side_effect=lambda conf: conf) + + result = _run_load_step("image", ["not-a-dict"], None, expand) + + expand.assert_not_called() + assert result["image"] == ["not-a-dict"] + + +def test_expand_hook_skipped_for_entry_missing_platform_key() -> None: + """A dict entry missing the `platform:` key is left alone -- the normal + per-entry error reporting further down catches this case instead.""" + expand = Mock(side_effect=lambda conf: conf) + + result = _run_load_step("image", [{"id": "a"}], None, expand) + + expand.assert_not_called() + assert result["image"] == [{"id": "a"}] + + +def test_expand_hook_skipped_for_autoload() -> None: + """A non-empty AutoLoad reaching the hook stage is left alone.""" + expand = Mock(side_effect=lambda conf: conf) + auto = AutoLoad() + auto["id"] = "a" + + result = _run_load_step("image", auto, None, expand) + + expand.assert_not_called() + assert result["image"] == [auto] + + +def test_expand_hook_runs_when_all_entries_are_platform_tagged_dicts() -> None: + """The guard does not block the normal, well-formed case.""" + expand = Mock(side_effect=lambda conf: conf) + conf = [{"platform": "file", "id": "a"}, {"platform": "animation", "id": "b"}] + + result = _run_load_step("image", conf, None, expand) + + expand.assert_called_once_with(conf) + assert result["image"] == conf + + +def test_expand_hook_invalid_reports_single_error_at_domain_path() -> None: + """A `cv.Invalid` from the hook is reported once with the domain path prepended; no further validation runs.""" + expand = Mock(side_effect=cv.Invalid("bad shape")) + pre_expand_conf = [{"platform": "file", "id": "a"}] + + result = _run_load_step("image", pre_expand_conf, None, expand) + + assert len(result.errors) == 1 + assert result.errors[0].path == ["image"] + assert "bad shape" in str(result.errors[0]) + assert result["image"] == pre_expand_conf + + +def test_expand_hook_final_external_invalid_reports_without_path_prepend() -> None: + """`cv.FinalExternalInvalid` keeps its already-resolved path (no domain path prepended).""" + already_resolved_error = cv.FinalExternalInvalid( + "bad shape", path=["image", 3, "files"] + ) + expand = Mock(side_effect=already_resolved_error) + pre_expand_conf = [{"platform": "file", "id": "a"}] + + result = _run_load_step("image", pre_expand_conf, None, expand) + + assert len(result.errors) == 1 + assert result.errors[0] is already_resolved_error + assert result.errors[0].path == ["image", 3, "files"] + assert result["image"] == pre_expand_conf + + +def test_expand_hook_non_list_return_raises_type_error() -> None: + """A non-list return is a component bug: it escapes as an uncaught TypeError + (explicit raise survives -O/-OO).""" + expand = Mock(return_value={"not": "a list"}) + + with pytest.raises(TypeError, match="must return a list"): + _run_load_step("image", [{"platform": "file", "id": "a"}], None, expand) + + def _write_merge_conflict_config(tmp_path: Path, *, suppress: bool) -> Path: """Create a config where two `<<` includes both define `logger:`. From 78a65eabdc6f33e6ac7f398a905217f61f779b64 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 19 Aug 2026 16:30:34 -0500 Subject: [PATCH 1581/1815] [ci] Stop jobs hanging on apt by restoring the cached apt action and bounding raw apt calls (#18518) --- .github/workflows/ci-api-proto.yml | 28 +++++++++- .github/workflows/ci.yml | 89 +++++++++++++++++++++++++----- 2 files changed, 101 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 820081cc46..771b4cd94f 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -41,10 +41,32 @@ jobs: version: "0.11.15" - name: Install apt dependencies + # PR-only workflow, so nothing on dev could seed a shared apt cache + # entry; the cached apt action would save one copy per PR. Plain apt + # with every call bounded: the apt.conf.d timeouts make a dead + # mirror fail over in seconds, and timeout runs under sudo so it can + # kill apt-get itself. Install without update first: image lists are + # fresh, and the index refresh is what a congested mirror makes slow. + timeout-minutes: 15 run: | - sudo apt update - sudo apt-cache show protobuf-compiler - sudo apt install -y protobuf-compiler + sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF' + Acquire::Retries "1"; + Acquire::http::Timeout "15"; + Acquire::https::Timeout "15"; + EOF + # Common path: the image's package lists are fresh enough. + if sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 \ + apt-get install -y protobuf-compiler; then + protoc --version + exit 0 + fi + # Rescue path: refresh the lists once with a generous bound; the + # apt config already fails a stalled mirror over quickly. + sudo DEBIAN_FRONTEND=noninteractive timeout -k 10 30 \ + dpkg --configure -a || true + sudo timeout -k 15 300 apt-get update + sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 \ + apt-get install -y protobuf-compiler protoc --version - name: Install python dependencies run: uv pip install --system aioesphomeapi -c requirements.txt -r requirements_dev.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d8f35ed83..d2d4c7a2a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,22 @@ jobs: uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt uv pip install -e . + seed-apt-cache: + name: Seed apt package cache + runs-on: ubuntu-24.04 + # PR-branch cache saves are invisible to other PRs, so dev/beta/release + # pushes seed the one shared entry PR jobs restore. The key is derived + # only from the package list and version; keep both identical in every + # step that restores it. In ci-status needs so a broken seed fails dev. + if: github.event_name == 'push' + timeout-minutes: 10 + steps: + - name: Install apt packages (cached) + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 + with: + packages: libsdl2-dev ccache + version: 1.1 + determine-jobs: name: Determine which jobs to run runs-on: ubuntu-24.04 @@ -323,7 +339,8 @@ jobs: integration-tests: name: Run integration tests (${{ matrix.bucket.name }}) - runs-on: ubuntu-latest + # Must match seed-apt-cache's image: the apt cache key has no OS in it. + runs-on: ubuntu-24.04 needs: - common - determine-jobs @@ -335,12 +352,16 @@ jobs: steps: - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install ccache - # Speeds up the host compiles: tests in a bucket compile overlapping - # component sets, so later tests reuse earlier tests' objects. - run: | - sudo apt-get update -qq - sudo apt-get install -y --no-install-recommends ccache + - name: Install apt packages (cached) + # ccache speeds up the host compiles. A cache hit never touches apt + # (mirror outages cannot hang the job); the timeout bounds the cold + # path. Packages and version must match seed-apt-cache exactly; + # libsdl2-dev is unused here and carried only for cache-key parity. + timeout-minutes: 10 + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 + with: + packages: libsdl2-dev ccache + version: 1.1 - name: Set up Python 3.13 id: python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -421,6 +442,7 @@ jobs: benchmarks: name: Run CodSpeed benchmarks runs-on: ubuntu-24.04 + timeout-minutes: 30 needs: - common - determine-jobs @@ -457,6 +479,41 @@ jobs: fi echo "binary=$BINARY" >> $GITHUB_OUTPUT + - name: Bound apt fetches and pre-install libc6-dbg + # The CodSpeed runner installs valgrind + libc6-dbg via its own + # unbounded apt-get update; per-invocation apt options cannot reach + # it. The apt.conf.d timeouts below bound every later apt call in + # this job, the runner's included. Pre-installing libc6-dbg lets the + # runner skip apt once its valgrind cache is restored (it checks + # ``dpkg -s libc6-dbg``, so the cache action's unregistered restores + # would not count). Install without update first: image lists are + # fresh, and the index refresh is what a congested mirror makes + # slow. Best effort; the job timeout is the last backstop. + timeout-minutes: 15 + continue-on-error: true + run: | + sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF' + Acquire::Retries "1"; + Acquire::http::Timeout "15"; + Acquire::https::Timeout "15"; + EOF + if dpkg -s libc6-dbg >/dev/null 2>&1; then + echo "libc6-dbg already installed" + exit 0 + fi + # Common path: the image's package lists are fresh enough. + if sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 \ + apt-get install -y libc6-dbg; then + exit 0 + fi + # Rescue path: refresh the lists once with a generous bound; the + # apt config already fails a stalled mirror over quickly. + sudo DEBIAN_FRONTEND=noninteractive timeout -k 10 30 \ + dpkg --configure -a || true + sudo timeout -k 15 300 apt-get update + sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 \ + apt-get install -y libc6-dbg + - name: Run CodSpeed benchmarks uses: CodSpeedHQ/action@4296e51e7041e24dadb86d1d6e8b9320d223dbe8 # v5.0.3 with: @@ -875,12 +932,17 @@ jobs: - name: List components run: echo ${{ matrix.batch.components }} - - name: Install apt packages - # Not cached: this job is pull-request-only, so a cache save could - # never be shared and would only consume quota. - run: | - sudo apt-get update -qq - sudo apt-get install -y --no-install-recommends libsdl2-dev ccache + - name: Install apt packages (cached) + # A cache hit (seeded on dev by seed-apt-cache) never touches apt, + # so mirror outages cannot hang this PR-only job; the timeout bounds + # the cold path. Packages and version must match seed-apt-cache + # exactly. The action has no --no-install-recommends; same package + # set this job used before #17463. + timeout-minutes: 10 + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 + with: + packages: libsdl2-dev ccache + version: 1.1 - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -1415,6 +1477,7 @@ jobs: # this check. needs: - common + - seed-apt-cache - determine-jobs - ci-custom - pylint From f735dcadc0c38f25eec83cf4f5eba97bc62e30d6 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:33:05 +1200 Subject: [PATCH 1582/1815] Bump version to 2026.8.0b6 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 3dad4629be..c83d95d0ef 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.8.0b5 +PROJECT_NUMBER = 2026.8.0b6 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index e86465f9a0..2296f8c0b7 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.8.0b5" +__version__ = "2026.8.0b6" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From c45599196235345e2f13415eccb537b2d6e13b49 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 19 Aug 2026 11:39:01 -0500 Subject: [PATCH 1583/1815] [ci] Key PlatformIO cache on the Python version so a runner image bump does not serve a broken LibreTiny venv (#18512) --- .github/workflows/ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2d4c7a2a1..fa119fb6d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -598,24 +598,29 @@ jobs: fetch-depth: 2 - name: Restore Python + id: restore-python uses: ./.github/actions/restore-python with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} + # Key on the exact Python version as well: LibreTiny creates a venv under + # ~/.platformio/penv whose interpreter is a symlink into the runner's + # hosted toolcache, so a cache saved on an older runner image breaks once + # a new image ships a newer patch release and drops the old interpreter. - name: Cache platformio if: github.ref == 'refs/heads/dev' && matrix.pio_cache_key uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio - key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} + key: platformio-${{ matrix.pio_cache_key }}-${{ steps.restore-python.outputs.python-version }}-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' && matrix.pio_cache_key uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio - key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} + key: platformio-${{ matrix.pio_cache_key }}-${{ steps.restore-python.outputs.python-version }}-${{ hashFiles('platformio.ini') }} - name: Cache ESP-IDF install if: matrix.cache_idf From 185f12266a3f9ae0248df1a38ce96f2cc6aae7b0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 19 Aug 2026 17:29:10 -0500 Subject: [PATCH 1584/1815] [tests] Keep PlatformIO libdeps per xdist worker to stop a compile race (#18524) --- tests/integration/conftest.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 1bf799b658..483d5392af 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -60,7 +60,11 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]: env = os.environ.copy() env["PLATFORMIO_CORE_DIR"] = str(cache_dir) env["PLATFORMIO_CACHE_DIR"] = str(cache_dir / ".cache") - env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps") + # libdeps is keyed only by env name (the device name), and fixtures share + # names; two xdist workers first-compiling the same name race pio pkg + # install in the same directory. Keep libdeps per worker. + worker = os.environ.get("PYTEST_XDIST_WORKER", "master") + env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps" / worker) # Prevent cache cleaning during integration tests env["ESPHOME_SKIP_CLEAN_BUILD"] = "1" # Compile with THIS tree's esphome sources, not wherever the venv's editable From d9359a70c1ef82ab907aba6adad45a27cd5d5fab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 19 Aug 2026 17:29:10 -0500 Subject: [PATCH 1585/1815] [tests] Keep PlatformIO libdeps per xdist worker to stop a compile race (#18524) --- tests/integration/conftest.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 1bf799b658..483d5392af 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -60,7 +60,11 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]: env = os.environ.copy() env["PLATFORMIO_CORE_DIR"] = str(cache_dir) env["PLATFORMIO_CACHE_DIR"] = str(cache_dir / ".cache") - env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps") + # libdeps is keyed only by env name (the device name), and fixtures share + # names; two xdist workers first-compiling the same name race pio pkg + # install in the same directory. Keep libdeps per worker. + worker = os.environ.get("PYTEST_XDIST_WORKER", "master") + env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps" / worker) # Prevent cache cleaning during integration tests env["ESPHOME_SKIP_CLEAN_BUILD"] = "1" # Compile with THIS tree's esphome sources, not wherever the venv's editable From 828eac90f36ffe9dd1fa714f41b27377fda2cafd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:51:59 +1200 Subject: [PATCH 1586/1815] Bump version to 2026.8.0 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index c83d95d0ef..ed0670621d 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.8.0b6 +PROJECT_NUMBER = 2026.8.0 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 2296f8c0b7..17ff1e17d9 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.8.0b6" +__version__ = "2026.8.0" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From ca97c86d6580746e12e071351bf3d219ef7de51f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 19:06:55 -0500 Subject: [PATCH 1587/1815] [ci] Install requirements_dev.txt when the venv cache misses (#18502) --- .github/actions/restore-python/action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index daf041819c..fab6dc6ffb 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -49,7 +49,7 @@ runs: python -m venv venv source venv/bin/activate python --version - uv pip install -r requirements.txt -r requirements_test.txt + uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt uv pip install -e . - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os == 'Windows' @@ -58,5 +58,5 @@ runs: python -m venv venv source ./venv/Scripts/activate python --version - uv pip install -r requirements.txt -r requirements_test.txt + uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt uv pip install -e . From b7d0b676fc0cd5f93a772f6f11d398a157ae612d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20H=C3=A4ll?= Date: Thu, 20 Aug 2026 06:31:27 +0200 Subject: [PATCH 1588/1815] [wifi] Take the lwIP core lock around sntp_servermode_dhcp() (#18511) --- esphome/components/wifi/wifi_component_esp_idf.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 245390b097..24cb060edb 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -580,7 +580,14 @@ bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { // lwIP starts the SNTP client if it gets an SNTP server from DHCP. We don't need the time, and more importantly, // the built-in SNTP client has a memory leak in certain situations. Disable this feature. // https://github.com/esphome/issues/issues/2299 - sntp_servermode_dhcp(false); + { +#if SNTP_GET_SERVERS_FROM_DHCP || SNTP_GET_SERVERS_FROM_DHCPV6 + // sntp_servermode_dhcp() is an empty macro unless lwIP is built with + // DHCP-supplied NTP servers, so only that build needs the core lock. + LwIPLock lock; +#endif + sntp_servermode_dhcp(false); + } // No manual IP is set; use DHCP client if (dhcp_status != ESP_NETIF_DHCP_STARTED) { From 5e9de7c94bde17b782e643a1d12745f67e23f3d6 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:00:44 -0500 Subject: [PATCH 1589/1815] Bump bundled esphome-device-builder to 1.12.1 (#18541) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 18f705b501..55aa0ac982 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.12.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.12.1 RUN \ platformio settings set enable_telemetry No \ From 132f494195869750e7ccf3ad2a66f58c0234da21 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 06:59:57 -0500 Subject: [PATCH 1590/1815] [nrf52] Rebuild the Python env when its interpreter symlink dangles (#18540) --- esphome/components/nrf52/framework.py | 26 ++++--- tests/unit_tests/test_nrf52_framework.py | 90 +++++++++++++++++++++++- 2 files changed, 107 insertions(+), 9 deletions(-) diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 6b32fe1fea..d487820440 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -62,6 +62,22 @@ def get_sdk_nrf_tools_path() -> Path: return path.resolve() +def _needs_venv_rebuild( + env_python_path: Path, sentinel: Path, requirements_hash: str +) -> bool: + """True when a penv must be (re)built. + + Rebuild when the interpreter is not a regular file, which covers a + dangling symlink (a cached venv outliving a host interpreter upgrade) + and a corrupt restore, or when the sentinel is missing or stale. + """ + return ( + not env_python_path.is_file() + or not sentinel.exists() + or sentinel.read_text(encoding="utf-8") != requirements_hash + ) + + def _get_python_env_path(version: str) -> Path: return get_sdk_nrf_tools_path() / "penvs" / version @@ -198,10 +214,7 @@ def setup_platformio_python_env() -> None: + "\n".join(_PLATFORMIO_PENV_REQUIREMENTS).encode() + f"python{sys.version_info.major}.{sys.version_info.minor}".encode() ).hexdigest() - if ( - not sentinel.exists() - or sentinel.read_text(encoding="utf-8") != requirements_hash - ): + if _needs_venv_rebuild(env_python_path, sentinel, requirements_hash): rmdir(penv_path, msg="Clean up PlatformIO toolchain Python environment") create_venv(penv_path, msg="PlatformIO toolchain") @@ -250,10 +263,7 @@ def check_and_install() -> None: env_python_path = get_python_env_executable_path(python_env_path, "python") sentinel = python_env_path / ".ready" requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest() - install_venv = ( - not sentinel.exists() - or sentinel.read_text(encoding="utf-8") != requirements_hash - ) + install_venv = _needs_venv_rebuild(env_python_path, sentinel, requirements_hash) if install_venv: rmdir(python_env_path, msg=f"Clean up {version} Python environment") diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index 0a6bddc280..c2ee0c2a75 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -16,6 +16,7 @@ from esphome.components.nrf52.framework import ( _get_penv_site_packages, _get_platformio_penv_path, _get_toolchain_platform_info, + _needs_venv_rebuild, check_and_install, get_build_env, get_sdk_nrf_tools_path, @@ -123,10 +124,19 @@ def mock_nrf52_ops(): # --------------------------------------------------------------------------- +def _touch_penv_python(penv: Path) -> None: + """Create the interpreter file so the rebuild gate sees a live venv.""" + python = get_python_env_executable_path(penv, "python") + python.parent.mkdir(parents=True, exist_ok=True) + python.touch() + + def _mark_venv_ready(python_env: Path) -> None: - """Write the venv sentinel with the current requirements hash.""" + """Write the venv sentinel with the current requirements hash and a + present interpreter so the rebuild gate passes.""" requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest() (python_env / ".ready").write_text(requirements_hash, encoding="utf-8") + _touch_penv_python(python_env) class TestCheckAndInstall: @@ -148,6 +158,23 @@ class TestCheckAndInstall: mock_nrf52_ops.download_from_mirrors.assert_not_called() mock_nrf52_ops.archive_extract_all.assert_not_called() + def test_missing_interpreter_rebuilds_venv( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A valid sentinel must not mask a missing interpreter (a cached venv + restored after a host interpreter upgrade).""" + requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest() + (nrf52_dirs.python_env / ".ready").write_text( + requirements_hash, encoding="utf-8" + ) + # no interpreter on disk + + check_and_install() + + mock_nrf52_ops.create_venv.assert_called_once() + def test_fresh_install_runs_all_steps( self, nrf52_dirs: SimpleNamespace, @@ -348,6 +375,7 @@ class TestSetupPlatformioPythonEnv: (platformio_penv_dir / ".ready").write_text( _platformio_requirements_hash(), encoding="utf-8" ) + _touch_penv_python(platformio_penv_dir) with patch.dict(os.environ): setup_platformio_python_env() @@ -392,6 +420,22 @@ class TestSetupPlatformioPythonEnv: assert not (platformio_penv_dir / ".ready").exists() + def test_missing_interpreter_reinstalls( + self, + platformio_penv_dir: Path, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A valid sentinel must not mask a missing interpreter.""" + (platformio_penv_dir / ".ready").write_text( + _platformio_requirements_hash(), encoding="utf-8" + ) + # no interpreter on disk + + with patch.dict(os.environ): + setup_platformio_python_env() + + mock_nrf52_ops.create_venv.assert_called_once() + def test_repeated_calls_do_not_duplicate_env_entries( self, platformio_penv_dir: Path, @@ -401,6 +445,7 @@ class TestSetupPlatformioPythonEnv: (platformio_penv_dir / ".ready").write_text( _platformio_requirements_hash(), encoding="utf-8" ) + _touch_penv_python(platformio_penv_dir) site_packages = str(_get_penv_site_packages(platformio_penv_dir)) bin_dir = str( get_python_env_executable_path(platformio_penv_dir, "python").parent @@ -422,6 +467,7 @@ class TestSetupPlatformioPythonEnv: (platformio_penv_dir / ".ready").write_text( _platformio_requirements_hash(), encoding="utf-8" ) + _touch_penv_python(platformio_penv_dir) site_packages = str(_get_penv_site_packages(platformio_penv_dir)) with patch.dict(os.environ, {"PYTHONPATH": "/existing/path"}): @@ -531,3 +577,45 @@ def testget_tools_path_default_is_global_cache( Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "sdk-nrf" ).resolve() assert get_sdk_nrf_tools_path() == expected + + +def test_needs_venv_rebuild_gates(tmp_path: Path) -> None: + """The shared penv gate rebuilds on any missing or stale piece.""" + penv = tmp_path / "penv" + penv.mkdir() + python = penv / "python" + sentinel = penv / ".ready" + good_hash = "abc123" + + # Nothing in place yet + assert _needs_venv_rebuild(python, sentinel, good_hash) + + python.write_text("") + # Interpreter present but no sentinel + assert _needs_venv_rebuild(python, sentinel, good_hash) + + sentinel.write_text(good_hash, encoding="utf-8") + # Everything in place + assert not _needs_venv_rebuild(python, sentinel, good_hash) + + # Stale requirements hash + assert _needs_venv_rebuild(python, sentinel, "otherhash") + + +@pytest.mark.skipif( + sys.platform == "win32", reason="symlink creation needs privileges on Windows" +) +def test_needs_venv_rebuild_on_dangling_interpreter_symlink(tmp_path: Path) -> None: + """A cached venv restored after a host interpreter upgrade has a + bin/python symlink whose target is gone; the valid sentinel must not + mask it.""" + penv = tmp_path / "penv" + penv.mkdir() + python = penv / "python" + sentinel = penv / ".ready" + sentinel.write_text("abc123", encoding="utf-8") + python.symlink_to(tmp_path / "hostedtoolcache" / "3.12.14" / "python3") + assert python.is_symlink() + assert not python.exists() + + assert _needs_venv_rebuild(python, sentinel, "abc123") From aafeca585920d39990457e46fec68faf8d4ae2d8 Mon Sep 17 00:00:00 2001 From: Alar Aun Date: Thu, 20 Aug 2026 16:54:28 +0300 Subject: [PATCH 1591/1815] [modbus_controller] Brace single-statement log bodies to fix -Wempty-body (#18543) --- esphome/components/modbus_controller/modbus_controller.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 2c568938e4..515459f62a 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -200,8 +200,9 @@ void ModbusController::update_range_(ModbusCommandItem &cmd) { return; } // A refusal is already logged by the hub; note the affected range for controller-level diagnostics. - if (!cmd.send()) + if (!cmd.send()) { ESP_LOGD(TAG, "Poll refused by hub for range 0x%X", cmd.register_address()); + } } void ModbusController::update() { @@ -214,8 +215,9 @@ void ModbusController::update() { ESP_LOGV(TAG, "Module offline - retrying"); this->cmd_non_responses_ = 0; // allow the probe through can_send() for (auto &cmd : this->polling_command_items_) { - if (!cmd.send()) + if (!cmd.send()) { ESP_LOGD(TAG, "Probe refused by hub for range 0x%X", cmd.register_address()); + } } } else { ESP_LOGV(TAG, "Module offline - skipping update"); From 3c47ab42d63b53026dcfa611baca05d08b79e3ea Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 01:55:15 +1200 Subject: [PATCH 1592/1815] [core] Add type annotations to component Python (1/11) (#18338) --- esphome/components/bl0906/sensor.py | 12 +++++-- esphome/components/datetime/__init__.py | 36 +++++++++++++------ esphome/components/esp32_ble/__init__.py | 30 ++++++++++++---- esphome/components/esp32_rmt/__init__.py | 14 +++++--- esphome/components/espnow/__init__.py | 27 ++++++++------ .../espnow/packet_transport/__init__.py | 3 +- esphome/components/http_request/__init__.py | 20 +++++++---- .../components/http_request/ota/__init__.py | 13 +++++-- .../http_request/update/__init__.py | 3 +- esphome/components/i2s_audio/__init__.py | 13 +++---- .../i2s_audio/microphone/__init__.py | 13 +++---- .../components/i2s_audio/speaker/__init__.py | 13 +++---- esphome/components/mcp23xxx_base/__init__.py | 8 +++-- esphome/components/mcp4461/__init__.py | 3 +- esphome/components/mcp4461/output/__init__.py | 28 ++++++++++++--- esphome/components/microphone/__init__.py | 31 ++++++++++------ esphome/components/pn532/__init__.py | 14 ++++++-- esphome/components/pn532/binary_sensor.py | 7 ++-- esphome/components/pn7150/__init__.py | 26 +++++++++++--- esphome/components/pn7160/__init__.py | 26 +++++++++++--- 20 files changed, 245 insertions(+), 95 deletions(-) diff --git a/esphome/components/bl0906/sensor.py b/esphome/components/bl0906/sensor.py index 059e10e962..1a0c2287ab 100644 --- a/esphome/components/bl0906/sensor.py +++ b/esphome/components/bl0906/sensor.py @@ -32,6 +32,9 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType # Import ICONS not included in esphome's const.py, from the local components const.py from .const import ICON_ENERGY, ICON_FREQUENCY, ICON_VOLTAGE @@ -145,13 +148,18 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ), synchronous=True, ) -async def reset_energy_to_code(config, action_id, template_arg, args): +async def reset_energy_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index 87997daa3d..f8b6446006 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -21,13 +21,14 @@ from esphome.const import ( CONF_WEB_SERVER, CONF_YEAR, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@rfdarter", "@jesserockz"] @@ -65,7 +66,7 @@ DATETIME_MODES = [ ] -def _validate_time_present(config): +def _validate_time_present(config: ConfigType) -> ConfigType: config = config.copy() if CONF_ON_TIME in config and CONF_TIME_ID not in config: time_id = cv.use_id(time.RealTimeClock)(None) @@ -139,7 +140,7 @@ def datetime_schema(class_: MockObjClass) -> cv.Schema: @setup_entity("datetime") -async def setup_datetime_core_(var, config): +async def setup_datetime_core_(var: MockObj, config: ConfigType) -> None: if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: mqtt_ = cg.new_Pvariable(mqtt_id, var) await mqtt.register_mqtt_component(mqtt_, config) @@ -160,7 +161,7 @@ async def setup_datetime_core_(var, config): await cg.register_parented(trigger, var) -async def register_datetime(var, config): +async def register_datetime(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) entity_type = config[CONF_TYPE].lower() @@ -169,14 +170,14 @@ async def register_datetime(var, config): await setup_datetime_core_(var, config) -async def new_datetime(config, *args): +async def new_datetime(config: ConfigType, *args: SafeExpType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID], *args) await register_datetime(var, config) return var @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(datetime_ns.using) @@ -193,7 +194,12 @@ async def to_code(config): ), synchronous=True, ) -async def datetime_date_set_to_code(config, action_id, template_arg, args): +async def datetime_date_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: action_var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(action_var, config[CONF_ID]) @@ -226,7 +232,12 @@ async def datetime_date_set_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def datetime_time_set_to_code(config, action_id, template_arg, args): +async def datetime_time_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: action_var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(action_var, config[CONF_ID]) @@ -259,7 +270,12 @@ async def datetime_time_set_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def datetime_datetime_set_to_code(config, action_id, template_arg, args): +async def datetime_datetime_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: action_var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(action_var, config[CONF_ID]) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index f099c68e57..79747c6f31 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -31,7 +31,8 @@ from esphome.const import ( CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, ) -from esphome.core import CORE, TimePeriod +from esphome.core import CORE, ID, TimePeriod +from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv from esphome.types import ConfigType @@ -383,7 +384,7 @@ def _validate_key_sizes(config: ConfigType) -> ConfigType: CONFIG_SCHEMA = cv.All(CONFIG_SCHEMA, _validate_key_sizes) -def validate_variant(_): +def validate_variant(_: ConfigType) -> None: variant = get_esp32_variant() if variant in NO_BLUETOOTH_VARIANTS: raise cv.Invalid(f"{variant} does not support Bluetooth") @@ -443,7 +444,7 @@ def validate_connection_slots(max_connections: int) -> None: ) -def final_validation(config) -> None: +def final_validation(config: ConfigType) -> None: validate_variant(config) if (name := config.get(CONF_NAME)) is not None: full_config = fv.full_config.get() @@ -518,7 +519,7 @@ def final_validation(config) -> None: FINAL_VALIDATE_SCHEMA = final_validation -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT])) cg.add(var.set_io_capability(config[CONF_IO_CAPABILITY])) @@ -605,19 +606,34 @@ async def to_code(config): @automation.register_condition("ble.enabled", BLEEnabledCondition, cv.Schema({})) -async def ble_enabled_to_code(config, condition_id, template_arg, args): +async def ble_enabled_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: return cg.new_Pvariable(condition_id, template_arg) @automation.register_action( "ble.enable", BLEEnableAction, cv.Schema({}), synchronous=True ) -async def ble_enable_to_code(config, action_id, template_arg, args): +async def ble_enable_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: return cg.new_Pvariable(action_id, template_arg) @automation.register_action( "ble.disable", BLEDisableAction, cv.Schema({}), synchronous=True ) -async def ble_disable_to_code(config, action_id, template_arg, args): +async def ble_disable_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: return cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/esp32_rmt/__init__.py b/esphome/components/esp32_rmt/__init__.py index 1076bcabdc..a213a78778 100644 --- a/esphome/components/esp32_rmt/__init__.py +++ b/esphome/components/esp32_rmt/__init__.py @@ -1,17 +1,23 @@ +from collections.abc import Callable, Iterable +from typing import Any + from esphome.components import esp32 import esphome.config_validation as cv from esphome.core import CORE +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] VARIANTS_NO_RMT = {esp32.VARIANT_ESP32C2, esp32.VARIANT_ESP32C61} -def validate_rmt_not_supported(rmt_only_keys): +def validate_rmt_not_supported( + rmt_only_keys: Iterable[str], +) -> Callable[[ConfigType], ConfigType]: """Validate that RMT-only config keys are not used on variants without RMT hardware.""" rmt_only_keys = set(rmt_only_keys) - def _validator(config): + def _validator(config: ConfigType) -> ConfigType: if CORE.is_esp32: variant = esp32.get_esp32_variant() if variant in VARIANTS_NO_RMT: @@ -26,8 +32,8 @@ def validate_rmt_not_supported(rmt_only_keys): return _validator -def validate_clock_resolution(): - def _validator(value): +def validate_clock_resolution() -> Callable[[Any], int]: + def _validator(value: Any) -> int: cv.only_on_esp32(value) value = cv.int_(value) variant = esp32.get_esp32_variant() diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index 373ef345d1..ee3732c406 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation, core import esphome.codegen as cg from esphome.components import wifi @@ -14,6 +16,7 @@ from esphome.const import ( CONF_WIFI, ) from esphome.core import CORE, HexInt +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] @@ -78,7 +81,7 @@ CONF_CONTINUE_ON_ERROR = "continue_on_error" CONF_WAIT_FOR_SENT = "wait_for_sent" -def _validate_max_payload_size(value: int) -> int: +def _validate_max_payload_size(value: Any) -> int: if value > ESPNOW_PAYLOAD_V1: return cv.require_framework_version( esp_idf=cv.Version(5, 4, 0), @@ -88,7 +91,7 @@ def _validate_max_payload_size(value: int) -> int: return value -def validate_channel(value): +def validate_channel(value: Any) -> int: if value is None: raise cv.Invalid("channel is required if wifi is not configured") return wifi.validate_channel(value) @@ -129,7 +132,7 @@ CONFIG_SCHEMA = cv.All( ) -async def _trigger_to_code(config): +async def _trigger_to_code(config: ConfigType) -> MockObj: if address := config.get(CONF_ADDRESS): address = address.parts trigger = cg.new_Pvariable(config[CONF_TRIGGER_ID], address) @@ -145,7 +148,7 @@ async def _trigger_to_code(config): return trigger -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -180,13 +183,13 @@ async def to_code(config): # ========================================== A C T I O N S ================================================ -def validate_peer(value): +def validate_peer(value: Any) -> Any: if isinstance(value, cv.Lambda): return cv.returning_lambda(value) return cv.mac_address(value) -def _validate_raw_data(value): +def _validate_raw_data(value: Any) -> str | list: if isinstance(value, str): if len(value) > MAX_ESPNOW_PACKET_SIZE: raise cv.Invalid( @@ -204,7 +207,9 @@ def _validate_raw_data(value): ) -async def register_peer(var, config, args): +async def register_peer( + var: MockObj, config: ConfigType, args: TemplateArgsType +) -> None: peer = config[CONF_ADDRESS] if isinstance(peer, core.MACAddress): peer = [HexInt(p) for p in peer.parts] @@ -231,7 +236,7 @@ SEND_SCHEMA = PEER_SCHEMA.extend( ) -def _validate_send_action(config): +def _validate_send_action(config: ConfigType) -> ConfigType: if not config[CONF_WAIT_FOR_SENT] and not config[CONF_CONTINUE_ON_ERROR]: raise cv.Invalid( f"'{CONF_CONTINUE_ON_ERROR}' cannot be false if '{CONF_WAIT_FOR_SENT}' is false as the automation will not wait for the failed result.", @@ -267,7 +272,7 @@ async def send_action( action_id: core.ID, template_arg: cg.TemplateArguments, args: list[tuple], -): +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) @@ -316,7 +321,7 @@ async def peer_action( action_id: core.ID, template_arg: cg.TemplateArguments, args: list[tuple], -): +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) await register_peer(var, config, args) @@ -341,7 +346,7 @@ async def channel_action( action_id: core.ID, template_arg: cg.TemplateArguments, args: list[tuple], -): +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_CHANNEL], args, cg.uint8) diff --git a/esphome/components/espnow/packet_transport/__init__.py b/esphome/components/espnow/packet_transport/__init__.py index e6d66440db..ee4706ca1c 100644 --- a/esphome/components/espnow/packet_transport/__init__.py +++ b/esphome/components/espnow/packet_transport/__init__.py @@ -9,6 +9,7 @@ from esphome.components.packet_transport import ( import esphome.config_validation as cv from esphome.core import HexInt from esphome.cpp_types import PollingComponent +from esphome.types import ConfigType from .. import ESPNowComponent, espnow_ns @@ -28,7 +29,7 @@ CONFIG_SCHEMA = transport_schema(ESPNowTransport).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: """Set up the ESP-NOW transport component.""" var, _ = await new_packet_transport(config) diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index 54d7f5c77b..afc39e06a8 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import Any from esphome import automation import esphome.codegen as cg @@ -20,8 +21,10 @@ from esphome.const import ( PlatformFramework, __version__, ) -from esphome.core import CORE, Lambda +from esphome.core import CORE, ID, Lambda +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.helpers import IS_MACOS +from esphome.types import ConfigType DEPENDENCIES = ["network"] AUTO_LOAD = ["json", "watchdog"] @@ -63,14 +66,14 @@ CONF_BODY = "body" CONF_JSON = "json" -def validate_url(value): +def validate_url(value: Any) -> str: value = cv.url(value) if value.startswith(("http://", "https://")): return value raise cv.Invalid("URL must start with 'http://' or 'https://'") -def validate_ssl_verification(config): +def validate_ssl_verification(config: ConfigType) -> ConfigType: error_message = "" if CORE.is_rp2 and config[CONF_VERIFY_SSL]: @@ -91,7 +94,7 @@ def validate_ssl_verification(config): return config -def _declare_request_class(value): +def _declare_request_class(value: Any) -> ID: if CORE.is_host: return cv.declare_id(HttpRequestHost)(value) if CORE.is_esp32: @@ -151,7 +154,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_timeout(config[CONF_TIMEOUT])) cg.add(var.set_useragent(config[CONF_USERAGENT])) @@ -298,7 +301,12 @@ HTTP_REQUEST_SEND_ACTION_SCHEMA = HTTP_REQUEST_ACTION_SCHEMA.extend( HTTP_REQUEST_SEND_ACTION_SCHEMA, synchronous=True, ) -async def http_request_action_to_code(config, action_id, template_arg, args): +async def http_request_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/http_request/ota/__init__.py b/esphome/components/http_request/ota/__init__.py index b7026e0f55..784e4ee47a 100644 --- a/esphome/components/http_request/ota/__init__.py +++ b/esphome/components/http_request/ota/__init__.py @@ -3,8 +3,10 @@ import esphome.codegen as cg from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PASSWORD, CONF_URL, CONF_USERNAME -from esphome.core import coroutine_with_priority +from esphome.core import ID, coroutine_with_priority from esphome.coroutine import CoroPriority +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType from .. import CONF_HTTP_REQUEST_ID, HttpRequestComponent, http_request_ns @@ -42,7 +44,7 @@ CONFIG_SCHEMA = cv.All( @coroutine_with_priority(CoroPriority.OTA_UPDATES) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await ota_to_code(var, config) await cg.register_component(var, config) @@ -72,7 +74,12 @@ OTA_HTTP_REQUEST_FLASH_ACTION_SCHEMA = cv.All( OTA_HTTP_REQUEST_FLASH_ACTION_SCHEMA, synchronous=True, ) -async def ota_http_request_action_to_code(config, action_id, template_arg, args): +async def ota_http_request_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/http_request/update/__init__.py b/esphome/components/http_request/update/__init__.py index d84d80109a..4bdc30e4cf 100644 --- a/esphome/components/http_request/update/__init__.py +++ b/esphome/components/http_request/update/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import ota, update import esphome.config_validation as cv from esphome.const import CONF_SOURCE +from esphome.types import ConfigType from .. import CONF_HTTP_REQUEST_ID, HttpRequestComponent, http_request_ns from ..ota import OtaHttpRequestComponent @@ -29,7 +30,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await update.new_update(config) ota_parent = await cg.get_variable(config[CONF_OTA_ID]) cg.add(var.set_ota_parent(ota_parent)) diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index 4809bf5a92..c5e82beb46 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -21,8 +21,9 @@ from esphome.components.esp32.const import ( import esphome.config_validation as cv from esphome.const import CONF_BITS_PER_SAMPLE, CONF_CHANNEL, CONF_ID, CONF_SAMPLE_RATE from esphome.core import CORE -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass import esphome.final_validate as fv +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["esp32"] @@ -145,7 +146,7 @@ I2S_MCLK_MULTIPLE = { _validate_bits = cv.float_with_unit("bits", "bit") -def validate_mclk_divisible_by_3(config): +def validate_mclk_divisible_by_3(config: ConfigType) -> ConfigType: if config[CONF_BITS_PER_SAMPLE] == 24 and config[CONF_MCLK_MULTIPLE] % 3 != 0: raise cv.Invalid( f"{CONF_MCLK_MULTIPLE} must be divisible by 3 when bits per sample is 24" @@ -159,7 +160,7 @@ def i2s_audio_component_schema( default_sample_rate: int, default_channel: str, default_bits_per_sample: str, -): +) -> cv.Schema: return cv.Schema( { cv.GenerateID(): cv.declare_id(class_), @@ -182,7 +183,7 @@ def i2s_audio_component_schema( ) -async def register_i2s_audio_component(var, config): +async def register_i2s_audio_component(var: MockObj, config: ConfigType) -> None: await cg.register_parented(var, config[CONF_I2S_AUDIO_ID]) cg.add(var.set_i2s_role(I2S_ROLE_OPTIONS[config[CONF_I2S_MODE]])) slot_mode = config[CONF_CHANNEL] @@ -260,7 +261,7 @@ def _assign_ports() -> None: next_port += 1 -def _final_validate(_): +def _final_validate(_: ConfigType) -> None: i2s_audio_configs = fv.full_config.get()[CONF_I2S_AUDIO] variant = get_esp32_variant() if variant not in I2S_PORTS: @@ -275,7 +276,7 @@ def _final_validate(_): FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/i2s_audio/microphone/__init__.py b/esphome/components/i2s_audio/microphone/__init__.py index 9c6228087c..c217317237 100644 --- a/esphome/components/i2s_audio/microphone/__init__.py +++ b/esphome/components/i2s_audio/microphone/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( CONF_NUM_CHANNELS, CONF_SAMPLE_RATE, ) +from esphome.types import ConfigType from .. import ( CONF_ADC_TYPE, @@ -46,7 +47,7 @@ I2S_PDM_DSR = { } -def _validate_esp32_variant(config): +def _validate_esp32_variant(config: ConfigType) -> ConfigType: variant = esp32.get_esp32_variant() if config[CONF_ADC_TYPE] == "external": if config[CONF_PDM] and variant not in PDM_VARIANTS: @@ -65,13 +66,13 @@ def _validate_esp32_variant(config): raise NotImplementedError -def _validate_channel(config): +def _validate_channel(config: ConfigType) -> ConfigType: if config[CONF_CHANNEL] == CONF_MONO: raise cv.Invalid(f"I2S microphone does not support {CONF_MONO}.") return config -def _set_num_channels_from_config(config): +def _set_num_channels_from_config(config: ConfigType) -> ConfigType: if config[CONF_CHANNEL] in (CONF_LEFT, CONF_RIGHT): config[CONF_NUM_CHANNELS] = 1 else: @@ -80,7 +81,7 @@ def _set_num_channels_from_config(config): return config -def _set_stream_limits(config): +def _set_stream_limits(config: ConfigType) -> ConfigType: audio.set_stream_limits( min_bits_per_sample=config.get(CONF_BITS_PER_SAMPLE), max_bits_per_sample=config.get(CONF_BITS_PER_SAMPLE), @@ -134,7 +135,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: if config[CONF_ADC_TYPE] == "internal": raise cv.Invalid( "Internal ADC is no longer supported. Use an external I2S microphone instead." @@ -144,7 +145,7 @@ def _final_validate(config): FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await register_i2s_audio_component(var, config) diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index 6d3c39c68e..1849c376aa 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( CONF_SAMPLE_RATE, CONF_TIMEOUT, ) +from esphome.types import ConfigType from .. import ( CONF_I2S_DOUT_PIN, @@ -78,7 +79,7 @@ I2C_COMM_FMT_OPTIONS = { INTERNAL_DAC_VARIANTS = [esp32.VARIANT_ESP32] -def _set_num_channels_from_config(config): +def _set_num_channels_from_config(config: ConfigType) -> ConfigType: if config[CONF_CHANNEL] in (CONF_MONO, CONF_LEFT, CONF_RIGHT): config[CONF_NUM_CHANNELS] = 1 else: @@ -87,7 +88,7 @@ def _set_num_channels_from_config(config): return config -def _set_stream_limits(config): +def _set_stream_limits(config: ConfigType) -> ConfigType: if config.get(CONF_SPDIF_MODE, False): # SPDIF mode: 16/24/32-bit audio and stereo at configured sample rate audio.set_stream_limits( @@ -133,14 +134,14 @@ def _set_stream_limits(config): return config -def _select_speaker_class(config): +def _select_speaker_class(config: ConfigType) -> ConfigType: """Override ID type when SPDIF mode is enabled.""" if config.get(CONF_SPDIF_MODE, False): config[CONF_ID].type = I2SAudioSpeakerSPDIF return config -def _validate_esp32_variant(config): +def _validate_esp32_variant(config: ConfigType) -> ConfigType: variant = esp32.get_esp32_variant() if config[CONF_DAC_TYPE] == "internal": if variant not in INTERNAL_DAC_VARIANTS: @@ -207,7 +208,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: if config[CONF_DAC_TYPE] == "internal": raise cv.Invalid( "Internal DAC is no longer supported. Use an external I2S DAC instead." @@ -238,7 +239,7 @@ def _final_validate(config): FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await register_i2s_audio_component(var, config) diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index d53499a78f..755d86e4ea 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -15,6 +15,8 @@ from esphome.const import ( CONF_PULLUP, ) from esphome.core import CORE, ID, coroutine +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType AUTO_LOAD = ["gpio_expander"] CODEOWNERS = ["@jesserockz"] @@ -41,7 +43,7 @@ MCP23XXX_CONFIG_SCHEMA = cv.Schema( @coroutine -async def register_mcp23xxx(config, num_pins): +async def register_mcp23xxx(config: ConfigType, num_pins: int) -> MockObj: id: ID = config[CONF_ID] var = cg.new_Pvariable(id) await cg.register_component(var, config) @@ -52,7 +54,7 @@ async def register_mcp23xxx(config, num_pins): return var -def validate_mode(value): +def validate_mode(value: ConfigType) -> ConfigType: if not (value[CONF_INPUT] or value[CONF_OUTPUT]): raise cv.Invalid("Mode must be either input or output") if value[CONF_INPUT] and value[CONF_OUTPUT]: @@ -81,7 +83,7 @@ MCP23XXX_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_MCP23XXX, MCP23XXX_PIN_SCHEMA) -async def mcp23xxx_pin_to_code(config): +async def mcp23xxx_pin_to_code(config: ConfigType) -> MockObj: parent_id: ID = config[CONF_MCP23XXX] parent = await cg.get_variable(parent_id) diff --git a/esphome/components/mcp4461/__init__.py b/esphome/components/mcp4461/__init__.py index f3ef6f4917..60cece67d7 100644 --- a/esphome/components/mcp4461/__init__.py +++ b/esphome/components/mcp4461/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@p1ngb4ck"] DEPENDENCIES = ["i2c"] @@ -30,7 +31,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable( config[CONF_ID], config[CONF_DISABLE_WIPER_0], diff --git a/esphome/components/mcp4461/output/__init__.py b/esphome/components/mcp4461/output/__init__.py index 99d4988c90..db1a1e6a29 100644 --- a/esphome/components/mcp4461/output/__init__.py +++ b/esphome/components/mcp4461/output/__init__.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_CHANNEL, CONF_ID, CONF_INITIAL_VALUE +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType from .. import CONF_MCP4461_ID, Mcp4461Component, mcp4461_ns @@ -34,7 +37,7 @@ CONF_NONVOLATILE_WRITE_DELAY = "nonvolatile_write_delay" VOLATILE_CHANNELS = ("A", "B", "C", "D") -def _validate_nonvolatile(config) -> None: +def _validate_nonvolatile(config: ConfigType) -> None: channel = str(config[CONF_CHANNEL]) # Channels E-H address the nonvolatile registers directly — the mirroring options only @@ -89,7 +92,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( FINAL_VALIDATE_SCHEMA = _validate_nonvolatile -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_MCP4461_ID]) var = cg.new_Pvariable( config[CONF_ID], @@ -147,7 +150,12 @@ TERMINAL_ACTION_SCHEMA = cv.Schema( @automation.register_action( "mcp4461.wiper.decrease", WiperDecreaseAction, WIPER_ACTION_SCHEMA, synchronous=True ) -async def mcp4461_wiper_step_to_code(config, action_id, template_arg, args): +async def mcp4461_wiper_step_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: wiper = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, wiper) @@ -158,7 +166,12 @@ async def mcp4461_wiper_step_to_code(config, action_id, template_arg, args): WIPER_ACTION_SCHEMA, synchronous=True, ) -async def mcp4461_wiper_store_to_code(config, action_id, template_arg, args): +async def mcp4461_wiper_store_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: wiper = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, wiper) @@ -169,7 +182,12 @@ async def mcp4461_wiper_store_to_code(config, action_id, template_arg, args): TERMINAL_ACTION_SCHEMA, synchronous=True, ) -async def mcp4461_wiper_terminal_to_code(config, action_id, template_arg, args): +async def mcp4461_wiper_terminal_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: wiper = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable( action_id, template_arg, wiper, ord(config[CONF_TERMINAL]), config[CONF_ENABLE] diff --git a/esphome/components/microphone/__init__.py b/esphome/components/microphone/__init__.py index 6b5ee8c3e1..9a3f5b43e7 100644 --- a/esphome/components/microphone/__init__.py +++ b/esphome/components/microphone/__init__.py @@ -1,3 +1,5 @@ +from collections.abc import Callable + from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg @@ -12,8 +14,10 @@ from esphome.const import ( CONF_ON_DATA, CONF_TRIGGER_ID, ) -from esphome.core import CORE +from esphome.core import CORE, ID from esphome.coroutine import CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType AUTO_LOAD = ["audio"] CODEOWNERS = ["@jesserockz", "@kahrendt"] @@ -50,7 +54,7 @@ IsCapturingCondition = microphone_ns.class_( IsMutedCondition = microphone_ns.class_("IsMutedCondition", automation.Condition) -async def setup_microphone_core_(var, config): +async def setup_microphone_core_(var: MockObj, config: ConfigType) -> None: for conf in config.get(CONF_ON_DATA, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation( @@ -60,7 +64,7 @@ async def setup_microphone_core_(var, config): ) -async def register_microphone(var, config): +async def register_microphone(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) await setup_microphone_core_(var, config) @@ -85,7 +89,7 @@ def microphone_source_schema( max_bits_per_sample: int = 16, min_channels: int = 1, max_channels: int = 1, -): +) -> cv.All: """Schema for a microphone source Components requesting microphone data should use this schema instead of accessing a microphone directly. @@ -97,7 +101,7 @@ def microphone_source_schema( max_channels (int, optional): Maximum number of channels the requesting component supports. Defaults to 1. """ - def _validate_unique_channels(config): + def _validate_unique_channels(config: list[int]) -> list[int]: if len(config) != len(set(config)): raise cv.Invalid("Channels must be unique") return config @@ -124,7 +128,7 @@ def microphone_source_schema( def final_validate_microphone_source_schema( component_name: str, sample_rate: int = cv.UNDEFINED -): +) -> Callable[[ConfigType], ConfigType]: """Validates that the microphone source can provide audio in the correct format. In particular it validates the sample rate and the enabled channels. Note that: @@ -136,7 +140,7 @@ def final_validate_microphone_source_schema( sample_rate (int, optional): The sample rate the component requesting mic audio requires """ - def _validate_audio_compatability(config): + def _validate_audio_compatability(config: ConfigType) -> ConfigType: if sample_rate is not cv.UNDEFINED: # Issues require changing the microphone configuration # - Verifies sample rates match @@ -161,7 +165,9 @@ def final_validate_microphone_source_schema( return _validate_audio_compatability -async def microphone_source_to_code(config, passive=False): +async def microphone_source_to_code( + config: ConfigType, passive: bool = False +) -> MockObj: """Creates a MicrophoneSource variable for codegen. Setting passive to true makes the MicrophoneSource never start/stop the microphone, but only receives audio when another component has actively started the Microphone. If false, then the microphone needs to be explicitly started/stopped. @@ -183,7 +189,12 @@ async def microphone_source_to_code(config, passive=False): return mic_source -async def microphone_action(config, action_id, template_arg, args): +async def microphone_action( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -219,6 +230,6 @@ automation.register_condition( @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(microphone_ns.using) cg.add_define("USE_MICROPHONE") diff --git a/esphome/components/pn532/__init__.py b/esphome/components/pn532/__init__.py index f34df21647..6258932312 100644 --- a/esphome/components/pn532/__init__.py +++ b/esphome/components/pn532/__init__.py @@ -9,6 +9,9 @@ from esphome.const import ( CONF_ON_TAG_REMOVED, CONF_TRIGGER_ID, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter", "@jesserockz"] AUTO_LOAD = ["binary_sensor", "nfc"] @@ -41,7 +44,7 @@ PN532_SCHEMA = cv.Schema( ).extend(cv.polling_component_schema("1s")) -def CONFIG_SCHEMA(conf): +def CONFIG_SCHEMA(conf: ConfigType) -> None: if conf: raise cv.Invalid( "This component has been moved in 1.16, please see the docs for updated " @@ -56,7 +59,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def setup_pn532(var, config): +async def setup_pn532(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) for conf in config.get(CONF_ON_TAG, []): @@ -85,7 +88,12 @@ async def setup_pn532(var, config): } ), ) -async def pn532_is_writing_to_code(config, condition_id, template_arg, args): +async def pn532_is_writing_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/pn532/binary_sensor.py b/esphome/components/pn532/binary_sensor.py index b9c3103c65..8f490ba7d0 100644 --- a/esphome/components/pn532/binary_sensor.py +++ b/esphome/components/pn532/binary_sensor.py @@ -1,15 +1,18 @@ +from typing import Any + import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_UID from esphome.core import HexInt +from esphome.types import ConfigType from . import CONF_PN532_ID, PN532, pn532_ns DEPENDENCIES = ["pn532"] -def validate_uid(value): +def validate_uid(value: Any) -> str: value = cv.string_strict(value) for x in value.split("-"): if len(x) != 2: @@ -39,7 +42,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(PN532BinarySensor).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) hub = await cg.get_variable(config[CONF_PN532_ID]) diff --git a/esphome/components/pn7150/__init__.py b/esphome/components/pn7150/__init__.py index 9dd3e8c5b0..4638992abf 100644 --- a/esphome/components/pn7150/__init__.py +++ b/esphome/components/pn7150/__init__.py @@ -12,6 +12,9 @@ from esphome.const import ( CONF_ON_TAG_REMOVED, CONF_TRIGGER_ID, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType AUTO_LOAD = ["binary_sensor", "nfc"] CODEOWNERS = ["@kbx81", "@jesserockz"] @@ -107,7 +110,12 @@ PN7150_SCHEMA = cv.Schema( SET_MESSAGE_ACTION_SCHEMA, synchronous=True, ) -async def pn7150_set_message_to_code(config, action_id, template_arg, args): +async def pn7150_set_message_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_MESSAGE], args, cg.std_string) @@ -158,7 +166,12 @@ async def pn7150_set_message_to_code(config, action_id, template_arg, args): SIMPLE_ACTION_SCHEMA, synchronous=True, ) -async def pn7150_simple_action_to_code(config, action_id, template_arg, args): +async def pn7150_simple_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -174,7 +187,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def setup_pn7150(var, config): +async def setup_pn7150(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) pin = await cg.gpio_pin_expression(config[CONF_IRQ_PIN]) @@ -216,7 +229,12 @@ async def setup_pn7150(var, config): } ), ) -async def pn7150_is_writing_to_code(config, condition_id, template_arg, args): +async def pn7150_is_writing_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/pn7160/__init__.py b/esphome/components/pn7160/__init__.py index ef14a29099..7f9f9172a1 100644 --- a/esphome/components/pn7160/__init__.py +++ b/esphome/components/pn7160/__init__.py @@ -12,6 +12,9 @@ from esphome.const import ( CONF_ON_TAG_REMOVED, CONF_TRIGGER_ID, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType AUTO_LOAD = ["binary_sensor", "nfc"] CODEOWNERS = ["@kbx81", "@jesserockz"] @@ -111,7 +114,12 @@ PN7160_SCHEMA = cv.Schema( SET_MESSAGE_ACTION_SCHEMA, synchronous=True, ) -async def pn7160_set_message_to_code(config, action_id, template_arg, args): +async def pn7160_set_message_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_MESSAGE], args, cg.std_string) @@ -162,7 +170,12 @@ async def pn7160_set_message_to_code(config, action_id, template_arg, args): SIMPLE_ACTION_SCHEMA, synchronous=True, ) -async def pn7160_simple_action_to_code(config, action_id, template_arg, args): +async def pn7160_simple_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -178,7 +191,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def setup_pn7160(var, config): +async def setup_pn7160(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) if dwl_req_pin_config := config.get(CONF_DWL_REQ_PIN): @@ -228,7 +241,12 @@ async def setup_pn7160(var, config): } ), ) -async def pn7160_is_writing_to_code(config, condition_id, template_arg, args): +async def pn7160_is_writing_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var From 8ef0f38f4efff4974bb1e96a210e128d99feb2fc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 08:59:30 -0500 Subject: [PATCH 1593/1815] [ethernet] Skip the custom W5500 SPI driver for other ethernet types (#18533) --- esphome/components/ethernet/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 5eda0fc12c..7686b64cb4 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -811,6 +811,10 @@ _platform_filter = filter_source_files_from_platform( PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO, }, + "w5500_custom_spi.cpp": { + PlatformFramework.ESP32_IDF, + PlatformFramework.ESP32_ARDUINO, + }, } ) @@ -830,6 +834,11 @@ def _filter_source_files() -> list[str]: # to avoid shadowing. Native IDF builds always need the custom driver. if cv.Version(5, 4, 2) <= idf_version() < cv.Version(6, 0, 0): excluded.append("esp_eth_phy_jl1101.c") + # The custom W5500 SPI driver is fully #ifdef'd on USE_ESP32 and + # USE_ETHERNET_W5500 (the platform filter map above handles non-ESP32); + # skip it entirely for the other ethernet types. + if eth_type != "W5500": + excluded.append("w5500_custom_spi.cpp") return excluded From 2d62ea78d203727c0f20a824add2b78271f33d24 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 09:00:11 -0500 Subject: [PATCH 1594/1815] [ota] Skip partition-access OTA sources when the feature is disabled (#18532) --- esphome/components/ota/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 2d4de52e8f..1e2ee947c1 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -182,4 +182,11 @@ def FILTER_SOURCE_FILES() -> list[str]: for define in CORE.defines ): files.append("ota_signature_esp_idf.cpp") + # ota_bootloader_esp_idf.cpp and ota_partitions_esp_idf.cpp are fully + # #ifdef'd on USE_OTA_PARTITIONS (set by the esphome OTA platform when + # allow_partition_access is enabled). Filter them out otherwise for the + # same reason as above. + if not any(define.name == "USE_OTA_PARTITIONS" for define in CORE.defines): + files.append("ota_bootloader_esp_idf.cpp") + files.append("ota_partitions_esp_idf.cpp") return files From a8e721abebdda3a42b1b6ecf391a90938b2187ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:03:49 -0500 Subject: [PATCH 1595/1815] [esp32] Apply IDF component exclusions to native toolchain builds (#18531) --- esphome/build_gen/espidf.py | 41 +++++++- esphome/components/esp32/__init__.py | 19 ++-- esphome/espidf/toolchain.py | 24 ++++- tests/unit_tests/build_gen/test_espidf.py | 115 +++++++++++++++++++--- tests/unit_tests/test_espidf_toolchain.py | 69 +++++++++++++ 5 files changed, 243 insertions(+), 25 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index cf476555e7..b65ce23307 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -3,7 +3,12 @@ import json from pathlib import Path -from esphome.components.esp32 import get_esp32_variant, idf_version +from esphome.components.esp32 import ( + get_esp32_variant, + get_excluded_builtin_components, + get_managed_component_require_names, + idf_version, +) import esphome.config_validation as cv from esphome.core import CORE from esphome.framework_helpers import ( @@ -119,24 +124,40 @@ def get_project_cmakelists(minimal: bool = False) -> str: # runs as a separate CMake script invocation that doesn't load the # project's top-level CMakeLists; without this, ${ESPHOME_PROJECT_ # MANAGED_COMPONENTS} in a converted-lib REQUIRES expands to empty). - from esphome.components.esp32 import get_managed_component_require_names - managed_components_property = "\n".join( f"idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS {name} APPEND)" for name in get_managed_component_require_names() ) + # Components excluded from the build (DEFAULT_EXCLUDED_IDF_COMPONENTS + # minus per-component re-includes). project.cmake reads the plain + # EXCLUDE_COMPONENTS variable when seeding the component list, so this + # must be set before project(). Emitted on minimal writes too so the + # discovery reconfigure never registers the excluded components. + excluded_components = get_excluded_builtin_components() + exclude_components_var = ( + f'set(EXCLUDE_COMPONENTS "{";".join(excluded_components)}")' + if excluded_components + else "" + ) + # Built-in IDF components exposed via our own property (not IDF's # __COMPONENT_REQUIRES_COMMON, which would append them to every # component's REQUIRES including real IDF components). Referenced by # src/CMakeLists and by each converted PIO lib's CMakeLists. Skipped # on minimal writes because project_description.json may be stale. + # Excluded components are dropped here as well: a stale + # project_description.json from a build without exclusions may still + # list them, and requiring an excluded component pulls it back into + # the build (IDF requirement expansion overrides EXCLUDE_COMPONENTS). builtin_components_property = ( "" if minimal else "\n".join( f"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS {name} APPEND)" - for name in sorted(get_available_components() or []) + for name in sorted( + set(get_available_components() or []).difference(excluded_components) + ) ) ) @@ -165,6 +186,8 @@ set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src) include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) +{exclude_components_var} + {cpp_standard_options} {cxx_compile_options} @@ -264,3 +287,13 @@ def write_project(minimal: bool = False) -> None: CORE.relative_src_path("CMakeLists.txt"), get_component_cmakelists(), ) + + # Snapshot the exclusion set so has_outdated_files() can trigger a + # discovery reconfigure when it changes. Excluded components never + # register in project_description.json, so re-including one (e.g. a + # config gains mqtt) requires a fresh discovery pass before the + # ESPHOME_PROJECT_BUILTIN_COMPONENTS property can list it. + write_file_if_changed( + CORE.relative_build_path("exclude_components.esphomeinternal"), + ";".join(get_excluded_builtin_components()), + ) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 3065cdadad..d6e0890751 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -738,6 +738,16 @@ def include_builtin_idf_component(name: str) -> None: CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS].discard(name) +def get_excluded_builtin_components() -> list[str]: + """Return the sorted built-in IDF components excluded from the build. + + Single accessor for both build writers: the PlatformIO path passes it as + ``-DEXCLUDE_COMPONENTS`` and the native ESP-IDF path emits it into the + generated CMakeLists. + """ + return sorted(CORE.data.get(KEY_ESP32, {}).get(KEY_EXCLUDE_COMPONENTS, ())) + + def _enable_arduino_library(name: str) -> None: """Enable an Arduino library that is disabled by default. @@ -2122,13 +2132,10 @@ def _configure_lwip_max_sockets(conf: dict) -> None: @coroutine_with_priority(CoroPriority.FINAL) async def _write_exclude_components() -> None: """Write EXCLUDE_COMPONENTS cmake arg after all components have registered exclusions.""" - if KEY_ESP32 not in CORE.data: - return - excluded = CORE.data[KEY_ESP32].get(KEY_EXCLUDE_COMPONENTS) - if excluded: - exclude_list = ";".join(sorted(excluded)) + if excluded := get_excluded_builtin_components(): cg.add_platformio_option( - "board_build.cmake_extra_args", f"-DEXCLUDE_COMPONENTS={exclude_list}" + "board_build.cmake_extra_args", + f"-DEXCLUDE_COMPONENTS={';'.join(excluded)}", ) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index bb6452acf2..07ba03e2cf 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -273,6 +273,11 @@ def has_outdated_files(): happen without any sdkconfig impact, and ``_write_idf_component_yml`` already deletes ``dependencies.lock`` on a change but that signal gets lost as soon as the lock is missing. + - ``exclude_components.esphomeinternal`` -- the resolved + EXCLUDE_COMPONENTS set. Excluded components never register in + ``project_description.json``, so re-including one needs a fresh + discovery pass before it can appear in the builtin-components + property that ``src`` REQUIRES. We deliberately don't watch: - The top-level/src ``CMakeLists.txt`` -- ESPHome owns those, and @@ -291,6 +296,9 @@ def has_outdated_files(): f"sdkconfig.{CORE.name}.esphomeinternal" ) idf_component_yml_path = CORE.relative_build_path("src/idf_component.yml") + exclude_components_path = CORE.relative_build_path( + "exclude_components.esphomeinternal" + ) dependency_lock_path = CORE.relative_build_path("dependencies.lock") build_ninja_path = CORE.relative_build_path("build/build.ninja") @@ -309,7 +317,11 @@ def has_outdated_files(): cmakecache_txt_mtime = cmakecache_txt_path.stat().st_mtime return any( f.stat().st_mtime > cmakecache_txt_mtime - for f in [sdkconfig_internal_path, idf_component_yml_path] + for f in [ + sdkconfig_internal_path, + idf_component_yml_path, + exclude_components_path, + ] if f.exists() ) @@ -386,6 +398,16 @@ def run_compile(config, verbose: bool) -> int: return rc _LOGGER.info("Regenerating CMakeLists.txt with discovered components...") write_project(minimal=False) + # Restamp the reference file has_outdated_files() compares against. + # A reconfigure that only changes properties or plain variables + # (sdkconfig options, the exclusion set) does not rewrite + # CMakeCache.txt, so without this the watched inputs stay newer + # forever and every subsequent build repeats the discovery pass. + # Done after the full write so an interrupt cannot leave a minimal + # CMakeLists behind that is already marked fresh. + cmakecache = CORE.relative_build_path("build/CMakeCache.txt") + if cmakecache.is_file(): + os.utime(cmakecache) if CORE.testing_mode: # Reconfigure again so cmake is up to date with the full # component list before the build's idf.py invocation runs -- diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index f21549b48c..ec01000920 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -11,6 +11,7 @@ import pytest from esphome.components.esp32 import ( KEY_COMPONENTS, KEY_ESP32, + KEY_EXCLUDE_COMPONENTS, KEY_IDF_VERSION, KEY_PATH, KEY_REF, @@ -28,6 +29,7 @@ def _reset_core(tmp_path: Path) -> None: CORE.data.setdefault(KEY_CORE, {}) CORE.data[KEY_ESP32] = { KEY_COMPONENTS: {}, + KEY_EXCLUDE_COMPONENTS: set(), KEY_IDF_VERSION: cv.Version(5, 5, 4), } @@ -47,6 +49,17 @@ def _write_project_description(tmp_path: Path, components: dict[str, str]) -> No ) +def _render(minimal: bool = False) -> str: + """Render the top-level CMakeLists with the standard variant/name patches.""" + with ( + patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), + patch.object(CORE, "name", "test"), + ): + from esphome.build_gen.espidf import get_project_cmakelists + + return get_project_cmakelists(minimal=minimal) + + def test_get_available_components_returns_none_without_build_path() -> None: """No build_path set yet: must not raise on Path(None).""" CORE.build_path = None @@ -88,13 +101,7 @@ def test_get_project_cmakelists_minimal_omits_builtin_components_property( first write before the discovery pass refreshes it).""" _write_project_description(tmp_path, {"esp_lcd": "/idf/components/esp_lcd"}) - with ( - patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), - patch.object(CORE, "name", "test"), - ): - from esphome.build_gen.espidf import get_project_cmakelists - - content = get_project_cmakelists(minimal=True) + content = _render(minimal=True) assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS" not in content @@ -115,13 +122,7 @@ def test_get_project_cmakelists_full_emits_builtin_components_property( }, ) - with ( - patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), - patch.object(CORE, "name", "test"), - ): - from esphome.build_gen.espidf import get_project_cmakelists - - content = get_project_cmakelists(minimal=False) + content = _render() assert ( "idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS esp_lcd APPEND)" @@ -136,6 +137,92 @@ def test_get_project_cmakelists_full_emits_builtin_components_property( assert "JPEGDEC APPEND" not in content +def test_get_project_cmakelists_emits_exclude_components(tmp_path: Path) -> None: + """Excluded components are passed to IDF via EXCLUDE_COMPONENTS and are + dropped from ESPHOME_PROJECT_BUILTIN_COMPONENTS even when a stale + project_description.json still lists them (requiring an excluded + component would pull it back into the build).""" + _write_project_description( + tmp_path, + { + "esp_lcd": "/idf/components/esp_lcd", + "freertos": "/idf/components/freertos", + "unity": "/idf/components/unity", + }, + ) + CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity", "esp_lcd"} + + content = _render() + + assert 'set(EXCLUDE_COMPONENTS "esp_lcd;unity")' in content + # Must be set before project() so project.cmake sees it. + assert content.index("set(EXCLUDE_COMPONENTS") < content.index("project(test)") + assert ( + "idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS freertos APPEND)" + in content + ) + assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS unity" not in content + assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS esp_lcd" not in content + + +def test_get_project_cmakelists_minimal_emits_exclude_components() -> None: + """The discovery (minimal) write also excludes components so they never + register in project_description.json.""" + CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity"} + + content = _render(minimal=True) + + assert 'set(EXCLUDE_COMPONENTS "unity")' in content + + +def test_get_project_cmakelists_no_exclude_components_line_when_empty() -> None: + """No EXCLUDE_COMPONENTS line at all when nothing is excluded.""" + content = _render() + + assert "EXCLUDE_COMPONENTS" not in content + + +def test_include_builtin_idf_component_removes_exclusion() -> None: + """include_builtin_idf_component() drops a name from the exclusion set so + a component a config actually uses is not passed to EXCLUDE_COMPONENTS.""" + from esphome.components.esp32 import ( + exclude_builtin_idf_component, + get_excluded_builtin_components, + include_builtin_idf_component, + ) + + exclude_builtin_idf_component("esp_eth") + exclude_builtin_idf_component("unity") + include_builtin_idf_component("esp_eth") + + assert get_excluded_builtin_components() == ["unity"] + + content = _render() + + assert 'set(EXCLUDE_COMPONENTS "unity")' in content + assert "esp_eth" not in content + + +def test_write_project_writes_exclude_components_stamp(tmp_path: Path) -> None: + """write_project() snapshots the exclusion set; the toolchain watches the + stamp to trigger a discovery reconfigure when the set changes (excluded + components never register in project_description.json).""" + CORE.build_flags = set() + CORE.build_path = tmp_path + CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity", "esp_lcd"} + + with ( + patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), + patch.object(CORE, "name", "test"), + ): + from esphome.build_gen.espidf import write_project + + write_project() + + stamp = tmp_path / "exclude_components.esphomeinternal" + assert stamp.read_text() == "esp_lcd;unity" + + def test_get_component_cmakelists_no_link_flags() -> None: """With no -Wl, flags the target_link_options block is emitted with an empty body.""" CORE.build_flags = set() diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 26d812af8b..2556397aef 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -100,6 +100,33 @@ def _setup_build(setup_core: Path) -> tuple[Path, Path]: return compile_commands, cache +def test_has_outdated_files_detects_exclusion_change(setup_core: Path) -> None: + """A newer exclude_components.esphomeinternal stamp forces a reconfigure + so components that leave the exclusion set get rediscovered.""" + CORE.build_path = setup_core + build = setup_core / "build" + (build / "config").mkdir(parents=True) + (build / "config" / "sdkconfig.h").write_text("") + cmakecache = build / "CMakeCache.txt" + cmakecache.write_text("") + (build / "build.ninja").write_text("") + + with patch.object(CORE, "name", "test"): + assert not toolchain.has_outdated_files() + + stamp = setup_core / "exclude_components.esphomeinternal" + stamp.write_text("unity") + os.utime(stamp, (cmakecache.stat().st_mtime + 10,) * 2) + + assert toolchain.has_outdated_files() + + # The flag must clear once the reference file is restamped (as + # run_compile does after a successful discovery reconfigure); + # otherwise every later build would repeat the discovery pass. + os.utime(cmakecache, (stamp.stat().st_mtime + 10,) * 2) + assert not toolchain.has_outdated_files() + + def test_get_idedata_returns_none_without_compile_commands(setup_core: Path) -> None: """No compile DB yet -> None (rather than an error).""" _setup_build(setup_core) @@ -373,6 +400,48 @@ def test_run_idf_py_jobs_sets_build_jobs_env(setup_core: Path) -> None: assert "IDF_PY_BUILD_JOBS" not in env +def test_run_compile_restamps_cmakecache_after_discovery(setup_core: Path) -> None: + """After a successful discovery reconfigure the reference CMakeCache.txt + is restamped; cmake does not rewrite it when only properties or plain + variables change, so the staleness flag would otherwise never clear.""" + _setup_build(setup_core) + config = {CONF_ESPHOME: {}} + cmakecache = CORE.relative_build_path("build/CMakeCache.txt") + cmakecache.parent.mkdir(parents=True, exist_ok=True) + cmakecache.write_text("") + old = cmakecache.stat().st_mtime - 100 + os.utime(cmakecache, (old, old)) + + with ( + patch.object(toolchain, "need_reconfigure", return_value=True), + patch("esphome.build_gen.espidf.write_project"), + patch.object(toolchain, "run_reconfigure", return_value=0), + patch.object(toolchain, "run_idf_py", return_value=0), + patch.object(toolchain, "print_summary"), + ): + assert toolchain.run_compile(config, verbose=False) == 0 + + assert cmakecache.stat().st_mtime > old + + +def test_run_compile_discovery_without_cmakecache(setup_core: Path) -> None: + """A discovery pass that produced no CMakeCache.txt (nothing to restamp) + still completes normally.""" + _setup_build(setup_core) + config = {CONF_ESPHOME: {}} + + with ( + patch.object(toolchain, "need_reconfigure", return_value=True), + patch("esphome.build_gen.espidf.write_project"), + patch.object(toolchain, "run_reconfigure", return_value=0), + patch.object(toolchain, "run_idf_py", return_value=0), + patch.object(toolchain, "print_summary"), + ): + assert toolchain.run_compile(config, verbose=False) == 0 + + assert not CORE.relative_build_path("build/CMakeCache.txt").exists() + + def test_run_compile_passes_compile_process_limit(setup_core: Path) -> None: """compile_process_limit is forwarded to run_idf_py as the job limit.""" _setup_build(setup_core) From 347a6155f8783342d1bb7da05ad4a1254fe47f45 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 09:07:17 -0500 Subject: [PATCH 1596/1815] [uptime] Skip the timestamp sensor source when no time component is configured (#18535) --- esphome/components/uptime/sensor/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/esphome/components/uptime/sensor/__init__.py b/esphome/components/uptime/sensor/__init__.py index e2a7aee1a2..debeb41444 100644 --- a/esphome/components/uptime/sensor/__init__.py +++ b/esphome/components/uptime/sensor/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( STATE_CLASS_TOTAL_INCREASING, UNIT_SECOND, ) +from esphome.core import CORE uptime_ns = cg.esphome_ns.namespace("uptime") UptimeSecondsSensor = uptime_ns.class_( @@ -59,3 +60,11 @@ async def to_code(config): if time_id_config := config.get(CONF_TIME_ID): time_id = await cg.get_variable(time_id_config) cg.add(var.set_time(time_id)) + + +def FILTER_SOURCE_FILES() -> list[str]: + # uptime_timestamp_sensor.cpp is fully #ifdef'd on USE_TIME; skip it + # when no time component is configured. + if not any(define.name == "USE_TIME" for define in CORE.defines): + return ["uptime_timestamp_sensor.cpp"] + return [] From ecca240eef2b79baa85d3eca6665a333e11c0e62 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 02:26:16 +1200 Subject: [PATCH 1597/1815] [core] Add type annotations to component Python (2/11) (#18339) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/cm1106/sensor.py | 12 +++++-- esphome/components/esp_ldo/__init__.py | 22 +++++++++---- esphome/components/ili9xxx/display.py | 13 ++++---- esphome/components/it8951/display.py | 32 +++++++++++++------ esphome/components/mapping/__init__.py | 17 ++++++---- esphome/components/mipi_dsi/display.py | 9 +++--- esphome/components/mipi_rgb/display.py | 14 ++++---- esphome/components/mipi_rgb/models/st7701s.py | 2 +- esphome/components/mipi_spi/display.py | 15 +++++---- esphome/components/online_image/image.py | 10 ++++-- .../components/packet_transport/__init__.py | 27 +++++++++------- .../packet_transport/binary_sensor.py | 5 +-- esphome/components/packet_transport/sensor.py | 3 +- esphome/components/pca9554/__init__.py | 12 ++++--- esphome/components/qspi_dbi/display.py | 16 ++++++---- esphome/components/qspi_dbi/models.py | 10 +++--- esphome/components/rpi_dpi_rgb/display.py | 9 ++++-- esphome/components/sdl/binary_sensor.py | 3 +- esphome/components/sdl/display.py | 9 ++++-- .../components/sdl/touchscreen/__init__.py | 3 +- esphome/components/seeed_mr24hpc1/__init__.py | 3 +- .../seeed_mr24hpc1/binary_sensor.py | 3 +- .../seeed_mr24hpc1/button/__init__.py | 3 +- .../seeed_mr24hpc1/number/__init__.py | 3 +- .../seeed_mr24hpc1/select/__init__.py | 3 +- esphome/components/seeed_mr24hpc1/sensor.py | 3 +- .../seeed_mr24hpc1/switch/__init__.py | 3 +- .../components/seeed_mr24hpc1/text_sensor.py | 3 +- esphome/components/seeed_mr60bha2/__init__.py | 3 +- .../seeed_mr60bha2/binary_sensor.py | 3 +- esphome/components/seeed_mr60bha2/sensor.py | 3 +- esphome/components/seeed_mr60fda2/__init__.py | 3 +- .../seeed_mr60fda2/binary_sensor.py | 3 +- .../seeed_mr60fda2/button/__init__.py | 3 +- .../seeed_mr60fda2/select/__init__.py | 3 +- esphome/components/st7701s/display.py | 11 ++++--- esphome/components/st7701s/init_sequences.py | 2 +- esphome/components/udp/__init__.py | 22 +++++++++---- .../udp/packet_transport/__init__.py | 3 +- esphome/components/usb_uart/__init__.py | 19 +++++------ 40 files changed, 220 insertions(+), 125 deletions(-) diff --git a/esphome/components/cm1106/sensor.py b/esphome/components/cm1106/sensor.py index 3c82fac977..936c5fc673 100644 --- a/esphome/components/cm1106/sensor.py +++ b/esphome/components/cm1106/sensor.py @@ -13,6 +13,9 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_PARTS_PER_MILLION, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["uart"] CODEOWNERS = ["@andrewjswan"] @@ -44,7 +47,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config) -> None: +async def to_code(config: ConfigType) -> None: """Code generation entry point.""" var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -67,7 +70,12 @@ CALIBRATION_ACTION_SCHEMA = maybe_simple_id( CALIBRATION_ACTION_SCHEMA, synchronous=True, ) -async def cm1106_calibration_to_code(config, action_id, template_arg, args) -> None: +async def cm1106_calibration_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: """Service code generation entry point.""" paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/esp_ldo/__init__.py b/esphome/components/esp_ldo/__init__.py index a489651b59..46810d422d 100644 --- a/esphome/components/esp_ldo/__init__.py +++ b/esphome/components/esp_ldo/__init__.py @@ -1,9 +1,14 @@ +from typing import Any + from esphome.automation import Action, register_action import esphome.codegen as cg from esphome.components.esp32 import VARIANT_ESP32P4, only_on_variant import esphome.config_validation as cv from esphome.const import CONF_CHANNEL, CONF_ID, CONF_VOLTAGE +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.final_validate import full_config +from esphome.types import ConfigType CODEOWNERS = ["@clydebarrow"] @@ -22,7 +27,7 @@ CONF_PASSTHROUGH = "passthrough" adjusted_ids = set() -def validate_ldo_voltage(value): +def validate_ldo_voltage(value: Any) -> str | float: if isinstance(value, str) and value.lower() == CONF_PASSTHROUGH: return CONF_PASSTHROUGH value = cv.voltage(value) @@ -33,7 +38,7 @@ def validate_ldo_voltage(value): ) -def validate_ldo_config(config): +def validate_ldo_config(config: ConfigType) -> ConfigType: channel = config[CONF_CHANNEL] allow_internal = config[CONF_ALLOW_INTERNAL_CHANNEL] if allow_internal and channel not in CHANNELS_INTERNAL: @@ -77,7 +82,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(configs): +async def to_code(configs: list[ConfigType]) -> None: for config in configs: var = cg.new_Pvariable(config[CONF_ID], config[CONF_CHANNEL]) await cg.register_component(var, config) @@ -89,7 +94,7 @@ async def to_code(configs): cg.add(var.set_adjustable(config[CONF_ADJUSTABLE])) -def final_validate(configs): +def final_validate(configs: list[ConfigType]) -> None: for channel in CHANNELS: used = [config for config in configs if config[CONF_CHANNEL] == channel] if len(used) > 1: @@ -112,7 +117,7 @@ def final_validate(configs): FINAL_VALIDATE_SCHEMA = final_validate -def adjusted_ldo_id(value): +def adjusted_ldo_id(value: Any) -> ID: value = cv.use_id(EspLdo)(value) adjusted_ids.add(value) return value @@ -131,7 +136,12 @@ def adjusted_ldo_id(value): ), synchronous=True, ) -async def ldo_voltage_adjust_to_code(config, action_id, template_arg, args): +async def ldo_voltage_adjust_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: parent = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, parent) template_ = await cg.templatable(config[CONF_VOLTAGE], args, cg.float_) diff --git a/esphome/components/ili9xxx/display.py b/esphome/components/ili9xxx/display.py index b1d332c1e5..64f87c167c 100644 --- a/esphome/components/ili9xxx/display.py +++ b/esphome/components/ili9xxx/display.py @@ -31,6 +31,7 @@ from esphome.const import ( ) from esphome.core import CORE, HexInt from esphome.final_validate import full_config +from esphome.types import ConfigType DEPENDENCIES = ["spi"] @@ -91,7 +92,7 @@ CONF_INVERT_DISPLAY = "invert_display" CONF_PIXEL_MODE = "pixel_mode" -def cmd(c, *args): +def cmd(c: int, *args: int) -> list[int]: """ Create a command sequence :param c: The command (8 bit) @@ -101,7 +102,7 @@ def cmd(c, *args): return [c, len(args)] + list(args) -def map_sequence(value): +def map_sequence(value: list[int]) -> list[int]: """ An initialisation sequence is a literal array of data bytes. The format is a repeated sequence of [CMD, ] @@ -111,7 +112,7 @@ def map_sequence(value): return cmd(*value) -def _validate(config): +def _validate(config: ConfigType) -> ConfigType: if ( config.get(CONF_COLOR_PALETTE) == "IMAGE_ADAPTIVE" and CONF_COLOR_PALETTE_IMAGES not in config @@ -196,7 +197,7 @@ CONFIG_SCHEMA = cv.All( ) -def final_validate(config): +def final_validate(config: ConfigType) -> None: global_config = full_config.get() # Ideally would calculate buffer size here, but that info is not available on the Python side needs_buffer = ( @@ -218,7 +219,7 @@ def final_validate(config): FINAL_VALIDATE_SCHEMA = final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: LOGGER.warning( "The 'ili9xxx' component is deprecated, it is recommended to use 'mipi_spi' instead." ) @@ -278,7 +279,7 @@ async def to_code(config): cg.add(var.set_buffer_color_mode(ILI9XXXColorMode.BITS_8_INDEXED)) from PIL import Image - def load_image(filename): + def load_image(filename: str) -> Image.Image: path = CORE.relative_config_path(filename) try: return Image.open(path) diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py index bdc68b5257..57bf86c4c6 100644 --- a/esphome/components/it8951/display.py +++ b/esphome/components/it8951/display.py @@ -2,6 +2,9 @@ ESPHome configuration for the IT8951 e-paper controller. """ +from collections.abc import Callable +from typing import Any + from esphome import automation, core, pins import esphome.codegen as cg from esphome.components import display, spi @@ -33,8 +36,10 @@ from esphome.const import ( CONF_UPDATE_INTERVAL, CONF_WIDTH, ) -from esphome.cpp_generator import RawExpression +from esphome.core import ID +from esphome.cpp_generator import MockObj, RawExpression, TemplateArgsType from esphome.final_validate import full_config +from esphome.types import ConfigType AUTO_LOAD = ["split_buffer"] DEPENDENCIES = ["spi"] @@ -97,16 +102,16 @@ class IT8951Model: models: dict[str, "IT8951Model"] = {} - def __init__(self, name: str, **defaults): + def __init__(self, name: str, **defaults: Any) -> None: name = name.upper() self.name = name self.defaults = defaults IT8951Model.models[name] = self - def get_default(self, key, fallback=None): + def get_default(self, key: str, fallback: Any = None) -> Any: return self.defaults.get(key, fallback) - def get_dimensions(self, config) -> tuple[int, int]: + def get_dimensions(self, config: ConfigType) -> tuple[int, int]: # If dimensions are in config, use them; otherwise fall back to model defaults. if CONF_DIMENSIONS in config: dimensions = config[CONF_DIMENSIONS] @@ -181,14 +186,16 @@ DIMENSION_SCHEMA = cv.Schema( ) -def _model_pin_option(model, key, schema): +def _model_pin_option( + model: IT8951Model, key: str, schema: Callable[[Any], Any] +) -> tuple[cv.Optional | cv.Required, Callable[[Any], Any]]: default = model.get_default(key) if default is None: return cv.Required(key), schema return cv.Optional(key, default=default), schema -def _model_schema(config): +def _model_schema(config: ConfigType) -> cv.Schema: model = IT8951Model.models[config[CONF_MODEL]] has_default_dimensions = ( model.get_default(CONF_WIDTH) is not None @@ -293,7 +300,7 @@ def _model_schema(config): return schema.extend(pin_extra) -def _customise_schema(config): +def _customise_schema(config: ConfigType) -> ConfigType: config = cv.Schema( { cv.Required(CONF_MODEL): cv.one_of( @@ -336,7 +343,7 @@ def _customise_schema(config): CONFIG_SCHEMA = _customise_schema -def _final_validate(config) -> None: +def _final_validate(config: ConfigType) -> None: # IT8951 reads from SPI (DevInfo, VCOM, register reads) so MISO is required. spi.final_validate_device_schema("it8951", require_miso=True, require_mosi=True)( config @@ -356,7 +363,7 @@ def _final_validate(config) -> None: FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: model = IT8951Model.models[config[CONF_MODEL]] width, height = model.get_dimensions(config) @@ -423,7 +430,12 @@ async def to_code(config): ), synchronous=True, ) -async def it8951_update_action_to_code(config, action_id, template_arg, args): +async def it8951_update_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: display_var = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, display_var) if mode := config.get(CONF_MODE): diff --git a/esphome/components/mapping/__init__.py b/esphome/components/mapping/__init__.py index 3c7d78a27b..cd846877ae 100644 --- a/esphome/components/mapping/__init__.py +++ b/esphome/components/mapping/__init__.py @@ -1,5 +1,6 @@ from collections.abc import Callable import difflib +from typing import Any import esphome.codegen as cg from esphome.components.const import KEY_METADATA @@ -13,6 +14,7 @@ from esphome.cpp_generator import ( add_global, ) from esphome.loader import get_component +from esphome.types import ConfigType CODEOWNERS = ["@clydebarrow"] MULTI_CONF = True @@ -32,13 +34,16 @@ class IndexType: """ def __init__( - self, validator: Callable, data_type: MockObj, conversion: Callable = None + self, + validator: Callable, + data_type: MockObj, + conversion: Callable | None = None, ) -> None: self.validator = validator self.data_type = data_type self.conversion = conversion - async def convert_value(self, value): + async def convert_value(self, value: Any) -> Any: if self.conversion: return self.conversion(value) return await cg.get_variable(value) @@ -60,7 +65,7 @@ class MappingMetaData: self.to_ = to_ -def to_schema(value): +def to_schema(value: Any) -> str: """ Generate a schema for the 'to' field of a map. This can be either one of the index types or a class name. :param value: @@ -82,7 +87,7 @@ BASE_SCHEMA = cv.Schema( ) -def get_object_type(to_) -> MockObjClass | None: +def get_object_type(to_: str) -> MockObjClass | None: """ Get the object type from a string. Possible formats: xxx The name of a component which defines INSTANCE_TYPE @@ -121,7 +126,7 @@ def add_metadata( get_all_mapping_metadata()[mapping_id.id] = MappingMetaData(from_, to_) -def map_schema(config): +def map_schema(config: ConfigType) -> ConfigType: config = BASE_SCHEMA(config) if CONF_ENTRIES not in config or not isinstance(config[CONF_ENTRIES], dict): raise cv.Invalid("an entries dictionary is required for a mapping") @@ -163,7 +168,7 @@ def map_schema(config): CONFIG_SCHEMA = map_schema -async def to_code(config): +async def to_code(config: ConfigType) -> MockObj: varid = config[CONF_ID] metadata = get_mapping_metadata(varid.id) entries = { diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 8c125a9606..b23982655a 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -53,6 +53,7 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.final_validate import full_config +from esphome.types import ConfigType from . import mipi_dsi_ns, models from .models import DsiDriverChip @@ -85,7 +86,7 @@ COLOR_DEPTHS = { } -def model_schema(config): +def model_schema(config: ConfigType) -> cv.All: model = MODELS[config[CONF_MODEL].upper()] transform = model.transform_schema() # CUSTOM model will need to provide a custom init sequence @@ -148,7 +149,7 @@ def model_schema(config): @model_schema_extractor(MODELS, model_schema) -def _config_schema(config): +def _config_schema(config: ConfigType) -> ConfigType: config = cv.Schema( { cv.Required(CONF_MODEL): cv.one_of(*MODELS, upper=True), @@ -175,7 +176,7 @@ def _config_schema(config): return config -def _final_validate(config) -> None: +def _final_validate(config: ConfigType) -> None: global_config = full_config.get() from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN @@ -189,7 +190,7 @@ CONFIG_SCHEMA = _config_schema FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: model = MODELS[config[CONF_MODEL].upper()] color_depth = COLOR_DEPTHS[get_color_depth(config)] pixel_mode = int(config[CONF_PIXEL_MODE].removesuffix("bit")) diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 897088a257..e23e19a000 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -1,5 +1,6 @@ import importlib import pkgutil +from typing import Any from esphome import pins import esphome.codegen as cg @@ -72,6 +73,7 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.final_validate import full_config +from esphome.types import ConfigType from . import models from .models import RgbDriverChip @@ -97,7 +99,7 @@ for module_info in pkgutil.iter_modules(models.__path__): MODELS = DriverChip.get_models() -def data_pin_validate(value): +def data_pin_validate(value: Any) -> ConfigType: """ It is safe to use strapping pins as RGB output data bits, as they are outputs only, and not initialised until after boot. @@ -112,14 +114,14 @@ def data_pin_validate(value): return DATA_PIN_SCHEMA(value) -def data_pin_set(length): +def data_pin_set(length: int) -> cv.All: return cv.All( [data_pin_validate], cv.Length(min=length, max=length, msg=f"Exactly {length} data pins required"), ) -def model_schema(config): +def model_schema(config: ConfigType) -> cv.Schema: model = MODELS[config[CONF_MODEL].upper()] transform = model.transform_schema() # RPI model does not use an init sequence, indicates with empty list @@ -213,7 +215,7 @@ def model_schema(config): @model_schema_extractor(MODELS, model_schema) -def _config_schema(config): +def _config_schema(config: ConfigType) -> ConfigType: config = cv.Schema( { cv.Required(CONF_MODEL): cv.one_of(*MODELS, upper=True), @@ -248,7 +250,7 @@ def _config_schema(config): CONFIG_SCHEMA = _config_schema -def _final_validate(config) -> None: +def _final_validate(config: ConfigType) -> None: global_config = full_config.get() from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN @@ -265,7 +267,7 @@ def _final_validate(config) -> None: FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: model = MODELS[config[CONF_MODEL].upper()] width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( model.get_dimensions(config) diff --git a/esphome/components/mipi_rgb/models/st7701s.py b/esphome/components/mipi_rgb/models/st7701s.py index a20e9d1c01..cad5dc8e20 100644 --- a/esphome/components/mipi_rgb/models/st7701s.py +++ b/esphome/components/mipi_rgb/models/st7701s.py @@ -8,7 +8,7 @@ SDIR_CMD = 0xC7 class ST7701S(RgbDriverChip): # The ST7701s does not use the standard MADCTL bits for x/y mirroring - def add_madctl(self, sequence: list, config: dict): + def add_madctl(self, sequence: list, config: dict) -> int: transform = self.get_transform(config) madctl = 0x00 if config[CONF_COLOR_ORDER] == MODE_BGR: diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 246db237b1..e8b54da5c7 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -53,8 +53,9 @@ from esphome.const import ( CONF_TRANSFORM, CONF_WIDTH, ) -from esphome.cpp_generator import TemplateArguments +from esphome.cpp_generator import MockObjClass, TemplateArguments from esphome.final_validate import full_config +from esphome.types import ConfigType from . import CONF_BUS_MODE, CONF_SPI_16, DOMAIN, models @@ -110,7 +111,7 @@ DISPLAY_PIXEL_MODES = { } -def denominator(config): +def denominator(config: ConfigType) -> int: """ Calculate the best denominator for a buffer size fraction. The denominator should be a number between 2 and 16 that divides the display height evenly, @@ -132,7 +133,7 @@ def denominator(config): return next(x for x in range(2, 17) if frac >= 1 / x) -def model_schema(config): +def model_schema(config: ConfigType) -> cv.All | cv.Schema: model = MODELS[config[CONF_MODEL]] bus_mode = config[CONF_BUS_MODE] transform = model.transform_schema() @@ -238,7 +239,7 @@ def model_schema(config): @model_schema_extractor(MODELS, model_schema, extra={CONF_BUS_MODE: TYPE_SINGLE}) -def customise_schema(config): +def customise_schema(config: ConfigType) -> ConfigType: """ Create a customised config schema for a specific model and validate the configuration. :param config: The configuration dictionary to validate @@ -305,7 +306,7 @@ def customise_schema(config): CONFIG_SCHEMA = customise_schema -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: global_config = full_config.get() model = MODELS[config[CONF_MODEL]] @@ -341,7 +342,7 @@ def _final_validate(config): FINAL_VALIDATE_SCHEMA = _final_validate -def get_instance(config): +def get_instance(config: ConfigType) -> tuple[MockObjClass, list]: """ Get the type of MipiSpi instance to create based on the configuration, and the template arguments. @@ -394,7 +395,7 @@ def get_instance(config): return MipiSpi, templateargs -async def to_code(config): +async def to_code(config: ConfigType) -> None: model = MODELS[config[CONF_MODEL]] var_id = config[CONF_ID] init_sequence = model.get_sequence(config, add_madctl=False, add_reset=True) diff --git a/esphome/components/online_image/image.py b/esphome/components/online_image/image.py index cb86f93e29..ae785d17f9 100644 --- a/esphome/components/online_image/image.py +++ b/esphome/components/online_image/image.py @@ -6,7 +6,8 @@ from esphome.components.http_request import CONF_HTTP_REQUEST_ID, HttpRequestCom from esphome.components.image import CONF_TRANSPARENCY, add_metadata import esphome.config_validation as cv from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_ON_ERROR, CONF_TYPE, CONF_URL -from esphome.core import Lambda +from esphome.core import ID, Lambda +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType AUTO_LOAD = ["runtime_image"] @@ -89,7 +90,12 @@ RELEASE_IMAGE_SCHEMA = automation.maybe_simple_id( RELEASE_IMAGE_SCHEMA, synchronous=True, ) -async def online_image_action_to_code(config, action_id, template_arg, args): +async def online_image_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/packet_transport/__init__.py b/esphome/components/packet_transport/__init__.py index 7beb13ca31..c36d421a35 100644 --- a/esphome/components/packet_transport/__init__.py +++ b/esphome/components/packet_transport/__init__.py @@ -1,7 +1,9 @@ """ESPHome packet transport component.""" +from collections.abc import Callable, Iterator import hashlib import logging +from typing import Any import esphome.codegen as cg from esphome.components.binary_sensor import BinarySensor @@ -17,8 +19,9 @@ from esphome.const import ( CONF_PLATFORM, CONF_SENSORS, ) -from esphome.core import CORE -from esphome.cpp_generator import MockObjClass +from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType CODEOWNERS = ["@clydebarrow"] AUTO_LOAD = ["xxtea"] @@ -43,7 +46,7 @@ CONF_TRANSPORT_ID = "transport_id" _LOGGER = logging.getLogger(__name__) -def sensor_validation(cls: MockObjClass): +def sensor_validation(cls: MockObjClass) -> Callable[[Any], Any]: return cv.maybe_simple_value( cv.Schema( { @@ -55,7 +58,7 @@ def sensor_validation(cls: MockObjClass): ) -def provider_name_validate(value): +def provider_name_validate(value: Any) -> str: value = cv.valid_name(value) if "_" in value: _LOGGER.warning( @@ -83,7 +86,7 @@ PROVIDER_SCHEMA = cv.Schema( ).extend(ENCRYPTION_SCHEMA) -def validate_(config): +def validate_(config: ConfigType) -> ConfigType: if CONF_ENCRYPTION in config: if CONF_SENSORS not in config and CONF_BINARY_SENSORS not in config: raise cv.Invalid("No sensors or binary sensors to encrypt") @@ -117,11 +120,11 @@ TRANSPORT_SCHEMA = ( ) -def transport_schema(cls): +def transport_schema(cls: MockObjClass) -> cv.Schema: return TRANSPORT_SCHEMA.extend({cv.GenerateID(): cv.declare_id(cls)}) -def get_sensors(transport_id): +def get_sensors(transport_id: ID) -> Iterator[ConfigType]: """Return the list of sensors for this platform.""" return ( sensor @@ -130,7 +133,7 @@ def get_sensors(transport_id): ) -def validate_packet_transport_sensor(config): +def validate_packet_transport_sensor(config: ConfigType) -> ConfigType: if CONF_NAME in config and CONF_INTERNAL not in config: raise cv.Invalid("Must provide internal: config when using name:") conf_sensors = CORE.data.setdefault(DOMAIN, {}).setdefault(CONF_SENSORS, []) @@ -138,7 +141,7 @@ def validate_packet_transport_sensor(config): return config -def packet_transport_sensor_schema(base_schema): +def packet_transport_sensor_schema(base_schema: cv.Schema) -> cv.Schema: return cv.All( base_schema.extend( { @@ -152,11 +155,11 @@ def packet_transport_sensor_schema(base_schema): ) -def hash_encryption_key(config: dict): +def hash_encryption_key(config: dict) -> list[int]: return list(hashlib.sha256(config[CONF_KEY].encode()).digest()) -async def register_packet_transport(var, config): +async def register_packet_transport(var: MockObj, config: ConfigType) -> set[str]: var = await cg.register_component(var, config) cg.add(var.set_rolling_code_enable(config[CONF_ROLLING_CODE_ENABLE])) cg.add(var.set_ping_pong_enable(config[CONF_PING_PONG_ENABLE])) @@ -203,7 +206,7 @@ async def register_packet_transport(var, config): return providers -async def new_packet_transport(config): +async def new_packet_transport(config: ConfigType) -> tuple[MockObj, set[str]]: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_platform_name(config[CONF_PLATFORM])) providers = await register_packet_transport(var, config) diff --git a/esphome/components/packet_transport/binary_sensor.py b/esphome/components/packet_transport/binary_sensor.py index 3291ff2c59..37c4688242 100644 --- a/esphome/components/packet_transport/binary_sensor.py +++ b/esphome/components/packet_transport/binary_sensor.py @@ -11,6 +11,7 @@ from esphome.const import ( ENTITY_CATEGORY_DIAGNOSTIC, ) import esphome.final_validate as fv +from esphome.types import ConfigType from . import ( CONF_ENCRYPTION, @@ -44,7 +45,7 @@ CONFIG_SCHEMA = cv.typed_schema( ) -def _final_validate(config) -> None: +def _final_validate(config: ConfigType) -> None: if config[CONF_TYPE] != CONF_STATUS: # Only run this validation if a status sensor is being configured return @@ -65,7 +66,7 @@ def _final_validate(config) -> None: FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) comp = await cg.get_variable(config[CONF_TRANSPORT_ID]) if config[CONF_TYPE] == CONF_STATUS: diff --git a/esphome/components/packet_transport/sensor.py b/esphome/components/packet_transport/sensor.py index 15c0e33b30..018f1c3a9b 100644 --- a/esphome/components/packet_transport/sensor.py +++ b/esphome/components/packet_transport/sensor.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components.sensor import new_sensor, sensor_schema from esphome.const import CONF_ID +from esphome.types import ConfigType from . import ( CONF_PROVIDER, @@ -12,7 +13,7 @@ from . import ( CONFIG_SCHEMA = packet_transport_sensor_schema(sensor_schema()) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await new_sensor(config) comp = await cg.get_variable(config[CONF_TRANSPORT_ID]) remote_id = str(config.get(CONF_REMOTE_ID) or config.get(CONF_ID)) diff --git a/esphome/components/pca9554/__init__.py b/esphome/components/pca9554/__init__.py index f49a68bc3f..5272df2b55 100644 --- a/esphome/components/pca9554/__init__.py +++ b/esphome/components/pca9554/__init__.py @@ -11,6 +11,8 @@ from esphome.const import ( CONF_NUMBER, CONF_OUTPUT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@hwstar", "@clydebarrow", "@bdraco"] AUTO_LOAD = ["gpio_expander"] @@ -40,7 +42,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_pin_count(config[CONF_PIN_COUNT])) await cg.register_component(var, config) @@ -49,7 +51,7 @@ async def to_code(config): cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) -def validate_mode(value): +def validate_mode(value: ConfigType) -> ConfigType: if not (value[CONF_INPUT] or value[CONF_OUTPUT]): raise cv.Invalid("Mode must be either input or output") if value[CONF_INPUT] and value[CONF_OUTPUT]: @@ -69,7 +71,9 @@ PCA9554_PIN_SCHEMA = pins.gpio_base_schema( ) -def pca9554_pin_final_validate(pin_config, parent_config): +def pca9554_pin_final_validate( + pin_config: ConfigType, parent_config: ConfigType +) -> None: count = parent_config[CONF_PIN_COUNT] if pin_config[CONF_NUMBER] >= count: raise cv.Invalid(f"Pin number must be in range 0-{count - 1}") @@ -78,7 +82,7 @@ def pca9554_pin_final_validate(pin_config, parent_config): @pins.PIN_SCHEMA_REGISTRY.register( CONF_PCA9554, PCA9554_PIN_SCHEMA, pca9554_pin_final_validate ) -async def pca9554_pin_to_code(config): +async def pca9554_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) parent = await cg.get_variable(config[CONF_PCA9554]) diff --git a/esphome/components/qspi_dbi/display.py b/esphome/components/qspi_dbi/display.py index 48cd72ecdf..dce1a95687 100644 --- a/esphome/components/qspi_dbi/display.py +++ b/esphome/components/qspi_dbi/display.py @@ -1,4 +1,5 @@ import logging +from typing import Any from esphome import pins import esphome.codegen as cg @@ -26,6 +27,7 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.core import TimePeriod +from esphome.types import ConfigType from . import CONF_DRAW_FROM_ORIGIN from .models import DriverChip @@ -49,14 +51,14 @@ DATA_PIN_SCHEMA = pins.internal_gpio_output_pin_schema DELAY_FLAG = 0xFF -def validate_dimension(value): +def validate_dimension(value: Any) -> int: value = cv.positive_int(value) if value % 2 != 0: raise cv.Invalid("Width/height/offset must be divisible by 2") return value -def map_sequence(value): +def map_sequence(value: Any) -> list[int]: """ The format is a repeated sequence of [CMD, ] where is s a sequence of bytes. The length is inferred from the length of the sequence and should not be explicit. @@ -74,14 +76,14 @@ def map_sequence(value): return [value[0], len(params)] + list(params) -def _validate(config): +def _validate(config: ConfigType) -> ConfigType: chip = DriverChip.chips[config[CONF_MODEL]] if not chip.initsequence and CONF_INIT_SEQUENCE not in config: raise cv.Invalid(f"{chip.name} model requires init_sequence") return config -def power_of_two(value): +def power_of_two(value: Any) -> int: value = cv.int_range(1, 128)(value) if value & (value - 1) != 0: raise cv.Invalid("value must be a power of two") @@ -122,11 +124,11 @@ BASE_SCHEMA = display.FULL_DISPLAY_SCHEMA.extend( ) -def model_property(name, defaults, fallback): +def model_property(name: str, defaults: dict[str, Any], fallback: Any) -> cv.Optional: return cv.Optional(name, default=defaults.get(name, fallback)) -def model_schema(defaults): +def model_schema(defaults: dict[str, Any]) -> cv.Schema: transform = cv.Schema( { cv.Optional(CONF_MIRROR_X, default=False): cv.boolean, @@ -162,7 +164,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: LOGGER.warning( "The 'qspi_dbi' component is deprecated, it is recommended to use 'mipi_spi' instead." ) diff --git a/esphome/components/qspi_dbi/models.py b/esphome/components/qspi_dbi/models.py index 8ce592e0cf..7611279509 100644 --- a/esphome/components/qspi_dbi/models.py +++ b/esphome/components/qspi_dbi/models.py @@ -1,4 +1,6 @@ # Commands +from typing import Any + from esphome.components.const import CONF_DRAW_ROUNDING from esphome.const import CONF_INVERT_COLORS, CONF_SWAP_XY @@ -26,16 +28,16 @@ PAGESEL = 0xFE class DriverChip: - chips = {} + chips: dict[str, "DriverChip"] = {} - def __init__(self, name: str, defaults=None): + def __init__(self, name: str, defaults: dict[str, Any] | None = None) -> None: name = name.upper() self.name = name self.chips[name] = self self.initsequence = [] self.defaults = defaults or {} - def cmd(self, c, *args): + def cmd(self, c: int, *args: int) -> None: """ Add a command sequence to the init sequence :param c: The command (8 bit) @@ -43,7 +45,7 @@ class DriverChip: """ self.initsequence.extend([c, len(args)] + list(args)) - def delay(self, ms): + def delay(self, ms: int) -> None: self.initsequence.extend([ms, 0xFF]) diff --git a/esphome/components/rpi_dpi_rgb/display.py b/esphome/components/rpi_dpi_rgb/display.py index 314852832c..1ca29a3259 100644 --- a/esphome/components/rpi_dpi_rgb/display.py +++ b/esphome/components/rpi_dpi_rgb/display.py @@ -1,4 +1,6 @@ +from collections.abc import Callable import logging +from typing import Any from esphome import pins import esphome.codegen as cg @@ -38,6 +40,7 @@ from esphome.const import ( CONF_VSYNC_PIN, CONF_WIDTH, ) +from esphome.types import ConfigType DEPENDENCIES = ["esp32"] LOGGER = logging.getLogger(__name__) @@ -53,7 +56,7 @@ COLOR_ORDERS = { DATA_PIN_SCHEMA = pins.internal_gpio_output_pin_schema -def data_pin_validate(value): +def data_pin_validate(value: Any) -> ConfigType: """ It is safe to use strapping pins as RGB output data bits, as they are outputs only, and not initialised until after boot. @@ -68,7 +71,7 @@ def data_pin_validate(value): return DATA_PIN_SCHEMA(value) -def data_pin_set(length): +def data_pin_set(length: int) -> Callable[[Any], Any]: return cv.All( [data_pin_validate], cv.Length(min=length, max=length, msg=f"Exactly {length} data pins required"), @@ -128,7 +131,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: LOGGER.warning( "The 'rpi_dpi_rgb' component is deprecated, it is recommended to use 'mipi_rgb' instead." ) diff --git a/esphome/components/sdl/binary_sensor.py b/esphome/components/sdl/binary_sensor.py index e19a488800..0fdda25ed3 100644 --- a/esphome/components/sdl/binary_sensor.py +++ b/esphome/components/sdl/binary_sensor.py @@ -5,6 +5,7 @@ import esphome.config_validation as cv from esphome.const import CONF_KEY from esphome.core import Lambda from esphome.cpp_generator import ExpressionStatement, RawExpression +from esphome.types import ConfigType from .display import CONF_SDL_ID, Sdl @@ -275,7 +276,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) parent = await cg.get_variable(config[CONF_SDL_ID]) listener = Lambda( diff --git a/esphome/components/sdl/display.py b/esphome/components/sdl/display.py index 57266f33e2..5ced2edf5a 100644 --- a/esphome/components/sdl/display.py +++ b/esphome/components/sdl/display.py @@ -1,4 +1,6 @@ +from collections.abc import Callable import subprocess +from typing import Any import esphome.codegen as cg from esphome.components import display @@ -14,6 +16,7 @@ from esphome.const import ( CONF_Y, PLATFORM_HOST, ) +from esphome.types import ConfigType sdl_ns = cg.esphome_ns.namespace("sdl") Sdl = sdl_ns.class_("Sdl", display.Display, cg.Component) @@ -35,7 +38,7 @@ WINDOW_OPTIONS = ( SDL_WINDOWPOS_CENTERED_MASK = 0x2FFF0000 -def get_sdl_options(value): +def get_sdl_options(value: str) -> str: if value != "": return value try: @@ -46,7 +49,7 @@ def get_sdl_options(value): raise cv.Invalid("Unable to run sdl2-config - have you installed sdl2?") from e -def get_window_options(): +def get_window_options() -> dict[cv.Optional, Callable[[Any], Any]]: return {cv.Optional(option, default=False): cv.boolean for option in WINDOW_OPTIONS} @@ -100,7 +103,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: for option in config[CONF_SDL_OPTIONS].split(): cg.add_build_flag(option) cg.add_build_flag("-DSDL_BYTEORDER=4321") diff --git a/esphome/components/sdl/touchscreen/__init__.py b/esphome/components/sdl/touchscreen/__init__.py index 9f84f91c72..d7af8da403 100644 --- a/esphome/components/sdl/touchscreen/__init__.py +++ b/esphome/components/sdl/touchscreen/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import touchscreen import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType from ..display import CONF_SDL_ID, Sdl, sdl_ns @@ -16,7 +17,7 @@ CONFIG_SCHEMA = touchscreen.TOUCHSCREEN_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_parented(var, config[CONF_SDL_ID]) await touchscreen.register_touchscreen(var, config) diff --git a/esphome/components/seeed_mr24hpc1/__init__.py b/esphome/components/seeed_mr24hpc1/__init__.py index f71239d18c..56630f18f4 100644 --- a/esphome/components/seeed_mr24hpc1/__init__.py +++ b/esphome/components/seeed_mr24hpc1/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType DEPENDENCIES = ["uart"] # is the code owner of the relevant code base @@ -43,7 +44,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( # The async def keyword is used to define a concurrent function. # Concurrent functions are special functions designed to work with Python's asyncio library to support asynchronous I/O operations. -async def to_code(config): +async def to_code(config: ConfigType) -> None: # This line of code creates a new Pvariable (a Python object representing a C++ variable) with the variable's ID taken from the configuration. var = cg.new_Pvariable(config[CONF_ID]) # This line of code registers the newly created Pvariable as a component so that ESPHome can manage it at runtime. diff --git a/esphome/components/seeed_mr24hpc1/binary_sensor.py b/esphome/components/seeed_mr24hpc1/binary_sensor.py index 26de1e4ac1..121eb2b4b3 100644 --- a/esphome/components/seeed_mr24hpc1/binary_sensor.py +++ b/esphome/components/seeed_mr24hpc1/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_HAS_TARGET, DEVICE_CLASS_OCCUPANCY +from esphome.types import ConfigType from . import CONF_MR24HPC1_ID, MR24HPC1Component @@ -13,7 +14,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) if has_target_config := config.get(CONF_HAS_TARGET): sens = await binary_sensor.new_binary_sensor(has_target_config) diff --git a/esphome/components/seeed_mr24hpc1/button/__init__.py b/esphome/components/seeed_mr24hpc1/button/__init__.py index 1e68d7e071..3386118bcf 100644 --- a/esphome/components/seeed_mr24hpc1/button/__init__.py +++ b/esphome/components/seeed_mr24hpc1/button/__init__.py @@ -7,6 +7,7 @@ from esphome.const import ( ENTITY_CATEGORY_CONFIG, ICON_RESTART_ALERT, ) +from esphome.types import ConfigType from .. import CONF_MR24HPC1_ID, MR24HPC1Component, mr24hpc1_ns @@ -31,7 +32,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) if restart_config := config.get(CONF_RESTART): b = await button.new_button(restart_config) diff --git a/esphome/components/seeed_mr24hpc1/number/__init__.py b/esphome/components/seeed_mr24hpc1/number/__init__.py index 4de3654e39..d01618b0e6 100644 --- a/esphome/components/seeed_mr24hpc1/number/__init__.py +++ b/esphome/components/seeed_mr24hpc1/number/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import number import esphome.config_validation as cv from esphome.const import CONF_SENSITIVITY, ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType from .. import CONF_MR24HPC1_ID, MR24HPC1Component, mr24hpc1_ns @@ -63,7 +64,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) if sensitivity_config := config.get(CONF_SENSITIVITY): n = await number.new_number( diff --git a/esphome/components/seeed_mr24hpc1/select/__init__.py b/esphome/components/seeed_mr24hpc1/select/__init__.py index 14854f0795..9d46dee6f6 100644 --- a/esphome/components/seeed_mr24hpc1/select/__init__.py +++ b/esphome/components/seeed_mr24hpc1/select/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import select import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType from .. import CONF_MR24HPC1_ID, MR24HPC1Component, mr24hpc1_ns @@ -38,7 +39,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) if scenemode_config := config.get(CONF_SCENE_MODE): s = await select.new_select( diff --git a/esphome/components/seeed_mr24hpc1/sensor.py b/esphome/components/seeed_mr24hpc1/sensor.py index ca15fd5be6..36ee2c0087 100644 --- a/esphome/components/seeed_mr24hpc1/sensor.py +++ b/esphome/components/seeed_mr24hpc1/sensor.py @@ -8,6 +8,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_METER, ) +from esphome.types import ConfigType from . import CONF_MR24HPC1_ID, MR24HPC1Component @@ -60,7 +61,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) if custompresenceofdetection_config := config.get( CONF_CUSTOM_PRESENCE_OF_DETECTION diff --git a/esphome/components/seeed_mr24hpc1/switch/__init__.py b/esphome/components/seeed_mr24hpc1/switch/__init__.py index 741e7de3ca..f9588d783e 100644 --- a/esphome/components/seeed_mr24hpc1/switch/__init__.py +++ b/esphome/components/seeed_mr24hpc1/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import DEVICE_CLASS_SWITCH, ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType from .. import CONF_MR24HPC1_ID, MR24HPC1Component, mr24hpc1_ns @@ -22,7 +23,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) if underlying_open_function_config := config.get(CONF_UNDERLYING_OPEN_FUNCTION): s = await switch.new_switch(underlying_open_function_config) diff --git a/esphome/components/seeed_mr24hpc1/text_sensor.py b/esphome/components/seeed_mr24hpc1/text_sensor.py index fadd9c6dbc..8f284cb20a 100644 --- a/esphome/components/seeed_mr24hpc1/text_sensor.py +++ b/esphome/components/seeed_mr24hpc1/text_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType from . import CONF_MR24HPC1_ID, MR24HPC1Component @@ -47,7 +48,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) if heartbeat_config := config.get(CONF_HEART_BEAT): sens = await text_sensor.new_text_sensor(heartbeat_config) diff --git a/esphome/components/seeed_mr60bha2/__init__.py b/esphome/components/seeed_mr60bha2/__init__.py index 87bdbbd003..6bf8657af9 100644 --- a/esphome/components/seeed_mr60bha2/__init__.py +++ b/esphome/components/seeed_mr60bha2/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@limengdu"] DEPENDENCIES = ["uart"] @@ -35,7 +36,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/seeed_mr60bha2/binary_sensor.py b/esphome/components/seeed_mr60bha2/binary_sensor.py index 99940ebf6d..4130bac224 100644 --- a/esphome/components/seeed_mr60bha2/binary_sensor.py +++ b/esphome/components/seeed_mr60bha2/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_HAS_TARGET, DEVICE_CLASS_OCCUPANCY +from esphome.types import ConfigType from . import CONF_MR60BHA2_ID, MR60BHA2Component @@ -15,7 +16,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr60bha2_component = await cg.get_variable(config[CONF_MR60BHA2_ID]) if has_target_config := config.get(CONF_HAS_TARGET): diff --git a/esphome/components/seeed_mr60bha2/sensor.py b/esphome/components/seeed_mr60bha2/sensor.py index d7f667d862..a2f41a90a8 100644 --- a/esphome/components/seeed_mr60bha2/sensor.py +++ b/esphome/components/seeed_mr60bha2/sensor.py @@ -12,6 +12,7 @@ from esphome.const import ( UNIT_BEATS_PER_MINUTE, UNIT_CENTIMETER, ) +from esphome.types import ConfigType from . import CONF_MR60BHA2_ID, MR60BHA2Component @@ -49,7 +50,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr60bha2_component = await cg.get_variable(config[CONF_MR60BHA2_ID]) if breath_rate_config := config.get(CONF_BREATH_RATE): sens = await sensor.new_sensor(breath_rate_config) diff --git a/esphome/components/seeed_mr60fda2/__init__.py b/esphome/components/seeed_mr60fda2/__init__.py index e79134deec..de6e8ad57b 100644 --- a/esphome/components/seeed_mr60fda2/__init__.py +++ b/esphome/components/seeed_mr60fda2/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@limengdu"] DEPENDENCIES = ["uart"] @@ -35,7 +36,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/seeed_mr60fda2/binary_sensor.py b/esphome/components/seeed_mr60fda2/binary_sensor.py index 2860ac0100..63bd02acd0 100644 --- a/esphome/components/seeed_mr60fda2/binary_sensor.py +++ b/esphome/components/seeed_mr60fda2/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import DEVICE_CLASS_OCCUPANCY, DEVICE_CLASS_SAFETY +from esphome.types import ConfigType from . import CONF_MR60FDA2_ID, MR60FDA2Component @@ -21,7 +22,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr60fda2_component = await cg.get_variable(config[CONF_MR60FDA2_ID]) if people_exist_config := config.get(CONF_PEOPLE_EXIST): diff --git a/esphome/components/seeed_mr60fda2/button/__init__.py b/esphome/components/seeed_mr60fda2/button/__init__.py index 8236248b8c..82f0fc9aea 100644 --- a/esphome/components/seeed_mr60fda2/button/__init__.py +++ b/esphome/components/seeed_mr60fda2/button/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( ENTITY_CATEGORY_DIAGNOSTIC, ENTITY_CATEGORY_NONE, ) +from esphome.types import ConfigType from .. import CONF_MR60FDA2_ID, MR60FDA2Component, mr60fda2_ns @@ -33,7 +34,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr60fda2_component = await cg.get_variable(config[CONF_MR60FDA2_ID]) if get_radar_parameters_config := config.get(CONF_GET_RADAR_PARAMETERS): b = await button.new_button(get_radar_parameters_config) diff --git a/esphome/components/seeed_mr60fda2/select/__init__.py b/esphome/components/seeed_mr60fda2/select/__init__.py index 2fea150cd2..6d8864455f 100644 --- a/esphome/components/seeed_mr60fda2/select/__init__.py +++ b/esphome/components/seeed_mr60fda2/select/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import select import esphome.config_validation as cv from esphome.const import CONF_SENSITIVITY, ENTITY_CATEGORY_CONFIG, ICON_ACCELERATION_Z +from esphome.types import ConfigType from .. import CONF_MR60FDA2_ID, MR60FDA2Component, mr60fda2_ns @@ -33,7 +34,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr60fda2_component = await cg.get_variable(config[CONF_MR60FDA2_ID]) if install_height_config := config.get(CONF_INSTALL_HEIGHT): s = await select.new_select( diff --git a/esphome/components/st7701s/display.py b/esphome/components/st7701s/display.py index 7f6492812f..16d7ef8e86 100644 --- a/esphome/components/st7701s/display.py +++ b/esphome/components/st7701s/display.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import pins import esphome.codegen as cg from esphome.components import display, spi @@ -41,6 +43,7 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.core import TimePeriod +from esphome.types import ConfigType from .init_sequences import ST7701S_INITS, cmd @@ -58,7 +61,7 @@ COLOR_ORDERS = { DATA_PIN_SCHEMA = pins.internal_gpio_output_pin_schema -def data_pin_validate(value): +def data_pin_validate(value: Any) -> ConfigType: """ It is safe to use strapping pins as RGB output data bits, as they are outputs only, and not initialised until after boot. @@ -73,14 +76,14 @@ def data_pin_validate(value): return DATA_PIN_SCHEMA(value) -def data_pin_set(length): +def data_pin_set(length: int) -> cv.Schema: return cv.All( [data_pin_validate], cv.Length(min=length, max=length, msg=f"Exactly {length} data pins required"), ) -def map_sequence(value): +def map_sequence(value: Any) -> list: """ An initialisation sequence can be selected from one of the pre-defined sequences in init_sequences.py, or can be a literal array of data bytes. @@ -170,7 +173,7 @@ FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) await spi.register_spi_device(var, config, write_only=True) diff --git a/esphome/components/st7701s/init_sequences.py b/esphome/components/st7701s/init_sequences.py index 4786731c78..a67f3f63fb 100644 --- a/esphome/components/st7701s/init_sequences.py +++ b/esphome/components/st7701s/init_sequences.py @@ -1,7 +1,7 @@ # These are initialisation sequences for ST7701S displays. The contents are somewhat arcane. -def cmd(c, *args): +def cmd(c: int, *args: int) -> list[int]: """ Create a command sequence :param c: The command (8 bit) diff --git a/esphome/components/udp/__init__.py b/esphome/components/udp/__init__.py index 5dfd188f0f..a782d875b9 100644 --- a/esphome/components/udp/__init__.py +++ b/esphome/components/udp/__init__.py @@ -1,3 +1,6 @@ +from collections.abc import Callable +from typing import Any, NoReturn + from esphome import automation from esphome.automation import Trigger import esphome.codegen as cg @@ -13,7 +16,7 @@ from esphome.components.packet_transport import ( import esphome.config_validation as cv from esphome.const import CONF_DATA, CONF_ID, CONF_PORT, CONF_TRIGGER_ID from esphome.core import ID -from esphome.cpp_generator import MockObj +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@clydebarrow"] @@ -45,8 +48,8 @@ UDP_SCHEMA = cv.Schema( ) -def is_relocated(option): - def validator(value): +def is_relocated(option: str) -> Callable[[Any], NoReturn]: + def validator(value: Any) -> NoReturn: raise cv.Invalid( f"The '{option}' option should now be configured in the 'packet_transport' component" ) @@ -109,13 +112,13 @@ CONFIG_SCHEMA = cv.All( ) -async def register_udp_client(var, config): +async def register_udp_client(var: MockObj, config: ConfigType) -> MockObj: udp_var = await cg.get_variable(config[CONF_UDP_ID]) cg.add(var.set_parent(udp_var)) return udp_var -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_define("USE_UDP") cg.add_global(udp_ns.using) var = cg.new_Pvariable(config[CONF_ID]) @@ -147,7 +150,7 @@ async def to_code(config): cg.add(var.set_should_listen()) -def validate_raw_data(value): +def validate_raw_data(value: Any) -> bytes | list[int]: if isinstance(value, str): return value.encode("utf-8") if isinstance(value, str): @@ -171,7 +174,12 @@ def validate_raw_data(value): ), synchronous=True, ) -async def udp_write_to_code(config, action_id, template_arg, args): +async def udp_write_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) udp_var = await cg.get_variable(config[CONF_ID]) await cg.register_parented(var, udp_var) diff --git a/esphome/components/udp/packet_transport/__init__.py b/esphome/components/udp/packet_transport/__init__.py index e725276717..f2c15289a9 100644 --- a/esphome/components/udp/packet_transport/__init__.py +++ b/esphome/components/udp/packet_transport/__init__.py @@ -7,6 +7,7 @@ from esphome.components.packet_transport import ( ) from esphome.const import CONF_BINARY_SENSORS, CONF_ENCRYPTION, CONF_SENSORS from esphome.cpp_types import PollingComponent +from esphome.types import ConfigType from .. import UDP_SCHEMA, register_udp_client, udp_ns @@ -15,7 +16,7 @@ UDPTransport = udp_ns.class_("UDPTransport", PacketTransport, PollingComponent) CONFIG_SCHEMA = transport_schema(UDPTransport).extend(UDP_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var, providers = await new_packet_transport(config) udp_var = await register_udp_client(var, config) if CONF_ENCRYPTION in config or providers: diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index a921b6fbf0..edbf75f70f 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -18,6 +18,7 @@ from esphome.const import ( ) from esphome.core import CORE from esphome.cpp_types import Component +from esphome.types import ConfigType AUTO_LOAD = ["uart", "usb_host", "bytebuffer"] CODEOWNERS = ["@clydebarrow"] @@ -48,14 +49,14 @@ DEFAULT_BAUD_RATE = 9600 class Type: def __init__( self, - name, - vid, - pid, - cls, - max_channels=1, - baud_rate_required=True, - max_baud=1_000_000, - ): + name: str, + vid: int, + pid: int, + cls: str | None, + max_channels: int = 1, + baud_rate_required: bool = True, + max_baud: int = 1_000_000, + ) -> None: self.name = name cls = cls or name self.vid = vid @@ -156,7 +157,7 @@ CONFIG_SCHEMA = cv.ensure_list( ) -async def to_code(config): +async def to_code(config: list[ConfigType]) -> None: # The output chunk pool/queue are compile-time-sized templates shared by all # USBUartChannel instances, so use the largest buffer_size across every channel # of every device. Add one extra slot because LockFreeQueue is a ring From fbe4b39a165d7e2bd54a448c020f4640d2809dbe Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 02:58:58 +1200 Subject: [PATCH 1598/1815] [core] Add type annotations to component Python (3/11) (#18340) --- .../components/copy/binary_sensor/__init__.py | 3 +- esphome/components/copy/button/__init__.py | 3 +- esphome/components/copy/cover/__init__.py | 3 +- esphome/components/copy/fan/__init__.py | 3 +- esphome/components/copy/lock/__init__.py | 3 +- esphome/components/copy/number/__init__.py | 3 +- esphome/components/copy/select/__init__.py | 3 +- esphome/components/copy/sensor/__init__.py | 3 +- esphome/components/copy/switch/__init__.py | 3 +- esphome/components/copy/text/__init__.py | 3 +- .../components/copy/text_sensor/__init__.py | 3 +- esphome/components/integration/sensor.py | 23 +++++++++--- esphome/components/key_collector/__init__.py | 19 +++++++--- .../key_collector/text_sensor/__init__.py | 4 +-- esphome/components/ledc/output.py | 20 ++++++++--- esphome/components/matrix_keypad/__init__.py | 5 +-- .../matrix_keypad/binary_sensor/__init__.py | 5 +-- esphome/components/pid/climate.py | 26 +++++++++++--- esphome/components/pid/sensor/__init__.py | 3 +- esphome/components/rp2/__init__.py | 15 ++++---- esphome/components/rp2/generate_boards.py | 2 +- esphome/components/rp2/gpio.py | 16 +++++---- esphome/components/rp2040_pwm/output.py | 12 +++++-- esphome/components/sn74hc165/__init__.py | 12 ++++--- esphome/components/sun/__init__.py | 24 ++++++++++--- esphome/components/sun/sensor/__init__.py | 3 +- .../components/sun/text_sensor/__init__.py | 5 +-- esphome/components/touchscreen/__init__.py | 22 ++++++++---- .../touchscreen/binary_sensor/__init__.py | 5 +-- esphome/components/update/__init__.py | 34 ++++++++++++------ esphome/components/vbus/__init__.py | 3 +- .../components/vbus/binary_sensor/__init__.py | 3 +- esphome/components/vbus/sensor/__init__.py | 3 +- .../components/voice_assistant/__init__.py | 35 +++++++++++++++---- .../components/xiaomi_rtcgq02lm/__init__.py | 3 +- .../xiaomi_rtcgq02lm/binary_sensor.py | 3 +- esphome/components/xiaomi_rtcgq02lm/sensor.py | 3 +- 37 files changed, 246 insertions(+), 95 deletions(-) diff --git a/esphome/components/copy/binary_sensor/__init__.py b/esphome/components/copy/binary_sensor/__init__.py index 840200409f..cc8492f21e 100644 --- a/esphome/components/copy/binary_sensor/__init__.py +++ b/esphome/components/copy/binary_sensor/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( CONF_SOURCE_ID, ) from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -33,7 +34,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/copy/button/__init__.py b/esphome/components/copy/button/__init__.py index 8028d6a217..768131bbe5 100644 --- a/esphome/components/copy/button/__init__.py +++ b/esphome/components/copy/button/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( CONF_SOURCE_ID, ) from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -32,7 +33,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await button.register_button(var, config) await cg.register_component(var, config) diff --git a/esphome/components/copy/cover/__init__.py b/esphome/components/copy/cover/__init__.py index ff5bef5668..d23602fa74 100644 --- a/esphome/components/copy/cover/__init__.py +++ b/esphome/components/copy/cover/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( CONF_SOURCE_ID, ) from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -31,7 +32,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await cover.new_cover(config) await cg.register_component(var, config) diff --git a/esphome/components/copy/fan/__init__.py b/esphome/components/copy/fan/__init__.py index a208e5f80a..ffa414c5f2 100644 --- a/esphome/components/copy/fan/__init__.py +++ b/esphome/components/copy/fan/__init__.py @@ -3,6 +3,7 @@ from esphome.components import fan import esphome.config_validation as cv from esphome.const import CONF_ENTITY_CATEGORY, CONF_ICON, CONF_SOURCE_ID from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -25,7 +26,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await fan.new_fan(config) await cg.register_component(var, config) diff --git a/esphome/components/copy/lock/__init__.py b/esphome/components/copy/lock/__init__.py index 46bc08273e..8d9c4b6eca 100644 --- a/esphome/components/copy/lock/__init__.py +++ b/esphome/components/copy/lock/__init__.py @@ -3,6 +3,7 @@ from esphome.components import lock import esphome.config_validation as cv from esphome.const import CONF_ENTITY_CATEGORY, CONF_ICON, CONF_SOURCE_ID from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -25,7 +26,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await lock.new_lock(config) await cg.register_component(var, config) diff --git a/esphome/components/copy/number/__init__.py b/esphome/components/copy/number/__init__.py index 3e2bbf2aae..9659a605f9 100644 --- a/esphome/components/copy/number/__init__.py +++ b/esphome/components/copy/number/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( CONF_UNIT_OF_MEASUREMENT, ) from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -33,7 +34,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await number.new_number(config, min_value=0, max_value=0, step=0) await cg.register_component(var, config) diff --git a/esphome/components/copy/select/__init__.py b/esphome/components/copy/select/__init__.py index d7ddc52c44..97776b1edd 100644 --- a/esphome/components/copy/select/__init__.py +++ b/esphome/components/copy/select/__init__.py @@ -3,6 +3,7 @@ from esphome.components import select import esphome.config_validation as cv from esphome.const import CONF_ENTITY_CATEGORY, CONF_ICON, CONF_ID, CONF_SOURCE_ID from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -25,7 +26,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await select.register_select(var, config, options=[]) await cg.register_component(var, config) diff --git a/esphome/components/copy/sensor/__init__.py b/esphome/components/copy/sensor/__init__.py index 57ca06aca7..5468798047 100644 --- a/esphome/components/copy/sensor/__init__.py +++ b/esphome/components/copy/sensor/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( CONF_UNIT_OF_MEASUREMENT, ) from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -37,7 +38,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/copy/switch/__init__.py b/esphome/components/copy/switch/__init__.py index ee27e38c5f..0e714540f9 100644 --- a/esphome/components/copy/switch/__init__.py +++ b/esphome/components/copy/switch/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( CONF_SOURCE_ID, ) from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -31,7 +32,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) diff --git a/esphome/components/copy/text/__init__.py b/esphome/components/copy/text/__init__.py index f1ca404b7b..59fdce6c96 100644 --- a/esphome/components/copy/text/__init__.py +++ b/esphome/components/copy/text/__init__.py @@ -3,6 +3,7 @@ from esphome.components import text import esphome.config_validation as cv from esphome.const import CONF_ENTITY_CATEGORY, CONF_ICON, CONF_MODE, CONF_SOURCE_ID from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -26,7 +27,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await text.new_text(config) await cg.register_component(var, config) diff --git a/esphome/components/copy/text_sensor/__init__.py b/esphome/components/copy/text_sensor/__init__.py index 7b38ff1a64..146beae5ea 100644 --- a/esphome/components/copy/text_sensor/__init__.py +++ b/esphome/components/copy/text_sensor/__init__.py @@ -3,6 +3,7 @@ from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_ENTITY_CATEGORY, CONF_ICON, CONF_SOURCE_ID from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -25,7 +26,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/integration/sensor.py b/esphome/components/integration/sensor.py index 8d784df672..82e8ba8df8 100644 --- a/esphome/components/integration/sensor.py +++ b/esphome/components/integration/sensor.py @@ -11,7 +11,10 @@ from esphome.const import ( CONF_UNIT_OF_MEASUREMENT, CONF_VALUE, ) +from esphome.core import ID from esphome.core.entity_helpers import inherit_property_from +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType integration_ns = cg.esphome_ns.namespace("integration") IntegrationSensor = integration_ns.class_( @@ -39,14 +42,14 @@ CONF_TIME_UNIT = "time_unit" CONF_INTEGRATION_METHOD = "integration_method" -def inherit_unit_of_measurement(uom, config): +def inherit_unit_of_measurement(uom: str, config: ConfigType) -> str: suffix = config[CONF_TIME_UNIT] if uom.endswith("/" + suffix): return uom[0 : -len("/" + suffix)] return uom + suffix -def inherit_accuracy_decimals(decimals, config): +def inherit_accuracy_decimals(decimals: int, config: ConfigType) -> int: return decimals + 2 @@ -90,7 +93,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -113,7 +116,12 @@ async def to_code(config): ), synchronous=True, ) -async def sensor_integration_reset_to_code(config, action_id, template_arg, args): +async def sensor_integration_reset_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -130,7 +138,12 @@ async def sensor_integration_reset_to_code(config, action_id, template_arg, args ), synchronous=True, ) -async def sensor_integration_set_value_to_code(config, action_id, template_arg, args): +async def sensor_integration_set_value_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_VALUE], args, cg.float_) diff --git a/esphome/components/key_collector/__init__.py b/esphome/components/key_collector/__init__.py index 1f4519df2d..bf47b6df88 100644 --- a/esphome/components/key_collector/__init__.py +++ b/esphome/components/key_collector/__init__.py @@ -15,8 +15,9 @@ from esphome.const import ( CONF_TIMEOUT, CONF_TRIGGER_ID, ) +from esphome.core import ID from esphome.cpp_generator import MockObj, literal -from esphome.types import TemplateArgsType +from esphome.types import ConfigType, TemplateArgsType CODEOWNERS = ["@ssieb"] @@ -90,7 +91,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) for source_conf in config.get(CONF_SOURCE_ID, ()): @@ -144,7 +145,12 @@ async def to_code(config): ), synchronous=True, ) -async def enable_to_code(config, action_id, template_arg, args): +async def enable_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -160,7 +166,12 @@ async def enable_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def disable_to_code(config, action_id, template_arg, args): +async def disable_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/key_collector/text_sensor/__init__.py b/esphome/components/key_collector/text_sensor/__init__.py index 1676cf7bdf..e32d15df2e 100644 --- a/esphome/components/key_collector/text_sensor/__init__.py +++ b/esphome/components/key_collector/text_sensor/__init__.py @@ -4,7 +4,7 @@ from esphome.components.text_sensor import TextSensor import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.cpp_generator import literal -from esphome.types import TemplateArgsType +from esphome.types import ConfigType, TemplateArgsType from .. import CONF_ON_RESULT, CONF_SOURCE_ID, TRIGGER_TYPES, KeyCollector @@ -15,7 +15,7 @@ CONFIG_SCHEMA = text_sensor.text_sensor_schema(TextSensor).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_SOURCE_ID]) var = cg.new_Pvariable(config[CONF_ID]) await text_sensor.register_text_sensor(var, config) diff --git a/esphome/components/ledc/output.py b/esphome/components/ledc/output.py index 95df1fba23..637e607b6d 100644 --- a/esphome/components/ledc/output.py +++ b/esphome/components/ledc/output.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation, pins import esphome.codegen as cg from esphome.components import output @@ -9,20 +11,23 @@ from esphome.const import ( CONF_PHASE_ANGLE, CONF_PIN, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["esp32"] -def calc_max_frequency(bit_depth): +def calc_max_frequency(bit_depth: int) -> float: return 80e6 / (2**bit_depth) -def calc_min_frequency(bit_depth): +def calc_min_frequency(bit_depth: int) -> float: max_div_num = ((2**20) - 1) / 256.0 return 80e6 / (max_div_num * (2**bit_depth)) -def validate_frequency(value): +def validate_frequency(value: Any) -> float: value = cv.frequency(value) min_freq = calc_min_frequency(20) max_freq = calc_max_frequency(1) @@ -56,7 +61,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: gpio = await cg.gpio_pin_expression(config[CONF_PIN]) var = cg.new_Pvariable(config[CONF_ID], gpio) await cg.register_component(var, config) @@ -79,7 +84,12 @@ async def to_code(config): ), synchronous=True, ) -async def ledc_set_frequency_to_code(config, action_id, template_arg, args): +async def ledc_set_frequency_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) diff --git a/esphome/components/matrix_keypad/__init__.py b/esphome/components/matrix_keypad/__init__.py index 868b149211..47cf4793b1 100644 --- a/esphome/components/matrix_keypad/__init__.py +++ b/esphome/components/matrix_keypad/__init__.py @@ -4,6 +4,7 @@ from esphome.components import key_provider from esphome.components.const import CONF_ROWS import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_ON_KEY, CONF_PIN, CONF_TRIGGER_ID +from esphome.types import ConfigType CODEOWNERS = ["@ssieb"] @@ -27,7 +28,7 @@ CONF_HAS_DIODES = "has_diodes" CONF_HAS_PULLDOWNS = "has_pulldowns" -def check_keys(obj): +def check_keys(obj: ConfigType) -> ConfigType: if CONF_KEYS in obj and len(obj[CONF_KEYS]) != len(obj[CONF_ROWS]) * len( obj[CONF_COLUMNS] ): @@ -62,7 +63,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) row_pins = [] diff --git a/esphome/components/matrix_keypad/binary_sensor/__init__.py b/esphome/components/matrix_keypad/binary_sensor/__init__.py index 8e63ed43ce..6c6e0aad73 100644 --- a/esphome/components/matrix_keypad/binary_sensor/__init__.py +++ b/esphome/components/matrix_keypad/binary_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_COL, CONF_ID, CONF_KEY, CONF_ROW +from esphome.types import ConfigType from .. import CONF_KEYPAD_ID, MatrixKeypad, matrix_keypad_ns @@ -12,7 +13,7 @@ MatrixKeypadBinarySensor = matrix_keypad_ns.class_( ) -def check_button(obj): +def check_button(obj: ConfigType) -> ConfigType: if CONF_ROW in obj or CONF_COL in obj: if CONF_KEY in obj: raise cv.Invalid("You can't provide both a key and a position") @@ -40,7 +41,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: if CONF_KEY in config: var = cg.new_Pvariable(config[CONF_ID], config[CONF_KEY][0]) else: diff --git a/esphome/components/pid/climate.py b/esphome/components/pid/climate.py index 3e4ff754c9..4945547f2e 100644 --- a/esphome/components/pid/climate.py +++ b/esphome/components/pid/climate.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import climate, output, sensor import esphome.config_validation as cv from esphome.const import CONF_HUMIDITY_SENSOR, CONF_ID, CONF_SENSOR +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType pid_ns = cg.esphome_ns.namespace("pid") PIDClimate = pid_ns.class_("PIDClimate", climate.Climate, cg.Component) @@ -82,7 +85,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) await cg.register_component(var, config) @@ -141,7 +144,12 @@ async def to_code(config): ), synchronous=True, ) -async def pid_reset_integral_term(config, action_id, template_arg, args): +async def pid_reset_integral_term( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -163,7 +171,12 @@ async def pid_reset_integral_term(config, action_id, template_arg, args): ), synchronous=True, ) -async def esp8266_set_frequency_to_code(config, action_id, template_arg, args): +async def esp8266_set_frequency_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) cg.add(var.set_noiseband(config[CONF_NOISEBAND])) @@ -185,7 +198,12 @@ async def esp8266_set_frequency_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def set_control_parameters(config, action_id, template_arg, args): +async def set_control_parameters( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/pid/sensor/__init__.py b/esphome/components/pid/sensor/__init__.py index d26e88e38a..94d641de47 100644 --- a/esphome/components/pid/sensor/__init__.py +++ b/esphome/components/pid/sensor/__init__.py @@ -3,6 +3,7 @@ from esphome.components import sensor from esphome.components.const import CONF_CLIMATE_ID import esphome.config_validation as cv from esphome.const import CONF_TYPE, ICON_GAUGE, STATE_CLASS_MEASUREMENT, UNIT_PERCENT +from esphome.types import ConfigType from ..climate import PIDClimate, pid_ns @@ -40,7 +41,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_CLIMATE_ID]) var = await sensor.new_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index 60fcd4f8b0..ed975ec01a 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -34,6 +34,7 @@ from esphome.core import ( from esphome.core.config import BOARD_MAX_LENGTH from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed from esphome.platformio.toolchain import copy_ccache_script +from esphome.storage_json import StorageJSON from esphome.types import ConfigType from . import boards @@ -145,7 +146,7 @@ def only_on_variant( return validator_ -def get_download_types(storage_json): +def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: """Binary-download entries for a built RP2040 firmware. Used by device-builder (esphome/device-builder), via @@ -181,7 +182,7 @@ def _format_framework_arduino_version(ver: cv.Version) -> str: return f"https://github.com/earlephilhower/arduino-pico/releases/download/{ver}/rp2040-{ver}.zip" -def _parse_platform_version(value): +def _parse_platform_version(value: Any) -> str: value = cv.string(value) if value.startswith("http"): return value @@ -205,7 +206,7 @@ RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(6, 0, 0) RECOMMENDED_ARDUINO_PLATFORM_VERSION = "9c167c6b8aac4f4cfa6d55a0c4e5b848795150c0" -def _arduino_check_versions(value): +def _arduino_check_versions(value: ConfigType) -> ConfigType: value = value.copy() lookups = { "dev": (cv.Version(6, 0, 0), "https://github.com/earlephilhower/arduino-pico"), @@ -316,7 +317,7 @@ CONFIG_SCHEMA = cv.All( @coroutine_with_priority(CoroPriority.PLATFORM) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add(rp2_ns.setup_preferences()) # Allow LDF to properly discover dependency including those in preprocessor @@ -588,7 +589,7 @@ def _generate_lwipopts_h() -> None: write_file_if_changed(lwip_dir / "lwipopts.h", content) -def add_pio_file(component: str, key: str, data: str): +def add_pio_file(component: str, key: str, data: str) -> None: try: cv.validate_id_name(key) except cv.Invalid as e: @@ -629,7 +630,7 @@ def generate_pio_files() -> bool: # Called by writer.py -def copy_files(): +def copy_files() -> None: dir = Path(__file__).parent post_build_file = dir / "post_build.py.script" copy_file_if_changed( @@ -670,7 +671,7 @@ def _addr2line(tool: str, elf: Path, addr: str) -> str: return f"{addr} (decode failed)" -def process_stacktrace(config, line: str, backtrace_state: bool) -> bool: +def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> bool: """Decode RP2040 crash handler output using addr2line.""" if _CRASH_RE.search(line): _LOGGER.error("RP2040 crash detected - decoding addresses") diff --git a/esphome/components/rp2/generate_boards.py b/esphome/components/rp2/generate_boards.py index cd3f50182c..4066ef6b34 100644 --- a/esphome/components/rp2/generate_boards.py +++ b/esphome/components/rp2/generate_boards.py @@ -256,7 +256,7 @@ def generate(arduino_pico_path: Path) -> str: return result.stdout.decode() -def main(): +def main() -> None: if len(sys.argv) < 2: print(f"Usage: {sys.argv[0]} ", file=sys.stderr) sys.exit(1) diff --git a/esphome/components/rp2/gpio.py b/esphome/components/rp2/gpio.py index e4db6a831c..d325131178 100644 --- a/esphome/components/rp2/gpio.py +++ b/esphome/components/rp2/gpio.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import pins import esphome.codegen as cg import esphome.config_validation as cv @@ -14,6 +16,8 @@ from esphome.const import ( CONF_PULLUP, ) from esphome.core import CORE +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import boards from .const import KEY_BOARD, KEY_RP2, rp2_ns @@ -21,7 +25,7 @@ from .const import KEY_BOARD, KEY_RP2, rp2_ns RP2GPIOPin = rp2_ns.class_("RP2GPIOPin", cg.InternalGPIOPin) -def _lookup_pin(value): +def _lookup_pin(value: str) -> int: board = CORE.data[KEY_RP2][KEY_BOARD] board_pins = boards.RP2_BOARD_PINS.get(board, {}) @@ -35,7 +39,7 @@ def _lookup_pin(value): raise cv.Invalid(f"Cannot resolve pin name '{value}' for board {board}.") -def _translate_pin(value): +def _translate_pin(value: Any) -> int: if isinstance(value, dict) or value is None: raise cv.Invalid( "This variable only supports pin numbers, not full pin schemas " @@ -54,12 +58,12 @@ def _translate_pin(value): return _lookup_pin(value) -def _board_max_virtual_pin(board): +def _board_max_virtual_pin(board: str) -> int | None: """Get the max CYW43 virtual pin for this board, or None if no virtual pins.""" return boards.BOARDS.get(board, {}).get("max_virtual_pin") -def validate_gpio_pin(value): +def validate_gpio_pin(value: Any) -> int: value = _translate_pin(value) board = CORE.data[KEY_RP2][KEY_BOARD] max_virtual = _board_max_virtual_pin(board) @@ -71,7 +75,7 @@ def validate_gpio_pin(value): return value -def validate_supports(value): +def validate_supports(value: ConfigType) -> ConfigType: board = CORE.data[KEY_RP2][KEY_BOARD] if ( _board_max_virtual_pin(board) is None @@ -100,7 +104,7 @@ RP2_PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register("rp2", RP2_PIN_SCHEMA) -async def rp2_pin_to_code(config): +async def rp2_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) num = config[CONF_NUMBER] cg.add(var.set_pin(num)) diff --git a/esphome/components/rp2040_pwm/output.py b/esphome/components/rp2040_pwm/output.py index a2fda58c9e..a0344e8054 100644 --- a/esphome/components/rp2040_pwm/output.py +++ b/esphome/components/rp2040_pwm/output.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID, CONF_PIN +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["rp2"] @@ -22,7 +25,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await output.register_output(var, config) @@ -44,7 +47,12 @@ async def to_code(config): ), synchronous=True, ) -async def rp2040_set_frequency_to_code(config, action_id, template_arg, args): +async def rp2040_set_frequency_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) diff --git a/esphome/components/sn74hc165/__init__.py b/esphome/components/sn74hc165/__init__.py index f2ba5fedd1..4f21312fec 100644 --- a/esphome/components/sn74hc165/__init__.py +++ b/esphome/components/sn74hc165/__init__.py @@ -10,6 +10,8 @@ from esphome.const import ( CONF_MODE, CONF_NUMBER, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] DEPENDENCIES = [] @@ -38,7 +40,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) data_pin = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) @@ -54,7 +56,7 @@ async def to_code(config): cg.add(var.set_sr_count(config[CONF_SR_COUNT])) -def _validate_input_mode(value): +def _validate_input_mode(value: bool) -> bool: if value is not True: raise cv.Invalid("Only input mode is supported") return value @@ -77,7 +79,9 @@ SN74HC165_PIN_SCHEMA = cv.All( ) -def sn74hc165_pin_final_validate(pin_config, parent_config): +def sn74hc165_pin_final_validate( + pin_config: ConfigType, parent_config: ConfigType +) -> None: max_pins = parent_config[CONF_SR_COUNT] * 8 if pin_config[CONF_NUMBER] >= max_pins: raise cv.Invalid(f"Pin number must be less than {max_pins}") @@ -86,7 +90,7 @@ def sn74hc165_pin_final_validate(pin_config, parent_config): @pins.PIN_SCHEMA_REGISTRY.register( CONF_SN74HC165, SN74HC165_PIN_SCHEMA, sn74hc165_pin_final_validate ) -async def sn74hc165_pin_to_code(config): +async def sn74hc165_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_parented(var, config[CONF_SN74HC165]) diff --git a/esphome/components/sun/__init__.py b/esphome/components/sun/__init__.py index c065a82958..33a5c677bd 100644 --- a/esphome/components/sun/__init__.py +++ b/esphome/components/sun/__init__.py @@ -1,5 +1,6 @@ import contextlib import re +from typing import Any from esphome import automation import esphome.codegen as cg @@ -12,6 +13,9 @@ from esphome.const import ( CONF_TIME_ID, CONF_TRIGGER_ID, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter"] sun_ns = cg.esphome_ns.namespace("sun") @@ -40,7 +44,7 @@ ELEVATION_MAP = { } -def elevation(value): +def elevation(value: Any) -> float: if isinstance(value, str): with contextlib.suppress(cv.Invalid): value = ELEVATION_MAP[ @@ -60,7 +64,7 @@ LAT_LON_REGEX = re.compile( ) -def parse_latlon(value): +def parse_latlon(value: Any) -> float: if isinstance(value, str) and value.endswith("°"): # strip trailing degree character value = value[:-1] @@ -114,7 +118,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) time_ = await cg.get_variable(config[CONF_TIME_ID]) cg.add(var.set_time(time_)) @@ -150,7 +154,12 @@ async def to_code(config): } ), ) -async def sun_above_horizon_to_code(config, condition_id, template_arg, args): +async def sun_above_horizon_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) await cg.register_parented(var, config[CONF_ID]) templ = await cg.templatable(config[CONF_ELEVATION], args, cg.double) @@ -171,7 +180,12 @@ async def sun_above_horizon_to_code(config, condition_id, template_arg, args): } ), ) -async def sun_below_horizon_to_code(config, condition_id, template_arg, args): +async def sun_below_horizon_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) await cg.register_parented(var, config[CONF_ID]) templ = await cg.templatable(config[CONF_ELEVATION], args, cg.double) diff --git a/esphome/components/sun/sensor/__init__.py b/esphome/components/sun/sensor/__init__.py index a1ced8ff5b..d2e9fa750d 100644 --- a/esphome/components/sun/sensor/__init__.py +++ b/esphome/components/sun/sensor/__init__.py @@ -7,6 +7,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_DEGREES, ) +from esphome.types import ConfigType from .. import CONF_SUN_ID, Sun, sun_ns @@ -37,7 +38,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/sun/text_sensor/__init__.py b/esphome/components/sun/text_sensor/__init__.py index fc733d3435..523471bd41 100644 --- a/esphome/components/sun/text_sensor/__init__.py +++ b/esphome/components/sun/text_sensor/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( ICON_WEATHER_SUNSET_DOWN, ICON_WEATHER_SUNSET_UP, ) +from esphome.types import ConfigType from .. import CONF_ELEVATION, CONF_SUN_ID, DEFAULT_ELEVATION, Sun, elevation, sun_ns @@ -22,7 +23,7 @@ SUN_TYPES = { } -def validate_optional_icon(config): +def validate_optional_icon(config: ConfigType) -> ConfigType: if CONF_ICON not in config: config = config.copy() config[CONF_ICON] = { @@ -48,7 +49,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/touchscreen/__init__.py b/esphome/components/touchscreen/__init__.py index cf0c5fca19..c8b918007b 100644 --- a/esphome/components/touchscreen/__init__.py +++ b/esphome/components/touchscreen/__init__.py @@ -1,3 +1,7 @@ +from typing import Any + +import voluptuous as vol + from esphome import automation import esphome.codegen as cg from esphome.components import display @@ -14,6 +18,8 @@ from esphome.const import ( CONF_TRANSFORM, ) from esphome.core import CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz", "@nielsnl68"] DEPENDENCIES = ["display"] @@ -40,7 +46,7 @@ CONF_Y_MIN = "y_min" CONF_Y_MAX = "y_max" -def validate_calibration(calibration_config): +def validate_calibration(calibration_config: ConfigType) -> ConfigType: x_min = calibration_config[CONF_X_MIN] x_max = calibration_config[CONF_X_MAX] y_min = calibration_config[CONF_Y_MIN] @@ -60,7 +66,9 @@ def validate_calibration(calibration_config): return calibration_config -def option_with_default(option: str, defaults: dict, required: bool = False): +def option_with_default( + option: str, defaults: dict, required: bool = False +) -> vol.Marker: if option in defaults or not required: return cv.Optional(option, default=defaults.get(option, cv.UNDEFINED)) return cv.Required(option) @@ -119,9 +127,9 @@ def _transform_schema(defaults: dict) -> dict: def touchscreen_schema( - default_touch_timeout=cv.UNDEFINED, - calibration_required=False, - defaults: dict = None, + default_touch_timeout: Any = cv.UNDEFINED, + calibration_required: bool = False, + defaults: dict | None = None, ) -> cv.Schema: defaults = defaults or {} return cv.Schema( @@ -143,7 +151,7 @@ def touchscreen_schema( TOUCHSCREEN_SCHEMA = touchscreen_schema(cv.UNDEFINED) -async def register_touchscreen(var, config): +async def register_touchscreen(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) disp = await cg.get_variable(config[CONF_DISPLAY]) @@ -192,6 +200,6 @@ async def register_touchscreen(var, config): @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(touchscreen_ns.using) cg.add_define("USE_TOUCHSCREEN") diff --git a/esphome/components/touchscreen/binary_sensor/__init__.py b/esphome/components/touchscreen/binary_sensor/__init__.py index 5ce0defb31..6a66d00ea6 100644 --- a/esphome/components/touchscreen/binary_sensor/__init__.py +++ b/esphome/components/touchscreen/binary_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor, display import esphome.config_validation as cv from esphome.const import CONF_PAGE_ID, CONF_PAGES +from esphome.types import ConfigType from .. import CONF_TOUCHSCREEN_ID, TouchListener, Touchscreen, touchscreen_ns @@ -22,7 +23,7 @@ CONF_Y_MAX = "y_max" CONF_USE_RAW = "use_raw" -def _validate_coords(config): +def _validate_coords(config: ConfigType) -> ConfigType: if ( config[CONF_X_MAX] < config[CONF_X_MIN] or config[CONF_Y_MAX] < config[CONF_Y_MIN] @@ -66,7 +67,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) await cg.register_parented(var, config[CONF_TOUCHSCREEN_ID]) diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index 18d333a5ef..5ebe58881d 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -14,14 +14,15 @@ from esphome.const import ( DEVICE_CLASS_FIRMWARE, ENTITY_CATEGORY_CONFIG, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +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, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] IS_PLATFORM_COMPONENT = True @@ -95,7 +96,7 @@ def update_schema( @setup_entity("update") -async def setup_update_core_(var, config): +async def setup_update_core_(var: MockObj, config: ConfigType) -> None: setup_device_class(config) if on_update_available := config.get(CONF_ON_UPDATE_AVAILABLE): @@ -113,7 +114,7 @@ async def setup_update_core_(var, config): await web_server.add_entity_config(var, web_server_config) -async def register_update(var, config): +async def register_update(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) queue_entity_register("update", config) @@ -121,14 +122,14 @@ async def register_update(var, config): await setup_update_core_(var, config) -async def new_update(config): +async def new_update(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await register_update(var, config) return var @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(update_ns.using) @@ -145,7 +146,12 @@ async def to_code(config): ), synchronous=True, ) -async def update_perform_action_to_code(config, action_id, template_arg, args): +async def update_perform_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) @@ -164,7 +170,12 @@ async def update_perform_action_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def update_check_action_to_code(config, action_id, template_arg, args): +async def update_check_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -180,8 +191,11 @@ async def update_check_action_to_code(config, action_id, template_arg, args): ), ) async def update_is_available_condition_to_code( - config, condition_id, template_arg, args -): + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/vbus/__init__.py b/esphome/components/vbus/__init__.py index 2663496456..94857050f2 100644 --- a/esphome/components/vbus/__init__.py +++ b/esphome/components/vbus/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@ssieb"] @@ -29,7 +30,7 @@ CONFIG_SCHEMA = uart.UART_DEVICE_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/vbus/binary_sensor/__init__.py b/esphome/components/vbus/binary_sensor/__init__.py index 85f1172166..5c09a025f8 100644 --- a/esphome/components/vbus/binary_sensor/__init__.py +++ b/esphome/components/vbus/binary_sensor/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAGNOSTIC, ) +from esphome.types import ConfigType from .. import ( CONF_DELTASOL_BS2, @@ -256,7 +257,7 @@ CONFIG_SCHEMA = cv.typed_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/vbus/sensor/__init__.py b/esphome/components/vbus/sensor/__init__.py index 9c3665eb1c..e8a6ea7bfa 100644 --- a/esphome/components/vbus/sensor/__init__.py +++ b/esphome/components/vbus/sensor/__init__.py @@ -29,6 +29,7 @@ from esphome.const import ( UNIT_PERCENT, UNIT_WATT_HOURS, ) +from esphome.types import ConfigType from .. import ( CONF_DELTASOL_BS2, @@ -650,7 +651,7 @@ CONFIG_SCHEMA = cv.typed_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index f41adfd8de..d30eaf4768 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -14,6 +14,9 @@ from esphome.const import ( CONF_ON_START, CONF_SPEAKER, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType AUTO_LOAD = ["audio", "ring_buffer", "socket"] DEPENDENCIES = ["api", "microphone"] @@ -78,7 +81,7 @@ ConnectedCondition = voice_assistant_ns.class_( Timer = voice_assistant_ns.struct("Timer") -def tts_stream_validate(config): +def tts_stream_validate(config: ConfigType) -> ConfigType: if CONF_SPEAKER not in config and ( CONF_ON_TTS_STREAM_START in config or CONF_ON_TTS_STREAM_END in config ): @@ -199,7 +202,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -420,7 +423,12 @@ VOICE_ASSISTANT_ACTION_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(VoiceAssis ), synchronous=True, ) -async def voice_assistant_listen_to_code(config, action_id, template_arg, args): +async def voice_assistant_listen_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) if CONF_SILENCE_DETECTION in config: @@ -434,7 +442,12 @@ async def voice_assistant_listen_to_code(config, action_id, template_arg, args): @register_action( "voice_assistant.stop", StopAction, VOICE_ASSISTANT_ACTION_SCHEMA, synchronous=True ) -async def voice_assistant_stop_to_code(config, action_id, template_arg, args): +async def voice_assistant_stop_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -443,7 +456,12 @@ async def voice_assistant_stop_to_code(config, action_id, template_arg, args): @register_condition( "voice_assistant.is_running", IsRunningCondition, VOICE_ASSISTANT_ACTION_SCHEMA ) -async def voice_assistant_is_running_to_code(config, condition_id, template_arg, args): +async def voice_assistant_is_running_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -452,7 +470,12 @@ async def voice_assistant_is_running_to_code(config, condition_id, template_arg, @register_condition( "voice_assistant.connected", ConnectedCondition, VOICE_ASSISTANT_ACTION_SCHEMA ) -async def voice_assistant_connected_to_code(config, condition_id, template_arg, args): +async def voice_assistant_connected_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/xiaomi_rtcgq02lm/__init__.py b/esphome/components/xiaomi_rtcgq02lm/__init__.py index 3e235d985f..7b289a8ee3 100644 --- a/esphome/components/xiaomi_rtcgq02lm/__init__.py +++ b/esphome/components/xiaomi_rtcgq02lm/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_BINDKEY, CONF_ID, CONF_MAC_ADDRESS +from esphome.types import ConfigType AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] CODEOWNERS = ["@jesserockz"] @@ -26,7 +27,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await ble_device_base.register_ble_device(var, config) diff --git a/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py b/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py index 8d0508b59b..57420125cb 100644 --- a/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py +++ b/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py @@ -11,6 +11,7 @@ from esphome.const import ( DEVICE_CLASS_MOTION, ) from esphome.core import TimePeriod +from esphome.types import ConfigType from . import XiaomiRTCGQ02LM @@ -45,7 +46,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_ID]) if CONF_MOTION in config: diff --git a/esphome/components/xiaomi_rtcgq02lm/sensor.py b/esphome/components/xiaomi_rtcgq02lm/sensor.py index e49f1c960b..e0e4b4640b 100644 --- a/esphome/components/xiaomi_rtcgq02lm/sensor.py +++ b/esphome/components/xiaomi_rtcgq02lm/sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_PERCENT, ) +from esphome.types import ConfigType from . import XiaomiRTCGQ02LM @@ -29,7 +30,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_ID]) if CONF_BATTERY_LEVEL in config: From b6a9761dae4b015a30d9dd2492c408ff3ea424b1 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 02:59:51 +1200 Subject: [PATCH 1599/1815] [core] Add type annotations to component Python (4/11) (#18341) --- esphome/components/aic3204/audio_dac.py | 12 +++++- esphome/components/audio_adc/__init__.py | 13 ++++-- esphome/components/audio_dac/__init__.py | 20 ++++++++-- esphome/components/bme68x_bsec2/__init__.py | 7 ++-- esphome/components/bme68x_bsec2/sensor.py | 6 ++- .../components/bme68x_bsec2/text_sensor.py | 6 ++- .../components/dfrobot_sen0395/__init__.py | 23 +++++++++-- .../dfrobot_sen0395/binary_sensor.py | 3 +- .../dfrobot_sen0395/switch/__init__.py | 3 +- esphome/components/dlms_meter/__init__.py | 14 ++++--- .../dlms_meter/binary_sensor/__init__.py | 3 +- .../components/dlms_meter/sensor/__init__.py | 5 ++- .../dlms_meter/text_sensor/__init__.py | 5 ++- esphome/components/ina2xx_base/__init__.py | 11 +++-- esphome/components/logger/__init__.py | 30 +++++++++----- esphome/components/logger/select/__init__.py | 3 +- esphome/components/ltr501/sensor.py | 13 +++--- esphome/components/ltr_als_ps/sensor.py | 11 +++-- esphome/components/msa3xx/__init__.py | 3 +- esphome/components/msa3xx/binary_sensor.py | 3 +- esphome/components/msa3xx/sensor.py | 3 +- esphome/components/msa3xx/text_sensor.py | 6 ++- esphome/components/ota/__init__.py | 10 +++-- esphome/components/safe_mode/__init__.py | 16 +++++--- .../components/safe_mode/button/__init__.py | 3 +- .../components/safe_mode/switch/__init__.py | 3 +- esphome/components/spi/__init__.py | 40 ++++++++++--------- esphome/components/st7789v/display.py | 10 +++-- esphome/components/substitutions/jinja.py | 12 +++--- esphome/components/thermostat/climate.py | 18 ++++++--- .../waveshare_io_ch32v003/__init__.py | 8 ++-- .../waveshare_io_ch32v003/output/__init__.py | 5 ++- .../waveshare_io_ch32v003/sensor/__init__.py | 3 +- esphome/components/web_server/__init__.py | 12 +++--- esphome/components/web_server/ota/__init__.py | 2 +- 35 files changed, 229 insertions(+), 116 deletions(-) diff --git a/esphome/components/aic3204/audio_dac.py b/esphome/components/aic3204/audio_dac.py index b478b573a3..50e2f81f1b 100644 --- a/esphome/components/aic3204/audio_dac.py +++ b/esphome/components/aic3204/audio_dac.py @@ -4,6 +4,9 @@ from esphome.components import i2c from esphome.components.audio_dac import AudioDac import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_MODE +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] DEPENDENCIES = ["i2c"] @@ -39,7 +42,12 @@ SET_AUTO_MUTE_ACTION_SCHEMA = cv.maybe_simple_value( SET_AUTO_MUTE_ACTION_SCHEMA, synchronous=True, ) -async def aic3204_set_volume_to_code(config, action_id, template_arg, args): +async def aic3204_set_volume_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) @@ -49,7 +57,7 @@ async def aic3204_set_volume_to_code(config, action_id, template_arg, args): return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/audio_adc/__init__.py b/esphome/components/audio_adc/__init__.py index 3c3a4988b5..c2bdfb6cb0 100644 --- a/esphome/components/audio_adc/__init__.py +++ b/esphome/components/audio_adc/__init__.py @@ -2,7 +2,9 @@ from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_MIC_GAIN -from esphome.core import CoroPriority, coroutine_with_priority +from esphome.core import ID, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] IS_PLATFORM_COMPONENT = True @@ -28,7 +30,12 @@ SET_MIC_GAIN_ACTION_SCHEMA = cv.maybe_simple_value( SET_MIC_GAIN_ACTION_SCHEMA, synchronous=True, ) -async def audio_adc_set_mic_gain_to_code(config, action_id, template_arg, args): +async def audio_adc_set_mic_gain_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) @@ -39,6 +46,6 @@ async def audio_adc_set_mic_gain_to_code(config, action_id, template_arg, args): @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_define("USE_AUDIO_ADC") cg.add_global(audio_adc_ns.using) diff --git a/esphome/components/audio_dac/__init__.py b/esphome/components/audio_dac/__init__.py index 46c277ce51..1351793afd 100644 --- a/esphome/components/audio_dac/__init__.py +++ b/esphome/components/audio_dac/__init__.py @@ -3,7 +3,9 @@ from esphome.automation import maybe_simple_id import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_VOLUME -from esphome.core import CoroPriority, coroutine_with_priority +from esphome.core import ID, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] IS_PLATFORM_COMPONENT = True @@ -37,7 +39,12 @@ SET_VOLUME_ACTION_SCHEMA = cv.maybe_simple_value( @automation.register_action( "audio_dac.mute_on", MuteOnAction, MUTE_ACTION_SCHEMA, synchronous=True ) -async def audio_dac_mute_action_to_code(config, action_id, template_arg, args): +async def audio_dac_mute_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -48,7 +55,12 @@ async def audio_dac_mute_action_to_code(config, action_id, template_arg, args): SET_VOLUME_ACTION_SCHEMA, synchronous=True, ) -async def audio_dac_set_volume_to_code(config, action_id, template_arg, args): +async def audio_dac_set_volume_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) @@ -59,6 +71,6 @@ async def audio_dac_set_volume_to_code(config, action_id, template_arg, args): @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_define("USE_AUDIO_DAC") cg.add_global(audio_dac_ns.using) diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index c12eb39d2d..8208672b6a 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( CONF_SAMPLE_RATE, CONF_TEMPERATURE_OFFSET, ) +from esphome.cpp_generator import MockObj from esphome.external_files import RemoteFile from esphome.types import ConfigType @@ -94,7 +95,7 @@ def _compute_url(config: dict) -> str: return f"https://raw.githubusercontent.com/boschsensortec/Bosch-BSEC2-Library/{BSEC2_LIBRARY_VERSION}/src/config/{model}/{model}_{algo}_{volts}_{sample_rate}_{operating_age}/{filename}.txt" -def download_bme68x_blob(config): +def download_bme68x_blob(config: ConfigType) -> ConfigType: url = _compute_url(config) path = _compute_local_file_path(url) external_files.download_content(url, path) @@ -138,7 +139,7 @@ def _extract_blob_ref(entry: ConfigType) -> RemoteFile | None: PREFETCH_FILES = external_files.single_stage_prefetch(_extract_blob_ref) -def validate_bme68x(config): +def validate_bme68x(config: ConfigType) -> ConfigType: if CONF_ALGORITHM_OUTPUT not in config: return config @@ -178,7 +179,7 @@ CONFIG_SCHEMA_BASE = ( ) -async def to_code_base(config): +async def to_code_base(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/bme68x_bsec2/sensor.py b/esphome/components/bme68x_bsec2/sensor.py index 52587dba99..863cd9d601 100644 --- a/esphome/components/bme68x_bsec2/sensor.py +++ b/esphome/components/bme68x_bsec2/sensor.py @@ -29,6 +29,8 @@ from esphome.const import ( UNIT_PARTS_PER_MILLION, UNIT_PERCENT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_BME68X_BSEC2_ID, SAMPLE_RATE_OPTIONS, BME68xBSEC2Component @@ -119,7 +121,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if conf := config.get(key): sens = await sensor.new_sensor(conf) cg.add(getattr(hub, f"set_{key}_sensor")(sens)) @@ -127,7 +129,7 @@ async def setup_conf(config, key, hub): cg.add(getattr(hub, f"set_{key}_sample_rate")(sample_rate)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BME68X_BSEC2_ID]) for key in TYPES: await setup_conf(config, key, hub) diff --git a/esphome/components/bme68x_bsec2/text_sensor.py b/esphome/components/bme68x_bsec2/text_sensor.py index fce00afe34..5c6f9f696c 100644 --- a/esphome/components/bme68x_bsec2/text_sensor.py +++ b/esphome/components/bme68x_bsec2/text_sensor.py @@ -2,6 +2,8 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_IAQ_ACCURACY +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_BME68X_BSEC2_ID, BME68xBSEC2Component @@ -21,13 +23,13 @@ CONFIG_SCHEMA = cv.Schema( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if conf := config.get(key): sens = await text_sensor.new_text_sensor(conf) cg.add(getattr(hub, f"set_{key}_text_sensor")(sens)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BME68X_BSEC2_ID]) for key in TYPES: await setup_conf(config, key, hub) diff --git a/esphome/components/dfrobot_sen0395/__init__.py b/esphome/components/dfrobot_sen0395/__init__.py index 943c510279..51562f923c 100644 --- a/esphome/components/dfrobot_sen0395/__init__.py +++ b/esphome/components/dfrobot_sen0395/__init__.py @@ -1,9 +1,14 @@ +from typing import Any + from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_FACTORY_RESET, CONF_ID, CONF_SENSITIVITY +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@niklasweber"] DEPENDENCIES = ["uart"] @@ -38,7 +43,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) @@ -54,14 +59,19 @@ async def to_code(config): ), synchronous=True, ) -async def dfrobot_sen0395_reset_to_code(config, action_id, template_arg, args): +async def dfrobot_sen0395_reset_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var -def range_segment_list(input): +def range_segment_list(input: Any) -> list: """Validate input is a list of ranges which can be used to configure the dfrobot mmwave radar A list of segments should be provided. A minimum of one segment is required and a maximum of @@ -154,7 +164,12 @@ MMWAVE_SETTINGS_SCHEMA = cv.Schema( MMWAVE_SETTINGS_SCHEMA, synchronous=True, ) -async def dfrobot_sen0395_settings_to_code(config, action_id, template_arg, args): +async def dfrobot_sen0395_settings_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) diff --git a/esphome/components/dfrobot_sen0395/binary_sensor.py b/esphome/components/dfrobot_sen0395/binary_sensor.py index 193ef925a4..e299c35a42 100644 --- a/esphome/components/dfrobot_sen0395/binary_sensor.py +++ b/esphome/components/dfrobot_sen0395/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import DEVICE_CLASS_MOTION +from esphome.types import ConfigType from . import CONF_DFROBOT_SEN0395_ID, DfrobotSen0395Component @@ -16,7 +17,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_DFROBOT_SEN0395_ID]) binary_sens = await binary_sensor.new_binary_sensor(config) diff --git a/esphome/components/dfrobot_sen0395/switch/__init__.py b/esphome/components/dfrobot_sen0395/switch/__init__.py index 8e492080de..22aaa1640c 100644 --- a/esphome/components/dfrobot_sen0395/switch/__init__.py +++ b/esphome/components/dfrobot_sen0395/switch/__init__.py @@ -3,6 +3,7 @@ from esphome.components import switch import esphome.config_validation as cv from esphome.const import CONF_TYPE, ENTITY_CATEGORY_CONFIG from esphome.cpp_generator import MockObjClass +from esphome.types import ConfigType from .. import CONF_DFROBOT_SEN0395_ID, DfrobotSen0395Component @@ -55,7 +56,7 @@ CONFIG_SCHEMA = cv.typed_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_DFROBOT_SEN0395_ID]) var = await switch.new_switch(config) await cg.register_component(var, config) diff --git a/esphome/components/dlms_meter/__init__.py b/esphome/components/dlms_meter/__init__.py index b747f73a14..00a1694cc3 100644 --- a/esphome/components/dlms_meter/__init__.py +++ b/esphome/components/dlms_meter/__init__.py @@ -1,5 +1,6 @@ import logging import re +from typing import Any import esphome.codegen as cg from esphome.components import esp32, uart @@ -12,6 +13,7 @@ from esphome.const import ( CONF_RECEIVE_TIMEOUT, ) from esphome.core import CORE +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -33,13 +35,13 @@ DlmsMeterComponent = dlms_meter_component_ns.class_( ) -def obis_code(value): +def obis_code(value: Any) -> str: # Normalize the OBIS code to the strict A.B.C.D.E.F format bytes_list = parse_obis_code_bytes(value) return ".".join(str(b) for b in bytes_list) -def parse_obis_code_bytes(value): +def parse_obis_code_bytes(value: Any) -> list[int]: value = cv.string(value) normalized = re.sub(r"[\-\:\*]", ".", value) parts = normalized.split(".") @@ -57,19 +59,19 @@ def parse_obis_code_bytes(value): return bytes_list -def custom_pattern_dict(value): +def custom_pattern_dict(value: Any) -> ConfigType: if isinstance(value, str): return {CONF_PATTERN: value} return value -def validate_custom_pattern(value): +def validate_custom_pattern(value: ConfigType) -> ConfigType: if CONF_DEFAULT_OBIS in value and CONF_NAME not in value: raise cv.Invalid(f"'{CONF_DEFAULT_OBIS}' requires '{CONF_NAME}' to be set") return value -def validate_provider_deprecation(config): +def validate_provider_deprecation(config: ConfigType) -> ConfigType: if CONF_PROVIDER in config: provider = str(config[CONF_PROVIDER]).lower() if provider == "netznoe": @@ -154,7 +156,7 @@ CONFIG_SCHEMA = cv.All( FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema("dlms_meter", require_rx=True) -async def to_code(config): +async def to_code(config: ConfigType) -> None: dec_key_expr = cg.RawExpression("std::nullopt") if dec_key := config.get(CONF_DECRYPTION_KEY): key_bytes = [str(int(dec_key[i : i + 2], 16)) for i in range(0, 32, 2)] diff --git a/esphome/components/dlms_meter/binary_sensor/__init__.py b/esphome/components/dlms_meter/binary_sensor/__init__.py index f9bc1d9df7..a15e58b957 100644 --- a/esphome/components/dlms_meter/binary_sensor/__init__.py +++ b/esphome/components/dlms_meter/binary_sensor/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv +from esphome.types import ConfigType from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code @@ -14,7 +15,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) var = await binary_sensor.new_binary_sensor(config) cg.add(hub.register_binary_sensor(config[CONF_OBIS_CODE], var)) diff --git a/esphome/components/dlms_meter/sensor/__init__.py b/esphome/components/dlms_meter/sensor/__init__.py index ec4639351d..8ded150cd0 100644 --- a/esphome/components/dlms_meter/sensor/__init__.py +++ b/esphome/components/dlms_meter/sensor/__init__.py @@ -16,6 +16,7 @@ from esphome.const import ( UNIT_WATT, UNIT_WATT_HOURS, ) +from esphome.types import ConfigType from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code @@ -47,7 +48,7 @@ DYNAMIC_SCHEMA = sensor.sensor_schema().extend( ) -def deprecation_warning(config): +def deprecation_warning(config: ConfigType) -> ConfigType: _LOGGER.warning( "The dlms_meter sensor schema using predefined keys (e.g., 'voltage_l1') is deprecated and will be removed in 2026.11.0. " "Please update your configuration to use the new schema with 'obis_code'." @@ -145,7 +146,7 @@ OLD_SCHEMA = cv.All( CONFIG_SCHEMA = cv.Any(DYNAMIC_SCHEMA, OLD_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) if obis := config.get(CONF_OBIS_CODE): diff --git a/esphome/components/dlms_meter/text_sensor/__init__.py b/esphome/components/dlms_meter/text_sensor/__init__.py index 0bfb43a285..c2ff0779ee 100644 --- a/esphome/components/dlms_meter/text_sensor/__init__.py +++ b/esphome/components/dlms_meter/text_sensor/__init__.py @@ -3,6 +3,7 @@ import logging import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv +from esphome.types import ConfigType from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code @@ -23,7 +24,7 @@ DYNAMIC_SCHEMA = text_sensor.text_sensor_schema().extend( ) -def deprecation_warning(config): +def deprecation_warning(config: ConfigType) -> ConfigType: _LOGGER.warning( "The dlms_meter text_sensor schema using predefined keys (e.g., 'timestamp') is deprecated and will be removed in 2026.11.0. " "Please update your configuration to use the new schema with 'obis_code'." @@ -46,7 +47,7 @@ OLD_SCHEMA = cv.All( CONFIG_SCHEMA = cv.Any(DYNAMIC_SCHEMA, OLD_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) if obis := config.get(CONF_OBIS_CODE): diff --git a/esphome/components/ina2xx_base/__init__.py b/esphome/components/ina2xx_base/__init__.py index 15e2faba07..7bb589f0b1 100644 --- a/esphome/components/ina2xx_base/__init__.py +++ b/esphome/components/ina2xx_base/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + import esphome.codegen as cg from esphome.components import sensor from esphome.components.const import UNIT_AMPERE_HOUR @@ -26,6 +28,9 @@ from esphome.const import ( UNIT_WATT, UNIT_WATT_HOURS, ) +from esphome.core import EnumValue +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@latonita"] @@ -76,7 +81,7 @@ SENSOR_MODEL_OPTIONS = { } -def validate_model_config(config): +def validate_model_config(config: ConfigType) -> ConfigType: model = config[CONF_MODEL] for key in config: @@ -92,7 +97,7 @@ def validate_model_config(config): return config -def validate_adc_time(value): +def validate_adc_time(value: Any) -> EnumValue: value = cv.positive_time_period_microseconds(value).total_microseconds return cv.enum(ADC_TIMES, int=True)(value) @@ -198,7 +203,7 @@ INA2XX_SCHEMA = cv.Schema( ).extend(cv.polling_component_schema("60s")) -async def setup_ina2xx(var, config): +async def setup_ina2xx(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_model(config[CONF_MODEL])) diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index f307f5d5d1..07b8b03084 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -1,4 +1,5 @@ import re +from typing import Any from esphome import automation from esphome.automation import LambdaAction, StatelessLambdaAction @@ -58,7 +59,8 @@ from esphome.const import ( PLATFORM_RTL87XX, PlatformFramework, ) -from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, Lambda, coroutine_with_priority +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] @@ -164,7 +166,7 @@ HARDWARE_UART_TO_SERIAL = { is_log_level = cv.one_of(*LOG_LEVELS, upper=True) -def uart_selection(value): +def uart_selection(value: Any) -> str: if CORE.is_esp32: variant = get_esp32_variant() if variant in UART_SELECTION_ESP32: @@ -187,7 +189,7 @@ def uart_selection(value): raise NotImplementedError -def validate_local_no_higher_than_global(config): +def validate_local_no_higher_than_global(config: ConfigType) -> ConfigType: global_level = config[CONF_LEVEL] global_level_index = LOG_LEVEL_SEVERITY.index(global_level) errs = [] @@ -204,7 +206,7 @@ def validate_local_no_higher_than_global(config): return config -def validate_initial_no_higher_than_global(config): +def validate_initial_no_higher_than_global(config: ConfigType) -> ConfigType: if initial_level := config.get(CONF_INITIAL_LEVEL): global_level = config[CONF_LEVEL] if LOG_LEVEL_SEVERITY.index(initial_level) > LOG_LEVEL_SEVERITY.index( @@ -217,7 +219,7 @@ def validate_initial_no_higher_than_global(config): return config -def validate_wait_for_cdc(config): +def validate_wait_for_cdc(config: ConfigType) -> ConfigType: if config.get(CONF_WAIT_FOR_CDC) and config.get(CONF_HARDWARE_UART) != USB_CDC: raise cv.Invalid("wait_for_cdc requires hardware_uart: USB_CDC") return config @@ -518,7 +520,7 @@ async def _late_logger_init(config: ConfigType) -> None: CORE.add_job(final_step) -def validate_printf(value): +def validate_printf(value: ConfigType) -> ConfigType: # https://stackoverflow.com/questions/30011379/how-can-i-parse-a-c-format-string-in-python cfmt = r""" ( # start of capture group 1 @@ -559,7 +561,12 @@ LOGGER_LOG_ACTION_SCHEMA = cv.All( @automation.register_action( CONF_LOGGER_LOG, LambdaAction, LOGGER_LOG_ACTION_SCHEMA, synchronous=True ) -async def logger_log_action_to_code(config, action_id, template_arg, args): +async def logger_log_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: esp_log = LOG_LEVEL_TO_ESP_LOG[config[CONF_LEVEL]] args_ = [cg.RawExpression(str(x)) for x in config[CONF_ARGS]] @@ -584,7 +591,12 @@ async def logger_log_action_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def logger_set_level_to_code(config, action_id, template_arg, args): +async def logger_set_level_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: level = LOG_LEVELS[config[CONF_LEVEL]] logger = await cg.get_variable(config[CONF_LOGGER_ID]) if tag := config.get(CONF_TAG): @@ -656,7 +668,7 @@ def request_log_listener() -> None: @coroutine_with_priority(CoroPriority.FINAL) -async def final_step(): +async def final_step() -> None: """Final code generation step to configure optional logger features.""" domain_data = CORE.data.get(DOMAIN, {}) if domain_data.get(KEY_LEVEL_LISTENERS, False): diff --git a/esphome/components/logger/select/__init__.py b/esphome/components/logger/select/__init__.py index 6ce663978e..00f67422f3 100644 --- a/esphome/components/logger/select/__init__.py +++ b/esphome/components/logger/select/__init__.py @@ -4,6 +4,7 @@ import esphome.config_validation as cv from esphome.const import CONF_LEVEL, CONF_LOGGER, ENTITY_CATEGORY_CONFIG, ICON_BUG from esphome.core import CORE from esphome.cpp_helpers import register_component, register_parented +from esphome.types import ConfigType from .. import ( CONF_LOGGER_ID, @@ -26,7 +27,7 @@ CONFIG_SCHEMA = select.select_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: request_logger_level_listeners() parent = await cg.get_variable(config[CONF_LOGGER_ID]) levels = list(LOG_LEVELS) diff --git a/esphome/components/ltr501/sensor.py b/esphome/components/ltr501/sensor.py index c1fa9009b3..c2091a6336 100644 --- a/esphome/components/ltr501/sensor.py +++ b/esphome/components/ltr501/sensor.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components import i2c, sensor @@ -24,6 +26,7 @@ from esphome.const import ( UNIT_LUX, UNIT_MILLISECOND, ) +from esphome.types import ConfigType CODEOWNERS = ["@latonita"] DEPENDENCIES = ["i2c"] @@ -87,17 +90,17 @@ PS_GAINS = { } -def validate_integration_time(value): +def validate_integration_time(value: Any) -> Any: value = cv.positive_time_period_milliseconds(value).total_milliseconds return cv.enum(INTEGRATION_TIMES, int=True)(value) -def validate_repeat_rate(value): +def validate_repeat_rate(value: Any) -> Any: value = cv.positive_time_period_milliseconds(value).total_milliseconds return cv.enum(MEASUREMENT_REPEAT_RATES, int=True)(value) -def validate_time_and_repeat_rate(config): +def validate_time_and_repeat_rate(config: ConfigType) -> ConfigType: integraton_time = config[CONF_INTEGRATION_TIME] repeat_rate = config[CONF_REPEAT] if integraton_time > repeat_rate: @@ -107,7 +110,7 @@ def validate_time_and_repeat_rate(config): return config -def validate_als_gain_and_integration_time(config): +def validate_als_gain_and_integration_time(config: ConfigType) -> ConfigType: integraton_time = config[CONF_INTEGRATION_TIME] if config[CONF_GAIN] == "1X" and integraton_time > 100: raise cv.Invalid( @@ -221,7 +224,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/ltr_als_ps/sensor.py b/esphome/components/ltr_als_ps/sensor.py index 893415f028..af09282e2d 100644 --- a/esphome/components/ltr_als_ps/sensor.py +++ b/esphome/components/ltr_als_ps/sensor.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components import i2c, sensor @@ -23,6 +25,7 @@ from esphome.const import ( UNIT_LUX, UNIT_MILLISECOND, ) +from esphome.types import ConfigType CODEOWNERS = ["@latonita"] DEPENDENCIES = ["i2c"] @@ -93,17 +96,17 @@ PS_GAINS = { } -def validate_integration_time(value): +def validate_integration_time(value: Any) -> Any: value = cv.positive_time_period_milliseconds(value).total_milliseconds return cv.enum(INTEGRATION_TIMES, int=True)(value) -def validate_repeat_rate(value): +def validate_repeat_rate(value: Any) -> Any: value = cv.positive_time_period_milliseconds(value).total_milliseconds return cv.enum(MEASUREMENT_REPEAT_RATES, int=True)(value) -def validate_time_and_repeat_rate(config): +def validate_time_and_repeat_rate(config: ConfigType) -> ConfigType: integraton_time = config[CONF_INTEGRATION_TIME] repeat_rate = config[CONF_REPEAT] if integraton_time > repeat_rate: @@ -211,7 +214,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/msa3xx/__init__.py b/esphome/components/msa3xx/__init__.py index 04514b584f..0beece6710 100644 --- a/esphome/components/msa3xx/__init__.py +++ b/esphome/components/msa3xx/__init__.py @@ -16,6 +16,7 @@ from esphome.const import ( CONF_TRANSFORM, CONF_TYPE, ) +from esphome.types import ConfigType CODEOWNERS = ["@latonita"] DEPENDENCIES = ["i2c"] @@ -123,7 +124,7 @@ MSA_SENSOR_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/msa3xx/binary_sensor.py b/esphome/components/msa3xx/binary_sensor.py index 732a0ed291..ef27c98e66 100644 --- a/esphome/components/msa3xx/binary_sensor.py +++ b/esphome/components/msa3xx/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_ACTIVE, CONF_NAME, DEVICE_CLASS_VIBRATION, ICON_VIBRATE +from esphome.types import ConfigType from . import CONF_MSA3XX_ID, MSA_SENSOR_SCHEMA @@ -31,7 +32,7 @@ CONFIG_SCHEMA = MSA_SENSOR_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_MSA3XX_ID]) for sensor in EVENT_SENSORS: diff --git a/esphome/components/msa3xx/sensor.py b/esphome/components/msa3xx/sensor.py index 63f050fa05..22bcb94025 100644 --- a/esphome/components/msa3xx/sensor.py +++ b/esphome/components/msa3xx/sensor.py @@ -10,6 +10,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_METER_PER_SECOND_SQUARED, ) +from esphome.types import ConfigType from . import CONF_MSA3XX_ID, MSA_SENSOR_SCHEMA @@ -34,7 +35,7 @@ CONFIG_SCHEMA = MSA_SENSOR_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_MSA3XX_ID]) for accel_key in ACCELERATION_SENSORS: if accel_key in config: diff --git a/esphome/components/msa3xx/text_sensor.py b/esphome/components/msa3xx/text_sensor.py index c53a4aa139..6693ec8542 100644 --- a/esphome/components/msa3xx/text_sensor.py +++ b/esphome/components/msa3xx/text_sensor.py @@ -2,6 +2,8 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_NAME +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_MSA3XX_ID, MSA_SENSOR_SCHEMA @@ -25,13 +27,13 @@ CONFIG_SCHEMA = MSA_SENSOR_SCHEMA.extend( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if sensor_config := config.get(key): var = await text_sensor.new_text_sensor(sensor_config) cg.add(getattr(hub, f"set_{key}_text_sensor")(var)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_MSA3XX_ID]) for key in ORIENTATION_SENSORS: diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 1e2ee947c1..5240db9e8f 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -12,6 +12,8 @@ from esphome.const import ( ) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType OTA_STATE_LISTENER_KEY = "ota_state_listener" @@ -49,7 +51,7 @@ OTAStateChangeTrigger = ota_ns.class_( ) -def _ota_final_validate(config): +def _ota_final_validate(config: ConfigType) -> None: if len(config) < 1: raise cv.Invalid( f"At least one platform must be specified for '{CONF_OTA}'; add '{CONF_PLATFORM}: {CONF_ESPHOME}' for original OTA functionality" @@ -95,7 +97,7 @@ BASE_OTA_SCHEMA = cv.Schema( @coroutine_with_priority(CoroPriority.OTA_UPDATES) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_define("USE_OTA") CORE.add_job(final_step) @@ -103,7 +105,7 @@ async def to_code(config): cg.add_library("Updater", None) -async def ota_to_code(var, config): +async def ota_to_code(var: MockObj, config: ConfigType) -> None: await cg.past_safe_mode() use_state_callback = False for conf in config.get(CONF_ON_STATE_CHANGE, []): @@ -145,7 +147,7 @@ def request_ota_state_listeners() -> None: @coroutine_with_priority(CoroPriority.FINAL) -async def final_step(): +async def final_step() -> None: """Final code generation step to configure optional OTA features.""" if CORE.data.get(OTA_STATE_LISTENER_KEY, False): cg.add_define("USE_OTA_STATE_LISTENER") diff --git a/esphome/components/safe_mode/__init__.py b/esphome/components/safe_mode/__init__.py index 70096a56bc..9bc8a263c8 100644 --- a/esphome/components/safe_mode/__init__.py +++ b/esphome/components/safe_mode/__init__.py @@ -10,8 +10,9 @@ from esphome.const import ( CONF_STORAGE, KEY_PAST_SAFE_MODE, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.cpp_generator import RawExpression +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj, RawExpression, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@paulmonigatti", "@jsuanet", "@kbx81"] @@ -24,7 +25,7 @@ SafeModeComponent = safe_mode_ns.class_("SafeModeComponent", cg.Component) MarkSuccessfulAction = safe_mode_ns.class_("MarkSuccessfulAction", automation.Action) -def _remove_id_if_disabled(value): +def _remove_id_if_disabled(value: ConfigType) -> ConfigType: value = value.copy() if value[CONF_DISABLED]: value.pop(CONF_ID) @@ -62,7 +63,12 @@ CONFIG_SCHEMA = cv.All( ), synchronous=True, ) -async def safe_mode_mark_successful_to_code(config, action_id, template_arg, args): +async def safe_mode_mark_successful_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: parent = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg) cg.add(var.set_parent(parent)) @@ -75,7 +81,7 @@ _CALLBACK_AUTOMATIONS = ( @coroutine_with_priority(CoroPriority.APPLICATION) -async def to_code(config): +async def to_code(config: ConfigType) -> None: if not config[CONF_DISABLED]: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/safe_mode/button/__init__.py b/esphome/components/safe_mode/button/__init__.py index 0731ca50f5..89e2475799 100644 --- a/esphome/components/safe_mode/button/__init__.py +++ b/esphome/components/safe_mode/button/__init__.py @@ -7,6 +7,7 @@ from esphome.const import ( ENTITY_CATEGORY_CONFIG, ICON_RESTART_ALERT, ) +from esphome.types import ConfigType from .. import SafeModeComponent, safe_mode_ns @@ -26,7 +27,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await button.new_button(config) await cg.register_component(var, config) diff --git a/esphome/components/safe_mode/switch/__init__.py b/esphome/components/safe_mode/switch/__init__.py index d656eee84a..529b023d68 100644 --- a/esphome/components/safe_mode/switch/__init__.py +++ b/esphome/components/safe_mode/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import CONF_SAFE_MODE, ENTITY_CATEGORY_CONFIG, ICON_RESTART_ALERT +from esphome.types import ConfigType from .. import SafeModeComponent, safe_mode_ns @@ -21,7 +22,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index 608adc7514..d7b85ee20d 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -83,7 +83,7 @@ def _render_hz(value: float) -> str: return formatted + unit -def _frequency_validator(value): +def _frequency_validator(value: Any) -> float: platform = get_target_platform() frequency = PLATFORM_SPI_CLOCKS[platform] value = cv.frequency(value) @@ -153,17 +153,17 @@ RP_SPI_PINSETS = [ ] -def get_target_platform(): +def get_target_platform() -> str: return CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] -def get_target_variant(): +def get_target_variant() -> str: return CORE.data[KEY_ESP32].get(KEY_VARIANT, "") # Get a list of available hardware interfaces based on target and variant. # The returned value is a list of lists of names -def get_hw_interface_list(): +def get_hw_interface_list() -> list[list[str]]: target_platform = get_target_platform() if target_platform == PLATFORM_ESP8266: return [["spi", "hspi"]] @@ -196,7 +196,7 @@ def one_of_interface_validator(additional_values: list[str] | None = None) -> An if additional_values is None: additional_values = [] - def validator(value: str) -> str: + def validator(value: Any) -> str: return cv.one_of( *sum(get_hw_interface_list(), additional_values), lower=True, @@ -206,7 +206,7 @@ def one_of_interface_validator(additional_values: list[str] | None = None) -> An # Given an SPI name, return the index of it in the available list -def get_spi_index(name): +def get_spi_index(name: str) -> int: for i, ilist in enumerate(get_hw_interface_list()): if name in ilist: return i @@ -218,7 +218,7 @@ def get_spi_index(name): # \param spi the config data for the spi instance # \param index the selected hw interface number, -1 if not yet known # TODO verify that the pins are internal -def validate_hw_pins(spi, index=-1): +def validate_hw_pins(spi: ConfigType, index: int = -1) -> bool: clk_pin = spi[CONF_CLK_PIN] if clk_pin[CONF_INVERTED]: return False @@ -265,7 +265,7 @@ def validate_hw_pins(spi, index=-1): return False -def get_hw_spi(config, available): +def get_hw_spi(config: ConfigType, available: list[int]) -> int | None: """Get an available hardware spi interface suitable for this config""" matching = list(filter(lambda idx: validate_hw_pins(config, idx), available)) if len(matching) != 0: @@ -273,7 +273,7 @@ def get_hw_spi(config, available): return None -def validate_spi_config(config): +def validate_spi_config(config: list[ConfigType]) -> list[ConfigType]: available = list(range(len(get_hw_interface_list()))) for spi in config: interface = spi[CONF_INTERFACE] @@ -317,7 +317,7 @@ def validate_spi_config(config): # Given an SPI index, convert to a string that represents the C++ object for it. -def get_spi_interface(index): +def get_spi_interface(index: int) -> str: platform = get_target_platform() if platform == PLATFORM_ESP32: # ESP32 uses ESP-IDF SPI driver for both Arduino and IDF frameworks @@ -353,7 +353,7 @@ SPI_SINGLE_SCHEMA = cv.All( ) -def spi_mode_schema(mode): +def spi_mode_schema(mode: str) -> cv.Schema: if mode == TYPE_SINGLE: return SPI_SINGLE_SCHEMA pin_count = 4 if mode == TYPE_QUAD else 8 @@ -400,7 +400,7 @@ CONFIG_SCHEMA = cv.All( @coroutine_with_priority(CoroPriority.BUS) -async def to_code(configs): +async def to_code(configs: list[ConfigType]) -> None: cg.add_define("USE_SPI") cg.add_global(spi_ns.using) if CORE.using_arduino and not CORE.is_esp32: @@ -427,11 +427,11 @@ async def to_code(configs): def spi_device_schema( - cs_pin_required=True, - default_data_rate=cv.UNDEFINED, - default_mode=cv.UNDEFINED, - mode=TYPE_SINGLE, -): + cs_pin_required: bool = True, + default_data_rate: Any = cv.UNDEFINED, + default_mode: Any = cv.UNDEFINED, + mode: str = TYPE_SINGLE, +) -> cv.Schema: """Create a schema for an SPI device. :param cs_pin_required: If true, make the CS_PIN required in the config. :param default_data_rate: Optional data_rate to use as default @@ -456,7 +456,7 @@ def spi_device_schema( async def register_spi_device( - var: cg.Pvariable, config: ConfigType, write_only: bool = False + var: cg.MockObj, config: ConfigType, write_only: bool = False ) -> None: parent = await cg.get_variable(config[CONF_SPI_ID]) cg.add(var.set_spi_parent(parent)) @@ -473,7 +473,9 @@ async def register_spi_device( cg.add(var.set_release_device(release_device)) -def final_validate_device_schema(name: str, *, require_mosi: bool, require_miso: bool): +def final_validate_device_schema( + name: str, *, require_mosi: bool, require_miso: bool +) -> cv.Schema: hub_schema = {} if require_miso: hub_schema[ diff --git a/esphome/components/st7789v/display.py b/esphome/components/st7789v/display.py index 3b4d6d99ea..fa72c7c328 100644 --- a/esphome/components/st7789v/display.py +++ b/esphome/components/st7789v/display.py @@ -1,4 +1,5 @@ import logging +from typing import Any from esphome import pins import esphome.codegen as cg @@ -19,6 +20,7 @@ from esphome.const import ( CONF_ROTATION, CONF_WIDTH, ) +from esphome.types import ConfigType from . import st7789v_ns @@ -38,7 +40,9 @@ MODEL_PRESETS = "model_presets" REQUIRE_PS = "require_ps" -def model_spec(require_ps=False, presets=None): +def model_spec( + require_ps: bool = False, presets: dict[str, Any] | None = None +) -> dict[str, Any]: if presets is None: presets = {} return {MODEL_PRESETS: presets, REQUIRE_PS: require_ps} @@ -119,7 +123,7 @@ MODELS = { } -def validate_st7789v(config): +def validate_st7789v(config: ConfigType) -> ConfigType: model_data = MODELS[config[CONF_MODEL]] presets = model_data[MODEL_PRESETS] for key, value in presets.items(): @@ -178,7 +182,7 @@ FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: LOGGER.warning( "The 'st7789v' component is deprecated, it is recommended to use 'mipi_spi' instead." ) diff --git a/esphome/components/substitutions/jinja.py b/esphome/components/substitutions/jinja.py index 36a7425a69..230ad09df9 100644 --- a/esphome/components/substitutions/jinja.py +++ b/esphome/components/substitutions/jinja.py @@ -43,23 +43,23 @@ SAFE_GLOBALS = { class JinjaError(Exception): - def __init__(self, context_trace: dict, expr: str): + def __init__(self, context_trace: dict, expr: str) -> None: self.context_trace = context_trace self.eval_stack = [expr] - def parent(self): + def parent(self) -> BaseException | None: return self.__context__ - def error_name(self): + def error_name(self) -> str: return type(self.parent()).__name__ - def context_trace_str(self): + def context_trace_str(self) -> str: return "\n".join( f" {k} = {repr(v)} ({type(v).__name__})" for k, v in self.context_trace.items() ) - def stack_trace_str(self): + def stack_trace_str(self) -> str: return "\n".join( f" {len(self.eval_stack) - i}: {expr}{i == 0 and ' <-- ' + self.error_name() or ''}" for i, expr in enumerate(self.eval_stack) @@ -67,7 +67,7 @@ class JinjaError(Exception): class TrackerContext(jinja.runtime.Context): - def resolve_or_missing(self, key): + def resolve_or_missing(self, key: str) -> Any: val = super().resolve_or_missing(key) if val is Missing: # Variable not in the template context — check if a resolver callback diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index d609e22ac2..3cc4dc7009 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components import climate, sensor @@ -70,6 +72,7 @@ from esphome.const import ( CONF_TARGET_TEMPERATURE_CHANGE_ACTION, CONF_VISUAL, ) +from esphome.types import ConfigType CONF_DEFAULT_PRESET = "default_preset" CONF_HUMIDITY_CONTROL_DEHUMIDIFY_ACTION = "humidity_control_dehumidify_action" @@ -124,7 +127,12 @@ PRESET_CONFIG_SCHEMA = cv.Schema( ) -def validate_temperature_preset(preset, root_config, name, requirements): +def validate_temperature_preset( + preset: ConfigType, + root_config: ConfigType, + name: str, + requirements: dict[str, list[str]], +) -> None: # verify temperature settings for the provided preset / default / away configuration for config_temp, req_actions in requirements.items(): for req_action in req_actions: @@ -140,7 +148,7 @@ def validate_temperature_preset(preset, root_config, name, requirements): ) -def generate_comparable_preset(config, name): +def generate_comparable_preset(config: ConfigType, name: str) -> str: comparable_preset = f"{CONF_PRESET}:\n - {CONF_NAME}: {name}\n" if CONF_DEFAULT_TARGET_TEMPERATURE_LOW in config: @@ -151,7 +159,7 @@ def generate_comparable_preset(config, name): return comparable_preset -def validate_heat_cool_mode(value) -> list: +def validate_heat_cool_mode(value: Any) -> list: """Validate heat_cool_mode - accepts either True or an automation.""" if value is True: # Convert True to empty automation list @@ -164,7 +172,7 @@ def validate_heat_cool_mode(value) -> list: return automation.validate_automation(single=True)(value) -def validate_thermostat(config): +def validate_thermostat(config: ConfigType) -> ConfigType: # verify corresponding action(s) exist(s) for any defined climate mode or action requirements = { CONF_HEAT_COOL_MODE: [ @@ -681,7 +689,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) await cg.register_component(var, config) diff --git a/esphome/components/waveshare_io_ch32v003/__init__.py b/esphome/components/waveshare_io_ch32v003/__init__.py index b692b858a3..29a939c523 100644 --- a/esphome/components/waveshare_io_ch32v003/__init__.py +++ b/esphome/components/waveshare_io_ch32v003/__init__.py @@ -10,6 +10,8 @@ from esphome.const import ( CONF_NUMBER, CONF_OUTPUT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@latonita"] @@ -41,13 +43,13 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) -def validate_mode(value): +def validate_mode(value: ConfigType) -> ConfigType: if not (value[CONF_INPUT] or value[CONF_OUTPUT]): raise cv.Invalid("Mode must be either input or output") if value[CONF_INPUT] and value[CONF_OUTPUT]: @@ -71,7 +73,7 @@ WAVESHARE_IO_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_WAVESHARE_IO_CH32V003, WAVESHARE_IO_PIN_SCHEMA) -async def waveshare_io_pin_to_code(config): +async def waveshare_io_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) parent = await cg.get_variable(config[CONF_WAVESHARE_IO_CH32V003]) diff --git a/esphome/components/waveshare_io_ch32v003/output/__init__.py b/esphome/components/waveshare_io_ch32v003/output/__init__.py index 9af9ce7e4b..7438769928 100644 --- a/esphome/components/waveshare_io_ch32v003/output/__init__.py +++ b/esphome/components/waveshare_io_ch32v003/output/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_MAX_VALUE, CONF_MIN_VALUE +from esphome.types import ConfigType from .. import ( CONF_WAVESHARE_IO_CH32V003_ID, @@ -23,7 +24,7 @@ DUTY_DEFAULT_MIN = 1 DUTY_DEFAULT_MAX = 247 -def validate_pwm_limits(config): +def validate_pwm_limits(config: ConfigType) -> ConfigType: """Validate that safe_pwm_levels.min_value <= safe_pwm_levels.max_value.""" min_val = config.get(CONF_SAFE_PWM_LEVELS, {}).get(CONF_MIN_VALUE, DUTY_DEFAULT_MIN) @@ -61,7 +62,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) await cg.register_parented(var, config[CONF_WAVESHARE_IO_CH32V003_ID]) diff --git a/esphome/components/waveshare_io_ch32v003/sensor/__init__.py b/esphome/components/waveshare_io_ch32v003/sensor/__init__.py index 1e060bdfe4..8ec2702da6 100644 --- a/esphome/components/waveshare_io_ch32v003/sensor/__init__.py +++ b/esphome/components/waveshare_io_ch32v003/sensor/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_VOLT, ) +from esphome.types import ConfigType from .. import ( CONF_WAVESHARE_IO_CH32V003_ID, @@ -46,7 +47,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_parented(var, config[CONF_WAVESHARE_IO_CH32V003_ID]) await cg.register_component(var, config) diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index b2c0ea14ad..a50c14a2f7 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -4,6 +4,7 @@ import base64 import gzip import logging import re +from typing import Any import esphome.codegen as cg from esphome.components import web_server_base @@ -39,6 +40,7 @@ from esphome.const import ( PLATFORM_RTL87XX, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj import esphome.final_validate as fv from esphome.types import ConfigType @@ -128,7 +130,7 @@ def validate_ota(config: ConfigType) -> ConfigType: _ORIGIN_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*://[^/\s]+$") -def validate_origin(value: str) -> str: +def validate_origin(value: Any) -> str: # "*" is the wildcard that allows any origin. if value == "*": return value @@ -306,7 +308,7 @@ CONFIG_SCHEMA = cv.All( ) -def add_sorting_groups(web_server_var, config): +def add_sorting_groups(web_server_var: MockObj, config: list[ConfigType]) -> None: for group in config: sorting_groups[group[CONF_ID]] = group[CONF_NAME] group_sorting_weight = group.get(CONF_SORTING_WEIGHT, 50) @@ -317,7 +319,7 @@ def add_sorting_groups(web_server_var, config): ) -async def add_entity_config(entity, config): +async def add_entity_config(entity: MockObj, config: ConfigType) -> None: web_server = await cg.get_variable(config[CONF_WEB_SERVER_ID]) sorting_weight = config.get(CONF_SORTING_WEIGHT, 50) sorting_group_hash = hash(config.get(CONF_SORTING_GROUP_ID)) @@ -332,7 +334,7 @@ async def add_entity_config(entity, config): ) -def build_index_html(config) -> str: +def build_index_html(config: ConfigType) -> str: html = "" css_include = config.get(CONF_CSS_INCLUDE) js_include = config.get(CONF_JS_INCLUDE) @@ -366,7 +368,7 @@ def add_resource_as_progmem( @coroutine_with_priority(CoroPriority.WEB) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID]) var = cg.new_Pvariable(config[CONF_ID], paren) diff --git a/esphome/components/web_server/ota/__init__.py b/esphome/components/web_server/ota/__init__.py index 260e6aea6d..03a5c2ca9b 100644 --- a/esphome/components/web_server/ota/__init__.py +++ b/esphome/components/web_server/ota/__init__.py @@ -80,7 +80,7 @@ FINAL_VALIDATE_SCHEMA = _web_server_ota_final_validate @coroutine_with_priority(CoroPriority.WEB_SERVER_OTA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await ota_to_code(var, config) await cg.register_component(var, config) From e83439eaaeed12653926473ff9fbfcbc168254f2 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 04:07:10 +1200 Subject: [PATCH 1600/1815] [core] Add type annotations to component Python (7/11) (#18344) --- esphome/components/as5600/__init__.py | 24 ++++++----- esphome/components/as5600/sensor/__init__.py | 3 +- esphome/components/audio/__init__.py | 17 ++++---- esphome/components/duty_time/sensor.py | 40 ++++++++++++++++--- esphome/components/esp32_hosted/__init__.py | 10 ++--- esphome/components/mixer/speaker/__init__.py | 16 ++++++-- esphome/components/rc522/__init__.py | 4 +- esphome/components/rc522/binary_sensor.py | 7 +++- .../components/resampler/speaker/__init__.py | 11 +++-- esphome/components/rtttl/__init__.py | 28 ++++++++++--- esphome/components/scd4x/sensor.py | 19 +++++++-- esphome/components/sen5x/sensor.py | 13 +++++- esphome/components/sendspin/__init__.py | 6 +-- .../components/sendspin/sensor/__init__.py | 4 +- esphome/components/sound_level/sensor.py | 12 +++++- esphome/components/sps30/sensor.py | 12 +++++- esphome/components/sx127x/__init__.py | 24 ++++++++--- .../sx127x/packet_transport/__init__.py | 3 +- esphome/components/tm1651/__init__.py | 40 ++++++++++++++++--- esphome/components/ufire_ec/sensor.py | 19 +++++++-- esphome/components/ufire_ise/sensor.py | 26 ++++++++++-- 21 files changed, 261 insertions(+), 77 deletions(-) diff --git a/esphome/components/as5600/__init__.py b/esphome/components/as5600/__init__.py index c05e556376..780712c3bd 100644 --- a/esphome/components/as5600/__init__.py +++ b/esphome/components/as5600/__init__.py @@ -1,3 +1,6 @@ +from collections.abc import Callable +from typing import Any + from esphome import pins import esphome.codegen as cg from esphome.components import i2c @@ -11,6 +14,7 @@ from esphome.const import ( CONF_RANGE, CONF_WATCHDOG, ) +from esphome.types import ConfigType CODEOWNERS = ["@ammmze"] DEPENDENCIES = ["i2c"] @@ -72,13 +76,13 @@ POSITION_TO_ANGLE = 360 / RESOLUTION MIN_RANGE = round(18 * ANGLE_TO_POSITION) -def angle(min=-360, max=360): +def angle(min: float = -360, max: float = 360) -> Callable[[Any], Any]: return cv.All( cv.float_with_unit("angle", "(°|deg)"), cv.float_range(min=min, max=max) ) -def angle_to_position(value, min=-360, max=360): +def angle_to_position(value: Any, min: float = -360, max: float = 360) -> int: try: value = angle(min=min, max=max)(value) return (RESOLUTION + round(value * ANGLE_TO_POSITION)) % RESOLUTION @@ -86,17 +90,17 @@ def angle_to_position(value, min=-360, max=360): raise cv.Invalid(f"When using angle, {e.error_message}") from e -def percent_to_position(value): +def percent_to_position(value: Any) -> int: value = cv.possibly_negative_percentage(value) return (RESOLUTION + round(value * RESOLUTION)) % RESOLUTION -def position(min=-MAX_POSITION, max=MAX_POSITION): +def position(min: int = -MAX_POSITION, max: int = MAX_POSITION) -> Callable[[Any], Any]: """Validate that the config option is a position. Accepts integers, degrees, or percentage (of 360 degrees). """ - def validator(value): + def validator(value: Any) -> int: if isinstance(value, str) and value.endswith("%"): value = percent_to_position(value) @@ -112,7 +116,7 @@ def position(min=-MAX_POSITION, max=MAX_POSITION): return validator -def position_range(): +def position_range() -> Callable[[Any], Any]: """Validate that value given is a valid range for the device. A valid range is one of the following: - a value of 0 (meaning full range) @@ -129,7 +133,7 @@ def position_range(): zero_validator, ) - def validator(value): + def validator(value: Any) -> Any: is_negative_str = isinstance(value, str) and value.startswith("-") is_negative_num = isinstance(value, (float, int)) and value < 0 if is_negative_str or is_negative_num: @@ -139,13 +143,13 @@ def position_range(): return validator -def has_valid_range_config(): +def has_valid_range_config() -> Callable[[ConfigType], ConfigType]: """Validate that that the config start + end position results in a valid positional range, which must be >= 18degrees """ range_validator = position_range() - def validator(config): + def validator(config: ConfigType) -> ConfigType: # if we don't have an end position, then there is nothing to do if CONF_END_POSITION not in config: return config @@ -203,7 +207,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/as5600/sensor/__init__.py b/esphome/components/as5600/sensor/__init__.py index cf67a3f203..847b89f121 100644 --- a/esphome/components/as5600/sensor/__init__.py +++ b/esphome/components/as5600/sensor/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( ICON_ROTATE_RIGHT, STATE_CLASS_MEASUREMENT, ) +from esphome.types import ConfigType from .. import AS5600Component, as5600_ns @@ -77,7 +78,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_parented(var, config[CONF_AS5600_ID]) await cg.register_component(var, config) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 1c522cbb5d..277df0506a 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -1,4 +1,6 @@ +from collections.abc import Callable from dataclasses import dataclass, field +from typing import Any import esphome.codegen as cg from esphome.components.esp32 import ( @@ -15,6 +17,7 @@ from esphome.const import ( ) from esphome.core import CORE import esphome.final_validate as fv +from esphome.types import ConfigType AUTO_LOAD = ["ring_buffer"] CODEOWNERS = ["@kahrendt"] @@ -125,10 +128,10 @@ CONF_THREADSAFE = "threadsafe" _MEMORY_LOCATION_VALIDATOR = cv.one_of(*MEMORY_LOCATIONS, lower=True) -def _maybe_empty_codec(schema): +def _maybe_empty_codec(schema: cv.Schema) -> Callable[[Any], Any]: """Wrap a codec dict schema so that a bare key (None value) is treated as an empty dict.""" - def validator(value): + def validator(value: Any) -> Any: if value is None: value = {} return schema(value) @@ -200,14 +203,14 @@ def set_stream_limits( max_channels: int = cv.UNDEFINED, min_sample_rate: int = cv.UNDEFINED, max_sample_rate: int = cv.UNDEFINED, -): +) -> Callable[[ConfigType], None]: """Sets the limits for the audio stream that audio component can handle When the component sinks audio (e.g., a speaker), these indicate the limits to the audio it can receive. When the component sources audio (e.g., a microphone), these indicate the limits to the audio it can send. """ - def set_limits_in_config(config): + def set_limits_in_config(config: ConfigType) -> None: if min_bits_per_sample is not cv.UNDEFINED: config[CONF_MIN_BITS_PER_SAMPLE] = min_bits_per_sample if max_bits_per_sample is not cv.UNDEFINED: @@ -233,7 +236,7 @@ def final_validate_audio_schema( sample_rate: int = cv.UNDEFINED, enabled_channels: list[int] = cv.UNDEFINED, audio_device_issue: bool = False, -): +) -> cv.Schema: """Validates audio compatibility when passed between different components. The component derived from ``AUDIO_COMPONENT_SCHEMA`` should call ``set_stream_limits`` in a validator to specify its compatible settings @@ -251,7 +254,7 @@ def final_validate_audio_schema( audio_device_issue (bool, optional): Format the error message to indicate the problem is in the configuration for the ``audio_device`` component. Defaults to False. """ - def validate_audio_compatiblity(audio_config): + def validate_audio_compatiblity(audio_config: ConfigType) -> ConfigType: audio_schema = {} if bits_per_sample is not cv.UNDEFINED: @@ -329,7 +332,7 @@ def _emit_memory_pair(value: str | None, psram_key: str, internal_key: str) -> N add_idf_sdkconfig_option(internal_key, True) -async def to_code(config): +async def to_code(config: ConfigType) -> None: # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time) include_builtin_idf_component("esp_http_client") diff --git a/esphome/components/duty_time/sensor.py b/esphome/components/duty_time/sensor.py index 456859f8e4..6d878a80a5 100644 --- a/esphome/components/duty_time/sensor.py +++ b/esphome/components/duty_time/sensor.py @@ -19,6 +19,9 @@ from esphome.const import ( STATE_CLASS_TOTAL_INCREASING, UNIT_SECOND, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CONF_LAST_TIME = "last_time" @@ -66,7 +69,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) cg.add(var.set_restore(config[CONF_RESTORE])) @@ -93,7 +96,12 @@ DUTY_TIME_ID_SCHEMA = maybe_simple_id( @register_action( "sensor.duty_time.start", StartAction, DUTY_TIME_ID_SCHEMA, synchronous=True ) -async def sensor_runtime_start_to_code(config, action_id, template_arg, args): +async def sensor_runtime_start_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -102,7 +110,12 @@ async def sensor_runtime_start_to_code(config, action_id, template_arg, args): @register_action( "sensor.duty_time.stop", StopAction, DUTY_TIME_ID_SCHEMA, synchronous=True ) -async def sensor_runtime_stop_to_code(config, action_id, template_arg, args): +async def sensor_runtime_stop_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -111,7 +124,12 @@ async def sensor_runtime_stop_to_code(config, action_id, template_arg, args): @register_action( "sensor.duty_time.reset", ResetAction, DUTY_TIME_ID_SCHEMA, synchronous=True ) -async def sensor_runtime_reset_to_code(config, action_id, template_arg, args): +async def sensor_runtime_reset_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -120,7 +138,12 @@ async def sensor_runtime_reset_to_code(config, action_id, template_arg, args): @register_condition( "sensor.duty_time.is_running", RunningCondition, DUTY_TIME_ID_SCHEMA ) -async def duty_time_is_running_to_code(config, condition_id, template_arg, args): +async def duty_time_is_running_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(condition_id, template_arg, paren, True) @@ -128,6 +151,11 @@ async def duty_time_is_running_to_code(config, condition_id, template_arg, args) @register_condition( "sensor.duty_time.is_not_running", RunningCondition, DUTY_TIME_ID_SCHEMA ) -async def duty_time_is_not_running_to_code(config, condition_id, template_arg, args): +async def duty_time_is_not_running_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(condition_id, template_arg, paren, False) diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 7dc61ce382..ab9455250c 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -64,7 +64,7 @@ SDIO_SCHEMA = BASE_SCHEMA.extend( ) -def _validate_sdio(config): +def _validate_sdio(config: ConfigType) -> ConfigType: if config[CONF_BUS_WIDTH] == 4: for pin in (CONF_D1_PIN, CONF_D2_PIN, CONF_D3_PIN): if pin not in config: @@ -98,7 +98,7 @@ SPI_SCHEMA = BASE_SCHEMA.extend( ) -def _validate_spi(config): +def _validate_spi(config: ConfigType) -> ConfigType: variant = config[CONF_VARIANT] defaults = _SPI_VARIANT_DEFAULTS.get(variant, _SPI_DEFAULT) @@ -141,7 +141,7 @@ def _final_validate(config: ConfigType) -> None: FINAL_VALIDATE_SCHEMA = _final_validate -def _configure_sdio(config): +def _configure_sdio(config: ConfigType) -> None: slot = config[CONF_SLOT] esp32.add_idf_sdkconfig_option( f"CONFIG_ESP_HOSTED_SDIO_SLOT_{slot}", @@ -183,7 +183,7 @@ def _configure_sdio(config): ) -def _configure_spi(config): +def _configure_spi(config: ConfigType) -> None: esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SPI_HOST_INTERFACE", True) # SPI mode is set via per-variant choice options variant = config[CONF_VARIANT] @@ -231,7 +231,7 @@ def _configure_spi(config): esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_DR_ACTIVE_LOW", True) -async def to_code(config): +async def to_code(config: ConfigType) -> None: add_define("USE_ESP32_HOSTED") transport = config[CONF_TYPE] transport_prefix = "SDIO" if transport == "sdio" else "SPI" diff --git a/esphome/components/mixer/speaker/__init__.py b/esphome/components/mixer/speaker/__init__.py index 47164a9997..a3746c019a 100644 --- a/esphome/components/mixer/speaker/__init__.py +++ b/esphome/components/mixer/speaker/__init__.py @@ -15,8 +15,11 @@ from esphome.const import ( CONF_TIMEOUT, PLATFORM_ESP32, ) +from esphome.core import ID from esphome.core.entity_helpers import inherit_property_from +from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv +from esphome.types import ConfigType AUTO_LOAD = ["audio"] CODEOWNERS = ["@kahrendt"] @@ -48,7 +51,7 @@ SOURCE_SPEAKER_SCHEMA = speaker.SPEAKER_SCHEMA.extend( ) -def _validate_source_speaker(config): +def _validate_source_speaker(config: ConfigType) -> ConfigType: fconf = fv.full_config.get() # Get ID for the output speaker and add it to the source speakers config to easily inherit properties @@ -70,7 +73,7 @@ def _validate_source_speaker(config): return config -def _validate_output_speaker(config): +def _validate_output_speaker(config: ConfigType) -> ConfigType: audio.final_validate_audio_schema( "mixer", audio_device=CONF_OUTPUT_SPEAKER, @@ -112,7 +115,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -161,7 +164,12 @@ async def to_code(config): ), synchronous=True, ) -async def ducking_set_to_code(config, action_id, template_arg, args): +async def ducking_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) decibel_reduction = await cg.templatable( diff --git a/esphome/components/rc522/__init__.py b/esphome/components/rc522/__init__.py index ce0d408c04..e9e8dd7b73 100644 --- a/esphome/components/rc522/__init__.py +++ b/esphome/components/rc522/__init__.py @@ -8,6 +8,8 @@ from esphome.const import ( CONF_RESET_PIN, CONF_TRIGGER_ID, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@glmnet"] AUTO_LOAD = ["binary_sensor"] @@ -38,7 +40,7 @@ RC522_SCHEMA = cv.Schema( ).extend(cv.polling_component_schema("1s")) -async def setup_rc522(var, config): +async def setup_rc522(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) if CONF_RESET_PIN in config: diff --git a/esphome/components/rc522/binary_sensor.py b/esphome/components/rc522/binary_sensor.py index 87f81c2223..f295b75df7 100644 --- a/esphome/components/rc522/binary_sensor.py +++ b/esphome/components/rc522/binary_sensor.py @@ -1,15 +1,18 @@ +from typing import Any + import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_UID from esphome.core import HexInt +from esphome.types import ConfigType from . import CONF_RC522_ID, RC522, rc522_ns DEPENDENCIES = ["rc522"] -def validate_uid(value): +def validate_uid(value: Any) -> str: value = cv.string_strict(value) for x in value.split("-"): if len(x) != 2: @@ -39,7 +42,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(RC522BinarySensor).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) hub = await cg.get_variable(config[CONF_RC522_ID]) diff --git a/esphome/components/resampler/speaker/__init__.py b/esphome/components/resampler/speaker/__init__.py index ea080adc6b..7de468cb50 100644 --- a/esphome/components/resampler/speaker/__init__.py +++ b/esphome/components/resampler/speaker/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + import esphome.codegen as cg from esphome.components import audio, psram, speaker import esphome.config_validation as cv @@ -13,6 +15,7 @@ from esphome.const import ( PLATFORM_ESP32, ) from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType AUTO_LOAD = ["audio"] CODEOWNERS = ["@kahrendt"] @@ -27,7 +30,7 @@ CONF_TAPS = "taps" PASSTHROUGH = "passthrough" -def _set_stream_limits(config): +def _set_stream_limits(config: ConfigType) -> ConfigType: audio.set_stream_limits( min_bits_per_sample=16, max_bits_per_sample=32, @@ -36,7 +39,7 @@ def _set_stream_limits(config): return config -def _validate_audio_compatibility(config): +def _validate_audio_compatibility(config: ConfigType) -> None: inherit_property_from(CONF_NUM_CHANNELS, CONF_OUTPUT_SPEAKER)(config) inherit_property_from(CONF_SAMPLE_RATE, CONF_OUTPUT_SPEAKER)(config) @@ -57,7 +60,7 @@ def _validate_audio_compatibility(config): )(config) -def _validate_taps(taps): +def _validate_taps(taps: Any) -> int: value = cv.int_range(min=16, max=128)(taps) if value % 4 != 0: raise cv.Invalid("Number of taps must be divisible by 4") @@ -88,7 +91,7 @@ CONFIG_SCHEMA = cv.All( FINAL_VALIDATE_SCHEMA = _validate_audio_compatibility -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await speaker.register_speaker(var, config) diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index 4880f9ac41..b6c4183586 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -6,7 +6,10 @@ from esphome.components.output import FloatOutput from esphome.components.speaker import Speaker import esphome.config_validation as cv from esphome.const import CONF_GAIN, CONF_ID, CONF_OUTPUT, CONF_PLATFORM, CONF_SPEAKER +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -37,7 +40,7 @@ CONFIG_SCHEMA = cv.All( ) -def validate_parent_output_config(value): +def validate_parent_output_config(value: ConfigType) -> None: platform = value.get(CONF_PLATFORM) PWM_GOOD = ["esp8266_pwm", "ledc"] PWM_BAD = [ @@ -78,7 +81,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -110,7 +113,12 @@ async def to_code(config): ), synchronous=True, ) -async def rtttl_play_to_code(config, action_id, template_arg, args): +async def rtttl_play_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_RTTTL], args, cg.std_string) @@ -128,7 +136,12 @@ async def rtttl_play_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def rtttl_stop_to_code(config, action_id, template_arg, args): +async def rtttl_stop_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -143,7 +156,12 @@ async def rtttl_stop_to_code(config, action_id, template_arg, args): } ), ) -async def rtttl_is_playing_to_code(config, condition_id, template_arg, args): +async def rtttl_is_playing_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/scd4x/sensor.py b/esphome/components/scd4x/sensor.py index 6f14118660..af3ff3a7af 100644 --- a/esphome/components/scd4x/sensor.py +++ b/esphome/components/scd4x/sensor.py @@ -26,6 +26,9 @@ from esphome.const import ( UNIT_PARTS_PER_MILLION, UNIT_PERCENT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@sjtrny", "@martgras"] DEPENDENCIES = ["i2c"] @@ -108,7 +111,7 @@ SETTING_MAP = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -143,7 +146,12 @@ SCD4X_ACTION_SCHEMA = maybe_simple_id( SCD4X_ACTION_SCHEMA, synchronous=True, ) -async def scd4x_frc_to_code(config, action_id, template_arg, args): +async def scd4x_frc_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_VALUE], args, cg.uint16) @@ -164,7 +172,12 @@ SCD4X_RESET_ACTION_SCHEMA = maybe_simple_id( SCD4X_RESET_ACTION_SCHEMA, synchronous=True, ) -async def scd4x_reset_to_code(config, action_id, template_arg, args): +async def scd4x_reset_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index 761a1885ea..e86c8bf899 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg @@ -41,6 +43,8 @@ from esphome.const import ( UNIT_MICROGRAMS_PER_CUBIC_METER, UNIT_PERCENT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@martgras"] @@ -115,7 +119,7 @@ def _gas_sensor( ) -def float_previously_pct(value): +def float_previously_pct(value: Any) -> Any: if isinstance(value, str) and "%" in value: raise cv.Invalid( f"The value '{value}' is a percentage. Suggested value: {float(value.strip('%')) / 100}" @@ -284,6 +288,11 @@ SEN5X_ACTION_SCHEMA = maybe_simple_id( SEN5X_ACTION_SCHEMA, synchronous=True, ) -async def sen54_fan_to_code(config, action_id, template_arg, args): +async def sen54_fan_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 082639374f..570fd3fadd 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -15,7 +15,7 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.core import CORE, ID -from esphome.cpp_generator import TemplateArgsType +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType # mdns for autodiscovery @@ -219,7 +219,7 @@ async def sendspin_switch_to_code( action_id: ID, template_arg: cg.TemplateArguments, args: TemplateArgsType, -): +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -297,7 +297,7 @@ async def to_code(config: ConfigType) -> None: codecs.append(CODEC_FORMAT_OPUS) codecs.append(CODEC_FORMAT_PCM) - def _audio_format(codec, channels): + def _audio_format(codec: MockObj, channels: int) -> cg.StructInitializer: return cg.StructInitializer( AudioSupportedFormatObject, ("codec", codec), diff --git a/esphome/components/sendspin/sensor/__init__.py b/esphome/components/sendspin/sensor/__init__.py index dc9b86c2a3..d6016ed91d 100644 --- a/esphome/components/sendspin/sensor/__init__.py +++ b/esphome/components/sendspin/sensor/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + import esphome.codegen as cg from esphome.components import sensor import esphome.config_validation as cv @@ -50,7 +52,7 @@ def _request_roles(config: ConfigType) -> ConfigType: _HUB_ID_SCHEMA = cv.Schema({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) -def _metadata_schema(**sensor_kwargs): +def _metadata_schema(**sensor_kwargs: Any) -> cv.Schema: """Schema for event-driven numeric metadata sensors (duration/year/track).""" return ( sensor.sensor_schema( diff --git a/esphome/components/sound_level/sensor.py b/esphome/components/sound_level/sensor.py index 44f31979b4..d217534041 100644 --- a/esphome/components/sound_level/sensor.py +++ b/esphome/components/sound_level/sensor.py @@ -11,6 +11,9 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_DECIBEL, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType AUTO_LOAD = ["audio"] CODEOWNERS = ["@kahrendt"] @@ -63,7 +66,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -95,7 +98,12 @@ SOUND_LEVEL_ACTION_SCHEMA = automation.maybe_simple_id( @automation.register_action( "sound_level.stop", StopAction, SOUND_LEVEL_ACTION_SCHEMA, synchronous=True ) -async def sound_level_action_to_code(config, action_id, template_arg, args): +async def sound_level_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/sps30/sensor.py b/esphome/components/sps30/sensor.py index 40557f2cbd..681166cd3c 100644 --- a/esphome/components/sps30/sensor.py +++ b/esphome/components/sps30/sensor.py @@ -26,6 +26,9 @@ from esphome.const import ( UNIT_MICROGRAMS_PER_CUBIC_METER, UNIT_MICROMETER, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@martgras"] DEPENDENCIES = ["i2c"] @@ -120,7 +123,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -197,7 +200,12 @@ SPS30_ACTION_SCHEMA = maybe_simple_id( SPS30_ACTION_SCHEMA, synchronous=True, ) -async def sps30_action_to_code(config, action_id, template_arg, args): +async def sps30_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 8fa7247192..34f2d4122f 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation, pins import esphome.codegen as cg from esphome.components import spi @@ -5,6 +7,8 @@ from esphome.components.const import CONF_CRC_ENABLE, CONF_ON_PACKET import esphome.config_validation as cv from esphome.const import CONF_DATA, CONF_FREQUENCY, CONF_ID from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType MULTI_CONF = True CODEOWNERS = ["@swoboda1337"] @@ -136,7 +140,7 @@ SetModeStandbyAction = sx127x_ns.class_( ) -def validate_raw_data(value): +def validate_raw_data(value: Any) -> bytes | list[int]: if isinstance(value, str): return value.encode("utf-8") if isinstance(value, list): @@ -146,7 +150,7 @@ def validate_raw_data(value): ) -def validate_config(config): +def validate_config(config: ConfigType) -> ConfigType: if config[CONF_MODULATION] == "LORA": bws = [ "7_8kHz", @@ -230,7 +234,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await spi.register_spi_device(var, config) @@ -312,7 +316,12 @@ NO_ARGS_ACTION_SCHEMA = automation.maybe_simple_id( NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -async def no_args_action_to_code(config, action_id, template_arg, args): +async def no_args_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -333,7 +342,12 @@ SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( SEND_PACKET_ACTION_SCHEMA, synchronous=True, ) -async def send_packet_action_to_code(config, action_id, template_arg, args): +async def send_packet_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) data = config[CONF_DATA] diff --git a/esphome/components/sx127x/packet_transport/__init__.py b/esphome/components/sx127x/packet_transport/__init__.py index 2f3a0f6e2b..33204a7d83 100644 --- a/esphome/components/sx127x/packet_transport/__init__.py +++ b/esphome/components/sx127x/packet_transport/__init__.py @@ -6,6 +6,7 @@ from esphome.components.packet_transport import ( ) import esphome.config_validation as cv from esphome.cpp_types import PollingComponent +from esphome.types import ConfigType from .. import CONF_SX127X_ID, SX127x, SX127xListener, sx127x_ns @@ -20,7 +21,7 @@ CONFIG_SCHEMA = transport_schema(SX127xTransport).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var, _ = await new_packet_transport(config) sx127x = await cg.get_variable(config[CONF_SX127X_ID]) cg.add(var.set_parent(sx127x)) diff --git a/esphome/components/tm1651/__init__.py b/esphome/components/tm1651/__init__.py index 7d957df3be..c0cc6f1d2c 100644 --- a/esphome/components/tm1651/__init__.py +++ b/esphome/components/tm1651/__init__.py @@ -9,6 +9,9 @@ from esphome.const import ( CONF_ID, CONF_LEVEL, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@mrtoy-me"] @@ -43,7 +46,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) clk_pin = await cg.gpio_pin_expression(config[CONF_CLK_PIN]) @@ -75,7 +78,12 @@ BINARY_OUTPUT_ACTION_SCHEMA = maybe_simple_id( ), synchronous=True, ) -async def tm1651_set_brightness_to_code(config, action_id, template_arg, args): +async def tm1651_set_brightness_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_BRIGHTNESS], args, cg.uint8) @@ -95,7 +103,12 @@ async def tm1651_set_brightness_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def tm1651_set_level_to_code(config, action_id, template_arg, args): +async def tm1651_set_level_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_LEVEL], args, cg.uint8) @@ -115,7 +128,12 @@ async def tm1651_set_level_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def tm1651_set_level_percent_to_code(config, action_id, template_arg, args): +async def tm1651_set_level_percent_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_LEVEL_PERCENT], args, cg.uint8) @@ -129,7 +147,12 @@ async def tm1651_set_level_percent_to_code(config, action_id, template_arg, args BINARY_OUTPUT_ACTION_SCHEMA, synchronous=True, ) -async def output_turn_off_to_code(config, action_id, template_arg, args): +async def output_turn_off_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -138,7 +161,12 @@ async def output_turn_off_to_code(config, action_id, template_arg, args): @automation.register_action( "tm1651.turn_on", TurnOnAction, BINARY_OUTPUT_ACTION_SCHEMA, synchronous=True ) -async def output_turn_on_to_code(config, action_id, template_arg, args): +async def output_turn_on_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/ufire_ec/sensor.py b/esphome/components/ufire_ec/sensor.py index 1d8775ccf0..9d989ad4e6 100644 --- a/esphome/components/ufire_ec/sensor.py +++ b/esphome/components/ufire_ec/sensor.py @@ -14,6 +14,9 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_MILLISIEMENS_PER_CENTIMETER, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -63,7 +66,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) cg.add(var.set_temperature_compensation(config[CONF_TEMPERATURE_COMPENSATION])) @@ -99,7 +102,12 @@ UFIRE_EC_CALIBRATE_PROBE_SCHEMA = cv.Schema( UFIRE_EC_CALIBRATE_PROBE_SCHEMA, synchronous=True, ) -async def ufire_ec_calibrate_probe_to_code(config, action_id, template_arg, args): +async def ufire_ec_calibrate_probe_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) solution_ = await cg.templatable(config[CONF_SOLUTION], args, cg.float_) @@ -122,6 +130,11 @@ UFIRE_EC_RESET_SCHEMA = cv.Schema( UFIRE_EC_RESET_SCHEMA, synchronous=True, ) -async def ufire_ec_reset_to_code(config, action_id, template_arg, args): +async def ufire_ec_reset_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/ufire_ise/sensor.py b/esphome/components/ufire_ise/sensor.py index 23254b2f47..c7e3b6f28d 100644 --- a/esphome/components/ufire_ise/sensor.py +++ b/esphome/components/ufire_ise/sensor.py @@ -13,6 +13,9 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PH, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -60,7 +63,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -93,7 +96,12 @@ UFIRE_ISE_CALIBRATE_PROBE_SCHEMA = cv.Schema( UFIRE_ISE_CALIBRATE_PROBE_SCHEMA, synchronous=True, ) -async def ufire_ise_calibrate_probe_low_to_code(config, action_id, template_arg, args): +async def ufire_ise_calibrate_probe_low_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_SOLUTION], args, cg.float_) @@ -107,7 +115,12 @@ async def ufire_ise_calibrate_probe_low_to_code(config, action_id, template_arg, UFIRE_ISE_CALIBRATE_PROBE_SCHEMA, synchronous=True, ) -async def ufire_ise_calibrate_probe_high_to_code(config, action_id, template_arg, args): +async def ufire_ise_calibrate_probe_high_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_SOLUTION], args, cg.float_) @@ -124,6 +137,11 @@ UFIRE_ISE_RESET_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(UFireISEComponent UFIRE_ISE_RESET_SCHEMA, synchronous=True, ) -async def ufire_ise_reset_to_code(config, action_id, template_arg, args): +async def ufire_ise_reset_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) From 545f762568609fa7f57e841852308e6c9f2d7dd4 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 04:08:26 +1200 Subject: [PATCH 1601/1815] [core] Add type annotations to component Python (8/11) (#18345) --- .../components/atm90e32/button/__init__.py | 3 +- .../components/atm90e32/number/__init__.py | 3 +- esphome/components/atm90e32/sensor.py | 3 +- .../atm90e32/text_sensor/__init__.py | 3 +- esphome/components/bl0940/button/__init__.py | 3 +- esphome/components/bl0940/number/__init__.py | 5 +-- esphome/components/bl0940/sensor.py | 19 ++++++----- esphome/components/bm8563/time.py | 26 ++++++++++++--- esphome/components/event/__init__.py | 24 ++++++++++---- esphome/components/ld2410/__init__.py | 12 +++++-- esphome/components/ld2410/binary_sensor.py | 3 +- esphome/components/ld2410/button/__init__.py | 3 +- esphome/components/ld2410/number/__init__.py | 3 +- esphome/components/ld2410/select/__init__.py | 3 +- esphome/components/ld2410/sensor.py | 3 +- esphome/components/ld2410/switch/__init__.py | 3 +- esphome/components/ld2410/text_sensor.py | 3 +- esphome/components/ld2412/__init__.py | 3 +- esphome/components/ld2412/binary_sensor.py | 3 +- esphome/components/ld2412/button/__init__.py | 3 +- esphome/components/ld2412/number/__init__.py | 3 +- esphome/components/ld2412/select/__init__.py | 3 +- esphome/components/ld2412/sensor.py | 3 +- esphome/components/ld2412/switch/__init__.py | 3 +- esphome/components/ld2412/text_sensor.py | 3 +- esphome/components/ld2420/__init__.py | 3 +- .../ld2420/binary_sensor/__init__.py | 3 +- esphome/components/ld2420/button/__init__.py | 3 +- esphome/components/ld2420/number/__init__.py | 3 +- esphome/components/ld2420/select/__init__.py | 3 +- esphome/components/ld2420/sensor/__init__.py | 3 +- .../components/ld2420/text_sensor/__init__.py | 3 +- esphome/components/ld2450/__init__.py | 3 +- esphome/components/ld2450/binary_sensor.py | 3 +- esphome/components/ld2450/button/__init__.py | 3 +- esphome/components/ld2450/number/__init__.py | 3 +- esphome/components/ld2450/select/__init__.py | 3 +- esphome/components/ld2450/sensor.py | 3 +- esphome/components/ld2450/switch/__init__.py | 3 +- esphome/components/ld2450/text_sensor.py | 3 +- esphome/components/max6956/__init__.py | 23 ++++++++++--- esphome/components/max6956/output/__init__.py | 3 +- esphome/components/max7219digit/display.py | 33 ++++++++++++++++--- esphome/components/micronova/__init__.py | 10 ++++-- .../components/micronova/button/__init__.py | 3 +- .../components/micronova/number/__init__.py | 3 +- .../components/micronova/sensor/__init__.py | 3 +- .../components/micronova/switch/__init__.py | 3 +- .../micronova/text_sensor/__init__.py | 3 +- esphome/components/pipsolar/__init__.py | 3 +- .../pipsolar/binary_sensor/__init__.py | 3 +- .../components/pipsolar/output/__init__.py | 12 +++++-- .../components/pipsolar/sensor/__init__.py | 3 +- .../components/pipsolar/switch/__init__.py | 3 +- .../pipsolar/text_sensor/__init__.py | 3 +- esphome/components/text/__init__.py | 30 ++++++++++------- .../components/text/text_sensor/__init__.py | 3 +- 57 files changed, 238 insertions(+), 97 deletions(-) diff --git a/esphome/components/atm90e32/button/__init__.py b/esphome/components/atm90e32/button/__init__.py index 19f62ccfbd..274cce6adb 100644 --- a/esphome/components/atm90e32/button/__init__.py +++ b/esphome/components/atm90e32/button/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import button import esphome.config_validation as cv from esphome.const import CONF_ID, ENTITY_CATEGORY_CONFIG, ICON_SCALE +from esphome.types import ConfigType from .. import atm90e32_ns from ..sensor import ATM90E32Component @@ -67,7 +68,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_ID]) if run_gain := config.get(CONF_RUN_GAIN_CALIBRATION): diff --git a/esphome/components/atm90e32/number/__init__.py b/esphome/components/atm90e32/number/__init__.py index 848680b875..9c2865dde3 100644 --- a/esphome/components/atm90e32/number/__init__.py +++ b/esphome/components/atm90e32/number/__init__.py @@ -15,6 +15,7 @@ from esphome.const import ( UNIT_AMPERE, UNIT_VOLT, ) +from esphome.types import ConfigType from .. import atm90e32_ns from ..sensor import ATM90E32Component @@ -90,7 +91,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_ID]) if voltage_cfg := config.get(CONF_REFERENCE_VOLTAGE): diff --git a/esphome/components/atm90e32/sensor.py b/esphome/components/atm90e32/sensor.py index dc46138add..38b24c7cf6 100644 --- a/esphome/components/atm90e32/sensor.py +++ b/esphome/components/atm90e32/sensor.py @@ -41,6 +41,7 @@ from esphome.const import ( UNIT_WATT, UNIT_WATT_HOURS, ) +from esphome.types import ConfigType from . import atm90e32_ns @@ -191,7 +192,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_instance_id(str(config[CONF_ID]))) await cg.register_component(var, config) diff --git a/esphome/components/atm90e32/text_sensor/__init__.py b/esphome/components/atm90e32/text_sensor/__init__.py index ab96f6c207..30585cb873 100644 --- a/esphome/components/atm90e32/text_sensor/__init__.py +++ b/esphome/components/atm90e32/text_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C +from esphome.types import ConfigType from ..sensor import ATM90E32Component @@ -34,7 +35,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_ID]) if phase_cfg := config.get(CONF_PHASE_STATUS): diff --git a/esphome/components/bl0940/button/__init__.py b/esphome/components/bl0940/button/__init__.py index 04d11e6e30..e87a647392 100644 --- a/esphome/components/bl0940/button/__init__.py +++ b/esphome/components/bl0940/button/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import button import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_CONFIG, ICON_RESTART +from esphome.types import ConfigType from .. import CONF_BL0940_ID, bl0940_ns from ..sensor import BL0940 @@ -21,7 +22,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await button.new_button(config) await cg.register_component(var, config) await cg.register_parented(var, config[CONF_BL0940_ID]) diff --git a/esphome/components/bl0940/number/__init__.py b/esphome/components/bl0940/number/__init__.py index 92ab2837b3..b5a66e682a 100644 --- a/esphome/components/bl0940/number/__init__.py +++ b/esphome/components/bl0940/number/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( ENTITY_CATEGORY_CONFIG, UNIT_PERCENT, ) +from esphome.types import ConfigType from .. import CONF_BL0940_ID, bl0940_ns from ..sensor import BL0940 @@ -27,7 +28,7 @@ CalibrationNumber = bl0940_ns.class_( ) -def validate_min_max(config): +def validate_min_max(config: ConfigType) -> ConfigType: if config[CONF_MAX_VALUE] <= config[CONF_MIN_VALUE]: raise cv.Invalid("max_value must be greater than min_value") return config @@ -69,7 +70,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: # Get the BL0940 component instance bl0940 = await cg.get_variable(config[CONF_BL0940_ID]) diff --git a/esphome/components/bl0940/sensor.py b/esphome/components/bl0940/sensor.py index 96445d5c38..7e6403c3bc 100644 --- a/esphome/components/bl0940/sensor.py +++ b/esphome/components/bl0940/sensor.py @@ -23,6 +23,7 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.types import ConfigType from . import bl0940_ns @@ -69,27 +70,29 @@ DEFAULT_BL0940_LEGACY_EREF = 3.6e6 / 297 # methods to calculate voltage and current reference values -def calculate_voltage_reference(vref, r_one, r_two): +def calculate_voltage_reference(vref: float, r_one: float, r_two: float) -> float: # formula: 79931 / Vref * (R1 * 1000) / (R1 + R2) return 79931 / vref * (r_one * 1000) / (r_one + r_two) -def calculate_current_reference(vref, r_shunt): +def calculate_current_reference(vref: float, r_shunt: float) -> float: # formula: 324004 * RL / Vref return 324004 * r_shunt / vref -def calculate_power_reference(voltage_reference, current_reference): +def calculate_power_reference( + voltage_reference: float, current_reference: float +) -> float: # calculate power reference based on voltage and current reference return voltage_reference * current_reference * 4046 / 324004 / 79931 -def calculate_energy_reference(power_reference): +def calculate_energy_reference(power_reference: float) -> float: # formula: power_reference * 3600000 / (1638.4 * 256) return power_reference * 3600000 / (1638.4 * 256) -def validate_legacy_mode(config): +def validate_legacy_mode(config: ConfigType) -> ConfigType: # Only allow schematic calibration options if legacy_mode is False if config.get(CONF_LEGACY_MODE, True): forbidden = [ @@ -106,7 +109,7 @@ def validate_legacy_mode(config): return config -def set_command_defaults(config): +def set_command_defaults(config: ConfigType) -> ConfigType: # Set defaults for read_command and write_command based on legacy_mode legacy = config.get(CONF_LEGACY_MODE, True) if legacy: @@ -118,7 +121,7 @@ def set_command_defaults(config): return config -def set_reference_values(config): +def set_reference_values(config: ConfigType) -> ConfigType: # Set default reference values based on legacy_mode if config.get(CONF_LEGACY_MODE, True): config.setdefault(CONF_VOLTAGE_REFERENCE, DEFAULT_BL0940_LEGACY_UREF) @@ -223,7 +226,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/bm8563/time.py b/esphome/components/bm8563/time.py index ba264f00bf..5ef162bb7c 100644 --- a/esphome/components/bm8563/time.py +++ b/esphome/components/bm8563/time.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv from esphome.const import CONF_DURATION, CONF_ID +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -35,7 +38,12 @@ CONFIG_SCHEMA = ( ), synchronous=True, ) -async def bm8563_write_time_to_code(config, action_id, template_arg, args): +async def bm8563_write_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -52,7 +60,12 @@ async def bm8563_write_time_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def bm8563_start_timer_to_code(config, action_id, template_arg, args): +async def bm8563_start_timer_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_DURATION], args, cg.uint32) @@ -70,13 +83,18 @@ async def bm8563_start_timer_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def bm8563_read_time_to_code(config, action_id, template_arg, args): +async def bm8563_read_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index e205e4b910..881107b713 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -16,14 +16,15 @@ from esphome.const import ( DEVICE_CLASS_EMPTY, DEVICE_CLASS_MOTION, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +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, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@nohat"] IS_PLATFORM_COMPONENT = True @@ -93,7 +94,9 @@ _CALLBACK_AUTOMATIONS = ( @setup_entity("event") -async def setup_event_core_(var, config, *, event_types: list[str]): +async def setup_event_core_( + var: MockObj, config: ConfigType, *, event_types: list[str] +) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) cg.add(var.set_event_types(event_types)) @@ -108,7 +111,9 @@ async def setup_event_core_(var, config, *, event_types: list[str]): await web_server.add_entity_config(var, web_server_config) -async def register_event(var, config, *, event_types: list[str]): +async def register_event( + var: MockObj, config: ConfigType, *, event_types: list[str] +) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) queue_entity_register("event", config) @@ -116,7 +121,7 @@ async def register_event(var, config, *, event_types: list[str]): await setup_event_core_(var, config, event_types=event_types) -async def new_event(config, *, event_types: list[str]): +async def new_event(config: ConfigType, *, event_types: list[str]) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await register_event(var, config, event_types=event_types) return var @@ -133,7 +138,12 @@ TRIGGER_EVENT_SCHEMA = cv.Schema( @automation.register_action( "event.trigger", TriggerEventAction, TRIGGER_EVENT_SCHEMA, synchronous=True ) -async def event_fire_to_code(config, action_id, template_arg, args): +async def event_fire_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) templ = await cg.templatable(config[CONF_EVENT_TYPE], args, cg.std_string) @@ -142,5 +152,5 @@ async def event_fire_to_code(config, action_id, template_arg, args): @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(event_ns.using) diff --git a/esphome/components/ld2410/__init__.py b/esphome/components/ld2410/__init__.py index 360e56330a..19786f38d3 100644 --- a/esphome/components/ld2410/__init__.py +++ b/esphome/components/ld2410/__init__.py @@ -4,6 +4,9 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PASSWORD, CONF_THROTTLE, CONF_TIMEOUT +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] DEPENDENCIES = ["uart"] @@ -69,7 +72,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) @@ -102,7 +105,12 @@ BLUETOOTH_PASSWORD_SET_SCHEMA = cv.Schema( BLUETOOTH_PASSWORD_SET_SCHEMA, synchronous=True, ) -async def bluetooth_password_set_to_code(config, action_id, template_arg, args): +async def bluetooth_password_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_PASSWORD], args, cg.std_string) diff --git a/esphome/components/ld2410/binary_sensor.py b/esphome/components/ld2410/binary_sensor.py index fb5b5cabff..2b68733532 100644 --- a/esphome/components/ld2410/binary_sensor.py +++ b/esphome/components/ld2410/binary_sensor.py @@ -13,6 +13,7 @@ from esphome.const import ( ICON_ACCOUNT, ICON_MOTION_SENSOR, ) +from esphome.types import ConfigType from . import CONF_LD2410_ID, LD2410Component @@ -46,7 +47,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) if has_target_config := config.get(CONF_HAS_TARGET): sens = await binary_sensor.new_binary_sensor(has_target_config) diff --git a/esphome/components/ld2410/button/__init__.py b/esphome/components/ld2410/button/__init__.py index fa6f31ee25..59a9558331 100644 --- a/esphome/components/ld2410/button/__init__.py +++ b/esphome/components/ld2410/button/__init__.py @@ -12,6 +12,7 @@ from esphome.const import ( ICON_RESTART, ICON_RESTART_ALERT, ) +from esphome.types import ConfigType from .. import CONF_LD2410_ID, LD2410Component, ld2410_ns @@ -44,7 +45,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) if factory_reset_config := config.get(CONF_FACTORY_RESET): b = await button.new_button(factory_reset_config) diff --git a/esphome/components/ld2410/number/__init__.py b/esphome/components/ld2410/number/__init__.py index 01dbcc785d..3500d704a1 100644 --- a/esphome/components/ld2410/number/__init__.py +++ b/esphome/components/ld2410/number/__init__.py @@ -16,6 +16,7 @@ from esphome.const import ( UNIT_PERCENT, UNIT_SECOND, ) +from esphome.types import ConfigType from .. import CONF_LD2410_ID, LD2410Component, ld2410_ns @@ -85,7 +86,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) if timeout_config := config.get(CONF_TIMEOUT): n = await number.new_number( diff --git a/esphome/components/ld2410/select/__init__.py b/esphome/components/ld2410/select/__init__.py index 9c4f654aa1..e89e3d5997 100644 --- a/esphome/components/ld2410/select/__init__.py +++ b/esphome/components/ld2410/select/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( ICON_SCALE, ICON_THERMOMETER, ) +from esphome.types import ConfigType from .. import CONF_LD2410_ID, LD2410Component, ld2410_ns @@ -48,7 +49,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) if distance_resolution_config := config.get(CONF_DISTANCE_RESOLUTION): s = await select.new_select( diff --git a/esphome/components/ld2410/sensor.py b/esphome/components/ld2410/sensor.py index 459018e263..ca42b3a1d3 100644 --- a/esphome/components/ld2410/sensor.py +++ b/esphome/components/ld2410/sensor.py @@ -15,6 +15,7 @@ from esphome.const import ( UNIT_CENTIMETER, UNIT_PERCENT, ) +from esphome.types import ConfigType from . import CONF_LD2410_ID, LD2410Component @@ -155,7 +156,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) if moving_distance_config := config.get(CONF_MOVING_DISTANCE): sens = await sensor.new_sensor(moving_distance_config) diff --git a/esphome/components/ld2410/switch/__init__.py b/esphome/components/ld2410/switch/__init__.py index 4276b28a71..6d8053ddd6 100644 --- a/esphome/components/ld2410/switch/__init__.py +++ b/esphome/components/ld2410/switch/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( ICON_BLUETOOTH, ICON_PULSE, ) +from esphome.types import ConfigType from .. import CONF_LD2410_ID, LD2410Component, ld2410_ns @@ -35,7 +36,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) if engineering_mode_config := config.get(CONF_ENGINEERING_MODE): s = await switch.new_switch(engineering_mode_config) diff --git a/esphome/components/ld2410/text_sensor.py b/esphome/components/ld2410/text_sensor.py index a34c8ec0d2..25c61a4825 100644 --- a/esphome/components/ld2410/text_sensor.py +++ b/esphome/components/ld2410/text_sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( ICON_BLUETOOTH, ICON_CHIP, ) +from esphome.types import ConfigType from . import CONF_LD2410_ID, LD2410Component @@ -26,7 +27,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) if version_config := config.get(CONF_VERSION): sens = await text_sensor.new_text_sensor(version_config) diff --git a/esphome/components/ld2412/__init__.py b/esphome/components/ld2412/__init__.py index e701d0bda9..82db319861 100644 --- a/esphome/components/ld2412/__init__.py +++ b/esphome/components/ld2412/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_THROTTLE +from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] CODEOWNERS = ["@Rihan9"] @@ -40,7 +41,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/ld2412/binary_sensor.py b/esphome/components/ld2412/binary_sensor.py index 98fa5965cd..80cff014c0 100644 --- a/esphome/components/ld2412/binary_sensor.py +++ b/esphome/components/ld2412/binary_sensor.py @@ -13,6 +13,7 @@ from esphome.const import ( ICON_ACCOUNT, ICON_MOTION_SENSOR, ) +from esphome.types import ConfigType from . import CONF_LD2412_ID, LD2412Component @@ -48,7 +49,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) if dynamic_background_correction_status_config := config.get( CONF_DYNAMIC_BACKGROUND_CORRECTION_STATUS diff --git a/esphome/components/ld2412/button/__init__.py b/esphome/components/ld2412/button/__init__.py index e0ca285265..5a1ea2e6a5 100644 --- a/esphome/components/ld2412/button/__init__.py +++ b/esphome/components/ld2412/button/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( ICON_RESTART, ICON_RESTART_ALERT, ) +from esphome.types import ConfigType from .. import CONF_LD2412_ID, LD2412_ns, LD2412Component @@ -54,7 +55,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) if factory_reset_config := config.get(CONF_FACTORY_RESET): b = await button.new_button(factory_reset_config) diff --git a/esphome/components/ld2412/number/__init__.py b/esphome/components/ld2412/number/__init__.py index b6e1c8d039..1a81c330ad 100644 --- a/esphome/components/ld2412/number/__init__.py +++ b/esphome/components/ld2412/number/__init__.py @@ -16,6 +16,7 @@ from esphome.const import ( UNIT_PERCENT, UNIT_SECOND, ) +from esphome.types import ConfigType from .. import CONF_LD2412_ID, LD2412_ns, LD2412Component @@ -85,7 +86,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) if light_threshold_config := config.get(CONF_LIGHT_THRESHOLD): n = await number.new_number( diff --git a/esphome/components/ld2412/select/__init__.py b/esphome/components/ld2412/select/__init__.py index a54cd700ed..02ecf2c30f 100644 --- a/esphome/components/ld2412/select/__init__.py +++ b/esphome/components/ld2412/select/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( ICON_SCALE, ICON_THERMOMETER, ) +from esphome.types import ConfigType from .. import CONF_LD2412_ID, LD2412_ns, LD2412Component @@ -48,7 +49,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) if baud_rate_config := config.get(CONF_BAUD_RATE): s = await select.new_select( diff --git a/esphome/components/ld2412/sensor.py b/esphome/components/ld2412/sensor.py index f562afe0ee..0b6e676931 100644 --- a/esphome/components/ld2412/sensor.py +++ b/esphome/components/ld2412/sensor.py @@ -16,6 +16,7 @@ from esphome.const import ( UNIT_EMPTY, UNIT_PERCENT, ) +from esphome.types import ConfigType from . import CONF_LD2412_ID, LD2412Component @@ -156,7 +157,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) if detection_distance_config := config.get(CONF_DETECTION_DISTANCE): sens = await sensor.new_sensor(detection_distance_config) diff --git a/esphome/components/ld2412/switch/__init__.py b/esphome/components/ld2412/switch/__init__.py index 7a87e9e483..e7f71222fd 100644 --- a/esphome/components/ld2412/switch/__init__.py +++ b/esphome/components/ld2412/switch/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( ICON_BLUETOOTH, ICON_PULSE, ) +from esphome.types import ConfigType from .. import CONF_LD2412_ID, LD2412_ns, LD2412Component @@ -35,7 +36,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) if bluetooth_config := config.get(CONF_BLUETOOTH): s = await switch.new_switch(bluetooth_config) diff --git a/esphome/components/ld2412/text_sensor.py b/esphome/components/ld2412/text_sensor.py index 22fba5193e..c8e9f42ef3 100644 --- a/esphome/components/ld2412/text_sensor.py +++ b/esphome/components/ld2412/text_sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( ICON_BLUETOOTH, ICON_CHIP, ) +from esphome.types import ConfigType from . import CONF_LD2412_ID, LD2412Component @@ -26,7 +27,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) if version_config := config.get(CONF_VERSION): sens = await text_sensor.new_text_sensor(version_config) diff --git a/esphome/components/ld2420/__init__.py b/esphome/components/ld2420/__init__.py index 71a5fa13e4..5a5aabeba0 100644 --- a/esphome/components/ld2420/__init__.py +++ b/esphome/components/ld2420/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@descipher"] @@ -33,7 +34,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/ld2420/binary_sensor/__init__.py b/esphome/components/ld2420/binary_sensor/__init__.py index 5ebc4a9f63..76b42c0362 100644 --- a/esphome/components/ld2420/binary_sensor/__init__.py +++ b/esphome/components/ld2420/binary_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_HAS_TARGET, CONF_ID, DEVICE_CLASS_OCCUPANCY +from esphome.types import ConfigType from .. import CONF_LD2420_ID, LD2420Component, ld2420_ns @@ -23,7 +24,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) if CONF_HAS_TARGET in config: diff --git a/esphome/components/ld2420/button/__init__.py b/esphome/components/ld2420/button/__init__.py index dfeb121c91..cfcffd0922 100644 --- a/esphome/components/ld2420/button/__init__.py +++ b/esphome/components/ld2420/button/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( ICON_RESTART, ICON_RESTART_ALERT, ) +from esphome.types import ConfigType from .. import CONF_LD2420_ID, LD2420Component, ld2420_ns @@ -50,7 +51,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2420_component = await cg.get_variable(config[CONF_LD2420_ID]) if apply_config := config.get(CONF_APPLY_CONFIG): b = await button.new_button(apply_config) diff --git a/esphome/components/ld2420/number/__init__.py b/esphome/components/ld2420/number/__init__.py index a2637b7b06..448639c911 100644 --- a/esphome/components/ld2420/number/__init__.py +++ b/esphome/components/ld2420/number/__init__.py @@ -12,6 +12,7 @@ from esphome.const import ( ICON_TIMELAPSE, UNIT_SECOND, ) +from esphome.types import ConfigType from .. import CONF_LD2420_ID, LD2420Component, ld2420_ns @@ -113,7 +114,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2420_component = await cg.get_variable(config[CONF_LD2420_ID]) if gate_timeout_config := config.get(CONF_PRESENCE_TIMEOUT): n = await number.new_number( diff --git a/esphome/components/ld2420/select/__init__.py b/esphome/components/ld2420/select/__init__.py index b9059c120f..cd66064e47 100644 --- a/esphome/components/ld2420/select/__init__.py +++ b/esphome/components/ld2420/select/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import select import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType from .. import CONF_LD2420_ID, LD2420Component, ld2420_ns @@ -23,7 +24,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2420_component = await cg.get_variable(config[CONF_LD2420_ID]) if operating_mode_config := config.get(CONF_OPERATING_MODE): sel = await select.new_select( diff --git a/esphome/components/ld2420/sensor/__init__.py b/esphome/components/ld2420/sensor/__init__.py index 97acdabd7b..f98d63585b 100644 --- a/esphome/components/ld2420/sensor/__init__.py +++ b/esphome/components/ld2420/sensor/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_CENTIMETER, ) +from esphome.types import ConfigType from .. import CONF_LD2420_ID, LD2420Component, ld2420_ns @@ -30,7 +31,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) if CONF_MOVING_DISTANCE in config: diff --git a/esphome/components/ld2420/text_sensor/__init__.py b/esphome/components/ld2420/text_sensor/__init__.py index 14d982e5fb..cee8f25c1f 100644 --- a/esphome/components/ld2420/text_sensor/__init__.py +++ b/esphome/components/ld2420/text_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_ID, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP +from esphome.types import ConfigType from .. import CONF_LD2420_ID, LD2420Component, ld2420_ns @@ -24,7 +25,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) if CONF_FW_VERSION in config: diff --git a/esphome/components/ld2450/__init__.py b/esphome/components/ld2450/__init__.py index 585c9f7bf5..4c37f4fcd1 100644 --- a/esphome/components/ld2450/__init__.py +++ b/esphome/components/ld2450/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_ON_DATA, CONF_THROTTLE +from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] DEPENDENCIES = ["uart"] @@ -49,7 +50,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/ld2450/binary_sensor.py b/esphome/components/ld2450/binary_sensor.py index 89e629253a..779d151fd9 100644 --- a/esphome/components/ld2450/binary_sensor.py +++ b/esphome/components/ld2450/binary_sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( DEVICE_CLASS_MOTION, DEVICE_CLASS_OCCUPANCY, ) +from esphome.types import ConfigType from . import CONF_LD2450_ID, LD2450Component @@ -39,7 +40,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) if has_target_config := config.get(CONF_HAS_TARGET): sens = await binary_sensor.new_binary_sensor(has_target_config) diff --git a/esphome/components/ld2450/button/__init__.py b/esphome/components/ld2450/button/__init__.py index 682487d750..42cadd2052 100644 --- a/esphome/components/ld2450/button/__init__.py +++ b/esphome/components/ld2450/button/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( ICON_RESTART, ICON_RESTART_ALERT, ) +from esphome.types import ConfigType from .. import CONF_LD2450_ID, LD2450Component, ld2450_ns @@ -35,7 +36,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) if factory_reset_config := config.get(CONF_FACTORY_RESET): b = await button.new_button(factory_reset_config) diff --git a/esphome/components/ld2450/number/__init__.py b/esphome/components/ld2450/number/__init__.py index 799c0703f2..4f242076d6 100644 --- a/esphome/components/ld2450/number/__init__.py +++ b/esphome/components/ld2450/number/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( UNIT_MILLIMETER, UNIT_SECOND, ) +from esphome.types import ConfigType from .. import CONF_LD2450_ID, LD2450Component, ld2450_ns @@ -78,7 +79,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) if presence_timeout_config := config.get(CONF_PRESENCE_TIMEOUT): n = await number.new_number( diff --git a/esphome/components/ld2450/select/__init__.py b/esphome/components/ld2450/select/__init__.py index 4f237dc94f..d91b42426a 100644 --- a/esphome/components/ld2450/select/__init__.py +++ b/esphome/components/ld2450/select/__init__.py @@ -7,6 +7,7 @@ from esphome.const import ( ENTITY_CATEGORY_CONFIG, ICON_THERMOMETER, ) +from esphome.types import ConfigType from .. import CONF_LD2450_ID, LD2450Component, ld2450_ns @@ -31,7 +32,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) if baud_rate_config := config.get(CONF_BAUD_RATE): s = await select.new_select( diff --git a/esphome/components/ld2450/sensor.py b/esphome/components/ld2450/sensor.py index ae13900e7a..40462e202d 100644 --- a/esphome/components/ld2450/sensor.py +++ b/esphome/components/ld2450/sensor.py @@ -15,6 +15,7 @@ from esphome.const import ( UNIT_DEGREES, UNIT_MILLIMETER, ) +from esphome.types import ConfigType from . import CONF_LD2450_ID, LD2450Component @@ -226,7 +227,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) if target_count_config := config.get(CONF_TARGET_COUNT): diff --git a/esphome/components/ld2450/switch/__init__.py b/esphome/components/ld2450/switch/__init__.py index 0c0c92377b..084f79ee1b 100644 --- a/esphome/components/ld2450/switch/__init__.py +++ b/esphome/components/ld2450/switch/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( ICON_BLUETOOTH, ICON_PULSE, ) +from esphome.types import ConfigType from .. import CONF_LD2450_ID, LD2450Component, ld2450_ns @@ -35,7 +36,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) if bluetooth_config := config.get(CONF_BLUETOOTH): s = await switch.new_switch(bluetooth_config) diff --git a/esphome/components/ld2450/text_sensor.py b/esphome/components/ld2450/text_sensor.py index 4e5d7d419b..a8b978ef48 100644 --- a/esphome/components/ld2450/text_sensor.py +++ b/esphome/components/ld2450/text_sensor.py @@ -12,6 +12,7 @@ from esphome.const import ( ICON_CHIP, ICON_SIGN_DIRECTION, ) +from esphome.types import ConfigType from . import CONF_LD2450_ID, LD2450Component @@ -49,7 +50,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) if version_config := config.get(CONF_VERSION): sens = await text_sensor.new_text_sensor(version_config) diff --git a/esphome/components/max6956/__init__.py b/esphome/components/max6956/__init__.py index e9fae4cceb..5e45d71899 100644 --- a/esphome/components/max6956/__init__.py +++ b/esphome/components/max6956/__init__.py @@ -11,6 +11,9 @@ from esphome.const import ( CONF_OUTPUT, CONF_PULLUP, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@looping40"] @@ -54,7 +57,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -62,7 +65,7 @@ async def to_code(config): cg.add(var.set_brightness_global(config[CONF_BRIGHTNESS_GLOBAL])) -def validate_mode(value): +def validate_mode(value: ConfigType) -> ConfigType: if not (value[CONF_INPUT] or value[CONF_OUTPUT]): raise cv.Invalid("Mode must be either input or output") if value[CONF_INPUT] and value[CONF_OUTPUT]: @@ -87,7 +90,7 @@ MAX6956_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_MAX6956, MAX6956_PIN_SCHEMA) -async def max6956_pin_to_code(config): +async def max6956_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) parent = await cg.get_variable(config[CONF_MAX6956]) @@ -114,7 +117,12 @@ async def max6956_pin_to_code(config): ), synchronous=True, ) -async def max6956_set_brightness_global_to_code(config, action_id, template_arg, args): +async def max6956_set_brightness_global_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_BRIGHTNESS_GLOBAL], args, cg.uint8) @@ -136,7 +144,12 @@ async def max6956_set_brightness_global_to_code(config, action_id, template_arg, ), synchronous=True, ) -async def max6956_set_brightness_mode_to_code(config, action_id, template_arg, args): +async def max6956_set_brightness_mode_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable( diff --git a/esphome/components/max6956/output/__init__.py b/esphome/components/max6956/output/__init__.py index 352ba04a95..f92bbb762a 100644 --- a/esphome/components/max6956/output/__init__.py +++ b/esphome/components/max6956/output/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PIN +from esphome.types import ConfigType from .. import CONF_MAX6956, MAX6956, max6956_ns @@ -20,7 +21,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_MAX6956]) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/max7219digit/display.py b/esphome/components/max7219digit/display.py index df2423b0d0..54711263dd 100644 --- a/esphome/components/max7219digit/display.py +++ b/esphome/components/max7219digit/display.py @@ -10,6 +10,9 @@ from esphome.const import ( CONF_NUM_CHIPS, CONF_STATE, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@rspaargaren"] DEPENDENCIES = ["spi"] @@ -84,7 +87,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await spi.register_spi_device(var, config, write_only=True) await display.register_display(var, config) @@ -144,7 +147,12 @@ MAX7219_ON_ACTION_SCHEMA = automation.maybe_simple_id( MAX7219_ON_ACTION_SCHEMA, synchronous=True, ) -async def max7219digit_invert_to_code(config, action_id, template_arg, args): +async def max7219digit_invert_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) @@ -164,7 +172,12 @@ async def max7219digit_invert_to_code(config, action_id, template_arg, args): MAX7219_ON_ACTION_SCHEMA, synchronous=True, ) -async def max7219digit_visible_to_code(config, action_id, template_arg, args): +async def max7219digit_visible_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) @@ -184,7 +197,12 @@ async def max7219digit_visible_to_code(config, action_id, template_arg, args): MAX7219_ON_ACTION_SCHEMA, synchronous=True, ) -async def max7219digit_reverse_to_code(config, action_id, template_arg, args): +async def max7219digit_reverse_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) @@ -209,7 +227,12 @@ MAX7219_INTENSITY_SCHEMA = cv.maybe_simple_value( MAX7219_INTENSITY_SCHEMA, synchronous=True, ) -async def max7219digit_intensity_to_code(config, action_id, template_arg, args): +async def max7219digit_intensity_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_INTENSITY], args, cg.uint8) diff --git a/esphome/components/micronova/__init__.py b/esphome/components/micronova/__init__.py index b462352229..ff06d0b913 100644 --- a/esphome/components/micronova/__init__.py +++ b/esphome/components/micronova/__init__.py @@ -7,6 +7,8 @@ import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@jorre05", "@edenhaus"] @@ -63,7 +65,7 @@ def MICRONOVA_ADDRESS_SCHEMA( default_memory_location: int | None = None, default_memory_address: int | None = None, is_polling_component: bool, -): +) -> cv.Schema: location_key = ( cv.Optional(CONF_MEMORY_LOCATION, default=default_memory_location) if default_memory_location is not None @@ -91,7 +93,9 @@ def register_micronova_writer() -> None: _get_data().has_writer = True -async def to_code_micronova_listener(mv, var, config): +async def to_code_micronova_listener( + mv: MockObj, var: MockObj, config: ConfigType +) -> None: _get_data().listener_count += 1 await cg.register_component(var, config) cg.add(var.set_memory_location(config[CONF_MEMORY_LOCATION])) @@ -100,7 +104,7 @@ async def to_code_micronova_listener(mv, var, config): cg.add(mv.register_micronova_listener(var)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: enable_rx_pin = await cg.gpio_pin_expression(config[CONF_ENABLE_RX_PIN]) var = cg.new_Pvariable(config[CONF_ID], enable_rx_pin) await cg.register_component(var, config) diff --git a/esphome/components/micronova/button/__init__.py b/esphome/components/micronova/button/__init__.py index 63b127e63d..68b5b9aca6 100644 --- a/esphome/components/micronova/button/__init__.py +++ b/esphome/components/micronova/button/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import button import esphome.config_validation as cv +from esphome.types import ConfigType from .. import ( CONF_MEMORY_ADDRESS, @@ -33,7 +34,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) if custom_button_config := config.get(CONF_CUSTOM_BUTTON): diff --git a/esphome/components/micronova/number/__init__.py b/esphome/components/micronova/number/__init__.py index bcc972c5a9..d33bb150ce 100644 --- a/esphome/components/micronova/number/__init__.py +++ b/esphome/components/micronova/number/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import number import esphome.config_validation as cv from esphome.const import CONF_STEP, DEVICE_CLASS_TEMPERATURE, UNIT_CELSIUS +from esphome.types import ConfigType from .. import ( CONF_MICRONOVA_ID, @@ -56,7 +57,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) if thermostat_temperature_config := config.get(CONF_THERMOSTAT_TEMPERATURE): diff --git a/esphome/components/micronova/sensor/__init__.py b/esphome/components/micronova/sensor/__init__.py index e53c49aca5..6091718d65 100644 --- a/esphome/components/micronova/sensor/__init__.py +++ b/esphome/components/micronova/sensor/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_REVOLUTIONS_PER_MINUTE, ) +from esphome.types import ConfigType from .. import ( CONF_MICRONOVA_ID, @@ -125,7 +126,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) for key, divisor in { diff --git a/esphome/components/micronova/switch/__init__.py b/esphome/components/micronova/switch/__init__.py index e149ee3ce3..1f57497ad7 100644 --- a/esphome/components/micronova/switch/__init__.py +++ b/esphome/components/micronova/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import ICON_POWER +from esphome.types import ConfigType from .. import ( CONF_MICRONOVA_ID, @@ -49,7 +50,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) if stove_config := config.get(CONF_STOVE): diff --git a/esphome/components/micronova/text_sensor/__init__.py b/esphome/components/micronova/text_sensor/__init__.py index 33d0779eae..d6b94c437f 100644 --- a/esphome/components/micronova/text_sensor/__init__.py +++ b/esphome/components/micronova/text_sensor/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv +from esphome.types import ConfigType from .. import ( CONF_MICRONOVA_ID, @@ -33,7 +34,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) if stove_state_config := config.get(CONF_STOVE_STATE): diff --git a/esphome/components/pipsolar/__init__.py b/esphome/components/pipsolar/__init__.py index e3966aa2cc..b404409145 100644 --- a/esphome/components/pipsolar/__init__.py +++ b/esphome/components/pipsolar/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType DEPENDENCIES = ["uart"] CODEOWNERS = ["@andreashergert1984"] @@ -26,7 +27,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/pipsolar/binary_sensor/__init__.py b/esphome/components/pipsolar/binary_sensor/__init__.py index 5bcf1f75ee..62c0ed8538 100644 --- a/esphome/components/pipsolar/binary_sensor/__init__.py +++ b/esphome/components/pipsolar/binary_sensor/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv +from esphome.types import ConfigType from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA @@ -132,7 +133,7 @@ CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_PIPSOLAR_ID]) for type in TYPES: if type in config: diff --git a/esphome/components/pipsolar/output/__init__.py b/esphome/components/pipsolar/output/__init__.py index d1ea981589..62e6d0f113 100644 --- a/esphome/components/pipsolar/output/__init__.py +++ b/esphome/components/pipsolar/output/__init__.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_VALUE +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA, pipsolar_ns @@ -75,7 +78,7 @@ CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_PIPSOLAR_ID]) for type, (_, command) in TYPES.items(): @@ -100,7 +103,12 @@ async def to_code(config): ), synchronous=True, ) -async def output_pipsolar_set_level_to_code(config, action_id, template_arg, args): +async def output_pipsolar_set_level_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: 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.float_) diff --git a/esphome/components/pipsolar/sensor/__init__.py b/esphome/components/pipsolar/sensor/__init__.py index 88c6566d63..5a697157b7 100644 --- a/esphome/components/pipsolar/sensor/__init__.py +++ b/esphome/components/pipsolar/sensor/__init__.py @@ -25,6 +25,7 @@ from esphome.const import ( UNIT_VOLT_AMPS, UNIT_WATT, ) +from esphome.types import ConfigType from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA @@ -325,7 +326,7 @@ CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_PIPSOLAR_ID]) for type in TYPES: diff --git a/esphome/components/pipsolar/switch/__init__.py b/esphome/components/pipsolar/switch/__init__.py index 11dbc91110..2b493eac95 100644 --- a/esphome/components/pipsolar/switch/__init__.py +++ b/esphome/components/pipsolar/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import ICON_POWER +from esphome.types import ConfigType from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA, pipsolar_ns @@ -36,7 +37,7 @@ CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_PIPSOLAR_ID]) for type, (on, off) in TYPES.items(): diff --git a/esphome/components/pipsolar/text_sensor/__init__.py b/esphome/components/pipsolar/text_sensor/__init__.py index 90ce3a7e55..cc7477395b 100644 --- a/esphome/components/pipsolar/text_sensor/__init__.py +++ b/esphome/components/pipsolar/text_sensor/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv +from esphome.types import ConfigType from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA @@ -31,7 +32,7 @@ CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_PIPSOLAR_ID]) for type in TYPES: diff --git a/esphome/components/text/__init__.py b/esphome/components/text/__init__.py index 06b5a10892..e010e2c292 100644 --- a/esphome/components/text/__init__.py +++ b/esphome/components/text/__init__.py @@ -13,13 +13,14 @@ from esphome.const import ( CONF_VALUE, CONF_WEB_SERVER, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@mauritskorse"] IS_PLATFORM_COMPONENT = True @@ -90,13 +91,13 @@ def text_schema( @setup_entity("text") async def setup_text_core_( - var, - config, + var: MockObj, + config: ConfigType, *, min_length: int | None, max_length: int | None, pattern: str | None, -): +) -> None: cg.add(var.traits.set_min_length(min_length)) cg.add(var.traits.set_max_length(max_length)) if pattern is not None: @@ -117,13 +118,13 @@ async def setup_text_core_( async def register_text( - var, - config, + var: MockObj, + config: ConfigType, *, min_length: int | None = 0, max_length: int | None = 255, pattern: str | None = None, -): +) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) queue_entity_register("text", config) @@ -134,12 +135,12 @@ async def register_text( async def new_text( - config, + config: ConfigType, *, min_length: int | None = 0, max_length: int | None = 255, pattern: str | None = None, -): +) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await register_text( var, config, min_length=min_length, max_length=max_length, pattern=pattern @@ -148,7 +149,7 @@ async def new_text( @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(text_ns.using) @@ -169,7 +170,12 @@ OPERATION_BASE_SCHEMA = cv.Schema( ), synchronous=True, ) -async def text_set_to_code(config, action_id, template_arg, args): +async def text_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: 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.std_string) diff --git a/esphome/components/text/text_sensor/__init__.py b/esphome/components/text/text_sensor/__init__.py index 5e45f10193..ab0e9bdcdc 100644 --- a/esphome/components/text/text_sensor/__init__.py +++ b/esphome/components/text/text_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_SOURCE_ID +from esphome.types import ConfigType from .. import Text, text_ns @@ -19,7 +20,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: source = await cg.get_variable(config[CONF_SOURCE_ID]) var = await text_sensor.new_text_sensor(config, source) await cg.register_component(var, config) From 7fe4399b945e242cf07ac8f7aa830976d1c850d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 11:11:53 -0500 Subject: [PATCH 1602/1815] [esp32] Grow the default IDF component exclusion list (#18536) --- esphome/components/ac_dimmer/output.py | 6 ++ esphome/components/esp32/__init__.py | 25 ++++++++- esphome/components/http_request/__init__.py | 5 +- esphome/components/i2c/__init__.py | 5 ++ esphome/components/ledc/output.py | 4 ++ esphome/components/mqtt/__init__.py | 2 + esphome/components/nextion/display.py | 2 + esphome/components/web_server_idf/__init__.py | 8 ++- .../esp32/config/exclusion_reincludes.yaml | 20 +++++++ .../exclusion_reincludes_http_request.yaml | 14 +++++ .../config/exclusion_reincludes_mqtt.yaml | 14 +++++ .../config/exclusion_reincludes_nextion.yaml | 20 +++++++ .../exclusion_reincludes_web_server.yaml | 14 +++++ tests/component_tests/esp32/test_esp32.py | 56 +++++++++++++++++++ 14 files changed, 190 insertions(+), 5 deletions(-) create mode 100644 tests/component_tests/esp32/config/exclusion_reincludes.yaml create mode 100644 tests/component_tests/esp32/config/exclusion_reincludes_http_request.yaml create mode 100644 tests/component_tests/esp32/config/exclusion_reincludes_mqtt.yaml create mode 100644 tests/component_tests/esp32/config/exclusion_reincludes_nextion.yaml create mode 100644 tests/component_tests/esp32/config/exclusion_reincludes_web_server.yaml diff --git a/esphome/components/ac_dimmer/output.py b/esphome/components/ac_dimmer/output.py index 1f35095e0e..48bef2c317 100644 --- a/esphome/components/ac_dimmer/output.py +++ b/esphome/components/ac_dimmer/output.py @@ -49,6 +49,12 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): + if CORE.is_esp32: + from esphome.components.esp32 import include_builtin_idf_component + + # Re-enable the gptimer driver (excluded by default to save compile time) + include_builtin_idf_component("esp_driver_gptimer") + if CORE.is_esp8266: # ac_dimmer uses setTimer1Callback which requires the waveform generator from esphome.components.esp8266.const import require_waveform diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index d6e0890751..f1f039922a 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -204,18 +204,32 @@ COMPILER_OPTIMIZATIONS = { # ESP-IDF components excluded by default to reduce compile time. # Components can be re-enabled by calling include_builtin_idf_component() in to_code(). # -# Cannot be excluded (dependencies of required components): -# - "console": espressif/mdns unconditionally depends on it -# - "sdmmc": driver -> esp_driver_sdmmc -> sdmmc dependency chain +# Note: excluding a component only removes it from the initial build set. +# ESP-IDF's requirement expansion adds an excluded component back when any +# component still in the build REQUIRES it (e.g. espressif/mdns pulls +# "console" back in, esp_http_client pulls "tcp_transport" back in), so +# exclusions here are safe for such components and simply become no-ops in +# builds that need them. DEFAULT_EXCLUDED_IDF_COMPONENTS = ( + "app_trace", # CPU trace/SystemView support - unused by ESPHome "cmock", # Unit testing mock framework - ESPHome doesn't use IDF's testing + "console", # Console REPL - unused by ESPHome; espressif/mdns pulls it back when configured "driver", # Legacy driver shim - only needed by esp32_touch, esp32_can for legacy headers + "esp-tls", # TLS wrapper - re-included by http_request, mqtt, web_server_idf "esp_adc", # ADC driver - only needed by adc component + "esp_driver_cam", # Camera driver - the esp32-camera managed component pulls it back "esp_driver_dac", # DAC driver - only needed by esp32_dac component + "esp_driver_gptimer", # General purpose timer - re-included by ac_dimmer, opentherm, Arduino BLE libs + "esp_driver_i2c", # I2C driver - re-included by i2c; esp32-camera pulls it back itself "esp_driver_i2s", # I2S driver - only needed by i2s_audio component + "esp_driver_ledc", # LEDC PWM driver - re-included by ledc; esp32-camera pulls it back itself "esp_driver_mcpwm", # MCPWM driver - ESPHome doesn't use motor control PWM "esp_driver_pcnt", # PCNT driver - only needed by pulse_counter, hlw8012 components "esp_driver_rmt", # RMT driver - only needed by remote_transmitter/receiver, neopixelbus + "esp_driver_sdio", # SDIO device-mode driver - unused by ESPHome + "esp_driver_sdm", # Sigma-delta modulation driver - unused by ESPHome + "esp_driver_sdmmc", # SD/MMC host driver - unused by ESPHome + "esp_driver_sdspi", # SD-over-SPI driver - unused by ESPHome "esp_driver_touch_sens", # Touch sensor driver - only needed by esp32_touch "esp_driver_twai", # TWAI/CAN driver - only needed by esp32_can component "esp_eth", # Ethernet driver - only needed by ethernet component @@ -227,11 +241,16 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = ( "esp_local_ctrl", # Local control over HTTPS/BLE - ESPHome has native API "espcoredump", # Core dump support - ESPHome has its own debug component "fatfs", # FAT filesystem - ESPHome doesn't use filesystem storage + "json", # cJSON library - ESPHome uses ArduinoJson instead "mqtt", # ESP-IDF MQTT library - ESPHome has its own MQTT implementation "openthread", # Thread protocol - only needed by openthread component "perfmon", # Xtensa performance monitor - ESPHome has its own debug component + "protobuf-c", # Protobuf runtime - only used by provisioning components (also excluded) "protocomm", # Protocol communication for provisioning - unused by ESPHome + "rt", # POSIX realtime extensions - unused by ESPHome + "sdmmc", # SD/MMC protocol layer - only used by SD drivers and fatfs (also excluded) "spiffs", # SPIFFS filesystem - ESPHome doesn't use filesystem storage (IDF only) + "tcp_transport", # Transport layer - esp_http_client/mqtt pull it back when re-included "ulp", # ULP coprocessor - not currently used by any ESPHome component "unity", # Unit testing framework - ESPHome doesn't use IDF's testing "wear_levelling", # Flash wear levelling for fatfs - unused since fatfs unused diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index afc39e06a8..8a5aae022a 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -170,8 +170,11 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_watchdog_timeout(timeout_ms)) if CORE.is_esp32: - # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time) + # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time). + # esp-tls is re-enabled too because http_request includes + # directly and esp_http_client only pulls it in as a private dependency. esp32.include_builtin_idf_component("esp_http_client") + esp32.include_builtin_idf_component("esp-tls") cg.add(var.set_buffer_size_rx(config[CONF_BUFFER_SIZE_RX])) cg.add(var.set_buffer_size_tx(config[CONF_BUFFER_SIZE_TX])) diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 7b163d065e..94aad4d019 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -284,6 +284,11 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config): cg.add_global(i2c_ns.using) cg.add_define("USE_I2C") + if CORE.is_esp32: + from esphome.components.esp32 import include_builtin_idf_component + + # Re-enable the I2C driver (excluded by default to save compile time) + include_builtin_idf_component("esp_driver_i2c") if CORE.is_host: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/ledc/output.py b/esphome/components/ledc/output.py index 637e607b6d..e5e7c3dcbe 100644 --- a/esphome/components/ledc/output.py +++ b/esphome/components/ledc/output.py @@ -3,6 +3,7 @@ from typing import Any from esphome import automation, pins import esphome.codegen as cg from esphome.components import output +from esphome.components.esp32 import include_builtin_idf_component import esphome.config_validation as cv from esphome.const import ( CONF_CHANNEL, @@ -62,6 +63,9 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( async def to_code(config: ConfigType) -> None: + # Re-enable the LEDC driver (excluded by default to save compile time) + include_builtin_idf_component("esp_driver_ledc") + gpio = await cg.gpio_pin_expression(config[CONF_PIN]) var = cg.new_Pvariable(config[CONF_ID], gpio) await cg.register_component(var, config) diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 98ca23b60b..9178bc79e5 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -361,6 +361,8 @@ async def to_code(config): add_idf_component(name="espressif/mqtt", ref="1.0.0") else: include_builtin_idf_component("mqtt") + # mqtt_client.h drags in esp_tls types; esp-tls is excluded by default + include_builtin_idf_component("esp-tls") cg.add_define("USE_MQTT") cg.add_global(mqtt_ns.using) diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 4ab123c354..3f5ba94b40 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -290,7 +290,9 @@ async def to_code(config): if CORE.is_esp32: # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time) + # and esp-tls, whose sdkconfig options below need the component present esp32.include_builtin_idf_component("esp_http_client") + esp32.include_builtin_idf_component("esp-tls") esp32.add_idf_sdkconfig_option("CONFIG_ESP_TLS_INSECURE", True) esp32.add_idf_sdkconfig_option( "CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", True diff --git a/esphome/components/web_server_idf/__init__.py b/esphome/components/web_server_idf/__init__.py index 74a9d657a6..adf21ddc49 100644 --- a/esphome/components/web_server_idf/__init__.py +++ b/esphome/components/web_server_idf/__init__.py @@ -1,4 +1,7 @@ -from esphome.components.esp32 import add_idf_sdkconfig_option +from esphome.components.esp32 import ( + add_idf_sdkconfig_option, + include_builtin_idf_component, +) import esphome.config_validation as cv CODEOWNERS = ["@dentra"] @@ -12,3 +15,6 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): # Increase the maximum supported size of headers section in HTTP request packet to be processed by the server add_idf_sdkconfig_option("CONFIG_HTTPD_MAX_REQ_HDR_LEN", 1024) + # Re-enable esp-tls (excluded by default to save compile time); + # web_server_idf.cpp includes for digest auth + include_builtin_idf_component("esp-tls") diff --git a/tests/component_tests/esp32/config/exclusion_reincludes.yaml b/tests/component_tests/esp32/config/exclusion_reincludes.yaml new file mode 100644 index 0000000000..ba5bf17688 --- /dev/null +++ b/tests/component_tests/esp32/config/exclusion_reincludes.yaml @@ -0,0 +1,20 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +i2c: + sda: 21 + scl: 22 + +output: + - platform: ledc + id: ledc_out + pin: 25 + - platform: ac_dimmer + id: dimmer_out + gate_pin: 26 + zero_cross_pin: 27 diff --git a/tests/component_tests/esp32/config/exclusion_reincludes_http_request.yaml b/tests/component_tests/esp32/config/exclusion_reincludes_http_request.yaml new file mode 100644 index 0000000000..5adfd66b00 --- /dev/null +++ b/tests/component_tests/esp32/config/exclusion_reincludes_http_request.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +http_request: + verify_ssl: false diff --git a/tests/component_tests/esp32/config/exclusion_reincludes_mqtt.yaml b/tests/component_tests/esp32/config/exclusion_reincludes_mqtt.yaml new file mode 100644 index 0000000000..c509942635 --- /dev/null +++ b/tests/component_tests/esp32/config/exclusion_reincludes_mqtt.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +mqtt: + broker: "10.0.0.1" diff --git a/tests/component_tests/esp32/config/exclusion_reincludes_nextion.yaml b/tests/component_tests/esp32/config/exclusion_reincludes_nextion.yaml new file mode 100644 index 0000000000..3c6e527b09 --- /dev/null +++ b/tests/component_tests/esp32/config/exclusion_reincludes_nextion.yaml @@ -0,0 +1,20 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +uart: + tx_pin: 17 + rx_pin: 16 + baud_rate: 115200 + +display: + - platform: nextion + tft_url: "http://10.0.0.1/display.tft" diff --git a/tests/component_tests/esp32/config/exclusion_reincludes_web_server.yaml b/tests/component_tests/esp32/config/exclusion_reincludes_web_server.yaml new file mode 100644 index 0000000000..6041bffee6 --- /dev/null +++ b/tests/component_tests/esp32/config/exclusion_reincludes_web_server.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +web_server: + version: 3 diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 1fd835076d..7208318d3a 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -236,6 +236,62 @@ def test_esp32_configuration_errors( FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA(config)) +@pytest.mark.parametrize( + ("config_file", "reincluded"), + [ + pytest.param( + "exclusion_reincludes.yaml", + ("esp_driver_i2c", "esp_driver_ledc", "esp_driver_gptimer"), + id="i2c_ledc_ac_dimmer", + ), + # esp-tls has three owners; a per-owner config makes a dropped + # re-include from any single one fail the test. + pytest.param( + "exclusion_reincludes_http_request.yaml", + ("esp-tls", "esp_http_client"), + id="http_request", + ), + pytest.param( + # "mqtt" itself is deliberately not asserted: on IDF >= 6.0 it + # is a managed component and never leaves the exclusion set. + "exclusion_reincludes_mqtt.yaml", + ("esp-tls",), + id="mqtt", + ), + pytest.param( + "exclusion_reincludes_web_server.yaml", + ("esp-tls",), + id="web_server_idf", + ), + pytest.param( + "exclusion_reincludes_nextion.yaml", + ("esp-tls", "esp_http_client"), + id="nextion", + ), + ], +) +def test_default_exclusions_reincluded_by_owning_components( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + reincluded: tuple[str, ...], +) -> None: + """Components whose IDF driver is excluded by default must re-include it + during codegen; a dropped include_builtin_idf_component() call would only + surface as a missing-header failure in a full compile job.""" + from esphome.components.esp32.const import KEY_EXCLUDE_COMPONENTS + + generate_main(component_config_path(config_file)) + excluded = CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] + + for name in reincluded: + assert name not in excluded, f"{name} should have been re-included" + + # Components no part of this config touches stay excluded. + assert "unity" in excluded + assert "fatfs" in excluded + + def test_execute_from_psram_s3_sdkconfig( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], From a3ea77c2f1206939c0f59aa90870485270998b3e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 11:32:15 -0500 Subject: [PATCH 1603/1815] [core] Keep templated !include filenames as strings so Windows path normalization cannot corrupt them (#18549) --- esphome/components/substitutions/__init__.py | 5 +- esphome/yaml_util.py | 28 ++++++---- tests/unit_tests/test_bundle.py | 56 +++++++++++++++++++- tests/unit_tests/test_substitutions.py | 19 +++++++ tests/unit_tests/test_yaml_util.py | 27 +++++++++- 5 files changed, 120 insertions(+), 15 deletions(-) diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index b4fcf36c9e..5ef7a699eb 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -363,13 +363,12 @@ def resolve_include( an explicit non-goal here. """ original = include.file - original_str = str(original) filename = str( _expand_substitutions( - original_str, path + ["file"], context_vars, strict_undefined, errors + original, path + ["file"], context_vars, strict_undefined, errors ) ) - substituted = filename != original_str + substituted = filename != original if substituted: include = include.with_file(filename) try: diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index d3c6caf60b..c280e550c9 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -231,18 +231,22 @@ class IncludeFile: def __init__( self, parent_file: Path, - file: Path | str, + file: str, vars: dict[str, Any] | None, yaml_loader: Callable[[Path], Any], ) -> None: self.parent_file = parent_file - self.file = Path(file) + # The raw include text may be a substitution/Jinja expression, so it + # must never round-trip through Path(): on Windows, WindowsPath str() + # rewrites "/" to "\", which Jinja then decodes as escapes like + # "\b" -> backspace (issue #18545). + self.file = file self.vars = vars self.yaml_loader = yaml_loader self._content: Any = _UNSET def __repr__(self) -> str: - return f"IncludeFile({self.file.as_posix()})" + return f"IncludeFile({self.file})" def load(self) -> Any: """Load and cache the included file content. @@ -258,15 +262,15 @@ class IncludeFile: raise Invalid( f"Cannot load include with unresolved substitutions: {self.file}" ) - self._content = self.yaml_loader(Path(self.parent_file.parent / self.file)) + self._content = self.yaml_loader(self.parent_file.parent / self.file) self._content = add_context(self._content, self.vars) return self._content def has_unresolved_expressions(self) -> bool: """Check if the filename contains substitution variables or Jinja expressions.""" - return has_substitution_or_expression(str(self.file)) + return has_substitution_or_expression(self.file) - def with_file(self, file: Path | str) -> IncludeFile: + def with_file(self, file: str) -> IncludeFile: """Clone this include with *file* as the filename.""" return IncludeFile(self.parent_file, file, self.vars, self.yaml_loader) @@ -313,7 +317,7 @@ def _candidate_include_paths(include: IncludeFile) -> list[Path]: parent_dir = include.parent_file.parent parent_resolved = include.parent_file.resolve() candidates: list[Path] = [] - for pattern in include_candidate_patterns(str(include.file)): + for pattern in include_candidate_patterns(include.file): if "*" in pattern: matches = sorted(_glob_include_candidates(parent_dir, pattern)) else: @@ -362,7 +366,7 @@ def _load_include_candidates( continue expanded_paths.add(candidate) try: - loaded = include.with_file(candidate).load() + loaded = include.with_file(candidate.as_posix()).load() except (EsphomeError, Invalid) as err: # Unlike an unresolved pattern (expected during the discovery # re-parse), a matched on-disk candidate that fails to load is a @@ -794,6 +798,10 @@ class ESPHomeLoaderMixin: file = fields.get("file") if file is None: raise yaml.MarkedYAMLError("Must include 'file'", node.start_mark) + if not isinstance(file, str): + raise yaml.MarkedYAMLError( + "Include 'file' must be a string", node.start_mark + ) vars = fields.get(CONF_VARS) return file, vars @@ -1333,11 +1341,11 @@ class ESPHomeDumper(yaml.SafeDumper): def represent_include_file(self, value): if value.vars: - mapping = {"file": value.file.as_posix(), "vars": value.vars} + mapping = {"file": value.file, "vars": value.vars} return self.represent_mapping( tag="!include", mapping=mapping, flow_style=False ) - return self.represent_scalar(tag="!include", value=value.file.as_posix()) + return self.represent_scalar(tag="!include", value=value.file) def represent_id(self, value): if is_secret(value.id): diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index 29e917fe44..1abc7a3ab8 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -29,8 +29,9 @@ from esphome.bundle import ( read_bundle_manifest, remap_bundle_path, ) +from esphome.components.substitutions import do_substitution_pass from esphome.core import CORE, EsphomeError -from esphome.yaml_util import force_load_include_files +from esphome.yaml_util import force_load_include_files, load_yaml # --------------------------------------------------------------------------- # Helpers @@ -1277,6 +1278,59 @@ def test_discover_files_bundles_all_include_candidates(tmp_path: Path) -> None: assert "includes/empty.yaml" in paths +@pytest.mark.parametrize("enable_proxy", [True, False]) +def test_bundle_roundtrip_templated_include_with_path_separator( + tmp_path: Path, enable_proxy: bool +) -> None: + r"""The issue-18545 flow: a Jinja !include whose branches contain "/" still + resolves after the bundle is extracted on the build server. + + Windows is the leg that regresses: the raw expression text must survive + verbatim, or its separators get rewritten to "\" and Jinja decodes + sequences like "\b" as string escapes. + """ + config_dir = _setup_config_dir( + tmp_path, + files={ + "includes/boards/board.yaml": ( + "packages:\n" + ' - !include ${ "bluetooth/bluetooth_proxy_single_core.yaml"' + ' if enable_bluetooth_proxy else "../empty.yaml" }\n' + ), + "includes/boards/bluetooth/bluetooth_proxy_single_core.yaml": ( + "bluetooth_proxy:\n active: true\n" + ), + "includes/empty.yaml": "{}\n", + }, + ) + (config_dir / "test.yaml").write_text( + "substitutions:\n" + f" enable_bluetooth_proxy: {str(enable_proxy).lower()}\n" + "esphome:\n name: test\n" + "packages:\n - !include includes/boards/board.yaml\n" + ) + + result = ConfigBundleCreator({}).create_bundle() + bundle_path = tmp_path / "device.esphomebundle.tar.gz" + bundle_path.write_bytes(result.data) + + # Both conditional branches must ship in the bundle. + paths = [f.path for f in result.files] + assert "includes/boards/bluetooth/bluetooth_proxy_single_core.yaml" in paths + assert "includes/empty.yaml" in paths + + # Extract to a fresh directory and resolve the config from there, as a + # remote build server would. + extracted_config = extract_bundle(bundle_path, tmp_path / "remote") + config = do_substitution_pass(load_yaml(extracted_config)) + + board_pkg = config["packages"][0]["packages"][0] + if enable_proxy: + assert board_pkg == {"bluetooth_proxy": {"active": True}} + else: + assert board_pkg == {} + + def test_discover_files_candidate_outside_config_dir_skipped( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index f4063237b1..73c6e496a9 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -744,6 +744,25 @@ def test_include_filename_substitution_undefined_var(tmp_path: Path) -> None: substitutions.do_substitution_pass(config) +def test_include_filename_jinja_expression_with_path_separator( + tmp_path: Path, +) -> None: + """A jinja !include whose string literals contain "/" resolves correctly (issue #18545).""" + main_file = tmp_path / "main.yaml" + main_file.write_text( + "substitutions:\n" + " enable_bluetooth_proxy: true\n" + "result: !include " + '${ "bluetooth/proxy.yaml" if enable_bluetooth_proxy else "../empty.yaml" }\n' + ) + (tmp_path / "bluetooth").mkdir() + (tmp_path / "bluetooth" / "proxy.yaml").write_text("value: 42\n") + + config = yaml_util.load_yaml(main_file) + config = substitutions.do_substitution_pass(config) + assert config["result"] == {"value": 42} + + def test_raise_first_undefined_logs_extras_at_debug( caplog: pytest.LogCaptureFixture, ) -> None: diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index e0a81652e3..3bdbd04396 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -701,6 +701,31 @@ def test_include_file_has_unresolved_expressions( assert include.has_unresolved_expressions() == expected +def test_mapping_include_non_string_file_rejected(tmp_path: Path) -> None: + """The mapping !include form rejects a non-string 'file' with a clear error.""" + entry = tmp_path / "entry.yaml" + entry.write_text("wifi: !include\n file: [not, a, string]\n") + with pytest.raises(EsphomeError, match="Include 'file' must be a string"): + yaml_util.load_yaml(entry) + + +def test_include_file_templated_filename_stays_raw_string(tmp_path: Path) -> None: + """A templated filename keeps its verbatim text (issue #18545).""" + parent = tmp_path / "main.yaml" + expr = '${ "bluetooth/proxy.yaml" if enable_bluetooth_proxy else "../empty.yaml" }' + include = yaml_util.IncludeFile(parent, expr, None, lambda _: {}) + assert include.file == expr + assert include.has_unresolved_expressions() + assert repr(include) == f"IncludeFile({expr})" + + +def test_represent_include_file_templated() -> None: + """Dumping a templated IncludeFile emits the raw expression unchanged.""" + expr = '${ "a/b.yaml" if flag else "../c.yaml" }' + include = yaml_util.IncludeFile(Path("/fake/main.yaml"), expr, None, lambda _: {}) + assert yaml_util.dump({"key": include}) == f"key: !include '{expr}'\n" + + def test_include_in_list_context() -> None: """!include of a file returning a list is handled correctly, including when that list itself contains a nested IncludeFile.""" @@ -1051,7 +1076,7 @@ class _StubInclude: ) -> None: # Default parent lives in a nonexistent directory so unresolved # stubs never glob real files during candidate expansion. - self.file = Path(file) + self.file = file self.parent_file = parent_file or Path("/nonexistent/parent.yaml") self._unresolved = unresolved self._load_result = load_result if load_result is not None else {} From 2ab09e1a77227718e1d9318319e8745bcfab01ac Mon Sep 17 00:00:00 2001 From: David van 't Wout Date: Thu, 20 Aug 2026 19:30:33 +0200 Subject: [PATCH 1604/1815] [core] Add add_cmake_arg (#18498) --- esphome/build_gen/espidf.py | 38 +++++++++------- esphome/build_gen/platformio.py | 11 +++++ esphome/codegen.py | 1 + esphome/components/esp32/__init__.py | 19 ++++---- esphome/core/__init__.py | 35 ++++++++++++++- esphome/cpp_generator.py | 5 +++ tests/unit_tests/build_gen/test_espidf.py | 27 ++++++++++++ tests/unit_tests/build_gen/test_platformio.py | 44 +++++++++++++++++++ tests/unit_tests/test_core.py | 32 ++++++++++++++ 9 files changed, 187 insertions(+), 25 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index b65ce23307..5d4e6b8401 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -72,6 +72,13 @@ def has_discovered_components() -> bool: return get_available_components() is not None +def _cmake_quote(value: str) -> str: + """Quote a cmake arg value for a set() line. add_cmake_arg rejects + whitespace, quotes, and '$', so only backslashes need escaping.""" + escaped = value.replace("\\", "\\\\") + return f'"{escaped}"' + + def get_project_cmakelists(minimal: bool = False) -> str: """Generate the top-level CMakeLists.txt for ESP-IDF project. @@ -114,6 +121,15 @@ def get_project_cmakelists(minimal: bool = False) -> str: else "" ) + # CMake variables registered via cg.add_cmake_arg(). Emitted before + # include(project.cmake) so values like EXCLUDE_COMPONENTS are already + # set when project.cmake seeds the component list, and on minimal + # (discovery) writes too so excluded components never register. + cmake_args = "\n".join( + f"set({name} {_cmake_quote(value)})" + for name, value in sorted(CORE.cmake_args.items()) + ) + # Per-project list exposed as a CMake variable so converted PIO libs # can reference ${ESPHOME_PROJECT_MANAGED_COMPONENTS} without baking # project-specific names into their cached CMakeLists. @@ -129,18 +145,6 @@ def get_project_cmakelists(minimal: bool = False) -> str: for name in get_managed_component_require_names() ) - # Components excluded from the build (DEFAULT_EXCLUDED_IDF_COMPONENTS - # minus per-component re-includes). project.cmake reads the plain - # EXCLUDE_COMPONENTS variable when seeding the component list, so this - # must be set before project(). Emitted on minimal writes too so the - # discovery reconfigure never registers the excluded components. - excluded_components = get_excluded_builtin_components() - exclude_components_var = ( - f'set(EXCLUDE_COMPONENTS "{";".join(excluded_components)}")' - if excluded_components - else "" - ) - # Built-in IDF components exposed via our own property (not IDF's # __COMPONENT_REQUIRES_COMMON, which would append them to every # component's REQUIRES including real IDF components). Referenced by @@ -150,13 +154,17 @@ def get_project_cmakelists(minimal: bool = False) -> str: # project_description.json from a build without exclusions may still # list them, and requiring an excluded component pulls it back into # the build (IDF requirement expansion overrides EXCLUDE_COMPONENTS). + # Derived from the EXCLUDE_COMPONENTS cmake arg emitted above so the + # two can never disagree within one generated file. builtin_components_property = ( "" if minimal else "\n".join( f"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS {name} APPEND)" for name in sorted( - set(get_available_components() or []).difference(excluded_components) + set(get_available_components() or []).difference( + CORE.cmake_args.get("EXCLUDE_COMPONENTS", "").split(";") + ) ) ) ) @@ -184,9 +192,9 @@ set(CMAKE_NINJA_FORCE_RESPONSE_FILE 1) set(IDF_TARGET {idf_target}) set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src) -include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) +{cmake_args} -{exclude_components_var} +include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) {cpp_standard_options} diff --git a/esphome/build_gen/platformio.py b/esphome/build_gen/platformio.py index b63c4b733d..0a12d344a0 100644 --- a/esphome/build_gen/platformio.py +++ b/esphome/build_gen/platformio.py @@ -63,6 +63,17 @@ def get_ini_content(): # Add extra script for C++ flags CORE.add_platformio_option("extra_scripts", [f"pre:{CXX_FLAGS_FILE_NAME}"]) + # Add CMake args. A user-supplied value (str or list) is deliberately + # replaced; this option was always overwritten at FINAL priority. + if CORE.cmake_args: + CORE.add_platformio_option( + "board_build.cmake_extra_args", + " ".join( + f"-D{name}={value}" for name, value in sorted(CORE.cmake_args.items()) + ), + replace=True, + ) + content = "[platformio]\n" content += f"description = ESPHome {__version__}\n" diff --git a/esphome/codegen.py b/esphome/codegen.py index 2430f17f3a..2aa6a70abd 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -25,6 +25,7 @@ from esphome.cpp_generator import ( # noqa: F401 add, add_build_flag, add_build_unflag, + add_cmake_arg, add_cxx_build_flag, add_define, add_global, diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index f1f039922a..d6ed6d9399 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -760,9 +760,10 @@ def include_builtin_idf_component(name: str) -> None: def get_excluded_builtin_components() -> list[str]: """Return the sorted built-in IDF components excluded from the build. - Single accessor for both build writers: the PlatformIO path passes it as - ``-DEXCLUDE_COMPONENTS`` and the native ESP-IDF path emits it into the - generated CMakeLists. + The set reaches both build writers as the ``EXCLUDE_COMPONENTS`` CMake + arg (registered via ``cg.add_cmake_arg`` at FINAL priority); the native + ESP-IDF writer also reads it directly to filter the built-in component + list. """ return sorted(CORE.data.get(KEY_ESP32, {}).get(KEY_EXCLUDE_COMPONENTS, ())) @@ -2148,14 +2149,16 @@ def _configure_lwip_max_sockets(conf: dict) -> None: add_idf_sdkconfig_option("CONFIG_LWIP_MAX_SOCKETS", max_sockets) +def register_exclude_components_cmake_arg() -> None: + """Register the current exclusion set as the EXCLUDE_COMPONENTS cmake arg.""" + if excluded := get_excluded_builtin_components(): + cg.add_cmake_arg("EXCLUDE_COMPONENTS", ";".join(excluded)) + + @coroutine_with_priority(CoroPriority.FINAL) async def _write_exclude_components() -> None: """Write EXCLUDE_COMPONENTS cmake arg after all components have registered exclusions.""" - if excluded := get_excluded_builtin_components(): - cg.add_platformio_option( - "board_build.cmake_extra_args", - f"-DEXCLUDE_COMPONENTS={';'.join(excluded)}", - ) + register_exclude_components_cmake_arg() @coroutine_with_priority(CoroPriority.FINAL) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 534b740a5d..0f1ac9213e 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -641,6 +641,8 @@ class EsphomeCore: self.platformio_libraries: dict[str, Library] = {} # A set of build flags to set in the platformio project self.build_flags: set[str] = set() + # A map of CMake args to apply to build systems that use CMake. + self.cmake_args: dict[str, str] = {} # A set of build flags that apply to C++ compiles only (CXXFLAGS / # CXX_COMPILE_OPTIONS), for flags GCC rejects or warns about on C self.cxx_build_flags: set[str] = set() @@ -704,6 +706,7 @@ class EsphomeCore: self.global_statements = [] self.platformio_libraries = {} self.build_flags = set() + self.cmake_args = {} self.cxx_build_flags = set() self.build_unflags = set() self.cpp_standard = None @@ -1062,6 +1065,30 @@ class EsphomeCore: _LOGGER.debug("Adding build flag: %s", build_flag) return build_flag + def add_cmake_arg(self, name: str, value: str) -> None: + """Register a CMake variable for CMake-based toolchains. + + The value must not contain whitespace or quotes (the PlatformIO + backend passes all args to CMake as a single space-joined string + of ``-DNAME=VALUE`` pairs) or ``$`` (expanded by CMake on the + ESP-IDF path but interpolated differently or passed through by + PlatformIO). + """ + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name): + raise ValueError(f"Invalid CMake arg name: {name!r}") + if re.search(r"[\s\"'$]", value): + raise ValueError( + f"CMake arg {name} value {value!r} must not contain " + "whitespace, quotes, or '$'" + ) + old = self.cmake_args.get(name) + if old is not None and old != value: + _LOGGER.warning( + "CMake arg %s already set to %s; overwriting with %s", name, old, value + ) + self.cmake_args[name] = value + _LOGGER.debug("Adding CMake arg: %s=%s", name, value) + def add_cxx_build_flag(self, build_flag: str) -> str: self.cxx_build_flags.add(build_flag) _LOGGER.debug("Adding C++ build flag: %s", build_flag) @@ -1091,10 +1118,14 @@ class EsphomeCore: _LOGGER.debug("Adding define: %s", define) return define - def add_platformio_option(self, key: str, value: str | list[str]) -> None: + def add_platformio_option( + self, key: str, value: str | list[str], *, replace: bool = False + ) -> None: + """Set a platformio.ini option; list values append to an existing list + unless ``replace`` is True, which overwrites any existing value.""" new_val = value old_val = self.platformio_options.get(key) - if isinstance(old_val, list): + if not replace and isinstance(old_val, list): assert isinstance(value, list) new_val = old_val + value self.platformio_options[key] = new_val diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 6bcf4eed77..e6b8c0de42 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -699,6 +699,11 @@ def add_build_flag(build_flag: str): CORE.add_build_flag(build_flag) +def add_cmake_arg(name: str, value: str) -> None: + """Add a CMake arg for CMake-based toolchains; see ``EsphomeCore.add_cmake_arg``.""" + CORE.add_cmake_arg(name, value) + + def add_cxx_build_flag(build_flag: str) -> None: """Add a global build flag that applies to C++ compiles only. diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index ec01000920..29010bcf0e 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -16,6 +16,7 @@ from esphome.components.esp32 import ( KEY_PATH, KEY_REF, KEY_REPO, + register_exclude_components_cmake_arg, ) import esphome.config_validation as cv from esphome.const import KEY_CORE @@ -137,6 +138,27 @@ def test_get_project_cmakelists_full_emits_builtin_components_property( assert "JPEGDEC APPEND" not in content +def test_get_project_cmakelists_emits_cmake_args() -> None: + """Args registered via CORE.add_cmake_arg() are emitted as set() lines, + on minimal writes too.""" + CORE.add_cmake_arg("EXECUTABLE_COMPONENT_NAME", "src") + + content = _render(minimal=True) + + assert 'set(EXECUTABLE_COMPONENT_NAME "src")' in content + + +def test_get_project_cmakelists_escapes_backslashes_in_cmake_args() -> None: + """Backslashes (the only character escaping applies to; the rest are + rejected at registration) are doubled so CMake reads the value back + verbatim.""" + CORE.add_cmake_arg("MY_PATH", r"C:\esp\idf") + + content = _render(minimal=True) + + assert r'set(MY_PATH "C:\\esp\\idf")' in content + + def test_get_project_cmakelists_emits_exclude_components(tmp_path: Path) -> None: """Excluded components are passed to IDF via EXCLUDE_COMPONENTS and are dropped from ESPHOME_PROJECT_BUILTIN_COMPONENTS even when a stale @@ -151,6 +173,7 @@ def test_get_project_cmakelists_emits_exclude_components(tmp_path: Path) -> None }, ) CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity", "esp_lcd"} + register_exclude_components_cmake_arg() content = _render() @@ -169,6 +192,7 @@ def test_get_project_cmakelists_minimal_emits_exclude_components() -> None: """The discovery (minimal) write also excludes components so they never register in project_description.json.""" CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity"} + register_exclude_components_cmake_arg() content = _render(minimal=True) @@ -177,6 +201,8 @@ def test_get_project_cmakelists_minimal_emits_exclude_components() -> None: def test_get_project_cmakelists_no_exclude_components_line_when_empty() -> None: """No EXCLUDE_COMPONENTS line at all when nothing is excluded.""" + register_exclude_components_cmake_arg() + content = _render() assert "EXCLUDE_COMPONENTS" not in content @@ -197,6 +223,7 @@ def test_include_builtin_idf_component_removes_exclusion() -> None: assert get_excluded_builtin_components() == ["unity"] + register_exclude_components_cmake_arg() content = _render() assert 'set(EXCLUDE_COMPONENTS "unity")' in content diff --git a/tests/unit_tests/build_gen/test_platformio.py b/tests/unit_tests/build_gen/test_platformio.py index 3df2fb1036..20acbe302c 100644 --- a/tests/unit_tests/build_gen/test_platformio.py +++ b/tests/unit_tests/build_gen/test_platformio.py @@ -169,6 +169,7 @@ def clean_core(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(CORE, "platformio_libraries", {}) monkeypatch.setattr(CORE, "build_flags", set()) monkeypatch.setattr(CORE, "build_unflags", set()) + monkeypatch.setattr(CORE, "cmake_args", {}) def test_get_ini_content_pins_cpp_standard( @@ -202,6 +203,49 @@ def test_get_ini_content_no_cpp_standard( assert "-std=" not in content +def test_get_ini_content_emits_cmake_args( + clean_core: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """Registered args are space-joined into one option, sorted by name.""" + monkeypatch.setattr( + CORE, + "cmake_args", + {"EXECUTABLE_COMPONENT_NAME": "src", "EXCLUDE_COMPONENTS": "unity"}, + ) + + content = platformio.get_ini_content() + + assert ( + "board_build.cmake_extra_args = " + "-DEXCLUDE_COMPONENTS=unity -DEXECUTABLE_COMPONENT_NAME=src" in content + ) + + +def test_get_ini_content_no_cmake_option_when_no_args(clean_core: None) -> None: + """No board_build.cmake_extra_args line at all when nothing registered + (ESP8266/RP2040/LibreTiny builds must not get a blank option).""" + content = platformio.get_ini_content() + + assert "board_build.cmake_extra_args" not in content + + +def test_get_ini_content_overwrites_list_valued_user_cmake_option( + clean_core: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """A user-supplied board_build.cmake_extra_args may be a list; the + registered args must replace it without tripping add_platformio_option's + list-append assert.""" + monkeypatch.setattr( + CORE, "platformio_options", {"board_build.cmake_extra_args": ["-DFOO=1"]} + ) + monkeypatch.setattr(CORE, "cmake_args", {"EXECUTABLE_COMPONENT_NAME": "src"}) + + content = platformio.get_ini_content() + + assert "board_build.cmake_extra_args = -DEXECUTABLE_COMPONENT_NAME=src" in content + assert "-DFOO=1" not in content + + def test_write_cxx_flags_script_emits_registered_flags( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 7f00d00ef7..c373116106 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -990,3 +990,35 @@ class TestEsphomeCore: ) # The unflag is still recorded either way. assert target.build_unflags == {"-fno-rtti", "-fno-exceptions"} + + def test_add_cmake_arg(self, target) -> None: + target.add_cmake_arg("EXCLUDE_COMPONENTS", "unity;esp_lcd") + assert target.cmake_args == {"EXCLUDE_COMPONENTS": "unity;esp_lcd"} + + @pytest.mark.parametrize("name", ["", "BAD NAME", 'A"B', "A(B)", "1ABC"]) + def test_add_cmake_arg__rejects_invalid_name(self, target, name: str) -> None: + with pytest.raises(ValueError, match="Invalid CMake arg name"): + target.add_cmake_arg(name, "value") + + @pytest.mark.parametrize("value", ["a b", "a\tb", 'a"b', "a'b", "a${FOO}b"]) + def test_add_cmake_arg__rejects_invalid_value(self, target, value: str) -> None: + """Whitespace and quotes are rejected (the PlatformIO backend passes + args as one space-joined string, which would split such a value), and + so is '$' (expanded differently by CMake and PlatformIO).""" + with pytest.raises(ValueError, match="must not contain"): + target.add_cmake_arg("MY_ARG", value) + + def test_add_cmake_arg__warns_on_overwrite( + self, target, caplog: pytest.LogCaptureFixture + ) -> None: + """Re-registering with a different value is last-writer-wins; warn so + the silently dropped value is diagnosable.""" + target.add_cmake_arg("MY_ARG", "one") + target.add_cmake_arg("MY_ARG", "one") + assert "overwriting" not in caplog.text + + target.add_cmake_arg("MY_ARG", "two") + assert ( + "CMake arg MY_ARG already set to one; overwriting with two" in caplog.text + ) + assert target.cmake_args == {"MY_ARG": "two"} From 6343c11873fb62b1ed83b841f4e9c46e5fc73697 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:56:02 +1200 Subject: [PATCH 1605/1815] [core] Add type annotations to component Python (5/11) (#18342) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/bedjet/__init__.py | 6 ++++-- esphome/components/bedjet/climate/__init__.py | 3 ++- esphome/components/bedjet/fan/__init__.py | 3 ++- esphome/components/bedjet/sensor/__init__.py | 3 ++- esphome/components/bme680_bsec/__init__.py | 3 ++- esphome/components/bme680_bsec/sensor.py | 6 ++++-- esphome/components/bme680_bsec/text_sensor.py | 6 ++++-- esphome/components/cs5460a/sensor.py | 14 +++++++++++--- esphome/components/esp32_touch/__init__.py | 13 ++++++++----- .../components/esp32_touch/binary_sensor.py | 3 ++- esphome/components/esp8266_pwm/output.py | 14 +++++++++++--- esphome/components/factory_reset/__init__.py | 7 ++++--- .../factory_reset/button/__init__.py | 3 ++- .../factory_reset/switch/__init__.py | 3 ++- esphome/components/hbridge/fan/__init__.py | 12 ++++++++++-- esphome/components/hbridge/light/__init__.py | 3 ++- esphome/components/hbridge/switch/__init__.py | 3 ++- esphome/components/hmc5883l/sensor.py | 15 +++++++++++---- esphome/components/mhz19/sensor.py | 19 ++++++++++++++++--- esphome/components/mpr121/__init__.py | 14 +++++++++----- .../mpr121/binary_sensor/__init__.py | 3 ++- esphome/components/pcf85063/time.py | 19 ++++++++++++++++--- esphome/components/pcf8563/time.py | 19 ++++++++++++++++--- esphome/components/pcm5122/audio_dac.py | 12 +++++++----- esphome/components/pcm5122/switch/__init__.py | 3 ++- esphome/components/pmwcs3/sensor.py | 19 ++++++++++++++++--- esphome/components/qmc5883l/sensor.py | 13 +++++++++---- .../components/remote_transmitter/__init__.py | 15 +++++++++++---- esphome/components/rotary_encoder/sensor.py | 14 +++++++++++--- .../components/rp2040_pio_led_strip/light.py | 8 ++++---- esphome/components/rx8130/time.py | 19 ++++++++++++++++--- esphome/components/servo/__init__.py | 19 ++++++++++++++++--- esphome/components/sx1509/__init__.py | 10 ++++++---- .../sx1509/binary_sensor/__init__.py | 3 ++- esphome/components/sx1509/output/__init__.py | 3 ++- 35 files changed, 246 insertions(+), 86 deletions(-) diff --git a/esphome/components/bedjet/__init__.py b/esphome/components/bedjet/__init__.py index d4bf813846..1b967e665a 100644 --- a/esphome/components/bedjet/__init__.py +++ b/esphome/components/bedjet/__init__.py @@ -2,6 +2,8 @@ import esphome.codegen as cg from esphome.components import ble_client, time import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_RECEIVE_TIMEOUT, CONF_TIME_ID +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@jhansche"] DEPENDENCIES = ["ble_client"] @@ -32,12 +34,12 @@ BEDJET_CLIENT_SCHEMA = cv.Schema( ) -async def register_bedjet_child(var, config): +async def register_bedjet_child(var: MockObj, config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_BEDJET_ID]) cg.add(parent.register_child(var)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await ble_client.register_ble_node(var, config) diff --git a/esphome/components/bedjet/climate/__init__.py b/esphome/components/bedjet/climate/__init__.py index 4de9dcca0b..36650d643c 100644 --- a/esphome/components/bedjet/climate/__init__.py +++ b/esphome/components/bedjet/climate/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import climate import esphome.config_validation as cv from esphome.const import CONF_HEAT_MODE, CONF_TEMPERATURE_SOURCE +from esphome.types import ConfigType from .. import BEDJET_CLIENT_SCHEMA, bedjet_ns, register_bedjet_child @@ -37,7 +38,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) await cg.register_component(var, config) await register_bedjet_child(var, config) diff --git a/esphome/components/bedjet/fan/__init__.py b/esphome/components/bedjet/fan/__init__.py index a4a611fefc..f5dfe32f4c 100644 --- a/esphome/components/bedjet/fan/__init__.py +++ b/esphome/components/bedjet/fan/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import fan import esphome.config_validation as cv +from esphome.types import ConfigType from .. import BEDJET_CLIENT_SCHEMA, bedjet_ns, register_bedjet_child @@ -16,7 +17,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await fan.new_fan(config) await cg.register_component(var, config) await register_bedjet_child(var, config) diff --git a/esphome/components/bedjet/sensor/__init__.py b/esphome/components/bedjet/sensor/__init__.py index fa9ca7953e..595e798e49 100644 --- a/esphome/components/bedjet/sensor/__init__.py +++ b/esphome/components/bedjet/sensor/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, ) +from esphome.types import ConfigType from .. import BEDJET_CLIENT_SCHEMA, bedjet_ns, register_bedjet_child @@ -38,7 +39,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(BEDJET_CLIENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await register_bedjet_child(var, config) diff --git a/esphome/components/bme680_bsec/__init__.py b/esphome/components/bme680_bsec/__init__.py index e1e01facd0..35df2a7ea3 100644 --- a/esphome/components/bme680_bsec/__init__.py +++ b/esphome/components/bme680_bsec/__init__.py @@ -3,6 +3,7 @@ from esphome.components import esp32, i2c from esphome.components.const import CONF_STATE_SAVE_INTERVAL import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_SAMPLE_RATE, CONF_TEMPERATURE_OFFSET, Framework +from esphome.types import ConfigType CODEOWNERS = ["@trvrnrth"] DEPENDENCIES = ["i2c"] @@ -76,7 +77,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/bme680_bsec/sensor.py b/esphome/components/bme680_bsec/sensor.py index bdc8d8f2d3..153890b57f 100644 --- a/esphome/components/bme680_bsec/sensor.py +++ b/esphome/components/bme680_bsec/sensor.py @@ -29,6 +29,8 @@ from esphome.const import ( UNIT_PARTS_PER_MILLION, UNIT_PERCENT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_BME680_BSEC_ID, SAMPLE_RATE_OPTIONS, BME680BSECComponent @@ -110,7 +112,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if sensor_config := config.get(key): sens = await sensor.new_sensor(sensor_config) cg.add(getattr(hub, f"set_{key}_sensor")(sens)) @@ -120,7 +122,7 @@ async def setup_conf(config, key, hub): ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BME680_BSEC_ID]) for key in TYPES: await setup_conf(config, key, hub) diff --git a/esphome/components/bme680_bsec/text_sensor.py b/esphome/components/bme680_bsec/text_sensor.py index 1fbb9e2aeb..6da1c9d287 100644 --- a/esphome/components/bme680_bsec/text_sensor.py +++ b/esphome/components/bme680_bsec/text_sensor.py @@ -2,6 +2,8 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_IAQ_ACCURACY +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_BME680_BSEC_ID, BME680BSECComponent @@ -21,13 +23,13 @@ CONFIG_SCHEMA = cv.Schema( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if sensor_config := config.get(key): sens = await text_sensor.new_text_sensor(sensor_config) cg.add(getattr(hub, f"set_{key}_text_sensor")(sens)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BME680_BSEC_ID]) for key in TYPES: await setup_conf(config, key, hub) diff --git a/esphome/components/cs5460a/sensor.py b/esphome/components/cs5460a/sensor.py index 0c6ae0d821..5f14457101 100644 --- a/esphome/components/cs5460a/sensor.py +++ b/esphome/components/cs5460a/sensor.py @@ -17,6 +17,9 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@balrog-kun"] DEPENDENCIES = ["spi"] @@ -40,7 +43,7 @@ CONF_VOLTAGE_HPF = "voltage_hpf" CONF_PULSE_ENERGY = "pulse_energy" -def validate_config(config): +def validate_config(config: ConfigType) -> ConfigType: current_gain = abs(config[CONF_CURRENT_GAIN]) * ( 1.0 if config[CONF_PGA_GAIN] == "10X" else 5.0 ) @@ -105,7 +108,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await spi.register_spi_device(var, config) @@ -138,6 +141,11 @@ async def to_code(config): ), synchronous=True, ) -async def restart_action_to_code(config, action_id, template_arg, args): +async def restart_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/esp32_touch/__init__.py b/esphome/components/esp32_touch/__init__.py index 10ad339b12..ede6beb9b6 100644 --- a/esphome/components/esp32_touch/__init__.py +++ b/esphome/components/esp32_touch/__init__.py @@ -1,4 +1,6 @@ +from collections.abc import Callable, Iterable import logging +from typing import Any import esphome.codegen as cg from esphome.components import esp32 @@ -23,6 +25,7 @@ from esphome.const import ( CONF_VOLTAGE_ATTENUATION, ) from esphome.core import TimePeriod +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -181,7 +184,7 @@ EFFECTIVE_HIGH_VOLTAGE = { } -def validate_touch_pad(value): +def validate_touch_pad(value: Any) -> int: value = gpio.gpio_pin_number_validator(value) variant = get_esp32_variant() pads = TOUCH_PADS.get(variant) @@ -192,7 +195,7 @@ def validate_touch_pad(value): return pads[value] # Return integer channel ID -def validate_variant_vars(config): +def validate_variant_vars(config: ConfigType) -> ConfigType: variant = get_esp32_variant() invalid_vars = set() if variant == VARIANT_ESP32: @@ -219,8 +222,8 @@ def validate_variant_vars(config): return config -def validate_voltage(values): - def validator(value): +def validate_voltage(values: Iterable[str]) -> Callable[[Any], str]: + def validator(value: Any) -> str: if isinstance(value, float) and value.is_integer(): value = int(value) value = cv.string(value) @@ -300,7 +303,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: # New unified touch sensor driver include_builtin_idf_component("esp_driver_touch_sens") diff --git a/esphome/components/esp32_touch/binary_sensor.py b/esphome/components/esp32_touch/binary_sensor.py index 75560d71b1..2489c2abc1 100644 --- a/esphome/components/esp32_touch/binary_sensor.py +++ b/esphome/components/esp32_touch/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PIN, CONF_THRESHOLD +from esphome.types import ConfigType from . import ESP32TouchComponent, esp32_touch_ns, validate_touch_pad @@ -24,7 +25,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(ESP32TouchBinarySensor).exten ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_ESP32_TOUCH_ID]) var = cg.new_Pvariable( config[CONF_ID], diff --git a/esphome/components/esp8266_pwm/output.py b/esphome/components/esp8266_pwm/output.py index f119a6ba9f..dd151a3e04 100644 --- a/esphome/components/esp8266_pwm/output.py +++ b/esphome/components/esp8266_pwm/output.py @@ -4,11 +4,14 @@ from esphome.components import output from esphome.components.esp8266.const import require_waveform import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID, CONF_NUMBER, CONF_PIN +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["esp8266"] -def valid_pwm_pin(value): +def valid_pwm_pin(value: ConfigType) -> ConfigType: num = value[CONF_NUMBER] cv.one_of(0, 1, 2, 3, 4, 5, 9, 10, 12, 13, 14, 15, 16)(num) return value @@ -35,7 +38,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config) -> None: +async def to_code(config: ConfigType) -> None: require_waveform() var = cg.new_Pvariable(config[CONF_ID]) @@ -59,7 +62,12 @@ async def to_code(config) -> None: ), synchronous=True, ) -async def esp8266_set_frequency_to_code(config, action_id, template_arg, args): +async def esp8266_set_frequency_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) diff --git a/esphome/components/factory_reset/__init__.py b/esphome/components/factory_reset/__init__.py index d5d5d2ecb5..a9064eb18f 100644 --- a/esphome/components/factory_reset/__init__.py +++ b/esphome/components/factory_reset/__init__.py @@ -12,6 +12,7 @@ from esphome.const import ( ) from esphome.core import CORE from esphome.final_validate import full_config +from esphome.types import ConfigType CODEOWNERS = ["@anatoly-savchenkov"] @@ -23,7 +24,7 @@ CONF_RESETS_REQUIRED = "resets_required" CONF_ON_INCREMENT = "on_increment" -def _validate(config): +def _validate(config: ConfigType) -> ConfigType: if CONF_RESETS_REQUIRED in config: return cv.only_on( [ @@ -60,7 +61,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config) -> None: +def _final_validate(config: ConfigType) -> None: if CORE.is_esp8266 and CONF_RESETS_REQUIRED in config: fconfig = full_config.get() if not fconfig.get_config_for_path([KEY_ESP8266, CONF_RESTORE_FROM_FLASH]): @@ -81,7 +82,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: if reset_count := config.get(CONF_RESETS_REQUIRED): var = cg.new_Pvariable( config[CONF_ID], diff --git a/esphome/components/factory_reset/button/__init__.py b/esphome/components/factory_reset/button/__init__.py index 61df5f297b..040614c151 100644 --- a/esphome/components/factory_reset/button/__init__.py +++ b/esphome/components/factory_reset/button/__init__.py @@ -7,6 +7,7 @@ from esphome.const import ( ENTITY_CATEGORY_CONFIG, ICON_RESTART_ALERT, ) +from esphome.types import ConfigType from .. import factory_reset_ns @@ -22,7 +23,7 @@ CONFIG_SCHEMA = button.button_schema( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await button.register_button(var, config) diff --git a/esphome/components/factory_reset/switch/__init__.py b/esphome/components/factory_reset/switch/__init__.py index a384a57f80..69a635a917 100644 --- a/esphome/components/factory_reset/switch/__init__.py +++ b/esphome/components/factory_reset/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_CONFIG, ICON_RESTART_ALERT +from esphome.types import ConfigType from .. import factory_reset_ns @@ -17,6 +18,6 @@ CONFIG_SCHEMA = switch.switch_schema( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) diff --git a/esphome/components/hbridge/fan/__init__.py b/esphome/components/hbridge/fan/__init__.py index 8ea8677ba2..2cf1693b47 100644 --- a/esphome/components/hbridge/fan/__init__.py +++ b/esphome/components/hbridge/fan/__init__.py @@ -13,6 +13,9 @@ from esphome.const import ( CONF_PRESET_MODES, CONF_SPEED_COUNT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType from .. import hbridge_ns @@ -54,12 +57,17 @@ CONFIG_SCHEMA = ( maybe_simple_id({cv.GenerateID(): cv.use_id(HBridgeFan)}), synchronous=True, ) -async def fan_hbridge_brake_to_code(config, action_id, template_arg, args): +async def fan_hbridge_brake_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await fan.new_fan( config, config[CONF_SPEED_COUNT], diff --git a/esphome/components/hbridge/light/__init__.py b/esphome/components/hbridge/light/__init__.py index f9451e2594..f7866cb990 100644 --- a/esphome/components/hbridge/light/__init__.py +++ b/esphome/components/hbridge/light/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import light, output import esphome.config_validation as cv from esphome.const import CONF_OUTPUT_ID, CONF_PIN_A, CONF_PIN_B, CONF_UPDATE_INTERVAL +from esphome.types import ConfigType from .. import hbridge_ns @@ -21,7 +22,7 @@ CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) cg.add(var.set_update_interval(config.pop(CONF_UPDATE_INTERVAL))) await cg.register_component(var, config) diff --git a/esphome/components/hbridge/switch/__init__.py b/esphome/components/hbridge/switch/__init__.py index e26bd6b1d8..294be6ed5f 100644 --- a/esphome/components/hbridge/switch/__init__.py +++ b/esphome/components/hbridge/switch/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import CONF_OPTIMISTIC, CONF_PULSE_LENGTH, CONF_WAIT_TIME +from esphome.types import ConfigType from .. import hbridge_ns @@ -30,7 +31,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) diff --git a/esphome/components/hmc5883l/sensor.py b/esphome/components/hmc5883l/sensor.py index cf3c594f36..a2e1f8054a 100644 --- a/esphome/components/hmc5883l/sensor.py +++ b/esphome/components/hmc5883l/sensor.py @@ -1,3 +1,6 @@ +from collections.abc import Callable +from typing import Any + import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv @@ -17,6 +20,8 @@ from esphome.const import ( UNIT_DEGREES, UNIT_MICROTESLA, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -59,14 +64,16 @@ HMC5883L_RANGES = { } -def validate_enum(enum_values, units=None, int=True): +def validate_enum( + enum_values: dict[Any, Any], units: str | list[str] | None = None, int: bool = True +) -> Callable[[Any], Any]: _units = [] if units is not None: _units = units if isinstance(units, list) else [units] _units = [str(x) for x in _units] enum_bound = cv.enum(enum_values, int=int) - def validate_enum_bound(value): + def validate_enum_bound(value: Any) -> Any: value = cv.string(value) for unit in _units: if value.endswith(unit): @@ -112,7 +119,7 @@ CONFIG_SCHEMA = ( ) -def auto_data_rate(config): +def auto_data_rate(config: ConfigType) -> MockObj: interval_msec = config[CONF_UPDATE_INTERVAL].total_milliseconds interval_hz = 1000.0 / interval_msec for datarate in sorted(HMC5883LDatarates.keys()): @@ -121,7 +128,7 @@ def auto_data_rate(config): return HMC5883LDatarates[75] -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/mhz19/sensor.py b/esphome/components/mhz19/sensor.py index b7d0ad1998..33cb27080c 100644 --- a/esphome/components/mhz19/sensor.py +++ b/esphome/components/mhz19/sensor.py @@ -15,6 +15,9 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PARTS_PER_MILLION, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["uart"] @@ -78,7 +81,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) @@ -129,7 +132,12 @@ NO_ARGS_ACTION_SCHEMA = maybe_simple_id( NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -async def mhz19_no_args_action_to_code(config, action_id, template_arg, args): +async def mhz19_no_args_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -151,7 +159,12 @@ RANGE_ACTION_SCHEMA = maybe_simple_id( RANGE_ACTION_SCHEMA, synchronous=True, ) -async def mhz19_detection_range_set_to_code(config, action_id, template_arg, args): +async def mhz19_detection_range_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) detection_range = config.get(CONF_DETECTION_RANGE) diff --git a/esphome/components/mpr121/__init__.py b/esphome/components/mpr121/__init__.py index 0bf9377275..da56b4ff4b 100644 --- a/esphome/components/mpr121/__init__.py +++ b/esphome/components/mpr121/__init__.py @@ -12,7 +12,9 @@ from esphome.const import ( CONF_NUMBER, CONF_OUTPUT, ) +from esphome.cpp_generator import MockObj import esphome.final_validate as fv +from esphome.types import ConfigType CONF_TOUCH_THRESHOLD = "touch_threshold" CONF_RELEASE_THRESHOLD = "release_threshold" @@ -49,7 +51,7 @@ CONFIG_SCHEMA = ( ) -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: fconf = fv.full_config.get() max_touch_channel = 3 if (binary_sensors := fconf.get(CONF_BINARY_SENSOR)) is not None: @@ -71,7 +73,7 @@ def _final_validate(config): FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_touch_debounce(config[CONF_TOUCH_DEBOUNCE])) cg.add(var.set_release_debounce(config[CONF_RELEASE_DEBOUNCE])) @@ -82,7 +84,7 @@ async def to_code(config): await i2c.register_i2c_device(var, config) -def validate_mode(value): +def validate_mode(value: ConfigType) -> ConfigType: if bool(value[CONF_INPUT]) == bool(value[CONF_OUTPUT]): raise cv.Invalid("Mode must be either input or output") return value @@ -105,7 +107,9 @@ MPR121_GPIO_PIN_SCHEMA = pins.gpio_base_schema( ) -def mpr121_pin_final_validate(pin_config, parent_config): +def mpr121_pin_final_validate( + pin_config: ConfigType, parent_config: ConfigType +) -> None: if pin_config[CONF_NUMBER] <= parent_config[CONF_MAX_TOUCH_CHANNEL]: raise cv.Invalid( "Pin number must be higher than the max touch channel of the MPR121 component", @@ -115,7 +119,7 @@ def mpr121_pin_final_validate(pin_config, parent_config): @pins.PIN_SCHEMA_REGISTRY.register( CONF_MPR121, MPR121_GPIO_PIN_SCHEMA, mpr121_pin_final_validate ) -async def mpr121_gpio_pin_to_code(config): +async def mpr121_gpio_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) parent = await cg.get_variable(config[CONF_MPR121]) diff --git a/esphome/components/mpr121/binary_sensor/__init__.py b/esphome/components/mpr121/binary_sensor/__init__.py index 1252a65a84..565789cdc3 100644 --- a/esphome/components/mpr121/binary_sensor/__init__.py +++ b/esphome/components/mpr121/binary_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_CHANNEL +from esphome.types import ConfigType from .. import ( CONF_MPR121_ID, @@ -24,7 +25,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(MPR121BinarySensor).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) hub = await cg.get_variable(config[CONF_MPR121_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) diff --git a/esphome/components/pcf85063/time.py b/esphome/components/pcf85063/time.py index 8e19178cc9..771461905e 100644 --- a/esphome/components/pcf85063/time.py +++ b/esphome/components/pcf85063/time.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@brogon"] DEPENDENCIES = ["i2c"] @@ -31,7 +34,12 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ), synchronous=True, ) -async def pcf85063_write_time_to_code(config, action_id, template_arg, args): +async def pcf85063_write_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -47,13 +55,18 @@ async def pcf85063_write_time_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def pcf85063_read_time_to_code(config, action_id, template_arg, args): +async def pcf85063_read_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/pcf8563/time.py b/esphome/components/pcf8563/time.py index 1502158c29..8a0b871be9 100644 --- a/esphome/components/pcf8563/time.py +++ b/esphome/components/pcf8563/time.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@KoenBreeman"] @@ -34,7 +37,12 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ), synchronous=True, ) -async def pcf8563_write_time_to_code(config, action_id, template_arg, args): +async def pcf8563_write_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -50,13 +58,18 @@ async def pcf8563_write_time_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def pcf8563_read_time_to_code(config, action_id, template_arg, args): +async def pcf8563_read_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/pcm5122/audio_dac.py b/esphome/components/pcm5122/audio_dac.py index c18fb3993e..5091efabea 100644 --- a/esphome/components/pcm5122/audio_dac.py +++ b/esphome/components/pcm5122/audio_dac.py @@ -13,6 +13,8 @@ from esphome.const import ( CONF_NUMBER, CONF_OUTPUT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@remcom"] DEPENDENCIES = ["i2c"] @@ -50,7 +52,7 @@ PCM5122_CHANNEL_MIX_ENUM = { _validate_bits = cv.float_with_unit("bits", "bit") -def _validate_volume_range(config): +def _validate_volume_range(config: ConfigType) -> ConfigType: if config[CONF_VOLUME_MIN_DB] >= config[CONF_VOLUME_MAX_DB]: raise cv.Invalid(f"{CONF_VOLUME_MIN_DB} must be less than {CONF_VOLUME_MAX_DB}") return config @@ -90,7 +92,7 @@ CONFIG_SCHEMA = cv.All( ) -def _validate_pin_mode(value): +def _validate_pin_mode(value: ConfigType) -> ConfigType: if not (value[CONF_INPUT] or value[CONF_OUTPUT]): raise cv.Invalid("Mode must be either input or output") if value[CONF_INPUT] and value[CONF_OUTPUT]: @@ -98,7 +100,7 @@ def _validate_pin_mode(value): return value -def _validate_pin(value): +def _validate_pin(value: ConfigType) -> ConfigType: if value[CONF_MODE][CONF_INPUT] and value[CONF_NUMBER] == 6: raise cv.Invalid("GPIO6 cannot be used as input on the PCM5122") return value @@ -120,7 +122,7 @@ PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(CONF_PCM5122, PIN_SCHEMA) -async def pcm5122_pin_to_code(config): +async def pcm5122_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_parented(var, config[CONF_PCM5122]) @@ -130,7 +132,7 @@ async def pcm5122_pin_to_code(config): return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/pcm5122/switch/__init__.py b/esphome/components/pcm5122/switch/__init__.py index 10519da895..829adeccb7 100644 --- a/esphome/components/pcm5122/switch/__init__.py +++ b/esphome/components/pcm5122/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import CONF_POWER_MODE, ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType from ..audio_dac import CONF_PCM5122, PCM5122, pcm5122_ns @@ -26,7 +27,7 @@ CONFIG_SCHEMA = switch.switch_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_parented(var, config[CONF_PCM5122]) cg.add(var.set_power_mode(config[CONF_POWER_MODE])) diff --git a/esphome/components/pmwcs3/sensor.py b/esphome/components/pmwcs3/sensor.py index c0bc54c5ba..ae22b3e0d6 100644 --- a/esphome/components/pmwcs3/sensor.py +++ b/esphome/components/pmwcs3/sensor.py @@ -10,6 +10,9 @@ from esphome.const import ( ICON_THERMOMETER, STATE_CLASS_MEASUREMENT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@SeByDocKy"] DEPENDENCIES = ["i2c"] @@ -72,7 +75,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -114,7 +117,12 @@ PMWCS3_CALIBRATION_SCHEMA = cv.Schema( PMWCS3_CALIBRATION_SCHEMA, synchronous=True, ) -async def pmwcs3_calibration_to_code(config, action_id, template_arg, args): +async def pmwcs3_calibration_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: parent = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, parent) @@ -134,7 +142,12 @@ PMWCS3_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( PMWCS3_NEW_I2C_ADDRESS_SCHEMA, synchronous=True, ) -async def pmwcs3newi2caddress_to_code(config, action_id, template_arg, args): +async def pmwcs3newi2caddress_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: parent = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, parent) address = await cg.templatable(config[CONF_ADDRESS], args, cg.int_) diff --git a/esphome/components/qmc5883l/sensor.py b/esphome/components/qmc5883l/sensor.py index fe34381ad8..e0186be163 100644 --- a/esphome/components/qmc5883l/sensor.py +++ b/esphome/components/qmc5883l/sensor.py @@ -1,4 +1,6 @@ +from collections.abc import Callable import logging +from typing import Any from esphome import pins import esphome.codegen as cg @@ -24,6 +26,7 @@ from esphome.const import ( UNIT_DEGREES, UNIT_MICROTESLA, ) +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -60,7 +63,7 @@ QMC5883LOversamplings = { } -def validate_config(config): +def validate_config(config: ConfigType) -> ConfigType: if ( config[CONF_UPDATE_INTERVAL].total_milliseconds < 15 and CONF_DRDY_PIN not in config @@ -72,14 +75,16 @@ def validate_config(config): return config -def validate_enum(enum_values, units=None, int=True): +def validate_enum( + enum_values: dict[Any, Any], units: str | list[str] | None = None, int: bool = True +) -> Callable[[Any], Any]: _units = [] if units is not None: _units = units if isinstance(units, list) else [units] _units = [str(x) for x in _units] enum_bound = cv.enum(enum_values, int=int) - def validate_enum_bound(value): + def validate_enum_bound(value: Any) -> Any: value = cv.string(value) for unit in _units: if value.endswith(unit): @@ -137,7 +142,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index 521c3daf87..a97b925e06 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -18,7 +18,9 @@ from esphome.const import ( CONF_VALUE, PlatformFramework, ) -from esphome.core import CORE +from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -94,7 +96,7 @@ CONFIG_SCHEMA = ( ) -def _validate_non_blocking(config): +def _validate_non_blocking(config: ConfigType) -> None: if ( CORE.is_esp32 and esp32.get_esp32_variant() not in esp32_rmt.VARIANTS_NO_RMT @@ -125,7 +127,12 @@ DIGITAL_WRITE_ACTION_SCHEMA = cv.maybe_simple_value( DIGITAL_WRITE_ACTION_SCHEMA, synchronous=True, ) -async def digital_write_action_to_code(config, action_id, template_arg, args): +async def digital_write_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_TRANSMITTER_ID]) template_ = await cg.templatable(config[CONF_VALUE], args, cg.bool_) @@ -133,7 +140,7 @@ async def digital_write_action_to_code(config, action_id, template_arg, args): return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: pin = await cg.gpio_pin_expression(config[CONF_PIN]) if CORE.is_esp32 and esp32.get_esp32_variant() not in esp32_rmt.VARIANTS_NO_RMT: # Re-enable ESP-IDF's RMT driver (excluded by default to save compile time) diff --git a/esphome/components/rotary_encoder/sensor.py b/esphome/components/rotary_encoder/sensor.py index 0e5a03523d..72722ec4b1 100644 --- a/esphome/components/rotary_encoder/sensor.py +++ b/esphome/components/rotary_encoder/sensor.py @@ -15,6 +15,9 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_STEPS, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType rotary_encoder_ns = cg.esphome_ns.namespace("rotary_encoder") @@ -44,7 +47,7 @@ RotaryEncoderSetValueAction = rotary_encoder_ns.class_( ) -def validate_min_max_value(config): +def validate_min_max_value(config: ConfigType) -> ConfigType: if CONF_MIN_VALUE in config and CONF_MAX_VALUE in config: min_val = config[CONF_MIN_VALUE] max_val = config[CONF_MAX_VALUE] @@ -92,7 +95,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) @@ -126,7 +129,12 @@ async def to_code(config): ), synchronous=True, ) -async def sensor_template_publish_to_code(config, action_id, template_arg, args): +async def sensor_template_publish_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: 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.int_) diff --git a/esphome/components/rp2040_pio_led_strip/light.py b/esphome/components/rp2040_pio_led_strip/light.py index 9f7479edd0..5b7259f9e5 100644 --- a/esphome/components/rp2040_pio_led_strip/light.py +++ b/esphome/components/rp2040_pio_led_strip/light.py @@ -18,7 +18,7 @@ from esphome.types import ConfigType from esphome.util import _LOGGER -def get_nops(timing): +def get_nops(timing: float) -> list[float | str]: """ Calculate the number of NOP instructions required to wait for a given amount of time. """ @@ -39,7 +39,7 @@ def get_nops(timing): return nops -def generate_assembly_code(id, t0h, t0l, t1h, t1l): +def generate_assembly_code(id: str, t0h: int, t0l: int, t1h: int, t1l: int) -> str: """ Generate assembly code with the given timing values. """ @@ -125,7 +125,7 @@ writezero: return assembly_template + const_csdk_code -def time_to_cycles(time_us): +def time_to_cycles(time_us: float) -> int: cycles_per_us = 57.5 return round(float(time_us) * cycles_per_us) @@ -172,7 +172,7 @@ CONF_BIT1_HIGH = "bit1_high" CONF_BIT1_LOW = "bit1_low" -def _validate_timing(value): +def _validate_timing(value: str) -> float: # if doesn't end with us, raise error if not value.endswith("us"): raise cv.Invalid("Timing must be in microseconds (us)") diff --git a/esphome/components/rx8130/time.py b/esphome/components/rx8130/time.py index 4f6310358c..40d10e9f6b 100644 --- a/esphome/components/rx8130/time.py +++ b/esphome/components/rx8130/time.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@beormund"] DEPENDENCIES = ["i2c"] @@ -29,7 +32,12 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ), synchronous=True, ) -async def rx8130_write_time_to_code(config, action_id, template_arg, args): +async def rx8130_write_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -45,13 +53,18 @@ async def rx8130_write_time_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def rx8130_read_time_to_code(config, action_id, template_arg, args): +async def rx8130_read_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/servo/__init__.py b/esphome/components/servo/__init__.py index c2eaefe455..666c7dbcdd 100644 --- a/esphome/components/servo/__init__.py +++ b/esphome/components/servo/__init__.py @@ -13,6 +13,9 @@ from esphome.const import ( CONF_RESTORE, CONF_TRANSITION_LENGTH, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType servo_ns = cg.esphome_ns.namespace("servo") Servo = servo_ns.class_("Servo", cg.Component) @@ -39,7 +42,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -64,7 +67,12 @@ async def to_code(config): ), synchronous=True, ) -async def servo_write_to_code(config, action_id, template_arg, args): +async def servo_write_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_LEVEL], args, cg.float_) @@ -82,6 +90,11 @@ async def servo_write_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def servo_detach_to_code(config, action_id, template_arg, args): +async def servo_detach_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/sx1509/__init__.py b/esphome/components/sx1509/__init__.py index b61b92fd1e..c1e4e11d54 100644 --- a/esphome/components/sx1509/__init__.py +++ b/esphome/components/sx1509/__init__.py @@ -15,6 +15,8 @@ from esphome.const import ( CONF_PULLUP, CONF_TRIGGER_ID, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CONF_KEYPAD = "keypad" CONF_KEYS = "keys" @@ -40,7 +42,7 @@ SX1509KeyTrigger = sx1509_ns.class_( ) -def check_keys(config): +def check_keys(config: ConfigType) -> ConfigType: if ( CONF_KEYS in config and len(config[CONF_KEYS]) != config[CONF_KEY_ROWS] * config[CONF_KEY_COLUMNS] @@ -82,7 +84,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -104,7 +106,7 @@ async def to_code(config): await automation.build_automation(trigger, [(cg.uint8, "x")], tconf) -def validate_mode(value): +def validate_mode(value: ConfigType) -> ConfigType: if not (value[CONF_INPUT] or value[CONF_OUTPUT]): raise cv.Invalid("Mode must be either input or output") if value[CONF_INPUT] and value[CONF_OUTPUT]: @@ -142,7 +144,7 @@ SX1509_PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(CONF_SX1509, SX1509_PIN_SCHEMA) -async def sx1509_pin_to_code(config): +async def sx1509_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) parent = await cg.get_variable(config[CONF_SX1509]) cg.add(var.set_parent(parent)) diff --git a/esphome/components/sx1509/binary_sensor/__init__.py b/esphome/components/sx1509/binary_sensor/__init__.py index 0ceca77a5d..154a841348 100644 --- a/esphome/components/sx1509/binary_sensor/__init__.py +++ b/esphome/components/sx1509/binary_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_COL, CONF_ROW +from esphome.types import ConfigType from .. import CONF_SX1509_ID, SX1509Component, sx1509_ns @@ -18,7 +19,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(SX1509BinarySensor).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) hub = await cg.get_variable(config[CONF_SX1509_ID]) cg.add(var.set_row_col(config[CONF_ROW], config[CONF_COL])) diff --git a/esphome/components/sx1509/output/__init__.py b/esphome/components/sx1509/output/__init__.py index 9e2db7bb10..aed5ab7dd4 100644 --- a/esphome/components/sx1509/output/__init__.py +++ b/esphome/components/sx1509/output/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PIN +from esphome.types import ConfigType from .. import CONF_SX1509_ID, SX1509Component, sx1509_ns @@ -20,7 +21,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_SX1509_ID]) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) From c006e9804a2e88d5852ca6761800c01c0cde6fa7 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:56:48 +1200 Subject: [PATCH 1606/1815] [core] Add type annotations to component Python (9/11) (#18346) --- esphome/components/as3935/__init__.py | 4 +++- esphome/components/as3935/binary_sensor.py | 3 ++- esphome/components/as3935/sensor.py | 3 ++- esphome/components/bthome_mithermometer/__init__.py | 8 ++++++-- esphome/components/bthome_mithermometer/sensor.py | 3 ++- esphome/components/color/__init__.py | 11 +++++++---- esphome/components/ds248x/__init__.py | 7 ++++--- esphome/components/ds248x/one_wire.py | 5 +++-- esphome/components/emontx/__init__.py | 10 +++++++--- esphome/components/gdk101/__init__.py | 3 ++- esphome/components/gdk101/binary_sensor.py | 3 ++- esphome/components/gdk101/sensor.py | 3 ++- esphome/components/gdk101/text_sensor.py | 3 ++- esphome/components/hc8/sensor.py | 12 ++++++++++-- esphome/components/lcd_base/__init__.py | 10 +++++++--- esphome/components/libretiny_pwm/output.py | 12 ++++++++++-- esphome/components/lightwaverf/__init__.py | 12 ++++++++++-- esphome/components/max17043/sensor.py | 12 ++++++++++-- esphome/components/nau7802/sensor.py | 12 ++++++++++-- esphome/components/ntc/sensor.py | 12 +++++++----- esphome/components/openthread_info/sensor.py | 5 +++-- esphome/components/openthread_info/text_sensor.py | 5 +++-- esphome/components/pmsx003/sensor.py | 12 ++++++++---- esphome/components/pzemac/sensor.py | 11 +++++++++-- esphome/components/pzemdc/sensor.py | 11 +++++++++-- esphome/components/remote_receiver/__init__.py | 9 ++++++--- esphome/components/remote_receiver/binary_sensor.py | 3 ++- esphome/components/scd30/sensor.py | 12 +++++++++--- esphome/components/senseair/sensor.py | 12 ++++++++++-- esphome/components/sml/__init__.py | 6 ++++-- esphome/components/sml/sensor/__init__.py | 3 ++- esphome/components/sml/text_sensor/__init__.py | 3 ++- esphome/components/sn74hc595/__init__.py | 12 ++++++++---- esphome/components/spa06_base/__init__.py | 12 +++++++----- esphome/components/sy6970/__init__.py | 3 ++- esphome/components/sy6970/binary_sensor/__init__.py | 3 ++- esphome/components/sy6970/sensor/__init__.py | 3 ++- esphome/components/sy6970/text_sensor/__init__.py | 3 ++- esphome/components/tm1638/binary_sensor/__init__.py | 3 ++- esphome/components/tm1638/display.py | 3 ++- esphome/components/tm1638/output/__init__.py | 3 ++- esphome/components/tm1638/switch/__init__.py | 3 ++- esphome/components/uponor_smatrix/__init__.py | 6 ++++-- .../components/uponor_smatrix/climate/__init__.py | 3 ++- .../components/uponor_smatrix/sensor/__init__.py | 3 ++- esphome/components/vl53l0x/sensor.py | 10 +++++++--- esphome/components/weikai/__init__.py | 12 +++++++----- esphome/components/zephyr_ble_server/__init__.py | 13 ++++++++++--- 48 files changed, 238 insertions(+), 97 deletions(-) diff --git a/esphome/components/as3935/__init__.py b/esphome/components/as3935/__init__.py index 70015c53b9..bd02d22d1b 100644 --- a/esphome/components/as3935/__init__.py +++ b/esphome/components/as3935/__init__.py @@ -14,6 +14,8 @@ from esphome.const import ( CONF_TUNE_ANTENNA, CONF_WATCHDOG_THRESHOLD, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType MULTI_CONF = True @@ -42,7 +44,7 @@ AS3935_SCHEMA = cv.Schema( ) -async def setup_as3935(var, config): +async def setup_as3935(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) irq_pin = await cg.gpio_pin_expression(config[CONF_IRQ_PIN]) diff --git a/esphome/components/as3935/binary_sensor.py b/esphome/components/as3935/binary_sensor.py index 10004e69dc..929b653294 100644 --- a/esphome/components/as3935/binary_sensor.py +++ b/esphome/components/as3935/binary_sensor.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv +from esphome.types import ConfigType from . import AS3935, CONF_AS3935_ID @@ -13,7 +14,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_AS3935_ID]) var = await binary_sensor.new_binary_sensor(config) cg.add(hub.set_thunder_alert_binary_sensor(var)) diff --git a/esphome/components/as3935/sensor.py b/esphome/components/as3935/sensor.py index 9b43155563..b727b8fdb9 100644 --- a/esphome/components/as3935/sensor.py +++ b/esphome/components/as3935/sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_KILOMETER, ) +from esphome.types import ConfigType from . import AS3935, CONF_AS3935_ID @@ -31,7 +32,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_AS3935_ID]) if distance_config := config.get(CONF_DISTANCE): diff --git a/esphome/components/bthome_mithermometer/__init__.py b/esphome/components/bthome_mithermometer/__init__.py index 4be7ca8268..ed0cbaa9e1 100644 --- a/esphome/components/bthome_mithermometer/__init__.py +++ b/esphome/components/bthome_mithermometer/__init__.py @@ -3,6 +3,8 @@ from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_BINDKEY, CONF_ID, CONF_MAC_ADDRESS from esphome.core import HexInt +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@nagyrobi"] AUTO_LOAD = ["ble_device_base"] @@ -14,7 +16,9 @@ BTHomeMiThermometer = bthome_mithermometer_ns.class_( ) -def bthome_mithermometer_base_schema(extra_schema=None): +def bthome_mithermometer_base_schema( + extra_schema: cv.Schema | dict | None = None, +) -> cv.All: if extra_schema is None: extra_schema = {} return cv.All( @@ -32,7 +36,7 @@ def bthome_mithermometer_base_schema(extra_schema=None): ) -async def setup_bthome_mithermometer(var, config): +async def setup_bthome_mithermometer(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/bthome_mithermometer/sensor.py b/esphome/components/bthome_mithermometer/sensor.py index 02551391ad..f559d0aa9b 100644 --- a/esphome/components/bthome_mithermometer/sensor.py +++ b/esphome/components/bthome_mithermometer/sensor.py @@ -20,6 +20,7 @@ from esphome.const import ( UNIT_PERCENT, UNIT_VOLT, ) +from esphome.types import ConfigType from . import bthome_mithermometer_base_schema, setup_bthome_mithermometer @@ -67,7 +68,7 @@ CONFIG_SCHEMA = bthome_mithermometer_base_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await setup_bthome_mithermometer(var, config) diff --git a/esphome/components/color/__init__.py b/esphome/components/color/__init__.py index c39c5924af..70240eff07 100644 --- a/esphome/components/color/__init__.py +++ b/esphome/components/color/__init__.py @@ -1,5 +1,8 @@ +from typing import Any + from esphome import codegen as cg, config_validation as cv from esphome.const import CONF_BLUE, CONF_GREEN, CONF_ID, CONF_RED, CONF_WHITE +from esphome.types import ConfigType ColorStruct = cg.esphome_ns.struct("Color") @@ -14,7 +17,7 @@ CONF_WHITE_INT = "white_int" CONF_HEX = "hex" -def hex_color(value): +def hex_color(value: Any) -> tuple[int, int, int]: if isinstance(value, int): value = str(value) if not isinstance(value, str): @@ -39,7 +42,7 @@ components = { } -def validate_color(config): +def validate_color(config: ConfigType) -> ConfigType: has_components = set(config) & components has_hex = CONF_HEX in config if has_hex and has_components: @@ -68,7 +71,7 @@ CONFIG_SCHEMA = cv.All( ) -def from_rgbw(config): +def from_rgbw(config: ConfigType) -> tuple[int, int, int, int]: r = 0 if CONF_RED in config: r = int(config[CONF_RED] * 255) @@ -96,7 +99,7 @@ def from_rgbw(config): return (r, g, b, w) -async def to_code(config): +async def to_code(config: ConfigType) -> None: if CONF_HEX in config: r, g, b = config[CONF_HEX] w = 0 diff --git a/esphome/components/ds248x/__init__.py b/esphome/components/ds248x/__init__.py index 5a26ceab50..a2e2a87ed0 100644 --- a/esphome/components/ds248x/__init__.py +++ b/esphome/components/ds248x/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_SLEEP_PIN, CONF_TYPE +from esphome.types import ConfigType CODEOWNERS = ["@tomwellnitz"] MULTI_CONF = True @@ -35,7 +36,7 @@ ds248x_ns = cg.esphome_ns.namespace("ds248x") DS248xComponent = ds248x_ns.class_("DS248xComponent", cg.Component, i2c.I2CDevice) -def _component_schema(*extras): +def _component_schema(*extras: dict) -> cv.Schema: schema = cv.Schema( { cv.GenerateID(): cv.declare_id(DS248xComponent), @@ -79,11 +80,11 @@ CONFIG_SCHEMA = cv.typed_schema( ) -def get_channel_count(config): +def get_channel_count(config: ConfigType) -> int: return CHANNEL_COUNTS[config[CONF_TYPE]] -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/ds248x/one_wire.py b/esphome/components/ds248x/one_wire.py index 19861eae36..b028958132 100644 --- a/esphome/components/ds248x/one_wire.py +++ b/esphome/components/ds248x/one_wire.py @@ -12,6 +12,7 @@ import esphome.codegen as cg from esphome.components.one_wire import OneWireBus import esphome.config_validation as cv from esphome.const import CONF_CHANNEL, CONF_ID +from esphome.types import ConfigType from . import CONF_DS248X_ID, DS248xComponent, ds248x_ns, get_channel_count @@ -29,7 +30,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: """Validate that the channel is within the parent's channel count.""" fconf = fv.full_config.get() path = fconf.get_path_for_id(config[CONF_DS248X_ID])[:-1] @@ -47,7 +48,7 @@ def _final_validate(config): FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index 3f83578926..7dde794f0b 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -11,7 +11,8 @@ from esphome.const import ( CONF_RX_BUFFER_SIZE, CONF_UART_ID, ) -from esphome.core import CORE +from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv from esphome.types import ConfigType @@ -143,8 +144,11 @@ EMONTX_SEND_COMMAND_ACTION_SCHEMA = cv.Schema( synchronous=True, ) async def emontx_send_command_action_to_code( - config: ConfigType, action_id, template_arg, args -) -> None: + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_COMMAND], args, cg.std_string) diff --git a/esphome/components/gdk101/__init__.py b/esphome/components/gdk101/__init__.py index 878f27bc44..f98af3f863 100644 --- a/esphome/components/gdk101/__init__.py +++ b/esphome/components/gdk101/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@Szewcson"] @@ -26,7 +27,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/gdk101/binary_sensor.py b/esphome/components/gdk101/binary_sensor.py index a80487977f..14f5fa0e1c 100644 --- a/esphome/components/gdk101/binary_sensor.py +++ b/esphome/components/gdk101/binary_sensor.py @@ -7,6 +7,7 @@ from esphome.const import ( ENTITY_CATEGORY_DIAGNOSTIC, ICON_VIBRATE, ) +from esphome.types import ConfigType from . import CONF_GDK101_ID, GDK101Component @@ -24,7 +25,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_GDK101_ID]) var = await binary_sensor.new_binary_sensor(config[CONF_VIBRATIONS]) cg.add(hub.set_vibration_binary_sensor(var)) diff --git a/esphome/components/gdk101/sensor.py b/esphome/components/gdk101/sensor.py index 6cf89e0fd4..4ed081a7be 100644 --- a/esphome/components/gdk101/sensor.py +++ b/esphome/components/gdk101/sensor.py @@ -15,6 +15,7 @@ from esphome.const import ( UNIT_MICROSILVERTS_PER_HOUR, UNIT_SECOND, ) +from esphome.types import ConfigType from . import CONF_GDK101_ID, GDK101Component @@ -59,7 +60,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_GDK101_ID]) if radiation_dose_per_1m := config.get(CONF_RADIATION_DOSE_PER_1M): diff --git a/esphome/components/gdk101/text_sensor.py b/esphome/components/gdk101/text_sensor.py index 703e68493a..bdef2466df 100644 --- a/esphome/components/gdk101/text_sensor.py +++ b/esphome/components/gdk101/text_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP +from esphome.types import ConfigType from . import CONF_GDK101_ID, GDK101Component @@ -17,7 +18,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_GDK101_ID]) var = await text_sensor.new_text_sensor(config[CONF_VERSION]) cg.add(hub.set_fw_version_text_sensor(var)) diff --git a/esphome/components/hc8/sensor.py b/esphome/components/hc8/sensor.py index 29b428e310..616162eb40 100644 --- a/esphome/components/hc8/sensor.py +++ b/esphome/components/hc8/sensor.py @@ -12,6 +12,9 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_PARTS_PER_MILLION, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["uart"] @@ -47,7 +50,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) @@ -73,7 +76,12 @@ CALIBRATION_ACTION_SCHEMA = cv.Schema( CALIBRATION_ACTION_SCHEMA, synchronous=True, ) -async def hc8_calibration_to_code(config, action_id, template_arg, args): +async def hc8_calibration_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_BASELINE], args, cg.uint16) diff --git a/esphome/components/lcd_base/__init__.py b/esphome/components/lcd_base/__init__.py index bf1072ce66..08ec395720 100644 --- a/esphome/components/lcd_base/__init__.py +++ b/esphome/components/lcd_base/__init__.py @@ -1,7 +1,11 @@ +from typing import Any + import esphome.codegen as cg from esphome.components import display import esphome.config_validation as cv from esphome.const import CONF_DATA, CONF_DIMENSIONS, CONF_POSITION +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CONF_USER_CHARACTERS = "user_characters" @@ -9,7 +13,7 @@ lcd_base_ns = cg.esphome_ns.namespace("lcd_base") LCDDisplay = lcd_base_ns.class_("LCDDisplay", cg.PollingComponent) -def validate_lcd_dimensions(value): +def validate_lcd_dimensions(value: Any) -> list[int]: value = cv.dimensions(value) if value[0] > 0x40: raise cv.Invalid("LCD displays can't have more than 64 columns") @@ -18,7 +22,7 @@ def validate_lcd_dimensions(value): return value -def validate_user_characters(value): +def validate_user_characters(value: list[ConfigType]) -> list[ConfigType]: positions = set() for conf in value: if conf[CONF_POSITION] in positions: @@ -51,7 +55,7 @@ LCD_SCHEMA = display.BASIC_DISPLAY_SCHEMA.extend( ).extend(cv.polling_component_schema("1s")) -async def setup_lcd_display(var, config): +async def setup_lcd_display(var: MockObj, config: ConfigType) -> None: await display.register_display(var, config) cg.add(var.set_dimensions(config[CONF_DIMENSIONS][0], config[CONF_DIMENSIONS][1])) if CONF_USER_CHARACTERS in config: diff --git a/esphome/components/libretiny_pwm/output.py b/esphome/components/libretiny_pwm/output.py index 6f71530aaf..716ccfad2b 100644 --- a/esphome/components/libretiny_pwm/output.py +++ b/esphome/components/libretiny_pwm/output.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID, CONF_PIN +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["libretiny"] @@ -21,7 +24,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: gpio = await cg.gpio_pin_expression(config[CONF_PIN]) var = cg.new_Pvariable(config[CONF_ID], gpio) await cg.register_component(var, config) @@ -40,7 +43,12 @@ async def to_code(config): ), synchronous=True, ) -async def libretiny_pwm_set_frequency_to_code(config, action_id, template_arg, args): +async def libretiny_pwm_set_frequency_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) diff --git a/esphome/components/lightwaverf/__init__.py b/esphome/components/lightwaverf/__init__.py index 76eabc2b71..0f42083cb5 100644 --- a/esphome/components/lightwaverf/__init__.py +++ b/esphome/components/lightwaverf/__init__.py @@ -11,7 +11,10 @@ from esphome.const import ( CONF_REPEAT, CONF_WRITE_PIN, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.cpp_helpers import gpio_pin_expression +from esphome.types import ConfigType CODEOWNERS = ["@max246"] @@ -57,7 +60,12 @@ LIGHTWAVE_SEND_SCHEMA = cv.Any( LIGHTWAVE_SEND_SCHEMA, synchronous=True, ) -async def send_raw_to_code(config, action_id, template_arg, args): +async def send_raw_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) @@ -71,7 +79,7 @@ async def send_raw_to_code(config, action_id, template_arg, args): return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/max17043/sensor.py b/esphome/components/max17043/sensor.py index ebb045dfce..67fb8aa5b7 100644 --- a/esphome/components/max17043/sensor.py +++ b/esphome/components/max17043/sensor.py @@ -14,6 +14,9 @@ from esphome.const import ( UNIT_PERCENT, UNIT_VOLT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -50,7 +53,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -74,6 +77,11 @@ MAX17043_ACTION_SCHEMA = maybe_simple_id( @automation.register_action( "max17043.sleep_mode", SleepAction, MAX17043_ACTION_SCHEMA, synchronous=True ) -async def max17043_sleep_mode_to_code(config, action_id, template_arg, args): +async def max17043_sleep_mode_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/nau7802/sensor.py b/esphome/components/nau7802/sensor.py index 9798c1c297..415ae09daf 100644 --- a/esphome/components/nau7802/sensor.py +++ b/esphome/components/nau7802/sensor.py @@ -4,6 +4,9 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import CONF_GAIN, CONF_ID, ICON_SCALE, STATE_CLASS_MEASUREMENT +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@cujomalainey"] DEPENDENCIES = ["i2c"] @@ -93,7 +96,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -131,7 +134,12 @@ NAU7802_CALIBRATE_SCHEMA = maybe_simple_id( NAU7802_CALIBRATE_SCHEMA, synchronous=True, ) -async def nau7802_calibrate_to_code(config, action_id, template_arg, args): +async def nau7802_calibrate_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/ntc/sensor.py b/esphome/components/ntc/sensor.py index dd7d1bd35d..6c2cb69990 100644 --- a/esphome/components/ntc/sensor.py +++ b/esphome/components/ntc/sensor.py @@ -1,4 +1,5 @@ from math import log +from typing import Any import esphome.codegen as cg from esphome.components import sensor @@ -15,6 +16,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, ) +from esphome.types import ConfigType ntc_ns = cg.esphome_ns.namespace("ntc") NTC = ntc_ns.class_("NTC", cg.Component, sensor.Sensor) @@ -25,7 +27,7 @@ CONF_C = "c" ZERO_POINT = 273.15 -def validate_calibration_parameter(value): +def validate_calibration_parameter(value: Any) -> ConfigType: if isinstance(value, dict): return cv.Schema( { @@ -48,7 +50,7 @@ def validate_calibration_parameter(value): ) -def calc_steinhart_hart(value): +def calc_steinhart_hart(value: list[ConfigType]) -> tuple[float, float, float]: r1 = value[0][CONF_VALUE] r2 = value[1][CONF_VALUE] r3 = value[2][CONF_VALUE] @@ -73,7 +75,7 @@ def calc_steinhart_hart(value): return a, b, c -def calc_b(value): +def calc_b(value: ConfigType) -> tuple[float, float, float]: beta = value[CONF_B_CONSTANT] t0 = value[CONF_REFERENCE_TEMPERATURE] + ZERO_POINT r0 = value[CONF_REFERENCE_RESISTANCE] @@ -85,7 +87,7 @@ def calc_b(value): return a, b, c -def process_calibration(value): +def process_calibration(value: Any) -> ConfigType: if isinstance(value, dict): value = cv.Schema( { @@ -132,7 +134,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/openthread_info/sensor.py b/esphome/components/openthread_info/sensor.py index 4d5b3d54f4..e77b84e17c 100644 --- a/esphome/components/openthread_info/sensor.py +++ b/esphome/components/openthread_info/sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( UNIT_DECIBEL_MILLIWATT, UNIT_EMPTY, ) +from esphome.types import ConfigType CONF_PARENT_AVERAGE_RSSI = "parent_average_rssi" CONF_PARENT_LAST_RSSI = "parent_last_rssi" @@ -166,13 +167,13 @@ CONFIG_SCHEMA = cv.Schema( ) -async def setup_conf(config: dict, key: str): +async def setup_conf(config: dict, key: str) -> None: if conf := config.get(key): var = await sensor.new_sensor(conf) await cg.register_component(var, conf) -async def to_code(config): +async def to_code(config: ConfigType) -> None: await setup_conf(config, CONF_PARENT_AVERAGE_RSSI) await setup_conf(config, CONF_PARENT_LAST_RSSI) await setup_conf(config, CONF_PARENT_LINK_QUALITY_IN) diff --git a/esphome/components/openthread_info/text_sensor.py b/esphome/components/openthread_info/text_sensor.py index b672831bf0..da789ae706 100644 --- a/esphome/components/openthread_info/text_sensor.py +++ b/esphome/components/openthread_info/text_sensor.py @@ -8,6 +8,7 @@ from esphome.components.openthread.const import ( ) import esphome.config_validation as cv from esphome.const import CONF_CHANNEL, CONF_IP_ADDRESS, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType CONF_ROLE = "role" CONF_RLOC16 = "rloc16" @@ -86,13 +87,13 @@ CONFIG_SCHEMA = cv.Schema( ) -async def setup_conf(config: dict, key: str): +async def setup_conf(config: dict, key: str) -> None: if conf := config.get(key): var = await text_sensor.new_text_sensor(conf) await cg.register_component(var, conf) -async def to_code(config): +async def to_code(config: ConfigType) -> None: await setup_conf(config, CONF_IP_ADDRESS) await setup_conf(config, CONF_ROLE) await setup_conf(config, CONF_RLOC16) diff --git a/esphome/components/pmsx003/sensor.py b/esphome/components/pmsx003/sensor.py index 0a11120bf0..fe784c5ffe 100644 --- a/esphome/components/pmsx003/sensor.py +++ b/esphome/components/pmsx003/sensor.py @@ -1,3 +1,5 @@ +from typing import Any + import esphome.codegen as cg from esphome.components import sensor, uart import esphome.config_validation as cv @@ -32,6 +34,8 @@ from esphome.const import ( UNIT_MICROGRAMS_PER_CUBIC_METER, UNIT_PERCENT, ) +from esphome.core import TimePeriodMilliseconds +from esphome.types import ConfigType CODEOWNERS = ["@ximex"] DEPENDENCIES = ["uart"] @@ -167,14 +171,14 @@ SENSORS_TO_TYPE = { } -def validate_pmsx003_sensors(value): +def validate_pmsx003_sensors(value: ConfigType) -> ConfigType: for key, types in SENSORS_TO_TYPE.items(): if key in value and value[CONF_TYPE] not in types: raise cv.Invalid(f"{value[CONF_TYPE]} does not have {key} sensor!") return value -def validate_update_interval(value): +def validate_update_interval(value: Any) -> TimePeriodMilliseconds: value = cv.positive_time_period_milliseconds(value) if value == cv.time_period("0s"): return value @@ -295,7 +299,7 @@ CONFIG_SCHEMA = cv.All( ) -def final_validate(config): +def final_validate(config: ConfigType) -> None: require_tx = config[CONF_UPDATE_INTERVAL] > cv.time_period("0s") schema = uart.final_validate_device_schema( "pmsx003", baud_rate=9600, require_rx=True, require_tx=require_tx @@ -306,7 +310,7 @@ def final_validate(config): FINAL_VALIDATE_SCHEMA = final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/pzemac/sensor.py b/esphome/components/pzemac/sensor.py index 5bb734cb2d..f093262e18 100644 --- a/esphome/components/pzemac/sensor.py +++ b/esphome/components/pzemac/sensor.py @@ -26,6 +26,8 @@ from esphome.const import ( UNIT_WATT, UNIT_WATT_HOURS, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType AUTO_LOAD = ["modbus"] @@ -93,7 +95,12 @@ CONFIG_SCHEMA = ( ), synchronous=True, ) -async def reset_energy_to_code(config, action_id, template_arg, args): +async def reset_energy_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -105,7 +112,7 @@ def _final_validate(config: ConfigType) -> None: FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) diff --git a/esphome/components/pzemdc/sensor.py b/esphome/components/pzemdc/sensor.py index b2c7c3a29d..b9f7246b72 100644 --- a/esphome/components/pzemdc/sensor.py +++ b/esphome/components/pzemdc/sensor.py @@ -20,6 +20,8 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType AUTO_LOAD = ["modbus"] @@ -75,7 +77,12 @@ CONFIG_SCHEMA = ( ), synchronous=True, ) -async def reset_energy_to_code(config, action_id, template_arg, args): +async def reset_energy_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -87,7 +94,7 @@ def _final_validate(config: ConfigType) -> None: FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index ad9c4b5a18..6e8c73d331 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import pins import esphome.codegen as cg from esphome.components import esp32, esp32_rmt, remote_base @@ -21,6 +23,7 @@ from esphome.const import ( PlatformFramework, ) from esphome.core import CORE, TimePeriod +from esphome.types import ConfigType CONF_FILTER_SYMBOLS = "filter_symbols" CONF_RECEIVE_SYMBOLS = "receive_symbols" @@ -62,7 +65,7 @@ RemoteReceiverComponent = remote_receiver_ns.class_( ) -def validate_config(config): +def validate_config(config: ConfigType) -> ConfigType: if CORE.is_esp32: variant = esp32.get_esp32_variant() if variant in esp32_rmt.VARIANTS_NO_RMT: @@ -78,7 +81,7 @@ def validate_config(config): return config -def validate_tolerance(value): +def validate_tolerance(value: Any) -> ConfigType: if isinstance(value, dict): return TOLERANCE_SCHEMA(value) @@ -196,7 +199,7 @@ CONFIG_SCHEMA = remote_base.validate_triggers( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: pin = await cg.gpio_pin_expression(config[CONF_PIN]) if CORE.is_esp32 and esp32.get_esp32_variant() not in esp32_rmt.VARIANTS_NO_RMT: # Re-enable ESP-IDF's RMT driver (excluded by default to save compile time) diff --git a/esphome/components/remote_receiver/binary_sensor.py b/esphome/components/remote_receiver/binary_sensor.py index fe3e2af950..d4009f396b 100644 --- a/esphome/components/remote_receiver/binary_sensor.py +++ b/esphome/components/remote_receiver/binary_sensor.py @@ -1,4 +1,5 @@ from esphome.components import binary_sensor, remote_base +from esphome.types import ConfigType from . import FILTER_SOURCE_FILES # noqa: F401 pylint: disable=unused-import @@ -7,6 +8,6 @@ DEPENDENCIES = ["remote_receiver"] CONFIG_SCHEMA = remote_base.validate_binary_sensor -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await remote_base.build_binary_sensor(config) await binary_sensor.register_binary_sensor(var, config) diff --git a/esphome/components/scd30/sensor.py b/esphome/components/scd30/sensor.py index f60e913a0c..37789100f7 100644 --- a/esphome/components/scd30/sensor.py +++ b/esphome/components/scd30/sensor.py @@ -22,6 +22,9 @@ from esphome.const import ( UNIT_PARTS_PER_MILLION, UNIT_PERCENT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] AUTO_LOAD = ["sensirion_common"] @@ -82,7 +85,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -131,8 +134,11 @@ async def to_code(config): synchronous=True, ) async def scd30_force_recalibration_with_reference_to_code( - config, action_id, template_arg, args -): + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_VALUE], args, cg.uint16) diff --git a/esphome/components/senseair/sensor.py b/esphome/components/senseair/sensor.py index 277648137a..82368a60d0 100644 --- a/esphome/components/senseair/sensor.py +++ b/esphome/components/senseair/sensor.py @@ -11,6 +11,9 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_PARTS_PER_MILLION, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["uart"] @@ -62,7 +65,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) @@ -109,6 +112,11 @@ CALIBRATION_ACTION_SCHEMA = maybe_simple_id( CALIBRATION_ACTION_SCHEMA, synchronous=True, ) -async def senseair_action_to_code(config, action_id, template_arg, args): +async def senseair_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/sml/__init__.py b/esphome/components/sml/__init__.py index d25e883fa1..07ca5bf444 100644 --- a/esphome/components/sml/__init__.py +++ b/esphome/components/sml/__init__.py @@ -1,10 +1,12 @@ import re +from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_ON_DATA +from esphome.types import ConfigType CODEOWNERS = ["@alengwenus"] @@ -46,14 +48,14 @@ _CALLBACK_AUTOMATIONS = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -def obis_code(value): +def obis_code(value: Any) -> str: value = cv.string(value) match = re.match(r"^\d{1,3}-\d{1,3}:\d{1,3}\.\d{1,3}\.\d{1,3}$", value) if match is None: diff --git a/esphome/components/sml/sensor/__init__.py b/esphome/components/sml/sensor/__init__.py index e6d7180f17..64ac9773c6 100644 --- a/esphome/components/sml/sensor/__init__.py +++ b/esphome/components/sml/sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import sensor import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType from .. import CONF_OBIS_CODE, CONF_SERVER_ID, CONF_SML_ID, Sml, obis_code, sml_ns @@ -24,7 +25,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable( config[CONF_ID], config[CONF_SERVER_ID], config[CONF_OBIS_CODE] ) diff --git a/esphome/components/sml/text_sensor/__init__.py b/esphome/components/sml/text_sensor/__init__.py index 5a5ab658c4..feff4ef256 100644 --- a/esphome/components/sml/text_sensor/__init__.py +++ b/esphome/components/sml/text_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_FORMAT +from esphome.types import ConfigType from .. import CONF_OBIS_CODE, CONF_SERVER_ID, CONF_SML_ID, Sml, obis_code, sml_ns @@ -33,7 +34,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor( config, config[CONF_SERVER_ID], diff --git a/esphome/components/sn74hc595/__init__.py b/esphome/components/sn74hc595/__init__.py index 26e5c03802..367b65176b 100644 --- a/esphome/components/sn74hc595/__init__.py +++ b/esphome/components/sn74hc595/__init__.py @@ -12,6 +12,8 @@ from esphome.const import ( CONF_OUTPUT, CONF_TYPE, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType MULTI_CONF = True @@ -65,7 +67,7 @@ CONFIG_SCHEMA = cv.typed_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) if config[CONF_TYPE] == TYPE_GPIO: @@ -84,7 +86,7 @@ async def to_code(config): cg.add(var.set_sr_count(config[CONF_SR_COUNT])) -def _validate_output_mode(value): +def _validate_output_mode(value: ConfigType) -> ConfigType: if value.get(CONF_OUTPUT) is not True: raise cv.Invalid("Only output mode is supported") return value @@ -103,7 +105,9 @@ SN74HC595_PIN_SCHEMA = pins.gpio_base_schema( ) -def sn74hc595_pin_final_validate(pin_config, parent_config): +def sn74hc595_pin_final_validate( + pin_config: ConfigType, parent_config: ConfigType +) -> None: max_pins = parent_config[CONF_SR_COUNT] * 8 if pin_config[CONF_NUMBER] >= max_pins: raise cv.Invalid(f"Pin number must be less than {max_pins}") @@ -112,7 +116,7 @@ def sn74hc595_pin_final_validate(pin_config, parent_config): @pins.PIN_SCHEMA_REGISTRY.register( CONF_SN74HC595, SN74HC595_PIN_SCHEMA, sn74hc595_pin_final_validate ) -async def sn74hc595_pin_to_code(config): +async def sn74hc595_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_parented(var, config[CONF_SN74HC595]) diff --git a/esphome/components/spa06_base/__init__.py b/esphome/components/spa06_base/__init__.py index 97d09aad81..c995c2c087 100644 --- a/esphome/components/spa06_base/__init__.py +++ b/esphome/components/spa06_base/__init__.py @@ -15,6 +15,8 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PASCAL, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@danielkent-net"] @@ -55,7 +57,7 @@ OVERSAMPLING_OPTIONS = { SPA06Component = spa06_ns.class_("SPA06Component", cg.PollingComponent) -def spa_oversample_time(oversample): +def spa_oversample_time(oversample: str) -> float: # Pressure oversampling conversion times are listed on datasheet Pg. 26 # Datasheet does not have a table for temperature oversampling; # assumption is that it is the same as pressure @@ -72,7 +74,7 @@ def spa_oversample_time(oversample): return OVERSAMPLING_CONVERSION_TIMES[oversample] -def spa_sample_rate(rate): +def spa_sample_rate(rate: str) -> float: SAMPLE_RATE_OPTIONS_HZ = { "1": 1.0, "2": 2.0, @@ -94,7 +96,7 @@ def spa_sample_rate(rate): return SAMPLE_RATE_OPTIONS_HZ[rate] -def compute_measurement_conversion_time(config): +def compute_measurement_conversion_time(config: ConfigType) -> int: # - adds up sensor conversion time based on temperature and pressure oversampling rates given in datasheet # - returns a rounded up time in ms @@ -115,7 +117,7 @@ def compute_measurement_conversion_time(config): return math.ceil(1.05 * (pressure_conversion_time + temperature_conversion_time)) -def measurement_timing_check(config): +def measurement_timing_check(config: ConfigType) -> ConfigType: temp_time = 0.0 if temperature_config := config.get(CONF_TEMPERATURE): @@ -176,7 +178,7 @@ CONFIG_SCHEMA_BASE = cv.Schema( CONFIG_SCHEMA_BASE.add_extra(measurement_timing_check) -async def to_code_base(config): +async def to_code_base(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) if temperature_config := config.get(CONF_TEMPERATURE): diff --git a/esphome/components/sy6970/__init__.py b/esphome/components/sy6970/__init__.py index 2390d046e4..cb9d64aee7 100644 --- a/esphome/components/sy6970/__init__.py +++ b/esphome/components/sy6970/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@linkedupbits"] DEPENDENCIES = ["i2c"] @@ -48,7 +49,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable( config[CONF_ID], config[CONF_ENABLE_STATUS_LED], diff --git a/esphome/components/sy6970/binary_sensor/__init__.py b/esphome/components/sy6970/binary_sensor/__init__.py index 132b282051..c95850aadc 100644 --- a/esphome/components/sy6970/binary_sensor/__init__.py +++ b/esphome/components/sy6970/binary_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import DEVICE_CLASS_CONNECTIVITY, DEVICE_CLASS_POWER +from esphome.types import ConfigType from .. import CONF_SY6970_ID, SY6970Component, sy6970_ns @@ -40,7 +41,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_SY6970_ID]) if vbus_connected_config := config.get(CONF_VBUS_CONNECTED): diff --git a/esphome/components/sy6970/sensor/__init__.py b/esphome/components/sy6970/sensor/__init__.py index e6ee9d1337..8f8090b6ee 100644 --- a/esphome/components/sy6970/sensor/__init__.py +++ b/esphome/components/sy6970/sensor/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( UNIT_MILLIAMP, UNIT_VOLT, ) +from esphome.types import ConfigType from .. import CONF_SY6970_ID, SY6970Component, sy6970_ns @@ -71,7 +72,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_SY6970_ID]) if vbus_voltage_config := config.get(CONF_VBUS_VOLTAGE): diff --git a/esphome/components/sy6970/text_sensor/__init__.py b/esphome/components/sy6970/text_sensor/__init__.py index 2a4eb90811..03a55393b9 100644 --- a/esphome/components/sy6970/text_sensor/__init__.py +++ b/esphome/components/sy6970/text_sensor/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv +from esphome.types import ConfigType from .. import CONF_SY6970_ID, SY6970Component, sy6970_ns @@ -36,7 +37,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_SY6970_ID]) if bus_status_config := config.get(CONF_BUS_STATUS): diff --git a/esphome/components/tm1638/binary_sensor/__init__.py b/esphome/components/tm1638/binary_sensor/__init__.py index de6ea35e54..4f89b7bf5e 100644 --- a/esphome/components/tm1638/binary_sensor/__init__.py +++ b/esphome/components/tm1638/binary_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_KEY +from esphome.types import ConfigType from ..display import CONF_TM1638_ID, TM1638Component, tm1638_ns @@ -15,7 +16,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(TM1638Key).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) cg.add(var.set_keycode(config[CONF_KEY])) hub = await cg.get_variable(config[CONF_TM1638_ID]) diff --git a/esphome/components/tm1638/display.py b/esphome/components/tm1638/display.py index 14b70be94d..d6491129c6 100644 --- a/esphome/components/tm1638/display.py +++ b/esphome/components/tm1638/display.py @@ -10,6 +10,7 @@ from esphome.const import ( CONF_LAMBDA, CONF_STB_PIN, ) +from esphome.types import ConfigType CODEOWNERS = ["@skykingjwc"] @@ -31,7 +32,7 @@ CONFIG_SCHEMA = display.BASIC_DISPLAY_SCHEMA.extend( ).extend(cv.polling_component_schema("1s")) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) diff --git a/esphome/components/tm1638/output/__init__.py b/esphome/components/tm1638/output/__init__.py index b16b08d504..961abfee47 100644 --- a/esphome/components/tm1638/output/__init__.py +++ b/esphome/components/tm1638/output/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_LED +from esphome.types import ConfigType from ..display import CONF_TM1638_ID, TM1638Component, tm1638_ns @@ -17,7 +18,7 @@ CONFIG_SCHEMA = output.BINARY_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) await cg.register_component(var, config) diff --git a/esphome/components/tm1638/switch/__init__.py b/esphome/components/tm1638/switch/__init__.py index 90ff87938c..f42b835e03 100644 --- a/esphome/components/tm1638/switch/__init__.py +++ b/esphome/components/tm1638/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import CONF_LED +from esphome.types import ConfigType from ..display import CONF_TM1638_ID, TM1638Component, tm1638_ns @@ -20,7 +21,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) cg.add(var.set_lednum(config[CONF_LED])) diff --git a/esphome/components/uponor_smatrix/__init__.py b/esphome/components/uponor_smatrix/__init__.py index 9588b0df7f..093408e868 100644 --- a/esphome/components/uponor_smatrix/__init__.py +++ b/esphome/components/uponor_smatrix/__init__.py @@ -2,6 +2,8 @@ import esphome.codegen as cg from esphome.components import time, uart import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID, CONF_TIME_ID +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@kroimon"] @@ -61,7 +63,7 @@ UPONOR_SMATRIX_DEVICE_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(uponor_smatrix_ns.using) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -74,7 +76,7 @@ async def to_code(config): cg.add(var.set_time_device_address(time_device_address)) -async def register_uponor_smatrix_device(var, config): +async def register_uponor_smatrix_device(var: MockObj, config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_UPONOR_SMATRIX_ID]) cg.add(var.set_parent(parent)) cg.add(var.set_address(config[CONF_ADDRESS])) diff --git a/esphome/components/uponor_smatrix/climate/__init__.py b/esphome/components/uponor_smatrix/climate/__init__.py index 47495fde9a..e80f59df24 100644 --- a/esphome/components/uponor_smatrix/climate/__init__.py +++ b/esphome/components/uponor_smatrix/climate/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import climate +from esphome.types import ConfigType from .. import ( UPONOR_SMATRIX_DEVICE_SCHEMA, @@ -22,7 +23,7 @@ CONFIG_SCHEMA = climate.climate_schema(UponorSmatrixClimate).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) await cg.register_component(var, config) await register_uponor_smatrix_device(var, config) diff --git a/esphome/components/uponor_smatrix/sensor/__init__.py b/esphome/components/uponor_smatrix/sensor/__init__.py index f2b34538ba..52e755f005 100644 --- a/esphome/components/uponor_smatrix/sensor/__init__.py +++ b/esphome/components/uponor_smatrix/sensor/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PERCENT, ) +from esphome.types import ConfigType from .. import ( UPONOR_SMATRIX_DEVICE_SCHEMA, @@ -61,7 +62,7 @@ CONFIG_SCHEMA = cv.COMPONENT_SCHEMA.extend( ).extend(UPONOR_SMATRIX_DEVICE_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await register_uponor_smatrix_device(var, config) diff --git a/esphome/components/vl53l0x/sensor.py b/esphome/components/vl53l0x/sensor.py index 583d6ccca9..3029e0f77b 100644 --- a/esphome/components/vl53l0x/sensor.py +++ b/esphome/components/vl53l0x/sensor.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import pins import esphome.codegen as cg from esphome.components import i2c, sensor @@ -10,6 +12,8 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_METER, ) +from esphome.core import TimePeriodMicroseconds +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -23,7 +27,7 @@ CONF_LONG_RANGE = "long_range" CONF_TIMING_BUDGET = "timing_budget" -def check_keys(obj): +def check_keys(obj: ConfigType) -> ConfigType: if obj[CONF_ADDRESS] != 0x29 and CONF_ENABLE_PIN not in obj: msg = "Address other then 0x29 requires enable_pin definition to allow sensor\r" msg += "re-addressing. Also if you have more then one VL53 device on the same\r" @@ -32,7 +36,7 @@ def check_keys(obj): return obj -def check_timeout(value): +def check_timeout(value: Any) -> TimePeriodMicroseconds: value = cv.positive_time_period_microseconds(value) if value.total_seconds > 60: raise cv.Invalid("Maximum timeout can not be greater then 60 seconds") @@ -70,7 +74,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) cg.add(var.set_signal_rate_limit(config[CONF_SIGNAL_RATE_LIMIT])) diff --git a/esphome/components/weikai/__init__.py b/esphome/components/weikai/__init__.py index bc80f167ef..8f0cf4ba33 100644 --- a/esphome/components/weikai/__init__.py +++ b/esphome/components/weikai/__init__.py @@ -12,6 +12,8 @@ from esphome.const import ( CONF_NUMBER, CONF_OUTPUT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@DrCoolZic"] AUTO_LOAD = ["uart"] @@ -26,7 +28,7 @@ WeikaiComponent = weikai_ns.class_("WeikaiComponent", cg.Component) WeikaiChannel = weikai_ns.class_("WeikaiChannel", uart.UARTComponent) -def check_channel_max(value, max): +def check_channel_max(value: ConfigType, max: int) -> ConfigType: channel_uniq = [] channel_dup = [] for x in value[CONF_UART]: @@ -41,11 +43,11 @@ def check_channel_max(value, max): return value -def check_channel_max_4(value): +def check_channel_max_4(value: ConfigType) -> ConfigType: return check_channel_max(value, 4) -def check_channel_max_2(value): +def check_channel_max_2(value: ConfigType) -> ConfigType: return check_channel_max(value, 2) @@ -70,7 +72,7 @@ WKBASE_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -async def register_weikai(var, config): +async def register_weikai(var: MockObj, config: ConfigType) -> None: """Register an weikai device with the given config.""" cg.add(var.set_crystal(config[CONF_CRYSTAL])) cg.add(var.set_test_mode(config[CONF_TEST_MODE])) @@ -85,7 +87,7 @@ async def register_weikai(var, config): cg.add(chan.set_parity(uart_elem[CONF_PARITY])) -def validate_pin_mode(value): +def validate_pin_mode(value: ConfigType) -> ConfigType: """Checks input/output mode inconsistency""" if not (value[CONF_MODE][CONF_INPUT] or value[CONF_MODE][CONF_OUTPUT]): raise cv.Invalid("Mode must be either input or output") diff --git a/esphome/components/zephyr_ble_server/__init__.py b/esphome/components/zephyr_ble_server/__init__.py index 658137d1a2..463b9c0887 100644 --- a/esphome/components/zephyr_ble_server/__init__.py +++ b/esphome/components/zephyr_ble_server/__init__.py @@ -3,7 +3,9 @@ import esphome.codegen as cg from esphome.components.zephyr import zephyr_add_prj_conf import esphome.config_validation as cv from esphome.const import CONF_ID, Framework -from esphome.core import CORE +from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType zephyr_ble_server_ns = cg.esphome_ns.namespace("zephyr_ble_server") BLEServer = zephyr_ble_server_ns.class_("BLEServer", cg.Component) @@ -32,7 +34,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) zephyr_add_prj_conf("BT", True) zephyr_add_prj_conf("BT_PERIPHERAL", True) @@ -65,7 +67,12 @@ BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA = cv.Schema( BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA, synchronous=True, ) -async def numeric_comparison_reply_to_code(config, action_id, template_arg, args): +async def numeric_comparison_reply_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: parent = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, parent) From 12da2140cf93273875315823ba041eb4b5841ade Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:57:14 +1200 Subject: [PATCH 1607/1815] [core] Add type annotations to component Python (11/11) (#18348) --- esphome/components/animation/image.py | 9 ++++++++- esphome/components/apds9960/__init__.py | 3 ++- esphome/components/apds9960/binary_sensor.py | 3 ++- esphome/components/apds9960/sensor.py | 3 ++- esphome/components/emc2101/__init__.py | 3 ++- esphome/components/emc2101/output/__init__.py | 3 ++- esphome/components/emc2101/sensor/__init__.py | 3 ++- esphome/components/graph/__init__.py | 9 ++++++--- esphome/components/pylontech/__init__.py | 3 ++- esphome/components/pylontech/sensor/__init__.py | 3 ++- esphome/components/pylontech/text_sensor/__init__.py | 3 ++- esphome/components/rd03d/__init__.py | 3 ++- esphome/components/rd03d/binary_sensor.py | 3 ++- esphome/components/rd03d/sensor.py | 3 ++- esphome/components/sun_gtil2/__init__.py | 3 ++- esphome/components/sun_gtil2/sensor.py | 3 ++- esphome/components/sun_gtil2/text_sensor.py | 3 ++- esphome/components/teleinfo/__init__.py | 3 ++- esphome/components/teleinfo/sensor/__init__.py | 3 ++- esphome/components/teleinfo/text_sensor/__init__.py | 3 ++- esphome/components/ufm01/__init__.py | 3 ++- esphome/components/ufm01/binary_sensor.py | 3 ++- esphome/components/ufm01/sensor.py | 3 ++- esphome/components/xl9535/__init__.py | 10 ++++++---- 24 files changed, 62 insertions(+), 29 deletions(-) diff --git a/esphome/components/animation/image.py b/esphome/components/animation/image.py index 73d428bd20..0265a350f7 100644 --- a/esphome/components/animation/image.py +++ b/esphome/components/animation/image.py @@ -6,6 +6,8 @@ from esphome.components.file.image import image_schema, write_image from esphome.components.image import Image_, validate_settings import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_REPEAT +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@syndlex"] @@ -79,7 +81,12 @@ SET_FRAME_SCHEMA = cv.Schema( @automation.register_action( "animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True ) -async def animation_action_to_code(config, action_id, template_arg, args): +async def animation_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/apds9960/__init__.py b/esphome/components/apds9960/__init__.py index 99e37d3764..7ac1e5eb32 100644 --- a/esphome/components/apds9960/__init__.py +++ b/esphome/components/apds9960/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] MULTI_CONF = True @@ -57,7 +58,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/apds9960/binary_sensor.py b/esphome/components/apds9960/binary_sensor.py index 48e923ab2b..342f688249 100644 --- a/esphome/components/apds9960/binary_sensor.py +++ b/esphome/components/apds9960/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_DIRECTION, DEVICE_CLASS_MOVING +from esphome.types import ConfigType from . import APDS9960, CONF_APDS9960_ID @@ -19,7 +20,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_APDS9960_ID]) var = await binary_sensor.new_binary_sensor(config) func = getattr(hub, f"set_{config[CONF_DIRECTION]}_direction_binary_sensor") diff --git a/esphome/components/apds9960/sensor.py b/esphome/components/apds9960/sensor.py index 468eb0995f..a75fb79d1b 100644 --- a/esphome/components/apds9960/sensor.py +++ b/esphome/components/apds9960/sensor.py @@ -7,6 +7,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_PERCENT, ) +from esphome.types import ConfigType from . import APDS9960, CONF_APDS9960_ID @@ -27,7 +28,7 @@ CONFIG_SCHEMA = sensor.sensor_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_APDS9960_ID]) var = await sensor.new_sensor(config) func = getattr(hub, f"set_{config[CONF_TYPE]}_sensor") diff --git a/esphome/components/emc2101/__init__.py b/esphome/components/emc2101/__init__.py index 323195e99a..639847345f 100644 --- a/esphome/components/emc2101/__init__.py +++ b/esphome/components/emc2101/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_INVERTED, CONF_RESOLUTION +from esphome.types import ConfigType CODEOWNERS = ["@ellull"] @@ -68,7 +69,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/emc2101/output/__init__.py b/esphome/components/emc2101/output/__init__.py index 586f0800a6..a8820345e2 100644 --- a/esphome/components/emc2101/output/__init__.py +++ b/esphome/components/emc2101/output/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType from .. import CONF_EMC2101_ID, EMC2101_COMPONENT_SCHEMA, emc2101_ns @@ -16,7 +17,7 @@ CONFIG_SCHEMA = EMC2101_COMPONENT_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_EMC2101_ID]) var = cg.new_Pvariable(config[CONF_ID], paren) await output.register_output(var, config) diff --git a/esphome/components/emc2101/sensor/__init__.py b/esphome/components/emc2101/sensor/__init__.py index b6a2c8a333..cc8901cf38 100644 --- a/esphome/components/emc2101/sensor/__init__.py +++ b/esphome/components/emc2101/sensor/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( UNIT_PERCENT, UNIT_REVOLUTIONS_PER_MINUTE, ) +from esphome.types import ConfigType from .. import CONF_EMC2101_ID, EMC2101_COMPONENT_SCHEMA, emc2101_ns @@ -53,7 +54,7 @@ CONFIG_SCHEMA = EMC2101_COMPONENT_SCHEMA.extend( ).extend(cv.polling_component_schema("60s")) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_EMC2101_ID]) var = cg.new_Pvariable(config[CONF_ID], paren) await cg.register_component(var, config) diff --git a/esphome/components/graph/__init__.py b/esphome/components/graph/__init__.py index 0749d7e2a3..1b99491f9c 100644 --- a/esphome/components/graph/__init__.py +++ b/esphome/components/graph/__init__.py @@ -29,6 +29,7 @@ from esphome.const import ( CONF_X_GRID, CONF_Y_GRID, ) +from esphome.types import ConfigType CODEOWNERS = ["@synco"] @@ -115,7 +116,9 @@ GRAPH_SCHEMA = cv.Schema( ) -def _relocate_fields_to_subfolder(config, subfolder, subschema): +def _relocate_fields_to_subfolder( + config: ConfigType, subfolder: str, subschema: cv.Schema +) -> ConfigType: fields = [k.schema for k in subschema.schema] fields.remove(CONF_ID) if subfolder in config: @@ -138,7 +141,7 @@ def _relocate_fields_to_subfolder(config, subfolder, subschema): return config -def _relocate_trace(config): +def _relocate_trace(config: ConfigType) -> ConfigType: return _relocate_fields_to_subfolder(config, CONF_TRACES, GRAPH_TRACE_SCHEMA) @@ -148,7 +151,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_duration(config[CONF_DURATION])) cg.add(var.set_width(config[CONF_WIDTH])) diff --git a/esphome/components/pylontech/__init__.py b/esphome/components/pylontech/__init__.py index 82b98654a2..4ab606d9f9 100644 --- a/esphome/components/pylontech/__init__.py +++ b/esphome/components/pylontech/__init__.py @@ -4,6 +4,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -41,7 +42,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/pylontech/sensor/__init__.py b/esphome/components/pylontech/sensor/__init__.py index 450f663274..40391206fb 100644 --- a/esphome/components/pylontech/sensor/__init__.py +++ b/esphome/components/pylontech/sensor/__init__.py @@ -16,6 +16,7 @@ from esphome.const import ( UNIT_PERCENT, UNIT_VOLT, ) +from esphome.types import ConfigType from .. import CONF_BATTERY, CONF_PYLONTECH_ID, PYLONTECH_COMPONENT_SCHEMA, pylontech_ns @@ -90,7 +91,7 @@ CONFIG_SCHEMA = PYLONTECH_COMPONENT_SCHEMA.extend( ).extend({cv.Optional(marker): schema for marker, schema in TYPES.items()}) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_PYLONTECH_ID]) bat = cg.new_Pvariable(config[CONF_ID], config[CONF_BATTERY]) diff --git a/esphome/components/pylontech/text_sensor/__init__.py b/esphome/components/pylontech/text_sensor/__init__.py index f68ca10374..511eb7d542 100644 --- a/esphome/components/pylontech/text_sensor/__init__.py +++ b/esphome/components/pylontech/text_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType from .. import CONF_BATTERY, CONF_PYLONTECH_ID, PYLONTECH_COMPONENT_SCHEMA, pylontech_ns @@ -24,7 +25,7 @@ CONFIG_SCHEMA = PYLONTECH_COMPONENT_SCHEMA.extend( ).extend({cv.Optional(marker): text_sensor.text_sensor_schema() for marker in MARKERS}) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_PYLONTECH_ID]) bat = cg.new_Pvariable(config[CONF_ID], config[CONF_BATTERY]) diff --git a/esphome/components/rd03d/__init__.py b/esphome/components/rd03d/__init__.py index 52e9a2c09a..4fff41e4f6 100644 --- a/esphome/components/rd03d/__init__.py +++ b/esphome/components/rd03d/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_THROTTLE +from esphome.types import ConfigType CODEOWNERS = ["@jasstrong"] DEPENDENCIES = ["uart"] @@ -38,7 +39,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/rd03d/binary_sensor.py b/esphome/components/rd03d/binary_sensor.py index afb7527aa1..2c040d0560 100644 --- a/esphome/components/rd03d/binary_sensor.py +++ b/esphome/components/rd03d/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_TARGET, DEVICE_CLASS_OCCUPANCY +from esphome.types import ConfigType from . import CONF_RD03D_ID, RD03DComponent @@ -26,7 +27,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_RD03D_ID]) if target_config := config.get(CONF_TARGET): diff --git a/esphome/components/rd03d/sensor.py b/esphome/components/rd03d/sensor.py index 953d99c2da..d29656bab0 100644 --- a/esphome/components/rd03d/sensor.py +++ b/esphome/components/rd03d/sensor.py @@ -15,6 +15,7 @@ from esphome.const import ( UNIT_DEGREES, UNIT_MILLIMETER, ) +from esphome.types import ConfigType from . import CONF_RD03D_ID, RD03DComponent @@ -75,7 +76,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend({cv.Optional(f"target_{i + 1}"): TARGET_SCHEMA for i in range(MAX_TARGETS)}) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_RD03D_ID]) if target_count_config := config.get(CONF_TARGET_COUNT): diff --git a/esphome/components/sun_gtil2/__init__.py b/esphome/components/sun_gtil2/__init__.py index c7082794db..0f5ae27753 100644 --- a/esphome/components/sun_gtil2/__init__.py +++ b/esphome/components/sun_gtil2/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@Mat931"] MULTI_CONF = True @@ -24,7 +25,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/sun_gtil2/sensor.py b/esphome/components/sun_gtil2/sensor.py index 55c8195391..26435cfa67 100644 --- a/esphome/components/sun_gtil2/sensor.py +++ b/esphome/components/sun_gtil2/sensor.py @@ -13,6 +13,7 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.types import ConfigType from . import CONF_SUN_GTIL2_ID, SunGTIL2Component @@ -73,7 +74,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_SUN_GTIL2_ID]) if ac_voltage_config := config.get(CONF_AC_VOLTAGE): sens = await sensor.new_sensor(ac_voltage_config) diff --git a/esphome/components/sun_gtil2/text_sensor.py b/esphome/components/sun_gtil2/text_sensor.py index f74f89b3b4..eae69fb4df 100644 --- a/esphome/components/sun_gtil2/text_sensor.py +++ b/esphome/components/sun_gtil2/text_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_STATE +from esphome.types import ConfigType from . import CONF_SUN_GTIL2_ID, SunGTIL2Component @@ -22,7 +23,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_SUN_GTIL2_ID]) if state_config := config.get(CONF_STATE): sens = await text_sensor.new_text_sensor(state_config) diff --git a/esphome/components/teleinfo/__init__.py b/esphome/components/teleinfo/__init__.py index 87c7b9e85c..f9233511e1 100644 --- a/esphome/components/teleinfo/__init__.py +++ b/esphome/components/teleinfo/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@0hax"] MULTI_CONF = True @@ -34,7 +35,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], config[CONF_HISTORICAL_MODE]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/teleinfo/sensor/__init__.py b/esphome/components/teleinfo/sensor/__init__.py index 150484d97a..b51d4cb795 100644 --- a/esphome/components/teleinfo/sensor/__init__.py +++ b/esphome/components/teleinfo/sensor/__init__.py @@ -7,6 +7,7 @@ from esphome.const import ( STATE_CLASS_TOTAL_INCREASING, UNIT_WATT_HOURS, ) +from esphome.types import ConfigType from .. import CONF_TAG_NAME, CONF_TELEINFO_ID, TELEINFO_LISTENER_SCHEMA, teleinfo_ns @@ -22,7 +23,7 @@ CONFIG_SCHEMA = sensor.sensor_schema( ).extend(TELEINFO_LISTENER_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], config[CONF_TAG_NAME]) await cg.register_component(var, config) await sensor.register_sensor(var, config) diff --git a/esphome/components/teleinfo/text_sensor/__init__.py b/esphome/components/teleinfo/text_sensor/__init__.py index 79fabd10d0..0b6ff11d74 100644 --- a/esphome/components/teleinfo/text_sensor/__init__.py +++ b/esphome/components/teleinfo/text_sensor/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import text_sensor from esphome.const import CONF_ID +from esphome.types import ConfigType from .. import CONF_TAG_NAME, CONF_TELEINFO_ID, TELEINFO_LISTENER_SCHEMA, teleinfo_ns @@ -13,7 +14,7 @@ CONFIG_SCHEMA = text_sensor.text_sensor_schema(TeleInfoTextSensor).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], config[CONF_TAG_NAME]) await cg.register_component(var, config) await text_sensor.register_text_sensor(var, config) diff --git a/esphome/components/ufm01/__init__.py b/esphome/components/ufm01/__init__.py index 51cf3cfd91..ca0ea57796 100644 --- a/esphome/components/ufm01/__init__.py +++ b/esphome/components/ufm01/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@ljungqvist"] @@ -34,7 +35,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/ufm01/binary_sensor.py b/esphome/components/ufm01/binary_sensor.py index 92ae585d96..59583357e4 100644 --- a/esphome/components/ufm01/binary_sensor.py +++ b/esphome/components/ufm01/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType from . import CONF_UFM01_ID, UFM01Component @@ -32,7 +33,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ufm01_component = await cg.get_variable(config[CONF_UFM01_ID]) if ufc_chip_error_config := config.get(CONF_UFC_CHIP_ERROR): diff --git a/esphome/components/ufm01/sensor.py b/esphome/components/ufm01/sensor.py index 4dcd7ceebe..e3281f0b2d 100644 --- a/esphome/components/ufm01/sensor.py +++ b/esphome/components/ufm01/sensor.py @@ -13,6 +13,7 @@ from esphome.const import ( UNIT_CUBIC_METER_PER_HOUR, UNIT_LITRE, ) +from esphome.types import ConfigType from . import CONF_UFM01_ID, UFM01Component @@ -47,7 +48,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: ufm01_component = await cg.get_variable(config[CONF_UFM01_ID]) if CONF_ACCUMULATED_FLOW in config: diff --git a/esphome/components/xl9535/__init__.py b/esphome/components/xl9535/__init__.py index 58ce4a30f8..5686b74173 100644 --- a/esphome/components/xl9535/__init__.py +++ b/esphome/components/xl9535/__init__.py @@ -10,6 +10,8 @@ from esphome.const import ( CONF_NUMBER, CONF_OUTPUT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CONF_XL9535 = "xl9535" @@ -29,13 +31,13 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) -def validate_mode(mode): +def validate_mode(mode: ConfigType) -> ConfigType: if not (mode[CONF_INPUT] or mode[CONF_OUTPUT]) or ( mode[CONF_INPUT] and mode[CONF_OUTPUT] ): @@ -43,7 +45,7 @@ def validate_mode(mode): return mode -def validate_pin(pin): +def validate_pin(pin: int) -> int: if pin in (8, 9): raise cv.Invalid(f"pin {pin} doesn't exist") return pin @@ -67,7 +69,7 @@ XL9535_PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(CONF_XL9535, XL9535_PIN_SCHEMA) -async def xl9535_pin_to_code(config): +async def xl9535_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) parent = await cg.get_variable(config[CONF_XL9535]) From 44dcd82d78bf4da00ac00f1738d9b95e9735df9e Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:58:11 +1200 Subject: [PATCH 1608/1815] [mipi_spi] Toggle D/C only while holding the SPI bus (#18529) --- esphome/components/mipi_spi/mipi_spi.h | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index b269f46dc9..2552451bd7 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -246,33 +246,36 @@ class MipiSpi : public display::Display, this->write_cmd_addr_data(8, 0x02, 24, cmd << 8, bytes, len); this->disable(); } else if constexpr (BUS_TYPE == BUS_TYPE_OCTAL) { - this->dc_pin_->digital_write(false); + // Toggle D/C only while holding the bus; on boards where D/C doubles as + // another bus signal, driving it while another device owns the bus + // corrupts that device's transfer. this->enable(); + this->dc_pin_->digital_write(false); this->write_cmd_addr_data(0, 0, 0, 0, &cmd, 1, 8); - this->disable(); this->dc_pin_->digital_write(true); + this->disable(); if (len != 0) { this->enable(); this->write_cmd_addr_data(0, 0, 0, 0, bytes, len, 8); this->disable(); } } else if constexpr (BUS_TYPE == BUS_TYPE_SINGLE) { - this->dc_pin_->digital_write(false); this->enable(); + this->dc_pin_->digital_write(false); this->write_byte(cmd); - this->disable(); this->dc_pin_->digital_write(true); + this->disable(); if (len != 0) { this->enable(); this->write_array(bytes, len); this->disable(); } } else if constexpr (BUS_TYPE == BUS_TYPE_SINGLE_16) { - this->dc_pin_->digital_write(false); this->enable(); + this->dc_pin_->digital_write(false); this->write_byte(cmd); - this->disable(); this->dc_pin_->digital_write(true); + this->disable(); for (size_t i = 0; i != len; i++) { this->enable(); this->write_byte(0); From edd4a86d14a0d4ac64b1b6efc2bcb00cee0d5111 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:02:15 -0500 Subject: [PATCH 1609/1815] Bump prek from 0.4.13 to 0.4.14 (#18563) Signed-off-by: dependabot[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index cedc107b17..079c375c01 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -2,7 +2,7 @@ pylint==4.0.7 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating ruff==0.16.3 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating -prek==0.4.13 # also change in .github/workflows/ci.yml when updating +prek==0.4.14 # also change in .github/workflows/ci.yml when updating # Unit tests pytest==9.1.1 From 4cfa4893ef4bde01ab73da64470f83eeef2e7461 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:24:28 -0500 Subject: [PATCH 1610/1815] Bump docker/setup-buildx-action from 4.2.0 to 4.3.0 in the docker-actions group (#18564) Signed-off-by: dependabot[bot] --- .github/workflows/ci-docker.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 71dedd65aa..f3f7cb30eb 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -67,7 +67,7 @@ jobs: with: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Determine tag and whether to push id: tag @@ -153,7 +153,7 @@ jobs: with: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Log in to the GitHub container registry uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 10b28ace38..d0dee8165c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -123,7 +123,7 @@ jobs: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Log in to docker hub uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 @@ -202,7 +202,7 @@ jobs: merge-multiple: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Log in to docker hub if: matrix.registry == 'dockerhub' From ece90ee97b11ecfeff40a3c2da11cf357cf381f7 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:59:30 -0500 Subject: [PATCH 1611/1815] Bump bundled esphome-device-builder to 1.12.2 (#18573) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 55aa0ac982..2bbe5331e5 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.12.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.12.2 RUN \ platformio settings set enable_telemetry No \ From 5177972c041d75bf00351c7f6d2cb250253c19e5 Mon Sep 17 00:00:00 2001 From: guillempages Date: Fri, 21 Aug 2026 00:27:44 +0200 Subject: [PATCH 1612/1815] [runtime_image] keep decoder allocated (#18488) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../components/online_image/online_image.cpp | 2 +- esphome/components/runtime_image/__init__.py | 5 + .../components/runtime_image/bmp_decoder.cpp | 21 +- .../components/runtime_image/bmp_decoder.h | 12 +- .../components/runtime_image/image_decoder.h | 34 +- .../components/runtime_image/image_format.h | 19 + .../components/runtime_image/jpeg_decoder.cpp | 6 - .../components/runtime_image/jpeg_decoder.h | 3 +- .../components/runtime_image/png_decoder.cpp | 7 +- .../components/runtime_image/png_decoder.h | 8 + .../runtime_image/runtime_image.cpp | 53 +-- .../components/runtime_image/runtime_image.h | 34 +- .../sendspin/image/sendspin_image.cpp | 4 +- tests/components/runtime_image/__init__.py | 15 + .../runtime_image/test_decoder_reuse.cpp | 336 ++++++++++++++++++ 15 files changed, 487 insertions(+), 72 deletions(-) create mode 100644 esphome/components/runtime_image/image_format.h create mode 100644 tests/components/runtime_image/__init__.py create mode 100644 tests/components/runtime_image/test_decoder_reuse.cpp diff --git a/esphome/components/online_image/online_image.cpp b/esphome/components/online_image/online_image.cpp index 22bce4cc41..fe4f727cd6 100644 --- a/esphome/components/online_image/online_image.cpp +++ b/esphome/components/online_image/online_image.cpp @@ -216,7 +216,7 @@ void OnlineImage::loop() { } void OnlineImage::end_connection_() { - // Abort any in-progress decode to free decoder resources. + // Abort any in-progress decode; the decoder object is kept warm for the next decode. // Use RuntimeImage::release() directly to avoid recursion with OnlineImage::release(). if (this->is_decoding()) { RuntimeImage::release(); diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index 9fa32a5a65..3c130a7d75 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -77,6 +77,11 @@ class JPEGFormat(Format): def actions(self) -> None: cg.add_define("USE_RUNTIME_IMAGE_JPEG") cg.add_library("JPEGDEC", "1.8.4", "https://github.com/bitbank2/JPEGDEC#1.8.4") + if CORE.is_host: + # JPEGDEC's host detection checks __MACH__/__LINUX__, but gcc only + # predefines the lowercase __linux__; without this a Linux host + # build tries to include Arduino.h. + cg.add_build_flag("-D__LINUX__") if CORE.is_esp32: from esphome.components.esp32 import add_idf_component diff --git a/esphome/components/runtime_image/bmp_decoder.cpp b/esphome/components/runtime_image/bmp_decoder.cpp index 6a1bd61d86..5d45621fb7 100644 --- a/esphome/components/runtime_image/bmp_decoder.cpp +++ b/esphome/components/runtime_image/bmp_decoder.cpp @@ -12,6 +12,22 @@ namespace esphome::runtime_image { static const char *const TAG = "image_decoder.bmp"; +void BmpDecoder::reset() { + ImageDecoder::reset(); + this->bits_per_pixel_ = 0; + this->compression_method_ = 0; + this->image_data_size_ = 0; + this->width_ = 0; + this->height_ = 0; + this->current_index_ = 0; + this->paint_index_ = 0; + // color_table_ is deliberately kept allocated so the next decode can reuse it + this->color_table_entries_ = 0; + this->data_offset_ = 0; + this->padding_bytes_ = 0; + this->width_bytes_ = 0; +} + int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) { size_t index = 0; if (this->current_index_ == 0) { @@ -85,7 +101,10 @@ int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) { size_t header_size = encode_uint32(buffer[17], buffer[16], buffer[15], buffer[14]); size_t offset = 14 + header_size; - this->color_table_ = std::make_unique(this->color_table_entries_); + if (this->color_table_entries_ > this->color_table_capacity_) { + this->color_table_ = std::make_unique(this->color_table_entries_); + this->color_table_capacity_ = this->color_table_entries_; + } for (size_t i = 0; i < this->color_table_entries_; i++) { this->color_table_[i] = encode_uint32(buffer[offset + i * 4 + 3], buffer[offset + i * 4 + 2], diff --git a/esphome/components/runtime_image/bmp_decoder.h b/esphome/components/runtime_image/bmp_decoder.h index a52a561584..01acc41f91 100644 --- a/esphome/components/runtime_image/bmp_decoder.h +++ b/esphome/components/runtime_image/bmp_decoder.h @@ -21,8 +21,9 @@ class BmpDecoder : public ImageDecoder { * * @param image The RuntimeImage to decode the stream into. */ - BmpDecoder(RuntimeImage *image) : ImageDecoder(image) {} + BmpDecoder(RuntimeImage *image) : ImageDecoder(image, BMP) {} + void reset() override; int HOT decode(uint8_t *buffer, size_t size) override; bool is_finished() const override { @@ -35,17 +36,18 @@ class BmpDecoder : public ImageDecoder { } protected: + std::unique_ptr color_table_; size_t current_index_{0}; size_t paint_index_{0}; ssize_t width_{0}; ssize_t height_{0}; - uint16_t bits_per_pixel_{0}; + size_t width_bytes_{0}; + size_t data_offset_{0}; uint32_t compression_method_{0}; uint32_t image_data_size_{0}; uint32_t color_table_entries_{0}; - std::unique_ptr color_table_; - size_t width_bytes_{0}; - size_t data_offset_{0}; + uint32_t color_table_capacity_{0}; // Allocated entries in color_table_, kept across decodes + uint16_t bits_per_pixel_{0}; uint8_t padding_bytes_{0}; }; diff --git a/esphome/components/runtime_image/image_decoder.h b/esphome/components/runtime_image/image_decoder.h index 6d351a10aa..2a8b393888 100644 --- a/esphome/components/runtime_image/image_decoder.h +++ b/esphome/components/runtime_image/image_decoder.h @@ -1,5 +1,6 @@ #pragma once #include "esphome/core/color.h" +#include "image_format.h" namespace esphome::runtime_image { @@ -36,18 +37,41 @@ class ImageDecoder { * @brief Construct a new Image Decoder object * * @param image The RuntimeImage to decode the stream into. + * @param format The image format this decoder handles. */ - ImageDecoder(RuntimeImage *image) : image_(image) {} + ImageDecoder(RuntimeImage *image, ImageFormat format) : image_(image), format_(format) {} virtual ~ImageDecoder() = default; + /// @brief Get the image format handled by this decoder. + ImageFormat get_format() const { return this->format_; } + + /// @brief Check if a decoding session is in progress (prepare() called, reset() not yet). + bool is_active() const { return this->active_; } + /** - * @brief Initialize the decoder. + * @brief Reset the decoder state, ending any decoding session. + * Subclasses should override this method to reset any format-specific state. + * Buffers the next decode can reuse should be kept allocated to avoid heap churn. + */ + virtual void reset() { + this->active_ = false; + this->expected_size_ = 0; + this->decoded_bytes_ = 0; + this->size_valid_ = true; + this->x_scale_ = 1.0; + this->y_scale_ = 1.0; + } + + /** + * @brief Initialize the decoder, starting a new decoding session. * * @param expected_size Hint about the expected data size (0 if unknown). * @return int Returns 0 on success, a {@see DecodeError} value in case of an error. */ virtual int prepare(size_t expected_size) { + this->reset(); this->expected_size_ = expected_size; + this->active_ = true; return 0; } @@ -103,11 +127,13 @@ class ImageDecoder { } protected: + double x_scale_ = 1.0; + double y_scale_ = 1.0; RuntimeImage *image_; size_t expected_size_ = 0; // Expected data size (0 if unknown) size_t decoded_bytes_ = 0; // Bytes processed so far - double x_scale_ = 1.0; - double y_scale_ = 1.0; + const ImageFormat format_; + bool active_ = false; // A decoding session is in progress bool size_valid_ = true; // Last set_size() result; draw() no-ops while false }; diff --git a/esphome/components/runtime_image/image_format.h b/esphome/components/runtime_image/image_format.h new file mode 100644 index 0000000000..524e52d7bc --- /dev/null +++ b/esphome/components/runtime_image/image_format.h @@ -0,0 +1,19 @@ +#pragma once + +namespace esphome::runtime_image { + +/** + * @brief Image format types that can be decoded dynamically. + */ +enum ImageFormat { + /** Automatically detect from data. Not implemented yet. */ + AUTO, + /** JPEG format. */ + JPEG, + /** PNG format. */ + PNG, + /** BMP format. */ + BMP, +}; + +} // namespace esphome::runtime_image diff --git a/esphome/components/runtime_image/jpeg_decoder.cpp b/esphome/components/runtime_image/jpeg_decoder.cpp index c46e86fd0d..85ec945259 100644 --- a/esphome/components/runtime_image/jpeg_decoder.cpp +++ b/esphome/components/runtime_image/jpeg_decoder.cpp @@ -52,12 +52,6 @@ static int draw_callback(JPEGDRAW *jpeg) { return 1; } -int JpegDecoder::prepare(size_t expected_size) { - ImageDecoder::prepare(expected_size); - // JPEG decoder needs complete data before decoding - return 0; -} - int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) { // JPEG decoder requires complete data // If we know the expected size, wait for it diff --git a/esphome/components/runtime_image/jpeg_decoder.h b/esphome/components/runtime_image/jpeg_decoder.h index ed2401e263..67c9b77f4d 100644 --- a/esphome/components/runtime_image/jpeg_decoder.h +++ b/esphome/components/runtime_image/jpeg_decoder.h @@ -18,10 +18,9 @@ class JpegDecoder : public ImageDecoder { * * @param image The RuntimeImage to decode the stream into. */ - JpegDecoder(RuntimeImage *image) : ImageDecoder(image) {} + JpegDecoder(RuntimeImage *image) : ImageDecoder(image, JPEG) {} ~JpegDecoder() override {} - int prepare(size_t expected_size) override; int HOT decode(uint8_t *buffer, size_t size) override; protected: diff --git a/esphome/components/runtime_image/png_decoder.cpp b/esphome/components/runtime_image/png_decoder.cpp index 9501702711..106f25bbe1 100644 --- a/esphome/components/runtime_image/png_decoder.cpp +++ b/esphome/components/runtime_image/png_decoder.cpp @@ -48,7 +48,7 @@ static void draw_callback(pngle_t *pngle, uint32_t x, uint32_t y, uint32_t w, ui } } -PngDecoder::PngDecoder(RuntimeImage *image) : ImageDecoder(image) { +PngDecoder::PngDecoder(RuntimeImage *image) : ImageDecoder(image, PNG) { { RAMAllocator allocator; pngle_t *pngle = allocator.allocate(1, PNGLE_T_SIZE); @@ -57,8 +57,8 @@ PngDecoder::PngDecoder(RuntimeImage *image) : ImageDecoder(image) { return; } memset(pngle, 0, PNGLE_T_SIZE); - pngle_reset(pngle); this->pngle_ = pngle; + pngle_reset(this->pngle_); } } @@ -71,11 +71,12 @@ PngDecoder::~PngDecoder() { } int PngDecoder::prepare(size_t expected_size) { - ImageDecoder::prepare(expected_size); + // Check before the base prepare() so a failure never leaves an active session if (!this->pngle_) { ESP_LOGE(TAG, "PNG decoder engine not initialized!"); return DECODE_ERROR_OUT_OF_MEMORY; } + ImageDecoder::prepare(expected_size); pngle_set_user_data(this->pngle_, this); pngle_set_init_callback(this->pngle_, init_callback); pngle_set_draw_callback(this->pngle_, draw_callback); diff --git a/esphome/components/runtime_image/png_decoder.h b/esphome/components/runtime_image/png_decoder.h index 24521d33a8..a1cd60e0a6 100644 --- a/esphome/components/runtime_image/png_decoder.h +++ b/esphome/components/runtime_image/png_decoder.h @@ -22,6 +22,14 @@ class PngDecoder : public ImageDecoder { PngDecoder(RuntimeImage *image); ~PngDecoder() override; + void reset() override { + ImageDecoder::reset(); + if (this->pngle_) { + pngle_reset(this->pngle_); + } + this->pixels_decoded_ = 0; + } + int prepare(size_t expected_size) override; int HOT decode(uint8_t *buffer, size_t size) override; diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index 8fe9be4c8c..e269f7d8f3 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -172,33 +172,38 @@ void RuntimeImage::draw(int x, int y, display::Display *display, Color color_on, } bool RuntimeImage::begin_decode(size_t expected_size) { - if (this->decoder_) { + if (this->is_decoding()) { ESP_LOGW(TAG, "Decoding already in progress"); return false; } - this->decoder_ = this->create_decoder_(); + // An idle decoder for a different format cannot be reused + if (this->decoder_ != nullptr && this->decoder_->get_format() != this->format_) { + ESP_LOGD(TAG, "Decoder format mismatch: current: %d, new: %d", this->decoder_->get_format(), this->format_); + this->decoder_ = nullptr; + } + if (!this->decoder_) { - ESP_LOGE(TAG, "Failed to create decoder for format %d", this->format_); - return false; + this->decoder_ = this->create_decoder_(this->format_); + if (!this->decoder_) { + ESP_LOGE(TAG, "Failed to create decoder for format %d", this->format_); + return false; + } } - this->total_size_ = expected_size; this->decoded_bytes_ = 0; - // Initialize decoder int result = this->decoder_->prepare(expected_size); if (result < 0) { ESP_LOGE(TAG, "Failed to prepare decoder: %d", result); - this->decoder_ = nullptr; + this->decoder_ = nullptr; // If prepare fails, a full reset is needed return false; } - return true; } int RuntimeImage::feed_data(uint8_t *data, size_t len) { - if (!this->decoder_) { + if (!this->is_decoding()) { ESP_LOGE(TAG, "No decoder initialized"); return -1; } @@ -212,7 +217,7 @@ int RuntimeImage::feed_data(uint8_t *data, size_t len) { } bool RuntimeImage::end_decode() { - if (!this->decoder_) { + if (!this->is_decoding()) { return false; } @@ -224,26 +229,23 @@ bool RuntimeImage::end_decode() { this->data_start_ = this->buffer_; } - // Clean up decoder - this->decoder_ = nullptr; + // End the session; the decoder object stays warm so the next decode can + // reuse it (and its buffers) without churning the heap. + this->decoder_->reset(); ESP_LOGD(TAG, "Decoding complete: %dx%d, %zu bytes", this->width_, this->height_, this->decoded_bytes_); return true; } -bool RuntimeImage::is_decode_finished() const { - if (!this->decoder_) { - return false; - } - return this->decoder_->is_finished(); -} +bool RuntimeImage::is_decode_finished() const { return this->is_decoding() && this->decoder_->is_finished(); } void RuntimeImage::release() { this->release_buffer_(); - // Reset decoder separately — release() can be called from within the decoder - // (via set_size -> resize -> resize_buffer_), so we must not destroy the decoder here. - // The decoder lifecycle is managed by begin_decode()/end_decode(). - this->decoder_ = nullptr; + // End any active decode session; decoders free the format-specific working buffers + // they can (PNG), while the decoder object itself is kept warm for the next decode. + if (this->decoder_) { + this->decoder_->reset(); + } } void RuntimeImage::release_buffer_() { @@ -347,8 +349,9 @@ size_t RuntimeImage::get_buffer_size(int width, int height) const { int RuntimeImage::get_position_(int x, int y) const { return (x + y * this->buffer_width_) * this->get_bpp() / 8; } -std::unique_ptr RuntimeImage::create_decoder_() { - switch (this->format_) { +std::unique_ptr RuntimeImage::create_decoder_(ImageFormat format) { + ESP_LOGV(TAG, "Creating decoder for format %d", format); + switch (format) { #ifdef USE_RUNTIME_IMAGE_BMP case BMP: return make_unique(this); @@ -362,7 +365,7 @@ std::unique_ptr RuntimeImage::create_decoder_() { return make_unique(this); #endif default: - ESP_LOGE(TAG, "Unsupported image format: %d", this->format_); + ESP_LOGE(TAG, "Unsupported image format: %d", format); return nullptr; } } diff --git a/esphome/components/runtime_image/runtime_image.h b/esphome/components/runtime_image/runtime_image.h index 10ce980be2..cfac253fdb 100644 --- a/esphome/components/runtime_image/runtime_image.h +++ b/esphome/components/runtime_image/runtime_image.h @@ -3,25 +3,11 @@ #include "esphome/components/image/image.h" #include "esphome/core/helpers.h" +#include "image_decoder.h" +#include "image_format.h" + namespace esphome::runtime_image { -// Forward declaration -class ImageDecoder; - -/** - * @brief Image format types that can be decoded dynamically. - */ -enum ImageFormat { - /** Automatically detect from data. Not implemented yet. */ - AUTO, - /** JPEG format. */ - JPEG, - /** PNG format. */ - PNG, - /** BMP format. */ - BMP, -}; - /** * @brief A dynamic image that can be loaded and decoded at runtime. * @@ -99,7 +85,7 @@ class RuntimeImage : public image::Image { /** * @brief Check if decoding is currently in progress. */ - bool is_decoding() const { return this->decoder_ != nullptr; } + bool is_decoding() const { return this->decoder_ != nullptr && this->decoder_->is_active(); } /** * @brief Check if the decoder has finished processing all data. @@ -120,9 +106,10 @@ class RuntimeImage : public image::Image { ImageFormat get_format() const { return this->format_; } /** - * @brief Release the image buffer and free memory. + * @brief Release the image buffer and free its memory, ending any decode session. * - * An external buffer is let go of rather than freed. + * An external buffer is let go of rather than freed. The decoder object is kept + * warm so the next decode can reuse it without churning the heap. */ void release(); @@ -194,9 +181,11 @@ class RuntimeImage : public image::Image { int get_position_(int x, int y) const; /** - * @brief Create decoder instance for the image's format. + * @brief Create decoder instance for the requested format. + * @param format The image format to decode. + * @return Unique pointer to the created decoder, or nullptr on failure. */ - std::unique_ptr create_decoder_(); + std::unique_ptr create_decoder_(ImageFormat format); // Memory management uint8_t *buffer_{nullptr}; @@ -224,7 +213,6 @@ class RuntimeImage : public image::Image { int buffer_height_{0}; // Decoding state - size_t total_size_{0}; size_t decoded_bytes_{0}; /** Fixed width requested on configuration, or 0 if not specified. */ diff --git a/esphome/components/sendspin/image/sendspin_image.cpp b/esphome/components/sendspin/image/sendspin_image.cpp index 626d7966b7..558a292d5b 100644 --- a/esphome/components/sendspin/image/sendspin_image.cpp +++ b/esphome/components/sendspin/image/sendspin_image.cpp @@ -86,8 +86,8 @@ void SendspinImageSlot::on_decode_(const uint8_t *data, size_t length) { } const bool decoded = this->decode_frame_(data, length, target); - // Drops any half-finished decoder. An external buffer is let go of rather than freed, so this is - // safe on every path. + // Ends any half-finished decode session (the decoder object is kept for reuse). An external + // buffer is let go of rather than freed, so this is safe on every path. this->decode_sink_.release(); if (!decoded) { diff --git a/tests/components/runtime_image/__init__.py b/tests/components/runtime_image/__init__.py new file mode 100644 index 0000000000..a8ff4bb68e --- /dev/null +++ b/tests/components/runtime_image/__init__.py @@ -0,0 +1,15 @@ +from esphome.components.runtime_image import enable_format +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # to_code is suppressed in cpptest builds; formats are normally enabled by + # process_runtime_image_config(). Enable all formats so the format-switch + # tests have two decoder types and every retained decoder is under test. + async def to_code_testing(config: ConfigType) -> None: + enable_format("BMP") + enable_format("PNG") + enable_format("JPEG") + + manifest.to_code = to_code_testing diff --git a/tests/components/runtime_image/test_decoder_reuse.cpp b/tests/components/runtime_image/test_decoder_reuse.cpp new file mode 100644 index 0000000000..87e77b00be --- /dev/null +++ b/tests/components/runtime_image/test_decoder_reuse.cpp @@ -0,0 +1,336 @@ +#include +#include + +#include +#include +#include +#include + +#include "esphome/components/runtime_image/image_decoder.h" +#include "esphome/components/runtime_image/runtime_image.h" + +namespace esphome::runtime_image::testing { + +// 3x2 24bpp BMP, every pixel a unique color (rows padded to 4 bytes) +static const uint8_t BMP_24BPP[] = { + 0x42, 0x4D, 0x4E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x36, 0x00, 0x00, 0x00, 0x28, 0x00, + 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x18, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x18, 0x00, 0x00, 0x00, 0xC4, 0x0E, 0x00, 0x00, 0xC4, 0x0E, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x33, 0x22, 0x11, 0x77, 0x88, 0x99, 0xEF, 0xCD, 0xAB, 0x00, + 0x00, 0x00, 0x20, 0x10, 0xE0, 0x40, 0xC0, 0x30, 0xA0, 0x60, 0x50, 0x00, 0x00, 0x00, +}; + +static const uint8_t BMP_24BPP_EXPECTED[2][3][3] = { + {{0xE0, 0x10, 0x20}, {0x30, 0xC0, 0x40}, {0x50, 0x60, 0xA0}}, + {{0x11, 0x22, 0x33}, {0x99, 0x88, 0x77}, {0xAB, 0xCD, 0xEF}}, +}; + +// 3x2 8bpp BMP with a 4-entry color table +static const uint8_t BMP_8BPP[] = { + 0x42, 0x4D, 0x4E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x00, 0x00, 0x00, 0x28, 0x00, + 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x08, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x13, 0x0B, 0x00, 0x00, 0x13, 0x0B, 0x00, 0x00, 0x04, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x20, 0x10, 0x00, 0xD0, 0xE0, 0xF0, 0x00, 0x00, 0xFF, + 0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0x03, 0x02, 0x01, 0x00, 0x00, 0x01, 0x02, 0x00, +}; + +static const uint8_t BMP_8BPP_EXPECTED[2][3][3] = { + {{0x10, 0x20, 0x30}, {0xF0, 0xE0, 0xD0}, {0x00, 0xFF, 0x00}}, + {{0xFF, 0x00, 0xFF}, {0x00, 0xFF, 0x00}, {0xF0, 0xE0, 0xD0}}, +}; + +// 3x2 8bpp BMP with an 8-entry color table, all colors distinct from BMP_8BPP's +static const uint8_t BMP_8BPP_BIG[] = { + 0x42, 0x4D, 0x5E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x56, 0x00, 0x00, 0x00, 0x28, 0x00, 0x00, 0x00, 0x03, + 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, + 0x13, 0x0B, 0x00, 0x00, 0x13, 0x0B, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x28, 0x18, 0x08, + 0x00, 0xA8, 0xB8, 0xC8, 0x00, 0xFF, 0x80, 0x00, 0x00, 0x00, 0xFF, 0x80, 0x00, 0x00, 0x80, 0xFF, 0x00, 0x55, 0x99, + 0x11, 0x00, 0xCC, 0x00, 0x66, 0x00, 0x44, 0x22, 0xEE, 0x00, 0x01, 0x06, 0x04, 0x00, 0x07, 0x05, 0x03, 0x00, +}; + +static const uint8_t BMP_8BPP_BIG_EXPECTED[2][3][3] = { + {{0xEE, 0x22, 0x44}, {0x11, 0x99, 0x55}, {0x80, 0xFF, 0x00}}, + {{0xC8, 0xB8, 0xA8}, {0x66, 0x00, 0xCC}, {0xFF, 0x80, 0x00}}, +}; + +// 4x4 RGB PNG, every pixel a unique color +static const uint8_t PNG_RGB[] = { + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, + 0x04, 0x00, 0x00, 0x00, 0x04, 0x08, 0x02, 0x00, 0x00, 0x00, 0x26, 0x93, 0x09, 0x29, 0x00, 0x00, 0x00, 0x38, 0x49, + 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x60, 0x64, 0x62, 0x16, 0x50, 0x30, 0x58, 0xB0, 0xE1, 0xC0, 0xFF, 0xFF, 0x0C, + 0x0C, 0x0E, 0x0C, 0x50, 0xEC, 0xE0, 0xE0, 0xC0, 0x50, 0xCF, 0xF0, 0x9F, 0xA1, 0xFE, 0xFF, 0xFF, 0x7A, 0x86, 0xFA, + 0xFF, 0x0C, 0x0C, 0x42, 0x26, 0x61, 0xA9, 0xCE, 0x8A, 0xFF, 0xEE, 0xEC, 0x5A, 0x7D, 0xF6, 0x3D, 0x00, 0x81, 0xCB, + 0x12, 0x4D, 0xB3, 0xFB, 0xD4, 0xE1, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, +}; + +static const uint8_t PNG_RGB_EXPECTED[4][4][3] = { + {{0x01, 0x02, 0x03}, {0x10, 0x20, 0x30}, {0xA0, 0xB0, 0xC0}, {0xFF, 0xFF, 0x00}}, + {{0x40, 0x00, 0x00}, {0x00, 0x40, 0x00}, {0x00, 0x00, 0x40}, {0x40, 0x40, 0x40}}, + {{0x7F, 0x00, 0xFF}, {0x00, 0x7F, 0xFF}, {0xFF, 0x7F, 0x00}, {0x7F, 0xFF, 0x00}}, + {{0x12, 0x34, 0x56}, {0x65, 0x43, 0x21}, {0xFE, 0xDC, 0xBA}, {0xAB, 0xCD, 0xEF}}, +}; + +/// Exposes the protected decoder machinery so reuse and eviction can be observed directly. +class TestableRuntimeImage : public RuntimeImage { + public: + explicit TestableRuntimeImage(ImageFormat format) + : RuntimeImage(format, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE, nullptr, false, 0, 0) {} + + ImageDecoder *decoder() { return this->decoder_.get(); } + + /// Simulates the state a dynamic-format producer (PR #16337) would leave behind: + /// a cached decoder whose format no longer matches the image's format. + /// TODO: once #16337 adds a public way to change the format, drive the mismatch + /// through it and delete this seam. + void plant_decoder(ImageFormat format) { this->decoder_ = this->create_decoder_(format); } +}; + +/// Runs one full decode session. Returns true when every stage succeeded. +static bool decode_all(TestableRuntimeImage &img, const uint8_t *data, size_t len) { + std::vector buffer(data, data + len); // feed_data needs mutable bytes + if (!img.begin_decode(len)) { + return false; + } + size_t offset = 0; + while (offset < len) { + int consumed = img.feed_data(buffer.data() + offset, len - offset); + if (consumed <= 0) { + return false; // decode error, or no progress despite full data + } + offset += consumed; + } + return img.end_decode(); +} + +/// Feeds the image the way online_image's download loop does: append a small +/// chunk to a window, feed the window, drop what was consumed, repeat. A zero +/// return mid-stream means "need more data" and grows the window. +static bool decode_chunked(TestableRuntimeImage &img, const uint8_t *data, size_t len, size_t chunk_size) { + if (!img.begin_decode(len)) { + return false; + } + std::vector window; + size_t supplied = 0; + while (supplied < len || !window.empty()) { + if (supplied < len) { + size_t take = std::min(chunk_size, len - supplied); + window.insert(window.end(), data + supplied, data + supplied + take); + supplied += take; + } + int consumed = img.feed_data(window.data(), window.size()); + if (consumed < 0 || (consumed == 0 && supplied >= len)) { + return false; // decode error, or stuck with all data supplied + } + window.erase(window.begin(), window.begin() + consumed); + } + return img.end_decode(); +} + +template static void expect_pixels(TestableRuntimeImage &img, const uint8_t (&expected)[H][W][3]) { + ASSERT_EQ(img.get_width(), static_cast(W)); + ASSERT_EQ(img.get_height(), static_cast(H)); + for (size_t y = 0; y < H; y++) { + for (size_t x = 0; x < W; x++) { + SCOPED_TRACE(::testing::Message() << "pixel (" << x << "," << y << ")"); + Color color = img.get_pixel(x, y); + EXPECT_THAT((std::array{color.r, color.g, color.b}), ::testing::ElementsAreArray(expected[y][x])); + } + } +} + +TEST(RuntimeImageDecoder, DecoderStaysWarmAcrossDecodes) { + TestableRuntimeImage img(BMP); + + ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP))); + expect_pixels(img, BMP_24BPP_EXPECTED); + ImageDecoder *first = img.decoder(); + ASSERT_NE(first, nullptr); + + ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP))); + expect_pixels(img, BMP_24BPP_EXPECTED); + EXPECT_EQ(img.decoder(), first) << "decoder must be reused, not reallocated"; +} + +TEST(RuntimeImageDecoder, SecondDecodeStartsClean) { + TestableRuntimeImage img(BMP); + + // Palettized decode, then a 24bpp decode, then palettized again, all on the + // same decoder: each session must produce correct pixels for its own image. + ASSERT_TRUE(decode_all(img, BMP_8BPP, sizeof(BMP_8BPP))); + expect_pixels(img, BMP_8BPP_EXPECTED); + ImageDecoder *first = img.decoder(); + + ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP))); + expect_pixels(img, BMP_24BPP_EXPECTED); + EXPECT_EQ(img.decoder(), first); + + ASSERT_TRUE(decode_all(img, BMP_8BPP, sizeof(BMP_8BPP))); + expect_pixels(img, BMP_8BPP_EXPECTED); + EXPECT_EQ(img.decoder(), first); +} + +TEST(RuntimeImageDecoder, ColorTableGrowsAndShrinksAcrossReuse) { + TestableRuntimeImage img(BMP); + + // Small palette first: the retained table is allocated at 4 entries. + ASSERT_TRUE(decode_all(img, BMP_8BPP, sizeof(BMP_8BPP))); + expect_pixels(img, BMP_8BPP_EXPECTED); + ImageDecoder *first = img.decoder(); + + // Growing to 8 entries on the reused decoder must reallocate, not overflow. + ASSERT_TRUE(decode_all(img, BMP_8BPP_BIG, sizeof(BMP_8BPP_BIG))); + expect_pixels(img, BMP_8BPP_BIG_EXPECTED); + EXPECT_EQ(img.decoder(), first); + + // Shrinking back must not surface stale colors from the larger table. + ASSERT_TRUE(decode_all(img, BMP_8BPP, sizeof(BMP_8BPP))); + expect_pixels(img, BMP_8BPP_EXPECTED); + EXPECT_EQ(img.decoder(), first); +} + +TEST(RuntimeImageDecoder, ChunkedFeedDecodesLikeDownloadLoop) { + TestableRuntimeImage img(BMP); + + ASSERT_TRUE(decode_chunked(img, BMP_24BPP, sizeof(BMP_24BPP), 16)); + expect_pixels(img, BMP_24BPP_EXPECTED); + ImageDecoder *first = img.decoder(); + + // Chunked again on the warm decoder: the cross-call resume state + // (current_index_ / paint_index_) must have been fully reset. + ASSERT_TRUE(decode_chunked(img, BMP_24BPP, sizeof(BMP_24BPP), 16)); + expect_pixels(img, BMP_24BPP_EXPECTED); + EXPECT_EQ(img.decoder(), first); +} + +TEST(RuntimeImageDecoder, FormatSwitchEvictsMismatchedDecoder) { + // PNG image holding a stale BMP decoder: begin_decode must evict and recreate. + TestableRuntimeImage png_img(PNG); + png_img.plant_decoder(BMP); + ASSERT_NE(png_img.decoder(), nullptr); + ASSERT_EQ(png_img.decoder()->get_format(), BMP); + + ASSERT_TRUE(decode_all(png_img, PNG_RGB, sizeof(PNG_RGB))); + EXPECT_EQ(png_img.decoder()->get_format(), PNG); + expect_pixels(png_img, PNG_RGB_EXPECTED); + + // And the other direction: BMP image holding a stale PNG decoder. + TestableRuntimeImage bmp_img(BMP); + bmp_img.plant_decoder(PNG); + ASSERT_NE(bmp_img.decoder(), nullptr); + ASSERT_EQ(bmp_img.decoder()->get_format(), PNG); + + ASSERT_TRUE(decode_all(bmp_img, BMP_24BPP, sizeof(BMP_24BPP))); + EXPECT_EQ(bmp_img.decoder()->get_format(), BMP); + expect_pixels(bmp_img, BMP_24BPP_EXPECTED); +} + +TEST(RuntimeImageDecoder, ReleaseKeepsDecoderWarm) { + TestableRuntimeImage img(PNG); + + ASSERT_TRUE(decode_all(img, PNG_RGB, sizeof(PNG_RGB))); + ImageDecoder *first = img.decoder(); + ASSERT_NE(first, nullptr); + + img.release(); + EXPECT_EQ(img.decoder(), first) << "release() must keep the decoder for reuse"; + EXPECT_FALSE(img.is_decoding()); + EXPECT_EQ(img.get_width(), 0); + EXPECT_EQ(img.get_height(), 0); + + ASSERT_TRUE(decode_all(img, PNG_RGB, sizeof(PNG_RGB))); + expect_pixels(img, PNG_RGB_EXPECTED); + EXPECT_EQ(img.decoder(), first); +} + +TEST(RuntimeImageDecoder, FailedDecodeRecovers) { + TestableRuntimeImage img(BMP); + + uint8_t garbage[32]; + memset(garbage, 'X', sizeof(garbage)); + ASSERT_TRUE(img.begin_decode(sizeof(garbage))); + EXPECT_LT(img.feed_data(garbage, sizeof(garbage)), 0) << "garbage must fail to decode"; + img.release(); + + ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP))); + expect_pixels(img, BMP_24BPP_EXPECTED); +} + +#ifdef USE_RUNTIME_IMAGE_JPEG +// 8x8 gradient JPEG (quality 90). JPEG is lossy, so the test asserts that a +// reused decoder reproduces the exact same pixels, not absolute colors. +static const uint8_t JPEG_GRADIENT[] = { + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, + 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x03, 0x02, 0x02, 0x03, 0x02, 0x02, 0x03, 0x03, 0x03, 0x03, 0x04, 0x03, 0x03, + 0x04, 0x05, 0x08, 0x05, 0x05, 0x04, 0x04, 0x05, 0x0A, 0x07, 0x07, 0x06, 0x08, 0x0C, 0x0A, 0x0C, 0x0C, 0x0B, 0x0A, + 0x0B, 0x0B, 0x0D, 0x0E, 0x12, 0x10, 0x0D, 0x0E, 0x11, 0x0E, 0x0B, 0x0B, 0x10, 0x16, 0x10, 0x11, 0x13, 0x14, 0x15, + 0x15, 0x15, 0x0C, 0x0F, 0x17, 0x18, 0x16, 0x14, 0x18, 0x12, 0x14, 0x15, 0x14, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x03, + 0x04, 0x04, 0x05, 0x04, 0x05, 0x09, 0x05, 0x05, 0x09, 0x14, 0x0D, 0x0B, 0x0D, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, + 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, + 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, + 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x00, 0x08, 0x00, 0x08, 0x03, 0x01, 0x22, 0x00, + 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, + 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, + 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, + 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, + 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, + 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, + 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, + 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, + 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, + 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, + 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xC4, 0x00, 0x1F, 0x01, 0x00, + 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, + 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x11, 0x00, 0x02, 0x01, 0x02, 0x04, 0x04, + 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, + 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09, + 0x23, 0x33, 0x52, 0xF0, 0x15, 0x62, 0x72, 0xD1, 0x0A, 0x16, 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, + 0x26, 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, + 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, + 0x76, 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, + 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, + 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, + 0xD9, 0xDA, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, + 0xFA, 0xFF, 0xDA, 0x00, 0x0C, 0x03, 0x01, 0x00, 0x02, 0x11, 0x03, 0x11, 0x00, 0x3F, 0x00, 0xE5, 0x3E, 0x0B, 0xFE, + 0xC8, 0x7F, 0xEA, 0x3F, 0xD0, 0xBD, 0x3F, 0x86, 0x8A, 0x28, 0xAA, 0xC2, 0x62, 0x6A, 0xFB, 0x25, 0xA9, 0xD5, 0xC0, + 0x7C, 0x6B, 0x9D, 0x7F, 0x62, 0xD3, 0xFD, 0xEF, 0xF5, 0xF7, 0x9F, 0xFF, 0xD9, +}; + +static std::vector pixel_bytes(TestableRuntimeImage &img) { + const uint8_t *start = img.get_data_start(); + return std::vector(start, start + img.get_width_stride() * img.get_height()); +} + +TEST(RuntimeImageDecoder, JpegDecoderStaysWarmAcrossDecodes) { + TestableRuntimeImage img(JPEG); + + ASSERT_TRUE(decode_all(img, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + ASSERT_EQ(img.get_width(), 8); + ASSERT_EQ(img.get_height(), 8); + std::vector first_pixels = pixel_bytes(img); + ImageDecoder *first = img.decoder(); + ASSERT_NE(first, nullptr); + + ASSERT_TRUE(decode_all(img, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + EXPECT_EQ(img.decoder(), first); + EXPECT_EQ(pixel_bytes(img), first_pixels) << "reused decoder must reproduce identical pixels"; +} +#endif // USE_RUNTIME_IMAGE_JPEG + +TEST(RuntimeImageDecoder, SessionFlagsTrackLifecycle) { + TestableRuntimeImage img(BMP); + std::vector buffer(BMP_24BPP, BMP_24BPP + sizeof(BMP_24BPP)); + + ASSERT_TRUE(img.begin_decode(buffer.size())); + EXPECT_TRUE(img.is_decoding()); + EXPECT_FALSE(img.is_decode_finished()); + + ASSERT_EQ(img.feed_data(buffer.data(), buffer.size()), static_cast(buffer.size())); + EXPECT_TRUE(img.is_decode_finished()) << "all pixel data consumed"; + + ASSERT_TRUE(img.end_decode()); + EXPECT_FALSE(img.is_decoding()) << "end_decode() must close the session"; + EXPECT_FALSE(img.is_decode_finished()) << "no session means nothing is 'finished'"; +} + +} // namespace esphome::runtime_image::testing From 46f90d0c54af2ce42af6aa55dcdcdbaf2717e5ae Mon Sep 17 00:00:00 2001 From: guillempages Date: Fri, 21 Aug 2026 00:28:17 +0200 Subject: [PATCH 1613/1815] [core] Add portable strcasestr implementation named str_contains_ignore_case (#18497) Co-authored-by: J. Nick Koston --- esphome/components/audio/audio.cpp | 2 +- esphome/core/helpers.cpp | 13 ++++++++ esphome/core/helpers.h | 19 +++++++++++ tests/components/core/helpers_test.cpp | 46 ++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 1 deletion(-) diff --git a/esphome/components/audio/audio.cpp b/esphome/components/audio/audio.cpp index b0aa3c1abb..402e741059 100644 --- a/esphome/components/audio/audio.cpp +++ b/esphome/components/audio/audio.cpp @@ -86,7 +86,7 @@ AudioFileType detect_audio_file_type(const char *content_type, const char *url) // Match "audio/ogg" with a codecs parameter containing "opus" // Valid forms: audio/ogg;codecs=opus, audio/ogg; codecs="opus", etc. // Plain "audio/ogg" without opus is not matched (almost always Ogg Vorbis) - if (strncasecmp(content_type, "audio/ogg", 9) == 0 && strcasestr(content_type + 9, "opus") != nullptr) { + if (strncasecmp(content_type, "audio/ogg", 9) == 0 && str_contains_ignore_case(content_type + 9, "opus")) { return AudioFileType::OPUS; } #endif diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index bd08d3b63e..a276020be4 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -220,6 +220,19 @@ bool str_endswith_ignore_case(const char *str, size_t str_len, const char *suffi return strncasecmp(str + str_len - suffix_len, suffix, suffix_len) == 0; } +bool str_contains_ignore_case_fallback(const char *haystack, const char *needle) { + const size_t needle_len = strlen(needle); + if (needle_len == 0) { + return true; + } + for (const char *p = haystack; *p != '\0'; p++) { + if (strncasecmp(p, needle, needle_len) == 0) { + return true; + } + } + return false; +} + // str_truncate, str_until, str_lower_case, str_upper_case, str_snake_case moved to alloc_helpers.cpp char *str_sanitize_to(char *buffer, size_t buffer_size, const char *str) { if (buffer_size == 0) { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 994fa2c26a..5a9c120b84 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -981,6 +981,25 @@ inline bool str_endswith_ignore_case(const std::string &str, const char *suffix) return str_endswith_ignore_case(str.c_str(), str.size(), suffix, strlen(suffix)); } +/// Fallback implementation for case insensitive substring comparison. +bool str_contains_ignore_case_fallback(const char *haystack, const char *needle); + +/// Case-insensitive check if needle string is contained in haystack (no heap allocation). +inline bool str_contains_ignore_case(const char *haystack, const char *needle) { + if (!needle || !haystack) { + return false; + } + +// strcasestr is a GNU extension: newlib only declares it when _GNU_SOURCE is set. +// ESP32/ESP8266/host builds get it from their framework or from g++ on Linux; +// LibreTiny, RP2 and Zephyr do not, so they use the hand-rolled fallback. +#if defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_ZEPHYR) + return str_contains_ignore_case_fallback(haystack, needle); +#else // defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_ZEPHYR) + return strcasestr(haystack, needle) != nullptr; +#endif // defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_ZEPHYR) +} + // str_truncate moved to alloc_helpers.h - remove this include before 2026.11.0 // str_until, str_lower_case, str_upper_case moved to alloc_helpers.h - remove this comment before 2026.11.0 diff --git a/tests/components/core/helpers_test.cpp b/tests/components/core/helpers_test.cpp index a9a940392f..d5219f9d47 100644 --- a/tests/components/core/helpers_test.cpp +++ b/tests/components/core/helpers_test.cpp @@ -83,4 +83,50 @@ TEST(StaticVectorTest, ConvertingConstructorSameSize) { EXPECT_EQ(dst[2], 3); } +TEST(StringContainsIgnoreCaseTest, NullPointerAlwaysFalse) { + const char *haystack = nullptr; + const char *needle = nullptr; + + EXPECT_FALSE(str_contains_ignore_case(haystack, needle)); + EXPECT_FALSE(str_contains_ignore_case("Hello World", needle)); + EXPECT_FALSE(str_contains_ignore_case(haystack, "anything")); +} + +TEST(StringContainsIgnoreCaseTest, EmptySearchMatches) { + const char *haystack = "Hello World"; + + EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "")); +} + +TEST(StringContainsIgnoreCaseTest, MiscCaseMatches) { + const char *haystack = "Hello World"; + + EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "Hello")); + EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "hello")); + EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "HELLO")); + EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "hELLO")); +} + +TEST(StringContainsIgnoreCaseTest, MiscNotMatching) { + const char *haystack = "Hello World"; + + // Expected to match + EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "Hell")); + + // Expected not to match + EXPECT_FALSE(str_contains_ignore_case_fallback(haystack, "Heaven")); + EXPECT_FALSE(str_contains_ignore_case_fallback(haystack, "Hello!")); + EXPECT_FALSE(str_contains_ignore_case_fallback(haystack, "world!")); +} + +TEST(StringContainsIgnoreCaseTest, FallbackMatchesLibc) { + const char *haystack = "Hello World"; + for (const char *needle : {"", "Hello", "hELLO", "Hell", "world", "Heaven", "Hello!", "d"}) { + EXPECT_EQ(str_contains_ignore_case_fallback(haystack, needle), str_contains_ignore_case(haystack, needle)) + << "needle: " << needle; + } + EXPECT_EQ(str_contains_ignore_case_fallback("", ""), str_contains_ignore_case("", "")); + EXPECT_EQ(str_contains_ignore_case_fallback("ab", "abc"), str_contains_ignore_case("ab", "abc")); +} + } // namespace esphome From 52bfc0efb1c574324910c5d0c1de628a4bcc1147 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 18:06:23 -0500 Subject: [PATCH 1614/1815] [espnow] Fix dump_config crash when enable_on_boot is false (#18572) --- esphome/components/espnow/espnow_component.cpp | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index df9a1b8668..ecf0f79e4a 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -129,14 +129,17 @@ void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int ESPNowComponent::ESPNowComponent() { global_esp_now = this; } void ESPNowComponent::dump_config() { - uint32_t version = 0; - esp_now_get_version(&version); - ESP_LOGCONFIG(TAG, "espnow:"); - if (this->is_disabled()) { - ESP_LOGCONFIG(TAG, " Disabled"); + // Only report driver details once enabled; with enable_on_boot: false the + // Wi-Fi driver is not initialized yet and esp_now_get_version() would crash, + // and after a failed enable_() the values would be meaningless. + if (this->state_ != ESPNOW_STATE_ENABLED) { + // OFF here means enable_() failed; the core logs the FAILED marker separately + ESP_LOGCONFIG(TAG, " %s", this->is_disabled() ? LOG_STR_LITERAL("Disabled") : LOG_STR_LITERAL("Not enabled")); return; } + uint32_t version = 0; + esp_now_get_version(&version); char own_addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(this->own_address_, own_addr_buf); ESP_LOGCONFIG(TAG, From 7957808f00eec1eac78e40cd59dac8815ae7c55d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:57:58 +0200 Subject: [PATCH 1615/1815] [emontx] Fix sensor state_class defaults not being applied correctly (#17610) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 --- esphome/components/emontx/sensor/__init__.py | 63 ++++++----- tests/component_tests/emontx/__init__.py | 0 .../emontx/test_sensor_defaults.py | 100 ++++++++++++++++++ tests/components/emontx/test.esp32-idf.yaml | 3 +- tests/components/emontx/test.esp8266-ard.yaml | 3 +- tests/components/emontx/test.rp2040-ard.yaml | 3 +- .../components/emontx/validate.esp32-idf.yaml | 73 +++++++++++++ 7 files changed, 213 insertions(+), 32 deletions(-) create mode 100644 tests/component_tests/emontx/__init__.py create mode 100644 tests/component_tests/emontx/test_sensor_defaults.py create mode 100644 tests/components/emontx/validate.esp32-idf.yaml diff --git a/esphome/components/emontx/sensor/__init__.py b/esphome/components/emontx/sensor/__init__.py index 83a972c5e0..967bc4e699 100644 --- a/esphome/components/emontx/sensor/__init__.py +++ b/esphome/components/emontx/sensor/__init__.py @@ -68,6 +68,7 @@ PATTERN_CONFIGS = { "PULSE": { CONF_UNIT_OF_MEASUREMENT: UNIT_PULSES, CONF_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + CONF_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, CONF_ACCURACY_DECIMALS: 0, }, "PF": { @@ -78,12 +79,13 @@ PATTERN_CONFIGS = { }, } -# Create a base schema that's flexible for any tag -BASE_SCHEMA = sensor.sensor_schema( - EmonTxSensor, - state_class=STATE_CLASS_MEASUREMENT, - accuracy_decimals=0, -).extend( +# BASE_SCHEMA intentionally omits state_class and accuracy_decimals defaults. +# Passing them to sensor_schema() would register them via cv.Optional(key, default=...), +# making them always present in the validated config dict and preventing +# apply_tag_defaults from overriding them with the correct per-prefix values. +# They are injected by apply_tag_defaults below, after running through +# sensor.validate_state_class() so the value is code-generation-ready. +BASE_SCHEMA = sensor.sensor_schema(EmonTxSensor).extend( { cv.GenerateID(CONF_EMONTX_ID): cv.use_id(EmonTx), cv.Required(CONF_TAG_NAME): cv.string, @@ -91,34 +93,43 @@ BASE_SCHEMA = sensor.sensor_schema( ) +def _apply_defaults(config: ConfigType, defaults: dict) -> None: + """Inject defaults into config, skipping keys already set by the user. + state_class values are run through validate_state_class so they are + code-generation-ready, matching what sensor_schema() would normally do.""" + for key, value in defaults.items(): + if key not in config: + if key == CONF_STATE_CLASS: + value = sensor.validate_state_class(value) + config[key] = value + + def apply_tag_defaults(config: ConfigType) -> ConfigType: """Apply defaults based on tag prefix if applicable, but don't restrict any tags.""" tag = config[CONF_TAG_NAME] - # Skip if tag is too short - if len(tag) < 2: - return config + if len(tag) >= 2: + tag_upper = tag.upper() - # Check if this tag starts with a known prefix - tag_upper = tag.upper() + for pattern, pattern_config in PATTERN_CONFIGS.items(): + if tag_upper.startswith(pattern): + _apply_defaults(config, pattern_config) + return config - for pattern, pattern_config in PATTERN_CONFIGS.items(): - if tag_upper.startswith(pattern): - # Apply pattern defaults if not overridden by user - for key, value in pattern_config.items(): - if key not in config: - config[key] = value + # Only apply defaults for known prefixes with numeric indices (e.g. E1, V2, T3) + prefix = tag_upper[0] + if prefix in SENSOR_CONFIGS and tag[1:].isdigit(): + _apply_defaults(config, SENSOR_CONFIGS[prefix]) return config - # Only apply defaults for known prefixes with numeric indices - prefix = tag_upper[0] - if prefix in SENSOR_CONFIGS and len(tag) > 1 and tag[1:].isdigit(): - # Apply defaults for known tag types, but only if not overridden by user - defaults = SENSOR_CONFIGS[prefix] - for key, value in defaults.items(): - if key not in config: - config[key] = value - + # Fall back to generic defaults for tags with no known prefix + _apply_defaults( + config, + { + CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT, + CONF_ACCURACY_DECIMALS: 0, + }, + ) return config diff --git a/tests/component_tests/emontx/__init__.py b/tests/component_tests/emontx/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/emontx/test_sensor_defaults.py b/tests/component_tests/emontx/test_sensor_defaults.py new file mode 100644 index 0000000000..00d24d282e --- /dev/null +++ b/tests/component_tests/emontx/test_sensor_defaults.py @@ -0,0 +1,100 @@ +"""Tests for emontx sensor tag defaults.""" + +import pytest + +from esphome.components import sensor +from esphome.components.emontx.sensor import CONFIG_SCHEMA, apply_tag_defaults +from esphome.const import ( + CONF_ACCURACY_DECIMALS, + CONF_STATE_CLASS, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) + + +def _resolve_via_config_schema(tag: str) -> dict: + """Run a minimal config through the real CONFIG_SCHEMA pipeline, the + same path a user's YAML goes through.""" + return CONFIG_SCHEMA( + {"tag_name": tag, "emontx_id": "my_emontx", "name": f"{tag} sensor"} + ) + + +def test_config_schema_applies_tag_default_state_class(): + """If sensor_schema(state_class=...) is reintroduced, the schema-level + default wins over apply_tag_defaults' per-prefix value, and E1 would + resolve to measurement instead of total_increasing. Driving the real + CONFIG_SCHEMA (not just apply_tag_defaults) catches that, since + sensor_schema() runs before apply_tag_defaults in the cv.All() chain. + """ + result = _resolve_via_config_schema("E1") + assert result[CONF_STATE_CLASS] == sensor.validate_state_class( + STATE_CLASS_TOTAL_INCREASING + ) + + +def test_config_schema_applies_tag_default_accuracy_decimals(): + """Same root cause as the state_class regression: reintroducing + sensor_schema(accuracy_decimals=...) would make V1 resolve to the + schema-level default instead of the prefix-specific value of 2. + """ + result = _resolve_via_config_schema("V1") + assert result[CONF_ACCURACY_DECIMALS] == 2 + + +def _make_config(tag: str) -> dict: + """Minimal config dict with only tag_name set — no overrides.""" + return {"tag_name": tag} + + +@pytest.mark.parametrize( + ("tag", "expected_state_class", "expected_decimals"), + [ + # Known numeric-index prefixes + ("E1", STATE_CLASS_TOTAL_INCREASING, 0), + ("E12", STATE_CLASS_TOTAL_INCREASING, 0), + ("P1", STATE_CLASS_MEASUREMENT, 0), + ("V1", STATE_CLASS_MEASUREMENT, 2), + ("I1", STATE_CLASS_MEASUREMENT, 2), + ("T1", STATE_CLASS_MEASUREMENT, 2), + # Known patterns + ("PULSE1", STATE_CLASS_TOTAL_INCREASING, 0), + ("PULSE12", STATE_CLASS_TOTAL_INCREASING, 0), + ("PF1", STATE_CLASS_MEASUREMENT, 2), + # Unknown / free-form tags fall back to generic defaults + ("CUSTOM1", STATE_CLASS_MEASUREMENT, 0), + ("X", STATE_CLASS_MEASUREMENT, 0), + ], +) +def test_apply_tag_defaults(tag, expected_state_class, expected_decimals): + """apply_tag_defaults must inject the correct state_class and accuracy_decimals + for each tag type when no user overrides are present.""" + config = _make_config(tag) + result = apply_tag_defaults(config) + + assert result[CONF_STATE_CLASS] == sensor.validate_state_class(expected_state_class) + assert result[CONF_ACCURACY_DECIMALS] == expected_decimals + + +@pytest.mark.parametrize( + ("tag", "user_state_class", "user_decimals"), + [ + # User overrides must not be clobbered by defaults + ("E1", STATE_CLASS_MEASUREMENT, 3), + ("PULSE1", STATE_CLASS_MEASUREMENT, 1), + ("V1", STATE_CLASS_TOTAL_INCREASING, 0), + ("CUSTOM1", STATE_CLASS_TOTAL_INCREASING, 4), + ], +) +def test_apply_tag_defaults_respects_user_overrides( + tag, user_state_class, user_decimals +): + """apply_tag_defaults must not overwrite values already set by the user.""" + config = _make_config(tag) + config[CONF_STATE_CLASS] = sensor.validate_state_class(user_state_class) + config[CONF_ACCURACY_DECIMALS] = user_decimals + + result = apply_tag_defaults(config) + + assert result[CONF_STATE_CLASS] == sensor.validate_state_class(user_state_class) + assert result[CONF_ACCURACY_DECIMALS] == user_decimals diff --git a/tests/components/emontx/test.esp32-idf.yaml b/tests/components/emontx/test.esp32-idf.yaml index a0784fcd53..e56b1bda5d 100644 --- a/tests/components/emontx/test.esp32-idf.yaml +++ b/tests/components/emontx/test.esp32-idf.yaml @@ -1,4 +1,3 @@ packages: uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml - -<<: !include common.yaml + emontx: !include common.yaml diff --git a/tests/components/emontx/test.esp8266-ard.yaml b/tests/components/emontx/test.esp8266-ard.yaml index 80a2cb2fc0..9ec9377437 100644 --- a/tests/components/emontx/test.esp8266-ard.yaml +++ b/tests/components/emontx/test.esp8266-ard.yaml @@ -1,4 +1,3 @@ packages: uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml - -<<: !include common.yaml + emontx: !include common.yaml diff --git a/tests/components/emontx/test.rp2040-ard.yaml b/tests/components/emontx/test.rp2040-ard.yaml index 410c579d4b..6f4952d8e5 100644 --- a/tests/components/emontx/test.rp2040-ard.yaml +++ b/tests/components/emontx/test.rp2040-ard.yaml @@ -1,4 +1,3 @@ packages: uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml - -<<: !include common.yaml + emontx: !include common.yaml diff --git a/tests/components/emontx/validate.esp32-idf.yaml b/tests/components/emontx/validate.esp32-idf.yaml new file mode 100644 index 0000000000..7caee78a07 --- /dev/null +++ b/tests/components/emontx/validate.esp32-idf.yaml @@ -0,0 +1,73 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + emontx: !include common.yaml + +# Validate that each sensor type gets the correct default state_class, +# unit_of_measurement, device_class, and accuracy_decimals when NO overrides +# are provided. The values are intentionally omitted so apply_tag_defaults is +# exercised, not the user-override path. + +sensor: + # Energy sensor (E prefix): expects state_class=total_increasing, unit=Wh, + # device_class=energy, accuracy_decimals=0 + - platform: emontx + tag_name: E1 + name: Energy 1 + emontx_id: test_emontx + + # Power sensor (P prefix): expects state_class=measurement, unit=W, + # device_class=power, accuracy_decimals=0 + - platform: emontx + tag_name: P1 + name: Power 1 + emontx_id: test_emontx + + # Voltage sensor (V prefix): expects state_class=measurement, unit=V, + # device_class=voltage, accuracy_decimals=2 + - platform: emontx + tag_name: V1 + name: Voltage 1 + emontx_id: test_emontx + + # Current sensor (I prefix): expects state_class=measurement, unit=A, + # device_class=current, accuracy_decimals=2 + - platform: emontx + tag_name: I1 + name: Current 1 + emontx_id: test_emontx + + # Temperature sensor (T prefix): expects state_class=measurement, unit=°C, + # device_class=temperature, accuracy_decimals=2 + - platform: emontx + tag_name: T1 + name: Temperature 1 + emontx_id: test_emontx + + # Pulse sensor (PULSE pattern): expects state_class=total_increasing, + # unit=pulses, device_class=energy, accuracy_decimals=0 + - platform: emontx + tag_name: PULSE1 + name: Pulse 1 + emontx_id: test_emontx + + # Power factor sensor (PF pattern): expects state_class=measurement, + # device_class=power_factor, accuracy_decimals=2 + - platform: emontx + tag_name: PF1 + name: Power Factor 1 + emontx_id: test_emontx + + # Unknown tag: no prefix match, falls back to state_class=measurement, + # accuracy_decimals=0 + - platform: emontx + tag_name: CUSTOM1 + name: Custom sensor + emontx_id: test_emontx + + # User override: verify that explicit values are respected and not clobbered + - platform: emontx + tag_name: E2 + name: Energy 2 (user override) + emontx_id: test_emontx + state_class: measurement + accuracy_decimals: 3 From 409d74a48da48ea3152c7d8aedb49f622123782f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:53:44 -0400 Subject: [PATCH 1616/1815] [esp32_hosted] Fire on_update_available trigger when update is detected (#18591) --- .../esp32_hosted/update/esp32_hosted_update.cpp | 9 +++++++++ .../esp32_hosted/test-embedded.esp32-p4-idf.yaml | 3 +++ .../components/esp32_hosted/test-http.esp32-p4-idf.yaml | 3 +++ 3 files changed, 15 insertions(+) diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index 351b0869b0..4eb5d1745b 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -135,6 +135,10 @@ void Esp32HostedUpdate::setup() { // Publish state this->status_clear_error(); this->publish_state(); + // Defer so the automation runs on the main loop after setup, not during App.setup() + if (this->state_ == update::UPDATE_STATE_AVAILABLE && this->update_available_trigger_) { + this->defer([this]() { this->update_available_trigger_->trigger(this->update_info_); }); + } #else // HTTP mode: check every 10s until network is ready (max 6 attempts) // Only if update interval is > 1 minute to avoid redundant checks @@ -185,6 +189,8 @@ void Esp32HostedUpdate::check() { return; } + const bool was_available = this->state_ == update::UPDATE_STATE_AVAILABLE; + // Compare versions if (this->update_info_.latest_version.empty() || this->update_info_.latest_version == this->update_info_.current_version) { @@ -197,6 +203,9 @@ void Esp32HostedUpdate::check() { this->update_info_.progress = 0.0f; this->status_clear_error(); this->publish_state(); + if (this->state_ == update::UPDATE_STATE_AVAILABLE && !was_available && this->update_available_trigger_) { + this->update_available_trigger_->trigger(this->update_info_); + } #endif } diff --git a/tests/components/esp32_hosted/test-embedded.esp32-p4-idf.yaml b/tests/components/esp32_hosted/test-embedded.esp32-p4-idf.yaml index 9640032b34..5cf33179ba 100644 --- a/tests/components/esp32_hosted/test-embedded.esp32-p4-idf.yaml +++ b/tests/components/esp32_hosted/test-embedded.esp32-p4-idf.yaml @@ -6,3 +6,6 @@ update: type: embedded path: $component_dir/test_firmware.bin sha256: de2f256064a0af797747c2b97505dc0b9f3df0de4f489eac731c23ae9ca9cc31 + on_update_available: + then: + - logger.log: "Coprocessor update available" diff --git a/tests/components/esp32_hosted/test-http.esp32-p4-idf.yaml b/tests/components/esp32_hosted/test-http.esp32-p4-idf.yaml index 17cde0f35d..88b620cfe8 100644 --- a/tests/components/esp32_hosted/test-http.esp32-p4-idf.yaml +++ b/tests/components/esp32_hosted/test-http.esp32-p4-idf.yaml @@ -8,3 +8,6 @@ update: type: http source: https://esphome.github.io/esp-hosted-firmware/manifest/esp32c6.json update_interval: 6h + on_update_available: + then: + - logger.log: "Coprocessor update available" From aa944456e0ab4531d7b9184d5d97de166d522913 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Sat, 22 Aug 2026 04:11:52 +1200 Subject: [PATCH 1617/1815] [core] Add type annotations to component Python (6/11) (#18343) --- esphome/components/ags10/sensor.py | 19 ++++++++++--- esphome/components/at581x/__init__.py | 19 ++++++++++--- esphome/components/at581x/switch/__init__.py | 3 ++- esphome/components/canbus/__init__.py | 20 +++++++++----- esphome/components/daly_bms/__init__.py | 3 ++- esphome/components/daly_bms/binary_sensor.py | 6 +++-- esphome/components/daly_bms/sensor.py | 6 +++-- esphome/components/daly_bms/text_sensor.py | 6 +++-- esphome/components/deep_sleep/__init__.py | 21 +++++++++++---- esphome/components/ds1307/time.py | 19 ++++++++++--- .../components/esp32_ble_tracker/__init__.py | 21 ++++++++++----- esphome/components/ethernet/__init__.py | 27 ++++++++++++------- esphome/components/hdc302x/sensor.py | 23 +++++++++++++--- esphome/components/htu21d/sensor.py | 19 ++++++++++--- esphome/components/ld6002b/__init__.py | 2 +- esphome/components/ld6002b/binary_sensor.py | 3 ++- esphome/components/ld6002b/button/__init__.py | 2 +- esphome/components/ld6002b/number/__init__.py | 2 +- esphome/components/ld6002b/select/__init__.py | 3 ++- esphome/components/ld6002b/sensor.py | 3 ++- esphome/components/ld6002b/switch/__init__.py | 3 ++- esphome/components/ld6002b/text_sensor.py | 3 ++- esphome/components/m5stack_8angle/__init__.py | 3 ++- .../m5stack_8angle/binary_sensor/__init__.py | 3 ++- .../m5stack_8angle/light/__init__.py | 3 ++- .../m5stack_8angle/sensor/__init__.py | 3 ++- esphome/components/modbus/__init__.py | 20 ++++++++------ esphome/components/openthread/__init__.py | 25 +++++++++++------ esphome/components/pulse_counter/sensor.py | 21 ++++++++++----- esphome/components/pulse_meter/sensor.py | 21 ++++++++++----- esphome/components/shelly_dimmer/light.py | 11 ++++---- 31 files changed, 246 insertions(+), 97 deletions(-) diff --git a/esphome/components/ags10/sensor.py b/esphome/components/ags10/sensor.py index 6491d7d810..8606e7c247 100644 --- a/esphome/components/ags10/sensor.py +++ b/esphome/components/ags10/sensor.py @@ -17,6 +17,9 @@ from esphome.const import ( UNIT_OHM, UNIT_PARTS_PER_BILLION, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CONF_RESISTANCE = "resistance" @@ -62,7 +65,7 @@ CONFIG_SCHEMA = ( FINAL_VALIDATE_SCHEMA = i2c.final_validate_device_schema("ags10", max_frequency="15khz") -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -94,7 +97,12 @@ AGS10_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( AGS10_NEW_I2C_ADDRESS_SCHEMA, synchronous=True, ) -async def ags10newi2caddress_to_code(config, action_id, template_arg, args): +async def ags10newi2caddress_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) address = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) @@ -126,7 +134,12 @@ AGS10_SET_ZERO_POINT_SCHEMA = cv.Schema( AGS10_SET_ZERO_POINT_SCHEMA, synchronous=True, ) -async def ags10setzeropoint_to_code(config, action_id, template_arg, args): +async def ags10setzeropoint_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) mode = await cg.templatable( diff --git a/esphome/components/at581x/__init__.py b/esphome/components/at581x/__init__.py index 5031b72cce..193e62f615 100644 --- a/esphome/components/at581x/__init__.py +++ b/esphome/components/at581x/__init__.py @@ -4,6 +4,9 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@X-Ryl669"] DEPENDENCIES = ["i2c"] @@ -70,7 +73,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -91,7 +94,12 @@ AT581XSettingsAction = at581x_ns.class_("AT581XSettingsAction", automation.Actio ), synchronous=True, ) -async def at581x_reset_to_code(config, action_id, template_arg, args): +async def at581x_reset_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) @@ -163,7 +171,12 @@ RADAR_SETTINGS_SCHEMA = cv.Schema( RADAR_SETTINGS_SCHEMA, synchronous=True, ) -async def at581x_settings_to_code(config, action_id, template_arg, args): +async def at581x_settings_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) diff --git a/esphome/components/at581x/switch/__init__.py b/esphome/components/at581x/switch/__init__.py index 8e1b82b356..7e45ed89ec 100644 --- a/esphome/components/at581x/switch/__init__.py +++ b/esphome/components/at581x/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import DEVICE_CLASS_SWITCH, ICON_WIFI +from esphome.types import ConfigType from .. import CONF_AT581X_ID, AT581XComponent, at581x_ns @@ -22,7 +23,7 @@ CONFIG_SCHEMA = switch.switch_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: at581x_component = await cg.get_variable(config[CONF_AT581X_ID]) s = await switch.new_switch(config) await cg.register_parented(s, config[CONF_AT581X_ID]) diff --git a/esphome/components/canbus/__init__.py b/esphome/components/canbus/__init__.py index fcd342ad38..b7de235dd1 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -1,10 +1,13 @@ import re +from typing import Any from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_DATA, CONF_ID, CONF_TRIGGER_ID from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@mvturnho", "@danielschramm"] IS_PLATFORM_COMPONENT = True @@ -18,7 +21,7 @@ CONF_BIT_RATE = "bit_rate" CONF_ON_FRAME = "on_frame" -def validate_id(config): +def validate_id(config: ConfigType) -> ConfigType: if CONF_CAN_ID in config: can_id = config[CONF_CAN_ID] id_ext = config[CONF_USE_EXTENDED_ID] @@ -27,7 +30,7 @@ def validate_id(config): return config -def validate_raw_data(value): +def validate_raw_data(value: Any) -> bytes | list: if isinstance(value, str): return value.encode("utf-8") if isinstance(value, list): @@ -71,7 +74,7 @@ CAN_SPEEDS = { } -def get_rate(value): +def get_rate(value: str) -> int: match = re.match(r"(\d+)(?:K(\d+)?)?BPS", value, re.IGNORECASE) if not match: raise ValueError(f"Invalid rate format: {value}") @@ -103,7 +106,7 @@ CANBUS_SCHEMA = cv.Schema( CANBUS_SCHEMA.add_extra(validate_id) -async def setup_canbus_core_(var, config): +async def setup_canbus_core_(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_can_id([config[CONF_CAN_ID]])) cg.add(var.set_use_extended_id([config[CONF_USE_EXTENDED_ID]])) @@ -134,7 +137,7 @@ async def setup_canbus_core_(var, config): ) -async def register_canbus(var, config): +async def register_canbus(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.new_Pvariable(config[CONF_ID], var) await setup_canbus_core_(var, config) @@ -157,7 +160,12 @@ async def register_canbus(var, config): ), synchronous=True, ) -async def canbus_action_to_code(config, action_id, template_arg, args): +async def canbus_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_CANBUS_ID]) diff --git a/esphome/components/daly_bms/__init__.py b/esphome/components/daly_bms/__init__.py index 87f00ce507..ba0be4d3a5 100644 --- a/esphome/components/daly_bms/__init__.py +++ b/esphome/components/daly_bms/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@s1lvi0"] MULTI_CONF = True @@ -26,7 +27,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/daly_bms/binary_sensor.py b/esphome/components/daly_bms/binary_sensor.py index 95a2ae3b44..2b6ceffff1 100644 --- a/esphome/components/daly_bms/binary_sensor.py +++ b/esphome/components/daly_bms/binary_sensor.py @@ -1,6 +1,8 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_BMS_DALY_ID, DalyBmsComponent @@ -27,13 +29,13 @@ CONFIG_SCHEMA = cv.All( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if sensor_config := config.get(key): var = await binary_sensor.new_binary_sensor(sensor_config) cg.add(getattr(hub, f"set_{key}_binary_sensor")(var)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BMS_DALY_ID]) for key in TYPES: await setup_conf(config, key, hub) diff --git a/esphome/components/daly_bms/sensor.py b/esphome/components/daly_bms/sensor.py index aa92cfa86a..3e91fb280a 100644 --- a/esphome/components/daly_bms/sensor.py +++ b/esphome/components/daly_bms/sensor.py @@ -23,6 +23,8 @@ from esphome.const import ( UNIT_PERCENT, UNIT_VOLT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_BMS_DALY_ID, DalyBmsComponent @@ -222,13 +224,13 @@ CONFIG_SCHEMA = cv.All( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if sensor_config := config.get(key): sens = await sensor.new_sensor(sensor_config) cg.add(getattr(hub, f"set_{key}_sensor")(sens)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BMS_DALY_ID]) for key in TYPES: await setup_conf(config, key, hub) diff --git a/esphome/components/daly_bms/text_sensor.py b/esphome/components/daly_bms/text_sensor.py index 9f4e2df85a..1a91081bbf 100644 --- a/esphome/components/daly_bms/text_sensor.py +++ b/esphome/components/daly_bms/text_sensor.py @@ -2,6 +2,8 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_STATUS +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_BMS_DALY_ID, DalyBmsComponent @@ -23,13 +25,13 @@ CONFIG_SCHEMA = cv.All( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if sensor_config := config.get(key): sens = await text_sensor.new_text_sensor(sensor_config) cg.add(getattr(hub, f"set_{key}_text_sensor")(sens)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BMS_DALY_ID]) for key in TYPES: await setup_conf(config, key, hub) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 3b70f947d2..91131a3ed7 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -38,7 +38,8 @@ from esphome.const import ( PLATFORM_NRF52, PlatformFramework, ) -from esphome.core import CORE +from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType WAKEUP_PINS = { @@ -174,7 +175,7 @@ def validate_config(config: ConfigType) -> ConfigType: return config -def _validate_ex1_wakeup_mode(value): +def _validate_ex1_wakeup_mode(value: str) -> str: if value == "ALL_LOW": esp32.only_on_variant(supported=[VARIANT_ESP32], msg_prefix="ALL_LOW")(value) if value == "ANY_LOW": @@ -345,7 +346,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -458,7 +459,12 @@ DEEP_SLEEP_ENTER_SCHEMA = cv.All( DEEP_SLEEP_ENTER_SCHEMA, synchronous=True, ) -async def deep_sleep_enter_to_code(config, action_id, template_arg, args): +async def deep_sleep_enter_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) if CONF_SLEEP_DURATION in config: @@ -487,7 +493,12 @@ async def deep_sleep_enter_to_code(config, action_id, template_arg, args): automation.maybe_simple_id(DEEP_SLEEP_ACTION_SCHEMA), synchronous=True, ) -async def deep_sleep_action_to_code(config, action_id, template_arg, args): +async def deep_sleep_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/ds1307/time.py b/esphome/components/ds1307/time.py index 0e7bb976a2..a3ae3eb5af 100644 --- a/esphome/components/ds1307/time.py +++ b/esphome/components/ds1307/time.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@badbadc0ffee"] DEPENDENCIES = ["i2c"] @@ -29,7 +32,12 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ), synchronous=True, ) -async def ds1307_write_time_to_code(config, action_id, template_arg, args): +async def ds1307_write_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -45,13 +53,18 @@ async def ds1307_write_time_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def ds1307_read_time_to_code(config, action_id, template_arg, args): +async def ds1307_read_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 28c8c7fcf1..4f6355df70 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -38,7 +38,8 @@ from esphome.const import ( CONF_SERVICE_UUID, CONF_TRIGGER_ID, ) -from esphome.core import CORE, CoroPriority, TimePeriod, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, TimePeriod, coroutine_with_priority +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.enum import StrEnum from esphome.types import ConfigType @@ -262,7 +263,7 @@ ESP_BLE_DEVICE_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: # Register the loggers this component needs esp32_ble.register_bt_logger(BTLoggers.BLE_SCAN) @@ -360,7 +361,7 @@ async def to_code(config): # chance to call register_ble_tracker and register_client before the list is checked # and added to the global defines list. @coroutine_with_priority(CoroPriority.FINAL) -async def _add_ble_features(): +async def _add_ble_features() -> None: # Add feature-specific defines based on what's needed required_features = _get_required_features() # Sensors registered through the neutral ble_device_base path (BLEHub) need @@ -389,8 +390,11 @@ ESP32_BLE_START_SCAN_ACTION_SCHEMA = cv.Schema( synchronous=True, ) async def esp32_ble_tracker_start_scan_action_to_code( - config, action_id, template_arg, args -): + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_CONTINUOUS], args, cg.bool_) @@ -414,8 +418,11 @@ ESP32_BLE_STOP_SCAN_ACTION_SCHEMA = automation.maybe_simple_id( synchronous=True, ) async def esp32_ble_tracker_stop_scan_action_to_code( - config, action_id, template_arg, args -): + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 7686b64cb4..cd5904f501 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -48,10 +48,12 @@ from esphome.const import ( ) from esphome.core import ( CORE, + ID, CoroPriority, TimePeriodMilliseconds, coroutine_with_priority, ) +from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv from esphome.types import ConfigType @@ -276,7 +278,7 @@ def _validate_spi_interface(config: ConfigType) -> ConfigType: return config -def _validate(config): +def _validate(config: ConfigType) -> ConfigType: if CONF_USE_ADDRESS not in config: if CONF_MANUAL_IP in config: use_address = str(config[CONF_MANUAL_IP][CONF_STATIC_IP]) @@ -441,7 +443,7 @@ GENERIC_SCHEMA = cv.All( ) -def _spi_schema(default_clock: str = "26.67MHz", max_clock: int = int(80e6)): +def _spi_schema(default_clock: str = "26.67MHz", max_clock: int = int(80e6)) -> cv.All: return cv.All( BASE_SCHEMA.extend( cv.Schema( @@ -517,7 +519,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate_spi(config): +def _final_validate_spi(config: ConfigType) -> None: if not CORE.is_esp32: return # SPI interface validation is ESP32-only if config[CONF_TYPE] not in SPI_ETHERNET_TYPES: @@ -537,7 +539,7 @@ def _final_validate_spi(config): ) -def manual_ip(config): +def manual_ip(config: ConfigType) -> cg.StructInitializer: return cg.StructInitializer( ManualIP, ("static_ip", ip_address_literal(config[CONF_STATIC_IP])), @@ -548,7 +550,7 @@ def manual_ip(config): ) -def phy_register(address: int, value: int, page: int): +def phy_register(address: int, value: int, page: int) -> cg.StructInitializer: return cg.StructInitializer( PHYRegister, ("address", address), @@ -558,7 +560,7 @@ def phy_register(address: int, value: int, page: int): @coroutine_with_priority(CoroPriority.COMMUNICATION) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) # Apply network priority before register_component (which emits the user's @@ -610,7 +612,7 @@ async def to_code(config): CORE.add_job(final_step) -async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: +async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None: from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, @@ -698,7 +700,7 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: add_idf_component(name=component.name, ref=component.version) -async def _to_code_rp2040(var: cg.Pvariable, config: ConfigType) -> None: +async def _to_code_rp2040(var: cg.MockObj, config: ConfigType) -> None: cg.add(var.set_clk_pin(config[CONF_CLK_PIN])) cg.add(var.set_miso_pin(config[CONF_MISO_PIN])) cg.add(var.set_mosi_pin(config[CONF_MOSI_PIN])) @@ -793,7 +795,7 @@ FINAL_VALIDATE_SCHEMA = _final_validate @coroutine_with_priority(CoroPriority.FINAL) -async def final_step(): +async def final_step() -> None: """Final code generation step to configure optional Ethernet features.""" if ip_state_count := CORE.data.get(ETHERNET_IP_STATE_LISTENERS_KEY, 0): cg.add_define("USE_ETHERNET_IP_STATE_LISTENERS") @@ -845,7 +847,12 @@ def _filter_source_files() -> list[str]: FILTER_SOURCE_FILES = _filter_source_files -async def _new_pvariable_to_code(config, id_, template_arg, args): +async def _new_pvariable_to_code( + config: ConfigType, + id_: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: return cg.new_Pvariable(id_, template_arg) diff --git a/esphome/components/hdc302x/sensor.py b/esphome/components/hdc302x/sensor.py index a6265b9b98..6d91c3df7c 100644 --- a/esphome/components/hdc302x/sensor.py +++ b/esphome/components/hdc302x/sensor.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg @@ -16,6 +18,9 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PERCENT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -62,7 +67,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -86,7 +91,7 @@ HDC302X_HEATER_POWER_MAP = { } -def heater_power_value(value): +def heater_power_value(value: Any) -> cv.Lambda | int: """Accept enum names or raw uint16 values""" if isinstance(value, cv.Lambda): return value @@ -119,7 +124,12 @@ HDC302X_HEATER_ON_ACTION_SCHEMA = maybe_simple_id( HDC302X_HEATER_ON_ACTION_SCHEMA, synchronous=True, ) -async def hdc302x_heater_on_to_code(config, action_id, template_arg, args): +async def hdc302x_heater_on_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_POWER], args, cg.uint16) @@ -135,7 +145,12 @@ async def hdc302x_heater_on_to_code(config, action_id, template_arg, args): HDC302X_ACTION_SCHEMA, synchronous=True, ) -async def hdc302x_heater_off_to_code(config, action_id, template_arg, args): +async def hdc302x_heater_off_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/htu21d/sensor.py b/esphome/components/htu21d/sensor.py index 8808dc70f5..86dca77725 100644 --- a/esphome/components/htu21d/sensor.py +++ b/esphome/components/htu21d/sensor.py @@ -17,6 +17,9 @@ from esphome.const import ( UNIT_EMPTY, UNIT_PERCENT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -63,7 +66,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -95,7 +98,12 @@ async def to_code(config): ), synchronous=True, ) -async def set_heater_level_to_code(config, action_id, template_arg, args): +async def set_heater_level_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) level_ = await cg.templatable(config[CONF_LEVEL], args, cg.uint8) @@ -115,7 +123,12 @@ async def set_heater_level_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def set_heater_to_code(config, action_id, template_arg, args): +async def set_heater_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) status_ = await cg.templatable(config[CONF_STATUS], args, cg.bool_) diff --git a/esphome/components/ld6002b/__init__.py b/esphome/components/ld6002b/__init__.py index 99f2ead3bb..af1e501a6a 100644 --- a/esphome/components/ld6002b/__init__.py +++ b/esphome/components/ld6002b/__init__.py @@ -60,7 +60,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/ld6002b/binary_sensor.py b/esphome/components/ld6002b/binary_sensor.py index 63f7b40c23..74095d5ded 100644 --- a/esphome/components/ld6002b/binary_sensor.py +++ b/esphome/components/ld6002b/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_TARGET, DEVICE_CLASS_OCCUPANCY +from esphome.types import ConfigType from . import LD6002BComponent from .const import AREA_COUNT, CONF_LD6002B_ID, MAX_TARGETS @@ -36,7 +37,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_LD6002B_ID]) if target_config := config.get(CONF_TARGET): diff --git a/esphome/components/ld6002b/button/__init__.py b/esphome/components/ld6002b/button/__init__.py index 508d5c2bc6..a664890a86 100644 --- a/esphome/components/ld6002b/button/__init__.py +++ b/esphome/components/ld6002b/button/__init__.py @@ -129,7 +129,7 @@ BUTTON_MAP = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: for key, button_type in BUTTON_MAP.items(): if button_config := config.get(key): b = cg.new_Pvariable(button_config[CONF_ID], button_type) diff --git a/esphome/components/ld6002b/number/__init__.py b/esphome/components/ld6002b/number/__init__.py index 452e38d6e3..236b049f53 100644 --- a/esphome/components/ld6002b/number/__init__.py +++ b/esphome/components/ld6002b/number/__init__.py @@ -136,7 +136,7 @@ def final_validate(config: ConfigType) -> None: FINAL_VALIDATE_SCHEMA = final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_LD6002B_ID]) for key, number_type, setter, min_value, max_value, step in ( diff --git a/esphome/components/ld6002b/select/__init__.py b/esphome/components/ld6002b/select/__init__.py index 3da647ee2c..7f5e528b84 100644 --- a/esphome/components/ld6002b/select/__init__.py +++ b/esphome/components/ld6002b/select/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import select import esphome.config_validation as cv from esphome.const import CONF_AREA_ID, CONF_SENSITIVITY, ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType from .. import LD6002BComponent, ld6002b_ns from ..const import CONF_INSTALLATION_MODE, CONF_LD6002B_ID, CONF_TRIGGER_SPEED @@ -64,7 +65,7 @@ SELECT_MAP = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_LD6002B_ID]) for key, select_type, setter, options in SELECT_MAP: diff --git a/esphome/components/ld6002b/sensor.py b/esphome/components/ld6002b/sensor.py index 3aedaf9fdd..cceefb3837 100644 --- a/esphome/components/ld6002b/sensor.py +++ b/esphome/components/ld6002b/sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_METER, ) +from esphome.types import ConfigType from . import LD6002BComponent from .const import ( @@ -150,7 +151,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_LD6002B_ID]) if target_count_config := config.get(CONF_TARGET_COUNT): diff --git a/esphome/components/ld6002b/switch/__init__.py b/esphome/components/ld6002b/switch/__init__.py index d27baa87fe..a414308b65 100644 --- a/esphome/components/ld6002b/switch/__init__.py +++ b/esphome/components/ld6002b/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import DEVICE_CLASS_SWITCH, ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType from .. import LD6002BComponent, ld6002b_ns from ..const import ( @@ -46,7 +47,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_LD6002B_ID]) for key, switch_type, setter in ( diff --git a/esphome/components/ld6002b/text_sensor.py b/esphome/components/ld6002b/text_sensor.py index a18d387437..0e8e2e80e7 100644 --- a/esphome/components/ld6002b/text_sensor.py +++ b/esphome/components/ld6002b/text_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType from . import LD6002BComponent from .const import CONF_LD6002B_ID, CONF_OTA_VERSION, CONF_WORK_MODE @@ -21,7 +22,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_LD6002B_ID]) if work_mode_config := config.get(CONF_WORK_MODE): sens = await text_sensor.new_text_sensor(work_mode_config) diff --git a/esphome/components/m5stack_8angle/__init__.py b/esphome/components/m5stack_8angle/__init__.py index a1c197b381..6404bcf64c 100644 --- a/esphome/components/m5stack_8angle/__init__.py +++ b/esphome/components/m5stack_8angle/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] CODEOWNERS = ["@rnauber"] @@ -26,7 +27,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(i2c.i2c_device_schema(0x43)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/m5stack_8angle/binary_sensor/__init__.py b/esphome/components/m5stack_8angle/binary_sensor/__init__.py index 22ab73e901..09398876d4 100644 --- a/esphome/components/m5stack_8angle/binary_sensor/__init__.py +++ b/esphome/components/m5stack_8angle/binary_sensor/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv +from esphome.types import ConfigType from .. import CONF_M5STACK_8ANGLE_ID, M5Stack8AngleComponent, m5stack_8angle_ns @@ -22,7 +23,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_M5STACK_8ANGLE_ID]) sens = await binary_sensor.new_binary_sensor(config) cg.add(sens.set_parent(hub)) diff --git a/esphome/components/m5stack_8angle/light/__init__.py b/esphome/components/m5stack_8angle/light/__init__.py index 806ecaabf4..5c4863acf7 100644 --- a/esphome/components/m5stack_8angle/light/__init__.py +++ b/esphome/components/m5stack_8angle/light/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import light import esphome.config_validation as cv from esphome.const import CONF_OUTPUT_ID +from esphome.types import ConfigType from .. import CONF_M5STACK_8ANGLE_ID, M5Stack8AngleComponent, m5stack_8angle_ns @@ -21,7 +22,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_M5STACK_8ANGLE_ID]) lights = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(lights, config) diff --git a/esphome/components/m5stack_8angle/sensor/__init__.py b/esphome/components/m5stack_8angle/sensor/__init__.py index 2132eaa4c2..87d1425241 100644 --- a/esphome/components/m5stack_8angle/sensor/__init__.py +++ b/esphome/components/m5stack_8angle/sensor/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( ICON_ROTATE_RIGHT, STATE_CLASS_MEASUREMENT, ) +from esphome.types import ConfigType from .. import ( CONF_M5STACK_8ANGLE_ID, @@ -55,7 +56,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) await cg.register_parented(var, config[CONF_M5STACK_8ANGLE_ID]) diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 58bd0f65dc..a98591c6bc 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -8,8 +8,10 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_DISABLE_CRC, CONF_FLOW_CONTROL_PIN, CONF_ID +from esphome.cpp_generator import MockObj from esphome.cpp_helpers import gpio_pin_expression import esphome.final_validate as fv +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -84,7 +86,7 @@ CONFIG_SCHEMA = cv.typed_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(modbus_ns.using) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -112,7 +114,9 @@ def _validate_server_address(value: Any) -> int: return address -def modbus_device_schema(default_address, role: Literal["client", "server"] = "client"): +def modbus_device_schema( + default_address: int | None, role: Literal["client", "server"] = "client" +) -> cv.Schema: hub_type = ModbusClient if role == "client" else ModbusServer address_validator = _validate_server_address if role == "server" else cv.hex_uint8_t schema = { @@ -127,14 +131,14 @@ def modbus_device_schema(default_address, role: Literal["client", "server"] = "c def final_validate_modbus_device( name: str, *, role: Literal["server", "client"] | None = None -): - def validate_role(value): +) -> cv.Schema: + def validate_role(value: str) -> str: assert role in MODBUS_ROLES if value != role: raise cv.Invalid(f"Component {name} requires role to be {role}") return value - def validate_hub(hub_config): + def validate_hub(hub_config: ConfigType) -> ConfigType: hub_schema = {} if role is not None: hub_schema[cv.Required(CONF_ROLE)] = validate_role @@ -147,19 +151,19 @@ def final_validate_modbus_device( ) -async def register_modbus_client_device(var, config): +async def register_modbus_client_device(var: MockObj, config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_MODBUS_ID]) cg.add(var.set_parent(parent)) cg.add(var.set_address(config[CONF_ADDRESS])) -async def register_modbus_server_device(var, config): +async def register_modbus_server_device(var: MockObj, config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_MODBUS_ID]) cg.add(var.set_address(config[CONF_ADDRESS])) cg.add(parent.register_device(var)) -async def register_modbus_device(var, config): +async def register_modbus_device(var: MockObj, config: ConfigType) -> None: # Remove before 2026.12.0 _LOGGER.warning( "'register_modbus_device' is deprecated, use 'register_modbus_client_device' " diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 4018ad81e7..ab69f5d9ae 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components.esp32 import ( @@ -31,10 +33,12 @@ from esphome.const import ( ) from esphome.core import ( CORE, + ID, CoroPriority, TimePeriodMilliseconds, coroutine_with_priority, ) +from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv from esphome.types import ConfigType @@ -76,7 +80,7 @@ CONF_DEVICE_TYPES = [ ] -def _validate_txpower(value): +def _validate_txpower(value: Any) -> int | float: if CORE.is_esp32: variant = get_esp32_variant() @@ -90,7 +94,7 @@ def _validate_txpower(value): return value # Unsupported, fail later with clear error -def set_sdkconfig_options(config): +def set_sdkconfig_options(config: ConfigType) -> None: # and expose options for using SPI/UART RCPs add_idf_sdkconfig_option("CONFIG_IEEE802154_ENABLED", True) add_idf_sdkconfig_option("CONFIG_OPENTHREAD_RADIO_NATIVE", True) @@ -180,7 +184,7 @@ def _validate(config: ConfigType) -> ConfigType: return config -def _require_vfs_select(config): +def _require_vfs_select(config: ConfigType) -> ConfigType: """Register VFS select requirement during config validation.""" # OpenThread uses esp_vfs_eventfd which requires VFS select support (ESP32 only) if CORE.is_esp32: @@ -188,7 +192,7 @@ def _require_vfs_select(config): return config -def _validate_platform(config): +def _validate_platform(config: ConfigType) -> ConfigType: if CORE.using_zephyr: return config return only_on_variant( @@ -203,7 +207,7 @@ def _validate_platform(config): )(config) -def _validate_tlv_hex(value): +def _validate_tlv_hex(value: Any) -> str: s = cv.string_strict(value) if len(s) % 2 != 0: raise cv.Invalid("TLV must have an even number of hex characters") @@ -242,7 +246,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(_): +def _final_validate(_: ConfigType) -> None: full_config = fv.full_config.get() network_config = full_config.get("network", {}) if not network_config.get(CONF_ENABLE_IPV6, False): @@ -274,7 +278,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( @coroutine_with_priority(CoroPriority.COMMUNICATION) -async def to_code(config): +async def to_code(config: ConfigType) -> None: # Re-enable openthread IDF component (excluded by default) if CORE.is_esp32: include_builtin_idf_component("openthread") @@ -339,7 +343,12 @@ POLL_PERIOD_ACTION_SCHEMA = automation.maybe_conf( POLL_PERIOD_ACTION_SCHEMA, synchronous=True, ) -async def openthread_poll_period_action_to_code(config, action_id, template_arg, args): +async def openthread_poll_period_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_POLL_PERIOD], args, cg.uint32) diff --git a/esphome/components/pulse_counter/sensor.py b/esphome/components/pulse_counter/sensor.py index 3326745846..7c5a0590d7 100644 --- a/esphome/components/pulse_counter/sensor.py +++ b/esphome/components/pulse_counter/sensor.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation, pins import esphome.codegen as cg from esphome.components import sensor @@ -19,7 +21,9 @@ from esphome.const import ( UNIT_PULSES, UNIT_PULSES_PER_MINUTE, ) -from esphome.core import CORE +from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CONF_USE_PCNT = "use_pcnt" @@ -42,7 +46,7 @@ SetTotalPulsesAction = pulse_counter_ns.class_( ) -def validate_internal_filter(value): +def validate_internal_filter(value: ConfigType) -> ConfigType: use_pcnt = value.get(CONF_USE_PCNT) if CORE.is_esp8266 and use_pcnt: raise cv.Invalid( @@ -63,7 +67,7 @@ def validate_internal_filter(value): return value -def validate_pulse_counter_pin(value): +def validate_pulse_counter_pin(value: Any) -> ConfigType: value = pins.internal_gpio_input_pin_schema(value) if CORE.is_esp8266 and value[CONF_NUMBER] >= 16: raise cv.Invalid( @@ -72,7 +76,7 @@ def validate_pulse_counter_pin(value): return value -def validate_count_mode(value): +def validate_count_mode(value: ConfigType) -> ConfigType: rising_edge = value[CONF_RISING_EDGE] falling_edge = value[CONF_FALLING_EDGE] if rising_edge == "DISABLE" and falling_edge == "DISABLE": @@ -126,7 +130,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: use_pcnt = config.get(CONF_USE_PCNT) if CORE.is_esp32 and use_pcnt: include_builtin_idf_component("esp_driver_pcnt") @@ -157,7 +161,12 @@ async def to_code(config): ), synchronous=True, ) -async def set_total_action_to_code(config, action_id, template_arg, args): +async def set_total_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: 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.uint32) diff --git a/esphome/components/pulse_meter/sensor.py b/esphome/components/pulse_meter/sensor.py index ab3dd2a249..9bda891efc 100644 --- a/esphome/components/pulse_meter/sensor.py +++ b/esphome/components/pulse_meter/sensor.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation, pins import esphome.codegen as cg from esphome.components import sensor @@ -17,7 +19,9 @@ from esphome.const import ( UNIT_PULSES, UNIT_PULSES_PER_MINUTE, ) -from esphome.core import CORE +from esphome.core import CORE, ID, TimePeriodMicroseconds +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@stevebaxter", "@cstaahl", "@TrentHouliston"] @@ -37,18 +41,18 @@ FILTER_MODES = { SetTotalPulsesAction = pulse_meter_ns.class_("SetTotalPulsesAction", automation.Action) -def validate_internal_filter(value): +def validate_internal_filter(value: Any) -> TimePeriodMicroseconds: return cv.positive_time_period_microseconds(value) -def validate_timeout(value): +def validate_timeout(value: Any) -> TimePeriodMicroseconds: value = cv.positive_time_period_microseconds(value) if value.total_minutes > 70: raise cv.Invalid("Maximum timeout is 70 minutes") return value -def validate_pulse_meter_pin(value): +def validate_pulse_meter_pin(value: Any) -> ConfigType: value = pins.internal_gpio_input_pin_schema(value) if CORE.is_esp8266 and value[CONF_NUMBER] >= 16: raise cv.Invalid( @@ -81,7 +85,7 @@ CONFIG_SCHEMA = sensor.sensor_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) @@ -107,7 +111,12 @@ async def to_code(config): ), synchronous=True, ) -async def set_total_action_to_code(config, action_id, template_arg, args): +async def set_total_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: 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.uint32) diff --git a/esphome/components/shelly_dimmer/light.py b/esphome/components/shelly_dimmer/light.py index dd99fcbc90..c166076e0f 100644 --- a/esphome/components/shelly_dimmer/light.py +++ b/esphome/components/shelly_dimmer/light.py @@ -1,6 +1,7 @@ import hashlib from pathlib import Path import re +from typing import Any from esphome import external_files, pins import esphome.codegen as cg @@ -66,7 +67,7 @@ KNOWN_FIRMWARE = { } -def parse_firmware_version(value): +def parse_firmware_version(value: str) -> tuple[int, int]: match = re.fullmatch(r"(\d+)\.(\d+)", value) if match is None: raise ValueError(f"Not a valid version number {value}") @@ -154,7 +155,7 @@ def _extract_firmware_ref(entry: ConfigType) -> RemoteFile | None: PREFETCH_FILES = external_files.single_stage_prefetch(_extract_firmware_ref) -def validate_firmware(value): +def validate_firmware(value: ConfigType) -> ConfigType: config = value.copy() if CONF_URL not in config: try: @@ -167,14 +168,14 @@ def validate_firmware(value): return config -def validate_sha256(value): +def validate_sha256(value: Any) -> str: value = cv.string(value) if not re.fullmatch(r"[0-9a-fA-F]{64}", value): raise ValueError(f"Not a valid SHA256 hex string: {value}") return value -def validate_version(value): +def validate_version(value: str) -> str: parse_firmware_version(value) return value @@ -231,7 +232,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: fw_hex = get_firmware(config[CONF_FIRMWARE]) fw_major, fw_minor = parse_firmware_version(config[CONF_FIRMWARE][CONF_VERSION]) From 00cffa09a2491be8a39ffd1a62d2c6355bedc61c Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 21 Aug 2026 14:05:07 -0400 Subject: [PATCH 1618/1815] [sendspin] Convert tests to package-style includes (#18588) --- tests/components/sendspin/common-action.yaml | 2 +- tests/components/sendspin/common-ethernet.yaml | 5 +++++ tests/components/sendspin/common-hub.yaml | 6 ++++++ tests/components/sendspin/common-media_player.yaml | 3 ++- tests/components/sendspin/common-media_source.yaml | 3 ++- tests/components/sendspin/common-sensor.yaml | 3 ++- tests/components/sendspin/common-text_sensor.yaml | 3 ++- tests/components/sendspin/common.yaml | 10 +++------- tests/components/sendspin/test-action.esp32-idf.yaml | 3 ++- .../components/sendspin/test-ethernet.esp32-idf.yaml | 11 ++--------- .../sendspin/test-media_player.esp32-idf.yaml | 3 ++- .../sendspin/test-media_source.esp32-idf.yaml | 3 ++- tests/components/sendspin/test-sensor.esp32-idf.yaml | 3 ++- .../sendspin/test-text_sensor.esp32-idf.yaml | 3 ++- tests/components/sendspin/test.esp32-idf.yaml | 3 ++- 15 files changed, 37 insertions(+), 27 deletions(-) create mode 100644 tests/components/sendspin/common-ethernet.yaml create mode 100644 tests/components/sendspin/common-hub.yaml diff --git a/tests/components/sendspin/common-action.yaml b/tests/components/sendspin/common-action.yaml index 16f19ad7d1..1bba06ab46 100644 --- a/tests/components/sendspin/common-action.yaml +++ b/tests/components/sendspin/common-action.yaml @@ -1,6 +1,6 @@ # `sendspin.switch` action enables the controller role, so we use a standalone test packages: - base: !include common.yaml + sendspin: !include common.yaml wifi: on_connect: diff --git a/tests/components/sendspin/common-ethernet.yaml b/tests/components/sendspin/common-ethernet.yaml new file mode 100644 index 0000000000..276163cda1 --- /dev/null +++ b/tests/components/sendspin/common-ethernet.yaml @@ -0,0 +1,5 @@ +packages: + sendspin_hub: !include common-hub.yaml + +ethernet: + type: OPENETH diff --git a/tests/components/sendspin/common-hub.yaml b/tests/components/sendspin/common-hub.yaml new file mode 100644 index 0000000000..7a6a9ffd4f --- /dev/null +++ b/tests/components/sendspin/common-hub.yaml @@ -0,0 +1,6 @@ +psram: + mode: quad + +sendspin: + id: sendspin_hub_id + task_stack_in_psram: true diff --git a/tests/components/sendspin/common-media_player.yaml b/tests/components/sendspin/common-media_player.yaml index d3792cf470..afb8b992f3 100644 --- a/tests/components/sendspin/common-media_player.yaml +++ b/tests/components/sendspin/common-media_player.yaml @@ -1,4 +1,5 @@ -<<: !include common.yaml +packages: + sendspin: !include common.yaml media_player: - platform: sendspin diff --git a/tests/components/sendspin/common-media_source.yaml b/tests/components/sendspin/common-media_source.yaml index 5b33a54647..1977b79c04 100644 --- a/tests/components/sendspin/common-media_source.yaml +++ b/tests/components/sendspin/common-media_source.yaml @@ -1,4 +1,5 @@ -<<: !include common.yaml +packages: + sendspin: !include common.yaml media_source: - platform: sendspin diff --git a/tests/components/sendspin/common-sensor.yaml b/tests/components/sendspin/common-sensor.yaml index 6d9745cff9..6467e38b90 100644 --- a/tests/components/sendspin/common-sensor.yaml +++ b/tests/components/sendspin/common-sensor.yaml @@ -1,4 +1,5 @@ -<<: !include common.yaml +packages: + sendspin: !include common.yaml sensor: - platform: sendspin diff --git a/tests/components/sendspin/common-text_sensor.yaml b/tests/components/sendspin/common-text_sensor.yaml index fc6a56a21a..23111e8d37 100644 --- a/tests/components/sendspin/common-text_sensor.yaml +++ b/tests/components/sendspin/common-text_sensor.yaml @@ -1,4 +1,5 @@ -<<: !include common.yaml +packages: + sendspin: !include common.yaml text_sensor: - platform: sendspin diff --git a/tests/components/sendspin/common.yaml b/tests/components/sendspin/common.yaml index 9d7da76758..980635b4e3 100644 --- a/tests/components/sendspin/common.yaml +++ b/tests/components/sendspin/common.yaml @@ -1,9 +1,5 @@ +packages: + sendspin_hub: !include common-hub.yaml + wifi: ap: - -psram: - mode: quad - -sendspin: - id: sendspin_hub_id - task_stack_in_psram: true diff --git a/tests/components/sendspin/test-action.esp32-idf.yaml b/tests/components/sendspin/test-action.esp32-idf.yaml index 70a7ee1bad..080eb59034 100644 --- a/tests/components/sendspin/test-action.esp32-idf.yaml +++ b/tests/components/sendspin/test-action.esp32-idf.yaml @@ -1 +1,2 @@ -<<: !include common-action.yaml +packages: + sendspin: !include common-action.yaml diff --git a/tests/components/sendspin/test-ethernet.esp32-idf.yaml b/tests/components/sendspin/test-ethernet.esp32-idf.yaml index 069e397d99..09a951d211 100644 --- a/tests/components/sendspin/test-ethernet.esp32-idf.yaml +++ b/tests/components/sendspin/test-ethernet.esp32-idf.yaml @@ -1,9 +1,2 @@ -ethernet: - type: OPENETH - -psram: - mode: quad - -sendspin: - id: sendspin_hub_id - task_stack_in_psram: true +packages: + sendspin: !include common-ethernet.yaml diff --git a/tests/components/sendspin/test-media_player.esp32-idf.yaml b/tests/components/sendspin/test-media_player.esp32-idf.yaml index cbbdb07c77..bcd4062bbe 100644 --- a/tests/components/sendspin/test-media_player.esp32-idf.yaml +++ b/tests/components/sendspin/test-media_player.esp32-idf.yaml @@ -1 +1,2 @@ -<<: !include common-media_player.yaml +packages: + sendspin: !include common-media_player.yaml diff --git a/tests/components/sendspin/test-media_source.esp32-idf.yaml b/tests/components/sendspin/test-media_source.esp32-idf.yaml index 47aeb2257c..faadccb06d 100644 --- a/tests/components/sendspin/test-media_source.esp32-idf.yaml +++ b/tests/components/sendspin/test-media_source.esp32-idf.yaml @@ -1 +1,2 @@ -<<: !include common-media_source.yaml +packages: + sendspin: !include common-media_source.yaml diff --git a/tests/components/sendspin/test-sensor.esp32-idf.yaml b/tests/components/sendspin/test-sensor.esp32-idf.yaml index f9127d47bc..1646902ca3 100644 --- a/tests/components/sendspin/test-sensor.esp32-idf.yaml +++ b/tests/components/sendspin/test-sensor.esp32-idf.yaml @@ -1 +1,2 @@ -<<: !include common-sensor.yaml +packages: + sendspin: !include common-sensor.yaml diff --git a/tests/components/sendspin/test-text_sensor.esp32-idf.yaml b/tests/components/sendspin/test-text_sensor.esp32-idf.yaml index 8998b8896e..69cf8e63fb 100644 --- a/tests/components/sendspin/test-text_sensor.esp32-idf.yaml +++ b/tests/components/sendspin/test-text_sensor.esp32-idf.yaml @@ -1 +1,2 @@ -<<: !include common-text_sensor.yaml +packages: + sendspin: !include common-text_sensor.yaml diff --git a/tests/components/sendspin/test.esp32-idf.yaml b/tests/components/sendspin/test.esp32-idf.yaml index dade44d145..36667f7fae 100644 --- a/tests/components/sendspin/test.esp32-idf.yaml +++ b/tests/components/sendspin/test.esp32-idf.yaml @@ -1 +1,2 @@ -<<: !include common.yaml +packages: + sendspin: !include common.yaml From abc9098bd833ca2186b4dc0ec59bf32d049d862d Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:05:00 -0500 Subject: [PATCH 1619/1815] Bump bundled esphome-device-builder to 1.12.3 (#18601) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 2bbe5331e5..4cde6505b3 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.12.2 +RUN uv pip install --no-cache-dir esphome-device-builder==1.12.3 RUN \ platformio settings set enable_telemetry No \ From 11ea819bc7728d72586f34f381de3c57d1584ff5 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:36:47 -0500 Subject: [PATCH 1620/1815] Bump aioesphomeapi from 45.12.0 to 45.13.1 (#18600) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 740a8c1a79..3362e43239 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.12.0 +aioesphomeapi==45.13.1 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From 8e9fb0f93c9c8da438dd1f301e8ef593d94ca4c2 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 21 Aug 2026 23:10:47 -0500 Subject: [PATCH 1621/1815] [remote_transmitter] Fix repeat gap timing on LibreTiny Beken (#18585) Co-authored-by: J. Nick Koston --- .../remote_transmitter/remote_transmitter.cpp | 40 ++++++++++++------- .../remote_transmitter/remote_transmitter.h | 2 +- .../remote_transmitter/test.bk72xx-ard.yaml | 7 ++++ 3 files changed, 34 insertions(+), 15 deletions(-) create mode 100644 tests/components/remote_transmitter/test.bk72xx-ard.yaml diff --git a/esphome/components/remote_transmitter/remote_transmitter.cpp b/esphome/components/remote_transmitter/remote_transmitter.cpp index 49c711330b..31e7464314 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter.cpp @@ -81,25 +81,37 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen ESP_LOGD(TAG, "Sending remote code"); uint32_t on_time, off_time; this->calculate_on_off_time_(this->temp_.get_carrier_frequency(), &on_time, &off_time); - this->target_time_ = 0; this->transmit_trigger_.trigger(); for (uint32_t i = 0; i < send_times; i++) { - InterruptLock lock; - for (int32_t item : this->temp_.get_data()) { - if (item > 0) { - const auto length = uint32_t(item); - this->mark_(on_time, off_time, length); - } else { - const auto length = uint32_t(-item); - this->space_(length); + { + InterruptLock lock; + // Re-anchor every iteration: timing must never span a lock boundary, as micros() can + // jump when interrupts are re-enabled between repeats (e.g. LibreTiny's Beken micros() + // discards its interrupt-lock correction, stretching the repeat gap by the lock duration) + this->target_time_ = 0; + for (int32_t item : this->temp_.get_data()) { + if (item > 0) { + const auto length = uint32_t(item); + this->mark_(on_time, off_time, length); + } else { + const auto length = uint32_t(-item); + this->space_(length); + } + App.feed_wdt(); } - App.feed_wdt(); + this->await_target_time_(); // wait for duration of last pulse + this->pin_->digital_write(false); } - this->await_target_time_(); // wait for duration of last pulse - this->pin_->digital_write(false); - if (i + 1 < send_times) - this->target_time_ += send_wait; + if (i + 1 < send_times) { + // Wait out the repeat gap with interrupts enabled: wait_time is unbounded user config + // (previously this spin ran inside the next iteration's lock, disabling interrupts for + // the whole gap). Anchoring after the lock release keeps it exact on all platforms. + const uint32_t gap_end = micros() + send_wait; + while ((int32_t) (gap_end - micros()) > 0) { + App.feed_wdt(); + } + } } this->complete_trigger_.trigger(); } diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index e2d33d13cc..0aa04682ba 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -72,7 +72,7 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa void space_(uint32_t usec); void await_target_time_(); - uint32_t target_time_; + uint32_t target_time_{0}; #endif #if defined(USE_ESP32) && SOC_RMT_SUPPORTED diff --git a/tests/components/remote_transmitter/test.bk72xx-ard.yaml b/tests/components/remote_transmitter/test.bk72xx-ard.yaml new file mode 100644 index 0000000000..2a5cceddec --- /dev/null +++ b/tests/components/remote_transmitter/test.bk72xx-ard.yaml @@ -0,0 +1,7 @@ +remote_transmitter: + id: xmitr + pin: GPIO26 + carrier_duty_percent: 50% + +packages: + buttons: !include common-buttons.yaml From 5a300e92f14ef6e2f308dd2394bc5f999fcd5b5f Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:14:56 -0500 Subject: [PATCH 1622/1815] [wifi] Inline the trivial WiFiScanResult accessors (#18613) --- esphome/components/wifi/wifi_component.cpp | 8 -------- esphome/components/wifi/wifi_component.h | 14 +++++++------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 127eb50df1..5ed5fc9094 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -2396,14 +2396,6 @@ bool WiFiScanResult::matches(const WiFiAP &config) const { } return true; } -bool WiFiScanResult::get_matches() const { return this->matches_; } -void WiFiScanResult::set_matches(bool matches) { this->matches_ = matches; } -const bssid_t &WiFiScanResult::get_bssid() const { return this->bssid_; } -uint8_t WiFiScanResult::get_channel() const { return this->channel_; } -int8_t WiFiScanResult::get_rssi() const { return this->rssi_; } -bool WiFiScanResult::get_with_auth() const { return this->with_auth_; } -bool WiFiScanResult::get_is_hidden() const { return this->is_hidden_; } - bool WiFiScanResult::operator==(const WiFiScanResult &rhs) const { return this->bssid_ == rhs.bssid_; } void WiFiComponent::clear_roaming_state_() { diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index ea043fd5c6..ff90fbe49b 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -319,14 +319,14 @@ class WiFiScanResult { bool matches(const WiFiAP &config) const; - bool get_matches() const; - void set_matches(bool matches); - const bssid_t &get_bssid() const; + bool get_matches() const { return this->matches_; } + void set_matches(bool matches) { this->matches_ = matches; } + const bssid_t &get_bssid() const { return this->bssid_; } StringRef get_ssid() const { return this->ssid_.ref(); } - uint8_t get_channel() const; - int8_t get_rssi() const; - bool get_with_auth() const; - bool get_is_hidden() const; + uint8_t get_channel() const { return this->channel_; } + int8_t get_rssi() const { return this->rssi_; } + bool get_with_auth() const { return this->with_auth_; } + bool get_is_hidden() const { return this->is_hidden_; } int8_t get_priority() const { return priority_; } void set_priority(int8_t priority) { priority_ = priority; } From a30e82459f2d7fbb97d2c4861f87b2c784938c9f Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:51:19 -0500 Subject: [PATCH 1623/1815] [deep_sleep] Reject wakeup_pin_mode at both levels on BK72xx (#18615) --- esphome/components/deep_sleep/__init__.py | 5 +++ .../deep_sleep/test_deep_sleep.py | 40 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 91131a3ed7..dc03708645 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -163,6 +163,11 @@ def validate_config(config: ConfigType) -> ConfigType: "You need to remove the global wakeup_pin_mode and define it per pin" ) if wakeup_pins: + if CONF_WAKEUP_PIN_MODE in wakeup_pins[0]: + raise cv.Invalid( + "Specify wakeup_pin_mode either at the top level under deep_sleep " + "or under the pin entry, not both" + ) wakeup_pins[0][CONF_WAKEUP_PIN_MODE] = config.pop(CONF_WAKEUP_PIN_MODE) elif ( isinstance(config.get(CONF_WAKEUP_PIN), list) diff --git a/tests/component_tests/deep_sleep/test_deep_sleep.py b/tests/component_tests/deep_sleep/test_deep_sleep.py index f105ed5888..e68b1d17cc 100644 --- a/tests/component_tests/deep_sleep/test_deep_sleep.py +++ b/tests/component_tests/deep_sleep/test_deep_sleep.py @@ -1,5 +1,13 @@ """Tests for the deep sleep component.""" +import pytest + +from esphome import config_validation as cv +from esphome.components import deep_sleep +from esphome.const import CONF_WAKEUP_PIN, PlatformFramework + +from ..types import SetCoreConfigCallable + def test_deep_sleep_setup(generate_main): """ @@ -83,3 +91,35 @@ def test_deep_sleep_run_duration_dictionary(generate_main): " .gpio_cause = 30000,\n" "});" ) in main_cpp + + +def test_deep_sleep_bk72xx_wakeup_pin_mode_at_both_levels_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """On BK72xx, wakeup_pin_mode at the top level and under the pin entry is an error.""" + set_core_config(PlatformFramework.BK72XX_ARDUINO) + config = { + CONF_WAKEUP_PIN: [ + {"pin": "GPIO12", deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE"} + ], + deep_sleep.CONF_WAKEUP_PIN_MODE: "INVERT_WAKEUP", + } + with pytest.raises(cv.Invalid, match="not both"): + deep_sleep.validate_config(config) + + +def test_deep_sleep_bk72xx_top_level_wakeup_pin_mode_moved_onto_single_pin( + set_core_config: SetCoreConfigCallable, +) -> None: + """On BK72xx, a top-level wakeup_pin_mode is moved onto the only pin entry.""" + set_core_config(PlatformFramework.BK72XX_ARDUINO) + config = { + CONF_WAKEUP_PIN: [{"pin": "GPIO12"}], + deep_sleep.CONF_WAKEUP_PIN_MODE: "INVERT_WAKEUP", + } + result = deep_sleep.validate_config(config) + + assert deep_sleep.CONF_WAKEUP_PIN_MODE not in result + assert ( + result[CONF_WAKEUP_PIN][0][deep_sleep.CONF_WAKEUP_PIN_MODE] == "INVERT_WAKEUP" + ) From 65704e881f868546390770ec1ca75b63c97739de Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Sat, 22 Aug 2026 06:52:18 +0200 Subject: [PATCH 1624/1815] [mitsubishi_cn105] Add Fahrenheit support (#15488) Co-authored-by: J. Nick Koston --- .../components/mitsubishi_cn105/__init__.py | 3 + .../mitsubishi_cn105/mitsubishi_cn105.h | 1 + .../mitsubishi_cn105_climate.cpp | 20 ++++--- .../mitsubishi_cn105_component.cpp | 7 +++ .../mitsubishi_cn105_component.h | 35 ++++++++++- esphome/components/mqtt/mqtt_climate.cpp | 3 +- .../mitsubishi_cn105_climate_tests.cpp | 60 +++++++++++++++++++ tests/components/mitsubishi_cn105/common.h | 1 + tests/components/mitsubishi_cn105/common.yaml | 1 + 9 files changed, 121 insertions(+), 10 deletions(-) diff --git a/esphome/components/mitsubishi_cn105/__init__.py b/esphome/components/mitsubishi_cn105/__init__.py index 450d1cd222..470b7be5fc 100644 --- a/esphome/components/mitsubishi_cn105/__init__.py +++ b/esphome/components/mitsubishi_cn105/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( CONF_ON_STATE, CONF_TEMPERATURE, CONF_UPDATE_INTERVAL, + CONF_USE_FAHRENHEIT, ) from esphome.core import ID, Lambda from esphome.cpp_generator import LambdaExpression, MockObj @@ -71,6 +72,7 @@ CONFIG_SCHEMA = ( cv.Optional( CONF_TELEMETRY_REQUEST_MIN_INTERVAL, default="60s" ): cv.update_interval, + cv.Optional(CONF_USE_FAHRENHEIT, default=False): cv.boolean, cv.Optional(CONF_VANE): cv.Schema( { cv.Optional(CONF_ON_STATE): automation.validate_automation({}), @@ -114,6 +116,7 @@ async def to_code(config: ConfigType) -> None: config[CONF_TELEMETRY_REQUEST_MIN_INTERVAL] ) ) + cg.add(var.set_use_fahrenheit(config[CONF_USE_FAHRENHEIT])) if on_state := config.get(CONF_VANE, {}).get(CONF_ON_STATE): cg.add_global(mitsubishi_ns.using) for conf in on_state: diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h index b6b11b4820..4d3f899dee 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h @@ -83,6 +83,7 @@ class MitsubishiCN105 { return this->is_telemetry_polling_enabled() ? !std::isnan(this->status_.room_temperature) : !std::isnan(this->status_.target_temperature); } + bool is_temperature_encoding_b() const { return this->property_context_.use_temperature_encoding_b; } void set_power(bool power_on); void set_target_temperature(float target_temperature); diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp index 197e1e1bb5..17ff6d34ca 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp @@ -50,7 +50,11 @@ static constexpr std::optional reverse_map_lookup(const std::arrayparent_->get_temperature_mapping().get_use_fahrenheit() ? 'F' : 'C'); +} void MitsubishiCN105Climate::setup() { this->parent_->add_on_status_callback([this]() { this->apply_values_(); }); @@ -72,13 +76,15 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() { traits.set_supported_swing_modes(this->supported_swing_modes_); - traits.set_visual_min_temperature(16.0f); - traits.set_visual_max_temperature(31.0f); + const bool use_fahrenheit = this->parent_->get_temperature_mapping().get_use_fahrenheit(); + traits.set_temperature_unit(use_fahrenheit ? TemperatureUnit::FAHRENHEIT : TemperatureUnit::CELSIUS); + traits.set_visual_min_temperature(use_fahrenheit ? 61.0f : 16.0f); + traits.set_visual_max_temperature(use_fahrenheit ? 88.0f : 31.0f); traits.set_visual_temperature_step(1.0f); if (this->parent_->is_telemetry_polling_enabled()) { traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); - traits.set_visual_current_temperature_step(0.5f); + traits.set_visual_current_temperature_step(use_fahrenheit ? 1.0f : 0.5f); } return traits; @@ -86,7 +92,7 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() { void MitsubishiCN105Climate::control(const climate::ClimateCall &call) { if (const auto target_temperature = call.get_target_temperature()) { - this->parent_->set_target_temperature(*target_temperature); + this->parent_->set_target_temperature(this->parent_->get_temperature_mapping().to_mitsubishi(*target_temperature)); } if (const auto mode = call.get_mode()) { @@ -139,10 +145,10 @@ void MitsubishiCN105Climate::control(const climate::ClimateCall &call) { void MitsubishiCN105Climate::apply_values_() { const auto &status = this->parent_->status(); - this->target_temperature = status.target_temperature; + this->target_temperature = this->parent_->get_temperature_mapping().from_mitsubishi(status.target_temperature); if (this->parent_->is_telemetry_polling_enabled()) { - this->current_temperature = status.room_temperature; + this->current_temperature = this->parent_->get_temperature_mapping().from_mitsubishi(status.room_temperature); } if (status.power_on) { diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp index 8e9e954645..e2a6ee05af 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp @@ -27,6 +27,13 @@ void MitsubishiCN105Component::setup() { this->hp_.initialize(); } void MitsubishiCN105Component::loop() { if (this->hp_.update()) { + // Encoding A only supports whole °C values and cannot represent native °F setpoints accurately. + // See https://github.com/esphome/esphome/pull/15488#issuecomment-5268304343 + if (this->temperature_mapping_.get_use_fahrenheit() && !this->hp_.is_temperature_encoding_b()) { + ESP_LOGE(TAG, "Unit reports encoding A, which cannot accurately convert °F setpoints; disable 'use_fahrenheit'"); + this->mark_failed(); + return; + } this->notify_status_listeners_(); } } diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h index 6461fb464b..508a15e6d5 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h @@ -3,13 +3,43 @@ #include "mitsubishi_cn105.h" #include "esphome/core/component.h" +#include "esphome/core/helpers.h" #include "esphome/components/uart/uart.h" -#include +#include +#include #include +#include namespace esphome::mitsubishi_cn105 { +struct TemperatureMapping { + float to_mitsubishi(float value) const { + if (!this->use_fahrenheit_) { + return value; + } + const int fahrenheit = std::clamp(static_cast(std::round(value)), 61, 88); + return 0.5f * (fahrenheit - 28 + (fahrenheit > 68) - (fahrenheit < 68)); + } + + float from_mitsubishi(float value) const { + if (!this->use_fahrenheit_) { + return value; + } + if (value < 16.0f || value > 30.5f) { + return celsius_to_fahrenheit(value); + } + const int mitsubishi_half_degrees = static_cast(std::round(value * 2.0f)); + return mitsubishi_half_degrees + 29 - (mitsubishi_half_degrees >= 40) - (mitsubishi_half_degrees > 40); + } + + bool get_use_fahrenheit() const { return this->use_fahrenheit_; } + void set_use_fahrenheit(bool value) { this->use_fahrenheit_ = value; } + + protected: + bool use_fahrenheit_{false}; +}; + enum VerticalVaneMode : uint8_t { VERTICAL_VANE_MODE_AUTO = static_cast(MitsubishiCN105::VaneMode::AUTO), VERTICAL_VANE_MODE_POSITION_1 = static_cast(MitsubishiCN105::VaneMode::POSITION_1), @@ -60,6 +90,7 @@ class MitsubishiCN105Component : public Component, public uart::UARTDevice { void set_update_interval(uint32_t ms) { this->hp_.set_update_interval(ms); } void set_telemetry_request_min_interval(uint32_t ms) { this->hp_.set_telemetry_request_min_interval(ms); } + void set_use_fahrenheit(bool value) { this->temperature_mapping_.set_use_fahrenheit(value); } void set_remote_temperature(float temperature) { this->hp_.set_remote_temperature(temperature); } void clear_remote_temperature() { this->hp_.clear_remote_temperature(); } @@ -75,6 +106,7 @@ class MitsubishiCN105Component : public Component, public uart::UARTDevice { const MitsubishiCN105::Status &status() const { return this->hp_.status(); } bool is_status_initialized() const { return this->hp_.is_status_initialized(); } bool is_telemetry_polling_enabled() const { return this->hp_.is_telemetry_polling_enabled(); } + const TemperatureMapping &get_temperature_mapping() const { return this->temperature_mapping_; } template void add_on_status_callback(F &&callback) { this->status_callback_.add(std::forward(callback)); @@ -99,6 +131,7 @@ class MitsubishiCN105Component : public Component, public uart::UARTDevice { } MitsubishiCN105 hp_; + TemperatureMapping temperature_mapping_; CallbackManager status_callback_; LazyCallbackManager vane_state_callback_; }; diff --git a/esphome/components/mqtt/mqtt_climate.cpp b/esphome/components/mqtt/mqtt_climate.cpp index d5ee4c6a9b..0e6a374f9b 100644 --- a/esphome/components/mqtt/mqtt_climate.cpp +++ b/esphome/components/mqtt/mqtt_climate.cpp @@ -118,8 +118,7 @@ void MQTTClimateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCo root[MQTT_TARGET_TEMPERATURE_STEP] = roundf(traits.get_visual_target_temperature_step() * 10) * 0.1f; // current_temp_step root[MQTT_CURRENT_TEMPERATURE_STEP] = roundf(traits.get_visual_current_temperature_step() * 10) * 0.1f; - // temperature units are always coerced to Celsius internally - root[MQTT_TEMPERATURE_UNIT] = "C"; + root[MQTT_TEMPERATURE_UNIT] = traits.get_temperature_unit() == TemperatureUnit::FAHRENHEIT ? "F" : "C"; // min_humidity root[MQTT_MIN_HUMIDITY] = traits.get_visual_min_humidity(); diff --git a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_climate_tests.cpp b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_climate_tests.cpp index 36e0fc90b4..b91252c9fa 100644 --- a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_climate_tests.cpp +++ b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_climate_tests.cpp @@ -1,7 +1,67 @@ +#include +#include #include "../common.h" namespace esphome::mitsubishi_cn105::testing { +TEST(MitsubishiCN105ClimateTests, CelsiusTemperatureMappingAndTraitsMatchExpectedValues) { + TestableMitsubishiCN105Climate sut; + const auto mapping = TemperatureMapping(); + + for (int temperature = 16; temperature <= 31; ++temperature) { + EXPECT_EQ(mapping.to_mitsubishi(temperature), temperature); + EXPECT_EQ(mapping.from_mitsubishi(temperature), temperature); + } + + const auto traits = sut.traits(); + EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::CELSIUS); + EXPECT_FLOAT_EQ(traits.get_visual_min_temperature(), 16.0f); + EXPECT_FLOAT_EQ(traits.get_visual_max_temperature(), 31.0f); + EXPECT_FLOAT_EQ(traits.get_visual_target_temperature_step(), 1.0f); + EXPECT_FLOAT_EQ(traits.get_visual_current_temperature_step(), 0.5f); +} + +TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingAndTraitsMatchExpectedValues) { + TestableMitsubishiCN105Climate sut; + auto mapping = TemperatureMapping(); + mapping.set_use_fahrenheit(true); + sut.set_use_fahrenheit(true); + + const std::array cases{ + std::pair{61, 16.0f}, std::pair{62, 16.5f}, std::pair{63, 17.0f}, std::pair{64, 17.5f}, std::pair{65, 18.0f}, + std::pair{66, 18.5f}, std::pair{67, 19.0f}, std::pair{68, 20.0f}, std::pair{69, 21.0f}, std::pair{70, 21.5f}, + std::pair{71, 22.0f}, std::pair{72, 22.5f}, std::pair{73, 23.0f}, std::pair{74, 23.5f}, std::pair{75, 24.0f}, + std::pair{76, 24.5f}, std::pair{77, 25.0f}, std::pair{78, 25.5f}, std::pair{79, 26.0f}, std::pair{80, 26.5f}, + std::pair{81, 27.0f}, std::pair{82, 27.5f}, std::pair{83, 28.0f}, std::pair{84, 28.5f}, std::pair{85, 29.0f}, + std::pair{86, 29.5f}, std::pair{87, 30.0f}, std::pair{88, 30.5f}, + }; + + for (const auto &[fahrenheit, mitsubishi_celsius] : cases) { + EXPECT_FLOAT_EQ(mapping.to_mitsubishi(fahrenheit), mitsubishi_celsius); + EXPECT_FLOAT_EQ(mapping.from_mitsubishi(mitsubishi_celsius), fahrenheit); + } + const auto traits = sut.traits(); + EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::FAHRENHEIT); + EXPECT_FLOAT_EQ(traits.get_visual_min_temperature(), 61.0f); + EXPECT_FLOAT_EQ(traits.get_visual_max_temperature(), 88.0f); + EXPECT_FLOAT_EQ(traits.get_visual_target_temperature_step(), 1.0f); + EXPECT_FLOAT_EQ(traits.get_visual_current_temperature_step(), 1.0f); +} + +TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingUsesLinearConversionOutsideSetpointRange) { + auto mapping = TemperatureMapping(); + mapping.set_use_fahrenheit(true); + + const std::array cases{ + std::pair{0.0f, 32.0f}, std::pair{10.0f, 50.0f}, std::pair{15.5f, 59.9f}, + std::pair{31.0f, 87.8f}, std::pair{35.0f, 95.0f}, std::pair{40.0f, 104.0f}, + }; + + for (const auto &[celsius, fahrenheit] : cases) { + EXPECT_FLOAT_EQ(mapping.from_mitsubishi(celsius), fahrenheit); + } +} + TEST(MitsubishiCN105ClimateTests, SupportedSwingModeOffLeavesTraitsEmpty) { TestableMitsubishiCN105Climate sut; diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h index f542880eef..ee287d2548 100644 --- a/tests/components/mitsubishi_cn105/common.h +++ b/tests/components/mitsubishi_cn105/common.h @@ -73,6 +73,7 @@ class TestableMitsubishiCN105Climate : public MitsubishiCN105Climate { using MitsubishiCN105Climate::last_non_swing_wide_vane_mode_; MitsubishiCN105::Status &status() { return const_cast(this->component_.status()); } + void set_use_fahrenheit(bool value) { this->component_.set_use_fahrenheit(value); } protected: MitsubishiCN105Component component_; diff --git a/tests/components/mitsubishi_cn105/common.yaml b/tests/components/mitsubishi_cn105/common.yaml index fc14724786..3f7e8c8f95 100644 --- a/tests/components/mitsubishi_cn105/common.yaml +++ b/tests/components/mitsubishi_cn105/common.yaml @@ -3,6 +3,7 @@ mitsubishi_cn105: uart_id: uart_bus update_interval: 30s telemetry_request_min_interval: 120s + use_fahrenheit: true vane: on_state: - logger.log: From dccf55eadc6c41eaadeca24d10d7cd470ccf8bd7 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 21 Aug 2026 23:53:16 -0500 Subject: [PATCH 1625/1815] [remote_transmitter] Use hardware PWM on rtl87xx to fix watchdog crash (#18579) --- .../components/remote_transmitter/__init__.py | 4 +- .../remote_transmitter/remote_transmitter.cpp | 3 +- .../remote_transmitter/remote_transmitter.h | 13 +- .../remote_transmitter_rtl87xx.cpp | 137 ++++++++++++++++++ .../remote_transmitter/test.rtl87xx-ard.yaml | 7 + 5 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp create mode 100644 tests/components/remote_transmitter/test.rtl87xx-ard.yaml diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index a97b925e06..9d8761ea90 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -185,12 +185,14 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP32_IDF, }, + "remote_transmitter_rtl87xx.cpp": { + PlatformFramework.RTL87XX_ARDUINO, + }, "remote_transmitter.cpp": { PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP32_IDF, PlatformFramework.ESP8266_ARDUINO, PlatformFramework.BK72XX_ARDUINO, - PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, PlatformFramework.RP2_ARDUINO, }, diff --git a/esphome/components/remote_transmitter/remote_transmitter.cpp b/esphome/components/remote_transmitter/remote_transmitter.cpp index 31e7464314..67341e936f 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter.cpp @@ -2,7 +2,8 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -#if defined(USE_LIBRETINY) || defined(USE_ESP8266) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) +#if (defined(USE_LIBRETINY) && !defined(USE_RTL87XX)) || defined(USE_ESP8266) || defined(USE_RP2) || \ + (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) namespace esphome::remote_transmitter { diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index 0aa04682ba..94bcb74b09 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -65,14 +65,21 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa protected: void send_internal(uint32_t send_times, uint32_t send_wait) override; #if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) + void await_target_time_(); + uint32_t target_time_{0}; +#endif +#if defined(USE_ESP8266) || (defined(USE_LIBRETINY) && !defined(USE_RTL87XX)) || defined(USE_RP2) || \ + (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) void calculate_on_off_time_(uint32_t carrier_frequency, uint32_t *on_time_period, uint32_t *off_time_period); void mark_(uint32_t on_time, uint32_t off_time, uint32_t usec); void space_(uint32_t usec); - - void await_target_time_(); - uint32_t target_time_{0}; +#endif +#ifdef USE_RTL87XX + // Carrier frequency the PWM is currently configured for; 0 = not yet configured + uint32_t current_carrier_frequency_{0}; + void *pwm_{nullptr}; // pwmout_t*, opaque here to keep the SDK header out of this shared header #endif #if defined(USE_ESP32) && SOC_RMT_SUPPORTED diff --git a/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp b/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp new file mode 100644 index 0000000000..b7078b9d69 --- /dev/null +++ b/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp @@ -0,0 +1,137 @@ +#include "remote_transmitter.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +// clang-tidy cannot parse the Realtek SDK headers pulled in via ArduinoPrivate.h +#if defined(USE_RTL87XX) && !defined(CLANG_TIDY) + +// ArduinoPrivate.h = Arduino.h + the SDK's mbed HAL (pwmout etc.) with the core's fixes for +// type-name collisions between the two (e.g. PinMode) +#include +#include +#include + +namespace esphome::remote_transmitter { + +static const char *const TAG = "remote_transmitter"; + +// The carrier is generated by the PWM peripheral instead of bit-banging the pin: software carrier +// generation requires disabling interrupts for the whole frame, but this core's micros() is derived +// from the FreeRTOS tick and freezes while interrupts are off, so the timing loop never advances and +// the watchdog resets the chip. With hardware PWM, software only times the mark/space envelope and +// interrupts can stay enabled. +// +// The PWM is driven through the SDK's pwmout HAL directly rather than the Arduino wiring layer: +// changing the carrier frequency via the wiring requires a GPIO/PWM pin mode round-trip, which +// use-after-frees the core's per-pin state (pinRemoveMode() frees without nulling) and corrupts the +// heap. pwmout_period_us() changes the frequency with no mode transitions. + +void RemoteTransmitterComponent::setup() { + // Deliberately no pin_->setup(): registering the pin as GPIO claims it in the SDK's pin + // management, and the pad is then never handed over to the PWM peripheral -- pwmout_init() + // must own the pin from the start. + PinInfo *info = pinInfo(this->pin_->get_pin()); + if (info == nullptr || !pinSupported(info, PIN_PWM)) { + // checked here because the AmebaZ (RTL8710B) SDK does not report PWM init failure + ESP_LOGE(TAG, "Pin %u is not PWM-capable", this->pin_->get_pin()); + this->mark_failed(); + return; + } + auto *pwm = new pwmout_t(); + this->pwm_ = pwm; + pwmout_init(pwm, static_cast(info->gpio)); +#if LT_RTL8720C + // only the AmebaZ2 SDK's pwmout_s reports init success + if (!pwm->is_init) { + ESP_LOGE(TAG, "PWM init failed on pin %u", this->pin_->get_pin()); + delete pwm; + this->pwm_ = nullptr; + this->mark_failed(); + return; + } +#endif + pwmout_period_us(pwm, 26); // placeholder; the real carrier period is set per transmission + pwmout_write(pwm, this->pin_->is_inverted() ? 1.0f : 0.0f); +} + +void RemoteTransmitterComponent::dump_config() { + ESP_LOGCONFIG(TAG, + "Remote Transmitter:\n" + " Carrier Duty: %u%%", + this->carrier_duty_percent_); + LOG_PIN(" Pin: ", this->pin_); +} + +void RemoteTransmitterComponent::await_target_time_() { + const uint32_t current_time = micros(); + if (this->target_time_ == 0) { + this->target_time_ = current_time; + } else { + while ((int32_t) (this->target_time_ - micros()) > 0) { + } + } +} + +void RemoteTransmitterComponent::digital_write(bool value) { + if (this->pwm_ == nullptr) + return; + pwmout_write(static_cast(this->pwm_), (value != this->pin_->is_inverted()) ? 1.0f : 0.0f); +} + +void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { + auto *pwm = static_cast(this->pwm_); + if (pwm == nullptr) { + ESP_LOGW(TAG, "Cannot send: PWM not initialized"); + return; + } + ESP_LOGD(TAG, "Sending remote code"); + const uint32_t carrier_frequency = this->temp_.get_carrier_frequency(); + // unmodulated protocols (no carrier or 100% duty) drive the pin constantly during marks + float mark_duty = + (carrier_frequency > 0 && this->carrier_duty_percent_ < 100) ? this->carrier_duty_percent_ / 100.0f : 1.0f; + float space_duty = 0.0f; + if (this->pin_->is_inverted()) { + mark_duty = 1.0f - mark_duty; + space_duty = 1.0f; + } + if (carrier_frequency > 0 && carrier_frequency != this->current_carrier_frequency_) { + // round(1000000/freq), clamped like the bit-bang path so a bad lambda can't hand the SDK a zero period + const uint32_t period = std::max(uint32_t(1), (1000000UL + carrier_frequency / 2) / carrier_frequency); + pwmout_period_us(pwm, period); + this->current_carrier_frequency_ = carrier_frequency; + } + this->transmit_trigger_.trigger(); + const UBaseType_t saved_priority = uxTaskPriorityGet(nullptr); + for (uint32_t i = 0; i < send_times; i++) { + // Boost task priority for the frame only, so WiFi/lwIP tasks can't preempt mid-frame and + // merge adjacent marks. Interrupts stay enabled: micros() needs the FreeRTOS tick, and + // ISR latency is within receiver tolerance. + vTaskPrioritySet(nullptr, configMAX_PRIORITIES - 1); + // Re-anchor every iteration: a late exit from the normal-priority gap wait must not + // leave the schedule behind micros(), which would compress the next frame's leading items + this->target_time_ = 0; + for (int32_t item : this->temp_.get_data()) { + const bool is_mark = item > 0; + this->await_target_time_(); + pwmout_write(pwm, is_mark ? mark_duty : space_duty); + this->target_time_ += is_mark ? uint32_t(item) : uint32_t(-item); + App.feed_wdt(); + } + this->await_target_time_(); // wait for duration of last pulse + pwmout_write(pwm, space_duty); + vTaskPrioritySet(nullptr, saved_priority); + if (i + 1 < send_times) { + // The repeat gap is user-configurable and unbounded, so wait it out at normal + // priority, feeding the watchdog + const uint32_t gap_end = micros() + send_wait; + while ((int32_t) (gap_end - micros()) > 0) { + App.feed_wdt(); + } + } + } + this->complete_trigger_.trigger(); +} + +} // namespace esphome::remote_transmitter + +#endif // USE_RTL87XX && !CLANG_TIDY diff --git a/tests/components/remote_transmitter/test.rtl87xx-ard.yaml b/tests/components/remote_transmitter/test.rtl87xx-ard.yaml new file mode 100644 index 0000000000..769adbdf5c --- /dev/null +++ b/tests/components/remote_transmitter/test.rtl87xx-ard.yaml @@ -0,0 +1,7 @@ +remote_transmitter: + id: xmitr + pin: GPIO12 + carrier_duty_percent: 50% + +packages: + buttons: !include common-buttons.yaml From ef1d77885dd5a7f1beef4e3d34e22e26d5661fa1 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:04:08 -0500 Subject: [PATCH 1626/1815] [captive_portal] Show each network once in the scan list (#17847) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: Bluetooth Devices Bot --- .../captive_portal/captive_portal.cpp | 11 +- esphome/components/captive_portal/scan_list.h | 28 ++++ esphome/components/wifi/wifi_component.h | 1 + tests/components/captive_portal/__init__.py | 10 ++ .../captive_portal/scan_list_test.cpp | 130 ++++++++++++++++++ 5 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 esphome/components/captive_portal/scan_list.h create mode 100644 tests/components/captive_portal/__init__.py create mode 100644 tests/components/captive_portal/scan_list_test.cpp diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 704a61d4de..ffd121499b 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -6,6 +6,7 @@ #include "esphome/core/string_ref.h" #include "esphome/components/wifi/wifi_component.h" #include "captive_index.h" +#include "scan_list.h" namespace esphome::captive_portal { @@ -33,8 +34,10 @@ void CaptivePortal::handle_config(AsyncWebServerRequest *request) { // Invariant: only bounded in-memory work under the lock; the network send // happens later in request->send() wifi::ScanResultsLock lock(wifi::global_wifi_component); - for (const auto &scan : wifi::global_wifi_component->get_scan_result()) { - if (scan.get_is_hidden()) + const auto &results = wifi::global_wifi_component->get_scan_result(); + for (const auto &scan : results) { + bool with_auth = false; + if (!should_show_scan_entry(results, scan, with_auth)) continue; json_escape_into_buffer(escaped_ssid, scan.get_ssid()); @@ -44,10 +47,10 @@ void CaptivePortal::handle_config(AsyncWebServerRequest *request) { stream->print(ESPHOME_F("\",\"rssi\":")); stream->print(scan.get_rssi()); stream->print(ESPHOME_F(",\"lock\":")); - stream->print(scan.get_with_auth()); + stream->print(with_auth); stream->print(ESPHOME_F("}")); #else - stream->printf(R"(,{"ssid":"%s","rssi":%d,"lock":%d})", escaped_ssid, scan.get_rssi(), scan.get_with_auth()); + stream->printf(R"(,{"ssid":"%s","rssi":%d,"lock":%d})", escaped_ssid, scan.get_rssi(), with_auth); #endif } } diff --git a/esphome/components/captive_portal/scan_list.h b/esphome/components/captive_portal/scan_list.h new file mode 100644 index 0000000000..d24a88a670 --- /dev/null +++ b/esphome/components/captive_portal/scan_list.h @@ -0,0 +1,28 @@ +#pragma once +#include + +namespace esphome::captive_portal { + +// A scan lists every BSSID, so one SSID can appear several times. Returns true for +// the strongest entry per SSID (earliest on ties), never for hidden entries. scan +// must be an element of results. with_auth is written only when returning true and +// is set if any entry with that SSID needs a key. Templated for host tests. +template +bool should_show_scan_entry(const Results &results, const Entry &scan, bool &with_auth) { + if (scan.get_is_hidden()) + return false; + const int8_t rssi = scan.get_rssi(); + bool any_auth = false; + for (const auto &other : results) { + if (other.get_is_hidden() || !other.ssid_equals(scan)) + continue; + // Same array, so address order is index order. scan fails both checks against itself. + if (other.get_rssi() > rssi || (other.get_rssi() == rssi && &other < &scan)) + return false; + any_auth |= other.get_with_auth(); + } + with_auth = any_auth; + return true; +} + +} // namespace esphome::captive_portal diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index ff90fbe49b..c54fbc004b 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -327,6 +327,7 @@ class WiFiScanResult { int8_t get_rssi() const { return this->rssi_; } bool get_with_auth() const { return this->with_auth_; } bool get_is_hidden() const { return this->is_hidden_; } + bool ssid_equals(const WiFiScanResult &other) const { return this->ssid_ == other.ssid_; } int8_t get_priority() const { return priority_; } void set_priority(int8_t priority) { priority_ = priority; } diff --git a/tests/components/captive_portal/__init__.py b/tests/components/captive_portal/__init__.py new file mode 100644 index 0000000000..1ac0704a59 --- /dev/null +++ b/tests/components/captive_portal/__init__.py @@ -0,0 +1,10 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The scan list helper is header-only and needs none of the component's real + # dependencies. Pulling them in breaks the host build: web_server_base + # includes ESPAsyncWebServer.h and ota.web_server includes md5/md5.h, neither + # of which exists there. + manifest.dependencies = [] + manifest.auto_load = [] diff --git a/tests/components/captive_portal/scan_list_test.cpp b/tests/components/captive_portal/scan_list_test.cpp new file mode 100644 index 0000000000..f67581dc0b --- /dev/null +++ b/tests/components/captive_portal/scan_list_test.cpp @@ -0,0 +1,130 @@ +#include + +#include +#include +#include + +#include "esphome/components/captive_portal/scan_list.h" + +namespace esphome::captive_portal::testing { + +namespace { + +// Stand-in for wifi::WiFiScanResult, which does not compile on the host. +struct Entry { + std::string ssid; + int8_t rssi; + bool with_auth{true}; + bool is_hidden{false}; + + // Compares length and bytes like CompactString does, so an embedded NUL counts. + bool ssid_equals(const Entry &other) const { return this->ssid == other.ssid; } + int8_t get_rssi() const { return this->rssi; } + bool get_with_auth() const { return this->with_auth; } + bool get_is_hidden() const { return this->is_hidden; } +}; + +// One row as the portal would emit it. +struct Row { + std::string ssid; + int8_t rssi; + bool lock; + + bool operator==(const Row &rhs) const { return ssid == rhs.ssid && rssi == rhs.rssi && lock == rhs.lock; } +}; + +// Walk the results the way handle_config does and collect the rows that survive. +std::vector rows(const std::vector &results) { + std::vector out; + for (size_t i = 0; i < results.size(); i++) { + bool with_auth = false; + if (!should_show_scan_entry(results, results[i], with_auth)) + continue; + out.push_back({results[i].ssid, results[i].rssi, with_auth}); + } + return out; +} + +} // namespace + +TEST(ScanList, SingleEntryShown) { + std::vector results = {{"Home", -60}}; + EXPECT_EQ(rows(results), (std::vector{{"Home", -60, true}})); +} + +TEST(ScanList, DistinctSsidsAllShownInOrder) { + std::vector results = {{"Home", -60}, {"Guest", -70}, {"Cafe", -40}}; + EXPECT_EQ(rows(results), (std::vector{{"Home", -60, true}, {"Guest", -70, true}, {"Cafe", -40, true}})); +} + +// Results are ordered by connection preference, not RSSI, so the strongest entry +// can sit anywhere in the list. +TEST(ScanList, SameSsidKeepsStrongest) { + std::vector results = {{"Home", -70}, {"Home", -50}, {"Home", -60}}; + EXPECT_EQ(rows(results), (std::vector{{"Home", -50, true}})); +} + +TEST(ScanList, EqualRssiKeepsFirst) { + std::vector results = {{"Home", -60}, {"Home", -60}, {"Home", -60}}; + bool with_auth = false; + EXPECT_TRUE(should_show_scan_entry(results, results[0], with_auth)); + EXPECT_FALSE(should_show_scan_entry(results, results[1], with_auth)); + EXPECT_FALSE(should_show_scan_entry(results, results[2], with_auth)); + EXPECT_EQ(rows(results), (std::vector{{"Home", -60, true}})); +} + +// with_auth is an out-parameter that must only be written for a shown entry. +TEST(ScanList, WithAuthUntouchedWhenNotShown) { + std::vector results = {{"Home", -50, false}, {"Home", -70, true}}; + bool with_auth = false; + EXPECT_FALSE(should_show_scan_entry(results, results[1], with_auth)); + EXPECT_FALSE(with_auth); +} + +TEST(ScanList, DuplicatesInterleavedWithOtherNetworks) { + std::vector results = {{"Home", -70}, {"Guest", -55}, {"Home", -50}, {"Guest", -65}}; + EXPECT_EQ(rows(results), (std::vector{{"Guest", -55, true}, {"Home", -50, true}})); +} + +// Hidden networks scan with an empty SSID. They are never listed and do not +// collapse into each other or into anything else. +TEST(ScanList, HiddenEntriesNeverShown) { + std::vector results = {{"", -40, true, true}, {"Home", -70}, {"", -30, true, true}}; + EXPECT_EQ(rows(results), (std::vector{{"Home", -70, true}})); +} + +// On ESP8266 the hidden flag comes from the driver alongside a real SSID, so a +// hidden access point can share its name with a visible one. It must not +// outrank that visible entry and leave the network unlisted. +TEST(ScanList, HiddenEntryDoesNotSuppressVisibleSameSsid) { + std::vector results = {{"Home", -40, true, true}, {"Home", -70}}; + EXPECT_EQ(rows(results), (std::vector{{"Home", -70, true}})); +} + +// An open access point and a secured one sharing an SSID collapse to one row that +// still asks for a password, whichever of them is strongest. +TEST(ScanList, LockSetWhenAnyEntryRequiresAuth) { + std::vector open_stronger = {{"Home", -50, false}, {"Home", -70, true}}; + EXPECT_EQ(rows(open_stronger), (std::vector{{"Home", -50, true}})); + + std::vector secured_stronger = {{"Home", -70, false}, {"Home", -50, true}}; + EXPECT_EQ(rows(secured_stronger), (std::vector{{"Home", -50, true}})); +} + +TEST(ScanList, LockClearWhenEveryEntryIsOpen) { + std::vector results = {{"Cafe", -60, false}, {"Cafe", -50, false}}; + EXPECT_EQ(rows(results), (std::vector{{"Cafe", -50, false}})); +} + +// The auth flag of an unrelated network must not leak into another SSID's row. +TEST(ScanList, LockIsPerSsid) { + std::vector results = {{"Cafe", -60, false}, {"Home", -50, true}}; + EXPECT_EQ(rows(results), (std::vector{{"Cafe", -60, false}, {"Home", -50, true}})); +} + +TEST(ScanList, EmptyListShowsNothing) { + std::vector results; + EXPECT_TRUE(rows(results).empty()); +} + +} // namespace esphome::captive_portal::testing From ea10f94376d967f2099701abd12902efdc9e7cf8 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Sun, 23 Aug 2026 03:31:47 +1200 Subject: [PATCH 1627/1815] [core] Add type annotations to component Python (10/11) (#18347) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/adc/__init__.py | 5 ++- esphome/components/adc/sensor.py | 6 +-- esphome/components/api/__init__.py | 36 ++++++++++++----- esphome/components/button/__init__.py | 20 ++++++---- esphome/components/climate/__init__.py | 29 ++++++++++---- esphome/components/cover/__init__.py | 40 ++++++++++++++----- .../components/dashboard_import/__init__.py | 10 +++-- esphome/components/debug/__init__.py | 3 +- esphome/components/debug/sensor.py | 3 +- esphome/components/debug/text_sensor.py | 3 +- esphome/components/esp8266/__init__.py | 18 +++++---- esphome/components/esp8266/gpio.py | 15 ++++--- esphome/components/file/image.py | 17 ++++---- esphome/components/globals/__init__.py | 12 ++++-- .../components/gpio/binary_sensor/__init__.py | 5 ++- esphome/components/gpio/one_wire/__init__.py | 3 +- esphome/components/gpio/output/__init__.py | 3 +- esphome/components/gpio/switch/__init__.py | 3 +- esphome/components/homeassistant/__init__.py | 12 ++++-- .../homeassistant/binary_sensor/__init__.py | 3 +- .../homeassistant/number/__init__.py | 3 +- .../homeassistant/sensor/__init__.py | 3 +- .../homeassistant/switch/__init__.py | 3 +- .../homeassistant/text_sensor/__init__.py | 3 +- .../components/homeassistant/time/__init__.py | 3 +- esphome/components/host/__init__.py | 5 ++- esphome/components/host/gpio.py | 9 +++-- esphome/components/host/time/__init__.py | 3 +- esphome/components/i2c/__init__.py | 32 ++++++++------- esphome/components/lock/__init__.py | 34 +++++++++++----- 30 files changed, 230 insertions(+), 114 deletions(-) diff --git a/esphome/components/adc/__init__.py b/esphome/components/adc/__init__.py index 1c50b6b81b..5c763a4f4c 100644 --- a/esphome/components/adc/__init__.py +++ b/esphome/components/adc/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import pins import esphome.codegen as cg from esphome.components.esp32 import ( @@ -16,6 +18,7 @@ from esphome.components.esp32 import ( import esphome.config_validation as cv from esphome.const import CONF_ANALOG, CONF_INPUT, CONF_NUMBER, PLATFORM_ESP8266 from esphome.core import CORE +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] @@ -225,7 +228,7 @@ ESP32_VARIANT_ADC2_PIN_TO_CHANNEL = { } -def validate_adc_pin(value): +def validate_adc_pin(value: Any) -> ConfigType | str: if str(value).upper() == "VCC": if CORE.is_rp2: return pins.internal_gpio_input_pin_schema(29) diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index b2a4382a21..5d1031825e 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -52,7 +52,7 @@ _attenuation = cv.enum(ATTENUATION_MODES, lower=True) _sampling_mode = cv.enum(SAMPLING_MODES, lower=True) -def validate_config(config): +def validate_config(config: ConfigType) -> ConfigType: if config[CONF_RAW] and config.get(CONF_ATTENUATION, None) == "auto": raise cv.Invalid("Automatic attenuation cannot be used when raw output is set") @@ -120,7 +120,7 @@ CONFIG_SCHEMA = cv.All( CONF_ADC_CHANNEL_ID = "adc_channel_id" -def _overlay_io_channels(): +def _overlay_io_channels() -> str: channel_count = CORE.data[CONF_ADC_CHANNEL_ID] entries = ", ".join(f"<&adc {channel_id}>" for channel_id in range(channel_count)) return f""" @@ -132,7 +132,7 @@ def _overlay_io_channels(): """ -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await sensor.register_sensor(var, config) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 912d580a0f..0dc4b905bf 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -1,5 +1,6 @@ import base64 import logging +from typing import Any from esphome import automation from esphome.automation import Condition @@ -129,7 +130,7 @@ def _register_provisioning_source(config: ConfigType) -> ConfigType: return config -def validate_encryption_key(value): +def validate_encryption_key(value: Any) -> str: value = cv.string_strict(value) try: decoded = base64.b64decode(value, validate=True) @@ -217,7 +218,7 @@ def _auto_detect_supports_response(config: ConfigType) -> ConfigType: return config -def _validate_supports_response(value): +def _validate_supports_response(value: Any) -> str: """Validate supports_response after auto-detection has set the value.""" return cv.enum(SUPPORTS_RESPONSE_OPTIONS, lower=True)(value) @@ -256,7 +257,7 @@ ENCRYPTION_SCHEMA = cv.Schema( ) -def _encryption_schema(config): +def _encryption_schema(config: ConfigType | None) -> ConfigType: if config is None: config = {} return ENCRYPTION_SCHEMA(config) @@ -393,7 +394,7 @@ async def to_code(config: ConfigType) -> None: if actions := config.get(CONF_ACTIONS, []): # Collect all triggers first, then register all at once with initializer_list - triggers: list[cg.Pvariable] = [] + triggers: list[cg.MockObj] = [] for conf in actions: func_args: list[tuple[MockObj, str]] = [] service_template_args: list[MockObj] = [] # User service argument types @@ -581,7 +582,7 @@ async def homeassistant_service_to_code( action_id: ID, template_arg: cg.TemplateArguments, args: TemplateArgsType, -): +) -> MockObj: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") serv = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, serv, False) @@ -647,7 +648,7 @@ async def homeassistant_service_to_code( return var -def validate_homeassistant_event(value): +def validate_homeassistant_event(value: Any) -> str: value = cv.string(value) if not value.startswith("esphome."): raise cv.Invalid( @@ -676,7 +677,12 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema( HOMEASSISTANT_EVENT_ACTION_SCHEMA, synchronous=True, ) -async def homeassistant_event_to_code(config, action_id, template_arg, args): +async def homeassistant_event_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") serv = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, serv, True) @@ -724,7 +730,12 @@ HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA = cv.maybe_simple_value( HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA, synchronous=True, ) -async def homeassistant_tag_scanned_to_code(config, action_id, template_arg, args): +async def homeassistant_tag_scanned_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") serv = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, serv, True) @@ -740,7 +751,7 @@ CONF_SUCCESS = "success" CONF_ERROR_MESSAGE = "error_message" -def _validate_api_respond_data(config): +def _validate_api_respond_data(config: ConfigType) -> ConfigType: """Set flag during validation so AUTO_LOAD can include json component.""" if CONF_DATA in config: CORE.data.setdefault(DOMAIN, {})[CONF_CAPTURE_RESPONSE] = True @@ -824,7 +835,12 @@ API_CONNECTED_CONDITION_SCHEMA = cv.Schema( @automation.register_condition( "api.connected", APIConnectedCondition, API_CONNECTED_CONDITION_SCHEMA ) -async def api_connected_to_code(config, condition_id, template_arg, args): +async def api_connected_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) templ = await cg.templatable(config[CONF_STATE_SUBSCRIPTION_ONLY], args, cg.bool_) cg.add(var.set_state_subscription_only(templ)) diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index a4245f43e6..ee24002b8a 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -16,14 +16,15 @@ from esphome.const import ( DEVICE_CLASS_RESTART, DEVICE_CLASS_UPDATE, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +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, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -88,7 +89,7 @@ _CALLBACK_AUTOMATIONS = ( @setup_entity("button") -async def setup_button_core_(var, config): +async def setup_button_core_(var: MockObj, config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) setup_device_class(config) @@ -101,7 +102,7 @@ async def setup_button_core_(var, config): await web_server.add_entity_config(var, web_server_config) -async def register_button(var, config): +async def register_button(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) queue_entity_register("button", config) @@ -109,7 +110,7 @@ async def register_button(var, config): await setup_button_core_(var, config) -async def new_button(config, *args): +async def new_button(config: ConfigType, *args: SafeExpType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID], *args) await register_button(var, config) return var @@ -125,11 +126,16 @@ BUTTON_PRESS_SCHEMA = maybe_simple_id( @automation.register_action( "button.press", PressAction, BUTTON_PRESS_SCHEMA, synchronous=True ) -async def button_press_to_code(config, action_id, template_arg, args): +async def button_press_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(button_ns.using) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index fe050fca22..80dd913fba 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components import mqtt, web_server @@ -48,13 +50,19 @@ from esphome.const import ( CONF_VISUAL, CONF_WEB_SERVER, ) -from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, Lambda, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import LambdaExpression, MockObjClass +from esphome.cpp_generator import ( + LambdaExpression, + MockObj, + MockObjClass, + TemplateArgsType, +) +from esphome.types import ConfigType, SafeExpType IS_PLATFORM_COMPONENT = True @@ -132,7 +140,7 @@ VISUAL_TEMPERATURE_STEP_SCHEMA = cv.Schema( ) -def visual_temperature_step(value): +def visual_temperature_step(value: Any) -> ConfigType: # Allow defining target/current temperature steps separately if isinstance(value, dict): return VISUAL_TEMPERATURE_STEP_SCHEMA(value) @@ -273,7 +281,7 @@ def climate_schema( @setup_entity("climate") -async def setup_climate_core_(var, config): +async def setup_climate_core_(var: MockObj, config: ConfigType) -> None: visual = config.get(CONF_VISUAL, {}) if (min_temp := visual.get(CONF_MIN_TEMPERATURE)) is not None: cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES") @@ -443,7 +451,7 @@ async def setup_climate_core_(var, config): await web_server.add_entity_config(var, web_server_config) -async def register_climate(var, config): +async def register_climate(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) queue_entity_register("climate", config) @@ -451,7 +459,7 @@ async def register_climate(var, config): await setup_climate_core_(var, config) -async def new_climate(config, *args): +async def new_climate(config: ConfigType, *args: SafeExpType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID], *args) await register_climate(var, config) return var @@ -485,7 +493,12 @@ CLIMATE_CONTROL_ACTION_SCHEMA = cv.Schema( CLIMATE_CONTROL_ACTION_SCHEMA, synchronous=True, ) -async def climate_control_to_code(config, action_id, template_arg, args): +async def climate_control_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) # All configured fields are folded into a single stateless lambda whose @@ -549,5 +562,5 @@ async def climate_control_to_code(config, action_id, template_arg, args): @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(climate_ns.using) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index 7639e15334..011b2c2f04 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -46,7 +46,7 @@ from esphome.core.entity_helpers import ( setup_entity, ) from esphome.cpp_generator import LambdaExpression, MockObj, MockObjClass -from esphome.types import ConfigType, TemplateArgsType +from esphome.types import ConfigType, SafeExpType, TemplateArgsType IS_PLATFORM_COMPONENT = True @@ -162,7 +162,7 @@ _COVER_SCHEMA = ( _COVER_SCHEMA.add_extra(entity_duplicate_validator("cover")) -def _validate_mqtt_state_topics(config): +def _validate_mqtt_state_topics(config: ConfigType) -> ConfigType: if config.get(CONF_MQTT_JSON_STATE_PAYLOAD): if CONF_POSITION_STATE_TOPIC in config: raise cv.Invalid( @@ -201,7 +201,7 @@ def cover_schema( @setup_entity("cover") -async def setup_cover_core_(var, config): +async def setup_cover_core_(var: MockObj, config: ConfigType) -> None: setup_device_class(config) if CONF_ON_OPEN in config: @@ -235,7 +235,7 @@ async def setup_cover_core_(var, config): await web_server.add_entity_config(var, web_server_config) -async def register_cover(var, config): +async def register_cover(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) queue_entity_register("cover", config) @@ -243,7 +243,7 @@ async def register_cover(var, config): await setup_cover_core_(var, config) -async def new_cover(config, *args): +async def new_cover(config: ConfigType, *args: SafeExpType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID], *args) await register_cover(var, config) return var @@ -259,7 +259,12 @@ COVER_ACTION_SCHEMA = maybe_simple_id( @automation.register_action( "cover.open", OpenAction, COVER_ACTION_SCHEMA, synchronous=True ) -async def cover_open_to_code(config, action_id, template_arg, args): +async def cover_open_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -267,7 +272,12 @@ async def cover_open_to_code(config, action_id, template_arg, args): @automation.register_action( "cover.close", CloseAction, COVER_ACTION_SCHEMA, synchronous=True ) -async def cover_close_to_code(config, action_id, template_arg, args): +async def cover_close_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -275,7 +285,12 @@ async def cover_close_to_code(config, action_id, template_arg, args): @automation.register_action( "cover.stop", StopAction, COVER_ACTION_SCHEMA, synchronous=True ) -async def cover_stop_to_code(config, action_id, template_arg, args): +async def cover_stop_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -283,7 +298,12 @@ async def cover_stop_to_code(config, action_id, template_arg, args): @automation.register_action( "cover.toggle", ToggleAction, COVER_ACTION_SCHEMA, synchronous=True ) -async def cover_toggle_to_code(config, action_id, template_arg, args): +async def cover_toggle_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -421,5 +441,5 @@ automation.register_condition( @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(cover_ns.using) diff --git a/esphome/components/dashboard_import/__init__.py b/esphome/components/dashboard_import/__init__.py index 31559a514c..c27669d77e 100644 --- a/esphome/components/dashboard_import/__init__.py +++ b/esphome/components/dashboard_import/__init__.py @@ -2,6 +2,7 @@ import base64 from pathlib import Path import re import secrets +from typing import Any import requests from ruamel.yaml import YAML @@ -13,6 +14,7 @@ import esphome.config_validation as cv from esphome.const import CONF_ESPHOME, CONF_PROJECT, CONF_REF, CONF_WIFI import esphome.final_validate as fv from esphome.happy_eyeballs import ensure_happy_eyeballs +from esphome.types import ConfigType from esphome.yaml_util import dump dashboard_import_ns = cg.esphome_ns.namespace("dashboard_import") @@ -23,14 +25,14 @@ DEPENDENCIES = ["api"] CODEOWNERS = ["@esphome/core"] -def validate_import_url(value): +def validate_import_url(value: Any) -> str: value = cv.string_strict(value) value = cv.Length(max=255)(value) validate_source_shorthand(value) return value -def validate_full_url(config): +def validate_full_url(config: ConfigType) -> ConfigType: if not config[CONF_IMPORT_FULL_CONFIG]: return config source = validate_source_shorthand(config[CONF_PACKAGE_IMPORT_URL]) @@ -55,7 +57,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: full_config = fv.full_config.get()[CONF_ESPHOME] if CONF_PROJECT not in full_config: raise cv.Invalid( @@ -73,7 +75,7 @@ wifi: """ -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_define("USE_DASHBOARD_IMPORT") url = config[CONF_PACKAGE_IMPORT_URL] if config[CONF_IMPORT_FULL_CONFIG]: diff --git a/esphome/components/debug/__init__.py b/esphome/components/debug/__init__.py index 3e94d04f21..a889d13329 100644 --- a/esphome/components/debug/__init__.py +++ b/esphome/components/debug/__init__.py @@ -12,6 +12,7 @@ from esphome.const import ( PlatformFramework, ) from esphome.core import CORE +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["logger"] @@ -45,7 +46,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: if CORE.using_zephyr: zephyr_add_prj_conf("HWINFO", True) # gdb thread support diff --git a/esphome/components/debug/sensor.py b/esphome/components/debug/sensor.py index a018ce5c3b..72e2efebc2 100644 --- a/esphome/components/debug/sensor.py +++ b/esphome/components/debug/sensor.py @@ -21,6 +21,7 @@ from esphome.const import ( UNIT_MILLISECOND, UNIT_PERCENT, ) +from esphome.types import ConfigType from . import ( # noqa: F401 pylint: disable=unused-import CONF_DEBUG_ID, @@ -111,7 +112,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: debug_component = await cg.get_variable(config[CONF_DEBUG_ID]) if free_conf := config.get(CONF_FREE): diff --git a/esphome/components/debug/text_sensor.py b/esphome/components/debug/text_sensor.py index c69b8d9461..9d4fcc1b42 100644 --- a/esphome/components/debug/text_sensor.py +++ b/esphome/components/debug/text_sensor.py @@ -7,6 +7,7 @@ from esphome.const import ( ICON_CHIP, ICON_RESTART, ) +from esphome.types import ConfigType from . import ( # noqa: F401 pylint: disable=unused-import CONF_DEBUG_ID, @@ -33,7 +34,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: debug_component = await cg.get_variable(config[CONF_DEBUG_ID]) if CONF_DEVICE in config: diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 2161a902cb..3dd9750c6f 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -3,6 +3,7 @@ from pathlib import Path import platform import re import subprocess +from typing import Any import esphome.codegen as cg import esphome.config_validation as cv @@ -31,6 +32,7 @@ from esphome.core import ( from esphome.core.config import BOARD_MAX_LENGTH from esphome.helpers import IS_MACOS, copy_file_if_changed from esphome.platformio.toolchain import copy_ccache_script +from esphome.storage_json import StorageJSON from esphome.types import ConfigType from .boards import BOARDS, ESP8266_LD_SCRIPTS @@ -88,7 +90,7 @@ def lambdas_use_scanf_float(config: ConfigType) -> bool: return False -def set_core_data(config): +def set_core_data(config: ConfigType) -> ConfigType: CORE.data[KEY_ESP8266] = {} CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_ESP8266 CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = "arduino" @@ -102,7 +104,7 @@ def set_core_data(config): return config -def get_download_types(storage_json): +def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: """Binary-download entries for a built ESP8266 firmware. Used by device-builder (esphome/device-builder), via @@ -157,7 +159,7 @@ ARDUINO_3_PLATFORM_VERSION = cv.Version(3, 2, 0) ARDUINO_4_PLATFORM_VERSION = cv.Version(4, 2, 1) -def _arduino_check_versions(value): +def _arduino_check_versions(value: ConfigType) -> ConfigType: value = value.copy() lookups = { "dev": (cv.Version(3, 1, 2), "https://github.com/esp8266/Arduino.git"), @@ -200,7 +202,7 @@ def _arduino_check_versions(value): return value -def _parse_platform_version(value): +def _parse_platform_version(value: Any) -> str: try: # if platform version is a valid version constraint, prefix the default package cv.platformio_version_constraint(value) @@ -275,7 +277,7 @@ def check_rosetta() -> None: @coroutine_with_priority(CoroPriority.PLATFORM) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add(esp8266_ns.setup_preferences()) cg.add_platformio_option("lib_ldf_mode", "off") @@ -504,7 +506,7 @@ ESP8266_EXCEPTION_CODES = { } -def _decode_pc(config, addr): +def _decode_pc(config: ConfigType, addr: str) -> None: from esphome.platformio import toolchain idedata = toolchain.get_idedata(config) @@ -525,7 +527,7 @@ def _decode_pc(config, addr): _LOGGER.warning("Decoded %s", translation) -def _parse_register(config, regex, line): +def _parse_register(config: ConfigType, regex: re.Pattern[str], line: str) -> None: match = regex.match(line) if match is not None: _decode_pc(config, match.group(1)) @@ -549,7 +551,7 @@ STACKTRACE_BAD_ALLOC_RE = re.compile( STACKTRACE_ESP8266_BACKTRACE_PC_RE = re.compile(r"4[0-9a-f]{7}") -def process_stacktrace(config, line, backtrace_state): +def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> bool: line = line.strip() # ESP8266 Exception type match = re.match(STACKTRACE_ESP8266_EXCEPTION_TYPE_RE, line) diff --git a/esphome/components/esp8266/gpio.py b/esphome/components/esp8266/gpio.py index 64be4a6495..356af6e006 100644 --- a/esphome/components/esp8266/gpio.py +++ b/esphome/components/esp8266/gpio.py @@ -1,5 +1,6 @@ from dataclasses import dataclass import logging +from typing import Any from esphome import pins import esphome.codegen as cg @@ -18,6 +19,8 @@ from esphome.const import ( PLATFORM_ESP8266, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import boards from .const import KEY_BOARD, KEY_ESP8266, KEY_PIN_INITIAL_STATES, esp8266_ns @@ -27,7 +30,7 @@ _LOGGER = logging.getLogger(__name__) ESP8266GPIOPin = esp8266_ns.class_("ESP8266GPIOPin", cg.InternalGPIOPin) -def _lookup_pin(value): +def _lookup_pin(value: str) -> int: board = CORE.data[KEY_ESP8266][KEY_BOARD] board_pins = boards.ESP8266_BOARD_PINS.get(board, {}) @@ -42,7 +45,7 @@ def _lookup_pin(value): raise cv.Invalid(f"Cannot resolve pin name '{value}' for board {board}.") -def _translate_pin(value): +def _translate_pin(value: Any) -> int: if isinstance(value, dict) or value is None: raise cv.Invalid( "This variable only supports pin numbers, not full pin schemas " @@ -69,7 +72,7 @@ _ESP_SDIO_PINS = { } -def validate_gpio_pin(value): +def validate_gpio_pin(value: Any) -> int: value = _translate_pin(value) if value < 0 or value > 17: raise cv.Invalid(f"ESP8266: Invalid pin number: {value}") @@ -86,7 +89,7 @@ def validate_gpio_pin(value): return value -def validate_supports(value): +def validate_supports(value: ConfigType) -> ConfigType: num = value[CONF_NUMBER] mode = value[CONF_MODE] is_input = mode[CONF_INPUT] @@ -160,7 +163,7 @@ class PinInitialState: @pins.PIN_SCHEMA_REGISTRY.register(PLATFORM_ESP8266, ESP8266_PIN_SCHEMA) -async def esp8266_pin_to_code(config): +async def esp8266_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) num = config[CONF_NUMBER] mode = config[CONF_MODE] @@ -192,7 +195,7 @@ async def esp8266_pin_to_code(config): @coroutine_with_priority(CoroPriority.WORKAROUNDS) -async def add_pin_initial_states_array(): +async def add_pin_initial_states_array() -> None: # Add includes at the very end, so that they override everything initial_states: list[PinInitialState] = CORE.data[KEY_ESP8266][ KEY_PIN_INITIAL_STATES diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index feced063d0..7cef7c754a 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -5,6 +5,7 @@ import io import logging from pathlib import Path import re +from typing import Any from PIL import Image, UnidentifiedImageError @@ -75,12 +76,12 @@ def compute_local_image_path(value: str | ConfigType) -> Path: return external_files.compute_local_file_path(DOMAIN, url) -def local_path(value): +def local_path(value: str | ConfigType) -> str: value = value[CONF_PATH] if isinstance(value, dict) else value return str(CORE.relative_config_path(value)) -def download_file(url, path): +def download_file(url: str, path: Path) -> str: # The shared NETWORK_TIMEOUT applies; a per-caller timeout would be # silently ignored on a per-run memo hit anyway (memos key by path). external_files.download_content(url, path) @@ -98,7 +99,7 @@ def download_gh_svg(value: str | ConfigType, source: str) -> str: return download_file(url, path) -def download_image(value): +def download_image(value: str | ConfigType) -> str: value = value[CONF_URL] if isinstance(value, dict) else value return download_file(value, compute_local_image_path(value)) @@ -146,7 +147,7 @@ def _extract_entry_ref(entry: ConfigType) -> RemoteFile | None: PREFETCH_FILES = external_files.single_stage_prefetch(_extract_entry_ref) -def validate_file_shorthand(value): +def validate_file_shorthand(value: Any) -> str: value = cv.string_strict(value) if (remote := _parse_remote_shorthand(value)) is not None: return download_file(remote.url, remote.path) @@ -163,8 +164,8 @@ LOCAL_SCHEMA = cv.All( ) -def mdi_schema(source): - def validate_mdi(value): +def mdi_schema(source: str) -> cv.All: + def validate_mdi(value: ConfigType) -> str: return download_gh_svg(value, source) return cv.All( @@ -259,7 +260,9 @@ async def new_image(config: ConfigType) -> MockObj: return var -async def write_image(config, all_frames=False): +async def write_image( + config: ConfigType, all_frames: bool = False +) -> tuple[MockObj, int, int, MockObj, MockObj, int]: path = Path(config[CONF_FILE]) if not path.is_file(): raise core.EsphomeError(f"Could not load image file {path}") diff --git a/esphome/components/globals/__init__.py b/esphome/components/globals/__init__.py index 46725fe6dd..bd6bc5f783 100644 --- a/esphome/components/globals/__init__.py +++ b/esphome/components/globals/__init__.py @@ -8,7 +8,8 @@ from esphome.const import ( CONF_TYPE, CONF_VALUE, ) -from esphome.core import CoroPriority, coroutine_with_priority +from esphome.core import ID, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] @@ -62,7 +63,7 @@ CONFIG_SCHEMA = _globals_schema # Run with low priority so that namespaces are registered first @coroutine_with_priority(CoroPriority.LATE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: type_ = cg.RawExpression(config[CONF_TYPE]) restore = config[CONF_RESTORE_VALUE] @@ -104,7 +105,12 @@ async def to_code(config): ), synchronous=True, ) -async def globals_set_to_code(config, action_id, template_arg, args): +async def globals_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID]) template_arg = cg.TemplateArguments(full_id.type, *template_arg) var = cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/gpio/binary_sensor/__init__.py b/esphome/components/gpio/binary_sensor/__init__.py index 703806670c..7cc16eb5b2 100644 --- a/esphome/components/gpio/binary_sensor/__init__.py +++ b/esphome/components/gpio/binary_sensor/__init__.py @@ -12,6 +12,7 @@ from esphome.const import ( CONF_PIN, ) from esphome.core import CORE +from esphome.types import ConfigType from .. import gpio_ns @@ -68,7 +69,7 @@ def _pin_shared_only_with_deep_sleep(pin_num: int) -> bool: return any(path and path[0] == "deep_sleep" for path, _, _ in pin_users) -def _final_validate(config) -> None: +def _final_validate(config: ConfigType) -> None: use_interrupt = config[CONF_USE_INTERRUPT] if not use_interrupt: return @@ -124,7 +125,7 @@ def _final_validate(config) -> None: FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/gpio/one_wire/__init__.py b/esphome/components/gpio/one_wire/__init__.py index e2bb94dd66..feb8b53dff 100644 --- a/esphome/components/gpio/one_wire/__init__.py +++ b/esphome/components/gpio/one_wire/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components.one_wire import OneWireBus import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PIN +from esphome.types import ConfigType from .. import gpio_ns @@ -18,7 +19,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/gpio/output/__init__.py b/esphome/components/gpio/output/__init__.py index 786e04bac0..ab242c643f 100644 --- a/esphome/components/gpio/output/__init__.py +++ b/esphome/components/gpio/output/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PIN +from esphome.types import ConfigType from .. import gpio_ns @@ -16,7 +17,7 @@ CONFIG_SCHEMA = output.BINARY_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) await cg.register_component(var, config) diff --git a/esphome/components/gpio/switch/__init__.py b/esphome/components/gpio/switch/__init__.py index 9462cd0161..2e0b0969bc 100644 --- a/esphome/components/gpio/switch/__init__.py +++ b/esphome/components/gpio/switch/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import CONF_INTERLOCK, CONF_PIN +from esphome.types import ConfigType from .. import gpio_ns @@ -24,7 +25,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) diff --git a/esphome/components/homeassistant/__init__.py b/esphome/components/homeassistant/__init__.py index 7b23775b47..1b66842f1e 100644 --- a/esphome/components/homeassistant/__init__.py +++ b/esphome/components/homeassistant/__init__.py @@ -1,13 +1,19 @@ +from collections.abc import Callable, Iterable + import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_INTERNAL +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter", "@esphome/core"] homeassistant_ns = cg.esphome_ns.namespace("homeassistant") -def validate_entity_domain(platform, supported_domains): - def validator(config): +def validate_entity_domain( + platform: str, supported_domains: Iterable[str] +) -> Callable[[ConfigType], ConfigType]: + def validator(config: ConfigType) -> ConfigType: domain = config[CONF_ENTITY_ID].split(".", 1)[0] if domain not in supported_domains: raise cv.Invalid( @@ -34,7 +40,7 @@ HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA = cv.Schema( ) -def setup_home_assistant_entity(var, config): +def setup_home_assistant_entity(var: MockObj, config: ConfigType) -> None: cg.add(var.set_entity_id(config[CONF_ENTITY_ID])) if CONF_ATTRIBUTE in config: cg.add(var.set_attribute(config[CONF_ATTRIBUTE])) diff --git a/esphome/components/homeassistant/binary_sensor/__init__.py b/esphome/components/homeassistant/binary_sensor/__init__.py index a943368dd7..6ea17b6831 100644 --- a/esphome/components/homeassistant/binary_sensor/__init__.py +++ b/esphome/components/homeassistant/binary_sensor/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import binary_sensor +from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_SCHEMA, @@ -18,7 +19,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(HomeassistantBinarySensor).ex ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) setup_home_assistant_entity(var, config) diff --git a/esphome/components/homeassistant/number/__init__.py b/esphome/components/homeassistant/number/__init__.py index 8f760772c3..ab1389e13a 100644 --- a/esphome/components/homeassistant/number/__init__.py +++ b/esphome/components/homeassistant/number/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import number import esphome.config_validation as cv +from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, @@ -22,7 +23,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") var = await number.new_number( config, diff --git a/esphome/components/homeassistant/sensor/__init__.py b/esphome/components/homeassistant/sensor/__init__.py index 6437476827..abee957fda 100644 --- a/esphome/components/homeassistant/sensor/__init__.py +++ b/esphome/components/homeassistant/sensor/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_SCHEMA, @@ -18,7 +19,7 @@ CONFIG_SCHEMA = sensor.sensor_schema(HomeassistantSensor, accuracy_decimals=1).e ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) setup_home_assistant_entity(var, config) diff --git a/esphome/components/homeassistant/switch/__init__.py b/esphome/components/homeassistant/switch/__init__.py index c299a731f2..55854cd659 100644 --- a/esphome/components/homeassistant/switch/__init__.py +++ b/esphome/components/homeassistant/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, @@ -36,7 +37,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/homeassistant/text_sensor/__init__.py b/esphome/components/homeassistant/text_sensor/__init__.py index b59f9d23df..265250c695 100644 --- a/esphome/components/homeassistant/text_sensor/__init__.py +++ b/esphome/components/homeassistant/text_sensor/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import text_sensor +from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_SCHEMA, @@ -18,7 +19,7 @@ CONFIG_SCHEMA = text_sensor.text_sensor_schema(HomeassistantTextSensor).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(config) await cg.register_component(var, config) setup_home_assistant_entity(var, config) diff --git a/esphome/components/homeassistant/time/__init__.py b/esphome/components/homeassistant/time/__init__.py index 05ca86a26e..146b8278ea 100644 --- a/esphome/components/homeassistant/time/__init__.py +++ b/esphome/components/homeassistant/time/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import time as time_ import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_TIMEZONE +from esphome.types import ConfigType from .. import homeassistant_ns @@ -16,7 +17,7 @@ CONFIG_SCHEMA = time_.TIME_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await time_.register_time(var, config) await cg.register_component(var, config) diff --git a/esphome/components/host/__init__.py b/esphome/components/host/__init__.py index b6a3b8b615..c5846f5406 100644 --- a/esphome/components/host/__init__.py +++ b/esphome/components/host/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( ) from esphome.core import CORE from esphome.platformio.toolchain import copy_ccache_script +from esphome.types import ConfigType from .const import KEY_HOST @@ -22,7 +23,7 @@ AUTO_LOAD = ["network", "preferences"] IS_TARGET_PLATFORM = True -def set_core_data(config): +def set_core_data(config: ConfigType) -> ConfigType: CORE.data[KEY_HOST] = {} CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_HOST CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = "host" @@ -40,7 +41,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_build_flag("-DUSE_HOST") cg.add_define("USE_NATIVE_64BIT_TIME") # The prefs file finds stored preferences by key, so key migration is possible diff --git a/esphome/components/host/gpio.py b/esphome/components/host/gpio.py index fcfb0b6c54..e39d35d077 100644 --- a/esphome/components/host/gpio.py +++ b/esphome/components/host/gpio.py @@ -1,4 +1,5 @@ import logging +from typing import Any from esphome import pins import esphome.codegen as cg @@ -14,6 +15,8 @@ from esphome.const import ( CONF_PULLDOWN, CONF_PULLUP, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from .const import host_ns @@ -22,7 +25,7 @@ _LOGGER = logging.getLogger(__name__) HostGPIOPin = host_ns.class_("HostGPIOPin", cg.InternalGPIOPin) -def _translate_pin(value): +def _translate_pin(value: Any) -> int | str: if isinstance(value, dict) or value is None: raise cv.Invalid( "This variable only supports pin numbers, not full pin schemas " @@ -41,7 +44,7 @@ def _translate_pin(value): return value -def validate_gpio_pin(value): +def validate_gpio_pin(value: Any) -> int | str: return _translate_pin(value) @@ -53,7 +56,7 @@ HOST_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register("host", HOST_PIN_SCHEMA) -async def host_pin_to_code(config): +async def host_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) num = config[CONF_NUMBER] cg.add(var.set_pin(num)) diff --git a/esphome/components/host/time/__init__.py b/esphome/components/host/time/__init__.py index d9a2f1207c..6eb0cf954d 100644 --- a/esphome/components/host/time/__init__.py +++ b/esphome/components/host/time/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import time as time_ import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@clydebarrow"] @@ -14,7 +15,7 @@ CONFIG_SCHEMA = time_.TIME_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await time_.register_time(var, config) diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 94aad4d019..b053125446 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -1,6 +1,7 @@ import logging import re import sys +from typing import Any from esphome import pins import esphome.codegen as cg @@ -52,9 +53,10 @@ from esphome.const import ( PLATFORM_RP2, PlatformFramework, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.cpp_generator import MockObj import esphome.final_validate as fv +from esphome.types import ConfigType LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@esphome/core"] @@ -96,13 +98,13 @@ CONF_SCL_PULLUP_ENABLED = "scl_pullup_enabled" MULTI_CONF = True -def validate_device(value): +def validate_device(value: str) -> str: if not re.match(r"^/(?:[^/]+/)*[^/]+$", value): raise cv.Invalid("Device must be an absolute device path (e.g., /dev/i2c-0)") return value -def _bus_declare_type(value): +def _bus_declare_type(value: Any) -> ID: if CORE.is_esp32: return cv.declare_id(IDFI2CBus)(value) if CORE.using_arduino: @@ -114,7 +116,7 @@ def _bus_declare_type(value): raise NotImplementedError -def _rp2040_i2c_controller(pin): +def _rp2040_i2c_controller(pin: int) -> int: """Return the I2C controller number (0 or 1) for a given RP2040/RP2350 GPIO pin. See RP2040 datasheet Table 2 (section 1.4.3, "GPIO Functions"): @@ -125,7 +127,7 @@ def _rp2040_i2c_controller(pin): return (pin // 2) % 2 -def validate_config(config): +def validate_config(config: ConfigType) -> ConfigType: if CORE.is_esp32: return cv.require_framework_version( esp_idf=cv.Version(5, 4, 2), esp32_arduino=cv.Version(3, 2, 1) @@ -142,7 +144,7 @@ def validate_config(config): return config -def validate_host_config(config): +def validate_host_config(config: ConfigType) -> ConfigType: if CORE.is_host: # Host I2C is currently only supported on Linux if not sys.platform.lower().startswith("linux"): @@ -229,7 +231,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: full_config = fv.full_config.get()[CONF_I2C] if CORE.using_zephyr and len(full_config) > 1: raise cv.Invalid("Second i2c is not implemented on Zephyr yet") @@ -281,7 +283,7 @@ FINAL_VALIDATE_SCHEMA = _final_validate @coroutine_with_priority(CoroPriority.BUS) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(i2c_ns.using) cg.add_define("USE_I2C") if CORE.is_esp32: @@ -358,7 +360,7 @@ async def to_code(config): cg.add(var.set_lp_mode(bool(config[CONF_LOW_POWER_MODE]))) -def i2c_device_schema(default_address): +def i2c_device_schema(default_address: int | None) -> cv.Schema: """Create a schema for a i2c device. :param default_address: The default address of the i2c device, can be None to represent @@ -375,7 +377,7 @@ def i2c_device_schema(default_address): return cv.Schema(schema) -async def register_i2c_device(var, config): +async def register_i2c_device(var: MockObj, config: ConfigType) -> None: """Register an i2c device with the given config. Sets the i2c bus to use and the i2c address. @@ -390,11 +392,11 @@ async def register_i2c_device(var, config): def final_validate_device_schema( name: str, *, - min_frequency: cv.frequency = None, - max_frequency: cv.frequency = None, - min_timeout: cv.time_period = None, - max_timeout: cv.time_period = None, -): + min_frequency: Any = None, + max_frequency: Any = None, + min_timeout: Any = None, + max_timeout: Any = None, +) -> cv.Schema: hub_schema = {} if (min_frequency is not None) and (max_frequency is not None): hub_schema[cv.Required(CONF_FREQUENCY)] = cv.Range( diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index 0a8ad58bc2..a4a7b5237d 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -12,13 +12,14 @@ from esphome.const import ( CONF_ON_UNLOCK, CONF_WEB_SERVER, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -102,7 +103,7 @@ _CALLBACK_AUTOMATIONS = ( @setup_entity("lock") -async def _setup_lock_core(var, config): +async def _setup_lock_core(var: MockObj, config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) if mqtt_id := config.get(CONF_MQTT_ID): @@ -113,7 +114,7 @@ async def _setup_lock_core(var, config): await web_server.add_entity_config(var, web_server_config) -async def register_lock(var, config): +async def register_lock(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) queue_entity_register("lock", config) @@ -121,7 +122,7 @@ async def register_lock(var, config): await _setup_lock_core(var, config) -async def new_lock(config, *args): +async def new_lock(config: ConfigType, *args: SafeExpType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID], *args) await register_lock(var, config) return var @@ -143,23 +144,38 @@ LOCK_ACTION_SCHEMA = maybe_simple_id( @automation.register_action( "lock.open", OpenAction, LOCK_ACTION_SCHEMA, synchronous=True ) -async def lock_action_to_code(config, action_id, template_arg, args): +async def lock_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_condition("lock.is_locked", LockCondition, LOCK_ACTION_SCHEMA) -async def lock_is_on_to_code(config, condition_id, template_arg, args): +async def lock_is_on_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(condition_id, template_arg, paren, True) @automation.register_condition("lock.is_unlocked", LockCondition, LOCK_ACTION_SCHEMA) -async def lock_is_off_to_code(config, condition_id, template_arg, args): +async def lock_is_off_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(condition_id, template_arg, paren, False) @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(lock_ns.using) From 74fc2e367abb874d74c436d9961d7ff3d9981a11 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:42:53 +0000 Subject: [PATCH 1628/1815] Bump bundled esphome-device-builder to 1.12.4 (#18651) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 4cde6505b3..9f27d51059 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.12.3 +RUN uv pip install --no-cache-dir esphome-device-builder==1.12.4 RUN \ platformio settings set enable_telemetry No \ From d119ad6c6078fd8fa3d2191c4d5e2b91fbc7a38e Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Sat, 22 Aug 2026 17:47:46 +0200 Subject: [PATCH 1629/1815] [usb_uart] Extract non-final USBUartChannelBase from USBUartChannel (#17472) Co-authored-by: p1ngb4ck --- esphome/components/usb_uart/ch34x.cpp | 2 +- esphome/components/usb_uart/cp210x.cpp | 2 +- esphome/components/usb_uart/ft23xx.cpp | 6 +-- esphome/components/usb_uart/pl2303.cpp | 2 +- esphome/components/usb_uart/usb_uart.cpp | 20 ++++---- esphome/components/usb_uart/usb_uart.h | 65 ++++++++++++++---------- 6 files changed, 55 insertions(+), 42 deletions(-) diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index abfed74f94..00c5e0b069 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -95,7 +95,7 @@ void USBUartTypeCH34X::dump_config() { ESP_LOGCONFIG(TAG, " CH34x chip: %s", this->chip_name_); } -bool USBUartTypeCH34X::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, +bool USBUartTypeCH34X::config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) { uint8_t cmd = 0xA1 + channel->index_; if (channel->index_ >= 2) diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index 2722ec8555..5551abe1a1 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -97,7 +97,7 @@ std::vector USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev return cdc_devs; } -bool USBUartTypeCP210X::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, +bool USBUartTypeCP210X::config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) { // On reload, skip the one-time IFC_ENABLE step (the interface is already enabled). if (reload) diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index 79aa107d72..fcebf0fbd9 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -270,7 +270,7 @@ std::vector USBUartTypeFT23XX::parse_descriptors(usb_device_handle_t dev return cdc_devs; } -void USBUartTypeFT23XX::start_input(USBUartChannel *channel) { +void USBUartTypeFT23XX::start_input(USBUartChannelBase *channel) { if (!channel->initialised_.load()) return; @@ -336,12 +336,12 @@ void USBUartTypeFT23XX::start_input(USBUartChannel *channel) { } } -void USBUartTypeFT23XX::on_rx_overflow(USBUartChannel *channel) { +void USBUartTypeFT23XX::on_rx_overflow(USBUartChannelBase *channel) { ESP_LOGW(TAG, "RX buffer overflow on channel %d, clearing to resync", channel->index_); channel->input_buffer_.clear(); } -bool USBUartTypeFT23XX::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, +bool USBUartTypeFT23XX::config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) { // On reload (settings change on an open channel) skip the SIO reset; the FTDI set_termios // path only re-applies baud + line properties and does not re-assert DTR/RTS. diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index c56f43f75a..a9f7348331 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -226,7 +226,7 @@ static const Pl2303InitStep PL2303_INIT[] = { }; static constexpr uint8_t PL2303_INIT_COUNT = sizeof(PL2303_INIT) / sizeof(PL2303_INIT[0]); -bool USBUartTypePL2303::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, +bool USBUartTypePL2303::config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) { bool is_legacy = (this->chip_type_ == PL2303_TYPE_H); bool is_hxn = (this->chip_type_ == PL2303_TYPE_HXN); diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index c289625f1a..cf66e4c369 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -136,7 +136,7 @@ size_t RingBuffer::pop(uint8_t *data, size_t len) { } return len; } -void USBUartChannel::write_array(const uint8_t *data, size_t len) { +void USBUartChannelBase::write_array(const uint8_t *data, size_t len) { if (!this->initialised_.load()) { ESP_LOGD(TAG, "Channel not initialised - write ignored"); return; @@ -170,7 +170,7 @@ void USBUartChannel::write_array(const uint8_t *data, size_t len) { this->parent_->start_output(this); } -uart::UARTFlushResult USBUartChannel::flush() { +uart::UARTFlushResult USBUartChannelBase::flush() { // Spin until the output queue is drained and the last USB transfer completes. // Safe to call from the main loop only. // The flush_timeout_ms_ timeout guards against a device that stops responding mid-flush; @@ -186,14 +186,14 @@ uart::UARTFlushResult USBUartChannel::flush() { return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; } -bool USBUartChannel::peek_byte(uint8_t *data) { +bool USBUartChannelBase::peek_byte(uint8_t *data) { if (this->input_buffer_.is_empty()) { return false; } *data = this->input_buffer_.peek(); return true; } -bool USBUartChannel::read_array(uint8_t *data, size_t len) { +bool USBUartChannelBase::read_array(uint8_t *data, size_t len) { if (!this->initialised_.load()) { ESP_LOGV(TAG, "Channel not initialised - read ignored"); return false; @@ -277,7 +277,7 @@ void USBUartComponent::dump_config() { YESNO(channel->dummy_receiver_)); } } -void USBUartComponent::start_input(USBUartChannel *channel) { +void USBUartComponent::start_input(USBUartChannelBase *channel) { if (!channel->initialised_.load()) return; // THREAD CONTEXT: Called from both USB task and main loop threads @@ -346,7 +346,7 @@ void USBUartComponent::start_input(USBUartChannel *channel) { } } -void USBUartComponent::start_output(USBUartChannel *channel) { +void USBUartComponent::start_output(USBUartChannelBase *channel) { // THREAD CONTEXT: Called from both main loop and USB task threads. // The output_queue_ is a lock-free SPSC queue, so pop() is safe from either thread. // The output_started_ atomic flag is claimed via compare_exchange to guarantee that @@ -491,7 +491,7 @@ void USBUartTypeCdcAcm::on_disconnected() { USBClient::on_disconnected(); } -bool USBUartTypeCdcAcm::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, +bool USBUartTypeCdcAcm::config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) { static constexpr uint8_t CDC_REQUEST_TYPE = usb_host::USB_TYPE_CLASS | usb_host::USB_RECIP_INTERFACE; static constexpr uint8_t CDC_SET_LINE_CODING = 0x20; @@ -537,7 +537,7 @@ void USBUartComponent::enable_channels() { this->start_config_(false); } -void USBUartComponent::apply_channel_settings(USBUartChannel *channel) { +void USBUartComponent::apply_channel_settings(USBUartChannelBase *channel) { if (this->cfg_active_) { // A config sequence is already running. Defer this reload until it finishes to preserve // the one-control-transfer-at-a-time guarantee (restarting mid-flight would let an @@ -620,7 +620,7 @@ bool USBUartComponent::run_config_machine_() { this->cfg_ok_ = true; } - USBUartChannel *channel = + USBUartChannelBase *channel = this->cfg_single_ != nullptr ? this->cfg_single_ : (this->cfg_channel_idx_ < this->channels_.size() ? this->channels_[this->cfg_channel_idx_] : nullptr); @@ -664,7 +664,7 @@ bool USBUartComponent::run_config_machine_() { return true; } -void USBUartChannel::load_settings(bool /*dump_config*/) { +void USBUartChannelBase::load_settings(bool /*dump_config*/) { // The per-channel control transfers already log their values at debug level. this->parent_->apply_channel_settings(this); } diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 5bb4c97796..00b34fb942 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -16,7 +16,7 @@ namespace esphome::usb_uart { class USBUartTypeCdcAcm; class USBUartComponent; -class USBUartChannel; +class USBUartChannelBase; class USBUartTypePL2303; static const char *const TAG = "usb_uart"; @@ -110,7 +110,7 @@ class RingBuffer { struct UsbDataChunk { uint8_t data[usb_host::USB_MAX_PACKET_SIZE]; uint16_t length; - USBUartChannel *channel; + USBUartChannelBase *channel; // Required for EventPool - no cleanup needed for POD types void release() {} @@ -126,7 +126,11 @@ struct UsbOutputChunk { void release() {} }; -class USBUartChannel final : public uart::UARTComponent, public Parented { +// Common, non-final base for all USB UART channel implementations. +// Concrete channel types (USBUartChannel for CDC-style devices, vendor-specific +// multiplexed channels like CH934X) derive from this and are themselves final, +// per the "configurable classes are final" convention. +class USBUartChannelBase : public uart::UARTComponent, public Parented { friend class USBUartComponent; friend class USBUartTypeCdcAcm; friend class USBUartTypeCP210X; @@ -139,7 +143,6 @@ class USBUartChannel final : public uart::UARTComponent, public Parented cb) { this->rx_callback_ = std::move(cb); } protected: + // Not directly instantiable; construct a concrete channel type instead. + USBUartChannelBase(uint8_t index, uint16_t buffer_size) : input_buffer_(RingBuffer(buffer_size)), index_(index) {} void check_logger_conflict() override {} // Larger structures first (8+ bytes) RingBuffer input_buffer_; @@ -185,33 +190,40 @@ class USBUartChannel final : public uart::UARTComponent, public Parented get_channels() { return this->channels_; } + std::vector get_channels() { return this->channels_; } - void add_channel(USBUartChannel *channel) { this->channels_.push_back(channel); } + void add_channel(USBUartChannelBase *channel) { this->channels_.push_back(channel); } - virtual void start_input(USBUartChannel *channel); - void start_output(USBUartChannel *channel); + virtual void start_input(USBUartChannelBase *channel); + void start_output(USBUartChannelBase *channel); // Begin configuring all channels (full initialisation). Called from on_connected(). void enable_channels(); // Re-apply line settings to a single, already-open channel (used by - // USBUartChannel::load_settings()). - void apply_channel_settings(USBUartChannel *channel); + // USBUartChannelBase::load_settings()). + void apply_channel_settings(USBUartChannelBase *channel); // Called from loop() when input_buffer_ has insufficient space for the incoming chunk. // Default is a no-op; override in device-specific subclasses that need resync on overflow. - virtual void on_rx_overflow(USBUartChannel *channel) {} + virtual void on_rx_overflow(USBUartChannelBase *channel) {} // Lock-free data transfer from USB task to main loop static constexpr int USB_DATA_QUEUE_SIZE = 32; LockFreeQueue usb_data_queue_; - // Pool sized to queue capacity (SIZE-1) — see USBUartChannel::output_pool_ comment. + // Pool sized to queue capacity (SIZE-1) — see USBUartChannelBase::output_pool_ comment. EventPool chunk_pool_; protected: @@ -231,18 +243,19 @@ class USBUartComponent : public usb_host::USBClient { // next control transfer via config_transfer_() and return true, or return false when the // channel has no more steps. reload=true ⇒ apply only baud/parity/stop/data (skip // enable/reset/DTR-RTS). ok/response carry the previous step's result and IN data. - virtual bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) = 0; + virtual bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, + const uint8_t *response) = 0; // Optional one-time device-level setup run before the per-channel phase on init only // (e.g. CH34x chip detection). Same contract as config_step_(). Default: no steps. virtual bool config_device_step(uint8_t step, bool ok, const uint8_t *response) { return false; } - std::vector channels_{}; + std::vector channels_{}; // Config state machine - USBUartChannel *cfg_single_{nullptr}; // non-null: reload of a single channel - USBUartChannel *cfg_pending_reload_{nullptr}; // reload requested while the machine was busy - std::atomic cfg_done_{false}; // synchronizes cfg_ok_/cfg_response_ across threads - uint8_t cfg_response_[8]{}; // last IN transfer payload (for detection reads) + USBUartChannelBase *cfg_single_{nullptr}; // non-null: reload of a single channel + USBUartChannelBase *cfg_pending_reload_{nullptr}; // reload requested while the machine was busy + std::atomic cfg_done_{false}; // synchronizes cfg_ok_/cfg_response_ across threads + uint8_t cfg_response_[8]{}; // last IN transfer payload (for detection reads) uint8_t cfg_channel_idx_{0}; uint8_t cfg_step_{0}; bool cfg_active_{false}; @@ -260,7 +273,7 @@ class USBUartTypeCdcAcm : public USBUartComponent { virtual std::vector parse_descriptors(usb_device_handle_t dev_hdl); void on_connected() override; void on_disconnected() override; - bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; }; class USBUartTypeCP210X : public USBUartTypeCdcAcm { @@ -269,7 +282,7 @@ class USBUartTypeCP210X : public USBUartTypeCdcAcm { protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; - bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; }; class USBUartTypeCH34X : public USBUartTypeCdcAcm { public: @@ -277,7 +290,7 @@ class USBUartTypeCH34X : public USBUartTypeCdcAcm { void dump_config() override; protected: - bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; bool config_device_step(uint8_t step, bool ok, const uint8_t *response) override; std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; @@ -291,12 +304,12 @@ class USBUartTypeFT23XX : public USBUartTypeCdcAcm { public: USBUartTypeFT23XX(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) {} - void start_input(USBUartChannel *channel) override; - void on_rx_overflow(USBUartChannel *channel) override; + void start_input(USBUartChannelBase *channel) override; + void on_rx_overflow(USBUartChannelBase *channel) override; protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; - bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; uint8_t chip_type_{255}; }; @@ -312,14 +325,14 @@ enum Pl2303ChipType : uint8_t { }; class USBUartTypePL2303 : public USBUartTypeCdcAcm { - friend class USBUartChannel; + friend class USBUartChannelBase; public: USBUartTypePL2303(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) {} protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; - bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; Pl2303ChipType chip_type_{PL2303_TYPE_UNKNOWN}; }; From dcabaedff1b5adfb7d2070b7d1557e67d0eca8c0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 18:59:24 -0500 Subject: [PATCH 1630/1815] [bk72xx_ble] Block BK7238 until the LibreTiny bonding partition fix lands (#18649) --- esphome/components/bk72xx_ble/__init__.py | 30 +++++++++---------- .../bk72xx_ble/config/test_bk7238.yaml | 7 +++++ .../bk72xx_ble/test_family_gate.py | 1 + 3 files changed, 23 insertions(+), 15 deletions(-) create mode 100644 tests/component_tests/bk72xx_ble/config/test_bk7238.yaml diff --git a/esphome/components/bk72xx_ble/__init__.py b/esphome/components/bk72xx_ble/__init__.py index 81073c9b02..74b9cb5954 100644 --- a/esphome/components/bk72xx_ble/__init__.py +++ b/esphome/components/bk72xx_ble/__init__.py @@ -4,9 +4,12 @@ The platform analog of esp32_ble / rp2040_ble: owns the Beken BDK BLE stack bring-up and the controller BLE address. Consumers (bk72xx_ble_tracker) build on this component and contain no SDK calls of their own. -Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7238/BK7252N/BK7253 -(BLE 5.2), and any future BLE-5.x SoC. Known non-5.x families are rejected in -to_code; unknown families are capability-checked at compile time via +Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7252N/BK7253 (BLE 5.2), +and any future BLE-5.x SoC. BK7238 (BLE 5.2) is blocked for now: with BLE +compiled in, the Beken SDK erases the bootloader flash sector at boot because +LibreTiny's partition table has no BLE bonding entry (esphome#18646, +libretiny-eu/libretiny#408). Known non-5.x families and BK7238 are rejected in +to_code. Unknown families are capability-checked at compile time via `__has_include("app_ble.h")`, a header only on the BLE 5.x include path (ble_api.h ships for every SoC, so it cannot be the probe). A non-5.x build fails with a clear #error. @@ -65,6 +68,14 @@ def _unsupported_family_message(family: str) -> str | None: ) if family == FAMILY_BK7231Q: return "bk72xx_ble does not support BK7231Q: this SoC has no BLE" + if family == FAMILY_BK7238: + return ( + "bk72xx_ble is disabled on BK7238: with BLE compiled in, the Beken SDK " + "erases the bootloader flash sector at boot and the device can no longer " + "start (see https://github.com/esphome/esphome/issues/18646); support " + "returns once the LibreTiny partition table fix " + "(libretiny-eu/libretiny#408) is released" + ) return None @@ -113,18 +124,7 @@ async def to_code(config: ConfigType) -> None: # BK7231N, but NOT on BK7238 (its BLE stack has no such symbol; the address is # derived from the WiFi MAC instead — the BDK's own fallback). Tell the C++ # which path is available so it doesn't reference a missing symbol. - family = libretiny.get_libretiny_family() - if family == FAMILY_BK7231N: + if libretiny.get_libretiny_family() == FAMILY_BK7231N: cg.add_define("BK72XX_BLE_HAS_COMMON_BDADDR") - elif family == FAMILY_BK7238: - # ESPHome's LibreTiny disables BLE on BK7238 because the SDK can hang at - # WiFi STA startup when BLE init runs. This component re-enables BLE, so - # warn loudly: BK7238 is accepted but not hardware-verified and may be - # WiFi-unstable with BLE on. - _LOGGER.warning( - "bk72xx_ble on BK7238: enabling BLE is known to risk a WiFi STA startup " - "hang on this family and is not yet hardware-verified. Expect possible " - "instability." - ) cg.add_define("USE_BK72XX_BLE") diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7238.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7238.yaml new file mode 100644 index 0000000000..0880cf69f5 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7238.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-7238 + +bk72xx: + board: generic-bk7238 + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/test_family_gate.py b/tests/component_tests/bk72xx_ble/test_family_gate.py index da67749bb3..86f3ef0039 100644 --- a/tests/component_tests/bk72xx_ble/test_family_gate.py +++ b/tests/component_tests/bk72xx_ble/test_family_gate.py @@ -16,6 +16,7 @@ from esphome.core import EsphomeError ("test_bk7231t.yaml", "BK7231T.*BLE 4.2"), ("test_bk7252.yaml", "BK7251.*BLE 4.2"), ("test_bk7231q.yaml", "BK7231Q.*no BLE"), + ("test_bk7238.yaml", "BK7238.*bootloader"), ], ) def test_unsupported_family_rejected( From c062d0c7171a1e576fdb0bd8864e374be51a707c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 20:57:13 -0500 Subject: [PATCH 1631/1815] [ota] Log prepare, upload, and total OTA timing in espota2 (#18582) --- esphome/espota2.py | 18 ++++++++++++++++++ tests/unit_tests/test_espota2.py | 28 ++++++++++++++++++++++++---- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index 61e897f601..ca833f1816 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -460,8 +460,14 @@ def perform_ota( (upload_size >> 8) & 0xFF, (upload_size >> 0) & 0xFF, ] + # The device erases flash between receiving the size and acking the + # prepare, so this window shows the erase cost (near zero when the + # device erases lazily during the upload) + prepare_start = time.perf_counter() send_check(sock, upload_size_encoded, "binary size") receive_exactly(sock, 1, "update prepare result", RESPONSE_UPDATE_PREPARE_OK) + prepare_duration = time.perf_counter() - prepare_start + _LOGGER.info("Preparing for upload took %.2f seconds", prepare_duration) upload_md5 = hashlib.md5(upload_contents).hexdigest() _LOGGER.debug("MD5 of upload is %s", upload_md5) @@ -528,11 +534,23 @@ def perform_ota( # reboots on its own; the exact commit point is not observable from # here, so treat everything past the data phase as non-retryable. A # re-upload could flash a device that already updated successfully. + commit_start = time.perf_counter() try: receive_exactly(sock, 1, "update receive result", RESPONSE_RECEIVE_OK) receive_exactly(sock, 1, "update end result", RESPONSE_UPDATE_END_OK) except OTANetworkError as err: raise _committed_error(err) from err + commit_duration = time.perf_counter() - commit_start + + # Sum of the named windows so the breakdown is self consistent; connect, + # handshake, auth, and the one MD5 round trip are not included + _LOGGER.info( + "Update took %.2f seconds (prepare %.2f, upload %.2f, commit %.2f)", + prepare_duration + duration + commit_duration, + prepare_duration, + duration, + commit_duration, + ) try: send_check(sock, RESPONSE_OK, "end acknowledgement") diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index db4a4b1117..e0e9185e1c 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -6,6 +6,8 @@ from collections.abc import Generator import gzip import hashlib import io +import itertools +import logging from pathlib import Path import socket import struct @@ -53,8 +55,9 @@ def mock_sleep() -> Generator[Mock]: @pytest.fixture def mock_time(mock_sleep: Mock) -> Generator[None]: """Mock time-related functions for consistent testing.""" - # Provide enough values for multiple calls (tests may call perform_ota multiple times) - with patch("time.perf_counter", side_effect=[0, 1, 0, 1, 0, 1]): + # Monotonically increasing, never exhausted regardless of how many timing + # windows perform_ota measures or how many times a test calls it + with patch("time.perf_counter", side_effect=itertools.count()): yield @@ -372,7 +375,9 @@ def test_perform_ota_successful_md5_auth( @pytest.mark.usefixtures("mock_time") -def test_perform_ota_no_auth(mock_socket: Mock, mock_file: io.BytesIO) -> None: +def test_perform_ota_no_auth( + mock_socket: Mock, mock_file: io.BytesIO, caplog: pytest.LogCaptureFixture +) -> None: """Test OTA without authentication.""" recv_responses = [ bytes([espota2.RESPONSE_OK]), # First byte of version response @@ -387,7 +392,14 @@ def test_perform_ota_no_auth(mock_socket: Mock, mock_file: io.BytesIO) -> None: mock_socket.recv.side_effect = recv_responses - espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + # Distinct window lengths pin each duration to its label; exactly the 6 + # expected perf_counter calls, so an unaccounted timing window raises + timings = [0.0, 2.0, 10.0, 15.0, 20.0, 27.0] + with ( + patch("time.perf_counter", side_effect=timings), + caplog.at_level(logging.INFO), + ): + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") # Should not send any auth-related data auth_calls = [ @@ -397,6 +409,14 @@ def test_perform_ota_no_auth(mock_socket: Mock, mock_file: io.BytesIO) -> None: ] assert len(auth_calls) == 0 + # The timing summary is the observable output of the upload; exact strings + # pin each duration to its label + assert "Preparing for upload took 2.00 seconds" in caplog.text + assert ( + "Update took 14.00 seconds (prepare 2.00, upload 5.00, commit 7.00)" + in caplog.text + ) + @pytest.mark.usefixtures("mock_time") def test_perform_ota_with_compression(mock_socket: Mock) -> None: From 1f31e51446af7bf7d6a34c8dfc40861a9a7ce783 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 20:57:37 -0500 Subject: [PATCH 1632/1815] [esphome] Inline the trivial OTA port accessors (#18625) --- esphome/components/esphome/ota/ota_esphome.cpp | 2 -- esphome/components/esphome/ota/ota_esphome.h | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index cab725f704..9cbb25b373 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -588,8 +588,6 @@ bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len) { } float ESPHomeOTAComponent::get_setup_priority() const { return setup_priority::AFTER_WIFI; } -uint16_t ESPHomeOTAComponent::get_port() const { return this->port_; } -void ESPHomeOTAComponent::set_port(uint16_t port) { this->port_ = port; } void ESPHomeOTAComponent::log_socket_error_(const LogString *msg) { ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno); diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 0053ca6969..979e3f2d7d 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -39,14 +39,14 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { #endif // USE_OTA_PASSWORD /// Manually set the port OTA should listen on - void set_port(uint16_t port); + void set_port(uint16_t port) { this->port_ = port; } void setup() override; void dump_config() override; float get_setup_priority() const override; void loop() override; - uint16_t get_port() const; + uint16_t get_port() const { return this->port_; } protected: void handle_handshake_(); From 6aab523dd9e6c716d1f2f246598bbc786954ef0e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 20:57:58 -0500 Subject: [PATCH 1633/1815] [esp32_ble] Log connection parameter update results (#18607) --- esphome/components/esp32_ble/ble.cpp | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index e2d79173ff..6e6fb0e30d 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -643,8 +643,28 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa App.wake_loop_threadsafe(); return; + // Log the result of connection parameter updates: a peer can reject or + // never answer an update, and without this the link silently stays on the + // old parameters (visible only as unexplained supervision timeouts). + case ESP_GAP_BLE_UPDATE_CONN_PARAMS_EVT: { + if (param->update_conn_params.status != ESP_BT_STATUS_SUCCESS) { + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + format_mac_addr_upper(param->update_conn_params.bda, mac_s); + ESP_LOGW(TAG, "[%s] Conn param update failed, status=%d", mac_s, param->update_conn_params.status); + } +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + else { + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + format_mac_addr_upper(param->update_conn_params.bda, mac_s); + ESP_LOGV(TAG, "[%s] Conn params updated: interval=%u (x1.25ms) latency=%u timeout=%u (x10ms)", mac_s, + param->update_conn_params.conn_int, param->update_conn_params.latency, + param->update_conn_params.timeout); + } +#endif + return; + } + // Ignore these GAP events as they are not relevant for our use case - case ESP_GAP_BLE_UPDATE_CONN_PARAMS_EVT: case ESP_GAP_BLE_SET_PKT_LENGTH_COMPLETE_EVT: case ESP_GAP_BLE_PHY_UPDATE_COMPLETE_EVT: // BLE 5.0 PHY update complete case ESP_GAP_BLE_CHANNEL_SELECT_ALGORITHM_EVT: // BLE 5.0 channel selection algorithm From 14499223fd1e1560faf034747f39bd2b2c28f8a4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 20:58:13 -0500 Subject: [PATCH 1634/1815] [esp8266] Don't report stale crash state after hardware WDT resets (#18597) --- esphome/components/esp8266/crash_handler.cpp | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/esphome/components/esp8266/crash_handler.cpp b/esphome/components/esp8266/crash_handler.cpp index 91b0cf9082..dc79043f21 100644 --- a/esphome/components/esp8266/crash_handler.cpp +++ b/esphome/components/esp8266/crash_handler.cpp @@ -118,8 +118,6 @@ static const LogString *get_exception_cause(uint32_t cause) { } static const LogString *get_reset_reason(uint32_t reason) { - if (reason == REASON_WDT_RST) - return LOG_STR("Hardware WDT"); if (reason == REASON_EXCEPTION_RST) return LOG_STR("Exception"); if (reason == REASON_SOFT_WDT_RST) @@ -162,13 +160,20 @@ void crash_handler_log() { if (!is_crash_reason(resetInfo.reason)) return; + ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); + if (resetInfo.reason == REASON_WDT_RST) { + // A hardware WDT reset happens entirely in hardware: the postmortem hook + // never runs, so rst_info epc1/exccause and the RTC backtrace are + // leftovers from an earlier crash. Don't misattribute them (#18596). + ESP_LOGE(TAG, " Reason: Hardware WDT (no crash state is recorded for hardware WDT resets)"); + return; + } + // Read and filter backtrace from RTC into stack-local buffer (no persistent RAM cost). // Both resetInfo and RTC data survive until the next reset, so this can be // called multiple times (logger init + API subscribe) with the same result. uint32_t backtrace[MAX_BACKTRACE]; uint8_t bt_count = read_rtc_backtrace(backtrace, MAX_BACKTRACE); - - ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); // GCC's ROM divide routine triggers IllegalInstruction (exccause=0) at specific // ROM addresses instead of IntegerDivideByZero (exccause=6). Patch to match // the Arduino core's postmortem handler behavior. From 74bdf275d20ab138cd9950e4ee281a11c21b39cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 20:58:28 -0500 Subject: [PATCH 1635/1815] [core] Dump the main.cpp config comment with sorted keys (#18653) --- esphome/__main__.py | 6 ++++-- tests/unit_tests/test_main.py | 30 +++++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index c1e05d2ea7..769b66ecc8 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -762,9 +762,11 @@ def _wrap_to_code(name, comp, yaml_util): async def wrapped(conf): cg.add(cg.LineComment(f"{name}:")) if comp.config_schema is not None: - conf_str = yaml_util.dump(conf) + # sort_keys: voluptuous fills defaults in set order, so an + # unsorted dump would churn main.cpp and relink every run + conf_str = yaml_util.dump(conf, sort_keys=True) conf_str = conf_str.replace("//", "") - # remove tailing \ to avoid multi-line comment warning + # remove trailing \ to avoid multi-line comment warning conf_str = conf_str.replace("\\\n", "\n") cg.add(cg.LineComment(indent(conf_str))) await coro(conf) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index a40341e194..1cb710ca58 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -11,6 +11,7 @@ from pathlib import Path import re import sys import time +from types import SimpleNamespace from typing import Any, Self from unittest.mock import AsyncMock, MagicMock, Mock, patch @@ -18,7 +19,7 @@ import pytest from pytest import CaptureFixture from zeroconf import ServiceStateChange -from esphome import __main__ as main +from esphome import __main__ as main, yaml_util from esphome.__main__ import ( Purpose, _get_configured_xtal_freq, @@ -29,6 +30,7 @@ from esphome.__main__ import ( _unresolved_default_error, _validate_bootloader_binary, _validate_partition_table_binary, + _wrap_to_code, check_permissions, choose_upload_log_host, command_analyze_memory, @@ -116,6 +118,7 @@ from esphome.espota2 import ( OTA_TYPE_UPDATE_PARTITION_TABLE, ) from esphome.platformio import toolchain +from esphome.types import ConfigType from esphome.util import BootselResult, FlashImage from esphome.zeroconf import _await_discovery, discover_mdns_devices @@ -7130,3 +7133,28 @@ def test_warn_source_tree_mismatch_falls_back_when_stat_fails( # Same tree, so the path comparison still finds them equal and stays silent assert not caplog.text + + +@pytest.mark.asyncio +async def test_wrap_to_code_comment_is_insertion_order_independent() -> None: + """The config comment dumps with sorted keys: voluptuous fills schema + defaults in set-iteration order, so an unsorted dump would churn + main.cpp and relink the firmware on every run.""" + comments: list[str] = [] + + async def to_code(conf: ConfigType) -> None: + """Accept any config; only the wrapper's comment output matters.""" + + comp = SimpleNamespace(to_code=to_code, config_schema=object()) + wrapped = _wrap_to_code("demo", comp, yaml_util) + with patch("esphome.codegen.add", side_effect=lambda st: comments.append(str(st))): + # Nested on purpose: the real churn lives in nested action configs, + # so sorting must apply at every mapping level + await wrapped({"beta": 1, "alpha": {"z": 1, "a": 2}}) + first = "\n".join(comments) + comments.clear() + await wrapped({"alpha": {"a": 2, "z": 1}, "beta": 1}) + second = "\n".join(comments) + assert first == second + assert second.index("alpha") < second.index("beta") + assert second.index("a: 2") < second.index("z: 1") From 259e7182a350e16b1f70fe88a5bb0dff7fbfc546 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 20:59:55 -0500 Subject: [PATCH 1636/1815] [esp32] Exclude esp_gdbstub from the build by default (#18604) --- esphome/components/esp32/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index d6ed6d9399..501c2e525f 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -233,6 +233,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = ( "esp_driver_touch_sens", # Touch sensor driver - only needed by esp32_touch "esp_driver_twai", # TWAI/CAN driver - only needed by esp32_can component "esp_eth", # Ethernet driver - only needed by ethernet component + "esp_gdbstub", # GDB stub panic handler - unused by ESPHome; bt pulls it back "esp_hid", # HID host/device support - ESPHome doesn't implement HID functionality "esp_http_client", # HTTP client - only needed by http_request component "esp_https_ota", # ESP-IDF HTTPS OTA - ESPHome has its own OTA implementation From a282cb095ec2dc4bb08e4109714b4d71768c4629 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:00:39 -0500 Subject: [PATCH 1637/1815] [ethernet] Inline the trivial EthernetComponent setters (#18618) --- .../ethernet/ethernet_component.cpp | 8 ---- .../components/ethernet/ethernet_component.h | 48 +++++++++---------- .../ethernet/ethernet_component_esp32.cpp | 20 +------- .../ethernet/ethernet_component_rp2.cpp | 7 --- 4 files changed, 25 insertions(+), 58 deletions(-) diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index 42cb0b3cfc..14a4fd660b 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -10,14 +10,6 @@ EthernetComponent *global_eth_component; // NOLINT(cppcoreguidelines-avoid-non- EthernetComponent::EthernetComponent() { global_eth_component = this; } -float EthernetComponent::get_setup_priority() const { return setup_priority::WIFI; } - -void EthernetComponent::set_type(EthernetType type) { this->type_ = type; } - -#ifdef USE_ETHERNET_MANUAL_IP -void EthernetComponent::set_manual_ip(const ManualIP &manual_ip) { this->manual_ip_ = manual_ip; } -#endif - #ifdef USE_ETHERNET_IP_STATE_LISTENERS void EthernetComponent::notify_ip_state_listeners_() { auto ips = this->get_ip_addresses(); diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 646e0af8e6..2da070b5e0 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -125,7 +125,7 @@ class EthernetComponent final : public Component { void setup() override; void loop() override; void dump_config() override; - float get_setup_priority() const override; + float get_setup_priority() const override { return setup_priority::ETHERNET; } void on_powerdown() override { powerdown(); } bool is_connected() { return this->state_ == EthernetComponentState::CONNECTED; } @@ -146,9 +146,9 @@ class EthernetComponent final : public Component { esp_netif_t *get_esp_netif() { return this->eth_netif_; } #endif - void set_type(EthernetType type); + void set_type(EthernetType type) { this->type_ = type; } #ifdef USE_ETHERNET_MANUAL_IP - void set_manual_ip(const ManualIP &manual_ip); + void set_manual_ip(const ManualIP &manual_ip) { this->manual_ip_ = manual_ip; } #endif void set_fixed_mac(const std::array &mac) { this->fixed_mac_ = mac; } @@ -171,35 +171,35 @@ class EthernetComponent final : public Component { esp_eth_handle_t get_eth_handle() const { return this->eth_handle_; } #ifdef USE_ETHERNET_SPI - void set_clk_pin(uint8_t clk_pin); - void set_miso_pin(uint8_t miso_pin); - void set_mosi_pin(uint8_t mosi_pin); - void set_cs_pin(uint8_t cs_pin); - void set_interrupt_pin(uint8_t interrupt_pin); - void set_reset_pin(uint8_t reset_pin); - void set_clock_speed(int clock_speed); - void set_interface(spi_host_device_t interface); + void set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } + void set_miso_pin(uint8_t miso_pin) { this->miso_pin_ = miso_pin; } + void set_mosi_pin(uint8_t mosi_pin) { this->mosi_pin_ = mosi_pin; } + void set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; } + void set_interrupt_pin(uint8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; } + void set_reset_pin(uint8_t reset_pin) { this->reset_pin_ = reset_pin; } + void set_clock_speed(int clock_speed) { this->clock_speed_ = clock_speed; } + void set_interface(spi_host_device_t interface) { this->interface_ = interface; } #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT - void set_polling_interval(uint32_t polling_interval); + void set_polling_interval(uint32_t polling_interval) { this->polling_interval_ = polling_interval; } #endif #else - void set_phy_addr(uint8_t phy_addr); - void set_power_pin(int power_pin); - void set_mdc_pin(uint8_t mdc_pin); - void set_mdio_pin(uint8_t mdio_pin); - void set_clk_pin(uint8_t clk_pin); - void set_clk_mode(emac_rmii_clock_mode_t clk_mode); + void set_phy_addr(uint8_t phy_addr) { this->phy_addr_ = phy_addr; } + void set_power_pin(int power_pin) { this->power_pin_ = power_pin; } + void set_mdc_pin(uint8_t mdc_pin) { this->mdc_pin_ = mdc_pin; } + void set_mdio_pin(uint8_t mdio_pin) { this->mdio_pin_ = mdio_pin; } + void set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } + void set_clk_mode(emac_rmii_clock_mode_t clk_mode) { this->clk_mode_ = clk_mode; } void add_phy_register(PHYRegister register_value); #endif // USE_ETHERNET_SPI #endif // USE_ESP32 #ifdef USE_RP2 - void set_clk_pin(uint8_t clk_pin); - void set_miso_pin(uint8_t miso_pin); - void set_mosi_pin(uint8_t mosi_pin); - void set_cs_pin(uint8_t cs_pin); - void set_interrupt_pin(int8_t interrupt_pin); - void set_reset_pin(int8_t reset_pin); + void set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } + void set_miso_pin(uint8_t miso_pin) { this->miso_pin_ = miso_pin; } + void set_mosi_pin(uint8_t mosi_pin) { this->mosi_pin_ = mosi_pin; } + void set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; } + void set_interrupt_pin(int8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; } + void set_reset_pin(int8_t reset_pin) { this->reset_pin_ = reset_pin; } #endif // USE_RP2 #ifdef USE_ETHERNET_IP_STATE_LISTENERS diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 0220d6a19b..4af2d5f93c 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -908,25 +908,7 @@ void EthernetComponent::dump_connect_params_() { #endif /* USE_NETWORK_IPV6 */ } -#ifdef USE_ETHERNET_SPI -void EthernetComponent::set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } -void EthernetComponent::set_miso_pin(uint8_t miso_pin) { this->miso_pin_ = miso_pin; } -void EthernetComponent::set_mosi_pin(uint8_t mosi_pin) { this->mosi_pin_ = mosi_pin; } -void EthernetComponent::set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; } -void EthernetComponent::set_interrupt_pin(uint8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; } -void EthernetComponent::set_reset_pin(uint8_t reset_pin) { this->reset_pin_ = reset_pin; } -void EthernetComponent::set_clock_speed(int clock_speed) { this->clock_speed_ = clock_speed; } -void EthernetComponent::set_interface(spi_host_device_t interface) { this->interface_ = interface; } -#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT -void EthernetComponent::set_polling_interval(uint32_t polling_interval) { this->polling_interval_ = polling_interval; } -#endif -#else -void EthernetComponent::set_phy_addr(uint8_t phy_addr) { this->phy_addr_ = phy_addr; } -void EthernetComponent::set_power_pin(int power_pin) { this->power_pin_ = power_pin; } -void EthernetComponent::set_mdc_pin(uint8_t mdc_pin) { this->mdc_pin_ = mdc_pin; } -void EthernetComponent::set_mdio_pin(uint8_t mdio_pin) { this->mdio_pin_ = mdio_pin; } -void EthernetComponent::set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } -void EthernetComponent::set_clk_mode(emac_rmii_clock_mode_t clk_mode) { this->clk_mode_ = clk_mode; } +#ifndef USE_ETHERNET_SPI void EthernetComponent::add_phy_register(PHYRegister register_value) { this->phy_registers_.push_back(register_value); } #endif diff --git a/esphome/components/ethernet/ethernet_component_rp2.cpp b/esphome/components/ethernet/ethernet_component_rp2.cpp index 119e447689..7f4db4fab7 100644 --- a/esphome/components/ethernet/ethernet_component_rp2.cpp +++ b/esphome/components/ethernet/ethernet_component_rp2.cpp @@ -355,13 +355,6 @@ void EthernetComponent::dump_connect_params_() { this->get_eth_mac_address_pretty_into_buffer(mac_buf)); } -void EthernetComponent::set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } -void EthernetComponent::set_miso_pin(uint8_t miso_pin) { this->miso_pin_ = miso_pin; } -void EthernetComponent::set_mosi_pin(uint8_t mosi_pin) { this->mosi_pin_ = mosi_pin; } -void EthernetComponent::set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; } -void EthernetComponent::set_interrupt_pin(int8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; } -void EthernetComponent::set_reset_pin(int8_t reset_pin) { this->reset_pin_ = reset_pin; } - void EthernetComponent::enable() { // RP2040 uses arduino-pico's LwipIntfDev which manages link state internally; // there is no clean enable/disable hook today. The YAML option is accepted on From 0dc69aab1e3f6927cd6ee33804c69e2404a0728b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:00:49 -0500 Subject: [PATCH 1638/1815] [logger] Inline the trivial Logger accessors (#18619) --- esphome/components/logger/logger.cpp | 7 ------- esphome/components/logger/logger.h | 6 +++--- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 6527b6aa8c..bfc005070e 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -201,17 +201,10 @@ void Logger::process_messages_() { #endif // USE_ESPHOME_TASK_LOG_BUFFER } -void Logger::set_baud_rate(uint32_t baud_rate) { this->baud_rate_ = baud_rate; } #ifdef USE_LOGGER_RUNTIME_TAG_LEVELS void Logger::set_log_level(const char *tag, uint8_t log_level) { this->log_levels_[tag] = log_level; } #endif -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) -UARTSelection Logger::get_uart() const { return this->uart_; } -#endif - -float Logger::get_setup_priority() const { return setup_priority::BUS + 500.0f; } - // Log level strings - packed into flash on ESP8266, indexed by log level (0-7) PROGMEM_STRING_TABLE(LogLevelStrings, "NONE", "ERROR", "WARN", "INFO", "CONFIG", "DEBUG", "VERBOSE", "VERY_VERBOSE"); diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 69d8e6d32a..9c26814f7e 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -148,7 +148,7 @@ class Logger final : public Component { void loop() override; #endif /// Manually set the baud rate for serial, set to 0 to disable. - void set_baud_rate(uint32_t baud_rate); + void set_baud_rate(uint32_t baud_rate) { this->baud_rate_ = baud_rate; } uint32_t get_baud_rate() const { return baud_rate_; } #if defined(USE_ARDUINO) && !defined(USE_ESP32) Stream *get_hw_serial() const { return hw_serial_; } @@ -163,7 +163,7 @@ class Logger final : public Component { #if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) void set_uart_selection(UARTSelection uart_selection) { uart_ = uart_selection; } /// Get the UART used by the logger. - UARTSelection get_uart() const; + UARTSelection get_uart() const { return this->uart_; } #endif /// Set the default log level for this logger. @@ -197,7 +197,7 @@ class Logger final : public Component { void add_level_listener(LoggerLevelListener *listener) { this->level_listeners_.push_back(listener); } #endif - float get_setup_priority() const override; + float get_setup_priority() const override { return setup_priority::BUS + 500.0f; } void log_vprintf_(uint8_t level, const char *tag, int line, const char *format, va_list args); // NOLINT #ifdef USE_STORE_LOG_STR_IN_FLASH From 4db16660242dc4db15bef9fd3ab2bc3b96ce8ed7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:00:59 -0500 Subject: [PATCH 1639/1815] [light] Inline the trivial LightState accessors (#18620) --- esphome/components/light/esp_range_view.cpp | 2 -- esphome/components/light/esp_range_view.h | 3 +++ esphome/components/light/light_state.cpp | 18 ------------- esphome/components/light/light_state.h | 28 ++++++++++++--------- 4 files changed, 19 insertions(+), 32 deletions(-) diff --git a/esphome/components/light/esp_range_view.cpp b/esphome/components/light/esp_range_view.cpp index 58d552031a..5d372983d9 100644 --- a/esphome/components/light/esp_range_view.cpp +++ b/esphome/components/light/esp_range_view.cpp @@ -13,8 +13,6 @@ ESPColorView ESPRangeView::operator[](int32_t index) const { index = interpret_index(index, this->size()) + this->begin_; return (*this->parent_)[index]; } -ESPRangeIterator ESPRangeView::begin() { return {*this, this->begin_}; } -ESPRangeIterator ESPRangeView::end() { return {*this, this->end_}; } void ESPRangeView::set(const Color &color) { for (int32_t i = this->begin_; i < this->end_; i++) { diff --git a/esphome/components/light/esp_range_view.h b/esphome/components/light/esp_range_view.h index f5e4ebb83f..ec129bdf70 100644 --- a/esphome/components/light/esp_range_view.h +++ b/esphome/components/light/esp_range_view.h @@ -75,4 +75,7 @@ class ESPRangeIterator { int32_t i_; }; +inline ESPRangeIterator ESPRangeView::begin() { return {*this, this->begin_}; } +inline ESPRangeIterator ESPRangeView::end() { return {*this, this->end_}; } + } // namespace esphome::light diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 9d0181a05c..82c00e2382 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -157,8 +157,6 @@ void LightState::loop() { } } -float LightState::get_setup_priority() const { return setup_priority::HARDWARE - 1.0f; } - void LightState::publish_state() { if (this->remote_values_listeners_) { for (auto *listener : *this->remote_values_listeners_) { @@ -194,25 +192,11 @@ void LightState::add_target_state_reached_listener(LightTargetStateReachedListen this->target_state_reached_listeners_->push_back(listener); } -void LightState::set_default_transition_length(uint32_t default_transition_length) { - this->default_transition_length_ = default_transition_length; -} -uint32_t LightState::get_default_transition_length() const { return this->default_transition_length_; } -void LightState::set_flash_transition_length(uint32_t flash_transition_length) { - this->flash_transition_length_ = flash_transition_length; -} -uint32_t LightState::get_flash_transition_length() const { return this->flash_transition_length_; } -void LightState::set_gamma_correct(float gamma_correct) { this->gamma_correct_ = gamma_correct; } -void LightState::set_restore_mode(LightRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } -void LightState::set_initial_state(void (*callback)(LightStateRTCState &)) { this->initial_state_callback_ = callback; } -bool LightState::supports_effects() { return !this->effects_.empty(); } -const FixedVector &LightState::get_effects() const { return this->effects_; } void LightState::add_effects(const std::initializer_list &effects) { // Called once from Python codegen during setup with all effects from YAML config this->effects_ = effects; } -void LightState::current_values_as_binary(bool *binary) { this->current_values.as_binary(binary); } void LightState::current_values_as_brightness(float *brightness) { this->current_values.as_brightness(brightness); *brightness = this->gamma_correct_lut(*brightness); @@ -333,8 +317,6 @@ float LightState::gamma_uncorrect_lut(float value) const { } #endif // USE_LIGHT_GAMMA_LUT -bool LightState::is_transformer_active() { return this->is_transformer_active_; } - void LightState::start_effect_(uint32_t effect_index) { this->stop_effect_(); if (effect_index == 0) diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 5efc05358b..3a3f8fc368 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -109,7 +109,7 @@ class LightState : public EntityBase, public Component { void dump_config() override; void loop() override; /// Shortly after HARDWARE. - float get_setup_priority() const override; + float get_setup_priority() const override { return setup_priority::HARDWARE - 1.0f; } /** The current values of the light as outputted to the light. * @@ -157,15 +157,19 @@ class LightState : public EntityBase, public Component { void add_target_state_reached_listener(LightTargetStateReachedListener *listener); /// Set the default transition length, i.e. the transition length when no transition is provided. - void set_default_transition_length(uint32_t default_transition_length); - uint32_t get_default_transition_length() const; + void set_default_transition_length(uint32_t default_transition_length) { + this->default_transition_length_ = default_transition_length; + } + uint32_t get_default_transition_length() const { return this->default_transition_length_; } /// Set the flash transition length - void set_flash_transition_length(uint32_t flash_transition_length); - uint32_t get_flash_transition_length() const; + void set_flash_transition_length(uint32_t flash_transition_length) { + this->flash_transition_length_ = flash_transition_length; + } + uint32_t get_flash_transition_length() const { return this->flash_transition_length_; } /// Set the gamma correction factor - void set_gamma_correct(float gamma_correct); + void set_gamma_correct(float gamma_correct) { this->gamma_correct_ = gamma_correct; } float get_gamma_correct() const { return this->gamma_correct_; } #ifdef USE_LIGHT_GAMMA_LUT @@ -186,17 +190,17 @@ class LightState : public EntityBase, public Component { #endif // USE_LIGHT_GAMMA_LUT /// Set the restore mode of this light - void set_restore_mode(LightRestoreMode restore_mode); + void set_restore_mode(LightRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } /// Set a callback to populate the initial state defaults during setup. /// The callback is called once, then cleared. Values live in flash as code. - void set_initial_state(void (*callback)(LightStateRTCState &)); + void set_initial_state(void (*callback)(LightStateRTCState &)) { this->initial_state_callback_ = callback; } /// Return whether the light has any effects that meet the trait requirements. - bool supports_effects(); + bool supports_effects() const { return !this->effects_.empty(); } /// Get all effects for this light state. - const FixedVector &get_effects() const; + const FixedVector &get_effects() const { return this->effects_; } /// Add effects for this light state. void add_effects(const std::initializer_list &effects); @@ -254,7 +258,7 @@ class LightState : public EntityBase, public Component { } /// The result of all the current_values_as_* methods have gamma correction applied. - void current_values_as_binary(bool *binary); + void current_values_as_binary(bool *binary) { this->current_values.as_binary(binary); } void current_values_as_brightness(float *brightness); @@ -281,7 +285,7 @@ class LightState : public EntityBase, public Component { * return; * } */ - bool is_transformer_active(); + bool is_transformer_active() const { return this->is_transformer_active_; } protected: friend LightOutput; From 763a1d9371690543487037fa97a53d2bb2ea03a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:01:16 -0500 Subject: [PATCH 1640/1815] [select] Inline the trivial Select accessors (#18621) --- esphome/components/select/select.cpp | 17 ----------------- esphome/components/select/select.h | 14 ++++++++------ esphome/components/select/select_traits.cpp | 2 -- esphome/components/select/select_traits.h | 2 +- 4 files changed, 9 insertions(+), 26 deletions(-) diff --git a/esphome/components/select/select.cpp b/esphome/components/select/select.cpp index 17c6c811dd..05a0ee1ed9 100644 --- a/esphome/components/select/select.cpp +++ b/esphome/components/select/select.cpp @@ -8,8 +8,6 @@ namespace esphome::select { static const char *const TAG = "select"; -void Select::publish_state(const std::string &state) { this->publish_state(state.c_str()); } - void Select::publish_state(const char *state) { auto index = this->index_of(state); if (index.has_value()) { @@ -34,21 +32,6 @@ void Select::publish_state(size_t index) { #endif } -StringRef Select::current_option() const { - return this->has_state() ? StringRef(this->option_at(this->active_index_)) : StringRef(); -} - -bool Select::has_option(const std::string &option) const { return this->index_of(option.c_str()).has_value(); } - -bool Select::has_option(const char *option) const { return this->index_of(option).has_value(); } - -bool Select::has_index(size_t index) const { return index < this->size(); } - -size_t Select::size() const { - const auto &options = traits.get_options(); - return options.size(); -} - optional Select::index_of(const char *option, size_t len) const { const auto &options = traits.get_options(); for (size_t i = 0; i < options.size(); i++) { diff --git a/esphome/components/select/select.h b/esphome/components/select/select.h index 34d9248523..2294f34e62 100644 --- a/esphome/components/select/select.h +++ b/esphome/components/select/select.h @@ -33,27 +33,29 @@ class Select : public EntityBase { Select() = default; ~Select() = default; - void publish_state(const std::string &state); + void publish_state(const std::string &state) { this->publish_state(state.c_str()); } void publish_state(const char *state); void publish_state(size_t index); /// Return the currently selected option, or empty StringRef if no state. /// The returned StringRef points to string literals from codegen (static storage). /// Traits are set once at startup and valid for the lifetime of the program. - StringRef current_option() const; + StringRef current_option() const { + return this->has_state() ? StringRef(this->option_at(this->active_index_)) : StringRef(); + } /// Instantiate a SelectCall object to modify this select component's state. SelectCall make_call() { return SelectCall(this); } /// Return whether this select component contains the provided option. - bool has_option(const std::string &option) const; - bool has_option(const char *option) const; + bool has_option(const std::string &option) const { return this->index_of(option).has_value(); } + bool has_option(const char *option) const { return this->index_of(option).has_value(); } /// Return whether this select component contains the provided index offset. - bool has_index(size_t index) const; + bool has_index(size_t index) const { return index < this->size(); } /// Return the number of options in this select component. - size_t size() const; + size_t size() const { return this->traits.get_options().size(); } /// Find the (optional) index offset of the provided option value. optional index_of(const char *option, size_t len) const; diff --git a/esphome/components/select/select_traits.cpp b/esphome/components/select/select_traits.cpp index ff52c0d85b..67a5118646 100644 --- a/esphome/components/select/select_traits.cpp +++ b/esphome/components/select/select_traits.cpp @@ -11,6 +11,4 @@ void SelectTraits::set_options(const FixedVector &options) { } } -const FixedVector &SelectTraits::get_options() const { return this->options_; } - } // namespace esphome::select diff --git a/esphome/components/select/select_traits.h b/esphome/components/select/select_traits.h index 78a83e5944..e1b261bc96 100644 --- a/esphome/components/select/select_traits.h +++ b/esphome/components/select/select_traits.h @@ -9,7 +9,7 @@ class SelectTraits { public: void set_options(const std::initializer_list &options); void set_options(const FixedVector &options); - const FixedVector &get_options() const; + const FixedVector &get_options() const { return this->options_; } protected: FixedVector options_; From c60062c418b3a2e2fb841557d611bd0f341ae551 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:01:28 -0500 Subject: [PATCH 1641/1815] [sensor] Inline the trivial ExponentialMovingAverageFilter setters (#18622) --- esphome/components/sensor/filter.cpp | 2 -- esphome/components/sensor/filter.h | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 0105580d26..dbd6f4d34b 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -164,8 +164,6 @@ optional ExponentialMovingAverageFilter::new_value(float value) { } return {}; } -void ExponentialMovingAverageFilter::set_send_every(uint16_t send_every) { this->send_every_ = send_every; } -void ExponentialMovingAverageFilter::set_alpha(float alpha) { this->alpha_ = alpha; } // ThrottleAverageFilter ThrottleAverageFilter::ThrottleAverageFilter(uint32_t time_period) : time_period_(time_period) {} diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index b79bfa17d6..bc086e3805 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -239,8 +239,8 @@ class ExponentialMovingAverageFilter : public Filter { optional new_value(float value) override; - void set_send_every(uint16_t send_every); - void set_alpha(float alpha); + void set_send_every(uint16_t send_every) { this->send_every_ = send_every; } + void set_alpha(float alpha) { this->alpha_ = alpha; } protected: float accumulator_{NAN}; From efc0a94112f93d25b9806a332310a877faebe6b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:01:42 -0500 Subject: [PATCH 1642/1815] [text_sensor] Inline the trivial TextSensor forwarding overloads (#18623) --- esphome/components/text_sensor/text_sensor.cpp | 8 -------- esphome/components/text_sensor/text_sensor.h | 8 +++++--- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/esphome/components/text_sensor/text_sensor.cpp b/esphome/components/text_sensor/text_sensor.cpp index d2483619a6..17c606d253 100644 --- a/esphome/components/text_sensor/text_sensor.cpp +++ b/esphome/components/text_sensor/text_sensor.cpp @@ -18,10 +18,6 @@ void log_text_sensor(const char *tag, const char *prefix, const char *type, Text LOG_ENTITY_ICON(tag, prefix, *obj); } -void TextSensor::publish_state(const std::string &state) { this->publish_state(state.data(), state.size()); } - -void TextSensor::publish_state(const char *state) { this->publish_state(state, strlen(state)); } - void TextSensor::publish_state(const char *state, size_t len) { #ifdef USE_TEXT_SENSOR_FILTER if (this->filter_list_ == nullptr) { @@ -91,10 +87,6 @@ const std::string &TextSensor::get_raw_state() const { #endif return this->state; // No filters, raw == filtered } -void TextSensor::internal_send_state_to_frontend(const std::string &state) { - this->internal_send_state_to_frontend(state.data(), state.size()); -} - void TextSensor::internal_send_state_to_frontend(const char *state, size_t len) { // Only assign if changed to avoid heap allocation if (len != this->state.size() || memcmp(state, this->state.data(), len) != 0) { diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index aa48781f41..0e7364bf98 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -37,8 +37,8 @@ class TextSensor : public EntityBase { /// Returns the raw (pre-filter) state. const std::string &get_raw_state() const; - void publish_state(const std::string &state); - void publish_state(const char *state); + void publish_state(const std::string &state) { this->publish_state(state.data(), state.size()); } + void publish_state(const char *state) { this->publish_state(state, strlen(state)); } void publish_state(const char *state, size_t len); #ifdef USE_TEXT_SENSOR_FILTER @@ -70,7 +70,9 @@ class TextSensor : public EntityBase { // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) - void internal_send_state_to_frontend(const std::string &state); + void internal_send_state_to_frontend(const std::string &state) { + this->internal_send_state_to_frontend(state.data(), state.size()); + } void internal_send_state_to_frontend(const char *state, size_t len); protected: From 5c2286cc4a1ea1cd3392df32ec1dc27f4ca4a27b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:01:55 -0500 Subject: [PATCH 1643/1815] [climate] Inline the trivial visual override setters (#18624) --- esphome/components/climate/climate.cpp | 23 ----------------------- esphome/components/climate/climate.h | 21 ++++++++++++++++----- 2 files changed, 16 insertions(+), 28 deletions(-) diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index b41ca4a540..0f01443bd0 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -511,29 +511,6 @@ ClimateTraits Climate::get_traits() { return traits; } -#ifdef USE_CLIMATE_VISUAL_OVERRIDES -void Climate::set_visual_min_temperature_override(float visual_min_temperature_override) { - this->visual_min_temperature_override_ = visual_min_temperature_override; -} - -void Climate::set_visual_max_temperature_override(float visual_max_temperature_override) { - this->visual_max_temperature_override_ = visual_max_temperature_override; -} - -void Climate::set_visual_temperature_step_override(float target, float current) { - this->visual_target_temperature_step_override_ = target; - this->visual_current_temperature_step_override_ = current; -} - -void Climate::set_visual_min_humidity_override(float visual_min_humidity_override) { - this->visual_min_humidity_override_ = visual_min_humidity_override; -} - -void Climate::set_visual_max_humidity_override(float visual_max_humidity_override) { - this->visual_max_humidity_override_ = visual_max_humidity_override; -} -#endif - ClimateCall Climate::make_call() { return ClimateCall(this); } ClimateCall ClimateDeviceRestoreState::to_call(Climate *climate) { diff --git a/esphome/components/climate/climate.h b/esphome/components/climate/climate.h index 04f653a2b0..a906897235 100644 --- a/esphome/components/climate/climate.h +++ b/esphome/components/climate/climate.h @@ -228,11 +228,22 @@ class Climate : public EntityBase { ClimateTraits get_traits(); #ifdef USE_CLIMATE_VISUAL_OVERRIDES - void set_visual_min_temperature_override(float visual_min_temperature_override); - void set_visual_max_temperature_override(float visual_max_temperature_override); - void set_visual_temperature_step_override(float target, float current); - void set_visual_min_humidity_override(float visual_min_humidity_override); - void set_visual_max_humidity_override(float visual_max_humidity_override); + void set_visual_min_temperature_override(float visual_min_temperature_override) { + this->visual_min_temperature_override_ = visual_min_temperature_override; + } + void set_visual_max_temperature_override(float visual_max_temperature_override) { + this->visual_max_temperature_override_ = visual_max_temperature_override; + } + void set_visual_temperature_step_override(float target, float current) { + this->visual_target_temperature_step_override_ = target; + this->visual_current_temperature_step_override_ = current; + } + void set_visual_min_humidity_override(float visual_min_humidity_override) { + this->visual_min_humidity_override_ = visual_min_humidity_override; + } + void set_visual_max_humidity_override(float visual_max_humidity_override) { + this->visual_max_humidity_override_ = visual_max_humidity_override; + } #endif /// Set the supported custom fan modes (stored on Climate, referenced by ClimateTraits). From ce019f508d3a78a31e17bf71f98a70ef0d63419e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:02:04 -0500 Subject: [PATCH 1644/1815] [safe_mode] Inline the trivial set_safe_mode setters (#18626) --- esphome/components/safe_mode/button/safe_mode_button.cpp | 4 ---- esphome/components/safe_mode/button/safe_mode_button.h | 2 +- esphome/components/safe_mode/switch/safe_mode_switch.cpp | 4 ---- esphome/components/safe_mode/switch/safe_mode_switch.h | 2 +- 4 files changed, 2 insertions(+), 10 deletions(-) diff --git a/esphome/components/safe_mode/button/safe_mode_button.cpp b/esphome/components/safe_mode/button/safe_mode_button.cpp index 04203854fb..982ecf8402 100644 --- a/esphome/components/safe_mode/button/safe_mode_button.cpp +++ b/esphome/components/safe_mode/button/safe_mode_button.cpp @@ -7,10 +7,6 @@ namespace esphome::safe_mode { static const char *const TAG = "safe_mode.button"; -void SafeModeButton::set_safe_mode(SafeModeComponent *safe_mode_component) { - this->safe_mode_component_ = safe_mode_component; -} - void SafeModeButton::press_action() { ESP_LOGI(TAG, "Restarting in safe mode"); this->safe_mode_component_->set_safe_mode_pending(true); diff --git a/esphome/components/safe_mode/button/safe_mode_button.h b/esphome/components/safe_mode/button/safe_mode_button.h index 6012bb2aeb..035bd77802 100644 --- a/esphome/components/safe_mode/button/safe_mode_button.h +++ b/esphome/components/safe_mode/button/safe_mode_button.h @@ -9,7 +9,7 @@ namespace esphome::safe_mode { class SafeModeButton final : public button::Button, public Component { public: void dump_config() override; - void set_safe_mode(SafeModeComponent *safe_mode_component); + void set_safe_mode(SafeModeComponent *safe_mode_component) { this->safe_mode_component_ = safe_mode_component; } protected: SafeModeComponent *safe_mode_component_; diff --git a/esphome/components/safe_mode/switch/safe_mode_switch.cpp b/esphome/components/safe_mode/switch/safe_mode_switch.cpp index f513465db0..b4b9735757 100644 --- a/esphome/components/safe_mode/switch/safe_mode_switch.cpp +++ b/esphome/components/safe_mode/switch/safe_mode_switch.cpp @@ -7,10 +7,6 @@ namespace esphome::safe_mode { static const char *const TAG = "safe_mode.switch"; -void SafeModeSwitch::set_safe_mode(SafeModeComponent *safe_mode_component) { - this->safe_mode_component_ = safe_mode_component; -} - void SafeModeSwitch::write_state(bool state) { // Acknowledge this->publish_state(false); diff --git a/esphome/components/safe_mode/switch/safe_mode_switch.h b/esphome/components/safe_mode/switch/safe_mode_switch.h index cbd79cd520..cb48023f63 100644 --- a/esphome/components/safe_mode/switch/safe_mode_switch.h +++ b/esphome/components/safe_mode/switch/safe_mode_switch.h @@ -9,7 +9,7 @@ namespace esphome::safe_mode { class SafeModeSwitch final : public switch_::Switch, public Component { public: void dump_config() override; - void set_safe_mode(SafeModeComponent *safe_mode_component); + void set_safe_mode(SafeModeComponent *safe_mode_component) { this->safe_mode_component_ = safe_mode_component; } protected: SafeModeComponent *safe_mode_component_; From dba3b287dd817b9ad68941b7fd1a8294bdc4a616 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:02:14 -0500 Subject: [PATCH 1645/1815] [api] Inline the trivial APIServer accessors (#18627) --- esphome/components/api/api_server.cpp | 10 ---------- esphome/components/api/api_server.h | 10 +++++----- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index ef5b43d7b1..2d5f9e4155 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -423,12 +423,6 @@ void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_ API_DISPATCH_UPDATE(alarm_control_panel::AlarmControlPanel, alarm_control_panel) #endif -float APIServer::get_setup_priority() const { return setup_priority::AFTER_WIFI; } - -void APIServer::set_port(uint16_t port) { this->port_ = port; } - -void APIServer::set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = batch_delay; } - #ifdef USE_API_HOMEASSISTANT_SERVICES void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call) { bool has_subscriber = false; @@ -553,10 +547,6 @@ const std::vector &APIServer::get_sta } #endif -uint16_t APIServer::get_port() const { return this->port_; } - -void APIServer::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } - #ifdef USE_API_NOISE bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg, bool make_active) { diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 248b83a0ff..a58e42534b 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -51,8 +51,8 @@ class APIServer final : public Component, public: APIServer(); void setup() override; - uint16_t get_port() const; - float get_setup_priority() const override; + uint16_t get_port() const { return this->port_; } + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } void loop() override; void dump_config() override; void on_shutdown() override; @@ -63,9 +63,9 @@ class APIServer final : public Component, #ifdef USE_CAMERA void on_camera_image(const std::shared_ptr &image) override; #endif - void set_port(uint16_t port); - void set_reboot_timeout(uint32_t reboot_timeout); - void set_batch_delay(uint16_t batch_delay); + void set_port(uint16_t port) { this->port_ = port; } + void set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } + void set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = batch_delay; } uint16_t get_batch_delay() const { return batch_delay_; } void set_listen_backlog(uint8_t listen_backlog) { this->listen_backlog_ = listen_backlog; } From 0e915e9b8bf709acd8b63258cfb6d7bd27b2a85b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:02:58 -0500 Subject: [PATCH 1646/1815] [core] Inline the ESPTime::strftime std::string overload (#18628) --- esphome/core/time.cpp | 2 -- esphome/core/time.h | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/core/time.cpp b/esphome/core/time.cpp index b6fc9b90ad..d1ba981e95 100644 --- a/esphome/core/time.cpp +++ b/esphome/core/time.cpp @@ -114,8 +114,6 @@ std::string ESPTime::strftime(const char *format) { return std::string(buf, len); } -std::string ESPTime::strftime(const std::string &format) { return this->strftime(format.c_str()); } - // Helper to parse exactly N digits, returns false if not enough digits static bool parse_digits(const char *&p, const char *end, int count, uint16_t &value) { value = 0; diff --git a/esphome/core/time.h b/esphome/core/time.h index 0b67b7b3fc..f58cf20b4e 100644 --- a/esphome/core/time.h +++ b/esphome/core/time.h @@ -71,7 +71,7 @@ struct ESPTime { * @warning This method can return "ERROR" when the underlying strftime() call fails or when the * output exceeds STRFTIME_BUFFER_SIZE bytes. */ - std::string strftime(const std::string &format); + std::string strftime(const std::string &format) { return this->strftime(format.c_str()); } /// @copydoc strftime(const std::string &format) std::string strftime(const char *format); From 3ef5a8e6a4cca3f061712c39a8757c8e1a001eb7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:03:07 -0500 Subject: [PATCH 1647/1815] [water_heater] Inline the trivial visual override setters (#18629) --- esphome/components/water_heater/water_heater.cpp | 12 ------------ esphome/components/water_heater/water_heater.h | 12 +++++++++--- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/esphome/components/water_heater/water_heater.cpp b/esphome/components/water_heater/water_heater.cpp index 9ee8faadee..9862253ad9 100644 --- a/esphome/components/water_heater/water_heater.cpp +++ b/esphome/components/water_heater/water_heater.cpp @@ -233,18 +233,6 @@ WaterHeaterTraits WaterHeater::get_traits() { return traits; } -#ifdef USE_WATER_HEATER_VISUAL_OVERRIDES -void WaterHeater::set_visual_min_temperature_override(float min_temperature_override) { - this->visual_min_temperature_override_ = min_temperature_override; -} -void WaterHeater::set_visual_max_temperature_override(float max_temperature_override) { - this->visual_max_temperature_override_ = max_temperature_override; -} -void WaterHeater::set_visual_target_temperature_step_override(float visual_target_temperature_step_override) { - this->visual_target_temperature_step_override_ = visual_target_temperature_step_override; -} -#endif - // Water heater mode strings indexed by WaterHeaterMode enum (0-6): OFF, ECO, ELECTRIC, PERFORMANCE, HIGH_DEMAND, // HEAT_PUMP, GAS PROGMEM_STRING_TABLE(WaterHeaterModeStrings, "OFF", "ECO", "ELECTRIC", "PERFORMANCE", "HIGH_DEMAND", "HEAT_PUMP", "GAS", diff --git a/esphome/components/water_heater/water_heater.h b/esphome/components/water_heater/water_heater.h index 995b815440..1255a68595 100644 --- a/esphome/components/water_heater/water_heater.h +++ b/esphome/components/water_heater/water_heater.h @@ -217,9 +217,15 @@ class WaterHeater : public EntityBase { virtual WaterHeaterCallInternal make_call() = 0; #ifdef USE_WATER_HEATER_VISUAL_OVERRIDES - void set_visual_min_temperature_override(float min_temperature_override); - void set_visual_max_temperature_override(float max_temperature_override); - void set_visual_target_temperature_step_override(float visual_target_temperature_step_override); + void set_visual_min_temperature_override(float min_temperature_override) { + this->visual_min_temperature_override_ = min_temperature_override; + } + void set_visual_max_temperature_override(float max_temperature_override) { + this->visual_max_temperature_override_ = max_temperature_override; + } + void set_visual_target_temperature_step_override(float visual_target_temperature_step_override) { + this->visual_target_temperature_step_override_ = visual_target_temperature_step_override; + } #endif virtual void control(const WaterHeaterCall &call) = 0; From cb4e55e4449b08dac696d38dc4314216a8c9b332 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:03:15 -0500 Subject: [PATCH 1648/1815] [cover] Inline the trivial Cover and CoverCall accessors (#18630) --- esphome/components/cover/cover.cpp | 7 ------- esphome/components/cover/cover.h | 8 ++++---- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/esphome/components/cover/cover.cpp b/esphome/components/cover/cover.cpp index e98a555fe5..dc2db3bf32 100644 --- a/esphome/components/cover/cover.cpp +++ b/esphome/components/cover/cover.cpp @@ -135,10 +135,6 @@ CoverCall &CoverCall::set_stop(bool stop) { this->stop_ = stop; return *this; } -bool CoverCall::get_stop() const { return this->stop_; } - -CoverCall Cover::make_call() { return {this}; } - void Cover::publish_state(bool save) { this->position = clamp(this->position, 0.0f, 1.0f); this->tilt = clamp(this->tilt, 0.0f, 1.0f); @@ -184,9 +180,6 @@ optional Cover::restore_state_() { return recovered; } -bool Cover::is_fully_open() const { return this->position == COVER_OPEN; } -bool Cover::is_fully_closed() const { return this->position == COVER_CLOSED; } - CoverCall CoverRestoreState::to_call(Cover *cover) { auto call = cover->make_call(); auto traits = cover->get_traits(); diff --git a/esphome/components/cover/cover.h b/esphome/components/cover/cover.h index 9a75e68487..8bf45cfb57 100644 --- a/esphome/components/cover/cover.h +++ b/esphome/components/cover/cover.h @@ -50,7 +50,7 @@ class CoverCall { void perform(); const optional &get_position() const; - bool get_stop() const; + bool get_stop() const { return this->stop_; } const optional &get_tilt() const; const optional &get_toggle() const; @@ -123,7 +123,7 @@ class Cover : public EntityBase { float tilt{COVER_OPEN}; /// Construct a new cover call used to control the cover. - CoverCall make_call(); + CoverCall make_call() { return {this}; } template void add_on_state_callback(F &&f) { this->state_callback_.add(std::forward(f)); } @@ -139,9 +139,9 @@ class Cover : public EntityBase { virtual CoverTraits get_traits() = 0; /// Helper method to check if the cover is fully open. Equivalent to comparing .position against 1.0 - bool is_fully_open() const; + bool is_fully_open() const { return this->position == COVER_OPEN; } /// Helper method to check if the cover is fully closed. Equivalent to comparing .position against 0.0 - bool is_fully_closed() const; + bool is_fully_closed() const { return this->position == COVER_CLOSED; } protected: friend CoverCall; From fedb3ac5c1999f03eb4f47f1b63c2d23b37ed47f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:03:38 -0500 Subject: [PATCH 1649/1815] [fan] Inline the trivial Fan call helpers (#18631) --- esphome/components/fan/fan.cpp | 5 ----- esphome/components/fan/fan.h | 8 ++++---- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index 853bf94ffe..7dc0b5c6fe 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -153,11 +153,6 @@ void FanRestoreState::apply(Fan &fan) { fan.publish_state(); } -FanCall Fan::turn_on() { return this->make_call().set_state(true); } -FanCall Fan::turn_off() { return this->make_call().set_state(false); } -FanCall Fan::toggle() { return this->make_call().set_state(!this->state); } -FanCall Fan::make_call() { return FanCall(*this); } - const char *Fan::find_preset_mode_(const char *preset_mode) { return this->find_preset_mode_(preset_mode, preset_mode ? strlen(preset_mode) : 0); } diff --git a/esphome/components/fan/fan.h b/esphome/components/fan/fan.h index 3d731e6eb0..106e6e74cd 100644 --- a/esphome/components/fan/fan.h +++ b/esphome/components/fan/fan.h @@ -115,10 +115,10 @@ class Fan : public EntityBase { /// The current direction of the fan FanDirection direction{FanDirection::FORWARD}; - FanCall turn_on(); - FanCall turn_off(); - FanCall toggle(); - FanCall make_call(); + FanCall turn_on() { return this->make_call().set_state(true); } + FanCall turn_off() { return this->make_call().set_state(false); } + FanCall toggle() { return this->make_call().set_state(!this->state); } + FanCall make_call() { return FanCall(*this); } /// Register a callback that will be called each time the state changes. template void add_on_state_callback(F &&callback) { From 832a738588e2d308e981c71a6620e894a2ac356d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:03:51 -0500 Subject: [PATCH 1650/1815] [switch] Inline the trivial inverted accessors (#18632) --- esphome/components/switch/switch.cpp | 3 --- esphome/components/switch/switch.h | 4 ++-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/esphome/components/switch/switch.cpp b/esphome/components/switch/switch.cpp index abc7338a62..101a0b9ffa 100644 --- a/esphome/components/switch/switch.cpp +++ b/esphome/components/switch/switch.cpp @@ -69,9 +69,6 @@ void Switch::publish_state(bool state) { } bool Switch::assumed_state() { return false; } -void Switch::set_inverted(bool inverted) { this->inverted_ = inverted; } -bool Switch::is_inverted() const { return this->inverted_; } - void log_switch(const char *tag, const char *prefix, const char *type, Switch *obj) { if (obj != nullptr) { // Prepare restore mode string diff --git a/esphome/components/switch/switch.h b/esphome/components/switch/switch.h index b7761cba0a..0564c3efd2 100644 --- a/esphome/components/switch/switch.h +++ b/esphome/components/switch/switch.h @@ -87,7 +87,7 @@ class Switch : public EntityBase { * * @param inverted Whether to invert this switch. */ - void set_inverted(bool inverted); + void set_inverted(bool inverted) { this->inverted_ = inverted; } /** Set callback for state changes. * @@ -117,7 +117,7 @@ class Switch : public EntityBase { */ virtual bool assumed_state(); - bool is_inverted() const; + bool is_inverted() const { return this->inverted_; } void set_restore_mode(SwitchRestoreMode restore_mode) { this->restore_mode = restore_mode; } From ad1a4fca3653f4cc98da63abe7b66f434f7ee66c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:04:08 -0500 Subject: [PATCH 1651/1815] [version] Inline the trivial VersionTextSensor setters (#18633) --- esphome/components/version/version_text_sensor.cpp | 2 -- esphome/components/version/version_text_sensor.h | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/version/version_text_sensor.cpp b/esphome/components/version/version_text_sensor.cpp index 34c7aae6bc..15e6b0d088 100644 --- a/esphome/components/version/version_text_sensor.cpp +++ b/esphome/components/version/version_text_sensor.cpp @@ -48,8 +48,6 @@ void VersionTextSensor::setup() { version_str[sizeof(version_str) - 1] = '\0'; this->publish_state(version_str); } -void VersionTextSensor::set_hide_hash(bool hide_hash) { this->hide_hash_ = hide_hash; } -void VersionTextSensor::set_hide_timestamp(bool hide_timestamp) { this->hide_timestamp_ = hide_timestamp; } void VersionTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Version Text Sensor", this); } } // namespace esphome::version diff --git a/esphome/components/version/version_text_sensor.h b/esphome/components/version/version_text_sensor.h index d2ca0ba6f6..96f72ad035 100644 --- a/esphome/components/version/version_text_sensor.h +++ b/esphome/components/version/version_text_sensor.h @@ -7,8 +7,8 @@ namespace esphome::version { class VersionTextSensor final : public text_sensor::TextSensor, public Component { public: - void set_hide_hash(bool hide_hash); - void set_hide_timestamp(bool hide_timestamp); + void set_hide_hash(bool hide_hash) { this->hide_hash_ = hide_hash; } + void set_hide_timestamp(bool hide_timestamp) { this->hide_timestamp_ = hide_timestamp; } void setup() override; void dump_config() override; From ecb007da70a94f6605d01f8d775c27a639ae5e02 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:04:23 -0500 Subject: [PATCH 1652/1815] [deep_sleep] Inline the trivial DeepSleepComponent setters (#18634) --- esphome/components/deep_sleep/deep_sleep_component.cpp | 8 -------- esphome/components/deep_sleep/deep_sleep_component.h | 10 +++++----- esphome/components/deep_sleep/deep_sleep_esp32.cpp | 6 ------ 3 files changed, 5 insertions(+), 19 deletions(-) diff --git a/esphome/components/deep_sleep/deep_sleep_component.cpp b/esphome/components/deep_sleep/deep_sleep_component.cpp index e7ce70b60c..9a3e537e05 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.cpp +++ b/esphome/components/deep_sleep/deep_sleep_component.cpp @@ -43,10 +43,6 @@ void DeepSleepComponent::loop() { this->begin_sleep(); } -void DeepSleepComponent::set_sleep_duration(uint32_t time_ms) { this->sleep_duration_ = uint64_t(time_ms) * 1000; } - -void DeepSleepComponent::set_run_duration(uint32_t time_ms) { this->run_duration_ = time_ms; } - void DeepSleepComponent::begin_sleep(bool manual) { if (this->prevent_ && !manual) { this->next_enter_deep_sleep_ = true; @@ -76,8 +72,4 @@ void DeepSleepComponent::begin_sleep(bool manual) { float DeepSleepComponent::get_setup_priority() const { return setup_priority::LATE; } -void DeepSleepComponent::prevent_deep_sleep() { this->prevent_ = true; } - -void DeepSleepComponent::allow_deep_sleep() { this->prevent_ = false; } - } // namespace esphome::deep_sleep diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index a620d52a02..208f88d707 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -132,7 +132,7 @@ template class PreventDeepSleepAction; class DeepSleepComponent final : public Component { public: /// Set the duration in ms the component should sleep once it's in deep sleep mode. - void set_sleep_duration(uint32_t time_ms); + void set_sleep_duration(uint32_t time_ms) { this->sleep_duration_ = uint64_t(time_ms) * 1000; } #if defined(USE_ESP32) /** Set the pin to wake up to on the ESP32 once it's in deep sleep mode. * Use the inverted property to set the wakeup level. @@ -157,7 +157,7 @@ class DeepSleepComponent final : public Component { #if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ !defined(USE_ESP32_VARIANT_ESP32C5) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) - void set_touch_wakeup(bool touch_wakeup); + void set_touch_wakeup(bool touch_wakeup) { this->touch_wakeup_ = touch_wakeup; } #endif // Set the duration in ms for how long the code should run before entering @@ -166,7 +166,7 @@ class DeepSleepComponent final : public Component { #endif // USE_ESP32 /// Set a duration in ms for how long the code should run before entering deep sleep mode. - void set_run_duration(uint32_t time_ms); + void set_run_duration(uint32_t time_ms) { this->run_duration_ = time_ms; } void setup() override; void dump_config() override; @@ -176,8 +176,8 @@ class DeepSleepComponent final : public Component { /// Helper to enter deep sleep mode void begin_sleep(bool manual = false); - void prevent_deep_sleep(); - void allow_deep_sleep(); + void prevent_deep_sleep() { this->prevent_ = true; } + void allow_deep_sleep() { this->prevent_ = false; } protected: // Returns nullopt if no run duration is set. Otherwise, returns the run diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index f64e1f37e1..3fa1a1f1ed 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -74,12 +74,6 @@ void DeepSleepComponent::set_wakeup_pin_mode(WakeupPinMode wakeup_pin_mode) { void DeepSleepComponent::set_ext1_wakeup(Ext1Wakeup ext1_wakeup) { this->ext1_wakeup_ = ext1_wakeup; } #endif -#if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ - !defined(USE_ESP32_VARIANT_ESP32C5) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ - !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) -void DeepSleepComponent::set_touch_wakeup(bool touch_wakeup) { this->touch_wakeup_ = touch_wakeup; } -#endif - void DeepSleepComponent::set_run_duration(WakeupCauseToRunDuration wakeup_cause_to_run_duration) { wakeup_cause_to_run_duration_ = wakeup_cause_to_run_duration; } From 5a9f06e584ac8e771f9aaca53ae85574f5d7776b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:04:41 -0500 Subject: [PATCH 1653/1815] [thermostat] Inline the trivial ThermostatClimate setters and getters (#18635) --- .../thermostat/thermostat_climate.cpp | 95 -------------- .../thermostat/thermostat_climate.h | 116 +++++++++++------- 2 files changed, 74 insertions(+), 137 deletions(-) diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index 2390a96337..c10eb5b9f5 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -76,11 +76,6 @@ void ThermostatClimate::loop() { } } -float ThermostatClimate::cool_deadband() { return this->cooling_deadband_; } -float ThermostatClimate::cool_overrun() { return this->cooling_overrun_; } -float ThermostatClimate::heat_deadband() { return this->heating_deadband_; } -float ThermostatClimate::heat_overrun() { return this->heating_overrun_; } - void ThermostatClimate::refresh() { this->switch_to_mode_(this->mode, false); this->switch_to_action_(this->compute_action_(), false); @@ -121,8 +116,6 @@ bool ThermostatClimate::fan_mode_change_delayed() { climate::ClimateAction ThermostatClimate::delayed_climate_action() { return this->compute_action_(true); } -climate::ClimateFanMode ThermostatClimate::locked_fan_mode() { return this->prev_fan_mode_; } - bool ThermostatClimate::hysteresis_valid() { if ((this->supports_cool_ || (this->supports_fan_only_ && this->supports_fan_only_cooling_)) && (std::isnan(this->cooling_deadband_) || std::isnan(this->cooling_overrun_))) @@ -1286,10 +1279,6 @@ bool ThermostatClimate::change_preset_internal_(const ThermostatClimateTargetTem return something_changed; } -void ThermostatClimate::set_preset_config(std::initializer_list presets) { - this->preset_config_ = presets; -} - void ThermostatClimate::set_custom_preset_config(std::initializer_list presets) { this->custom_preset_config_ = presets; // Populate Climate base class custom presets vector @@ -1317,19 +1306,6 @@ void ThermostatClimate::set_default_preset(const char *custom_preset) { void ThermostatClimate::set_default_preset(climate::ClimatePreset preset) { this->default_preset_ = preset; } -void ThermostatClimate::set_on_boot_restore_from(thermostat::OnBootRestoreFrom on_boot_restore_from) { - this->on_boot_restore_from_ = on_boot_restore_from; -} -void ThermostatClimate::set_set_point_minimum_differential(float differential) { - this->set_point_minimum_differential_ = differential; -} -void ThermostatClimate::set_cool_deadband(float deadband) { this->cooling_deadband_ = deadband; } -void ThermostatClimate::set_cool_overrun(float overrun) { this->cooling_overrun_ = overrun; } -void ThermostatClimate::set_heat_deadband(float deadband) { this->heating_deadband_ = deadband; } -void ThermostatClimate::set_heat_overrun(float overrun) { this->heating_overrun_ = overrun; } -void ThermostatClimate::set_supplemental_cool_delta(float delta) { this->supplemental_cool_delta_ = delta; } -void ThermostatClimate::set_supplemental_heat_delta(float delta) { this->supplemental_heat_delta_ = delta; } - void ThermostatClimate::set_timer_duration_in_sec_(ThermostatClimateTimerIndex timer_index, uint32_t time) { uint32_t new_duration_ms = 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); @@ -1389,80 +1365,9 @@ void ThermostatClimate::set_heating_minimum_run_time_in_sec(uint32_t time) { void ThermostatClimate::set_idle_minimum_time_in_sec(uint32_t time) { this->set_timer_duration_in_sec_(thermostat::THERMOSTAT_TIMER_IDLE_ON, time); } -void ThermostatClimate::set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } -void ThermostatClimate::set_humidity_sensor(sensor::Sensor *humidity_sensor) { - this->humidity_sensor_ = humidity_sensor; -} void ThermostatClimate::set_humidity_hysteresis(float humidity_hysteresis) { this->humidity_hysteresis_ = std::clamp(humidity_hysteresis, 0.0f, 100.0f); } -void ThermostatClimate::set_use_startup_delay(bool use_startup_delay) { this->use_startup_delay_ = use_startup_delay; } -void ThermostatClimate::set_supports_heat_cool(bool supports_heat_cool) { - this->supports_heat_cool_ = supports_heat_cool; -} -void ThermostatClimate::set_supports_auto(bool supports_auto) { this->supports_auto_ = supports_auto; } -void ThermostatClimate::set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } -void ThermostatClimate::set_supports_dry(bool supports_dry) { this->supports_dry_ = supports_dry; } -void ThermostatClimate::set_supports_fan_only(bool supports_fan_only) { this->supports_fan_only_ = supports_fan_only; } -void ThermostatClimate::set_supports_fan_only_action_uses_fan_mode_timer( - bool supports_fan_only_action_uses_fan_mode_timer) { - this->supports_fan_only_action_uses_fan_mode_timer_ = supports_fan_only_action_uses_fan_mode_timer; -} -void ThermostatClimate::set_supports_fan_only_cooling(bool supports_fan_only_cooling) { - this->supports_fan_only_cooling_ = supports_fan_only_cooling; -} -void ThermostatClimate::set_supports_fan_with_cooling(bool supports_fan_with_cooling) { - this->supports_fan_with_cooling_ = supports_fan_with_cooling; -} -void ThermostatClimate::set_supports_fan_with_heating(bool supports_fan_with_heating) { - this->supports_fan_with_heating_ = supports_fan_with_heating; -} -void ThermostatClimate::set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } -void ThermostatClimate::set_supports_fan_mode_on(bool supports_fan_mode_on) { - this->supports_fan_mode_on_ = supports_fan_mode_on; -} -void ThermostatClimate::set_supports_fan_mode_off(bool supports_fan_mode_off) { - this->supports_fan_mode_off_ = supports_fan_mode_off; -} -void ThermostatClimate::set_supports_fan_mode_auto(bool supports_fan_mode_auto) { - this->supports_fan_mode_auto_ = supports_fan_mode_auto; -} -void ThermostatClimate::set_supports_fan_mode_low(bool supports_fan_mode_low) { - this->supports_fan_mode_low_ = supports_fan_mode_low; -} -void ThermostatClimate::set_supports_fan_mode_medium(bool supports_fan_mode_medium) { - this->supports_fan_mode_medium_ = supports_fan_mode_medium; -} -void ThermostatClimate::set_supports_fan_mode_high(bool supports_fan_mode_high) { - this->supports_fan_mode_high_ = supports_fan_mode_high; -} -void ThermostatClimate::set_supports_fan_mode_middle(bool supports_fan_mode_middle) { - this->supports_fan_mode_middle_ = supports_fan_mode_middle; -} -void ThermostatClimate::set_supports_fan_mode_focus(bool supports_fan_mode_focus) { - this->supports_fan_mode_focus_ = supports_fan_mode_focus; -} -void ThermostatClimate::set_supports_fan_mode_diffuse(bool supports_fan_mode_diffuse) { - this->supports_fan_mode_diffuse_ = supports_fan_mode_diffuse; -} -void ThermostatClimate::set_supports_fan_mode_quiet(bool supports_fan_mode_quiet) { - this->supports_fan_mode_quiet_ = supports_fan_mode_quiet; -} -void ThermostatClimate::set_supports_swing_mode_both(bool supports_swing_mode_both) { - this->supports_swing_mode_both_ = supports_swing_mode_both; -} -void ThermostatClimate::set_supports_swing_mode_off(bool supports_swing_mode_off) { - this->supports_swing_mode_off_ = supports_swing_mode_off; -} -void ThermostatClimate::set_supports_swing_mode_horizontal(bool supports_swing_mode_horizontal) { - this->supports_swing_mode_horizontal_ = supports_swing_mode_horizontal; -} -void ThermostatClimate::set_supports_swing_mode_vertical(bool supports_swing_mode_vertical) { - this->supports_swing_mode_vertical_ = supports_swing_mode_vertical; -} -void ThermostatClimate::set_supports_two_points(bool supports_two_points) { - this->supports_two_points_ = supports_two_points; -} void ThermostatClimate::set_supports_dehumidification(bool supports_dehumidification) { this->supports_dehumidification_ = supports_dehumidification; if (supports_dehumidification) { diff --git a/esphome/components/thermostat/thermostat_climate.h b/esphome/components/thermostat/thermostat_climate.h index f30659a8a6..4dc2a74d8e 100644 --- a/esphome/components/thermostat/thermostat_climate.h +++ b/esphome/components/thermostat/thermostat_climate.h @@ -93,14 +93,16 @@ class ThermostatClimate final : public climate::Climate, public Component { void set_default_preset(const char *custom_preset); void set_default_preset(climate::ClimatePreset preset); - void set_on_boot_restore_from(OnBootRestoreFrom on_boot_restore_from); - void set_set_point_minimum_differential(float differential); - void set_cool_deadband(float deadband); - void set_cool_overrun(float overrun); - void set_heat_deadband(float deadband); - void set_heat_overrun(float overrun); - void set_supplemental_cool_delta(float delta); - void set_supplemental_heat_delta(float delta); + void set_on_boot_restore_from(thermostat::OnBootRestoreFrom on_boot_restore_from) { + this->on_boot_restore_from_ = on_boot_restore_from; + } + void set_set_point_minimum_differential(float differential) { this->set_point_minimum_differential_ = differential; } + void set_cool_deadband(float deadband) { this->cooling_deadband_ = deadband; } + void set_cool_overrun(float overrun) { this->cooling_overrun_ = overrun; } + void set_heat_deadband(float deadband) { this->heating_deadband_ = deadband; } + void set_heat_overrun(float overrun) { this->heating_overrun_ = overrun; } + void set_supplemental_cool_delta(float delta) { this->supplemental_cool_delta_ = delta; } + void set_supplemental_heat_delta(float delta) { this->supplemental_heat_delta_ = delta; } void set_cooling_maximum_run_time_in_sec(uint32_t time); void set_heating_maximum_run_time_in_sec(uint32_t time); void set_cooling_minimum_off_time_in_sec(uint32_t time); @@ -111,39 +113,69 @@ class ThermostatClimate final : public climate::Climate, public Component { void set_heating_minimum_off_time_in_sec(uint32_t time); void set_heating_minimum_run_time_in_sec(uint32_t time); void set_idle_minimum_time_in_sec(uint32_t time); - void set_sensor(sensor::Sensor *sensor); - void set_humidity_sensor(sensor::Sensor *humidity_sensor); + void set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } + void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } void set_humidity_hysteresis(float humidity_hysteresis); - void set_use_startup_delay(bool use_startup_delay); - void set_supports_auto(bool supports_auto); - void set_supports_heat_cool(bool supports_heat_cool); - void set_supports_cool(bool supports_cool); - void set_supports_dry(bool supports_dry); - void set_supports_fan_only(bool supports_fan_only); - void set_supports_fan_only_action_uses_fan_mode_timer(bool fan_only_action_uses_fan_mode_timer); - void set_supports_fan_only_cooling(bool supports_fan_only_cooling); - void set_supports_fan_with_cooling(bool supports_fan_with_cooling); - void set_supports_fan_with_heating(bool supports_fan_with_heating); - void set_supports_heat(bool supports_heat); - void set_supports_fan_mode_on(bool supports_fan_mode_on); - void set_supports_fan_mode_off(bool supports_fan_mode_off); - void set_supports_fan_mode_auto(bool supports_fan_mode_auto); - void set_supports_fan_mode_low(bool supports_fan_mode_low); - void set_supports_fan_mode_medium(bool supports_fan_mode_medium); - void set_supports_fan_mode_high(bool supports_fan_mode_high); - void set_supports_fan_mode_middle(bool supports_fan_mode_middle); - void set_supports_fan_mode_focus(bool supports_fan_mode_focus); - void set_supports_fan_mode_diffuse(bool supports_fan_mode_diffuse); - void set_supports_fan_mode_quiet(bool supports_fan_mode_quiet); - void set_supports_swing_mode_both(bool supports_swing_mode_both); - void set_supports_swing_mode_horizontal(bool supports_swing_mode_horizontal); - void set_supports_swing_mode_off(bool supports_swing_mode_off); - void set_supports_swing_mode_vertical(bool supports_swing_mode_vertical); + void set_use_startup_delay(bool use_startup_delay) { this->use_startup_delay_ = use_startup_delay; } + void set_supports_auto(bool supports_auto) { this->supports_auto_ = supports_auto; } + void set_supports_heat_cool(bool supports_heat_cool) { this->supports_heat_cool_ = supports_heat_cool; } + void set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } + void set_supports_dry(bool supports_dry) { this->supports_dry_ = supports_dry; } + void set_supports_fan_only(bool supports_fan_only) { this->supports_fan_only_ = supports_fan_only; } + void set_supports_fan_only_action_uses_fan_mode_timer(bool supports_fan_only_action_uses_fan_mode_timer) { + this->supports_fan_only_action_uses_fan_mode_timer_ = supports_fan_only_action_uses_fan_mode_timer; + } + void set_supports_fan_only_cooling(bool supports_fan_only_cooling) { + this->supports_fan_only_cooling_ = supports_fan_only_cooling; + } + void set_supports_fan_with_cooling(bool supports_fan_with_cooling) { + this->supports_fan_with_cooling_ = supports_fan_with_cooling; + } + void set_supports_fan_with_heating(bool supports_fan_with_heating) { + this->supports_fan_with_heating_ = supports_fan_with_heating; + } + void set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } + void set_supports_fan_mode_on(bool supports_fan_mode_on) { this->supports_fan_mode_on_ = supports_fan_mode_on; } + void set_supports_fan_mode_off(bool supports_fan_mode_off) { this->supports_fan_mode_off_ = supports_fan_mode_off; } + void set_supports_fan_mode_auto(bool supports_fan_mode_auto) { + this->supports_fan_mode_auto_ = supports_fan_mode_auto; + } + void set_supports_fan_mode_low(bool supports_fan_mode_low) { this->supports_fan_mode_low_ = supports_fan_mode_low; } + void set_supports_fan_mode_medium(bool supports_fan_mode_medium) { + this->supports_fan_mode_medium_ = supports_fan_mode_medium; + } + void set_supports_fan_mode_high(bool supports_fan_mode_high) { + this->supports_fan_mode_high_ = supports_fan_mode_high; + } + void set_supports_fan_mode_middle(bool supports_fan_mode_middle) { + this->supports_fan_mode_middle_ = supports_fan_mode_middle; + } + void set_supports_fan_mode_focus(bool supports_fan_mode_focus) { + this->supports_fan_mode_focus_ = supports_fan_mode_focus; + } + void set_supports_fan_mode_diffuse(bool supports_fan_mode_diffuse) { + this->supports_fan_mode_diffuse_ = supports_fan_mode_diffuse; + } + void set_supports_fan_mode_quiet(bool supports_fan_mode_quiet) { + this->supports_fan_mode_quiet_ = supports_fan_mode_quiet; + } + void set_supports_swing_mode_both(bool supports_swing_mode_both) { + this->supports_swing_mode_both_ = supports_swing_mode_both; + } + void set_supports_swing_mode_horizontal(bool supports_swing_mode_horizontal) { + this->supports_swing_mode_horizontal_ = supports_swing_mode_horizontal; + } + void set_supports_swing_mode_off(bool supports_swing_mode_off) { + this->supports_swing_mode_off_ = supports_swing_mode_off; + } + void set_supports_swing_mode_vertical(bool supports_swing_mode_vertical) { + this->supports_swing_mode_vertical_ = supports_swing_mode_vertical; + } void set_supports_dehumidification(bool supports_dehumidification); void set_supports_humidification(bool supports_humidification); - void set_supports_two_points(bool supports_two_points); + void set_supports_two_points(bool supports_two_points) { this->supports_two_points_ = supports_two_points; } - void set_preset_config(std::initializer_list presets); + void set_preset_config(std::initializer_list presets) { this->preset_config_ = presets; } void set_custom_preset_config(std::initializer_list presets); Trigger<> *get_cool_action_trigger(); @@ -181,10 +213,10 @@ class ThermostatClimate final : public climate::Climate, public Component { Trigger<> *get_humidity_control_humidify_action_trigger(); Trigger<> *get_humidity_control_off_action_trigger(); /// Get current hysteresis values - float cool_deadband(); - float cool_overrun(); - float heat_deadband(); - float heat_overrun(); + float cool_deadband() { return this->cooling_deadband_; } + float cool_overrun() { return this->cooling_overrun_; } + float heat_deadband() { return this->heating_deadband_; } + float heat_overrun() { return this->heating_overrun_; } /// Call triggers based on updated climate states (modes/actions) void refresh(); /// Returns true if a climate action/fan mode transition is being delayed @@ -193,7 +225,7 @@ class ThermostatClimate final : public climate::Climate, public Component { /// Returns the climate action that is being delayed (check climate_action_change_delayed(), first!) climate::ClimateAction delayed_climate_action(); /// Returns the fan mode that is locked in (check fan_mode_change_delayed(), first!) - climate::ClimateFanMode locked_fan_mode(); + climate::ClimateFanMode locked_fan_mode() { return this->prev_fan_mode_; } /// Set point and hysteresis validation bool hysteresis_valid(); // returns true if valid bool humidity_hysteresis_valid(); // returns true if valid From 78240c9a46f63fb6e7778f2b2e5720765e7a8bd7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:04:57 -0500 Subject: [PATCH 1654/1815] [sprinkler] Inline the trivial Sprinkler accessors (#18636) --- esphome/components/sprinkler/sprinkler.cpp | 43 --------------------- esphome/components/sprinkler/sprinkler.h | 44 +++++++++++++--------- 2 files changed, 27 insertions(+), 60 deletions(-) diff --git a/esphome/components/sprinkler/sprinkler.cpp b/esphome/components/sprinkler/sprinkler.cpp index 336123a472..2edceb76a5 100644 --- a/esphome/components/sprinkler/sprinkler.cpp +++ b/esphome/components/sprinkler/sprinkler.cpp @@ -211,8 +211,6 @@ uint32_t SprinklerValveOperator::time_remaining() { return 0; // run completed } -SprinklerState SprinklerValveOperator::state() { return this->state_; } - switch_::Switch *SprinklerValveOperator::pump_switch() { if ((this->controller_ == nullptr) || (this->valve_ == nullptr)) { return nullptr; @@ -288,11 +286,8 @@ SprinklerValveRunRequest::SprinklerValveRunRequest(size_t valve_number, uint32_t SprinklerValveOperator *valve_op) : valve_number_(valve_number), run_duration_(run_duration), valve_op_(valve_op) {} -bool SprinklerValveRunRequest::has_request() { return this->has_valve_; } bool SprinklerValveRunRequest::has_valve_operator() { return !(this->valve_op_ == nullptr); } -void SprinklerValveRunRequest::set_request_from(SprinklerValveRunRequestOrigin origin) { this->origin_ = origin; } - void SprinklerValveRunRequest::set_run_duration(uint32_t run_duration) { this->run_duration_ = run_duration; } void SprinklerValveRunRequest::set_valve(size_t valve_number) { @@ -317,8 +312,6 @@ void SprinklerValveRunRequest::reset() { uint32_t SprinklerValveRunRequest::run_duration() { return this->run_duration_; } -size_t SprinklerValveRunRequest::valve() { return this->valve_number_; } - optional SprinklerValveRunRequest::valve_as_opt() { if (this->has_valve_) { return this->valve_number_; @@ -328,8 +321,6 @@ optional SprinklerValveRunRequest::valve_as_opt() { SprinklerValveOperator *SprinklerValveRunRequest::valve_operator() { return this->valve_op_; } -SprinklerValveRunRequestOrigin SprinklerValveRunRequest::request_is_from() { return this->origin_; } - Sprinkler::Sprinkler() : Sprinkler("") {} Sprinkler::Sprinkler(const char *name) : name_(name) { // The `name` is stored for dump_config logging @@ -414,18 +405,6 @@ void Sprinkler::set_controller_main_switch(SprinklerControllerSwitch *controller this->sprinkler_turn_on_automation_->add_actions({sprinkler_resumeorstart_action_.get()}); } -void Sprinkler::set_controller_auto_adv_switch(SprinklerControllerSwitch *auto_adv_switch) { - this->auto_adv_sw_ = auto_adv_switch; -} - -void Sprinkler::set_controller_queue_enable_switch(SprinklerControllerSwitch *queue_enable_switch) { - this->queue_enable_sw_ = queue_enable_switch; -} - -void Sprinkler::set_controller_reverse_switch(SprinklerControllerSwitch *reverse_switch) { - this->reverse_sw_ = reverse_switch; -} - void Sprinkler::set_controller_standby_switch(SprinklerControllerSwitch *standby_switch) { this->standby_sw_ = standby_switch; @@ -434,14 +413,6 @@ void Sprinkler::set_controller_standby_switch(SprinklerControllerSwitch *standby this->sprinkler_standby_turn_on_automation_->add_actions({sprinkler_standby_shutdown_action_.get()}); } -void Sprinkler::set_controller_multiplier_number(SprinklerControllerNumber *multiplier_number) { - this->multiplier_number_ = multiplier_number; -} - -void Sprinkler::set_controller_repeat_number(SprinklerControllerNumber *repeat_number) { - this->repeat_number_ = repeat_number; -} - void Sprinkler::configure_valve_switch(size_t valve_number, switch_::Switch *valve_switch, uint32_t run_duration) { if (this->is_a_valid_valve(valve_number)) { this->valve_[valve_number].valve_switch = valve_switch; @@ -498,10 +469,6 @@ void Sprinkler::set_multiplier(const optional multiplier) { call.perform(); } -void Sprinkler::set_next_prev_ignore_disabled_valves(bool ignore_disabled) { - this->next_prev_ignore_disabled_ = ignore_disabled; -} - void Sprinkler::set_pump_start_delay(uint32_t start_delay) { this->start_delay_is_valve_delay_ = false; this->start_delay_ = start_delay; @@ -522,10 +489,6 @@ void Sprinkler::set_valve_stop_delay(uint32_t stop_delay) { this->stop_delay_ = stop_delay; } -void Sprinkler::set_pump_switch_off_during_valve_open_delay(bool pump_switch_off_during_valve_open_delay) { - this->pump_switch_off_during_valve_open_delay_ = pump_switch_off_during_valve_open_delay; -} - void Sprinkler::set_valve_open_delay(const uint32_t valve_open_delay) { if (valve_open_delay > 0) { this->valve_overlap_ = false; @@ -945,8 +908,6 @@ optional Sprinkler::active_valve() { return this->active_req_.valve_as_opt(); } -optional Sprinkler::paused_valve() { return this->paused_valve_; } - optional Sprinkler::queued_valve() { if (!this->queued_valves_.empty()) { return this->queued_valves_.back().valve_number; @@ -954,10 +915,6 @@ optional Sprinkler::queued_valve() { return nullopt; } -optional Sprinkler::manual_valve() { return this->manual_valve_; } - -size_t Sprinkler::number_of_valves() { return this->valve_.size(); } - bool Sprinkler::is_a_valid_valve(const size_t valve_number) { return (valve_number < this->number_of_valves()); } bool Sprinkler::pump_in_use(switch_::Switch *pump_switch) { diff --git a/esphome/components/sprinkler/sprinkler.h b/esphome/components/sprinkler/sprinkler.h index bd610f7ad3..2499a0a591 100644 --- a/esphome/components/sprinkler/sprinkler.h +++ b/esphome/components/sprinkler/sprinkler.h @@ -124,9 +124,9 @@ class SprinklerValveOperator { void set_stop_delay(uint32_t stop_delay, bool stop_delay_is_valve_delay); void start(); void stop(); - uint32_t run_duration(); // returns the desired run duration in seconds - uint32_t time_remaining(); // returns seconds remaining (does not include stop_delay_) - SprinklerState state(); // returns the valve's state/status + uint32_t run_duration(); // returns the desired run duration in seconds + uint32_t time_remaining(); // returns seconds remaining (does not include stop_delay_) + SprinklerState state() { return this->state_; } switch_::Switch *pump_switch(); // returns this SprinklerValveOperator's pump switch protected: @@ -152,18 +152,18 @@ class SprinklerValveRunRequest { public: SprinklerValveRunRequest(); SprinklerValveRunRequest(size_t valve_number, uint32_t run_duration, SprinklerValveOperator *valve_op); - bool has_request(); + bool has_request() { return this->has_valve_; } bool has_valve_operator(); - void set_request_from(SprinklerValveRunRequestOrigin origin); + void set_request_from(SprinklerValveRunRequestOrigin origin) { this->origin_ = origin; } void set_run_duration(uint32_t run_duration); void set_valve(size_t valve_number); void set_valve_operator(SprinklerValveOperator *valve_op); void reset(); uint32_t run_duration(); - size_t valve(); + size_t valve() { return this->valve_number_; } optional valve_as_opt(); SprinklerValveOperator *valve_operator(); - SprinklerValveRunRequestOrigin request_is_from(); + SprinklerValveRunRequestOrigin request_is_from() { return this->origin_; } protected: bool has_valve_{false}; @@ -189,14 +189,20 @@ class Sprinkler final : public Component { /// configure important controller switches void set_controller_main_switch(SprinklerControllerSwitch *controller_switch); - void set_controller_auto_adv_switch(SprinklerControllerSwitch *auto_adv_switch); - void set_controller_queue_enable_switch(SprinklerControllerSwitch *queue_enable_switch); - void set_controller_reverse_switch(SprinklerControllerSwitch *reverse_switch); + void set_controller_auto_adv_switch(SprinklerControllerSwitch *auto_adv_switch) { + this->auto_adv_sw_ = auto_adv_switch; + } + void set_controller_queue_enable_switch(SprinklerControllerSwitch *queue_enable_switch) { + this->queue_enable_sw_ = queue_enable_switch; + } + void set_controller_reverse_switch(SprinklerControllerSwitch *reverse_switch) { this->reverse_sw_ = reverse_switch; } void set_controller_standby_switch(SprinklerControllerSwitch *standby_switch); /// configure important controller number components - void set_controller_multiplier_number(SprinklerControllerNumber *multiplier_number); - void set_controller_repeat_number(SprinklerControllerNumber *repeat_number); + void set_controller_multiplier_number(SprinklerControllerNumber *multiplier_number) { + this->multiplier_number_ = multiplier_number; + } + void set_controller_repeat_number(SprinklerControllerNumber *repeat_number) { this->repeat_number_ = repeat_number; } /// configure a valve's switch object and run duration. run_duration is time in seconds. void configure_valve_switch(size_t valve_number, switch_::Switch *valve_switch, uint32_t run_duration); @@ -214,7 +220,9 @@ class Sprinkler final : public Component { void set_multiplier(optional multiplier); /// enable/disable skipping of disabled valves by the next and previous actions - void set_next_prev_ignore_disabled_valves(bool ignore_disabled); + void set_next_prev_ignore_disabled_valves(bool ignore_disabled) { + this->next_prev_ignore_disabled_ = ignore_disabled; + } /// set how long the pump should start after the valve (when the pump is starting) void set_pump_start_delay(uint32_t start_delay); @@ -230,7 +238,9 @@ class Sprinkler final : public Component { /// if pump_switch_off_during_valve_open_delay is true, the controller will switch off the pump during the /// valve_open_delay interval - void set_pump_switch_off_during_valve_open_delay(bool pump_switch_off_during_valve_open_delay); + void set_pump_switch_off_during_valve_open_delay(bool pump_switch_off_during_valve_open_delay) { + this->pump_switch_off_during_valve_open_delay_ = pump_switch_off_during_valve_open_delay; + } /// set how long the controller should wait to open/switch on the valve after it becomes active void set_valve_open_delay(uint32_t valve_open_delay); @@ -335,17 +345,17 @@ class Sprinkler final : public Component { optional active_valve(); /// returns the number of the valve that is paused, if any. check with 'has_value()' - optional paused_valve(); + optional paused_valve() { return this->paused_valve_; } /// returns the number of the next valve in the queue, if any. check with 'has_value()' optional queued_valve(); /// returns the number of the valve that is manually selected, if any. check with 'has_value()' /// this is set by next_valve() and previous_valve() when manual_selection_delay_ > 0 - optional manual_valve(); + optional manual_valve() { return this->manual_valve_; } /// returns the number of valves the controller is configured with - size_t number_of_valves(); + size_t number_of_valves() { return this->valve_.size(); } /// returns true if valve number is valid bool is_a_valid_valve(size_t valve_number); From 47156c9a5b5c517df04bb4c943a63a407435205a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:05:39 -0500 Subject: [PATCH 1655/1815] [mqtt] Inline the trivial MQTT client, component and sensor accessors (#18637) --- esphome/components/mqtt/mqtt_client.cpp | 8 -------- esphome/components/mqtt/mqtt_client.h | 12 ++++++------ esphome/components/mqtt/mqtt_component.cpp | 4 ---- esphome/components/mqtt/mqtt_component.h | 4 ++-- esphome/components/mqtt/mqtt_sensor.cpp | 2 -- esphome/components/mqtt/mqtt_sensor.h | 4 ++-- 6 files changed, 10 insertions(+), 24 deletions(-) diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index ab665e2579..1127c36dc6 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -668,9 +668,7 @@ void MQTTClientComponent::on_message(const std::string &topic, const std::string // Setters void MQTTClientComponent::disable_log_message() { this->log_message_.topic = ""; } bool MQTTClientComponent::is_log_message_enabled() const { return !this->log_message_.topic.empty(); } -void MQTTClientComponent::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } void MQTTClientComponent::register_mqtt_component(MQTTComponent *component) { this->children_.push_back(component); } -void MQTTClientComponent::set_log_level(int level) { this->log_level_ = level; } void MQTTClientComponent::set_keep_alive(uint16_t keep_alive_s) { this->mqtt_backend_.set_keep_alive(keep_alive_s); } void MQTTClientComponent::set_log_message_template(MQTTMessage &&message) { this->log_message_ = std::move(message); } const MQTTDiscoveryInfo &MQTTClientComponent::get_discovery_info() const { return this->discovery_info_; } @@ -683,10 +681,6 @@ void MQTTClientComponent::set_topic_prefix(const std::string &topic_prefix, cons } } const std::string &MQTTClientComponent::get_topic_prefix() const { return this->topic_prefix_; } -void MQTTClientComponent::set_publish_nan_as_none(bool publish_nan_as_none) { - this->publish_nan_as_none_ = publish_nan_as_none; -} -bool MQTTClientComponent::is_publish_nan_as_none() const { return this->publish_nan_as_none_; } void MQTTClientComponent::disable_birth_message() { this->birth_message_.topic = ""; this->recalculate_availability_(); @@ -766,8 +760,6 @@ MQTTClientComponent *global_mqtt_client = nullptr; // NOLINT(cppcoreguidelines- // MQTTMessageTrigger MQTTMessageTrigger::MQTTMessageTrigger(std::string topic) : topic_(std::move(topic)) {} -void MQTTMessageTrigger::set_qos(uint8_t qos) { this->qos_ = qos; } -void MQTTMessageTrigger::set_payload(const std::string &payload) { this->payload_ = payload; } void MQTTMessageTrigger::setup() { global_mqtt_client->subscribe( this->topic_, diff --git a/esphome/components/mqtt/mqtt_client.h b/esphome/components/mqtt/mqtt_client.h index f741be561c..fe0966e725 100644 --- a/esphome/components/mqtt/mqtt_client.h +++ b/esphome/components/mqtt/mqtt_client.h @@ -159,7 +159,7 @@ class MQTTClientComponent final : public Component { /// Manually set the topic used for logging. void set_log_message_template(MQTTMessage &&message); - void set_log_level(int level); + void set_log_level(int level) { this->log_level_ = level; } /// Get the topic used for logging. Defaults to "/debug" and the value is cached for speed. void disable_log_message(); bool is_log_message_enabled() const; @@ -241,7 +241,7 @@ class MQTTClientComponent final : public Component { void check_connected(); - void set_reboot_timeout(uint32_t reboot_timeout); + void set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } void register_mqtt_component(MQTTComponent *component); @@ -262,8 +262,8 @@ class MQTTClientComponent final : public Component { void set_on_disconnect(mqtt_on_disconnect_callback_t &&callback); // Publish None state instead of NaN for Home Assistant - void set_publish_nan_as_none(bool publish_nan_as_none); - bool is_publish_nan_as_none() const; + void set_publish_nan_as_none(bool publish_nan_as_none) { this->publish_nan_as_none_ = publish_nan_as_none; } + bool is_publish_nan_as_none() const { return this->publish_nan_as_none_; } void set_wait_for_connection(bool wait_for_connection) { this->wait_for_connection_ = wait_for_connection; } @@ -344,8 +344,8 @@ class MQTTMessageTrigger final : public Trigger, public Component { public: explicit MQTTMessageTrigger(std::string topic); - void set_qos(uint8_t qos); - void set_payload(const std::string &payload); + void set_qos(uint8_t qos) { this->qos_ = qos; } + void set_payload(const std::string &payload) { this->payload_ = payload; } void setup() override; void dump_config() override; float get_setup_priority() const override; diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index 3bbc1cdfa3..18a759725f 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -340,10 +340,6 @@ bool MQTTComponent::send_discovery_() { // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) } -uint8_t MQTTComponent::get_qos() const { return this->qos_; } - -bool MQTTComponent::get_retain() const { return this->retain_; } - bool MQTTComponent::is_discovery_enabled() const { return this->discovery_enabled_ && global_mqtt_client->is_discovery_enabled(); } diff --git a/esphome/components/mqtt/mqtt_component.h b/esphome/components/mqtt/mqtt_component.h index 7983e04870..b4ae624404 100644 --- a/esphome/components/mqtt/mqtt_component.h +++ b/esphome/components/mqtt/mqtt_component.h @@ -108,11 +108,11 @@ class MQTTComponent : public Component { /// Set QOS for state messages. void set_qos(uint8_t qos); - uint8_t get_qos() const; + uint8_t get_qos() const { return this->qos_; } /// Set whether state message should be retained. void set_retain(bool retain); - bool get_retain() const; + bool get_retain() const { return this->retain_; } /// Disable discovery. Sets friendly name to "". void disable_discovery(); diff --git a/esphome/components/mqtt/mqtt_sensor.cpp b/esphome/components/mqtt/mqtt_sensor.cpp index c66465dd16..1c0625d1c9 100644 --- a/esphome/components/mqtt/mqtt_sensor.cpp +++ b/esphome/components/mqtt/mqtt_sensor.cpp @@ -39,8 +39,6 @@ uint32_t MQTTSensorComponent::get_expire_after() const { return *this->expire_after_; return 0; } -void MQTTSensorComponent::set_expire_after(uint32_t expire_after) { this->expire_after_ = expire_after; } -void MQTTSensorComponent::disable_expire_after() { this->expire_after_ = 0; } void MQTTSensorComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) { // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson diff --git a/esphome/components/mqtt/mqtt_sensor.h b/esphome/components/mqtt/mqtt_sensor.h index 1d5ee8095c..a56963d9c1 100644 --- a/esphome/components/mqtt/mqtt_sensor.h +++ b/esphome/components/mqtt/mqtt_sensor.h @@ -22,9 +22,9 @@ class MQTTSensorComponent final : public mqtt::MQTTComponent { explicit MQTTSensorComponent(sensor::Sensor *sensor); /// Setup an expiry, 0 disables it - void set_expire_after(uint32_t expire_after); + void set_expire_after(uint32_t expire_after) { this->expire_after_ = expire_after; } /// Disable Home Assistant value expiry. - void disable_expire_after(); + void disable_expire_after() { this->expire_after_ = 0; } void send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) override; From a30238aab6de17a67d1624291db47e325935ac79 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:05:51 -0500 Subject: [PATCH 1656/1815] [wireguard] Inline the trivial Wireguard setters (#18638) --- esphome/components/wireguard/wireguard.cpp | 21 --------------------- esphome/components/wireguard/wireguard.h | 18 +++++++++--------- 2 files changed, 9 insertions(+), 30 deletions(-) diff --git a/esphome/components/wireguard/wireguard.cpp b/esphome/components/wireguard/wireguard.cpp index b4641894db..2f07344d3b 100644 --- a/esphome/components/wireguard/wireguard.cpp +++ b/esphome/components/wireguard/wireguard.cpp @@ -178,25 +178,6 @@ time_t Wireguard::get_latest_handshake() const { return result; } -void Wireguard::set_keepalive(const uint16_t seconds) { this->keepalive_ = seconds; } -void Wireguard::set_reboot_timeout(const uint32_t seconds) { this->reboot_timeout_ = seconds; } -void Wireguard::set_srctime(time::RealTimeClock *srctime) { this->srctime_ = srctime; } - -#ifdef USE_BINARY_SENSOR -void Wireguard::set_status_sensor(binary_sensor::BinarySensor *sensor) { this->status_sensor_ = sensor; } -void Wireguard::set_enabled_sensor(binary_sensor::BinarySensor *sensor) { this->enabled_sensor_ = sensor; } -#endif - -#ifdef USE_SENSOR -void Wireguard::set_handshake_sensor(sensor::Sensor *sensor) { this->handshake_sensor_ = sensor; } -#endif - -#ifdef USE_TEXT_SENSOR -void Wireguard::set_address_sensor(text_sensor::TextSensor *sensor) { this->address_sensor_ = sensor; } -#endif - -void Wireguard::disable_auto_proceed() { this->proceed_allowed_ = false; } - void Wireguard::enable() { this->enabled_ = true; ESP_LOGI(TAG, "Enabled"); @@ -218,8 +199,6 @@ void Wireguard::publish_enabled_state() { #endif } -bool Wireguard::is_enabled() { return this->enabled_; } - void Wireguard::start_connection_() { if (!this->enabled_) { ESP_LOGV(TAG, "Disabled, cannot start connection"); diff --git a/esphome/components/wireguard/wireguard.h b/esphome/components/wireguard/wireguard.h index 1fda802415..c9c2feb7ae 100644 --- a/esphome/components/wireguard/wireguard.h +++ b/esphome/components/wireguard/wireguard.h @@ -63,25 +63,25 @@ class Wireguard final : public PollingComponent { /// Prevent accidental use of std::string which would dangle void set_allowed_ips(std::initializer_list> ips) = delete; - void set_keepalive(uint16_t seconds); - void set_reboot_timeout(uint32_t seconds); - void set_srctime(time::RealTimeClock *srctime); + void set_keepalive(const uint16_t seconds) { this->keepalive_ = seconds; } + void set_reboot_timeout(const uint32_t seconds) { this->reboot_timeout_ = seconds; } + void set_srctime(time::RealTimeClock *srctime) { this->srctime_ = srctime; } #ifdef USE_BINARY_SENSOR - void set_status_sensor(binary_sensor::BinarySensor *sensor); - void set_enabled_sensor(binary_sensor::BinarySensor *sensor); + void set_status_sensor(binary_sensor::BinarySensor *sensor) { this->status_sensor_ = sensor; } + void set_enabled_sensor(binary_sensor::BinarySensor *sensor) { this->enabled_sensor_ = sensor; } #endif #ifdef USE_SENSOR - void set_handshake_sensor(sensor::Sensor *sensor); + void set_handshake_sensor(sensor::Sensor *sensor) { this->handshake_sensor_ = sensor; } #endif #ifdef USE_TEXT_SENSOR - void set_address_sensor(text_sensor::TextSensor *sensor); + void set_address_sensor(text_sensor::TextSensor *sensor) { this->address_sensor_ = sensor; } #endif /// Block the setup step until peer is connected. - void disable_auto_proceed(); + void disable_auto_proceed() { this->proceed_allowed_ = false; } /// Enable the WireGuard component. void enable(); @@ -93,7 +93,7 @@ class Wireguard final : public PollingComponent { void publish_enabled_state(); /// Return if the WireGuard component is or is not enabled. - bool is_enabled(); + bool is_enabled() { return this->enabled_; } bool is_peer_up() const; time_t get_latest_handshake() const; From b83ce91528c4c99043e0dee42b0e28ce24375c0f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:06:03 -0500 Subject: [PATCH 1657/1815] [display] Inline the trivial DisplayPage setters and page navigation helpers (#18639) --- esphome/components/display/display.cpp | 6 ------ esphome/components/display/display.h | 9 ++++++--- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index 115adf503a..c2d45dbb60 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -685,9 +685,6 @@ void Display::show_page(DisplayPage *page) { } } -void Display::show_next_page() { this->page_->show_next(); } -void Display::show_prev_page() { this->page_->show_prev(); } - void Display::do_update_() { if (this->auto_clear_enabled_) { this->clear(); @@ -892,9 +889,6 @@ void DisplayPage::show_prev() { this->prev_->show(); } -void DisplayPage::set_parent(Display *parent) { this->parent_ = parent; } -void DisplayPage::set_prev(DisplayPage *prev) { this->prev_ = prev; } -void DisplayPage::set_next(DisplayPage *next) { this->next_ = next; } const display_writer_t &DisplayPage::get_writer() const { return this->writer_; } const LogString *text_align_to_string(TextAlign textalign) { diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index 3a136937f6..a9ffda422d 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -802,9 +802,9 @@ class DisplayPage final { void show(); void show_next(); void show_prev(); - void set_parent(Display *parent); - void set_prev(DisplayPage *prev); - void set_next(DisplayPage *next); + void set_parent(Display *parent) { this->parent_ = parent; } + void set_prev(DisplayPage *prev) { this->prev_ = prev; } + void set_next(DisplayPage *next) { this->next_ = next; } const display_writer_t &get_writer() const; protected: @@ -814,6 +814,9 @@ class DisplayPage final { DisplayPage *next_{nullptr}; }; +inline void Display::show_next_page() { this->page_->show_next(); } +inline void Display::show_prev_page() { this->page_->show_prev(); } + template class DisplayPageShowAction final : public Action { public: TEMPLATABLE_VALUE(DisplayPage *, page) From a63c3bc0c7f9eab2b08def454b89223480fcf9ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:06:31 -0500 Subject: [PATCH 1658/1815] [valve] Inline the trivial Valve and ValveCall accessors (#18641) --- esphome/components/valve/valve.cpp | 7 ------- esphome/components/valve/valve.h | 8 ++++---- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/esphome/components/valve/valve.cpp b/esphome/components/valve/valve.cpp index 8fccd1e6d6..d8fb18b1b7 100644 --- a/esphome/components/valve/valve.cpp +++ b/esphome/components/valve/valve.cpp @@ -120,10 +120,6 @@ ValveCall &ValveCall::set_stop(bool stop) { this->stop_ = stop; return *this; } -bool ValveCall::get_stop() const { return this->stop_; } - -ValveCall Valve::make_call() { return {this}; } - void Valve::publish_state(bool save) { this->position = clamp(this->position, 0.0f, 1.0f); @@ -162,9 +158,6 @@ optional Valve::restore_state_() { return recovered; } -bool Valve::is_fully_open() const { return this->position == VALVE_OPEN; } -bool Valve::is_fully_closed() const { return this->position == VALVE_CLOSED; } - ValveCall ValveRestoreState::to_call(Valve *valve) { auto call = valve->make_call(); call.set_position(this->position); diff --git a/esphome/components/valve/valve.h b/esphome/components/valve/valve.h index c6cdf07096..183680e5e4 100644 --- a/esphome/components/valve/valve.h +++ b/esphome/components/valve/valve.h @@ -47,7 +47,7 @@ class ValveCall { void perform(); const optional &get_position() const; - bool get_stop() const; + bool get_stop() const { return this->stop_; } const optional &get_toggle() const; protected: @@ -114,7 +114,7 @@ class Valve : public EntityBase { float position; /// Construct a new valve call used to control the valve. - ValveCall make_call(); + ValveCall make_call() { return {this}; } template void add_on_state_callback(F &&f) { this->state_callback_.add(std::forward(f)); } @@ -130,9 +130,9 @@ class Valve : public EntityBase { virtual ValveTraits get_traits() = 0; /// Helper method to check if the valve is fully open. Equivalent to comparing .position against 1.0 - bool is_fully_open() const; + bool is_fully_open() const { return this->position == VALVE_OPEN; } /// Helper method to check if the valve is fully closed. Equivalent to comparing .position against 0.0 - bool is_fully_closed() const; + bool is_fully_closed() const { return this->position == VALVE_CLOSED; } protected: friend ValveCall; From 435d5226838d8f2818d637f1a284f4f85c214295 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:06:47 -0500 Subject: [PATCH 1659/1815] [text] Inline the trivial Text publish_state forwarding overloads (#18642) --- esphome/components/text/text.cpp | 4 ---- esphome/components/text/text.h | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/esphome/components/text/text.cpp b/esphome/components/text/text.cpp index 032ea468e6..a1df6286c7 100644 --- a/esphome/components/text/text.cpp +++ b/esphome/components/text/text.cpp @@ -8,10 +8,6 @@ namespace esphome::text { static const char *const TAG = "text"; -void Text::publish_state(const std::string &state) { this->publish_state(state.data(), state.size()); } - -void Text::publish_state(const char *state) { this->publish_state(state, strlen(state)); } - void Text::publish_state(const char *state, size_t len) { this->set_has_state(true); // Only assign if changed to avoid heap allocation diff --git a/esphome/components/text/text.h b/esphome/components/text/text.h index eb6a68f998..54afb8db8f 100644 --- a/esphome/components/text/text.h +++ b/esphome/components/text/text.h @@ -23,8 +23,8 @@ class Text : public EntityBase { std::string state; TextTraits traits; - void publish_state(const std::string &state); - void publish_state(const char *state); + void publish_state(const std::string &state) { this->publish_state(state.data(), state.size()); } + void publish_state(const char *state) { this->publish_state(state, strlen(state)); } void publish_state(const char *state, size_t len); /// Instantiate a TextCall object to modify this text component's state. From 01ad424d12bc6c376e695084c078e9cb8d5c54fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:07:03 -0500 Subject: [PATCH 1660/1815] [datetime] Inline the trivial make_call helpers (#18643) --- esphome/components/datetime/date_entity.cpp | 2 -- esphome/components/datetime/date_entity.h | 2 ++ esphome/components/datetime/datetime_entity.cpp | 2 -- esphome/components/datetime/datetime_entity.h | 2 ++ esphome/components/datetime/time_entity.cpp | 2 -- esphome/components/datetime/time_entity.h | 2 ++ 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/components/datetime/date_entity.cpp b/esphome/components/datetime/date_entity.cpp index 997aec3f69..b99b89259f 100644 --- a/esphome/components/datetime/date_entity.cpp +++ b/esphome/components/datetime/date_entity.cpp @@ -37,8 +37,6 @@ void DateEntity::publish_state() { #endif } -DateCall DateEntity::make_call() { return DateCall(this); } - void DateCall::validate_() { if (this->year_.has_value() && (this->year_ < 1970 || this->year_ > 3000)) { ESP_LOGE(TAG, "Year must be between 1970 and 3000"); diff --git a/esphome/components/datetime/date_entity.h b/esphome/components/datetime/date_entity.h index 9b86c12228..93ce1411f8 100644 --- a/esphome/components/datetime/date_entity.h +++ b/esphome/components/datetime/date_entity.h @@ -96,6 +96,8 @@ class DateCall { optional day_; }; +inline DateCall DateEntity::make_call() { return DateCall(this); } + template class DateSetAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(ESPTime, date) diff --git a/esphome/components/datetime/datetime_entity.cpp b/esphome/components/datetime/datetime_entity.cpp index a8e00d6eb3..8f180fd081 100644 --- a/esphome/components/datetime/datetime_entity.cpp +++ b/esphome/components/datetime/datetime_entity.cpp @@ -53,8 +53,6 @@ void DateTimeEntity::publish_state() { #endif } -DateTimeCall DateTimeEntity::make_call() { return DateTimeCall(this); } - ESPTime DateTimeEntity::state_as_esptime() const { ESPTime obj; obj.year = this->year_; diff --git a/esphome/components/datetime/datetime_entity.h b/esphome/components/datetime/datetime_entity.h index 159e4ccc6f..fec620b5ba 100644 --- a/esphome/components/datetime/datetime_entity.h +++ b/esphome/components/datetime/datetime_entity.h @@ -121,6 +121,8 @@ class DateTimeCall { optional second_; }; +inline DateTimeCall DateTimeEntity::make_call() { return DateTimeCall(this); } + template class DateTimeSetAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(ESPTime, datetime) diff --git a/esphome/components/datetime/time_entity.cpp b/esphome/components/datetime/time_entity.cpp index 1cc9eaf2fb..da4c9eb31e 100644 --- a/esphome/components/datetime/time_entity.cpp +++ b/esphome/components/datetime/time_entity.cpp @@ -33,8 +33,6 @@ void TimeEntity::publish_state() { #endif } -TimeCall TimeEntity::make_call() { return TimeCall(this); } - void TimeCall::validate_() { if (this->hour_.has_value() && this->hour_ > 23) { ESP_LOGE(TAG, "Hour must be between 0 and 23"); diff --git a/esphome/components/datetime/time_entity.h b/esphome/components/datetime/time_entity.h index 643f4bd176..736e26f4a7 100644 --- a/esphome/components/datetime/time_entity.h +++ b/esphome/components/datetime/time_entity.h @@ -98,6 +98,8 @@ class TimeCall { optional second_; }; +inline TimeCall TimeEntity::make_call() { return TimeCall(this); } + template class TimeSetAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(ESPTime, time) From f24b731f9510815fe164e975b7e4a4f4605cd45e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:07:14 -0500 Subject: [PATCH 1661/1815] [infrared] Inline the trivial make_call helper (#18644) --- esphome/components/infrared/infrared.cpp | 2 -- esphome/components/infrared/infrared.h | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/infrared/infrared.cpp b/esphome/components/infrared/infrared.cpp index 9b97995a96..5a909738c6 100644 --- a/esphome/components/infrared/infrared.cpp +++ b/esphome/components/infrared/infrared.cpp @@ -75,8 +75,6 @@ void Infrared::dump_config() { YESNO(this->traits_.get_supports_receiver())); } -InfraredCall Infrared::make_call() { return InfraredCall(this); } - void Infrared::control(const InfraredCall &call) { if (this->transmitter_ == nullptr) { ESP_LOGW(TAG, "No transmitter configured"); diff --git a/esphome/components/infrared/infrared.h b/esphome/components/infrared/infrared.h index 6d91c97cce..b6863e37ce 100644 --- a/esphome/components/infrared/infrared.h +++ b/esphome/components/infrared/infrared.h @@ -134,7 +134,7 @@ class Infrared : public Component, public EntityBase, public remote_base::Remote const InfraredTraits &get_traits() const { return this->traits_; } /// Create a call object for transmitting - InfraredCall make_call(); + InfraredCall make_call() { return InfraredCall(this); } /// Get capability flags for this infrared instance uint32_t get_capability_flags() const; From f0651e5c9b2ae24c33256819dd78eedce73c45a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:07:34 -0500 Subject: [PATCH 1662/1815] [radio_frequency] Inline the trivial make_call helper (#18645) --- esphome/components/radio_frequency/radio_frequency.cpp | 2 -- esphome/components/radio_frequency/radio_frequency.h | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/radio_frequency/radio_frequency.cpp b/esphome/components/radio_frequency/radio_frequency.cpp index 3e0a905737..61e7feb9af 100644 --- a/esphome/components/radio_frequency/radio_frequency.cpp +++ b/esphome/components/radio_frequency/radio_frequency.cpp @@ -81,8 +81,6 @@ void RadioFrequency::dump_config() { } } -RadioFrequencyCall RadioFrequency::make_call() { return RadioFrequencyCall(this); } - uint32_t RadioFrequency::get_capability_flags() const { uint32_t flags = 0; if (this->traits_.get_supports_transmitter()) diff --git a/esphome/components/radio_frequency/radio_frequency.h b/esphome/components/radio_frequency/radio_frequency.h index 7dfd2dd77e..8782c255f0 100644 --- a/esphome/components/radio_frequency/radio_frequency.h +++ b/esphome/components/radio_frequency/radio_frequency.h @@ -157,7 +157,7 @@ class RadioFrequency : public Component, public EntityBase, public remote_base:: const RadioFrequencyTraits &get_traits() const { return this->traits_; } /// Create a call object for transmitting - RadioFrequencyCall make_call(); + RadioFrequencyCall make_call() { return RadioFrequencyCall(this); } /// Get capability flags for this radio frequency instance uint32_t get_capability_flags() const; From cd536817876caff90c750fa9e17b2bdbb181034f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:51:39 -0500 Subject: [PATCH 1663/1815] [wifi] Inline the remaining trivial WiFiAP and WiFiComponent accessors (#18617) --- esphome/components/wifi/wifi_component.cpp | 38 ------------------ esphome/components/wifi/wifi_component.h | 46 +++++++++++----------- 2 files changed, 24 insertions(+), 60 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 5ed5fc9094..b8a31f97a3 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -618,8 +618,6 @@ static const char *eap_phase2_to_str(esp_eap_ttls_phase2_types type) { } #endif -float WiFiComponent::get_setup_priority() const { return setup_priority::WIFI; } - void WiFiComponent::setup() { this->wifi_pre_setup_(); @@ -931,10 +929,6 @@ void WiFiComponent::loop() { WiFiComponent::WiFiComponent() { global_wifi_component = this; } -#ifdef USE_WIFI_11KV_SUPPORT -void WiFiComponent::set_btm(bool btm) { this->btm_ = btm; } -void WiFiComponent::set_rrm(bool rrm) { this->rrm_ = rrm; } -#endif network::IPAddresses WiFiComponent::get_ip_addresses() { if (this->has_sta()) return this->wifi_sta_ip_addresses(); @@ -1327,8 +1321,6 @@ void WiFiComponent::disable() { this->wifi_mode_(false, false); } -bool WiFiComponent::is_disabled() { return this->state_ == WIFI_COMPONENT_STATE_DISABLED; } - void WiFiComponent::start_scanning() { this->action_started_ = millis(); ESP_LOGD(TAG, "Starting scan"); @@ -2196,7 +2188,6 @@ void WiFiComponent::retry_connect() { } } -void WiFiComponent::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } void WiFiComponent::set_power_save_mode(WiFiPowerSaveMode power_save) { this->power_save_ = power_save; #if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) @@ -2204,8 +2195,6 @@ void WiFiComponent::set_power_save_mode(WiFiPowerSaveMode power_save) { #endif } -void WiFiComponent::set_passive_scan(bool passive) { this->passive_scan_ = passive; } - bool WiFiComponent::is_captive_portal_active_() { #ifdef USE_CAPTIVE_PORTAL return captive_portal::global_captive_portal != nullptr && captive_portal::global_captive_portal->is_active(); @@ -2324,33 +2313,6 @@ void WiFiComponent::save_fast_connect_settings_(const bssid_t &bssid, uint8_t ch } #endif -void WiFiAP::set_ssid(const std::string &ssid) { this->ssid_ = CompactString(ssid.c_str(), ssid.size()); } -void WiFiAP::set_ssid(const char *ssid) { this->ssid_ = CompactString(ssid, strlen(ssid)); } -void WiFiAP::set_bssid(const bssid_t &bssid) { this->bssid_ = bssid; } -void WiFiAP::clear_bssid() { this->bssid_ = {}; } -void WiFiAP::set_password(const std::string &password) { - this->password_ = CompactString(password.c_str(), password.size()); -} -void WiFiAP::set_password(const char *password) { this->password_ = CompactString(password, strlen(password)); } -#ifdef USE_WIFI_WPA2_EAP -void WiFiAP::set_eap(optional eap_auth) { this->eap_ = std::move(eap_auth); } -#endif -void WiFiAP::set_channel(uint8_t channel) { this->channel_ = channel; } -void WiFiAP::clear_channel() { this->channel_ = 0; } -#ifdef USE_WIFI_MANUAL_IP -void WiFiAP::set_manual_ip(optional manual_ip) { this->manual_ip_ = manual_ip; } -#endif -void WiFiAP::set_hidden(bool hidden) { this->hidden_ = hidden; } -const bssid_t &WiFiAP::get_bssid() const { return this->bssid_; } -bool WiFiAP::has_bssid() const { return this->bssid_ != bssid_t{}; } -#ifdef USE_WIFI_WPA2_EAP -const optional &WiFiAP::get_eap() const { return this->eap_; } -#endif -#ifdef USE_WIFI_MANUAL_IP -const optional &WiFiAP::get_manual_ip() const { return this->manual_ip_; } -#endif -bool WiFiAP::get_hidden() const { return this->hidden_; } - WiFiScanResult::WiFiScanResult(const bssid_t &bssid, const char *ssid, size_t ssid_len, uint8_t channel, int8_t rssi, bool with_auth, bool is_hidden) : bssid_(bssid), diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index c54fbc004b..07d4ff23c6 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -21,6 +21,7 @@ #include #include #include +#include #include #ifdef USE_LIBRETINY @@ -261,38 +262,38 @@ class WiFiAP { friend class WiFiScanResult; public: - void set_ssid(const std::string &ssid); - void set_ssid(const char *ssid); + void set_ssid(const std::string &ssid) { this->ssid_ = CompactString(ssid.c_str(), ssid.size()); } + void set_ssid(const char *ssid) { this->set_ssid(StringRef(ssid)); } void set_ssid(StringRef ssid) { this->ssid_ = CompactString(ssid.c_str(), ssid.size()); } - void set_bssid(const bssid_t &bssid); - void clear_bssid(); - void set_password(const std::string &password); - void set_password(const char *password); + void set_bssid(const bssid_t &bssid) { this->bssid_ = bssid; } + void clear_bssid() { this->bssid_ = {}; } + void set_password(const std::string &password) { this->password_ = CompactString(password.c_str(), password.size()); } + void set_password(const char *password) { this->set_password(StringRef(password)); } void set_password(StringRef password) { this->password_ = CompactString(password.c_str(), password.size()); } #ifdef USE_WIFI_WPA2_EAP - void set_eap(optional eap_auth); + void set_eap(optional eap_auth) { this->eap_ = std::move(eap_auth); } #endif // USE_WIFI_WPA2_EAP - void set_channel(uint8_t channel); - void clear_channel(); + void set_channel(uint8_t channel) { this->channel_ = channel; } + void clear_channel() { this->channel_ = 0; } void set_priority(int8_t priority) { priority_ = priority; } #ifdef USE_WIFI_MANUAL_IP - void set_manual_ip(optional manual_ip); + void set_manual_ip(optional manual_ip) { this->manual_ip_ = manual_ip; } #endif - void set_hidden(bool hidden); + void set_hidden(bool hidden) { this->hidden_ = hidden; } StringRef get_ssid() const { return this->ssid_.ref(); } StringRef get_password() const { return this->password_.ref(); } - const bssid_t &get_bssid() const; - bool has_bssid() const; + const bssid_t &get_bssid() const { return this->bssid_; } + bool has_bssid() const { return this->bssid_ != bssid_t{}; } #ifdef USE_WIFI_WPA2_EAP - const optional &get_eap() const; + const optional &get_eap() const { return this->eap_; } #endif // USE_WIFI_WPA2_EAP uint8_t get_channel() const { return this->channel_; } bool has_channel() const { return this->channel_ != 0; } int8_t get_priority() const { return priority_; } #ifdef USE_WIFI_MANUAL_IP - const optional &get_manual_ip() const; + const optional &get_manual_ip() const { return this->manual_ip_; } #endif - bool get_hidden() const; + bool get_hidden() const { return this->hidden_; } protected: CompactString ssid_; @@ -442,6 +443,7 @@ class WiFiComponent final : public Component { void set_sta(const WiFiAP &ap); // Returns a copy of the currently selected AP configuration WiFiAP get_sta() const; + // init_sta/add_sta kept out of line: inlining them into the generated setup() grows flash void init_sta(size_t count); void add_sta(const WiFiAP &ap); void clear_sta(); @@ -461,7 +463,7 @@ class WiFiComponent final : public Component { void enable(); void disable(); - bool is_disabled(); + bool is_disabled() { return this->state_ == WIFI_COMPONENT_STATE_DISABLED; } void start_scanning(); void check_scanning_finished(); void start_connecting(const WiFiAP &ap); @@ -472,7 +474,7 @@ class WiFiComponent final : public Component { void retry_connect(); - void set_reboot_timeout(uint32_t reboot_timeout); + void set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } bool is_connected() const { return this->connected_; } @@ -492,7 +494,7 @@ class WiFiComponent final : public Component { void set_phy_mode(WiFi8266PhyMode phy_mode) { this->phy_mode_ = phy_mode; } #endif - void set_passive_scan(bool passive); + void set_passive_scan(bool passive) { this->passive_scan_ = passive; } void save_wifi_sta(const std::string &ssid, const std::string &password); void save_wifi_sta(const char *ssid, const char *password); @@ -506,7 +508,7 @@ class WiFiComponent final : public Component { void dump_config() override; void restart_adapter(); /// WIFI setup_priority. - float get_setup_priority() const override; + float get_setup_priority() const override { return setup_priority::WIFI; } /// Reconnect WiFi if required. void loop() override; @@ -515,8 +517,8 @@ class WiFiComponent final : public Component { bool is_ap_active() const { return this->ap_started_; } #ifdef USE_WIFI_11KV_SUPPORT - void set_btm(bool btm); - void set_rrm(bool rrm); + void set_btm(bool btm) { this->btm_ = btm; } + void set_rrm(bool rrm) { this->rrm_ = rrm; } #endif network::IPAddress get_dns_address(int num); From 5b3a6c05bf40a5776da603d422495e345363da3b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:59:47 -0500 Subject: [PATCH 1664/1815] [core] Remove deprecated esp_log_vprintf_ flash-string overload (#18377) --- esphome/core/log.cpp | 10 ---------- esphome/core/log.h | 5 ----- 2 files changed, 15 deletions(-) diff --git a/esphome/core/log.cpp b/esphome/core/log.cpp index 0da457adec..9fcddfeff6 100644 --- a/esphome/core/log.cpp +++ b/esphome/core/log.cpp @@ -60,16 +60,6 @@ void HOT esp_log_vprintf_(int level, const char *tag, int line, const char *form #endif } -#ifdef USE_STORE_LOG_STR_IN_FLASH -// Remove before 2026.9.0 -void HOT esp_log_vprintf_(int level, const char *tag, int line, const __FlashStringHelper *format, va_list args) { -#ifdef USE_LOGGER - ESPHOME_DEBUG_ASSERT(logger::global_logger != nullptr); - logger::global_logger->log_vprintf_(static_cast(level), tag, line, format, args); -#endif -} -#endif - #ifdef USE_ESP32 int HOT esp_idf_log_vprintf_(const char *format, va_list args) { // NOLINT #ifdef USE_LOGGER diff --git a/esphome/core/log.h b/esphome/core/log.h index 72e06cabac..272e516808 100644 --- a/esphome/core/log.h +++ b/esphome/core/log.h @@ -68,11 +68,6 @@ void esp_log_printf_(int level, const char *tag, int line, const char *format, . void esp_log_printf_(int level, const char *tag, int line, const __FlashStringHelper *format, ...); #endif void esp_log_vprintf_(int level, const char *tag, int line, const char *format, va_list args); // NOLINT -#ifdef USE_STORE_LOG_STR_IN_FLASH -// Remove before 2026.9.0 -__attribute__((deprecated("Use esp_log_printf_() instead. Removed in 2026.9.0."))) void esp_log_vprintf_( - int level, const char *tag, int line, const __FlashStringHelper *format, va_list args); -#endif #if defined(USE_ESP32) int esp_idf_log_vprintf_(const char *format, va_list args); // NOLINT #endif From ab45ab316a0190cf898e436a345e2a20a5f15c42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 22:00:02 -0500 Subject: [PATCH 1665/1815] [core] Remove deprecated entity_base getters (#18375) --- esphome/core/entity_base.cpp | 40 ------------------------------- esphome/core/entity_base.h | 46 ------------------------------------ 2 files changed, 86 deletions(-) diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index fc6ac503b5..21a5fc3706 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -80,24 +80,6 @@ const char *EntityBase::get_device_class_to([[maybe_unused]] std::spandevice_class_idx_)); -#else - return StringRef(entity_device_class_lookup(0)); -#endif -} -std::string EntityBase::get_device_class() const { -#ifdef USE_ENTITY_DEVICE_CLASS - return std::string(entity_device_class_lookup(this->device_class_idx_)); -#else - return std::string(entity_device_class_lookup(0)); -#endif -} -#endif // !USE_ESP8266 - // Entity unit of measurement (from index) StringRef EntityBase::get_unit_of_measurement_ref() const { #ifdef USE_ENTITY_UNIT_OF_MEASUREMENT @@ -106,10 +88,6 @@ StringRef EntityBase::get_unit_of_measurement_ref() const { return StringRef(entity_uom_lookup(0)); #endif } -std::string EntityBase::get_unit_of_measurement() const { - return std::string(this->get_unit_of_measurement_ref().c_str()); -} - // Entity icon — buffer-based API for PROGMEM safety on ESP8266 const char *EntityBase::get_icon_to([[maybe_unused]] std::span buffer) const { #ifdef USE_ENTITY_ICON @@ -129,24 +107,6 @@ const char *EntityBase::get_icon_to([[maybe_unused]] std::spanicon_idx_)); -#else - return StringRef(entity_icon_lookup(0)); -#endif -} -std::string EntityBase::get_icon() const { -#ifdef USE_ENTITY_ICON - return std::string(entity_icon_lookup(this->icon_idx_)); -#else - return std::string(entity_icon_lookup(0)); -#endif -} -#endif // !USE_ESP8266 - // Calculate Object ID Hash directly from name using snake_case + sanitize void EntityBase::calc_object_id_() { this->object_id_hash_ = fnv1_hash_object_id(this->name_.c_str(), this->name_.size()); diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 5f2e173d8d..f38e30bf52 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -109,60 +109,14 @@ class EntityBase { // On ESP8266: copies from PROGMEM to buffer, returns buffer pointer. const char *get_device_class_to(std::span buffer) const; -#ifdef USE_ESP8266 - // On ESP8266, rodata is RAM. Device classes are in PROGMEM and cannot be accessed - // directly as const char*. Use get_device_class_to() with a stack buffer instead. - template StringRef get_device_class_ref() const { - static_assert(sizeof(T) == 0, "get_device_class_ref() unavailable on ESP8266 (rodata is RAM). " - "Use get_device_class_to() with a stack buffer."); - return StringRef(""); - } - template std::string get_device_class() const { - static_assert(sizeof(T) == 0, "get_device_class() unavailable on ESP8266 (rodata is RAM). " - "Use get_device_class_to() with a stack buffer."); - return ""; - } -#else - // Deprecated: use get_device_class_to() instead. Device classes are in PROGMEM. - ESPDEPRECATED("Use get_device_class_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0") - StringRef get_device_class_ref() const; - ESPDEPRECATED("Use get_device_class_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0") - std::string get_device_class() const; -#endif // Get unit of measurement as StringRef (from packed index) StringRef get_unit_of_measurement_ref() const; - /// Get the unit of measurement as std::string (deprecated, prefer get_unit_of_measurement_ref()) - ESPDEPRECATED("Use get_unit_of_measurement_ref() instead for better performance (avoids string copy). Will be " - "removed in ESPHome 2026.9.0", - "2026.3.0") - std::string get_unit_of_measurement() const; // Get this entity's icon into a stack buffer. // On ESP32: returns pointer to PROGMEM string directly (buffer unused). // On ESP8266: copies from PROGMEM to buffer, returns buffer pointer. const char *get_icon_to(std::span buffer) const; -#ifdef USE_ESP8266 - // On ESP8266, rodata is RAM. Icons are in PROGMEM and cannot be accessed - // directly as const char*. Use get_icon_to() with a stack buffer instead. - template StringRef get_icon_ref() const { - static_assert(sizeof(T) == 0, - "get_icon_ref() unavailable on ESP8266 (rodata is RAM). Use get_icon_to() with a stack buffer."); - return StringRef(""); - } - template std::string get_icon() const { - static_assert(sizeof(T) == 0, - "get_icon() unavailable on ESP8266 (rodata is RAM). Use get_icon_to() with a stack buffer."); - return ""; - } -#else - // Deprecated: use get_icon_to() instead. Icons are in PROGMEM. - ESPDEPRECATED("Use get_icon_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0") - StringRef get_icon_ref() const; - ESPDEPRECATED("Use get_icon_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0") - std::string get_icon() const; -#endif - #ifdef USE_DEVICES // Get this entity's device id uint32_t get_device_id() const { From b115813fbe2880a3e7ffbc2e4725e208c589d97b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 22:00:17 -0500 Subject: [PATCH 1666/1815] [esp32] Report abort and task watchdog panics correctly in crash handler (#18575) --- esphome/components/esp32/crash_handler.cpp | 61 +++++++++++++++++++--- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index b61dad7386..6f65243aaa 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -124,6 +124,15 @@ static uint8_t IRAM_ATTR capture_riscv_backtrace(RvExcFrame *frame, uint32_t *ou // Version is uint32_t because it would be padded to 4 bytes anyway before the next // uint32_t field, so we use the full width rather than wasting 3 bytes of padding. static constexpr uint32_t CRASH_DATA_VERSION = 4; +#if CONFIG_IDF_TARGET_ARCH_XTENSA +// EXCCAUSE is a 6-bit register; larger recorded values mean the frame's +// cause/vaddr slots were never written (not a real exception frame). +static constexpr uint32_t XTENSA_EXCCAUSE_COUNT = XCHAL_EXCCAUSE_NUM; +#elif CONFIG_IDF_TARGET_ARCH_RISCV +// Synchronous mcause exception codes are small and have no interrupt bit; +// anything else in a non-pseudo record is a stale slot. +static constexpr uint32_t RISCV_EXCEPTION_CAUSE_COUNT = 32; +#endif struct RawCrashData { uint32_t version; uint32_t magic; @@ -198,10 +207,28 @@ void crash_handler_clear() { s_raw_crash_data.magic = 0; } +// Whether the cause slot was written by a real exception frame. +static bool cause_slot_was_written() { +#if CONFIG_IDF_TARGET_ARCH_XTENSA + return s_raw_crash_data.cause < XTENSA_EXCCAUSE_COUNT; +#else + return s_raw_crash_data.cause < RISCV_EXCEPTION_CAUSE_COUNT; +#endif +} + // Look up the exception cause as a human-readable string. // Tables mirror ESP-IDF's panic_arch_fill_info() which uses local static arrays // not exposed via any public API. static const char *get_exception_reason() { + uint8_t exception = s_raw_crash_data.exception; + if (exception == PANIC_EXCEPTION_ABORT || exception == PANIC_EXCEPTION_TWDT) { + // Abort-class panics carry no cause register + return nullptr; + } + if (!cause_slot_was_written()) { + // Garbage from old-build or corrupt records; report just the type + return nullptr; + } #if CONFIG_IDF_TARGET_ARCH_XTENSA if (s_raw_crash_data.pseudo_excause) { // SoC-level panic: watchdog, cache error, etc. @@ -354,10 +381,11 @@ static const char *const FAULT_ADDR_REG = "MTVAL"; static const char *const FAULT_ADDR_REG_LOWER = "mtval"; #endif -// Whether the fault address is meaningful — real CPU faults only, not -// aborts/watchdogs or SoC-level pseudo exceptions. +// Whether the fault address is meaningful: real CPU faults with a validly +// written frame only. static bool has_fault_addr() { - return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause; + return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause && + cause_slot_was_written(); } // The record was captured by a different firmware build (it survives soft @@ -458,6 +486,10 @@ void crash_handler_log() { // into NOINIT memory before the normal panic handler runs. // extern "C" { +// Set by IDF's task watchdog (task_wdt.c, no header) before it simulates an +// abort; weak so builds without the task watchdog still link. +extern bool g_twdt_isr __attribute__((weak)); + // NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) // Names are mandated by the --wrap linker mechanism extern void __real_esp_panic_handler(panic_info_t *info); @@ -470,6 +502,14 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { s_raw_crash_data.exception = (uint8_t) info->exception; s_raw_crash_data.pseudo_excause = info->pseudo_excause ? 1 : 0; s_raw_crash_data.crashed_core = (uint8_t) info->core; + if (g_panic_abort) { + // IDF reclassifies to ABORT only inside esp_panic_handler(), after this + // wrapper captured info->exception; correct it here. TWDT is our own + // distinction (IDF never assigns PANIC_EXCEPTION_TWDT). The abort text is + // not stored; the symbolized backtrace already identifies the site. + bool is_twdt = &g_twdt_isr != nullptr && g_twdt_isr; + s_raw_crash_data.exception = (uint8_t) (is_twdt ? PANIC_EXCEPTION_TWDT : PANIC_EXCEPTION_ABORT); + } // Zero unconditionally so a null frame doesn't leave stale .noinit data from a previous boot s_raw_crash_data.cause = 0; s_raw_crash_data.fault_addr = 0; @@ -487,8 +527,12 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { // Xtensa: walk the backtrace using the public API if (info->frame != nullptr) { auto *xt_frame = (XtExcFrame *) info->frame; - s_raw_crash_data.cause = xt_frame->exccause; - s_raw_crash_data.fault_addr = xt_frame->excvaddr; + if (!g_panic_abort) { + // Abort-class frames carry no useful cause/vaddr: TWDT task snapshots + // never wrote them and abort() traps describe only the synthetic trap. + s_raw_crash_data.cause = xt_frame->exccause; + s_raw_crash_data.fault_addr = xt_frame->excvaddr; + } s_raw_crash_data.backtrace_count = walk_xtensa_backtrace(xt_frame, s_raw_crash_data.backtrace, MAX_BACKTRACE); } @@ -510,8 +554,11 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { // RISC-V: capture MEPC + RA, then scan stack for code addresses if (info->frame != nullptr) { auto *rv_frame = (RvExcFrame *) info->frame; - s_raw_crash_data.cause = rv_frame->mcause; - s_raw_crash_data.fault_addr = rv_frame->mtval; + if (!g_panic_abort) { + // See the Xtensa branch: abort-class frames carry no valid cause/vaddr. + s_raw_crash_data.cause = rv_frame->mcause; + s_raw_crash_data.fault_addr = rv_frame->mtval; + } s_raw_crash_data.backtrace_count = capture_riscv_backtrace(rv_frame, s_raw_crash_data.backtrace, MAX_BACKTRACE, &s_raw_crash_data.reg_frame_count); } From f3cdefce210b9b418da3487ff57c1421bb779137 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 22:00:38 -0500 Subject: [PATCH 1667/1815] [wifi] Remove deprecated wifi_ssid() (#18378) --- esphome/components/wifi/wifi_component.h | 3 --- esphome/components/wifi/wifi_component_esp8266.cpp | 10 ---------- esphome/components/wifi/wifi_component_esp_idf.cpp | 12 ------------ esphome/components/wifi/wifi_component_libretiny.cpp | 1 - esphome/components/wifi/wifi_component_pico_w.cpp | 1 - 5 files changed, 27 deletions(-) diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 07d4ff23c6..ada7be4ba4 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -552,9 +552,6 @@ class WiFiComponent final : public Component { void set_sta_priority(bssid_t bssid, int8_t priority); network::IPAddresses wifi_sta_ip_addresses(); - // Remove before 2026.9.0 - ESPDEPRECATED("Use wifi_ssid_to() instead. Removed in 2026.9.0", "2026.3.0") - std::string wifi_ssid(); /// Write SSID to buffer without heap allocation. /// Returns pointer to buffer, or empty string if not connected. const char *wifi_ssid_to(std::span buffer); diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index acaa94b13c..005d655d88 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -944,16 +944,6 @@ bssid_t WiFiComponent::wifi_bssid() { } return bssid; } -std::string WiFiComponent::wifi_ssid() { - struct station_config conf {}; - if (!wifi_station_get_config(&conf)) { - return ""; - } - // conf.ssid is uint8[32], not null-terminated if full - auto *ssid_s = reinterpret_cast(conf.ssid); - size_t len = strnlen(ssid_s, sizeof(conf.ssid)); - return {ssid_s, len}; -} const char *WiFiComponent::wifi_ssid_to(std::span buffer) { struct station_config conf {}; if (!wifi_station_get_config(&conf)) { diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 24cb060edb..32d46887b6 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -1237,18 +1237,6 @@ bssid_t WiFiComponent::wifi_bssid() { std::copy(info.bssid, info.bssid + 6, bssid.begin()); return bssid; } -std::string WiFiComponent::wifi_ssid() { - wifi_ap_record_t info{}; - esp_err_t err = esp_wifi_sta_get_ap_info(&info); - if (err != ESP_OK) { - // Very verbose only: this is expected during dump_config() before connection is established (PR #9823) - ESP_LOGVV(TAG, "esp_wifi_sta_get_ap_info failed: %s", esp_err_to_name(err)); - return ""; - } - auto *ssid_s = reinterpret_cast(info.ssid); - size_t len = strnlen(ssid_s, sizeof(info.ssid)); - return {ssid_s, len}; -} const char *WiFiComponent::wifi_ssid_to(std::span buffer) { wifi_ap_record_t info{}; esp_err_t err = esp_wifi_sta_get_ap_info(&info); diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 66c397a8ad..e3c08416e8 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -762,7 +762,6 @@ bssid_t WiFiComponent::wifi_bssid() { } return bssid; } -std::string WiFiComponent::wifi_ssid() { return WiFi.SSID().c_str(); } const char *WiFiComponent::wifi_ssid_to(std::span buffer) { #ifdef USE_BK72XX LinkStatusTypeDef link_status{}; diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 69af9e9a4e..325bcf2652 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -265,7 +265,6 @@ bssid_t WiFiComponent::wifi_bssid() { bssid[i] = raw_bssid[i]; return bssid; } -std::string WiFiComponent::wifi_ssid() { return WiFi.SSID().c_str(); } const char *WiFiComponent::wifi_ssid_to(std::span buffer) { // TODO: Find direct CYW43 API to avoid Arduino String allocation String ssid = WiFi.SSID(); From 8899713ef97229f881ab8694802ef03a9290c65a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 22:00:53 -0500 Subject: [PATCH 1668/1815] [core] Remove deprecated gamma_correct and gamma_uncorrect (#18376) --- esphome/core/helpers.cpp | 17 ----------------- esphome/core/helpers.h | 9 --------- 2 files changed, 26 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index a276020be4..ded8051df8 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -723,23 +723,6 @@ bool base64_decode_int32_vector(const std::string &base64, std::vector // Colors -float gamma_correct(float value, float gamma) { - if (value <= 0.0f) - return 0.0f; - if (gamma <= 0.0f) - return value; - - return powf(value, gamma); // NOLINT - deprecated, removal 2026.9.0 -} -float gamma_uncorrect(float value, float gamma) { - if (value <= 0.0f) - return 0.0f; - if (gamma <= 0.0f) - return value; - - return powf(value, 1 / gamma); // NOLINT - deprecated, removal 2026.9.0 -} - void rgb_to_hsv(float red, float green, float blue, int &hue, float &saturation, float &value) { float max_color_value = std::max({red, green, blue}); float min_color_value = std::min({red, green, blue}); diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 5a9c120b84..b13d92ccce 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1646,15 +1646,6 @@ bool base64_decode_int32_vector(const std::string &base64, std::vector /// @name Colors ///@{ -/// Applies gamma correction of \p gamma to \p value. -// Remove before 2026.9.0 -ESPDEPRECATED("Use LightState::gamma_correct_lut() instead. Removed in 2026.9.0.", "2026.3.0") -float gamma_correct(float value, float gamma); -/// Reverts gamma correction of \p gamma to \p value. -// Remove before 2026.9.0 -ESPDEPRECATED("Use LightState::gamma_uncorrect_lut() instead. Removed in 2026.9.0.", "2026.3.0") -float gamma_uncorrect(float value, float gamma); - /// Convert \p red, \p green and \p blue (all 0-1) values to \p hue (0-360), \p saturation (0-1) and \p value (0-1). void rgb_to_hsv(float red, float green, float blue, int &hue, float &saturation, float &value); /// Convert \p hue (0-360), \p saturation (0-1) and \p value (0-1) to \p red, \p green and \p blue (all 0-1). From 160d8b8f0ccdb5362daf2b144131fe5e35355991 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 22:02:05 -0500 Subject: [PATCH 1669/1815] [web_server_idf] Remove deprecated AsyncWebServerRequest::url() (#18382) --- esphome/components/web_server_idf/web_server_idf.h | 6 ------ 1 file changed, 6 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index baa55898bb..6469b4c564 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -117,12 +117,6 @@ class AsyncWebServerRequest { /// Write URL (without query string) to buffer, returns StringRef pointing to buffer. /// URL is decoded (e.g., %20 -> space). StringRef url_to(std::span buffer) const; - // Remove before 2026.9.0 - ESPDEPRECATED("Use url_to() instead. Removed in 2026.9.0", "2026.3.0") - std::string url() const { - char buffer[URL_BUF_SIZE]; - return std::string(this->url_to(buffer)); - } // NOLINTNEXTLINE(readability-identifier-naming) size_t contentLength() const { return this->req_->content_len; } From b2440cb655f794ec7a2d3e83f87fea0d99fc8ed8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 22:02:22 -0500 Subject: [PATCH 1670/1815] [modbus] Remove deprecated waiting_for_response() (#18381) --- esphome/components/modbus/modbus.h | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index bb303c43a8..e5cbba88ec 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -618,9 +618,6 @@ class ModbusClientDevice { inline void clear_tx_queue_for_address() { this->parent_->clear_tx_queue_for_address(this->address_); } inline void clear_tx_queue_for_device() { this->parent_->clear_tx_queue_for_device(this); } - // If more than one device is connected block sending a new command before a response is received - ESPDEPRECATED("Use ready_for_immediate_send() instead. Removed in 2026.9.0", "2026.3.0") - bool waiting_for_response() { return !this->ready_for_immediate_send(); } bool ready_for_immediate_send() { return this->parent_->tx_buffer_empty() && !this->parent_->tx_blocked(); } protected: From 02da5c6484ecc478f80617ed956c9337dd42f653 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 22:02:41 -0500 Subject: [PATCH 1671/1815] [ethernet] Remove deprecated get_eth_mac_address_pretty() (#18379) --- esphome/components/ethernet/ethernet_component.h | 3 --- esphome/components/ethernet/ethernet_component_esp32.cpp | 5 ----- esphome/components/ethernet/ethernet_component_rp2.cpp | 5 ----- 3 files changed, 13 deletions(-) diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 2da070b5e0..1482e7a828 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -159,9 +159,6 @@ class EthernetComponent final : public Component { const char *get_use_address() const { return this->use_address_; } void set_use_address(const char *use_address) { this->use_address_ = use_address; } void get_eth_mac_address_raw(uint8_t *mac); - // Remove before 2026.9.0 - ESPDEPRECATED("Use get_eth_mac_address_pretty_into_buffer() instead. Removed in 2026.9.0", "2026.3.0") - std::string get_eth_mac_address_pretty(); const char *get_eth_mac_address_pretty_into_buffer(std::span buf); eth_duplex_t get_duplex_mode(); eth_speed_t get_link_speed(); diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 4af2d5f93c..069478e70c 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -928,11 +928,6 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { ESPHL_ERROR_CHECK(err, "ETH_CMD_G_MAC error"); } -std::string EthernetComponent::get_eth_mac_address_pretty() { - char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - return std::string(this->get_eth_mac_address_pretty_into_buffer(buf)); -} - const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer( std::span buf) { uint8_t mac[MAC_ADDRESS_SIZE]; diff --git a/esphome/components/ethernet/ethernet_component_rp2.cpp b/esphome/components/ethernet/ethernet_component_rp2.cpp index 7f4db4fab7..94d84cc891 100644 --- a/esphome/components/ethernet/ethernet_component_rp2.cpp +++ b/esphome/components/ethernet/ethernet_component_rp2.cpp @@ -249,11 +249,6 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { } } -std::string EthernetComponent::get_eth_mac_address_pretty() { - char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - return std::string(this->get_eth_mac_address_pretty_into_buffer(buf)); -} - const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer( std::span buf) { uint8_t mac[MAC_ADDRESS_SIZE]; From d1f065671eab8fcfd25dae8106beedbcc0499452 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 22:21:04 -0500 Subject: [PATCH 1672/1815] [http_request] Abort OTA backend when update fails before first write (#18581) --- .../http_request/ota/ota_http_request.cpp | 17 +++++++++-------- .../http_request/ota/ota_http_request.h | 3 +-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/esphome/components/http_request/ota/ota_http_request.cpp b/esphome/components/http_request/ota/ota_http_request.cpp index 8893b96c65..7e7594c3c3 100644 --- a/esphome/components/http_request/ota/ota_http_request.cpp +++ b/esphome/components/http_request/ota/ota_http_request.cpp @@ -64,8 +64,9 @@ void OtaHttpRequestComponent::flash() { } } -void OtaHttpRequestComponent::cleanup_(ota::OTABackendPtr backend, const std::shared_ptr &container) { - if (this->update_started_) { +void OtaHttpRequestComponent::cleanup_(ota::OTABackendPtr backend, const std::shared_ptr &container, + bool abort_backend) { + if (abort_backend) { ESP_LOGV(TAG, "Aborting OTA backend"); backend->abort(); } @@ -106,7 +107,8 @@ uint8_t OtaHttpRequestComponent::do_ota_() { auto error_code = backend->begin(container->content_length); if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGW(TAG, "backend->begin error: %d", error_code); - this->cleanup_(std::move(backend), container); + // Nothing to abort: begin() failed, so no OTA handle was opened + this->cleanup_(std::move(backend), container, /*abort_backend=*/false); return error_code; } @@ -140,7 +142,7 @@ uint8_t OtaHttpRequestComponent::do_ota_() { } else { ESP_LOGE(TAG, "Error reading data: %d", bufsize_or_error); } - this->cleanup_(std::move(backend), container); + this->cleanup_(std::move(backend), container, /*abort_backend=*/true); return OTA_CONNECTION_ERROR; } @@ -150,14 +152,13 @@ uint8_t OtaHttpRequestComponent::do_ota_() { md5_receive.add(buf, bufsize_or_error); // write bytes to OTA backend - this->update_started_ = true; error_code = backend->write(buf, bufsize_or_error); if (error_code != ota::OTA_RESPONSE_OK) { // error code explanation available at // https://github.com/esphome/esphome/blob/dev/esphome/components/ota/ota_backend.h ESP_LOGE(TAG, "Error code (%02X) writing binary data to flash at offset %d and size %d", error_code, container->get_bytes_read() - bufsize_or_error, container->content_length); - this->cleanup_(std::move(backend), container); + this->cleanup_(std::move(backend), container, /*abort_backend=*/true); return error_code; } } @@ -181,7 +182,7 @@ uint8_t OtaHttpRequestComponent::do_ota_() { this->md5_computed_ = md5_receive_str; if (strncmp(this->md5_computed_.c_str(), this->md5_expected_.c_str(), MD5_SIZE) != 0) { ESP_LOGE(TAG, "MD5 computed: %s - Aborting due to MD5 mismatch", this->md5_computed_.c_str()); - this->cleanup_(std::move(backend), container); + this->cleanup_(std::move(backend), container, /*abort_backend=*/true); return ota::OTA_RESPONSE_ERROR_MD5_MISMATCH; } else { backend->set_update_md5(md5_receive_str); @@ -197,7 +198,7 @@ uint8_t OtaHttpRequestComponent::do_ota_() { error_code = backend->end(); if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGW(TAG, "Error ending update! error_code: %d", error_code); - this->cleanup_(std::move(backend), container); + this->cleanup_(std::move(backend), container, /*abort_backend=*/true); return error_code; } diff --git a/esphome/components/http_request/ota/ota_http_request.h b/esphome/components/http_request/ota/ota_http_request.h index a706331d9a..9bb748f175 100644 --- a/esphome/components/http_request/ota/ota_http_request.h +++ b/esphome/components/http_request/ota/ota_http_request.h @@ -38,7 +38,7 @@ class OtaHttpRequestComponent final : public ota::OTAComponent, public Parented< void flash(); protected: - void cleanup_(ota::OTABackendPtr backend, const std::shared_ptr &container); + void cleanup_(ota::OTABackendPtr backend, const std::shared_ptr &container, bool abort_backend); uint8_t do_ota_(); std::string get_url_with_auth_(const std::string &url); bool http_get_md5_(); @@ -51,7 +51,6 @@ class OtaHttpRequestComponent final : public ota::OTAComponent, public Parented< std::string username_{}; std::string url_{}; int status_ = -1; - bool update_started_ = false; static const uint16_t HTTP_RECV_BUFFER = 256; // the firmware GET chunk size }; From e697a40fda84887373d1ab3ba77bb3af64da8560 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 09:04:06 -0500 Subject: [PATCH 1673/1815] [core] Register the OTA component in dummy_main like its siblings (#18666) --- tests/dummy_main.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/dummy_main.cpp b/tests/dummy_main.cpp index 6fa0c08aa3..228d54ef01 100644 --- a/tests/dummy_main.cpp +++ b/tests/dummy_main.cpp @@ -29,6 +29,7 @@ void setup() { auto *ota = new esphome::ESPHomeOTAComponent(); // NOLINT ota->set_port(8266); + App.register_component_(ota); App.setup(); } From 33484108a982678208a9619d03e67d691df49f03 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 09:04:24 -0500 Subject: [PATCH 1674/1815] [core] Replace a damaged existing file in write_file_if_changed (#18665) --- esphome/helpers.py | 13 ++++++++++++- tests/unit_tests/test_helpers.py | 25 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/esphome/helpers.py b/esphome/helpers.py index 9b2a461ccd..7aa1a9a88c 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -552,7 +552,18 @@ def write_file_if_changed(path: Path, text: str) -> bool: """ src_content = None if path.is_file(): - src_content = read_file(path) + try: + src_content = path.read_text(encoding="utf-8") + except UnicodeDecodeError as err: + # Replace a damaged file rather than abort the regeneration that + # fixes it; an OSError may hide an intact file, so it still raises + _LOGGER.warning("Replacing damaged file %s: %s", path, err) + with suppress(OSError): + path.unlink(missing_ok=True) + except OSError as err: + from esphome.core import EsphomeError + + raise EsphomeError(f"Error reading file {path}: {err}") from err if src_content == text: return False write_file(path, text) diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 6e00e5b80f..eaa7d5a8dc 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -253,6 +253,31 @@ class Test_write_file_if_changed: assert dst.read_text() == text + def test_damaged_existing_file_is_replaced( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ): + """A non-UTF-8 existing file is logged and overwritten.""" + dst = tmp_path / "generated.txt" + dst.write_bytes(b"\xff\xfe") + + assert helpers.write_file_if_changed(dst, "fresh content") is True + + assert dst.read_text(encoding="utf-8") == "fresh content" + assert "Replacing damaged file" in caplog.text + + def test_unreadable_existing_file_still_raises(self, tmp_path: Path): + """An OSError on the comparison read still raises EsphomeError.""" + dst = tmp_path / "generated.txt" + dst.write_text("intact") + + with ( + patch.object(Path, "read_text", side_effect=OSError("permission denied")), + pytest.raises(EsphomeError, match="Error reading file"), + ): + helpers.write_file_if_changed(dst, "fresh content") + + assert dst.exists() + def test_dst_does_not_exist(self, tmp_path: Path): text = "A files are unique.\n" dst = tmp_path / "file-a.txt" From e7574a574b6d5e2303df20edb73e64474ef23113 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 09:04:48 -0500 Subject: [PATCH 1675/1815] [ota] Restore lazy flash erase for ESP32 OTA with 64 KiB block erase (#18580) --- .../components/esphome/ota/ota_esphome.cpp | 2 +- esphome/components/ota/ota_backend.h | 13 +++ .../components/ota/ota_backend_esp_idf.cpp | 86 +++++++++++++++---- esphome/components/ota/ota_backend_esp_idf.h | 19 +++- .../components/ota/ota_bootloader_esp_idf.cpp | 11 ++- .../components/ota/ota_signature_esp_idf.cpp | 2 +- tests/components/ota/test_erase_ahead.cpp | 41 +++++++++ 7 files changed, 153 insertions(+), 21 deletions(-) create mode 100644 tests/components/ota/test_erase_ahead.cpp diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 9cbb25b373..74f84b71fb 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -398,7 +398,7 @@ void ESPHomeOTAComponent::handle_data_() { this->notify_state_(ota::OTA_STARTED, 0.0f, 0); #endif - // begin() may block for a few seconds while it locks flash. + // begin() returns quickly; flash sectors are erased incrementally during write(). error_code = this->backend_->begin(ota_size, ota_type); if (error_code != ota::OTA_RESPONSE_OK) goto error; // NOLINT(cppcoreguidelines-avoid-goto) diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index aa93df60a5..1c24fc320a 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -66,6 +66,19 @@ enum OTAResponseTypes { */ bool version_is_older(const char *candidate, const char *reference); +// 64 KiB flash block; the erase granularity the ESP-IDF backend erases ahead with. +static constexpr size_t OTA_BLOCK_ERASE_SIZE = 64 * 1024; + +/** Target erased watermark for lazy block erase-ahead. + * + * Rounds the write end offset up to a block boundary, clamped to the partition + * size. Platform-independent so the arithmetic is host-testable. + */ +constexpr size_t next_erase_end(size_t write_end, size_t partition_size) { + const size_t rounded = (write_end + OTA_BLOCK_ERASE_SIZE - 1) & ~(OTA_BLOCK_ERASE_SIZE - 1); + return rounded < partition_size ? rounded : partition_size; +} + enum OTAState { OTA_COMPLETED = 0, OTA_STARTED, diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index eb23ad82dd..f33f37bbeb 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -7,7 +7,7 @@ #include "esphome/core/log.h" #include -#include +#include #include #ifdef USE_OTA_DOWNGRADE_PROTECTION #include @@ -60,27 +60,38 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION; } - // esp_ota_begin() erases the destination region, which blocks loopTask and - // scales with the erase size -- a fixed watchdog overruns on large OTA slots. - // An unknown size (0, e.g. web_server uploads) erases the whole partition, so - // budget against the bytes actually erased. ~10ms/KiB (conservative - // ~100 KiB/s erase) over a 15s floor; panic stays on so a stuck erase still - // resets rather than hanging forever. - size_t erase_size = image_size; - if (erase_size == 0 || erase_size > this->partition_->size) { - erase_size = this->partition_->size; + // Both lazy-erase paths below replace esp_ota_begin()'s blocking full erase. + // Size check replaces the one that erase performed (0 = unknown size, + // e.g. web_server uploads). + if (image_size != 0 && image_size > this->partition_->size) { + return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE; } - const uint32_t erase_budget_ms = 15000 + (erase_size >> 10) * 10; - watchdog::WatchdogManager watchdog(erase_budget_ms); - esp_err_t err = esp_ota_begin(this->partition_, image_size, &this->update_handle_); + this->written_ = 0; + esp_err_t err; +#ifdef USE_OTA_BLOCK_ERASE_AHEAD + this->erased_end_ = 0; + // Unlike esp_ota_begin(), esp_ota_resume() does not reject a running app in + // ESP_OTA_IMG_PENDING_VERIFY; that state is unreachable here because the app + // was marked valid at boot (esp32/hal.cpp) or just above under USE_OTA_ROLLBACK. + // erase_size 0 (!= OTA_WITH_SEQUENTIAL_WRITES) means no erase; erase_ahead_() handles it + err = esp_ota_resume(this->partition_, 0, 0, &this->update_handle_); +#if defined(CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE) && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) + // esp_ota_begin() does this on IDF 5.5+; esp_ota_resume() does not. Prevents + // booting a half-written slot after a crash mid-OTA. Not available on the + // 5.3.3/5.4.2 backports, whose esp_ota_begin() did not invalidate either. + if (err == ESP_OK) { + esp_ota_invalidate_inactive_ota_data_slot(); + } +#endif +#else + err = esp_ota_begin(this->partition_, OTA_WITH_SEQUENTIAL_WRITES, &this->update_handle_); +#endif if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_ota_begin failed (err=0x%X)", err); + ESP_LOGE(TAG, "OTA begin failed (err=0x%X)", err); esp_ota_abort(this->update_handle_); this->update_handle_ = 0; - if (err == ESP_ERR_INVALID_SIZE) { - return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE; - } else if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { + if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { return OTA_RESPONSE_ERROR_WRITING_FLASH; } else if (err == ESP_ERR_OTA_PARTITION_CONFLICT) { // This error appears with 1 factory and 1 ota partition @@ -120,6 +131,17 @@ OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) { if (!this->is_app_or_bootloader_update_()) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } +#endif + // Overflow can only happen on unknown-size uploads (web_server); known + // sizes were rejected in begin(). + if (this->written_ + len > this->partition_->size) { + return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE; + } +#ifdef USE_OTA_BLOCK_ERASE_AHEAD + OTAResponseTypes erase_result = this->erase_ahead_(len); + if (erase_result != OTA_RESPONSE_OK) { + return erase_result; + } #endif esp_err_t err = esp_ota_write(this->update_handle_, data, len); this->md5_.add(data, len); @@ -127,14 +149,40 @@ OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) { ESP_LOGE(TAG, "esp_ota_write failed (err=0x%X)", err); if (err == ESP_ERR_OTA_VALIDATE_FAILED) { return OTA_RESPONSE_ERROR_MAGIC; + } else if (err == ESP_ERR_INVALID_SIZE) { + // Sequential-writes fallback: IDF's lazy erase reports overflow here + return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE; } else if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { return OTA_RESPONSE_ERROR_WRITING_FLASH; } return OTA_RESPONSE_ERROR_UNKNOWN; } + this->written_ += len; return OTA_RESPONSE_OK; } +#ifdef USE_OTA_BLOCK_ERASE_AHEAD +OTAResponseTypes IDFOTABackend::erase_ahead_(size_t len) { + const size_t end = this->written_ + len; + if (this->erased_end_ >= end) { + return OTA_RESPONSE_OK; + } + // Round up to a block boundary, clamped to the partition end; IDF splits the + // range into 64 KiB block erases where aligned, sector erases elsewhere. + const size_t erase_to = next_erase_end(end, this->partition_->size); + // A block erase is one uninterruptible flash op (typically ~150 ms, seconds + // on aged flash) and the transfer loop may not have fed the WDT for ~1s. + watchdog::WatchdogManager watchdog(15000); + esp_err_t err = esp_partition_erase_range(this->partition_, this->erased_end_, erase_to - this->erased_end_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_erase_range failed (err=0x%X)", err); + return err == ESP_ERR_INVALID_SIZE ? OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE : OTA_RESPONSE_ERROR_WRITING_FLASH; + } + this->erased_end_ = erase_to; + return OTA_RESPONSE_OK; +} +#endif + OTAResponseTypes IDFOTABackend::end() { if (this->md5_set_) { this->md5_.calculate(); @@ -226,6 +274,10 @@ void IDFOTABackend::abort() { // or not an update is in flight. esp_ota_abort(this->update_handle_); this->update_handle_ = 0; + this->written_ = 0; +#ifdef USE_OTA_BLOCK_ERASE_AHEAD + this->erased_end_ = 0; +#endif } } // namespace esphome::ota diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 9dffd5429e..c991f896e8 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -5,8 +5,18 @@ #include "esphome/components/md5/md5.h" #include "esphome/core/defines.h" +#include #include +// esp_ota_resume() (IDF 5.4.2+, backported to 5.3.3) provides a no-erase OTA +// handle, letting write() block-erase 64 KiB ahead of the write cursor +// (~4x faster than the per-sector lazy erase of OTA_WITH_SEQUENTIAL_WRITES, +// used as fallback on older IDF). +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 2) || \ + (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 3) && ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 0)) +#define USE_OTA_BLOCK_ERASE_AHEAD +#endif + namespace esphome::ota { #ifdef USE_OTA_PARTITIONS @@ -54,6 +64,9 @@ class IDFOTABackend final { #endif private: +#ifdef USE_OTA_BLOCK_ERASE_AHEAD + OTAResponseTypes erase_ahead_(size_t len); +#endif #ifdef USE_OTA_SIGNED_VERIFICATION_MULTI_KEY // Accept an image signed by any key the running app trusts (up to 3 blocks), // so rotation and backup keys work. Fails closed. Covers app and bootloader. @@ -62,7 +75,11 @@ class IDFOTABackend final { // Keep md5_ first since its digest_ is alignas(32) on DMA-SHA variants; md5_set_ stays last so buf_ packs tightly. md5::MD5Digest md5_{}; esp_ota_handle_t update_handle_{0}; - const esp_partition_t *partition_; + const esp_partition_t *partition_{nullptr}; + size_t written_{0}; // Bytes handed to esp_ota_write() +#ifdef USE_OTA_BLOCK_ERASE_AHEAD + size_t erased_end_{0}; // Erased up to this partition offset; must stay >= written_ +#endif char expected_bin_md5_[32]; bool md5_set_{false}; #ifdef USE_OTA_PARTITIONS diff --git a/esphome/components/ota/ota_bootloader_esp_idf.cpp b/esphome/components/ota/ota_bootloader_esp_idf.cpp index 57b5529350..5a83d92689 100644 --- a/esphome/components/ota/ota_bootloader_esp_idf.cpp +++ b/esphome/components/ota/ota_bootloader_esp_idf.cpp @@ -1,6 +1,7 @@ #ifdef USE_ESP32 #include "ota_backend_esp_idf.h" +#include "esphome/components/watchdog/watchdog.h" #include "esphome/core/defines.h" #ifdef USE_OTA_PARTITIONS @@ -69,12 +70,20 @@ OTAResponseTypes IDFOTABackend::setup_bootloader_staging_() { return OTA_RESPONSE_ERROR_BOOTLOADER_VERIFY; } // Erase full size of the bootloader partition in the staging partition - // to avoid copying old data to the bootloader partition later + // to avoid copying old data to the bootloader partition later. Up to + // ESP_BOOTLOADER_SIZE of blocking erase; widen the WDT for its duration. + watchdog::WatchdogManager watchdog(15000); esp_err_t err = esp_partition_erase_range(this->partition_, 0, this->bootloader_part_->size); if (err != ESP_OK) { ESP_LOGW(TAG, "esp_partition_erase_range failed (err=0x%X)", err); // No critical error, don't return } +#ifdef USE_OTA_BLOCK_ERASE_AHEAD + if (err == ESP_OK) { + // Skip re-erasing the pre-erased staging region in erase_ahead_() + this->erased_end_ = this->bootloader_part_->size; + } +#endif err = esp_ota_set_final_partition(this->update_handle_, this->bootloader_part_, false); if (err != ESP_OK) { esp_ota_abort(this->update_handle_); diff --git a/esphome/components/ota/ota_signature_esp_idf.cpp b/esphome/components/ota/ota_signature_esp_idf.cpp index 71dcc0eb83..501d6ac241 100644 --- a/esphome/components/ota/ota_signature_esp_idf.cpp +++ b/esphome/components/ota/ota_signature_esp_idf.cpp @@ -211,7 +211,7 @@ bool rsa_pss_verify(uint8_t *block, const uint8_t *digest) { bool IDFOTABackend::verify_signed_image_(const esp_partition_t *incoming) { // Verification re-hashes the full image (after esp_ota_end already did one // pass), which can approach the task WDT budget on a large app. Extend it for - // the duration, mirroring the erase budget in begin(). + // the duration, scaled to the image size over a 15 s floor. const uint32_t verify_budget_ms = 15000 + (incoming->size >> 10) * 10; watchdog::WatchdogManager watchdog(verify_budget_ms); diff --git a/tests/components/ota/test_erase_ahead.cpp b/tests/components/ota/test_erase_ahead.cpp new file mode 100644 index 0000000000..f84dd8a85d --- /dev/null +++ b/tests/components/ota/test_erase_ahead.cpp @@ -0,0 +1,41 @@ +// Pins the lazy erase-ahead arithmetic used by the ESP-IDF OTA backend: the +// erased watermark must always cover the write end, stay 64 KiB block-aligned +// until the clamp, and never exceed the partition. + +#include + +#include "esphome/components/ota/ota_backend.h" + +namespace esphome::ota::testing { + +static constexpr size_t BLOCK = 64 * 1024; +static constexpr size_t PART = 1835008; // 0x1C0000, a real app slot size + +TEST(NextEraseEnd, FirstWriteRoundsUpToOneBlock) { EXPECT_EQ(next_erase_end(1024, PART), BLOCK); } + +TEST(NextEraseEnd, ExactBlockBoundaryDoesNotOverErase) { EXPECT_EQ(next_erase_end(BLOCK, PART), BLOCK); } + +TEST(NextEraseEnd, StraddlingWriteCoversNextBlock) { EXPECT_EQ(next_erase_end(BLOCK + 1, PART), 2 * BLOCK); } + +TEST(NextEraseEnd, ClampsToPartitionEnd) { + // Partition sizes are sector multiples but not always block multiples + constexpr size_t part = 27 * BLOCK + 4096; + EXPECT_EQ(next_erase_end(27 * BLOCK + 1, part), part); + EXPECT_EQ(next_erase_end(part, part), part); +} + +// Bootloader staging seeds erased_end_ mid-block (e.g. 0x8000); the target for +// a write past that seed must still cover the write end. +TEST(NextEraseEnd, MidBlockSeedStillCovered) { EXPECT_EQ(next_erase_end(0x8000 + 1024, PART), BLOCK); } + +TEST(NextEraseEnd, SweepAlwaysCoversWriteEndWithinPartition) { + for (size_t end = 1; end <= PART; end += 4093) { + const size_t erased = next_erase_end(end, PART); + ASSERT_GE(erased, end); + ASSERT_LE(erased, PART); + // Block-aligned unless clamped at the partition end + ASSERT_TRUE(erased == PART || erased % BLOCK == 0); + } +} + +} // namespace esphome::ota::testing From cf31c08a5cc0b92c667966bf5abc55e9c0b502e8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 09:05:04 -0500 Subject: [PATCH 1676/1815] [core] Skip copying entity automation and filter sources when unused (#18602) --- esphome/components/binary_sensor/__init__.py | 18 +++++++++ .../components/binary_sensor/automation.cpp | 12 ++++++ esphome/components/esp32/__init__.py | 8 ++++ esphome/components/esp32/gpio.cpp | 7 +++- esphome/components/esp32/gpio.py | 1 + esphome/components/ota/__init__.py | 38 +++++++++---------- esphome/components/sensor/__init__.py | 6 +++ esphome/components/text_sensor/__init__.py | 6 +++ esphome/components/uptime/sensor/__init__.py | 11 ++---- esphome/config_helpers.py | 25 ++++++++++++ esphome/core/defines.h | 3 ++ tests/components/binary_sensor/common.yaml | 16 ++++++++ tests/unit_tests/test_config_helpers.py | 24 ++++++++++++ 13 files changed, 145 insertions(+), 30 deletions(-) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 5800e0bd9e..1ab6f7103f 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -5,6 +5,7 @@ from esphome.automation import Condition, maybe_simple_id import esphome.codegen as cg from esphome.components import mqtt, web_server, zigbee from esphome.components.const import CONF_ON_STATE_CHANGE +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_DELAY, @@ -560,6 +561,11 @@ _CALLBACK_AUTOMATIONS = ( async def _build_binary_sensor_automations(var, config): await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + if config.get(CONF_ON_CLICK) or config.get(CONF_ON_DOUBLE_CLICK): + cg.add_define("USE_BINARY_SENSOR_CLICK_TRIGGER") + if config.get(CONF_ON_MULTI_CLICK): + cg.add_define("USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER") + for conf in config.get(CONF_ON_CLICK, []): trigger = cg.new_Pvariable( conf[CONF_TRIGGER_ID], var, conf[CONF_MIN_LENGTH], conf[CONF_MAX_LENGTH] @@ -673,3 +679,15 @@ async def to_code(config): async def binary_sensor_invalidate_state_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) + + +# automation.cpp only implements the click/double_click/multi_click triggers +FILTER_SOURCE_FILES = filter_source_files_from_defines( + { + "automation.cpp": ( + "USE_BINARY_SENSOR_CLICK_TRIGGER", + "USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER", + ), + "filter.cpp": "USE_BINARY_SENSOR_FILTER", + } +) diff --git a/esphome/components/binary_sensor/automation.cpp b/esphome/components/binary_sensor/automation.cpp index b13e4a88dd..1a3c1f7536 100644 --- a/esphome/components/binary_sensor/automation.cpp +++ b/esphome/components/binary_sensor/automation.cpp @@ -1,8 +1,13 @@ +#include "esphome/core/defines.h" +#if defined(USE_BINARY_SENSOR_CLICK_TRIGGER) || defined(USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER) + #include "automation.h" #include "esphome/core/log.h" namespace esphome::binary_sensor { +#ifdef USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER + static const char *const TAG = "binary_sensor.automation"; // MultiClickTrigger timeout IDs. @@ -120,6 +125,9 @@ void MultiClickTriggerBase::trigger_() { this->trigger(); } +#endif // USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER + +#ifdef USE_BINARY_SENSOR_CLICK_TRIGGER bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length) { if (max_length == 0) { return length >= min_length; @@ -127,4 +135,8 @@ bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length) { return length >= min_length && length <= max_length; } } +#endif // USE_BINARY_SENSOR_CLICK_TRIGGER + } // namespace esphome::binary_sensor + +#endif // USE_BINARY_SENSOR_CLICK_TRIGGER || USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 501c2e525f..cde0cfd68b 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -12,6 +12,7 @@ from typing import Any from esphome import yaml_util import esphome.codegen as cg from esphome.components.const import CONF_ENABLE_OTA_DOWNGRADE_PROTECTION +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_ADVANCED, @@ -3451,3 +3452,10 @@ def process_stacktrace(config, line, backtrace_state): _decode_pc(config, addr.group()) return backtrace_state + + +# gpio.cpp only implements ESP32InternalGPIOPin and its ISR helpers, which +# are instantiated solely by the pin schema codegen (esp32_pin_to_code) +FILTER_SOURCE_FILES = filter_source_files_from_defines( + {"gpio.cpp": "USE_ESP32_INTERNAL_GPIO"} +) diff --git a/esphome/components/esp32/gpio.cpp b/esphome/components/esp32/gpio.cpp index 4b53d3a172..74665f3126 100644 --- a/esphome/components/esp32/gpio.cpp +++ b/esphome/components/esp32/gpio.cpp @@ -1,4 +1,7 @@ -#ifdef USE_ESP32 +#include "esphome/core/defines.h" +// Also defines the core ISRInternalGPIOPin methods; those are only reachable +// via ESP32InternalGPIOPin::to_isr(), so the same define gates both safely. +#if defined(USE_ESP32) && defined(USE_ESP32_INTERNAL_GPIO) #include "gpio.h" #include "esphome/core/log.h" @@ -204,4 +207,4 @@ void IRAM_ATTR ISRInternalGPIOPin::pin_mode(gpio::Flags flags) { } // namespace esphome -#endif // USE_ESP32 +#endif // USE_ESP32 && USE_ESP32_INTERNAL_GPIO diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index 321dd3d498..98aac209ec 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -257,6 +257,7 @@ ESP32_PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(PLATFORM_ESP32, ESP32_PIN_SCHEMA) async def esp32_pin_to_code(config): + cg.add_define("USE_ESP32_INTERNAL_GPIO") var = cg.new_Pvariable(config[CONF_ID]) num = config[CONF_NUMBER] cg.add(var.set_pin(getattr(gpio_num_t, f"GPIO_NUM_{num}"))) diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 5240db9e8f..a2e6953a16 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -1,6 +1,9 @@ from esphome import automation import esphome.codegen as cg -from esphome.config_helpers import filter_source_files_from_platform +from esphome.config_helpers import ( + filter_source_files_from_defines, + filter_source_files_from_platform, +) import esphome.config_validation as cv from esphome.const import ( CONF_ESPHOME, @@ -171,24 +174,17 @@ _filter_backend_source_files = filter_source_files_from_platform( ) +# USE_OTA_SIGNED_VERIFICATION_MULTI_KEY is set only on ESP32/IDF; +# USE_OTA_PARTITIONS is set by the esphome OTA platform when +# allow_partition_access is enabled. +_filter_define_source_files = filter_source_files_from_defines( + { + "ota_signature_esp_idf.cpp": "USE_OTA_SIGNED_VERIFICATION_MULTI_KEY", + "ota_bootloader_esp_idf.cpp": "USE_OTA_PARTITIONS", + "ota_partitions_esp_idf.cpp": "USE_OTA_PARTITIONS", + } +) + + def FILTER_SOURCE_FILES() -> list[str]: - files = _filter_backend_source_files() - # ota_signature_esp_idf.cpp implements multi-key OTA signature verification, - # compiled only when the esp32 component enables it (external RSA signed - # OTA sets USE_OTA_SIGNED_VERIFICATION_MULTI_KEY). The define is set only on - # ESP32/IDF, so this also excludes the file on every other platform. Filter - # it out otherwise so the (otherwise fully #ifdef'd-out) file isn't opened - # and parsed on every build. - if not any( - define.name == "USE_OTA_SIGNED_VERIFICATION_MULTI_KEY" - for define in CORE.defines - ): - files.append("ota_signature_esp_idf.cpp") - # ota_bootloader_esp_idf.cpp and ota_partitions_esp_idf.cpp are fully - # #ifdef'd on USE_OTA_PARTITIONS (set by the esphome OTA platform when - # allow_partition_access is enabled). Filter them out otherwise for the - # same reason as above. - if not any(define.name == "USE_OTA_PARTITIONS" for define in CORE.defines): - files.append("ota_bootloader_esp_idf.cpp") - files.append("ota_partitions_esp_idf.cpp") - return files + return _filter_backend_source_files() + _filter_define_source_files() diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 6ad76046a1..79d4ce5e0c 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -5,6 +5,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import mqtt, web_server, zigbee from esphome.components.const import CONF_B_CONSTANT +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_ABOVE, @@ -1303,3 +1304,8 @@ def _lstsq(a, b): @coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(sensor_ns.using) + + +FILTER_SOURCE_FILES = filter_source_files_from_defines( + {"filter.cpp": "USE_SENSOR_FILTER"} +) diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index a3f4999a8f..29399a51b7 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -1,6 +1,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import mqtt, web_server +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_DEVICE_CLASS, @@ -256,3 +257,8 @@ async def text_sensor_state_to_code(config, condition_id, template_arg, args): templ = await cg.templatable(config[CONF_STATE], args, cg.std_string) cg.add(var.set_state(templ)) return var + + +FILTER_SOURCE_FILES = filter_source_files_from_defines( + {"filter.cpp": "USE_TEXT_SENSOR_FILTER"} +) diff --git a/esphome/components/uptime/sensor/__init__.py b/esphome/components/uptime/sensor/__init__.py index debeb41444..dd76bb5a87 100644 --- a/esphome/components/uptime/sensor/__init__.py +++ b/esphome/components/uptime/sensor/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import sensor, time +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_TIME_ID, @@ -10,7 +11,6 @@ from esphome.const import ( STATE_CLASS_TOTAL_INCREASING, UNIT_SECOND, ) -from esphome.core import CORE uptime_ns = cg.esphome_ns.namespace("uptime") UptimeSecondsSensor = uptime_ns.class_( @@ -62,9 +62,6 @@ async def to_code(config): cg.add(var.set_time(time_id)) -def FILTER_SOURCE_FILES() -> list[str]: - # uptime_timestamp_sensor.cpp is fully #ifdef'd on USE_TIME; skip it - # when no time component is configured. - if not any(define.name == "USE_TIME" for define in CORE.defines): - return ["uptime_timestamp_sensor.cpp"] - return [] +FILTER_SOURCE_FILES = filter_source_files_from_defines( + {"uptime_timestamp_sensor.cpp": "USE_TIME"} +) diff --git a/esphome/config_helpers.py b/esphome/config_helpers.py index c82c2b3dbe..60bed1537e 100644 --- a/esphome/config_helpers.py +++ b/esphome/config_helpers.py @@ -151,6 +151,31 @@ def filter_source_files_from_platform( return filter_source_files +def filter_source_files_from_defines( + files_map: dict[str, str | tuple[str, ...]], +) -> Callable[[], list[str]]: + """Helper to build a FILTER_SOURCE_FILES function from a define mapping. + + Args: + files_map: Dict mapping filename to the define name (or tuple of + define names) that keeps the file in the build; the file is + excluded when none of its defines is set for the current config. + + Returns: + Function that returns the files to exclude for the current config. + """ + + def filter_source_files() -> list[str]: + defines = {define.name for define in CORE.defines} + return [ + filename + for filename, needed in files_map.items() + if defines.isdisjoint((needed,) if isinstance(needed, str) else needed) + ] + + return filter_source_files + + def get_logger_level() -> str: """Get the configured logger level. diff --git a/esphome/core/defines.h b/esphome/core/defines.h index bb4960aec7..20aca3776f 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -43,7 +43,9 @@ #define USE_ALARM_CONTROL_PANEL #define USE_AREAS #define USE_BINARY_SENSOR +#define USE_BINARY_SENSOR_CLICK_TRIGGER #define USE_BINARY_SENSOR_FILTER +#define USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER #define USE_BLE_DEVICE_IRK #define USE_BUTTON #define USE_CAMERA @@ -281,6 +283,7 @@ // ESP32-specific feature flags #ifdef USE_ESP32 #define USE_ESP32_CRASH_HANDLER +#define USE_ESP32_INTERNAL_GPIO #define USE_MQTT_IDF_ENQUEUE #define USE_ESPHOME_TASK_LOG_BUFFER #define ESPHOME_TASK_LOG_BUFFER_SIZE 768 diff --git a/tests/components/binary_sensor/common.yaml b/tests/components/binary_sensor/common.yaml index 4f4cf6ea59..d0a16cc99c 100644 --- a/tests/components/binary_sensor/common.yaml +++ b/tests/components/binary_sensor/common.yaml @@ -136,3 +136,19 @@ binary_sensor: invalid_cooldown: 2s then: - logger.log: "Click with custom cooldown" + + # Test on_click and on_double_click (compiles match_interval via + # USE_BINARY_SENSOR_CLICK_TRIGGER) + - platform: template + id: click_triggers + name: "Click Triggers" + on_click: + min_length: 50ms + max_length: 350ms + then: + - logger.log: "Clicked" + on_double_click: + min_length: 50ms + max_length: 350ms + then: + - logger.log: "Double clicked" diff --git a/tests/unit_tests/test_config_helpers.py b/tests/unit_tests/test_config_helpers.py index 88913c0f23..e53016dfc3 100644 --- a/tests/unit_tests/test_config_helpers.py +++ b/tests/unit_tests/test_config_helpers.py @@ -6,6 +6,7 @@ from unittest.mock import patch import pytest from esphome.config_helpers import ( + filter_source_files_from_defines, filter_source_files_from_platform, frameworks_for_platforms, get_logger_level, @@ -18,6 +19,7 @@ from esphome.const import ( KEY_TARGET_PLATFORM, PlatformFramework, ) +from esphome.core import Define def test_filter_source_files_from_platform_esp32() -> None: @@ -148,3 +150,25 @@ def test_frameworks_for_platforms_derives_and_rejects_unknown() -> None: } with pytest.raises(ValueError, match="unknown platform"): frameworks_for_platforms(["esp32", "not_a_platform"]) + + +def test_filter_source_files_from_defines() -> None: + """Files are excluded unless one of their defines is set.""" + files_map: dict[str, str | tuple[str, ...]] = { + "filter.cpp": "USE_SENSOR_FILTER", + "automation.cpp": ("USE_CLICK", "USE_MULTI_CLICK"), + } + filter_func: Callable[[], list[str]] = filter_source_files_from_defines(files_map) + + with patch("esphome.config_helpers.CORE") as mock_core: + mock_core.defines = {Define("USE_SENSOR_FILTER")} + assert filter_func() == ["automation.cpp"] + + mock_core.defines = {Define("USE_MULTI_CLICK")} + assert filter_func() == ["filter.cpp"] + + mock_core.defines = {Define("USE_SENSOR_FILTER"), Define("USE_CLICK")} + assert filter_func() == [] + + mock_core.defines = set() + assert sorted(filter_func()) == ["automation.cpp", "filter.cpp"] From f741c274d577f748afc31b9156b1daecca9392cc Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:55:42 +0000 Subject: [PATCH 1677/1815] Bump aioesphomeapi from 45.13.1 to 46.0.0 (#18683) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3362e43239..822eebc1f2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.13.1 +aioesphomeapi==46.0.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From 4efd30834575606ffc878549d7c4626c79e5eba4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 11:26:55 -0500 Subject: [PATCH 1678/1815] [tests] Fix flaky pty log probe test on macOS (#18681) --- tests/unit_tests/test_log.py | 48 +++++++++++++++++------------------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/tests/unit_tests/test_log.py b/tests/unit_tests/test_log.py index 194b38209b..40e3aa6d22 100644 --- a/tests/unit_tests/test_log.py +++ b/tests/unit_tests/test_log.py @@ -1,5 +1,4 @@ from collections.abc import Generator -import errno import io import logging import os @@ -178,37 +177,34 @@ def _run_probe_on_pty( output = b"" deadline = time.monotonic() + 60 try: - try: - proc = subprocess.Popen( - _probe_command(fixture_path), - stdout=follower, - stderr=follower if stderr_to_pty else subprocess.PIPE, - stdin=follower, - env=probe_env, - ) - finally: - os.close(follower) - while True: - timeout = deadline - time.monotonic() - if timeout <= 0 or not select.select([controller], [], [], timeout)[0]: - pytest.fail(f"pty probe produced no EOF in time; got {output!r}") - try: - chunk = os.read(controller, 1024) - except OSError as err: - # macOS raises EIO once the child closes its end of the pty; - # anything else is a real failure, not end-of-stream. - if err.errno != errno.EIO: - raise - break - if not chunk: - break + proc = subprocess.Popen( + _probe_command(fixture_path), + stdout=follower, + stderr=follower if stderr_to_pty else subprocess.PIPE, + stdin=follower, + env=probe_env, + ) + # The parent keeps the follower open until the child has exited and + # the controller is drained: macOS discards buffered pty output once + # the last follower closes, so closing it early loses the probe's + # output whenever the child finishes before the first read. + while proc.poll() is None: + if time.monotonic() > deadline: + pytest.fail(f"pty probe did not exit in time; got {output!r}") + if select.select([controller], [], [], 0.01)[0]: + output += os.read(controller, 4096) + # Everything the child wrote is already buffered, so drain without waiting. + while select.select([controller], [], [], 0)[0] and ( + chunk := os.read(controller, 4096) + ): output += chunk stderr_text = "" if proc.stderr is not None: stderr_text = proc.stderr.read().decode(errors="replace") proc.stderr.close() - assert proc.wait(60) == 0, stderr_text + assert proc.returncode == 0, stderr_text finally: + os.close(follower) os.close(controller) if proc is not None and proc.poll() is None: proc.kill() From dde6906f980ecff7dedf08f289edf97b6e9ef5ba Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 23 Aug 2026 11:01:34 -0700 Subject: [PATCH 1679/1815] [modbus_client] Add continuous option to the read and send actions (#18542) Co-authored-by: Claude --- esphome/components/modbus/__init__.py | 79 ++++++++++++++++++- esphome/components/modbus/modbus.cpp | 23 +++--- esphome/components/modbus/modbus.h | 48 +++++++---- esphome/components/modbus_client/__init__.py | 70 +++++++++++++--- .../components/modbus_client/modbus_client.h | 40 ++++++++-- .../modbus_client/test_modbus_client.py | 31 +++++++- .../modbus/modbus_client_hub_test.cpp | 40 +++++----- tests/components/modbus_client/common.yaml | 5 ++ 8 files changed, 261 insertions(+), 75 deletions(-) diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index a98591c6bc..89ffc7facf 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -1,17 +1,23 @@ from __future__ import annotations import logging -from typing import Any, Literal +from typing import Any, Literal, NamedTuple from esphome import pins import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ADDRESS, CONF_DISABLE_CRC, CONF_FLOW_CONTROL_PIN, CONF_ID +from esphome.const import ( + CONF_ADDRESS, + CONF_CONTINUOUS, + CONF_DISABLE_CRC, + CONF_FLOW_CONTROL_PIN, + CONF_ID, +) from esphome.cpp_generator import MockObj from esphome.cpp_helpers import gpio_pin_expression import esphome.final_validate as fv -from esphome.types import ConfigType +from esphome.types import ConfigType, TemplateArgsType _LOGGER = logging.getLogger(__name__) @@ -48,6 +54,73 @@ CONF_TURNAROUND_TIME = "turnaround_time" MODBUS_ROLES = ["client", "server"] + +class _CommandOption(NamedTuple): + """One per-command option forwarded to the hub (modbus::CommandOptions).""" + + conf_key: str + field: str # the C++ field, and so the set_() setter name + validator: Any # the static (non-templatable) validator for the key + cpp_type: Any # the C++ type the value is generated as + default: Any + + +# Per-direction command options. Single-sourcing the schema and the setter generation here keeps +# them from drifting; the C++ side must add the matching field per the rules documented on +# CommandOptions (modbus.h). +_COMMAND_OPTIONS: dict[str, list[_CommandOption]] = { + "read": [_CommandOption(CONF_CONTINUOUS, "continuous", cv.boolean, bool, False)], + "write": [], +} + + +def _command_options(direction: str) -> list[_CommandOption]: + try: + return _COMMAND_OPTIONS[direction] + except KeyError: + raise ValueError(f"unknown command-options direction {direction!r}") from None + + +def command_options_schema( + *, direction: Literal["read", "write"], templatable: bool = False +) -> dict[cv.Optional, Any]: + """Schema fragment for the per-command options a component forwards to the hub + (modbus::CommandOptions). Extend this into any schema that queues commands. Keys are + direction-specific so a schema never offers an option the hub would strip (e.g. + continuous on a write); the write side has no options yet. For actions (templatable=True the + keys also accept lambdas), register the values with register_templatable_command_options(). + """ + return { + cv.Optional(option.conf_key, default=option.default): ( + cv.templatable(option.validator) if templatable else option.validator + ) + for option in _command_options(direction) + } + + +async def register_templatable_command_options( + var: MockObj, config: ConfigType, args: TemplateArgsType, direction: str +) -> None: + """Generate the set_